Khái niệm, ý tưởng, chiến lược
Khái niệm, ý tưởng, chiến lượcSo sánh đối số trường và chỉ thị

So sánh đối số trường và chỉ thị

Cùng một chức năng để sửa đổi đầu ra của một trường trong GraphQL thường có thể đạt được thông qua hai phương pháp khác nhau:

  1. Đối số trường: field(arg: value)
  2. Chỉ thị kiểu query: field @directive

(Chỉ thị kiểu query là những chỉ thị được áp dụng trên query ở phía client, trái ngược với chỉ thị kiểu schema, được áp dụng thông qua SDL -Schema Definition Language- khi xây dựng schema ở phía server. Vì Gato GraphQL tạo schema từ mã PHP, không phải từ SDL, các chỉ thị của nó đều thuộc loại query và được gọi đơn giản là "chỉ thị".)

Chẳng hạn, việc chuyển đổi phản hồi của trường title thành chữ hoa có thể đạt được bằng cách truyền field arg format với giá trị enum UPPERCASE, như sau:

{
  posts {
    title(format: UPPERCASE)
  }
}

hoặc bằng cách áp dụng chỉ thị @strUpperCase lên trường, như sau:

{
  posts {
    title @strUpperCase
  }
}

Trong cả hai trường hợp, phản hồi từ server GraphQL sẽ giống nhau:

{
  "data": {
    "posts": [
      {
        "title": "HELLO WORLD!"
      },
      {
        "title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
      }
    ]
  }
}

Khi nào nên dùng đối số trường và khi nào nên dùng chỉ thị phía query? Có sự khác biệt nào giữa hai phương pháp không, hay có tình huống nào một lựa chọn tốt hơn lựa chọn kia?

Đối số trường và chỉ thị dùng để làm gì

Giải quyết một trường trong GraphQL bao gồm hai thao tác khác nhau:

  1. lấy dữ liệu được yêu cầu từ thực thể được truy vấn
  2. áp dụng chức năng (chẳng hạn như định dạng) lên dữ liệu được yêu cầu

Chúng ta có thể gọi hai thao tác này là "giải quyết dữ liệu" và "áp dụng chức năng", hay ngắn gọn là "dữ liệu" và "chức năng" tương ứng.

Sự khác biệt chính giữa đối số trường và chỉ thị là đối số trường có thể được dùng cho cả "dữ liệu" lẫn "chức năng", nhưng chỉ thị chỉ có thể được dùng cho "chức năng".

Hãy xem chi tiết hơn điều này có nghĩa là gì.

Giải quyết dữ liệu thông qua đối số trường

Đối số trường được xử lý khi giải quyết trường, do đó chúng có thể được dùng để lấy dữ liệu thực tế, chẳng hạn như quyết định thuộc tính nào của đối tượng được truy cập.

Chẳng hạn, đoạn mã resolver này cho thấy cách đối số size được dùng để lấy nguồn hình ảnh này hay nguồn hình ảnh khác từ kiểu đối tượng Media:

function resolveValue(
  object $mediaObject,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  if ($fieldDataAccessor->getFieldName() === 'src') {
    $size = $fieldDataAccessor->getValue('size');
    return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
  }
  // ...
}

Field args cũng có thể được dùng để giúp quyết định hàng hoặc cột nào trong bảng cơ sở dữ liệu cần được truy vấn.

Trong query này, đối số trường id được dùng để truy vấn một thực thể cụ thể thuộc kiểu Post, mà resolver sẽ dịch thành một hàng cụ thể trong bảng wp_posts của cơ sở dữ liệu WordPress:

{
  post(by: { id: 1 }) {
    title
  }
}

Cùng một bảng lưu trữ ngày của bài đăng trong hai cột khác nhau, post_modifiedpost_modified_gmt (vì lý do tương thích ngược). Trong query này, truyền đối số trường gmt với true hoặc false tương ứng với việc lấy giá trị từ cột này hay cột kia:

{
  post(by: { id: 1 }) {
    title
    date(gmt: true)
  }
}

Những ví dụ này chứng minh rằng field args có thể sửa đổi nguồn dữ liệu khi giải quyết trường.

Chỉ thị không thể được dùng để sửa đổi nguồn dữ liệu, vì logic của chúng được cung cấp thông qua các directive resolver, được gọi sau field resolver. Do đó, khi chỉ thị được áp dụng, giá trị của trường phải đã được lấy.

Chẳng hạn, query này sẽ không bao giờ hoạt động:

{
  post @selectEntity(id: 1) {
    title
  }
}

Trong ví dụ này, trường post yêu cầu phải cung cấp id của thực thể, và vì nó không được cung cấp dưới dạng đối số trường, server sẽ trả về lỗi:

{
  "errors": [
    {
      "message": "Argument 'id' cannot be empty",
      "extensions": {
        "type": "QueryRoot",
        "field": "post @selectEntity(id:1)"
      }
    }
  ]
}

Tóm lại, chỉ đối số trường mới có thể giúp lấy dữ liệu giải quyết trường.

Áp dụng chức năng thông qua đối số trường hoặc chỉ thị

Sau khi lấy dữ liệu cho trường, chúng ta có thể muốn thao tác giá trị của nó. Chẳng hạn, chúng ta có thể:

  • Định dạng một chuỗi, chuyển đổi thành chữ hoa hoặc chữ thường
  • Định dạng ngày được biểu diễn bằng chuỗi, từ định dạng mặc định YYYY-mm-dd sang dd/mm/YYYY
  • Che giấu một chuỗi, thay thế email và số điện thoại bằng ***
  • Cung cấp giá trị mặc định nếu nó là null hoặc rỗng
  • Làm tròn số thực đến 2 chữ số thập phân

Bất kỳ thao tác nào trong số này đều là thao tác trên dữ liệu đã được lấy. Do đó, chúng có thể được lập trình trong field resolver, ngay sau khi lấy dữ liệu và trước khi trả về nó, hoặc trong directive resolver, sẽ nhận giá trị của trường làm đầu vào. Như vậy, bất kỳ thao tác nào trong số này đều có thể được triển khai thông qua đối số trường hoặc chỉ thị.

Chẳng hạn, field resolver cho Post.excerpt có thể cung cấp giá trị mặc định thông qua field arg default, và sau đó chúng ta có thể tùy chỉnh giá trị cho arg default trong query:

{
  posts {
    excerpt(default: "(No excerpt)")
  }
}

Chúng ta cũng có thể tạo một chỉ thị @default, với directive resolver như sau:

/**
 * Replace all the empty results with the default value
 */
function resolveDirective(
  array $directiveArgs,
  array $objectIDFields,
  array $objectsByID,
  array &$responseByObjectIDAndField
): void {
  foreach ($objectIDFields as $id => $fields) {
    $object = $objectsByID[$id];
    $defaultValue = $directiveArgs['value'];
    foreach ($fields as $field) {
      if (empty($responseByObjectIDAndField[$id][$field])) {
        $responseByObjectIDAndField[$id][$field] = $defaultValue;
      }
    }
  }
}

Hai chiến lược này có phù hợp như nhau không? Hãy khám phá câu hỏi này dựa trên các lĩnh vực quan tâm khác nhau.

Đối số trường được đặc tả GraphQL bao quát tốt hơn

Mức độ mà chỉ thị được phép hoạt động không được định nghĩa rõ ràng trong đặc tả GraphQL, vốn đọc như sau:

Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document.

In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.

Định nghĩa này cho phép sử dụng các chỉ thị như @include@skip, lần lượt bao gồm và bỏ qua một trường có điều kiện, và @stream@defer, cung cấp thực thi runtime khác để lấy dữ liệu từ server.

Tuy nhiên, định nghĩa này không rõ ràng về các chỉ thị sửa đổi giá trị của trường, chẳng hạn như @strUpperCase, biến đổi giá trị đầu ra "Hello world!" thành "HELLO WORLD!".

Do sự mơ hồ này, các server, client và công cụ GraphQL khác nhau có thể xem xét chỉ thị ở các mức độ khác nhau, tạo ra xung đột giữa chúng.

Một ví dụ về điều này là Relay, vốn không xem xét chỉ thị khi lưu vào bộ nhớ đệm các giá trị trường. Nếu trước tiên truy vấn:

{
  post(by: { id: 1 }) {
    title
  }
}

...Relay sẽ truy vấn và lưu vào bộ nhớ đệm giá trị "Hello world!" cho bài đăng có ID 1. Nếu sau đó chạy query này:

{
  post(by: { id: 1 }) {
    title @strUpperCase
  }
}

...phản hồi phải là "HELLO WORLD!", tuy nhiên Relay sẽ trả về "Hello world!", là giá trị được lưu trong bộ nhớ đệm của nó cho bài đăng có ID 1, bỏ qua chỉ thị được áp dụng lên trường.

Liệu chỉ thị có được phép sửa đổi giá trị đầu ra của trường hay không vẫn là một vùng xám, vì nó không được đặc tả GraphQL cho phép hay cấm rõ ràng, nhưng có các dấu hiệu cho cả hai tình huống trái ngược.

Một mặt, đặc tả GraphQL có vẻ cho phép chỉ thị tự do cải thiện và tùy chỉnh GraphQL:

As future versions of GraphQL adopt new configurable execution capabilities, they may be exposed via directives. GraphQL services and tools may also provide any additional custom directive beyond those described here.

Mặt khác, đặc tả không xem xét chỉ thị cho xác thực FieldsInSetCanMerge hoặc thuật toán CollectFields. Query GraphQL sau đây hợp lệ, nhưng không chắc chắn người dùng sẽ nhận được phản hồi nào:

{
  user(by: { id: 1 }) {
    name
    name @strUpperCase
    name @strLowerCase
  }
}

Tùy thuộc vào hành vi của server GraphQL, phản hồi cho trường name có thể là "Leo", "LEO" hoặc "leo"... chúng ta không biết trước, và đó là một vấn đề.

Vấn đề tương tự không xảy ra với đối số trường. Khi query sau được thực thi:

{
  user(by: { id: 1 }) {
    name
    name(format: UPPERCASE)
    name(format: LOWERCASE)
  }
}

...đặc tả yêu cầu server GraphQL trả về lỗi, vì vậy giá trị của name sẽ là null. Khi đó chúng ta buộc phải đưa vào alias để thực thi query:

{
  user(by: { id: 1 }) {
    name
    ucName: name(format: UPPERCASE)
    lcName: name(format: LOWERCASE)
  }
}

Chỉ thị tốt hơn cho tính mô-đun và khả năng tái sử dụng mã

Nhiều thao tác do chỉ thị cung cấp không phụ thuộc vào thực thể và trường mà chúng được áp dụng. Chẳng hạn, @strUpperCase sẽ hoạt động trên bất kỳ chuỗi nào, dù được áp dụng trên tiêu đề bài đăng, tên người dùng, địa chỉ địa điểm hay bất cứ thứ gì khác.

Do đó, mã cho chỉ thị này chỉ được triển khai một lần và ở một nơi duy nhất, trong directive resolver. Tương tự như lập trình hướng khía cạnh (giúp tăng tính mô-đun bằng cách cho phép tách các mối quan tâm chung), chỉ thị được áp dụng lên trường mà không ảnh hưởng đến logic của trường.

Ngược lại, triển khai cùng một chức năng thông qua đối số trường liên quan đến việc thực thi cùng mã trong field resolver (và trong các field resolver khác nhau):

function formatString(string $string, string $format): string
{
  if ($format === "UPPERCASE") {
    return strtoupper($string);
  }
  if ($format === "LOWERCASE") {
    return strtolower($string);;
  }
  return $string;
};
 
function resolveValue(
  object $post,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  $format = $fieldDataAccessor->getValue('format');
  if ($fieldDataAccessor->getFieldName() === 'title') {
    return formatString($post->post_title, $format);
  }
  if ($fieldDataAccessor->getFieldName() === 'excerpt') {
    return formatString($post->post_excerpt, $format);
  }
  if ($fieldDataAccessor->getFieldName() === 'content') {
    return formatString($post->post_content, $format);
  }
  // ...
}

Để giảm lượng mã trong các resolver, chỉ thị phù hợp hơn đối số trường.

Chỉ thị tốt hơn cho thiết kế schema

Thêm đối số trường sẽ thêm thông tin phụ vào schema, có thể làm cho nó phình to và không nhất quán.

Chẳng hạn, đối số trường format sẽ cần được thêm vào tất cả các trường String và, nếu không cẩn thận, nó có thể không đồng nhất giữa các trường, chẳng hạn như sử dụng tên khác nhau, giá trị khác nhau, giá trị mặc định khác nhau, hoặc thậm chí chia đối số thành nhiều đầu vào:

type Post {
  # Input value is "uppercase" or "strLowerCase"
  title(format: String): String
  content(format: String): String
  excerpt(format: String): String
}
 
type Category {
  # Input name is "case" instead of "format"
  # Input value is an enum StringCase with values UPPERCASE and LOWERCASE
  name(case: StringCase): String
}
 
type Tag {
  # Using a default value
  name(format: String = "strLowerCase"): String
}
 
type User {
  # Using multiple Boolean inputs
  description(useUppercase: Boolean, useLowercase: Boolean): String
}

Chỉ thị cho phép chúng ta giữ schema càng gọn gàng càng tốt:

directive @strUpperCase on FIELD
directive @strLowerCase on FIELD
 
type Post {
  title: String
  content: String
  excerpt: String
}
 
type Category {
  name: String
}
 
type Tag {
  name: String
}
 
type User {
  description: String
}

Chỉ thị có thể hiệu quả hơn đối số trường

Trong thời gian thực thi, một đối số trường sẽ được truy cập khi giải quyết trường, điều này xảy ra theo từng trường và từng đối tượng. Chẳng hạn, khi giải quyết các trường titlecontent trên một danh sách bài đăng, resolver sẽ được gọi một lần cho mỗi bài đăng và mỗi trường:

function resolveValue(
  object $post,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  if ($fieldDataAccessor->getFieldName() === 'title') {
    return $post->post_title;
  }
  if ($fieldDataAccessor->getFieldName() === 'content') {
    return $post->post_content;
  }
  // ...
}

Hãy tưởng tượng chúng ta muốn dịch các chuỗi này bằng Google Translate API, mà chúng ta thêm đối số translateTo vào đó:

function executeGoogleTranslate(string $string, string $lang): string
{
  // Execute against https://translation.googleapis.com
  // ...
};
 
function resolveValue(
  object $post,
  FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
  $lang = $fieldDataAccessor->getValue('lang');
  if ($fieldDataAccessor->getFieldName() === 'title') {
    return executeGoogleTranslate($post->post_title, $lang);
  }
  if ($fieldDataAccessor->getFieldName() === 'content') {
    return executeGoogleTranslate($post->post_content, $lang);
  }
  // ...
}

Vì logic tự nhiên được thực thi theo từng tổ hợp trường và đối tượng, chúng ta có thể kết thúc bằng việc yêu cầu một số lượng lớn kết nối đến API bên ngoài, tạo ra phản hồi chậm khi giải quyết query.

Ngoài ra, thực thi các cuộc gọi độc lập với nhau sẽ không cho phép liên kết dữ liệu của chúng, vì vậy chất lượng dịch thuật sẽ kém hơn so với khi tất cả dữ liệu được gửi cùng nhau trong một lần gọi API.

Chẳng hạn, tiêu đề bài đăng "Power" có thể được dịch tốt hơn nếu nội dung bài đăng, làm rõ rằng từ này đề cập đến "điện năng", được gửi cùng với nó.

Gato GraphQL chỉ gọi một chỉ thị một lần, truyền tất cả các trường và đối tượng cần áp dụng làm đầu vào. Bằng cách nhận tất cả dữ liệu cùng một lúc, chỉ thị @strTranslate có thể thực thi một lần gọi duy nhất đến Google Translate, truyền tất cả các trường titlecontent cho tất cả các đối tượng, như trong query này:

{
  posts(pagination: { limit: 6 }) {
    title @strTranslate(from: "en", to: "fr")
    excerpt @strTranslate(from: "en", to: "fr")
  }
}

Chỉ thị có thể cung cấp cách hiệu quả hơn để sửa đổi giá trị của các trường, chẳng hạn như khi tương tác với các API bên ngoài.