← 中文资源

简体中文版本

HTTP 尾部字段(Trailer)

尾部字段是在分块或流式响应体之后发送的头部,用于上报只有响应体生成完毕才可确定的校验和或状态信息。

阅读英文原文

概念概述

HTTP 尾部字段(Trailer)(HTTP Trailer Fields)指的是:尾部字段是在分块或流式响应体之后发送的头部,用于上报只有响应体生成完毕才可确定的校验和或状态信息。

从更严格的定义看,A trailer is a set of header fields placed at the end of a message body, announced up front in a Trailer header, that carries metadata computed while the body is streamed. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。

为什么需要它

They are used by gRPC and streaming responses to deliver a final status or integrity value after the payload without buffering the whole body first. 它与 CAA Records Constraining Certificates、Equal-Cost Multipath Routing、TCP Zero Window and Flow Control 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。

常见误解

  • trailers are available on every response regardless of framing
  • trailers are just a slower way to send ordinary headers

需要避免的反模式

  • relying on trailers when the response uses a fixed Content-Length
  • assuming client libraries surface trailer values automatically

实践要点

  1. Trailers require a self-delimiting body, typically chunked transfer coding, because a fixed-length body has no framing point at which to introduce the final fields.
  2. Many clients and intermediaries drop trailers silently, so an implementation must not place essential control information only there unless it has verified the whole path supports them.
  3. gRPC encodes status and error details in trailers, which is why proxies that do not propagate them can turn a streamed call into an opaque failure.

参考来源

https://datatracker.ietf.org/doc/html/rfc9110#section-6.5

代码示例

export interface ByteRange {
  readonly start: number;
  readonly end: number;
}

export type RangeParseResult =
  | { kind: "none" }
  | { kind: "invalid"; reason: string }
  | { kind: "unsatisfiable"; reason: string }
  | { kind: "partial"; ranges: ByteRange[] };

function parseInteger(value: string): number | null {
  if (!/^\d+$/.test(value)) return null;
  const parsed = Number(value);
  return Number.isSafeInteger(parsed) ? parsed : null;
}

export function parseRangeHeader(
  header: string | null | undefined,
  size: number,
): RangeParseResult {
  if (!Number.isSafeInteger(size) || size < 0) {
    throw new RangeError("size must be a non-negative safe integer");
  }
  if (header === null || header === undefined) {
    return { kind: "none" };
  }
  if (typeof header !== "string") {
    throw new TypeError("Range header must be a string");
  }

  const value = header.trim();
  if (value.length === 0) {
    return { kind: "invalid", reason: "empty Range header" };
  }
  const match = /^bytes=(.+)$/i.exec(value);
  if (match === null) {
    return {
      kind: "invalid",
      reason: "only bytes ranges are supported",
    };
  }

  const specs = match[1].split(",").map((item) => item.trim());
  if (specs.length === 0 || specs.length > 16) {
    return {
      kind: "invalid",
      reason: "expected between 1 and 16 ranges",
    };
  }

  const ranges: ByteRange[] = [];
  let hadUnsatisfiable = false;

  for (const spec of specs) {
    if (spec.length === 0) {
      return { kind: "invalid", reason: "empty range specification" };
    }

    if (spec.startsWith("-")) {
      const suffix = parseInteger(spec.slice(1));
      if (suffix === null || suffix === 0) {
        return {
          kind: "invalid",
          reason: `invalid suffix range "${spec}"`,
        };
      }
      if (size === 0) {
        hadUnsatisfiable = true;
        continue;
      }
      const length = Math.min(suffix, size);
      ranges.push({ start: size - length, end: size - 1 });
      continue;
    }

    const separator = spec.indexOf("-");
    if (separator < 1) {
      return { kind: "invalid", reason: `invalid range "${spec}"` };
    }
    const start = parseInteger(spec.slice(0, separator));
    const endText = spec.slice(separator + 1);
    if (start === null) {
      return { kind: "invalid", reason: `invalid range "${spec}"` };
    }
    if (start >= size) {
      hadUnsatisfiable = true;
      continue;
    }
    if (endText.length === 0) {
      ranges.push({ start, end: size - 1 });
      continue;
    }

    const end = parseInteger(endText);
    if (end === null || end < start) {
      return { kind: "invalid", reason: `invalid range "${spec}"` };
    }
    ranges.push({ start, end: Math.min(end, size - 1) });
  }

  if (ranges.length === 0) {
    return {
      kind: "unsatisfiable",
      reason: hadUnsatisfiable
        ? "no range overlaps the resource"
        : "no ranges requested",
    };
  }

  ranges.sort((left, right) => left.start - right.start || left.end - right.end);
  const merged: ByteRange[] = [];
  for (const range of ranges) {
    const last = merged[merged.length - 1];
    if (last !== undefined && range.start <= last.end + 1) {
      merged[merged.length - 1] = {
        start: last.start,
        end: Math.max(last.end, range.end),
      };
    } else {
      merged.push(range);
    }
  }
  return { kind: "partial", ranges: merged };
}

const cases: Array<[string | null, number]> = [
  ["bytes=0-499", 1000],
  ["bytes=500-", 1000],
  ["bytes=-200", 1000],
  ["bytes=0-0,900-999", 1000],
  ["bytes=2000-3000", 1000],
  ["items=0-10", 1000],
];
for (const [header, size] of cases) {
  console.log(header, parseRangeHeader(header, size));
}
export function parseRange(header, size) {
  if (!header || typeof header !== 'string') return null;
  const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
  if (!match) return { unsatisfiable: true };
  const [, startRaw, endRaw] = match;
  if (startRaw === '' && endRaw === '') return { unsatisfiable: true };
  let start;
  let end;
  if (startRaw === '') {
    const suffix = Number(endRaw);
    if (suffix === 0) return { unsatisfiable: true };
    start = Math.max(size - suffix, 0);
    end = size - 1;
  } else {
    start = Number(startRaw);
    end = endRaw === '' ? size - 1 : Number(endRaw);
  }
  if (start > end || start >= size) return { unsatisfiable: true };
  return { start, end: Math.min(end, size - 1), length: Math.min(end, size - 1) - start + 1 };
}