Giới thiệu
Bạn vừa đẩy một thay đổi đơn giản đến cơ sở dữ liệu vào môi trường sản xuất. Năm phút sau, điện thoại của bạn vang lên với hàng loạt thông báo từ Slack:
“Trang hồ sơ người dùng bị hỏng!”
“Ứng dụng di động bị sập!”
“Tôi đã bỏ lỡ sinh nhật của bạn!”
Chuyện gì đã xảy ra? Bạn đã đổi tên một trường trong cơ sở dữ liệu từ birth_day
thành birthday
, và API REST của bạn tự động trả về tên trường mới. Giao diện frontend của bạn mong đợi birth_day
nhưng lại nhận được birthday
do một quy ước đơn giản bị bỏ qua.
Đôi khi, một thay đổi nhỏ ở backend lại trở thành một sự cố khẩn cấp trong sản xuất.
“Được rồi, được rồi”, bạn nghĩ. “Chúng ta sẽ phối hợp triển khai trước. Backend trước, sau đó là frontend.”
Nhưng ngay cả trong trường hợp đó, vẫn có khoảng thời gian hồi hộp mà giao diện frontend của bạn bị hỏng trong sản xuất. Và đó là giả định rằng bạn đã nhớ cập nhật mọi thử nghiệm đơn vị liên quan đến birth_day
và hy vọng pipeline sẽ không gặp sự cố.
Cảnh báo trước:
Bạn có thể đã bỏ lỡ điều đó.
“Ồ, bạn đúng. Chúng ta sẽ chỉ triển khai vào giờ thấp điểm.”
Không có gì nói lên kiến trúc phần mềm tốt hơn việc đặt báo thức lúc 3 giờ sáng để đổi tên một trường cơ sở dữ liệu.
Tránh Vấn Đề Này
Nếu bạn không muốn ở lại muộn hay nhận được những thông báo tức giận từ Slack hoặc Gmail từ sếp của bạn, hoặc tệ hơn, từ kỹ sư DevOps của bạn sau khi triển khai, bạn nên xem xét thiết kế lại REST API của mình để cuối cùng có thể lấy lại giấc ngủ của mình.
Tin tốt là có những mẫu đã được chứng minh có thể cứu bạn khỏi những tình huống khẩn cấp vào giữa đêm. Đã đến lúc lấy lại những giờ ngủ bình thường!
Thiết Kế
Tôi sẽ sử dụng Express JS cho các ví dụ mã, nhưng mẫu mà tôi sẽ thảo luận không chỉ giới hạn ở Express mà còn áp dụng cho các framework web khác.
Hãy tưởng tượng API của bạn được thiết kế như sau:
Đây là một minh họa rất đơn giản về cách dữ liệu chảy đến khách hàng.
Vấn đề ở đây là cách API trả lại phản hồi cho khách hàng.
Giả sử bạn có một endpoint GET /api/v1/user
và endpoint này có một middleware (hoặc serializer) mà lập trình viên ánh xạ tất cả các trường từ mô hình Người dùng vào phản hồi.
Phản hồi:
{
"first_name": "Foo",
"last_name": "Bar",
"phone_number": "+1234567890",
"birth_day": "14 tháng 10, 2023",
"password": "dajkldhajsdhk" // Hoàn toàn an toàn phải không?
}
Được rồi, tôi đã đề cập rằng endpoint có một middleware lấy tất cả các trường và ánh xạ vào một phản hồi JSON đúng không? Bạn có thể muốn cấu hình middleware của mình để có thể bỏ qua một số trường như trường password
.
Phản hồi cập nhật:
{
"first_name": "Foo",
"last_name": "Bar",
"phone_number": "+1234567890",
"birth_day": "14 tháng 10, 2023"
}
Tốt hơn nhiều! Đó là một caveat cho việc ánh xạ các trường cơ sở dữ liệu vào phản hồi một cách lập trình. Bạn phải cẩn thận trong việc gửi những gì đến khách hàng.
Nhưng, bạn chưa xong. Bạn vẫn có nhiệm vụ đổi tên trường birth_day
. Hãy nhớ rằng, phản hồi JSON là những gì mà frontend của bạn kỳ vọng ngay bây giờ. Nếu bạn thay đổi điều gì đó ở backend, nó có thể ảnh hưởng đến frontend của bạn.
Phản hồi với trường đã cập nhật
{
"first_name": "Foo",
"last_name": "Bar",
"phone_number": "+1234567890",
"birthday": "14 tháng 10, 2023" // Đây là trường được ánh xạ bởi middleware của bạn
}
Frontend của bạn vẫn kỳ vọng trường birth_day
từ phản hồi. Nhưng bạn có thể dừng lại ngay đây và xem lại những gì bạn đã làm sau khi bạn đã thay đổi trường.
Mà không thay đổi gì ở frontend, nếu tôi nói với bạn rằng bạn vẫn có thể cập nhật trường birth_day
thành birthday
mà không ảnh hưởng đến frontend của bạn?
Đây là nơi chúng ta thiết lập cái mà chúng ta gọi là hợp đồng API. Mẫu này có thể quen thuộc với những người đã thử hoặc sử dụng gRPC's Protocol Buffers (protoBuf) hoặc schema của GraphQL.
Mục tiêu của chúng ta ở đây là làm cho frontend của chúng ta phải chịu được sự thay đổi hoặc cơ bản là chúng ta vẫn muốn nó tương thích ngược, để chúng ta không tạo ra thời gian ngừng hoạt động không cần thiết vì chúng ta đã thay đổi tên trường hoặc bất kỳ mối quan hệ nào.
Trong middleware của backend của bạn, thay vì ánh xạ lập trình các trường mô hình cơ sở dữ liệu vào phản hồi JSON, hãy ánh xạ chúng một cách thủ công.
Ví dụ:
const myMiddleware = (...) => {
// :: ... các thực hiện khác của bạn
// :: Ánh xạ các trường một cách thủ công, thiết lập một hợp đồng rõ ràng
return {
first_name: user.first_name,
last_name: user.last_name,
phone_number: user.phone_number,
birth_day: user.birth_day
}
}
// :: Sau khi thay đổi DB: birth_day -> birthday
const myMiddleware = (...) => {
// :: ... các thực hiện khác của bạn
// :: Ánh xạ các trường một cách thủ công, thiết lập một hợp đồng rõ ràng
return {
first_name: user.first_name,
last_name: user.last_name,
phone_number: user.phone_number,
birth_day: user.birthday // :: Frontend vẫn nhận được birth_day!
}
}
Trong ví dụ trên, tôi đã thiết lập một hợp đồng mà phải chứa first_name
, last_name
, phone_number
, và birth_day
bất kể tên của các trường trong bảng của bạn là gì.
Điều này trông tốt hơn nhiều. Frontend vẫn nhận được trường birth_day
không theo quy ước, vì vậy bạn có thể yên tâm.
Có lẽ, trong cách tiếp cận này, nếu bạn muốn làm cho nó giống như một cách tiếp cận lai, chúng ta có thể ánh xạ lập trình các trường không thay đổi và ánh xạ một cách rõ ràng các trường đã thay đổi ảnh hưởng đến frontend của bạn.
Có, mẫu này trông giống như DTOs hoặc serializers vì nó thực sự vậy. Nhưng điểm quan trọng là xem lớp chuyển đổi đó như là hợp đồng của API của bạn, không chỉ là một mã nguồn rỗng.
Cách Tiếp Cận Lai
// :: Sau khi thay đổi DB: birth_day -> birthday
const myMiddleware = (...) => {
// :: ... các thực hiện khác của bạn
const response = { ...user }
// :: Đảm bảo xóa bất kỳ trường nhạy cảm nào
delete response.password
// :: Thêm tên trường cũ để tương thích ngược
response.birth_day = user.birthday
return response
}
Trong cách tiếp cận lai này, điều này cho phép frontend của bạn dần dần chuyển sang tên trường mới. Trong thời gian chuyển tiếp, bạn trả về cả tên trường cũ và mới, giúp nhóm frontend có thời gian cập nhật mã của họ mà không cần vội vàng hoặc đau đầu về phối hợp và chúng ta có thể nói lời tạm biệt với trường birth_day
xấu xí (ai lại đặt tên như vậy?).
Như tôi đã đề cập, điều này cũng áp dụng cho các framework web khác, chẳng hạn như:
- Serializer của Django (
ModelSerializer
, custom serializers) - Tài nguyên API của Laravel (
JsonResource
classes) - DTOs của NestJS (với các decorator
class-transformer
) - Serializer của Ruby on Rails (
ActiveModel::Serializer
)
Tôi không biết đối với các framework web khác (bởi vì đó là tất cả những gì tôi đã thử) nhưng tôi 100% chắc chắn rằng nó sẽ hoạt động bất kể framework web bạn sử dụng là gì.
Tên trường là một phần của API công cộng của bạn, hãy đối xử với chúng như một lời hứa vĩnh viễn.
Khi Nào Bỏ Qua Mẫu Này?
Bạn có thể bỏ qua mẫu này khi bạn đang xây dựng các API nội bộ mà chỉ đội của bạn sử dụng, hoặc nếu dự án của bạn vẫn còn trong giai đoạn đầu hoặc giai đoạn MVP mà bạn vẫn đang tìm hiểu mô hình dữ liệu của mình.
Chi phí này trở nên đáng giá khi API của bạn phát triển và các endpoint bắt đầu xuất hiện khắp nơi.
Đối với các dự án mới: BẮT ĐẦU đơn giản và xem xét quyết định này khi hệ thống của bạn trưởng thành.
Các Bước Tiếp Theo
Tôi đang sử dụng REST API như một ví dụ, nhưng bạn đã hiểu ý.
Nếu bạn thực hiện mẫu này (mà tôi rất khuyến nghị), bạn có thể làm cho nó hiệu quả hơn bằng cách thiết lập các hợp đồng API chính thức bằng các công cụ như OpenAPI specs.
Hãy coi OpenAPI spec như hiến pháp của API của bạn - nó trở thành nguồn thông tin duy nhất cho những gì các trường API của bạn hứa hẹn sẽ trả về. Trong ví dụ của chúng tôi, bạn sẽ định nghĩa rằng hợp đồng API của bạn luôn trả về birth_day
trong OpenAPI spec, bất kể trường cơ sở dữ liệu của bạn được gọi là birth_day
, birthday
, hay date_of_birth
.
Bằng cách này, cơ sở dữ liệu của bạn có thể phát triển tự do trong khi hợp đồng API của bạn vẫn vững chắc.
Kết Luận
Nếu bạn đến từ GraphQL hoặc gRPC, có thể cảm thấy kỳ lạ rằng các API REST không đi kèm với các schema được tích hợp sẵn. Và đúng vậy, sự thiếu cấu trúc đó có thể gây đau đầu khi dự án của bạn phát triển.
Nhưng điều đó không có nghĩa là GraphQL hoặc gRPC tự động “tốt hơn.” Như mọi thứ trong phần mềm, nó phụ thuộc vào kích thước đội ngũ của bạn, nhu cầu dự án và mục tiêu dài hạn.
Nếu bạn đang vận hành một dự án REST lớn và gặp khó khăn với tính tương thích ngược, đừng nghĩ rằng bạn cần phải vứt bỏ mọi thứ và viết lại bằng GraphQL. Thay vào đó, hãy củng cố những gì bạn đã có. Các công cụ như OpenAPI specs, JSON Schema, Pydantic (Python), hoặc Zod (TypeScript/JavaScript) có thể cung cấp cho API REST của bạn lưới an toàn dựa trên schema mà bạn đang thiếu.
Hãy nghĩ theo cách này: nếu xe của bạn không có CarPlay, bạn không ngay lập tức mua một chiếc xe mới, bạn nâng cấp đầu DVD. Điều tương tự cũng áp dụng ở đây: tận dụng bộ công nghệ hiện có của bạn trước khi chuyển sang một mô hình mới.
Bộ công nghệ của bạn có thể có nhiều hơn những gì bạn nghĩ. Đôi khi quyết định kiến trúc tốt nhất là đơn giản không viết lại mọi thứ.
Tóm lại
Hợp đồng API của bạn không nên là schema cơ sở dữ liệu.
Tách chúng ra, sử dụng các công cụ phù hợp, và bạn sẽ tiết kiệm cho mình khỏi việc viết lại và triển khai lúc 3 giờ sáng.