Khắc phục sự cố kết nối WebSocket trong Netty: Lỗi bắt tay và ClassCastException

Trong quá trình phát triển ứng dụng sử dụng Netty để xây dựng máy chủ WebSocket, một tình huống lỗi khó hiểu đã phát sinh liên quan đến quá trình bắt tay (handshake) và xử lý thông điệp.

Thiết lập Máy chủ WebSocket ban đầu

Dưới đây là đoạn mã khởi tạo máy chủ WebSocket với Netty:

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
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.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class WebSocketServerInitializer {
    private static final Logger logger = LoggerFactory.getLogger(WebSocketServerInitializer.class);
    private final int serverPort;

    public WebSocketServerInitializer(int port) {
        this.serverPort = port;
    }

    public void start() {
        EventLoopGroup masterGroup = new NioEventLoopGroup(); // Nhóm luồng xử lý kết nối mới
        EventLoopGroup workerGroup = new NioEventLoopGroup(); // Nhóm luồng xử lý dữ liệu I/O
        try {
            ServerBootstrap serverBoot = new ServerBootstrap();
            serverBoot.group(masterGroup, workerGroup)
                    .channel(NioServerSocketChannel.class) // Sử dụng kênh NIO TCP
                    .handler(new LoggingHandler()) // Ghi log cấp độ server
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketCh) {
                            ChannelPipeline pipeline = socketCh.pipeline();
                            pipeline.addLast("http-codec", new HttpServerCodec()); // Mã hóa/giải mã HTTP
                            pipeline.addLast("chunked-writer", new ChunkedWriteHandler()); // Hỗ trợ gửi file lớn
                            pipeline.addLast("http-aggregator", new HttpObjectAggregator(8192)); // Gom các phần HTTP thành FullHttpRequest/Response
                            // Handler chính cho WebSocket, xử lý bắt tay và đóng gói khung dữ liệu
                            pipeline.addLast("ws-protocol-handler", new WebSocketServerProtocolHandler("/ws-path"));
                            pipeline.addLast("auth-handler", new CustomAuthHandler()); // Handler tùy chỉnh để xác thực
                            pipeline.addLast("msg-parser", new CustomMessageParserHandler()); // Handler để phân tích tin nhắn
                            pipeline.addLast("data-router", new CustomDataRouterHandler()); // Handler để định tuyến dữ liệu
                            pipeline.addLast("error-handler", new GlobalExceptionHandler()); // Handler cuối cùng xử lý ngoại lệ
                        }
                    });
            ChannelFuture future = serverBoot.bind(serverPort).sync(); // Bắt đầu lắng nghe cổng
            logger.info("Dịch vụ WebSocket đã khởi động thành công trên cổng {}", serverPort);
            future.channel().closeFuture().sync(); // Chờ server đóng
        } catch (InterruptedException e) {
            logger.error("Lỗi khi khởi động dịch vụ WebSocket", e);
            Thread.currentThread().interrupt();
        } finally {
            masterGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
            logger.info("Dịch vụ WebSocket đã đóng.");
        }
    }

    // Các handler tùy chỉnh được định nghĩa ở đây để minh họa
    static class CustomAuthHandler extends ChannelInboundHandlerAdapter {
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            // Logic xác thực người dùng
            logger.info("Đang xử lý xác thực trong CustomAuthHandler...");
            super.channelRead(ctx, msg); // Chuyển tiếp đến handler tiếp theo
        }
    }

    static class CustomMessageParserHandler extends ChannelInboundHandlerAdapter {
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            logger.info("Đang phân tích tin nhắn trong CustomMessageParserHandler...");
            super.channelRead(ctx, msg);
        }
    }

    static class CustomDataRouterHandler extends ChannelInboundHandlerAdapter {
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            logger.info("Đang định tuyến dữ liệu trong CustomDataRouterHandler...");
            if (msg instanceof TextWebSocketFrame) {
                TextWebSocketFrame textFrame = (TextWebSocketFrame) msg;
                logger.info("Nhận được tin nhắn WebSocket: {}", textFrame.text());
                // Gửi phản hồi lại client
                ctx.channel().writeAndFlush(new TextWebSocketFrame("Server đã nhận: " + textFrame.text()));
            }
            super.channelRead(ctx, msg);
        }
    }

    static class GlobalExceptionHandler extends ChannelInboundHandlerAdapter {
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
            logger.error("Lỗi không mong muốn trong pipeline: {}", cause.getMessage());
            ctx.close(); // Đóng kênh khi có lỗi
        }
    }

    public static void main(String[] args) {
        new WebSocketServerInitializer(8888).start();
    }
}

Vấn đề ban đầu: Không hoàn thành bắt tay WebSocket

Khi sử dụng công cụ kiểm thử như ApiFox để kết nối tới địa chỉ ws://localhost:8888/ws-path/token/*** (trong đó /ws-path là đường dẫn cấu hình cho WebSocketServerProtocolHandler và token/*** là phần tùy chỉnh để xác thực), máy khách dường như bị treo, chờ phản hồi từ máy chủ mà không thể thiết lập kết nối WebSocket.

Điều đáng ngạc nhiên là yêu cầu kết nối này vẫn đi qua được CustomAuthHandler (tương đương với WebLoginHandler trong mã gốc). Sau khi điều tra, chúng tôi nhận ra rằng gói tin bắt tay WebSocket đầu tiên từ máy khách được Netty đóng gói thành một đối tượng FullHttpRequest. Do đó, nó đi vào các handler HTTP thông thường trong pipeline, bao gồm cả CustomAuthHandler.

Tuy nhiên, vấn đề cốt lõi là máy chủ Netty không tự động phản hồi gói tin bắt tay. Mặc dù WebSocketServerProtocolHandler đã được thêm vào pipeline, quá trình bắt tay WebSocket tiêu chuẩn dường như không được kích hoạt, khiến máy khách không nhận được phản hồi và không thể hoàn tất kết nối.

Thử nghiệm giải pháp: Bắt tay thủ công

Để khắc phục sự cố, một giải pháp tạm thời đã được thử nghiệm là tự thực hiện quá trình bắt tay WebSocket trong CustomAuthHandler:

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker;
import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

// ... (các import khác)

static class CustomAuthHandler extends ChannelInboundHandlerAdapter {
    private static final Logger logger = LoggerFactory.getLogger(CustomAuthHandler.class);
    private final String wsUri = "ws://localhost:8888/ws-path"; // URI cấu hình cho WebSocket

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (msg instanceof FullHttpRequest) {
            FullHttpRequest httpRequest = (FullHttpRequest) msg;
            logger.info("CustomAuthHandler nhận FullHttpRequest cho bắt tay: {}", httpRequest.uri());

            // Thực hiện bắt tay thủ công
            WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(wsUri, null, false);
            WebSocketServerHandshaker handshaker = wsFactory.newHandshaker(httpRequest);

            if (handshaker == null) {
                // Phiên bản WebSocket không được hỗ trợ
                WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
                logger.warn("Phiên bản WebSocket không được hỗ trợ.");
            } else {
                // Hoàn tất bắt tay
                handshaker.handshake(ctx.channel(), httpRequest);
                ctx.channel().writeAndFlush(new TextWebSocketFrame("Đăng nhập và bắt tay thành công!"));
                logger.info("Bắt tay WebSocket thủ công hoàn tất.");
                // Không chuyển tiếp FullHttpRequest nữa vì nó đã được xử lý như một bắt tay.
                // Nếu muốn giữ handler này trong pipeline sau bắt tay, cần logic phức tạp hơn.
                // Ở đây, giả định handler này chỉ xử lý yêu cầu bắt tay.
            }
            // Không gọi super.channelRead(ctx, msg); để ngăn FullHttpRequest đi tiếp vào pipeline
            // nơi WebSocketServerProtocolHandler sẽ xử lý nó lần nữa.
        } else {
            // Chuyển tiếp các tin nhắn không phải FullHttpRequest (ví dụ: TextWebSocketFrame sau khi bắt tay)
            super.channelRead(ctx, msg);
        }
    }
}

Sau khi triển khai bắt tay thủ công, máy khách đã có thể kết nối thành công và nhận được tin nhắn "Đăng nhập và bắt tay thành công!". Tuy nhiên, khi máy khách gửi tin nhắn thứ hai, một lỗi mới xuất hiện:

io.netty.handler.codec.http.websocketx.TextWebSocketFrame cannot be cast to io.netty.handler.codec.http.FullHttpRequest

Lỗi này xảy ra bên trong phương thức channelRead của WebSocketServerProtocolHandshakeHandler (một thành phần nội bộ của WebSocketServerProtocolHandler). Sau khi kết nối được thiết lập, máy khách gửi các tin nhắn WebSocket dưới dạng TextWebSocketFrame. Tuy nhiên, WebSocketServerProtocolHandshakeHandler vẫn cố gắng ép kiểu các tin nhắn này thành FullHttpRequest, dẫn đến ngoại lệ.

Phân tích nguyên nhân sâu xa

Vấn đề nằm ở cách Netty xử lý WebSocketServerProtocolHandler. Handler này có một thành phần là WebSocketServerProtocolHandshakeHandler, có nhiệm vụ chính là thực hiện quá trình bắt tay WebSocket. Nó sẽ kiểm tra gói tin đầu tiên (là FullHttpRequest) để xem có phải là yêu cầu bắt tay WebSocket hợp lệ hay không.

Khi máy khách gửi yêu cầu kết nối lần đầu, nó là một FullHttpRequest. Nếu đường dẫn URI trong yêu cầu của máy khách (ví dụ: /ws-path/token/***) không khớp chính xác với đường dẫn được cấu hình trong WebSocketServerProtocolHandler (ví dụ: /ws-path), thì WebSocketServerProtocolHandler sẽ coi đây là một yêu cầu HTTP thông thường. Nó sẽ bỏ qua quá trình bắt tay WebSocket và chuyển tiếp FullHttpRequest xuống pipeline cho các handler tiếp theo xử lý (như CustomAuthHandler).

Đây chính là lý do ban đầu máy khách không nhận được phản hồi bắt tay tự động. Vì đường dẫn không khớp, WebSocketServerProtocolHandler đã không làm nhiệm vụ của nó. Khi chúng ta thực hiện bắt tay thủ công trong CustomAuthHandler, máy khách nghĩ rằng kết nối đã được thiết lập. Sau đó, các tin nhắn tiếp theo của máy khách (là TextWebSocketFrame) lại đi vào pipeline và đến WebSocketServerProtocolHandshakeHandler. Handler này vẫn đang "chờ" một FullHttpRequest cho quá trình bắt tay, và khi nó nhận được TextWebSocketFrame, việc ép kiểu thất bại và gây ra ClassCastException.

Giải pháp chính xác

Nguyên nhân gốc rễ là sự không khớp giữa đường dẫn WebSocket mà máy khách sử dụng và đường dẫn được cấu hình trong WebSocketServerProtocolHandler.

Giải pháp chính xác và đơn giản nhất là đảm bảo rằng đường dẫn truy cập của máy khách phải khớp chính xác với đường dẫn được cấu hình trong WebSocketServerProtocolHandler. Ví dụ, nếu WebSocketServerProtocolHandler được cấu hình với /ws-path, thì máy khách phải kết nối tới ws://localhost:8888/ws-path.

Nếu bạn muốn thực hiện các logic xác thực hoặc xử lý dữ liệu khác trước khi quá trình bắt tay WebSocket hoàn tất, bạn có hai lựa chọn:

  1. Thêm một handler vào pipeline trước WebSocketServerProtocolHandler. Handler này sẽ chặn FullHttpRequest (yêu cầu bắt tay), thực hiện logic của bạn (ví dụ: kiểm tra token từ URI hoặc header), và sau đó chuyển tiếp yêu cầu đến WebSocketServerProtocolHandler chỉ khi logic của bạn thành công. Nếu thất bại, bạn có thể gửi một phản hồi HTTP lỗi và đóng kênh.
  2. Tự triển khai toàn bộ logic bắt tay WebSocket. Tuy nhiên, điều này phức tạp hơn nhiều và thường không cần thiết vì WebSocketServerProtocolHandler của Netty đã xử lý hầu hết các trường hợp một cách mạnh mẽ.

Trong hầu hết các trường hợp, việc điều chỉnh đường dẫn để khớp là đủ để Netty xử lý bắt tay WebSocket một cách tự động và chính xác.

Thẻ: Netty WebSocket Java ChannelPipeline HttpServerCodec

Đăng vào ngày 29 tháng 10 lúc 05:52