JavaScript
Go
GoogleAppEngine
GoogleCloudPlatform
gRPC

Google App Engine でgRPCを使うためのgRPC Web #golang #gcpja

アドベントカレンダー1番手です。
僕から見た今年のGoogle Cloud PlatformはMachine LearningとFirebaseかなーという印象でした。
データベース周りではSpannerも熱いですね。高くて使えてませんが:sob:
ちなみに僕の好きな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を定義してEchoEchoHistoryメソッドを定義しました。

次にここからインターフェイスとクライアントを自動生成します。

$ 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.goecho_pb.jsecho_pb_service.jsが生成されます。

サーバの実装

先にサーバの実装をします。

App Engineの設定ファイルであるapp.yamlは次の通りです。

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の実装です。

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を返すようにしています。

メソッドの実装は次のようになっています。

echo.go
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に依存してるわけではないので好きなフレームワークと組み合わせられます。

App.vue
<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でも使えます。

main.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をお願いしました。