乐趣区

关于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 的线程池配置上。

退出移动版