← 服务端与数据 · 第 4 篇

按职责组织服务端代码

按 HTTP 适配、用例、规则与仓储划分预约服务,验证事务、取消和依赖失败,并区分框架集成证据。

学完这一篇,你能做到

定义预约用例边界,用独立规则与依赖失败测试验证结果和副作用。

资料预约服务有输入解析、身份检查、名额规则、数据库与消息通知。如果所有动作写在一个 HTTP 回调里,规则很难脱离网络测试,失败时也难判断哪一步应该回滚。按职责组织代码,可以让业务规则有独立入口,再由服务用例协调外部操作。

本课以资料预约为架构案例,代码用合成价格列表演示规则与请求适配分开。它不启动 Express、Spring 或 Go 服务,不连接数据库;输出只证明纯函数与教学处理器的行为。

从用例确定责任边界

HTTP 适配负责解析输入、获得可信身份、限制请求体和映射响应;应用服务负责一个用例的步骤与事务边界;领域规则表达名额、价格或状态限制;仓储处理数据访问。边界决定依赖方向,不要求每个项目固定建四个文件夹。

预约用例可以接收用户 ID、资料 ID 与请求 ID,读取必要数据,验证名额,保存预留与结果。规则不需要知道请求头和数据库连接字符串;HTTP 层也不该把库存扣减写成散落在多个路由里的 SQL。

先列输入、输出、失败与副作用,再划分模块。只有一个简单规则时可以使用直接函数,没必要为了“企业架构”创建大量抽象工厂。复杂度增加后,把已经不同的责任拆开,比提前猜测所有未来场景更容易验证。

规则函数要明确接受什么

金额使用最小单位的整数,可以避免浮点表示带来的部分问题,但仍要定义币种、范围与四舍五入规则。数量也有上限,不应只判断非负。Python 中 bool 是 int 的子类,严格整数检查需要考虑是否允许布尔输入。

规则返回业务结果或明确错误,不直接构造 HTTP 响应。这样批处理、任务消费者和 Web 接口可以复用同一规则。失败时是拒绝、补偿还是重试,由用例与外部边界决定,不由纯计算函数自行发网络请求。

纯函数容易检查不变量,但不能用它证明存储并发或权限安全。预约名额仍需要数据库条件更新与约束。规则在应用层表达意图,数据库层保证多个写入者不能一起破坏核心条件。

应用服务协调事务与外部依赖

一个用例通常读取数据、检查规则、提交变化,再安排后续通知。将事务内必要读写集中处理,避免把长模型调用或文件上传留在锁持有期间。事务提交与外部消息发布不属于同一自动原子边界,需要 outbox、补偿或其他协调方案。

仓储接口按用例需要提供查询和操作,不必做一个万能 CRUD 抽象掩盖条件更新。保存预约时需要知道是否成功、是否重复和当前版本,而不是把任何结果都当成一个空对象。

调用外部依赖传递超时与取消条件。请求方离开时,如果底层工作可以取消,就及时停止;如果无法确定是否完成,应记录状态供恢复。取消一个界面等待不意味着数据库变化会自动撤销。

框架适配有不同约定

Express 用路由和中间件组合请求处理,顺序影响认证、解析和错误路径;具体错误处理方式应依据所用版本文档。Spring 常见 controller、service、repository 划分,但注解与目录名不会自动保证事务和业务正确。

Go net/http 使用 handler 处理请求,可以通过请求 context 传播取消与截止条件;连接和依赖调用仍需设置合适的超时。不同语言的并发模型不同,async I/O 不等于 CPU 并行,线程也不自动保护共享可变状态。

选择框架依据团队语言、依赖、部署和维护要求。一个模块化单体可以先把边界做清楚,不必一开始拆成微服务。拆服务会引入网络失败、版本与跨服务一致性问题,只有明确需求才值得承担。

用纯规则和教学适配器验证分工

total 拒绝负数与非严格整数,返回总额。handle 检查请求形状,将规则错误映射成 400,将成功映射成 200 与结果。示例中的金额 20、30 是合成最小单位,不代表实际价格。

def total(items):
    if any(type(x) is not int or x < 0 for x in items):
        raise ValueError("invalid price")
    return sum(items)
def handle(payload):
    try:
        if not isinstance(payload, dict) or not isinstance(payload.get("prices"), list):
            return {"status": 400}
        return {"status": 200, "total": total(payload["prices"])}
    except ValueError:
        return {"status": 400}
assert handle({"prices": [20, 30]}) == {"status": 200, "total": 50}
assert handle({"prices": [-1]})["status"] == 400
print(handle({"prices": [20, 30]}))

输出为 {'status': 200, 'total': 50}。负数路径的断言检查请求被拒绝;规则独立于 HTTP 结构。这个模型没有完整价格上限、币种、身份或数据库,因此不是完整收费服务,也没有真正发送状态码。

如果把 total 直接改成访问全局请求对象,规则就重新依赖入口。可以对纯函数增加空列表、布尔值、字符串和大数测试,再对适配器检查输入形状与错误映射。真实 HTTP 层还需独立验证头、序列化与服务启动。

测试边界,不复制框架实现

规则检查覆盖不变量;用例检查依赖失败、事务与重复请求;HTTP 集成检查路由、身份、参数和响应。测试对象不同,不应让每个规则测试都启动完整服务器,也不应只测函数就宣布完整服务通过。

替身依赖需要表达失败行为,例如库存不足、保存冲突与通知不可达,不只永远返回成功。测试应断言外部效果次数、失败后状态与安全错误,而不是精确复制框架内部调用顺序。

共享状态放在模块全局时,必须考虑多请求并发与多实例。内存锁可能保护单进程,但不能代替数据库跨实例规则。日志记录用例与关联 ID,避免把所有内部异常以相同“失败”吞掉。

模块循环依赖通常提示责任没有理清。若规则反过来导入 HTTP 层,只为取得一个字段,应把该字段作为显式输入。共享辅助模块也不能变成任意业务都能写入的全局状态。

练习:完成一个预约用例

写出预约输入、允许状态、名额约束和重复请求语义。分别定义纯规则、仓储操作、应用服务和 HTTP 适配,明确依赖方向。用合成依赖模拟无名额、重复提交、保存失败与通知失败。

验收要求:规则可脱离框架验证;无效输入不调用写操作;成功预留只扣一次;保存失败不发成功通知;通知失败有可恢复状态;超时条件传到可取消依赖。实际框架集成应另外记录运行结果,代码层次图与纯函数断言不能证明完整部署已可用。

资料来源与延伸阅读

资料核验:2026-11-10。本文为原创工程讲解,教学示例不代表线上实测。