✏️作者:银河罐头
系列专栏:JavaEE
“种一棵树最好的时间是十年前,其次是现在”
网络编程这里的核心,Socket API,操作系统给应用程序提供的网络编程 API。
可以认为 Socket API 是和传输层密切相关的。
传输层里提供了两个最核心的协议,UDP 和 TCP。因此 Socket API 也提供了两种风格,UDP 和 TCP
简单认识下 UDP 和 TCP
UDP :无连接 不可靠传输 面向数据报 全双工
TCP :有连接 可靠传输 面向字节流 全双工
打电话就是有连接的,需要建立连接才能通信,建立连接需要对方"接受"
发短信/微信就是无连接的,直接发就行了
网络环境天然是复杂的,不可能保证传输的数据 100% 都能到达。发送方能知道自己的消息是不是发过去了,还是丢了。
打电话是可靠传输,发短信/微信 是不可靠传输(带有已读功能的是可靠传输,比如钉钉)
可靠不可靠和有没有连接没有任何关系
面向字节流:数据传输就和文件读写类似,"流式"的
面向数据报:数据传输则以一个一个的"数据报"为基本单位(一个数据报可能是若干个字节,带有一定格式的)
一个通信通道,可以双向传输(既可以发送,也可以接收)
为啥 TCP 和 UDP 都是全双工呢?
一根网线里有 8 根线
DatagramSocket 使用这个类表示一个 Socket 对象。在操作系统中也是把这个对象当成是一个文件处理的,相当于是文件描述符表上的某一项。
普通的文件对应的硬件设备是硬盘;socket 文件对应的硬件设备是网卡。
一个 Socket 对象就可以和另外一台主机进行通信了,如果要和多个不同的主机进行通信,就要有多个 socket 对象。
DatagramSocket() 没有指定端口,系统则会自动分配一个空闲的端口
DatagramSocket(int port) 这个版本是要传入一个端口号,此时就是让当前的这个 socket 对象和指定的端口(简单的整数)关联起来。
端口号用来标识主机上不同的应用程序
本质上不是进程和端口建立联系,而是进程里的 socket 对象和端口建立了联系
DatagramPacket 表示 UDP 中传输的一个报文,构造这个对象可以指定一些具体的数据进去。
void receive(DatagramPacket p) 此时传入的相当于是一个空的对象,receive 方法内部会对这个空对象进行内容填充,从而构造出结果数据了。这里的参数也是一个"输出型参数"
void send(DatagramPacket p)
void close() 释放资源,用完之后记得关闭
DatagramPacket(byte[] buf, int length) 把 buf 这个缓冲区给设置进去了
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 构造缓冲区 + 地址
SocketAddress 使用这个类表示 IP + 地址
InetAddress getAddress()
int getPort()
byte[] getData()
编写一个最简单的 UDP 版本的客户端服务器程序,称之为回显服务器(echo server)
一个普通的服务器:收到请求,根据请求计算响应(业务逻辑),返回响应;
echo server 省略了其中的"根据请求计算响应",请求是啥就返回啥(这个代码没有实际的业务,这个代码也没啥作用和意义,只是展示了 socket api 的基本用法)
作为一个真正的服务器,"根据请求计算响应"这个环节是最重要的
举个栗子:你去一家餐馆,点一份蛋炒饭,跟老板说"老板,我要一份蛋炒饭",老板把饭炒好以后,给你端上来一份蛋炒饭,其中制作蛋炒饭的过程是最困难的,你点餐和老板给你把饭端上来这两个动作都是简单的
//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[4096],4096);
socket.receive(requestPacket);
//此时这个 DatagramPacket 是一个特殊的对象,并不方便直接进行处理,可以把这里包含的数据拿出来,构造成一个字符串
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
//2.根据请求计算响应,由于这里是回显服务器,请求和响应相同
String response = process(request);
//3.把响应协会到客户端,send的参数也是一个 DatagramPacket,需要把这个 Packet 对象给构造好
//此处这里的响应对象,不能是空的字节数组构造了,而是要使用响应数据来构造
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());
socket.send(responsePacket);
//4.打印一下,当前这次请求响应的处理中间结果
System.out.printf("[%s:%d] req: %s;resp: %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 里任意指定一个
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
关于服务器一定要关联上一个具体的端口这一点
举个栗子:假设我在某学校三食堂 5 号窗口,租了个店面,卖肉夹馍。
此时的三食堂就相当于我的 IP 地址,5 号窗口相当于我的端口号。我开了个餐厅,相当于我搭起了个服务器。
如果我作为一个服务器,我是一个随机端口,会有啥效果?(如果不是固定端口)。每次服务器启动就是一个不同的端口了。
有顾客觉得好吃下一回再来这个5号窗口时,发现不卖肉夹馍了?!
因此作为服务器得固定端口,才方便客户端找到我
对于 UDP 来说,传输数据的基本单位,DatagramPacket
receive 内部会针对参数对象填充数据,填充的数据来自网卡。
服务器的工作流程:
1.读取请求并解析
2.根据请求计算响应
3.构造响应发给对应的客户端
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP = null;
private int serverPort = 0;
//一次通信需要 2 个 IP ,2 个端口
//客户端的 IP 是 127.0.0.1 已知的
//客户端的端口是系统分配的
//服务器的 IP 和端口需要告诉客户端,才能顺利把消息发给服务器
public UdpEchoClient(String serverIP,int serverPort) throws SocketException {
socket = new DatagramSocket();
this.serverIP = serverIP;
this.serverPort = serverPort;
}
public void start(){
System.out.println("客户端启动");
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[4096],4096);
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();
}
}
构造这个 socket 对象,不需要显示的绑定一个端口,让操作系统自动分配一个端口
对于服务器来说,端口必须是确定好的;
对于客户端来说,端口可以是系统分配的。
服务器的端口是要固定指定的:目的是为了方便客户端找到服务器程序
客户端的端口是由系统随机分配的:如果手动指定,可能会和客户端其他程序的端口冲突(服务器上面的程序可控,客户端是运行在用户电脑上,环境更复杂,不可控)
127.0.0.1 => 32位的整数(给计算机看的)
=> 点分十进制(给人看的)
每个部分是 0 ~ 255 一个字节
服务器 - 客户端 交互执行过程:
1.一定是服务器先启动,服务器运行到 receive() 阻塞
2.客户端读取用户输入的内容
3.客户端发送请求
4.客户端阻塞等待响应过来;服务器收到请求,从阻塞中返回。
5.服务器根据请求计算响应
6.服务器发送响应
7.客户端从阻塞中返回,读到响应了
启动服务器,客户端
在 IDEA 上可以手动设置打开多个客户端,让服务器同时和多个客户端进行通信
每次点 运行 都是创建了一个客户端进程。
当前的服务器和客户端的程序,都是在自己的本机上跑的。而实际上网络存在的意义是跨主机通信。当前按这个程序可以做到跨主机通信。
举个栗子:如果张三(服务器)在北京,李四(客户端)在南京。李四想要和张三实现网络通信,不可行。因为张三的电脑没有外网IP,只能在局域网内部进行访问,除非李四到张三家里才可以和张三通信。
不过,李四可以连上"云服务器"(有外网 IP)这样的特殊的电脑,任何一个连上网络的设备都能访问。
回显服务器缺少业务逻辑,在上述代码的基础上稍作调整,实现一个"查词典"的服务器。(英文翻译成中文)
package network;
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
//对于 DictServer 来说,和 EchoServer 相比,大部分都是一样的,
// 主要是"根据请求计算响应"这一步不太一样
public class UdpDictServer extends UdpEchoServer{
private Map<String,String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
//给这个 Dict 设置下内容
dict.put("cat","小猫");
dict.put("dog","小狗");
//这里可以无限多的设置键值对...
}
@Override
public String process(String request){
//查词典的过程
return dict.getOrDefault(request,"当前单词没有查到结果!");
}
public static void main(String[] args) throws IOException {
UdpDictServer udpDictServer = new UdpDictServer(9090);
udpDictServer.start();
}
}
一个端口只能被一个进程使用,如果有多个使用就不行。
前面的UdpEchoServer 和 UdpDictServer 端口都是 9090
UdpEchoServer server = new UdpEchoServer(9090);
UdpDictServer udpDictServer = new UdpDictServer(9090);
此时如果同时运行 UdpEchoServer 和 UdpDictServer 就会抛异常
TCP 提供的 API 主要是 2 个类。
ServerSocket : 专门给服务器使用的 Socket 对象
ServerSocket 构造方法:
ServerSocket(int port) :创建一个服务端流套接字Socket,并绑定到指定端口
ServerSocket 方法:
Socket accept() :开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket
对象,并基于该Socket建立与客户端的连接,否则阻塞等待。
TCP是有连接,此处的 accept() 相当于是"接电话"
2)Socket : 既会给客户端使用,也会给服务器使用
注意,TCP不需要一个类来表示 “TCP 数据报”,因为 UDP 是以数据报为单位来传输的,而 TCP 是以字节为单位进行传输的
Socket 在服务器这边是由 accept() 返回的;
在客户端这边,代码里构造指定一个 IP 和端口号(此处的 IP 和端口是服务器的 IP 和端口),有了这个信息就能和服务器建立连接了。
InputStream getInputStream() :返回此套接字的输入流
OutputStream getOutputStream() :返回此套接字的输出流
进一步通过 Socket 对象获取到内部的流对象,通过流对象来发送和接收
之前的 文件操作是操作硬盘,这里是 操作网卡,读网卡,写网卡。
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();
//效果是建立连接,前提是有客户端来连接
//客户端在构造 Socket 对象的时候指定服务器的 IP 和 端口
//如果没有客户端来连接,就会阻塞
processConnection(clientSocket);
}
}
//使用这个方法来处理一个连接
//这一个连接对应一个客户端,但是这里面可能涉及到多次交互
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
//基于上述 socket 对象和客户端进行通信
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
//由于要处理多个请求和响应,此处用 while 循环
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] req:%s res:%s",clientSocket.getInetAddress().toString(),
clientSocket.getPort(),request,response);
}
}catch(IOException e){
e.printStackTrace();
}finally {
//更合适的做法,是把 close 放到 finally 里面,保证他一定能够执行到
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private String process(String request) {
return request;
}
}
任意一个 客户端连上来都会返回/创建一个 socket 对象,socket 就是文件,每次创建一个 clientSocket 对象,就要占用一个文件描述符表的位置,因此在使用完毕之后要释放。
前面的 UdpEchoServer 的 socket 没有手动释放,一方面是因为这些 socket 的生命周期更长(伴随整个程序),另一方面是这些 socket 不多,固定数量。
而此处的 clientSocket ,数量多,每个客户端有一个,生命周期更短。
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(){
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while(true){
//1.先从键盘读取用户输入的内容
System.out.print("> ");
String request = scanner.next();
if(request.equals("exit")){
System.out.println("goodbye");
break;
}
//2.把读到的内容构造成请求,发送给服务器
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
printWriter.flush();
//3.读取服务器发来的响应
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.next();
//4.把响应内容打印到屏幕上
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
当前代码中还有一个重要的问题:当前的服务器,同一时刻只能处理一个连接(只能给一个客户端提供服务)
当启动服务器之后,启动客户端1可以看到正常的上线提示;再启动客户端2此时发现没有任何提醒了。
客户端1给服务器发送消息正常,而客户端2发送消息则没有任何提示。
(相当于打电话占线了)
当客户端1退出之后,客户端2就可以正常发消息了。
针对 TcpEchoServer 里的代码而言,当客户端连上服务器之后,代码就执行到了 processConnection 这个方法的循环中,此时意味着只要processConnection 这个方法的循环不结束,processConnection 这个方法就结束不了,从而无法第二次调用 accept().
那如果 processConnection 里面不用循环可行吗?
不行,虽然不使用循环,读取请求的时候,可能会阻塞,(next一直读到空白符才结束)
//next一直读到 换行/空格/空白符 结束,最终返回结果里不包换上述空白符
String request = scanner.next();
这里的解决办法是可以采用多线程。
每次收到一个连接,就创建一个新线程,由这个新线程负责处理这个新的客户端。每个线程都是独立的执行流,每个独立的执行流是各自执行各自的逻辑,彼此之间是并发的逻辑,不会发生这边阻塞影响到另一边的情形。
public class TcpEchoServer {
public void start() throws IOException {
System.out.println("服务器启动");
while (true){
//使用 clientSocket 和具体的客户端进行交流
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(()->{
processConnection(clientSocket);
});
t.start();
}
}
}
如果客户端特别多,很多客户端频繁的来建立连接,就需要频繁创建/销毁线程了。此时就可以用线程池来做进一步的优化。
public void start() throws IOException {
System.out.println("服务器启动");
//此处使用 cachedThreadPool,使用 FixedThreadPool不太合适(线程数量不太应该是固定的)
ExecutorService threadPool = Executors.newCachedThreadPool();
while (true){
//使用 clientSocket 和具体的客户端进行交流
Socket clientSocket = serverSocket.accept();
//使用线程池
threadPool.submit(()->{
processConnection(clientSocket);
});
}
}
TCP有连接的场景下,针对连接这个概念有两种典型的表现形式。
1)短连接:客户端每次给服务器发消息,先建立连接,发送请求,读取响应,关闭连接,下次再发送则重新建立连接
2)长连接:客户端,建立连接之后,连接先不着急断开,然后再发送请求读取响应,再发送请求读取响应,若干轮之后,客户端确实短时间之内不再需要使用这个连接了,此时再断开。
上述 TcpEchoServer 虽然是使用了线程池了,但是还不够。
如果客户端非常多,而且客户端连接都迟迟不断开,就会导致咱们的机器上有很多线程,如果一个服务器有几千个客户端就得是几千个线程,有几万个客户端,几万个线程…
这个事情对于机器来说是个很大的负担。
多开服务器是能解决这个问题,但是多开服务器意味着成本的增加。
是否有办法解决单机支持更大量客户端的问题呢?C10M 问题
C10K 问题:单机处理 1 w 个客户端
C10M 问题:单机处理 1kw 个客户端(1kw不是具体数量,只是为了描述比C10K多很多)
针对上述多线程的版本,最大问题是机器承担不了这么大的线程开销。
是否有办法让 1 个线程处理多个客户端的连接?
IO多路复用(IO多路转接)
举个栗子:相当于一个人同时接 2 个电话,这2个电话传输过来的内容是有停顿的,IO过程中也会有等待,IO多路复用就是充分利用等待时间,做别的事。
比如生活中到饭点我去买饭,打算取买个饭再买杯奶茶再去取个快递,干这3件事,最省时间的做法是我先去买饭点好饭之后,利用这个等饭的时间取点杯奶茶,然后立马去取快递,这样做就充分利用了这个等待时间。
给这个线程安排个集合,这个集合就放了一堆连接,这个线程就负责监听这个集合,哪个连接有数据来了线程就来处理哪个连接。这个其实就应用了一个事实,虽然连接有很多,但是这些连接的请求并非严格意义的同时,总还是有间隔时间的。
在操作系统里,提供了一些原生 API:select , poll , epoll。在 Java 中,提供了一组 NIO 这样的类,就封装了上述多路复用的 API。