编程语言
首页 > 编程语言> > day21网络编程(下)

day21网络编程(下)

作者:互联网

day21 网络编程(下)

课程目标:学会网络编程开发的必备知识点。

今日概要:

1. OSI 7层模型

请添加图片描述

请添加图片描述

OSI的7层模型对于大家来说可能不太好理解,所以我们通过一个案例来讲解:
请添加图片描述

假设,你在浏览器上输入了一些关键字,内部通过DNS找到对应的IP后,再发送数据时内部会做如下的事:

请添加图片描述

每一层各司其职,最终保证数据呈现在到用户手中。

简单的可以理解为发快递:将数据外面套了7个箱子,最终用户收到箱子时需要打开7个箱子才能拿到数据。而在运输的过程中有些箱子是会被拆开并替换的,例如:

最终运送目标:上海 ~ 北京(中途可能需要中转站),在中转站会会打开箱子查看信息,在进行转发。
	- 对于二级中转站(二层交换机):拆开数据链路层的箱子,查看mac地址信息。
	- 对于三级中转站(路由器或三层交换机):拆开网络层的箱子,查看IP信息。

在开发过程中其实只能体现:应用层、表示层、会话层、传输层,其他层的处理都是在网络设备中自动完成的。

import socket

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('110.242.68.3', 80)) # 向服务端发送了数据包


key = "你好"
# 应用层
content = "GET /s?wd={} http1.1\r\nHost:www.baidu.com\r\n\r\n".format(key)
# 表示层
content = content.encode("utf-8")

client.sendall(content)
result = client.recv(8196)
print(result.decode('utf-8'))

# 会话层 & 传输层
client.close()

2. UDP和TCP协议

协议,其实就是规定 连接、收发数据的一些规定。

在OSI的 传输层 除了定义端口信息以外,常见的还可以指定UDP或TCP的协议,协议不同连接和传输数据的细节也会不同。

2.1 UDP和TCP 示例代码

UDP示例如下:

UDP是不需要建立连接的。

TCP示例如下:

2.2 TCP三次握手和四次挥手

请添加图片描述

ACK:用于对收到的数据进行确认,所确认的数据由确认序列号表示。
SYN:用于建立连接时的同步信号。
FIN:表示后面没有数据需要发送,通常意味着所建立的连接需要关闭了。


第一次:
建立连接时,客户端发生syn包(seq=j) 到服务器,并进入syn_sent(半连接,同步已发送)状态,等待服务器确认;syn:同步序列编号。

第二次:
服务器收到syn包,必须确认客户端的syn(ack=j+1),同时自己也发生一个syn包(seq=k),即syn+ack包,此时服务器进入syn_recv状态。(syn_recv是指服务端被动打开后,接收到了客户端的syn并且发送了ack的状态。)

第三次:
客户端收到服务器的syn+ack包,向服务器发生确认包ack(ack=k+1),此包发生完毕,客户端和服务器进入ESTABLISHED(TCP连接成功)状态,完成三次握手。ESTABLISHED(正式成立)

这是一个常见的面试题。

什么是tcp三次握手?
为什么连接的时候是三次握手,断开的时候却是四次握手?

为什么要三次握手?
主要是为了信息对等和防止请求超时导致脏连接。
    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Source Port          |       Destination Port        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                        Sequence Number                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Acknowledgment Number                      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Data |           |U|A|P|R|S|F|                               |
   | Offset| Reserved  |R|C|S|S|Y|I|            Window             |
   |       |           |G|K|H|T|N|N|                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Checksum            |         Urgent Pointer        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                             data                              |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

网络中的双方想要基于TCP连接进行通信,必须要经过:

3. 粘包

请添加图片描述

请添加图片描述

两台电脑在进行收发数据时,其实不是直接将数据传输给对方。

所以,如果发送者连续快速的发送了2条信息,接收者在读取时会认为这是1条信息,即:2个数据包粘在了一起。例如:

# socket客户端(发送者)
import socket

client = socket.socket()
client.connect(('127.0.0.1', 8001))
# 也可以使用send,不推荐。当缓冲区位置不够时,只能把一部分数据写到缓冲区,而我们无法直接感知,还的通过返回值来得知。
client.sendall('Emma正在玩'.encode('utf-8'))
client.sendall('游戏'.encode('utf-8'))

client.close()


# socket服务端(接收者)
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.1', 8001))
sock.listen(5)
conn, addr = sock.accept()

client_data = conn.recv(1024)
print(client_data.decode('utf-8'))

conn.close()
sock.close()

# 输出:Emma正在玩游戏。  【实际发出的是两条信息,但是接受者收到的确是一条。】

如何解决粘包的问题?

每次发送的消息时,都将消息划分为 头部(固定字节长度) 和 数据 两部分。例如:头部,用4个字节表示后面数据的长度。

  • 发送数据,先发送数据的长度,再发送数据(或拼接起来再发送)。
  • 接收数据,先读4个字节就可以知道自己这个数据包中的数据长度,再根据长度读取到数据。

对于头部需要一个数字并固定为4个字节,这个功能可以借助python的struct包来实现:

import struct

# ########### 数值转换为固定4个字节,四个字节的范围 -2147483648 <= number <= 2147483647  ###########
v1 = struct.pack('i', 199)
print(v1)  # b'\xc7\x00\x00\x00'

for item in v1:
    print(item, bin(item))
    
# ########### 4个字节转换为数字 ###########
v2 = struct.unpack('i', v1) # v1= b'\xc7\x00\x00\x00'
print(v2) # (199,)

请添加图片描述

示例代码:

  • 服务端

    import socket
    import struct
    # AF_INET译网际域 SOCK_STREAM译套接字
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 
    sock.bind(('127.0.0.1', 8001))
    sock.listen(5)
    conn, addr = sock.accept()
    
    # 固定读取4字节
    header1 = conn.recv(4)
    data_length1 = struct.unpack('i', header1)[0] # 得到数据字节长度
    has_recv_len = 0
    data1 = b"" # b字节
    while True:
        length = data_length1 - has_recv_len
        if length > 1024:
            lth = 1024
        else:
            lth = length
        chunk = conn.recv(lth) # 如果发送的数据特别长,可能一次收不完,自己可以计算长度再次使用recv收取,直到收完为止。 1024 * 8 = 8192
        data1 += chunk
        has_recv_len += len(chunk)
        if has_recv_len == data_length1:
            break
    print(data1.decode('utf-8'))
    
    # 固定读取4字节
    header2 = conn.recv(4)
    data_length2 = struct.unpack('i', header2)[0] # 数据字节长度
    data2 = conn.recv(data_length2) # 长度
    print(data2.decode('utf-8'))
    
    conn.close()
    sock.close()
    
  • 客户端

    import socket
    import struct
                            
    client = socket.socket()
    client.connect(('127.0.0.1', 8001))
                            
    # 第一条数据
    data1 = 'emma正在玩'.encode('utf-8')
                            
    header1 = struct.pack('i', len(data1))
                            
    client.sendall(header1)
    client.sendall(data1)
                            
    # 第二条数据,先发送他的长度,再发送数据
    data2 = '游戏'.encode('utf-8')
    header2 = struct.pack('i', len(data2))
    client.sendall(header2)
    client.sendall(data2)
                            
    client.close()
    

案例:消息 & 文件上传

4. 阻塞和非阻塞

默认情况下我们编写的网络编程的代码都是阻塞的(等待),阻塞主要体现在:

# ################### socket服务端(接收者)###################
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.1', 8001))
sock.listen(5)

# 阻塞
conn, addr = sock.accept()

# 阻塞
client_data = conn.recv(1024)
print(client_data.decode('utf-8'))

conn.close()
sock.close()


# ################### socket客户端(发送者) ###################
import socket

client = socket.socket()

# 阻塞
client.connect(('127.0.0.1', 8001))

client.sendall('emma正在玩游戏'.encode('utf-8'))

client.close()

如果想要让代码变为非阻塞,需要这样写:

# ################### socket服务端(接收者)###################
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

sock.setblocking(False) # 加上就变为了非阻塞,True为阻塞

sock.bind(('127.0.0.1', 8001))
sock.listen(5)

# 非阻塞,BlockingIOError,想要去接收一个客户端的连接
conn, addr = sock.accept()

# 非阻塞,
client_data = conn.recv(1024)
print(client_data.decode('utf-8'))

conn.close()
sock.close()

# ################### socket客户端(发送者) ###################
import socket

client = socket.socket()

client.setblocking(False) # 加上就变为了非阻塞

# 非阻塞
client.connect(('127.0.0.1', 8001))

client.sendall('emma正在玩游戏'.encode('utf-8'))

client.close()

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-i6IjCYjZ-1642299494969)(images/image-20211221211408039.png)]

如果代码变成了非阻塞,程序运行时一旦遇到 acceptrecvconnect 就会抛出 BlockingIOError 的异常。

这不是代码编写的有错误,而是原来的IO阻塞变为非阻塞之后,由于没有接收到相关的IO请求抛出的固定错误。

非阻塞的代码一般与IO多路复用结合,可以迸发出更大的作用。

5. IO多路复用

I/O多路复用指:通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

注:“一个描述符一般就是个数字,表示一个打开的文件,进程,磁盘inode等等。内核有一个大数组维护这个东西。也可以理解描述符为数据库表里的一个id字段,描述符对应的资源是这个字段本身。只不过这里的数据库是内核。”

IO多路复用 + 非阻塞,可实现让TCP的服务端同时处理多个客户端的请求,例如:

# ################### socket服务端 ###################
import select
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setblocking(False)  # 加上就变为了非阻塞
server.bind(('127.0.0.1', 8001))
server.listen(5)

inputs = [server, ] # socket对象列表 -> [server,第一个客户端连接conn,第二个客户端连接conn ]

while True:
    # 当 参数1 序列中的socket对象发生可读时(accetp和read),则获取发生变化的对象并添加到 r列表中。
    # r = []没有接收的新连接时,r此时为空列表,此时不执行死循环中的代码
    # r = [server,]第一次有新连接时,server发生变化,此时 r = [server,]
    # r = [第一个客户端连接conn,]
    # r = [server,]
    # r = [第一个客户端连接conn,第二个客户端连接conn]
    # r = [第二个客户端连接conn,] #第二个客户端连接conn,没有数据则不再监听,并重列表中移除
    # 谁发生变化,这个列表中就是谁。r = [发生变化的这个连接,]
    r, w, e = select.select(inputs, [], [], 0.05)
    for sock in r:
        # server
        if sock == server:
            conn, addr = sock.accept() # 接收新连接。
            print("有新连接")
            # conn.sendall() 可以通过conn.sendall()给客服端发消息
            # conn.recv("xx") 接收消息
            inputs.append(conn)
        else: # 当收到连接后时,sock != server 就会执行else下的代码
            data = sock.recv(1024)
            if data: # 如果数据为空则关闭连接,并从列表中移除
                print("收到消息:", data)
            else:
                print("关闭连接")
                inputs.remove(sock)
	# 处理其他事 20s
"""
优点:
	1. 没有新链接到来时,可以处理其他事情
	2. 让服务端支持多个客户端同时来连接。
"""
select 模块参数解释:
select就是针对许多文件描述符(简称fd)进行监控,它有三个参数:

rlist -- wait until ready for reading

wlist -- wait until ready for writing

xlist -- wait for an "exceptional condition"

第一个参数监控 进来的 数据的fd列表,select监控这个列表,等待这些fd发送过来数据,一旦数据发送过来了(可以读取了),就返回一个可读的fd列表

第二个参数监控 出去的 数据的fd列表,select监控这个列表,等待这些fd发送出去数据,一旦fd准备好发送了(可以写入了),就返回一个可写的fd列表

第三个参数监控fd列表,返回出异常的fd列表

第四个参数,检测时间周期
# ################### socket客户端1 ###################
import socket

client = socket.socket()
# 阻塞
client.connect(('127.0.0.1', 8001))

while True:
    content = input(">>>")
    if content.upper() == 'Q':
        break
    client.sendall(content.encode('utf-8'))

client.close()
# ################### socket客户端2 ###################
import socket

client = socket.socket()
# 阻塞
client.connect(('127.0.0.1', 8001))


while True:
    content = input(">>>")
    if content.upper() == 'Q':
        break
    client.sendall(content.encode('utf-8'))

client.close() # 与服务端断开连接(四次挥手),默认会想服务端发送空数据。

IO多路复用 + 非阻塞,可以实现让TCP的客户端同时发送多个请求,例如:去某个网站发送下载图片的请求。

import socket
import select
import uuid
import os

client_list = []  # socket对象列表

# 创建5个socket
for i in range(5):
    client = socket.socket()
    client.setblocking(False)

    try:
        # 连接百度,虽然有异常BlockingIOError,但向还是正常发送连接的请求
        client.connect(('47.98.134.86', 80))
    except BlockingIOError as e:
        pass

    client_list.append(client)

recv_list = []  # 放已连接成功,且已经把下载图片的请求发过去的socket
while True:
    # w = [第一个socket对象,]
    # r = [socket对象,]
    r, w, e = select.select(recv_list, client_list, [], 0.1)
    for sock in w:
        # 连接成功,发送数据
        # 下载图片的请求
        sock.sendall(b"GET /nginx-logo.png HTTP/1.1\r\nHost:47.98.134.86\r\n\r\n")
        recv_list.append(sock)
        client_list.remove(sock)

    for sock in r:
        # 数据发送成功后,接收的返回值(图片)并写入到本地文件中
        data = sock.recv(8196)
        content = data.split(b'\r\n\r\n')[-1]
        random_file_name = "{}.png".format(str(uuid.uuid4()))
        with open(os.path.join("images", random_file_name), mode='wb') as f:
            f.write(content)
        recv_list.remove(sock)

    if not recv_list and not client_list:
        break
        
"""
优点:
	1. 可以伪造出并发的现象。
"""

基于 IO多路复用 + 非阻塞的特性,无论编写socket的服务端或客户端都可以提升性能。其中

注意:IO多路复用只能用来监听 IO对象 是否发生变化,常见的有:文件是否可读写、电脑终端设备输入和输出、网络请求(常见)。

在Linux操作系统化中 IO多路复用 有三种模式,分别是:select,poll,epoll。(windows 只支持select模式)

监测socket对象是否新连接到来 or 新数据到来。

select
 
select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。
 
poll
 
poll在1986年诞生于System V Release 3,它和select在本质上没有多大差别,但是poll没有最大文件描述符数量的限制。
poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
另外,select()和poll()将就绪的文件描述符告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()和poll()的时候将再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。
 
#### 以上两种效率低

epoll
 
直到Linux2.6才出现了由内核直接支持的实现方法,那就是epoll,它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
epoll可以同时支持水平触发和边缘触发(Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发),理论上边缘触发的性能要更高一些,但是代码实现相当复杂。
epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。
另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。

#### 回调机制,相当于谁有数据谁举手告诉我,我就去他那取数据,效率更高。

补充:socket + 非阻塞+ IO多路复用(只要是IO操作对象和 文件 都可以监测 )。

总结

  1. OSI 7层模型

    应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。
    
  2. UDP和TCP的区别

    UDP,速度快但无法保证数据的准确性。
    TCP,需要先创建可靠连接,在进行收发数据(ack)。
    
  3. TCP的三次握手和四次挥手

  4. 为什么会有粘包?如何解决?

  5. 如何让socket请求变成非阻塞?

  6. IO多路复用的作用?

    监测多个 IO对象 是否发生变化(可读/可写)。
    
    • IO多路复用 + 非阻塞 + socket服务端,可以让服务端同时处理多个客户端的请求。
    • IO多路复用 + 非阻塞 + socket客户端,可以向服务端同时发起多个请求。

标签:recv,socket,sock,编程,day21,网络,client,data,conn
来源: https://blog.csdn.net/m0_49654228/article/details/122519974