面向架構編程

  • 2020 年 12 月 12 日
  • 筆記

領域設計:聚合與聚合根一文中,提到了兩個導致設計與程式碼脫節的情況:

  • 程式碼未反映出軟體架構:架構圖說的是一回事,程式碼說的卻是另外一回事
  • 設計的表現力不足:設計沒有體現出某些約束,需要閱讀程式碼實現才能清楚具體的內容

領域設計:聚合與聚合根通過淘寶購物的例子說明了「設計的表現力不足」的問題。本文將通過《敏捷軟體開發:原則、模式與實踐》中保齡球計分軟體的例子來說明「程式碼未反映出軟體架構」的問題。

保齡球記分規則

在開始之前,我們需要了解需求,這裡就是「保齡球的記分規則」:

  • 保齡球一局比賽由10輪組成,在每輪比賽中,參賽者可以投擲兩次來嘗試擊倒所有的瓶子。
  • 如果參賽者一次即擊倒所有的瓶子,則稱為「全中」,並且本輪結束。
  • 如果參賽者第一次沒有擊倒所有的瓶子,第二次擊倒了所有的瓶子,則稱為「補中」。
  • 如果一輪比賽中,兩次投擲都沒有擊倒所有的瓶子,本輪也宣告結束。
  • 全中輪記分規則:本輪擊倒得到的10分,加上接下來的兩次投擲擊倒的瓶子數量,再加上前一輪的分數
  • 補中輪記分規則:本輪擊倒得到的10分,加上接下來的一次投擲擊倒的瓶子數量,再加上前一輪的分數
  • 其它輪記分規則:本輪兩次擊倒的瓶子數量,再加上前一輪的分數
  • 如果第十輪為全中,那麼參賽者可以多投兩次,以完成對全中的記分
  • 相應的,如果第十輪為補中,那麼參賽者可以多投一次,以完成對補中的記分。

初步設計

從上面的規則,我們可以得到初步的設計:

  • 一局比賽(Game)有10輪(Frame)
  • 每輪(Frame)有一到三次投擲(Throw)
  • 全中則是一次投擲
  • 其它為兩次投擲
  • 最後一輪如果全中或補中,則是三次投擲
  • 也就是說,遊戲最多可以投23次
  • 每輪的記分規則如下:
  • 全中輪:本輪10分+後兩次投擲得分+前一輪得分
  • 補中輪:本輪10分+後一次投擲得分+前一輪得分
  • 其它輪:本輪兩次投擲得分綜合+前一輪得分
  • 也就是說,遊戲得分即當前輪的得分

對象初步關係如下:

面向架構編程

 

《敏捷》中的程式碼

《敏捷》花了一章的內容來討論這個軟體的開發過程。初步設計如上圖所示,然後通過結對編程+TDD的方式一步步的進行程式碼演進(具體推導過程請閱讀《敏捷》,這裡不再贅述),最終得到的如下程式碼:

public class Game {
 private int itsCurrentFrame = 0;
 private boolean firstThrowInFrame = true;
 private Scorer itsScorer = new Scorer();
 public int score() {
 return scoreForFrame(itsCurrentFrame);
 }
 public void add(int pins) {
 itsScorer.addThrow(pins);
 adjustCurrentFrame(pins);
 }
 public int scoreForFrame(int theFrame) {
 return itsScorer.scoreForFrame(theFrame);
 }
 private void adjustCurrentFrame(int pins) {
 if (lastBallInFrame(pins)) {
 advanceFrame();
 } else {
 firstThrowInFrame = false;
 }
 }
 private boolean lastBallInFrame(int pins) {
 return strike(pins) || !firstThrowInFrame;
 }
 private boolean strike(int pins) {
 return (firstThrowInFrame && pins == 10);
 }
 private void advanceFrame() {
 itsCurrentFrame = Math.min(10, itsCurrentFrame + 1);
 }
}

public class Scorer {
 private int ball;
 private int[] itsThrows = new int[21];
 private int itsCurrentThrow = 0;
 public void addThrow(int pins) {
 itsThrows[itsCurrentThrow++] = pins;
 }
 public int scoreForFrame(int theFrame) {
 ball = 0;
 int score = 0;
 for (int currentFrame = 0; currentFrame < theFrame; currentFrame++) {
 if (strike()) {
 score += 10 + nextTwoBallsForStrike();
 ball++;
 } else if (spare()) {
 score += 10 + nextBallForSpare();
 ball += 2;
 } else {
 score += twoBallsInFrame();
 ball += 2;
 }
 }
 return score;
 }
 private int twoBallsInFrame() {
 return itsThrows[ball] + itsThrows[ball + 1];
 }
 private int nextBallForSpare() {
 return itsThrows[ball + 2];
 }
 private int nextTwoBallsForStrike() {
 return itsThrows[ball + 1] + itsThrows[ball + 2];
 }
 private boolean spare() {
 return (itsThrows[ball] + itsThrows[ball + 1]) == 10;
 }
 private boolean strike() {
 return itsThrows[ball] == 10;
 }
}
  • 一個Game類表示電競筆電身
  • 一個Scorer類用於計算Game的得分
  • 初始設計中的Frame和Throw都隱藏到了程式碼中

從程式碼本身來看,實現足夠簡單,變數名、方法名取得都有意義,符合開發原則,有完整的單元測試。但是,程式碼結構沒有體現出業務邏輯。

上面的程式碼結構如下:

面向架構編程

 

從這個類關係圖中,只能看出來有一個遊戲(Game)和這個遊戲的得分(Scorer)!這是從編程的角度一步步推導出來的程式碼,在推導的過程中可能是理所當然的,但是過了一段時間後,你再來看這段程式碼的時候,可能就不記得這段程式碼是幹嘛的了!

另一方面,當別人來接手這段程式碼時,你是否是先告訴他業務邏輯,然後讓他看程式碼?但是因為程式碼結構與設計的脫離,導致了雖然已經理解了業務邏輯、程式碼結構也很清晰,但是還是需要讀了源碼才能清楚這段程式碼具體是幹嘛的!這是否是增加了理解的難度?

原因就是這個結構關係沒有體現出業務邏輯!理想情況應該是在開發人員理解業務以後,從程式碼結構就可以理解具體的實現!

從業務推導

在保齡球記分邏輯中,是有輪(Frame)和投擲(Throw)這兩個概念的,所以在程式碼中需要保留這兩個類!

public class Frame {}
public class Throw {}

一局遊戲有十輪,所以在創建Game時就初始化十個Frame。同時,當前Frame的計算,需要前一個Frame的得分,所以除了第一個Frame,其它Frame都持有前一個Frame的引用,同時每個Frame都知道自己是第幾局(roundNum)!

public class Game {
 private static final int MAX_ROUND = 10;// 一局有十輪
 private Frame[] frameList = new Frame[MAX_ROUND];
 public Game() {
 for (int i = 0; i < MAX_ROUND; i++) {
 frameList[i] = new Frame(i);
 if (i > 0) {
 frameList[i].setPreFrame(frameList[i - 1]);
 }
 }
 }
}

public class Frame {
 private int roundNum; // 所在局,從0開始
 private Frame preFrame;
 public Frame(int roundNum) {
 this.roundNum = roundNum;
 }
 
 public void setPreFrame(Frame preFrame) {
 this.preFrame = preFrame;
 }
}

每一次投擲都會有擊倒數量,所以Throw中需要有欄位表示擊倒數量,同時因為一次投擲後,數量是不可修改的,所以數量由構造函數傳入,只有get方法而沒有set方法:

public class Throw {
 private int num; // 擊倒數量
 public Throw(int num) {
 this.num = num;
 }
 public int getNum() {
 return num;
 }
}

Frame可以包括1到3次Throw,而按照全中、補中、其它擊中的不同,記分方式也有所不同。如果完全按照這個邏輯編寫,程式碼會相對複雜。因為需要根據擊倒方式的不同,判斷是否要獲取後兩次的投擲。我們是否可以做一些調整?我們實際上是要計算投擲的得分,那麼這個投擲屬於哪一輪,是不是就不是那麼重要了?也就是說,投擲和記分規則可以調整為下面這樣:

  • 每輪(Frame)有一到三次投擲(Throw)
  • 全中為一次當前輪投擲+後兩次投擲
  • 補中為兩次當前輪投擲+後一次投擲
  • 其它為兩次投擲
  • 也就是說,遊戲最多可以投23次
  • 每輪的記分,為當前Frame投擲的得分的總和+前一輪的得分

現在Frame分數的計算就統一了!

public class Frame {
 private List<Throw> throwList = new ArrayList<>();
 public int score() {
 int throwScore = throwList.stream().mapToInt(it -> it.getNum()).sum();
 if (preFrame != null) {
 throwScore += preFrame.score();
 }
 return throwScore;
 }
}

最後,就是怎麼將一個Throw添加到Frame中,按照上面的設計調整,一次Throw可能既屬於當前輪,也屬於上一輪甚至上上輪!怎們樣來判斷呢?根據Frame是全中、還是補中還是其它來判定,所以Frame中需要有方法來判定自身是全中、補中還是其它!

public class Frame {
 private boolean isSpare() { // 是否是補中
 return throwList.size() >= 2
 && throwList.get(0).getNum() < 10
 && (throwList.get(0).getNum() + throwList.get(1).getNum() == 10);
 }
 private boolean isStrike() { // 是否是全中
 return throwList.size() >= 1 && throwList.get(0).getNum() == 10;
 }
}

一次Throw添加到Frame後,還要判斷這個Frame是否已經結束,即:

  • 如果這個Frame是全中或補中,是否已經包含了三次投擲
  • 如果這個Frame為普通擊倒,是否已經包含了兩次投擲
public class Frame {
 
 public boolean isFinish() {
 if (throwList.size() == 3) return true;
 if (throwList.size() == 2 && !isStrike() && !isSpare()) {
 return true;
 }
 return false;
 }
 
}

同時還要判斷,是否進入下一輪:

public class Frame {
 
 public int add(Throw aThrow) {
 this.throwList.add(aThrow);
 if (isStrike() || isSpare() || isFinish()) return Math.min(9, roundNum + 1);
 return roundNum;
 }
 
}

Game就是將Throw添加到當前輪和上一輪及上上輪的邏輯:

public class Game {
 public void add(int pins) {
 Throw aThrow = new Throw(pins);
 add2PreFrame(aThrow);// 根據邏輯判定是否要添加到上一輪,或上上輪
 currentFrameIdx = frameList[currentFrameIdx].add(aThrow); // 添加當前輪後,是否進入下一輪
 }
 private void add2PreFrame(Throw aThrow) {
 if (currentFrameIdx - 1 >= 0 && !frameList[currentFrameIdx - 1].isFinish()) {
 frameList[currentFrameIdx - 1].add(aThrow);
 }
 if (currentFrameIdx - 2 >= 0 && !frameList[currentFrameIdx - 2].isFinish()) {
 frameList[currentFrameIdx - 2].add(aThrow);
 }
 }
}

調整後的設計如下:

  • 一局比賽(Game)有10輪(Frame)
  • 一次投擲(Throw)得分可能屬於一到三輪(Frame)
  • 屬於當前輪
  • 如果前一輪是全中或補中,則此次投擲也屬於前一輪
  • 如果上上輪屬於全中,則此次投擲也屬於上上輪
  • 遊戲最多可以投擲23次
  • 每輪的記分,為當前Frame投擲的得分的總和+前一輪的得分

對應的類結構如下:

面向架構編程

 

此結構與設計相符和,只要理解了業務邏輯,順著業務就可以梳理出程式碼結構,即使不看源碼,也能猜到程式碼的邏輯!

《敏捷》中有效程式碼行數為71行,上面的有效程式碼為79行,多了8行程式碼!但是從理解上來看的話,後者更易於理解!完整程式碼見下文。

完整程式碼

public class Game {
 private static final int MAX_ROUND = 10;// 一局有十輪
 private Frame[] frameList = new Frame[MAX_ROUND];
 private int currentFrameIdx = 0;
 public Game() {
 for (int i = 0; i < MAX_ROUND; i++) {
 frameList[i] = new Frame(i);
 if (i > 0) {
 frameList[i].setPreFrame(frameList[i - 1]);
 }
 }
 }
 public int score() {
 return frameList[currentFrameIdx].score();
 }
 public void add(int pins) {
 Throw aThrow = new Throw(pins);
 add2PreFrame(aThrow);
 currentFrameIdx = frameList[currentFrameIdx].add(aThrow);
 }
 private void add2PreFrame(Throw aThrow) {
 if (currentFrameIdx - 1 >= 0 && !frameList[currentFrameIdx - 1].isFinish()) {
 frameList[currentFrameIdx - 1].add(aThrow);
 }
 if (currentFrameIdx - 2 >= 0 && !frameList[currentFrameIdx - 2].isFinish()) {
 frameList[currentFrameIdx - 2].add(aThrow);
 }
 }
 public int scoreForFrame(int theFrame) {
 return frameList[theFrame - 1].score();
 }
}
public class Frame {
 private int roundNum; // 所在局,從0開始
 private Frame preFrame;
 private List<Throw> throwList = new ArrayList<>();
 public Frame(int roundNum) {
 this.roundNum = roundNum;
 }
 public int score() {
 int throwScore = throwList.stream().mapToInt(it -> it.getNum()).sum();
 if (preFrame != null) {
 throwScore += preFrame.score();
 }
 return throwScore;
 }
 public int add(Throw aThrow) {
 this.throwList.add(aThrow);
 if (isStrike() || isSpare() || isFinish()) return Math.min(9, roundNum + 1);
 return roundNum;
 }
 public boolean isFinish() {
 if (throwList.size() == 3) return true;
 if (throwList.size() == 2 && !isStrike() && !isSpare()) {
 return true;
 }
 return false;
 }
 private boolean isSpare() {
 return throwList.size() >= 2
 && throwList.get(0).getNum() < 10
 && (throwList.get(0).getNum() + throwList.get(1).getNum() == 10);
 }
 private boolean isStrike() {
 return throwList.size() >= 1 && throwList.get(0).getNum() == 10;
 }
 public void setPreFrame(Frame preFrame) {
 this.preFrame = preFrame;
 }
}
public class Throw {
 private int num; // 擊倒數量
 public Throw(int num) {
 this.num = num;
 }
 public int getNum() {
 return num;
 }
}

總結

本文通過《敏捷》中保齡球的例子,來說明了程式碼不能體現設計的原因及提出一種保證程式碼和設計相一致的方法。

設計本身就是一種取捨,沒有完全正確的方法,只有適合的方法。從程式碼本身出發,能夠構建出符合編碼原則的程式碼,但是可能和設計本身有出入,這可能會增加後續的理解難度,變相增加了修改程式碼的難度;反之從設計觸發,能構建出和設計相匹配的程式碼,但是可能程式碼本身的易讀性、程式碼量、符合編碼原則上會有所妥協。

個人認為,對於業務邏輯不複雜,但是計算邏輯很複雜的程式碼,以按照程式碼原則來編寫程式碼為主,以按照業務邏輯編寫程式碼邏輯為輔,以保證程式碼的簡潔明了;而對於業務邏輯複雜,但是計算邏輯不複雜的程式碼,以按照業務邏輯編寫程式碼為主,以按照程式碼原則編寫程式碼為輔,以保證程式碼結構與業務邏輯的直觀匹配。

以上內容僅為個人觀點,歡迎探討!

參考資料

  • 《敏捷軟體開發:原則、模式與實踐》