☹️

GraphQLでの失敗から学ぶ、GraphQLの良さ

2022/03/16に公開

昨今、GraphQLが採用される事例が増えてきています。
一方で、GraphQLが正しく利用されていないケースもあるのではないでしょうか?
本記事では私が遭遇した正しく利用されていないケースについて、どんなケースか、なぜそのようなことになったのか、そうならないようにするにはどうすべきかを書きます。

すべてのResolverが独立したとんでもGraphqL

私が出会ったとんでもGraphQLを一言でいうと、Graphの概念などない謎の専用API群です。
正直これより酷いものはないと思う... というよりも、こんなことする開発組織って他にあるのだろうか?
GraphQLを利用する場合、コードファーストなアプローチと、スキーマファーストなアプローチがありますが、コードファーストなアプローチを選択したが故に陥った問題と言えるのかもしれません。

以下、簡単なフィクションをもとに、コードファーストなアプローチで実装した際に発生した、重大な問題についてみていきます。登場する開発言語はTypeScriptです。


Entityの定義

ここに商品、注文、出荷の3Entityがあります。それぞれ、下記のような構造となっています。シンプルです。

class Plan {
  id: ID
  name: string
  price: number
}
class Order {
  id: ID
  planId: string
  quantity: number
  amount: number
  
  plan?: Plan
}
class Shipment {
  id: ID
  orderId: string
  shippingDate: string
  
  order?: Order
}

Entity実装からGraphQLスキーマへ

これをGraphQLスキーマへ落とし込むと、こういう仕上がりです。

type Plan {
  id: ID!
  name: String!
  price: Float!
}

type Order {
  id: ID!
  planId: ID!
  quantity: Float!
  amount: Float!
  plan: Plan
}

type Shipment {
  id: ID!
  orderId: string
  shippingDate: string
  order: Order
}

Resolverを実装

次に、これらのEntityへアクセスするResolverがあります。

  • 商品詳細
  • 注文詳細
  • 発送詳細
// 実装はなんとなくこういう実装という概念のみ
class PlanResolver {
  // 商品詳細
  async plan(id: ID) {
    const plan: Plan = repository.getPlan(id)
    return plan
  }  
}

class OrderResolver {
  // 注文詳細
  async order(id: ID) {
    const order: Order = repository.getOrder(id)
    return order
  }
}

class ShipmentResolver {
  // 発送詳細
  async shipment(id: ID) {
    const shipment: Shipment = repository.getShipment(id)
    return shipment
  }
}

Reolver実装からGraphQLスキーマへ

そして、この実装からGraphQL型を生成した結果がこちらです。

type Query {
  plan(id: !ID): Plan
  order(id: !ID): Order
  shipment(id: !ID): Shipment
}

クライアントからの呼び出し

そして、クライアントから下記のようなリクエストが飛んでくるとします。

query {
  order(id: orderId) {
    id
    quantity
    plan {
      name
    }
  }
}

問題が発生!

フロントエンド開発者から、「order.plan.nameでエラーになる」という報告が発生しました。
実は、このQueryが成功するかどうかは、サーバーの実装に依存してしまっています。
GraphQLでは、Relationを都度解決するという前提があります。

  1. 注文へのアクセス
  2. 注文から商品へのアクセス <- この部分

注文から商品へのアクセスが発生すると、GraphQL Serverは注文オブジェクト内に存在する値を返却 or 注文から商品への解決処理を実行して戻り値を返却します。
前述の実装では、注文から商品への解決処理を実装していないため、GraphQL Serverはオブジェクト内に存在する値を返却することしかできません。サーバー側の処理を見返してみます。

class OrderResolver {
  // 注文詳細
  async order(id: ID) {
    // repository.getOrderで商品情報までまとめて取得しているならQueryは成功するし、
    // 取得していない場合は商品情報はundefinedとなり、Queryは失敗する
    const order: Order = repository.getOrder(id)
    return order
  }
}

関連Entityを事前にオブジェクト内に持つ形で対応

実装を修正して、注文詳細を取得時に、商品情報をまとめて取得するようにします。
これでフロントエンド開発者からの依頼には対応できました。

class OrderResolver {
  // 注文詳細
  async order(id: ID) {
    // 注文詳細取得時に、商品情報をまとめて取得
    const order: Order = repository.getOrderWithPlan(id)
    return order
  }
}

注文一覧Resolverを追加

仕様追加が発生して、注文一覧が必要となりました。早速実装していきます。

class OrderResolver {
  // 注文詳細
  async order(id: ID) {
    // 注文詳細取得時に、商品情報をまとめて取得
    const order: Order = repository.getOrderWithPlan(id)
    return order
  }
  
  // 注文一覧 <-- 新しく追加
  async orderList() {
    const orderList: OrderList = repository.getOrderList()
    return orderList
  }
}

問題が発生! その2

フロントエンド開発者から、「orderList[number].plan.nameでエラーになる」という報告が発生しました。
バックエンド開発者は、注文詳細の実装時と同じように、注文一覧取得時に商品情報をまとめて取得することで、対応しました。

class OrderResolver {
  // 注文一覧
  async orderList() {
    // 注文一覧取得時に、商品情報をまとめて取得
    const orderList: OrderList = repository.getOrderListWithPlan()
    return orderList
  }
}

以上が、私が遭遇した、とんでもGraphQL案件です。「このQueryからはこの情報まで辿れる」、「ここまで辿れるQueryを追加して欲しい」などの会話が行き交っている状態でした。
ここまで読んでくださった方はお気づきかもしれませんが、正しい解決方法は注文から商品への解決処理を実装することでした。
もちろん、注文一覧で100件の注文を取得して、毎回商品への解決処理を実行していると処理効率の問題が発生するのですが、そこはDataLoaderを利用することで改善できます。
解決処理の実装 + パフォーマンスが懸念される箇所はDataLoaderで対応という提案をすぐに実施したわけですが、「でもパフォーマンスが気になるから...」とその後も専用API群を作り続けていました。強行突破して現在はかなり改善することができましたが、まだ専用APIが多数残っている状態です。

本来あるべき実装

解決処理を実装していれば、注文詳細から商品詳細も見れるし、注文一覧から商品詳細も見れます。
今後発生する仕様変更にも強くなります。出荷一覧->注文詳細->商品詳細が見たいと言われても、きっとすぐ対応できるはずです。専用API方式だと、一体どうなることやら、です。

class OrderResolver {
  // 注文詳細
  async order(id: ID) {
    const order: Order = repository.getOrder(id)
    return order
  }
  
  // 注文一覧
  async orderList() {
    const orderList: OrderList = repository.getOrderList()
    return orderList
  }
  
  // 商品への解決処理 <-- これを実装すべきだった
  async plan(order: Order){
    const plan: Plan = planRepository.getPlan(order.planId)
    return plan
  }
}

なぜこのようなことになったのか

WebAPIは大きく3つのフェーズを経ていると言えます。

1.専用API群

Ajax通信が始まった頃のエンドポイントは前述のとんでもGraphQLのように専用API群でした。
関連情報へのアクセスができるかどうかはエンドポイント次第です。

2.RESTAPI

次に、外部API連携の普及に伴い、RESTAPIという思想が誕生しました。しかし、RESTは汎用性は高いものの、関連情報へのアクセスは、返却されたURLに対して再度リクエストを投げるという方式で、あまり効率的とは言い難いものでした。取得するフィールドを取捨選択することもできません。

3. GraphQL

RESTの効率性の課題を解決してくれるものとしてGraphQLが誕生し、普及しつつあります。

今回取り上げたプロジェクトでは、非効率さに対して専用API群を用意することで対応しようとしてしまい、結果として時代に逆行した実装となってしまったと言えます。もちろん、こうした背景を理解していなくても、GraphQLの公式ドキュメント等をちゃんと読み込んでいけば、Entity間の解決処理を実装するという選択はきっと取れたはずです。新しい技術を正しく理解して取り入れるのは大変かもしれませんが、そこを怠って発生するコストは計り知れません。

私はGraphQL大好きです、もっと広まってデファクトになって欲しいです。
もし、この記事で取り上げたような問題に遭遇しているチームがあれば、GraphQLの思想に正面から向き合ってみて欲しいと思う次第です。

Discussion