Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails: dry-rbでForm Objectを作る(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: dry-rbでForm Objectを作る(翻訳)

現代のRailsでは、Form Objectを作るのは珍しくありません。多くのRuby開発者はVirtusActiveModel::ValidationsをincludeしてForm Objectを作成することに慣れています。本記事では、dry-typedry-validationを使ってForm Objectを作成する方法をご紹介したいと思います。

絵ハガキ(postcard)を作成する簡単なForm Objectを作ってみましょう。このアプリには次の3つのモデルがあります。

  • Country: フィールドはnameis_state_required。2つ目のフィールドは正しいアドレスの作成に使われ、米国などのユーザーは州名の入力が必要です。
  • Country::State: フィールドはnamecountry_id
  • Postcard: フィールドはstate_idcountry_idcontentaddress

完了までの作業を定義する

  • フォームで新しい絵ハガキを作成する(だいたいおわかりですね)
  • 住所、市町村、郵便番号、コンテンツ、国のバリデーションを行う
  • 郵便番号フォーマットのバリデーションを行う
  • コンテンツの長さのバリデーションを行う(ツィートやテキストメッセージ並に短くしたい場合)
  • 選択した国で州名が必要な場合、州名の存在のバリデーションも必要

属性と型

まずは属性の定義から行います。Form ObjectはDry::Types::Structから継承する必要があります。必要なゲッターやコンストラクタはDryで定義されます。

class Postcard
  module Types
    include Dry::Types.module
  end

  class CreateForm < Dry::Types::Struct
    attribute :address, Types::Coercible::String
    attribute :city, Types::Coercible::String
    attribute :zip_code, Types::Coercible::String
    attribute :content, Types::Coercible::String
  end
end

Dry::Types.moduleincludeするだけでDry-typesの型を使えるようになります。Dry-typesでは変更に応じた多くのプリミティブ型を選択できます

Railsモデルを使う場合はもう少し複雑です。これらの型で属性を作成するには、型を登録する必要があり、TypeName = Dry::Types::Definition.new(::MyRubyClass)のように行います。.constructorをブロック付きで呼び出すと、dry-typesで構成される型を指定できます。

定義は以下のような感じになります。

module Types
  include Dry::Types.module
  Country = Dry::Types::Definition.new(::Country)
  CountryState = Dry::Types::Definition.new(::Country::State)
end

これで、CountryCountryStateを型として使えるようになりました。最終的なフォームの定義は次のようになります。

class CreateForm < Dry::Types::Struct
  attribute :address, Types::Coercible::String
  attribute :city, Types::Coercible::String
  attribute :zip_code, Types::Coercible::String
  attribute :content, Types::Coercible::String
  attribute :country, Types::Country
  attribute :state, Types::CountryState
end

これでやっと、シンプルなstructを作成できました。

メモ: dry-typesのstructコンストラクタについて

コンストラクタの種類を指定しないと、strictコンストラクタが生成されます。この場合、属性が見つからないとArgumentErrorをスローします。存在のバリデーションはdry-validationで行うので、より多くの情報を含むコンストラクタであるschemaコンストラクタやsymbolizedコンストラクタを使うことになります。schemaコンストラクタを使うには、クラス本体の中でconstructor_type(:schema)を呼ぶ必要があります。

バリデーション

Form Object内部でバリデーションを実行するには、dry-validation gemを使います。これにはさまざまな述語(メソッド)が含まれており、使い方も簡単です。まずは存在のバリデーションを行ってみましょう。

PostcardSchema = Dry::Validation.Schema do
  required(:address).filled
  required(:city).filled
  required(:zip_code).filled
  required(:content).filled
  required(:country).filled
end

先ほど定義したモデルの属性を渡すスキーマを次のように定義します。

errors = PostcardSchema.call(to_hash).messages(full: true)

それではこの動作を見てみましょう。

  • to_hash(またはto_h): 属性をハッシュベースで生成する
  • .messages(full: true): 完全なエラーメッセージを返す。

フォーマットや長さなど、渡すバリデーションの要件を増やすには、単に.filledメソッドにパラメータを渡します。contentを例に取ると、存在バリデーションの他に、20文字より長いこともバリデーションされます。

required(:content).filled(min_size?: 20)

利用できる述語の全リストはこちらをご覧ください。

バリデーションロジックがさらに複雑な場合

存在や長さのバリデーション機能はdry-validationによって提供されます。残念なことに(?)、実際に動くアプリではこれだけでは不十分です。そのため、dry-validationで独自の述語を書けるようになっています。

まずは簡単なものから。バリデーションに渡されたcountrystateが必要な場合は以下のように書きます。

PostcardSchema = Dry::Validation.Schema do
  configure do
    config.messages_file = Rails.root.join('config/locales/errors.yml')
    def state_required?(country)
      country.is_state_required
    end
  end
# (...)
end

このとおり簡単です。errors.ymlに正しいエラーメッセージを書いておくのをお忘れなく。エラーファイルについて詳しくはこちらをどうぞ。

次はいよいよ、countryで必要になった場合にのみstateの存在をチェックしましょう。stateが存在するかどうかをバリデーションに伝える必要があります。これは、スキーマに以下の行を書くだけでできます。

required(:state).maybe

ルール自体を定義する

ルール自体は次のように定義します。

rule(country_requires_state: [:country, :state]) do |country, state|
  country.state_required? > state.filled?
end

これも見てのとおり簡単です。

  • ルール内で必要となるフィールドに沿ったルール名を渡します。ここではcountryとstateを使います。
  • これらの変数はブロックにyieldされます。
  • 「stateが必要な場合は、stateの存在をチェックする」というようにルールが変換されます。

より高度なルールについて詳しくはこちらをどうぞ。

完成したForm Object

class Postcard
  module Types
    include Dry::Types.module
    Country = Dry::Types::Definition
                .new(::Country)
    CountryState = Dry::Types::Definition
                     .new(::Country::State)
  end

  class CreateForm < Dry::Types::Struct
    constructor_type(:schema)

    ZIP_CODE_FORMAT = /\d{5}/
    MINIMAL_CONTENT_LENGTH = 20

    attribute :address, Types::Coercible::String
    attribute :city, Types::Coercible::String
    attribute :zip_code, Types::Coercible::String
    attribute :content, Types::Coercible::String
    attribute :country, Types::Country
    attribute :state, Types::CountryState


    def save!
      errors = PostcardSchema.call(to_hash).messages(full: true)
      raise CommandValidationFailed, errors if errors.present?
      Postcard.create!(to_hash)
    end

    private

    PostcardSchema = Dry::Validation.Schema do
      configure do
        config.messages_file = Rails.root.join('config/locales/errors.yml')
        def state_required?(country)
          country.is_state_required
        end
      end
      required(:address).filled
      required(:city).filled
      required(:zip_code).filled(format?: ZIP_CODE_FORMAT)
      required(:content).filled(min_size?: MINIMAL_CONTENT_LENGTH)
      required(:state).maybe
      required(:country).filled

      rule(country_requires_state: [:country, :state]) do |country, state|
        country.state_required? > state.filled?
      end
    end
  end
end

モデルやspecを含む完全なプロジェクトは私のGitHubに公開してあります。

適用できるリファクタリング

記事を読みやすくするため、私はオブジェクト自身に関連するものをすべてひとつのファイルに書きました。このような書き方は、おそらく実際のアプリにおけるコードベースの編成法として最適ではありません。次のリファクタリングが考えられます。

  • Typesモジュールを別のモジュールに配置する(場合によってはグローバルスコープに)
  • PostcardSchemaはForm Objectの外部に配置し、UpdateFormなどでも使う
  • ZIP_CODE_FORMATMINIMAL_CONTENT_LENGTHについても同様

まとめ

dry-typesを使うと、アプリで型安全なコンポーネントを書けるようになります。このライブラリでは多数の型が利用可能で、独自定義も簡単です。

私にとって、dry-validationによるアプローチはActiveModelを使ったものよりも明快に感じられます。バリデーションロジックをすべて明確に区切られた場所に集められます。これらのバリデーションは他のフォーム(UpdateForなど)での再利用も簡単です。

dry-rbシリーズの最大の問題は(ROMRodaにも同種の問題があるのですが)、初めてのユーザーが簡単に使えるようなドキュメントがないことです。信じていただけるかどうかはともかく、私はこのForm Objectの作成に2時間かかりました。原因のほとんどは、ドキュメントの問題と、ブログ記事がないことです。本記事が皆さまの2時間を節約するのに役立てばと願っています。

関連記事

Ruby: Dry-rb gemシリーズのラインナップと概要

Rails: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

RubyのModule Builderパターン #2 Module Builderパターンとは何か(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。