Skip to content

GigaのGraphQL実装(Perl版)

エンドポイントからリゾルバの流れ

Section titled “エンドポイントからリゾルバの流れ”

ルーティングの定義は lib/Giga/Web/Media/CommonRoutes.pmregister で実行している。GraphQLのルーティングに関する主な定義は、おそらくだが以下の2つがある。どちらも同じ関数を実行するが与えるパラメータが異なる。

sub register {
POST '/graphql' => 'Giga::Web::Core::GraphQL#execute' => {
through_csrf => 1,
can_login => 1
};
POST '/api/v1/graphql' => 'Giga::Web::Core::GraphQL#execute' => {
through_csrf => 1,
user_required => 1
};
}

execute 関数がやっていることはコンテキストを作ってクエリを実行するだけ。

sub execute {
my ($class, $c) = @_;
my $payload = $c->req->payload // +{};
my $graphql_context = $class->_build_graphql_context($c);
my ($res, $cache_hit) = $class->_execute_query_with_cache($c, $graphql_context, $payload);
return $c->json($res);
}
sub _execute_query_with_cache {
# キャッシュの検査や保存などを行っているが本質的な処理はこのくらい
my $res = $class->_execute_query($graphql_context, $payload);
return ($res, !!0);
}
sub _execute_query {
my $res = Giga::GraphQL::Execution->execute(
graphql_context => $graphql_context,
query => $payload->{query} // '',
variables => $payload->{variables} // {},
operation_name => $payload->{operationName} // '',
is_read_only => $is_read_only,
);
return $res;
}

ここで lib/Giga/GraphQL/Execution.pmexecute が呼び出されている。これがGraphQL処理の本質的なロジックとなる。Perlで作るGraphQL APIの記事に書かれている内容と類似しているので詳細は省略するが、途中で GraphQL::Execution::execute が呼び出されて graphql-perl ライブラリの処理へと移る。

sub execute {
$result = GraphQL::Execution::execute(
$schema,
$parsed_query,
$root_value,
$graphql_context,
$variables,
$operation_name,
sub { _fields_resolver_with_handler($graphql_context, @_) },
{
all => \&collect,
resolve => \&_resolve,
reject => sub { _reject($graphql_context, @_) },
},
);
}
sub _resolve {
Giga::Util::Promise::resolved(@_);
}
# これがリゾルバのようだが...
sub _fields_resolver {
}

どうやら、ファイル名とセレクタを接続しているのは _fields_resolver が行っていて、Querylib/Giga/GraphQL/Resolver/Query 以下のファイル名がセレクタ名と対応している。例えば SeriesBannerGroup 型の seriesBannerGroup を解決する際には SeriesBannerGroup.pmexecute が処理されるらしい。Perlのモジュールは try_load_class を使って動的ロードするので、この関数を探せばそれっぽい処理が見つかる。

Query と同様に、Mutationlib/Giga/GraphQL/Resolver/Mutation 以下にある。

リポジトリの lib/Giga/GraphQL/Resolver 以下には、いわゆるサブリゾルバが置かれている。基本的に Query 以下のモジュールから呼ばれる。

リポジトリの lib/Giga/GraphQL/Resolver/Query 以下で Query 直下のセレクタが実装されている。上で見たように、セレクタとモジュールファイルが対応している。

リポジトリの lib/Giga/GraphQL/Resolver/Mutation 以下で Mutation 直下のセレクタが実装されている。おそらく Query と同じ仕組みだろう。

リポジトリの lib/Giga/GraphQL/Loader 以下で実装されているいわゆるデータローダーで、一般的には id を受け取りそのエンティティを返す関数。具体的には、以下のような関数が Resolver または Mutation に実装されていて、これが Loader を呼び出す。

package Giga::GraphQL::Resolver::Caller;
sub execute {
my $id = $args->{id};
my ($data, _) = from_global_id($id);
my $databaseId = $data->{databaseId};
return Giga::GraphQL::Loader::Something->find_by_id($databaseId)
}

最後の行で find_by_id を呼び出しているけれど、これが Loader の提供する関数となる。よくある実装では複数の ids を受け取ることが多いと思うが、ここでは単一の id らしい。

スキーマの記述パターンを例示する。

明確な型が定義されているケース

Section titled “明確な型が定義されているケース”

スキーマ上では type を使って型定義がされているけれども、Perlのコード上では必ずしも型がモジュールになっている訳ではなく、簡単なセレクタの場合は親の型モジュールから呼び出されているだけの実装となっている。具体的には以下の定義があるとして、

union Banner = YouTubeBanner | ImageBanner | TextBanner
type TextBanner {
databaseId: String!
text: String!
linkUrl: String
openAt: DateTime!
isExternalLinkUrl: Boolean!
}
type Series implements Node & ReadableProductParent {
bannerGroup(groupName: String): [Banner!]!
}

ここで bannerGroup を解決するリゾルバは次のような実装になっている。

package Giga::GraphQL::Resolver::Series;
sub banner_group {
# 型の理解に関係がある分部だけを抜粋
if ($_->type eq 'text') {
Giga::GraphQL::Type::TextBanner->new(
id => $_->id,
text => $_->text,
link_url => $_->link_url,
open_at => $_->open_at,
);
} elsif ($_->type eq 'image') {
Giga::GraphQL::Type::ImageBanner->new(...);
} elsif ($_->type eq 'youtube') {
Giga::GraphQL::Type::YouTubeBanner->new(...);
}
}

上記で使われている TextBanner 型は次のような形なので、これでスキーマとの整合性を保っているらしい。

package Giga::GraphQL::Type::TextBanner;
use Giga::Util::ClassAccessorTyped (
new => 1,
ro => {
id => 'Id',
text => 'Str',
link_url => 'Maybe[NonEmptyStr]',
open_at => 'Giga::DateTime',
},
);

次にPerl上で型が定義されておらず動的に返している場合。

type Series implements Node & ReadableProductParent { ... }
type SeriesSlice {
totalCount: Int!
seriesList: [Series!]!
}
type ComicdaysSeriesCampaign {
title: String!
seriesSlice(first: Int): SeriesSlice!
}

このリゾルバは lib/Giga/GraphQL/Resolver/ComicdaysSeriesCampaign.pm にあるのだが、ここでは型を用意せずにオブジェクトを返していることが読める。

package Giga::GraphQL::Resolver::ComicdaysSeriesCampaign;
sub series_slice {
my $series_list = ...;
my $total_count = ...;
return +{
total_count => $total_count,
series_list => $series_list,
};
}

複数のモジュールで構成される型

Section titled “複数のモジュールで構成される型”

ところで同じ名前のモジュールは lib/Giga/GraphQL/Type/ComicdaysSeriesCampaign.pm にもあって、例えば title などは Type 以下のモジュールで定義されているので、この2つは合成されているようにみえるが実際は _fields_resolver で動的にフィールドを解決している。

実際の処理としては、_fields_resolver の引数で $root_value があるけれど、ここには「親のフィールドが返した値またはルートの場合は {} が渡されてくる。そのうえで次の処理が行われる。

  1. Giga::GraphQL::Resolver::Query::(TypeName)execute があれば実行結果を返す
  2. Giga::GraphQL::Resolver::Mutation::(TypeName)execute があれば実行結果を返す
  3. GraphQL::Resolver::(TypeName)(field名の関数) があれば実行結果を返す
  4. $root_value 引数が (field名のメソッド) を持っているならそれを実行結果を返す
  5. $root_value がハッシュ値なら (field名) のキーを返す

[! Tip] 上記から、ルートとなる Query または Mutation には必ずフィールドに対応するモジュールファイルが存在することが分かる

合成されているようにみえるのは $root_valueType:: 以下のモジュール型のときに「(field名のメソッド) を持っている」分部と「Resolver::(TypeName)field名の関数 がある」分部が解決されることで行われている。

このディレクティブが付与されたセレクタは、特定の機能を持ったメディアにだけ有効化されることを意味する。例えば以下の定義では、

type AnswerSeriesReadingCompletionEnquetePayload { ... }
answerSeriesReadingCompletionEnquete(
input: AnswerSeriesReadingCompletionEnqueteInput!
): AnswerSeriesReadingCompletionEnquetePayload!
@hasFeature(features: ["SeriesReadingCompletionEnquete"])

この場合、answerSeriesReadingCompletionEnquete はシリーズ読了アンケート機能があるメディアの場合だけ有効となる。無効な場合にどのような対処となるかは Execution.pm に書かれている。