Share
Embed
KaigiOnRails2024
Railsの仕組みを理解してモデルを上手に育てる
- モデルを見つける、モデルを分割する良いタイミング -
Kuniaki IGARASHI
October 24, 2024
Transcript
47 slides
-
おしながき 前編: モデルの見つけ方 モデルを探す方法の基礎 イベント型モデルを探す POROをつくる Service層を入れるのはできるだけやめてほしい 後編: モデルを分割する良いタイミング モデルを分割する良いタイミングの1つはバリデーションを分岐したくなったとき
RailsのバリデーションはDB用と入力用を共有している フォームオブジェクトをつくってバリデーションを書くクラスをつくる フォームオブジェクトのつくりかた -
自己紹介 五十嵐邦明(igaiga) ガーネットテック373株式会社 代表取締役 フリーランスのRailsエンジニア プログラミングスクール「フィヨルドブートキャンプ」顧問 著書 ゼロからわかる Ruby超入門 Railsの教科書
パーフェクトRuby on Rails[増補改訂版] RubyとRailsの学習ガイド Railsの練習帳 ほか 謝辞 応援してくれている妻、子(1歳)、家族に感謝します -
モデルを探す方法の基礎 一般的なDBテーブル設計 「Railsの練習帳 - DBモデリング基礎講座」 に書いています https://zenn.dev/igaiga/books/rails-practice- note/viewer/rails_db_modeling_workshop 今日はごく一部だけ抜粋して説明します Railsの練習帳のどこかのページを読んだことがある方いらっしゃいますか?
「読んで良かった」 「もっと読みたい」と思ったら ️いいね お願いします メモ: 「Railsの練習帳」に ️いいね する方がいるかもなので、水 を飲んで待つ -
ファミレスメニューでのテーブル設計例 「メインとなるテーブル名」 を名詞で出す メニューは「注文するための道具」なので 「注 文(orders)」 をメインとなるテーブルにします 「誰が・何が」 を考える 「誰が注文するか」と考えて
「顧客 (customers)」 テーブルを作ります 「誰を・何を」 を考える 「何を注文するか」と考えて 「商品(items)」 テーブルを作ります 見つけるコツが必要なテーブルの1つ 「イベント型テ ーブル(モデル)」 をこのあと説明します -
例: 入荷処理でのイベント型モデル 入荷処理は以下の仕様だとします 在庫(Stockモデル)を増やす 支払った代金を銀行口座(BankAccountモデル)から減らす StockモデルとBankAccountモデルしか見つけられていないとき どちらのモデルに入荷処理を書くか迷う イベント型モデル 「Arrival(入荷)モデル」 をつくる
Arrivalモデルにreceivedメソッドをつくる 「在庫を増やす」 「支払った代金を銀行口座から減らす」 をする 複数モデルで実装場所を迷った処理を、イベント型モデルに書けて嬉しい 実装に適切な責務のモデルとして、イベント型モデルがみつけられることがある -
イベント型モデルに関する参考資料 諸橋さん Kaigi on Rails 2023講演 「Simplicity on Rails --
RDB, REST and Ruby」 https://speakerdeck.com/moro/simplicity-on-rails-rdb-rest-and-ruby yasaichiさん、t-wadaさん podcast 「texta.fm」 https://open.spotify.com/show/2BdZHve9cIU6c8OFyz7LeB 次はPOROをつかってモデルを育てていく方法を話します -
PORO(Plain Old Ruby Object)をつくる PORO(Plain Old Ruby Object): 何も継承していないただのクラスのこと ActiveRecordを継承せず、テーブルとも結びつかない
POROをつくると嬉しいときの例 責務がしっくりくるモデルがつくれないとき イベント型モデルをがんばって探しておくことも大切 テーブルに保存しなくても良い、モデルぽいオブジェクトを発見したとき 例: 別アプリのAPIに問い合わせて取得したオブジェクト 集計して得られた結果のオブジェクト Redisやsessionなどに一時保存しておけば良いオブジェクト POROファイルの置き場所はモデルと同じくapp/models以下に置くのがお勧め ActiveRecordを継承しないが、ビジネスロジック置き場なのでモデルの仲間 -
Service層を入れたときの困りごと(追加) ビジネスロジックをモデルとServiceオブジェクトのどちらへ書くか迷いやすい Serviceオブジェクト間でのメソッド共有が難しい Serviceオブジェクトは単一のpublicメソッド(例: call)を持つ設計が多い それ以外のprivateメソッドを他の場所でつかえない Service層はみんなの理解や認識がバラバラになりがち 別の文脈でService層についての知識を持っている人や、それを持たない人 で、同じものについて話しているつもりがすれ違いが起こる すり合わせるときに「私の考える最強のService対決」になると議論に時間が
かかり、合意点をみつけるのが難しい Service層を入れるのはできるだけやめてほしい(3回目) -
モデルが太っているかをどう判断すると良い? モデルが太っていることを表現する「Fatモデル」という言葉があります 覚えやすく意味もわかりやすい言葉なので、気をつけている人も多いと思います でも、 「太っている」をどうやって判断すると良いのでしょうか? コード行数で判断? モデルが太っているかをコード行数で判断するのはあまり効果がない しかも、コードの行数を減らすために分割をすると失敗することが多い 「コード行数が多すぎる」は気にしすぎないのがお勧めです たとえばメソッド数が多くてコード行数が多くてもあまり困らないのでは
それでは、 「太っている」をどう判断するのが良いでしょうか? 「そのまま書き続けるとしんどくなるとき」 かつ 「そのタイミングで良い分 割方法があるとき」 と考えることを提案します -
モデルを分割する良いタイミングの1つはバリデーションを条件分 岐したくなったとき 太っている判断をここでは次のように考えます 「そのまま書き続けるとしんどくなるとき」 かつ 「そのタイミングで良い分割方法があるとき」 これに当てはまる1つは 「バリデーションを条件分岐したくなったとき」 validatesメソッドに if:
:condition? を書きたくなったとき なぜこれが分割の良いタイミングになるのでしょうか? Railsのモデルのバリデーションの仕組みから考えていきます -
モデルを分割する良いタイミング まとめ モデル分割の良いタイミングの1つはバリデーションを条件分岐したくなったとき モデルのバリデーションはDB用とフォーム用を共有している DB用と異なるフォーム用のバリデーションが出てくると共有がしんどくなる フォームオブジェクトをつくってフォーム用のバリデーションを担当させる フォーム用とDB用で共有していたバリデーションをそれぞれの仕事に分離 ️️ 時間経過とともに、良い設計が変わっていくことがある モデルのバリデーションはDB用とフォーム用を共有している
特にアプリ開発初期でこの共有はうまく働く この段階で分割してしまうと共有のメリットが得られない DB用と異なるフォーム用のバリデーションが出てくると共有がしんどくなる アプリが成長して共有がしんどくなったら分割する -
フォームオブジェクトのつくりかた Railsが提供する ActiveModel::Model, ActiveModel::Attributes をつかう ActiveModel::Attributes 型(cast type)を指定したattributesをつくれる ActiveModel::Model form_withに渡せたり、validationできたり、newメソッドでattributesと一緒に
初期化できたり、など、モデルのように振る舞える Rails7.0以降ではかわりに ActiveModel::API をつかうこともできます 違い: ActiveModel::Model = ActiveModel::API + ActiveModel::Access ActiveModel::Accessにはsliceメソッドとvalues_atメソッドがあります どちらをつかうのが良いのかご存知の方、教えてください サンプルコード: https://github.com/igaiga/rails_form_object_sample_app 別案としてはYAAF Gemでつくる方法も https://github.com/rootstrap/yaaf -
ActiveModel::Attributesモジュール 型を持つattributes(カラム的なもの)をかんたんに定義できるようにする attributeメソッドに名前と型(cast type)を指定して定義できる class FooFormObject include ActiveModel::Attributes attribute :name,
:string attribute :email, :string attribute :terms_of_service, :boolean end -
ActiveModel::Modelモジュール モデルのように振る舞う機能各種をつかえるようになる バリデーションを設定して実行できる機能 form_withとやりとりする機能 newメソッドでattributesと一緒に初期化する機能 ほか FooFormObject.new(name: "iga", email: "[email protected]",
terms_of_service: true) class FooFormObject include ActiveModel::Model include ActiveModel::Attributes attribute :name, :string attribute :email, :string attribute :terms_of_service, :boolean validates :name, presence: true validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } # URI::MailTo::EMAIL_REGEXPはRubyに定義されてるemail検証正規表現 validates :terms_of_service, acceptance: { allow_nil: false } # acceptanceはチェックボックス確認用 https://railsguides.jp/active_record_validations.html#acceptance -
Userモデルのattributesとバリデーション app/models/user.rb # DB schema # create_table "users" do |t|
# t.string "name", null: false # t.string "email" # class User < ApplicationRecord validates :name, presence: true validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true end nameは必須 emailはblank可で、フォーマットの確認をする URI::MailTo::EMAIL_REGEXP はRubyに定義されてるemail検証正規表現 -
フォームオブジェクトをつくる その1 基礎工事 名前は UserNameForm 、置くフォルダは app/forms とします app/forms/user_name_form.rb nameは必須、全ひらがな(=ひらがなだけ)で入力する仕様
attributesとして attribute :name, :string を持ちます validates :name, format: { with: /\A\p{Hiragana}+\z/ }, presence: true /\A\p{Hiragana}+\z/ は全ひらがなかどうか判定する正規表現 app/forms/user_name_form.rb class UserNameForm include ActiveModel::Model include ActiveModel::Attributes attribute :name, :string validates :name, format: { with: /\A\p{Hiragana}+\z/ }, presence: true end -
その2 Userモデルをフォームオブジェクトへ渡す Userモデルに仕事を委譲するために attr_accessor :user で設定取得可能に DB保存機能を委譲するためにUserモデルを設定可能に URLヘルパーで xxx_path(user) のようにつかいたいので取得も可能に
transfer_attributesメソッドでフォームオブジェクトからモデルへattributesセット app/forms/user_name_form.rb class UserNameForm # ...(略)... attr_accessor :user # フォームオブジェクトからモデルへattributesをセット def transfer_attributes user.name = name end end -
その3 saveメソッドをフォームオブジェクトに実装 DB保存機能をsaveメソッドとして実装 モデルと似た書き味を目指します app/forms/user_name_form.rb def save(...) # ... は全引数を引き渡す記法
transfer_attributes # フォームオブジェクトからモデルへattributesをセット if valid? # フォームオブジェクトのバリデーション実行 user.save(...) # モデルのsaveメソッドへ委譲 else false # これがないとvalid?失敗時にnilが返る end # valid? && user.save(...) # 短く書いても良い end -
その4 initializeメソッドをフォームオブジェクトに実装 UserNameForm.new(name_params) フォームからのparamsで初期化する想定 追加実装しなくてもnewメソッドでattributesを設定可能 UserNameForm.new(name: "いがいが") 今回はnewメソッドへattributesのほかにUserモデルも渡したい initializeメソッドをオーバーライドして機能追加 app/forms/user_name_form.rb
def initialize(model: nil, **attrs) # `**`はキーワード引数をHashで受け取る文法 attrs.symbolize_keys! # StringとSymbolの両対応 if model @user = model attrs = {name: @user.name}.merge(attrs) # attrsがあれば優先 end super(**attrs) # もともとのinitializeメソッドを呼び出し # `**`はHashをキーワード引数で渡す文法 end -
その5-1 コントローラの変更 StrongPrameters NamesControllerとそのroutes, viewのベース部分はscaffoldでつくったコードの想定 StrongPrametersを変更 フォームからのparamsにあわせて require(:user_name_form) へ app/controllers/names_controller.rb
class NamesController < ApplicationController private def name_params params.require(:user_name_form).permit(:name) end end -
その5-2 コントローラの変更 new & createアクション @user_name_form 変数へUserNameFormオブジェクトを代入 リダイレクト先のURL取得を変更 app/controllers/names_controller.rb class
NamesController < ApplicationController def new @user_name_form = UserNameForm.new(model: User.new) end def create @user_name_form = UserNameForm.new(model: User.new, **name_params) if @user_name_form.save redirect_to user_url(@user_name_form.user), notice: "User was successfully created." else render :new, status: :unprocessable_entity end end end -
その5-3 コントローラの変更 edit & updateアクション @user_name_form 変数へUserNameFormオブジェクトを代入 リダイレクト先のURL取得を変更 app/controllers/names_controller.rb class
NamesController < ApplicationController def edit @user_name_form = UserNameForm.new(model: User.find(params[:id])) end def update @user_name_form = UserNameForm.new(model: User.find(params[:id]), **name_params) if @user_name_form.save redirect_to user_url(@user_name_form.user), notice: "User was successfully updated." else render :edit, status: :unprocessable_entity end end end -
その6-1 form_withでフォームオブジェクトをつかう form_withのmodelオプションにはフォームオブジェクトを渡すことにします 変数user_name_formにフォームオブジェクトが入っています app/views/names/_form.html.erb <%= form_with(model: user_name_form) do |form|
%> 次のエラーが出ます undefined method `user_name_forms_path' for an instance of #<Class:0x...> フォームのリクエスト先パスがわからない旨のエラー 今回はform_withへurl, methodオプションでリクエスト先を指定する方法で対応 他にはidメソッドとpersisted?メソッドとroutesを実装する方法もあります -
その6-2 form_withへurl, methodオプションを指定 form_withのurl, methodオプションでリクエスト先を指定 モデルが未保存のときはcreateアクションへ モデルが保存済みのときはupdateアクションへ app/views/names/_form.html.erb <%# 実際は改行なし
%> <% form_with_options = user_name_form.user.persisted? ? { url: name_path(user_name_form.user), method: :patch } : { url: names_path, method: :post } %> <%= form_with(model: user_name_form, **form_with_options) do |form| %> model.persisted? メソッドはDB保存済みかどうかを判定 **form_with_options の ** はHashをキーワード引数で渡す文法 ビューに書くと読みづらいのでフォームオブジェクトへ移動します -
その6-3 form_withのオプションをフォームオブジェクトから取得 app/forms/user_name_form.rb def form_with_options if user.persisted? # update用 {
url: Rails.application.routes.url_helpers.name_path(user), method: :patch } else # create用 { url: Rails.application.routes.url_helpers.names_path, method: :post } end end Rails.application.routes.url_helpers.names_path ビュー以外でURLヘルパーメソッド(names_pathなど)をつかう方法 app/views/names/_form.html.erb <%= form_with(model: user_name_form, **user_name_form.form_with_options) do |form| %> これで完成です! -
フォームオブジェクト最終形 GitHub: https://github.com/igaiga/rails_form_object_sample_app class UserNameForm include ActiveModel::Model # バリデーション機能、form_withに渡せる機能、 #
new(name: "xxx", ...)のようにattributesとあわせて初期化する機能などを足す include ActiveModel::Attributes # 型を持つattributesをかんたんに定義できるようにする attribute :name, :string # このフォームオブジェクトのバリデーション validates :name, format: { with: /\A\p{Hiragana}+\z/ }, presence: true # DB保存などの機能を委譲するためにUserモデルをセット可能に # redirect_to @user のときなどUserモデルを取りたいので取得もできるようにする attr_accessor :user def initialize(model: nil, **attrs) # `**`はキーワード引数をHashで受け取る文法 attrs.symbolize_keys! # StringとSymbolの両対応 if model @user = model attrs = {name: @user.name}.merge(attrs) # attrsがあれば優先 end super(**attrs) # もともとのinitializeメソッドを呼び出し # `**`はHashをキーワード引数で渡す文法 end def save(...) # ... は全引数を引き渡す記法 transfer_attributes # フォームオブジェクトからモデルへattributesをセット if valid? # フォームオブジェクトのバリデーション実行 user.save(...) # モデルのsaveメソッドへ委譲 else false # これがないとvalid?失敗時にnilが返る end # valid? && user.save(...) # 短く書いても良い end def form_with_options if user.persisted? # update用 { url: Rails.application.routes.url_helpers.name_path(user), method: :patch } else # create用 { url: Rails.application.routes.url_helpers.names_path, method: :post } end end private # フォームオブジェクトからモデルへattributesをセット def transfer_attributes user.name = name end end -
参考資料 諸橋さん 「Simplicity on Rails -- RDB, REST and Ruby」
https://speakerdeck.com/moro/simplicity-on-rails-rdb-rest-and-ruby yasaichiさん、t-wadaさん 「texta.fm」 https://open.spotify.com/show/2BdZHve9cIU6c8OFyz7LeB yasaichiさん「Ruby on Railsの正体と向き合い方」 https://speakerdeck.com/yasaichi/what-is-ruby-on-rails-and-how-to-deal-with-it 「パーフェクト Ruby on Rails 増補改訂版」 https://gihyo.jp/book/2020/978-4-297-11462-6 「Railsの練習帳」 DBモデリング基礎講座 https://zenn.dev/igaiga/books/rails-practice- note/viewer/rails_db_modeling_workshop フォームオブジェクト https://zenn.dev/igaiga/books/rails-practice- note/viewer/ar_form_object -
お仕事募集中! Railsの業務委託の仕事を月1〜6日程リモートで承っています。育成が得意分野です。 著書 パーフェクトRails や Railsの練習帳 などをつかった対話会、読書会、講義 ペアプロ屋、二人三脚での開発技術相談、実装 入社後の研修育成体制を構築して、採用できるエンジニア範囲を増やす コードの健康診断とレポート
レガシーコード改善実装、RubyとRailsのバージョンアップ実装 今日話した感じで、Railsのいろいろな話題を勉強会やペアプロでお話しします。 ご相談は会場や弊社問い合わせページにて気軽にお声かけください! 仕事内容詳細ページ: https://garnettech373.com/services 問い合わせページ: https://garnettech373.com/contacts -
Railsの練習帳 スポンサー募集!! Railsの練習帳は無料で読める形で公開しています 読者からではなく、エンジニアを応援する企業からお金をいただく形を模索中 Rails学習者の中にはこれからRailsエンジニアを目指す人たちがいます 仕事をやめて学んでいる方など、金銭状況が厳しい方もいます Railsエンジニアを応援する会社さんの支援を受けて、もっと書きたい! 模索中なので、スポンサーの組み方からご相談させてください スポンサー特典の例 社名広告掲載、五十嵐による定期勉強会の開催、など
スポンサー方法の例 金銭提供、五十嵐の業務時間の一部を執筆に充てる、など Zennで個人スポンサーになってくれたみなさまに感謝します iCARE様、コインチェック様、ペアプロ業務を通じた応援に感謝します