# Flask 後端課程教學講義
## 前言
後端的應用對於現今的生活而言是非常廣泛的,從最常見的各種網站請求處理,到遊戲軟體的運行邏輯運算,亦或是線上應用機器人、雲端硬碟的管理系統、各種應用程式交互介面等等。
因此,後端邏輯是身為一名工程師必須了解的知識之一!
本課程將會以使用 Python 語言的後端函式庫 — Flask 作為實作的範例,並一步步的操作各種在設計後端架構時可能會遇到的問題。
## 目錄
[TOC]
<br>
## Flask 簡介
### 前端/後端功能與簡介
前後端在編程的世界中應該是耳熟能詳,接著我們要來簡述一下何謂前端/後端:
- 前端:在應用程式服務/網站交互中你的電腦所運行的內容,包含了網頁的介面呈現、表單數據回傳伺服器等發送行為的運行、還有你的使用習慣等資料在本地的存取。
- 後端:運行應用程式服務/網站的伺服器,大部分的應用邏輯運算都屬於後端的工作,其中也包含對資料庫的操作及帳號系統等等。
### API 功能簡介
上一篇我們提到了前端會向後端發送數據,而後端用以接收數據的接口則稱為 ***API (Application Programming Interface / 應用程式交互介面)*** ,API 在應用程式要跟伺服器請求資訊或是回傳資訊時使用到,詳細的實作介紹將會在後面章節提及
### 一般網站後端應用
後端架構最常見的應用莫過於網站,我們在網頁中的所見以及能互動的這些組件大多都是從連上特定的 ***URL(Uniform Resource Locator / 統一資源定位符)*** 開始,經過後端處理應該傳給你的資料,再由瀏覽器解析這些資料成為網頁。
前端的資料通常由三樣東西組成:
- HTML(HyperText Markup Language / 超文本標記語言):負責控制整個介面的排版、各個標籤的名稱,以及網頁的基礎設定(標題、預覽畫面、加載的 CSS、JS 等)。
- CSS(Cascading Style Sheets / 階層式樣式表):負責對每個標記進行樣式上的設定,像是文字顏色位置字體、點擊時的顏色變化、甚至是一些簡單的動畫呈現等等。
- Javascript:三件套中唯一的程式語言,各種邏輯處理、API交互、進階的動畫或介面呈現都是藉由 JS 完成,用於網頁的 JS 也衍伸出各種框架來幫助前端設計,甚至包含到後端架構,像是 Vue.js、React.js、Next.js。
### 後端框架簡介與比較
[Flask](https://github.com/pallets/flask) 是 Python 語言所建立的一個 Web 應用框架,相對於其他於其他框架較為架構單純,輕量化也使得他入門難度較低,相對的在進行較大架構的編寫時較需要去設計檔案架構,以及對於各種功能的補充與函式庫支援更需要加以維護。
以 Ruby on Rails 為例,其架構相對固定,而 Flask 的檔案架構則相當自由,基本上除了 Blueprint 有點架構性之外,其餘部分都需要各位自行進行編制。

(上圖:RoR之架構)
另外常見的 Python 框架還有 Django、FastAPI 等等,前者的內建功能豐富,像是帳號系統與資料庫管理等,後者語法上與 Flask 相似,但在運行速度上做了不少優化,若對更廣的應用有興趣者可以深入研究。
現今最為泛用框架還有眾多基於 JS 語言的框架,好處是可以同時兼顧前端的 JS 邏輯,使兩者之間的交互編程更加絲滑,以及 ***npm(Node Package Manager / node套件管理器)*** 的優秀套件管理能對網站設計帶來相當大的幫助。
### 引入 Flask 函式庫與使用
首先我們要來安裝 Python。
進入 [Python 官網](https://www.python.org/),根據你的作業系統下載指定版本,之後點開下載下來的安裝程式
<!---->
<img src="https://hackmd.io/_uploads/rJpeuGSFR.png">
勾選完畢後點擊 Install Now ,便完成安裝。
若是沒有新增 Python 到 %PATH% 環境變數,之後 IDE 與終端可能會抓不到你的 Python 編譯器。
在終端機中輸入以下內容以安裝 Flask:
```shell=
pip install flask
```
可以在終端輸入以下內容以確認是否成功安裝以及寫入環境變數:
```shell=
flask --version
```
看到正常顯示版本代表安裝成功
## 建立 Flask 應用程式
### 初始化
打開你習慣的程式編輯軟體(此處以 Visual Studio Code 為例),建立一個 Python 檔案,並寫入以下內容:
```python=
from flask import Flask
app = Flask(__name__)
if __name__ == '__main__':
app.run()
```
`app` 為我們的 Flask 應用程式核心,對網站後端的各種操作都是藉由它去進行的,這邊我們建立了一個應用,並且在以此檔案執行時調用 `app.run()` 來啟動應用。
>Tips:`__name__` 內置參數為執行檔案的開始位置,當它等於 '\_\_main__' 的時候即表示該檔案的調用不是被引用,而是直接執行這個檔案。
成功後會見到以下內容:

可以看到預設是跑在 `127.0.0.1:5000` 上面的,這代表的是我們電腦本身的位置,當然你也可以透過 `app.run()` 的參數去調整 `host` / `port`。
```python=
from flask import Flask
app = Flask(__name__)
if __name__ == '__main__':
app.run(host="localhost", port=8080)
```
可以看到它現在跑在 `localhost:8080` 了。

>Tips:在 `host` 的配置中通常有以下三種選項
>- `127.0.0.1`:通往自己電腦的一個 IP 位置。
>- `localhost`:對於本機的一個位置,預設是 `127.0.0.1`,可透過自行設定去調整配置。
>- `0.0.0.0`:任何的 IP 位置,在本機運行時與上面兩者區別不大,不同於前者的是,它會監聽對於任意 IP 位置的訊息,因此在使用 Apache 進行伺服器化的時候會使用此位置。
另外,Flask 提供了 debug 模式可以去做使用,一樣在 `app.run()` 的參數去做設定:
```python=
# 以上略
app.run(debug=True)
```
在 debug 模式時,每次對程式的更動都會自動重啟 app ,並且在發生錯誤時會在網頁中顯示錯誤內容,也可以直接在前端進入終端機進行調整,在開發時啟用是個不錯的選擇。
### Config 配置
在編程應用時,我們通常習慣以一個檔案甚至是一個 package 去處理各種設定檔,在程式啟動時便能透過引用該設定檔的內容去快速的取得各方面的設定資訊。
其中有個常用的設定儲存手段為環境變數,在一個應用中可能有許多資訊是不適合公開的,如資料庫密碼、API 金鑰、TOKEN 等等,這種時候會將這些資料存取在 `.env` 檔案中,此檔案會透過 `.gitignore` 避免跟著上傳,而設定檔程式內使用 Python 的 dotenv 函式庫將 `.env` 的內容存進環境變數,在透過內建函式庫調用。
```python=
# config.py
import os
from dotenv import load_dotenv
BASEDIR = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(BASEDIR, ".env"), override=True) # 將.env寫入環境變數
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI") # 資料庫連結
API_KEY = os.getenv("API_KEY") # API 金鑰
```
>Tips:在 Python 的各種檔案路徑調用中,常常會有因為當前所在資料夾與測試環境不同而導致抓不到檔案的情況,因此通常會使用 `os.path.dirname(__file__)` 取得當前檔案的所在資料夾後轉為絕對路徑確保之後的操作路徑正確。
### URL 導向
在第一章的時候提到使用者電腦連上特定的 URL 之後會經過後端的處理來發送指定的資料內容給客戶端,我們可以使用 Flask 簡單的實現:
```python=
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello World!"
if __name__ == '__main__':
app.run(host="localhost", port=8080)
```
在程式中設定了當連結上 '/' 路徑時會回傳一個 "Hello World!",而 '/' 也就是根路徑,以我們設定的 host 為例,它會相當於 `localhost:8080/`,也就是直接連上時第一個映入眼簾的內容。
```python=
@app.route("/home/<int:number>")
def home(number):
return f"Hello World! {number}"
```
也可以透過 `<變數型別:變數名稱>` 來讓執行函式接收使用者在網址的內容,上圖設定下進入 `localhost:8080/home/100` 會看到 "Hello World! 100",而進入 `localhost:8080/home/一百` 則會因為型別不符合而無法找到頁面(404 Error)。
>Tips:變數型別可以省略,此時寫法會變成 `@app.route("/home/<number>")` (不用冒號),而讀進去的內容需自行轉換為期望型別。
<br>
回傳的資料當然也可以是 html,可透過 Flask 的函式進行回傳,而預設抓取 html 檔案的路徑會在 `templates` 資料夾底下,我們建立此資料夾並建立新檔案命名為 `index.html`,並且寫入以下內容:
```html=
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>我是標題</title>
</head>
<body>
<h1>Hello World!</h1>
</body>
```
之後再將原 Python 程式改為以下內容:
```python=
from flask import Flask, render_template
app = Flask(__name__)
@app.route("/")
def home():
return render_template("index.html")
if __name__ == '__main__':
app.run(host="localhost", port=8080)
```
會看到我們連上根路徑後的標題如同 html 所設定,並且按下 F12 開啟開發者工具可以看見我們剛剛寫的 html。
>Tips:如果顯示找不到 html ,可以檢查看看資料夾結構與下圖是否相符:
>
<br>
另外可以透過後端將使用者重新導向到別的 URL ,像是這樣:
```python=
from flask import redirect
# 略
@app.route("/very_good_link")
def very_good_link():
return redirect("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
```
不知道那個網址是什麼的點了就知道了:D
### 前端模板化
Flask 使用 Jinja2 模板,可以在前端的 html 中寫入一些特殊的語法來達到後端回傳時在裡面加入指定資料。
透過 `{{ 變數名 }}` 可以得到後端傳入的變數,範例如下:
```html=
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>我是標題</title>
</head>
<body>
<h1>your number is {{ number }}!</h1>
</body>
```
```python=
@app.route("/<int:number>")
def home(number):
return render_template("index.html", number=number)
```
這麼一來,就可以在根路徑後面帶數字的情況下直接在 html 內看到傳入的數字。
<br>
另外,Jinja2 能夠使用的遠遠不止於此,我們甚至可以對它傳入字典、陣列等結構,之後在 html 進行解析、條件判斷或是對它進行迭代,讓我們看看下面範例:
```html=
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>我是標題</title>
</head>
<body>
{% for book in books %}
{% if book.title != "這是禁書" %}
<h1>{{ book.title }}</h1>
<p>{{ book.author }}</p>
<p>{{ book.price }}</p>
{% endif %}
{% endfor %}
</body>
```
```python=
@app.route("/")
def home():
books = [
{
"title": "這是一本書",
"author": "這是一名作者",
"price": 100
},
{
"title": "這是第二本書",
"author": "這是第二名作者",
"price": 200
},
{
"title": "這是第三本書",
"author": "這是第三名作者",
"price": 300
},
{
"title": "這是禁書",
"author": "你猜",
"price": 114514
}
]
return render_template("index.html", books=books)
```
成功的話你會在根路徑看到以下畫面:
<img src="https://hackmd.io/_uploads/SJnQEx1Iyx.png" style="width: 200px">
可以看到禁書被隱藏了,且它確實按照我們希望的邏輯去對資料做遍歷了。
<br>
另外,在網頁中許多重複性極高的標籤像是一開始的宣告、導覽列等等,可以藉由模板來避免重複寫,也可以在需要修改的時候方便許多。
`base.html`:
```html=
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>我是標題</title>
</head>
<body>{% block content %}{% endblock content %}</body>
```
`index.html`:
```html=
{% extends "base.html" %}
{% block content %}
{% for book in books %}
{% if book.title != "這是禁書" %}
<h1>{{ book.title }}</h1>
<p>{{ book.author }}</p>
<p>{{ book.price }}</p>
{% endif %}
{% endfor %}
{% endblock content %}
```
> ※ Python 檔案無須修改
於是乎便能達成與前一個範例相同的結果,在有更多檔案需要使用 `base.html` 的模板時也可以快速的使用。
>Tips:此時的資料夾結構:
>
### 錯誤處理
不知你是否有遇到過 404 Page Not Found 等錯誤,這些錯誤代碼是在 http 連線中有固定規範的,在不同情況的狀態代碼會有所區別,其中簡易規則如下:
| 狀態代碼 | 1XX | 2XX | 3XX | 4XX | 5XX |
|:--------:| ---- | ---- | -------- | ---------- | ---------- |
| 表示意義 | 訊息 | 成功 | 重新導向 | 客戶端錯誤 | 伺服器錯誤 |
其中我們需要捕捉的通常是 404(用戶亂戳不存在的 URL)、500(伺服器運行時發生錯誤) 等等,另外可以視情況主動引發 403(權限不足) 等錯誤。
預設發生錯誤的介面長這樣:

嗯,很醜~ 因此我們可透過 Flask 內建函式去設定在錯誤時使用自訂的 html 呈現的介面:
```python=
@app.errorhandler(404)
def page_not_found(e):
return render_template("404.html"), 404
```
如此一來就可以讓介面變成自己喜歡的樣子。
<img src="https://hackmd.io/_uploads/By6pFek81l.png" style="width: 400px">
> ※ 上圖範例的 html 的部分過於冗長,故此處不提供。一般而言可配合網頁風格進行設計。
<br>
剛剛提到的手動引發則是透過 `abort()` 函式,提供狀態碼後將會執行設定好的錯誤處理函式或是預設的醜醜介面:
```python=
from flask import abort
# 略
@app.route("/admin")
def admin():
abort(403)
```
> ※ abort() 不需要放在 return 後。
## 結合資料庫
### SQL vs NoSQL
#### RDBMS (Relational Database Management System / 關聯式資料庫管理系統) & SQL (Structured Query Language / 結構化查詢語言)
將每個需要儲存的東西以物件的形式儲存,跟物件導向的概念相似,先創建好對應物件的表格,並設定好其屬性值,而後便可透過給予對應的值產生新的物件存放在資料庫內。
| 優點 | 缺點 |
|:--------------------:|:--------------------:|
| 快速、泛用、一致性高 | 在實現橫向擴展上困難 |
其範例如圖:

#### NoSQL (Non-SQL / 非結構化查詢語言)
有別於關聯式資料庫之儲存方式的查詢語言皆可稱為 NoSQL,其形式各自有別,但主流型式為 JSON 物件格式,以字串及數字的字典、陣列物件構造出的結構。
| 優點 | 缺點 |
|:--------------------------:|:------------------:|
| 資料結構自由、儲存邏輯與程式相同 | 各查詢語言之間格式不通 |
### 何謂 ORM
前面提到 SQL 的語言格式是固定的,也因此我們通常會使用 ***ORM(Object Relational Mapping / 物件關聯對映)*** 相關的函式庫,來讓我們的程式能夠以相同程式碼相容各種 SQL 資料庫,並且在物件的操作過程中提高了極大的安全性,因為通常函式庫內都會事先做好 ***SQL Injection (SQL 注入)*** 的防護。
讓我們看看相同功能實現上的差別:
```python=
import sqlite3
dbfile = "sqlite3.db"
conn = sqlite3.connect(dbfile)
cursor = conn.cursor()
def init():
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)")
cursor.execute("INSERT INTO users (username, password) VALUES ('admin', 'admin')")
cursor.execute("INSERT INTO users (username, password) VALUES ('user', 'user')")
conn.commit()
def query(username, password):
cursor.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password))
return cursor.fetchone()
```
(未使用 ORM 函式庫)
```python=
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, Integer, String
db = SQLAlchemy()
class User(db.Model):
id = Column(Integer, primary_key=True)
username = Column(String(80), unique=True, nullable=False)
password = Column(String(80), nullable=False)
def init():
db.create_all()
db.session.add(User(username="admin", password="admin"))
db.session.add(User(username="user", password="user"))
db.session.commit()
def query(username, password):
return User.query.filter_by(username=username, password=password).first()
```
(使用 ORM 函式庫)
可以看到後者有別於前者,能夠對應到各種資料庫而非 sqlite,並且在查詢的地方也不怕被輸入 `;` 等方式來越過密碼取得用戶資料。
### 物件結構化
由於 NoSQL 的形式各自有別,且 SQL 的泛用性及複雜程度都勝過 NoSQL,因此本章將會著重在 SQL 的使用教學。
在教學開始前,請先安裝 `flask_sqlalchemy`。
```shell=
pip install flask_sqlalchemy
```
<br>
如同前一章看到的:
```python=
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)")
```
```python=
class User(db.Model):
id = Column(Integer, primary_key=True)
username = Column(String(80), unique=True, nullable=False)
password = Column(String(80), nullable=False)
```
在 ORM 的架構中會使用物件導向的邏輯去設定資料庫內存取的物件結構,透過給予物件需要的 Column 的屬性,可以在可讀性極高的情況下設定好資料庫 Table 的欄位格式。
另外,通常我們會在物件中加上一些函式,來便於物件的一些使用:
```python=
class User(db.Model):
id = Column(Integer, primary_key=True)
username = Column(String(80), unique=True, nullable=False)
password = Column(String(80), nullable=False)
def __init__(self, username: str, password: str):
self.username = username
self.password = password
def __repr__(self):
return f"<User '{self.username}'>"
```
設定初始化函式以及被調用時的回傳訊息。
```python=
user = User(username="name", password="qwertyuiop")
print(user) # <User 'name'>
```
### 資料庫初始化
資料庫的創建需要先建立資料庫(db)的物件:
```python=
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
```
而這個 db 要跟我們的應用綁定可以有兩種方式:
- 在建立時賦予 app 物件作為參數:
```python=
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
db = SQLAlchemy(app)
```
- 使用 `init()` 函式給予 app 參數:
```python=
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
db = SQLAlchemy()
db.init_app(app)
```
其兩者看似無區別,實際應用上多以後者來使 db 的初始化檔案能夠被母資料夾內的 `main.py` 引用,之後在初始化函式內再一併初始化 app。
前面提到的物件結構化也是在這邊先建立好 Class。
```python=
# models.py
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, Integer, String
db = SQLAlchemy()
class User(db.Model):
id = Column(Integer, primary_key=True)
username = Column(String(80), unique=True, nullable=False)
password = Column(String(80), nullable=False)
def __init__(self, username: str, password: str):
self.username = username
self.password = password
def __repr__(self):
return f"<User '{self.username}'>"
```
<br>
光是做到這邊在執行時會發現發生錯誤,那是因為我們沒有給予他資料庫的連結,這個連結會包含你的資料庫帳密讓他能夠直接連接上,通常會在 config 檔案內進行設定。
```python=
# config.py
import os
from dotenv import load_dotenv
BASEDIR = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(BASEDIR, ".env"), override=True) # 將.env寫入環境變數
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI") # 資料庫連結
API_KEY = os.getenv("API_KEY") # API 金鑰
class Config(object):
JSON_AS_ASCII = False # 不要讓他 JSON 預設使用 ASCII,如此一來可以預防中文編碼爛掉的問題
SQLALCHEMY_DATABASE_URI = SQLALCHEMY_DATABASE_URI
```
還記得這個嗎,前面章節的 config 檔案,此時便揭曉 `SQLALCHEMY_DATABASE_URI` 之用途了!
```python=
# main.py
from flask import Flask
from config import Config
from models import db
app = Flask(__name__)
app.config.from_object(Config)
db.init_app(app)
@app.before_request
def db_init():
db.create_all() # 執行此函式以創建設定好的 Table,因此讓他在所有 Request 之前調用
@app.route("/")
def hello():
return "Hello World!"
if __name__ == '__main__':
app.run(host="localhost", port=8080)
```
### 資料庫基本操作
對資料庫的操作最主要聚焦於創建、查詢與更新資料庫,我們先來介紹如何創建新的物件:
```python=
from flask import Flask
from models import db, User
app = Flask(__name__)
# 略
with app.app_context():
user = User(username="name", password="test")
db.session.add(user)
db.session.commit()
```
先是建立 `user` 作為新的用戶資料,之後將它新增進資料庫的 Session 中,每次對資料庫進行更新時都要加上 `db.session.commit()` (查詢因為沒有更新到資料庫所以不用)。
>Tips:所有的資料庫操作都必須在應用程式上下文底下進行,一般的 url route 底下都符合條件因此可以使用,若在其他地方使用可以加上 `with app.app_context():`。
<br>
接下來示範查詢的例子,在查詢時會使用到我們建立好的物件 Class:
```python=
User.query.all() # 查詢所有 User (回傳一個 List)
User.query.filter_by(username="name").all() # 查詢所有 username 為 "name" 的 User (回傳一個 List)
User.query.filter_by(username="name").first() # 查詢第一個 username 為 "name" 的 User
User.query.filter_by(username="name").first_or_404() # 查詢第一個 username 為 "name" 的 User,查詢不到則引發404錯誤
User.query.count() # 查詢 User 數量
```
上面的 `filter_by()` 函式回傳的物件與 `User.query` 相同,因此對查詢加上過濾前後可以適用的回傳選項( `first()`、`first_or_404()`、`all()`、`count()` ...)相同。
<br>
繼續看下去更新的部分:
```python=
User.query.filter_by(username="name").update({"password": "new_password"}) # 更新所有 username 為 "name" 的 User 的 password 為 "new_password"
User.query.filter_by(username="name").delete() # 刪除所有 username 為 "name" 的 User
db.session.commit()
```
可以對查詢到的物件(可複數物件)進行刪除或是更新,更新時透過給予一個字典包含更新的欄位與值進行。
最後不要忘記更新或刪除後要進行 commit,不然就白弄了。
<div style='display: none;'>### Migrate 操作與應用封裝</div>
## 前端基礎互動
### 權限管理/帳號系統
帳號系統是大多網站中不可或缺的要素之一,而此時便是使用後端結合資料庫進行操作的時候了。
其中可以簡易分為使用第三方平台的帳號服務登入(像是 Google、Discord 帳號登入等),亦或是建立一個自己的帳號系統,我們先來介紹前者。
#### OAuth2
OAuth2 是一種廣泛被使用的授權方式,簡易流程如下:
1. 客戶端登入請求給伺服器
2. 伺服器將客戶端導向帳號服務的身分驗證介面
3. 當客戶端同意後帳號服務會導向到設定好的 URL,讓客戶端回到我們的網站
4. 伺服器接收到帳號服務伺服器給予的該客戶端對應的 Access Token,並以該 Token 可以向帳號服務端獲得客戶端同意給予的資料
以 Discord 身分驗證服務為例,客戶端會見到如下圖介面:

在實作上我們要先獲取對應的 OAuth2 URL,該部分視使用的服務而有所差異,本處以 Discord 為例:
```python=
# config.py
# 略
TOKEN = os.getenv("TOKEN")
CLIENT_ID = os.getenv("CLIENT_ID")
CLIENT_SECRET = os.getenv("CLIENT_SECRET")
REDIRECT_URI = os.getenv("REDIRECT_URI")
OAUTH_URL = "https://discord.com/oauth2/authorize?client_id=" + CLIENT_ID + "&redirect_uri=" + REDIRECT_URI + "&response_type=code&scope=identify+email"
class Config(object):
JSON_AS_ASCII = False # 不要讓他 JSON 預設使用 ASCII,如此一來可以預防中文編碼爛掉的問題
SQLALCHEMY_DATABASE_URI = SQLALCHEMY_DATABASE_URI
SECRET_KEY = os.urandom(12).hex() # 產生一個隨機的 12 位元的 HEX 字串
```
```python=
# main.py
from flask import Flask, jsonify, redirect, request, session
from zenora import APIClient
from config import Config, TOKEN, CLIENT_SECRET, OAUTH_URL, REDIRECT_URI
from models import db, User
app = Flask(__name__)
app.config.from_object(Config)
client = APIClient(TOKEN, client_secret=CLIENT_SECRET, validate_token=False)
db.init_app(app)
@app.before_request
def db_init():
db.create_all()
@app.route("/login")
def login():
return redirect(OAUTH_URL)
@app.route("/oauth/callback")
def callback():
if "code" in request.args:
code = request.args["code"]
access_token = client.oauth.get_access_token(code, REDIRECT_URI).access_token
session["token"] = access_token
session.permanent = True
return redirect("/")
@app.route("/logout")
def logout():
session.clear()
return redirect("/")
@app.route("/")
def home():
if "token" not in session:
return redirect("/login")
bearer_client = APIClient(session.get("token"), bearer=True)
current_user = bearer_client.users.get_current_user()
return jsonify(current_user)
if __name__ == '__main__':
app.run(host="localhost", port=8080, debug=True)
```
成功的話可以透過進入 `/login`、`/logout` 控制登入登出,並且在根路徑能夠看到自己的使用者資訊
注意使用 Discord 帳號系統時,必須要在 Discord 應用程式管理介面將自己網站的路徑輸入進 Redirects 中,範例中路徑為`http://localhost:8080/oauth/callback`。

在 Discord 的設定細項由於較便離主題故不多做著墨,如需詳細介紹可參考我之前的簡報。
#### 帳號系統
若是希望能在帳號內做更多自己的設定,那麼可以自己創建一個帳號系統,Flask 的輕量化框架本身沒有內建帳號系統,因此我們要來自己設計,在設計帳號系統時為了要加密,因此我們需要安裝 `flask-bcrypt`。
```shell=
pip install flask-bcrypt
```
然後在設定帳號系統之前,我們需要在 config 中設定 `secret_key` 參數,來讓我們的在 session 加密時能透過這個金鑰產生每個人不一樣的數值。
```python=
# config.py
# 略
class Config(object):
JSON_AS_ASCII = False # 不要讓他 JSON 預設使用 ASCII,如此一來可以預防中文編碼爛掉的問題
SQLALCHEMY_DATABASE_URI = SQLALCHEMY_DATABASE_URI
SECRET_KEY = os.urandom(12).hex() # 產生一個隨機的 12 位元的 HEX 字串
```
到了這邊可能有人好奇什麼是 session,在網站中有兩個短期儲存使用者資料的媒介,分別是存在伺服器端的 session 與存在客戶端的 cookie,而我們的帳號驗證當然不能讓使用者能從客戶端自行修改,因此選擇存在 session。
<br>
接著就可以來設定我們的帳號系統了。
```python=
def login_required(func):
def wrapper(*args, **kwargs):
if not session.get("username"):
return redirect("/")
return func(*args, **kwargs)
return wrapper
```
先設定好裝飾器,在函式執行之前先判斷 session 內的使用者名稱是否有值,若是的話正常執行,否則重新導向至首頁。
<br>
```python=
@app.route("/register", methods=["POST"])
def register():
username = request.json.get("username")
password = request.json.get("password")
# 若是沒給滿資料直接 return
if not username or not password:
return jsonify({"message": "Missing username or password"}), 400
# 已經註冊過也是 return
if User.query.filter_by(username=username).first():
return jsonify({"message": "User already exists"}), 400
user = User(username, Bcrypt().generate_password_hash(password).decode("utf-8"))
db.session.add(user)
db.session.commit()
return jsonify({"message": "User created"}), 201
```
當使用者要註冊時透過前端的 JS 傳送請求到 `/register`,後端會從請求中獲得使用者名稱與密碼,經過幾個檢查過後創建先前在 `models.py` 建立的 `User` 物件,之後新增進資料庫就註冊完畢了。
另外這邊將密碼透過 Bcrypt 加密過後在儲存,因為密碼不會從資料庫提出,所以可以對他進行 hash,並且這邊通常會在客戶端傳輸時就進行加密避免明碼直接被傳輸,此處僅示範簡易的系統因此沒有特別在前端對資料做處理。
>Tips:`jsonify()` 會將可以被轉換成 JSON 格式的資料轉換為 JSON 格式發送到客戶端,通常做為 API 的回傳值使用。
<br>
```python=
@app.route("/login", methods=["POST"])
def login():
username = request.json.get("username")
password = request.json.get("password")
if not username or not password:
return jsonify({"message": "Missing username or password"}), 400
user = User.query.filter_by(username=username).first()
if not user or not Bcrypt().check_password_hash(user.password, password):
return jsonify({"message": "Invalid username or password"}), 401
session["username"] = username
return jsonify({"message": "Logged in"}), 200
```
登入部分與註冊相似,從資料庫拿到用戶後檢查密碼是否相同,若是相同往 session 放入使用者名稱即可。
<br>
```python=
@app.route("/logout", methods=["GET", "POST"])
def logout():
session.clear()
return redirect("/")
```
登出只需要將 session 清空即可。
<br>
```python=
@app.route("/")
def home():
current_user = User.query.filter_by(username=session.get("username")).first()
return render_template("index.html", current_user=current_user)
```
首頁會先從資料庫內拿到當前使用者,前端可以讓 Jinja2 判斷 `current_user` 是否存在,進而改變顯示的內容。
<br>
```python=
@app.route("/user")
@login_required
def user():
return jsonify({"message": "user page"})
```
最後測試一個需要登入才能瀏覽的介面,在未登入時會因為 `@login_required` 重新導向至首頁,只有在登入狀態下才能看到 user page 的字樣。
<br>
完整程式碼:
```python=
# main.py
from flask import Flask, jsonify, render_template, redirect, request, session
from flask_bcrypt import Bcrypt
from config import Config
from models import db, User
app = Flask(__name__)
app.config.from_object(Config)
db.init_app(app)
@app.before_request
def db_init():
db.create_all()
def login_required(func):
def wrapper(*args, **kwargs):
if not session.get("username"):
return redirect("/")
return func(*args, **kwargs)
return wrapper
@app.route("/register", methods=["POST"])
def register():
username = request.json.get("username")
password = request.json.get("password")
if not username or not password:
return jsonify({"message": "Missing username or password"}), 400
if User.query.filter_by(username=username).first():
return jsonify({"message": "User already exists"}), 400
user = User(username, Bcrypt().generate_password_hash(password).decode("utf-8"))
db.session.add(user)
db.session.commit()
return jsonify({"message": "User created"}), 201
@app.route("/login", methods=["POST"])
def login():
username = request.json.get("username")
password = request.json.get("password")
if not username or not password:
return jsonify({"message": "Missing username or password"}), 400
user = User.query.filter_by(username=username).first()
if not user or not Bcrypt().check_password_hash(user.password, password):
return jsonify({"message": "Invalid username or password"}), 401
session["username"] = username
return jsonify({"message": "Logged in"}), 200
@app.route("/logout", methods=["GET", "POST"])
def logout():
session.clear()
return redirect("/")
@app.route("/")
def home():
current_user = User.query.filter_by(username=session.get("username")).first()
return render_template("index.html", current_user=current_user)
@app.route("/user")
@login_required
def user():
return jsonify({"message": "user page"})
if __name__ == '__main__':
app.run(host="localhost", port=8080, debug=True)
```
而前端只需要在使用者觸發登入登出事件時向對應的 URL 發送請求即可,以下為一個簡單的範例:
```htmlembedded=
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login System</title>
</head>
<body>
<h1>Login System</h1>
{% if current_user %}
<p>Welcome, {{ current_user.username }}!</p>
<form action="/logout" method="post">
<button type="submit">Logout</button>
</form>
<a href="/user">User</a>
{% else %}
<h2>Register</h2>
<form id="registerForm">
<label for="registerUsername">Username:</label>
<input type="text" id="registerUsername" name="username" required>
<label for="registerPassword">Password:</label>
<input type="password" id="registerPassword" name="password" required>
<button type="submit">Register</button>
</form>
<h2>Login</h2>
<form id="loginForm">
<label for="loginUsername">Username:</label>
<input type="text" id="loginUsername" name="username" required>
<label for="loginPassword">Password:</label>
<input type="password" id="loginPassword" name="password" required>
<button type="submit">Login</button>
</form>
{% endif %}
<script>
document.getElementById('registerForm').addEventListener('submit', async (event) => {
event.preventDefault();
const username = document.getElementById('registerUsername').value;
const password = document.getElementById('registerPassword').value;
const response = await fetch('/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const result = await response.json();
alert(result.message);
});
document.getElementById('loginForm').addEventListener('submit', async (event) => {
event.preventDefault();
const username = document.getElementById('loginUsername').value;
const password = document.getElementById('loginPassword').value;
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const result = await response.json();
alert(result.message);
if (response.ok) {
window.location.reload();
}
});
</script>
</body>
</html>
```
### Ajax 應用
在前面的章節中,我們獲取前端訊息都是依靠 `render_template()` 時跟著傳入,但經常會有訊息隨時間更新的情況,前面的狀態下無法在前端需要獲得新訊息的時候順利得到,這種時候我們會使用一種方式成為 Ajax。
我們會先設定一個 URL 做為 API 的連結,在前端對此發出訪問時回傳 JSON 資料。
```python=
from datetime import datetime
# 略
timedata = []
@app.route("/api/data")
def get_data():
timedata.append(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
return jsonify(timedata)
```
以此函式為例,他會在每次被訪問時往列表新增一個當前時間。
接著我們要來寫前端,這邊使用 jQuery 內建的 ajax 功能來處理,在 Flask 架構寫 CSS、JS 時,我們通常會創建一個資料夾成為 `static` 做為靜態資源的儲存,在裡面再創建一個 `index.js`,此時資料夾結構如下:

之後在 HTML 檔案中加入以下內容:
```htmlembedded=
<script src="{{ url_for('static', filename='index.js') }}"></script>
```
然後因為我們要使用 jQuery,因此要再加上一條:
```htmlembedded=
<script src="https://code.jquery.com/jquery-3.3.1.js"></script>
```
(版本可以自行更改)
之後就可以開始寫 JS 了。
```javascript=
$(function() {
$.ajax({
url: '/api/data',
success: function(data) {
console.log('data:', data);
$('#content').text(data);
}
})
})
```
如此一來便會在頁面加載完成時對 API 發出請求獲得 data,之後將 HTML 內 id 為 content 的標籤內容設定為資料內容。
完整 HTML:
```htmlembedded=
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Test</title>
<script src="https://code.jquery.com/jquery-3.3.1.js"></script>
<script src="{{ url_for('static', filename='index.js') }}"></script>
</head>
<body>
<div id="content"></div>
</body>
</html>
```
成功的話會發現每次進入到有回傳這個 HTML 的路徑時,裡面紀錄的時間戳記就會增加。
### npm 簡介與結合應用
上一章在使用 jQuery 時是在 HTML 檔案中透過連結直接載入,而需要載入的模組變多或是需要使用前端框架時,我們通常會使用 npm 來進行管理,Flask 框架本身是沒有內建 npm 相關的串接功能,但是兩者在使用上沒有衝突,因此可以將兩者整合在我們的專案中。
首先我們要先安裝 node.js 以獲取 npm ,進入 [node.js](https://nodejs.org/en/download/) 官網安裝,或是 linux 系統且有 apt 的話可以使用以下指令安裝:
```shell=
apt-get install nodejs
```
安裝完畢後可以透過檢視版本確定是否安裝成功。
```shell=
node -v
npm -v
```
>Tips:若是安裝了但是沒顯示版本可以進入 PATH 環境變數加入 `C:\Program Files\nodejs\`。
安裝完畢後我們就可以開始使用了,首先讓終端機進入專案的資料夾路徑,之後輸入以下指令初始化我們的環境:
```shell=
npm init
```
成功的話會生成一個檔案名為 `package.json`,裡面可以設定你的專案資訊,我們將裡面的資訊設定好後就可以開始裝需要用到的套件了。
```shell=
npm i -D jquery webpack webpack-cli
```
(不在本次課程範圍的)
```shell=
npm i -D react bootstrap typescript
```
- React、Bootstrap:之後的 React 課可能會用到的套件
- jQuery:前面章節有使用到的套件
- Typescript:跟我一樣認為 JS 的弱型很毒瘤的可以使用
- Webpack:讓我們的 TS、React 可以被轉換成能被 HTML 載入的 JS
>Tips:
>`npm install` = `npm i`
>`npm uninstall` = `npm un`
>`npm install --save -dev <package name>` = `npm i -D <package name>`
完成後會看到他幫你生成了一個 `package-lock.json`,這個文件是後面 build 的時候讓他知道要載那些東西的,我們不用動他。
之後我們要讓每次 build 時執行 Webpack,因此往 `package.json` 輸入以下內容(注意不要覆蓋掉原本的其他預設內容):
```json=
"scripts": {
"build": "webpack",
"watch": "webpack --watch"
},
```
這個時候我們建立一個檔案命名為 `webpack.config.js`,這是 Webpack 在每次 build 時轉換出 JS 的設定檔,我們寫入以下內容:
```javascript=
const path = require('path');
module.exports = {
entry: './static/index.js',
output: {
filename: 'index.bundle.js',
path: path.resolve(__dirname, './static/dist')
}
};
```
如此一來,每次 build 的時候就會把 `index.js` 轉換成 `index.bundle.js` 並存放在 `static/dist` 中,HTML 的部分只需要在模板寫入:
```htmlembedded=
<script src="{{ url_for('static', filename='dist/index.bundle.js') }}"></script>
```
接著往 `main.py` 寫入以下內容:
```python=
# main.py
# 略
if __name__ == "__main__":
os.system(f"cd {os.path.abspath(os.path.dirname(__file__))} && npm run build")
app.run(host="localhost", port=8080, debug=True)
```
然後我們在寫 JS 時就可以直接匯入想要使用的插件了。
```javascript=
import $ from 'jquery';
import react from 'react';
...
```
最後在 `index.js` 入口檔案中將使用到的 JS 檔案全部寫入,他可能會長成這個樣子,實務上根據你創建的檔案而有所不同。
```javascript=
require('./main');
require('./bg-anime');
require('./word_test');
require('./sentence_test');
require('./library');
```
>Tips:這邊的所有檔案都可以換成 TS,每次 bulid 時都會一同轉換,且這麼做還有一個好處便是可以進行代碼混淆,使有心人士難以直接盜用或修改,若是要更進一步的混淆可以使用 Webpack 的更多插件,因較為偏離主題故此處不多做贅述,
<div style='display: none;'>
## 即時通訊技術
### 輪詢(Polling)
### 長輪詢(Long Polling)
### Websocket
## 雜項
### 安全性檢測與基本防禦
### 專案部署與 Docker 簡介
### 多語系支援
</div>
## 參考資料
[Flask Documentation](https://flask.palletsprojects.com): https://flask.palletsprojects.com
[Flask-SQLAlchemy Documentation](https://flask-sqlalchemy.readthedocs.io):https://flask-sqlalchemy.readthedocs.io