フィールド引数とディレクティブの比較
GraphQLでフィールドの出力を変更するための同じ機能は、2つの異なる方法で実現できることがよくあります。
- フィールド引数:
field(arg: value) - クエリタイプのディレクティブ:
field @directive
(クエリタイプのディレクティブとは、クライアントサイドのクエリに適用されるものです。これはサーバー上でスキーマを構築する際にSDL(Schema Definition Language)を通じて適用されるスキーマタイプのディレクティブとは対照的です。Gato GraphQLはSDLではなくPHPコードからスキーマを作成するため、すべてのディレクティブはクエリタイプであり、単に「ディレクティブ」と呼ばれます。)
例えば、titleフィールドのレスポンスを大文字に変換するには、UPPERCASEというenum値を持つフィールド引数formatを渡すことで実現できます。
{
posts {
title(format: UPPERCASE)
}
}または、フィールドに@strUpperCaseディレクティブを適用することでも実現できます。
{
posts {
title @strUpperCase
}
}どちらの場合も、GraphQLサーバーからのレスポンスは同じになります。
{
"data": {
"posts": [
{
"title": "HELLO WORLD!"
},
{
"title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
}
]
}
}フィールド引数とクエリサイドのディレクティブはそれぞれいつ使うべきでしょうか?2つの方法に違いはあるのか、またはどちらか一方が優れている状況はあるのでしょうか?
フィールド引数とディレクティブが得意なこと
GraphQLでフィールドを解決するには、2つの異なる操作が伴います。
- クエリされたエンティティからリクエストされたデータを取得する
- 取得したデータに機能(フォーマットなど)を適用する
これら2つの操作を「データ解決」と「機能の適用」、または略して「データ」と「機能」とラベル付けすることができます。
フィールド引数とディレクティブの主な違いは、フィールド引数は「データ」と「機能」の両方に使用できますが、ディレクティブは「機能」にのみ使用できるという点です。
これが何を意味するのか、もう少し詳しく見ていきましょう。
フィールド引数によるデータの解決
フィールド引数はフィールドを解決する際に処理されるため、オブジェクトのどのプロパティにアクセスするかを決めるなど、実際のデータを取得するために使用できます。
例えば、次のリゾルバーコードは、引数sizeを使用してMediaオブジェクトタイプから一方または他方の画像ソースを取得する方法を示しています。
function resolveValue(
object $mediaObject,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'src') {
$size = $fieldDataAccessor->getValue('size');
return $this->getMediaTypeAPI()->getImageSrc($mediaObject, $size);
}
// ...
}フィールド引数は、DBテーブルのどの行または列をクエリする必要があるかを決定するためにも使用できます。
このクエリでは、フィールド引数idを使用してPost型の特定のエンティティをクエリしており、リゾルバーはそれをWordPressのwp_posts DBテーブルの特定の行に変換します。
{
post(by: { id: 1 }) {
title
}
}同じテーブルには投稿の日付がpost_modifiedとpost_modified_gmtという2つの異なるカラムに格納されています(後方互換性の理由から)。このクエリでは、フィールド引数gmtにtrueまたはfalseを渡すことで、どちらかのカラムの値を取得することになります。
{
post(by: { id: 1 }) {
title
date(gmt: true)
}
}これらの例は、フィールド引数がフィールドを解決する際のデータのソースを変更できることを示しています。
ディレクティブはデータのソースを変更するために使用できません。なぜなら、ディレクティブのロジックはフィールドリゾルバーの後に呼び出されるディレクティブリゾルバーによって提供されるからです。そのため、ディレクティブが適用される時点では、フィールドの値はすでに取得されていなければなりません。
例えば、このクエリは決して機能しません。
{
post @selectEntity(id: 1) {
title
}
}この例では、フィールドpostにはエンティティのidを提供する必要がありますが、フィールド引数として提供されていないため、サーバーはエラーを返します。
{
"errors": [
{
"message": "Argument 'id' cannot be empty",
"extensions": {
"type": "QueryRoot",
"field": "post @selectEntity(id:1)"
}
}
]
}結論として、フィールドを解決するデータの取得を助けることができるのはフィールド引数だけです。
フィールド引数またはディレクティブによる機能の適用
フィールドのデータを取得したら、その値を操作したい場合があります。例えば、次のようなことができます。
- 文字列をフォーマットして大文字または小文字に変換する
- 文字列で表された日付を、デフォルトの
YYYY-mm-dd形式からdd/mm/YYYY形式にフォーマットする - 文字列をマスクして、メールアドレスや電話番号を
***で置き換える - 値が
nullまたは空の場合にデフォルト値を提供する - 浮動小数点数を小数点以下2桁に丸める
これらの操作はいずれも、すでに取得されたデータに対する操作です。そのため、フィールドリゾルバー内(データ取得直後で返す前)でも、フィールドの値を入力として受け取るディレクティブリゾルバーでもコーディングできます。したがって、これらの操作はいずれもフィールド引数またはディレクティブで実装できます。
例えば、Post.excerptのフィールドリゾルバーはフィールド引数defaultでデフォルト値を提供でき、クエリでdefault引数の値をカスタマイズできます。
{
posts {
excerpt(default: "(No excerpt)")
}
}@defaultディレクティブを作成し、次のようなディレクティブリゾルバーを使用することもできます。
/**
* 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;
}
}
}
}これら2つの戦略は同等に適切でしょうか?さまざまな観点からこの問いを探ってみましょう。
フィールド引数はGraphQL仕様でより十分にカバーされている
ディレクティブが操作を許可される範囲はGraphQL仕様で明確に定義されておらず、仕様には次のように記されています。
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.
この定義は、条件に応じてフィールドをインクルードまたはスキップする@includeや@skip、サーバーからのデータ取得に異なるランタイム実行を提供する@streamや@deferなどのディレクティブの使用を認めています。
しかし、この定義は、出力値"Hello world!"を"HELLO WORLD!"に変換する@strUpperCaseのような、フィールドの値を変更するディレクティブについては明確ではありません。
この曖昧さにより、異なるGraphQLサーバー、クライアント、ツールがディレクティブを異なる程度で考慮し、それらの間で競合が生じる可能性があります。
その例としてRelayがあり、フィールド値のキャッシュにディレクティブを考慮しません。最初に次のクエリを実行すると:
{
post(by: { id: 1 }) {
title
}
}...Relayは ID 1の投稿に対して"Hello world!"という値をクエリしてキャッシュします。次にこのクエリを実行すると:
{
post(by: { id: 1 }) {
title @strUpperCase
}
}...レスポンスは"HELLO WORLD!"になるべきですが、Relayはフィールドに適用されたディレクティブを無視して、ID 1の投稿のキャッシュに格納された値"Hello world!"を返します。
ディレクティブがフィールドの出力値を変更することが許可されるかどうかはグレーゾーンにあります。GraphQL仕様では明示的に許可も禁止もされていませんが、両方の状況に対するインジケーターが存在します。
一方では、GraphQL仕様はディレクティブに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.
他方、仕様はFieldsInSetCanMergeバリデーションやCollectFieldsアルゴリズムでディレクティブを考慮していません。次のGraphQLクエリは有効ですが、ユーザーがどんなレスポンスを得るかは不確かです。
{
user(by: { id: 1 }) {
name
name @strUpperCase
name @strLowerCase
}
}GraphQLサーバーの動作によって、フィールドnameのレスポンスは"Leo"、"LEO"、または"leo"になる可能性があります…事前にはわからず、それが問題です。
フィールド引数では同じ問題は発生しません。次のクエリが実行されると:
{
user(by: { id: 1 }) {
name
name(format: UPPERCASE)
name(format: LOWERCASE)
}
}...仕様はGraphQLサーバーにエラーを返すよう指示するため、nameの値はnullになります。クエリを実行するためにエイリアスを導入することを余儀なくされます。
{
user(by: { id: 1 }) {
name
ucName: name(format: UPPERCASE)
lcName: name(format: LOWERCASE)
}
}ディレクティブはモジュール性とコードの再利用性に優れている
ディレクティブが提供する多くの操作は、適用されるエンティティやフィールドに依存しません。例えば、@strUpperCaseは、投稿のタイトル、ユーザーの名前、場所の住所など、あらゆる文字列に対して機能します。
その結果、このディレクティブのコードはディレクティブリゾルバーという1か所にのみ実装されます。アスペクト指向プログラミング(横断的関心事の分離を可能にすることでモジュール性を高める)と同様に、ディレクティブはフィールドのロジックに影響を与えることなくフィールドに適用されます。
対照的に、フィールド引数を介して同じ機能を実装すると、フィールドリゾルバー全体(および異なるフィールドリゾルバー全体)で同じコードを実行することになります。
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);
}
// ...
}リゾルバーのコード量を減らすためには、フィールド引数よりもディレクティブの方が適切です。
ディレクティブはスキーマ設計に優れている
フィールド引数を追加すると、スキーマに余分な情報が追加され、スキーマが肥大化して一貫性が失われる可能性があります。
例えば、フィールド引数formatはすべてのStringフィールドに追加する必要があり、注意しないと、異なる名前、異なる値、異なるデフォルト値を使用したり、引数を複数の入力に分割したりと、フィールド間で均一でない場合があります。
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
}ディレクティブを使用すると、スキーマをできる限りスリムに保つことができます。
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
}ディレクティブはフィールド引数より効率的な場合がある
実行時には、フィールド引数はフィールドを解決する際にアクセスされます。これはフィールドごと、オブジェクトごとに行われます。例えば、投稿のリストでtitleとcontentフィールドを解決する場合、リゾルバーは投稿とフィールドの組み合わせごとに1回呼び出されます。
function resolveValue(
object $post,
FieldDataAccessorInterface $fieldDataAccessor,
): mixed {
if ($fieldDataAccessor->getFieldName() === 'title') {
return $post->post_title;
}
if ($fieldDataAccessor->getFieldName() === 'content') {
return $post->post_content;
}
// ...
}Google Translate APIを使用してこれらの文字列を翻訳したいとします。そのために引数translateToを追加します。
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);
}
// ...
}ロジックはフィールドとオブジェクトの組み合わせごとに自然に実行されるため、外部APIへの接続が大量に発生し、クエリの解決に時間がかかるレスポンスになる可能性があります。
また、各呼び出しを独立して実行すると、それらのデータを関連付けることができないため、すべてのデータを単一のAPI呼び出しで送信した場合よりも翻訳の品質が低下します。
例えば、投稿タイトル"Power"は、この単語が「電力」を指すことを明確にする投稿コンテンツと一緒に送信されると、より適切に翻訳できます。
Gato GraphQLはディレクティブを1回だけ呼び出し、適用するすべてのフィールドとオブジェクトを入力として渡します。すべてのデータをまとめて受け取ることで、@strTranslateディレクティブはすべてのオブジェクトのすべてのtitleとcontentフィールドを渡して、Google Translateへの単一の呼び出しを実行できます。次のクエリのように:
{
posts(pagination: { limit: 6 }) {
title @strTranslate(from: "en", to: "fr")
excerpt @strTranslate(from: "en", to: "fr")
}
}ディレクティブは、外部APIとのやり取りなど、フィールドの値を変更するより効率的な方法を提供できます。