アドベントカレンダー1番手です。
僕から見た今年のGoogle Cloud PlatformはMachine LearningとFirebaseかなーという印象でした。
データベース周りではSpannerも熱いですね。高くて使えてませんが
ちなみに僕の好きなGoogle App Engineの今年ははChannel APIがシャットダウンされたこととGo 1.8が使えるようになりました。
だけど、はやくGo 1.9来て!!!!!
去年はGAEの構成について書きましたが今年はAPIをどうするか?という事を書きます。
gRPCとは
gRPCは、Googleが開発したオープンソースのRPCフレームワークです。HTTP/2の上で Protocol Buffersでシリアライズされたデータを高速に送ることができます。
gRPCはProcol BuffersのIDLを使ってサービスとメソッドを定義します。
そのIDLからgRPCはクライアントやサーバで実装に使うためのインターフェイスの自動生成をしてくれます。
IDLはサービスを定義するための専用の構文なのでプログラミング言語でclassやstructを書くように書けるのでJSONやYAMLで書くような辛さはないところがよいです。(エディタの支援が受けやすいという意味で)
gRPCは有名な言語をいくつもサポートしています。
- C++
- Java
- Python
- Go
- Ruby
- C#
- Node.js
- Android Java
- Objective-C
- PHP
gRPC Webとは
クライアントも自動生成してくれるし小さいデータをやりとりできてとっても素晴らしいフレームワークなのですが一つ弱点があります。
それはブラウザで使えないことです。
Node.jsはサポートされているのでJavaScriptで使えますがブラウザでは使えません。
しかしその弱点を補うために開発されているのがgRPC Webです。
現在は仕様だけで実装はgrpc/grpcに取り込まれていません、現在はまだprivateで開発中です。
ただ仕様があれば実装も誰かが作っているもので、それがimprobable-eng/grpc-webです。
Google App EngineでgRPC Webを使う
Google App Engineを知らない人のために簡単に説明するとWebサービスを作るためのGoogle Cloud PlatformのPaaSです。
Herokuと比べると制約が多いですがその制約の上で作ることでスケールするサービスが出来上がります。
豊富な無料枠があるので放置しててもお金がかからないため、よく遊び場として使っています。
大抵のWebサービスを作るならGAEで全てができると思っているのですがAPIサーバを作る時REST APIを作ったりするのがめんどくさいしgRPCを使いたいのですが悲しいことにgRPCをApp Engineでは使えません。
なのでgRPC Webを使います。
ここから紹介するコードは全てGitHubにあるのでそちらを参考にしてみてください。
デモ: https://gae-grpc-web.appspot.com/
サービスの定義
まずはIDLでサービスを定義します。
syntax = "proto3";
package net.k2lab.test.grpc.testing.echo;
option go_package = "echo";
message EchoRequest {
string message = 1;
}
message Echo {
string id = 1;
string message = 2;
int64 created = 3;
}
message EchoResponse {
Echo echo = 1;
}
message EchoHistoryRequest {
int32 limit = 1;
}
service EchoService {
rpc Echo(EchoRequest) returns (EchoResponse);
rpc EchoHistory(EchoHistoryRequest) returns (stream EchoResponse);
}
EchoService
を定義してEcho
とEchoHistory
メソッドを定義しました。
次にここからインターフェイスとクライアントを自動生成します。
$ go get -u github.com/golang/protobuf/{proto,protoc-gen-go}
$ go get -u google.golang.org/grpc
$ npm i -D ts-protoc-gen
$ protoc \
--plugin=protoc-gen-go=${GOPATH}/bin/protoc-gen-go \
--plugin=protoc-gen-js=../node_modules/.bin/protoc-gen-js_service \
--go_out=plugins=grpc:. \
--js_out=import_style=commonjs,binary:. \
--js_out=. \
./echo.proto
このコマンドを実行するとecho.pb.go
とecho_pb.js
とecho_pb_service.js
が生成されます。
サーバの実装
先にサーバの実装をします。
App Engineの設定ファイルであるapp.yaml
は次の通りです。
runtime: go
api_version: go1.8
handlers:
- url: /(.+(\.js|\.css))$
static_files: app/\1
upload: app/(.+(\.js|\.css))$
expiration: 10m
secure: always
- url: /(.+(\.gif|\.png|\.jpg))$
static_files: app/\1
upload: app/(.+(\.gif|\.png|\.jpg))$
expiration: 30m
secure: always
- url: /.*
script: _go_app
secure: always
Goのバージョンは1.8を使います。
次にserver.go
の実装です。
package backend
import (
"html/template"
"net/http"
"strings"
"time"
"golang.org/x/net/context"
"github.com/improbable-eng/grpc-web/go/grpcweb"
"github.com/k2wanko/gae-grpc-web/echo"
"github.com/k2wanko/gaegrpc"
"google.golang.org/appengine/log"
)
var tpl *template.Template
func init() {
l, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
panic(l)
}
time.Local = l
tpl = template.Must(template.New("").ParseFiles("index.html"))
sv := gaegrpc.NewServer()
echo.RegisterEchoServiceServer(sv, &EchoService{})
wh := gaegrpc.NewWrapHandler(grpcweb.WrapServer(sv))
http.HandleFunc("/", createAppHandler(wh))
}
func createAppHandler(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc-web") {
h.ServeHTTP(w, r)
} else {
serverTop(w, r)
}
}
}
func serverTop(w http.ResponseWriter, r *http.Request) {
tpl.ExecuteTemplate(w, "index.html", nil)
}
func logf(ctx context.Context, format string, args ...interface{}) {
log.Infof(ctx, format, args...)
}
func debugf(ctx context.Context, format string, args ...interface{}) {
log.Debugf(ctx, format, args...)
}
func warnf(ctx context.Context, format string, args ...interface{}) {
log.Warningf(ctx, format, args...)
}
func errorf(ctx context.Context, format string, args ...interface{}) {
log.Errorf(ctx, format, args...)
}
Go 1.8ですがnet/context
を使ってるのはgrpc-go
が標準のcontext
パッケージを使っていないためです。
次にContent-Type
をみて処理をわけているところはgRPC以外の通信が来たらHTMLを返すようにしています。
そしてGAEでgRPC Webを使うために止む無く登場するのがgithub.com/k2wanko/gaegrpc
パッケージです。
これの仕事は主に2つあります。
1つはGAEのサービスを利用するためにappengine.NewContext
をgRPCのメソッドのハンドラに渡すことです。
*http.Request
をメモリ上に保存しておきメソッドの実装に渡すようにしてくれます。
もう一つはimprobable-eng/grpc-web
がGAEを考慮していないためerrorが起きるところを吸収しています。
具体的にはGAEのGoのhttp.ResponseWriter
にはhttp.CloseNotify
が実装されていないため実装がなければnilを返すようにしています。
メソッドの実装は次のようになっています。
package backend
import (
"time"
"github.com/k2wanko/gae-grpc-web/echo"
"github.com/kjk/betterguid"
"golang.org/x/net/context"
"google.golang.org/appengine/datastore"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
// EchoService structs
type EchoService struct{}
// Echo structs message
type Echo struct {
ID *datastore.Key `datastore:"-"`
Message string
Created time.Time
}
// ToMessage returns Echo of protocl buffer
func (e *Echo) ToMessage() *echo.Echo {
id := ""
if e.ID != nil {
id = e.ID.StringID()
}
return &echo.Echo{
Id: id,
Message: e.Message,
Created: e.Created.Unix(),
}
}
// Echo implements EchoServiceServer
func (*EchoService) Echo(ctx context.Context, req *echo.EchoRequest) (res *echo.EchoResponse, err error) {
grpc.SendHeader(ctx, metadata.Pairs("Pre-Response-Metadata", "Is-sent-as-headers-unary"))
grpc.SetTrailer(ctx, metadata.Pairs("Post-Response-Metadata", "Is-sent-as-trailers-unary"))
msg := req.GetMessage()
logf(ctx, "Echo Message = %s", msg)
k := datastore.NewKey(ctx, "Echo", betterguid.New(), 0, nil)
e := &Echo{
ID: k,
Message: req.GetMessage(),
Created: time.Now(),
}
if _, err := datastore.Put(ctx, k, e); err != nil {
errorf(ctx, "don't save message: %v", err)
return nil, err
}
res = &echo.EchoResponse{
Echo: e.ToMessage(),
}
return
}
// EchoHistory implements EchoServiceServer
func (*EchoService) EchoHistory(req *echo.EchoHistoryRequest, ss echo.EchoService_EchoHistoryServer) (err error) {
limit := int(req.GetLimit())
if limit <= 0 {
limit = 10
} else if limit > 100 {
limit = 100
}
ctx := ss.Context()
it := datastore.NewQuery("Echo").
Limit(limit).
Order("-Created").
Run(ctx)
for {
select {
case <-ctx.Done():
return nil
default:
}
e := &Echo{}
k, err := it.Next(e)
if err == datastore.Done {
break
} else if err != nil {
errorf(ctx, "don't get history: %v", err)
return err
}
e.ID = k
ss.Send(&echo.EchoResponse{
Echo: e.ToMessage(),
})
}
return
}
gaegrpc
のおかげでctx context.Context
には既にappengine.NewContext
の戻り値が入っているのでdatastoreへもアクセスできるようになりました。
クライアントの実装(ブラウザ)
次はクライアントの実装です。
ブラウザでアプリケーションを作るためのフレームワークとしてVue.jsを使っていますが
Vue.jsに依存してるわけではないので好きなフレームワークと組み合わせられます。
<template>
<div id="app">
<h1>grpc web testing</h1>
<ul>
<li><input type="text" v-model="echoRequest"><button @click="echo">Echo</button><span>{{echoResponse}}</span></li>
</ul>
<h2>History</h2>
<ul>
<li v-for="echo in history" :key="echo.id">{{echo.message}}</li>
</ul>
</div>
</template>
<script>
import { grpc, Code, Metadata } from "grpc-web-client";
import { EchoService } from "../echo/echo_pb_service";
import { EchoRequest, EchoHistoryRequest } from "../echo/echo_pb";
const host = location.protocol + "//" + location.hostname + ":" + location.port;
export default {
name: "app",
data() {
return {
echoRequest: "",
echoResponse: "",
history: []
};
},
mounted() {
this.fetchEchoHistory();
},
methods: {
echo() {
if (!this.echoRequest) {
return;
}
const request = new EchoRequest();
request.setMessage(this.echoRequest);
grpc.unary(EchoService.Echo, {
request,
host,
onEnd: res => {
const { status, statusMessage, headers, message, trailers } = res;
console.log("EchoService.Echo.onEnd.status", status, statusMessage);
console.log("EchoService.Echo.onEnd.headers", headers);
if (status === Code.OK && message) {
const resp = message.toObject();
console.log("EchoService.Echo.onEnd.message", resp);
this.echoResponse = resp.echo.message;
if (this.history.length >= 10) {
this.history.pop();
}
this.history.unshift(resp.echo);
}
console.log("EchoService.Echo.onEnd.trailers", trailers);
this.echoRequest = "";
}
});
},
fetchEchoHistory() {
const request = new EchoHistoryRequest();
request.setLimit(10);
grpc.invoke(EchoService.EchoHistory, {
request,
host,
onMessage: msg => {
const resp = msg.toObject();
console.log("fetchEchoHistory", "onMessage", resp);
this.history.push(resp.echo);
},
onEnd: () => {}
});
}
}
};
</script>
<style>
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
margin-top: 60px;
text-align: center;
}
h1,
h2 {
font-weight: normal;
}
ul {
/* list-style-type: none; */
padding: 0;
}
/* li {
display: inline-block;
margin: 0 10px;
} */
a {
color: #42b983;
}
</style>
戻り値が1つであればgrpc.unary
を使いstreamの場合はgrpc.invoke
を使っておくとよさそうです。
使い分けるのがめんどうな場合はgrpc.invoke
だけ使えばよさそうですね。
クライアントの実装(Node.js)
おまけですがgrpc-web-client
はNode.jsでも使えます。
const {EchoHistoryRequest} = require('../echo/echo_pb')
const {EchoService} = require('../echo/echo_pb_service')
const {grpc, Code} = require('grpc-web-client')
const host = 'https://gae-grpc-web.appspot.com:443'
function main() {
const request = new EchoHistoryRequest()
request.setLimit(10)
grpc.invoke(EchoService.EchoHistory, {
request,
host,
onMessage: msg => {
const resp = msg.toObject()
console.log("fetchEchoHistory", "onMessage", resp)
},
onEnd: () => {}
})
}
main()
Cloud Functionsで動くということで、つまりGAEをAPIサーバにしてCloud Functionsでフロントと一貫したAPIを使ってサーバサイドレンダリングができるということです。
もちろん用途はサーバサイドレンダリングだけじゃなくCloud FunctionsとGAEの橋渡しに使えるので夢は広がります。
まとめ
[ブラウザ] - [gRPC Web] - [App Engine]
で通信ができるようになり
モバイルアプリも僕はReact Nativeを使ってるのでgRPC Webで行けると思っています。
ネイティブはgRPC Webではなく普通にgRPCを使えばいいと思いますがGAEと直接コミュニケーションできないのが辛いところですね。
ちなみにgRPC Webは、いつかGAEがgRPCをネイティブに使えるようになったときサーバ側の移行は容易だと信じてます。
サンタさんにはgRPCを直接喋れるGAEをお願いしました。