Python socket 编程|Python 主题月

1,526 阅读7分钟

本文正在参加「Python主题月」,详情查看 活动链接

socket 是对 TCP/IP 协议族的封装,在网络中可以通过 socket 通信。Python 中也实现了对 socket 编程的支持,可以方便的开发网络应用

客户端-服务端通信应用

下面是一个简单的客户端和服务端通信的例子

通信流程

  1. 服务端启动并创建一个 socket
  2. 服务端将 socket 绑定到一个端口
  3. 服务端开始监听端口
  4. 客户端请求服务端监听的端口
  5. 服务端接收到客户端的连接请求,并与客户端建立连接
  6. 客户端与服务端相互发送数据
  7. 客户端向服务端发送关闭连接的请求,然后退出
  8. 服务端关闭连接

服务端实现

import socket
HOST = '127.0.0.1' # 将监听的地址设为本地的 127.0.0.1
PORT = 65432       # 将监听的端口设为 65432
# 使用 socket.socket 创建一个 socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
	# 将 socket 绑定到指定的地址和端口
    s.bind((HOST, PORT))
	# 服务端开始监听
    s.listen()
	# 服务端等待客户端的连接请求
    conn, addr = s.accept()
    with conn:
        print('Connected by', addr)
        # 服务端循环接收客户端发来的数据
		while True:
            data = conn.recv(1024)
            if not data:
                break
			# 接收到数据后将数据返回给客户端
            conn.sendall(data)

创建 socket 连接

服务端实现的开头,先创建了一个 socket 对象,使用了 socket.AF_INETsocket.SOCK_STREAM 这两个参数,分别表示使用 IPv4 的地址和 TCP 协议。 如果需要使用 IPv6 地址,那么第一个参数可以使用 socket.AF_INET6 ,例如:

import socket

with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s:
	s.bind((HOST, PORT))
    s.listen()

如果基于 Unix 文件创建 socket,那么第一个参数可以使用 socket.AF_UNIX ,并且需要将绑定的地址和端口换成需要的文件路径,例如:

import socket

with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
    s.bind("/tmp/socket_test.s")
    s.listen()

如果不使用 TCP 协议,而是要使用 UDP 协议,那么需要将将第二个参数改成 socket.SOCK_DGRAM 并且去掉 listen() ,例如:

import socket

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.bind((HOST, PORT))

s.listen() 表示开始监听 socket, listen() 有一个可选的参数 backlog ,用来设置等待连接的客户端的最大数量,等待连接的客户端数量超过这个值后,新的连接请求将被拒绝。

接收客户端连接

 conn, addr = s.accept()
 with conn:
     print('Connected by', addr)

accept() 用于接收客户端连接,它是一个阻塞方法,当有新客户端连接时会返回连接对象 conn 和 (Host, Port) 组成的表示客户端地址的 addr 。 获取 conn 后在 with conn 的作用域内操作 conn 对象,这样当离开 with conn 的作用域后会自动调用 conn.close() ,就不需要手动关闭连接了。

接收数据

conn.recv() 方法用来从连接对象中读取数据,1024 表示每次最多读取 1024 个字节。如果有数据接收到,就使用 sendall() 方法将数据再传回客户端。

while True:
    data = conn.recv(1024)
    if not data:
        break
    conn.sendall(data)

send 和 sendall 的区别:

s.send()发送 TCP 数据,将 string 中的数据发送到连接的套接字。返回值是要发送的字节数量,该数量可能小于 string 的字节大小。
s.sendall()完整发送 TCP 数据。将 string 中的数据发送到连接的套接字,但在返回之前会尝试发送所有数据。成功返回 None,失败则抛出异常。

客户端实现

import socket

HOST = '127.0.0.1'
PORT = 65432

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(b'Hello, world')
    data = s.recv(1024)

客户端实现和服务端实现类似,不过在创建了 socket 对象之后,客户端使用 connect() 向服务端发起连接请求。成功连上之后,客户端就可以使用 sendall() 方法向服务端发送数据,或者使用 recv() 方法从服务端接收数据了

I/O 多路复用

前面的例子中,服务端一次只能处理一个客户端的请求,等到这个客户端退出之后才能继续处理其他客户端。为了是服务端能够同时支持多客户端的连接,就需要用到 Python 的 selectors 库。 selectors 对多路复用的系统调用进行了封装,使用 selectors.DefaultSelector 就能根据代码的执行平台自动选择最高效的系统调用方法。

selectors 示例

首先创建一个 selector 对象

import selectors

sel = selectors.DefaultSelector()

这里 DefaultSelector 会根据代码运行的平台自动从 kqueue, epoll, devpoll, poll, select 这几种 I/O 多路复用的方法中选择一个,优先顺序为 epoll|kqueue|devpoll > poll > select 。 接着同前面一样,创建一个 socket 对象,绑定到 65432 端口,然后开启监听:

host = '127.0.0.1'
port = 65432

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((host, port))
sock.listen()
sock.setblocking(False)

print('listening on', (host, port))

注意,这里需要使用 socket.setblocking(False) 将 socket 上执行的操作设为非阻塞模式,否则会因为执行 socket 的系统调用将应用阻塞。而非阻塞模式下,即使系统无法立即执行 send() 或 recv() 操作,也会立即返回,但是会抛出一个异常。 下面将 socket 注册到 selector 对象,这里只监听可读事件,因此第二个参数使用 selectors.EVENT_READ 

sel.register(sock, selectors.EVENT_READ, data=None)

接下来当有可读事件时就可以从 selector 对象获取发生事件的 socket,例如:

while True:
    # 读取事件
    events = sel.select(timeout=None)
    for key, mask in events:
        # 处理客户端连接
        if key.data is None:
            # 接收客户端连接
            handle_conn(key)
        # 处理客户端事件
        else:
            # 读写客户端数据
            handle_rw(key, mask)

sel.select() 会返回一个元组列表,每个元组由 (selectorKey, events) 组成。其中,可以通过 selectorKey.fileobj 获取发生事件的 socket,而 events 表示就绪事件的掩码。通过 selectorKey.data 可以获取客户端传来的数据,如果 selectorKey.data 为 None ,则表示这个 selectorKey 是监听 65432 端口的 socket,因此是一个新的连接请求,需要把它注册到 selector 中,以便接收后续传来的数据。否则表示这是一个已连接客户端,直接接收它传来的数据即可。

接收客户端连接

通过 key.data is None可以判定这个 key 是监听 65432 端口的 socket,需要通过这个 socket 接收新的连接请求,然后注册到 selector 中。

def handle_conn(sock):
    # 接收连接
    conn, addr = sock.accept()
    # 将操作设为非阻塞模式
    conn.setblocking(False)
    # 创建一个 data 对象用来存储这个连接相关的数据
    data = types.SimpleNamespace(addr=addr, inb=b'', outb=b'')
    # 对于新的连接,需要关注可读事件(客户端有数据传来)和可写事件(允许将服务端数据写回客户端)
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    # 在 selector 注册新连接对应的 socket
    sel.register(conn, events, data=data)

处理客户端读写

客户端触发的事件可以分为 可读事件 和 可写事件 两种情况:

  • 可读事件,表示客户端有新的数据发到服务端,需要服务端调用 recv() 从客户端对应的 socket 读取数据。
  • 可写事件,表示客户端对应的 socket 能够接收数据,此时如果服务端有数据需要传给客户端的话,就可以通过 send() 或者 sendall() 方法向客户端发送数据。
def handle_data(key, mask):
    # 获取触发事件的 socket
    sock = key.fileobj
    # 读取数据,得到的 data 和用 register 绑定的 data 参数一样,
    # 是一个 types.SimpleNamespace 对象
    data = key.data
    
    # 判断是否有可读事件
    if mask & selectors.EVENT_READ:
        # 接收数据,添加到 data 的 outb 属性
        recv_data = sock.recv(1024)
        if recv_data:
            data.outb += recv_data
        # 客户端发来空数据,表示数据发送完了
        else:
            print('closing connection to', data.addr)
            # socket 即将关闭,之后 selector 不需要再监听这个 socket,因此需要
            # 注销 selector 中的对应的 socket
            sel.unregister(sock)
            # 关闭 socket 连接
            sock.close()
            
    # 判断是否有可写事件
    if mask & selectors.EVENT_WRITE:
        # 将客户端发来的数据原样返回给客户端
        if data.outb:
            print('echoing', repr(data.outb), 'to', data.addr)
            sent = sock.sendall(data.outb)

参考