ProtocolBuffersのTypeScriptを生成するプラグインを作ってみた

あると良いなーと思ったので.protoファイルからTypeScriptのインターフェースを生み出すプラグインを作りました。
実のところJavaScriptを生み出すものが公式に存在していますが、それは後述するgRPCなのでちょっと困るのです。

ProtocolBuffersとは

ProtocolBuffersというのはGoogleが作ったAPIのためのスキーマの定義方法です。
これによって環境によらず同一のファイルからAPIの定義〜実装ができるという触れ込みです。
OpenAPIと同じような指向のものですね。

gRPC?

ProtocolBuffersというのは(多分)本来はgRPCという通信方法が前提となっていると思います。
gRPCはHTTP/2を利用して、バイナリによるストリーム配信を行います。
これによって、効率が良いデータ転送ができるという触れ込みです。

デメリット

唯一にして絶対のデメリットがHTTP/2という導入障壁です。
比較的最近に策定されたHTTP/2は各種ミドルウェアなどの対応状況や、ブラウザでの対応状況があまり芳しくありません。
CDNの内部などでは効率的に使えるとは思いますが、弱小サービスではハードルがメリットを上回ってしまいます。

今回作ったもの

今回作ったものは.protoで記述されたProtocolBuffersの定義情報をTypeScriptのinterfaceとfetchを利用した通信処理を行うソースを生み出す、対protoc用のプラグインです。
gRPCではなく、fetchによる旧来のHTTP/1.1による通信です。また、実際の通信処理が無いinterfaceも生み出しますので、型情報だけを使いたいという場合にも有用だと思います。

使用準備

  1. protocを取得
  2. goをインストール
  3. ソースをビルド

1. protocを取得

まずは、肝心のprotocを公式から取得します。
各リリースの下の方に 「protoc-バージョン-OS」 という名前で実行ファイルがありますので、それをダウンロードします。

2. goをインストール

プラグインはgoで書いたのでビルド用にインストールします。
公式ページにインストーラがありますので、それを実行すれば大体終わりです。

3. ソースをビルド

私が作ったソースを取得してビルドします。※私がリリースを用意すれば上記goのインストールと共に不要なのは内緒
こちらのリポジトリのClone or downloadのDownload Zipを選択しソースをダウンロードします。
以下のコマンドを実行してビルドします。 (フォルダは解凍したREADMEとかがある場所です。)
$ go get github.com/golang/protobuf/proto
$ go build plugin/src/protoc-gen-tsi.go
うまくいけば実行ファイル(Windowsならprotoc-gen-tsi.exe)が生み出されます。

実行

  1. protoファイル用意
  2. 各種ツール配置
  3. 実行
  4. 結果

1. protoファイル用意

ひとまずサンプルにprotoファイルを用意します。
まず、Sample1.protoというファイルを用意してこんな感じに。
syntax = "proto3";

package Sample1;

enum Sample1Enum {
    V1 = 0;
    V2 = 1;
    V3 = 2;
}

message Sample1Input {
    string Vaue1 = 1;
    int32 Value2 = 2;
}

message Sample1Output {
    string Value1 = 1;
    string Value2 = 2;
    Sample1Enum Value3 = 3;
}

message Sample1Output2 {
    int64 Value1 = 1;
}

service Sample1 {
    rpc API1 (Sample1Input) returns (Sample1Output);
    rpc API2 (Sample1Input) returns (Sample1Output2);
}
つづいて別ファイルを参照しているとどうなるかのサンプルSample2.protoを用意。
syntax = "proto3";

package Sample2;
import "Sample1.proto";

message Sample2Input {
    Sample1.Sample1Input Value = 1;
}

message Sample2Output {
    string Value = 1;
}

service Sample2 {
    rpc API1 (Sample2Input) returns (Sample2Output);
}

2. 各種ツール配置

ツールを置きます。こんな感じです。
root
┝ bin
│┝ protoc (公式からダウンロードしてきたやつ)
│└ protoc-gen-tsi (上でgoで作ったやつ)
┝ outut (フォルダ)
┝ Sample1.proto
└ Sampel2.proto
outputの中はツールを実行した結果が入ります。(画像だともう入っていますが……)

3. 実行

ツールを実行します。
$ ./bin/protoc --plugin="protoc-gen-tsi=./bin/protoc-gen-tsi" --tsi_out=./output *.proto
pluginオプションで今回作成したやつを指定し、–XXX_outを指定します。※XXXのことろにはプラグインの名前(–pluginのところで指定しているprotoc-gen-XXXの部分)を記述します。
Windowsの場合は–pluginのところで.exeの指定が必要かもしれません。
# Windowsの場合は.exeがいるかも
$ ./bin/protoc --plugin="protoc-gen-tsi=./bin/protoc-gen-tsi.exe" --tsi_out=./output *.proto

4. 結果

結果は以下のようなTypeScriptのソースができます。


export const PackageName = 'Sample1';

export enum Sample1Enum
{
    V1 = 0,
    V2 = 1,
    V3 = 2,
}

export interface Sample1Input
{
    Vaue1? : string;
    Value2? : number;
}

export interface Sample1Output
{
    Value1? : string;
    Value2? : string;
    Value3? : Sample1Enum;
}

export interface Sample1Output2
{
    Value1? : number;
}


export interface Sample1
{
    API1(input: Sample1Input) : Promise;
    API2(input: Sample1Input) : Promise;
}

export class Sample1Client
    implements Sample1
{

    constructor(
        private basePath: string
                = '',                                          // default
        private makeUrl: (basePath: string, packageName: string, className:string, methodName: string) => string
                = (b, _, c, m) => `${b}${c}/${m}`,             // =default
        private makeHeaders: (baseHeaders: {}) => {}
                = (h) => h,                                    // =default
        private makeQuery: (baseQuery: {}) => {}
                = (q) => q                                     // =default
    ) {}

    
    async API1 (input: Sample1Input) : Promise {
        const url = this.makeUrl(this.basePath, PackageName, 'Sample1', 'API1');
        const headers = this.makeHeaders({'Content-Type': 'application/json'});
        const query = this.makeQuery({
            method: 'POST',
            headers,
            body: JSON.stringify(input)
        });
        const response = await fetch(url, query);
        return (await response.json()) as Sample1Output;
    }
    
    async API2 (input: Sample1Input) : Promise {
        const url = this.makeUrl(this.basePath, PackageName, 'Sample1', 'API2');
        const headers = this.makeHeaders({'Content-Type': 'application/json'});
        const query = this.makeQuery({
            method: 'POST',
            headers,
            body: JSON.stringify(input)
        });
        const response = await fetch(url, query);
        return (await response.json()) as Sample1Output2;
    }
    
}
import * as Sample1 from './Sample1.proto';


export const PackageName = 'Sample2';


export interface Sample2Input
{
    Value? : Sample1.Sample1Input;
}

export interface Sample2Output
{
    Value? : string;
}


export interface Sample2
{
    API1(input: Sample2Input) : Promise;
}

export class Sample2Client
    implements Sample2
{

    constructor(
        private basePath: string
                = '',                                          // default
        private makeUrl: (basePath: string, packageName: string, className:string, methodName: string) => string
                = (b, _, c, m) => `${b}${c}/${m}`,             // =default
        private makeHeaders: (baseHeaders: {}) => {}
                = (h) => h,                                    // =default
        private makeQuery: (baseQuery: {}) => {}
                = (q) => q                                     // =default
    ) {}

    
    async API1 (input: Sample2Input) : Promise {
        const url = this.makeUrl(this.basePath, PackageName, 'Sample2', 'API1');
        const headers = this.makeHeaders({'Content-Type': 'application/json'});
        const query = this.makeQuery({
            method: 'POST',
            headers,
            body: JSON.stringify(input)
        });
        const response = await fetch(url, query);
        return (await response.json()) as Sample2Output;
    }
    
}
こんな感じです。
protoで定義したmessageとserviceをそのままinterfaceにします。
ついでにserviceをfetchを使った通信処理で実装もしています。気が向けばXMLHttpRequest版とかPOST前提なところをなんとかするかもしれません。
(gRPCにはGETもPOSTもないから……)
実のところこの吐き出したソースをまだ試していないので実は動かないとかあるかもしれません。
そんな時はプルリを投げてくださるとマージすると思います。
タイトルとURLをコピーしました