TypeScript也能用Go和Rust的错误处理方式?不要try/catch也能行?这也太叛逆了吧
Rust、TypeScript和Go的标志组合图像 | 图片由作者制作
所以,让我们从一点我的背景说起。我是一名软件开发者,大约有十年的工作经验,最初是做PHP开发,然后慢慢转向了JavaScript。
大概五年前我开始使用TypeScript,从那以后我就再也没有回头用过JavaScript。一开始使用它的时候,我觉得它就是最好的编程语言。大家都爱用它……它就是最好的,对吧?对吧?对吧?
嗯,然后我开始尝试玩弄其他更现代的语言,首先是 Go。我慢慢加入了 Rust(感谢 Prime 社区)。
当你不知道有些东西存在时,很难注意到它们。
我在谈论什么?Go 和 Rust 有什么共同点?最让我印象深刻的是错误处理。更具体地讲,它们是如何处理错误的。
JavaScript 依赖抛出异常来处理错误的方式,而 Go 和 Rust 则将错误当作值来处理。你可能会认为这没那么严重……但别小看了它,这其实是个重大变革。
咱们一个个来看这些语言。我们不会对每一种语言进行深入研究;我们只想了解一个大概的方法。
我们从JavaScript/TypeScript开始吧,玩个小游戏。
给自己五秒钟查看下面的代码,并说说为什么我们需要用 try/catch 来包裹它。
try {
const request = { name: "test", value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: "POST",
body,
});
if (!response.ok) {
return;
}
// 处理响应数据
} catch (e) {
// 处理异常
return;
}
所以,我想你们大多数人猜到这一点了,即使我们在检查 response.ok
,fetch
方法仍然可能抛出错误,因为 response.ok
只能捕获 4xx 和 5xx 的网络错误。但如果网络真的出了问题,会抛出一个错误。
但我不知道有多少人猜到过 JSON.stringify
也会抛出错误。原因是请求中的对象包含这样的 bigint (2n)
,因为 JSON 不知道如何将其字符串化。
所以,第一个问题也是我个人认为最大的JavaScript问题:我们不知道哪些代码会抛出错误。从JavaScript错误的角度来看,情况就好比这样。
try {
let data = "你好";
} catch (err) {
console.error(err);
}
JavaScript不在乎也不知道。不过你应该知道。
第二点,这是完全可以运行的代码段。
const request = { name: "test", value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: "POST",
body,
});
if (!response.ok) {
return;
}
没有错误,也没有代码规范检查工具的警告,但这样可能会让应用程序崩溃。
现在,我脑海里响起,“出了什么问题呢?到处用 try/catch 不就得了。”这里就是第三个问题:我们不知道是哪个抛出的。当然,我们可以通过错误信息来猜测,但对于这些服务或函数,有很多可能出错的地方,你能确定一个 try/catch 能正确处理所有情况吗?
好的,现在是时候停止吐槽JS了,转向其他语言。我们从这段Go代码开始吧。
f, err := os.Open("filename.ext 文件")
if err != nil {
log.Fatal(err)
}
// 可以对打开的文件 f 进行一些操作
我们正在尝试打开一个会返回文件或错误的函数。你经常会看到这种情况,特别是当我们知道某些函数总是会返回错误时。绝对不会错过这种情况。这是将错误当作值来处理的第一个例子。你指定哪些函数可以返回它们,然后返回、赋值并检查它们,最后使用它们。
它也不够亮眼,这也让Go语言备受批评——繁琐的错误检查代码,比如 if err != nil { ...
有时甚至比其他部分的代码行数还要多。
如果 err 不是 nil {
// 添加适当的代码注释或解释
如果 err 不是 nil {
// 添加适当的代码注释或解释
如果 err 不是 nil {
// 添加适当的代码注释或解释
}
}
}
如果 err 不是 nil {
// 添加适当的代码注释或解释
}
// 添加适当的代码注释或解释
如果 err 不是 nil {
// 添加适当的代码注释或解释
}
绝对值得,相信我,真的。
最后来谈谈,Rust:
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("打开文件时出错: {:?}", error),
};
在这三种展示的语言中,Rust 的代码虽然最啰嗦,但却可能是最好的。首先,Rust 使用其出色的枚举类型来处理错误(这与 TypeScript 的枚举类型不一样哦!)。简单点说,它使用一个名为 Result
的枚举,包含两个变体:Ok
和 Err
。就像你猜的一样,Ok
包含一个值,而 Err
吗?就是一个错误哦 :D。
它也有很多方法可以更方便地处理这些问题,以缓解Go问题。最著名的是?
运算符。
let greeting_file_result = File::open("hello.txt"); // 尝试打开文件
这里的关键点是,Go 和 Rust 都能预知可能出错的地点,并且会让你在错误出现的地方立即处理它。没有隐藏的错误,无需猜测,不会因意外导致应用程序崩溃。
这种方法好太多了,简直不是一点两点的好。
好的,现在要诚实一点;我说了点谎。我们不能让 TypeScript 的错误像 Go 或 Rust 那样处理。这里的限制因素就是语言本身,它缺乏相应的工具来实现这一点。
但我们能做的就是尽量让它相似一些,并尽量简单化。
就这样开始。
export type 安全的<T> =
| {
成功标志: true;
数据值: T;
}
| {
成功标志: false;
错误信息: string;
};
这里没啥特别的,只是一个简单的泛型类型。但这个小家伙完全可以改变代码。你可能已经注意到,这里最大的不同就是我们要么返回数据,要么返回错误信息。听起来熟悉吧?
还有……第二个说法是,确实需要几个 try-catch 结构。好在只需要大约两个,而不是十万左右。
export function safe<T>(promise: Promise<T>, err?: string): Promise<Safe<T>>;
export function safe<T>(func: () => T, err?: string): Safe<T>;
export function safe<T>(
promiseOrFunc: Promise<T> | (() => T),
err?: string,
): Promise<Safe<T>> | Safe<T> {
if (promiseOrFunc instanceof Promise) {
return safeAsync(promiseOrFunc, err);
}
return safeSync(promiseOrFunc, err);
}
async function safeAsync<T>(
promise: Promise<T>,
err?: string
): Promise<Safe<T>> {
try {
const data = await promise;
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "出了点问题" };
}
}
function safeSync<T>(
func: () => T,
err?: string
): Safe<T> {
try {
const data = func();
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "出了点问题" };
}
}
“哇,真聪明。他为 try/catch 实现了一个包装器。”没错,你说得对,这只是一个以 Safe
类型作为返回类型的包装器。但有时候简单的解决方案就足够了。让我们结合上面提到的例子来使用它。
老版本(16行代码):
try {
const request = { name: "test", value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: "POST",
body,
});
if (!response.ok) {
// 网络出错时处理
return;
}
// 处理返回
} catch (e) {
// 异常处理
return;
}
新内容(20行):
const request = { name: 'test', value: 2n };
const body = safe(
() => JSON.stringify(request),
'未序列化请求失败',
);
if (!body.success) {
// 处理错误 ({body.error})
return;
}
const response = await safe(
fetch("https://example.com", {
method: 'POST',
body: body.data,
}),
);
if (!response.success) {
// 处理错误 ({response.error})
return;
}
if (!response.data.ok) {
// 处理网络错误:{response.data.ok}
return;
}
// 处理响应:{response.data}
虽然我们的新解决方案更长,但是因为有以下几个原因表现更好。
- 不使用 try/catch
- 我们直接在每个出错的地方处理错误
- 我们可以针对特定函数设置错误信息
- 我们的逻辑是自上而下的,清晰明了,所有的错误信息都放在最上面,然后才是响应部分
但现在来了王牌。如果我们忘了检查这一项会怎样?
if (!body.success) {
// Handle error (body.error)
return;
}
问题是……我们不能这么干。是的,我们必须进行那个检查。如果我们不验证,body.data
就不会有值。LSP 会抛出“Property ‘data’ does not exist on type ‘Safe<string>’”的错误来提醒我们。这多亏了我们创建的那个简单的 Safe
类型。对于错误信息也是一样的情况,我们无法读取 body.error
,除非我们先确认 !body.success
。
我们应当欣赏一下TypeScript,看看它是如何改变了JavaScript世界的。
以下也是一样。
if (!response.success) {
// 处理错误信息(响应错误)
return;
}
我们不能去掉 !response.success
的检查,否则 response.data
就可能不存在。
当然,我们的解决方案也有一些问题。最大的问题是您必须记得用我们的safe
包装器包裹那些可能会引发错误的Promise或函数。然而,这个“我们需要知道”的限制是语言上的局限性,我们无法克服。
听起来这可能很难,但实际上并不难。很快你就会发现,你代码里的几乎所有 Promise 都可能抛出错误,而那些可能抛出错误的同步函数你也熟悉,这样的同步函数其实不多。
不过你可能会问,这样做真的值得吗?我们认为值得。它在我们团队中运作得非常顺利 :). 当你看更大的服务文件,里面没有 try/catches,每个错误都在其出现的地方得到妥善处理,逻辑流程也很流畅……这看起来真的很棒。
这里有一个真实的 SvelteKit FormAction
示例,比如:
export const actions = {
createEmail: async ({ locals, request }) => {
const end = perf("CreateEmail");
const form = await safe(request.formData());
if (!form.success) {
return fail(400, { error: form.error });
}
const schema = z
.object({
emailTo: z.string().email(),
emailName: z.string().min(1),
emailSubject: z.string().min(1),
emailHtml: z.string().min(1),
})
.safeParse({
emailTo: form.data.get("emailTo"),
emailName: form.data.get("emailName"),
emailSubject: form.data.get("emailSubject"),
emailHtml: form.data.get("emailHtml"),
});
if (!schema.success) {
console.error(schema.error.flatten());
return fail(400, { form: schema.error.flatten().fieldErrors });
}
const metadata = createMetadata(URI_GRPC, locals.user.key)
if (!metadata.success) {
return fail(400, { error: metadata.error });
}
const response = await new Promise<Safe<Email__Output>>((res) => {
usersClient.createEmail(schema.data, metadata.data, grpcSafe(res));
});
if (!response.success) {
return fail(400, { error: response.error });
}
end();
return {
email: response.data,
};
},
} satisfies Actions;
这里有几个需要注意的地方:
- 我们用自定义函数
grpcSafe
来帮助处理 gGRPC 回调。 createMetadata
返回Safe
,所以不用再额外处理它。zod
库也采用了同样的方法 :) 如果我们不检查schema.success
,就无法获取schema.data
。
看起来挺干净的,试试看吧!也许它也非常适合你哦 :)
谢谢您的阅读。
附:看起来差不多?
f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// 可以对打开的文件 f 做点事情
const response = await safe(fetch("https://example.com"));
if (!response.success) {
console.error(response.error);
return;
}
// 可以在这里处理 response.data
好的,结束了。
希望你玩得开心!
一如既往地,稍微自我夸奖一下 :)
我也是GoFast的创始人之一,这是构建现代应用程序的终极基础,利用了Golang和SvelteKit/Next.js的强大之处。它也完全利用了这里提到的所有功能。有兴趣的话,欢迎加入我们!
共同学习,写下你的评论
评论加载中...
作者其他优质文章