コンテンツへスキップ

QualiArtsengineer blog

「IDOLY PRIDE」におけるgRPC利用とカスタマイズ

「IDOLY PRIDE」におけるgRPC利用とカスタマイズ

11 min read

はじめに

株式会社QualiArtsでバックエンドエンジニアをしている簗瀬です。 2021年6月リリースの「IDOLY PRIDE」(以降、アイプラ)の開発に携わり、通信基盤やマスタデータ配信基盤などの基盤系機能開発を行いました。 本記事では、ユーザーへより快適なサーバー通信を提供するため導入したgRPCとそのカスタマイズについて一部を紹介をします。

gRPC

gRPCはGoogleが開発したRPC(Remote Procedure Call)システムです。ProtocolBuffersをIDL(インタフェース定義言語)としてインタフェースを定義し、その定義ファイルを元に様々なプログラミング言語用のソースコードを生成することでデータのシリアライズ・デシリアライズ、および通信処理を実装することができます。

またHTTP/2をトランスポートプロトコルとして利用しており、「高速」「双方向ストリーミング通信が可能」といった特徴があります。

gRPCの通信方式としてはUnaryとStreamingを組み合わせた以下の4つがあります。

  1. Unary RPC
  2. Server streaming RPC
  3. Client streaming RPC
  4. Bidirectional streaming RPC

アイプラではAPIサーバーをGo言語で開発しており、APIとアプリはUnary RPCを利用したgRPCで通信を行なっております。本記事ではUnary RPCを前提としたgRPCの利用について紹介したいと思います。

gRPCの採用理由

gRPCを採用した理由は大きく分けて3つあります。

ProtocolBuffers

1つ目は「ProtocolBuffers」です。

Webサービスの開発においてクライアント・サーバー間で通信する内容をチーム内で明確に定義・共有することは、開発スピードや開発の精度を高める上で重要な要素です。そのためIDLを利用することはチームとして必須要件でした。

gRPCにおけるProtocolBuffersの定義では、APIのリクエスト/レスポンスの内容の他、独自のOptionを定義することでAPI自体や各リクエスト/レスポンスのパラメーター毎にメタデータを付与することができます。

ゲームAPIの開発を行う上でAPI自体にメタデータを付与できることは魅力となります。例えばアイプラでは以下のようなメタデータをAPI毎に定義しています。

  • 認証チェックの有効/無効
  • アプリバージョンチェックの有効/無効
  • アプリとAPIサーバーのマスタデータのバージョン一致チェックの有効/無効
  • メンテナンスチェックの有効/無効

以下はOptionを利用したprotoファイルの定義例です。

syntax = "proto3";

package api;
option go_package = "game-server/pkg/proto/api";
option csharp_namespace = "Game.Proto.Api";

import "google/api/annotations.proto";

service Game {
  rpc Start(GameStartRequest) returns (GameStartResponse) {
    option (google.api.http) = {
      post: "/game/start"
      body: "*"
    };
    option (checkOption) = {
      // アプリのバージョンチェックを無効にする
      disableAppVersionCheck: true
    };
  }
}

message GameStartRequest {
  string gameId = 1;
}

message GameStartResponse {
  string gameData = 1;
}

message CheckOption {
  // 認証チェック: デフォルト有効
  bool disableAuthTokenCheck = 1;
  // マスタデータバージョンチェック: デフォルト有効
  bool disableMasterVersionCheck = 2;
  // アプリバージョンチェック: デフォルト有効
  bool disableAppVersionCheck = 3;
  // メンテナンスチェック: デフォルト有効
  bool disableMaintenanceCheck = 4;
}

extend google.protobuf.MethodOptions {
  CheckOption checkOption = 50001;
}

このような定義を行うことで、サービス全体のAPIの設定をIDL上で一元管理することが可能となります。

自由度の高いインタフェース定義とクライアント/サーバー双方のソースコードの自動生成が可能なProtocolBuffersを利用できることがgRPCを採用した理由の1つです。

gRPCの通信速度

gRPCを採用した2つ目の理由はその「速度」です。

gRPCの採用を検討するに辺り、gRPCとHTTP/1.1のREST APIとの速度の比較検証を行いました。 以下は同様の結果を返すAPIをgRPC, REST APIそれぞれで実装し、Unityアプリ上から連続10回APIリクエストを行ったレスポンスタイムの結果です。

iOS端末12345678910平均*1
REST1158ms115ms98ms135ms131ms200ms197ms131ms165ms133ms150.875ms
gRPC610ms87ms98ms65ms63ms64ms63ms62ms65ms63ms71ms
Android端末12345678910平均*1
REST483ms166ms132ms119ms137ms129ms132ms123ms115ms141ms134.875ms
gRPC233ms33ms42ms43ms44ms39ms43ms42ms40ms44ms42.125ms

*1 最大値と最小値を除いた値の平均値

gRPC、REST API共に1回目のリクエストではコネクションの作成の影響かパフォーマンスの悪化が見られたものの、2回目以降のリクエストではgRPCがREST APIに比べ2倍以上速い結果となりました。

gRPCの実装の容易さ

gRPCを採用した3つ目の理由は「実装の容易さ」です。

前項でgRPCの通信速度について紹介しましたが、この恩恵を.protoファイルから自動生成したソースコードを利用することで簡単に享受することができました。以下はGo言語とC#(Unity)それぞれのgRPCクライアントの実装例です。

【Go言語】

conn, err := grpc.Dial("localhost:8080", grpc.WithInsecure())
if err != nil {
    panic(err)
}
defer conn.Close()

client := pb.NewGameClient(conn)
response, _ := client.GameStart(context.Background(), &pb.GameStartRequest{
    GameID: "IDOLY PRIDE",
})

【C#(Unity)】

var channel = new Channel("localhost:8080", ChannelCredentials.Insecure);
var client = new Game.GameClient(channel);
var response = await client.GameStartAsync(new GameStartRequest { GameId = "IDOLY PRIDE" });

RPC(Remote Procedure Call)の名の通り、メソッド(手続き)を実行するような形でgRPCのAPIコールを実行することができます。プログラミング言語によって実装に大きな差はなく、自動生成されたメソッドを実行するだけなのでとてもシンプルでわかりやすいコードを記述することが可能です。

以上の「ProtocolBuffers」「通信速度」「実装の容易さ」の3点がgRPCを採用した大きな理由です。

カスタムcodecによるカスタマイズ

アイプラではゲームの性質上APIのレスポンスサイズが大きくなる場合があります。特に「ライブ」という機能ではライブ楽曲中の各ビートのスコアやスキルの発動、結果の計算などを全てサーバーサイドで行なっており、それらのデータを描画のためにアプリ側へ返却しているためAPIのレスポンスサイズが大きくなりがちです。

ライブ画面

gRPCではProtocolBuffersによりシリアライズが行われ、REST APIに比べレスポンスサイズは小さくなりますが、それでもレスポンスサイズが大きくなってしまう場合があります。アイプラでは更なるデータ通信量の削減やその他バイナリデータの加工のため独自にgRPC codecを実装し利用しています。

codecとは

gRPCの通信データはmetadata部分とmessage部分に分けられます。 codecはこのmessage部分のシリアライズ/デシリアライズ処理を担うモジュールを指しており、gRPCのデフォルトcodecはProtocolBuffersによるシリアライズ/デシリアライズを行なっています。

gRPCデータ構造

カスタムcodecの情報参照

カスタムcodecの実装をするにあたって必要な情報は全てmessage部分に含まれている必要があります。これはcodecの処理中にmetadataの参照を行うことができないためです。

今回アイプラでは「一定以上のデータサイズの場合はシリアライズされたデータをさらに圧縮する」という処理をカスタムcodecに加えています。そのため圧縮されたデータの解凍を行うため「圧縮されたか否か」という情報をcodecが参照できる形で保持する必要がありました。この情報はmessage部分のバイナリデータのbyte配列に以下のようにルール付けをする形で実現しました。

message部分加工

アイプラでは圧縮の他にもmessage部分のデータの加工を行なっていますが、この圧縮と同様に加工に必要な情報はバイナリデータのbyte配列にルール付けをして格納しています。

カスタムcodecの実装

今回はGo言語でのカスタムcodecの実装について紹介をします。

codecの開発は Codec interface を実装する形で行います。

// Codec defines the interface gRPC uses to encode and decode messages.  Note
// that implementations of this interface must be thread safe; a Codec's
// methods can be called from concurrent goroutines.
type Codec interface {
	// Marshal returns the wire format of v.
	Marshal(v interface{}) ([]byte, error)
	// Unmarshal parses the wire format into v.
	Unmarshal(data []byte, v interface{}) error
	// Name returns the name of the Codec implementation. The returned string
	// will be used as part of content type in transmission.  The result must be
	// static; the result cannot change between calls.
	Name() string
}

gRPCのデフォルトのcodecである proto codec は以下のような実装となっています。

// Package proto defines the protobuf codec. Importing this package will
// register the codec.
package proto

import (
	"fmt"

	"github.com/golang/protobuf/proto"
	"google.golang.org/grpc/encoding"
)

// Name is the name registered for the proto compressor.
const Name = "proto"

func init() {
	encoding.RegisterCodec(codec{})
}

// codec is a Codec implementation with protobuf. It is the default codec for gRPC.
type codec struct{}

func (codec) Marshal(v interface{}) ([]byte, error) {
	vv, ok := v.(proto.Message)
	if !ok {
		return nil, fmt.Errorf("failed to marshal, message is %T, want proto.Message", v)
	}
	return proto.Marshal(vv)
}

func (codec) Unmarshal(data []byte, v interface{}) error {
	vv, ok := v.(proto.Message)
	if !ok {
		return fmt.Errorf("failed to unmarshal, message is %T, want proto.Message", v)
	}
	return proto.Unmarshal(data, vv)
}

func (codec) Name() string {
	return Name
}

Marshalメソッドでシリアライズ処理を、Unmarshalメソッドでデシリアライズ処理を定義しています。アイプラではこの proto.Marshalメソッド によって生成されたbyte配列に対して「圧縮されたか否か」の情報をbyteデータとして付加する実装を追加しました。

Nameメソッドは encoding.RegisterCodec でcodecを登録する際に利用されるcodec名を定義しています。例えばNameメソッドで「idolypride」という文字列を返した場合、以下のようにgRPCのAPIコールを行うと「idolypride」codecを利用したシリアライズ/デシリアライズが行われます。

conn, err := grpc.Dial("localhost:8080",
    grpc.WithInsecure(),
    grpc.WithDefaultCallOptions(grpc.CallContentSubtype("idolypride")))
if err != nil {
    panic(err)
}
defer conn.Close()

client := pb.NewGameClient(conn)
response, _ := client.GameStart(context.Background(), &pb.GameStartRequest{
    GameID: "IDOLY PRIDE",
})

また上記の場合、proto codecをデフォルトcodecとして維持したまま追加でidolypride codecを登録し、gRPCクライアントが任意のcodecを選択できるようになっていますが、gRPCサーバー側で使用するcodecを固定することも可能です。 その際、サーバー側が固定したcodecに対応するcodecでクライアント側のリクエストデータのシリアライズが行われていない場合はエラーとなります。

s := grpc.NewServer(grpc.ForceServerCodec(&CustomCodec{}))
lis, err := net.Listen("tcp", ":8080")
if err != nil {
    panic(err)
}
if err := s.Serve(lis); err != nil {
    panic(err)
}

このようにcodecの実装と設定を行うことでデシリアライズ/シリアライズ処理をカスタマイズすることが可能となります。

カスタムcodec利用のデメリットと対策

カスタムcodecを利用することでgRPCのシリアライズ/デシリアライズを任意の処理にカスタマイズできることを紹介しましたが、一方でカスタムcodecの利用方法によってはデメリットもあります。

そのデメリットとは、デフォルトのcodecをカスタムcodecに変更した場合に既製のgRPCのクライアントツールが利用できなくなるという点です。

gRPCにはgRPC標準の grpc-cli や、人気の gRPCurl といったコマンドラインクライアントツールが存在します。しかしこれらのクライアントツールではcodecの設定を行うことができずデフォルトのproto codecが利用されるため、デフォルトのcodecをカスタムcodecに変更したアイプラの開発では利用することができませんでした。

アイプラではこの問題を gRPC-Gatewaygrpc-gateway/protoc-gen-openapiv2 プラグイン、そして Swagger-UI を利用して解決しました。

swagger-ui

1つの.protoファイルからgRPCのコード、gRPC-Gatewayのコード、そしてOpenAPI Specファイルを出力します。出力されたコードを元にgRPCサーバーとgRPC-Gatewayの実装を行います。その際、gRPCサーバー、gRPC-Gatewayにそれぞれカスタムcodecの設定を行います。

【gRPCサーバー】

s := grpc.NewServer(grpc.ForceServerCodec(&CustomCodec{}))
lis, err := net.Listen("tcp", ":8080")
if err != nil {
    panic(err)
}
if err := s.Serve(lis); err != nil {
    panic(err)
}

【gRPC-Gateway】

addr := ":9080"
target := "localhost:8080"
mux := runtime.NewServeMux()
opts := []grpc.DialOption{
    grpc.WithInsecure(),
    grpc.WithDefaultCallOptions(grpc.ForceCodec(&CustomCodec{})),
}
if err := pb.RegisterGameHandlerFromEndpoint(context.Background(), mux, target, opts); err != nil {
    panic(err)
}

s := &http.Server{
    Addr:    addr,
    Handler: mux,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
    panic(err)
}
if err := s.Serve(ln); err != nil {
    panic(err)
}

このようにgRPC-Gateway側にもカスタムcodecを設定することで、gRPCサーバーとgRPC-Gateway間でカスタムcodecを利用した通信を行ってくれるようになります。

それに加え、.protoファイルからgrpc-gateway/protoc-gen-openapiv2プラグインを利用してOpenAPI Specファイルを生成しSwagger-UIに読み込ませることで、APIドキュメントの作成とSwagger-UIが持つ機能を利用した使い勝手の良いクライアントツールを用意することができました。

おわりに

今回アイプラでのgRPCの導入と利用について一部紹介をさせて頂きました。

gRPCを導入したことで高速な通信に加え、チーム開発として重要なAPIインタフェースの可視化やコードの自動生成、APIのメタデータの拡充や通信内容のカスタマイズなど「開発」においての様々なメリットを得ることができました。gRPCは今回紹介した機能の他にもエコシステムが充実しているため、要件に応じた様々な利用ができるのではないかと考えております。

本記事後半で紹介をしたcodecのカスタマイズは特殊な利用方法ではありますが、gRPCの基本的な使い方は非常にシンプルで簡単なため、ご興味を持って頂けた方は実際にぜひ触れて頂ければと思います。その際、本記事が参考になれば幸いです。

2013年株式会社サイバーエージェント新卒入社。バックエンドエンジニアとして複数のプロジェクトの開発や新卒採用に携わる。