現代のアプリケーションにおけるWeb APIの実装形式(1) gRPC

// Application

// Part of 現代のアプリケーションにおけるWeb API

その昔、ウェブアプリケーションはとてもシンプルなものでした。ユーザーがブラウザへURLを入力し、該当するHTMLリソースを返却する。必要に応じてCGIスクリプトを実行し、データベースやファイルの更新を行って新たなHTMLを生成する。このようなアーキテクチャは、本質的にはクライアントとサーバーの間でHTMLをやり取りするのみの簡単なものでした。しかし現代では、一つの画面で複数の操作ができるダッシュボード型のアプリケーションや、リアルタイムでデータを更新するアプリケーションが増えています。このようなアプリケーションのコアな機能を実装するために、単純なHTMLの交換のみでは不十分であるケースが増えてきています。

そんな現代の複雑なウェブアプリケーションでは、クライアントとサーバー間におけるデータのやり取りをHTMLの交換だけではなく、様々な形で行う「Web API」という仕組みが使われるようになりました。Web APIは、クライアントとサーバー間でデータをやり取りするためのインターフェースを提供するもので、ウェブアプリケーションのコアな機能を実装するために欠かせないものとなっています。

この連載では、現代のウェブアプリケーションにおけるWeb APIの実装形式について、その特徴や利点、実装方法などを解説していきます。第1回目の今回は、比較的新しいWeb APIの実装形式の一つである「gRPC」について解説します。

そもそもRPCとは

RPCとは、Remote Procedure Callの略で、リモートのコンピュータ上でプログラムを実行するための仕組みです。通常、プログラムは同じコンピュータ上のプロセス間でのみ実行されますが、RPCを使うことでクライアントがリモートのサーバー上で実行されているプログラムに対して、ローカルのプログラムと同じように関数を呼び出すことができるため、クライアントとサーバー間でのデータのやり取りを簡単に行うことができます。

gRPCとは

gRPC(Google Remote Procedure Call) は、Googleが開発したRPCの一種で、HTTP/2をベースにしたプロトコルを使用しています。gRPCは、Protocol Buffersというデータのシリアライズ形式を使用しており、この形式を使うことで、データのシリアライズやデシリアライズを自動的に行うことができます。また、gRPCは、HTTP/2をベースにしているため、リクエストやレスポンスを並列で送信することができ、通信の効率を向上させることができます。

また、複数の言語でのサポートがされており、異なる言語間での通信を行う際にも利用することができます。

gRPCの歴史

gRPCは2015年にGoogleによってオープンソース化されました。しかし、gRPC自体はGoogleの社内プロジェクトであるRPCフレームワーク・Stubbyをベースにしており、Google内で2005年から使用されていました。Stubbyは、Google内のマイクロサービスアーキテクチャを支える基盤として活躍していましたが、これをオープンソース化したものが今日のgRPCとなっています。

gRPCが目指した物と解決

gRPCは、以下のような課題に対する解決として開発されました。

  1. スケーラブルなマイクロサービスアーキテクチャのサポート:
    マイクロサービスアーキテクチャでは、個々のサービスが独立して動作し、サービス間で大量の通信が行われます。このため、効率的で高速な通信プロトコルが必要でした。gRPCは、Google内部でのStubbyの成功を踏まえ、マイクロサービス間の通信に最適なプロトコルを目指しました。

  2. 言語やプラットフォームの壁を超えた通信:
    gRPCは多言語対応を重視して設計されています。異なるプログラミング言語で実装されたサービス間でもシームレスに通信できることが求められていました。これにより、企業やプロジェクトが特定の言語やプラットフォームに依存せず、最適な言語を選択して開発できるようになっています。

  3. 効率的な通信と低レイテンシ:
    gRPCは、HTTP/2とProtocol Buffersの組み合わせにより、軽量で低レイテンシの通信を実現しています。特にProtocol Buffersはバイナリフォーマットであるため、データのシリアライズ/デシリアライズが非常に効率的で、通信量を大幅に削減できます。これにより、リソースの節約と高速な通信が可能になります。

  4. 双方向ストリーミングとリアルタイム通信:
    HTTP/2の特性を活かし、gRPCは双方向ストリーミングを標準でサポートしています。これにより、クライアントとサーバーがリアルタイムで大量のデータをやり取りでき、チャットやライブフィードなど、双方向性が必要なアプリケーションに適しています。

実際にgRPCを使ってみる

百聞は一見にしかず、実際にgRPCを使ってみましょう。以下は、gRPCを使って簡単なサーバーとクライアントを実装する例です。
なお、今回作成したプロジェクトは公開しておりますので、興味のある方はクローンして手元で動かしてみてください。

https://github.com/mast1ff/2024-10-11_grpc

プロジェクトの作成

gRPCは、複数の言語でサポートされており、どの言語でも堅牢で効率的な通信が実現できるよう設計されています。現在、gRPCが公式でサポートしている言語は以下の通り。

  • C# / .NET
  • C++
  • Dart
  • Go
  • Java
  • Kotlin
  • Node.js
  • Objective-C
  • PHP
  • Python
  • Ruby

gRPCはクライアントとサーバー間の通信のためのプロトコルを提供するため、クライアントとサーバーの両方を実装する必要があります。今回は両方Node.jsを使用して実装していきます。
まずはgRPCに必要なツールをインストールします。

$ pnpm add -D @grpc/grpc-js @grpc/proto-loader grpc-tools

また、今回はそれぞれの実装にTypeScriptを使用したいため、以下のパッケージもインストールします。

$ pnpm add -D typescript grpc_tools_node_protoc_ts

Protocol Buffersの定義

gRPCの大きな特徴として、Protocol Buffersというデータのシリアライズ形式を使用していることが挙げられます。Protocol Buffersは、通信を行うデータのシリアライズやデシリアライズを行うためのメカニズムであり、これにより異なる言語間や環境間でも共通のデータ形式を保持することが容易くなっています。

まずは、Protocol Buffersの定義ファイルを作成します。以下のような内容で、services/hello/hello.protoというファイルを作成します。

services/hello/hello.proto
syntax = "proto3";

package hello;

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
    string name = 1;
}

message HelloReply {
    string message = 1;
}

このファイルでは、Greeterというサービスを定義しています。このサービスには、SayHelloというメソッドがあり、HelloRequestというリクエストメッセージを受け取り、HelloReplyというレスポンスメッセージを返すようになっています。このとき、HelloRequestHelloReplyがクライアントとサーバー間でやり取りされる共通のデータの形式となります。
これを図にしたものが以下となります(https://grpc.io/docs/what-is-grpc/introduction/ より引用)。

Protocol Buffersを用いた通信の図

  • クライアントは、HelloRequestを作成してサーバーのsayHelloへ送信し、HelloReplyを受け取る。
  • サーバーは、sayHelloの実装の中でHelloRequestを受け取り、HelloReplyを返す。

gRPCのコードの生成

定義したProtocol Buffersのファイルから、クライアントとサーバーのコードを生成します。以下のコマンドを実行して、services/hello/hello.protoからTypeScriptのコードを生成します。

$ ./node_modules/.bin/grpc_tools_node_protoc \
    --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
    --js_out=import_style=commonjs,binary:. \
    --grpc_out=grpc_js:. \
    --ts_out=service=grpc-node,mode=grpc-js:. \
    -I . \
    services/**/*.proto`;

このコマンドにより、services/helloディレクトリにhello_grpc_pb.jshello_pb.jsというファイルが生成されます。これらのファイルには、Protocol Buffersの定義に基づいてクライアントとサーバーのコードが生成されています。実際に生成されたコードが以下。

hello_grpc_pb.js
// GENERATED CODE -- DO NOT EDIT!

'use strict';
var grpc = require('@grpc/grpc-js');
var services_hello_hello_pb = require('../../services/hello/hello_pb.js');

function serialize_hello_HelloReply(arg) {
  if (!(arg instanceof services_hello_hello_pb.HelloReply)) {
    throw new Error('Expected argument of type hello.HelloReply');
  }
  return Buffer.from(arg.serializeBinary());
}

function deserialize_hello_HelloReply(buffer_arg) {
  return services_hello_hello_pb.HelloReply.deserializeBinary(new Uint8Array(buffer_arg));
}

function serialize_hello_HelloRequest(arg) {
  if (!(arg instanceof services_hello_hello_pb.HelloRequest)) {
    throw new Error('Expected argument of type hello.HelloRequest');
  }
  return Buffer.from(arg.serializeBinary());
}

function deserialize_hello_HelloRequest(buffer_arg) {
  return services_hello_hello_pb.HelloRequest.deserializeBinary(new Uint8Array(buffer_arg));
}


var GreeterService = exports.GreeterService = {
  sayHello: {
    path: '/hello.Greeter/SayHello',
    requestStream: false,
    responseStream: false,
    requestType: services_hello_hello_pb.HelloRequest,
    responseType: services_hello_hello_pb.HelloReply,
    requestSerialize: serialize_hello_HelloRequest,
    requestDeserialize: deserialize_hello_HelloRequest,
    responseSerialize: serialize_hello_HelloReply,
    responseDeserialize: deserialize_hello_HelloReply,
  },
};

exports.GreeterClient = grpc.makeGenericClientConstructor(GreeterService);
hello_pb.js
// source: services/hello/hello.proto
/**
 * @fileoverview
 * @enhanceable
 * @suppress {missingRequire} reports error on implicit type usages.
 * @suppress {messageConventions} JS Compiler reports an error if a variable or
 *     field starts with 'MSG_' and isn't a translatable message.
 * @public
 */
// GENERATED CODE -- DO NOT EDIT!
/* eslint-disable */
// @ts-nocheck

var jspb = require('google-protobuf');
var goog = jspb;
var global = (function() {
  if (this) { return this; }
  if (typeof window !== 'undefined') { return window; }
  if (typeof global !== 'undefined') { return global; }
  if (typeof self !== 'undefined') { return self; }
  return Function('return this')();
}.call(null));

goog.exportSymbol('proto.hello.HelloReply', null, global);
goog.exportSymbol('proto.hello.HelloRequest', null, global);
/**
 * Generated by JsPbCodeGenerator.
 * @param {Array=} opt_data Optional initial data array, typically from a
 * server response, or constructed directly in Javascript. The array is used
 * in place and becomes part of the constructed object. It is not cloned.
 * If no data is provided, the constructed object will be empty, but still
 * valid.
 * @extends {jspb.Message}
 * @constructor
 */
proto.hello.HelloRequest = function(opt_data) {
  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.hello.HelloRequest, jspb.Message);
if (goog.DEBUG && !COMPILED) {
  /**
   * @public
   * @override
   */
  proto.hello.HelloRequest.displayName = 'proto.hello.HelloRequest';
}
/**
 * Generated by JsPbCodeGenerator.
 * @param {Array=} opt_data Optional initial data array, typically from a
 * server response, or constructed directly in Javascript. The array is used
 * in place and becomes part of the constructed object. It is not cloned.
 * If no data is provided, the constructed object will be empty, but still
 * valid.
 * @extends {jspb.Message}
 * @constructor
 */
proto.hello.HelloReply = function(opt_data) {
  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.hello.HelloReply, jspb.Message);
if (goog.DEBUG && !COMPILED) {
  /**
   * @public
   * @override
   */
  proto.hello.HelloReply.displayName = 'proto.hello.HelloReply';
}



if (jspb.Message.GENERATE_TO_OBJECT) {
/**
 * Creates an object representation of this proto.
 * Field names that are reserved in JavaScript and will be renamed to pb_name.
 * Optional fields that are not set will be set to undefined.
 * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
 * For the list of reserved names please see:
 *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
 * @param {boolean=} opt_includeInstance Deprecated. whether to include the
 *     JSPB instance for transitional soy proto support:
 *     http://goto/soy-param-migration
 * @return {!Object}
 */
proto.hello.HelloRequest.prototype.toObject = function(opt_includeInstance) {
  return proto.hello.HelloRequest.toObject(opt_includeInstance, this);
};


/**
 * Static version of the {@see toObject} method.
 * @param {boolean|undefined} includeInstance Deprecated. Whether to include
 *     the JSPB instance for transitional soy proto support:
 *     http://goto/soy-param-migration
 * @param {!proto.hello.HelloRequest} msg The msg instance to transform.
 * @return {!Object}
 * @suppress {unusedLocalVariables} f is only used for nested messages
 */
proto.hello.HelloRequest.toObject = function(includeInstance, msg) {
  var f, obj = {
    name: jspb.Message.getFieldWithDefault(msg, 1, "")
  };

  if (includeInstance) {
    obj.$jspbMessageInstance = msg;
  }
  return obj;
};
}


/**
 * Deserializes binary data (in protobuf wire format).
 * @param {jspb.ByteSource} bytes The bytes to deserialize.
 * @return {!proto.hello.HelloRequest}
 */
proto.hello.HelloRequest.deserializeBinary = function(bytes) {
  var reader = new jspb.BinaryReader(bytes);
  var msg = new proto.hello.HelloRequest;
  return proto.hello.HelloRequest.deserializeBinaryFromReader(msg, reader);
};


/**
 * Deserializes binary data (in protobuf wire format) from the
 * given reader into the given message object.
 * @param {!proto.hello.HelloRequest} msg The message object to deserialize into.
 * @param {!jspb.BinaryReader} reader The BinaryReader to use.
 * @return {!proto.hello.HelloRequest}
 */
proto.hello.HelloRequest.deserializeBinaryFromReader = function(msg, reader) {
  while (reader.nextField()) {
    if (reader.isEndGroup()) {
      break;
    }
    var field = reader.getFieldNumber();
    switch (field) {
    case 1:
      var value = /** @type {string} */ (reader.readString());
      msg.setName(value);
      break;
    default:
      reader.skipField();
      break;
    }
  }
  return msg;
};


/**
 * Serializes the message to binary data (in protobuf wire format).
 * @return {!Uint8Array}
 */
proto.hello.HelloRequest.prototype.serializeBinary = function() {
  var writer = new jspb.BinaryWriter();
  proto.hello.HelloRequest.serializeBinaryToWriter(this, writer);
  return writer.getResultBuffer();
};


/**
 * Serializes the given message to binary data (in protobuf wire
 * format), writing to the given BinaryWriter.
 * @param {!proto.hello.HelloRequest} message
 * @param {!jspb.BinaryWriter} writer
 * @suppress {unusedLocalVariables} f is only used for nested messages
 */
proto.hello.HelloRequest.serializeBinaryToWriter = function(message, writer) {
  var f = undefined;
  f = message.getName();
  if (f.length > 0) {
    writer.writeString(
      1,
      f
    );
  }
};


/**
 * optional string name = 1;
 * @return {string}
 */
proto.hello.HelloRequest.prototype.getName = function() {
  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, ""));
};


/**
 * @param {string} value
 * @return {!proto.hello.HelloRequest} returns this
 */
proto.hello.HelloRequest.prototype.setName = function(value) {
  return jspb.Message.setProto3StringField(this, 1, value);
};





if (jspb.Message.GENERATE_TO_OBJECT) {
/**
 * Creates an object representation of this proto.
 * Field names that are reserved in JavaScript and will be renamed to pb_name.
 * Optional fields that are not set will be set to undefined.
 * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
 * For the list of reserved names please see:
 *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
 * @param {boolean=} opt_includeInstance Deprecated. whether to include the
 *     JSPB instance for transitional soy proto support:
 *     http://goto/soy-param-migration
 * @return {!Object}
 */
proto.hello.HelloReply.prototype.toObject = function(opt_includeInstance) {
  return proto.hello.HelloReply.toObject(opt_includeInstance, this);
};


/**
 * Static version of the {@see toObject} method.
 * @param {boolean|undefined} includeInstance Deprecated. Whether to include
 *     the JSPB instance for transitional soy proto support:
 *     http://goto/soy-param-migration
 * @param {!proto.hello.HelloReply} msg The msg instance to transform.
 * @return {!Object}
 * @suppress {unusedLocalVariables} f is only used for nested messages
 */
proto.hello.HelloReply.toObject = function(includeInstance, msg) {
  var f, obj = {
    message: jspb.Message.getFieldWithDefault(msg, 1, "")
  };

  if (includeInstance) {
    obj.$jspbMessageInstance = msg;
  }
  return obj;
};
}


/**
 * Deserializes binary data (in protobuf wire format).
 * @param {jspb.ByteSource} bytes The bytes to deserialize.
 * @return {!proto.hello.HelloReply}
 */
proto.hello.HelloReply.deserializeBinary = function(bytes) {
  var reader = new jspb.BinaryReader(bytes);
  var msg = new proto.hello.HelloReply;
  return proto.hello.HelloReply.deserializeBinaryFromReader(msg, reader);
};


/**
 * Deserializes binary data (in protobuf wire format) from the
 * given reader into the given message object.
 * @param {!proto.hello.HelloReply} msg The message object to deserialize into.
 * @param {!jspb.BinaryReader} reader The BinaryReader to use.
 * @return {!proto.hello.HelloReply}
 */
proto.hello.HelloReply.deserializeBinaryFromReader = function(msg, reader) {
  while (reader.nextField()) {
    if (reader.isEndGroup()) {
      break;
    }
    var field = reader.getFieldNumber();
    switch (field) {
    case 1:
      var value = /** @type {string} */ (reader.readString());
      msg.setMessage(value);
      break;
    default:
      reader.skipField();
      break;
    }
  }
  return msg;
};


/**
 * Serializes the message to binary data (in protobuf wire format).
 * @return {!Uint8Array}
 */
proto.hello.HelloReply.prototype.serializeBinary = function() {
  var writer = new jspb.BinaryWriter();
  proto.hello.HelloReply.serializeBinaryToWriter(this, writer);
  return writer.getResultBuffer();
};


/**
 * Serializes the given message to binary data (in protobuf wire
 * format), writing to the given BinaryWriter.
 * @param {!proto.hello.HelloReply} message
 * @param {!jspb.BinaryWriter} writer
 * @suppress {unusedLocalVariables} f is only used for nested messages
 */
proto.hello.HelloReply.serializeBinaryToWriter = function(message, writer) {
  var f = undefined;
  f = message.getMessage();
  if (f.length > 0) {
    writer.writeString(
      1,
      f
    );
  }
};


/**
 * optional string message = 1;
 * @return {string}
 */
proto.hello.HelloReply.prototype.getMessage = function() {
  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, ""));
};


/**
 * @param {string} value
 * @return {!proto.hello.HelloReply} returns this
 */
proto.hello.HelloReply.prototype.setMessage = function(value) {
  return jspb.Message.setProto3StringField(this, 1, value);
};


goog.object.extend(exports, proto.hello);

これらの生成されたコードは、飽くまでクライアントとサーバー間の通信のためのプロトコルを定義しているだけであり、データベースやファイルなどのリソースにアクセスしたりなどの実際のロジックは含まれていません。ですので、これをベースにしてクライアントとサーバーの実装を行っていきます。

helloサービスのサーバーの実装

まずはサーバー側の実装を行います。gRPCでリクエストを受け取るサーバーは、生成されたコード内にサーバーの起動まで含まれているため、各サービスの実装を行うだけでサーバーアプリケーションを作成することが出来ます。しかしこれには、認証やミドルウェアなどの機能は含まれていないことに注意してください。

以下のような内容で、server/hello.tsserver/main.tsというファイルを作成します。

server/hello.ts
import type { sendUnaryData, ServerUnaryCall, UntypedHandleCall } from "@grpc/grpc-js";
import { IGreeterServer } from "../services/hello/hello_grpc_pb";
import { HelloReply, HelloRequest } from "../services/hello/hello_pb";

export class GreeterServer implements IGreeterServer {
  [name: string]: UntypedHandleCall;

  public sayHello(call: ServerUnaryCall<HelloRequest, HelloReply>, callback: sendUnaryData<HelloReply>): void {
    const reply = new HelloReply();
    reply.setMessage(call.request.getName());

    callback(null, reply);
  }
}

server/main.ts
import { Server, ServerCredentials } from "@grpc/grpc-js";
import { GreeterService } from "../services/hello/hello_grpc_pb";
import { GreeterServer } from "./hello";

const bootstrap = async () => {
  // サーバーのインスタンスを作成
  const server = new Server();

  // サービスの実装
  server.addService(GreeterService, new GreeterServer());

  // サーバーの起動
  server.bindAsync("0.0.0.0:9000", ServerCredentials.createInsecure(), (err, port) => {
    if (err) {
      console.error(err);
      return;
    }

    console.log(`Server is running on ${port}`);
  });
};

bootstrap();

server/hello.tsでは、GreeterServerというクラスを定義しています。このクラスは、IGreeterServerというインターフェースを実装しており、sayHelloというメソッドを持っています。このメソッドは、HelloRequestを受け取り、HelloReplyを返すようになっています。
つまり、このクラスこそが、services/hello/hello.protoで定義したGreeterサービスの実装になります。もしもデータベースなどの外部リソースへの接続やその結果が必要な場合は、このメソッド内で実装することになります。

そして、server/main.tsでは、サーバーのインスタンスを作成し、GreeterServerGreeterServiceにバインドしています。最後に、server.bindAsyncメソッドを使って、サーバーを起動しています。

これにより、このサーバーでgRPCを用いてGreeterServiceへのリクエストを受け付けることができるようになりました。

helloサービスのクライアントの実装

さて、先ほど実装したサーバーへリクエストを送信するためのクライアントを実装していきます。クライアントとはいえ、現在ブラウザから直接gRPCのリクエストを送信することは難しいため、Node.js上でクライアントを実装します。
ブラウザを用いて実装する場合は、gRPC Webなどを使用してプロキシサーバーを介してgRPCのリクエストを送信することが一般的です。

今回は、純粋なNode.jsを用いたクライアントの実装を行います。まずはクライアントを作成するために用いるフレームワークやライブラリをインストールします。今回はhonoというウェブアプリケーションフレームワークを使用して実装します。

$ pnpm add hono @hono/node-server

以下のような内容で、client/hello.tsclient/main.tsというファイルを作成します。

client/hello.ts
import { Hono } from "hono";
import { html } from "hono/html";
import { credentials } from "@grpc/grpc-js";
import { GreeterClient } from "../services/hello/hello_grpc_pb";
import { HelloRequest, HelloReply } from "../services/hello/hello_pb";

const app = new Hono();

app
  // 名前を入力するフォームを表示
  .get("/", async (ctx) => {
    ctx.header("Content-Type", "text/html");
    ctx.status(200);
    return ctx.html(
      html`<h1>Hello, ...?</h1>
        <form action="/hello" method="post">
          <input type="text" name="name" />
          <button type="submit">Submit</button>
        </form>
      `
    ),
  })
  // gRPCサーバーにリクエストを送信
  .post("/", async (ctx) => {
    // gRPCクライアントの作成
    const client = new GreeterClient("localhost:9000", credentials.createInsecure());

    // リクエストの作成
    const request = new HelloRequest();
    request.setName(ctx.body.name as string);

    // リクエストの送信
    const response = await new Promise<HelloReply>((resolve, reject) => {
      client.sayHello(request, (err, res) => {
        if (err) {
          reject(err);
          return;
        }

        resolve(res);
      });
    });

    ctx.header("Content-Type", "text/html");
    ctx.status(200);
    return ctx.html(
      html`<h1>${response.getMessage()}</h1>
        <form action="/hello" method="post">
          <input type="text" name="name" />
          <button type="submit">Submit</button>
        </form>
      `
    );
  });

export const hello = app;
client/main.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { hello } from "./hello";

const app = new Hono();

app.route("/hello", hello);

serve(app, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`);
});

client/hello.tsでは、Honoを使ってクライアントの実装を行っています。このクライアントは、/helloにアクセスすると名前を入力するフォームが表示され、そのフォームから/helloにPOSTリクエストを送信することで、gRPCサーバーのsayHelloへリクエストを送信し、その結果を画面に表示します。

client/main.tsでは、/helloのルーティングを行うhello/helloにバインドしています。そして、serve関数を使ってサーバーを起動しています。

サーバーとクライアントの起動

これにより、このクライアントでgRPCサーバーにリクエストを送信することができるようになりました。実際に起動をして挙動を確認してみましょう。

今回はTypeScriptを使用しているので本来であればビルド環境を整える必要がありますが、今回は省略するためにtsxというパッケージを使用します。

$ pnpm add -D tsx

# サーバーの起動
$ pnpm tsx server/main.ts
Server listening on 9000

# クライアントの起動
$ pnpm tsx client/main.ts
Client server listening on http://localhost:3000

ブラウザでhttp://localhost:3000/helloへアクセスし、名前を入力して送信すると、gRPCサーバーにリクエストが送信され、その結果が画面に表示されることが確認できるはずです。

ここまでで、gRPCを使ったサーバーとクライアントの実装を行いました。しかしこれだけでは、実践でどのように使われるのか、どのようなメリットがあるのかが分かりにくいかもしれません。そこで、実際のCRUDを行うAPIをgRPCで実装しつつ、そのメリットやデメリットを考えていきましょう。

実践的なAPIの実装

今回はタスク管理アプリケーションを作成し、そのAPIをgRPCで実装していきます。このアプリケーションでは、タスクの作成、取得、更新、削除を行うAPIを提供します。また、データベースとしてSQLiteを使用し、DrizzleORMを使ってデータベースとのやり取りを行います。

まずはそれぞれに必要なパッケージをインストールと設定を行います。

$ pnpm add drizzle-orm @libsql/client dotenv
$ pnpm add -D drizzle-kit

データベースの設定

drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "sqlite",
  schema: "./db/schema.ts",
  out: "./db/migrations",
  dbCredentials: {
    url: process.env.DB_FILE_NAME!,
  },
});
.env
DB_FILE_NAME="file:local.db"
db/schema.ts
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const tasks = sqliteTable("tasks", {
  id: int("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  description: text("description").notNull(),
  completed: int("completed", { mode: "boolean" }).notNull().default(false),
  created: int("created", { mode: "timestamp" }).notNull(),
  updated: int("updated", { mode: "timestamp" }),
});

export type TaskSchema = typeof tasks.$inferSelect;
# マイグレーション用のSQLの発行
$ pnpm drizzle-kit generate
# マイグレーションの実行
$ pnpm drizzle-kit migrate

gRPCの定義

services/tasks/tasks.proto
syntax = "proto3";

syntax = "proto3";

package tasks;

service TaskManager {
    // タスクの作成
    rpc CreateTask (CreateTaskRequest) returns (Task) {}
    // タスクの取得
    rpc GetTask (GetTaskRequest) returns (Task) {}
    // タスクの一覧取得
    rpc ListTasks (ListTasksRequest) returns (ListTasksResponse) {}
    // タスクの更新
    rpc UpdateTask (UpdateTaskRequest) returns (Task) {}
    // タスクの削除
    rpc DeleteTask (DeleteTaskRequest) returns (Task) {}
}

message CreateTaskRequest {
    string title = 1;
    string description = 2;
}

message GetTaskRequest {
    int32 id = 1;
}

message ListTasksRequest {
    int32 page = 1;
    int32 pageSize = 2;
}

message ListTasksResponse {
    repeated Task tasks = 1;
}

message UpdateTaskRequest {
    int32 id = 1;
    string title = 2;
    string description = 3;
    bool completed = 4;
}

message DeleteTaskRequest {
    int32 id = 1;
}

message Task {
    int32 id = 1;
    string title = 2;
    string description = 3;
    bool completed = 4;
    int64 created = 5;
    optional int64 updated = 6;
}

gRPCのコードの生成

$ ./node_modules/.bin/grpc_tools_node_protoc \
    --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
    --js_out=import_style=commonjs,binary:. \
    --grpc_out=grpc_js:. \
    --ts_out=service=grpc-node,mode=grpc-js:. \
    -I . \
    services/**/*.proto`;

ここまでで、データベースとgRPCの定義を行い、コードの生成を行いました。次に、gRPCサーバーとクライアントの実装を行っていきます。

サーバーの実装

server/tasks.ts
import { eq } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql";
import type { sendUnaryData, ServerUnaryCall, UntypedHandleCall } from "@grpc/grpc-js";
import type { ITaskManagerServer } from "../services/tasks/tasks_grpc_pb";
import {
  CreateTaskRequest,
  DeleteTaskRequest,
  GetTaskRequest,
  ListTasksRequest,
  ListTasksResponse,
  Task,
  UpdateTaskRequest,
} from "../services/tasks/tasks_pb";
import { tasks, TaskSchema } from "../db/schema";

export class TaskManagerServer implements ITaskManagerServer {
  // @ts-ignore
  private db: LibSQLDatabase;

  [name: string]: UntypedHandleCall;

  // コンストラクタでデータベースのインスタンスを受け取る
  constructor(db: LibSQLDatabase) {
    this.db = db;
  }

  // タスクの作成
  public async createTask(
    call: ServerUnaryCall<CreateTaskRequest, Task>,
    callback: sendUnaryData<Task>,
  ): Promise<void> {
    const [task] = await this.db
      .insert(tasks)
      .values({
        title: call.request.getTitle(),
        description: call.request.getDescription(),
        created: new Date(),
      })
      .returning();

    if (!task) {
      callback(new Error("Failed to create task"));
      return;
    }

    const reply = new Task();
    reply.setId(task.id);
    reply.setTitle(task.title);
    reply.setDescription(task.description);
    reply.setCompleted(task.completed);
    reply.setCreated(task.created.getTime());
    callback(null, reply);
  }

  // タスクの取得
  public async getTask(call: ServerUnaryCall<GetTaskRequest, Task>, callback: sendUnaryData<Task>): Promise<void> {
    const [task] = await this.db.select().from(tasks).where(eq(tasks.id, call.request.getId())).limit(1);

    if (!task) {
      callback(new Error("Task not found"));
      return;
    }

    const reply = new Task();
    reply.setId(task.id);
    reply.setTitle(task.title);
    reply.setDescription(task.description);
    reply.setCompleted(task.completed);
    reply.setCreated(task.created.getTime());
    callback(null, reply);
  }

  // タスクの一覧取得
  public async listTasks(
    call: ServerUnaryCall<ListTasksRequest, ListTasksResponse>,
    callback: sendUnaryData<ListTasksResponse>,
  ): Promise<void> {
    const page = call.request.getPage();
    const pageSize = call.request.getPagesize();

    let taskList: TaskSchema[];
    if (page && pageSize) {
      taskList = await this.db
        .select()
        .from(tasks)
        .limit(pageSize)
        .offset(page - 1 * pageSize);
    } else {
      taskList = await this.db.select().from(tasks).all();
    }

    const reply = new ListTasksResponse();
    reply.setTasksList(
      taskList.map((task) => {
        const reply = new Task();
        reply.setId(task.id);
        reply.setTitle(task.title);
        reply.setDescription(task.description);
        reply.setCompleted(task.completed);
        reply.setCreated(task.created.getTime());
        return reply;
      }),
    );

    callback(null, reply);
  }

  // タスクの更新
  public async updateTask(
    call: ServerUnaryCall<UpdateTaskRequest, Task>,
    callback: sendUnaryData<Task>,
  ): Promise<void> {
    const [task] = await this.db
      .update(tasks)
      .set({
        title: call.request.getTitle(),
        description: call.request.getDescription(),
        completed: call.request.getCompleted(),
        updated: new Date(),
      })
      .where(eq(tasks.id, call.request.getId()))
      .returning();

    if (!task) {
      callback(new Error("Task not found"));
      return;
    }

    const reply = new Task();
    reply.setId(task.id);
    reply.setTitle(task.title);
    reply.setDescription(task.description);
    reply.setCompleted(task.completed);
    reply.setCreated(task.created.getTime());
    callback(null, reply);
  }

  // タスクの削除
  public async deleteTask(
    call: ServerUnaryCall<DeleteTaskRequest, Task>,
    callback: sendUnaryData<Task>,
  ): Promise<void> {
    const [task] = await this.db.delete(tasks).where(eq(tasks.id, call.request.getId())).returning();

    if (!task) {
      callback(new Error("Task not found"));
      return;
    }

    const reply = new Task();
    reply.setId(task.id);
    reply.setTitle(task.title);
    reply.setDescription(task.description);
    reply.setCompleted(task.completed);
    reply.setCreated(task.created.getTime());
    callback(null, reply);
  }
}
server/main.ts
import "dotenv/config";
import { Server, ServerCredentials } from "@grpc/grpc-js";
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import { GreeterService } from "../services/hello/hello_grpc_pb";
import { TaskManagerService } from "../services/tasks/tasks_grpc_pb";
import { TaskManagerServer } from "./tasks";
import { GreeterServer } from "./hello";

const bootstrap = async () => {
  // データベースのインスタンスを作成
  const client = createClient({ url: process.env.DB_FILE_NAME! });
  const db = drizzle(client);
  
  const server = new Server();

  server.addService(GreeterService, new GreeterServer());
  // タスク管理サービスの実装、データベースのインスタンスを渡す
  server.addService(TaskManagerService, new TaskManagerServer(db));

  server.bindAsync("0.0.0.0:9000", ServerCredentials.createInsecure(), (err, port) => {
    if (err) {
      console.error(err);
      return;
    }

    console.log(`Server listening on ${port}`);
  });
};

bootstrap();

クライアントの実装

client/tasks.ts
import { Hono } from "hono";
import { html } from "hono/html";
import { credentials } from "@grpc/grpc-js";
import { TaskManagerClient } from "../services/tasks/tasks_grpc_pb";
import { layout } from "./layout";
import {
  CreateTaskRequest,
  DeleteTaskRequest,
  ListTasksRequest,
  ListTasksResponse,
  Task,
  UpdateTaskRequest,
} from "../services/tasks/tasks_pb";

const createClient = () => {
  return new TaskManagerClient("localhost:9000", credentials.createInsecure());
};

const app = new Hono();

function int(value: any, def: number): number {
  if (typeof value === "number") {
    return value;
  }

  if (typeof value !== "string") {
    return def;
  }

  if (Number.isNaN(Number.parseInt(value))) {
    return def;
  }

  return Number.parseInt(value);
}

app
  // タスクの一覧表示と作成フォーム
  .get("/", async (ctx) => {
    const client = createClient();
    const request = new ListTasksRequest();
    const page = int(ctx.req.query("page"), 1);

    request.setPage(page);
    request.setPagesize(10);

    const response = await new Promise<ListTasksResponse>((resolve, reject) => {
      client.listTasks(request, (err, response) => {
        if (err) {
          reject(err);
        } else {
          resolve(response);
        }
      });
    });

    ctx.header("Content-Type", "text/html");
    ctx.status(200);
    return ctx.html(
      html`<h1>Tasks</h1>
        <ul>
          ${response.getTasksList().map(
            (task) =>
              html`<li>
                <form id="delete-${task.getId()}" action="/tasks/${task.getId()}/delete" method="get"></form>
                <form action="/tasks/${task.getId()}" method="post">
                  <div style="display: flex;">
                    <input type="checkbox" name="completed" ${task.getCompleted() ? "checked" : ""} />
                    <input type="hidden" name="title" value="${task.getTitle()}" />
                    <input type="hidden" name="description" value="${task.getDescription()}" />
                    <div style="flex-grow: 1;">
                      <h2>${task.getTitle()}</h2>
                      <p>${task.getDescription()}</p>
                    </div>
                    <button type="submit">Update</button>
                    <button form="delete-${task.getId()}" type="submit">Delete</button>
                  </div>
                </form>
              </li>`,
          )}
        </ul>
        <form action="/tasks" method="post">
          <div>
            <label for="title">Title</label>
            <input type="text" name="title" id="title" />
          </div>
          <div>
            <label for="description">Description</label>
            <textarea name="description" id="description"></textarea>
          </div>
          <button type="submit">Submit</button>
        </form>`,
    );
  })
  // タスクの作成、作成後は一覧にリダイレクト
  .post("/", async (ctx) => {
    const payload = await ctx.req.parseBody();

    const client = createClient();
    const request = new CreateTaskRequest();
    request.setTitle(payload.title as string);
    request.setDescription(payload.description as string);

    const task = await new Promise<Task>((resolve, reject) => {
      client.createTask(request, (err, response) => {
        if (err) {
          reject(err);
        } else {
          resolve(response);
        }
      });
    });

    ctx.status(302);
    return ctx.redirect("/tasks");
  })
  // タスクの更新、更新後は一覧にリダイレクト
  .post("/:id", async (ctx) => {
    const id = ctx.req.param("id");
    const payload = await ctx.req.parseBody();

    const client = createClient();
    const request = new UpdateTaskRequest();
    request.setId(int(id, 0));
    request.setTitle(payload.title as string);
    request.setDescription(payload.description as string);
    request.setCompleted(payload.completed === "on");

    await new Promise<void>((resolve, reject) => {
      client.updateTask(request, (err, response) => {
        if (err) {
          reject(err);
        } else {
          resolve();
        }
      });
    });

    ctx.status(302);
    return ctx.redirect("/tasks");
  })
  // タスクの削除、削除後は一覧にリダイレクト
  .get("/:id/delete", async (ctx) => {
    const id = ctx.req.param("id");

    const client = createClient();
    const request = new DeleteTaskRequest();
    request.setId(int(id, 0));

    await new Promise<void>((resolve, reject) => {
      client.deleteTask(request, (err, response) => {
        if (err) {
          reject(err);
        } else {
          resolve();
        }
      });
    });

    ctx.status(302);
    return ctx.redirect("/tasks");
  });

export const tasks = app;
client/main.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { hello } from "./hello";
import { layout } from "./layout";
import { html } from "hono/html";
import { tasks } from "./tasks";

const app = new Hono();

app.route("/hello", hello);
// タスク管理アプリのルーティング
app.route("/tasks", tasks);

app.get("/", async (ctx) => {
  return ctx.html(
    layout(
      html`<h1>gRPC App</h1>
        <a href="/hello">Hello</a>
        <a href="/tasks">Tasks</a>`,
    ),
  );
});

serve(app, (info) => {
  console.log(`Client server listening on http://localhost:${info.port}`);
});

サーバーとクライアントの起動

$ pnpm tsx server/main.ts
Server listening on 9000

$ pnpm tsx client/main.ts
Client server listening on http://localhost:3000

いかがでしょうか?少し複雑になりましたが、これでタスク管理アプリケーションが作成され、gRPCを使ってAPIを提供することができるようになりました。

実装から考えるgRPCのメリットとデメリット

ここまで、gRPCを用いた簡易的なアプリケーションの実装を行いました。この実装を通して、gRPCのメリットとデメリットを考えてみましょう。

メリット

性能

gRPCはHTTP/2をベースにしており、HTTP/2はマルチプレキシングやヘッダー圧縮などの機能を持っているため、HTTP/1.1よりも高速に通信を行うことができます。また、バイナリ形式で通信を行うため、JSONやXMLなどのテキスト形式よりも通信量が少なくなります。

プロトコルバッファ

gRPCはGoogleが開発したプロトコルバッファを使って通信を行うため、データのシリアライズやデシリアライズが高速に行えます。また、プロトコルバッファはスキーマを定義することができるため、APIの変更に対しても柔軟に対応することができます。

クライアントとサーバーのコード生成

gRPCはプロトコルバッファからクライアントとサーバーのコードを生成することができるため、クライアントとサーバーの間の通信のためのプロトコルを定義するだけで、実際の通信のためのコードを書く必要がありません。これにより、開発効率を向上させることができます。

異なる言語間のインターフェースの統一化

gRPCはプロトコルバッファを使って通信を行うため、異なる言語間でのインターフェースを統一することができます。これにより、異なる言語で開発されたクライアントとサーバーが簡単に通信を行うことができます。

ストリーミングやリアルタイム通信のサポート

通常のrpc通信に加えて、リクエストとレスポンスの片方または両方のストリーミング通信をサポートしています。これにより、リアルタイム通信や大量のデータを効率的に送受信することができます。

デメリット

デバッグの難しさ

gRPCはバイナリ形式で通信を行うため、通常のHTTP通信のようにブラウザの開発者ツールなどで通信内容を確認することが難しいです。そのため、デバッグが難しくなることがあります。

学習コスト

gRPCはHTTP/2やプロトコルバッファなど、新しい技術を使って通信を行うため、学習コストが高いと言われています。また、gRPCを使った開発には、プロトコルバッファの定義やコード生成など、新しい作業が必要になるため、プロジェクトの開始当初は開発効率が低下することがあります。

HTTP/2サポートの制限

HTTP/2に依存しているため、古いネットワーク環境やプロキシでは対応が必要な場合があります。

gRPCを使用するべきケース

これらのメリットやデメリットから、gRPCを使用するべきケースを考察します。

マイクロサービスアーキテクチャ

マイクロサービスでは、複数の独立したサービス間で通信が頻繁に行われます。gRPCは、以下の点でマイクロサービスの通信に非常に適しています。

  • Protocol Buffersによる軽量なデータフォーマットとHTTP/2を使うことで、高速で効率的な通信が可能です。
  • 異なるプログラミング言語で書かれたサービス同士を簡単に通信させることができます。
  • サービス間でリアルタイムにデータをストリームできるため、継続的なデータ交換が必要なマイクロサービスに最適です。

モバイルアプリケーション

モバイルアプリケーションでは、通信速度や通信量が重要な要素となります。gRPCはHTTP/2を使うことで高速な通信が可能であり、バイナリ形式で通信を行うため、通信量を削減することができます。また、プロトコルバッファを使うことで、データのシリアライズやデシリアライズが高速に行えるため、モバイルアプリケーションの通信に適しています。

複雑なクライアント・サーバー間の通信パターン

gRPCは、音声やビデオのストリーミングや大規模なファイル転送などの、複雑な通信パターンが要求される場面にも適しています。ストリーミング通信をサポートしているため、リアルタイム通信や大量のデータを効率的に送受信することができます。

分散システムやクラウド

分散システムでは、多数のノード間で効率的な通信が必要です。gRPCは低レイテンシかつスケーラブルな通信を実現するため、以下のような場面で適しています。

  • KubernetesやDockerを使ったクラウドネイティブアプリケーションでのサービス間通信において、gRPCはその効率性とパフォーマンスで広く採用されています。
  • gRPCは、通信の詳細なトレーシングやモニタリングが必要な分散システムでも、通信プロトコルとして適しています。

まとめ

本記事では、gRPCを使ったWeb APIの実装について解説しました。gRPCは、高速で効率的な通信を実現するためのプロトコルであり、マイクロサービスやモバイルアプリケーション、分散システムなど、さまざまなシーンで活用されています。gRPCを使ったAPIの実装を通じて、そのメリットやデメリットを理解し、適切なシーンで活用することが重要です。

この記事はWeb APIに関する連載であり、今後もさまざまなWeb APIの実装パターンについて解説していきます。

P.S.

シンプルなアプリだとRESTとほぼ変わらない設計になりがち

参考文献