HTTP 方法描述客户端希望对目标资源执行的动作,客户端和中间层会据此判断抓取、缓存或重试方式。选择一个常见动词只是开始,应用还必须实现这个动词承诺的行为。把破坏性操作命名成 GET,并不会让它自动安全。可用的接口说明还需要交代资源模型、表示格式、授权和错误条件,不能认为方法名称已经替代了所有业务文档。
分清安全性与幂等性
安全方法具有基本只读的请求语义。GET 取得表示,HEAD 获取相应响应的元数据,却不携带响应内容。访问日志等附带效果,不会让一次读取在协议意义上变成不安全操作。但因为爬虫跟随 GET 链接就扣费,是另一回事:这个请求要求的业务动作本身改变状态,违反了安全方法的预期。判断时要看请求的预期目的,而不是要求服务器任何计数器都绝对不能变化。
幂等性关心同一个请求重复执行后的预期服务器效果。PUT 根据提交的表示替换目标资源状态;DELETE 移除目标与当前功能之间的关联。重复执行它们,预期效果应与一次执行相同,返回码、日志条数和耗时则可能不同。幂等性不能让请求自动获得权限,也不能保证不会受到其他并发写入影响。协议性质不是对任意业务实现的免费保护。
POST 请求目标资源按自身语义处理提交内容,创建资源只是常见用法,并不是全部定义。POST 通常不具备幂等保证,但应用可以另行提供明确的去重契约。PATCH 表达局部修改,具体性质取决于补丁格式和操作内容;重复“设成某个值”与重复“再增加一次”并不相同。不能看到方法名就认定所有局部修改都可以无条件重放。
坏掉的样子
重试组件如果把任何超时都当成可以重复 POST 的理由,就可能重复创建事件或扣款。即使使用幂等方法,也要理解并发与资源当前状态;条件请求可以防止覆盖已经被别人改过的表示。方法语义帮助定义动作,不会替代业务前置条件或事务规则。一次响应丢失,并不自动说明服务器没有执行原操作,重试判断必须考虑这个不确定窗口。
GET 也不代表所有缓存都可以自动重用任意响应,状态、保鲜指令、认证与表示选择仍然影响复用。GET 和 HEAD 的请求内容没有通用定义的语义,有些实现或中间层会拒绝它。不能因为本地处理器接受了读取请求中的正文,就认定这种查询方式在所有代理和客户端之间都能互通。设计公共接口时,应核对真实调用链的处理能力。
对于服务器认识、但目标不允许的方法,可以返回四零五,并用 Allow 指明支持的方法。这个能力列表不是授权凭据,也不同于 CORS 许可响应头。OPTIONS 可以说明通信选项,却不会因此让另一个浏览器来源自动有权读取响应。方法支持、账号授权和浏览器跨源限制,各自解决不同层面的问题,不能用同一个字段替代它们。
快速验证
将脚本保存为 methods.py 后执行 python3 methods.py。它使用标准库启动临时回环服务,提供一个条目资源和一个创建事件的端点。请求通过真实 HTTP 连接依次发送,断言同时检查响应与最终应用状态。这是一个小范围的方法契约实验,不是通用生产接口,没有接入外部账号或数据库,也没有修改现有站点。
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import json
state = {'item': None, 'events': []}
allowed = 'GET, HEAD, PUT, DELETE, OPTIONS'
class Handler(BaseHTTPRequestHandler):
def reply(self, status, data=b'', extra=None, head=False):
self.send_response(status)
if status != 204:
self.send_header('Content-Length', str(len(data)))
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
if not head and status != 204:
self.wfile.write(data)
def do_GET(self):
if self.path == '/item' and state['item'] is not None:
self.reply(200, state['item'])
else:
self.reply(404, b'missing')
def do_HEAD(self):
data = state['item'] if self.path == '/item' else None
self.reply(200 if data is not None else 404, data if data is not None else b'missing', head=True)
def do_PUT(self):
if self.path != '/item':
self.reply(404, b'missing')
return
existed = state['item'] is not None
state['item'] = self.rfile.read(int(self.headers['Content-Length']))
self.reply(204 if existed else 201, extra={'Location': '/item'} if not existed else None)
def do_POST(self):
if self.path != '/events':
self.reply(405, extra={'Allow': allowed})
return
state['events'].append(self.rfile.read(int(self.headers['Content-Length'])))
self.reply(201, extra={'Location': f'/events/{len(state["events"])}'})
def do_DELETE(self):
existed = self.path == '/item' and state['item'] is not None
if existed:
state['item'] = None
self.reply(204 if existed else 404)
def do_OPTIONS(self):
self.reply(204, extra={'Allow': allowed}) if self.path == '/item' else self.reply(404)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
observed = []
def request(method, path, body=None):
connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
try:
connection.request(method, path, body)
response = connection.getresponse()
item = {'method': method, 'status': response.status, 'body': response.read().decode(),
'length': response.getheader('Content-Length'),
'location': response.getheader('Location'), 'allow': response.getheader('Allow')}
observed.append(item)
return item
finally:
connection.close()
assert request('PUT', '/item', b'first')['status'] == 201
assert request('PUT', '/item', b'first')['status'] == 204
assert state['item'] == b'first'
assert request('GET', '/item')['body'] == 'first'
head = request('HEAD', '/item')
assert head['body'] == '' and head['length'] == '5'
assert request('POST', '/events', b'same')['location'] == '/events/1'
assert request('POST', '/events', b'same')['location'] == '/events/2'
assert len(state['events']) == 2
assert request('DELETE', '/item')['status'] == 204
assert request('DELETE', '/item')['status'] == 404 and state['item'] is None
assert request('OPTIONS', '/item')['allow'] == allowed
assert request('POST', '/item', b'wrong')['status'] == 405
print(json.dumps({'responses': observed, 'final_item': state['item'], 'event_count': len(state['events'])}))
finally:
server.shutdown()
thread.join()
server.server_close()
第一次 PUT 创建条目,返回二零一;重复完全相同的 PUT,返回二零四,但保存的值仍然是 first。GET 返回这五个字节,HEAD 没有响应内容,却报告 Content-Length 为五。该字段描述的是对应 GET 表示的长度,不能把它误解为 HEAD 响应实际携带了五字节正文。结果和消息语义需要一起阅读。
两次相同 POST 创建两个事件,并返回不同的 Location。随后第一次 DELETE 返回二零四,第二次返回四零四,条目仍然不存在。返回码不同并不违背删除效果的幂等性。OPTIONS 报告条目支持的方法,对这个条目使用 POST 则得到四零五和 Allow。这些断言把资源效果与响应形态区分开,避免用状态是否完全一样来判断幂等性。
实验没有执行 PATCH、缓存、条件更新或自动重试,因此不把这些能力算作已经验证。引入重试之前,应先说明重复请求的预期效果,再核对服务器是否真实实现。标准方法把承诺传递给其他组件;如果处理器本身违约,中间层不可能仅凭看到正确的方法名,就把错误的业务行为自动修正。
延伸阅读
来源
- RFC 9110: HTTP Semantics,安全性、幂等性、方法定义及 Allow 响应头,已核验 2026-09-13。
- MDN: HTTP request methods,标准请求方法的用途与性质,已核验 2026-09-13。