最近徐々に採用が増えているらしい?GraphQLをPHPを使いながら学ぼうという趣旨です。

TL;DR

GraphQLについて調べてみて、GraphQLに対応したサーバーアプリケーションをPHPで書きました。
ライブラリとしてgraphql-phpを使用しましたが、スキーマをPHPで表現するところや、型チェックのやり方などが結構辛かったです。
GraphQLが言語に依存しない仕様なので、将来的にはPHP側のスキーマ定義はきっと自動生成されるようになるのでしょう。
GraphQLは言ってみればAPIがどのデータを返すかをクライアント側が強制するような仕様なので、GraphQLに対応しようとすると、サーバー側はクライアント側の要望に答えるためにより複雑になるか、柔軟性がなくなるのではないかと感じました。

GraphQLとは

GraphQLはAPI用のクエリ言語です。SQLをデータベースからデータを取得するためのクエリ言語とすると、GraphQLはAPIからJSONを取得するためのクエリ言語です。

GraphQLを見てみる

GraphQLではスキーマ定義(型と型のフィールドを定義)をするとそれがそのままAPIの仕様になります。

以下のスキーマ定義に対して

type Query {
  me: User
}

type User {
  id: ID
  name: String
}

以下のようなリクエストをすると

{
  me {
    name
  }
}

このようなレスポンスが返ってきます。

{
  "me": {
    "name": "Luke Skywalker"
  }
}

リクエストとレスポンスの形式がとても似ているので分かりやすいですね。ここで、Userにはid,nameの2つがありますが、nameだけを返しているのはリクエストでnameのみを指定しているからです。

GraphQLの利点

GraphQLの利点は様々あります。

  1. 複数のリソースを1度のリクエストで取得することができる。
  2. 一つのエンドポイントから全てのデータにアクセスできる
  3. リソースの定義があるので、型的なものが使える
  4. 取得するリソースをfieldレベルで制御できる
  5. jsonに似た形式で直感的

詳しくはこちらの記事を読むと良いかなと思います。https://qiita.com/bananaumai/items/3eb77a67102f53e8a1ad
あとは普通に公式サイト http://graphql.org/learn/

GraphQLを受け付けるサーバーを書いてみよう

ユーザーがいて記事を投稿することができる投稿型のサービスを想定します。
全てのソースコードは https://github.com/kazuhei/graphql-sample です

オブジェクトのスキーマを定義する

User、Tag、Postのスキーマを定義します。

schema
type User {
    id: ID!
    name: String!
    age: Int
}

type Tag {
    name: String!
}

type Post {
    id: ID!
    title: String!
    contents: String!
    author: User!
    tags: [Tag]!
}

GraphQLにはIDというstringかつuniqueであるという型表現があります。
また、!はNotNullという意味です。

クエリのスキーマを定義する

schema
type Query {
    posts: [Post]
    popularPosts: [Post]
    post(id: ID): Post
}

Queryという特別なtypeを指定することによって問い合わせ方法を指定することができます。

実装する

今回は https://github.com/webonyx/graphql-php というライブラリを使って実装します。

デバッグ環境の用意

ChromeiQLというGraphQLのリクエストを簡単に際限できるChrome拡張を入れます。

スクリーンショット 2017-12-04 15.52.35.png

スキーマを実装

スキーマをPHPのクラスで表現していきます。といってもほとんどが配列を使った設定です。

type/Post.php
<?php

namespace Type;

use DataSource\TagDataSource;
use DataSource\UserDataSource;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;

class Post extends ObjectType
{
    public function __construct()
    {
        $config = [
            'name' => 'Post',
            'fields' => [
                'id' => [
                    'type' =>Type::id(),
                ],
                'title' => [
                    'type' => Type::string(),
                ],
                'contents' => [
                    'type' => Type::string()
                ],
                'author' => [
                    'type' => User::getInstance(),
                ],
                'tags' => Type::listOf(Tag::getInstance())
            ],
            'resolveField' => function ($value, $args, $context, ResolveInfo $info) {
                $method = 'resolve' . ucfirst($info->fieldName);
                if (method_exists($this, $method)) {
                    return $this->{$method}($value, $args, $context, $info);
                } else {
                    return $value->{$info->fieldName};
                }
            }
        ];
        parent::__construct($config);
    }

    private static $singleton;

    public static function getInstance(): self
    {
        return self::$singleton ? self::$singleton : self::$singleton = new self();
    }

    public function resolveAuthor($value)
    {
        return UserDataSource::getById($value->authorId);
    }

    public function resolveTags($value)
    {
        return TagDataSource::getByPostId($value->id);
    }
}

type/User.php
<?php

namespace Type;

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;

class User extends ObjectType
{
    public function __construct()
    {
        $config = [
            'name' => 'User',
            'fields' => [
                'id' => Type::int(),
                'name' => Type::string(),
                'age' => Type::int(),
            ],
        ];
        parent::__construct($config);
    }

    private static $singleton;

    public static function getInstance(): self
    {
        return self::$singleton ? self::$singleton : self::$singleton = new self();
    }
}

type/Tag.php
<?php

namespace Type;

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;

class Tag extends ObjectType
{
    public function __construct()
    {
        $config = [
            'name' => 'Tag',
            'fields' => [
                'name' => [
                    'type' => Type::string(),

                ],
            ],
        ];
        parent::__construct($config);
    }

    private static $singleton;

    public static function getInstance(): self
    {
        return self::$singleton ? self::$singleton : self::$singleton = new self();
    }
}

graphqlのスキーマをphpの表現で$configに定義していきます。

graphql-phpはオブジェクトの型の同一性をconfigのtypeの型定義クラスが同一インスタンスかどうかで判断しているので、常に同一のインスタンスを返すようにsingletonな関数getInstanceを用意しています。

$config内にあるresolveFieldの関数でfieldの中身が解決されます。ここがGraphQLの重要なところだという気がしていて、一般的なAPIのデータを用意してからAPI用に整形して返すというのと違って、GraphQL側が求めているからデータを用意して返すという感じになります。

データクラスの用意

data/Post.php
<?php

namespace Data;

class Post
{
    // DBから取得可能
    public $id;
    public $title;
    public $contents;
    public $authorId;

    // graphql-phpに上書きされる
    public $author;
    public $tags;

    public function __construct(string $id, string $title, string $contents, string $authorId)
    {
        $this->id = $id;
        $this->title = $title;
        $this->contents = $contents;
        $this->authorId = $authorId;
    }
}

graphql-phpが$configのresolveFieldに従ってGraphQLのレスポンスに合うように直接フィールドを上書きしてくるので、それを見越した設計になっています。結構辛い。GraphQLのスキーマに合わせようとした結果、PHP側のデータクラスの型が崩壊していきます…。

他のデータクラスとサーバー部分のコードも気になる方は https://github.com/kazuhei/graphql-sample でどうぞ

動かしてみる

GraphQLのクエリを発行する。

schema

query {

  # Postの一覧
  posts {
    id
    title
    contents
    author {
      name
      age
    }
    tags {
      name
    }
  }

  # 人気Postのidとtitleだけを一覧で取得
  popularPosts {
    id
    title
  }

  # PostをIDで取得
  post(id: "2") {
    id
    title
    contents
  }
}

レスポンスは以下のようになります。
通常なら3つのエンドポイントから別々に取得するような内容ですが、1リクエストで取得することができています。

result.json
{
  "data": {
    "posts": [
      {
        "id": "1",
        "title": "first season",
        "contents": "...",
        "author": {
          "name": "Sophie Hojo",
          "age": 15
        },
        "tags": [
          {
            "name": "Prism Stone"
          },
          {
            "name": "SoLaMi Dressing"
          }
        ]
      },
      {
        "id": "2",
        "title": "second season",
        "contents": "...",
        "author": {
          "name": "Mirei Minami",
          "age": 14
        },
        "tags": [
          {
            "name": "armageddon"
          }
        ]
      },
      {
        "id": "3",
        "title": "third season",
        "contents": "...",
        "author": {
          "name": "Laala Manaka",
          "age": 12
        },
        "tags": [
          {
            "name": "nonsugar"
          }
        ]
      },
      {
        "id": "4",
        "title": "4th season",
        "contents": "...",
        "author": {
          "name": "Laala Manaka",
          "age": 12
        },
        "tags": [
          {
            "name": "DanPri"
          },
          {
            "name": "FantasyTime"
          }
        ]
      }
    ],
    "popularPosts": [
      {
        "id": "4",
        "title": "4th season"
      },
      {
        "id": "3",
        "title": "third season"
      },
      {
        "id": "1",
        "title": "first season"
      }
    ],
    "post": {
      "id": "2",
      "title": "second season",
      "contents": "..."
    }
  }
}

ここで面白いのは

  # 人気Postのidとtitleだけを一覧で取得
  popularPosts {
    id
    title
  }

の部分ですね。
popularPostsのうちidとtitleだけを取得してauthorを取得していないので、サーバー側ではauthorの取得処理が走りません。これは普通のAPIと比べると自分的にはかなり気持ち悪いなという感じがしていて、クライアント側がサーバー側を支配しているなと感じます。

考察・感想

GraphQLについて

実装していて気になったのは、クライアント側のあらゆるリクエストにサーバーが対応するために、型それぞれが自身の取得方法を保持している点です。
その結果、サーバー側では全てのリソースがそれぞれ別々にデータ取得しなければなりません。
具体的にいうと、従来であれば、データベースからpostsテーブルとusersテーブルをjoinして取得していた部分が、postsテーブルからデータを取得し、postそれぞれについてusersテーブルからデータを取得するという形になります。典型的なN+1問題です。

これを解決するために、graphql-phpでは取得のbufferingと非同期通信の2つを勧めていますがどちらも複雑だなと感じました。無理にクエリの数を減らさず、ガンガンmemcacheに乗せてしまってできるだけDBを叩かないようにするというのもありかもしれません。

GraphQL側のスキーマ定義やjsonの構造と一致したリクエストの書き方などはとても良いと思いましたが、サーバー側の実装は結構複雑になるなと感じました。

graphql-phpについて

まだバージョンがv0.11.4だというところはありますが、何か間違うとすぐinternal server errorで理由がわからなかったりするので、だいぶ辛かったですね。