Featured / · 约 5 分钟

【开发第一性原理】05 · 为什么"友好化"反而害了排障

配图:友好化

《开发第一性原理》系列第 5/12 篇

系列说明:这个系列写我在开发里反复验证过的基本规律。不追新概念,只讲那些早晚会再次应验的东西。

TL;DR:包装别替换,累加别丢弃。

业务报上来一句”同步失败”,你翻一上午日志也找不到哪步出错——问题多半出在”友好化”:每层 catch 都把原始消息换成一句好听话,翻到最里层,根因没了。这篇讲消息链怎么传才不丢:抛出点是源头,下游只能追加、不能替换;友好提示走另一条线给用户看。落到 ABAP,就是 sy-msgv1-4、SLG1 和消息类的用法。


一、六个字的报错,查了一上午

前段时间排查一个跨系统的订单同步问题。

业务部门反馈:“订单同步失败了。“再问提示什么,答:“同步失败,请联系管理员。”

我手里就这六个字。

打开应用日志 SLG1,翻到对应时间。日志有,一层不少。但每层都是 catch 之后抛自己新写的消息:最外层”订单同步失败”,往里一层”订单校验失败”,再往里”校验失败”。翻到最深处,本该躺着原始错误的地方,也是一句自造的”操作失败”。

原始错误是什么?“外部系统返回 HTTP 503,重试三次超时。“——早被中间某一层丢掉了。

我花了一上午,逐层翻代码、逐层追抛出点,才在最深的 try-catch 里找到它。

我以前觉得,给用户友好提示是好事,总比甩一堆技术术语强。那次之后改了看法:友好提示和保留原始消息,是两件事。加一层好听话,是包装;把原来的话扔掉换一句新的,是替换。很多 catch 块写的是前者,干的是后者。

二、抛出点才是消息的源头

这条规矩一句话能说完:包装别替换,累加别丢弃。

抛出那一行是消息的源头——和 03 篇”一个数据只有一个源头”是同一个思想。消息在这里出生,权威版本只有这一份,下游谁都没资格改写它。

下游每层能做的事只有两种:

  • 追加自己的上下文:“调订单校验服务失败,原因是……”
  • 翻译给特定受众:把”HTTP 503”翻译成”外部系统暂时不可用”

不能做的也有两种:替换原文;吞掉异常——catch 之后 log 一下,让程序”继续跑”。前者让根因消失,后者让错误连存在过的痕迹都不留。

排障怕这个,是因为排障要的是一条链,不是一个点。错误从最深处冒出来,穿过校验、穿过服务、穿过接口、穿过 UI,每层都该留着上一层的原话,运维和开发者顺着链往回走,才落得到根因。链断了,到外层只剩一行”操作失败”——错误还活着,诊断信息全没了。这就是 01 篇说的”安静的低级错误”的另一种形态:它不报错,它只是让你查不到。

你可能觉得:本来也不该把”HTTP 503”直接甩给用户看。对,所以是双轨。友好消息给用户——“订单同步失败,请稍后重试”;原始消息留给运维和开发者,进日志、进消息参数。两条线各走各的,谁也不替代谁。Java 的 cause、Go 的 %w、Python 的 raise from,做的都是这件事:错误链不断,根因不丢。

三、体检报告和病历

开发之外,这个结构天天在用:体检报告和病历。

体检报告上写”血脂偏高,建议复查”——友好、易懂、不吓人,是给本人看的。病历里写”LDL 4.7 mmol/L,伴颈动脉斑块”——精确、可追溯,是给医生决策用的。两份记的是同一个事实,一份负责让人看懂,一份负责让人能治病。

把两份合并成一句”血脂不太好”呢?表面上更友好,实际既不能帮本人理解,也不能帮医生决策。

错误消息也一样。给用户的友好提示和给排障用的技术细节,是同一次失败的两份记录。合成一句”操作失败”,等于病历被体检报告吃掉了。

四、我现在怎么写

落到 ABAP 里,三个场景,加一个清单。

FM 嵌套调用。被调 FM 抛了消息,调它的 FM 里第一件事:把 sy-msgid、sy-msgno、sy-msgv1-4 存下来,再决定怎么办。图省事写一句 MESSAGE '操作失败' TYPE 'E',原始消息就没了。要包上下文,用 MESSAGE e001(zx) WITH '订单同步' RAISING——消息类里写”订单同步失败,原因:&1”,原始消息放参数里接着往下传。

后台作业和应用日志。异步场景没人盯着屏幕,SLG1 是事后唯一的入口。写日志用 BAL_LOG_MSG_ADD,传消息类和参数,别硬编码文本。好处是 ID 不变、文本可变:运维事后 grep 一下”ZORDER 001”,直接定位到抛出点,连当时的订单号都看得到。

BAdI 和 Enhancement。实现里出错,最差的是 RETURN 静默退出——标准程序以为 BAdI 成功了,接着往下走,这是最诡异的那类 bug。该 MESSAGE e001(zx) RAISING 就抛出去,让标准程序感知到。

你可能想问:catch 之后 log 一下、再把异常 rethrow,log 和异常都留,是不是重复了?不重复。log 给运维看,是事后的线索;异常给调用方看,是它决定下一步的依据。写日志解决”事后查得到”,抛异常解决”当场处理对”,少一个都缺一角。

最后是任何 catch 块都过一遍的清单:

  1. catch 必 rethrow,绝不只 log——catch 后 log 一下继续跑,是事故现场
  2. log 和异常分开写:一个给运维,一个给调用方
  3. 包装只追加,不动原始消息
  4. 只在边界做友好化——跨系统、到用户眼前才翻译,系统内部保持原样
  5. 用 SE91 消息类,不写硬编码文本

五、写在最后

包装别替换,累加别丢弃。

给用户友好提示,和给开发者留诊断信息,不冲突。拆开这两件事:用户得到一句好懂的话,运维得到一条完整的链,两头都占着。

这也是 01 篇的延续:消息链一断,错误就穿上了隐身衣——不报错,只是查不到。

下一篇聊失败处理的另一半:事前防御。和业务把数据的边界讲清楚,再动手写码。

评论