urql コードリーディング
こんにちは
どうも、僕です。
今回は urql という GraphQL Client のライブラリのコードリーディングをしたいと思います。
ではやっていきます。
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 のキャッシュについて)にまとめたので気になる人は見てください。
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 の例)
- 定義された query を受け取る
- createRequest 関数に query と variable を渡してリクエストを作成する
- 作成したリクエストと context を executeQuery 関数に渡して実行する
- executeQuery 関数で operation を作成する
- 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
関数は以下のようになっています。
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
関数のコードは以下のようになっています。
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
関数の中身は以下のようになっています。
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
関数は、以下のようになっています。
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 を実行しています。
executeQuery(query, opts) {
const operation = client.createRequestOperation('query', query, opts);
return client.executeRequestOperation(operation);
},
createRequestOperation 関数
まず createRequestOperation
から見てみます。
createRequestOperation
は非常にシンプルで、makeOperation
関数を呼び出しています。
makeOperation
関数には上で説明した kind
、request
に加えて createOperationContext
関数に opts
を渡した戻り値を渡しています。
createRequestOperation(kind, request, opts) {
return makeOperation(kind, request, client.createOperationContext(opts));
}
createOperationContext 関数
ということで、createOperationContext
関数を見てみます。
createOperationContext
関数は、operation の形を作成して返します。opts
には query から得た引数が入ります。
引数は任意なので、opts
がなかった場合は最初の if 文で代わりの値を入れてあげます。
戻り値は、GraphQL で使用する OperationContext
を返します。
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...) が入っています。他の値もこれまで説明した通りです。
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 の関数で、処理をパイプライン的に実行します。
前の戻り値と順番に渡される関数を呼び出すだけの関数です。
詳しくは ドキュメント を見てください。
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);
}
次に、source
と isNetworkOperation
を見てみます。
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
には next
と complate
が存在していて、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
は以下のように書かれています。
また分割して説明していきます。
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
の時に処理を終了します。
switchMap
の result
は 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 が入ってきます。
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