💬 Proponowanie nowego podejścia dla 'Gutenberg i Oddzielonych Aplikacji'
Kilka dni temu twórca WPGraphQL, Jason Bahl, opublikował Gutenberg and Decoupled Applications, analizując zalety i ograniczenia 3 podejść do integracji GraphQL z Gutenberg.
Tydzień wcześniej napisał na Twitterze, że podejście Gato GraphQL do modelowania Gutenberg jest nieodpowiednie:
To nie jest coś, z czego należy się chwalić, moim zdaniem. Jedną z rzeczy, którą GraphQL stara się rozwiązać za pomocą typowanego schematu, jest zapewnienie przewidywalności i spójności dla klientów oraz dawanie im kontroli nad tym, o co chcą zapytać, aż do poziomu pola.
Zwracanie ogólnego typu "Object" bez przewidywalnego kształtu oznacza, że aplikacje klienckie mogą się zepsuć w dowolnym momencie, ponieważ nie istnieje już kontrakt między serwerem a klientem. Serwer odebrał teraz kontrolę klientowi.
Poprzez ten artykuł dołączam do rozmowy. Odniosę się do krytyki Jasona i przy tej okazji opiszę podejście mojego pluginu, pokazując, dlaczego uważam, że może ono rzeczywiście bardzo dobrze pasować do Gutenberg.
Używanie COPE do wyodrębniania metadanych Gutenberg
Moje rozwiązanie można uznać za 4. podejście i wygląda ono następująco:
Aby uzyskać dane Gutenberg zasilające GraphQL, nie twórz dodatkowego schematu po stronie PHP ani nie duplikuj żadnych istniejących danych. Zamiast tego wyodrębnij dane z przechowywanej zawartości bloków, używając strategii COPE ("Create Once, Publish Everywhere").
(COPE to strategia, która umożliwia posiadanie jednego źródła prawdy dla treści i udostępnianie jej różnym aplikacjom. W naszym przypadku jedynym źródłem prawdy są dane bloków Gutenberg, tak jak są one przechowywane w bazie danych. Opisałem COPE i jego implementację dla WordPress w tym artykule.)
Na koniec możemy użyć GraphQL do pobrania wyodrębnionych danych dla dowolnego bloku Gutenberg, mapując wszystkie bloki do jednego typu Block.
Ta strategia to kompromis, nie ostateczne rozwiązanie
Ta strategia nie rozwiązuje problemu, który wskazuje Jason: braku schematu po stronie serwera, który umożliwiłby stworzenie kontraktu między serwerem a klientem.
COPE nie może rozwiązać tego problemu, ponieważ wyłącznie na podstawie przechowywanej zawartości nie jesteśmy w stanie odtworzyć schematu:
- Przechowywana zawartość nie wskazuje typu pola
- Przechowywana zawartość nie wskazuje, jakie ograniczenia ma pole (czy jest nullable? czy jest dodatnią liczbą całkowitą? czy string jest dla adresu e-mail czy URL?)
- Pola nullable mogą mieć wartość domyślną, która nie będzie obecna w przechowywanej zawartości
Niemniej jednak, używając strategii COPE i jednego typu Block do reprezentowania wszystkich bloków, Gato GraphQL może zbudować bardzo dobrą integrację z Gutenberg, pokonując istniejące ograniczenia.
Wyjaśnię to w całym tym artykule.
Integracja Gato GraphQL z Gutenberg
To rozwiązanie jest w trakcie opracowywania, ale mogę już wyjaśnić, jak będzie się zachowywać.
Zamiast polegać na innym typie dla każdego bloku (jak robi to WPGraphQL, używając pluginu WPGraphQL for Gutenberg), Gato GraphQL zapewni jeden typ Block do reprezentowania wszystkich bloków.
W tym zapytaniu pole Post.blockDataItems pobiera listę elementów Block z posta (dla różnych bloków Gutenberg, w tym akapitów, obrazów, list i innych):
{
post(by: { id: 1499 }) {
title
blockDataItems
}
}Jeśli chcemy pobrać dane dla konkretnego bloku, możemy filtrować według nazwy bloku (core/paragraph, core/quote itd.).
W tym zapytaniu pobieramy tylko bloki obrazów:
{
post(by: { id: 1177 }) {
title
blockDataItems(
filterBy: { include: "core/image" }
)
}
}Inspekcja jednego typu Block
Przy tym podejściu odpowiedź może się różnić w zależności od przechowywanej zawartości, a nie od schematu. Ta cecha jest jednocześnie jej zaletą (ponieważ czyni API elastycznym) i jej wadą (nie możemy egzekwować kontraktów serwer-klient).
Każdy element Block zawiera dwie właściwości:
name: Nazwa bloku (core/paragraph,core/quoteitd.)meta: Metadane zawarte w bloku
Każdy blok Gutenberg jest inny i zawiera różne dane (zawartość akapitu, film z Youtube, URL źródła obrazu i jego wymiary itd.). Dlatego dane zawarte w odpowiedzi dla pola meta również będą różne.
W związku z tym pole meta zostało zmapowane po prostu jako obiekt JSON (który może zawierać dane "surowe"), poprzez odpowiadający typ JSONObject w schemacie GraphQL.
To daje następującą odpowiedź:
{
"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"
]
}
},
]
}
}
}Jak widać, różne bloki pobierają różne właściwości:
core/paragraphma właściwośćcontentcore/imagema właściwośćsrc, a opcjonalnie właściwościwidth,heighticaption(niewidoczne w powyższej odpowiedzi)core/quotema właściwościquoteicite(dla cytowanej osoby)core/headingma właściwościheaderisize(wartośćxlreprezentuje<h2>, ponieważ COPE oddziela wartość od aplikacji docelowej, w tym przypadku strony internetowej)core/listma właściwośćitems, która jest listą elementów
Dlaczego typ JSONObject nie jest częścią specyfikacji
Opisany powyżej typ JSONObject pozwala GraphQL na pobieranie pól "dynamicznych" (takich jak pola, o których nie wiemy), lub pól, które mogą mieć wiele konfiguracji (jak może być w przypadku bloków Gutenberg).
Otóż specyfikacja GraphQL obecnie nie obsługuje typów JSONObject ani Map. Dodanie obsługi zostało zgłoszone, z powodów takich jak:
[...] brak tej funkcji jest szczególnie problematyczny, ponieważ jest ona obsługiwana w wielu systemach typów i usługach, z którymi GraphQL współpracuje.
Prowadzi to do implementowania niestandardowych resolverów na serwerze, a następnie niestandardowych transformacji po stronie klienta, aby radzić sobie z sytuacjami, gdy mój serwer wysyła Map, mój klient chce Map, a GraphQL jest pośrodku bez obsługi Map. Tak, jest to możliwe i zrobiłem to, ale wymaga sporej ilości boilerplate i abstrakcji, co zdaje się niwelować cel pisania specyfikacji API w GraphQL.
Ta funkcja nie jest obsługiwana przez specyfikację, ponieważ obsługa dynamicznych pól stoi w sprzeczności z zachowaniem silnego typowania GraphQL, które łamie kontrakt między serwerem a klientem.
Mimo to typ ten może być korzystny dla Gutenberg, jak pokażę później.
Problemy przy używaniu różnego typu dla każdego bloku i rejestru po stronie serwera
Jeśli tworzymy nowy typ GraphQL dla każdego bloku, to wszystkie pluginy muszą mieć swoje bloki dodane do schematu GraphQL. Można to osiągnąć automatycznie, sprawiając, aby wszystkie bloki definiowały swoje właściwości w proponowanym nowym rejestrze po stronie serwera.
Jeśli tego nie zrobią, ich bloki będą niedostępne dla API, co może mieć dodatkowe konsekwencje. W niektórych okolicznościach cała zawartość odpytanego posta może stać się niewiarygodna.
Taki przypadek może zaistnieć, gdy GraphQL współpracuje z zewnętrzną usługą działającą w chmurze, która stosuje pewną funkcję do wszystkich bloków w poście (pomyśl o tłumaczeniu, korygowaniu gramatyki, sugestiach SEO, analityce itd.).
Zobaczmy przykład.
Ponieważ możliwości wielojęzyczne zostaną dodane do Gutenberg w fazie 4, zamodelujmy sposób tłumaczenia wszystkich bloków w pluginie za pomocą wywołania API Google Translate wykonywanego przez dyrektywę @strTranslate.
(Po tym wstępnym tłumaczeniu opartym na API użytkownik może kontynuować edycję wpisu na blogu w przetłumaczonym języku, zawsze wewnątrz edytora WordPress.)
Różne bloki zawierają różne informacje, które należy przetłumaczyć:
core/paragraph: tekstcore/image: podpiscore/quote: cytat i cytowana osoba (ponieważ może to być tytuł osoby, np. "The school headmaster")core/heading: nagłówekcore/list: wszystkie elementy listy
Używając różnego typu dla każdego bloku, wynikowe zapytanie może wyglądać mniej więcej tak:
{
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
}
}
}
}I tak dalej. Im więcej bloków mamy, tym dłuższe będzie to zapytanie, łatwo osiągając setki linii lub więcej.
Oczywisty problem polega na tym, że zapytanie staje się trudnym do utrzymania potworem.
Ponadto musimy wprowadzać niestandardową funkcjonalność, aby działało to dla każdego bloku. Na przykład @strTranslate nie działa z CoreListBlock.items, które zwraca listę stringów (tj. zwraca [String], podczas gdy dyrektywa oczekuje String), więc musimy stworzyć @strTranslateList.
Następnie core/table wymagałby własnej niestandardowej dyrektywy (@strTranslateTable?).
A niestandardowe bloki firm trzecich mogą wymagać własnych niestandardowych dyrektyw.
I wtedy widzę jeszcze kilka problemów.
Wszystko albo nic
Wpis na blogu może zawierać dowolny blok zainstalowany w edytorze WordPress. I nie wiemy z góry (podczas pisania zapytania), jakich bloków używa post.
Przy jednym typie na blok, liczba typów do obsłużenia w zapytaniu nie będzie równoważna liczbie bloków w poście. Zamiast tego będzie równoważna liczbie bloków zainstalowanych w edytorze WordPress.
Co się stanie, jeśli na naszej stronie mamy 100 bloków, zarówno z rdzenia WordPress, jak i z pluginów? Musimy wtedy mieć 100 typów zmapowanych do schematu GraphQL. Jeden niezmapowany może złamać "kontrakt treści", powodując, że niektóre bloki są tłumaczone z angielskiego na francuski, podczas gdy inne pozostają po angielsku.
W rezultacie nie będziemy mogli ufać przetłumaczonym postom, niezależnie od tego, czy zawierają problematyczny blok. Jeśli więc nie wszystkie bloki są dodane do rejestru, aplikacja może stać się niewiarygodna.
Zapytanie musi być aktualizowane za każdym razem, gdy instalowany jest nowy blok
Podobnie każdy blok musi być obsługiwany w zapytaniu GraphQL. Oznacza to, że za każdym razem, gdy instalujemy nowy blok, musimy przejść do kodu naszej aplikacji, zaktualizować go i ponownie wdrożyć.
To nie jest tylko dodatkowa biurokracja: nie będziemy mogli zainstalować bloku na działającej stronie bez obawy o zepsucie aplikacji (dopóki wszystkie zapytania nie zostaną zaktualizowane).
GraphQL musi służyć WordPress, a nie odwrotnie
Wracając do kwestii, dlaczego JSONObject nie został dodany do specyfikacji GraphQL — powodem jest to, że nie pasuje do sposobu działania GraphQL.
Jednak tutaj nie jesteśmy naprawdę zainteresowani GraphQL. Interesuje nas tylko WordPress i, konkretniej w tym przypadku, Gutenberg.
Integrując GraphQL z Gutenberg, GraphQL będzie działał w kontekście WordPress. Oznacza to, że WordPress będzie musiał spełniać wymagania GraphQL. Ale ważniejsze jest to, że to GraphQL musi spełniać wymagania WordPress.
I w przypadku konfliktu, WordPress ma priorytet.
Jeśli funkcja nie pasuje do GraphQL, ale mimo to pasuje do Gutenberg, czy powinna być brana pod uwagę?
Uważam, że tak.
Zobaczmy, jak jeden typ Block może lepiej służyć Gutenberg.
Rozwiązywanie poprzednich problemów za pomocą jednego typu Block
Nawiązując do poprzedniego przykładu, tłumaczenie wszystkich bloków w poście z angielskiego na francuski, używając jednego typu Block, będzie wyglądać tak (lub mniej więcej w oparciu o ten koncept):
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
}
}
}To wszystko? Całe zapytanie? Do tłumaczenia wszystkich bloków? Tak.
Czy zadziała dla wszystkich bloków, zarówno z rdzenia, jak i z pluginów, już istniejących lub jeszcze do stworzenia? Tak.
Czy to zapytanie wygląda dla ciebie trochę dziwnie? Jeśli tak, to dlatego, że używa niestandardowych funkcji GraphQL obsługiwanych tylko przez Gato GraphQL:
{{ translatablePaths }}to pole osadzalne, służące do wprowadzenia wartości pola jako argumentu do innego pola lub dyrektywy (w tym przypadku typBlockbędzie miał poletranslatableFields, którego wartość jest wstrzykiwana do dyrektywy@advancePointersInArray)- dyrektywy mogą być składane z innych dyrektyw
Jeśli funkcja spełnia dokładnie to, czego potrzebuje CMS, ale jest niestandardowa, czy nadal powinniśmy jej używać? Uważam, że tak.
Zgłosiłem również te funkcje do specyfikacji GraphQL (mimo że nie zostaną zaakceptowane):
Jak działa jeden typ Block
Uwaga: przed nami sekcja techniczna.
Typ Block będzie miał pole translatablePaths, zwracające tablicę właściwości z JSONObject, które należy przetłumaczyć:
core/paragraphzwraca["content"]core/imagezwraca["caption"]core/quotezwraca["quote", "cite"]core/headingzwraca["header"]core/listzwraca["items.0", "items.1", "items.2", ...]
@advancePointersInArray to meta-dyrektywa: modyfikuje kontekst dla kolejnej dyrektywy. Sprawia, że kolejna dyrektywa otrzymuje podelement z wnętrza odpytywanego JSONObject, na przykład właściwość content z bloku akapitu. Lista ścieżek jest uzyskiwana przez pole translatablePaths, oceniane dla tej samej odpytywanej jednostki.
Następnie @underEachArrayItem to kolejna meta-dyrektywa, która iteruje po liście elementów odpytywanej jednostki i przekazuje odwołanie do iterowanego elementu do następnej dyrektywy. W tym przypadku pobiera całą listę właściwości do przetłumaczenia dla wszystkich jednostek, każda z nich typu String, i przekazuje poszczególne elementy String dalej.
Na koniec dyrektywa @strTranslate odbiera element typu String zawarty w JSONObject i tłumaczy go bezpośrednio tam, wewnątrz samego JSONObject.
Proszę zwrócić uwagę, jak elastyczne jest to rozwiązanie. Samo podanie ścieżki do stringa w JSONObject wystarczy, aby uzyskać dostęp do wartości, zmodyfikować ją za pomocą @strTranslate (lub dowolnej innej dyrektywy), a nawet ewentualnie zapisać wartość ponownie w bazie danych (prace nad tym są obecnie w toku).
Działa już dla core/list, ponieważ wszystkie elementy listy są dostępne pod własną ścieżką (items.0 to 1. element w tablicy itd.). Następnie można uzyskać dostęp do wartości String z każdego z nich i przekazać ją do @strTranslate, więc nie ma potrzeby tworzenia @strTranslateList.
Podobnie zadziała również z core/table. Wystarczy udostępnić dane przez właściwość cells, która będzie tablicą 2-wymiarową (jedna dla wierszy, zawierająca jedną dla kolumn). Następnie translatablePaths może dotrzeć do wszystkich elementów jako ["cells.0.0", "cells.0.1", "cells.1.0", ...].
I zadziała również dla dowolnego bloku firm trzecich. W tym celu musimy zwrócić uwagę, jak dane bloku są przechowywane, i na tej podstawie możemy wywnioskować ścieżkę do jego właściwości.
Jeden Block wymaga konfiguracji opartej na kodzie PHP
Mapowanie bloków, abyśmy wiedzieli, gdzie znaleźć ich właściwości metadanych, można osiągnąć przez konfigurację. Możemy więc radzić sobie z tym w bardzo elastyczny sposób.
W Gutenberg istnieją dwa miejsca, w których właściwość bloku może być przechowywana: jako atrybut lub wewnątrz renderowanej zawartości.
Na przykład, oto jak przechowywany jest blok core/image:
<!-- 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 -->W tym przypadku mamy:
- Właściwości
id,sizeSlugilinkDestinationsą przechowywane jako atrybuty - Właściwość
srcjest przechowywana wewnątrz renderowanej zawartości
Teraz, odpytując API, odpowiedź dla bloku core/image będzie następująca:
{
"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 wie, jak pobierać właściwości, analizując przechowywany blok w Gutenberg (to jest strategia COPE). Ten proces może być wykonywany automatycznie do pewnego stopnia, a następnie z pewnym ręcznym wkładem przez hooki lub przez interfejs użytkownika.
Uzyskanie właściwości bezpośrednio zmapowanych jako atrybuty jest trywialne. Serwer GraphQL może już pobierać wszystkie atrybuty z bloku i udostępniać je jako właściwości. Lub, jeśli chcemy jawnie zdefiniować, które z nich udostępnić, możemy to zrobić przez filter hooki:
$attrs = apply_filters("blockPropsAsAttr:core/image", []);
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})Właściwości przechowywane w zawartości mogą być wyodrębniane przez wyrażenia regularne:
$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
$propRegexes['src'] = '/<img src="(.*?)"/';
return $propRegexes;
})Na koniec wskazujemy, które właściwości bloku są tłumaczalne, aby @strTranslate mógł na nich działać:
$propRegexes = apply_filters("translatableProperties:core/image", []);
add_filter("translatableProperties:core/image", function ($properties) {
$properties[] = 'caption';
return $properties;
})Teraz te właściwości nadal muszą być dostarczone przez kogoś, najprawdopodobniej przez dewelopera pluginu. Dlatego posiadanie rejestru po stronie serwera pomoże osiągnąć ten cel.
Ale co, jeśli społeczność WordPress nie chce dodawać proponowanego rejestru po stronie serwera? Cóż, ta strategia może się łatwo dostosować, ponieważ mapowanie można wykonać przez kod PHP, jak właśnie pokazano.
Jeśli jakiś blok nie został zmapowany, użytkownik może to zrobić samodzielnie, znając nieco Gutenberg, nie wiedząc nic o GraphQL ani schematach.
Ponadto możemy sprawić, aby GraphQL alertował użytkownika, gdy istnieje blok, który nie został zmapowany (i dlatego nie może być przetłumaczony). Możemy to zrobić, dodając meta-dyrektywę @if, która, jeśli warunek jest spełniony, wykonuje dyrektywę @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"
)
}
}
}To rozwiązanie jest elastyczne i proste, i sprawia, że GraphQL służy WordPress, nie wymagając od deweloperów uczenia się nowej technologii ani zmiany sposobu działania Gutenberg.
Podsumowanie
Myśląc o tym, jak może wyglądać możliwa integracja między GraphQL a Gutenberg (z perspektywy potencjalnego włączenia do rdzenia WordPress), musimy się upewnić, że GraphQL poradzi sobie ze wszystkimi przyszłymi wymaganiami Gutenberg, w tym pełną obsługą:
- wielojęzycznych bloków
- Full Site Editing
- edycji współpracy
- interakcji z usługami firm trzecich na działającej stronie
Wszystko to musi być osiągnięte, mamy nadzieję, bez konieczności zmiany Gutenberg (przynajmniej nie w znaczący sposób) i ograniczając nowe zadania wymagane od deweloperów pluginów.
Biorąc to wszystko pod uwagę, uważam, że 4. podejście, które tutaj sugeruję, może rzeczywiście działać bardzo dobrze.