マイクロサービスにおけるWeb APIスキーマの管理 ─ GraphQL、gRPC、OpenAPIの特徴と使いどころ

マイクロサービスにおける通信方式の選択について、おおた(ota42y)さんが、GraphQL・gRPC・OpenAPIといった主なWeb APIスキーマの管理の利点と使い分けを解説します。

マイクロサービスにおけるWeb APIスキーマの管理 ─ GraphQL、gRPC、OpenAPIの特徴と使いどころ

近年流行しているマイクロサービスアーキテクチャにおいては、「どういった通信方式を選ぶか」が開発の効率やサービスの信頼性、パフォーマンスを大きく左右します。この記事では、GraphQL・gRPC・OpenAPIそれぞれの利点と適切な使い分けについて解説します。

マイクロサービスにおけるWeb API管理の重要性

マイクロサービスによって構築されたアプリケーションは、複数の独立したサービスが互いに連携することで、1つのアプリケーションとして動作します。サービスごとに持っているデータは異なるため、APIを適切に用いて複数のマイクロサービスからデータを取得する必要があります。

特定のサービスでは、自身が持つデータだけで処理が完結しない場合もあります。その場合、事前に、もしくはリクエストのたびに、マイクロサービス内の他のサービスに対して必要な情報を問い合わせ、複数の情報をまとめた上で処理する必要があります。

1つのモノリシックなシステムであれば、これはデータベースへのクエリ発行や別モジュールのメソッド実行などで済みますが、マイクロサービスの場合は各サービスが独立しているため、通信によって問い合わせるのが一般的です。

つまり、マイクロサービスでは、ユーザが触れるアプリとバックエンドとの通信、バックエンドのサービス同士の通信といったように、モノリシックなシステムと比べてより多くのWeb APIを定義する必要があります。また、基本的にサービスが違うと管理するチームも違うため、アプリケーションの作り方や、用いられているプログラミング言語まで変わる(Polyglot)場合があります。

そのため、Web APIを使う側がコードを読まなくても、ある程度処理の概要が分かるような管理方法が重要になるわけです。

Schema First DevelopmentとWeb API

Web APIの管理は、Schema First Development(スキーマファースト開発)という開発手法においても、同様に重要です1。これは「最初にWeb APIの仕様(スキーマ)を決め、そのスキーマが満たされる前提のもとにサーバ・クライアントが同時に開発を進め、最後に結合する」という開発手法です。

スキーマファースト開発のススメ - onk.ninja

サーバ・クライアントの接続部分であるスキーマをはじめに決定し、それを元に作業を始めるため、結合時に認識のズレが起きにくく、開発の効率化が期待できます。マイクロサービスにおいては前述のようにWeb APIが多く作られるため、開発の効率化やミスを減らすことの重要性がとても高いです。

また、この手法ではドキュメントに流用可能なスキーマが同時に作られるため、その特徴も開発効率の向上に寄与してくれます。

人ではなくプログラムが処理できるよう管理する

このように、マイクロサービスにおいてWeb APIを管理することは重要ですが、大量のWeb APIをきちんと管理することの難易度は高いです。

例えば、WikiやExcelといったテキストのドキュメントとして仕様を記述する場合、「実装とドキュメントが紐付いていないため、コード変更がテキストに反映されない」「ドキュメントの定義をコードが正しく実装してない」など、実装とドキュメントの乖離が発生します。そうなると、ドキュメントの信頼度が下がって使われなくなってしまいますし、間違ったドキュメントに依存したアプリケーションが複数できると、それらを直すのは非常に困難になります。

このように、人の手による温かみのある管理は、多くの場合、破綻します。

この問題に対して、プログラムとして処理可能な方法でWeb APIを記述することで、ある程度の解決が見込めます。自然言語による説明文に加え、必要とするパラメータやレスポンスの形式、データの型などはプログラムによって自動的に判定できます。

そのため、プログラムの単体テスト時に形式をチェックしたり、定義を用いてシリアライズすることで定義したとおりの形式でしか送れないようにするなど、インタフェースとスキーマの定義をそろえることができます。また、コード生成による実装量の軽減なども期待できます。

Web APIのインタフェース定義手法の比較

今回は、このようなWeb APIのインタフェースを定義するという観点から、その用途として使える通信方式としてOpenAPI、gRPC、GraphQLの3つを挙げ、それぞれの特徴や技術選択時の観点について紹介していきます。

厳密にはそれぞれ対象とする領域が違うため、全体を比較しようとするのは難しいのですが、今回は「マイクロサービスにおいて各サービスのWeb APIのインタフェースを記述する」という用途に限定して比較します。

なお、本セクションに掲載したサンプルファイルは、次のリポジトリに置いています。gRPCとGraphQLでは動作するコードを用意しています。

1Microservices API sample repository

OpenAPI ─ REST APIの資産を生かして導入できる

OpenAPIは、OpenAPI Initiativeが策定しているREST APIのインタフェースを記述する仕様です。かつてはSwaggerと呼ばれていましたが、Swaggerのバージョン2.0がOpenAPI Initiativeに寄贈された後、OpenAPI 2.0という名称に変更されました2

2Home - OpenAPI Initiative

この仕様では、REST APIのURLやリクエスト・レスポンスの形式を、JSONまたはYAMLで記述します。特定のプログラミング言語に依存しない、独立した定義ファイルとなっています。

OpenAPIの2019年7月の最新バージョンは3.0.2で、本記事ではこのバージョンをもとに紹介します。

OpenAPI 3の定義をYAMLで書いた場合、以下のような形になります。

openapi: 3.0.0
info:
  title: Sample API
  version: 0.1.0
paths:
  "/apps":
    post:
      requestBody:
        content:
          'application/json':
            schema:
              "$ref": '#/components/schemas/app'
      responses:
        '200':
          "$ref": '#/components/responses/appResponse'
  "/apps/{id}":
    parameters: 
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      responses:
        '200':
          "$ref": '#/components/responses/appResponse'
    delete:
      responses:
        '204':
          description: delete app
  
components:
  schemas:
    app:
      type: object
      description: application data
      properties:
        id:
          type: integer
  responses:
    appResponse:
      description: get selected app
      content:
        'application/json':
          schema: 
            "$ref": '#/components/schemas/app'

これで定義されたサーバでは、POSTの​/appsにidを指定してデータを作成し、作成時のidをGETの/apps/{id}にパスパラメーターとして指定するとデータが取得できます。不要になったら、DELETEの/apps/{id}でデータを削除できます。

また、GETとPOSTにおいて200のレスポンスは同一で、idを持つオブジェクトがapplication/jsonで返ってきます。POSTのリクエストをcurlで表現すると、以下のリクエストとレスポンスになります。

$ curl -X POST "https://editor.swagger.io/apps" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"id\":42}"
{
  "id": 42
}

今回の例では、JSONのAPIのみを紹介していますが、POSTするmultipart/form-dataやx-www-form-urlencodedなどもサポートしており、一般的なREST APIの用途はほぼカバーしていると思います。

OpenAPIの定義を読んで処理する多様なツール

OpenAPIの定義は、JSONまたはYAMLファイルの書き方に準拠しています。テキストやWikiに定義を書くのとほとんど変わりません。ですが、構造化されており、プログラムが処理しやすい形式になっているため、OpenAPIの定義を読み込んでさまざまな処理をしてくれるツールが存在します。

例えば、筆者はサーバのリクエスト・レスポンスの形式がOpenAPI 3の定義どおりかどうかを検証するツールをRubyで作っています。また、定義ファイルからさまざまなプログラミング言語のクライアントライブラリを作成してくれるOpenAPI Generatorというツールもあります。

定義ファイルからパラメータを設定して、リクエストを試せるインタラクティブなドキュメントを作成してくれるSwagger UIや、実装をもとにOpenAPIの定義を作成してくれるFastAPIのようなフレームワークも存在します。

その他にもさまざまなツールが作られています。公式リポジトリの実装例紹介や、OpenAPI.Toolsでは多種多様なツールが紹介されていますし、ここに載っていないものもたくさんあります。

また、OpenAPIの仕様は、後述するgRPCやGraphQLと比べると規模が小さく、複雑ではないため、定義を読み込む実装を自分で作ることも簡単です。

特定の言語に依存せずREST APIの仕組みを生かせる

OpenAPIは特定の言語に依存しない定義であるため、さまざまな言語の実装を利用できます。REST APIを拡張するものであるため、導入・学習コストは低く、既存のフレームワークをそのまま使うなど、今ある技術的資産を最大限に活用しながら導入できます。

また、実際の通信は既存のREST APIの仕組みを使うため、HTTP層でのキャッシュやメトリクス収集、ルーティング管理などあらゆる層の仕組みをそのまま生かせます。RESTは事実上Webの標準技術であり、さまざまなベストプラクティスを集めたものであるため、REST APIに流用できる資産はとても多く、インタフェース定義を加えるOpenAPIの費用対効果は高いと言えます。

gRPC ─ HTTP/2とProtocol Buffersによる高いパフォーマンス

gRPCとは、CNCF(Cloud Native Computing Foundation)がホストしているRPC(Remote Procedure Call)のフレームワークの1つです。もともとはGoogleが社内で使っていたもので、CNCFに寄贈されました。

3gRPC

gRPCではHTTP/1やJSONなどさまざまな組み合わせがあり得ますが、本記事ではHTTP/2とProtocol Buffersを前提として進めます。

gRPCではIDL(インタフェース技術言語)でリモート呼び出しのインタフェースの記述や、やり取りするデータの構造を記述し、各プログラミング言語に対して実装を生成します。

gRPCの定義ファイルとGo言語による実装例

gRPCの例を紹介します。まず、定義ファイルは以下のようになります。

syntax = "proto3";
import "google/protobuf/empty.proto";
package pb;
service Application {
  rpc GetApps (google.protobuf.Empty) returns (Apps) {}
}
message App {
  int64 id = 1;
  string description = 2;
}
message Apps {
    repeated App items = 1;
}

これはApplicationというサーバに対してGetAppsというRPCを実行できる定義です。GetAppsは引数を取らず、Appの配列をitemsという要素に詰めて返します。

protocコマンドでこの定義から言語毎のコードを生成し、アプリケーションコード側から読み込んで使います。

Goのサーバでは、上記で定義したRPC名と同じインタフェースを持つ関数を定義して、以下のように実装します。今回は、固定で2つのデータを返すように実装しています。その後、この関数をインタフェースに持つオブジェクトを、gRPCのフレームワークに登録します。クライアントからアクセスがあると、登録したオブジェクトの関数をフレームワークが実行します。

type server struct{}

func (s *server) GetApps(ctx context.Context, in *empty.Empty) (*app.Apps, error) {
    ret := &app.Apps{
        Items: []*app.App{
            &app.App{
                Id:          1,
                Description: "description",
            },
            &app.App{
                Id: 2,
            },
        },
    }
    return ret, nil
}

func main() {
    fmt.Println("start")

    lis, _ := net.Listen("tcp", "0.0.0.0:80")

    s := grpc.NewServer()
    app.RegisterApplicationServer(s, &server{})

    if err := s.Serve(lis); err != nil {
        fmt.Println("failed server %v", err)
    }
}

アクセスするクライアント側のコードは、以下のようになります。生成されたコードの対応するメソッドを呼ぶと、通信の後にデータが入ったオブジェクトが得られます。

func main() {
    conn, _ := grpc.Dial("server_host:80", grpc.WithInsecure())
    defer conn.Close()
    client := app.NewApplicationClient(conn)
    res, _ := client.GetApps(context.TODO(), &empty.Empty{})
    for _, item := range res.Items {
        fmt.Printf("result: id = %v, description=%s \n", item.Id, item.Description)
    }
}
多様な言語サポート、静的型付き言語に便利なシリアライズ

gRPCは、複数の言語を公式にサポートしており、サーバサイドの言語はもちろん、WebやAndroid、iOSといったエンドユーザーが直接触れる場面でも使えるようになっています。

デフォルトでHTTP/2とProtocol Buffersを用いたやり取りが行われるため、データ量や通信コストが抑えられており、高いパフォーマンスが期待できます。また、データはフレームワークによって自動でProtocol Buffersにシリアライズ/デシリアライズされるため、インタフェース宣言どおりの型になり、静的型付き言語においては特に便利です。

言語のサポートがない、HTTP/2が使えないなど、gRPCが使えないクライアントに対しても、gRPCの定義ファイルを元にREST APIでアクセス可能なゲートウェイを作成するgRPC-gatewayというライブラリも存在しているため、マイクロサービスで複数の言語を使い分けている場合は、一部だけgRPCにしないという方針も選択できます。

GraphQL ─ クライアント側の自由度が高いクエリ言語

GraphQLとは、GraphQL Foundationがホストしているクエリ言語です。もともとはFacebookが開発していたものですが、GraphQL Foundationに移管されました。

4GraphQL | A query language for your API

GraphQLでは、やりとりするデータのスキーマを、独自言語を用いて定義します。クライアント側は、取得したいデータを専用のクエリ言語で指定し、実行用のWeb APIに送ります。サーバ側はクエリを受け取ると、そのクエリの定義どおりにデータの取得や更新処理を行い、通常はJSONでレスポンスを返します。

graphql-rubyによるGraphQLの実装例

GraphQLの例は、以下のようになります。実装としてはgraphql-rubyを利用しています。まず、以下のようにスキーマを定義し、やり取りするデータの形式を決定します。

module Types
  class Application < GraphQL::Schema::Object
    description "Application's Object Type"
    field :id, Integer, null: false
    field :description, String, null: true
  end
  
  class Player < GraphQL::Schema::Object  
    field :id, Integer, null: false
    field :name, String, null: false
  end
end

次に、データを取得するクエリ定義を記述します。ここではクエリのフィールド名とパラメータ、およびレスポンスとしてどういったスキーマなのかを定義します。実際にデータを取ってくる処理は、graphql-rubyではフィールド名と同名のメソッドを使うというルールがあるため、そちらを使います。

このメソッドの中は普通のRubyコードが書けるようになっており、通常はDBに問い合わせなどを行ってデータを作ります。今回はapplicationsというフィールドでTypes::Applicationの配列が、Integer型のidを必須として要求するplayerというクエリで、TypesPlayerのデータを1つレスポンスとして返します。

require 'graphql'
require_relative 'types'
class QueryType < GraphQL::Schema::Object
  field :applications, [Types::Application], null: false do
    description 'Get all applications'
  end
  def applications
    [
        { id: 1 },
        { id: 2, description: 'id=2 application' }
    ]
  end
  field :player, Types::Player, null: false do
    argument :id, Integer, required: true
  end
  def player(id:)
    {
      1 => { id: 1, name: 'Honoka'},
      2 => { id: 2, name: 'Mari'}
    }.fetch(id)
  end
end

このクエリを、GraphQLのスキーマとして定義します。

require 'graphql'
require_relative 'query'
class Schema < GraphQL::Schema
  query QueryType
end

最後に送られてきたJSONデータをスキーマのexecuteメソッドに渡すと、GraphQLの処理系が処理し、必要なデータを取得します。

    result = Schema.execute(params[:query])
    json result

このエンドポイントがlocalhost:14567で動いていると仮定すると、以下のようにリクエストを送ってデータを取得できます。

$ curl -X POST -d '{"query": "{  applications { id  description } player(id: 1) { name } }"}' -H "Content-Type: application/json" localhost:14567/graphql
{
  "data": {
    "applications": [
      {
        "id": 1,
        "description": null
      },
      {
        "id": 2,
        "description": "id=2 application"
      }
    ],
    "player": {
      "name": "Honoka"
    }
  }
}

id=2のPlayerだけ、かつidもレスポンスに含めるようなクエリにしてみます。

$ curl -X POST -d '{"query": "{  player(id: 2) { id name } }"}' -H "Content-Type: application/json" localhost:14567/graphql
{
  "data": {
    "player": {
      "id": 2,
      "name": "Mari"
    }
  }
}

このように、GraphQLでは送信するクエリを変えるだけで取得するデータを自由に変更できるため、サーバ側の修正は全く必要ありません。

GraphQLの具体的な内容についてもっと詳しく知りたい方は、より詳細に解説した記事がありますのでご覧ください。

取得するデータの種類などをクエリで自由に変更できる

GraphQLによって作られたサーバアプリケーションの場合、GraphQLの処理系から要求されたデータを返すだけで、処理系がクエリに沿ってレスポンスのJSONを組み立ててくれます。そのため、レスポンスに含まれるデータの種類やデータの中の必要なフィールドは、クライアント側から送るクエリによって決定されます。

前述したOpenAPI(REST API)やgRPCは、規定の方法で呼び出すと定義しているスキーマに沿ったデータが返ってくるため、レスポンスの形式は固定されています。そのため、クライアント側から取得したいデータが変わった場合、これらの方法では複数回通信をするか、もしくは新しいWeb APIを定義する必要があります3

しかし、GraphQLでは、一度の通信で取得するデータをクエリによって自由に変えられるため、取得するデータを変える場合にはクライアント側を変更するだけでよく、開発の効率化や通信回数・通信量の節約が容易に実現できます。

また、GraphQLはクエリをサーバに送るだけで動作するため、クライアント側ではライブラリがなくても使うことができます。

どの手法を導入するのが良いのか

エンジニアHubに会員登録すると
続きをお読みいただけます(無料)。
登録のメリット
  • すべての過去記事を読める
  • 過去のウェビナー動画を
    視聴できる
  • 企業やエージェントから
    スカウトが届く