DHHが考えるRailsのバリデーション設計
3行まとめ
- 単純なバリデーション(必須・範囲・文字数など)はHTMLとDB制約、CHECK制約があれば十分であるというのが最近のDHHの主張。
- SQLiteではCHECK制約が少し貧弱なため、制約変更の可能性がある場合は従来通りアプリケーションでもバリデーションした方がいい。
- Rails初心者はDHHの方法をそのまま採用するのはやめた方が良い。
調べたきっかけ
最近DHHがonce.comでのCampfireをはじめとしたプロダクトで、NULL制約やDB制約で防げるようなRailsのモデルのバリデーションを積極的には利用しないでいるという主張をしている。
DHHの主張を要約すると以下のようになる。[1]
- HTMLでのバリデーションが優れている
- 例えば、
input type=“email”
にしておくとブラウザで勝手にメールアドレス形式ではない場合にエラーにしてデータ送信をしないようにしてくれたりなど - Submitすれば即座にフィードバックしてくれるのでUXも良い。
- 例えば、
- 入力必須だったり、単純なバリデーションであれば、DB制約とHTMLで十分。ここを潜り抜けてくるのは「入力間違い以外」のリクエスト[2]なので、バリデーションのエラーメッセージにするのではなく例外を発生させるだけで、バリデーションのエラーメッセージは省略できるのではないか?
- 元々Railsを作ってた時より良い方向に環境が変わったので、意見が変わった。
- 環境の変化については、HTMLの進化やCHECK制約が色々なRDBMSでまともに使えるようになってきていることなどを指している。
言っていることについては理解できつつも、より深くメリット・デメリットを理解したいので、感覚を掴むためにコードを書いたりして試してみることにした。
実際に試した
- Rails: 8系
- SQLite: 最新版
SQLiteを選定している理由
- RailsがSQLiteのプロダクション利用を推奨し始めているため
- SQLiteはMySQLやPostgreSQLに比べてCHECK制約の機能が弱いため、DB制約との相性を確認する
DB制約・CHECK制約を定義したコード例
基本的にはバリデーションについてはActive Recordでは設定しないで、下記のようなNULL制約およびCHECK制約をテーブル作成時に合わせて定義している。
class CreateUsers < ActiveRecord::Migration[6.1]
def change
create_table :users do |t|
t.string :email, null: false
t.string :name, null: false, limit: 50
t.integer :age, null: false
t.boolean :active, null: false, default: true
t.string :role, null: false, default: 'user'
t.check_constraint "age BETWEEN 18 AND 100", name: 'age_range_check'
t.check_constraint "role IN ('admin', 'user', 'guest')", name: 'role_check'
t.check_constraint "LENGTH(name) BETWEEN 2 AND 50", name: 'name_length_check'
t.timestamps
end
add_index :users, :email, unique: true
end
end
モデル側のコードについては書いていないが、原則バリデーションは一切設定していない。
HTML
<%= form_with(model: user, remote: false) do |form| %>
<div class="field">
<%= form.label :name %><br>
<!-- 最低文字数3, 最大文字数50を設定 -->
<%= form.text_field :name, required: true, minlength: 3, maxlength: 50 %>
</div>
<div class="field">
<%= form.label :email %><br>
<!-- HTML5のemail型、required属性 -->
<%= form.email_field :email, required: true, autofocus: true %>
</div>
<div class="field">
<%= form.label :age %><br>
<!-- 数値フィールド、範囲は18から100 -->
<%= form.number_field :age, required: true, min: 18, max: 100 %>
</div>
<div class="field">
<%= form.label :active %><br>
<%= form.check_box :active %>
</div>
<div class="field">
<%= form.label :role %><br>
<!-- セレクトボックスでinclusion制限(admin, user, guest) -->
<%= form.select :role, options_for_select([['Admin', 'admin'], ['User', 'user'], ['Guest', 'guest']], user.role), { required: true } %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
色々試してみた所感
DB制約・CHECK制約で十分にカバー可能なケース
以下のような単純なバリデーションについては未設定でもよいと考えている。
- 入力が必須(required)
- HTMLのバリデーションとDBのNULL制約で対応可能
- 最低・最大入力文字数(minLength, maxLength)
- HTMLのバリデーション + DBのCHECK制約で対応可能
- 数値の入力範囲(min, max)
- HTML + DBのCHECK制約で対応可能
上記の用途であれば確かにRailsでのバリデーションは要らないなという感想。
DB制約・CHECK制約では難しいケース
DBに依存しない複雑なルール
当然ながら、テーブルを跨いだチェックであったりは進化しているCHECK制約でもやっぱり厳しい。ここは諦めてアプリケーションでバリデーションするしかない。
メールアドレスの正規表現
正規表現だけでメールアドレスは判断すべきではないし、などいろいろあるのだけど、
メールアドレスのバリデーションについては正規表現を使うにしても、URI::MailTo::EMAIL_REGEXP
を使ったりする方が良いと思う。[3]
CHECK制約で正規表現を定義する場合、「その正規表現が正しいか?」を確かめるテストを継続的に行う必要がある。URI::MailTo::EMAIL_REGEXP
を使っておけば、ほとんどのケースでは問題にならないように思うので、細かな正規表現に対してのテストをスキップできる。
テストコスト
テストコストについても目を向けておきたい。
アプリケーションでのバリデーションなしに、DBのNULL制約・CHECK制約を使った実装では、実際にトランザクションを通してDBに書き込む必要があるため、テスト環境によっては実行コストやパフォーマンスに影響が出る可能性がある。
昨今CI環境の発展が著しいので、実際にRDBMSをテストで動かすコストは大幅に減ったが、まだまだRDBMSをユニットテストで動かしたくないポリシーの場合は、できるだけアプリケーションにバリデーションを行う方が無難な選択なように思う。
SQLite
SQLiteについてはRailsもSQLiteのプロダクション利用も積極的に勧めていきたい側面もあるし、MySQLなどに比べるとCHECK制約の機能が少し貧弱な側面もあるので、特別に言及しておきたい。
テーブル作成後にCHECK制約を追加・更新できない
SQLiteはCHECK制約周りで制約があり、テーブル作成した後でCHECK制約を追加したり、削除することができない。したがって、CHECK制約を更新したい場合はテーブルを再生成したりする必要があるということである。
例えば、ユーザーにRole
という概念がある場合、最初は3種類だったのだが、後から4種類に追加したり、名前を変更したりということが、上記制約から事実上不可能である。
結論
不安ならアプリケーションのバリデーションをサボるのはお勧めしない
自信がなくて「どうすればいいと思う?」って聞くくらいなら、Railsのバリデーションを書くコストは高くないから書いた方が良い。バリデーション書いても、DB制約はつけておくべきだし、ユニットテストもするべき。上級者がサービス作成のフェーズで僅かでも工数削減したいから利用するテクニックであり万人に勧められるものではない。また、無理してシンプルなバリデーションをDB制約に置き換えたりということも現場レベルで困っていることがなければしなくて良いように思う。
これまでの記事を読んで「不安なくアプリケーションのバリデーションは省略可能」と判断できるエンジニアだけがバリデーションを省略するべきだと思っている。
-
Xなどで断片的な出典はあるものの、明言しているのはCampfire購入特典での質問用ページなので、許可も取っていないので出典の明言は控えておきます。しかしCampfireのコードではRailsのバリデーションを定義しているのは1つだけでした。 ↩︎
-
HTMLの場合はHTMLを改竄したりしない限りはFormから送信できないはずで、そのユーザーに向けてはユーザービリティを提供するのではなく、単に不正なパラメータのエラーであると表現すれば十分であるということ。 ↩︎
-
メールアドレスバリデーションについて細かく知りたい人はこちらをご確認ください。https://techracho.bpsinc.jp/hachi8833/2024_04_05/140298 ↩︎
Discussion
PostgreSQLとかMySQLみたいにネットワーク越しにアクセスする場合は、レイテンシーと、あとバリデーションのコンピューティングリソースをどのコンピューターで持つかというのも問題になりそうですね。なるべくDBでの計算を減らして、横にスケールするアプリケーションでやった方が全体のスループットが増える、とかあるかも知れません。
(RailsのバリデーションでもDBにアクセスが発生したりすると思いますが、傾向として)
SQLite推進、という流れからは気にしないでいいことなんだと思いますが、現実問題としてそういうDBは使われているので。
と言いつつ、そんなに大きなアプリケーションを扱ったこと無いのでエアプですが・・・
そうですね。おっしゃるとおりだと思います。ここからは憶測の域を出ませんが、MySQLであろうがSQliteであろうが、DHHにとってはレイテンシーやバリデーションのコンピューティングリソースについては取るに足らないということなのだと思いますし、サービスの性質によっても大きく異なりそうですね。攻撃はWAFで防げばよいのではということもありますし。
なので、私も自信を持ってDB制約やCHECK制約があればいけるっしょって言えないというのが実情かなと。そういった部分も含めて、個人・チームで自信を持って答えを出せる人だけが、RailsのバリデーションをSkipしてもいいというのが私の結論ですねー