Adventar における Server Side Rendering の導入

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

Adventar のフロントエンドは Nuxt.js で SPA な構成ですが、一部で Server Side Rendering(以下 SSR)をおこなっています。今日は SSR をする理由や Adventar での導入方法について書きます。

なぜ SSR するのか

SPA で SSR が必要な理由は主に

  • マシンリーダビリティ(SEO とか OGP とか)
  • パフォーマンス

の2つだと思っています。SPA は基本的に JavaScriptAPI を呼び出してコンテンツをレンダリングするので、JavaScript を理解できないクライアントには正しいコンテンツを提供できません。典型的な例は検索エンジンクローラーや、OGP などでサイトのコンテンツを取得するケースです。

SEO については最近のクローラーJavaScript を理解してくれるので SSR 必須ではないと思います。OGP は、Adventar においてはカレンダーページのタイトルと概要テキストだけどうにかできればいいので、Edge Side(Lambda@Edge など)でどうにかして差し込むなどの方法があるかもしれません。

一方パフォーマンスについてはちゃんとやればかなり効果があると思っています。一般的な SPA をレンダリングする場合ユーザーがアクセスしてコンテンツをレンダリングするまでには

  1. HTML を取得する
  2. JavaScript を取得する
  3. JavaScript を実行して API コールする
  4. API の結果を元に DOM を構築する

というフローがあります。SSR だと最初の HTML を受け取った時点でコンテンツ情報もできているので、ユーザーは即座にコンテンツを閲覧することができ、パフォーマンス的には優位です。

もちろん SPA が返す、JS と CSS 以外何もコンテンツがない HTML と比べると、SSR の HTML をつくる過程にはサーバー側で API コールなり、SQL の呼び出しがあり、それを元に HTML を構築する処理が走るので、SPA のような を作るよりも時間がかかります。とはいえ、ネットワーク的なレイテンシの少ない SSR のほうが基本的に速くなるほうが多いでしょう。しかし、サイトの特性によっては初回ロードの遅延がそこまで気にならない場合もあるでしょう。

一方で、SSR によるパフォーマンス的な最大のメリットは、キャッシュによるパフォーマンス最適化がやりやすいところにあると思っています。SSR した結果を CDN などでキャッシュして適切にパージができれば、パフォーマンスは超速になります。

ただし、このパージがけっこう大変で、当初は CDN でキャッシュするアーキテクチャを前提で設計していましたが、色々と考えた結果パージに思ったよりコスト(実装コスト、お金的なコスト両方で)がかかるので結局 CDN のキャッシュは今年はいったん諦めて、リクエスト毎に SSR しています。諦めた理由などについては後日詳しく書こうと思います。来年やれたらやりたいです。

このように、ちゃんとやればいくつかのメリットがある SSR ですが、正直実装コスト、運用コストが高すぎてメリットに見合うかは微妙なところだと思っています。実際、今回実装してみてそれを実感しました。

やはりブラウザとNode.jsという実行環境が全然違う環境で同一のコードを実行するというのは、理論上は可能でも色々と困難です。5日目の記事にもその一部を書きましたし、この他にも実装コスト、運用コストがかなり高いです。

今回 Adventar で SSR を導入するのに費用対効果はあまり考えてなくて、単なる技術的なチャレンジという意味合いが強いです。Lambda を使ったサーバーレスな環境での SSR というのを試したかったので導入しました。

どこに SSR を導入するか

今回、まず決めたのは SSR において認証情報を扱わない、ということです。今回は認証に Firebase Authentication を利用しているのもあり、認証をクライアントサイドで行っており、最初のリクエストにセッション情報をリクエストしていません。

Cookie に認証情報を乗せて頑張ればできないことはないのですが、API サーバーと SSR のサーバーで認証の機構を共有するなど、かなりの実装コストがかかりまし、SSR した結果を CDN でキャッシュする場合、レスポンスの HTML に認証された状態のものを乗せるのは複雑度が爆増するのが目に見てみます。

また、SSR した結果をキャッシュに乗せることを考えると、キャッシュのパージ戦略を考える必要があります。キャッシュのパージは対象のコンテンツとページが多くなるほど複雑になって制御が難しくなるので、できるだけ局所的にするのが良いと思っています。そのような理由から、今回 SSR するのはカレンダー詳細ページだけに限定することにしました。カレンダー詳細ページというは以下のような画面です。

https://adventar.org/calendars/3860

この画面は、ソーシャルメディアなどでもよく共有されるので、OGP を SSR で埋め込む必要もあるし、アクセスの8割はこの画面に最初に着弾するので、初期描画のパフォーマンスが最適化されることに大きい意味があるためです。

SSR 画面を限定したことで、すべて SSR するよりはトータルの実装コストは低くなったとは思いますが、最初にロードする画面が SSR 対象(カレンダー詳細画面)か、そうでないかによって微妙に挙動が変わって、どちらかでしか起きないバグが発生したり、デプロイのフローが複雑になったりと、それはそれで大変でした。

まとめ

今回は SSR のメリットや、Adventar における導入事例を紹介しました。明日はもう少し具体的な、Nuxt.js を Lambda で SSR する場合の技術的なトピックについて紹介したいと思います。

Firebase Authentication の苦労話

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

今日は Firebase Authentication で苦労した点や工夫した点について書きます。

ログインの判定が遅い問題

Firebase Authentication を使って、現在のセッションがログインしているかどうかを検出するには、firebase.auth().onAuthStateChanged ()を呼び出します。

https://firebase.google.com/docs/auth/web/manage-users

firebase.auth().onAuthStateChanged(function(user) {
  if (user) {
    // User is signed in.
  } else {
    // No user is signed in.
  }
});

しかし、この処理は呼び出してから初回のイベントが発火するのに数秒ぐらいレイテンシがあります。なので、愚直にこの機能を使うと、ページがロードされて、ログインしていれば数秒後にログインしている状態(自分のアイコンがヘッダに表示されたり、自分の投稿に編集ボタンが出たり)に切り替わる、ということになり、いい体験ではありません。

そこでログイン状態は localStorage にキャッシュすることにしました。キャッシュしているコードはこのへんです。

キャッシュした情報は plugin でリストアしています。

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/frontend/plugins/auth.ts

この処理は初期描画の直前に一回だけ実行されるので、描画と同時にログイン状態がわかります。機密情報をlocalStorageに保存するのはセキュリティ的なアレがありますが、今回保存するのは token そのものでなく、表示に必要なデータだけにしているのでそのあたりは安心です。

これでほぼよさそうに見えますが、キャッシュの状態が不整合になる可能性を考えてみましょう。

  1. 別タブでログアウトした/別のアカウントでログインした
  2. ログイン処理を終えてリダイレクトされて戻ってきた

1 のケースは問題にならなくて、firebase.auth().onAuthStateChanged()のイベントはユーザーのログイン状態が変わったら発火するイベントリスナーなのですが、別タブでログイン/ログアウトしたような場合でもイベントが発火します。なので、別タブでログイン/ログアウトしてもユーザーの状態は同期されます。

2のケースは、ログインプロバイダのログイン画面から戻ってきたときの話しです。ログイン画面から戻ってきた瞬間なので、キャッシュの状態は未ログイン状態のはずですが、実際にはプロバイダでのログインが成功していればログイン状態となるはずです。なので、キャッシュと実際の状態が不整合になるのは実はこの場合だけです。

このケースは、ログイン処理中であるというローディングを出すことで対応しました。最初は画面全体をローディングにしていましたが、状態が変わってから再描画するところはそんなに多くなく、ログイン処理中というのがわかればいいので、ヘッダのアイコンのところだけ局所的にローディングすることにしました。

f:id:hokaccha:20191205123241p:plain

ただ、プロバイダのログイン画面から戻ってきた、という状態を取る方法がなさそうだったので、リダイレクト前に sessionStorage に適当な値をセットして、次に初期化処理が走った場合はリダイレクトから帰ってきたとみなして、firebase.auth().onAuthStateChanged()が発火するまでローディングを出します。

唯一不自然になるパスは、ログインを押してログイン画面に遷移後、ログインを完了せずに戻ってくるパターンですが、まあそんなに致命的な問題でもないので許容しています。

アイコン URL が更新されない問題

Adventar ではユーザーアイコンに OAuth でとってきた画像を使っています。例えば Twitter でユーザーがアイコンを変えた場合には Adventar でも新しいアイコンになってほしいので、ログインの処理のときにアイコンをアップデートする処理をいれています。コードはこのあたりです。

https://github.com/adventar/adventar/blob/b73af145638e9f4d4da6655015786bdac54510eb/api-server/grpc-server/service/user.go#L47-L57

しかしこれではダメで、Firebase Authentication は最初にログインしたときの情報を Firebase Authentication 側にストアしてもっているらしく、ユーザーが Twitter 側でアイコンを更新しても、Firebase Authentication 側のデータは更新されないようです。

いくつか方法はありそうですが、一旦今はログアウト時に Firebase Authentication 側のユーザーを消すという方法を取っています。

https://github.com/adventar/adventar/blob/b73af145638e9f4d4da6655015786bdac54510eb/frontend/lib/Auth.ts#L56

export async function logoutWithFirebase() {
  const user = firebase.auth().currentUser;
  if (!user) return;

  try {
    await user.delete();
  } catch (err) {
    console.error(err);
  }

  await firebase.auth().signOut();
}

Adventar 側では特に Firebase Authentication の User ID などは使ってないので、ログアウト時に消しても問題ないためです。ただ、ユーザーとしてはアイコンを更新するためにログアウトが必要、というのは非常にわかりにくいので、もう少しいい方法を考えたいところです。

Firebase Authentication でログイン時にプロバイダの API のアクセストークンが取れるので、それで API をこちらで叩いて最新のアイコンを取得して更新、という感じになるかなと思っています(が、けっこうめんどくさいので後回しになっている)。

third-patry cookie が無効な場合にログインできない問題

これはそのままなのですが、Firebase Authentication は third-patry cookie が無効な場合はログインが失敗するという問題があります。

https://github.com/firebase/firebase-js-sdk/issues/934

これはもう仕方ないので、ログイン時のエラーをハンドリングして、Cookie によるエラーの場合は third-party cookie が無効な可能性がある、というメッセージを表示することにしました。

https://github.com/adventar/adventar/blob/b73af145638e9f4d4da6655015786bdac54510eb/frontend/lib/Auth.ts#L88-L92

const COOKIE_ERROR_MSG =
  "third-party cookie の設定が無効になってる可能性があります。ブラウザの設定をご確認ください。";
const msg = err.code === "auth/web-storage-unsupported" ? COOKIE_ERROR_MSG : err.message;
alert(`ログインに失敗しました。\n${msg}`);
console.error(err);

GitHub 認証の場合にユーザー名が空のケースがある問題

Firebase Authentication から取得できる JWT の中には名前やプロフィールアイコンのURLなどが以下のような形で格納されています。

{
  "name": "Kazuhito Hokamura",
  "picture": "https://lh3.googleusercontent.com/a-/AAuE7mDtNB98Nu2WHMoeBUs1x3XrNqCrav4GnZRTbwMB8g",
  "user_id": "xxx",
  "sub": "xxx",
  "iat": 1234567890,
  "exp": 1234567890,
  "firebase": {
    "identities": {
      "google.com": [
        "xxx"
      ]
    },
    "sign_in_provider": "google.com"
  }
}

この namepicture がそうです。GoogleTwitter などのプロバイダに設定してある名前やアイコン画像が取れるのですが、GitHub の場合、name が含まれない場合がありました。

GitHub は名前の入力が必須ではないので、入力してないユーザーの場合はこのフィールドがない状態の token になります。名前は必ず存在する、という前提でコードを書いていたので、そのような状態のユーザーがログインしようとするとエラーになってログインできない状態になっていました。

空の場合は user_id (github.com/xxxxxx)の部分が取れればいいのですが、Firebase Authentication 経由だとその情報は取れなさそうだった(もちろん自前でアクセストークン使って GitHubAPI を自前で呼び出せばとれますが)ので諦めて"No Name"のような名前でユーザーを作ることで対応しました。

https://github.com/adventar/adventar/blob/b73af145638e9f4d4da6655015786bdac54510eb/api-server/grpc-server/util/auth.go#L47-L51

まとめ

Firebase Authentication でハマった点などをいくつか紹介しました。慣れてないので色々とハマりましたが、やはり自前で認証の仕組みを用意するのに比べると格段に楽でよかったです。

明日は Server Side Rendering のについて書きます。

Firebase Authentication を利用した認証

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

今日は Firebase Authentication 認証について書きます。

選定理由

まず、なぜ今回 Firebase Authentication を利用することに決めたかを説明します。検討した選択肢としては

  • Firebase Authentication
  • Amazon Cognito
  • Auth0
  • 自前でがんばる

あたりです。このうち自前でがんばるのは大変なので最後の手段とし、可能な限りマネージドなサービスを検討しました。Rails 時代はセッションを Cookie ストアにしていたので、サーバー側でデータストアが不要だったりしてそんなに面倒ではなかったのですが、安全面を考えると Cookie ストアはやめたいと思っていたし、JWT にするにしても、結局安全面を考えると expire を短くしてリフレッシュトークンを使うことになりますが、リフレッシュトークンの管理をするのが面倒だったりします。なのでそのあたりをマネージドでできるのであればマネージドがいい。

マネージドサービスを検討するにあたり、考えないといけなかったのは過去のデータとの互換性です。ログインシステムを変更することで過去に作ったカレンダーや投稿を管理できなくなるのは避けたいところです。

Adventar ではこれまで、認証には omniauth という Gem を利用していました。omuniauth は色々な OAuth プロバイダを利用して認証システムを作ることができるライブラリです。Adventar では Google, Twitter, Facebook, GitHub をプロバイダとして利用していました。

なので、前提条件として、Google, Twitter, Facebook, GitHub の OAuth には絶対に対応している必要がありました。それを考えると Amazon Cognito は Twitter, GitHub のログインに対応していないので採用を見送りました。

Auth0 と Firebase Authentication はどちらも要件を満たしていましたが、Firebase Authentication はなんと無料で使えるということだったので、Firebase Authentication が今回のユースケースで使えるかを検証し、問題なさそうだったので今回利用を決めました。

Auth0 も Adventar ぐらいの規模だと無料か一番安いプラン($23)ぐらいで利用できそうではありましたが、個人開発にとって $23 はかなりでかいのです。

アーキテクチャ

Firebase Authentication は Firebase のその他の機能と連携すると、おそらくそんなに面倒なことはないのですが、今回は Firebase Authentication 以外に Firebase の機能は使わないので、Firebase Authentication を使った認証の仕組みを自前のサーバーに組み込む必要があります。

サーバー側が必要な情報は、ログインプロバイダと OAuth の uid 、名前とプロフィールアイコンの4つです。旧システムではログインプロバイダと OAuth の uid ユーザーの組み合わせを一意なユーザーの識別子として利用していたので、これがないとユーザーの識別ができません。名前とプロフィールアイコンは表示に使うだけなので、あれば嬉しいという感じです。これらを Firebase Authentication の API から取得してサーバーに送ることができればいいわけです。

Firebase Authentication ではまず最初に、クライアント側で認証の処理を行います。要点だけ説明していきますので、詳細はドキュメントを見てください。

https://firebase.google.com/docs/auth/web/firebaseui

まず、ユーザーが Adventar から、「Google でログイン」を押すと以下の処理が実行されます(API Key などは別途設定しています)。

firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider());

これでユーザーは Firebase Authentication のサイトを経由して Google 認証の画面に飛びます。ユーザーが Google の認証情報を入力して成功すると Adventar に戻ってくるわけですが、このとき以下のようなコードで JWT のトークンを取得することができます。

firebase.auth().onAuthStateChanged(async user => {
  const token = await user.getIdToken();
});

実際のコードはこのあたりです。

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/frontend/lib/Auth.ts#L105-L127

この token の実態は JWT で、前述したログインプロバイダ、uid、名前、プロフィールアイコンなどのほしかった情報が入っています。

なので、これをサーバー側に投げれば Adventar が持っているデータと突き合わせてユーザーを識別できるのですが、この token が改ざんされていないかを確認するため、JWT の検証が必要です。

https://firebase.google.com/docs/auth/admin/verify-id-tokens

今回はアプリケーションサーバーに Go を使っていますが、Firebase Authentication の Go SDK に JWT の検証機能があるのでこれを使います。

json := os.Getenv("FIREBASE_CREDENTIAL_JSON")
if json == "" {
    return nil, fmt.Errorf("FIREBASE_CREDENTIAL_JSON is empty")
}
opt := option.WithCredentialsJSON([]byte(json))
app, err := firebase.NewApp(context.Background(), nil, opt)
if err != nil {
    return nil, xerrors.Errorf("Failed to initialize firebase app: %w", err)
}

client, err := app.Auth(context.Background())
if err != nil {
    return nil, xerrors.Errorf("Failed to get auth client: %w", err)
}

token, err := client.VerifyIDToken(context.Background(), idToken)
if err != nil {
    return nil, xerrors.Errorf("Failed to verify token: %w", err)
}

こんな感じです。実際のコードは以下にあります。

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/api-server/grpc-server/util/auth.go#L24-L83

検証が済んだらこの token に含まれるデータを信頼できるので、それを使ってユーザーの認証を行います。

まとめ

Firebase Authentication を選んだ理由や基本的な認証の仕組みについて解説しました。

明日は Firebase Authentication を実際のアプリケーションに組み込んだときに工夫した点や苦労した点について書きたいと思います。

envoy の gRPC proxy に関する便利機能

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

今日は envoy の gRPC に関する便利機能について紹介しようと思います。

gRPC-Web proxy

4日目の記事でも書きましたが、今回は gRPC-Web の proxy レイヤーとして envoy を利用しています。envoy で gRPC-Web の機能を有効するのは簡単で、HTTP filters に envoy.grpc_web を書いて、ヘッダの設定をするだけです。

これだけで gRPC-Web を受けて、upstream のクラスタに gRPC でリクエストするようになります。超簡単。他に書くことがありません。

gRPC-JSON transcoder

5日目の記事に書きましたが、Adventar では、gRPC と JSON API の両方を envoy によって実現しています。JSON API で受けて、upstream の gRPC に流すのは、envoy の gRPC-JSON transcoder という機能を使っています。

https://www.envoyproxy.io/docs/envoy/latest/api-v2/config/filter/http/transcoder/v2/transcoder.proto

これは grpc-gateway と同じようなものです。Protocol Buffers の定義から生成したスキーマを envoy が読んで、gRPC をバックエンドにした JSON API を提供します。例えば、次のような Protocol Buffers を定義します。

syntax = "proto3";

package adventar.v1;

import "google/api/annotations.proto";

message GetCalendarRequest {
  int64 id = 1;
}

message Calendar {
  int64 id = 1;
  string title = 2;
  ...
}

service Adventar {
  rpc GetCalendar(GetCalendarRequest) returns (Calendar) {
    option (google.api.http) = {
      get : "/v1/calendars/"
    }
  }
}

optionで指定しているのは、grpc-gateway でも使われているもので、 gRPC と JSON APIマッピングを定義するためのものです。

envoy 側の設定は簡単で、envoy.grpc_json_transcoderを設定するだけです。

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/api-server/envoy/envoy-prod.yaml#L46-L49

ここで指定ているproto.pbは以下のようなコマンドで出力しています。

$ protoc \
  --include_imports \
  --include_source_info \
  --descriptor_set_out=../api-server/envoy/proto.pb \
  adventar/v1/*.proto

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/protobuf/protoc.sh#L6-L10

これで envoy と gRPC サーバーを起動すると、以下のようにアクセスすることが可能になります。

$ curl http://localhot:8080/v1/calendars?id=1 | jq
{
  "id": 1,
  "title": "xxx",
  ...
}

クライアント側が gRPC を使えなくても、普通の HTTP で通信できるので、様々な場面で便利な機能だと思います。

auto_mapping

gRPC-JSON transcoder は便利なのですが、Protocol Buffers の定義に

option (google.api.http) = {
  get : "/v1/calendars/"
}

のようなアノテーションを書かないといけないのが面倒です。これを解消するのに、envoy v1.11.0 から auto_mapping という機能が追加されました。

https://github.com/envoyproxy/envoy/pull/6731

これはgoogle.api.httpの定義を書かなくても、POST /<package>.<service>/<method>という URL でアクセス可能になるという機能です。

設定は簡単でauto_mapping: trueと書くだけです。

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/api-server/envoy/envoy-prod.yaml#L50

これで以下のようにアクセスできます。

$ curl -X POST -d '{"calendar_id":1}' https://localhost:8080/adventar.v1.Adventar/GetCalendar | jq
{
  "id": 1,
  "title": "xxx",
  ...
}

Adventar ではこの機能を使っているので、google.api.httpの定義は書いていません。

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/protobuf/adventar/v1/adventar.proto

(余談)google.api.http を捨てられていない理由

google.api.httpの定義は書いていません、と言いつつ、実は一番下にgoogle.api.httpの記述があるのに気づいたと思いますが、これは gRPC サーバーの前に立てている AWS Application Load Balancer(ALB) のヘルスチェックが GET でしかできず、auto_mapping は POST にしか対応してないので、しかたなく書いています。

ちなみに ALB は今現在 HTTP/2 の pass through に対応していないので、普通は gRPC の前段に置くことはできなくて色々と面倒なのですが、今回は gRPC-Web と JSON API しか通しておらず、直接 ALB が gRPC をしゃべる必要がないので ALB が利用できて便利です。このあたりのインフラの概要については後日書く予定でいます。

明日は Firebase Authentication について書く予定です。

gRPC-Web と Server Side Rendering の苦労話

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

今日は gRPC-Web 導入にあたって最も苦労した Server Side Rendering(以降は SSR と書きます)の話を書きます。

前提条件

初日の記事 に書いたとおり、今回の Adventar のシステム変更の目的に、

  • gRPC-Web を使う
  • Serverless な仕組みで SSR をおこなう

というのを入れました。趣味プロダクトなので技術が目的になっています。なので、gRPC-Web をやめる、SSR をしない、という選択肢はそもそも取らない、という前提で読んでください。(どちらかをやめることができればどれほど楽だったか...)

また、ここでいう SSR とは Nuxt.js などで作った JavaScript のアプリケーションを同一コードでサーバー側でも動かして HTML をレンダリングする、という文脈のやつです。Universal JavaScript と言い換えてもよいです。

個人的には SPA の SSR は実装難易度や運用コストが増えるので、やらないで済むならやらないほうがいい派なんですが、今回は普段やらないようなこともやってみるというのも目的の一つにありました。

gRPC-Web が Node.js で利用できない問題

gRPC-Web の自動生成したクライアントは完全にブラウザ用になっていて、Node.js では利用できません。何が困るかというと、SSR する際にブラウザとサーバーで同一のコードが使えないので詰んでしまいます。

ブラウザ用の Polyfill などを入れてみたりしましたが、どうしても動かないので諦めました。正攻法は gRPC-Web のクライアント生成に手を入れて Node.js 用のトランスポートオプションをサポートすることだと思いますが、C++ (protoc の gRPC-Web プラグインの実装言語)が苦手すぎて今回は諦めました。

そこで今回は妥協案として、Node.js 向けに JSON API を提供することにしました。JSON API というのは普通に ContentType: application/json で通信する API という意味です。つまりブラウザと API サーバー間は gRPC-Web で通信しますが、SSR する場合の Node.js と API サーバー間は JSON API で通信します。最悪な妥協策というのは理解しています。

envoy を使った JSON API Proxy

API を2種類用意するといっても、サーバーを別途を立てたわけではありません。gRPC-Web の proxy として利用している envoy に、JSON API を gRPC に変換する機能があるため、gRPC-Web と JSON API を同一のプロセスで処理できます。

f:id:hokaccha:20191203213418p:plain

envoy のこの機能については後日詳しく紹介しますが、少し設定を足すだけで済むので、サーバー側は特に手間はかかりません。2つの API があることで面倒なのはクライアント側だけということです。

クライアント側のコード

クライアント側の対応はサーバー側よりは面倒ですが、そこまで複雑ではありません。以下のように gRPC のクライアントと JSON API のクライアントを用意します。

そして以下のように ブラウザが実行する場合は gRPC、SSR の場合は JSON API を呼び出すだけです。

SSR は基本的に更新系のAPIを呼び出すことはないですし、今回は SSR するのをカレンダーの詳細ページ( https://adventar.org/calendars/3860 のようなページ)だけに留めたので、最終的にはこれぐらいのコード量でなんとかなりました。しかしここに行き着くまでにはだいぶ苦労しました...。

まとめ

仕事だったらおそらく頑張って Node.js のトランスポートオプションを実装するか、gRPC-Web を諦めて JSON API に寄せるか、SSR を諦めて別の方法を取る道をさぐったかもしれませんが、今回は前提条件にもあるように、gRPW-Web と SSR は妥協できなかったのでこのような方法を採用しました。来年時間が取れれば gRPC-Web に Node.js サポートパッチを書きたいです。

明日は envoy の便利機能について書く予定です。

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 について書きます。