1. Giới thiệu
Tục ngữ Việt Nam có câu “Sai một ly, đi một dặm” để nói về sự cần thiết của việc suy nghĩ thấu đáo trước khi hành động. Không nên vội vàng hành động để tránh hậu quả khó lường và những tổn thất khó bù đắp. Cẩn thận trong công việc là rất cần thiết, đặc biệt là khi làm việc với những thành phần có ảnh hưởng lớn đối với cả hệ thống, ví dụ như API.
API (Application Programming Interface) là một giao diện cho phép các ứng dụng khác nhau giao tiếp và trao đổi dữ liệu với nhau. API giúp các nhóm phát triển có thể tận dụng chức năng của một hệ thống mà không cần phải hiểu rõ chi tiết bên trong của hệ thống đó.
Khi chúng ta phát triển API, những lỗi thường được phát hiện sớm nhờ quy trình kiểm thử và ít khi ảnh hưởng đến người dùng. Tuy nhiên, lỗi API vẫn có thể xảy ra và trong một số trường hợp, chúng có thể ảnh hưởng đến hàng triệu người dùng. Một bài nghiên cứu được trình bày tại Hội thảo Quốc tế lần thứ 24 của IEEE đã thống kê rằng, cùng với sự phát triển của công nghệ, tần suất xảy ra lỗi API cũng tăng dần qua các năm.
Chất lượng của API và hệ thống có thể giảm xuống nếu lỗi xảy ra thường xuyên, và các nhà phát triển sẽ muốn tìm đến những hệ thống khác để thay thế. Do đó, tính ổn định của API là rất quan trọng, nhất là khi thực hiện những thay đổi hay cập nhật phiên bản mới, điều này cần các kỹ sư đặc biệt chú ý. Một API chất lượng tốt có nhiều tính chất tiêu biểu, nhưng nội dung lần này sẽ tập trung vào khả năng tương thích của API. Khả năng tương thích có hai chiều, đó là tương thích ngược (backward compatibility) và tương thích xuôi (forward compatibility), và chúng ảnh hưởng đến tính tin cậy (reliability) cũng như tính linh hoạt (flexibility) của thiết kế API.
Đối với một API có tính tương thích cao, các thay đổi có thể được thực hiện một cách liền mạch và hiệu quả mà không gây ra downtime hay lỗi giữa hệ thống và client. Rủi ro trong quá trình triển khai và sử dụng sẽ được giảm thiểu đáng kể, đặc biệt là đối với các hệ thống cần nhiều thời gian để cập nhật phiên bản mới đến người dùng, ví dụ như back-end API cho các ứng dụng di động.
2. Backward Compatibility - Tương thích ngược
Đặc điểm của tương thích ngược là những thay đổi về API không ảnh hưởng đến sự vận hành bình thường của các client sử dụng phiên bản trước đó của API. Trong ngành sản xuất game, lợi ích của tương thích ngược giúp tăng tốc quá trình xây dựng cơ sở người dùng. Ví dụ, nếu game thủ có thể chơi các game cũ mà họ yêu thích trên một console mới, họ sẽ sẵn lòng đầu tư để nâng cấp console. Trong thiết kế và phát triển API, tính tương thích ngược giúp hệ thống giữ vững được cơ sở người dùng và giảm số lượng outage có thể xảy ra, vì hệ thống có thể cập nhật liên tục mà không gây ảnh hưởng đến người dùng. Ngoài ra, quá trình giữ tương thích ngược cũng tạo ra khoảng thời gian đệm giúp cho client chuyển đổi dần sang API mới, và giúp việc gỡ bỏ phiên bản cũ của API diễn ra suôn sẻ hơn.
a. Thay đổi API có tương thích ngược
(Các ví dụ trong bài viết sẽ sử dụng REST API, nhưng nội dung sẽ được trình bày một cách khái quát nhất có thể để có thể áp dụng được với các loại API khác.)
Dưới đây là ví dụ của một payment API (đã được đơn giản hóa) khi thay đổi mà vẫn giữ được tính tương thích ngược.
trước | sau |
|
|
Chúng ta hay gặp các thay đổi như thêm một trường tùy chọn (optional field) cho request hoặc thêm một trường cho response, và đa phần các thay đổi này đều giữ được tính tương thích ngược. Những thay đổi sau đây thường đảm bảo tính tương thích ngược cho API (đây là những thay đổi an toàn):
- Thêm endpoint mới
- Thêm phương thức HTTP mới vào endpoint cũ
- Thêm header mới
- Thêm query parameter mới
- Thêm trường tùy chọn (không bắt buộc) cho request
- Thêm trường mới cho response
- Đổi một trường request từ bắt buộc thành không bắt buộc
- ...
Nhìn chung, những thay đổi trên sẽ không có ảnh hưởng nghiêm trọng đến client. Tuy nhiên, trong trường hợp client có kiểm tra dạng dữ liệu nghiêm ngặt (strict type checking), thì vẫn có thể xảy ra lỗi.
b. Thay đổi API không tương thích ngược
Cùng là thay đổi cho payment API, nhưng như ví dụ dưới đây thì sẽ xảy ra lỗi tương thích.
trước | sau |
|
|
Việc thay đổi định dạng dữ liệu của các trường request (chẳng hạn từ số tăng dần thành chuỗi UUID cho userId/orderId), cũng như thay đổi mã HTTP, dễ dẫn đến client không kịp chuẩn bị logic cần thiết để xử lý thay đổi, từ đó tạo ra lỗi. Ngoài ra, thêm giá trị cho trường response cũng thường gây ra lỗi, nhưng nếu tài liệu API được chuẩn bị trước và client có thay đổi tương ứng thì có thể tránh được lỗi này. Những trường hợp sau thường được định nghĩa là các thay đổi không tương thích (thay đổi không an toàn) cho API:
- Gỡ bỏ endpoint
- Gỡ bỏ phương thức HTTP có sẵn của endpoint
- Gỡ bỏ một hoặc nhiều trường của request và response
- Thêm một trường bắt buộc trong request
- Đổi một trường không bắt buộc thành bắt buộc trong request
- Đổi một trường bắt buộc thành không bắt buộc trong response
- Thay đổi định dạng dữ liệu của trường response
- Thêm một giá trị vào trường Enum trong response
- Thay đổi cấu trúc của request hoặc response
- Thay đổi URL của API
- Thay đổi header
- Thay đổi mã HTTP status
- ...
Các thay đổi liên quan đến việc xoá hoặc sửa thường dễ dẫn đến lỗi không tương thích ngược, nên các trường hợp này cần được đánh giá và chuẩn bị kỹ càng trước khi thực hiện.
c. Phát hiện thay đổi không tương thích
Ngoài việc dựa vào kiểm thử end-to-end (E2E), để phát hiện sớm các thay đổi không tương thích, các nhóm dự án có thể lựa chọn một số phương án bổ sung. Ví dụ, họ có thể áp dụng các định dạng API tiêu chuẩn hoặc áp dụng phương pháp spec-first development.
i. Áp dụng định dạng API tiêu chuẩn
Sử dụng tiêu chuẩn API giúp cho hệ thống có tính nhất quán và khi có thay đổi, khả năng phát hiện lỗi sớm sẽ cao hơn và hiệu quả hơn. Các công cụ phân tích tĩnh thường được xây dựng theo một tiêu chuẩn API nào đó, và việc tái sử dụng lại công cụ cho một API không tuân theo chuẩn là rất khó. Đối với REST API, hệ thống có thể áp dụng tiêu chuẩn OpenAPI và sử dụng các công cụ phân tích như openapi-diff để tìm ra những thay đổi không tương thích. Để tích hợp các công cụ này vào quá trình CI/CD thường khá đơn giản, và chúng giúp nhà phát triển có thể nhận biết được rủi ro từ những thay đổi một cách sớm hơn.
ii. Spec-first development
Phương pháp phát triển dựa trên spec trước (spec-first development) thường được so sánh với phương pháp phát triển dựa trên triển khai trước (implementation-first development), hay còn có thể gọi là cách phát triển truyền thống. Trong phương pháp truyền thống, hệ thống sẽ thay đổi dựa trên business logic trước, sau đó mới dẫn tới các thay đổi kèm theo ở phía client. Trong quá trình này, API thường được thay đổi từ phía back-end, và sau đó các client sẽ dần chuyển sang phiên bản mới của API. Rủi ro về lỗi API xảy ra khi thay đổi ở phía back-end không tương thích ngược.
Spec-first development sẽ bắt đầu từ API specification (spec) - trước cả những business logic cần thiết. Dựa trên API spec này, cả hệ thống back-end và client sẽ phát triển logic tương ứng. Những thay đổi trong quá trình phát triển của một bên sẽ được cập nhật trực tiếp vào API spec, và bên còn lại sẽ được thông báo để thay đổi theo cập nhật mới.
Để áp dụng spec-first development, chúng ta cần có một hệ thống kiểm thử hợp đồng (contract testing) để lưu trữ các phiên bản spec cũng như tạo ra cầu nối giúp hệ thống và client kiểm thử những thay đổi trong logic so với spec. Hiện nay, có một số framework hỗ trợ kiểm thử hợp đồng, trong đó có cả những framework mã nguồn mở như Spring Cloud Contract hay Pact. Lợi ích của contract testing là giúp quá trình review các thay đổi của API diễn ra nhanh hơn, do đó giúp tăng hiệu suất của quá trình phát hiện và sửa lỗi.
d. Breaking changes
Mặc dù duy trì tính tương thích ngược là điều mà các nhóm phát triển luôn muốn thực hiện, nhưng có những trường hợp mà chúng ta không thể tránh khỏi việc phải tạo ra những thay đổi API mà không tương thích với client cũ - đây là những breaking change. Ví dụ, có thể có yêu cầu liên quan đến bảo mật - như đổi ID tăng dần thành UUID, hoặc tăng hiệu năng - như xoá những trường không cần thiết để thu gọn một response phức tạp, v.v. Trong những trường hợp bất khả kháng như vậy, các nhóm phát triển có thể dựa vào độ khẩn cấp của yêu cầu để lựa chọn một khoảng thời gian đệm phù hợp.
Để giúp client vận hành bình thường trong quá trình thay đổi, khi có breaking change cho API thì việc chia phiên bản (versioning) và thông báo cho người dùng là cần thiết. Thông báo thay đổi phiên bản giúp người dùng lựa chọn được phiên bản tương thích và tránh được các lỗi có thể xảy ra sau khi API được cập nhật.
i. Versioning Specifications
Các cách đặt phiên bản thường thấy cho API là semantic versioning hoặc calendar versioning.
Semantic versioning đang là phương án phổ biến nhất, tuy nhiên, nếu thời gian phát hành phiên bản mới quan trọng đối với cả nhóm phát triển và client, hoặc nếu cần cách so sánh đơn giản giữa các phiên bản, thì có thể lựa chọn calendar versioning. Ví dụ như Stripe API chọn calendar versioning và họ lưu thời điểm đầu tiên mà client sử dụng API, sau đó sẽ chỉ sử dụng API tương thích với thời điểm đó - tất nhiên, có cơ chế để client tự thay đổi phiên bản của API theo mong muốn.
Ngoài hai cách đặt phiên bản như trên, nhóm phát triển cũng có thể tự tạo custom versioning, nhưng sẽ ít gặp hơn. Cũng không có những yêu cầu là bắt buộc phải chọn cách này hay cách khác, cho nên có thể tự do chọn cách đặt phiên bản phù hợp theo ý muốn của nhóm.
ii. Versioning Methods
Sau khi lựa chọn cách đặt phiên bản, cần phải có phương pháp để liên kết giữa API với phiên bản. Các phương pháp hay được sử dụng là (sắp xếp tương đối theo đ ộ thường gặp):
- Đặt version trong Accept header
-
Version trong URI path
- Sử dụng custom header cho version, ví dụ như X-API-Version
- Đặt version ở query parameter
- Version ở subdomain
- Client tự kiểm soát version (từ client config và cần lưu lại trên hệ thống)
- ...
Ví dụ dưới đây là API được liên kết với phiên bản bằng cách sử dụng Accept header
trước | sau |
|
|
Lợi ích của việc đặt phiên bản (versioning) là giúp việc theo dõi các vấn đề trở nên dễ dàng hơn, tạo ra khoảng thời gian đệm cho việc gỡ bỏ phiên bản cũ, và cũng là phương pháp để đảm bảo tương thích xuôi (client sẽ có logic để kiểm tra phiên bản ngay từ đầu).
e. Deprecation - gỡ bỏ
Việc duy trì đồng thời nhiều phiên bản của API có thể dẫn đến sự thừa thãi (redundancy). Mặc dù hệ thống sẽ có độ tin cậy và ổn định cao, nhưng cũng sẽ tốn thêm chi phí cho việc duy trì và vận hành. Thêm vào đó, do những lý do như thay đổi cơ sở hạ tầng, xoá hệ thống cũ (legacy), hoặc giảm rủi ro về bảo mật, chúng ta có thể buộc phải gỡ bỏ những phiên bản cũ khỏi hệ thống.
Mặc dù tính ổn định cũng là mục tiêu của nhóm phát triển, nhưng nếu có xung đột với những mục tiêu phát triển khác quan trọng hơn, thì việc gỡ bỏ phiên bản cũ là lựa chọn cần thiết.
Quá trình gỡ bỏ sẽ có ảnh hưởng đến người dùng, và để giảm thiểu những ảnh hưởng đó cũng như hậu quả, chúng ta có thể áp dụng một số cách sau:
- Cung cấp thông tin đầy đủ và chính xác cho client cũng như người dùng về hạn cuối để thay đổi
- Qua các kênh thông báo hay kênh liên hệ người dùng như Email, Slack, Gitter.im, v.v.
- Trong changelog/release note.
- Các nguồn khác để đảm bảo thông tin về hạn cuối này có thể được truyền đến cơ sở người dùng rộng nhất một cách có thể.
- Cập nhật tài liệu về phiên bản mới và thông tin hướng dẫn chuyển đổi cho người dùng.
- Nếu có thể, giám sát lượng người dùng API cũ và thông tin thường xuyên với những client đó để cập nhật.
- Giảm lưu lượng dần dần cho phiên bản cũ.
- Duy trì backup một thời gian sau khi gỡ bỏ.
3. Forward Compatibility - Tương thích xuôi
So với tương thích ngược, tương thích xuôi ít được nói đến hơn, vì tính tương thích xuôi thường được định hình trong quá trình thiết kế hoặc những bước đầu của quá trình phát triển, và vì thế nên có ít thảo luận về đề tài này hơn.
Đặc điểm của tương thích xuôi là API có thể xử lý được các thay đổi, bổ sung hay cải tiến trong tương lai. Ví dụ, khi có client được phát triển dựa theo phiên bản mới của API mà gọi về phiên bản cũ, thì hệ thống của phiên bản cũ vẫn có thể đảm bảo client vận hành được bình thường.
Một trong những ví dụ thực tế nhất về lợi ích của tương thích xuôi là khi hệ thống cần phải quay trở lại phiên bản cũ (rollback - do xảy ra lỗi hay có thay đổi về yêu cầu) - khi đó có thể đã có những client cập nhật lên phiên bản mới, và dù hệ thống đang chạy phiên bản cũ nhưng client vẫn có thể hoạt động được.
"Hãy bảo thủ trong những gì bạn gửi đi và tự do trong những gì bạn chấp nhận từ người khác" - Định luật Postel nói về tính mạnh mẽ đồng thời cũng khái quát lại việc làm thế nào để đạt được tương thích xuôi. Ví dụ dưới đây sẽ thể hiện một hệ thống không có tương thích xuôi.
trước | sau |
|
|
Trong trường hợp này, client mới gửi thêm một trường currency
, nhưng hệ thống không xử lý được trường mới này và trả lại lỗi 500. Vậy, cần những thay đổi gì để hệ thống có thể hỗ trợ những trường mới mà chưa được biết đến?
Chúng ta có thể lựa chọn bỏ qua những trường mà không được biết trước và chỉ xử lý những thông tin đã được dự kiến, và API vẫn có thể vận hành như thường và trả về kết quả. Ví dụ, nếu sử dụng Java cho trường hợp trên, chúng ta có thể thay đổi model của request để chấp nhận unknown field.
forward compatibility |
|
a. Phương pháp tăng khả năng tương thích xuôi
Tương thích xuôi thường khó để đảm bảo hơn tương thích ngược vì cần phải xử lý những thứ chưa được biết trước. Tuy nhiên, có một vài phương pháp có thể áp dụng trong quá trình thiết kế và phát triển để có thể dự đoán phần nào những thay đổi:
- Nghiên cứu kỹ về lĩnh vực kinh doanh: Ví dụ, trong mảng thanh toán, có thể dự đoán được những loại tiền tệ nào được chấp nhận, định dạng của số tiền như thế nào, hay liệu có các phương thức thanh toán nào khác không.
- Phân tích khả năng mở rộng của công nghệ và cơ sở hạ tầng đang sử dụng: Liệu có thể thay đổi mà không ảnh hưởng đến client không, có khả năng áp dụng kiểm thử hợp đồng (contract testing) không, nếu cần chuyển lưu lượng về phiên bản cũ thì có khả thi không, có cần tích hợp với phân trang (pagination) hay giới hạn tốc độ (rate limit) ngay từ đầu không và cần chuẩn bị gì, v.v.
- Đặt phiên bản cho API ngay từ lần release đầu tiên: giúp người dùng hiểu là sẽ có những thay đổi trong tương lai, và họ có thể lựa chọn logic thích hợp theo từng phiên bản.
- Chú ý đến cách sử dụng các định dạng dữ liệu, và đặc biệt là khi có enum, vì đây là những trường hợp dễ xảy ra lỗi nhất.
b. Tự do hay an toàn?
Vậy chúng ta có nên luôn mở rộng hệ thống với các trường chưa được biết đến không? Có thể luôn sử dụng chuỗi ký tự (string/text) cho tất cả các trường thay vì các kiểu dữ liệu khác được không?
Cộng đồng công nghệ không có một câu trả lời rõ ràng cho việc nên hay không nên làm như vậy. Ví dụ, Ruby on Rails framework mặc định cho phép tất cả các trường không biết trước, trong khi Spring-boot framework (Java) lại sử dụng thư viện mà mặc định sẽ trả lỗi nếu có trường không biết trước. Các câu hỏi về vấn đề tương tự trên StackOverflow hay Quora cũng thường có hai loại trả lời - giữa 200 OK và 400 Bad Request. Điều này cho thấy lựa chọn vẫn chủ yếu là do các kỹ sư, và họ cần đánh giá xem có những rủi ro hay đánh đổi gì trước khi quyết định.
Một hệ thống khi tự do trong khả năng chấp nhận các loại dữ liệu thì có những đánh đổi trong các tính chất khác, và có lẽ tính an toàn của hệ thống sẽ là dễ bị ảnh hưởng nhất. Nếu hệ thống không chú trọng vào bảo mật ở mức cần thiết, thì có thể tạo cơ hội cho các cuộc tấn công từ phía bên ngoài qua API.
OWASP ASVS (Application Security Verification Standard) V5.1 có đề xuất danh mục kiểm thử để hạn chế các cuộc tấn công về input có thể xảy ra như parameter pollution, mass parameter assignment, hay injection. Đã có những trường hợp mà hệ thống bị tấn công do request không được xử lý cẩn thận và ảnh hưởng trực tiếp tới domain model, do đó gây hư hại cho hệ thống. Ngoài ra, khi API chấp nhận dữ liệu một cách tự do thay vì báo lỗi có thể dẫn đến các client trở nên "cả tin" (naive), và có thể sẽ có những API giả mạo để lấy thêm thông tin từ client.
Các cuộc tấn công có thể xảy ra ngay cả khi chỉ có một sơ suất nhỏ trong quá trình kiểm thử hay giám sát, và vì API là thành phần đối ngoại của hệ thống nên chắc chắn cần phải tối đa hoá độ an toàn của API. Tuy nhiên, nếu tính bảo mật và an toàn được đảm bảo, thì nhóm phát triển hoàn toàn có thể hỗ trợ tương thích xuôi cho API khi thiết kế.
4. Kết luận
Việc xảy ra lỗi tương thích của API thường được phát hiện sớm và ảnh hưởng không được mở rộng, nhưng thực tế cho thấy những lỗi này vẫn xảy ra thường xuyên. Tính tương thích của API có ảnh hưởng trực tiếp đến độ ổn định và tin cậy của một hệ thống. Tuy nhiên, ngoài tính tương thích, cần phải đánh giá các phương diện khác trước khi đưa ra quyết định liên quan đến API. Đặc biệt nhất, hãy luôn quan tâm đến người dùng và cố gắng giảm thiểu các ảnh hưởng tiêu cực đến họ trong mỗi lần thay đổi. Chúc các bạn thành công trong quá trình xây dựng API - Happy building API!