Khái niệm, ý tưởng, chiến lược
Khái niệm, ý tưởng, chiến lượcPhát triển schema thông qua phiên bản hóa trường

Phát triển schema thông qua phiên bản hóa trường

Khi nhu cầu của ứng dụng phát triển, GraphQL API cung cấp dữ liệu cho ứng dụng cũng sẽ cần phát triển theo, giới thiệu các thay đổi vào schema của nó. Khi thay đổi là không phá vỡ tương thích, chẳng hạn như khi thêm một kiểu hoặc trường mới, chúng ta có thể áp dụng trực tiếp mà không lo ngại tác dụng phụ. Nhưng khi thay đổi là phá vỡ tương thích, chúng ta cần đảm bảo rằng chúng ta không đưa vào lỗi hoặc hành vi không mong muốn trong ứng dụng.

Các thay đổi phá vỡ tương thích là những thay đổi xóa một kiểu, trường hoặc directive, hoặc sửa đổi chữ ký của một trường (hoặc directive) đã tồn tại, chẳng hạn như:

  • Đổi tên một trường
  • Thay đổi kiểu của một đối số trường hiện có, hoặc làm cho nó bắt buộc
  • Thêm một đối số bắt buộc mới vào trường
  • Thêm non-nullable vào kiểu phản hồi của một trường

Để xử lý các thay đổi phá vỡ tương thích, có hai chiến lược chính: phiên bản hóa và tiến hóa, được triển khai bởi REST và GraphQL tương ứng.

Các REST API chỉ ra phiên bản của API cần sử dụng trên URL endpoint (chẳng hạn như https://api.mycompany.com/v1 hoặc https://api-v1.mycompany.com) hoặc thông qua một header (chẳng hạn như Accept-version: v1). Thông qua phiên bản hóa, các thay đổi phá vỡ tương thích được thêm vào một phiên bản mới của API, và vì các client cần trỏ rõ ràng đến phiên bản mới của API, họ sẽ nhận biết được các thay đổi.

GraphQL không bác bỏ việc sử dụng phiên bản hóa, nhưng nó khuyến khích sử dụng tiến hóa. Như được nêu trong trang GraphQL best practices:

While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.

Tiến hóa hoạt động khác biệt ở chỗ nó không được kỳ vọng sẽ diễn ra một lần vài tháng một lần, như phiên bản hóa. Thay vào đó, đây là một quá trình liên tục, diễn ra ngay cả hàng ngày nếu cần, điều này làm cho nó phù hợp hơn với việc lặp lại nhanh. Cách tiếp cận này đã được đặt ra bởi Principled GraphQL, một tập hợp các phương pháp tốt nhất để hướng dẫn việc phát triển dịch vụ GraphQL, trong nguyên tắc thứ năm:

5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time

Phát triển schema

Thông qua tiến hóa, các trường có thay đổi phá vỡ tương thích phải trải qua quy trình sau:

  1. Triển khai lại trường bằng một tên khác.
  2. Đánh dấu trường là deprecated, yêu cầu các client sử dụng trường mới thay thế.
  3. Khi trường không còn được sử dụng bởi bất kỳ ai, hãy xóa nó khỏi schema.

Hãy xem một ví dụ. Giả sử chúng ta có một kiểu Account, mô hình hóa một tài khoản là một người với tên và họ thông qua schema này (sử dụng SDL của GraphQL - Schema Definition Language):

type Account {
  id: Int
  name: String!
  surname: String!
}

Trong schema này, cả hai trường namesurname đều bắt buộc (đó là ký hiệu ! được thêm sau kiểu String) vì chúng ta mong đợi tất cả mọi người đều có cả tên và họ.

Cuối cùng, chúng ta cũng cho phép các tổ chức mở tài khoản. Tuy nhiên, các tổ chức không có họ, vì vậy chúng ta phải thay đổi chữ ký của trường surname để làm cho nó không bắt buộc:

type Account {
  id: Int
  name: String!
  surname: String # This has changed
}

Đây là một thay đổi phá vỡ tương thích vì ứng dụng không mong đợi trường surname trả về null, do đó nó có thể không kiểm tra điều kiện này, như khi thực thi đoạn mã JavaScript này:

// This will fail when account.surname is null
const upperCaseSurname = account.surname.toUpperCase();

Các lỗi tiềm ẩn phát sinh từ các thay đổi phá vỡ tương thích có thể được tránh bằng cách phát triển schema:

  • Chúng ta không sửa đổi chữ ký của trường surname; thay vào đó, chúng ta đánh dấu nó là deprecated, thêm một thông báo hữu ích chỉ ra tên của trường thay thế nó
  • Chúng ta giới thiệu một tên trường mới personSurname (hoặc accountSurname) vào schema

Kiểu Account của chúng ta bây giờ trông như thế này:

type Account {
  id: Int
  name: String!
  surname: String! @deprecated(reason: "Use `personSurname`")
  personSurname: String
}

Cuối cùng, bằng cách thu thập nhật ký các queries từ các client của chúng ta, chúng ta có thể phân tích xem họ đã chuyển sang trường mới chưa. Khi chúng ta nhận thấy rằng trường surname không còn được sử dụng bởi ai nữa, chúng ta có thể xóa nó khỏi schema:

type Account {
  id: Int
  name: String!
  personSurname: String
}

Vấn đề với tiến hóa

Ví dụ được mô tả ở trên rất đơn giản, nhưng nó đã cho thấy một vài vấn đề tiềm ẩn từ việc phát triển schema:

Vấn đềMô tả
Tên trường trở nên kém gọn gàng hơnLần đầu tiên chúng ta đặt tên trường, chúng ta có thể sẽ tìm thấy tên tối ưu cho nó, chẳng hạn như surname. Tuy nhiên, khi chúng ta cần thay thế nó, chúng ta sẽ cần tạo một tên khác có thể không tối ưu (tên tối ưu đã bị lấy rồi!). Tất cả các thay thế có thể có trong ví dụ trên đều có vấn đề:

- personName làm rõ ràng rằng tài khoản là cho một người, vì vậy nếu sau này chúng ta phải mở tài khoản cho một thực thể không phải người có họ (ai biết được... một người Hỏa Tinh?), thì chúng ta sẽ cần phát triển schema lại để giữ tên nhất quán
- Từ "account" trong accountName hoàn toàn dư thừa vì kiểu đã là Account rồi
- Nếu không, tên nào khác để sử dụng? surname1? surnameNew? Hay tệ hơn, surnameV2?

Do đó, schema được cập nhật sẽ kém dễ hiểu hơn và dài dòng hơn.
Schema có thể tích lũy các trường deprecatedViệc đánh dấu trường là deprecated là hợp lý nhất như một hoàn cảnh tạm thời; cuối cùng, chúng ta thực sự muốn xóa những trường đó khỏi schema để dọn dẹp nó trước khi chúng bắt đầu tích lũy.

Tuy nhiên, có thể có những client không xem xét lại queries của họ và vẫn lấy thông tin từ trường deprecated. Trong trường hợp này, schema của chúng ta sẽ dần dần trở thành một loại nghĩa địa trường, tích lũy nhiều trường khác nhau cho cùng một chức năng.

Hãy xem cách giải quyết các vấn đề này.

Phiên bản hóa trường

Chúng ta có thể tạo trường của mình với một đối số gọi là version, thông qua đó chúng ta chỉ định phiên bản nào của trường cần sử dụng.

Trong kịch bản này, chúng ta vẫn phải giữ việc triển khai cho trường deprecated, vì vậy chúng ta không cải thiện được ở vấn đề đó. Tuy nhiên, hợp đồng của nó trở nên ẩn: trường mới bây giờ có thể giữ tên gốc của nó (không cần đổi tên từ surname sang personSurname), ngăn schema của chúng ta trở nên quá dài dòng.

Xin lưu ý rằng khái niệm phiên bản hóa này khác với REST:

  • REST thiết lập một tình huống tất-cả-hoặc-không-gì trong đó toàn bộ API được truy vấn có cùng phiên bản vì phiên bản cần sử dụng là một phần của endpoint
  • Trong cách tiếp cận khác này, mỗi trường được phiên bản hóa độc lập

Do đó, chúng ta có thể truy cập các phiên bản khác nhau cho các trường khác nhau, như thế này:

query GetPosts {
  posts(version: "1.0.0") {
    id
    title(version: "2.1.1")
    url
    author {
      id
      name(version: "1.5.3")
    }
  }
}

Hơn nữa, bằng cách dựa vào semantic versioning, chúng ta có thể sử dụng các ràng buộc phiên bản để chọn phiên bản, theo dõi theo các quy tắc tương tự được Composer sử dụng để khai báo các phụ thuộc gói. Sau đó, chúng ta đổi tên đối số trường version thành versionConstraint và cập nhật query:

query GetPosts {
  posts(versionConstraint: "^1.0") {
    id
    title(versionConstraint: ">=2.1")
    url
    author {
      id
      name(versionConstraint: "~1.5.3")
    }
  }
}

Áp dụng chiến lược này cho trường deprecated surname của chúng ta, bây giờ chúng ta có thể gắn nhãn triển khai deprecated là phiên bản "1.0.0" và triển khai mới là phiên bản "2.0.0" và truy cập cả hai, ngay cả trong cùng một query:

query GetSurname {
  account(id: 1) {
    oldVersion: surname(versionConstraint: "^1.0")
    newVersion: surname(versionConstraint: "^2.0")
  }
}

Tính năng này có sẵn trong Gato GraphQL:

Querying fields through version constraints

Phiên bản hóa directive

Vì các directive cũng nhận các đối số, chúng ta có thể triển khai chính xác cùng phương pháp để phiên bản hóa các directive!

Ví dụ, khi chạy query này:

query {
  post(by: { id: 1 }) {
    oldVersion: title @strTitleCase(versionConstraint: "^0.1")
    newVersion: title @strTitleCase(versionConstraint: "^0.2")
  }
}

Nó có thể tạo ra một phản hồi khác nhau cho mỗi phiên bản của directive:

Querying a versioned directive