异步和非阻塞
尽量使用 async 而不是 coroutine 装饰器
- 基于 coroutine 是一个从生成器过渡到协程的方案。
- yield 和 await 的混合使用代码的可读性很差。
- 生成器可以模拟协程,但是生成器应该做自己。
- 原生协程总体来说比基于装饰器的携程快。
- 原生协程可以使用 async for 和 async with 更符合python风格
- 原生协程返回的是一个awaitable的对象、装饰器的协程返回的是一个 future。
阻塞、非阻塞
- 阻塞是指调用函数时候当前线程被挂起。
- 非阻塞是指调用函数时当前线程不会被挂起,而是立即返回。
同步、异步
- 同步和异步关注的是获取结果的方式。同步是获取到结果之后才进行下一步操作,阻塞非阻塞关注的是接口当前线程的状态,同步可以调用阻塞也可以调用非阻塞。异步是调用非阻塞接口。
CPU / IO 基础
- CPU 的素的远高于 IO 速度。
- IO 包括网络访问和本地文件访问,比如 requests、urllib 等传统的网络库都是同步 IO。
- 网络 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 服务
- Tornado 中的 handler 相当于 Flask 中的 view(视图函数),当客户端发起不同的 http 方法的时候,只需要重载 handler 中的对应方法即可。
- Tornado 的核心是一个单线程模式,定义方法时不要做一些阻塞 IO 操作,否则会将所有请求阻塞住,因此正常情况下尽量将方法定义成一个协程。
- 使用 debug = True 启动 Tornado 调试模式,所有修改会自动刷新,所有错误信息会显示在浏览器中。
- 调试模式下启动服务后会显示进程已结束,但重启服务失败,原因是此进程仍然在后台运行,启动时待监听端口处于占用状态。
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><a href='http://www.baidu.com'>跳转到百度</a><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
前后端分离的优缺点
为什么要前后端分离
- pc、app、pad 多端适应。
- SPA(单页)开发模式流行。
- 前后端开发职责不清。
- 开发效率问题,前后端互相等待。
- 前端一直配合后端,能力受限。
- 后端开发语言和模板高度耦合,导致开发语言依赖严重。
前后端分离缺点
- 前后端职责分开,各司其职,学习门槛增加。
- 数据依赖导致文档重要性增加。
- 前端工作量加大。
- 搜索引擎爬虫抓取页面难度增加,SEO 难度加大。
- 后端开发模式迁移成本增加。
RESTFull api
- 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,帮你封装好了很多东西