qinghuan
← 回到知识探索

后端现场 / 动手实验

同样是跳转,为什么 POST 到下一站变成了 GET?

用 JDK 17 的真实 HTTP 客户端和本机服务器,对照不跟随、跟随 303、跟随 307 时的方法、正文与响应链。

展开本文目录
  1. 下载完整练习包
  2. 跳转响应会引出另一条请求
  3. 先关闭自动跟随,看到真正的第一站响应
  4. 303:去另一个资源读取结果
  5. 307:自动跟随时,不改变请求方法
  6. 把响应链与服务器记录一项项对齐
  7. 运行一个文件,自动完成四组对照
  8. 固定实验条件,才能知道比较了什么
  9. 先约定下一站应该做什么,再选状态码
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 理解 HTTP 方法、状态码、请求正文与 Location 响应头。
  • 准备包含 jdk.httpserver 模块的完整 JDK 17。
  • 实验仅使用本机回环地址,无需浏览器、Spring、账号或第三方库。

程序向一个地址发送 POST,服务器让它去另一个地址。最后拿到 200,看上去请求顺利完成了,但下一站到底收到什么?有时是带着原正文的 POST,有时已经变成没有该正文的 GET。只检查最终状态码,会把这些不同过程压成同一个“成功”。

本篇启动一个只监听 127.0.0.1 的教学服务器,由 JDK 17 HttpClient 实际发请求。我们只比较 303 和 307 两种跳转,再加入不自动跟随的对照。证据同时来自客户端响应历史与服务器收到的方法、路径和正文;记录只在内存里,不执行保存订单等业务。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17;制作时实测 Microsoft OpenJDK 17.0.20.1。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • RedirectMethodDemo.java:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的观察记录
  • verification.json:对应源码的验证范围
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
java --source 17 RedirectMethodDemo.java

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 真实 JDK 17 回环 HTTP/1.1 请求;不代表浏览器、代理、凭据转发或真实业务写入。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

跳转响应会引出另一条请求

第一条 POST 发向 /redirect 开头的路径。服务器读取固定正文并留下记录,随后返回重定向状态和 Location,指向同一台本机服务器的 /sink 路径。如果客户端决定跟随,它会向这个新地址再发请求。路径记录中出现的第二项来自客户端的后续发送,并不是服务器在内部把第一条请求转交过去。

这个区别会影响排查方式。服务器内部转发、HTTP 重定向和业务重试不是同一个动作;本文只研究第二种。初始调用仍然可能只写了一次 client.send,但它内部可以处理中间响应并继续请求。因此代码层的一次调用,不等于网络上只有一条 HTTP 请求。

先关闭自动跟随,看到真正的第一站响应

第一组客户端明确设置 Redirect.NEVER,分别向 303 和 307 路径发送相同 POST。两次都应只留下第一站的一条记录:方法 POST,正文 message=demo。client.send 返回的状态分别为 303 与 307,响应地址仍是最初地址,previousResponse 为空。

这个对照说明服务器确实发出了两种不同指示,也说明 Location 的存在不会强迫所有客户端立即跟随。客户端策略参与了结果。下一组改用 Redirect.NORMAL,才观察本实验中的自动跟随行为;不要把某个工具默认开启或关闭跳转的习惯,直接当成所有客户端共同的默认值。

303:去另一个资源读取结果

RFC 9110 把 303 描述为到另一个资源取得原请求的间接响应。HTTP 中的后续读取可以使用 GET 或 HEAD。本例初始请求是 POST,JDK 17 自动跟随后,服务器观察到第二站是 GET,正文为空,最终响应为 200。客户端历史则保留第一站的 303。

一种常见设计是提交后再读取结果页,但状态码本身不会替服务保存数据。本例第一站只记录收到的内容,没有真正建立订单,也不能凭 303 推断业务已经持久提交。这里实际证明的是这份客户端配置下的请求方法与正文变化;业务结果仍要由接口合同及其数据证据说明。

307:自动跟随时,不改变请求方法

对于 307,规范要求客户端自动重定向时不得改变请求方法。本例使用同一份固定字符串正文,第二站实际收到 POST,正文仍为 message=demo,最终返回 200。它和 303 一样得到最终成功状态,却经过不同的方法序列:一个是 POST 后接 GET,另一个是 POST 后接 POST。

固定字符串适合这个对照,因为程序可以再次提供相同字节。实验没有测试不可重复读取的输入流、超大上传或中途读取失败;这些正文来源能否再次发送,要结合客户端与发布器约定检查。也不能因为两次都是 POST,就把它们当作某种自动去重协议:307 不会为业务产生幂等键或恢复已完成部分。

把响应链与服务器记录一项项对齐

HttpResponse.uri 表示该响应来自哪里,request 返回与该响应对应的请求,可能已经不是最初提交的那个。代码沿 previousResponse 向前读取,把方法、路径和状态排成从第一站到最后一站的顺序。正常跟随时恰好有一个中间响应;不跟随时只有当前响应。

只看最终 request.method,也还不足以知道服务器实际读了哪些字节,所以程序同时保留服务端 Seen 记录,并比较完整的预期列表。303 路径应是第一站 POST 带正文、第二站 GET 空正文;307 路径两站都是 POST 且正文相同。断言同时核对数量、顺序、方法、正文、最终路径和状态,防止少看一个字段便得出过宽结论。

这里的 previousResponse 只用于本次跳转历史。API 还可能把认证产生的中间响应放在同一机制中,而且历史响应的 body 返回 null。不能据此发明“所有中间正文都已被完整保存”的调试能力;需要哪些证据,应在合适的位置另行记录。

运行一个文件,自动完成四组对照

把完整代码保存为 RedirectMethodDemo.java,使用下方给出的 JDK 17 命令运行。程序从系统申请一个空闲本机端口,启动服务,再按固定顺序执行四组:不跟随 303、不跟随 307、跟随 303、跟随 307。你无需手动打开浏览器或分别启动客户端和服务器。

输出中的 response chain 是客户端看到的链,server saw 是服务端实际收到的记录。每组路径包含自己的试验名称,便于对照。全部断言完成后,程序停止服务器、关闭自己创建的执行器并等待退出,最后打印 PASS。源码模式不在当前目录生成 class 文件,实验也不创建数据库或输出数据文件。

展开收起代码 · 小实验java · 134 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.ProxySelector;
import java.net.SocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/** JDK 17. Local HTTP only; two status codes, fixed small body, no business writes. */
public class RedirectMethodDemo {
    static final String BODY = "message=demo";
    record Seen(String path, String method, String body) {}
    static final List<Seen> seen = new CopyOnWriteArrayList<>();
    static final AtomicReference<Throwable> handlerFailure = new AtomicReference<>();

    static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    static void handle(HttpExchange exchange) throws IOException {
        try {
            String path = exchange.getRequestURI().getPath();
            byte[] bytes = exchange.getRequestBody().readNBytes(513);
            if (bytes.length > 512) {
                exchange.sendResponseHeaders(413, -1);
                return;
            }
            String body = new String(bytes, StandardCharsets.UTF_8);
            seen.add(new Seen(path, exchange.getRequestMethod(), body));
            String[] parts = path.split("/");
            if (parts.length == 4 && parts[1].equals("redirect")
                    && (parts[2].equals("303") || parts[2].equals("307"))) {
                exchange.getResponseHeaders().set("Location", "/sink/" + parts[3]);
                exchange.sendResponseHeaders(Integer.parseInt(parts[2]), -1);
            } else if (parts.length == 3 && parts[1].equals("sink")) {
                byte[] reply = "observed".getBytes(StandardCharsets.UTF_8);
                exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
                exchange.sendResponseHeaders(200, reply.length);
                exchange.getResponseBody().write(reply);
            } else {
                exchange.sendResponseHeaders(404, -1);
            }
        } catch (IOException | RuntimeException failure) {
            handlerFailure.compareAndSet(null, failure);
            throw failure;
        } finally {
            exchange.close();
        }
    }

    static HttpClient client(HttpClient.Redirect policy, ExecutorService workers) {
        return HttpClient.newBuilder().followRedirects(policy).executor(workers)
                .connectTimeout(Duration.ofSeconds(3))
                .version(HttpClient.Version.HTTP_1_1)
                .proxy(new ProxySelector() {
                    public List<Proxy> select(URI uri) { return List.of(Proxy.NO_PROXY); }
                    public void connectFailed(URI uri, SocketAddress address, IOException failure) { }
                }).build();
    }

    static void observe(HttpClient client, String base, int status, boolean follow) throws Exception {
        seen.clear();
        String trial = (follow ? "follow-" : "never-") + status;
        String initialPath = "/redirect/" + status + "/" + trial;
        String finalPath = "/sink/" + trial;
        HttpRequest request = HttpRequest.newBuilder(URI.create(base + initialPath))
                .timeout(Duration.ofSeconds(5)).header("Content-Type", "text/plain; charset=utf-8")
                .POST(HttpRequest.BodyPublishers.ofString(BODY, StandardCharsets.UTF_8)).build();
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        check(handlerFailure.get() == null, "server handler failed: " + handlerFailure.get());
        List<Seen> expected = new ArrayList<>();
        expected.add(new Seen(initialPath, "POST", BODY));
        if (follow) expected.add(new Seen(finalPath, status == 303 ? "GET" : "POST", status == 303 ? "" : BODY));
        check(List.copyOf(seen).equals(expected), "unexpected requests: " + seen);
        check(response.statusCode() == (follow ? 200 : status), "unexpected final status");
        check(response.uri().getPath().equals(follow ? finalPath : initialPath), "wrong final URI");
        check(response.request().method().equals(follow && status == 303 ? "GET" : "POST"), "wrong final method");
        check(response.previousResponse().isPresent() == follow, "wrong response history");
        if (follow) {
            HttpResponse<String> previous = response.previousResponse().orElseThrow();
            check(previous.statusCode() == status && previous.previousResponse().isEmpty(), "wrong redirect chain");
            check(response.body().equals("observed"), "final body mismatch");
        }
        List<String> chain = new ArrayList<>();
        for (HttpResponse<String> current = response; current != null;
                current = current.previousResponse().orElse(null)) {
            chain.add(0, current.request().method() + " " + current.uri().getPath() + " -> " + current.statusCode());
        }
        System.out.println(trial + " response chain: " + chain);
        for (Seen event : seen) {
            System.out.println("  server saw " + event.method() + " " + event.path()
                    + " body=[" + event.body() + "]");
        }
    }

    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        ExecutorService serverWorkers = Executors.newSingleThreadExecutor();
        ExecutorService clientWorkers = Executors.newFixedThreadPool(2);
        try {
            server.setExecutor(serverWorkers);
            server.createContext("/", RedirectMethodDemo::handle);
            server.start();
            String base = "http://127.0.0.1:" + server.getAddress().getPort();
            HttpClient never = client(HttpClient.Redirect.NEVER, clientWorkers);
            HttpClient normal = client(HttpClient.Redirect.NORMAL, clientWorkers);
            observe(never, base, 303, false);
            observe(never, base, 307, false);
            observe(normal, base, 303, true);
            observe(normal, base, 307, true);
        } finally {
            server.stop(0);
            serverWorkers.shutdownNow();
            clientWorkers.shutdownNow();
            check(serverWorkers.awaitTermination(5, TimeUnit.SECONDS), "server executor did not terminate");
            check(clientWorkers.awaitTermination(5, TimeUnit.SECONDS), "client executor did not terminate");
        }
        System.out.println("PASS: four cases; response history matches server methods and bodies; server stopped");
        System.out.println("Scope: JDK 17 local HTTP/1.1; no browser, proxy, credentials, external URL or business mutation.");
    }
}

怎样运行:完整 JDK 17;保存为 RedirectMethodDemo.java,运行 java --add-modules jdk.httpserver --source 17 RedirectMethodDemo.java。程序自动启动回环服务器、运行四组并停止;不用浏览器。 2026-10-13 已用 Microsoft OpenJDK 17.0.20.1 执行这份示例并通过自带断言;下方为当次输出。真实 JDK 17 回环 HTTP/1.1 请求;不代表浏览器、代理、凭据转发或真实业务写入。

运行后对照结果
never-303 response chain: [POST /redirect/303/never-303 -> 303]
  server saw POST /redirect/303/never-303 body=[message=demo]
never-307 response chain: [POST /redirect/307/never-307 -> 307]
  server saw POST /redirect/307/never-307 body=[message=demo]
follow-303 response chain: [POST /redirect/303/follow-303 -> 303, GET /sink/follow-303 -> 200]
  server saw POST /redirect/303/follow-303 body=[message=demo]
  server saw GET /sink/follow-303 body=[]
follow-307 response chain: [POST /redirect/307/follow-307 -> 307, POST /sink/follow-307 -> 200]
  server saw POST /redirect/307/follow-307 body=[message=demo]
  server saw POST /sink/follow-307 body=[message=demo]
PASS: four cases; response history matches server methods and bodies; server stopped
Scope: JDK 17 local HTTP/1.1; no browser, proxy, credentials, external URL or business mutation.

固定实验条件,才能知道比较了什么

本例固定 HTTP/1.1、明文回环连接、同一服务器、短字符串正文和一次跳转,显式绕过代理选择。连接与请求等待设置上限,用于让故障暴露,不是延迟测评结果。每次请求采用阻塞发送并等到响应完成后才开始下一组,因此没有靠随机睡眠等待服务器“大概收到了”。

它没有 Cookie、Authorization、HTTPS 降级、跨主机跳转、缓存或浏览器安全策略。Redirect.NORMAL 的名字也不表示业务目标已经通过允许名单检查;它的文档定义的是自动跟随政策。本项目只访问自己创建的同一回环服务器,所以没有验证其他目的地是否合法,也不能把此配置直接当作通用 URL 获取器的完整设计。

先约定下一站应该做什么,再选状态码

如果下一站负责读取处理结果,就明确它接受的读取方法、结果身份和状态;如果下一站承接同样的请求,就明确方法与正文是否可再次发送、原站是否已经产生业务效果。方法保存、请求再次发送、业务只能执行一次是三项不同要求,不能让一个跳转状态码同时替它们作保证。

排查“正文为什么丢了”时,先记录原始方法和状态,确认客户端有没有自动跟随,再观察下一站的方法与正文。若只留下最后的 200,就无法还原这些选择。生产日志还应遵守自己的内容记录规则;本文打印的是固定无敏感信息的短文本,目的只是让每一步证据可见。

记住这三点

  • 一次 client.send 可能包含多条 HTTP 请求,最终 200 不能概括整个过程。
  • 本例自动跟随 303 后为 GET 空正文,跟随 307 后保留 POST 与固定正文。
  • 响应历史和服务端收到的记录要对齐,方法保持不等于业务幂等。

别把例子用过头

  • 把 HTTP 跳转误认为服务器内部转发。
  • 只检查最终状态,遗漏中间状态、最终 URI 和方法变化。
  • 从固定字符串正文的成功,推断任意上传流都可以重复发送。
  • 把 Redirect.NORMAL 当成目标授权检查,或把 307 当成去重能力。

自己试一小步

如果增加两站连续跳转,怎样证明每一跳的方法和正文符合你预先写好的合同?

  1. 只在同一本机服务器增加一个中间路径,自选 303 与 307 的排列,先画出你预测的请求链。
  2. 保留现有四组作为对照,新增独立试验名称,记录每个响应和服务端实际收到的内容。
  3. 加入不自动跟随的路径,再检查历史链长度、最终地址与关闭过程;不使用外部站点。

做到这些,就算完成

  • 交出自己的预测、源码和完整记录,不能只提交最后一次 200。
  • 逐跳对照方法、正文和状态,没有把方法保持写成业务已经去重。
  • 说明固定本机正文与真实认证、上传和业务提交之间还有哪些未验证条件。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • RFC 9110:303 与 307 (新标签页)↗IETF · 英文

    阅读第 15.4.4 节 303 的间接响应与读取请求,以及第 15.4.8 节 307 自动跟随不得改变方法;本篇只实测这两个状态。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。

  • Java 17:HttpClient.Redirect (新标签页)↗Oracle · 英文

    阅读 NEVER、NORMAL 的定义与重定向可能改变方法的实现说明;不把枚举名解释为业务目的地已被授权。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。

  • Java 17:HttpResponse (新标签页)↗Oracle · 英文

    阅读 request、uri、previousResponse 与 body:重定向后的请求/地址、中间响应含义,以及历史响应正文返回 null 的边界。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。

  • Java 17:HttpServer (新标签页)↗Oracle · 英文

    阅读 create 的地址绑定、setExecutor/start 的执行方式与 stop 的监听套接字及当前交换关闭语义;支持本机教学服务器的生命周期。

    公开原始规范或官方 API 文档;本篇只采用注明的条款,实验输出单独验证。