valid,invalid

関心を持てる事柄について

ActiveRecord Association extensionsでメソッドを追加する

has_many*1にblockを渡すとassociationにメソッドを追加することができる。

class User < ActiveRecord::Base
  has_many :posts do
    def stats
      group(:status).count
      # このcontextで`self`は`ActiveRecord::Associations::CollectionProxy`
      # `proxy_association.owner`で`user.posts`の`user`にアクセスすることもできる
    end
  end
end

user.posts.stats
# => {'draft'=>3,'published'=>42}

内部的にはblockがModule.new(&block)されextend optionに渡される。なので始めからextend optionとして渡してやってもよい。

module StatsCounter
  def stats
    group(:status).count
  end
end

class User < ActiveRecord::Base
  has_many :posts, extend: StatsCounter
  # has_many :posts, extend: [StatsCounter] でもよい
end

user.posts.stats
# => {'draft'=>3,'published'=>42}

ちなみにblockを渡した際にnewされたModuleActiveRecord::Associations::Builder.define_extensionsによってUser classにUser::PostsAssociationExtensionのような名前でconst_setされる*2

使いどころを考える

子テーブルの絞り込みを行うfinderを書くなら子テーブルのほうのモデルのscopeでよいし、何を書いても子テーブル側の詳細や知識が親テーブル側のモデルに漏れている感じがする。

親テーブルから辿ったときにしか使わせたくない、つまり対象となるassociationの一部としてのみ扱いたいロジックを書きたいときが使いどころといえそう。

公式ドキュメントではファクトリーメソッドのような例が挙げられている。

class Account < ActiveRecord::Base
  has_many :people do
    def find_or_create_by_name(name)
      first_name, last_name = name.split(" ", 2)
      find_or_create_by(first_name: first_name, last_name: last_name)
    end
  end
end

person = Account.first.people.find_or_create_by_name("David Heinemeier Hansson")
person.first_name # => "David"
person.last_name  # => "Heinemeier Hansson"

うーん、上記の例でもPeople classに書いたほうが良さそうな気もする


This article corresponds to the 7th day of ohbarye Advent Calendar 2020.

Rails ActiveRecord 1ファイルで 新機能試す デバグ バグレポート 便利

(追記: 2021-03-21) 本家が提供しているテンプレートがあった…!!

rails/active_record_main.rb at main · rails/rails · GitHub


いつもググったり思い出したりしながら書いているのでメモしておく。

新機能触ったりデバグしたりバグレポートしたりするときに便利。

Dockerで立ち上げれば掃除も楽。


良いタイトルが思いつかなかったけどohbarye Advent Calendar 2020の6日目の記事でした。

pull requestをmergeした人の一覧を得る

git log --mergedでmerge commitを絞り込み、git log --prettyでAuthor情報を表示する。

# ~/.ghq/github.com/ohbarye/rails on git:master
$ git log --merges upstream/master..v6.0.0 --pretty=format:%an | sort | uniq
Eileen M. Uchitelle
Guillermo Iguaran
Rafael França
Ryuta Kamizono
प्रथमेश Sonpatki

絶対覚えられそうにない git log の pretty format の詳細は以下に記載されている。

Git - git-log Documentation

このやり方はgit-pr-releaseでmergeされたpull requestsの一覧をfetchするコードを参考にした。

git-pr-release/cli.rb at a9ac6700916dd68921926e329e0f3c034d1e07ed · x-motemen/git-pr-release · GitHub


This article corresponds to the 5th day of ohbarye Advent Calendar 2020.

React Adminで開発しているSPAのReactを17.0へupgradeした

業務で扱っているSPAのReactバージョンを17.0にアップグレードした。

reactjs.org

対象のSPAの開発が始まったのが2020年内で元々16.13からスタートしていることもあり、production codeに関するブロッカーは1つもなかった。

ちなみにReact Admin 3.10.4を使っているがReact AdminやMaterial UI関連で壊れた箇所もなかった。

enzymeのmountが使えない

ただし、テストでenzymemountを使っている箇所すべてでUncaught TypeError: Cannot read property 'child' of undefinedが発生した。

この問題は2020年8月時点から報告されており、2020年12月時点の最新バージョン3.11.0でも解消されていない。自分が見落としていなければ解消の目処も立っていない。

github.com

個人的にはもともとenzymeが好きではないので、mountを利用している箇所をすべてReact Testing Libraryで書き直した。diffは700行程度で済んだのでまぁ良かった。

現状そのプロジェクトには2つのtesting libraryが存在していてちょっとややこしい感じにはなっているが、shallowを使っている一部のテストを一掃できれば完全移行できるので目を瞑っている。

Reactや周辺のアップグレードのブロッカーであるenzyme*1を放置し続けるほうがより大きな問題になると判断した。

New JSX Transform

reactjs.org

ReactのアップグレードとTypeScriptのアップグレード (v4.1以上) とタイミングがずれてしまったのでまだ対応してない...。


これはohbarye Advent Calendar 2020の4日目の記事。

*1:ここ数年、Reactのminor upgradeのたびに何かしら壊れている気がする

2020年の健康と体重と運動

ohbarye Advent Calendar 2020の3日目の記事です。


体重推移

  • 最大値: 69.0kg
  • 最小値: 64.0kg

f:id:ohbarye:20201229154559p:plain
"波形-バイオリズム-"

1~2月

週5で出社し、ランチはほぼ毎食外食していたので米をそれなりに摂取していた。 夕食はたいていサラダ。

通勤で歩いていたのと、オフィスでたまに懸垂する程度の運動。

結果は微増だが継続していたら2017年ぶりに70.0kgまで昇っていたかもしれない。

3~8月

ほぼ毎日自宅におり、自発的な運動ゼロ。

半年で体重が5.0kg落ちたが筋肉量が減っただけのような気がする。

外食をしなくなったので米の摂取量がかなり減少した。

9月~12月

引き続きほぼ毎日自宅にいるが、運動習慣を復活させた。

週2~4のペースで15分のランニングと合わせて以下を行っている。

半年ぶりの懸垂でだいぶ衰えを感じたが背中の使い方を思い出せた。

また、週4~7のペースで腹筋ローラーを続けている。膝をついた状態で上体を伸ばし切るところまでできるようになった。腹筋は成長が早い。

いずれも回数はあまり気にせず疲労を感じる程度まで。追い込むほどではないので筋肉痛になったりならなかったりする。

10月から蒸した温野菜 or 鍋を主食にしており、週4~6日は野菜350g/日取れていると思う。

野菜中心なので筋肥大のためのカロリーが足りてない気がするが体重が増えているので継続する。

総括

今年も炊飯器抜きの生活ができた。しかし、糖質を抑えていても運動が不足していると、体重減少は達成できても"健康"からは遠ざかっている感じがしたので運動習慣を再開できてよかった。

また、今年のMVPは家で眠らせていた蒸し器(フードスチーマー)で、これを発掘したおかげで野菜生活を取り戻せた。

任意の野菜を突っ込むだけで温野菜ができる。温野菜の味はオリーブオイルと塩だけでも十分だが、コロナ禍でブームとなったレトルトカレーやスパゲティソースを温野菜に絡めることで毎日異なる味を楽しめる。タンパク質はサラダチキンやゆで卵やローストビーフや納豆等を中心に摂取する。

そういえば外出も少なくコロナ対策も一通りやっていたおかげか、一度も風邪を引かなかったのも良い話。


振り返ってみて概ね良い感じに進捗しているかと思ったが毎年恒例の健康診断を受診し忘れていたことに気づいた…。

TWINBIRD フードスチーマー ホワイト SP-4137W

TWINBIRD フードスチーマー ホワイト SP-4137W

  • 発売日: 2010/03/25
  • メディア: ホーム&キッチン

複数のDocker Compose YAMLをマージして1つにする

複数のdocker-compose.ymlをマージして1つのYAMLにする方法です。


複数のアプリケーションが協調して動くようなシステム*1を開発していて、各レポジトリにdocker-compose.ymlが存在している状況を想定します。

ローカルで複数アプリを協調して稼働させるにはアプリケーションAのrepositoryに移動してdocker-compose upし、次はBに移動して…というのをやっていたのですが、どうせdocker-composeするのならレポジトリ構成に依存せず必要なアプリケーション全てに対してまとめて操作したいのでYAMLを統合してまとめて管理できるようにしました。

操作とはup, down, buildを始めとした諸々です。

docker-composeの関連知識

以下の知識を利用します。

  • docker-composeには環境変数COMPOSE_FILEで複数のファイルを渡すことができ、YAMLの内容はマージされる
    • docker-compose -f <filename>でもできる
  • docker-compose configコマンドでcompose fileを検証、評価できる
    • マージ結果を確認できる
COMPOSE_FILE=./auth_api.yml:./payment_api.yml:./payment_frontend.yml \
  docker-compose config > ./docker-compose.yml

複数ファイル指定時のマージは一番最初に指定したファイルに対して後続のファイルの設定をマージしていく挙動になります。

  • キーが無い場合:追加
  • キーがある場合:後で指定したファイルの内容で上書き
  • リストの場合:リストの要素を追加

主に、基底となるファイルを環境ごとに拡張して使いまわしたいときに使える (docker-compose.local.ymlでローカル用の設定を足すなど)

具体的なやり方

以下のようにrepository群が並んでいるとします。

# ディレクトリ構成
.
└── repos
    ├── auth_api
    │   └── docker-compose.yml
    ├── payment_api
    │   └── docker-compose.yml
    └── payment_frontend
        └── docker-compose.yml

これらを愚直にCOMPOSE_FILE=./repos/auth_api/docker-compose.yml:...と繋げていってもconfigコマンドは成功した風に完了します。

しかし、YAMLファイル内に記述した相対パスが各repositoryのルート基準ではなくCOMPOSE_FILE envに渡した最初のファイルのパス基準で評価されてしまいます。

具体例として、repos/payment_api/docker-compose.yml内に以下のような相対パスを書いていたとします。

services:
  payment_api:
    build: .

ここでCOMPOSE_FILE=./repos/auth_api/docker-compose.yml:./repos/payment_api/docker-compose.ymlというコマンドを実行すると、この相対パスは意図しないディレクトリとして評価されてしまいます。

services:
  payment_api:
    build:
      context: /absolute/path/to/repos/auth_api

これを避けるためには最終形態の特大YAMLを生成する前に各repositoryのYAMLを個別に評価し、YAML内の相対パス絶対パス表現に変換する必要があります。パスを予め静的に決定することはできないので動的に生成します。

そのためにこんなRubyスクリプトを書きました。

#!/usr/bin/env ruby

require 'yaml'
require 'pathname'
require 'fileutils'

root_dir = Pathname.new(__dir__)
repos = %w[
  auth_api
  payment_api
  payment_frontend
].map {|r| root_dir.join('repos', r) }

FileUtils.mkdir_p "tmp/config"

tmp_config_filenames = repos.each_with_object([]) do |repo, memo|
  compose_path = repo.join('docker-compose.yml')
  if compose_path.exist?
    memo << root_dir.join("tmp/config/#{repo.basename}.yml").tap do |tmp_config_filename|
      system "COMPOSE_FILE=#{compose_path} docker-compose config > #{tmp_config_filename}"
    end
  end
end

system "COMPOSE_FILE=#{tmp_config_filenames.join(':')} docker-compose config > ./docker-compose.yml"

見た通り、各repositoryのYAMLdocker-compose configで評価したものを一時ディレクトリに吐き出します。ここで生成されたYAMLたちの中ではパスはすべて絶対パスに変換されています。

この一時ディレクトリのファイルをCOMPOSE_FILEに与え、再度docker-compose configを実行するとパス問題が解決された大統一YAMLが生成されます。

場合によってはnetworksの調整やservice nameやport衝突やらが発生したりすると思いますが、各repositoryのYAMLを修正したり大統一YAMLをちょっと修正するようにスクリプトを直せばなんとかなります。なる。

これで勝利です。レポジトリが別れていてもすべてのserviceをまとめてdocker-composeで操作することができます。

補遺

docker-compose実行時のファイルを指定したエイリアスを登録しておくと各レポジトリ内で操作するときも大統一YAMLを参照できて便利です。

alias xc='docker-compose -f /Users/ohbarye/path/to/unified/docker-compose.yml'

また、対象のレポジトリ群を管理するレポジトリを作っておくと上述のようなスクリプトを置いたり、全レポジトリをまたぐ横断的な"なにか"をするときに非常に便利です*2

最近の事例だと、共通のGitHub Actionsをまとめて突っ込んだり、master branchをまとめてmain branchに変更するときに役立ちました。


ohbarye Advent Calendar 2020の2日目の記事でした。

*1:マイクロサービスかどうかは問わない

*2:この発想は、@mtsmfmが中心になって作ってくれたQallという仕組みにインスパイアされています。現在はQuipper社はmonorepoなので事情がupdateされています。

sentry-ravenでエラー通知するとrack envの中身が書き換わることがある

エラー検知・監視ツールであるところのSentryが提供するRubySDKsentry-ravenというgemがあります。

このgemを利用するとごくわずかなコードの記述をするだけでSentryに対してイベントを送信することができます。イベントにはユーザーが定義したカスタムタグや実行環境を含む多様な情報を含めることができ大変便利です。

begin
  some_dangerous_code!
rescue => ex
  Raven.capture_exception(ex)
end

# とか

if record.unknown?
  Raven.capture_message("Found suspisious data")
end

上述のようなコードを見た/書いた方も多いと思います。しかしながら特定条件下では上記のような Raven.capture_*メソッドの呼び出しの内部でRack envの一部を書き換えてしまう ことがあります。

なお、本件が意図した挙動なのか不明だったので本体にレポートしまして、トリアージ待ちです。本件の行く末が気になる方はissueをウォッチしていただければと思います。

github.com

また、sentry-ravenはすでにアクティブな開発が停止しており、sentry-rubysentry-railsに移行するようアナウンスがされています。僕が遭遇したプロジェクトでもこの問題による不具合を契機としてmigrationを実施しています。

Migration Guide for Ruby | Sentry Documentation

環境

  • rack 2.1.1
  • sentry-raven 3.1.1

事象の説明

以下の条件をすべて満たすときRack envのenv["rack.request.form_hash"]のHashのうち、2で該当したkeyのvalueがマスク用の文字列 ("********") に破壊的に書き換えられます。

  1. Rack::Request#form_data?trueとなる
    • Content-Type"application/x-www-form-urlencoded""multipart/form-data"であるとき
    • 画像アップロードでハマった
  2. リクエストのform dataのkeyが以下のいずれかに一致・マッチする
Raven.configure do |config|
  config.sanitize_fields = %w[quite_sensitive_field]
end

後継のsentry-rubyでは起きない?

はい。

sentry-ravenが持つdata sanitization等を行う機構をprocessorと呼ぶのですが、この機構は後継のsentry-rubyや周辺のgemには存在しません。ユーザーが自前でhookを書いたり、SentryのGUIからfilter ruleを登録するようになっています。

何が問題か

Raven.capture_*に副作用がないと思っていると痛い目を見る…。

具体的な例を挙げます。

RailsActionParameterインスタンスを作るより手前のRack middlewareでRaven.capture_*を呼び出した場合、paramsの中身の一部が"********"になり、クライアントから送信されたform dataを参照できません。

もしくはRailsのcontrollerやmodelでRaven.capture_*を呼び出し、そのあとでenv["rack.request.form_hash"]を見るようなことがあれば同様にマスクされた文字列になっています。

ActionParameterインスタンスが保持するform dataはコピーされたものなのでRailsアプリケーション内でparamsを見るふつうの(?)コードを書いていれば途中でRavenを使っても問題ないでしょう。

発見の経緯

そんなことするか?って思いますかね。まぁたしかにenvを直接触ることはほぼ無いと思いますが、rack middlewareのどこかでうっかりSentryに通知することはあるんじゃないでしょうか。というか、あった。

僕がハマったパターンは「committeeCommittee::Middleware::RequestValidation middlewareでOpenAPI定義に違反するリクエストが来たらSentryに記録する(けど後続の処理はそのまま行う)」という使い方でした。

最初はユーザーが不正なリクエストをして400になっただけと思ったけど、開発環境でも事象が再現すること、および僕が変更したOpenAPIの定義をrevertすると再現しないことを同僚が発見してくれたのでバグだと気づくことができました。

sentry-ravenの実装の詳細

Rack::Request#form_data?trueのときRaven::RackRack::Request#POSTを呼び出し、書き換える対象のHashへの参照を得ています。

 # https://github.com/getsentry/sentry-ruby/blob/82e1ffe711af287ddc23e8517bdb8275beff94d5/sentry-raven/lib/raven/integrations/rack.rb#L87-L89
 def read_data_from(request) 
   if request.form_data? 
     request.POST 

後のRaven::Event#to_hashというメソッドで実行環境の情報をHash化します。このときも同一のポインタをそのまま渡しています。

# https://github.com/getsentry/sentry-ruby/blob/82e1ffe711af287ddc23e8517bdb8275beff94d5/sentry-raven/lib/raven/client.rb#L74-L75
def encode(event) 
   hash = @processors.reduce(event.to_hash) { |a, e| e.process(a) } 

そしてRaven::Processor::SanitizeDataというデータマスクを行うclassにそのHashが渡るのですが、その中でHash#merge!を実行して破壊的に更新をしています。

# https://github.com/getsentry/sentry-ruby/blob/82e1ffe711af287ddc23e8517bdb8275beff94d5/sentry-raven/lib/raven/processor/sanitizedata.rb#L44-L50
 def sanitize_hash_value(key, value) 
   if key =~ sensitive_fields 
     STRING_MASK 
   elsif value.frozen? 
     value.merge(value) { |k, v| process v, k } 
   else 
     value.merge!(value) { |k, v| process v, k } 

デバグ

さらっと書きましたが最初は「OpenAPI定義を変更したらリクエストパラメータがマスクされるようになった」ように見えたのでわけがわかりませんでした。

Raven configのsanitize_fieldを変更すると再現しない、Sentryへの通知をやめると再現しない、といったあたりまで同僚が絞り込んでくれたので、僕の方ではsentry-ravenのコードを読みながらステップ実行していってようやく原因にあたりがつきました。

最小の再現プロジェクトを作る

100%再現するバグなら勝利は約束されるので、最小の構成で再現するプロジェクトを作ってみました。

github.com

Raven::Rackをかませたrack appを作り、その中でRaven.capture_*を呼ぶ。その前後でrack envの中身が変わっていることを確認する。 味の秘密は、それだけ。

require 'rack'
require 'sentry-raven'

Raven.configure do |config|
  config.dsn = ENV['SENTRY_DSN']
  config.sanitize_fields = %w[name]
end

class SentryRavenSanitizeDemo
  def call(env)
    r = Rack::Request.new(env)

    puts "=========Before========="
    puts r.POST
    puts env[Rack::RACK_REQUEST_FORM_HASH]
    before = env[Rack::RACK_REQUEST_FORM_HASH].dup

    begin
      raise "foo"
    rescue => ex
      Raven.capture_exception(ex)
    end

    puts "=========After========="
    puts r.POST
    puts env[Rack::RACK_REQUEST_FORM_HASH]
    after = env[Rack::RACK_REQUEST_FORM_HASH].dup

    [200, {"Content-Type" => "text/plain"}, ["before: #{before}, after: #{after}"]]
  end
end

use Raven::Rack
run SentryRavenSanitizeDemo.new

rackupで起動し、curlでform dataをPOSTしてみると…

$ curl -X POST http://localhost:9292 -F 'name=foo' -F 'age=32'

before: {"name"=>"foo", "age"=>"32"}, after: {"name"=>"********", "age"=>"32"}%

BINGOです。sanitize_fieldをいじったりRaven.capture_exception(ex)を呼ばなければ再現しなくなることもすぐに確認できました。

結論

  • 特定条件下では上記のような Raven.capture_*メソッドの呼び出しの内部でRack envの一部を書き換えてしまう ことがある
  • sentry-ravenはすでにアクティブな開発が停止しているのでsentry-rubysentry-railsに移行しよう

ohbarye Advent Calendar 2020の1日目は@ohbaryeが担当しました。