Perl Hackers Hub

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

<前回(1)こちら。>

発展的な型の実装

前節までは仕様で定義されたスカラ型のID型などを使ってきましたが、アプリケーション側で定義が必要なインタフェースを使えるようにすることで、スキーマ上でアプリケーションの特性を表現しやすくなります。

インタフェースの定義

GraphQLで一般的なNodeインタフェースを実装します。Nodeインタフェースはid: ID!フィールドを持つインタフェースで、オブジェクトを一意なIDによって取得することが容易になります。Book型とAuthor型でNodeインタフェースを実装しましょう。

コード schema.graphql
type Query {
    # 略
    node(id: ID!): Node
}

interface Node {
    id: ID!
}

# Authorも同様にNodeを実装する
type Book implements Node {
    id: ID!
    # 略
}

加えて、idの値からNodeインタフェースをユニークに取得できるQuery.node(id: "ID")フィールドを追加しています。

インタフェースの解決

スキーマ定義だけではインタフェースは解決できません。定義したインタフェースの具体的な型が、どれに対応するかを示す型解決の実装が必要になります。今回で言うと、先ほど定義したNodeインタフェースからBook型やAuthor型に落とし込むための実装が必要です。

インタフェース解決を委譲するモジュールを指定する

アプリケーションの名前空間にあるインタフェース別のモジュールを参照し、解決するモジュールを作ります。

コード App/GraphQL/Type/Interface.pm
package App::GraphQL::Type::Interface;
use Class::Load qw(try_load_class);
use GraphQL::Type::Interface;

sub from_ast {
    my ($class, $name2type, $ast_node) = @_;
    # 対象のインタフェース名を取得して
    # 個別の実装に型解決を委譲
    my $name = $ast_node->{name};
    my $delegate = __PACKAGE__ . '::' . $name;
    my ($loaded, $error) = try_load_class($delegate);
    unless ($loaded && $delegate->can('resolve_type')) {
        die 'No Interface Implementation for ' . $name;
    }
    return GraphQL::Type::Interface->new(
        GraphQL::Type::Interface
            ->_from_ast_named($ast_node),
        GraphQL::Type::Interface->_from_ast_fields(
            $name2type, $ast_node, 'fields',
        ),
        resolve_type => sub {
            $delegate->resolve_type(@_);
        },
    );
}
1;

作成したモジュールをGraphQL::Schemaでのスキーマ生成時に指定します。

コード App/GraphQL.pm
# 略
my $schema = GraphQL::Schema->from_doc(
    $schema_file,
    +{
        %GraphQL::Schema::KIND2CLASS,
        # インタフェースで型解決を個別に委譲可能に
        interface => 'App::GraphQL::Type::Interface',
    },
);
# 略

Nodeインタフェースの型解決を実装する

委譲の実装を行ったうえで、今回のNodeインタフェースの型解決を実装します。Nodeの具象型がidの値に含まれるとすると、idの値から型情報を抜き出して実際の型を返してあげる実装を行うことで、ライブラリ側でインタフェースから具体的な型を解決できます。

コード App/GraphQL/Type/Interface/Node.pm
package App::GraphQL::Type::Interface::Node;

sub resolve_type {
    my ($class, $value, $ctx, $info, $type_node) = @_;
    my $args = $info->{field_nodes}->[0]->{arguments};
    my $id = ref($args->{id}) eq 'SCALAR'
        ? $info->{variable_values}->{id}->{value}
        : $args->{id};
    my ($type, ) = split '::', $id;

    return $type;
}
1;

インタフェース解決の確認

こうして型の解決ができるようになったので、Query.nodeフィールドのリゾルバを実装してみます。

コード App/GraphQL/Resolver/Query.pm
sub node {
    my ($class, $root_value, $args, $ctx, $info) = @_;
    my $id = $args->{id};
    my ($type, ) = split '::', $id;

    if ($type eq 'Book') {
        return App::Repository::Book->get_by_id($id);
    } elsif ($type eq 'Author') {
        return App::Repository::Author->get_by_id($id);
    }
    die "Invalid Interface";
}

Query.nodeフィールドに対するクエリ結果から、実際の型に基づいていることが確認できます。

コード クエリ
query {
  node(id: "Book::2") {
    __typename
    id
    ... on Book {
        title
    }
  }
}
コード クエリの結果
{
  "data": {
    "node": {
      "title": "Book2",
      "__typename": "Book",
      "id": "Book::2"
    },
  }
}

キャッシュによるパフォーマンス改善

GraphQLはキャッシュが難しい印象を多くの人が持っているかもしれません。実際、REST APIなどのエンドポイントベースのAPIとは違った戦略が必要になります。

クエリに対するレスポンスのキャッシュ

graphql-perlではキャッシュのしくみを手作りする必要があります。クエリ全体に対するレスポンスをキャッシュする方法と、フラグメント単位や型単位でキャッシュする方法が考えられますが、今回は単純な前者のキャッシュ実装を紹介します。

クエリのハッシュをもとにレスポンスを保存する

一番簡単なのは、クエリのハッシュをもとにレスポンスを保存しておく方法です。今回はキャッシュのバックエンドとしてよく使われるインメモリデータベースであるRedisを使用します。

コード App/GraphQL/Cache.pm
package App::GraphQL::Cache;
use Digest::SHA1 qw(sha1_hex);
use JSON::XS;
use Redis::Fast;
use constant KEY => 'cache';
use constant SERVER => 'redis:6379';

sub fetch_cache {
    my ($class, $payload) = @_;
    my $cache_key = $class->_generate_key($payload);
    my $redis = Redis::Fast->new(server => SERVER);
    my $serialized_response = $redis->get($cache_key);
    return eval { decode_json($serialized_response) };
}

sub set_cache {
    my ($class, $payload, $response) = @_;
    my $cache_key = $class->_generate_key($payload);
    my $serialized_response = encode_json($response);
    my $redis = Redis::Fast->new(server => SERVER);
    $redis->set($cache_key, $serialized_response);
}

sub _generate_key {
    my ($class, $payload) = @_;
    my $serialized_payload = JSON::XS->new
        ->utf8(1)->canonical->encode($payload);
    return KEY . ':' . sha1_hex($serialized_payload);
}
1;

クエリキャッシュを実装に組み込んで高速化する

クエリ実行時にキャッシュの確認と保存を行い、キャッシュが有効な場合はクエリの結果を高速に返せるようにします。

コード App/GraphQL.pm
use App::GraphQL::Cache;
sub execute_with_cache {
    my ($class, $payload) = @_;
    my $response = App::GraphQL::Cache
        ->fetch_cache($payload);
    unless (defined $response) {
        $response = $class->execute(
            $payload->{query}, $payload->{variables},
            $payload->{operationName},
        );
        App::GraphQL::Cache
            ->set_cache($payload, $response);
    }
    return $response;
}

なお、実用的なキャッシュのためには、適切な生存期間の設定、キャッシュしてはいけないプライベートなレスポンスの除外のしくみが必要です。たとえば、データの操作が発生するMutationはキャッシュしたくないですよね。紙面では解説を省きますので、詳しくはサンプルコードをご覧ください。

既知のクエリを事前にキャッシュするテクニック

本項では、クエリのキャッシュを利用した実用的なテクニックを紹介します。

アプリケーションの特性的にクエリが固定で予期できるサービスでは、定期的にキャッシュを作成するユースケースは珍しくありません。前項で作ったクエリのキャッシュを使い、定期的なキャッシュ作成のバッチ処理を行うと、ユーザーに常にキャッシュされたレスポンスを返すことができます。

リクエストを保存する

事前キャッシュの対象となるリクエストの保存の実装を考えます。Redisのソート済みセット型であるZSET型を使い、リクエストの多いものからソートしつつユニークにもします。

コード App/GraphQL/Repository/Request.pm
package App::GraphQL::Repository::Request;
use Encode qw(encode_utf8 decode_utf8);
use JSON::XS qw(decode_json encode_json);
use Redis::Fast;
use constant KEY => 'request_log';
use constant SERVER => 'redis:6379';

sub save {
    my ($class, $payload) = @_;
    my $json = encode_utf8(encode_json($payload));
    my $redis = Redis::Fast->new(server => SERVER);
    $redis->zIncrBy(KEY, 1, $json);
}


sub get_all {
    my $redis = Redis::Fast->new(server => SERVER);
    return [map {
        eval { decode_json(decode_utf8 $_) }
    } $redis->zRevRange(KEY, 0, -1)->@*];
}
1;

この解説では考慮しませんが、実用的には、キャッシュ可否の判定やユーザー体験とデータの更新性を損なわない程度の生存期間を設定する必要があります。

保存されたリクエストのキャッシュを作成する

作成したリクエストログから、リクエスト数の多い順にキャッシュを作成します。

my $requests = App::GraphQL::Repository::Request
    ->get_all();
for my $request ($requests->@*) {
    App::GraphQL->execute_with_cache($request);
}

ここではループで直列に処理していますが、実運用ではワーカーで処理を分散して実行すると処理時間面で有効です。

Persisted Query ─⁠─ ハッシュでリクエストし効率と安全性を高める

クエリとハッシュの対応をストアに持つようにすれば、ハッシュとoperationNamevariablesパラメータだけを指定することで、クエリ本文を与えずとも実行できます。このアイデアはPersisted Queryと呼ばれています。

Persisted Queryでは、クエリはSHA256のハッシュとして指定されます。ハッシュをクエリ本文の代わりに受け取り実行する実装をしてみましょう。

コード App/GraphQL.pm
sub execute_persisted_query {
    my ($class, $opname, $variables, $extensions) = @_;
    # 保存されたPersisted Queryがあればそれを使う
    my $hash = $extensions->{persistedQuery}
        ->{sha256Hash} // '';
    my $query = App::GraphQL::Repository::Query
        ->restore($hash);
    if ($query) {
        return $class->execute_with_cache(+{
            query => $query,
            variables => $variables,
            operationName => $opname,
        });
    }
    # 見つからなければ指定のエラーを返す
    return +{errors => [+{
        message => 'No Persisted Query Available',
        extensions => +{
            code => 'PERSISTED_QUERY_NOT_FOUND'
        },
    }]};
}

こうすれば、該当するハッシュ値のPersisted Queryが保存されている場合は、ハッシュ値でクエリを指定できます。/graphql?extensions={"persistedQuery":{ "sha256Hash": "<ハッシュ値>" }}&variables=...&operation_name=...の形式でのGETリクエストが現実的になり、リクエストのペイロードが大幅に削減されます。

メリットとしてはほかにも、Varnishなどのミドルウェアが活用できることや、保存されたクエリしか実行できないようにすればセキュリティが高まることなどが挙げられます。

まとめ

今回は、Perlで実践的なGraphQL APIを作成しました。みなさんも、PerlでGraphQLを採用してください。

さて、次回の執筆者は藤浪大弥さんで、テーマは「正規表現の脆弱性『ReDoS』徹底解説」です。お楽しみに。

おすすめ記事

記事・ニュース一覧