看到重複就刪除? 小心不要看到黑影就開槍!

Z-xuan Hong
22 min readSep 19, 2021

--

看到重複就刪除、看到黑影就開槍,這句話烙印在每個想寫出高品質軟體的工程師心中。

為什麼工程師會有這個執念呢?原因很簡單:如果特定功能的程式碼 (ex: 驗證邏輯、資料結構資訊)有大量重複,當特定功能需要擴充或修改錯誤時,工程師需要修改系統中每個有重複程式碼的地方,這是非常痛苦的事情。

舉個例子:如果有十個地方重複,就有十個地方需要修改,如果只修改了九個地方但另外一個地方忘記了,就會破壞既有系統行為,產生regression error

重複有以下缺點:

  • 降低系統可讀性。(特定功能的實作以不同形式散落在世界各地,造成資訊碎片化、難以理解)
  • 降低系統抽象化。(缺少穩定的抽象化來封裝重複程式碼)
  • 降低系統聚合力。(實作細節擴散到多個模組、降低各個模組之聚合力)
  • 增加系統耦合性。(實作細節擴散到多個模組、增加各個模組之間的耦合性)

重複的缺點相信大家都很清楚,因此就不再一一贅述。

今天不是要介紹刪除重複的優點,而是要介紹刪除重複的另外一個面向:過度刪除重複對於系統架構所造成的風險。

文章希望能幫助大家 (包括我)掌握當遇到重複時,如何用不同的設計方式與設計決策來解除煩人的重複問題,章節如下:

  • 了解怎麼樣算是真正的重複?
  • 了解如何有效重複利用不同層級的程式碼?
  • 了解為何過度刪除重複會傷害系統架構?
  • 透過具體例子闡明上述觀點與設計決策過程。

真正的重複?

Uncle bob在Clean Architecture書中第十六章Independence中提到假性重複與真正重複的概念。

真正重複:當一個區塊的重複程式碼修改時,其他區塊相同的重複程式碼也需要進行修改。

假性重複:兩段看起來相似的重複程式碼,實際上各自擁有不同改變的原因、頻率,兩者會朝著不同方向演化。

那麼要如何判斷真正重複,還是假性重複呢?我們可以透過DRY (Do not repeat youself)原則來協助我們進行判斷。

DRY最重要的概念在於:避免Domain Knowledge的重複,而不是程式碼的重複。

為什麼?先有責任,後有實作細節 (程式碼),過度關注重複程式碼,容易產生錯誤的抽象化類別 (責任分配錯誤),導致類別有多種改變的原因,違反SRP。

因此,當有重複程式碼出現時,我們先問問自己:這些重複程式碼是代表相同的概念嗎?、有違反DRY嗎?如果大家試試看,會發現大部分的回答都是:No。

這邊舉出一個單元測試的例子:撰寫單元測試時,我們會希望單元測試之間彼此獨立 (Isolation)。但當測試有重複程式碼出現時,大部分的人會把重複程式碼搬移到test setup。

範例如下:

public class Duplication {
private int left;
private int right;
private Sum sum;

@BeforeEach
void setup() {
left = 1;
right = 1;
sum = new Sum(left, right);
}


@Test
void sum_of_two_number() {
assertEquals(2, sum.getValue());
}

@Test
void sum_of_two_number_with_negative_sign() {
assertEquals(-2, sum.getNegativeValue());
}

private static class Sum {
private final Integer left;
private final Integer right;

public Sum(Integer left, Integer right) {
this.left = left;
this.right = right;
}

Integer getValue() {
return this.left + this.right;
}

Integer getNegativeValue() {
return -1 * getValue();
}
}
}

上述程式碼消除的是假性重複還是真正重複呢?答案是:假性重複。

為什麼?我們針對兩個測試所移除的重複程式碼 (測試資料、建立sum instance)是否代表相同的概念?是否擁有相同改變的頻率?

明顯是沒有的:因為兩個測試的目的、概念不同,所以擁有不同改變的頻率。

以下為將重複inline回來的程式碼:

public class RemoveDuplication {
@Test
void sum_of_two_number() {
int left = 1;
int right = 1;

var sum = new Sum(left, right);

assertEquals(2, sum.getValue());
}

@Test
void sum_of_two_number_with_negative_sign() {
int left = 1;
int right = 1;

var sum = new Sum(left, right);
assertEquals(-2, sum.getNegativeValue());
}
}

修改後的測試,不僅可讀性上升,彼此之間的耦合也大幅度下降,至於測試資料left與right、建立sum instance的程式碼只是意外的重複而已。

講完了重複的概念後,我們接下來從架構的角度探討重複的影響性…

刪除重複會降低系統架構的維護性?

Uncle bob在Clean Architecture書中提倡架構吶喊的概念,他認為架構應該要能夠反應軟體所要解決的問題。

購物網站的架構就應該要反映出系統中購物相關的行為、金融網站的架構就應該要反映出系統中金融相關的行為。

他採用Usecase的方式來捕捉使用者能夠對系統進行的操作、行為。舉例:

  • 購物網站會有:CreateShoppingCartUsecase、DeleteShoppingCartUsecase等等
  • 金融網站會有:CreatePortofolioUsecaseCreateInsuranceUsecase等等。

在Clean Architecture中,最重要的三層分別為:

  • Adapter Layer:針對http port、websocket port、message port等進行轉接,並呼叫usecase。針對usecase定義的output port進行轉接,並實作其定義的output port介面
  • Application Layer:負責跟外部環境溝通、處理屬於應用程式的邏輯、delegate商業邏輯給entity。
  • Entity Layer:負責封裝商業邏輯,確保狀態一致性。

以下為一個完整系統行為的架構圖:

usecase簡易架構圖

圖中我們可以看到:

  • adapter定義一組dto給webClient (瀏覽器)使用。
  • usecase定義一組input、output port給adapter使用。
  • entity定義商業邏輯給usecase使用。

一個完整的系統行為會以垂直的方式涵蓋adapter、application、entity,如下圖目錄結構:

系統由多個完整的行為組成,而每一組行為會有屬於他自己的adapter、application、entity。

Uncle Bob在書中建議架構應該要:針對usecase進行解耦合,也就是不要針對usecase之間進行重複利用,像是input重複利用、usecase之間的重複利用等等。

再解釋為什麼之前,先來談談一個蠻重要的概念:我們應該針對不同層級進行不同抽象化程度的重複利用 (different layers, reuse different level of abstraction)。

是什麼意思呢?上述介紹到一個usecase有adapter、usecase、entity,也介紹各個層級負擔的責任,當我們要reuse各層時,需要思考以下幾個問題:

  • 我們是否在reuse此層負擔的責任?

如果我們在usecase層想要reuse商業邏輯的話,代表違反了different layers, reuse different level of abstraction,需要將商業邏輯移動到適合的層級 (entity)後,再透過entity進行reuse。

  • 我們想要reuse的程式碼,是否代表相同的概念?還是只是意外重複?

如果我們想要reuse usecase層,那麼此usecase所代表的application logic是否跟新程式碼的application logic一樣?如果不一樣只需要新增一個全新的usecase即可。

如下圖所示:我們可以看到代表不同概念的usecase抽象化在一起的錯誤示範 (也就是沒聽Uncle bob叔叔的建議):

錯誤抽象化架構圖

圖片中可以看到,ChangeStudentNameUsecase與AddStudentEnrollmentUsecase耦合再一起,很明顯的我們知道違反了SRP。

那為何這樣會對系統造成傷害呢?

不同團隊可能維護若干個usecase,假設Team A維護UsecaseA、UsecaseB、UsecaseC,Team B維護UsecaseD、UsecaseE、UsecaseF。

假設情況比較極端,有人將所有usecase抽象化在一起 (這是現實發生的案例),那麼會造成什麼樣的後果?

  • Team A更新他維護的usecase會影響Team B。
  • Team B更新他維護的usecase會影響Team A。
  • 假設Team A在修改UsecaseA時,需要區分這段程式碼是屬於UsecaseA,還是屬於UsecaseB、UsecaseC、UsecaseD、UsecaseE、UsecaseF。
  • 假設Team B在修改UsecaseD時,需要區分這段程式碼是屬於UsecaseD,還是屬於UsecaseE、UsecaseF、UsecaseA、UsecaseB、UsecaseC。

上述你可能光用想的都覺得可怕,但儘管如此大家還是對刪除重複愛不釋手。

良好的架構應該要能夠:

  • 好讓多個團隊進行開發。
  • 好讓多個團隊進行部署。
  • 好讓多個團隊進行維運。
  • 好讓多個團隊測試自身開發的需求。

當過度抽象化、刪除重複程式碼時,將沒有辦法達到上述需求,這就是過度刪除重複對系統架構造成的傷害!!!

再舉一個更極端的例子:假設Problem Domain中有三十個entity,如果都用一個entity來代表會發生什麼事?(這是現實發生的案例 orz)

不要問,很可怕!!!

具體例子

假設系統提供一隻RESTful API用來更新學生資訊,目前有兩個客戶端 (Desktop、WebClient)使用此API。

程式碼如下圖:

class UpdateStudentDto {
private String name;
private Integer age; // primitive obsession code smell
private String email; // primitive obsession code smell

UpdateStudentDto(String name, Integer age, String email) {
this.name = name;
this.age = age;
this.email = email;
}

// getter省略
}

@RestController
class StudentController {
private final UpdateStudentUsecase updateStudentUsecase;

@Autowired
public StudentController(UpdateStudentUsecase updateStudentUsecase) {
this.updateStudentUsecase = updateStudentUsecase;
}

@PostMapping("/students")
public ResponseEntity<Long> updateStudent(UpdateStudentDto updateStudentDto) {
var input = new UpdateStudentInput(updateStudentDto.getName(), updateStudentDto.getAge(), updateStudentDto.getEmail());
Result<Long> result = this.updateStudentUsecase.execute(input);
return ResponseEntity.ok(result.getValue());
}
}

class UpdateStudentInput {
private String name;
private Integer age; // primitive obsession code smell
private String email; // primitive obsession code smell

UpdateStudentInput(String name, Integer age, String email) {
this.name = name;
this.age = age;
this.email = email;
}

// getter省略
}

class UpdateStudentUsecase {
private final StudentRepository studentRepository;
UpdateStudentUsecase(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}

Result<Long> execute(UpdateStudentInput updateStudentInput) {
var student = studentRepository.findStudentBy(updateStudentInput.getStudentId());
student.changeName(updateStudentInput.getName());
student.changeAge(updateStudentInput.getAge());
student.changeEmail(updateStudentInput.getEmail());
return Result.of(student.getId());
}
}

目前設計很合理,因為Desktop與Webclient都使用同樣的輸入資料、邏輯,如下圖:

假設公司為了增加Web使用者的用戶體驗,打算將UI從CRUD-Based模式轉換成Task-Based模式,那麼現在CRUD-Based RESTful API就不能滿足Task-Based fine-grained的需求了。

我們現在有兩條路可以選擇:

  • 繼續使用同一隻RESTful API。
  • 重新開發三隻RESTful API給Web Client使用。

先說答案:根據Uncle Bob對於架構的建議,我們要選擇後者:重新開發三隻RESTful API給Web Client使用。

此時你可能會想說:這樣不是會產生一堆dto物件、一堆input物件、一堆usecase物件嗎?

So what?類別的數量多不代表維護的難易度上升,相反的類別的數量少不代表維護的難易度下降。

重點是能否區分系統中的抽象化邊界,並且讓各個抽象化邊界內部的設計獨立演化

講到這邊大家可能還是不信邪 (跟當初的我一樣),那麼接下來給大家展示繼續使用同一隻API的範例程式 (只有usecase的部分,其餘程式碼一樣):

class UpdateStudentUsecase {
private final StudentRepository studentRepository;
UpdateStudentUsecase(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}

Result<Long> execute(UpdateStudentInput updateStudentInput) {
if (updateStudentInput.getEmail() != null && updateStudentInput.getAge() == null && updateStudentInput.getName() == null) {
var student = studentRepository.findStudentBy(updateStudentInput.getStudentId());
student.changeEmail(updateStudentInput.getEmail());
return Result.of(student.getId());
} else if (updateStudentInput.getName() != null && updateStudentInput.getAge() == null && updateStudentInput.getEmail() == null) {
var student = studentRepository.findStudentBy(updateStudentInput.getStudentId());
student.changeName(updateStudentInput.getName());
return Result.of(student.getId());
} else if (updateStudentInput.getAge() != null && updateStudentInput.getName() == null && updateStudentInput.getEmail() == null) {
var student = studentRepository.findStudentBy(updateStudentInput.getStudentId());
student.changeAge(updateStudentInput.getAge());
return Result.of(student.getId());
} else {
var student = studentRepository.findStudentBy(updateStudentInput.getStudentId());
student.changeEmail(updateStudentInput.getEmail());
student.changeName(updateStudentInput.getName());
student.changeAge(updateStudentInput.getAge());
return Result.of(student.getId());
}
}
}

大家有看出來噁心的地方嗎?原本非常簡單的UpdateStudentUsecase現在多了一大堆條件判斷式。

為什麼會變成這樣呢?因為在UpdateStudentUsecase這個context下有四種客戶端的force,分別為:

  • changeName
  • changeAge
  • changeEmail
  • updateStudent

當不同客戶端傳遞不同資料時,usecase就必須進行條件判斷去了解現在是哪個客戶端在使用它。在物件導向中,receiver不能跟sender有耦合 (提供服務的人不應該知道誰會使用此服務),會造成receiver難以reuse。此情況恰好違反物件導向的基本概念。

除了上述問題,還有以下缺點:

  • 測試性大幅度降低。
  • 開發者需要在大量邏輯判斷裡面找出他要修改的usecase,可能是上述四種其中一個。
  • 不同開發者彼此之間會互相耦合,修改也容易造成衝突。
  • 未來如果學生的資訊越來越多,此usecase的條件式會越來越多,此類別會非常unstable。
  • input會有一大堆的null value,因為不同context需要的資訊不同,違反SRP。

如果我們採用Uncle Bob建議的方式,修改後的程式碼如下 (只有changeName範例,其他修改大同小異,留給大家自行練習):

@RestController
class ChangeStudentNameController {
private final ChangeStudentNameUsecase changeStudentNameUsecase;

@Autowired
public ChangeStudentNameController(ChangeStudentNameUsecase changeStudentNameUsecase) {
this.changeStudentNameUsecase = changeStudentNameUsecase;
}

@PostMapping("/students/{studentId}/name")
public ResponseEntity<Long> updateStudent(@PathVariable Long studentId, ChangeStudentNameDto changeStudentNameDto) {
var input = new ChangeStudentNameInput(changeStudentNameDto.getName(), studentId);
Result<Long> result = this.changeStudentNameUsecase.execute(input);
return ResponseEntity.ok(result.getValue());
}
}

class ChangeStudentNameInput {
private String name;
private Long studentId;

ChangeStudentNameInput(String name, Long studentId) {
this.name = name;
this.studentId = studentId;
}

public String getName() {
return name;
}

public Long getStudentId() {
return studentId;
}
}

class ChangeStudentNameUsecase {
private final StudentRepository studentRepository;
ChangeStudentNameUsecase(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}

Result<Long> execute(ChangeStudentNameInput changeStudentNameInput) {
var student = studentRepository.findStudentBy(changeStudentNameInput.getStudentId());
student.changeName(updateStudentInput.getName());
return Result.of(student.getId());
}
}

修改後的程式碼,解決了上述提到的問題:

  • 一個usecase做一件事情,跟使用他的客戶端沒有耦合。
  • 非常好測試,沒有太多條件判斷式。
  • 不同開發者之間相互獨立,不會互相耦合。
  • 假設未來學生有新的資訊要記錄,只要新增usecase即可。
  • ChangeStudentNameUsecase變得非常簡單,遵守SRP。

後記

由於重複的恐懼深植我們心中,讓我們本能的看到重複就開槍,但經過今天的介紹,相信大家也了解適當、合理的重複反而有助於系統架構的整體維護性。

當遇到重複時,不仿思考下列問題,相信會有不同答案:

  • 此重複是真重複?還是假性重複?
  • 我們希望從哪一層開始重複利用呢?利用的程式碼是否跟此層保持相同的抽象化層級?
  • 我們重複利用的資訊是否唯一?是否真正違反DRY?
  • 移除重複後會不會降低可讀性?
  • 移除重複後會不會導致模組需要寫一大堆判斷去區別不同的客戶端?導致耦合到客戶端?
  • 移除重複後會不會導致開發者彼此影響?影響平行開發的能力?
  • 移除重複後會不會導致聚合下降?
  • 移除重複後會不會降低測試性?

結論:

  • 真正重複會共享同樣改變的頻率、相同的概念,假性重複擁有不同改變的頻率、不相同的概念。
  • 透過DRY原則,針對責任、概念而不是程式碼來判斷重複程式碼是否需要進一步處理。
  • 針對不同層級進行不同抽象化程度的重複利用。
  • 過度刪除重複會對系統架構造成傷害,降低架構的維運、開發、部署、測試性。
  • 設計決策要全面性,不能為了滿足特定的force (duplicate)卻導致其他force不平衡 (coupling、cohesion、readability、testability)。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

熱愛軟體開發、物件導向、自動化測試、框架設計,擅長透過軟工幫助企業達到成本最小化、價值最大化。單元測試企業內訓、導入請私訊信箱:t107598042@ntut.org.tw。

No responses yet