基于springboot websocket和okhttp实现消息中转

1、业务介绍

消息源服务的消息不能直接推给用户侧,用户与中间服务建立websocket连接,中间服务再与源服务建立websocket连接,源服务的消息推给中间服务,中间服务再将消息推送给用户。流程如下图:

此例中我们定义中间服务A的端口为8082,消息源头服务B的端口为8081,方便阅读下面代码。

说明:此例子只实现了中间服务的转发,连接的关闭等其他逻辑并没有完善,如需要请自行完善;

2、中间服务实现

中间服务即为上图的中间服务A,由于中间服务既要发送(发给用户端)消息,又要接收(从消息源服务B接收)消息,故服务A分为服务端与客户端。

服务A的websocket服务端我们使用springboot websocket实现,客户端使用okhttp实现;会话缓存暂使用内存缓存(实际项目中可置于其他缓存中)

中间服务所需依赖为:

复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.2.2</version>
</dependency>

缓存类:

复制代码
public class WSCache {

    //存储客户端session信息, {会话id:ws_session}
    public static Map<String, Session> clients = new ConcurrentHashMap<>();

    //存储把不同用户的客户端session信息集合 {userId, [会话id1,会话id2,会话id3,会话id4]}
    public static Map<String, Set<String>> connection = new ConcurrentHashMap<>();
}

自定义消息类:

复制代码
@Accessors(chain = true)
@Data
public class MsgInfo {

    private String massage;

    //为userId,用于从缓存中获取对应用户的websocket session
    private String userKey;
}

2.1 中间服务A的客户端:

客户端也可以使用springboot websocket,当下我们选择使用okhttp实现。

复制代码
@Slf4j
public class CommonWSClient extends WebSocketListener {

    /**
     * websocket连接建立
     *
     * @param webSocket
     * @param response
     */
    @Override
    public void onOpen(WebSocket webSocket, Response response) {
        super.onOpen(webSocket, response);
		log.info("客户端连接建立:{}", response.body().string());
    }

    /**
     * 收到消息
     * @param webSocket
     * @param text
     */
    @Override
    public void onMessage(WebSocket webSocket, String text) {
        super.onMessage(webSocket, text);
        log.info("okhttp receive=>{}", text);
        //todo 收到源(8081)的消息,取到对应userId的消息,并将消息通过本地server发送给用户
        ObjectMapper mapper = new ObjectMapper();
        try {
            MsgInfo msgInfo = mapper.readValue(text, MsgInfo.class);
            Set<String> strings = WSCache.connection.get(msgInfo.getUserKey());
            if(!CollectionUtils.isEmpty(strings)){
                for (String sid : strings) {
                    Session session = WSCache.clients.get(sid);
                    session.getBasicRemote().sendText(msgInfo.getMassage());
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
            //throw new RuntimeException(e);
        }
    }

    @Override
    public void onMessage(WebSocket webSocket, ByteString bytes) {
        super.onMessage(webSocket, bytes);
    }

    @Override
    public void onClosing(WebSocket webSocket, int code, String reason) {
        super.onClosing(webSocket, code, reason);
        log.info("okhttp socket closing.");
    }

    @Override
    public void onClosed(WebSocket webSocket, int code, String reason) {
        super.onClosed(webSocket, code, reason);
        log.info("okhttp socket closed.");
    }

    @Override
    public void onFailure(WebSocket webSocket, Throwable t, Response response) {
        super.onFailure(webSocket, t, response);
        if (response == null) {
            log.error("okhttp onFailure, response is null.");
            return;
        }
        try {
            log.error("okhttp onFailure, code: {}, errmsg: {}", response.code(), response.body().string());
        } catch (IOException e) {
            log.warn("okhttp onFailure failed, error: {}", e.getMessage());
        }
    }

}

2.2 中间服务A的服务端:

websocket服务:

复制代码
@Slf4j
@Component
@ServerEndpoint("/notice/{userId}")
public class WebSocketServer {
    
    //会话id
    private String sid = null;

    //建立连接的用户id
    private String userId;

    /**
     * @description: 当与用户端连接成功时,执行该方法
     * @PathParam 获取ServerEndpoint路径中的占位符信息类似 控制层的 @PathVariable注解
     **/
    @OnOpen
    public String onOpen(Session session, @PathParam("userId") String userId){
        this.sid = UUID.randomUUID().toString();
        this.userId = userId;
        WSCache.clients.put(this.sid,session);
        //判断该用户是否存在会话信息,不存在则添加
        Set<String> clientSet = WSCache.connection.get(userId);
        if (CollectionUtils.isEmpty(clientSet)){
            clientSet = new HashSet<>();
            clientSet.add(this.sid);
        }else {
            clientSet.add(this.sid);
        }
        WSCache.connection.put(userId,clientSet);
        log.info("用户{}与本地(8082)server建立连接", this.userId);
        
        //todo 本地client与源server(8081)连接
        Request requestRemote = new Request.Builder()
                .url("ws://127.0.0.1:8081/api/notice/" + userId)
                .build();
        OkHttpClient webSocketClientRemote = new OkHttpClient.Builder()
                .build();
        WebSocket localClientRemote = webSocketClientRemote.newWebSocket(requestRemote, new CommonWSClient());
        log.info("本地server创建本地client,且本地client与远程(8082)server连接成功");
        
        return userId + "与本地server连接";
    }

    /**
     * @description: 当连接失败时,执行该方法
     **/
    @OnClose
    public void onClose(){
        WSCache.clients.remove(this.sid);
        System.out.println(this.sid+"连接断开");
    }

    /**
     * @description: 当收到client发送的消息时,执行该方法
     **/
    @OnMessage
    public void onMessage(String message, Session session) {
        System.out.println("-----------收到来自用户:" + this.userId + "的信息   " + message);
    }


    /**
     * @description: 当连接发生错误时,执行该方法
     **/
    @OnError
    public void onError(Throwable error){
        System.out.println("error--------系统错误");
        error.printStackTrace();
    }
}

websocket配置类:

复制代码
@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
}

3、消息源服务

消息源服务B只需要websocket服务用来发送消息即可,其实现与中间服务A的服务端相同。

服务:

复制代码
@Slf4j
@Component
@ServerEndpoint("/notice/{userId}")
public class WebSocketServer {
    //存储客户端session信息, {会话id:ws_session}
    public static Map<String, Session> clients = new ConcurrentHashMap<>();

    //存储把不同用户的客户端session信息集合 {userId, [会话id1,会话id2,会话id3,会话id4]}
    public static Map<String, Set<String>> connection = new ConcurrentHashMap<>();

    //会话id
    private String sid = null;

    //建立连接的用户id
    private String userId;

    /**
     * @description: 当与客户端的websocket连接成功时,执行该方法
     * @PathParam 获取ServerEndpoint路径中的占位符信息类似 控制层的 @PathVariable注解
     **/
    @OnOpen
    public void onOpen(Session session, @PathParam("userId") String userId){
        log.info("onOpen-->session.getRequestParameterMap():{}", session.getRequestParameterMap());
        this.sid = UUID.randomUUID().toString();
        this.userId = userId;
        clients.put(this.sid,session);
        //判断该用户是否存在会话信息,不存在则添加
        Set<String> clientSet = connection.get(userId);
        if (clientSet == null){
            clientSet = new HashSet<>();
            connection.put(userId,clientSet);
        }
        clientSet.add(this.sid);
        System.out.println(this.userId + "用户建立连接," + this.sid+"连接开启!");
    }

    /**
     * @description: 当连接失败时,执行该方法
     **/
    @OnClose
    public void onClose(){
        clients.remove(this.sid);
        System.out.println(this.sid+"连接断开");
    }

    /**
     * @description: 当收到客户端发送的消息时,执行该方法
     **/
    @OnMessage
    public void onMessage(String message, Session session) {
        System.out.println("-----------收到来自用户:" + this.userId + "的信息   " + message);
        //自定义消息实体
        MsgInfo msgInfo = new MsgInfo()
                .setUserKey(this.userId)
                .setMassage("服务端-" + System.currentTimeMillis() + ":已收到用户" +
                        this.userId + "的信息: " + message);
        sendMessageByUserId(this.userId,  msgInfo);
    }


    /**
     * @description: 当连接发生错误时,执行该方法
     **/
    @OnError
    public void onError(Throwable error){
        System.out.println("error--------系统错误");
        error.printStackTrace();
    }

    /**
     * @description: 通过userId向用户发送信息
     * 该类定义成静态可以配合定时任务实现定时推送
     **/
    public static void sendMessageByUserId(String userId, MsgInfo msgInfo){
        if (!StringUtils.isEmpty(userId)) {
            Set<String> clientSet = connection.get(userId);
            //用户是否存在客户端连接
            if (Objects.nonNull(clientSet)) {
                Iterator<String> iterator = clientSet.iterator();
                while (iterator.hasNext()) {
                    String sid = iterator.next();
                    Session session = clients.get(sid);
                    //向每个会话发送消息
                    if (Objects.nonNull(session)){
                        try {
                            //同步发送数据,需要等上一个sendText发送完成才执行下一个发送
                            ObjectMapper mapper = new ObjectMapper();
                            session.getBasicRemote().sendText(mapper.writeValueAsString(msgInfo));
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }

    @Scheduled(cron = "0/10 * * * * ?")
    public void testSendMessageByCron(){
        log.info("-----------模拟消息开始发送--------------");
        //模拟两个用户100和200
        MsgInfo msg100 = new MsgInfo()
                .setUserKey("100")
                        .setMassage("这是8081发给用户100的消息" + System.currentTimeMillis());
        sendMessageByUserId("100", msg100);
        MsgInfo msg200 = new MsgInfo()
                .setUserKey("200")
                .setMassage("这是8081发给用户200的消息" + System.currentTimeMillis());
        sendMessageByUserId("200", msg200);
    }
}

4、测试

我们使用: wss在线测试工具进行测试;

1、 打开两个该工具窗口,分别模拟用户100和用户200,这两个用户都连接中间服务A(端口8082的服务);

2、分别启动消息源服务B和中间服务A

此时在服务B控制台我们可以看到:

我们模拟的消息发送已经在给用户100和用户200发送,因为我们的用户100和用户200均没有与中间服务A建立连接,故此时测试界面看不到消息;

当我们在用户100的模拟界面点击"开启连接"后,可以在右侧看到发给用户100的模拟消息:

之后我们再打开用户200的连接:

好了,到这里就结束了,有任何问题请积极指出,此例子只是个例子,并未经受任何生产的测试,欢迎讨论沟通:)

相关推荐
用户908324602732 天前
Spring AI 1.1.2 + Neo4j:用知识图谱增强 RAG 检索(上篇:图谱构建)
java·spring boot
用户8307196840823 天前
Spring Boot 集成 RabbitMQ :8 个最佳实践,杜绝消息丢失与队列阻塞
spring boot·后端·rabbitmq
Java水解3 天前
Spring Boot 视图层与模板引擎
spring boot·后端
Java水解3 天前
一文搞懂 Spring Boot 默认数据库连接池 HikariCP
spring boot·后端
洋洋技术笔记3 天前
Spring Boot Web MVC配置详解
spring boot·后端
初次攀爬者4 天前
Kafka 基础介绍
spring boot·kafka·消息队列
用户8307196840824 天前
spring ai alibaba + nacos +mcp 实现mcp服务负载均衡调用实战
spring boot·spring·mcp
Java水解4 天前
SpringBoot3全栈开发实战:从入门到精通的完整指南
spring boot·后端
初次攀爬者5 天前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq
花花无缺5 天前
搞懂@Autowired 与@Resuorce
java·spring boot·后端