從Clean Architecture角度探討RESTful API Contract之設計

Z-xuan Hong
16 min readAug 28, 2021

--

現在的網頁為了應付前後端的複雜度,幾乎都會使用前後端分離的技術,由於前後端分離的關係,我們需要一種機制讓前後端有效溝通,RESTful API就是很常見的溝通方法。

RESTful API的概念很簡單:定義出適當的resource,透過url去identify resource,並且使用http method (POST、PUT、DELETE、GET)去操作對應的resource。

在網頁中,我們填寫一些表單、更新狀態時,通常會用到POST、PUT等http method,而我們填寫的資料,會以request body的方式傳送給後端去處理。

這些request body是前後端彼此之間定義的Data Contract,前端為Data Contract的Consumer、後端為Data Contract的Provider

Data Contract:Data指的是request body內的資料結構,Contract指的是前後端定義好的格式,是前後端邊界的連接處,不能隨意變動。

如何設計request body有非常多的面向:

  • 如何從Consumer的角度設計,而不是Provider角度。
  • 如何從系統Usecase的角度設計,讓客戶端資料的準備變得更容易。
  • 如何設計較穩定的Contract,讓Contract之間獨立演化。

今天想要探討的是第三點:如何設計穩定的Contract,讓Contract之間獨立演化。

再討論之前,先給一個範例,在文章中會針對此範例進行討論。

@RestController
public class StudentController {

@PostMapping("/students")
public ResponseEntity<?> register(@RequestBody RegisterStudentDto registerStudentDto) {
// register student with its usecase...
}

@PutMapping("/students")
public ResponseEntity<?> editStudent(@RequestBody EditStudentDto editStudentDto) {
// edit student with it usecase...
}

private static class EditStudentDto {
private String name;
private String address;


private EditStudentDto(String name, String address) {
this.name = name;
this.address = address;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getAddress() {
return address;
}

public void setAddress(String address) {
this.address = address;
}
}

private static class RegisterStudentDto {
private String name;
private String email;
private String address;

private RegisterStudentDto(String name, String email, String address) {
this.name = name;
this.email = email;
this.address = address;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}

public String getAddress() {
return address;
}

public void setAddress(String address) {
this.address = address;
}
}
}

上述範例設計如下:

  • 使用者透過Post /students來註冊新學生,其Data Contract為RegisterStudentDto
  • 使用者透過Put /students來更新學生資訊,其Data Contract為EditStudentDto

上述兩個Data Contract,一個是RegisterStudentDto,另外一個是EditStudentDto。你會發現,他們兩者非常相似,差別只在於一個有email屬性而另一個沒有。

此時,你可能會想刪除重複、抽象化這些Data Contract…

而關於Data Contract的設計,我們有三種方式:

  • Explicit Duplicate Data Contract
  • Explicit Non-Duplicate Data Contract
  • General Data Contract

我們將一一進行探討…

Explicit Duplicate Data Contract

針對每組RESTful API設計一組Data Contract,就算Contract之間有重複程式碼,也不需要進行特別的處理。

這種設計方式,是Uncle Bob在Clean Architecture書中倡導的概念:每一組usecase會對應一組input data,input data之間的重複不需要理會,他認為這是假性重複

這邊我們把usecase換成RESTful endpointinput data換成request body

看到這邊你心中可能會有一些疑問:

  • 這樣不是會有一堆重複程式碼嗎?
  • 什麼叫做假性重複?
  • 為什麼不能多個RESTful endpoint共用一組request body?

先說答案:每組usecase對應一組input data的做法,能讓input data有效model使用者行為 (explicit),刻意重複程式碼 (duplicate),能讓usecase之間彼此獨立演化。

說完答案了,我們來談談為什麼,先從重複的概念談起…

不管是初學者、老手,都一定知道刪除重複程式碼的重要性,這是系統能穩定維護的關鍵,但你知道為什麼重複不好嗎?刪除重複程式碼為什麼能改善系統?

重複程式碼最大的問題如下:

  • 耦合:模組與模組之間,如果有重複程式碼,會造成資訊散落在這些模組,導致這些模組coupled在一起。也就是說:當其中一個模組修改時,其他模組也需要跟著修改。
  • 抽象化:當系統中有重複程式碼且會coupled多個模組時,代表系統缺少一個穩定抽象化來將這些資訊封裝成物件。此物件能有效代表problem domain中的要解決的問題,也能提升系統可讀性。

因此去除重複的最終目的是:降低系統之間的耦合、提高系統的抽象化程度。

Uncle Bob所說的假性重複並不會造成系統之間耦合,也沒有隱藏的抽象化物件。

我們用範例中兩個Data Contract進行說明:

  • RegisterStudentDto與EditStudentDto的重複是否會造成系統之間的耦合?

在我們的例子中:註冊功能與編輯功能明顯是兩種不同的責任,我們不希望修改註冊時影響編輯、修改編輯時影響註冊,因此重複反之能達到我們想要的效果。

  • RegisterStudentDto與EditStudentDto的重複是否會有隱藏的抽象化沒有被發現?

我們沒辦法從RegisterStudentDto與EditStudentDto中發現更高階的抽象化,因為註冊與編輯這兩個概念已經被清楚的表達了。

Explicit Non-Duplicate Data Contract

針對每組RESTful API設計一組Data Contract,Contract之間如有重複程式碼,需要進行特殊處理。

跟第一種一樣透過Explicit的方式來表達使用者行為,但不同的是,會將重複程式碼移除,並且讓Data Contract之間互相shared。

範例程式碼:

private static class NameAndAddress {
private String name;
private String address;

private NameAndAddress(String name, String address) {
this.name = name;
this.address = address;
}

public String getName() {
return name;
}

public String getAddress() {
return address;
}

public void setName(String name) {
this.name = name;
}

public void setAddress(String address) {
this.address = address;
}
}

private static class EditStudentDto extends NameAndAddress {

private EditStudentDto(String name, String address) {
super(name, address);
}
}

private static class RegisterStudentDto extends NameAndAddress {
private String email;

private RegisterStudentDto(String name, String email, String address) {
super(name, address);
this.email = email;
}

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}
}

從上述程式碼中,我們看到:將重複程式碼抽成NameAndAddress物件,並且讓Register與Edit去shared。

但有以下缺點:

  • Shallow Class:當為了刪除重複而刪除重複,容易產生類別名稱與實作細節一致的類別,此類別能負責的事情很少,並且非常不穩定 (因為是實作細節的一部分),沒辦法有效代表系統的抽象化,好的類別名稱要代表What而不是How (很明顯NameAndAddress就是How)
  • Context Fragmentation:當我們看Register與Edit時,沒辦法清楚獲得Context的全貌,降低可讀性。因為部分Context被父類別隱藏。如果是好的抽象化,父類別所封裝的會是高階抽象步驟,能從父類別看清楚Context的全貌 (ex: template method),NameAndAddress則明顯不行。
  • Coupled Contract:Contract之間會被其他Contract客戶端的需求影響。假設今天使用編輯的客戶端想要修改需求:限制名稱不能更新 (刪除name屬性),那麼會間接影響註冊的功能 (因為他需要name屬性)。

General Data Contract

針對多組RESTful API設計一組可重複利用的Data Contract,降低任何重複程式碼,節省類別數量。

這種方式跟上述兩種不同,較為implicit,他想要把多個Contract抽象化成相同的概念。

在維護過這種設計後,我想說:千萬不要使用此方式去設計Data Contract,會大大降低系統整體維護性

範例程式碼:

private static class StudentDto {
private String name;
private String address;
private String email;

private StudentDto(String name, String address, String email) {
this.name = name;
this.address = address;
this.email = email;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getAddress() {
return address;
}

public void setAddress(String address) {
this.address = address;
}

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}
}

看完上述程式碼,你可能會想說:哇,這樣只有一個類別要維護,程式碼變得非常精簡!!!

我們接著看下去…

假設我們需要提供一組RESTful API讓學生去註冊課程,需要的資料如下:

  • courseId

為了general、為了reuse,我們把courseId加到StudentDto,修改後如下:

private static class StudentDto {
private String name;
private String address;
private String email;
private Long courseId;

private StudentDto(String name, String address, String email, Long courseId) {
this.name = name;
this.address = address;
this.email = email;
this.courseId = courseId;
}

// getter setter省略...
}

非常好,不是嗎?

之後,我們需要針對學生註冊完成後的課程進行評分,需要的資料如下:

  • enrollmentId
  • grade

為了general、為了reuse,我們把enrollmentId、grade加到StudentDto,修改後如下:

private static class StudentDto {
private String name;
private String address;
private String email;
private Long courseId;
private Long enrollmentId;
private String grade;

private StudentDto(String name, String address, String email, Long courseId, Long enrollmentId, String grade) {
this.name = name;
this.address = address;
this.email = email;
this.courseId = courseId;
this.enrollmentId = enrollmentId;
this.grade = grade;
}
// getter setter省略...
}

上述程式碼有以下缺點:

  • God Data Contract:當經過越多的iteration,Data Contract會越來越肥大,最終變成God Data Contract,一個Data Contract有上百個欄位都是很常見的現象,缺點應該不用多說。
  • Implicit Context:系統中有註冊學生、編輯學生資料、註冊課程、修改課程分數等概念,但卻意圖用一個類別去代表這些概念。試著想想當要修改編輯學生功能時,開發者怎麼知道哪些屬性屬於編輯學生資料?導致我們需要從幾百個屬性中,分辨哪幾個是概念A、哪幾個是概念B、哪幾個是概念C。會嚴重降低開發的速度。
  • Different Rate of Change:當把多種概念混合在一起時,不同客戶端會彼此互相影響。舉個例子:註冊要求name、address、email為non-null、編輯要求name、address為non-null、註冊課程要求course為non-null、修改課程分數要求enrollmentId、grade為non-null,試問要如何驗證資料?開發者只能寫一大堆判斷條件,判斷目前屬於哪種功能的範疇,需要使用哪種驗證方式。許多框架提供的Validation屬於宣告式的設計,沒辦法有效處理這麼複雜的條件(就算有,可讀性也大幅度降低),因此讓我們沒辦法有效利用框架便利性。
  • Client ambiguous:前端要如何知道哪些屬性的是屬於哪些功能?Data Contract應該要從Consumer的角度定義,而不是從Provider的角度。
  • OCP violation:由於企圖抽象全部功能,導致介面非常不穩定 (介面沒辦法有效Closed),因為沒辦法透過擴充的方式 (加入新的Data Contract)來擴充功能,只能透過修改的方式 (增加新的屬性、介面到Data Contract上)來擴充功能。

經過三種設計的分析後,我認為第一種方式最好,能夠清楚表達使用者功能、降低不同RESTful API彼此之間的耦合。

三種API設計

Interface Segregation, Single Responsibility

ISP:如果強迫客戶端使用他不需要的介面,會造成多個客戶端之間相互耦合。

SRP:如果把多種改變的原因放在一起,會讓這些功能彼此互相影響。

Uncle Bob在Clean Architecture建議的設計方式能夠有效滿足ISP與SRP。

我們不希望不同RESTful API的客戶端彼此耦合 ( violate ISP)、不希望代表不同功能的RESTful API互相影響 (violate SRP)。

結論:

  • 刪除重複是為了降低耦合,提高系統抽象化。
  • Uncle Bob說的假性重複,不會造成耦合、抽象化的問題。
  • Explicit Duplicate Data Contract有效滿足ISP與SRP,能讓系統演進更穩定。
  • Explicit Non-Duplicate Data Contract會產生Context Fragmentation的問題,讓開發者沒辦法看清問題的全貌。
  • General Data Contract是一種Anti-Pattern,會把所有概念混合在一起,也會讓介面演進非常不穩定,最終影響系統整體維護性。

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

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

Responses (1)