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 语法的解析器,保留重复字段并正确处理关系标记。如果代理把多条字段覆盖成最后一条,正文虽然仍然有效,下一页信息却可能已经丢失。此时应比较源站和代理后的原始头部。
另一类故障是盲目跟随绝对地址。携带身份信息的接口客户端必须先检查目标是否符合访问策略,再决定是否发起请求以及能否转发凭据。一个链接并没有授权程序把令牌发送到其他源。浏览器还有额外边界:跨源请求能够读取正文,不代表脚本能读取 Link;服务器需要在 Access-Control-Expose-Headers 中明确暴露它。只用命令行看见字段,无法验证浏览器里的读取权限。
快速验证
本轮复核核对了协议与示例实现,没有重新运行本地监听或浏览器 CORS 检查。
以下 Python 3 实验启动一个两页的回环接口。第一页分别返回 next 和 alternate 两条字段,末页不返回链接。客户端检查原始字段,并用标准 URL 库解析实验里已知的相对目标;这段代码不是通用链接解析器,不能照搬其中的固定字符串假定来解析任意接口。
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.parse import urljoin, urlsplit
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Length", "2")
if self.path == "/items?cursor=a":
self.send_header("Link", '<?cursor=b>; rel="next"')
self.send_header("Link", '</items.csv>; rel="alternate"')
self.end_headers()
self.wfile.write(b"[]")
def log_message(self, *args):
pass
with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server:
worker = Thread(target=server.serve_forever, daemon=True)
worker.start()
client = HTTPConnection(*server.server_address, timeout=3)
try:
client.request("GET", "/items?cursor=a")
response = client.getresponse()
links = response.headers.get_all("Link")
assert response.read() == b"[]"
assert links == ['<?cursor=b>; rel="next"',
'</items.csv>; rel="alternate"']
base = f"http://127.0.0.1:{server.server_port}/items?cursor=a"
target = urlsplit(urljoin(base, "?cursor=b"))
client.request("GET", target.path + "?" + target.query)
response = client.getresponse()
assert response.read() == b"[]"
assert response.headers.get_all("Link") is None
print("two fields preserved; terminal page has no next link")
finally:
client.close()
server.shutdown()
worker.join()
预期没有断言失败,并打印最后一行确认信息。查询目标应解析成 /items?cursor=b,第一页的两条字段都应保留。实验验证了响应构造、重复字段读取和相对地址解析,没有证明完整分页客户端已经正确。真实解析器还要覆盖引号内逗号、重定向、多关系、跨源目标和循环链接;浏览器暴露字段的效果,则需要从实际调用页面的源进行验证。
延伸阅读
来源
- RFC 8288:Web 链接,链接上下文、关系类型和字段语法,已核验 2026-09-06。
- MDN:Link 头,字段语法和相对地址解析,已核验 2026-09-06。
- MDN:Access-Control-Expose-Headers,向跨源浏览器脚本暴露响应元数据,已核验 2026-09-06。