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

bugVanisher · 2021年02月05日 · 最后由 陈子昂 回复于 2021年02月06日 · 1234 次阅读
本帖已被设为精华帖!

前段时间,我主导推动组里实现了一套基于 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

共收到 4 条回复 时间 点赞

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

花菜 回复

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

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月06日 19:08
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册