全栈开发八 socket网络编程

时间:2022-05-05 23:56:30

一、客户端(client)服务端(sever)架构

  在计算机中有很多常见的C/S架构,例如我们的浏览器是客户端、而百度网站和其他的网站就是服务端;视频软件是客户端,提供视频的腾讯、优酷、爱奇艺就是服务端。

C/S与socket的关系:

  学习socket就是为了开发C/S架构。

二、OSI七层

  C/S架构的软件(软件属于应用层)是基于网络进行通信的,网络的核心即一堆协议,协议即标准,你想开发一款基于网络通信的软件,就必须遵循这些标准。所以在学习socket之前,先了解一下OSI七层了解基本的网络协议,方便学习socket。

全栈开发八 socket网络编程

在上面的OSI中好像并没有与socket有关的信息,那么请看下面这图:

全栈开发八 socket网络编程

三、socket是什么

  从上面这个图中可以看出,socket就是网络层和运输层的抽象结合。Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。所以,我们无需深入理解tcp/udp协议,socket已经为我们封装好了,我们只需要遵循socket的规定去编程,写出的程序自然就是遵循tcp/udp标准的。

  当然还有另一种解释:网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个socket。建立网络通信连接至少要一对端口号(socket)。socket本质是编程接口(API),对TCP/IP的封装,TCP/IP也要提供可供程序员做网络开发所用的接口,这就是Socket编程接口;HTTP是轿车,提供了封装或者显示数据的具体形式;Socket是发动机,提供了网络通信的能力。Socket的英文原义是"孔"或"插座"。

  作为BSD UNIX的进场通信机制,取后一种意思。通常也称作"套接字",用于描述IP地址和端口,是一个通信链的句柄,可以用来实现不同虚拟机或不同计算机之间的通信。在Internet上的主机一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务。Socket正如其英文原意那样,像一个多孔插座。一台主机犹如布满各种插座的房间,每个插座有一个编号,有的插座提供220伏交流电, 有的提供110伏交流电,有的则提供有线电视节目。 客户软件将插头插到不同编号的插座,就可以得到不同的服务。

四 套接字发展史及分类

套接字起源于 20 世纪 70 年代加利福尼亚大学伯克利分校版本的 Unix,即人们所说的 BSD Unix。 因此,有时人们也把套接字称为“伯克利套接字”或“BSD 套接字”。一开始,套接字被设计用在同 一台主机上多个应用程序之间的通讯。这也被称进程间通讯,或 IPC。套接字有两种(或者称为有两个种族),分别是基于文件型的和基于网络型的。 

基于文件类型的套接字家族

套接字家族的名字:AF_UNIX

unix一切皆文件,基于文件的套接字调用的就是底层的文件系统来取数据,两个套接字进程运行在同一机器,可以通过访问同一个文件系统间接完成通信

基于网络类型的套接字家族

套接字家族的名字:AF_INET

(还有AF_INET6被用于ipv6,还有一些其他的地址家族,不过,他们要么是只用于某个平台,要么就是已经被废弃,或者是很少被使用,或者是根本没有实现,所有地址家族中,AF_INET是使用最广泛的一个,python支持很多种地址家族,但是由于我们只关心网络编程,所以大部分时候我么只使用AF_INET)

五、套接字工作流程

根据连接启动的方式以及本地套接字要连接的目标,套接字之间的连接过程可以分为三个步骤:服务器监听,客户端请求,连接确认。

(1)服务器监听:是服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态。

(2)客户端请求:是指由客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。

(3)连接确认:是指当服务器端套接字监听到或者说接收到客户端套接字的连接请求,它就响应客户端套接字的请求,建立一个新的线程,把服务端套接字的描述发给客户端,一旦客户端确认了此描述,连接就建立好了。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

生活中的打电话就是一个简单的套接字工作流程:

全栈开发八 socket网络编程

五、常见的套接字函数:

服务端套接字函数
s.bind() 绑定(主机,端口号)到套接字
s.listen() 开始TCP监听
s.accept() 被动接受TCP客户的连接,(阻塞式)等待连接的到来

客户端套接字函数
s.connect() 主动初始化TCP服务器连接
s.connect_ex() connect()函数的扩展版本,出错时返回出错码,而不是抛出异常

公共用途的套接字函数
s.recv() 接收TCP数据
s.send() 发送TCP数据(send在待发送数据量大于己端缓存区剩余空间时,数据丢失,不会发完)
s.sendall() 发送完整的TCP数据(本质就是循环调用send,sendall在待发送数据量大于己端缓存区剩余空间时,数据不丢失,循环调用send直到发完)
s.recvfrom() 接收UDP数据
s.sendto() 发送UDP数据
s.getpeername() 连接到当前套接字的远端的地址
s.getsockname() 当前套接字的地址
s.getsockopt() 返回指定套接字的参数
s.setsockopt() 设置指定套接字的参数
s.close() 关闭套接字

面向锁的套接字方法
s.setblocking() 设置套接字的阻塞与非阻塞模式
s.settimeout() 设置阻塞套接字操作的超时时间
s.gettimeout() 得到阻塞套接字操作的超时时间

面向文件的套接字的函数
s.fileno() 套接字的文件描述符
s.makefile() 创建一个与该套接字相关的文件

六、基于TCP的套接字

  在实现TCP的套接字之前,小编带大家了解一下基于TCP的三次握手,四次挥手。

全栈开发八 socket网络编程

TCP是面向连接的,无论哪一方向另一方发送数据之前,都必须先在双方之间建立一条连接。在TCP/IP协议中,TCP 协议提供可靠的连接服务,连接是通过三次握手进行初始化的。三次握手的目的是同步连接双方的序列号和确认号 并交换 TCP窗口大小信息。

1.第一次握手:建立连接。

客户端发送连接请求报文段,将SYN位置为1,Sequence Number为x;然后,客户端进入SYN_SEND状态,等待服务器的确认;

2.第二次握手:服务器收到SYN报文段。

服务器收到客户端的SYN报文段,需要对这个SYN报文段进行确认,设置Acknowledgment Number为x+1(Sequence Number+1);同时,自己自己还要发送SYN请求信息,将SYN位置为1,Sequence Number为y;服务器端将上述所有信息放到一个报文段(即SYN+ACK报文段)中,一并发送给客户端,此时服务器进入SYN_RECV状态;

3.第三次握手:客户端收到服务器的SYN+ACK报文段。

然后将Acknowledgment Number设置为y+1,向服务器发送ACK报文段,这个报文段发送完毕以后,客户端和服务器端都进入ESTABLISHED状态,完成TCP三次握手。

完成了三次握手,客户端和服务器端就可以开始传送数据。以上就是TCP三次握手的总体介绍。

那四次挥手呢?

当客户端和服务器通过三次握手建立了TCP连接以后,当数据传送完毕,肯定是要断开TCP连接的啊。那对于TCP的断开连接,这里就有了神秘的“四次挥手”。

1.第一次挥手:主机1(可以使客户端,也可以是服务器端),设置Sequence Number和Acknowledgment Number,向主机2发送一个FIN报文段;此时,主机1进入FIN_WAIT_1状态;这表示主机1没有数据要发送给主机2了;

2.第二次挥手:主机2收到了主机1发送的FIN报文段,向主机1回一个ACK报文段,Acknowledgment Number为Sequence Number加1;主机1进入FIN_WAIT_2状态;主机2告诉主机1,我也没有数据要发送了,可以进行关闭连接了;

3.第三次挥手:主机2向主机1发送FIN报文段,请求关闭连接,同时主机2进入CLOSE_WAIT状态;

4.第四次挥手:主机1收到主机2发送的FIN报文段,向主机2发送ACK报文段,然后主机1进入TIME_WAIT状态;主机2收到主机1的ACK报文段以后,就关闭连接;此时,主机1等待2MSL后依然没有收到回复,则证明Server端已正常关闭,那好,主机1也可以关闭连接了。

至此,TCP的四次挥手就这么愉快的完成了。当你看到这里,你的脑子里会有很多的疑问,很多的不懂,感觉很凌乱;没事,我们继续总结。

 

为什么要三次握手?

既然总结了TCP的三次握手,那为什么非要三次呢?怎么觉得两次就可以完成了。那TCP为什么非要进行三次连接呢?在谢希仁的《计算机网络》中是这样说的:

为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误。

在书中同时举了一个例子,如下:

"已失效的连接请求报文段”的产生在这样一种情况下:client发出的第一个连接请求报文段并没有丢失,

而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达server。本来这是一

个早已失效的报文段。但server收到此失效的连接请求报文段后,就误认为是client再次发出的一个新

的连接请求。于是就向client发出确认报文段,同意建立连接。假设不采用“三次握手”,那么只要server

发出确认,新的连接就建立了。由于现在client并没有发出建立连接的请求,因此不会理睬server的确认,

也不会向server发送数据。但server却以为新的运输连接已经建立,并一直等待client发来数据。这样,

server的很多资源就白白浪费掉了。采用“三次握手”的办法可以防止上述现象发生。例如刚才那种情况,

client不会向server的确认发出确认。server由于收不到确认,就知道client并没有要求建立连接。"

这就很明白了,防止了服务器端的一直等待而浪费资源。

为什么要四次挥手?

那四次挥手又是为何呢?TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议。TCP是全双工 模式,这就意味着,当主机1发出FIN报文段时,只是表示主机1已经没有数据要发送了,主机1告诉主机2, 它的数据已经全部发送完毕了;但是,这个时候主机1还是可以接受来自主机2的数据;当主机2返回ACK报文 段时,表示它已经知道主机1没有数据发送了,但是主机2还是可以发送数据到主机1的;当主机2也发送了FIN 报文段时,这个时候就表示主机2也没有数据要发送了,就会告诉主机1,我也没有数据要发送了,之后彼此 就会愉快的中断这次TCP连接。如果要正确的理解四次挥手的原理,就需要了解四次挥手过程中的状态变化。

FIN_WAIT_1: 这个状态要好好解释一下,其实FIN_WAIT_1和FIN_WAIT_2状态的真正含义都是表示等 待对方的FIN报文。而这两种状态的区别是:FIN_WAIT_1状态实际上是当SOCKET在ESTABLISHED状态时, 它想主动关闭连接,向对方发送了FIN报文,此时该SOCKET即进入到FIN_WAIT_1状态。而当对方回应ACK报 文后,则进入到FIN_WAIT_2状态,当然在实际的正常情况下,无论对方何种情况下,都应该马上回应ACK 报文,所以FIN_WAIT_1状态一般是比较难见到的,而FIN_WAIT_2状态还有时常常可以用netstat看到。 (主动方)

FIN_WAIT_2:上面已经详细解释了这种状态,实际上FIN_WAIT_2状态下的SOCKET,表示半连接,也即 有一方要求close连接,但另外还告诉对方,我暂时还有点数据需要传送给你(ACK信息),稍后再关闭连接。 (主动方)

CLOSE_WAIT:这种状态的含义其实是表示在等待关闭。怎么理解呢?当对方close一个SOCKET后发送FIN 报文给自己,你系统毫无疑问地会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态。接下来呢,实 际上你真正需要考虑的事情是察看你是否还有数据发送给对方,如果没有的话,那么你也就可以 close这个 SOCKET,发送FIN报文给对方,也即关闭连接。所以你在CLOSE_WAIT状态下,需要完成的事情是等待你去关 闭连接。(被动方)

LAST_ACK: 这个状态还是比较容易好理解的,它是被动关闭一方在发送FIN报文后,最后等待对方的ACK报 文。当收到ACK报文后,也即可以进入到CLOSED可用状态了。(被动方)

TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,就等2MSL后即可回到CLOSED可用状态了。 如果FINWAIT1状态下,收到了对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无 须经过FIN_WAIT_2状态。(主动方)

CLOSED: 表示连接中断。

七、TCP套接字实践

服务端:

 1 from socket import *
 2 
 3 #对数据进行独立化方便更改
 4 IP_PORT = ("127.0.0.1",8080)
 5 BACK_LOG = 5
 6 BUFFER_SIZE = 1024
 7 
 8 #建立TCP连接
 9 sever = socket(AF_INET,SOCK_STREAM)    #生成套接字对象
10 sever.bind(IP_PORT)                    #绑定端口
11 sever.listen(BACK_LOG)                 #开始监听(BACK_LOG:代表的是允许监听的数量)
12 conn,addr = sever.accept()
13 
14 #连接成功后的数据传输
15 data = conn.recv(BUFFER_SIZE)
16 de_data = data.decode("utf-8")
17 conn.send(de_data.upper().encode("utf-8"))
18 
19 #测试数据发送成功
20 print("send success")
21 
22 #关闭连接
23 conn.close()
24 sever.close()

客户端:

 1 from socket import *
 2 
 3 IP_PORT = ("127.0.0.1",8080)
 4 BUFFER_SIZE = 1024
 5 
 6 #建立连接
 7 client = socket(AF_INET,SOCK_STREAM)   #生成套接字对象
 8 client.connect(IP_PORT)                #连接到对应的端口
 9 
10 #数据传输
11 data = input(">>:")
12 client.send(data.encode("utf-8"))
13 sever_data= client.recv(BUFFER_SIZE)
14 print("success recv:" ,sever_data.decode("utf-8"))

上面的代码就实现了一个简单的基于TCP的套接字服务,但是这仅仅进行了一次简单的通信,这与电话通信的你一句我一句有较大的差异。接下来是socket的进阶版:

socket_sever:

 1 from socket import *
 2 
 3 #对数据进行独立化方便更改
 4 IP_PORT = ("127.0.0.1",8080)
 5 BACK_LOG = 5
 6 BUFFER_SIZE = 1024
 7 
 8 sever = socket(AF_INET,SOCK_STREAM)
 9 sever.bind(IP_PORT)
10 sever.listen(BACK_LOG)
11 conn,addr = sever.accept()
12 
13 try:
14     while True:
15         data = conn.recv(BUFFER_SIZE)
16         print("收到数据:",data.decode("utf-8"))
17         deal_data = data.decode("utf-8").upper()
18         conn.send(deal_data.encode("utf-8"))
19         print("发送数据:",deal_data)
20 except ConnectionResetError as e:
21     print(e)
22 
23 sever.close()
24     

socket_client:

 1 from socket import *
 2 
 3 #对数据进行独立化方便更改
 4 IP_PORT = ("127.0.0.1",8080)
 5 BUFFER_SIZE = 1024
 6 
 7 client = socket(AF_INET,SOCK_STREAM)
 8 client.connect(IP_PORT)
 9 
10 while True:
11     data = input(">>:")
12     if not data:continue
13     client.send(data.encode("utf-8"))
14     rv_data = client.recv(BUFFER_SIZE)
15     print(rv_data.decode("utf-8"))
16         
17 client.close()

上述的C/S实现了一个简单的可以无限收发的简单功能,但是此时的socket_sever仅实现了一对一的连接,我们打点电话的时候还可能有更多的连接进来,而上述的功能在第一 连接中断的时候,第二个服务端也会被中断,所以我们对上述功能再次进行了加工:

tcp_socket_sever:

 1 from socket import *
 2 
 3 #对数据进行独立化方便更改
 4 IP_PORT = ("127.0.0.1",8080)
 5 BACK_LOG = 5
 6 BUFFER_SIZE = 1024
 7 
 8 sever = socket(AF_INET,SOCK_STREAM)
 9 sever.bind(IP_PORT)
10 sever.listen(BACK_LOG)
11 
12 while True:       #这个循环是为了可以接收多个循环,但是每次只能有一个循环进入连接
13     conn, addr = sever.accept()
14     while True:   #这个循环是为了可以使客户端和服务端循环无限次
15         try:      #这是为了解决一个客户端不正常断开的时候而抛出的异常
16             data = conn.recv(BUFFER_SIZE)
17             print("收到数据:",data.decode("utf-8"))
18             deal_data = data.decode("utf-8").upper()
19             conn.send(deal_data.encode("utf-8"))
20             print("已发送数据:",deal_data)
21         except ConnectionResetError as e:
22             print(e)
23             break

tcp_socket_client:

 1 from socket import *
 2 
 3 IP_PORT = ("127.0.0.1",8080)
 4 BUFFER_SIZE = 1024
 5 
 6 client = socket(AF_INET,SOCK_STREAM)
 7 client.connect(IP_PORT)
 8 
 9 while True:
10     data = input(">>:")
11     if not data:continue
12     client.send(data.encode("utf-8"))
13     print("已发送:",data)
14     re_data = client.recv(BUFFER_SIZE)
15     print("已接收:",re_data.decode("utf-8"))

接下来运用socket写一个简单的C/S,作用是可以远程的允许DOS命令,并且返回一些信息,但是无法做更改删除等,仅仅查询功能:

全栈开发八 socket网络编程全栈开发八 socket网络编程
 1 from socket import *
 2 import subprocess
 3 import struct
 4 IP_PORT = ("localhost",8080)
 5 BACK_LOG = 5
 6 BUFFER_SIZE = 1024
 7 
 8 #建立连接
 9 sever = socket(AF_INET,SOCK_STREAM)
10 sever.bind(IP_PORT)
11 sever.listen(BACK_LOG)
12 
13 while True:
14     conn,addr = sever.accept()
15     print("我准备接收链接了:")
16     while True:
17         try:
18             cmd = conn.recv(BUFFER_SIZE)
19             print("接收到指令:",cmd.decode("utf-8"))
20 
21             #对接收到的数据进行处理
22             if not cmd:break       #这里的判断解决客户端正常断开时退出连接
23             res = subprocess.Popen(
24                 cmd.decode("utf-8"),shell=True,
25                 stderr=subprocess.PIPE,
26                 stdin=subprocess.PIPE,
27                 stdout=subprocess.PIPE
28             )
29             '''
30             subprocess模块:shell=True指的是允许将输出在shell的内容输入到管道
31             stderr,stdin,stdout:这些都是将输入流,输出流接入管道
32             '''
33             err = res.stderr.read()
34             if err:
35                 cmd_res = err
36             else:
37                 cmd_res = res.stdout.read()
38 
39             #
40             if not cmd_res:
41                 cmd_res = "执行成功".encode("gbk")
42 
43             conn.send(cmd_res)
44         except Exception as e:
45             print(e)
46             break
cmd_socket_sever
全栈开发八 socket网络编程全栈开发八 socket网络编程
 1 from socket import *
 2 
 3 IP_PORT = ("localhost",8080)
 4 BUFFER_SIZE = 1024
 5 
 6 client = socket(AF_INET,SOCK_STREAM)
 7 client.connect(IP_PORT)
 8 
 9 while True:
10     cmd = input(">>:")
11     print("")
12     if not cmd:continue
13     if cmd == "quit":break
14     client.send(cmd.encode("utf-8"))
15     print("已经发送指令:",cmd)
16     data = client.recv(BUFFER_SIZE)
17     print(data.decode("gbk"))
18 
19 client.close()
cmd_socket_client

上述的简易的C/S服务还有一个重要的问题没有解决,那就是粘包。

八、粘包问题

在前面的C/S服务我们输入ipconfig发送到服务端,接收返回到的消息时,会发现接收到的消息不全,再次输入其他命令,接收到的消息还是原来ipconfig输出的信息,这就的典型的一种粘包现象。

   全栈开发八 socket网络编程

全栈开发八 socket网络编程

 

这是为什么呢,为什么我一个收,一个发为什么会出现粘包现象,这就要从底层说起。

全栈开发八 socket网络编程

 

 根据上图我们模拟一下简单的TCP套接字工作原理:

第一:启动服务端和客户端

第二:客户端的将指令和请求等数据信息读取到用户态内存,然后内核态将用户态的数据拷贝

第三:然后通过网卡等硬件层发送到服务端的内核态内存

第四:服务端的用户态再从内核态拷贝然后对拷贝过来的数据进行处理

第五:再按照刚才来的路线进行返回。

从上面的流程结合TCP套接字的实例可以得出以下结论:

  • 基于TCP的C/S服务并不是由用户到用户这种直接发送的模式,而是中间隔着一个内核态。
  • 客户端不管发多少次,都要通过客户端的内核态,这就使得每个客户端的send服务端无需与之对应一个recv,因为是内核态之间的交互,完全可以一个recv对应多个send
  • 由于一个recv可以对应多个send这就会引起第一种粘包状态天,例如像服务端发送两次数据时,两个数据黏在一起。
  • 用户态和内核态的内存都有一定的大小,所以当我们一次性传输过大的数据时,内核态的容量不够,无法一次性传输完毕,只能进行多次传输。这就产生了粘包的第二种状态。

粘包解决方案一:

全栈开发八 socket网络编程全栈开发八 socket网络编程
 1 from socket import *
 2 import subprocess
 3 import struct
 4 IP_PORT = ("localhost",8080)
 5 BACK_LOG = 5
 6 BUFFER_SIZE = 1024
 7 
 8 sever = socket(AF_INET,SOCK_STREAM)
 9 sever.bind(IP_PORT)
10 sever.listen(BACK_LOG)
11 
12 while True:
13     conn, addr = sever.accept()
14     print("接收到连接:", addr)
15     while True:
16         try:
17             print("开始接收")
18             cmd = conn.recv(BUFFER_SIZE)
19             print("接收到指令:",cmd.decode("utf-8"))
20             if not cmd:break
21             res = subprocess.Popen(
22                 cmd.decode("utf-8"),shell=True,
23                 stdout=subprocess.PIPE,
24                 stdin=subprocess.PIPE,
25                 stderr=subprocess.PIPE
26             )
27 
28             res_err = res.stderr.read()
29             if res_err:
30                 res_cmd=res_err
31             else:
32                 res_cmd = res.stdout.read()
33 
34             data_len = len(res_cmd)
35             print(data_len)
36             conn.send(str(data_len).encode("gbk"))  #发送长度
37             data2 = conn.recv(BUFFER_SIZE).decode("gbk")  #为了避免长度与数据的粘包,先接收一个确认数据
38                 #开始发送数据
39             if data2 == "recv_ready":
40                 conn.sendall(res_cmd)  #这段代码会循环发送,直到数据被发送完毕
41             print("发送完毕")
42         except Exception as e:
43             print(e)
44             break
socket_sever_粘包
全栈开发八 socket网络编程全栈开发八 socket网络编程
 1 from socket import *
 2 
 3 IP_PORT = ("localhost",8080)
 4 BUFFER_SIZE = 1024
 5 
 6 client = socket(AF_INET,SOCK_STREAM)
 7 client.connect(IP_PORT)
 8 
 9 '''
10 解决粘包的方法一就是服务端在发送数据的时候,先发一个数据长度,然后让客户端循环收,直到收完为止
11 '''
12 
13 while True:
14     cmd = input(">>:")
15     if not cmd:continue
16     if cmd == "quit":break
17 
18     client.send(cmd.encode("utf-8")) #发送命令
19     cmd_data_lenth = int(client.recv(BUFFER_SIZE).decode("gbk"))  #接收长度
20     print(cmd_data_lenth)
21     client.send("recv_ready".encode("utf-8"))
22     cmd_data = ""
23     data_lenth= 0
24     while data_lenth < cmd_data_lenth:
25         cmd_data += client.recv(BUFFER_SIZE).decode("gbk")
26         print(cmd_data)
27         data_lenth += len(cmd_data)
28     print("数据接收完毕",cmd_data)
29 
30 client.close()
socket_client_粘包

该方案程序的运行速度远快于网络传输速度,所以在发送一段字节前,先用send去发送该字节流长度,这种方式会放大网络延迟带来的性能损耗

粘包解决方案二: