基于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的连接:

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

相关推荐
MasterSpring5 小时前
spring cloud gateway客户端websocket断开连接,服务侧连接没有关闭的问题处理
websocket·spring cloud·gateway
多多*7 小时前
每天一道面试题之浅浅讲一下java5中的自动装箱和自动拆箱
java·开发语言·spring boot·后端·spring
hummhumm8 小时前
数据结构第3节: 抽象数据类型
数据结构·spring boot·spring·spring cloud·java-ee·maven·intellij-idea
qxlxi9 小时前
【SpringBoot】SpringBoot内置Servlet容器源码分析-Tomcat
spring boot·servlet·tomcat
破晓的历程9 小时前
Spring Boot的无缝衔接:深入解析与实践
数据库·spring boot·后端
weixin_teach007x9 小时前
springboot+vue+mybatis图书馆借阅管理系统+PPT+论文+讲解+售后
java·数据库·vue.js·spring boot·mybatis
容若只如初见11 小时前
项目实战--Spring Boot 3整合Flink实现大数据文件处理
java·spring boot·flink
java66666888812 小时前
Spring Boot中的微服务通信方式
spring boot·后端·微服务
垫脚摸太阳13 小时前
springboot三层架构详细讲解
spring boot·后端·架构
@Murphy13 小时前
Spring Boot logback 日志文件配置
spring boot·后端·logback