概念概述
应用层协议协商(ALPN)(Application-Layer Protocol Negotiation (ALPN))指的是:ALPN 在 TLS 握手中协商 HTTP/2 或 HTTP/3 等应用层协议,双方在一次握手中确定协议,避免建立连接后再额外往返进行协议升级。
从更严格的定义看,ALPN is a TLS extension that lets the client advertise a list of application protocols and the server pick one during the handshake, so both ends agree on HTTP/2, HTTP/1.1, or HTTP/3 without a separate negotiation request. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。
为什么需要它
It is the mechanism browsers and servers use to decide which HTTP version a connection will speak. 它与 PROXY Protocol for Client Address Preservation、Server-Timing Metrics Header、Hop-by-Hop Versus End-to-End Headers 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。
常见误解
- ALPN inspects packet payloads to choose a protocol
- an HTTP upgrade header is still required after the handshake
需要避免的反模式
- advertising a protocol the origin cannot actually speak end to end
- mismatching ALPN IDs between a load balancer and its backends
实践要点
- The client sends a protocol list in the ClientHello and the server answers with exactly one selection; if no selection is made, both sides fall back to HTTP/1.1.
- Because the choice happens at the TLS layer, a proxy that advertises h2 on the client side must also be able to forward HTTP/2 semantics or deliberately downgrade the protocol toward the backend.
- For HTTP/3 the connection actually runs over QUIC, so ALPN advertises h3 while the transport is UDP, and servers that terminate QUIC dedicate UDP listeners separate from TCP.
参考来源
https://datatracker.ietf.org/doc/html/rfc7301
代码示例
export function parseAcceptHeader(header) {
if (typeof header !== "string" || header.trim() === "") {
return [{ type: "*", subtype: "*", q: 1, params: {}, order: 0 }];
}
const ranges = [];
for (const [order, part] of header.split(",").entries()) {
const segments = part.split(";").map((segment) => segment.trim());
const mediaRange = segments.shift();
if (!mediaRange) continue;
const slash = mediaRange.indexOf("/");
if (slash <= 0 || slash === mediaRange.length - 1) {
throw new SyntaxError("invalid media range: " + mediaRange);
}
const type = mediaRange.slice(0, slash).toLowerCase();
const subtype = mediaRange.slice(slash + 1).toLowerCase();
const params = {};
let q = 1;
for (const segment of segments) {
if (!segment) continue;
const equals = segment.indexOf("=");
if (equals <= 0) throw new SyntaxError("invalid parameter: " + segment);
const name = segment.slice(0, equals).trim().toLowerCase();
const raw = segment.slice(equals + 1).trim().replace(/^"|"$/g, "");
if (name === "q") {
q = Number(raw);
if (!Number.isFinite(q) || q < 0 || q > 1) {
throw new RangeError("invalid q value: " + raw);
}
} else {
params[name] = raw;
}
}
ranges.push({ type, subtype, q, params, order });
}
if (ranges.length === 0) throw new SyntaxError("Accept header has no media ranges");
return ranges;
}
export function negotiateContentType(header, supported) {
if (!Array.isArray(supported) || supported.length === 0) {
throw new TypeError("supported must be a non-empty array");
}
const ranges = parseAcceptHeader(header);
const candidates = [];
for (const [order, mediaType] of supported.entries()) {
const slash = mediaType.indexOf("/");
if (slash <= 0) throw new TypeError("invalid supported media type: " + mediaType);
const type = mediaType.slice(0, slash).toLowerCase();
const subtype = mediaType.slice(slash + 1).toLowerCase();
let best;
for (const range of ranges) {
const typeMatches = range.type === "*" || range.type === type;
const subtypeMatches = range.subtype === "*" || range.subtype === subtype;
if (!typeMatches || !subtypeMatches) continue;
const specificity = (range.type === "*" ? 0 : 1) + (range.subtype === "*" ? 0 : 1);
if (!best || specificity > best.specificity ||
(specificity === best.specificity && range.order < best.order)) {
best = { q: range.q, specificity, order: range.order };
}
}
if (best && best.q > 0) {
candidates.push({ mediaType, q: best.q, specificity: best.specificity, order });
}
}
candidates.sort((a, b) => b.q - a.q || b.specificity - a.specificity || a.order - b.order);
return candidates.length > 0 ? candidates[0].mediaType : null;
}
export function retryDelay(header, { now = Date.now(), maxDelayMs = 60000, fallbackMs = 1000 } = {}) {
if (header === undefined || header === null || String(header).trim() === '') return fallbackMs;
const raw = String(header).trim();
if (/^\d+$/.test(raw)) {
return Math.min(Number(raw) * 1000, maxDelayMs);
}
const when = Date.parse(raw);
if (Number.isNaN(when)) return fallbackMs;
return Math.min(Math.max(when - now, 0), maxDelayMs);
}
export async function fetchWithRetry(url, options = {}, maxAttempts = 3) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(url, options);
if (res.status !== 429 && res.status < 500) return res;
if (attempt === maxAttempts) return res;
lastError = res;
const delay = retryDelay(res.headers.get('retry-after'));
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw lastError;
}