资讯详情 PRG解决哪种重复提交:POST之后为什么用303跳到GET
📅 2026/11/2 14:59:56
表单提交成功服务器直接返回一张“保存完成”的页面。用户刷新时浏览器可能询问是否重新提交表单如果再次发送POST后台就有再次创建记录的风险。PRG把这两件事分开POST执行操作GET展示操作结果。浏览器完成跳转后当前结果页对应的是GET请求结果页的刷新不会再走原来的创建接口。先看一组HTTP交互下面是协议示例假设应用把一条笔记保存为资源/notes/42。POST /notes HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded Content-Length: 11 titlehello保存成功后服务器返回HTTP/1.1 303 See Other Location: /notes/42 Content-Length: 0浏览器按照重定向去读取结果GET /notes/42 HTTP/1.1 Host: example.com这个GET处理器负责查询并展示笔记不能顺便再创建一遍笔记。HTTP对GET的安全与幂等语义也需要应用代码正确实现“使用GET”本身不会阻止错误的副作用。图为协议流程示意。真正发生变化的是结果页的获取方式不是浏览器替后台保证了业务只执行一次。为什么明确选择303在POST之后希望用户查看另一份资源时303 See Other能够清楚表达这个意图。RFC 9110第15.4.4节定义的是转向另一个资源进行检索普通表单的这个后续请求使用GET。几种重定向状态码不能随意替换状态码POST之后的关键区别302由于历史兼容行为客户端可能把POST改为GET语义表达不如这里直接用303清楚303让客户端读取另一份结果资源适合本文的POST→GET流程307、308跟随重定向时保留请求方法和请求体不能拿来代替本文的POST→GET步骤例如使用Jakarta Servlet 6.1时保存成功后的代码可以明确写出状态码// noteId由已成功保存的业务结果提供这是Servlet方法内的代码片段。 response.sendRedirect( request.getContextPath() /notes/ noteId, HttpServletResponse.SC_SEE_OTHER ); return;带状态码的sendRedirect重载从Servlet 6.1开始提供。旧版本不能直接照搬这个API应采用自身框架支持的重定向响应方式并在网络面板核对实际状态码与Location。Servlet 6.1 API用一次真实表单提交检查刷新行为本次在Windows本机用Node.js v24.22.1的内置HTTP服务搭了一个最小实验再通过Edge提交普通HTML表单。服务仅监听127.0.0.1的动态端口记录保存在进程内存里有意没有业务去重结果页设置Cache-Control: no-store方便观察刷新产生的请求。计数在服务器处理真实请求时增加页面没有JavaScript计数器。这里记录的是POST /notes和结果页GET的次数favicon请求不计入这两列。完整程序、启动与停止步骤放在文末可以在本机独立复现这组操作。实际操作POST创建请求累计结果页GET累计演示记录数提交一次titlehello111刷新当前/notes/1结果页121人工再次提交titlehello232服务器已完成请求日志的关键字段按发生顺序整理如下POST /notes - 303 Location: /notes/1 GET /notes/1 - 200 GET /notes/1 - 200 刷新结果页 POST /notes - 303 Location: /notes/2 GET /notes/2 - 200第一次提交后浏览器位于/notes/1刷新该页时只增加了一次结果页GET创建POST和记录数都没有增加。GET访问计数变化也不等于又执行了一次创建业务。再次提交同样的hello仍然经过303和GET但这个没有去重逻辑的服务创建了第2条记录。若应用要把两次请求认定为同一个业务动作需要另行定义业务标识和约束内容相同本身并不自动意味着应该去重。这些结果来自本次Edge文档导航和这个最小服务。双击、网络重试、多客户端并发以及其它客户端行为还需要分别验证它们没有被这一次实验覆盖。它防不住哪几种重复PRG作用于成功跳到GET结果页之后的刷新。以下情况仍可能产生多个POST用户连续点击两次提交两个请求都已发出。POST已经写入数据库响应却在途中丢失客户端重试。两个客户端同时提交同一个业务操作。涉及“同一业务动作只能成功一次”时需要后端识别这是不是同一动作例如使用业务唯一约束或把幂等键、处理状态和业务写入放进一致的处理流程。不能只凭前端按钮置灰或一次重定向响应判断重复已经解决。结果地址也需要检查访问权限。把资源ID放进URL只解决如何定位资源不代表拿到链接的人都应该能读取它它是否适合分享、缓存还取决于内容和响应策略。在浏览器里检查什么对一个可以测试的普通HTML表单打开开发者工具的Network面板并保留导航请求提交一次表单找到POST请求查看返回状态是否为303、Location是否指向正确结果。找到随后的结果请求核对方法为GET。刷新结果页确认新增的是GET而不是又发一次创建POST同时核对后台记录没有新增。再单独测试双击和超时重试验证后端的重复处理策略。它们不是PRG本身的验收结果。如果提交来自fetch或前端框架客户端即使跟随了HTTP重定向也不会因此自动把整个页面导航到结果地址。此时需要结合前端路由与接口协议设计交互。完整复现这个本机实验新建一个空目录将下面的完整代码保存为server.mjs。这是本次已运行的版本包含HTML表单、POST与GET处理器、计数面板、过程日志和关闭逻辑只使用Node内置模块不需要安装npm依赖。本次验证的Node版本是v24.22.1。在保存文件的目录运行node --version node server.mjs启动时会输出一条包含origin的JSON记录也会生成runtime.json。在浏览器打开本次输出的origin地址。程序通过listen(0, 127.0.0.1)让系统分配空闲端口每次启动的地址可能不同。import http from node:http; import fs from node:fs; import path from node:path; import { fileURLToPath } from node:url; import os from node:os; const directory path.dirname(fileURLToPath(import.meta.url)); const eventPath path.join(directory, requests.jsonl); const snapshotPath path.join(directory, state.json); const completed []; const state { postCount: 0, resultGetCount: 0, notes: [] }; let origin ; let shuttingDown false; let sequence 0; fs.writeFileSync(eventPath, , utf8); const escape value String(value).replaceAll(, amp;).replaceAll(, lt;).replaceAll(, gt;).replaceAll(, quot;); const save () fs.writeFileSync(snapshotPath, JSON.stringify({ recordedAt: new Date().toISOString(), ...state }, null, 2) \n, utf8); save(); function writeEvent(event) { const row { sequence: sequence, recordedAt: new Date().toISOString(), ...event }; fs.appendFileSync(eventPath, JSON.stringify(row) \n, utf8); completed.push(row); console.log(JSON.stringify(row)); } function page(currentPath, note null) { const redirect note completed.findLast(event event.method POST event.status 303 event.location /notes/${note.id}); const heading note ? 已创建第 ${note.id} 条演示记录 : 先提交一次表单再刷新结果页; const value note?.title ?? hello; const rows state.notes.map(item trtd${item.id}/tdtd${escape(item.title)}/td/tr).join(); return !doctype htmlhtml langzh-CNheadmeta charsetutf-8meta nameviewport contentwidthdevice-width,initial-scale1titlePRG最小实验/titlestyle *{box-sizing:border-box}body{margin:0;background:#f4f6f9;color:#172033;font:17px/1.5 system-ui,Microsoft YaHei,sans-serif}main{max-width:760px;margin:18px auto;padding:20px 26px;background:#fff;border:1px solid #d8e0eb;border-radius:10px}.tag{font-size:14px;color:#2563eb;font-weight:650}h1{font-size:30px;line-height:1.2;margin:5px 0 9px}.address{font:14px/1.4 ui-monospace,Consolas,monospace;overflow-wrap:anywhere;color:#536176}.counts{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:17px 0}.count{background:#eef3fb;border:1px solid #dbe4f2;padding:10px 14px;border-radius:6px}.count span{display:block;font-size:15px;color:#46536a}.count strong{display:block;font-size:40px;line-height:1.2;color:#174baf}.outcome{font-size:21px;font-weight:650;margin:9px 0}.response{font-size:14px;color:#536176;margin:4px 0 12px}form{display:flex;gap:10px;align-items:center;margin:12px 0 17px}label{white-space:nowrap;font-size:16px}input{width:180px;min-width:0;border:1px solid #aebcd0;border-radius:5px;padding:8px;font:inherit}button{border:0;border-radius:5px;background:#245ed6;color:white;padding:10px 15px;font:600 16px/1.2 inherit;cursor:pointer}h2{font-size:18px;margin:10px 0 4px}table{width:100%;border-collapse:collapse;font-size:16px}td,th{padding:5px 10px;text-align:left;border-bottom:1px solid #e2e8f0}th{color:#536176;font-weight:500}.foot{font-size:13px;color:#657187;margin:13px 0 0}media(max-width:600px){main{margin:0;border-radius:0;padding:18px}.counts{gap:6px}.count{padding:8px}.count span{font-size:13px}.count strong{font-size:33px}form{flex-wrap:wrap}h1{font-size:26px}} /style/headbodymaindiv classtagHTTP行为验证 · 最小实验 · 仅本机/divh1POST → 303 → GET/h1div classaddress当前页面${escape(origin currentPath)}/divdiv classcountsdiv classcountspanPOST创建请求/spanstrong idpost-count${state.postCount}/strong/divdiv classcountspanGET结果页请求/spanstrong idget-count${state.resultGetCount}/strong/divdiv classcountspan创建记录数/spanstrong idrecord-count${state.notes.length}/strong/div/divp classoutcome${heading}/p${redirect ? p classresponse服务器已完成响应POST /notes → ${redirect.status}Location: ${escape(redirect.location)}/p : p classresponse计数由Node处理真实HTTP请求时更新页面不使用JavaScript计数。/p}form methodpost action/noteslabel fortitle演示内容/labelinput idtitle nametitle value${escape(value)} maxlength80 requiredbutton typesubmit${note ? 再次提交相同内容 : 提交演示表单}/button/formh2本进程已创建的记录/h2tabletheadtrth记录ID/thth内容/th/tr/theadtbody${rows || trtd colspan2尚无记录/td/tr}/tbody/tablep classfoot响应缓存no-store。示例使用进程内存并有意不做业务去重刷新与重新POST是不同操作。/p/main/body/html; } const server http.createServer(async (req, res) { const requestPath new URL(req.url, origin || http://127.0.0.1).pathname; const requestTitle { value: null }; res.setHeader(Cache-Control, no-store); res.on(finish, () writeEvent({ method: req.method, path: requestPath, status: res.statusCode, location: res.getHeader(Location) ?? null, title: requestTitle.value, counters: { post: state.postCount, resultGet: state.resultGetCount, records: state.notes.length }, userAgent: req.headers[user-agent] ?? null, })); try { if (req.method GET requestPath /) { res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(page(/)); return; } if (req.method POST requestPath /notes) { let body ; for await (const chunk of req) { body chunk.toString(utf8); if (Buffer.byteLength(body, utf8) 4096) { res.writeHead(413).end(); return; } } const title new URLSearchParams(body).get(title)?.slice(0, 80) ?? ; if (!title.trim()) { res.writeHead(400, { Content-Type: text/plain; charsetutf-8 }).end(请提供演示内容); return; } state.postCount 1; const note { id: state.notes.length 1, title }; state.notes.push(note); requestTitle.value title; save(); res.writeHead(303, { Location: /notes/${note.id} }); res.end(); return; } const match requestPath.match(/^\/notes\/(\d)$/); if (req.method GET match) { const note state.notes.find(item item.id Number(match[1])); if (!note) { res.writeHead(404).end(); return; } state.resultGetCount 1; save(); res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(page(requestPath, note)); return; } if (req.method GET requestPath /favicon.ico) { res.writeHead(204).end(); return; } res.writeHead(404).end(); } catch (error) { writeEvent({ type: handler_error, message: String(error) }); if (!res.headersSent) res.writeHead(500); res.end(); } }); server.listen(0, 127.0.0.1, () { const address server.address(); origin http://127.0.0.1:${address.port}; const runtime { pid: process.pid, origin, address, node: process.version, os: { platform: process.platform, release: os.release() }, startedAt: new Date().toISOString(), demoOnly: true }; fs.writeFileSync(path.join(directory, runtime.json), JSON.stringify(runtime, null, 2) \n, utf8); writeEvent({ type: server_started, ...runtime }); }); function shutdown(reason) { if (shuttingDown) return; shuttingDown true; save(); server.close(() { const final { stoppedAt: new Date().toISOString(), reason, ...state }; fs.writeFileSync(path.join(directory, final-state.json), JSON.stringify(final, null, 2) \n, utf8); writeEvent({ type: server_stopped, ...final }); process.stdin.pause(); process.exit(0); }); server.closeIdleConnections?.(); } process.on(SIGINT, () shutdown(SIGINT)); process.on(SIGTERM, () shutdown(SIGTERM)); process.stdin.setEncoding(utf8); process.stdin.on(data, value { if (value.split(/\r?\n/).some(line line.trim() shutdown)) shutdown(owned_exec_stdin); });按前面的实验方式在普通浏览器页面中依次操作保持演示内容为hello点击“提交演示表单”。本次Edge中的成功标志是地址来到/notes/1三个计数依次为1 / 1 / 1。刷新这个结果页。地址仍为/notes/1计数变为1 / 2 / 1只增加了结果页GET。点击“再次提交相同内容”。地址来到/notes/2计数为2 / 3 / 2表中出现两条hello记录。这个示例没有业务去重第二个POST仍会创建记录。终端输出与同目录的requests.jsonl记录每次已完成响应的方法、路径、状态、Location和计数state.json保留当前快照。可以用这些记录对照页面而不是只看按钮点击次数。favicon请求单独记录不计入结果页GET次数。完成后先关闭这个实验页面再回到运行Node的终端输入shutdown并回车。看到server_stopped且命令结束说明该服务已经关闭结束状态会写入final-state.json。每次启动都从零计数并重写本例请求日志需要保留一轮结果时先复制该实验目录。这里保存的是进程内存中的演示记录不会连接外部数据库或修改网络设置。参考MDN 303、MDN 307、RFC 9110安全与幂等语义。修订于2026年11月1日明确303与307/308的区别补最小浏览器实验、实际计数与业务幂等边界。