Grape::Entity 在 API 渲染层承担着字段声明与序列化职责。很多项目使用 format_with 注册格式化函数,把数据库中的时间、金额等原始值转换成 API 需要的展示值。带块格式化则把这层转换逻辑直接写在 expose 附近,看起来只是传了一个 Proc,但最终调用时能拿到哪些入参,其实由内部 Call::Arguments 结构统一编排。这个结构不会出现在日常实体代码里,却决定了块定义中的一个参数和两个参数之间的真实差异。理解这条调用链之后,遇到块内变量为 nil、继承后格式化失效等问题,就不用靠试错去猜。

一、Exposure 声明与格式化块的基本链路
在 Grape::Entity 中,expose 是字段声明入口。一个最简单的写法是 expose :id, as: :user_id。如果字段值需要处理,可以传 block,这个块接收实体对象和 options。但 block 的作用是直接生成字段返回值,不享受 format_with 注册的格式化器。另一种写法是 expose :created_at, format_with: :iso_date,它先取字段原始值,再交给 iso_date 格式化器处理。这里的格式化器可以是 Symbol 指向 format_with 注册的方法,也可以直接传一个 Proc。Proc 形式就会在内部被包装成 Formatted::WithBlock 对象,而不是普通 block。
可以看一个基础实体。里面的 format_with :ymd 注册了日期格式化块,expose 两个日期字段时都指定 format_with: :ymd。下面一个普通 block 返回 profile 的 slice,它不走格式化器。这两类块虽然都写在 expose 附近,但在 Grape::Entity 源码中被归入不同执行路径:普通 block 由 Exposure 直接调用,格式化块则要先创建 Call 对象,再由 Call 对象带着参数执行。
class UserEntity < Grape::Entity
format_with :ymd do |date|
date.strftime('%Y-%m-%d')
end
expose :created_at, format_with: :ymd
expose :updated_at, format_with: :ymd
expose :profile do |user, options|
user.profile.slice(:nickname, :avatar)
end
end
当 format_with 接收一个 Proc 时,调用路径会经过 Formatted::WithBlock。这个类内部维护 call 属性,call 又被拆成 formatter 与 arguments。这样做的原因在于格式化块很可能被多处复用,比如父类定义后子类继承,或者同一个实体里多个字段共享一个格式化器。如果把参数直接散落在数组里,一旦目标字段值类型变化或 options 结构不同,就很难统一处理。
二、Call::Arguments 的参数封装与块参数匹配
Call::Arguments 可以理解为一个参数容器。它至少包含 value 与 options,value 是字段原始值,options 是 represent 调用时传进来的顶层选项。还有的实现会附带 context,用于保存当前序列化的上下文。序列化执行到该字段时,Arguments 会根据格式化块的 arity 决定最终传入哪些参数。Ruby 里块通过 |value| 还是 |value, options| 声明参数,arity 分别是 1 和 2。Arguments#to_a 通常会先构造 [value, options],然后裁剪掉 nil 或多余项。
以下是一个结构示意,展示这种封装的基本形态。实际 gem 中的方法名和文件路径可能随版本不同略有变化,但核心概念一致:Arguments 不是简单的数组,而是一个能根据调用需要展开参数的 Struct。它的 to_a 方法会检查 options 是否为 nil,避免把不需要的 nil 传给单参数块。
module Grape
module Entity
class Exposure
module Formatted
class WithBlock
class Call
Arguments = Struct.new(:value, :options, :context) do
def to_a
if value.nil?
[nil]
elsif options.nil?
[value]
else
[value, options]
end
end
end
end
end
end
end
end
end
这种设计解决了一个很实际的问题。如果你写 format_with :money do |value| ... end,块里只处理原值,这时传入两个参数虽然不会报错,但第二个参数会在块内部被忽略,开发者容易误以为 options 不可用。如果写 |value, options|,Call 就把 options 一并传入。因此,当你需要在格式化块中读取 currency、precision 这类 represent 选项时,必须把块的第二个参数写出来,否则 options 不会进入你的作用域。
三、实战:金额、日期与脱敏字段的格式化
接下来用一个订单实体把带块格式化串起来。金额字段以分存储,格式化时除以 100 并拼接币种;日期字段只输出月日;状态字段根据 options 里的 mask 决定是否打码。这里金额格式化器明确写两个参数,第二个参数用于读取 currency。
require 'grape-entity'
class OrderEntity < Grape::Entity
format_with :money do |value, options|
currency = options.fetch(:currency, 'CNY')
format('%s %.2f', currency, value / 100.0)
end
format_with :short_date do |date|
date.strftime('%m-%d')
end
expose :order_no
expose :amount, format_with: :money
expose :discount_amount, format_with: :money
expose :paid_at, format_with: :short_date
expose :status do |order, options|
if options[:mask]
'***'
else
order.status
end
end
end
调用 represent 时,currency 和 mask 会作为 options 传入。金额字段的 Call::Arguments 会收到 value 为 12050 这类整数,options 是包含 currency 的 Hash。格式化块执行后得到字符串。值得注意的是,多个金额字段共用 money 格式化器,但每次调用都创建各自的 Call 对象,value 不同,而 options 来自同一次 represent,所以 currency 可以保持一致。
order = OpenStruct.new( order_no: 'SO10001', amount: 12050, discount_amount: 2050, paid_at: Time.now, status: 'paid' ) json = OrderEntity.represent(order, currency: 'USD', mask: true) puts JSON.pretty_generate(json)
如果想在格式化时忽略 options,可以定义成单参数块,但那样就取不到 currency。这种场景下,Call::Arguments 的裁剪逻辑会直接放弃 options。实际开发里常见错误是块定义时用了单参数,但块体里却写着 options[:currency],Ruby 不会主动给你设置局部变量,于是报错或得到 nil。只要把签名改成 |value, options|,并在第一行写 options ||= {} 做防御,问题就解决。
调试时可以借助 entity.exposures 查看内部对象。这个数组里装着所有暴露字段对应的 Exposure 实例,金额字段会被包装成 Formatted::WithBlock。可以打印该实例,查看 call 和 arguments 的当前 value,帮助确认在进入格式化器之前原值是否符合预期。
entity = OrderEntity.new(order, currency: 'USD', mask: true)
target = entity.exposures.find { |exposure| exposure.attribute == :amount }
p target
四、继承和字段合并时的参数保留问题
实体继承是 Grape::Entity 常见用法,父类定义公共字段,子类补充详情字段。问题在于子类如果重新 expose 同名字段,但忘了写 format_with,就会把父类的格式化 exposure 覆盖掉。比如 BaseOrderEntity 把 amount 声明为金额格式化,DetailOrderEntity 又重新 expose :amount 只返回 order.amount,此时最终输出的是分,而不是格式化后的字符串。这个现象经常被误认为 Call::Arguments 丢了参数,其实是声明被替换。
最稳妥的做法是尽量避免在子类中重复 expose 父类已有字段。如果确实需要改变金额输出,建议注册一个新的格式化器,并在子类中显式覆盖 format_with。这样新的格式化器内部依然通过 Call::Arguments 接收 value 与 options,逻辑清晰。下面的示例演示了这一写法。
class BaseOrderEntity < Grape::Entity
format_with :money_base do |value, options|
format('%.2f', value / 100.0)
end
expose :amount, format_with: :money_base
end
class TaxOrderEntity < BaseOrderEntity
format_with :money_with_tax do |value, options|
format('%.2f (含税)', value / 100.0 * 1.13)
end
expose :amount, format_with: :money_with_tax
end
如果只是想在原格式化结果上追加文本,不建议在子类里用普通 block 覆盖,因为普通 block 不会经过格式化器,调用参数也不同。此时可以考虑把格式化逻辑抽成独立的纯函数,在多个格式化器或 block 中复用。纯函数不依赖实体状态,容易测试,也能减少 Call::Arguments 隐含的 options 传递带来的理解成本。
五、源码阅读与最佳实践
浏览 gem 源码时,可以按照 Exposure -> Formatted -> WithBlock -> Call -> Arguments 这一层级去定位。Exposure 负责字段声明和值获取,Formatted 表示该字段走了格式化分支,WithBlock 表示格式化器是 Proc,Call 是执行单元,Arguments 是参数容器。不同版本命名有差异,甚至可能没有完全匹配的类路径,但把参数封装成独立对象的思想是稳定的。
在实际项目中使用带块格式化时有几个建议:格式化块保持无副作用,不要修改实体本身;需要 options 时显式声明两个参数并做默认值处理;每个格式化器只做一类转换,避免把金额、日期、脱敏都塞进一个块;同名字段继承时注意覆盖关系,优先注册新格式化器而不是直接替换。
回到标题里那个长长的类名,它并不是一段需要死记的 API,而是 Grape::Entity 为规范化块调用参数而设计的内部坐标。理解之后,你不仅能更快读懂序列化堆栈,也能在代码评审时一眼看出哪些块会收到 options,哪些块只会收到原始 value,从而减少隐蔽的展示层缺陷。
Grape EntityExposure格式化带块调用参数修改时间:2026-10-05 16:31:29