Cập nhật 1 tháng 110 views

Tất tần tật kinh nghiệm của tôi về thiết kế API

Phần lớn công việc của một lập trình viên hiện đại đều xoay quanh API: những cổng giao tiếp công khai giúp các phần mềm nói chuyện với nhau, kiểu như API của Twilio vậy. Tôi đã dành rất nhiều thời gian với API, cả ở chiều xây dựng lẫn chiều sử dụng. Tôi từng viết API công khai cho bên thứ ba, API nội bộ (phục vụ riêng cho một trang web), API kiểu REST, GraphQL, và thậm chí là các giao diện dòng lệnh (CLI).

Cũng giống như thiết kế hệ thống phần mềm, tôi thấy nhiều lời khuyên về API ngoài kia đang bị làm quá lên. Mọi người cứ mải miết tranh cãi thế nào mới là REST "chuẩn bài", hay liệu HATEOAS có thực sự đáng dùng hay không. Bài viết này là toàn bộ đúc kết của tôi về cách tạo ra một API tốt.

Thiết kế API là bài toán cân bằng giữa "Thân quen" và "Linh hoạt"

Nếu điều này đúng với thiết kế hệ thống — thì với API nó lại càng đúng: API tốt là một API "nhàm chán". Một API mà tỏ ra "thú vị" thường là một API tồi (hoặc ít nhất, nó sẽ tốt hơn nếu bớt thú vị đi).

Đối với người làm ra nó, API là một sản phẩm phức tạp tốn nhiều công sức chăm chút. Nhưng với người sử dụng, API chỉ là một công cụ giúp họ xong việc. Bất kỳ phút nào họ phải dừng lại để suy nghĩ về bản thân cái API đó đều là thời gian lãng phí. Ở góc độ người dùng, một API lý tưởng phải quen thuộc tới mức mới nhìn qua là đã biết dùng, chưa cần đọc tài liệu.

Tuy nhiên, khác với phần lớn phần mềm thông thường, API là thứ cực kỳ khó sửa. Một khi bạn đã tung API ra và có người dùng, bất kỳ thay đổi nào ở giao diện cũng có thể làm sập ứng dụng của họ. Dĩ nhiên bạn vẫn có thể sửa, nhưng cái giá phải trả rất đắt: mỗi lần bắt người dùng phải sửa lại code theo bạn, họ sẽ nghiêm túc cân nhắc việc bỏ sang dùng một API khác ổn định hơn. Điều này buộc người làm API phải tính toán thật kỹ và làm chuẩn ngay từ đầu.

Thế là người làm API rơi vào một thế giằng co thú vị: Một mặt muốn làm ra API đơn giản nhất có thể, mặt khác lại muốn thêm thắt các kỹ thuật thông minh để sau này dễ tùy biến. Về cơ bản, thiết kế API chính là đi tìm điểm cân bằng giữa hai mục tiêu trái ngược đó.

NGUYÊN TẮC VÀNG: KHÔNG ĐƯỢC TỰ Ý BẺ GÃY CODE CỦA NGƯỜI DÙNG

Điều gì xảy ra khi bạn bắt buộc phải sửa API?

Nếu là thêm mới — ví dụ như nhét thêm một trường (field) dữ liệu vào phản hồi — thì thường không sao. Dù vẫn có vài ứng dụng sẽ lăn ra lỗi nếu nhận được nhiều dữ liệu hơn kỳ vọng, nhưng theo tôi đó là do phía người dùng viết code chưa khéo. Lẽ ra code nhận dữ liệu API phải biết tự bỏ qua các trường không ngờ tới.

Tuy nhiên, bạn tuyệt đối không được xóa trường dữ liệu cũ hoặc đổi kiểu dữ liệu của nó. Bạn cũng không thể tự ý đổi cấu trúc dữ liệu (kiểu như chuyển user.address thành user.details.address). Nếu bạn làm thế, tất cả đoạn code đang chạy dựa vào cấu trúc cũ sẽ sập ngay lập tức. Người dùng sẽ báo lỗi, và các kỹ sư bảo trì hệ thống đó sẽ cực kỳ tức giận vì bạn đã vô trách nhiệm làm hỏng phần mềm của họ.

Triết lý ở đây giống như câu nói nổi tiếng của Linus Torvalds: "WE DO NOT BREAK USERSPACE" (Tuyệt đối không làm hỏng môi trường đang chạy của người dùng). Đứng ở vị trí người làm API, bạn gánh trên mình một trách nhiệm lớn là không được gây tổn hại cho các hệ thống phụ thuộc phía sau. Tiêu chuẩn này nghiêm ngặt đến vậy là vì hệ sinh thái phần mềm phụ thuộc lẫn nhau rất dây dưa. Chỉ cần một API ở đầu nguồn ẩu tả là có thể kéo theo hàng trăm, hàng nghìn phần mềm phía sau ngưng hoạt động.

Đừng bao giờ đổi API chỉ vì thấy đổi xong "nhìn gọn hơn" hay vì cấu trúc cũ "hơi chướng mắt". Trong chuẩn HTTP, từ referer vốn dĩ bị viết sai chính tả (đúng ra phải là referrer), nhưng hàng chục năm qua người ta vẫn giữ nguyên như thế. Bởi vì nguyên tắc là: không được làm hỏng code của người dùng.

Đổi API sao cho không làm hỏng ứng dụng người dùng?

Thực ra rất hiếm trường hợp bắt buộc phải thay đổi theo kiểu "đập đi xây lại". Nhưng nếu lợi ích kỹ thuật quá lớn khiến bạn chấp nhận "cắn răng" thực hiện, thì làm thế nào cho văn minh? Câu trả lời là: Phân chia phiên bản (Versioning).

Phân phiên bản API hiểu đơn giản là: Chạy song song cả bản cũ lẫn bản mới cùng lúc. Các ứng dụng đang chạy cứ tiếp tục dùng bản cũ, còn ai muốn dùng tính năng mới thì chủ động chuyển sang bản mới. Cách đơn giản nhất là gắn thêm chữ /v1/ vào đường dẫn URL. Ví dụ, API chat của OpenAI nằm ở v1/chat/completions, nên sau này nếu muốn đập đi làm lại toàn bộ, họ chỉ cần mở đường dẫn v2/chat/completions mà không làm ảnh hưởng đến ai.

Khi đã chạy song song hai bản, bạn có thể bắt đầu nhắc người dùng nâng cấp. Quá trình này rất mất thời gian, tính bằng tháng hoặc bằng năm. Dù bạn có treo thông báo trên web, viết tài liệu, gửi email riêng hay gắn cảnh báo vào dữ liệu trả về, thì đến ngày chính thức khai tử bản cũ, kiểu gì bạn vẫn sẽ nhận về một rổ gạch đá từ những người dùng chậm chân. Nhưng ít nhất, bạn cũng đã làm hết trách nhiệm của mình.

Có nhiều kiểu phân phiên bản khác nhau. Stripe thì quản lý phiên bản qua Header và cho người dùng chọn bản mặc định trong trang cài đặt. Tuy cách làm khác nhau nhưng nguyên lý vẫn vậy: giúp người dùng yên tâm là ứng dụng của họ sẽ không đột ngột bị sập, và họ có thể thong thả nâng cấp theo tiến độ của mình.

Thú thật, tôi không khoái việc phân phiên bản API. Cùng lắm nó chỉ là một "tội ác bắt buộc phải làm". Nó làm người dùng rối trí vì tìm tài liệu rất dễ nhầm phiên bản. Còn với người bảo trì thì đúng là ác mộng: Nếu bạn có 30 đường dẫn API, mỗi lần lên một phiên bản mới là bạn phải gánh thêm 30 đường dẫn nữa để chăm sóc. Chẳng mấy chốc bạn sẽ ngập trong hàng trăm API cần kiểm thử, sửa lỗi và hỗ trợ người dùng.

Tất nhiên, thêm phiên bản mới không có nghĩa là lượng code của bạn tăng gấp đôi. Một hệ thống backend thông minh sẽ có một lớp "dịch thuật" ở giữa để biến đổi dữ liệu trả về theo đúng phiên bản mà khách hàng yêu cầu. Stripe cũng làm theo cách này: logic cốt lõi bên dưới giữ nguyên cho mọi phiên bản, chỉ có đoạn xử lý dữ liệu đầu vào/đầu ra là cần nhận biết phiên bản. Dù vậy, cách làm này vẫn sẽ có những chỗ rò rỉ logic. (Có một bình luận năm 2017 trên Hacker News của một kỹ sư Stripe thừa nhận rằng nhiều thay đổi phiên bản vẫn buộc họ phải viết thêm điều kiện ngoắc ngéo ngay trong phần code lõi).

Nói ngắn gọn: Chỉ phân chia phiên bản API khi không còn cách nào khác.

API thành công hay không là do Sản phẩm quyết định

Bản thân một API đứng riêng lẻ thì chẳng có giá trị gì. Nó chỉ là cây cầu nối người dùng tới thứ họ thực sự cần. Với OpenAI, thứ người dùng cần là trí tuệ của mô hình ngôn ngữ. Với Twilio, đó là khả năng gửi tin nhắn SMS. Chẳng ai dùng một API chỉ vì nó được thiết kế "nghệ thuật" cả. Họ dùng nó vì sản phẩm bên trong. Nếu sản phẩm của bạn đủ tốt, API có tệ đến mấy người ta vẫn cắm đầu vào dùng.

Đó là lý do vì sao một số API phổ biến nhất hiện nay lại cực kỳ khó dùng. Facebook và Jira nổi tiếng là có API như một cơn ác mộng, nhưng chẳng sao cả — nếu bạn bắt buộc phải tích hợp với Facebook hay Jira, bạn vẫn phải bỏ thời gian ra mà mày mò. Các công ty đó có nên làm API tốt hơn không? Có chứ, tốt quá đi chứ. Nhưng tại sao họ phải tốn tiền tốn sức cải thiện khi mà đằng nào người ta cũng phải dùng? Làm API tử tế khó lắm chứ bộ.

Trong phần còn lại của bài viết, tôi sẽ đưa ra nhiều lời khuyên cụ thể để viết API tốt. Nhưng hãy luôn nhớ rằng: phần lớn thời gian, chuyện này không quyết định tất cả. Nếu sản phẩm của bạn ngon, một API vừa đủ dùng là quá đủ; ngược lại nếu sản phẩm dở, API có đẹp đẽ đến mấy cũng vứt. Chất lượng API chỉ là yếu tố phụ: nó chỉ có tác dụng khi người dùng đang cân nhắc giữa hai sản phẩm có giá trị ngang ngửa nhau.

Tuy nhiên, việc có API hay không lại là chuyện hoàn toàn khác. Một sản phẩm mà hoàn toàn không cung cấp API thì đúng là điểm trừ lớn. Dân kỹ thuật luôn đòi hỏi phải có cách viết code để tự động hóa công việc trên phần mềm họ mua.

Sản phẩm thiết kế ẩu thì API không thể ngon được

Một API xịn xò về mặt kỹ thuật cũng không gánh nổi một sản phẩm chẳng ai muốn dùng. Ngược lại, một sản phẩm có kiến trúc bên dưới yếu kém sẽ khiến việc làm ra một API đẹp đẽ trở thành nhiệm vụ bất khả thi. Lý do là vì thiết kế API thường sẽ soi chiếu trực tiếp từ các "đối tượng cốt lõi" của sản phẩm (ví dụ với Jira thì đó là các task, dự án, người dùng...). Khi các đối tượng này được tổ chức lộn xộn, API cũng sẽ lộn xộn theo.

Lấy ví dụ một hệ thống blog lưu trữ bình luận dưới dạng danh sách liên kết (linked list) trong bộ nhớ (bình luận này chỉ tới bình luận tiếp theo qua một trường next). Đây là cách lưu trữ rất dở. Nếu cứ thế bê nguyên vào thiết kế REST API, bạn sẽ cho ra một giao diện trông như thế này:

GET /comments/1 -{ id: 1, body: "...", next_comment_id: 2 }

Hoặc tệ hơn nữa:

GET /comments -{ body: "...", next_comment: { body: "...", next_comment: {...} } }

Ví dụ này nghe có vẻ ngớ ngẩn, vì thực tế bạn hoàn toàn có thể duyệt qua danh sách đó rồi trả về một danh sách (array) bình luận gọn gàng. Nhưng kể cả khi bạn chịu khó làm việc đó, bạn sẽ duyệt sâu đến mức nào? Trong một bài viết có hàng nghìn bình luận, lẽ nào người dùng không thể lấy được các bình luận nằm ở phía sau? Hay API của bạn lại phải chạy một tiến trình ngầm (background job), buộc giao diện API phải biến thành dạng phức tạp như thế này:

POST /comments/fetch_job/1 -{ job_id: 589 }
GET /comments_job/589 -{ status: 'complete', comments: [...] }

Đó chính là cách những API thảm họa ra đời. Những hạn chế kỹ thuật vốn có thể giấu giếm khéo léo ở mặt giao diện đồ họa (UI) thì nay lại bị phơi bày trần trụi ra API, bắt người dùng API phải hiểu sâu vào kiến trúc hệ thống — một điều đáng lẽ họ không cần phải bận tâm.

Xác thực (Authentication)

Hãy cho phép người dùng xài API bằng API Key dùng lâu dài (long-lived API key). Đúng là API Key thì không bảo mật bằng mấy cơ chế cấp quyền ngắn hạn như OAuth (dù bạn vẫn nên hỗ trợ thêm OAuth). Nhưng điều đó không quan trọng bằng trải nghiệm ban đầu. Mọi ứng dụng tích hợp API đều bắt đầu từ một đoạn code thử nghiệm đơn giản, và xài API Key là cách nhanh nhất để đoạn code đó chạy được. Hãy tạo điều kiện dễ nhất có thể để các kỹ sư bắt tay vào làm.

Mặc dù người dùng API chắc chắn là có viết code, nhưng rất nhiều người trong số họ không phải là lập trình viên chuyên nghiệp. Họ có thể là dân sales, quản lý sản phẩm (PM), học sinh, sinh viên hoặc người làm vì sở thích. Khi là một kỹ sư làm việc ở công ty công nghệ, bạn rất dễ mặc định rằng mình đang viết API cho những người giống mình: những lập trình viên toàn thời gian, lành nghề và chuyên nghiệp. Nhưng thực tế không phải vậy. Bạn đang phục vụ một tập khách hàng rất rộng, và nhiều người trong số họ không hề thạo việc đọc hay viết code. Nếu API của bạn bắt họ làm những bước phức tạp — như thực hiện quy trình xác thực OAuth ngoắc ngéo — rất nhiều người sẽ đầu hàng ngay từ bước gửi xe.

Tính lũy đẳng (Idempotency) và Cơ chế thử lại (Retries)

Khi một yêu cầu API thành công, bạn biết chắc nó đã xong việc. Nhưng khi nó thất bại thì sao? Một số lỗi thì rất rõ ràng: mã 422 nghĩa là dữ liệu gửi lên không hợp lệ, hệ thống chưa hề xử lý gì cả. Nhưng nếu là lỗi 500 thì sao? Hay lỗi hết thời gian chờ (timeout) thì sao?

Điều này cực kỳ quan trọng đối với các API có hành động thay đổi dữ liệu. Ví dụ bạn gọi API của Jira để tạo một bình luận, nhưng yêu cầu bị timeout hoặc báo lỗi 500. Giờ bạn có nên bấm gửi lại (retry) không? Bạn đâu biết chắc bình luận đó đã kịp tạo dưới cơ sở dữ liệu hay chưa (vì lỗi có thể xảy ra ở đoạn trả kết quả về). Nếu gửi lại, khả năng cao bạn sẽ làm đăng trùng 2 bình luận. Chuyện này còn tồi tệ hơn nhiều nếu thao tác đó liên quan đến tiền bạc hay phát thuốc cho bệnh nhân.

Giải pháp cho vấn đề này là Tính lũy đẳng (Idempotency) — hiểu nôm na là: "Yêu cầu có thể gửi lại nhiều lần an toàn mà không sợ bị trùng lặp dữ liệu."

Cách làm phổ biến là hỗ trợ một "Mã lũy đẳng" (Idempotency Key) do người dùng tự tạo truyền vào Header hoặc tham số. Khi máy chủ API nhận được lệnh "tạo bình luận" kèm theo mã này, việc đầu tiên nó làm là kiểm tra xem mã này đã từng được xử lý chưa. Nếu xử lý rồi thì bỏ qua; nếu chưa thì mới tạo bình luận và lưu lại cái mã đó. Bằng cách này, người dùng có thử lại bao nhiêu lần đi nữa, miễn là dùng chung một mã lũy đẳng, thao tác cũng chỉ được thực hiện đúng một lần.

Nên lưu mã này ở đâu? Tôi thấy một số bên lưu thẳng vào bảng dữ liệu chính (như thêm một cột trong bảng comments), nhưng tôi thấy không nhất thiết phải làm vậy. Cách dễ nhất là quăng nó vào Redis hoặc kho lưu trữ Key/Value tương tự. Bản thân chuỗi UUID đã đủ duy nhất nên không nhất thiết phải phân loại theo từng user, nhưng nếu làm được thì càng tốt. Nếu không phải là API thanh toán tiền bạc, bạn có thể cho mã này tự xóa sau vài giờ, vì thường người ta chỉ thử lại ngay lập tức khi gặp lỗi.

Có cần bắt buộc dùng mã lũy đẳng cho mọi API không?

Với các lệnh chỉ đọc (Read), bạn không cần, vì đọc đi đọc lại nhiều lần chẳng hại ai. Với các lệnh xóa (Delete) theo ID tài nguyên, bạn cũng không cần, vì bản thân cái ID đó đã đóng vai trò là mã lũy đẳng rồi. Hãy thử nghĩ xem: nếu bạn gửi liên tiếp 3 lệnh DELETE /comments/32, nó sẽ không thể xóa 3 bình luận được. Lệnh đầu tiên sẽ xóa bình luận số 32, 2 lệnh sau sẽ báo lỗi 404 vì không còn tìm thấy bình luận đó nữa.

Trong hầu hết trường hợp, tính lũy đẳng nên là một lựa chọn tùy chọn (optional). Như tôi đã nói, bạn cần làm sao để API dễ dùng nhất với cả những người không chuyên (vốn là những người rất mù tờ mờ về khái niệm lũy đẳng). Xét cho cùng, kéo được nhiều người vào dùng API của bạn vẫn quan trọng hơn là việc thi thoảng lại có một vài dữ liệu bị trùng lặp...