ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ABAP静态类到对象化改造:从能跑到可演进的价格计算重构

ABAP静态类到对象化改造:从能跑到可演进的价格计算重构 从一次真实的订单审批增强说起。我当时维护一个SAP ERP里的价格计算逻辑最初就是一个静态方法传入基础价格和客户等级返回最终价格。调用点散布在销售订单、交货单、开票三个模块。后来业务提了新需求线上渠道要额外打折大客户黑名单不打折月底大促再叠加一个全场折扣。我硬着头皮在那个静态方法里加了三个IF还往方法签名上补了三个IMPORTING参数。两周后自己回来看这方法已经不太敢动了。那一刻我意识到ABAP代码停留在能跑阶段远远不够它需要进入可演进阶段。而把这个弯转过来的关键动作就是从静态类走向对象。这篇文章不讲高深理论就讲我这几年在ABAP里做对象化改造的完整思路、实操步骤和踩过的坑。1. 静态类能跑但走不远的三个信号1.1 信号一状态藏在CLASS-DATA里悄悄互相污染静态类的本质是“类级别的方法和类级别的数据”。方法用CLASS-METHODS定义数据用CLASS-DATA定义。很多人写静态类是因为不需要维护实例调用方便zcl_utilitydo_something( )一行搞定。但方便是有代价的代价就是CLASS-DATA会产生跨调用点的共享状态。我处理过一个库存预占的类类里有个CLASS-DATA存当前批次号两个不同的程序同时调用时后一个调用会覆盖前一个的批次号。最诡异的是问题不总是出现——只有当两个程序在极短时间窗内先后调用同一个方法时才会触发。这类bug一旦出现排查成本极高因为你无法从一次调用中看到完整上下文。解决办法就是把CLASS-DATA改成实例属性让每个调用者持有自己的对象。批次号作为构造参数传入对象内部的所有方法都基于这个实例属性运算彼此隔离才不会出现“你在楼上改了黑板我在楼下读错了值”的尴尬局面。1.2 信号二变动一来静态方法内部疯狂长IF静态类还有一个典型症状方法签名越来越长方法体里IF越叠越深。因为静态方法没有多态能力所有分支逻辑只能堆在同一个方法里用各种条件参数区分业务场景。我见过一个运费计算静态方法参数有重量、目的地、运输方式、是否加急、是否月结客户、是否偏远地区六个参数交叉组合方法体里密密麻麻的IF和CASE超过三百行。新需求的到来方式永远是再加一个参数再补一个IF分支。这种代码的脆弱之处在于任何新增条件都会让原有的所有分支重新处于“可能受影响”的状态。你只验证了A组合但B组合的隐性依赖可能被意外改变。静态类缺少一种把“变化点”局部化的能力所有的变化都汇聚到一个函数里变成耦合。1.3 信号三写单测时发现静态方法根本mock不进去ABAP也支持单元测试但静态类的测试体验让我很痛苦。当你被测的方法内部直接调用另一个静态类的方法时你无法在测试环境中替换那个静态方法的行为。举个例子方法里调用了zcl_exchange_rateget_rate( iv_currency )而在单元测试环境里这个汇率服务依赖的底层表可能是空的或者外部接口根本不可用。你无法注入一个假汇率来隔离测试只能去准备测试数据。而如果这个依赖是一个对象、一个接口引用你可以在测试里创建mock对象传进去。依赖注入的前提是被注入的东西是一个对象引用。如果到处都是静态方法调用依赖关系在编译期就绑死了测试就只能在真实依赖上打转单元测试变成集成测试。这是静态类阻碍可测试性、进而阻碍可演进性的最直接证据。2. 从静态方法到实例对象本质是重新分配责任边界2.1 实例状态让这次调用和那次调用互不干扰对象和静态类最直观的差异就是对象有实例状态。实例状态是什么简单说就是一个对象从创建到销毁之间独属于这个对象的变量。不同对象拥有各自独立的实例状态互不覆盖。用生活类比就是静态类像公司前台的公共白板谁都能写写了别人也能看到后来的覆盖先前的对象像每个人自己的笔记本各自记录各自的互不影响。在ABAP里实例状态通常放在PRIVATE SECTION的DATA定义中构造函数CONSTRUCTOR负责初始化。一旦把上下文相关的数据从参数列表移入实例状态方法的签名就大大简化了。原本get_final_price( iv_base_price, iv_discount, iv_channel, ... )这种长签名会变成get_final_price( iv_base_price )折扣规则和渠道信息通过构造函数一次性注入。这个变化看似只是代码组织方式变了实际上它让“一次调用”拥有了完整、自洽的上下文。调用者先构建一个携带上下文的计算器对象再反复问它要结果而不是每次调用都把一堆上下文参数摔在方法脸上。2.2 接口把我要什么和谁来实现分开对象化改造走到一半你会自然接触到ABAP的接口INTERFACE。接口定义一组方法签名但不提供实现。任何类只要实现了这个接口就可以被当作这个接口类型来使用。这个机制的威力在于调用方编程到接口而不是编程到具体类。只要接口不变我可以随时替换实现类替换的过程对调用方完全透明。举个例子原来的价格计算直接依赖一个具体类zcl_price_calc。对象化之后我定义一个接口zif_discount_rule方法get_discount_rate返回折扣率。会员折扣是一个实现渠道折扣是另一个实现。价格计算器只依赖接口具体传入哪个实现由调用方组装时决定。这就是依赖倒置原则的落地。依赖方向反转之后变化就被限制在了局部。新增一种折扣规则不需要碰价格计算器的代码只需要新增一个类实现这个接口。ABAP代码从“改一处牵全身”变成了“加一处局部生效”。2.3 组合扩展ABAP现实世界的策略模式说到策略模式ABAP完全支持而且实现起来非常自然。策略模式的核心思想是定义一族算法分别封装起来让它们可以互相替换。在ABAP里就是用接口引用组成的内部表来管理一组策略对象。比如价格计算我可以把多个折扣规则对象放进一个STANDARD TABLE OF REF TO zif_discount_rule然后循环遍历每个策略累加折扣率。增加一条新规则就新增一个实现类加入这个集合。删除一条规则就从集合里去掉对应对象。这个模式在静态类时代几乎无法优雅实现。静态类的方法引用不能像对象引用一样被存储、传递、组合。静态类的方法签名必须预先把所有规则参数都列出来否则调用方无法传入。而对象策略集合完全解耦了“规则的种类”和“规则的使用方式”。3. 迁移实操一个静态价格计算类的对象化改造3.1 改造前的调用点与依赖盘点动手改代码之前先做现状盘点。我在重构前会做三件事第一找出所有调用这个静态方法的地方。ABAP里可以用“Where Used List”查看引用列表改完代码以后要针对每个调用点回归验证。第二识别静态方法内部访问了哪些全局数据。是读了表调了其他静态类还是改了CLASS-DATA这个步骤直接决定新对象的构造函数需要哪些入参。第三按调用频率和调用场景把调用点分类。高频调用和低频调用的对象管理策略不同批量作业和在线事务的处理模式也不同。以价格计算为例当时的静态方法签名是get_final_price( iv_base_price, iv_customer_level, iv_channel )。调用点一共7处。内部依赖包括一个汇率静态类、一个客户主数据读取函数。调用频率虽有差异但都是毫秒级以下对象化不会造成性能压力。3.2 定义接口与对象模型盘完现状后我第一步不是写实现类而是定义接口。接口是对象化改造的定海神针它把调用方和实现方之间的契约固定下来。对价格计算我定义了这样的接口INTERFACE zif_discount_rule. METHODS get_discount_rate RETURNING VALUE(rv_rate) TYPE i. ENDINTERFACE.这个接口表达的含义是任何能返回一个折扣率的规则都可以参与价格计算。我不关心你是根据客户等级算的还是根据渠道、季节、活动算的。接下来是价格计算器对象它依赖接口集合CLASS zcl_price_calc DEFINITION. PUBLIC SECTION. METHODS constructor IMPORTING it_rules TYPE STANDARD TABLE OF REF TO zif_discount_rule. METHODS get_final_price IMPORTING iv_base_price TYPE i RETURNING VALUE(rv_price) TYPE i. PRIVATE SECTION. DATA mt_rules TYPE STANDARD TABLE OF REF TO zif_discount_rule. ENDCLASS.构造函数接收一个规则对象集合存入实例属性。get_final_price只需基础价格其余规则通过循环策略对象自动汇总。3.3 重写实现类状态进入构造函数原来的静态方法像一个大杂烩现在拆成多个实现类。每个类专注一个规则状态通过构造函数注入。会员折扣规则实现类CLASS zcl_member_rule DEFINITION. PUBLIC SECTION. INTERFACES zif_discount_rule. METHODS constructor IMPORTING iv_level TYPE i. PRIVATE SECTION. DATA mv_level TYPE i. ENDCLASS. CLASS zcl_member_rule IMPLEMENTATION. METHOD constructor. mv_level iv_level. ENDMETHOD. METHOD zif_discount_rule~get_discount_rate. CASE mv_level. WHEN 1. rv_rate 0. WHEN 2. rv_rate 10. WHEN 3. rv_rate 20. WHEN OTHERS. rv_rate 0. ENDCASE. ENDMETHOD. ENDCLASS.渠道折扣规则实现类CLASS zcl_channel_rule DEFINITION. PUBLIC SECTION. INTERFACES zif_discount_rule. METHODS constructor IMPORTING iv_channel TYPE c. PRIVATE SECTION. DATA mv_channel TYPE c. ENDCLASS. CLASS zcl_channel_rule IMPLEMENTATION. METHOD constructor. mv_channel iv_channel. ENDMETHOD. METHOD zif_discount_rule~get_discount_rate. IF mv_channel O. rv_rate 5. 线上渠道额外5%折扣 ELSE. rv_rate 0. ENDIF. ENDMETHOD. ENDCLASS.这样拆完以后每个类都很小只干一件事。整段逻辑可以用自然语言向业务人员解释而不是让他们去读三百行的IF。3.4 调用点最小改动切换调用点最忌讳一步到位全部重写。我会先构建一个“规则装配工厂”或者直接在调用点写一小段装配代码把旧的静态方法调用替换为新对象的调用。改造前调用点长这样lv_price zcl_price_calcget_final_price( iv_base_price lv_base iv_customer_level lv_level iv_channel lv_channel ).改造后调用点需要先组装规则对象DATA(lo_member_rule) NEW zcl_member_rule( iv_level lv_level ). DATA(lo_channel_rule) NEW zcl_channel_rule( iv_channel lv_channel ). DATA(lt_rules) VALUE STANDARD TABLE OF REF TO zif_discount_rule( ( lo_member_rule ) ( lo_channel_rule ) ). DATA(lo_price_calc) NEW zcl_price_calc( it_rules lt_rules ). lv_price lo_price_calc-get_final_price( iv_base_price lv_base ).看起来代码量变多了但这多出来的几行是在做“装配”告诉系统这次计算由哪些规则组合而成。信息量是增加的不是冗余的。我当时的策略是先把7个调用点中最典型的2个切换过去跑通回归确认没问题后再切换到其余5个。切换是机械性的真正的风险在于规则覆盖不全——比如有些调用点之前传的客户等级为0在新对象里对应的折扣率是0这个行为要保持一致。3.5 第一个可测试的版本对象化改造的隐藏红利是单元测试终于可以写了。因为规则是以接口引用传入的我可以在测试里构造任意规则的组合。ABAP的单元测试类里我可以创建真实实现类传入特殊参数验证返回价格是否符合预期。也可以编写测试替身类实现接口返回固定折扣率验证价格计算器的循环累加逻辑是否正确。我用ABAP单元测试框架CL_AUNIT_ASSERT或ABAP Unit写了几个核心测试METHOD test_member_with_channel. DATA(lo_member_rule) NEW zcl_member_rule( iv_level 2 ). DATA(lo_channel_rule) NEW zcl_channel_rule( iv_channel O ). DATA(lt_rules) VALUE STANDARD TABLE OF REF TO zif_discount_rule( ( lo_member_rule ) ( lo_channel_rule ) ). DATA(lo_price_calc) NEW zcl_price_calc( it_rules lt_rules ). cl_abap_unit_assertassert_equals( act lo_price_calc-get_final_price( iv_base_price 100 ) exp 85 ). 100 * (100 - 10 - 5) / 100 ENDMETHOD.静态类时代这种测试几乎写不出来因为规则固化在方法体内你没法单独验证两条规则叠加的效果。对象化之后测试的成本低到可以天天跑。4. 生命周期与性能对象化之后的ABAP资源消耗怎么看4.1 一个NEW的成本到底有多大很多ABAP开发者抵触对象化理由是“创建对象太慢”。这个顾虑在ABAP里有历史原因早期的ABAP对象创建确实比方法调用开销大。但现代ABAP版本中创建轻量业务对象的成本已经大幅降低普通业务场景下完全感知不到差异。我把一次静态方法调用和一次对象创建方法调用的耗时对比过。在循环一万次的价格计算场景下静态方法版本明显更快但差距是以毫秒甚至亚毫秒计的。而在线事务处理中一次数据库查询、一次RFC调用的耗时远远大于这种差异。为了这零点几毫秒的收益牺牲代码的可演进性得不偿失。关键是区分“对象创建频率”。如果在一个循环内对每行数据都创建新对象那确实要优化。如果是在一个事务开始时创建一次循环里反复使用同一个对象实例成本完全可以忽略。4.2 高频调用场景的对象复用策略高频调用场景如何设计我有几个实测有效的策略策略一是长生命周期对象复用。在程序开始时创建规则对象和价格计算器存入全局变量或共享内存区域整个程序生命周期内复用。策略二是在方法内部延迟创建。如果你把价格计算器作为服务对象可以不做成一个对象依赖另一个对象而是让价格计算器内部维护规则工厂第一次调用时构建规则集合之后直接复用。策略三是在特殊情况下保留静态方法作为门面。注意这里不是回到静态类而是用一个静态门面方法内部创建对象、委托处理。这在外层有性能压力、调用点又不想每次组装规则时很有用。门面内部可以做缓存规则组合不变时直接复用对象。ABAP内存管理由运行时垃圾回收机制负责对象不再被引用后会被回收。但要注意循环引用场景如果两个对象互相引用GC可能无法回收需要显式清理。不过对于业务计算对象生命周期通常很短不用太担心。4.3 为什么多数业务场景性能焦虑是多余的说句大实话ABAP应用程序的性能瓶颈绝大多数在数据库访问和外部接口调用上不在对象创建上。SELECT返回几万条数据每条执行一次规则调用慢的是SELECT和网络传输不是对象实例化。我见过一个报表原本把所有计算都写成SQL聚合加静态方法后来因为逻辑复杂到无法维护而对象化改造。改造后跑批时间反而略有下降因为对象化改掉了原来重复读取主数据的逻辑依赖注入让数据读取可以被缓存。性能优化的真正路径是减少不必要的数据访问对象化只是把依赖理顺数据访问的责任边界清晰之后缓存和复用才变得自然。5. 演进能力的实证同一需求变更两套代码的应对差异5.1 新需求叠加渠道折扣与会员折扣改造前价格计算只支持会员折扣。业务变成线上渠道在会员折扣基础上再打95折即额外5%折扣。这个需求在旧方法里的实现路径是加一个渠道参数在方法体里再加一个IF。参数列表从两个变三个方法体多一段逻辑。这表面上不难但成本在后面。三个月后需求又变成“大客户在渠道折扣上叠加3%”你还要再加参数。每次变化方法的圈复杂度都在累积直到某天你自己都理不清所有参数组合的行为。5.2 静态类的改法参数列表崩塌模拟一下静态类的改法CLASS-METHODS get_final_price IMPORTING iv_base_price TYPE i iv_customer_level TYPE i iv_channel TYPE c iv_vip_flag TYPE abap_bool RETURNING VALUE(rv_price) TYPE i.方法体里IF iv_vip_flag abap_true再叠一个折扣。调用点也要跟着改所有没有vip_flag的调用点都要补一个默认值。更大问题是如果新增的需求只在特定业务场景出现所有调用点无论是否需要都得感知这个参数。接口契约被不必要的细节污染调用方被迫知道他们本不该知道的规则。5.3 对象化的改法新增两个实现类和一行装配对象化之后同一需求的实现路径完全不同。渠道折扣已经是一个独立实现类我只需为VIP叠加规则再写一个类或者直接在装配点传入一个已有的季度活动规则对象。改动范围明确第一新增一个实现类比如zcl_vip_rule实现zif_discount_rule~get_discount_rate返回3%。第二在需要应用VIP折扣的调用点把新规则对象加入规则集合DATA(lt_rules) VALUE STANDARD TABLE OF REF TO zif_discount_rule( ( NEW zcl_member_rule( iv_level lv_level ) ) ( NEW zcl_channel_rule( iv_channel lv_channel ) ) ( NEW zcl_vip_rule( ) ) ).第三其他调用点完全不用动它们依然按自己的规则组合运行。这就是可演进性的直观体现你不需要为了一项新需求去审核所有既有调用点只需要在本地新增代码并在特定位置装配。代码的“影响半径”被大幅缩小这比任何性能优化都更值钱。6. 对象化不是银弹三类合理保留静态类的情况6.1 纯函数工具类无状态就是最大优点说了这么多对象化但我不认为所有静态类都应该消灭。对象化的价值在于引入实例状态、接口、多态来管理变化但如果一个类根本没有状态也不会有多种实现那么它保持静态类反而是最优选择。典型的例子是字符串处理、数学计算、类型转换、日期格式化这类工具。比如ABAP里判断一个字符串是否为数值我可以写一个zcl_abap_utilis_numeric( lv_str )它没有状态、没有依赖、不会变静态方法完全够用。强行让这类工具变成对象只会增加无意义的装配代码。我自己的判断标准是如果一个方法集没有任何“业务规则变化点”所有调用方对它的期望完全一致那静态类或者函数组完全可以胜任。演进压力只存在于那些会随业务变化而变化的功能点上。6.2 一次性批处理与报表程序ABAP里大量存在一次性数据修复程序、月度报表程序。这类程序的共同点是运行一次就完成使命业务规则明确后续不会再演进。对这种程序如果逻辑本身清晰用静态方法组织也无可厚非。重构成对象模式带来的演进收益没有发挥场景。不过即使在这种程序里如果计算逻辑复杂我仍然建议把核心计算抽取成对象化类因为这类程序往往会被复制、修改后用于类似场景。被复制本身就是一种维护对象化能让这种复制更安全。6.3 团队认知成本与现实节奏最后一个现实因素团队里不是每个人都有对象化思维。如果团队大部分人还在面向过程编程强行引入大量对象模式代码会变成“穿着对象外衣的过程代码”维护成本反而更高。我的建议是分两步走先在核心业务规则上做对象化改造做出示范和收益同时带着团队一起过代码评审讲解设计原因。不要一夜之间把代码库全量重写那既积累了技术债又制造了团队恐慌。渐进式重构让对象化的收益肉眼可见团队才会自发接受这种写法。我在实际项目中见过不少因为对象化改造失败的案例失败原因通常不是技术方案不对而是步子太大、团队没跟上。演进的前提是团队能理解演进后的代码否则只是换了一种写法继续混乱。最后再分享一个我的个人心得判断一段ABAP代码是否值得对象化不要看代码行数也不要看类名是否以CL开头而要看它面对需求变化时的反应。如果一次新需求会迫使你修改现有方法的签名会迫使你阅读整个方法体来确认影响范围那这个位置就是对象化的切入点。反之如果新需求只需要新增代码、不动旧代码那你的代码架构已经在为你服务了。能跑只是开始可演进才是ABAP开发的真正分水岭。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进