[长文 | 分层架构] 从分层混乱到职责分明:一套后端架构规范的完整走读

翻翻 2026-07-28 18:50 1


本文同步发表在 杜松子酒 我的个人博客



前言


想写的话是早就想写了。不过真正着手还是在蛮久之后(其实写完也是好久之后。)。


继去年差不多这个时段“智课方舟”的项目、年末的时候对项目进行了复盘,近来还想开一个来讲一讲系统架构的方面的东西。主要基于以下几点原因:


一是继那个项目、那篇博文后有了自己新的认识,并且当时讲的也不全面、也需要再深入一些(btw 后面又看了之前写的代码,简直是不忍直视,真是1tan√10啊(不过某种意义上也在说明我在进步就是了))


还有一个层面是我自己做项目,想抽象出来一套规范的架构以供自己使用。在让AI参考自己的代码风格同时,也有架构层面进行约束。


同时嘛我也去查阅了一些关于架构方面的文章,发现大部分是概念式罗列。关于Cache层直接“用 @Cacheable 注解”或“在 Service 里 redisTemplate”,Service / Logic 的拆分少有说红线意识,缺少实战走读完整链路。并且比较散。于是这里想稍微给补一补,把讲散的东西给讲的系统些。并且借助真实实战场景方便各位理解。


开始之前,还是要进行一下声明。本博文针对的是有一定复杂度的单体项目——接口数量多、有缓存一致性需求、可能多人协作、需要给 AI 介入留接口。所以说<如果>你做的是 CRUD 简单项目,传统 MVC 三层完全够用,本博文属于过度设计,不必照搬。我看来其实是适配度的问题。很多事情无所谓一定好或坏,主要是适配不适配。

像是“引入Cache”的决策,我在自己的项目中也不是每一个模块都要用的。需要进行合理的评估。为了技术的高大上而引入,那绝对是不行的。

骑自行车上高速当然不行,开汽车在胡同里也难受。关键要看你的项目是什么体量。也要能经得起项目规模增长。


本文讲的是单体应用内的分层,不涉及微服务、分布式、RPC、消息队列。那是另一篇文章的事。

但有一点要说在前面:分层式微服务的基础。 一个连单体内部都分不清的项目,拆成微服务只会得到 N 个…(你懂的)。

先把单体里的分层搞清楚,再考虑拆服务,是更稳的演进路径。


这里不讲什么高深的东西,就是把两个问题说清楚:



  1. 为什么后端要分层?

  2. 每一层的边界到底在哪?


我会先从不分层会怎样讲起,然后讲我用的这套分层架构,再逐层拆解每一层做什么不做什么。最后用两个比较基础的实战场景(用户登录+赛事报名)把完整的数据流转走一遍。(←行文脉络嗷

呢,行文过程中也会说一些其他学到的、想到的知识。

(关于这些以及注意事项之类的是考虑过新开一个文章的,但是想了想还是直接这里面说好。(会进行标注,有些可选跳过。当然还是建议时间充裕可以多看看啦))




一、问题引入:不分层会怎么样


我看文章也是,肯定会有人在想啦,“我为什么要听你的?”“你说的就是对的吗?” 我想先从几个坏味道讲起,说说你不这么做会怎么样。


你大概率见过(也可能写过)这种代码:一个 ServiceImpl 方法里,先从数据库查用户,然后比对密码,再生成 Token,再往 Redis 里塞会话信息,最后组装一个返回结果丢给 Controller。

看起来逻辑还蛮顺的对嘛。一口气写完了。


但问题来了。


坏味道一:Logic 里直接 redisTemplate.set()


你在登录方法里用redisTemplate.opsForValue().set("user:" + userId, userJson) 存了一份用户信息。过了一段时间、同事在另一个接口里用redisTemplate.opsForHash().put("user_info", userId, userMap) 存了另一份。格式不一样,key 不一样,谁也读不到谁的数据。并且缓存里的数据和数据库里的数据对不上了,你也不知道该信谁。


坏味道二:DO直接返回给前端


查出来的 UserDO 里带着 password、is_deleted、created_at 这些字段,嫌麻烦懒得转换就直接返回了,前端控制台一打开,密码哈希值就这样清清楚楚地躺在 JSON 里。这就不是只是“代码风格”问题了,这是安全事故。


坏味道三:Service 里又做业务又拼 SQL 又管缓存


一个方法里塞了业务判断、数据库操作、缓存管理、数据格式转换,洋洋洒洒几百行。当时写的时候可能还觉得,“哇,一口气写完,好爽。”

爽个鬼,后面回来改这个方法,你可能就发现自己都看不懂。

让 AI 来看?估计也是一头雾水。它可能不知道哪些是业务逻辑、哪些是数据操作、哪些可以动哪些不能动。这时候它可能就要“骗”你了。


坏味道四:到处 new 出来的缓存 Key 格式不一致


A 写 user:id:123,B 写 user_info:123,C 更直接,就一个 u:123。同一个用户的数据,在 Redis 里竟然存了三份。有的存的是 JSON 字符串,有的存的是 Hash 对象,有的干脆整个对象直接丢了进去。三种格式,三个 TTL。出了问题你都不知道该信哪一份——缓存说用户还在,数据库说用户已经删了,前端拿到的又是另一套数据。找 bug?先猜半小时是哪个 key 写的吧。


实话说,这种问题不是谁故意搞出来的。根本原因是:从来没人跟代码说清楚,这块你只管干这个,那块你不许碰那个。


打个比方。假设你开了个小公司,一共就五六个人。然后你让前台接待客户的同时还要管账、还要写代码——倒也不是干不了,就是她自己也痛苦,后面来接班的人也痛苦。代码也是一样的道理。每个模块得知道自己该干嘛:前台接人,指挥官打仗,军需官管物资,苦力搬运。不是为了好看,是为了"能跑就行"之外的另一个目标——让代码活得久一点。




二、总览:我的分层体系是什么样的


在讲具体每一层之前,先给一个整体图。我用了比喻来理解这套东西的,虽然不太严谨,但比较浅显易懂(应该)。


Controller 就是前台。 客户(前端)来了,她负责登记需求、问清楚要啥,然后转交给后面的人。她不需要知道密码怎么加密,也不需要知道数据从哪来。她只管翻译。翻译成 Java 对象,交给后面的人。


Service 层比较特殊,是一份"岗位说明书"。 只写"这个岗位需要做什么",不写"具体怎么干"。这层的存在主要是为了接口契约,这个后面细说。


Logic 是真正打仗的人。 所有的业务判断、数据转换、决策编排,都在这一层。如果项目是一个军队,Logic 就是前线指挥部。


DAO 是军需官。 你需要子弹?他帮你去拿。从仓库(数据库)拿还是从抽屉(缓存)拿,他自己决定,你不用操心。你只需要告诉他:“我要这个用户的资料。” 至于他怎么弄到的,别管。


Cache 层我管它叫"抽屉管理员"。 为什么单独拎出来?因为这个"抽屉"(缓存)太容易乱用了。抽屉管理员规定:这个抽屉怎么开、东西怎么放、放多久。没有他的允许,谁也不能直接往抽屉里塞东西。否则就会出现前面说的"三份数据三种格式"的惨剧。


Mapper 就是苦力。只负责搬东西。SQL 来了,他执行。别的不管,也不操心。


(上面的比喻也是改了几版,改的我自己也觉得好笑又可爱,hh)


项目目录结构长这样:


src/main/java/com/fanfan/apexsports/
├── controllers/ # Controller 层
├── services/ # Service 接口层(只有接口,没有实现)
├── logic/ # Logic 实现层(Service 的具体实现)
├── daos/ # DAO 数据调度层
├── cache/ # Cache 缓存管理层(独立包)
├── mappers/ # Mapper SQL 执行层
├── models/ # 数据对象统一管理
│ ├── entity/ # Entity / DO(数据库映射)
│ ├── dto/ # DTO(数据传输对象)
│ └── vo/ # VO(视图对象)
└── ...

Service 和 Logic 是分开的,这个不必多说。Service 里只有接口定义,真正的实现在 Logic 层。拆分成两层的理由各位应该也很清楚,简单来说就是Java多态、接口契约稳定、AI 协作更可控、职责更清晰这些。


至于为啥我要叫 Logic 嘛,其实也没啥。就是我个人偏好,从学的项目跟过来的习惯,顺便方便 AI 识别。详细可见 “§ 补充:说一下 Logic 命名原因(可跳过)”这一节。


// Service 层:只定义“做什么”
public interface UserService {
UserVO login(LoginDTO loginDTO);
}

// Logic 层:写“怎么做”
@Service
public class UserLogic implements UserService {
@Override
public UserVO login(LoginDTO loginDTO) {
// 业务逻辑...
}
}

整个核心链路一句话概括:


Controller → Service(接口) → Logic(实现) → DAO → Mapper + Cache


这条链路里有一个非常重要的约束:Redis 从未暴露给 Logic 层。这个后面会重点讲为什么。


这里放张总览架构图(简洁版),让大家一开始有个视觉印象^-^:


flowchart TD

A[前端/客户端] --> B[Controller 层<br/>表现层]

B -->|DTO| C[Service 层<br/>逻辑接口层]

C -.->|接口| D[Logic 层<br/>业务实现层]

D -->|ID / 简单参数 / DO| E[DAO 层<br/>数据调度层 普通Bean]

E --> F[Cache 层<br/>缓存管理层 独立]

E --> G[Mapper 层<br/>SQL 执行层]

F --> H[(Redis)]

G --> I[(MySQL)]

style D fill:#fff3cd

style E fill:#d1ecf1

style F fill:#d4edda

(btw。博文完结后让 AI 帮我核查了下。关于 Mapper 层它觉得我比喻成“苦力”这个词不好,说是不尊重之类云云^-^ 那咋啦我们还是牛马呢,我现在要叫它牛马层…^-^

又想起来,关于 Git 的分支 master → main。也是说到了 master 有历史遗留问题之类的^-^ 不过毕竟也是 20 年开始的。我入的项目也多是 master 为主分支的。


另外多说一下。上层可以依赖下层,但是下层不能依赖上层(模块朝 哪个方向 依赖,调用方向)。高层不应依赖低层,都依赖抽象(模块间 怎么 依赖,耦合方式)。

很简单是吧,其实我最开始的时候也是记着有这样的话、但是实际没有理解。后面有一刻突然脑袋灵光了一下下。哦,那不就是 Controller 那边为上层,然后这个流程图下面的部分为下层嘛,然后依赖关系之类的。(以防真有和我一样的笨笨…溜了溜了


然后稍微具体点说一些,上层依赖下层暴露的接口/抽象→ 符合 DIP(松耦合)。上层依然在“调用”下层(方向不变,还是自上而下),但耦合的点是接口不是实现。




三、逐层拆解:每一层做什么、不做什么


该部分为文章主体。我会逐层讲清楚职责、为什么这样设计、以及最常见的错误。每一层差不多按这样的结构进行讲解:先说做什么,再说为什么,最后说不做什么


3.1 Controller 层:前台接待


做什么:



  • 接收 HTTP 请求

  • 校验参数(如用户名非空、UUID 格式是否正确)

  • 把请求 JSON 反序列化为 DTO(入参载体)

  • 调用 Service 接口

  • 负责多个 Logic 原子方法的组合编排

  • 拿到 Logic 返回的 VO,封装为统一响应 Result<VO> 序列化为 JSON


(关于 Logic 相关的详见 §3.2.3 的粒度说明)

简单来一句话给说一下:Controller 接收数据,对数据进行校验、以及引导如何进行 Logic 操作。


为什么这样设计:


Controller 其实就干一件事:翻译


前端发来的 JSON、请求头、Query 参数,这是 HTTP 世界的语言。Controller 把它们翻译成 Java 能懂的东西(DTO对象),然后丢给后面。后面处理完了,再把结果翻译回 JSON 扔给前端。


它不需要知道密码怎么加密、用户数据从哪来、缓存怎么管理。它只知道“客户要什么,我给什么”。


不过很多人会在 Controller 里写 if/else 做业务判断(子弹貌似正中当时的我,可恶)。比如判断用户是否存在、密码是否正确(但是这是 Logic 的事)。如果 Controller 做了这些,那 Logic 就成了摆设,分层就失去意义了。


另一个还有个更常见的坑是,DO 直接出现在 Controller 的返回值里。查出来的 UserDO 带着 password、is_deleted 这些字段,懒得转就直接 return userDO,前端拿到一看,密码哈希值就在 JSON 里躺着。那这问题可就大了,安全事故啊。


3.2 Service + Logic层:指挥官


3.2.1 拆成两层


先说为什么要拆成 Service(接口) 和 Logic(实现) 两层(可跳过):


我指的并不是为什么要定义一个接口 XxxService、然后再实现 XxxServiceImpl 这块。并且我在总览里面也简单说了下理由。


这里我主要想说的是为什么从 Service 包下的模块 → 单独搞了一个Logic(且和 Logic 平级)。除了个人偏好外,这里想简单的说一下自己的看法:



  • 包隔离的语义信号:services/ 只放接口,logic/ 只放实现,一眼就能看出“这层只管契约”。(不过其实严格来说这应该归到目录组织的好处,不是“分层”。所以不要攻击我)

  • 避免 ServiceImpl 后缀误导:有人会觉得 ServiceImpl 是“Service的附属”,而 Logic 是平级的独立层。这是心智模型差异。

  • 方便识别:更改代码时方便自己找和 AI 识别(理由稍微弱一点,感觉也算一个)。


文件拆分 ≠ 逻辑解耦


需要注意,代码 物理上 拆到了不同文件,但 逻辑上 仍然耦合(方法做了多件事,无法复用),这是“假分层”陷阱。


举两个对比例子:



  • 假分层:login() 方法里做了“查用户 + 验证码 + 发 Token + 存Redis”四件事 → 虽然在 Logic 文件里,但别的接口没法复用里面任何一段

  • 真分层:拆成四个原子方法 → 任何接口都能组合使用


补充:说一下 Logic 命名原因(可跳过)


其实也就是不同编程语言里那些“同一个东西,叫不同名字”的命名差异。

Java Spring 生态特别爱“造名字”,不过很多概念在不同语言里其实是同一个东西。


如关于业务层命名偏好,Java Spring 会叫 XxxServiceImpl, 其他语言就会叫 XxxLogic。因为刚开始跟的,我学的就是这种嘛,所以我后面自己开项目做项目也会这样搞。^-^


除此之外啦关于数据层命名,Java 会叫 DAO(Data Access Object)(数据访问对象),其他语言就会叫 Repository / Repo, 也就是仓储层。

现代 Java 也开始用 Repository,比如 Spring Data JPA 的命名。


然后嘛简单多说一嘴和实体类相关的。Java 的命名体系偏重,很多是 Spring 带来的。但是不一定是最好的。不要用 POJO(职责模糊),建议 models 作为顶层文件夹,内部按职责细分(entity / dto / vo)。


以及很多技术名词并不是官方文档定义的,而是社区在实践中逐渐形成的默契。


命名没有绝对的规范,只要大部分人看得懂就ok了。


3.2.2 Logic做什么



  • 处理业务逻辑(密码加密、数据格式转换)

  • 调用 DAO 层获取数据(注意:不直接调 Mapper)

  • 将 DO 转换为 VO

  • 处理跨多个 DAO 的复杂业务聚合

  • 管理事务边界(@Transactional加在 Logic 层方法上)


补充:为什么事务加在 Logic 而不是 DAO?


事务的粒度是“业务操作”,不是“数据操作”。如,赛事报名要写三张表(参赛者、报名记录、赛事状态更新),这三张表的写入必须是一个原子操作,即要么全成功、要么全回滚。

这个“业务原子性”的边界只有 Logic 知道,DAO 只管自己那张表。


如果 @Transactional 加在 DAO 的单个方法上,跨多个 DAO 的业务操作就无法保证原子性。加在 Controller 上又太粗(一个接口可能调多个 Logic 方法,事务范围过大)。


所以 Logic 层是事务边界的最佳位置。


3.2.3 Logic 的方法设计:最小化与组合编排


Logic 的方法设计原则:最小化功能点。


一个方法只做一件事。verifyPassword() 就只校验密码,不要顺手在里面颁发 Token 或更新登录时间。

为什么?因为一旦这个方法被其他地方复用,那些额外的逻辑就是累赘。可能有人会说"那我加个参数控制不要颁发 Token",但这正是往屎山的方向走(方法的职责开始模糊了)。


对比一下:


// ❌ 不应该:一个方法做了太多事
public UserVO login(LoginDTO dto) {
UserDO user = userDAO.getByUsername(dto.getUsername());
if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) {
throw new BusinessException(ErrorCode.USERNAME_OR_PASSWORD_ERROR);
}
String token = jwtUtil.generateToken(user.getUuid());
redisTemplate.opsForValue().set("token:" + user.getUuid(), token, 2, TimeUnit.HOURS); // ← Logic 直接碰 Redis,红线!
return convertToVO(user);
}

// ✅ 应该:Logic 只做业务编排,数据操作交给 DAO
public UserVO login(LoginDTO dto) {
UserDO user = userDAO.getByUsername(dto.getUsername());
if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) {
throw new BusinessException("用户名或密码错误");
}
return convertToVO(user);
}

看到区别了吗?第二种写法里,Logic只管"密码都不对"、“数据怎么转换”,不碰 Redis、不碰 Token 存储。Token 的管理是另一个 Logic 方法的职责,或者交给专门的认证模块。


原子化 Logic 的组合编排


"一个方法只做一件事"说完容易,真正体现价值的是:同一组原子方法,能被不同接口复用。


以用户模块为例,Logic 层有这些原子方法:
































原子方法 职责
getUserInfo(username) 查用户(调 DAO,返回 UserDO)
verifyPassword(rawPw, hashedPw) 校验密码(纯业务判断)
issueToken(uuid) 颁发 Token(调 AuthDAO 存储)
resetPassword(uuid, newPw) 重置密码(加密 + 调 DAO 更新)
checkEmailCode(email, code) 校验邮箱验证码

然后 Controller 的不同接口,就是对这些原子方法的不同编排

(简单场景下 Controller 可以只调一个 Logic 复合方法;复杂场景下 Controller 编排多个 Logic 原子方法。两种粒度都可以。)




























接口 编排
登录 getUserInfo → verifyPassword → issueToken
重置密码(旧密码) getUserInfo → verifyPassword → resetPassword
邮箱重置密码 getUserInfo → checkEmailCode → resetPassword
获取用户信息 getUserInfo

可见,getUserInfo 被四个接口复用,verifyPassword 被两个复用。如果最开始把"查用户 + 验密码 + 发Token"写成一个大方法的话,那"邮箱重置"接口就没法复用里面的"查用户"和"验密码"了,只能重写一遍、或硬拆。


这就是"文件解耦"和"逻辑解耦"的区别:方法拆到了不同文件,但如果每个方法只服务一个接口,那只是"看起来整齐",本质上还是耦合的。


btw,微服务场景下,Logic 调用的可能不是本地 DAO,而是另一个服务的 RPC 接口。但 Logic 的职责边界(不碰数据访问基础设施)不变。这个到微服务那篇文章再细聊。


3.2.4 Logic 绝对不做什么(红线)


(这是红线,划重点)



  • ^-^ 不出现 redisTemplaterdb(Redis Database)、database 这些字样(Logic 不直接依赖数据访问基础设施)。

  • ^-^ 不直接调用 Mapper

  • ^-^ 不处理数据存储相关的操作


为什么 Redis 绝对不能出现在 Logic 层?


这是本文想说的最核心的一条红线。原因是:Logic 层不应该知道数据是来自数据库还是缓存。


设想,如果 Logic 直接操作 Redis, 缓存的生命周期就会散落在各个业务方法里。这个接口存了一份,哪个接口又存了一份,格式还不一样。缓存和数据库对不上了,谁来修?没人知道该信谁。


更具体的翻车场景(存入格式 A、读取按格式 B 解析导致 ClassCastException)和"为什么必须把缓存封装成独立的类",在 §3.4 详述。


所以,Logic 只负责纯业务逻辑,数据从哪来、怎么存、缓存怎么管,全是 DAO 和 Cache 的事。

Logic 调用 DAO 的方法时,根本不需要知道 DAO 是从缓存拿的数据还是从数据库拿的,它只需要拿到数据后做业务处理即可。


3.3 DAO 层:军需官


做什么:



  • 数据调度:决定数据从缓存拿还是从数据库拿(★)

  • 数据持久化:将数据存到数据库

  • 参数转换:将 Logic 传来的业务数据,转换为数据库可存储的格式

  • 自动填充内部字段:主键、创建时间、默认值等


DAO的数据调度流程:


收到 Logic 的数据请求

先问 Cache:有数据吗?
├─ 有 → 直接返回,不惊动数据库
└─ 没有 → 呼叫 Mapper 执行 SQL

拿到 DO(Entity)

让 Cache 存一份(下次就不用查库了)

返回给 Logic

这个流程是 DAO 层最核心的东西。Logic 层只管说“我要这个用户的数据”,至于这个数据是从缓存秒级返回的、还是从数据库查出来的,Logic 完全不知道也不需要知道。


为什么 DAO 必须是“调度者”,而不是单纯的“执行者”?


反过来想:如果 DAO 只负责执行 SQL(跟 Mapper 一样),那“先查缓存、没有再查库、查完回写缓存”这套调度逻辑该放哪?


只能上浮,浮到 Logic。


然后 Logic 里就出现了“先查 Cache,没有再查 Mapper”的代码,Logic 就知道了数据的来源,它开始区分”这个数据是从缓存来的还是从数据库来的“。紧接着,缓存的 Key 格式、TTL、序列化方式就全渗进来了。红线就这么一步步地被打破了。


所以 DAO 必须承担”调度“职责:把”数据从哪来“这个决策封死在自己内部,让 Logic 永远只看到”拿到了数据“这一个结果。


这也是为什么说 DAO 是数据安全的底线。Cache 格式对不对、数据库操作原子不原子、缓存和数据库的数据一致性能不能保证,全看 DAO 调度逻辑写的怎么样。

DAO 写好了,上面的 Logic 可以随便组合不会出事;DAO 写烂了,上面再怎么分层也救不回来。


DAO 找不到数据返回 null:


(注:现代风格倾向于 Optional(如 Spring Data JPA 的 findById),但 MBP 生态习惯返回 null,看团队约定。本示例项目采用 null 风格,与 MBP 一致。


为什么不是抛异常?因为“查不到”不一定是错误。用户没注册过,查不到是正常的;赛事还没创建,查不到也是正常的。

该不该当作错误处理,这个判断交给 Logic 层。DAO 只管如实汇报,Logic 来决定“查不到了怎么办”。


DAO 里一个典型的查询方法长这样(实战时核心调度逻辑):


public EventDO getById(Long id) {
// 1. 先查缓存
EventDO cached = eventCache.getEventById(id);
if (cached != null) {
return cached;
}
// 2. 缓存未命中,查数据库
EventDO eventDO = eventMapper.selectById(id);
// 3. 命中 DB 则回写缓存
// 注:此处 null 不缓存。若需防缓存穿透,可缓存空对象(短 TTL)或前置布隆过滤器
if (eventDO != null) {
eventCache.cacheEvent(eventDO);
}
return eventDO;
}

注意:DAO 内部只调用了 Cache 的“业务语义方法”(getEventByIdcacheEvent),而不是直接redisTemplate.set()

Cache 的 Key 格式、TTL、序列化方法全部封装在 Cache 类内部,DAO不知道也不需要知道。


补充一个写操作“对称”调度(展示缓存生效闭环):


public EventDO update(EventDO eventDO) {
eventDO.setUpdatedAt(LocalDateTime.now());
eventMapper.updateById(eventDO);
EventDO updated = eventMapper.selectById(eventDO.getId());
// 写操作:逐条缓存失效(数据已变,下次读回源) + 失效所有列表缓存(过滤结果可能变化)
eventCache.evictEvent(eventDO.getId());
eventCache.evictAllEventLists();
return updated;
}

读走 cache-aside, 写走 evict, 这是 DAO 层”数据调度“的核心职责。(并发下的读写竞态是另一个话题)


3.4 Cache 层:抽屉管理员


这一层我花了很长时间想明白的,效果也更为显著。


3.4.1 为什么 Cache 必须独立成一个包?


很多人写缓存就是哪里需要哪里 redisTemplate.set(),看起来好方便,但是试想一下这样的场景:



  • 你用 StringRedisTemplate 存了个 JSON 字符串

  • 同事用 RedisTemplate(默认 JDK 序列化)存了个对象

  • 另一个同事用 Hash 结构存

  • Key 格式:你用user:123,他用user_info:123,还有一个用u:123

  • TTL:你设了30min,他设了1h,还有一个忘了设…^-^


结果就是:同一个业务数据,在 Redis 里愣是存了三份。格式不一样,Key 和 TTL 也不一样,谁也读不到谁的数据。更恶心的是,你更新了数据库,但要同步更新缓存的时候,你都不知道去哪里更新、更新哪一份。


更隐蔽的风险:存入格式 A,读取按格式 B 解析。


想象这样的场景:你在 Logic 里直接 redisTemplate.opsForValue().set("user:123",userJson),存的是 JSON 字符串。三个月后,另一个同事(或三个月后的你自己)在另一个 Logic 方法里 redisTemplate.opsForValue().get("user:123")

拿到之后按 Map 去反序列化 → 直接炸了。ClassCastException。


发现没?数据库和 Redis 在这方面完全不一样:


数据库:结构不一致 → SQL 报错 → 你立即发现

Redis:结构不一致 → 只要 set 语法合法就能存 → 静默数据污染(等你发现时已经灾难了)


这就是我在 §3.2 那条红线说的:Logic 直接操作 Redis, 意味着缓存的序列化方式、Key 格式、TTL 全部散落在各个业务方法里,而且下一次读取的人和这一次存入的人用的也不一定会是同一套约定。


所以 Cache 层的核心设计原则是:Key格式、TTL、序列化方式全部私有化,对外只暴露“业务语义”方法。


我采用的是“按实体划分”的方案,对需要缓存的实体才建 Cache 类:


cache/
├── AuthCache.java # Token 黑名单,StringRedisTemplate, Key=auth:blacklist:{token}
├── CompetitionCache.java # 比赛/秩序册聚合,Key=competition:booklet:{date}
├── RecordCache.java # 校记录,Key=record:{eventId}:{type},TTL=30min
├── ScoreCache.java # 积分
├── EventCache.java # 赛事项目, Key=event:id:{id} & event:list:{filterKey}, TTL=1h
├── DashboardCache.java # 看板
├── CollegeScoreSummaryCache.java # 学院积分汇总(带互斥刷新锁)
└── ...

对比一下两种方案:
















































维度 按实体划分(采用) 通用 CacheService
Key 管理 每个类私有常量,不会冲突 调用方自己拼,容易碰撞
TTL管理 每个实体统一控制 可以传参差异化,但容易“忘了传”走默认值
序列化 每个实体内部统一 需要调用方约定,或全局统一序列化器
生命周期 实体级控制(用户30min, 赛事1h) 可以按 Key 前缀差异化,但管理分散
可维护性 改一个实体不影响其他 牵一发动全身
开发效率 每个实体要写一个类,初期慢 一个方法搞定,初期快
适合场景 实体少但缓存逻辑复杂、有差异化需求 实体多但缓存逻辑简单、统一程度高

这两种方案我都想过,也都能做对,通用 CacheService 完全可以设计成“Key 私有化 + 序列化统一”的形态。

不过我最后还是选了按“实体”分,总结一下有以下几点原因:



  1. 实体 Cache 类把 Key/TTL/序列化 全锁在类里,调用方想偷懒都不行。通用方案靠自觉,人一多就崩

  2. Event 1h TTL, Record 30min TTL, Auth 黑名单不设 TTL…每个实体的策略差异在类里一目了然;通用方案还需要在一堆 cacheService.put(key, value, ttl) 调用点里翻。

  3. 缓存逻辑复杂时,独立类更内聚。比如 CollegeScoreSummary 需要带互斥刷新锁,EventCache 需要 SCAN 驱逐列表。把这些东西都塞进一个通用 Service 里,迟早要变成另一个屎山。


当然了,如果你的项目缓存逻辑都很简单(全是 get/set/expire), 通用 CacheService 完全够用,按实划分反而是过度设计。关键看你的缓存逻辑有没有差异化需求。


总结一下就是:Cache 类是缓存的“宪法”,规定了怎么存、存多久、怎么取,任何人不能绕过它直接操作Redis。


有人可能会说:“这也太死板了吧,我就临时存个值还得写个方法?”


是挺死板的。但这个“死板”在关键时候会发挥重要作用。当你看到缓存里的数据和数据库不一致的时候,只需要打开对应的 Cache 类看一下存取逻辑,就能定位问题。

如果缓存操作散落在十几个 Service 里,你光找“这个 Key 是谁存的”就要翻半天。翻完了还不一定找的着。


3.4.2 关于何时引入 Cache 的决策架构


核心原则:Cache 解决的是“读得多 + 改的少”的问题,而非“数据多大”的问题。


三个必要条件(缺一不可):

① 读的频率高 + ② 改的频率低 + ③ 查询/计算有成本


具体可拆解为:



  1. 读的频率高

    典型场景:

    • 列表页用户反复打开(如比赛项目列表、学院排名)

    • 每次页面加载都要查的数据

      反例:运动员详情页——用户点开一个看一眼就关了,不会反复点同一个人。单独为这个加缓存不值。



  2. 改的频率低

    典型场景:

    • 比赛项目(学期初定好,整个运动会期间不变)

    • 学员信息(几乎不改)

    • 常量配置

      反例:实时比分——每秒都在更新,缓存还没读完就过期了。这种情况应该优化写入而非读取。



  3. 查询 / 计算有成本

    这里区分三种成本:
























成本类型 例子
SQL 成本 联表查询、聚合统计、全文搜索
计算成本 排行榜排名统计、积分汇总
几乎没有成本 单表 SELECT * FROM table WHERE id = ?

经典反例:学院列表。`select * from as_college where deleted = 0` , 一条 SQL 几毫秒,数据不超过20条。你加个缓存,序列化反序列化的开销比直接查库还大。

这里放一个方便判断的决策流程:


查询/计算有成本吗?
├─ 否(单表主键查询、数据量小)
│ └─ 读频率超出数据库承受能力吗?
│ ├─ 否 → 不加缓存
│ └─ 是 → ✅ 加缓存(用于减压)
└─ 是(联表/聚合/计算/远程调用)→ 读得频繁吗?
├─ 否(偶尔查一次)→ 可加可不加,收益不大
└─ 是 → 改得频繁吗?
├─ 是 → 缓存意义不大(刚存就过期,优先优化写入)
└─ 否 → ✅ 考虑加缓存

(OS:我刚开始就搞错了!把是否读频繁放在了第一个。其实“成本”放第一道门才是正确的工程直觉。缓存的核心价值是避免昂贵操作。实际在用人单位的时候关于 成本 这个东西也需要考虑的好多好多。


此外,微服务场景下,Cache 层可能是本地缓存(Caffeine)+ 分布式缓存(Redis)的多级架构,但 “Key/TTL/序列化私有化” 的红线不变。


并且注意到,架构复杂度要匹配团队能力,过度设计比不分层更危险。


3.5 Mapper层:苦力


Mapper 层没什么太多可说。他就是一个苦力(牛马..),只负责执行SQL。作用有:



  • 继承 MyBatis-Plus的 BaseMapper,自动拥有 CRUD 能力。

  • 复杂查询(联表、分组、排序)在 Mapper 中自定义方法,对应 XML 文件写在 resources/mapper/ 目录下。


有两点需要注意:



  1. BaseMapper 只能处理单表操作。 联表查询需要在 Mapper 中额外定义方法(XML 或 注解)。

  2. MBP 的方法(如 selectById, updateById)应该用在 DAO 层。 在 Logic 层直接用这些方法属于“侵入”,因为 Mapper 只管执行,不管调度。如果 Logic 层(或 DAO 以外的层)里出现了 LambdaQueryWrapper,说明逻辑跑到不该去的地方了。


3.6 异常处理:分层错误语义


前面讲的是“正常流程”数据怎么流转,现在说说“异常流程”错误怎么流转。

这也是分层架构里容易写乱的地方。


核心原则:每一层抛自己的语义异常,最终在 Controller 统一兜底。











































抛什么 例子
Mapper 框架异常(DataAccessException) SQL 执行失败、约束冲突
DAO 框架异常(原样上抛,不吞) 同上, DAO 不做业务判断
Logic 业务异常(BusinessException) “用户名或密码错误”、“赛事已结束报名”
Controller 不主动抛,交给全局异常处理器 -
GlobalExceptionHandler 兜底,转成统一响应 见下文
为什么不直接抛 RuntimeException?

因为前端得区分两种错误。一种是“用户自己操作不当”,密码输错了、赛事已经截止了这种,提示一下就行。还有一种是“服务器自己炸了”,DB 挂了、Redis 连不上,这种得记日志、得报警。


这两类东西的 HTTP 状态码不一样,返回给前端的文案不一样,日志级别也不一样。不能密码输错了也给人家返回 500吧?所以搞一个 BusinessException + ErrorCode 枚举 是很有必要的,业务错误和系统错误就能清晰区分了。


举个例子:


// 1. 业务异常类
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
// ...
}

// 2. 错误枚举码,所有错误集中在这一个地方管理
public enum ErrorCode {
USERNAME_OR_PASSWORD_ERROR(40101, "用户名或密码错误"),
ACCOUNT_DISABLE(40102, "账号已被禁用"),
EVENT_NOT_FOUND(40401, "赛事不存在"),
PARAM_VALIDATION_FAILED(40001, "参数校验失败"),
INTERNAL_ERROR(50000, "服务器内部错误"),
// ...
}

// 3. Logic 层抛业务异常
if (!BCrypt.checkpw(dto.getPassword(), user.getPassword())) {
throw new BusinessException(ErrorCode.USERNAME_OR_PASSWORD_ERROR);
}

// 4. 全局异常处理
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException e) {
// 业务异常:返回业务错误码 + 提示文案
return Result.fail(e.getErrorCode().getCode(), e.getMessage());
}

@ExceptionHandler(Exception.class)
public Result<Void> handleSystem(Exception e) {
// 系统异常:记日志 + 返回通用错误
log.error("系统异常", e);
return Result.fail(500, "服务器内部错误");
}
}

(注:方便演示起见,后面的代码没有严格用 §3.6 介绍的 ErrorCode 枚举,分清主次矛盾即可…


几个关键点拎一下:



  • DAO 不吞异常。SQL 失败了就原样上抛,让 Logic 决定怎么处理(是重试、转业务异常,还是让它炸到全局处理器)。

  • Logic 只抛业务异常。

  • Controller 里不写 try-catch, 所有异常交给 GlobalExceptionHandler统一处理。(保持干净,只做“翻译”)

  • 参数校验异常 和 请求体反序列化异常,这俩要单独捕获,返回 400(BAD_REQUEST)。不要让它走兜底的 500。(不然前端拿到“服务器内部错误”,其实是用户自己 JSON 格式写错了,排查方向就错了)




四、数据载体:VO,DTO,DO 怎么流转


分层讲完了,你大概知道每一层干什么了。但数据在这些层之间是怎么流动的?同一份数据,在不同层会长什么样?这部分把这件事讲清楚。


4.1 三个对象的定位


先来讲清楚三个对象是什么:



  • DO(Data Object) :数据库的镜像,和数据库表 1:1 严格对应。在 DAO ↔ Mapper ↔ Logic 之间流转,永远不暴露给 Controller 和前端。

  • DTO(Data Transfer Object) :层与层之间的运输箱。Controller ↔ Logic 之间的数据载体,按需裁剪字段。

  • VO(Value Object) :给前端看的包装盒。最后一次“整容” (时间格式化、敏感字段脱敏、嵌套结构拼装)。


越往外越贴近展示需求,越往里越贴近存储结构。(呼应 DDD 中“anti corruption layer” (防腐层) 的理念,即外层不该被内层的存储细节污染)


4.2 请求线:前端 → 后端


前端 JSON
→ Controller接收为 DTO(反序列化)
→ Logic 将 DTO 转为 DO
→ DAO 用 DO 入库


为什么不能前端传什么就直接存什么?三个原因:




  1. 字段裁剪。前端注册的时候传的是 {username, password}, 但数据库的 User 表有十几个字段(uuid, created_at, updated_at, is_deleted…)。这些内部字段不应该由前端来填,而是DAO层自动生成。




  2. 格式转换。前端传的密码是明文,Logic 层加密后交给 DAO。DAO拿到的就已经是加密后的最终密码,它不需要关心加密过程。




  3. 安全过滤。DTO 只包含前端“应该”提交的字段。如果用 DO 接受前端参数,恶意用户可能多传一个"isAdmin: true" 字段,你的代码没做过滤就直接入库了。




4.3 响应线:后端 → 前端


数据库
→ DAO 返回 DO
→ Logic 将 DO 转为 VO
→ Controller 封装成统一响应,序列化为 JSON 给前端


为什么 DO 不能直接给前端:


最直接的原因:DO里有敏感字段password, is_deleted, updated_at 这些字段不应该暴露给前端。而且 DO 的字段和数据库表完全对应,直接返回意味着前后端耦合了,你改个数据库字段名,前端就得跟着改。


为什么 VO 只用 @Getter 不用 @Data?


因为 VO 是“视图对象”,不应该被修改(其实也不绝对,见下一小节)。@Data 包含了@Setter , 意味着 VO 的字段可以被随意修改。在返回给前端的过程中被意外set 了某个值,Bug 很难排查。用 @Getter + 构造函数赋值,保证不可变性,更安全。


此外,@Data 还包含:

@Data = @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor


见本人另一篇博文 智课方舟复盘 :


// VO:不可变,只读
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserVO {
private String uuid;
private String username;
private String createdAt;
// 注:没有 password 字段
}


VO 层只用 @Getter 是理想状态,工程上要权衡(可跳过)


很多场景下,VO 是逐步组装的,不是一次性构建好的。这里举个例子:


for (UserVO record : userVOPage.getRecords()) {
Dept dept = deptDAO.getById(record.getDeptId());
record.setDeptName(dept != null ? dept.getName() : null); // ← 需要set!

List<UserRole> userRoles = userRoleMapper.selectList(...);
record.setRoles(collect); // ← 又需要 set!
}

看到没,这种场景下 VO 不是一次性构建好的,而是“先 new 出来 → 查关联数据 → 一步步往里填”。这种“渐进式组装”模式,不用 Setter 就写不出来(除非用 Builder,但 Builder 也有限制,后面说)。


有三种方案,我按推荐度排序:


方案 1:@Getter + @Builder(首选)

@Getter
@Builder
public class UserVO {
private Integer id;
private String userName;
private String deptName;
private String roles;
}

// 链式构造,构造完就不可变了
UserVO userVO = UserVO.builder()
.id(1)
.userName("fanfan187")
.deptName("技术部")
.roles("admin,editor")
.build();

优点:



  • 不可变(无 Setter)

  • 不依赖字段顺序(比 @AllArgsConstructor 安全)

  • 可读性极好


这里放一下查资料的时候看到的东西。Lombok 官方和业界都推荐 Builder 替代 AllArgsConstructor。LinkedIn 的一篇译文(https://www.linkedin.com/pulse/mastering-lombok-choosing-right-constructor-marvelous-ikejiama-sbhnf)说:



“For public APIs, skip @AllArgsConstructor. It can make your code brittle. Opt for @Builder or handcrafted constructors.”



翻译成人话:公共 API 不要用 @AllArgsConstructor, 它会让代码脆弱。用 @Builder

(需登录后才可进行查看)


方案 2:@Getter + @AllArgsConstructor(简单场景可用)

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginVO {
private String user;
private String password;
}

优点:简单直接

缺点:依赖字段顺序,字段多了容易出错(比如两个 String 字段搞反了,编译器不报错)


方案 3:@Getter + @Setter(聚合 VO 的选择)

@Getter
@Setter
public class UserVO {
private Integer id;
private String deptName; // 需要后续补充
private String roles; // 需要后续补充
}

适用场景:必须“渐进式组装”的聚合VO,且不想引入 Builder。


还是说。具体情况具体分析对待…


分页的特殊处理


此处补充一个特殊场景:分页对象怎么流转。

列表查询返回的不是单个 VO, 而是一页 VO。这里涉及 MBP 的 Page 对象在层间怎么传。


原则:DAO 返回 Page<DO>,Logic 转成 Page<VO>, Controller 包装成 Result<Page<VO>>


// DAO 层:返回 MBP 原生 Page<DO> (内部使用,不外抛)
public Page<UserDO> page(int pageNum, int pageSize, ...) {
LambdaQueryWrapper<UserDO> wrapper = new LambdaQueryWrapper<>();
// ... 拼条件
return userMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
}

// Logic 层:把 Page<UserDO> 转成 PageResult<UserVO> (自定义分页对象,只含 5 个字段)
public PageResult<UserVO> page(...) {
Page<UserDO> userPage = userDAO.page(...);
// 转换 records,保留分页信息(total/pages/current/size)
List<UserVO> voList = userPage.getRecords().stream()
.map(UserVO::from)
.collect(Collectors.toList());
return new PageResult<>(
voList,
userPage.getTotal(),
userPage.getCurrent(),
userPage.getSize(),
userPage.getPages()
);
}

// Controller 层:包统一响应
return Result.success(userLogic.page(...));

注意:



  • Page<DO> 不要从 DAO 直接穿透到 Controller(否则 DO 暴露了, 且 MBP Page 的内部字段会一起序列化)。

  • 分页信息(total, current, size, pages)原样保留。只替换 records。

  • 推荐用自定义 PageResult 封装,只暴露 records / total / current / size / pages 5个字段,避免 MBP Page 的内部字段(searchCount, optimizeCount 等)泄露给前端。


4.4 对象转化的实践


说了这么多“应该怎么流转”,那具体代码里怎么转呢?


简单场景: DTO 和 DO 字段基本一致的时候,用 BeanUtils.copyProperties() 做同名字段快速拷贝就行。


复杂场景: 需要聚合多个 DO 的数据时,手动 new + setter。如赛事报名的响应 VO 里需要参赛者姓名、赛事名称、项目名称,这些来自三张不同的表。Logic 拿到三个 DO 后手动拼接成一个 VO。


有些项目会用 MapStruct 这种映射框架,通过注解在编译期自动生成转换代码(类型安全、性能最高);同类的反射派还有 ModelMapper(运行时反射,比 BeanUtils 更强,支持嵌套字段和 validate() 校验)。但我目前的项目规模还没到需要引入的程度,手写更直观,也更方便排查问题。等项目大了再引入也不迟。


然后多说几嘴关于<注解>和<封装好了的东西>的使用方面。

实际工程中会发现,像是注解之类的没有什么神奇的,其实也就是标记作用。运行到这个位置后知道一下,像是 @Service 之类的。主要也就是两个,底层是注解处理器、反射,运行时反射那套性能是略低于原生的,并且不好控制。实际做开发的时候会关注一些“更好控制”这样的东西,对字段要求之类的会更高一些。框架之类的不好维护。

当然个人的建议是不要那么局限!放开眼界,建议是感兴趣可以自己写一些注解、封装一些方法,看看运作机理。


注意工程哲学:可控、可维护、显式。


4.5 完整链路流转图


这里把前面讲的请求线、响应线、异常线画在一张图里,方便整体理解:


graph TD

classDef external fill:#e1f5fe,stroke:#01579b,stroke-width:2px;

classDef controller fill:#fff9c4,stroke:#fbc02d,stroke-width:2px;

classDef logic fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;

classDef dao fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px;

classDef infra fill:#eceff1,stroke:#455a64,stroke-width:2px;

classDef error fill:#ffebee,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5;

User((前端)):::external

Controller[Controller<br/>校验 + 反序列化]:::controller

Logic[Logic<br/>业务编排 + 事务 + DO→VO]:::logic

DAO[DAO<br/>数据调度]:::dao

Cache[Cache 类<br/>Key/TTL/序列化私有]:::infra

Mapper[Mapper<br/>执行 SQL]:::infra

DB[(MySQL)]:::infra

Redis[(Redis)]:::infra

GEH[GlobalExceptionHandler<br/>兜底转统一响应]:::error

%% 请求线

User -->|1. JSON| Controller

Controller -->|2. DTO| Logic

Logic -->|3. 查询/写入| DAO

%% DAO 内部调度

DAO -->|4. 先问抽屉| Cache

Cache -.->|Hit: 直接返回| DAO

DAO -->|5. Miss: 呼叫| Mapper

Mapper -->|6. SQL| DB

DB -->|7. DO| Mapper

Mapper -->|8. DO| DAO

DAO -->|9. 回写抽屉| Cache

Cache <-->|存取| Redis

%% 响应线

DAO -->|10. DO| Logic

Logic -->|11. VO| Controller

Controller -->|12. Result&lt;VO&gt; JSON| User

%% 异常线

Logic -.->|BusinessException| GEH

DAO -.->|框架异常上抛| GEH

GEH -.->|统一错误响应| User

%% 异常线高亮
linkStyle 14,15,16 stroke:#c62828,stroke-width:1px,stroke-dasharray: 5 5;



五、红线:这些事绝对不能做


前面部分讲了“应该怎么做”,这里专门把“绝对不能做”的事情领出来,作为速查清单(详细论证见 §3.2 和 §3.4)。




























红线 后果
DO 出现在 Controller 泄露敏感字段(password, is_deleted), 多传一堆没用的数据
Redis 出现在 Logic 存储不一致(存入格式 A 读取按格式 B 解析,灾难级事故)
Logic 直接调用 Mapper 绕过 DAO 的数据调度,缓存一致性被破坏
Cache 层外直接操作 Redis 数据格式不统一、解析失败、生命周期无法管理

这几条红线不是“建议”,是底线




六、两个实战场景:从简单到复杂


前面部分把 每一层的职责数据流转规则 都给讲得蛮清楚的了。虽然之前行文过程中有举例一些代码啦场景啦之类的,不过多少还是有点空,所以现在我拿两个场景把前面的东西串一遍,把架构完整走一遍。


第一个场景(用户登录)比较简单,主要让你看看“分层之后数据流转确实清晰”。

第二个场景(赛事报名)复杂度会高一些,让你感受一下分层在复杂度高的项目中的好处。


简单场景大家都能写好。只有复杂场景才能回答你心里的那个质疑:“搞这么多层到底有什么用”


这里简单写一下“开发顺序建议”:



  1. 先写 DAO 层的原子方法(数据层面)

  2. 再写 Logic 层的原子方法(业务层面)

  3. 最后写 Controller 层的编排(接口层面)

  4. 自底向上验证(非自顶向下)


问的话,就是 DAO 是地基,所以下面要先稳。


6.1 用户登录:走通基础流转


场景:用户输入用户名和密码,登录系统。


S1:前端发请求


POST /api/auth/login
Body: {"username": "fanfan187", "password": "123456"}

S2:Controller 层(前台接待)


Controller 接到请求,校验一下参数(用户名非空、密码非空),封装成 LoginDTO, 调用 Service 接口。


@PostMapping("/login")
public Result<UserVO> login(@RequestBody LoginDTO loginDTO) {
// 参数校验
// 调用 Service(实际走 Logic 实现)
UserVO userVO = authService.login(loginDTO);
return Result.success(userVO);
}

在这一步:JSON → DTO,纯翻译工作。Controller 不知道、也不需要知道密码怎么加密,数据从哪来。


S3:Logic 层(指挥官)


AuthLogic 拿到 DTO,开始做业务处理:



  1. 调用 UserDAO 查用户(调度 DAO 取数据)

  2. 比对密码(业务判断)

  3. 查到了 → 把 DO 转成 VO 返回

  4. 查不到或密码错误 → 抛业务异常


@Override
public UserVO login(LoginDTO loginDTO) {
UserDO userDO = userDAO.getByUsername(loginDTO.getUsername());
if (userDO == null || !BCrypt.checkpw(loginDTO.getPassword(), userDO.getPassword())) {
throw new BusinessException("用户名或密码错误");
}
return convertToVO(userDO);
}

注意:Logic 里没有 Redis、没有 redisTemplate、没有SQL。它只做业务编排。


(btw 实际项目里登录还会颁发 Token,这里为了聚焦分层流就省略了。Token 的编排见 §3.2.3 的原子方法表。


S4:DAO 层(军需官)


UserDAO 收到查询请求,按规则流程办事:



  1. 先问 UserCache:有这个用户的数据吗?

  2. 缓存有 → 直接返回 UserDO

  3. 缓存没有 → 调用 UserMapper.selectByUsername() → 拿到 UserDO → 让UserCache 存一份 → 返回


S5:Controller 拿到 VO,封装成统一响应返回前端


{
"code": 200,
"data": {
"uuid": "a1b2c3d4",
"username": "fanfan187",
"createdAt": "2026-06-15"
}
}

(注意到,返回数据没有 password,没有 is_deleted。VO 在 Logic 层就已经脱敏了)


完整链路一览:


前端 JSON (username, password)
→ Controller: 校验 + 封装 LoginDTO
→ Logic: 调 DAO 查用户 + 比对密码 + DO→VO 转换
→ DAO:先查 Cache → 没有 → 查 Mapper → 写入 Cache → 返回 DO
→ Mapper: selectByUsername() 执行SQL
← Logic 返回 UserVO
← Controller 封装统一响应
← 前端拿到 JSON(无敏感字段)

一条线走下来,每一层都在做自己的事情,没有越界,没有混乱。数据类型的变化也很清晰:JSON → DTO → DO → VO → JSON。


6.2 赛事报名:展示架构对复杂设计的处理


该选型项目涉及表会多一些。场景复杂度在于:前端一次提交的数据涉及多个实体(参赛者、赛事、比赛项目),需要跨多张表校验和写入,还得保证缓存一致性。


先看看如果不分层,会写成什么样:


一个 ServiceImpl 方法里,先查用户存不存在(直接写 SQL),再查赛事有没有开放报名(查完顺手缓存了),再检查有没有重复报名(又查了一次数据库),然后创建报名记录(直接 Mapper 插入),最后还要更新缓存、组装返回数据…


洋洋洒洒几百行,方法名叫 registerEvent, 但它做了查、校、存、缓存、转换五件事。几个月后你回来改这个方法,光读懂都要好久。如果让 AI 帮你加一个“报名人数上限校验”的功能,它都不知道往哪里加(其实严谨来说是乱加)。


再用分层的方式走一遍:


Controller 层:


接受请求,封装 EventRegistrationDTO。这个 DTO 里包含:参赛者 UUID、赛事 UUID、多个比赛项目的 ID 列表。注意这是一个“聚合 DTO”,前端一次提交的内容涉及多张表,但 DTO 做了统一封装。


Logic 层:


这一层需要额外注意一下。Logic 需要编排多个 DAO 完成业务流程:


1. participantDAO.getByUuid()         → 参赛者存在吗?
2. eventDAO.getByUuid() → 赛事存在吗?开放报名了吗?
3. registrationDAO.checkDuplicate() → 重复报名了吗?
4. registrationDAO.create() → 创建报名记录(缓存失效由 DAO 内部处理)

每个 DAO 调用都是独立的、职责单一的。Logic 层只做编排和业务判断:存在性校验、状态校验、重复校验、数据转换。它不碰 SQL,不碰 Redis,不碰缓存更新。


DAO 层:


每个 DAO 方法各自负责自己的数据调度。如 eventDAO.getByUuid() 会先查 EventCache, 没有再查 Mapper;registrationDAO.create() 会插入数据库并处理相关缓存失效。


关键在于:缓存一致性是在 DAO 内部闭环的。Logic 不需要知道“报名成功后要失效哪些缓存”,这是 DAO 的事。Logic 只管“创建报名记录”,DAO 在创建成功后自己处理缓存更新。


数据转换:


Logic 拿到三个 DO(参赛者、赛事、报名记录)后,手动拼成一个聚合 VO,包含参赛者姓名、赛事名称、报名的项目列表。前端拿到的是一个干净的对象,不用再发起二次请求。


对比一下:


不分层的写法:一个方法几百行,查、校、存、缓存、转换全混在一起。改一个地方,三个接口跟着炸。


分层的写法(大概行数):Controller 10 行(翻译),Logic 30 行(编排),DAO 每个方法 10-20 行(调度),Cache 每个方法5行(存取)。

改“报名人数上限校验”?Logic 里加一个判断就行。改“缓存 Key 格式”?只改 Cache 类,其他层完全不受影响。


这就是分层的价值:复杂度不会消失。但分层之后,它被分散到了每一层。每一层只需要面对自己那份复杂度。




七、题外话:谈谈这套架构和 AI 协作的关系


最后聊一个跟“写代码”有关的,但又不完全是架构的话题:这套分层规范和 AI 协作有什么关系。


先说我的看法:



有了清晰的分层规范,让 AI 介入开发变得非常可控。



差不多想了两个方向,也就是写代码修 Bug。增量和存量?


先说让 AI 修 Bug。假设,用户反馈“报名后赛事列表没更新”。嗯快速定位到问题的你告诉 AI,“问题在 DAO 层的缓存失效逻辑,请查阅 EventDAOEventCache 这两个文件。然后blablabla…”

AI 不需要理解整个项目的架构,只需要看这两个文件的交互就行。范围越小,AI 越不容易跑偏。

相较下,直接说 “报名后赛事列表没更新,给我改 bug。”有点好笑^-^,不过完整描述错误非常非常详尽,那感觉可以直接自己改了hh,想起一个词“中译中”,不过应该都不会这样做。


然后说让AI写代码哦。比如新增一个功能,你告诉 AI:“在 Logic 层加一个赛事取消的方法,只改 EventLogic.java, 不要碰 DAO 和 Controller。”

它拿到这个约束后,就老老实实只写业务逻辑,如校验赛事状态、调用 DAO 方法、做数据转换。它不会去动 DAO 的调度逻辑,也不会去改 Controller 的参数校验。

提示词的准确程度是一个方面,分层架构也是一个占比较大的层面。


不过实际编码过程中我给 AI 放手的范围其实是更大的,甚至不是按功能点、是按模块来的,然后分析模块之间是否有依赖关系;若没有还可以模块并行操作。不过在此之前还是要进行详尽的plan。不然你怎么能放心呢hh。还和我之前博文说的一样,你首先自己是要会的啊,不然真的是被骗了都不知道,方向会越来越偏的。


此外我还会让 AI 学习我的编码风格。恰好 oc(OpenCode) 就有AGENTS.md 这样的“功能”,开启新的对话它会先读这个文件。那么关于我的个人编码风格我就可以说“使用 new LambdaQueryWrapper<T>() 显式 new 的风格,不使用 Wrappers.xxx() 工厂方法”。

代码更加符合我的框架规范,减少我人工检查次数(降低人力成本…)

(仅举例,实际可供探索的很多很多的。这个和人设的那块也蛮像的(CLAUDE.md)。)


好了,来到本章末尾。架构规范不止是给人看的,也是给 AI 看的。 规范越清晰,AI 的产出越可控。规范越模糊,AI 就会“自由发挥”(而它发挥的方向,大概率不是你想要的)。




收尾


啊。终于写完了。再来回顾一下这篇文章吧。然后是本人的一些极其碎的碎碎碎碎碎念。


文章回顾


按照导航栏来看。我先说了不分层会怎么样,然后举了几个典型的坏味道例子。

行文中以分层架构图为索引(我是这样的啊hh,感觉蛮好用),介绍了我的分层体系:

Controller → Service → Logic → DAO → Mapper + Cache

关于每一层不该做什么、应该做什么。

接着说了数据载体,VO、DTO、DO 在层与层之间怎么流转。

然后用两个实战场景简单把内容串了一下:一个是用户登录,比较基础常见的,用来展示基础流转;还有一个带业务场景的赛事报名,来展示架构处理复杂性的能力。最后面说了些 AI 相关的。


写作中大概的线着这样的。很多刚开始拟定的时候是只有刚开始看到的大标题标题那样的。但是细入发现还有很多可以讲。并且也去查找资料、力求准确性,补充一些我觉得比较必要的部分;还有一些我想说、但是和本文主题有点点不强相关的(不过这些个地方标注了“可跳过”)。同时行进过程中也发现自己理解的很多地方都有不足,以及还有一些地方不够深入、和“为什么最终会选择这样”的一个形象阐述。


怎么说呢,这套架构规范当然不是唯一的正确答案。每个团队、每个项目都有不同的需求和约束。(这个在前言部分、以及文章的好多处都提到过很多次了!所以这里不多说了)

但我可以说,这套规范是经过实践验证的,至少不会出大问题。


它解决的核心问题不是“代码怎么写更好看”,而是“当项目变复杂、团队变大、AI 开始介入的时候,你的代码还能不能被理解和维护”。


如果你正在经历“代码怎么改都乱”的阶段(俗称…),不妨试试把每一层的职责想清楚,划好边界。一开始会觉得“多此一举”,但当你的项目从 5 个接口变成 50 个接口的时候,你也许就会觉得当初的决定很正确。


碎碎碎碎碎念(可跳过)


(OS:说一些和技术性不强相关的话。


跌跌撞撞终于搓完了^-^历时了好久,也写了好久感觉。有时候时间相对分散,有时候集中写。

之前也有 啊呀好气呀 的时候。在 git 上摔过一跤。在公司 slack off 时候会在公司电脑上写,回家后又会用自己的电脑,个人又比较喜欢用 Obsidian,装的这个插件可能还不太熟,导致 pull 的时候貌似没成功,个人又新改了、乱得很。同步更改后个人新写的一些见解也没了。像是梦回了之前团队协作代码冲突严重、Git 操作了好久好久、又怕把其他人代码给搞没了。

第二次写同样的见解确实就是没有第一次有耐心、有文思。也想起了看文时候也有作者说没保存写的都没了hh..

甚至也是遇到过一些和之前的认知有悖的地方(不过也还好了啦 都是些小的或者新的,不需要大改)。


慢慢写也还好,不过极少几次也会有点开一看到 哦 好长的字,有点点皱眉抵触(非也)的心理。特别是还要再看几遍,也要通篇看看整体逻辑之类的。(至少别有技术性的错误啊哈哈哈

让我想到了,现在 AI 可以给你写代码了,但是你的活变多了,脑子更累了。(类比不恰当啊,难说突然想到了这个。


然后也开始思考了,开始反思自己(哦,日常反思求精环节)。为什么会这样呢?其实这篇文章的预期结束时间也是比我刚开始算的要晚上个一周还多。也想起来我之前在校时有大把自己的时间,我就要给自己排很满的任务、然后又总是做不完,又累又生气^-^


我也问过他人啊,也自己思考过(要谢谢我的朋友们,给我提供了很宝贵的建议!!),搜索后,后来总结了这样的东西:


任务完不成是对未知的复杂性估少了。拿本架构来说,光是我自己写,回头读会抵触,很正常,是认知负荷的问题。人脑面对自己产出的长文本,会有一种“我都写过了为啥还要重读”的隐形抗拒。加上里面肯定还有当时没完全透彻的地方,一读就暴露,焦虑就上来了。


我一直是比较看重效率和产出的人嘛,我很讨厌用无用的时间来换质量。于是我就给自己开始设定时间,我开始干活的时候打个标签,结束了再打个标签,统计一天专注时间。再我进行专注的这段时间内,其他人事物都不得打扰我。ahhhh虽然蛮幼稚的、还要做加法,不过也相当于有个“人”在隐隐的看着我一样 我要开始 all-in 了。

这算是一个不错的点,不过我的任务还是完不成。后面寻得了朋友的建议!她说,列完清单,同时你还需要评估一下你做每件事情的时间。又分为短期和长期,需要进行拆分 成小的任务,然后每个小任务你再进行一下时间的评估。

!恰好我又知道我每天大概的专注时间会是多久。于是会有这样一段很有满足感成就感的时段。


但是怎么解决遇到困难就想退缩呢?其实真的很正常啦!“万事开头难,中间难,后面更难,难上加难”,那就先开始,过程中适当给予自己正反馈!也像是朋友和我说的那样。先开始,开始就已经不错啦。我会有时候过度纠结于一处、导致后面草草的问题,那也先开始,把上面说的都给也做了。系统化体系化。

遇到困难就抵触就想放弃就看不进去,那就休息一下、或者去找点其他也重要的事情做做,换个上下文。(不过要稍微换成低认知负荷的喔)


当然。最后要说,适合自己的才是最好的。一路上从刚开始上学啊到现在,听到好多好多声音,好多真的不一定适合自己,要有自己的判断、要会分辨。


那么说回计算机。我好喜欢,它把我成就得很平静,让我的个人性格也变得冷静、理性(ahh好多学理的朋友也这样和我说 TA们也在往这个趋势变),让我来形容的话我会描述自己为“有一种<淡淡的死感>”。有时候说话甚至还有一股子味(、。?)。哦。那我现在的输出可还标准?


也听到好多人和我说啦 AI 要取代程序员啦什么什么的,说用个 AI 一下就把代码写完了然后就开始在那边自贬之类的。不要这样。取代人的不是 AI,是使用 AI 的人。你自己原先没有一点基础的话,那你连用都不一定会用它啊,它骗你你都不知道。




碎碎念就到这里。如果你有不同的分层思路或者觉得我哪里说的不对,欢迎讨论^-^[真挚]。写代码这件事,没有标准答案,只有不断优化的过程。


希望会对你有帮助!

最新回复 (3)
  • Java 8 07-28 19:17
    1

    我的注意力已经不允许我看这么长的文章了

  • pipi 07-28 19:41
    2

    确实有点长,关于分层我觉得可以看下DDD

  • noobieHwang 07-28 20:03
    3

    粗看下来,我感觉佬的思考更多是在讨论分层职责和工程规范,架构背后的取舍相对少一些。

    我个人更倾向于把架构理解为隔离变化、聚焦关注点和能力抽取。真正需要把控的是三者冲突时如何权衡,如何保证系统在持续变化中的可演进性。

* 帖子来源Linux.do
返回