[Learn] Spring Boot 分層:Controller、Service 與 Repository
/ 9 min read
Table of Contents
這篇沿用同一個 Java 21、Spring Web 專案,改成查詢學生的 API
需求是輸入學生 ID,回傳姓名、分數與是否及格,找不到學生時回傳 404
先用固定的記憶體資料練習分層,不需要加入 JPA 或設定資料庫
三層各自回答什麼問題
HTTP request ↓BootController 接收參數、決定 HTTP response ↓StudentService 執行查詢流程、判斷是否及格 ↓StudentRepository 定義讀取學生資料的契約 ↓InMemoryStudentRepository ↓記憶體中的 Map| 層 | 主要責任 | 這篇的例子 |
|---|---|---|
| Controller | HTTP 輸入與輸出 | 讀取 ID,回傳 200 或 404 |
| Service | use case 與業務規則 | 查詢學生,以 60 分判定及格 |
| Repository | 資料存取 | 從資料來源取得學生 |
這是本範例採用的設計方式,不是 Spring 強制所有 API 都要有三層
重點是讓變動有地方放,例如及格門檻改變時,不需要一起修改 URL mapping 或資料查詢方式
準備檔案
保留上一篇的 BootdemoApplication.java,替換 BootController.java,再新增以下檔案
src/main/java/com/example/bootdemo/├── BootdemoApplication.java├── BootController.java├── Student.java├── StudentResponse.java├── StudentRepository.java├── InMemoryStudentRepository.java└── StudentService.java上一篇的 BootMessage.java 可以保留,只是這篇不再使用它
每個 public top-level type 都放在同名檔案中,以下範例全部位於 com.example.bootdemo
建立 Student.java
package com.example.bootdemo;
public record Student(long id, String name, int score) {}再建立 StudentResponse.java
package com.example.bootdemo;
public record StudentResponse(long id, String name, int score, boolean passed) {}Student 表達這次使用的學生資料,StudentResponse 則表達 API 想回傳的內容
把 response 分開後,未來資料模型增加內部欄位時,可以明確決定是否對外提供
這裡的 Student 是一般 Java record,沒有 JPA entity 的意義,也不會自動對應資料表
Repository 隔離資料來源
建立 StudentRepository.java
package com.example.bootdemo;
import java.util.Optional;
public interface StudentRepository { Optional<Student> findById(long id);}interface 只定義「用 ID 查學生」,不要求使用者知道資料放在 Map、SQL database 或其他來源
Optional<Student> 表達可能查不到資料,避免把「不存在」藏在沒有說明的 null 裡
建立 InMemoryStudentRepository.java
package com.example.bootdemo;
import java.util.Map;import java.util.Optional;import org.springframework.stereotype.Repository;
@Repositorypublic class InMemoryStudentRepository implements StudentRepository { private final Map<Long, Student> students = Map.of( 1L, new Student(1L, "Amy", 85), 2L, new Student(2L, "Ben", 55));
@Override public Optional<Student> findById(long id) { return Optional.ofNullable(students.get(id)); }}Map.of(...) 建立不可修改的 Map,這篇只有讀取操作,資料固定在程式中,每次啟動重新建立
@Repository 是 Spring 的 component stereotype,標記這個 class 承擔資料存取責任,讓 component scan 可以找到它
它也可以配合 Spring 的 persistence exception translation 基礎設施轉換資料存取例外,但不是加上 annotation 就會產生 SQL、資料表或 CRUD method
本例的 findById 仍然是自己實作,相關 stereotype 行為可參考 Spring component scanning 文件
Service 放查詢流程與業務規則
建立 StudentService.java
package com.example.bootdemo;
import java.util.Optional;import org.springframework.stereotype.Service;
@Servicepublic class StudentService { private final StudentRepository studentRepository;
public StudentService(StudentRepository studentRepository) { this.studentRepository = studentRepository; }
public Optional<StudentResponse> findById(long id) { return studentRepository.findById(id) .map(student -> new StudentResponse( student.id(), student.name(), student.score(), student.score() >= 60)); }}@Service 標記這個 class 承擔 service layer 的責任,也能被 component scan 找到
這裡的業務規則是 score >= 60,它不需要知道 HTTP status,也不需要知道學生資料存在 Map 裡
Optional.map(...) 只有在查到學生時才會建立 response,查不到就繼續回傳 empty Optional
constructor 宣告這個 service 必須取得一個 StudentRepository
public StudentService(StudentRepository studentRepository) { this.studentRepository = studentRepository;}Spring 建立 Service 時會把符合型別的 Repository Bean 傳進來,這就是 constructor injection
這裡只有一個 constructor,不需要額外加 @Autowired,下一篇會專門拆解這個過程
Controller 把查詢結果轉成 HTTP response
用以下內容替換 BootController.java,不要同時保留上一篇同名 class
package com.example.bootdemo;
import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;
@RestController@RequestMapping("/api/students")public class BootController { private final StudentService studentService;
public BootController(StudentService studentService) { this.studentService = studentService; }
@GetMapping("/{id}") public ResponseEntity<StudentResponse> findById( @PathVariable("id") long id) { return ResponseEntity.of(studentService.findById(id)); }}@PathVariable("id") 讀取 URL path 裡的 ID,例如 /api/students/1
上一篇的 @RequestParam 則是讀取 query string,例如 /api/boot/hello?name=Amy
ResponseEntity 可以明確表達 status、header 與 body
這裡的 ResponseEntity.of(Optional) 在有值時回傳 200 與該物件,empty 時回傳沒有 body 的 404,可對照 ResponseEntity API
Service 不負責決定 HTTP status,因此未來從排程或其他 Java method 呼叫 Service,也不必跟著處理 HTTP response
實際查詢三種結果
重新啟動 application
./mvnw spring-boot:run查詢及格的學生
curl -i 'http://localhost:8080/api/students/1'預期 200,body 如下
{ "id": 1, "name": "Amy", "score": 85, "passed": true }查詢未及格的學生
curl -i 'http://localhost:8080/api/students/2'預期 200,body 如下
{ "id": 2, "name": "Ben", "score": 55, "passed": false }查詢不存在的 ID
curl -i 'http://localhost:8080/api/students/999'預期 404,沒有 response body
如果傳入 /api/students/abc,因為 abc 無法轉成 long,預設 Spring MVC 處理會回傳 400,在進入 Service 之前就會被擋下
/api/students/-1 則可以轉成 long,本例沒有額外的正數驗證,因為找不到資料而得到 404
補充:為什麼有些 Repository 只有 interface
回到本篇的程式,StudentService 呼叫的是 StudentRepository
return studentRepository.findById(id);StudentRepository 只規定必須提供 findById,真正從 Map 取資料的是 InMemoryStudentRepository
StudentService ↓ 呼叫 StudentRepository.findByIdInMemoryStudentRepository ↓ 執行自己撰寫的 findByIdMap所以本篇不是「interface 沒有實作也能查資料」,而是 Service 只依賴 interface,執行時由 InMemoryStudentRepository 負責實作
看到這裡,可能會想到 Spring Data JPA 常見的另一種寫法
public interface StudentJpaRepository extends JpaRepository<StudentEntity, Long> {}這段程式確實沒有自己建立 StudentJpaRepositoryImpl,但不代表執行時沒有實作
差別在於 JpaRepository 屬於 Spring Data JPA 提供的機制
Application 啟動時,Spring Data 會找到這個 interface,建立一個實作它的 proxy object,再把 proxy 註冊成 Bean
可以先把 proxy 理解成「Spring Data 在執行時準備好的代理物件」
Service 呼叫 findById 時,實際接到呼叫的是這個 proxy,由它使用 JPA 執行資料庫查詢
Service ↓ 呼叫 StudentJpaRepository.findByIdSpring Data 建立的 repository proxy ↓ 使用 JPA 查詢Database兩種寫法可以這樣區分
| 寫法 | 誰提供實作 | 資料來源 |
|---|---|---|
本篇的 StudentRepository | 自己撰寫的 InMemoryStudentRepository | Map |
| Spring Data JPA repository | Spring Data 在啟動時建立的 proxy | database |
一般 Java interface 不會自動擁有實作
名稱以 Repository 結尾,或在 class 加上 @Repository,也不會自動產生 findById 等 method
Spring Data JPA 能省略實作 class,是因為另外提供了 repository proxy 機制,而且還需要 dependency、entity、datasource 與 repository scanning 等設定
本篇沒有加入這些設定,先記住目前的查詢仍由 InMemoryStudentRepository 執行即可
可延伸閱讀 Spring Data repository 核心概念
分層後觀察變動位置
- URL 從
/api/students改名:修改 Controller - 及格門檻從 60 改成 70:修改 Service
- Map 改成資料庫:新增或替換 Repository 實作,並設定所需資料存取元件
- 對外回傳欄位改變:檢查 response DTO 與 Service 的轉換
未來加入寫入功能時,跨多個資料操作的 transaction boundary 通常會在 Service 層規劃,但 @Service 本身不會自動開啟 transaction
現在已經可以看懂「request 進來後誰呼叫誰」,下一篇 spring-boot-di-bean.md 要處理另一個問題:程式裡沒有自己 new StudentService(...),為什麼 Controller 卻拿得到它