SpringBoot集成WebSocket-2

150 阅读5分钟

WebSocket和Http区别

WebSocket 的出现主要是为了解决 HTTP 协议在实时通信方面的一些局限性:

  • 连接重用:HTTP 协议在每次请求时都需要重新建立连接(HTTP/1.1 之前),这在需要频繁通信的场景中效率很低。
  • 非实时性:传统的 HTTP 请求-响应模型不能满足实时互动的需求,因为服务器无法主动向客户端推送信息。
  • 开销较大:每次 HTTP 请求都会携带完整的头信息,增加了不必要的网络负载。

HTTP的不足

  • 单工通信:HTTP 是单工的,客户端发送请求后服务器才能响应,服务器不能主动发送消息。
  • 频繁的连接开销:每个 HTTP 连接在传输完毕后通常都需要关闭,再次通信需要重新建立连接,这在需要频繁实时交互的应用中显得尤为低效。
  • 头部开销:HTTP 请求和响应都包含大量的头部信息,这对于小数据包的传输非常不利。

并且由于http是单向的,必须有客户端发起请求,我们开发的服务端才会接收返回响应

不多说,直接上干货

基于SpringWebSocket方式

pom依赖

<!-- WebSocket-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

WebSocketConfig

核心配置类

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

/**
 * 描述:WebSocket相关配置
 *
 * @author bobo
 */
@Configuration
//开启webSocket 此注解是必须的
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private AuthHandshakeInterceptor interceptor;
    @Autowired
    private TestHandler testHandler;
    
    /**
     * 注册handler
     *
     * @param registry 注册表
     * @author messi
     * @date 2024/10/25
     */
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        //注册自定义handler 支持注册多个
        registry.addHandler(testHandler,"/test")
                //注册拦截器 自定义拦截信息
                .addInterceptors(interceptor)
                //请求路径过滤 * 代表不做过滤
                .setAllowedOrigins("*");
    }
}

WebSocketHandler

消息处理器

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

/**
 * @author bobo
 * @date 2023/11/21
 * @apiNote 说明
 * 通过继承 TextWebSocketHandler 类并覆盖相应方法,可以对 websocket 的事件进行处理,这里可以同原生注解的那几个注解连起来看
 * <p>
 * afterConnectionEstablished 方法是在 socket 连接成功后被触发,同原生注解里的 @OnOpen 功能
 * afterConnectionClosed  方法是在 socket 连接关闭后被触发,同原生注解里的 @OnClose 功能
 * handleTextMessage 方法是在客户端发送信息时触发,同原生注解里的 @OnMessage 功能
 */
@Component
@Slf4j
public class TestHandler extends TextWebSocketHandler {
    /**
     * socket 建立成功事件
     *
     * @param session
     * @throws Exception
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        //session中保存了设置的信息,可以对应做设置
        String token = (String) session.getAttributes().get("token");
        if (token != null) {
            // 用户连接成功,放入在线用户缓存
            WebSocketSessionConfig.add("test", token, session);
        } else {
            throw new RuntimeException("用户登录已经失效!");
        }
    }

    /**
     * 接收消息事件
     *
     * @param session
     * @param message
     * @throws Exception
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 触发发送消息
        String payload = message.getPayload();
        log.info("发送流程信息为:{}", payload);
        session.sendMessage(message);
    }

    /**
     * socket 断开连接时
     *
     * @param session
     * @param status
     * @throws Exception
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        Object token = session.getAttributes().get("token");
        if (token != null) {
            // 用户退出,移除缓存
            WebSocketSessionConfig.remove("test", token.toString());
        }
    }

    /**
     * 连接错误时
     *
     * @param session
     * @param exception
     * @throws Exception
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        Object token = session.getAttributes().get("token");
        if (token != null) {
            // 用户退出,移除缓存
            WebSocketSessionConfig.remove("test", token.toString());
        }
        log.info("连接错误,该用户token为:{}", session.getAttributes().get("token"));
        exception.printStackTrace();
    }
}

WebSocketSessionConfig

session管理器

import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.WebSocketSession;

import javax.validation.constraints.Null;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

/**
 * @author bobo
 * @date 2023/11/21
 * @apiNote
 * 这里简单通过 ConcurrentHashMap 来实现了一个 session 池,用来保存已经登录的 web socket 的  session。服务端发送消息给客户端必须要通过这个 session。
 */
@Slf4j
public class WebSocketSessionConfig {
    /**
     * 保存连接 session 的地方
     * 注意点1:建议设置为线程安全的容器
     * 注意点2:
     *   map1的键:链接信息关键字
     *   map2的键:token(如果存在的话)一个登陆态,一个链接  
     */
    private static ConcurrentHashMap<String, ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>>> SESSION_POOL = new ConcurrentHashMap<>();

    /**
     * 添加 session
     *
     * @param key
     */
    public static void add(String key,String token, WebSocketSession session) {
        if (SESSION_POOL.containsKey(key)){
            ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>> tokenMap = SESSION_POOL.get(key);
            if (tokenMap.containsKey(token)){
                CopyOnWriteArraySet<WebSocketSession> set = tokenMap.get(token);
                set.add(session);
            }else {
                CopyOnWriteArraySet<WebSocketSession> set = new CopyOnWriteArraySet<>();
                set.add(session);
                tokenMap.put(token,set);
            }
        }else {
            CopyOnWriteArraySet<WebSocketSession> set = new CopyOnWriteArraySet<>();
            set.add(session);
            ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>> map = new ConcurrentHashMap<>();
            map.put(token,set);
            // 添加 session
            SESSION_POOL.put(key, map);
        }

        log.info(key.concat(token).concat("加入连接,当前连接总量为").concat(SESSION_POOL.toString()));

    }

    /**
     * 删除 session,会返回删除的 session
     *
     * @param key
     * @return
     */
    public static Object remove(String key, String token) {
        if (StrUtil.isEmpty(token)){
            return SESSION_POOL.remove(key);
        }else {
            return SESSION_POOL.get(key).remove(token);
        }
    }

    /**
     * 删除并同步关闭连接
     *
     * @param key
     */
    public static void removeAndClose(String key) {
        ConcurrentHashMap<String,Set<WebSocketSession>> remove = (ConcurrentHashMap<String, Set<WebSocketSession>>) remove(key, null);
        if (remove != null) {
                // 关闭连接
                remove.values().stream().forEach(item -> {
                    item.forEach(temp -> {
                        try {
                            temp.close();
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    });
                });
        }
    }

    /**
     * 获得 session
     *
     * @param key
     * @return
     */
    public static ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>> get(String key) {
        // 获得 session
        return SESSION_POOL.get(key);
    }

    /**
     * 是否包含键值
     * @param key
     * @return {@code Boolean}
     */
    public static Boolean hasKey(String key){
        //返回是否包含key值
        return SESSION_POOL.containsKey(key);
    }

    /**
     * 是否包含键名
     * 模糊匹配
     *
     * @param key 钥匙
     * @return {@code Boolean}
     */
    public static Boolean containsKey(String key){
        Boolean flag = false;
        Set<Map.Entry<String, ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>>>> entries = SESSION_POOL.entrySet();
        for (Map.Entry<String, ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>>> entry : entries) {
            if (entry.getKey().contains(key)){
                flag = true;
            }
        }
        return flag;
    }
    /**
     * 是否包含值
     * @param session
     * @return {@code Boolean}
     */
    public static Boolean hasValue(WebSocketSession session){
        return SESSION_POOL.containsValue(session);
    }
}

Interceptor

自定义拦截器

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.WebSocketHttpHeaders;
import org.springframework.web.socket.server.HandshakeInterceptor;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * <p>
 * 自定义{@link Interceptor},实现“需要登录才允许连接WebSocket”
 * </p>
 *
 * @author bobo
 * @version 1.2.0
 * @date 2023/11/20
 * @see HandshakeInterceptor
 */
@Component
@Slf4j
public class Interceptor implements HandshakeInterceptor {

    /**
     * 握手前
     *  token权限验证
     *  appId如果存在则放入属性域
     * @param request
     * @param response
     * @param webSocketHandler
     * @param attributes
     * @return boolean
     * @throws Exception
     */
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler webSocketHandler, Map<String, Object> attributes) throws Exception {
        log.info("握手开始");
        //获取首次连接请求头 -- 协议请求头中存放token
        String token = SpringContextUtils.getRequest().getHeader(AuthConstant.WEBSCOKET_JWT_TOKEN_HEADER);
        if (StrUtil.isEmpty(token)){
            log.info("token不存在");
            return false;
        }
        // verify token 校验token 如果成功 放入属性域中
        attributes.put("token",token);
        response.getHeaders().put(WebSocketHttpHeaders.SEC_WEBSOCKET_PROTOCOL, CollectionUtil.newArrayList(token));
        return true;
    }

     /**
     * 握手后 可以按照需求设置
     */
    @Override
    public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) {
        log.info("握手成功");
    }

}

TestImpl

触发发送消息

@Service
@Slf4j
public class TestImpl implements ITestService {
    @Autowired
    private TestHandler testHandler;
    //处理器,键名,消息内容
    @Override
    public void sendData(TextWebSocketHandler handler,String key,String message) {
        //获取键名内链接信息
        ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>> tokenMap = WebSocketSessionConfig.get(key);
        //遍历链接信息,保证每一个链接都发送消息
        Collection<CopyOnWriteArraySet<WebSocketSession>> values = tokenMap.values();
            values.forEach(item -> {
                item.forEach(temp -> {
                    try {
                        //temp = session 发送消息
                        handler.handleMessage(temp, new TextMessage(message));
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                });
            });
    }
}