Lua 中避免低效解析 TCP 网络数据包体的一种方式

Stella981
• 阅读 1228

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 函数用于将接收到的字节流解析成单个协议数据包。inputoutput 涉及的字符串操作在调用比较频繁时效率会很低。如果对工具的效率要求提高,便不再满足需求。但是又想这个机器人尽量简单,会先考虑用纯 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 返回,就会卡住。

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Jacquelyn38 Jacquelyn38
2年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
2年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
2年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
2年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
2年前
34.TCP取样器
阅读文本大概需要3分钟。1、TCP取样器的作用   TCP取样器作用就是通过TCP/IP协议来连接服务器,然后发送数据和接收数据。2、TCP取样器详解!(https://oscimg.oschina.net/oscnet/32a9b19ba1db00f321d22a0f33bcfb68a0d.png)TCPClien
Wesley13 Wesley13
2年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
为什么mysql不推荐使用雪花ID作为主键
作者:毛辰飞背景在mysql中设计表的时候,mysql官方推荐不要使用uuid或者不连续不重复的雪花id(long形且唯一),而是推荐连续自增的主键id,官方的推荐是auto_increment,那么为什么不建议采用uuid,使用uuid究
Python进阶者 Python进阶者
3个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这