最近看了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
,你就可以進行注冊賬號,創建文章等操作了,希望一切順利吧!
最后附上幾張截圖: