GraphQLについて再入門

知ったつもりだったけど、いざとなると知らないこと多かったので
もう少しだけ突っ込んで入門したときの走り書き。

Learning GraphQL を読むんで再認識したことも中心にメモした。

グラフ理論

GraphQLはこのグラフ理論に基づく考え方を知っておく必要がある。
ノード(節点・頂点)の集合とエッジ(枝・辺)の集合で構成されるグラフに関する数学の理論。

1
G(グラフ) = (V(頂点)、E(頂点))

で表すことができる。

ノード間に方向や階層がないものを無向グラフという。

ノード間の移動はどこからでもできて、方向は任意。非線形データ構造。
また、辺が順序付けられたペアであるときは有向グラフになる

実世界のグラフ

Facebookの各ユーザーの結びつきは、相互に関連し合う多数の関係を持つ構造で無向グラフ。
それに対してTwitterは有向グラフ。フォローするが、フォローされるわけではないため。

GraphQL

GraphQLとは

GraphQLはクエリ言語および実行エンジンであり、クエリを使用してデータを変更または削除できる。
GraphQLクエリをAPIに送信し、1つ以上のデータベース、REST API、WebSocketなどにデータを格納する。
MySQLのINSERT、UPDATE、DELETEと違いデータ変更を1つのMutation型にまとめ、データ型にまとめ実行するなどが可能。
ソケットを介しデータ変更を監視するためのsubscribe型がある。

GraphQLの操作

Graphqlの操作タイプは3種類ある。

  • query: 問い合わせクエリ
  • mutation: 作成、更新、削除クエリ
  • subscription: websocketを介した変更監視クエリ

GraphQLのqueryの例

1
2
3
4
5
query {
person {
name
}
}

上記は以下のようなhttpリクエストを通じて実行できる。

1
2
3
$ curl 'http://my.graphql.com'
-H 'Content-Type:application/json'
--data '{"query": "{person {name}}"}'

データ更新する場合は次の雰囲気のクエリを実行する。

1
2
3
4
5
6
mutation {
healthStatus(id: "TOM" status: 'OK') {
name
status
}
}

こちらは以下のhttpリクエストで行える

1
2
3
curl 'http://my.graphql.com'
-H 'Content-Type: application/json'
--data '{"query":"mutation {healthStatus(id: \"TOM\" status: \"OK\") {name status}}"}'

フィールドには引数を受け付けることもできる

Fragmant

再利用可能なクエリの実行単位。
大きなクエリや頻繁に利用されるデータ要件を分割管理することができる。

1
2
3
4
5
6
7
8
fragment somethingFields on Person {
name
likes {
foods
books
sports
}
}

Introspection

ざっくりGraphQLがどのようなクエリやフィールドをサポートしているのかを問合る機能。

Schema、Type、TypeKind、Field、nputValue、EnumValue、Directive
アンダースコア(
)で始まるこれらはすべてイントロスペクションを表す。
内部の情報をクエリ経由で参照できる。つまりそのgraphqlサービスが提供するすべてを知ることができる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
__type(name: "Node") {
name
kind
fields {
name
type {
kind
ofType {
name
kind
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"__type": {
"name": "Node",
"kind": "INTERFACE",
"fields": [
{
"name": "id",
"type": {
"kind": "NON_NULL",
"ofType": {
"name": "ID",
"kind": "SCALAR"
}
}
}
]
}
}

Directive

Directiveを利用することで既存の型システムに注釈をつけることができる。
ただしこればGraphqlサーバーの実装による。
https://graphql.org/learn/queries/#directives

@deprecatedディレクティブ

1
2
3
4
5
6
7
8
directive @deprecated(
reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE

type ExampleType {
newField: String
oldField: String @deprecated(reason: "Use `newField`.")
}

上記の場合@deprecatedディレクティブ宣言が与えられると、引数types(reason: String)とlocations(FIELD_DEFINITION | ENUM_VALUE)を適用するかどうかはサーバーの実装次第になる。

@exampleディレクティ部はフィールドFIELD_DEFINITIONと引数定義ARGUMENT_DEFINITIONに注釈をつけることができる。

@skipディレクティブ

クエリの実行をスキップする。

1
2
3
4
5
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

query myQuery($isStatus: Boolean) {
experimentalField @skip(if: $isStatus)
}

$isStatusがtrueの場合のみクエリが実行される。

@includeディレクティブ

@skipとは逆にクエリの条件にふくめることになる

1
2
3
4
5
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

query myQuery($isStatus: Boolean) {
experimentalField @include(if: $isStatus)
}

クエリでの再帰的検索

GraphQLはデータを無限に再帰検索しない。
Graphデータが無限再起でないことを保証する方法がない。
また再帰的にしても、クエリを利用するアプリケーションの制約があるため利用できないことがある。
対策法として、任意の深さまでデータを取得し、更に追加の情報を取得する必要がある場合は「もっと見る」などのリンクを追加することがよい。

この辺の議論

ID型について

IDがintなのかstringがいいのかとかの話につながるが、Scaler-IDを読むと以下の通り。

The ID type is serialized in the same way as a String; however, it is not intended to be human‐readable. While it is often numeric, it should always serialize as a String.

IDはStringと同じ方法でシリアライズされます。ただし、人間が読める形式ではありません。
多くの場合数値ですが、常にStringとしてシリアル化する必要がある。

言語実装

graphql.jsを例にすると、lexer, ast, parserがあることから、これを基準に各言語でも言語処理が可能。

GraphQL周辺のコミュニティ

GraphQLは既にFaceBookを離れてGraphQL Foundationとなっている。

Apollo GraphQLでGraphQL界隈のツールを多く提供している。Meteor Development Group Inc.が母体。

prisma

GraphQLをとり言えれたアプリケーションのアーキテクチャ

REST APIでは、複数のエンドポイントにアクセスしてデータを収集する。
GraphQLはデータ要件を含む単一のクエリをGraphQLサーバーに送信するだけになる。
よって、複数のエンドポイントを束ねたエンドポイントを構築するよりも、ミドルウェアとしてGraphQLを導入する。
また、スキーマファーストでの開発となる。既にあるリソースを用い、データ構造を抽象化していくことになる。
あらかじめ各データのスキーマを定義しておくことでデータ結合のためのビジネスロジックの開発から解放される。

Falcorのような解決方法もあるがGraphQLのエコシステムを選ぶことが懸命になっている。

セキュリティ上検討すべきこと

認証

実行クライアントを制限。認証Tokenなどを使い実行クライアントを認証することができる。
(Githubなど)

SlowQuery

Slowクエリなどの問題は適切なタイムアウトを設定しておく必要がある。
ただし、jmutationなどが実行された場合、すでにデータに不整合が発生していることもある。

Max Depth

クエリの深度を限定する。再帰的に検索することにもなるため、クエリ実行時にAST分析を行い、最大深度以上のフィールをを無視する。(またはエラーにする)実装が必要。
DoSなどでネスとクエリで攻撃をかけることも可能。

Complex Query

複雑なクエリも定義できるが、実装は難しくなることもある。
また、複雑ゆえに本来隠匿されるべきデータへのアクセスにより、情報が漏洩することや、
データ構造からSQL/NoSQLへのInjectionも起こりえそうなため
できるだけクエリはシンプルに保つことが望ましそう。

Validation

アカウント認証、パスワード認証などの検証機構は提供されてない(あくまでクエリ言語なのため)。
実装するやり方にもよるが、自前でタイプなどの実装が必要になることも。
Validation不備の場合、webのアプリケーションと同じく、injectionやXSSの脆弱性を生むことにもつながる。

そもそもGraphQLで実現すべき機能なのかも踏まえて検討するべき。

実装に関して

Apollo界隈のtool中心に

Apollo Server利用にしている場合。
SchemaDirectiveVisitorを利用する。
このクラスにはllocationsに対応するメソッドが準備されているのでそれをオーバーライドすることになる。

SchemaDirectiveVisitorを継承したDeprecatedDirectiveを作成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// directive @deprecated(
// reason: String = "No longer supported"
// ) on FIELD_DEFINITION | ENUM_VALUE
// 上記を実装する場合

import { SchemaDirectiveVisitor } from 'graphql-tools';
export default class DeprecatedDirective extends SchemaDirectiveVisitor {
public visitFieldDefinition(field: GraphQLField<any, any>) {
field.isDeprecated = true;
field.deprecationReason = this.args.reason;
}

public visitEnumValue(value: GraphQLEnumValue) {
value.isDeprecated = true;
value.deprecationReason = this.args.reason;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { makeExecutableSchema } from 'graphql-tools';
import DeprecatedDirective from 'directive/DeprecatedDirective'

const typeDefs = `
type ExampleType {
newField: String
oldField: String @deprecated(reason: "No longer supported")
}`;

const schema = makeExecutableSchema({
typeDefs,
schemaDirectives: {
deprecated: DeprecatedDirective
}
});

ユースケーストして日付フォーマットの変更の場合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { defaultFieldResolver } from "graphql";

const typeDefs = `
directive @date(format: String) on FIELD_DEFINITION

scalar Date

type Post {
published: Date @date(format: "mmmm d, yyyy")
}`;

class DateFormatDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
// https://github.com/graphql/graphql-js/blob/master/src/execution/execute.js#L1238
// fileld is `resolve`
const { resolve = defaultFieldResolver } = field;
const { format } = this.args;
// overwrite resolve filed method
field.resolve = async function (...args) {
const date = await resolve.apply(this, args);
return require('dateformat')(date, format);
};
// The formatted Date becomes a String, so the field type must change:
field.type = GraphQLString;
}
}

const schema = makeExecutableSchema({
typeDefs,
schemaDirectives: {
date: DateFormatDirective
}
});

Apollo GraphQL Platform

Apollo Client

名前の通り。
https://github.com/apollographql/apollo-client
アプリのデータと状態を管理するClient。

InMemoryCache

Apollo Clientのキャッシュ機構。
Apollo ClientのDataProxyを通して、
readQuery, readFragment, writeQuery, writeFragment インターフェイスを経由してcacheとやり取りする。

readQuery

queryの場合は、サーバーにリクエストするが、readQueryはApplicationにcacheあがない場合はエラーを投げる。

1
2
3
4
5
6
7
8
9
10
11
const { todo } = client.readQuery({
query: gql`
query ReadTodo {
todo(id: 5) {
id
text
completed
}
}
`,
});
1
2
3
4
5
6
7
8
9
10
11
client.writeFragment({
id: '5',
fragment: gql`
fragment myTodo on Todo {
completed
}
`,
data: {
completed: true,
},
});

GraphQLサーバーへのリクエストを制御するインターフェイス。
GraphQLリクエストを実行すると、各Linkの機能が次々に適用されます。
Apollo Clientコアにある機能をモジュラーリンクに移行する予定。

1
ApolloLink.from([...])

fromで配列を受け取りそれらを単一のリンクに統合する。
内部的にreduceする。

1
ApolloLink.concat(x, y)

concatでリンクを結合して1つにする。

状況によってことなるリンクを使うのであれば

1
ApolloLink.spilit(conditon, condition_is_true, condition_is_false)

spilitのオプションは第一引数に条件、第二引数にtrueの場合のリンク、第三引数にfalseの場合のリンク

ローカルの状態を管理する。
起動時にwithClientStateでローカルのデータストアに状態を登録する。

apollo-cache-persist

clientの永続化ストレージにstateを自動的に保存または復元する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { InMemoryCache } from 'apollo-cache-inmemory';
import { persistCache } from 'apollo-cache-persist';

const cache = new InMemoryCache({...});

persistCache({
cache,
storage: window.localStorage,
});

// Continue setting up Apollo as usual.
const client = new ApolloClient({
cache,
...
});

GraphQLまたはネットワークエラー発生時のロジックを提供する。
以下のオブジェクトキーが利用できる。

operation エラーとなった操作
response 次のリンクチェーンに渡されるレスポンス
graphQLErrors GraphQLエンドポイントからのエラー配列
networkError errorsGraphQLサーバーのレスポンスエラーまたは結果のパースエラー
forward チェーン内の次のリンクへの参照。return forward(operation)コールバックを呼び出すとリクエストが再試行される

GraphQL Server

apollo-server

名前の通り。
https://github.com/apollographql/apollo-server
JavaScript GraphQLサーバー。schmemeとresolverを定義。

バックエンドに既存のREST API、Database, Microserviceの場合、それぞれが持っているミドルウェアと一緒に組み込むことが可能。
またAmazon LambdaやMicrosoft Azure Functionsなどのサーバーレス(Faas)をバックエンドに使うことも可能。

apllo-serverのintegrationには以下がある。

  • apollo-server-express
  • apollo-server-koa
  • apollo-server-hapi
  • apollo-server-lambda
  • apollo-server-azure-functions
  • apollo-server-cloud-functions
  • apollo-server-cloudflare

Mocking

フロントエンドの開発者がバックエンドの実装を待つことなくUIコンポーネントと機能を構築できるようにするためにmocking機能がある。

モックの例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const { ApolloServer, gql } = require('apollo-server');

const typeDefs = gql`
type Query {
hello: String
}
`;

const mocks = {
Person: () => ({
name: casual.name,
age: () => casual.integer(0, 120),
}),
};

const server = new ApolloServer({
typeDefs,
mocks,
});

server.listen().then(({ url }) => {
console.log(`Server ready at ${url}`)
});

MockList Resolver

モックの作成を自動化するためにMockListクラスを利用できる。

1
2
3
4
5
6
7
8
9
10
const { MockList } = require('apollo-server');

const mocks = {
Person: () => ({
// a list of length between 2 and 6 (inclusive)
friends: () => new MockList([2,6]),
// a list of three lists each with two items: [[1, 1], [2, 2], [3, 3]]
listOfLists: () => new MockList(3, () => new MockList(2)),
}),
};

Resolver

リゾルバーはGraphQL操作をデータに変換するための方法を提供します。

Resolver Map

すべてのクエリに対応するために、スキーマはすべてのフィールドに対して解決関数を持っている。
この関数の集合をResolver Mapという。
スキーマのフィールドと型を関数で紐付ける。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const { gql } = require('apollo-server');
const { find, filter } = require('lodash');

const schema = gql`
type Book {
title: String
author: Author
}

type Author {
books: [Book]
}

type Query {
author: Author
}
`;

const resolvers = {
Query: {
author(parent, args, context, info) {
return find(authors, { id: args.id });
},
},
Author: {
books(author) {
return filter(books, { author: author.name });
},
},
};

型とリゾルバは組み合わせることができるので、複数に分けて管理することは可能。

リゾルバ関数

schemeで定義されたフィールを返すPromise関数。

parent 親引数。親フィールドでリゾルバから返された結果を含むオブジェクト。最上位の場合はrootValueから渡された値。
args クエリ引数。クエリ関数に渡される引数。
context クエリ、リゾルバで共有されるオブジェクト。認証スコープ、データベース接続、カスタムフェッチなど複数のリゾルバで共有されるオブジェクト。
info フィールド名、ルートからのパス、クエリの実行状態などが含まれる。参照

contextの例

1
2
3
4
5
6
7
8
9
10
11
12
13
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
authScope: getScope(req.headers.authorization)
})
}));

// resolver
(parent, _, context) => {
if(context.authScope !== ADMIN) throw AuthenticationError('not admin');
...
}

react-apollo

ReactのためのApolloClient拡張。
GraphQLサーバーからデータを取得し、ReactのComponentにprops経由で接続する。
よく見られる高階関数パターン。

1
2
3
4
5
6
7
8
9
10
11
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';

const client = new ApolloClient();

ReactDOM.render(
<ApolloProvider client={client}>
<MyRootComponent />
</ApolloProvider>,
document.getElementById('root'),
);

でコンポーネントにclientを渡すことができる
https://github.com/apollographql/react-apollo/blob/master/src/ApolloProvider.tsx#L9

各コンポーネントからクエリを発行する。
複数のクエリをcomposeして発行することも可能。


ということでここまでで時間切れ。

参考ページ

Comments