游戏测试 pb 数据的使用 -- 游戏协议工具实战

陈随想 · 2021年07月19日 · 最后由 skottZy 回复于 2022年05月14日 · 1246 次阅读

pb 数据的使用 -- 游戏协议工具实战

前言:

其实之前已经用 python 实现过了(协议工具),最近又用 go 重构了一遍(刚入门 go)。所以本篇会分别从 go(详细讲)以及 py(粗略讲) 两方面讲叙。由于鄙人也是小小白,因此会讲的没那么高大上,尽可能俗一点,让其他刚进入游戏的老铁也能看个明白。

背景分析:

  • 通讯协议是使用 pb 数据,而且没有做进一步的二次封装数据包。如果程序做进一步的数据包封装,去约定一些特定规则,加个头啊加个尾什么的,那么就需要去找程序了解这个规则了。
  • 用户注册是另外的 http 服务
  • proto 协议文件挺多的,而且内容指不定就变了

需求与方案制定:

至少需要实现:从客户端那边 log 输出中复制所有 “发送协议-->>XXX” 协议内容,通过工具可以实现并发去创建多个账号并执行这些协议内容。

为了应对协议内容的增删改变化,决定偷懒,不一一通过原有协议数据执行序列化与反序列化,而是全部通过 GM 协议执行。好处是可以偷懒,不需要采用其他方式(比如动态 import 去导入对应需要的 xx.pd.go),坏处是不能够对所有协议作反序列化。但是这个并不影响我的核心需求。

message CmdGMReqMsg {
    required string command = 1; 
}
message CmdGMRspMsg {
}

接下来请看代码实战。

GO 篇

HTTP 请求

"net/http"包使用

func Get(yoururl string, data map[string]string) map[string]interface{} {
    request, err := http.NewRequest("GET", yoururl, nil)
    if err != nil {
        log.Println("err->", err)
    }
    //加入get参数
    q := request.URL.Query()
    for key, value := range data {
        q.Add(key, value)
    }
    request.URL.RawQuery = q.Encode()
    resp, err := http.DefaultClient.Do(request)
    if err != nil {
        log.Println("err->", err)
    }
    defer resp.Body.Close()
    data1, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Println("err->", err)
    }
    rdata := string(data1)
    result := make(map[string]interface{})
    err1 := json.Unmarshal([]byte(rdata), &result)
    if err1 != nil {
        fmt.Println(err)
    }
    return result

proto 文件处理

最简单直接的办法,从官网直接拉 protoc.exe 文件,通过命令行转换即可。

举个栗子:

protoc --proto_path=..\\protos --python_out=..\\protos ..\\protos\XXX.proto

转换成 go 之后得到文件:XX.pb.go,接着如何根据协议内容创建结构体呢?稍微看一下官方例子就不难写出:

func Gmmsg(gm string) []byte {
    gm_mess := &pb.CmdGMReqMsg{
        Command: &gm,
    }
    //通过"google.golang.org/protobuf/proto"序列化
    out, _ := proto.Marshal(gm_mess)
    return out
}

我这边根据实际项目内容分析,协议最后都是嵌套作为 message ClientCmdData 中 data 值,那么也不难写出:

func Clientmsg(data []byte) []byte {
    clinetmsg := &pb.ClientCmdData{
        //部分字段省略
        Data:        data,
    }
    out, _ := proto.Marshal(clinetmsg)
    return out
}

websocket 连接

主要使用包:"github.com/gorilla/websocket"。这个去 github 学习一下,基本都能写出基础的 websocket 链接。

func connect() (c *websocket.Conn) {
    var addr string = "冬天的秘密"
    var u = url.URL{Scheme: "ws", Host: addr, Path: "冬天的秘密"}
    c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
    if err != nil {
        log.Fatal("dial:", err)
    }
    log.Printf("connecting to %s", u.String())
    return
}

发送与接收,以及反序列化尝试

func Send(t []byte, c *websocket.Conn) *pb.ServerCmdData {
    c.WriteMessage(websocket.BinaryMessage, t)
    time.Sleep(500 * time.Millisecond)
    _, message, _ := c.ReadMessage()
    serdata := &pb.ServerCmdData{}
    if err := proto.Unmarshal(message, serdata); err != nil {
        log.Fatalln("failed----------:", err)
    }
    //在我有需要的时候去掉注释,可以针对指定协议反序列化拿到服务器返回的数据
    // if *serdata.MessageId == int32(协议号) {
    //  temp := &pb.XXXXX{}
    //  if err := proto.Unmarshal(serdata.Data, temp); err != nil {
    //      log.Fatalln("failed----------:", err)
    //  }
    //  fmt.Printf("%+v#####", temp)
    // }
    return serdata
}

比如反序列化拿某个 message 的某个字段 id

另外可以根据需要,多加点实用的功能,比如知道服务器处理协议失败可以重发 10 次,超过 10 次不管;协议数据中最多会涉及到一个变化的 playerid,这个是需要根据实际注册账号得到的 playerid 的;还有其他小细节可以自己在使用过程中慢慢发挥。

协议 Log 内容处理

处理从客户端复制下来的一大批协议内容:发送协议:> XXXXXXX

这块主要涉及 go 文件处理

func Handfile() (result []string) {
    file := "冬天的秘密.log"
    f, err := os.Open(file)
    if err != nil {
        log.Fatal(err)
    }
    defer func() {
        if err = f.Close(); err != nil {
            log.Fatal(err)
        }
    }()
    s := bufio.NewScanner(f)
    for s.Scan() {
        result = append(result, s.Text())
    }
    err = s.Err()
    if err != nil {
        log.Fatal(err)
    }
    return
}

接收 log 内容,做一定处理(根据需要)然后发送;接收 nickname,根据 nickname 注册并创建 nickname 游戏角色

func Handmess(nickname string, mess []string) {
    //涉及协议内容格式,不敢放太多
    con := connect()
    loginmsg := Clientmsg(1001, Loginmessage(token, accountid))
    Resend(loginmsg, con)
    registmsg := Clientmsg(1000, Regist(nickname, token, accountid))
    serverrsg := Resend(registmsg, con)
    if !*serverrsg.Result {
        log.Printf("%s注册失败,该账号可能已经注册并在游戏中创建了账号(或者账号非法)", nickname)
        return
    }
    //省略mess处理与发送
    time.Sleep(1 * time.Second)
    con.Close()

go 的并发(太香了)

type Glimit struct {
    n int
    c chan struct{}
}

func New(n int) *Glimit {
    return &Glimit{
        n: n,
        c: make(chan struct{}, n),
    }
}
func (g *Glimit) Run(f func()) {
    g.c <- struct{}{}
    go func() {
        f()
        <-g.c
    }()
}
g := New(20)
log.Printf("start-------------")
filemess := websockets_test.Handfile()
var wg sync.WaitGroup
for i := 100; i < 200; i++ {
    wg.Add(1)
    value := strconv.Itoa(i)
    gofunc := func() {
        websockets_test.Handmess("newnick"+value, filemess)
        log.Printf("end-------------%s", "newnick"+value)
        wg.Done()
    }
    g.Run(gofunc)
}
wg.Wait()
log.Printf("end-------------")

实战结果

Python 篇

相信大伙的 python 都比我厉害,因此 Python 篇会简略一点。

  • http 请求用 request
  • proto 文件处理同 go
  • websockets 这个也不难
  • asyncio 使用
  • log 文件处理相信大家都会了
  • config 文件
    # asyncio
    async def run():
        semaphore = asyncio.Semaphore(15)
        to_get = [main_logic(i, semaphore) for i in range(num)]
        await asyncio.wait(to_get)
loop = asyncio.get_event_loop()
loop.run_until_complete(run())
    # websockets
    async with websockets.connect(_server_ws_url) as ws:
        # ws.close_timeout = 200
        await send_message(XXXXXX)
    # pd数据的引用
    def clientmsg(self, messageid, msg=None):
        cmsg = Cmd_pb2.ClientCmdData()
        cmsg.messageId = messageid
        cmsg.clientIndex = 0
        if msg:
            cmsg.data = msg
        return cmsg

[config]
# 服务器id
serverid = 冬天的秘密
_server_ws_url = 冬天的秘密
# 平台id
platformId = 冬天的秘密
# 你想要登录的账号,如果不存在会自动注册
nickname = nihao
start = 39
num = 1
# playerid(自增id),进阶功能,可以将协议数据中所有用到playerid替换为该player的正确playerid
playerid = 冬天的秘密
log = 高级账号.log

另外还有一套用于 jenkins 配置

class config(object):
    def __init__(self):
        server = {
            "A服": '冬天的秘密',
            "B网": '冬天的秘密',
        }
        serverid = {
            "A服": XXX,
            "B网": XX,
        }
        platformId = {
            "A服": XX,
            "B网": XX,
        }
        log = {
            "高级账号": "D:/高级账号.log",
            "创建新号": "D://创建新号.log",
            "第一章": "D://什么都不做只通关第一章.log",
        }
        if not os.getenv("choice_server"):
            print("你没有选择服务器!!!")
            sys.exit(0)
        if not os.getenv("可选协议") and not os.getenv("protos"):
            print("你没有填写任何协议内容!!!")
            sys.exit(0)
        self.serverid = serverid[os.getenv("choice_server")]
        self.platformId = platformId[os.getenv("choice_server")]
        gamename = re.split(r'\n',os.getenv("gamename"))
        self.nickname = gamename[0]
        self.num = gamename[2]
        self.start = gamename[1]
        if os.getenv("可选协议"):
            self.log = linecache.getlines(log[os.getenv("可选协议")])
        else:
            self.log = re.split(r'\n', os.getenv("protos"))
        self._server_ws_url = server[os.getenv("choice_server")]
最佳回复

今天做了一点优化:

_, message, _ := c.ReadMessage()
   serdata := &pb.ServerCmdData{}
   if err := proto.Unmarshal(message, serdata); err != nil {
       log.Fatalln("failed----------:", err)
   }

这块代码单独取出来放在协程的循环中

go func(){for{
如果连接关闭,退出循环;
否则处理接受到的信息,并写入log日志}
}

这个协程写在 websocket 连接之后;同时新增了发送协议也写入日志,如下:

主要解决服务器会回很多协议的问题,同时记录所有协议返回。另外为了解析协议,不得不写对应的 case 去解析,因为每个协议有可能多层嵌套,就需要多次反序列化。暂时只能用 case 办到,毕竟 go 不是动态语言。

恩,先这样吧。其实我只是想要实现发送协议就行,毕竟目前我也就用这么个功能了。结果越写越多了。

共收到 11 条回复 时间 点赞

今天做了一点优化:

_, message, _ := c.ReadMessage()
   serdata := &pb.ServerCmdData{}
   if err := proto.Unmarshal(message, serdata); err != nil {
       log.Fatalln("failed----------:", err)
   }

这块代码单独取出来放在协程的循环中

go func(){for{
如果连接关闭,退出循环;
否则处理接受到的信息,并写入log日志}
}

这个协程写在 websocket 连接之后;同时新增了发送协议也写入日志,如下:

主要解决服务器会回很多协议的问题,同时记录所有协议返回。另外为了解析协议,不得不写对应的 case 去解析,因为每个协议有可能多层嵌套,就需要多次反序列化。暂时只能用 case 办到,毕竟 go 不是动态语言。

恩,先这样吧。其实我只是想要实现发送协议就行,毕竟目前我也就用这么个功能了。结果越写越多了。

解析协议不需要额外去解析吧,这样维护起来没完没了,可以用 pb 自己的反射方法:

msgType, err := protoregistry.GlobalTypes.FindMessageByName("msgName")
if err != nil{
msg = msgType.New().InterFace()
}
嵌套也不需要多次反序列化,protojson.Marshal(msg) 既可以转成 json 去处理

skottZy 回复

醍醐灌顶啊老哥,我还是太嫩了当时没继续彻底研究。虽然历经沧桑(工作已换),脚本已经没办法继续调试了,但是老哥你这个指点真的是醍醐灌顶啊,我有空看看相关反射方法。特别感谢!!!

陈随想 回复

没啥,这些 pb 的文档都是有的,go 的性能不错,不过我们都是用 python & c++,高性能的部分,还是交给 c++ 去做好一点

想请问 4 楼 python 解析 pb 文件怎么用反射实现

胡恒章 回复

~~这个其实 4 楼已经指点了方向了,不过就是得咱自个花时间研究了。我以 go 为例子,https://github.com/protocolbuffers/protobuf-go/tree/v1.28.0 比如这里就有写相关的内容。

reflect/protoreflect: 包protoreflect提供接口来动态操作 protobuf 消息。
reflect/protoregistry: 包protoregistry提供数据结构来注册和查找 protobuf 描述符类型。
reflect/protodesc: 包protodesc提供将 descriptorpb.FileDescriptorProto消息转换为/从反射的 功能protoreflect.FileDescriptor。
reflect/protopath: 包protopath提供对消息的一系列 protobuf 反射操作的表示。
reflect/protorange: 包protorange提供了遍历 protobuf 消息的功能。

就是得花时间看看。。。。

陈随想 回复

确实看的有点吃力啊 在文档中有看到 reflect 模块中提供了这个方法

def ParseMessage(descriptor, byte_str):
  """Generate a new Message instance from this Descriptor and a byte string.
  DEPRECATED: ParseMessage is deprecated because it is using MakeClass().
  Please use MessageFactory.GetPrototype() instead.
  Args:
    descriptor: Protobuf Descriptor object
    byte_str: Serialized protocol buffer byte string
  Returns:
    Newly created protobuf Message object.
  """
  result_class = MakeClass(descriptor)
  new_msg = result_class()
  new_msg.ParseFromString(byte_str)
  return new_msg

但是第一个参数 descriptor 的对象初始化参数传值不太明白又没有示例

class google.protobuf.descriptor.Descriptor(name, full_name, filename, containing_type, fields, nested_types, enum_types, extensions, options=None, serialized_options=None, is_extendable=True, extension_ranges=None, oneofs=None, file=None, serialized_start=None, serialized_end=None, syntax=None, create_key=None)
skottZy 回复

能麻烦老哥具体指导一下 python 如何基于反射去解析 protobuf,目前在做协议测试卡主了

胡恒章 回复

其实我也没用过,我现在也不清楚帮不了你。Please use MessageFactory.GetPrototype() instead 这里不是说用 MessageFactory 么,找下这个看看吧。

胡恒章 回复

看一下官方文档,我们是用 c++ 的 api 做的,python 部分只是传参数,c++ 去处理

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册