最近看了Flask Web开发:基于Python的Web应用开发实战,书中详细介绍了Web程序的开发、测试、部署过程,值得一读!我在书中例子的基础上做了些更改,实现了一个简单的个人博客:NiceBlog,仅作为个人学习,还有许多不足的地方待完善,这里做一些简单的记录,方便以后查阅,代码放在了Github上:https://github.com/SheHuan/NiceBlog。
一、功能
1、对于普通用户,主要有如下功能:
- 注册、登录、重置密码(邮箱验证)
- 文章列表、详情
- 评论
- 喜欢
2、对于管理员,除了有普通用户的功能,主要有如下功能:
- 写文章(Markdown编辑)
- 用户权限管理(管理喜欢、评论的权限)
- 评论管理(删除、屏蔽)
3、为移动端提供相关api接口
二、项目结构
遵循了书中多文件Flask程序的基本结构,下边是NiceBlog的项目结构:
|-NiceBlog
|-app/ 主目录
|-api/ 为移动端提供接口的蓝本
|-auth/ 权限认证的蓝本
|-main/ 主体功能的蓝本
|-manage/ 管理相关功能的蓝本
|-static/ 静态资源目录(icon、js、css)
|-templates/ html模板目录
|-__init__.py 初始化项目的工厂函数
|-decorators.py 自定义的装饰器
|-email.py 发送邮件功能
|-excepitions.py 自定义异常处理
|-models.py 数据模型
|-migrations/ 数据库迁移脚本目录
|-nb_env/ 虚拟环境
|-tests/ 单元测试目录
|-config.py 配置文件
|-manage.py 启动程序以及其他的程序任务
|-requirements.txt 项目的依赖包列表
三、实现
1、工厂函数
一个简单的Flask Web程序可以写在单文件中,test.py
:
app = Flask(__name__)
# 定义的路由
@app.route('/')
def index():
return '<h1>Hello World!</h1>'
if __name__ == '__main__':
app.run()
但是执行程序时,由于在全局作用域创建导致无法动态修改配置,也导致了单元测试时无法在不同配置环境运行程序。所以可以把程序的创建转移到可显示调用的工厂函数中,也就是前边项目结构中的__init__.py
,在工厂函数中导入需要的Flask扩展:
def create_app(config_name):
app = Flask(__name__)
# 导致指定的配置对象
app.config.from_object(config[config_name])
# 调用config.py的init_app()
config[config_name].init_app(app)
# 初始化扩展
bootstrap.init_app(app)
mail.init_app(app)
moment.init_app(app)
db.init_app(app)
login_manager.init_app(app)
pagedown.init_app(app)
return app
2、蓝本
新的问题来了,使用工厂函数后,程序在运行时创建,而不是在全局作用域,必须等到执行create_app()
后才能使用@app.route()
装饰器,这时就要使用蓝本了,在蓝本中也可以定义路由,但是定义的路由处于休眠状态直到蓝本注册到程序后在成为程序一部分,例如main蓝本的目录结构如下:
|-NiceBlog
|-app/ 主目录
|-main/ 主体功能的蓝本
|-__init__.py 创建蓝本
|-errors.py 蓝本的错误处理
|-forms.py 蓝本的表单
|-views.py 蓝本的路由
首先看一下__init__.py
:
# 两个参数分别指定蓝本的名字、蓝本所在的包或模块(使用 __name__即可)
main = Blueprint('main', __name__)
# 导入路由模块、错误处理模块,将其和蓝本关联起来
# 在蓝本的末尾导入在两个模块里还要导入蓝本,防止循环导入依赖
from app.main import views, errors
2.1、表单
forms.py
是当前蓝本中的表单,项目中使用了FlaskForm
,可以方便的完成表单校验,例如创建、编辑文章的表单:
class BlogForm(FlaskForm):
title = StringField('请输入文章标题', validators=[DataRequired(), Length(1, 128)])
labels = StringField('文章标签(标签之间用空格隔开)', validators=[DataRequired()])
summary = TextAreaField('文章概要', validators=[DataRequired()])
content = TextAreaField('文章内容', validators=[DataRequired()])
preview = TextAreaField('文章预览', validators=[DataRequired()])
publish = SubmitField('发布')
save = SubmitField('保存')
2.2、路由
views.py
就是在蓝本中定义的路由,例如主页的路由:
@main.route('/create-blog', methods=['GET', 'POST'])
@admin_required
def create_blog():
"""
写新文章
"""
form = BlogForm()
if form.validate_on_submit():
blog = None
if form.publish.data:
# 发布
elif form.save.data:
# 保存草稿
return redirect(url_for('main.index'))
return render_template('markdown_editor.html', form=form, type='create')
注意装饰器为当前蓝本的名字main
,而不是之前的app
。create_blog()
称为视图函数,一个路由保存了URL到视图函数的映射关系。redirect(url_for('main.index'))
代表重定向到主页,url_for()
的参数为要跳转到的URL对应的视图函数名,但需要加上视图函数所在的蓝本名,即main.index
。render_template()
是Flask提供的函数,把Jinja2
模板引擎集成到了程序中,第一个参数是模板名称对应一个html文件,即执行该视图函数后最终要渲染的页面,后边的参数为传递给模板的参数。
2.3、错误处理
errors.py
是蓝本中的错误处理程序,例如:
@main.app_errorhandler(404)
def page_not_found(e):
if request.url.find('api') != -1:
return jsonify({'error': '请求的资源不存在', 'code': '404', 'data': ''})
return render_template('error/404.html'), 404
如果使用@main.errorhandler
装饰器只有当前蓝本的错误才能触发,为了使其它错误也能触发所以使用了@main.app_errorhandler
装饰器
2.4、注册蓝本
其它蓝本的定义也类似,最后需要在工厂函数中重注册蓝本,例如:
def create_app(config_name):
# ......
# 注册main蓝本
from app.main import main as main_blueprint
app.register_blueprint(main_blueprint)
# 注册auth蓝本
from app.auth import auth as auth_blueprint
# 使用url_prefix注册后,蓝本中定义的所有路由都会加上指定前缀,/login --> /auth/login
app.register_blueprint(auth_blueprint, url_prefix='/auth')
return app
3、前端
3.1、Jinja2
Flask使用Jinja2
作为模板引擎,模板是一个包含响应文本的HTML文件,其中包含只有在请求的上下文才知道的动态占位变量。默认情况下,模板保存在templates
目录。
在Jinja2
模板中{{ 变量名 }}
代表一个变量(注意变量名两边有一个空格,可以识别任意类型的变量),从渲染模板时使用的数据中获取。如果变量的值是HTML,由于转义的原因导致浏览器不能正常显示HTML代码,所以需要使用safe
过滤器,例如文章详情的HTML显示就需要这样处理,过滤器写在变量名后用竖线隔开{{ 变量名|过滤器名 }}
Jinja2
中用{% 控制语句 %}
代表控制结构来改变模板的渲染流程,例如:
# 条件控制
{% if xxx %}
<h1>Android</h1>
{% else %}
<h1>iOS</h1>
{% endif %}
# for循环
{% for x in xs %}
<li>{{ x }}</li>
{% endfor %}
# 导入
{% import 'xxx.html' %}
# 包含
{% include 'xxx.html' %}
导入、包含的目的都是为了复用,还可以通过继承实现复用,类似于类的继承:
# 继承
{% extends "base.html" %}
通过继承,模板中重复的代码都可以写在父模板里,例如导航条和页面底部footer就可以放在父模板里。
3.2、Bootstrap
前端使用了Bootstrap框架,它提供了良好的CSS规范,可以帮助我们更好的美化界面,具体的可参考:
https://v3.bootcss.com/,要在项目中集成它可以使用Flask的Flask-Bootstrap
扩展,直接在PyCharm安装,并在工厂函数中初始化,还要让项目的父模板继承Bootstrap的基类模板:
# common_base.html
{% extends "bootstrap/base.html" %}
Bootstrap的基类模板base.html
提供了一个网页框架,包含了Bootstrap中的所有CSS和JS文件。除此之外基类模板还定义了许多可在其子类模板中重定义的块,使用格式如下:
{% block 块名称 %}
{% endblock %}
常用的块如下:
块名称 | 含义 |
---|---|
head | <head>标签中的内容 |
title | <title>标签中的内容 |
body | <body>标签中的内容 |
styles | css样式单的定义 |
navbar | 自定义的导航条 |
content | 自定义的页面内容 |
page_content | 定义content在内部 |
scripts | JS声明,一般在模板尾部 |
注意如在子模板在模板已有的块中添加新内容,需要使用super()
函数:
{% block scripts %}
{{ super() }}
<!-- 新加的内容 -->
{% endblock %}
3.3、Flask-WTF
在2.1中我们已经看到了用Flask-WTF
定义表单的方式,即自定义的表单类继承FlaskForm
类,并添加需要的类变量,Flask-WTF
定义了许多标准字段可以被渲染成指定的表单类HTML标签,例如:
字段名 | 对应的H5标签 |
---|---|
StringField | 文本框 |
TextAreaField | 多行文本框 |
PasswordField | 密码输入框 |
BooleanField | 复选框 |
SubmitField | 表单提交按钮 |
同时Flask-WTF
还提供了许多常用的表单校验函数,例如:Email()
、EqualTo()
、DataRequired()
、Length()
等等,当点击提交按钮时,会自动校验表单是否满足预定义的条件。
在2.2中,我们通过参数把表单类的实例同步form
参数传入模板:
render_template('markdown_editor.html', form=form, type='create')
在模板中可以通过如下方式生表单(只保留了部分核心代码):
<form method="post" role="form" class="height-full">
{{ form.hidden_tag() }}
{{ form.title(id="title", class="form-control editor-blog-title", placeholder=form.title.label.text) }}
{{ form.labels(class="form-control editor-blog-area", placeholder=form.labels.label.text) }}
{{ form.summary(class="form-control editor-blog-area", placeholder=form.summary.label.text, rows=3) }}
{{ form.publish(class="btn btn-info") }}
{{ form.save(class="btn btn-success") }}
</form>
这样的好处是我们能自定义表单的样式等等,但是工作量蛮大的,如果对表单样式没有特殊的需求,Bootstrap中的表单样式可以满足需求,可以通过Flask-Bootstrap
提供的辅助函数快速的渲染表单,只需要如下两步:
{% import "bootstrap/wtf.html" as wtf %}
{{ wtf.quick_form(form) }}
例如登录的H5模板就是这样做的。
form.hidden_tag()
模板参数将被替换为一个隐藏字段,用来实现在配置中激活的 CSRF 保护。如果你已经激活了CSRF,这个字段需要出现在你所有的表单中。
在2.2中,如果点击表单提交按钮,所有的表单都能成功通过校验,则form.validate_on_submit()
的值为True
,否则校验失败,网页会出现对应提示。如果有两个提交按钮,那么在校验成功后,还需要判断点击的是哪个按钮,否则所有的按钮都执行了同一个操作。例如我们的文章发布和保存按钮,当表单类中的按钮字段的data
属性为True
则代表该按钮被点击,例如:
if form.publish.data:
# 发布
elif form.save.data:
# 保存草稿
3.4、jQuery
有些页面需要在相关操作后修改控件的CSS样式,例如文章详情的喜欢和取消喜欢按钮,最简单的方式是操作成功后直接刷新整个页面,但这样体验并不好,更好的方式是局部刷新。这里直接使用jQuery(Bootstrap也提供了类似的操作,同时包含了jQuery,不需要单独导入jQuery)来实现。使用jQuery
强大的选择器功能可以方便的得到要操作的DOM节点
,按钮的点击也是发起一个请求,jQuery
也集成了ajax
,可以方便的处理请求,在请求完成后根据响应结果来更改DOM节点
的样式。看下按钮的点击事件:
favourite = function (id) {
if ($('.blog-favourite-btn').length > 0) {//取消喜欢
$.get('/manage/blog/cancel_favourite', {
id: id
}).done(function (data) {
$('.blog-favourite-btn span').removeClass('glyphicon-heart').addClass('glyphicon-heart-empty');
$('.blog-favourite-btn').removeClass('blog-favourite-btn').addClass('blog-unfavourite-btn');
})
} else if ($('.blog-unfavourite-btn').length > 0) {//喜欢
$.get('/manage/blog/favourite', {
id: id
}).done(function (data) {
if ('200' === data) {
$('.blog-unfavourite-btn span').removeClass('glyphicon-heart-empty').addClass('glyphicon-heart');
$('.blog-unfavourite-btn').removeClass('blog-unfavourite-btn').addClass('blog-favourite-btn');
}
if ('403' === data) {
alert('没有操作权限');
}
})
}
}
4、Markdown
书中使用的是Flask-PageDown
、Markdown
两个库来实现对Markdown功能的支持,但是不够理想,有些Markdown语法并不能很好的支持,例如Flask-PageDown
实时预览时并不支持代码块和表格等。最后使用了marked这个库,它是一个全功能的Markdown解析器和编译器,用JavaScript编写,构建速度快,其实就是实时将用Markdown语法编辑的内容转换成对应的HTML预览,但是没有CSS样式的HTML还是有点丑,github-markdown-css是一个不错的选择,可以帮助我们实现github风格的Markdwon预览。既然是要编辑文章那么直接使用HTML里的<textarea>
肯定难以实现理想的效果,这里使用了ace,它是一个用JavaScript编写的独立代码编辑器,下载ace-builds/arc-min
即可。核心的帮助工具就这些了,接下来就是把他们组合起来,首先看HTML界面主要有编辑和预览两部分:
<!--编辑-->
<div class="col-md-6 markdown-panel">
<div id="markdown-edit"></div>
</div>
<!--预览-->
<div class="col-md-6 markdown-panel">
<div id="markdown-preview" class="markdown-body"></div>
</div>
接下来就是编辑器的初始化了:
<script>
//编辑器配置
var ace_edit = ace.edit('markdown-edit');
ace_edit.setTheme('ace/theme/chrome');
ace_edit.getSession().setMode('ace/mode/markdown');
ace_edit.renderer.setShowPrintMargin(false);
//字体大小
ace_edit.setFontSize(15);
//自动换行
ace_edit.setOption('wrap', 'free');
$("#markdown-edit").keyup(function () {
// 实现Markdown到HTML的预览
$("#preview").text(marked(ace_edit.getValue()));
});
</script>
更多细节可参考markdown_editor.html,看一下效果:
5、数据库
数据库使用的是MySql
,同时使用了ORM
框架SQLAlchemy
把关系数据库的表结构映射到对象上,来简化数据库的操作,Flask有一个Flask-SQLAlchemy
扩展可以方便的在程序中使用SQLAlchemy
,首先需要指定数据库URL,这一步在config.py
完成:
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@127.0.0.1:3306/niceblog_dev'
然后在工厂函数完成配置。之后就是定义数据模型了,项目中一共定义了6个数据模型:User
、Role
、Blog
、Comment
、Favourite
、Label
,都继承db.Model
,在数据模型中指定表名称、列名称等信息,例如保存文章信息的Blog
:
class Blog(db.Model):
"""
博客数据Model
"""
__tablename__ = 'blogs'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(128))
summary = db.Column(db.Text)
content = db.Column(db.Text)
content_html = db.Column(db.Text)
# 发布日期
publish_date = db.Column(db.DateTime, index=True)
# 最后的编辑日期
edit_date = db.Column(db.DateTime, index=True)
# 外键,和User表对应
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# 是否是草稿
draft = db.Column(db.Boolean)
# 是否禁用评论
disable_comment = db.Column(db.Boolean, default=False)
# 被浏览的次数
views = db.Column(db.Integer, default=0)
comments = db.relationship('Comment', backref='blog', lazy='dynamic')
favourites = db.relationship('Favourite', backref='blog', lazy='dynamic')
配置好了数据库、定义好了数据模型,就可以通过如下命令来操作数据库了:
-
db.create_all()
:创建表 -
db.session.add()
:插入行、修改行,最后需要执行db.session.commit()
-
db.session.delete()
:删除行,最后需要执行db.session.commit()
-
数据模型名.query().查询过滤器.查询执行函数
:查询行
常用的查询过滤器有:filter()
、filter_by()
、limit
、offset()
、order_by()
、group_by()
常用的查询执行函数有:all()
、first()
、first_or_404()
、get()
、get_or_404()
、count()
、paginate()
如果在shell中操作数据库,每次都要导入数据库实例和数据模型,如何避免这个问题呢?由于项目使用了Flask-Script
命令行解释器,支持自定义命令,可以让Flask-Script
的shell命令自动导入特定对象即可:
def make_shell_context():
return dict(app=app, db=db, User=User, Role=Role, Blog=Blog, Comment=Comment, Favourite=Favourite, Label=Label,
Permission=Permission)
manager.add_command('shell', Shell(make_context=make_shell_context))
开发中修改数据模型是不可避免的,为了不发生删表重建导致数据丢失的问题,我们需要使用数据库迁移框架
,增量式的把数据模型的改变应用到数据库中,我们可以直接使用Flask-Migrate
来完成,在Flask-Script
集成数据库迁移功能:
migrate = Migrate(app, db)
manager.add_command('db', MigrateCommand)
数据库迁移只要有如下三个命令:
-
python manage.py db init
:创建迁移仓库,初始执行一次即可 -
python manage.py db migrate --message "initial migration"
:创建迁移脚本 -
python manage.py db upgrade
:更新数据库
每次修改数据模型后需要更新数据库时执行命令2、3即可。
6、接口开发
api蓝本的目录结构如下:
|-NiceBlog
|-app/ 主目录
|-api/ 为移动端提供接口的蓝本
|-__init__.py 创建蓝本
|-authentication.py 登录、注册、token检验
|-blogs.py 文章列表、详情的接口
|-comments.py 评论相关接口
|-decorators.py 自定义装饰器
|-favourites.py 喜欢操作相关的接口
|-labels.py 文章分类标签接口
|-responses.py 帮助返回JSON数据
Flask提供的jsonify()
函数可以方便的把一个字典转换成JSON串返,例如返回文章分类标签的路由可以这么写:
@api.route('/labels/')
def get_labels():
labels = Label.query.all()
data = {'labels': [label.to_json() for label in labels]}
return jsonify({'error': '', 'code': '200', 'data': data})
to_json()
方法是数据模型中Label
类的方法,完成数据模型到JSON格式化的序列化字典转换:
def to_json(self):
json_label = {
'id': self.id,
'name': self.name,
}
return json_label
为了保证接口有一定的安全性,不被随意访问,除了登录、注册、以及文章预览的html页面外其他接口都需要一个token参数,token可以在登录后得到,token过期后需要重新请求。由于要对请求携带的token参数校验,可以定义一个before_request
钩子,在每次请求前统一完成token的校验:
@api.before_request
def before_request():
url = request.url
if url.find('login') == -1 and url.find('register') == -1 and url.find('preview') == -1:
token = request.args.get('token')
if token is None:
return unauthorized('token缺失')
user = User.verify_auth_token(token)
if user is None:
return forbidden('token过期,请重新登录')
else:
# g是程序上下文,用作临时存储对象,
# 保存当前的请求对应的user,每次请求都会更新
g.current_user = user
测试接口可以使用HTTPie,通过PyCharm在虚拟环境安装HTTPie后,启动Web服务,windows下通过cmd进入虚拟环境目录,执行Scripts\activate
激活虚拟环境(退出虚拟环境执行deactivate
):
执行登录请求,命令如下:
http POST http://127.0.0.1:5000/api/login/ email==shehuan320@163.com password==123456
响应如下:
使用登录的得到的token请求文章分类标签接口:
http GET http://127.0.0.1:5000/api/labels/ token==eyJhbGciOiJIUzI1NiIsImlhdCI6MTUxODEzOTQwNiwiZXhwIjoxNTE4NzQ0MjA2fQ.eyJpZCI6MX0.EujL1Pb4lg20Bb2QWngop1N79os0LdFWniA8bL4JQHo
响应如下:
四、安装
以下的安装步骤是基于Windows环境的!
- 从Guthub clone NiceBlog到本地
- 安装Python 3 的开发环境
- 安装PyCharm开发工具,导入项目,建议使用虚拟环境,可直接在 PyCharm 中创建一个虚拟环境,或者使用命令行创建。
- 在虚拟环境中安装
requestments.txt
中的扩展包,直接在 PyCharm 的 Terminal 执行如下命令:
pip install -r requirements.txt
- 安装MySql,创建数据库,并在
config.py
中替换为自己创建的数据名,并修改用户名和密码 - 在 Terminal 执行
python manage.py shell
切换到 shell 环境,再执行db.create_all()
创建数据表 - 由于注册账号使用了qq邮箱验证,请在
config.py
中替换自己的qq邮箱和授权登录密码,并更改管理员邮箱为自己的邮箱。 - 执行
exit()
退出 shell 环境,再执行python manage.py runserver
就可以启动 Web 服务了,默认运行在http://127.0.0.0.1:5000
。 - 在浏览器访问
http://127.0.0.0.1:5000
,你就可以进行注册账号,创建文章等操作了,希望一切顺利吧!
最后附上几张截图: