关于spring:Spring-WebSocket简析

博文原始连贯

前言

Websocket是一种在TCP连贯上进行全双工网络通信的协定,之所以在有了Http协定之后仍旧诞生了Websocket的起因在于Http的缺点:通信只能由客户端发动。在Websocket呈现之前,为了在Web C/S架构上实现服务端向客户端推送音讯,大都是靠Ajax进行轮询来“实现”推送,而因须要不停地关上连贯以及Http较长的头部信息,在一直申请的过程中这将产生资源的节约。Websocket因而应运而生。

因为目前可搜得的材料各式各样天壤之别(因为有的是Java原生API而有的是基于STOMP的利用示例),本文将着重介绍Spring环境下(SpringBoot为例)的Websocket利用。

Websocket基本概念

基本概念原理这里就不细讲了,一查一大把,举荐大佬的这篇博客(偏代码实际一些)以及知乎的这篇高赞答复(偏寓教于乐一些)。须要重点阐明的是:

  1. Websocket作为规范的通信协议在Web C/S应用时,须要浏览器和Web服务容器的反对
  2. Websocket依附Http来实现握手,握手实现后才是齐全走Websocket协定
  3. 资源描述符的前缀为ws,加密通道传输则为wss,例如ws://example.com:80/some/path
  4. Websocket没有规定发送内容的格局,反对文本、二进制

Http握手

握手比拟重要因而独自拿出来说一下,握手的具体细节能够参考下面举荐的文章以及百度,这里细说下为什么要应用Http来进行握手而不是齐全独立采纳自有协定,集体认为这一点次要起因有:

  1. Websocket次要还是作为Http的一种补充,与Http紧密结合是荒诞不经的,并且可能较好地融入Http生态
  2. 提供了良好的兼容性解决,能够通过Http来获取兼容性反对反馈以及应用Http来在不反对websocket的客户端上模仿兼容Websocket

SockJS

SockJS是一个JavaScript库,次要用于应答浏览器缺失websocket反对的状况。它提供了连贯的、跨浏览器的JavaScript API,它首先尝试应用原生Websocket,在失败时可能应用各种浏览器特定的传输协定来模仿Websocket的行为。

Java标准

Java公布提供了Websocket的规范API接口JSR-356,作为Java EE7规范的一部分。大部分规范的Java web容器都曾经实现了对Websocket的反对,同时也是兼容这个标准接口的,例如Tomcat 7.0.47+, Jetty 9.1+, GlassFish 4.1+, WebLogic 12.1.3+, Undertow 1.0+ (WildFly 8.0+)等。

何时应用Websocket

这里次要参考Spring文档中的叙述:

Websocket尽管能够使网页变得动静以及更加有交互性,然而在很多状况下Ajax联合Http Streaming或者长轮询能够提供简略高效的解决方案。例如新闻、邮件、社交订阅等须要动静更新,然而在这些情景下每隔几分钟更新一次是齐全没有问题的。而另一方面,合作、游戏以及金融利用则须要更加靠近实时更新。留神提早自身并不是决定性因素,如果信息量绝对较少(例如监控网络故障),Http Streaming或轮询同样也能够高效地解决。低提早、高频率以及高信息量的组合状况下,Websocket才是最佳抉择。

STOMP协定

这里把STOMP协定提到后面一点的地位,也能够抉择先看上面的内容,到介绍STOMP利用时再看这部分。

STOMP是一个简略的互操作协定,它被设计为罕用消息传递模式的最小子集,定义了一种基于文本的简略异步音讯协定,它最后是为脚本语言(如 Ruby、 Python 和 Perl)创立的,用于连贯企业音讯代理。STOMP曾经宽泛应用了好几年,并且失去了很多客户端(如stomp.js、Gozirra、stomp.py、stompngo等)、音讯代理端(如ActiveMQ、RabbitMQ等)工具库的反对,目前最新的协定版本为1.2。

STOMP是一种基于’Frame’的协定,Frame基于Http建模,每个Frame由一个命令(Command)、一组头部(Headers)和可选的注释(Body)组成,如下是一个STOMP frame的根本构造示例:

COMMAND
header1:value1
header2:value2

Body^@

能够看到STOMP自身的构造是十分简单明了的。STOMP同样有客户端和服务端的概念,服务端被认为是能够接管和发送音讯的一组目的地;而客户端则是用户代理,能够进行两种操作:发送音讯(SEND)、发送订阅(SUBSCRIBE),为此,STOMP的命令有如下几种。

客户端命令:

  • CONNECT:用于初始化信息流或TCP连贯,是客户端第一个须要发送的命令
  • SEND:示意向目的地发送音讯,必须要蕴含一个名为destination的头部
  • SUBSCRIBE:用于注册监听一个目的地,必须蕴含一个名为destination的头部
  • BEGIN:用于启动事务,必须蕴含一个名为transaction的头部
  • COMMIT:用于提交事务,必须蕴含一个名为transaction的头部
  • ABORT:用于回滚事务,必须蕴含一个名为transaction的头部
  • DISCONNECT:告知服务端敞开连贯

服务端命令:

  • CONNECTED:服务器响应客户的段的CONNECT申请,示意连贯胜利
  • MESSAGE:用于将订阅的音讯发送给客户端,头部destination的值应与SEND frame中的雷同,且必须蕴含一个名为message-id的头部用于惟一标识这个音讯
  • RECIPT:收据,示意服务器胜利解决了一个客户端要求返回收据的音讯,必须蕴含头部message-id表明是哪个音讯的收据
  • ERROR:出现异常时,服务端可能会发送该命令,通常在发送ERROR后将敞开连贯

能够说STOMP次要就是提供了发送音讯、订阅音讯的语义,同时还可能反对事务的解决。

Spring利用集成

与其余提供绝对繁多的最佳实际的Spring整合不同,在Spring中利用Websocket能够有多种形式,且天壤之别,这也造成了初步查阅材料时的困惑,为何两篇文章中的示例会看起来齐全不一样。

这里咱们选用Springboot(2.x)以及其默认应用的tomcat来作为根底进行各种模式的示例演示阐明,其中几个示例没有前端,能够应用在线测试工具来试验。应用Maven搭建我的项目,在最简单的利用模式下,POM的依赖将蕴含如下内容:

<dependencies>

    <!-- websocket根底,次要引入了spring-websocket和spring-messaging -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    
    <!-- web根底 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- message security -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-messaging</artifactId>
    </dependency>

    <!-- lombok,可选 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- test,可选 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>

    <!-- 以下为前端库的反对 -->
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>webjars-locator-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>sockjs-client</artifactId>
        <version>1.0.2</version>
    </dependency>
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>stomp-websocket</artifactId>
        <version>2.3.3</version>
    </dependency>
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>bootstrap</artifactId>
        <version>3.3.7</version>
    </dependency>
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>jquery</artifactId>
        <version>3.1.1-1</version>
    </dependency>

</dependencies>

原生Java API

这种状况只利用Spring的根底能力以及辅助工具类,间接以规范的java标准接口(javax.websocket包下的内容)来编写websocket利用,在依赖上仅须要spring-boot-starter-websocket。让程序可能解决websocket申请仅须要编写两个额定的类即可。

其一是配置类,注入一个类型为ServerEndpointExporter的工具Bean,它的作用是扫描注解了@ServerEndpoint这个注解的类并且将其主动注册到Websocket容器以及探测ServerEndpointConfig的配置Bean。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
// @EnableWebSocket // no need
public class OriginWebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

其二是音讯解决入口:

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
@ServerEndpoint("/myWs") // 对应websocket连贯、发送音讯的地址 
public class OriginWSEndpoint {

    // 简略存储客户端对应的session
    private ConcurrentHashMap<String, Session> userMap = new ConcurrentHashMap<>();
    
    // 简略的推送音讯
    public void sendMessage(String username, String message) throws IOException {
        Session session = userMap.get(username);
        if (null != session) {
            session.getBasicRemote().sendText(message);
            log.info("音讯发送胜利");
        } else {
            log.info("未找到客户Session");
        } 
    }
    
    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        log.info("客户端接入:{}", session);
        
        // you can define your own logic
        String username = session.getId();
        userMap.put(username, session);
        
        // something more
        // session.addMessageHandler( ... );
    }

    @OnMessage
    public void onMessage(Session session, String message) {
        log.info("从{}接管到一条音讯({})", session, message);
        
        // message handling ...
    }

    @OnClose
    public void onClose(Session session, CloseReason closeReason) {
        log.info("{}连贯敞开。敞开起因:({})", session, closeReason);

        String username = session.getId();
        userMap.remove(username);
    }

    @OnError
    public void onError(Session session, Throwable throwable) {
        log.info("{}产生异样", session, throwable);
        
        // error handling, maybe close session ...
    }
    
}

这样就实现了一个最根本的解决流程,当然原生api不仅限于此,更多的原生api解决本篇暂不做探讨。

独自应用Spring Websocket

Spring-websocket对原生api进行了一些封装,增加了更多的个性反对,使得解决时更加便捷,提供了更加丰盛的编程接口。首先增加一个配置类:

import com.example.ws.controller.MyHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

@Configuration
@EnableWebSocket // 开启spring-websocket反对
public class PureWebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myHandler(), "/myHandler") // 注册handler
                .addInterceptors(new HttpSessionHandshakeInterceptor()) // 增加拦截器
                .setAllowedOrigins("*") // 容许非同源拜访
                .withSockJS(); // 应用sockJS
    }

    @Bean
    public WebSocketHandler myHandler() {
        return new MyHandler();
    }

}

具体阐明如上的代码:

  • @EnableWebSocket

    典型的Spring Enable系列注解,查看注解的内容就是引入了一个配置类DelegatingWebSocketConfiguration,它干的事件并不多,次要就如下几点:

    1. 收集WebSocketHandlerRegistry的实现类Bean,并以此通过ServletWebSocketHandlerRegistry为Websocke创立一个HandlerMapping
    2. 注入一个在默认状况下为SockJS所应用的TaskScheduler
  • registry.addHandler

    相似Spring http的handlermapping,注册URL Path对应的处理器,处理器的基接口为WebSocketHandler

  • registry.addInterceptors

    很显著的,增加拦截器,不过这是对Websocket连贯握手阶段的拦挡,拦截器的基接口为HandshakeInterceptor,Spring提供了一些默认的拦挡工具,如引入Http握手时的HttpSession、CsrfToken解决、同源解决等。

  • withSockJS

    提供SockJS的反对,值得注意的是尽管doc上写的是fallback,但这经试验验证并不是同时反对websocket连贯和SockJS连贯,例如前端间接应用Websocket api来连贯应用了withSockJS的URL Path会产生报错,客户端和服务端应该保持一致,同时应用SockJS或者同时不必,或者服务端注册两个Handler别离解决SockJS和原始Websocket。

而后就是创立一个WebSocketHandler,Handler基本上对应了原生API中的ServerEndPoint:

import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.socket.messaging.SubProtocolWebSocketHandler;

public class MyHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("Receive a text message:"+ message.getPayload());

        session.sendMessage(new TextMessage("What the Fuck ?"));
    }
}

这里为了不便间接继承了TextWebSocketHandler,此外Spring还提供了BinaryWebSocketHandler用于应答二进制数据处理。对于实用性来说,还是应该间接实现WebSocketHandler接口或者继承AbstractWebSocketHandler,通常状况下须要在连贯建设后进行额定的操作(afterConnectionEstablished)以及错误处理(handleTransportError)。为了被动推送音讯往往须要提供获取对应Session的办法,比方将用户和对应的Session保留在Map(ConcurrentHashMap)中。

采纳Spring的Websocket封装后另外一个比拟大的不同是针对org.springframework.web.socket.WebSocketSession来进行操作,屏蔽了底层的差别(Websocket Session、SockJS等),同时可能携带额定的数据,包含Attribute(属性map)、Principal(身份)、HandshakeHeader等。

STOMP On Spring Websocket

不同于Http各种各样的头部、申请体等领有各种标准规范,Websocket握手完结建设连贯后并没有提供更加具体的交互协定,只是文本和二进制都反对,这对于一个非常简单的应用程序来说会显得比拟低级、过于凑近底层。处于此起因Websocket RFC定义了子协定的应用,在握手阶段能够应用头部Sec-WebSocket-Protocol来就应用何种子协定达成统一,但这并不是必须的。Spring提供了以STOMP作为Websocket子协定的反对,选用STOMP的起因可能包含:

  1. Spring认为Websocket继续在同一个TCP连贯上以事件为驱动异步传递音讯的模式更加趋近于消息传递应用程序如JMS、AMQP,而STOMP属于消息传递协定
  2. STOMP的设计哲学是简洁性、互操作性,STOMP很笨重同时利用宽泛较为成熟,通用性比拟强

启用STOMP后整个信息流就会简单很多,然而也缩小了很多额定的人工配置,从Spring文档的篇幅、提供的利用样例以及spring-boot-starter-websocket间接引入了spring-messaging模块(蕴含了STOMP等相干内容)等各种状况不难看出这种形式是spring主推的websocket解决方案。应用这种形式的长处有:

  1. 使得Spring可能提供更加丰盛的编程模型
  2. 无需自定义音讯交互协定和音讯格局,无需手动治理Session
  3. 提供了许多现成的工具,如Spring框架中蕴含的STOMP Java客户端
  4. 可能应用音讯队列来代为治理音讯
  5. 与Spring Security集成良好
根底配置

演示此种模式时,参考了Spring官网的疏导样例代码,它提供了前端的内容,在试验时能够抉择间接pull它的代码,而后批改。同样首先须要一个配置类:

@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic", "/queue"); // 1
        config.setApplicationDestinationPrefixes("/app"); // 2
        config.setUserDestinationPrefix("/user"); // 默认就是'/user' 3
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/gs-guide-sockjs") // 4
                .withSockJS();  // 5

        registry.addEndpoint("/gs-guid-websocket");
    }

}

能够看到应用的是@EnableWebSocketMessageBroker而不是@EnableWebsocket,同时实现的接口是WebSocketMessageBrokerConfigurer。在这种利用形式下,首先要留神Spring提供好了发送音讯、订阅音讯的解决模型,而不是简略的客户端和服务端互发音讯,整体更加趋向于一个音讯队列服务器(Message Broker)。客户端能够订阅某个播送地址(播送)或传输通道地址(单播),订阅之后将会接管公布在该地址上的音讯,同时客户端也能够被动给某个地址发送音讯。在该配置类中:

①配置用于传输音讯的地址前缀,使得用户能够订阅地址/topic/xxx或者向/topic/xxx发送音讯,用于端到端的状况,即客户端之间间接通信
②配置用于间接发送给服务器执行解决的地址前缀,这是为了应答间接发送申请给服务器的状况,此时对于发送到/app/xxx的音讯将被路由到@Controller注解的类中注解了@MessageMapping的办法上,与@RequestMapping十分类似,留神发送到/app/abc时将由@MessageMapping("/abc")接管解决
③配置用户地址前缀,该模式下反对给特定的用户发送音讯,为了不便解决这种状况,提供了用户地址前缀。在默认的/user前缀状况下,客户端能够订阅/user/queue/xxx表明监听一个只会发给本人音讯的地址/queue/xxx(留神/queue的配置),在服务端能够发送到形如/user/{username}/queue/xxx的目的地或者通过调用SimpMessagingTemplateconvertAndSendToUser('{username}', '/queue/xxx')来给指标用户发送音讯,spring将主动解析转化为用户会话惟一的目的地(如/queue/xxx-user {session-id}),保障与其余用户不抵触
④增加websocket握手http地址,该地址仅用于握手
⑤开启SockJS

服务端音讯解决

不同于独自应用Spring Websocket,配置中并没有呈现Handler相干的配置,因为语义产生了变动(蕴含了端到端、端到服务),同时Spring提供了以注解的形式来提供Handler的形容和定义。示例如下:

@Controller
public class StompController {

    @Autowired
    private SimpMessagingTemplate messagingTemplate;

    // HelloMessage是一个仅蕴含name字段的pojo
    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(Message<HelloMessage> message) throws Exception {
        System.out.println("Receive a Message with header: " + message.getHeaders());
        Thread.sleep(1000); // simulated delay
        return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getPayload().getName()) + "!");
    }

    @MessageMapping("/hehe")
    @SendToUser("/queue/xxx")
    public Message<String> toUser(HelloMessage message) throws Exception {
        SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
        accessor.setUser(message::getName); // set user name
        return MessageBuilder.createMessage("Somebody say hello to you", accessor.getMessageHeaders());
    }

    @MessageMapping("/haha")
    public void useTemplate(HelloMessage message) throws Exception {
        messagingTemplate.convertAndSendToUser(message.getName(),
                "/queue/xxx", "Hello from messaging template");
    }

}

与SpringMVC十分相近地用@Controller润饰class,这也使得能够在同一个Controller类中同时解决Http和websocket,只不过这里websocket用的是@MessageMapping。下面的三个办法,别离应用了@SendTo@SendToUserSimpMessagingTemplate来将音讯发送至指定的目的地,因为咱们指定了ApplicationDestinationPrefix为/app,因而客户端在向/app/hello/app/hehe/app/haha来发送音讯时将别离被对应的办法执行解决,而不是以/app结尾的目的地将不会被@MessageMapping捕获解决。罕用的相干注解和类总结如下:

名称 地位 形容
@MessageMapping class / method 地址映射注解,注解在类上时对类内所有办法产生影响,留神applicationDestinationPrefix的配置
@SubscribeMapping method 相似于@MessageMapping,然而仅限于订阅音讯并且返回值间接发送给clientOutboundChannel
@MessageExceptionHandler method 相似于mvc的@ExceptionHandler,用于错误处理
Message 音讯的基类,蕴含Header和Payload
MessageHeaderAccessor 正如其名称所述,用于读取、设置Header内容,罕用的子类有SimpMessageHeaderAccessor和StompHeaderAccessor,蕴含了规范头部值的解决办法
@Header method argument 用于疾速将音讯的头部值并将其赋值到办法入参上
@Headers method argument 用户将所有头部信息赋值到入参,能够调配给java.util.Map
@DestinationVariable method argument 从映射地址中获取模板变量,与mvc的@PathVariable类似
Pricipal 作为办法参数时,将赋值为发送音讯的用户信息
@SendTo class / method 指定将办法返回值发送到指定的目的地
@SendToUser class / method 指定将办法返回值发送到指定的用户目的地,用户信息将从Message头部读取
SimpMessagingTemplate 发送音讯的工具类,默认曾经配置好了一个Bean能够间接注入,@SendTo和@SendToUser实质上也是调用这个工具
用户认证

在原生Java API和独自应用Spring Websocket的场景中,通常都须要手动治理Session以及用户与Session的映射,而在STOMP模式下这些都由框架解决实现。在Http中判断一个连贯属于哪个用户通常有两种形式:Cookie-Session或者Auth Token,websocket也是相似,不同的是因为websocket是异步双工通信,用户的确认须要在握手连贯阶段实现。带认证的配置文件如下(蕴含多种认证办法,择一即可):

@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic", "/queue");
        config.setApplicationDestinationPrefixes("/app");
        config.setUserDestinationPrefix("/user"); // 默认就是/user
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/gs-guide-sockjs")
                .setHandshakeHandler(handshakeHandler())
                .withSockJS();

        registry.addEndpoint("/gs-guide-websocket")
                .setHandshakeHandler(handshakeHandler());
    }
    
    @Bean
    public UserAuthHandshakeHandler handshakeHandler() {
        return new UserAuthHandshakeHandler();
    }

    public static class UserAuthHandshakeHandler extends DefaultHandshakeHandler {
        @Override
        protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
            // return request.getPrincipal(); // 1 默认认证形式

            // 2 握手阶段URL参数认证
            ServletServerHttpRequest servletServerHttpRequest = (ServletServerHttpRequest) request;
            HttpServletRequest httpRequest = servletServerHttpRequest.getServletRequest();
            // /gs-guide-websocket?token=xxxx
            String token = httpRequest.getParameter("token"); 

            String user =  ...; // extract from token
            return () -> user;
        }
    }

    // 3 增加channel拦截器,在STOMP CONNECT阶段认证
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                System.out.println("Activate the inbound interceptor now! Message header: " + message.getHeaders());
                StompHeaderAccessor accessor =
                        MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    String auth = accessor.getFirstNativeHeader("Authentication");
                    if (null != auth) {
                        String user = ... ; // extract from token
                        accessor.setUser(() -> user);
                    } else {
                        throw new IllegalStateException("token illegal");
                    }
                }

                return message;
            }
        });
    }
}
  1. 握手阶段通过Http Session认证:这是默认反对的验证办法,间接通过Http握手时Http Session获取用户信息,获取的形式是HttpServletRequest#getUserPrincipal()
  2. 握手阶段通过URL申请参数认证:属于Token认证的一种办法,因为Websocket和SockJS均不反对在握手阶段增加额定的Http头部(理由是平安问题,参考SockJS的一个issue),那么只好通过申请参数来实现,将token信息附带在握手URL中
  3. 在STOMP子协定的CONNECT阶段进行认证,spring提供了在CONNECT时通过对message调拥setUser办法触发回调函数来执行认证,罕用于Token认证

值得注意的是在子协定阶段认证无奈阻止大量歹意连贯,客户端能够只进行连贯而不发送CONNECT音讯。

音讯流和原理剖析

音讯散发、路由通过spring-messaging这个由Spring Integration形象倒退而来的模块来解决,因而要了解它的运作机制必须理解该模块的内容,本博正好有一篇Spring Integration的介绍,可供参考。这里简略介绍一下其中的几个最根本的概念:

  • Message:包含音讯头部和负载,是对音讯的形象封装
  • MessageHandler:音讯处理器的封装,接管音讯执行操作
  • MessageChannel:音讯通道,用于传递音讯,解耦生产者和消费者。在利用层面,MessageChannel蕴含了1个或多个MessageHandler,给channel发消息即是申请其中的Handlers解决该音讯,不同的Channel有不同的解决形式,可能是同步调用也可能是异步解决

理解概念起初看一下整个音讯流的过程(不蕴含集成内部音讯队列的状况):

客户端音讯的流入:尽管通过了层层封装然而源头仍然是基于原生Java API从web容器获取申请,Spring的接入点为StandardWebSocketHandlerAdapter,该类继承了javax.websocket.Endpoint。数据被封装为WebSocketMessage的子类后被发送给SubProtocolWebSocketHandler,对于传入的音讯SubProtocolWebSocketHandler将获取到音讯对应的子协定处理器,即StompSubProtocolHandler,将WebSocketMessage交由其解决。StompSubProtocolHandler解析音讯原始数据,并将其封装为org.springframework.messaging.Message(也可能解析为多条Message),同时设置对应的Header信息包含User、SessionId等等,封装实现后将音讯发送给ClientInboundChannel。ClientInboundChannel默认状况下有三个Handler:WebSocketAnnotationMethodMessageHandler(负责解决@MessageMapping)、UserDestinationMessageHandler(负责解析转换用户地址)、SimpleBrokerMessageHandler(负责发送响应音讯以及记录订阅情况)。其中UserDestinationMessageHandler仅负责地址转换,转换实现后会从新将音讯发送到ClientInboundChannel,SimpleBrokerMessageHandler将音讯发送给ClientOutboundChannel。

响应音讯的流出:音讯的对立进口是ClientOutboundChannel,其对应的Handler为SubProtocolWebSocketHandler,它保留了SessionId到Session的映射,依据Message头部的SessionId信息获取到对应的Session后交由StompSubProtocolHandler最终执行音讯的发送。留神能够间接通过SimpMessageTemplate在其余环境中间接给用户推送音讯。

集成Spring Security

Spring Security 4增加了对Spring websocket的反对,不过值得注意的是Spring Security并不提供对原生Java API以及独自应用Spring websocket的反对,起因是数据格式不明确,Spring Security对未知格局的数据能做的事件比拟少。咱们须要在依赖中引入spring-boot-starter-securityspring-security-messaging,增加实现后主配置文件更改为如下:

@Configuration
@EnableWebSocketMessageBroker
// 继承Spring Security提供的基类
public class SecurityWebSocketConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/queue", "/topic");
        config.setApplicationDestinationPrefixes("/app");

        /**
         * 保障对同一个session发送的音讯有序,这会带来肯定的性能开销
         * @see org.springframework.messaging.simp.broker.OrderedMessageSender
         */
        // config.setPreservePublishOrder(true);
    }


    /**
     * 发动websocket连贯时要应用http,在进行连贯时的申请也会被security拦挡,留神放行
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/gs-guide-websocket")
                .setHandshakeHandler(handshakeHandler()) // 认证形式抉择一种即可
                .setAllowedOrigins("*");

        registry.addEndpoint("/gs-guide-sockjs")
                .setHandshakeHandler(handshakeHandler())
                .setAllowedOrigins("*")
                .withSockJS();

        // 错误处理
        // registry.setErrorHandler()
    }

    @Override
    protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
        messages
                // 容许任何CONNECT、DISCONNECT音讯
                .simpTypeMatchers(CONNECT, DISCONNECT).permitAll()
                // 无地址音讯解决
                .nullDestMatcher().authenticated()
                // 依照门路的解决
                .simpDestMatchers("/app/**").hasRole("USER")
                .simpDestMatchers("/topic/**").authenticated()
                // 依照订阅门路的解决
                .simpSubscribeDestMatchers("/topic/**", "/user/**").authenticated()
                // 其余音讯全副禁止
                .anyMessage().denyAll();
    }

    // 是否容许非同源拜访,默认为false不容许,会增加CSRF解决
    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }

    @Bean
    public UserAuthHandshakeHandler handshakeHandler() {
        return new UserAuthHandshakeHandler();
    }

    // 认证形式抉择一种即可
    public static class UserAuthHandshakeHandler extends DefaultHandshakeHandler {
        @Override
        protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {

            // 握手阶段URL参数认证
            ServletServerHttpRequest servletServerHttpRequest = (ServletServerHttpRequest) request;
            HttpServletRequest httpRequest = servletServerHttpRequest.getServletRequest();
            // /gs-guide-websocket?token=xxxx
            String token = httpRequest.getParameter("token"); 

            String user =  ...; // extract from token
            
            // 用户名、权限的解决
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                new UserPrincipal(user), null, Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
            
            // 须要返回Spring Security实用的Authentication子类
            return authenticationToken;
        }
    }

}

此外还须要Spring Security本身的配置:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .authorizeRequests()
                // 解决websocket握手地址,token认证模式下须要间接放行
                .antMatchers("/gs-guide-websocket", "/gs-guide-sockjs").permitAll()
                // ... more configuration
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("*.html", "*.css", "*.js");
    }

}

配置过程与一般的Spring Security格调保持一致,还是十分不便的。同样的,基本原理也差不多,也是增加了Spring Security的拦截器(ChannelSecurityInterceptor),在Message进入时创立SecurityContext而后执行后续校验,详细情况可参考官网文档。

小结

从原生Java API到独自应用Spring Websocket,最初是STOMP on Spring Websocket,是一个封装水平逐步进步的过程,最初的代码外观天差地别,可见代码构造的威力,不得不拜服Spring的形象能力。尽管STOMP on Spring Websocket是性能最齐全、语义最丰盛的一种利用形式,也是Spring主推的解决方案,然而集体感觉略微简单了那么一些,而且有额定的基础知识门槛,如果只是为了实现服务端推送一些音讯,并不一定须要这种利用形式。

在此状况下服务端和音讯队列十分类似(集成内部音讯队列工具间接推送到页面倒是不错,不过本文没有介绍这种状况),然而须要留神客户端发送过去的音讯有可能被异步解决n次(最多的状况达到4次),每次解决都是线程池调用,也就意味着要被系统调度选中n次,那么响应的延时可能会比拟高,调优也是集中在Channel的线程池配置上。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理