Skip to content

RESTful 设计:让调用方知道一次请求究竟做了什么 ​

假设你要给订单系统设计 API。创建订单超时后,客户端能不能重试?两个人同时修改地址,后保存的人会不会覆盖先保存的内容?翻第二页时又插入了新订单,会不会重复看到上一页?这些问题不会因为 URL 写成复数名词就消失。

RESTful 设计要把资源、操作语义、失败方式和演进规则放进一份稳定契约。本文用一个可修改的资源串起这些决定,并用 Go 1.27.2 的小实验验证条件请求、幂等去重与分页现象。

先确定资源,再决定 URL ​

资源是服务愿意让调用方识别和操作的对象,不等同于数据库中的一行。一个订单、订单集合、导出任务、某次退款都可以是资源。JSON 是资源的一种表示;同一资源也可能有不同语言、不同媒体类型的表示。

要表达的事情可用的资源设计这样设计方便什么
查询某订单GET /orders/42稳定标识单个对象
创建订单POST /orders服务端分配 ID,返回新资源地址
查询订单下的退款GET /orders/42/refunds表达从属关系
创建退款POST /orders/42/refunds退款本身可查询、审计、有状态
启动大批量导出POST /exports返回任务资源,避免长时间占请求
查看导出进度GET /exports/9轮询任务状态及下载地址

“资源化”并不要求把所有业务都伪装成 CRUD。例如审核、结算有自己的状态机和权限检查;可以建审核记录资源,也可以明确设计动作接口,关键是说清副作用与重复调用语义。不要让 GET /orders/42?delete=true 执行删除,因为缓存、爬虫、预取都可能自动发 GET。

严格的 REST 还包括客户端与服务端分离、无状态交互、缓存、统一接口、分层等约束;统一接口涉及通过表示和超媒体推动应用状态。许多现实中的 JSON API 只借用了其中一部分。本文讨论这些 API 最常用的设计手段,不把“用了 HTTP 和 JSON”当成满足全部 REST 约束的证明。Fielding 论文第 5 章

无状态也不是“服务器不能存数据”。订单当然要存。它要求服务器能从当前请求及资源状态理解请求,不依赖某台应用实例里隐含的“上次对话走到哪一步”。身份凭据、目标资源、操作参数都应明确,这样请求才能经负载均衡到不同实例。

方法语义决定客户端能做什么假设 ​

方法常见用途安全幂等
GET、HEAD读取表示或元信息是是
PUT用给定表示创建或替换目标资源否是
DELETE删除目标资源关联否是
POST由目标资源处理提交内容,例如创建子资源否无通用保证
PATCH按补丁格式局部修改否无通用保证,取决于补丁含义

“安全”是调用方不请求改变资源状态,日志、计费统计等附带效果不在这个承诺内。“幂等”是同一请求重复执行,对服务器的预期效果与执行一次相同,不要求响应内容、时间戳或状态码完全相同。RFC 9110 §9.2

例如第一次 DELETE 返回 204,第二次因为资源已经不存在而返回 404,仍可符合幂等语义。PUT 把数量设成 5,重发后还是 5;PATCH 若表达“数量加 1”,重发就可能变成 6。PATCH 必须声明补丁媒体类型和并发控制方式,不能仅凭方法名判断可重试。RFC 5789 §2

为什么 POST 重试需要业务幂等键 ​

客户端没收到响应,只能知道“结果未知”,无法知道服务器是否已经创建订单。若重发时重新生成一个幂等键,服务端会把它当成新操作。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant C as 客户端
    participant A as API
    participant D as 持久化存储
    C->>A: 创建订单,key=K
    A->>D: 原子检查 K,写订单和结果
    D-->>A: 订单 42 已提交
    A--xC: 响应丢失
    C->>A: 同一输入、同一 key=K
    A->>D: 查 K 与请求指纹
    D-->>A: 已完成,结果为订单 42
    A-->>C: 返回已记录的结果
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant C as 客户端
    participant A as API
    participant D as 持久化存储
    C->>A: 创建订单,key=K
    A->>D: 原子检查 K,写订单和结果
    D-->>A: 订单 42 已提交
    A--xC: 响应丢失
    C->>A: 同一输入、同一 key=K
    A->>D: 查 K 与请求指纹
    D-->>A: 已完成,结果为订单 42
    A-->>C: 返回已记录的结果

文末的小型去重器把“检查键、比较输入、创建结果”放在同一把锁内。关键部分如下(省略错误返回以外的日志,完整函数见文末):

go
d.mu.Lock()
defer d.mu.Unlock()
digest := sha256.Sum256([]byte(body))
if old, ok := d.entries[key]; ok {
    if old.digest != digest {
        return 0, false, errors.New("key reused with different input")
    }
    return old.id, true, nil
}
d.next++
d.entries[key] = result{digest: digest, id: d.next}

这里的 entries 需要先初始化;完整函数包含这一步。连续三次用同一键,输入分别是 book、book、pen,得到:

text
id=1 replay=false error=false
id=1 replay=true error=false
id=0 replay=false error=true

第二次复用结果,第三次拒绝把旧键用在不同输入上。另一个测试让 20 个 goroutine 同时提交同一输入,断言只生成一个 ID,并通过 race 检查。

这是进程内算法示范。生产实现还要把键与租户或用户、操作名绑定,明确有效期、执行中状态、失败结果是否保存,并让幂等记录与业务写入具有同一事务边界。内存 map 在重启后丢失,不能当成支付或订单的可靠去重存储。外部副作用跨越事务时,需要下游同样支持幂等,或使用任务/消息与补偿设计。

状态码先表达大类,错误体再解释细节 ​

情况常用状态码调用方应理解什么
返回表示200按声明的媒体类型读取 Body
新资源已创建201通常用 Location 指向新资源
工作已接收,尚未完成202需要任务查询等后续机制;不保证最终成功
成功,无响应内容204不要再按 JSON 解码
条件读取发现未变化304重用已有表示;不是普通成功 JSON
请求格式错误400修改输入,直接重试通常无用
缺少或无效身份凭据401结合 WWW-Authenticate 处理身份验证
无权执行403换同样凭据重试通常无用
资源不存在,或服务不愿透露其存在404不从状态码反推资源一定存在或不存在
方法不支持405读取 Allow
与当前业务状态冲突409例如状态机不允许这次操作
条件请求前提不成立412重新获取版本,再决定是否合并
格式正确但内容不能处理422返回具体字段或业务校验问题
请求过多429按策略与 Retry-After 退避
服务暂时不可用503是否重试还要看操作语义与预算

各状态的规范定义见 RFC 9110 §15,429 和可用于强制条件写入的 428 见 RFC 6585。这张表是常见映射,不是让所有业务硬套唯一状态码。比如验证失败用 400 还是 422,需要在契约中统一。

不要所有错误都返回 HTTP 200,再让调用方读取 code != 0。HTTP 基础设施只看到了成功,监控、缓存和通用重试器便无法利用状态语义。

统一错误体可以采用 RFC 9457 的 Problem Details:

json
{
  "type": "https://api.example/problems/invalid-count",
  "title": "Invalid count",
  "status": 422,
  "detail": "count must be between 1 and 10",
  "instance": "/requests/r-42"
}

上面是格式示例,不是一个真实请求的输出。媒体类型是 application/problem+json。type 标识稳定的问题类型;title 是该类问题的简短说明;detail 解释这一次失败;instance 标识这次发生。客户端应依赖稳定的类型或自定义机器字段,别解析人类可读的英文句子。Body 中的 status 应与实际 HTTP 状态一致;不要泄露 SQL、堆栈、访问令牌或内部地址。RFC 9457 §3、§5

用条件请求避免重复传输和覆盖别人的修改 ​

ETag 是资源表示的校验标识,不是必须等于数据库主键的字段。它可以随内容变化。弱 ETag 形如 W/"v1",只表达语义等价;If-Match 使用强比较,不能随便拿弱 ETag 做乐观锁。RFC 9110 §8.8、§13

请求服务端判断结果
GET 加 If-None-Match表示仍与缓存相符304,不再传完整内容
PUT 加 If-Match客户端修改基于当前版本满足才写入,否则 412
本 API 要求 If-Match,但没提供无法检查覆盖风险本文示例返回 428

本次 PUT 的核心是锁内完成版本比较与替换;这段来自文末 Handler,next 是已经解码、校验的新内容:

go
s.mu.Lock()
defer s.mu.Unlock()
tag := r.Header.Get("If-Match")
if tag == "" { problem(w, 428); return }
if tag != s.etag() { problem(w, 412); return }
s.value = next
w.WriteHeader(204)

本次测试的过程如下,状态码都由断言验证:

text
GET 当前内容                 → 200,得到 ETag A
GET If-None-Match: A          → 304
PUT 新内容,If-Match: A        → 204
PUT 另一内容,仍 If-Match: A   → 412

比较版本与写入必须原子执行。先查版本、释放锁、再写入,会让两个请求都通过检查。示例用 Mutex 包住两步;数据库实现通常用版本列条件更新或事务,并检查受影响行数。

示例只接受单个强 ETag 的精确匹配,没有实现 ETag 列表、星号、弱比较等完整 HTTP 语法。生产解析器要按规范处理,不能把这段教学字符串比较直接当成通用实现。示例 PUT 会把 JSON 解码后重新表示,未保存原始报文字节,因此成功 PUT 不直接发新的验证器,而让随后 GET 返回 ETag;这是为了遵守 PUT 对转换后表示返回验证器的限制。RFC 9110 §9.3.4

缓存控制也需要明确。no-cache 允许存储,但使用前需要验证;no-store 才要求不存储。含用户私有信息的表示不能不加区分地给共享缓存使用;private 限制共享缓存。变更响应也不等于所有缓存副本瞬间失效。RFC 9111 §5.2

分页、过滤和排序必须一起设计 ​

先过滤再排序,然后取一页。排序要形成稳定的全序,例如 created_at DESC, id DESC。只有 created_at 时,多条记录时间相同,数据库可以用不同顺序返回它们,翻页便可能重复或遗漏。

方案请求示例优点代价
Offset?limit=20&offset=40简单,容易跳页前面插入/删除会移动偏移;深分页可能昂贵
Cursor / keyset?limit=20&after=...可按稳定排序键继续向后取一般不便任意跳页;游标需绑定查询条件
快照分页游标还带快照标识可保持一次遍历的稳定视图需要存储或数据库支持,寿命与成本更高

用整数 ID 模拟升序列表,第一页取 [1 2],随后在前面插入 0。代码直接显示两种“继续”的含义:

go
rows := []int{1, 2, 3, 4}
fmt.Println("first:", rows[:2])
rows = append([]int{0}, rows...)
fmt.Println("offset=2:", rows[2:4])
after := slices.DeleteFunc(slices.Clone(rows), func(id int) bool { return id <= 2 })
fmt.Println("after=2:", after[:2])

本次运行:

text
first: [1 2]
offset=2: [2 3]
after=2: [3 4]

Offset 的第二页重复了 2。按键继续能避开这个例子的重复,但它仍不保证跨页快照一致:若排序字段可变,记录可能跨过游标;若后续记录被删,也不会神奇出现。导出财务报表等需要稳定集合的场景,要另建快照或明确截至时间。这里是基于列表实验的设计推论,没有声称测过任何数据库。

过滤字段、操作符和排序字段应列白名单;页大小设默认值和最大值。不要把客户端的排序文本直接拼成 SQL。游标可以封装排序键、过滤条件摘要和版本,并签名防篡改;Base64 只是一种编码,不提供保密或完整性。无论游标来自哪里,每页都要重新执行权限检查。

版本管理:改变路径并不能自动兼容 ​

兼容性看的是已有调用方能否继续正确工作。给响应加一个可选字段通常较容易兼容,但严格拒绝未知字段的客户端可能失败;增加枚举值也可能打破客户端的穷举分支。删除字段、改变单位、把整数改成字符串、收紧输入范围,风险更明确。

做法适合什么情况要承担的维护
保持现有版本,增加可选能力可以兼容旧客户端保留默认行为,测试旧客户端
路径版本,如 /v2/orders契约确实有破坏性变化并行运营版本、迁移、明确下线计划
媒体类型或请求头协商已有完善内容协商体系缓存 Vary、网关、SDK、文档都要配合

URI 版本不是 REST 的强制规则。选哪一种不如把迁移窗口、弃用通知和契约测试做清楚。分页游标本身也要有版本或过期策略,不能部署新排序规则后,继续把旧游标当成新格式解析。

OpenAPI 要写到能约束实现的程度 ​

OpenAPI 描述 HTTP API 的路径、参数、请求与响应结构和安全方案。它不证明实现符合描述,也不会自动帮你处理幂等、权限或事务。文末给出本次实验的完整 openapi.json,固定使用 3.1.1,以免把“规范版本”和“工具当前支持版本”混为一谈。OpenAPI 3.1.1

最容易漏的不是成功示例,而是边界:哪些字段必填,缺失和 null 是否不同,最大页大小,数字范围,错误媒体类型,204 是否没有 Body,哪些响应带 ETag,写入是否要求 If-Match。它们同时进入测试,文档才不只是漂亮的展示页。

可以从契约生成服务端接口和客户端,再在 CI 中运行规范校验、破坏性变更检查和真实请求契约测试;也可以从代码生成描述,但仍需审查输出是否表达了业务限制。本文只验证了 JSON 文件语法与代码中的行为,没有运行 OpenAPI 完整规范验证器,也没有声称生成器能接受该文件。

资料与实测范围 ​

Go 1.27.2、Linux/amd64,go test、go test -race、go vet 全通过。验证覆盖 304、412、重复替换最终状态、并发去重及分页移动反例。未启动数据库、外部缓存或网关;可靠事务、缓存失效和实际查询代价是设计讨论,不是本次测量结论。

完整复现代码与契约 ​

将以下文件放在同一目录。运行:

sh
go test -v -count=1 ./...
go test -race -count=1 ./...
go vet ./...
python -m json.tool openapi.json >/dev/null

最后一条只检查 JSON 语法。示例聚焦条件请求,没有加入身份验证、持久化存储及完整媒体类型协商。

go.mod ​

go.mod
module example.com/webapi/02_rest

go 1.27.2

rest_test.go ​

代码块 · 116 行 · 点击收起 / 展开
go
package restdemo

import (
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
)

type item struct {
	Title string `json:"title"`
}
type store struct {
	mu    sync.Mutex
	value item
}

func (s *store) etag() string { return fmt.Sprintf(`"%x"`, sha256.Sum256([]byte(s.value.Title))) }
func problem(w http.ResponseWriter, status int) {
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]any{"type": "about:blank", "title": http.StatusText(status), "status": status})
}
func (s *store) handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /items/1", func(w http.ResponseWriter, r *http.Request) {
		s.mu.Lock()
		defer s.mu.Unlock()
		tag := s.etag()
		w.Header().Set("ETag", tag)
		w.Header().Set("Cache-Control", "private, no-cache")
		// This exercise accepts exactly one strong ETag, not the full HTTP list grammar.
		if r.Header.Get("If-None-Match") == tag {
			w.WriteHeader(304)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		if r.Method == http.MethodHead {
			return
		}
		_ = json.NewEncoder(w).Encode(s.value)
	})
	mux.HandleFunc("PUT /items/1", func(w http.ResponseWriter, r *http.Request) {
		r.Body = http.MaxBytesReader(w, r.Body, 256)
		defer r.Body.Close()
		var next item
		d := json.NewDecoder(r.Body)
		d.DisallowUnknownFields()
		if err := d.Decode(&next); err != nil || next.Title == "" {
			problem(w, 400)
			return
		}
		var extra any
		if err := d.Decode(&extra); err != io.EOF {
			problem(w, 400)
			return
		}
		s.mu.Lock()
		defer s.mu.Unlock()
		tag := r.Header.Get("If-Match")
		if tag == "" {
			problem(w, 428)
			return
		}
		if tag != s.etag() {
			problem(w, 412)
			return
		}
		s.value = next
		// No ETag here: this server transforms JSON on storage, so PUT validator rules apply.
		w.WriteHeader(204)
	})
	return mux
}
func TestConditionalUpdate(t *testing.T) {
	s := &store{value: item{Title: "before"}}
	h := s.handler()
	call := func(method, body, header, value string) *httptest.ResponseRecorder {
		r := httptest.NewRequest(method, "/items/1", strings.NewReader(body))
		if header != "" {
			r.Header.Set(header, value)
		}
		w := httptest.NewRecorder()
		h.ServeHTTP(w, r)
		return w
	}
	first := call("GET", "", "", "")
	old := first.Header().Get("ETag")
	if old == "" {
		t.Fatal("missing validator")
	}
	if w := call("GET", "", "If-None-Match", old); w.Code != 304 {
		t.Fatal(w.Code)
	}
	if w := call("PUT", `{"title":"after"}`, "If-Match", old); w.Code != 204 {
		t.Fatal(w.Code)
	}
	if w := call("PUT", `{"title":"lost update"}`, "If-Match", old); w.Code != 412 || w.Header().Get("Content-Type") != "application/problem+json" {
		t.Fatal(w.Code)
	}
	current := call("GET", "", "", "").Header().Get("ETag")
	for i := 0; i < 2; i++ {
		if w := call("PUT", `{"title":"after"}`, "If-Match", current); w.Code != 204 {
			t.Fatal(w.Code)
		}
	}
	if s.value.Title != "after" {
		t.Fatal("unexpected final state")
	}
	t.Log("304, stale-write 412, and repeated replacement final state verified")
}

teaching_test.go ​

代码块 · 78 行 · 点击收起 / 展开
go
package restdemo

import (
	"crypto/sha256"
	"errors"
	"fmt"
	"slices"
	"sync"
	"testing"
)

func Example_pagination() {
	rows := []int{1, 2, 3, 4}
	fmt.Println("first:", rows[:2])
	rows = append([]int{0}, rows...)
	fmt.Println("offset=2:", rows[2:4])
	after := slices.DeleteFunc(slices.Clone(rows), func(id int) bool { return id <= 2 })
	fmt.Println("after=2:", after[:2])
	// Output:
	// first: [1 2]
	// offset=2: [2 3]
	// after=2: [3 4]
}

type result struct {
	digest [32]byte
	id     int
}
type deduper struct {
	mu      sync.Mutex
	next    int
	entries map[string]result
}

func (d *deduper) create(key, body string) (int, bool, error) {
	d.mu.Lock()
	defer d.mu.Unlock()
	digest := sha256.Sum256([]byte(body))
	if old, ok := d.entries[key]; ok {
		if old.digest != digest {
			return 0, false, errors.New("key reused with different input")
		}
		return old.id, true, nil
	}
	d.next++
	if d.entries == nil {
		d.entries = make(map[string]result)
	}
	d.entries[key] = result{digest: digest, id: d.next}
	return d.next, false, nil
}
func Example_idempotency() {
	d := new(deduper)
	for _, input := range []string{"book", "book", "pen"} {
		id, replay, err := d.create("user-7:key-1", input)
		fmt.Printf("id=%d replay=%v error=%v\n", id, replay, err != nil)
	}
	// Output:
	// id=1 replay=false error=false
	// id=1 replay=true error=false
	// id=0 replay=false error=true
}
func TestConcurrentDedup(t *testing.T) {
	d := new(deduper)
	var wg sync.WaitGroup
	for range 20 {
		wg.Go(func() {
			id, _, err := d.create("same", "book")
			if id != 1 || err != nil {
				t.Errorf("id=%d err=%v", id, err)
			}
		})
	}
	wg.Wait()
	if d.next != 1 {
		t.Fatal(d.next)
	}
}

openapi.json ​

json
{
  "openapi": "3.1.1",
  "info": {"title": "Conditional item example", "version": "1.0.0"},
  "paths": {
    "/items/1": {
      "get": {
        "operationId": "getItem",
        "parameters": [{"name": "If-None-Match", "in": "header", "schema": {"type": "string"}}],
        "responses": {
          "200": {"description": "Current item", "headers": {"ETag": {"schema": {"type": "string"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Item"}}}},
          "304": {"description": "Unchanged"}
        }
      },
      "put": {
        "operationId": "replaceItem",
        "parameters": [{"name": "If-Match", "in": "header", "required": true, "schema": {"type": "string"}}],
        "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Item"}}}},
        "responses": {
          "204": {"description": "Replaced"},
          "400": {"$ref": "#/components/responses/Problem"},
          "412": {"$ref": "#/components/responses/Problem"},
          "428": {"$ref": "#/components/responses/Problem"}
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Item": {"type": "object", "required": ["title"], "additionalProperties": false, "properties": {"title": {"type": "string", "minLength": 1}}},
      "Problem": {"type": "object", "required": ["type", "title", "status"], "properties": {"type": {"type": "string"}, "title": {"type": "string"}, "status": {"type": "integer"}}}
    },
    "responses": {"Problem": {"description": "Request failed", "content": {"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}}}}
  }
}

保留各书来源与许可 · 示例运行条件以正文为准