Replacing Zuul with Spring Cloud Gateway to access WebSocket

Reprinted from: https://www.bl-blog.com/a/9/27

Preface

The previous project used Zuul gateway, which requires WebSocket, so I have been looking up the Spring Cloud Zuul's tutorials and articles on Forwarding WebSocket requests. I found that Zuul's support for WebSocket is not very friendly.

To sum up, here are the following points:

  • After the first http request, the higher version of websocket uses a faster tcp connection. zuul gateway can only manage http requests, and does not support tcp and udp requests
  • When zuul forwards websocket, it will degrade websocket to http request forwarding (polling mode, efficiency is not ideal), in other words, it does not support forwarding long connection, zuul2 seems to be able to.

This worries me a lot. After all, I am an obsessive-compulsive disorder, and I can't do my own project. Of course, I can't do any work. So I found other ways. By chance, I found that they said that Spring Cloud Gateway supports forwarding WebSocket. My eyes are bright, but I can't say that I can change the Gateway, so I searched if I can think of a way on the basis of Zuul. There are many ways on the Internet, But unfortunately, I don't think it's ideal. There's no way. Let's change the Gateway. Don't talk too much. Take a tutorial.

The first step is to introduce Gateway's maven dependency

	<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-web</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

In this case, we need to talk about a pit (knock on the blackboard and highlight it). Because the Gateway has its own netty, which conflicts with tomcat, so look at the above. The second dependency, spring boot starter websocket, has its own spring boot starter web jar package, while spring boot starter Web jar package contains a series of Tomcat jar packages, so we need to exclude it here, otherwise the startup will report an error. If it still does Error reporting. Check the error reporting prompt to see if there is a conflict between netty and Tomcat. Find out if there is any Tomcat in other jar packages and exclude it.

The second step is to start writing the configuration file after the dependency is imported

There are two ways for Gateway, one is configuration file and the other is interceptor
Here is the configuration of the yml file

spring:
  cloud:
    #spring-cloud-gateway
    gateway:
      routes:
      - id: xxx              #Do not repeat the route id and parameter configuration. If not, the Gateway will use to generate a uuid instead.
        uri: lb://xxx        #lb: / /: obtain path from the registry for forwarding, xxx is the name of the microservice registered in the registry
        predicates:
        - Path=/xxx/**       #After the path is met, forward
      #WebSocket forwarding configuration
      - id: xxx     
        uri: lb:ws://xxx     #lb:ws://xxx means to get path forwarding from the registry and change the request protocol to ws	
        predicates:
        - Path=/xxx/**

OK, the configuration file is written. - Path=/xxx / * * set the assertion. If your request path conforms to this rule, Gateway will forward it accordingly. Isn't it simple? But there is a problem here. Normal forwarding doesn't matter. It's over here. However, WebSocket forwarding has some problems. I use SockJS + Stomp + WebSocket. Here it is It is necessary to briefly introduce SockJS, Stomp and WebSocket.

WebSocket

  • WebSocke is a full duplex communication protocol provided by HTML5 over a single TCP connection.

  • WebSocket protocol is a new network protocol based on TCP. It is an application layer protocol and a subset of TCP/IP protocol.

  • It realizes full duplex communication between browser and server. Both client and server can actively send and receive data to each other. In the WebSocket API, the browser and server only need to complete one handshake, and they can directly create a persistent connection and carry out two-way data transmission.

  • After WebSocket is created in JS, an HTTP request will be sent from the browser to the server. After getting the server response, the established connection will use HTTP upgrade to convert HTTP protocol to WebSocket protocol. That is to say, WebSocket cannot be implemented with standard HTTP protocol. Only specialized browsers supporting those protocols can work normally. Because WebSocket uses a custom protocol, the URL is slightly different from the HTTP protocol. The unencrypted connection is ws: / /, not http: / /. The encrypted connection is wss: / /, not https: / /, so if your project uses a gateway and wants to use WebSocket, you will encounter problems in the aspect of gateway forwarding.

SockJS

  • Sockjs is a browser JavaScript library, which provides a coherent, cross browser JavaScript API, and establishes a low latency, full duplex, cross domain communication channel between browser and web server. One of the great advantages of sockjs is that it provides browser compatibility, preferring to use the native websocket. In browsers that do not support websocket, it will automatically reduce to polling mode.

Stomp

  • Stomp (simple text oriented messaging protocol), in Chinese: Message Oriented simple text protocol

  • websocket defines two types of transmission information: text information and binary information. Although the type is determined, their transport body is not specified. Therefore, we need to use a simple text transfer type to specify the transfer content, which can be used as the text transfer protocol in communication.

  • STOMP is a frame based protocol. Client and server use STOMP frame flow to communicate

  • A STOMP client is a user agent that can run in two modes, possibly both.

  • As a producer, SEND messages to a service of the server through the SEND framework

  • As a consumer, a target service is developed through SUBSCRIBE, and messages are received from the server through the MESSAGE framework.

summary

  • SockJS provides browser compatibility. In browsers that do not support WebSocket, it will automatically reduce to polling mode.
  • The simple understanding of Stomp is that it specifies the content to be transmitted as the text transmission protocol in the communication. I also have a little understanding of this, but I think if you know enough about WebSocket, you will know why to use it.
  • WebSocket realizes full duplex communication between browser and server. Both client and server can actively send and receive data to each other. The first protocol to establish WebSocket connection is HTTP or https protocol, and the url uses http: / / or https: /. After successful establishment, the url uses ws: / / or wss: /.

See here, you will probably understand, what is the problem I'm talking about
Because the first connection of WebSocket uses http protocol or https protocol, and our configuration file is configured as follows:

      - id: xxx     
        uri: lb:ws://xxx     #lb:ws://xxx means to get path forwarding from the registry and change the request protocol to ws	
        predicates:
        - Path=/xxx/**

This configuration means that if the address path - Path=/xxx / *, it will be converted into WebSocket protocol to forward the request.

If your project does not have a gateway, the front-end WebSocket establishment request will be directly requested to your WebSocket service to successfully establish a connection.

If your project has a gateway, and you have configured forwarding rules like this, every WebSocket request in the front end will forward the request in the path of ws: / / or wss: / / url, and the connection will not be established successfully.

At this time, we need to do some special processing, not to say much, on the code.

Step 3: intercept the first WebSocket request for special processing.

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.util.ArrayList;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;

@Component
@Slf4j
public class WebSocketFilter implements GlobalFilter, Ordered {

    private final static String DEFAULT_FILTER_PATH = "/ws/info";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
        String scheme = requestUrl.getScheme();
        if (!"ws".equals(scheme) && !"wss".equals(scheme)) {
            return chain.filter(exchange);
        } else if (DEFAULT_FILTER_PATH.equals(requestUrl.getPath())) {
            String wsScheme = convertWsToHttp(scheme);
            URI wsRequestUrl = UriComponentsBuilder.fromUri(requestUrl).scheme(wsScheme).build().toUri();
            exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, wsRequestUrl);
        }
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE - 2;
    }

    private static String convertWsToHttp(String scheme) {
        scheme = scheme.toLowerCase();
        return "ws".equals(scheme) ? "http" : "wss".equals(scheme) ? "https" : scheme;
    }
}

Due to various attempts, I have known that when the front end makes the first WebSocket request, the path is "/ ws/info". What this code does is to intercept this path in the forwarding process, replace ws or wss with http or https to forward the request, so as to recruit civet cat to replace crown prince. I don't know what the ghost is.

I found this method on the Internet, and I don't know who wrote it. There are so many ways.

Now the problem of gateway forwarding has been solved. However, due to the cross domain problem of the request, you need to configure it on the WebSocket service. If you need authentication, the following code also has

import com.alibaba.fastjson.JSONObject;
import io.jsonwebtoken.Claims;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
import org.springframework.web.socket.server.HandshakeInterceptor;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;

import java.security.Principal;
import java.util.Map;

/**
 * Enable webSocket support
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws") //Requests that match this path
                .addInterceptors(new HandshakeInterceptor() {
                    /**
                     * websocket Before shaking hands
                     */
                    @Override
                    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
                        ServletServerHttpRequest req = (ServletServerHttpRequest) request;
                        //Get token authentication
                        String token = req.getServletRequest().getParameter("token");
                        //Resolving token to get user information
                        Principal user = "";  //Authentication. My method is that the front end passes the token, parses the token, and judges whether it is correct or not. return true indicates pass, and false request does not pass.
                        if (user == null) {   //If the token authentication failure user is null, return false to reject the handshake
                            return false;
                        }
                        //Save authenticated user
                        attributes.put("user", user);
                        return true;
                    }

                    @Override
                    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {

                    }
                })
		//After shaking hands
                .setHandshakeHandler(new DefaultHandshakeHandler() {
                    @Override
                    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
                        //Set authentication user
                        return (Principal) attributes.get("user");
                    }
                })
                .setAllowedOrigins("xxxx") 			//Cross domain is set here, which address is allowed to access, and * is all
                .withSockJS();                                  //Using sockJS
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //topic is used for broadcasting, and user sends it separately
        registry.enableSimpleBroker("/topic", "/user");
    }

    /**
     * Message transmission parameter configuration
     */
    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
        registry.setMessageSizeLimit(8192) //Set message byte size
                .setSendBufferSizeLimit(8192)//Set message cache size
                .setSendTimeLimit(10000); //Set message sending time limit milliseconds
    }
}

The WebSocket service configuration is now finished. Here is the sending data.
Because I only need to send data point-to-point, that is, to send data to a user, so I want to subscribe to a user at the front end, and the back end will push according to the user id

Front-end code

    let socket = new SockJS("");	//This is the path you want to establish the connection
    this.stompClient = Stomp.over(socket);
    this.stompClient.heartbeat.outgoing = 10000; //The time of heartbeat detection from the front end to the back end ms
    this.stompClient.heartbeat.incoming = 0; //The length of time for back-end to front-end heartbeat detection ms

    //Get rid of debug printing
    this.stompClient.debug = null;
    
    //Here is the subscription path. If you want to point to point push messages, this is the format "/ user" is the prefix,
    //"/ 123" is the id of the user you want to subscribe to. Of course, my requirement is that you can change anything you want,
    //"/ single" is a logo. It doesn't matter whether it's added or not. It's probably easier to understand. These should correspond to your backend settings
    this.subscribeUrl = '/user/123/single';

    //Start connecting
    this.stompClient.connect({}, () => {
      console.info("[WebSocket] Successful connection!");

      //Subscription service
      this.stompClient.subscribe(this.subscribeUrl, message => {
          console.log(message);	//This is the data pushed from the back end
      });

    }, err => {
      //Disconnect
      this.error("[WebSocket] "+err);
    })

Backend code

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

@Service
public class WebSocketServer {

    @Autowired
    private SimpMessagingTemplate template;

    public void sendGroupMessage(String content) throws Exception {
	//It's easy to broadcast
        template.convertAndSend("/topic/group", content);
    }

    public void sendSingleMessage(String userId, String content) throws Exception {
	//In the point-to-point mode, the backend wants to push the data to the front-end. Use convertandsend to user
	//The three parameters are userId, ID and data content to be pushed
        template.convertAndSendToUser(userId, "/single", content);
    }
}

After finishing the above, it's over. Maybe my friends are happy to test it, but!!!
There is a final problem that hasn't been solved, which was also found during the test.

In the process of front-end and back-end joint debugging, I always prompt cross domain problems after finding front-end requests. I have set cross domain clearly. Why do I still report an error? I couldn't help but think about it. I copied the error message from the front end and translated it. He said that there were multiple origins in the response head. I haven't been exposed to this situation before. I don't know that there can only be one. I can only search for it at a certain time. I found it soon< Solve the BUG of duplicate Origin when Spring Cloud Gateway 2.x cross domain >, this article says that this is the BUG of Spring Cloud Gateway 2.x. the big guy gives the solution code and pastes the source code. If it doesn't match, it will go to the source code. This really puts a lot of pressure on me.

In summary, this BUG will cause duplicate Origin, so the code given by the boss is to solve the duplication, and a complete configuration will be pasted below

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.util.ArrayList;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;

@Component
@Slf4j
public class WebSocketFilter implements GlobalFilter, Ordered {

    private final static String DEFAULT_FILTER_PATH = "/ws/info";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
        String scheme = requestUrl.getScheme();
        if (!"ws".equals(scheme) && !"wss".equals(scheme)) {
            return chain.filter(exchange);
        } else if (DEFAULT_FILTER_PATH.equals(requestUrl.getPath())) {
            String wsScheme = convertWsToHttp(scheme);
            URI wsRequestUrl = UriComponentsBuilder.fromUri(requestUrl).scheme(wsScheme).build().toUri();
            exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, wsRequestUrl);
        }

        //Solution returns multiple origin information
        return chain.filter(exchange).then(Mono.defer(() -> {
            exchange.getResponse().getHeaders().entrySet().stream()
                    .filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
                    .filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
                            || kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)))
                    .forEach(kv ->
                    {
                        kv.setValue(new ArrayList<String>() {{
                            add(kv.getValue().get(0));
                        }});
                    });

            return chain.filter(exchange);
        }));
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE - 2;
    }

    private static String convertWsToHttp(String scheme) {
        scheme = scheme.toLowerCase();
        return "ws".equals(scheme) ? "http" : "wss".equals(scheme) ? "https" : scheme;
    }
}

Go to test again this time, there is no problem.
This is the end of the article. If you have any friends who don't understand, you can comment and inquire at the bottom. If there is something wrong in the article, please correct it.

If you need to reprint, please indicate the source, thank you!

Published 0 original articles, won praise and 25 visitors
Private letter follow

Keywords: Spring socket Java Tomcat

Added by alecapone on Mon, 13 Jan 2020 11:42:48 +0200