spring websocket项目实践

本文基于spring websocket总结的项目实践经验,希望能够帮助大家

Websocket、sockjs、stomp简介

websocket

WebSocket protocol是HTML5一种新的协议。它实现了浏览器与服务器全双工通信(full-duple)。

一开始的握手需要借助HTTP请求完成,在 WebSocket API,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道,两者之间就直接可以数据互相传送。在此WebSocket 协议中,为我们实现即时服务带来了两大好处:
1. Header
互相沟通的Header是很小的,大概只有 2 Bytes
2. Server Push
服务器的推送,服务器不再被动的接收到浏览器的request之后才返回数据,而是在有新数据时就主动推送给浏览器。

sockJs

SockJS 是一个浏览器上运行的 JavaScript 库,如果浏览器不支持 WebSocket,该库可以模拟对 WebSocket 的支持,实现浏览器和 Web 服务器之间低延迟、全双工、跨域的通讯通道。

stomp

STOMP即Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。STOMP协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。

STOMP的客户端和服务器之间的通信是通过“帧”(Frame)实现的,每个帧由多“行”(Line)组成。
第一行包含了命令,然后紧跟键值对形式的Header内容。
第二行必须是空行。
第三行开始就是Body内容,末尾都以空字符结尾。

下图是一个STOMP消息帧样例:
stomp帧

spring websocket

Spring4.0版本的发布,提供了对Websocket的支持,它不仅在基于JSR-356容器之上提供Websocket API,而且也为那些不支持或者不允许使用Websocket的浏览器和网络提供了一些候选项。更重要的是,它为在网络应用中构建Websocket形式的消息架构提供了基础。

spring websocket使用SockJS protocol作为候选项,解决了部分浏览器不兼容websocket的问题,能够提供最好和最广泛的数据传输方式,此外还提供了stomp协议的支持。spring支持使用Controller,同时处理Http请求以及websocket消息,而且非常容易地将消息广播给感兴趣的websocket客户端,或者特定的客户端。

开发实战

在使用spring websocket开发应用程序时,最简便的方法就是继承TextWebSocketHandler,但是需要自行维护WebSocketSession,并且需要对数据进行解析,此外,由于websocket发送的消息类型很多,因此还需要写很多的判断语句,不利于后续产品的更新迭代,下方是部分示例代码:

/**
 * websocket handler for chat online 
 * @author huangxf
 * @date 2016年11月3日
 */
public class ChatWebSocketHandler extends TextWebSocketHandler {

    private static final Log logger = LogFactory.getLog( ChatWebSocketHandler.class );

    @Override
    public void afterConnectionEstablished(WebSocketSession session)
            throws Exception {
        logger.info( "Websocket connection was established." );
    }

    @Override
    protected void handleTextMessage(WebSocketSession session,
            TextMessage message) throws Exception {
        String httpSessionId = (String)session.getAttributes().get( HttpSessionHandshakeInterceptor.HTTP_SESSION_ID_ATTR_NAME );
        String sockSessionId = session.getId();
        logger.info( "============Http session id:" + httpSessionId );
        logger.info( "============Websocket session id:" + sockSessionId );
        logger.info( "Text message:" + message.getPayload() );
        TextMessage retMsg = new TextMessage( "欢迎进入移动商城在线客服系统,很高兴为您服务!" );
        session.sendMessage( retMsg );
    }

}

鉴于上述原因,在线客服触屏版需求开发中,采用了Spring Websocket+SockJs+Stomp技术框架,支持发布/订阅、点对点模式,在服务器、访客端之间实现了双向通信,具有以下特点:
1. 支持SpringMVC,和Controller开发类似,简单容易上手;
2. 针对无法使用websocket的浏览器,支持自动切换至SSE或者轮询方式;
3. 支持消息中间件;
4. 在实际的业务场景中,可能还需要扩展Spring websocket的功能。

websocket配置

websocket的配置,支持xml,同时也支持编码的方式,后者较为灵活方便,因此项目中采用了后者。在WebSocketBrokerConfig里面,主要是设置了消息代理、Stomp端点,以及Transport。

/**
 * 使用注解的方式集成Spring WebSocket,同时集成Spring Session
 * 避免在WebSocket会话期间Http Session失效
 * @author huangxf
 * @date 2016年11月14日
 */
@Configuration
@EnableWebSocket
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig extends AbstractSessionWebSocketMessageBrokerConfigurer<ExpiringSession> {

    @Resource
    private HandshakeInterceptor chatHandshakeInterceptor;

    @Resource
    private ApplicationEventPublisher eventPublisher;

    @Resource
    private WebSocketSessionManager webSocketSessionManager;

    /** 允许哪些域名的请求 */
    private final String[] ALLOWED_ORIGINS = {"*"};

    private static final Logger logger = LoggerFactory.getLogger( WebSocketBrokerConfig.class );

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {

        String size = JProperties.getMessage( "touch", "MessageBrokerRegistry.CACHE_LIMIT" );
        int cacheLimit = NumberUtils.toInt( size, 1024 );

        //设置DefaultSubscriptionRegistry的cacheLimit,尽量避免线程阻塞
        config.setCacheLimit( cacheLimit );

        //使用简单消息代理
        config.enableSimpleBroker("/topic", "/queue");

        //加入/app、/user的请求地址处理,/app-->发送消息@MessageMapping, /user-->订阅@SubscribeMapping,见Controller
        config.setApplicationDestinationPrefixes("/app", "/user");
    }

    @Override
    protected void configureStompEndpoints(StompEndpointRegistry registry) {
        //allowedOrigins配置可以拦截跨站域名请求
        registry.addEndpoint("/chat").setAllowedOrigins( ALLOWED_ORIGINS )
            .addInterceptors( chatHandshakeInterceptor ).withSockJS();
    }

    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {

        //设置发送超时时间
        registration.setSendTimeLimit( 5000 );

        //添加WebSocket的装饰工厂,便于监听Session状态
        registration.addDecoratorFactory( wsHandlerDecoratorFactory() );

    }

    @Bean
    public ChatWebSocketHandlerDecoratorFactory wsHandlerDecoratorFactory() {
        return new ChatWebSocketHandlerDecoratorFactory( eventPublisher, webSocketSessionManager );
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        super.configureClientInboundChannel(registration);
        String size = JProperties.getMessage( "touch", 
                "clientInboundChannel.taskExecutor.corePoolSize" );
        int poolSize = NumberUtils.toInt( size, Runtime.getRuntime().availableProcessors() * 2 );
        logger.info( "config clientInboundChannel.taskExecutor.corePoolSize:{}", poolSize );
        registration.taskExecutor().corePoolSize( poolSize );
    }

    @Override
    public void configureClientOutboundChannel(ChannelRegistration registration) {
        super.configureClientOutboundChannel(registration);
        String size = JProperties.getMessage( "touch", 
                "clientOutboundChannel.taskExecutor.corePoolSize" );
        int poolSize = NumberUtils.toInt( size, Runtime.getRuntime().availableProcessors() * 2 );
        logger.info( "config clientOutboundChannel.taskExecutor.corePoolSize:{}", poolSize );
        registration.taskExecutor().corePoolSize( poolSize );
    }
}

Controller开发

Spring message模块为我们提供了很多实用的注解,方便我们使用,请参照官方文档:
http://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html#websocket-stomp-handle-annotations

常用注解:
- @Controller:标识这个类被spring管理,与SpringMVC相同;
- @MessageMapping:标识websocket消息接收的映射方法;
- @SendToUser:点对点模式,将消息发给指定的客户端;
- @SubscribeMapping:订阅地址的映射方法

服务端主动推送消息

websocket是支持双向通讯的,在很多业务场景下,需要服务端主动地往触屏版客户端推送消息,spring websocket提供了发布订阅、点对点模式,实现数据交互。发布订阅模式,url地址以/topic开头,而点对点模式,url地址是以/queue开头。此外,spring还提供了@SendToUser注解,以及SimpMessagingTemplate,简化编码。
以下是触屏版访客端的订阅地址:
1. /queue/chat/newMsg:订阅新消息
2. /queue/chat/msgResponse:订阅消息响应
3. /queue/system/close:订阅会话关闭通知
4. /queue/system/newMsg:订阅系统新消息

推送订阅消息

在访客和客服建立好会话之后,就会创建websocket连接,并且订阅以上地址,同时访客会收到类似欢迎之类的消息。在这种场景下,需要在请求订阅地址的时候,返回该消息,编码如下。

/**
 * 订阅新消息 
 * @param accessor
 * @return LivechatResponse
 */
@SubscribeMapping("/queue/system/newMsg")
public LivechatResponse subcribeNewMsg( SimpMessageHeaderAccessor accessor ) {

    ClientPullRequestVO request = new ClientPullRequestVO();
    request.setClientId( getClientId( accessor ) );
    request.setSessionId( getChatSessionId( accessor ) );
    request.setTenantId( getTenantId( accessor )  );

    //获取未读消息,这个时候主要是系统提示消息
    try {
        ClientPullSessionResponseVO pullResp = sessionService.clientSessionPull( request );
        return new LivechatResponse( pullResp );
    } catch (Exception e) {
        log.error( "WebSocket获取系统消息异常", e );
        return new LivechatResponse( MessageCode.RUNTIME_EXCEPTION, "获取系统消息异常." );
    }

}

其中@SubscribeMapping是订阅的映射注解,客户端发起订阅时会调用该方法,并返回消息实体。但是,值得注意的是,需要在MessageBrokerRegistry中将”/user”路径添加到应用访问路径前缀中,否则在Contronller中是接收不到对应的订阅请求的,因为客户端发送过来的地址是以/user开头的。

推送客服消息

下图是客服-访客的对话时序图,客服发送的消息到达dubbo之后,从当前的ChatSession中获取对应的websocket信息(包括tomcat节点),接着http异步通知对应的tomcat节点,然后调用Spring提供的消息模板SimpMessageTemplate的convertAndSend方法,使用点对点的模式,将消息推送给对应的websocket客户端,这样访客端便可以收到客服发送的消息,相比传统的ajax轮询模式,实时性得以大幅度提高。

websocket-客服对话时序图

推送确认消息

访客发送消息之后,需要经过Tomcat和Spring线程池才会被Controller接收处理,然后再返回一个WebSocketMessageResponse,告知客户端发送成功或者失败,代码如下:

/**
 * Websocket客户端发送消息,异步通知/queue/chat/msgResponse
 * @param msg
 * @param accessor
 * @return LivechatResponse
 */
@MessageMapping("/sendMessage")
@SendToUser("/queue/chat/msgResponse")
public WebSocketMessageResponse sendMessage( WebSocketRequestMessage msg, SimpMessageHeaderAccessor accessor ) {

    String requestId = msg.getWebsocketRequestId();
    try {

        //设置其它数据
        msg.setTenantId( getTenantId( accessor ) );
        msg.setUserId( getClientId( accessor ) );
        msg.setSessionId( getChatSessionId( accessor ) );
        msg.setToUserId( getToAgentId( accessor ) );

        //调用dubbo服务发送消息
        MessageResponseVO msgResp = messageService.sendMessage( msg );

        //返回消息给websocket
        return new WebSocketMessageResponse( msgResp, requestId );
    }
    catch ( ServiceException e ) {
        log.error( "WebSocket消息接收异常", e );
        return new WebSocketMessageResponse( e.getErrorCode(), e.getErrorMsg(), requestId );
    }
    catch (Exception e) {
        log.error( "WebSocket消息接收异常", e );
        String errMsg = MessageCode.getMsg( MessageCode.SERVICE_EORROR );
        return new WebSocketMessageResponse( MessageCode.SERVICE_EORROR, errMsg, requestId );
    }
}

其中,@MessageMapping是消息接收的映射地址,@SendToUser是将数据推送给该websocket。这一块的逻辑,都是在spring中处理的。

Spring websocket扩展

处理WebsocketSession

WebSocketHandler是spring抽象的接口,用于处理Websocket消息,以及接受Lifecycle的事件通知,比如创建连接、关闭连接。如果我们希望额外处理websocket的这些事件通知,可以自己实现该接口,WebSocketHandler接口定义如下所示:

public interface WebSocketHandler {

    void afterConnectionEstablished(WebSocketSession session) throws Exception;

    void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception;

    void handleTransportError(WebSocketSession session, Throwable exception) throws Exception;

    void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception;

    boolean supportsPartialMessages();

}

WebSocketHandler这个接口提供了afterConnectionEstablished、afterConnectionClosed等API方法,虽然spring提供了SessionConnectedEvent、SessionDisconnectEvent事件通知,但是只能获取websocketId,如果我们想添加websocket连接的监听器,只需要扩展WebscoketHandler即可,而扩展WebsocketHandler需要借助DecoratoryFactory。如前面的WebSocketBrokerConfig配置类所示,需要实现WebSocketHandlerDecoratorFactory接口,并添加到WebSocketTransportRegistration这个注册类中

首先,实现WebSocketHandlerDecoratorFactory接口,实现decorate方法,返回自定义的ChatWebSocketHandler(实现WebSocketHandler接口),而这个实现类里面做的事情就是管理WebSocketSession

public class ChatWebSocketHandlerDecoratorFactory implements WebSocketHandlerDecoratorFactory {

    private final ApplicationEventPublisher eventPublisher;

    private final WebSocketSessionManager webSocketSessionManager;

    public ChatWebSocketHandlerDecoratorFactory( ApplicationEventPublisher eventPublisher, 
            WebSocketSessionManager webSocketSessionManager ) {
        this.eventPublisher = eventPublisher;
        this.webSocketSessionManager = webSocketSessionManager;
    }

    @Override
    public WebSocketHandler decorate(WebSocketHandler handler) {
        return new ChatWebSocketHandler( handler, eventPublisher, webSocketSessionManager );
    }

}

然后,我们把ChatWebSocketHandlerDecoratorFactory添加到WebSocketTransportRegistration注册器中,这样便可以使用自定义的WebSocketHandler了

@Configuration
@EnableWebSocket
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig extends AbstractSessionWebSocketMessageBrokerConfigurer<ExpiringSession> {

    // other code......

    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {

        //设置发送超时时间
        registration.setSendTimeLimit( 5000 );

        //添加WebSocket的装饰工厂,便于监听Session状态
        registration.addDecoratorFactory( wsHandlerDecoratorFactory() );

    }

    @Bean
    public ChatWebSocketHandlerDecoratorFactory wsHandlerDecoratorFactory() {
        return new ChatWebSocketHandlerDecoratorFactory( eventPublisher, webSocketSessionManager );
    }
}

websocket拦截器开发

HandshakeInterceptor

在实际开发过程中,往往需要关联http中的信息,或者是限制websocket的连接,这个时候就需要在websocket握手阶段进行处理,在Spring里面只需要实现HandshakeInterceptor接口,并且将其实现类注册到StompEndpointRegistry即可。

而HttpSessionHandshakeInterceptor是spring提供的实现类,它将HttpSession中的信息,保存至WebSocketSession中方便后续读取,这样便实现了HttpSession与WebsocketSession关联。

如果我们现在有这样的一个需求:限制websocket连接,不允许未登录、未建立会话的用户进行连接。如下所示,ChatShakeHandInteceptor继承HttpSessionHandshakeInterceptor,在握手阶段校验当前用户是否已登录,是否与客服已经建立对话,否则返回false拒绝握手协议

/**
 * Websocket建立连接阶段的拦截器
 * <p>
 * 主要用于获取http中的session信息、校验是否有chatSession
 * </p>
 * @author huangxf
 * @date 2016年11月7日
 */
public class ChatShakeHandInteceptor extends HttpSessionHandshakeInterceptor {

    private static final Logger logger = LoggerFactory.getLogger( ChatShakeHandInteceptor.class );

    /**
     * 访客-客服的对话ID
     */
    public static final String CHAT_SESSION_ID = "chatSessionId";

    private final String devMode = System.getProperty("Websocket.DevMode");

    @Override
    public boolean beforeHandshake(ServerHttpRequest request,
            ServerHttpResponse response, WebSocketHandler wsHandler,
            Map<String, Object> attributes) throws Exception {

        // 调用父类的方法,将HttpSession中的信息copy到attributes中
        super.beforeHandshake(request, response, wsHandler, attributes);

        //如果不存在http session id以及会话id,则拒绝连接,返回未授权错误码
        if ( StringUtils.isBlank( sessionId ) || StringUtils.isBlank( chatSessionId ) ) {
            if ( !"true".equals( devMode ) ) {
                response.setStatusCode( HttpStatus.UNAUTHORIZED );
                return false;
            }
        }

        response.getHeaders().add( "server", "LiveChatWebSocketServer" );

        return true;
    }

}

接下来,把ChatShakeHandInteceptor拦截器注册到StompEndpointRegistry中,请参考前面的WebSocketBrokerConfig#configureStompEndpoints方法

ChannelInterceptor

使用ChannelInterceptor,可以对接收、发送的前后进行逻辑处理,在某些场景下,可以改变消息的内容,以及拦截消息,例如spring session中为了维护HttpSession的有效性便在preSend接口中更新了时间戳。
首先,实现ChannelInterceptor接口,或者继承ChannelInterceptorAdapter,然后将实现注册到ChannelRegistration即可,例如:channelRegistration.setInterceptors( channelInterceptor );

自定义事件

spring为我们提供了很多事件,比如SessionConnectEvent、SessionConnectedEvent、SessionDisconnectEvent、SessionSubscribeEvent、SessionUnsubscribeEvent,我们只需要实现ApplicationListener 接口,并且注册到spring容器中即可,如下图所示:

/**
 * 消息订阅监听器,websocket进行消息订阅时触发
 * @author huangxf
 * @date 2016年11月17日
 */
@Component
public class SessionSubscribeListener implements ApplicationListener<SessionSubscribeEvent> {

    private static final Logger logger = LoggerFactory.getLogger( SessionConnectedListener.class );

    @Override
    public void onApplicationEvent(SessionSubscribeEvent event) {
        Message<byte[]> message = event.getMessage();
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        StompCommand command = accessor.getCommand();
        if (command.equals(StompCommand.SUBSCRIBE)) {
            String sessionId = accessor.getSessionId();
            String subscriptionId = accessor.getSubscriptionId();
            String destination = accessor.getDestination();
            logger.debug("SessionSubscribe, sessionId:{},subscriptionId:{},destination:{}", 
                    sessionId, subscriptionId, destination );
        } 
    }

}

但是,在项目开发过程中,需要获取到WebSocketSession对象,而spring并没有定义这样的Event,只能自定义事件了。spring不开放这个接口想必是有原因的,如果我们对这个WebSocketSession进行不正当操作,肯定是不安全的。

首先,实现ApplicationEvent接口,如下所示:

/**
 * WebSocket建立连接的事件
 * @author huangxf
 * @date 2016年11月15日
 */
public class WebsocketConnectedEvent extends ApplicationEvent {

    private static final long serialVersionUID = 8925298824870091591L;

    private WebSocketSession session;

    public WebsocketConnectedEvent(Object source, WebSocketSession session) {
        super(source);
        this.session = session;
    }

    public WebSocketSession getSession() {
        return this.session;
    }

}

然后,实现ApplicationListener接口,并且将其注册到spring容器中;

有了这些还不够,我们需要在合适的地方触发这个事件即可,可以借助前面的ChatWebSocketHandler,核心代码如下:

/**
 * WebSocketHandler用于处理WebSocket连接、断开连接等事件,
 * 以及保存WebSocket会话
 * @author huangxf
 * @date 2016年11月17日
 */
public class ChatWebSocketHandler extends WebSocketHandlerDecorator {

    private final ApplicationEventPublisher eventPublisher;

    private final WebSocketSessionManager webSocketSessionManager;

    private static final Logger logger = LoggerFactory.getLogger( ChatWebSocketHandler.class );

    public ChatWebSocketHandler(WebSocketHandler delegate, ApplicationEventPublisher eventPublisher, 
            WebSocketSessionManager webSocketSessionManager) {
        super(delegate);
        this.eventPublisher = eventPublisher;
        this.webSocketSessionManager = webSocketSessionManager;
    }

    @Override
    public void afterConnectionEstablished(WebSocketSession session)
            throws Exception {

        logger.info( "WebSocket连接已建立,websocketId:{}, userId:{}", session.getId(), 
                WebSocketUtils.getAttribute( session, SsoUtil.LIVECHATCLIENTPHONE ) );

        //推送连接事件
        publishEvent( new WebsocketConnectedEvent(this, session) );

        //保存会话
        webSocketSessionManager.save( session );

        super.afterConnectionEstablished(session);

    }

    @Override
    public void afterConnectionClosed(WebSocketSession session,
            CloseStatus closeStatus) throws Exception {

        logger.info( "WebSocket连接已关闭,websocketId:{}, userId:{}", session.getId(), 
                WebSocketUtils.getAttribute( session, SsoUtil.LIVECHATCLIENTPHONE ) );

        //从会话管理容器中移除
        webSocketSessionManager.remove( session.getId() );

        //推送关闭事件
        publishEvent( new WebsocketClosedEvent(this, session, closeStatus) );

        super.afterConnectionClosed(session, closeStatus);

    }

    private void publishEvent(ApplicationEvent event) {
        try {
            eventPublisher.publishEvent(event);
        }
        catch (Throwable ex) {
            //忽略异常
            logger.error( "Failed to publisher event.", ex );
        }
    }

}

前端开发

需要在页面上引用sockjs-1.1.1.js、stomp.js,另外,需要根据服务端提供的地址,进行连接、订阅即可。spring官网上有提供相关的例子,请自行参考

项目实践问题

如何维护Spring WebsocketSession

首先,抽象WebSocketSessionManager接口,然后实现该接口,内部实现使用ConcurrentHashMap保存WebsocketSession,同时为了避免异常关闭WebsocketSession而SessionManager无法感知的情况,在getSession的时候返回了WebsocketSession的装饰类

然后,在合适的地方,将WebsocketSession添加到这个容器中管理即可,项目中是这样做的:继承WebSocketHandlerDecoratorFactory,并重写decorate方法,返回自定义的ChatWebSocketHandler,这样一来,可以在合适的时机将WebsocketSession从容器中添加/移除即可。

WebsocketSession监听器

在实际应用场景中,往往需要对Websocket的连接、断开事件进行监听,见前面的ChatWebSocketHandler代码

维持HttpSession有效性

在websocket对话过程中,可能长时间无HTTP请求,这样一来就会导致HttpSession失效,websocket会话也会断开。所以,在websocket对话中需要更新HttpSession时间戳,避免会话失效。要解决这个问题,要用到spring提供的拦截器ChannelInterceptor:

/**
 * Interface for interceptors that are able to view and/or modify the
 * {@link Message Messages} being sent-to and/or received-from a
 * {@link MessageChannel}.
 *
 * @author Mark Fisher
 * @author Rossen Stoyanchev
 * @since 4.0
 */
public interface ChannelInterceptor {

    Message<?> preSend(Message<?> message, MessageChannel channel);

    void postSend(Message<?> message, MessageChannel channel, boolean sent);

    void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, Exception ex);

    boolean preReceive(MessageChannel channel);

    Message<?> postReceive(Message<?> message, MessageChannel channel);

    void afterReceiveCompletion(Message<?> message, MessageChannel channel, Exception ex);

}

其实,spring session提供了解决方案,一方面需要在项目中使用spring session,另外一方面,需要在配置websocket的时候,继承AbstractSessionWebSocketMessageBrokerConfigurer即可。如果项目中未使用spring session,只能另行编码,使用ChannelInterceptor拦截器,在接收到websocket消息之后,更新对应的HttpSession的时间戳。

spring session的SessionRepositoryMessageInterceptor核心代码如下所示,它会在消息发送前更新HttpSession的lastAccessedTime时间戳

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
    if (message == null) {
        return message;
    }
    SimpMessageType messageType = SimpMessageHeaderAccessor
            .getMessageType(message.getHeaders());
    if (!this.matchingMessageTypes.contains(messageType)) {
        return super.preSend(message, channel);
    }
    Map<String, Object> sessionHeaders = SimpMessageHeaderAccessor
            .getSessionAttributes(message.getHeaders());
    String sessionId = sessionHeaders == null ? null
            : (String) sessionHeaders.get(SPRING_SESSION_ID_ATTR_NAME);
    if (sessionId != null) {
        S session = this.sessionRepository.getSession(sessionId);
        if (session != null) {
            // update the last accessed time
            session.setLastAccessedTime(System.currentTimeMillis());
            this.sessionRepository.save(session);
        }
    }
    return super.preSend(message, channel);
}

访问不到Controller

在TransportHandlingSockJsService的构造方法里面有对messageCodec初始化的代码,如果没有jackson的jar包,则很有可能导致messageCodec未被初始化,使用stomp协议发送的消息则无法被正确解析,因此不会被Controller接收处理。

分布式支持,nginx、openrestly

nginx对websocket的支持比较简单,只需要在nginx.conf中配置下即可,但是openrestly还需要lua脚本的支持。另外,websocket是TCP层协议,如果项目中用到了硬负载,可能还需要额外设置。

性能优化

spring websocket线程池

适当提高InboundChannel、OutboundChannel的线程池大小,对于提升spring websocket的消息处理能力有非常大的帮助,默认的线程池大小是cpu核数*2。下列表格是压测中的一组数据,通过响应时间的对比,便知线程池的大小的优化效果。

测试业务 | 线程池 | 并发数 | TPS域值 | 平均异步响应时间ms
| - | :-: | :-: | - | :-: | :-: |
发送消息 | 100/100 | 1000 | 1500 | 3869
发送消息 | 300/200 | 1000 | 1500 | 87

以下是设置线程池大小的代码,详见前面的WebSocketBrokerConfig配置类:

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
    super.configureClientInboundChannel(registration);
    registration.taskExecutor().corePoolSize( 200 );
}

@Override
public void configureClientOutboundChannel(ChannelRegistration registration) {
    super.configureClientOutboundChannel(registration);
    registration.taskExecutor().corePoolSize( 100 );
}

接入消息中间件

简单消息代理流程图(摘自spring官网):
image

使用消息中间件的流程图(摘自spring官网):
image

在SimpleBrokerMessageHandler中有一个致命的弱点,就是使用了DefaultSubscriptionRegistry,而在这个里面又使用了DestinationCache,这是一个基于LinkedHashMap实现的LRU缓存策略,如果缓存大小超过了cacheLimit值,则会去将原有旧的数据清理掉,并且再次读取的时候仍然会去读取所有的订阅地址直到找到符合条件的,涉及到写的部分,都加了synchronized重量级锁,所以在压测的过程中这一块的线程BLOCK也是相对较频繁的,程序的并发性能下降。目前,项目中还未使用消息中间件,后续如果接入消息中间件,对应用系统的性能将会有很大提高。

优化思路有以下3种:
1. 增大cacheLimit值,减少频繁遍历所有的订阅地址
2. 使用spring的BeanPostProcessor,改变SimpleBrokerMessageHandler的subscriptionRegistry实现类;
3. 使用消息中间件,spring会将订阅操作交给消息中间件处理,这样便可以很好的解决这个问题

参考资料:

  1. http://docs.spring.io/spring/docs/current/spring-framework-reference/html/websocket.html
  2. http://blog.csdn.net/icode0410/article/details/39496823

猜你喜欢

转载自blog.csdn.net/dwade_mia/article/details/79054590