ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

核心JAR包设计:Spring Boot自动配置与模块化实践

核心JAR包设计:Spring Boot自动配置与模块化实践 接手这个项目的第三周我终于撑不住把JSCM-CORE.jar的文档推倒重写了。原因很简单之前的文档只写“有什么类”但完全没讲清楚“为什么这么设计”——团队新人每次集成都要来问我一遍而我每次都要从“你先把 Spring Boot 的自动配置原理翻一遍”开始讲起。后来我把这套框架的定位、核心模块、集成步骤和踩过的坑整理成一份内部开发文档同时在团队里做了两次分享反响出乎意料地好。今天我把这份内容整理出来给那些同样在做 Java 服务端基础框架、或者正在被核心 JAR 包文档折磨的同学做参考。这个JSCM-CORE.jar是一个基于 Spring Boot 的中台核心框架包。它把日常业务开发中高频使用的工具类、公共组件、安全认证、统一响应、异常处理、日志埋点、分布式锁等能力全部收敛到了一起其他业务服务只需要引入这一个依赖就能拿到一套开箱即用的基础设施能力。它的核心价值在于让业务开发的人只写 Controller 和 Mapper把那些和业务无关却又不得不写的重复代码全部干掉。1. 项目初衷与设计思路拆解1.1 为什么需要一个核心 Jar 包我见过太多项目从单体开始然后因为业务增长拆成微服务结果每个服务里的ResultT封装、GlobalExceptionHandler、JWT 工具类、MD5 加密工具、Excel 导出工具全部各写各的。同一个公司里A 服务的登录拦截器逻辑和 B 服务的实现细节都不一致互相之间联调还要先对字段命名。这种情况下抽一个核心 jar 包是水到渠成的事情。我们当时定下的三个核心目标统一:所有服务的响应结构、异常处理、日志格式、安全策略必须完全一致不能出现同一个字段在 A 服务叫userName、在 B 服务叫username的情况。减负:新业务服务搭建时引入依赖后就自带配置不用再拷贝一堆config类。我们把 Spring Boot 的自动配置能力用到了极致业务服务只需要在application.yml里写好对应开关核心 jar 包自己完成装配。沉淀:把多项目中反复出现的通用能力向上提取避免重复造轮子。内部孵化出的工具全部放进 jar 包后续项目直接复用。1.2 模块划分与设计原则在设计JSCM-CORE.jar包结构时我参考了业界比较成熟的分层做法把核心包拆成了 5 个模块它们之间依赖关系清晰不会出现循环引用模块职责核心包名基础工具模块日期、字符串、加解密、树结构、脱敏com.jscm.core.util公共组件模块统一响应、全局异常、参数校验、日志埋点com.jscm.core.common安全认证模块JWT 生成与校验、登录鉴权、接口防刷com.jscm.core.security数据扩展模块MyBatis-Plus 扩展、字段自动填充、逻辑删除、多数据源com.jscm.core.data自动配置模块Spring Boot Starter 自动装配、外部配置绑定com.jscm.core.boot设计原则其实就两条。第一条是可裁剪性业务项目用不到安全模块完全可以通过开关关掉不影响其他模块正常工作。第二条是自动配置优先能交给框架做的绝不交给业务方。比如ObjectMapper的序列化规则、RestTemplate的连接池参数、RedisTemplate的序列化方式这些统统由核心包统一配置好业务方想改再通过Bean覆盖。2. 核心模块功能详解2.1 基础工具模块将重复代码收敛起来这个模块看起来最“不起眼”但被引用的次数最多。我们平时写业务时最常碰到的几个操作——对象属性拷贝、集合转树、Excel 导入导出、敏感字段脱敏、AES/RSA 加解密——全都收敛在这里面。举一个实际例子。在做用户列表导出时我们要求用户手机号必须脱敏中间四位用星号代替。以前每个项目都要写一个StringUtil.maskPhone()而且实现方式还不完全一样。在JSCM-CORE.jar里我们提供了一个注解SensitiveField在 DTO 字段上标记策略即可public class UserExcelVO { ExcelProperty(value 姓名) private String name; ExcelProperty(value 手机号) SensitiveField(strategy SensitiveStrategy.PHONE) private String phone; }然后在导出工具类里通过反射扫描带注解的字段统一做脱敏操作。这样处理的好处是所有服务的脱敏规则完全统一不会出现 A 服务脱敏成138****1234B 服务脱敏成138***1234的尴尬情况。树结构处理也是一个高频需求做菜单、部门、分类的时候都要用。我们封装了一个通用方法// 入参是所有菜单节点parentId 为 0 的是根节点 ListMenuNode tree TreeUtil.build(menuList, 0);这个方法内部通过一次遍历 HashMap 缓存建立父子关系时间复杂度是 O(n)。它支持任意层级的嵌套不会因为层级过深导致递归栈溢出。2.2 公共组件模块统一响应与全局异常的优雅实现公共组件是整个 jar 包最核心的部分它决定了业务方对接时的体验。我们的统一响应体设计如下{ code: 200, message: 操作成功, data: { }, traceId: a1b2c3d4e5f6, timestamp: 1623456789123, path: /api/user/list }这个结构中比较容易被忽略的是traceId和path。traceId是一个请求链路追踪号由过滤器在请求入口处生成放到 MDC 里日志框架自动打印排查问题时直接根据 traceId 把一次请求的所有日志捞出来。path则方便前端在接口报错时定位是哪个地址出了问题。全局异常处理器也是所有服务必须统一的。我们捕获了以下几类异常并给出了不同的 HTTP 状态码与业务 code 对照关系异常类型HTTP 状态码业务 code说明BizException业务异常2001001校验失败、参数有误、状态非法AuthException未认证4011002token 缺失、过期、签名错误PermissionDeniedException4031003有认证但权限不足DataNotFoundException4041004数据不存在SystemException5001005未知系统异常打印完整堆栈注意把 HTTP 状态码恒定为 200而用业务 code 区分错误是很多互联网大厂的做法。这样做的原因是部分网关、浏览器对非 200 状态码有特殊处理逻辑统一 200 便于前端统一拦截。2.3 安全认证模块JWT 与权限控制我们选型 JWT 作为登录凭证而不是传统的 Session核心原因是无状态。在微服务架构下Session 要么需要引入 Spring Session 做共享存储要么就得靠网关转发保证粘性会话两种方案的运维成本都不低。JWT 本身携带用户信息每个服务都可以独立完成校验非常适合做服务间的身份透传。JSCM-CORE.jar内置了完整的 JWT 支持// 生成 token登录成功时调用 String token JwtUtil.createToken(userId, username, roleList, Duration.ofHours(2));token 中包含了用户 ID、用户名、角色列表、过期时间等声明。同时我们规定服务端必须配置一个密钥jscm.security.jwt-secret且不能使用默认值避免生产环境被恶意伪造 token。权限控制我们封装了一个注解RequirePermissionGetMapping(/delete) RequirePermission(user:delete) public ResultVoid delete(RequestParam Long id) { userService.delete(id); return Result.success(); }这个注解通过 AOP 实现在方法执行前从 JWT 里解析出当前用户拥有的权限码集合再和注解要求的权限码做比对。权限码设计成模块:操作的格式例如user:add、order:export规则清晰也方便后期做权限点管理。2.4 数据扩展模块字段自动填充与多数据源这个模块解决的是数据操作中的重复劳动。比如createTime、updateTime、createBy、updateBy这 4 个字段几乎每张业务表都有但每次 insert 和 update 的时候都要手动 set。我们通过 MyBatis-Plus 的MetaObjectHandler统一处理Component public class AutoFillMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, createBy, String.class, SecurityUtil.getUserId()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateBy, String.class, SecurityUtil.getUserId()); } }这样业务方在写 Mapper 的insert和update语句时完全不用关心这 4 个字段框架自动帮你填好。多数据源的支持我们封装成了注解DataSource通过 AOP 在方法执行前切换DynamicDataSourceContextHolder中的数据源 keyDataSource(slave) public ListUser getUsersFromSlave() { return userMapper.selectList(...); }这样做的场景很典型一个主库用于写入多个从库用于查询。业务只需要加注解不需要关心连接如何获取、事务如何管理。3. 集成与快速上手指南3.1 一分钟引入依赖使用JSCM-CORE.jar的第一步是在pom.xml中引入依赖dependency groupIdcom.jscm/groupId artifactIdjscm-core-starter/artifactId version2.1.0/version /dependency引入之后Spring Boot 应用启动时就会自动加载JSCMCoreAutoConfiguration完成所有核心 Bean 的注册。这里有一段关键代码是我们踩了很多坑才完善的Configuration ConditionalOnClass(RedisTemplate.class) EnableConfigurationProperties(JscmCoreProperties.class) public class JscmCoreAutoConfiguration { Bean ConditionalOnMissingBean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { // 自定义序列化避免默认 JDK 序列化导致可视化工具乱码 RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializerObject serializer new Jackson2JsonRedisSerializer(Object.class); template.setKeySerializer(RedisSerializer.string()); template.setValueSerializer(serializer); template.afterPropertiesSet(); return template; } }3.2 核心配置项对照表application.yml中的配置项如下所示。大部分配置都有默认值但在生产环境强烈建议显式声明配置项默认值说明jscm.security.enabledtrue是否启用安全模块纯内网服务可关闭jscm.security.jwt-secret无必须配置JWT 签名密钥生产环境务必修改jscm.security.token-expire-hours24token 过期时间单位小时jscm.data.fill-enabledtrue是否启用字段自动填充jscm.core.response-wrapper-enabledtrue是否启用统一响应包装jscm.cors.enabledfalse是否开启跨域支持jscm.idempotent.enabledfalse是否启用接口幂等控制3.3 第一个接口的完整流程引入 jar 包后写一个接口最少只需要两步。第一步写好 ControllerRestController RequestMapping(/api/user) public class UserController { Resource private UserService userService; PostMapping(/add) public ResultBoolean addUser(Valid RequestBody UserAddDTO dto) { return Result.success(userService.addUser(dto)); } }第二步在启动类上配置扫描包路径。这里有一个关键点JSCM-CORE.jar的包名是com.jscm.core.*业务项目的包名一般是com.company.project.*。Spring Boot 默认只扫描启动类所在包及其子包所以业务项目必须显式加ComponentScan(basePackages {com.company.project, com.jscm.core})否则 jar 包中的 Controller、配置类不会被扫描到自动配置也不会生效。为了避免每次都手动写ComponentScan我们在 jar 包的spring.factories中注册了一个自定义的EnvironmentPostProcessor读取业务项目的主启动类所在包然后动态追加扫描路径。这样业务项目中只需要一行SpringBootApplication就完全够用了。4. 常见问题与排查技巧实录4.1 你的 jar 包为什么没有生效这是刚集成同学问得最多的问题。现象是应用正常启动但是访问不到 jar 包提供的接口或者 jar 包里的配置类没生效。排查第一步看启动日志中有没有输出JSCM-CORE 自动配置已加载这行日志。如果没有说明spring.factories中的自动配置类没有被加载。可能原因是打包时把spring.factories文件打丢了或者被其他插件过滤了。检查一下编译后的META-INF目录。排查第二步看项目中是否已经有同类的RedisTemplate、ObjectMapper等 Bean。如果业务项目中手动定义了这些 Bean根据ConditionalOnMissingBean的规则jar 包中的配置会静默失效。此时需要判断业务方是不是有意覆盖如果不是建议删除业务项目中的重复定义。4.2 灵活运用事件机制来解耦在做用户注册这个功能时刚集成 jar 包的同事小张提了个诉求用户注册成功后需要发欢迎短信、送新人优惠券、记录注册日志。如果把这些逻辑都写在注册方法里这个方法会越来越臃肿而且后续每加一个动作都要改注册代码。我们当时给出建议是使用 Spring 的事件机制。// 注册成功后发布事件 ApplicationEventPublisher publisher; publisher.publishEvent(new UserRegisterEvent(userId, username)); // 短信监听器 EventListener public void onRegister(UserRegisterEvent event) { smsService.sendWelcome(event.getPhone()); } // 优惠券监听器 EventListener public void onRegister(UserRegisterEvent event) { couponService.sendNewUserCoupon(event.getUserId()); }核心 jar 包中提供了一个工具类EventPublishHelper业务方直接调用EventPublishHelper.publish(userRegisterEvent)即可不需要注入ApplicationEventPublisher。这个设计让注册入口只关心核心流程后续新增动作时只需要新增一个监听器不用改主流程代码。4.3 性能与安全避坑指南我们总结了在接入安全模块和数据模块时的避坑经验特别整理成一个速查表踩坑点建议使用默认 JWT 密钥生产环境必须通过环境变量注入密钥禁止写死明文传输敏感信息登录接口建议 HTTPS AES 加密 时间戳防重放每个表都写逻辑删除字段核心数据表必须有逻辑删除关联表谨慎使用逻辑删除避免级联查询复杂化查询列表不设上限必须提供分页能力避免全量导出导致 OOMRedis 缓存过期不设置随机值缓存过期时间加入随机量避免同时失效导致缓存雪崩使用select *查询大宽表只查询需要的字段使用 DTO 接收结果避免传输大字段性能方面有一个典型案例。一次压测中我们发现某个接口的响应时间从 50ms 涨到了 300ms排查后发现是 jar 包中的日志切面每次请求都会把完整参数和响应结果序列化后打印到日志中。后来我们调整了日志切面的级别生产环境只打印方法名、耗时和参数长度不打印完整参数只有在debug级别下才输出完整内容。调整后响应时间下降了接近 40%。安全方面最容易被忽略的是 JWT 的泄露风险。JWT 一旦签发在过期之前服务端无法主动使其失效。所以我们设计了一个 Redis 登录态管理机制登录成功后把 token 的jti唯一 ID写入 Redis设置过期时间与 token 一致。每次请求拦截器都会检查 Redis 中是否存在对应的jti如果用户注销或管理员踢人直接删除 Redis 中的jti下次请求就会判定为未认证。这样既保留了 JWT 无状态的优势又能支持服务端的主动失效。特别提醒JWT 的payload部分是 Base64 编码不是加密。千万不要把密码、身份证号、手机号等敏感信息放进 JWT。网上随手能解析出来。4.4 版本升级与兼容性策略框架升级一直是比较头疼的事情。JSCM-CORE.jar从 1.0 迭代到 2.1我们总结了一套版本管理策略。第一个原则是语义化版本。主版本号变化代表不兼容的 API 修改例如把Result.ok()改名成Result.success()这种必须升级大版本并且提供迁移工具。第二个原则是兼容性开关。在大版本升级时我们不建议直接删除旧 API而是保留并加上Deprecated注解。例如统一响应体从data字段直接返回对象改为data字段返回分页对象时我们提供了一个配置项jscm.compatible.page-mode默认值为旧模式新项目可以显式开启新模式。还有一条经验是关于依赖冲突的。JSCM-CORE.jar传递了大量第三方依赖很容易和业务项目里的其他依赖版本冲突。我们的做法是在pom.xml中把核心 jar 包的所有依赖都设置成optionaltrue或者provided让业务项目自行管理版本。这样虽然增加了引入成本但避免了NoSuchMethodError、ClassNotFoundException等问题长期来看是值得的。!-- 核心 jar 包内部这样声明依赖 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version optionaltrue/optional /dependency我个人的体会是做核心框架的人必须始终保持克制。每次想往 jar 包里塞新功能的时候先问问自己这个功能是不是有多个项目真的需要如果只有一个项目用那就应该放在业务项目自己的common模块里而不是塞进核心包。能不能给业务方提供最简单的接入方式如果每次接入都要写很多代码说明框架设计得还不够好。最后分享一个小技巧。我们在JSCM-CORE.jar里加了一个启动时自检功能它会扫描当前项目中的所有 Controller列出所有没有加RequirePermission注解的接口并打印警告日志。这样在开发阶段就能发现哪些接口缺少权限控制避免上线后被人通过未授权接口调用。这个小功能看起来不起眼但在一次安全的例行扫描里帮我提前发现了一个因开发疏忽遗留的敏感接口。框架的价值并不在于你写了多少代码而在于能不能在关键时刻帮业务把风险挡在外面。
返回列表