SpringBoot+Netty實現WebSocket伺服器
前言
傳統的請求-應答模式(http)越來越不能滿足現實需求,伺服器過於被動,而採用輪訓或者long poll的方式過於浪費資源,這便有了WebSocket。WebSocket是HTML5出的東西(協議),也就是說HTTP協議沒有變化,或者說沒關係,但HTTP是不支援持久連線的(長連線,迴圈連線的不算)首先,Websocket是一個持久化的協議,相對於HTTP這種非持久的協議來說,二者區別如下。
- HTTP是執行在TCP協議傳輸層上的應用協議,而WebSocket是通過HTTP協議協商如何連線,然後獨立執行在TCP協議傳輸層上的應用協議。
- Websocket是一個持久化的協議,相對於HTTP這種非持久的協議來說。
- websocket約定了一個通訊的規範,通過一個握手的機制,客戶端和伺服器之間能建立一個類似tcp的連線,從而方便它們之間的通訊
接下來使用一個小例子來實現伺服器往客戶端的主動推送功能。
示例
index.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>無標題文件</title> <script type="text/javascript"> var socket; if(!window.WebSocket){ window.WebSocket = window.MozWebSocket; } if(window.WebSocket){ socket = new WebSocket("ws://127.0.0.1:12345/ws"); socket.onmessage = function(event){ var ta = document.getElementById('responseText'); ta.value += event.data+"\r\n"; }; socket.onopen = function(event){ var ta = document.getElementById('responseText'); ta.value = "這裡顯示伺服器推送資訊"+"\r\n"; }; socket.onclose = function(event){ var ta = document.getElementById('responseText'); ta.value = ""; ta.value = "WebSocket 關閉"+"\r\n"; }; }else{ alert("您的瀏覽器不支援WebSocket協議!"); } function send(message){ if(!window.WebSocket){return;} if(socket.readyState == WebSocket.OPEN){ socket.send(message); }else{ alert("WebSocket 連線沒有建立成功!"); } } </script> </head> <body> <form onSubmit="return false;"> <input type="text" name="message" value="這裡輸入訊息" /> <br /> <br /> <input type="button" value="傳送 WebSocket 請求訊息" onClick="send(this.form.message.value)" /> <hr color="blue" /> <h3>服務端返回的應答訊息</h3> <textarea id="responseText" style="width: 1024px;height: 300px;"></textarea> </form> </body> </html>
第一次握手請求由客戶端發起,當伺服器收到握手請求後,返回響應,這時客戶端收到詳情並開啟socket完成握手,這樣就建立了伺服器與客戶端之間的tcp長連線,對於 WebSocket 來說,它必須依賴HTTP協議的第一次握手 ,握手成功後,資料就直接從 TCP 通道傳輸,與 HTTP 無關了。
伺服器目錄結構如下:
首先看下啟動類:
WebsocketApplication.java
package com.jhz.websocket; import com.jhz.websocket.server.NettyServer; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class WebsocketApplication { public static void main(String[] args) throws Exception { SpringApplication.run(WebsocketApplication.class, args); new NettyServer(12345).start(); } }
在啟動類中啟動NettyServer(Netty伺服器)。
NettyServer.java
package com.jhz.websocket.server;
import com.jhz.websocket.handler.WebSocketHandler;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
/**
* @author jhz
* @date 18-10-21 下午9:45
*/
public class NettyServer {
private final int port;
public NettyServer(int port) {
this.port = port;
}
public void start() throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup group = new NioEventLoopGroup();
try {
ServerBootstrap sb = new ServerBootstrap();
sb.option(ChannelOption.SO_BACKLOG, 1024);
sb.group(group, bossGroup) // 繫結執行緒池
.channel(NioServerSocketChannel.class) // 指定使用的channel
.localAddress(this.port)// 繫結監聽埠
.childHandler(new ChannelInitializer<SocketChannel>() { // 繫結客戶端連線時候觸發操作
@Override
protected void initChannel(SocketChannel ch) throws Exception {
System.out.println("收到新連線");
//websocket協議本身是基於http協議的,所以這邊也要使用http解編碼器
ch.pipeline().addLast(new HttpServerCodec());
//以塊的方式來寫的處理器
ch.pipeline().addLast(new ChunkedWriteHandler());
ch.pipeline().addLast(new HttpObjectAggregator(8192));
ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws"));
ch.pipeline().addLast(new WebSocketHandler());
}
});
ChannelFuture cf = sb.bind().sync(); // 伺服器非同步建立繫結
System.out.println(NettyServer.class + " 啟動正在監聽: " + cf.channel().localAddress());
cf.channel().closeFuture().sync(); // 關閉伺服器通道
} finally {
group.shutdownGracefully().sync(); // 釋放執行緒池資源
bossGroup.shutdownGracefully().sync();
}
}
}
這裡要注意這四個Handler,HttpServerCodec、ChunkedWriteHandler、HttpObjectAggregator、WebSocketServerProtocolHandler,其中HttpServerCodec用於對HttpObject訊息進行編碼和解碼,但是HTTP請求和響應可以有很多訊息資料,你需要處理不同的部分,可能也需要聚合這些訊息資料,這是很麻煩的。為了解決這個問題,Netty提供了一個聚合器,它將訊息部分合併到FullHttpRequest和FullHttpResponse,因此不需要擔心接收碎片訊息資料,這就是HttpObjectAggregator的作用;ChunkedWriteHandler,允許通過處理ChunkedInput來寫大的資料塊;而WebSocketServerProtocolHandler是Netty封裝好的WebSocket協議處理類,有了它可以少寫很多步驟,包括握手的過程,以及url的定義(這裡的/ws其實就定義了url指定的字尾)。
WebSocketHandler.java
package com.jhz.websocket.handler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import java.util.Scanner;
/**
* @author jhz
* @date 18-10-21 下午9:51
*/
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame>{
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("與客戶端建立連線,通道開啟!");
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.out.println("與客戶端斷開連線,通道關閉!");
}
@Override
protected void messageReceived(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
System.out.println("客戶端收到伺服器資料:" + msg.text());
Scanner s = new Scanner(System.in);
System.out.println("伺服器推送:");
while(true) {
String line = s.nextLine();
if(line.equals("exit")) {
ctx.channel().close();
break;
}
String resp= "(" +ctx.channel().remoteAddress() + ") :" + line;
ctx.writeAndFlush(new TextWebSocketFrame(resp));
}
}
}
可以看到,建立長連線的過程都由WebSocketServerProtocolHandler為我們做完了(但是個人覺得還是要去自己寫一次http握手的處理過程,Netty也做了一些封裝,非常方便),客戶端與伺服器之間形成了一個全雙工通訊的管道。
DefaultController.java
package com.jhz.websocket.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* @author jhz
* @date 18-10-21 下午8:25
*/
@Controller
public class DefaultController {
@RequestMapping("/")
public String index(){
return "index";
}
}
application.properties
# 定位模板的目錄
spring.mvc.view.prefix=classpath:/templates/
# 給返回的頁面新增字尾名
spring.mvc.view.suffix=.html
測試結果
在前端頁面傳送123:
在伺服器的控制檯可以看到已經收到了訊息:
在伺服器控制檯推送訊息“456”、“789”,再次檢視前端頁面:
WebSocket的小Demo便完成了。
小結
在前面的IO章節中,已經對比了使用Netty與傳統的NIO方式的區別,Netty是高度封裝的NIO框架,用起來會比傳統的NIO程式設計方式方便很多,而其對WebSocket的支援同樣為我們帶來了極大的便利,WebSocket伺服器在接收到客戶端訊息時需要對其判斷,這個訊息是http訊息還是已經建立tcp連線的WebSocketFrame訊息,若是前者,則代表是握手請求,伺服器需要對握手請求進行響應,通常的寫法如下:
private void handHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) {
//如果不是WebSocket握手請求訊息,那麼就返回 HTTP 400 BAD REQUEST 響應給客戶端。
if (!req.getDecoderResult().isSuccess()
|| !("websocket".equals(req.headers().get("Upgrade")))) {
sendHttpResponse(ctx, req,
new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST));
return;
}
//如果是握手請求,那麼就進行握手
WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(
WEB_SOCKET_URL, null, false);
handshaker = wsFactory.newHandshaker(req);
if (handshaker == null) {
WebSocketServerHandshakerFactory.sendUnsupportedWebSocketVersionResponse(ctx.channel());
} else {
// 通過它構造握手響應訊息返回給客戶端,
// 同時將WebSocket相關的編碼和解碼類動態新增到ChannelPipeline中,用於WebSocket訊息的編解碼,
// 新增WebSocketEncoder和WebSocketDecoder之後,服務端就可以自動對WebSocket訊息進行編解碼了
handshaker.handshake(ctx.channel(), req);
}
}
而使用WebSocketServerProtocolHandler就能為我們省下很多事了。其實通常使用tomcat不需要我們實現WebSocket,從tomcat7之後就開始支援Websocket了,這裡為了進一步的學習一下Netty,但是萬一不用Tomcat呢?相對於Tomcat這種Web Server(顧名思義主要是提供Web協議相關的服務的),Netty是一個 是一個Network Server,是處於Web Server更下層的網路框 架,也就是說你可以使用Netty模仿Tomcat做一個提供HTTP服務的Web容器。簡而言之,Netty通過使用NIO的很多新特性,對TCP/UDP程式設計進行了簡化和封 裝,提供了更容易使用的網路程式設計介面,讓你可以根據自己的需要封裝獨特的HTTP Server或者FTP Server等.