1. 程式人生 > >SpringBoot+Netty實現WebSocket伺服器

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等.