响应把带名字的关系挂到其它资源上。

Link 头把相邻的资源关联起来

Link 响应头描述当前资源与其他资源之间的关系。一个返回 JSON 的接口可以用它给出下一页,让客户端不必解析 HTML,也不必猜测分页参数。这个字段提供目标地址和关系类型,是否访问仍由客户端决定。它是可供理解的元数据,不是强制浏览器下载资源、要求爬虫收录页面或授予访问权限的指令。 链接包含上下文与目标 例如 </items?cursor=b>; rel="next",表示从当前上下文资源指向指定目标。没有显式的 anchor 参数时,上下文通常是被请求的资源。相对目标应根据重定向之后的有效请求地址解析,不能根据程序工作目录或想象中的接口根路径拼接。只包含查询串的目标会替换查询参数而保留路径,这与以斜杠开头、从站点根部解析的地址不同。 rel 指出关系类型,注册关系包括 next、prev、alternate 和 canonical,扩展关系还可以使用 URI 标识。type="application/json" 等参数描述的是目标资源,不会改写当前响应实际的内容类型,也不保证目标可信。如果一个链接通过 anchor 改变了上下文,无法处理这种上下文的客户端就不应擅自把它解释成当前页面的关系,否则链接含义已经改变。 不同关系还对应不同消费者。爬虫可能参考 canonical 选择优先收录地址,支持相应机制的浏览器可能根据 preload 提示提前获取资源,但这两种关系都不会把当前请求变成重定向。需要将字段语法验证与目标消费者的行为验证分开:普通 HTTP 库完全可以正确返回这个头,却不主动跟随或下载任何目标。 分页沿服务端契约前进 分页接口由服务器判断是否存在后续结果,并给出下一次应当请求的完整目标。游标可能编码排序、筛选条件和快照位置,应当作为不透明值使用。客户端自己递增页码、解码后改写游标或丢掉返回链接上的查询条件,都可能跳过数据、重复读取或使请求失效。跟随服务器提供的目标,才能让客户端适应接口内部分页方式的调整。 在具体接口的分页契约下,没有 next 通常意味着本次遍历结束,但不能据此推断整个集合从此不再变化。若没有快照语义,并发插入与删除依然可能改变两次请求之间的成员。采集程序应设置请求上限,并识别反复出现的续页目标,以免服务端错误导致无限循环。遇到限流或失败也不能直接当成最后一页,否则得到的是被误标为完整的残缺集合。 坏掉的样子 一个响应可以用多条 Link 字段,也可以在同一字段中用逗号分隔多个链接。直接按每个逗号切分会出错,因为带引号的参数值、甚至 URI 引用本身都可能包含逗号。应使用符合 Web Linking 语法的解析器,保留重复字段并正确处理关系标记。如果代理把多条字段覆盖成最后一条,正文虽然仍然有效,下一页信息却可能已经丢失。此时应比较源站和代理后的原始头部。 ...

十二月 4, 2026 · 1 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

Server-Timing 向浏览器暴露具名测量结果

Server-Timing 响应头允许服务端给响应附加具名测量结果,浏览器开发者工具和性能接口随后可以把这些结果与对应资源请求关联起来。当网络瀑布图只能显示等待很久,却无法说明后端是否在查询数据库、渲染页面或执行其他操作时,这类信息很有帮助。但它提供的是服务端选择上报的观测结果,并不是浏览器独立测得这些内部操作的耗时,因此仍然需要理解测量范围和产生位置。 正确的时长语法是 db;dur=42, render;dur=81,单位为毫秒。直接写 db=42,并不是这项协议表达时长的方式。每个指标有名称,也可以带时长和描述参数。生产环境应采用含义稳定的名称,从实际插桩中得到数值。描述可以解释测量对象,但不应包含响应本来不该披露的原始查询、身份编号或内部细节。浏览器能够展示字段,不代表所有调试内容都适合跟随响应对外发送。 让数值对应明确的时间区间 服务端的多个测量值不一定能加成浏览器看到的请求总耗时。它们可能覆盖嵌套操作、相互重叠的工作,也可能只覆盖处理路径的一部分。只有数据库时间与渲染时间的定义允许相加时,加总才有意义。网络传输、测量区间之外的排队、缓存和客户端处理,都可能解释两侧数字的差异,并不一定说明某个时钟有错误。应先弄清每个数值从哪里开始、在哪里结束。 这个响应头也不会自动生成分布式链路。它在单个响应上命名测量结果,并没有自动提供父子关系、跨服务传播或采集器存储。可以用它展示少量对请求有帮助的测量,再通过日志或链路关联获取更深的解释。当响应经过缓存和代理时,还应检查最终交付的响应头,确定这些数字由哪个组件产生。读取一个字段与证明当前源站执行了相应工作,是不同层次的证据。 坏掉的样子 跨源资源存在两个容易混淆的权限边界。CORS 允许脚本读取响应,而 Access-Control-Expose-Headers 可以让 response.headers 暴露 Server-Timing 字段。Timing-Allow-Origin 则控制相应的详细资源计时信息是否可见。因此,脚本成功读取响应头,并不能证明 PerformanceResourceTiming.serverTiming 数组一定包含同样的数据。排查时需要分别验证,不能只用其中一个接口有值就推断另一项能力已经成立。 另一个错误,是把解析演示当成真实性能测量。服务端发送固定时长,只能证明浏览器怎样解析这个数字,不能说明处理器实际运行了那么久。同样,计时数组为空,也不能证明服务端没有做任何工作。应先核对来源授权与真实线上响应头,再检查测量代码放置的位置。公开名称与说明还要符合接收对象的范围,避免为了排障而把不该公开的后端结构随响应一起暴露出去。 快速验证 将代码保存为 server-timing.py,执行 python3 server-timing.py –serve;本次使用 Python 3.14.4。脚本在两个不同的临时端口上启动本机回环 HTTP 服务,因此它们是不同来源,然后输出待打开的页面地址。页面会请求两份资源,把响应头内容与浏览器解析出的性能条目同时显示出来。结束后按控制键加 C 关闭两个服务;不带服务参数执行时,只检查响应头并退出,不会自动运行浏览器。 import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Event, Thread from urllib.request import Request, urlopen timing = "db;dur=42, render;dur=81" class API(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): body = b"synthetic timing fixture\n" self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("Access-Control-Allow-Origin", app_origin) self.send_header("Access-Control-Expose-Headers", "Server-Timing") self.send_header("Server-Timing", timing) if self.path.split("?", 1)[0] == "/allow": self.send_header("Timing-Allow-Origin", app_origin) self.end_headers() self.wfile.write(body) class App(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): body = ("""<!doctype html><meta charset="utf-8"><title>Server-Timing lab</title> <h1>Cross-origin Server-Timing</h1><pre id="result">Running...</pre> <script> const api = """ + json.dumps(api_origin) + """; async function run() { performance.clearResourceTimings(); const results = {}; for (const path of ['deny', 'allow']) { const url = api + '/' + path; const response = await fetch(url, {cache:'no-store'}); await response.text(); await new Promise(resolve => requestAnimationFrame(resolve)); const entry = performance.getEntriesByName(url).at(-1); results[path] = {header:response.headers.get('Server-Timing'), timing:entry.serverTiming.map(item => ({name:item.name, duration:item.duration}))}; } window.labResult = results; document.getElementById('result').textContent = JSON.stringify(results, null, 2); } run().catch(error => {window.labError=String(error); document.getElementById('result').textContent=String(error);}); </script>""").encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) api = HTTPServer(("127.0.0.1", 0), API) app = HTTPServer(("127.0.0.1", 0), App) api_origin = f"http://127.0.0.1:{api.server_port}" app_origin = f"http://127.0.0.1:{app.server_port}" threads = [Thread(target=server.serve_forever, daemon=True) for server in (api, app)] for thread in threads: thread.start() try: for path in ("deny", "allow"): with urlopen(Request(api_origin + "/" + path, headers={"Origin": app_origin}), timeout=5) as response: assert response.headers["Server-Timing"] == timing assert response.headers["Access-Control-Allow-Origin"] == app_origin assert response.headers["Access-Control-Expose-Headers"] == "Server-Timing" assert response.headers.get("Timing-Allow-Origin") == (app_origin if path == "allow" else None) response.read() print(json.dumps({"app_url": app_origin, "api_url": api_origin, "wire_checks": "PASS", "durations_are_synthetic": True}), flush=True) if "--serve" in sys.argv: Event().wait() except KeyboardInterrupt: pass finally: for server in (api, app): server.shutdown() server.server_close() for thread in threads: thread.join(timeout=5) 两份响应都通过 CORS 允许页面来源,都显式暴露 Server-Timing,也都携带同样的合成数据库与渲染时长。只有 allow 路径额外提供 Timing-Allow-Origin。在本次 Chrome 152 检查中,两侧脚本都读到 db;dur=42, render;dur=81,但未授权的计时数组为空,已授权数组则包含名为 db、时长四十二,以及名为 render、时长八十一的两项结果。这份浏览器证据与 Python 的线上响应头断言分别检查,避免用非浏览器客户端代替实际可见性验证。 ...

十一月 7, 2026 · 2 分钟 · XB Field Notes