架构
架构图角色分析:
- 生产者client: 发送send命令到某个目的地址(destination)的client.
- 消费者client: 订阅某个目的地址(destination), 并接收此目的地址所推送过来的消息的client.
- request channel: 一组用来接收生产者client所推送过来的消息的线程池.
- response channel: 一组用来推送消息给消费者client的线程池.
- broker channel:一组用来传输服务端应用程序向消息代理broker发送的消息.
- broker: 消息队列管理者. 简单讲就是记录哪些client订阅了哪个目的地址(destination).
- 应用目的地址(图中的”/app”): 发送到这类目的地址的消息在到达broker之前, 会先路由到由应用写的某个方法. 相当于对进入broker的消息进行一次拦截, 目的是针对消息做一些业务处理.
- 非应用目的地址(图中的”/topic”): 发送到这类目的地址的消息会直接转到broker. 不会被应用拦截.
- SimAnnotatonMethod: 发送到应用目的地址的消息在到达broker之前, 先路由到的方法. 这部分代码是由应用控制的.
消息生产与消费流程:
- 生产者通过发送一条SEND命令消息到某个目的地址(destination)
- 服务端request channel接受到这条SEND命令消息
- 如果目的地址是应用目的地址则转到相应的由应用自己写的业务方法做处理, 再转到broker.
- 如果目的地址是非应用目的地址则直接转到broker.
- broker通过SEND命令消息来构建MESSAGE命令消息,再通过response channel推送MESSAGE命令消息给所有订阅此目的的消费者.
添加websocket依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
配置简单消息代理
spring websocket 提供了一个基于内存的broker实现:SimpleBrokerMessageHandler,简单broker有以下缺点:
- 对于任何形式的目的地址(destination)都只能是发布订阅模式(topic),没有负载均衡模式(queue)
- 只能单点,也就是如果启动两个服务端实例,他们之间的简单broker是不共享数据的
@Configuration
// 注解开启使用STOMP协议来传输基于代理的消息,这时控制器支持使用@MessageMapping
@EnableWebSocketMessageBroker
public class WebSocketConfig4Stomp implements WebSocketMessageBrokerConfigurer {
/**
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp") // 设置websocket端点
.setAllowedOrigins("*") // 允许跨域请求
.setHandshakeHandler(clientHandshakeHandler()) // 握手处理器
.addInterceptors(clientHandshakeInterceptor()) // 握手拦截器
.withSockJS();// 指定使用SockJS协议
}
/**
*
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 使用spring提供的简单内存消息代理
registry.enableSimpleBroker("/queue", "/topic")
.setHeartbeatValue(new long[]{10000l, 10000l}) // 配置服务端期望发送心跳和接收心跳的间隔
.setTaskScheduler(new DefaultManagedTaskScheduler()); // 配置发送心跳的scheduler;
// 配置服务端接收消息的地址前缀与@MessageMapping路径组合使用
registry.setApplicationDestinationPrefixes("/app");
// 配置点对点使用的订阅前缀,默认是"/user" 例如:(@link org.springframework.messaging.simp.user.DefaultUserDestinationResolver)
// 客户端订阅:/user/queue/message
// 服务器推送指定用户:/user/{userId}/queue/message
registry.setUserDestinationPrefix("/user");
}
/**
* 输入通道参数设置
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.taskExecutor()
// 核心线程数默认为系统核数*2.
// 如果业务没大量io操作, client与server网络情况情况良好, 则默认配置就可以.
// 如果业务方法有大量io操作, 那应当适当加大request channel的线程数, 以充分利用cpu.
.corePoolSize(8) // 设置消息输入通道的线程池线程数
.maxPoolSize(64)// 最大线程数
// queueCapacity的默认配置是无限大;
// 如果是无限大, 那么线程数则永远是核心线程数.
// 只能当队列容积不够用时, 实际线程数才会大于核心线程数.
.queueCapacity(1000) // 队列容积(默认Integer.MAX_VALUE)
.keepAliveSeconds(60);// 线程活动时间
registration.interceptors(clientInboundChannelInterceptor());
}
/**
* 输出通道参数设置
*/
@Override
public void configureClientOutboundChannel(ChannelRegistration registration) {
// 如果client与server之间网络连接不可控, 比如通过外网连接手机上的客户端, 则应该当适当加大response channel的线程数
registration.taskExecutor()
.corePoolSize(16)
.maxPoolSize(32)
.queueCapacity(2000)
.keepAliveSeconds(60);
registration.interceptors(clientOutboundChannelInterceptor());
}
/**
* 消息传输参数配置
*/
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.setMessageSizeLimit(64 * 1024) // 设置接收客户端消息字节数大小
.setSendBufferSizeLimit(512 * 1024)// 设置消息缓存大小
.setSendTimeLimit(15000); // 设置服务端消息发送时间限制毫秒
}
/**
* websocket握手处理器
*
* @return
*/
@Bean
public ClientHandshakeHandler clientHandshakeHandler() {
return new ClientHandshakeHandler();
}
/**
* @return websocket握手拦截器
*/
@Bean
public ClientHandshakeInterceptor clientHandshakeInterceptor() {
return new ClientHandshakeInterceptor();
}
/**
* @return stomp入站通道拦截器
*/
@Bean
public ChannelInterceptor clientInboundChannelInterceptor() {
return new ClientInboundChannelInterceptor();
}
/**
* @return stomp出站通道拦截器
*/
@Bean
public ChannelInterceptor clientOutboundChannelInterceptor() {
return new ClientOutboundChannelInterceptor();
}
}
配置解读:
1. registerStompEndpoints(StompEndpointRegistry registry):添加服务端点以及相关配置
- addEndpoint("/stomp"):表示添加了一个"/stomp"端点,客户端就可以通过这个端点来进行连接
- setAllowedOrigins("*"):设置允许跨域访问的站点
- setHandshakeHandler():设置websocket握手处理器
- addInterceptors():添加websocket握手拦截器(用于客户端连接认证等)
- withSockJS():指定使用SockJS协议
2. configureMessageBroker(MessageBrokerRegistry registry):配置消息代理
- registry.enableSimpleBroker("/queue", "/topic"):配置消息代理的前缀,在简单消息代理中前缀可自定义,符合代理前缀的消息都会被SimpleBrokerMessageHandler路由到message broker进行处理。由上述消息架构图可知,对于生产者而言,不经过消息代理的消息将不会被处理,这对于消费者订阅同样适用。在外部消息代理中,代理前缀将由代理来指定,在下篇文章中将会提到。
- setHeartbeatValue()配置的是server返回client的CONNECTED命令消息中heart-beat header的值.参考stomp协议, 第一值表示server最小能保证发的心跳间隔毫秒数, 第二个值代表server希望client发送心跳间隔毫秒数.返回10000,10000并不代表client与server都是10秒发心跳,最终client与server到底以什么频率发心跳还需和client发送的CONNECT命令消息中的heart-beat header中的值进行协商。
- registry.setApplicationDestinationPrefixes("/app"):配置可被服务端@SubscribeMapping或@MessageMapping注解的业务方法拦截的消息前缀。在本例中,符合"/app"前缀的消息都会被SimpAnnotationMethodMessageHandler路由到上述注解的业务方法之后手动对其进行转发,如果转发的前缀符合消息代理的前缀,将再路由到消息代理进行处理。
- registry.setUserDestinationPrefix("/user"):配置点对点消息的前缀,默认是"/user"。符合该前缀的消息将被UserDestinationMessageHandler处理器处理,它会根据消息目的用户获取其会话ID然后把消息目的转换为实际消息目的,最后转发给消息代理进行处理。消息目的解析参考:DefaultUserDestinationResolver,下面贴出官方文档说明:
* When a user attempts to subscribe, e.g. to "/user/queue/position-updates", * the "/user" prefix is removed and a unique suffix added based on the session * id, e.g. "/queue/position-updates-useri9oqdfzo" to ensure different users can * subscribe to the same logical destination without colliding. * * When sending to a user, e.g. "/user/{username}/queue/position-updates", the * "/user/{username}" prefix is removed and a suffix based on active session id's * is added, e.g. "/queue/position-updates-useri9oqdfzo".
3. configureClientInboundChannel和configureClientOutboundChannel:配置输入输出通道
- 其中corePoolSize为核心线程数,maxPoolSize最大线程数,queueCapacity队列容积。这里需要注意一点,queueCapacity的默认配置是无限大,如果是无限大,那么线程数则永远是核心线程数。只有当队列容积不够用时, 实际线程数才会大于核心线程数。
- 性能优化:主要对request channel和response channel这两个线程池进行配置,默认这两个线程池核心线程数为系统核数*2。如果业务没大量IO操作,client与server网络情况情况良好,则默认配置就可以。如果业务方法有大量io操作,那应当适当加大request channel的线程数,以充分利用CPU。如果client与server之间网络连接不可控,比如通过外网连接手机上的客户端,则应该当适当加大response channel的线程数。(注:这里的业务方法就是指在到达broker之前被业务拦截的那些方法)
- registration.interceptors():设置通道拦截器,用于身份认证、订阅或消息拦截等。
4. configureWebSocketTransport(WebSocketTransportRegistration registry):由于server到client的网络状况以及client处理能力很难预测,合理配置response channel线程数相对比较困难,为此spring websocket提供了以下参数进行优化
- setMessageSizeLimit:设置接收客户端消息字节数大小
- setSendBufferSizeLimit: 设置消息缓存大小,发送不到client的消息都会先缓存, 如果缓存满则会尝试关闭session
- setSendTimeLimit(15000):设置服务端消息发送时间限制毫秒,如果在配置时间内发送不到client则会尝试关闭session
- 注:只有当消息累积时上述配置才启作用。比如如果只发一条消息到client,而这条消息被阻塞,上述配置并不启作用。
只能当第二条消息进入队列并阻塞,上述配置才启作用。
通过入站通道拦截器进行身份认证
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class ClientInboundChannelInterceptor implements ChannelInterceptor {
/**
* 客户端入站消息前置处理
*
* @return 返回null将不会处理客户端的相应指令消息
*/
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && accessor.getCommand() != null) {
// 判断客户端的连接状态
switch(accessor.getCommand()) {
case CONNECT:
List<String> tokens = accessor.getNativeHeader("token");
if(tokens != null && !tokens.isEmpty()) {
Principal principal = verifyToken(tokens.get(0));
if (principal != null) {
// 设置当前访问器的认证用户: User类需要实现Principal接口
accessor.setUser(principal);
return message;
}
}
// 手动抛出异常以便触发客户端errorCallback
throw new RuntimeException("User authentication failure");
}
}
return message;
}
/**
* 解析token并获取认证用户信息
*
* @param token
* @return
*/
private Principal verifyToken(String token) {
if(true) {
return new ClientAuthInfo(token);
}
return null;
}
}
websocket控制层
package com.bo.msgpush.controller;
import java.security.Principal;
import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.stereotype.Controller;
import com.bo.msgpush.domain.ClientMessage;
@Controller
public class StompTestController {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${server.port}")
private String appport;
@Resource
private SimpUserRegistry simpUserRegistry;// 用来获取连接的客户端信息
@Resource
private SimpMessagingTemplate simpMessagingTemplate;// 用于向消息代理发送消息
@MessageMapping("/send-notice")
@SendTo("/topic/notice")
public ClientMessage notice(ClientMessage clientMessage) {
logger.info("[port: " + appport + "|online: " + simpUserRegistry.getUserCount() + "] 广播消息:" + clientMessage);
return new ClientMessage(clientMessage.getFromUserId() + " said: " + clientMessage.getMessage());
}
@MessageMapping("/send-msg")
@SendToUser(destinations = "/queue/msg", broadcast = false)
public ClientMessage sendToUser(ClientMessage clientMessage, Principal principal) {
logger.info("[port: " + appport + "|online: " + simpUserRegistry.getUserCount() + "] P2P消息:" + clientMessage);
ClientMessage serverMessage = new ClientMessage(clientMessage.getFromUserId() + " said to You: " + clientMessage.getMessage());
simpMessagingTemplate.convertAndSendToUser(clientMessage.getToUserId().trim(), "/queue/msg", serverMessage);
return new ClientMessage("You said to " + clientMessage.getToUserId() + ": " + clientMessage.getMessage());
}
@MessageExceptionHandler
@SendToUser(destinations = {"/queue/error"}, broadcast = false)
public ClientMessage handleExceptions(Exception e, ClientMessage clientMessage) {
logger.error("Error handling message: {}, exp: {}", clientMessage, e.getMessage());
ClientMessage serverMessage = new ClientMessage("System: sorry, send msg error..");
return serverMessage;
}
}
代码解读:
- SimpUserRegistry和SimpMessagingTemplate:SimpUserRegistry用来获取连接的客户端信息,SimpMessagingTemplate是spring提供的用来发送消息的模板,这两个实例可以直接注入。
- @MessageMapping注解:用于接收处理指定目的地前缀的消息,前缀配置参考之前配置registry.setApplicationDestinationPrefixes("/app") 本例中以"/app"为例,当客户端发送消息的目的地为"/app/send-notice"时,@MessageMapping("/send-notice")注解方法将接收消息。当@MessageMapping方法返回一个值时,默认情况下,该值通过配置的MessageConverter序列化为有效负载,然后作为消息发送到“brokerChannel”,通过代理向用户广播。 出站消息的目的地与入站消息的目的地相同,但前缀为“/ topic”。
- @SubscribeMapping注解:用于接收订阅消息。消息前缀与用法和@MessageMapping基本一致,不同的是,默认情况下方法返回值消息不是发送到“brokerChannel”而是发送到“clientOutboundChanne,即不经过代理,消息目的地与订阅目的地相同。
- @SendTo:该注解只有一个参数,表示消息发送目的地。被该注解修饰的方法将会把返回值消息发送到注解指定的目的地。订阅了该目的地的用户都将收到此消息。
- @SendToUser:该注解有2个参数,destinations表示消息发送的目的地,broadcast表示是否将消息广播到用户其他的存活会话。被该注解修饰的方法将会把返回值消息发送到当前会话用户的目的地,该目的地前缀必须是配置的代理前缀。
- @MessageExceptionHandler:处理 @MessageMapping方法所抛出的异常,默认捕获所有异常,可以在注释中声明所要捕获的异常类型。如果要获取对异常实例的访问权限,则可以通过方法参数声明。@MessageExceptionHandler方法支持灵活的方法签名,并支持与@MessageMapping方法相同的方法参数类型和返回值。
- SimpMessagingTemplate:用于服务端向客户端推送消息,有2个方法。convertAndSend(destination, message)用于向指定目的地发送消息;convertAndSendToUser(username, destination, message)用于向指定用户发送消息(目标用户必须已经认证),该方法会将消息目的地转化为"/user/{username}/{destination}","user"前缀是上面配置提到的点对点消息前缀,最终经过UserDestinationMessageHandler处理转化为实际目的地:/{destination}-user{sessionId}。客户端只需订阅"/user/{destination}"就能收到发给自己的消息,实际上这个订阅地址最终转化为:/{destination}-user{sessionId}
H5客户端
function connect() {
// SockJS所处理的URL是 "http://"或 "https://模式,而不是 "ws://"或"wss://"
// 连接SockJS的endpoint
socket = new SockJS(server_path);
// 使用STMOP子协议的WebSocket客户端
stompClient = Stomp.over(socket);
// client will send heartbeats every 20000ms
stompClient.heartbeat.outgoing = 25000;
// client does not want to receive heartbeats from the server with config 0
stompClient.heartbeat.incoming = 25000;
// 向服务器发起websocket连接并发送CONNECT帧
stompClient.connect(
// 携带stomp header信息
{
"token" : currentUser // 标识当前客户端,用于认证
},
// 连接成功回调函数
function connectCallback(frame) {
// 显示聊天室信息
setConnected(true);
// 订阅服务器广播消息 -> 取消订阅:topic_sub.unsubscribe();
var topic_sub = stompClient.subscribe("/topic/notice", function(response) {
showNotice(JSON.parse(response.body).message);
});
// 订阅发送到当前用户的P2P消息 -> 取消订阅:user_sub.unsubscribe();
var user_sub = stompClient.subscribe("/user/queue/msg", function(response) {
showNotice(JSON.parse(response.body).message);
});
// 订阅消息发送处理出错的消息(使用自定义持久化队列无需订阅此地址使用p2p消息队列即可)
var error_sub = stompClient.subscribe("/user/queue/error", function(response) {
showNotice(JSON.parse(response.body).message);
});
},
// 连接失败回调函数
function errorCallBack(error) {
// 连接失败时(服务器响应 ERROR帧的回调方法
if (stompClient != null) {
stompClient.disconnect();
}
if (socket != null) {
socket.close();
}
showNotice("connected error ...");
alert("connected error");
});
}
function disconnect() {
if (stompClient != null) {
stompClient.disconnect();
}
if (socket != null) {
socket.close();
}
setConnected(false);
}
/** 发送广播消息 */
function sendNotice() {
var notice = $("#notice").val();
var clientMessage = {
"fromUserId" : currentUser,
"toUserId" : "all",
"message" : notice
};
// 第一个参数:json负载消息发送的 目的地; 第二个参数:是一个头信息的Map,它会包含在 STOMP帧中;第三个参数:负载消息
stompClient.send("/app/send-notice", {}, JSON.stringify(clientMessage));
$("#notice").val('');
}
/** 发送点对点消息 */
function sendToUser() {
var msg = $("#notice").val();
var clientMessage = {
"fromUserId" : currentUser,
"toUserId" : $("#toUserId").val(),
"message" : msg
};
stompClient.send("/app/send-msg", {}, JSON.stringify(clientMessage));
$("#notice").val('');
}