Write a Tiny Shell-based Test Framework

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

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

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

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

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

待测服务器程序

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

\-- myserver-1.0
  |-- myserver
  \-- conf
    |-- server.conf
  \-- log
    |-- server.log

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

server.conf

server.conf
1
port: 4000

即服务器占用的端口号。

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

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

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

配置文件

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

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

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

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

tester.conf

# 被测程序包地址
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:

# 如果要记录运行信息
# 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 函数用于打印帮助信息:

OVERVIEW: 一个简易测试框架
USAGE: tiny_tester.sh [options]
OPTIONS:
-c			配置文件的位置,默认为 tester.conf
-v			打印版本信息
-h			打印帮助信息
EXAMPLES:
mini_tester.sh
mini_tester.sh -c myconfig.conf

读取配置文件

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

	
source ${CONFIG_FILE}

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

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

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

check_helper.sh

##! 检查文件是否存在
##! @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

# 读取辅助函数
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

##! 使用 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

##! 检查传入参数数量
##! @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

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

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

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

check_helper.sh

# 处理文件解压失败
function unarchive_err()
{
    check_arg_num $# 1
    echo ""
    echo "ERROR: 文件 $1 解压失败" >&2
    exit 1
}

服务器本地化配置

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

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

check_helper.sh

# 判断指定端口号是否已被占用
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

##! 本地化配置 - 配置端口号
##! @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

# 对 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

# 后台启动待测服务器程序
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

# 读取请求数据,每次一行
# 向服务器发送请求
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 的日志格式如下:

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

##! 用于日志解析的 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

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 "-------------------------"

生成的报表如下所示:

-------------------------
测试结果
value_avg=123.123 
succ_rate=0.83
name_num=7654
-------------------------

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

tiny_tester.sh

# 清除临时文件
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 ""

原文发布于微信公众号 - HaHack(gh_12d2fe363c80)

原文发表时间:2015-01-16

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏進无尽的文章

Java工具篇| Eclipse 常用快捷键

古人云:工欲善其事,必先利其器。快键键作为开发中及其常用的节省开发时间提升效率的方式之一,其重要性不言而喻,也许你可以不使用它,但是当你熟练使用、巧妙使用这些快...

662
来自专栏偏前端工程师的驿站

Chrome Extension in CLJS —— 搭建开发环境

922
来自专栏偏前端工程师的驿站

Chrome Extension in CLJS —— 搭建开发环境

前言  磨刀不误砍柴工,本篇将介绍如何搭建Chrome插件的ClojureScript开发环境。 具体工具栈:vim(paredit,tslime,vim-c...

2496
来自专栏静晴轩

How to Use ADB Command Line Tool

How to Use Android ADB Command Line Tool Android Debug Bridge (adb) is a tool th...

3275
来自专栏Android开发指南

eclipse遇到的问题

33910
来自专栏苍云横渡学习笔记

【Django】在项目中配置Mailgun并发送邮件

【本文目录】 注册Mailgun并添加新域名 添加发送DNS解析记录 添加追踪DNS解析记录 等待域名验证 配置settings.py Django自带邮件功能...

3879
来自专栏用户2442861的专栏

google glog 使用方法

glog官方地址:https://code.google.com/p/google-glog/

843
来自专栏闻道于事

IDEA使用

最重要的: Ctrl+Shift+A  打开搜索 定位代码: 项目之间的跳转: ? 文件之间的跳转: 打开最近文件列表  Ctrl + E     打开最近修改...

2694
来自专栏james大数据架构

分布式监控系统Zabbix3.2给异常添加邮件报警

  在前一篇 分布式监控系统Zabbix3.2跳坑指南 中已安装好服务端和客户端,此处客户端是被监控的服务器,可能有上百台服务器。监控的目的一个是可以查看历史状...

2069
来自专栏Danny的专栏

Navicat备份远程Oracle数据库到本地

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/huyuyang6688/article/...

802

扫码关注云+社区