刚开始带一个十人左右的技术团队接手《看潮企业管理软件》那阵子,我最大的感受不是功能难写,而是代码越写越乱——今天加个销售单据,明天补个库存查询,后天改个审批流程,每个人都在自己觉得顺手的位置放文件。项目开发到第三章3-1节,聊到的“项目结构”,听起来是最没有技术含量的话题,但踩过坑的人都知道,一个管理软件能不能顺利迭代到第20个版本,结构几乎决定一切。
《看潮企业管理软件》定位很明确:中小企业用的进销存、财务、人事一体化管理工具,不是互联网那种高并发产品。这类软件的特点是业务逻辑多、表单流程长、权限维度复杂,最怕的不是性能慢,而是改了A功能把B功能弄崩,来了新员工一个月还找不到代码在哪。编程与数学这份课程内容之所以把“项目结构”单独拆成一个大章节来讲,就是因为结构本质上是一种数学模型——它要解决的问题是“把复杂系统变成层次清晰的组成部分,并控制它们之间的依赖关系”。
这一节3-1是整个项目结构章节的开篇,核心是三件事:一是弄清楚业务模块怎么拆分,二是确定技术层面的目录与分层,三是建立约束代码走向的依赖规则。接下来我把我们在实际开发中怎么定的、踩过哪些雷、最后形成什么样的结构,完整拆开讲一遍。
1. 项目结构设计之前,先想清楚这两个问题
1.1 业务模块的边界到底怎么切
很多人在搭项目结构时有个误区,一上来就跟着技术框架走,Spring Boot默认给你建几个包就开始往里塞东西。但企业管理软件的根子是业务逻辑,结构如果贴合业务域,后面加功能才会顺畅。我们做《看潮》时,第一件事不是打开IDE建目录,而是把需求清单重新归类。
整个系统最后被划成六大业务域:
- 系统管理域:用户、角色、菜单、操作日志、数据权限
- 基础资料域:商品档案、客户档案、供应商档案、仓库档案
- 采购管理域:采购订单、采购入库、供应商对账
- 销售管理域:销售订单、销售出库、客户对账
- 库存管理域:库存查询、调拨、盘点、库存预警
- 财务结算域:应收应付、费用登记、收付款核销
这个划分不是拍脑袋拍的,背后用的是一种朴素的建模思路——“人、货、钱”。人事和权限管的是“谁能用”;商品和库存管的是“货在哪儿”;采购销售管的是“货怎么流动”;财务管的是“钱怎么结算”。任何一笔业务,最终都会落到这三个核心对象上。比如“销售出库”这个操作,它同时牵扯到货(库存减少)、钱(产生应收账款)、人(哪个销售员经手,客户是谁)。这样切出来的模块,彼此之间有清晰的数据关系,而不是功能名称的堆砌。
1.2 结构里的数学思想:分治、抽象和依赖方向
如果只把“项目结构”理解成文件夹摆放,那确实没什么好讲的。但它背后的东西全是数学。首先是分治——把大问题拆成可以独立解决的小问题,再逐个击破。企业管理软件动辄几十张表、上百个接口,如果不拆,一个Service类能写到两万行。拆的标准是“内聚”,同一个模块里的事尽量放在一起,不同模块之间尽量少发生直接调用。
然后是抽象——剥离共性,保留变化。我们在三层架构里做了一层“防腐层”,数据库表对应的Entity不直接返回给前端,前端传来的JSON也不直接落库,而是通过DTO(数据传输对象)做转换。这就像做菜时把食材清洗切配和最终摆盘分成两个工序,哪怕摆盘方式变了,食材处理流程不用动。
最后是依赖方向。这是整个结构设计里最像数学定理的一条规则:上层可以依赖下层,下层绝不能反向依赖上层。Controller可以调Service,Service可以调Mapper,但Mapper绝对不能反过来调Service。依赖关系一旦出现环,整个结构的数学意义就崩了——两个模块互为前提,等于谁都没法被独立替换和测试。后面我们用脚本扫描依赖图,凡是出现环的提交直接打回。
2. 看潮项目目录结构全景拆解
2.1 后端骨架:一个可以直接抄的Spring Boot结构
技术选型上我们用了Spring Boot 3.x + MyBatis-Plus + MySQL,这套组合在中小企业管理系统里非常成熟,招人容易、资料多、踩坑方案也齐全。后端目录我们最终固化成了这样一个结构:
kanchao-server/ ├── pom.xml ├── kanchao-admin/ # 后台管理接口入口模块 ├── kanchao-common/ # 通用工具与基础封装 │ ├── src/main/java/com/kanchao/common/ │ │ ├── result/ # 统一返回结果封装 │ │ ├── exception/ # 全局异常与业务异常 │ │ ├── utils/ # 日期、字符串、Excel等工具 │ │ └── constant/ # 常量定义 ├── kanchao-framework/ # 框架配置模块 │ ├── config/ # Redis、MyBatis、安全配置 │ ├── security/ # 登录认证与权限注解 │ └── aspect/ # 操作日志切面 ├── kanchao-system/ # 系统管理业务模块 │ ├── controller/ │ ├── service/ │ ├── mapper/ │ └── domain/ ├── kanchao-base/ # 基础资料模块 ├── kanchao-purchase/ # 采购管理模块 ├── kanchao-sale/ # 销售管理模块 ├── kanchao-stock/ # 库存管理模块 └── kanchao-finance/ # 财务管理模块你可能会问,为什么不让每个模块都单独成为一个Spring Boot应用?因为企业管理软件的业务模块之间耦合度太高了。销售出库要扣库存,库存盘点要生成财务凭证,这些跨模块调用是常态。如果按微服务拆,光分布式事务就能把人折腾死,对几十人规模的公司来说纯属过度设计。
所以用的是多模块Maven工程 + 单一部署单元的模式。每个业务模块是一个Maven子模块,模块之间通过Service接口调用,而不是直接new对方的实现类。这样模块边界在编译期就是清楚的——pom.xml里没引的依赖,代码里想调都调不了。这个约束比任何代码评审都硬,因为编译器替你做了检查。
服务层内部还有一个不成文但强制执行的约定:controller只做参数接收和路由转发,不写业务逻辑;service层写业务编排,一个方法对应一个完整的业务动作;mapper只做数据读写。有一次新同事把一段库存计算的逻辑写在了Controller里,代码跑起来没问题,但后续要复用时找不到地方,最后还是花了一个下午把它挪回Service层。
2.2 前端目录:Vue3工程的组织方式
前端的混乱程度通常比后端更严重。我们前端用的是Vue3 + Vite + Element Plus + Pinia,目录结构经过两个项目打磨后固定成这样:
kanchao-web/ ├── public/ ├── src/ │ ├── api/ # 接口请求封装,按业务域拆分 │ │ ├── system/ │ │ ├── stock/ │ │ ├── sale/ │ │ └── finance/ │ ├── assets/ # 静态资源 │ ├── components/ # 通用组件 │ ├── layout/ # 布局框架 │ ├── router/ # 路由配置 │ ├── store/ # Pinia状态管理 │ ├── utils/ # 工具函数 │ ├── views/ # 页面组件,与后端模块一一对应 │ └── constants/ # 前端常量与枚举 └── package.json这里最容易被忽略的是api/目录。我见过很多项目喜欢把请求直接写在页面组件里,美其名曰“省事”,结果就是同一个接口被三四个页面各写一遍,后面前端改一个字段名要全局搜索替换。我们的约定是:每个后端Controller都对应一个JS模块文件,所有请求方法统一导出。页面里禁止直接写axios。
views/目录也不是随便放页面,而是和后端模块严格对应。后端有stock/inventory接口,前端就有views/stock/inventory/index.vue。这样前后端在结构上形成镜像关系,排查问题时沿着URL就能从浏览器一路找到Java代码,不需要查任何文档。
2.3 数据库层面也是结构的一部分
很多人以为项目结构只管代码目录,实际上表结构的设计同样属于结构的范畴。我们在建表时遵循了两条原则:
- 表名统一用业务域前缀:
sys_user、base_goods、stock_inventory、sale_order - 主键统一用雪花ID,不暴露自增ID给前端
还有一张全局的sys_config参数表,key-value结构,用来存系统级配置,比如单号生成规则、库存预警阈值。这些参数如果硬编码在代码里,每次改都要发版,放进配置表后运营人员自己就能调。
字段命名上我们统一使用下划线风格,在Java实体里用驼峰映射。时间字段统一用create_time、update_time,每次插入和更新都由MyBatis-Plus自动填充,不允许在业务代码里手动set时间。这是个小约定,但避免了“有的张表叫created_at,有的叫createTime”这种低级混乱。
3. 实操落地:三步搭出一个不后悔的项目结构
3.1 第一步:把模块依赖图画出来再用代码实现
搭建结构最容易犯的错是“边写边改目录”,这跟盖房子不打地基一样。我们在3-1这一节让学生先做一件事——不写代码,只画一张模块依赖图,用的是最原始的办法:一张纸,每个业务域写成一个圆圈,两个模块之间如果有调用关系就画一条线,线上标方向。
这个练习做完你会发现,最干净的结构是树状或近树状图,最危险的结构是出现环路。
举个实际例子:最开始我们把“采购入库”放在库存模块里,但采购入库会生成应付账款,于是库存模块就要调财务模块的接口。后来发现财务那边对账时要反查入库单,于是又反向调用了库存模块的接口。两条线一交叉,环就出现了。最后我们把“采购入库”这个动作整体挪回采购模块,由采购模块同时向库存和财务发出消息,依赖图才重新恢复成无环的树形结构。
这个经验非常深刻:边界切在“业务流程的完整动作”上,往往比切在“数据对象的归属”上更准确。一个业务动作的发起者拥有该动作的编排权,其他模块只能被通知,不能反客为主。
3.2 第二步:立下命名和分层公约并写进规范文档
结构如果只有摆放位置,没有命名规则,照样会乱。我们定了一套硬性公约,新人入职第一周必须背下来:
| 层级 | 命名规则 | 示例 | 说明 |
|---|---|---|---|
| Controller | 模块名 + Controller | SaleOrderController | 只做路由,不写业务 |
| Service接口 | 模块名 + Service | SaleOrderService | 定义完整业务动作 |
| Service实现 | 模块名 + ServiceImpl | SaleOrderServiceImpl | 核心业务逻辑 |
| Mapper | 模块名 + Mapper | SaleOrderMapper | 数据读写 |
| Entity | 对应表名驼峰 | SaleOrderEntity | 与表字段一一对应 |
| DTO | 模块名 + 场景 + DTO | SaleOrderCreateDTO | 接收前端参数 |
| VO | 模块名 + 场景 + VO | SaleOrderPageVO | 返回前端数据 |
命名统一的最大好处是团队协作时不需要“翻译”。代码评审时一眼就能看出某个类放错了位置,比如一个叫PurchaseService的接口出现在kanchao-sale模块里,那肯定有问题。路由URL也遵循统一规则:/api/{模块}/{资源}/{动作},比如POST /api/sale/order/create。
3.3 第三步:用接口通信代替直接调用,给结构加一层保险
结构定好了,还要防止它被破坏。我们在跨模块调用上做了一个硬性规定:业务模块之间不允许直接依赖对方的Mapper或Entity,只能调用对方Service接口暴露的方法。
这样做有三个直接好处。第一,模块之间的耦合被收窄到一个清晰的接口面,只要接口不变,模块内部怎么改都不影响别人。第二,权限和日志可以在接口层统一做,不用每个模块自己重复写一遍。第三,测试时可以很方便地mock掉其他模块的依赖,光这一点就省了大量联调时间。
实现上没什么神秘的,就是每个模块在自己的service包里定义好接口,其他模块在pom里引入依赖后,通过Spring的依赖注入拿到接口实例。为了强制这个约定,我们还在代码扫描规则里加了一条:mapper包只允许所在模块的serviceImpl引用,外部模块如果import了别人的mapper类,CI直接失败。
这一步相当于把架构规则从“口头约定”升级成了“工程化强制”,效果立竿见影。项目开发到中后期时,有不少模块的mapper层从来没被外部调用过,重构起来非常安全。
4. 结构相关常见问题排查实录
4.1 循环依赖:最隐蔽的结构炸弹
Spring循环依赖算是老生常谈,但真正在这个项目里遇到的循环依赖大多不是Spring层面报告的,而是逻辑层面的。A模块调B模块,B模块的某些逻辑又绕回来调A模块,编译器不报错、启动不报错,但一次业务操作会把整个调用链绕一大圈,性能越来越差。
排查方法非常直接:项目里有一个docs目录,每次迭代后跑一次依赖分析脚本,把模块间的调用关系重新生成视图。任何一次新增调用,如果让原本无环的图产生了环,就必须当场解决,不允许带病提交。
提示:解决循环依赖的关键在职责收口。把引起环的那个跨模块调用抽出来,放到发起业务动作的模块里,或者引入第三个模块做协调,环自然就解开了。靠Spring的
@Lazy注解规避问题,只会让结构更差。
4.2 目录树越改越乱的几个典型信号
项目结构不会一夜之间坏掉,它是一点一点腐烂的。我们复盘时总结了几个“结构恶化”的前兆:
- 某个Java文件超过1500行,说明分层已经失效,逻辑堆在了一起
utils工具类越来越大,几千行什么方法都往里塞- 新增一个业务功能时,改动的文件分布在五六个模块里
- Controller出现了大量
Map<String, Object>接收参数 - 打开一个页面要加载七八个接口才能把数据凑齐
只要出现其中两条,就该停下来重构结构了,继续在烂结构上加功能,成本只会指数级增长。
4.3 命名不统一带来的排查灾难
有一次排查销售订单无法审核的问题,找了半天发现原因特别无语:数据库字段叫audit_status,Java实体里用的是auditStatus,但前端传的参数叫approvalStatus。三层三个名字,映射配置转来转去,最后在某个角落漏配了一处。
这种问题在项目初期根本犯不着花大力气治理,但一旦团队超过三个人、功能超过二十个,命名不统一就会变成最大的隐性成本。我们后来在数据库设计文档里建立了一个“字段字典”,每个字段只能有一个标准名,任何层都不允许改名。
4.4 测试代码该放在哪
很多中小型项目完全没有测试代码,但凡有一点测试代码的,存放位置也经常乱。我们用的是Maven标准结构:src/test/java目录与主代码结构一一对应。kanchao-sale模块的测试就放在该模块的src/test/java/com/kanchao/sale/下,保证测试和被测代码在一起。
单测集中测Service层的纯业务逻辑,比如折扣计算、库存扣减校验、状态流转判断。这类逻辑写起来相对独立,不依赖数据库,用Mock就能跑。我们规定核心业务域的Service方法覆盖率不低于70%,不要求100%,剩下的集成测试在联调环境里补。测试这块说得直白一点,不一定能帮你提前发现所有bug,但重构时它是唯一敢改代码的底气。
5. 拿比例子说透结构演进的节奏
讲一个我们真实经历的迭代案例。第一版《看潮》的销售订单功能是放在“基础资料”模块里的,因为当时觉得“订单不就是客户和商品的关系吗,应该属于基础数据”。结果做到第三个月,订单开始要处理价格策略、发货批次、审核流、对账标记,基础资料模块越来越大,一个BaseGoodsService里又查库存又算价格又生凭证,彻底变成垃圾场。
重构的时候我们做了三件事:把订单相关的表拆出去建成sale_order系列表;新建kanchao-sale模块,把Controller、Service、Mapper整体平移;定义好SaleOrderService接口,让其他模块只依赖这个接口。整个过程花了大概两周时间,重构完成后代码行数少了将近20%,因为原来大量绕来绕去的跨模块查询被干净的服务接口取代了。
这个例子说明,结构不是一开始就能设计完美的,但它需要保持演进能力。只要你把模块边界、依赖方向、命名规则这三件事守住,后续调整结构就是局部平移,而不是牵一发动全身地返工。
6. 给刚起步的团队几点实在建议
如果你所在的团队正准备做一个新的企业管理系统,下面几条建议是花钱都买不到的经验。
第一,项目结构文档一定要在最开始写。不写过一遍,你不知道自己有哪些模糊的地方。这份文档不用很厚,三五页就够,把模块划分、目录树、命名规范、依赖规则写清楚,新人来了先读文档再碰代码。
第二,结构规则要进CI,不能只靠人肉遵守。无论用什么框架,都应该有办法检查目录是否存在乱放、跨模块import是否违规、命名是否符合规范。这些检查一开始就配上,成本是最低的,后面再补就会遇到“存量代码各种违规但不敢改”的困局。
第三,定期重构比一次大重构更划算。我们每完成一个迭代就顺手做一轮小的结构整理,比如删掉没用的类、合并重复的工具方法、把新发现的公共逻辑下沉到common模块。这些小动作看似不起眼,却能让项目结构一直保持在可维护的状态。
我在这个系列课程里反复讲一个观点:企业管理软件的开发难度不在算法,也不在高并发,而在复杂的业务关系如何被清晰有序地组织。项目结构就是这种组织能力的物理体现。把目录、分层、依赖这些基本功做到位,后续的开发效率会超出你的预期。编程与数学在学生阶段重视的是数据结构、算法复杂度这些“硬数学”,但进入真实项目后你会慢慢发现,分治、抽象、图论里依赖无环这些思想,才是每天都要用的“软数学”。