【开发第一性原理】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 块都过一遍的清单:
- catch 必 rethrow,绝不只 log——catch 后 log 一下继续跑,是事故现场
- log 和异常分开写:一个给运维,一个给调用方
- 包装只追加,不动原始消息
- 只在边界做友好化——跨系统、到用户眼前才翻译,系统内部保持原样
- 用 SE91 消息类,不写硬编码文本
五、写在最后
包装别替换,累加别丢弃。
给用户友好提示,和给开发者留诊断信息,不冲突。拆开这两件事:用户得到一句好懂的话,运维得到一条完整的链,两头都占着。
这也是 01 篇的延续:消息链一断,错误就穿上了隐身衣——不报错,只是查不到。
下一篇聊失败处理的另一半:事前防御。和业务把数据的边界讲清楚,再动手写码。