背景
最近在项目中遇到一个情况,在我们的测试执行结束时需要将测试结果更新到我们的缺陷跟踪系统里面,但是同一时刻可能会有很多客户端都有更新结果的需求。为了避免过度耦合并且尽量让客户端轻量级一些,我们抽取了测试结果更新相关的接口并将其以REST API的形式进行了封装, 以便不同的客户端程序能够通过简单的发送HTTP请求的方式完成相关任务,用户也能使用自己喜欢的语言直接调用我们的服务。而我们实际在后端是通过调用Rally(我们的缺陷跟踪系统)提供的API进行更新的,单次更新结果的过程大概需要10-20s左右时间(中间包括一些资源占用趋势图的生成等操作),为了保证我们的web服务能够处理此类耗时较长的任务并且在高并发的情况下不至于出现用户体验的大幅下降,于是我们决定将对应的后端任务转为异步处理。下文就是笔者在使用Flask+Celery+Redis实现异步任务的一些总结。
应用
注:
项目的完整代码放在GitHub上了,不想码字的朋友可以直接下载:
git clone https://github.com/hdw868/async_flask.git
这里采用一个简化的模型来说明如何实现,首先是构建一个celery的应用, 这里我们使用Redis作为消息代理和存储结果的后端,然后我们模拟一个启动测试的任务,中间休眠30秒用来模拟长时间的测试执行,整体的代码没有什么难点参考官方的教程即可:
tasks.py
import os
import time
from celery import Celery
celery_app = Celery(
'celery_app',
backend=os.getenv('REDIS_URI', 'redis://localhost:6379/0'),
broker=os.getenv('REDIS_URI', 'redis://localhost:6379/0')
)
@celery_app.task()
def launch_new_test(tc_id):
print(f'Provisioning environment for {tc_id}...')
# Simulate the test execution process
time.sleep(30)
print(f'{tc_id} is completed!')
return {"result": 'pass',
"testCaseId": tc_id
}
接着,我们编写一个flask应用作为这个异步任务的前端,并且返回任务的id以便后续查询任务状态及结果,如下:
app.py
import os
from celery.result import AsyncResult
from flask import Flask, render_template, request, jsonify, url_for
from tasks import celery_app, launch_new_test
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'something hard to guess')
@app.route('/', methods=['GET', 'POST'])
def index():
if request.method == 'GET':
return render_template('index.html')
if request.form['submit'] == 'Launch':
tc_id = request.form['test_case_id']
result = launch_new_test.delay(tc_id)
summary = {"taskId": result.id,
"location": str(url_for('get_task_state', task_id=result.id))
}
return jsonify(summary), 202
@app.route('/tasks/<string:task_id>/state')
def get_task_state(task_id):
result = AsyncResult(task_id, app=celery_app)
summary = {
"state": result.state,
"result": result.result,
"id": result.id,
}
return jsonify(summary), 200
注:
- 为了演示方便,这里直接用了一个web前端来触发任务,实际项目中应该使用的REST API形式来组织视图函数。
- 对于异步任务,返回的状态码应该使用202,Accepted;body里面最好包含一个location和id相关信息,以便用户可以根据此类信息进一步查询任务的状态;
index.html
<html>
<head>
<title>Flask + Celery Examples</title>
</head>
<body>
<h1>Flask + Celery Examples</h1>
{% for message in get_flashed_messages() %}
<p style="color: blue;">{{ message }}</p>
{% endfor %}
<form method="POST">
<p>Launch new test:
<input type="text" name="test_case_id" placeholder="test_case_id"></p>
<input type="submit" name="submit" value="Launch">
</form>
</body>
</html>
容器化
这里我们希望通过容器的方式来进行部署,对于镜像构建,我们使用的配置如下:
Dockerfile
FROM python:3.7
WORKDIR /usr/src/app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/
COPY . .
CMD [ "flask", "run"]
EXPOSE 5000
Tips:
- 在安装依赖的时候应该选择用
--no-cache-dir
的方式安装依赖的包。这是因为从pip 6.0+开始,pip
在下载对应包时会缓存对应的包文件,以便需要时直接使用。但是对于docker镜像构建,这显然是没有必要的,并且会增加镜像的大小,所以我们再安装依赖时一般都会带上--no-cache-dir
的选项;- 鉴于国内访问官方pip镜像源的感人速率,如果pip安装包时特别慢或者超时,可以带上
-i <pypi source site>
。可选的站点网上搜一下,比如清华的 https://pypi.tuna.tsinghua.edu.cn/simple/。- 我们使用推荐的
flask run
命令来启动flask应用,通过环境变量的方式指定对应的host、port以及debug模式参数,便于在不同的环境上进行开发测试,下面的docker-compose文件可以看到这一点。- 如果你的项目文件夹下面还有一些不希望打包进image的文件,比如测试代码等,可以使用.dockerignore文件来排除这些文件或者文件夹,用法类似于.gitignore文件
由于我们的应用需要多个服务之间的协作,我这边通过docker-compose来控制多个服务的启停与管理,配置如下:
docker-compose.yml
version: '3.7'
services:
redis:
image: "redis:alpine"
hostname: redis
networks:
- redis-net
flask:
build: .
ports:
- 5000:5000
env_file:
- ~/.env
depends_on:
- redis
networks:
- redis-net
volumes:
- HelloQA:/usr/src/app/public
celery:
build: .
command: celery -A tasks.celery_app worker -l Info
env_file:
- ~/.env
depends_on:
- redis
networks:
- redis-net
volumes:
- HelloQA:/usr/src/app/public
networks:
redis-net:
volumes:
HelloQA:
需要注意的是这里指定了Redis服务的hostname,以便同一网络的其他容器可以通过hostname访问该容器, 在指定Redis的URI时我们只要指定其使用该hostname即可,例如我们得环境变量文件可以写成下面的样子:
~/.env
FLASK_DEBUG=FALSE
REDIS_URI="redis://redis:6379/0"
FLASK_APP=app.py
FLASK_RUN_HOST=0.0.0.0
部署
一切配置完毕后,在docker-compose.yml所在目录下输入如下命令就可以启动整个应用了:
docker-compose up -d
一切顺利的话服务应该全部起来了,这时通过如下命令就能查看各个容器的状态了:
docker-compose ps
测试
确认一切OK以后,我们就可以访问我们的web网页,通过触发几个异步任务来测试一下功能是否正常。当用户访问flask的网站并且提交表单后会立即(不会被阻塞)返回类似如下的结果:
{
"location": "/tasks/cf103488-64e3-4868-aff6-abbbf79b4f31/state",
"taskId": "cf103488-64e3-4868-aff6-abbbf79b4f31"
}
用户可以根据返回的JSON结果中的location字段进一步查询具体的task状态,比如通过访问http://127.0.0.1:5000/tasks/cf103488-64e3-4868-aff6-abbbf79b4f31/state 就会得到类似如下的JSON
结果:
{
"id": "cf103488-64e3-4868-aff6-abbbf79b4f31",
"result": null,
"state": "PENDING"
}
而等到任务结束以后再次发送GET请求,则会得到类似如下的结果 :
{
"id": "cf103488-64e3-4868-aff6-abbbf79b4f31",
"result": {
"result": "pass",
"testCaseId": "11111"
},
"state": "SUCCESS"
}
至此,我们就完成了一个简单的异步任务从构建到部署的全部过程。