前回に引き続き、LaravelにおけるGraphQL+Lighthouseの実装方法を紹介していきます。
ぜひ、GraphQL+Lighthouse(+Laravel)でAPI開発1(インストール方法・設定編)もご覧ください。
方針
前回のエントリでも記述しておりますが、今回は既存のシステムに途中からGraphQLでAPIを実装することになりました。また、Laravelのアプリケーション実装自体も旧システムの方針を引き継ぎ実装されたため、ORMの恩恵をあまり受けられないDBの設計となってしまっています。
そのため、今回はEloquentを用いない方針でGraphQLのサーバーサイド側を実装していくことにします。
またGraphQLにはQuery
、Mutation
と2種類のメソッドがありますが、今回はQuery
の実装メインの紹介です。
実装
1. スキーマの定義
前回のエントリでroutes/graphql/schema.graphql
にスキーマ定義ファイル(以下、型ファイル)の雛形ができていると思います。
こちらにクエリの型定義を記述していきましょう。
まずは、今回関係のない型定義を削除してしまいます。
#"A datetime string with format 'Y-m-d H:i:s', e.g. '2018-01-01 13:00:00'."
scalar DateTime @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime")
#"A date string with format 'Y-m-d', e.g. '2011-05-23'."
scalar Date @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\Date")
# ここから下削除
# ...
上の2つのscalar
はlighthouse
から提供されている型なので、残しても削除してしまっても問題ありません。
次にQuery
の型を定義しましょう。
type {
top: Top
}
Query
はtop
でTop
型の値を返します。
Top
型を次に定義します。
小さなアプリケーションの場合1つの型ファイルに全ての型を記述していっても良いですが、開発が進んでいくと型ファイル内がとんでもないことになりそうなので、別の型ファイルtop.graphql
を用意しましょう。今回適当にいくつか型を用意します。
type Top {
# nullを許容しない
user: User!
purchaseHistory: PurchaseHistory
# Shopが複数入った配列型
favoriteShops: [Shop]
}
type User {
id: Int!
name: String!
point: Int!
}
type PurchaseHistory {
timestamp: Date
histories: [History]
}
type Date {
year: Int
month: Int
day: Int
hour: Int
minute: Int
second: Int
}
type History {
shop: Shop
purchasedDate: Date
purchasedAmount: Int
}
type Shop {
shopId: Int!
shopName: String!
}
こちらのTop
型をroutes/graphql/schema.graphql
で読み込むために次の一文を追加します。
#import ./top.graphql
type {
top: Top
}
#
とimport
にスペースを空けてしまうと上手く動作しないので気をつけてください。
以上でスキーマの定義は終了です。
2. Queryの雛形生成
まずは下記のコマンドを実行して、Query
の雛形を作成しましょう。
php artisan lighthouse:query Top
# docker環境の場合 (dc=docker-compose, hoge=${service_name})
dc exec hoge php artisan lighthouse:query Top
すると、次のようなファイルが生成されます。
<?php
declare(strict_types = 1);
namespace App\Http\GraphQL\Queries;
class Top
{
/**
* Return a value for the field.
*
* @param null $rootValue Usually contains the result returned from the parent field. In this case, it is always `null`.
* @param array $args The arguments that were passed into the field.
* @param GraphQLContext|null $context Arbitrary data that is shared between all fields of a single query.
* @param ResolveInfo $resolveInfo Information about the query itself, such as the execution state, the field name, path to the field from the root, and more.
*
* @return mixed
*/
public function resolve($rootValue, array $args, GraphQLContext $context = null, ResolveInfo $resolveInfo): array
{
}
}
こちらは通常のController
にあたるものという認識で良いと思います。
名前空間
Http
配下に新規にGraphQL
というディレクトリがデフォルトで生成されますが、
/*
|--------------------------------------------------------------------------
| Namespaces
|--------------------------------------------------------------------------
|
| These are the default namespaces where Lighthouse looks for classes
| that extend functionality of the schema.
|
*/
'namespaces' => [
'models' => 'App\\Models',
'queries' => 'App\\Http\\GraphQL\\Queries',
'mutations' => 'App\\Http\\GraphQL\\Mutations',
'interfaces' => 'App\\Http\\GraphQL\\Interfaces',
'unions' => 'App\\Http\\GraphQL\\Unions',
'scalars' => 'App\\Http\\GraphQL\\Scalars',
],
上記の設定ファイル内で名前空間を指定すると任意の場所にファイルが生成されます。
Custom Query/Resolver
また、今回はtop
というクエリに対し対応する名前のクラスを雛形として生成しましたが、
php artisan lighthouse query:CustomQuery
として任意のクラス名で雛形を生成することも可能です。
その際はschema.graphql
内で対象のクエリに@field
ディレクティブを用いてresolver
を指定する必要があります。
type {
top: Top @field(resolver: "App\\GraphQL\\Queries\\CustomQuery@resolverMethodName")
# config/lighthouse.phpで指定した名前空間と同じ場合、省略記法が使える
# @field("CustomQuery@resolverMethodName")
}
3. Queryの実装
続いて、実際にリクエストをハンドルしレスポンスを返す部分を実装していきましょう。
php artisan lighthouse query:Top
を実行するとLaravelのController部分にあたる部分のQueryクラスが生成されます。GraphQLのエンドポイントにリクエストを投げるとresolve
というメソッドが実行されます。引数にはリクエストのコンテキスト情報などが入っています。
3.1 ダミーデータの準備
まずは、定義した型に対応する連想配列を返り値として準備しましょう。
<?php
declare(strict_types = 1);
namespace App\Http\GraphQL\Queries;
class Top
{
/**
* Return a value for the field.
*
* @param null $rootValue Usually contains the result returned from the parent field. In this case, it is always `null`.
* @param array $args The arguments that were passed into the field.
* @param GraphQLContext|null $context Arbitrary data that is shared between all fields of a single query.
* @param ResolveInfo $resolveInfo Information about the query itself, such as the execution state, the field name, path to the field from the root, and more.
*
* @return mixed
*/
public function resolve($rootValue, array $args, GraphQLContext $context = null, ResolveInfo $resolveInfo)
{
return [
'user' => [
'id' => 10,
'name' => 'GraphQL Taro',
'point' => 3000,
],
'purchaseHistory' => [
'timestamp' => [
'year' => 2019,
'month' => 2,
'day' => 10,
'hour' => 22,
'minute' => 10,
'second' => 0,
],
'histories' => [
[
'shop' => [
'shopId' => 100,
'shopName' => 'shop1',
],
'purchasedDate' => [
'year' => 2019,
'month' => 1,
'day' => 1,
'hour' => 10,
'minute' => 0,
'second' => 0,
],
'purchasedAmount' => 1000,
],
],
],
'favoriteShops' => [
['shopId' => 100, 'shopName' => 'shop1'],
['shopId' => 200, 'shopName' => 'shop2'],
],
];
}
}
基本的にはこのようなレスポンスを返すようなものであれば、どのようなクラス設計でも問題ありません。
Repositoryパターンを用いてデータベースやその他のストレージからデータを取得するなど、LaravelではEloquentなしでも自由な設計が可能です。DDD設計からドメイン毎にServiceクラスなどを用意しHistory
、Shop
などのモデルクラスを返すこともできるでしょう。
最終的なレスポンスの形だけ決めてしまえば、GraphQLのサーバーサイド開発はほとんど完了します。
3.2 カラム毎の処理
GraphQLの特徴であるクエリ毎に必要なデータを返す機能を実装する場合、全てのロジックプロセスを実行してからデータをフィルタリングするよりも、必要なプロセスのみを実行する方が好まれるでしょう。今回は下記のようなcolumnResolver
メソッドを用意し、クエリに含まれるカラムに対応したロジックを実行するための実装を行いました。
<?php
declare(strict_types = 1);
namespace App\Http\GraphQL\Queries;
use Throwable;
class Top
{
public const USER = 'user';
public const PURCHASE_HISTORY = 'purchaseHistory';
public const FAVORITE_SHOPS = 'favoriteShops';
public const COLUMNS = [
self::USER,
self::PURCHASE_HISTORY,
self::FAVORITE_SHOPS,
];
/** @var string[] */
protected $columns = [];
/** @var mixed[] */
protected $response = [];
/**
* Return a value for the field.
*
* @param null $rootValue Usually contains the result returned from the parent field. In this case, it is always `null`.
* @param array $args The arguments that were passed into the field.
* @param GraphQLContext|null $context Arbitrary data that is shared between all fields of a single query.
* @param ResolveInfo $resolveInfo Information about the query itself, such as the execution state, the field name, path to the field from the root, and more.
*
* @return mixed
*/
public function resolve($rootValue, array $args, GraphQLContext $context = null, ResolveInfo $resolveInfo)
{
$this->columns = $resolveInfo->getFieldSelection();
foreach (self::COLUMNS as $column) {
$this->resolveColumn($column);
}
return $this->response;
}
public function resolveColumn(string $name): void
{
$resolver = $this->getResolverName($name);
// リクエストに含まれるカラムのみを処理するためのフィルタ
if (!isset($this->columns[$name])) {
return;
}
try {
$this->response[$name] = $resolver();
} catch (Throwable $throwable) {
// Log残すなどの処理を含めても良い
$this->response[$name] = null;
}
}
public function getUser(): array
{
return [
'id' => 10,
'name' => 'GraphQL Taro',
'point' => 3000,
];
}
public function getPurchaseHistory(): array
{
// 省略
return [];
}
public function getFavoriteShops(): array
{
// 省略
return [];
}
private function getResolverName(string $name): string
{
return 'get' . ucwords($name);
}
}
以上でQueryの実装は終了です。まだまだこれから、GraphQLがより浸透し様々な実装方法が提案されるでしょう。私の方でも引き続き試行錯誤していこうと思います。
次回へ向けて
次回は今回実装したものに関して自作のエラーハンドリングを追加する場合について書いていこうと思います。