第8回は、gRPCをサポートしないWebブラウザなどのクライアントからgRPCサービスを利用するためのリバースプロキシgrpc-gatewayを紹介します。プログラミング言語はGoです。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
今回のテーマは、WebブラウザをはじめとするgRPCをサポートしないWebクライアントのgRPCクライアント化です。gRPCはバックエンドでマイクロサービス間の通信に用いられることが多いのですが、フロントエンドでもgRPCを使えれば、Webブラウザのために別途APIを整備する必要がなくなるなどのメリットが生まれます。
Webブラウザは、本稿の執筆時点でgRPCをサポートしていません。そのため、以下のような手段で間接的にgRPCサービスを利用することになります。
gRPC-Webは、WebブラウザでgRPCサービスを利用するためのプロトコルです。JavaScriptなどからは、このプロトコルをサポートするクライアントスタブライブラリ(protocのgRPC-Webプラグインで生成)を使用して、gRPCサービスを呼び出すためのコードを記述できます。ただし、gRPC-WebはWebブラウザの機能上の制約により完全なgRPCクライアントになることはできず、実際にはクライアントとgRPCサーバの間にプロトコル変換のためのリバースプロキシを挟む必要があります。リバースプロキシとしては、Envoyというプロキシソフトウェアがよく使われています。
grpc/grpc-web: gRPC for Web Clients
リバースプロキシとは、特定のサーバのために設けられるプロキシ(アクセスを代行するサーバ)で、クライアントは不特定多数となります。これに対して一般的なプロキシ(フォワードプロキシ)は、特定のクライアントのために設けられるプロキシで、サーバは不特定多数となります。いわゆるプロキシというと後者であったため、リバース(逆)プロキシと呼ばれています。フォワードプロキシが、クライアントネットワーク側に設置されるのに対して、リバースプロキシはサーバ側ネットワークに配置されます。後述の図1では、不特定のクライアントからのアクセスを中継し、gRPCサーバへのアクセスを代行していることが示されています。
リバースプロキシによるプロトコル変換とは別に、Connectというライブラリを利用することで、プロトコル定義ファイルからgRPCとgRPC-Webの双方に対応したコードを生成するというアプローチもあります。ConnectはRESTful API形式の独自のプロトコルもサポートするので、これによりWebクライアントからの利用が非常に容易になるというメリットがあります。
Connect - Simple, reliable, interoperable. A better gRPC.
grpc-gatewayは、gRPCサービスをRESTful APIにマップするためのリバースプロキシのためのprotocプラグインです。プロトコル定義ファイルなどにgRPCの手続きとAPIのマッピングを記述することで、プロキシのためのスタブコードを自動生成できます。そのコードを利用してリバースプロキシを実装することで、容易にgRPCサーバにRESTful APIなインタフェースを設けることができます(図1)。
本稿では、このgrpc-gatewayによるgRPCサービスのRESTful API化について紹介します。このため、まずはgRPCサーバを作成し、そしてそれを利用するgRPCリバースプロキシを作成します。gRPCサーバではRESTfulなAPIを意識して、これまでの回で紹介したものに新たに手続きを追加します。また、grpc-gatewayはGo言語のスタブ生成にしか対応していないので、便宜上gRPCサーバも同じGoで記述することにします。
最初に、開発に必要な環境を準備しておきます。以下をインストールしておきます。
Goの開発環境は、本記事と同一著者による@ITの記事「GoでWebAssembly――Go標準のWebAssemblyサポートを体験する」を参考に作成してください。Go 1.11以降が必要です。
protocは、連載第1回で触れたProtocol Buffersのツールで、プロトコル定義ファイルをコンパイルして言語ごとのクラスファイルなどを作成します。本連載のこれまでの回のいずれかの環境構築を実施していればイントールは済んでいるはずです。
protoc-gen-go、protoc-gen-go-grpc、protoc-gen-grpc-gatewayは、protocがGoのコードを生成するために必要なプラグインです。protocは標準でGoに対応していないので、このような外部のプラグインを必要とします。protoc-gen-openapiv2は、grpc-gatewayによるAPIの仕様をOAS(OpenAPI Specification)形式で生成するプラグインです。これらを、以下のコマンドでインストールしてください。
% go install google.golang.org/protobuf/cmd/protoc-gen-go@latest % go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest % go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest % go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
Goをインストールすると、ユーザーのホームフォルダにgoフォルダが作成されます。プラグインは、ここのbinフォルダすなわち~/go/binフォルダ(Windowsの場合は%HOMEPATH%\go\binフォルダ)にインストールされます。protocコマンドの実行時にプラグインの場所が分かるように、環境変数PATHにbinフォルダを追加しておいてください。
gRPCサーバから作成していきます。今回のテーマはgRPCサービスをRESTfulなAPIでラップすることなので、その一部として読み出しに相当するGet手続きを新たに実装することにします。
まずは、プロジェクトを作成します。本連載のためのatmarkit_grpcフォルダの中に、goフォルダを作成し、ここをプロジェクトのフォルダとします。以降、このフォルダを基点に作業します。カレントフォルダを移動したら、go mod initコマンドでモジュールとして初期化します。パラメーターには、モジュールが公開される場合のインポートパスを指定しますが、今回はモジュールとして公開するものではないので、単にsampleとしています。本来はGitHubのリポジトリURLなど一意となる名前を指定します。
% mkdir go % cd go % go mod init sample go: creating new go.mod: module sample
これで、goフォルダがGoプロジェクトのために初期化されます。具体的には、go.modファイルが作成されます。go.modはモジュール管理のためのファイルで、go getコマンドなどで実行されるモジュールへの参照が追加されていきます。さらにgo.sumファイルが作成され、そこには参照モジュールのバージョンとハッシュが登録されていきます。
このあと、サーバアプリケーションのコードに必要になるモジュールを、ここであらかじめ追加しておきます。
% go get google.golang.org/grpc
go.modファイルにrequireブロックが追加され、github.com/golang/protobufをはじめとする幾つかの依存関係が記述されていることを確認してください。
プロトコル定義ファイルをprotoフォルダを作成して配置します。連載第4回で作成したbook.protoファイルを、protoフォルダにコピーします。そして、Go独自のオプションと、idを指定して書籍情報を1個取得するGet手続きを、それぞれ追加します。
…略… // 生成コードの場所を指定(パッケージの指定) option go_package = "pb/"; (1) …略… // Get手続きの引数と戻り値のためのメッセージを追加 message GetRequest { (2) int32 id = 1; } message GetResponse { int32 status_code = 1; BookItem item = 2; } …略… service BookInfo { // idを指定して対応する書籍情報を取得するGet手続きを追加 rpc Get(GetRequest) returns (GetResponse); (3) rpc Search(SearchRequest) returns (stream SearchResponse); }
(1)は、生成されるファイルの置き場所すなわちGoパッケージの指定です。これを指定しないとprotocコマンドの実行時に指摘されますので、必ず指定します。
(3)は、今回新規となるGet手続きで、(2)にてその引数と戻り値を定義しています。idを指定して呼び出すと、対応する書籍情報が返るという単純な手続きです。
このファイルを作成した時点で、protocによってスタブのコードを生成できます。以下のコマンドを実行します。
% protoc --go_out=. --go-grpc_out=require_unimplemented_servers=false:. ./proto/bookinfo.proto
--go_outオプションと--go-grpc_outオプションは生成ファルの置き場所の基点です。bookinfo.protoファイルのgo_packageオプションで指定したパスが追加されて、すなわち./pbフォルダに出力されます。--go-grpc_outオプションに指定されているrequire_unimplemented_servers=falseは、手続きが未実装であることを返す埋め込みコードを入れないという指定です。全ての手続きをきちんと実装するなら埋め込みは不要なので、ここではfalseを指定しています。
コマンドの実行で、pb/bookinfo.pb.goとpb/bookinfo.pb.gw.goの2つのファイルが生成されます。これまでの回でも触れてきたように、生成されたファイルは触る必要はありませんし、中身を見る必要もありません。
BookInfoサービスの既定のデータ(bookinfo.json)をdataフォルダを作成して配置します。本来は、データソースとしてデータベースやWebサービスを利用することになりますが、これまで通り簡略化のためにJSONファイルを読み込んで検索するものとしています。内容は連載第4回で紹介したものに、実行時エラーを回避するために修正を施したものです。具体的には、列挙型であるtypeキーを文字列ではなく整数値で指定しています。これは、Goのjson.Unmarshal関数で「cannot unmarshal string into Go struct field BookItem.type of type pb.BookType」エラーが発生するのを回避するためです。
"type": "BOOK" ↓ "type": 0
最後に、サーバアプリケーションを作成します。ベースとするのは、公式リポジトリにあるgrpc/grpc-go/blob/master/examples/route_guide/server/server.goファイルです。このファイルを、server.goとしてserverフォルダを作成して配置し、BookInfoサービスのために修正した内容の抜粋が以下です。必要に応じてオリジナルのファイルも参照してください。
…略… // BookInfoサーバのための構造体 type bookInfoServer struct { (1) pb.UnimplementedBookInfoServer savedBooks []*pb.BookItem } // Get手続きに相当するGetメソッド func (s *bookInfoServer) Get(ctx context.Context, req *pb.GetRequest) (*pb.GetResponse, error) { (2) for _, book := range s.savedBooks { // idが一致するものがあればその要素を返す if book.Id == req.Id { var res pb.GetResponse var item pb.BookItem res.StatusCode = 1 item.Id = book.Id item.Title = book.Title item.Author = book.Author item.Publisher = book.Publisher item.Isbn = book.Isbn item.Price = book.Price item.Type = book.Type res.Item = &item return &res, nil } } // idが一致するものがなければStatusCodeを-1として返す return &pb.GetResponse{StatusCode: -1}, nil (3) } …略…
これは、主にGet手続きに相当するGetメソッドの定義(2)のみ抜き出したものです。このメソッドは、(1)のbookInfoServer構造体のレシーバーから呼び出すことになっていて、引数にコンテキスト(フロー制御のためのオブジェクト)と手続きの引数GetRequestを受け取り、手続きの戻り値GetResultとエラーオブジェクトerrorを返します。
(1)では、BookItem型の配列をsavedBooksフィールドとして保持しており、ここにはサーバアプリケーションの起動時にJSONファイルから初期データが読み込まれて保持されます。
(2)では、そのsavedBooksフィールドを先頭要素から調べて、メソッドの引数で指定されたidが見つかれば、その要素を戻り値としてメソッドを終了させます。一致する要素がなければ、(3)においてStatusCodeを-1としたGetResultを返します。
Search手続きに相当するSearchメソッドは、サーバサイドストリーミングに対応していることから引数と戻り値がGetメソッドと若干異なること、検索結果をSendメソッドで順次戻すことなどが異なります。具体的なコードは、配布サンプルを参照してください。
ゲートウェイの作成に移る前に、サーバアプリケーションが正しく動作するか、汎用(はんよう)のgRPCクライアントであるEvansで検証しましょう。サーバは、以下のように起動します。
% go run sample/server -json_db_file=./data/bookinfo.json
Evansは、別のターミナルを開き、以下のコマンドで起動します。詳しい手順は、連載第2回を参照してください。
% evans --proto ./proto/bookinfo.proto --host localhost --port 50051 repl
サーバを作成できたところで、リバースプロキシの作成に移っていきましょう。リバースプロキシのコードに必要になるモジュールを、ここであらかじめ追加しておきます。
% go get github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway
リバースプロキシのコードを生成するには、プロトコル定義ファイルにそのためのオプションを追加していきます。
…略… // API用のアノテーションを追加 import "google/api/annotations.proto"; (1) …略… service BookInfo { // Get手続きにGET:/get/{id}をマッピング rpc Get(GetRequest) returns (GetResponse) { (2) option (google.api.http) = { get: "/get/{id}" }; } // Search手続きにPOST:/searchをマッピング rpc Search(SearchRequest) returns (stream SearchResponse) { (3) option (google.api.http) = { post: "/search" body: "*" }; } }
(1)は、(2)と(3)に追加するオプションを使うために必要なインポートです。このインポートのために、後ほど手動で公式リポジトリにあるプロトコル定義ファイルをプロジェクト中に配置します。
(2)は、Get手続きとAPIのマッピングを指定しています。インポートされたgoogle.api.httpに基づくものであり、getキーによりHTTP GETの/get/{id}にマッピングされます。{id}はパラメーターであり、手続きの引数であるGetRequestメッセージのフィールドidに対応します。このように、RESTfulなAPIのマッピングに必要な書式を使えるようになっています。
(3)は、Search手続きとAPIのマッピングです。こちらは、HTTP POSTの/searchにマッピングされます。bodyキーの"*"は何らかのメッセージボディーがあることを示しており、この場合はJSON形式で渡されるSearch手続きの引数に対応します。
ここで、(1)のインポートに対応するために、Google APIのリポジトリ(https://github.com/googleapis/googleapis/tree/master/google/api)からannotations.proto、field_behaviour.proto、http.proto、httpbody.protoの4つのファイルを取得し、google/apiフォルダを作成してそこに配置します。
ここまでが済んだら、以下のコマンドでリバースプロキシのコードを生成しましょう。
% protoc -I . \ --grpc-gateway_out=. \ --grpc-gateway_opt=logtostderr=true \ --grpc-gateway_opt=generate_unbound_methods=true \ ./proto/bookinfo.proto
これで、pb/bookinfo.pb.gw.goファイルが生成されます。このファイルも、修正することなく使用します。
最後は、リバースプロキシアプリケーションの作成です。これも、grpc-gatewayの公式リポジトリにあるコードに最低限の修正を加えて利用することにします。gatewayフォルダを作成し、そこにmain.goとしてファイルを配置し、以下の修正を施します。
…略… import ( …略… gw "sample/pb" (1) ) var ( grpcServerEndpoint = flag.String("grpc-server-endpoint", "localhost:50051", "gRPC server endpoint") (2) ) …略… func run() error { …略… opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} err := gw.RegisterBookInfoHandlerFromEndpoint(ctx, mux, *grpcServerEndpoint, opts) (3) …略…
修正は3カ所です。(1)はプロジェクトのモジュールパスに合わせた修正、(2)はサーバの待機するポートを既定のエンドポイントとする指定、(3)はBookInfoサービスに合わせたメソッド名RegisterBookInfoHandlerFromEndpointへの修正です。
リバースプロキシの動作確認のために、前述のコマンドでサーバアプリケーションを起動してください。そして、別ターミナルでリバースプロキシを以下のように起動します。
% go run sample/gateway
このとき、例えばmacOSなら「アプリケーション"gateway"へのネットワーク受信接続を許可しますか?」というようにファイアウォールの警告が表示されます。ここでは[許可]をクリックしてください。Windowsでも同様です。
さらに別ターミナルを開き、curlコマンドでリバースプロキシにアクセスし、正しくレスポンスが得られることを確認します。以下の2つのコマンドを実行して、期待するレスポンスが得られれば成功です。
% curl -X GET "http://localhost:8081/get/2" -H "Content-Type: application/json" {"statusCode":1,"item":{"id":2,"title":"Androidアプリ開発の教科書 第3版 Kotlin対応","author":"齊藤新三","publisher":"翔泳社","isbn":"978-4-7981-7613-0","price":2850,"type":"BOOK"}} % curl -X POST "http://localhost:8081/search" -H "Content-Type: application/json" -d "{ \"text\": \"Ruby\"}" {"result":{"statusCode":1,"item":{"id":1,"title":"Ruby on Rails 7 ポケットリファレンス","author":"山内直","publisher":"技術評論社","isbn":"978-4-297-13062-6","price":3200,"type":"BOOK"}}}
Get手続きに対してはHTTP GETを指定して、クエリパラメーターで対象のidを指定しています。Search手続きに対してはHTTP POSTを指定して、メッセージボディーにJSON形式で検索キーワードを指定しています。
今回は、grpc-gatewayを用いてgRPCサーバにリバースプロキシを置き、HTTP経由でアクセスする例を紹介しました。要は普通のRESTful APIに見えるので、サーバがgRPCであることを意識せずに利用できることをお伝えできたのではないかと思います。
今回を以て、本連載は終了となります。gRPCは軽量でコンパクトなサービス間の通信技術として、今後も利用範囲が広がっていくでしょう。読者がgRPCに関心を持ち、自身のプロジェクトに導入が必要になったときに、少しでも本連載が役に立てば幸いです。
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.