Mọi điều tôi biết về thiết kế API tốt
(seangoedecke.com)- Trong kỹ thuật phần mềm, API là công cụ cốt lõi, và một API tốt nên quen thuộc và đơn giản đến mức nhàm chán
- Một khi API đã được công khai thì rất khó thay đổi, vì vậy nguyên tắc không phá vỡ môi trường của người dùng (WE DO NOT BREAK USERSPACE) là rất quan trọng
- Khi buộc phải thay đổi, cần có quản lý phiên bản (versioning), nhưng đây là một cái ác cần thiết làm tăng mạnh độ phức tạp và chi phí bảo trì
- Chất lượng API rốt cuộc phụ thuộc vào giá trị của chính sản phẩm, và một sản phẩm được thiết kế tệ sẽ khiến việc tạo ra API tốt trở nên khó khăn
- Để đảm bảo tính ổn định và khả năng mở rộng, cần cân nhắc xác thực bằng API key, tính idempotency, rate limit, phân trang dựa trên con trỏ
Mở đầu: Tầm quan trọng và bối cảnh của thiết kế API
- Một trong những công việc chính của kỹ sư phần mềm hiện đại là tương tác với API
- Tác giả cũng có kinh nghiệm thiết kế/triển khai/sử dụng nhiều dạng API công khai và nội bộ khác nhau như REST, GraphQL, công cụ dòng lệnh
- Những lời khuyên hiện có về thiết kế API thường có xu hướng bám chặt vào các khái niệm phức tạp (định nghĩa REST, HATEOAS, v.v.)
- Bài viết này tổng hợp các nguyên tắc thiết kế API thực dụng dựa trên kinh nghiệm thực tế
Cân bằng giữa sự quen thuộc và tính linh hoạt: Điều kiện đầu tiên của một API tốt
- Một API tốt là API 'bình thường và nhàm chán', tức cách dùng nên tương tự các API mà người dùng từng gặp trước đây
- Người dùng tập trung vào việc đạt được mục tiêu của họ hơn là vào chính API, nên cần thiết kế có rào cản gia nhập thấp
- Một khi API đã được công khai thì rất khó thay đổi, nên cần thận trọng ngay từ giai đoạn thiết kế ban đầu
- Lập trình viên luôn muốn API càng đơn giản càng tốt, nhưng đồng thời cũng phải để lại không gian cho tính linh hoạt dài hạn
- Kết quả là, cân bằng giữa sự quen thuộc và tính linh hoạt lâu dài là bài toán cốt lõi
Tuyệt đối không phá vỡ không gian người dùng (WE DO NOT BREAK USERSPACE)
- Việc thêm trường vào cấu trúc phản hồi hiện có trong đa số trường hợp là không vấn đề gì
- Nhưng xóa trường, đổi kiểu hoặc đổi cấu trúc sẽ dẫn đến việc làm hỏng toàn bộ mã phía consumer
- Người duy trì API có trách nhiệm không cố ý làm hỏng phần mềm của người dùng hiện tại
- Ngay cả lỗi chính tả trong header HTTP
referercũng không bị sửa là vì văn hóa bảo toàn không gian người dùng
Thay đổi API mà không làm gãy: Chiến lược quản lý phiên bản
- Chỉ khi thực sự cần thiết mới cho phép thay đổi phá vỡ tương thích trong API, và lúc đó quản lý phiên bản là câu trả lời
- Cần vận hành song song phiên bản cũ và mới, đồng thời thúc đẩy quá trình chuyển đổi dần dần
- Định danh phiên bản có thể được thể hiện qua nhiều cách như URL (
/v1/), header, v.v., và người dùng có thể chuyển đổi theo tốc độ của riêng họ - Quản lý phiên bản có nhược điểm là chi phí bảo trì khổng lồ (tăng endpoint, kiểm thử, hỗ trợ) và gây nhầm lẫn cho người dùng
- Ngay cả khi có một tầng dịch nội bộ như Stripe, vẫn không thể tránh được độ phức tạp gốc rễ
- Chỉ nên đưa quản lý phiên bản API vào như lựa chọn cuối cùng
Yếu tố quyết định thành công của API hoàn toàn phụ thuộc vào giá trị sản phẩm
- Về bản chất, API chỉ là giao diện của một sản phẩm kinh doanh thực tế
- Những API như OpenAI, Twilio rốt cuộc cũng được dùng vì thứ người dùng muốn là chính chức năng mà API cung cấp
- Nếu sản phẩm có giá trị thì người ta vẫn sẽ dùng dù API bất tiện
- Chất lượng API là một thuộc tính kiểu biên lợi thế: chỉ trở thành yếu tố lựa chọn khi năng lực cạnh tranh cốt lõi tương đương nhau
- Ngược lại, một sản phẩm không có API sẽ là rào cản lớn với người dùng kỹ thuật
Nếu thiết kế sản phẩm tệ thì API cũng không thể tốt
- Dù có API hoàn thiện về mặt kỹ thuật, nếu sản phẩm không có sức hút thị trường thì cũng không có nhiều ý nghĩa
- Quan trọng hơn, nếu cấu trúc tài nguyên cơ bản là phi logic hoặc kém hiệu quả thì điều đó cũng sẽ lộ rõ trong API
- Ví dụ, một hệ thống lưu bình luận dưới dạng linked list sẽ khiến ngay cả thiết kế RESTful cũng khó trở nên tự nhiên
- Những vấn đề kỹ thuật có thể bị che đi trong UI sẽ bị phơi bày hết trong API, đồng thời ép người dùng phải hiểu hệ thống ở mức không cần thiết
Xác thực (Authenticaton) và sự đa dạng của người dùng
- Cần обязательно hỗ trợ xác thực bằng API key có vòng đời dài
- Dù có thể hỗ trợ thêm các cách an toàn hơn như OAuth, rào cản gia nhập của API key vẫn thấp hơn hẳn
- Người dùng API không chỉ có kỹ sư mà còn có cả người không phải lập trình viên (bán hàng, lập kế hoạch, sinh viên, lập trình viên hobby, v.v.)
- Những yêu cầu xác thực khó hoặc phức tạp (như OAuth) sẽ trở thành rào cản với người dùng không chuyên
Tính idempotency và xử lý retry
- Với các request mang tính hành động (ví dụ: thanh toán, thay đổi trạng thái, v.v.), tính an toàn khi retry nếu thất bại là rất quan trọng
- Idempotency là việc đảm bảo dù gửi cùng một request nhiều lần thì kết quả cũng chỉ được xử lý một lần
- Cách làm tiêu chuẩn là truyền idempotency key qua tham số hoặc header để ngăn xử lý trùng lặp
- Việc lưu idempotency key chỉ cần một kho key/value đơn giản như Redis, và trong đa số trường hợp có thể áp dụng thời hạn hết hạn định kỳ
- Thường thì không cần cho các request đọc/xóa (theo kiểu REST)
An toàn API và giới hạn tốc độ (Rate limiting)
- Request API qua code có thể diễn ra nhanh hơn thao tác của người dùng rất nhiều
- Chỉ một API vô tình được triển khai cũng có thể bị sử dụng theo những cách ngoài dự kiến (ví dụ: hệ thống chat quy mô lớn)
- Giới hạn tốc độ (ratelimit) là bắt buộc, và cần áp dụng nghiêm ngặt hơn với các tác vụ tốn kém
- Cũng nên cân nhắc tùy chọn tạm vô hiệu hóa API cho khách hàng cụ thể (
killswitch) - Cần cung cấp thông tin về giới hạn tốc độ qua header phản hồi như
X-Limit-Remaining,Retry-After, v.v.
Chiến lược phân trang (Pagination)
- Để trả về hiệu quả các tập dữ liệu lớn (ví dụ: hàng triệu ticket), phân trang là bắt buộc
- Phân trang dựa trên offset đơn giản nhưng sẽ chậm dần với dữ liệu lớn
- Phân trang dựa trên con trỏ (cursor-based) vẫn hiệu quả với tập dữ liệu rất lớn mà không làm giảm hiệu năng truy vấn
- Cách dựa trên con trỏ có phần khó triển khai và sử dụng hơn, nhưng về dài hạn rất có thể là thay đổi thiết yếu
- Khôn ngoan là đưa
next_pagehoặc trường tương tự vào phản hồi để chỉ rõ con trỏ cho request tiếp theo
Trường tùy chọn và quan điểm về GraphQL
- Những trường tốn kém hoặc chậm nên bị loại khỏi phản hồi mặc định và chỉ được thêm khi cần
- Có thể cho phép bao gồm dữ liệu liên quan bằng tham số như
includes - GraphQL có ưu điểm về độ linh hoạt cấu trúc dữ liệu, nhưng cũng có các vấn đề như khó tiếp cận hơn với người không phải lập trình viên, làm phức tạp caching/các edge case, tăng độ khó triển khai phía sau
- Theo kinh nghiệm thực tế, chỉ nên áp dụng GraphQL khi thực sự cần thiết
Đặc điểm của API nội bộ
- API nội bộ trong công ty khác với API bên ngoài (công khai) ở nhiều điều kiện
- Người dùng chủ yếu là kỹ sư phần mềm chuyên nghiệp, nên có thể chấp nhận xác thực phức tạp hơn hoặc thay đổi phá vỡ tương thích
- Dù vậy, các nguyên tắc thiết kế để đảm bảo idempotency, ngăn ngừa sự cố và giảm gánh nặng vận hành vẫn còn nguyên giá trị
Tóm tắt
- API khó thay đổi nhưng phải dễ sử dụng
- Không phá vỡ không gian người dùng là nghĩa vụ quan trọng nhất của người duy trì API
- Quản lý phiên bản API có chi phí lớn nên chỉ dùng như biện pháp cuối cùng
- Cuối cùng, chất lượng API bị chi phối bởi giá trị cốt lõi của sản phẩm
- Một sản phẩm được thiết kế tệ thì dù vá víu ở tầng API cũng có giới hạn lớn
- Quan trọng là hỗ trợ cách xác thực đơn giản, bắt buộc có idempotency cho các request hành động thiết yếu, cùng các biện pháp ổn định như giới hạn tốc độ/phân trang
- API nội bộ có chiến lược khác tùy theo mục đích và đối tượng, nhưng vẫn đòi hỏi sự thận trọng trong thiết kế
- REST, JSON hay các định dạng như OpenAPI không phải là điểm tranh luận cốt lõi. Tài liệu hóa rõ ràng mới quan trọng hơn
1 bình luận
Ý kiến trên Hacker News
Lời khuyên “đừng bao giờ phá vỡ userspace” rất nổi tiếng, nhưng ở đây cũng chỉ ra khá đúng mặt ngược lại của vấn đề. Tức là “kernel API có thể bị phá vỡ mà không báo trước”. Điều quan trọng không phải là “đừng tùy tiện phá vỡ mọi API”, mà là sự cân bằng tinh tế: “chỉ những phần đã tuyên bố ổn định thì tuyệt đối không được phá vỡ”
Dù kernel Linux không phá vỡ userspace, GNU libc lại phá vỡ tính tương thích userspace khá thường xuyên. Vì thế, rốt cuộc không gian người dùng Linux vẫn thường xuyên bị hỏng dù các nhà phát triển kernel có cố gắng đến đâu. Chương trình và thư viện được build bằng libc phiên bản mới đôi khi không chạy đúng trên libc cũ hơn, nên thực tế thường phải nâng cấp tất cả các thành phần cùng lúc. Hơi mỉa mai là Windows đã giải quyết vấn đề này từ vài chục năm trước bằng cách phân phối redistributable
Linux nổi tiếng là không có public driver API ổn định, và tôi nghe nói đó chính là động lực để Google phát triển Fuschia OS. Linux dường như có hai hướng tiếp cận khác nhau đối với userspace và phần cứng
Có vẻ tác giả không thích API dựa trên version cho lắm, nhưng tôi thì luôn khuyên nên đưa quản lý phiên bản vào ngay từ lúc bắt đầu làm ứng dụng. Không thể dự đoán tương lai, nên sớm muộn gì bạn cũng sẽ gặp thay đổi mang tính phá vỡ do yếu tố bên ngoài
Thực ra tôi nghĩ tác giả cũng có khuyến nghị dùng versioning. Trong bài có câu “version là cách thay đổi API một cách có trách nhiệm”, nên rốt cuộc vẫn là đang khuyến khích quản lý phiên bản. Chỉ là việc chuyển sang phiên bản mới nên được xem là lựa chọn cuối cùng
Tôi đồng ý với ý kiến không nhất thiết phải gắn "v1" vào endpoint. Điều thực sự xảy ra khi API phát triển là trước tiên người ta cố giữ tương thích ngược bằng cách thêm field hoặc option vào endpoint cũ. Rồi khi cần làm việc gì đó hoàn toàn không tương thích, thường sẽ đặt hẳn tên endpoint mới và tạo một endpoint hoàn toàn mới (chứ không phải /v2). Nếu phải thay cả API, thì người ta thường khai tử dịch vụ cũ và ra mắt một dịch vụ hoàn toàn khác ngay từ tên gọi. Trong 25 năm đi làm, tôi chỉ thấy đúng một lần có dịch vụ dùng song song "/v1" và "/v2"
Tôi không nghĩ ý của tác giả là ngay từ đầu tuyệt đối không được đưa /v1 vào endpoint. Ý chính là phải cố hết sức để không sinh ra phiên bản mới (/v2). Một khi có /v2, mỗi lần sửa bug đều phải sửa code ở cả hai bên, số lượng nhánh điều kiện tăng theo cấp số nhân, và codebase sẽ trở nên rối như mì spaghetti. Cuối cùng, việc phải hỗ trợ nhiều phiên bản cho thấy thiết kế /v1 ban đầu đã không quan tâm đủ đến khả năng tương thích trong tương lai
Tôi nghĩ thêm versioning về sau cũng không vấn đề gì. Ví dụ ban đầu có thể bắt đầu với /api/posts, rồi phiên bản tiếp theo chỉ cần thêm /api/v2/posts là đủ
Tôi không đồng ý với cách nhúng sẵn version ngay từ đầu. Làm vậy sẽ khiến việc dùng nhiều phiên bản trở nên thường xuyên hơn, và theo tôi điều đó còn tệ hơn
Bài này rất hữu ích. Tôi muốn bổ sung một lời khuyên nữa: chất lượng API tỷ lệ nghịch với mức độ khó khăn để lấy được tài liệu API. Nếu phải ký hợp đồng mới được xem tài liệu, bạn có thể mặc định API đó sẽ có chất lượng rất tệ
Tác giả nói nên lưu idempotency key trong kho key/value như Redis thay vì lưu riêng trong bảng comment, nhưng tôi tự hỏi cách này có đảm bảo idempotency chắc chắn trong mọi trường hợp thất bại hay không. Ví dụ, nếu server đang thực hiện ghi có điều kiện như
SET key 1 NXvà phát hiện key đã tồn tại, thì nó phải bỏ qua việc tạo comment, nhưng tại thời điểm đó yêu cầu trước đó có thể vẫn chưa thực sự được phản ánh vào DB. Việc lưu idempotency key phải được commit cùng với thao tác thực tế trong cùng một transaction, và khi cần thì cũng phải rollback được. Rốt cuộc, bản chất của idempotency key phải là “ID duy nhất của thao tác hoặc request này”. Ví dụ, nó nên là định danh theo từng loại tài nguyên như “tạo comment”, “cập nhật comment”, v.v.Ưu điểm của cursor-based pagination là từ góc nhìn người dùng, ngay cả khi có item mới được thêm vào trong lúc họ tải trang rồi bấm nút “tiếp theo”, họ vẫn không phải xem lại những mục đã thấy trước đó. Cách dùng cursor ghi nhớ ID của object cuối cùng ở trang trước rồi trả về các item sau đó, nên đặc biệt hữu ích cho infinite scroll. Mặt khác, nhược điểm của cursor-based là khó làm tính năng “nhảy đến trang thứ N” hơn
Ngày nay khi nói đến "API", đa số mọi người nghĩ đến việc gửi request tới web app, thiết lập tham số và header rồi lấy dữ liệu về, nhưng bản chất API là “Application Programming Interface”, tức “giao diện của chương trình ứng dụng”. Thuật ngữ này được dùng lần đầu vào thập niên 1940, và đến tận thập niên 1990 gần như vẫn được dùng mà không có nghĩa khác. Lịch sử của API đã hơn 80 năm, và có rất nhiều tài liệu cực kỳ cũ. Nếu suy nghĩ xem các lập trình viên thời đó đã xử lý vấn đề gì và giải quyết ra sao, tôi nghĩ sẽ có những điểm vẫn hữu ích với chúng ta ngày nay
Tôi không đồng ý với quan điểm chỉ xem người dùng nội bộ đơn thuần là 'người dùng'. Dù họ thường là những người kỹ thuật hơn và có khả năng là lập trình viên, họ cũng bận rộn và tập trung vào dự án của mình, nên nhiều khi không có thời gian hay dư địa để theo kịp thay đổi của API. Nếu có thể, điều quan trọng là phải làm đủ các bài test "dogfooding" (tự dùng thật) trong nội bộ trước khi mở ra ngoài. Một khi đã công khai ra bên ngoài, thì lời hứa ‘không phá vỡ userspace’ bắt buộc phải được giữ
Với người dùng nội bộ, thường có sẵn các công cụ đo lường để liên hệ trực tiếp và thúc đẩy migration. Nhờ vậy cũng có thể khai tử các phiên bản API, nên việc đưa versioning vào một cách chiến lược là khá hấp dẫn. Tôi từng trực tiếp tham gia quản lý phiên bản API, và thực sự thấy nó hiệu quả hơn hẳn so với những tổ chức về cơ bản không dùng cách này
Tôi nghĩ cách quản lý phiên bản giúp giải quyết vấn đề này. Một trong những cách tốt nhất để quan tâm đến người dùng nội bộ là cùng cộng tác về spec, đồng thời chia sẻ với các bên liên quan cả phiên bản đang được xây dựng của spec đó. Dù tài liệu có được cập nhật liên tục, việc có một mốc tham chiếu vẫn giúp việc lấy phản hồi cả trong lẫn ngoài thuận lợi hơn, và miễn là tránh được rủi ro xung đột về mặt chính sách thì nó rất hữu ích
Thay vì lưu idempotency key trong Redis, tôi nghĩ nếu có thể thì nên lưu cả idempotency key trong cùng transaction dùng để ghi dữ liệu thực tế để đáng tin cậy hơn
Cảnh báo “đừng bao giờ phá vỡ userspace” thực sự rất quan trọng. Gần đây tôi khá thất vọng khi thấy Spotify, Reddit và Twitter phớt lờ nguyên tắc này
Nhân tiện, tôi cũng khuyên nên xem thêm https://jcs.org/2023/07/12/api, vì ở đó có tổng hợp rất tốt các khuyến nghị về API