リファクタリングに ActiveModel::EachValidator はいかがですか
リファクタリングに ActiveModel::EachValidator はいかがですか
外部APIによるチェック処理の重複問題
こんにちは、虎の穴ラボのawamoです。 先日、Railsアプリの機能改修をしていて気づいたことがありました。 「あれ、このテキスト分析のチェック処理、他のコントローラーでも見たような...」 調べてみると、同じ外部APIを呼び出すチェック処理が、複数のコントローラーにコピペで散らばっています。
※ コードは例です。実際のプロダクトコードではありません。
# posts_controller.rb def create # ... 省略 ... analyzer = TextAnalyzer.new analyzer.analyze(@post.body) if analyzer.invalid? render :new and return end # ... end # comments_controller.rb def create # ... 省略 ... analyzer = TextAnalyzer.new analyzer.analyze(@comment.content) if analyzer.invalid? render :new and return end # ... end # messages_controller.rb にも同様のコード...
これは典型的な DRY 原則違反です。修正が必要になったら、すべてのコントローラーを探し回らなければなりません。
皆さんにも、このような経験はないでしょうか。
長い間運用されてきたサービスであれば目にすることもある問題ではないかと思います。
本記事では、この「複数のコントローラーに散らばったバリデーション処理」を ActiveModel::EachValidator でリファクタリングし、モデル層に集約した際のことを紹介します。
ここでは、特に外部のAPIをラップした TextAnalyzer クラスが用意されていた場合について記載します。
リファクタリング前の問題点の整理
まず、既存コードの何が問題なのかを明確にしましょう。
問題1: 同じ処理が複数箇所に存在
# 3つのコントローラーに同じコード... analyzer = TextAnalyzer.new analyzer.analyze(value) if analyzer.invalid? # エラー処理 end
チェック処理の追加・削除・修正が必要になった場合、すべてのコントローラーを探して変更を行う必要があります。
問題2: コントローラーの責務が肥大化している
コントローラーは本来「リクエストを受けてレスポンスを返す」ことに集中すべきです。バリデーションロジックがコントローラーにあると以下の問題が起こり得ます。
- コントローラーのテストが複雑になる
- ビジネスロジックがリクエストと密結合になる
問題3: モデルの valid? と連動しない
コントローラーでチェックをしていると、model.valid? を呼んでもそのチェックは実行されません。これにより以下の問題が起こりえます。
- Form Object や Service からの保存時にチェックが漏れる
createとupdateで、意図せず挙動が変わる
解決策: EachValidator でモデル層に集約
こっらの問題への解決策は、ActiveModel::EachValidator を用いたカスタムバリデータの作成です。
railsguides.jp
api.rubyonrails.org
特定の属性検証に適した EachValidator の採用
カスタムバリデータを作成するのには他にも使えるクラスがあります。
ただ、今回のようなテキスト分析は「本文」「コメント」など特定のカラムに対して行うため、EachValidator の方が良いでしょう。
| クラス | 用途 | 使用シーン |
|---|---|---|
ActiveModel::Validator |
レコード全体を検証 | 複数カラムの組み合わせチェック |
ActiveModel::EachValidator |
特定の属性を検証 | 1つのカラムに対する検証 |
リファクタリング後のメリット
1. 宣言的で読みやすい記述になる
# Before: コントローラーに手続き的なコード def create analyzer = TextAnalyzer.new analyzer.analyze(@post.body) if analyzer.invalid? render :new and return end @post.save end # After: モデルに宣言的に記述 validates :body, text_analyze: true
2. 外部APIロジックをモデルからも完全分離
チェックはチェック単体で検証できるので、コントローラーとバリデーションのそれぞれで単体テストが容易になります。
3. どこから保存しても必ずチェックされる
コントローラー、Form Object、Service、Job、どこから save しても同じバリデーションが走るようになります。
また、create や update についても、意図的に処理をスキップしない限りはチェックが走ります。
実装手順: コントローラーからモデルへ移行
それでは、実際にリファクタリングを進めていきましょう。
ステップ1: バリデータークラスを作成
app/validators/ ディレクトリにファイルを作成します(ディレクトリがなければ作成します)。
ファイル: app/validators/text_analyze_validator.rb
class TextAnalyzeValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) # 空値は他のバリデーション(presence等)に任せる return if value.blank? analyzer = TextAnalyzer.new analyzer.analyze(value) analyzer.raise_exception! if analyzer.invalid? rescue MyApp::ValidationError => e # 何らかの業務エラー(スパム検出、禁止ワード等) record.errors.add(attribute, e.message) rescue StandardError => e # システムエラー(APIタイムアウト、ネットワーク障害等) Rails.logger.error("TextAnalyzeValidator error=#{e.inspect}") record.errors.add(:base, 'エラーが発生しました。時間を置いて再度お試しください。') end end
実装のポイント
ポイント1: 早期リターンで不要な処理をスキップ
return if value.blank?
空文字や nil の場合は、外部APIを呼ぶ必要がありません。presence: true など他のバリデーションに任せて良いでしょう。
ポイント2: 業務エラーとシステムエラーを分離
rescue MyApp::ValidationError => e # ユーザーに伝えるべきエラー(禁止ワード検出など) record.errors.add(attribute, e.message) rescue StandardError => e # 運用上の問題(API障害など) Rails.logger.error(...) record.errors.add(:base, '汎用メッセージ')
- 業務エラー : 「禁止ワードが含まれています」など、ユーザーが修正可能な内容
- システムエラー : APIタイムアウトなど、ユーザーには詳細を見せず汎用メッセージを表示
外部APIでのバリデーションで、それに通らなければデータの登録や更新をさせたくない場合などには、システムエラー時には汎用エラーを提示することになるかと思います。
ステップ2: 命名規則に従う
Rails は validates に渡されたキー名から、対応するバリデータークラスを自動で探します。
| validates のキー | 探されるクラス名 |
|---|---|
text_analyze |
TextAnalyzeValidator |
presence |
PresenceValidator |
length |
LengthValidator |
snake_case のキー名が、CamelCase + Validator のクラス名に変換されます。
Rails のCoC(Convention over Configuration)における規約の部分ですね。
ステップ3: モデルにバリデーションを追加
class Post < ApplicationRecord validates :title, presence: true validates :body, presence: true, length: { maximum: 10_000 } validates :body, text_analyze: true, on: :create end class Comment < ApplicationRecord validates :content, presence: true, length: { maximum: 1_000 } validates :content, text_analyze: true, on: :create end class Message < ApplicationRecord validates :body, presence: true validates :body, text_analyze: true, on: :create end
ステップ4: コントローラーから重複コードを削除
# Before class PostsController < ApplicationController def create @post = current_user.posts.build(post_params) # この部分を削除します analyzer = TextAnalyzer.new analyzer.analyze(@post.body) if analyzer.invalid? flash.now[:error] = analyzer.error_message render :new and return end if @post.save redirect_to @post else render :new end end end
# After class PostsController < ApplicationController def create @post = current_user.posts.build(post_params) # Rails でよくみるコントローラーでの保存ロジックと、レンダリング分岐だけ if @post.save redirect_to @post else render :new end end end
コントローラーがシンプルになり、本来の責務に集中できるようになりました。
まとめ
コントローラーに散らばっていたバリデーション処理を、ActiveModel::EachValidator でモデル層に集約するリファクタリングを解説しました。 外部サービス連携のような「ちょっと重い処理」こそ、コントローラーから追い出してモデル層で整理することで、コードの保守性が向上するかと思います。
「あれ、このチェック他でも見たな...」と感じた際は、EachValidator による共通化を検討してみるのはいかがでしょうか。
Fantia開発採用情報
虎の穴ラボでは現在、一緒にFantiaを開発していく仲間を積極募集中です! 多くのユーザーに使っていただけるtoCサービスの開発をやってみたい方は、ぜひ弊社の採用情報をご覧ください。 toranoana-lab.co.jp
Discussion in the ATmosphere