上一篇我們追了一次:
run()
↓
useRequest
↓
Service.getById()
↓
Response
↓
State / Form
但如果今天 Bug 不在前端呢?
例如:
前端明明有送 Request
↓
Network 也有 Response
↓
但資料就是不對
這時候就不能只停在:
InvoiceService.getById(id)
而要繼續往後追。
也就是:
這支 API 到後端之後,到底經過哪些地方?
我現在會先把前後端流程想成:
Frontend Component
↓
Service
↓
HTTP Request
↓
Backend Controller
↓
Service
↓
Repository
↓
Database
↓
Response
↓
Frontend
這張圖非常重要。
因為每次遇到資料問題,都可以問:
問題可能在哪一層?
而不是看到錯誤就整個專案亂翻。
假設 Component 裡看到:
const result = await InvoiceService.getById(id);
這裡的:
InvoiceService.getById(id)
通常不是資料庫查詢。
它只是前端的一層封裝。
Service 裡可能長得像:
export const InvoiceService = {
getById(id: number) {
return request(`/invoices/${id}`);
},
};
這時候資料流就是:
Component
↓
InvoiceService.getById(id)
↓
GET /invoices/123
所以如果想知道:
「前端到底打哪支 API?」
就可以繼續進 Service 找真正的:
HTTP Method
+
URL
常見的 Method 可以先這樣理解:
GET
→ 取得資料
POST
→ 新增資料
PATCH
→ 修改部分資料
PUT
→ 更新資源
DELETE
→ 刪除資料
例如:
GET /invoices/123
先不用看後端程式,就可以大概知道:
要取得 id = 123 的 Invoice。
如果是:
PATCH /invoices/123
則是在告訴後端:
我要修改這筆 Invoice 的某些內容。
所以開始追 API 時,我會先記:
Method 是什麼?
URL 是什麼?
Request 帶了什麼?
假設後端是 Spring / Kotlin 類型的專案,可能會看到:
@RestController
@RequestMapping("/invoices")
class InvoiceController(
private val invoiceService: InvoiceService
) {
@GetMapping("/{id}")
fun getById(
@PathVariable id: Long
): InvoiceViewModel {
return invoiceService.getById(id)
}
}
第一次看到這些 Annotation 很容易先卡住:
@RestController 是什麼?
@RequestMapping 是什麼?
@GetMapping 又是什麼?
@PathVariable 又是什麼?
但我現在不會先全部查。
我會先找:
URL 對不對得起來?
前端:
GET /invoices/123
後端:
@RequestMapping("/invoices")
搭配:
@GetMapping("/{id}")
合起來就是:
/invoices/{id}
所以:
/invoices/123
會進到這個 function。
所以:
fun getById(
@PathVariable id: Long
): InvoiceViewModel {
return invoiceService.getById(id)
}
可以先翻成人話:
收到 GET /invoices/123
↓
把 URL 裡的 123 取成 id
↓
交給 invoiceService.getById(id)
↓
把結果回傳
這時候 Controller 本身通常不一定負責真正查資料。
它比較像:
後端 API 的入口。
Controller 呼叫:
invoiceService.getById(id)
所以往下追 Service:
@Service
class InvoiceService(
private val invoiceRepository: InvoiceRepository
) {
fun getById(id: Long): InvoiceViewModel {
val invoice =
invoiceRepository.findById(id)
.orElseThrow()
return InvoiceViewModel(
id = invoice.id,
number = invoice.number,
discount = invoice.discount
)
}
}
這時候可以先不管每個 Kotlin 語法。
只看流程:
Service 收到 id
↓
Repository 查資料
↓
拿到 invoice
↓
整理成 InvoiceViewModel
↓
回傳
所以 Service 可以先理解成:
放商業邏輯、流程處理與資料整理的地方。
剛才看到:
invoiceRepository.findById(id)
Repository 的角色可以先想成:
負責跟資料存取層溝通。
例如:
interface InvoiceRepository :
JpaRepository<Invoice, Long>
如果使用 JPA / Hibernate,很多基本查詢不一定需要自己寫 SQL。
像:
findById(id)
框架可以幫忙轉成對資料庫的查詢。
所以資料流又多一層:
Service
↓
Repository
↓
Database
假設看到:
@Entity
class Invoice(
@Id
val id: Long,
val number: String,
val discount: BigDecimal?
)
可以先把 Entity 理解成:
後端程式用來對應資料庫資料結構的物件。
例如 Database:
invoice
id
number
discount
在程式裡可能就是:
Invoice
所以:
Database Row
↓
ORM / JPA
↓
Entity
這裡又接回 Service。
剛才 Service 最後沒有直接:
return invoice
而是:
return InvoiceViewModel(
id = invoice.id,
number = invoice.number,
discount = invoice.discount
)
這是因為 API Response 不一定需要把整個 Entity 全部回給前端。
例如 Entity 可能還有:
internalNote
createdBy
updatedAt
relations
其他內部欄位
但前端 Detail 頁可能只需要:
id
number
discount
所以可以另外整理成:
data class InvoiceViewModel(
val id: Long,
val number: String,
val discount: BigDecimal?
)
資料流:
Database
↓
Entity
↓
Service 整理
↓
ViewModel
↓
Response
↓
Frontend
這也是為什麼:
Database 有某個欄位,不代表 API Response 一定會有。
中間還可能經過 ViewModel / DTO 的轉換。
現在就可以完整畫:
Frontend Component
↓
InvoiceService.getById(123)
↓
GET /invoices/123
↓
Controller
↓
invoiceService.getById(123)
↓
Repository.findById(123)
↓
Database
↓
Invoice Entity
↓
轉成 InvoiceViewModel
↓
HTTP Response
↓
Frontend result
↓
State / Form
↓
Render
這就是一支 API 從前端跑到後端,再跑回來的基本流程。
假設前端發現:
discount 顯示錯誤
以前可能直接改:
form.setFieldsValue(...)
但現在可以先一路確認:
① UI 顯示錯?
↓
② API Response 裡 discount 是什麼?
↓
③ Response 已經錯了嗎?
↓
④ ViewModel 怎麼組 discount?
↓
⑤ Entity 裡 discount 是什麼?
↓
⑥ Database 真正存的是什麼?
如果 Network Response 已經是:
{
"discount": 10
}
但 UI 顯示:
20
問題比較可能在前端。
反過來,如果 Response 就已經是:
{
"discount": 20
}
那就不用一直改前端 Input。
應該繼續往:
Controller
Service
Repository
Database
追。
假設:
使用者修改 discount
↓
PATCH /invoices/123
後端可能變成:
Controller
↓
收到 Request Body
↓
Service
↓
找到原本 Entity
↓
修改欄位
↓
Repository save
↓
Database
↓
Response
所以之前提到的:
「PATCH 200,但重新 GET 又變回舊值」
就可以開始拆:
Frontend 有送新值嗎?
↓
Controller 有收到嗎?
↓
Service 有套用嗎?
↓
Repository 有 save 嗎?
↓
Database 有真的改嗎?
↓
GET 時 ViewModel 又回了什麼?
這時候 Debug 就不再只是猜。
而是沿著資料流逐層排除。
看到 Kotlin 專案時,我以前很容易卡在:
annotation
generic
nullable
JPA
Hibernate
repository
data class
然後覺得:
我後端完全看不懂。
但其實第一輪只要先找:
Controller
↓
Service
↓
Repository
↓
Entity / Database
↓
ViewModel
↓
Response
先知道資料去哪裡。
陌生語法再慢慢補。
這和前面讀 React 的方法其實完全一樣:
先讀資料流,再補語法。
Frontend
│
├─ Component
│
├─ Request Hook
│
└─ Service
↓
HTTP Request
↓
Backend
│
├─ Controller
│
├─ Service
│
├─ Repository
│
└─ Entity
↓
Database
Database
↓
Entity
↓
ViewModel / DTO
↓
Response
↓
Frontend
所以以後看到:
SomeService.getById(id)
不要只把它當成:
「取得資料的 function。」
它背後其實可能連著整條:
前端
↓
HTTP
↓
後端
↓
資料庫
↓
再一路回到畫面
而開始看懂這張地圖之後,我也更容易知道:
這個 Bug 到底該從哪一層開始查。
下一篇可以接著處理一個我以前非常容易混亂的問題:
null、undefined、空字串、0 到底差在哪?為什麼前端常常要一直防空值?