Netty | 工作流程圖分析 & 核心組件說明 & 代碼案例實(shí)踐
一、Netty 工作流程
我們先來看看Netty的工作原理圖,簡單說一下工作流程,然后通過這張圖來一一分析Netty的核心組件。
1.1、Server工作流程圖:

1.2、Server工作流程分析:
server端啟動(dòng)時(shí)綁定本地某個(gè)端口,初始化
NioServerSocketChannel.將自己
NioServerSocketChannel注冊(cè)到某個(gè)BossNioEventLoopGroup的selector上。server端包含1個(gè)
Boss NioEventLoopGroup和1個(gè)Worker NioEventLoopGroup,Boss NioEventLoopGroup專門負(fù)責(zé)接收客戶端的連接,Worker NioEventLoopGroup專門負(fù)責(zé)網(wǎng)絡(luò)的讀寫NioEventLoopGroup相當(dāng)于1個(gè)事件循環(huán)組,這個(gè)組里包含多個(gè)事件循環(huán)NioEventLoop,每個(gè)NioEventLoop包含1個(gè)selector和1個(gè)事件循環(huán)線程。
BossNioEventLoopGroup循環(huán)執(zhí)行的任務(wù):1、輪詢accept事件;
2、處理accept事件,將生成的NioSocketChannel注冊(cè)到某一個(gè)
WorkNioEventLoopGroup的Selector上。3、處理任務(wù)隊(duì)列中的任務(wù),runAllTasks。任務(wù)隊(duì)列中的任務(wù)包括用戶調(diào)用
eventloop.execute或schedule執(zhí)行的任務(wù),或者其它線程提交到該eventloop的任務(wù)。WorkNioEventLoopGroup循環(huán)執(zhí)行的任務(wù):輪詢
read和Write事件處理IO事件,在NioSocketChannel可讀、可寫事件發(fā)生時(shí),回調(diào)(觸發(fā))ChannelHandler進(jìn)行處理。
處理任務(wù)隊(duì)列的任務(wù),即
runAllTasks
1.3、Client工作流程圖

流程就不重復(fù)概述啦??
二、核心模塊組件
Netty的核心組件大致是以下幾個(gè):
Channel 接口
EventLoopGroup 接口
ChannelFuture 接口
ChannelHandler 接口
ChannelPipeline 接口
ChannelHandlerContext 接口
SimpleChannelInboundHandler 抽象類
Bootstrap、ServerBootstrap 類
ChannelFuture 接口
ChannelOption 類
2.1、Channel 接口
我們平常用到基本的 I/O 操作(bind()、connect()、read()和 write()),其本質(zhì)都依賴于底層網(wǎng)絡(luò)傳輸所提供的原語,在Java中就是Socket類。
Netty 的 Channel 接 口所提供的 API,大大地降低了直接使用Socket 類的復(fù)雜性。另外Channel 提供異步的網(wǎng)絡(luò) I/O 操作(如建立連接,讀寫,綁定端口),異步調(diào)用意味著任何 I/O 調(diào)用都將立即返回,并且不保證在調(diào)用結(jié)束時(shí)所請(qǐng)求的 I/O 操作已完成。
在調(diào)用結(jié)束后立即返回一個(gè) ChannelFuture 實(shí)例,通過注冊(cè)監(jiān)聽器到 ChannelFuture 上,支持 在I/O 操作成功、失敗或取消時(shí)立馬回調(diào)通知調(diào)用方。
此外,Channel 也是擁有許多預(yù)定義的、專門化實(shí)現(xiàn)的廣泛類層次結(jié)構(gòu)的根,比如:
LocalServerChannel:用于本地傳輸?shù)腟erverChannel ,允許 VM 通信。EmbeddedChannel:以嵌入式方式使用的 Channel 實(shí)現(xiàn)的基類。NioSocketChannel:異步的客戶端 ?TCP 、Socket 連接。NioServerSocketChannel:異步的服務(wù)器端 ?TCP、Socket 連接。NioDatagramChannel:異步的 ?UDP 連接。NioSctpChannel:異步的客戶端 Sctp 連接,它使用非阻塞模式并允許將 SctpMessage 讀/寫到底層 SctpChannel。NioSctpServerChannel:異步的 Sctp 服務(wù)器端連接,這些通道涵蓋了 UDP 和 TCP 網(wǎng)絡(luò) IO 以及文件 IO。
2.2、EventLoopGroup接口
EventLoop 定義了Netty的核心抽象,用于處理連接的生命周期中所發(fā)生的事件。
Netty 通過觸發(fā)事件將 Selector 從應(yīng)用程序中抽象出來,消除了所有本來將需要手動(dòng)編寫 的派發(fā)代碼。在內(nèi)部,將會(huì)為每個(gè) Channel 分配一個(gè) EventLoop,用以處理所有事件,包括:
注冊(cè)事件;
將事件派發(fā)給 ChannelHandler;
安排進(jìn)一步的動(dòng)作。
不過在這里我們不深究它,針對(duì) Channel、EventLoop、Thread 以及 EventLoopGroup 之間的關(guān)系做一個(gè)簡單說明。
一個(gè)
EventLoopGroup包含一個(gè)或者多個(gè)EventLoop;每個(gè)
EventLoop維護(hù)著一個(gè)Selector實(shí)例,所以一個(gè) EventLoop 在它的生命周期內(nèi)只和一個(gè)Thread綁定;因此所有由
EventLoop處理的 I/O 事件都將在它專有的Thread上被處理,實(shí)際上消除了對(duì)于同步的需要;一個(gè)
Channel在它的生命周期內(nèi)只注冊(cè)于一個(gè)EventLoop;一個(gè)
EventLoop可能會(huì)被分配給一個(gè)或多個(gè)Channel。通常一個(gè)服務(wù)端口即一個(gè)
ServerSocketChannel對(duì)應(yīng)一個(gè)Selector和一個(gè)EventLoop線程。BossEventLoop負(fù)責(zé)接收客戶端的連接并將SocketChannel交給WorkerEventLoopGroup來進(jìn)行 IO 處理,就如上文中的流程圖一樣。
2.3、ChannelFuture 接口
Netty 中所有的 I/O 操作都是異步的。因?yàn)橐粋€(gè)操作可能不會(huì) 立即返回,所以我們需要一種用于在之后的某個(gè)時(shí)間點(diǎn)確定其結(jié)果的方法。具體的實(shí)現(xiàn)就是通過 Future 和 ChannelFutures,其 addListener()方法注冊(cè)了一個(gè) ChannelFutureListener,以便在某個(gè)操作完成時(shí)(無論是否成功)自動(dòng)觸發(fā)注冊(cè)的監(jiān)聽事件。
常見的方法有
Channel channel(),返回當(dāng)前正在進(jìn)行IO操作的通道ChannelFuture sync(),等待異步操作執(zhí)行完畢
2.4、ChannelHandler 接口
從之前的入門程序中,我們可以看到ChannelHandler在Netty中的重要性,它充當(dāng)了所有處理入站和出站數(shù)據(jù)的應(yīng)用程序邏輯的容器。我們的業(yè)務(wù)邏輯也大都寫在實(shí)現(xiàn)的字類中,另外ChannelHandler 的方法是由事件自動(dòng)觸發(fā)的,并不需要我們自己派發(fā)。
ChannelHandler的實(shí)現(xiàn)類或者實(shí)現(xiàn)子接口有很多。平時(shí)我們就是去繼承或子接口,然后重寫里面的方法。

最常見的幾種Handler:
ChannelInboundHandler:接收入站事件和數(shù)據(jù)ChannelOutboundHandler:用于處理出站事件和數(shù)據(jù)。
常見的適配器:
ChannelInboundHandlerAdapter:用于處理入站IO事件ChannelInboundHandler實(shí)現(xiàn)的抽象基類,它提供了所有方法的實(shí)現(xiàn)。這個(gè)實(shí)現(xiàn)只是將操作轉(zhuǎn)發(fā)到ChannelPipeline的下一個(gè)ChannelHandler。子類可以覆蓋方法實(shí)現(xiàn)來改變這一ChannelOutboundHandlerAdapter:用于處理出站IO事件
我們經(jīng)常需要自定義一個(gè) Handler 類去繼承 ChannelInboundHandlerAdapter,然后通過重寫相應(yīng)方法實(shí)現(xiàn)業(yè)務(wù)邏輯,我們來看看有哪些方法可以重寫:
public class ChannelInboundHandlerAdapter extends ChannelHandlerAdapter implements ChannelInboundHandler {
//注冊(cè)事件
public void channelRegistered(ChannelHandlerContext ctx) ;
//
public void channelUnregistered(ChannelHandlerContext ctx);
//通道已經(jīng)就緒
public void channelActive(ChannelHandlerContext ctx);
public void channelInactive(ChannelHandlerContext ctx) ;
//通道讀取數(shù)據(jù)事件
public void channelRead(ChannelHandlerContext ctx, Object msg) ;
//通道讀取數(shù)據(jù)事件完畢
public void channelReadComplete(ChannelHandlerContext ctx) ;
public void userEventTriggered(ChannelHandlerContext ctx, Object evt);
//通道可寫性已更改
public void channelWritabilityChanged(ChannelHandlerContext ctx);
//異常處理
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
}
復(fù)制代碼2.5、ChannelPipeline 接口
ChannelPipeline 提供了 ChannelHandler 鏈的容器,并定義了用于在該鏈上傳播入站和出站事件流的 API。當(dāng) Channel 被創(chuàng)建時(shí),它會(huì)被自動(dòng)地分配到它專屬的 ChannelPipeline。他們的組成關(guān)系如下:

一個(gè) Channel 包含了一個(gè) ChannelPipeline,而 ChannelPipeline 中又維護(hù)了一個(gè)由ChannelHandlerContext 組成的雙向鏈表,并且每個(gè)ChanneHandlerContext中又關(guān)聯(lián)著一個(gè)ChannelHandler。
ChannelHandler 安裝到 ChannelPipeline 中的過程:
一個(gè)
ChannelInitializer的實(shí)現(xiàn)被注冊(cè)到了ServerBootstrap中 ;當(dāng)
ChannelInitializer.initChannel()方法被調(diào)用時(shí),ChannelInitializer將在ChannelPipeline中安裝一組自定義的ChannelHandler;ChannelInitializer將它自己從ChannelPipeline中移除。
從一個(gè)客戶端應(yīng)用程序 的角度來看,如果事件的運(yùn)動(dòng)方向是從客戶端到服務(wù)器端,那么我們稱這些事件為出站的,反之 則稱為入站的。服務(wù)端反之。
如果一個(gè)消息或者任何其他的入站事件被讀取,那么它會(huì)從 ChannelPipeline 的頭部 開始流動(dòng),并被傳遞給第一個(gè) ChannelInboundHandler。次此handler處理完后,數(shù)據(jù)將會(huì)被傳遞給鏈中的下一個(gè) ChannelInboundHandler。最終,數(shù)據(jù)將會(huì)到達(dá) ChannelPipeline 的尾端,至此,所有處理就結(jié)束了。
出站事件會(huì)從尾端往前傳遞到最前一個(gè)出站的 handler。出站和入站兩種類型的 handler互不干擾。
2.6、ChannelHandlerContext 接口
作用就是使ChannelHandler能夠與其ChannelPipeline和其他處理程序交互。因?yàn)?ChannelHandlerContext保存channel相關(guān)的所有上下文信息,同時(shí)關(guān)聯(lián)一個(gè) ChannelHandler 對(duì)象, 另外,ChannelHandlerContext 可以通知ChannelPipeline的下一個(gè)ChannelHandler以及動(dòng)態(tài)修改它所屬的ChannelPipeline 。
2.7、SimpleChannelInboundHandler 抽象類
我們常常能夠遇到應(yīng)用程序會(huì)利用一個(gè) ?ChannelHandler 來接收解碼消息,并在這個(gè)Handler中實(shí)現(xiàn)業(yè)務(wù)邏輯,要寫一個(gè)這樣的 ChannelHandler ,我們只需要擴(kuò)展抽象類 SimpleChannelInboundHandler< T > 即可, 其中T類型是我們要處理的消息的Java類型。
在SimpleChannelInboundHandler 中最重要的方法就是void channelRead0(ChannelHandlerContext ctx, T msg),
我們自己實(shí)現(xiàn)了這個(gè)方法之后,接收到的消息就已經(jīng)被解碼完的消息啦。
舉個(gè)例子:

2.8、Bootstrap、ServerBootstrap 類
Bootstrap 意思是引導(dǎo),一個(gè) Netty 應(yīng)用通常由一個(gè) Bootstrap 開始,主要作用是配置整個(gè) Netty 程序,串聯(lián)各個(gè)組件。
| 類別 | Bootstrap | ServerBootstrap |
|---|---|---|
| 引導(dǎo) | 用于引導(dǎo)客戶端 | 用于引導(dǎo)服務(wù)端 |
| 在網(wǎng)絡(luò)編程中作用 | 用于連接到遠(yuǎn)程主機(jī)和端口 | 用于綁定到一個(gè)本地端口 |
| EventLoopGroup 的數(shù)目 | 1 | 2 |
我想大家對(duì)于最后一點(diǎn)可能會(huì)存有疑惑,為什么一個(gè)是1一個(gè)是2呢?
因?yàn)榉?wù)器需要兩組不同的 Channel。
第一組將只包含一個(gè) ServerChannel,代表服務(wù) 器自身的已綁定到某個(gè)本地端口的正在監(jiān)聽的套接字。
而第二組將包含所有已創(chuàng)建的用來處理傳入客戶端連接(對(duì)于每個(gè)服務(wù)器已經(jīng)接受的連接都有一個(gè))的 Channel。
這一點(diǎn)可以上文中的流程圖。
2.9、ChannelFuture ?接口
異步 Channel I/O 操作的結(jié)果。Netty 中的所有 I/O 操作都是異步的。這意味著任何 I/O 調(diào)用將立即返回,但不保證在調(diào)用結(jié)束時(shí)請(qǐng)求的 I/O 操作已完成。相反,您將返回一個(gè) ChannelFuture 實(shí)例,該實(shí)例為您提供有關(guān) I/O 操作的結(jié)果或狀態(tài)的信息。ChannelFuture 要么未完成,要么已完成。當(dāng) I/O 操作開始時(shí),會(huì)創(chuàng)建一個(gè)新的未來對(duì)象。新的未來最初是未完成的——它既沒有成功,也沒有失敗,也沒有取消,因?yàn)?I/O 操作還沒有完成。如果 I/O 操作成功完成、失敗或取消,則使用更具體的信息(例如失敗原因)將未來標(biāo)記為已完成。請(qǐng)注意,即使失敗和取消也屬于完成狀態(tài)。
Netty 中所有的 IO 操作都是異步的,不能立刻得知消息是否被正確處理。但是可以過一會(huì)等它執(zhí)行完成或者直接注冊(cè)一個(gè)監(jiān)聽,具體的實(shí)現(xiàn)就是通過 Future 和 ChannelFutures,他們可以注冊(cè)一個(gè)監(jiān)聽,當(dāng)操作執(zhí)行成功或失敗時(shí)監(jiān)聽會(huì)自動(dòng)觸發(fā)注冊(cè)的監(jiān)聽事件
常見的方法有
Channel channel(),返回當(dāng)前正在進(jìn)行IO操作的通道ChannelFuture sync(),等待異步操作執(zhí)行完畢
2.10、ChannelOption 類
Netty在創(chuàng)建Channel實(shí)例后,一般都需要設(shè)置ChannelOption參數(shù)。ChannelOption參數(shù)如下:ChannelOption.SO_KEEPALIVE:一直保持連接狀態(tài)ChannelOption.SO_BACKLOG:對(duì)應(yīng)TCP/IP協(xié)議listen 函數(shù)中的backlog參數(shù),用來初始化服務(wù)器可連接隊(duì)列大小。服務(wù)端處理客戶端連接請(qǐng)求是順序處理內(nèi),所N博求放在隊(duì)剛中等待處理,backilog參數(shù)指定端來的時(shí)候,服務(wù)端將不能處理的客戶端連接請(qǐng)求放在隊(duì)列中等待處理, backlog參數(shù)指定了隊(duì)列的大小。
三、應(yīng)用實(shí)例
【案例】:
寫一個(gè)服務(wù)端,兩個(gè)或多個(gè)客戶端,客戶端可以相互通信。
3.1、服務(wù)端 Handler
ChannelHandler的實(shí)現(xiàn)類或者實(shí)現(xiàn)子接口有很多。平時(shí)我們就是去繼承或子接口,然后重寫里面的方法。
在這里我們就是繼承了 SimpleChannelInboundHandler< T > ,這里面許多方法都是大都只要我們重寫一下業(yè)務(wù)邏輯,觸發(fā)大都是在事件發(fā)生時(shí)自動(dòng)調(diào)用的,無需我們手動(dòng)調(diào)用。
package com.crush.atguigu.group_chat;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.util.concurrent.GlobalEventExecutor;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author crush
*/
public class GroupChatServerHandler extends SimpleChannelInboundHandler<String> {
/**
* 定義一個(gè)channle 組,管理所有的channel
* GlobalEventExecutor.INSTANCE) 是全局的事件執(zhí)行器,是一個(gè)單例
*/
private static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* handlerAdded 表示連接建立,一旦連接,第一個(gè)被執(zhí)行
* 將當(dāng)前channel 加入到 channelGroup
* @param ctx
* @throws Exception
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
//將該客戶加入聊天的信息推送給其它在線的客戶端
/*
該方法會(huì)將 channelGroup 中所有的channel 遍歷,并發(fā)送 消息,
我們不需要自己遍歷
*/
channelGroup.writeAndFlush("[客戶端]" + channel.remoteAddress() + " 加入聊天" + sdf.format(new java.util.Date()) + " \n");
channelGroup.add(channel);
}
/**
* 斷開連接, 將xx客戶離開信息推送給當(dāng)前在線的客戶
* @param ctx
* @throws Exception
*/
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
channelGroup.writeAndFlush("[客戶端]" + channel.remoteAddress() + " 離開了\n");
System.out.println("channelGroup size" + channelGroup.size());
}
/**
* 表示channel 處于活動(dòng)狀態(tài), 既剛出生 提示 xx上線
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println(ctx.channel().remoteAddress() + " 上線了~");
}
/**
* 表示channel 處于不活動(dòng)狀態(tài), 既死亡狀態(tài) 提示 xx離線了
* @param ctx
* @throws Exception
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.out.println(ctx.channel().remoteAddress() + " 離線了~");
}
//讀取數(shù)據(jù)
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
//獲取到當(dāng)前channel
Channel channel = ctx.channel();
//這時(shí)我們遍歷channelGroup, 根據(jù)不同的情況,回送不同的消息
channelGroup.forEach(ch -> {
if (channel != ch) { //不是當(dāng)前的channel,轉(zhuǎn)發(fā)消息
ch.writeAndFlush("[客戶]" + channel.remoteAddress() + " 發(fā)送了消息" + msg + "\n");
} else {//回顯自己發(fā)送的消息給自己
ch.writeAndFlush("[自己]發(fā)送了消息" + msg + "\n");
}
});
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//關(guān)閉通道
ctx.close();
}
}
復(fù)制代碼3.2、服務(wù)端 Server 啟動(dòng)
package com.crush.atguigu.group_chat;
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.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
/**
* @author crush
*/
public class GroupChatServer {
/**
* //監(jiān)聽端口
*/
private int port;
public GroupChatServer(int port) {
this.port = port;
}
/**
* 編寫run方法 處理請(qǐng)求
* @throws Exception
*/
public void run() throws Exception {
//創(chuàng)建兩個(gè)線程組
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
//8個(gè)NioEventLoop
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//獲取到pipeline
ChannelPipeline pipeline = ch.pipeline();
//向pipeline加入解碼器
pipeline.addLast("decoder", new StringDecoder());
//向pipeline加入編碼器
pipeline.addLast("encoder", new StringEncoder());
//加入自己的業(yè)務(wù)處理handler
pipeline.addLast(new GroupChatServerHandler());
}
});
System.out.println("netty 服務(wù)器啟動(dòng)");
ChannelFuture channelFuture = b.bind(port).sync();
//監(jiān)聽關(guān)閉
channelFuture.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws Exception {
new GroupChatServer(7000).run();
}
}
復(fù)制代碼 3.3、客戶端 Handler
package com.crush.atguigu.group_chat;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
/**
* @author crush
*/
public class GroupChatClientHandler extends SimpleChannelInboundHandler<String> {
//當(dāng)前Channel 已從對(duì)方讀取消息時(shí)調(diào)用。
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
System.out.println(msg.trim());
}
}
復(fù)制代碼3.4、客戶端 Server
package com.crush.atguigu.group_chat;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import java.util.Scanner;
/**
* @author crush
*/
public class GroupChatClient {
private final String host;
private final int port;
public GroupChatClient(String host, int port) {
this.host = host;
this.port = port;
}
public void run() throws Exception {
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap()
.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//得到pipeline
ChannelPipeline pipeline = ch.pipeline();
//加入相關(guān)handler
pipeline.addLast("decoder", new StringDecoder());
pipeline.addLast("encoder", new StringEncoder());
//加入自定義的handler
pipeline.addLast(new GroupChatClientHandler());
}
});
ChannelFuture channelFuture = bootstrap.connect(host, port).sync();
//得到channel
Channel channel = channelFuture.channel();
System.out.println("-------" + channel.localAddress() + "--------");
//客戶端需要輸入信息
Scanner scanner = new Scanner(System.in);
while (scanner.hasNextLine()) {
String msg = scanner.nextLine();
//通過channel 發(fā)送到服務(wù)器端
channel.writeAndFlush(msg + "\r\n");
}
} finally {
group.shutdownGracefully();
}
}
public static void main(String[] args) throws Exception {
new GroupChatClient("127.0.0.1", 7000).run();
}
}
復(fù)制代碼 多個(gè)客戶端,cv一下即可。
3.5、測試:
測試流程是先啟動(dòng) 服務(wù)端 Server,再啟動(dòng)客戶端 。



四、自言自語
這篇文章應(yīng)該算是個(gè)存稿了,之前忙其他事情去了??。
作者:寧在春
鏈接:https://juejin.cn/post/7017602386747195429
來源:稀土掘金
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。
