在网络服务开发中,客户端传来的参数往往包含多层嵌套的JSON结构,传统手动判断不仅代码冗长,而且错误信息难以统一。Ruby的dry-validation库提供了一套基于契约(contract)的校验机制,让开发者以声明方式描述参数规则,并通过独立的消息配置实现多语言输出。这种方式把数据结构约束和业务逻辑解耦,特别适合需要对外提供稳定API的场景。

契约定义的基本写法与核心概念
Dry-validation中的契约通过继承Dry::Validation::Contract来定义,在schema块内部使用required和optional方法描述字段。每个字段都可以指定类型,例如:string、:integer或者更复杂的:hash。当请求参数进入系统时,契约对象会按照定义逐层比对,只有全部规则通过才认为合法。这种声明式写法比在控制器里写一堆if params[:x]要清晰得多,也更容易测试。
除了基础类型,dry-validation还支持嵌套子契约,用来校验对象里的对象。比如一个网络请求里带有user字段,而user内部又有name与address,我们可以把address再抽成一个独立契约复用。下面的例子展示了一个最基础的契约,它要求email为字符串且符合格式,age为可选整数。
require 'dry-validation'
class UserContract < Dry::Validation::Contract
params do
required(:email).value(:string)
optional(:age).value(:integer)
end
rule(:email) do
if key?(:email) && !value.match?(/A[^@s]+@[^@s]+z/)
key.failure('must be a valid email')
end
end
end
result = UserContract.new.call({ email: 'test@ippipp.com', age: 20 })
puts result.success? # true
上面的代码里,params块定义结构,rule块写自定义逻辑。注意rule里的失败消息是直接写死的英文字符串,这在单语言项目里没问题,但一旦要支持中文或其它语言就需要抽离,这正是后面本地化要解决的问题。契约本身不关心消息语言,只负责判定是否满足规则。
错误消息本地化的配置与实践
Dry-validation并不直接生成用户看到的文案,而是抛出带消息键(message key)的错误,再由I18n层翻译。默认情况下,类型不匹配会引用像type?这样的内置键,我们也可以在自己的规则里用key.failure(:invalid_email)这样的符号,然后在locale文件中写对应翻译。这样做之后,控制器拿到的错误就能随请求头里的Accept-Language切换。
假设我们把系统语言设为简体中文,需要在配置中加载对应的yml。下面展示一个典型的本地化文件结构,以及如何在契约中引用自定义键。可以看到,消息完全从校验逻辑中剥离,产品同学也能自行修改文案而不碰代码。
zh-CN:
dry_validation:
errors:
invalid_email: '邮箱格式不正确'
required: '字段 %{key} 不能为空'
type?: '字段 %{key} 类型错误'
在契约里使用这些键的方式如下。我们把之前写死的字符串换成符号,框架就会去locale里查找。若找不到,则回退到默认英文,保证开发期不崩。实际项目中建议把所有用户可见文案都走这套机制,避免分散在Ruby文件里难以维护。
class UserContract < Dry::Validation::Contract
params do
required(:email).value(:string)
end
rule(:email) do
if key?(:email) && !value.match?(/A[^@s]+@[^@s]+z/)
key.failure(:invalid_email)
end
end
end
I18n.locale = :'zh-CN'
result = UserContract.new.call({})
puts result.errors.to_h # 包含中文消息
本地化不只是翻译,还涉及占位符。例如required错误里用%{key}动态填入字段名,这样无论参数怎么变,提示都能准确定位。网络参数校验经常要告诉前端哪个具体路径出错,比如user.address.city,dry-validation会把嵌套路径自动拼好,再传给翻译层,非常省心。
嵌套网络参数与契约复用策略
真实API请求常常是多层包裹,例如{ data: { user: { profile: { phone: } } } }。如果全写在一个契约里会很长,dry-validation允许把子结构定义为独立契约,然后通过required(:user).hash(UserSchema)引用。这种组合方式让校验规则像积木一样拼装,不同接口可共享同一块UserSchema而无需复制代码。
下面的示例定义了地址契约和用户契约,用户契约内嵌地址。当外部调用UserContract时,框架会递归校验每一层,任何深层失败都会带着完整路径返回。相比手写递归判断,这种方式几乎零样板代码,且错误定位精确。我们还可以在子契约里单独切换消息命名空间,以便不同模块使用不同的翻译文件。
class AddressContract < Dry::Validation::Contract
params do
required(:city).value(:string)
optional(:zip).value(:string)
end
end
class UserContract < Dry::Validation::Contract
params do
required(:name).value(:string)
required(:address).hash(AddressContract.new)
end
end
payload = { name: 'Li', address: { city: 123 } }
res = UserContract.new.call(payload)
puts res.errors.to_h
# {:address=>{:city=>["must be a string"]}}
在微服务架构下,多个服务可能都要校验同一个对外DTO,把契约打包成gem或共享库就能保证规则一致。配合本地化,即便A服务返回中文、B服务返回英文,也只需要各自配置locale。最后提醒,契约里不要用raise来中断,而应统一走failure机制,否则错误信息无法被框架收集与翻译,失去dry-validation的设计优势。
dry-validationcontract_definitionerror_localization修改时间:2026-08-15 22:44:36