目录
一. 何为网络编程
二. Socket套接字
1. 简单认识 UDP 和 TCP
2. 基于 UDP 实现简单的客户端服务器的网络通信程序
1. 方法使用
2. 回显服务器的实现
3. 回显客户端的实现
3. 基于 UDP 实现简单的客户端服务器的网络通信程序
1. 方法使用
2. 回显服务器的实现
3. 回显客户端的实现
三. 从代码角度分析 UDP和TCP 的特征
一. 何为网络编程
网络编程,指的是网络上的主机(可以是同一主机,也可以不同主机),通过不同的进程,以编程的方式实现网络通信。(网络数据传输)所以一般会有一个进程A:编程来获取网络数据;进程B:编程来提供网络数据。
因此在一次网络数据传输中,就会有发送端和接收端。
发送端:数据的发送方进程,称为发送端。发送端主机即网络通信中的源主机。
接收端: 数据的接收方进程,称为接收端。接收端主机即网络通信中的目的主机。
注意:发送端和接收端只是相对的,只是一次网络数据传输产生数据流向后的概念。
请求和相应:一般来说,获取一个网络数据,会涉及到两次网络数据传输。
第一次:请求数据的发送;第二次:响应数据的发送;
客户端和服务端
服务端:在常见的网络数据传输场景下,把提供数据的一方进程,称为服务端,可以提供对外服务。
客户端:获取数据的一方进程,称为客户端。
常见的客户端服务端模型
1.客户端发送请求到服务端;2.服务端根据请求数据,执行相应的业务处理;
3.服务端返回相应:发送业务处理结果;4.客户端根据响应数据,展示处理结果;
二. Socket套接字
Socket 套接字,是操作系统给应用程序提供的用于网络编程的 API,是基于 TCP/IP 协议的网络通信的基本操作单元。基于Socket 套接字的网络程序开发就是网络编程。
socket 是和传输层密切相关的,而传输层有两个核心协议:UDP 和 TCP ,因此 socket 也就提供了两种风格。这里先对 UDP 和 TCP 的特点进行简单概况。(具体细节会在后续文章详细介绍)
1. 简单认识 UDP 和 TCP
UDP :无连接;不可靠传输;面向数据报;全双工;
TCP:有连接;可靠传输;面向字节流;全双工;
这里的有连接和无连接,使用类比来说明,有连接就类似于打电话,需要电话打通了,才可以进行信息交流,而无连接,就类似于发微信,你不需要确保对方在线,就可以将信息传达。也就是有连接,需要连接建立了才能进行通信,而建立连接是需要双方都 “接受”(具体细节后续会讲),如果连接没有建立起来,就无法进行通信,就相当于电话没打通,没法进行信息交流。
可靠传输和不可靠传输: 由于网络环境是天然复杂的,因此在每次网络通信中,并没有办法保证每次传输的数据一定会送达。可靠传输,指的是发送方可以知道发送的数据是否到达了,还是丢了;而不可靠传输,发送数据后,就不关心是否传达,还是数据丢失了。因此也是类似于有连接和无连接的事例:打电话相当于是可靠传输,电话没拨通就是数据传输失败,电话拨通了,进行信息传达对方是可以收到的,也就传输成功了;而发微信,你并不知道对方是收到了消息还是没收到消息,就相当于是不可靠传输。
应当注意:可靠不可靠和有没有连接是没有任何关系的。
面向数据报:对于数据报来说,可以简单的理解为,传输数据是一块一块的,发送一块数据,假如是100个字节,必须一次发送,接收也必须一次接收100个字节,而不能分100次,每次接收1个字节。(数据传输是以一个个数据报为基本单位的,一个数据报可能是若干个字节,带有一定的格式)
面向字节流:对于字节流来说,可以简单的理解为,传输数据是基于IO流,和读写文件类似,流式数据的特征就是在IO流没有关闭的情况下,是无边界的数据,可以多次发送,也可以分开多次接收。
全双工: 一个通信通道,既可以进行发送数据,也可以接收数据。
2. 基于 UDP 实现简单的客户端服务器的网络通信程序
1. 方法使用
DatagramSocket 是 UDP 的 Socket ,用于发送和接受 UDP 数据报。
DatagramPacket是UDP Socket发送和接收的数据报。
使用 DatagramSocket 这个类来表示一个 socket 对象。在操作系统中,也把这个是socket对象当成一个文件来进行处理,也就相当于是文件描述符表上的一项。
普通的文件,对应的硬件设备是硬盘;socket 文件对应的硬件设备是网卡;在操作上都是以文件的形式来进行操作的。
一个socket 对象,就可以和另外一台主机进行通信了,如果要和多台主机进行通信,那么就需要构造多个socket 对象。
DatagramSocket 构造方法:
DatagramSocket() |
创建一个
UDP
数据报套接字的
Socket
,绑定到本机任意一个随机端口(一般用于客户端)
没有指定端口,此时系统会自动分配一个空闲的端口。
|
DatagramSocket(int port) |
创建一个
UDP
数据报套接字的
Socket
,绑定到本机指定的端口(一般用于服务端)
|
本质上是,不是进程和端口建立了联系,而是进程中的 socket对象和端口建立了联系。
DatagramSocket 方法:
void receive(DatagramPacket p) |
从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待)
|
void send(DatagramPacket p) |
从此套接字发送数据报包(不会阻塞等待,直接发送)
|
void close() |
关闭此数据报套接字。释放资源,用完记得关闭。
|
要注意,receive方法中的参数 p,是一个 “输出型参数”,因此需要先构建好这个参数,传入时是一个空的对象,再在receive方法内部对这个参数进行内容填充。
DatagramPacket 构造方法:
DatagramPacket(byte[] buf, int length)
|
构造一个
DatagramPacket
以用来接收数据报,接收的数据保存在字节数组(第一个参数buf
)中,接收指定长度(第二个参数 length)
|
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)
|
构造一个
DatagramPacket
以用来发送数据报,发送的数据为字节数组(第一个参数buf
)中,从
0
到指定长度(第三个参数 length)。
address指定目的主机的IP和端口号.
|
DatagramPacket 方法:
InetAddress getAdress() |
从接收的数据报中,获取发送端主机
IP
地址;或从发送的数据报中,获取接收端主机IP
地址
|
int getPort() |
从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号
|
byte[] getData |
获取数据报中的数据
|
构造UDP发送的数据报时,需要传入 SocketAddress,该对象可以使用 InetSocketAddress来创建。InetSocketAddress 是SocketAdress 的子类。
InetSocketAddress的构造方法:
InetSocketAddress(InetAddress addr,int port ) |
创建一个
Socket
地址,包含
IP
地址和端口号
|
也可以使用 DatagramPacket.getSocketAddress() 来获取到数据报的端口和 IP。
2. 回显服务器的实现
回显服务器省略了其中的“根据请求计算响应”,按照请求是什么,就响应什么。
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
// UDP 版本的回显服务器
public class UdpEchoServer {
// 网络编程, 本质上是要操作网卡.
// 但是网卡不方便直接操作. 在操作系统内核中, 使用了一种特殊的叫做 "socket" 这样的文件来抽象表示网卡.
// 因此进行网络通信, 势必需要先有一个 socket 对象.
private DatagramSocket socket = null;
// 对于服务器来说, 创建 socket 对象的同时, 要让他绑定上一个具体的端口号.
// 服务器一定要关联上一个具体的端口的!!!
// 服务器是网络传输中, 被动的一方. 如果是操作系统随机分配的端口, 此时客户端就不知道这个端口是啥了, 也就无法进行通信了!!!
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true){
// 服务器不是只给一个客户端提供服务就完了. 需要服务很多客户端.
// 1. 读取客户端发来的请求是什么.
// receive 方法的参数是一个输出型参数, 需要先构造好个空白的 DatagramPacket 对象. 交给 receive 来进行填充.
DatagramPacket requestPacket = new DatagramPacket(new byte[4090],4090);
socket.receive(requestPacket);
// 此时这个 DatagramPacket 是一个特殊的对象, 并不方便直接进行处理. 可以把这里包含的数据拿出来, 构造成一个字符串.
//虽然给的数组最大长度是4090,但是这里的空间不一定用满,通过getLength来获取实际的数据报长度,来构造字符串
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
// 2. 根据请求计算响应, 由于此处是回显服务器, 响应和请求相同.
String response = process(request);
// 3. 把响应写回到客户端. send 的参数也是 DatagramPacket. 需要把这个 Packet 对象构造好.
// 此处构造的响应对象, 不能是用空的字节数组构造了, 而是要使用响应数据来构造.
// 要注意传入的数据要转换成 字节数组 ,DatagramPacket 只认字节,不认字符
// 对于指定长度,不可用response.length(),这个表示的是字符长度而不是字节长度
// 通过 getSocketAddress 来获得 客户端的 IP 和 端口(这两个信号本身是存在于 requestPacker 中的)
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),0,response.getBytes().length,requestPacket.getSocketAddress()); //获得端口和IP
socket.send(responsePacket);
// 4. 打印一下, 当前这次请求响应的处理中间结果.
System.out.printf("[%s:%d] request : %s ; response : %s \n",requestPacket.getAddress().toString(),requestPacket.getPort(),request,response);
}
}
// 这个方法就表示 "根据请求计算响应"
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
// 端口号的指定, 大家可以随便指定.
// 1024 -> 65535 这个范围里随便挑个数字就行了.( 0~1023 为知名端口号)
UdpEchoServer udpEchoServer = new UdpEchoServer(9090);
udpEchoServer.start();
}
}
服务器的中的 receive,如果客户端发请求了,receive就能够顺利读出来;如果客户端没有发送请求,则receive 就处于阻塞状态,要注意,上述代码中的服务器是出于 while(true) 循环中的,也就是说,服务器随时做好接受客户端发来请求的准备了,因为客户端什么时候来,来多少个,是服务器无法得知的。每循环一次,就处理好一次 请求-响应 操作。
但会不会有很多个客户端,并且客户端发送请求的速率很快,这样服务器就处理不过来了?答案当然是会有的,也就是所谓的 “高并发” 指的就是客户端请求过多,服务器处理不过来。对于处理高并发,也就可以采用多线程的方式来解决,充分调用计算机的硬件资源;也可以使用分布式,使用多个服务器来响应需求。
receive接收到的网卡数据,可以认为就是写入了缓冲数组中了。
数据到达网卡后,经过内核的层层分用,最终到达了 UDP传输层协议,调用receive相当于是执行到了内核中相关的 UDP的代码,就会把这个UDP数据报里的 载荷 部分取出来,拷贝到用户提供的 byte[] 数组中。
对于上述代码,更主要的是要理解服务器的工作流程:
1.读取请求并解析;
2.根据请求计算响应;
3.构造响应并写回给客户端;
3. 回显客户端的实现
在实现回显客户端的时候,构造Socket对象时就不再需要主动去绑定一个端口了,交给操作系统随机分配一个空闲的端口就可以了。客户端是主动对服务器发起请求的,因此是需要主动指定服务器的IP 和端口的。(这里同一主机上,是使用环回IP:127.0.0.1)
关于服务器和客户端之间端口的指定
端口号是用来区分/标识一个进程的。因此不允许一个端口同时被多个进程使用。(前提是在同一个主机上)但是一个进程是可以绑定多个端口的,进程只要创建多个 Socket对象,就可以分别关联不同的端口。更准确的说:Socket和端口是一对一的关系,进程和 Socket是一对多的。
因此,可以得知:对于服务器来说,端口必须是确定好的;对于客户端来说,端口是系统进行分配的;那么就有一个问题了:客户端的端口号,可不可以自己确定呢?答案是可以的,但是不推荐。
因为客户端如果显式指定端口,可能就会和客户端电脑上的其他程序的端口冲突了,这一冲突就可能导致程序无法正确通信了。例如:客户端想要8080这个端口号,但此时这个端口号被客户端的另一个程序所占有,此时程序就无法正确运行了,运行就会抛出异常,提示绑定端口失败。
那么为何服务器这里指定端口不怕重复冲突呢?
服务器是我们程序猿自己手里的机器,上面运行什么,都是我们可控的,我们可以安排哪个程序用哪个端口,这样我们就不会导致端口冲突。但是客户端的机器是在各个用户手上的,不同用户手里的机器,各有不同,上面运行着什么样的程序,占用着哪个端口也是各不相同的,因此此时你自己来定义,就难免会有发生端口重复冲突的情况,但如果是交给系统来自动分配,系统会自动分配一个空闲的端口号,也就避免了端口冲突了。
总结来说:服务器的端口是要指定的,目的是为了方便客户端找到服务器程序,从而发送请求;客户端的端口是由系统自动分配的,如果手动指定,就可能会和客户端其他程序的端口冲突。
服务器自己指定端口不怕发生端口冲突,是因为服务器上的程序是程序猿可控的,而客户端是运行在用户电脑上的,环境各不相同,更复杂,更不可控,所以交给系统来自动分配空闲的端口。
这里还需注意,我们看到的 IP:127.0.0.1 是点分十进制(每个部分,表示范围是0~255,表示一个字节),是为了方便我们看的形式,实际上计算机中是以32位的整数的形式的。所以在构建DatagramPacket,指定端口号的时候,是需要转换形式的。
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
// UDP 版本的回显客户端
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp = null;
private int serverPort = 0;
// 一次通信, 需要有两个 ip, 两个端口.
// 客户端的 ip 是 127.0.0.1 已知.
// 客户端的 port 是系统自动分配的.
// 服务器 ip 和 端口 也需要告诉客户端. 才能顺利把消息发个服务器.
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
socket = new DatagramSocket();
this.serverIp = serverIp;
this.serverPort = serverPort;
}
public void start() throws IOException {
System.out.println("客户端启动!");
Scanner scanner = new Scanner(System.in);
while (true){
// 1. 从控制台读取要发送的数据
System.out.print("> ");
String request = scanner.next();
if (request.equals("exit")){
System.out.println("goodbye");
break;
}
// 2. 构造成 UDP 请求, 并发送
// 构造这个 Packet 的时候, 需要把 serverIp 和 port 都传入过来. 但是此处 IP 地址需要填写的是一个 32位的整数形式.
// 上述的 IP 地址是一个字符串. 需要使用 InetAddress.getByName 来进行一个转换.
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length, InetAddress.getByName(serverIp),serverPort);
socket.send(requestPacket);
// 3. 读取服务器的 UDP 响应, 并解析
DatagramPacket responsePacket = new DatagramPacket(new byte[4090],4090);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(),0, responsePacket.getLength());
// 4. 把解析好的结果显示出来.
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1",9090);
udpEchoClient.start();
}
}
对服务器客户端代码进行图解:
观察程序运行结果:
对于客户端服务器的程序来说,一个服务器是要给多个客户端提供服务的。IDEA默认只能启动一个客户端,需要微调一下,才可以启动多个客户端。
当然,此处的运行结果是在本机上运行的,而实际上,网络存在的意义,是进行跨主机通信。
在针对上述的程序,来看看 “端口冲突”的异常:
这里的 bind 是操作系统原生的 API,这个 API 本身就是起到的:“绑定IP + 端口”
此处的 Address 表示的含义就相当于是 IP + 端口。
3. 基于 UDP 实现简单的客户端服务器的网络通信程序
1. 方法使用
ServerSocket 是创建TCP服务端的Socket。(专门给服务器使用的Socket对象)
Socket 是客户端 Socket ,或服务端中接收到客户端建立连接( accept 方法)的请求后,返回的服务端Socket。(既会给客户端使用,也会给服务器使用)不管是客户端还是服务端的 Socket ,都是双方建立连接以后,保存的对方信息,及用来与对方收发数据的。
应当注意: TCP不再需要以数据报为单位进行传输了,TCP是面向字节流的,因此是以字节的方式,流式传输。
ServerSocket 构造方法:
ServerSocket(int port) |
创建一个服务端流套接字
Socket
,并绑定到指定端口
|
ServerSocket 方法:
Socket accept()
|
开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端
Socket 对象,并基于该 Socket
建立与客户端的连接,否则阻塞等待
|
void close() |
关闭此套接字
|
Socket accept() 就相当于前面讲到的例子中的 接电话,接了电话后,会返回一个 Socket对象,通过这个 Socket对象来和客户端来进行交流。
Socket 构造方法:
Socket(String host, int port)
|
创建一个客户端流套接字
Socket
,并与对应
IP(host)
的主机上,对应端口(port)的进程建立连接
|
在客户端这边,在构造Socket对象的时候,是需要指定一个 IP 和 端口 的,此处的 IP 和端口就是服务器的 IP 和端口。这样就和服务器建立了连接。
Socket 方法:
InetAddress getInetAddress() |
返回套接字所连接的地址
|
InputStream getInputStream |
返回此套接字的输入流
|
OutputStream getOutputStream |
返回此套接字的输出流
|
进一步可以通过Socket 对象,获取到内部的流对象,借助流对象就可以进行发送和接受数据了。
2. 回显服务器的实现
此处的 accept 的效果是 “接收连接”,也就是需要客户端来建立连接,客户端在构造 Socket 对象的时候,就会指定服务器的 IP和端口,从而建立连接,如果没有客户端来建立连接,此时accept就处于阻塞状态。
此时任意一个客户端连接上来,accept成功了,都会创建一个Socket对象(clientSocket),Socket就相当于文件,会占用文件描述符表的位置,因此在 clientSocket对象使用完毕的时候,要记得 close 进行释放。因为此处的 clientSocket 数量较多,每个客户端都有一个,生命周期也较短,所以要及时close以释放空间。这里把 close 操作放在 finally中以防忘记。
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
public class TcpEchoServer {
private ServerSocket serverSocket = null;
// 对服务器指定端口
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器!");
while (true){
// 使用这个 clientSocket 和具体的客户端进行交流.
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
// 使用这个方法来处理一个连接.
// 这一个连接对应到一个客户端. 但是这里可能会涉及到多次交互.
private void processConnection(Socket clientSocket) throws IOException{
System.out.printf("[%s:%d]客户端上线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
// 基于上述 socket 对象和客户端进行通信
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
// 由于要处理多个请求和响应, 也是使用循环来进行.
while (true){
// 1. 读取请求
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()){
// 没有下个数据, 说明读完了. (客户端关闭了连接)
System.out.printf("[%s:%d]客户端下线!\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
// 注意!! 此处使用 next 是一直读取到换行符/空格/其他空白符结束, 但是最终返回结果里不包含上述 空白符 .
String request = scanner.next();
// 2. 根据请求构造响应
String response = process(request);
// 3. 返回响应结果.
// OutputStream 没有 write String 这样的功能. 可以把 String 里的字节数组拿出来, 进行写入;
// 也可以用字符流来转换一下.
PrintWriter printWriter = new PrintWriter(outputStream);
// 此处使用 println 来写入. 让结果中带有一个 \n 换行. 方便对端来接收解析.
printWriter.println(response);
// flush 用来刷新缓冲区, 保证当前写入的数据, 全部发送出去了.
printWriter.flush();
System.out.printf("[%s:%d] request: %s; response: %s \n",clientSocket.getInetAddress().toString(),clientSocket.getPort(),request,response);
}
}finally {
//把 close 放到 finally 里面, 保证一定能够执行到!!
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoClient = new TcpEchoServer(9090);
tcpEchoClient.start();
}
}
此时代码中还存在一个缺陷:一般一个服务器都是需要应对多个客户端的,而这个代码一次只能应对一个客户端, 这里先完成客户端代码然后再进行分析。
这里再做一次说明:ServerSocket是专属于服务器使用的 socket,用来构造对象,同时指定端口号,然后使用这个socket对象来accept客户端的连接,连接成功后就会返回一个Socket对象,这个Socket对象是用来和客户端进行通信的。通信之间是采用字节流传输的。
这里可能还有一个点比较难理解,就是我们把socket对象当成是文件了。那么写文件,就相当于是通过网卡发送数据,读文件,就相当于是通过网卡接收数据。因此上述服务器代码中 printWriter.printf 就是通过网卡发送数据。 然后执行完 Scanner scanner = new Scanner(inputStream);后再进行scanner.next 就相当于是读取网卡数据了。
对于代码中的转换成PrintWriter:outputStream 相当于对应着一个文件描述符(socket文件) 通过 outputStream 就可以对这个文件描述符中写数据。但是outpuStream自身的方法不方便直接写字符串,因此就把这个流对象转换一下,用一个PrintWriter对象来表示,其对应的文件描述符还是同一个。
使用PrintWriter 写,和OutputStream写,都是往同一个地方写,只不过写字符串更方便了。
至于为什么要是 println 来发送数据,因为这样来发送数据,会自带 \n,而当前的代码中,没有 \n 是不行的,TCP协议,是面向字节流的协议,而字节流的特性就是不管读多少个字节都是可以的,那么接收方如何知道一次传输过程中读取多少字节呢,而这里就隐式规定了使用 \n 来作为当前代码的请求/响应 分割约定。
3. 回显客户端的实现
在客户端的代码中,创建 Socket 对象的时候,需要传入服务器的 IP 和端口。对象构造的过程,就会触发 TCP 建立连接的过程。换句话说,没有这个对象构造,那么服务器就一直在 accept 那里处于阻塞状态,无法得到 clientSocket ,从而无法建立连接。 (这里的 IP参数 能够识别点分十进制格式的 IP 地址,比Datagrampacket要方便一些)
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIP,int serverPort) throws IOException {
// Socket 构造方法, 能够识别 点分十进制格式的 IP 地址. 比 DatagramPacket 更方便.
// new 这个对象的同时, 就会进行 TCP 连接操作.
socket = new Socket(serverIP,serverPort);
}
public void start(){
System.out.println("启动客户端!");
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while (true){
System.out.println(">");
// 1. 先从键盘上读取用户输入的内容
String request = scanner.next();
if (request.equals("exit")){
System.out.println("goodbye");
break;
}
// 2. 把读到的内容构造成请求, 发送给服务器.
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
// 此处加上 flush 保证数据确实发送出去了.
printWriter.flush();
// 3. 读取服务器的响应
Scanner responseScanner = new Scanner(inputStream);
String response = responseScanner.next();
// 4. 把响应内容显示到界面上
System.out.println(response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
tcpEchoClient.start();
}
}
在完成服务器和客户端代码后,回到上述讲到的一个问题,此处的服务器一次只能给一个客户端提供服务。分析原因:
当有客户端连接上服务器之后,代码就执行到 processConnection 这个方法对应的循环里了,此处就意味着,只要这个循环不结束,processConnection这个方法就无法结束,也就无法读取到第二次客户端请求连接了(接受accept)。那么如果在 processConnection 中不使用循环呢,虽然不使用循环了,但是方法中 String request = scanner.next();这条语句会使得,只要客户端不主动下线,就会一直处于阻塞等待的状态,等待客户端发送数据。因此也是无法读取到第二次客户端请求连接的。(接受accept)简单理解,就是一直被客户端1占线了,对其他客户端无法响应。
因此,对应的处理方式就是使用多线程,主线程用于accept,连接客户端。每次收到一个连接,就创建一个新线程来负责该客户端的响应。这样的话,每一个线程就是一个独立的执行流,每个执行流都各自执行各自的逻辑,彼此之间是并发关系,就不会发生客户端1阻塞,影响到其他客户端的连接通信。这里采取线程池来进行优化程序。
public void start() throws IOException {
System.out.println("启动服务器!");
ExecutorService threadPool = Executors.newCachedThreadPool();
while (true){
Socket clientSocket = serverSocket.accept();
// 使用线程池.
threadPool.submit(()->{
try {
processConnection(clientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
}
使用了线程池后,如果客户端还是很多,而且客户端都迟迟不断开连接,这样的话,还是会有可能导致客户端连接不上,对机器也是一个很大的负担。所以就还需要采取更多的方法来解决这样的单机支持更大量客户端的问题,C10M问题。
多开服务器是一个解决办法。针对上述多线程的版本,最大的问题是机器承受不了这么大的线程开销,所以就从这个点出发,做到一个线程处理多个客户端连接。这也就是 IO多路复用/IO多路转接:给线程放一个集合,集合中就放一些连接,这个线程就负责监听这个集合,哪个连接有数据传输过来了,就处理哪个连接。从这也可看出,连接虽然有很多,也都得到了解决,但是这些连接的解决并非是同时进行的,而是又先后顺序的。
对服务器客户端代码进行图解:
三. 从代码角度分析 UDP和TCP 的特征
在完成了 UDP 和 TCP 两个版本的代码后,再从它们之间的特点出发,来横向对比一下 。
UDP :无连接;不可靠传输;面向数据报;全双工;
TCP:有连接;可靠传输;面向字节流;全双工;
从代码角度看,可靠传输是无法感知到的,这一点后面会从网络原理来进行详细分析。