最近,我们团队,把一个,新项目,从JavaScript,迁移到了TypeScript。
本来,以为,是,一次,简单的,迁移。毕竟,TypeScript,是,JavaScript的,超集,只是,加了,类型系统。把.js文件,改成.ts文件,加上,类型定义,应该,就,差不多了。
没想到,这次,迁移,却,遇到了,一个,惊心动魄的,故障。上线后,发现,部分用户,页面,白屏,无法使用。我们,紧急,回滚,然后,排查了,整整,一夜,才,找到,原因。
今天,把,这次,故障,详细,记录下来,做个复盘。希望,能给,正在,使用,或者,准备,使用TypeScript的,朋友,带来,一些,启发。
一、故障发生
事情,是,这样的。
我们,这个,项目,是,一个,面向C端的,Web应用。用户量,不算,特别大,但是,也,有,几十万,日活。
之前,一直,用的,是,JavaScript,加上,React。最近,团队,决定,新项目,统一,用,TypeScript,来,开发。因为,TypeScript的,类型系统,能,在,编译时,发现,很多,错误,提高,代码质量,也,方便,后期,维护。
这个,项目,是,我们,第一个,用TypeScript的,项目。大家,都是,刚开始,用TypeScript,边学,边用。
开发过程,还算,顺利。虽然,有时候,会,被,类型报错,搞得,有点,烦躁,但是,总体,来说,TypeScript,确实,帮我们,发现了,一些,潜在的,bug。
测试环境,也,测了,好几轮,没发现,什么,大问题。
于是,我们,就,安排了,上线。
上线,是,在,周四的,晚上,十点,流量低峰期。按照,惯例,先,灰度,5%的,用户,观察,半小时,没问题,再,逐步,扩大,灰度比例。
灰度,5%,观察了,半小时,监控,看起来,一切,正常。错误率,没有,明显,上升,页面加载时间,也,正常。
于是,我们,就,把,灰度比例,扩大到了,20%,然后,50%,最后,100%。
全部,灰度,完成,已经,晚上,十一点多了。我们,又,观察了,一会儿,确认,没问题,就,准备,下班了。
就在,我们,准备,走人的时候,客服,突然,在群里,说,有,用户,反馈,页面,白屏,打不开。
我们,心里,咯噔一下。赶紧,看监控。
果然,错误率,开始,上升了。虽然,上升的,幅度,不算,特别大,但是,确实,比,平时,高了,不少。
而且,更,奇怪的是,错误率,不是,一上线,就,高的。而是,过了,一段时间,才,开始,上升。
我们,赶紧,先,回滚,把,流量,切回,旧版本。
回滚后,错误率,很快,就,降下来了。
但是,问题,到底,出在哪?我们,必须,查清楚。否则,下次,上线,还会,出问题。
于是,我们,几个人,留下来,开始,排查问题。
这,一查,就是,整整,一夜。
二、排查过程
第一步:看错误日志
排查问题,第一步,当然,是,看错误日志。
我们,去,日志平台,搜,相关的,错误。
很快,就,找到了,错误信息。错误,是,在,一个,工具函数,里,报的。错误信息,是:"Cannot read property 'map' of undefined"。
也就是说,有一个,变量,我们,以为,它,是,一个,数组,调用了,它的,map方法。但是,实际上,它,是,undefined,所以,调用map,就,报错了。
这个,错误,看起来,很普通,就是,一个,空指针,的问题。
但是,奇怪的是,这个,工具函数,我们,在,测试环境,测了,很多次,都,没报错。为什么,到了,生产环境,就,报错了呢?
而且,更,奇怪的是,不是,所有用户,都,报错。只有,部分用户,报错。
我们,看了一下,报错的,用户,发现,这些用户,有一个,共同点:他们,都是,老用户,而且,账号,注册时间,比较早。
新用户,反而,没有,报错。
这,就,更,奇怪了。为什么,老用户,报错,新用户,不报错?
第二步:分析代码
我们,找到,那个,报错的,工具函数。
这个,函数,的作用,是,把,后端,返回的,用户数据,做一下,转换,适配,前端的,组件。
代码,大概,是,这样的:
interface UserData {
id: number;
name: string;
hobbies: string[];
// ... 其他字段
}
function transformUserData(data: UserData): TransformedUserData {
return {
id: data.id,
name: data.name,
hobbies: data.hobbies.map(hobby => hobby.toUpperCase()),
// ... 其他转换
};
}看起来,没问题啊。data.hobbies,类型,是,string[],是,一个,数组。调用map,应该,没问题。
但是,等等。TypeScript的,类型,只是,编译时的,类型。运行时,类型,是,不存在的。
也就是说,我们,在,代码里,写了,data.hobbies,是,string[]。但是,这,只是,我们,的,一个,假设。如果,后端,返回的数据,hobbies字段,不存在,或者,不是,数组,那么,运行时,还是,会,出问题。
但是,为什么,测试环境,没问题,生产环境,有问题?为什么,新用户,没问题,老用户,有问题?
我们,去,查了,后端的,接口,返回的数据。
发现,后端,这个,接口,最近,做了,一次,升级。加了,hobbies,这个,字段。
但是,这个,字段,是,后加的。对于,新注册的用户,注册的时候,就,会,初始化,hobbies字段,是,一个,空数组。
但是,对于,老用户,他们,注册的时候,还,没有,hobbies,这个,字段。后端,虽然,加了,这个,字段,但是,没有,做,数据迁移,给,老用户,补上,这个,字段。
所以,老用户的,数据里,hobbies字段,是,undefined。
而,新用户的,数据里,hobbies字段,是,一个,空数组。
这,就,解释了,为什么,老用户,报错,新用户,不报错。
但是,还有,一个,问题:为什么,测试环境,没测出来?
因为,我们,测试的时候,用的,都是,测试账号,都是,新注册的。所以,hobbies字段,都,存在,是,空数组。自然,不会,报错。
我们,没有,用,老用户的,账号,去,测试。所以,就,漏掉了,这个,问题。
第三步:为什么,TypeScript,没发现?
找到了,问题的,直接原因。但是,我们,还有,一个,疑问:为什么,TypeScript,没发现,这个,问题?
我们,不是,用了,TypeScript吗?类型系统,不是,应该,帮我们,发现,这种,问题吗?
我们,仔细,看了,代码,发现了,问题所在。
我们,在,调用,后端接口,的时候,是,这样,写的:
async function getUserData(id: number): Promise<UserData> {
const response = await fetch(`/api/user/${id}`);
const data = await response.json();
return data as UserData;
}看到,问题了吗?
我们,把,后端返回的,data,用,as UserData,强制,断言成了,UserData类型。
但是,TypeScript的,类型断言,只是,告诉,编译器,"相信我,这个,数据,就是,UserData类型"。编译器,不会,做,任何,运行时的,检查。
也就是说,这个,类型断言,只是,一个,"谎言"。我们,骗了,编译器,也,骗了,自己。
我们,以为,后端返回的,数据,一定,符合,UserData的,类型定义。但是,实际上,后端返回的,数据,可能,缺字段,可能,字段类型,不对。
而,TypeScript,因为,我们,用了,类型断言,就,不会,再,检查,这个,数据,到底,是不是,真的,符合,类型。
所以,这个,问题,TypeScript,在,编译时,是,发现不了的。
这,就是,问题的,根本原因:我们,滥用了,类型断言,以为,有了,类型定义,就,万事大吉了。但是,实际上,类型定义,只是,编译时的,保障,运行时,数据,可能,和,类型定义,不一致。
三、解决方案
找到,原因,之后,我们,就,开始,想,解决方案。
方案一:后端,做数据迁移
最简单的,方案,就是,让,后端,做,数据迁移,给,所有,老用户,补上,hobbies字段,初始化为,空数组。
这样,所有用户,的数据,都,有,hobbies字段,就,不会,报错了。
但是,这个,方案,有,几个,问题:
- 数据迁移,需要,时间,而且,有风险。
- 以后,再加,新字段,还是,会,遇到,同样的,问题。
- 不能,保证,后端,以后,返回的,数据,一定,符合,类型定义。
所以,这个,方案,只是,治标,不治本。
方案二:前端,做运行时检查
另一个,方案,是,前端,在,拿到,后端数据,之后,做,运行时的,检查,确保,数据,符合,类型定义。
比如,我们,可以,写一个,函数,检查,UserData的,每个字段,是否,存在,类型,是否,正确。如果,有,缺失,或者,类型不对,就,给,一个,默认值。
function validateUserData(data: any): UserData {
return {
id: typeof data.id === 'number' ? data.id : 0,
name: typeof data.name === 'string' ? data.name : '',
hobbies: Array.isArray(data.hobbies) ? data.hobbies : [],
// ... 其他字段
};
}然后,在,拿到,后端数据,之后,调用,这个,函数,做,校验,和,转换。
async function getUserData(id: number): Promise<UserData> {
const response = await fetch(`/api/user/${id}`);
const data = await response.json();
return validateUserData(data);
}这样,就,能,保证,运行时,数据,一定,符合,类型定义。即使,后端,返回的,数据,有问题,前端,也,能,容错,不会,白屏。
这个,方案,比较,稳妥,但是,写起来,比较,繁琐。每个,接口,都,要,写,一个,校验函数。
方案三:使用,类型校验库
为了,简化,运行时,类型校验,我们,可以,使用,一些,现成的,类型校验库,比如,io-ts,zod,yup,等。
这些,库,可以,让你,定义,运行时的,类型schema,然后,自动,做,校验,和,转换。
比如,用io-ts:
import * as t from 'io-ts';
const UserDataSchema = t.type({
id: t.number,
name: t.string,
hobbies: t.array(t.string),
// ... 其他字段
});
type UserData = t.TypeOf<typeof UserDataSchema>;
async function getUserData(id: number): Promise<UserData> {
const response = await fetch(`/api/user/${id}`);
const data = await response.json();
const result = UserDataSchema.decode(data);
if (result.isRight()) {
return result.value;
} else {
// 校验失败,处理错误,或者,返回默认值
throw new Error('Invalid user data');
}
}这样,就,不用,手动,写,校验函数了。而且,类型定义,和,运行时校验,是,一致的,不会,出现,不一致的,情况。
我们,最后,选择了,方案三,用了,io-ts,来,做,运行时的,类型校验。
四、经验教训
这次,故障,给,我们,上了,深刻的,一课。总结一下,经验教训:
1. TypeScript的类型,只是,编译时的,不是,运行时的
这是,最,重要的,一点。很多,刚,接触TypeScript的,人,都会,误以为,有了,类型定义,就,万事大吉了,运行时,数据,一定,符合,类型。
但是,实际上,TypeScript的,类型,只是,编译时的,检查。编译,完成,之后,类型,就,被,擦除了。运行时,没有,类型,的,概念。
所以,对于,外部,来的,数据,比如,后端接口,返回的,数据,用户,输入的,数据,localStorage,里的,数据,等等,一定,要,做,运行时的,校验。不要,盲目,相信,类型定义。
2. 不要,滥用,类型断言
类型断言(as),是,一个,很,强大的,工具,但是,也,很,危险。
用,类型断言,相当于,告诉,编译器,"别检查了,相信我"。但是,如果你,错了,编译器,不会,帮你,发现,错误。
所以,尽量,不要,用,类型断言。如果,一定要,用,也要,确保,你,真的,知道,数据的,类型。
对于,外部数据,不要,直接,用,类型断言,断言成,某个,类型。应该,先,做,运行时,校验,再,使用。
3. 测试,要,覆盖,各种,边界情况
这次,故障,之所以,没在,测试环境,发现,是因为,我们,测试的时候,只用了,新用户的,账号,没有,用,老用户的,账号。
所以,测试的时候,一定要,覆盖,各种,边界情况。比如,老用户,新用户,有数据的,没数据的,数据完整的,数据不完整的,等等。
不要,只,测,正常的,情况。异常的,情况,边界的,情况,更,要,测。
4. 灰度发布,要,有,足够的,观察时间
这次,故障,不是,一上线,就,出现的。而是,过了,一段时间,才,出现。
因为,报错的,是,老用户。而,灰度,刚开始,流量,小,可能,刚好,没怎么,命中,老用户。后来,流量,大了,才,命中,老用户,报错,才,显现出来。
所以,灰度发布,不要,太,急躁。每个,灰度比例,都,要,有,足够的,观察时间。不要,看了,几分钟,没问题,就,急着,扩大,比例。
而且,监控,要,全面。不要,只,看,整体的,错误率。还要,分,用户群体,看,错误率。比如,新用户,老用户,不同,地区,不同,设备,等等。
5. 要有,完善的,回滚机制
这次,故障,我们,能,快速,恢复,是因为,我们,有,完善的,回滚机制。发现,问题,一键,回滚,很快,就,恢复了。
所以,上线,之前,一定要,确保,回滚机制,是,可用的。而且,要,测试过,回滚,确实,能,正常,工作。
不要,等到,出了,问题,才,发现,回滚,不好用,或者,回滚,需要,很长时间。
五、写在最后
这次,TypeScript入门的,故障,虽然,让我们,熬了,一夜,但是,也,让我们,学到了,很多。
TypeScript,是,一个,很好的,工具。它的,类型系统,能,帮我们,在,编译时,发现,很多,错误。但是,它,不是,万能的。我们,不能,因为,用了,TypeScript,就,放松,警惕。
特别是,对于,外部数据,一定要,做,运行时的,校验。不要,盲目,相信,类型定义。
而且,TypeScript,只是,一个,工具。更,重要的,是,我们,自己,要有,严谨的,态度,完善的,流程,和,充分的,测试。
只有,这样,才能,真正,发挥,TypeScript的,价值,写出,高质量的,代码。
希望,这次,故障复盘,能给,大家,带来,一些,启发。如果你,也,遇到过,类似的,TypeScript的,坑,欢迎,在评论区,留言,我们,一起,交流。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录