在 Grape 框架的 API 开发中,Entity 层负责把 Ruby 对象转成 JSON 结构。字段值往往不能直接返回,例如时间对象需要转成字符串、金额需要保留两位小数、枚举值需要映射成业务文案。如果在每个接口里手动处理,很容易出现同一字段在不同实体中格式不一致的问题。Grape::Entity 内部为这种场景提供了专门的暴露对象,其中 Formatted 暴露类用于执行格式化逻辑,它通过 format_with 选项把转换动作从实体属性读取中分离出来。

一、从 expose 到 Formatted 暴露对象的触发条件
普通字段暴露只需要调用 expose 方法并传入属性名,Grape::Entity 会创建基础暴露对象来读取模型字段。但当暴露声明中加入 format_with 参数时,内部会实例化 Grape::Entity::Exposure::Formatted 类型的对象。这个类继承自基础暴露类,额外保存格式化器的符号名称,并在取值时查找对应的格式化器对原始值进行加工。
以下代码展示了最简单的用法。先定义一个用户实体,created_at 字段使用 iso8601 格式化器,将 Time 对象转成 ISO 8601 字符串。
class UserEntity < Grape::Entity expose :id expose :nickname expose :created_at, format_with: :iso8601 end
这里只负责声明字段需要格式化,真正的转换逻辑独立在格式化器中。这样一来,实体类本身仍然聚焦于数据结构,而不会因为字段格式化堆满 inline 代码。格式化器还需要注册才能被找到,常见的注册方式是在实体加载时统一配置。
Grape::Entity.format_with :iso8601 do |value| value.respond_to?(:iso8601) ? value.iso8601 : value.to_s end
当实体序列化到 created_at 时,Formatted 暴露对象先读取对象上的 created_at 属性,再把值传给 iso8601 格式化器,最后返回格式化后的字符串。如果 value 是 nil,上面的例子会返回空字符串;如果希望保留 nil,则需要在格式化器内部显式判断。
二、格式化器的三种注册位置与优先级
Grape::Entity 支持在不同层级注册格式化器,使用上的灵活度较高。最通用的方式是挂在 Grape::Entity 基类上,这样所有继承自它的实体都能使用同一个格式化器。适合日期、时间、金额这类跨实体通用的字段格式。
第二种方式是在实体类内部通过 format_with 块直接声明局部格式化器。这样该格式化器只在当前实体可用,不会影响其他实体,适合某些特定业务字段的格式化。例如订单实体中的金额字段,只在这个实体里需要保留两位小数。
module Entities
class OrderEntity < Grape::Entity
format_with :money do |value|
format('%0.2f', value || 0)
end
expose :amount, format_with: :money
expose :paid_at, format_with: :iso8601
end
end
第三种方式是直接给 expose 传入 lambda 对象,而不是注册符号。这种方式省去了注册步骤,但格式化逻辑会内联在暴露声明里,一旦逻辑复杂会降低可读性。因此建议只有极简单的转换才使用 lambda。
expose :tax_rate, format_with: lambda { |value| "#{value}%" }
优先级方面,如果实体内部局部注册了与全局同名的格式化器,实体内部的声明会优先匹配。局部格式化器更适合覆盖全局行为,例如某个实体需要不同的日期精度,就可以在实体内部重新声明 iso8601,而不影响其他实体。
三、Formatted 暴露对象与嵌套实体、条件字段的协作
实际 API 字段往往不是孤立的,很多实体里会嵌套其他实体,或者使用 if、unless 控制字段是否输出。Formatted 暴露对象主要处理标量字段,嵌套实体内部是否格式化取决于被嵌套实体自己的声明。外层实体的 format_with 不会穿透到嵌套实体里,因此不要期待通过外层设置统一格式化嵌套对象的所有字段。
条件字段与格式化同时出现时,需要特别注意执行顺序。Grape::Entity 在判断 if 或 unless 条件时,通常使用原始对象的属性值,而不是格式化之后的值。也就是说,如果你想根据格式化后的文本判断是否输出某个字段,直接写在 if 里是不生效的。
class PurchaseEntity < Grape::Entity
expose :state, format_with: :state_text
expose :refunded, if: lambda { |object, _options| object.state == 'refunded' }
end
上面的实体中,state 字段最终会输出格式化后的文案,但 refunded 字段的判断依据是原始 state 值。这个行为是合理的,因为条件判断在实体暴露流程中较早执行。若确实需要根据格式化结果做判断,可以先把格式化逻辑抽成模型方法或 helper,再让条件判断调用这个方法。
另一个常见的执行顺序问题是空值处理。如果条件判断允许输出字段,但值为 nil,格式化器仍然会被调用。所以每个格式化器都需要能安全处理 nil,否则实体序列化会抛异常。建议在格式化器第一行就处理 nil,或者通过 respond_to? 做防御式检查。
四、设计自定义格式化器的实践建议
格式化器应当保持纯函数特征,只依赖输入值,不应查询数据库、调用外部服务或读写文件。Grape::Entity 在序列化大量记录时,每个对象的每个格式化字段都会执行对应的格式化器,如果格式化器里有重 IO 或 N+1 查询,会直接拖慢接口响应。
如果多个字段需要共享同一个中间计算结果,应该把计算放到实体方法或预先构造的展示对象里,而不是在格式化器中重复计算。格式化器的作用是输出转换,不适合承载业务计算。保持单一职责,后续需要调整输出格式时,只需要改格式化器即可。
例如下面这个实体中,标题和分类名称都截断到 10 个字符,逻辑非常简单,可以共用同名格式化器。
class ReportEntity < Grape::Entity
format_with :short_name do |name|
name.to_s[0, 10]
end
expose :title, format_with: :short_name
expose :category_name, format_with: :short_name
end
此外,给格式化器写单元测试时,直接调用格式化器块或者使用实体序列化结果断言即可。重点覆盖 nil 值、空字符串、非预期类型等边界情况。格式化逻辑稳定后,接口返回的字段格式会变得高度可预期,前端处理起来也会轻松很多。
总结来看,Grape::Entity::Exposure::Formatted 把字段格式化从实体编排中解耦出来,配合 format_with 和全局或局部注册的格式化器,能比较优雅地处理 API 输出字段的格式统一问题。掌握它的触发条件、优先级和与条件字段的协作规则,可以避免不少序列化阶段才能暴露出来的意外行为。
Grape::EntityExposure::Formatted字段格式化修改时间:2026-09-17 03:38:05