👮

簡易ValidationのようなPolicy Object用RubyGemを作った話し

2023/02/11に公開

Policy Objectパターン用のDSLを提供するRubyGemを作りました。
簡易的なActiveModel::Validationのように使え、ポリシー違反をメッセージとして取得できる軽量なGemです。

元々はRails初心者の時に「ActiveModel::Validation的な感じで、save時にブロックしなくて必要な時にだけ正当性を検証できて、エラーメッセージが取得できるものが欲しい。」という需要が始まりでした。

その後、「それってPolicy Objectっていうらしいぞ?」「でもValidationみたいに書きたい…」「なんとなく自作したけど使いづらい」と何年か格闘した結果今回のGemに行きつきました。

使い方

ここでいうPolicy Objectとは下記のようなクラスを指します。

class UserPolicy
  def initialize(user)
    @user = user
  end

  def administrator_account_name?
    user.sign_in_count > 0 && user.role == "admin"
  end

  def use_email_as_name?
    user.full_name.blank? && user.email.present?
  end

  private
  attr_reader :user
end

参考、引用元: Rails tips: Policy Objectパターンでリファクタリング(翻訳)|TechRacho by BPS株式会社

単なるPolicy Objectであればこのままでいいのですが、

  • 何がポリシー違反だったのかを知りたい
  • ちょっとしたポリシーは元のクラスに直接書きたい
  • 大量の条件があるとメソッドが異様に長くなる
  • 大量の条件を分割するためにメソッド名を考えるのが辛い

という問題を解決します。

上記の UserPolicy をPolicyCheckを使って書き直すと下記のようになります。

class UserPolicy
  extend PolicyCheck

  def initialize(user)
    @user = user
  end
  
  policy :administrator_account_name do
    error("このアカウントはまだ使用されてません") { user.sign_in_count = 0 }
    error("管理者アカウントではありません") { user.role != "admin" }
  end

  policy :use_email_as_name do
    error("ユーザー名が設定されています") { user.full_name.present? }
    error("メールアドレスが設定されていません") { user.email.blank? }
  end

  private
  attr_reader :user
end

user_policy = UserPolicy.new(user) 
user_policy.administrator_account_name? #=> false
user_policy.use_email_as_name? #=> true
user_policy.administrator_account_name_errors #=> ["管理者アカウントではありません"]

基本的にはActive Recordなどに直接書くとFat Model化してしまうので推奨はされませんが、それも想定して作っています。

class User < ApplicationRecord
  extend PolicyCheck
  
  policy :administrator do
    error("このアカウントはまだ使用されてません") { sign_in_count = 0 }
    error("管理者アカウントではありません") { role != "admin" }
  end
end

User.find(1).administrator? #=> true

また、無名policyを使うとシンプルなPolicy Objectを実装できます。

class AdminUserPolicy
  extend PolicyCheck

  def initialize(user)
    @user = user
  end
  
  policy do
    error("このアカウントはまだ使用されてません") { user.sign_in_count = 0 }
    error("管理者アカウントではありません") { user.role != "admin" }
  end

  private
  attr_reader :user
end

admin_policy = AdminUserPolicy.new(user) 
admin_policy.valid? #=> false
admin_policy.invalid? #=> true
admin_policy.error_messages #=> ["管理者アカウントではありません"]

内部実装

PolicyCheckを作る前に似たようなgemを作ってみたのですが、多数のメソッド、変数を使っており気軽にinclude/extendしたくないなという作りになってしまいました。

なので、今回はそういったことが起きないように必要最低限のメソッドが作られるように、Module Builderパターンを使ってextend PolicyCheck を行っても .policy クラスメソッドが作られるだけで、クラス変数やインスタンス変数は作られないようにしました。

policy :xxx を実行した時に初めて #xxx? #xxx_errors インスタンスメソッドが作られます。

#error で定義した内容はどこに行くかというと、blockの状態で各メソッドに保存されています。

実装としてはこの辺りになります。

今後に関して

#error_messages はデバッグの際にも役立ちますが、フロントに表示するエラーメッセージとして使用することもあると思うので、需要がありそうだったらRuby I18nを使ったローカライズにも簡単に対応できるようにしようかなとは思っています。

その他何かアイディアがあれば是非コメントしてください。

基本的にはあまり高機能にしない方が需要にフィットしそうだなとは思ってます。

類似Gem

類似gemとしてProcedureというものがあります。こちらは開発した後に存在を知ったのですが、procedure design patternとされており、Policy Objectよりだいぶ複雑なものを想定されてそうです。

参考: Rails procedure design pattern – Long Live Ruby

Discussion