如上图所示,现在我们开始使用代码来描述 RPC 的服务器模型,从简单变化到复杂,从经典变化到现代。
本节我们会主要讲解以下内容:
Python 有内置的网络编程类库,方便用户编写 tcp/udp 相关的代码。两个不同机器的进程需要通信时,可以通过 socket 来传输数据。
# 套接字客户端大致 API,参数略
sock = socket.socket() # 创建一个套接字
sock.connect() # 连接远程服务器
sock.recv() # 读
sock.send() # 尽可能地写
sock.sendall() # 完全写
sock.close() # 关闭
注意send和sendall方法的区别,在网络状况良好的情况下,这两个方法几乎没有区别。但是需要特别注意的是send方法有可能只会发送了部分内容,它通过返回值来指示实际发出去了多少内容。而sendall方法是对send方法的封装,它考虑了这个情况,如果第一次send方法发送不完全,就会尝试第二次第三次循环发送直到全部内容都发送出去了或者中间出了错误才会返回。后续所有调用我们都会使用sendall方法。
# 套接字服务器大致 API,参数略
sock = socket.socket() # 创建一个服务器套接字
sock.bind() # 绑定端口
sock.listen() # 监听连接
sock.accept() # 接受新连接
sock.close() # 关闭服务器套接字
Python 内置的二进制解码编码库,用于将各种不同的类型的字段编码成二进制字节串。类似于 java 语言的 bytebuffer 可以将各种不同类型的字段内容编码成 byte 数组。我们通过 struct 包将消息的长度整数编码成 byte 数组。
value_in_bytes = struct.pack("I", 1024) # 将一个整数编码成 4 个字节的字符串
value, = struct.unpack("I", value_in_bytes) # 将一个 4 字节的字符串解码成一个整数
# 注意等号前面有个逗号,这个非常重要,它不是笔误。
# 因为 unpack 返回的是一个列表,它可以将一个很长的字节串解码成一系列的对象。
# value 取这个列表的第一个对象。
Python 内置的 json 序列化库。它可以将内存的对象序列化成 json 字符串,也可以将字符串反序列化成 Python 对象。它的序列化性能不高,但是使用方便直观。
raw = json.dumps({"hello": "world"}) # 序列化
po = json.loads(raw) # 反序列化
我们将使用长度前缀法来确定消息边界,消息体使用 json 序列化。
每个消息都有相应的名称,请求的名称使用 in 字段表示,请求的参数使用 params 字段表示,响应的名称是 out 字段表示,响应的结果用 result 字段表示。
我们将请求和响应使用 json 序列化成字符串作为消息体,然后通过 Python 内置的 struct 包将消息体的长度整数转成 4 个字节的长度前缀字符串。
// 输入
{
in: "ping",
params: "ireader 0"
}
// 输出
{
out: "pong",
result: "ireader 0"
}
后续使用的客户端代码是通用的,它适用于演示所有服务器模型。它的过程就是向服务器连续发送 10 个 RPC 请求,并输出服务器的响应结果。它使用约定的长度前缀法对请求消息进行编码,对响应消息进行解码。如果要演示多个并发客户端进行 RPC 请求,那就启动多个客户端进程。
import json
import time
import struct
import socket
#```
# // 输入
# {
# in: "ping",
# params: "ireader 0"
# }
#
# // 输出
# {
# out: "pong",
# result: "ireader 0"
# }
# ```
def rpc(sock, in_, params):
request = json.dumps({"in": in_, "params": params})
length_prefix = struct.pack('I', len(request)) #发送4字节长度信息
sock.sendall(length_prefix)
sock.sendall(str.encode(request)) #发送完毕
print('send data:', str.encode(request))
#等待响应包
length_prefix = sock.recv(4) #响应包长度
length, = struct.unpack('I', length_prefix) #unpack返回一个元组
body = sock.recv(length) # 响应消息体
print('recv:', body)
response = json.loads(body)
return response['out'], response['result'] # 返回响应类型和结果
if __name__ == '__main__':
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('localhost', 8080))
for i in range(10): # 连续发送 10 个 rpc 请求
out, result = rpc(s, 'ping', 'ireader {0}'.format(i))
print(out, result)
time.sleep(1)
s.close()
值得注意的是代码中有两个地方调用了socket.recv方法来读取期望的消息,通过传入一个长度值来获取想要的字节数组。实际上这样使用是不严谨的,甚至根本就是错误的。
socket.recv(int)默认是阻塞调用,不过这个阻塞也是有条件的。如果内核的套接字接收缓存是空的,它才会阻塞。只要里面有哪怕只有一个字节,这个方法就不会阻塞,它会尽可能将接受缓存中的内容带走指定的字节数,然后就立即返回,而不是非要等待期望的字节数全满足了才返回。这意味着我们需要尝试循环读取才能正确地读取到期望的字节数。
def receive(sock, n):
rs = [] # 读取的结果
while n > 0:
r = sock.recv(n)
if not r: # EOF
return rs
rs.append(r)
n -= len(r)
return ''.join(rs)
但是为了简单起见,我们后面的章节代码都直接使用socket.recv,在开发环境中网络延迟的情况较少发生,一般来说很少会遇到recv方法一次读不全的情况发生。
单线程同步模型的服务器是最简单的服务器模型,每次只能处理一个客户端连接,其它连接必须等到前面的连接关闭了才能得到服务器的处理。否则发送过来的请求会悬挂住,没有任何响应,直到前面的连接处理完了才能继续。
服务器根据 RPC 请求的 in 字段来查找相应的 RPC Handler 进行处理。例子中只展示了 ping 消息的处理器。如果你想支持多种消息,可以在代码中增加更多的处理器函数,并将处理器函数注册到全局的 handlers 字典
中。
import json
import struct
import socket
def handle_conn(conn, addr, handlers):
print('client: {0} connect'.format(addr))
#循环读写
while True:
length_prefix = conn.recv(4) #接收请求头长度
if not length_prefix: #连接关闭
print('client: {0} close'.format(addr))
conn.close()
break
length, = struct.unpack('I', length_prefix)
body = conn.recv(length)
request = json.loads(body)
print('recv :', request)
in_ = request['in']
params = request['params']
handler = handlers[in_] #查找请求对应的处理函数
handler(conn, params) #处理请求
def loop(sock, handlers):
while True:
conn , addr = sock.accept() #接收连接
handle_conn(conn, addr, handlers)
def ping(conn, params):
send_result(conn, 'pong', params)
def send_result(conn, out, result):
response = json.dumps({'out' : out, 'result':result}) # 响应消息体
lenght_prefix = struct.pack('I', len(response))
conn.sendall(lenght_prefix)
conn.sendall(str.encode(response))
if __name__ == '__main__':
# 创建一个 TCP 套接字
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 打开 reuse addr 选项
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("localhost", 8080)) # 绑定端口
sock.listen(1) # 监听客户端连接
print('listen')
#注册请求处理器
handlers = {
'ping' : ping
}
loop(sock, handlers)
交互结果如下:
如果在上一个客户端运行期间,再开一个新的客户端,会看到新的客户端没有任何输出。直到前一个客户端运行结束,输出才开始显示出来。因为服务器是串行地处理客户端连接。
这样的服务器毫无疑问肯定是不会有人用的,如果你家的服务器只能服务一个客户,其它人都得排队,这不是要把人家活活急死么。
下一章介绍并发模式的服务器实现。