Perl Hackers Hub

第73回Perlで作るGraphQL API ~graphql-perlを使って実装してみよう!(1)

本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回のハッカーはmangano-itoさんと中岡大樹さんで、テーマは「Perlで作るGraphQL API」です。

GraphQLはAPIのためのクエリ言語です。クエリの柔軟性やスキーマで構造を記述できるメリットがあり、GitHubをはじめとした多くのWebサービスのAPIに採用されています。

本稿で解説すること

本稿では、Perlで実践的なGraphQL APIを開発する手法を解説します。スキーマのフィールドに対応するデータを返す関数であるリゾルバを中心に解説します。リゾルバに関連して、N+1問題を解決するためのデータローダ、パフォーマンスを改善するためのキャッシュレイヤも実装します。

GraphQLのスキーマやクエリのパースには、graphql-perlが提供するGraphQL::SchemaGraphQL::Executionを使います。また、本稿のコードは、執筆時点(2022年5月)の最新であるPerl 5.34.1とGraphQL 0.54で動作確認を行っています。本稿のサンプルコードは、本誌サポートサイトから入手できます。以降で省略しているコードはこちらを参照してください。

なお、本稿ではGraphQLの文法などについての解説は省きますので、ドキュメントや解説書などを参照してください。また、GraphQL APIの実装の前提としてPSGIPerl Web Server Gateway Interfaceサーバを用意しておく必要があります。

Perlで実装してみよう

それでは、PerlでGraphQL APIを実装してみましょう。本節では、クエリの実行に必要なリゾルバを実装します。

スキーマの設計

今回は、電子書籍サービスのAPIのためのスキーマを考えます。書籍を取得するクエリを考えてみましょう。書籍を表すBook型と筆者を表すAuthor型を作ります。

コード schema.graphql
type Query {
    book: Book!
}

type Book {
    id: ID!
    title: String!
    author: Author!
}

type Author {
    id: ID!
    name: String!
}

最小の実装

App::GraphQLモジュールを作り、スキーマのパースおよびリクエストされたクエリの実行を担当させます。

コード App/GraphQL.pm
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をつなぎ込み、リクエストからqueryvariablesoperationNameパラメータを渡し、得られた結果をJSONにエンコードしてリクエストからレスポンスを得られるようにしてください。

リゾルバの実装

さて、本項ではそれぞれの型に対して個別にリゾルバのモジュールを作成します。

個別のリゾルバを実装する

App::GraphQL::Resolver::Bookモジュールを用意して、type Bookに対してはApp::GraphQL::Resolver::Bookモジュールを、type Queryに対してはApp::GraphQL::Resolver::Queryモジュールを担当させましょう。

コード App/GraphQL/Resolver/Book.pm
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に応じたリゾルバをモジュールとして実装する必要があります。

動的にリゾルバを委譲する

ライブラリのデフォルトのリゾルバにすべての処理を追加すると、見通しが悪くなります。そこでデフォルトの実装を置き換えて、先ほど作成したモジュールにディスパッチしてリゾルバを委譲していきます。

コード App/GraphQL.pm
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サブルーチンの(1)GraphQL::Execution::executeを実行していましたが、実は7番目の引数はリゾルバを指定するもので、未指定ではデフォルトの実装が使われるため、作成したリゾルバ実装の参照を渡して上書きします。

コード App/GraphQL.pm
my $result = GraphQL::Execution::execute(
    $schema, $parsed_query, {},
    undef, $variables, $opname,
    \&_resolver, # 今回作成したリゾルバ
);

こうして、自作のリゾルバ実装を使えました。

実際のデータ取得クエリの成功を確認する

次はBookを取得するクエリを実行してみましょう。

コード クエリ
query {
    book {
        title
    }
}
コード クエリの結果
{
  "data": {
    "book": {
      "title": "My Book"
    }
  }
}

data.book.titleフィールドに対して値としてMy Bookが返ってきてリクエストに成功します。リゾルバ実装ができましたね。ここからさらに発展させて、実際にAPI構築ができます。

データローダによるN+1問題の解決

N+1問題とは、ループ中でデータソースに対する多数のクエリが発行される問題です。GraphQLではグラフをたどるクエリを書けるので、N+1問題の起こりやすさは想像に難くないでしょう。この問題を防ぐために、データローダと呼ばれるしくみがあります。

データローダでは、各リゾルバでのデータ取得を集約し、集約した各取得処理を束ねてバッチとして取得するしくみを実装します。

Promiseライブラリを使った遅延評価

各リゾルバではそれぞれの単一のデータの結果がないと処理を継続できません。この問題を解決するために、Promiseを導入します。

Promiseは、JavaScriptでよく知られた遅延評価を行う概念です。Promiseを使うことで、個々のリゾルバでデータが返ってきたかのように処理を継続でき、最終的に一括でデータ取得を行うことができます。

graphql-perlが要求するインタフェースに合うものとして、Promise::XSライブラリを使います。Promise::XSを使うと、データローダは単一のキーのデータ取得処理を次のように書けます。

コード App/GraphQL/DataLoader.pm
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-perlPromiseを処理できるように、Promiseのインタフェースを合わせて渡します。

コード App/GraphQL.pm
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.bookフィールドのリゾルバについて、データローダを使うリファクタをしましょう。

まず、IDから対応するBook型のデータを取得するため、複数のIDからバッチで取得する実装を用いるデータローダを定義します。

コード App/GraphQL/Resolver/Query.pm
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から取得する実装を直接使っていたところを、データローダを経由して取得するように変更します。

コード App/GraphQL/Resolver/Query.pm
# 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);

<続きの(2)こちら。>

おすすめ記事

記事・ニュース一覧