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
返回,就會卡住。