同一个资源可以同时提供面向读者的 HTML 视图和面向程序的 JSON 表示。共用 URL 的前提是源站与中间层始终保留这种区别。端点需要选择可用格式、正确序列化、说明缓存变化维度,并验证本次选中的表示,而不是拿同一资源的另一种版本来代替它。单一地址不会自动完成这些工作。

先选择,再序列化与验证

对于 GET 响应,请求中的 Accept 表达希望接收哪些媒体类型,请求中的 Content-Type 则描述请求内容本身。把后者写成 JSON,并不等于要求服务器返回 JSON。质量值与通配符匹配应交给框架已有的媒体范围解析器。应用还需要分别规定:没有 Accept 时默认返回什么,以及字段存在却匹配不到任何可用表示时如何处理。

下面的端点只在 Accept 缺失时默认选择 HTML。如果客户端明确表达的偏好没有可接受候选,就返回 406,并列出可用类型。这是一项刻意选择的接口策略,因为 HTTP 也允许忽略偏好。不要把它换成 415,后者针对的是请求内容格式不受支持。分清错误方向,客户端才能知道应该修改希望接收的类型,还是自己发送的正文及其声明。

完成选择后,Content-Type 应由实际使用的序列化方式确定。HTML 中插入数据需要适当转义,JSON 则应通过 JSON 序列化器生成,而不是塞进 HTML 模板。该路由的各类表示,包括默认表示,都需要 Vary: Accept。但 Vary 不授予存储资格,也不保护个人信息;实验中的公开缓存指令只适用于这份对所有调用者完全相同、没有敏感信息的固定状态记录,不能直接复制到账户接口。

坏掉的样子

只测源站,可能看不出缓存正在把之前的 HTML 发给现在的 JSON 请求。需要通过完整交付路径检查响应头和解析后的正文,并交换请求顺序。新响应补上正确的 Vary,不会自动修复此前已经缓存、却缺失选择元数据的条目。应使用受控测试地址或已获批准的失效流程,不要为了验证一个端点而随意清空共享缓存。

更隐蔽的问题是把数据库记录的版本号直接作为两种格式共同的强 ETag。ETag 识别的是所选表示,不同的 HTML 与 JSON 字节不能仅因为描述同一条记录,就共用这个强验证器。如果先处理条件请求、后选择格式,就可能对客户端根本没有的表示错误返回 304。正确顺序是先选择、序列化、计算该表示的标记,再判断条件是否成立。资源内容相同与表示字节相同,不是一个概念。

快速验证

使用 Python 3.11 或更新版本建立以下临时环境,然后用 "$media_lab/venv/bin/python" 运行 Python 代码。程序在自动分配的回环端口启动 Werkzeug 开发服务器,完成后关闭服务器与连接。只有安装依赖需要联网,六次测试请求都留在本机。这是协议实验,不是生产 WSGI 部署,也没有假装验证真实共享缓存的配置。

media_lab=$(mktemp -d)
python3 -m venv "$media_lab/venv"
"$media_lab/venv/bin/python" -m pip install Werkzeug==3.1.8
import hashlib
import html
import json
from http.client import HTTPConnection
from threading import Thread

from werkzeug.serving import make_server
from werkzeug.wrappers import Request, Response

record = {"message": "ready"}
variants = {
    "text/html": f"<!doctype html><p>{html.escape(record['message'])}</p>\n".encode(),
    "application/json": json.dumps(record).encode("utf-8"),
}


@Request.application
def application(request):
    if request.path != "/status":
        return Response("not found\n", status=404)
    if request.method != "GET":
        return Response("GET only\n", status=405, headers={"Allow": "GET"})
    media = "text/html" if "Accept" not in request.headers else (
        request.accept_mimetypes.best_match(variants)
    )
    if media is None:
        response = Response("available: text/html, application/json\n", status=406)
        response.headers["Cache-Control"] = "no-store"
    else:
        body = variants[media]
        response = Response(body, mimetype=media)
        response.set_etag(hashlib.sha256(body).hexdigest())
        response.headers["Cache-Control"] = "public, max-age=60"
        response.make_conditional(request)
    response.vary.add("Accept")
    return response


server = make_server("127.0.0.1", 0, application)
worker = Thread(target=server.serve_forever)
worker.start()


def get(accept=None, etag=None):
    headers = {}
    if accept is not None:
        headers["Accept"] = accept
    if etag is not None:
        headers["If-None-Match"] = etag
    connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
    try:
        connection.request("GET", "/status", headers=headers)
        response = connection.getresponse()
        body = response.read()
        assert response.headers["Vary"] == "Accept"
        print(accept, "->", response.status, "bytes=", len(body))
        return response.status, response.headers, body
    finally:
        connection.close()


try:
    status, html_headers, body = get()
    assert status == 200 and body == variants["text/html"]
    assert html_headers.get_content_type() == "text/html"
    status, json_headers, body = get("application/json")
    assert status == 200 and json.loads(body) == record
    assert json_headers.get_content_type() == "application/json"
    assert html_headers["ETag"] != json_headers["ETag"]
    status, _, body = get("application/json", json_headers["ETag"])
    assert status == 304 and body == b""
    status, _, body = get("text/html", json_headers["ETag"])
    assert status == 200 and body == variants["text/html"]
    for accept in ["application/xml", "*/*;q=0"]:
        status, headers, _ = get(accept)
        assert status == 406 and headers["Cache-Control"] == "no-store"
finally:
    server.shutdown()
    server.server_close()
    worker.join()

预期状态码依次为 200、200、304、200、406、406。前两次以不同格式返回同一状态信息,程序同时检查正文、媒体类型以及两个强 ETag 确实不同。第三次重新验证 JSON,得到不携带正文的 304。第四次携带 JSON 的标记,却明确请求 HTML,因此必须得到完整 HTML 与 200。这是只验证“两种格式都能正常打开”时最容易遗漏的负向检查。

最后两次分别请求服务器没有提供的 XML,以及明确排除全部媒体类型,都会触发本例选定的拒绝策略。错误响应使用 no-store,避免把这类实验错误交给缓存保存。每个测试响应都断言存在 Vary,包括 304。解析和条件响应处理复用 Werkzeug,而不是自己拆分头字段语法或手写实体标记比较规则;实验成功只证明所覆盖的调用路径,不能推导出未测试客户端都正确处理这些响应。

采用这项设计之前,应确认两种序列化结果属于同一个资源契约,并能一起测试。如果调用者、访问权限或发布时间存在实质差异,独立 URL 可能更容易维护。不要因为地址统一就把不同的授权规则合并。不论最终采用哪种寻址方案,都应把正文验证与跨表示验证器检查保留在集成测试中,防止以后修改序列化方式时重新引入错误复用。

延伸阅读

来源