职责驱动设计及状态模式的融会贯通

一、需求

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

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

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

  • Shell
  • UShell

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

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

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

  • login (Master Board Shell)
  • inactive (Master Board UShell)
  • get (Slave Board Shell)
  • transfer(Master Board Shell)
  • put(Slave Board Shell)
  • deleteFiles(Slave Board Ushell)
  • active(Master Board UShell)
  • 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为接口提供了默认方法,已经可以规避这个问题了。

Image placeholder
IT头条
未设置
  26人点赞

没有讨论,发表一下自己的看法吧

推荐文章
领域驱动设计(DDD)高手养成记

开发出一款基于业务的高质量软件产品,一直是架构师与软件开发工程师为之努力的目标,之所以说是努力的目标,是因为这其中存在众多的难题。比如:如何将业务需求准确的转化为软件设计?如何能让团队人员在开发时能专

Spring WebFlux 的设计及工作原理剖析

前言 Spring5发布有两年了,随Spring5一起发布了一个和SpringWebMvc同级的SpringWebFlux。这是一个支持反应式编程模型的新框架体系。反应式模型区别于传统的MVC最大的不

设计模式: 策略模式

博客主页定义:策略模式定义了一系列的算法,并将每一个算法封装起来,而且使它们还可以相互替换。策略模式让算法独立于使用它的客户而独立变化。使用场景: 针对同一类型问题的多种处理方式,仅仅是具体行为有差别

适配器模式的三种形式

适配器模式,顾名思义,就是把原本不兼容的接口,通过适配,使之兼容。举个生活中简单的例子,以前的手机内存卡可以取出来,但是想和电脑之间传输音乐、视频等资料不能直接传输,需要通过USB读卡器,然后插入US

在 Laravel 中实现「福勒的货币设计模式」

“这个世界上有很大比例的计算机都在操纵金钱,因此,我一直感到困惑的是,金钱实际上并不是任何主流编程语言中的一流数据类型。缺乏类型会导致问题,这是最明显的周边货币。如果您所有的计算都是用一种货币完成的

Python 面向对象 高阶-描述符与设计模式笔记

描述符 当一个类中,包含了三个魔术方法(__get__,__set__,__delete__)之一,或者全部时,那么这个类就称为描述符类 作用 描述符的作用就是对一个类中的某个成员进行一个详细的

Golang设计模式系列开篇

课程推荐:GO开发工程师--学习猿地精品课程 概念设计模式这个术语是由ErichGamma等人在1990年代从建筑设计领域引入到计算机科学的。在《Domain-DrivenTerms》一书中,设计模式

如何设计 QQ、微信、微博、Github 等等,第三方账号登陆 ?(附表设计)

前言:多账户登陆1.创业初期用户名密码注册登陆手机号注册登陆2.数据库设计3.引入第三方账户方案4.数据库设计5.总结前言:多账户登陆互联网应用当中,我们的应用会使用多个第三方账号进行登录,比如:网易

ie加载不出css样式的原因有哪些?

ie加载不出css样式的原因有哪些?可能的原因如下:1.HTML页面编码与CSS编码不同(如HTML为gbk,CSS为utf-8);2.CSS文件中未指定@charset头声明,导致IE默认使用页面编

wmv是什么格式的?

课程推荐:web全栈开发就业班--拿到offer再缴学费--融职教育 WMV(WindowsMediaVideo)是微软开发的一系列视频编解码和其相关的视频编码格式的统称,是微软Windows媒体框架

Go语言高级编程_5.8 接口和表驱动开发

5.8接口和表驱动开发 在Web项目中经常会遇到外部依赖环境的变化,比如: 公司的老存储系统年久失修,现在已经没有人维护了,新的系统上线也没有考虑平滑迁移,但最后通牒已下,要求N天之内迁移完毕。 平台

如何通过测试驱动开发构建 Laravel REST API

这是TDD和敏捷开发方法学的先驱之一 JamesGrenning的名言 如果您不进行测试驱动的开发,那么您将进行后期调试-JamesGrenning 今天我们将进行测试驱动的Laravel之旅。我们

如何构建“小数据”驱动的泛场景智能应用体系?

张真百信银行首席技术架构师&AILab负责人目前负责基于自然语言的动态银行研究与落地,关注AI技术与金融,办公,生活场景的深度融入;开源软件UAVStack创始人,面向智能运维提供解决方案,AIOps

一个多业务、多状态、多操作的交易链路?闲鱼架构这样演进

前言双十一刚刚结束,成交额2684亿震惊全世界,每秒订单峰值达54.4W笔。在闲鱼2000万DAU,交易数额同样增长迅速的今天,我们如何保障交易链路的稳定与快速支撑业务?这篇文章从客户端开发的角度,介

go学习笔记-goroutine竞争状态

如果两个或者多个goroutine在没有相互同步状态的情况下同时访问某个资源,并且同时对这个资源进行读写的时候,对于这个资源就处于相互竞争状态(racecandition)。下面来看一个相互竞争的例子

返回状态码

200表示成功403访问被拒绝404页面没找到

1.0. 抽象工厂模式(Abstract Factory)

1.1.1.目的 在不指定具体类的情况下创建一系列相关或依赖对象。通常创建的类都实现相同的接口。抽象工厂的客户并不关心这些对象是如何创建的,它只是知道它们是如何一起运行的。 1.1.2.UML图 1

1.1. 建造者模式(Builder)

1.2.1.目的 建造者是创建一个复杂对象的一部分接口。 有时候,如果建造者对他所创建的东西拥有较好的知识储备,这个接口就可能成为一个有默认方法的抽象类(又称为适配器)。 如果对象有复杂的继承树,那么

1.3. 工厂方法模式(Factory Method)

1.3.1.目的 对比简单工厂模式的优点是,您可以将其子类用不同的方法来创建一个对象。 举一个简单的例子,这个抽象类可能只是一个接口。 这种模式是「真正」的设计模式,因为他实现了S.O.L.I.D原则

详解 PHP 中的三大经典模式

单例模式 单例模式的含义:作为对象的创建模式,单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统全局地提供这个实例。它不会创建实例副本,而是会向单例类内部存储的实例返回一个引用。单例模式的三

学习猿地开启IT在线课的精品模式

程序员的工作已经连续十年被国家评为“性价比”最高的职业,而这种技术岗位是需要有一定的技术功底才能从事的工作,需要长时间的、有目的学习积累,并且需要不断磨练,才可以胜任的工作。现在企业招聘程序员的要求越

学习猿地开创IT教育2.0模式

传统的学习方式都是进入班级,跟着老师面对面的学习。需要有几个硬性条件,就是在固定场地,并且在固定的时间里,按照老师安排的进度学习。基础不一样进度没办法调整、学习不好的跟不上会一直学习不好、有事旷课课程

详解 PHP 中的三大经典模式

单例模式 单例模式的含义:作为对象的创建模式,单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统全局地提供这个实例。它不会创建实例副本,而是会向单例类内部存储的实例返回一个引用。单例模式的三

Golang 里的 AES、DES、3DES 加解密,支持 ECB、CBC 等多种模式组合

Opensslencryption:OpenSSL库的功能包装,用于对称和非对称加密和解密。 AES-ECB AES-CBC DES-ECB DES-CBC 3DES-ECB 3DES-CBC 安

Go语言高级编程_1.6 常见的并发模式

1.6常见的并发模式 Go语言最吸引人的地方是它内建的并发支持。Go语言并发体系的理论是C.A.RHoare在1978年提出的CSP(CommunicatingSequentialProcess,通讯