LoginSignup
30
10

More than 3 years have passed since last update.

Apollo LinkでGraphQLのCustom Directiveを作ろう

Posted at

これは GraphQL アドカレ2019 の記事です。

アドカレを覗いたとき、最終日だけ空いていたので予約しておいたのですが、最終日のエントリとするにはかなりニッチなネタになってしまった気がしないでもないです。

GraphQLのDirectiveとは

知っている人も多いと思いますが、GraphQLのDirectiveというのは文中に @include のように@マークをつけて注釈をつけるための機能です。

query ProfileView($renderingDetailedProfile: Boolean!){
  viewer {
    handle
    # These fields will be included only if the check passes:
    ... @include(if: $renderingDetailedProfile) {
      location
      homepageUrl
    }
  }
}

有名なのはbuilt-in Directiveである @skip@include あたりでしょうか。

Client Custom Directive

GraphQLの仕様上はユーザーが新しいDirectiveを定義して利用することもできるようになっています。Directiveは基本的にはサーバー側で処理するものなので、例えばgraphql-rubyだと GraphQL::Schema::Directive を拡張して実装するようになっていたりします。

といっても、今日はサーバー側ではなくクライアントサイドで動作するCustom Directiveを実装しようというのがテーマです。

クライアントで動作するDirectiveとして割と有名なのは、Apollo Clientにおける @client Directive とかですかね。「このフィールドはlocal stateから引っ張るんだぞ!」という情報を注釈するための代物です。

local state機能そのものは今日の主題じゃないので深くは触れませんが、apollo-client v2.5 がリリースされるまでは、local state機能を使うには apollo-link-state というNPMパッケージを追加しないといけなかったんです。

ということはですよ、v2.4までは @client というDirectiveはApollo Linkで実装されてたということです。これで Client Custom Directiveの実装方法が見えてきましたね。

そうです、Apollo Linkを実装すれば、Apollo Client上で動作するCustom Directiveが作れそうです。

Apollo Linkができること

Apollo Linkは下図のような構造になっています。

link_stack.png

Expressのmiddlewareみたいなものですね。上位のLinkからOperation(Query, Mutation, Subscriptionの総称)を受け取って、呼び出し元に結果のデータを返却するだけです。GraphQLは仕様上、Subscriptionという1リクエストに対して複数レスポンスが降ってくるパターンがあるため、上図の「データ」というのは、実際は Observable<Data> の形式です1

さて、これらを踏まえてApollo Linkの実装を見ていきましょう。下記は何もせずに下層のLinkに処理を委譲するだけのLinkです。

import { ApolloLink, Operation, NextLink } from "apollo-link";

export class MyLink extends ApolloLink {
  request(operation: Operation, forward?: NextLink) {
    if (!forward) {
      return null;
    }
    return forward(operation);
  }
}

上記のコードから、Apollo Linkを実装すれば次のことができる気配を感じますね。

  1. forward に渡す operation をイジって、リクエスト電文をこねくりまわす
  2. forward の結果をイジって、呼び出し元に返却されるStreamをこねくりまわす

1.と2.を組み合わせれば、 例えば「リクエストを適当にバッファに貯めてから一気に下層のlinkに流して、その結果を自分で分割してStreamを返す」みたいなこともできるわけですね。というか、それをやってるのがapollo-link-batchです。

今回作りたいのはCustom Directiveを処理するApollo Linkなので、2.の方はともかくとして、1.の部分が重要です。

operation.query でGraphQLのリクエスト文(query だけど MutationやSubscriptionも含む)にアクセスできるので、ここから自分でDirective情報を引っこ抜く必要があります。また、Custom Directiveが付与されたリクエストをそのままサーバーサイドまで送信したらエラーになるので、実装するLink内でDirective情報を消しておくのも必須ですね。

ASTを使ったOperation Transformation

operation.query の部分ですが、stringではなくAST(抽象構文木)が格納されています。

「最終的なリクエスト電文は結局stringになるのに、なんでASTにしてるの?」と思う人がいるかもなので、一応補足しておくと、そもそもApollo Clientはユーザーが書いたクエリを一部書き換えていて、__typename というフィールドが全てのObject Typeの結果に含まれるように変換しています。これは取得した __typename をcacheのkeyなどで利用するためですね。そういう理由で、Apollo Linkを通す時点で最初からASTにしてくれてるわけですね2

AST変換という意味では、operation の変換部分はTypeScriptのcustom transformerと構造が似ていますね。伝わりにくい例えなのは自分でも承知してますけど、ここからの話はAST Transformationの話なので、TypeScriptとかBabelで静的なコード変換の経験があると、イメージ湧きやすいと思ってます。

AST変換そのものの処理になると、最早Apollo Linkと関係なくGraphQLの参照実装である graphql NPM パッケージの関数だけで事足りてしまいます。

import { visit, DocumentNode } "graphql/language";

function transform(input: DocumentNode) {
  return visit(input, {
    // visitorの処理
  });
}

visit 関数の第2引数には、以下の構造を持つオブジェクトを与えることで処理を定義します。このインターフェイスもBabel Pluginと似ていますね。

{
  [NodeKind] /* 何かしらのGraphQL AST Node種別名 */ : {
    enter(node) {
      // enterはその種別のNodeにvisitorが到達した時点で動く処理
      //
      // 1. undefined を返すと何も変更はない
      // 2. null を返すとそのNodeが消滅
      // 3. 別のAST Nodeを返すとそのNodeでupdate
    },
    leave(node) {
      // leaveはその種別のNodeの下層Nodeを全て処理し終わった後に動く処理
      //
      // 1. undefined を返すと何も変更はない
      // 2. null を返すとそのNodeが消滅
      // 3. 別のAST Nodeを返すとそのNodeでupdate
    },
  },
}

簡単なサンプルとして「フィールドに @nyaan というDirectiveがある場合にコンソールに にゃーん: <フィールド名> を出力する」というのをAST変換で書いてみました。ついでに末尾にはApollo Linkとして呼び出す部分も書いてあります。

import { visit, DocumentNode } from "graphql/language";
import { ApolloLink, Operation, NextLink } from "apollo-link";

function transform(input: DocumentNode) {
  return visit(input, {
    Field: {
      enter(node) {
        if (node.directives) {
          if (node.directives.find(d => d.name.value === "nyaan")) {
            const fieldName = node.name.value;
            console.log(`にゃーん: ${fieldName}`);
          }
        }
      },
    },
    Directive: {
      leave(node) {
        if (node.name.value === "nyaan") {
          return null;
        }
      }
    },
  });
}

export class NyaanLink extends ApolloLink {
  request(operation: Operation, forward?: NextLink) {
    if (!forward) {
      return null;
    }
    operation.query = transform(operation.query);
    return forward(operation);
  }
}

ここでのAST変換処理は以下になっていますね。

  • Field Nodeを見つけた時点で対象のDirectiveがあるかどうか見つけて処理
  • Directive Node を出るときに、対象のDirectiveであればASTから削除

ちなみに、graphql NPMパッケージが取り扱うASTは、Nodeにparent情報を保持していないため、この例のように「〇〇の種別がもつDirective」に限定して処理をする場合は、 Directive をキーとするのではなく、「そのDirectiveの親となるNode Kind」をキーにして処理する必要があります。

TypeScriptを使っている場合、@types/graphql が良くできているため、エディタ上でのチェックや補完もいい感じに動きます。また、GraphQLのAST構造は AST Explorer で可視化できるので、AST変換処理を実装するときは、横で開きながら確認するとよいです。

Image from Gyazo

作ったもの

さすがにコンソールに「にゃーん」と出力する例で終わらせるとアホくさいので、もう少し真面目なApollo Linkを作ってNPMにpublishしてみました。

apollo-link-fragment-argument

「Fragmentに閉じた引数を定義するためのDirectiveを提供する」Apollo Linkです。

何でこんなものを作ったかというと、以前に別のエントリとして https://qiita.com/Quramy/items/1f9431b42d95ebdc59a8 に書いたことがあるのですが、現状のGraphQLの仕様上ではFragment Scopedな変数を定義する方法がないため「親Fragmentから子Fragmentに変数を渡す」ができません。

参照先のエントリに詳細は書いたので、軽く流しますが、僕は個人的に「FragmentはUI Componentとセットで管理する」というアプローチ(Colocating Fragments)が好きなのですが、そのためにはFragments Scoped Variableが欲しくなるんです。

この問題をCustom Directiveで解決するというのは、別に僕が考えたわけではなく、Relay Modernが同じアプローチで解決を図っていたため、パクってApollo版にしてみました3。作ってから気づいたんですけど、Apolloのfeature request にも同様のテーマが挙がってました。

利用イメージとしては、下記のように @argumentDefinitions@arguments を使います(ちなみにこのサンプルもRelayと一緒です)。

const todoListFragment = gql`
  fragment TodoList_list on TodoList
    @argumentDefinitions(
      count: { type: "Int", defaultValue: 10 } # Optional argument
      userID: { type: "ID" } # Required argument
    ) {
    title
    todoItems(userID: $userID, first: $count) {
      # Use fragment arguments here as variables
      ...TodoItem_item
    }
  }
`;
const query = gql`
  query TodoListQuery($count: Int, $userID: ID) {
    ...TodoList_list @arguments(count: $count, userID: $userID) # Pass arguments here
  }
  ${todoListFragment}
`;

このクエリの場合、Apollo Linkには以下のような operation.query が降ってきます。

inputとなるoperation.query
 fragment TodoList_list on TodoList @argumentDefinitions(
   count: {type: "Int", defaultValue: 10},  # Optional argument
   userID: {type: "ID"},                    # Required argument
 ) {
   title
   todoItems(userID: $userID, first: $count) {  # Use fragment arguments here as variables
     ...TodoItem_item
   }
 }
 query TodoListQuery($count: Int, $userID: ID) {
   ...TodoList_list @arguments(count: $count, userID: $userID) # Pass arguments here
 }

このクエリの @arguments で指定された変数をstaticに解決するようにASTを変換すると、次のようなクエリになるわけですね。

transform後のoperation.query
fragment TodoList_list on TodoList {
  title
  todoItems(userID: $userID, first: $count) {
    ...TodoItem_item
  }
}

query TodoListQuery($count: Int, $userID: ID) {
  ...TodoList_list
}

今回作ったApollo Linkは純粋にAST変換だけで実装しているので、結果側のStream処理は一切行ってないですが、例えば「特定のDirectiveが付与されたフィールドはlocal storageから持ってきて一瞬で返却する」みたいなちょっと複雑なcacheみたいなものを実装するのであれば、結果値のStream処理との合せ技になりそうですね。

おわりに

このエントリではApollo ClientでCustom Directiveを実装する方法について書いてきました。

まぁそこまでヘビーに使うようなユースケースもあまり思い浮かばいのでアレですが、Client Custom Directiveの懸念として「SchemaにDirectiveが定義されているわけではない」というのがあります。GraphQLのクライアントサイド開発って、なんだかんだ「schemaの情報とクエリの情報を突き合わせてゴニョゴニョ」から逃げられないんですよね。分かりやすいところだと、クエリの結果型定義自動生成とかそういう系統のやつです。

サーバーから提供されるSchema定義には、当たり前ですが「Client Custom Directiveが利用可能」という情報はどこにも書いてないので、この辺のツールを組み合わせたときに「自分で定義したDirectiveのせいで別のツールでValidation Errorが起きる」という事態にぶつかりそうな予感もあります。

この辺りの、他のstaticなツールとの親和性などについては今後調べてまた何かの機会にまとめようと思います。


  1. Observable実装はrxjsではなくてzen-observableというApolloが実装している軽量Observableです。複雑なストリーム処理をしたい場合はRxのObservableに変換しちゃうのもありかも。 

  2. gql のTagged Template Functionがかかっていれば、その結果はASTですし、graphql-loaderとかを使うと、string -> ASTのparse処理はビルド時に実行することもできます。 

  3. https://relay.dev/docs/en/fragment-container.html#composing-fragments 

30
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
10