Netty 框架學習 —— 新增 WebSocket 支援

低吟不作語發表於2021-07-05

WebSocket 簡介

WebSocket 協議是完全重新設計的協議,旨在為 Web 上的雙向資料傳輸問題提供一個切實可行的解決方案,使得客戶端和伺服器之間可以在任意時刻傳輸訊息

Netty 對於 WebSocket 的支援包含了所有正在使用鐘的主要實現,我們將通過建立一個基於 WebSocket 的實時聊天應用程式來演示這一點


WebSocket 應用程式示例

我們將通過使用 WebSocket 協議來實現一個基於瀏覽器的聊天應用程式,使得多個使用者之間可以同時進行相互通訊

下圖說明了該應用程式的邏輯:

  1. 客戶端傳送一個訊息
  2. 該訊息將被廣播到所有其他連線的客戶端

所有人都在可以和其他人聊天,在示例中,我們只實現伺服器,客戶端則是通過 Web 頁面訪問該聊天室的瀏覽器

1. 新增 WebSocket 支援

在從標準的 HTTP 或者 HTTPS 協議切換到 WebSocket 時,將會使用一種稱為升級握手的機制,使用 WebSocket 的應用協議將始終以 HTTP/S 作為開始,然後再執行升級。這個升級動作發生的確切時刻特定於應用程式,可能會發生在啟動時,也可能會發生在請求了某個特定的 URL 之後

我們的應用程式將採用如下約定:如果被請求的 URL 以 /ws 結尾,那麼把該協議升級為 WebSocket,否則伺服器將使用基本的 HTTP/S

下圖解釋了 Netty 如何處理 HTTP 以及 WebSocket 協議技術,它由一組 ChannelHandler 實現

2. 處理 HTTP 請求

首先,我們實現處理 HTTP 請求的元件,這個元件將提供用於訪問聊天室並顯示由連線的客戶端傳送的訊息的網頁

public class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {

    private final String wsUri;
    private static final File INDEX;

    static {
        URL location = HttpRequestHandler.class.getProtectionDomain().getCodeSource().getLocation();
        try {
            String path = location.toURI() + "index.html";
            path = !path.contains("file") ? path : path.substring(5);
            INDEX = new File(path);
        } catch (URISyntaxException e) {
            throw new IllegalStateException("Unable to locate index.html", e);
        }
    }

    public HttpRequestHandler(String wsUri) {
        this.wsUri = wsUri;
    }

    @Override
    protected void messageReceived(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
        if (wsUri.equalsIgnoreCase(request.uri())) {
            // 如果請求了 WebSocket 協議升級,則增加引用計數,並將它傳遞給下一個 ChannelInboundHandler
            ctx.fireChannelRead(request.retain());
        } else {
            // 讀取 index.html
            RandomAccessFile file = new RandomAccessFile(INDEX, "r");
            DefaultHttpResponse response = new DefaultHttpResponse(request.protocolVersion(), HttpResponseStatus.OK);
            response.headers().set("CONTENT_TYPE", "text/html; charset=UTF-8");
            // 將 HttpResponse 寫到客戶端
            ctx.write(response);
            // 將 index.html 寫到客戶端
            if (ctx.pipeline().get(SslHandler.class) == null) {
                ctx.write(new DefaultFileRegion(file.getChannel(), 0, file.length()));
            } else {
                ctx.write(new ChunkedNioFile(file.getChannel()));
            }
            // 寫 LastHttpContent 並沖刷到客戶端
            ChannelFuture future = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
            // 寫操作完成後關閉 Channel
            future.addListener(ChannelFutureListener.CLOSE);
        }
    }
}

3. 處理 WebSocket 幀

由 IETF 釋出的 WebSocket RFC 定義了六種幀,Netty 為它們每種都提供了一個 POJO 實現。下表列出了這些幀型別,並描述了它們的用法

幀型別 描述
BinaryWebSocketFrame 包含了二進位制資料
TextWebSocketFrame 包含了文字資料
ContinuationWebSocketFrame 包含屬於上一個 BinaryWebSocketFrame 或 TextWebSocketFrame 的文字資料或者二進位制資料
CloseWebSocketFrame 表示一個 CLOSE 請求,包含一個關閉的狀態碼和關閉的原因
PingWebSocketFrame 表示傳輸一個 PongWebSocketFrame
PongWebSocketFrame 作為一個對於 PingWebSocketFrame 的響應被髮送

下述程式碼展示了用於處理 TextWebSocketFrame 的 ChannelInboundHandler,其還將在它的 ChannelGroup 中跟蹤所有活動的 WebSocket 連線

public class TextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    private final ChannelGroup group;

    public TextWebSocketFrameHandler(ChannelGroup group) {
        this.group = group;
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
            // 如果該事件握手成功,則移除 HttpRequestHandler,因為不會再接收到任何 HTTP 訊息了
            ctx.pipeline().remove(HttpRequestHandler.class);
            // 通知所有已經連線的 WebSocket 客戶端新的客戶端已經連線上了
            group.writeAndFlush(new TextWebSocketFrame("Client " + ctx.channel() + " joined"));
            // 將新的 WebSocket Channel 新增到 ChannelGroup
            group.add(ctx.channel());
        } else {
            super.userEventTriggered(ctx, evt);
        }
    }

    @Override
    protected void messageReceived(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        // 增加訊息的引用計數,並將它寫到 ChannelGroup 中所有已經連線的客戶端
        group.writeAndFlush(msg.retain());
    }
}

4. 初始化 ChannelPipeline

為了將 ChannelHandler 安裝到 ChannelPipeline 中,我們需要擴充套件 ChannelInitializer 並實現 initChannel() 方法

public class ChatServerInitializer extends ChannelInitializer<Channel> {

    private final ChannelGroup group;

    public ChatServerInitializer(ChannelGroup group) {
        this.group = group;
    }

    @Override
    protected void initChannel(Channel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();
        pipeline.addLast(new HttpServerCodec());
        pipeline.addLast(new ChunkedWriteHandler());
        pipeline.addLast(new HttpObjectAggregator(64 * 1024));
        pipeline.addLast(new HttpRequestHandler("/ws"));
        pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
        pipeline.addLast(new TextWebSocketFrameHandler(group));
    }
}

對於 initChannel() 方法的呼叫,通過安裝所有必需的 ChannelHandler 來設定該新註冊的 Channel 的 ChannelPipeline。Netty 的 WebSocketServerProtocolHandler 處理了所有委託管理的 WebSocket 幀型別以及升級握手本身。如果握手本身,那麼所需的 ChannelHandler 將被新增到 ChannelPipeline 中,而那些不再需要的 ChannelHandler 則會被移除

5. 引導

最後一步是引導該伺服器,並安裝 ChatServerInitializer 的程式碼,這將由 ChatServer 類處理

public class ChatServer {

    private final ChannelGroup channelGroup = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE);
    private final EventLoopGroup group = new NioEventLoopGroup();
    private Channel channel;

    public ChannelFuture start(InetSocketAddress address) {
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(group)
                .channel(NioServerSocketChannel.class)
                .childHandler(createInitializer(channelGroup));
        ChannelFuture future = bootstrap.bind(address);
        future.syncUninterruptibly();
        channel = future.channel();
        return future;
    }

    protected ChannelInitializer<Channel> createInitializer(ChannelGroup group) {
        return new ChatServerInitializer(group);
    }

    public void destroy() {
        if (channel != null) {
            channel.close();
        }
        channelGroup.close();
        group.shutdownGracefully();
    }

    public static void main(String[] args) {
        if (args.length != 1) {
            System.err.println("Please give port as argument");
            System.exit(1);
        }
        int port = Integer.parseInt(args[0]);
        final ChatServer endpoint = new ChatServer();
        ChannelFuture future = endpoint.start(new InetSocketAddress(port));
        Runtime.getRuntime().addShutdownHook(new Thread(endpoint::destroy));
        future.channel().closeFuture().syncUninterruptibly();
    }
}

相關文章