创建和查询订单

上一章的业务方法还只是普通 Go 函数。这一章把它接到 HTTP:提交两件 coffee,拿到订单号,再查回同一笔订单。每一步都回到实际运行的代码。

本章内容

这一章,你会弄清三件事

  • 请求如何找到业务方法,JSON 在哪里变成 Go 值。
  • 订单在哪里保存,为什么保存失败就不能返回创建成功。
  • 修改一个 HTTP 行为时,哪些文件需要改,哪些不用动。

1. 创建订单,保存返回的 ID

请先停止上一章的 hello 或 orders 服务,避免两个程序同时占用 8080。在解压后的 orders 目录打开两个终端。这里使用内存存储,暂时不让数据库配置干扰请求流程。

终端 1 · 启动后保持运行
go run ./cmd/orders -memory

看到 listeners ready 后,在终端 2 发出请求。Windows PowerShell 请把下面的 curl 写成 curl.exe;使用工程内的 JSON 文件,可以避开命令行引号差异。

requests/create.jsonL1
{"sku":"coffee","quantity":2}
终端 2 · 创建一笔订单
curl -i -H "Content-Type: application/json" --data-binary "@requests/create.json" http://127.0.0.1:8080/orders
预期响应 · 为便于阅读已格式化;ID 和时间仅作示例
HTTP/1.1 201 Created
Location: /orders/7b41d3e8275842c88dca6a591a3cbe20
Content-Type: application/json

{
  "id": "7b41d3e8275842c88dca6a591a3cbe20",
  "sku": "coffee",
  "quantity": 2,
  "status": "pending",
  "created_at": "2026-10-21T08:00:00Z"
}
现在检查

状态码应是 201;sku 和 quantity 与输入相同;id、status、created_at 是服务端补上的。复制你自己响应里的完整 ID,下一步会用到它。不要使用本文的示例 ID。

你拿到的工程已经包含完整实现。本章不是要求再粘贴一遍所有函数,而是先运行、再沿这次请求逐段拆解,最后独立修改一个行为。下面标注“节选”的代码均取自下载包;点击文件名可以查看上下文。

2. 找到请求进入项目的位置

打开 internal/httpapi/api.go。在 Register 中,API 对象记住传入的业务服务,然后把路径交给 HTTP 端点:

internal/httpapi/api.go节选 · L19
type API struct{ orders *order.Service }

func Register(endpoint *httpx.Endpoint, orders *order.Service, ready func(context.Context) error) {
	api := &API{orders: orders}

orders 是 API 对象持有的 Service。继续往下看同一个 Register 的订单路由部分;中间的健康检查路由留到第 8 章再讲:

internal/httpapi/api.go节选 · L34
	endpoint.Post("/orders", api.create)
	endpoint.Get("/orders/{id}", api.get)
	endpoint.Post("/orders/{id}/cancel", api.cancel)
}

POST /orders 对应 api.create,GET /orders/{id} 对应 api.get。注册时只保存对应关系;真正收到请求后,HTTP 端点才调用这个函数。

这些业务对象并不是每个请求重新创建的。启动时,internal/app/app.go 创建 Service 并调用 Register;服务运行期间,handler 一直使用保存在 a.orders 中的对象。第 4 章会完整拆开这段组装过程。

  1. HTTP 入口读懂请求internal/httpapi/api.go

    create 把请求体解码成 CreateInput,再调用业务方法。

  2. 业务方法决定能否创建internal/order/service.go

    Create 检查商品编号与数量,补上 ID、初始状态和时间。

  3. 存储实现保存这笔订单internal/memory/store.go

    本次启用了 -memory,因此 Store.Create 最终把订单放进 map。

  4. 结果沿调用路径返回internal/httpapi/api.go

    保存成功 → Service 返回 Order → create 写出 201 和 JSON。

这四步发生在一个 Go 进程里,不是四个微服务。除客户端到 HTTP 端点的网络请求外,这里主要是普通函数调用。

3. 把 JSON 交给业务方法

在 create 中,Content-Type 和请求体大小先被检查。通过后,下面这段把字节转换为上一章定义的 order.CreateInput:

internal/httpapi/api.go节选 · L52
	var input order.CreateInput
	decoder := json.NewDecoder(bytes.NewReader(body))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&input); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_json"})
	}
	if err := decoder.Decode(new(any)); err != io.EOF {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "one_json_object_required"})
	}

第一次 Decode 成功后,input 的值等价于 order.CreateInput{SKU: "coffee", Quantity: 2}。此时还没有订单 ID,也没有写入任何存储。

DisallowUnknownFields 会拒绝拼错的字段,例如把 quantity 写成 quanity。第二次 Decode 要求读到 EOF,是为了拒绝在同一个请求体里连续发送两个 JSON 对象。它不是在创建第二笔订单。

为什么还需要 Content-Type 和请求体检查?

入口层要先明确“怎样解释这些字节”和“最多接收多少”。非 JSON 内容返回 415;请求体超出本例的 4 KiB 限制返回 413。它们和“数量不能为 0”不是同一种错误:前者属于 HTTP 边界,后者属于业务规则。

internal/httpapi/api.go节选 · L39
func (a *API) create(c *httpx.Context) error {
	mediaType, _, err := mime.ParseMediaType(c.Request().Header.Get("Content-Type"))
	if err != nil || mediaType != "application/json" {
		return c.JSON(http.StatusUnsupportedMediaType, map[string]string{"error": "use_application_json"})
	}
	body, err := c.Body()
	if err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			return c.JSON(http.StatusRequestEntityTooLarge, map[string]string{"error": "body_too_large"})
		}
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid_body"})
	}

接着,handler 创建一个最长 2 秒的上下文,并把 input 传给 a.orders.Create。上下文会继续传到存储,供支持取消的操作使用;它不会强行终止任意 Go 代码。

4. 保存成功,才返回 201

现在跟到 internal/order/service.go。这就是上一章的 Create,不是一份专门为 HTTP 重新编写的业务:

internal/order/service.go节选 · L50
func (s *Service) Create(ctx context.Context, input CreateInput) (Order, error) {
	input.SKU = strings.TrimSpace(input.SKU)
	if n := len([]rune(input.SKU)); n == 0 || n > 64 || input.Quantity < 1 || input.Quantity > 20 {
		return Order{}, ErrInvalid
	}
	var id [16]byte
	if _, err := rand.Read(id[:]); err != nil {
		return Order{}, err
	}
	o := Order{
		ID: hex.EncodeToString(id[:]), SKU: input.SKU, Quantity: input.Quantity,
		Status: Pending, CreatedAt: time.Now().UTC(),
	}
	if err := s.store.Create(ctx, o); err != nil {
		return Order{}, err
	}
	return o, nil
}

对于 coffee × 2,校验通过后得到完整的 Order。注意高亮的调用顺序:先保存,再返回。假如保存返回 error,业务方法立刻返回错误,后面的成功返回不会执行。

这次的 Store 是内存实现。打开 internal/memory/store.go,你能找到订单真正进入共享状态的位置:

internal/memory/store.go节选 · L20
func (s *Store) Create(ctx context.Context, o order.Order) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return err
	}
	if _, exists := s.orders[o.ID]; exists {
		return errors.New("duplicate order id")
	}
	s.orders[o.ID] = o
	return nil
}

map 的键是 ID,值是 Order。多个 HTTP 请求可能同时访问它,所以这一段用锁保护;同一个进程后续的查询可以根据 ID 取回订单。内存保存不代表磁盘持久化,停止进程会丢失这些数据,第 5 章才替换成 SQLite。

Store 返回 nil 后,Service 返回这笔 Order,程序回到 HTTP handler:

internal/httpapi/api.go节选 · L61
	ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second)
	defer cancel()
	o, err := a.orders.Create(ctx, input)
	if err != nil {
		return respondError(c, err)
	}
	c.Header().Set("Location", "/orders/"+o.ID)
	return c.JSON(http.StatusCreated, o)
}

Location 告诉客户端可以去哪里读取新资源;c.JSON 将 Order 编码为 JSON,并设置 201。业务代码不需要知道这些 HTTP 细节。

把响应里的字段对回代码

sku、quantity 来自输入;id、status、created_at 由 Service 填写;201 和 Location 由 HTTP handler 决定。Store 负责保存 Order,不负责选择状态码。

5. 用 ID 查回同一笔订单

保持终端 1 不退出,将下面的 ORDER_ID 换成第 1 步复制的完整 ID:

终端 2 · Windows 使用 curl.exe
curl -i http://127.0.0.1:8080/orders/ORDER_ID

应当得到 200,响应的五个字段都与创建时一致。尤其 ID 和创建时间不应该变;如果它们变化,就可能是重新创建而不是查询。

先试着回答:ID 从哪里取?应该调用哪个 Service 方法?找不到时谁决定返回 404?再展开实现对照。

查看查询实现与调用关系
internal/httpapi/api.go节选 · L71
func (a *API) get(c *httpx.Context) error {
	ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second)
	defer cancel()
	o, err := a.orders.Get(ctx, c.Param("id"))
	if err != nil {
		return respondError(c, err)
	}
	return c.JSON(http.StatusOK, o)
}

c.Param("id") 从匹配到的路径中取值。Service.Get 把 ID 传给 Store.Get;内存实现按 orders[id] 查找。成功就返回原来的 Order,失败则交给 respondError。

internal/order/service.go节选 · L69
func (s *Service) Get(ctx context.Context, id string) (Order, error) {
	return s.store.Get(ctx, id)
}
internal/memory/store.go节选 · L33
func (s *Store) Get(ctx context.Context, id string) (order.Order, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return order.Order{}, err
	}
	o, ok := s.orders[id]
	if !ok {
		return order.Order{}, order.ErrNotFound
	}
	return o, nil
}

这里没有生成 ID,也没有创建时间。它们来自刚才保存的 Order,这正是“查回同一笔订单”的含义。

如果返回 404,先检查是否完整复制了 ID,以及中途是否重启过内存模式。不要仅凭一个 404 就改路由:路径不匹配和业务找不到记录都可能使用这个状态码,响应内容与注册代码可以帮助区分。

6. 用失败请求检查分层

接下来故意发送数量为 0 的请求。工程已提供对应文件:

requests/invalid.jsonL1
{"sku":"coffee","quantity":0}
合法 JSON,不合法的业务输入
curl -i -H "Content-Type: application/json" --data-binary "@requests/invalid.json" http://127.0.0.1:8080/orders
预期响应
HTTP/1.1 422 Unprocessable Entity

{"error":"invalid_order"}

JSON 解码已经成功;Service.Create 拒绝数量,且在调用 Store 之前就返回了 ErrInvalid。HTTP 层的 respondError 再把这个 Go error 转成状态码和公共错误名称。

internal/httpapi/api.go节选 · L91
func respondError(c *httpx.Context, err error) error {
	status, code := http.StatusInternalServerError, "internal_error"
	switch {
	case errors.Is(err, order.ErrInvalid):
		status, code = http.StatusUnprocessableEntity, "invalid_order"
	case errors.Is(err, order.ErrNotFound):
		status, code = http.StatusNotFound, "order_not_found"
	case errors.Is(err, order.ErrConflict):
		status, code = http.StatusConflict, "order_conflict"
	case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
		status, code = http.StatusServiceUnavailable, "request_unavailable"
	default:
		// Details go to server logs, never into the public response.
		log.Printf("order operation failed: %v", err)
	}
	return c.JSON(status, map[string]string{"error": code})
}

存储失败则是另一条路径:Service 将错误返回,HTTP 层给出 500 和 internal_error,详细原因只写服务端日志。它不能被包装成“用户数量填错了”,也不能泄露 SQL 或连接信息。

尝试应得到在哪里拒绝
quantity 写成字符串 "2"400 · invalid_jsonHTTP JSON 解码
quantity 写成数字 0422 · invalid_orderService 校验
查询一个不存在的 ID404 · order_not_foundStore 查找,HTTP 映射错误
没有 JSON Content-Type415 · use_application_jsonHTTP 媒体类型检查
请求体超过 4 KiB413 · body_too_large请求体限制
存储写入失败500 · internal_error存储失败向上传递

不必手动构造所有边界。下面的测试通过真正的 HTTP endpoint 检查创建、查询、取消与错误响应,使用测试内的内存存储,不会修改刚才启动的服务:

验证本章的接口行为
go test ./internal/httpapi -v -count=1

应看到 TestHTTPJourney、TestHTTPFailures 和 TestInternalErrorIsNotExposed 通过。后者使用故意失败的 Store,验证存储错误确实返回 500,且不会把内部详情交给客户端。

7. 练习:只修改 HTTP 响应

一个新需求:查询订单的成功响应应带上 Cache-Control: no-store,告知遵守该指令的缓存不要保存这份响应。业务数据和 JSON 结构保持不变。

  1. 先执行一次 curl -i 查询,确认当前没有这个响应头。
  2. 找到处理查询响应的函数,添加响应头。不要把 HTTP header 放进 Order 或 Store。
  3. 停止并重启服务。由于使用 -memory,先重新创建一笔订单,再用新的 ID查询。
  4. 检查状态码仍为 200、订单字段正确,并出现 Cache-Control: no-store。最后重跑 HTTP 测试。
查看修改位置与原因

只需修改 internal/httpapi/api.go 的 get:在成功的 return c.JSON(http.StatusOK, o) 之前加入 c.Header().Set("Cache-Control", "no-store")。

放在处理 error 的分支之后、写响应之前,就只影响成功的查询响应。HTTP 头属于入口适配,Service、Store、app 和 TCP 入口都不用改。

现有测试可以检查原行为有没有被破坏,但不会自动验证新增的缓存头。要把新需求留在测试里,可在 TestHTTPJourney 的 GET 成功分支中检查 got.Header().Get("Cache-Control") 是否等于 "no-store"。

现在请求路径已经清楚了,还剩一个问题:这些对象最初由谁创建,为什么不同请求拿到的是同一个 Store?下一章回到 main 与 app,把启动时的连接过程补完整。

项目教程

构建一个订单服务

创建和查询订单

本页目录