Python 异步框架—Sanic
简介
Sanic 是 Python3.7+ Web 服务器和 Web 框架(Sanic 不仅仅是一个 框架,它还是一个 Web 服务器),旨在提高性能。它允许使用 Python3.5 中添加的 async
/await
语法,这使得您的代码有效的避免阻塞从而达到提升响应速度的目的。
Sanic(包括Vibora,Vibora声称比其它框架快几倍,比竞争对手Sanic还快两倍多。)与flask有点类似,但有不同。
开始
1 新建项目
项目名称为sanic_pro
在该目录下新建python package
:
config:系统配置文件
server:服务
utils:其他工具
2 项目配置
在异步框架中,异步orm
有很多,在sanic
中,有SanicDB
等,sanicDB
是使用原生SQL
语句,此列中,使用类似于django-orm
框架的tortoise-orm
,详细信息见 https://tortoise.github.io/
pip install sanic
pip install tortoise-orm[aiomysql]
在config
目录新建settings.py
文件,该文件为该项目主要配置文件路径
**注:此列所用数据库为MariaDB,它是由MySQL衍生除的版本,具体请看官网介绍 **
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
# ============================== ProjectConfig start ==============================
# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = ''
TITLE = 'recharge_platform'
# =============================== ProjectConfig end ===============================
# ================================== MySQL start ==================================
default_user = '' # 数据库用户
default_host = '' # 数据库ip
default_port = 3306 # 数据库端口
default_password = '' # 数据库密码
default_database = '' # 数据库
TORTOISE_ORM = {
'connections': {
'default': {
'engine': 'tortoise.backends.mysql',
'credentials': {
'host': default_host,
'port': default_port,
'user': default_user,
'password': default_password,
'database': default_database,
}
},
},
'apps': {
'default': {
'models': [
'aerich.models',
],
'default_connection': 'default',
},
},
'use_tz': False,
'timezone': 'Asia/Shanghai'
}
# ================================== MySQL end ====================================
# =============================== RedisConfig start ===============================
REDIS_HOST = ''
REDIS_PORT = 6379
REDIS_PASSWD = ''
REDIS_SYS_DB = 1
REDIS_NAME = ''
REDIS = {
'host': REDIS_HOST,
'port': REDIS_PORT,
'password': REDIS_PASSWD,
'db': REDIS_SYS_DB
}
# ================================ RedisConfig end ================================
这里,我们使用aerich
对数据库进行管理
pip install aerich
创建迁移目录并初始化数据库链接
aerich init -t config.settings.TORTOISE_ORM
执行以上命令后 会在config
同目录下生成一个migrations
目录和aerich.ini
文件,该文件目录用来存放数据库迁移历史记录
3 新建服务
在config
目录下新建base_model.py
from tortoise import models, fields
class DBBaseModel(models.Model):
class Meta:
abstract = True
id = fields.IntField(pk=True, allows_generated=True)
create_time = fields.DatetimeField(auto_now_add=True, null=True, description='创建时间')
update_time = fields.DatetimeField(auto_now=True, null=True, description='更新时间')
在server
目录中创建 python package
并命名为app_system
,在该目录新建models.py
from tortoise import fields
from config.base_model import DBBaseModel
class Zoning(DBBaseModel):
class Meta:
table = 'sys_zoning'
table_description = '行政区划'
code = fields.CharField(max_length=64, null=False, description='行政区划码')
name = fields.CharField(max_length=64, null=False, description='行政区划名称')
short = fields.CharField(max_length=32, null=True, default=None, description='行政简称')
level = fields.IntField(null=False, default=-1, description='等级')
area = fields.DecimalField(max_digits=16, decimal_places=4, default=0, description='面积')
longitude = fields.DecimalField(max_digits=16, decimal_places=13, default=0, description='经度')
latitude = fields.DecimalField(max_digits=16, decimal_places=13, default=0, description='纬度')
coordinates = fields.TextField(null=True, description='坐标集合')
serial = fields.IntField(null=True, default=None, description='序号')
z_list = fields.TextField(null=True, description='地域层级结构')
is_active = fields.SmallIntField(default=1, description='状态')
parent = fields.ForeignKeyField('default.Zoning',
null=True,
on_delete=fields.SET_NULL,
description='父结点')
class Projects(DBBaseModel):
class Meta:
table = 'sys_projects'
table_description = '项目信息'
name = fields.CharField(max_length=128, null=False, description='名称')
app_key = fields.CharField(max_length=64, unique=True, null=False, description='项目key')
describe = fields.TextField(null=True, description='项目简介')
image = fields.TextField(null=True, description='项目图片')
is_active = fields.SmallIntField(default=1, description='状态')
zoning = fields.ForeignKeyField('default.Zoning',
null=True,
on_delete=fields.SET_NULL,
description='行政区划')
class Account(DBBaseModel):
class Meta:
table = 'sys_account'
table_description = '用户信息'
username = fields.CharField(max_length=64, unique=True, null=False, description='用户名')
password = fields.CharField(max_length=256, null=False, description='密码')
telephone = fields.CharField(max_length=64, unique=True, null=False, description='手机号')
real_name = fields.CharField(max_length=64, null=False, description='用户名称')
is_superuser = fields.SmallIntField(default=1, description='是否是超管')
is_active = fields.SmallIntField(default=1, description='状态')
email = fields.CharField(max_length=64, null=True, default=None, description='邮箱')
address = fields.CharField(max_length=256, null=True, default=None, description='联系地址')
longitude = fields.DecimalField(max_digits=16, decimal_places=13, default=0, description='经度')
latitude = fields.DecimalField(max_digits=16, decimal_places=13, default=0, description='纬度')
z_list = fields.TextField(null=True, default=None, description='地域层级结构')
project = fields.ForeignKeyField('default.Projects',
null=True,
on_delete=fields.SET_NULL,
description='所属项目')
zoning = fields.ForeignKeyField('default.Zoning',
null=True,
on_delete=fields.SET_NULL,
description='行政区划')
...
修改settings.py
文件
.....
'apps': {
'default': {
'models': [
'aerich.models',
'server.app_system.models',
],
'default_connection': 'default',
},
},
....
第一次迁移使用以下命令进行数据库初始化
aerich init-db
执行完该命令,会在migrations
生成default
目录,该目录是默认数据库配置迁移版本文件,如果配置多数据库时,会创建相对应的目录。现在数据库中已经生成对应的数据表。
如果对数据表进行修改后,使用以下命令对数据库进行迁移
# 生成迁移文件
aerich migrate --name drop_column
# 执行迁移文件
aerich upgrade
在system
下新建views_area.py
from sanic import Blueprint
from server.app_system.models import Zoning
from utils.helper import return_result
area_router = Blueprint('行政区划', url_prefix='/area')
@area_router.get('/list')
async def area_list(request):
parent_id = request.args.get('parent_id')
code = request.args.get('code')
name = request.args.get('name')
page_size = int(request.args.get('page_size', '10'))
page_number = int(request.args.get('page_number', '1'))
offset = (page_number - 1) * page_size
zoning_info = Zoning.filter()
if parent_id:
zoning_info = zoning_info.filter(id=parent_id)
if code:
zoning_info = zoning_info.filter(code__contains=code)
if name:
zoning_info = zoning_info.filter(name__contains=name)
res = await zoning_info.select_related(
'parent__name', 'parent__code'
).offset(offset).limit(page_size).values()
count = await zoning_info.select_related('parent__name', 'parent__code').count()
return return_result(data=res, count=count)
__init__.py
from sanic import Blueprint
from .views_area import area_router
auth_blue_group = Blueprint.group(auth_router)
sys_blue_group = Blueprint.group(
area_router,
url_prefix='/system'
)
在数据序列化时部分数据类型不支持序列化,因此对改部分数据需进行处理
在utils
下创建helper.py
文件
import json as public_json
from decimal import Decimal
from datetime import datetime, date
from sanic.response import json as sanic_json
# 对不能序列化的数据进行处理
class DateEncoder(public_json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime):
return obj.strftime("%Y-%m-%d %H:%M:%S")
elif isinstance(obj, date):
return obj.strftime("%Y-%m-%d")
elif isinstance(obj, Decimal):
return str(Decimal(obj))
else:
return public_json.JSONEncoder.default(self, obj)
def data_json_str(data):
if not isinstance(data, str):
return public_json.dumps(data, cls=DateEncoder)
else:
return data
def data_json_dict(data):
if isinstance(data, str):
return public_json.loads(data)
else:
return data
def return_result(code: int = 200, msg: str = 'OK', data=None, count: int = 0, access_token: str = '', *args, **kwargs):
if data is None:
data = {}
return sanic_json({
'code': code,
'msg': msg,
'data': data_json_str(data),
'count': count,
'args': args,
'access_token': access_token,
'kwargs': kwargs
})
在server
目录下的__init__.py
文件添加一下内容
from config.settings import TORTOISE_ORM
from sanic import Sanic
from server.app_system import auth_blue_group, sys_blue_group
from tortoise.contrib.sanic import register_tortoise
app = Sanic(name='recharge_platform')
app.blueprint(sys_blue_group, url_prefix='/api')
# 数据库
register_tortoise(
app=app,
config=TORTOISE_ORM,
generate_schemas=False
)
在项目根目录创建manager.py
文件
from server import app
if __name__ == '__main__':
app.run(port=1000, auto_reload=True, debug=False)
运行manager.py
文件
在FastAPI
中,有这完善的api
文档,Sanic
借助sanic_openapi
亦可实现
pip install sanic_openapi
修改serve.__init__.py
文件
from config.settings import TORTOISE_ORM
from sanic_openapi import swagger_blueprint, openapi3_blueprint
from sanic import Sanic
from server.app_system import auth_blue_group, sys_blue_group
from tortoise.contrib.sanic import register_tortoise
app = Sanic(name='recharge_platform')
# 接口文档
# http://127.0.0.1:1000/swagger
app.blueprint(swagger_blueprint)
# app.blueprint(openapi3_blueprint)
app.blueprint(auth_blue_group)
app.blueprint(sys_blue_group, url_prefix='/api')
# 数据库
register_tortoise(
app=app,
config=TORTOISE_ORM,
generate_schemas=False
)
修改app_system.views_area.py
文件
注:这里需要注意参数类型
@area_router.get('/list')
@doc.summary('获取行政区划列表')
@doc.consumes({'page_number': '1'}, {'page_size': '10'}, location='query', required=True, )
@doc.consumes({'parent_id': int}, {'name': str}, {'code': str}, location='query')
async def area_list(request):
parent_id = request.args.get('parent_id')
code = request.args.get('code')
name = request.args.get('name')
page_size = int(request.args.get('page_size', '10'))
page_number = int(request.args.get('page_number', '1'))
offset = (page_number - 1) * page_size
zoning_info = Zoning.filter()
if parent_id:
zoning_info = zoning_info.filter(id=parent_id)
if code:
zoning_info = zoning_info.filter(code__contains=code)
if name:
zoning_info = zoning_info.filter(name__contains=name)
res = await zoning_info.select_related(
'parent__name', 'parent__code'
).offset(offset).limit(page_size).values()
count = await zoning_info.select_related('parent__name', 'parent__code').count()
return return_result(data=res, count=count)
4 鉴权
sanic
对接口鉴权(登录验证)方式有很多,详情见 https://github.com/mekicha/awesome-sanic/blob/master/README.md#authentication
在config
下新建account_conf.py
文件
import hashlib
import jwt
from config.settings import SECRET_KEY, TITLE
from config.database_pool.redis_pool import RedisPool
from functools import wraps
from utils.helper import return_result
# 生成密码
def make_password(password):
pass_str = password + hashlib.new('sha256', SECRET_KEY.encode()).hexdigest()
encryption = hashlib.new('md5', pass_str.encode()).hexdigest()
return encryption
# 验证密码
def check_password(login_pass, user_pass):
encryption = make_password(login_pass)
if encryption == user_pass:
return True
else:
return False
# 生成token
def create_token(user_name, user_id):
payload = {
'sub': user_id,
'user_name': user_name
}
herder = {
'key': SECRET_KEY,
'title': TITLE,
}
token = jwt.encode(
payload=payload,
key=SECRET_KEY,
headers=herder,
algorithm='HS256'
)
return token
# 验证token
def check_token(token, user_id):
if not user_id or not token:
return False
try:
payload_data = jwt.decode(
jwt=token,
key=SECRET_KEY,
algorithms=['HS256']
)
if payload_data.get('sub') != user_id:
return False
else:
return True
except jwt.exceptions.InvalidTokenError:
return False
# 接口认证
def protected(wrapped):
def decorator(func):
@wraps(func)
async def decorated_function(request, *args, **kwargs):
try:
token = request.ctx.token
user_id = request.ctx.user.get('id')
is_authenticated = check_token(user_id=user_id, token=token)
if is_authenticated:
_redis = RedisPool(db=0)
user_token = await _redis.get_redis_info(_key=str(user_id))
if token == user_token:
response = await func(request, *args, **kwargs)
return response
else:
return return_result(code=401, msg='用户登录已过期,请重新登录')
else:
return return_result(code=401, msg='用户登录已过期,请重新登录')
except Exception as e:
return return_result(code=401, msg='用户还未登录')
return decorated_function
return decorator(wrapped)
if __name__ == '__main__':
_token = create_token('user', 1)
data = jwt.decode(
jwt=_token,
key=SECRET_KEY,
algorithms=['HS256']
)
print(data, type(data))
修改app_system.views_area.py
文件
@area_router.get('/list')
@doc.summary('获取行政区划列表')
@doc.consumes({'page_number': '1'}, {'page_size': '10'}, location='query', required=True, )
@doc.consumes({'parent_id': int}, {'name': str}, {'code': str}, location='query')
@protected
async def area_list(request):
parent_id = request.args.get('parent_id')
code = request.args.get('code')
name = request.args.get('name')
page_size = int(request.args.get('page_size', '10'))
page_number = int(request.args.get('page_number', '1'))
offset = (page_number - 1) * page_size
zoning_info = Zoning.filter()
if parent_id:
zoning_info = zoning_info.filter(id=parent_id)
if code:
zoning_info = zoning_info.filter(code__contains=code)
if name:
zoning_info = zoning_info.filter(name__contains=name)
res = await zoning_info.select_related(
'parent__name', 'parent__code'
).offset(offset).limit(page_size).values()
count = await zoning_info.select_related('parent__name', 'parent__code').count()
return return_result(data=res, count=count)