💬 「GutenbergとDecoupledアプリケーション」に対する新しいアプローチの提案
数日前、WPGraphQLの作者であるJason BahlがGutenberg and Decoupled Applicationsを公開し、GraphQLとGutenbergを統合するための3つのアプローチのメリットと課題を分析しました。
その1週間前、彼はTwitterでも述べていました。Gato GraphQLのGutenbergモデリングアプローチは不適切だと:
私の意見では、これは自慢すべきことではありません。GraphQLが型付きスキーマで解決しようとすることの一つは、クライアントに予測可能性と一貫性を提供し、フィールドレベルまで望むものを要求する制御をクライアントに与えることです。
予測可能な形を持たないワイルドカードの「Object」型を返すということは、サーバーとクライアントの間にもはや契約がないため、クライアントアプリケーションはいつでも壊れる可能性があることを意味します。サーバーはクライアントから制御を奪ってしまっています。
この記事を通じて、私もこの会話に参加します。Jasonの批判に向き合い、そうすることで私のプラグインのアプローチを説明し、それがGutenbergに実際にとてもよく合うと考える理由をお見せします。
COPEを使ったGutenbergメタデータの抽出
私のソリューションは4番目のアプローチと考えられるかもしれません。内容は以下の通りです:
GraphQLを動かすためのGutenbergデータを取得するにあたり、PHP側に追加スキーマを作成したり、既存のデータを複製したりしません。代わりに、COPE(「Create Once, Publish Everywhere」)戦略を使って、ブロックに保存されたコンテンツからデータを抽出します。
(COPEは、コンテンツの単一の信頼できる情報源を持ち、それをさまざまなアプリケーションに公開できる戦略です。私たちのケースでは、単一の信頼できる情報源はデータベースに保存されているGutenbergブロックのデータです。COPEとWordPressへの実装については、この記事で解説しました。)
最終的に、GraphQLを使ってすべてのブロックを単一のBlock型にマッピングすることで、任意のGutenbergブロックの抽出データを取得できます。
この戦略はトレードオフであり、決定的な解決策ではない
この戦略は、Jasonが指摘している問題、すなわちサーバー側にスキーマがないためサーバーとクライアントの間の契約を作れないという問題を解決するものではありません。
COPEがこの問題を解決できないのは、保存されたコンテンツだけからはスキーマを再構築できないからです:
- 保存されたコンテンツはフィールドの型を示さない
- 保存されたコンテンツはフィールドの制約(nullableか?正の整数か?文字列はメールアドレスかURLか?)を示さない
- nullableフィールドはデフォルト値を持つことがあるが、それは保存されたコンテンツには存在しない
しかし、COPE戦略と単一のBlock型を使ってすべてのブロックを表現することで、Gato GraphQLは既存の制限を克服した、Gutenbergとの非常に優れた統合を構築できます。
この記事を通じて説明していきます。
Gato GraphQLとGutenbergの統合
このソリューションは進行中の作業ですが、どのように動作するかはすでに説明できます。
ブロックごとに異なる型に依存する(WPGraphQLがWPGraphQL for Gutenbergプラグインに依存する際に行う)代わりに、Gato GraphQLはすべてのブロックを表現する単一のBlock型を提供します。
このクエリでは、フィールドPost.blockDataItemsが投稿からBlock要素のリストを取得します(段落、画像、リストなど、さまざまなGutenbergブロックを含む):
{
post(by: { id: 1499 }) {
title
blockDataItems
}
}特定のブロックのデータを取得したい場合は、ブロックの名前(core/paragraph、core/quoteなど)でフィルタリングできます。
このクエリでは、画像ブロックのみを取得します:
{
post(by: { id: 1177 }) {
title
blockDataItems(
filterBy: { include: "core/image" }
)
}
}単一のBlock型の検査
このアプローチでは、レスポンスはスキーマではなく保存されたコンテンツによって異なります。この特性はその利点(APIを柔軟にする)と欠点(サーバー・クライアント間の契約を強制できない)の両方です。
すべてのBlock要素は2つのプロパティを持ちます:
name:ブロックの名前(core/paragraph、core/quoteなど)meta:ブロックに含まれるメタデータ
各Gutenbergブロックは異なり、異なるデータを含んでいます(段落コンテンツ、YouTubeビデオ、画像ソースURLと寸法など)。そのため、metaフィールドのレスポンスに含まれるデータも異なります。
そのため、metaフィールドはGraphQLスキーマ内の対応するJSONObject型を通じて、単純にJSONオブジェクト(「生の」データを含むことができる)としてマッピングされています。
このレスポンスが生成されます:
{
"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"
]
}
},
]
}
}
}見てわかるように、異なるブロックが異なるプロパティを取得しています:
core/paragraphはプロパティcontentを持つcore/imageはプロパティsrcを持ち、オプションでプロパティwidth、height、captionを持つ(上記のレスポンスには現れない)core/quoteはプロパティquoteとcite(引用された人物のため)を持つcore/headingはプロパティheaderとsizeを持つ(値xlは<h2>を表す。COPEは値をターゲットアプリケーション、この場合はウェブサイトから切り離すため)core/listはプロパティitemsを持ち、要素のリストである
JSONObject型がスペックに含まれない理由
上記で説明したJSONObject型により、GraphQLは「動的な」フィールド(知らないフィールドなど)、または複数の構成を持てるフィールド(Gutenbergブロックの場合がそれにあたる)を取得できます。
現在、GraphQLスペックはJSONObject型やMap型をサポートしていません。サポートの追加が要求されています。その理由として次のような声があります:
[...] この機能の欠如は特に問題です。GraphQLがインターフェースする多くの型システムやサービスでサポートされているからです。
これにより、サーバーがMapを送信し、クライアントがMapを望んでいるのに、GraphQLがMapのサポートなしに間に入っている状況に対処するために、サーバーにカスタムリゾルバーを実装し、クライアントにカスタム変換を実装することになります。はい、それは可能ですし、私はそれを行いましたが、__GraphQLでAPIスペックを書く__目的を損なうかなりのボイラープレートと抽象化が必要です。
この機能がスペックでサポートされていないのは、動的フィールドを扱うことがGraphQLの強い型付け動作に反し、サーバーとクライアント間の契約を壊すからです。
それでも、この型はGutenbergにとって有益になりえます。後で示します。
ブロックごとに異なる型とサーバー側レジストリを使う場合の問題
ブロックごとに新しいGraphQL型を作成する場合、すべてのプラグインがそのブロックをGraphQLスキーマに追加する必要があります。これは、すべてのブロックが提案されている新しいサーバー側レジストリにプロパティを定義することで自動的に実現できる可能性があります。
そうしない場合、そのブロックはAPIで利用できなくなり、これはさらなる結果をもたらす可能性があります。状況によっては、クエリされた投稿のコンテンツ全体が信頼できなくなることがあります。
これは、GraphQLが外部のクラウドベースのサービスと連携し、投稿内のすべてのブロックに何らかの機能を適用する場合(翻訳、文法修正、SEOの提案、アナリティクスなどを考えてみてください)に起こりえます。
これの例を見てみましょう。
Gutenbergのフェーズ4で多言語機能が追加される予定なので、@strTranslateディレクティブを通じて実行されるGoogle Translate APIの呼び出しにより、プラグイン内のすべてのブロックを翻訳する方法をモデル化してみましょう。
(この初期のAPIベースの翻訳の後、ユーザーはWordPressエディター内で常に翻訳された言語でブログ投稿を編集し続けることができます。)
異なるブロックには翻訳が必要な異なる情報が含まれています:
core/paragraph:テキストcore/image:キャプションcore/quote:引用文、および引用された人物(「校長先生」などの肩書きである可能性があるため)core/heading:見出しcore/list:リスト内のすべての項目
ブロックごとに異なる型を使った場合、結果のクエリは次のようになるかもしれません:
{
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
}
}
}
}以下同様に続きます。ブロックが多くなるほど、このクエリは長くなり、容易に100行以上に及びます。
明らかな問題は、クエリが保守しなければならない手に負えない代物になることです。
また、すべてのブロックで機能させるためにカスタム機能を導入する必要があります。例えば、@strTranslateはCoreListBlock.itemsに対しては機能しません。これは文字列のリスト(つまり[String]を返す)であり、ディレクティブはStringを期待するためです。そのため、@strTranslateListを作成しなければなりません。
そしてcore/tableには独自のカスタムディレクティブ(@strTranslateTable?)が必要になるでしょう。
サードパーティのカスタムブロックも独自のカスタムディレクティブが必要かもしれません。
さらに、もう2つの問題も見えてきます。
すべてか無か
ブログ投稿にはWordPressエディターにインストールされた任意のブロックが含まれる可能性があります。そして、クエリをコーディングする際に、投稿がどのブロックを使用するかを事前に知ることはできません。
そうなると、ブロックごとに1つの型という場合、クエリで処理する型の数は投稿内のブロック数と同等にはなりません。代わりに、WordPressエディターにインストールされたブロックの数と同等になります。
WordPressコアとプラグイン両方を含めて、サイトに100個のブロックがある場合はどうなるでしょうか?そうなるとGraphQLスキーマにマッピングされた100個の型が必要になります。マッピングされていない1つだけで「コンテンツ契約」が壊れ、一部のブロックは英語からフランス語に翻訳され、他のブロックは英語のままになります。
その結果、問題のあるブロックを含むかどうかにかかわらず、翻訳された投稿を信頼できなくなります。すべてのブロックがレジストリに追加されていない場合、アプリケーションが信頼できなくなる可能性があります。
新しいブロックがインストールされるたびにクエリを更新しなければならない
同様に、すべてのブロックはGraphQLクエリで処理される必要があります。つまり、新しいブロックをインストールするたびに、アプリケーションのコードに戻り、更新し、再デプロイする必要があります。
これは単なる余分な官僚的手続きではありません:すべてのクエリが更新されるまでアプリケーションを壊す恐れなしに、本番サイトにブロックをインストールすることができなくなります。
GraphQLはWordPressに仕えるべきであり、その逆ではない
JSONObjectがGraphQLスペックに追加されなかった理由を改めて考えると、それはGraphQLのやり方に合わないからです。
しかし、ここで私たちが本当に関心を持っているのはGraphQLではありません。私たちが気にするのはWordPressだけであり、このケースではより具体的にGutenbergです。
GraphQLをGutenbergと統合する際、GraphQLはWordPressのコンテキスト内で動作します。つまり、WordPressはGraphQLの要件を満たす必要があります。しかしより重要なのは、GraphQLがWordPressの要件を満たす必要があるということです。
そして、競合が生じた場合は、WordPressが優先されます。
ある機能がGraphQLに合わなくても、Gutenbergには合う場合、それは検討すべきでしょうか?
私はそうすべきだと思います。
単一のBlock型がどのようにGutenbergにより良く仕えられるかを見てみましょう。
単一のBlock型で以前の問題を解決する
前の例に従い、単一のBlock型を使って投稿内のすべてのブロックを英語からフランス語に翻訳する場合、次のように行います(またはこの概念に近いもの):
{
post(by: { id: 1 }) {
blocks {
name
meta
@advancePointersInArray(paths: "{{ translatablePaths }}")
@underEachArrayItem
@strTranslate(from: "en", to: "fr")
}
}
}それだけですか?クエリ全体が?すべてのブロックを翻訳するために?はい。
コアとプラグイン両方の、既存または将来作成されるすべてのブロックに対して機能しますか?はい。
このクエリが少し奇妙に見えますか?そうであれば、それはGato GraphQLのみがサポートする非標準のGraphQL機能を使用しているからです:
{{ translatablePaths }}は埋め込み可能なフィールドであり、フィールドの値を別のフィールドやディレクティブへの引数として入力するためのものです(この場合、Block型はフィールドtranslatableFieldsを持ち、その値はディレクティブ@advancePointersInArrayに注入されます)- ディレクティブは他のディレクティブによって構成できます
ある機能がCMSが必要とするものを正確に満たすが、その機能が非標準である場合、それでも使うべきでしょうか?私はそうすべきだと思います。
これらの機能についてGraphQLスペックにも要望を出しました(受け入れられないでしょうが):
単一のBlock型の仕組み
注意:技術的なセクションが続きます。
Block型はフィールドtranslatablePathsを持ち、翻訳が必要なJSONObjectのプロパティの配列を返します:
core/paragraphは["content"]を返すcore/imageは["caption"]を返すcore/quoteは["quote", "cite"]を返すcore/headingは["header"]を返すcore/listは["items.0", "items.1", "items.2", ...]を返す
@advancePointersInArrayはメタディレクティブです:後続のディレクティブのコンテキストを変更します。後続のディレクティブがクエリされたJSONObject内のサブ要素(段落ブロックのcontentプロパティなど)を受け取るようにします。パスのリストは、同じクエリされたエンティティに対して評価されたフィールドtranslatablePathsを通じて取得されます。
次に、@underEachArrayItemは別のメタディレクティブであり、クエリされたエンティティから要素のリストを反復処理し、反復された要素への参照を次のディレクティブに渡します。この場合、すべてのエンティティの翻訳するプロパティのリストをすべて取得し、各プロパティはString型で、個々のString要素を後続に渡します。
最後に、ディレクティブ@strTranslateはJSONObject内に含まれるString型の要素を受け取り、JSONObject自体の中でその場で翻訳します。
このソリューションがいかに柔軟であるかに注目してください。JSONObject内の文字列へのパスを提供するだけで、値にアクセスし、@strTranslate(または他のディレクティブ)で変更し、さらにはDBに値を再保存することもできます(これを実現する作業は現在進行中です)。
core/listではすでに機能します。リスト内のすべての要素は独自のパス(items.0が配列の最初の要素など)でアクセスでき、各要素からString値にアクセスし、それを@strTranslateに渡せるため、@strTranslateListを作成する必要がありません。
同様に、core/tableでも機能します。プロパティcellsを通じてデータを公開するだけです。これは2次元配列(行の配列、列の配列を含む)になります。そうすれば、translatablePathsは["cells.0.0", "cells.0.1", "cells.1.0", ...]としてすべての要素にアクセスできます。
サードパーティのブロックにも機能します。そのためには、ブロックデータがどのように保存されているかに注意を払い、そこからプロパティへのパスを推測できます。
単一のBlockはPHPコードに基づいた設定が必要
ブロックをマッピングし、メタデータプロパティの場所を把握することは、設定を通じて実現できます。これにより、非常に柔軟な方法で対処できます。
Gutenbergでは、ブロックのプロパティを保存できる場所が2つあります:属性として、またはレンダリングされたコンテンツの内部にです。
例えば、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 -->この場合、次のようになっています:
- プロパティ
id、sizeSlug、linkDestinationは属性として保存されている - プロパティ
srcはレンダリングされたコンテンツ内に保存されている
さて、APIをクエリした場合、core/imageブロックのレスポンスは次のようになります:
{
"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はGutenbergに保存されたブロックを解析することでプロパティを取得する方法を知っています(それがCOPE戦略です)。このプロセスはある程度自動的に行えますが、その後フックを通じた手動入力やユーザーインターフェースが必要です。
属性として直接マッピングされたプロパティを取得することは簡単です。GraphQLサーバーはすでにブロックからすべての属性を取得し、プロパティとして利用可能にすることができます。または、公開するものを明示的に定義したい場合は、フィルターフックを通じて行うことができます:
$attrs = apply_filters("blockPropsAsAttr:core/image", []);
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})コンテンツに保存されたプロパティはregexで抽出できます:
$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
$propRegexes['src'] = '/<img src="(.*?)"/';
return $propRegexes;
})最後に、@strTranslateが作用するためのブロックの翻訳可能なプロパティを指定します:
$propRegexes = apply_filters("translatableProperties:core/image", []);
add_filter("translatableProperties:core/image", function ($properties) {
$properties[] = 'caption';
return $properties;
})さて、これらのプロパティはやはり誰かが満たす必要があり、おそらくプラグイン開発者がその役を担います。そのため、サーバー側レジストリを持つことがこの目標達成に役立ちます。
しかし、WordPressコミュニティが提案されているサーバー側レジストリを追加したくない場合はどうでしょうか?この戦略は容易に適応できます。マッピングはPHPコードで行えるからです(上記で示した通り)。
ブロックがマッピングされていない場合、ユーザーはGutenbergについて少し知っているだけで(GraphQLやスキーマについての知識は不要)、自分でマッピングを行うことができます。
さらに、マッピングされていないブロック(翻訳できないブロック)がある場合にGraphQLがユーザーにアラートを出すようにすることもできます。これは、条件が成立した場合に@sendEmailディレクティブを実行する@ifメタディレクティブを追加することで実現できます:
{
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"
)
}
}
}このソリューションは柔軟でシンプルであり、開発者が新しい技術を学んだりGutenbergの仕組みを変更することなく、GraphQLがWordPressに仕えることができます。
まとめ
GraphQLとGutenbergの統合がどのようになるか(WordPressコアへの潜在的な組み込みから)を考える際には、GraphQLがGutenbergの将来の要件をすべて処理できることを確認しなければなりません。完全なサポートが必要なものには以下が含まれます:
- 多言語ブロック
- Full Site Editing
- 協調編集
- 本番サイト上でサードパーティサービスとの連携
これらはすべて、できればGutenbergを(少なくとも大幅には)変更することなく、プラグイン開発者に必要な新たな作業を減らしながら実現しなければなりません。
これらを踏まえると、私がここで提案している4番目のアプローチは、確かに非常にうまく機能できると信じています。