从电梯按钮开始状态机一下就不抽象了我一直觉得很多设计模式之所以让人读得头大不是因为它真的高深而是因为讲法太容易飘在半空。状态机就是个典型例子。单看术语finite-state machine、Mealy machine、Moore machine每个词都像认识拼在一起就像在念咒。可一旦把场景换成日常生活这个东西立刻就落地了。就拿写字楼电梯来说。早高峰时很多人挤在一楼等电梯。有人一来就冲过去按一下上行按钮过两秒再按一下旁边的人再补按一下仿佛按得越勤电梯就越快下来。可系统真正关心的往往不是按钮被按了多少次而是当前所处的状态。按钮第一次被按下时系统从「空闲」进入「已呼梯」状态后面重复按键大概率不会带来新的状态变化。状态机的要点就在这里同一个输入在不同状态下系统的反应可能完全不同状态切换由事件驱动状态与事件共同决定行为。有限状态机的通用定义本来就是系统在有限个状态之间依据输入和转移规则发生切换。UML 状态机还进一步把入口动作、出口动作、条件守卫都纳入了表达范围。(Wikipedia)这个视角一旦建立起来很多业务程序的混乱就有了一个更清晰的拆法。我们不再把系统理解成一坨IF ... ELSEIF ... ELSE ...的条件泥浆而是把它看成一个不断接收事件、检查当前状态、决定是否允许迁移、再执行动作的机器。状态机不是花里胡哨的学术词它其实是在帮我们回答一个特别实际的问题系统此刻处在什么阶段这个阶段允许做什么不允许做什么做完以后会走到哪里。状态模式在 ABAP 里不是装饰品很多人一提到设计模式就会本能地怀疑这套东西是不是更适合Java、C#这类语言跟ABAP的实际开发距离太远。这个怀疑很正常因为不少模式在SAP世界里确实已经被语言或者框架吸收掉一部分了。像事件处理ABAP通过SET HANDLER来注册事件处理器事件通过RAISE EVENT触发这种写法已经天然带着Observer Pattern的味道。再比如Web Dynpro它从框架层面就是按MVC思路组织的。至于远程代理这一类复杂模式SAP也早就通过SPROXY这类机制把很多脏活累活包好了。(Eduardo Copat)也正因为这样在ABAP里谈状态模式不该只是为了证明自己也会讲设计模式。真正有价值的地方在于它能不能把原本已经失控的业务流程整理成一个看得见、改得动、测得稳的结构。尤其是在老系统里很多流程类程序经过多年需求堆叠原始意图已经被无数个条件分支、复制粘贴和补丁式修复盖得严严实实。这个时候状态机的价值非常直接它能把业务规则重新画回一张状态图把行为从条件洪流里捞出来。说得再直白一点状态模式在ABAP里真正珍贵的地方不是OO味道有多浓而是它让我们终于可以把系统行为按「状态、事件、转移、动作」四个维度拆开。这四层一拆开程序就不再只是一堆代码而是一份行为说明书。一个真正有用的判断标准我一直不太相信那种只靠概念定义就能把模式讲明白的文章。对程序员来说最可靠的理解方式还是把一个模式塞进真实代码里再看看它在改需求的时候到底有没有帮上忙。状态模式也一样。真正的问题从来不是「我知不知道状态模式的定义」而是「当一个旧程序需要从A业务改造成B业务时这套做法能不能让我少改点少炸点少怀疑人生一点」。这也是为什么我特别认同把状态机和Domain Specific Language放在一起看。因为状态图本来就很像一门小型业务语言。系统有哪些状态进入某个状态要发什么命令收到某个事件之后会转去哪里这些规则一旦能写得像自然语言一样代码的阅读对象就不只是ABAP程序员连业务顾问都能大致看懂七八成。在这种思路下程序就可以分成两层。下面那层是通用骨架负责事件对象、状态对象、转移对象、控制器、外部系统接口这些技术结构。上面那层是业务配置负责描述一套具体系统的行为规则。下层追求复用上层追求可读。这样一来程序真正变化频繁的部分会集中在状态和规则的配置上而那些相对稳定的底层机制就可以沉到独立的Z类里。Mealy 和 Moore不用背概念用动作挂载位置来记很多人第一次遇到Mealy machine和Moore machine时都会有种被教科书迎面砸中的感觉。其实区分它们抓一条主线就够了。Moore machine的输出主要依赖状态动作往往绑定在状态进入这件事上。Mealy machine的输出则依赖状态加输入动作可以直接挂在某次转移上。UML 状态机同时支持两类特征一方面允许状态具有entry和exit动作另一方面也支持基于事件和条件的转移行为。(Wikipedia)拿自动门举例就很顺手。门处在Opening状态时进入状态就启动电机这种更像Moore。而门在Closed状态下只有收到open命令时才执行开门动作这里又带着Mealy的味道。现实里的系统往往不会那么教条它们经常两边都沾。程序设计里真正重要的也不是给一个系统硬贴标签而是把动作到底属于状态还是属于状态加事件这件事想明白。只要这个边界清楚代码就会稳很多。在老代码面前真正可怕的不是复杂而是脆讲状态机如果只讲模式本身文章会很容易写得漂漂亮亮但落不了地。真正落地的时候我们马上会撞上另一个问题老程序怎么改。我见过太多这样的代码原本一段循环跑空表下面又补了一段SELECT * INTO TABLE再复制一份几乎一样的循环逻辑只因为十几年前有人不敢碰旧代码只敢在下面追加新代码把事情糊住。也见过名字起得非常离谱的变量表面上像本地变量实际却是STATICS一旦调用方式从SUBMIT变成函数模块整个行为就变样。还见过弹窗逻辑散落在几十个地方屏幕坐标每个都不一样用户一路点下去弹框像在屏幕上跳舞。这种系统最麻烦的地方不是代码丑而是它已经变得异常脆弱。改一处另外两处跟着碎团队却慢慢对这种状况习以为常。久而久之大家会形成一种很保守的生存策略只改眼前那一厘米别碰周围任何东西。表面上是在控制风险实际是在把未来的风险滚得越来越大。这时候状态模式带来的好处就不止是结构优雅了。它会逼着我们重新审视那些隐藏在条件分支里的业务规则把原本模糊的行为边界重新画出来。而一旦行为边界清楚很多原来看起来必须复制六份的逻辑就会自然暴露出它其实应该被收口到一个地方。真正让人敢重构的不是勇气是单元测试很多人喜欢谈merciless refactoring听起来很燃。可如果系统没有测试保护网这种做法在生产项目里跟裸奔差不多。状态机框架之所以值得做不只是因为它能复用还因为它天生适合测试。原因很简单。状态机程序的核心是确定性的。给定初始状态给定事件序列目标状态和输出动作应该是可预期的。这样的东西特别适合写单元测试。测试不需要关心界面怎么弹也不需要关心数据库怎么存它只关心系统在这个状态下收到这个事件会不会迁移会迁到哪里会不会发命令会不会抛异常。这里还有一个特别现实的问题。只要开始认真写测试就会被迫把用户界面和核心逻辑隔离开。因为单元测试里不能真去弹popup也不能让程序在测试跑到一半突然冒个消息框。于是用户交互就得抽出去错误场景更适合转成异常再由外层决定是弹消息、记日志还是在测试里断言捕获。这个约束看起来烦实际是在逼我们把边界画清楚。很多代码一旦能被测试它的设计通常也会跟着变干净。状态机框架抽象到哪一步才刚好做这种框架最容易犯的错有两个。一个是抽象不够结果每写一个新状态机程序都要从头再来一遍。另一个是抽象过头把本来简单的事搞成一套谁都不敢碰的元编程装置。比较合适的做法是把真正稳定的公共部件提炼出来。事件类状态类转移类控制器类系统重置类外部系统接口这些都属于基础设施。它们处理的是状态机运行机制不是某个具体业务的规则。至于某个程序里到底有哪些状态状态之间如何流转哪些转移带守卫条件哪些状态进入时要向外部系统发命令这些则属于业务配置层。尤其是guard expression这种东西非常值得在框架层预留。因为很多业务规则都不是简单的「收到事件就转」。常见情况是事件来了没错但还得看库存、权限、时间窗口、对象属性条件满足才能走那条箭头。UML 状态机本来就支持这种守卫条件。(Wikipedia)拿糖果机这个经典例子来说系统不能只因为手柄被拉动就无脑执行出货。还得先判断库存是不是大于零。这个检查如果散落在业务代码里后面就会到处出现重复判断。可如果把它提升为框架支持的边界条件或者守卫条件程序就会清楚很多某个状态在某个事件下是否允许转移会变成一个显式规则而不是埋在深处的条件语句。让代码像业务句子一样说话我一直觉得很多OO程序之所以看起来难读不是因为面向对象本身难而是因为命名太技术化。TYPE REF TO、INHERITING FROM、io_、ro_、mt_这类标记对技术人员当然有帮助可一旦业务规则层也被这种东西淹没代码就很难再像一门业务语言。状态机特别适合做这个清理。因为它天然是由短句组成的。某个状态到达时发送某个命令。某个状态在收到某个事件后切换到另一个状态。某个事件在某个条件下无效。只要把命名调整好这些句子完全可以读得像英文配置甚至接近伪代码。这类写法的妙处不只是好看。更重要的是它降低了业务规则被误读的概率。一个程序里最常变的从来不是事件类的基类怎么写而是规则本身。规则如果写得像自然语言后续每次改需求团队理解成本都会低很多。一个可复用的 ABAP 状态机骨架下面这些代码片段正好体现了这种思路。先把公共概念提纯再把行为配置挂上去。抽象事件类CLASS lcl_abstract_event DEFINITION.PUBLICSECTION.METHODS: constructor IMPORTING id_nameTYPEstring id_codeTYPEchar04,get_nameRETURNINGvalue(rd_name)TYPEstring,get_codeRETURNINGvalue(rd_code)TYPEchar04.PRIVATE SECTION.DATA: md_nameTYPEstring,md_codeTYPEchar04.ENDCLASS.Abstract Event Definition这段代码传达的意图很清楚事件至少要有两个维度一个是给人看的名称一个是给机器识别的代码。放到真正的框架里我更倾向于把这种getter收掉改成公开只读属性。因为在ABAP OO里这种读访问没必要再绕一层方法。真正重要的不是get_name这类样板方法而是把事件对象作为状态转移的稳定输入。外部系统接口INTERFACE lif_external_system.METHODS: poll_for_eventRETURNINGvalue(rd_event)TYPEstring,send_command IMPORTING id_commandTYPEstring.ENDINTERFACE.这层抽象很关键。它把状态机核心和外部设备、外部系统隔开了。现实里外部系统可能是门禁、设备控制器、第三方接口也可能只是测试替身。接口一旦存在状态机就能专心处理规则而不用知道外面到底连的是什么。状态类CLASS lcl_state DEFINITION.PUBLICSECTION.METHODS: constructor IMPORTING id_nameTYPEstring,*--------------------------------------------------------------------**Define Behaviour*--------------------------------------------------------------------*state_reached_sends_command IMPORTING io_commandTYPEREFTOzcl_sm_command_out,state_changes_after_event IMPORTING io_eventTYPEREFTOzcl_sm_event_in io_target_stateTYPEREFTOlcl_state,*--------------------------------------------------------------------**ExecuteBehaviour*--------------------------------------------------------------------*changes_state_after_event IMPORTING id_event_codeTYPEstringRETURNINGvalue(rf_bool)TYPEabap_bool,target_state_after_event IMPORTING id_event_codeTYPEstringRETURNINGvalue(rf_target_state)TYPEREFTOlcl_state,send_outbound_commands IMPORTING io_external_systemTYPEREFTOzif_sm_external_system.PRIVATE SECTION.DATA: md_nameTYPEstring,mt_outbound_commandsTYPESTANDARDTABLEOFREFTOzcl_sm_command_out,mt_transitionsTYPEg_tt_transitions.ENDCLASS.State Definition这里已经能看出状态机的骨架了。状态不仅保存名称还维护两块核心数据一块是达到这个状态时需要发出的外部命令另一块是从这个状态出发可走的转移集合。真正业务化以后方法名完全可以继续往自然语言方向推像state_changes_after、to_target_state这类命名会比技术前缀更接近规则表达。转移类CLASS lcl_transition DEFINITION.PUBLICSECTION.METHODS: constructor IMPORTING io_source_stateTYPEREFTOlcl_state io_trigger_eventTYPEREFTOlcl_event io_target_stateTYPEREFTOlcl_state,get_sourceRETURNINGvalue(ro_source)TYPEREFTOlcl_state,get_targetRETURNINGvalue(ro_target)TYPEREFTOlcl_state,get_triggerRETURNINGvalue(ro_trigger)TYPEREFTOlcl_event,get_event_codeRETURNINGvalue(ro_code)TYPEchar04.PRIVATE SECTION.DATA: mo_source_stateTYPEREFTOlcl_state,mo_target_stateTYPEREFTOlcl_state,mo_trigger_eventTYPEREFTOlcl_event.ENDCLASS.Transition Definition转移类其实是在给状态图里的箭头建模。箭头从哪里来被什么事件触发要到哪里去这三件事必须放在一起看。只要这个对象清楚后续加守卫条件、加异常处理、加日志追踪都会顺很多。控制器类CLASS lcl_controller DEFINITION.PUBLICSECTION.METHODS: constructor IMPORTING io_external_systemTYPEREFTOzif_sm_external_system io_system_resetterTYPEREFTOzcl_sm_system_resetter,handle_command IMPORTING io_event_codeTYPEstring,get_current_stateRETURNINGvalue(ro_state)TYPEREFTOzcl_sm_state.PRIVATE SECTION.DATA: mo_external_systemTYPEREFTOzif_sm_external_system,mo_system_resetterTYPEREFTOzcl_sm_system_resetter,mo_current_stateTYPEREFTOzcl_sm_state.METHODS: transition_to IMPORTING io_target_stateTYPEREFTOzcl_sm_state.ENDCLASS.Controller Definiton控制器的职责也很纯粹它维护当前状态接收入站事件判断是否需要迁移并在迁移时协调状态变化与外部动作。真正落地时这一层最好别掺杂业务细节它更像调度中心。这种设计真正妙的地方不在类多而在变化被关起来了很多人一看到这种代码会立刻皱眉类是不是太多了。这个反应很正常。单看文件数量肯定比传统一锅炖式写法更多。可这套做法的收益不在于类越多越高级而在于变化被关进了正确的盒子里。外部系统变化改接口实现。状态图变化改配置。转移规则变化改守卫条件。输出策略变化改命令对象。测试场景变化换替身。每种变化都有自己的落点不再把所有问题都挤到一个FORM或一个超长方法里。这才是OO在这里最有价值的地方。不是为了炫技不是为了把程序写得像论文而是为了把「会变的东西」和「相对稳定的东西」分家。只要这个分家做对了下一次需求变更系统就不至于像碰一下就碎的古董瓷器。把状态模式放回 SAP 开发现场如果把视野拉回SAP开发现场这种思路的适用面其实比想象中广。门禁、审批流、单据生命周期、设备交互、接口重试、队列消费、批处理阶段控制这些都天然带状态。很多项目里之所以没把它们写成状态机不是因为不适合而是因为历史代码一开始就按顺序流程堆起来了后面越补越重最后谁都不敢拆。真正高明的改法不是一次性把整个程序推倒重来而是在每次改需求的时候顺手把最容易复用、最容易看清、最值得测试的那一小块抽出来。今天把重复的状态迁移判断收一次明天把外部命令发送隔离一次后天把用户交互和核心逻辑拆一次。每次都只往更清晰的方向挪一点点程序就会慢慢从Aunty Fragile变成没那么脆的东西。状态机给我们的不只是一种模式更像是一种整理行为的视角。它逼着我们承认一件事复杂系统之所以难维护很多时候不是因为业务太复杂而是因为业务状态被淹没在技术实现里。只要把状态重新捞出来很多混乱都会开始自动分层。程序不一定会立刻变短但会变得更诚实。它会更明确地告诉我们系统现在在哪为什么能走为什么不能走走完以后会变成什么样子。而一旦代码能把这些事讲清楚后面的重构、测试、复用都会开始变得顺理成章。