🐥

gRPCを使ったWebアプリケーション開発

2020/12/05に公開

これはSupershipグループ Advent Calendar 2020 の5日目の記事です。Momentum株式会社の阿部が担当します。MomentumのWebアプリケーションがどのように gRPC を利用しているかを紹介します。

要約

  • ブラウザはネイティブで gRPC を話すことができない
  • gRPC Web には gRPC Web Proxy が必要になる
  • IDLに沿って開発するのは楽しい

APIの定義

REST API を定義したい場合には SwaggerRAML を使うことが多いと思います。

swagger.io/docs/specification/basic-structure/
openapi: 3.0.0
info:
  title: Sample API
  description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML.
  version: 0.1.9
servers:
  - url: http://api.example.com/v1
    description: Optional server description, e.g. Main (production) server
  - url: http://staging-api.example.com
    description: Optional server description, e.g. Internal staging server for testing
paths:
  /users:
    get:
      summary: Returns a list of users.
      description: Optional extended description in CommonMark or HTML.
      responses:
        '200':    # status code
          description: A JSON array of user names
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string

これらは YAML で定義を書きます。YAMLは書きやすい Markup 言語だとは思いますが、階層が深くなると読みづらいですし、ちょっとインデントがズレただけで壊れてしまうのであまり長いものは書きたくないです。
ただ、ツール群は本当によくできていて、Swagger CodegenSwagger UI は非常に強力です。第3者にAPIを提供するようなケースではとても役に立ちます。

これらの REST API を定義するものはレスポンスとして JSON を想定しています。JSONはとても広く使われているテキストフォーマットですし、どの言語でも標準で変換ライブラリが用意されていると思います。
一方で、必ずしも効率の良いフォーマットではないため、よりハイパフォーマンスな処理を求められるような場面では他のシリアライズフォーマットが使われることも多々あります。

Serialization

https://www.json.org/json-en.html

ブラウザで簡単にシリアアライズできるのがもっとも大きな特徴だと思います。JSON はテキストデータなので人が見て内容を確認でき、また書きかえれるのは素晴らしいです。ただし、それによりとても壊れやすいファイルフォーマットでもあります。

https://msgpack.org/index.html

MessagePack はバイナリJSONと呼ばれることもあるように、JSONと同じような感覚(型)でデータをやりとりできます。シリアライぜーションが高速であり、一般的にデータサイズを小さくできます。ただし、テキストデータがメインの場合にはそれほどサイズは小さくなりません。MessagePackを使うときは本当にパフォーマンスが向上するかよく考えてから導入する必要があります。

JSON や MessagePack はそれ自身がデータ構造を表現できるため、前準備なくさまざまな言語でデータのやりとりができます。一方で、IDLでデータ構造を定義し、さらにRPCも定義してクライアント実装とサーバーインターフェースのコードを生成してくれるフレームワークもあります。

https://thrift.apache.org
Apache Thrift は RPC を実現するためのフレームワークです。IDLを通じて仕様を共有することで複数の言語間でのRPCを実現します。さまざまな言語に対応しており、コードジェネレーションもあるのでわりと使いやすいと思います。以前、プロジェクトで採用しようか検討したことがありましたが、その時は gRPC を採用したため深くは触りませんでした。HTTP 越しにブラウザと話すこともできるようです。

thrift.apache.org
/**
 * Ahh, now onto the cool part, defining a service. Services just need a name
 * and can optionally inherit from another service using the extends keyword.
 */
service Calculator extends shared.SharedService {

  /**
   * A method definition looks like C code. It has a return type, arguments,
   * and optionally a list of exceptions that it may throw. Note that argument
   * lists and exception lists are specified using the exact same syntax as
   * field lists in struct or exception definitions.
   */

   void ping(),

   i32 add(1:i32 num1, 2:i32 num2),

   i32 calculate(1:i32 logid, 2:Work w) throws (1:InvalidOperation ouch),

   /**
    * This method has a oneway modifier. That means the client only makes
    * a request and does not listen for any response at all. Oneway methods
    * must be void.
    */
   oneway void zip()

}

https://capnproto.org

Cap'n proto はパフォーマンスを強く意識したRPCフレームワークです。こちらもIDLで複数言語間で通信ができます。使ったことはないので詳細は分かっていないですが、パフォーマンスを強く意識するような場面では検討したいフレームワークです。

capnproto.org/language.html
@0xdbb9ad1f14bf0b36;  # unique file ID, generated by `capnp id`

struct Person {
  name @0 :Text;
  birthdate @3 :Date;

  email @1 :Text;
  phones @2 :List(PhoneNumber);

  struct PhoneNumber {
    number @0 :Text;
    type @1 :Type;

    enum Type {
      mobile @0;
      home @1;
      work @2;
    }
  }
}

struct Date {
  year @0 :Int16;
  month @1 :UInt8;
  day @2 :UInt8;
}
capnproto.org/rpc.html
# A happy, object-oriented interface!

interface Node {}

interface Directory extends(Node) {
  list @0 () -> (list: List(Entry));
  struct Entry {
    name @0 :Text;
    file @1 :Node;
  }

  create @1 (name :Text) -> (node :Node);
  open @2 (name :Text) -> (node :Node);
  delete @3 (name :Text);
  link @4 (name :Text, node :Node);
}

interface File extends(Node) {
  size @0 () -> (size: UInt64);
  read @1 (startAt :UInt64, amount :UInt64) -> (data: Data);
  write @2 (startAt :UInt64, data :Data);
  truncate @3 (size :UInt64);
}

https://google.github.io/flatbuffers/

FlatBuffers はゲームなどで利用が見られるシリアライぜーションライブラリです。こちらもパフォーマンスを強く意識しており、速度とメモリ効率を重視しています。RPCとして gRPC を使うことができますが、gRPC もサポートしている言語はC++だけのようです。実装はそんなに難しくないのでGoからgRPCを使えるようにして使ったことがありますが、オフィシャルで提供されないのは辛いです。

google.github.io/flatbuffers/flatbuffers_grpc_guide_use_cpp.html
table HelloReply {
  message:string;
}

table HelloRequest {
  name:string;
}

table ManyHellosRequest {
  name:string;
  num_greetings:int;
}

rpc_service Greeter {
  SayHello(HelloRequest):HelloReply;
  SayManyHellos(ManyHellosRequest):HelloReply (streaming: "server");
}

Protocol Buffers と gRPC

Backend に Go 言語を使うことは決まっているので、定義ファイルが書きやすくて Go と相性の良いフレームワークとして Protocol Buffers と gRPC を使うことにしました。Protocol Buffers はパフォーマンスを見ると FlatBuffers などには劣る印象ですが、Protocol Buffers と gRPC の組み合わせは Google の内部でも使われておりよくメンテナンスされているので安心感があります。

https://developers.google.com/protocol-buffers/
https://grpc.io

Protocol Buffers は Google で使われているシリアライぜーションライブラリです。IDLを使ってさまざまな言語とデータのやりとりができます。IDL はRPC定義も記述することができ gRPC をサポートします。Protocol Buffers と gRPC はそれぞれデフォルトのシリアライぜーション/通信プロトコルです。

grpc.io/docs/what-is-grpc/introduction/
// The greeter service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  repeated string message = 1;
}

string name = 1= 1 の部分が代入文みたいで若干気持ち悪いですが読みにくくはないです。配列のような表現ではなく repeated という指示子で配列を表すのもちょっと変わっているなあと思います。

protoc

https://grpc.io/docs/protoc-installation/

IDL から protoc コマンドを使って各言語向けにクライアント実装とサーバーインターフェースを作成できます。ただし、Goについてはプラグインで提供されているため gRPC 向けのプラグインと合わせて2つのプラグインを別途インストールする必要があります。

https://github.com/protocolbuffers/protobuf-go
https://github.com/grpc/grpc-go

また、gRPC Web にもプラグインが必要です。gRPC Web については後述します。

https://www.npmjs.com/package/grpc-web

各種プラグインの準備ができたら以下のように各言語向けにコードを生成できます。descriptor を出力しているため --include_imports, --include_source_info オプションも付与しています。

$ protoc -I=proto/ \
  --go_out=dist/go \
  --go-grpc_out=dist/go \
  --js_out=import_style=commonjs:dist/js \
  --grpc-web_out=import_style=commonjs,mode=grpcweb:dist/js \
  --descriptor_set_out=dist/descriptor \
  --include_imports \
  --include_source_info

Docker を使う

定義ファイルである .proto ファイルを git リポジトリに含めていますが、ここから生成されるコードをリポジトリに含めるかどうかは議論の別れるところだと思います。Go で import 可能なパッケージとして提供したい場合は生成されたコードを含めて公開する必要がありますが、特定のプロジェクトでしか使わないようなものであれば生成されたコードを公開する利点はあまりないと思います。

定義ファイルをローカル環境でコンパイルする場合、特にプラグインのセットアップに苦労します。Protocol Buffers のプラグインを使うためにはプラグインのバイナリにPATHを通す必要がありますが、1プロジェクトでしか使わないもののためにシステムにバイナリをインストールするというのも気が引けます。

これは好みの問題でもあるため、システムが汚れることを嫌う人でも手っ取り早く protoc を実行できるように専用の Docker image を作れるように準備しました。

https://github.com/TeamMomentum/docker-images

以下のプラグインを同封しています

  • protoc-gen-go
  • protoc-gen-go-grpc
  • protoc-gen-grpc-web

現在は Docker Hub などにはアップしていませんが、気が向いたらどこかに上げるかもしれません。

gRPC Web

IDL をコンパイルできるようになれば、あとは生成されたサーバーのインターフェースに沿って実装を書いていくだけです。また、フロントエンドは生成されたコードを使ってAPIを呼び出すだけですが、ブラウザは gRPC を直接話すことができません。

https://github.com/grpc/grpc-web

ブラウザで gRPC を利用するためには gRPC Web を使う必要があります。現在、ブラウザは gRPC を直接話すことはできないためブラウザと gRPC サーバーとの中立をする gRPC Web Proxy が必要になります。gRPC Web は protoc のプラグインを提供しブラウザが gRPC Web Proxy と通信するための gRPC Web クライアントを提供します。

https://www.envoyproxy.io/docs/envoy/latest/

gRPC Web Proxy には Envoy が使われることが多いと思います。Envoy は k8s でも使われるとてもパフォーマンスが良く拡張性の高い proxy です。gRPC Web の extension も提供されています。

以下のような設定を書いてローカル環境での開発時に Envoy を使います。

envoy.yaml
...
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: route
            virtual_hosts:
            - name: local
              domains: ["*"]
              routes:
              - match:
                  prefix: "/my.service.api"
                route:
                  cluster: cluster_server
                  max_grpc_timeout: 0s
              - match:
                  prefix: "/"
                route:
                  cluster: cluster_html
          http_filters:
          - name: envoy.filters.http.grpc_web
          - name: envoy.filters.http.router
...

フロントエンドのHTML等をホスティングするサーバーと、APIを提供するバックエンドサーバー、そしてこの Envoy の3つがあれば gRPW で Web アプリケーションの開発を進めることができます。

GCP Cloud Endpoints

Momentum では GCP を使って各種サービスを提供しています。GCP には Cloud Endpoints というものがあり、Protocol Buffers の descriptor と連携して動作する gRPC Web Proxy サーバーとして使うことができます。
本番環境ではこの Cloud Endpoints と Cloud Run にデプロイした ESP サーバー (Envoy) 、そして Cloud Run にデプロイした gRPC サーバーを使って API を提供しています。

grpc-gateway

https://github.com/grpc-ecosystem/grpc-gateway

クライアント側では REST API にアクセスしつつ、grpc-gateway を使うことでバックエンドのみ gRPC で実装するという方法もあります。Protocol Buffers の IDL から Swagger の定義ファイルを生成するプラグインもあるので、こちらを使うのも良いかもしれません。

TypeScript での利用

protoc の JavaScript 向けのコード生成は google-protobuf がベースになっています。Closure スタイルと CommonJS スタイルのインポートをサポートしていますが、TypeScript はサポートされていないため d.ts ファイルも出力されません。

TypeScript サポートに関する関連 Issue はたくさんありますがまだ実現に至っていません。以下のコメントからも今後もあまり期待できないのではないかと思います。

https://github.com/protocolbuffers/protobuf/issues/4733#issuecomment-398212675

I'm afraid we don't have much experience with TypeScript and so I'm not sure of the best way to handle that use case well. If you have any ideas then we would probably be open to taking pull requests, though.

Go のコード生成は公式ではサポートされておらずプラグインの形でサポートされています。TypeScript も同様に3rd Party からいくつかプラグインが出ています。TypeScript コードを直接出力するものと d.ts のみを出力ものがあります

また、プラグインではありませんが、protobufjs という runtime とコンパイラの別の実装があります。
https://github.com/protobufjs/protobuf.js

あまり推奨はされないですが、自身で any な d.ts ファイルを作ってとりあえず使うこともできます。また、gRPCに関してはExperimentalではありますがオフィシャルから提供されています


関連するフレームワーク等が多くなったこともあり細かい説明はできませんでしたが、gRPC で Web アプリケーションを構築するイメージは掴んでもらえたのではないかと思います。

gRPC Web を使う利点は何よりも Protocol Buffers の IDL を使ってデータとサービスを定義できることだと思います。準備は大変ですが、フロントエンドはとても簡単にAPIを呼び出すことができます。サーバー側の実装もインターフェースに沿って関数(またはメソッド)を実装するだけですの、考えることが減って実装に集中できます。フロントエンドとバックエンドが正しいエンドポイントで通信できることをIDLで保証されますので、エンドポイントの間違いやGET/POST/DELETEなどのメソッドを意識する必要もありません。
ぼくはフロントエンドとバックエンドの両方のコードを書きましたが、フロントエンドを開発しているときにバックエンドのコードを意識することはほとんどありませんでし、その逆もありませんでした。IDLだけを意識して開発すれば良くとても良い体験でした。

gRPC Web はまだまだメジャーな開発手法ではないと思いますが、1人でも多くの人に興味を持っていただけたら嬉しいです。

Discussion