当前位置: 首页 > news >正文

websocket中spring注入失效

一个null指针引发的思考

websocket中spring注入失效

  • 一个null指针引发的思考
    • 场景
    • 代码
        • SpringBoot入口类
        • 配置类
        • websocket类
    • 问题
      • 排查
        • 问题1:
        • 问题2:
    • 反思
    • 解决
      • 方案一:
      • 方案二:
      • 方案三:
      • 方案四:

场景

首页有个websocket的连接,初始化的时候需要去后台获取数据,然后会定时5s一次获取后端的数据。

代码

SpringBoot入口类

扫描注册到spring中

package com;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@ComponentScan(basePackages = {com.xxx})//扫描com.xxx及子包
@EnableScheduling//开启定时任务
@SpringBootApplication //启动springboot
public class Application implements ApplicationRunner {

  public static void main(String[] args) throws Exception {
    SpringApplication.run(Application.class, args);
  }

  @Override
  public void run(ApplicationArguments args) throws Exception {
  }

}
配置类
package com.xxx.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}
websocket类

实现和web前端的交互,获取web后端的数据

package com.xxx.webSocket;


import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.xxx.service.IOverviewService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 首页概况
 *
 * @author 阿明
 * @date 2023/11/30-15:39
 */
@Slf4j
@ServerEndpoint("/websocket/v3/overview")
@Component
public class WebSocketServerOverview {
    private static final int CARD = 5;
    private static final Map<String, DataWrap<OverviewVo>> dataWrapMap = new ConcurrentHashMap<>();

    @Resource
    private IOverviewService overviewService;

    @Scheduled(cron = "*/5 * * * * ? ")
    public void sendCard() throws InterruptedException {
        try {
            for (DataWrap<OverviewVo> value : dataWrapMap.values()) {
                    OverviewVo overviewData = overviewService.card();//new CardVo(totalDatas, cls, toDay, history, totalRules, use, cpu, hardDisk, memory, traffic);
                    value.getSession().getBasicRemote().sendText(JSONUtil.toJsonStr(overviewData));
            }
        } catch (IOException e) {
            log.error("websocket overview错误:", e);
        }
    }


    /**
     * 连接建立成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session) {

        log.info("连接成功");
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose(Session session) {
        Iterator<DataWrap<OverviewVo>> iterator = dataWrapMap.values().iterator();
        while (iterator.hasNext()) {
            DataWrap<OverviewVo> value = iterator.next();
            if (value.getSession().equals(session)) {
                //清除缓存数据 value.getType()
                iterator.remove();
            }
        }
        log.info("连接关闭");
    }

    /**
     * 收到客户端消息后调用的方法,第一次连接成功,这里会发送一次消息
     *
     * @param message 客户端发送过来的消息
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        JSONObject jsonObject = JSONUtil.parseObj(message);
        //无效消息,避免发送心跳解析失败
        if (jsonObject.isNull("userId")) {
            return;
        }
        String userId = (String) jsonObject.get("userId");
        Integer type = (Integer) jsonObject.get("type");
        Integer dataId = null;
        if (!jsonObject.isNull("dataId")) {
            dataId = (Integer) jsonObject.get("dataId");
        }
        if (!dataWrapMap.containsKey(userId + "_" + type) || dataWrapMap.containsKey(userId + "_" + type) && !Objects.equals(dataWrapMap.get(userId + "_" + type).getDataId(), dataId)) {
            DataWrap<OverviewVo> dataWrap = new DataWrap<>(userId, type, dataId, session, new LinkedList<>());
            try {
                switch (type) {
                    case CARD:
                        dataWrapMap.put(userId + "_" + type, dataWrap);
                        OverviewVo overviewData = overviewService.card();//overviewService会是null;
                        // OverviewVo overviewData = SpringUtil.getBean(IOverviewService.class).card();
                        session.getBasicRemote().sendText(JSONUtil.toJsonStr(overviewData));
                        break;
                    default:
                        break;
                }
            } catch (IOException e) {
                log.error("websocket overview错误:", e);
            }
        }
        log.info("收到..消息:" + message);
    }

    /**
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        Iterator<DataWrap<OverviewVo>> iterator = dataWrapMap.values().iterator();
        while (iterator.hasNext()) {
            DataWrap value = iterator.next();
            if (value.getSession().equals(session)) {
                iterator.remove();
            }
        }
        log.error("发生错误", error);
    }
}

问题

小A问我他项目遇到个问题,前端页面先和后端建立连接,等连接成功后,前端会发送个{“type”:5,“userId”:“admin-uuid”}给到后端,开始的时候用的@Scheduled注解的sendCard方法5s一执行,执行到overviewService正常返回数据,今天测试说,你这个一进来没数据,等5s才有数据,想让首页一进来就有数据,所以他就打算前端发送上来数据直接去查库,返回一次数据,返回的时候OnMessage注解的onMessage方法中overviewService属性一直为空,想不到为啥为null。

排查

  • 这个时候的思路肯定是执行的OnMessage注解的onMessage方法没走spring的bean,具体是为啥,开始分析,经过排查发现
问题1:

Tomcat 在 org.apache.tomcat.websocket.server.WsServerContainer 里注册 @ServerEndpoint

@Override
public void addEndpoint(Class<?> clazz) throws DeploymentException {
    ServerEndpoint annotation = clazz.getAnnotation(ServerEndpoint.class);
    if (annotation == null) {
        throw new DeploymentException("Missing @ServerEndpoint annotation");
    }
    ServerEndpointConfig sec = ServerEndpointConfig.Builder.create(
            clazz, annotation.value()).build();
    addEndpoint(sec);
}

📌 关键点

  • Tomcat 通过 clazz.getAnnotation(ServerEndpoint.class) 发现 @ServerEndpoint 标注的类
  • Tomcat 直接 new 这个类(它不会用 Spring 方式去创建 Bean)
  • Spring 的 @Component@Autowired 机制不会生效
问题2:

其实这个不算问题,算是为什么。

@Scheduled 是由 Spring 管理的,所以 @Autowired 可以用。
@ServerEndpoint 由 Tomcat 创建,不是 Spring Bean,所以 @Autowired 不能用。
💡 如果想在 WebSocket 里用 @Autowired 的 Bean,可以用 staticSpringContextUtil 获取。

Spring 类加载器加载的@Scheduled 注解的类,WebSocket 容器类加载器加载的@ServerEndpoint 注解的类。

反思

  • 做java的一直用spring,虽然spring帮我们做了很多事,但是也让我们养成了懒惰的思维,习惯了spring的依赖注入,然后想当然的认为都可以直接依赖使用,这种惯性思维导致了这种问题。

解决

方案一:

可以注释掉overviewService使用SpringUtil.getBean(IOverviewService.class)替代,这种直接使用spring的上下文直接取IOverviewService类对象,肯定是取到了,直接用就行。

方案二:

使用SpringConfigurator 来注入 Spring Bean

package com.xxx.autoconfig;

import cn.hutool.extra.spring.SpringUtil;
import org.springframework.context.ApplicationContext;

import javax.websocket.server.ServerEndpointConfig;

public class SpringConfigurator extends ServerEndpointConfig.Configurator {

    @Override
    public <T> T getEndpointInstance(Class<T> clazz) throws InstantiationException {
        ApplicationContext applicationContext = SpringUtil.getApplicationContext();
        if (applicationContext == null) {
            throw new InstantiationException("Spring ApplicationContext is null");
        }
        return applicationContext.getBean(clazz);
    }
}

@ServerEndpoint("/websocket/v3/overview")改为
@ServerEndpoint(value = "/websocket/v3/overview", configurator = SpringConfigurator.class)

方案三:

  • 使用 @Component + ServerEndpointExporter没管用,证明WebSocket 端点的实例仍然不是由 Spring 管理的,而是由 Tomcat管理的。最终看源码:

    package org.springframework.web.socket.server.standard;
    public class ServerEndpointExporter extends WebApplicationObjectSupport
    		implements InitializingBean, SmartInitializingSingleton {
            protected void registerEndpoints() {
    		Set<Class<?>> endpointClasses = new LinkedHashSet<>();
    		if (this.annotatedEndpointClasses != null) {
    			endpointClasses.addAll(this.annotatedEndpointClasses);
    		}
    
    		ApplicationContext context = getApplicationContext();
    		if (context != null) {
    			String[] endpointBeanNames = context.getBeanNamesForAnnotation(ServerEndpoint.class);
    			for (String beanName : endpointBeanNames) {//这里虽然会执行,但是最终的这个bean是放到tomcat里边了
    				endpointClasses.add(context.getType(beanName));
    			}
    		}
    
    		for (Class<?> endpointClass : endpointClasses) {
    			registerEndpoint(endpointClass);
    		}
    
    		if (context != null) {
    			Map<String, ServerEndpointConfig> endpointConfigMap = context.getBeansOfType(ServerEndpointConfig.class);
    			for (ServerEndpointConfig endpointConfig : endpointConfigMap.values()) {
    				registerEndpoint(endpointConfig);
    			}
    		}
    	}
    

    源码主要是这里tomcat-embed-websocket-9.0.39.jar

    package org.apache.tomcat.websocket.server; 
    public class WsServerContainer extends WsWebSocketContainer
            implements ServerContainer {
     void addEndpoint(Class<?> pojo, boolean fromAnnotatedPojo) throws DeploymentException {
    
            if (deploymentFailed) {
                throw new DeploymentException(sm.getString("serverContainer.failedDeployment",
                        servletContext.getContextPath(), servletContext.getVirtualServerName()));
            }
    
            ServerEndpointConfig sec;
    
            try {
                ServerEndpoint annotation = pojo.getAnnotation(ServerEndpoint.class);
                if (annotation == null) {
                    throw new DeploymentException(
                            sm.getString("serverContainer.missingAnnotation",
                                    pojo.getName()));
                }
                String path = annotation.value();
    
                // Validate encoders
                validateEncoders(annotation.encoders());
    
                // ServerEndpointConfig 这里使用了Configurator,这里就是为啥方案二能成功的原因。
                Class<? extends Configurator> configuratorClazz =
                        annotation.configurator();
                Configurator configurator = null;
                if (!configuratorClazz.equals(Configurator.class)) {
                    try {
                        configurator = annotation.configurator().getConstructor().newInstance();
                    } catch (ReflectiveOperationException e) {
                        throw new DeploymentException(sm.getString(
                                "serverContainer.configuratorFail",
                                annotation.configurator().getName(),
                                pojo.getClass().getName()), e);
                    }
                }
                //这里直接拿到这个类从tomcat生成了
                sec = ServerEndpointConfig.Builder.create(pojo, path).
                        decoders(Arrays.asList(annotation.decoders())).
                        encoders(Arrays.asList(annotation.encoders())).
                        subprotocols(Arrays.asList(annotation.subprotocols())).
                        configurator(configurator).
                        build();
            } catch (DeploymentException de) {
                failDeployment();
                throw de;
            }
    
            addEndpoint(sec, fromAnnotatedPojo);
        }
    

方案四:

  • 见网上好多用@EnableWebSocket的我是没成功,去官网找的用@EnableWebSocketMessageBroker这种更好,格式更规范

  • 用spring自己的websocketGetting Started | Using WebSocket to build an interactive web application

  • git clone https://github.com/spring-guides/gs-messaging-stomp-websocket.git

接受实体类HelloMessage

package com.example.messagingstompwebsocket;

public class HelloMessage {

	private String name;

	public HelloMessage() {
	}

	public HelloMessage(String name) {
		this.name = name;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}
}

发送实体类Greeting

package com.example.messagingstompwebsocket;

public class Greeting {

	private String content;

	public Greeting() {
	}

	public Greeting(String content) {
		this.content = content;
	}

	public String getContent() {
		return content;
	}

}

控制层GreetingController

package com.example.messagingstompwebsocket;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import org.springframework.web.util.HtmlUtils;

@Controller
public class GreetingController {


	@MessageMapping("/hello")
	@SendTo("/topic/greetings")
	public Greeting greeting(HelloMessage message) throws Exception {
		Thread.sleep(1000); // simulated delay
		return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
	}

}

配置类WebSocketConfig

package com.example.messagingstompwebsocket;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void configureMessageBroker(MessageBrokerRegistry config) {
		config.enableSimpleBroker("/topic");//配置订阅消息的目标前缀
		config.setApplicationDestinationPrefixes("/app");//配置应用程序的目的地前缀
	}

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/gs-guide-websocket");//配置端点
	}

}

启动类MessagingStompWebsocketApplication

package com.example.messagingstompwebsocket;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MessagingStompWebsocketApplication {

	public static void main(String[] args) {
		SpringApplication.run(MessagingStompWebsocketApplication.class, args);
	}
}

主页面index.html

<!DOCTYPE html>
<html>
<head>
    <title>Hello WebSocket</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <link href="/main.css" rel="stylesheet">
    <script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.0.0/bundles/stomp.umd.min.js"></script>
    <script src="/app.js"></script>
</head>
<body>
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being
    enabled. Please enable
    Javascript and reload this page!</h2></noscript>
<div id="main-content" class="container">
    <div class="row">
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="connect">WebSocket connection:</label>
                    <button id="connect" class="btn btn-default" type="submit">Connect</button>
                    <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
                    </button>
                </div>
            </form>
        </div>
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="name">What is your name?</label>
                    <input type="text" id="name" class="form-control" placeholder="Your name here...">
                </div>
                <button id="send" class="btn btn-default" type="submit">Send</button>
            </form>
        </div>
    </div>
    <div class="row">
        <div class="col-md-12">
            <table id="conversation" class="table table-striped">
                <thead>
                <tr>
                    <th>Greetings</th>
                </tr>
                </thead>
                <tbody id="greetings">
                </tbody>
            </table>
        </div>
    </div>
</div>
</body>
</html>

app.js

const stompClient = new StompJs.Client({
    brokerURL: 'ws://localhost:8080/gs-guide-websocket'
});

stompClient.onConnect = (frame) => {
    setConnected(true);
    console.log('Connected: ' + frame);
    stompClient.subscribe('/topic/greetings', (greeting) => {
        showGreeting(JSON.parse(greeting.body).content);
    });
};

stompClient.onWebSocketError = (error) => {
    console.error('Error with websocket', error);
};

stompClient.onStompError = (frame) => {
    console.error('Broker reported error: ' + frame.headers['message']);
    console.error('Additional details: ' + frame.body);
};

function setConnected(connected) {
    $("#connect").prop("disabled", connected);
    $("#disconnect").prop("disabled", !connected);
    if (connected) {
        $("#conversation").show();
    }
    else {
        $("#conversation").hide();
    }
    $("#greetings").html("");
}

function connect() {
    stompClient.activate();
}

function disconnect() {
    stompClient.deactivate();
    setConnected(false);
    console.log("Disconnected");
}

function sendName() {
    stompClient.publish({
        destination: "/app/hello",
        body: JSON.stringify({'name': $("#name").val()})
    });
}

function showGreeting(message) {
    $("#greetings").append("<tr><td>" + message + "</td></tr>");
}

$(function () {
    $("form").on('submit', (e) => e.preventDefault());
    $( "#connect" ).click(() => connect());
    $( "#disconnect" ).click(() => disconnect());
    $( "#send" ).click(() => sendName());
});


main.css

body {
    background-color: #f5f5f5;
}

#main-content {
    max-width: 940px;
    padding: 2em 3em;
    margin: 0 auto 20px;
    background-color: #fff;
    border: 1px solid #e5e5e5;
    -webkit-border-radius: 5px;
    -moz-border-radius: 5px;
    border-radius: 5px;
}

在这里插入图片描述

启动项目

访问 http://localhost:8080/
点击connect
What is your name?输入名字点击send

Greetings下显示响应结果

相关文章:

  • 【多线程】线程安全集合类,ConcurrentHashMap实现原理
  • 本地部署DeepSeek-R1(每天8:00Dify通过企微机器人推送新闻热点到群里)
  • C语言:结构化程序设计的核心思想笔记
  • 面试康复训练-SQL语句
  • RIP实验
  • CloudStack安装部署
  • 【10】高效存储MongoDB的用法
  • 长列表局部渲染(监听window滚动),wndonw滚动同理
  • Learn:C++ Primer Plus Chapter13
  • ChainLit快速接入DeepSeek实现一个深度推理的网站应用图文教程-附完整代码
  • Swift 并发任务的协作式取消
  • Mysql 安装教程和Workbench的安装教程以及workbench的菜单栏汉化
  • Python 常用内建模块-itertools
  • HTML(超文本标记语言)
  • Python FastApi(2):基础使用
  • 【SpringBoot】MorningBox小程序的完整后端接口文档
  • 第3章 Internet主机与网络枚举(网络安全评估)
  • Python 爬取 1688 详情接口数据返回说明
  • Mysql架构理论部分
  • github代理 | 快速clone项目
  • 马上评丨火车穿村而过多人被撞身亡,亡羊补牢慢不得
  • 习近平在中拉论坛第四届部长级会议开幕式的主旨讲话(全文)
  • 《淮水竹亭》:一手好牌,为何打成这样
  • 1至4月我国汽车产销量首次双超千万辆
  • 来伊份深夜回应“粽子中吃出疑似创可贴”:拿到实物后会查明原因
  • 江西省司法厅厅长张强已任江西省委政法委分管日常工作副书记