Slide 1

Slide 1 text

Railsの仕組みを理解してモデルを上手に育てる - モデルを見つける、モデルを分割する良いタイミング - 五十嵐邦明 / igaiga 2024/10/25 Kaigi on Rails 2024

Slide 2

Slide 2 text

この講演の対象者と、聞いたあとでこれができるようになる 対象者 Railsアプリでの機能実装に慣れてきたあと、メンテナンスしやすいコードを書く 技術を身につけたい人 Rails歴としては1年〜数年程度を想定 聞いたあとでこれができるようになる モデルを上手にみつけられる モデル分割の良いタイミング(の1つ)であるバリデーションの分岐に対応できる フォームオブジェクトを実装できる これらの妥当性を説明できる Railsアプリ開発で長期的につかえる知識を持ち帰ってもらうことが私の目標です

Slide 3

Slide 3 text

おしながき 前編: モデルの見つけ方 モデルを探す方法の基礎 イベント型モデルを探す POROをつくる Service層を入れるのはできるだけやめてほしい 後編: モデルを分割する良いタイミング モデルを分割する良いタイミングの1つはバリデーションを分岐したくなったとき RailsのバリデーションはDB用と入力用を共有している フォームオブジェクトをつくってバリデーションを書くクラスをつくる フォームオブジェクトのつくりかた

Slide 4

Slide 4 text

自己紹介 五十嵐邦明(igaiga) ガーネットテック373株式会社 代表取締役 フリーランスのRailsエンジニア プログラミングスクール「フィヨルドブートキャンプ」顧問 著書 ゼロからわかる Ruby超入門 Railsの教科書 パーフェクトRuby on Rails[増補改訂版] RubyとRailsの学習ガイド Railsの練習帳 ほか 謝辞 応援してくれている妻、子(1歳)、家族に感謝します

Slide 5

Slide 5 text

前編: モデルの見つけ方 モデルを探す方法の基礎 イベント型モデルを探す POROをつくる Service層を入れるのはできるだけやめてほしい

Slide 6

Slide 6 text

モデルを探す方法の基礎 一般的なDBテーブル設計 「Railsの練習帳 - DBモデリング基礎講座」 に書いています https://zenn.dev/igaiga/books/rails-practice- note/viewer/rails_db_modeling_workshop 今日はごく一部だけ抜粋して説明します Railsの練習帳のどこかのページを読んだことがある方いらっしゃいますか? 「読んで良かった」 「もっと読みたい」と思ったら ️いいね お願いします メモ: 「Railsの練習帳」に ️いいね する方がいるかもなので、水 を飲んで待つ

Slide 7

Slide 7 text

ファミレスメニューでのテーブル設計例 「メインとなるテーブル名」 を名詞で出す メニューは「注文するための道具」なので 「注 文(orders)」 をメインとなるテーブルにします 「誰が・何が」 を考える 「誰が注文するか」と考えて 「顧客 (customers)」 テーブルを作ります 「誰を・何を」 を考える 「何を注文するか」と考えて 「商品(items)」 テーブルを作ります 見つけるコツが必要なテーブルの1つ 「イベント型テ ーブル(モデル)」 をこのあと説明します

Slide 8

Slide 8 text

イベント型モデル 「行為を記録するモデル」 をここではイベント型モデルと呼びます イベント型モデルの見分け方 名詞であるモデル名に「~する」をつけると行為になるもの イベント型モデルの例 Order(注文) Shipment(出荷) Payment(支払い)

Slide 9

Slide 9 text

例: 入荷処理でのイベント型モデル 入荷処理は以下の仕様だとします 在庫(Stockモデル)を増やす 支払った代金を銀行口座(BankAccountモデル)から減らす StockモデルとBankAccountモデルしか見つけられていないとき どちらのモデルに入荷処理を書くか迷う イベント型モデル 「Arrival(入荷)モデル」 をつくる Arrivalモデルにreceivedメソッドをつくる 「在庫を増やす」 「支払った代金を銀行口座から減らす」 をする 複数モデルで実装場所を迷った処理を、イベント型モデルに書けて嬉しい 実装に適切な責務のモデルとして、イベント型モデルがみつけられることがある

Slide 10

Slide 10 text

イベント型モデルのメリット 適切なイベント型モデルを探し出せると、責務が適切なモデルに処理を書ける 複数のモデルにまたがる処理を書きたい問題の解消 つくられたのはモデルで、Rails wayに乗った設計方法になっている Rails wayに乗っていると、迷いづらく説明もかんたんになる できるだけ遠くまでRails wayに乗っていきたい

Slide 11

Slide 11 text

イベント型モデルに関する参考資料 諸橋さん 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をつかってモデルを育てていく方法を話します

Slide 12

Slide 12 text

PORO(Plain Old Ruby Object)をつくる PORO(Plain Old Ruby Object): 何も継承していないただのクラスのこと ActiveRecordを継承せず、テーブルとも結びつかない POROをつくると嬉しいときの例 責務がしっくりくるモデルがつくれないとき イベント型モデルをがんばって探しておくことも大切 テーブルに保存しなくても良い、モデルぽいオブジェクトを発見したとき 例: 別アプリのAPIに問い合わせて取得したオブジェクト 集計して得られた結果のオブジェクト Redisやsessionなどに一時保存しておけば良いオブジェクト POROファイルの置き場所はモデルと同じくapp/models以下に置くのがお勧め ActiveRecordを継承しないが、ビジネスロジック置き場なのでモデルの仲間

Slide 13

Slide 13 text

POROにルールをつくる POROは自由につくることもできるが、ルールがあった方が良い Rails wayから外れそうな境界にあるので、チームでルールを育てるのがお勧め ルールづくりの理想 機能の実装方法をチームメンバー全員に問うたときに、全員が同じ実装方法を答 える状態をつくること このときのメンバー全員は、未来に加わるメンバーも含む 「迷うことを減らす」と考えてみてもよさそう

Slide 14

Slide 14 text

POROにつくるルールの例 POROのルールでつくりやすいお勧めのルール 「クラス名には返すオブジェクトの名前を名詞でつける」 ActiveRecordを継承したモデルがこのような名付けをされることにならったもの たとえばBookモデルでは、戻り値としてBookオブジェクトやその配列を返す メソッドが用意されている Book.first , Book.all.to_a などなど このルールによってクラス名のブレを減らすことができる

Slide 15

Slide 15 text

「クラス名には返すオブジェクトの名前を名詞でつける」の例 ECサービスのカート(Cart)をPOROで実装する例 POROとしてCartクラスをつくる 「クラス名には返すオブジェクトの名前を名詞でつける」に従った命名 Cartオブジェクトを返すメソッドを実装していく カートを扱う責務の様々なメソッドを実装していく 将来、データを記録したくなったときは、POROからモデルへ変更しても良い 対応するcartsテーブルをつくってActiveRecordを継承させる ルール通りならば、クラス名を変えずにモデルへ変更できる

Slide 16

Slide 16 text

Service層を入れるのはできるだけやめてほしい Service層(app/services) があるプロダクトもあるかと思います しかしながら、Service層を入れることはあまりお勧めしません その理由をRailsの特徴から考えます

Slide 17

Slide 17 text

Railsの特徴: 層(レイヤー)分割を減らした密結合な設計 Railsは層(レイヤー)分割を減らして、書くコード量を減らす設計になっている ここでの層はたとえばルーティング、コントローラ、ビュー、モデルなど モデルが担当する仕事たち ORマッパー バリデーション コールバック フォームオブジェクト ビジネスロジック置き場 いろいろな機能をあわせ持った密結合な設計

Slide 18

Slide 18 text

密結合な設計によるメリット うまく結合できると、1カ所に書いたコードが複数の役割を担当できる Railsは一緒のクラスに集めると都合が良いものを上手に集めている 特にモデルは前のページに書いたたくさんの機能を集めている その結果、コード量、クラス数、ファイル数を減らせる 高い生産性を得られる 登場人物を減らすことで全体の把握を容易にするメリットもある 上のメリットを得るために、次 のような密結合による弱点は受け入れている 複数の役割を持つコードが、役割ごとに分岐するとしんどい 変更時の影響範囲が増える

Slide 19

Slide 19 text

Railsアプリに層(レイヤー)を増やすこと Railsは層分割を減らして密結合な設計にしてメリットを得ている 層を増やすことは、Railsの長所を消す方向に働きかねない Service層のような新しい層を入れるのは相当の覚悟を持って挑む作業 Service層を入れるのはできるだけやめてほしい(2回目) Service層を入れるかわりにお勧めしたいこと イベント型モデルを丁寧に探していく 新しい層が増えない Rails wayに乗っていける POROのようなシンプルな考え方からはじめて、チームでルールを育てていく

Slide 20

Slide 20 text

Service層を入れたときの困りごと(追加) ビジネスロジックをモデルとServiceオブジェクトのどちらへ書くか迷いやすい Serviceオブジェクト間でのメソッド共有が難しい Serviceオブジェクトは単一のpublicメソッド(例: call)を持つ設計が多い それ以外のprivateメソッドを他の場所でつかえない Service層はみんなの理解や認識がバラバラになりがち 別の文脈でService層についての知識を持っている人や、それを持たない人 で、同じものについて話しているつもりがすれ違いが起こる すり合わせるときに「私の考える最強のService対決」になると議論に時間が かかり、合意点をみつけるのが難しい Service層を入れるのはできるだけやめてほしい(3回目)

Slide 21

Slide 21 text

後編: モデルを分割する良いタイミング モデルを分割する良いタイミングの1つはバリデーションを分岐したくなったとき RailsのバリデーションはDB用と入力用を共有している フォームオブジェクトをつくってバリデーションを書くクラスをつくる フォームオブジェクトを実装する

Slide 22

Slide 22 text

モデルが太っているかをどう判断すると良い? モデルが太っていることを表現する「Fatモデル」という言葉があります 覚えやすく意味もわかりやすい言葉なので、気をつけている人も多いと思います でも、 「太っている」をどうやって判断すると良いのでしょうか? コード行数で判断? モデルが太っているかをコード行数で判断するのはあまり効果がない しかも、コードの行数を減らすために分割をすると失敗することが多い 「コード行数が多すぎる」は気にしすぎないのがお勧めです たとえばメソッド数が多くてコード行数が多くてもあまり困らないのでは それでは、 「太っている」をどう判断するのが良いでしょうか? 「そのまま書き続けるとしんどくなるとき」 かつ 「そのタイミングで良い分 割方法があるとき」 と考えることを提案します

Slide 23

Slide 23 text

モデルを分割する良いタイミングの1つはバリデーションを条件分 岐したくなったとき 太っている判断をここでは次のように考えます 「そのまま書き続けるとしんどくなるとき」 かつ 「そのタイミングで良い分割方法があるとき」 これに当てはまる1つは 「バリデーションを条件分岐したくなったとき」 validatesメソッドに if: :condition? を書きたくなったとき なぜこれが分割の良いタイミングになるのでしょうか? Railsのモデルのバリデーションの仕組みから考えていきます

Slide 24

Slide 24 text

No content

Slide 25

Slide 25 text

No content

Slide 26

Slide 26 text

No content

Slide 27

Slide 27 text

モデルを分割する良いタイミング まとめ モデル分割の良いタイミングの1つはバリデーションを条件分岐したくなったとき モデルのバリデーションはDB用とフォーム用を共有している DB用と異なるフォーム用のバリデーションが出てくると共有がしんどくなる フォームオブジェクトをつくってフォーム用のバリデーションを担当させる フォーム用とDB用で共有していたバリデーションをそれぞれの仕事に分離 ️️ 時間経過とともに、良い設計が変わっていくことがある モデルのバリデーションはDB用とフォーム用を共有している 特にアプリ開発初期でこの共有はうまく働く この段階で分割してしまうと共有のメリットが得られない DB用と異なるフォーム用のバリデーションが出てくると共有がしんどくなる アプリが成長して共有がしんどくなったら分割する

Slide 28

Slide 28 text

フォームオブジェクトのつくりかた 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

Slide 29

Slide 29 text

ActiveModel::Attributesモジュール 型を持つattributes(カラム的なもの)をかんたんに定義できるようにする attributeメソッドに名前と型(cast type)を指定して定義できる class FooFormObject include ActiveModel::Attributes attribute :name, :string attribute :email, :string attribute :terms_of_service, :boolean end

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

No content

Slide 32

Slide 32 text

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検証正規表現

Slide 33

Slide 33 text

フォームオブジェクトをつくる その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

Slide 34

Slide 34 text

その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

Slide 35

Slide 35 text

その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

Slide 36

Slide 36 text

その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

Slide 37

Slide 37 text

その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

Slide 38

Slide 38 text

その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

Slide 39

Slide 39 text

その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

Slide 40

Slide 40 text

その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 # フォームのリクエスト先パスがわからない旨のエラー 今回はform_withへurl, methodオプションでリクエスト先を指定する方法で対応 他にはidメソッドとpersisted?メソッドとroutesを実装する方法もあります

Slide 41

Slide 41 text

その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をキーワード引数で渡す文法 ビューに書くと読みづらいのでフォームオブジェクトへ移動します

Slide 42

Slide 42 text

その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| %> これで完成です!

Slide 43

Slide 43 text

フォームオブジェクト最終形 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

Slide 44

Slide 44 text

参考資料 諸橋さん 「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

Slide 45

Slide 45 text

謝辞 前島真一さん 諸橋恭介さん yasaichiさん hotoolongさん よーた(youyai1nyny)さん 草間康太さん

Slide 46

Slide 46 text

お仕事募集中! Railsの業務委託の仕事を月1〜6日程リモートで承っています。育成が得意分野です。 著書 パーフェクトRails や Railsの練習帳 などをつかった対話会、読書会、講義 ペアプロ屋、二人三脚での開発技術相談、実装 入社後の研修育成体制を構築して、採用できるエンジニア範囲を増やす コードの健康診断とレポート レガシーコード改善実装、RubyとRailsのバージョンアップ実装 今日話した感じで、Railsのいろいろな話題を勉強会やペアプロでお話しします。 ご相談は会場や弊社問い合わせページにて気軽にお声かけください! 仕事内容詳細ページ: https://garnettech373.com/services 問い合わせページ: https://garnettech373.com/contacts

Slide 47

Slide 47 text

Railsの練習帳 スポンサー募集!! Railsの練習帳は無料で読める形で公開しています 読者からではなく、エンジニアを応援する企業からお金をいただく形を模索中 Rails学習者の中にはこれからRailsエンジニアを目指す人たちがいます 仕事をやめて学んでいる方など、金銭状況が厳しい方もいます Railsエンジニアを応援する会社さんの支援を受けて、もっと書きたい! 模索中なので、スポンサーの組み方からご相談させてください スポンサー特典の例 社名広告掲載、五十嵐による定期勉強会の開催、など スポンサー方法の例 金銭提供、五十嵐の業務時間の一部を執筆に充てる、など Zennで個人スポンサーになってくれたみなさまに感謝します iCARE様、コインチェック様、ペアプロ業務を通じた応援に感謝します