领域设计:聚合与聚合根一文中,提到了两个导致设计与代码脱节的情况:

  • 代码未反映出软件架构:架构图说的是一回事,代码说的却是另外一回事
  • 设计的表现力不足:设计没有体现出某些约束,需要阅读代码实现才能清楚具体的内容

领域设计:聚合与聚合根通过淘宝购物的例子说明了「设计的表现力不足」的问题。本文将通过《敏捷软件开发:原则、模式与实践》中保龄球计分软件的例子来说明「代码未反映出软件架构」的问题。

保龄球记分规则

在开始之前,我们需要了解需求,这里就是「保龄球的记分规则」:

  • 保龄球一局比赛由10轮组成,在每轮比赛中,参赛者可以投掷两次来尝试击倒所有的瓶子。
  • 如果参赛者一次即击倒所有的瓶子,则称为「全中」,并且本轮结束。
  • 如果参赛者第一次没有击倒所有的瓶子,第二次击倒了所有的瓶子,则称为「补中」。
  • 如果一轮比赛中,两次投掷都没有击倒所有的瓶子,本轮也宣告结束。
  • 全中轮记分规则:本轮击倒得到的10分,加上接下来的两次投掷击倒的瓶子数量,再加上前一轮的分数
  • 补中轮记分规则:本轮击倒得到的10分,加上接下来的一次投掷击倒的瓶子数量,再加上前一轮的分数
  • 其它轮记分规则:本轮两次击倒的瓶子数量,再加上前一轮的分数
  • 如果第十轮为全中,那么参赛者可以多投两次,以完成对全中的记分
  • 相应的,如果第十轮为补中,那么参赛者可以多投一次,以完成对补中的记分。

初步设计

从上面的规则,我们可以得到初步的设计:

  • 一局比赛(Game)有10轮(Frame)
  • 每轮(Frame)有一到三次投掷(Throw)
  • 全中则是一次投掷
  • 其它为两次投掷
  • 最后一轮如果全中或补中,则是三次投掷
  • 也就是说,游戏最多可以投23次
  • 每轮的记分规则如下:
  • 全中轮:本轮10分+后两次投掷得分+前一轮得分
  • 补中轮:本轮10分+后一次投掷得分+前一轮得分
  • 其它轮:本轮两次投掷得分综合+前一轮得分
  • 也就是说,游戏得分即当前轮的得分

对象初步关系如下:

面向架构编程-LMLPHP

《敏捷》中的代码

《敏捷》花了一章的内容来讨论这个软件的开发过程。初步设计如上图所示,然后通过结对编程+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都隐藏到了代码中

从代码本身来看,实现足够简单,变量名、方法名取得都有意义,符合开发原则,有完整的单元测试。但是,代码结构没有体现出业务逻辑。

上面的代码结构如下:

面向架构编程-LMLPHP

从这个类关系图中,只能看出来有一个游戏(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投掷的得分的总和+前一轮的得分

对应的类结构如下:

面向架构编程-LMLPHP

此结构与设计相符和,只要理解了业务逻辑,顺着业务就可以梳理出代码结构,即使不看源码,也能猜到代码的逻辑!

《敏捷》中有效代码行数为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;
 }
}

总结

本文通过《敏捷》中保龄球的例子,来说明了代码不能体现设计的原因及提出一种保证代码和设计相一致的方法。

设计本身就是一种取舍,没有完全正确的方法,只有适合的方法。从代码本身出发,能够构建出符合编码原则的代码,但是可能和设计本身有出入,这可能会增加后续的理解难度,变相增加了修改代码的难度;反之从设计触发,能构建出和设计相匹配的代码,但是可能代码本身的易读性、代码量、符合编码原则上会有所妥协。

个人认为,对于业务逻辑不复杂,但是计算逻辑很复杂的代码,以按照代码原则来编写代码为主,以按照业务逻辑编写代码逻辑为辅,以保证代码的简洁明了;而对于业务逻辑复杂,但是计算逻辑不复杂的代码,以按照业务逻辑编写代码为主,以按照代码原则编写代码为辅,以保证代码结构与业务逻辑的直观匹配。

以上内容仅为个人观点,欢迎探讨!

参考资料

  • 《敏捷软件开发:原则、模式与实践》
12-13 14:12