Day05 建了 Controller 骨架(先寫死回應),Day07 建了 LinkService 跟 LinkRepository(真正查資料庫),但這兩塊目前還沒接在一起——Controller 呼叫的還是假資料。這篇要做的事很單純:把 Day05 的 Controller 改成呼叫 Day07 已經寫好的 LinkService。
Controller (接請求、組回應,不寫商業邏輯)
↓
Service (商業邏輯、呼叫 Repository,Day07 已經寫好)
↓
Repository (資料庫存取,Day07 已經寫好)
Laravel 沒有強制要求這三層——很多 Laravel 專案就是 Controller 直接操作 Model,兩層就結束,Service 層是不是要獨立出來完全看團隊習慣。Spring 生態系的慣例則比較固定地走三層,這不是 Spring 框架本身強制的規則,是社群長期累積下來的慣例,但幾乎所有 Spring 專案都會照著做。
@RestController
@RequestMapping("/api/links")
class LinkController {
private final LinkService linkService;
LinkController(LinkService linkService) {
this.linkService = linkService;
}
@PostMapping
ResponseEntity<LinkResponse> store(@Valid @RequestBody CreateLinkRequest request) {
LinkResponse response = linkService.create(request);
return ResponseEntity.ok(response);
}
@GetMapping("/{code}")
ResponseEntity<LinkResponse> show(@PathVariable String code) {
return linkService.findByCode(code)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
跟 Day05 的版本比,Controller 現在完全不碰資料庫、不寫任何商業邏輯——它唯一的工作是「接請求、呼叫 Service、把結果包成 HTTP 回應」。findByCode 回傳的 Optional(Day02、Day07 都提過的型別)在這裡發揮作用:有值就回 200,沒有值就回 404,一行寫完,不用寫 if (result == null)。
Spring 其實技術上做得到——@RestController 會自動把回傳的物件序列化成 JSON,就算回傳的是 JPA Entity 也能動:
// 技術上能跑,但不建議
@GetMapping("/{code}")
Link show(@PathVariable String code) {
return linkRepository.findByCode(code).orElseThrow();
}
問題不在「跑不跑得動」,是這樣做會有幾個實際的坑:
Link entity 有 ownerId 這種內部欄位,直接序列化整個 Entity,等於把「這個短網址是誰建的」也回給了呼叫端的前端,就算現在用不到,也不該暴露Link 關聯到 User),沒設定好可能會在序列化時觸發額外的資料庫查詢,甚至丟例外這正是這篇要解決的問題——用一個獨立的 DTO(Data Transfer Object)當作 API 對外的回應格式,跟內部的 Entity 完全脫鉤。LinkResponse 這個 record 其實從 Day05 就已經是 DTO 了:
record LinkResponse(String code, String shortUrl) {}
只暴露呼叫端真正需要的兩個欄位,ownerId、clickCount、createdAt 這些內部細節完全不會外流。
Day07 的 LinkService.create() 裡其實已經在做「Entity 轉 DTO」這件事,只是寫得很直白:
Link saved = linkRepository.save(link);
return new LinkResponse(saved.getCode(), "https://short.ly/" + saved.getCode());
這種手動組裝的轉換邏輯,就是 Mapper 的雛形。現在專案還小,直接寫在 Service 裡沒問題;等轉換邏輯變複雜(例如同一個 Entity 要轉成好幾種不同用途的 DTO),會抽成獨立的 Mapper 類別或方法。
Laravel 對應的概念是 API Resource:
class LinkResource extends JsonResource
{
public function toArray($request)
{
return [
'code' => $this->code,
'short_url' => 'https://short.ly/' . $this->code,
];
}
}
// Controller
return new LinkResource($link);
兩邊做的事情本質相同——都是「決定 Entity/Model 裡哪些欄位要暴露、用什麼形狀暴露」——差別在 Laravel 的 API Resource 是框架提供的專屬機制、有固定的基底類別可以繼承;Spring 沒有官方的「Mapper」機制,手動寫轉換方法,或使用像 MapStruct 這類第三方函式庫在編譯期自動生成轉換程式碼(這系列目前先手動寫,函式庫留待之後視情況評估)。
到這篇為止,POST /api/links 已經是一條完整走通的路徑:Controller 接請求 → Validation(Day06)通過 → Service 呼叫 Repository(Day07)存進資料庫(Day08 的 migration 建好的表)→ 組成 DTO 回傳。短碼目前還是 Day07 寫死的假值 "abc123",真正的 base62 產生邏輯留到 Day14;例外處理目前也還是 Spring 預設的原始格式,統一整理留到 Day10。這篇算是縮網址服務第一個「端到端」能跑的版本,接下來幾天會逐一把還沒處理好的地方補齊。