skip to content
BlogZzz

[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
層主要責任這篇的例子
ControllerHTTP 輸入與輸出讀取 ID,回傳 200 或 404
Serviceuse 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;
@Repository
public 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;
@Service
public 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

Terminal window
./mvnw spring-boot:run

查詢及格的學生

Terminal window
curl -i 'http://localhost:8080/api/students/1'

預期 200,body 如下

{ "id": 1, "name": "Amy", "score": 85, "passed": true }

查詢未及格的學生

Terminal window
curl -i 'http://localhost:8080/api/students/2'

預期 200,body 如下

{ "id": 2, "name": "Ben", "score": 55, "passed": false }

查詢不存在的 ID

Terminal window
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.findById
InMemoryStudentRepository
↓ 執行自己撰寫的 findById
Map

所以本篇不是「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.findById
Spring Data 建立的 repository proxy
↓ 使用 JPA 查詢
Database

兩種寫法可以這樣區分

寫法誰提供實作資料來源
本篇的 StudentRepository自己撰寫的 InMemoryStudentRepositoryMap
Spring Data JPA repositorySpring Data 在啟動時建立的 proxydatabase

一般 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 卻拿得到它