💬 Đề xuất một phương pháp mới cho 'Gutenberg và các ứng dụng tách rời'
Vài ngày trước, người tạo ra WPGraphQL - Jason Bahl - đã xuất bản Gutenberg and Decoupled Applications, phân tích những ưu điểm và hạn chế của 3 phương pháp tích hợp GraphQL với Gutenberg.
Một tuần trước đó, anh ấy cũng đã nói trên Twitter rằng cách tiếp cận của Gato GraphQL để mô hình hóa Gutenberg là không phù hợp:
Theo tôi, đây không phải điều đáng tự hào. Một trong những điều GraphQL cố gắng giải quyết với Schema có kiểu dữ liệu mạnh là cung cấp tính dự đoán và nhất quán cho các client, đồng thời trao cho client quyền kiểm soát để yêu cầu những gì họ muốn, đến tận cấp độ field.
Trả về một kiểu "Object" chung chung không có hình dạng có thể dự đoán đồng nghĩa với việc các ứng dụng client có thể bị hỏng bất cứ lúc nào vì không còn hợp đồng giữa server và client nữa. Server đã tước đoạt quyền kiểm soát khỏi tay client.
Qua bài viết này, tôi tham gia vào cuộc thảo luận. Tôi sẽ phản hồi những chỉ trích của Jason và, trong quá trình đó, mô tả cách tiếp cận của plugin, đồng thời chỉ ra lý do tôi tin rằng nó thực sự có thể phù hợp rất tốt với Gutenberg.
Sử dụng COPE để trích xuất metadata Gutenberg
Giải pháp của tôi có thể được coi là phương pháp thứ 4, và nó như sau:
Để lấy dữ liệu Gutenberg cung cấp cho GraphQL, không tạo thêm schema ở phía PHP, cũng không sao chép bất kỳ dữ liệu hiện có nào. Thay vào đó, hãy trích xuất dữ liệu từ nội dung đã lưu trữ của các block, sử dụng chiến lược COPE ("Create Once, Publish Everywhere" - Tạo một lần, Xuất bản ở khắp nơi).
(COPE là chiến lược cho phép có một nguồn sự thật duy nhất về nội dung và hiển thị nó cho các ứng dụng khác nhau. Trong trường hợp của chúng ta, nguồn sự thật duy nhất là dữ liệu block Gutenberg, như được lưu trữ trong cơ sở dữ liệu. Tôi đã mô tả COPE và cách triển khai của nó cho WordPress trong bài viết này.)
Cuối cùng, chúng ta có thể sử dụng GraphQL để lấy dữ liệu đã trích xuất, cho bất kỳ block Gutenberg nào, bằng cách ánh xạ tất cả các block vào một kiểu Block duy nhất.
Chiến lược này là sự đánh đổi, không phải giải pháp dứt khoát
Chiến lược này không giải quyết vấn đề mà Jason đang chỉ ra: sự thiếu vắng của một schema phía server, thứ sẽ cho phép tạo ra một hợp đồng giữa server và client.
COPE không thể giải quyết vấn đề này vì, chỉ dựa vào nội dung đã lưu trữ, chúng ta không thể tái tạo schema:
- Nội dung đã lưu trữ không chỉ ra kiểu của field
- Nội dung đã lưu trữ không chỉ ra những ràng buộc của field (liệu nó có nullable không? có phải số nguyên dương không? chuỗi dành cho email hay URL?)
- Các field nullable có thể có giá trị mặc định, điều này sẽ không xuất hiện trong nội dung đã lưu trữ
Tuy nhiên, sử dụng chiến lược COPE và một kiểu Block duy nhất để đại diện cho tất cả các block, Gato GraphQL có thể xây dựng một tích hợp khá tốt với Gutenberg, vượt qua những hạn chế hiện có.
Tôi sẽ giải thích xuyên suốt bài viết này.
Tích hợp của Gato GraphQL với Gutenberg
Giải pháp này đang trong quá trình phát triển, nhưng tôi đã có thể giải thích cách nó sẽ hoạt động.
Thay vì phụ thuộc vào một kiểu khác nhau cho mỗi block (như WPGraphQL làm khi dựa vào plugin WPGraphQL for Gutenberg), Gato GraphQL sẽ cung cấp một kiểu Block duy nhất để đại diện cho tất cả các block.
Trong query này, field Post.blockDataItems lấy danh sách các phần tử Block từ bài đăng (cho các block Gutenberg khác nhau, bao gồm đoạn văn, hình ảnh, danh sách và nhiều loại khác):
{
post(by: { id: 1499 }) {
title
blockDataItems
}
}Nếu muốn lấy dữ liệu cho một block cụ thể, chúng ta có thể lọc dựa trên tên của block (core/paragraph, core/quote, v.v.).
Trong query này, chúng ta chỉ lấy các block hình ảnh:
{
post(by: { id: 1177 }) {
title
blockDataItems(
filterBy: { include: "core/image" }
)
}
}Khám phá kiểu Block duy nhất
Với cách tiếp cận này, phản hồi có thể thay đổi tùy thuộc vào nội dung đã lưu trữ, chứ không phải theo schema. Đặc điểm này vừa là ưu điểm (vì nó làm cho API linh hoạt) vừa là nhược điểm (chúng ta không thể thực thi các hợp đồng server-client).
Mỗi phần tử Block chứa hai thuộc tính:
name: Tên của block (core/paragraph,core/quote, v.v.)meta: Metadata chứa trong block
Mỗi block Gutenberg là khác nhau, chứa những dữ liệu khác nhau (nội dung đoạn văn, video YouTube, URL nguồn hình ảnh và kích thước, v.v.). Do đó, dữ liệu chứa trong phản hồi cho field meta cũng sẽ khác nhau.
Vì vậy, field meta đã được ánh xạ đơn giản là một đối tượng JSON (có thể chứa dữ liệu "thô"), thông qua một kiểu JSONObject tương ứng trong schema GraphQL.
Nó tạo ra phản hồi này:
{
"data": {
"post": {
"title": "COPE with WordPress: Post demo containing plenty of blocks",
"blockDataItems": [
{
"name": "core/paragraph",
"attributes": {
"content": "Lorem ipsum dolor sit amet"
}
},
{
"name": "core/image",
"attributes": {
"src": "https://ps.w.org/gutenberg/assets/banner-1544x500.jpg"
}
},
{
"name": "core/quote",
"attributes": {
"quote": "Etiam tempor orci eu lobortis elementum nibh tellus molestie",
"cite": "Aristoteles"
}
},
{
"name": "core/heading",
"attributes": {
"size": "xl",
"heading": "Welcome to my site"
}
},
{
"name": "core/list",
"attributes": {
"items": [
"First element",
"Second element",
"Third element"
]
}
},
]
}
}
}Như chúng ta có thể thấy, các block khác nhau lấy các thuộc tính khác nhau:
core/paragraphcó thuộc tínhcontentcore/imagecó thuộc tínhsrc, và tùy chọn các thuộc tínhwidth,heightvàcaption(không xuất hiện trong phản hồi trên)core/quotecó các thuộc tínhquotevàcite(cho người được trích dẫn)core/headingcó các thuộc tínhheadervàsize(giá trịxlđại diện cho<h2>, vì COPE tách rời giá trị khỏi ứng dụng đích, trong trường hợp này là một trang web)core/listcó thuộc tínhitems, là một danh sách các phần tử
Tại sao kiểu JSONObject không thuộc đặc tả
Kiểu JSONObject tôi mô tả ở trên cho phép GraphQL lấy các field "động" (chẳng hạn các field mà chúng ta không biết trước), hoặc các field có thể có nhiều cấu hình (như có thể xảy ra với các block Gutenberg).
Hiện tại, đặc tả GraphQL không hỗ trợ các kiểu JSONObject hay Map. Việc bổ sung hỗ trợ đã được đề xuất, với những lý do như:
[...] việc thiếu tính năng này đặc biệt gây khó khăn vì nó được hỗ trợ trong nhiều hệ thống kiểu và dịch vụ mà GraphQL giao tiếp.
Điều này dẫn đến việc triển khai các resolver tùy chỉnh trên server, tiếp theo là các phép biến đổi tùy chỉnh trên client, để xử lý các tình huống mà server của tôi gửi một Map, client của tôi muốn một Map, và GraphQL ở giữa mà không hỗ trợ Map. Vâng, điều đó là khả thi, và tôi đã làm rồi, nhưng đó là khá nhiều boilerplate và abstraction có vẻ đánh bại mục đích viết đặc tả API bằng GraphQL.
Tính năng này không được đặc tả hỗ trợ vì việc xử lý các field động đi ngược lại hành vi kiểu dữ liệu mạnh của GraphQL, phá vỡ hợp đồng giữa server và client.
Dù vậy, kiểu này có thể có ích cho Gutenberg, như tôi sẽ chỉ ra sau.
Vấn đề khi sử dụng một kiểu khác nhau cho mỗi block và registry phía server
Nếu tạo một kiểu GraphQL mới cho mỗi block, thì tất cả các plugin phải có các block của họ được thêm vào schema GraphQL. Điều này có thể được thực hiện tự động bằng cách yêu cầu tất cả các block định nghĩa thuộc tính của chúng trong registry phía server mới được đề xuất.
Nếu không làm vậy, các block của họ sẽ không khả dụng với API, và điều này có thể có những hậu quả bổ sung. Trong một số trường hợp, toàn bộ nội dung bài đăng được truy vấn có thể trở nên không đáng tin cậy.
Điều này có thể xảy ra khi GraphQL tương tác với một dịch vụ đám mây bên ngoài, áp dụng một chức năng nào đó cho tất cả các block trong bài đăng (hãy nghĩ đến dịch thuật, sửa lỗi ngữ pháp, gợi ý SEO, phân tích, v.v.).
Hãy xem một ví dụ về điều này.
Vì khả năng đa ngôn ngữ sẽ được thêm vào Gutenberg trong giai đoạn 4, hãy mô hình hóa cách dịch tất cả các block trong plugin, thông qua lệnh gọi đến API Google Translate được thực thi qua directive @strTranslate.
(Sau bản dịch ban đầu dựa trên API này, người dùng có thể tiếp tục chỉnh sửa bài đăng, bằng ngôn ngữ đã dịch, luôn trong trình soạn thảo WordPress.)
Các block khác nhau chứa những thông tin khác nhau cần được dịch:
core/paragraph: văn bảncore/image: chú thíchcore/quote: trích dẫn, và người được trích dẫn (vì đó có thể là chức danh của người đó, chẳng hạn như "The school headmaster")core/heading: tiêu đềcore/list: tất cả các mục trong danh sách
Sử dụng một kiểu khác nhau cho mỗi block, query kết quả có thể trông như thế này:
{
post(by: { id: 1 }) {
blocks {
... on CoreParagraphBlock {
content @strTranslate
}
... on CoreImageBlock {
caption @strTranslate
}
... on CoreQuoteBlock {
quote @strTranslate
cite @strTranslate
}
... on CoreHeadingBlock {
heading @strTranslate
}
... on CoreListBlock {
items @strTranslateList
}
... on EmbedTwitterBlock {
caption @strTranslate
}
... on EmbedYoutubeBlock {
caption @strTranslate
}
... on EmbedVimeoBlock {
caption @strTranslate
}
}
}
}Cứ tiếp tục như vậy. Càng nhiều block, query càng dài, dễ dàng kéo dài tới hàng trăm dòng hoặc hơn.
Vấn đề hiển nhiên là query trở thành một con quái vật mà chúng ta cần duy trì.
Ngoài ra, chúng ta cần đưa vào các chức năng tùy chỉnh để làm cho nó hoạt động cho mỗi block. Ví dụ, @strTranslate không hoạt động với CoreListBlock.items, vốn trả về một danh sách các chuỗi (tức là trả về [String], trong khi directive lại mong đợi String), và vì vậy chúng ta phải tạo @strTranslateList.
Rồi core/table sẽ cần directive tùy chỉnh riêng của nó (@strTranslateTable?).
Và các block tùy chỉnh của bên thứ ba có thể cần các directive tùy chỉnh riêng của chúng.
Và sau đó, tôi thấy thêm một vài vấn đề nữa.
Tất cả hoặc không có gì
Một bài đăng blog có thể chứa bất kỳ block nào được cài đặt trong trình soạn thảo WordPress. Và chúng ta không biết trước (khi viết code cho query) bài đăng sử dụng những block nào.
Khi đó, với một kiểu cho mỗi block, số lượng kiểu cần xử lý trong query sẽ không tương đương với số lượng block trong bài đăng. Thay vào đó, nó sẽ tương đương với số lượng block được cài đặt trong trình soạn thảo WordPress.
Điều gì xảy ra nếu chúng ta có 100 block trên trang web, bao gồm cả từ WordPress core và các plugin? Thì chúng ta cần có 100 kiểu được ánh xạ vào schema GraphQL. Chỉ một cái không được ánh xạ có thể phá vỡ "hợp đồng nội dung", dẫn đến một số block được dịch từ tiếng Anh sang tiếng Pháp, trong khi các block khác vẫn giữ nguyên tiếng Anh.
Kết quả là, chúng ta sẽ không thể tin tưởng vào các bài đăng đã dịch nữa, dù chúng có chứa block vi phạm hay không. Vì vậy, nếu không phải tất cả các block đều được thêm vào registry, ứng dụng có thể trở nên không đáng tin cậy.
Query phải được cập nhật mỗi khi một block mới được cài đặt
Tương tự như vậy, mỗi block phải được xử lý trong query GraphQL. Điều đó có nghĩa là, bất cứ khi nào cài đặt một block mới, chúng ta cần vào code của ứng dụng, cập nhật nó và triển khai lại.
Đây không chỉ là thủ tục hành chính thêm: Chúng ta sẽ không thể cài đặt một block trên một trang web đang chạy, mà không lo ngại sẽ làm hỏng ứng dụng (cho đến khi tất cả các queries được cập nhật).
GraphQL phải phục vụ WordPress, không phải ngược lại
Xem xét lại lý do tại sao JSONObject không được thêm vào đặc tả GraphQL, đó là vì nó không phù hợp với cách làm việc của GraphQL.
Tuy nhiên, ở đây chúng ta không thực sự quan tâm đến GraphQL. Chúng ta chỉ quan tâm đến WordPress và, cụ thể hơn trong trường hợp này, là Gutenberg.
Khi tích hợp GraphQL với Gutenberg, GraphQL sẽ hoạt động trong ngữ cảnh của WordPress. Điều đó có nghĩa là WordPress sẽ cần đáp ứng các yêu cầu từ GraphQL. Nhưng quan trọng hơn, chính GraphQL phải đáp ứng các yêu cầu từ WordPress.
Và trong trường hợp xung đột, WordPress được ưu tiên.
Nếu một tính năng không phù hợp với GraphQL, nhưng vẫn phù hợp với Gutenberg, liệu nó có nên được xem xét không?
Tôi nghĩ là nên.
Hãy xem cách một kiểu Block duy nhất có thể phục vụ Gutenberg tốt hơn.
Giải quyết các vấn đề trước đó thông qua kiểu Block duy nhất
Theo ví dụ trước, dịch tất cả các block trong bài đăng từ tiếng Anh sang tiếng Pháp, sử dụng kiểu Block duy nhất, sẽ được thực hiện như thế này (hoặc gì đó xung quanh khái niệm này):
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
}
}
}Chỉ vậy thôi? Toàn bộ query? Để dịch tất cả các block? Đúng vậy.
Nó có hoạt động cho tất cả các block, từ cả core lẫn plugin, đã tồn tại hoặc chưa được tạo? Đúng vậy.
Query này trông có vẻ hơi lạ với bạn không? Nếu có, đó là vì nó sử dụng các tính năng GraphQL phi chuẩn, chỉ được Gato GraphQL hỗ trợ:
{{ translatablePaths }}là một field có thể nhúng, để đưa giá trị của một field làm đối số cho một field hoặc directive khác (trong trường hợp này, kiểuBlocksẽ có một fieldtranslatableFields, giá trị của nó được inject vào directive@advancePointersInArray)- các directive có thể được tạo thành từ các directive khác
Bây giờ, nếu một tính năng đáp ứng chính xác những gì CMS cần, nhưng tính năng đó là phi chuẩn, liệu chúng ta có nên sử dụng nó không? Tôi nghĩ là nên.
Tôi cũng đã đề xuất các tính năng này cho đặc tả GraphQL (mặc dù chúng sẽ không được chấp nhận):
Cách kiểu Block duy nhất hoạt động
Cảnh báo: phần kỹ thuật phía trước.
Kiểu Block sẽ có một field translatablePaths, trả về một mảng các thuộc tính từ JSONObject cần được dịch:
core/paragraphtrả về["content"]core/imagetrả về["caption"]core/quotetrả về["quote", "cite"]core/headingtrả về["header"]core/listtrả về["items.0", "items.1", "items.2", ...]
@advancePointersInArray là một meta-directive: nó sửa đổi ngữ cảnh cho directive tiếp theo. Nó làm cho directive tiếp theo nhận được một phần tử con từ bên trong JSONObject được truy vấn, chẳng hạn như thuộc tính content từ block đoạn văn. Danh sách các đường dẫn được lấy qua field translatablePaths, được đánh giá trên cùng một thực thể được truy vấn.
Sau đó, @underEachArrayItem là một meta-directive khác, lặp qua danh sách các phần tử từ thực thể được truy vấn, và truyền tham chiếu đến phần tử được lặp cho directive tiếp theo. Trong trường hợp này, nó lấy tất cả danh sách các thuộc tính cần dịch cho tất cả các thực thể, mỗi cái có kiểu String, và truyền các phần tử String riêng lẻ xuống.
Cuối cùng, directive @strTranslate nhận một phần tử có kiểu String chứa trong JSONObject, và dịch nó ngay tại đó, bên trong chính JSONObject.
Hãy chú ý giải pháp này linh hoạt đến mức nào. Chỉ cần cung cấp đường dẫn đến chuỗi trong JSONObject là đủ để truy cập giá trị, sửa đổi nó bằng @strTranslate (hoặc bất kỳ directive nào khác), và thậm chí có thể lưu lại giá trị vào cơ sở dữ liệu (công việc để thực hiện điều này hiện đang được tiến hành).
Nó đã hoạt động cho core/list, vì tất cả các phần tử trong danh sách có thể được truy cập theo đường dẫn riêng của chúng (items.0 là phần tử thứ 1 trong mảng, v.v.). Sau đó, nó có thể truy cập giá trị String từ mỗi cái, và truyền nó cho @strTranslate, vì vậy không cần tạo @strTranslateList.
Tương tự, nó cũng sẽ hoạt động với core/table. Chúng ta chỉ cần hiển thị dữ liệu thông qua thuộc tính cells, sẽ là một mảng 2 chiều (một cho các hàng, chứa một cho các cột). Sau đó, translatablePaths có thể tiếp cận tất cả các phần tử như ["cells.0.0", "cells.0.1", "cells.1.0", ...].
Và nó cũng sẽ hoạt động cho bất kỳ block của bên thứ ba nào. Để làm điều đó, chúng ta phải chú ý đến cách dữ liệu block được lưu trữ, và từ đó chúng ta có thể suy ra đường dẫn đến các thuộc tính của nó.
Một Block duy nhất yêu cầu cấu hình, dựa trên code PHP
Việc ánh xạ các block, để chúng ta biết tìm thuộc tính metadata của chúng ở đâu, có thể được thực hiện thông qua cấu hình. Vì vậy, chúng ta có thể xử lý nó theo cách rất linh hoạt.
Trong Gutenberg, có hai nơi mà thuộc tính của block có thể được lưu trữ: dưới dạng attribute, hoặc bên trong nội dung được render.
Ví dụ, đây là cách block core/image được lưu trữ:
<!-- wp:image {"id":1670,"sizeSlug":"large","linkDestination":"none"} -->
<figure class="wp-block-image size-large">
<img src="https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp" alt="" class="wp-image-1670"/>
</figure>
<!-- /wp:image -->Trong trường hợp này, chúng ta có:
- Các thuộc tính
id,sizeSlugvàlinkDestinationđược lưu trữ dưới dạng attribute - Thuộc tính
srcđược lưu trữ bên trong nội dung được render
Bây giờ, khi truy vấn API, phản hồi cho block core/image sẽ như sau:
{
"data": {
"blocks": [
{
"name": "core/image",
"meta": {
"id": 1670,
"sizeSlug": "large",
"linkDestination": "none",
"src": "https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp"
}
}
]
}
}API biết cách lấy các thuộc tính bằng cách phân tích block đã lưu trữ trong Gutenberg (đó là chiến lược COPE). Quá trình này có thể được thực hiện tự động đến một mức độ nhất định, và sau đó là một số đầu vào thủ công qua hook, hoặc thông qua một giao diện người dùng.
Để lấy các thuộc tính được ánh xạ trực tiếp dưới dạng attribute là điều đơn giản. Server GraphQL đã có thể lấy tất cả các attribute từ block và làm chúng có sẵn dưới dạng thuộc tính. Hoặc, nếu chúng ta muốn định nghĩa rõ ràng cái nào cần hiển thị, chúng ta có thể làm điều đó qua filter hook:
$attrs = apply_filters("blockPropsAsAttr:core/image", []);
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})Các thuộc tính được lưu trữ trong nội dung có thể được trích xuất bằng regex:
$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
$propRegexes['src'] = '/<img src="(.*?)"/';
return $propRegexes;
})Cuối cùng, chúng ta chỉ ra đâu là các thuộc tính có thể dịch của block, để @strTranslate tác động vào:
$propRegexes = apply_filters("translatableProperties:core/image", []);
add_filter("translatableProperties:core/image", function ($properties) {
$properties[] = 'caption';
return $properties;
})Bây giờ, những thuộc tính này vẫn cần được ai đó đáp ứng, rất có thể là nhà phát triển plugin. Do đó, việc có registry phía server sẽ giúp đạt được mục tiêu này.
Nhưng điều gì xảy ra nếu cộng đồng WordPress không muốn thêm registry phía server được đề xuất? Vâng, chiến lược này có thể dễ dàng thích nghi, vì việc ánh xạ có thể được thực hiện qua code PHP, như vừa được chỉ ra.
Nếu bất kỳ block nào chưa được ánh xạ, người dùng cũng có thể tự làm, chỉ cần biết một chút về Gutenberg, và không cần biết gì về GraphQL hay schema.
Ngoài ra, chúng ta có thể có GraphQL cảnh báo người dùng khi có một block chưa được ánh xạ (và vì vậy không thể được dịch). Chúng ta có thể làm điều này bằng cách thêm một meta-directive @if, khi điều kiện áp dụng, sẽ thực thi directive @sendEmail:
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
@if(condition: "{{ isTranslatablePathsUnmapped }}")
@sendEmail(
to: "{{ root.adminEmail }}",
subject: "Block with name {{ name }} has 'translatablePaths' unmapped"
)
}
}
}Giải pháp này linh hoạt và đơn giản, và có GraphQL phục vụ WordPress, mà không yêu cầu các nhà phát triển học một công nghệ mới, hay thay đổi cách Gutenberg hoạt động.
Kết luận
Khi nghĩ về cách một tích hợp khả dĩ giữa GraphQL và Gutenberg sẽ trông như thế nào (từ khả năng được đưa vào WordPress core), chúng ta phải đảm bảo rằng GraphQL có thể xử lý tất cả các yêu cầu trong tương lai của Gutenberg, bao gồm hỗ trợ đầy đủ cho:
- các block đa ngôn ngữ
- Full Site Editing
- chỉnh sửa cộng tác
- tương tác với các dịch vụ bên thứ ba trên một trang web đang chạy
Tất cả những điều này phải được thực hiện mà hy vọng không cần thay đổi Gutenberg (ít nhất là không đáng kể), và giảm bớt các tác vụ mới được yêu cầu từ các nhà phát triển plugin.
Tính đến những điều này, tôi tin rằng phương pháp thứ 4 tôi đề xuất ở đây thực sự có thể hoạt động rất tốt.