gRPC-Web を利用したクライアント・サーバー間の通信

Adventarを支える技術 Advent Calendar 2019 の4日目です。

今日は gRPC-Web について書きます。

gRPC-Web とは

gRPC-Web は今年の10月に GA になったプロトコルで、今回の Adventar システムリニューアルでは絶対に gRPC-Web を production で使ってみる、という気持ちの元、 gRPC-Web を中心にその他の設計を決めました。

gRPC は Google が公開している RPC 方式で、Protocol Buffers と HTTP/2 をベースにしたバイナリプロトコルです。ブラウザは HTTP/2 に対応していないブラウザもまだまだ現役でたくさんいますし、バイナリを扱うのが苦手だったりします。

そこで、ブラウザでも利用できる gRPC-Web という新しいプロトコルを作り、gRPC-Web を gRPC に変換する proxy 層を介して通信することで、gRPC の旨味をブラウザでも利用できるようにする、というのが gRPC-Web です。

gRPC の旨味というのは、Protocol Buffers による API スキーマの定義、そのスキーマを利用したクライアントの自動生成などが挙げられると思っています。grpc-gatewayを利用すると gRPC のサーバーとブラウザで通信することはできますが、Protocol Buffers の定義を使ったクライアントの自動生成などはできませんでした。

proxy の実装は公式でいくつか提供されていますが、今回は envoy を利用しました。envoy 採用の理由は、gRPC-JSON transcoder などの機能も使いたかったためです。これについては後日の記事で解説します。

実際に gRPC-Web でブラウザがどのような通信を行っているかは Chrome のコンソールなどで確認できると思います。リクエスト・レスポンスの body は Base64 なので読めないですが。

f:id:hokaccha:20191203205918p:plain

TypeScript 対応

今回 gRPC-Web を採用して一番よかったと思うところは、TypeScript のクライアント自動生成です。まだ Experimental な機能ではあるのですが、公式で TypeScript のサポートをしています。

https://github.com/grpc/grpc-web#typescript-support

実際に生成しているのは以下で

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/protobuf/protoc.sh#L18-L21

以下のように利用しています。

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/frontend/lib/GrpcClient.ts

自動生成されたクライアントを一段 wrap してアプリケーション内の型にキャストしているのがややダサいですが、これは実装し始めたときに PromiseClient というものが生えることに気づいてなくて、callback を Promise にキャストするついでに型変換も行っていた名残りだったりします。

このように、TypeScript によるクライアントを自動生成することで、API のリクエスト/レスポンスの型が保証されるのは非常に良い体験でした。

とはいえ、ハマったところや苦労したところも多々ありましたので、明日はそのあたりの話を書こうと思います。

Nuxt.js によるフロントエンドの構築

Adventarを支える技術 Advent Calendar 2019 の3日目です。

今日はフロントエンドのフレームワークとして採用した Nuxt.js について感想を書こうと思います。

個人的には Vue.js よりも React のほうが好きなので、最初は Next.js を検討しましたが、ルーティングまわりのできの良さ、エコシステム、全体の完成度などを吟味した結果 Nuxt.js を選択しました。Next.js が全然ダメというわけではないので、好みにもよると思います。

ここがよかった Nuxt.js

オールインワンな環境

ビルドや Lint の環境が自動できて、テストのライブラリや State 管理の仕組みも組み込まれていて、デプロイもコマンド一発で簡単にできる環境がすぐに整うのはよかったです。

小さいライブラリを自分で組み合わせて作るのもいいですけど、Webフロントエンドの技術スタックは選択すべきものが多すぎるので、何も考えずに Nuxt.js が勧めてくるものを脳死で使うというは楽です。

このあたりはおそらく Next.js も大差ないですね。

エコシステム

プラグインやドキュメントが豊富なのはよかったです。Nuxt PWAなどは少し設定するだけで PWA の設定ができたり、nuxt-fontawesomeでパっと fontawesome が導入できたりとか。

View のコンポーネントライブラリについても Vue.js のライブラリが利用できるので、欲しいものはだいたいあるので良いですね。個人的に UI コンポーネントはよほど面倒なものじゃない限り自作するのが好きなので今回はほとんど使いませんでしたが。

ルーティング

Next.js との比較になりますが、ルーティングの機能はよくできていると思います。特に動的なルーティングに大きな違いがありました。

Adventar はもともと Rails でできていたので、/calendars/:id のような URL になっていました。これを Next.js で表現しようとすると、/calendars?id=100のようにするか、express などのサーバーを置いて処理するしかありませんでした(つまり SSR 必須)。

Nuxt.js では、以下のようなディレクトリ構成にするだけでこのルーティングを express などのサーバーを建てずに実現できます。

.
└── pages
    └── calendars
        └── _id # アンダースコアから始まる名前は動的な値を指定できる
            └── index.vue

ただ、この機能は Next.js にも少し前に入りました。

https://nextjs.org/blog/next-9#dynamic-route-segments

実際に試してはいませんが、これがあったら Next.js を採用していた可能性はあります。しかし、動的なルーティングのファイル名の規則が、Nuxt.js が _id のようにアンスコで始まる、というものに対して Next.js は [id] のようにブラケットで囲む、というものでありだいぶ気持ち悪い感じはありますね...。

ここは微妙だった Nuxt.js

TypeScript対応

TypeScript 対応に苦労しました。今は公式で対応しているので難しくないと思いますが、作り始めたころは絶賛 TypeScript 対応中という感じで、試行錯誤した覚えがあります。

また、これは Nuxt.js というよりは Vue.js の問題なのですが、TypeScript の型チェックがテンプレートに効かないので、結局テンプレートのところで型の齟齬が発生してエラーになることがあってせっかくの TypeScript が片手落ちという感じでした。Vuex と TypeScript の相性もアレですし。

Vue.js もそのうち改善されるとは思いますが、この点においては React のほうが圧倒的に好きです。

バージョンアップ

つらい。けっきょくまだバージョンアップできてません。

まとめ

総合的には Nuxt.js はよくできているフレームワークで、採用してよかったと思いました。ただやはり個人的には TypeScript との相性の面で React のほうが好きなので、Next.js に期待したいところです。

明日は gRPC-Web について書きます。

Adventar をローカルで開発する

Adventarを支える技術 Advent Calendar 2019 の2日目です。

Adventar はオープンソースでコードを公開しているので、誰でも環境を再現することができます。ただ、システムがそこそこ複雑で、ドキュメントなども全然書いていないので、たぶんリポジトリを見ても起動する方法がわからないと思うので、自分用のメモも兼ねて Adventar をローカルで起動して開発する方法をここに記しておきます。

まずソースコードを手元に持ってきます。

$ git clone https://github.com/adventar/adventar
$ cd adventar

ソースコードディレクトリ構成は次のようになっています。

$ tree -L 1
.
├── README.md
├── api-server    # API server
├── batch         # スケジュール実行されるバッチジョブ
├── db            # database のスキーマ
├── frontend      # Nuxt.js によるフロントエンド(SSRのコード含む)
├── image-server  # 画像サーバー
├── protobuf      # protocol buffers の定義
└── terraform     # インフラ構成のコード

batch, image-server , terraform, protobuf あたりはローカルで起動するのには使わないので今回は気にしないでください。

まず認証で使っている Firebase Authentication に必要な Credentials を作る必要があります。これがなくても起動はできますが、ログインができないのでカレンダーの作成や登録ができなくて、実質なにもできません。

Firebase Consoleで新規プロジェクトを作り、Googleログインに設定を有効にします。

f:id:hokaccha:20191201230953p:plain

次にサービスアカウントの設定から秘密鍵の生成を実行します。

f:id:hokaccha:20191201230956p:plain

そうするとJSONファイルがダウンロードされるので、これをAPIサーバーの起動時に環境変数に設定します。

$ export FIREBASE_CREDENTIAL_JSON=$(cat ~/Downloads/adventar-test-firebase-adminsdk-xxx.json)
$ cd api-server
$ docker-compose up

これでAPIサーバーは起動するはずです。環境変数の設定は毎回やるのは面倒なら direnvenvchain などを使うといいと思います。僕は envchain を使っています。

これだけだとまだ DB に table ができていないのでスキーマを流します。

$ cd adventar/db
$ mysql -u root -h 127.0.0.1 --port 13306 adventar_dev < schema.sql

最後にフロントエンドのサーバーを起動します。フロントエンドのサーバーにも Firebase の環境変数をいくつか設定する必要があります。

$ export FIREBASE_API_KEY=xxx
$ export FIREBASE_PROJECT_ID=your-project-name
$ export FIREBASE_AUTH_DOMAIN=your-project-name.firebaseapp.com
$ cd adventar/frontend
$ yarn install
$ yarn dev

FIREBASE_PROJECT_IDは先程ダウンロードした JSON に記述されているproject_idです。

$ cat ~/Downloads/adventar-test-firebase-adminsdk-xxx.json | jq .project_id

などで確認できると思います。FIREBASE_AUTH_DOMAINは project id に.firebaseapp.comを付け足したもの、FIREBASE_API_KEYはFirebaseの設定画面から確認できるはずです。

f:id:hokaccha:20191201230958p:plain

これで http://localhost:3333/ をブラウザで開いてログイン、カレンダーの作成などができるはずです。

明日は Firebase Authentication についてもう少し詳しく書こうと思います。

Adventar 2019 の技術構成概要

Adventar を支える技術 Advent Calendar 2019 の1日目です。

Adventar はサービスを開始した2012年以来、Rails を利用してサービスを提供してきました。今年はそのシステムを一から設計し直し、以下のような技術要素を使って実装しました。

  • Nuxt.js による SPA なフロントエンド
  • Go で gRPC の API サーバー
  • gRPC-web によるクライアント/サーバー間通信
  • Firebase Authentication による認証
  • envoy で gRPC の proxy
  • AWS Lambda による Nuxt.js の Server Side Rendering
  • Terraform による AWS リソースの管理
  • Serverless Framework による AWS Lambda, API Gateway のデプロイ
  • Amazon ECS, Fargate を利用した API サーバーのホスティング
  • Lambda と CloudWatch Events を利用した定義ジョブ
  • Lambda と API Gateway を使った画像のリサイズサーバー

他にも色々ありますが、概ねこんな感じです。また、今年からソースコードオープンソースにしました。

技術選定について

細かいところは追々説明するとして、今回はなぜこういう技術を選定したのか、について書こうと思います。

実装する前からわかっていたことではあるのですが、これぐらいのサービス(tableは3つ程度、トラフィックも小規模)でこのような構成にするのは完全にオーバーテクノロジーで、私が一人で開発する前提であれば Rails で実装して Heroku でホストするのが実装コストも運用コストも絶対に安く済みます。

今回このようなオーバーテクノロジーな構成を選択したのは完全に個人的な学習目的であり、この規模でこのような構成を選択することはオススメしないということを一番最初に断っておきます。

今回の構成にするのに、最初から今の構成に決めて実装したわけではなく、最初に決めたのは、フロントエンドとサーバーサイドは実装を分けて、次の2つを使うということだけです。

  • gRPC-web
  • サーバーレスな環境(AWS Lambda や Google Cloud Functions)で Server Side Rendering

理由としては、個人的に学びたいと思っているけど直近で利用する機会がなさそうなので、個人サービスで実験的に導入して試してみたいというのが大きいところです。

その他の構成要素はこれらを軸に実装していく上で、比較検討しながら決めていきました。

  • Nuxt.js(フロントエンドのフレームワーク
    • Next.js か Nuxt.js の2択だった
    • Vue.js よりは React のほうが好きだが、フレームワーク的に Nuxt.js のほうがよくできていそうだった
  • Go(gRPC Server)
    • Ruby は仕事でも使っていてお腹いっぱい気味なので別の言語にしたい
    • gRPC サーバーで安定した実装が提供されているのは Go or Java
    • go と Java の知識は同程度(ほぼない)が Go のほうを学びたい
  • ECS/Fargate(API サーバーのホスティング
    • gRPC をホストしないといけないので Heroku は使えない
    • Kubernetes を試してみたい気持ちはあったが自前にしろマネージド(EKS, GKE)にしろ学習コスト、運用コストが高い、マネージドだと料金も高い
    • 適当な VPS や EC2 で systemd とかで運用するというのも考えたが自前でサーバーを管理したくないので多少コストはかかるが ECS, Fargate を選択
  • Firebase Authentication(認証サービス)
    • 対抗は AWS Cognito、Auth0、自前でがんばる
    • 自前はめんどくさいので最後の手段
    • Cognito は要件を満たしてない、Auth0 はお金かかりそう、ということで Firebase Authentication
  • CloudFront(CDN
    • 対抗は Fastly
    • 金額などの面で CloudFront を採用(詳しくは後日)
  • AWS Lambda(FaaS)
    • 色々決めていくうちに AWS によってきたので Google Cloud Functions でなく AWS Lambda にした。あまり比較検討はしていない
  • Serverless Framework(Lambda のデプロイツール)
    • AWS SAM との比較になった
    • ほぼ変わりなかったがプラグインが豊富にありそうだったので選択
  • Terraform(AWS のリソース管理)
    • 特に考える余地なし(対抗は CloudFormation ぐらい?)

だいたいこんな感じです。選定の基準としては

  • 技術的チャレンジの有無
  • 金銭的コスト
  • 時間的コスト(学習コスト、実装コスト、運用コストなどを含む)
  • ユーザー体験

あたりのバランスを考えながら選んでいます。例えば時間とお金が無限にあれば、Kubernetes を使ってみたり、普段利用している AWS でなく GCP をフル活用してみたかったですし、何も技術的チャレンジをしないなら Rails, Heroku が一番コスパがいいのはわかっていました。

細かい話しはもっと色々ありますが、あと24日あるので後に譲ることにします。

明日は Adventar をローカルで起動する方法について書きます。

Nginx で grpc-web の proxy

Envoy より Nginx のほうが慣れているので Nginx を使いたい。一応公式でも Docker でビルドできるようになってるけど、無駄にサイズがでかい(2GB超え)しコンパイルオプションがいじりたいので alpine でビルドできるようにしてみた。

https://github.com/hokaccha/grpc-web-proxy-nginx

streamのサンプルも動いた。(client stream はまだ対応してないらしいので server stream だけ)

navigator.sendBeacon で Content-Type を指定したい

計測用のリクエストみたいな小さいリクエストを投げるためのnavigator.sendBeaconっていうAPIがあって、fetchXHRと違ってunload時でもリクエストが送信されるのが保証されるのが売りらしい。メジャーなブラウザには実装されつつある。

Navigator.sendBeacon() - Web APIs | MDN

デフォルトだとContentTypetext/plainなんだけど、application/jsonとかに変更したいということはけっこうあると思う。その場合Blobtypeに指定すればいいらしい。

const body = { name: 'foo' };
const blob = new Blob([JSON.stringify(body)], { type: 'application/json' });
navigator.sendBeacon('/log', blob);

これで Firefox 63 はいけたんだけど、Chrome 70 だと以下のようなエラーで送れなかった。

Uncaught DOMException: Failed to execute 'sendBeacon' on 'Navigator': sendBeacon() with a Blob whose type is not any of the CORS-safelisted values for the Content-Type request header is disabled temporarily. See http://crbug.com/490015 for details.

CORS の問題で一時的に無効になってるらしい。Same Originなのに...。元々 preflight が飛ばないapplication/x-www-form-urlencodedだと大丈夫そう。

Firefox は Cross Origin の場合はちゃんと preflight 飛ばして送信してた。

npm scripts のコマンドライン引数は sh で展開される

package.json

"scripts": {
  "lint": "eslint src/**/*.{js,jsx}"
}

と書いた場合、npm run lintyarn run lintは、このコマンドをシェルを通して実行する。このときデフォルトではshが使われる。例えばmacOSだとshbashなので、bashsrc/**/*.{js,jsx}を展開することになる。しかしbash**/*の展開に対応してないので思ったとおりにうごかない。

$ tree
.
└  src
    ├  a
    │   └ bar.js
    └── foo.js

$ zsh -c 'echo src/**/*.js'
src/a/bar.js src/foo.js

$ bash -c 'echo src/**/*.js'
src/a/bar.js

この場合はeslintがglobを展開してくれるので、クオートしてそのまま渡してあげるのが正解。

"scripts": {
  "lint": "eslint 'src/**/*.{js,jsx}'"
}

ちなみに npm config でシェルを指定できるらしいがシェルに依存したスクリプトなんぞ npm scripts 書きたくない。