19
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

GraphQL+Lighthouse(+Laravel)でAPI開発2(開発編1 - 非Eloquentモデルの実装)

Last updated at Posted at 2019-02-10

前回に引き続き、LaravelにおけるGraphQL+Lighthouseの実装方法を紹介していきます。
ぜひ、GraphQL+Lighthouse(+Laravel)でAPI開発1(インストール方法・設定編)もご覧ください。

方針

前回のエントリでも記述しておりますが、今回は既存のシステムに途中からGraphQLでAPIを実装することになりました。また、Laravelのアプリケーション実装自体も旧システムの方針を引き継ぎ実装されたため、ORMの恩恵をあまり受けられないDBの設計となってしまっています。

そのため、今回はEloquentを用いない方針でGraphQLのサーバーサイド側を実装していくことにします。
またGraphQLにはQueryMutationと2種類のメソッドがありますが、今回はQueryの実装メインの紹介です。

実装

1. スキーマの定義

前回のエントリでroutes/graphql/schema.graphqlにスキーマ定義ファイル(以下、型ファイル)の雛形ができていると思います。

こちらにクエリの型定義を記述していきましょう。

まずは、今回関係のない型定義を削除してしまいます。

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つのscalarlighthouseから提供されている型なので、残しても削除してしまっても問題ありません。

次にQueryの型を定義しましょう。

routes/graphql/schema.graphql
type {
    top: Top
}

QuerytopTop型の値を返します。

Top型を次に定義します。
小さなアプリケーションの場合1つの型ファイルに全ての型を記述していっても良いですが、開発が進んでいくと型ファイル内がとんでもないことになりそうなので、別の型ファイルtop.graphqlを用意しましょう。今回適当にいくつか型を用意します。

routes/graphql/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で読み込むために次の一文を追加します。

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

すると、次のようなファイルが生成されます。

app/Http/GraphQL/Queries/Top.php
<?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というディレクトリがデフォルトで生成されますが、

config/lighthouse.php
    /*
    |--------------------------------------------------------------------------
    | 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を指定する必要があります。

schema.graphql
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 ダミーデータの準備

まずは、定義した型に対応する連想配列を返り値として準備しましょう。

Top.php
<?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クラスなどを用意しHistoryShopなどのモデルクラスを返すこともできるでしょう。

最終的なレスポンスの形だけ決めてしまえば、GraphQLのサーバーサイド開発はほとんど完了します。

3.2 カラム毎の処理

GraphQLの特徴であるクエリ毎に必要なデータを返す機能を実装する場合、全てのロジックプロセスを実行してからデータをフィルタリングするよりも、必要なプロセスのみを実行する方が好まれるでしょう。今回は下記のようなcolumnResolverメソッドを用意し、クエリに含まれるカラムに対応したロジックを実行するための実装を行いました。

Top.php
<?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がより浸透し様々な実装方法が提案されるでしょう。私の方でも引き続き試行錯誤していこうと思います。

次回へ向けて

次回は今回実装したものに関して自作のエラーハンドリングを追加する場合について書いていこうと思います。

19
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?