【Spring Boot】第5課-實作三層式架構的 Service 與 Repository #2024 年更新


https://unsplash.com/photos/LmyPLbbUWhA

第 3 課第 4 課,我們只有 Controller 這個地方,能撰寫程式來處理請求。然而若全部都寫在這裡,整個類別就會很龐大。

本文將介紹「三層式架構」的目標,並搭配範例專案,點出當前做法所隱含的問題。接著進行修改,進一步將程式碼片段抽離到 Service 與 Repository 兩個層次,讓讀者體會切分的過程與思路。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch4


一、範例專案概觀

在正式介紹三層式架構前,先讓我們稍微認識目前的練習用專案。

以下是 Controller 的其中兩支 API,用途是建立和取得產品資料。本文並未串接真實的 DB,故以 Java 的 Map 資料結構代替。其中 key 為產品 id,而 value 為整筆資料。

以下是目前用來攜帶產品資料的類別,包含編號、名稱與價格,共 3 個欄位。


二、三層式架構的目的與做法

(一)介紹

第 3 課,筆者提到 MVC 架構是為了將不同用途的程式分門別類,不要通通寫在一起。在前後端分離的系統中,V(view)屬於前端的工作。

三層式架構的目標,是將屬於後端的 M(Model)與 C(Controller)做進一步的切分。那麼要如何進行呢?這會包含「分層」與「設計資料規格」兩項工作。

(二)分層

請讀者回頭看看第一節的範例程式,createProduct 這支 API 處理方法,混合了不同性質的程式碼片段,包含:

  1. 檢查 request body 是否有缺漏的資料。
  2. 檢查產品 id 是否已存在。
  3. 儲存產品資料。
  4. 建立出 response 的內容。

由於這段程式並沒有很長,所以看起來還好。倘若隨著日後開發,程式碼越來越多,則未來可能會形成不易維護的現象,畢竟要閱讀一大片程式。

實務上會根據各個程式碼片段的目的,歸類為以下三層,也就是在程式中拆成三種類別。

  • 表現層:負責接收請求,接著呼叫其他程式進行處理,最後給予回應。其實指的就是 Controller,它是與前端最靠近的層次。
  • 資料存取層:負責與 DB 進行 CRUD 的操作,而對外就像是 DB 的代理人。類別名稱常以「Repository」、「Dao」(Data Access Object)結尾。
  • 商業邏輯層:負責進行資料處理,類別名稱常以「Service」結尾。商業邏輯是為了滿足情境而開發出的程式邏輯,比方說「觀看產品時要知道賣家資料」、「產品不存在時要出現提示畫面」、「有人留言時要收到通知」等,這都是情境。

這三個層次會透過呼叫方法來互動。原則上是 Controller 呼叫 Service,而 Service 再呼叫 Repository。

分層也未必只能分為三層,當系統太複雜,視情況再繼續細分也沒關係。以下列出幾項分層的好處:

  • 幫助我們在開發新功能,或修復問題時,判斷該從何處下手。例如沒收到 query string,要去確認 Controller 層。
  • 隔離實作細節,限制修改程式的範圍。例如 Repository 層的儲存方式想從範例中的 Map 換成 MySQL,則只要在這一層修改即可。其他呼叫 Repository 層的地方不必調整。
  • 相同目的的程式碼,也能被重複利用。比方說一支「取得多個產品」的程式,它就可以被「顯示產品列表」和「顯示訂單內容」這兩項功能利用。這也意味著,修改一處,所有呼叫它的地方都會生效。

(三)設計資料規格

再請讀者看看第一節的範例程式。Product 類別除了用於 request 與 response 的 body,同時也是儲存在 Map 的格式。

假設我們還想在 DB 紀錄產品的建立時間,於是添加叫做「createdTime」的欄位,打算由後端寫入。

但不知道的人單獨看這個類別,搞不好會以為我們在建立與更新的 request body 中,開放前端攜帶這項時間資料呢!

再假設前端想顯示產品建立者的名字,於是我們添加「creatorName」欄位。並且經過資料庫正規化後,DB 還需紀錄使用者 id 來做關聯,欄位叫做「creatorId」。

同樣的道理,別人可能感到疑惑:為何 DB 的產品資料會同時儲存使用者的 id 與名字呢,不是有做正規化嗎?

從這兩個例子可察覺,當一個類別身兼多個用途,可能造成不必要的誤會。不該把 request 和 response body 的欄位,直接套用到 DB。

架構中的三個層次,在呼叫方法時,伴隨資料的輸入與輸出。因此,除了分層之外還有一項工作,那就是設計層次間溝通的資料規格。也就是說,攜帶產品資料的類別,會依照用途而建立出多個。比方說 request 專用、response 專用、DB 專用等。

三、實作不同的資料規格

在實作 Service 與 Repository 層之前,本節先準備好各種攜帶產品資料的類別,作為層次之間溝通的規格。

以下的 ProductCreateRequest 類別,會在建立資料的 API,接收 request body。包含產品 id、名稱、價格與建立者 id,共 4 個欄位。

以下的 ProductUpdateRequest 類別,會在更新資料的 API,接收 request body。但只允許更新名稱與價格,這 2 個欄位。

以下的 ProductPO 類別,是對應到 DB 儲存的欄位。「PO」的全稱為「Persistence Object」,有持久儲存的意思。

它比上述兩個 Request 多了建立與更新時間,這 2 個欄位。

以下的 ProductVO 類別,是用來攜帶要回傳給前端的資料。「VO」的全稱為「View Object」,有展示的意思。

它比 ProductPO 多了建立者名稱這個欄位。

本文想讓產品能透過 creatorId 欄位,關聯到使用者資料。因此再額外建立使用者的類別。

完成後,就能開始將原先寫在 Controller 的程式碼抽離到其他層次。過程中,也會運用這些類別來攜帶資料。

四、實作 Repository 層

(一)宣告類別與測試資料

以下是產品和使用者的 Repository,並內建測試資料,模擬出一個 DB。

接下來要提供 CRUD 的方法,讓其他地方能藉此存取 DB。

(二)取得一筆資料

以下是在產品和使用者的 Repository,實作「透過 id 取得一筆資料」的方法。

此處很單純地從 Map 取出資料。而回傳的型態是代表 DB 欄位的 ProductPO 與 UserPO。

(三)新增資料

以下是實作「新增產品資料」的方法。

此處設計成產品 id 可經由傳入 ProductPO 來自行提供,或者讓內部產生隨機字串。接著 Repository 層會寫入資料的建立時間,隨後儲存。

若 id 發生重複,可透過拋出例外(exception)的方式,停止處理這項請求。這裡暫時使用 RuntimeException,我們留到第五節再來調整。

(四)更新與刪除資料

以下是更新與刪除產品資料的方法。

更新時,先透過 id 確認該資料是否存在。是的話,就寫入更新時間後儲存,否則拋出 exception 來中止。

刪除時,則直接從 Map 移除,不做檢查。站在 Repository 層最靠近 DB 的立場,它只要確保 Map 不要有該 id 的資料即可。

(五)取得多筆資料

基本上就是將原先寫在 Controller 的 getProducts 方法,幾乎原封不動地搬過來。只是要將回傳值型態改為代表 DB 欄位的 ProductPO。

在此不贅述,讀者可參考文末附上的完成後專案。


五、用 Exception 回傳 HTTP 狀態碼

在練習用專案,我們在 Controller 是利用「ResponseEntity」物件回傳 HTTP 狀態碼,如 404(Not Found)、422(Unprocessable Entity)等,讓前端知道有問題發生。

但等到將程式碼抽離到其他層次後,就不適合這麼做了,畢竟 ResponseEntity 是被設計用來回應給前端,不建議出現在擔任其他職責的層次。

本節介紹如何在拋出 exception 中止程式流程時,能回傳 HTTP 狀態碼。

以 404 和 422 的狀態碼為例,我們可以像這樣建立例外類別:

它們繼承了 RuntimeException,並使用 @ResponseStatus 注解設定狀態碼。

接著請回到 ProductRepository,改為拋出這種 exception。

然而只有回傳狀態碼,可能無法讓前端確切知道發生了什麼事。若想透過 response body 提供前端更詳細的資訊,請參考「【Spring Boot】使用 Controller Advice 處理例外與 query string」文章。

六、實作 Service 層

(一)宣告類別

完成資料存取層後,本節接續進行商業邏輯層的實作。

上面宣告了一個 Service 類別,用來實作產品的商業邏輯。

此處還在全域變數用 new 建立了 ProductRepository 和 UserRepository 物件,以便呼叫前面第五節封裝好的程式。

如果讀者以前已經碰過一點 Spring,看到這邊應該覺得奇怪:怎麼不使用 @Autowired 注解,而是用 new 的?

原因是筆者想在第 6 課單獨用一篇文章,來介紹「控制反轉」及「依賴注入」這兩項關於 Spring 的重要觀念,故本文暫時用 new 的方式。

(二)取得資料

由於代表 DB 欄位的類別為 ProductPO,若想將其變成前端需要的 ProductVO 規格,那就得先寫出轉換的方法。

此處僅將兩個類別的欄位一一對應罷了。然而 ProductVO 的 creatorName 欄位的資料,還需靠 Service 層的程式來處理。

首先從 Repository 層取得產品資料,將其轉換為 ProductVO。接著取得使用者的名字後,再填入進去。

當然,如果沒有該 id 的產品,就回傳 404 狀態碼。

(三)建立資料

由於後續會由 ProductCreateRequest 類別接收前端的請求,因此也要準備一個轉換成 ProductPO 的方法。

而 Service 的邏輯如下:

首先檢查是否有這位建立者。有的話,將 ProductCreateRequest 轉換為 ProductPO,再呼叫 Repository 儲存即可。

(四)更新與刪除資料

以下是更新與刪除產品資料的邏輯。

更新時,會從 Repository 層取得資料,並以 ProductUpdateRequest 類別的欄位值,只更新名稱與價格,最後儲存回去。

刪除時,會先嘗試透過 id 取得資料。若不存在,則回傳 404 狀態。否則呼叫 Repository 層進行刪除。

(五)取得多筆資料

基本上就是直接呼叫 Repository 的方法,取得多個 ProductPO。接著再仿照前面取得一筆資料的做法,將建立者的名字寫入,組成完整的 ProductVO。

要留意的是,應盡量避免在迴圈中存取 DB。具體實作細節,讀者可參考文末附上的完成後專案。


七、調整 Controller 層

第五節實作完 Repository,而第六節讓 Service 的商業邏輯呼叫 Repository。最後我們要讓 Controller 只與 Service 互動,讓它專注於自己的職責。

這裡同樣暫時用 new 的方式,建立 ProductService 物件。並且以 ProductCreateResquest 或 ProductUpdateRequest 類別接收請求,而給予回應則使用 ProductVO 類別。

如此一來,Controller 的每一支 API 處理方法,不再有過長的程式碼,凸顯它只有接收請求、呼叫處理、給予回應這些工作,變得簡潔許多!

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch5

上一篇:【Spring Boot】第4課-在 Controller 接收 query string 與操作 header

下一篇:【Spring Boot】第6課-元件的控制反轉、依賴注入與多型呼叫

留言