APIs là bộ mặt của mọi hệ thống phần mềm. Để xây dựng một hệ thống phần mềm tốt, chúng ta cần xác định rõ các APIs cần thiết, cách thức giao tiếp giữa các thành phần, và cách xử lý lỗi. Tuy nhiên trong một hệ thống lớn thì việc đảm bảo chất lượng của các APIs thường không được đảm bảo, dẫn đến việc hệ thống phần mềm không ổn định, khó bảo trì, và khó mở rộng, đặc biệt là trong kiến trúc microservices.

Xác định rõ những thách thức trên, team của tôi đã áp dụng phương pháp phát triển Contract-first Development (CFD) để giải quyết vấn đề này. Trong bài viết này, các bạn hãy cùng tôi khám phá xem CFD là gì và làm thế nào nó giúp team của tôi phát triển sản phẩm nhanh hơn mà vẫn đảm bảo chất lượng hệ thống nhé!

Đây là bài blog thuộc series System Design của mình, nếu bạn quan tâm đến các vấn đề liên quan đến thiết kế hệ thống phần mềm, hãy theo dõi series này để cập nhật những kiến thức mới nhất nhé!

Contract-first Development là gì?

Có lẽ các bạn đã từng nghe qua API-first Development, một phương pháp phát triển API bằng cách xác định API trước, sau đó mới phát triển mã nguồn. Contract-first Development cũng tương tự như vậy, ở đây chúng ta dùng từ “contract” để thể hiện rõ hơn về trách nhiệm cuả API. Ở đây một contract phải đảm bảo được chất lượng của API, cách thức giao tiếp, và cách xử lý lỗi. Nó giống như một trung tâm để định nghĩa ra các các tài liệu schema (OpenAPI, AsyncAPI) trước, sau đó mới phát triển các thành phần khác sao cho chất lượng phải được đảm đúng như “contract”.

Lấy ví dụ, khi cần phát triển một Restful server, developer phải tạo ra một tài liệu OpenAPI trước. Tài liệu này sẽ mô tả toàn bộ các endpoint của API, các phương thức HTTP (GET, POST, PUT, DELETE), các tham số yêu cầu và phản hồi, cũng như các mã lỗi có thể xảy ra. Sau khi tài liệu này được hoàn thành và thống nhất giữa các bên liên quan (như team phát triển backend, team phát triển frontend, và các bên thứ ba sử dụng API), việc phát triển mới bắt đầu.

Tại sao chúng ta cần áp dụng Contract-first Development?

Khi phát triển một hệ thống phần mềm theo kiến trúc microservices dựa trên Contract-first Development thì chúng ta đang tuân thủ mô hình SLA (Service Level Agreement) giữa các dịch vụ. Điều này giúp đảm bảo rằng các dịch vụ sẽ tuân thủ theo các yêu cầu đã được định nghĩa rõ ràng trong hợp đồng trước khi bắt đầu phát triển.

Hãy đưa ra tình huống giả định rằng có 2 team phát triển backend (team A) và frontend (team B) đang làm việc trên cùng một dự án. Trong đó, team A phát triển một dịch vụ “Payment” để xử lý thanh toán, và team B phát triển một ứng dụng web để sử dụng dịch vụ “Payment” này từ team A. Nếu không có một hợp đồng chung giữa 2 team, có thể xảy ra một số vấn đề sau:

  1. Không đồng bộ về giao diện API: Team A có thể thiết kế API theo một cách, trong khi team B mong đợi một giao diện khác. Điều này dẫn đến sự không tương thích giữa dịch vụ Payment và ứng dụng web, gây ra lỗi khi tích hợp.
  2. Thiếu nhất quán về dữ liệu: Các định dạng dữ liệu hoặc các trường thông tin mà API trả về có thể không được nhất quán giữa hai team. Chẳng hạn, team A trả về thông tin thanh toán với các trường tên không khớp với những gì team B đã dự kiến.
  3. Sai lệch về logic kinh doanh: Nếu không có một hợp đồng chung để xác định rõ ràng logic kinh doanh, team A và team B có thể hiểu khác nhau về các quy tắc xử lý thanh toán. Điều này có thể dẫn đến các lỗi nghiêm trọng trong quá trình xử lý thanh toán.
  4. Vấn đề về bảo mật và hiệu năng: Team A và team B có thể có các yêu cầu khác nhau về bảo mật và hiệu năng. Nếu không có hợp đồng xác định rõ các yêu cầu này, dịch vụ có thể không đáp ứng được các tiêu chuẩn cần thiết, gây ra rủi ro về bảo mật hoặc hiệu suất kém.
  5. Khó khăn trong việc kiểm thử và triển khai: Nếu không có hợp đồng rõ ràng, việc viết các bộ kiểm thử tự động trở nên khó khăn, dẫn đến việc kiểm thử và triển khai trở nên phức tạp và dễ mắc lỗi.
  6. Thiếu minh bạch và trách nhiệm rõ ràng: Khi không có hợp đồng, rất khó xác định trách nhiệm của mỗi team khi xảy ra vấn đề. Điều này làm giảm tính minh bạch và gây khó khăn trong việc giải quyết các sự cố.
  7. Chậm trễ trong phát triển: Khi các vấn đề trên xảy ra, thời gian để xác định và khắc phục chúng sẽ lâu hơn do không có tài liệu rõ ràng để tham chiếu, dẫn đến làm chậm tiến độ phát triển chung của dự án, dẫn đến việc trì hoãn hoàn thành sản phẩm.

Các vấn đề này minh họa tầm quan trọng của việc áp dụng Contract-first Development để đảm bảo rằng tất cả các nhóm phát triển có cùng một hiểu biết và tuân thủ theo các yêu cầu đã được định nghĩa rõ ràng ngay từ đầu.

Áp dụng quy trình Contract-first Development trong thực tế

Bước 1: Định nghĩa contract

Đầu tiên, và cũng là bước quan trọng nhất, chúng ta cần định nghĩa contract cho API. Contract này sẽ mô tả toàn bộ các đặc tả của API, tuỳ thuộc vào loại API mà chúng ta đang phát triển (RESTful, GraphQL, gRPC, …). Các công cụ hỗ trợ như OpenAPI, AsyncAPI, hay gRPC. Tuỳ vào loại API mà chúng ta sẽ tổ chức cấu trúc của contract theo cách phù hợp.

Thông thường contract trong một hệ thống microservices sẽ là một repository riêng biệt so với các repository khác (services, frontend, …). Repository này sẽ đóng vai trò như một trung tâm để định nghĩa ra các APIs (contract) mà các team khác sẽ phát triển.

Ví dụ về cấu trúc thư mục cho contract repository:

contract/
├─ clients/
│  ├─ dashboard/
│  │  ├─ graphql/
│  │  │  ├─ fedaration.yaml
├─ services/
│  ├─ payment/
│  │  ├─ openapi/
│  │  │  ├─ server.yaml

Qua ví dụ trên, chúng ta có thể thấy một cấu trúc thư mục đơn giản cho contract. Ở đây chúng ta có thư mục clients để lưu trữ các contract cho các client (frontend, mobile, …), và thư mục services để lưu trữ các contract cho các service (backend, …).

Tất nhiên rồi, trong một hệ thống lớn và phức tạp, bên cạnh OpenAPI, chúng ta còn có áp dụng các công nghệ khác như GraphQL, gRPC, Avro … để định nghĩa contract cho các API khác nhau.

Ví dụ cụ thể với file server.yaml trong thư mục payment được viết theo đặc tả OpenAPI như sau:

server.yaml
openapi: 3.1.0
info:
  title: Payment Service
  version: 1.0.0
  description: Payment service for processing payment
servers:
  - url: "http://your_domain:8080"
paths:
  /payment:
    post:
      summary: Process payment
      operationId: processPayment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PaymentRequest"
      responses:
        "200":
          description: Payment processed successfully
        "400":
          description: Invalid request
components:
  schemas:
    PaymentRequest:
      type: object
      properties:
        amount:
          type: number
          format: float
          description: Payment amount
        currency:
          type: string
          description: Payment currency
        required:
          - amount
          - currency

Trong ví dụ trên, chúng ta định nghĩa một contract OpenAPI cho API /payment với phương thức POST. Contract này mô tả toàn bộ các đặc tả của API, bao gồm các tham số yêu cầu, phản hồi, và mã lỗi. Trên dự án thực tế, chúng ta cần định nghĩa rõ hơn về các đặc tả của API, cũng như các đặc tả khác như security, tags, … Nếu như bạn chưa biết về OpenAPI, bạn có thể tham khảo thêm tại đây.

Bước 2: Review và thống nhất contract

Sau khi contract được định nghĩa bởi developer (trong trường hợp trên với server.yaml là backend developer), chúng ta cần thực hiện review và thống nhất contract giữa các team liên quan. Ở đây, chúng ta cần tham gia các bên liên quan như backend developer, frontend developer, product owner, QA, … để tất cả đều hiểu rõ về contract.

Bước 3: Generate code từ contract

Sau khi contract được thống nhất, chúng ta có thể sử dụng các công cụ hỗ trợ để generate code từ contract. Với OpenAPI, chúng ta có thể sử dụng các công cụ để generate code cho server (backend) và client (frontend, mobile, …). Điều này giúp giảm thiểu việc phải viết mã nguồn thủ công và đảm bảo chất lượng của mã nguồn tương ứng với API đã định nghĩa trong contract. Đối với từng ngôn ngữ lập trình, chúng ta có thể sử dụng các công cụ hỗ trợ khác nhau để generate code từ contract. Bạn có thể tìm hiểu thêm về các công cụ hỗ trợ tại đây.

Ví dụ, khi muốn generate code cho server (Go) từ contract OpenAPI, chúng ta có thể sử dụng thư viện openapi-codegen:

oapi-codegen -generate types,server,client -package payment -o payment.gen.go server.yaml

Với câu lệnh trên, chúng ta sẽ generate ra mã nguồn Go cho server từ contract server.yaml với package payment và lưu vào file payment.gen.go.

Bước 4: Tập trung vào phát triển logic nghiệp vụ

Sau khi đã generate code từ contract, chúng ta có thể tập trung vào việc phát triển logic nghiệp vụ mà không cần phải lo lắng về việc thiết kế API cho server và client. Chắc chắn rồi, với việc phát triển logic nghiệp vụ, chúng ta có thể tăng năng suất làm việc phải không.

Sơ đồ quy trình Contract-first Development

Để tóm tắt lại, dưới đây là sơ đồ quy trình Contract-first Development:

flowchart  TD
  A[Định nghĩa contract] --> B[Review và thống nhất contract]
  B --> C[Generate code từ contract]
  C --> D[Tập trung vào phát triển logic nghiệp vụ]
  D --> |Cập nhật contract| A

Ưu điểm khi áp dụng Contract-first Development

Qua việc áp dụng Contract-first Development, Contract-first Development đem lại một số lợi ích đáng kể sau:

  1. Tiết kiệm thời gian và công sức:
    • Việc tự động tạo ra mã nguồn từ contract giúp giảm bớt khối lượng công việc thủ công, giúp các lập trình viên tập trung vào việc phát triển logic nghiệp vụ cốt lõi.
    • Tiết kiệm thời gian phát triển do không cần phải viết lại các phần mã trùng lặp cho server và client.
  2. Đảm bảo tính nhất quán::
    • Do contract là nguồn tài liệu trung tâm cho cả server và client, sự nhất quán giữa các phần này được đảm bảo. Mọi thay đổi trong API chỉ cần thực hiện một lần trên contract và tự động được áp dụng cho cả hai phía.
    • Giảm thiểu lỗi do sự không khớp giữa các mô tả API và thực thi thực tế trên server và client.
  3. Dễ dàng bảo trì::
    • Khi cần thay đổi API, chỉ cần cập nhật contract và generate lại mã nguồn. Điều này giúp quá trình bảo trì và nâng cấp hệ thống trở nên dễ dàng hơn.
    • Việc theo dõi và quản lý các thay đổi của API cũng trở nên dễ dàng hơn, vì mọi sự thay đổi đều được ghi nhận trong contract.
  4. Tăng cường khả năng kiểm thử::
    • Việc có contract rõ ràng giúp việc viết các test case cho API trở nên dễ dàng hơn. Các test case có thể được tạo tự động dựa trên contract, đảm bảo tất cả các tình huống sử dụng được kiểm thử đầy đủ.
    • Giảm thiểu rủi ro xuất hiện lỗi trong quá trình phát triển do đã có một chuẩn mực rõ ràng để kiểm tra và so sánh.
  5. Nâng cao sự hợp tác giữa các nhóm::
    • Contract-first Development khuyến khích sự hợp tác chặt chẽ giữa các nhóm phát triển front-end và back-end. Cả hai nhóm đều có thể làm việc dựa trên contract từ đầu, giúp giảm bớt các sự cố không mong muốn khi tích hợp. -Sự phân chia rõ ràng giữa thiết kế API và triển khai giúp các nhóm có thể làm việc song song mà không ảnh hưởng lẫn nhau.
  6. Cải thiện khả năng mở rộng::
    • Hệ thống có thể dễ dàng mở rộng bằng cách bổ sung các tính năng mới thông qua việc cập nhật contract. Các phần mở rộng này sẽ tự động được áp dụng cho cả server và client mà không cần phải viết lại nhiều mã.
    • Các dịch vụ mới có thể được thêm vào mà không làm gián đoạn hoạt động của các dịch vụ hiện tại, giúp hệ thống phát triển một cách linh hoạt và bền vững.

Kết luận

Nhìn chung, việc áp dụng Contract-first Development không chỉ giúp tăng hiệu quả phát triển mà còn đảm bảo chất lượng và tính ổn định của hệ thống, đồng thời tạo điều kiện thuận lợi cho sự hợp tác và mở rộng trong tương lai. Tuy nhiên, để áp dụng Contract-first Development hiệu quả, chúng ta cần phải có sự đồng thuận và sự hỗ trợ từ tất cả các bên liên quan, cũng như tuân thủ quy trình và công cụ hỗ trợ một cách chặt chẽ.

Hy vọng rằng bài viết này đã giúp các bạn hiểu rõ hơn về Contract-first Development và cách áp dụng nó trong thực tế. Nếu có bất kỳ ý kiến hoặc thắc mắc nào, hãy để lại comment bên dưới để chúng ta cùng thảo luận nhé!