HTTP/3已经不再是纸面上的协议,主流浏览器和CDN厂商都在大规模部署。但很多人忽略了一个关键环节:客户端第一次访问某个站点时,走的还是HTTP/2甚至HTTP/1.1,它是怎么知道这个站点其实支持HTTP/3的?这就轮到Alt-Svc(Alternative Services)头出场了。Alt-Svc是HTTP协议中专门用于服务发现和版本协商的机制,服务器通过响应头告诉客户端:我这里还有一个跑在QUIC上的HTTP/3服务,地址是某某端口,你可以试试。本文将深入分析Alt-Svc头的语法结构,并用Ruby实现一个健壮的解析器。

Alt-Svc头的工作原理与语法结构
Alt-Svc头的本质是一种声明式的服务发现机制。当客户端通过TCP上的HTTP/2获取到资源时,服务器在响应中附带Alt-Svc头,声明同一资源还可以通过其他协议、其他主机或端口获取。客户端收到后可以将这条信息缓存起来,后续请求尝试用更优的协议(比如HTTP/3 over QUIC)直连。
标准定义在RFC 7838中,一个典型的Alt-Svc头长这样:
Alt-Svc: h3=":443"; ma=86400, h3-29=":443"; ma=3600
拆解一下语法:h3是协议标识符(ALPN token),双引号包裹的:443表示替代服务的地址,冒号开头表示沿用原主机名,443是端口号。ma参数是max-age的缩写,单位秒,表示这条记录最多可缓存多久。persist参数是个标志位,提示客户端这条记录在网络切换或重启后是否值得持久化保存。多个候选值用逗号分隔,客户端按顺序逐个尝试。
还有几个容易踩坑的细节。第一,协议标识符必须出现在IANA注册的ALPN token列表中,h3、h3-29这类是合法的,乱写的应该被解析器忽略。第二,如果替代服务的端口与原服务相同且协议也相同,这条记录没有意义,直接丢弃。第三,值中的双引号内可能出现转义字符,严格解析时需要处理。第四,ma为0表示立即失效,v参数用于指定QUIC版本列表(虽然现在已较少使用)。
用Ruby实现Alt-Svc头解析器
理解了语法,就可以动手写解析逻辑了。整体思路分三层:先把多个候选值按逗号切分,再对每个候选值提取协议ID和授权信息,最后解析可选参数。下面是完整的实现代码。
require 'uri'
class AltSvcParser
Entry = Struct.new(:protocol, :host, :port, :max_age, :persist, keyword_init: true)
# 已注册的ALPN协议标识符(示例,可按需扩展)
KNOWN_PROTOCOLS = %w[h3 h3-29 h3-30 h2 h2c].freeze
def self.parse(header_value)
return [] if header_value.nil? || header_value.strip.empty?
split_entries(header_value).filter_map { |raw| parse_entry(raw) }
end
# 第一层:按逗号切分候选值,注意逗号可能出现在引号内
def self.split_entries(value)
entries = []
buffer = +''
in_quotes = false
value.each_char do |ch|
case ch
when '"' then in_quotes = !in_quotes; buffer << ch
when ','
if in_quotes
buffer << ch
else
entries << buffer.strip
buffer = +''
end
else buffer << ch
end
end
entries << buffer.strip
entries.reject(&:empty?)
end
# 第二、三层:解析单个候选值
def self.parse_entry(raw)
parts = raw.split(';', 2)
proto_field, params_field = parts
proto, authority = extract_protocol_and_authority(proto_field)
return nil unless proto && authority
params = parse_params(params_field)
host, port = split_authority(authority)
return nil if port.nil? || port <= 0 || port > 65535
Entry.new(
protocol: proto,
host: host,
port: port,
max_age: params[:ma] || 3600,
persist: params[:persist] == 1
)
end
def self.extract_protocol_and_authority(field)
field = field.to_s.strip
return [nil, nil] unless field.include?('=')
proto, authority = field.split('=', 2)
proto = proto.strip
return [nil, nil] unless KNOWN_PROTOCOLS.include?(proto)
authority = authority.to_s.strip
return [nil, nil] unless authority.start_with?('"') && authority.end_with?('"')
[proto, authority[1..-2]]
end
# 解析可选参数:ma、persist等
def self.parse_params(field)
result = {}
field.to_s.split(';').each do |pair|
key, value = pair.split('=', 2)
next if key.nil?
key = key.strip
case key
when 'ma'
result[:ma] = value.to_i
when 'persist'
result[:persist] = value.to_i
end
end
result
end
# 拆分host:port,":443"表示沿用原主机
def self.split_authority(authority)
if authority.start_with?(':')
[nil, authority[1..].to_i]
else
host, port = authority.split(':', 2)
[host, port.to_i]
end
end
end
# 测试
entries = AltSvcParser.parse('h3=":443"; ma=86400, h3-29="alt.ippipp.com:8443"; ma=3600; persist=1')
entries.each { |e| puts "#{e.protocol} -> #{e.host || '(原主机)'}:#{e.port}, ma=#{e.max_age}, persist=#{e.persist}" }这段代码有几个值得注意的设计点。split_entries没有直接用split(','),因为按RFC 8941的定义,逗号如果出现在双引号内部就不是分隔符,手写的状态机逐字符扫描能正确处理这种情况。filter_map会自动丢弃解析失败的候选值,符合规范要求的宽松解析原则:遇到不认识的条目跳过,而不是让整个头失效。
另一个细节是split(';', 2)限定了只切分一次,这样参数值里即使出现分号也不会破坏第一段的协议和地址提取。而KNOWN_PROTOCOLS白名单机制保证了客户端不会去尝试来路不明的协议标识,这也是安全性的基本要求,恶意构造的Alt-Svc头可能诱导客户端连接到不安全的服务。
Alt-Svc缓存管理与Rack中间件集成
解析只是第一步,真正让版本协商跑起来还需要一个缓存层。客户端把解析结果按源站(origin)为键存储,并在每次发请求前检查缓存中是否有可用的替代服务。当缓存条目超过ma设定的存活期后应当被剔除。下面是一个简单的内存缓存实现。
class AltSvcCache
def initialize
@store = {}
end
def set(origin, entry)
expires_at = Time.now + entry.max_age
@store[origin] = { entry: entry, expires_at: expires_at }
end
def get(origin)
record = @store[origin]
return nil if record.nil?
if Time.now >= record[:expires_at]
@store.delete(origin)
return nil
end
record[:entry]
end
def clear
@store.clear
end
end如果你在开发服务端,还可以反过来:用Rack中间件为所有响应自动附加Alt-Svc头,主动向客户端宣告HTTP/3能力。这样做的好处是与业务代码完全解耦。
class AltSvcMiddleware
def initialize(app, port: 443, max_age: 86_400)
@app = app
@port = port
@max_age = max_age
end
def call(env)
status, headers, body = @app.call(env)
headers['Alt-Svc'] = %(h3=":#{@port}"; ma=#{@max_age})
[status, headers, body]
end
end
# 在config.ru中使用
# use AltSvcMiddleware, port: 443
# run MyApp需要注意服务端输出时的几个实践要点:ma值不要设得过长,协议升级或服务下线后,过长的缓存期会让客户端持续尝试连接一个已经失效的端口,造成请求延迟甚至失败,一般86400秒(一天)是比较稳妥的值。如果需要立即让所有客户端放弃替代服务,可以发送Alt-Svc: clear,表示清空该origin的所有缓存记录,解析器在实现时也应单独处理这个特殊值。
版本协商失败的降级策略
Alt-Svc机制的核心假设是协商可能失败。客户端尝试QUIC连接时,UDP流量可能被中间网络设备丢弃,目标端口可能没有开启HTTP/3服务,这时必须优雅降级回TCP通道。一个健壮的实现应该遵循两条规则:一是设置QUIC连接的超时时间,超时后立刻回退,不能让用户干等;二是记录失败次数,短时间内多次失败的origin应标记为不可用,避免每个请求都重复尝试。
Ruby生态里目前对QUIC的支持还不算成熟,net-http标准库尚不支持HTTP/3,可以关注基于QUIC的第三方实现。不过在客户端层面,实现Alt-Svc解析与缓存的价值依然很大:即使暂时无法发起QUIC连接,正确解析并存储这些头信息,也能为后续接入HTTP/3客户端铺平道路,还可以用于监控服务端的协议宣告情况。
总结一下,Alt-Svc是HTTP/3生态中承上启下的一环,向上连接客户端的协议升级决策,向下打通QUIC连接的建立。用Ruby实现解析器的过程中,重点是处理好引号内的分隔符、参数白名单校验和缓存过期这三件事,把这些细节做扎实,服务发现与版本协商就能稳定运行。
RubyHTTP/3Alt-Svc头解析修改时间:2026-09-15 08:16:39