1. 工程结构设计,到底在设计什么
我见过太多"能跑"的项目了,代码能跑、接口能用、页面能点,看起来一切正常。但只要你有机会把代码拉下来打开看一眼,那种窒息感会瞬间涌上来——几百个类堆在几个包里,Service里躺着几千行的业务方法,一个Controller同时干着参数校验、权限判断、数据组装、日志记录的活,DTO和Entity在层与层之间毫无原则地互相传递。
我管这叫"能跑,但活不久"的项目。
这种项目往往不是死在业务复杂度上,而是死于工程结构失控。第一年大家还能靠记忆力撑着,第二年主力离职之后就没人说得清某个接口到底在哪几层做了哪些事,第三年一个看似人畜无害的需求改动,牵一发动全身,测试要回归两天,上线战战兢兢。所以当有人问我"后端工程结构怎么设计"的时候,我给的答案从来不是某张架构图、某个目录模板,而是先问一个问题:你这套代码,是只想让它跑起来,还是想让它活过三年?
1.1 拿到一个"能跑"的项目,先看哪里最痛
判断一个后端工程质量,不需要看文档,不需要开代码评审会,你只需要做三件事。
第一,随便找一个业务需求,比如"查询订单详情",然后从Controller入口开始往下跟,看这条调用链要穿越多少个类、跨多少个包、经过几次类型转换。如果从Controller到数据库查询这段路走下来要经过六七个类,每个类之间还互相new来new去,这个项目的基本盘就有点危险了。
第二,统计一下最大的那个Service类有多少行。超过两千行不是问题,超过五千行是常态,一万行以上就是灾难。行数本身不杀人,杀人的是行数背后隐藏的"上帝类"——所有业务逻辑都堆在一起,没有层次、没有边界、没有任何约束。
第三,看一眼现有的包结构。如果整个项目只有controller、service、mapper、entity四个包,而且包下面平铺了所有业务模块的类,我可以负责任地说,这个项目在半年到一年之间就会进入维护地狱。类会越来越多,命名会越来越随意,新来的同学根本不知道该往哪个包里放代码,最后的结果就是哪里有缝往哪塞。
这三个检查点基本能反映一个后端工程的结构健康状况。你觉得扎心,说明你见过。
1.2 好的工程结构,三个目标就够了
关于工程结构,很多文章会讲一堆高深的概念:DDD、整洁架构、六边形架构、CQRS……这些理论本身没错,但对多数中小团队来说,落地时最容易出现的状况是:理论学了一堆,代码一写还是老样子。
我不反对学架构理论,但我觉得工程结构设计的第一性原理其实很朴素。一套结构,只要能帮你稳定达成三个目标,它就是合格的。
目标一:边界清晰。任何一个类放在哪个包、哪一层、承担什么职责,是有明确规则的。别人看到包名就能大概猜到里面是什么东西,不需要打开类才能反推结构。
目标二:依赖可控。整条调用链的方向是单向的、可追踪的。上层可以依赖下层,下层不能反向依赖上层;同层之间的横向依赖有明确约束,不允许随便乱调。
目标三:变更成本可预估。加一个新接口、改一个业务规则、换一个数据源,维护者能明确知道改动影响哪些文件,不需要全局搜索才能摸清影响面。
这三个目标听起来简单,但能同时做到的项目其实不多。原因也很现实:结构是反人性的,人类天然倾向于"怎么方便怎么来",而工程结构恰恰要求你在写每一行代码的时候都想着"这个类放哪里更合适"。这种心智负担需要靠结构设计和配套规范来分担,不能全指望个人自觉。
1.3 三种主流工程结构形态
后端工程的代码组织方式,归纳下来大概有三种主流形态。
形态一:经典分层结构。也是Spring Boot最普及的形态——Controller暴露接口,Service承载业务,Mapper操作数据库。优点是人人都熟,新同学上手快;缺点是如果只有这四层没有别的约束,项目一大就必然走向混乱。
形态二:按业务模块分包。不按技术层次分,而是按业务域分,比如user、order、product三大包,每个包内部再各自分层。优点是高内聚,一个业务相关的类都在附近;缺点是跨模块调用如果管理不好,很容易产生循环依赖。
形态三:多Module的模块化结构。把工程拆成多个Maven或Gradle模块,比如common、framework、system、business几个Module,各自独立编译,模块之间通过接口或API交互。优点是边界最硬、编译期就能约束依赖方向;缺点是前期搭建成本高,对团队的分层能力有要求。
没有哪个形态是绝对正确的。关键在于跟团队规模和业务复杂度匹配。三五个人做内部系统,硬套多Module纯粹是给自己找麻烦;二十个人做大项目还在平房式的单包里堆类,迟早要还债。
1.4 为什么Spring Boot默认的单包结构撑不过一年
Spring Boot的初始化器给你生成的工程,默认是这样一个结构:一个主类在最外层,下面自动生成controller、service、mapper、entity、config等几个包。说实话,这个结构作为"骨架"是合格的,但作为项目的"最终形态",它撑不过一年。
原因不复杂。Spring Boot给的默认包结构是基于技术分层思想的——所有Controller放一起,所有Service放一起。这套结构在小项目里没问题,类不多,你翻一下就能找到。但项目一旦膨胀,问题就来了:订单相关的Controller、用户相关的Controller、支付相关的Controller全部挤在controller包里,在IDE的文件列表里都分不清谁是谁。
这不是包结构有问题,是"技术分层+平铺业务"的组合撑不住业务扩张。所以很多成熟的团队会演进到业务分包或者在业务模块内再嵌套分层,本质上是在技术分层的骨架上加了业务维度。这个演进方向,后文我会详细展开。
2. 后端分层设计:Controller、Service、Mapper的边界到底在哪
分层这件事,看起来是后端开发的基本常识,但真正能把边界划清楚的人不多。每次代码评审,我都能看到大量违反分层原则的代码在堂而皇之地往仓库里提交。
最常见的几个典型问题:Controller里写事务、Service里拼HTML、Mapper里写复杂到天际的SQL、Service和Service之间随意互相new、DTO直接传给了Mapper层然后Mapper返回Entity再被Controller直接序列化给前端……这些问题单独拎出来都能用"懒"来解释,但本质上是层与层之间的职责边界没有在团队里达成共识。
2.1 Controller层:只做协议适配,不做业务
Controller的核心职责是HTTP协议的适配层,它的工作只包含三件:接收请求参数并完成基础校验、调用Service层方法、把Service的返回值转换成可序列化的响应体。
不要把业务规则写进Controller。最典型的反面教材是:在一个Controller方法里手写分页计算,或者在Controller里判断用户角色来决定走哪条业务分支。这些都属于业务逻辑,放在Controller里会让业务规则散落在接口层,后续想复用就找不到入口,想改规则就得在HTTP协议层里翻代码。
Controller层还有一个隐性的职责——参数对象的生命周期管理。Controller接收的Request对象在方法结束后就应该被丢弃,不应该把它传给Service层去做业务处理。正确的做法是在Controller里完成从Request参数到业务参数的转换,Service层只接收它定义的业务入参,不感知HTTP协议的任何细节。这就是Controller做"协议适配"的含义。
2.2 Service层:业务逻辑的唯一合法位置
Service层是整个后端工程的核心。判断一个项目结构是否健康,最直接的方式就是看业务逻辑的分布:如果业务规则全部在Service里,结构是健康的;如果业务逻辑散落在Controller、Mapper注解甚至前端JS里,那结构肯定是有问题的。
Service层设计的几个关键点我逐个说。
第一个关键点:Service是事务的边界。如果你的项目用了Spring的@Transactional,它的正确位置一定是在Service层的公开方法上。一个Service方法就是一个完整的事务单元,方法内部要么全部成功要么全部回滚。不要把事务注解打在Controller上,也不要在Mapper上做事务控制。把事务放在Service层,意味着事务的粗细粒度由业务方法决定,这是最合理的控制粒度。
第二个关键点:一个Service只服务一个业务域。尽量避免把两个不相关的业务逻辑塞进同一个Service类。订单Service只管订单,用户Service只管用户,如果订单创建过程中需要扣减库存,订单Service应该调用库存Service的接口,而不是自己直接去写库存表的Mapper操作。这种跨域调用要保持“单向依赖”,不要让库存Service反向依赖订单Service,否则模块间就成了蜘蛛网。
第三个关键点:Service之间的调用用接口,不要直接new。用Spring的依赖注入,把其他Service作为依赖注入到当前Service中。新手最容易犯的错是Service里new一个其他Service来用,这种写法会破坏依赖管理,让单元测试没法做Mock,也让Spring的代理机制失效。
2.3 Mapper/DAO层:数据访问的最底层
Mapper层是数据访问的最底层,它的职责非常单纯:负责SQL的执行和结果集的映射。业务条件判断、数据组装、字段计算,这些都不该在Mapper里做的。
我见过很多把业务逻辑写进Mapper XML的做法,比如在一个查询语句里堆了十几个if判断,根据不同的参数组合生成不同的查询条件。这种做法在短期内看着很"方便",能把一个复杂查询压缩在一个方法里,但长期维护是噩梦——你根本不知道这个方法被多少个Service调用过,每个调用方传入了哪些参数,任何一个分支逻辑的改动都可能影响全局。
Mapper的正确定位是“数据访问原子操作”的提供者。一个Mapper方法对应一个明确、单一的数据访问需求,比如根据ID查询、按条件分页、更新某几个字段。复杂的业务查询可以通过多个Mapper方法的组合来完成,而不是强行把业务逻辑翻译成一条大SQL。
Mapper层还有一个很容易被忽略的点:事务内的连接管理。Spring的@Transactional作用于Service层时,同一线程内的多次Mapper调用会共享同一个数据库连接,这是Spring利用ThreadLocal实现的事务传播机制。所以你在Mapper层不要尝试自己获取连接或关闭连接,这些都交给Spring统一管理。
2.4 依赖方向与分层铁律
分层结构一旦定下来,依赖方向就是铁律。
在标准的三层架构中,依赖方向是:Controller → Service → Mapper。Controller可以依赖Service,Service可以依赖Mapper,反过来都不行。Controller不应该直接依赖Mapper,Service不应该被Mapper反向调用。
这条铁律有什么意义?它保证代码的调用链永远是单向的、可追踪的。你可以顺着Controller开始往下追,从接口到业务到数据,不会遇到环路。一旦依赖方向失控,比如Mapper里注入了某个Service来拿配置数据,那整个结构就乱套了——看似只是一个小小的反向依赖,实际意味着你在数据访问层耦合了业务逻辑,后续要替换数据源或者做缓存改造时,会牵出一堆业务依赖。
在检查依赖方向的时候,我建议团队里至少要有一两个代码洁癖患者。他们存在的意义不是让代码“好看”,而是在依赖方向失控的早期把它掐灭在萌芽状态。
2.5 DTO、VO、BO、Entity,别让它们打架
分层依赖方向背后,还藏着一个几乎所有后端团队都要面对的问题:数据模型怎么分层。
这里要区分清楚四类对象的职责。
**Entity(实体对象)**映射数据库表结构,字段与表字段一一对应,只存在于Mapper层与数据库之间。**DTO(数据传输对象)**在服务间或服务与外部接口间传输,比如RPC调用的请求和响应就是DTO。**VO(视图对象)**是面向接口输出层的,直接决定前端能拿到什么数据,字段命名、类型都应该贴合前端需求。**BO(业务对象)**承载业务处理过程中的临时数据状态,用于Service内部逻辑。
很多项目做不好分层就是因为这一块混乱。最常见的反模式是:Entity直接传给Controller然后序列化返回给前端。短期看很省事,长期全是坑——数据库字段暴露给前端了,不想让前端看到的内部字段泄漏了,前端想要的字段名和数据库字段对不上,你只能在前端做各种奇怪的兼容。
正确的做法是:Entity只在Mapper与Service之间传递,Service返回BO或DTO给Controller,Controller再转换成VO输出。每一层之间的对象转换可以手工写,也可以用MapStruct等工具来减少样板代码。
我知道有人会吐槽说"这太繁琐了,项目里字段那么多,转来转去很麻烦"。但请记住"能活三年"这个目标——你一次转换的繁琐,换来的是后续所有维护者对数据边界和层次边界的清晰认知,这笔账怎么算都值。
3. 单体内的包结构与模块划分
层与层之间怎么协作搞清楚了,下一步要解决的是包怎么组织、模块怎么划分。这部分直接关系到日常开发中"新代码往哪里放"这个高频问题。
3.1 技术分层包 vs 业务分包,怎么选
在实战中,包结构设计最纠结的一个选择是:按技术分层建包还是按业务建包。
纯技术分层包就是Spring Boot默认生成的那种:controller包、service包、mapper包、entity包各管一摊。优点是结构简单、理解成本低;缺点是业务跨层追踪时要在多个包之间跳来跳去,业务本身的内聚性被割裂了。
纯业务分包就是按业务域建包:user包、order包、product包,每个包内部再自己分controller、service、mapper。优点是高内聚——跟用户相关的所有代码都在user包下面;缺点是跨业务模块交互时职责归属容易模糊,比如订单要查用户信息,访问的是user包暴露的service接口,但user包里是允许别人随便注入自己的service还是通过一个专门的facade接口暴露,很多团队定义不清楚,最后会退化成包之间直接互相new。
从我经历过的项目来看,组合式结构是最稳的选择:按业务模块建立顶层包,每个业务模块内部再按技术层次分包。兼顾业务内聚与技术分层两个维度,也不复杂,团队容易上手。
3.2 推荐的包结构模板
下面是我在项目里落地过多次、被验证"能活三年"的单体应用包结构模板,供参考。
com.xxx.xxx ├── common/ # 通用模块(工具类、通用常量、通用异常) │ ├── constant/ │ ├── enums/ │ ├── exception/ │ └── utils/ ├── config/ # 全局配置(Spring配置、安全配置、缓存配置) ├── framework/ # 框架封装(若依风格的通用基类、切面、拦截器) │ ├── aspect/ │ ├── interceptor/ │ └── base/ ├── module/ # 业务模块(按业务域划分) │ ├── user/ │ │ ├── controller/ │ │ ├── service/ │ │ ├── mapper/ │ │ ├── entity/ │ │ ├── dto/ │ │ └── vo/ │ ├── order/ │ │ ├── controller/ │ │ ├── service/ │ │ ├── mapper/ │ │ ├── entity/ │ │ ├── dto/ │ │ └── vo/ │ └── ...其他业务模块 └── Application.java这个结构的好处在于:业务模块自包含,跨模块调用只能访问对方暴露的Service接口,各模块内部的分层逻辑与技术分层一致,新来的同学看一眼例子就能很快知道代码该放哪里。这是一种把"业务分包+技术分层"融合的结构,在很多中大型项目里被验证过是实用可落地的。
3.3 什么时候该把单模块拆成多Module
单模块工程能支撑的业务规模是有上限的。当项目膨胀到一定程度,你可能需要把工程拆成多Module的Maven工程。
什么程度是"需要拆"的信号?我列几个判断依据:
- 团队规模超过15人以上,大家都在同一个Application里开发,频繁的代码冲突开始拖慢开发效率;
- 业务模块之间的边界已经稳定,比如系统管理模块与核心业务模块几乎没有交叉改动;
- 出现了“只想给某个业务模块加部署实例,但不希望其他模块一起发布”的诉求;
- 公共类的变更会触发所有业务模块的联调,即使改动本身只影响某一个模块。
拆Module的核心价值不是把代码分散开,而是用Maven或Gradle的依赖管理把模块边界变成编译期约束。哪个模块能依赖哪个模块,在pom.xml里定义好,破坏了边界直接编译不过。这是最硬的结构保障,靠代码评审和个人自觉是无法替代的。
常见的模块划分方式包括:common(纯工具,不依赖任何业务)、framework(框架封装,依赖common)、system(系统管理相关业务)、business(核心业务模块)等。模块之间的依赖关系必须是单向的、无环的,出现循环依赖的Module结构时,一定是设计没有理清。
3.4 参考若依框架的模块设计,好在哪里
提到后端工程结构,很多人会想到若依框架。抛开它的具体业务功能不谈,单从工程结构设计的角度,若依的模块划分确实有一套值得参考的逻辑。
若依把工程拆成了几个核心模块:ruoyi-common放通用工具、ruoyi-framework放框架核心、ruoyi-system放系统服务、ruoyi-admin作为Web入口、ruoyi-ui是前端。这个划分把"通用能力"和"业务功能"做了隔离,公共逻辑下沉到底层模块,业务模块依赖底层模块但不反向依赖。这样后续如果要做微服务拆分,common和framework直接下沉到独立的基础库,业务模块单独出来成服务,过渡成本低很多。
它对单体或SRM型项目的借鉴意义在于:通用能力和业务能力分层的理念是普遍适用的。哪怕你就一个单体模块,也要在包结构上把common、framework、business三块分开。这能避免一个最常见的坑:业务代码里到处是工具方法,改一个公共工具类的签名,全项目跟着编译错误。
4. 配套规范:撑起工程结构的隐形骨架
工程结构不只是目录长什么样,更是代码怎么长。
很多项目的目录结构是挺好的,但代码写的随心所欲:包名大小写混乱、类名不能表达职责、方法动不动写两三百行、异常信息一串看不懂的英文……这些看起来都是细枝末节,但它们共同决定了一个工程能否长期可维护。
4.1 命名规范:包、类、方法的统一约定
命名这件事,人人都会,但把命名当作工程规范来严格执行的团队不多。
包名建议全小写,单词之间不用分隔符,比如userinfo而不是userInfo、user_info。这是Java的长期约定,别为了标新立异去打破它。
类名的命名要确保“见名知责”。Controller以Controller结尾,Service以Service结尾,ServiceImpl以ServiceImpl结尾,Mapper以Mapper结尾,Entity不带后缀,DTO以DTO结尾,VO以VO结尾。这些约定看起来很简单,但在快速迭代的项目里,如果没有强制约束,很容易出现UserService、UserBiz、UserHandler、UserManager这种混乱的命名,新人根本搞不清哪个才是真正的Service入口。
方法命名同样要统一。查询以get或query开头、新增用create或add、修改用update或modify、删除用delete或remove。一套统一的方法命名会让代码的阅读体验大幅提升,我在做代码评审时,看到符合约定命名的方法,基本可以跳过不看;看到奇奇怪怪的命名,就得多花几分钟去理解它的意图。
4.2 接口安全规范:别让一个漏洞毁掉整个结构
结构设计得再漂亮,如果安全层面有漏洞,整个系统也活不久。这里说的"活不久",不只是指被攻击者攻破,还包括因为安全问题导致的大量紧急修复、临时补丁,这些补丁往往会破坏原有的工程结构。
后端接口安全,有一些底线规范是必须坚持的。
输入校验不能只靠前端。请求参数在后端必须重新校验。长度、格式、枚举值范围、必填项,后端都要校验一遍。前端校验只是用户体验,后端校验才是安全边界。
上传功能要严格做类型校验。上传文件不能只校验扩展名,因为扩展名可以被轻易伪造。要校验文件的MIME类型、文件头魔数、文件大小,上传目录建议禁用脚本执行权限。这一条在Apache、Nginx等服务器上都需要单独配置,很多团队都因为上传校验不严格吃过亏。
SQL注入的防线不能依赖外部组件。用MyBatis的#{}参数占位,不要用${}直接拼接。如果业务上实在需要动态排序字段,建议用白名单,而不是直接把前端传的字段名拼进SQL。
敏感数据加密存储。用户密码必须用BCrypt等不可逆算法加密存储,不能是MD5加盐就了事。前端展示的数据要脱敏,手机号、身份证号只能显示部分字段。
防重放与越权。后端接口要校验用户的权限范围,不只是"是否登录",还要校验"这个用户是否有权限操作这条数据"。常见的越权漏洞就是只校验了登录态,没有校验数据归属。
4.3 代码审查与架构守护:怎么让规范真正落下去
规范写得再好,没有闭环的落地机制,就是一张废纸。代码审查和架构守护是让结构规范真正成为团队习惯的两个关键手段。
代码审查的制度设计有几个注意点。一是别让审查变成走过场,要让审查的人真的去读代码、理解改动,而不是光看diff有没有冲突。二是审查的重点要放在结构正确性上——这个类放在了正确的包吗?Service有没有绕过Mapper直接操作数据源?DTO被Controller以外的地方使用了吗?这些问题比"变量名拼写对不对"重要得多。
架构守护方面,可以考虑引入自动化工具。我用过ArchUnit,它能用JUnit的方式编写架构规则测试,比如"controller包下的类不得直接依赖mapper包下的类"、"所有Service实现必须放在service.impl子包下"、"只有DTO可以被其他模块引用"等等。把这些规则写进单元测试,在CI构建时运行,一旦有人破坏了架构规则,构建直接失败。这是把人工审查和自动化检查结合起来的最优解。
4.4 用ArchUnit守住架构边界
简单演示一下ArchUnit的用法。假设你定了这么几条规则:
- controller包不直接访问mapper包
- service实现类的命名必须以ServiceImpl结尾
- entity包不得依赖service包(反向依赖禁止)
在测试目录下建一个ArchUnitTest类,写类似这样的检查:
@RunWith(ArchUnitRunner.class) @AnalyzeClasses(packages = "com.xxx.xxx") public class ArchitectureTest { @Test public void controller_should_not_depend_on_mapper() { noClasses() .that().resideInAPackage("..controller..") .should().dependOnClassesThat() .resideInAPackage("..mapper..") .check(new ClassFileImporter().importPackages("com.xxx.xxx")); } @Test public void service_impl_should_be_named_correctly() { classes() .that().resideInAPackage("..service.impl..") .should().haveSimpleNameEndingWith("ServiceImpl") .check(new ClassFileImporter().importPackages("com.xxx.xxx")); } }ArchUnit的核心价值在于把架构规范变成了可运行的代码,团队可以定期执行,也可以放到CI里。它不能替代代码评审,但它能把结构层的问题在早期就暴露出来,省掉大量人工review的时间。
5. 配套设施:让工程在线上真正活下来
项目结构搭好之后,还有一些"非业务"的配套设施决定一个工程能不能健康地活在线上。这些设施不是某个具体业务功能,但缺了它们,整个工程就是裸奔。
5.1 统一响应体与全局异常处理
前端对接后端接口,最怕的就是每个接口的返回格式都不一样。有的接口直接返回业务数据,有的接口返回一个包装对象,出错时有的抛异常、有的返回null、有的返回一个业务错误码。这种混乱会让前后端联调效率极低,也会让前端代码里塞满各种防御式判断。
所以后端工程从第一天就应该约定一个统一响应体。常见的做法是用一个Result 类型统一包装接口返回值,包含code(状态码)、message(提示信息)、data(业务数据)三个字段。正常返回时code为成功值,业务异常时code为对应的错误码,message给前端展示友好的错误信息。
配合统一响应体的是全局异常处理。用Spring的@RestControllerAdvice配合@ExceptionHandler,可以统一捕获Controller层抛出的异常,把它们转换成标准响应体输出。这样业务代码里不需要到处写try-catch,只需要在需要的地方抛出业务异常即可,异常处理逻辑收敛到一处,维护成本大幅降低。
5.2 配置管理:环境的切换不该改代码
一个工程在它的生命周期里,至少要经历本地开发、测试、预生产、生产多个环境。不同环境的数据库地址、Redis连接、第三方服务的密钥都可能不同。如果这些配置是写死在代码里的,部署时就要改代码重新编译,这简直是灾难。
正确做法是把配置外置。Spring Boot的application.yml天然支持多环境配置区分,通过application-{profile}.yml来分割不同环境的配置,再通过启动参数spring.profiles.active指定当前激活的环境。如果项目在容器或者K8s里跑,可以考虑用环境变量或者配置中心(如Nacos、Apollo)来管理配置,实现配置的实时动态更新,避免修改配置还要重启服务的尴尬。
配置管理的另一个重点是敏感信息的保护。数据库密码、第三方密钥不能明文存在配置文件里。常见的做法是使用环境变量注入或专门的密钥管理服务。很多生产事故和数据泄露事件,根源都是配置文件被泄露或者密钥写死在代码仓库里。
5.3 日志规范与链路追踪:排查问题的底牌
线上出了问题,第一手排查工具不是Debugger,是日志。但很多项目的日志是没有规范的——日志散落在各种类里,格式不统一,没有日志级别区分,出了问题想捞日志要么捞不到、要么捞出来是一堆无用的信息。
日志规范要解决几个问题。日志格式统一:时间、线程名、级别、类名、消息内容,每一行日志都有统一格式,方便收集和检索。日志级别规范:error记录系统和业务异常,warn记录不直接影响功能但需要注意的情况,info记录关键业务流程的关键状态,debug记录方法级别的细节。业务关键路径要有日志:比如订单创建、支付回调、用户登录,这些核心动作必须在info级别有明确的日志输出,这样才能在问题出现时快速定位"事情做到哪一步了"。
链路追踪是更进一步的保障。在微服务或者分布式场景下,一个请求会经过多个应用,排查问题时要能做到根据一个请求ID串联起整条调用链。当前端报一个错误时,如果你能拿到一个traceId,然后在一堆日志里按traceId搜一遍,整个链路的调用情况就一目了然。即使是单体应用,也可以在入口处生成一个requestId放进MDC里,在日志中打印出来,排查问题同样受益。
5.4 API版本管理与兼容策略
后端接口一旦被前端、APP、第三方系统使用,就变成了一个“有契约”的接口。如果哪天你改了某个接口的字段名、调整了参数结构、升级了响应格式,下流调用方轻则报错,重则核心功能瘫痪。API版本管理就是解决这类问题的关键手段。
常见的版本管理方式有几种:URL路径带版本号(/api/v1/order)、请求头带版本号、参数带版本号。业界最常用也最容易被接受的是URL路径带版本号的方式,直观、可读性好,也方便在网关层做路由分发。
版本管理要配套一个策略:不轻易删旧版本的接口。旧接口下线需要给调用方留一个缓冲周期,先标记为deprecated,再逐步切流量到新版本,最后实在没有调用量了再下线。这个过程需要一定的流程管理,依靠人来自觉不现实,最好在API文档平台或者接口管理工具上做标记和统计。
6. 从"能跑"到"能活三年":实践清单与常见问题
前面聊了这么多原理和方法,这一节我来点实际的——如果你现在接手一个结构混乱的工程,或者正在从零搭建一个新工程,具体该怎么操作。
6.1 接手中型项目,先做这三件事
第一件事:先画依赖图,再动手重构。花一天时间,把现有的包结构梳理清楚,按业务域和依赖关系画出一个粗略的依赖图。标出哪些模块是高内聚的、哪些是严重耦合的、哪些是循环依赖的重灾区。这个图是你后续所有重构决策的依据,画清楚再动手,不然重构就是拆东墙补西墙。
第二件事:先立规矩,再改代码。不要一上来就搬代码,要先定好目标结构——用前文推荐的包结构模板,确定包命名、类命名、依赖方向、DTO/Entity边界这些规范,把它写进团队的开发规范文档里。规矩立好了,后续的代码迁移才有依据,否则改到一半发现当初定的结构有问题,又得推倒重来。
第三件事:从边缘模块开始,逐步迁移。最有价值也最稳妥的方式是选一个跟其他模块耦合较小的业务域,比如系统管理或者操作日志模块,先按新结构完整迁移一遍,跑通了流程、验证了规范,再逐步推广到其他模块。千万不要想着一次性把整个项目的结构重构完,那是一个高风险、长周期、几乎必然失败的计划。
6.2 常见问题速查表,遇到直接翻
我把这些年做后端工程结构落地时最容易踩的坑整理成一张速查表,遇到问题可以对着看。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 一个Service类上千行甚至上万行 | 业务逻辑没有按业务域拆分 | 按业务域拆Service,跨域调用用接口 |
| 前端报错但后端没有日志 | 缺少全局异常处理或日志级别设置不当 | 增加@RestControllerAdvice统一异常处理,关键路径加info日志 |
| Controller直接操作Mapper | 分层边界不清晰 | 强制Controller只能调用Service层 |
| 改了公共工具类,全项目编译错误 | 公共类承载了过多被依赖的随意方法,缺乏下沉与收敛 | 收敛公共类,按模块下沉,减少跨模块依赖 |
| 一个查询方法传十几个参数 | Mapper方法职责不单一 | 拆分Mapper方法,职责单一化,必要时引入查询DTO对象 |
| 环境之间配置混乱 | 配置没有外置,环境切换靠改代码 | 引入多profile配置或配置中心 |
| 事务不生效 | 事务注解放在私有方法或同类内部调用上 | 事务注解放在Service层public方法,避免同类内部自调用 |
| 跨域请求失败 | 前后端分离场景下未配置跨域策略 | 统一配置CorsFilter或使用网关跨域 |
这里单独说一个热搜词场景——微信小程序真机调试请求无法到达后端。这类问题多数不是工程结构本身的问题,而是网络环境的问题:小程序真机调试时的域名必须是HTTPS且已在后台配置合法域名,本地开发时又要打开“不校验合法域名”的选项。这些点跟后端结构没关系,但排查时需要你懂,不然会怀疑是自己的代码写错了。
6.3 三年后回头看,这些决策最重要
项目能活三年的关键,往往不是你的技术有多炫、不是你的框架选了多新,而是当初在最基础的结构决策上有没有想过:
业务边界是不是划清楚了。哪些属于用户域、哪些属于订单域、哪些属于支付域,这个边界一旦定了,后续大部分代码放哪、模块怎么复用,都是水到渠成的事。
依赖方向是不是定死了。Controller只能调Service,Service只调自己的依赖,Entity不往上层传,DTO不往下层漏。这些铁律在项目初期没什么感觉,撑过一年之后你就会发现,它们是项目不乱套的底线。
公共逻辑是不是真正下沉了。工具方法、基类、通用组件这些,有没有沉淀到common或framework模块,而不是散落在各个业务类里。没有下沉的公共逻辑是项目膨胀后最难治理的地方,可以说是“陈年债务”的源头。
工程结构是给人看的,更是给时间看的。它的价值不是让你今天开发起来多顺手,而是让三年后的同事——不管是你还是接手你代码的人——看到这台“代码机器”的时候,还愿意维护它。从这个角度看,后端工程结构设计这个题目,本质上是个长期主义的技术活。