ARTICLE DETAIL

资讯详情

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

Spring Boot整合实战:WebSocket推送、Caffeine缓存与Bean注入全解析

Spring Boot整合实战:WebSocket推送、Caffeine缓存与Bean注入全解析 最近帮团队把一个餐饮SaaS项目做了次 Spring Boot 框架整合说白了就是把 WebSocket 实时推送、Caffeine 本地缓存、多环境配置、Bean 装配控制这些东西一个个塞进同一个 Spring Boot 应用让它们各自干活又不互相打架。这类工作看着不复杂真做起来却很磨人——你会发现真正让项目卡住的往往不是某个框架本身而是框架之间衔接的那些细节版本怎么选、Bean 怎么注入、yml 怎么写、握手拦截器怎么接。热搜词里既有“第一个 Spring Boot 程序”也有“WebSocket yml 配置”“Bean 注入控制”“Caffeine”正好覆盖了从入门到整合的完整链路。这篇文章就顺着这条路把整合过程中最关键、也最容易翻车的几个点一个个拆开讲。1. 第一个 Spring Boot 程序从零搭脚手架别在第一步埋雷很多人以为 Spring Boot 整合是后期的事先把项目跑起来再说。但实际上排查过太多团队项目问题往往出在第一步脚手架搭得不规范。第一个 Spring Boot 程序看起来简单启动类往那一放main方法一跑就能起来但目录结构、依赖选择、启动类位置这些细节会在后续整合 WebSocket、Caffeine、多数据源时集中爆发。1.1 初始化方式的选择快速创建 Spring Boot 项目主流有三条路Spring Initializrstart.spring.io 网页版以及 IDEA 内置的初始化向导、Maven 原型模板、纯手工建目录写 pom。我个人的建议很简单能用 Initializr 就用 Initializr别手工搭。原因很实际。手工搭 Maven 项目你得自己拼spring-boot-starter-parent、spring-boot-starter-web、maven-compiler-plugin这些坐标版本兼容性全靠记忆。Spring Boot 的 starter 依赖关系是一张很大的网2.x 和 3.x 里同一个 starter 的传递依赖差异很大手工拼出来的项目经常出现NoSuchMethodError这种运行时才能暴露的问题。Initializr 生成的工程父 POM、依赖版本、插件都对应好了相当于给你一个经过官方测试的基准线。选择依赖时只需遵守一个原则按功能选 starter别按习惯乱加。例如要写接口就加spring-boot-starter-web要做参数校验就加spring-boot-starter-validation要测接口就加spring-boot-starter-test。那些暂时用不上的 starter 先不加因为每多一个 starter自动配置就会多加载一批条件判断启动时排查问题就会多一层干扰。1.2 启动类与目录结构约定比配置更重要Spring Boot 默认的组件扫描机制是从启动类所在的包开始向下扫描。所以启动类的位置必须是根包假设项目域名是com.example.saas启动类就放在com.example.saas下后续的 controller、service、config、websocket 各建子包。这个约定几乎所有的教程都会讲但实际操作里我看到不少项目把启动类放在com.example.saas.api这种子包里然后ComponentScan注解手动指定一堆扫描路径。短期没问题时间一长新来的同事不知道哪些包需要手动加扫描整合 WebSocket 时写了一个ServerEndpoint类放在根包扫描范围之外结果页面一直连不上查了半天才发现是 Bean 根本没进容器。所以我的做法很死板启动类永远放在根包不加 ComponentScan 也能扫到所有组件。整合新框架时新增的配置类、Handler、拦截器统一放进根包对应的子包下让自动扫描和自动配置各司其职。另外有一点要注意IDEA 的 Initializr 默认生成的测试类用SpringBootTest在整合适配阶段如果项目里已经接了 Redis、Nacos 这类中间件测试类启动时会连带拉起真实连接。建议在测试配置里用spring.autoconfigure.exclude排除暂时不需要的自动配置或者直接准备一个带application-test.yml的测试环境否则等整合到中后期每次点一下测试按钮都要半天。2. 版本选型2.3.x 与 2.6.x 的真实差异以及升级遇到的那些坑热搜词里有“spring boot 2.3.x 2.6.x”这说明很多人卡在了版本选择上。Spring Boot 的版本号不是随便跳的2.3 到 2.6 之间隔了好几代行为变化尤其是 2.6 这个版本它把很多“历史遗留习惯”直接改了默认值。选错版本后面整合 WebSocket、Caffeine 时会出现一堆看起来莫名其妙的报错。2.1 版本差异不只是数字我整理了一张对照表是这几个版本在实际项目里感知最强的差异版本Java 要求路径匹配策略循环依赖自动配置注册方式2.3.xJava 8AntPathMatcher默认允许只告警META-INF/spring.factories2.6.xJava 8PathPatternParser默认默认禁止启动即报错META-INF/spring.factories仍可用但弃用2.7.xJava 8PathPatternParser默认默认禁止spring.factories / AutoConfiguration.imports 并存2.6.x 是我认为 Spring Boot 2.x 里最需要认真对待的一个版本因为它在默认行为上做了两件大事第一默认禁止循环依赖。老项目里常见这种写法A 的构造器需要注入 BB 的构造器又需要注入 A以前 Spring 会用三级缓存兜底勉强转起来。2.6 开始直接抛BeanCurrentlyInCreationException启动就失败。这对新项目是好事逼着你把设计理清楚但对接手老代码的团队就是一次大改造得把所有互相绕的 Bean 拆开或者改成Lazy延迟注入这属于补丁不建议长期依赖。第二默认路径匹配策略变成 PathPatternParser。这是个比较容易踩坑的点比如在网关、拦截器里配置/**或/*通配符时PathPatternParser 对路径匹配的规则和老的 AntPathMatcher 不完全一样某些场景下/*.html这种扩展名匹配会表现异常。整合 WebSocket 时如果同时配了路径拦截器这个差异会直接导致握手请求被拦掉但又看不到明显日志——我后来是在请求日志里发现 URL 后缀突然不匹配了才追到是路径策略变了。2.2 升级到 2.6.x 后的实测问题我接手的一个餐饮 SaaS 系统原来跑在 2.3.12 RELEASE 上升级到 2.6.14 后最先暴露的就是循环依赖问题。报错信息很直白“The dependencies of some of the beans in the application context form a cycle”。当时有 A 服务依赖 B 服务B 服务依赖 C 服务C 服务又依赖 A 服务链路绕了三个类。逐一改成构造器注入后插入了一个PostConstruct初始化方法去打破链条才顺利启动。另外一个坑是spring.factories弃用。2.6 之前自定义自动配置类写在META-INF/spring.factories里还能生效2.6 虽然没强制但控制台会刷警告。升级时我把自定义配置改成了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports这个新文件格式更简单每行一个配置类全限定名。这个改动要趁升级时顺手做完否则迟早要返工。2.3 该用哪个版本给团队定版本我现在的原则是写新项目直接上 2.7.x甚至考虑 3.x维护老项目 JDK 是 8 的话尽量往 2.7.18 靠。2.3.x 实在有点老了它对应的 Spring 5.2.x一些安全性维护已经停止。3.x 对 Java 17 是硬性要求如果团队的 JDK 还在 8那就老老实实 2.7.x不要硬升。版本这件事图新没意义团队能长期稳定维护才是真实收益。3. Bean 注入控制从 Autowired 到构造器注入把依赖关系彻底管住“spring boot bean注入控制”这个热搜词几乎是每个从零开始写 Spring Boot 的人都会搜的。因为 Bean 注入这件事网上说法很多有人天天用Autowired也没出过事有人一用就报错。真实项目里注入方式的不统一带来的维护成本远比你想象的严重。3.1 字段注入为什么被诟病最常见的老写法是Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private StockService stockService; }这在 Controller 或 Service 里看着省事但问题在于依赖关系被藏起来了。你扫一眼这个类根本看不出它依赖了哪些 Bean单元测试时只能靠 Spring 容器硬拉起来没法直接new一个OrderService并替换 Mapper 的 Mock 实现。多写几个这样的类项目里所有类的依赖关系就变成了“黑盒”排查NullPointerException时特别头疼。我现在的做法是全部改成构造器注入配合 LombokService RequiredArgsConstructor public class OrderService { private final OrderMapper orderMapper; private final StockService stockService; }RequiredArgsConstructor会给所有final字段生成一个构造器Spring 在创建 Bean 时会自动把依赖传进来。这么写有几个实质好处一来依赖关系直接在字段列表里暴露无遗二来测试时可以new OrderService(mockMapper, mockStockService)完全不依赖容器三来final字段天然防止后续代码不小心重新赋值。这个改动对老项目来说看似繁琐但一次改完长期收益非常高。3.2 同类型多 Bean 怎么选比注入方式更隐蔽的坑是同类型多个 Bean 时的歧义问题。比如项目里定义了两个CacheManager一个本地 Caffeine一个 Redis如果你在 Service 里直接Autowired private CacheManager cacheManager;Spring 会直接报NoUniqueBeanDefinitionException。解决办法一般有两种Primary标记其中一个 Bean 作为默认首选。Qualifier(beanName)明确指定要注入哪个 Bean。我的习惯是尽量用Qualifier把名字写清楚因为Primary只解决“默认选择”的问题不看代码的人还是不知道你实际用到了哪个实现。特别是在整合多级缓存时有caffeineCacheManager和redisCacheManager同时在容器里Qualifier让代码的可读性好了不是一点半点。3.3 条件装配让 Bean 在合适的时机出现整合框架时最容易被忽略的是 Spring Boot 提供的那套条件装配注解。第一次看到ConditionalOnProperty时我的感觉是“这玩意儿还能控制 Bean 是否创建”但后来发现它特别适合处理多环境差异。举个例子餐饮 SaaS 项目本地联调时不接真正的消息队列只有测试环境才需要一个KafkaConsumer监听类。如果用Component硬写本地启动必然尝试连 Kafka半天连不上然后启动失败。用条件装配Component ConditionalOnProperty(name messaging.enabled, havingValue true, matchIfMissing false) public class KafkaMessageConsumer { // ... }然后本地application-local.yml里不配置messaging.enabled测试环境配成trueBean 就会在合适的时机出现。整合 Caffeine、WebSocket 时也能用这种思路比如某些模块只有特定版本才启用缓存通过配置项控制 Bean 的加载比启动后再用if判断要干净得多。4. yml 配置深度实践从多环境到自定义属性的绑定Spring Boot 里yml 是大多数人的第一道坎。“spring boot 集成 web socket yml 配置”这个热搜词很有意思因为很多人以为 WebSocket 要靠 yml 配置才能起效但实际上 WebSocket 在 Spring Boot 里几乎没有现成的 yml 配置项核心配置都在 Java 配置类中。yml 真正的大头是多环境拆分和自定义属性的绑定。4.1 多环境配置文件拆分与激活一个规范点的 Spring Boot 项目至少要有三份配置application.yml公共配置激活哪个环境在这里控制。application-dev.yml本地联调配置数据源指向本地日志级别 DEBUG。application-prod.yml线上配置打印访问日志缓存策略保守。激活环境就一行spring: profiles: active: dev也有团队用启动参数--spring.profiles.activeprod来切换效果一样。这里有个细节容易踩坑如果在多份配置里重复定义了同一个配置项后加载的会覆盖先加载的。Spring Boot 的配置加载优先级里application-{profile}.yml的优先级高于application.yml所以可以放心地在公共配置里放默认值再在环境配置里覆盖。另外不要把数据库密码、密钥这些东西直接写进 yml尤其注意别把文件提交到公开仓库。用环境变量SPRING_DATASOURCE_PASSWORD或配置中心如 Nacos去管理这种敏感信息才是线上项目该有的姿势。4.2 自定义配置的绑定业务系统里总有各种自定义参数比如餐饮 SaaS 里的排队叫号间隔、超时时间。好多项目一直用Value到处取写起来快但管理和校验都很差。比如Value(${queue.notify.interval:3000}) private long notifyInterval;这个字段散落在好几个 Service 里以后想统一改格式、加个默认值校验要各个类里去找。更好的做法是使用ConfigurationProperties把这些参数绑定到一个配置类上Component ConfigurationProperties(prefix queue.notify) Data public class QueueNotifyProperties { private long interval 3000; private int maxRetry 3; private boolean enabled true; }然后在 yml 里queue: notify: interval: 3000 max-retry: 5 enabled: trueSpring Boot 会自动把max-retry映射到maxRetry松耦合的绑定还很智能。以后的代码里只需要注入QueueNotifyProperties这一个 Bean字段集中、类型安全还能加Validated做参数校验。整合 Caffeine 时缓存区的配置也很适合这种绑定方式把缓存容量、过期时间都抽到 Properties 类里调参不用改代码维护体验好很多。4.3 配置文件的“熵增”治理yml 的另一个问题是不加节制的增长。每新增一个中间件就往 yml 里丢一段配置时间一长配置文件几百行谁也不敢动。我的实践是给 yml 分区块基础区spring.application.name、server.port、spring.profiles.active。中间件区数据源、Redis、消息队列按依赖的中间件分组。自定义区通过ConfigurationProperties绑定的业务参数。区块之间留空行加注释保持清晰。凡是某个中间件的配置超过了三行以上就考虑写一个专门的配置类去封装。5. 集成 WebSocketyml 配置只是入口完整链路才决定成败WebSocket 整合是这段时间做餐饮 SaaS 项目里最典型的实战场景。餐厅订单状态变化、排队叫号、消息通知都需要服务端主动推给客户端。HTTP 做不到轮询又太耗资源WebSocket 是正解。这里有一个特别常见的误解要先澄清Spring Boot 的 WebSocket 并没有提供太多 yml 配置项你找不到一个叫spring.websocket的地方去配端口、配路径。真正的关键在于如何写配置类、如何做握手鉴权、如何管理 Session。5.1 依赖与基础配置引入依赖很简单dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependencyspring-boot-starter-websocket会把 WebSocket 相关的容器能力带进来。下一步是写一个配置类实现WebSocketConfigurer。我在项目里的做法是Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(orderWebSocketHandler(), /ws/order) .addInterceptors(new OrderHandshakeInterceptor()) .setAllowedOrigins(*); } Bean public WebSocketHandler orderWebSocketHandler() { return new OrderWebSocketHandler(); } }路径是/ws/order客户端通过这个路径握手。setAllowedOrigins(*)表示允许所有来源如果项目有明确的域名限制建议配具体域名别图省事。5.2 握手拦截器与鉴权WebSocket 的鉴权比 HTTP 麻烦因为握手是一次特殊的 HTTP Upgrade 请求可以先通过拦截器做鉴权。我写了一个OrderHandshakeInterceptorpublic class OrderHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { String token request.getHeaders().getFirst(Authorization); if (token null || !TokenUtils.validate(token)) { return false; } attributes.put(userId, TokenUtils.getUserId(token)); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { } }注意attributes这个 Map它是往 WebSocketSession 里塞用户信息的关键通道。握手成功后在 Handler 里可以通过session.getAttributes().get(userId)拿出来。不做这一步后端根本不知道该给哪个连接发消息整条链路就断了。5.3 消息分发、离线消息与心跳WebSocket 的完整链路里Handler 是最核心的部分。我建议维护一个SessionRegistry专门管理 userId 到WebSocketSession的映射Component public class SessionRegistry { private final ConcurrentHashMapString, WebSocketSession sessions new ConcurrentHashMap(); public void add(String userId, WebSocketSession session) { sessions.put(userId, session); } public void remove(String userId) { sessions.remove(userId); } public WebSocketSession get(String userId) { return sessions.get(userId); } public void broadcast(Object message) { sessions.values().forEach(session - sendMessage(session, message)); } }ConcurrentHashMap是必须的因为 WebSocket 是多线程环境并发连接建立、断开都同时发生普通 HashMap 分分钟死循环或丢数据。消息发送用 synchronized 块或直接同步方法控制。然后写一个OrderWebSocketHandlerComponent public class OrderWebSocketHandler extends TextWebSocketHandler { private final SessionRegistry sessionRegistry; public OrderWebSocketHandler(SessionRegistry sessionRegistry) { this.sessionRegistry sessionRegistry; } Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId (String) session.getAttributes().get(userId); sessionRegistry.add(userId, session); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 客户端可能发送心跳包服务端回复 pong if (ping.equals(message.getPayload())) { session.sendMessage(new TextMessage(pong)); return; } // 业务消息解析、分发 OrderMessage msg JsonUtil.parse(message.getPayload(), OrderMessage.class); WebSocketSession target sessionRegistry.get(msg.getTargetUserId()); if (target ! null target.isOpen()) { target.sendMessage(new TextMessage(...)); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId (String) session.getAttributes().get(userId); sessionRegistry.remove(userId); } }有几个很实际的细节项目中反复踩过心跳机制必须有。服务器和客户端之间如果长时间没有消息Nginx 或中间的负载均衡设备可能会把连接当成空闲连接掐掉。我让前端每 30 秒发一个ping后端回pong连接就一直是活跃状态。这个策略一定要提前做否则线上过一段时间就会大量掉线。断开重连时先清理旧 Session。如果用户在网络切换时快速重连旧连接可能还没触发afterConnectionClosed新连接又进来了SessionRegistry里同一个 userId 会有两条 session广播时就会重复推送。我处理时是先remove(userId)再put并且尽量在旧 session 上调用close()。离线消息不能丢。如果目标用户不在线消息可以直接丢弃或者落到消息表里。我做了一个简单方案用 WebSocket 推送的同时把关键消息写入数据库等用户上线后拉取未读消息。这里可以和 Caffeine 配合最近一分钟的离线消息放进本地缓存用户上线时先查缓存命中就直接推没命中查库。6. 整合 Caffeine 缓存给热点数据装一个进程内加速器缓存是整合过程中“收益最明显、最容易出错”的模块。“spring boot caffeine”这个热搜词说明很多人已经在研究 Caffeine 了但把它和 Spring Cache 抽象结合起来再把参数调对不只是引入依赖那么简单。6.1 依赖与 CacheManager 配置Caffeine 是进程内缓存速度极快非常适合热点数据的短时缓存。引入依赖dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId /dependencySpring Boot 里直接使用 Caffeine 的推荐姿势是通过 Spring Cache 抽象。先定义 CacheManagerConfiguration EnableCaching public class CacheConfig { Bean(caffeineCacheManager) public CacheManager caffeineCacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(dishCache, storeCache); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(Duration.ofMinutes(10))); cacheManager.setAllowNullValues(false); return cacheManager; } }这里要注意几点。第一CaffeineCacheManager默认在第一次访问某个 cache 名字时才创建对应的 Cache所以最好在构造函数或setCacheNames里把要用的缓存放进去否则Cacheable(value dishCache)以外的名字也能动态生成缓存容易失控。第二setAllowNullValues(false)是我个人的默认选择因为允许缓存 null 值会导致“缓存穿透”的问题被掩盖线上查不出来的数据一多缓存里堆满null命中率再高也没意义。然后在业务方法上直接加注解Service RequiredArgsConstructor public class DishService { private final DishMapper dishMapper; Cacheable(cacheManager caffeineCacheManager, value dishCache, key #dishId) public Dish getDishById(Long dishId) { return dishMapper.selectById(dishId); } }6.2 缓存策略参数怎么定Caffeine 的参数网上有无数模板但我见过的项目里真正按业务特点调过参的没几个。最关键的三个参数是参数作用业务场景匹配maximumSize最大条目数超出后按淘汰策略驱逐根据热点数据总量估算别拍脑袋expireAfterWrite写入后固定时间过期适合读多写少、允许短暂脏读的数据expireAfterAccess访问后固定时间不访问则过期适合用户会话类数据refreshAfterWrite写后延迟刷新访问时异步刷新适合“可以脏读但不能长时间过期”的数据我实际的做法是菜单、菜品这种变化极少的字典数据用expireAfterWrite(Duration.ofMinutes(30))30 分钟刷新一次足够订单状态这种实时性要求高的数据不进本地缓存直接查库或走 Redis用户排队状态这类数据用expireAfterWrite(Duration.ofSeconds(10))短暂缓存防抖避免同一秒内大量请求打到数据库。特别提醒refreshAfterWrite在CaffeineCacheManager里不是开箱即用。单独用Caffeine.newBuilder().refreshAfterWrite(...)搭配CacheLoader才能生效在 Spring Cache 抽象下很容易配完没反应。所以我建议不要在一个整合项目里强行搞高级刷新策略先用expireAfterWrite打底等确认边界再加Caffeine Cache.asMap()这类手工操作。6.3 和 Redis 组合时的常见误区Caffeine 是本地缓存Redis 是分布式缓存项目后期往往同时接两个。但这里有一个特别常见的误区试图用 Caffeine 完全替代 Redis或者把两者当成同一层技术选二选一。它们定位不同Redis缓存所有节点共享的数据跨实例一致性强适合更新频率低、多个服务实例都要读的数据。Caffeine只缓存本实例热点数据适合单 JVM 内高频读写、可以容忍短暂不一致的数据。多级缓存的正确做法是先查 Caffeine未命中再查 Redis仍未命中才查数据库并逐级回填。此时容器里会有两个 CacheManager要在Cacheable上显式指定cacheManager否则 Spring 会因为多个 CacheManager 没有一个标记为Primary而直接启动报错。但如果要求更严的一致性比如餐企后台对菜品价格的修改要立刻生效多级缓存就会很痛苦——Caffeine 的过期时间再短也有一个窗口。我给这类数据定的策略是不使用 Cacheable 注解直接用 Redis 的 pattern 订阅做缓存失效广播。本机实例收到频道消息后手动调用caffeineCacheManager.getCache(dishCache).evictIfPresent(dishId);这样既保留了本地缓存的性能又能做到秒级失效。但这种方案对代码结构要求较高适合已经把缓存封装成模块的项目小项目不建议一上来就上。整合做到最后我的体会是Spring Boot 里的框架整合难点从来不是某个框架的 API而是怎么把版本、配置、Bean 装配、连接生命周期这些“胶水”部分捏合得干净。每一步单独看都有参考答案但凑在一个项目里互相之间的作用与反作用才是花时间的点。写这篇文章时我特意把 WebSocket 和 Caffeine 放在相邻的位置是因为这两者在餐饮 SaaS 场景下的配合非常典型WebSocket 负责推Caffeine 负责挡——挡住了大量重复请求推送通道才不会被系统自身打到过载。如果你也在整合过程中遇到了某个奇怪的问题先别急着搜报错信息回头检查版本、配置加载顺序和 Bean 注入链这三样大概率能在五分钟内定位根因。
返回列表