Gemfile.lockをもとに特定のバージョンのRubyで利用できないgemの一覧を出す

Railsアプリなど、Bundlerでgemの依存管理をしているプロジェクトでRubyのアップデートをしようとするとき、不幸なことにgemが全体的に古いことがある。

また、gemspecでrequired_ruby_versionというメタデータでRubyのバージョンの範囲を設定していると、その範囲のRubyの環境下でしかそのgemはインストールできない。よくある例として、次のようにRuby 2系でしか使えないようにしているものがある。

Gem::Specification.new do |spec|
  spec.required_ruby_version = "~> 2.0"
end

このような状況下でRubyだけアップデートするとgemをインストールできないことがある。

こうなると、まずgemをアップデートしていく必要がある。この作業の範囲がどれぐらいか把握するために、Gemfile.lockに載っているgemのうち、特定のバージョンのRubyだとrequired_ruby_versionを満たせないgemの一覧を出せるようなスクリプトを書いた。

# unusable_gems.rb

require 'bundler'

def resolve_gemspec_path(spec)
  # nokogiriはarchとosをgemspecのファイル名に持つ場合がある
  if spec.name == 'nokogiri'
    native_gem_path = Bundler.specs_path.join("#{spec.name}-#{spec.version}-#{Gem::Platform.local.cpu}-#{Gem::Platform.local.os}.gemspec")
    return native_gem_path if native_gem_path.exist?
  end

  case spec.source
  when Bundler::Source::Rubygems
    # default gem
    default_gem_path = Pathname.new(Gem.default_specifications_dir).join("#{spec.name}-#{spec.version}.gemspec")
    return default_gem_path if default_gem_path.exist?

    # ふつうのgem
    Bundler.specs_path.join("#{spec.name}-#{spec.version}.gemspec")
  when Bundler::Source::Git
    # git sourceからインストールしたgem
    Bundler.bundle_path.join("#{spec.source.install_path}", "#{spec.name}.gemspec")
  else
    warn "failed to resolve: #{spec.inspect}"
    nil
  end
end

gemspec_paths = Bundler::LockfileParser.new(Bundler.default_lockfile.read).specs.map { |spec|
  path = resolve_gemspec_path(spec)

  unless path&.exist?
    warn "gemspec not found: #{path}"
    next nil
  end

  path
}.compact

gemspecs = gemspec_paths.map { |path| Gem::Specification.load(path.to_s) }

checked_ruby_version = Gem::Version.create(ARGV[0])
puts gemspecs.reject { |gemspec| gemspec.required_ruby_version.satisfied_by?(checked_ruby_version) }.map { |gemspec| "#{gemspec.name} (#{gemspec.version})" }

例として、まず次のGemfileでbundle installしておく。

source "https://rubygems.org"

gem "activerecord", "7.0.4.2" # required_ruby_version = ">= 2.7.0"

Gemfile.lockが存在するディレクトリで次のように実行する。かなり古いバージョン2.6.0、2.7.0を例として使う。

$ ruby unusable_gems.rb 2.6.0
activemodel (7.0.4.2)
activerecord (7.0.4.2)
activesupport (7.0.4.2)
nokogiri (1.14.1)
oauth (1.1.0)
oauth-tty (1.0.5)

$ ruby unusable_gems.rb 2.7.0
# 2.7.0だと使えないgemがないので結果は空

たとえば間接依存のoauth 1.1.0も https://github.com/oauth-xx/oauth-ruby/blob/v1.1.0/oauth.gemspec#L36 を見るとわかるように2.7.0以上の制限がある。

注意点としては、今回必要だったユースケースではgemのソースの種類としてパス (Bundler::Source::Path) などはなかったので入れていない。また、nokogiriのような特殊なパターンもまだあるかもしれない。

TerraformでRoute 53のゾーンに付属するNS/SOAレコードをインポートする

TerraformでRoute 53の設定をインポートするときに、aws_route53_recordのドキュメントを読んでいた。このドキュメントにはRoute 53のゾーンを作るときに自動で作られるNSレコードとSOAレコードの管理方法について記述がある。しかし、コード例にはNSレコードについての記載しかない。

https://registry.terraform.io/providers/hashicorp/aws/4.53.0/docs/resources/route53_record#ns-and-soa-record-management

また、

Enabling the allow_overwrite argument will allow managing these records in a single Terraform run without the requirement for terraform import

とあるが、ゾーン作成時に一緒に作成されたNS/SOAレコードをインポートしたいだけのときは合わないユースケースに見えた(自分が把握できていないユースケースがあるのかもしれない)。

これらのNS/SOAレコードをTerraformで管理するには、あらかじめaws_route53_record.example_nsaws_route53_record.example_soa(名前は例)をインポートする。

$ terraform import aws_route53_record.example_ns XXXXXXXXXXXXX_example.com_NS
$ terraform import aws_route53_record.example_soa XXXXXXXXXXXXX_example.com_SOA

そのうえで、リソースを次のように定義して、terraform planで差分が出ないようにする。

resource "aws_route53_zone" "example" {
  name = "example.com"
}

resource "aws_route53_record" "example_ns" {
  zone_id = aws_route53_zone.example.id
  name    = "example.com"
  records = [
    "${aws_route53_zone.example.name_servers[0]}.",
    "${aws_route53_zone.example.name_servers[1]}.",
    "${aws_route53_zone.example.name_servers[2]}.",
    "${aws_route53_zone.example.name_servers[3]}.",
  ]
  ttl  = 86400
  type = "NS"
}

resource "aws_route53_record" "example_soa" {
  zone_id = aws_route53_zone.example.id
  name    = "example.com"
  records = [
    "${aws_route53_zone.example.primary_name_server}. awsdns-hostmaster.amazom.com 1 7200 900 1209600 86400"
  ]
  ttl  = 900
  type = "SOA"
}

GitHub Actionsで"Files changed"のファイルを取得する

GitHub ActionsでPRの"Files changed"タブと同じファイルの内容を取得する方法*1

- uses: actions/checkout@v3
  with:
    # マージベースの探索でコミットをさかのぼるために全コミットを取得しておく
    fetch-depth: 0
- run: |
    # ... でPRのFiles changedと同じ差分
    git diff origin/main...HEAD

    # ファイル名のリストだけ
    git diff --name-only origin/main...HEAD

    # 必要に応じて追加、変更したファイルだけなど
    git diff --diff-filter=AM origin/main...HEAD

やっていることは次と同じ。

# fast-forwardでない可能性があるのでマージベースを見つけておく
merge_base_sha=$(git merge-base origin/main HEAD)

git diff $merge_base_sha HEAD

たぶん実用的にはchanged-filesアクションを使えばよいが、とりあえずインラインで書きたい、なんらかの理由で外部アクションに依存したくないという時用。

*1:2022-11-23に ... を使う方法を追記

できるだけコントローラではなくモデルで例外処理する

問題: コントローラで例外処理している

アプリケーションが扱うドメイン特有のエラーを例外として表現する場合に、その例外をコントローラで処理するコードを書くと、ほとんどの場合でコードが読みにくくなったり、コードを変更しづらくなったりする結果となる。そして、開発の効率が落ちたり不具合を作りやすくなったりする。

具体的な問題は次のとおり。

コントローラのアクションの凝集度が下がる

コントローラのアクションに、たとえば複数の決済方法のエラーのような微妙に異なる種類の例外に対する処理を書くと、コントローラが肥大化し、凝集度が下がる。結果として、エラーが関わる変更を入れるときに、本来ビジネスロジックだけの変更であっても、コントローラとモデルの両方を必ず変更しなければならなくなる。また、関係の薄いさまざまな例外が1箇所で処理されることになり、コードが読みにくくなる。

class ChargesController < ApplicationController
  def create
    # ...

  # いろいろな例外処理
  rescue FooPay::Error
    # ...

  rescue BarPay::Error
    # ...

  rescue BazPay::Error
    # ...

  # 以降同じような節が続く
  end
end

さらに、こういうコードは「ここで例外処理しているから今回のも追加しておくか」という心理を誘発しやすい(割れ窓)。持続的なWebアプリケーション開発は割れ窓との戦いである。

テストの負担が増える

コントローラ層を対象としてテストする場合、いちいち結合テスト(近年のRailsだとintegration testまたはrequest spec)を書く必要がある。プレーンなクラスやいわゆるモデルなどと比較するとテストの準備が大変になるし、テストの実行速度も落ちる。また、異常系はとくにだが、しばしばリクエストやコンテキストを含めた複雑な事前状態を作る必要があり、読みにくいテストになる。

テストがしづらいところにがんばってテストを書くことはできるが、それは問題解決になっていない。

原因

そもそもコントローラはHTTPのことをやるところだが、それ以上の責務を負わせているのが上述した問題の主な原因といえる。

コントローラは、外から来るリクエストを受け取って、レスポンスを作って返すというのが本来の責務。HTTPに基づいてパラメータやヘッダのパース、Cookieの管理、レスポンスの生成などを実行する。

つまり、コントローラはレイヤードアーキテクチャのUI層やプレゼンテーション層にあたる。これらの責務やレイヤーには、アプリケーションの中核となるビジネスロジック(アプリケーションが解決する問題領域に固有のロジック)は含まれない。

一方、ビジネスロジックはモデルで扱う。これはレイヤードアーキテクチャで言うとドメイン層やモデル層と呼ばれるところ。ビジネスロジックを実行中に処理を継続できなくなったら発生させたいエラーは本来ビジネスロジックとして処理するべきだが、このエラーがモデルを外から利用しているコントローラにまで侵食してくると、上述したような問題が発生する。

解決策

本当に例外を使うべきか考える

ビジネスロジックにおけるエラーは「例外」ではないはず、とまず考えたい。たとえば、ユーザーが変な値を送信してきたときにそれを不正としてエラーを返すのは、例外的ではなく想定内のはず。別の側面だと、例外は制御構造から大域脱出するので、多用するとコードを読むときの負荷を高めうる。

例外の定義の1つを引いてくると『オブジェクト指向入門 第2版 設計・コンセプト』の12章に「例外とはルーチンコールの失敗を引き起こす可能性のある実行時イベントである」とある。つまり、例外とは、ルーチン(メソッド)の実行時に最後まで期待どおり実行できない可能性がある事態の発生を表す*1

どのようなエラーを例外として表現すべきか。技術的な例外としては外部サービスとの通信の失敗や、NoMethodErrorなどの実行時エラーがある。また、ビジネスロジックの例外としては、データとビジネスルールのあいだの不整合などで一度開始したトランザクションを続けられない状態になったとき発生させる例外が考えられる。これらのケースを除くと、本当に例外が必要ではないケースもそれなりにある。

そこで、エラーをできるだけ例外ではない方法で表現できないかを考える。たとえば、ビジネスロジック実行前のデータの検証失敗時に例外を起こすのではなく、検証の実行結果を取得できるような設計にする。これは、RailsだとActiveModel::Validationをうまく使い、入力値に関するエラーは戻り値、またモデルやフォームオブジェクトの属性として取得する事になる。

モデル層で適切にビジネスロジックを書く

ビジネスロジックに関するエラーをコントローラで処理するようになることが問題の原因だと書いた。ビジネスロジックに関するエラー処理をモデル層でやるためには、RailsのActive Recordのモデル(つまり、アクティブレコードパターンに基づいたDBのテーブルに対応しているクラス)だけではなく、必要に応じてふつうのクラスを作るようにする。そして、そのなかでエラー処理をやる。Railsならプレーンなクラスでもいいし、コントローラやビューで使えるようにするならActive Model (ActiveModel::Model)を使ってもよい。

このようなクラスをうまく作るためには、リソースだけではなくアプリケーションにおけるイベントもモデルとして抽出したり、ユースケースを見出してクラスにする必要がある。この話題については、非常にわかりやすくまとまっている次の文献を参照されたい。

トランザクションを張る場合は、例外を発生させることでトランザクションをロールバックする形になる。このケースにも対応するための案としては、次のようにビジネスロジックの一部としてロールバックと例外処理を実行し、このモデルを使う側からは実行結果だけが取得できるよう方法がある。これができると、コントローラは薄くなり、scaffoldしたときの構造に近くなる。

class Foo < ApplicationRecord
  # ...

  def execute_some_business_logic
    transaction do
      foobar!
      save!
    end

    true

  rescue ActiveRecord::RecordInvalid
    false

  rescue Foo::SomeError
    # 特有のビジネス例外としてなにか処理する

    false
  end
end

class FoosController < ApplicationController
  def update
    @foo = Foo.find(params[:id])

    if @foo.execute_some_business_logic
      # ...
    else
      # ...
    end
  end
end

DBと通信できないなどの技術的な例外はそもそもプログラムの実行を続けられないはずなので、ここで処理せず呼び出し元まで例外を突き抜けさせるのがよい。

FAQ: 404、500などのエラーにしたいときはコントローラより上の層で例外処理する必要があるのでは?

利用しているフレームワークにもよるが、404や500などの一般的、技術的エラーはアプリケーション共通の処理を定義していることが多いので、そういうケースで例外を発生させるのは問題なさそうに思う。たとえば、PUT /foos/:foo_id/bars/:bar_idのようにパスパラメータとしてIDが入るリソース設計にすると、

  • foobarは最初にコントローラでDBから探す
  • それらのレコードが見つかったら、そこからビジネスロジックに処理を依頼する

という流れになる。このときは、foobarがなければ、コントローラで例外処理をすることになる。

class BarsController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound do
    # ...
  end

  def update
    foo = Foo.find(params[:foo_id])
    @bar = foo.bars.find(params[:bar_id])

    if @bar.update(bar_params)
      # ...
    end
  end
end

*1:契約によるプログラミングの言葉を使うと、呼び出されたルーチンが事後条件を満足できないときに発生するといえる cf. https://www.slideshare.net/t_wada/exception-design-by-contract

Macのセットアップでやること2022

新しくMacBookをセットアップする機会があった。開発環境はdotfilesである程度作れるようにしているが、macOSについてはいつもの設定を覚えておくしかない状態だったのでメモを残しておく。書いているのはデフォルトから変更している箇所だけ。

後述するがOSの言語を英語に設定しているので設定名も英語で書く。

UI

  • Appearance
    • Auto
      • 時間帯に応じてライトモード・ダークモードを切り替える
  • Dock
    • 書くのが難しいのでスクショ
      • 自動で隠す
  • Night Shift
    • Sunset to Sunrise
      • 夜は画面の色温度を下げる
  • Preferred languages
    • English、日本語の順番。英語の練習

入力

Trackpad

  • Tracking speed
    • Fast
  • Click
    • Light
  • Swipe between pages
    • three fingers
  • Swipe between full-screen app
    • four fingers

Keyboard

  • Key repeat
    • Fast
  • Delay until repeat
    • Short
  • Modifier Keys
    • Caps LockとControlの入れ替え

IME

  • Candidate window
    • Font: Hiragino Sans W5(ヒラギノ角ゴW5)
    • Font Size: 16
  • "¥" key generates
    • \ (Backslash)
  • "Full-width numeral characters"を無効化
  • "Use the Caps Lock key to switch to and from ABC"を有効化
    • 日本語と英語の切り替えをCaps Lockに割り当てる
    • "Modifier Keys"でCaps LockとControlを入れ替えているので、Magic Keyboard上のControlキーで日本語と英語を切り替えることになる

ウインドウ管理

画面1枚で仕事するので、次の記事の手法を参考にしている。

macOSでディスプレイ1枚で作業する技術 - Qiita

  • KeyboardのShotcutsタブ
    • Mission Controlの"Switch to desktop 1/2/3"に123を割り当て
    • Keyboardの"Move focus to next window"に⌥⇥(Option+タブ)を割り当て
      • 同じアプリの複数ウインドウを⌘⇥と似た操作感で切り替えられる
  • Mission Control
    • "automatically rearrange"を無効化

ウインドウの割り当てかたは次のとおり

  • ブラウザ、ターミナル、エディタをそれぞれdesktop 1、2、3に割り当て
  • Slack、Books.appなどは"All Desktops"に割り当て
    • どの画面に切り替えても出現する

認証

  • Touch ID
    • いまのところ右手人差し指だけ
    • 1PasswordでもTouch IDを使うように設定
  • ブラウザ組み込みのパスワード機能を無効化
    • 1Passwordに一本化

OSその他

  • Battery
    • Power Adapterで"prevent your Mac from sleeping while the display is off"を有効化
  • Date and time
    • "set time zone automatically"を有効化
  • Activity monitor
    • DockアイコンをCPU usageに変更

開発

dotfilesのセットアップスクリプト実行で自動化できていないものがある。

  • SSHの設定
  • GPGの設定
  • VS Codeのワークスペースの移行
  • シェルのヒストリーの移行
    • .local/share/fish/fish_historyにある
  • Terminal.appの設定
    • デフォルトシェルをfishに設定
  • フォントのインストール

所感

従来、入力ソースの切り替えのためだけにKarabiner Elementsを使っていた(左Commandキーで英語、右Commandキーで日本語に切り替え)。しかし、ここまで来たらKarabiner Elements依存をなくせるのではないかと思い、OSの機能だけを使ってControlキーで入力ソースをトグルする運用に切り替えてみた。

両方のCommandキーをそれぞれ英語と日本語に割り当てると、入力ソースの状態を意識する必要がなくなるという利点がある。一方でCommandキーはキーバインドで多用されるので、文字を入力しながら入力ソースを切り替えるつもりが、誤ってキーバインドを発動させてしまうという問題がよく起きていた。

まだControlキーの運用は全然しっくりきてないがしばらく使ってみる。