2023年12月24日 星期日

Flask網頁開發 - 第二版 - Flask基礎 (2)

  身為ESG團隊內唯一的開發者,不只要著重Data Science的知識技術,更要使用CICD等工具完成資訊產品的自動化建置。然而隨著ESG產品的迫切需求,網頁開發這類我最沒興趣的資訊領域,恐怕也得開始硬著頭皮學習了,畢竟不是所有的客戶,都有能力使用程式工具進行各式各樣的資料工作,因此嘗試以Flask製作具使用者介面的產品原型,已是長期下來,我必須具備的工作技能之一(什麼都要會,真的是工作難做,錢難賺)

  Flask是以Python進行開發的輕量級框架,以我現在的工作需求來說,會比選擇學習Django這樣的重量級框架來得適合;選擇閱讀Miguel Grinberg著作,賴屹民翻譯的《Flask網頁開發 第二版》(Flask Web Development, 2nd Edition)這本書,也是參考各個開發者的讀書心得而做的選擇;本書作者雖然很詳盡地說明各個程式碼片段的定義與功能,但自己距離想要隨心所欲地駕馭Flask這個網頁開發框架,恐怕仍相當遙遠。

  Flask的學習曲線非常陡峭,網頁開發所需具備的基本知識也相當繁瑣,所幸我曾經做過DevOpsInfraOps相關的工作,至少有基本的知識技術,支援我繼續深入學習Flask這個熱門但不易入門的網頁開發框架。

第一部分、詳細介紹Flask

第一章、安裝

l   本書作者的範例Repository如下,需要切換branch以檢視各章節範例:
https://github.com/miguelgrinberg/flasky

l   安裝Flask
pip install flask

第二章、基本app結構(2a2b)

l   git checkout 2a
git checkout 2b
git checkout 2c

l   傳統路由方式,add_url_rule()的三個引數為URL、端點名稱、view函式:
# http://localhost:5000/
def index():
    return "<h1>Hello World!</h1>"
app.add_url_rule("/", "index", index)

l   一般路由方式:
# http://localhost:5000/
@app.route("/")
def index():
    return "<h1>Hello World!</h1>"

l   動態路由方式,路由的動態成分預設使用字串:
# http://localhost:5000/user/Timmy
#
也可以使用不同的形態例如<int:id>
# Flask
支援的路由型態包含stringintfloatpathpath是可以使用正斜線的特殊字串型態
@app.route("/user/<name>")
def user(name):
    return f"<h1>Hello, {name}!</h1>"

l   啟動Flask網頁方式一,直接在主程式加上程式碼如下:
if __name__ == "__main__":
    # reloader
Flask會查看專案的所有原始碼檔案,並且在任何檔案被修改時自動重新啟動伺服器
    # debugger
:當app出現未處理的異常狀況時,debugger會在瀏覽器上出現
    # app.run(debug=True)
    app.run()

l   啟動Flask網頁方式二,下指令:
# Linux
方式如下
export FLASK_APP=hello.py
flask run
# Windows
方式如下
set FLASK_APP=hello.py
flask run

l   可以先將export FLASK_DEBUG=1等環境變數命令存檔為script一次執行:
# Linux
方式如下
source ./env.sh
# Windows
方式如下
call ./env.bat

l   啟動Flask Shell方式:
# Linux
方式如下
export FLASK_APP=hello.py
flask shell
# Windows
方式如下
set FLASK_APP=hello.py
flask shell

l   啟動Flask Shell後,檢視所有路由範例,其中/static/<filename>Flask加入的特殊路由,用途是存取靜態檔案:
>>> from hello import app
>>> app.url_map
Map([<Rule '/' (HEAD, OPTIONS, GET) -> index>,
 <Rule '/static/<filename>' (HEAD, OPTIONS, GET) -> static>,
 <Rule '/user/<name>' (HEAD, OPTIONS, GET) -> user>])

l   Flask Context全域變數:
各個執行續需要在request內看到不同的物件,Flask可藉由context讓某一個執行緒可全域存取某些變數。

Variable Name

Context

說明

current_app

application context

運行中的app的實例。

g

application context

app處理request時,可將這個物件當成暫時存放區。這個變數會在各個request中重設。

request

request context

request物件,封裝了用戶端送來的HTTP request內容。

session

request context

使用者session,它是個字典,app可用它來儲存需要在各個request之間"記住"的值。

l   可直接執行程式測試Application Context (current_app)啟動與否:
from flask import Flask, current_app

# application context
未啟動,故執行失敗
# print(current_app.name)

app = Flask(__name__)
app_ctx = app.app_context()
app_ctx.push()
# application context
已啟動,故執行成功
print(current_app.name)
app_ctx.pop()

l   Request Context (request)範例:
from flask import Flask, request
app = Flask(__name__)

@app.route("/")
def index():
    user_agent = request.headers.get("User-Agent")
    return f"<p>Your browser is {user_agent}</p>"

l   狀態碼範例:
from flask import Flask
app = Flask(__name__)

@app.route("/")
def index():
    return "<h1>Bad Request</h1>", 400

l   回應物件設置cookie範例:
from flask import Flask, make_response
app = Flask(__name__)

@app.route("/")
def index():
    response = make_response("<h1>This document carries a cookie!</h1>")
    response.set_cookie("answer", "42")
    return response

l   轉址範例:
from flask import Flask, redirect
app = Flask(__name__)

@app.route("/")
def index():
    return redirect("http://www.example.com")

l   request勾點是一種裝飾器,Flask提供四種勾點:
@app.before_request
註冊在每一個request之前執行的函式。
@app.before_first_request
註冊只需要在第一個request之前執行的函式。
@app.after_request
沒有異常情況下,註冊需要在每一個request之後執行的函式。
@app.teardown_request
即使在有未處理的異常情況下,註冊需要在每一個request之後執行的函式。

第三章、模板(3a)

l   git checkout 3a

l   在預設情況下,Flask會在app主目錄的templates子目錄裡面尋找模板:
from flask import Flask, render_template
app = Flask(__name__)

@app.route("/user/<username>")
def user(username):
    # templates/user.html
模板:<h1>Hello, {{ name }}!</h1>
    return render_template("user.html", name=username)

l   Jinja2可辨識任何型態的變數:
<p>A value from a dictionary: {{ mydict['key'] }}.</p>
<p>A value from a list, with a variable index: {{ mylist[myintvar] }}.</p>
<p>A value from an object's method: {{ myobj.somemethod() }}.</p>

l   Jinja2可以用過濾器(filter)來修改變數:
<h1>Hello, {{ name|capitalize }}!</h1>

l   Jinja2條件陳述式:
{% if user %}
    Hello, {{ user }}!
{% else %}
    Hello, Stranger!
{% endif %}

l   Jinja2迴圈:
<ul>
    {% for comment in comments %}
        <li>{{ comment }}</li>
    {% endfor %}
</ul>

l   Jinja2巨集:
{% macro render_comment(comment) %}
    <li>{{ comment }}</li>
{% endmacro %}
<ul>
    {% for comment in comments %}
        {{ render_comment(comment) }}
    {% endfor %}
</ul>

l   Jinja2巨集放在一個獨立的檔案:
{% import 'macros.html' as macros %}
<ul>
    {% for comment in comments %}
        {{ macros.render_comment(comment) }}
    {% endfor %}
</ul>

l   可以把需要在許多地方重複使用的模板程式碼放在一個單獨的檔案內,並在所有模板include它:
{% include 'common.html' %}

l   模板繼承:
<!--
基礎模板base.html -->
<html>
<head>
    {% block head %}
    <title>{% block title %}{% endblock %} - My Application</title>
    {% endblock %}
</head>
<body>
    {% block body %}
    {% endblock %}
</body>
</html>

<!--
繼承基礎模板的模板 -->
{% extends "base.html" %}
{% block title %}Index{% endblock %}
{% block head %}
    {{ super() }}
    <style>
    </style>
{% endblock %}
{% block body %}
<h1>Hello, World!</h1>
{% endblock %}

第三章、模板(3b)

l   git checkout 3b

l   要整合Bootstrap (https://getbootstrap.com/)app,原本的做法是按照Bootstrap文件的建議,對HTML模板做所有必要的修改,但是使用Flask擴充套件更容易完成這種整合工作:
pip install flask-bootstrap

l   主程式整合Bootstrap做法:
from flask import Flask, render_template
from flask_bootstrap import Bootstrap
app = Flask(__name__)
bootstrap = Bootstrap(app)

l   模板整合Bootstrap做法:
{% extends "bootstrap/base.html" %}

l   Flask-Bootstrap的基礎模板區塊,使用方式請參考Flask-BootstrapBasic usage

doc

整個HTML文件

html_attribs

<html>標籤裡面的屬性

html

<html>標籤的內容

head

<head>標籤的內容

title

<title>標籤的內容

metas

<metas>標籤清單

styles

CSS定義

body_attribs

<body>標籤內的屬性

body

<body>標籤內容

navbar

使用者定義的導覽列

content

使用者定義的網頁內容

scripts

在文件底部的JavaScript宣告

第三章、模板(3c3d)

l   git checkout 3c
git checkout 3d

l   自訂錯誤網頁:
@app.errorhandler(404)
def page_not_found(e):
    return render_template("404.html"), 404

@app.errorhandler(500)
def internal_server_error(e):
    return render_template("500.html"), 500

l   連結url_for()的使用方式:
呼叫url_for("index")會收到/
呼叫url_for("index", _external=True)會收到http://localhost:5000/
呼叫url_for("user", username="timmy", _external=True)會收到http://localhost:5000/user/timmy
呼叫url_for("user", username="timmy", page=2, version=1)會收到/user/timmy?page=2&version=1
呼叫url_for("static", filename="css/styles.css", _external=True)會收到http://localhost:5000/static/css/styles.css

l   在主程式使用url_for()範例:
from flask import url_for

@app.route("/user/<username>")
def user(username):
    print(url_for("user", username="timmy", _external=True))
    return render_template("user.html", name=username)

l   在模板使用url_for()範例:
{% block head %}
{{ super() }}
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
{% endblock %}

第三章、模板(3e)

l   git checkout 3e

l   Moment.js可在瀏覽器轉譯日期與時間,Flask-Moment是讓Flask app使用的擴充套件,可讓你輕鬆地將Moment.js整合到Jinja2模板裡面:
pip install flask-moment

l   主程式整合Flask-Moment做法:
from flask import Flask, render_template
from flask_bootstrap import Bootstrap
from datetime import datetime
from flask_moment import Moment
app = Flask(__name__)
bootstrap = Bootstrap(app)
moment = Moment(app)

@app.route("/")
def index():
    return render_template("index.html", current_time=datetime.utcnow())

l   Flask-Moment除了需要Moment.js之外,也需要jQuery.js,必須在HTML文件中include這兩個程式庫,但因為Bootstrap已經include jQuery.js了,本書案例只要加入Moment.js即可:
<!-- templates/base.html
匯入Moment.js程式庫 -->
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
<!-- Flask-Moment
轉譯的時戳可以轉換成許多語言 -->
{{ moment.locale('es') }}
{% endblock %}

l   模板整合Flask-Moment做法:
<!-- format('LLL')
函式會根據用戶端電腦的時區與地區設定來轉譯日期與時間,它的引數是轉譯樣式,從'L''LLL'代表四個等級的詳細程度 -->
<p>The local date and time is {{ moment(current_time).format('LLL') }}.</p>

<!-- fromNow()
會轉譯相對時戳,而且refresh=True選項會隨著時間的推移而自動更新它,a few seconds ago -> a minute ago以此類推 -->
<p>That was {{ moment(current_time).fromNow(refresh=True) }}.</p>

第四章、web表單(4a4b)

l   git checkout 4a
git checkout 4b

l   Flask-WTF擴充套件可讓你在處理網頁表單時更輕鬆:
pip install flask-wtf

l   主程式整合Flask-WTF做法:
from flask import Flask, render_template, session, redirect, url_for
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)
# Flask-WTF
不需要做app層級的初始化,但是它希望app設置一個密鑰,用來保證客戶端會話的安全
app.config["SECRET_KEY"] = "hard to guess string"
bootstrap = Bootstrap(app)
moment = Moment(app)

class NameForm(FlaskForm):
    name = StringField("What is your name?", validators=[DataRequired()])
    submit = SubmitField("Submit")

#
因為要求瀏覽器重新整理網頁時,瀏覽器會重複送出上一個送出的request,改為使用轉址來回應POST請求(Post/Redirect/Get模式)可以避免此問題
# app
可以將資料放在使用者session,在兩次request之間"記得"
@app.route("/", methods=["GET", "POST"])
def index():
    form = NameForm()
    if form.validate_on_submit():
        session["name"] = form.name.data
        return redirect(url_for("index"))
    return render_template("index.html", form=form, name=session.get("name"))

l   模板整合Flask-WTF做法:
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block page_content %}
<div class="page-header">
    <h1>Hello, {% if name %}{{ name }}{% else %}Stranger{% endif %}!</h1>
</div>
{{ wtf.quick_form(form) }}
{% endblock %}

l   WTForms標準HTML欄位:

BooleanField

TrueFalse值的確認方塊

DateField

以指定的格式接收datetime.date值的文字欄位

DateTimeField

以指定的格式接收datetime.datetime值的文字欄位

DecimalField

接收decimal.Decimal值的文字欄位

FileField

檔案上傳欄位

HiddenField

隱藏文字欄位

MultipleFileField

多檔案上傳欄位

FieldList

一串指定類型的欄位

FloatField

接受浮點值的文字欄位

FormField

在容器表單內當成欄位的表單

IntegerField

接受整數值的文字欄位

PasswordField

密碼文字欄位

RadioField

單選按鈕列

SelectField

下拉式選單

SelectMultipleField

可多重選取的下拉式選單

SubmitField

表單送出按鈕

StringField

文字欄位

TextAreaField

多行文字欄位

l   WTForms驗證函式:

DataRequired

驗證欄位在轉換型態後含有資料

Email

驗證email地址

EqualTo

比較兩個欄位的值;當你要求輸入兩次密碼來做確認時可以使用

InputRequired

在轉換型態之前驗證欄位裡面有資料

IPAddress

驗證IPv4網址

Length

驗證輸入字串的長度

MacAddress

驗證MAC位址

NumberRange

驗證輸入值在某個數字範圍內

Optional

允許欄位沒有被輸入,忽略其他的驗證函式

Regexp

用正規表達式來驗證輸入

URL

驗證URL

UUID

驗證UUID

AnyOf

驗證輸入是一串可能的值之一

NoneOf

驗證輸入不屬於一串可能的值

第四章、web表單(4c)

l   git checkout 4c

l   主程式整合閃現(flash)訊息做法:
from flask import Flask, render_template, session, redirect, url_for, flash
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)
app.config["SECRET_KEY"] = "hard to guess string"
bootstrap = Bootstrap(app)
moment = Moment(app)

class NameForm(FlaskForm):
    name = StringField("What is your name?", validators=[DataRequired()])
    submit = SubmitField("Submit")

@app.route("/", methods=["GET", "POST"])
def index():
    form = NameForm()
    if form.validate_on_submit():
        old_name = session.get("name")
        if old_name is not None and old_name != form.name.data:
            flash("Looks like you have changed your name!")
        session["name"] = form.name.data
        return redirect(url_for("index"))
    return render_template("index.html", form=form, name=session.get("name"))

l   模板整合閃現(flash)訊息做法,get_flashed_messages會將session內的所有message全部取出:
{% for message in get_flashed_messages() %}
<div class="alert alert-warning">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{ message }}
</div>
{% endfor %}

第五章、資料庫(5a5b5c)

l   git checkout 5a
git checkout 5b
git checkout 5c

l   Flask-SQLAlchemy是一種Flask擴充包,可讓你在Flask app中輕鬆使用SQLAlchemySQLAlchemy是一種強大的關聯式資料庫框架,它支援許多資料庫後端,且提供高階的ORM以及低階操作,可讓你操作資料庫的原生SQL功能:
pip install flask-sqlalchemy

l   主程式整合Flask-SQLAlchemy做法:
import os
from flask import Flask, render_template, session, redirect, url_for
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired
from flask_sqlalchemy import SQLAlchemy

basedir = os.path.abspath(os.path.dirname(__file__))

app = Flask(__name__)
app.config["SECRET_KEY"] = "hard to guess string"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(basedir, "data.sqlite")
#
使用較少的記憶體
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

bootstrap = Bootstrap(app)
moment = Moment(app)
db = SQLAlchemy(app)

class Role(db.Model):
    __tablename__ = "roles"
   
    # primary_key
主鍵、unique不允許重複的值、index為該欄位建立索引、nullable允許空值、default預設值
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64), unique=True)
   
    #
此例為一對多關係,若為一對一關係,要設定db.relationship(uselist=False)
    # backref="role"
中的role像是暗號,未來在讀取User表格時,後面只需像這樣加上User.role,就可以讀取到Role表格內的資料
    users = db.relationship("User", backref="role", lazy="dynamic")
   
    #
提供給人看的字串,協助除錯或測試
    def __repr__(self):
        return "<Role %r>" % self.name

class User(db.Model):
    __tablename__ = "users"
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), unique=True, index=True)
    role_id = db.Column(db.Integer, db.ForeignKey("roles.id"))
   
    def __repr__(self):
        return "<User %r>" % self.username

class NameForm(FlaskForm):
    name = StringField("What is your name?", validators=[DataRequired()])
    submit = SubmitField("Submit")

#
使用flask shell命令自動匯入資料庫實例(app)與模型(dbUserRole)至殼層
@app.shell_context_processor
def make_shell_context():
    return dict(db=db, User=User, Role=Role)

@app.route("/", methods=["GET", "POST"])
def index():
    form = NameForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.name.data).first()
        if user is None:
            user = User(username=form.name.data)
            db.session.add(user)
            db.session.commit()
            session["known"] = False
        else:
            session["known"] = True
        session["name"] = form.name.data
        return redirect(url_for("index"))
    return render_template("index.html", form=form, name=session.get("name"), known=session.get("known", False))

l   Flask-SQLAlchemy常見資料庫URI寫法:

MySQL

mysql://username:password@hostname/database

Postgres

postgresql://username:password@hostname/database

SQLite (Linux, macOS)

sqlite:////absolute/path/to/database

SQLite (Windows)

sqlite:///c:/absolute/path/to/database

l   使用flask shell操作此範例主程式的模型:
#
刪除、建立所有資料表
from hello import db
db.drop_all()
db.create_all()

#
準備要插入的資料
from hello import Role, User
admin_role = Role(name="Admin")
mod_role = Role(name="Moderator")
user_role = Role(name="User")
user_john = User(username="john", role=admin_role)
user_susan = User(username="susan", role=user_role)
user_david = User(username="david", role=user_role)

#
目前還沒有被寫入資料庫,故沒有id
admin_role.id

#
對資料庫所做的修改都是透過資料庫session來管理的
#
單筆資料加入方式為db.session.add(admin_role)
db.session.add_all([admin_role, mod_role, user_role, user_john, user_susan, user_david])
#
提交
db.session.commit()
#
若尚未提交,可使用db.session.rollback()回復

#
修改資料列
admin_role.name = "Administrator"
db.session.add(admin_role)
db.session.commit()

#
刪除資料列
db.session.delete(mod_role)
db.session.commit()

#
查詢資料列,all()會用串列回傳所有結果,first()只會回傳第一個結果
Role.query.all()
User.query.all()
User.query.filter_by(role=user_role).all()
Role.query.filter_by(name="User").first()

#
查看SQLAlchemy為查詢指令產生的原生SQL查詢指令
str(User.query.filter_by(role=user_role))

l   常見的SQLAlchemy查詢過濾器:
filter()
filter_by()limit()offset()order_by()group_by()

l   常見的SQLAlchemy查詢指令執行方法:
all()
first()first_or_404()get()get_or_404()count()paginate()

l   lazy="dynamic"與查詢指令:
user_role = Role.query.filter_by(name="User").first()
user_role.users[0].role

#
users = db.relationship("User", backref="role")沒有設定lazy="dynamic",會自動呼叫all()
user_role.users

#
users = db.relationship("User", backref="role", lazy="dynamic")有設定lazy="dynamic",不會自動呼叫all()
user_role.users.all()

第五章、資料庫(5d)

l   git checkout 5d

l   Flask-SQLAlchemy只會在資料表還不存在時才會用模型建立它們,所以用它更新資料表唯一的做法就是銷毀舊的資料表,比較好的做法是使用Flask-Migrate資料庫遷移框架:
pip install flask-migrate

l   主程式整合Flask-Migrate做法:
import os
from flask import Flask
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

basedir = os.path.abspath(os.path.dirname(__file__))

app = Flask(__name__)
app.config["SECRET_KEY"] = "hard to guess string"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(basedir, "data.sqlite")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

bootstrap = Bootstrap(app)
moment = Moment(app)
db = SQLAlchemy(app)
migrate = Migrate(app, db)

l   可以用flask db init子命令來加入對於資料庫遷移的支援,這個命令會建立一個migrations目錄,將所有遷移腳本放在那裡,以下為建立遷移腳本必須採取的程序:
1.
對模型類別做必要的變更。
2.
flask db migrate -m "initial migration"命令在migrations/versions目錄建立自動遷移腳本。
3.
檢查生成的腳本並修改它,讓它可以準確地描述模型的變更。
4.
將遷移腳本加入原始檔控制系統。
5.
flask db upgrade命令將遷移套用到資料庫。

l   擴充最新遷移腳本的程序:
1.
flask db downgrade命令在資料庫移除上一次遷移。
2.
刪除上一個遷移腳本。
3. flask db migrate
命令。
4.
後續步驟與建立遷移腳本相同。

l   使用flask db stamp命令可將既有的資料庫標記為已升級。

第六章、Email(6a6b)

l   git checkout 6a
git checkout 6b

l   Flask-Mail擴充套件包裝了smtplib,並將它與Flask做很好的整合,但Pythonsmtplib程式庫不支援OAuth2驗證,故依照此範例以Gmail寄信時必須先調整設定(可參考https://github.com/twtrubiks/Flask-Mail-example)
pip install flask-mail

l   主程式整合Flask-Mail做法:
#
依主程式設定先新增環境變數export MAIL_USERNAME=<Gmail username>
#
依主程式設定先新增環境變數export MAIL_PASSWORD=<Gmail password>
#
依主程式設定先新增環境變數export FLASKY_ADMIN=<Email recipient>

from threading import Thread
from flask import Flask, render_template, session, redirect, url_for
from flask_mail import Mail, Message

app = Flask(__name__)

# Email
所需設定如下,以Gmail為例
app.config["MAIL_SERVER"] = "smtp.googlemail.com"
app.config["MAIL_PORT"] = 587
app.config["MAIL_USE_TLS"] = True
app.config["MAIL_USERNAME"] = os.environ.get("MAIL_USERNAME")
app.config["MAIL_PASSWORD"] = os.environ.get("MAIL_PASSWORD")
app.config["FLASKY_MAIL_SUBJECT_PREFIX"] = "[Flasky]"
app.config["FLASKY_MAIL_SENDER"] = "Flasky Admin <flasky@example.com>"
app.config["FLASKY_ADMIN"] = os.environ.get("FLASKY_ADMIN")

mail = Mail(app)

#
避免在處理請求的過程中產生沒必要的延遲,我們可以將email寄送函式移往背景執行緒
def send_async_email(app, msg):
    with app.app_context():
        mail.send(msg)

def send_email(to, subject, template, **kwargs):
    msg = Message(app.config["FLASKY_MAIL_SUBJECT_PREFIX"] + " " + subject, sender=app.config["FLASKY_MAIL_SENDER"], recipients=[to])
   
    #
使用templates/mail/new_user.txt模板,模板內容為User {{ user.username }} has joined.
    msg.body = render_template(template + ".txt", **kwargs)
    #
使用templates/mail/new_user.html模板,模板內容為User <b>{{ user.username }}</b> has joined.
    msg.html = render_template(template + ".html", **kwargs)
   
    # mail.send(msg)
    thr = Thread(target=send_async_email, args=[app, msg])
    thr.start()
    return thr

@app.route("/", methods=["GET", "POST"])
def index():
    form = NameForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.name.data).first()
        if user is None:
            user = User(username=form.name.data)
            db.session.add(user)
            db.session.commit()
            session["known"] = False
           
            #
在表單中輸入新名字,就會收到一封email
            if app.config["FLASKY_ADMIN"]:
                send_email(app.config["FLASKY_ADMIN"], "New User", "mail/new_user", user=user)
        else:
            session["known"] = True
        session["name"] = form.name.data
        return redirect(url_for("index"))
    return render_template("index.html", form=form, name=session.get("name"), known=session.get("known", False))

l   使用flask shell操作此範例主程式的模型:
#
依主程式設定先新增環境變數export MAIL_USERNAME=<Gmail username>
#
依主程式設定先新增環境變數export MAIL_PASSWORD=<Gmail password>

from flask_mail import Message
from hello import mail

msg = Message("test email", sender="you@example.com", recipients=["you@example.com"])
msg.body = "This is the plain text body"
msg.html = "This is the <b>HTML</b> body"
with app.app_context():
    mail.send(msg)

第七章、大型的app結構(7a)

l   git checkout 7a

l   Flask不會限制大型專案的組織結構,本書的專案結構:
|-flasky
  |-app/
    |-templates/ #
網頁模板
    |-static/ #
靜態檔案、文件
    |-main/ # main
藍圖
      |-__init__.py # main
藍圖建構式
      |-errors.py # main
藍圖錯誤處理頁面路由
      |-forms.py # main
藍圖輸入欄位
      |-views.py # main
藍圖路由
    |-__init__.py #
使用app工廠以延遲app建立,讓腳本有時間設定組態,也可以讓它建立多個app實例;此為app套件建構式
    |-email.py # Email
功能,透過current_app._get_current_object()呼叫APP實例進行操作
    |-models.py #
資料庫的資料表模型
  |-migrations/ #
資料庫遷移
  |-tests/ #
單元測試
    |-__init__.py
    |-test*.py
  |-venv/
  |-requirements.txt
  |-config.py #
組態設定選項
  |-flasky.py # app
腳本,定義app實例的地方

l   本書於此章節說明藍圖的功能,但其講解與範例略顯複雜,可參考以下文章更簡潔的說明「Python Web Flask Blueprints 解決大架構的網站」。

l   app/__init__.py
#
此工廠函式還少了路由與自訂的錯誤網頁處理函式

from flask import Flask
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy
from flask_mail import Mail
#
本書自訂組態設定檔
from config import config

bootstrap = Bootstrap()
moment = Moment()
db = SQLAlchemy()
mail = Mail()

def create_app(config_name):
    app = Flask(__name__)
    # from_object()
方法來將config.py裡面定義的類別所儲存的組態直接匯入app
    app.config.from_object(config[config_name])
    #
本書自訂組態設定檔方法
    config[config_name].init_app(app)
   
    bootstrap.init_app(app)
    moment.init_app(app)
    db.init_app(app)
    mail.init_app(app)
   
    #
藍圖
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)
   
    return app

l   app/main/__init__.py
# app.route
裝飾器在app工廠create_app()執行之後才存在,此時為時已晚
# Flask
藉由藍圖提供了一種很棒的解決方案。藍圖與app相似的地方在於它也可以定義路由與錯誤處理函式,差異在於,當你在藍圖裡面定義它們時,它們會處於休眠狀態,直到藍圖被app註冊時,才會成為app的一部分
from flask import Blueprint

#
兩個引數,藍圖名稱與藍圖所在的模組或套件(通常用__name__)
main = Blueprint("main", __name__)

#
因為會匯入main藍圖物件,所以main必須先被定義,避免循環的依賴關係造成的錯誤
from . import views, errors

l   app/main/errors.py
from flask import render_template
from . import main

#
路由裝飾器來自藍圖,故使用main而不是app
# errorhandler
裝飾器處理函式只會在"錯誤是在藍圖定義的路由中發生",若要遍及app範圍,必須改用app_errorhandler裝飾器
@main.app_errorhandler(404)
def page_not_found(e):
    return render_template('404.html'), 404

@main.app_errorhandler(500)
def internal_server_error(e):
    return render_template('500.html'), 500

l   app/main/views.py
from flask import render_template, session, redirect, url_for, current_app
from .. import db
from ..models import User
from ..email import send_email
from . import main
from .forms import NameForm

@main.route("/", methods=["GET", "POST"])
def index():
    form = NameForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.name.data).first()
        if user is None:
            user = User(username=form.name.data)
            db.session.add(user)
            db.session.commit()
            session["known"] = False
            if current_app.config["FLASKY_ADMIN"]:
                send_email(current_app.config["FLASKY_ADMIN"], "New User", "mail/new_user", user=user)
        else:
            session["known"] = True
        session["name"] = form.name.data
       
        # Flask
會將命名空間套用到在藍圖中定義的所有端點,所以各個藍圖可以用同樣的端點名稱來定義view函式,且不會造成衝突
        #
藍圖名稱.端點名稱(view.pyindex()),此例可以寫成url_for("main.index"),或使用目前request的藍圖而將藍圖名稱省略,寫成url_for(".index")
        return redirect(url_for(".index"))
    return render_template("index.html", form=form, name=session.get("name"), known=session.get("known", False))

l   flasky.py
# export FLASK_APP=flasky.py
# export FLASK_DEBUG=1

import os
import click
from flask_migrate import Migrate
from app import create_app, db
from app.models import User, Role

#
本書自訂環境變數或組態設定
app = create_app(os.getenv("FLASK_CONFIG") or "default")
migrate = Migrate(app, db)

@app.shell_context_processor
def make_shell_context():
    return dict(db=db, User=User, Role=Role)

#
單元測試啟動命令:flask test,將被裝飾的函式的名稱當成命令名稱
@app.cli.command()
@click.argument("test_names", nargs=-1)
def test(test_names):
    """Run the unit tests."""
    import unittest
    if test_names:
        tests = unittest.TestLoader().loadTestsFromNames(test_names)
    else:
        tests = unittest.TestLoader().discover("tests")
    unittest.TextTestRunner(verbosity=2).run(tests)

l   tests/test_basics.py
import unittest
from flask import current_app
from app import create_app, db

class BasicsTestCase(unittest.TestCase):
    #
測試開始前執行
    def setUp(self):
        self.app = create_app("testing")
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()
   
    #
測試結束後執行
    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.app_context.pop()
   
    # test_
開頭為要執行的測試,確定app實例的存在
    def test_app_exists(self):
        self.assertFalse(current_app is None)
   
    # test_
開頭為要執行的測試,確定app是否在測試組態之下運行
    def test_app_is_testing(self):
        self.assertTrue(current_app.config["TESTING"])

沒有留言:

張貼留言