URL 指认目标资源,请求方法描述客户端希望源站执行的操作。服务器、缓存、代理、爬虫和重试客户端都会读取这个动词,因此方法不是路径上的装饰,而是关于预期效果的一份承诺。只有当承诺诚实时,外围设施才能做出安全的缓存、预取、重试和监控决定。同一个 URL 配上不同方法,对下游来说就是不同请求。
安全与幂等不是一回事
安全方法的定义是“本质上只读”:GET 和 HEAD 都不应要求源站改变目标资源状态。服务器仍可能写访问日志、计费或更新观测计数,但这些附带动作不改变方法的安全语义。幂等则表示,把完全相同的请求发送多次,服务器的预期效果与发送一次相同。PUT 和 DELETE 在协议上具有幂等意图,可是第一次请求可能已经到达而响应在途中丢失,客户端仍需结合版本、条件头和业务结果判断是否重试。
POST 通常表示创建从属资源或执行一次动作;它不会因为当前处理器恰好没有重复副作用,就自动变成幂等。PATCH 表示部分修改,也不天然保证重放安全。OPTIONS 用来询问通信选项,TRACE 有专门的诊断用途。具体资源允许哪些方法由源站决定;已知但不支持的方法应返回 405,并用 Allow 说明可用集合。未知方法则可能得到 501,不能把两个状态码混成一个“失败”。
让动词帮助基础设施判断
GET 与 HEAD 是否可缓存,要结合响应的缓存规则,但它们可被预取、重复读取,正是因为请求意图是只读。若把登出、购买或删除藏在 GET 地址后面,浏览器预取、链接扫描器、爬虫或缓存都可能触发不该发生的动作。重试库可以优先考虑幂等请求,却仍需检查认证、期限、请求体能否重放、条件版本和服务端是否已经提交。幂等不是分布式事务,也不保证客户端永远只看见一份响应。
PUT 请求创建或替换指定目标的状态,重复请求保持相同预期效果;DELETE 移除目标 URI 与其当前功能的关联,不保证物理删除底层数据。本例中条目变成不存在,第一次可能返回 204,后续可能返回 404。若服务端要选择新 ID 并创建对象,用 POST 加幂等键或单独的占位记录更准确,不能仅为了方便重试就假装它是 PUT。API 文档应同时写清方法、状态码、前置条件、版本冲突和重试策略,不能只写一个“请求成功”。
幂等性约束请求的预期效果,不要求响应文本、访问日志或修订历史完全一致。附带记录中的时间戳变化,不会自动破坏幂等性;但重复执行请求明确要求的购买或递增,是另一回事。重试需保留正文与适用的 If-Match 条件。幂等也不防止并发覆盖:无条件重放旧值,仍可能覆盖其他写入者刚提交的更新,必要时应核对条件失败后的权威状态。
坏掉的样子
用 GET 触发有副作用的接口,会破坏安全承诺,缓存可能发回旧结果,预取器可能提前执行动作;用 POST 进行完整替换时,重放行为取决于端点契约,不能反过来认定 POST 必然创建对象。把 PUT 叫作“绝对安全”也不准确:同一请求的效果可以幂等,但网络超时后客户端仍不知道第一次是否已提交。若没有保存可重放的请求体,重试可能送出空正文或另一版本。HEAD 处理器如果写入状态,也违反了它的只读语义。
方法边界还会与代理、重定向和媒体类型交错。代理可能保留动词却丢掉正文,重定向可能改变动词,415 则表示表示格式不被接受。应观察真实线上请求和最终响应,不要只读客户端日志里的初始对象。授权、输入 schema、条件头和持久化确认各有自己的检查;一个诚实的动词不能替代权限,也不能证明数据库已经提交。
工程上可以把契约拆成可观测断言:在本例的资源模型中,安全请求不修改业务值,重复 PUT 保持预期业务值,405 返回明确的 Allow,被拒绝的正文不增加写计数。再把断言放进代理、客户端和源站各自的测试,才能发现某一层偷偷重写方法或丢失正文。不要只在单元测试里调用处理函数,因为真实请求还会经过连接复用、重定向、认证中间件和缓存,这些边界可能改变你以为已经发送的内容。
快速验证
把下面程序保存为 methods_probe.go,用 Go 1.23 或更新版本运行。回环服务保留一个内存值,开放 GET、HEAD、PUT、DELETE、OPTIONS,拒绝其他方法,并限制 PUT 正文为 64 字节。写计数用来观察安全读取、重复幂等请求和真正副作用的区别;程序不代表持久事务。
package main
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
const allowed = "GET, HEAD, PUT, DELETE, OPTIONS"
var mu sync.Mutex
value, exists := "seed", true
var handled, writeCalls atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
defer r.Body.Close()
if r.URL.Path != "/item" {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet, http.MethodHead:
mu.Lock()
body, found := value, exists
mu.Unlock()
if !found {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
if r.Method == http.MethodGet {
fmt.Fprint(w, body)
}
case http.MethodPut:
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64))
if err != nil {
status := http.StatusBadRequest
var oversized *http.MaxBytesError
if errors.As(err, &oversized) {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, "body rejected", status)
return
}
mu.Lock()
created := !exists
value, exists = string(body), true
writeCalls.Add(1)
mu.Unlock()
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusNoContent)
}
case http.MethodDelete:
mu.Lock()
found := exists
value, exists = "", false
writeCalls.Add(1)
mu.Unlock()
if found {
w.WriteHeader(http.StatusNoContent)
} else {
http.NotFound(w, r)
}
case http.MethodOptions:
w.Header().Set("Allow", allowed)
w.WriteHeader(http.StatusNoContent)
case http.MethodPost, http.MethodPatch, http.MethodTrace, http.MethodConnect:
w.Header().Set("Allow", allowed)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
default:
http.Error(w, "unknown method", http.StatusNotImplemented)
}
}))
server.Config.ReadHeaderTimeout = time.Second
server.Config.ReadTimeout = 2 * time.Second
server.Start()
defer server.Close()
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
probe := func(method, payload string, status int, expected string) {
request, err := http.NewRequest(method, server.URL+"/item", strings.NewReader(payload))
must(err)
response, err := client.Do(request)
must(err)
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.Proto == "HTTP/1.1" && response.StatusCode == status, method+": wrong status/protocol")
check(string(body) == expected, method+": wrong response body")
if method == http.MethodHead {
check(response.ContentLength == 4 && len(body) == 0, "HEAD must report the GET length without its body")
}
if method == http.MethodOptions || status == 405 {
check(response.Header.Get("Allow") == allowed, "Allow must describe the target methods")
}
fmt.Printf("%s: status=%d body=%q\n", method, status, body)
}
probe("GET", "", 200, "seed")
probe("GET", "", 200, "seed")
probe("HEAD", "", 200, "")
check(writeCalls.Load() == 0, "safe requests must not call the write operation")
probe("PUT", "new", 204, "")
probe("PUT", "new", 204, "")
probe("GET", "", 200, "new")
check(writeCalls.Load() == 2, "both repeated PUT requests must reach the server")
probe("PUT", strings.Repeat("x", 65), 413, "body rejected\n")
probe("GET", "", 200, "new")
check(writeCalls.Load() == 2, "rejected PUT must leave state unchanged")
probe("DELETE", "", 204, "")
probe("DELETE", "", 404, "404 page not found\n")
probe("GET", "", 404, "404 page not found\n")
probe("OPTIONS", "", 204, "")
probe("POST", "", 405, "method not allowed\n")
probe("FROB", "", 501, "unknown method\n")
check(writeCalls.Load() == 4, "method discovery and rejection must not write")
probe("PUT", "ready", 201, "")
probe("GET", "", 200, "ready")
check(handled.Load() == 16 && writeCalls.Load() == 5, "wrong final request counts")
fmt.Println("sixteen requests; five PUT/DELETE handler calls; final state ready")
fmt.Println(runtime.Version())
}
十六次请求先执行两次安全 GET 和一次没有正文的 HEAD,写计数仍为零;两次相同 PUT 都到达源站,却收敛到同一个 new 状态。超过 64 字节的 PUT 返回 413,状态不变。第一次 DELETE 返回 204,第二次返回 404,但资源都保持不存在,体现幂等效果。OPTIONS 返回 Allow,POST 得到 405,未知的 FROB 得到 501。最后 PUT 创建 ready,GET 证明拒绝路径清理后服务仍可恢复。这些是本地处理器的可观察结果,不等于缓存新鲜度、授权、网络重放或持久化成功的证明。
延伸阅读
来源
- RFC 9110:HTTP 语义,安全与幂等方法、Allow 和状态语义,已核验 2026-09-09。
- MDN:HTTP 请求方法,方法行为和实践用法,已核验 2026-09-09。
- Go net/http,MaxBytesReader、请求方法和响应处理,已核验 2026-09-09。