定义订单与校验规则

店员提交商品编号和数量,系统才生成订单号、状态与时间。这一章直接调用 Go 方法,不经过 HTTP,先把订单本身的规则弄清楚。

本章内容

不启动服务器,也能创建订单

在 orders 工程根目录运行下面的测试。它不连接上一章的进程,也不占用 8080;测试会自己创建内存存储和业务对象。

先验证现成的业务行为
go test ./internal/order -run '^(TestCreate|TestRejectInvalidOrder|TestQuantityBoundary)$' -v -count=1
关键结果 · 省略用时和包路径
--- PASS: TestCreate
--- PASS: TestRejectInvalidOrder
--- PASS: TestQuantityBoundary
PASS

测试通过意味着:合法输入被保存,非法输入被拒绝,数量 1 和 20 都能接受。下面沿着 TestCreate 传入的 SKU: " coffee ", Quantity: 2,看清这个过程。

输入不是已经创建的订单

internal/order/service.go节选 · L19
const (
	Pending   = "pending"
	Cancelled = "cancelled"
)

type Order struct {
	ID        string    `json:"id"`
	SKU       string    `json:"sku"`
	Quantity  int       `json:"quantity"`
	Status    string    `json:"status"`
	CreatedAt time.Time `json:"created_at"`
}

type CreateInput struct {
	SKU      string `json:"sku"`
	Quantity int    `json:"quantity"`
}

CreateInput 只有两个字段,因为客户端只能决定买什么、买多少。Order 多出来的 ID、Status、CreatedAt,由服务端生成。不能直接拿 Order 当创建参数,否则就混淆了“客户端的请求”与“系统接受请求后的结果”。

这个例子中的值谁决定规则
SKU:" coffee " → "coffee"客户端输入,业务层整理去掉两端空白后为 1–64 个 Unicode 码点
Quantity:2客户端输入整数,范围 1–20
ID:32 位十六进制字符串Service 生成由 16 个随机字节编码而来
Status:pendingService 决定新订单不能由客户端指定为 cancelled
CreatedAt:本次创建时间Service 生成使用 UTC 时间

字段上的 json: 标签规定序列化名称,不会自动执行校验。Go 的 int 允许 0 和负数;“数量必须大于 0”是我们自己要写的业务规则。

沿着一份输入读完 Create

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
}

先看前四行:去掉 SKU 两端空白,再检查长度和数量。这里按 []rune 计数,不是 UTF-8 字节数;它也不是完整的“用户可见字符”计数算法。空白 SKU 会变为空字符串而被拒绝。若数量为 0,此处立即返回 ErrInvalid,下面的 ID 生成和保存都不会执行。

输入通过后,函数生成 ID,组装出 pending 订单。此时 o 只是函数中的一个 Go 值,还不代表以后能查到它。接下来调用 s.store.Create(ctx, o) 保存;如果保存失败,返回错误。只有保存成功,最后的 return 才把订单交给调用者。

所以方法的两个返回值有明确约定:成功是“订单 + nil”,失败是“零值订单 + error”。调用方必须先判断 error,不能把失败时的空 ID 当作一次成功创建。

Service 为什么能保存,又不知道数据库是什么

internal/order/service.go节选 · L37
// Store describes what the application needs, not how a database works.
type Store interface {
	Create(context.Context, Order) error
	Get(context.Context, string) (Order, error)
	// UpdateStatus must read, apply change, and save atomically. If change fails,
	// nothing is saved. change must not do network I/O or call Store again.
	UpdateStatus(context.Context, string, func(string) (string, error)) (Order, error)
}

type Service struct{ store Store }

func New(store Store) *Service { return &Service{store: store} }

Store 列出订单业务需要的三种能力:创建、查询、原子修改状态。Service 内部只记住这个接口;New(store) 把外面传进来的对象保存起来。这就是此处“依赖注入”的具体动作,没有自动查找实现的容器。

本章测试传入 memory.New(),第 5 章运行程序时会传入 SQLite 对象。两者都实现这些方法,所以 Create 中的业务规则不用重写。此时暂时不必理解 UpdateStatus 的回调,第 7 章会用取消订单解释为什么它需要是一个整体。

一条测试怎样证明订单真的保存了

internal/order/service_test.go节选 · L13
func TestCreate(t *testing.T) {
	service := order.New(memory.New())
	o, err := service.Create(context.Background(), order.CreateInput{SKU: " coffee ", Quantity: 2})
	if err != nil {
		t.Fatal(err)
	}
	if o.ID == "" || o.SKU != "coffee" || o.Quantity != 2 || o.Status != order.Pending || o.CreatedAt.IsZero() {
		t.Fatalf("unexpected order: %+v", o)
	}
	got, err := service.Get(context.Background(), o.ID)
	if err != nil || got != o {
		t.Fatalf("saved order = %+v, %v; want %+v", got, err, o)
	}
}

测试先建立自己的空 Store。它检查返回订单中的 SKU 已去空白、数量仍为 2、状态为 pending、ID 与时间不为空。然后用生成的 ID 再查一次,并比较整笔订单。后半段很重要:如果 Create 只生成一个看起来正确的对象却没有保存,查询就会失败。

另一个测试 internal/order/service_test.go 中的 TestRejectInvalidOrder 用空 SKU、纯空白、过长 SKU、负数、0 和 21 验证拒绝路径。它使用 errors.Is(err, order.ErrInvalid) 判断错误类别,不依赖一整句可能调整的提示文本。

现在回看第一次 PASS

你应能指出:哪个断言证明输入被整理,哪个断言证明订单被保存。仅检查 err == nil,还不足以证明这两件事。

让一个规则失效,观察测试为什么变红

临时把 Service.Create 的 input.SKU = strings.TrimSpace(input.SKU) 改为 input.SKU = strings.TrimSuffix(input.SKU, "")。TrimSuffix 去掉空后缀时会保持原样;这样 strings 包仍被使用,编译能通过,但去空白规则不再生效。

只运行会受影响的两个测试
go test ./internal/order -run '^(TestCreate|TestRejectInvalidOrder)$' -v -count=1
应观察到的失败 · 不是编译错误
TestCreate:SKU 仍是 " coffee ",触发 unexpected order
TestRejectInvalidOrder:纯空白 SKU 被接受,得到 nil 而不是 ErrInvalid
FAIL

这两个失败解释了同一行代码的两种作用:整理有效输入、阻止无效输入钻过长度检查。把该行恢复为 strings.TrimSpace,重跑上述命令,应恢复 PASS。继续下一章前请恢复,后面的示例仍以原规则为准。

为什么不把去空白放在 HTTP handler 里就好

那样 HTTP 请求可能正常,但本章直接调用 Service 的测试仍会失败;以后新增其他入口还要记得复制这条规则。把规则放在所有入口共同调用的 Service,才是由一处定义同一件业务。

下一步:把业务接回 HTTP

本章传入的是已经具有正确 Go 类型的 CreateInput。HTTP 发来的却是一段字节:quantity: "2" 不能解码进 int,是输入格式问题;quantity: 0 可以解码,但会被业务拒绝。下一章让入口层负责前一种情况,再把后一种情况交给刚才的 Service。

项目教程

构建一个订单服务

定义订单与校验规则

本页目录