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 なので読めないですが。
TypeScript 対応
今回 gRPC-Web を採用して一番よかったと思うところは、TypeScript のクライアント自動生成です。まだ Experimental な機能ではあるのですが、公式で TypeScript のサポートをしています。
https://github.com/grpc/grpc-web#typescript-support
実際に生成しているのは以下で
以下のように利用しています。
自動生成されたクライアントを一段 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 を 2.8.1 -> 2.10.1 にあげたんだけど全然動かなくてセマンティックバージョニングとはいったい... という気持ちになってる
— hokaccha (@hokaccha) 2019年10月14日
つらい。けっきょくまだバージョンアップできてません。
まとめ
総合的には 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ログインに設定を有効にします。
次にサービスアカウントの設定から秘密鍵の生成を実行します。
そうするとJSONファイルがダウンロードされるので、これをAPIサーバーの起動時に環境変数に設定します。
$ export FIREBASE_CREDENTIAL_JSON=$(cat ~/Downloads/adventar-test-firebase-adminsdk-xxx.json) $ cd api-server $ docker-compose up
これでAPIサーバーは起動するはずです。環境変数の設定は毎回やるのは面倒なら direnv や envchain などを使うといいと思います。僕は 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の設定画面から確認できるはずです。
これで 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つを使うということだけです。
理由としては、個人的に学びたいと思っているけど直近で利用する機会がなさそうなので、個人サービスで実験的に導入して試してみたいというのが大きいところです。
その他の構成要素はこれらを軸に実装していく上で、比較検討しながら決めていきました。
- Nuxt.js(フロントエンドのフレームワーク)
- Next.js か Nuxt.js の2択だった
- Vue.js よりは React のほうが好きだが、フレームワーク的に Nuxt.js のほうがよくできていそうだった
- Go(gRPC Server)
- 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)
- Serverless Framework(Lambda のデプロイツール)
- 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があって、fetch
やXHR
と違ってunload
時でもリクエストが送信されるのが保証されるのが売りらしい。メジャーなブラウザには実装されつつある。
Navigator.sendBeacon() - Web APIs | MDN
デフォルトだとContentType
はtext/plain
なんだけど、application/json
とかに変更したいということはけっこうあると思う。その場合Blob
のtype
に指定すればいいらしい。
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 lint
やyarn run lint
は、このコマンドをシェルを通して実行する。このときデフォルトではsh
が使われる。例えばmacOSだとsh
はbash
なので、bash
がsrc/**/*.{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 書きたくない。