はじめに
最終的には AWS AppSync を使いたいため、なんとなく GraphQL や AppSync の使い方を流し読みしたが、やっぱり実際に使ってみないとよくわからない。
そこで、まずは第一歩としてローカルに Apollo サーバー を立てて、手作業でクエリを発行したりしながら GraphQL の理解を試みる。
なお、この記事では「GraphQLのメリットや良さ」については取り扱わない。 簡単なサンプルを動かしながら GraphQL を使う手順の理解をすることに注力する。
GraphQL とは
Wikipedia では GraphQL(グラフQL)はAPI向けに作られたクエリ言語およびランタイムである と説明されている。 クエリ言語およびランタイムである、という説明から分かるとおり、複数の項目を含む概念であるため、簡潔に説明するのは難しい。
そこで、GraphQL を理解するために関連項目を以下の通り図で表した。 単に GraphQL とだけ行った場合、この図のすべての範囲、あるいはその一部を示している。
GraphQL は効率良くデータを取得(と変更)するために、利用者と実データの間を結ぶ実装を提供するもの と理解するのが良いと思う。
一般的なデータベースサービス (RDBMS, つまりは Oracle Database とか MySQL とかのサーバー側) と比較すると、RDBMS は右側の黄色い枠全体を包括していて、データの保存にもサービス独自の規格が用いられている。 SQL や DDL によって、データベースおよびそこで管理されるデータの操作を行うことができる。 この時、利用者・DB管理者の視点では、データ操作のために SQL などを記載する必要があるが、書かれた SQL に対してどのようにデータを操作するかの具体的なプログラムを書くことはない。
一方、GraphQL のサービスではこれを分離できる。 その代わり、外部から与えられたコマンドに対して、どのような処理を行ったデータを返すかというマッピングというプログラム実装を resolver として行う必要がある。
この記事では、GraphQL Service として Apollo を利用する。 説明ではデータを分離しているが、GraphQL Service の Resolver で固定値、あるいはサーバーのメモリ上の値を返すように実装すれば、Dataset 部分を作らなくても GraphQL Service を動かすことができる。
利用した環境とバージョンなど
- OS: Ubuntu 20.04 LTS
- Node.js: v15.8.0
- yarn: 1.22.10
- apollo-server: 2.25.2
- graphql: 15.5.1
Apollo セットアップと Hello World
最初は Github の README を参考にセットアップする。
$ cd [プロジェクトディレクトリ]
$ yarn init
# ... 手を動かすだけなので今回は yarn init の入力内容は適当に
$ yarn add apollo-server graphql
# サーバーサイドスクリプトを記載
$ vim index.js
index.js
も README に書かれている内容をそのまま保存する。
const { ApolloServer, gql } = require('apollo-server');
// The GraphQL schema
const typeDefs = gql`
type Query {
"A simple type for getting started!"
hello: String
}
`;
// A map of functions which return data for the schema.
const resolvers = {
Query: {
hello: () => 'world',
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
これを記述した後、ローカルでサーバーを立ち上げてから http://localhost:4000 にアクセスする。
$ node index.js
すると、以下のようなコンソールが立ち上がる。
ここで左側に GraphQL のクエリを入力して中央の実行ボタンを押せば、右側にその結果が出てくる。 ここではクエリを投げて、GraphQL からのレスポンスを受け取ってみる。
query {
hello
}
というクエリを発行すると、
{
"data": {
"hello": "world"
}
}
というレスポンスが返ってくる。
これで、まずは最初のステップが実施できた。
ここでは query { hello }
と書いているが、以下で説明されている通り条件を満たす場合は query
は省略できる(以後、適時省略を用いる)。
Specifying the query keyword and an operation name is only required when a GraphQL document defines multiple operations. We therefore could have written the previous query with the query shorthand:
https://github.com/graphql/graphql-spec/blob/main/README.md から引用
GraphQLの CRUD 操作
データを追加してからクエリを発行する
index.js
を以下の通り修正して、簡単なユーザーデータを取得できるようにする。
// The GraphQL schema
const typeDefs = gql`
type User {
name: String!
age: Int!
}
type Query {
hello: String
user(name: String!): User
users: [User]
}
`;
const users = [
{ name: 'Sample User1', age: 20 },
{ name: 'Sample User2', age: 30 },
{ name: 'Sample User3', age: 40 },
]
// A map of functions which return data for the schema.
const resolvers = {
Query: {
hello: () => 'world',
user: (parent, args, context, info) => {
return users.find(u => u.name === args.name);
},
users: () => users,
},
};
typeDefs
にデータ型 User
を定義し、この User
型の値を取得できるようにしている。 ここで gql
を使っていて、この構文を全く見たことがなかったのだが、テンプレートリテラルの「タグ付きテンプレート」と言うらしい。
仕組み的には関数 fx
を fx(args)
ではなく fx`args`
という形で書いても呼び出すことができる。 ただし後半の `args`
部分はテンプレート文字列。 なので、 fx`my name is ${name}`
などの形で文字列を渡す時のみ利用できる。 より詳細な説明は以下を参考。
実際にクエリを発行してみる。
{
hello
}
{
"data": {
"hello": "world"
}
}
{
users {
name, age
}
}
{
"data": {
"users": [
{
"name": "Sample User1",
"age": 20
},
{
"name": "Sample User2",
"age": 30
},
{
"name": "Sample User3",
"age": 40
}
]
}
}
{
user(name: "Sample User1") {
age
}
}
{
"data": {
"user": {
"age": 20
}
}
}
このように、新しく定義した type Query
内に定義した users
や user
を利用できている。
ところで、SQL になれている人間からすると、例えば User の全項目を取得したい場合に逐一フィールドを記述したくないと考えると思う。 そのため、以下のようなクエリを打ちたくなる。
# 全ユーザーの全属性を取得したい
{
users
}
# 全ユーザーの全属性を取得したい
{
users { * }
}
しかし、これらのクエリは期待通りの値を返さない。
例1の場合 Field \"users\" of type \"[User]\" must have a selection of subfields. Did you mean \"users { ... }\"?"
というエラーメッセージが返される。 エラーメッセージに記載がある通り、子要素(属性)を持つ値(プリミティブではない値)を問い合わせる場合、明確にどの属性が必要かを問い合わせ時に明記する必要がある ( hello
はプリミティブな文字列を返すため、{ hello }
は有効)。
例1が失敗するなら、例2のようにワイルドカードで表記を簡略化したいと考える私と同じ思考の人もいるだろう。 しかし、これは構文エラーとなる。 ワイルドカードの導入は GraphQL 実装では議論されてはいるが予定はないそうだ。
後述する Fragment を利用することである程度の共通化は可能だが、その場合も必要な項目を一度は記載する必要がある。
条件付きのデータを取得する
先の例3では、name
が一致するユーザーを取得した。 もちろん、これも条件付きのデータ取得( name
が完全一致するユーザーを取得する条件付きのデータ取得) なのだが、もっと複雑な条件を持つデータ、例えば「年齢が一定以下/以上のユーザーを取得する」にはどうすれば良いだろうか?
最も簡単なのは「利用者側ですべて取得して、利用者側でフィルターをかけて利用するデータを取捨選択する」だが、データ通信量が増えてしまうため好ましくない。
そのため、こういった場合は検索条件用のデータ構造を作るなどの方法を取る。
ここでは input UserAgeFilter
が検索用のデータ構造になる。
type UserAgeFilter
のように type
で定義したものは出力用の型定義であり、input
で定義したものは入力用の型定義である。 Query の引数として input
は利用できるが type
は利用できない。
// The GraphQL schema
const typeDefs = gql`
input UserAgeFilter {
gt: Int
lt: Int
}
type User {
name: String!
age: Int!
}
type Query {
hello: String
users(name: String, ageFilter: UserAgeFilter): [User]
}
`;
const users = [
{ name: 'Sample User1', age: 20 },
{ name: 'Sample User2', age: 30 },
{ name: 'Sample User3', age: 40 },
]
// A map of functions which return data for the schema.
const resolvers = {
Query: {
hello: () => 'world',
users: (parent, args, context, info) => {
let us = users;
if (args.name) {
us = us.filter(u => u.name === args.name);
}
if (args.ageFilter) {
if (typeof args.ageFilter.gt !== 'undefined') {
us = us.filter(u => u.age > args.ageFilter.gt);
}
if (typeof args.ageFilter.lt !== 'undefined') {
us = us.filter(u => u.age < args.ageFilter.lt);
}
}
return us;
},
},
};
コードを見れば分かるが、users
に渡す引数の name
と ageFilter
を使ってデータのフィルタリングを実施している。 users
の利用例は以下の通り。
{
users (ageFilter: {gt: 29}) {
name, age
}
}
# 取得結果
# {
# "data": {
# "users": [
# {
# "name": "Sample User2",
# "age": 30
# },
# {
# "name": "Sample User3",
# "age": 40
# }
# ]
# }
# }
{
users (name: "Sample User1", ageFilter: {gt: 19, lt: 38}) {
name, age
}
}
# 取得結果
# {
# "data": {
# "users": [
# {
# "name": "Sample User1",
# "age": 20
# }
# ]
# }
# }
結論としては、入力に合わせて適切なデータを取得して返す resolver を実装する ということになる。 ここでは簡易的に lt
, gt
のみを実装したが、例えば lte
や gte
の実装や、文字列を渡して解析し、その結果によってフィルタをかけるような汎用実装を行うことも考えられる。
データの変更 (Create / Update / Delete)
これまではデータの取得 (Read) に焦点を当ててきたが、CRUD 操作の Read 以外であるデータの生成・更新・削除についてをどのようにすれば実現できるのかを見ていく。
データの変更には GraphQL の mutation
を利用する。 ここではデータを追加する場合の Mutation である add
を実装する。
// The GraphQL schema
const typeDefs = gql`
type User {
name: String!
age: Int!
}
type Query {
users: [User]
}
type Mutation {
add(name: String!, age: Int!): String
}
`;
const users = [
{ name: 'Sample User1', age: 20 },
{ name: 'Sample User2', age: 30 },
{ name: 'Sample User3', age: 40 },
]
// A map of functions which return data for the schema.
const resolvers = {
Query: {
users: (parent, args, context, info) => {
return users;
},
},
Mutation: {
add: (parent, args, context) => {
const u = { name: args.name, age: args.age };
users.push(u);
return `added ${u.name}`;
}
}
};
add
する場合の mutation 発行方法は以下の通り。 query の場合は省略可、と先に説明したが mutation は省略不可なので、問い合わせ文は mutation から始める。
mutation {
add(name: "Hoge", age: 10)
}
{
"data": {
"add": "added Hoge"
}
}
この状態で users
を取得すると、データの末尾に {name: "Hoge", age: 10}
となるユーザーが追加されている。
query {
users {
name, age
}
}
# {
# "data": {
# "users": [
# {
# "name": "Sample User1",
# "age": 20
# },
# {
# "name": "Sample User2",
# "age": 30
# },
# {
# "name": "Sample User3",
# "age": 40
# },
# {
# "name": "Hoge",
# "age": 10
# }
# ]
# }
# }
今回の実装では User データは永続化されていないので、アプリケーションを再起動すると追加したデータは元に戻る。 しかし、このデータを外部のファイルやデータベースなどに保存し永続化すれば、アプリケーションを再起動しても追加したデータは残り続ける。
更新・削除の場合も mutation の resolver 実装が異なるだけでやり方は同じ。
発展的なデータの取得方法
ここでは 実際に GraphQL で遭遇しそうな、少し複雑な問い合わせについてどうなるかを見ていく。
1回のクエリで複数のデータを取得する
問い合わせ query 内に複数の query を列挙する場合、それらを1回のリクエストで取得できる。
query {
hello,
users { name },
users { age }
}
{
"data": {
"hello": "world",
"users": [
{
"name": "Sample User1",
"age": 20
},
{
"name": "Sample User2",
"age": 30
},
{
"name": "Sample User3",
"age": 40
}
]
}
}
戻り値のフィールド名を指定する (エイリアス)
ここまで、戻り値の結果は data
というオブジェクト内にあるクエリ名と同じフィールドに設定されていた。 しかし、GraphQLではフィールド名を呼び出し側が指定することができる。 GraphQLの文脈ではこれを Alias と呼ぶ。
具体的な利用例は以下の通り。
{
names: users { AreYou: name },
ages: users { Age: age }
}
{
"data": {
"names": [
{
"AreYou": "Sample User1"
},
{
"AreYou": "Sample User2"
},
{
"AreYou": "Sample User3"
}
],
"ages": [
{
"Age": 20
},
{
"Age": 30
},
{
"Age": 40
}
]
}
}
同様の方法で得られるフィールド内部の値まで別のエイリアスを貼ることもできる。
利用方法としては、findUser
というクエリがあった場合、loginUser: findUser(...)
のようにして利用者が都合の良い名前を設定することができるため、利用者側での命名の自由が増える。
同一型の値を取得する (Fragment)
ここでは以下のようなクエリを考える。
{
user: user (name: "Sample User1") { name, age },
bestFriend: user(name: "Sample User2") {name, age}
}
# {
# "data": {
# "user": {
# "name": "Sample User1",
# "age": 20
# },
# "bestFriend": {
# "name": "Sample User2",
# "age": 30
# }
# }
# }
ここでは同一の user
という resolver から値を取得しているのだが、その戻り値の指定に name
, age
と記載している。 ここでは2個なのでまだよいかもしれないが、フィールドと項目が増えた場合、同じフィールドを取得しようとしているかどうかがひと目で分からないし、もし修正があった場合は大変になる。
こういった場合の共通化の方法として Fragment と呼ばれる方法が用意されている。 上記のクエリは以下のように置き換えられる。
{
user: user (name: "Sample User1") { ...userField },
bestFriend: user(name: "Sample User2") { ...userField }
}
fragment userField on User {
name, age
}
クエリ変数と変数展開
これまでクエリの呼び出し時には具体的な値を含むクエリ (例: user(name: "Sample User1")
) を発行してきた。 しかし、クライアントアプリケーションからクエリ呼び出しを行おうとしてユーザーの入力を元に 単純連結で クエリを作ると、以下のような問題が生じる。
- 入力次第でクエリが成立しなくなる
- 入力内容により既存の構文を改ざんし 関係ないクエリ/ミューテーションが発行できる可能性がある (=SQL Injectionと同じようなことが起こる)
これらの解消方法として、GraphQL では「構文」と「変数」を分割して問い合わせを行う方法がある。 構文内には $
から始まる変数を定義し、実行時に同時に渡した変数を展開してくれる。 SQLに馴染みのある人にはプレースホルダ構文(変数が入る場所に $?
などを埋め込み、関数実行時にSQLのテンプレートとは別に展開用の引数を渡す仕組み)だと思えば分かりやすいと思う。
Apollo Server で実施する場合、画面の左下に Query Variables を入力するところがあり、ここには JSON 形式で入力する。
上記コンソール内に書かれている内容を改めて書くと、
query ($name: String!){
user(name: $name) {
name, age
}
}
{
"name": "Sample User1"
}
{
"data": {
"user": {
"name": "Sample User1",
"age": 20
}
}
}
データ型の再帰定義について
GraphQL では型の再帰定義はできない。 そのため、例えば以下の friends
のようにある型の中に循環依存を作ることはできない。
type User {
name: String!
age: Int!
friends: [User]
}
まとめ
Apollo の resolver 実装を行い、実際にクエリを発行しながら CRUD 操作の実装の方法および実践的な構文 (Fragrment, Variables) についてを説明した。
実際に手を動かすことで、基本的な GraphQL の利用・実装方法は理解できたように思う。