目录
- 2.网络编程套接字
- 2.1 socket api
- 2.2 TCP和UDP之间的区别
- 有连接 vs 无连接
- 可靠传输 vs 不可靠传输
- 面向字节流vs面向数据报
- 全双工 vs 半双工
- 2.3UDP数据报套接字编程
- UDP 回显服务器
- UDP客户端
- 2.4 TCP流套接字编程
- TCP服务器
- TCP客户端
2.网络编程套接字
2.1 socket api
操作系统实际上为我们提供了进行网络编程的API,称为"socket api"
而在操作系统中,提供的socket api不止一套,有很多套
(1)流式套接字 => 给TCP使用的
(2)数据报套接字 => 给UDP使用的
(3)Unix域套接字 => 不能跨主机通信 ,只是本地主机进程与进程之间的通信(现在很少使用了)
2.2 TCP和UDP之间的区别
TCP和UDP都是传输层协议
但是由于二者之间的差异太大了,因此需要两套 API分别来表示
具体的特点就是
TCP是:有连接,可靠传输,面向字节流,全双工
UDP是:无连接,不可靠传输,面向数据报,全双工
有连接 vs 无连接
有连接就好比于 打电话的过程中,拨通电话之后,需要对方接受才能对话
无连接就好比于 发微信,不需要先接通,直接就可以发
可靠传输 vs 不可靠传输
首先要区分 可靠性 != 安全性
安全指的是,你传输的数据是否容易被黑客截取到,是否会造成严重后果
而可靠性,指的是,你传输的数据,尽可能的到达对方
注意:是尽可能,不是完全,只是尽力做到,不能保证100%
在当前网络通信过程中,会出现很多的意外情况,比如丢包就是很常见的情况
就比如A给B传输10个数据包,但是实际上B只是收到9个,这就是丢包
为什么会出现丢包
本质上就是因为当前网络环境太复杂了
A 给 B 传输数据,中间可能会经过很多的路由器和交换机,而这些路由器和交换机不只是转发你的数据,还要转发很多数据
当某个路由器 / 交换机非常繁忙的时候,要处理的数据量,已经超出了当前设备硬件的水平极限,就无法转发,某些数据会被直接丢弃掉
而且,丢包是随机的过程,我们无法预知,啥时候会出现丢包,也无法预知,哪个交换机 / 路由器会出现丢包
为了对抗丢包,就引入了"可靠传输",TCP就实现了可靠传输特点,内部就提供了一系列的机制来实现可靠传输
即使如此,TCP还是只能尽可能,无法保证数据100%到达对端,因为在极端情况下,可能会出现网线断开的情况
相比之下,UDP是不可靠传输,传输数据的时候,压根不关心对方是否收到 那UDP还有什么用??
实际上,可靠传输是有代价的
最典型的就是,效率会大打折扣,即UDP 比 TCP快
面向字节流vs面向数据报
我们之前说过.文件操作就是字节流的,即读写操作非常灵活
TCP和文件操作,具有相同的特点
而面向数据报就不一样了,此时传输数据的单位,是一个个的UDP数据报,一次读写,只能读写一个完整的UDP数据报,不能搞半个,也不能搞一个半
全双工 vs 半双工
全双工指的是,一条链路,能够进行双向通信
半双工指的是,一条链路,只能进行单向通信
而TCP和UDP都是全双工
2.3UDP数据报套接字编程
UDP的socket api主要就是两个类:
(1)DatagramSocket
系统中本来就有socket这个概念,DatagramSocket就是对于操作系统中socket的封装
在系统中,socket可以理解成是一种"文件",socket文件就可以理解成操作"网卡"这种硬件设备的抽象表示形式,而我们针对socket文件的读写操作就相当于是针对网卡这个硬件设备进行读写
其实本质上就类似于我们的普通文件,就是针对硬盘这样的硬件设备进行操作
此处,DatagramSocket就可以是视为操作网卡的"遥控器",针对这个对象进行读写操作,就是在针对网卡进行读写操作
DatagrmeSocket的构造方法:
DatagramStoket的方法
注意:socket也是一种文件,用完了要关闭.否则会占着文件描述符表的一个表项
(2)DatagramPacket
我们可以发现,在上面DatagramSoket的方法参数中,传的就是 DatagramPacket类型
DatagramPacket就是 针对 UDP数据报的一个抽象表示,
也就是说,一个DatagramPacket对象,就相当于一个Udp数据报
一次发送 / 一次接受,就是传输了一个DatagramPacket对象
构造方法:
方法
UDP 回显服务器
public class UdpEchoServer {
//操作网卡
private DatagramSocket socket = null;
}
接下来就要通过构造方法指定服务器的端口号
public class UdpEchoServer {
//操作网卡
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
//指定端口号
this.socket = new DatagramSocket(port);
}
}
这里的端口号是为客户端提供的
客户端是主动的一方,服务器是被动的一方
客户端要知道服务器在哪里,才能进行主动通信
还要确保,一个端口号不能同时被两个或 多个进程来关联,即不能重复
在实际开发,端口号指定是程序员自行指定的,但是你要确保 端口号是 合法的 (1 - 65535)
并且要保证,不能和别的进程使用的端口号重复(实际上重复是个小概率事件,实验一下即可看看是否重复)
接下来还要让 服务 能够不停的处理请求,不停的返回响应
public class UdpEchoServer {
//操作网卡
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
//指定端口号
this.socket = new DatagramSocket(port);
}
public void start () {
System.out.println("服务器启动!!");
while (true) {
//1) 读取请求并解析
//2)根据请求计算响应
//3)把响应写回到客户端
}
}
}
接着就要接受客户端的请求,就要准备一个DatagramPacket来装请求
public class UdpEchoServer {
//操作网卡
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
this.socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
//读取数据并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
}
}
进行receive操作,就是准备一个空盘子,把空盘子交给 它进行装数据
此时这个resultPacket对象就是一个UDP数据报,就包含两部分
一部分是报头(通过类的属性来表示的)
一部分是载荷(通过构造方法传递的字节数组,作为持有载荷的空间,且这个载荷的空间是我们自己new好的字节数组交给他的)
之后我们就可以针对请求,计算出响应
public class UdpEchoServer {
//操作网卡
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
this.socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
//读取数据并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
//为了在java代码中方便处理,可以将数据报里面的二进制数据拿出来,构造成字符串
String request = new String(requestPacket.getData(),0,requestPacket.getLength());//String的构造方法,通过字节数组来构造字符串
//根据请求计算响应
String response = process(request);
}
private String process(String request) {
//当前是回显服务器
return request;
}
}
接下来将响应写回到客户端
public class UdpEchoServer {
//操作网卡
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
this.socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
//读取数据并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
//为了在java代码中方便处理,可以将数据报里面的二进制数据拿出来,构造成字符串
String request = new String(requestPacket.getData(),0,requestPacket.getLength());//String的构造方法,通过字节数组来构造字符串
//根据请求计算响应
String response = process(request);
//把响应写回给客户端
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);
}
}
private String process(String request) {
//当前是回显服务器
return request;
}
}
注意,接受请求的时候,只需要一个空对象即可
但是,发送则是要有数据的对象,不光要有数据,还要指定数据发给谁
但是谁给我发的请求,UDP socket本身没有记录,是在DatagramPacke这个对象里面有记录
这个对象里面就包含了 发送方的IP和端口号,直接拿过来放到packet对象即可
此时我们的服务器逻辑就准备好了
直接启动即可接受到客户端的请求(注意在main方法里面指定服务器的端口号)
public class UdpEchoServer {
//操作网卡
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
this.socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
//读取数据并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
//为了在java代码中方便处理,可以将数据报里面的二进制数据拿出来,构造成字符串
String request = new String(requestPacket.getData(),0,requestPacket.getLength());//String的构造方法,通过字节数组来构造字符串
//根据请求计算响应
String response = process(request);
//把响应写回给客户端
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);
}
}
private String process(String request) {
//当前是回显服务器
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(4090);
server.start();
}
}
写到这里,还有几个细节问题:
(1)如果没有客户端发送请求,服务器的代码就会在receive这里阻塞
这里的阻塞是等待IO,是由系统内核来控制的,直到由客户端发送来请求为止
(2)一般的这种服务器程序都是 这种死循环 ,如果要结束就可以直接强制结束
UDP客户端
public class UdpEchoClient {
//操作网卡
private DatagramSocket socket = null;
public UdpEchoClient() throws SocketException {
socket = new DatagramSocket();
}
}
但是此时我们还要知道 服务器的IP 和 端口号,才能发送请求
客户端是需要通过额外的途径知道 服务器和 IP和端口号是啥的
因此,服务器的Ip和端口号得是固定的,不能老变
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 static void main(String[] agrs) throws SocketException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1",4090);
}
}
此时的Ip"127.0.0.1",指的是 服务器和客户端在一个主机上,就固定写这个Ip地址(指的是环回Ip,系统提供的特殊IP)
如果是 在不同主机上,那么服务器Ip是啥就写啥
实际上客户端也是需要端口号 和 ip的
在一次通信的过程中,至少需要知道4个核心指标
(1)源Ip
(2)源端口
(3)目的端口
(4)目的Ip
对于客户端来说,我们刚刚指定的 服务器ip和 端口号 是 目的ip和端口号
源Ip也就是客户端所在的Ip(在我们这个程序可以视为 是 127.0.0.1)
源端口 ,在构造socket对象的时候,没有指定端口号 ,没指定不代表没有
实际上操作系统自动分配了一个空闲的(不和其他程序冲突的)端口号过来了
这个自动分配的端口号 ,每次重新启动程序都可能不一样
而客户端 和 服务器的代码逻辑实际上是对应的
客户端方面,我们要实现
(1)从控制台读取用户输入
(2)构造请求并发送
(3)从服务器读取响应
(4)把响应打印到控制台上
public void start() throws IOException {
System.out.println("客户端启动!!");
while(true) {
//从控制台读取字符串
Scanner scanner = new Scanner(System.in);
String request = scanner.next();
//构造请求并发送
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),0,request.getBytes().length);
socket.send(requestPacket);
//从服务器读取到响应
//将响应打印到控制台
}
}
public void start() throws IOException {
System.out.println("客户端启动!!");
while(true) {
//从控制台读取字符串
Scanner scanner = new Scanner(System.in);
String request = scanner.next();
//构造请求并发送
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),0,request.getBytes().length);
socket.send(requestPacket);
//从服务器读取到响应
//将响应打印到控制台
}
}
但是客户端要知道,当前这个数据要传给谁,因此在requestPacket里面要带有服务器的Ip和端口号
public void start() throws IOException {
System.out.println("客户端启动!!");
while(true) {
//从控制台读取字符串
Scanner scanner = new Scanner(System.in);
String request = scanner.next();
//构造请求并发送
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),0,request.getBytes().length, InetAddress.getByName(serverIp),serverPort);
socket.send(requestPacket);
//从服务器读取到响应
//将响应打印到控制台
}
}
接下来就是读取响应
public void start() throws IOException {
System.out.println("客户端启动!!");
while(true) {
//从控制台读取字符串
Scanner scanner = new Scanner(System.in);
String request = scanner.next();
//构造请求并发送
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),0,request.getBytes().length, InetAddress.getByName(serverIp),serverPort);
socket.send(requestPacket);
//从服务器读取到响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4090],4090);
socket.receive(responsePacket);
//将响应打印到控制台
String response = new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.println(response);
}
}
}
此时服务器和客户端就能建立连接了
怎么在idea上运行多个客户端呢??
但是此时如果是不在同一个主机上,也是只有在同一个局域网才能进行通信
要想在广域网上使用,必须要有 能够在广域网使用的 “公网Ip”
我们自己要拥有公网Ip,只能通过买"云服务器"
有一个问题就是
似乎在我们上面Udp数据报套接字的例子里面,没有用到close??
实际上无论是在客户端还是服务器
都会有:
而这个对象的生命周期和整个程序的生命周期是一样的,只要程序还在运行着,那么这个socket对象就不能提前释放
而当我们手动去关闭这个程序的进程的时候,意味着进程里面的所有资源都会释放掉,包括持有的内存和文件描述符表,此时也不需要我们额外使用 close去关闭
如果在某些程序里,Socket对象的什么周期与 进程的生命周期不一样,那么就需要调用close
2.4 TCP流套接字编程
在TCP中,socket api也是两个关键的类
(1)ServerSocket
是专门给服务器用的
构造方法:
方法:
(2)Socket
这个类是客户端和服务器都要使用的类
构造方法:
此时调用构造方法本身,就能够和指定的主机建立连接
方法:
实际上ServeSocket 和 Socket 这两个东西起到的作用是截然不同的
TCP服务器
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();//接听
}
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
此时程序一启动就会立马执行到accept这里,如果没有客户端连接过来,那就阻塞等待,直到有客户端真的连接上来了
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();//接听
processConnect(clientSocket);
}
}
private void processConnect(Socket clientSocket) {
//通过这一个方法来处理一个连接
System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress(),clientSocket.getPort());
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
接下来我们要从socket中获取到流对象,进一步进行操作
private void processConnect(Socket clientSocket) {
//通过这一个方法来处理一个连接
System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress(),clientSocket.getPort());
//从socket获取到流对象,进一步进行操作
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
while(true) {
//针对某一个客户端,处理多次请求
}
}catch (IOException e){
e.printStackTrace();
}
}
此时读取客户端请求的方法是:
String request = scanner.next();
此时如果我们按照inputStream.read的方法来读,那么读到的数据还要进行转化
但是如果直接用Scanner来读,直接读出来的就是String(Scanner已经帮我们进行了上述的转化)
而对于next()方法,就会读取带空白符才算 读取完毕
空白符是一类字符的统称,包括但是不限于换行,回车,制表符,翻页符…
因此为了服务器更精确的处理一次请求,客户端那边子传输数据的时候,就要每一个请求的末尾添上空白符,比如填写 \n
这是我们进行的一个"约定"
由于TCP是按照字节来传输的,而实际上,我们更希望的是,若干个字节能够构成一个"应用层数据报"
如何区分多个应用层数据报,就是通过"分隔符"的方式;来约定
上述我们就是在约定说,使用空白符作为一次请求的一个结束标记
这个方式是可以随心所欲的,只要是客户端和服务器一致的行为即可
如果发现后续没有数据了,就说明客户端断开了
private void processConnect(Socket clientSocket) throws IOException {
//通过这一个方法来处理一个连接
System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress(),clientSocket.getPort());
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
while (true) {
//针对一个客户端,处理多次请求
//(1)读取请求并解析
if(!scanner.hasNext()) {
System.out.printf("[%s:%d] 客户端下线\n",clientSocket.getInetAddress(),clientSocket.getPort());
break;
}
String request = scanner.next();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
而这里的Scanner是带有阻塞功能的,就会阻塞等待请求的到来
这里的等待请求到来 和 客户端断开 是可以明确区分开来的.是不一样的
Scanner可以认为是关联到服务器这边的socket文件的
而socket是可以感知到tcp连接断开的(本身是系统内核的一系列操作,操作系统知道tcp连接断开后,就会通知socket)
此时对应的Scanner读取到的文件,就下单于是"无效文件",类似于读取到"EOF"这样的效果
private void processConnect(Socket clientSocket) {
//通过这一个方法来处理一个连接
System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress(),clientSocket.getPort());
//从socket获取到流对象,进一步进行操作
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
Scanner scanner = new Scanner(inputStream);
while(true) {
//针对某一个客户端,处理多次请求
//(1)读取请求并解析
if(!scanner.hasNext()) {
System.out.printf("[%s:%d] 客户端下线\n",clientSocket.getInetAddress(),clientSocket.getPort());
}
String request = scanner.next();
//(2)根据请求计算相应
String response = process(request);
//(3)把响应写回给客户端
outputStream.write(response.getBytes());
}
}catch (IOException e){
e.printStackTrace();
}
}
private String process(String request) {
//回显服务器
//服务器这边也是要加上一个换行符,以便于 客户端在读取响应的时候,有明确的分割符
return request + "\n";
}
那么此时服务器这边就写好了
但是此时上述代码还存在很严重的bug
1.服务器这边创建的Socket是没有进行关闭操作的
这个对象在processConnect使用之后,没有进行close,是不科学的
与上面的ServerSocket对象不一样的是,ServerSocket对象的生命周期是伴随着整个服务器的进程的
但是clientSocket就不行了,服务器会对应多个客户端,每个客户端连接之后都会对应一个clientSocket
如果用完了不关闭,就会使得当前clientSocket的文件描述符得不到释放,引起了文件资源的泄露
我们在processConnect使用完毕后,执行关闭逻辑即可
private void processConnect(Socket clientSocket) throws IOException {
//通过这一个方法来处理一个连接
System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress(),clientSocket.getPort());
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
while (true) {
//针对一个客户端,处理多次请求
//(1)读取请求并解析
if(!scanner.hasNext()) {
System.out.printf("[%s:%d] 客户端下线\n",clientSocket.getInetAddress(),clientSocket.getPort());
break;
}
String request = scanner.next();
//(2)根据请求计算响应
String response = process(request);
outputStream.write(response.getBytes());
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
clientSocket.close();
}
}
2,此时的服务器是无法同时给多个客户端提供服务的
本质上就是,第一个客户端连接上服务器后,此时accept方法就返回了,但是此时代码进入到processConnect方法中就循环起来了,此时如果第二个客户端想要连接上服务器,服务器的accept方法是执行不到的
我们可以引入多线程来处理多个客户端
或者我们直接使用线程池
但是实际上上述方法存在一个严重的问题:
在以前 ,互联网规模不大的时候,一个服务器在同一个时刻也处理不了几个客户端,那么上述直接使用多线程的方法就无所谓
但是随着时代的发展,现如今一个服务同一时刻要处理的客户端就有很多了
一个服务器在同时创建出几百个线程压力就已经很大了
更何况现如今可能要同时搞上万个线程
这里真正的核心解方案是 IO多路复用 + 多个服务器(分布式系统)
所谓IO多路复用就是指,使用一个线程就管理多个socket,而这些socket往往不是 同时有数据要处理,而是同一时刻,只有少数数据需要读取数据
而IO多路复用是系统内核已经处理好了,直接提供API给我们调用,在java中提供了NIO这样的一组类,对上述操作进行了进一步的封装
在实际开发中又有一些封装层次更高的框架处理上述逻辑,这次成熟的框架背后也是直接使用的NIO,因此我们通常不会直接使用NIO
TCP客户端
对于客户端那边,原理也是类似
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIp,int serverPort) throws IOException {
//一旦调用这样的构造方法,就会和服务器之间建立联系
//具体的连接步骤都是系统内核完成的
this.socket = new Socket(serverIp,serverPort);
}
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) {
System.out.print("输入你要发送的数据: ");
String request = scanner.next() + "\n";
//把请求发送给服务器
outputStream.write(request.getBytes());
//从服务器得到响应
if(!scannerNetwork.hasNext()) {
break;
}
String response = scannerNetwork.next();
//将响应输出到控制台
System.out.println(response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",3090);
tcpEchoClient.start();
}
}
此时就可以进行交互了