Netty中自定義事件處理程式和監聽器

banq發表於2024-03-05

在本教程中,我們將使用Netty 建立一個聊天室應用程式。在網路程式設計中,Netty 作為一個強大的框架而脫穎而出,它簡化了非同步 I/O 操作的複雜性。我們將探討如何構建一個基本的聊天伺服器,多個客戶端可以在其中連線並進行實時對話。

在 Netty 中,通訊是透過通道完成的,通道抽象了任何協議上的非同步 I/O 操作。這使我們能夠專注於應用程式邏輯而不是網路程式碼。我們的應用程式將透過命令列執行。

 我們將編寫一個伺服器和一個客戶端應用程式。

 對於通道之間的通訊,我們將實現SimpleChannelInboundHandler<String>,它是ChannelInboundHandlerAdapter的通用實現。該介面卡使我們能夠專注於僅實現我們關心的事件。在本例中,它是channelRead0(),當從伺服器接收到訊息時呼叫它。我們將使用它來簡化我們的用例,因為我們只交換字串訊息。


1. 客戶端事件處理程式
讓我們從客戶端訊息的處理程式開始,它將把伺服器接收到的任何內容列印到控制檯,無需修改:

public class ClientEventHandler extends SimpleChannelInboundHandler<String> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) {
        System.out.println(msg);
    }
}

稍後,我們將透過直接寫入通道來處理訊息傳送。

2. 訊息物件
在我們繼續討論伺服器事件之前,讓我們編寫一個POJO來表示傳送到伺服器的每條訊息。我們將註冊傳送的日期以及使用者名稱和訊息:

public class Message {
    private final Instant time;
    private final String user;
    private final String message;
    public Message(String user, String message) {
        this.time = Instant.now();
        this.user = user;
        this.message = message;
    }
    <font>// standard getters...<i>
}

然後,我們將包括一些幫助程式,首先是伺服器傳送訊息時訊息如何顯示在控制檯上:

@Override
public String toString() {
    return time + <font>" - " + user + ": " + message;
}

然後,為了解析客戶端收到的訊息,我們將使用 CSV 格式。當我們建立客戶端應用程式時,我們將看到客戶端如何以這種格式傳送訊息:

public static Message parse(String string) {
    String arr = string.split(<font>";", 2);
    return new Message(arr[0], arr[1]);
}

將分割限制為 2 很重要,因為訊息部分可能包含分號。

3. 伺服器事件處理程式
在我們的伺服器事件處理程式中,我們將首先為我們將覆蓋的其他事件建立一個輔助方法。此外,我們還需要一個已連線客戶端的對映和一個佇列來最多保留MAX_HISTORY元素:

public class ServerEventHandler extends SimpleChannelInboundHandler<String> {
    static final Map<String, Channel> clients = new HashMap<>();
    static final Queue<String> history = new LinkedList<>();
    static final int MAX_HISTORY = 5;
    private void handleBroadcast(Message message, ChannelHandlerContext context) {
        String channelId = context.channel()
          .id()
          .asShortText();
        
        clients.forEach((id, channel) -> {
            if (!id.equals(channelId))
                channel.writeAndFlush(message.toString());
        });
        <font>// history-control code...<i>
    }
   
// ...<i>
}


首先,我們獲取通道 ID 作為地圖的鍵。然後,對於廣播,對於每個連線的客戶端(不包括髮送者),我們中繼他們的訊息。
值得注意的是writeAndFlush()接收一個Object。而且,由於我們的處理程式只能處理字串,因此必須呼叫toString()以便客戶端可以正確接收它。

最後,我們進行歷史控制。每次新增新訊息時,如果列表超過MAX_HISTORY項,我們就會刪除最舊的訊息:

history.add(message.toString());
if (history.size() > MAX_HISTORY)
    history.poll();

 現在,我們可以重寫channelRead0()並解析從客戶端收到的訊息:

@Override
public void channelRead0(ChannelHandlerContext context, String msg) {
    handleBroadcast(Message.parse(msg), context);
}

然後,對於每個上線的客戶端,我們將其新增到我們的客戶端 列表中,中繼舊訊息以獲取上下文,併傳送一條系統訊息宣佈新客戶端message為上下文,併傳送系統訊息宣佈新客戶端:

@Override
public void channelActive(final ChannelHandlerContext context) {
    Channel channel = context.channel();
    clients.put(channel.id().asShortText(), channel);
    history.forEach(channel::writeAndFlush);
    handleBroadcast(new Message(<font>"system", "client online"), context);
}

 最後,我們重寫channelInactive(),在客戶端離線時呼叫。這次,我們只需要從列表中刪除客戶端併傳送系統訊息:

@Override
public void channelInactive(ChannelHandlerContext context) {
    Channel channel = context.channel();
    clients.remove(channel.id().asShortText());
    handleBroadcast(new Message(<font>"system", "client offline"), context);
}

 4、伺服器引導應用程式
我們的處理程式不獨立執行任何操作,因此我們需要一個應用程式來引導並執行它,這是一個通用模板。
在ChannelPipeline中註冊自定義元件

為了準備載入程式,我們選擇一個通道實現並實現一個子處理程式,該處理程式為通道的請求提供服務:

bootstrap.group(serverGroup, clientGroup)
  .channel(NioServerSocketChannel.class)
  .childHandler(new ChannelInitializer<SocketChannel>() {
      @Override
      public void initChannel(SocketChannel channel) {
          channel.pipeline()
            .addFirst(
              new StringDecoder(),
              new ServerEventHandler()
              new StringEncoder());
      }
  });

在子處理程式中,我們定義處理管道。由於我們只關心字串訊息,因此我們將使用內建的字串編碼器和解碼器,這樣就不必自己對交換的位元組緩衝區進行編碼/解碼,從而節省了一些時間。

最後,由於順序很重要,我們新增解碼器、ServerEventHandler和編碼器。這是因為事件透過管道從入站流向出站。

我們將伺服器繫結到主機/埠來完成我們的應用程式,該應用程式返回一個ChannelFuture。我們將使用它來等待非同步套接字透過sync()關閉:

ChannelFuture future = bootstrap.bind(HOST, PORT).sync();
System.out.println(<font>"server started. accepting clients.");
future.channel().closeFuture().sync();

 5、客戶端引導應用程式
最後,我們的客戶端應用程式遵循通用客戶端模板進行引導。最重要的是,當呼叫handler()時,我們將使用ClientEventHandler來代替:

channel.pipeline().addFirst(
  new StringDecoder(), 
  new ClientEventHandler(), 
  new StringEncoder());

 處理訊息輸入
最後,為了處理使用者輸入,連線到伺服器後,我們將使用掃描器迴圈,直到收到使用者名稱,然後直到訊息等於“退出”。最重要的是,我們必須使用writeAndFlush()來傳送訊息。我們以Message.parse()期望的格式傳送訊息:

private static void messageLoop(Scanner scanner, Channel channel) {
    while (user.isEmpty()) {
        System.out.print(<font>"your name: ");
        user = scanner.nextLine();
    }
    while (scanner.hasNext()) {
        System.out.print(
"> ");
        String message = scanner.nextLine();
        if (message.equals(
"exit"))
            break;
        channel.writeAndFlush(user +
";" + message);
    }
}

 6、建立自定義事件監聽器
在 Netty 中,事件監聽器在通道整個生命週期中處理非同步事件方面發揮著至關重要的作用。事件監聽器本質上是一種回撥機制,我們可以使用它對返回ChannelFuture的任何操作的完成做出反應。

我們在完成時實現ChannelFutureListener介面以實現自定義行為。ChannelFuture 表示非同步操作的結果,例如連線嘗試或 I/O 操作。

ChannelFutureListener很有用,因為它定義了預設實現,例如CLOSE_ON_FAILURE或FIRE_EXCEPTION_ON_FAILURE。但是,由於我們不會使用這些,因此讓我們實現一個用於操作確認的GenericFutureListener 。

我們將保留上下文的自定義事件名稱,並且我們將檢查未來是否成功完成。否則,我們將在記錄之前將狀態標記為“FAILED”:
 

public class ChannelInfoListener implements GenericFutureListener<ChannelFuture> {
    private final String event;
    public ChannelInfoListener(String event) {
        this.event = event;
    }
    @Override
    public void operationComplete(ChannelFuture future) throws Exception {
        Channel channel = future.channel();
        String status = <font>"OK";
        if (!future.isSuccess()) {
            status =
"FAILED";
            future.cause().printStackTrace();
        }
        System.out.printf(
         
"%s - channel#%s %s: %s%n", Instant.now(), channel.id().asShortText(), status, event);
    }
}

  事件接收
讓我們回到程式碼的某些部分來包含偵聽器。首先,對於客戶端,我們新增一個“連線到伺服器”確認:

future.addListener(new ChannelInfoListener("connected to server"));


然後,讓我們在訊息迴圈中包含“訊息已傳送”確認:

ChannelFuture sent = channel.writeAndFlush(user + ";" + message);
sent.addListener(new ChannelInfoListener("message sent"));


這使我們能夠確保在傳送訊息時仍然連線到伺服器。最後,對於伺服器處理程式,讓我們在廣播期間傳送“訊息中繼”確認:

clients.forEach((id, channel) -> {
    if (!id.equals(channelId)) {
        ChannelFuture relay = channel.writeAndFlush(message.toString());
        relay.addListener(new ChannelInfoListener(<font>"message relayed to " + id));
    }
});

 
7、執行
Netty 允許我們使用EmbeddedChannel測試管道,但對於客戶端/伺服器互動,讓我們看看從終端執行時它是什麼樣子。讓我們啟動伺服器(為了便於閱讀,我們將省略包名稱):

$ mvn exec:java -Dexec.mainClass=ChatServerMain
chat server started. ready to accept clients.


然後,讓我們啟動第一個客戶端,輸入名稱,然後傳送兩條訊息:

$ mvn exec:java -Dexec.mainClass=ChatClientMain
2024-01-12 3:47:02 - channel#03c40ad4 OK: connected to server
your name: Bob
> Hello
2024-01-12 3:47:02 - channel#03c40ad4 OK: message sent
> Anyone there?!
2024-01-12 3:47:03 - channel#03c40ad4 OK: message sent


當我們與第二個客戶端連線時,我們將在輸入名稱之前獲取訊息歷史記錄:

$ mvn exec:java -Dexec.mainClass=ChatClientMain
2024-01-12 3:49:33 - channeldaa64476 OK: connected to server
2024-01-12 3:46:55 - system: client online: 03c40ad4
2024-01-12 3:47:03 - Bob: Hello
2024-01-12 3:48:40 - Bob: Anyone there?!


當然,在選擇名稱併傳送訊息後:

your name: Alice
> Hi, Bob!
2024-01-12 3:51:05 - channeldaa64476 OK: message sent
第一個客戶端將收到它:

2024-01-12 3:49:33 - system: client online: daa64476
2024-01-12 3:51:05 - Alice: Hi, Bob!

 

相關文章