✏️作者:银河罐头
📋系列专栏:JavaEE
🌲“种一棵树最好的时间是十年前,其次是现在”
目录
- Socket 套接字
- UDP 和 TCP
- UDP数据报套接字编程
- DatagramSocket API
- DatagramPacket API
- UdpEchoServer
- UdpEchoClient
- UdpDictServer
- 端口冲突
- TCP流套接字编程
- ServerSocket API
- Socket API
- TcpEchoServer
- TcpEchoClient
- 服务器同时连接多个客户端
- Tcp 中的长短连接
- C10M 问题
Socket 套接字
网络编程这里的核心,Socket API,操作系统给应用程序提供的网络编程 API。
可以认为 Socket API 是和传输层密切相关的。
传输层里提供了两个最核心的协议,UDP 和 TCP。因此 Socket API 也提供了两种风格,UDP 和 TCP
UDP 和 TCP
简单认识下 UDP 和 TCP
UDP :无连接 不可靠传输 面向数据报 全双工
TCP :有连接 可靠传输 面向字节流 全双工
- 什么是有连接?什么是无连接?
打电话就是有连接的,需要建立连接才能通信,建立连接需要对方"接受"
发短信/微信就是无连接的,直接发就行了
- 什么是可靠传输?什么是不可靠传输?
网络环境天然是复杂的,不可能保证传输的数据 100% 都能到达。发送方能知道自己的消息是不是发过去了,还是丢了。
打电话是可靠传输,发短信/微信 是不可靠传输(带有已读功能的是可靠传输,比如钉钉)
可靠不可靠和有没有连接没有任何关系
- 面向字节流 && 面向数据报
面向字节流:数据传输就和文件读写类似,"流式"的
面向数据报:数据传输则以一个一个的"数据报"为基本单位(一个数据报可能是若干个字节,带有一定格式的)
- 全双工
一个通信通道,可以双向传输(既可以发送,也可以接收)
为啥 TCP 和 UDP 都是全双工呢?
一根网线里有 8 根线
UDP数据报套接字编程
DatagramSocket API
DatagramSocket 使用这个类表示一个 Socket 对象。在操作系统中也是把这个对象当成是一个文件处理的,相当于是文件描述符表上的某一项。
普通的文件对应的硬件设备是硬盘;socket 文件对应的硬件设备是网卡。
一个 Socket 对象就可以和另外一台主机进行通信了,如果要和多个不同的主机进行通信,就要有多个 socket 对象。
- DatagramSocket 构造方法:
DatagramSocket() 没有指定端口,系统则会自动分配一个空闲的端口
DatagramSocket(int port) 这个版本是要传入一个端口号,此时就是让当前的这个 socket 对象和指定的端口(简单的整数)关联起来。
端口号用来标识主机上不同的应用程序
本质上不是进程和端口建立联系,而是进程里的 socket 对象和端口建立了联系
- DatagramSocket 方法:
DatagramPacket 表示 UDP 中传输的一个报文,构造这个对象可以指定一些具体的数据进去。
void receive(DatagramPacket p) 此时传入的相当于是一个空的对象,receive 方法内部会对这个空对象进行内容填充,从而构造出结果数据了。这里的参数也是一个"输出型参数"
void send(DatagramPacket p)
void close() 释放资源,用完之后记得关闭
DatagramPacket API
- DatagramPacket 构造方法:
DatagramPacket(byte[] buf, int length) 把 buf 这个缓冲区给设置进去了
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 构造缓冲区 + 地址
SocketAddress 使用这个类表示 IP + 地址
- DatagramPacket 方法:
InetAddress getAddress()
int getPort()
byte[] getData()
UdpEchoServer
编写一个最简单的 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.构造响应发给对应的客户端
UdpEchoClient
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)这样的特殊的电脑,任何一个连上网络的设备都能访问。
UdpDictServer
回显服务器缺少业务逻辑,在上述代码的基础上稍作调整,实现一个"查词典"的服务器。(英文翻译成中文)
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流套接字编程
TCP 提供的 API 主要是 2 个类。
ServerSocket API
ServerSocket : 专门给服务器使用的 Socket 对象
-
ServerSocket 构造方法:
ServerSocket(int port) :创建一个服务端流套接字Socket,并绑定到指定端口
-
ServerSocket 方法:
Socket accept() :开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket
对象,并基于该Socket建立与客户端的连接,否则阻塞等待。
TCP是有连接,此处的 accept() 相当于是"接电话"
Socket API
2)Socket : 既会给客户端使用,也会给服务器使用
注意,TCP不需要一个类来表示 “TCP 数据报”,因为 UDP 是以数据报为单位来传输的,而 TCP 是以字节为单位进行传输的
Socket 在服务器这边是由 accept() 返回的;
在客户端这边,代码里构造指定一个 IP 和端口号(此处的 IP 和端口是服务器的 IP 和端口),有了这个信息就能和服务器建立连接了。
- Socket 方法:
InputStream getInputStream() :返回此套接字的输入流
OutputStream getOutputStream() :返回此套接字的输出流
进一步通过 Socket 对象获取到内部的流对象,通过流对象来发送和接收
之前的 文件操作是操作硬盘,这里是 操作网卡,读网卡,写网卡。
TcpEchoServer
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 ,数量多,每个客户端有一个,生命周期更短。
TcpEchoClient
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 中的长短连接
TCP有连接的场景下,针对连接这个概念有两种典型的表现形式。
1)短连接:客户端每次给服务器发消息,先建立连接,发送请求,读取响应,关闭连接,下次再发送则重新建立连接
2)长连接:客户端,建立连接之后,连接先不着急断开,然后再发送请求读取响应,再发送请求读取响应,若干轮之后,客户端确实短时间之内不再需要使用这个连接了,此时再断开。
C10M 问题
上述 TcpEchoServer 虽然是使用了线程池了,但是还不够。
如果客户端非常多,而且客户端连接都迟迟不断开,就会导致咱们的机器上有很多线程,如果一个服务器有几千个客户端就得是几千个线程,有几万个客户端,几万个线程…
这个事情对于机器来说是个很大的负担。
多开服务器是能解决这个问题,但是多开服务器意味着成本的增加。
是否有办法解决单机支持更大量客户端的问题呢?C10M 问题
C10K 问题:单机处理 1 w 个客户端
C10M 问题:单机处理 1kw 个客户端(1kw不是具体数量,只是为了描述比C10K多很多)
针对上述多线程的版本,最大问题是机器承担不了这么大的线程开销。
是否有办法让 1 个线程处理多个客户端的连接?
IO多路复用(IO多路转接)
举个栗子🌰:相当于一个人同时接 2 个电话,这2个电话传输过来的内容是有停顿的,IO过程中也会有等待,IO多路复用就是充分利用等待时间,做别的事。
比如生活中到饭点我去买饭,打算取买个饭再买杯奶茶再去取个快递,干这3件事,最省时间的做法是我先去买饭点好饭之后,利用这个等饭的时间取点杯奶茶,然后立马去取快递,这样做就充分利用了这个等待时间。
给这个线程安排个集合,这个集合就放了一堆连接,这个线程就负责监听这个集合,哪个连接有数据来了线程就来处理哪个连接。这个其实就应用了一个事实,虽然连接有很多,但是这些连接的请求并非严格意义的同时,总还是有间隔时间的。
在操作系统里,提供了一些原生 API:select , poll , epoll。在 Java 中,提供了一组 NIO 这样的类,就封装了上述多路复用的 API。