前集回顾
上一篇博客中我们写了一个UDP的echo server,是一个回显服务器:请求是啥,响应就是啥
一个正常的服务器,要做三个事情:
- 读取请求并解析
- 根据请求,计算响应
- 把响应写回到客户端
java
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), 0, response.getBytes().length,requestPacket.getSocketAddress());
//先得到字节数组,然后取字节,然后取数组的长度,这里的长度单位是"字节数"
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
//这里的长度单位是"字符数"
客户端的整体流程:
- 从控制台读取字符串
- 把字符串发送给服务器
- 从服务器读取到响应
- 把响应打印到控制台上
同时后面的serverIp为127.0.0.1,需要把字符串格式的ip地址转成Java能识别的对象,InetAddress对象,提供了getByName工厂方法,把上述字符串格式的ip地址,转成Java能识别的InetAddress对象了
这个对象里有的就是IP地址,会按照32位整数的形式来保存,计算机认识的是32位整数形式的ip,点分十进制是给人看的
java
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), 0, request.getBytes().length,InetAddress.getByName(serverIp), serverPort);
socket.send(requestPacket);
//读取响应数据
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
这个代码在写的时候,上面的send完成之后,就立即执行到下面的receive了,但是实际上,数据在网络传输过程中,也是需要时间的,此处的receive也会阻塞,阻塞到真正到达为止
上述阻塞行为,不需要咱们代码进行任何干预,都是人家的api内部已经实现好了(系统内核完成好了的功能)
在系统内核中,让某个线程阻塞是很容易的事情,通过链表方式来组织PCB,链表不只是一个,而是有多个,有的链表,称为"就绪队列",这里的PCB就要参与cpu调度,有的链表,称为"阻塞队列"(这里的PCB不参与PCB调度),调用receive的时候,系统会看网卡上是否有数据来了(通过网卡驱动很容易能够感知到),然后如果没来,就把当前线程PCB放到阻塞的队列中即可(链表节点的删除/插入)
服务器代码
客户端代码
运行
先运行服务器,后运行客户端
服务器是被动方,要先准备好,得餐馆先开门,然后才能去吃饭
有个客户端,发来一个请求,请求内容是hello,响应内容也是hello,客户端ip是127.0.0.1,客户端的port是49874(系统自动分配的结果)
如何启动多个客户端
默认情况下,idea里面只能启动一个客户端,要想启动多个,需要如下操作:
在service中可以看到
刚才是客户端和服务器,都在同一个电脑上,如果是不同电脑呢?
Question:如果我把客户端发给你,你在客户端中填写这个ip,能否访问我的这个服务器程序呢?
Answer:不能,要想能访问,有一个前提,需要在同一个局域网下,如何能够让世界上任何一个能上网的人脸上咱们的服务器呢?不是咱们的程序代码不行(代码已经就绪),当前这个电脑的IP只是一个局域网内部使用的私有IP,而不是能够在广域网上直接使用的"公网IP",需要一个云服务器来提供公网IP
通过XShell可以远程控制云服务器
上述一串数字就是云服务器IP,也就是公网IP,另外,在所有IP地址中,以下三种情况是私网IP,剩下的都是公网IP
私网IP常用格式
- 10.*
- 172.16-172.31.*
- 192.168.*
- 把写好的udpechoserver放到云服务器上,需要把服务器程序打一个jar包出来
- 把这个jar包传到云服务器上
- 运行这个程序
通过命令来运行程序
通过这串代码可以理解两方面:
总结
- socket api(UDP)
- 服务器程序的典型工作流程
- 读取请求并解析
- 根据请求计算响应
- 把响应写回到客户端
java
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
// 1) 读取请求并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
// 为了方便在 java 代码中处理 (尤其是后面进行打印) 可以把上述数据报中的二进制数据, 拿出来, 构造成 String
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// 2) 根据请求计算响应
String response = this.process(request);
// 3) 把响应写回到客户端
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), 0, response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
System.out.printf("[%s:%d] req=%s, resp=%s\n", requestPacket.getAddress(), requestPacket.getPort(),
request, response);
}
}
// 由于当前写的是 "回显服务器"
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
如果要写别的服务器(例如翻译网站),可以继承该服务器,然后重写process方法,在响应方面重新重写,使用Map来记录key-value之间的关系
TCP
TCP的socket api也有两个关键的类
ServerSocket
其中也有三个重要的方法:
接听操作方法
java
public Socket accept() throws IOException {
if (isClosed())
throw new SocketException("Socket is closed");
if (!isBound())
throw new SocketException("Socket is not bound yet");
Socket s = new Socket((SocketImpl) null);
implAccept(s);
return s;
}
构造方法
java
public ServerSocket(int port) throws IOException {
this(port, 50, null);
}
关闭资源的方法
java
public void close() throws IOException {
synchronized(closeLock) {
if (isClosed())
return;
if (created)
impl.close();
closed = true;
}
}
Socket客户端和服务器都要用(两边都要用,不能叫做ClientSocket),TCP传输的是字节流,就是传输字节,传输的基本单位就是byte
Socket
其中重要的方法
构造方法
java
public Socket(String host, int port) throws UnknownHostException, IOException {
this(host != null ? new InetSocketAddress(host, port) :
new InetSocketAddress(InetAddress.getByName(null), port),
(SocketAddress) null, true);
}
获取socket内部持有的对象
java
public InputStream getInputStream() throws IOException {
if (isClosed())
throw new SocketException("Socket is closed");
if (!isConnected())
throw new SocketException("Socket is not connected");
if (isInputShutdown())
throw new SocketException("Socket input is shutdown");
InputStream in = this.in;
if (in == null) {
// wrap the input stream so that the close method closes this socket
in = new SocketInputStream(this, impl.getInputStream());
if (!IN.compareAndSet(this, null, in)) {
in = this.in;
}
}
return in;
}
获取socket内部持有的流对象
java
public OutputStream getOutputStream() throws IOException {
if (isClosed())
throw new SocketException("Socket is closed");
if (!isConnected())
throw new SocketException("Socket is not connected");
if (isOutputShutdown())
throw new SocketException("Socket output is shutdown");
OutputStream out = this.out;
if (out == null) {
// wrap the output stream so that the close method closes this socket
out = new SocketOutputStream(this, impl.getOutputStream());
if (!OUT.compareAndSet(this, null, out)) {
out = this.out;
}
}
return out;
}
同时还可以获得源和目的主机的信息
java
public InetAddress getInetAddress() {
if (!isConnected())
return null;
try {
return getImpl().getInetAddress();
} catch (SocketException e) {
}
return null;
}
ServerSocket和Socket这俩起到的作用,截然不同的,服务器一上来要先处理客户端来的连接
java
Socket clientSocket = serverSocket.accept();
//服务器一启动,就会立即执行到这里,如果客户端没有连接过来,accept也会产生阻塞,直到说有客户端真的连接上来了
举一个比较形象的栗子:有一个西装革履的小哥来到我面前,这个人不是大老板就是销售,他把我领到一个地方,这个地方都是西装革履的人,给我介绍房子,然后小哥就消失了,然后由别的人带我介绍,此时小哥就可以理解为ServerSocket,其他带我介绍的人可以理解为clientSocket
- ServerScoket用于在服务器端使用的(揽客) - accept
- Socket用于服务器和客户端来进行通信 - getInputStream,getOutStream
相当于打电话的时候,接通电话后,会说很多话,不是说一句就挂了
java
Scanner scanner = new Scanner(inputStream);
String request = scanner.next();
//这个读取方式,就是会读到"空白符"才会读取完毕,如果直接按照read方式来读,读出来的就是byte[]还需要转成Srting
//如果直接使用Scanner的话,直接读出来的就是String,Scanner已经帮我们做好上述的转换操作了
//客户端在发送数据的时候,无比要在每个请求的结尾,填上空白符(\n, \t, 回车, 换行)
//上述要求属于咱们对于通信细节的约定
由于TCP上面是按照字节来传输的,而是实际上,我们是希望,若干个字节能够构成一个"应用层数据报",如何区分从哪到哪是一个应用层数据报?就可以通过"分隔符"的方式来约定,上述代码就是在约定说,使用空白符,来作为一个请求的结束标记
从TCP socket中读出一大串字节,可能会包含多个应用层的请求数据,就需要作出区分
不论是发请求,还是返回响应,也要使用Scanner的方式来next,因此就要求服务器返回的响应也要带有\n,使用next读取数据,如果数据中不带有\n等分隔符的话,此时next就会一直阻塞,Scanner是会带有阻塞功能的,
java
if (!scanner.hasNext()) {
// 如果发现后续没有数据了, 此时说明 TCP 的连接是已经断开了的.
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
String request = scanner.next();
这里的scanner.hasNext就会阻塞等待请求的到达
- 请求到了,有明确的分隔符,返回true
- tcp连接断开了,返回false
scanner,可以认为是关联到了服务器这边的socket文件,socket是能感知到tcp断开连接的(本身是系统内核里面会有一系列的流程,四次挥手)
操作系统知道这个事情之后,就会告知socket,对应的scanner读取的文件,就相当于是"无效文件",类似于读取到EOF这样的效果,使用scanner读文件的时候,就是读到文件末尾,hasNext也是会返回false,一旦tcp连接断开,scanner就相当于读到文件末尾的效果,都是读到EOF,反映到scanner上面就是hasNext为false
Question:
- hasNext方法阻塞时间长了就表名TCP连接断开了吗?
- 客户端自行断开会影响服务端吗?
Answer:
- 不是的,TCP连接断开,hasNext解除阻塞,并返回false,TCP连接仍然存在,但是对方没有发数据过来,hashNext是阻塞的,对方发数据过来了,hasNext解除阻塞,返回true
- 在TCP中是这样的,客户端断开连接,服务器是能感知到的
java
while (true) {
// 3.1 读取请求并解析
if (!scanner.hasNext()) {
// 如果发现后续没有数据了, 此时说明 TCP 的连接是已经断开了的.
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
String request = scanner.next();
// 3.2 根据请求计算响应
String response = process(request);
// 3.3 把响应写回给客户端.
outputStream.write(response.getBytes(), 0, response.getBytes().length);
// 3.4 服务器打印日志
System.out.printf("[%s:%d] req=%s, resp=%s\n", clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
}
java
public void start() {
System.out.println("客户端启动!");
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scannerNetwork = new Scanner(inputStream);
while (true) {
// 1. 从控制台读取数据
System.out.print("请输入要发送的数据: ");
String request = scanner.next();
// 2. 把请求发送给服务器, 发送的请求要带有 \n, 和服务器的 scanner.next 是对应的.
// 由于上述通过 next 读到的 request 本身已经没有 \n 结尾了. 需要手动添加上换行
request += "\n";
outputStream.write(request.getBytes());
// 3. 从服务器读取到响应
if (!scannerNetwork.hasNext()) {
break;
}
String response = scannerNetwork.next();
// 4. 把响应显示到控制台上
System.out.println(response);
}
} catch (Exception e) {
e.printStackTrace();
}
}
重点理解"客户端下线"操作,当强制终止客户端进程,或者通过代码调用客户端socket,close(),就会使客户端所在的主机的操作系统内核,触发TCP断开连接流程(四次挥手),服务器就能感知到,于是就会在hasNext解除阻塞,并返回false(if这里逻辑取反,于是就进入if语句内部,执行打印 下线 的操作,并break了)
上述代码,还存在一些问题
服务器代码这边,对于accept创建的socket对象,是没有进行关闭操作的
java
Socket clientSocket = serverSocket.accept();
//这个东西在processConnection中使用之后没有进行close,这个是不科学的
- serverSocket是可以不必特别关闭的,因为生命周期是跟随整个服务器进程的
- 客户端的socket也是可以不必特别关闭的
但是服务器的clientSocket就不行了,服务器会对应多个客户端,每个客户端都有一个对应的clientSocket,如果用完了不关闭,就会使当前clientSocket对应的文件描述符得不到释放,引起文件资源泄露
java
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
...
} catch (IOException e) {
e.printStackTrace();
} finally {
// 在这里关闭是比较靠谱的做法.
// 即使当前 processConnection 在不同的线程中被调用, 也可以正确关闭.
clientSocket.close();
}
有的时候,close操作的异常也需要单独处理,没啥太好的方法
当前这个代码,服务器是无法同时给多个客户端提供服务端
使用service操作,虽然有两个客户端了,但是服务器只感知到一个客户端上线,第一个客户端,发来的请求能够顺利处理,第二个客户端的请求就处理不了了,一旦第一个客户端结束,服务器就能立即感知到第二个客户端上线,以及感知到客户端之前的请求,并且立即返回响应
java
while (true) {
// 3.1 读取请求并解析
if (!scanner.hasNext()) {
// 如果发现后续没有数据了, 此时说明 TCP 的连接是已经断开了的.
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
String request = scanner.next();
// 3.2 根据请求计算响应
String response = process(request);
// 3.3 把响应写回给客户端.
outputStream.write(response.getBytes(), 0, response.getBytes().length);
// 3.4 服务器打印日志
System.out.printf("[%s:%d] req=%s, resp=%s\n", clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
}
解决方案:多线程
第一个客户端连接之后,此时accept就返回了,进入到processConnection方法,就在方法内部的while循环,开始循环起来了,第二个客户端来了之后,此时,没有办法执行到第二次accept的(第二个客户端,给服务器打电话,服务器一直没接听!)一方面希望给第一个客户端提供服务,另一方面,还能希望快速第二次调用到accept,我们可以使用多线程
主线程,专门负责循环的处理accept,每次accept获取到一个客户端连接之后,都创建一个新的线程,用新的线程来给客户端循环的提供服务
java
public void start() throws IOException {
System.out.println("服务器启动!");
// 能一直扩容
// 如果用固定线程个数的线程池, 不太合适, 就限制了最多有多少个客户端同时连接.
ExecutorService pool = Executors.newCachedThreadPool();
while (true) {
Socket clientSocket = serverSocket.accept();
// 代码改成多线程的形式, 就不能通过 try ( ) 的方式来关闭 clientSocket 了.
// 否则就会使 clientSocket 被立即关闭, 此时 processConnection 还没来得及使用的.
// Thread t = new Thread(() -> {
// try {
// processConnection(clientSocket);
// // clientSocket.close();
// } catch (IOException e) {
// e.printStackTrace();
// }
// });
// t.start();
// 把任务添加到线程池中
pool.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
如果线程太多,电脑会撑不住,所以这里核心解决方案:IO多路复用 + 多个服务器(分布式系统)
IO多路复用
通过AI可以知道IO多路复用是什么机制!
IO多路复用是一种同步非阻塞的IO模型,它允许单个线程处理多个文件描述符(FD),从而管理多个网络连接或IO流。这种机制使得服务器能够高效地处理大量的并发连接而不需要为每个连接创建一个独立的线程或进程。
IO多路复用主要通过三种机制实现:select、poll和epoll。
- select:这是最早的IO多路复用机制,它使用一个文件描述符集合来进行监控,并通过内核检查每个FD来确定是否有事件发生。select的缺点包括对文件描述符数量有限制(通常是1024),以及需要在用户态和内核态之间复制文件描述符集合,这会导致额外的性能开销。
- poll:poll是select的改进版,它没有最大文件描述符数量的限制,并且使用动态数组来存储FD。尽管如此,poll仍然需要遍历整个FD集合来查找哪些FD已经就绪,这在大量FD的情况下效率不高。
- epoll:这是Linux特有的IO多路复用机制,它比select和poll更高效。epoll使用内核事件表来管理FD,并且不需要复制文件描述符集合,从而减少了数据复制的开销。epoll还支持水平触发(LT)和边缘触发(ET)两种模式,其中边缘触发模式可以避免大量不必要的事件通知。
IO多路复用的优势在于它能够提高单个线程处理多个IO请求的能力,减少了线程创建和上下文切换的开销,提高了系统的并发性能。它在网络编程中特别有用,可以用于实现高性能的服务器和客户端应用程序。
在实际应用中,IO多路复用可以用于网络编程、高性能服务器、文件操作和定时器事件调度等场景。例如,Web服务器可以使用IO多路复用来同时处理大量并发请求,而不会因为某个请求的IO操作而阻塞其他请求的执行。
总结来说,IO多路复用是一种强大的技术,可以在单个线程中处理多个I/O操作,提高程序的实时性能,特别是在处理大量数据、提高实时性能和提高系统性能方面具有非常重要的作用。
当前写的tcp server和client这里,就涉及到三种socket
- 服务器ServerSocket
- 服务器Socket(通过这个Socket和客户端提供交互能力)
- 客户端Socket(通过这个Socket和服务器进行交互)
第二个socket,服务器这边会有多个这样的socket,每个客户端都有一个对应的socket,这个socket在客户端断开连接之后,就不再使用了,就需要关闭掉
第一个和第三个都是生命周期跟随整个进程,程序只要在运行,就需要这个socket,不能提前close的,随着进程结束,这些socket自然释放
何时关闭第二个socket?这个连接用完了之后
java
private void processConnection(Socket clientSocket) throws IOException;
此处的processConnection这个方法就表示一个客户端连接的,整个处理过程,这个方法执行完毕,就是用完了(这个方法里也是感知到客户端关闭(断开连接)才返回的),此时就可以在这个方法的末尾,进行close了
自定义应用层协议
- 信息
- 确定数据的格式
- 基于行文本的方式来传输
- 基于xml的方式
- 基于json
- yml
- protobuffer:针对要传输的数据进行压缩,虽然可读性不好,但是能够把空间最充分的利用,最节省网络带宽,效率也最高