声明:这篇文章主要面向python/Flask/web后端初级开发者,文章主要讲解了如何搭建一个基于Flask的纯web后台项目,以及相关的知识原理。不涉及部署相关操作。由于接触web开发不久,难免会有疏漏的地方,请读者指正。
下面是文章中会涉及到的内容:
- HTTP请求发送到后端与响应的过程
- Why Flask & 概览
- Flask的常用工具和项目配置
- pycharm
- HTTP requester
- redis
- 从第一个路由注册接口开始
- 使用装饰器处理接口必填字段
- Flask上下文获取request参数
- 错误处理
- 数据库连接池
- 循环引用的那些坑
- 封装加密方法
- ORM与Model
- celery多线程、异步任务
- 设置response模板
- 完整的注册接口
- 使用Blueprint进行模块划分
- 总结与展望
前言
前些日子转了python后台开发=。= 说实话现在对后端更有兴趣。我很享受这种不管做什么都是在学习新的知识的感觉,像新生儿一样对这个世界充满好奇。
这篇文章总结了我最近一段时间的学习成果:使用Flask框架搭建一个可扩展的中小型web service,并在其中加上一些原理的阐述或者链接。在本文中以实际的用户模块为例。之所以写这篇文章是因为自己在入门的时候遇到了很多坑,文档或者个人博客并不能满足我的需要,不是很基础(毫无架构可言,而且大多是不实用的博客项目)就是特别复杂。在此感谢我的同学/同事evolsnow,在开发学习过程中给了我很大的帮助。也希望这篇文章能帮到想入门python/Flask的同学。
HTTP请求发送到后端与响应过程
在进行项目搭建之前,我们先大致回顾一下一个HTTP请求是如何发送至后端并且响应的。
- 通讯双方进行连接
首先通讯双方遵从HTTP协议。当我们输入这样一个请求:http://www.test.com/api/info ,首先请求端会进行DNS解析,把http://www.test.com 变成一个ip地址,如果url里不包含端口号,则会使用该协议的默认端口号。通过ip地址和端口,三次握手建立一个tcp连接。 - 请求:连接成功建立后,开始向web服务器发送HTTP请求。Flask通过wsgi协议传递请求。
- 响应:接到请求后,交给相应路由处理,根据地址转发给相应的控制器(函数)处理。后端大部分工作就是写这些处理过程。处理完成后将最终的response返回给用户。这其中我们拿到的request与response都是由python的wsgi工具包werkzeug提供的。
- 关闭连接:通讯双方均可关闭socket结束tcp/ip会话。
关于wsgi:wsgi协议将处理请求的组件按照功能及调用关系分成了三种:
- server
- middleware
- application。
其中,server可以调用middleware和application,middleware可以调用application。
符合WSGI的框架对于一次HTTP请求的完整处理过程为:
- server读取解析请求,生成environ和start_response,然后调用middleware;
- middleware完成自己的处理部分后,可以继续调用下一个middleware或application,形成一个完整的请求链;
- application位于请求链的最后一级,其作用就是生成最终的响应。
如需更深入了解该过程,可以查看WSGI、Werkzeug。
一、Why Flask & 概览
我负责项目web后端的用户模块,对并发量要求不高。考虑到Flask小巧简单易上手,同时具有强大的扩展能力,使其功能可以不弱于django、Tornado等框架,我最终选择了Flask。下面是Flask最简单的一个示例,这篇文章要做的就是将其充实、扩展、拆分,使代码具有良好的可扩展性和可读性。
from flask import Flask
app = Flask(__name__)
@app.route('api/test')
def hello():
return 'Hello World!'
if __name__ == '__main__':
app.run()
本文假设你已有基础的python语法知识。装饰器是一种代码运行期间动态增加功能的方式,本质上是一个返回函数的高阶函数,也可以简单的将其理解为一个函数的包装函数。上述代码中的route方法是一个装饰器,这个装饰器的作用就是将地址(api/test)与方法名hello联系起来,当HTTP请求的url为(api/test)时候将调用hello方法进行处理。也就是建立了url与处理函数的映射。深入了解可以查看这篇文章。
看起来不复杂,那就让我们继续吧!先从环境工具配置开始:
常用工具
1.IDE
jetbrains家的pycharm,自带终端(虚拟环境、安装插件、启动redis等操作)、Python Console运行python、Version Control版本控制、Even Log打印。
2.请求工具
火狐浏览器插件—HTTP requester。可以自定义请求方式 request methods、请求内容request body、请求头 request header等,当你写好一个接口时,可以非常方便得进行测试。
项目配置
1.虚拟环境
可以按官方文档使用终端进行配置,也可以在pycharm的偏好设置里进行设置,这里我们使用python3.5的解释器。安装后执行命令进入虚拟环境,其中yourvenv为你指定创建的虚拟环境目录
$ source yourvenv/bin/activate
2.使用pip进行包管理
pip是python的包管理工具,如果你是通过homebrew安装python则会自动安装pip。其他情况参考stackoverflow。安装好pip之后,在虚拟环境中通过
$ pip install flask(库名)
安装Flask以及其他三方库。
3.配置redis
Reids是现在最流行的的非关系型数据库(key-value)。其数据缓存在内存中,因此效率很高。多用于存储临时的、高度动态的数据。在用户模块中我们将会对验证码进行redis存储(短时间内进行写入读取操作,并且在缓存一定时间后删除)。本文中将会已用户注册时生成的邀请码为例,进行redis存取操作。
从Redis官网下载安装包并按文档安装后,终端执行
$ redis-server
启动redis,在项目中pip安装即可调用其API。
4.项目相关约定
- 项目采用前后端分离的方式,只进行数据交互,不使用python的jinja2模板去渲染页面给web前端
- web前端、iOS、Android与后端数据交互的格式均为json
- 前端的请求头默认带terminal(前端类型)、version(版本号),后端返回的数据中包含“code”、“msg”参数,code=0表示请求处理成功,当code!=0时,msg为错误信息
- 本文中的后端开发环境为macOS系统,使用python3.5版本
二、从第一个路由注册接口开始
在web开发中,路由(route)是指根据url分配到对应的处理程序。
在用户模块中,注册接口无疑是最基础的。我们先来分析注册过程:
- 前端(包括网页端、iOS、Android)传过来的参数中必须包含用户名与密码,考虑到产品有邀请人机制,因此可能会传邀请码过来。因此,我们首先要做的是,判断该接口是否传了所必须的参数包括手机号和密码,其次判断邀请码是否存在并做相应处理
- 判断请求中是否有邀请码这一参数,若有则通过邀请码从数据库中查找邀请人。此处邀请码及邀请人存在redis数据库中(此处用redis只是教程需要,存入mysql即可)。若没有,则向用户返回错误码及信息
- 将密码加密,存入数据库并返回一个用户id(默认自增,用来创建邀请码),若失败则返回相应错误
- 若用户在请求参数中包含“idfa”参数,则更新用户来源渠道,该数据用于推广运营
- 上述过程处理完毕后,根据用户id生成token并返回给用户
from flask import Flask
app = Flask(__name__)
@app.route('user/register methods=['POST']')
def user_register():
#判断是否含有phone、password参数,若没有则返回400错误
#判断是否有invitationCode参数,若有则从redis数据库中获取邀请人,若获取不到则返回错误,参考REST
#获取phone参数,加密密码并将用户信息存入mysql数据,若成功则返回用户id,失败返回错误
#根据用户id生成邀请码,并存入redis数据库中
#判断是否有idfa参数,若有则在后台线程在mysql中修改用户来源(非必须同步操作,放后台即可)
#生成token
return 注册接口返回token(包含默认的code、msg)
if __name__ == '__main__':
app.run()
1. 使用装饰器处理接口必填字段
考虑到post请求都有检测必填参数并返回信息的需求,因此我们可以写一个装饰器来处理,避免每个接口都写一大段相同的判断代码。装饰器是一个返回函数的高阶函数,可以在函数执行之前执行其他操作。在这里我们可以写一个接受多个参数(即一个请求的所有必填参数)的装饰器,如下:
def require(*required_args):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kw):
for arg in required_args:
if arg not in request.json:
return flask.jsonify(code=400, msg='参数不正确')
return func(*args, **kw)
return wrapper
return decorator
我们只需要在请求方法之前添加装饰器即可:
@app.route('user/register', methods=['POST'])
@require('phone','password')
def customer_register():
xxxx
return xxx
当请求中包含了phone和password参数时,不进行任何操作,返回原函数即user_register(),当有一个参数缺失时,直接返回错误信息400。注意,此处的400并不是http请求的statusCode状态码,而是后端与前端的一种约定,只是返回数据的其中一个参数。
其中flask.jsonfy()方法可以将key-value参数转换为json格式。jsonify()与dump()的区别在此。
为了让项目清晰易懂,我们将此类为请求处理方法准备的装饰器单独放在一个文件中。在项目下创建一个python包文件夹(python package)命名为handler,创建hd_base.py文件,并将装饰器函数写在该文件中。当需要时,只需要引入该方法即可:
from handler.hd_base import required
看到这段代码,大家可能会好奇,我是怎么拿到请求中的参数呢? 程序怎么知道代码中的request就是我们现在正请求的request呢,这就涉及到了Flask的上下文。
2.Flask上下文获取request参数
一般来讲,想要拿到一个请求的内容,如header、body等,需要将这个请求当做参数传给我们定义的user_register()函数。但是考虑到我们可能会调各种各样的东西,为了避免大量可有可无的参数把函数搞的一团糟,Flask使用了上下文(context)临时把某些对象变成了全局可访问。Flask上下文分为应用上下文(AppContext)和请求上下文(RequestContext),可以简单地理解为一个应用运行过程中/一次请求中的所有数据。在上面的装饰器代码中,我们用到了request.json.get()来获取请求内容,其中request对象就是全局可访问的。你又有疑问,在多线程服务器中,多个线程同时处理不同客户端发送的不同请求时,每个线程拿到的request能一样吗?在Flask中,同一时刻一个线程只处理一个请求,使用上下文让特定的变量在一个线程中全局可访问,不会干扰其他线程。详见这篇文章
要使用上下文中的request全局可访问对象,需要引入:
from flask import request
若参数以json形式传输,则可通过request.json.get('phone')获取某参数key的值,请求头可以通过request.json.get('version')获取,表单则可通过rquest.form获取。
3.错误处理
上述代码中,装饰器对参数不满足的情况返回了包含错误信息的json数据。那么如何直接抛错,返回statusCode状态码错误呢,Flask为我们提供了abort方法与errohandler装饰器。将上述装饰器代码稍作修改即可:
def require(*required_args):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kw):
for arg in required_args:
if arg not in request.json:
return flask.abort(400)
return func(*args, **kw)
return wrapper
return decorator
@app.errorhandler(400)
def not_found(error):
return make_response(flask.jsonify({'error': '参数不正确'}), 400)
当请求不包含必填参数时,请求的发送端就会收到请求失败的信息,http状态码为400(请求成功的状态码为200,更多查看http状态码)。
4.数据库连接池
项目使用了MySQL数据库和Redis数据库。根据不同情况选择使用。
MySQL
pip安装flask-sqlalchemy。sqlalchemy是实现ORM的库,将会在下文进行介绍。按照官方文档进行数据库连接(此处为远程数据库连接):
from flask import Flask
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://dbname:[email protected]:3306/db1'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy(app)
当要使用数据库时,调用db即可,下文将会结合ORM使用。
Redis
pip 安装Redis,查阅文档,进行连接(此处连接到本地Redis数据库):
import redis
_redis_cache = redis.Redis(connection_pool=redis.ConnectionPool(host='127.0.0.1', port=6379, db=1))
_redis_db = redis.StrictRedis(host='127.0.0.1', port=6379, db=2)
我在这里使用两种方式连接了Redis数据库,其作用是相同的。其中_redis_cache用于缓存数据(如用户验证码),_redis_db用做数据库(消耗内存,但是速度快),按需使用。为了方便使用,我们将redis常用的存取方法进行封装:
class RedisDB:
def __init__(self, conn=_redis_db):
self.conn = conn
def set(self, key, value, expire=None):
self.conn.set(key, value, expire)
def hget(self, name, key):
ret = self.conn.hget(name, key)
if ret:
ret = ret.decode('utf-8')
return ret
def hset(self, name, key, value):
self.conn.hset(name, key, value)
class RedisCache(RedisDB):
def __init__(self):
super().__init__(_redis_cache)
我们在项目中创建一个python pacage命名为common,创建文件redisdb.py,将封装的redis相关类与方法写在其中。
当需要使用Redis时,实例化后进行操作:
rdb = RedisDB()
rdb.set('a',123) #(key,value)
a = rdb.get('a')
print(a) #输出123
连接好数据库,我们已经可以完成注册接口的一部分代码了:
from handler.hd_base import require
from common import redisdb
from flask import Flask
app = Flask(__name__)
rdb = redisdb.RedisDB()
@app.route('user/register', methods=['POST'])
@require('phone','password')
def customer_register():
#邀请码相关处理
inviter = None
if request.json.get('invitationCode'):
inviter = rdb.hget('invitationCode',request.json['invitationCode'].upper())
if not inviter:
return flask.jsonify(error=400,msg='邀请码无效')
#其他处理
if __name__ == '__main__':
app.run()
从项目文件目录的角度来讲,你可能发觉,所有的请求都写在这个我们创建项目时的第一个文件中,有些不太合适。因此我们可以将这个文件拆分,将实例化应用、接口方法、应用启动三个部分分离。
app/__init__.py中:
from flask import Flask
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://dbname:[email protected]:3306/db1'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy(app)
app/run.py中:
from app import app
if __name__ == '__main__':
app.run()
handler/hd_user.py中:
@app.route('user/register', methods=['POST'])
@require('phone','password')
def customer_register():
xxxxxx
return xxxxx
创建python包(python package)为app、handler,将实例化应用代码写入自动生成的app包下的init.py中,当需要用到app时,引入即可。同理,在app包下创建run.py,将应用启动代码写入。在handler下创建hd_user.py,写入路由方法。
5.循环引用的那些坑
在你为了项目可读性将代码进行分离时,可能会遇到一个奇怪的错误:cannot import xxxx,无法引入某个模块、方法或变量。如果你确认自己没有犯一些低级错误,那么可能就是产生了循环引用。这也是项目初期我遇到的浪费时间最多的坑。
其实问题出在模块导入的顺序上。 比如:在A文件头执行到语句 from B import XXX,程序马上就会转到B文件中去,从头到尾顺序寻找B文件中的XXX函数,而A文件就暂停执行,直到把XXX函数复制到内存中。但在这个过程中,如果B文件头中又导入了A文件中的函数,由于XXX函数还没有被复制,A文件就会因为暂停执行而无法导入,就会出现上面的错误。你可以选择尝试修改import顺序消除循环引用,但解决这个问题最好的方法就是重新梳理逻辑,将重复使用的东西单独提取出来的。当然,上面代码中,将app单独放在一个文件里定义,就是为了避免循环引用,每个文件需要app对象时,单独import它即可。
6.封装加密方法
对密码进行md5加盐加密是一种常见的安全手段。MD5加密算法是不可逆的,如果需要验证密码是否正确,需要对待验证的密码进行同样的MD5加密,然后和数据库中存放的加密后的结果进行对比。但是MD5加密依然不够安全,因此我们在此基础上采用加盐加密。
在common包中新建security.py文件,封装加密方法:
#md5加盐加密
def _hashed_with_salt(info, salt):
m = hashlib.md5()
m.update(info.encode('utf-8'))
m.update(salt)
return m.hexdigest()
#对登录密码进行加密
def hashed_login_pwd(pwd):
return _hashed_with_salt(pwd, const.login_pwd_salt)
之所以将加盐加密方法单独拿出来,是因为项目后期可能会对其他信息进行加密,比如交易密码。到时复用加密方法,修改参数即可,可以保证代码简洁。通过如下代码即可对用户登录密码进行加密:
from common import security
password = security.hashed_login_pwd(request.json['password'])
7.ORM与Model
ORM全称Object Relation Mapping,即对象关系映射。用于实现面向对象编程语言里不同类型系统的数据之间的转换。简单来讲,就是你不再需要写sql语句对数据库进行CRUD操作,只需建立每张表对应的模型Model类,调用相应方法即可达到相同的效果。
以用户表为例,我们新建Model包,创建user.py,添加user模型:
from app import db
from common import utils
from model import userconst
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80),unique=False)
phone = db.Column(db.String(80), unique=True)
password = db.Column(db.String(80),unique=False)
source = db.Column(db.Integer,unique=False)
terminal = db.Column(db.Integer,unique=False)
invited_from = db.Column(db.String(80),unique=False)
#重写该方法,方便输出user信息
def __repr__(self):
user = ''
user += 'name: %s\n' % (self.name)
user += 'phone: %s\n' % (self.phone)
user += 'password: %s\n' % (self.password)
········
return user
def create_user(phone,password,invited_from,terminal):
user = User(phone=phone,password=password,source=userconst.SOURCE_DEFAULT,
terminal=terminal,invited_from=invited_from)
db.session.add(user)
try:
db.session.commit()
except BaseException:
return 0
else:
return user.id
其中userconst.py 定义了一些user相关的常量。
我们定义了User类,并定义了一个创建用户的方法。若数据库添加用户成功,则返回默认自增id,若有任何异常错误,返回0,代表创建用户失败,即注册失败。
本例我们使用ORM代替了sql语句"INSERT INTO user VALUES (phone, name,....)"。
现在,让我们将注册路由的方法更进一步。在user_register方法中添加如下代码:
·········
def customer_register():
#邀请码相关操作,已省略
········
phone = request.json['phone']
#对密码进行MD5加盐加密
password = security.hashed_login_pwd(request.json['password'])
#写入数据库并处理返回
uid = create_user(phone,password,inviter,request.headers['terminal'])
if uid == 0:
return error_resp(500,'注册失败,请核对信息后重新输入')
#其他处理
········
了解了ORM后,我们可以试着从数据库中获取手机号为“18012341234”的用户的姓名并修改:
user = db.session().query(User).filter_by(phone='18012341234').first()
print(user.name) #输出姓名
user.name = '张三'
db.session.add(user)
db.session.commit() #提交修改
可以看到,简单、精确、易用是ORM的特点,但是ORM映射会消耗内存,当数据变得复杂且庞大时,使用ORM会带来不小的性能损耗,我们要根据实际情况进行选择。
8.celery多线程、异步任务
Celery是一个异步任务队列,一个基于分布式消息传递的作业队列。它支持使用任务队列的方式在分布的机器、进程、线程上执行任务调度。总的想法就是你的应用程序可能需要执行任何消耗资源的任务都可以交给任务队列,让你的应用程序自由和快速地响应客户端请求。
在handler中新建tasks.py,配置celery,添加相关方法:
from celery import Celery
def make_celery(app):
celery = Celery(app.import_name, broker=app.config['redis://localhost'])
celery.conf.update(app.config)
TaskBase = celery.Task
class ContextTask(TaskBase):
abstract = True
def __call__(self, *args, **kwargs):
with app.app_context():
return TaskBase.__call__(self, *args, **kwargs)
celery.Task = ContextTask
return celery
celery = make_celery(app)
当我们需要实现后台任务时,只需要在方法前添加celery的装饰器。将下列代码写入tasks.py中:
@celery.task
def update_user_source(ifa, phone):
records = db.session().query(Advertisement).filter_by(ifa=ifa).all()
if len(records) == 0:
return
selected = sorted(records, key=lambda x: x.create_time, reverse=True)[0]
source = Advertisement.AD_SOURCE_MAP[selected.ad_service]
user = db.session().query(Customer).filter_by(phone=phone).first()
user.source = source
db.session.add(user)
db.session.commit()
在终端中启动celery worker
$ celery -A tasks worker
在注册接口中,我们可以在用户数据插入成功之后,后台更改用户的source来源:
def customer_register():
········
idfa = request.headers.get('idfa')
if idfa:
update_user_source.delay(idfa,phone)
rdb.hset(const.user_idfa,uid,idfa)
········
其中const.py在common包下,存放字符串常量。
delay() 方法是强大的 apply_async() 调用的快捷方式。这样相当于使用 apply_async():
update_user_source.apply_async(args=[idfa,phone])
当使用 apply_async(),你可以给 Celery 后台任务如何执行的更详细的说明。一个有用的选项就是要求任务在未来的某一时刻执行。例如,这个调用将安排任务运行在大约一分钟后:
update_user_source.apply_async(args=[idfa,phone], countdown=60)
9.设置response模板
按照我们本项目的约定,接口返回的数据中带code和msg字段,当code为0说明请求成功,msg为空;当code不为0时,msg为错误信息。在handler中创建template.py:
import flask
_base_dic = {
'code':0,
}
def error_resp(code,msg):
return flask.jsonify(error=code,msg=msg)
def register_teml(token):
return dict({
'token':token,
},**_base_dic)
设置注册接口的response:
def user_register()
#所有处理
············
return flask.jsonify(**template.register_teml(token=security.generate_token(uid)))
10.完整的注册接口
至此,注册接口我们就完成了。完整的路由方法如下:
@app.route('user/register', methods=['POST'])
@require('phone','password')
def customer_register():
#邀请码相关处理
inviter = None
if request.json.get('invitationCode'):
inviter = rdb.hget('invitationCode',request.json['invitationCode'].upper())
if not inviter:
return flask.jsonify(error=400,msg='邀请码无效')
phone = request.json['phone']
#对密码进行MD5加盐加密
password = security.hashed_login_pwd(request.json['password'])
#写入数据库并处理返回
uid = create_user(phone,password,inviter,request.headers['terminal'])
if uid == 0:
return error_resp(500,'注册失败,请核对信息后重新输入')
#用户来源处理
idfa = request.headers.get('idfa')
if idfa:
update_user_source.delay(idfa,phone)
rdb.hset(const.user_idfa,uid,idfa)
return flask.jsonify(**template.register_teml(token=security.generate_token(uid)))
现在,一切看起来都很顺利。但是当用户模块的接口添加了登陆、验证码相关、重置密码、获取用户信息等接口后,hd_user会变得很大,这时再添加其他模块的接口时,文件就会变得难以维护。而且当你尝试着新建一个文件写其他模块的接口时,会产生循环引用、代码重复、耦合性太强等等问题。这还仅仅是纯后端,不涉及用模板渲染html页面。这时我们应该就想到将项目模块化,在Flask中Blueprint很好的帮我们解决了这个问题。
10.使用Blueprint进行模块划分
Blueprint通常译作蓝图或蓝本。蓝本允许你将不同路由分开,提供一些规范标准,并且附带了很多好处:让程序更加松耦合,更加灵活,增加复用性,提高查错效率,降低出错概率。如果你是纯萌新,没有大中型项目经验,对蓝图可能会理解困难。我在知乎找到一篇回答,从『小白』和『专业』两个方面解释了蓝图是什么,大家可以去看看:如何理解Flask中的蓝本?
Blueprint使用也非常简单。从避免循环导入、减少代码耦合的角度看,我们在app包下创建bpurls.py,注册蓝图:
from flask import Blueprint
userBP = Blueprint('user', __name__,url_prefix='/user')
productBP = Blueprint('product', __name__,url_prefix='/product')
每个蓝图代表一个模块,可以设置前缀。在hd_user中导入userBP,此时对方法进行修改:
#@app.route('user/register', methods=['POST'])
@userBP.route('register', methods=['POST'])
def customer_register():
·······
同理,当你写product模块相关接口时,创建hd_product.py,导入productBP即可。
蓝本在使用模板的项目中用处更加突出,有兴趣的同学可以查阅相关文档。
总结及展望
这篇文章我最近一段时间的项目实践总结。虽然内容很简单,但是由于自己知识漏洞较多,还是学到了不少新知识。在写文章的时候查阅了很多资料,眼界也更加开阔。虽说iOS还未精通,python web也才入门,但是我对其他技术也充满好奇。下一步可能会在下班空闲时间深入已有知识体系的同时,学习下数据相关的技术。总之,stay hungry ,stay fool !