前言
之前的項目使用的是Zuul網(wǎng)關(guān),有個需求需要用到WebSocket,所以一直在查Spring Cloud Zuul 轉(zhuǎn)發(fā) WebSocket請求的教程和文章,查來查去,發(fā)現(xiàn)不行,Zuul對WebSocket的支持不是很友好。
總結(jié)下來就是以下幾點:
- 高版本的websocket在第一次http請求后,使用的是更快速的tcp連接,zuul網(wǎng)關(guān)只能管理http請求,并且不支持tcp以及udp請求
- zuul轉(zhuǎn)發(fā)websocket時,會將websocket降級為http請求轉(zhuǎn)發(fā)掉(輪詢的方式,效率不是很理想),換句話說就是不支持轉(zhuǎn)發(fā)長連接,zuul2好像可以。
這讓我很發(fā)愁,畢竟我是一個強迫癥,做自己的項目,當(dāng)然不能湊活,于是我就找其他辦法,在查的偶然間,我發(fā)現(xiàn)他們說Spring Cloud Gateway 支持轉(zhuǎn)發(fā)WebSocket,我眼睛一亮,但是換網(wǎng)關(guān),也不能說換就換,就搜了搜能不能在Zuul的基礎(chǔ)上想想辦法,網(wǎng)上的辦法有很多,但可惜我覺得都不理想,沒有辦法,換Gateway吧,話不多說,上教程。
第一步,引入Gateway的 maven依賴
<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>
在這里要說個坑(敲黑板,劃重點了)由于Gateway自帶Netty,和tomcat沖突,所以看上面,第二個依賴,spring-boot-starter-websocket 這個里面自帶spring-boot-starter-web jar包,而spring-boot-starter-web jar包里面包含tomcat一系列jar包,所以這里要給排除掉,不然啟動會報錯,如果依然報錯,看看報錯提示,是不是提示netty和tomcat沖突,在找找其他jar包里有沒有包含tomcat的,排除掉。
第二步,依賴引入完畢,那么開始寫配置文件
Gateway 有兩種方式,一種是配置文件,一種是攔截器
下面是yml文件的配置
spring:
cloud:
#spring-cloud-gateway
gateway:
routes:
- id: xxx #路由的id,參數(shù)配置不要重復(fù),如不配置,Gateway會使用生成一個uuid代替。
uri: lb://xxx #lb:// 表示從注冊中心獲取路徑進(jìn)行轉(zhuǎn)發(fā),xxx是注冊在注冊中心的微服務(wù)的名稱
predicates:
- Path=/xxx/** #符合該路徑后,轉(zhuǎn)發(fā)
#WebSocket轉(zhuǎn)發(fā)配置
- id: xxx
uri: lb:ws://xxx #lb:ws://xxx 表示從注冊中心獲取路徑轉(zhuǎn)發(fā),并且請求協(xié)議換成ws
predicates:
- Path=/xxx/**
好了,配置文件寫好了,- Path=/xxx/** 設(shè)置好斷言,如果你的請求路徑符合這個規(guī)則,Gateway就會進(jìn)行相應(yīng)的轉(zhuǎn)發(fā),是不是很簡單,但是這里有個問題,普通的轉(zhuǎn)發(fā)無所謂,到這里就結(jié)束了,可是WebSocket的轉(zhuǎn)發(fā),會有點問題,我使用的是SockJS + Stomp + WebSocket,在這里就要簡單介紹一下 SockJS、Stomp、WebSocket了。
WebSocket
- WebSocke是 HTML5 提供的一種在單個 TCP 連接上進(jìn)行全雙工通訊的協(xié)議。
- WebSocket協(xié)議是基于TCP的一種新的網(wǎng)絡(luò)協(xié)議,是一個應(yīng)用層協(xié)議,是TCP/IP協(xié)議的子集。
- 它實現(xiàn)了瀏覽器與服務(wù)器全雙工(full-duplex)通信,客戶端和服務(wù)器都可以向?qū)Ψ街鲃影l(fā)送和接收數(shù)據(jù)。在 WebSocket API 中,瀏覽器和服務(wù)器只需要完成一次握手,兩者之間就直接可以創(chuàng)建持久性的連接,并進(jìn)行雙向數(shù)據(jù)傳輸。
- 在JS中創(chuàng)建WebSocket后,會有一個HTTP請求從瀏覽器發(fā)向服務(wù)器。在取得服務(wù)器響應(yīng)后,建立的連接會使用HTTP升級將HTTP協(xié)議轉(zhuǎn)換為WebSocket協(xié)議。也就是說,使用標(biāo)準(zhǔn)的HTTP協(xié)議無法實現(xiàn)WebSocket,只有支持那些協(xié)議的專門瀏覽器才能正常工作。由于WebScoket使用了自定義協(xié)議,所以URL與HTTP協(xié)議略有不同。未加密的連接為ws://,而不是http://。加密的連接為wss://,而不是https://,所以如果你的項目使用了網(wǎng)關(guān),又想使用WebSocket,在網(wǎng)關(guān)轉(zhuǎn)發(fā)這方面,就會遇到問題。
SockJS
- SockJS是一個瀏覽器JavaScript庫,它提供了一個連貫的、跨瀏覽器的JavaScript API,在瀏覽器和web服務(wù)器之間建立一個低延遲、全雙工、跨域通信通道。SockJs的一大好處是提供了瀏覽器兼容性,優(yōu)先使用原生的WebSocket,在不支持websocket的瀏覽器中,會自動降為輪詢的方式。
Stomp
- STOMP(Simple Text-Orientated Messaging Protocol),中文為:面向消息的簡單文本協(xié)議
- websocket定義了兩種傳輸信息類型:文本信息和二進(jìn)制信息。類型雖然被確定,但是他們的傳輸體是沒有規(guī)定的。所以,需要用一種簡單的文本傳輸類型來規(guī)定傳輸內(nèi)容,它可以作為通訊中的文本傳輸協(xié)議。
- STOMP是基于幀的協(xié)議,客戶端和服務(wù)器使用STOMP幀流通訊
- 一個STOMP客戶端是一個可以以兩種模式運行的用戶代理,可能是同時運行兩種模式。
- 作為生產(chǎn)者,通過SEND框架將消息發(fā)送給服務(wù)器的某個服務(wù)
- 作為消費者,通過SUBSCRIBE制定一個目標(biāo)服務(wù),通過MESSAGE框架,從服務(wù)器接收消息。
總結(jié)
- SockJS 提供了瀏覽器兼容性,在不支持WebSocket的瀏覽器中,會自動降為輪詢的方式。
- Stomp 簡單理解就是規(guī)定了傳輸內(nèi)容,作為通訊中的文本傳輸協(xié)議;這個我也是看的一知半解,但是我覺得如果你對WebSocket了解足夠多的話,你就清楚為什么要用它。
- WebSocket 實現(xiàn)了瀏覽器與服務(wù)器全雙工通信,客戶端和服務(wù)器都可以向?qū)Ψ街鲃影l(fā)送和接收數(shù)據(jù),而且第一次建立WebSocket的連接的協(xié)議是HTTP或HTTPS協(xié)議,url使用的是http://或https://,建立成功之后,url使用的是ws://或wss://。
看到這里,你大概就明白了,我說的問題在哪里了(劃重點)
由于WebSocket第一次連接使用的是http協(xié)議或者是https協(xié)議,并且咱們的配置文件還是這樣配置的:
- id: xxx
uri: lb:ws://xxx #lb:ws://xxx 表示從注冊中心獲取路徑轉(zhuǎn)發(fā),并且請求協(xié)議換成ws
predicates:
- Path=/xxx/**
這樣配置的意思就是,如果符合 - Path=/xxx/** 這個地址路徑,那么就會轉(zhuǎn)換成WebSocket協(xié)議來進(jìn)行轉(zhuǎn)發(fā)請求。
如果你的項目沒有網(wǎng)關(guān),前端的WebSocket建立請求會直接請求到你的WebSocket服務(wù)上,成功建立連接。
如果你的項目有網(wǎng)關(guān),而且你還這樣配置了轉(zhuǎn)發(fā)規(guī)則,那么前端的每一次WebSocket請求都會以url是ws://或wss://這個路徑進(jìn)行轉(zhuǎn)發(fā)請求,連接就不會建立成功。
那么這個時候就要做一下特殊處理,話不多說,上代碼。
第三步,攔截第一次WebSocket請求,做特殊處理。
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);
}
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;
}
}
由于種種嘗試,我已經(jīng)知道前端進(jìn)行第一次WebSocket請求的時候,路徑是"/ws/info",這段代碼做的就是針對這個路徑在轉(zhuǎn)發(fā)過程中進(jìn)行攔截,把ws或者wss替換成http或者h(yuǎn)ttps進(jìn)行轉(zhuǎn)發(fā)請求,來一招貍貓換太子,神不知鬼不覺。
這個方法是我在網(wǎng)上找到的,也不知道是哪位大佬寫的,辦法真多。
那么現(xiàn)在網(wǎng)關(guān)轉(zhuǎn)發(fā)的問題解決了,但是由于請求會存在跨域的問題,要在WebSocket的服務(wù)上進(jìn)行一下配置,還有如果你需要鑒權(quán)的話,下面的代碼也有
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;
/**
* 開啟webSocket支持
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws") //符合這個路徑的請求
.addInterceptors(new HandshakeInterceptor() {
/**
* websocket握手之前
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
ServletServerHttpRequest req = (ServletServerHttpRequest) request;
//獲取token認(rèn)證
String token = req.getServletRequest().getParameter("token");
//解析token獲取用戶信息
Principal user = ""; //鑒權(quán),我的方法是,前端把token傳過來,解析token,判斷正確與否,return true表示通過,false請求不通過。
if (user == null) { //如果token認(rèn)證失敗user為null,返回false拒絕握手
return false;
}
//保存認(rèn)證用戶
attributes.put("user", user);
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
})
//握手之后
.setHandshakeHandler(new DefaultHandshakeHandler() {
@Override
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
//設(shè)置認(rèn)證用戶
return (Principal) attributes.get("user");
}
})
.setAllowedOrigins("xxxx") //這里設(shè)置跨域,允許哪個地址訪問,*號是所有
.withSockJS(); //使用sockJS
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
//topic用來廣播,user單獨發(fā)送
registry.enableSimpleBroker("/topic", "/user");
}
/**
* 消息傳輸參數(shù)配置
*/
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.setMessageSizeLimit(8192) //設(shè)置消息字節(jié)數(shù)大小
.setSendBufferSizeLimit(8192)//設(shè)置消息緩存大小
.setSendTimeLimit(10000); //設(shè)置消息發(fā)送時間限制毫秒
}
}
WebSocket服務(wù)配置現(xiàn)在寫完了,下面就是發(fā)送數(shù)據(jù)了。
由于我現(xiàn)在只需要點對點的發(fā)送數(shù)據(jù),也就是給某一個用戶發(fā)送數(shù)據(jù),所以,在前端要訂閱某一個用戶,而后端根據(jù)這個用戶id進(jìn)行推送
前端代碼
let socket = new SockJS(""); //這里就是你要建立連接的路徑
this.stompClient = Stomp.over(socket);
this.stompClient.heartbeat.outgoing = 10000; //前端對后端進(jìn)行心跳檢測的時長 ms
this.stompClient.heartbeat.incoming = 0; //后端對前端就行心跳檢測的時長 ms
//去掉debug打印
this.stompClient.debug = null;
//這里是訂閱路徑,如果你要點對點的推送消息,就是這種格式”/user“是前綴,
//“/123”是你要訂閱的用戶的id,當(dāng)然了我這個需求是這樣的,你想換成什么都可以,
//“/single”就是個標(biāo)識,加不加無所謂,加上可能比較容易理解吧,這些都要跟你的后端設(shè)置對應(yīng)上
this.subscribeUrl = '/user/123/single';
//開始連接
this.stompClient.connect({}, () => {
console.info("[WebSocket] 連接成功!");
//進(jìn)行訂閱服務(wù)
this.stompClient.subscribe(this.subscribeUrl, message => {
console.log(message); //這里就是后端推送過來的數(shù)據(jù)
});
}, err => {
//斷開連接
this.error("[WebSocket] "+err);
})
后端代碼
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 {
//廣播就很簡單了
template.convertAndSend("/topic/group", content);
}
public void sendSingleMessage(String userId, String content) throws Exception {
//點對點的這種,后端想推送到前端數(shù)據(jù),使用convertAndSendToUser
//三個參數(shù)分別是 userId,標(biāo)識,想要推送的數(shù)據(jù)內(nèi)容
template.convertAndSendToUser(userId, "/single", content);
}
}
以上寫完之后,就結(jié)束了,可能小伙伴們滿心歡喜的去測試了,但是?。?!
還有最后一個問題沒有解決,這個問題也是在測試過程中發(fā)現(xiàn)的。
我在前后端聯(lián)調(diào)過程中,發(fā)現(xiàn)前端請求后總是提示跨域問題,我明明已經(jīng)設(shè)置跨域了呀,為什么還報錯?百思不得其解,英文不好的我復(fù)制了前端的報錯提示,翻譯了一下,他說響應(yīng)頭里有多個Origin,之前也沒接觸過這種情況,不知道只能有一個,只能去某度去搜搜,果不其然,很快就搜到了《解決Spring Cloud Gateway 2.x跨域時出現(xiàn)重復(fù)Origin的BUG》,這篇文章說這是Spring Cloud Gateway 2.x 的BUG,大佬給出了解決的代碼,還貼了源碼,一言不合就上源碼,真是讓我壓力很大。
總結(jié)來說這個BUG就是會造成重復(fù)的Origin,那么大佬給的代碼就是解決重復(fù),下面貼一個完整的配置
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);
}
//解決返回多個origin信息
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;
}
}
這次再去測試測試,已經(jīng)沒問題了把。
文章到這里就結(jié)束了,如果有不懂的小伙伴可以在底下評論問詢,文章有寫的不對的地方,還請各位大佬多多指正。
如需轉(zhuǎn)載,請注明出處,謝謝!