尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot项目结构怎么组织?从骨架到模块化,避开常见坑

Spring Boot项目结构怎么组织?从骨架到模块化,避开常见坑 很多同学刚开始接触Spring Boot的时候第一个问题往往不是“怎么写接口”而是“这个项目到底应该怎么放文件”。默认生成的骨架目录就那么几层网上资料各说各话有的按层分包有的按功能分包还有的搞多模块。项目一复杂连个配置类都不知道该塞哪儿更别提团队协作时大家各写各的最后整个工程变成一锅乱炖。我这些年看过太多Spring Boot项目也重构过不少这篇文章想把这套“项目结构”背后真正该想清楚的事讲明白初始骨架里每个文件是干嘛的、业务复杂之后怎么组织、配置和技术集成的标准落点在哪、以及实际开发里最常踩的结构类坑。内容会拿一个常见的校园讲座预约系统当例子也顺便把MinIO、Caffeine、WebSocket这些热词里的常见集成位置一起捋一捋。不管你是刚建第一个Spring Boot程序还是正准备给单体应用拆模块这篇都值得看完。1. Spring Boot骨架目录先认识每个文件和目录的角色1.1 启动类为什么一定要待在根包先从一个新生成的项目说起。用IDEA或者Spring Initializr创建的Spring Boot工程都会自动生成一个带main方法的类类名一般是Application或者项目名Application。最典型的样子是SpringBootApplication public class LectureApplication { public static void main(String[] args) { SpringApplication.run(LectureApplication.class, args); } }代码很简单但这里有个特别容易忽略的细节SpringBootApplication这个注解是个组合注解它把SpringBootConfiguration、EnableAutoConfiguration和ComponentScan打包在了一起。其中ComponentScan默认扫描的范围是启动类当前所在的包以及它下面的所有子包。也就是说如果你的启动类在com.example.lecture下那么com.example.lecture.controller、com.example.lecture.service这些子包都能被扫到。一旦你把启动类放到了其他位置比如com.example.lecture.controller目录里Spring Boot就会在启动时只扫描controller包及其子包导致service、config这些组件全部注册不上项目能跑起来但接口直接404这个问题在刚入门时特别常见。所以约定就是启动类放项目根包根包下面再按自己的方案组织业务代码。这个位置基本是雷打不动的。1.2 经典四层结构controller、service、mapper、entity各自的职责初始骨架只有一个启动类和空的resources目录但大家通常都会遵循一套经典分包方式。这套方式在单体应用中非常稳健分别负责不同的职责com.example.lecture ├── LectureApplication.java ├── controller # 接收HTTP请求处理参数校验返回响应 ├── service # 业务逻辑层事务边界在这层 ├── mapper # 数据访问层对应MyBatis接口或JPA的Repository ├── entity # 数据库表对应的实体或叫domain/model ├── dto # 接口入参和出参的数据传输对象 ├── config # 配置类各种Bean在这里注册 └── common # 工具类、常量、公共响应封装等这几层的职责边界特别重要。我见过很多项目把业务逻辑写在Controller里一个方法几百行SQL拼在实体里最后没人敢改代码。正常的思路是Controller只负责“接请求、传参数、返回结果”所有和业务相关的判断、计算、事务都在Service层处理Mapper层只做数据库读写Entity类只是数据的载体DTO则用来隔离外部参数和内部实体。可以这样理解Controller是餐厅门口的服务员Service是后厨Mapper是食材仓库Entity就是装食材的箱子。服务员只管记菜名和上菜做菜和后厨的事不该在门口干。1.3 resources目录不只是配置文件的家在Spring Boot项目中src/main/resources里放的是运行期的非Java文件。最核心的是application.yml或application.properties它决定了整个应用的端口、数据源、日志级别等。除此之外这个目录还承担了其他几类职责static/放静态资源比如CSS、JS、图片。现在前后端分离流行这块基本空着但在早期的模板项目中很常见。templates/服务端模板页面放这里比如Thymeleaf、Freemarker的模板文件。如果你做的是API服务这个目录用不到。mapper/MyBatis的XML映射文件一般单独建一个mapper目录和Java接口分离这样可以让SQL集中管理也可以避免编译时资源被过滤掉。db/数据库初始化脚本有时也放这里比如schema.sql、data.sql配合spring.sql.init配置来执行。logback-spring.xml日志框架的配置文件后面细说。有个容易犯的错误把mybatis-config.xml这种只适合放特定位置的配置随便丢。其实MyBatis的全局配置文件在Spring Boot里不是必须的用application.yml里的mybatis.mapper-locations、mybatis.type-aliases-package就能替代大部分功能。我在实际项目中更推荐把资源按用途分目录比如mapper只放SQL XMLstatic放需要被前端直接访问的文件不要把无关文件堆在资源根目录里。1.4 最小链路示例一次请求走完整个结构光说概念比较虚我拿一个“查询讲座列表”的接口来做示例。假设数据库里有lecture表实体类是LectureMapper接口负责查询Mapper public interface LectureMapper { ListLecture selectUpcoming(); }Service层对外开放一个方法Service public class LectureService { private final LectureMapper lectureMapper; public LectureService(LectureMapper lectureMapper) { this.lectureMapper lectureMapper; } public ListLectureVO upcomingLectureList() { return lectureMapper.selectUpcoming().stream() .map(lecture - new LectureVO(lecture.getTitle(), lecture.getStartTime())) .toList(); } }Controller接受请求并返回RestController RequestMapping(/api/lecture) public class LectureController { private final LectureService lectureService; public LectureController(LectureService lectureService) { this.lectureService lectureService; } GetMapping(/upcoming) public ResultListLectureVO upcoming() { return Result.success(lectureService.upcomingLectureList()); } }这条链路各层各司其职从controller到service再到mapper数据流方向是单向的不互相绕圈。这样写出来的项目哪怕团队里换人接手看目录结构差不多就能猜出代码在哪个文件里。2. 业务复杂之后用功能模块重新组织代码2.1 什么时候该从“按层分包”切换到“按功能分包”经典的四层结构在小项目里足够好但一旦业务范围变宽比如校园讲座预约系统既有用户管理又有讲座管理还有预约订单全部堆在controller包、service包里会让每个包里的类越来越多。到后期你找某个功能相关的代码需要在controller、service、mapper、entity四个包里分别找一遍改一个小功能同时改四五处心里还没底。这时候就该考虑按功能分包了。按功能分包的意思是把用户相关的Controller、Service、Mapper、Entity放到同一个user包下把讲座相关的代码放到lecture包下预约相关的放到reservation包下。这样每个功能自成一体改动时主要操作一个包内的文件聚合内聚性很强也方便以后把某个功能直接搬出去拆成微服务。那到底什么规模适合按层、什么规模适合按功能我的经验是一个只有十几个接口的管理后台按层就够了如果业务域明显超过三个或者每个域里有独立的实体和复杂的业务规则那就按功能分包。2.2 以校园讲座预约系统为例的模块划分假设现在要做一个校园讲座预约系统涉及的功能大概有用户登录注册、讲座列表与详情、讲座预约、管理员审核管理。按功能分包后的目录大概是这样的com.example.lecture ├── LectureApplication.java ├── common # 通用返回Result、异常处理、工具类 │ ├── Result.java │ └── GlobalExceptionHandler.java ├── config # 全局配置类 │ ├── CacheConfig.java │ └── WebConfig.java ├── user │ ├── controller/UserController.java │ ├── service/UserService.java │ ├── mapper/UserMapper.java │ └── entity/User.java ├── lecture │ ├── controller/LectureController.java │ ├── service/LectureService.java │ ├── mapper/LectureMapper.java │ └── entity/Lecture.java ├── reservation │ ├── controller/ReservationController.java │ ├── service/ReservationService.java │ ├── mapper/ReservationMapper.java │ └── entity/Reservation.java └── admin ├── controller/AdminLectureController.java └── service/AdminLectureService.java这种结构好处很明显比如你要加一个“讲座预约”的新功能直接新建reservation包把相关类都放进去改lecture包的可能性很小。而且user包和lecture包之间如果想交互通过Service方法调用依赖关系看得一清二楚不会出现跨包乱引Implementation的情况。但功能分包也不是没有代价。如果每个包内部都放一套controller/service/mapper/entity很多接口本身只是简单转发代码会稍显重复。应对方式是如果某个子包只有一个Controller和一个Service那就不需要强行拆出四层直接简化为LectureController、LectureService即可。项目结构的本质是服务可维护性不是为了好看而死守标准。2.3 模块内部还要不要再分层按功能分包之后每个功能包内部依然建议保持controller、service、mapper、entity这四个子包。这样你在做同一个功能的代码审查时能快速定位到某一类。以reservation为例reservation ├── controller/ReservationController.java ├── service/ReservationService.java ├── mapper/ReservationMapper.java ├── entity/Reservation.java └── dto/ReservationCreateDTO.java我在实际开发中还会在功能包里加一个dto子包专门放这个模块的入参出参避免把别的模块的DTO对象引进来。DTO和Entity分离这件事很重要尤其对外提供接口时Entity结构一旦变了会直接污染API契约。很多老项目就是Controller直接返回Entity后面数据库加个字段接口响应也跟着变把外部对接方搞得很被动。2.4 依赖方向的约定与单元测试的友好性功能分包后的依赖规则其实很简单上层模块可以依赖下层模块controller依赖serviceservice依赖mapper不要反向依赖。跨模块之间允许Service互相调用但Controller不能直接去调别的模块的Mapper。这个约定可以让每个模块都像一个独立的小产品改动的风险被限制在模块边界内。模块化还有一个隐形好处就是单元测试好写。比如reservation模块的Service测试完全不需要加载lecture模块的实现只要能Mock掉对lectureService的调用就行。你甚至可以约定每个模块内部的service接口对外暴露实现类带Impl后缀这样替换Mock对象非常轻松。我在做项目结构重构时总会顺手检查一下模块依赖关系图发现模块之间互引的时候第一时间评审是不是设计上出了问题。3. 配置、日志和技术集成的标准落点3.1 application.yml、profile与配置分层Spring Boot项目里配置文件的地位很高。一个中大型项目的配置如果全挤在一起不仅难读还会带来安全隐患。我的习惯是把配置文件按环境拆开src/main/resources ├── application.yml ├── application-dev.yml └── application-prod.ymlapplication.yml只保留公共配置比如应用名、当前激活的profile、公共的框架配置。application-dev.yml和application-prod.yml分别放本地开发和生产环境的差异化配置比如数据源地址、缓存地址、日志级别。切换环境的操作是改application.yml里的spring.profiles.active或者启动时用参数指定java -jar lecture.jar --spring.profiles.activeprod这样做的目的不是省事而是防止开发环境配置被误带到生产环境。比如本地数据库密码和生产库密码混在一起一旦提交到代码库等于把生产库的钥匙交了出去。我见过真实的公司因为这种问题导致数据库被脱裤所以配置隔离这个习惯一定要坚持。敏感信息最好通过环境变量或者配置中心注入而不是直接写进application.yml。如果必须写在文件里那就用${DB_PASSWORD}这种占位符的形式让外部运行时提供。Git里也别提交真实的prod配置只提交一个.example模板文件这是行业里很基本的优化。3.2 日志文件放哪logback-spring.xml怎么配日志是排查线上问题最重要的手段但项目结构里日志这块经常被忽略。Spring Boot默认使用Logback日志配置文件名约定是logback-spring.xml放在resources根目录下应用启动时自动识别。一个分环境、按天滚动、限制总大小的日志配置长这样?xml version1.0 encodingUTF-8? configuration springProperty scopecontext nameappName sourcespring.application.name/ appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/${appName}.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/${appName}.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n/pattern /encoder /appender root levelINFO appender-ref refFILE/ /root /configuration日志目录用相对路径logs/在项目的运行目录下生成方便Docker部署时挂载卷收集。如果你用的是分布式部署日志最终要交给监控平台采集那么File Appender的路径要跟采集器的配置对齐这个细节在做项目结构时就该提前想好不要等日志丢了再补救。3.3 MinIO、Caffeine、WebSocket的整合位置热词里出现了spring boot 集成minio、spring boot caffeine、spring boot 集成web socket yml 配置这几个都属于“组件集成”的范畴在项目结构中各有各的标准落点而且落点的合理性会直接影响后续维护成本。先看MinIO对象存储一般是建一个config包下的MinioConfig负责读配置并注入MinioClient再在common或专门的storage包里封装一个StorageService暴露上传、下载、生成临时链接的方法。目录结构大概是config ├── MinioConfig.java common ├── storage │ └── StorageService.javaMinioConfig的职责是把yml里的连接信息变成可以注入的BeanConfiguration public class MinioConfig { Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }然后业务代码里只依赖StorageService不直接碰MinioClient。这样以后就算换OSS也只是改StorageService的实现业务代码一行不用动。再看Caffeine本地缓存配置类同样放在config包里。比如用Caffeine搭配Spring CacheConfiguration EnableCaching public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(lecture, user); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(500)); return cacheManager; } }缓存的容量策略和过期时间按业务场景调这些配置属于基础设施在config包里很清晰。如果是更复杂的多级缓存我还可以在common/cache里封装一层自定义的缓存注解避免每个Service里都写一遍缓存逻辑。WebSocket的集成相对特殊因为除了配置类还需要编写实际的业务Handler。我的习惯是放在websocket包里集中管理websocket ├── WebSocketConfig.java ├── WebSocketHandler.java └── WebSocketSessionManager.javaWebSocketConfig负责创建Handler和注册相应的URL映射Handler里处理消息SessionManager负责维护在线会话。注意WebSocket在application.yml里一般不需要特殊配置真正需要配置的是Heartbeat、超时这些Session参数在Handler和配置类里用代码解决就够了。很多人一提到WebSocket就到处找yml配置其实加载依赖后直接写Java配置类才是更合适的方案。3.4 前端资源与模板文件的取舍现在的项目绝大多数是前后端分离前端代码直接单独一个仓库Spring Boot里只需要把构建好的静态文件放到static目录或交给Nginx托管。如果你的项目还在用模板引擎渲染页面templates目录就是模板的家controller返回视图名时Spring Boot会自动去templates下找对应文件。但我的建议是新项目尽量别用模板引擎写前端页面。模板引擎的最大问题是前端和后端代码耦合在一起改样式、改JS都要重新构建并部署后端调试效率和前后端协作体验都不好。哪怕公司内没有专业前端也优先用静态HTML接口调用至少把前后端边界划清楚以后补前端或者交出去都方便。Spring Boot的项目结构里静态资源和后端代码从目录层面就隔离了这也是框架给你留的“前后端分离”的天然位置。4. 项目结构常见问题与排查技巧4.1 端口能起、接口404多半是启动类或扫描路径的问题在我排查别人项目的时候不夸张地说30%的问题都出在组件没被扫描到。表现是项目能启动但访问接口返回404或者自定义的Filter、配置类没生效。这时候先看启动类的位置和ComponentScan的范围。如果启动类放在com.example根包下那com.example.lecture里的bean都能被扫描如果启动类在com.example.lecture包下而某个功能模块在com.example.common里那就扫不到了。解决方式在启动类上显式指定ComponentScan(basePackages com.example)或统一让启动类待在根包下其他代码全部放在根包的子包里。最稳妥的方案就是第一种约定根包持有启动类所有业务包都在它下面。这个约定一旦被打破比如有人为了图方便在src/main/java下建了个平级包各种奇奇怪怪的问题就都冒出来了。4.2 MyBatis的mapper.xml路径不对导致启动或查询报错使用MyBatis时常见两类问题。第一Mapper注解没写或者没在任何地方标注Spring Boot启动时就报“Field mapper ... was not injected”之类的错误。第二XML文件放错了位置运行期查询时报“Invalid bound statement (not found)”。XML文件我建议统一放在resources/mapper目录下然后在application.yml里做两处配置mybatis: mapper-locations: classpath:/mapper/**/*.xml type-aliases-package: com.example.lecture.**.entity这样mapper目录下的所有XML都能被加载实体类别名也自动生效。我踩过一个坑把XML放在resources下的根目录配置却写的classpath:/mapper/**/*.xml启动一切正常但一查询就报找不到语句。排查了半天才发现是目录对不上。所以文件放到哪个目录配置就要严格对齐两者成对出现时才不会坑人。4.3 循环依赖暴露的结构问题Spring Boot的默认Bean依赖注入在3.x版本之后对循环依赖支持减弱了报错的时候会明确告诉你The dependencies of some of the beans in the application context form a cycle。很多人第一反应是加Lazy治标不治本。循环依赖往往是项目结构混乱的信号。比如UserService调用了ReservationService同时ReservationService又调用了UserService这通常意味着你把本属于一个业务域的代码拆到了两个Service里。解决办法是把公共逻辑抽到第三个Service或者公共类中让依赖变成单向的。在做结构评审时我会用idea的依赖分析工具看一眼Service层的依赖图凡是出现环的都当作重构候选。结构清楚的项目循环依赖非常少。4.4 多环境配置切换总出问题先检查配置文件优先级Spring Boot的配置加载顺序有讲究越靠后的配置优先级越高。如果同时存在application.yml、application-dev.yml和application-{profile}.yml后者的相同key会覆盖前者的值。最常见的问题是在application.yml里写了数据源地址又在application-dev.yml里写了一份启动时发现生效的还是application.yml里的那份。排查思路是确认spring.profiles.active是否把预期的profile激活了。如果没有书写这个值Spring Boot只加载application.yml其他profile文件不会生效。还有一种情况是部署在云环境里外部传了名为SPRING_PROFILES_ACTIVE的环境变量它的优先级高于配置文件里的spring.profiles.active结果本地跑得好好的一部署就变成别的环境排查起来特别迷惑。我建议团队内部约定application.yml只放公共配置和profile激活项具体环境的差异化配置全部分文件管理。一行公共配置也不要从开发环境复制到生产文件保持源头只有一个这样切换环境的行为才是确定的。4.5 结构自查清单最后整理一份我每次接手Spring Boot项目时会过一遍的结构自查清单可以直接拿去对照检查项正确做法出了问题会怎样启动类位置放在根包下不与其他业务包平级Controller/Service扫不到接口404controller/service/mapper职责严格分层不跨层调用业务逻辑散落代码难维护按功能分包后依赖方向模块间只能Service调Service形成循环依赖启动失败mapper.xml位置统一在resources/mapper下配置mapper-locations查询时报语句找不到配置文件分区application.yml只放公共配置profile分文件多环境切换混乱敏感信息泄露日志配置logback-spring.xml自定义目录、滚动策略日志无处可查问题排查困难外部存储/缓存/WebSocketconfig包放配置类业务封装在service层业务代码到处依赖SDK替换困难这个清单不是死标准但它能cover掉我做项目结构评审时绝大多数会踩的问题。最后分享一个我特别喜欢的结构演进技巧项目结构不需要一次设计到完美但要有演进的意识。刚开始一个简单的管理后台按层结构足够等到业务模块变多把按层结构转换成功能分包其实是很快的——谁的产品逻辑变丰富了就先把谁搬出去。我见过很多团队为了“以后可能要微服务化”提前拆了一堆module结果每个module里就一个数据库Entity开发效率反而下降。真正合理的做法是让结构跟着业务复杂度走每次重构都是小步快跑不要为了架构而架构。在实际动手的时候只要记住一句话改一个功能你能把涉及的文件都在同一个包下找齐这个结构就是健康的。
返回列表