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