文章目录
一、网络编程基础
1.概念
网络编程,是通过代码来控制,让两台主机的进程之间能够进行数据交互。
例如:我使用qq发送一个消息,这个消息就通过我电脑上的qq客户端进程,先发送给了腾讯的服务器(对应的服务器进程),再由腾讯的服务器进程,把这个消息转发给对方的电脑的qq进程。
2. socket API介绍
为了达到网络编程,操作系统就把网络编程的一些相关操作就封装起来了,提供了一组API供程序员来调用。网络编程的相关操作都是由操作系统来提供的,如访问网络核心的硬件设备——网卡,它也是由操作系统管理的。
操作系统提供的API叫做socket API,也可以称为套接字。这个socket API 是C语言风格的接口,在Java中是不能直接使用的。而JDK也针对socket API也进行了封装,在标准库中有一组类,这组类能够让我们完成网络编程。这组类本质上仍然是调用了操作系统提供的scoket API 。
二、socket API
1. 流套接字和数据报套接字介绍
操作系统中提供的API主要有两类(实际上不止这两类,还要有其它的)。
1.流套接字 (底层使用TCP协议)
2.数据报套接字 (底层使用UDP协议)
TCP和UDP都是在传输层中的协议,socket API也是传输层的东西。
2. TCP和UDP的特点
TCP:
1.有连接
2.可靠传输
3.面向字节流
4.全双工
UDP:
1.无连接
2.不可靠传输
3.面向数据报
4.全双工
对TCP和UDP特点的分析:
1.有连接和无连接:
例如打电话就是有连接,发微信就是无连接。
2.可靠传输和不可靠传输:
可靠传输就是发送方能够知道对方是否收到。打电话就是可靠传输。
不可靠传输就是发送方不能够知道对方是否收到。发微信不是可靠传输。
注:可靠传输和不可靠传输跟安全性无关,TCP确实比UDP可靠,但是不是安全性的问题。而且所谓的可靠传输,不是100%能够被对方收到的。
3.面向字节流和面向数据报
面向字节流:为了发送100个字节,可以一次发1个字节,重复100次,也可以一次发10个字节,重复10次。可以非常灵活地完成这里的发送,接受也是同理。因为TCP面向字节流,因此网络编程时会涉及到InputStream和OutputStream
面向数据报:一个以数据报为基本单位(每个数据报多大,不同的协议里面是有不同的约定的) 。
发送的时候,一次至少发一个数据报(如果尝试发一个半,实际上只能发半个)
接收的时候,一次至少收一个数据报(如果尝试收半个,剩下半个就没了)
4.全双工和半双工
全双工:双向通信,A和B可以同时发送和接受数据。可以想成是两个根管子。
半双工:单向通信,要么A给B发,要么B给A发,不能同时。可以想象成一根管子里面有水,要把水吹出来。
3. UDP socket中核心的两个类
1.DatagramSocket
它是用来描述一个socket对象的,对socket文件进行了封装。有两个核心方法:receive和send。
a) receive方法:接受数据,如果没有数据过来,receive就会阻塞等待。如果有数据过来了,receive就会返回一个DatagramPacket对象。
b) send方法:以DatagramPacket为单位进行发送。
上面说到socket API是操作系统提供的,socket是该API中涉及到的核心概念,要想操作网卡,就要先创建出socket文件,通过读写这个socket文件的方式来操作网卡。
socket本质上是一个文件描述符。网卡也是一个硬件设备,操作系统也是通过文件来管理网卡,此处用来表示网卡的文件,就是socket文件。
2.DatagramPacket
它是用来描述一个UDP数据报的,对UDP数据报进行了封装。
面向数据报,就是以DatagramPacket为单位进行的。
注:发送的时候,要知道发送的目标在哪;接受的时候,需要知道这个数据是从哪里来。因此就涉及到IP地址和端口号。JDK都将它们封装成InetSocketAddress类来表示。
4. 基于UDP实现回显(Echo)程序和翻译程序
4.1 UDP实现回显程序
回显程序指的是“复读机”,客户端发送啥服务器就返回啥。
正常的客户端/服务器的通信流程:
注:
1.回显程序中没有第三步,相当于客户端的请求是啥,返回给客户端的响应就是啥。
2.第三步是服务器的灵魂。是最核心的操作。
服务器的代码:
public class UdpEchoSever {
private DatagramSocket socket = null;
// port 表示端口号.
//服务器需要指定自己的端口号是多少,客户端才能发送请求到服务器。
//若不指定服务器的端口号,那么客户端就无法发送请求到指定的服务器中
// 虽然此处 port 写的类型是 int, 但是实际上端口号是一个两个字节的无符号整数.
// 范围 0-65535
public UdpEchoSever(int port) throws SocketException {
this.socket=new DatagramSocket(port);
}
// 通过这个方法来启动服务器.
public void start() throws IOException {
System.out.println("服务器启动");
// 服务器一般都是持续运行的(7*24的时间)
while(true) {
// 1. 读取请求. 当前服务器不知道客户端啥时候发来请求. receive 方法也会阻塞.
// 如果真的有请求过来了, 此时 receive 就会返回.
// 参数 DatagramPacket 是一个输出型参数. socket 中读到的数据会设置到这个参数的对象中.
// DatagramPacket 在构造的时候, 需要指定一个缓冲区(就是一段内存空间, 通常使用 byte[]).
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
// 把 requestPacket 对象里面的内容取出来, 作为一个字符串.
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
// 2. 根据请求来计算响应.
String respond = process(request);
// 3. 把响应写回到客户端. 这时候也需要构造一个 DatagramPacket
// 此处给 DatagramPacket 中设置的长度, 必须是 "字节的个数".
// 如果直接取 response.length() 此处得到的是, 字符串的长度, 也就是 "字符的个数"
// 当前的 responsePacket 在构造的时候, 还需要指定这个包要发给谁.
// 其实发送给的目标, 就是发请求的那一方.
DatagramPacket responsePacket = new DatagramPacket(respond.getBytes(),respond.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
// 4. 加上日志打印.
// %d 表示要打印一个有符号十进制的整数. %s 表示要打印一个字符串.
// 不建议使用字符串拼接.
String log = String.format("[%s:%d] req:%s; resp:%s",
requestPacket.getAddress().toString(),
requestPacket.getPort(),
request,respond);
System.out.println(log);
}
}
// 此处的 process 方法负责的功能, 就是根据请求来计算响应.
// 由于当前是一个 回显服务器 , 就把客户端发的请求直接返回回去即可.
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoSever udpEchoSever = new UdpEchoSever(9090);
udpEchoSever.start();
}
}
注:
1.服务器中的socket要指定服务器的端口号,否则客户端无法得知发送请求给谁。
2.在接收数据的时候要创建一个DatagramPacket对象并指定缓冲区
3.接收到数据的时候要用字符串接收DatagramPacket对象中的数据,调用String的构造方法,传入的参数有接收的DatagramPacket对象的getData方法、内容的起始位置、偏移量。
4.如果要将字符串的结果返回客户端,需要构造出DatagramPacket对象,调用DatagramPacket的构造方法,传入的参数有 需要将返回的字符集编码为字节序列存到新的字节数组中(调用getBytes方法)、以及计算存到新的字节数组中的长度、从接收到的DatagramPacket对象中获取到客户端的ip和端口(调用接收的DatagramPacket的getSocketAddress方法)。
5.调用String的format方法来格式化字符串要注意:格式化的字符串和后面的参数必须要类型和个数都一致。
客户端的代码:
public class UdpEchoClient {
private String severIp ;
private int severPort;
private DatagramSocket socket = null;
// 参数的 ip 和 port 指的是, 服务器的 ip 和 端口.
public UdpEchoClient(String severIp, int severPort) throws SocketException {
this.severIp = severIp;
this.severPort = severPort;
this.socket = new DatagramSocket();
}
public void start() throws IOException {
Scanner scan = new Scanner(System.in);
while(true) {
// 1. 从标准输入读入一个数据
System.out.print("->");
String request = scan.nextLine();
if(request.equals("exit")) {
System.out.println("exit!");
return;
}
// 2. 把字符串构造成一个 UDP 请求, 并发送数据
// 这个 DatagramPacket 中, 既要包含具体的数据, 又要包含这个数据发给谁?
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,
InetAddress.getByName(severIp),severPort);
socket.send(requestPacket);
// 3. 尝试从服务器读取响应
DatagramPacket respondPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(respondPacket);
String respond = new String(respondPacket.getData(),0,respondPacket.getLength());
// 4. 显式这个结果.
String log = String.format("req:%s;resp:%s",request,respond);
System.out.println(log);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1",9090);
udpEchoClient.start();
}
}
注意:
1.在UdpEchoClient构造方法中不能指定客户端的端口号,客户端的端口号就会由操作系统去自动分配。如果指定了客户端的端口号并且该端口号有程序在使用,那么代码中的程序就无法继续使用了。
2.将输入的字符串打包成DatagramPacket对象,调用DatagramPacket的构造方法,传入的参数有:字符串的getBytes方法、字符串存到新的字节数组的长度、服务器的IP地址(调用 InetAddress 的 getByName 方法)和服务器的端口号。
3.在main方法中创建UdpEchoClient对象调用它的构造方法时一定要和服务器自己指定的端口号是相同的,而IP地址都是本机的IP地址(127.0.0.1) 。
可以将客户端和服务器的通信当作是一个客人和餐厅,客人必须要知道餐厅的地址才能去访问,而在客人访问后,餐厅才能知道该客人是从从何而来的。
4.2 UDP实现翻译程序
要实现的是英译汉,客户端输入的请求是英文单词,返回的响应是对应的中文解释。
对于上面的 UDP echo Server 程序来说,里面其实就已经完成了一个相对完整的逻辑。只需要将process的方法(处理请求)改动即可,而客户端的代码是一样的。
服务器的改动:
public class UdpDictClient {
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
public UdpDictClient(String severIP, int severPort) throws SocketException {
this.socket = new DatagramSocket();
this.serverIP = severIP;
this.serverPort = severPort;
}
public void start() throws IOException {
Scanner scan = new Scanner(System.in);
while (true) {
System.out.print("->");
String request = scan.next();
if(request.equals("exit")) {
System.out.println("exit!");
return;
}
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIP), serverPort);
socket.send(requestPacket);
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
String log = String.format("req:%s;resp:%s", request, response);
System.out.println(log);
}
}
public static void main(String[] args) throws IOException {
UdpDictClient udpDictClient = new UdpDictClient("127.0.0.1",9090);
udpDictClient.start();
}
}
关键是要使用一个 HashMap 来实现查表,根据接收到的请求来查表,并返回处理过的响应。
5. TCP实现多种程序
5.1 TCP中的关键类和方法
TCP的套接字API和UDP是完全不相同的。
1.ServerSocket
方法:
a) accpet:跟TCP中的特性“有连接”密切相关,accept就是“接电话”这个动作,客户端首先和服务器尝试建立连接,服务器的操作系统内核就尝试将该连接运用到代码中。用户代码调用accept,才是真正地把连接拿到用户代码中。
因为每次有客户端来尝试连接,而服务器就需要用accept来建立连接。accept方法是ServerSocket对象调用的,该对象也是对应到文件的,因此需要close。
而在Udp中没有关闭对应的socket对象,是因为它不需要关闭,它的生命周期是跟随整个程序的。如果socket/文件 没有关闭,当进程结束的时候,对应的资源也就自然释放了。
2.Socket
这个类是用来接收ServerSocket调用accept返回的结果的。后面的代码都是用这个Socket类对象来操作的。
TCP Socket是面向字节流的,因此能够用Socket对象来获取到对应的InputStream和OutputStream对象。
5.2 TCP实现回显(Echo)程序
关键思路:
首先在上面我们就了解TCP实现客户端和服务器的连接涉及到两个主要的类:ServerSocket和Socket,ServerSocket只是在服务器当中使用的,而Socket在服务器和客户端中都要使用,因为涉及到面向字节流。
构造方法中类似于UDP实现的Echo,先将ServerSocket初始化,传入端口号来指定服务器的端口号。
在start方法中,我们让它死循环地读取请求和返回响应,直到没有客户端连接为止。
接收请求的需要用到的是Scanner类,将InputStream对象传入它的构造方法中,就能够接收客户端的请求,调用它的next方法就能够接收客户端发送的字符串。而服务器要返回响应则需要PrintWriter类来返回,将OutputStream传入到它的构造方法中,再调用它的println方法来实现。
因为这是回显程序,因此process方法中对接收的请求不作计算就返回。
服务器代码:
public class TcpEchoServer {
private ServerSocket listenSocket = null;
public TcpEchoServer(int port) throws IOException {
listenSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
// UDP 的服务器进入主循环, 就直接尝试 receive 读取请求了.
// 但是 TCP 是有连接的. 先需要做的是, 建立好连接
// 当服务器运行的时候, 当前是否有客户端来建立连接, 不确定~~
// 如果客户端没有建立连接, accept 就会阻塞等待
// 如果有客户端建立连接了, 此时 accept 就会返回一个 Socket 对象
// 进一步的服务器和客户端之间的交互, 就交给 clientSocket 来完成了~
Socket clientSocket = listenSocket.accept();
processConnection(clientSocket);
}
}
private void processConnection(Socket clientSocket) throws IOException {
// 处理一个连接. 在这个连接中可能会涉及客户端和服务器之间的多次交互
String log = String.format("[%s:%d] 客户端上线!",
clientSocket.getInetAddress().toString(), clientSocket.getPort());
System.out.println(log);
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
while (true) {
// 1. 读取请求并解析
// 可以直接通过 inputStream 的 read 把数据读到一个 byte[] , 然后再转成一个 String
// 但是比较麻烦. 还可以借助 Scanner 来完成这个工作.
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
log = String.format("[%s:%d] 客户端下线!",
clientSocket.getInetAddress().toString(), clientSocket.getPort());
// 下线要打印日志.
System.out.println(log);
break;
}
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回给客户端
PrintWriter writer = new PrintWriter(outputStream);
writer.println(response);
writer.flush();
log = String.format("[%s:%d] req: %s; resp: %s",
clientSocket.getInetAddress().toString(), clientSocket.getPort(),
request, response);
System.out.println(log);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
// 当前的 clientSocket 生命周期, 不是跟随整个程序, 而是和连接相关.
// 因此就需要每个连接结束, 都要进行关闭.
// 否则随着连接的增多, 这个 socket 文件就可能出现资源泄露的情况
clientSocket.close();
}
}
// 当前是实现一个回显服务器
// 客户端发啥, 服务器就返回啥.
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
部分细节和代码解析:
1.下面的这部分代码,如果服务器和客户端连接后,客户端没有发送请求时,服务器就一直在if的判断中阻塞,除非该服务器与客户端断开连接才说明真正没有内容的传输了,就进入到if语句中退出。
2.对listenSocket名字的解析
在操作系统原生的socket API中(在操作系统提供的那组C语言风格的API),其中有一个API叫做listen,这个API功能,就是让当前的 socket 变成一个 处理连接的 socket ,但是在Java标准库中,listen方法已经被封装到 ServerSocket 的内部了,因此外面已经感知不到了。
3.对listenSocket和clientSocket的理解
例如房地产销售,首先是一个中介去找人看看有没有人买房,找到人之后他就会把要买房的人推荐给专门推销房子的人来推荐和介绍,而中介就会不理已经交给专门推销房子买房的人,而是去找下一个买房的人了。此时的中介就是listenSocket,而专门销售房子的人就是clientSocket。 如果客户端断了,对应的clientSocket就会自动销毁了。
4.在TCP服务器中有一个listenSocket,但是可能有多个clientSocket,并且是每个客户端对应的是一个clientSocket。因此每个客户端结束后在finally部分都会关闭clientSocket资源。
5.因为我们用Scanner 的next方法来接收请求或响应,那么就要对应到使用PrintWriter的println去发送。在发送后要调用flush方法去刷新缓冲区。当我们不知道是客户端还是服务器出错时,我们可以使用抓包工具来分析。
特殊的工具是可以监测到网卡上的数据是怎么来的,TCP协议主要的两个抓包工具:tcpdump(Linux下的抓包工具)、wireshark(跨平台、图形化的抓包工具)。
可以在官网中下载:
前提:先运行服务器,后运行客户端后再使用wireshark。并且使用PrintWriter后没有刷新缓冲区。
点入wireshark:
选择的网卡:主要是划红线的三个:
如果数据是走有线网的,就选 以太网 这个选项。
如果数据是走无线网的,就选 WLAN 这个选项。
如果数据是走127.0.0.1环回 IP 的,就选Adaptor for … 这个选项。
这些是当前机器的”网络接口“,网卡或者虚拟网卡。因此我们可以选最后一个。
点入该虚拟网卡后:红色方框是哪个端口号到哪个端口号的意思。在黄色方框处输入我们服务器的端口号9090,即输入 tcp.port=9090 黄色方框处也是一个过滤器,只抓取我们想要的数据,最简单的办法就是按照端口号来过滤。
输入后:当我们在客户端输入hello前,看到的是下图的这三次握手;当我们在客户端中输入hello+回车,此时刷新后仍然只看到这三次握手,就说明是输入的时候就出错了。(9090是服务器端口号,另外一个就是客户端端口号)
当我们在使用PrintWriter后刷新缓冲区,再来看wireshark的时候,出现了这几条:
在后面经常会用到的抓包工具有fiddler,它是专门抓 http 协议的数据报的。
客户端代码:
public class TcpEchoClient {
private String serverIP ;
private int serverPort ;
private Socket socket =null;
public TcpEchoClient(String serverIP, int serverPort) throws IOException {
this.serverIP = serverIP;
this.serverPort = serverPort;
socket=new Socket(serverIP,serverPort);
}
public void start() {
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
while(true) {
Scanner scanner = new Scanner(System.in);
System.out.print("->");
String request = scanner.next();
if(request.equals("exit")) {
System.out.println("exit!");
break;
}
PrintWriter writer = new PrintWriter(outputStream);
writer.println(request);
writer.flush();
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.next();
String log = String.format("[req:%s;resp:%s]",request,response);
System.out.println(log);
}
}catch(IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
tcpEchoClient.start();
}
}
客户端、服务器运行的整体逻辑:
先启动服务器,服务器就会在accept方法中等待客户端发送请求,因此就会阻塞等待。再启动客户端后,accept就返回进入到processConnection方法在if语句的hasNext方法中阻塞等待,等待客户端发送的请求。此时客户端让我们输入,输入完毕后将请求发送给服务器,发送完毕后客户端就阻塞第二个Scanner处等待返回的响应。服务器接收到请求后,就进行后面的处理请求并返回响应的操作。以此类推。
在进行 IO 处理中,确实还是很多时候会产生阻塞的,(阻塞等待的过程其实是无法从根本上避免的),毕竟服务器也不知道客户端啥时候才会发送请求。因此实际开发中,要想提高程序的效率,就只能在阻塞等待的过程中,来干点别的事。
5.3 TCP实现多线程回显程序
问题表述:在上面的代码中,如果实现多个客户端来对应一个服务器的话,可能会出现bug。因为根据代码结构来看。第一个客户端如果跟服务器连接了,就会进入到processConnection方法中,并且在等待请求中阻塞等待,因为又是在死循环中的操作,除非客户端下线,否则服务器就会一直在processConnection方法中出不来,就会一直占用着资源,其它客户端就没法再调用该方法了。并且在实际开发中,一个服务器要对应很多个客户端。
如何做到同一块的客户端代码来运行多个客户端?此时就需要一些设置:
a)
b)
在启动第二个客户端来连接没有实现多线程的服务器时,服务器没有提示有”客户端上线“,但是我们在服务器中设定的是:每有一个客户端与服务器连接,服务器就打印”客户端上线“并输出它的 IP 地址和端口号。但是实际上不是如此,因此该没有实现多线程的代码是存在漏洞的。
解决方法:
为了解决这个问题,我们可以使用多线程来调用processConnection方法,那么就可以做到很多个客户端可以调用多个processConnection方法,就解决了上面的问题。让主线程始终调用accept方法,其它的代码都是一样的。
要这样解决的原因很简单,因为服务器在连接成功客户端后代码都是在processConnection方法中阻塞的,因此利用多线程能够解决一个客户端把该方法阻塞,并且其它方法连接成功后不能进入到该方法 的问题。
代码:
public class TcpThreadEchoServer {
private ServerSocket listenSocket = null;
public TcpThreadEchoServer(int port) throws IOException {
listenSocket=new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while(true) {
Socket clientSocket = listenSocket.accept();
Thread t = new Thread() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
};
t.start();
}
}
public void processConnection(Socket clientSocket) throws IOException {
while(true) {
String log = String.format("[%s,%d] 客户端上线!",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
System.out.println(log);
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
while (true) {
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()) {
log = String.format("%s;%d",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
System.out.println(log);
break;
}
String request = scanner.next();
String response = process(request);
PrintWriter writer = new PrintWriter(outputStream);
writer.println(response);
writer.flush();
log = String.format("[%s,%d] req:%s resp:%s",clientSocket.getInetAddress().toString(),
clientSocket.getPort(),request,response);
System.out.println(log);
}
} catch (IOException e) {
e.printStackTrace();
}finally {
clientSocket.close();
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpThreadEchoServer tcpThreadEchoServer = new TcpThreadEchoServer(9090);
tcpThreadEchoServer.start();
}
}
5.4 TCP实现线程池的回显程序
描述问题:
在实际开发中,客户端的数量可能会很多,对于线程频繁地进行创建和销毁,对资源的开销非常大。虽然线程比进程更轻量,但是如果有很多的客户端连接又退出,就会有很大的成本。
解决方案:引入线程池来保存线程。
代码:
public class TcpThreadPoolEchoServer {
private ServerSocket listenSocket = null;
public TcpThreadPoolEchoServer(int port) throws IOException {
listenSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
ExecutorService executorService = Executors.newCachedThreadPool();
while (true) {
Socket clientSocket = listenSocket.accept();
// 使用线程池来处理当前的 processConnection
executorService.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
public void processConnection(Socket clientSocket) throws IOException {
String log = String.format("[%s:%d] 客户端上线!", clientSocket.getInetAddress().toString(),
clientSocket.getPort());
System.out.println(log);
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
while (true) {
// 1. 读取请求并解析
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
log = String.format("[%s:%d] 客户端下线!", clientSocket.getInetAddress().toString(),
clientSocket.getPort());
System.out.println(log);
break;
}
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回到客户端
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
printWriter.flush();
log = String.format("[%s:%d] req: %s; resp: %s", clientSocket.getInetAddress().toString(),
clientSocket.getPort(), request, response);
System.out.println(log);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
clientSocket.close();
}
}
// 回显服务器, 直接把请求返回即可
public String process(String request) {
return request;
}
}
假设在极端情况下,一个服务器面临很多的客户端,这些客户端连上后并没有退出,那么服务器在同一时刻,就可能会存在很多很多的线程。
实际上这是不科学的,每个线程都会占用一定的资源,如果线程太多了,此时很多系统资源(CPU\内存)都会非常紧张,达到一定程度后,机器就扛不住了。
解决方法:
1.可以利用协程代替线程来完成并发(协程比线程更轻量) 。
2.可以使用IO多路复用的机制,完成并发。
这个方法是从根本上解决机器高并发的问题,在内核里面是支持这个功能的。
场景:假设有1w个客户端,在服务器这边会利用一定的数据结构把这1w个socket文件都保存好,而IO多路复用机制就不需要一个线程对应一个客户端,一共就需要1个/几个线程就可以。它能够做到假如socket上面有数据了,就通知到应用程序,让一个线程从socket中读取数据。
3.使用多个主机(分布式),即提供更多的硬件资源。
5.5 利用继承先前的代码来实现TCP翻译程序
我们可以利用先前的线程池的代码实现复用,即继承线程池的代码即可。对于回显程序和翻译程序最大的不同,就是在服务器中计算响应的方式不同。因此我们可以利用当前类来重写 继承的实现线程池回显程序的类 的 process方法即可。
代码:
import java.io.IOException;
import java.util.HashMap;
// 通过观察前面的逻辑, 就发现, 其实 echo 服务器和 dict 服务器之间, 主要的区别就是 process 方法.
// 其他的都差不多. 因此就可以通过继承的手段来复用代码.
// 只需要重写 process 方法, 重新实现根据请求计算响应的逻辑即可.
public class TcpDictServer extends TcpThreadPoolEchoServer {
private HashMap<String, String> dict = new HashMap<>();
public TcpDictServer(int port) throws IOException {
super(port);
dict.put("hello", "你好");
dict.put("cat", "小猫");
dict.put("dog", "小狗");
}
// start 方法不变
// processConnection 方法也不变
// 只要修改 process 即可!
@Override
public String process(String request) {
return dict.getOrDefault(request, "[要查的词不存在]");
}
public static void main(String[] args) throws IOException {
TcpDictServer server = new TcpDictServer(9090);
server.start();
}
}