看到重複就刪除? 小心不要看到黑影就開槍!
看到重複就刪除、看到黑影就開槍,這句話烙印在每個想寫出高品質軟體的工程師心中。
為什麼工程師會有這個執念呢?原因很簡單:如果特定功能的程式碼 (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等等。
- 金融網站會有:CreatePortofolioUsecase、CreateInsuranceUsecase等等。
在Clean Architecture中,最重要的三層分別為:
- Adapter Layer:針對http port、websocket port、message port等進行轉接,並呼叫usecase。針對usecase定義的output port進行轉接,並實作其定義的output port介面
- Application Layer:負責跟外部環境溝通、處理屬於應用程式的邏輯、delegate商業邏輯給entity。
- Entity Layer:負責封裝商業邏輯,確保狀態一致性。
以下為一個完整系統行為的架構圖:
圖中我們可以看到:
- 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)。