性能测试工具 Go 实现 json 格式定义 http 协议压测脚本

bugVanisher · 2021年02月05日 · 最后由 bugVanisher 回复于 2022年07月14日 · 8545 次阅读
本帖已被设为精华帖!

前段时间,我主导推动组里实现了一套基于 Locust+boomer 的通用的压测平台,主要目的是满足我们组内的各种压测场景,比如 grpc、websocket、webrtc、http 等协议的压测场景。正好我们公司的技术栈以 go 为主,我们可以轻松地使用 go 编写脚本,通过公司的部署平台编译打包后横向扩缩施压集群,可以说解决了各种压测需求。但是我们发现,尽管自己编写脚本非常自由,但是对不了解平台、不了解 Go 的同学来说,使用成本是比较大的,尤其是首次接触,因此我开始思考如何简化脚本的编写和部署。

从 http 开始

来源于 httprunner 和公司其他 Group 的工具的灵感,我想到用 json 的方式去定义 http 压测场景,然后用 go 去解析执行,可以预见的是这种方式的压测性能不如直接写代码,但是如果可以通过可接受的性能损耗来换取更简单的接入方式,更统一的使用方式,也是极好的,毕竟我们不缺机器。针对 http 协议,以下大概梳理了一下需要实现的能力。

简单说明下:

  • 多接口

很好理解,压测的时候需要满足多个接口按一定的比例同时压测。在某些特殊场景下,可能还存在接口依赖的问题,这也要考虑到。

  • 登录态

压测的接口可能有登录校验,那么压测的时候需要带上登录态,如果能打通账号平台自动批量生成登录态就方便很多了。

  • 参数化

定义的脚本需要提供参数化能力,总不能所有参数写死,比如动态生成时间戳,ID,变长字符串等等,如果简单的参数生成无法满足,用户自己上传也是挺好的。

  • 校验

响应内容校验是接口测试很重要的部分,在压测场景也是一样的。

定义 Json 结构

接下来,定义 Json 结构,尽量去满足上面所描述的需求。http 协议,无非就是三个部分,body、header、url,因此每一个接口需要包含这个三个字段,当然,名字是必不可少的,还有一个非常重要的字段,就是校验字段 validator,下面就来看看这个 Json 应该是什么样子。

{
    "debug": true,
    "domain": "https://postman-echo.com",
    "header": {},  
    "declare": [
        "{{ $sessionId := getSid }}"
    ],
    "init_variables": {
        "roomId": 1001,
        "sessionId": "{{ $sessionId }}",
        "ids": "{{ $sessionId }}"
    },
    "running_variables": {
        "tid": "{{ getRandomId 5000 }}"
    },
    "func_set": [
        {
            "key": "getTest",
            "method": "GET",
            "url": "/get?name=gannicus&roomId={{ .roomId }}&age=10&tid={{ .tid }}",
            "body": "{\"timeout\":10000}",
            "validator": "{{ and  (eq .http_status_code 200) (eq .args.age (10 | toString )) }}"
        },
        {
            "key": "postTest",
            "method": "POST",
            "header": {
                "Cookie": "{{ .tid }}",
                "Content-Type": "application/json"
            },
            "url": "/post?name=gannicus",
            "body": "{\"timeout\":{{ .tid }}, \"retry\":true}",
            "validator": "{{ and  (eq .http_status_code 200) (eq .data.timeout (.tid | toFloat64 ) ) (eq .data.retry false) }}"
        }
    ]
}

func_set 应该挺好理解的,这里解释一下 declare、init_variables、running_variables:

  • declare 这个字段是为了声明变量的,比如在 init 或 running 变量中都可以引用这个变量,声明方式如:
[
  "{{ $sessionId := getSid }}",
  "{{ $id := 100100 }}"
]
  • init_variables

初始化变量,只初始化一次,可以是常量,也可以从模板函数中获取,如:

{
        "roomId": 1001,
        "sessionId": "{{ $sessionId }}",
        "ids": "{{ now }}"
}
  • running_variables

运行时变量,每一个请求发起前都会去构造参数,因此不建议常量定义在这里。

{
      "tid": "{{ getRandomId 5000 }}"
}

解析流程

想要利用 boomer,那就需要想办法生成 boomer.Task,它的结构如下:

type Task struct {
    // The weight is used to distribute goroutines over multiple tasks.
    Weight int
    // Fn is called by the goroutines allocated to this task, in a loop.
    Fn   func()
    Name string
}

核心就是得到这个执行函数 Fn,思路就是分别根据 init 和 running 变量定义,为 func_set 中声明的每个请求分别定义一个匿名函数,函数中去动态生成变量,然后发起真实请求,最后根据每个请求声明的 validator 进行断言。整个执行流程如下:

实现原理

go 的原生库中就有模板相关的库 text/template,我直接使用模板库实现了这套解析逻辑,包括参数的生成,模板方法、断言,整个 json 脚本的语法都是基于 go 的模板库的。感兴趣的朋友可以查看:

如何断言

因为断言这部分非常重要,所以单独讲。上面已经说过断言是通过模板来实现的,所以要使用断言就要掌握基本的模板语法。

模板库内置了比较和逻辑方法所以可以直接使用,比如比较 http 状态码:

"validator": "{{ eq .http_status_code 200 }}"

再比如多个比较:

"validator": "{{ and  (eq .http_status_code 200) (eq .data.timeout (.tid | toFloat64 ) ) (eq .data.retry false) }}"

可能你也已经注意到了有一个 toFloat64, 它是一个模板自定义函数,这里是为了做类型转换。

此外,你也可以看到基于 go 模板库,访问变量变得非常简单,比如上面的.data.timeout,它对应响应中的内容类似如下:

{
    "data":{
        "timeout": 1000
    }
}

这样我们就可以比较响应 json 中的任何字段了。

写在最后

简单做了一下压力测试,对比不使用模板解析和使用模板解析的情况,模板解析在 CPU 密集型的场景下性能大概是直接写脚本编译的三分之一,如果不是 CPU 密集型应该可以去到二分之一,因此我暂时不优化了。令人惊喜的是这个模板解析也可以扩展成为接口测试的编写方式,类似 httprunner。

目前只是做了一个 Demo,还没正真集成到我们的压测平台,还是挺令人期待呀。

源码地址:https://github.com/bugVanisher/go-httpwrapper

共收到 9 条回复 时间 点赞

很棒!我本来也有一个这样的想法,没想到你们先实现了

花菜 回复

哈哈,我将持续更新,欢迎交流探讨😀

bugVanisher 回复

已经 star,期待更加完整的功能和文档

挺好的分享,字符串模板形式的。
boomer 刚出时玩了一会,mq 那层后的确比 locust 本身好。
set 那层测试任务,安利一种注册的写法。

func NewCatLocust() *boomer.Task {
    task := &boomer.Task{
        Weight: 1,
        Fn:     TaskList,
        Name:   "TestType+游戏名称",
    }
    return task
}

TaskList 内部通过 registerFunc(taskName string, f func() error) 注册到一个支持权重范围的 WeightedFuncs() Map 对象里面后,进行压测。

func registerFunc(taskName string, fc func() error) {
    st := time.Now()
    err := fc()
    if err != nil {
        boomer.RecordFailure("xxGame", taskName , e.Milliseconds(), err)
    } else {
                end := time.Since(st)
        boomer.RecordSuccess("xxGame", taskName , e.Milliseconds(), 0)
    }
}
恒温 将本帖设为了精华贴 02月07日 03:08

大佬你好,想请问一下,我分别在两个 ide 中运行 master 与 slave 的代码。但是 master 那边控制台提示:You are running in distributed mode but have no worker servers connected. Please connect workers prior to swarming. 实际上我已经把 slave 的代码运行起来了。请问这个是什么问题呢?

乐天 回复

最好能把 worker(slave) 那边的图贴出来,你是运行 go 版本的吧,默认是找本地 5557 端口的 master,你可以看看端口情况

bugVanisher 回复

上述问题已经解决了,目前是 boomer 包:Version 3.0 received does match expected version 3.1 的这个异常。我看 myzhan 的 issue 里面。也没说有啥解决方案。然后我更新 github.com/zeromq/goczmq 时会出现异常:fatal error: czmq.h: No such file or directory。 网上的解决方案说,这个包没有更新了,作者已经换成了 cppzmq 啥的。 额,目前遇到的情况就是这样。谢谢大佬的回复。

仅楼主可见
debugtalk 回复

娃刚出生,比较忙,以后有机会哈😀

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