【得物技术】无侵入式mock平台在得物的实践

2,709 阅读13分钟

一、概述

1.1 背景介绍

作为测试应该都遇到过如下两大痛点:

1.只想测试被测系统A,却需要从依赖系统开始一层层造自己想要的测试数据,造数花费时间长,边界值及异常场景不好模拟。

2.接口自动化,UI自动化,埋点自动化因为服务或者测试数据的不稳定性导致自动化维护成本高。

要解决上述问题,基本都会想到mock。目前市面上有很多优秀的开源mock框架:Mockito、PowerMock、EasyMock、JMockit等,但这些框架对于我们现在的业务场景及主要是在集成测试过程中使用,显然不是我们想要的。因为我们希望在不改动开发代码的情况下支持随心所欲的构造mock接口的返回报文来测试不同的业务场景,基于这种外部依赖服务走http形式的技术架构,一套无侵入式的mock平台应运而生。

hulk是一个无侵入式的http mock平台,支持客户端代理,从网关层mock,支持后端服务之间的mock。支持返回报文函数配置,并且具备放行逻辑。未来还将支持filter,根据不同的入参返回不同的mock数据。

1.2 系统架构

基于Django + mitmproxy + vue + MongoDB + MySQL

目前整个技术架构比较简单,mock服务基于Django框架开发,代理层主要是在开源框架mitmproxy基础上做了二次开发打通和mock系统的交互,前端配置平台使用了公司的脚手架poizon-cli。数据存储主要用了MongoDB和MySQL,提高性能后续会考虑引入redis,将配置信息缓存到redis中降低接口响应时间。

1.2.1 服务端mock时序图

1.2.2 客户端mock时序图

二、mock服务

2.1 部署及性能

通过Nginx + Uwsgi + Django部署,支持高并发。

可以直接通过测试组jenkins构建部署。

部署脚本:

# 服务器项目地址
# shellcheck disable=SC2164
cd /home/dhk/workspace/hulk
python37 -m venv venv            # 生成虚拟环境
source venv/bin/activate            # 启动虚拟环境
python37 -m pip install --upgrade pip           # 升级pip
python37 -m pip install -r requirements.txt     # 安装依赖库
# shellcheck disable=SC2164
cd /home/dhk/workspace/hulk/hulk  #进到uwsgi.ini目录

# shellcheck disable=SC2006
# shellcheck disable=SC2009
# 获取uwsgi父进程
pid=`ps -ef | grep "uwsgi" | grep -v grep | awk '{print $2}' | awk 'NR==1{print}'`
if [ -n "$pid" ]
then
    uwsgi --reload uwsgi.pid
else
    uwsgi --ini uwsgi.ini
fi

性能:在4C8G的机器上的单机性能指标

2.2 框架设计原理

mock服务可以理解为一个类似于业务系统的应用,可以请求该服务的接口地址并返回对应的报文。并提供了前端配置功能,支持配置自定义的mock接口信息及放行接口对应的业务系统host映射关系。想要设计成一个支持动态接收自定义接口路径的mock服务,需要先了解Django处理请求的原理及路由配置。

2.2.1 Django 如何处理一个请求

先看一下Django框架处理请求的原理,熟悉该原理后,可以很好地利用这点来设计成一个mock服务所需要的支持自定义路由的功能。感兴趣的可以参照官方文档。

当一个用户请求Django 站点的一个页面,下面是Django 系统决定执行哪个Python 代码使用的算法:

Django 确定使用根 URLconf 模块。通常,这是 ROOT_URLCONF 设置的值,但如果传入 HttpRequest 对象拥有 urlconf 属性(通过中间件设置),它的值将被用来代替 ROOT_URLCONF 设置。
Django 加载该 Python 模块并寻找可用的 urlpatterns 。它是 django.urls.path() 和(或) django.urls.re_path() 实例的序列(sequence)。
Django 会按顺序遍历每个 URL 模式,然后会在所请求的URL匹配到第一个模式后停止,并与 path_info 匹配。
一旦有 URL 匹配成功,Djagno 导入并调用相关的视图,这个视图是一个Python 函数(或基于类的视图class-based view)。视图会获得如下参数:
一个 HttpRequest 实例。
如果匹配的 URL 包含未命名组,那么来自正则表达式中的匹配项将作为位置参数提供。
关键字参数由路径表达式匹配的任何命名部分组成,并由 django.urls.path() 或 django.urls.re_path() 的可选 kwargs 参数中指定的任何参数覆盖。
Changed in Django 3.0:
在旧版本里,带有 None 值的关键字参数也可以由未提供的命名部分组成。
5.如果没有 URL 被匹配,或者匹配过程中出现了异常,Django 会调用一个适当的错误处理视图。参照下面的错误处理( Error handling )。
https://docs.djangoproject.com/en/3.1/

2.2.2 路由配置

对于高质量的Web 应用来说,使用简洁、优雅的URL 模式是一个非常值得重视的细节。Django 允许你自由地设计你的URL,不受框架束缚。

这里用了Django框架的url正则表达式配置规则,达到了类似于动态注入接口地址的效果。这时候随便请求一个接口进来,会按顺序依次匹配,直到匹配到对应的path为止,除了web端的接口路径,其余的都会匹配上正则,例如请求:/rec/sns/du/ct_push_V2/recommend会进到view_mock.mock()函数中。

urlpatterns = [
    path('admin/', admin.site.urls),
    path('hulk/attention', view_attention.attention),
    path('hulk/check', view_attention.check),
    path('hulk/query_url_info', view_web.query_url_info),
    path('hulk/insert_url_info', view_web.insert_url_info),
    path('hulk/update_is_del_1', view_web.update_is_del_1),
    path('hulk/update_url_info_doc', view_web.update_url_info_doc),
    path('hulk/update_is_open', view_web.update_is_open),
    path('hulk/proxy_query_url_info', view_proxy.proxy_query_url_info),

    url(r'^(.*)$', view_mock.mock)
]

2.2.3 mock逻辑

根据上述路由配置的原理,业务系统所有请求被mock系统的接口都将走到mock()函数中,这里会先去读数据库的接口配置信息,如果该接口配置了mock那么直接把配置的response返回,没有配置mock时,会继续往下走,去查询该接口的路由配置信息,对应放行到被mock系统做正常的业务请求,相当于做了一层转发。

注:目前只做了常见的POST和GET请求方式的放行逻辑,POST请求的body类型也是默认json。
logger = logging.getLogger('log')
def mock(request, interface):
    logger.info(request.body)
    path = request.path
    # 查询mongodb
    data = MockApiInfo().query_url_info(path)
    if data:
        url_info = data[0]
        res = url_info['response']
        # 处理配置的返回报文
        response = JsonResponse(process_response.handle_variate(res))
        # 组装返回header
        if url_info.get('response_headers'):
            response_headers = json.loads(url_info['response_headers'])
            for k, v in response_headers.items():
                response.__setitem__(k, v)
        return response
    else:
        # 放行逻辑
        config = MockTransConfig().query_config(path)
        headers = request.headers
        if config:
            if request.method == 'POST':
                host = request.scheme + '://' + config[0]['host'] + path
                headers['Content-Type'] = 'application/json'
                res = requests.request(request.method, url=host, headers=headers,
                                       data=request.body)
                logger.info(res.json())
                return JsonResponse(res.json())
            elif request.method == 'GET':
                host = request.scheme + '://' + config[0]['host'] + request.get_full_path_info()
                res = requests.request(request.method, url=host, headers=request.headers)
                logger.info(res.json())
                return JsonResponse(res.json())
        else:
            response = JsonResponse({"code": 1001, "status": 200, "msg": '请先配置接口或者开启,当前接口路径:' + path})
            response.__setitem__("Content-Type", "application/json; charset=utf-8")
            return response

2.2.4 数据库设计

1.放行接口、host映射关系配置

这里选择的是关系型数据库mysql来存储配置信息。

字段说明:

path -> 接口路径

host -> 域名

bussiness -> 业务域

description -> 描述

2.接口信息配置,考虑到接口返回报文是json,很显然MongoDB比较适合

接口配置表mock_api_info

db.createCollection("mock_api_info")。

{
    "_id": ObjectId("5fcc546448bfde3202d2eaf4"),
    "url": "/rec/sns/du/ct_hot/recommend",
    "sys_name": "",
    "method": "",
    "content_type": "",
    "response_headers": "",
    "response": "{}",
    "rich_response": "",
    "description": "推荐流-算法",
    "is_del": "0",
    "is_open": "1",
    "add_time": "2020-12-06 11:47:48",
    "update_time": "2020-12-07 11:11:40"
}

字段说明:

url -> 接口路径

sys_name -> 系统名

method -> 请求方法:GET,POST。。。

content_type -> body类型:预留字段

response_headers -> 返回header头

response -> 返回报文

rich_response -> 富文本返回报文:预留字段

description -> 描述

is_del -> 是否删除:0:没有删除,1:删除

is_open -> 是否激活:0:关闭,1:打开

2.2.5 前端配置页面

2.3 如何mock服务端接口

2.3.1 配置需要mock的接口信息

1.在mock配置平台配置需要mock的接口信息。

如果需要自定义返回的header头,可以配置对应期望返回的header信息,都是json格式。没有配置会返回默认header头。

2.配置完验证,可以用接口测试工具比如postman请求配置好的接口,正常返回配置的报文说明配置正确。

例子:http://mock服务地址/rec/sns/du/ct_push_V2/recommend

3.修改服务端系统配置,下面列出了社区的两种配置。

2.3.2 Apollo配置

比如社区的go服务,依赖算法的接口,对应的请求域名都配置在Apollo上。

去修改需要mock的接口请求域名。

保存完,点击发布,然后重启对应的服务。

2.3.3 项目中配置

比如社区的php服务都是在项目中的environment.php配置文件。

直接通过跳板机在服务器上修改,jumpserver地址。

2.4 日志

通过跳板机连上root@dw-test-test-interfaces-01服务器。

进入/home/dhk/workspace/hulk目录。

实时日志可以查看tailf uwsgi.log

所有日志都保存在logs文件下。

三、代理层

3.1 常用的代理工具

测试过程经常用到app抓包工具比如:charles、fiddler、wireshark等。

上述列出的都是需要安装在自己电脑上的工具,但有些时候我们更希望有个代理服务器使用,比如玩过爬虫或者科学参与电商平台活动的同学工具库中都会有这么一款工具。这块目前有很多优秀的开源框架,比如whistle,支持写js脚本。还有anyproxy、mitmproxy等。

框架选择:这里我选择了mitmproxy这个开源框架,理由很简单因为它是Python实现的。。。考虑到要做二次开发。

在使用mock功能的场景中mitmproxy和Charles的对比

通过Charles进行mock:

优点:方便,不会相互影响

缺点:需要本地安装,mock的接口多了管理不方便,无法二次开发特性功能

使用hulk平台

优点:无需本地安装软件,配置完后,只要大家连上该代理服务都可使用,比较方便的支持UI自动化,埋点自动化的使用。并且支持很灵活的打开关闭mock。支持二次开发,可扩展性高。

缺点:共享一个代理服务,抓包信息需要自己过滤。

注:后续会支持userId的filter配置,就可以做到相互不影响

3.2 二次开发后的mitmproxy

3.2.1 介绍

目前该代理服务已经部署在内网服务器上,该框架非常强大,这里不做详细介绍。

Introduction
顾名思义,mitmproxy 就是用于 MITM 的 proxy,MITM 即中间人攻击(Man-in-the-middle attack)。用于中间人攻击的代理首先会向正常的代理一样转发请求,保障服务端与客户端的通信,其次,会适时的查、记录其截获的数据,或篡改数据,引发服务端或客户端特定的行为。

不同于 fiddler 或 wireshark 等抓包工具,mitmproxy 不仅可以截获请求帮助开发者查看、分析,更可以通过自定义脚本进行二次开发。举例来说,利用 fiddler 可以过滤出浏览器对某个特定 url 的请求,并查看、分析其数据,但实现不了高度定制化的需求,类似于:“截获对浏览器对该 url 的请求,将返回内容置空,并将真实的返回内容存到某个数据库,出现异常时发出邮件通知”。而对于 mitmproxy,这样的需求可以通过载入自定义 python 脚本轻松实现。

但 mitmproxy 并不会真的对无辜的人发起中间人攻击,由于 mitmproxy 工作在 HTTP 层,而当前 HTTPS 的普及让客户端拥有了检测并规避中间人攻击的能力,所以要让 mitmproxy 能够正常工作,必须要让客户端(APP 或浏览器)主动信任 mitmproxy 的 SSL 证书,或忽略证书异常,这也就意味着 APP 或浏览器是属于开发者本人的——显而易见,这不是在做黑产,而是在做开发或测试。

事实上,以上说的仅是 mitmproxy 以正向代理模式工作的情况,通过调整配置,mitmproxy 还可以作为透明代理、反向代理、上游代理、SOCKS 代理等。
https://www.cnblogs.com/H4ck3R-XiX/p/12624072.html

3.2.2 设计原理

一般mock客户端,直接拦截报文修改response即可。这里是当配置了需要mock的接口时,会把原来请求的接口域名改成mock服务的域名地址,从而使得客户端请求到mock服务,到达同样的效果。这样做完全隔离了和业务系统的交互。

mitmproxy 支持 mitmproxy、mitmdump、mitmweb 三个启动命令,这三个命令功能一致,且都可以加载自定义脚本,唯一的区别是交互界面的不同。这里考虑到保留前端抓包信息的展示,选用了mitmweb来启动,并且直接修改了底层源码来打通和mock服务的交互。

修改的核心代码片段如下,所有进来的请求都会先去判断是否需要mock,如果需要mock即直接将请求转发到mock系统,不需要mock的接口原路放行。

# 是否走mock逻辑
if hulk_api.proxy_query_url_info(path.decode("utf-8")) > 0:
    print(('命中mock,接口:' + str(path)).center(100, '='))
    host = config.HULK_HOST
    scheme = b'http'
    port = 80
    headers.__delitem__('Host')
    headers.insert(0, b'Host', bytes(host))

3.3 如何mock客户端接口

第一步:连代理。

第二步:在mock平台配置需要mock的接口。

接下来就可以非常丝滑的进行客户端接口mock,不需要mock了可以关闭mock按钮,或者直接断开代理即可。

四、社区实践

背景:4.60版本推荐流「负反馈优化」需求,需要测试服务端在不同业务场景返回不同负反馈文案的逻辑,推荐流接口/sns/v2/feed/recommend依赖算法接口/rec/sns/du/ct_hot/recommend,没法固定自己想要的内容。

方案: 1.算法侧帮忙造数据(显然成本相对较高,比较麻烦) 2.mock掉算法的接口

操作:

1.配置需要mock的接口

返回报文结构填和算法约定好的数据结构形式,并将数据改为自己需要测试用的数据!

2.推荐流接口在PHP服务,直接去跳板机上修改php server的配置

3.开始测试

①接入mock后返回的数据是自己配置的。

②不需要mock,关闭mock开关正常走算法逻辑。

既很方便的测试到了服务端的逻辑,也同时测试到了客户端的取值逻辑,并且方便于产品同学的验收。

五、结语

随着技术的不断发展,如果想要做一套通用的mock平台,任重而道远。比如现在流行的rpc怎么去支持?不同的技术架构有不一样的需求,这些都是需要去考虑的。当然各种测试工具平台的设计开发初衷都是提效,服务于业务。后续会不断的结合业务特性来迭代,希望能打造出贴合业务特点,真正的能带来提效的一个mock平台。

文|dhk

关注得物技术,携手走向技术的云端