男女比はカレーと福神漬けと同じくらい

マサカリよろしくお願いします。

ActiveModel::Dirty を使うときに気をつけること

最近ハマってしまったのでメモとして残しておく。

TL;DR

自身のmodel と association を同時に save する場合は dirty attribute が上書きされる可能性があるので注意する必要がある。

本編

雑なサンプルを提示する。

  • User : ユーザー作成時に新規ユーザー向けのメール送信する機能を持っている
  • UserName : User と has_one の関係で UserName 作成時に親の User model の filled_name を更新する
# == Schema Information
#
# Table name: user_names
#
#  id          :integer          not null, primary key
#  user_id     :integer          indexed
#  name        :string(32)
#  created_at  :datetime
#  updated_at  :datetime
#

class UserName < ApplicationRecord
  belongs_to :user
  
  after_create :mark_as_filled_name

  private

  def mark_as_filled_name
    user.filled_name = true
    user.save!
  end
end
# == Schema Information
#
# Table name: users
#
#  id          :integer          not null, primary key
#  filled_name :boolean          default(FALSE), not null, indexed
#  created_at  :datetime
#  updated_at  :datetime
#

class User < UsersModel::User
  has_one :user_name

  after_save :send_mail_for_new_user

  private

  def send_mail_for_new_user
    MailService.send_new_user_mail(user) if saved_change_to_id?
  end
end

上記のような model が存在したとき、以下のような実装で User と UserName の作成を同時に行うとメールが送信されない。

user = User.new
user.user_name.build(name: 'this is name')
user.save!

User と UserName の作成を別々に行うときちんとメールは送信される。(同一トランザクションでも問題ない)

user = User.new
user.save!
user.user_name.create!(name: 'this is name')

どうしてこうなるかを解説していく。
まず、 User と UserName の作成を同時に行う場合の user.save! では以下の処理が順番に実行されている。

  1. User の create
  2. UserName の create
  3. UserName の after_create による User の update
  4. User の after_save

 
4. を実行するタイミングでは 1. の dirty attribute(id column の変化) が残っていることを期待するが 3. の User update(filled_name column の変化) によって 1. の dirty attribute が上書きされ、サンプルコードで言うところのsaved_change_to_id?falseを返してしまう。

学び

  • model の同時 save を行う必要がないときはなるべく別々での save を心がける
  • dirty attribute 難しい

sorbet は production で使えるのか [2020/03/29 時点]

sorbet は少し前に話題になっていた Ruby の type annotaion gem。

sorbet.org

以下のようなコードを書くと静的型チェック、動的型チェックの両方をやってくれる。

# typed: true
require 'sorbet-runtime'

class A
  extend T::Sig

  sig {params(x: Integer).returns(String)}
  def bar(x)
    x.to_s
  end
end

def main
  A.new.barr(91)   # error: Typo!
  A.new.bar("91")  # error: Type mismatch!
end

上記コードのsrb ts実行結果

editor.rb:12: Method barr does not exist on A https://srb.help/7003
    12 |  A.new.barr(91)   # error: Typo!
          ^^^^^^^^^^^^^^
  Autocorrect: Use `-a` to autocorrect
    editor.rb:12: Replace with bar
    12 |  A.new.barr(91)   # error: Typo!
                ^^^^


editor.rb:13: Expected Integer but found String("91") for argument x https://srb.help/7002
    13 |  A.new.bar("91")  # error: Type mismatch!
          ^^^^^^^^^^^^^^^
    editor.rb:5: Method A#bar has specified x as Integer
     5 |  sig {params(x: Integer).returns(String)}
                      ^
  Got String("91") originating from:
    editor.rb:13:
    13 |  A.new.bar("91")  # error: Type mismatch!
                    ^^^^
Errors: 2

sorbet は前から気になっていたけど社内で運用できる状態になっているのかどうか気になったので土日で調べてみた。

気になっていたこと

  • sorbet-typed に更新があったら各リポジトリに更新を反映した PR を自動で作ることは可能なのか
  • 社内 gem 用の sorbet-typed は作れるのか
  • CI に組み込んで型チェックにコケたら PR 上で自動で指摘させることは可能か
  • 型の coverage のようなものを計測することはできるか
  • メタプロで生成されたメソッドの型定義はどうなるのか
  • 周辺環境はどのくらい整備されているのか
    • エディタとの integrate, sorbet の型定義を何かから自動生成する何か

sorbet-typed に更新があったら自身のリポジトリに更新を反映した PR を自動で作ることは可能なのか

sorbet には TypeScript の DefinitelyTyped のような型定義の中央管理システムがあって、gem に型定義を追加する場合はここに PR を投げる形になっている。
GitHub - sorbet/sorbet-typed: A central repository for sharing type definitions for Ruby gems

また、sorbet は型定義ファイルを commit する方針を取っている1ので利用している gem の型定義が更新されると自身のリポジトリsrb rbi updateを実行し、明示的に更新する必要がある。

型定義をいい感じに更新する PR を投げてくれる script がどこかに転がっていればいいなと思ったけど、パッと調べたところ見当たらなかった。作る必要がありそう。

travis cronjob とか使えばいい感じにできるんじゃないかと思っている。しらんけど

docs.travis-ci.com

結論:自作すれば可能

社内 gem 用の sorbet-typed は作れるのか

上に書いたように sorbet は sorbet-typed を使って型定義を共有する仕組みを作っている。 ただ、これは public なので private gem の型定義をここに書いちゃうと全世界に公開することになってしまう。 それは困る。
社内用の sorbet-typed を作り、それを差し込めるような仕組みがあれば private gem でも社内で利用することはできるはず。

sorbet, sorbet-typed の issue や sorbet Slack を眺めたりしたけど private gem に対する言及はどこにもされていなかった。 その辺りのソースコードも眺めてみたけど sorbet-typed の URL がハードコードされていた。 サポートされていない可能性が高い?

じゃあ Stripe はどうしているんだという話はあるし、他で問題になっていないわけがないので何を根本的に勘違いしている可能性がある

結論:本家に PR 投げれば可能?

CI に組み込んで型チェックにコケたら PR 上で自動で指摘させることは可能か

PR 上でコメントをつけてくれるサービスはいくつがあるが、社内では reviewdog を使うことが多い。
reivewdog は sorbet 未対応だったので PR を出した。数日中に使えるようになるはず。

追記:出していた PR が merge された!

github.com

他のサービスが sorbet に対応しているかどうかはちゃんと調べてない

結論:reviewdog を使えば可能

型の coverage のようなものを計測することはできるか

ここのサポートは手厚く、かなり細かい情報まで取得することができる。
Tracking Adoption with Metrics · Sorbet

しかも、導入フェーズに合わせてどの metrics に着目すべきかまで書いてくれているのでとても分かりやすい。
https://sorbet.org/docs/metrics#suggestions-for-driving-adoption

取得できる metrics 一覧

% srb tc --metrics-file=metrics.json
% cat metrics.json | jq ".metrics[].name" | sort
"ruby_typer.unknown..error.max"
"ruby_typer.unknown..error.min"
"ruby_typer.unknown..error.p25"
"ruby_typer.unknown..error.p50"
"ruby_typer.unknown..error.p75"
"ruby_typer.unknown..error.p90"
"ruby_typer.unknown..error.total"
"ruby_typer.unknown..release.build_scm_commit_count"
"ruby_typer.unknown..release.build_timestamp"
"ruby_typer.unknown..run.utilization.context_switch.involuntary"
"ruby_typer.unknown..run.utilization.context_switch.voluntary"
"ruby_typer.unknown..run.utilization.inblock"
"ruby_typer.unknown..run.utilization.major_faults"
"ruby_typer.unknown..run.utilization.max_rss"
"ruby_typer.unknown..run.utilization.minor_faults"
"ruby_typer.unknown..run.utilization.oublock"
"ruby_typer.unknown..run.utilization.system_time.us"
"ruby_typer.unknown..run.utilization.user_time.us"
"ruby_typer.unknown..types.input.bytes"
"ruby_typer.unknown..types.input.classes.total"
"ruby_typer.unknown..types.input.files"
"ruby_typer.unknown..types.input.files.sigil.autogenerated"
"ruby_typer.unknown..types.input.files.sigil.false"
"ruby_typer.unknown..types.input.files.sigil.ignore"
"ruby_typer.unknown..types.input.files.sigil.strict"
"ruby_typer.unknown..types.input.files.sigil.strong"
"ruby_typer.unknown..types.input.files.sigil.true"
"ruby_typer.unknown..types.input.methods.total"
"ruby_typer.unknown..types.input.methods.typechecked"
"ruby_typer.unknown..types.input.sends.total"
"ruby_typer.unknown..types.input.sends.typed"
"ruby_typer.unknown..types.sig.count"

metrics.json を人間が見やすい形式にしてくれる gem も見つけた。これを各 PR に comment する bot 作るととても良さそう。

github.com

bundle exec srb tc --metrics-file /tmp/sorbet_metrics.json
# No errors! Great job.
bundle exec sorbet_progress /tmp/sorbet_metrics.json
# Sorbet Progress

# Progress for sig coverage
# total_signatures  7528
# total_methods     183447
# total_classes     112433

# Progress for file coverage
# sigil_ignore      12      0.20 %
# sigil_false       5466        91.60 %
# sigil_true        460     7.71 %
# sigil_strict      12      0.20 %
# sigil_strong      17      0.28 %
# ---------------------------------------
# Total:        5967    100%
# Keep up the good work 👍

結論:可能

メタプロで生成されたメソッドの型定義はどうなるのか

experimental な機能として、メタプロで定義したメソッドに対して型定義を行うための plugin を書くことができる。

Metaprogramming plugins · Sorbet

# metaprogramming.rb
# typed: true

class Metaprogramming
  def self.macro(name)
    define_method("#{name}_time") { name }
  end

  macro(:bed)
  macro(:fun)
end

hello = Metaprogramming.new
hello.bed_time # error: Method `bed_time` does not exist on Metaprogramming
hello.fun_time # error: Method `fun_time` does not exist on Metaprogramming

上記のような macro を定義する DSL があった場合、以下のような plugin を書くことで sorbet にメソッドが定義されていたかのような挙動をさせることができる。

# macro_plugin.rb

# Sorbet calls this plugin with command line arguments similar to the following:
# ruby --class Metaprogramming --method macro --source macro(:bed)
# we only care about the source here, so we use ARGV[5]
source = ARGV[5]
/macro\(:(.*)\)/.match(source) do |match_data|
  puts "def #{match_data[1]}_time; end"
end
# Note that Sorbet treats plugin output as rbi files

Caveats

Sorbet decides which plugin to call using method names only. This might be a problem if different metaprogramming methods use the same name, have similar usages, but behave differently. To illustrate, using the configuration above, the following calls macro_plugin.rb twice but results in an error.
https://sorbet.org/docs/metaprogramming-plugins#caveats

気をつけないといけないこととして、同じ interface の DSL を作ると見分けるのが難しくなることが挙げられていた。
plugin には string のクラス名が渡ってくるので klass_str.constantize.method(method_name).owner で定義元を確かめることはできるが、クラスを load する必要があるのでsrb tcがかなり遅くなるはず。悩ましい。。。

Rails はメタプロの宝庫なのでどうなるんだろうと思っていたら、ちゃんとプロジェクトが進んでいた。
GitHub - chanzuckerberg/sorbet-rails: A set of tools to make the Sorbet typechecker work with Ruby on Rails seamlessly.

結論:基本的には可能

周辺環境はどのくらい整備されているのか

エディタとの integrate

Language Server Protocolに準拠しているので様々なエディタと互換性はあるらしいが、vscode plugin のようなものは見当たらなかった。 自分で設定して Language Server を立ち上げれば使えるのかも。

結論:可能(と言っている)

sorbet の型定義を何かから自動生成する何か

色々あった。

ちょっと性質は違うけど sorbet 用の rubocop も見つけた。
GitHub - Shopify/rubocop-sorbet

まとめ

今からすぐに使える感じではなかったけど、社内 gem の sorbet-typed 問題さえ解決すれば手の届く範囲にあるなと思った。 前向きに検討したい。


  1. その方針を取った理由は https://sorbet.org/docs/rbi#a-note-about-vendoring-rbis に書いてある

grpc_required_annotator gem つくった

rubygems.org

TL;DR

  • 会社で grpc ruby を使っていて、同じような実装何回もやってんなと思ったので共通化して gem にした
  • request message を required チェックを簡単にできる DSL を提供してくれる
  • いつも書いていた required をチェックするだけの冗長なテストがすごく短くなって生産性上がった

本編

最近会社で grpc ruby を使った backend を書いていて、「同じような実装何回もやってんな」と思うことがあった。 それは request message の required な field に対して、空だったときに GRPC::InvalidArgument を返すやつ。

class SampleService < SamplePb::Sample::Service
  def foo(request, call):
    raise GRPC::InvalidArgument.new("`num` is required") if req.num == 0  # これ
    raise GRPC::InvalidArgument.new("`str` is required") if req.str == "" # これも
    raise GRPC::InvalidArgument.new("`repeated_fields` is required") if req.repeated_fields.empty? # 嫌になってくる
    FooResponse.new(num: req.num, str: req.str, repeated_fields: req.repeated_fields)
  end
end

これに対応するテストを書くのもめんどくさい。

describe SampleService do
  describe "#foo" do
    subject {  described_class.new.foo(request) }

    context "when num is empty" do
      let(:request) {
        SamplePb::Sample::FooRequest.new(
          num: nil,
          str: "str",
          repeated_fields: ["a"]
        )
      }

      it "raises GRPC::InvalidArgument" do
        expect { subject }.to raise_error(GRPC::InvalidArgument)
      end
    end

    context "when str is empty" do
      ...
      it "raises GRPC::InvalidArgument" do
        expect { subject }.to raise_error(GRPC::InvalidArgument)
      end
    end

    context "when repeated_fields is empty" do
      ...
      it "raises GRPC::InvalidArgument" do
        expect { subject }.to raise_error(GRPC::InvalidArgument)
      end
    end
  end
end

shared_exaple を使えばそれなりに共通化はできるけど request のクラスは各 rpc ごとで異なるので微妙に使いにくい。

というわけでシュッと required な field を宣言できて、パッとテストを書けるいい感じの gem を作ろうと思った。
できたのがこちら

github.com

この gem に入っている GrpcRequiredAnnotator module を service の実装クラスに include することで required メソッドが使えるようになる。

class SampleService < SamplePb::Sample::Service
  include GrpcRequiredAnnotator

  required :num, :str, :repeated_fields
  def foo(request, call):
    # raise GRPC::InvalidArgument.new("`num` is required") if req.num == 0  # これ
    # raise GRPC::InvalidArgument.new("`str` is required") if req.str == "" # これも
    # raise GRPC::InvalidArgument.new("`repeated_fields` is required") if req.repeated_fields.empty? # 嫌になってくる
    FooResponse.new(num: req.num, str: req.str, repeated_fields: req.repeated_fields)
  end
end

こんな感じで各 rpc に対応するメソッドのすぐ上で required にしたい field を symbol で宣言すると実行前に validation してくれるようになる。便利。
各 rpc に対応する required な field を取得できるメソッドも生やしているのでテストもかなり行数が減った。

RSpec.describe SampleService do
  describe "#foo" do
    describe "required" do
      it "num, str and repeated_fields are required" do
        expect(described_class.required_fields(:foo)).to eq [:num, :str, :repeated_fields]
      end
    end
  end
end

結構便利だと思うので grpc ruby 書いてる人はぜひ使ってみてください。

method_addedで added された method の挙動を override して再定義してたりとか、その再定義を module の extend で実現しようとしたら grpc interceptor の治安の悪い仕様1によって壊れてしまった話は元気があればまた書きます。


  1. grpc interceptor は intercept した method の method インスタンスを参照できる

ModelとConcernに記述したcallbackの実行順

結論

Concern に記述された callback が先に実行される。
既存の callback を Model もしくは Concern に移すときは気をつける必要がある。


以下確認コード

class Hoge < ApplicationRecord
  include HogeConcern

  before_validation -> { puts "before_validation in Class" }
end

class HogeConcern
  extend ActiveSupport::Concern

  included do
    before_validation -> { puts "before_validation in Concern" }
  end
end
[2] pry(main)> Hoge.create
   (0.5ms)  BEGIN
before_validation in Concern
before_validation in Class
...

マイクロサービス他社事例

マイクロサービスの事例を調べた。 多少まとめた方がいいんだろうけど、一覧があるだけでも一定の価値はあると思ったので載せておく。


その他

更新系 Web API response 再考

更新系の Web API response をどうすればいいのか毎回悩むので良い感じのデザインパターンをパッと調べたけど見当たらなかったので友達と議論した。

どんな場合はどんな response を返すべき、という結論には至らなかったけど一通りのパターンは洗い出せたと思うのでメモしておく。

TL;DR

  • response bodyを返さないパターン
  • 更新したリソースの id だけ返すパターン
  • 更新したリソース全体を返すパターン
    • 更新したリソースだけを返すパターン
    • 関連する情報を含む更新したリソースだけを返すパターン
  • 更新したリソースのうちクライアント側が必要な情報だけを返すパターン
    • 柔軟なinterfaceである程度endpointを共通化するパターン
    • 固定のinterfaceで必要な情報のユースケース分のendpointを作るパターン

response bodyを返さないパターン

204 を返すパターン。 protocol buffer だと google.protobuf.Empty を返すパターン。

リソースの新規作成など、response として property を返さなくてもクライアント側でリソースの状態を知る術がある場合に使う印象がある。

更新したリソースの id だけ返すパターン

response body を返さないパターンと使う場面は大きく変わらない気がしている。

しかし、クライアント側が更新したリソースの id を知る術がない(request で複数のパラメータによってリソースを一意に特定するといった id 以外の情報で指定している場合)がクライアントが id を key として状態を管理しているため id を知りたい、といったケースでは id だけ返すパターンの有用性が出てくる。

更新したリソース全体を返すパターン

これは backend 側の目線で返すリソースを決めるパターンで大別すると2つに分類できる。

  • 更新したリソースだけを返すパターン
  • 関連する情報を含む更新したリソースだけを返すパターン

どっちで返すかはパフォーマンス次第という印象がある。

更新したリソースのうちクライアント側が必要な情報だけを返すパターン

これは frontend 側の目線で返すリソースを決めるパターンで実装方針は2つに分類できる。

  • 柔軟なinterfaceである程度endpointを共通化するパターン
  • 固定のinterfaceで必要な情報のユースケース分のendpointを作るパターン

柔軟なinterfaceである程度endpointを共通化するパターン

サーバに対してクライアントが複数存在しユースケースがたくさんある場合、こっちの方が手早く開発できる印象がある。 GraphQL, Protocol Buffer の Field Mask, Rails active-model-serializer の fields パラメータなどはこれに該当する。

固定のinterfaceで必要な情報のユースケース分のendpointを作るパターン

サーバとクライアントが 1:1 対応しているような比較的小規模なアプリケーションではこっちの方が手早く開発できる印象がある。

Thanks to

DB共有しているRailsアプリケーションのテストを書くときに気をつけること

これで2時間くらい潰れたので供養のために書いておく。

TL;DR

DatabaseCleanerのstrategyがtransactionになってると、もう一方のRailsアプリケーションに反映されないから気をつけよう

遭遇したこと

2つのRailsアプリケーションで1つのDBを共有しているサービスで以下のようなテストを書いていた。

  1. Rails 2 がレコードを作成する
  2. Rails 2 がRails1に対して作成したレコードのidを渡しAPI callをする
  3. Rails 1 が受け取ったidのレコードを取得し、中身を書き換える
  4. Rails 2 で中身が書き換わったことを確かめる
# めちゃくちゃ雑な図解

             Rails 1                Rails 2
                |                     |
                |                 a = A.create!
                |                     |
          A.find(a.id)                |
      a.update!(foo: "bar")           |
                |                     |
                |          expect(a.foo).to eq "bar"
                |                     |

実行すると、A.find(a.id)がResourceNotFoundを返してテストが落ちていた。
テストコードを何回見直してもおかしい箇所は見当たらず、悲しい気持ちになっていたら database_cleanerが悪いことをしている気がしたので確認してみると、

RSpec.configure do |config|
  ...
  condig.before do |_|
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.start
  end
  ...
end

database_cleanerのstrategyがtransactionに設定されていた。
なので変更してもDB自体にはcommitされていなかったためにRails 1から作成したレコードは見えていなかった。

よく考えると当たり前なんだけど、問題に直面するとなかなか気づかなかったりするのでメモメモ。