一.前言
今天来讲讲网络编程,网络编程是指在计算机网络上进行数据传输和通信的编程技术。它涉及到在不同计算机之间建立连接、发送和接收数据以及处理网络通信的各种操作。网络编程广泛应用于各种领域,包括服务器开发、Web开发、分布式系统、云计算等。
二.软件架构设计
软件架构设计可以根据应用场景的不同分为客户端/服务器(Client/Server,CS)架构和浏览器/服务器(Browser/Server,BS)架构。
这里我就不过多赘述,大家做个了解就好
CS架构是一种常见的软件架构,它将软件系统划分为两个主要部分:客户端和服务器。客户端负责展示用户界面和处理用户输入,而服务器负责处理业务逻辑和存储数据。客户端和服务器之间通过网络进行通信,客户端发送请求给服务器,服务器进行处理并返回结果给客户端。
在CS架构中,客户端和服务器可以运行在不同的物理设备上,通过网络连接进行通信。客户端可以是桌面应用程序、移动应用程序等,而服务器可以是独立的物理服务器或云服务器。
BS架构是一种特殊的CS架构,其中客户端是通过浏览器访问应用程序,而服务器负责提供应用程序的逻辑和数据。在BS架构中,客户端使用浏览器作为用户界面,通过HTTP协议与服务器进行通信。
BS架构的优势在于客户端无需安装额外的软件,只需使用普通的浏览器即可访问应用程序。这使得应用程序的部署和维护更加方便,同时也提供了跨平台和跨设备的能力。
在BS架构中,服务器端主要负责业务逻辑和数据处理,而客户端主要负责展示和用户交互。服务器端可以使用不同的技术栈,如Web服务器、应用服务器、数据库服务器等。
总的来说,cs架构就是需要下载到本地的exe进行访问,例如电脑里的QQ,微信等应用程序,而bs架构则是网站,例如淘宝网,京东网
三. 网络三要素
地址(Address): 地址用于唯一标识网络中的设备或应用程序。在网络通信中,每个设备或应用程序都有一个唯一的地址,使得数据能够准确地发送到目标位置。在Internet中,常用的地址是IP地址(Internet Protocol Address),它是一个由数字和点分隔符组成的标识符。IP地址可以用来标识主机(计算机)或网络设备。此外,还有其他类型的地址,如MAC地址(Media Access Control Address),用于在局域网中唯一标识网络接口。
端口(Port): 端口是在网络通信中用于标识应用程序或服务的数字。每个设备或主机上的应用程序可以使用不同的端口号,以便在同一台设备上同时运行多个应用程序。端口号是一个16位的数字,范围从0到65535。其中,0到1023之间的端口号是一些著名的端口,用于特定的服务或应用程序,如HTTP的端口号是80,HTTPS的端口号是443。端口号的使用确保了数据能够正确地传递给目标应用程序或服务。
协议(Protocol): 协议是在网络通信中规定的一组规则和约定,用于确保数据的正确传输和交换。协议定义了数据的格式、传输方式、错误处理、连接建立和断开等操作。常见的网络协议包括TCP(传输控制协议)、UDP(用户数据报协议)、IP(互联网协议)、HTTP(超文本传输协议)等。协议的使用确保了网络中的设备和应用程序之间可以相互通信和理解。
四.TCP协议和UDP协议
4.1 TCP协议
TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的通信协议,数据在传输前要建立连接,传输完毕后还要断开连接。
客户端在收发数据前要使用 connect() 函数和服务器建立连接。建立连接的目的是保证IP地址、端口、物理链路等正确无误,为数据的传输开辟通道。
TCP建立连接时要传输三个数据包,俗称三次握手(Three-way Handshaking)
可以形象的比喻为下面的对话:
-
客户端:“大哥,你能听到我说话吗”
-
服务端:“可以,小弟,你能听到我说话吗?”
-
客户端:“我也能,OK!”
使用 connect() 建立连接时,客户端和服务器端会相互发送三个数据包,请看下图:
客户端调用 socket() 创建套接字后,因为没有建立连接,所以套接字处于
CLOSED
状态;服务器端调用 listen() 函数后,套接字进入LISTEN
状态,开始监听客户端请求。这个时候,客户端开始发起请求:
当客户端调用 connect() 函数后,TCP协议会组建一个数据包,并设置 SYN 标志位,表示该数据包是用来建立同步连接的。同时生成一个随机数字 1000,填充“序号(Seq)”字段,表示该数据包的序号。完成这些工作,开始向服务器端发送数据包,客户端就进入了
SYN-SEND
状态。服务器端收到数据包,检测到已经设置了 SYN 标志位,就知道这是客户端发来的建立连接的“请求包”。服务器端也会组建一个数据包,并设置 SYN 和 ACK 标志位,SYN 表示该数据包用来建立连接,ACK 用来确认收到了刚才客户端发送的数据包。 服务器生成一个随机数 2000,填充“序号(Seq)”字段。2000 和客户端数据包没有关系。服务器将客户端数据包序号(1000)加1,得到1001,并用这个数字填充“确认号(Ack)”字段。服务器将数据包发出,进入
SYN-RECV
状态。客户端收到数据包,检测到已经设置了 SYN 和 ACK 标志位,就知道这是服务器发来的“确认包”。客户端会检测“确认号(Ack)”字段,看它的值是否为 1000+1,如果是就说明连接建立成功。接下来,客户端会继续组建数据包,并设置 ACK 标志位,表示客户端正确接收了服务器发来的“确认包”。同时,将刚才服务器发来的数据包序号(2000)加1,得到 2001,并用这个数字来填充“确认(Ack)”字段。客户端将数据包发出,进入
ESTABLISED
状态,表示连接已经成功建立。服务器端收到数据包,检测到已经设置了 ACK 标志位,就知道这是客户端发来的“确认包”。服务器会检测“确认号(Ack)”字段,看它的值是否为 2000+1,如果是就说明连接建立成功,服务器进入
ESTABLISED
状态。至此,客户端和服务器都进入了ESTABLISED
状态,连接建立成功,接下来就可以收发数据了。注意:三次握手的关键是要确认对方收到了自己的数据包,这个目标就是通过“确认号(Ack)”字段实现的。计算机会记录下自己发送的数据包序号 Seq,待收到对方的数据包后,检测“确认号(Ack)”字段,看
Ack = Seq + 1
是否成立,如果成立说明对方正确收到了自己的数据包
4.2 UDP协议
TCP 是面向连接的传输协议,建立连接时要经过三次握手,断开连接时要经过四次挥手,中间传输数据时也要回复 ACK 包确认,多种机制保证了数据能够正确到达,不会丢失或出错。
UDP 是非连接的传输协议,没有建立连接和断开连接的过程,它只是简单地把数据丢到网络中,也不需要 ACK 包确认。
UDP 传输数据就好像我们邮寄包裹,邮寄前需要填好寄件人和收件人地址,之后送到快递公司即可,但包裹是否正确送达、是否损坏我们无法得知,也无法保证。UDP 协议也是如此,它只管把数据包发送到网络,然后就不管了,如果数据丢失或损坏,发送端是无法知道的,当然也不会重发。
既然如此,TCP 应该是更加优质的传输协议吧?
如果只考虑可靠性,TCP 的确比 UDP 好。但 UDP 在结构上比 TCP 更加简洁,不会发送 ACK 的应答消息,也不会给数据包分配 Seq 序号,所以 UDP 的传输效率有时会比 TCP 高出很多,编程中实现 UDP 也比 TCP 简单。
UDP 的可靠性虽然比不上TCP,但也不会像想象中那么频繁地发生数据损毁,在更加重视传输效率而非可靠性的情况下,UDP 是一种很好的选择。比如视频通信或音频通信,就非常适合采用 UDP 协议;通信时数据必须高效传输才不会产生“卡顿”现象,用户体验才更加流畅,如果丢失几个数据包,视频画面可能会出现“雪花”,音频可能会夹带一些杂音,这些都是无妨的。
与 UDP 相比,TCP 的生命在于流控制,这保证了数据传输的正确性。
五.Socket
5.1 socket概念
socket 的原意是“插座”,在计算机通信领域,它是计算机之间进行通信的一种约定或一种方式。通过 socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。 我们把插头插到插座上就能从电网获得电力供应,同样,为了与远程计算机进行数据传输,需要连接到因特网,而 socket 就是用来连接到因特网的工具。
socket是在应用层与传输层之间的一个抽象层,它的本质是编程接口,通过socket,才能实现TCP/IP协议。它就是一个底层套件,用来处理最底层消息的接受和发送。
5.2 套接字类型
(1)流格式套接字(SOCK_STREAM)
流格式套接字(Stream Sockets)也叫“面向连接的套接字”,在代码中使用 SOCK_STREAM 表示。SOCK_STREAM 是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。
SOCK_STREAM 有以下几个特征:
数据在传输过程中不会消失;
数据是按照顺序传输的;
数据的发送和接收不是同步的(有的教程也称“不存在数据边界”)。
可以将 SOCK_STREAM 比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。
为什么流格式套接字可以达到高质量的数据传输呢?这是因为它使用了 TCP 协议(The Transmission Control Protocol,传输控制协议),TCP 协议会控制你的数据按照顺序到达并且没有错误。
你也许见过 TCP,是因为你经常听说“TCP/IP”。TCP 用来确保数据的正确性,IP(Internet Protocol,网络协议)用来控制数据如何从源头到达目的地,也就是常说的“路由”。
那么,“数据的发送和接收不同步”该如何理解呢?
假设传送带传送的是水果,接收者需要凑齐 100 个后才能装袋,但是传送带可能把这 100 个水果分批传送,比如第一批传送 20 个,第二批传送 50 个,第三批传送 30 个。接收者不需要和传送带保持同步,只要根据自己的节奏来装袋即可,不用管传送带传送了几批,也不用每到一批就装袋一次,可以等到凑够了 100 个水果再装袋。
流格式套接字的内部有一个缓冲区(也就是字符数组),通过 socket 传输的数据将保存到这个缓冲区。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。
也就是说,不管数据分几次传送过来,接收端只需要根据自己的要求读取,不用非得在数据到达时立即读取。传送端有自己的节奏,接收端也有自己的节奏,它们是不一致的。
(2)数据报套接字(SOCK_DGRAM)
数据报格式套接字(Datagram Sockets)也叫“无连接的套接字”,在代码中使用 SOCK_DGRAM 表示。
计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。
因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。
可以将 SOCK_DGRAM 比喻成高速移动的摩托车快递,它有以下特征:
强调快速传输而非传输顺序;
传输的数据可能丢失也可能损毁;
限制每次传输的数据大小;
数据的发送和接收是同步的(有的教程也称“存在数据边界”)。
众所周知,速度是快递行业的生命。用摩托车发往同一地点的两件包裹无需保证顺序,只要以最快的速度交给客户就行。这种方式存在损坏或丢失的风险,而且包裹大小有一定限制。因此,想要传递大量包裹,就得分配发送。
另外,用两辆摩托车分别发送两件包裹,那么接收者也需要分两次接收,所以“数据的发送和接收是同步的”;换句话说,接收次数应该和发送次数相同。
总之,数据报套接字是一种不可靠的、不按顺序传递的、以追求速度为目的的套接字。
数据报套接字也使用 IP 协议作路由,但是它不使用 TCP 协议,而是使用 UDP 协议(User Datagram Protocol,用户数据报协议)。
QQ 视频聊天和语音聊天就使用 SOCK_DGRAM 来传输数据,因为首先要保证通信的效率,尽量减小延迟,而数据的正确性是次要的,即使丢失很小的一部分数据,视频和音频也可以正常解析,最多出现噪点或杂音,不会对通信质量有实质的影响。
注意:SOCK_DGRAM 没有想象中的糟糕,不会频繁的丢失数据,数据错误只是小概率事件。
5.3 基于套接字的网络编程
socket翻译为套接字,可以把TCP/IP复杂的操作抽象为简单的几个接口来供应用层调用来实现进程在网络中的通信。socket起源于Unix,而Unix的基本要素之一就是“一切都为文件”,即可以通过打开——读写——关闭的模式来操作,通过这一点我们就可以来实现socket的简单编写
接下来和大家写一个简单的实例,非常简单的,bugger不断的那种
1.服务端
import socket
from loguru import logger
# 构建服务端套接字对象
sock=socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM) #这个是选择tcp协议
# 服务端三件套:bind listen accept
sock.bind(("127.0.0.1", 8899))
sock.listen(5)
logger.info("服务器启动")
while 1:
logger.info("等待新连接...")
conn, addr = sock.accept() # 阻塞函数
# print(f"conn:{conn},addr:{addr}")
logger.info(f"来自于客户端{addr}的请求成功")
while 1:
# (3) 收消息
data_bytes = conn.recv(1024) # 阻塞函数
print("data:", data_bytes.decode())
# len(data_bytes) == 是为了处理意外退出的
if data_bytes == "quit".encode() or len(data_bytes) == 0:
logger.info(f"来自于{addr}客户端退出!")
break
# (4) 处理数据并发送给客户端
data = data_bytes.decode()
res = data.upper()
conn.send(res.encode())
2.客户端
import socket
# (1) 构建客户端套接字对象
sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
# (2) 连接服务器
sock.connect(("127.0.0.1", 8899))
while 1:
name = input("请输入转换的姓名(英文):")
# (3) 发消息: 网络传输的数据一定是字节串
sock.send(name.encode())
# # 客户端退出
if name == "quit":
break
# (4) 接受来自于服务的响应消息
res = sock.recv(1024)
print("来自于服务的响应消息:", res.decode())
效果如上图,但是我们有没有想过,如果最大接受超过了1024,那该怎么办,我们可能会想,那我们就把这个改大一点,这个想法很好,但是如果他有十万个字节长度,那我们不得把内存撑爆了,那我们接下来的实例就来解决这个问题,这里给大家举一个模拟ssh实现的案例
六.模拟SSH实现
这里要接受一个模块就是subprocess,这个模块就是通过调用,使用subprocess.getoutput()
来执行命令
6.1 基本实现
服务端
客户端
我们把基本的都搭建起来了,我们执行命令发现都没问题
6.2 传输长字节报错解决
我们这么看一点问题都没有对不对,但是当我们执行Ipconfig,我们发现这个报错了
他说不让我们decode,那我们就不decode,直接打印长度看看
发现这两边长度都不一样,返回1095个字节,但是我们拿到的长度才1024,这就是我们之前说的,超过1024怎么解决,我们前面讲过了,当然我们得进行分批传输啦,那我们是不是可以在服务端先返回一个字节的长度,再返回字节数据,这样就能完整的拿到了
服务端
客户端
运行结果
6.3 粘包问题解决
现在这么看是不是觉得大功告成,但是如果网络连接有波动呢
我们手动给大家模拟延迟一下,发现就报错了,前面和大家介绍了粘包现象
粘包(Packet Congestion)是计算机网络中的一个常见问题,粘包问题通常出现在使用面向连接的传输协议(如TCP)进行数据传输时,这是因为TCP是基于字节流的,它并不了解应用层数据包的具体边界。当发送端迅速发送多个数据包时,底层网络协议栈可能会将这些数据包合并成一个较大的数据块进行发送。同样地,在接收端,网络协议栈也可能将接收到的数据块合并成一个较大的数据块,然后交给应用层处理。
粘包问题可能导致数据处理的困难和不准确性。例如,在一个基于文本的协议中,接收方可能需要将接收到的数据进行分割,以便逐个处理每个完整的消息。如果数据包粘连在一起,接收方就需要额外的处理来确定消息的边界,这增加了复杂性
其实说白了粘包现象就是当网络波动情况下,第一发送的和第二次发送的内容重复在一起了,其原理就是我们就需要借助一个struct模块来解决这个问题
这个例子
发现这个模块可以把一个压缩成一个4位长度的字节,然后解压缩就是原来的数据,那我们是不是就能通过这个模块返回这个压缩的字节,然后第一次接受四位长度,这样不就能解决粘包现象了,吗
服务端
客户端
这样不管怎么延迟都没问题了,这样我们就写出一个完善的代码了,这里给出代码
服务端
import socket
from loguru import logger
import subprocess
import struct
# 构建服务端套接字对象
sock=socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM) #这个是选择tcp协议
# 服务端三件套:bind listen accept
sock.bind(("127.0.0.1", 8898))
sock.listen(5)
logger.info("服务器启动")
while 1:
logger.info("等待新连接...")
conn, addr = sock.accept() # 阻塞函数
logger.info(f"来自于客户端{addr}的请求成功")
while 1:
cmd_bytes = conn.recv(1024) # 阻塞函数
print("cmd_bytes:", cmd_bytes.decode())
if cmd_bytes == "quit".encode() or len(cmd_bytes) == 0:
logger.info(f"来自于{addr}客户端退出!")
break
#处理数据并发送给客户端
data = cmd_bytes.decode()
res = subprocess.getoutput(cmd_bytes.decode())
if not res:
res='执行完毕!'
res_len=struct.pack('i',len(res.encode()))
logger.info(f'返回字符串的长度是:{res_len}')
conn.send(res_len)
conn.send(res.encode())
客户端
import socket
import time
import struct
# (1) 构建客户端套接字对象
sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
# (2) 连接服务器
sock.connect(("127.0.0.1", 8898))
while 1:
name = input("请输入一个ssh命令:")
# (3) 发消息: 网络传输的数据一定是字节串
sock.send(name.encode())
# # 客户端退出
if name == "quit":
break
# (4) 接受来自于服务的响应消息
time.sleep(1)
res_len_byte = sock.recv(4)
toticle_size=struct.unpack('i',res_len_byte)[0]
recive_size=0
alldata=b''
while recive_size<toticle_size:
data=sock.recv(1024)
alldata+=data
recive_size+=len(data)
print(alldata.decode())
print('收到相应的长度是', recive_size)
# print("来自于服务的响应消息:", res.decode())
七.总结
今天我们就讲完这么多了,本来想和大家讲一个文件的上传和下载的,但是发现讲不完根本讲不完,大家可以自己练习试试。当然,今天讲的这些都只能满足一个客户端,因为这个没写并发开多线程,等我和大家讲并发的时候会举例子的。
八.补充
有什么问题私我,记得点赞关注加收藏哦,有求必应