3121 字
约 10 分钟
13
S03-A03 · 算法与业务的分层:把逻辑放到正确的位置

"这段逻辑写在哪里"是日常设计里出现频率最高的决策,也是最容易被随手决定的一个。写在控制器里能跑,写在 SQL 里也能跑,写在实体里同样能跑——但三种写法的可测试性、变更成本、失效边界完全不同。分层的目的不是让代码符合某种图示,而是让每类逻辑落在变更频率与验证手段都最匹配的位置。


1. 四个落位候选

候选位置 擅长 不擅长 适用判据
数据库(SQL / 约束 / 存储过程) 集合运算、聚合、原子性约束 可变业务策略、跨系统计算 一次处理大量行且结果只与库内数据相关
应用服务 编排、事务边界、权限校验 领域不变量、算法细节 需要协调多个协作者并决定提交时机
领域对象 / 领域服务 不变量、状态转换、计算归属 大范围集合运算、IO 逻辑与某个实体状态强相关且需要长期维护
前端 交互即时反馈、展示派生 权威校验、跨用户一致性 只影响呈现、错误代价可回滚

判断顺序:先看错误代价,再看变更频率,最后看数据位置。错误代价高的逻辑必须靠后(服务端),变更频率高的逻辑必须集中,数据位置只影响通信成本。


2. 什么该进数据库

应进数据库 理由
大范围过滤与聚合 数据在库内,搬出来算等于放大网络与内存
集合去重、窗口函数、分组统计 SQL 表达力强且有优化器
唯一性、外键、非空等结构约束 约束在所有写入路径上生效
不该进数据库 后果
业务规则(折扣、审批条件) 规则与代码分裂,测试手段缺失
可变策略与阈值 改规则要发 DDL,无法灰度
依赖外部系统的判断 数据库内无法可靠获取外部状态
复杂的条件分支 难以调试、难以覆盖测试
-- 该进库的:一次算出汇总,避免把行搬到应用层
SELECT user_id, COUNT(*) AS cnt, SUM(amount) AS total
FROM  order_item
WHERE created_at >= ? AND status = 'PAID'
GROUP BY user_id
HAVING SUM(amount) > ?;

存储过程的真实成本是不可移植与不可单测:逻辑进了数据库,单测要连库,版本管理与发布流程也与代码分家。只在"必须原子完成且数据全在库内"时才值得。


3. 应用服务该薄到什么程度

// 应用服务:只做编排、事务与权限,不写规则
@Transactional
public Long placeOrder(PlaceOrderCmd cmd, CurrentUser user) {
    user.assertHasPermission("order:create");        // 权限校验
    var order = orderFactory.create(cmd, clock);     // 委托领域对象
    orderRepo.save(order);                           // 持久化
    eventPublisher.publish(order.pulledEvents());    // 事务后发事件
    return order.id();
}
应用服务该有 应用服务不该有
用例编排(调谁、按什么顺序) 具体的金额计算、折扣规则
事务边界(@Transactional 位置) 大段 if/else 业务分支
权限与租户隔离校验 拼装 SQL 或直接拼 DTO 字段
事件发布时机 调用第三方后的重试细节

贫血模型的真实代价:规则散落在各个服务方法里,同一规则被复制到多个用例,改一处漏一处。代价不在"不够面向对象",而在于缺少唯一的不变量持有者——没有人能回答"订单在什么条件下是合法的"。


4. 领域对象与领域服务

归属 内容 例子
实体自身 不变量、状态转换、与自身状态相关的计算 order.pay() 校验状态与金额
值对象 无标识的规则封装 Money.add() 校验币种一致
领域服务 需要多个实体协作、或规则本身无自然归属 PricingService 跨商品与优惠券
应用服务 只编排,不实现 用例入口

引入领域服务的信号是规则需要多个聚合的数据、且不属于其中任何一个。如果没有这个特征而引入领域服务,通常只是把贫血模型的逻辑换了个类名。

public void pay(Money paid) {
    if (status != Status.CREATED) throw new IllegalStateException("状态不可支付");
    if (!paid.currency().equals(total.currency())) throw new IllegalArgumentException("币种不符");
    if (paid.isLessThan(total)) throw new IllegalArgumentException("金额不足");
    this.status = Status.PAID;
    this.paidAt = clock.instant();
}

5. 算法代码与业务代码分离的三个信号

信号 判据 分离后的收益
可单测 不依赖库、网络、时钟即可断言 用例覆盖从集成级降到毫秒级
可替换 存在(或预计存在)第二个实现 换算法不动业务
参数化 行为由一组参数决定而非硬编码 同一份代码服务多个场景
// 分离形态:纯函数 + 参数对象,边界清晰
public interface FeeCalculator {
    Fee calc(FeeContext ctx, FeePolicy policy);
}

public record FeePolicy(BigDecimal baseRate, BigDecimal minFee, boolean vipExempt) {}

三条信号中任意两条成立,就值得抽成独立的类或包,而不是留在服务方法内部。


6. 策略模式与规则引擎

维度 策略模式 规则引擎
规则数量 少(数十以内) 多(上百,且持续增加)
变更频率 低,随版本发布 高,需业务方自行调整
变更者 开发 业务 / 运营
可解释性 靠代码阅读 需要规则命中追踪
调试成本 低 中高,需执行轨迹

规则引擎的隐藏成本是可解释性与可测试性同时下降:规则写在外部文件里,参数与上下文耦合不显式,出问题要重建执行轨迹。规则数量不到一定规模时,策略模式加配置表更划算。

# 策略参数化:规则留在代码,参数外置
fee_policy:
  default:
    base_rate: 0.006
    min_fee: 1.00
  vip:
    base_rate: 0.003
    min_fee: 0.00

7. 计算密集型逻辑的放置

放置 延迟预算 适用 代价
在线同步 主路径预算内(数十毫秒级) 结果必须实时 阻塞主路径,需限流与降级
异步任务 秒到分钟级 可容忍延迟、可重试 需要任务表 / 队列与状态机
预计算 离线算、在线读 输入变化可预测 一致性窗口与重算成本

延迟预算的算法是从 SLO 反推:主路径 P99 目标减去上下游占用,剩下的才是这段计算能花的钱。算不清就不该放在同步路径上。


8. 缓存与预计算的位置

位置 谁负责失效 一致性 风险
请求内局部计算 无需失效 强 重复计算
进程内缓存 本进程(TTL / 事件) 弱,多实例不一致 各节点视图不同
集中缓存 写方主动失效 中 缓存与库双写不一致
预计算表 定时任务 / 变更触发 弱 重算窗口内读到旧值

原则:谁改变数据,谁负责失效。让读方负责失效的缓存,最终一定会读到过期值。预计算表必须在表结构里带可见的时间水位,否则读方无法判断新鲜度。


9. 跨层数据传递的形态

形态 成本 风险
DTO 逐层转换 显式、可控,代码量大 转换遗漏字段
领域对象直接外传 省事 序列化耦合,暴露内部结构
共享实体模型跨层复用 最省事 一处改动波及全链路

DTO 转换的成本是样板代码,收益是每层可独立演化。领域对象直接外传,会把内部字段名、懒加载行为甚至序列化循环引用带进 API 契约。

// 显式转换,边界清楚
public record OrderView(Long id, String status, BigDecimal total) {
    public static OrderView of(Order o) {
        return new OrderView(o.id(), o.status().name(), o.total().amount());
    }
}

10. 分层过度与分层不足的症状

症状 表现 修法
分层过度 三层转发无逻辑、为每张表建一套 CRUD 合并为单一用例方法
分层过度 每个方法都只有一个调用方 删除中间层
分层过度 DTO / VO / BO / PO 四套一模一样 保留接口边界处一套
分层不足 控制器里写算法与循环 抽领域服务或纯函数
分层不足 SQL 里写业务分支与阈值 规则上移到代码
分层不足 事务边界在控制器上 收到应用服务

判断方法:如果删掉某一层,代码量减少而调用方无需改动,这一层就是转发层。


11. 可测试性与分层的关系

逻辑位置 测试手段 单次运行量级
纯函数 / 领域对象 单元测试 毫秒
应用服务(含事务) 集成测试(内存库 / 容器) 百毫秒到秒
SQL 中的逻辑 连真实库的集成测试 秒
规则引擎 场景化用例 + 轨迹断言 秒

逻辑落位决定了它能否被便宜地验证。把可单测的逻辑写进不可单测的位置,等于永久放弃回归保护——这比"职责单一"具体得多。


12. 排错

现象 常见原因 处理
改一个规则要改五个地方 规则散落在应用服务 收敛到领域对象
单元测试必须起数据库 逻辑写在 SQL 或实体与库耦合处 抽纯函数
同一份逻辑前后端重复 前端做了权威校验 前端只做提示,服务端唯一权威
缓存数据长期不一致 读方负责失效 改为写方失效
接口字段一改全线崩溃 领域对象直接外传 加 DTO 边界

13. 边界

场景 建议
一次性脚本 / 数据修复任务 扁平写法,不必分层
极简单的 CRUD 一层到底,别造四层
报表导出 让数据库算,应用只搬结果
试验性功能(预计两周内下线) 先扁平,等确定要长期保留再分层
多人长期维护的核心域 该分,且要有依赖方向约束

结论:分层的成本与收益都随维护周期长度放大。短期代码分层是纯成本,长期代码不分层是纯债务。


14. 自测

  1. 判断逻辑落位时,先看错误代价还是先看数据位置?
  2. 贫血模型的真实代价是什么,它为什么不是"不够面向对象"?
  3. 引入领域服务的信号是什么?
  4. 规则引擎相对于策略模式,隐藏成本体现在哪两点?
  5. 如何快速判断某一层是纯粹的转发层?
  6. 从 SLO 出发,如何确定一段计算能否放在同步路径上?
S03-A03 · 算法与业务的分层:把逻辑放到正确的位置
https://www.clxhxhhr.top/posts/6058/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。