本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回のハッカーはmangano-itoさんと中岡大樹さんで、テーマは
GraphQLはAPIのためのクエリ言語です。クエリの柔軟性やスキーマで構造を記述できるメリットがあり、GitHubをはじめとした多くのWebサービスのAPIに採用されています。
本稿で解説すること
本稿では、Perlで実践的なGraphQL APIを開発する手法を解説します。スキーマのフィールドに対応するデータを返す関数であるリゾルバを中心に解説します。リゾルバに関連して、N+1問題を解決するためのデータローダ、パフォーマンスを改善するためのキャッシュレイヤも実装します。
GraphQLのスキーマやクエリのパースには、graphql-perlが提供するGraphQL::SchemaやGraphQL::Executionを使います。また、本稿のコードは、執筆時点
なお、本稿ではGraphQLの文法などについての解説は省きますので、ドキュメントや解説書などを参照してください。また、GraphQL APIの実装の前提としてPSGI
Perlで実装してみよう
それでは、PerlでGraphQL APIを実装してみましょう。本節では、クエリの実行に必要なリゾルバを実装します。
スキーマの設計
今回は、電子書籍サービスのAPIのためのスキーマを考えます。書籍を取得するクエリを考えてみましょう。書籍を表すBook
型と筆者を表すAuthor
型を作ります。
type Query {
book: Book!
}
type Book {
id: ID!
title: String!
author: Author!
}
type Author {
id: ID!
name: String!
}
最小の実装
App::GraphQL
モジュールを作り、スキーマのパースおよびリクエストされたクエリの実行を担当させます。
package App::GraphQL;
use open qw/:utf8/;
use File::Slurp;
use GraphQL::Schema;
use GraphQL::Language::Parser;
use GraphQL::Execution;
sub execute {
# $query: リクエストされたクエリ
# $variable: クエリに必要な変数
# $opname: 操作対象の指定 (operationName)
my ($class, $query, $variables, $opname) = @_;
# スキーマを読み込みパースする
my $schema = GraphQL::Schema->from_doc(
read_file('schema.graphql', binmode => ':utf8'),
);
# クエリをパースする
my $parsed_query = GraphQL::Language::Parser::parse(
$query
);
#(1)スキーマとクエリをもとに結果の取得を実行する
my $result = GraphQL::Execution::execute(
$schema, $parsed_query, {}, undef,
$variables, $opname, undef,
);
return $result;
}
1;
ここでは説明を省きますが、サーバとApp::GraphQL
をつなぎ込み、リクエストからquery
、variables
、operationName
パラメータを渡し、得られた結果をJSONにエンコードしてリクエストからレスポンスを得られるようにしてください。
リゾルバの実装
さて、本項ではそれぞれの型に対して個別にリゾルバのモジュールを作成します。
個別のリゾルバを実装する
App::GraphQL::Resolver::Book
モジュールを用意して、type Book
に対してはApp::GraphQL::Resolver::Book
モジュールを、type Query
に対してはApp::GraphQL::Resolver::Query
モジュールを担当させましょう。
package App::GraphQL::Resolver::Book;
# Book.titleのリゾルバ実装
sub title {
my ($class, $root_value, $args, $ctx, $info) = @_;
return 'My Book';
}
1;
コードは省略しますが、同様にして、Query
型のbook
フィールドに対応するApp::GraphQL::Resolver::Query
モジュールにbook
サブルーチンを作ってください。それぞれのtype
に応じたリゾルバをモジュールとして実装する必要があります。
動的にリゾルバを委譲する
ライブラリのデフォルトのリゾルバにすべての処理を追加すると、見通しが悪くなります。そこでデフォルトの実装を置き換えて、先ほど作成したモジュールにディスパッチしてリゾルバを委譲していきます。
use Class::Load qw(try_load_class);
sub _resolver {
my ($root_value, $args, $ctx, $info) = @_;
# 対象のフィールドの名前が得られる
my $field_name = $info->{field_name};
# 単純にHashRefのフィールドであれば値をそのまま返す
if (!blessed($root_value)
&& ref $root_value eq 'HASH'
&& exists $root_value->{$field_name}) {
return $root_value->{$field_name};
}
# 個別のリゾルバモジュールに委譲する
my $parent_name = $info->{parent_type}->name;
my $impl = join('::',
(__PACKAGE__, 'Resolver', $parent_name));
my ($loaded) = try_load_class($impl);
if ($loaded && $impl->can($field_name)) {
return $impl->$field_name(
$root_value, $args, $ctx, $info
);
}
# 解決できず何も得られなかった!
return undef;
}
委譲を実装しました。簡単にするために、HashRef
型のプロパティまたは導入された個別のリゾルバモジュールへのディスパッチのみに絞っています。
先ほどexecute
サブルーチンのGraphQL::Execution::execute
を実行していましたが、実は7番目の引数はリゾルバを指定するもので、未指定ではデフォルトの実装が使われるため、作成したリゾルバ実装の参照を渡して上書きします。
my $result = GraphQL::Execution::execute(
$schema, $parsed_query, {},
undef, $variables, $opname,
\&_resolver, # 今回作成したリゾルバ
);
こうして、自作のリゾルバ実装を使えました。
実際のデータ取得クエリの成功を確認する
次はBookを取得するクエリを実行してみましょう。
query {
book {
title
}
}
{
"data": {
"book": {
"title": "My Book"
}
}
}
data.
フィールドに対して値としてMy Book
が返ってきてリクエストに成功します。リゾルバ実装ができましたね。ここからさらに発展させて、実際にAPI構築ができます。
データローダによるN+1問題の解決
N+1問題とは、ループ中でデータソースに対する多数のクエリが発行される問題です。GraphQLではグラフをたどるクエリを書けるので、N+1問題の起こりやすさは想像に難くないでしょう。この問題を防ぐために、データローダと呼ばれるしくみがあります。
データローダでは、各リゾルバでのデータ取得を集約し、集約した各取得処理を束ねてバッチとして取得するしくみを実装します。
Promiseライブラリを使った遅延評価
各リゾルバではそれぞれの単一のデータの結果がないと処理を継続できません。この問題を解決するために、Promiseを導入します。
Promiseは、JavaScriptでよく知られた遅延評価を行う概念です。Promiseを使うことで、個々のリゾルバでデータが返ってきたかのように処理を継続でき、最終的に一括でデータ取得を行うことができます。
graphql-perlが要求するインタフェースに合うものとして、Promise::XS
ライブラリを使います。Promise::XS
を使うと、データローダは単一のキーのデータ取得処理を次のように書けます。
use Promise::XS;
# データローダを使った指定のキーのオブジェクトの取得
sub load {
my ($self, $key) = @_;
# すでに指定のキーの結果のPromiseがあれば使う
my $deferred = $self->{batch_map}->{$key};
# Promiseで個別のキーに対する結果を伝える
unless (defined $deferred) {
$deferred = Promise::XS::deferred();
$self->{batch_map}->{$key} = $deferred;
}
return $deferred->promise();
}
また、graphql-perl
がPromise
を処理できるように、Promise
のインタフェースを合わせて渡します。
my $result = GraphQL::Execution::execute(
$schema, $parsed_query, {},
$ctx, $variables, $opname,
\&_resolver,
+{
resolve => \&Promise::XS::resolved,
reject => \&Promise::XS::rejected,
all => sub {
Promise::XS::all(map {
(blessed($_) && $_->can('then'))
? $_ : Promise::XS::resolved($_);
} @_);
},
},
);
リゾルバでのデータローダの使用
既存のQuery.
フィールドのリゾルバについて、データローダを使うリファクタをしましょう。
まず、IDから対応するBook
型のデータを取得するため、複数のIDからバッチで取得する実装を用いるデータローダを定義します。
package App::GraphQL::Resolver::Query;
use App::Repository::Book;
# IDによる取得を行うデータローダ
sub book_by_id_data_loader {
my ($class, $ctx) = @_;
return $ctx->create_data_loader('book_by_id', sub {
my ($ids) = @_;
my $result_map = App::Repository::Book
->get_by_ids($ids);
return $result_map;
});
}
次に、リゾルバで今まで単一のIDから取得する実装を直接使っていたところを、データローダを経由して取得するように変更します。
# Query.bookのリゾルバ実装
sub book {
my ($class, $root_value, $args, $ctx, $info) = @_;
return $class->book_by_id_data_loader($ctx)
->load($args->{id});
}
結果として、発行されるSQLは以下となり、データローダ未使用の場合は複数回で取得していたものが、IN
句により1回で取得できました。
SELECT * FROM book WHERE id IN (1, 2, 3);
<続きの