31天Python入门——第27天: Socket编程·TCP&UDP

在这里插入图片描述

你好,我是安然无虞。


在这里插入图片描述

什么是 Socket 编程

现在的软件开发基本都需要 网络通讯.

不管是传统计算机软件, 还是手机软件, 或是物联网嵌入系统软件, 都要和其他网络系统进行通讯.

而当今网络世界基本上都是使用 TCP/IP 协议进行通讯的.

任何应用, 比如 浏览网页、微信、抖音、支付宝或是我们开发的软件等等, 都是通过TCP/IP协议进行通讯的.

TCP/IP 协议 就是一种传输数据的方案.

我们可以用发快递来打比方:

在 南京的安然 要寄一个物品 给 在上海的无虞.

选择一个快递公司, 就是选择了一种 传输物品 的规范. 因为不同的快递公司传输物品的具体方案有所不同.

安然作为寄件人, 他不需要知道快递公司传输物品方案的所有细节.

他只需要知道, 如何把物品给快递公司上门取件的收件人 就行了.

无虞作为一个收件人, 他也不需要知道快递公司传输物品方案的所有细节.

他只需要知道, 如何从快递公司的收件人 收物品 就行了.


对应到软件开发上:

  • 收发信息的 程序进程 就像发件人和收件人.
  • 收发的信息 就像 快递传输的 物品.
  • 具体信息的传输路径 (中间经过哪些路由器) 和传输的方法 (使用什么协议) 就像快递公司的运输流程.

同样的, 我们编写的 发出信息的程序和接收信息的程序, 并不需要知道信息传输的所有细节, 比如中间经过哪些路由器, 路由器之间又是如何传输的, 这些我们并不关心.

我们作为程序员, 只要知道, 我们的程序如何把 所要发送的信息 交给 快递公司取件人, 以及如何从 快递公司送件人 手中获取信息即可.

那么和我们应用程序直接打交道的 快递公司取件人 和 快递公司送件人 到底是什么? 就是操作系统提供的 socket 编程接口.

发送信息的应用程序, 通过 socket 编程接口 把信息给操作系统的 TCP/IP 协议栈通讯模块.

通讯模块一层层传递给其他通讯模块 (网卡驱动等), 最后再通过网卡等硬件设备发送到网络上去.

经过网络上路由器的一次次转发, 最终到了目的程序所在的计算机, 再通过其操作系统的 TCP/IP协议栈通讯模块 一层层上传.

最后接收信息的程序, 通过 socket 编程接口 接收到了传输的信息.

这个过程可以用下图来表示:

在这里插入图片描述
其中 箭头表示 信息 传输的过程.

我们在前面使用过 requests 库 发送 HTTP请求消息, 其实 requests 库底层也是使用的 socket 编程接口发送HTTP请求消息.

HTTP 传输的消息底层也是通过 TCP/IP 协议 传输的, HTTP协议加上了一些额外的规定, 比如传输消息的格式等.

就像我们发快递的时候做了一些额外的处理, 比如在物品上贴上对应的快递单信息.

TCP Socket 编程

Python中要进行 socket 编程, 发送网络消息, 我们可以使用 Python 内置的 socket 库.

目前的 socket 编程, 使用的最多的就是通过 TCP 协议进行网络通讯的.

TCP 通讯的程序双方, 分为 服务端 和 客户端.

使用 TCP 协议进行通讯的双方, 是需要先建立一个虚拟连接的, 然后程序双方才能发送业务数据信息.

建立 TCP虚拟连接是通过著名的 三次握手 进行的.

现在我们来实现一个 用 TCP协议 进行通讯的 socket 服务端程序和客户端程序.

TCP 服务端程序

下面是TCP服务端程序 server.py

# ====== TCP 服务端程序 server.py =======

# 导入 socket 库
from socket import *

# 主机地址为空字符串, 表示绑定本机所有网络接口的IP地址
# 等待客户端来连接
IP = ''
# 端口号
PORT = 50000
# 定义一次从socket缓冲区最多读入512个字节数据
BUFLEN = 512

# 实例化一个socket对象
# 参数 AF_INET 表示该socket网络层使用的是IP协议
# 参数 SOCK_STREAM 表示该socket传输层使用的是TCP协议
listenSocket = socket(AF_INET, SOCK_STREAM)

# socket绑定地址和端口
listenSocket.bind((IP, PORT))

# 使socket处于监听状态, 等待客户端的连接请求
# 参数 8 表示最多接受多少个等待连接的客户端
listenSocket.listen(8)
print(f'服务端启动成功, 在{
      
      PORT}端口等待客户端连接...')

dataSocket, addr = listenSocket.accept()
print('接受一个客户端连接: ', addr)

while True:
    # 尝试读取对方发送的消息
    # BUFLEN 指定从接收缓冲区里最多读取多少字节
    recved = dataSocket.recv(BUFLEN)

    # 如果返回空bytes, 表示对方关闭了连接
    # 退出循环, 结束消息收发
    if not recved:
        break

    # ===== 特别注意: 网络中收发的消息类型都是经过编码的字节串类型 =====

    # 读取的字节数据是bytes类型, 需要解码为字符串
    info = recved.decode()
    print(f'收到对方信息: {
      
      info}')
    
    # 发送的数据类型必须是bytes类型, 所以要先编码在发送到网络中
    dataSocket.send(f'服务端就收到了信息 {
      
      info}'.encode())
    
# 服务端也调用close()关闭socket
dataSocket.close()
listenSocket.close()

TCP 客户端程序

下面是一个客户端程序 client.py

# ===== TCP 客户端程序 client.py =====

from socket import *

IP = '127.0.0.1'
SERVER_PORT = 50000
BUFLEN = 1024

# 实例化一个socket对象, 指明协议
dataSocket = socket(AF_INET, SOCK_STREAM)

# 连接服务端socket
dataSocket.connect((IP, SERVER_PORT))

while True:
    # 从终端读入用户输入的字符串
    toSend = input('>>> ')
    if toSend == 'exit':
        break
        
    # 发送消息, 也要编码为bytes
    dataSocket.send(toSend.encode())
    
    # 等待接受服务端的消息
    recved = dataSocket.recv(BUFLEN)
    
    # 如果返回了空bytes, 表示对方关闭了连接
    if not recved:
        break
        
    # 打印读取的信息
    print(recved.decode())
    
dataSocket.close()

老铁们可以保存一下运行看看.

先运行服务端, 再运行客户端.

应用消息格式

为什么要定义消息格式

上面的例子中, 我们发送的消息就是要传递的内容. 比如字符串: 你好, 我是安然无虞.

实际上, 我们在企业中开发的程序通讯, 消息往往是有格式定义的. 消息的格式定义可以归入 OSI 网络模型的表示层.

比如: 定义的消息包括消息头 和 消息体.

消息头存放消息的格式数据, 比如消息的长度、类型、状态等, 而消息体存放具体的传送数据.

对于使用 TCP 协议传输信息的程序来说, 格式定义一定要明确规定消息的边界.

因为 TCP 协议传输的是 字节流(bytes stream), 如果消息中没有指定 边界或者长度, 接收方就不知道一个完整的消息从字节流的哪里开始, 到哪里结束.

指定消息的边界有两种方式:

  • 用特殊字节作为消息的结尾符号

    • 可以用消息体中不可能出现的字节串 (比如 FFFFFF) 作为消息的结尾符号.
  • 在消息开头的某个位置, 直接指定消息的长度

    • 比如在消息的最前面用2个字节表示本消息的长度.

UDP协议通常不需要指定消息边界, 因为UDP协议是数据报协议, 应用程序从 socket 接收到的必定是发送方发送的完整消息.

支持多个TCP客户端

上面的 TCP 服务端代码只能和一个客户端进行通信.

如果我们同时运行多个客户端, 就会发现后面的客户端程序不能和服务端连接成功, 为什么呢?

因为服务端程序必须不停的对 监听socket对象调用accept方法, 才能不断的接收新的客户端连接.

而且还需要运行额外的代码对多个客户端连接后, 返回的多个数据传输 socket对象进行数据的收发.

很明显, 我们上面的程序并没有实现这块.

因为缺省情况下创建的 socket 是阻塞式的, 进行 accept 调用时, 如果没有客户端连接, 程序就会阻塞在此处, 不再执行后续代码.

同样的, 调用 recv 方法, 如果没有数据在本socket的接收缓冲区, 也会阻塞.

所以通常一个线程里面, 没法不断的调用监听socket的accept方法, 同时还能负责多个数据传输socket消息的收发.

那么如何让一个服务端程序和多个客户端同时连接并通信呢?

一个线程不行, 那么就采用多线程的方式.

再继续学习后面的内容之前, 可以看一下这篇关于多线程的文章:
31天Python入门——第24天:挑战一口气把多线程和多进程讲明白

我们使用多线程的方式修改之前的TCP服务端代码:

# ===== TCP 服务端程序 server.py 支持多个客户端 =====

# 导入 socket 库
from socket import *
from threading import Thread

IP = ''
PORT = 50000
BUFLEN = 512

# 这是新线程执行的函数, 每个线程负责和一个客户端通信
def clientHandle(dataSocket, addr):
	while True:
		recved = dataSocket.recv(BUFLEN)
		# 当对方关闭连接的时候, 返回空字符串
		if not recved:
			print(f'客户端{
      
      addr}关闭了连接')
			break
		
		# 读取的字节数据是bytes类型, 需要解码为字符串
		info = recved.decode()
		print(f'收到{
      
      addr}信息: {
      
      info}')

		dataSocket.send(f'服务端接收到了信息 {
      
      info}'.encode())

	dataSocket.close()

# 实例化一个socket对象, 用来监听客户端连接请求
listenSocket = socket(AF_INET, SOCK_STREAM)

# socket绑定地址和端口
listenSocket.bind((IP, PORT))

listenSocket.lienten(8)
print(f'服务端启动成功, 在{
      
      PORT}端口等待客户端连接')

while True:
	# 在循环中, 一直接受新的连接请求
	dataSocket, addr = listenSocket.accept()
	addr = str(addr)
	print(f"一个客户端 {
      
      addr} 连接成功")

	# 创建新线程处理和这个客户端的消息收发
	th = Thread(target=clientHandle, args=(dataSocket, addr))
	th.start()

listenSocket.close()

但是采用多线程方式有一个缺点.

如果一个服务端要同时处理大量的客户端连接, 比如 10000个, 需要创建10000个线程.

而操作系统通常不可能为一个进程分配这么多的线程.

实际上, 我们的服务端程序, 大部分时间都是空闲的, 都在等待连接请求, 等待接收消息, 根本不需要这么多的线程来处理.

这种程序通常被称为 IO bound程序, 也就是说程序的主要时间都是花费在IO上.

这种程序, 其实一个线程就够了.

关键问题是, 需要这一个线程很好的分配时间, 在有连接请求到来的时候, 执行处理连接请求代码, 有消息到达socket缓冲的时候, 执行读取处理消息的代码.

这种处理方式称之为 异步IO.

Python3新增了 asyncio 库, 我们可以使用该库来实现同时处理多个客户端数据收发.

示例代码如下:

#  === TCP 服务端程序 server.py 异步支持多客户端 ===
import asyncio, socket
IP = ''
PORT = 50000
BUFLEN = 512

# 定义处理数据收发的回调
async def handle_echo(reader, writer):
    addr = writer.get_extra_info('peername')
    while True:
        data = await reader.read(100)
        if not data:
            print(f'客户端{
      
      addr}关闭了连接')
            writer.close()
            break

        message = data.decode()
        print(f'收到{
      
      addr}信息: {
      
      message}')

        writer.write(data)

loop = asyncio.get_event_loop()
coro = asyncio.start_server(handle_echo, IP, PORT, loop=loop)
server = loop.run_until_complete(coro)

# Serve requests until Ctrl+C is pressed
print('服务端启动成功,在{}端口等待客户端连接...'.format(server.sockets[0].getsockname()[1]))
try:
    loop.run_forever()
except KeyboardInterrupt:
    pass

# Close the server
server.close()
loop.run_until_complete(server.wait_closed())
loop.close()

UDP Socket 编程

UDP协议的特点

UDP(User Datagram Protocol) 中文称之为用户数据报协议, 和TCP协议一样, 也是一种传输层协议.

和TCP协议较大的不同点在于:

1.UDP协议是一种无连接协议

也就是说: 无需事先建立虚拟连接, 可以直接给对方地址发送消息.
通信方的地址也是由IP地址和端口号组成.
所以相比TCP协议, 它更加简单快捷.

2.没有消息可靠性保证

UDP协议传输的消息如果在网络上丢失了, 就丢失了. UDP协议本身没有重传机制.
而 TCP协议底层有消息验证是否到达, 如果丢失, 发送方会重传的机制.
所以,使用UDP作为传输层协议, 要么是应用不在意丢失一些信息, 要么就是应用层自己实现一套机制保证可靠性.

3.数据消息发送是独立的报文

TCP协议通信双方的信息数据就像流动在管道中, 是有明确的先后次序的.
发送方应用先发送的信息肯定是先被接受方应用先接收的.
而UDP协议发送的是一个个独立的报文, 接收方应用接收到的次序不一定和发送的次序一致.


所以我们在使用 UDP 协议开发应用时, 需要特别注意的一点就是: 系统设计时要确定应用语义中的 最大报文长度.

这样编码的时候, 可以确定一个对应长度的应用程序接收缓冲, 防止出现只接收了一部分的情况.

TCP Socket是流式 (Stream) 协议, 如果应用接收缓冲不够大, 只接收了一部分, 没有关系, 后面继续接收, 然后找到消息边界拼接就可以了.

而UDP是数据报协议, UDP Socket如果只接受了数据报的一部分, 剩余的消息就会被丢弃. 下次接收, 只能接收到下一个数据报的内容了.

UDP 服务端程序

下面是一个UDP Socket通信的示例代码.

实现客户端请求服务端返回用户信息的功能.

# UDP 服务端代码

import socket,json

BUFF_LEN = 1000    # 接收缓冲长度
ADDR = ("", 18000)  # 指明服务端地址,IP地址为空表示本机所有IP

# 创建 UDP Socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 绑定地址
server_socket.bind(ADDR)

while True:
    try:
        recvbytes, client_addr = server_socket.recvfrom(BUFF_LEN)
    except socket.timeout:
        continue

    print(f'来自 {
      
      client_addr} 的请求')

    # 接收到的信息是字节,所以要解码,再反序列化
    message = json.loads(recvbytes.decode('utf8'))
    print(message)
    if message['action'] == '获取信息':
        # 可以从数据库的数据源查询 此用户的信息
        username = message['name']

        # 要发送的信息 对象
        message = {
    
    
            'action' : '返回信息',
            'info' : f'{
      
      username} 的信息是:xxxxxxxx'
        } 
        # 发送出去的信息必须是字节,所以要先序列化,再编码
        sendbytes = json.dumps(message).encode('utf8')
        server_socket.sendto(sendbytes, client_addr)

UDP客户端程序

客户端请求消息里面的 action 和 name 参数指定了 请求的目的 和 用户名.

# UDP 客户端代码

import socket,json

BUFF_LEN     = 1000                   # 接收缓冲长度
SERVER_ADDR  = ("127.0.0.1", 18000)  # 指明服务端地址

# 创建 UDP Socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 设置socket超时时间,单位:秒
client_socket.settimeout(2)

# 要发送的信息 对象
message = {
    
    
    'action' : '获取信息',
    'name' : '白月黑羽'
} 
# 发送出去的信息必须是字节,所以要先序列化,再编码
sendbytes = json.dumps(message).encode('utf8')
client_socket.sendto(sendbytes, SERVER_ADDR)
try:
    recvbytes, server = client_socket.recvfrom(BUFF_LEN)
    # 接收到的信息是字节,所以要解码,再反序列化
    message = json.loads(recvbytes.decode('utf8'))
    print(message)
except socket.timeout:
    print('接收消息超时')

可以看到, UDP通信的服务端也需要绑定端口号.

但是和TCP不同, 服务端只需要一个socket进行通信即可, 不需要2个socket分别用来监听和通信.

而UDP客户端的socket通常不需要指定绑定的端口号, 操作系统会自动帮其选择一个绑定.

当不需要使用 UDP Socket 时, 可以通过 socket对象的close方法关闭, 如下代码所示:

server_socket.close()

关闭 socket 后, 该端口绑定的 端口号就会被释放, 可以再次被本进程或其他进程的 socket 绑定使用.

遇见安然遇见你,不负代码不负卿。
谢谢老铁的时间,咱们下篇再见~