Ruby 2.7, 3.0, 3.1でもデータクラス (Data.define) が使えるようなライブラリdata_class_factoryを作った

まえおき

Ruby 3.2のデータクラスっていうやつがとてもハイセンス。実装した人自身が、dev.toでとても自慢げに記事を書いている。

dev.to

大昔に自分で初めて作ったライブラリが奇しくもデータクラスだったので、これは見逃せない。

yusukeiwaki.hatenablog.com

Ruby 3.2のデータクラスは、Structから機能を落とした版で、おそらく多くのRubyistたちが望んだものだろう。しかしながら当然のこと、これはRuby 3.1以前のバージョンでは使えない。

いっぽうで、そこそこ大きめのRailsアプリケーションを開発してると、Rubyのバージョンはそんなにホイホイ上げられるものではないので、Ruby 3.0や3.1止まりの会社も今はまだ多かろう。そんなわけで、kt_data_classのエッセンスは取り入れつつ、バックポートを目的としたライブラリを作ることにした。

1日で作りたい

kt_data_classを初めて作ったときは、たぶん1週間くらいかけて作ってたのだけど、今回のデータクラスのバックポートは、なる早で使いたい。

目標として「1日で仕上げる」ことを気に留めながら開発を進めた。

テスト駆動だが、今回はMinitest

スピード開発なので、迷わずテスト駆動で開発をすすめることにした。

kt_data_classのときは bundle gemのデフォルト値に従うままでRSpecでテストを書いていたが、Minitestの並列実行の速さに慣れてしまうとRSpecはとっても遅い。今回は速いMinitestを採用。

あと、bundle gemのデフォルトの構成だとtest_helperとか色々作られるが、よりシンプルな構成に変更。

Rakefileはこんなかんじ。 rake 単体でテストが流れるのは若干キモイので、 rake test でテストが流れるだけの設定にダイエット。

require 'bundler/gem_tasks'
require 'rake/testtask'

Rake::TestTask.new(:test) do |t|
  t.libs << 'test'
  t.libs << 'lib'
  t.test_files = FileList['test/**/*_test.rb']
end

個々のテストでは、test_helperを使わず、↓こんな感じのをテンプレートとしてこぴぺして使った。

require 'minitest/autorun'
require 'data_class_factory'

class XXXTest < Minitest::Test
  def test_zzzzzz

ここまでやることで、「テストファイル1つを見れば、単体テスト内容がすべてわかる」という状態にできる。あっちこっち見て回らなくて住むのでスピーディーにテストが書ける。

リファレンスとRuby本体の単体テストを大いに参考にした

Dataクラスについては、Rubyのリファレンスがとても充実している。

docs.ruby-lang.org

あとは、RubyにDataクラスが追加される際に書かれた単体試験(Add Data class implementation: Simple immutable value object by zverok · Pull Request #6353 · ruby/ruby · GitHub)も、ほぼそのまま流用することで、Dataクラスがどう振る舞うべきか、Ruby 3.2のDataクラスとの乖離が少ない形で実装が進められる。

dup, cloneと、Marshal.dump/loadの対応

Ruby本体の単体試験には以下のようなコードがある。

  def test_dup
    klass = Data.define(:foo, :bar)
    test = klass.new(foo: 1, bar: 2)
    assert_equal(klass.new(foo: 1, bar: 2), test.dup)
    assert_predicate(test.dup, :frozen?)
  end

  Klass = Data.define(:foo, :bar)

  def test_marshal
    test = Klass.new(foo: 1, bar: 2)
    loaded = Marshal.load(Marshal.dump(test))
    assert_equal(test, loaded)
    assert_not_same(test, loaded)
    assert_predicate(loaded, :frozen?)
  end

https://github.com/ruby/ruby/blob/v3_2_0/test/ruby/test_data.rb

シャローコピー(dupやclone)してもディープコピー(Marshal.dump/load)しても、freezeされた状態であれ、というもの。

class Data
  def initialize(**kwargs)
     ...いろいろ

     freeze
  end

こんな感じでinitializeの末尾でfreezeしておけばいいのだと思っていたが、これではdupやclone, Marshal.dump/loadいずれの操作時にも、freezeされていないインスタンスが生成されてしまった。

結論から言うと

  • dupやcloneでは #initialize_copy のオーバーライドが必要
  • Marshal.loadでは #marshal_dump #marshal_load のオーバーライドが必要
    • marshal_loadだけをオーバーライドしても動かない。両方オーバーライドが必要。

ということだった。kt_data_classのときはここまで考慮してなかったので、今回とても勉強になった。

github.com

.newの引数はキーワード引数だけにする

Structを踏襲してなのか、Dataクラスも「キーワード引数でもキーワード引数じゃなくてもいい」という仕様が健在だ。

Point = Data.define(:x, :y)

Point.new(3, 4) # OK
Point.new(x: 3, y: 4) # OK

ただ、これは完全に個人的意見だが、キーワード引数だけで十分だと思うし、そのほうが呼び出し側だけ読んで引数の意味がわかりやすくて良いと思う。

Feature #16122: Data: simple immutable value object - Ruby master - Ruby Issue Tracking Systemのディスカッションを見るに、どうもパターンマッチングとの整合性や、単純に書く量多くね?みたいな流れでキーワードじゃない引数もサポートするようにしたようだ。 また、New in Ruby 3.2 - Data.define - DEV Communityにも書かれているように、キーワードじゃない引数をサポートするための実装として、initializeではなく .newのほうで少々工夫が凝らされている。

もしキーワード引数だけの対応であれば、そんな工夫は一切いらない。

別にそれでよくね?

というわけで、バックポートDataクラスでは、一旦キーワード引数だけをサポートする形としてみた。

やっぱりpositional argumentもサポートしたほうが便利だなってなったら、今後追加するかもしれない。

で、一日でできたの?

思い立ったのが2023/3/4の10:53で

仕事終わりの夜に実装を進めて、データクラスが出来上がったのは 3/6の2:31 github.com

Gemを世に放ったのは3/8の10:28なので、まぁ全然1日では終わっていないw

そうはいっても、kt_data_classのときよりはだいぶスピーディーに開発したとは思う。(自画自賛w

まとめ

思い立って数日で、なんとか形にはした。

rubygems.org

そして、こういう既存機能ポーティングのライブラリ開発は作るたびに勉強になる。

自分で開発しているRuby用のライブラリに型情報を足す

仕事で忙殺され気味のため久々の更新。

playwright-ruby-clientにいきなり「型書け」ってissueが飛んできた。

github.com

個人的にはGitHub Copilotにどっぷり浸かっていて、「Rubyだったらべつに型いらんくね?」って考えなんだけど、playwright-ruby-clientに関してはユーザが触るコードは全部自動生成しているので、コード生成スクリプトにちょろっと追記したら手で型定義書かなくてもRBSも自動生成できるんじゃないか?と試してみた。

どういう状態にしないといけないか

RBSを"使う"側つまり、ライブラリを使ってコードを書く側の話はぐぐるとたくさんでてくるが、Gemを開発している人は何をやればいいのか?というのはそんなに情報がない。

唯一、この記事↓がまさにそれを紹介していた。

blog.kymmt.com

結論から言うと、ライブラリの開発者がやらないといけないことは至極単純で、 sig/your_library.rbs のファイルを同梱するだけ

.
├── CODE_OF_CONDUCT.md
├── Gemfile
├── Gemfile.lock
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
│   ├── console
│   └── setup
├── lib
│   ├── playwright
│   ├── playwright.rb
│   └── playwright_api
├── pkg
├── playwright.gemspec
├── sig ←★これを追加
│   └── playwright.rbs
└── spec

どんな型定義を書かないといけないのか

型定義はいきなり書けと言われて所見で書けるものではない。まずはTypeProfで雛形を作成する。

typeprof lib/playwright_api/*.rb -I lib/playwright -o sig/_playwright.rbs

これだけで割とそれっぽい型定義は作られる。

あとは、 sig/_playwright.rbsを参考に、 sig/playwright.rbs を頑張って作る。

さらにそれをTypeProfに食わせてみて、書いた型定義にミスが無いかを確認する。

typeprof sig/playwright.rbs lib/playwright_api/*.rb -I lib/playwright -o sig/_playwright.rbs 

rbs validateなどもあるが、typeprofだけでも結構いける。

「こんなときどう書けばいいの?」って疑問は、9割がた↓の記事をみて解決できた。pockeさん神記事です。

pocke.hatenablog.com

TypeProfの "unhandled exception" 問題

TypeProfに食わせるrbsファイルに不備があると、 "unhandled exception" っていうエラーが出て2回ハマった。

結論、以下の2つがハマりどころでした。

  • パラメータ名が "type" だとダメ
  • ArrayやHashは Array[untyped] Hash[untyped, untyped] って書かないとダメ

型を書いてみた結果...

RBSをサポートしてるぜって熱弁しているRubyMineをインストールして、対応前後の動きをみてみた。

もともとがこんな感じ。ほとんど何も補完が効かない。これならVSCodeSublime Textで十分、ってかんじ。

対応後はこんな感じ。「コード補完ほしい」「型書け」って言う人達にとってはまぁ嬉しいんだろうなという結果。

でもGitHub Copilotはもっとすごいよ...

型を書く書かないにかかわらず、Copilotはこのレベルで補完をガンガンしてくる↓

  • どちらかというと自分のライブラリのサンプルコードを世の中にバンバン出していくほうが、Copilotによる補完精度が上がって、ユーザにとってはありがたいんでは?
  • まだ人気のないライブラリに限ってはRBS書いたほうがよさそう

という所感に落ち着く。

まとめ

Gemを自分で作ってる人は sig/your_library.rbs を足すだけで「型書け」には答えられる。

蛇足: 「型書け」と言ったからには責任を!

GitHub Copilotにどっぷりな身としては、「なんとなくコードの補完効いてほしいよね〜」くらいのレベルで「型を書け」というのはRubyにおいては正しくないと思う。

Add type signatures (RBS) · Issue #239 · YusukeIwaki/playwright-ruby-client · GitHub

この手のissueを放り投げるのは、考えるきっかけとしてはありがたかったものの、「型書け」って人に言うからにはしっかり議論やフィードバックしてくれ!ってのが本音。

DBを読み取り専用で使うWebアプリケーションを作るならSinatraがお手軽

以前、まだQiitaを愛していた頃に、こんな記事を書いたことがある。

qiita.com

社内向けWebアプリで、機能が限定的だったとしても、SinatraじゃなくてRailsで書いたほうが総合的にラクよ、という話。この考え自体は今もそんなに変わっていなくて、なんだかんだでRailsラクだ。

しかし、最近になって、SinatraでWebアプリケーションを書いてみて「これはうまく行った」という例を1つ見つけた。

不具合解析のアシスタント的なアプリケーションだ。

  • DBはもともとあるものに接続して、そこにある情報を読むだけ。書き込みは行わない。
  • 入力フォームは最低限でいい
  • テストを真面目に書く必要がない

ようするに、手でSQLを叩くよりはマシ、くらいのものを作りたければ割とお手軽ということ。

## application.rb ##
# frozen_string_literal: true

require 'bundler'
Bundler.require :default, (ENV['RACK_ENV'] || :development).to_sym

require_relative './config/activerecord'
require_relative './config/zeitwerk'
require 'sinatra/base'

class Application < Sinatra::Base
  # いろいろ...
end
## config.ru ##
# frozen_string_literal: true

require_relative './application'

run Application
## Gemfile ##
# frozen_string_literal: true

source 'https://rubygems.org'

gem 'mysql2'
gem 'puma'
gem 'rack'
gem 'sinatra', require: false
gem 'sinatra-activerecord'
gem 'zeitwerk'

group :development do
  gem 'pry-byebug'
  gem 'rubocop'
end
## config/zeitwerk.rb ##
# frozen_string_literal: true

loader = Zeitwerk::Loader.new
loader.push_dir('./models')
loader.setup
## config/activerecord.rb ##
# frozen_string_literal: true

# ref: https://hai3.net/blog/active-record-readonly/
module ActiveRecord
  class Base
    def readonly?
      true
    end

    def self.readonly_attributes
      attribute_names
    end

    # Disable annoying STI
    self.inheritance_column = :_xxxxxx
  end
end

ActiveRecord::Base.logger = Logger.new($stdout)
ActiveRecord.verbose_query_logs = true

このくらいの下地を整えてあげれば、modelsにDB参照用に振り切ったモデルを定義して、config/database.ymlをRailsのものをコピってくれば、あとはviewsを必要な分だけ置けばいい。

MVCしたい?

SinatraアプリケーションではModelとViewはあるが、コントローラなんてものはない。

とはいえ、わざわざコントローラ"層"を定義する必要がどこまであるだろう?erbにして、冒頭で必要なパラメータを取ればそれでよくないだろうか?

class Application < Sinatra::Base
  get '/customer_info' do
    erb :"guide/customer_info.html"
  end
end
## views/guide/customer_info.html.erb
<%
  customer_id = params[:customer_id].presence
  customer_name = params[:customer_name].presence

  if customer_id.present?
    @customer = Customer.find(customer_id)
  elsif customer_name.present?
    @customer = Customer.find_by!(name: customer_name)
  end
%>

<form method="GET">
  <p>どれか1こ入れてね。</p>
  <div class="mb-3">
    <label for="input_customer_id" class="form-label">ID</label>
    <input
      id="input_customer_id"
      type="text"
      class="form-control"
      name="customer_id"
      value="<%= customer_id %>" />
  </div>
  <div class="mb-3">
    <label for="input_customer_name" class="form-label">NAME</label>
    <input
      id="input_customer_name"
      type="text"
      class="form-control"
      name="customer_name"
      value="<%= customer_name %>" />
  </div>
  <button type="submit" class="btn btn-primary">Submit</button>
</form>

<% if @customer %>
  <%
    data = [
      ['ID', @customer.id],
      ['NAME', @customer.name],
      ['契約開始日', @customer.contract.started_at],
    ]
  %>
  <table class="table">
    <thead>
      <tr>
        <th scope="col">key</th>
        <th scope="col">value</th>
      </tr>
    </thead>
    <tbody>
      <% data.each do |key, value| %>
      <tr>
        <th scope="row"><%= key %></th>
        <td><%= value %></td>
      </tr>
      <% end %>
    </tbody>
  </table>
<% end %>

まとめ

DBをみて解析が必要なシーンだと、Sinatra+ActiveRecordでWebアプリケーション作ると意外とイイ!

github.com

(コードは、このリポジトリを大いに参考にさせてもらった。)