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

技術瑣話發表於2022-12-05

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

一、需求

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

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31562044/viewspace-2649713/,如需轉載,請註明出處,否則將追究法律責任。

相關文章