Hanami::Action 在处理请求时依赖一个全局的 MIME 类型表来决定如何解析请求体和渲染响应。常见的 text/html、application/json、application/xml 已经在框架启动时自动注册,通过 Accept 头协商或者 URL 中的 format 参数就能直接命中。然而当 API 需要支持第三方系统约定的专用类型,例如 application/vnd.api+json 或者 application/x-ndjson,这张默认表就显得不够用了。此时如果直接在每个动作里写 Content-Type 判断,代码会迅速膨胀,而且容易漏掉请求体解析环节。

Hanami::Action::Mime::Type::Register 正是为了集中解决自定义 MIME 类型而提供的注册类。它把别名、完整媒体类型字符串、文件扩展名和默认字符集打包成一个类型对象,插入到框架运行时使用的类型表中。注册生效后,内容协商、格式识别和参数解析都会自动承认新类型,动作层无需再关心原始字符串匹配。
注册入口与调用时机
Register 类位于 Hanami::Action::Mime::Type 命名空间下,通常通过 call 类方法调用,也可以使用 new 实例化后再调用。注册的最佳时机是应用启动阶段,推荐放在 config/application.rb 的 prepare 块或专用初始化文件里。不要在每次请求处理过程中重复注册,因为类型表在内存中是共享的,重复调用虽然不会造成严重错误,但会带来不必要的性能开销,并且如果业务代码中有条件注册逻辑,还可能产生难以预测的顺序问题。
Register 接受的参数比较直观。第一个位置参数是内部使用的符号名,比如 :jsonapi;第二个是完整 MIME 字符串,由类型和子类型组成,必须全部小写。关键字参数 extensions 接收扩展名数组,用于根据 URL 后缀识别格式,例如 /articles/1.jsonapi 会得到 :jsonapi 格式。charset 指定默认字符集,在生成 Content-Type 响应头时会自动拼接。
Hanami::Action::Mime::Type::Register.call( :jsonapi, 'application/vnd.api+json', extensions: ['jsonapi'], charset: 'utf-8' )
这段代码完成了一个最小注册。执行后,框架内部的 MIME 类型表会多出一条记录,后续请求只要匹配到该类型字符串,就会映射到 :jsonapi 这个符号。如果省略扩展名,URL 后缀识别功能就不会生效,但 Accept 头协商仍然可用,需要根据实际场景决定是否保留。
注册新 MIME 类型别名的完整写法
在实际项目里,通常会一次性注册多个自定义类型。下面是一个放在应用初始化阶段的完整示例,同时注册 JSON API 类型和新行分隔 JSON 类型。需要特别注意的是,注册代码必须保证在应用开始接收请求之前执行完毕,否则前几个请求可能因为类型尚未注册而出现协商失败。
# config/application.rb
require "hanami/action"
module MyApp
class Application < Hanami::App
prepare do
Hanami::Action::Mime::Type::Register.call(
:jsonapi,
'application/vnd.api+json',
extensions: ['jsonapi'],
charset: 'utf-8'
)
Hanami::Action::Mime::Type::Register.call(
:ndjson,
'application/x-ndjson',
extensions: ['ndjson'],
charset: 'utf-8'
)
end
end
end
注册完成之后,类型表就具备了识别这两个新类型的能力。可以通过 Hanami::Action::Mime::Type[:jsonapi] 获取注册对象,或者使用 Hanami::Action::Mime::Type.registered 遍历完整列表。别名必须是 Symbol,媒体类型字符串区分大小写,应遵循 RFC 规范,避免出现空格或特殊字符。
扩展名和媒体类型并不是严格一对一的关系。例如同一个媒体类型理论上有多个扩展名时,可以把它们都放进数组,框架会按照数组顺序尝试匹配。反过来,不同媒体类型最好不要共用同一个扩展名,否则会造成格式歧义。如果出现冲突,后注册的类型会覆盖先前的映射,这一点在调试时要留意。
请求解析与响应渲染如何利用新类型
一旦自定义类型注册完成,动作层就可以通过 req.format 或 req.accept? 来判断客户端期望的格式。与直接解析 Accept 头字符串相比,这种符号化比较更清晰,也更容易维护。下面是一个根据格式返回不同内容的动作示例。
class Articles::Show < Hanami::Action
def handle(req, res)
if req.format == :jsonapi
res.headers['Content-Type'] = 'application/vnd.api+json'
res.body = JSON.generate(data: { id: req.params[:id], title: 'Hanami MIME' })
else
res.status = 406
res.body = 'Not Acceptable'
end
end
end
当请求头包含 Accept: application/vnd.api+json 时,框架会把 req.format 解析为 :jsonapi,动作层无需再写字符串比对。返回响应时手动设置 Content-Type 可以保证客户端得到预期媒体类型,因为框架在部分场景下不会自动根据 format 设置响应头。如果你的项目启用了响应渲染器,也可以依赖自动渲染,但手动设置更容易排查问题。
请求体解析同样受类型表影响。如果客户端使用 POST 发送 application/x-ndjson 内容,只有该类型已经注册,解析器才会知道如何处理。否则请求体可能被当作未知类型而无法读取。验证响应头可以用 curl,下面是一个简单命令,注意 Accept 头必须与注册字符串完全一致。
curl -i http://127.0.0.1:2300/articles/1 \ -H 'Accept: application/vnd.api+json'
响应头中如果出现 Content-Type: application/vnd.api+json; charset=utf-8,说明注册已经生效。如果仍然返回 406,需要检查注册时机和类型字符串是否完全匹配,包括是否少了 +json 后缀。
常见误区与排查手段
开发中最常见的做法是在动作里直接设置 Content-Type,却忘了执行 MIME 注册。这样虽然响应头看着正确,但一旦客户端在 Accept 中声明了该类型,内容协商阶段仍会失败,因为框架根本不知道这个类型存在。解决方式是统一在初始化阶段注册,而不是散落在控制器里。
另一个容易忽略的问题是字符集覆盖。如果你注册时指定了 charset: 'utf-8',框架会在响应头中自动追加 charset=utf-8。但如果动作里手动写死 Content-Type 时又加了一次 charset,可能出现重复或者冲突的字符集标注。保持注册参数和动作层响应头设置一致,可以避免这类细节问题。
排查自定义类型是否注册成功,最简单的方法是在应用启动后打印类型表。下面这段代码可以放在初始化脚本末尾,观察输出中是否包含你新增的符号名和媒体类型字符串。
Hanami::Action::Mime::Type.registered.each do |type|
puts "#{type.name} => #{type.mime_type}"
end
如果打印结果里出现两个符号指向同一个媒体类型,或者扩展名出现重复,需要回到 Register 调用处调整参数。保持类型表现清晰,比事后在日志中寻找协商错误要高效得多。掌握了这些排查方法,自定义 MIME 类型的支持就可以稳定地集成到 Hanami 应用里。
Hanami::ActionMIME类型别名注册修改时间:2026-09-28 22:00:13