🌊

urql コードリーディング

2021/06/22に公開

こんにちは

どうも、僕です。
今回は urql という GraphQL Client のライブラリのコードリーディングをしたいと思います。

https://github.com/FormidableLabs/urql

ではやっていきます。

urql とは

urql とは、Formidable によって開発されている GraphQL Client です。
メインのメンテナーは Phil Pluckthun 氏 が行っています。僕がこのライブラリを知ったきっかけは、preact のコアコミッターの Jovi 氏 が urql のコアコミッターでもあることがきっかけです。

特徴

urqlでは、リクエストを operation という単位で扱います。この operation のライフサイクルは、リクエストが投げられてから返ってくるまでの流れを stream として扱います。
また、exchange という拡張可能なミドルウェアのような役割を果たすものもあり、client 作成時に引数として渡すことができます(babel プラグインや Express の middleware みたいなものを想像してもらうとわかりやすいかも)

また、キャッシュの仕組みも非常にシンプルで、document caching を採用しています。すべてのquery と variable の組み合わせはハッシュとして保持され、レスポンスと共にキャッシュされるため、再度同じリクエうとが投げられるとリクエストは投げられずキャッシュされます。

document caching では、キャッシュに残ってる query に対しての mutation を実行した時、値が更新されると仮定して現在の query のキャッシュを無効化します。
query と mutation の紐付けは __typename というプロパティにクエリセットを追加することで追加の型情報を保存することができます。
つまり、ブログの投稿があって、それに対していいねをするようなリクエストを投げる mutation があったとき、__typename を同じにすることでその記事がいいねされたときにもともとのいいね数のキャッシュはクリアされ、mutation のレスポンスとして得られた値をキャッシュとして残すというようなことができるようになります。

また、キャッシュにはリクエストポリシーを設定することで自在に操ることができるようになっており、urql ではキャッシュの戦略として以下の4つが定められています。

  • cache-first
    • デフォルト
    • キャッシュがあればキャッシュを返す
  • cache-only
    • 常にキャッシュを返す
    • リクエストは投げない
  • network-only
    • cache-only の逆
    • 常にリクエストを投げる
  • cache-and-network
    • キャッシュがあればキャッシュを返す
    • その後リクエストを投げる

詳しくは僕のメモ帳(技術メモ | urql のキャッシュについて)にまとめたので気になる人は見てください。
https://dev.takurinton.com/tech/graphql/graphcache.html

urql の全体感

urql の全体像としては、core パッケージがあり、それに対して各ライブラリ(React、Vue など)のラッパーが用意されている感じなので、まずは core パッケージを見ると良さそうです。
core パッケージは /packages/core/ にあります。

core パッケージで起点となるのは、/packages/core/src/client.ts でクライアントを作成し、そのインスタンスにメソッドチェーンとして色々つなげていく感じなので、まずは /src/client.ts を見ると全体像を掴むことができそうです。

また、全ての型定義は /packages/core/types.ts で定義されており、そこを見ると大体何をしているのかが見えてきます。

/packages/core/src/utils/ には主にキャッシュやエラー、リクエストを捌く部分などの基本的なメソッドが入っており、ここを中心にライブラリ全体は動作します。

/packages/core/src/internal/ には fetch を実行する関数などが入っています。

/packages/core/src/exchanges/ には、デフォルトで用意されている exchange が定義されています。

やる

全体像をざっくり紹介したところで、実際にコードを読み進めていきたいと思います。
基本的にはデフォルトで実装されている部分とその周辺を中心に読んでいきます。
また、外部ライブラリ(@graphql-typed-document-node/core, wonka) やビルド(rollup) については深くは触れず、説明に必要なことだけ述べようと思います。
言語については TypeScript で書かれています。


最終的な目標は、createClient で query (または mutation) を投げたときに何をしているかを理解することと、キャッシュの仕組みを理解することです。

リクエストの流れ

型定義をしたところで早く Client のコードに移りたいところですが、Client を作成する部分を見る前に、リクエストを投げるまでの流れについてざっくりと確認します。
そもそものリクエストを投げる流れですが、以下のようになっています。(query の例)

  1. 定義された query を受け取る
  2. createRequest 関数に query と variable を渡してリクエストを作成する
  3. 作成したリクエストと context を executeQuery 関数に渡して実行する
  4. executeQuery 関数で operation を作成する
  5. executeRequestOperation 関数で operation を実行する

この流れを意識してコードを読んでいくと分かりやすく追えそうです。

Client を作成する

やっと本題です。
まず最初に Client を見ていきます。
ここでは主に createClient を使用してリクエストややりとりを行う、urql の中心となるものが定義されていて、そのための Client を起動するための関数やそれを実行するための関数などが定義されています。
また、それらの関数の中身は stream 的に処理されるため、wonka を使用して実装している部分が多数あります。

まずは、Client の interface から見ていきたいと思います。
Client interface では主に createClient を行う関数の中で扱う関数や変数の型を定義する interface として定義されています。
何やら色々定義されていますが、全部説明すると長いので当初の目標の query と mutation を扱う処理を中心に追っていきたいと思います。

実際の関数のコードを読んでみる

型定義は確認したので、実際のコードを見てみたいと思います。
先ほどと同じように query から追っていきたいと思います。

query は第一引数にクエリ、第二引数に変数(任意)、第三引数に context(任意)が入ります。具体的には以下のような感じです。

import { craeteClient, gql } from '@urql/core';

// Client の作成
const client = createClient({
    url: 'http:/localhost:8080//graphql',
});

// Query の作成
const QUERY = gql`
query postQuery($pages: Int, $category: String){
  getPosts (page: $pages, category: $category){
    current
    next
    previous
    category
    results {
      id
      title
      contents
      category
      pub_date
    }
  }
}
`;

// リクエストを投げる
client
.query(QUERY, { page: 1, category: '' }) // ← ここの処理について追っていく
.toPromise()
.then(res => { 処理; });

上で示した使用例を参考にしながらやっていきます。

query 関数

まず、起点となる query 関数は以下のようになっています。

/packages/core/src/client.ts
query(query, variables, context) {
  if (!context || typeof context.suspense !== 'boolean') {
    context = { ...context, suspense: false };
  }

  return withPromise(
    client.executeQuery(createRequest(query, variables), context      
  );
}

query 関数は先ほど説明した3つの引数をとります。
最初の if 文では context の存在確認と、suspense について確認をしています。suspense は React の Suspense を実装するための補助的な機能です。

createRequest 関数

query 関数がわかったところで、戻り値の中で一番内側で定義されている createRequest 関数を見てみます。
createRequest 関数は /packages/core/src/utils/request.ts に定義されています。
createRequest 関数のコードは以下のようになっています。

/packages/core/utils/request.ts
export const createRequest = <Data = any, Variables = object>(
  q: string | DocumentNode | TypedDocumentNode<Data, Variables>,
  vars?: Variables
): GraphQLRequest<Data, Variables> => {
  if (!vars) vars = {} as Variables;
  const query = keyDocument(q);
  return {
    key: phash(query.__key, stringifyVariables(vars)) >>> 0,
    query,
    variables: vars,
  };
};

第一引数は query, 第二引数は variable が渡されます。
中身を見てみます。

受け取った q という変数は keyDocument という関数に渡されています。
keyDocument 関数は query を元にしてハッシュを生成します。
このハッシュは query を一意に定めるためのもので、キャッシュを管理したりする際に使用します。ハッシュ関数には djb2 というハッシュアルゴリズムが使用されています。
また、その hash 関数の中で呼ばれている stringifyDocument 関数では query を生成して返してくれます。
keyDocument 関数の中身は以下のようになっています。

/packages/core/utils/request.ts
export const keyDocument = (q: string | DocumentNode): KeyedDocumentNode => {
  let key: number;
  let query: DocumentNode;
  if (typeof q === 'string') {
    // ここでハッシュを生成している
    key = hash(stringifyDocument(q));
    query = docs.get(key) || parse(q, { noLocation: true });
  } else {
    key = (q as KeyedDocumentNode).__key || hash(stringifyDocument(q));
    query = docs.get(key) || q;
  }

  // Add location information if it's missing
  if (!query.loc) stringifyDocument(query);

  (query as KeyedDocumentNode).__key = key;
  docs.set(key, query as KeyedDocumentNode);
  return query as KeyedDocumentNode;
};

上で述べた stringifyDocument 関数は、以下のようになっています。

/packages/core/utils/request.ts
export const stringifyDocument = (
  node: string | DefinitionNode | DocumentNode
): string => {
  let str = (typeof node !== 'string'
    ? (node.loc && node.loc.source.body) || print(node)
    : node
  )
    .replace(/([\s,]|#[^\n\r]+)+/g, ' ')
    .trim();

  if (typeof node !== 'string') {
    const operationName = 'definitions' in node && getOperationName(node);
    if (operationName) {
      str = `# ${operationName}\n${str}`;
    }

    if (!node.loc) {
      (node as WritableLocation).loc = {
        start: 0,
        end: str.length,
        source: {
          body: str,
          name: 'gql',
          locationOffset: { line: 1, column: 1 },
        },
      } as Location;
    }
  }

  return str;
};

以下の部分がミソで、operationName を見つけてそれを格納してレスポンスに丸めます。
この operationName というのは query に定義する名前で、例えば query todoQuery { id, todo, ... という query があったら operationName にあたるのは todoQuery といった具合になります。

const operationName = 'definitions' in node && getOperationName(node);
if (operationName) {
  str = `# ${operationName}\n${str}`;
}


このような形で createRequest 関数を使用してリクエストを生成してることがわかりました。
一つ疑問に思うことがあります。keyDocument で読んでいる hash 関数と、createRequest 関数で呼んでいる phash 関数の違いです。
これは実際にデバッグするとわかるのですが、hash 関数はクエリでキャッシュを扱う際に検索するように使い、phash はその戻り値を元にして生成する query を定義するために使用します。

executeQuery 関数

次に createRequest 関数の戻り値と context を引数として受け取る executeQuery 関数を見てみます。
ここでは query の値を引数として渡して createRequestOperation を呼び、その戻り値を executeRequestOperation に渡すことで operation を実行しています。

/packages/core/src/utils/client.ts
executeQuery(query, opts) {
  const operation = client.createRequestOperation('query', query, opts);
  return client.executeRequestOperation(operation);
},

createRequestOperation 関数

まず createRequestOperation から見てみます。
createRequestOperation は非常にシンプルで、makeOperation 関数を呼び出しています。
makeOperation 関数には上で説明した kindrequest に加えて createOperationContext 関数に opts を渡した戻り値を渡しています。

/packages/core/src/utils/client.ts
createRequestOperation(kind, request, opts) {
  return makeOperation(kind, request, client.createOperationContext(opts));
}

createOperationContext 関数

ということで、createOperationContext 関数を見てみます。
createOperationContext 関数は、operation の形を作成して返します。opts には query から得た引数が入ります。
引数は任意なので、opts がなかった場合は最初の if 文で代わりの値を入れてあげます。
戻り値は、GraphQL で使用する OperationContext を返します。

/packages/core/src/client.ts
createOperationContext(opts) {
  if (!opts) opts = {};
  
  return {
    url: client.url,
    fetchOptions: client.fetchOptions,
    fetch: client.fetch,
    preferGetMethod: client.preferGetMethod,
    ...opts,
    suspense: opts.suspense || (opts.suspense !== false && client.suspense),
    requestPolicy: opts.requestPolicy || client.requestPolicy,
  };
}

戻り値のイメージとしては以下のようになります。

{
  fetch: undefined
  fetchOptions: undefined
  preferGetMethod: false
  requestPolicy: "cache-first"
  suspense: false
  url: "https://localhost:8080/graphql"
}

makeOperation 関数

次に、makeOperation 関数とはなんぞやという形だと思うので、まずは makeOperation 関数を見てみます。
ここではレスポンスのための operation を定義しています。
戻り値の key には先ほど hash 関数で生成したハッシュ値が入っており、これを使用して query を一意に識別することができるようになっています。
第一引数の kind はその query のメソッド(query, mutation...) が入っています。他の値もこれまで説明した通りです。

/packages/core/src/utils/operation.ts
function makeOperation<Data = any, Variables = object>(
  kind: OperationType,
  request: GraphQLRequest<Data, Variables>,
  context: OperationContext
): Operation<Data, Variables>;

function makeOperation<Data = any, Variables = object>(
  kind: OperationType,
  request: Operation<Data, Variables>,
  context?: OperationContext
): Operation<Data, Variables>;

function makeOperation(kind, request, context) {
  if (!context) context = request.context;

  return {
    key: request.key,
    query: request.query,
    variables: request.variables,
    kind,
    context,
  };
}

executeRequestOperation 関数

次に、executeQuery 関数の戻り値である executeRequestOperation を見てみます。
executeRequestOperation は以下のように書かれています。

引数には createRequestOperation で生成した operation が渡されています。

補足ですが、pipe は、wonka の関数で、処理をパイプライン的に実行します。
前の戻り値と順番に渡される関数を呼び出すだけの関数です。
詳しくは ドキュメント を見てください。

/packages/core/src/client.ts
executeRequestOperation(operation) {
  if (operation.kind === 'mutation') {
    return makeResultSource(operation);
  }

  const source = active.get(operation.key) || makeResultSource(operation);

  const isNetworkOperation =
    operation.context.requestPolicy === 'cache-and-network' ||
    operation.context.requestPolicy === 'network-only';

  return make(observer => {
    return pipe(
      source,
      onStart(() => {
        const prevReplay = replays.get(operation.key);

        if (operation.kind === 'subscription') {
          return dispatchOperation(operation);
        } else if (isNetworkOperation) {
          dispatchOperation(operation);
        }

        if (
          prevReplay != null &&
          prevReplay === replays.get(operation.key)
        ) {
          observer.next(
            isNetworkOperation ? { ...prevReplay, stale: true } : prevReplay
          );
        } else if (!isNetworkOperation) {
          dispatchOperation(operation);
        }
      }),
      onEnd(observer.complete),
      subscribe(observer.next)
    ).unsubscribe;
  });
}

長いので切り出して説明します。
まず、最初の if 文については mutation の時に呼ばれる関数です。

if (operation.kind === 'mutation') {
  // mutation の時に呼ばれる
  return makeResultSource(operation);
}

次に、sourceisNetworkOperation を見てみます。
source は、key を呼ぶか、makeResultSource を使用して source を生成します。
makeResultSource については後で説明します。
isNetworkOperation は、上で述べたリクエストのキャッシュの方式についての判定をしています。

const source = active.get(operation.key) || makeResultSource(operation);

const isNetworkOperation =
    operation.context.requestPolicy === 'cache-and-network' ||
    operation.context.requestPolicy === 'network-only';

次に、戻り値についてです。
また wonka の関数が出てきます。
make は任意のソースを作成することができる関数で、observer を用いて作成することができます。
詳しくは make のドキュメント を参照してください。

上から見ていくと、make 関数を使用してソースを生成し、その中では pipe 関数を使用して上から順番に処理を走らせます。
onStart 関数は開始信号がソースによってシンクに送信されたときにコールバックを実行します。
prevReplay には古い operation が入っていて、onStart の後半でそれを削除する処理が走ります。
operation は実行順にキューに入れられていますが、dispatchOperation はそのキューを空にします。subscription や network request を送る際は現在の状態に関わらずリクエストを送るため、空にする必要があります。

また、observer には nextcomplate が存在していて、next はシンクに値を出力し、complate はソースを終了しシンクを完了するという役目があります。

return make(observer => {
  return pipe(
    source,
    onStart(() => {
      const prevReplay = replays.get(operation.key);

      if (operation.kind === 'subscription') {
        return dispatchOperation(operation);
      } else if (isNetworkOperation) {
        dispatchOperation(operation);
      }

      if (
        prevReplay != null &&
        prevReplay === replays.get(operation.key)
      ) {
        observer.next(
          isNetworkOperation ? { ...prevReplay, stale: true } : prevReplay
        );
      } else if (!isNetworkOperation) {
        dispatchOperation(operation);
      }
    }),
    onEnd(observer.complete),
    subscribe(observer.next)
  ).unsubscribe;
}

executeRequestOperation では、query、mutation、subscription のそれぞれについて処理を分けて実行していることがわかりました。
query、mutation、subscription の処理を細かく分けているのはこの executeRequestOperation と下で出てくる makeResultSource くらいなので、これらの関数は urql について非常に重要な処理をしている部分だということがわかりました。

makeResultSource 関数

上で少しスルーしてしまった makeResultSource 関数について説明します。
makeResultSource は以下のように書かれています。
また分割して説明していきます。

/packages/core/src/client.ts
const makeResultSource = (operation: Operation) => {
  let result$ = pipe(
    results$,
    filter(
      (res: OperationResult) =>
        res.operation.kind === operation.kind &&
        res.operation.key === operation.key
    )
  );

  // Mask typename properties if the option for it is turned on
  if (client.maskTypename) {
    result$ = pipe(
      result$,
      map(res => ({ ...res, data: maskTypename(res.data) }))
    );
  }

  // A mutation is always limited to just a single result and is never shared
  if (operation.kind === 'mutation') {
    return pipe(
      result$,
      onStart(() => dispatchOperation(operation)),
      take(1)
    );
  }

  const source = pipe(
    result$,
    // End the results stream when an active teardown event is sent
    takeUntil(
      pipe(
        operations$,
        filter(op => op.kind === 'teardown' && op.key === operation.key)
      )
    ),
    switchMap(result => {
      if (result.stale) {
        return fromValue(result);
      }

      return merge([
        fromValue(result),
        // Mark a result as stale when a new operation is sent for it
        pipe(
          operations$,
          filter(op => {
            return (
              op.kind === operation.kind &&
              op.key === operation.key &&
              (op.context.requestPolicy === 'network-only' ||
                op.context.requestPolicy === 'cache-and-network')
            );
          }),
          take(1),
          map(() => ({ ...result, stale: true }))
        ),
      ]);
    }),
    onPush(result => {
      replays.set(operation.key, result);
    }),
    onStart(() => {
      active.set(operation.key, source);
    }),
    onEnd(() => {
      // Delete the active operation handle
      replays.delete(operation.key);
      active.delete(operation.key);
      // Delete all queued up operations of the same key on end
      for (let i = queue.length - 1; i >= 0; i--)
        if (queue[i].key === operation.key) queue.splice(i, 1);
      // Dispatch a teardown signal for the stopped operation
      dispatchOperation(
        makeOperation('teardown', operation, operation.context)
      );
    }),
    share
  );

  return source;
};

まずは最初に定義されている results$ についてです。
ここでは、現在の operation とレスポンスの operarion を比較してリクエストとレスポンスが新しいかどうかを確かめます。

let result$ = pipe(
  results$,
  filter(
    (res: OperationResult) =>
      res.operation.kind === operation.kind &&
      res.operation.key === operation.key
  )
);

次にここを見てみます。
ここでは、client.maskTypename の opetion が有効になっている場合に __typename プロパティをラッピングします。

if (client.maskTypename) {
  result$ = pipe(
    result$,
    map(res => ({ ...res, data: maskTypename(res.data) }))
  );
}

次はここを見てみます。
ここは query が mutation だった時に dispatchOperation 関数を使用して operation をクリアします。
mutation が飛んできた場合には、新しい処理が走ったとみなされ、そのリクエストに紐づくキャッシュはクリアされ、処理はリセットされます。

if (operation.kind === 'mutation') {
  return pipe(
    result$,
    onStart(() => dispatchOperation(operation)),
    take(1)
  );
}

次に戻り値を見ます。
ここでは関数全体の戻り値である source を生成しています。
source というのは Source<OperationResult<any, any>> という型になっていて、ここでレスポンスを生成しています。つまり要となる値です。
source は、operation の状態を管理していて、kind が teardown の時に処理を終了します。
switchMapresult は response が入っています。
onPush から下の動作は処理を終了するために書かれています。
onEnd では最後に operation やキューを削除しています。

const source = pipe(
  result$,
  takeUntil(
    pipe(
      operations$,
      filter(op => op.kind === 'teardown' && op.key === operation.key)
    )
  ),
  switchMap(result => {
    if (result.stale) {
      return fromValue(result);
    }

    return merge([
      fromValue(result),
      pipe(
        operations$,
        filter(op => {
          return (
            op.kind === operation.kind &&
            op.key === operation.key &&
            (op.context.requestPolicy === 'network-only' ||
              op.context.requestPolicy === 'cache-and-network')
          );
        }),
        take(1),
        map(() => ({ ...result, stale: true }))
      ),
    ]);
  }),
  onPush(result => {
    replays.set(operation.key, result);
  }),
  onStart(() => {
    active.set(operation.key, source);
  }),
  onEnd(() => {
    // Delete the active operation handle
    replays.delete(operation.key);
    active.delete(operation.key);
    // Delete all queued up operations of the same key on end
    for (let i = queue.length - 1; i >= 0; i--)
      if (queue[i].key === operation.key) queue.splice(i, 1);
    // Dispatch a teardown signal for the stopped operation
    dispatchOperation(
      makeOperation('teardown', operation, operation.context)
    );
  }),
  share
);

ここで operation のレスポンスを生成していることがわかりました。

withPromise 関数

戻り値は withPromise という関数の戻り値を返しています。
ここで、withPromise 関数を見てみます。
withPromise 関数では、query を含むリクエスト関数を Promise でラッピングして返します。これによって非同期での処理が可能になります。
query 関数の時点で operation は生成されているので source$ には result の operation が入ってきます。

/packages/core/src/utils/streamUtils.ts
import { Source, pipe, toPromise, filter, take } from 'wonka';
import { OperationResult, PromisifiedSource } from '../types';

export function withPromise<T extends OperationResult>(
  source$: Source<T>
): PromisifiedSource<T> {
  (source$ as PromisifiedSource<T>).toPromise = () => {
    return pipe(
      source$,
      filter(result => !result.stale),
      take(1),
      toPromise
    );
  };

  return source$ as PromisifiedSource<T>;
}

これをすることにより、このような使い方をして Promise を使用した処理が可能になります。

client
.query(QUERY, { page: 1, category: '' })
.toPromise()
.then(res => console.log(res)); // 結果の operation が入っている

まとめ

長々書いてしまった上に、あまり上手くまとまっていない気がしますが、urql の query の実行の流れは大体掴むことができました。
urql にはこれ以外にも便利な Exchange がたくさん提供されています。
特にキャッシュの設定が非常にシンプルに定義できるため、これから重宝されそうです。
個人的にも GraphQL の仕組みを理解することができたので良かったです。では。

Discussion