张逸说

出口成张,逸派胡言

0%

职责驱动设计以及状态模式的变化

需求

针对某通信产品,我们需要开发一个版本升级管理系统。该系统需要通过由Java开发的管理后台,由Telnet发起向前端基站设备的命令,以获取基站设备的版本信息,并在后台比较与当前最新版本的差异,以确定执行什么样的命令对基站设备的软件文件进行操作。基站设备分为两种:

  • 主控板(Master Board)
  • 受控板(Slave Board)

基站设备允许执行的命令包括transfer、active、inactive等。这些命令不仅受到设备类型的限制,还要受制于该设备究竟运行在什么样的终端。类型分为:

  • Shell
  • UShell

对命令的约束条件大体如下表所示(不代表真实需求):

通过登录可以连接到主控板的Shell终端,此时,若执行enterUshell命令则进入UShell终端,执行enterSlaveBoard则进入受控板的Shell终端。在受控板同样可以执行enterUshell进入它的UShell终端。系统还提供了对应的退出操作。整个操作引起的变迁如下图所示:

执行升级的流程是在让基站设备处于失效状态下,获取基站设备的软件版本信息,然后在后端基于最新版本进行比较。得到版本之间的差异后,通过transfer命令传输新文件,put命令更新文件,deleteFiles命令删除多余的文件。成功更新后,再激活基站设备。因此,一个典型的升级流程如下所示:
0. login (Master Board Shell)

  1. inactive (Master Board UShell)
  2. get (Slave Board Shell)
  3. transfer(Master Board Shell)
  4. put(Slave Board Shell)
  5. deleteFiles(Slave Board Ushell)
  6. active(Master Board UShell)
  7. logout

整个版本升级系统要求:无论当前基站设备属于哪种分类,处于哪种终端,只要Telnet连接没有中断,在要求升级执行的命令必须执行成功。如果当前所处的设备与终端不满足要求,系统就需要迁移到正确的状态,以确保命令的执行成功。

寻找解决方案

根据这个需求,我们期待的客户端调用为(为简便起见,省略了所有的方法参数):

//client 
public void upgrade() {
TelnetService service = new TelnetService();
service.login();
service.inactive();
service.get();
service.transfer();
service.put();
service.deleteFiles();
service.active();
service.logout();
}

这样简便直观的调用,实则封装了复杂的规则和转换逻辑。我们应该怎么设计才能达到这样的效果呢?

使用条件分支

一种解决方法是使用条件分支,因为对于每条Telnet命令而言,都需要判断当前的状态,以决定执行不同的操作,例如:

public class TelnetService {
private String currentState = "INITIAL";
public void transfer() {
swich (currentState.toUpperCase()) {
case "INITIAL":
login();
currentState = "MASTER_SHELL";
break;
case "MASTER_SHELL":
// ignore
......
}
// 执行transfer命令
}
}

然而这样的实现是不可接受的,因为我们需要对每条命令都要编写相似的条件分支语句,这就导致出现了重复代码。我们可以将这样的逻辑封装到一个方法中:

public class TelnetService {
private String currentState = "INITIAL";
public void transfer() {
swichState("MASTER_SHELL");
// 执行transfer命令
}
private void switchState(String targetState) {
switch (currentState.toUpperCase()) {
case "INITIAL":
switch (targetState.toUpperCase()) {
case "INITIAL":
break;
case "MASTER_SHELL":
login();
break;
// 其他分支略
}
break;
// 其他分支略
}
}
}

switchState()方法避免了条件分支的重复代码,但是它同时也加重了方法实现的复杂度,因为它需要同时针对当前状态与目标状态进行判断,这相当于是一个条件组合。

Kent Beck认为:“(条件分支的)所有逻辑仍然在同一个类里,阅读者不必四处寻找所有可能的计算路径。但条件语句的缺点是:除了修改对象本身的代码之外,没有其他办法修改它的逻辑。……条件语句的好处在于简单和局部化。”显然,由于条件分支的集中化,导致变化发生时,我们只需要修改这一处;但问题在于任何变化都需要对此进行修改,这实际上是重构中“发散式变化(Divergent Change)”坏味道。

引入职责驱动设计

职责驱动设计强调从“职责”的角度思考设计。职责是“拟人化”的思考模式,这实际上是面向对象分析与设计的思维模式:将对象看作是有思想有判断有知识有能力的“四有青年”。这也就是我所谓的“智能对象”。只要分辨出职责,就可以从知识和能力的角度入手,寻找哪个对象具备履行该职责的能力?

回到版本升级系统这个例子,从诸如transfer、put等命令的角度思考职责,则可以识别职责为:

  • 执行Telnet命令
    • 迁移到正确的状态
    • 运行Telnet命令

TelnetService具有执行Telnet命令的能力,如果要运行的命令太多,也可以考虑将运行各个命令的职责再分派给对应的Command对象。那么,又该谁来执行“迁移到正确的状态”呢?看能力?——谁具有迁移状态的能力?一个对象能够履行某个职责,必须具备履行职责的知识,所以就要看知识。

迁移到正确状态需要哪些知识?——当前状态、目标状态以及如何迁移状态。只要确定了当前状态和目标状态,根据前面的状态变迁图就可以知道该如何迁移状态了。那么,谁确定地知道当前状态呢?——只有状态对象自身才知道!在条件分支实现中,状态是通过字符串表达的,字符串对象自身并不知道其值到底是什么,需要取出其值进行判断,这就是使用条件分支的原因。当状态从一个字符串升级为状态对象时,状态的值就是状态对象“自己知道”的知识。当每种状态都知道自己的状态值时,它们若要履行“迁移状态”的职责,就无需再对当前状态进行判断了,这正是为何多态能够替代条件分支的原因。

我们可以定义一个状态的继承树:

public interface NodeState {
void switchTo(???);
}
public class InitialState implements NodeState {}
public class MasterShellState implements NodeState {}

当状态变为对象且具有职责时,对象就是有思想的职能对象。遗憾的是,它具有的知识还不足以完全履行“迁移到正确状态”的职责,因为它并不知道该迁移到哪个目标状态。这个知识只有具体的Telnet命令才知道,因而需要传递给它。一种做法是作为方法参数传入,但这会导致方法体内需要对传入的参数作条件分支判断。另一种方法则利用方法的多态,显式地定义多种方法来履行迁移到不同目标状态的职责:

interface NodeState {
void switchToInitial();
void switchToMasterShell();
void switchToMasterUshell();
void switchToSlaveShell();
void switchToSlaveUshell();
}

public class InitialState implements NodeState {
public InitialState(TelnetService service) {
this.service = service;
}

public void switchToInitial() {
// do nothing
}

public void switchToMasterShell() {
service.login();
service.setCurrentState(new MasterShellState(service));
}

public void switchToMasterUshell() {
service.login();
service.enterUshell();
service.setCurrentState(new MasterUshellState(service));
}
public void switchToSlaveShell() {
service.login();
service.enterSlave();
service.setCurrentState(new SlaveShellState(service));
}

public void switchToSlaveUshell() {
service.login();
service.enterSlave();
service.enterUshell();
service.setCurrentState(new SlaveShellState(service));
}
}

public class MasterShellState implement NodeState {
public MasterShell(TelnetService service) {
this.service = service;
}

public void switchToInitial() {
service.logout();
service.setCurrentState(new InitialState(service));
}

public void switchToMasterShell() {
//do nothing
}

public void switchToMasterUshell() {
service.enterUshell();
service.setCurrentState(new MasterUshellState(service));
}

public void switchToSlaveShell() {
service.enterSlave();
service.setCurrentState(new SlaveShellState(service));
}

public void switchToSlaveUshell() {
service.enterSlave();
service.enterUshell();
service.setCurrentState(new SlaveShellState(service));
}
}

class TelnetService {
private NodeState currentState = new InitialState(this);
public void setCurrentState(NodeState state) {
this.currentState = state;
}
public void inactive() {
currentState.switchToMasterUshell();
//inactive impl
}
public void transfer() {
currentState.switchToMasterShell();
//real transfer impl
}

public void active() {
currentState.switchToMasterUshell();
// real active impl
}

public void get() {
currentState.switchToSlaveShell();
// get
}
}

这样的设计并没有做到“开放封闭原则”,当增加了新的状态时,由于需要在NodeState接口中增加新的方法,使得所有实现该接口的状态类都需要修改。这相当于从条件分支的“发散式变化”坏味道变成了“霰弹式修改(Shotgun Surgery)”坏味道,即一个变化引起多处修改。然而比起条件分支方案而言,由于不用再判断当前状态,复杂度降低了许多,可以有效减少bug的产生。

状态模式

将一个状态进化为对象,这种设计思想是状态模式的设计。根据GOF的《设计模式》,一个标准的状态模式类图如下所示:

当我们要设计的业务具有复杂的状态变迁时,往往通过状态图来表现。利用状态图,可以非常容易地将其转换为状态模式。状态图的每个状态被封装一个状态对象,所有状态对象实现同一个抽象接口。该抽象接口的方法则为状态图上触发状态迁移的命令。Context对象持有一个全局变量,用以保存当前状态对象。每个状态对象持有Context对象,通过Context访问全局的当前状态变量,以完成状态的迁移。具体的状态对象在实现状态接口时,倘若是不符合条件的命令,则实现为空,或者抛出异常。

依据状态图,可以实现为状态模式:

interface NodeState {
void login();
void logout();
void enterUshell();
void exitUshell();
void enterSlaveBoard();
void exitSlaveBoard();
}

public class InitialState implements NodeState {
private TelnetService telnetService;
public InitialState(TelnetService telnetService) {
this.telnetService = telnetService;
}
public void login() {
//login
telnetService.login();
this.telnetService.setCurrentState(new MasterShellState(telnetService));
}
public void logout() { //do nothing }
public void enterUshell() {
throw new IlegalStateException();
}
//其他方法略
}
// 其他状态对象略

在实现Telnet的transfer等命令时,这一设计却未达到意料的效果:

public class TelnetService {
private NodeState currentState = new InitialState();
public void setCurrentState(NodeState state) {
this.currentState = state;
}

public void transfer() {
// currentState到底是哪个状态?
if (!currentState.isMasterShell()) {
// 需要迁移到正确的状态
}
// transfer implementation
}
}

引入了状态模式后,在transfer()方法中仍然需要判断当前状态,这与条件分支方案何异?是状态模式存在问题吗?非也!这实际上是应用场景的问题。让我们联想一下地铁刷卡进站的场景,该场景只有Opened和Closed两个状态,其状态迁移如下图所示:

比较两个状态图。对于地铁场景,当地铁门处于Closed状态时,需要支付刷卡才能切换到Opened状态,如果不满足条件,这个状态将一直保持。也就是说,对于客户端调用者而言,合法的调用只能是pay(),如果调用行为是pass()或者timeout(),状态对象将不给予响应。版本升级系统则不然。当系统处于Initial状态时,系统无法限制客户端调用者只能发起正确的login()方法。因为提供给客户端的命令操作并非login()enterUShell()等引起状态变迁的方法,而是transfer、put等命令。同时,需求又要求无论当前处于什么状态,执行什么命令,都要迁移到正确的状态。这正是版本升级管理系统无法按照标准状态模式进行设计的原因所在。

结论

如果我们熟悉状态模式,针对本文的业务场景,或许会首先想到状态模式。然而,设计模式是有应用场景的,我们不能一味蛮干,或者按照模式的套路去套用,这是会出现问题的。通过分辨职责的设计方法,同时明确所谓“智能对象”的意义,我们照样可以推导出一个好的设计。我们虽然抽象出了状态对象,但抽象的方法并非引起状态迁移的行为,而是迁移状态的行为。我们没有从设计模式开始,而是从“职责”开始对设计进行驱动,这是职责驱动设计的设计驱动力。

当我们引入状态智能对象时,我们并没有获得一个完全遵循开放封闭原则的设计方案。实际上,当状态发生变化时,要做到对扩展完全开放是非常困难的。即使可行,在状态变化的需求是未知的情况下,为此付出太多的设计与开发成本是没有必要的。恰如其分的设计来满足当前的需求即可。当然,我们可以考虑将抽象的状态接口修改为抽象类,这样就可以把增加新方法对实现类带来的影响降低。不过,Java 8为接口提供了默认方法,已经可以规避这个问题了。