1. 项目整体设计与技术选型:为什么拆成两个服务
1.1 服务拆分的核心逻辑:api_service 和 admin_service 各管一摊
拿到“Spring Boot 3.2.10 + OpenJDK 17”这个技术栈,要搭企业级项目,第一个动作不是急着写代码,而是先把服务边界划清楚。从标题看,这次要同时产出两套接口服务:xxx_api_service对外提供业务接口,xxx_admin_service做管理平台接口。很多新手习惯把两类接口揉在一个工程里,配一个context-path区分前缀,比如/api/**和/admin/**,前期开发确实省事,但后患无穷。
我倾向于从第一天就分成两个独立Spring Boot应用,分别打jar包、分别部署。原因有三点:
- 面向的调用方完全不同。
api_service面向C端用户、移动端、第三方系统,接口需要鉴权、限流、幂等控制、签名校验;admin_service面向内部运营人员和管理员,走的是后台登录态、操作审计、RBAC权限控制。两类接口的安全模型差别很大,混在一个服务里,Security配置、拦截器、过滤器都得做两套逻辑,代码里全是if (path.startsWith("/admin"))这种味道很冲的写法。 - 扩容和发布策略不一样。C端流量有峰值,
api_service要能水平扩展,多个实例负载均衡;管理平台并发量低,但要求稳定和可审计,两个服务混在一起,扩容时管理接口的实例也被迫一起拉起,白白消耗资源,发布时也容易互相影响。 - 故障隔离。如果某个对外接口被刷、内存被打满,或者有慢SQL拖垮连接池,独立部署能尽量不让管理平台跟着遭殃。管理后台挂了的损失比对外接口挂了一部分严重得多。
至于降级方案,如果你确实想先快速验证业务,也可以先用一个服务加模块区分,但我的建议是:在Maven多模块的层面就划分好子模块边界,哪怕最终部署成一个应用,也要保证代码层是解耦的,后面拆服务只是个配置问题,而不是重构问题。
1.2 版本组合为什么是 Spring Boot 3.2.10 + OpenJDK 17
这个版本组合属于目前企业级项目里比较主流且稳健的搭配。Spring Boot 3.2.x 是继3.1之后的功能迭代版本,支持到2024年前后的维护周期,相比3.0、3.1,稳定性已经过大量生产验证。3.2.10 是3.2.x 分支里比较靠后的维护版本,等于把前面几个补丁版本的bug修复都收进来了,所以站在能拿到的稳定性和安全性角度,选它没问题。
JDK 17 是 LTS(长期支持)版本,会持续收到安全更新和性能优化,是Spring Boot 3.x 的基线版本之一。Spring Boot 3.x 整体是基于 Jakarta EE 9+ 的,包名从javax.*换成了jakarta.*,底层的Spring Framework 6.x 也从设计上就只支持 JDK 17+。如果你还用JDK 8,那只能停留在Spring Boot 2.7.x,拿不到3.x的新特性。
这里有个容易踩的坑:Spring Boot 3.2.x 之后,官方对 JDK 21 也做了支持,但并不是说 JDK 21 就一定比 17 好。如果你的团队对虚拟线程、新的垃圾回收器没有刚需,JDK 17 + Spring Boot 3.2.10 的匹配度更成熟,网上踩坑案例多、解决方案也多。我见过有人一上来就用 JDK 21 + Spring Boot 3.4,结果遇到某个三方库的反射机制不兼容,排查起来非常痛苦。
再看构建工具。Spring Boot 3.x 对 Maven 和 Gradle 都支持,但企业里 Maven 的使用率明显更高,尤其是多模块项目管理,Maven 的dependencyManagement和parent继承机制非常成熟。这次的项目规划我默认按 Maven 多模块来做,这也是大多数中大型 Java 项目实际在用的组织方式。
2. 企业级模块规划落地:从顶层目录到代码分层
2.1 多模块 Maven 项目划分与职责边界
企业级项目的模块划分,最重要的是职责不重叠、依赖方向清晰。一个实用的规划方案如下:
| 模块名 | 职责定位 | 依赖关系 |
|---|---|---|
xxx-parent | 父POM,统一管理依赖版本、插件配置、Java版本 | 无 |
xxx-common | 通用工具类、异常体系、统一返回结果、常量定义 | 仅依赖第三方工具库 |
xxx-api-service | 对外API服务,独立Spring Boot应用 | 依赖 common、dao、外部SDK |
xxx-admin-service | 管理平台API服务,独立Spring Boot应用 | 依赖 common、dao |
xxx-dao | 数据访问层,放 Entity、Mapper、MyBatis 接口等 | 依赖 common |
xxx-message | 消息通知、MQ 生产者消费者(可选延迟拆分) | 依赖 common、dao |
xxx-api-service和xxx-admin-service是最终运行的可执行模块,各自包含自己的Application启动类、Controller、Service、配置类。xxx-common禁止依赖任何业务模块,只能放与业务无关的通用能力,比如Result<T>统一封装、BizException、PageQuery分页对象、日期工具、JSON工具等。xxx-dao的设计要克制一点,不要所有表都往这一个模块里堆,如果等业务扩大,可以按领域拆成xxx-dao-order、xxx-dao-user,前期先合并,避免过度设计。
关于接口定义,我建议在xxx-api-service模块内部单独建一个api包存放对外的 RPC 接口或者 Feign 接口定义。如果你后续要对接 Spring Cloud OpenFeign,对外接口的 interface 和 DTO 需要被其他服务引用,那就把接口定义单独抽到一个xxx-api-contract模块,只放 interface 和请求响应 DTO,不依赖任何实现。不过如果目前没有跨服务调用需求,先在 service 模块内定义即可,等需要时再抽,不要提前建一堆空模块。
2.2 服务内部分层与包结构设计
每个可运行服务内部的包结构,我习惯按“先按技术分层,再按业务分域”来组织。拿xxx_api_service举例:
com.xxx.api ├── Application.java ├── controller // HTTP 层,负责参数接收和响应封装,不写业务逻辑 ├── service // 业务逻辑层,事务、规则、编排都在这里 │ └── impl // Service 实现类 ├── repository // 数据仓储层,调用 mapper 或第三方存储 ├── model │ ├── dto // 对外交互的数据传输对象 │ ├── vo // 视图对象,专门给前端返回 │ └── entity // 数据库实体 ├── mapper // MyBatis Mapper 接口 ├── config // 配置类,如 SecurityConfig、CorsConfig ├── interceptor // 拦截器,登录态、日志、幂等校验 ├── task // 定时任务 └── util // 业务相关的工具类Controller 层只做三件事:接收参数、调用 Service、把 Service 的返回值包成Result<T>返回。Service 层放业务规则,事务注解加在 Service 实现类的公开方法上。Mapper 层只做数据库操作,不做业务判断。DTO 和 VO 要区分开,DTO 是服务与服务、服务与外部系统之间的数据协议,VO 是给前端展示的模型,不要混用。
xxx_admin_service的结构类似,但它多一个管理后台特有的security子包,用来放后台用户登录、权限校验、操作日志相关的代码。如果两个服务要共享一套用户权限体系,可以把用户、角色、菜单相关的表设计和通用逻辑放进xxx-dao和xxx-common,但认证流程的控制权要留在各自服务里,否则后面想独立演进会很麻烦。
包命名别用复数,也别把controller、service这种层名包到很深的层级里,比如com.xxx.api.controller.order就比com.xxx.api.order.controller容易维护。前者先固定技术层,再按业务分子包;后者每个业务域都有一套自己的 controller/service/mapper,结构看起来清晰,但跨业务的公共逻辑很容易复制粘贴,通用代码不好收敛。
3. 核心配置实操:POM、YAML 与环境隔离
3.1 父 POM 版本管理与构建插件配置
多模块项目的第一步,是把根目录的pom.xml搭好。父 POM 的关键职责是锁定所有依赖的版本,子模块不需要也不能自己随便写<version>,全靠dependencyManagement统一管理。我给出一个可复制的模板:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.10</version> <relativePath/> </parent> <groupId>com.xxx</groupId> <artifactId>xxx-parent</artifactId> <version>1.0.0-SNAPSHOT</version> <packaging>pom</packaging> <properties> <java.version>17</java.version> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <mybatis-plus.version>3.5.7</mybatis-plus.version> <hutool.version>5.8.27</hutool.version> <jjwt.version>0.12.5</jjwt.version> </properties> <modules> <module>xxx-common</module> <module>xxx-dao</module> <module>xxx-api-service</module> <module>xxx-admin-service</module> </modules> <dependencyManagement> <dependencies> <dependency> <groupId>com.xxx</groupId> <artifactId>xxx-common</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> </dependencies> </dependencyManagement>注意 MyBatis-Plus 从3.5.x开始,Spring Boot 3 需要使用专门的 starter 坐标mybatis-plus-spring-boot3-starter,直接用老坐标会在启动时因为javax和jakarta命名空间不一致而报ClassNotFoundException。
Maven 编译插件在 Spring Boot 父 POM 里已经默认配置好 Java 17,但为了保证不同开发机上的编译行为一致,建议在父 POM 显式声明maven-compiler-plugin的release为 17,不要只靠<java.version>属性。
spring-boot-maven-plugin只加在两个可执行模块里,xxx-common和xxx-dao不需要。而且要注意在子模块里配置 plugin 时加一个<executions>把repackage关掉,避免公共模块被重新打成可执行 jar 导致引用方加载不到类。
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>3.2 多环境配置与应用参数设计
每个服务模块的src/main/resources下,配置文件按环境分成多份,命名规则为application.yml(公共配置)、application-dev.yml、application-test.yml、application-prod.yml。application.yml里放所有环境一致的配置,环境差异只放变化的部分。
一个典型的api_service配置长这样:
server: port: 8080 servlet: context-path: /api spring: application: name: xxx-api-service profiles: active: dev datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/xxx_api?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: ${DB_USERNAME:root} password: ${DB_PASSWORD:root} data: redis: host: ${REDIS_HOST:localhost} port: 6379 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl management: endpoints: web: exposure: include: health,info,metrics,prometheus endpoint: health: show-details: when_authorized probes: enabled: truexxx_admin_service建议用不同的端口,比如 8081,并单独设置context-path: /admin。生产环境里如果走网关,context-path不一定需要,但本地联调时它能有效避免两个服务同时启动时的路由冲突。
配置信息里的敏感项,比如数据库密码、Redis 密码、短信密钥,绝对不能硬编码在 YAML 里提交到 Git。正确做法是用占位符从环境变量读取,生产环境由部署平台注入;也可以用 Jasypt 做配置项加密,ENC(...)方式虽然多一层依赖,但对安全合规要求高的企业项目几乎是标配。
3.3 Actuator、Micrometer 与监控指标接入
Spring Boot Actuator 是生产环境的必备组件,它能暴露健康检查、指标、线程信息、环境变量等端点。但如果配置不当,它本身也是重大安全隐患。热搜里提到的 “spring boot actuator 漏洞”,本质上就是运维把 endpoints 全部暴露到了公网,导致攻击者通过/actuator/env拿到环境变量、通过/actuator/heapdump下载内存快照,从中扒出数据库密码和密钥。
所以 Actuator 的核心配置原则就一句话:生产环境只暴露真正需要的端点,其他一律关掉。
management: endpoints: web: exposure: include: health,info,metrics,prometheus exclude: env,beans,configprops,mappings,threaddump,heapdump endpoint: health: show-details: when_authorized probes: enabled: trueshow-details: when_authorized表示健康检查详情只有在登录后才展示,否则只返回 UP/DOWN,这样可以防止攻击者通过/actuator/health的详细信息探测服务内部状态。probes.enabled: true会激活 k8s 探针必需的/actuator/health/liveness和/actuator/health/readiness端点,为容器化部署做准备。
Micrometer 是 Spring Boot 3.x 默认的指标门面,接 Prometheus 只需加一个依赖:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>启动后访问/actuator/prometheus,就能看到标准的 Prometheus 指标格式。配合监控大盘,JVM 内存、GC 次数、HTTP 请求耗时、线程池状态全都有数据了。这一步建议项目初始化时就接上,等线上出问题才想起来加监控,往往已经晚了。
4. 安全体系搭建:认证、鉴权与端点防护
4.1 api_service 与 admin_service 的认证策略差异
两个服务的安全设计不能照搬同一套代码。api_service面向外部调用方,通常采用 Token 或者签名机制。如果是给自家 App 提供接口,常见的方案是登录后下发 JWT,后续请求在 Header 里带Authorization: Bearer <token>;如果是给第三方开放平台提供接口,还需要 AppId/AppSecret 签名校验、时间戳防重放、nonce 防重复请求。
admin_service是内部管理平台接口,虽然也用 JWT,但它更强调操作审计和权限控制。每个管理端接口都应该有对应的权限标识,比如system:user:add,用户在角色-菜单模型下被分配权限点,接口校验通过才放行。用 Spring Security 6 做这件事比较标准:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/login", "/captcha", "/actuator/health").permitAll() .requestMatchers("/**").hasAuthority("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }csrf.disable()是因为无状态 JWT 方案本身不依赖 Cookie,CSRF 攻击的载体被移除了,但如果你保留了 Session 登录方式,就不能随便关 CSRF。这个取舍要看你最终采用什么会话模型。
JWT 的密钥要足够长,至少 256 bit,不要用"123456"这种短密钥。建议通过环境变量注入,生产环境用专门的密钥管理服务保存,定期轮换。解析 JWT 时,算法要固定验签,不要用algorithm: none或者从 Token 头里动态取算法,这是 JWT 签名绕过漏洞的常见成因。
4.2 Actuator 端点安全与整体防护策略
如果你的服务直接暴露在公网,Actuator 端点的位置要放在网关或 Nginx 后面做一层访问控制。Spring Security 配置里显式放行/actuator/health,但其他 Actuator 端点一律需要认证。如果服务不要求外网访问,只在内网开放,也仍然要做认证,内网也不是法外之地。
整体防护上,我建议从这几个维度同时下手:
- 控制暴露范围:只保留
health,info,metrics,prometheus,其他端点全部关闭。尤其heapdump和env,在生产环境能不开就不开。 - 网络层隔离:Actuator 端口和管理端口分开,或者通过 Nginx
location规则只允许内网 IP 访问/actuator/**路径。 - 参数校验:Controller 接收的参数统一使用
@Validated注解,避免脏数据进入业务逻辑;DTO 里用@NotBlank、@Size、@Pattern约束字段,出参不要直接返回数据库实体。 - 敏感数据脱敏:日志里禁止打印完整的手机号、身份证、银行卡号,配置好 Logback 的
Converter做脱敏;接口返回时也做脱敏处理。
4.3 CORS 跨域配置与常见误区
跨域问题在前后端分离项目里非常高频。管理平台前端一般部署在独立的域名或端口,比如http://localhost:9528访问http://localhost:8081的接口,浏览器会先发 OPTIONS 预检请求,如果服务端没正确处理,前端就报 CORS 错误。
Spring Boot 里最直接的配置是实现WebMvcConfigurer:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:*", "https://admin.xxx.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }几个容易忽略的细节:
allowedOriginPatterns比allowedOrigins灵活,支持*通配符,而且配合allowCredentials(true)时不会因为携带凭证而失效。maxAge设大一点,比如 3600 秒,可以减少预检请求的次数,显著提升前端接口响应速度。- 如果用了 Spring Security,
SecurityConfig和CorsConfig要配合。Security 里如果开启了cors(),它的优先级更高,两边的配置要一致,否则可能出现 Security 层放行了但业务层还被拦的情况。
5. 常见问题排查与部署避坑
5.1 初始化阶段的高频异常速查表
Spring Boot 3 + JDK 17 项目在初始化和联调阶段,我收集了几个发生概率极高的异常,整理成速查表,遇到了可以直接对照:
| 现象 | 根因 | 解决方案 |
|---|---|---|
启动报java.lang.ClassNotFoundException: javax.servlet.Filter | 引用了基于javax.*的老三方库 | 换用适配 Jakarta EE 9+ 的版本,或搜索对应-jakarta后缀 artifact |
| 访问接口返回 404,Swagger 有页面但调不通 | context-path配了/api,但 Controller 的@RequestMapping也写了/api,导致路径变成/api/api/xxx | 统一约定:要么用context-path,要么全写在 Controller 上,不要两头都加 |
| 请求报 400 Bad Request | 请求体 JSON 与 DTO 字段类型不匹配,或缺少必填字段且没传 | 检查@RequestBodyDTO 字段名和类型,加上@Validated让错误信息更明确 |
MyBatis 报Invalid bound statement (not found) | Mapper 接口和 XML 文件没有对应上,或mapper-locations路径不对 | 确认 XML 放在resources/mapper/**下,且 namespace 和接口全限定名一致 |
部署报No active profile set, falling back to 1 default profile | 启动时没有指定--spring.profiles.active=prod | 启动命令加-Dspring.profiles.active=prod,或设置环境变量SPRING_PROFILES_ACTIVE |
服务无响应,日志出现大量connection timed out | 数据库连接池满,或 Redis 连接未释放 | 检查连接池最大连接数配置,排查是否有慢 SQL 或 Redis key 未设置过期时间导致阻塞 |
JWT 解析报SignatureException: JWT signature does not match | Token 里的签名与本地密钥计算的签名不一致 | 确认签发和校验使用的是同一个密钥,密钥长度是否满足算法要求 |
| 自定义注解用 AOP 切不到 | 在同一个类内部调用方法,走的是 this 调用,没经过代理 | 把注解方法拆到另一个 Service,或注入自身的代理对象,或改用@Resource注入 |
5.2 容器化部署镜像选型与内存参数调整
企业项目最终基本都会容器化部署。JDK 17 项目做镜像时,基础镜像的选择直接影响最终镜像大小和运行时内存表现。最省事的是直接用eclipse-temurin:17-jre-alpine,这个是 Eclipse 基金会官方维护的 JDK 发行版镜像,体积小,与 Spring Boot 3 兼容性经过大量验证。很多人在网上搜到openjdk:17-jdk-slim,这个镜像已经进入维护模式,不建议继续使用。
Dockerfile 参考:
FROM eclipse-temurin:17-jre-alpine WORKDIR /app ENV TZ=Asia/Shanghai COPY target/xxx-api-service-1.0.0-SNAPSHOT.jar /app/app.jar EXPOSE 8080 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar", "--spring.profiles.active=prod"]-XX:MaxRAMPercentage=75.0这个参数很关键。容器里如果你直接写-Xmx512m,会跟实际容器的内存配额对不上,Java 17 官方给出的容器内存感知机制是通过比例设置堆最大值,MaxRAMPercentage表示 JVM 最多使用容器内存的百分之多少,这样无论你给容器分配 2G 还是 4G,JVM 都会自动调整,不用跟着改配置。
JDK 17 里 G1 收集器是默认的,配合容器环境下自动启用的 JFR、JIT 分层编译,性能表现是够的。如果服务内存压力极大,可以再观察 GC 日志,调优方向一般集中在新生代比例和MaxGCPauseMillis,不要一开始就上 ZGC,它对大多数 CRUD 类服务没有明显收益,反而增加复杂度。
5.3 几个我踩过的坑和对应的规避习惯
搭这个架子的时候,我一个一个踩过不少坑,挑几个说出来供参考。
第一个坑是 Service 实现类里忘了加@Transactional,导致更新用户信息时先改了主表,再改从表失败,但主表数据已经提交了。后来我给自己定了一条规矩:所有涉及写操作的方法,不管逻辑多简单,都先评估是否要加事务,不要相信单条 SQL 不会出问题。
第二个坑是application.yml里的数据库连接参数少配了serverTimezone,本地用默认时区没问题,但测试服务器在 UTC 时区,时间全差了 8 小时。排查过程非常折腾,最后才发现是时区问题。现在我的习惯是把serverTimezone=Asia/Shanghai直接写死在连接串里,省得环境一换就出问题。
第三个坑是 JWT 过滤器注册顺序。Spring Security 6 里如果你没有把自定义过滤器加在UsernamePasswordAuthenticationFilter之前,登录状态解析总是晚于安全规则匹配,导致明明接口放行了,又因为认证信息还没设置而被拦截。现在我的习惯是自定义认证过滤器必须在这种位置:addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class),否则某些/permitAll的接口没问题,但需要用户信息的接口就会莫名拿到 null。
第四个坑是 Actuator 的heapdump端点。前期联调我图省事把exposure.include配成了*,部署到测试环境后同事发现可以通过/actuator/heapdump下载到几百兆的内存文件,里面甚至能搜到数据库密码。从那次之后,我对 Actuator 的配置策略就变成白名单模式,只开必须要的端点,其余一律 deny。
6. 最后再分享一点项目启动的建议
项目架子搭好之后,建议按下面的顺序做启动验证,能省不少联调时间:
- 先跑
mvn clean install -DskipTests确认所有模块能编译、打包通过。 - 只启动
xxx-api-service,验证数据库连接、Redis 连接、Actuator/actuator/health返回 UP。 - 再启动
xxx-admin-service,确认两个服务端口不冲突,网关或 Nginx 路由能正确转发。 - 写一个最简单的登录接口,把 JWT 从生成、校验到拦截器放行的整条链路走通。
- 接入 Prometheus 抓取指标,确认
/actuator/prometheus有数据输出。
这套流程走完,项目的底座就算稳了。后面加业务功能,都是在已经验证过的框架上填代码,不容易出现那种“功能写完了但不知道是框架问题还是业务问题”的困境。
架构规划这件事,最有价值的不是目录结构本身,而是每一步选择背后的理由。为什么要拆服务、为什么要统一管理依赖、为什么要控制端点暴露范围,这些决策会在项目生命周期的某个时刻变成你需要翻的账本。把账算清楚了,架子就不会倒。