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 書きたくない。

Railsのネストしたネームスペースのautoload

こんな感じのコードがあったとして

# app/models/user.rb
class User
end

# app/models/admin/user.rb
module Admin
  class User
  end
end

# app/controllers/admin/first_controller.rb
class Admin::FirstController
  def index
    p User
  end
end

# app/controllers/admin/second_controller.rb
module Admin
  class SecondController
    def index
      p User
    end
  end
end

FirstControllerSecondControllerの違いはモジュールのネストのシンタックスの違いだけ。このときRubyUser定数の探索を次の順におこなう。

  • FirstControllerの場合
    1. Admin::FirstController::User
    2. User
  • SecondController
    1. Admin::FirstController::User
    2. Admin::User
    3. User

したがってFirstControllerはトップレベルのUserSecondControllerAdmin::Userを参照する。

$ ruby -r './app/models/user' -r './app/models/admin/user' -r './app/controllers/admin/first_controller' \
  -e 'Admin::FirstController.new.index'
User

$ ruby -r './app/models/user' -r './app/models/admin/user' -r './app/controllers/admin/second_controller' \
  -e 'Admin::SecondController.new.index'
Admin::User

これがRubyの定数探索の挙動。

一方Railsconst_missingによる探索の挙動は異なり、どちらの場合も以下の順で探索する。

  1. Admin::FirstController::User
  2. Admin::User
  3. User
$ rails r 'Admin::FirstController.new.index'
Admin::User

$ rails r 'Admin::SecondController.new.index'
Admin::User

どちらもAdmin::Userを指す。Rails Guidesのドキュメントに以下のように書いてある。

Unfortunately Rails autoloading does not know the nesting in the spot where the constant was missing and so it is not able to act as Ruby would. In particular, Admin::User will get autoloaded in either case.

https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#nesting-and-qualified-constants

また、UserAdmin::Userが事前に読み込まれているかどうかで、Rubyによる解決で済むかRailsconst_missingの解決になるかが異なるので、場合によって挙動が違うようになる。これによりテストを実行したときに、単体のテストは通るけど全テストを実行したときに読み込み順によって落ちる、みたいなつらいことになりがち。

Userが読み込まれている場合はどちらの場合もRubyの探索でトップレベルのUserに行き着くのでUserを返す。これはわかりやすい。

$ rails r 'User; Admin::FirstController.new.index'
User

$ rails r 'User; Admin::SecondController.new.index'
User

Admin::Userが読み込まれている場合はFirstControllerでエラーになる。Admin::Userを返してくれることを期待していたが、予想と異なる挙動になった。

$ rails r 'Admin::User; Admin::FirstController.new.index'
uninitialized constant Admin::FirstController::User

$ rails r 'Admin::User; Admin::SecondController.new.index'
Admin::User

ドキュメントにはこのように書かれている。

If none of the parent namespaces of the class or module has the missing constant then Rails assumes the reference is relative. Otherwise qualified.

https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#autoloading-algorithms-qualified-references

つまり、Admin::FirstControllerから未定義のUserを呼び出したとき、RailsAdmin::FirstController::Userとして呼び出されたか、class Admin::FirstController; User; endとして呼び出されたかの判断ができない。したがって、このケースで、Admin::Userを返したら、Admin::FirstController::Userという呼び出しに対してAdmin::Userを返す可能性があってこれは非常にわかりづらい挙動になる。そのため、安全側に倒して親のネームスペースに探索対象の定数が定義されていたら親からは探さないようにする、ということらしい。

まとめると、思ったこととしてはこんな感じ。

  • Railsでネストしたネームスペースの名前がかぶるような命名は基本的には避けるべき
  • どうしてもネームスペースが重複する場合はその定数の参照はコンテキストに依存しない絶対的な参照に変更したほうがいい。::UserAdmin::Userのように
  • class Foo::Bar; endのような表記はRubyの定数探索と挙動が異なるのでmodule Foo; class Bar; end; endのようなネストで書いたほうがよさそう気がするけど他にも想定してないケースがありそうなので本当にそれがいいかはあんまり自信ない

Rails難しい。

SmartHRの体験入社にいってきた

tech.smarthr.jp

人事制度として興味あるし、他の会社で働いてみることにも興味があったので発表された直後に反射的に応募したところ、きていいよ(意訳)と返事をもらったのでほいほいと行ってきた。

一日のコースで、こんな感じのことをやってきた。

午前中

  • 30分ぐらいで環境構築
    • エディタとかシェルの設定を好きなようにする
    • 必要そうなソフトウェアとかミドルウェアとかはあらかじめインストールしてあった
    • リポジトリをクローンしてきてドキュメントに沿ってbundle installとかbin/rails db:migrateとかをやるだけ
    • 開発環境はRails以外(DBとか)はDockerだった(RailsはDocker for Macが遅くて諦めたらしい)
  • さくっとできるチケットを消化してマージまでのプロセスを体験する
    • CSSを2行追加しただけ
    • チケット管理はJiraでやられていてGitHubといい感じに連携されていた
    • Pull Requestを作ると自動でstaging環境がHeroku上に立ち上がって便利だった
    • 二人がApproveしたらマージできる

お昼

  • 麻婆豆腐を食べた。おいしかった
  • 色々なエンジニアの方と話しができてよかった

午後

  • 少し難し目のチケットをやってみる
    • APIに新しくエンドポイントを生やすようなやつ
    • ドメイン知識がけっこう必要なものだったけど丁寧に説明してもらって2時間ぐらいでプルリクエスト作った
    • 特に指摘もなくレビュー通ってマージされた
  • もう少し難しいチケットをやってみる
    • ドメイン知識モリモリかつ、独特な技術(PDF自動生成)を使っているところに機能を足す
    • さすがに一人で調べながらだと時間が足りなさそうそうなのでメンターの人とペアプロした
    • ほぼメンターの人に教えてもらいながらだけどやりきった
  • デプロイを見守る
    • デプロイは担当者が一日一回夕方におこなう
    • 朝やった修正が本番にデプロイされたのを確認

こんな感じの1日だった。最近あんまり仕事でコード書いてないのでなんにもできなかったらどうしようと不安だったけどそれなりにコードが書けた(と自分では思っている)ので安心した。

他の会社で試しに働いてみるというのは、社会人になるとなかなかできる経験ではないし、1日とはいえ他の会社の開発環境や雰囲気を知ることができてとてもよい体験だった。担当していただいたのぷりんたいさんをはじめ、SmartHRのみなさん、ありがとうございました。