代码改变世界

HttpRunner的PB序列化工具类解决方案(python3)

2022-01-17 20:31  第二个卿老师  阅读(392)  评论(0编辑  收藏  举报

背景

年初的时候团队内落地了HttpRunner3框架,简单介绍下:HttpRunner 是一款由python开发的面向 HTTP(S) 协议的开源通用测试框架,用例脚本为 YAML/JSON 格式,3.0版本支持py格式。

HttpRunner 依赖开源库requests ,pytest ,pydantic ,allure 和 locust,可实现自动化测试、性能测试、线上监控、持续集成等多种测试需求。

PB指的是Protocol Buffers 又简称为 Protobuf,是 Google 推出的一种二进制数据交换格式(类似json、xml一样,但更轻量)。

Protobuf 有自己的编译器,在 Linux 中叫做 protoc ,可以解释 .proto 文件并且声称对应语言的源文件,目前已支持多种语言,githab链接,如何接入PB网上很多教程,这里就不展开说了。

问题

目前公司前后端接口已经接入了PB,老接口暂时json入参不变,新接口采用PB格式,但HttpRunner不支持PB格式入参,新接口测试就无法进行。

解决方案

如果是第一次肯定一头雾水,于是我收集信息:

  1. pb是谷歌序列化协议,可以简单理解为base64编码解码,不同的是解析规则是我们自己定义的,而规则就是.proto文件(其实说.porto文件是接口文档更合适)。
  2. 一个proto文件就是一个解析规则,而文件中的一个message就是结构化数据,对应一个接口请求的结构或者出参的结构,也可以抽象理解为定义一个类,enum就是枚举,以数字编号作为主键等等。
  3. proto文件跟语言无关,其他语言要解析,需要对应语言的编译器工具把proto文件编译成目标语言,python就会编译成类似xxx_pb2.py的文件。
  4. 使用范例可以自行安装proto(pip install protobuf,验证用 import google.protobuf 不报错就ok),调用方法实例json格式的方法如下
google.protobuf.json_format 
包含用于以 JSON 格式打印协议消息的例程。
简单使用示例:
# 创建一个proto对象并将其序列化为json格式字符串。
message_object= my_proto_pb2.MyMessage(foo='bar')    
json_string = json_format.MessageToJson(message_object)
# 解析一个json格式的字符串到proto对象。
message = json_format.Parse(json_string, my_proto_pb2.MyMessage())   --my_proto_pb2是指编译后pb文件,MyMessage()这是对应的message方法名

结论如下:

  1. 前后端开发编写接口文档,也就是.proto文件(这里跟前后端语言无关,使用PB格式的语法),一个项目可以根据服务划分,每个服务可能包含多个.proto文件,每个.proto文件也可能对应多个接口。
  2. 前后端采用官方编译器编译每个.proto文件,生成自己使用的语言类包(比如python就是xxx_pb2.py)。
  3. 有了xxx_pb2.py文件,就引用google.protobuf.json_format包,调用相应的方法,针对特定的message,就可以把json的字符串解析为PB的二进制格式了。

于是我的解决思路:一,传入接口入参调用pb;二,根据入参找到对应的接口pb2文件;三,解析该接口入参数据;四,返回替换请求的入参;

第一步

  1. HttpRunner的接口请求前都有前置处理,所以只需要在debugtalk文件中写一个把json序列化为PB的共用方法就行。
  2. 为了使debugtalk简洁点,处理PB序列化的可以单写一个转换类,上面的方法引用这个转换类。
  3. 这个转换类至少需要提供json解析为PB、PB序列化为json的两个方法。
  4. 可能需要其他一些日志、过滤、加密方法(根据实际情况来)。

第二步

问题1:前面说到不用关心.proto文件,那xxx_pb2.py怎么来?

  好在我们前端人员做了转换工程,每次更新项目的.proto文件,都会上传到gitlab上,可根据自己的语言拉取即可。

问题2:现在_pb2文件有了,但是.proto肯定存在多个接口(对应多个message),而HttpRunner中每个接口的请求入参都需要解析,如何根据接口名找到对应的message呢?

  还是前端做了一个json文件(index.json),里面存在接口与其关联的message信息,于是查找这个文件即可,文件内容样式如下。

 

 于是:

  1. 转换类需要导入_pb2.py文件、json文件,由于不是同一个项目,需要使用到git的子模块submodule功能。
  2. 转换类需要实现一个查找方法,输入接口的请求url在json文件中找到对应的接口message。

第三步

这一步为重点,需要实现json解析为PB、PB序列化为json的两个方法

  1. 根据上面调用实例,两个方法都需要json字符串、pb2文件名、message名三个入参。
  2. 由于每个接口有对应pb2文件,所以这个也是变量,可以用接口的路径拼接起来。
  3. 两个方法应该返回对应的字符串,注意PB是字节流bytes格式。

第四步

基本上第三步已经完成了转换类,这一步主要是针对debugtalk的方法

  1. debugtalk中json序列化为PB的共用方法回填数据时,需要选择from_data格式(post的from_data格式才支持字节流bytes格式)。
  2. 如果涉及接口签名,可根据实际情况添加方法。

代码实现

上面的思路也是在写的过程慢慢思考的,目前按照这个实现项目已经持续运行了一段时间,主要是第二步内部转换工程已经实现了,倒省了不少事。

下面为实现代码,可能有瑕疵,欢迎各同学指正。

debugtalk调用方法

其中request为HttpRunner内置的请求对象,可对请求进行前置处理

def json_proto(request):
    """
    序列化request的入参json
    :param request: 接口请求对象
    :return: 序列化后的接口请求对象
    """
    if request["method"] == "POST":
        if 'data' in request and request["data"]:
            origin_json = json.dumps(request["data"]).replace("'", "\"")
            print("INF: 原始json为", origin_json)
            request["data"] = ProtoDataFormat().get_proto_data(request["url"], origin_json, "request")
            print("INF: 最后json为", request["data"])
    if request['method'] == 'GET':
        if 'params' in request and request["params"]:
            origin_json = json.dumps(request["params"]).replace("'", "\"")
            print("INF: 原始json为", origin_json)
            params = ProtoDataFormat().get_proto_data(request["url"], origin_json, "request")
            request['params'] =  "bbValue=" + params
            print("INF: 最后json为", request["params"])
    return request 

转换类

# coding: utf-8
import base64
import importlib
import google.protobuf.json_format as json_format
import json
import re
import sys

sys.path.append("./subModuleForPB/b-python")

class ProtoDataFormat:
    def __init__(self):
        self.pwd = r"./subModuleForPB/b-js/mock/index.json"
        try:
            self.read_json(self.pwd)
        except:
            print("当前目录无index.json,尝试更改pwd路径属性")


    def get_proto_data(self, url, paramer, type):
        """
        主要实现功能是 json格式数据转成pb格式
        :param url: 传入的接口Url,去掉域名IP地址
        :param paramer: 传入的json格式的原始字符串值
        :return: json数据转成pb协议格式并Base64的数据
        """
        print("INF: json字符串开始预处理", paramer)
        if type =="request":
            type = "requestMessage"
        elif type =="response":
            type = "responseMessage"
        else:
            return "type不合法"
        # 兼容url多余的/路径符
        # if url[0] =="/":
        #     url = url[1:]
        # 查找对应url的message正则
        pattern1 = ".*{(.*?), \"url\": \"" + url + "\""
        # 读取index.json文件,转为json字符串
        load_str = self.read_json(self.pwd)
        # print("INF: json字符串开始预处理2")
        # 提取json中的message信息
        relt = self.re_str(pattern1, load_str)
        if relt:
            proto_message = relt[type]
        else:
            print("Error: 提取json中的message信息失败 ", relt)
        # 查找pb2文件的path正则
        pattern2 = ".*{(.*?), \"name\": \"" + proto_message + "\""
        # 提取json中的pb2文件的path信息
        path = self.re_str(pattern2, load_str)
        if path:
            # 修改pb2文件的path为导入模块格式
            mod_path = self.path_module(path["path"])
        else:
            print("Error: 提取json中的pb2文件的path信息失败 ", path)
        print("INF: json字符串预处理完成")
        return self.json_proto_base64(paramer, mod_path, proto_message[17:])


    def get_json_data(self, url, paramer, type):
        """
        主要实现功能是 json格式数据转成pb格式
        :param url: 传入的接口Url,去掉域名IP地址
        :param paramer: 传入的pb格式的原始字符串值
        :return: pb协议格式数据转成json的数据
        """
        if type =="request":
            type = "requestMessage"
        elif type =="response":
            type = "responseMessage"
        else:
            return "type不合法"
        # 查找对应url的message正则
        pattern1 = ".*{(.*?), \"url\": \"" + url + "\""
        # 读取index.json文件,转为json字符串
        load_str = self.read_json(self.pwd)
        # 提取json中的message信息
        relt = self.re_str(pattern1, load_str)
        if relt:
            proto_message = relt[type]
        else:
            print("Error: 提取json中的message信息失败 ", relt)
        # 查找pb2文件的path正则
        pattern2 = ".*{(.*?), \"name\": \"" + proto_message + "\""
        # 提取json中的pb2文件的path信息
        path = self.re_str(pattern2, load_str)
        if path:
            # 修改pb2文件的path为导入模块格式
            mod_path = self.path_module(path["path"])
        else:
            print("Error: 提取json中的pb2文件的path信息失败 ", path)
        paramer = base64.b64decode(paramer)

        return self.proto_json(paramer, mod_path, proto_message[17:])

    def proto_json(self, orginjson, path, message):
        """
        主要实现功能是 pb格式数据转成json格式
        :param orginjson: 传入的json格式的原始字符串值
        :param path: 需要导入的model路径,特指xxx_py2文件
        :param message: message,特指xxx_py2文件中对应的接口message
        :return: pb协议格式转成的json数据
        如:message = my_proto_pb2.MyMessage(foo='bar') json_string = json_format.MessageToJson(message)
        """
        try:
            foo = importlib.import_module(path)
        except:
            raise ModuleNotFoundError("error: 模块导入失败,尝试修改源文件sys.path的b-python文件夹路径")
        fun = eval("foo." + message)
        mes = fun()

        mes.ParseFromString(orginjson)
        return json_format.MessageToJson(mes)

    def json_proto_base64(self, orginjson, path, message):
        """
        主要实现功能是 json格式数据转成pb格式
        :param orginjson: 传入的json格式的原始字符串值
        :param path: 需要导入的model路径,特指xxx_py2文件
        :return: json数据转成pb协议格式并Base64的数据
        """
        try:
            foo = importlib.import_module(path)
        except:
            print("Error: 模块导入失败,尝试修改源文件sys.path的b-python文件夹路径")
        fun = eval("foo."+ message)
        print("INF: json字符串开始转换PB")
        try:
            mes = json_format.Parse(orginjson, fun())
            buffer = mes.SerializeToString()
        except:
             raise Exception("Error: json格式化失败,请检查入参格式")
        else:
            print("INF: json_proto_base64主函数执行通过")
            return base64.b64encode(buffer).decode(encoding = "utf-8")

    def read_json(self, pwd):
        """
        读取index.json文件,并返回对应json字符串
        :param pwd: 传入的index.json文件路径
        :return: index.json内容json字符串的全部数据
        """
        with open(pwd, "r") as load_f:
            load_dict = json.load(load_f)
        return json.dumps(load_dict)

    def re_str(self, pattern, str):
        """
        json字符串根据正则取出接口对应的message信息
        :param pattern: 传入的正则表达式
        :param str: 需要查找的原始字符串
        :return: 接口对应的message字典,如{"name": "DecrVirtual","requestMessage": "billion.protobuf.BDecrVirtualRequest","responseMessage": ".google.protobuf.Empty","method": "POST"}
        """
        search_obj = re.search(pattern, str)
        lis = []
        if search_obj:
            for i in search_obj.groups():
                i = "{"+ i +"}"
                lis.append(i)
        if lis:
            dic = json.loads(lis[0])
            return dic
        else:
            return {}

    def path_module(self, path):
        """
        修改path为模块路径,替换"\"为".",加上"_pb2"
        :param path: index.json中接口文件路径
        :return: 可以导入的xxx_pb2文件的module路径
        """
        path = str(path).strip("/")
        new_path = path.replace("/",".")
        new_path = new_path[:-5]
        return new_path + "_pb2"


if __name__ == '__main__':
    """
    调用 get_json_data() 方法完成 pb协议的数据转成json格式
    调用 get_proto_data() 方法完成 json格式的数据转换成pb协议的数据
    """
    orginjson = '{"pageInfo": {"pageNo": 1, "pageSize": 10}, "topicInfo": {}, "searchType": "B_SEARCH_TYPE_NEW"}'
    apiUrl = "/fleaTopic/topic/v1/releaseTopic"
    # pbvalue = "Ci0QATABUicKFBIM57u/6Imy6aOf5ZOBIAEwAzgDEgcoATDuBUAKIgYKBG51bGw="
    # jp.pwd = r"D:\b-js\mock\index.json"
    type = "request"
    bbvalue = ProtoDataFormat().get_proto_data(apiUrl, orginjson, type)
    print(bbvalue)
    # bbjson = ProtoDataFormat().get_json_data(apiUrl, pbvalue, type)
    # print(bbjson)