Blog

🕸 GraphQL có thể cải thiện WordPress như thế nào, bổ sung cho REST API

Leonardo Losoviz
Bởi Leonardo Losoviz ·

Cập nhật 01/05/2024: Xem thêm bài so sánh Gato GraphQL vs WP REST API.

Cuối tuần vừa rồi tôi đã đăng bài 🦸🏿‍♂️ Gato GraphQL hiện được biên dịch chuyển đổi từ PHP 8.0 sang 7.1.

Sau khi chia sẻ bài viết lên Reddit's /r/php, cộng đồng đã bắt đầu một cuộc thảo luận sôi nổi về việc sử dụng GraphQL trong WordPress có xứng đáng không, nó khác với WP REST API như thế nào, và liệu có hợp lý khi đưa thêm một API nữa vào WordPress hay không.

Tôi nghĩ hầu hết các bình luận đều rất xác đáng, trong khi một số khác còn thiếu một số thông tin quan trọng. GraphQL không chỉ là một giao diện, mà còn là một triển khai cụ thể. Điều này có nghĩa là các máy chủ GraphQL khác nhau, từ các nhà cung cấp khác nhau, có thể được thiết kế để ưu tiên các đặc điểm khác nhau. Do đó, chúng ta không thể lúc nào cũng có một kỳ vọng thống nhất về những gì GraphQL cung cấp, hay hiểu đầy đủ về cách hoạt động của một engine GraphQL.

Chẳng hạn, trải nghiệm GraphQL trong WordPress và trong Laravel sẽ khác nhau, cũng như trải nghiệm do các máy chủ khác nhau cung cấp, WPGraphQL hay Gato GraphQL.

Bài viết này là quan điểm của tôi về vấn đề này, phản hồi một số bình luận từ bài đăng trên Reddit.

GraphQL vs WP REST API

[Thật là một ý tưởng tệ] khi có một API GraphQL trên WordPress vốn đã sử dụng REST API riêng của mình. Hãy dùng REST API thôi. [Source]

Cả REST API lẫn GraphQL đều phục vụ cùng một mục đích: cung cấp cho ứng dụng dữ liệu mà nó cần. Tuy nhiên, chúng hoạt động khác nhau trong cách đạt được điều này: trong khi REST có các endpoint được định sẵn cung cấp một tập dữ liệu cụ thể, GraphQL có thể cung cấp chính xác dữ liệu cần thiết.

Sự khác biệt này có thể tác động trực tiếp đến hiệu suất của ứng dụng. Với REST, nếu chúng ta cần lấy một danh sách bài viết cùng với một số dữ liệu của mỗi tác giả bài viết, điều đó sẽ cần gửi thêm các yêu cầu. Có thể là 1 yêu cầu thêm cho toàn bộ dữ liệu tác giả, hoặc 1 yêu cầu thêm cho mỗi tác giả. Trong thời gian đó, khách truy cập website có thể đang chờ trang được hiển thị.

GraphQL cải thiện tình huống này, vì chúng ta có thể lấy trực tiếp toàn bộ dữ liệu bài viết và tác giả trong một yêu cầu duy nhất, và việc hiển thị trang web sẽ nhanh hơn:

{
  posts {
    id
    title
    excerpt
    date
    url
    author {
      id
      name
      url
    }
  }
}

Do đó, dù chúng ta đã có REST API trong WordPress, điều đó không có nghĩa là nó luôn là công cụ phù hợp nhất cho mọi tác vụ. Tất nhiên, chúng ta luôn có thể sử dụng nó, nhưng nếu chúng ta cũng có quyền truy cập vào GraphQL, thì chúng ta có thể quyết định sử dụng API này bất cứ khi nào nó mang lại lợi thế hơn REST, và chúng ta sẽ được lợi hơn.

Thiết lập ban đầu khó khăn cho GraphQL + Phải viết resolver

Chắc chắn có lý do để lập luận rằng thiết lập ban đầu cho GraphQL cao hơn theo cấp số nhân so với REST; bạn nói đúng rằng các liên kết phải được thiết lập. [Source]

Và...

Điều mà bạn và hầu hết mọi người khác trên web đang bỏ qua là để định dạng API này hoạt động, bạn phải viết parser (resolver + kiểu dữ liệu) điều này kéo theo một loạt các vấn đề không có với REST. [Source]

Những bình luận này không hoàn toàn chính xác, vì cả WPGraphQL lẫn Gato GraphQL đều đã ánh xạ mô hình dữ liệu WordPress vào GraphQL schema (WPGraphQL hoàn toàn, plugin của tôi hầu hết).

Sau đó, sau khi bạn cài đặt bất kỳ plugin nào trong số này, bạn có thể bắt đầu lấy dữ liệu cho ứng dụng của mình ngay lập tức, mà không cần tạo bất kỳ resolver nào, hoặc phải thiết lập các liên kết giữa các thực thể.

Đúng là, để lấy dữ liệu tùy chỉnh từ các thực thể riêng của ứng dụng (chẳng hạn như từ CPT), chúng cần được ánh xạ thông qua resolver, và bạn sẽ cần làm điều đó. Nhưng điều này không khác gì với REST: nếu bạn cần dữ liệu tùy chỉnh từ CPT của mình, bạn sẽ cần tạo một REST endpoint để lấy dữ liệu tùy chỉnh đó. Một endpoint tùy chỉnh cũng là một resolver.

Do đó, liên quan đến nhu cầu về resolver, REST và GraphQL API về cơ bản là giống nhau.

Bây giờ, khi duyệt các website và tài liệu, nó tạo ra ấn tượng rằng GraphQL đòi hỏi nhiều nỗ lực hơn để thiết lập. Vì vậy, có sự thật trong giả định này.

Tôi tin có một vài lý do cho điều này. Một là, GraphQL bao gồm (ít nhất) hai phần:

  1. khái niệm về nó là gì, và cách nó hoạt động
  2. các máy chủ cung cấp một số triển khai thực tế

Khi duyệt tài liệu về GraphQL, chẳng hạn như trang chính thức graphql.org, nó tập trung vào các khái niệm đằng sau GraphQL, đi vào chi tiết về resolver, chúng là gì và tại sao chúng cần thiết.

Điều này hữu ích khi bạn đang xây dựng một ứng dụng từ đầu, chẳng hạn như khi sử dụng Laravel và Lighthouse. Trong trường hợp đó, bạn cần code các resolver của mình (nhưng bạn cũng sẽ cần tạo các REST endpoint của mình).

Tuy nhiên, WordPress đã là ứng dụng rồi, và WPGraphQL cùng Gato GraphQL là các giải pháp. Hai plugin này đã tạo sẵn các resolver cho chúng ta, vì vậy chúng ta không cần lo lắng về chúng (tương tự như WP REST API cũng cung cấp một tập endpoint ban đầu, vì vậy chúng ta không cần lo lắng về chúng).

Ngoài ra, GraphQL hướng đến nhà phát triển hơn, và tài liệu của nó dường như nói thẳng với các nhà phát triển. Các nhà phát triển tạo resolver ở phía máy chủ, và các nhà phát triển sử dụng các resolver đó với các queries tùy chỉnh ở phía client. Vì xây dựng resolver là một nhiệm vụ cho nhà phát triển, nó xuất hiện một cách tự nhiên và thường xuyên.

Đối với REST, kỳ vọng (tôi tin) là endpoint cung cấp dữ liệu cần thiết sẽ đã tồn tại sẵn (như được cung cấp bởi WP REST API). Nếu không có, chỉ khi đó chúng ta mới cần lo lắng về việc thiết lập một endpoint tùy chỉnh. Do đó, có ít sự nhấn mạnh hơn vào việc tạo resolver cho REST.

Do đó, cả REST lẫn GraphQL đều cung cấp dữ liệu cần thiết. Nhưng trong khi REST khuyến khích một cách tiếp cận tĩnh, nơi các endpoint nên đã tồn tại, và chỉ khi chúng không tồn tại chúng ta mới lo lắng về chúng, GraphQL khuyến khích một cách tiếp cận động, nơi mỗi query được tùy chỉnh, và sau đó chúng ta có thể code resolver hoàn hảo cho nó.

Vì vậy, cuối cùng, không có sự khác biệt cơ bản nào giữa REST và GraphQL, chỉ là những cách giải thích khác nhau về cách chúng phải đáp ứng các yêu cầu của mình.

Lỗ hổng bảo mật + Các cân nhắc bảo mật trong GraphQL

Chúng ta sẽ thấy một lỗ hổng bảo mật lớn từ GraphQL vào một ngày nào đó vì viết interpreter bảo mật thực sự rất khó. [Source]

Và...

WordPress đã rất lớn đến mức nó đã có một mục tiêu khổng lồ trên lưng; gắn thêm BẤT KỲ plugin nào đều thêm nhiều rủi ro, và một plugin đề nghị phơi bày theo nghĩa đen tất cả WordPress, bao gồm nhiều ví dụ code để vượt qua mô hình bảo mật, là một điều không thể chấp nhận được với tôi. Đầu ra không do theme điều khiển nên được hạn chế tối đa (không tồn tại trừ khi tôi yêu cầu) ngoài những gì hoàn toàn cần thiết để phơi bày. Tôi hy vọng điều này không bao giờ được đưa vào core. [Source]

GraphQL thực sự đặt ra các rủi ro bảo mật bổ sung mà chúng ta cần giải quyết. Tôi hoàn toàn đồng ý với cảm nhận này.

Nhưng tôi không nghĩ đây là vấn đề nghiêm trọng đến mức ngăn cản việc đưa GraphQL vào WP core. Hơn nữa, tôi thậm chí không nghĩ rằng nó thực sự khó để giải quyết.

Điều cần thiết là máy chủ GraphQL tận dụng các cơ chế bảo mật hiện có của WordPress, và sau đó nhà phát triển sử dụng các cơ chế này, đảm bảo rằng một trường nào đó chỉ có thể được truy cập bởi những người dùng phù hợp:

  • người dùng có đăng nhập không?
  • người dùng có phải là admin không?
  • người dùng có vai trò hoặc khả năng nào đó không?
  • người dùng có phải là tác giả của bài viết không?

Để đáp ứng đề xuất này, Gato GraphQL cung cấp Danh sách kiểm soát truy cập, để chúng ta có thể xác định ai có thể truy cập từng trường và directive, và bằng cấu hình.

Bây giờ, đôi khi chỉ sử dụng một ACL là chưa đủ, và máy chủ GraphQL cần cung cấp các biện pháp bảo mật bổ sung. Tôi sẽ mô tả những gì tôi đang làm ngay bây giờ cho phiên bản v0.8 sắp tới của Gato GraphQL.

Trường posts (để lấy dữ liệu bài viết) không yêu cầu xác thực, bất kỳ người dùng nào cũng có thể truy cập, dù đã đăng nhập hay không. Do đó, vì lý do bảo mật, nó chỉ lấy các bài viết đã xuất bản.

Nhưng có những tình huống khi chúng ta cần lấy cả bài viết nháp/chờ duyệt/đã xóa, chẳng hạn như:

  • Để xây dựng một website tĩnh, được thực thi bởi admin, với quyền truy cập vào tất cả dữ liệu từ trang
  • Cho tác giả của bài viết, để liệt kê tất cả bài viết nháp để họ có thể tiếp tục chỉnh sửa

Sau đó, tôi đã nghĩ ra sơ đồ sau. Để lấy bài viết, sẽ có 3 trường:

  • posts: mở cho tất cả mọi người, chỉ có thể lấy bài viết đã xuất bản
  • myPosts: mở cho tất cả mọi người, chỉ lấy bài viết từ người dùng đã đăng nhập, với bất kỳ trạng thái nào (đã xuất bản/nháp/chờ duyệt/đã xóa)
  • postsForAdmin: chỉ admin mới có thể truy cập, lấy bất kỳ bài viết nào với bất kỳ trạng thái nào

Và sau đó, postsForAdmin bị vô hiệu hóa theo mặc định, vì vậy nó thậm chí không xuất hiện trong GraphQL schema, trừ khi admin kích hoạt nó một cách rõ ràng (và, rất có thể, nó sẽ chỉ được kích hoạt để xây dựng các trang tĩnh).

Một tình huống khác là khi một trường nào đó có thể lấy cả dữ liệu công khai và riêng tư. Ví dụ, trường option lấy dữ liệu từ bảng wp_options. Một số mục là công khai (chẳng hạn như blogname), trong khi các mục khác thì không (chẳng hạn như admin_email).

Một tình huống tương tự là khi lấy giá trị meta, thông qua các trường Post.metaValue, User.metaValue, và các trường khác. Ví dụ, meta người dùng bao gồm mục wp_capabilities, chắc chắn là riêng tư, trong khi description là công khai. Và sau đó có last_name, có thể là công khai hoặc riêng tư tùy thuộc vào ứng dụng.

Để truy cập dữ liệu này một cách bảo mật, plugin sẽ cho phép chỉ định những mục nào có thể được truy vấn thông qua danh sách cho phép/từ chối trong trang cài đặt, chấp nhận cả mục đầy đủ hoặc một regex:

Định nghĩa các mục được phép/từ chối cho trường 'option'

Sau đó, truy vấn tùy chọn được phép sẽ hoạt động, trong khi tùy chọn bị từ chối sẽ chỉ trả về null:

{
  # This option is allowed
  siteName: optionValue(name: "blogname")
  # This optionValue is not allowed
  adminEmail: optionValue(name: "admin_email")
}

Với các biện pháp bảo mật phù hợp được cung cấp bởi máy chủ GraphQL, và sự cẩn thận của nhà phát triển, việc tạo một GraphQL API bảo mật sẽ không khó khăn.

GraphQL làm sập cơ sở dữ liệu

GraphQL là một cú pháp phong phú cho phép diễn đạt các queries quan hệ sâu, vì vậy đối với một hệ sinh thái như WordPress, nơi khả năng mở rộng của mô hình dữ liệu đến từ mô hình entity-attribute-value, điều này dẫn đến lượng hao mòn không thể tin được trên cơ sở dữ liệu, có thể khiến trang của bạn không phản hồi nếu GraphQL query sâu, phức tạp hoặc đệ quy. WordPress đã nổi tiếng là có thể đưa một phiên bản MySQL/MariaDB đến điểm quỵ ngã, vì vậy việc thêm GraphQL có thể làm cho điều này tệ hơn nhiều nếu các queries không được viết, xác thực và giới hạn tốc độ đúng cách. [Source]

Làm sập cơ sở dữ liệu là một mối lo ngại nghiêm trọng đối với các máy chủ GraphQL. Tôi sẽ mô tả cách Gato GraphQL cố gắng tránh tình huống này.

Gato GraphQL ngăn vấn đề N+1 xảy ra, đã theo thiết kế kiến trúc. Nó thực hiện điều này bằng cách để engine chịu trách nhiệm tải các thực thể từ cơ sở dữ liệu, không phải nhà phát triển.

Khi giải quyết các kết nối trong một resolver, giá trị trả về là ID (hoặc danh sách ID) của đối tượng, không phải bản thân đối tượng. Ví dụ, việc lấy tác giả của custom post được thực hiện như thế này:

class CustomPostFieldResolver extends AbstractDBDataFieldResolver
{
  private CustomPostUserTypeAPIInterface $customPostUserTypeAPI;
 
  public function getClassesToAttachTo(): array
  {
    return [
      CustomPostFieldInterfaceResolver::class,
    ];
  }
 
  public function getSchemaFieldType(string $fieldName): ?string
  {
    return match($fieldName) {
      'author' => SchemaDefinition::TYPE_ID,
      default => null,
    };
  }
 
  public function resolveValue(
    TypeResolverInterface $typeResolver,
    object $customPost,
    string $fieldName,
    array $fieldArgs = []
  ): mixed {
    switch ($fieldName) {
      case 'author':
        return $this->customPostUserTypeAPI->getAuthorID($customPost);
    }
 
    return null;
  }
 
  public function resolveFieldTypeResolverClass(
    TypeResolverInterface $typeResolver,
    string $fieldName
  ): ?string {
    switch ($fieldName) {
      case 'author':
        return UserTypeResolver::class;
    }
 
    return null;
  }
}

Có ID thực thể cơ sở dữ liệu từ resolveValue, và kiểu đối tượng từ resolveFieldTypeResolverClass (được đại diện bởi lớp UserTypeResolver), engine GraphQL sau đó có thể tải dữ liệu cho đối tượng.

Để tải dữ liệu, engine sử dụng một thuật toán cực kỳ hiệu quả: nó có độ phức tạp thời gian O(n), trong đó n là số lượng kiểu trong query, không phải số lượng node.

Thuật toán đạt được hiệu quả này vì nó không duyệt qua một đồ thị, mà chuyển đổi cấu trúc dữ liệu thành một ngăn xếp các thành phần, điều này đơn giản hơn nhiều để giải quyết. ("Graph" trong GraphQL là một khái niệm, không phải một triển khai thực tế.)

Do đó, ngay cả khi query có nhiều cấp độ, mỗi cấp độ lấy nhiều thực thể, thuật toán vẫn có thể xử lý nó khá tốt. Ví dụ, không có tác động lớn khi chạy query sau, có độ sâu 10 cấp độ:

{
  posts(pagination: { limit: 10 }) {
    excerpt
    title
    url
    author {
      name
      url
      posts(pagination: { limit: 10 }) {
        title
        tags(pagination: { limit: 10 }) {
          slug
          url
          posts(pagination: { limit: 10 }) {
            title
            comments(pagination: { limit: 10 }) {
              content
              date
              author {
                name
                posts(pagination: { limit: 10 }) {
                  title
                  url
                  comments(pagination: { limit: 10 }) {
                    content
                    date
                    author {
                      name
                      username
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Ngoại lệ cho hiệu quả này là khi lấy các giá trị meta, thông qua Post.metaValue, User.metaValue, Comment.metaValue, PostTag.metaValuePostCategory.metaValue (và cả trường metaValues của chúng). Đó là vì các hàm WordPress (get_post_meta, get_user_meta, v.v.) lấy dữ liệu cho 1 ID mỗi lần, có nghĩa là mỗi thực thể sẽ yêu cầu một lời gọi cơ sở dữ liệu để lấy giá trị meta của nó. Kết quả là, việc giải quyết các giá trị meta mở rộng dựa trên số lượng node, không phải số lượng kiểu (bình luận của OP đã chỉ đúng điều này).

Để ngăn các tác nhân xấu sử dụng và lạm dụng các trường meta, Gato GraphQL (trong v0.8) sẽ được phát hành với các trường này bị tắt theo mặc định. Sau đó, admin phải kích hoạt chúng một cách rõ ràng và, khi làm như vậy, có thể đặt các trường này dưới một số Danh sách kiểm soát truy cập, để cơ sở dữ liệu không bao giờ có nguy cơ bị tấn công.

Rate limiting cũng là một ý tưởng tuyệt vời, tôi dự định hỗ trợ nó cho một số bản phát hành sắp tới.

Và sau đó còn có việc phân tích và áp đặt các giới hạn về độ phức tạp của query (chẳng hạn như độ sâu bao nhiêu cấp). Máy chủ GraphQL giải quyết query với độ phức tạp thời gian O(n), vì vậy không có nhiều thiệt hại có thể xảy ra liên quan đến vòng lặp. Tuy nhiên, một query duy nhất vẫn có thể lấy lượng dữ liệu không giới hạn từ cơ sở dữ liệu, và đó là điều chúng ta có thể muốn tránh.

Ví dụ, query đơn giản này sẽ mang lại một lượng dữ liệu khổng lồ trong một yêu cầu duy nhất (trang demo của tôi chỉ có vài trăm bản ghi, vì vậy tôi có thể minh họa việc thực thi query):

{
  posts000: posts(pagination: { limit: 100 }) {
    ...PostFields
  }
  posts100: posts(pagination: { limit: 100, offset: 100 }) {
    ...PostFields
  }
  posts200: posts(pagination: { limit: 100, offset: 200 }) {
    ...PostFields
  }
  posts300: posts(pagination: { limit: 100, offset: 300 }) {
    ...PostFields
  }
  posts400: posts(pagination: { limit: 100, offset: 400 }) {
    ...PostFields
  }
  posts500: posts(pagination: { limit: 100, offset: 500 }) {
    ...PostFields
  }
  posts600: posts(pagination: { limit: 100, offset: 600 }) {
    ...PostFields
  }
  posts700: posts(pagination: { limit: 100, offset: 700 }) {
    ...PostFields
  }
  posts800: posts(pagination: { limit: 100, offset: 800 }) {
    ...PostFields
  }
  posts900: posts(pagination: { limit: 100, offset: 900 }) {
    ...PostFields
  }
}
 
fragment PostFields on Post {
  id
  title
  content
  date
}

Như có thể thấy, query thậm chí không cần phải lồng nhau để gây ra vấn đề. Vì vậy, phân tích độ phức tạp của một query là một công việc khó khăn, sẽ cần tinh chỉnh để hữu ích.

Tôi hy vọng cũng hỗ trợ phân tích query, nhưng nó không nằm trong danh sách ưu tiên cao của tôi, vì với sự kết hợp của các tính năng khác (chẳng hạn như persisted queries hoặc custom endpoints, kết hợp với Danh sách kiểm soát truy cập) chúng ta đã có thể ngăn chặn các tác nhân xấu, và bản thân chúng ta sẽ không (không nên!) lạm dụng dịch vụ GraphQL của chính mình.


Đăng ký nhận bản tin của chúng tôi

Cập nhật tất cả những điều mới từ Gato GraphQL.