年轻人的第二个网站 - The Flask Mega Tutorial

本文为 Flask 框架学习笔记,主要参考了 The-Flask-Mega-Tutorial 和 《Flask Web开发:基于Python的Web应用开发实战》两本书,并在原项目的基础上拓展。(下文统称这两个资源为“本教程”) 不熟悉 Flask 框架请先阅读快速上手 - flask 中文文档

这两本书的作者是同一个人,就内容上说后者算是前者的豪华版。本教程的优点是内容全面,从入门到部署一站式服务;缺点是不够深入,且有些过时,书中举例的诸多插件均为作者为了此书而开发的,已经许久不再维护,导致很难在其示例项目上拓展。一看扉页,2015年出版,那没事了。 至于第一个网站?参见#TODO:年轻人的第一个网站


0x00 大型项目结构

在大部分面向初学者的 demo 中,应用以简单的项目结构甚至单文件表示。在大型项目中,网站的不同功能被拆分成独立的模块,以方便拓展和维护。一个更通用的 Flask 项目代码架构如下:(仅考虑业务代码)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
microblog/ # 根目录
  app/ # 项目源码
    __init__.py # 项目初始化,当该包被import,首先执行__init__.py
    routes.py
    forms.py
    ...
  main.py # 框架入口
  config.py # Config配置类
  .flaskenv
  ...

根目录下的文件有:

  • app/所有网站源代码统一归到app目录下。
    • app/内部,不同的功能可进一步划分成独立模块,详见[模块化应用](#0x05 模块化应用:功能解耦)一章。
  • main.py: 入口脚本,通过该文件引入app中的代码并生成应用实例(命名随意)
  • .flaskenv: flask环境变量,以配合flask命令。入口脚本被定义为FLASK_APP,执行flask run时将启动该脚本。
  • config.py: 配置脚本,整个项目的配置信息都写在Config类里。与环境变量的区别在于,因为是python脚本,功能更强大,可被任何地方的代码引用。

0x01 Hello world:模板和视图

最基本的 web 功能,无非接受请求、返回数据。其中,路由 (route) 用来区分不同的请求,模板 (templates) 用来生成不同的数据。

路由/视图

在非前后端分离的项目中,视图函数直接返回渲染好的网页,由@app.route()修饰后,视图和路由便绑定在一起。 在mvc模型中更像controller控制器的角色,然而在flask生态中更喜欢称为视图函数。

  • url_for() 使用URL到视图函数的内部映射关系来生成URL,用来替换硬链接。在业务功能解耦后必须使用这种方式。
    • NOTE:当路由和视图函数名不一致,访问该路由可以正确响应,但是使用url_for()调用该视图时会报错
  • {% extends "base.html" %} and {% include "_post.html" %} 使用子模板来实现网页公用的部分。如:页眉,页脚,列表项等。
    • 模板和 Python 代码的关系有些类似与 JSP 和 Java 代码的关系,但模板语法并不是完整的脚本语言,相较而言限制更多,安全性更好。

表单

几乎所有成功的框架都有丰富的插件生态。下面引入新功能时,大多借助插件来方便的实现。大多数Flask插件使用flask_<name> 命名约定。

Flask-WTF插件提供了对Web表单的抽象,只需定义表单类以及设置类属性即可。

模板语法:

  • {{ form.<name>.label }}渲染标签
  • {{ form.<name>() }}获取属性值
  • form.hidden_tag()模板参数生成了一个隐藏字段,其中包含一个用于保护表单免受CSRF攻击的token

将表单引入模板

1
2
form = LoginForm() # 生成了一个实例传入模板
return render_template('login.html', title='Sign In', form=form)

flash 闪现消息

flash 通过 session 储存,用于显示只出现一次的提示消息。用法:

  • 在路由中使用flash(),触发时消息便写入 session 中的 message 列表
  • 在模板中使用get_flashed_messages(),从 session 中读取

0x02 数据库 ORM

很久很久以前,web网站和数据库交互还需要写很多很硬的 SQL 语句,效率低且容易出现注入漏洞(SQLi)。现代web开发都使用 ORM 框架简化数据库交互,且基本杜绝了 SQLi 漏洞。

本项目使用如下插件打通数据库:

  • Flask-SQLAlchemy: Python生态最知名的ORM框架
  • Flask-Migrate: 本教程作者编写的数据库迁移框架

插件首先要注册。统一流程: 初始化app实例,传入插件类作为插件实例的参数

1
2
3
4
5
# app/__init__.py
app = Flask(__name__) # flask基类
app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)

SQLalchemy:model层

模型定义

使用类和类属性代表 table 和 colunm ,便可轻松编写数据模型。SQLalchemy 的概念抽象如下图:

Flask-SQLAlchemy 自动设置类名为小写来作为对应表的名称,也可以用__tablename__类属性来定义。

1
2
3
class Post(db.Model): # 表
    id = db.Column(db.Integer, primary_key=True) # 列
    ....

CURD基本操作

ORM 框架通常集成了常用操作,但也支持更底层的数据库接口。

在 Springboot Jpa 中,根据方法名的拼写来写自定义查询,而在 SQLalchemy 中,提供的接口通过链式调用拼接。

在 SQLalchemy 中,基本操作大都有基于事务 (session) 的和基于查询 (query) 的两种方式。

  • 1
    
    session.query(User)
    

    query方法只有构造一个查询,只有在Query.get()Query.all()Query.one()等结束符之后才会执行查询

  • 增:

    1
    2
    
    db.session.add(user)
    db.session.commit()
    
  • 1
    2
    3
    4
    
    session.query(User).delete()
    # or
    session.delete(session.query(User).get(1))
    session.commit()
    
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    
    query = (session
    	.query(User)
    	.filter_by(id=1)
    	.update({"username": User.username + "a"}, synchronize_session=False)
    )
    # or
    user = (session.query(User).get(1))
    user.password = "zxcv"
    session.commit()
    

Flask-Migrate: 数据库迁移

配置数据库的初始数据框架,一般写成SQL脚本形式。 migrate 框架直接根据 model 层生成迁移脚本,可以方便的跟踪数据模型的修改和数据库的切换。(这个框架还是本教程作者自己开发的,强)

flask db子命令

  • flask db init:初始化,生成migrations目录
  • flask db migrate:生成迁移脚本,修改model后使其生效
    • flask db migrate -m "posts table"
  • flask db upgrade:应用数据库修改(开发阶段默认使用sqlite数据库
  • flask db downgrade:回滚上次的迁移

0x03 开发范式:用户系统

mixin:混入,多重继承的一种形式

表单和数据库支持分别解决了前端和后端的基本需求,下面可以上线一个基本功能了,用户登录。 所需插件:Flask-Login。

UserMixin类

UserMixin类集成了login插件要求的用户模型属性,将其混入到 User 模型中,即可用@login_required 实现权限控制。

1
2
@app.route('/result/', methods=['POST']) # NOTE:有顺序关系,反之则不生效
@login_required 

用户系统,包括登录、登出、注册几个功能。编写这些功能的步骤其实很类似:

  • 设计数据库,在model.py中
  • 设计表单对象,在form.py中
  • 设计页面,在模板.html中
  • 设计视图函数,在routes.py中

也对应了mvc框架的设计理念,比如设计表单就有些像 javaweb 中的 DAO 层。但也有区别, Flask 框架更希望业务逻辑写在数据库模型中,而视图函数尽量保持简洁,以方便单元测试。

PRG 模式

即为 Post/Redirect/Get,其格式大概如下:

1
2
3
4
5
6
7
8
9
@bp.route('/some_form', methods=['GET', 'POST'])
def some_form():
    # prepare forms 
    if form.validate_on_submit():
        # submit modification
        return redirect(url_for('main.some_form'))
    elif request.method == 'GET':
    	# GET data
    return render_template('some_form.html', form=form)

默认情况,提交 POST 请求后,如果直接刷新浏览器,会重新在 POST 一次。使用PRG模式即可解决重复提交表单的问题。

0x04 深入数据库:粉丝机制

数据库关系

要关注别人,就要让数据库记住我关注的人的名字,当然,只记住名字肯定不够,万一改名了呢。因此每个用户都需要有唯一有效的标识(其实更重要的是性能因素)。正因如此,数据库中每个表都要有一个唯一的列,称为主键(primary key)。当不同表之间存在关系,一个表要通过主键寻找其他表项,其他表的主键储存在本表中,称为外键(foreign key)。外键关联既可以表示一对一的关系,也可以一对多(1->n)。

SQLalchemy 对关系的定义如下:

  • 外键:db.ForeignKey('user.id')
  • 关系:db.relationship('Post', backref='author', lazy='dynamic')
    • 参数1:所关联的表(n in 1->n),这里是模型的变量名
    • 参数2:由 “n” 回调 “1” 的虚拟字段,用法:post.author

粉丝机制

然而,粉丝机制包括关注和被关注。这是一种多对多的关系,于是需要用含有两个外键的关联表表示。又因为关注者和被关注者在一个表里(User),这种关系又称为自引用

模型

  • 关联表只有引用类型,故不需要派生模型类

    1
    2
    3
    4
    5
    
    followers = db.Table(
        'followers',
        db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
        db.Column('followed_id', db.Integer, db.ForeignKey('user.id'))
    )
    
  • 为User添加关系

    1
    2
    3
    4
    5
    
    followed = db.relationship('User', # 右侧实体
        secondary=followers, # 指定关联表
        primaryjoin=(followers.c.follower_id == id), # 指定左关系
        secondaryjoin=(followers.c.followed_id == id), # 指定右关系
        backref=db.backref('followers', lazy='dynamic'), lazy='dynamic') # 指定回调
    

复杂查询

  • 查询粉丝列表

    • SQL 语句:SELECT * FROM user, followers WHERE followers.follower_id = 3 AND followers.followed_id = user.id

    • SQLalchemy 接口:user.followers.all()

    • 实际执行的 SQL 语句:(打印 query 对象得到)

      1
      2
      
      SELECT ,,,  FROM user, followers 
      WHERE followers.followed_id = ? AND followers.follower_id = user.id
      
    • NOTE:如果方法集成在model里,方法名不要和字段名相同,自己定义的方法会覆盖该字段。

  • 查看已关注用户的动态

    • SQL 语句:SELECT * FROM post JOIN followers on followers.followed_id = post.user_id where followers.follower_id = 2

    • SQLalchemy 接口:

    1
    2
    3
    4
    
    Post.query.join(
      followers, (followers.c.followed_id == Post.user_id)).filter(
        followers.c.follower_id == self.id).order_by(
           Post.timestamp.desc())
    
    • 实际执行的SQL语句:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      SELECT ,,, FROM 
      (SELECT ,,,
         FROM post JOIN followers ON followers.followed_id = post.user_id 
         WHERE followers.follower_id = ? 
         UNION SELECT * 
         FROM post 
         WHERE post.user_id = ?
      ) 
      AS anon_1 ORDER BY anon_1.post_timestamp DESC
      

由于python的弱类型特征,有时候很难明白函数之间传递的是什么对象。我们从上往下梳理一遍:

  • 请求到达路由函数,开始执行查询Post.query.....,此时只是在构造查询,并未取得数据,此时的对象类型:<class 'sqlalchemy.orm.query.Query'>
  • 直到get(),all(),paginate().items结束符等出现,查询才被执行,返回数据类型实例,如User。
  • 数据类实例传入模板,并由__str__等方法参与渲染。

0x05 网站美化

本教程提供的flask-bootstrap插件,较为简陋,且该插件年久失修,遂替换之。在此之前,先搞明白目前项目前端的架构

1
2
3
4
5
6
7
/templates
   auth/
   errors/
   base.html
   _posts.html
   index.html
   ... 

所有模板都有一个父模版:base.html,其结构如下:

1
2
3
4
5
6
7
8
9
{% extends 'bootstrap/base.html' %}
{% block title %}Hallo Wolrd{% endblock %}
{% block head %} ... {% endblock %}
{% block scripts %} ... {% endblock %}
{% block navbar %} ... {% endblock %}
{% block content %}
    ...
    {% block app_content %}{% endblock %}
{% endblock %}

app_content留空,即其余模板均在app_content内填充。

进一步追溯bootstrap/base.html的源码,发现其它 block 诸如navbar也都留空或仅仅配置了 Bootstrap 的 cdn。 由此,只需将base.html迁移即可。

在网上寻找新的UI模板,不要在中文互联网搜索,basically garbage。找到一个 Meterial 模板 还算顺眼,遂用之。

不熟悉 Bootstrap 布局的可以使用可视化工具来设计前端,如:http://www.ibootstrap.cn/

对照模板,将base.html掏空,效果如下:

遇到的bug有:

  • 下拉菜单失效:查询得知有可能是bootstrap版本冲突
    • //结果并不是,只是忘记引入js文件而已,我是傻逼。
  • 文件上传按钮消失:本教程中,表单渲染采用wtf.quick_form(),这玩意还是来自bootstrap/wtf.html

最后决定整个🐏了 Flask-Bootstrap 插件。

富文本编辑器

在《Flask Web开发:基于Python的Web应用开发实战》中提到了markdown编辑器的实现。

需要的包:

  • PageDown: JS 版 Markdown 渲染器,用于客户端预览。
  • Flask-PageDown: flask 集成插件。该插件需要注册
  • Markdown: Python 版 Markdown 渲染器,用于服务端渲染。
  • Bleach: HTML 清理器,保证安全性

为了兼顾安全和效率,做法是同时保存 markdown 源文本和 HTML 文件。步骤如下:

  • 表单改为 PageDownField
  • 模板引入 PageDown 宏,以实现即时预览
  • 为 Post 模型增加字段,并添加 markdown 渲染方法,该方法为类方法,需要@staticmethod修饰
  • 在模型外部监听数据库事件,仅当 markdown 文本出现变动时调用渲染方法。
  • 修改模板以显示服务端返回的 html 文本

然而预览器过于简陋,也很难修改。在github仓库上发现该插件也是本教程作者写的,已经很久没有维护。顿时对本书作者有些不满。

0x06 模块化应用:功能解耦

保持 app 作为全局变量的模式,可能会给后续引入新功能和单元测试带来麻烦。 要适应大型项目需求,需要把网站功能拆分成独立的模块。

Blueprint化

要实现解耦,一种功能的相关代码可以借助Blueprint归类到一个包里。其文件结构大致如下:

1
2
3
4
5
6
7
app/ 
    some_fuction/                       <-- blueprint package
        __init__.py                     <-- blueprint creation
        ... other code ...
    templates/
        some_fuction/                   <--  templates
    __init__.py                         <-- blueprint registration

创建blueprint与创建应用非常相似。

1
2
3
from flask import Blueprint
bp = Blueprint('func', __name__)
from app.func import Func

而消灭了app,蓝图内部的引用统一变成了蓝图名。而外部诸如url_for的参数则需要加上包名.做前缀。

应用工厂模式

工厂函数是一个外部函数,在这个函数内部执行插件注册和配置工作,并通过他返回应用实例。

1
2
3
4
5
6
7
8
# app/__init__.py
db = SQLAlchemy()
# ...
def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)
    db.init_app(app)
    # ...

返回后,flask提供的上下文对象current_app将指向应用实例。详见官方文档:应用上下文

多线程

current_app是线程绑定的,若要在诸如邮件服务的位于其他线程的功能调用他,则会发现没有赋值。 需要使用current_app._get_current_object()表达式。

Python概念辨析:包,库,插件

包 (package) 是指一种代码结构,只要有文件夹和 __init__.py 都是包。 库 (library) 和插件 (plugin) 都是从外部引入的包,区别在于,插件要集成进应用,所以需要注册等步骤;而库更独立,可以随时随地调用

0x07 开发帮手

本节讲解一些杂项。

调试

  • flask shell命令:为避免每次调试都要重新import app,使用上下文调用解释器,用@app.shell_context_processor装饰上下文函数

单元测试

unittest 库,详见下一篇

记录日志到文件

logger 库

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    if not app.debug:
        if not os.path.exists('logs'):
            os.mkdir('logs')
        file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240,
                                           backupCount=10)
        file_handler.setFormatter(logging.Formatter(
            '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)

        app.logger.setLevel(logging.INFO)
        app.logger.info('Microblog startup')

requirement.txt

装的库太多怎么办?只需要两条命令:

1
2
pip freeze > requirements.txt
pip install -r requirements.txt

0x08 网站上线

最后简单列出几种网站部署的方法,详情参考本教程或自行搜索。

native模式

  • 买主机
    • 连主机:ssh
  • 买域名
    • 配域名
  • 配环境
    • 数据库
    • 服务器
    • 其他依赖
  • 持续运维

容器化技术:docker

  • 写dockerfile
  • docker-compose up –build -d

云技术:PaaS

  • 注册云平台账户
  • 写Procfile
  • git push
Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy