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のみなさん、ありがとうございました。

developmentでridgepole applyしたときにtest DBもつくる

Rails標準のMigrationを使う場合はdevelopmentのDBをアップデートしたらschema.rbが更新されてspec/rails_helper.rbとかに書いてある

ActiveRecord::Migration.maintain_test_schema!

っていうのでrspec実行前にtest DBをmigrationしてくれる。

ridgepoleを使っている場合はschema.rb使わないので上記の行を消してtest DBにschemaの変更を反映するのにdevelopmentとは別途applyしてたんだけど、面倒なのでridgepoleのapply taskをこんな感じにしてみた。

namespace :ridgepole do
  desc 'Apply schema definition'
  task :apply do
    sh 'ridgepole', '--config', 'config/database.yml', '--env', ENV.fetch('RAILS_ENV', 'development'), '--apply', '--file', 'db/Schemafile.rb'

    unless Rails.env.production?
      Rake::Task['db:schema:dump'].invoke
      Rake::Task['db:test:prepare'].invoke
      Rails.root.join('db/schema.rb').delete
    end
  end
end

ridgepole applyするとtest DBも一緒に作られて便利。

ちなみにridgepole --env test --applyでもいいんだけど差分計算して適用する必要なくて、一回消してdevelopmentと同じ状態で作り直したいからdb:test:prepareにしている。

ruby 2.5で変更されたtoplevel constant lookupの挙動

こういうコードがあったとして

class Foo; end
class Bar; end
p Foo::Bar #=> Bar

ruby 2.4まではwarning: toplevel constant Bar referenced by Foo::Bar を出しつつFoo::BarBarを返す。

これはrubyの定数探索が継承関係を遡って探すという仕様と、トップレベルで定義された定数がObjectに所属する、という仕様によるもの。わかりやすく書くとこういう感じ。

class Foo < Object; end
Object::Bar = 1
p Foo::Bar #=> 1

Foo::BarとしたときFooにはBarがないので、次にFooの継承関係を辿って親クラスのObjectからBarを探す。するとObject::Barが見つかるのでObject::Barを返す。

2.5だとこれがエラーになるように変更された。

class Foo; end
class Bar; end
p Foo::Bar #=> NameError: uninitialized constant Foo::Bar

ref: https://bugs.ruby-lang.org/issues/11547