HTTPX是Ruby生态里一个比较现代的HTTP客户端库,它的插件机制允许开发者按需组合功能。其中ResponseCache插件负责响应缓存,而缓存是否命中,很大程度取决于缓存键的生成是否稳定。HTTP头字段从协议层面来说是不区分大小写的,服务器可能返回Content-Type,也可能返回content-type,如果直接拿原始字符串去拼缓存键,同一个逻辑头会产生两个不同的键,缓存命中率会大幅下降。所以KeyGenerator内部在处理Vary相关头时,引入了大小写不敏感的排序逻辑,本文就来拆解这套实现。

为什么缓存键生成前必须处理头的大小写
先看问题的根源。假设服务器返回的响应带有Vary: Accept-Encoding,客户端下一次请求携带的头是accept-encoding: gzip。如果KeyGenerator直接用字符串原样参与计算,这两个请求生成的缓存键就是两个不同的值,明明语义上它们应该命中同一条缓存记录。
HTTP/1.1的RFC 7230明确规定,字段名是大小写不敏感的。HTTP/2更进一步,直接要求所有头名必须序列化为小写。也就是说,一个健壮的缓存实现,必须在生成键之前把头名归一化。HTTPX的ResponseCache插件正是基于这个前提设计的:所有参与缓存键计算的头,都要先统一大小写,再排序,最后拼接或哈希。
排序这一步同样关键。Hash在Ruby中保持插入顺序,但如果两次响应中头的到达顺序不同(这在代理、重定向场景下很常见),不排序就会得到不同结果。先归一化、后排序,才能保证同样的头集合无论以什么顺序、什么大小写形式出现,都生成同一个键。
大小写不敏感排序的两种实现思路
在Ruby里实现大小写不敏感排序,最直接的方式是先downcase再sort。比如headers.map { |k, v| [k.downcase, v] }.sort,这种写法会创建新的中间数组,GC压力相对大一些,但逻辑清晰,Ruby 3.x的字符串性能下完全够用。
require "httpx"
# 模拟对响应头做大小写归一化加排序
def normalized_headers(response)
response.headers.each.map { |k, v| [k.downcase, v] }.sort
end
# 用归一化后的头生成稳定的缓存键
def cache_key(request, response)
vary = response.headers["vary"].to_s.split(",").map { |h| h.strip.downcase }.sort
Digest::SHA256.hexdigest(
[request.uri.to_s, vary.join("|"), normalized_headers(response).to_s].join("::")
)
end
另一种思路是利用casecmp?方法做无内存分配的比较,也就是array.sort { |a, b| a.casecmp(b) }。casecmp返回整数,正好符合sort块的要求,而且不需要创建downcase后的新字符串。对于头数量不多的一般场景,两者差距在微秒级;但如果是高并发下的缓存中间件,每秒要处理成千上万次键生成,casecmp方案的无分配特性就有实际价值了。
还有一种容易被忽视的坑:Unicode头值中可能包含非ASCII字符,直接downcase对某些多字节字符串的行为与预期不一致。虽然HTTP头值大多是ASCII,但如果你的应用层把自定义头塞进了缓存键,就要留意这一点,必要时显式指定编码。
Array在键生成链路中的角色与常见误用
整条模块链的最后落在Array上,是因为排序和拼接的操作对象本质上都是数组。头集合先被展开成[name, value]对的数组,再经过归一化、排序、flatten,最后通过join或序列化进入哈希函数。理解了这一点,就能看懂为什么插件作者要把逻辑拆成这么多层,每层只做一件事:Headers负责收集,Vary负责筛选需要参与键的头,Sort负责顺序稳定,CaseInsensitive负责归一化,Array负责承载。
常见误用之一是对嵌套数组直接调用flatten。如果头值本身是数组(Set-Cookie就可能出现多值),flatten会把所有层级拍平,破坏结构。正确的做法是用flatten(1)限定深度,或者在展开时就明确每层的结构。
# 多值头的安全展平写法
def safe_flatten(headers)
headers.map { |k, vs| [k.downcase, Array(vs).map(&:to_s).sort] }
end
# 错误示例:flatten会破坏[名称, 值数组]的结构
# ["set-cookie", ["a=1", "b=2"]].flatten
# => ["set-cookie", "a=1", "b=2"] 结构信息丢失
另一个误用是忘记了sort的稳定性问题。Ruby的Array#sort本身不保证稳定排序,对于头这种键唯一的数据影响不大,但如果你的键生成逻辑中存在重复头名,就要改用sort_by配合带下标的复合键,确保排序结果确定。
自己实现一个精简版的键生成器
理解了原理之后,不妨自己动手写一个不依赖HTTPX内部的精简实现,方便移植到其他HTTP客户端上。核心点只有三个:头名统一小写、头集合排序、多值头内部排序。
require "digest"
class SimpleCacheKey
def initialize(request_uri:, headers:, vary: [])
@request_uri = request_uri
@headers = headers
@vary = vary.map { |v| v.to_s.strip.downcase }
end
def call
Digest::SHA256.hexdigest(
[@request_uri, selected_headers.to_s, @vary.sort].join("::")
)
end
private
def selected_headers
return normalized_all if @vary.empty?
normalized_all.select { |k, _| @vary.include?(k) }
end
def normalized_all
@headers.map { |k, v| [k.to_s.downcase, Array(v).map(&:to_s).sort] }.sort
end
end
key = SimpleCacheKey.new(
request_uri: "https://ipipp.com/api/data",
headers: { "Content-Type" => "application/json", "X-Trace" => "abc" },
vary: ["content-type"]
).call
puts key
这个实现不到四十行,却涵盖了前面讨论的全部要点。实际项目中,你还可以在此基础上加入请求方法、Accept-Encoding白名单等维度。HTTPX把这些细节藏在插件深处,是为了让使用者开箱即用;而读懂它的实现,能让你在排查缓存不命中的问题时,第一时间想到去检查头的大小写与顺序,这才是研究这条模块链的真正价值。