ARTICLE DETAIL

资讯详情

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

SpringBoot3外部化配置与AOP实战:构建智慧社区报修平台

SpringBoot3外部化配置与AOP实战:构建智慧社区报修平台 1. 项目概述与整体设计1.1 为什么我选择 SpringBoot3 做报修平台做智慧社区报修平台这个项目其实不是心血来潮。我在小区物业群里看多了业主吐槽报修两周没人管报修了也不知道修到哪一步传统报修流程全靠微信群接龙和物业内部纸质工单消息一多就石沉大海。做这个平台的核心诉求就三个业主能方便提交报修、物业能高效派单、维修工能按优先级处理同时整个过程要留痕、可追溯。选技术栈时我几乎没有犹豫就锁定了 SpringBoot3。原因有三点一是 SpringBoot3 基于 Spring Framework 6性能底子比 2.x 扎实正好社区报修平台这种面向 C 端的小型系统启动速度和内存占用都很关键二是 SpringBoot3 强制要求 Jakarta EE 9 标准javax 包全面迁移到 jakarta这是未来长期维护的方向我不想再做一个新项目还背老包袱三是 SpringBoot3 在配置管理、原生镜像支持这些方面做了大量优化尤其是外部化配置能力非常适合需要灵活部署的物业侧系统。这个项目里外部化配置和 AOP 两个点是我刻意放大的技术线。外部化配置解决的是不改代码就能改行为的问题比如不同小区、不同物业的账单规则、派单策略、催单时间阈值这些业务规则如果写死在代码里每换一个小区就得重新发版开发效率会被拖垮。AOP 解决的是横切逻辑的问题像操作审计、异常通知、耗时监控这些逻辑散布在每个业务方法里如果到处手写代码会极其臃肿用 AOP 统一收敛才是正解。1.2 报修平台的核心业务链路在动手写代码之前我先把业务模型理清。这个平台涉及四类角色角色核心操作关注点业主提交报修、查看进度、确认完工流程透明、无需打电话催促物业管理员审核报修、派单、催办工单分配合理、处理及时维修工接单、上门、提交处理结果任务清晰、避免扯皮系统管理员配置平台参数、查看报表可配置、可审计核心流程是一条状态链路草稿 - 待审核 - 待派单 - 已接单 - 维修中 - 待验收 - 已完成/已关闭。每一步状态流转都会触发业务动作比如状态变为已接单时要给业主发通知状态变为维修中时要记录开始时间用于计算超时。我设计表结构时最重要的三张核心表是repair_order报修单主表、repair_order_log状态流转日志、repair_config报修规则配置表。报修单主表存当前状态、业主 ID、维修工 ID、优先级等关键字段状态日志表记录每一次流转的操作用户、操作时间、变更前后状态配置表则将军团催单阈值、自动派单策略、超时时间等做成可热更新的配置项。这三张表一画出来整个系统的主干就清楚了。接下来的技术选型都是围绕这条业务链路展开的。2. 外部化配置原理与实操2.1 SpringBoot3 配置加载优先级必须刻在脑子里外部化配置这个词听起来有点官方本质上就是把配置从代码里拿出来放到环境里。SpringBoot3 的配置源非常多包括命令行参数、环境变量、application.yml、application-{profile}.yml、随机值、JNDI、Servlet 参数等它们的优先级从高到低遵循一套严格规则。我直接说结论命令行参数 Java 系统属性 操作系统环境变量 application-{profile}.yml application.yml 内置默认配置。这条优先级链是排查配置不生效问题的第一把钥匙。比如你在 application.yml 里配了server.port8081命令行启动时又带了--server.port9090最终生效的一定是 9090。为什么需要这样的设计核心原因在于环境差异。同一套代码在开发环境连接本地数据库在测试环境连接测试库在生产环境连接正式库如果这些信息写死在代码里每次部署都要改代码重新打包这违背了一次构建多处运行的基本原则。把环境相关的内容从代码中剥离出来配置就能跟随环境走代码则与运行环境解耦。SpringBoot3 里配置绑定的核心机制是Environment抽象和Binder。Environment负责统一管理所有配置源Binder负责把配置值绑定到 Java 对象上。我建议项目中尽量使用ConfigurationProperties而不是散落各处的Value因为前者能把一批相关配置绑定到一个强类型对象上自带类型校验和默认值处理后者遇到配置缺失时启动阶段就报错排查起来费劲。Component ConfigurationProperties(prefix repair.order) public class RepairOrderProperties { /** * 自动派单超时时间(分钟) */ private Integer autoDispatchTimeout 30; /** * 催单时间间隔(分钟) */ private Integer remindInterval 120; /** * 是否开启超时自动升级 */ private Boolean timeoutAutoEscalate false; public Integer getAutoDispatchTimeout() { return autoDispatchTimeout; } public void setAutoDispatchTimeout(Integer autoDispatchTimeout) { this.autoDispatchTimeout autoDispatchTimeout; } // 其它getter/setter省略 }这里有个 SpringBoot 的宽松绑定特性值得多说两句。prefix repair.order对应配置文件里的repair.order.auto-dispatch-timeout中划线命名和驼峰命名会自动对应不需要做任何转换。这是 Spring 干活儿时特意留的容错空间我自己偶尔也会用REPAIR_ORDER_AUTODISPATCHTIMEOUT这种全大写下划线形式尤其是在环境变量里。不过我提醒你配置键别来回乱变风格团队一旦定了一个规则就统一遵守否则排查问题的时候很容易找不着北。2.2 使用 profile 做多环境隔离的落地姿势配置隔离是外部化配置最典型的应用场景。我见过不少项目在一个application.yml文件里塞满所有环境的配置用注释区分开发测试生产这种做法的隐患在于改配置时容易误动别的环境发布时还得小心翼翼挑着改手动操作一多就容易出事故。SpringBoot 的 profile 机制就是专门解决这个问题的。application-dev.yml放开发环境配置application-prod.yml放生产环境配置公共配置留在application.yml。激活方式有三种启动命令加--spring.profiles.activeprod环境变量设SPRING_PROFILES_ACTIVEprod或者在部署平台的启动脚本里固定写好。Shiro 不在这里我们继续说 Spring。我的报修平台里三套环境的差异集中在数据源、Redis、日志级别、基础 URL 这几个维度。开发环境日志级别是 DEBUG生产环境是 WARN开发环境的数据库是本地的生产环境走的是云上的内网地址。这些差异通过 profile 文件天然隔离发布时只需要把--spring.profiles.activeprod传进去其他什么都不用改。需要注意 profile 专属文件有一个覆盖关系application-prod.yml中的配置会覆盖application.yml中的同名配置但application.yml中独有的配置不会丢失。这个机制很适合默认值放公共配置、覆盖项放环境配置的玩法。2.3 报修平台的实际配置方案从配置中心到动态刷新做了几个项目之后我有个感受外部化配置的尽头往往是配置中心。社区报修平台如果只部署一套、服务单一用 yml 文件完全够用但如果需要管理几十个小区实例或者运营人员要经常调整催单策略、派单规则配置文件方式就不够灵活了。报修平台里我真正需要动态调整的配置有两类一类是业务规则参数比如业主催单触发阈值超过多少分钟算超时并触发提醒、自动派单窗口期维修工多少分钟内不接单就自动改派、维修工时上限。这些参数如果写死在 yml 里每次调整都要重启服务报修高峰期碰到这个小区维修工不够要把超时时间从 30 分钟改成 60 分钟的需求重启可不是好选择。另一类是开关配置比如是否开启短信通知是否允许业主取消报修是否启用维修评价。这类配置我采用了数据库配置表 本地缓存 定时刷新的模式。具体做法是建一张repair_config表配置项以 key-value 形式存储项目启动时把配置加载到本地缓存再用Scheduled定时任务每 30 秒刷新一次缓存。这样运营人员在后台管理界面修改配置后最多半分钟内就能生效无需重启。Component public class RepairConfigHolder { private final RepairConfigMapper configMapper; private final MapString, String configCache new ConcurrentHashMap(); public RepairConfigHolder(RepairConfigMapper configMapper) { this.configMapper configMapper; } /** * 应用启动时加载配置到本地缓存 */ PostConstruct public void init() { refresh(); } /** * 定时刷新配置 */ Scheduled(fixedDelay 30_000) public void refresh() { ListRepairConfig configs configMapper.selectAll(); MapString, String newCache new ConcurrentHashMap(); for (RepairConfig config : configs) { newCache.put(config.getConfigKey(), config.getConfigValue()); } configCache.clear(); configCache.putAll(newCache); } public String get(String key, String defaultValue) { return configCache.getOrDefault(key, defaultValue); } public Integer getInt(String key, Integer defaultValue) { String value configCache.get(key); if (value null) { return defaultValue; } return Integer.parseInt(value); } }整体方案就成了外部化配置管不变的环境差异配置中心/配置表管多变的业务规则。前者保证部署灵活后者保证运行时可调两者配合才能覆盖真实的运维需求。3. AOP 核心原理它到底在解决什么问题3.1 从代理模式说起Spring AOP 的底层灵魂很多人学 AOP 容易卡在概念上搞不清切面切点通知在说什么。我换个说法AOP 本质上是代理模式在生产代码中的应用就是要解决怎么在不修改原始代码的情况下给方法加功能这个问题。举一个报修平台里的实际例子。提交报修单的方法RepairOrderService.submit()里除了核心逻辑存一条报修记录还需要辅助逻辑写操作日志检查用户是否被限制报修发送通知。如果这些逻辑都直接写进submit()方法这个方法会越来越臃肿而且写操作日志这种逻辑在cancel()、assign()、complete()方法里也要用代码重复率高到你不想维护。AOP 的思路是把写操作日志这种横切逻辑提取成一个独立模块切面然后在运行时使用代理对象替代原始对象。调用方拿到的是代理对象调用submit()方法时代理对象先执行写操作日志再调用原始对象的submit()方法。Spring AOP 底层有两种代理方式。SpringBoot2.x 时代开始Spring 默认使用CGLIB 动态代理生成原始类的子类作为代理对象。这意味着一个 Hard 的事实使用 Spring AOP 代理的类不能是 final 类被增强的方法不能是 final 方法。如果类被标记为 finalCGLIB 无法生成子类代理就会失败如果方法是 private 的CGLIB 也无法覆盖它。这一点在越界很容易踩到后面避坑部分我会重点讲。3.2 Advice、Pointcut 和 Aspect 之间的关系AOP 三个核心概念我用报修流程来对应解释Aspect切面一个模块化的横切关注点集合。比如维修工接单操作审计切面就是一个切面类。Pointcut切点定义在哪些方法上生效。比如凡是RepairOrderService中以assign开头的 public 方法就是一个切点表达式。Advice通知定义在方法执行的什么时机做什么事。比如方法执行成功后记录审计日志就是一个 AfterReturning 通知。Spring AOP 提供了五种通知类型通知类型执行时机典型应用场景Before方法调用前参数校验、权限检查AfterReturning方法正常返回后记录操作结果、发送通知AfterThrowing方法抛出异常后异常告警、日志记录After方法结束后无论成功失败释放资源、清理状态Around方法调用前后完全控制耗时监控、幂等校验、事务管理掌握这个概念后写代码实际就是在选择切点表达式和编写通知逻辑之间填空。切点表达式是指定哪些方法会被拦截的规则比较常用的有几种写法Pointcut(execution(* com.community.repair.service.RepairOrderService.*(..))) public void orderServiceMethods() {}execution: 方法级别匹配最常用比如上方写法表示匹配 RepairOrderService 所有方法。within: 按类/包级别匹配比如within(com.community.repair.service..*)匹配 service 包下所有类的方法。annotation: 按注解匹配比如想要拦截所有标注了AuditLog的方法就写annotation(com.community.repair.aspect.AuditLog)。args: 按参数类型匹配比如args(RepairOrder)匹配入参包含RepairOrder的方法。我自己的经验是能用注解切点的场景优先用注解切点。因为execution表达式挂在包名/类名上一旦类重构、包路径调整表达式就失效了而且代码里看不出这个类的方法被什么切面拦截。而用自定义注解标注在方法上代码阅读者一眼就能看到这个方法是受 AOP 管理的维护成本更低。4. AOP 在报修平台中的三个实际落点4.1 做法一用自定义注解实现操作审计日志报修平台最关键的非功能性需求之一就是所有关键操作必须留痕。业主要能查到我的报修单为什么状态变了物业管理员要能追踪谁在什么时间改了什么。如果每个业务方法里手动写日志代码量爆炸还容易漏写。我定义了一个AuditLog注解标注在需要审计的业务方法上Target({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface AuditLog { /** * 操作类型描述如提交报修派单验收报修 */ String action() default ; /** * 操作对象类型如repair_order */ String targetType() default ; }然后在业务方法上直接标注AuditLog(action 提交报修, targetType repair_order) public Long submitRepairOrder(RepairOrderCreateRequest request) { // 核心业务逻辑 } AuditLog(action 确认完工, targetType repair_order) public void confirmRepair(Long orderId) { // 核心业务逻辑 }对应的切面类就负责统一处理审计日志的收集和入库Aspect Component public class AuditLogAspect { private final RepairOrderLogMapper logMapper; public AuditLogAspect(RepairOrderLogMapper logMapper) { this.logMapper logMapper; } Around(annotation(auditLog)) public Object doAudit(ProceedingJoinPoint pjp, AuditLog auditLog) throws Throwable { String methodName pjp.getSignature().getName(); Object[] args pjp.getArgs(); Long operatorId SecurityUtils.getCurrentUserId(); String operatorName SecurityUtils.getCurrentUserName(); long startTime System.currentTimeMillis(); try { Object result pjp.proceed(); long costTime System.currentTimeMillis() - startTime; // 记录成功日志 saveAuditLog(auditLog.action(), auditLog.targetType(), operatorId, operatorName, JSON.toJSONString(args), SUCCESS, costTime, null); return result; } catch (Throwable e) { long costTime System.currentTimeMillis() - startTime; // 记录失败日志同时包装异常 saveAuditLog(auditLog.action(), auditLog.targetType(), operatorId, operatorName, JSON.toJSONString(args), FAILED, costTime, e.getMessage()); throw e; } } private void saveAuditLog(String action, String targetType, Long operatorId, String operatorName, String requestData, String resultStatus, long costTime, String errorMsg) { RepairOrderLog log new RepairOrderLog(); log.setAction(action); log.setTargetType(targetType); log.setOperatorId(operatorId); log.setOperatorName(operatorName); log.setRequestData(requestData); log.setResultStatus(resultStatus); log.setCostTimeMs(costTime); log.setErrorMsg(errorMsg); log.setCreateTime(new Date()); logMapper.insert(log); } }这里有一个关键细节annotation(auditLog)这种切点写法会把AuditLog注解对象作为参数传入通知方法这样在切面里可以直接拿到注解上的action和targetType不需要在方法体里重复写死。这个方法比用反射在 proceed 前从方法上读取注解要简洁得多也是 Spring AOP 提供的便利特性。自己动手做的时候最容易漏掉的是操作人和操作时间。操作人不能从方法参数里拿而是从安全上下文中拿示例里是SecurityUtils.getCurrentUserId()这也是 AOP 的价值——横切逻辑统一从全局上下文取数据不需要每个业务方法手动传递。我之前看到过有的团队把操作人放在 request 参数的每个 DTO 里传来传去代码冗余程度相当高。4.2 做法二AOP 统一处理维修工接单超时监控报修平台有一个业务痛点工单派给维修工后如果维修工一直不接单业主等待时间就会拉长。产品要求实现派单后 30 分钟未接单自动提醒60 分钟未接单自动改派给其他维修工。这个逻辑如果用Scheduled定时任务全表扫描实现很直接每分钟查一次repair_order表找出status 待接单且update_time now() - 30分钟的记录批量处理。但当单量大了以后全表扫描的代价会越来越高而且从什么时候开始计算超时的数据不好维护因为超时判断需要记录派单时间如果派单时间更新在状态字段里每次状态变化都要去更新最后操作时间容易出错。我用的方案是 AOP 内存延迟队列的组合。在派单方法上做一个Around通知方法执行成功拿到工单 ID 后把工单 ID 最后接单时间放进程内延迟队列Aspect Component public class DispatchTimeoutAspect { private final DispatchTimeoutHandler timeoutHandler; public DispatchTimeoutAspect(DispatchTimeoutHandler timeoutHandler) { this.timeoutHandler timeoutHandler; } Around(execution(* com.community.repair.service.DispatchService.dispatch(..))) public Object scheduleTimeoutCheck(ProceedingJoinPoint pjp) throws Throwable { Object result pjp.proceed(); if (result ! null) { Long orderId (Long) result; // 派单成功后延迟 30 分钟检查接单状态 timeoutHandler.scheduleCheck(orderId, 30, TimeUnit.MINUTES); } return result; } }DispatchTimeoutHandler内部用一个ScheduledExecutorService实现了延迟任务30 分钟后执行检查订单是否已被接单如果仍然处于待接单状态触发提醒或者自动改派逻辑。这样好处在于超时任务只在派单成功后创建不会像定时全表扫描那样带来无效查询而且任务的触发时机和派单动作精确绑定调度逻辑清晰。这里我需要说明一下为什么派单超时这种看起来可以用定时任务解决的场景也适合 AOP。因为把调度超时检查这个动作从派单业务方法里抽出来派单服务只关心派单超时监控由切面在幕后无感执行业务代码不会出现又处理业务又写调度代码的混乱状态。不过当前这个方案适合单实例部署如果系统是多实例部署进程内延迟队列会出现任务只在一个实例上执行、其它实例不感知的问题需要改为 Redis 延迟队列等分布式方案这个大家要根据部署规模灵活判断。4.3 做法三记录报修单状态流转的关键链路还有一个非常典型的 AOP 场景就是用AfterReturning统一记录状态变更日志。报修单状态从待审核变为待派单从已接单变为维修中这些状态变化必然发生在某个业务方法里。我最初的做法是在每个方法里手动插入一条repair_order_log记录后来发现代码重复严重而且有的方法改了状态忘了插日志审计链就断了。用 AOP 后我在RepairOrderService内部定义一个专门用于状态变更的方法public void changeOrderStatus(Long orderId, RepairOrderStatus targetStatus, String remark) { RepairOrder order repairOrderMapper.selectById(orderId); RepairOrderStatus fromStatus order.getStatus(); // 状态校验是否允许从 fromStatus 流转到 targetStatus if (!statusFlowValidator.canTransit(fromStatus, targetStatus)) { throw new RepairOrderException(ErrorCode.INVALID_STATUS_TRANSITION); } order.setStatus(targetStatus); repairOrderMapper.updateById(order); // 记录流转日志交给 AOP 处理 statusChangeHolder.setContext(orderId, fromStatus, targetStatus, remark); }用Around切面拦截changeOrderStatus方法成功后从StatusChangeContextThreadLocal 中的上下文取出本次状态变化信息统一写入repair_order_log表。这样业务方法里没有任何日志代码日志完整性由切面保证。用 ThreadLocal 传递上下文信息到切面里这是我在实际项目里经常用的模式。它解决了切面拿不到业务方法内部局部数据的问题但需要注意ThreadLocal 必须在使用后清理否则线程池复用线程时会有脏数据。我用的清理方式是在切面的 finally 块里调用contextHolder.clear()。这一点在异步场景下尤其关键——如果业务方法内有子线程继续使用 ThreadLocal切面清理时机就要把握好否则会出现日志记录成功但上下文已清空的问题。5. 避坑指南与排查实录5.1 配置不生效的三大经典原因配置文件根本没被加载。SpringBoot3 默认加载 classpath 下的application.yml或application.properties。如果你把配置文件放在了别的位置或者打成了 jar 包后在外部修改了配置但没放在指定加载路径下应用启动时不会自动读取。我建议用--spring.config.location参数显式指定外部配置文件的位置而不是期望 Spring 自动发现。Profile 没有激活。这是我在测试环境踩过最多次的坑写了application-prod.yml但启动命令没带--spring.profiles.activeprodSpring 默认只加载application.yml结果 prod 配置全部没生效数据库连接串还是开发环境的。用启动脚本固定激活参数是个好习惯java -jar repair-platform.jar \ --spring.profiles.active${SPRING_PROFILE} \ --spring.config.locationfile:/opt/repair/config/配置键名拼写不一致。ConfigurationProperties(prefix repair.order)要求配置文件里写repair.order.auto-dispatch-timeout如果我写成repair.order.autoDispatchTimeout就不行——严格来说SpringBoot 的宽松绑定支持驼峰和下划线互转但不支持完全随意的命名。如果你用的是 IDEA强烈建议装 Spring Boot 插件配置文件里会有自动提示和拼写检查这能省大量排查时间。5.2 Spring AOP 的切点不生效九成原因是这几类我把 AOP 切点不生效的排查路径总结成一张速查表遇到问题挨个排除就对了现象可能原因解决方案切面完全没有被调用类没有被 Spring 管理检查是否加了Component或相关注解切面只对部分方法生效调用的目标方法不是 public切点只能增强 public 方法类内部方法调用不走代理同类内部调用绕过了代理对象通过代理对象调用或拆分到另一个 Bean启动报Bean 不是代理错误类或方法是 finalCGLIB 无法代理去掉 final 修饰切点表达式写错包名、类名、方法名有拼写错误用 AspectJ 表达式校验工具核对切面被重复执行多次切面类被多次声明或代理叠加检查配置是否重复加载切面类SpringBoot 3 项目用 Transactional 后切面失效事务代理和自定义 AOP 代理叠加通过 Order 调整切面优先级这里重点讲同类内部调用问题。Spring AOP 的代理对象只在外部调用时生效Service public class RepairOrderService { public Long submitRepairOrder(RepairOrderCreateRequest request) { // 非 AOP 生效场景 Long orderId this.confirmInternal(request); return orderId; } AuditLog(action 提交报修, targetType repair_order) public Long confirmInternal(RepairOrderCreateRequest request) { return createOrder(request); } }当你从submitRepairOrder()方法内部调用this.confirmInternal()时实际调用的不是 Spring 容器里的代理对象而是原始对象所以AuditLog切面不会执行。这是我自己刚开始做 AOP 时踩过的最典型的坑。解决方式有两种一是把confirmInternal()拆到另一个独立的 Bean比如RepairOrderCommandService中让RepairOrderService注入它这样外部调用会经过代理二是用AopContext.currentProxy()获取代理对象后再调用但需要显式开启EnableAspectJAutoProxy(exposeProxy true)。这种方式有侵入性我一般更推荐拆分 Bean 的方式因为职责也更清晰。5.3 Transactional 与自定义 AOP 的执行顺序报修平台里提交报修涉及创建订单 初始化流程 首次通知我需要保证这是一个事务。但同时我又想在事务提交成功后再发通知避免事务回滚了但通知已经发出去了这种不一致现象。这就涉及Transactional事务切面和自定义 AOP 切面的执行顺序。Spring 中多个切面在同一方法上可以指定优先级用Order注解控制。关键规则优先级值越小执行顺序越靠前。默认情况下Transactional的优先级是Ordered.LOWEST_PRECEDENCE也就是说它最晚执行。所以如果你在submitRepairOrder()上同时加了Transactional和自定义的AuditLog执行顺序是自定义切面在前事务切面在后。这意味着自定义切面的 Around 方法里如果调用pjp.proceed()之后立即执行代码此时事务可能还没提交。我为了让发通知逻辑等事务提交后再执行把通知切面的Order调成比事务切面低数值更大同时事务切面保持默认。但实践中更好的方式其实是使用 Spring 的事务同步机制在事务内注册TransactionSynchronization在afterCommit时执行通知。这个是 Spring 提供的官方能力比硬调切面顺序更优雅。public void submitRepairOrder(RepairOrderCreateRequest request) { // 业务操作 Long orderId createOrder(request); // 注册事务同步回调 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { notifyService.sendCreateOrderNotification(orderId); } }); }写代码的时候我特意把这个点分享出来是因为事务提交后发通知是很多业务系统的通用需求只用 Order 调切面优先级容易让后续维护的人一头雾水而TransactionSynchronization语义上更清晰、灵活度更高。5.4 SpringBoot3 特有的一些坑javax 包名报错是最常见的。SpringBoot3 已经全面迁移到 Jakarta 命名空间网上大量旧教程的import javax.persistence.*、import javax.validation.*在 SpringBoot3 项目里会直接编译失败。解决方式是统一替换为jakarta.persistence.*、jakarta.validation.*。如果是老项目往 SpringBoot3 升级这一步几乎是必经之路。第三方库的兼容性也要提前确认。SpringBoot3 要求依赖的框架也基于 Spring Framework 6很多老版本的 mybatis-spring-boot-starter、pagehelper-spring-boot-starter 并不直接支持 SpringBoot3。我之前选型时翻了好几个 starter 的文档最终锁定了官方适配版本。我建议你在项目搭建阶段就列一张依赖清单逐个确认是否支持 Spring Boot 3避免开发到一半发现某个关键依赖跑不起来。6. 完整实操从零搭建报修平台的核心模块6.1 项目骨架与 Maven 依赖我们来看一个最小可跑的 SpringBoot3 项目骨架。用 IDEA 的 Spring Initializr 创建项目时关键结构如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里特别说一下spring-boot-starter-aop。SpringBoot 的 AOP starter 会自动引入spring-aop和aspectjweaver不需要单独再配一遍。另外 MyBatis-Plus 3.5.5 是我验证过的兼容 SpringBoot3 版本用低版本可能遇到兼容问题建议避开。6.2 配置文件的关键内容application.yml里放公共配置spring: application: name: repair-platform datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: ${DB_USERNAME} password: ${DB_PASSWORD} mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl map-underscore-to-camel-case: true global-config: db-config: id-type: assign_id注意一个关键细节数据库连接信息我用${DB_HOST}这种占位符方式而不是把明文写死。这样配置信息完全由外部环境变量注入部署到哪套环境都不需要改配置文件。这是遵循应用中不包含环境相关配置的原则。application-prod.yml加生产环境的特有配置server: port: 8080 spring: datasource: url: jdbc:mysql://10.1.2.3:3306/repair_prod username: repair_app password: ${DB_PASSWORD} redis: host: ${REDIS_HOST} port: 6379 logging: level: root: WARN com.community.repair: INFO这里application.yml中定义了${DB_HOST}等占位符但是application-prod.yml里直接写了10.1.2.3。因为 prod 环境的这个值比较固定可以直接覆盖。如果连这个都希望完全动态可以继续用${PROD_DB_URL}占位符。我的习惯是保留一层环境变量覆盖保证安全性和灵活性兼得。6.3 核心切面代码全览把报修平台里用到的两个核心切面放在一起看你就能直观感受到 AOP 的威力Aspect Component Order(10) public class RepairOrderAuditAspect { private final RepairOrderLogMapper logMapper; public RepairOrderAuditAspect(RepairOrderLogMapper logMapper) { this.logMapper logMapper; } Pointcut(annotation(com.community.repair.aspect.AuditLog)) public void auditPointcut() { } Around(auditPointcut() annotation(auditLog)) public Object auditAround(ProceedingJoinPoint pjp, AuditLog auditLog) throws Throwable { long start System.currentTimeMillis(); String operatorId SecurityUtils.getCurrentUserId(); String operatorName SecurityUtils.getCurrentUserName(); Object[] args pjp.getArgs(); String methodName pjp.getSignature().getName(); RepairOrderLog entity new RepairOrderLog(); entity.setAction(auditLog.action()); entity.setTargetType(auditLog.targetType()); entity.setOperatorId(operatorId); entity.setOperatorName(operatorName); entity.setMethodName(methodName); entity.setRequestData(JSON.toJSONString(args)); entity.setCreateTime(new Date()); try { Object result pjp.proceed(); entity.setResultStatus(SUCCESS); entity.setCostTimeMs(System.currentTimeMillis() - start); logMapper.insert(entity); return result; } catch (Throwable e) { entity.setResultStatus(FAILED); entity.setCostTimeMs(System.currentTimeMillis() - start); entity.setErrorMsg(e.getMessage()); logMapper.insert(entity); throw e; } } }Aspect Component Order(20) public class MethodPerformanceAspect { private static final Logger log LoggerFactory.getLogger(MethodPerformanceAspect.class); Around(execution(* com.community.repair.service.*.*(..))) public Object logPerformance(ProceedingJoinPoint pjp) throws Throwable { String methodName pjp.getSignature().toShortString(); long start System.nanoTime(); try { return pjp.proceed(); } finally { long costMs TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); if (costMs 200) { // 超过 200ms 的慢方法记录到日志 log.warn(slow method detected: {}, cost {} ms, methodName, costMs); } } } }两个切面的Order我会特别注意审计切面Order(10)性能监控Order(20)。当同一个方法被多个切面拦截时Order值小的先执行。这里让审计先执行、性能监控后执行保证审计日志记录的是包含性能监控开销在内的完整操作耗时对我的监控目标更合理。6.4 报修提交接口的完整链路最后走一遍完整链路业主提交报修单这个操作实际经历了哪些 AOP 增强调用流程是这样的请求进入RepairOrderController.submitOrder()。RepairOrderService.submitRepairOrder()方法被翻译此时两个切面按Order顺序执行审计切面先记录开始时间和操作人性能监控切面记录方法调用时间。进入业务方法本体创建订单、保存到数据库、计算初始状态。方法返回审计切面记录成功状态和耗时插入repair_order_log表。如果服务抛出异常审计切面在 catch 块中记录失败原因和异常信息。这样一次提交操作不需要业务方法里写任何日志、监控代码但是日志、监控数据全部完整落到库里。这就是 AOP 的价值让业务代码只关心业务让横切关注点统一由切面承担。7. 关于 AOP 的延伸面向切面的更多可能性做完了报修平台这个项目我对 AOP 的使用边界也有了更深的理解。Spring AOP 最擅长的领域其实是业务逻辑的外围比如日志、权限、校验、监控、缓存、重试、幂等、分布式锁。这些逻辑的共同特征是不关心业务结果的具体内容只关心方法执行的时机、参数、返回结果以及异常情况。因此在你设计切面时一定要避免把业务判断写在切面里否则切面会变得十分沉重失去轻量横切的意义。关于 Spring AOP 的局限我也顺便提一嘴它有跨类调用失效、性能损耗动态代理、只能拦截 Spring 容器管理的 Bean 等问题。如果你要对非 Spring 管理的对象做增强或者要处理更细粒度的控制可以学一下 AspectJ 的静态织入方式。选择方案的关键还是看应用场景小型单体项目用 Spring AOP 足够了大型复杂系统再考虑引入全量 AspectJ。8. 项目中踩过的坑和我的最终体会最后分享几个我在实践中的独家体会都是代码之外的认知。关于外部化配置我最大的体会是配置管理能力决定系统的上线效率。如果一个部署流程需要修改配置文件才能切换环境这个流程一定还有优化空间。好配置设计的标准是同一份构建产物放到任何环境都能正确运行差异完全由外部输入环境变量、配置中心、启动参数决定。这是12-factor app的原则也是我评估一个项目配置能力做得好不好的标准。关于 AOP我最大的经验教训是AOP 是强约定需要团队有统一的规范。切面的好处是把横切逻辑集中管理但它也把拦截逻辑从业务代码中抽离了理解代码需要额外的心智成本。如果一个团队没有统一约定哪些方法会被 AOP 拦截、切面里做了什么、顺序是什么后续维护的人很容易一脸茫然。我在项目里专门写了一份切面清单文档罗列了项目中的所有 AOP 切面、作用范围、执行顺序并要求新增切面必须更新文档这个投入帮后来者节省了大量排查时间。还有一个实用小技巧开发环境下配置多个 profile 自动切换时可以把本机 preferred profile 写进 IDEA 的 Run Configuration 里而不是每次启动都拼参数。配合 SpringBoot3 的spring.profiles.group分组配置能把 dev、test、prod 的环境适配配置清晰地组织起来spring: profiles: group: dev: common-dev,db-local,cache-local prod: common-prod,db-prod,cache-prod这样启动时--spring.profiles.activedev就会一次性激活common-dev、db-local、cache-local三个配置片段模块化程度更高。社区报修平台这个系统本身不算复杂但通过外部化配置和 AOP 两个技术点的组合把配置灵活和逻辑整洁这两个软件工程的重要命题真正落地了。如果你也在做类似的业务系统希望这篇实战记录能给你一些参考尤其是避坑部分几乎都是我在真机环境里跑出来的教训照着检查一遍能少走很多弯路。
返回列表