Tornado 实践:基于 Peewee、Marshmallow、Aioredis 实现用户登录注册接口

1、首先看下需要的目录结构

auth_demo/
├── apps
│   ├── __init__.py
│   └── public
│   	   ├── handler.py
│   	   ├── __init__.py
│   	   ├── models.py
│   	   ├── schemas.py
│   	   ├── tests.py
│   	   └── urls.py
├── base
│   ├── handler.py
│   ├── __init__.py
│   ├── models.py
│   ├── schema.py
│   ├── settings.py
│   └── urls.py
├── __init__.py
├── logs
│   └── web.log
├── manage.py
├── requirements.txt
└── utils
	├── __init__.py
    ├── db_manage.py
    ├── decorators.py
    ├── logger.py
    └── utils.py

目录及文件描述:

base 是用于存放项目需要使用的一些基本类的目录。

​ handler.py 是项目使用的基本请求处理类文件

​ models.py 是用于存放基本 model 类

​ schema.py 是 用于存放基本序列化器类

​ settings.py 是用于存放配置文件的

​ urls.py 是用于收集所有的路由配置,并集中给 manage 组装起整个项目。

apps 是用于存放实际 app 的目录,对应不同的应用建议存放在不同的 app 内。

​ public 本项目实际的一个 app

​ handler.py 是对应 app 使用的请求处理类文件

​ models.py 是对应 app 使用的 model 类文件

​ schema.py 是对应 app 使用的序列化器类文件

​ urls.py 是对应 app 使用的路由文件

logs 是用于存放项目运行日志的目录。

utils 是用于存放一些插件和扩展的目录。

​ db_manage.py 是用于数据库迁移的处理文件

​ decorators.py 是用于用户登录验证的处理装饰器文件

​ utils.py 是项目使用的一些插件包文件

​ logger.py 是项目使用的日志输出处理文件

requirements.txt 是项目依赖文件,安装包时使用 pip install -r requirements.txt安装。

__init__.py 存在该文件的目录,在 Python 就是一个包(或模块)。

manage.py 是入口文件

由于参考了 Django 的项目结构,通过运行 manage.py 文件也是运行该 demo 的方法,设置的运行命令是 python manage.py runserver默认会从本地的8080端口启动服务。

2、先来介绍下各个主要的包都提供了什么功能

先看 requirements.txt 文件内的依赖如下:

tornado==6.0.4
aiomysql==0.0.20
peewee==3.13.3
peewee-async==0.7.0
marshmallow==3.6.0
aioredis==1.3.1
redis==3.5.3
aiofiles==0.5.0
Pillow==7.2.0
swagger-py-codegen==0.4.0
swagger-spec-validator==2.4.3
urllib3==1.24.2
six==1.12.0
requests==2.21.0
requests-toolbelt==0.9.1
pyotp==2.3.0
chardet==3.0.4
crypto==1.4.1
aiohttp==3.6.2

其中着重介绍的包是:peewee、peewee-async、marshmallow、aioredis 等。

peewee:是为项目提供一个轻量化的 ORM 框架,支持了 MySQL、SQLite、PostgreSQL 等主力关系型数据库。

peewee-async:属于 peewee 的异步扩展包,支持将 SQL 操作变为异步的。

marshmallow:是为项目提供一个序列化和反序列化的包,非常简单易用。

aioredis:为项目提供一个异步 redis 驱动,用于异步的连接并操作 redis。

3、源代码

由于实际代码偏多,因此本例源代码放到码云上提供大家参考和使用。

项目源码地址为:https://gitee.com/aeasringnar/auth_demo.git

源代码架构和关联性讲解:

manage.py 是入口文件,同时提供一些数据库的操作命令,使用python manage help获取帮助。使用python manage.py migrate 用于迁移数据库、python manage.py update用于更新数据库内容,迁移数据库的命令来自于 uitls 目录下的 db_mange.py 提供。主要命令python manage.py runserver会将实例化的 tornado app 运行起来提供服务。

apps 目录内的实际 app 的代码都基于 base 目录内的基本类,然后 app 会通过 urls 与 base 内的 urls整合到一起,最终会提供给 manage.py 内的 tornado 实例化的 app 接收并配置好 tornado app。最终会将该 app 绑定到 tornado 提供的 httpserver 实例上,并将 base 目录下的 settings 配置文件内容也装载上来运行整个服务。至此 整个项目基本耦合完毕,还有一下小插件,如 logger、权限装饰器、异步 redis 驱动等,在实际使用进行装载使用。

public 目录下的 models 代码,描述了基本的模型以及用户模型

from base.models import BaseModel
from peewee import *
from bcrypt import hashpw, gensalt
from base.settings import settings


class PasswordHash(bytes):
    def check_password(self, password):
        password = password.encode('utf-8')
        return hashpw(password, self) == self


class PasswordField(BlobField):
    '''自定义的字段类型'''
    def __init__(self, iterations=12, *args, **kwargs):
        if None in (hashpw, gensalt):
            raise ValueError('Missing library required for PasswordField: bcrypt')
        self.bcrypt_iterations = iterations
        self.raw_password = None
        super(PasswordField, self).__init__(*args, **kwargs)

    def db_value(self, value):
        if isinstance(value, PasswordHash):
            return bytes(value)

        if isinstance(value, str):
            value = value.encode('utf-8')
        salt = gensalt(self.bcrypt_iterations)
        return value if value is None else hashpw(value, salt)

    def python_value(self, value):
        if isinstance(value, str):
            value = value.encode('utf-8')

        return PasswordHash(value)


class Group(BaseModel):
    '''用户组表模型'''
    group_type_choices = (
        ('SuperAdmin', '超级管理员'),
        ('Admin', '管理员'),
        ('NormalUser', '普通用户'),
    )
    group_type = CharField(max_length=128, choices=group_type_choices, verbose_name='用户组类型')
    group_type_cn = CharField(max_length=128, verbose_name='用户组类型_cn')

    class Meta:
        table_name = 'Group'

        
class User(BaseModel):
    '''用户表模型'''
    username = CharField(max_length=32, default='', verbose_name='用户账号')
    # password = CharField(max_length=255, default='',verbose_name='用户密码')
    password = PasswordField(default='123456', verbose_name="密码")
    mobile = CharField(max_length=12, default='', verbose_name='用户手机号')
    email = CharField(default='', verbose_name='用户邮箱')
    real_name = CharField(max_length=16, default='', verbose_name='真实姓名')
    id_num = CharField(max_length=18, default='', verbose_name='身份证号')
    nick_name = CharField(max_length=32, default='', verbose_name='昵称')
    region = CharField(max_length=255, default='', verbose_name='地区')
    avatar_url = CharField(max_length=255, default='', verbose_name='头像')
    open_id = CharField(max_length=255, default='', verbose_name='微信openid') 
    union_id = CharField(max_length=255, default='', verbose_name='微信unionid')
    gender = IntegerField(choices=((0, '未知'), (1, '男'), (2, '女')), default=0, verbose_name='性别')
    birth_date = DateField(verbose_name='生日', null=True)
    is_freeze = IntegerField(default=0, choices=((0, '否'),(1, '是')),  verbose_name='是否冻结/是否封号')
    # is_admin = BooleanField(default=False, verbose_name='是否管理员')
    group = ForeignKeyField(Group, on_delete='RESTRICT', verbose_name='用户组')
    # 组权分离后 当有权限时必定为管理员类型用户,否则为普通用户
    bf_logo_time = DateTimeField(null=True, verbose_name='上次登录时间')

    class Meta:
        db_table = 'User'

public 目录下 urls 代码

from tornado.web import url
from .handler import UploadFileHandler, GetMobielCodeHandler, TestHandler, MobileLoginHandler,  UserInfoHandler

urlpatterns = [
    url('/public/test/', TestHandler), # 实际挂在的路径,访问时,当名字路径时,tornado会处理并返回response
    url('/public/uploadfile/', UploadFileHandler),
    url('/public/getcode/', GetMobielCodeHandler),
    url('/public/mobilelogin/', MobileLoginHandler), # 手机号验证码登录接口,不存在手机号时创建新用户
    url('/public/userinfo/', UserInfoHandler),
]

public 目录下 handler 的部分代码

...
from base.handler import BaseHandler
from .models import *
from .schemas import *
...

class TestHandler(BaseHandler): # urls 文件中指定的 handler 处理类,继承的类来自与 base 目录下的 BaseHandler
    '''
    测试接口
    get -> /public/test/
    '''
    async def get(self, *args, **kwargs):
        res_format = {
    
    "message": "ok", "errorCode": 0, "data": {
    
    }}
        try:
            res_format['message'] = 'Hello World'
            return self.finish(res_format)
        except Exception as e:
            logger.error('出现异常:%s' % str(e))
            return self.finish({
    
    "message": "出现无法预料的异常:{}".format(str(e)), "errorCode": 1, "data": {
    
    }})
        

class MobileLoginHandler(BaseHandler): #  urls 文件中指定的 MobileLoginHandler 处理类
    '''
    手机号登录
    POST -> /mobilelogin/
    payload:
        {
            "mobile": "手机号",
            "code": "验证码"
        }
    '''
    @validated_input_type()
    async def post(self, *args, **kwargs):
        res_format = {
    
    "message": "ok", "errorCode": 0, "data": {
    
    }}
        try:
            data = self.request.body.decode('utf-8') if self.request.body else "{}"
            validataed = MobielLoginSchema().load(json.loads(data))
            mobile = validataed['mobile']
            code = validataed['code']
            redis_pool = await aioredis.create_redis_pool('redis://127.0.0.1/0')
            value = await redis_pool.get(mobile, encoding='utf-8')
            if not value:
                return self.finish({
    
    "message": "验证码不存在,请重新发生验证码。", "errorCode": 2, "data": {
    
    }})
            if value != code:
                return self.finish({
    
    "message": "验证码错误,请核对后重试。", "errorCode": 2, "data": {
    
    }})
            redis_pool.close()
            await redis_pool.wait_closed()
            query = User.select().where(User.mobile == mobile)
            user = await self.application.objects.execute(query)
            if not user:
                # 创建用户
                user = await self.application.objects.create(
                    User,
                    username = mobile,
                    mobile = mobile,
                    group_id = 3
                )
            else:
                user = user[0]
            payload = {
    
    
                'id': user.id,
                'username': user.username,
                'exp': datetime.utcnow()
            }
            token = jwt.encode(payload, self.settings["secret_key"], algorithm='HS256')
            res_format['data']['token'] = token.decode('utf-8')
            return self.finish(res_format)
        except ValidationError as err:
            return self.finish({
    
    "message": str(err.messages), "errorCode": 2, "data": {
    
    }})
        except Exception as e:
            logger.error('出现异常:%s' % str(e))
            return self.finish({
    
    "message": "出现无法预料的异常:{}".format(str(e)), "errorCode": 1, "data": {
    
    }})
...

base 内的urls代码

from tornado.web import url
from tornado.web import StaticFileHandler
from base.settings import settings
from apps.public import urls as public_urls # 将实际 app 内的 urls 导入并整合
from .handler import OtherErrorHandler

urlpatterns = [
    (url("/media/(.*)", StaticFileHandler, {
    
    "path": settings["media_path"]}))
]


urlpatterns += public_urls.urlpatterns
urlpatterns.append(url(".*", OtherErrorHandler))

manage 内服务运行的部分代码

........
from base.urls import urlpatterns # 将 base 目录下的所有路由导入
......
    elif sys.argv[1] == 'runserver':
            if len(sys.argv) != 3:
                sys.argv.append('8080') # 设置默认的端口
            if ':' in sys.argv[2]:
                host, port = sys.argv[2].split(':')
            else:
                port = sys.argv[2]
                host = '127.0.0.1' # 设置默认的监听地址
            app = web.Application(
                urlpatterns, # 将路由配置到实例化后的 tornado web服务上
                **settings
            )
            async_db.set_allow_sync(False)
            app.objects = Manager(async_db) #设置用于操作数据的管理器
            loop = asyncio.get_event_loop()
            # app.redis = RedisPool(loop=loop).get_conn()
            app.redis = loop.run_until_complete(redis_pool(loop))
            logger.info("""[%s]Wellcome...
Starting development server at http://%s:%s/       
Quit the server with CTRL+C.""" % (('debug' if settings['debug'] else 'line'), host, port))
            server = HTTPServer(app)
            server.listen(int(port),host)
            if not settings['debug']:
                # 多进程 运行
                server.start(cpu_count() - 1)
            ioloop.IOLoop.current().start() # 运行服务
................

重要的登录认证装饰器代码

from functools import wraps
import jwt
from apps.users.models import User, Auth, AuthPermission
from .logger import logger


def authenticated_async(verify_is_admin=False):
    ''''
    JWT认证装饰器
    '''
    def decorator(func):
        @wraps(func)
        async def wrapper(self, *args, **kwargs):
            try:
                Authorization = self.request.headers.get('Authorization', None)
                if not Authorization:
                    self.set_status(401)
                    return self.finish({
    
    "message": "身份认证信息未提供。", "errorCode": 2, "data": {
    
    }})
                auth_type, auth_token = Authorization.split(' ')
                data = jwt.decode(
                    auth_token,
                    self.settings['secret_key'],
                    leeway=self.settings['jwt_expire'],
                    options={
    
    "verify_exp": True}
                )
                user_id = data.get('id')
                user = await self.application.objects.get(
                    User,
                    id=user_id
                )
                if not user:
                    self.set_status(401)
                    return self.finish({
    
    "message": "用户不存在", "errorCode": 1, "data": {
    
    }})
                self._current_user = user
                await func(self, *args, **kwargs)
            except jwt.exceptions.ExpiredSignatureError as e:
                self.set_status(401)
                return self.finish({
    
    "message": "Token过期", "errorCode": 1, "data": {
    
    }})
            except jwt.exceptions.DecodeError as e:
                self.set_status(401)
                return self.finish({
    
    "message": "Token不合法", "errorCode": 1, "data": {
    
    }})
            except Exception as e:
                self.set_status(401)
                logger.error('出现异常:{}'.format(str(e)))
                return self.finish({
    
    "message": "Token异常", "errorCode": 2, "data": {
    
    }})
        return wrapper
    return decorator

4、项目运行逻辑和测试

本例建议在虚拟环境中运行,推荐开发环境 Python>=3.6、MySQL5.7、Redis5.x,开发系统建议使用Linux。

运行流程:

第一步:进入项目目录后,安装并使用虚拟环境。

第二步:进入虚拟环境,安装依赖文件 pip install -r requirements.txt

第三步:运行项目 python manage.py runserver

第四步:使用 curl 或 postman 进行测试。

项目运行成功后,在 debug 模式下终端会实时打印日志,终端会输出如下信息:

# python manage.py runserver
2020-08-09 16:11:14,719 - selector_events.py[line:54] - DEBUG - Using selector: EpollSelector
2020-08-09 16:11:14,720 - connection.py[line:110] - DEBUG - Creating tcp connection to ('localhost', 6379)
2020-08-09 16:11:14,721 - manage.py[line:55] - INFO - [debug]Wellcome...
Starting development server at http://127.0.0.1:8080/       
Quit the server with CTRL+C.

部分接口测试图
测试接口
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/haeasringnar/article/details/108330618
今日推荐