架構

職責驅動設計及狀態模式的融會貫通

一、需求

針對某通信產品,我們需要開發一個版本升級管理系統。該系統通過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為接口提供了默認方法,已經可以規避這個問題了。

最干貨的java+分布式技術公眾號,兼及研發管理。本號專家陣容:螞蟻金服右軍、易寶CTO陳斌、米么金服總監李偉山、奧琪金科首席架構曲健、螞蟻金服高級技術專家張翔、美團高級技術專家楊彪等。

TensorFlow技術主管Peter Wardan:機器學習的未來是小而美

上一篇

全球經濟視野下的智能博弈:百度AI,沖向天穹

下一篇

你也可能喜歡

職責驅動設計及狀態模式的融會貫通

長按儲存圖像,分享給朋友

ITPUB 每周精要將以郵件的形式發放至您的郵箱


微信掃一掃

微信掃一掃
大丰收注册
七乐彩开奖走势图表 pk10最牛稳赚5码计划两期 安徽地方麻将下载 广东好彩1是属于福利彩票吗 股票配资推荐 金融权重股指的是哪些 河南快赢481开奖视频直播 闲来江西麻将手机版下载 江西11选5开奖记录 浙江20选5标准版走势图 探球比分即时篮球比分 欢乐捕鱼人赢话费 贵州奕乐捉鸡麻将 十分快三人工计划网 澳洲幸运10的玩法 大赢家比分网