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 响应实际携带了五字节正文。结果和消息语义需要一起阅读。
...