目录
一. 前言
Socket套接字,是由系统提供用于网络通信的技术,是基于 “TCP/IP协议” 的网络通信的基本操作单元。基于Socket套接字的网络程序开发就是网络编程。
Socket 本身是"插槽"的意思(电脑主板插着各种硬件的口)。socket只是一个形象的比喻,因为网络通信需要一根线将设备连接在一起,通过这个线就可以通信了,因此这个术语就保留下来了。
接下来要学习的是操作系统提供的 Socket API(Java版本),JDK把这些系统的相关操作都封装好了,系统原生API是C++要学的:比如 Linux操作系统,Java 只需要学习Linux基本操作,而C++除了要学习基本操作外,还要学习 “多线程编程” “多进程编程” “网络编程”,因为Java的API已经涵盖了“多线程编程”和“网络编程”,不需要学习系统原生的API。而C++直到最新的23标准,仍然不包含网络编程组件,只能学习原生API。但是不同操作系统的API是不一样的,C++学习了Linux的网络编程API,但无法进行 Windows的网络编程。Java的这一套 API 是可以跨平台的。(Java生态不鼓励“多进程编程”,虽然Java内部也提供了多进程相关的 API 封装,但一般很少用。)
Socket API提供了两组不同的API:UDP一套,TCP一套(由于UDP和TCP的差别有点大,不因此,不得不分成两套不同API)
二. TCP与UDP的对比
- TCP(传输控制协议):有连接,可靠传输,面向字节流,全双工
- UDP(用户数据报协议):无连接,不可靠传输,面向数据报,全双工
2.1 连接性
- TCP是面向连接的协议,在数据传输之前,需要先建立连接,数据传输完成后需要释放连接
- UDP是无连接的,发送数据之前不需要建立连接,数据可以直接发送给接收方
此处的连接是“抽象”的连接,可以认为是通信双方如果保存了通信对端的信息,就是“有连接”;如果不保存对端的信息,就是“无连接”。
比如 结婚证一式两份,本上记录了两个人的名字/照片等信息,夫妻双方一人一份,此时两人就相当于建立起了“抽象/逻辑上的连接”。 而所说的网络上的连接,指通信双方A B分别保存记录对方的信息(主要是IP与端口号),此时就建立起了连接;如果通信双方各自把对方的信息删除掉,就相当于断开了连接。
“对端” 是指网络通信中的另一端点或参与者,比如 网络连接的对端,当两个网络设备或网络应用程序通过互联网或其他网络进行通信时,每一方都可以被称为另一方的对端;在即时通讯软件中,两个用户之间的聊天会话中,每一方都是另一方的对端。
2.2 可靠性
- TCP提供了可靠的服务。它确保数据包的顺序传输,并且通过重传机制保证数据的可靠性。
- UDP不保证数据包的顺序,也不保证数据包的可靠性。如果发生数据丢失,UDP不会进行重传。
此处谈到的可靠,不是百分之百到达对方,而是尽可能。因为网络的环境十分复杂,会存在很多的不确定因素(比如通信光纤受到破坏)。相对来说,不可靠就是指 完全不考虑数据是否能够到达对方。
TCP内置了一些机制能够保证可靠传输:1)能感知对方是否收到了数据 2)有重传机制,在对方没收到的时候进行重试,提高传输成功的概率;UDP没有可靠机制,完全不管发出去的数据是否顺利到达对方
直观感觉可靠传输比不可靠传输更好,但不能一概而论。可靠传输要付出代价,会使TCP协议的设计比UDP复杂很多,同时也会降低一些传输数据的效率。
2.3 面向字节流/数据报
- TCP是面向字节流的,传输过程的基本单位是字节,与文件流/水流是一样的特点。(具体"流"的解释可参考文件 文件操作 和 I/O流 这篇文章)
- UDP是面向数据报的,传输过程的基本单位是UDP数据报。一次 发送/接收,必须 发送/接收 完整的UDP数据报(Datagram)。
上述不同就会直接影响到代码的写法。
2.4 全双工
“全双工” 允许数据在通信通道上同时双向传输,即数据可以在同一时间内沿着两个相反的方向流动。这种模式类似于电话通话,通话的双方可以同时说话和听对方说话,而不会相互干扰。TCP与UDP均为全双工。(在物理层面上,并非只有一根线连接,假如一根网线里有8根铜线,分成两组 [44一组, 一组主要是工作的,另一组主要是备份的 ] ,一组的4根线中,会有一根用来发送,一根用来接收)
“半双工” 允许数据在通信通道上双向传输,但是不能同时进行。在半双工模式下,设备可以在给定的时间内发送数据或者接收数据,但不能同时进行这两种操作。这就像一个单轨道铁路,火车只能在一个方向上行驶,当火车在轨道上行驶时,另一方向的火车必须等待。
Java代码基本都是全双工,不必考虑半双工;但C++的“进程间通信”-->管道pipe,就是半双工的。
三. UDP 的 API
核心类:DatagramSocket类 和 DatagramPacket类
3.1 DatagramSocket
DatagramSocket 代表一个 Socket 对象(操作系统的概念),相当于网卡设备的“遥控器”,可
以进行数据的发送和接收。(如果直接通过代码操作网卡,不好操作,因为网卡有很多种不同型号,它们之间提供的 API 都会有差别,因此操作系统就把网卡的概念封装成 Socket,应用程序员就不必关注硬件的差异和细节,统一操作 Socket对象 就能间接操作网卡,就像网卡遥控器一样)
在操作系统的广义“文件”下,Socket 也可以认为是一种文件类型,这样的文件,就是“网卡”这种硬件设备抽象的表现形式。操作系统中的硬件设备有很多种(显卡、硬盘、打印机、控制台、键盘....),为了便于管理、简化代码编写过程,就会对具体硬件设备做进一步的抽象,因此引入了“文件”的概念,而 Socket 就是文件中的一种类型,特指网卡这个硬件设备。
因此 Socket 的很多操作都与文件类似,比如 文件操作:打开->读写->关闭;同理,Socket操作:打开->读写->关闭;创建一个文件对象就会占用一个文件描述表里面的资源,同理,创建一个 Socket 对象也会占用一个文件描述表里面的资源。
3.1.1 构造方法
方法签名 | 方法说明 |
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端) |
3.1.2 方法
方法签名 | 方法说明 |
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
3.2 DatagramPacket
DatagramPacket 代表一个UDP数据报,就是UDP传输数据的基本单位。
3.2.1 构造方法
方法签名 | 方法说明 |
DatagramPacket(byte[] buf, int length) | 构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度 (第二个参数length) |
DatagramPacket(byte[]buf, int offset, int length, SocketAddress address) | 构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从0到指定长度(第二个参数length),address指定目的主机的IP和端口号 |
版本一需要指定 DatagramPacket 如何存储网络传输的数据,版本二需要传入SocketAddress(指定IP地址和端口号)。版本一是接收数据的时候使用,版本二是发送数据的时候使用。
3.2.2 方法
方法签名 | 方法说明 |
InetAddress getAddress() | 从接收的数据报中,获取发送端主机的IP地址;或从发送的数据报中,获取接收端主机IP地址 |
int getport() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
byte[] getData | 获取数据报中的数据 |
3.3 回显服务器
最简单的网络程序,都差不多要把上面的API用个遍,很难把API单独拎出来写一个示例,和
JDBC的情况是类似的。代码部分需要实现两个程序:1)UDP服务器:被动接收请求的一方 2)UDP客户端:主动发起通信的一方。
1)服务器
编写一个最简单的客户端服务器程序,不涉及业务流程,只是对 API 的用法做演示,这样的服务器叫做“回显服务器(echo server)” —— 客户端发出什么样的请求,就返回什么样的相应,没有任何的业务逻辑,不做任何计算或处理。
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);
// receive操作就会将从网卡收到的udp数据报 写到datagramPacket里面
// (这个datagramPacket里面包含了收到的请求数据,以二进制字节数组体现,后续如果要进行打印之类的处理操作,最好转成字符串)
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
// 2:根据请求计算响应,由于此处是一个回响服务器,响应就是请求
String response = process(request);
// 3:把响应协会到客户端
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());
//针对返回响应操作,不能使用空的数组来构造Packet对象
socket.send(responsePacket);
// 4:打印日志
System.out.printf("[%s:%d] req = %s, resp = %s\n",requestPacket.getAddress(),requestPacket.getPort(),
request,response);
}
}
protected String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();//当前只有服务端,无法收到数据,只能阻塞,无法看到效果
}
}
服务器需要在 Socket对象 创建的时候,指定一个端口号,作为构造方法的参数。当后续服务器开始运行之后,操作系统就会把端口号与该进程关联起来,用来区分进程。因为一台主机上要有很多的进程、很多程序操作网络,当收到数据时,就要通过端口号区分具体要将数据交给哪个进程处理。 在程序启动的时候,就要确定程序所关联的端口号,否则客户端后续想找到服务器就无法找到。(比如学校食堂新开了一个餐口,把店开起来的前提是必须要确定好开在哪个食堂【服务器的IP】、哪个档口【服务器的端口号】)
绑定端口号问题:
在调用这个构造方法的过程中,jvm 就会调用系统的原生 Socket API,完成"端口号"和"进程"之间的关联动作 --> "绑定端口号”(“绑定"-->系统原生 API 的名字叫做bind)。
对于一个系统来说,同一时刻,一个端口号只能被一个进程绑定(因为端口号是用来区分进程,明确收到的数据要交给谁),如果有多个进程尝试绑定同一个端口号,此时后来的进程就会绑定失败( 但是一个进程可以绑定多个端口号,通过创建多个Socket对象完成)
此时就需要找到端口号是被谁绑定了,找到对应的进程,决定要删掉旧的进程,还是修改新进程的端口号(需要用到一些命令,Windows上,提供了netstat、netstat -ano、netstat -ano|findstr 【按照指定的信息进行过滤】 的命令,就可以显示出主机上的网络相关的信息;
Linux上,netstat 命令,字符串查找就是 netstat | grep)
此时就能找到端口号为9090的一条UDP记录,这条记录就描述了当前绑定9090端口的进程信息,最后的数字32804就是绑定9090端口进程的PID(进程唯一标识),之后打开任务管理器,找到PID为32804的进程。
但如果是在不同协议下,9090端口,在UDP下被一个进程绑定了,还可以在TCP下被另个进程绑定。
服务器中包含一个死循环是很常见的情况,并不是bug。因为对于服务器这种 7*24小时 工作的情
况,存在死循环是非常常见的。对于一个服务器来说,主要的工作就是不断处理客户端发来的请求,由于客户端什么时候发来请求,服务器是无法预测的,因此服务器只能时刻准备好(随时有客户端的请求发来了,就要随时处理)
receive 操作时从网卡上读取数据,但是调用receive的时候,网卡上不一定就有数据。如果网
卡上收到数据了,receive就立即返回,并且获取到收到的数据;如果网卡上没有收到数据,receive就会阻塞等待,一直等待到真正收到数据为止。
此处 receive 也是通过输出型参数获取到网卡上收到的数据(首先需要构造一个DatagramPacket对象,将DatagramPacket对象作为参数传递给 receive,就能将获取到的数据存入到这个对象中)
DatagramPacket 自身需要存储数据的,但是存储数据的空间多大,需要外部来定义(空间大
小能确保存储下通信的一个数据报即可,无固定要求),receive 操作就会将从网卡收到的UDP数据报,写到DatagramPacket里面(这个DatagramPacket里面包含了收到的请求数据,以二进制字节数组体现,后续如果要进行打印之类的处理操作,最好转成字符串)
构造一个String对象,是可以基于一个字节数组来构造,参数要指定字节数组、起始位置(偏移量,默认指的是相对于数组开头的偏移位置,因此偏移量就与数组下标等价,即从数组的哪个位置开始构造 String)、长度。
- 请求(request):客户端主动给服务器发起的数据
- 响应(response):服务器给客户端返回的数据
String 可以通过字节数组来构造,也可以取出里面的字节数组。
注意:
response.getBytes().length 与 response.length() 并不是一样的。
- response.getBytes().length:先获取字节数组,再获取字节数组的长度,单位是“字节"
- response.length():获取字符串中字符的个数,单位是“字符"
很有可能字符数与字节数不同,如果字符串都是 英文字母 / 阿拉伯数字 / 英文标点符号,都是ASCII编码的,一个字符与一个字节是等长的;如果字符串中有中文,字节数与字符数是不同的。
UDP 的特点是"无连接"(所谓的连接就是通信对方保存对方的信息),换句话说, DatagramSocket 这个对象中,不持有对方(客户端)的IP和端口号,因此进行 send 时,就需要在 send 的数据报里,把要发给谁这样的信息写进去,才能够正确把数据进行返回。相比之下,后续TCP的代码中,不需要关心对端的IP和端口号,只管发数据即可。
客户端IP与端口号,就包含在 requestPacket.getSocketAddress() 中,源码如下:
因为创建一个Socket对象,就会在文件描述表占用空间,因此使用完后就要关闭。但在此处的代码中,Socket的生命周期是跟随整个进程的,进程结束了,Socket 就会被自动回收,释放文件描述表里面的所有内容,也就相当于close了。
2)客户端
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
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();
// 2.构造一个UDP请求,发送给服务器
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(this.serverIP), this.serverPort);
socket.send(requestPacket);
// 3.从服务器读取响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);//receive也会发生阻塞,直到服务器有数据发送过来,返回响应
//直接构造一个空白的DatagramPacket,然后调用receive方法,进行接收
// 4.将响应显示到控制台
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws SocketException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
try {
client.start();
} catch (IOException e) {
e.printStackTrace();
}
}
}
注意:
在服务器创建socket时,一定要指定端口号!
原因:服务器必须要明确自己的端口号,客户端主动发起数据的时候,才能找到服务器)
在客户端创建Socket时,最好不要指定端口号!
原因:
1. 客户端是主动发起的一方,不需要让服务器来找他,客户端就不需要指定自己的端口号了(不指定不代表没有端口号,而是系统自动分配了一个端口号。因为一次通信过程中,需要源IP 源端口,目的IP 目的端口。当客户端给服务器发数据,此时源IP 源端口就是客户端,目的IP 目的端口就是服务器)
2. 如果在客户端指定了端口之后,由于客户端是在"用户"的电脑上运行的,无法知道用户电脑上,有哪些程序已经占用了哪些端口。万一代码指定的端口与用户电脑上运行的其他的程序的端口冲突了,就出bug了。因此,让系统自动分配一个端口,就能确保是分配一个无人使用的空闲端口。
例子:
学校二食堂的七号档口开了一家麻辣烫,有同学过来点餐,食堂阿姨就说,你先找地方坐一下,一会儿好了给你端过去。这个同学找的位置必然是一个“空闲”的位置,而且他这次来和下次来大概率是坐在不同位置上(可能上次位置有人了)
同学“找的位置”就类似于系统自动分配的空闲端口号,不能指定固定的(当客户端被分配了端口号,服务器就要记录这个端口号);而“食堂档口号"就类似于服务器,必须要有一个固定端口号。
问题
服务器指定固定端口就不怕与别的程序冲突吗?—— 不怕的!
原因:
服务器程序运行在服务器上,服务器主机与用户电脑的区别是,服务器主机在程序员手里,当端口号发生冲突,程序员就可以进行修改,灵活操作。
如果代码运行在服务器上,环境问题就非常好处理;如果代码运行在用户电脑上,环境问题就非常麻烦。
此处是给服务器发送数据,发送数据的时候,UDP数据报里就需要带有目标IP和目标端口
号;接受数据的时候,构造的UDP数据报,就是一个空的数据报即可。
异常提示:此处DatagramPacket不提供这个版本的构造方法,问题主要出现在IP。因为一般使用的是整数IP,而此处是一个String类型的IP,因此要将这里的IP进行转换 --> 给serverlP包裹一个方法,根据字符串风格,将IP进行转换。
receive也会发生阻塞,直到服务器有数据发送过来,返回响应为止。
此处的IP是一个特殊的IP,127.0.0.1 叫做 "环回IP",如果客户端与服务器在同一台主机上通信
时,就会用到这个IP(代表本机)
3)运行
此处的信息就是客户端给服务器发起请求,服务器处理的过程关键日志:127.0.0.1 是客户
端的IP,56865 是客户端的端口号(随机的)
4)整个过程的流程
1. 服务器一启动,就会在 receive 的地方进行阻塞,等待客户端发起请求。
2. 用户在客户端输入内容之后,就会真正执行下列发送请求的逻辑,执行完send发送完毕的。同时,客户端继续往下走,就会在receive处阻塞,等待服务器的响应。
3. 服务器收到了请求,从 receive 返回,继续往下走(此时客户端阻塞),构造 String,处理请求后,构造响应数据包,再 send 给客户端。
4. 客户端收到了服务器返回的响应之后,就会从receive这里解除阻塞,继续执行。
5. 服务器 send 完毕之后,就会打印日志,进入下一次循环,继续在receive 处阻塞;客户端打印完之后,也会进入下一次循环,就要继续从scanner中,读取用户输入的内容。
四. TCP的API
核心类:ServerSocket类 和 Socket类。
SeverSocket类是专门给服务器使用的Socket,Socket类既会给客户端使用,也会给服务器使用。
问题
此处为什么没有一个类表示TCP数据报呢?
原因:
TCP是面向字节流的,TCP上传输数据的基本单位就是byte,并不需要额外定义,直接用byte数组即可;UDP是面向数据报的,因此需要定义专门的类,作为UDP传输的基本单位。因此TCP进行读数据写数据的时候,都是以字节作为参数进行展开。
4.1 ServerSocket
4.1.1 构造方法
方法签名 | 方法说明 |
ServerSocket (int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
注:同一时刻,同一个协议下,一个端口号,只能被一个进程绑定,如果有多个进程绑定,就会报错
4.1.2 方法
方法签名 | 方法说明 |
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
- accept():TCP是有连接的,就需要有一个"建立过程”,建立连接的过程就类似于打电话,此处的accept就相当于接电话。由于客户端是主动发起的一方,服务器是被动接受的一方,一定是客户端"打电话”,服务器“接电话”,有几个客户端就需要接几次电话。
4.2 Socket
4.2.1 构造方法
方法签名 | 方法说明 |
Socket(String host, int port) | 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接 |
host --> IP,port --> 指端口号,构造Socket对象,就是与服务器“打电话”,建立连接的过程。
4.2.2 方法
方法签名 | 方法说明 |
InetAddress getlnetAddress() | 返回套接字所连接的地址 |
InputStream getlnputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
- InetAddress getlnetAddress():得到对端的信息(IP、端口)
- InputStream 和 OutputStream 称为“字节流",而TCP本身就是面向字节流的,因此针对TCP进行的读写,就是基于 InputStream 和 OutputStream 展开的。前面针对文件操作的方法,此处针对 TCP Socket 也是完全适用的。
4.3 回显服务器
1)服务器
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){
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
private void processConnection(Socket clientSocket) throws IOException {
// 打印以下客户端的信息
System.out.printf("[%s:%d]客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
//获取到 socket 中持有的流对象
try(InputStream inputStream = clientSocket.getInputStream()){
OutputStream outputStream = clientSocket.getOutputStream();
// 使用Scanner包装一下数据,就可以更方便的读取到这里的请求数据了
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
//1.读取请求并解析
if(!scanner.hasNext()){
//如果scanner中无法读取数据,说明客户端关闭了连接,导致服务器这边读取到“末尾”
break;
}
String request = scanner.next();
//2.根据请求计算响应
String response = process(request);
//3.把响应写回给客户端
//此处可以按照字节数组直接来写,也可以有另外一种写法
//outputStream.write(response.getBytes());
printWriter.println(response);
//4.打印日志
System.out.printf("[%s:%d] req = %s; resp = %s\n",clientSocket.getInetAddress(),clientSocket.getPort(),
request,response);
}
}catch(IOException e){
e.printStackTrace();
}
System.out.printf("[%s:%d]客户端下线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
TCP 建立连接的过程是操作系统内核完成的,代码感知不到。accept操作,是内核已经完成了连接建立的操作,然后才能进行"接通电话";相当于针对内核中,已经建立好的连接进行一个确认的动作,才能进行后续通信。 accept的返回值是一个Socket对象,此时程序中就有两个Socket对象。(都是“Socket”,都是“网卡的遥控器”,操作网卡,但是在TCP中,使用两个不同的 Socket进
行表示,分工是不同的,工作也是不同的。)
每次服务器调用一次 accept,都会产生出一个新的Socket对象,来和客户端进行“一对一的服务”(类似于揽客与接客,买楼的销售,一个会负责揽客,将揽到的客人交给“置业顾问”,负责提供一对一的讲解服务,但他们本质都是“销售"的工作)
TCP是一个全双工的通信,一个Socket对象,既可以读,也可以写。
使用 PrintWriter 的 println 目的是在写入响应的时候,末尾能够自动加上“\n”(执行换行操作),因为Scanner读取数据的时候,隐藏了一个条件:请求应该是以“空白符”结尾(包括但不限于“回车符”“制表符”“空格”“翻页符”…...),因此就约定使用“\n”作为请求和响应的结尾,后续客户端也会使用scanner.next读取响应。
TCP是字节流的,读写方式存在无数种可能,就需要有办法区分出从哪里到哪里是一个完整的
请求数据,此处就可以引入空白符进行区分。
2)客户端
public class TcpEchoClient {
private Socket socket = null;
private String serverIP;
private int serverPort;
public TcpEchoClient(String serverIP, int serverPort) throws Exception {
// 此处Socket可以直接填写一个String风格的IP,不需要像UDP一样进行转换,因为java.net.Socket的构造函数已经做了处理
socket = new Socket(serverIP, serverPort);
}
public void start() throws Exception {
System.out.println("客户端启动");
try(InputStream inputStream = socket.getInputStream() ){
OutputStream outputStream = socket.getOutputStream();
Scanner scanner = new Scanner(inputStream);
Scanner scannerIn = new Scanner(System.in);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
//1.从控制台读取数据
System.out.print("->");
String request = scannerIn.next();
//2.把请求发送给服务器
printWriter.println(request);
//3.从服务器读取响应
if(!scanner.hasNext()){
break;
}
String response = scanner.next();
//4.打印响应结果
System.out.println(response);
}
}catch (Exception e){
e.printStackTrace();
}
}
public static void main(String[] args) throws Exception {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
tcpEchoClient.start();
}
}
这里写入IP和端口,意味着对象一旦 new 好 Socket对象,就会和服务器的连接建立完成;如果连接建立失败,就会直接在构造对象的时候抛出异常。
服务器的阻塞
- 第一处阻塞:等待客户端与自己连接上 (new Socket),才会执行accept操作
- 第二处阻塞:等待客户端发送数据(客户端进行println),在客户端发数据之前,hasNext不会返回,只会阻塞(next也会阻塞,如果不加hasNext),除非是主动告诉服务器不会再发消息,此时hasNext才会返回
3)运行与问题
问题一: 客户端发送了数据之后,并没有任何响应。
此处的情况是:客户端并没有真正把请求发送出去。为什么呢?—— 由于请求存放在PrintWriter的缓冲区里,每次的请求数据比较少,没有办法将请求发送出去,就一直停留在缓冲区了。
问题就出现在PrintWriter。因为像 PrintWriter 这样的类,以及很多IO流中的类,都是自带“缓冲区”,因为进行文件/网络操作,都是I/O操作,本身就是一个开销比较大,耗时比较多的操作。如果频繁进行I/O操作,就会很大影响程序的执行效率。引入缓冲区后,进行写入数据的操作,就不会立即触发I/O,而是先放到一个内存缓冲区中,等到缓冲区里的数据足够多,再统一进行发送,这样就能减少I/O操作的次数。
此时,我们可以引入一个 flush操作,主动“刷新缓冲区"的内容。
上述这个问题,其实是一个很普遍的问题,不局限于Java,未来在开发中,经常会添加“日
志”,可能会遇到明明打印日志的函数已经执行到了,但是日志没有显示出来,这种情况很可
能就是缓冲区导致的问题。
问题二:当前服务器的代码,针对 clientSocket 没有进行 close操作 。
每次循环都会执行 accept操作,即创建一个Socket对象。UDP的服务器中,像ServerSocket、DatagramSocket的生命周期是跟随整个进程的,因此此时可以不写close;而这里的 clientSocket的“连接级别”数据,是随着客户端断开连接,这个Socket就不再使用了(同一个客户端断开之后,重新连接,也是一个新的Socket,与旧的Socket不是同一个)。因此,这样的Socket应该主动关闭掉,如果不关闭,就会造成文件资源泄漏。
因此,在以后要注意一切需要关闭的资源(像 Socket、文件、释放锁等问题),都要即使处
理。
问题
为什么使用完Scanner和PrintWriter,没有进行close呢?
调用 close 最主要的目的是为了释放文件描述符。在Java网络编程中,Scanner 和 PrintWriter 作为高级流包装类,并不直接持有文件描述符,而是依赖于底层的Socket流,关闭Socket是释放文件描述符的关键操作。因此,在关闭Socket时,操作系统会释放与之关联的文件描述符,从而间接关闭了这些装饰流。尽管在上述代码中不显式关闭 Scanner 和 PrintWriter,不会导致资源泄露,但建议使用完毕后关闭所有流对象,有助于清晰管理资源并避免潜在问题。
一个进程中,有三个特殊的流对象(特殊的文件描述符),不需要关闭!当进程一启动,操作系统就会自动打开这三个流对象,他们的生命周期是跟随整个进程的:
- System.in --> 标准输入
- System.out --> 标准输出
- System.err --> 标准错误
标准输出和标准错误,都是显示在控制台上,看起来没什么区别(颜色差异只是idea染色)。其实标准输出,标准错误的内容,是支持“重定向”的,可以把这些输出的内容,重定向到文件中。如果采取重定向的话,就可以把标准输出(打印程序正常信息)和标准错误(打印程序异常信息)重定向到不同的文件中。
问题三: 此时的服务器在同一时刻,只能给一个服务器提供服务,只能停止上一个客户端,才能服务下一个客户端(这是不科学的!)
当第一个客户端连上服务器之后,服务器代码就会进入 processConnection 内部的 while循环。此时,第二个客户端,尝试连接的时候,无法执行到第二次 accept,就无法给第二个客户端提供服务。而第二个客户端输入的请求,都积压在操作系统内核的接收缓冲区中,等到第一个客户端退出的时候,意味着processConnection内部的while 循环结束了,于是外层循环就可以执行accept,就可以处理第二个客户端之前积压的数据了。
此处无法处理多个客户端,本质上是服务器代码结构存在的问题。由于采取了双重while循环的
写法,就会导致进入里层 while循环 的时候,外层while循环无法继续执行了。
解决办法: 将“双重while”改成“一重while”,分别进行执行。
如何实现呢? —— 多线程
这样的代码,属于是比较经典的一种服务器开发模型,给每个客户端分配一个线程来提供服务。
如果一旦短时间内有大量的客户端,并且每个客户端其实请求都是很快的,这时对于服务器来说,就会有比较大的压力。虽然创建线程比创建进程更轻量,但是也架不住短时间内,创建销毁大量的线程。此时,就可以选择使用“线程池”(应用于线程频繁创建和销毁的情况)
但是当有几百万个客户端连上了服务器,针对这种高并发场景,是以分布式(集群)方式来应对。
进行网络服务器开发的时候,可以使用更少的线程,处理更多的客户端。 虽然刚才是一个线程服务于一个客户端,实际上,每个这样的线程,都可能会阻塞(客户端不是持续发送请求的)。
相比于处理请求的时间,大部分的时间可能都是在阻塞等待。如果可以让一个线程,同时给多个客户端提供服务的话,就可以充分利用资源。
针对这样的情况,就需要操作系统内部提供支持了。IO多路复用,也就是操作系统内核提供的功能(IO多路复用具体的实现方案有多种,最知名的就是Linux下的epoll)
“IO多路复用” 是一种允许单个线程或进程同时监视多个文件描述符(通常是网络套接字)的状态变化(如可读、可写和异常)的技术。这种技术在网络编程中尤为重要,因为它可以有效地处理多个并发连接,而不需要为每个连接单独分配一个线程或进程。
Linux中的 epoll 机制:在操作系统内核中,设计了一种数据结构,可以将多个Socket(每个Socket对应一个客户端)放到这个数据结构中。同一时刻,大部分的Socket都是处于阻塞等待(没有数据需要处理),少数收到数据的Socket,epoll机制就会通过回调函数的方式,通知应用程序,这里有数据了。应用程序,就可以使用少量的线程,针对这里“有数据”的Socket 进行处理即可。
epoll 通常用于需要处理大量并发连接的网络服务器,如高性能的Web服务器、代理服务器、游戏服务器等,它通过优化文件描述符的管理和事件通知机制,大大提高了网络编程的性能和效率。
- 长连接 一种在通信完成后保持连接状态的连接方式,适用于需要频繁交换数据的场景。客户端连上服务器之后,一个连接中,会多次发起请求,接受多个响应(一个连接到底进行多少次请求是不确定的)。当前的 EchoClient 就属于这种模式
- 短连接 一种每次通信完成后就会断开的连接方式。客户端连上服务器之后,一个连接,只发一个请求,接受一个响应,然后就断开连接了。(可能会频繁和服务器 建立 / 断开连接,建立/断开连接,也是有开销的)
4)最终代码
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("启动服务器");
ExecutorService service = Executors.newCachedThreadPool();
while(true){
Socket clientSocket = serverSocket.accept();
//使用线程池
service.submit(()->{
try{
processConnection(clientSocket);
}catch(IOException e){
e.printStackTrace();
}
});
}
}
private void processConnection(Socket clientSocket) throws IOException {
// 打印以下客户端的信息
System.out.printf("[%s:%d]客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
//获取到 socket 中持有的流对象
try(InputStream inputStream = clientSocket.getInputStream()){
OutputStream outputStream = clientSocket.getOutputStream();
// 使用Scanner包装一下数据,就可以更方便的读取到这里的请求数据了
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
//1.读取请求并解析
if(!scanner.hasNext()){
//如果scanner中无法读取数据,说明客户端关闭了连接,导致服务器这边读取到“末尾”
break;
}
String request = scanner.next();
//2.根据请求计算响应
String response = process(request);
//3.把响应写回给客户端
//此处可以按照字节数组直接来写,也可以有另外一种写法
//outputStream.write(response.getBytes());
printWriter.println(response);
printWriter.flush();
//4.打印日志
System.out.printf("[%s:%d] req = %s; resp = %s\n",clientSocket.getInetAddress(),clientSocket.getPort(),
request,response);
}
}catch(IOException e){
e.printStackTrace();
}finally{
System.out.printf("[%s:%d]客户端下线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
public class TcpEchoClient {
private Socket socket = null;
private String serverIP;
private int serverPort;
public TcpEchoClient(String serverIP, int serverPort) throws Exception {
// 此处Socket可以直接填写一个String风格的IP,不需要像UDP一样进行转换,因为java.net.Socket的构造函数已经做了处理
socket = new Socket(serverIP, serverPort);
}
public void start() throws Exception {
System.out.println("客户端启动");
try(InputStream inputStream = socket.getInputStream() ){
OutputStream outputStream = socket.getOutputStream();
Scanner scanner = new Scanner(inputStream);
Scanner scannerIn = new Scanner(System.in);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
//1.从控制台读取数据
System.out.print("->");
String request = scannerIn.next();
//2.把请求发送给服务器
printWriter.println(request);
printWriter.flush();
//3.从服务器读取响应
if(!scanner.hasNext()){
break;
}
String response = scanner.next();
//4.打印响应结果
System.out.println(response);
}
}catch (Exception e){
e.printStackTrace();
}
}
public static void main(String[] args) throws Exception {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
tcpEchoClient.start();
}
}
五. 跨主机通信
问题
此处的通信是本机上的,如果有两个主机,能够跨主机通信吗?—— 能也不能。
原因:
- 不能:如果是独立的两台主机,是无法进行直接通信的;除非两个服务器都连接到同一个网络,无论是局域网(WiFi是无线局域网)、广域网、互联网,它们就能通过IP地址和端口号进行通信。(主要与IPv4协议有关)
- 能:如果把我们的服务器代码,放到“云服务器”上,此时就是可以的。云服务器拥有公网IP,而我们自己的电脑没有公网IP。
所谓的云服务器也是一个电脑,这个电脑是租来的电脑,通常租来的云服务器的硬件配置和性能,相比于咱们的台式机/笔记本弱很多,但是云服务器具备一点优势,带有公网IP(个人电脑很难获取到)。云服务器当然也有配置高的,但是价格较高。
云服务器一般都是使用Linux作为操作系统,与Windows的差别是很大的,最直观的体现是:Linux是通过命令行来操作的,Windows是通过可视化界面操作的(Linux虽然也有图形界面,但是Linux的图形界面比Windows的界面落后很多)
跨主机通信步骤
1)先有一个云服务器(腾讯云、阿里云、华为云、京东云、ucloud…...哪个便宜买哪个)
2) 还要本地电脑上有一个连接服务器的终端软件,种类非常多,用哪个都行(Xshell7)
3)登录到服务器上(服务器IP,用户名,密码)
4)把自己写的服务器代码,打包成一个jar包(Java 编译生成的.class文件,把.class文件打成
特定结构的压缩包,就是jar包),上传到服务器上
5)在云服务器上运行服务器程序 jar包(java -jar 包名.jar)
6)运行客户端,连接服务器(修改 serverIP为云服务器的IP),便能进行客户端服务器的通讯
网络编程的意义
打破空间的制约,只要能够连接上网络,就可以通过网络和服务器进行交互。掌握Scket编程之后,后续自己写的程序或项目,也都是服务器类的项目,就可以部署到云服务器上,同时也可以给别人使用。Socket 进行网络编程的基础API,后续实际工作中,更多时候使用的是封装层次更高的库/框架。
服务器的基本流程:
- 读取请求并解析
- 根据请求计算响应(服务器最重要的环节)
- 把响应写回到客户端
客户端的基本流程:
- 从控制台读取数据 (一种简单的情况)
- 构造请求,发送给服务器
- 从服务器读取响应
- 显示结果到控制台 (一种简单的情况)