TCP 是流式协议,发送方发送出的是字节流,接收方接收到的也是字节流数据。通常,在应用层都会通过 header + body 在字节流中标识出单个协议包。发送方将原始数据打包成 header + body 。header 是固定字节数包头,标识 body 包含了多少字节数据。接收方先读固定字节数 header ,然后根据 header 读出具体的 body 数据。
在游戏中,总会需要编写一些和服务器通信的机器人客户端。我们项目会习惯采用 Lua 来实现,就不可避免的解析 TCP 网络数据。逻辑很简单,通常采用字符串连接的方式几行代码就可以完成。完整代码点击这里 ,下面列出主要的代码片段。
function mt:init(header_bytes)
self.cache = ""
self.header_bytes = header_bytes
end
function mt:input(str)
self.cache = self.cache .. str
end
function mt:output()
local hb = self.header_bytes
local total = #self.cache
if total <= hb then
return
end
local body_bytes = string.unpack(">I2", self.cache)
if hb + body_bytes > total then
return
end
local body = self.cache:sub(hb + 1, hb + body_bytes)
self.cache = self.cache:sub(hb + body_bytes + 1)
return body
end
input
函数用于缓存收到的数据,output
函数用于将接收到的字节流解析成单个协议数据包。input
和 output
涉及的字符串操作在调用比较频繁时效率会很低。如果对工具的效率要求提高,便不再满足需求。但是又想这个机器人尽量简单,会先考虑用纯 Lua 来解决这个问题。
上述方案的问题在于字符串连接效率比较低,在接收数据比较频繁时,字符串操作占用大量的 CPU 资源。于是新方案的思想就是尽量避免字符串连接,如下所示。
function mt:init(header_bytes)
self.cache_list = {}
self.total_size = 0
self.header_bytes = header_bytes
self.body_list = {}
end
function mt:input(str)
local cache = self.cache_list
local block = cache[#cache]
if block and #block < self.header_bytes then
cache[#cache] = block .. str
else
cache[#cache + 1] = str
end
self.total_size = self.total_size + #str
end
function mt:output()
local body_list = self.body_list
local cache_body = body_list[1]
if cache_body then
table.remove(body_list, 1)
return cache_body
end
local total_str
if #self.cache_list == 1 then
total_str = self.cache_list[1]
else
total_str = table.concat(self.cache_list)
self.cache_list = {total_str}
end
local hb = self.header_bytes
local start_index = 1
while true do
if not total_str or #total_str < hb then
break
end
if self.total_size <= hb then
break
end
local header = total_str:sub(start_index, start_index + hb - 1)
local body_bytes = string.unpack(">I2", header)
if hb + body_bytes > self.total_size then
break
end
self.total_size = self.total_size - hb - body_bytes
local new_index = start_index + hb + body_bytes
local body = total_str:sub(start_index + hb, new_index - 1)
if cache_body then
body_list[#body_list + 1] = body
else
cache_body = body
end
start_index = new_index
end
if start_index > 1 then
self.cache_list = {total_str:sub(start_index)}
end
return cache_body
end
input
函数中不会进行字符串连接,而是把收到的数据保存到 self.cache_list
中。然后在 output
函数中一次尽最大可能解析协议数据,然后保存在 self.body_list
中,每次调用 output
时若 self.body_list
有数据,则直接返回这里的数据即可。
测试方式见这里。新的方式基本可以瞬间解析完 64M 数据。
最好是过一段时间调用一次 output
函数,这样会更高效。手游客户端的帧率一般是 30 FPS 或 60 FPS 。所以完全可以 1/60
秒调用一次 output
函数,甚至 1/100
秒调用一次也可以。
具体使用时,需要先获取完整的数据(位于 self.body_list
)数组中,若没有,则读 socket ,然后添加到缓存中,再解析是否有收到了完整的数据,若没有则 sleep 一小会儿,则尝试。具体代码如下。
function mt:read_packet()
local packet
while true do
-- 尝试获取完整的数据
packet = self.pack_obj:output(true)
if packet then
return packet
end
-- 读 socket
local buf, err = self.sock:read()
if not buf or #buf == 0 then
return nil, err
end
self.pack_obj:input(buf)
-- 解析是否收到了完整的数据
packet = self.pack_obj:output()
if packet then
break
end
Levent.sleep(0.01)
end
end
一开始使用这段代码时,没有先尝试获取完整的数据,每次调用 read_packet
都会读 socket ,当一次收到的数据量很大时,可能包含了多个完整的数据包,而此时还 read_packet
,若服务器没有返回数据,则客户端会一直等待 read_packet
返回,就会卡住。