写这套系列的时候,我就知道绕不开数据验证这一章。坦白说,Active Record 数据验证在 Rails 里是最“日常”的功能——每天写 model、写表单、写 API 参数校验,都离不开它。但真把它吃透的人不多,我见过很多项目跑着跑着,数据库里莫名其妙出现一堆脏数据,查下来发现是验证没写全、或者写错了时机、或者被某种写法悄悄绕过。这篇是“rails 指南总结”系列的第三篇,就专门把 Active Record 数据验证这块掰开揉碎讲清楚,从触发机制到内建验证器,从条件验证到错误信息处理,再到实际项目里那些坑,一篇说透。适合正在学 Rails 的初学者,也适合写了好几年 Rails 但没系统梳理过验证机制的朋友。
1. 数据验证的定位与整体设计思路
1.1 为什么必须在模型层做数据验证
不少刚接触 Rails 的同学会问一个问题:表单那边不是已经校验过了吗?数据库里不是也加了约束吗?为什么模型层还要再来一遍验证?
答案是:因为前端验证、数据库约束和模型层验证,它们三个负责的事情完全不一样,谁也替代不了谁。
前端验证(不管是浏览器端 JS 还是手机端表单校验)解决的是“用户填错了给我提醒一下”的问题。它的优点是即时反馈、体验好,缺点是它根本防不住恶意请求。我拿 curl 直接往接口里塞一条数据,压根不经过你的前端页面,你前端校验写得再漂亮也拦不住。所以前端验证只能当“第一道提醒”,不能当防线。
数据库约束(比如 NOT NULL、UNIQUE、CHECK、外键)解决的是“最底层数据完整性”的问题。它的优点是铁面无私,性能好,并发下也稳定,缺点是你很难把复杂的业务规则写进数据库约束里。比如“发布状态下标题不能为空,草稿状态下可以为空”,这种条件性的业务校验用 CHECK 约束写起来会很别扭,而且报错信息是从数据库抛上来的,用户根本看不懂那一串错误码。你在模型里用 Active Record 验证去写,业务规则清晰,错误信息还能直接用中文告诉用户哪里填错了。
模型层验证的位置恰好卡在中间:它在数据要写进数据库之前的那一步触发,能拿到完整的业务上下文,能做复杂的条件判断,能组装人类可读的错误信息。它才是业务数据完整性的真正防线。所以我一直跟团队里的人说:表单可以只做体验,数据库约束可以只做兜底,但模型层的 Active Record 数据验证,必须全员写到位。
1.2 验证的触发时机与生命周期
要写好验证,首先得搞明白一件事:验证是什么时候跑的?
很多人以为“只要调用了 save 就会跑验证”,这个说法方向对了,但不严谨。Active Record 在对象生命周期里有一整套回调链条,完整的保存流程大概是:
valid? → before_validation → validate(执行所有验证器) → after_validation → 写入数据库 → 事务提交具体一点的流程是这样的:你调用save的时候,Rails 会先检查这个对象是不是新的(new record),然后执行valid?或者invalid?来判断当前数据状态。如果验证失败,整个保存流程直接中止,返回 false;如果验证通过,才接着往下走回调、再真正执行 INSERT 或 UPDATE 的 SQL。
这里有几个关键的触发节点需要记清楚:
valid?和invalid?:手动触发验证,返回布尔值,并且会把错误信息填充进errors对象。它不会写数据库。save/save!:保存前自动触发验证。验证失败时save返回 false,save!直接抛出ActiveRecord::RecordInvalid异常。create/create!、update/update!:同样是保存前自动验证。update_attribute:这个方法会绕过验证直接更新单列,注意它的名字是 attribute(单数),跟update/update_attributes完全不同。这个坑我当年踩过。
还有个特别容易误解的地方:验证在内存里执行,不在数据库里执行。也就是说验证跑的是对象当前属性值,不会去数据库里逐条对照(除非你写了 uniqueness 这种会查库的验证器)。这也意味着,验证通过只能说明内存里的这个对象状态是合法的,但并发场景下另一个进程可能已经把同样的数据写进库了,所以需要配合数据库唯一索引做兜底。这个放到后面问题排查部分专门讲。
不过这里先提一个最重要的结论:要养成习惯,凡是需要写入的关键操作,尽可能用带感叹号的版本(save!、create!、update!),让验证失败时第一时间以异常的形式报出来,而不是默默返回一个 false,然后你还要到处去翻“这条数据到底存进去没有”。
2. 常用验证器详解与选型要点
2.1 内建验证器速览与选择原则
Active Record 自带了一批非常好用的验证器,多数场景不需要自己写逻辑。我先把最常用的列出来,再逐个说细节。
| 验证器 | 作用 | 常用参数 |
|---|---|---|
presence | 字段不能为空 | message |
uniqueness | 字段值唯一 | scope、case_sensitive、message |
length | 长度范围校验 | minimum、maximum、in、is |
format | 正则格式校验 | with、message |
numericality | 数值属性校验 | only_integer、greater_than、less_than、allow_nil |
inclusion | 值必须在集合内 | in: |
exclusion | 值不能在集合内 | in: |
acceptance | 确认类勾选校验 | message |
confirmation | 两次输入一致性校验 | message |
每个验证器都有自己的脾气,我挑几个特别容易出问题的细说。
presence这个最常用,它也最容易被误解。它内部判断逻辑用的是blank?,也就是说空字符串、全空格字符串、nil、空数组、空 Hash 都会被判定为“不存在”。这个设计其实是合理的,因为表单提交上来的空字符串本质上就是用户没填。但如果你有个字段本身语义就是“允许填空格”,或者你存的是 markdown、代码片段这类文本,需要区分“空”和“一个空格”,这时候presence就不够用了,得自己写一个validate方法去判断nil?。
uniqueness是外表最简单、内在最复杂的验证器。它看起来很朴素:查一下这张表里有没有相同的值。但有几个问题:第一,它区分大小写的行为在不同数据库里表现不一样,MySQL 默认排序规则下"abc"和"ABC"会被当成同一个值;第二,它默认是忽略nil的,也就是说如果你唯一约束的字段允许空值,那空值可以有好多个,因为数据库里NULL != NULL;第三,它天生存在并发竞态问题,两个请求同时插入相同值,验证都通过了,结果最后一个把库写崩了。所以uniqueness验证器一定要配合数据库的唯一索引使用,数据库才是最终裁决者。
numericality有个容易忽略的细节:它默认情况下不接受字符串,就算你表单传的是"18",它也可能会报错,除非你配合 Rails 的类型转换。其实多数情况下,从表单传进来的字符串到了 model 层会被 ActiveRecord 按列类型自动转成数字,所以这条验证器不会误伤正常使用的人。但如果你把某个字段定义成 string 却想校验它“看起来像数字”,就比较尴尬,这种情况我建议用format配合正则来做。
2.2 条件验证与自定义验证方法的正确姿势
现实项目里,很少有不带条件的验证。最常见的场景就是状态机式的结构:草稿、已发布、已归档,不同状态下必填字段不一样。Active Record 给条件验证提供了if和unless选项,这两个选项可以接收符号、字符串、Proc 或方法名。
class Article < ApplicationRecord validates :title, presence: true, if: :published? validates :summary, presence: true, unless: :draft? private def published? status == 'published' end end符号形式(:published?)会在验证时调用同名方法,返回真值就执行验证。Proc 形式适合短逻辑:
validates :phone, presence: true, if: ->(user) { user.role == 'seller' }多个验证共用一组条件时,用with_options会清爽很多:
with_options if: :published? do |post| post.validates :title, presence: true post.validates :content, length: { minimum: 100 } post.validates :cover_image, presence: true end条件验证写多了,很容易出现验证规则失控的情况。我的经验是:单个 model 里条件判断不要超过两三层,超过的话就把不同的验证拆到不同的 model 里,配合 STI 或多态去建模。比如把“草稿”和“已发布”拆成两个 model,每个 model 自己维护一套验证规则,逻辑反而更清晰。这个后面专门展开。
自定义验证方法用validate(注意是单数)注册,区别于validates。适合写一些没法用内建验证器表达的规则。比如某个字段依赖另一个字段的取值:
class Order < ApplicationRecord validate :must_have_shipping_address_when_physical_goods private def must_have_shipping_address_when_physical_goods if goods_type == 'physical' && shipping_address.blank? errors.add(:shipping_address, '实物商品必须填写收货地址') end end end自定义验证方法里,向errors对象加错误信息是这个机制的核心。errors.add(:字段名, '消息')之后,这个字段就会被判定为不合法。注意消息内容如果不带^前缀,Rails 会把它拼在默认错误信息前面,成为一个完整的句子;如果带^,则直接原样展示。这个细节后面还会说到。
自定义验证方法是在模型验证阶段被逐个调用的,如果方法内部有 I/O 操作(比如查第三方 API、读文件),要考虑把它们移到事后回调里,避免每次保存都被卡住。验证阶段只做内存判断,这个原则能让你的 model 保持轻量。
3. 实操过程与关键场景实现
3.1 从零搭建一个带完整验证的用户模型
讲完原理,用一个实际的例子把整套验证串起来。假设我们在做一个社区产品,用户注册时需要填写邮箱、昵称、密码、手机号,注册后可以完善个人简介和头像,其中个人简介只有昵称填了之后才能保存。
先建立模型:
class User < ApplicationRecord has_secure_password validates :email, presence: true, length: { maximum: 255 }, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { case_sensitive: false } validates :nickname, length: { maximum: 30 }, allow_nil: true validates :phone, format: { with: /\A1[3-9]\d{9}\z/, message: '请输入有效的手机号' }, allow_blank: true, uniqueness: { allow_nil: true } validates :password, length: { minimum: 6 }, if: ->(u) { u.password.present? } validates :bio, length: { maximum: 200 } validate :nickname_must_present_before_bio end这里有几个值得说透的场景。
第一个是uniqueness: { case_sensitive: false }的底层行为。Rails 接到这个参数后,对不同数据库会生成不同的 SQL。在 PostgreSQL 上会生成LOWER(email) = LOWER(?)或者citext类型,在 MySQL 上则要看表级别的排序规则。所以如果你在项目里发现邮箱大小写不敏感唯一没有按预期生效,先去看数据库的 collation 设置,别急着怀疑验证器。
第二个是allow_nil和allow_blank的差异。allow_nil表示字段值为 nil 时跳过验证;allow_blank更宽松,值为空字符串、全空格字符串、nil 时都跳过验证。两者的选择要看字段语义:昵称这种用户可以不填的字段,用allow_nil: true比较严谨;手机号这种表单上可能传空字符串的字段,用allow_blank: true更符合实际。如果你两个都没写,uniqueness验证遇到 nil 也会自动跳过,但format验证不会跳过,会把 nil 送进正则表达式里,返回一个错误。
第三个是那个自定义验证方法。当用户想保存个人简介但没有昵称时,我们报一个“请先填写昵称”的错误。这种跨字段的校验用自定义验证方法确实最自然,但注意错误信息挂在哪个字段上——我们挂在bio上,这样表单页面上昵称和简介这两个字段中间会显示一个关于“先填昵称”的提示,体验上是说得通的。如果你希望错误信息出现在昵称字段旁边,那就要改成errors.add(:nickname, '昵称不能为空')。
3.2 错误消息处理与国际化配置
验证写好之后,真正的输出物是errors对象。前端拿到错误后要展示给用户,所以错误消息的质量直接决定体验。
先看errors对象的基本玩法:
user = User.new(email: '', nickname: 'a' * 40, phone: '123') user.valid? user.errors.full_messages # => ["邮箱不能为空", "昵称过长(最多 30 个字符)", "请输入有效的手机号"] user.errors[:nickname] # => ["昵称过长(最多 30 个字符)"] user.errors.details[:nickname] # => [{ error: :too_long, count: 30 }]errors.full_messages返回的是完整句子,适合直接展示给用户;errors[:field]返回的是某个字段的错误列表;errors.details里存的是机器可读的错误码,适合在 API 里做结构化错误响应。
错误消息的文案推荐走国际化(i18n)文件统一管理。Rails 默认的错误信息文件在config/locales/zh-CN.yml里,需要你自己维护。一个典型的配置长这样:
zh-CN: errors: messages: blank: "不能为空" too_long: "过长(最多 %{count} 个字符)" taken: "已经被占用" invalid: "格式不正确"字段名在英文环境下是直接显示attribute的,但在中文环境下你需要在activerecord标题下定义属性翻译,否则错误消息里会出现英文的Email混在中文句子里,非常割裂:
zh-CN: activerecord: attributes: user: email: "邮箱" nickname: "昵称" phone: "手机号"这里有一个我在实际项目中反复用到的技巧:errors.add时消息里带^前缀。默认情况下errors.add(:nickname, "不能为空")会生成“昵称不能为空”,因为 Rails 会把字段名的翻译作为前缀拼进去;如果你传"^不能为空",Rails 会忽略字段名前缀,直接展示“不能为空”。在某些 UI 设计里(比如错误信息已经显示在对应输入框旁边,不需要重复字段名),这个特性非常好用。不过要注意,这个行为只有在errors.add的字符串消息里有效,如果你用的是 sym 形式的错误码(比如:blank),那拼不拼字段名前缀是由 i18n 配置里format决定的,不要混淆。
3.3 验证与数据库约束的配合策略
模型层验证是业务守门员,数据库约束是底线守卫。只写模型层验证不写数据库约束,数据库里可能会混进脏数据;只写数据库约束不写模型层验证,报错信息会很难看。两者必须配合,但配合的分工要明确。
一般原则是:业务校验放模型层,数据完整性校验两边都要。具体来说:
- 必填:模型层用
presence,数据库用NOT NULL。多数生成的 migration 默认就会加null: false,所以这块问题不大。 - 唯一性:模型层用
uniqueness提供友好错误信息,数据库加唯一索引兜底并发。这个最重要。 - 外键完整性:Rails 里用
belongs_to加optional: false会在模型层校验,数据库层面加外键约束。 - 取值范围:模型层用
inclusion或numericality,数据库如果用的是 PostgreSQL 可以考虑CHECK约束,但不用强制。
以唯一索引为例,migration 中需要这样写:
class AddUniqueIndexToUsersEmail < ActiveRecord::Migration[7.0] def change add_index :users, :email, unique: true end end如果表里已经有重复数据,这个 migration 会跑失败,你需要先清理数据再加索引。这是个常见坑:项目早期没加索引,运营一段时间后积累了大量重复邮箱,这时候加索引就会直接报错。我的建议是凡是声明了uniqueness的字段,在上线前就必须配套加好唯一索引,否则后患无穷。
还有一个配合细节:当数据库唯一约束被触发时(并发竞态、或者绕过模型层直接批量导入),save会抛出一个ActiveRecord::RecordNotUnique异常,而不是返回false,而且错误信息是英文的数据库报错。所以在批量导入等高风险环节,除了模型层验证,还要捕异常并转换成友好提示。这个属于生产环境稳定性的范畴,但验证这块绕不开它。
4. 常见问题与排查技巧实录
4.1 验证为什么没有生效
“我明明写了 validates,为什么脏数据还是进去了?”这是我被问过最多的问题,而且九成以上的原因都很集中。
第一个原因是用了绕过验证的写库方法。update_column、update_columns、update_attribute、increment!、decrement!、toggle!、touch这些方法都会跳过验证,直接更新数据库。它们的本意是对已经存在的数据做列级操作,不做完整校验,可一旦误用,验证就形同虚设。排查方法也简单:搜索代码里所有update_column和update_attribute,逐个确认使用场景。
第二个原因是批量操作默认跳过验证。Model.update_all、Model.delete_all、Model.insert_all都不走模型验证,直接生成 SQL 操作数据库。这种场景主要出现在后台脚本、数据迁移、批量导入中,所以数据迁移脚本里千万别指望验证器能拦住非法数据,你在脚本里写清楚业务规则才靠谱。
第三个原因比较隐蔽:验证写错了对象。有同学在子类里用validates声明验证,但父类里如果调用了valid?之后又reload了对象,验证结果会被覆盖。还有一种情况是关联对象验证:has_many关联写入时,默认不会自动验证关联对象的合法性,需要在accepts_nested_attributes_for里加上validate: true,或者在保存前显式调用associated_objects.each(&:valid?)。
第四个原因是if条件写得不对劲。最常见的写法是把两个条件用逗号塞给了同一个if选项:
# 错误写法 validates :title, presence: true, if: :published?, if: :approved? # 正确写法 validates :title, presence: true, if: ->(record) { record.published? && record.approved? }后一个if会把前一个覆盖掉,导致验证条件被意外改变。这种 bug 很难用眼睛看出来,所以条件验证我一般建议用 Proc 把逻辑合并成一个。
4.2 性能陷阱与数据流向误区
验证看起来只是几下逻辑判断,但uniqueness这类验证器背后是真真实实的数据库查询。每执行一次valid?,遇到uniqueness就会发一条SELECT语句,如果模型里有多个uniqueness验证,就会发多条。你在循环里调valid?或者批量创建对象时,这个开销会被放大得非常明显。
批量创建 N 条记录,每条记录有 2 个uniqueness验证,那就是 2N 条额外 SQL。N 是 1000 的时候就是 2000 条查询,接口能不慢吗。处理方式有两种:一是循环外先批量查好已有的唯一值集合,在内存里做判断;二是直接走insert_all绕过模型验证,依靠数据库唯一索引做兜底,撞上重复数据再捕获异常。第二种方案性能最好,但前提是你对业务规则有十足把握,而且数据库索引万无一失。
另一个性能相关的话题是errors对象里的重复验证。同一个对象连续调用save两次,如果第一次失败,errors对象里会累积两组错误吗?答案是会。所以如果在一个方法里反复调用valid?而导致错误累积,你需要用errors.clear来清空,但更推荐的做法是提前规划好验证调用路径,避免对象在验证失败后继续被操作。
数据流向的误区主要体现在“验证只检查当前对象,不检查关联数据”。比如订单模型:
class Order < ApplicationRecord has_many :items, dependent: :destroy validates :items, presence: true end这个validates :items, presence: true只检查关联集合是否为空,并不检查集合里每个 item 是否合法。如果某个 item 的数量是 0,订单照样能保存成功。要在保存订单时级联检查所有子项的验证,需要设置has_many :items, autosave: true,并让子项自己声明验证规则。这套组合拳在嵌套表单实践中非常常见,但很多人不知道autosave到底起了什么作用——它让父对象保存时自动保存所有关联对象,保存过程中子对象会执行自己的验证,任何一个子对象验证失败,整个事务都会回滚。
4.3 复杂业务校验的建模心得
当验证规则开始膨胀,一个 model 里塞了七八个validate方法,每个方法里还有三四个if,这时候就该停下来做一次建模思考了。
我的经验是,这种“状态不同、规则不同”的复杂模型,优先考虑单表继承(STI)或者用独立的 form object 把它们拆开。举一个实际例子:同样是文章的 model,草稿和已发布的验证规则差异很大,如果塞在一个 model 里,会写出类似下面这样口吐白沫的代码:
validates :cover_image, presence: true, if: ->(record) { record.status == 'published' && record.template_type == 'photo' }STI 拆分之后,DraftArticle和PublishedArticle各自持有自己的验证逻辑,条件判断直接消失了。当对象从草稿变成已发布时,只需要把 record 从一个类切换成另一个类,验证规则自动跟着切换,测试也好写得多。不过 STI 也有代价:单张表里会有很多用不到的字段,索引维护也更复杂,要用得克制。
另一个方案是 form object。说白了就是把一组验证规则从 model 里抽到一个专门处理表单提交的 Plain Ruby Object 里,这个对象内部实例化真正的 model,并复用 model 的验证逻辑。它在“用户提交的表单”和“数据库持久化结构”差异较大时特别好用,比如注册流程需要同时创建 User 和 Profile 两张表记录,还带一个邀请码校验,用 form object 封装成一个干净的接口就很顺手。
老实说,我见过不少项目不搞这些,照样用一个大 model 撑住了。小项目、校验规则确实简单的,完全没必要上 STI 或者 form object。但如果你发现自己写验证的时候开始搞“状态机 + 多重 if + 耦合在验证里的业务逻辑”,那就是重构的信号了。记住一句大实话:验证规则是业务规则的影子,业务规则复杂了,验证拆分自然要跟上,硬缝在一个 model 里只能越写越痛。
5. 实际项目里的补充经验
5.1 嵌套表单下的验证与事务行为
实际项目中,嵌套表单(nested forms)几乎是每个复杂业务都会碰到的东西。所谓嵌套表单,就是在一个表单里同时提交父记录和子记录的数据,比如创建一个订单的同时添加多个订单明细,或者注册用户时同时填好用户的收货地址。Rails 里用accepts_nested_attributes_for处理这种场景:
class Order < ApplicationRecord has_many :items, autosave: true accepts_nested_attributes_for :items, allow_destroy: true, reject_if: :all_blank end这里有几个验证相关的坑需要特别提醒。
第一个是reject_if: :all_blank的行为。它的作用是:提交上来的子记录如果是全空的(所有字段都没填),就直接丢弃,不会触发验证。这个很贴心,因为用户可能根本没打算填某个子表单。但你如果设置了allow_destroy: true,用户删除了一条子记录,提交的数据里会带_destroy: '1'这样的参数,这条记录会被标记删除,常规验证对它不生效。所以删除操作的校验,通常要在父模型里加一个自定义验证,检查“订单至少要保留一条明细”,否则会出现提交后订单被全部删光的情况。
第二个是事务回滚的行为。父模型保存触发嵌套验证时,子模型验证失败,父模型应该也不保存。但这里有个细节:accepts_nested_attributes_for自动保存关联对象的行为依赖autosave,如果子对象验证失败,Rails 会通过事务把父对象的保存一起回滚。实际工作中我发现很多人对“事务回滚”这件事有误解,以为只要加了autosave就一定事务化,其实不然。autosave控制的是“保存关联时校验”,真正的事务范围由has_many的autosave配置和accepts_nested_attributes_for的底层保存逻辑决定,多数情况下会包在事务里,但如果你在回调里手动调用save或者update_columns,这个保证就会被打破。
第三个是错误消息的归属。嵌套表单里子记录验证失败,错误信息默认挂在子记录上,父表单里如果不做处理,用户根本看不到子记录的错误提示。常用做法是在 controller 里把子记录的 errors 平铺到父对象上:
def create @order = Order.new(order_params) if @order.valid? && @order.items.all?(&:valid?) @order.save redirect_to @order else @order.items.each do |item| item.errors.full_messages.each { |msg| @order.errors.add(:base, msg) } end render :new, status: :unprocessable_entity end end这种平铺方式不算优雅,但确实能用,而且用户反馈清晰。更正统的做法是自己实现一个 form object,捕获每个子记录的错误并结构化展示,但代码量会大不少,适合业务复杂度确实值得投入时才上。
5.2 API 场景下的验证与错误响应设计
现在很多 Rails 项目是纯 API 后端,模型层验证的结果需要变成 JSON 响应返回给客户端。花极小成本就能把错误响应做得很专业,关键是别只甩一个“参数错误”的字符串。
第一步是统一响应结构。我常建议在ApplicationController里定义统一的错误格式化方法:
def render_errors(record) render json: { code: 'validation_failed', details: record.errors.details, messages: record.errors.full_messages }, status: :unprocessable_entity enderrors.details返回的是结构化错误码,客户端可以据此做机器处理(比如分类成“必填缺失”“格式错误”“唯一冲突”),errors.full_messages则直接用于展示。两者都返回,前后端各取所需。
第二步是处理 ActiveRecord 校验异常。save!抛出ActiveRecord::RecordInvalid时,exception.record上挂着原始对象,通过它可以取出 errors。在 controller 里可以用rescue_from统一接住:
class ApplicationController < ActionController::API rescue_from ActiveRecord::RecordInvalid do |exception| render_errors(exception.record) end end一旦统一接住了异常,controller 里写代码就可以放心大胆地使用save!而不用到处写if @user.save ... else render_errors,代码瞬间清爽不少。
第三步是给第三方客户端良好的 422 错误文档。422 Unprocessable Entity是 Rails 抛校验错误的默认状态码,语义上非常准确,一定别改成 400。还有个小细节:如果请求本身带的参数格式不对(比如传了数组给字符串字段),可能在到达验证器之前就抛了参数错误,那是另一层问题,返回值按 400 处理比较合适。
5.3 自定义验证器与可复用校验逻辑
当多个模型都需要相同的验证规则时,把它抽成一个自定义验证器类是个相当好的实践。比如多个模型都有“邀请码必须存在且未过期”的规则,你可以新建app/validators/invitation_code_validator.rb:
class InvitationCodeValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) return if value.blank? invitation = Invitation.find_by(code: value) if invitation.nil? record.errors.add(attribute, '请输入有效的邀请码') elsif invitation.expired? record.errors.add(attribute, '邀请码已过期') end end end然后在任何模型里:
class RegistrationForm include ActiveModel::Validations validates :invitation_code, invitation_code: true end这个写法比在每个 model 里复制粘贴一段validate :invitation_code_must_be_valid要干净得多。不过要注意自定义验证器内部不要做太重的查询,因为每个字段验证时它都会被调用,如果写一条低效 SQL 且数据量大,性能会很难看。
另一种复用方式是利用ActiveModel::Validations模块创建独立的验证对象(常说的 Validator Object)。它和自定义验证器类的区别在于:验证器类只处理单个字段,Validator Object 可以一次性组织多个字段的复杂规则。举个例子,创建“注册表单验证器”:
class RegistrationValidator include ActiveModel::Validations attr_accessor :email, :nickname, :password validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :nickname, presence: true, length: { maximum: 30 } validates :password, length: { minimum: 6 } end然后在 controller 里实例化它,或者把它作为 form object 的父类。这种方案的优点是不绑定数据库表结构,很适合临时表单、多步骤注册、导入数据预处理这类场景。
6. 踩坑记录与自查清单
6.1 我记忆里最深刻的三个验证事故
第一个事故发生在一次数据清洗任务中。任务要求把旧系统导入的用户数据做去重合并,主键是邮箱。我在脚本里用了find_or_create_by(email: email)方式创建用户,心想 model 里已经有uniqueness验证了,重复数据应该会被拦住。结果跑完一看,数据库里出现了一大片重复邮箱。排查下来发现,find_or_create_by内部在并发下有一个非常经典的竞态窗口:find的时候没查到,但在create之前另一个进程已经把同一条数据插进去了。模型层验证在这种“查找-再写入”的过程中根本拦不住并发写入,真正拦住的是数据库唯一索引。后来我把脚本改成了批量insert_all并捕获RecordNotUnique异常,按行去重,问题才算解决。这件事给我留下的教训足够刻骨铭心:验证器不是并发安全工具,唯一索引才是。
第二个事故是关于presence验证的。有个字段存的是关联表的外键,模型里写了validates :category_id, presence: true。表面上一切正常,但如果关联记录本身是无效的,外键还是会通过验证。比如从表单里传了一个不存在的 category_id,因为值不是 nil 也不是空字符串,presence验证直接放行,数据写进去了,前端渲染时发现关联记录查不到,直接白屏。这种情况是典型的“完整性校验”缺失,必须用belongs_to :category的关联验证来兜底,或者在自定义验证里确认关联确实存在。光靠presence检查外键值非空,解决不了“无效外键”的问题。
第三个事故比较隐蔽,发生在很多老项目里:覆盖了模型的valid?方法但忘了调super。有人为了让某个字段在特定条件下“假装合法”,直接改写了valid?方法,结果其他所有验证都被静默跳过了。数据进库快到没法发现,直到线上出了脏数据,查了很久才发现是valid?被重写后没调用父类实现。老老实实说实话,这种覆盖内建方法的做法在任何时候都不推荐,如果确实需要在验证前调整属性,应该在before_validation回调里做,而不是直接动valid?本身。
6.2 验证代码的日常自查清单
我在团队里推行了一套验证自查清单,每次提交模型改动前都会过一遍。这套清单也不神秘,就几条硬规则。
- 模型层验证只做内存判断,不执行 I/O。涉及外部依赖的校验(查 API、读文件)放回调。
- 所有
uniqueness验证必须配套数据库唯一索引,否则必须注明原因并单独审批。 - 保存数据优先用
save!/create!系列,让验证失败以异常形式暴露,而不是静默 false。 - 凡是允许外部输入的关键字段,至少有一个模型层验证兜底,前端校验不作为依据。
- 条件验证里的
if用 Proc 或多个条件时,检查是否出现重复键覆盖。 - 嵌套表单提交时,必须显式处理子记录验证失败后的错误展示。
- 数据导入、脚本、批量操作等绕过验证的场景,必须单独写明规则并用库约束兜底。
- 自定义验证方法里
errors.add的字段名和消息要配合 i18n,保证 API 返回的错误消息可读。
这套自查清单配上rubocop-rails里的Rails/Save!和Rails/Validation相关规则,能挡住大部分常见的低级错误。至于更深层的业务建模问题,那就只能靠人脑了。
7. 写在最后的经验补充
如果只能留一条建议,我会说:把验证规则当作产品需求的一部分来对待,而不是“顺手加两行 validates”。我从实际项目中观察到的规律是,验证规则写得随意、位置放得杂乱的项目,数据质量问题一定会集中在项目的中后期爆发。而且修复脏数据的成本,往往比一开始认真设计验证规则的成本高好几倍。
另一个很值得做的投入是给验证规则写好测试。Rails 的 model 测试里用shoulda-matchers写验证规则测试非常省力:
describe User do it { should validate_presence_of(:email) } it { should validate_uniqueness_of(:email).case_insensitive } it { should validate_length_of(:password).is_at_least(6) } end看着简单,但这些测试能在某次重构后第一时间告诉你:哪条验证被误删了,哪个条件写错了。验证规则是业务规则的映射,回归保护的意义比大多数人都意识到的要大得多。
技术选型上,我的立场是:在绝大多数项目里,Active Record 自带的验证机制已经足够好,没必要引第三方验证库。除非你的项目验证场景已经完全超出 Active Record 的模型范围(比如跨模型的复杂工作流校验、多步骤表单、规则引擎式的动态校验),否则第三方库只会带来额外的学习成本和抽象负担。Rails 的验证体系设计得足够完善,问题往往不出在工具本身,而出在怎么组织规则、怎么划分边界、怎么防绕过这些工程实践上。
数据验证是你和数据库之间的契约,也是你对用户承诺的数据质量的底线。认真对待每个validates、每个errors.add,写清楚为什么要验证、验证什么、不验证什么,你的项目会在某个意想不到的深夜少收到一条“数据错了”的报警。这是这个系列第三篇,下一篇我会继续梳理 Active Record 的查询接口与关联设计,到时候见。