介绍如何基于Shell编写一个简单的测试框架。

参与过服务端的后台开发和测试的同学对服务器压力测试应该都不陌生了。为了对线上服务进行模拟测试,往往需要编写自动化的测试工具。一个常见的原型通常是这样的:

  1. 从指定地址下载待测的服务器程序,完成本地化配置和部署;
  2. 使用事先构造好的压力词表生成一系列的请求,并以指定的速率(QPS)向服务器发送这些请求;
  3. 解析服务器的日志,统计压力测试结果。

当然,实际上的测试环境可能更加复杂。比如,有些服务还要防止同一个 ip 地址在短时间内发出大量请求,相应的就要通过伪造 ip 等手段覆盖这种 case 。但“万变不离其宗”,基本的流程不会有太大的改动。

无需借助其他语言,以上的工作其实只需用 Linux 自带的 Shell 就可以实现了。这给大多数 Linux 服务器开发测试人员所带来的好处就是完全轻量级,省去了配置开发环境的环节。本文就围绕如何基于 Shell 编写一个简单的测试框架,完成上面的所有工作。

待测服务器程序

假定我们有一个名为 myserver 的待测服务器,该服务器程序的打包文件位于 ftp://whatever.com/myserver-1.0.tar.gz 。程序的目录结构如下:

1
2
3
4
5
6
\-- myserver-1.0
|-- myserver
\-- conf
|-- server.conf
\-- log
|-- server.log

其中, myserver 文件是服务器的可执行程序, conf/server.conf 存放着服务器的相关配置,而 log/server.log 则存放服务器在运行过程中的日志。为了简化问题,server.conf 里只有一个配置:

server.conf
1
port: 4000

即服务器占用的端口号。

这个服务器程序在执行时,会先读取 server.conf 里头的配置参数,然后在运行过程中处理来自客户端的请求,并生成日志到 server.log 里。

待测程序下载和本地化配置

根据上一节的描述,我们已经对待测的服务器程序有了基本的了解。这一节我们可以编写代码实现第一步的工作。

配置文件

首先让我们思考一下我们自己的测试工具可以提供哪些配置参数:

  • PACKAGE_PATH: 待测程序的下载地址。
  • WORDS_PATH: 压力词表的下载地址。
  • QPS:每秒钟发送的请求次数。
  • RESULT_PATH: 存放压力测试结果的地址。
  • RESPONSE_PATH:存放服务器返回的请求结果。

当然,还可以把服务器的端口号也作为一个配置项。但后面将使用一个交互式的方案,允许用户在运行测试时再指定端口号。这样的好处是可以动态判断默认的端口号是否被占用了,而让用户指定一个新的端口号。

将这几个配置项写成一个配置文件 tester.conf 中:

tester.conf
1
2
3
4
5
6
7
8
9
10
# 被测程序包地址
PACKAGE_PATH="ftp://whatever.com/myserver-1.0.tar.gz"
# 压力词表的下载地址
WORDS_PATH="ftp://whatever.com/myserver-words.txt"
# 每秒钟发送的请求次数
QPS=800
# 结果存储地址
RESULT_PATH="./log/result.log"
# 服务器返回结果
RESPONSE_PATH="./log/response.log"

注意上面的等号 = 两边不要加空格,因为后面将直接使用 source 命令读入配置。

参数处理

完成后,我们开始编写 tiny_tester.sh:

tiny_tester.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 如果要记录运行信息
# set -x
# 强制管道命令出错退出
set -o pipefail
clear
VERSION_NUM=1.0 # 版本号
PID=$$ # 本程序的进程PID
CUR_PATH=$PWD # 当前路径
TMP_PATH=/tmp/tiny_tester # 临时文件存放路径
# 打印帮助信息
function usage
{
echo "OVERVIEW: 一个简易测试框架" >&2
echo "" >&2
echo "USAGE: $0 [options]" >&2
echo "" >&2
echo "OPTIONS:" >&2
echo -e "-c\t\t\t\t\t配置文件的位置,默认为 tester.conf" >&2
echo -e "-v\t\t\t\t\t打印版本信息" >&2
echo -e "-h\t\t\t\t\t打印帮助信息" >&2
echo "" >&2
echo "EXAMPLES:" >&2
echo "$0" >&2
echo "$0 -c myconfig.conf" >&2
echo "" >&2
}
# 解析控制命令行参数
CONFIG_FILE="${CUR_PATH}/tester.conf"
while getopts c:vh OPT
do
case $OPT in
c|+c)
CONFIG_FILE=${OPTARG}
;;
v|+v)
echo "VERSION: ${VERSION_NUM}" >&2
exit 0
;;
h|+h)
usage
exit 0
;;
*)
usage
exit 1
;;
esac
done

上面的程序首先做了一些初始化的工作,然后定义了一个 usage 函数用于打印帮助信息:

1
2
3
4
5
6
7
8
9
10
11
12
OVERVIEW: 一个简易测试框架
USAGE: tiny_tester.sh [options]
OPTIONS:
-c 配置文件的位置,默认为 tester.conf
-v 打印版本信息
-h 打印帮助信息
EXAMPLES:
mini_tester.sh
mini_tester.sh -c myconfig.conf

读取配置文件

配置文件的读取比较简单:

1
source ${CONFIG_FILE}

不过,为了保证程序的鲁棒性,最好在执行这一步前先检查配置文件是否存在:

1
2
3
4
5
if [ ! -f ${CONFIG_FILE} ]
then
echo "ERROR: 文件 ${CONFIG_FILE} 不存在" >&2
fi
source ${CONFIG_FILE}

在接下来的工作中,肯定少不了诸如文件是否存在、目录是否存在、端口号是否被占用、下载是否成功等判断,为了方便,可以单独编写一个模块 lib/check_helper.sh ,提供一些必要的检查操作和出错处理函数。例如,我们可以先创建一个 check_file 函数,用来检查文件是否存在:

check_helper.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
##! 检查文件是否存在
##! @TODO:
##! @AUTHOR: panweizhou
##! @VERSION: 1.0
##! @IN: $1 => file
function check_file()
{
file=$1
if [ ! -f ${file} ]
then
echo ""
echo "ERROR: 文件 ${file} 不存在" >&2
exit 1
fi
}

于是我们可以把刚刚的配置文件读取改写为:

tiny_tester.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 读取辅助函数
echo -n "检查函数库..."
if [ ! -f ./lib/check_helper.sh ]
then
echo ""
echo "ERROR:文件 ./lib/check_helper.sh 不存在" >&2
fi
source ./lib/check_helper.sh
# 读取配置文件
check_file ${CONFIG_FILE}
# 读取配置参数
source ${CONFIG_FILE}
# 处理两个自定义的存放地址
RESPONSE_PATH_DIR=$(dirname ${RESPONSE_PATH})
RESULT_PATH_DIR=$(dirname ${RESULT_PATH})
# 如果所在目录不存在,创建之
create_dir ${RESPONSE_PATH_DIR} || create_dir_err
create_dir ${RESULT_PATH_DIR} || create_dir_err
# 获得两个文件的绝对地址
RESPONSE_PATH=$(abspath ${RESPONSE_PATH})
RESULT_PATH=$(abspath ${RESULT_PATH})
# 清空两个文件的内容
echo -n "" > ${RESPONSE_PATH}
echo -n "" > ${RESULT_PATH}

这样,主函数中只需先检查并读入一次 check_helper.sh ,以后涉及到文件读入都可以先使用 check_file 函数检查文件是否存在,再读取文件。

注意 create_dir 、create_dir_err、abspath 都是自定义的函数,分别用来检查和创建文件夹、处理创建文件夹错误和获取文件的绝对路径。这几个函数的实现会在下一节介绍。

下载待测程序包

完成配置参数的读取后,我们可以开始编写下载模块,所使用的命令是 wget 。为了方便,我们可以编写另一个模块 lib/utils.sh ,存放实现文件下载等操作的函数。

utils.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
##! 使用 wget 下载文件
##! @TODO:
##! @AUTHOR: panweizhou
##! @VERSION: 1.0
##! @IN: $1 => url
##! @OUT: 0 => success; 1 => failure
function download()
{
check_arg_num $# 1 # 检查参数数量
local url=$1
wget -q $url
return $?
}
##! 创建文件夹
##! @TODO:
##! @AUTHOR: panweizhou
##! @VERSION: 1.0
##! @IN: $1 => dir
##! @OUT: 0 => success; 1 => failure
function create_dir()
{
check_arg_num $# 1
local dir=$1
if [ ! -d {dir} ]
then
mkdir -p ${dir} && return 0 || return 1
fi
return 0
}
##! 获取文件的绝对路径
##! TODO:
##! @AUTHOR: panweizhou
##! @VERSION: 1.0
##! @IN: $1 => path
##! @OUT: 0 => success; 1 => failure
function abspath()
{
local path=$1
local dir="$(dirname "${path}")"
if [ ! -d ${dir} ]
then
return 1
fi
cd ${dir}
printf "%s/%s\n" "$(pwd)" "$(basename "${path}")"
return 0
}

其中,check_arg_num 函数用于检查函数传入参数的数量,可以把它写到 check_helper.sh 中,同时还可以编写 download_err 处理下载失败的情况:

check_helper.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
##! 检查传入参数数量
##! @TODO:
##! @AUTHOR: panweizhou
##! @VERSION: 1.0
##! @IN: $1 => real_num
##! @IN: $2 => formal_num
function check_arg_num()
{
if [ $# -ne 2 ]
then
echo ""
echo "ERROR: 需要 3 个传参但只提供了 $# 个" >&2
exit 1
fi
local real_num=$1
local formal_num=$2
if [ ${formal_num} -ne ${real_num} ]
then
echo "ERROR: 需要 ${formal_num} 个参数但只提供了 ${real_num} 个" >&2
exit 1
fi
}
# 处理文件下载失败
function download_err()
{
check_arg_num $# 1
echo ""
echo "ERROR: 文件 $1 下载失败" >&2
exit 1
}
# 处理文件夹创建失败
function create_dir_err()
{
check_arg_num $# 1
echo ""
echo "ERROR: 文件夹 $1 创建失败" >&2
exit 1
}

完成之后,我们就可以在我们的 tiny_tester.sh 中例用这几个函数实现程序文件和词表文件的下载:

tiny_tester.sh
1
2
3
4
5
6
7
check_file ./lib/utils.sh
source ./lib/utils.sh
create_dir ${TMP_PATH} || create_dir_err
cd ${TMP_PATH}
download ${PACKAGE_PATH} || download_err ${PACKAGE_PATH}
download ${WORDS_PATH} || download_err ${WORDS_PATH}

紧接着可以解压程序的压缩包:

tiny_tester.sh
1
2
3
PROJECT_FILE=$(basename ${PACKAGE_PATH}) # 待测程序压缩包的文件名
PROJECT_NAME=$(basename ${PACKAGE_PATH} .tar.gz) # 待测程序包解压后的目录名
tar -xzf ${PROJECT_FILE} || unachive_err

类似的,unachive_err 用于处理文件解压失败:

check_helper.sh
1
2
3
4
5
6
7
8
# 处理文件解压失败
function unarchive_err()
{
check_arg_num $# 1
echo ""
echo "ERROR: 文件 $1 解压失败" >&2
exit 1
}

服务器本地化配置

接下来对服务器进行本地化配置。示例程序的配置项很简单,只有一个端口参数。我们可以编写一个交互式的配置方式:允许用户在运行测试时指定要使用的端口号。当检测到端口号被占用时,再次提示用户指定一个新的端口号。

我们先实现一个端口占用检查的函数 port_query 。原理很简单,就是判断 netstat -an 命令返回的结果中是否存在用户指定的端口号:

check_helper.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 判断指定端口号是否已被占用
function port_query()
{
check_arg_num $# 1
local port=$1
netstat -an | awk '/^tcp/ {print $4;}' | grep ${port} > /dev/null 2>&1
return $?
}
# 处理本地化配置失败
function localize_config_err
{
echo ""
echo "ERROR: 本地化配置失败" >&2
exit 1
}

之后,继续编写一个函数 localize_config_port 用来完成端口号配置:

utils.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
##! 本地化配置 - 配置端口号
##! @TODO: 加入端口必须是纯数字的验证
##! @AUTHOR: panweizhou
##! @VERSION: 1.0
##! @IN: $1 => config_file
##! @OUT: 0 => success; 1 => failure
function localize_config_port()
{
check_arg_num $# 1
local config_file=$1
local port=$(grep '^port' ${config_file} | awk -F':' '{print $2;}' | tr -d ' ')
local done_flag=0
local new_port
while [ ${done_flag} -ne 1 ]
do
echo -n "输入你希望使用的端口号(默认为${port}):"
read new_port
if [ -z ${new_port} ]
then
new_port=${port}
fi
# 检查端口是否被占用,如果是,则修改端口号
port_query ${new_port}
if [ $? -eq 0 ]
then
echo "WARNING: 端口号 ${new_port} 已被占用" >&2
else
# 将结果写入配置文件
echo "port: ${new_port}" > ${config_file}
return $?
done_flag=1
fi
done
return 0
}

该函数传入配置文件的存放路径,并读取里头的配置作为默认端口号。之后询问用户给定一个新的端口号。当检测到端口号已被占用时,就提醒用户重新选择其他端口号。

到此可以完成服务器的配置:

tiny_tester.sh
1
2
3
4
# 对 port 和 data_path 两项配置进行本地化
PROJECT_PATH=${TMP_PATH}/${PROJECT_NAME}
CONFIG_FILE=${PROJECT_PATH}/conf/server.conf # 待测服务器程序配置文件
localize_config_port ${PROJECT_PATH} || localize_config_err

压力测试

完成服务器的本地部署后,压力测试就是在本地启动服务器,然后向其发送请求的过程。发送请求主要用到的工具是 curl 命令。

tiny_tester.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 后台启动待测服务器程序
echo -n "启动待测服务器..."
cd ${PROJECT_PATH}
./bin/mini_http_server &
if [ $? -ne 0 ]
then
server_run_err
fi
# 获取服务器的pid
SERVER_PID=$!
echo " OK (PID: ${SERVER_PID}; PORT: ${PORT})"
# 读取请求数据,每次一行
# 向服务器发送请求
WORDS_FILE=${TMP_PATH}/$(basename ${WORDS_PATH})
echo -n "以 ${QPS}qps 向测试服务器发送请求 "
while read QUERY
do
QUERY="http://127.0.0.1:${PORT}${QUERY}"
# 发送请求,并将结果写入文件
curl --retry 3 -s ${QUERY} >> ${RESPONSE_PATH}
if [ ${QUERY_PERCENT} -eq 0 ]
then
echo -n "*"
fi
sleep ${INTERVAL}s
done < ${WORDS_FILE}
echo " OK"

上面的例子用的请求是 get 请求。而对于 post 请求,可以使用 curl -d ,后面跟着几个请求参数和 url 即可。

如果词表数量太大,整个过程可能耗时比较长,所以可以改一下上面的代码第 12 行之后的部分,实现一条进度条:

tiny_tester.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 读取请求数据,每次一行
# 向服务器发送请求
WORDS_FILE=${TMP_PATH}/$(basename ${WORDS_PATH})
QUERY_NUM=$(wc -l ${WORDS_FILE} | awk '{print $1}')
QUERY_BASE=$(expr ${QUERY_NUM} / 10)
QUERY_COUNTER=0
echo -n "以 ${QPS}qps 向测试服务器发送请求 "
while read QUERY
do
QUERY="http://127.0.0.1:${PORT}${QUERY}"
# 发送请求,并将结果写入文件
curl --retry 3 -s ${QUERY} >> ${RESPONSE_PATH}
QUERY_COUNTER=$(expr ${QUERY_COUNTER} + 1)
QUERY_PERCENT=$(expr ${QUERY_COUNTER} % ${QUERY_BASE})
if [ ${QUERY_PERCENT} -eq 0 ]
then
echo -n "*"
fi
sleep ${INTERVAL}s
done < ${WORDS_FILE}
echo " OK"

这样,每达到 10 个百分比,就会在终端中打印一个 * 字符,直到打完 10 个 *,即进度达到 100% 结束为止。

解析服务器日志

完成所有的请求发送和接收后,可以通过分析服务器的日志来统计成功率等信息。假定 myserver 的日志格式如下:

1
2
NOTICE: 01-16 17:28:17: myserver [src/worker.cpp:162]ip=127.0.0.1 succ=1 method=GET url=/whatever/xxx name=i94Q8o8 id=29279 value=1048327232.000000
NOTICE: 01-16 17:32:02: myserver [src/worker.cpp:162]ip=127.0.0.1 succ=1 method=GET url=/whatever/yyy name=TswcjgPDvzkaiY id=54015 value=806124928.000000

假定我们需要统计以下几个结果 1 1这里列举的几个项目只是示例,在实际的项目中,根据需求的不同,所需要统计的项目也不同。此外,不同的服务器程序日志格式不同,所记录的数据也千差万别。所以需要具体问题具体分析。

  • value_avg:value平均值,那些value字段为空的日志不参与统计
  • succ_rate:成功率,请求成功数在请求总数中的占比
  • name_num:name总数(相同的name只计算一次)

对日志的解析,最方便的工具是利用 awk 。我们可以编写一个 awk 脚本 lib/log_parser.awk 专门进行日志解析:

log_parser
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
##! 用于日志解析的 awk 脚本
##!@TODO:
##! @VERSION: 1.0
##! @AUTHOR: panweizhou
##! @PREV: ./mini_tester.sh
BEGIN {
query_num = 0
succ_num = 0
total_value = 0
name_num = 0
name_array[1] = 0
}
{
if (NF == 10) {
query_num = query_num + 1
split($5, succ_cell, "=")
if (succ_cell[2] == "1") {
# 成功返回数据,则成功次数加1
succ_num = succ_num + 1;
# 累加 value
split($10, value_cell, "=")
total_value = total_value + value_cell[2]
# 统计 name 次数,并避免重复
duplicate_flag = 0
split($8, name_cell, "=")
for (name in name_array) {
if (name == name_cell[2]) {
duplicate_flag = 1
}
}
if (duplicate_flag == 0) {
name_num = name_num + 1
name_array[name_num] = name_cell[2]
}
}
}
}
END {
printf "value_avg=%f\n", total_value / succ_num
printf "succ_rate=%f\n", succ_num / query_num
printf "name_num=%d\n", name_num
}

可以直接在我们的 tiny_tester.sh 中通过 awk -f 调用上面的脚本:

tiny_tester.sh
1
2
3
4
5
6
7
8
LOG_FILE=${PROJECT_PATH}/log/server.log # 待测服务器程序日志文件
check_file ${LOG_FILE}
awk -f ${CUR_PATH}/lib/log_parser.awk ${LOG_FILE} >> ${RESULT_PATH}
echo "-------------------------"
echo "测试结果"
cat ${RESULT_PATH}
echo "-------------------------"

生成的报表如下所示:

1
2
3
4
5
6
-------------------------
测试结果
value_avg=123.123
succ_rate=0.83
name_num=7654
-------------------------

到此,我们的 tiny_tester.sh 的主要任务就完成了。最后别忘了做一些收尾工作,清除临时文件,并结束服务器进程:

tiny_tester.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 清除临时文件
echo ""
echo -n "清除临时文件..."
if [ -d ${TMP_PATH} ]
then
rm -r ${TMP_PATH}
fi
echo " OK"
# 结束服务器进程
echo -n "关闭服务器..."
kill -2 ${SERVER_PID} > /dev/null || server_kill_err
echo " OK"
cd ${CUR_PATH}
echo ""
echo "测试完成。再见!"
echo ""