我前阵子看一段老代码,光一个审批状态就散着七八个裸字符串:'A01'、'A02'、'C00',有的地方写了注释,有的地方没写,后来有人改成 'B01',相关报表直接对不上。这类 magic values 在 ABAP 项目里太常见了,它带来的麻烦比“可读性差”要严重得多——本质上是隐形的接口契约缺失。这篇博文专门聊 ABAP OO 常量的正确打开方式:怎么把散落的魔法值收敛成接口常量,让调用方通过常量名来协作,而不是靠复制粘贴字符串值。适合刚接触 ABAP OO 的开发者,也适合准备做存量代码优化的人参考。
1. 为什么说 magic value 是 ABAP 里最顽固的技术债
1.1 魔法值到底是什么,ABAP 里为什么特别多
魔法值就是直接写在业务逻辑里的裸字面量:数字 3、字符 'X'、字符串 'A01'、日期 '20250101'。它们不在任何地方被定义和命名,只能靠读上下文猜含义。
ABAP 语言本身缺少原生枚举类型。虽然新版本里有了一些替代手段,但在传统开发习惯里,业务人员和技术同事都习惯直接把值写在代码里。再加上 ABAP 的字符比较很宽松,'01' 和 '1' 可能在不同上下文中都能通过编译,所以魔法值比 Java、C# 这类强类型语言更难被约束。
另一个特殊点是历史包袱。很多大型 ERP 系统里,一个状态字段从十几年前就是这样传值的,数据库里已经存了大量 '01'、'02' 这样的记录,你不能为了代码好看就把值改成 'OPEN'、'CLOSED'。这种“数据库值先于代码存在”的现实,让魔法值有了一个看似合理的借口:它和数据库字段值是对应的。
可问题在于:值虽然对,代码里却没有一个地方告诉你 'A01' 就是待审批。查代码的人只能靠搜索、靠猜测,经常还找到一堆无关的结果。
1.2 魔法值的真实成本:不只是可读性
魔法值无法被静态检查。参数是 string 类型,调用方传什么都能过。方法内部比对 'A01',如果上游传了个 'A02',代码逻辑会静默走过错误分支,数据错误要到下游才暴露。
魔法值让重构变得异常昂贵。你想把一个状态值从 'A01' 改成 'B01',全局搜索出来的可能包括日志片段、测试数据、注释、无关模块的同类写法,根本不敢批量替换。逐个判断呢?工作量直接吞掉重构价值。
魔法值还破坏了接口契约。所谓接口契约,就是调用方只需要知道“传什么、拿什么”,不需要知道内部怎么实现。但当你看到approve( status = 'A01' )时,你其实被迫去追踪被调方需要什么样的合法值。这个信息没有写在签名里,只散落在文档、注释甚至同事的记忆里。
从我的经验看,一个模块的魔法值如果没有被治理,三到六个月后一定会出现“同义不同值”的问题:两个地方表示同一个状态,一个写 'A1',一个写 'A01',对账对不上。等技术债积累到这一步,再想理清逻辑,成本比写新功能高得多。
1.3 常量是魔法值的最小可接受解
要完全消灭魔法值,得靠数据字典域、枚举类、类型化参数等多重手段。但绝大多数团队需要一个成本最低、上手最快的切入点:常量。
把 'A01' 改成一个命名常量status-pending,至少解决了三件事:
- 名字说明业务含义,读代码的时候常量名本身就带着注释;
- 合法值集中管理,改值和查值都只有一个入口,不会散落多个位置;
- 全局搜索有锚点,你搜
status-pending,得到的都是同一含义的使用点,而不是一堆巧合匹配的字符串。
所以我的基本观点是:ABAP OO 项目里,常量不是可有可无的优化,而是一种最小化的接口契约。你不一定要把全系统所有字面量都改成常量,但凡是跨模块共享的业务状态、类型、配置键,都应该先走常量这一层。
2. 常量到底放哪里:接口常量、类常量还是公共只读属性
常量的位置选择直接影响“契约”的质量。
2.1 接口常量:集中但容易变成“垃圾桶”
ABAP 支持在接口里定义常量。最传统的做法是建一个类似ZIF_CONSTANTS的全局接口,把全系统的常量都放进去。优点是集中,缺点是很容易长成“大杂烩”:订单状态、审批类型、打印格式、币种代码全堆在一起。
某个业务改订单状态常量,会影响所有引用这个接口的调用方。即使它们只用币种代码,也不得不重新编译一遍。接口成了公共基础设施,改起来要评估全系统影响,最后大家都不敢动。
更合理的做法是让接口按领域划分:ZIF_ORDER_STATUS管订单状态,ZIF_APPROVAL_STATUS管审批状态。接口名本身描述业务域,常量定义在这个接口里才有意义。调用方也只用自己关心的那个接口,依赖面清晰。
2.2 类常量:适合实现细节和内部粒度的常量
如果某个常量只在一个类内部使用,比如临时处理的标志位、内部转换的偏移量,那不应该上接口。放在类的私有静态常量里就够了。
ABAP 类常量用CONSTANTS声明,可以用私有区控制可见性。外部完全不知道它的存在,内部改值不影响调用方。这在“契约”里其实很重要:接口要暴露的只是“必须让外界知道的约定”,内部实现细节应该封装。
举个例子,一个类每次处理 Excel 文件都要设置导入模式参数字符串'C',这个值只在这个类内部出现,就不需要建一个公共接口。写成:
CLASS zcl_file_importer DEFINITION. PRIVATE SECTION. CONSTANTS: c_import_mode TYPE char1 VALUE 'C'. ENDCLASS.比在某个全局接口里放一个GC_IMPORT_MODE_C要干净得多,因为它不构成对外契约,属于类自己的实现细节。
2.3 公共只读属性:一种被忽略但很实用的方式
ABAP 的类属性可以用READ-ONLY暴露。这样外部可以读,但不能写。很多团队不知道这也能用来承载常量。
如果常量需要跟某个对象的状态绑定,比如配置对象的代码、单据类型的名称,公共只读属性就很合适。在构造方法里给它赋值,后续永远不会改变。它的表现和常量类似,但语义上是“这个对象的固有属性”,而不是“全局静态值”。
用公共只读属性还有一个好处:某个值在多个实例间可以不同。虽然严格意义上它不算“常量”,但在对象化设计里,把“单据类型”作为订单对象的只读属性,比维护全局常量更贴合业务模型。
2.4 我的取舍标准:常量跟着契约走
我在项目里一般用三条规则:
- 跨类、跨模块,调用方必须知道的业务值 → 领域接口常量
- 只在类内部使用,属于实现细节的值 → 类私有常量
- 与特定业务实体绑定,随对象生命周期存在 → 公共只读属性
核心原则是:常量不是按“放在哪里最方便”来选的,而是按“这个值是否属于对外契约”来选的。你希望外部以哪个名字、哪个语义来理解它,它就放到对应位置。
3. 把常量设计成接口契约:命名、分类、注释的落地做法
3.1 常量命名:让名字自己解释业务
ABAP 传统命名喜欢用GC_前缀加全大写。这种风格的问题是,名字能表达“这是一个全局常量”,却很难表达“它属于哪个业务域”。GC_STATUS_NEW和GC_STATUS_PENDING混在一起,时间长了仍然要靠猜。
我推荐域前缀加业务语义的命名。在一个接口里,用结构把同组常量包起来:
INTERFACE zif_order_status PUBLIC. CONSTANTS: BEGIN OF status, new TYPE char1 VALUE 'N', approved TYPE char1 VALUE 'A', rejected TYPE char1 VALUE 'R', END OF status. ENDINTERFACE.这个写法有很多好处。首先,调用方写zif_order_status=>status-approved,语义非常完整:这是订单状态里的“已审批”。其次,开发工具的自动补全可以按照status-把同组值全部列出来,开发者不需要背常量名。第三,以后增加枚举值都在status结构里统一加,分组天然清晰。
常量值本身没有硬性长度要求,但要考虑存储和日志。如果数据库字段是 char1,就定义成 char1;如果未来可能扩展成数字码,建议直接定义成 numc 或定长字符串,避免以后字段加长时大面积修改。
3.2 常量值:数据库兼容优先于代码可读
这是很多人会踩的坑。常量的名字可以很优雅,但值必须尊重现有存储。数据库里已经存了 'N'、'A'、'R',你不能为了语义清晰改成 'NEW'、'APPROVED'、'REJECTED',除非你有完整的迁移方案。
所以设计常量时,先查数据字典域,再查历史数据,最后才确定字面值。如果域定义里已经枚举了固定值,常量值必须和域值保持一致。
另外,值的选择要避免歧义。'Y' 和 'N' 在单据领域很常见,但如果同一个系统里既有“是否发送”又有“是否有效”,两个字段都用 'Y'/'N',那常量的业务前缀就要足够清楚,避免在不同接口里猜错。
3.3 给常量写注释:写出“为什么”而不是“是什么”
常量旁边的注释,写得好的能省掉一整套业务文档。我看过太多注释写的是“待审批状态”,这等于把常量名又抄了一遍。真正有用的注释应该写:
- 这个值代表什么业务状态,和它相邻的状态是什么;
- 该值来自哪里,是后台配置、前台输入还是其他系统的接口;
- 它会受哪些业务流程影响,改动时要考虑哪些模块;
- 有没有和它长得像的取值,如何区分。
例如:
" 审批状态:待审批(A01)。 " 由审批工作流创建时写入,流转目标是 status-approved。 " 注意:历史数据中有 A01 与 A001 两种写法,业务上等价,新代码统一只写 A01。 CONSTANTS: BEGIN OF status, pending TYPE char3 VALUE 'A01', END OF status.这个注释的价值在于,后面的人看到A001这种历史写法时,不会又当成新值写进代码。把业务现场的脏规则零散记录在常量处,比记在私人文档里可靠得多。
3.4 接口常量和数据字典域配合
接口常量解决“开发体验和代码可读性”,数据字典域解决“数据合法性”。两者要配合使用。
在数据字典里定义域,给域配置固定值:A01待审批,A02已通过,C00已取消。然后在结构或方法参数里使用基于该域的数据元素。这样调用方传参时,开发环境会按域校验输入,非法值在编辑阶段就被拦下一部分。
接口常量里的值和域固定值保持一致,相当于“代码侧的名字”和“数据侧的合法清单”指向同一个业务事实。我建议写一个 ABAP Unit,断言常量值和域固定值一致,防止两边出现漂移。
4. 实操记录:用常量契约重构一段被魔法值淹没的代码
4.1 一段典型的魔法值代码长什么样
先说一个我实际处理过的场景,业务逻辑是审批单流转:
METHOD check_status. IF iv_status = 'A01'. " 待审批:允许撤回 ELSEIF iv_status = 'A02'. " 已通过:触发后续释放 ELSEIF iv_status = 'C00'. " 已取消:清理工作流 ENDIF. ENDMETHOD.这段代码看着短,问题却不少。第一,三个状态值散落在方法里,没有说明它们是什么业务状态。第二,不同类之间可能存在同样的判断,比如另一个类判断IF iv_status = 'A01'时,语义可能是“允许编辑”。第三,新增一个状态时,要找到所有相关判断点逐一添加,漏一处就是线上事故。
这类代码通过搜索可以找到十几处、甚至几十处同类判断。在所有地方改一遍值,风险极高。
4.2 重构步骤:先统计,再定义,后替换
我推荐的分步流程是这样的:
第一步:先做使用统计。用代码全局搜索工具搜出所有出现'A01'、'A02'、'C00'的位置,逐个看上下文,判断哪些是“审批状态”,哪些是“恰好相同但语义完全不同的字符串”。这一层不做,后面替换必然出事。
第二步:定义契约接口。以“审批状态”为域,定义zif_approval_status接口,把状态值收敛进status结构。
第三步:小批量替换。替换时不要用全量查找替换,一次改一个业务对象或一个包,改完编译、跑测试。哪怕只改了一个类,也比一次替换五十处然后全部报错要高效。
第四步:清理与回归。所有替换完成后,再全局搜一次旧字符串。此时剩下的、未被替换的'A01',通常是其他领域的巧合值或历史注释,需要单独确认。
重构后的方法会变成这样:
METHOD check_status. IF iv_status = zif_approval_status=>status-pending. " 待审批:允许撤回 ELSEIF iv_status = zif_approval_status=>status-approved. " 已通过:触发后续释放 ELSEIF iv_status = zif_approval_status=>status-cancelled. " 已取消:清理工作流 ENDIF. ENDMETHOD.代码的行数没有变少,但每一行的意图都变成显式的了。后续搜status-approved,搜出来的所有位置都是同一个业务含义,不会再混进无关字符串。
4.3 一个关键细节:别让新常量和旧值打架
替换过程中最容易出的问题是:常量定义好了,但数据库里还有旧值。如果把常量值定义为'A01',可历史数据里存在'A1'甚至'A001',新代码按'A01'判断会漏处理。
所以在定常量值时,先写一个查询看一下实际存在的取值。或者用数据字典域做一次值校对。这一步看着基础,却最容易被跳过。等到上线后产生错账,代价就大了。
顺带分享一个我的习惯:定义完常量后,我会随手写一个 ABAP Unit,断言常量值跟数据字典域的固定值一致。这样以后有人改域、改常量,至少有一个测试点能够提醒。
4.4 常量替换后,方法签名要不要变
如果你只是在方法内部把字面量换成常量,方法签名可以不动。但如果你想让调用方也被契约约束,就应该把参数类型从通用字符串改成业务域类型。
ABAP 中,你可以定义一个数据字典域或数据元素,把参数类型设为TYPE z_approval_status_domain。这样一来,调用方传值就必须在域允许的范围内。就算有人想绕过常量直接传'B01',开发环境的输入校验也能提前拦下一部分问题。
域和常量是两个层面:常量解决“开发体验和代码可读性”,域解决“数据合法性”。两者配合,是把 magic value 彻底消灭的组合拳。
5. 更进一步:让常量约束业务规则与单元测试
5.1 获取合法值集合:让业务规则可以被查询
接口常量能定义值,但不能枚举出“那一组合法值”。我们可以用静态方法返回合法值内表,把校验逻辑集中起来:
CLASS zcl_approval_status_values DEFINITION. PUBLIC SECTION. CLASS-METHODS: get_legal_values RETURNING VALUE(rt_values) TYPE STANDARD TABLE OF z_approval_status_domain. ENDCLASS.方法内部从数据字典域或常量接口读取所有合法值。调用方在做数据校验时直接查这个内表,不再需要到处记录“到底哪些状态是合法的”。规则变成一个可调用的接口,而不是写在某个人脑中的经验。
高频调用场景下,返回内表可能会有性能开销。可以把合法值缓存为一个私有静态变量,第一次加载后复用。大多数单据校验调用频率根本到不了需要担心缓存的程度,所以不要在这个环节过度优化。
5.2 在单元测试中复用常量,而不是再造一份魔法值
单元测试是魔法值的高发区。测试代码里经常写mock->set_status( 'A01' ),看起来和业务代码无关,但这种方式让测试失去“陈述业务语义”的能力。更糟的是,业务代码将来改了常量值,测试里还写着旧值,测试的数据准备就悄悄脱离了真实规则。
正确做法是测试代码也引用同一个常量接口:
DATA(ls_order) = VALUE zorder( status = zif_order_status=>status-approved ). cl_abap_unit_assert=>assert_equals( act = mo_cut->get_status_text( ls_order ) exp = zif_order_status=>status-approved ).这样测试和被测代码共享同一个契约来源。如果业务常量变了,测试的期望值也跟着变,不会出现测试里存着旧世界魔法值的情况。
5.3 防止绕过常量的输入:方法入口做白名单校验
总有人会绕过常量直接传值,比如从外部接口接收数据时值在运行时才到达。这时候单靠常量无法兜底。我在公开方法入口处统一做一次校验:
METHOD set_status. IF NOT line_exists( legal_values[ table_line = iv_status ] ). RAISE EXCEPTION TYPE zcx_invalid_status. ENDIF. mv_status = iv_status. ENDMETHOD.合法值来源可以是数据字典域,也可以是常量枚举。校验逻辑集中在一个方法,非法值要么报错,要么经过明确映射后再写入。这么做的最大价值是:你可以顺着“入口校验加内部常量”这条链,把一套业务状态规则从头到尾固定在代码里。
5.4 常量变化时的受控流程
常量的值一旦被外部系统读取或持久化,就不能随便改。如果你确实要改,我建议做四件事:
- 先查数据库和接口日志,确认旧值没有被引用;
- 改常量定义,并同步更新数据字典域固定值;
- 跑一遍 ABAP Unit,确认所有引用点跟着更新;
- 通知下游接口调用方,约定新旧值兼容窗口。
这套流程看起来多,但每次改状态码、类型值都需要。常量契约的价值就在这:你知道改动影响面,而不是改完再靠线上事故去发现。
6. 常见坑与排查心得
6.1 常量接口越做越大,最后没人敢动
如果每次遇到新值都往原来的接口里塞,两三年后接口里会有几百个常量,涵盖订单、采购、库存、财务多个领域。这时候谁都怕动它,于是大家又开始绕过接口直接写魔法值。
解法就是按领域拆接口。不要造一个“全局常量接口”,要造一组按业务域命名的接口。接口之间尽量不要做交叉引用,调用方只依赖自己所属域的接口。这样影响面是可控的。
6.2 常量名重名而值不同:跨接口的命名冲突
两个领域都用status-open,一个是单据状态,一个是流程状态。如果放在不同接口里,通常没问题。但如果你习惯把常量名全用模块名做前缀,就容易为了区分而让名字变得没有语义。
ZIF_PO_STATUS=>status-open指的是采购单已下单,而ZIF_FLOW_STATUS=>status-open指的是工作流待处理。两者同时出现在一张报表里,刚开始确实容易混。这也说明接口名本身就是区分的第一个层级,使用时一定要带上接口前缀,不要把它省略掉。
6.3 把“值等于常量名”当成理所当然
有些团队为了让代码好看,把常量的值直接定义成常量名的缩写,比如status-approved的值设成'APRVD'。如果这个字段只是程序内部使用,没问题;如果它要写进数据库表、传输给外部系统,就要三思。外部系统的接口文档写的是'APPROVED',你这里存成'APRVD',下游就永远需要一份映射表。
所以常量值一开始就要和存储在数据库、传输到外部的实际值对齐,而不是由程序员的脑洞决定。
6.4 只加常量不加校验:治标不治本
常量的作用是把裸值变成有名字的值,但它不保证外部传入的值真的来自常量。如果入口参数还是通用字符串,调用方依然可以传一个'Z99'。这时候代码里虽然写的是IF iv_status = zif_approval_status=>status-pending,但对方传的是'Z99',两边不匹配,问题照样发生。
所以我在治理魔法值时,通常会分两步走:第一步先上常量,解决可读性;第二步再上域或入口校验,解决合法性。只做第一步,代码会漂亮很多,但数据的兜底能力并没有增强。
6.5 一个容易忽略的细节:常量接口要不要建实现类
ABAP 接口不能直接被实例化,但常有团队为接口写一个实现类,理由是“万一以后需要方法”。如果接口只定义常量,这个实现类没有必要。实现类会让接口看起来像抽象类,反而增加理解成本。
只有当你需要在接口中声明方法并让不同类各自实现时,再去建实现类。常量接口保持纯粹,就是一组命名值,这比强行套出继承关系要清晰。
我自己的习惯是每定义一个常量接口时,在接口注释里写清楚三句话:这个接口覆盖哪个业务域,为什么把这些常量放在一起,遇到看起来相似但语义不同的时候去哪里找。这三句话写清楚,后面的人基本不会用错接口。
最后再分享一个我反复用的小技巧:当你准备把一个魔法值替换成常量时,先问自己三个问题——这个值会变吗?会变的话影响哪些模块?哪些地方已经写上了同样的值但意思不同?想清楚这三个问题再动手,替换的成功率会高很多。好的常量契约不是靠一个接口文件达成的,而是靠每一个使用点都能让读者快速建立正确理解来达成的。