Java中的Spring WebSockets入门教程

227 阅读12分钟

Java中的Spring WebSockets入门

对于那些不知道的人来说,WebSocket是一种通信协议。它对于在网络客户端和服务器之间建立持久的双向连接非常有用。

如今,网络应用已经变得比以前更强大了。你现在可以以聊天应用甚至多人游戏的形式拥有实时功能。所有这些都是通过使用WebSockets实现的。

这使你能够建立起只用HTTP无法做到的实时互动。在本指南中,我们将进一步详细探讨这一协议背后的理论。

不仅如此,我们还将探讨一个你可以与它一起使用的子协议。然后,我们将讨论如何使用Spring Boot在Java中实现WebSockets,用一个简单的Web客户端来测试它。我们还将讨论一些基础知识,以确保WebSocket连接的安全。

前提条件

要遵循本指南,你应该对Spring Boot的基础知识有一个扎实的了解。这包括,例如,创建休息端点和常见的设计模式。

最后,对于本指南的安全部分,你需要了解[Spring Security]和[Spring Data]来存储和验证用户。

为什么你需要WebSockets?

在了解WebSockets之前,让我们深入了解一下传统的HTTP协议。这样我们就能理解你能用WebSockets解决的问题。通过HTTP,客户端以一系列的请求和响应与服务器对话。

在每个请求中,客户端都会打开一个新的连接,直到服务器发送一个响应。这对于一个典型的案例来说是很好的,你只需要在页面加载时从服务器上获取数据。

假设你想建立一个更复杂的应用程序,如一个聊天应用程序。在一个聊天应用程序中,你需要能够实时地从服务器上检索信息。如果你不得不为每条信息打开和关闭一个新的连接,那会变得非常慢。

一旦有人向你发送消息,服务器也需要能够向你传递消息。记住,在HTTP中,服务器只能作为对你的请求的回应与你交谈。

如果它只能在你发起的时候进行通信,它怎么能足够快地给你发送消息呢?换句话说,服务器需要能够在有人向客户发送消息时,立即带着消息接近客户。

WebSocket协议旨在解决这些问题。它的全部目的是允许一个持久的双向连接。

WebSocket协议的高层概述

客户端首先发送一个称为握手的特殊请求。你可以把握手看作是客户端要求服务器通过WebSocket进行对话。如果响应成功,那么服务器就会打开WebSocket连接,只要他们需要就可以了。连接打开后,客户端和服务器就像HTTP一样向URL端点发送消息。

但与HTTP不同的是,该协议没有指定消息格式。相反,客户端和服务器可以在握手期间商定一个子协议。这个子协议将定义我们发送和接收所有消息的格式。我们将在本教程中使用的子协议称为STOMP

STOMP协议

STOMP(简单文本导向的消息传输协议)是一个很像HTTP的子协议。每当任何一方发送数据时,他们必须以的形式发送。一个框架的结构就像一个HTTP请求。

它有一个与帧的意图相关的动词(例如,CONNECTDISCONNECT),就像HTTP方法。它还包含一个头以向对方提供额外的信息,以及一个主体以提供主要内容。正如你所看到的,STOMP的设计与我们发送HTTP请求的方式几乎相同,使用起来也会很直观。

在Spring中实现WebSockets

现在你对WebSockets有了很好的理解,让我们在Spring中实现它们。我们要建立的是一个简单的应用程序,它接收来自用户的消息,并将其发送回给每个人。每个用户将发送消息到一个端点/app/chat ,并订阅接收来自/topic/messages

每次用户向/app/chat ,我们的服务器就会把信息发回给/topic/messages 。为了简单起见,我将使用纯HTML和JavaScript制作一个简单的客户端。它将在自己的服务器上运行,与我们的Spring Boot应用程序分开。

首先,我们需要从Spring初始化器中创建一个新的Spring Boot项目。我们现在唯一需要的依赖性是spring-boot-starter-websocket依赖性。接下来,你需要创建一个配置类来注册我们的STOMP端点,并允许我们使用一个额外的工具,称为sockjs

sockjs 的作用是,在客户端无法通过 WebSocket 连接的情况下,它可以提供备份计划。如果发生这种情况,它将尝试使用其他协议进行连接,以尝试模仿WebSocket连接。如果我们想允许使用不支持WebSocket的旧浏览器,这就特别有用。

下面的代码应该为我们做到这一点。

package me.john.amiscaray.springwebsocketdemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
    
        // Set prefix for the endpoint that the client listens for our messages from
        registry.enableSimpleBroker("/topic");
        
        // Set prefix for endpoints the client will send messages to
        registry.setApplicationDestinationPrefixes("/app");
    
    }
    
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
    
        // Registers the endpoint where the connection will take place
        registry.addEndpoint("/stomp")
            // Allow the origin http://localhost:63343 to send messages to us. (Base URL of the client)
            .setAllowedOrigins("http://localhost:63343")
            // Enable SockJS fallback options
            .withSockJS();
    
    }

}

很容易吧?现在,我们在服务器端需要做的就是为我们接收信息的时间设置逻辑。就像我们在创建REST端点时一样,我们将使用控制器来处理这些框架。

唯一不同的是,我们将以不同的方式注解我们的方法,说明我们正在将它们发送到WebSocket端点。在这之前,我们将定义以下DTO,以表示传输的消息。

如果你不知道,DTO(数据传输对象)是一个专门用来表示JSON有效载荷的对象。

package me.john.amiscaray.springwebsocketdemo.dtos;

public class MessageDto {

    private String message;
    
    public String getMessage() {
    
        return message;
    
    }
    
    public void setMessage(String message) {
    
        this.message = message;
    
    }

}

现在我们把这个问题解决了,下面是我们的控制器。

package me.john.amiscaray.springwebsocketdemo.controllers;

import me.john.amiscaray.springwebsocketdemo.dtos.MessageDto;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;

@Controller
public class MessageController {

    // Handles messages from /app/chat. (Note the Spring adds the /app prefix for us).
    @MessageMapping("/chat")
    // Sends the return value of this method to /topic/messages
    @SendTo("/topic/messages")
    public MessageDto getMessages(MessageDto dto){
    
        return dto;
    
    }

}

你会注意到我们有一个@MessageMapping 注解,其值为/chat 。我们用它来指定我们的方法接收来自/app/chat 的消息。我们也有@SendTo 注释,其值为/topic/messages

我们添加这个来告诉Spring将返回值发送到给定的端点。我们在这里所做的就是接收从一个端点发出的消息并重定向到另一个端点。

现在我们需要在客户端连接到这些端点,这将是一个简单的HTML页面。在你的HTML的body标签中插入以下代码。

<label for="message-input">Enter message to send</label>
<input type="text" id="message-input">

<button onclick="sendMessage()">send</button>

<ul id="message-list"></ul>

<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.js" integrity="sha512-lyIq9fRcCeSCXhp41XC/250UBmypAHV8KW+AhLcSEIksWHBfhzub6XXwDe67wTpOG8zrO2NAU/TYmEaCW+aQSg==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js" integrity="sha512-iKDtgDyTHjAitUDdLljGhenhPwrbBfqTKWO1mkhSFH3A7blITC9MhYon6SjnMhp4o0rADGw9yAC6EW4t5a4K3g==" crossorigin="anonymous"></script>
<script src="./main.js"></script>

在脚本标签中,我们要导入stomp.js和sockjs-client库来连接到我们的服务器。让我们看一下我们链接的那个main.js 文件的内容,看看我们将如何使用它们。

// Try to set up WebSocket connection with the handshake at "http://localhost:8080/stomp"
let sock = new SockJS("http://localhost:8080/stomp");

// Create a new StompClient object with the WebSocket endpoint
let client = Stomp.over(sock);

// Start the STOMP communications, provide a callback for when the CONNECT frame arrives.
client.connect({}, frame => {
    // Subscribe to "/topic/messages". Whenever a message arrives add the text in a list-item element in the unordered list.
    client.subscribe("/topic/messages", payload => {
    
        let message_list = document.getElementById('message-list');
        let message = document.createElement('li');
        
        message.appendChild(document.createTextNode(JSON.parse(payload.body).message));
        message_list.appendChild(message);

    });

});

// Take the value in the ‘message-input’ text field and send it to the server with empty headers.
function sendMessage(){

    let input = document.getElementById("message-input");
    let message = input.value;
    
    client.send('/app/chat', {}, JSON.stringify({message: message}));

}

这可能是一个相当多的解包,所以让我们一步一步来。首先,我们创建一个sockjs客户端实例,并将其作为进行握手的URL。然后我们使用我们的sockjs客户端创建一个stomp客户端实例,我们将用它来连接服务器。

通过这个stomp客户端实例,我们用一个空对象和一个回调函数调用connect 方法。空对象代表我们将与我们的框架一起发送的头文件。在回调函数中,我们订阅在我们的/topic/messages 端点接收消息。

当我们订阅时,我们提供另一个回调函数,它将从服务器上获取框架并将消息内容放在HTML页面上。接下来,我们有我们的sendMessage 函数。这只是简单地获取我们的输入元素的值,并将其发送到带有空头的/app/chat 端点。

现在,如果你测试一下我们的客户端,你会发现每次你按下发送键,它都会把文本添加到屏幕上。更重要的是,试着在两个并排的窗口上打开它。你会看到,两个窗口都会收到信息。

确保我们的端点安全

我们后端的一个重要缺陷是任何人都可以连接到我们的服务器,无论我们是否认识他们。我们需要给我们的端点添加认证,以便只有有效的用户才能发送消息。据我所知,由于WebSockets仍然是一个相对较新的技术,所以没有一个特别的标准来保护它。

然而,我已经找到了一个合理有效的方法来保护我们的端点,你可以使用。在我们开始编码之前,有几件事情需要注意。JavaScript WebSocket库不允许你在握手中添加授权头信息。

相反,我们将使用用户发送的第一个CONNECT帧对其进行认证。在该框架中,用户将发送带有其证书的头信息,然后我们将对其进行检查。在头文件中发送凭证并不是最安全的方法,但它可以为你自己的系统提供一些想法。

例如,你可以找到一种方法在头文件中发送JWT令牌。我们要拦截帧的方式是用ChannelListener 接口的实现。顾名思义,它定义了一个你可以用来拦截帧的类。

实现WebSocket安全

首先,我们需要将spring-boot-starter-security依赖关系添加到我们的项目中。然后,我们需要设置一个User 实体并实现UserDetailsService 。为了专注于本指南的主题,我们将跳过这些。要知道,我们将在后台有几个类来为我们处理这个问题。

这些包括一个UserUserServiceAppUserDetailsService 、和一个AppUserDetails 类。假设你知道Spring Data和Spring Security设计模式,你应该明白这些类的目的。

User 类是一个代表用户的实体,UserService 类将用于查询这些用户。同时,AppUserDetails 类是一个用于存储账户信息的类。然后我们将使用AppUserDetailsService 类来查询这些信息。

接下来,我们需要为我们的应用程序配置Spring安全。

package me.john.amiscaray.springwebsocketdemo.config;

import me.john.amiscaray.springwebsocketdemo.service.AppUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class AppSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AppUserDetailsService userDetailsService;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
        // Set up simplified security settings requiring Spring to authenticate every request
        http.csrf().disable()
            .authorizeRequests()
            .anyRequest()
            .fullyAuthenticated();
    
    }
    
    @Override
    public void configure(WebSecurity web) throws Exception {
    
        // Tell Spring to ignore securing the handshake endpoint. This allows the handshake to take place unauthenticated
        web.ignoring().antMatchers("/stomp/**");
    
    }
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    
    }
    
    // Create an AuthenticationManager bean to Authenticate users in the ChannelInterceptor
    @Bean
    public AuthenticationManager authManager() throws Exception {
    
        return this.authenticationManager();
    
    }
    
    @Bean
    public PasswordEncoder passwordEncoder(){
    
        return new BCryptPasswordEncoder(10);
    
    }

}

现在,我们需要创建一个简单的服务类来接收给定的用户名和密码并进行验证。

package me.john.amiscaray.springwebsocketdemo.service;

import me.john.amiscaray.springwebsocketdemo.entities.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Collections;

@Service
public class WebSocketAuthenticatorService {

@Autowired
private UserService userService;

@Autowired
private PasswordEncoder passwordEncoder;

@Autowired
private AuthenticationManager authManager;

public UsernamePasswordAuthenticationToken getAuthenticatedOrFail(String username, String password) throws AuthenticationException {

        // Check the username and password are not empty
        if (username == null || username.trim().isEmpty()) {
        
            throw new AuthenticationCredentialsNotFoundException("Username was null or empty.");
        
        }
        
        if (password == null || password.trim().isEmpty()) {
        
            throw new AuthenticationCredentialsNotFoundException("Password was null or empty.");
        
        }
        
        // Check that the user with that username exists
        User user = userService.findUserByUsername(username);
        
        if(user == null){
        
            throw new AuthenticationCredentialsNotFoundException("User not found");
        
        }
        
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
            username,
            password,
            Collections.singletonList(new SimpleGrantedAuthority(user.getAuthority()))
        );
        
        // verify that the credentials are valid
        authManager.authenticate(token);
        
        // Erase the password in the token after verifying it because we will pass it to the STOMP headers.
        token.eraseCredentials();
        
        return token;
    
    }

}

现在我们已经创建了这个服务类,我们已经准备好创建我们的ChannelInterceptor

package me.john.amiscaray.springwebsocketdemo.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Service;

@Service
public class AuthChannelInterceptor implements ChannelInterceptor {

private final WebSocketAuthenticatorService service;
private static final String USERNAME_HEADER = "username";
private static final String PASSWORD_HEADER = "password";

    @Autowired
    public AuthChannelInterceptor(WebSocketAuthenticatorService service){
    
        this.service = service;
    
    }
    // Processes a message before sending it
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        // Instantiate an object for retrieving the STOMP headers
        final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        // Check that the object is not null
        assert accessor != null;
        // If the frame is a CONNECT frame
        if(accessor.getCommand() == StompCommand.CONNECT){
            
            // retrieve the username from the headers
            final String username = accessor.getFirstNativeHeader(USERNAME_HEADER);
            // retrieve the password from the headers
            final String password = accessor.getFirstNativeHeader(PASSWORD_HEADER);
            // authenticate the user and if that's successful add their user information to the headers.
            UsernamePasswordAuthenticationToken user = service.getAuthenticatedOrFail(username, password);
            accessor.setUser(user);
            
        }
        
        return message;
    
    }

}

很直观吧?如果我们从客户那里收到一个CONNECT框架,那么我们就检查头文件中的用户名和密码。使用用户名和密码,我们对用户进行认证。

现在,我们需要在服务器端做的就是注册我们的ChannelInterceptor ,以便Spring使用。要做到这一点,我们将在我们的WebSocket配置类中添加以下方法。

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {

    // Add our interceptor for authentication/authorization
    registration.interceptors(channelInterceptor);

}

最后,我们需要在客户端应用程序上说明这些变化。我们只需更新上面的JavaScript代码,编辑连接到服务器的调用即可。

/*
 Same as the above example, only adding username and password headers. The rest should stay the same. 
 See "Implementing WebSockets in Spring" above for details of how the client works.
*/
client.connect({'username': 'Jimbob', 'password': 'pass'}, (frame) => {

    client.subscribe("/topic/messages", payload => {
    
        let message_list = document.getElementById('message-list');
        let message = document.createElement('li');
        
        message.appendChild(document.createTextNode(JSON.parse(payload.body).message));
        
        message_list.appendChild(message);
    
    });

});

结论

在本指南中,我们介绍了WebSockets和STOMP的基础知识以及如何在Spring中实现它们。我们还介绍了通过拦截CONNECT帧来确保WebSocket连接安全的基本知识。有了这些知识,你就可以开始玩了,并创建自己的互动应用程序。

你可以尝试建立一个聊天应用程序,或者如果你觉得自己雄心勃勃,可以建立一个多人游戏。作为进一步的练习,我建议尝试改进我在其中添加的安全性。