GraphQL成熟度モデルをROUTE06のtoBプロダクトに当てはめてみる

ROUTE06でソフトウェアエンジニアをしている宮城です。

oisham.hatenablog.com

早速参照したこの記事は、Meta 社 relay.dev チームの Jordan Eldredge 氏のTweetで紹介された GraphQL 成熟度モデル (GraphQL maturity model) を、ブログ著者のjunyさんの個人的な見解を加えながら和訳した記事となっています。

この記事を見かけ、とても参考になり面白かったのですが、「あなたの組織はどの程度成熟して(=使いこなせて)いますか?」という Jordan さんの問いかけに反応し、ROUTE06でのto BプロダクトでのGraphQL活用状況を紹介していきます!

対象となるプロダクト

ROUTE06 ではエンタープライズ向けビジネスプラットフォーム「Plain」を開発しています。簡単にプロダクトの特徴を説明します。

  • エンタープライズ向けSaaS・マルチテナントアーキテクチャ
  • 現在開発中のプロダクト「Plain EDI」は商取引におけるクラウド EDI のドメインにフォーカス
  • バックエンドはRuby、フロントエンドはReact
  • 技術観点では入力内容の多い複雑なフォームとデータ量の多いテーブル表示が主な機能で、ページ数が多い
  • Webアプリケーションのみの提供で、主にデスクトップでの利用を想定

GraphQL以外にも、どのような技術を利用しているかはフロントエンド領域についての記事があります。こちらも参考にしてください。

tech.route06.co.jp

それでは早速行ってみましょう!

No. 1

1/サーバーがGraphQLをサポートしていることです。アプリケーションのモバイルとウェブの両クライアントは、サーバーのコードを変更することなく、また他のクライアントへの影響を心配したりすることなく、普通にクエリの作成、データの追加・削除ができます。

これは実現できています。 Plain EDIでは、提供する機能のほぼ全てをGraphQLを介して実装しています。バックエンドのGraphQLサーバーとしてはgraphql-ruby、フロントエンドのGraphQLクライアントとしてはurqlを使用しています。

No. 2

2/ 型やフィールドはきちんとドキュメント化されていることです。GraphiQLは、インターナルな環境でインタラクティブなプレイグラウンドやドキュメントとして機能します。開発者は必要なデータを見つけることができ、何度も同じサーバー上の処理を記載する必要はありません。

これは実現できています。

ドキュメントの閲覧と自由にGraphQLクエリが書けるプレイグラウンドとしては、GraphiQLの代わりにApollo Sandboxを使用しています。
Apollo SandboxはReactコンポーネントが提供されているので、フロントエンドアプリ内の開発者向けページとしてルーティングを実装し、開発者のローカル環境とステージング環境で有効化しています。ログイン認証後のページとしているためログインユーザーの認証情報を使ってAPIリクエストすることができます。

Apollo Sandbox上で補完を効かせながらクエリを書き、正しく実行できることが確認できたらコード中にコピーするという開発フローにすると、クエリの間違いによるミスを減らすことができて便利です。

また、開発チームではプロダクト仕様をスキーマに落とし込むためにカスタムディレクティブをいくつか実装し利用しています。例えば @auth ディレクティブはそのフィールドにアクセス可能なロールを制限し、リクエスト中のユーザーが許可がないロールの場合エラーを返すようバックエンドで実装しています。

enum UserRole {
  MEMBER
  ADMIN
}

directive @auth(
  role: [UserRole!]!
) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION

フロントエンドでも、 後述するGraphQL Code Generatorのカスタムプラグインで @auth ディレクティブを利用した判定関数を生成するなどで活用しています。詳しくはこちらにまとめています。

zenn.dev

No. 3

3/ GraphQL のサーバーとクライアントの両方で、それぞれの言語の型システムを統合して利用していることです。これによりネットワーク API の境界を超えた型安全性が確保されます。

これは実現できています。 フロントエンドでは、GraphQLスキーマから型やコードを生成するためにGraphQL Code Generatorを使用しています。APIレスポンスの型は生成されたものだけを使用することで、スキーマに変更があったとしても常に追従できます。

バックエンドではコード生成ツールは利用していませんが、それはgraphql-rubyがコードファーストなフレームワークだからです。まずRubyで書いた型定義のコードがあり、そこからスキーマファイルを生成します。 バックエンド側で型定義を変更 → スキーマファイルに反映 → フロントエンド側のコード生成により実装に反映 という流れで型安全性を確保しています。

またPlain EDI ではモノレポを採用しているため、スキーマに破壊的変更があった場合 PR 上の CI でフロントエンドコードの型チェックも行い、そこでエラーを起こすことで破壊的変更を検知することができています。

No. 4

4/ スキーマをデフォルトで null 許容にするという GraphQL Working Groups 推奨のベストプラクティスを採用していることです。一般的にサーバー上で発生するフィールドのエラーは大したことがなくハンドリングできる UI のエラーです。これによりアプリの回復力(可用性?)が高まります。

これは意図的に採用していません。
GraphQLのnull許容にするプラクティスは、主にモバイルアプリなどのレスポンスデータの破壊的変更が難しい環境を想定されているという認識です。null許容にするとフロントエンドではnullチェックのためのコードが増え可読性が悪化してしまいます。バックエンドでは本来nullを返すべきではない状況でnullを返してしまったときに気づくことが難しくなってしまいます。
Webでのみ提供するプロダクトのため即時デプロイが可能であり、レスポンスデータの破壊的変更はデプロイ順序を適切に行えば問題ないという判断で、一般的なREST APIと同様に基本的にnon-nullで設計しています。
ただ即時デプロイが可能とはいえ破壊的変更は慎重に行うべきです。非推奨のフィールドを安全に削除できるよう、最新バージョンの浸透率を確認する仕組みの導入を予定しています。

No. 5

5/ Node の仕様を採用していることです。Graph のほとんどのオブジェクトが強力なIDを持ちます。クライアントは、個々のオブジェクトのデータを簡単に再取得できます。

これは実現できています。 バックエンド側で基本的なオブジェクトは全てNodeインターフェースを実装し、グローバルなIDを持ちます。クライアント側での単体データの取得は node() フィールドから取得する形になっています。これにより不必要なフィールドの増加を抑え最小限に保つことができることと、実装方法の汎用化につながっています。

graphql-rubyでは、各オブジェクトクラスで implements GraphQL::Types::Relay::Node を追加し、

class Types::UserType < GraphQL::Schema::Object
  # Implement the "Node" interface for Relay
  implements GraphQL::Types::Relay::Node
  # ...
end

スキーマクラスで self.resolve_type によるオブジェクトの識別を実装するだけで利用可能になります。

class MySchema < GraphQL::Schema
  # You'll also need to define `resolve_type` for
  # telling the schema what type Relay `Node` objects are
  def self.resolve_type(type, obj, ctx)
    case obj
    when User
      Types::UserType
    when Post
      Types::PostType
    else
      raise("Unexpected object: #{obj}")
    end
  end
end

ref: https://graphql-ruby.org/schema/object_identification

フロントエンドで node() フィールドを利用する際は、 ... on User のようにInline Fragmentと呼ばれる記法でオブジェクトを識別します。 以下はユーザー情報を取得する例で一部簡略化して記載しています。

const query = graphql(`
  query UserQuery($nodeId: ID!) {
    node(id: $nodeId) {
      ... on User {
        __typename
        firstName
        lastName
      }
    }
  }
`)

const UserPage = ({ id }: { id: string }): JSX.Element => {
  const [{ data }] = useQuery({
    query,
    variables: { nodeId: id }
  })

  // GraphQL Code Generatorで生成した型の場合、__typenameを利用して型の絞り込みが可能
  // 予期しない型の場合は例外を投げる
  if (data.node.__typename !== 'User') {
    throw new Error(`Unexpected node type: ${data.node.__typename}`)
  }

  const { firstName, lastName } = data.node
  
  return (
    <div>
      <p>name: ${firstName} ${lastName}</p>
    </div>
  )
}

NodeはGraphQLとしてはインターフェース型であり、利用時には具象型の絞り込みが必要となります。
実行時に期待しない型が取り出されるケースはバックエンドの resolve_type() の実装ミスでしか起こりませんが、可能性としてはあるため例外を投げエラートラッキングツールで捕捉する運用としています。

No. 6

6/ GraphQL クライアントフレームワークが、Node 仕様の強い ID を使用して正規化された形式(キー ⇒ オブジェクト)で GraphQL のデータを保存していることです。表示上におけるデータの不整合は発生せず、クライアントのメモリ使用量も少なくて済みます。

これは意図的に採用していません。
いわゆる正規化されたキャッシュを利用しているか、というセクションですが、正規化されたキャッシュはネットワークリクエスト回数を最小限に抑えることができるメリットはあるものの、一部のユースケースでキャッシュを手動で操作する必要があります。キャッシュ操作はバグの原因となりがちです。Plain EDIはto Bアプリケーションなので、キャッシュ効率よりもバグの発生リスク回避を重視しています。
現在フロントエンドで利用しているurqlのキャッシュアルゴリズムとしてはDocument Cachingと呼ばれるものを利用しています。これはクエリ文字列とvariablesをハッシュ化したものをキャッシュキーとし、ミューテーションの実行タイミングで関連するクエリを全て再実行するというものです。ネットワークリクエスト回数は増えてしまうものの実装者がキャッシュ操作をすることはありません。

No. 7

7/ GraphQL サーバー側でリストをモデリングするために Connections 仕様を実装していることです(ほとんどの UI は見せかけのリストです)。 GraphQL クライアントフレームワークは、どのような表示においても機能する堅牢なページネーションを提供することができます。

これは実現できています。
バックエンド側ではリスト型のフィールドはほぼ全てConnection型として実装しています。graphql-rubyはConnection型の実装がしやすいのもメリットです。
field :items, Types::ItemType.connection_type, null: false のように指定するだけで以下の型を生成してくれます。

"""
The connection type for Item.
"""
type ItemConnection {
  """
  A list of edges.
  """
  edges: [ItemEdge!]!

  """
  A list of nodes.
  """
  nodes: [Item!]!

  """
  Information to aid in pagination.
  """
  pageInfo: PageInfo!
  totalCount: Int!
}

"""
An edge in a connection.
"""
type ItemEdge {
  """
  A cursor for use in pagination.
  """
  cursor: String!

  """
  The item at the end of the edge.
  """
  node: Item
}

graphql-ruby.org

No. 8

8/ 各 UI のコンポーネントが、GraphQLフラグメントを使用してコンポーネントに閉じた状態でデータを定義していることです。クエリーは、コンポーネントに書かれたフラグメントから構成されます。コンポーネントは、他のコンポーネントに対する破壊的な影響を心配することなく、コンポーネント内部に閉じてデータを追加/削除することができます。

これは実現できています。
いわゆるFragment Colocationと呼ばれるプラクティスで、コンポーネントとFragmentを1 : 1になる形で実装しています。実際に実装してみると設計が難しい状況もかなりあるのですが、それを乗り越えたあとのコードの可読性・保守性はかなり高いと感じています。

とはいえ書き方によってはFragment Colocationをせずに実装することもできてしまうため、コーディングルールとしてFragment Colocationの強制ができる仕組みがあると望ましいです。urqlではそういった仕組みは用意されていないのですが、GraphQL Code GeneratorのFragment Maskingという機能で「指定したFragmentに含まれるフィールドしかコンポーネントで取り出せない」という制約を持つ型を生成することができ、そちらで一定の担保ができています。以下の記事がわかりやすいです。

scrapbox.io

No. 9

9/ GraphQLのクライアントフレームワークが、コンポーネントのフラグメントを使って表示単位ごとに単一のクエリにまとめていることです。UIは、幾重にも重なったローディングの状態になることなく、1回でロードされ表示されます。これにより、ユーザーはあなたに感謝することでしょう。

これは実現できています。
チームでPageコンポーネントと呼んでいる上層のコンポーネントで複数のコンポーネント(Fragment)をまとめてクエリとして実行します。1回のネットワークリクエストで大半のデータの取得ができています。
逆に現在複数回ネットワークリクエストが起こる箇所としては、例えば「企業を選択した後にその企業に紐づく社員データを取得してコンボボックスとして表示する」などのユーザーのインタラクションによってクエリを発行するパターンがあります。

ローディングについては、urqlのsuspenseモードを利用しReact.Suspenseで表示を行なっています。

No. 10

10/ サーバーとクライアントの両方が @-defer と @-stream をサポートしていることです。UXデザイナーは、UXを向上させる場合にのみ、1行追加するだけでコンポーネント内でネストされたローディング状態を宣言的に許容することができます。

これは実現していません。
@deferと@streamは段階的なデータ送信が可能になる仕様ですが、to Bアプリケーションかつまだ初期フェーズということもあり、高度なパフォーマンスの要求は今のところ少ないためモチベーションはそこまでありません。
この二つのディレクティブは一応urqlとgraphql-rubyの両方でサポートはしているようです。

No. 11

11/ GraphQL クライアントフレームワークが、各コンポーネントのフラグメントを活用して、各コンポーネントで指定されたサブスクリプションを構築していることです。バックエンド側のストアに変更を加わると、直接影響を受けるコンポーネントのみが再レンダリングされます。これにより UI がよりレスポンシブになります。

これは実現していません。
GraphQLのSubscriptionはWebSocketやServer Sent EventsでリアルタイムにUIを変更する機能ですが、今のところ単純なポーリングでQueryを実行する形でユースケースを満たせています。

No. 12

12/ クライアント側のアプリを開発するエンジニアが、エディターで GraphQL のランゲージサーバーを活用していることです。各フィールドは利用可能な内容でオートコンプリートされます。フィールドの上にカーソルを合わせると、ドキュメントや型を見ることができます。非推奨のフィールドは、IDEで波線や取り消し線などのが表示されます。

これは実現できています。
VSCodeのGraphQL拡張機能を導入しGraphQL Configの設定ファイルを追加することで、マウスホバー時のドキュメントの閲覧や入力時のオートコンプリートができます。
またgraphql-eslintを利用しているので、非推奨のフィールドはエディタ上でESLintのエラー表示が行われます。

No. 13

13/ クライアント側のアプリを開発するエンジニアの GraphQL エディターは、フィールド/型のクリック時の定義ジャンプに対応しています。他のクライアントの関数にジャンプできるのと同様に、フィールドのサーバー実装に簡単にナビゲートすることができます。

これも実現できています。
No. 12と同様に、VSCodeのGraphQL拡張機能を導入しGraphQL Configの設定ファイルを追加することでOperation定義 → Fragment定義 → バックエンドのSchema定義までコードジャンプすることができます。

終わり

以上です。13の項目のうち9つは実現しており、2つは意図的に採用しておらず、残りの2つは現在採用していないという結果となりました。ある程度GraphQLをレベル高く使いこなせているのではないかと思っています。
業務プロダクトでのGraphQL活用について検討している方に参考になれば幸いです。ここまで読んでいただきありがとうございました!

ROUTE06では、GraphQLを使ってプロダクトを開発したいエンジニアを募集しています。ご興味ある方がいたらぜひカジュアル面談からお声がけください!

jobs.route06.co.jp