广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何用Hanami::Action::Mime实现自定义内容协商?

Web 应用中的内容协商经常被简化成“前端要 JSON、浏览器要 HTML”的简单二分。实际上 HTTP 的 Accept 头允许客户端用 q 值携带优先级,服务端需要在多个候选格式里选择最合适的一个。Hanami 把这件事下沉到了 Action 层,通过 Hanami::Action::Mime 统一处理 Accept 解析、格式匹配和匹配失败后的响应。这样做的好处是,路由和 Action 中不再散落大量对请求头的条件判断,新增一种导出格式也不需要改动每个控制器。

如何用Hanami::Action::Mime实现自定义内容协商?

解析 Accept 头:优先级与匹配链路

一段典型的 Accept 头可能长这样:application/json, text/html;q=0.9, */*;q=0.8。它表示客户端最想要 JSON,如果服务端不提供 JSON,可以退回到 HTML,再不行接受任意格式。这里的 q 值范围是 0 到 1,数值越高优先级越高,未写 q 值的类型默认按 1 处理。解析这段字符串时,Hanami::Action::Mime 会先把媒体类型和 q 值拆开,按照优先级排序,得到一个候选格式列表。

随后这个候选列表会与当前 Action 允许响应的格式做匹配。Action 中可以显式声明自己支持哪些格式,也可以使用框架默认支持的 JSON、HTML、XML 等常见类型。匹配结果会被放入请求对象,后续代码通常通过 req.format 读取。Hanami 使用符号标识格式,例如 :json、:html、:xml。这样处理的好处是,业务代码不需要认识 application/json 这种完整字符串,只需要与内部符号打交道,注册新的 MIME 类型时也更容易统一。

下面是一个基础 Action 的格式分支示例:

class Users::Show < Hanami::Action
  def handle(req, res)
    user = UserRepo.find(req.params[:id])
    case req.format
    when :json
      res.format = :json
      res.body = JSON.generate(user)
    when :html
      res.format = :html
      res.body = render_user(user)
    else
      res.status = 406
      res.body = "Not Acceptable"
    end
  end
end

这里 req.format 是已经协商好的内部格式符号。如果客户端发送的 Accept 头是 text/html;q=0.9, application/json;q=0.8,而 Action 同时支持 HTML 和 JSON,那么最终 req.format 会是 :html,因为它的优先级更高。通配符 */* 会被作为兜底选项,但只有在没有更具体的媒体类型匹配时才生效。

注册自定义 MIME 类型

默认格式通常能满足网页和基础接口,但真实业务里经常会出现 CSV、iCalendar、PDF 或 JSON API 规范中的 application/vnd.api+json。如果客户端发送了这类 Accept 头,而 Hanami 内部没有对应映射,匹配就会失败,甚至可能被通配符错误地吸引到普通 JSON 分支。因此需要提前注册自定义 MIME 类型。

注册动作一般放在应用的初始化器中,例如 config/initializers/mime_types.rb。Hanami::Action::Mime 允许把内部符号与标准或私有媒体类型绑定。一个常见写法如下:

# config/initializers/mime_types.rb
Hanami::Action::Mime.add(
  :csv,
  "text/csv"
)
Hanami::Action::Mime.add(
  :json_api,
  "application/vnd.api+json"
)

注册完成后,Action 层就可以把 :csv、:json_api 当作一等格式来使用。对于携带参数或结构化后缀的媒体类型,Hanami 依然能够进行精准匹配。比如 application/vnd.api+json 虽然本质上仍是 JSON,但它拥有独立的语义,客户端和服务端可以约定该类型只表示 JSON API 风格的响应,普通 application/json 则用于其他接口。把它们拆成两种格式符号,可以避免在代码里反复判断版本号。

自定义类型也会参与 Accept 头的优先级排序。如果客户端发送 application/vnd.api+json;q=1.0, application/json;q=0.9,而 Action 同时注册了 :json_api 和 :json,最终结果会优先选中 :json_api。这种机制让私有协议格式不必依赖 URL 路径或查询参数来区分,接口设计会更贴近 HTTP 语义。

响应生成与 406 处理

内容协商的最终目的是生成响应。Hanami 中可以通过 res.format = :json 直接设置响应格式,框架会自动补充对应的 Content-Type 头。自动写入 Content-Type 有两个好处:一是保证客户端拿到的媒体类型与请求阶段协商出的结果一致,二是避免开发者手动拼接字符串时遗漏字符集信息。对于文本类型,通常还会附带 charset=utf-8,减少中文内容出现乱码的概率。

但如果协商失败,例如客户端只接受 application/xml,而 Action 只支持 JSON,继续返回 JSON 会让客户端无法解析。更合适的做法是返回 406 Not Acceptable。HTTP 规范中 406 表示服务端无法提供客户端可接受的表示形式。下面是一个明确的处理示例:

def handle(req, res)
  users = UserRepo.export_list
  if req.format == :csv
    res.headers["Content-Type"] = "text/csv"
    res.headers["Content-Disposition"] = "attachment; filename=users.csv"
    res.body = build_csv(users)
  else
    res.status = 406
    res.body = "This endpoint only accepts text/csv"
  end
end
private
def build_csv(users)
  lines = ["id,name,email"]
  users.each do |user|
    lines << "#{user.id},#{user.name},#{user.email}"
  end
  lines.join("\n")
end

返回 406 时不要只给一个空状态,最好附带简短说明,例如支持哪些格式。这样客户端调试时可以快速定位问题,而不必猜测服务端能力。还有一点需要留意:如果客户端在 Accept 头中写了 text/csv;q=0,这表示客户端明确不接受 CSV。即使服务端只支持 CSV,也不应该返回 CSV,而应返回 406。q=0 的含义不是低优先级,而是拒绝。

实战:CSV 导出与 API 版本控制

CSV 导出是一个典型的内容协商场景。报表页面在浏览器中看 HTML,但下载数据时希望得到 CSV。可以把下载接口设计为只接受 text/csv,用 Accept 头区分用途。例如 GET /reports/users 默认返回 HTML,当客户端发送 Accept: text/csv 时,服务端返回文件内容。这样做比额外加一个 /reports/users.csv 路径更符合 HTTP 资源单一的思路。

API 版本控制同样可以从自定义 MIME 类型中受益。很多团队会使用形如 application/vnd.myapp.user.v1+json 的媒体类型标识 API 版本。注册之后,Action 中只判断 req.format 就能区分不同版本,无需在 URL 里加 v1 或 v2 路径段。示例:

# 注册版本化私有 MIME 类型
Hanami::Action::Mime.add(
  :user_v1,
  "application/vnd.myapp.user.v1+json"
)
class Users::Index < Hanami::Action
  def handle(req, res)
    if req.format == :user_v1
      res.format = :json
      res.body = serialize_v1(UserRepo.all)
    else
      res.status = 406
      res.body = "Unknown API version"
    end
  end
end

这种做法虽然前期需要多写一点注册代码,但长期来看可以把版本判断集中到 MIME 层,Action 中的业务逻辑保持干净。需要新增 v2 时,只需注册新的媒体类型并增加一个序列化器,不会影响既有 v1 接口。同时,因为版本信息位于 Accept 头而不是 URL,客户端更换版本不会改变资源地址,缓存和监控配置也更稳定。

内容协商不是银弹,尤其要避免无条件信任通配符。在设计公开 API 时,建议每个 Action 显式声明允许的格式列表,并拒绝未声明的格式。否则 */* 可能会掩盖客户端没有明确需求的事实,导致服务端返回不可预测的默认格式。Hanami::Action::Mime 的价值恰恰在于把这种声明变成结构化的配置,让内容协商从零散的条件判断变成可维护的格式策略。

广告位 · 文章底部(后台-模块管理替换此区域)