mybook

GraphQL と gRPC — RESTの先へ

RESTの限界

「このAPIを使うと、必要なデータを取るのに5回リクエストが必要です」

パートナー企業D社は、モバイルアプリを作っていた。ユーザー情報、記事一覧、コメント数、タグ、ブックマーク状態を取るために5つのAPIを叩く。モバイル回線では深刻な遅延だ。

「GraphQLを検討してほしい」

サクラはGraphQLの学習を始めた。同時に、マイクロサービス間通信に向いているgRPCも調査することにした。

REST vs GraphQL vs gRPC

Loading diagram...
項目RESTGraphQLgRPC
プロトコルHTTP/1.1HTTP/1.1HTTP/2
データ形式JSONJSONProtocol Buffers
型定義OpenAPI(任意)スキーマ必須.proto必須
オーバーフェッチ発生する発生しない発生しない
キャッシュHTTP標準複雑難しい
ストリーミング限定的Subscriptionネイティブ
学習コスト

GraphQL: 概念

GraphQLでは、クライアントが「欲しいものだけ」を宣言的に指定する。

# クライアントが指定するクエリ
query {
  user(id: 1) {
    name
    email
    articles(limit: 5) {
      title
      publishedAt
      commentCount
    }
  }
}
// 1回のリクエストで必要なデータだけが返る
{
  "data": {
    "user": {
      "name": "田中太郎",
      "email": "tanaka@example.com",
      "articles": [
        {
          "title": "Railsの基本",
          "publishedAt": "2024-01-15",
          "commentCount": 12
        }
      ]
    }
  }
}

graphql-rubyの実装

# Gemfile
gem 'graphql'
gem 'graphql-batch'  # N+1対策
 
# スキーマ生成
rails generate graphql:install

型定義

# app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    description "ユーザー"
 
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
    field :articles, [Types::ArticleType], null: false,
          description: "ユーザーの記事一覧"
 
    def articles
      # N+1対策: DataLoaderを使う
      dataloader.with(Sources::ArticleSource).load(object.id)
    end
  end
end
 
# app/graphql/types/article_type.rb
module Types
  class ArticleType < Types::BaseObject
    field :id, ID, null: false
    field :title, String, null: false
    field :body, String, null: true
    field :published_at, GraphQL::Types::ISO8601DateTime, null: true
    field :comment_count, Integer, null: false
 
    def comment_count
      object.comments.count
    end
  end
end

クエリタイプ

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    # ユーザー一覧
    field :users, [Types::UserType], null: false do
      description "ユーザー一覧"
      argument :status, String, required: false
      argument :page, Integer, required: false, default_value: 1
      argument :per_page, Integer, required: false, default_value: 20
    end
 
    def users(status: nil, page:, per_page:)
      scope = User.all
      scope = scope.where(status: status) if status
      scope.page(page).per(per_page)
    end
 
    # 単一ユーザー
    field :user, Types::UserType, null: true do
      description "ユーザー詳細"
      argument :id, ID, required: true
    end
 
    def user(id:)
      User.find_by(id: id)
    end
  end
end

ミューテーション

# app/graphql/mutations/create_user.rb
module Mutations
  class CreateUser < BaseMutation
    description "ユーザーを作成する"
 
    argument :name, String, required: true
    argument :email, String, required: true
 
    field :user, Types::UserType, null: true
    field :errors, [Types::ErrorType], null: false
 
    def resolve(name:, email:)
      user = User.new(name: name, email: email)
 
      if user.save
        { user: user, errors: [] }
      else
        {
          user: nil,
          errors: user.errors.map { |e|
            { field: e.attribute.to_s, message: e.full_message }
          }
        }
      end
    end
  end
end
 
# app/graphql/types/mutation_type.rb
module Types
  class MutationType < Types::BaseObject
    field :create_user, mutation: Mutations::CreateUser
    field :update_user, mutation: Mutations::UpdateUser
    field :delete_user, mutation: Mutations::DeleteUser
  end
end

サブスクリプション(リアルタイム)

# app/graphql/types/subscription_type.rb
module Types
  class SubscriptionType < Types::BaseObject
    field :article_created, Types::ArticleType, null: false do
      description "新しい記事が作成されたときに通知"
      argument :user_id, ID, required: false
    end
 
    def article_created(user_id: nil)
      object  # イベント発火時に渡されるオブジェクト
    end
  end
end
 
# 記事作成時にイベントを発火
class ArticlesController < ApplicationController
  def create
    @article = Article.create!(article_params)
    # Subscriptionにイベントを伝搬
    TechbridgeApiSchema.subscriptions.trigger(
      :article_created,
      { user_id: @article.user_id },
      @article
    )
  end
end

DataLoaderでN+1を解決

# app/graphql/sources/article_source.rb
class Sources::ArticleSource < GraphQL::Dataloader::Source
  def fetch(user_ids)
    articles = Article.where(user_id: user_ids).group_by(&:user_id)
    user_ids.map { |id| articles[id] || [] }
  end
end

コントローラー

# app/controllers/api/graphql_controller.rb
class Api::GraphqlController < ApplicationController
  skip_before_action :verify_authenticity_token
 
  def execute
    variables = prepare_variables(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
 
    result = TechbridgeApiSchema.execute(
      query,
      variables: variables,
      context: {
        current_partner: @current_partner,
        current_user: @current_user
      },
      operation_name: operation_name
    )
 
    render json: result
  rescue StandardError => e
    raise e unless Rails.env.development?
    render json: { errors: [{ message: e.message }] }, status: :internal_server_error
  end
 
  private
 
  def prepare_variables(variables_param)
    case variables_param
    when String
      variables_param.present? ? JSON.parse(variables_param) : {}
    when Hash
      variables_param
    else
      {}
    end
  end
end

WARNING

GraphQLは強力ですが、複雑なクエリで意図せずDBに大量の負荷をかけることがあります。クエリの深さ制限とコスト計算を必ず設定してください。

gRPC: マイクロサービス間通信

gRPCはRailsのパブリックAPIよりも、マイクロサービス間の通信に適している。

Protocol Buffers定義

// proto/user_service.proto
syntax = "proto3";
 
package techbridge.v1;
 
service UserService {
  rpc GetUser(GetUserRequest) returns (UserResponse);
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser(CreateUserRequest) returns (UserResponse);
  rpc StreamUsers(ListUsersRequest) returns (stream UserResponse);
}
 
message GetUserRequest {
  int64 id = 1;
}
 
message ListUsersRequest {
  int32 page = 1;
  int32 per_page = 2;
  string status = 3;
}
 
message CreateUserRequest {
  string name = 1;
  string email = 2;
}
 
message UserResponse {
  int64 id = 1;
  string name = 2;
  string email = 3;
  string created_at = 4;
}
 
message ListUsersResponse {
  repeated UserResponse users = 1;
  int32 total = 2;
}

gRPCサーバー実装(Ruby)

# Gemfile
gem 'grpc'
gem 'grpc-tools'
 
# lib/grpc/user_service_impl.rb
class UserServiceImpl < Techbridge::V1::UserService::Service
  def get_user(request, _call)
    user = User.find(request.id)
    to_user_response(user)
  rescue ActiveRecord::RecordNotFound
    raise GRPC::NotFound, "User #{request.id} not found"
  end
 
  def list_users(request, _call)
    users = User.page(request.page).per(request.per_page)
    users = users.where(status: request.status) if request.status.present?
 
    Techbridge::V1::ListUsersResponse.new(
      users: users.map { |u| to_user_response(u) },
      total: users.total_count
    )
  end
 
  def stream_users(request, _call)
    return enum_for(:stream_users, request, _call) unless block_given?
 
    User.find_each(batch_size: 100) do |user|
      yield to_user_response(user)
    end
  end
 
  private
 
  def to_user_response(user)
    Techbridge::V1::UserResponse.new(
      id: user.id,
      name: user.name,
      email: user.email,
      created_at: user.created_at.iso8601
    )
  end
end

どれを選ぶか

Loading diagram...
選ぶ基準RESTGraphQLgRPC
外部公開
画面ごとに違うデータ
超高速・低レイテンシ
ストリーミング
ブラウザから直接
キャッシュ

INFO

「全部GraphQLに移行すべき」は誤りです。RESTとGraphQLとgRPCは競合ではなく、それぞれが得意な領域があります。テックブリッジでは、パブリックAPIはREST、モバイル向けBFFにGraphQL、マイクロサービス間はgRPCという3層構造が理想です。

サクラの判断

「モバイルアプリ向けのBFF(Backend for Frontend)としてGraphQLを追加しましょう。既存のREST APIは廃止せず、外部パートナー向けに維持する」

CTOは頷いた。「なぜ両方を維持するのか説明できる人間が設計できる。それがサクラらしい」

技術の選択は「何が最新か」ではなく「何が問題を解決するか」で決まる。

次章では、APIエコシステムを育て続ける戦略を学ぶ。