Tornado 一个异步的 Python Web 框架

7 篇文章 0 订阅
2 篇文章 0 订阅

异步和非阻塞

尽量使用 async 而不是 coroutine 装饰器

  • 基于 coroutine 是一个从生成器过渡到协程的方案。
  • yield 和 await 的混合使用代码的可读性很差。
  • 生成器可以模拟协程,但是生成器应该做自己。
  • 原生协程总体来说比基于装饰器的携程快。
  • 原生协程可以使用 async for 和 async with 更符合python风格
  • 原生协程返回的是一个awaitable的对象、装饰器的协程返回的是一个 future。

阻塞、非阻塞

  • 阻塞是指调用函数时候当前线程被挂起。
  • 非阻塞是指调用函数时当前线程不会被挂起,而是立即返回。

同步、异步

  • 同步和异步关注的是获取结果的方式。同步是获取到结果之后才进行下一步操作,阻塞非阻塞关注的是接口当前线程的状态,同步可以调用阻塞也可以调用非阻塞。异步是调用非阻塞接口。

CPU / IO 基础

  1. CPU 的素的远高于 IO 速度。
  2. IO 包括网络访问和本地文件访问,比如 requests、urllib 等传统的网络库都是同步 IO。
  3. 网络 IO 大部分的时间都是处于等待的状态,在等待的时候 CPU 是空闲的,但是又不能执行其他操作。

Socket 阻塞 io 访问 HTML

import socket

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
host = 'www.baidu.com'
client.connect((host, 80))  # 阻塞 IO,意味着此时 CPU 是空闲的
client.send('GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n'.format('/', host).encode('utf8'))

resp_txt = b''

while True:
    if b := client.recv(1024):  # 阻塞直到有数据返回
        resp_txt += b
    else:
        break

print(resp_txt.decode('utf8'))

select、poll与epoll

  • select,poll,epoll 都是 IO 多路复用的机制。I/O 多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select。poll。epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。
  • epoll 内部使用红黑树的数据结构。
  • epoll 是在2.6内核中提出的,是之前的 select 和 poll 的增强版本。相对于 select 和 poll 来说,epoll 更加灵活,没有描述符限制。epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的多个事件表中,这样在用户空间和内核空间的 copy 只需一次。

使用 Tornado 的事件循环

from tornado import ioloop
from tornado.httpclient import AsyncHTTPClient


async def test():
    client = AsyncHTTPClient()
    try:
        resp = await client.fetch('http://www.tornadoweb.org/en/stable/')
    except Exception as e:
        print(e)
    else:
        print(resp.body.decode('utf-8'))


if __name__ == '__main__':
    io_loop = ioloop.IOLoop.current()
    # run_sync 方法可以在运行完某个协程之后停止事件循环
    io_loop.run_sync(test)


AsyncHTTPClient 异步 http 客户端实现简单爬虫

from urllib.parse import urljoin

from bs4 import BeautifulSoup
from tornado import ioloop, gen
from tornado.httpclient import AsyncHTTPClient
from tornado.queues import Queue


class AsyncSpider:
    def __init__(self):
        self.__base_url: str = 'http://www.tornadoweb.org/en/stable/'
        self.__url_links = []
        self.__concurrency = 3
        # 多路复用, 事件循环
        self.__loop_crt = ioloop.IOLoop.current()
        self.__visited_set = set()
        # Tornado 非阻塞队列
        self.__q = Queue()

    def __call__(self, *args, **kwargs):
        # 事件分发
        self.__loop_crt.run_sync(lambda: self.__get_request_url())
        self.__loop_crt.run_sync(lambda: self.do())

    @property
    def requests_urls(self) -> list:
        self.__loop_crt.run_sync(lambda: self.__get_request_url())
        return self.__url_links

    async def __get_request_url(self):
        # Tornado 异步 HTTP 工具
        resp = await AsyncHTTPClient().fetch(self.__base_url)
        body = resp.body if resp.code == 200 else ''
        bs_of_body = BeautifulSoup(body)
        title = bs_of_body.find('title')
        print(f'Title of Home Page: {title.text if title else ""}')
        [self.__url_links.append(urljoin(self.__base_url, link.get('href'))) for link in
         bs_of_body.findAll('a', href=True)]

    async def do(self):
        cort = gen.multi([self.__fetch_all() for _ in range(self.__concurrency)])
        # 等待所有事件结束
        await self.__q.join()

        # 清空队列
        for _ in range(self.__concurrency):
            await self.__q.put(None)

        await cort

    async def __fetch_all(self):
        for link in self.__url_links:
            if link and link.startswith(self.__base_url):
                await self.__q.put(link)

        async for url in self.__q:
            if url:
                try:
                    await self.__visit(url)
                except Exception as e:
                    print(e)
                finally:
                    # 标记事件结束
                    self.__q.task_done()

    async def __visit(self, url):
        if url in self.__visited_set:
            return
        self.__visited_set.add(url)
        resp = await AsyncHTTPClient().fetch(url)
        title = BeautifulSoup(resp.body if resp.code == 200 else '').find("title")
        print(f'Title of Sec Page: {title.text if title else ""}')


if __name__ == '__main__':
    AsyncSpider()()



Tornado Web 基础

一个简单的 Tornado 服务

  1. Tornado 中的 handler 相当于 Flask 中的 view(视图函数),当客户端发起不同的 http 方法的时候,只需要重载 handler 中的对应方法即可。
  2. Tornado 的核心是一个单线程模式,定义方法时不要做一些阻塞 IO 操作,否则会将所有请求阻塞住,因此正常情况下尽量将方法定义成一个协程。
  3. 使用 debug = True 启动 Tornado 调试模式,所有修改会自动刷新,所有错误信息会显示在浏览器中。
  4. 调试模式下启动服务后会显示进程已结束,但重启服务失败,原因是此进程仍然在后台运行,启动时待监听端口处于占用状态。
from tornado import web, ioloop


class MainHandler(web.RequestHandler):
    # def get(self, *args, **kwargs):
    #     self.write('hello world')

    async def get(self, *args, **kwargs):
        self.write('hello world')


if __name__ == '__main__':
    app = web.Application([
        ('/', MainHandler)
    ], debug=True)
    app.listen(8000)
    ioloop.IOLoop.current().start()
    # instance 方法返回 IOLoop.current()
    # ioloop.IOLoop.instance().start()


url 映射

url 参数配置

  • url 配置传入时每个 url 元祖会实例化为一个 URLSpec 对象。
urls = [
    tornado.web.URLSpec('/', MainHandler),
    # 使用正则表达式验证并接收 url 参数
    # ?P<arg_name>:指定参数名
    # url '/' 后加 '?',规避 url/ 与 url 不兼容问题
    tornado.web.URLSpec('/sec_url/(?P<arg_name>\d+)/?', MainHandler)
]

# 使用 url 传入参数
async def get(self, arg_name, *args, **kwargs):
    self.write(arg_name)

url 命名

utls = [
    ('/', MainHandler, name='url_name')
]

async def get(self, arg_name, *args, **kwargs):
    # 使用 handler 的 reverse_url 通过 url 名称获取 url,同时可以可变参数形式传入该 url 所需参数
    url_name = self.reverse_url('url_name', arg1, arg2...)
    # 重定向
    self.redirect(url_name)

给 handler 传入初始值

db_config = {
    'db_name': 'db'
}

# db_config: handler 中的 kwargs 参数
urls = [
    ('/', MainHandler, db_config, name='url_name')
]

# handler 方法,获取 db_config 中的键值对,参数列表为字典的键
async def initialize(self, db_name):
    self.db_name = db_name

接收命令行传入参数 options

  • options 是一个类,全局只有一个 options。
from tornado.options import define, options, parse_command_line, parse_config_file

# define,定义一些可以在命令行中传递的参数以及类型
define('port', default=8000, help='run on the specified port', type=int)

# 从命令行获取参数
options.parse_command_line()
# 从指定文件获取参数
options.parse_config_file(filename)

# 使用参数
options.port

RequestHandler 类

入口

  • 入口方法无法使用协程。
  • 用于初始化 handler 类的过程。
def initialize(self, arg):
    self.arg = arg

准备

  • prepare 方法用于真正调用请求处理之前的初始化方法(打印日志,打开文件)。
async def prepare(self):
    pass

网络请求输入

get_req = requests.get('http://127.0.0.1:8000/?name=test&name=test1')

# 指定此内容类型后,会将请求数据解析在 body_argument 中
headers = {
    'Content-Type': 'application/x-www-form-urlencoded'
}
post_req = requests.get('http://127.0.0.1:8000/?name=test&name=test1', headers=headers, data={
    'name': 'test2'
})

async def get(self):
    # 获取请求的所有参数,为字典类型,多个同名参数的情况下,对应参数名的值为参数值的列表
    self.request.arguments

    # 获取 url 中指定名称的参数,若参数名不存在,则抛出 400 异常
    # 获取单个参数,当有多个同名参数时,获取最后一个值
    self.get_query_argument('name')
    # 获取多个参数,为列表,多个同名参数的情况下,获取所有值
    self.get_query_arguments('name')

async def post(self):
    # 获取指定名称的参数,常用,包括 url 与 请求携带的数据(data)
    # 获取单个参数,当有多个同名参数时,获取最后一个值
    self.get_argument('name')
    # 获取多个参数,为列表,多个同名参数的情况下,获取所有值
    self.get_arguments('name')

    # 当请求携带的参数指定为 json(json=) 时,请求的 body_argument 为空,json 数据被解析到 body 中,为 bytes 类型数据。
    param = self.request.body.decode('utf8')
    data = json.loads(param)

    # 获取指定名称的参数,需指定特定的请求内容数据类型,包括 url 与 请求携带的数据(data)
    # 获取单个参数,当有多个同名参数时,获取最后一个值
    self.get_body_argument('name')
    # 获取多个参数,为列表,多个同名参数的情况下,获取所有值
    self.get_body_arguments('name')

async def delete(self):
    pass

async def patch(self):
    pass

网络请求输出

get_req = requests.get('http://127.0.0.1:8000/?name=test&name=test1').text

async def get(self):
    try:
        pass
    except Exception as e:
        # 手动设置响应码
        self.set_status(500)
        # write 方法为长连接,会将所有的输出放入缓存区直到返回时一次性写入
        self.write('str1')
        # 断开网络请求,可放回数据,类型为 json(自动设置返回类型),之后的操作会被丢弃,不会执行
        self.finish({'key', 'val'})
        self.write('str2')
        # 重定向
        self.redirect()

请求处理结束

  • 非协程下执行于请求响应体返回之后。
  • 协程下执行于 finish() 后。
  • 可用于关闭句柄、清理内存等操作。
async def on_finish(self):
    pass

RequestHandler 的子类

RedirectHandler

  • 重定向处理类,用于定义永久重定向,入口方法中默认指定重定向参数 permanent 为 True。
  • permanent(永久的,固定的): 默认为 False,发生重定向时,响应码为 301(永久重定向),否则响应码为 302(临时重定向)。
async def get(self):
    self.redirect(url, permanent=True)

urls = [
    ('/', MainHandler),
    # 传入重定向目标 url
    ('/o/', RedirectHandler, {'url': '/'})
]

# RedirectHandler 入口方法
def initialize(self, url, permanent=True):
    # 接收传入的目标 url,且默认 permanent 为 True
    self._url = url
    self._permanent = permanent

StaticFileHandler

  • 用于代理静态文件。
# 直接传入 Application 中,或者使用关键字参数的形式
setting = {
    # 指定静态文件路径,/static/test.jpg
    'static_path': static_resources_path,
    # 指定静态文件访问 url,默认为 '/static/',static_url_perfix/test.jpg
    'static_url_perfix': static_url_perfix
}

Application(url, **setting)
Application(url, static_path=static_resources_path)

# 使用 StaticFileHandler
urls = [
    ('static_url_perfix/(.*)', StaticFileHandler, {'path': static_resources_path})
]

Template 模板

模板使用

  • 推荐使用前后端分离的形式,尽量减少模板中的 Python 语法。
settings = {
    # 指定模板存放的根目录
    'template_path': template_root_dirname
}
Application(url, **settings)

async def get(self):
    word = 'test'
    # 使用 Handler 的 render 方法进行渲染,渲染时会自动从指定的模板根目录中查找模板文件
    self.render('htmlfile.html', word=word)

常用功能

  • 使用 static_url 动态获取静态资源目录。
<link rel="stylesheet" type="text/css" href="{{ static_url(relative_res_path) }}">
  • 模板中使用 Python 方法。

    • 通过 render 方法传入。

      def func():
          pass
      
      async def get(self):
          self.render(tempalte_path, data=data, func=self.func)
      
    • 模板中直接导入。

      {% from package_path import func %}
      <div> {{ func() }} </div>
      
  • 使用 row 显示原始字符串。

async def get(self):
    tag = '<a href="http://www.baidu.com">跳转到百度<a>'
    self.render(template_path, tag=tag)
<!-- 默认情况下会为字符串进行转码以在页面上显示原始字符串 -->
<!-- <div>&lt;a href=&#39;http://www.baidu.com&#39;&gt;跳转到百度&lt;/a&gt;<div> -->
<div>{{ tag }}</div>

<!-- 不转码,使用原始字符串,此时特殊字符串(如 html 代码)可生效 -->
<!-- <div><a href="http://www.baidu.com">跳转到百度</a><div> -->
<div>{% row tag %}</div>

继承与重载

  • extends 继承。
  • block 指定重载区域。
<!-- base.html -->
<html>
<head>
<!-- head content -->
{% block cus_css}
{% end %}
</head>
<body>
<!-- body content -->
{% block content}
{% end %}

{% block cus_js}
{% end %}
</body>
</html>
<!-- derive.html -->
{% extends 'base.html'}

{% block cus_css}
<!-- private css -->
{% end %}

{% block content}
<!-- private html content -->
{% end %}

{% block cus_js}
<!-- private js -->
{% end %}

UIModule

  • 可将模板封装成类似 Vue 的组件化形式。
# 定义组件
class CustomMoudle(UIMoudle):
    def render(self, args):
        # 与普通模板渲染方式不同,组件需返回渲染后的字符串以将 html 代码嵌入父页面
        return self.render_string('ui_moudles_root/cus_module.html', data=args)
    
    # 嵌入该组件时携带指定 css 样式
    def embedded_css(self):
        return css_string
    
    # 嵌入该组件时携带指定 javascript 代码
    def embedded_javascript(self):
        return javascript_string
    
    # 嵌入该组件时携带指定 css 文件
    def css_files(self):
        return [css_path]
    
    # 嵌入该组件时携带指定 js 文件
    def javascript_files(self):
        return [js_path]

# 将组件加入全局变量
settings={
    'ui_modules': {
        # 键为组件的标识
        'cus_module': CustomMoudle
    }
}

Application(urls, **settings)
<!-- cus_module.html -->
{% for arg in args %}
    <span>{{ arg }}</span>
{% end %}
  • 使用 module 嵌入组件
async def get(self):
    data = [1, 2, 3]
    self.render('index.html', data=data)
<!-- index.html -->
<html>
<head>
<!-- head content -->
</head>
<body>
<!-- body content -->

    <!-- 直接使用注册时的键调用组件,传入组件类的数据会放入组件类的 render 方法下 -->
    {% module cus_module(data) %}
    <!-- <span>1</span> -->
    <!-- <span>2</span> -->
    <!-- <span>3</span> -->

<!-- body content -->
</body>
</html>

aiomysql 完成留言板功能

使用 aiomysql 读写数据

  • aiomysql 事件循环底层由 asyncio(Python3) 实现,可直接使用 Tornado 的事件循环。
  • aiomysql 数据库底层由 pymysql 实现。
db_config = {
    'host': '127.0.0.1',
    'port': 3306,
    'user': 'root',
    'password': 'root',
    'db': 'message',
    'charset': 'utf8'
}

async def get(self):
    # 使用 create_pool 创建一个连接池
    async with create_pool(**db_config) as pool:
        # 使用 acquire(获取)获得一个数据库连接
        async with pool.acquire() as conn:
            async with conn.cursor() as cur:
                # 使用操作指针的 execute 直接运行 SQL 命令
                await cur.execute('select * from message')
                # fetchone 命令执行后获取第一条数据
                value = await cur.fetchone()
                print(value)

if __name__ = '__main__':
    ioloop.IOLoop.current().run_sync(get)
  • 使用 cur.commit() 将数据提交到数据库。

peewee 功能介绍

为什么使用 ORM

  • 隔离数据库之间的差异。
  • 便于维护。
  • 防止 sql 注入。
  • 变量传递式的调用更加简单。

peewee 数据操作

  • model 的定义和表的自动生成。
from datetime import datetime

from peewee import MySQLDatabase, Model, DateTimeField, CharField, IntegerField, FloatField, TextField, ForeignKeyField

# 初始化数据库连接
db = MySQLDatabase('message', host='127.0.0.1', port=3306, user='root', password='db_pwd')


# 使用 Model 类定义基类,使其派生类携带某些固定配置
class BaseModel(Model):
    # verbose_name,字段注释
    create_date = DateTimeField(default=datetime.now(), verbose_name='创建时间')

    # 表格配置,包括且不限于归属数据库、表名等
    class Meta:
        database = db


class Supplier(BaseModel):
    name = CharField(max_length=100, verbose_name='名称', index=True)
    address = CharField(max_length=100, verbose_name='联系地址')
    phone = CharField(max_length=11, verbose_name='联系方式')

    class Meta:
        table_name = 'supplier'


class Goods(BaseModel):
    # 建立外键,数据库中以 “表名_id” 替代
    supplier = ForeignKeyField(Supplier, verbose_name='商家', backref='goods')
    name = CharField(max_length=100, verbose_name='名称', index=True)
    click_num = IntegerField(default=0, verbose_name='点击数')
    goods_num = IntegerField(default=0, verbose_name='库存数')
    price = FloatField(default=0.0, verbose_name='价格')
    brief = TextField(verbose_name='商品简介')

    class Meta:
        table_name = 'goods'


def init_db():
    # 创建表
    db.create_tables([Supplier, Goods])


if __name__ == '__main__':
    init_db()

  • ModelClass.save() 保存数据。
  • 当字典与 model 类字段一一对应时,可使用 ModelClass(**data) 的形式直接创建实例。
  • peewee 查询
# 通过 id 查询
goods = Goods.get(Goods.id == id)
goods = Goods.get_by_id(id)

# select 查询
# 获取 modelselect 实例,可通过迭代协议以此获取数据
select = Goods.select()
for goods in select:
    print(goods)

# where 条件查询
# conditions:布尔表达式
goods = Goods.select().where(conditions)
# 包含查询
goods = Goods.select().where(Goods.name.contains(query_str))
# 排序
goods = Goods.selece().order_by(Goods.price.desc())

  • peewee 更新数据
# 使用 save() 方法更新。
goods = Goods.query_bu_id(id)
goods.click_num += 1
# 根据情况进行操作,若对应 id 不存在则插入,否则更新。
goods.save()

# 使用 ModelUpdate 对象更新。
# 返回一个 ModelUpdate 对象,调用此对象的 execute() 方法执行操作。
# execute() 方法为同步操作。
ModelClass.update(click_num=Goods.click_num + 1).where().execute()
  • peewee 删除数据
# delete_instance() 直接删除
# 若数据不存在,则抛出异常,因此需进行异常捕获
goods.delete_instance()

# 使用 ModelClass 的 delete() 方法删除
Goods.delete().where(conditions).execute()

通过 peewee-async 集成到 Tornado 中

  • 使用 peewee-async 后,peewee 中所有同步方法(save()、get_by_id()、execute()、for goods in gooods_list 等)均无法使用
from datetime import datetime

import peewee_async
import tornado.ioloop
from peewee import Model, DateTimeField, CharField, IntegerField, FloatField, TextField, ForeignKeyField
from peewee_async import MySQLDatabase

# 使用 peewee_async 的 MySQLDatabase,其余配置与 peewee 相同
db = MySQLDatabase('message', host='127.0.0.1', port=3306, user='root', password='db_pwd')

# 获取数据库的管理器,管理器中包含几乎所有 peewee 的方法,包括不限于 create()、execute() 等
db_manager = peewee_async.Manager(database=db)

# 设置数据库是否为同步模式。
db.set_allow_sync(False)


class BaseModel(Model):
    create_date = DateTimeField(default=datetime.now(), verbose_name='创建时间')

    class Meta:
        # 数据库对象为 peewee-async 中的 database 对象
        database = db


class Supplier(BaseModel):
    name = CharField(max_length=100, verbose_name='名称', index=True)
    address = CharField(max_length=100, verbose_name='联系地址')
    phone = CharField(max_length=11, verbose_name='联系方式')

    class Meta:
        table_name = 'supplier'


class Goods(BaseModel):
    supplier = ForeignKeyField(Supplier, verbose_name='商家', backref='goods')
    name = CharField(max_length=100, verbose_name='名称', index=True)
    click_num = IntegerField(default=0, verbose_name='点击数')
    goods_num = IntegerField(default=0, verbose_name='库存数')
    price = FloatField(default=0.0, verbose_name='价格')
    brief = TextField(verbose_name='商品简介')

    class Meta:
        table_name = 'goods'


supplier_data = {
    'name': 'name',
    'address': 'address',
    'phone': 'phone'
}

goods_data = {
    'name': 'name',
    'click_num': 0,
    'goods_num': 0,
    'price': 0.0,
    'brief': 'brief'
}


async def handler():
    # 使用管理器的上下文管理器临时进入同步状态以创建数据库
    with db_manager.allow_sync():
        db.create_tables([Supplier, Goods])
    # 使用 create() 方法异步插入一条记录,返回创建的记录 id
    sp_id = await db_manager.create(Supplier, **supplier_data)
    print(sp_id)
    await db_manager.create(Goods, **goods_data, supplier_id=sp_id)
    # 异步环境下 Model 类的 execute() 方法无法使用,使用管理器的 execute() 进行操作。
    goods_list = await db_manager.execute(Goods.select())
    for goods in goods_list:
        print(goods.name)


if __name__ == '__main__':
    tornado.ioloop.IOLoop.current().run_sync(handler)

wtforms 集成到 Tornado 中

WTforms 表单验证与显示

  • Tornado 中请求数据类型与其余 web 框架不同,不支持 wtforms 自带的 Form 类型,因此需要使用 wtforms-tornado 中的 Form 类型数据,否则会报错。
  • 使用 wtforms-tornado 时,wtforms 最高版本必须为 2.3.3(包括此版本) 以下,3.0.0 之后的版本取消了 compat 的模块,会导致 wtforms-tornado 导入此模块失败。
from wtforms import StringField
from wtforms_tornado import Form


class MessageForm(Form):
    # validators 参数中传入验证器列表
    name = StringField('姓名', validators=[DataRequired(message='请输入姓名'), Length(min=4, max=8, message='长度不符合要求')])
    email = StringField('邮件', validators=[Email(message='非法的邮箱格式')])
    address = StringField('地址', validators=[DataRequired(message='请填写地址')])
    message = TextAreaField('留言', validators=[DataRequired(message='请填写留言')])


async def post(self):
    # 使用请求数据进行初始化
    form = MessageForm(self.request.arguments)
    # 对请求数据进行验证,返回一个布尔值,
    if form.validate():
        # 若验证成功,所有数据会存储到对应属性名的 data 属性中
        name = form.name.data
        email = form.email.data
        address = form.address.data
        message = form.message.data
        self.render('message.html', msg_form=form)
    else:
        # 若验证失败,所有的失败信息会存储在 form 的 errors 属性中
        print(form.errors)

<!-- message.html -->
<!-- 关闭字符串自动转义,可规避对 html 字符串特殊含义字符的转义。 -->
{% autoescape None %}
<!-- 遍历 form 中的 field -->
{% for field in msg_form %}
    <!-- 通过 label 获取提示类容,数据存储于 text 中 -->
    <span>{{ field.label.text }}</span>
    <!-- field() 能自动转换成 html 字符串,格式取决于发送请求的前端 form 表单 -->
    {{ field(placeholder="请输入" + field.label.text) }}
{$ end %}

RESTFull api

前后端分离的优缺点

为什么要前后端分离

  1. pc、app、pad 多端适应。
  2. SPA(单页)开发模式流行。
  3. 前后端开发职责不清。
  4. 开发效率问题,前后端互相等待。
  5. 前端一直配合后端,能力受限。
  6. 后端开发语言和模板高度耦合,导致开发语言依赖严重。

前后端分离缺点

  1. 前后端职责分开,各司其职,学习门槛增加。
  2. 数据依赖导致文档重要性增加。
  3. 前端工作量加大。
  4. 搜索引擎爬虫抓取页面难度增加,SEO 难度加大。
  5. 后端开发模式迁移成本增加。

RESTFull api

  1. RESTFull api 目前是前后端分离的最佳实践。
    • 轻量,直接通过 http,不需要额外的协议,使用 get、post、put、delete 等 HTTP 动词。
    • 面向资源,一目了然,具有解释性。
    • 数据描述简单,一般通过 json 或 xml 做数据通信。

用户登录注册

请求数据验证

  • HTTPRequest 携带字典发送请求时,使用其 body 参数传入字典,且此时需使用原生的 urllib.parse 包中的 urlencode 方法将字典进行编码
from urllib.parse import urlencode
from tornado.httpclient import HTTPRequest, AsyncHTTPClient

async def post_request(kwargs: dict):
    request = HTTPRequest(url=url, method='POST', body=urlencode(kwargs))
    response = await AsyncHTTPClient().fetch(request)
  • 需要携带参数执行一个协程时,可使用 functools 包中的 partial 方法,可将方法所需参数与方法合并为一个新的方法。
from functools import partial

new_func = partial(post_request, **kwargs)
io_loop.run_sync(new_func)
  • urlSpec 可使用 url 替代
urls = (
    url('/', Handler),
    urlSpec('index', handler)
)
  • wtforms 接收请求参数后会将键值对的值当作一个可迭代对象来操作,此时若传入一个 json 字符串且键值对值为字符串,会将字符串作为一个可迭代对象依次读取
normal_req_param: ?msg='test_msg' => {'msg': ['test_msg']}

json_req_param: {"msg": "test_msg"} => {'msg': ['t', 'e', 's', 't', '_', 'm', 's', 'g']}
  • 需使用 wtforms_json 包规避此问题
import wtforms_json

# 继承 json 到 wtforms
wtforms_json.init()

app = Application()

async def get(self):
    param = self.request.body.decode('utf-8')
    param = json.loads(param)
    # from_json 在 wtforms 模块中是找不到的,是通过 wtforms_json 包在原代码的基础上打了个补丁
    form = MessageForm.from_json(param)

使用手机号注册

  • 注册全局 Redis 并生成手机验证码
from tornado.web import RequestHandler
import redis


class RedisHandler(RequestHandler):
    def __init__(self, application, request, **kwargs):
        super().__init__(application, request, **kwargs)
        # 通过全局配置获取 redis 配置
        self.redis_conn = redis.StrictRedis(**self.settings.get('redis'))


class SMSHandler(RedisHandler):
    async def get(self):
        res = {}
        param = json.loads(self.request.body.decode('utf-8'))
        form = SMSCodeForm.from_json(param)
        if form.validate():
            mobile = form.mobile.data
            vali_code = self.__gen_vali_code()
            # 通过第三方 api 发送验证码到手机
            resp = await SMSApi.send(mobile=mobile, code=vali_code)
            if resp.get('code') != 0:
                # 手动设置响应码
                self.set_status(400)
                res.udpate('mobile': resp.get('msg'))
            else:
                # 将验证码写入 Redis 中,指定有效时间为 10 分钟
                self.redis_conn.set(f'{mobile}_{vali_code}', 1, 10 * 60)
        else:
            self.set_status(400)
            for field in form.errors:
                res.update({field: form.errors.get(field)[0]})
        
        self.finish(res)
    
    def __gen_vali_code():
        # 从指定序列中随机选择一项
        from random import choice
        seeds = '0123456789'
        return ''.join([choice(seeds) for _ in range(4)])      
  • peewee 自定义密码字段类型
from bcrypt import hashpw, gensalt
from peewee import BlobField


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().__init__(*args, **kwargs)

    def db_value(self, value):
        """Convert the python value for storage in the database."""
        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):
        """Convert the database value to a pythonic value."""
        if isinstance(value, str):
            value = value.encode('utf-8')

        return PasswordHash(value)
  • peewee 指定字段值规范,当前字段的值只能从指定规范的值中选择
GENDERS = (
    ('female', '女'),
    ('male', '男')
)

class User(BaseModel):
    name = CharField()
    gender = Charfield(mex_length=200, choices=GENDERS, null=True, verbose_name='性别')
  • Application 对象设置全局变量。
# 数据库异步管理器
objects = Manager(database)

app = Application()
# 将数据库异步管理器的实例存入服务器核心对象,使其能被核心对象直接调用
app.objects = objects

class RegisterHandler(RequestHandler):
    async def post(self):
        # 检测用户是否存在
        try:
            user = await self.application.objects.get(User, name=name)
        except User.DoesNotExist as e:
            pass
  • 浏览器访问 API 时,为了防止跨域攻击,在使用 http 访问 url 前会先发送一个 OPTIONS 请求。解决此类问题,可创建一个全局 http handler,覆写其 options 方法,将访问许可写入 headers 返回。
class BaseHandler(RequestHandler):
    def set_default_headers(self):
        self.set_header(header_key, header_val)
    
    def options(self, *args, **kwargs):
        pass

JWT(json web token)原理


用户登录

验证密码

  • session 实际上是服务器随机生成的一段字符串,保存在服务器
  • jwt 本质上还是加密技术
  • 使用 PyJWT 进行 jwt 字符串生成
class LoginHandler(RequestHandler):
    async def post(self):
        res_data = {}
        form = LoginForm.from_json(json.loads(self.request.body.decode('utf-8')))
        if form.validate():
            mobile = form.mobile.data
            passwd = form.password.data
            
            try:
                user = await self.application.objects.get(User, mobile=mobile)
                # Model 类自带的字段验证手段
                if not user.password.check_password(passwd):
                    self.set_status(400)
                    res_data.setdefault('non_fields', '用户名或密码错误')
                else:
                    # 登录成功,使用 jwt 保存登录信息
                    import jwt
                    
                    pyload = {
                        'id': user.id,
                        'nick_name': user.nick_name,
                        # 设置过期时间
                        'exp': datetime.utfnow()
                    }
                    # secret_key 可保存在全局设置中重复使用
                    token = jwt.encode(pyload, self.setting.get('secret_key'), algorithm='HS256')
                    res_data.update({
                        'id': user.id,
                        'token': token
                    })
            except User.DoesNotExist as e:
                self.set_status(400)
                res_data.setdefault('mobile', '用户不存在')
        
        self.finish(ser_data)

登录状态验证

  • 使用 authenticated 装饰器进行登录验证,服务器执行 http 操作前,在 request handler 中查找 current_user 属性,若不存在则调用 get_current_user 方法,否则重定向到指定的 login url,若未指定 login url,则抛出异常;该操作为同步方法,在 http 协程请求中不适用,需重写一个装饰器。

  • 重写 authenticated 装饰器

from functools import wraps

import jwt

from apps.users.models import User


def authenticated_async(method):
    @wraps(method)
    async def wrapper(self, *args,  **kwargs):
        t_session_id = self.request.    headers.get("t_session_id", None)
        if t_session_id:
            try:
                # 解析 jwt 字符串
                send_data = jwt.decode(
                    t_session_id, self. settings.get ('secret_key'),  leeway=self.settings.    get('jwt_expire'),
                    options={"verify_exp":  True}
                )
                user_id = send_data.get ('id')

                # 从数据库中获取到user并设置    给_current_user
                try:
                    user = await self.  application.objects.get   (User, id=user_id)
                    self._current_user =    user

                    # 装饰的 http 方法是一个    协程,因此须使用 await  关键字
                    await method(self,  *args, **kwargs)
                # 用户不存在
                except User.DoesNotExist:
                    self.set_status(401)
            # 处理 jwt 过期的情况
            except jwt. ExpiredSignatureError:
                self.set_status(401)
        else:
            self.set_status(401)
        self.finish({})

    return wrapper

通过 aiofiles 保存图片文件

  • wtforms 使用 Anyof 限制输入内容
from wtforms_tornado import Form
from wtforms import StringField, TextAreaField, IntegerField
from wtforms.validators import DataRequired, Regexp, AnyOf, Length

class CommunityGroupForm(Form):
    name = StringField("名称", validators=[DataRequired("请输入小组名称")])
    category = StringField("类别", validators=[AnyOf(values=["教育同盟", "同城交易", "程序设计", "生活兴趣"])])
    desc = TextAreaField("简介", validators=[DataRequired(message="请输入简介")])
    notice = TextAreaField("简介", validators=[DataRequired(message="请输入公告")])
  • 使用 aiofiles 保存上传的文件
@authenticated_async
async def post(self, *args, **kwargs):
    re_data = {}

    # 不能使用jsonform, 使用原生的参数解析
    group_form = CommunityGroupForm(self.request.body_arguments)
    if group_form.validate():
        # 自己完成图片字段的验证
        files = self.request.files.get('front_image', None)
        if not files:
            self.set_status(400)
            re_data.setdefault('front_image', '请上传图片')
        else:
            # 完成图片保存并将值设置给对应的记录
            # 通过aiofiles写文件
            # 1. 文件名
            new_filename = ''
            for file in files:
                new_filename = f'{uuid.uuid1()}_{file.get("filename")}'
                file_path = os.path.join(self.settings.get('MEDIA_ROOT'), new_filename)
                # 使用 aiofiles 读写文件
                async with aiofiles.open(file_path, 'wb') as f:
                    await f.write(file.get('body'))
            
            group = await self.application.objects.create(
                CommunityGroup, creator=self.current_user, name=group_form.name.data,
                category=group_form.category.data, desc=group_form.desc.data, notice=group_form.notice.data,
                from_image=new_filename
            )

            re_data.setdefault('id', group.id)
    else:
        self.set_status(400)
        for field in group_form.errors:
            re_data.setdefault('field', field[0])

    await self.finish(re_data)

数据交互

通过 model_to_dict 异步地进行序列化

  • model_to_dict 为 peewee 自带方法,用以将 model 类直接转为字典
  • 若 model 类中存在外键,则 model_to_dict 方法会执行失败,因为 model_to_dict 方法为同步方法,因此须在 model 类中自定义外联表的信息获取
  • handler 类中 finish 方法无法返回列表,列表对象须序列化为 json 字符串
  • 对象中存在 date/datetime 类型数据时无法进行序列化,可自定义处理方法传入 json.dumps() 中
def json_serial(obj):
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    raise TypeError("Type {}s not serializable".format(type(obj)))

class CommunityGroup(BaseModel):
    creator = ForeignKeyField(User, verbose_name="创建者")
    name = CharField(max_length=100, null=True, verbose_name="名称")
    category = CharField(max_length=20, verbose_name="分类", null=True)
    front_image = CharField(max_length=200, null=True, verbose_name="封面图")
    desc = TextField(verbose_name="简介")
    notice = TextField(verbose_name="公告")

    # 小组的信息
    member_nums = IntegerField(default=0, verbose_name="成员数")
    post_nums = IntegerField(default=0, verbose_name="帖子数")

    @classmethod
    def extend(cls):
        # 使用 model_to_dict 方法序列化 model 类时,若 model 类存在外键且获取外联数据时直接返回整条记录,则序列化失败
        # 因此须返回具体字段
        return cls.select(cls, User.id, User.nick_name).join(User)

async def get(self, *args, **kwargs):
    # 获取小组列表
    re_data = []
    community_query = CommunityGroup.extend()
    # 根据类别进行过滤
    c = self.get_argument("c", None)
    if c:
        community_query = community_query.filter(CommunityGroup.category == c)
    # 根据参数进行排序
    if order := self.get_argument("o", None):
        if order == "new":
            community_query = community_query.order_by(CommunityGroup.add_time.desc())
        elif order == "hot":
            community_query = community_query.order_by(CommunityGroup.member_nums.desc())
    if limit := self.get_argument("limit", None):
        community_query = community_query.limit(int(limit))
    for group in await self.application.objects.execute(community_query):
        group_dict = model_to_dict(group)
        group_dict.setdefault(
            'front_image', f'{self.settings.get("SITE_URL")}/media/{group_dict.get("front_image")}/'
        )
        re_data.append(group_dict)
    await self.finish(json.dumps(re_data, default=json_serial))

常用的富文本编辑器

  • WangEditor:轻量级 web 富文本编辑器,配置方便,使用简单。支持 IE10+ 浏览器。vue版本。一个国人独立开发的基于javascript和css开发的web富文本编辑器,之前用过感觉还是很不错的,UI漂亮,中文文档齐全并且开源。不足的地方在于更新不及时,没有强大的团队支撑。不过细心的会发现现在开始有动作了,成立了wangeditor-team来进行维护,可能也是应了广大用户的需求。

  • TinyMCE:是一款易用、且功能强大的所见即所得的富文本编辑器。优势:插件丰富,自带插件基本涵盖日常所需功能;接口丰富,可扩展性强,有能力可以无限拓展功能;界面好看,符合现代审美;提供经典、内联、沉浸无干扰三种模式;多语言支持,官网可下载几十种语言。官方也在之前发布了 vue 版本的 tinymce-vue,帮你封装好了很多东西

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱吃芒果的芬里尔狼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值