Rust+Rocket+Sentry+Anyhow 的错误处理和上报
其实,Go 和 Rust 它们两者对错误的设计非常相似,这种相似,却因为后续更进一步的设计决策方向的差异,导致有了非常不同的体验。
...
if err != nil {
trace.GlobalLogger.For(ctx).Error("WantDoSomeThing CallSomeOne Failed",
zap.Any("param", param), zap.Error(err))
return nil, errno.IntranetAccessErr.WithErr(err)
}
...
这是曾经一个使用 Go 编写的业务系统中其中一处对于调用错误的处理,WantDoSomeThing 是这个代码块所在函数体的函数名称,CallSomeOne 是这个 err 所来源的调用函数名称。
这个业务系统中,有无数个地方有这样的 if err != nil 并配合 withErr 返回的处理,这样你在遇到业务问题需要进行排查的时候,你有办法通过 trace 服务或者是日志服务来大致快速地看到它出问题时候的整个调用链。
虽然做不到类似 Python 的 Sentry 集成能有非常高级又详细的错误堆栈信息,但是也是一个非常有力的业务问题排查辅助工具。
后来来到了 Rust 这边,如果设计的好,能看到类似这样带上 with_context 的业务代码。
fn get_res() -> anyhow::Result<Person> {
let s = "09900";
let res: Person = serde_json::from_str(s).with_context(|| format!("parse person error: {}", s))?;
Ok(res)
}
最早的时候,我没留意,很多时候可能就是类似这样,不会带上 with_context 去添加附加信息。
let res: Person = serde_json::from_str(s)?;
那么其实这种不带 with_context 呢,到后面生产环境服务出现问题的时候,就可能会比较棘手,你可能大概率无法通过报错提示来快速定位大致是哪个位置出现了问题,哪怕是定位到了一个函数体内,但是如果这个函数调用了许多方法,然后又都不带 with_context 的话,就真的比较难一眼看出谁可能出问题了。虽然 Rust 是目前最严格的语言,但是在面对业务问题,有时候可能是返回数据问题或者是第三方函数返回值的问题,这些都依然还是需要我们自己去控制的。
虽然说 Rust 与 Go 它们两个在设计上存在某种相似性,即每一个函数层层调用的返回结果,把它们都串起来之后,你会发现都存在一个数据流 + 一个错误流的设计模式。
但是 Rust 更进一步地,将数据与错误通过 Result 类型进行封装,又通过 ? 语法糖巧妙地实现了错误流的便捷向上传输,使得我们不再需要在一堆又一堆的 if err != nil 缝隙中去仔细寻找真正的业务代码逻辑。
通过 ? 语法糖便捷地向上传递错误信息,但是这里在真正的复杂业务系统里面,一旦真正出现业务问题时,会遇到一个问题,就是你可能无法直观地知晓这个错误提示它的最开始来源是哪里。
想要快速地知晓错误来源,有两个办法:一个是通过错误代码回溯,一个是通过错误日志提示。
错误代码回溯,需要你设置 RUST_BACKTRACE: ‘1’ 这个环境变量,目前根据我搜集到的信息来看,是非常建议在生产环境进行开启的,哪些说影响性能的,可能没有仔细分析状况,它仅在出现错误产生回溯时才会影响性能,如果代码逻辑正常,是不会出现性能影响的,如果你生产环境出现了错误,你肯定是希望能拿到足够的信息来分析并解决掉这个问题,而不是屏蔽掉而当它不存在。
错误代码回溯,大多数情况下,你会在服务日志里面看得到,但这需要你能接触到服务部署或者有日志采集服务帮你采集并聚合日志供你搜索和分析。
但是更多数情况,是用户,这个用户可能是业务调用方,例如前端,也可能是其它服务,甚至也有可能是客户或者是客服,通过某些方式拿到了这样一个消息提示,然后把它反馈给你,希望你能帮助排查问题并予以解决或解答。
而如果是直接面向客户的服务,它所面向客户的错误,有可能是这样的,一个非常笼统的错误提示,是不会把具体的错误细节暴露给客户的,就是没有任何能帮助你排查错误的有价值的信息。
Internal Server Error
而什么才是能帮助排查错误的有价值的信息呢?大概有可能是这样的。
Error: parse person error: 09900: invalid number at line 1 column 2
它其实是有结构的:将调用链上所有携带的错误提示文本,通过冒号进行拼接,从外到内。
这是在 Rust 服务中所能拿到的比较好的错误文本提示信息了。
为什么这么说,我们来看看设计细节。
这是我们服务内部的 Error 定义,基于 thiserror 进行设计。
#[derive(Error, Debug)]
pub enum Error {
#[error("{0:?}")]
Feedback(Code),
#[error(transparent)]
Anyhow(#[from] anyhow::Error),
}
然后在处理服务的错误返回值的时候,需要声明 Responder 实现,这个在每个框架有可能不一样,但是思路是差不多的。
impl<'r> Responder<'r, 'static> for Error {
fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
let code = self.get_code();
let res: Res<String> = Res {
code,
msg: format!("Error: {:#}", self),
data: None,
};
let string = serde_json::to_string(&res).map_err(|e| {
error_!("JSON failed to serialize: {:?}", e);
Status::InternalServerError
})?;
capture_anyhow(&format_err!("{:?}", self));
tracing::error!("{:?}", self);
content::RawJson(string).respond_to(req)
}
}
注意看这里面有几个细节。
msg: format!(“Error: {:#}”, self), 这个里面的 {:#} 则是将错误以冒号分隔的方式层层拼接为文本,然后在接口的 msg 消息里面返回,方便一旦出现问题时,能留下有效的错误提示信息供快速确定问题方向。
capture_anyhow(&format_err!("{:?}", self)); 这里面的 {:?} 则是将错误的问题提示和代码的完整回溯都拿到,然后 format_err 转化为可被 capture_anyhow 接受的类型(不使用 capture_error 的原因是它所上报的回溯有可能丢失了原始代码的信息,与 log 打出来的日志不一致),然后上报到 Sentry 服务上,这样你就能在 Sentry 后台看到这个报错信息和完整的代码回溯细节。虽然,它远比不上 Python 能完整抓到上下文甚至包括变量的值,但对于 Rust 本身来说,已经非常足够了,如果需要变量值,就需要自己主动把变量的值通过 with_context 携带到对应上下文的日志里面去了。
这里用到了几个对 Error 进行格式化的符号,具体解释下。
format!("Failed to call some one: {}", err), // 只打最外层错误文本
format!("Failed to call some one: {:?}", err), // 打完整错误文本和回溯代码
format!("Failed to call some one: {:#}", err), // 只打完整错误文本,层层串联,冒号分隔
format!("Failed to call some one: {:#?}", err), // 把错误用结构体显示
也可以具体去参考这个文档 Display representations , 这个文档我曾经有意或无意间看过很多次,但是一直都没有过多留意,直到最近在尝试解决一个棘手的线上问题后,才有了切身的体会。
而大多数时候可能你接收的系统,在最终处理错误时,很多默认使用了 {} 进行错误打印,这时候你是不会拿到更下层调用的错误提示信息的,即便每一层都使用 with_context 携带了上下文信息,就很可惜。
如果你不知道,那就陷入了“到底是谁把错误给吞了”的困境之中。
最后,做下总结。
上面四个模式,sentry 要最详细的上报,log 要最详细的记录,都选 {:?}
返回给客户的,要相对比较详细地返回,选 {:#}
要配置 RUST_BACKTRACE=1 日常运行
使用 with_context 和在代码里面插 log,适用场景还是有所区别,with_context 仅在你报错了才携带上下文,log 则是无论如何只要程序跑到某处就会记录,要看具体目的需求进行结合使用,帮助你写出稳定又放心的业务系统。
通过这样接入 Sentry 后,如果程序有意外的问题,就不会像无头苍蝇似的不知所措了。
不过这个 Sentry 的上报目前还有一个问题是可能显示不是很友好,期望后续它进一步升级迭代能更好。