2024年5月4日 星期六

Flask網頁開發 - 第二版 - 應用程式開發介面、測試與部署 (6)

  來說明這本由Miguel Grinberg著作,賴屹民翻譯的《Flask網頁開發 第二版》(Flask Web Development, 2nd Edition),本書的撰寫方式如同網頁開發人員新增應用程式功能,每新增一項功能,就會有新的章節說明這樣的新功能,是使用什麼樣的技術製作而成;由於網頁應用程式具結構性,程式碼之間的撰寫與修改是息息相關的,故作者有提供Repository供讀者檢視與試驗。

  我們可以依照本書各章節的順序,逐一檢視各章節在Repository對應的 Branch,藉由各個Branch的切換與推移,可以學習、理解與推演作者修改程式的邏輯,這對於Flask的學習是非常重要的。

  「Flask網頁開發」系列網誌,整理的方向也只是羅列修改方向一致的各個Branch,統整為較容易查找的功能主題,並輔以註解說明特殊模組、套件的API如何使用,以及使用的目的,對於完全沒有網頁應用程式開發經驗的讀者來說,依舊是極度難以理解的一系列筆記,建議還是得先學習好Flask的基礎,再掌握好本書作者訂定的目錄框架,最後才能掌握住本書的程式開發方式。

第二部分、範例:社群部落格app

第十四章、應用程式開發介面-API(14a)

l   API
git checkout 14a

l   本書的API藍圖結構:
|-flasky
  |-app/
    |-api
      |-__init__.py
      |-users.py
      |-posts.py
      |-comments.py
      |-authentication.py
      |-errors.py
      |-decorators.py

l   安裝Flask-HTTPAuth以驗證使用者,使用HTTP身分驗證時,使用者憑證會放在所有requestAuthorization標頭中:
pip install flask-httpauth

l   HTTPie測試web服務,在命令列測試Python web服務最常用的兩種用戶端是cURLHTTPie,而HTTPie是專為API請求量身訂做的:
pip install httpie

l   app/__init__.py
# ...
def create_app(config_name):
    # ...
    #
註冊API藍圖
    from .api import api as api_blueprint
    app.register_blueprint(api_blueprint, url_prefix="/api/v1")
    # ...

l   app/models.py
# ...
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from flask import current_app, request, url_for
from flask_login import UserMixin, AnonymousUserMixin
from app.exceptions import ValidationError
from . import db, login_manager
# ...
class User(UserMixin, db.Model):
    # ...
    #
將使用者轉換成JSON可序列化字典
    def to_json(self):
        json_user = {
            "url": url_for("api.get_user", id=self.id),
            "username": self.username,
            "member_since": self.member_since,
            "last_seen": self.last_seen,
            "posts_url": url_for("api.get_user_posts", id=self.id),
            "followed_posts_url": url_for("api.get_user_followed_posts", id=self.id),
            "post_count": self.posts.count()
        }
        return json_user
   
    #
支援權杖驗證(Token-based Authentication)功能
    def generate_auth_token(self, expiration):
        s = Serializer(current_app.config["SECRET_KEY"], expires_in=expiration)
        return s.dumps({"id": self.id}).decode("utf-8")
   
    @staticmethod
    #
支援權杖驗證(Token-based Authentication)功能
    def verify_auth_token(token):
        s = Serializer(current_app.config["SECRET_KEY"])
        try:
            data = s.loads(token)
        except:
            return None
        return User.query.get(data["id"])
# ...
class Post(db.Model):
    # ...
    #
將文章轉換成JSON可序列化字典
    def to_json(self):
        json_post = {
            "url": url_for("api.get_post", id=self.id),
            "body": self.body,
            "body_html": self.body_html,
            "timestamp": self.timestamp,
            "author_url": url_for("api.get_user", id=self.author_id),
            "comments_url": url_for("api.get_post_comments", id=self.id),
            "comment_count": self.comments.count()
        }
        return json_post
   
    #
JSON建立部落格文章
    @staticmethod
    def from_json(json_post):
        body = json_post.get("body")
        if body is None or body == "":
            raise ValidationError("post does not have a body")
        return Post(body=body)
# ...
class Comment(db.Model):
    # ...
    def to_json(self):
        json_comment = {
            "url": url_for("api.get_comment", id=self.id),
            "post_url": url_for("api.get_post", id=self.post_id),
            "body": self.body,
            "body_html": self.body_html,
            "timestamp": self.timestamp,
            "author_url": url_for("api.get_user", id=self.author_id),
        }
        return json_comment
   
    @staticmethod
    def from_json(json_comment):
        body = json_comment.get("body")
        if body is None or body == "":
            raise ValidationError("comment does not have a body")
        return Comment(body=body)

l   app/exceptions.py
#
ValidationError類別簡單地寫成Python ValueError的子類別
class ValidationError(ValueError):
    pass

l   app/main/errors.py
#
處理404500 HTTP狀態碼比較麻煩,因為這些錯誤通常是Flask自己產生的,而且它會回傳一個HTML回應,而不是JSON,這可能會造成用戶端的混亂
from flask import render_template, request, jsonify
from . import main

#
使用HTTP內容協商(Content Negotiation)403錯誤處理常式
@main.app_errorhandler(403)
def forbidden(e):
    if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
        response = jsonify({"error": "forbidden"})
        response.status_code = 403
        return response
    return render_template("403.html"), 403

#
使用HTTP內容協商(Content Negotiation)404錯誤處理常式
@main.app_errorhandler(404)
def page_not_found(e):
    if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
        response = jsonify({"error": "not found"})
        response.status_code = 404
        return response
    return render_template("404.html"), 404

#
使用HTTP內容協商(Content Negotiation)500錯誤處理常式
@main.app_errorhandler(500)
def internal_server_error(e):
    if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
        response = jsonify({"error": "internal server error"})
        response.status_code = 500
        return response
    return render_template("500.html"), 500

l   app/api/__init__.py
#
建立API藍圖
from flask import Blueprint
api = Blueprint("api", __name__)
from . import authentication, posts, users, comments, errors

l   app/api/errors.py
from flask import jsonify
#
本書作者自訂的ValidationError類別,位於app/exceptions.py
from app.exceptions import ValidationError
from . import api

#
狀態碼400API錯誤處理常式
def bad_request(message):
    response = jsonify({"error": "forbidden", "message": message})
    response.status_code = 400
    return response

#
狀態碼401API錯誤處理常式
def unauthorized(message):
    response = jsonify({"error": "unauthorized", "message": message})
    response.status_code = 401
    return response

#
狀態碼403API錯誤處理常式
def forbidden(message):
    response = jsonify({"error": "forbidden", "message": message})
    response.status_code = 403
    return response

# app/exceptions.py
ValidationError異常的API錯誤處理常式
#
被裝飾的函式會在指定類別發出異常時執行
@api.errorhandler(ValidationError)
def validation_error(e):
    # e.args[0]
為取得Exception詳細內容
    return bad_request(e.args[0])

l   app/api/authentication.py
from flask import g, jsonify
from flask_httpauth import HTTPBasicAuth
from ..models import User
from . import api
from .errors import unauthorized, forbidden

#
初始化Flask-HTTPAuth
auth = HTTPBasicAuth()

@auth.verify_password
def verify_password(email_or_token, password):
    if email_or_token == "":
        return False
    #
如果密碼是空的,我們假設email_or_token欄位是權杖並進行驗證
    if password == "":
        g.current_user = User.verify_auth_token(email_or_token)
        g.token_used = True
        return g.current_user is not None
    user = User.query.filter_by(email=email_or_token.lower()).first()
    if not user:
        return False
    g.current_user = user
    g.token_used = False
    return user.verify_password(password)

@auth.error_handler
def auth_error():
    return unauthorized("Invalid credentials")

@api.before_request
@auth.login_required
def before_request():
    if not g.current_user.is_anonymous and not g.current_user.confirmed:
        return forbidden("Unconfirmed account")

#
產生身分驗證權杖
@api.route("/tokens/", methods=["POST"])
def get_token():
    if g.current_user.is_anonymous or g.token_used:
        return unauthorized("Invalid credentials")
    return jsonify({"token": g.current_user.generate_auth_token(expiration=3600), "expiration": 3600})

l   app/api/posts.py
from flask import jsonify, request, g, url_for, current_app
from .. import db
from ..models import Post, Permission
from . import api
from .decorators import permission_required
from .errors import forbidden

#
文章的GET資源處理常式
@api.route("/posts/")
def get_posts():
    page = request.args.get("page", 1, type=int)
    pagination = Post.query.paginate(page=page, per_page=current_app.config["FLASKY_POSTS_PER_PAGE"], error_out=False)
    posts = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for("api.get_posts", page=page-1)
    next = None
    if pagination.has_next:
        next = url_for("api.get_posts", page=page+1)
    return jsonify({
        "posts": [post.to_json() for post in posts],
        "prev": prev,
        "next": next,
        "count": pagination.total
    })
# def get_posts():
#     posts = Post.query.all()
#     return jsonify({"posts": [post.to_json() for post in posts]})

#
文章的GET資源處理常式
@api.route("/posts/<int:id>")
def get_post(id):
    post = Post.query.get_or_404(id)
    return jsonify(post.to_json())

#
文章的POST資源處理常式
@api.route("/posts/", methods=["POST"])
@permission_required(Permission.WRITE)
def new_post():
    post = Post.from_json(request.json)
    post.author = g.current_user
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json()), 201, {"Location": url_for("api.get_post", id=post.id)}

#
文章的PUT資源處理常式
@api.route("/posts/<int:id>", methods=["PUT"])
@permission_required(Permission.WRITE)
def edit_post(id):
    post = Post.query.get_or_404(id)
    if g.current_user != post.author and not g.current_user.can(Permission.ADMIN):
        return forbidden("Insufficient permissions")
    post.body = request.json.get("body", post.body)
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json())

l   app/api/users.py
from flask import jsonify, request, current_app, url_for
from . import api
from ..models import User, Post

@api.route("/users/<int:id>")
def get_user(id):
    user = User.query.get_or_404(id)
    return jsonify(user.to_json())

@api.route("/users/<int:id>/posts/")
def get_user_posts(id):
    user = User.query.get_or_404(id)
    page = request.args.get("page", 1, type=int)
    pagination = user.posts.order_by(Post.timestamp.desc()).paginate(page=page, per_page=current_app.config["FLASKY_POSTS_PER_PAGE"], error_out=False)
    posts = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for("api.get_user_posts", id=id, page=page-1)
    next = None
    if pagination.has_next:
        next = url_for("api.get_user_posts", id=id, page=page+1)
    return jsonify({
        "posts": [post.to_json() for post in posts],
        "prev": prev,
        "next": next,
        "count": pagination.total
    })

@api.route("/users/<int:id>/timeline/")
def get_user_followed_posts(id):
    user = User.query.get_or_404(id)
    page = request.args.get("page", 1, type=int)
    pagination = user.followed_posts.order_by(Post.timestamp.desc()).paginate(page=page, per_page=current_app.config["FLASKY_POSTS_PER_PAGE"], error_out=False)
    posts = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for("api.get_user_followed_posts", id=id, page=page-1)
    next = None
    if pagination.has_next:
        next = url_for("api.get_user_followed_posts", id=id, page=page+1)
    return jsonify({
        "posts": [post.to_json() for post in posts],
        "prev": prev,
        "next": next,
        "count": pagination.total
    })

l   app/api/comments.py
from flask import jsonify, request, g, url_for, current_app
from .. import db
from ..models import Post, Permission, Comment
from . import api
from .decorators import permission_required

@api.route("/comments/")
def get_comments():
    page = request.args.get("page", 1, type=int)
    pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate(page=page, per_page=current_app.config["FLASKY_COMMENTS_PER_PAGE"], error_out=False)
    comments = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for("api.get_comments", page=page-1)
    next = None
    if pagination.has_next:
        next = url_for("api.get_comments", page=page+1)
    return jsonify({
        "comments": [comment.to_json() for comment in comments],
        "prev": prev,
        "next": next,
        "count": pagination.total
    })

@api.route("/comments/<int:id>")
def get_comment(id):
    comment = Comment.query.get_or_404(id)
    return jsonify(comment.to_json())

@api.route("/posts/<int:id>/comments/")
def get_post_comments(id):
    post = Post.query.get_or_404(id)
    page = request.args.get("page", 1, type=int)
    pagination = post.comments.order_by(Comment.timestamp.asc()).paginate(page=page, per_page=current_app.config["FLASKY_COMMENTS_PER_PAGE"], error_out=False)
    comments = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for("api.get_post_comments", id=id, page=page-1)
    next = None
    if pagination.has_next:
        next = url_for("api.get_post_comments", id=id, page=page+1)
    return jsonify({
        "comments": [comment.to_json() for comment in comments],
        "prev": prev,
        "next": next,
        "count": pagination.total
    })

@api.route("/posts/<int:id>/comments/", methods=["POST"])
@permission_required(Permission.COMMENT)
def new_post_comment(id):
    post = Post.query.get_or_404(id)
    comment = Comment.from_json(request.json)
    comment.author = g.current_user
    comment.post = post
    db.session.add(comment)
    db.session.commit()
    return jsonify(comment.to_json()), 201, \
        {"Location": url_for("api.get_comment", id=comment.id)}

l   app/api/decorators.py
from functools import wraps
from flask import g
from .errors import forbidden

# permission_required
裝飾器
def permission_required(permission):
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            if not g.current_user.can(permission):
                return forbidden('Insufficient permissions')
            return f(*args, **kwargs)
        return decorated_function
    return decorator

l   tests/test_user_model.py
import unittest
from app import create_app, db
# ...
class UserModelTestCase(unittest.TestCase):
    # ...
    def test_to_json(self):
        u = User(email="john@example.com", password="cat")
        db.session.add(u)
        db.session.commit()
        with self.app.test_request_context("/"):
            json_user = u.to_json()
        expected_keys = ["url", "username", "member_since", "last_seen", "posts_url", "followed_posts_url", "post_count"]
        self.assertEqual(sorted(json_user.keys()), sorted(expected_keys))
        self.assertEqual("/api/v1/users/" + str(u.id), json_user["url"])

l   本書API使用範例,用HTTPie測試web服務的GETPOST
http --json --auth <email>:<password> GET http://localhost:5000/api/v1/posts/

http --json --auth <email>:<password> POST http://localhost:5000/api/v1/posts/ "body=I'm adding a post from the *command line*."

l   本書API使用範例,用HTTPie測試web服務,若要使用身分驗證權杖,先送一個POST request/api/v1/tokens/,取得權杖後,接著對使用者名稱欄位傳入回傳的權杖,留空密碼欄位:
http --json --auth <email>:<password> POST http://localhost:5000/api/v1/tokens/

http --json --auth <token>: GET http://localhost:5000/api/v1/posts/

第三部分、最後一哩路

第十五章、測試-覆蓋率指標(15a)

l   覆蓋率指標:
git checkout 15a

l   測試程式覆蓋率工具,這個工具附帶一個命令列腳本可在啟用程式碼覆蓋率工具的情況下執行任何Python App,也可以用程式來啟動覆蓋率引擎:
pip install coverage

l   flasky.py
import os

COV = None
if os.environ.get("FLASK_COVERAGE"):
    import coverage
    #
啟動覆蓋率引擎,branch=True選項會啟用分支覆蓋分析,追蹤有哪幾行程式碼被執行,也會確認每一個條件式是否已經執行了TrueFalse兩種情況
    #
如果沒有include選項,在虛擬環境安裝的所有擴充套件以及測試程式本身都會被納入覆蓋率報告,讓報告有許多雜訊
    COV = coverage.coverage(branch=True, include="app/*")
    COV.start()

import sys
# Click
模組是製作命令列的工具,可參考https://myapollo.com.tw/blog/python-click/的介紹
import click
from flask_migrate import Migrate
from app import create_app, db
from app.models import User, Follow, Role, Permission, Post, Comment

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, Follow=Follow, Role=Role, Permission=Permission, Post=Post, Comment=Comment)

@app.cli.command()
#
flask test命令傳入--coverage選項來啟用程式碼覆蓋率的支援
#
flask test --coverage指令,可得純文字報告的範例
#
click用引數將布林旗標值傳給函式
@click.option("--coverage/--no-coverage", default=False, help="Run tests under code coverage.")
@click.argument("test_names", nargs=-1)
def test(coverage, test_names):
    """Run the unit tests."""
    if coverage and not os.environ.get("FLASK_COVERAGE"):
        import subprocess
        os.environ["FLASK_COVERAGE"] = "1"
        sys.exit(subprocess.call(sys.argv))
        # os.exec*
家族主要用來代替目前程序,執行新的而不返回值
        # sys.executable
取得可執行Pythonexe的絕對路徑
        # os.execvp(sys.executable, [sys.executable] + sys.argv)
    import unittest
    if test_names:
        tests = unittest.TestLoader().loadTestsFromNames(test_names)
    else:
        # TestLoader()
加載測試實例,並將它們返回給測試套件
        # discover()
找到指定目錄下的所有測試腳本
        tests = unittest.TestLoader().discover("tests")
    # verbosity=2
為詳細模式,測試結果會顯示每個測試實例所有訊息
    unittest.TextTestRunner(verbosity=2).run(tests)
    if COV:
        COV.stop()
        COV.save()
        print("Coverage Summary:")
        COV.report()
        basedir = os.path.abspath(os.path.dirname(__file__))
        covdir = os.path.join(basedir, "tmp/coverage")
        COV.html_report(directory=covdir)
        print("HTML version: file://%s/index.html" % covdir)
        COV.erase()

第十五章、測試-使用Flask測試用戶端進行單元測試(15b)

l   使用Flask測試用戶端進行單元測試:
git checkout 15b

l   測試用戶端(Test Client)會複製Web伺服器執行app時的環境,讓測試程式可以扮演用戶端的角色,並傳送Request

l   config.py
# Flask-WTF
生成的所有表單都有一個隱藏欄位,裡面儲存了必須連同表單一起送出的CSRF權杖,必須解析回應的HTML來取出權杖
# ...
#
在測試組態中停用CSFR保護
class TestingConfig(Config):
    # ...
    WTF_CSRF_ENABLED = False
# ...

l   tests/test_client.py
import re
import unittest
from app import create_app, db
from app.models import User, Role

#
使用Flask測試用戶端的測試框架
class FlaskClientTestCase(unittest.TestCase):
    def setUp(self):
        self.app = create_app("testing")
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()
        Role.insert_roles()
        # self.client
Flask測試用戶端物件
        self.client = self.app.test_client(use_cookies=True)
   
    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.app_context.pop()
   
    def test_home_page(self):
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)
        # get_data()
預設以位元組陣列來回傳,as_text=True轉換成字串
        # self.assertTrue(b"Stranger" in response.data)
        self.assertTrue("Stranger" in response.get_data(as_text=True))
   
    #
Flask測試用戶端來模擬新使用者的操作流程
    def test_register_and_login(self):
        #
註冊新帳號
        response = self.client.post("/auth/register", data={
            "email": "john@example.com",
            "username": "john",
            "password": "cat",
            "password2": "cat"
        })
        self.assertEqual(response.status_code, 302)
       
        #
用新帳號登入
        # follow_redirects=True
引數來讓測試用戶端扮演瀏覽器,並自動發出一個轉址URL GET request
        response = self.client.post("/auth/login", data={
            "email": "john@example.com",
            "password": "cat"
        }, follow_redirects=True)
        self.assertEqual(response.status_code, 200)
        # Jinja2
模板的建立方式,在最終HTML中,會有額外的空格,故使用正規表達式
        self.assertTrue(re.search("Hello,\s+john!", response.get_data(as_text=True)))
        self.assertTrue("You have not confirmed your account yet" in response.get_data(as_text=True))
       
        #
傳送確認權杖
        user = User.query.filter_by(email="john@example.com").first()
        token = user.generate_confirmation_token()
        response = self.client.get("/auth/confirm/{}".format(token), follow_redirects=True)
        user.confirm(token)
        self.assertEqual(response.status_code, 200)
        self.assertTrue("You have confirmed your account" in response.get_data(as_text=True))
       
        #
登出
        response = self.client.get("/auth/logout", follow_redirects=True)
        self.assertEqual(response.status_code, 200)
        self.assertTrue("You have been logged out" in response.get_data(as_text=True))

第十五章、測試-使用Flask測試用戶端進行API測試(15c)

l   使用Flask測試用戶端進行API測試:
git checkout 15c

l   tests/test_api.py
import unittest
import json
import re
from base64 import b64encode
from app import create_app, db
from app.models import User, Role, Post, Comment

#
使用Flask測試用戶端來做RESTful API測試
class APITestCase(unittest.TestCase):
    def setUp(self):
        self.app = create_app("testing")
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()
        Role.insert_roles()
        self.client = self.app.test_client()
   
    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.app_context.pop()
   
    #
會回傳必須與多數API request一起傳送的標頭,包括身分驗證憑證與MIME型態相關的標頭
    def get_api_headers(self, username, password):
        return {
            "Authorization": "Basic " + b64encode((username + ":" + password).encode("utf-8")).decode("utf-8"),
            "Accept": "application/json",
            "Content-Type": "application/json"
        }
   
    def test_404(self):
        response = self.client.get("/wrong/url", headers=self.get_api_headers("email", "password"))
        self.assertEqual(response.status_code, 404)
        json_response = json.loads(response.get_data(as_text=True))
        self.assertEqual(json_response["error"], "not found")
   
    #
確保不包含身分驗證憑證的request會被401錯誤碼拒絕
    def test_no_auth(self):
        response = self.client.get(url_for("api.get_posts"), content_type="application/json")
        # response = self.client.get("/api/v1/posts/", content_type="application/json")
        self.assertEqual(response.status_code, 401)
   
    def test_bad_auth(self):
        #
新增一名使用者
        r = Role.query.filter_by(name="User").first()
        self.assertIsNotNone(r)
        u = User(email="john@example.com", password="cat", confirmed=True, role=r)
        db.session.add(u)
        db.session.commit()
       
        #
以錯誤的密碼驗證
        response = self.client.get(
            "/api/v1/posts/",
            headers=self.get_api_headers("john@example.com", "dog"))
        self.assertEqual(response.status_code, 401)
   
    def test_token_auth(self):
        #
新增一名使用者
        r = Role.query.filter_by(name="User").first()
        self.assertIsNotNone(r)
        u = User(email="john@example.com", password="cat", confirmed=True, role=r)
        db.session.add(u)
        db.session.commit()
       
        #
以錯誤的Token發送Request
        response = self.client.get(
            "/api/v1/posts/",
            headers=self.get_api_headers("bad-token", ""))
        self.assertEqual(response.status_code, 401)
       
        #
取得Token
        response = self.client.post(
            "/api/v1/tokens/",
            headers=self.get_api_headers("john@example.com", "cat"))
        self.assertEqual(response.status_code, 200)
        json_response = json.loads(response.get_data(as_text=True))
        self.assertIsNotNone(json_response.get("token"))
        token = json_response["token"]
       
        #
Token發送Request
        response = self.client.get(
            "/api/v1/posts/",
            headers=self.get_api_headers(token, ""))
        self.assertEqual(response.status_code, 200)
   
    def test_anonymous(self):
        response = self.client.get(
            "/api/v1/posts/",
            headers=self.get_api_headers("", ""))
        self.assertEqual(response.status_code, 401)
   
    def test_unconfirmed_account(self):
        #
新增一名未受驗證的使用者
        r = Role.query.filter_by(name="User").first()
        self.assertIsNotNone(r)
        u = User(email="john@example.com", password="cat", confirmed=False, role=r)
        db.session.add(u)
        db.session.commit()
        #
以未驗證帳戶取得貼文列表
        response = self.client.get(
            "/api/v1/posts/",
            headers=self.get_api_headers("john@example.com", "cat"))
        self.assertEqual(response.status_code, 403)
   
    #
將一位使用者加入資料庫,接著插入一篇部落格文章,接著讀回
    def test_posts(self):
        #
加入使用者
        r = Role.query.filter_by(name="User").first()
        self.assertIsNotNone(r)
        u = User(email="john@example.com", password="cat", confirmed=True, role=r)
        db.session.add(u)
        db.session.commit()
       
        #
撰寫一則空的文章
        response = self.client.post(
            "/api/v1/posts/",
            headers=self.get_api_headers("john@example.com", "cat"),
            data=json.dumps({"body": ""}))
        self.assertEqual(response.status_code, 400)
       
        #
撰寫文章
        response = self.client.post(
            "/api/v1/posts/",
            headers=self.get_api_headers("john@example.com", "cat"),
            data=json.dumps({"body": "body of the *blog* post"}))
        self.assertEqual(response.status_code, 201)
        url = response.headers.get("Location")
        self.assertIsNotNone(url)
       
        #
取得新文章
        response = self.client.get(
            url,
            headers=self.get_api_headers("john@example.com", "cat"))
        self.assertEqual(response.status_code, 200)
        json_response = json.loads(response.get_data(as_text=True))
        self.assertEqual("http://localhost" + json_response["url"], url)
        self.assertEqual(json_response["body"], "body of the *blog* post")
        self.assertEqual(json_response["body_html"], "<p>body of the <em>blog</em> post</p>")
        json_post = json_response
       
        #
取得用戶的貼文
        response = self.client.get(
            "/api/v1/users/{}/posts/".format(u.id),
            headers=self.get_api_headers("john@example.com", "cat"))
        self.assertEqual(response.status_code, 200)
        json_response = json.loads(response.get_data(as_text=True))
        self.assertIsNotNone(json_response.get("posts"))
        self.assertEqual(json_response.get("count", 0), 1)
        self.assertEqual(json_response["posts"][0], json_post)
       
        #
以追蹤者的角色從用戶那裡取得貼文
        response = self.client.get(
            "/api/v1/users/{}/timeline/".format(u.id),
            headers=self.get_api_headers("john@example.com", "cat"))
        self.assertEqual(response.status_code, 200)
        json_response = json.loads(response.get_data(as_text=True))
        self.assertIsNotNone(json_response.get("posts"))
        self.assertEqual(json_response.get("count", 0), 1)
        self.assertEqual(json_response["posts"][0], json_post)
       
        #
編輯貼文
        response = self.client.put(
            url,
            headers=self.get_api_headers("john@example.com", "cat"),
            data=json.dumps({"body": "updated body"}))
        self.assertEqual(response.status_code, 200)
        json_response = json.loads(response.get_data(as_text=True))
        self.assertEqual("http://localhost" + json_response["url"], url)
        self.assertEqual(json_response["body"], "updated body")
        self.assertEqual(json_response["body_html"], "<p>updated body</p>")
        # ...
其餘省略,詳見作者Repository

第十五章、測試-使用Selenium進行單元測試(15d)

l   使用Selenium進行單元測試:
git checkout 15d

l   Selenium來做端對端測試:
pip install selenium

l   可以從ChromeDriver網站(https://sites.google.com/a/chromium.org/chromedriver/downloads)下載一般的ChromeDriver安裝程式。

l   app/main/views.py
# ...
#
當所有測試都完成時,所有的Flask伺服器都必須停止;Werkzeug Web伺服器有關機選項,要求伺服器關機唯一的方法是傳送一般的HTTP Request
# Werkzeug Web
伺服器關機路由
@main.route("/shutdown")
def server_shutdown():
    #
app在測試模式下運行時,關機路由才有效
    if not current_app.testing:
        abort(404)
    #
實際的關機程序需要呼叫Werkzeug在環境中公開的關機函式,呼叫函式並從request回傳後,開發web伺服器就必須退場了
    shutdown = request.environ.get("werkzeug.server.shutdown")
    if not shutdown:
        abort(500)
    shutdown()
    return "Shutting down..."
# ...

l   tests/test_selenium.py
#
Selenium執行測試的框架
import re
import threading
import time
import unittest
from selenium import webdriver
from app import create_app, db, fake
from app.models import Role, User, Post

class SeleniumTestCase(unittest.TestCase):
    client = None
   
    # setUpClass
tearDownClass在整個class只會執行一次,setUptearDown在每個測試方法之前和之後都會運行
    @classmethod
    def setUpClass(cls):
        #
啟動Chrome
        options = webdriver.ChromeOptions()
        options.add_argument("headless")
        try:
            cls.client = webdriver.Chrome(chrome_options=options)
        except:
            pass
       
        #
如果瀏覽器無法啟動,就跳過這些測試
        if cls.client:
            #
建立app
            cls.app = create_app("testing")
            cls.app_context = cls.app.app_context()
            cls.app_context.push()
           
            #
禁止記錄,來讓unittest有簡明的輸出
            import logging
            logger = logging.getLogger("werkzeug")
            logger.setLevel("ERROR")
           
            #
建立資料庫並填入一些偽造資料
            db.create_all()
            Role.insert_roles()
            fake.users(10)
            fake.posts(10)
           
            #
加入一位管理員使用者
            admin_role = Role.query.filter_by(name="Administrator").first()
            admin = User(email="john@example.com", username="john", password="cat", role=admin_role, confirmed=True)
            db.session.add(admin)
            db.session.commit()
           
            #
在執行緒中啟動Flask伺服器
            #
雖然啟動伺服器的app.run()已經換成flask run命令,但仍然可以使用app.run()
            cls.server_thread = threading.Thread(target=cls.app.run, kwargs={"debug": "false", "use_reloader": False, "use_debugger": False})
            cls.server_thread.start()
            #
給伺服器一點時間以確保其正常運作
            time.sleep(1)
   
    @classmethod
    def tearDownClass(cls):
        if cls.client:
            #
停止Flask伺服器與瀏覽器
            cls.client.get("http://localhost:5000/shutdown")
            cls.client.quit()
            #
將主執行緒暫停,等待指定的執行緒結束
            cls.server_thread.join()
           
            #
銷毀資料庫
            db.drop_all()
            db.session.remove()
           
            #
移除app context
            cls.app_context.pop()
   
    def setUp(self):
        if not self.client:
            self.skipTest("Web browser not available")
   
    def tearDown(self):
        pass
   
    # Selenium
單元測試範例
    def test_admin_home_page(self):
        #
前往首頁
        self.client.get("http://localhost:5000/")
        self.assertTrue(re.search("Hello,\s+Stranger!", self.client.page_source))
       
        #
前往登入頁
        self.client.find_element_by_link_text("Log In").click()
        self.assertIn("<h1>Login</h1>", self.client.page_source)
       
        #
登入
        self.client.find_element_by_name("email").send_keys("john@example.com")
        self.client.find_element_by_name("password").send_keys("cat")
        self.client.find_element_by_name("submit").click()
        self.assertTrue(re.search("Hello,\s+john!", self.client.page_source)
       
        #
前往使用者的個人資訊網頁
        self.client.find_element_by_link_text("Profile").click()
        self.assertIn("<h1>john</h1>", self.client.page_source)

第十六章、效能-分析與記錄緩慢的資料庫查詢(16a16b)

l   分析與記錄緩慢的資料庫查詢:
git checkout 16a
git checkout 16b

l   app/main/views.py
# Flask-SQLAlchemy
有個選項可記錄在處理一個request的過程中發出的資料庫查詢統計數據,回報緩慢的資料庫查詢
# ...
from flask_sqlalchemy import get_debug_queries
# ...

@main.after_app_request
def after_request(response):
    #
在預設情況下,get_debug_queries()函式只會在除錯模式中啟用
    for query in get_debug_queries():
        if query.duration >= current_app.config["FLASKY_SLOW_DB_QUERY_TIME"]:
            current_app.logger.warning("Slow query: %s\nParameters: %s\nDuration: %fs\nContext: %s\n" % (query.statement, query.parameters, query.duration, query.context))
    return response
# ...

l   config.py
# ...
class Config:
    # ...
    FLASKY_SLOW_DB_QUERY_TIME = 0.5
    # ...
# ...

l   Flask-SQLAlchemy記錄的查詢統計數據:

statement

SQL陳述式

parameters

SQL陳述式的參數

start_time

查詢被發出時的時間

end_time       

查詢回傳時的時間

duration

查詢的執行秒數

context

指出發出查詢的原始程式碼位置的字串

l   flasky.py
# Flask
的開發web伺服器(來自Werkzeug)可讓你選擇為每個request啟用Python剖析,每當web伺服器指派requestapp時,WSGI中介軟體就會執行,並修改request的處理方式
# Python
剖析器官方文件https://docs.python.org/2/library/profile.html

# ...
@app.cli.command()
@click.option("--length", default=25, help="Number of functions to include in the profiler report.")
@click.option("--profile-dir", default=None, help="Directory where profiler data files are saved.")
def profile(length, profile_dir):
    """Start the application under the code profiler."""
    from werkzeug.contrib.profiler import ProfilerMiddleware
    app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[length], profile_dir=profile_dir)
    app.run(debug=False)

第十七章、部署-部署指令(17a)

l   部署指令:
git checkout 17a

l   flasky.py
# ...
from flask_migrate import upgrade
from app.models import Role, User
# ...

@app.cli.command()
def deploy():
    """Run deployment tasks."""
    #
將資料庫遷移至最新版本
    upgrade()
   
    #
建立或更新使用者角色
    Role.insert_roles()
   
    #
確保所有使用者都追隨他們自己
    User.add_self_follows()

第十七章、部署-以Email通知應用程式錯誤(17b)

l   Email通知應用程式錯誤:
git checkout 17b

l   在啟動過程中,Flask會建立Python類別logging.Logger的實例,並將它指派給app實例,成為app.logger,在除錯模式中,這個紀錄器會將訊息寫到主控台,需要加入處理常式,否則Log不會被存起來。

l   config.py
# ...
#
寄送回報app錯誤的email
class ProductionConfig(Config):
    # ...
    @classmethod
    def init_app(cls, app):
        Config.init_app(app)
        #
將錯誤寄給管理員
        import logging
        from logging.handlers import SMTPHandler
        credentials = None
        secure = None
        # Python
getattr()函式用來取得物件屬性值,三個參數分別是物件、屬性名稱、預設值
        if getattr(cls, "MAIL_USERNAME", None) is not None:
            credentials = (cls.MAIL_USERNAME, cls.MAIL_PASSWORD)
            if getattr(cls, "MAIL_USE_TLS", None):
                secure = ()
        mail_handler = SMTPHandler(
            mailhost=(cls.MAIL_SERVER, cls.MAIL_PORT),
            fromaddr=cls.FLASKY_MAIL_SENDER,
            toaddrs=[cls.FLASKY_ADMIN],
            subject=cls.FLASKY_MAIL_SUBJECT_PREFIX + " Application Error",
            credentials=credentials,
            secure=secure)
        mail_handler.setLevel(logging.ERROR)
        app.logger.addHandler(mail_handler)
# ...

第十七章、部署-Heroku支援GunicornWaitress範例(17c17c-waitress)

l   Heroku支援GunicornWaitress範例:
git checkout 17c
git checkout 17c-waitress

l   Heroku是最早的PaaS供應商之一,Heroku使用一種名為dynos的計算單位來衡量服務的使用與收費,最常見的dyno類型是web dyno,也就是一個web伺服器實例,另一種dynoworker dyno,它的用途是執行背景工作或其它支援工作。

l   Heroku部署步驟:
建立Heroku帳號、
安裝Heroku CLI
建立app(gitconfig設定)
配置資料庫、
設置登入(log組態設定)
設置email
加入頂層的需求檔

l   Heroku會使用自己的SSL憑證,為了確保app的安全,唯一必要的動作就是攔截送往http://介面的所有請求,並將它們轉址到https://,這正是Flask-SSLify擴充套件的功能:
pip install flask-sslify

l   app/__init__.py
# ...
#
將所有request轉址到安全HTTP
def create_app(config_name):
    # ...
    if app.config["SSL_REDIRECT"]:
        from flask_sslify import SSLify
        sslify = SSLify(app)
    # ...

l   config.py
# ...
class Config:
    # ...
    SSL_REDIRECT = False
    # ...
# ...
#
支援代理伺服器
class HerokuConfig(ProductionConfig):
    SSL_REDIRECT = True if os.environ.get("DYNO") else False
   
    @classmethod
    def init_app(cls, app):
        ProductionConfig.init_app(app)
        #
處理反向代理伺服器標頭
        try:
            from werkzeug.middleware.proxy_fix import ProxyFix
        except ImportError:
            from werkzeug.contrib.fixers import ProxyFix
        app.wsgi_app = ProxyFix(app.wsgi_app)
        #
記錄Logstderr
        import logging
        from logging import StreamHandler
        file_handler = StreamHandler()
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)

config = {
    "development": DevelopmentConfig,
    "testing": TestingConfig,
    "production": ProductionConfig,
    "heroku": HerokuConfig,
    "default": DevelopmentConfig
}

l   GunicornuWSGI是可以和Flask app良好配合的兩種準產品web伺服器,要在本地端用Gunicorn運行app的指令,Gunicorn預設使用連接埠8000,而不是像Flask5000
pip install gunicorn

# flasky:app
引數可讓Gunicorn知道app實例在哪裡
gunicorn flasky:app

l   Gunicorn web伺服器無法在Microsoft Windows上運行,如果想要在Windows系統上測試Heroku部署,可使用Waitress
pip install waitress

waitress-serve -port 8000 flasky:app

第十七章、部署-Docker(17d17e17f)

l   Docker
git checkout 17d
git checkout 17e
git checkout 17f

l   Dockerfile
#
容器映像組建腳本
FROM python:3.6-alpine

ENV FLASK_APP flasky.py
ENV FLASK_CONFIG production

# adduser
-D引數會停止使用者密碼的互動提示
RUN adduser -D flasky
USER flasky

WORKDIR /home/flasky

COPY requirements requirements
RUN python -m venv venv
RUN venv/bin/pip install -r requirements/docker.txt

COPY app app
COPY migrations migrations
COPY flasky.py config.py boot.sh ./

#
執行期組態
EXPOSE 5000
ENTRYPOINT ["./boot.sh"]

l   config.py
# ...
# Docker
組態設置
class DockerConfig(ProductionConfig):
    @classmethod
    def init_app(cls, app):
        ProductionConfig.init_app(app)
       
        # log
stderr
        import logging
        from logging import StreamHandler
        file_handler = StreamHandler()
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)

config = {
    # ...
    "docker": DockerConfig,
    # ...
}

l   boot.sh
#
容器啟動腳本
#!/bin/sh
source venv/bin/activate

while true; do
    flask deploy
    if [[ "$?" == "0" ]]; then
        break
    fi
    echo Deploy command failed, retrying in 5 secs...
    sleep 5
done

exec gunicorn -b :5000 --access-logfile - --error-logfile - flasky:app

l   容器部署指令:
#
部署MySQL 5.7資料庫伺服器,為了能夠連接MySQL資料庫,SQLAlchemy要安裝一個pymysql這類的MySQL用戶端套件
docker run --name mysql -d -e MYSQL_RANDOM_ROOT_PASSWORD=yes -e MYSQL_DATABASE=flasky -e MYSQL_USER=flasky -e MYSQL_PASSWORD=<database-password> mysql/mysql-server:5.7

#
建立容器映像
docker build -t flasky:latest .

#
執行容器
# --link
選項可設置新容器與另一個既有容器的連結,冒號分隔來源容器名稱或ID,以及所建立容器的別名
docker run -d -p 8000:5000 --link mysql:dbserver -e DATABASE_URL=mysql+pymysql://flasky:<database-password>@dbserver/flasky -e MAIL_USERNAME=<your-gmail-username> -e MAIL_PASSWORD=<your-gmail-password> flasky:latest

l   docker-compose.yml
#
使用Docker Composer來調度容器
# Compose
會偵測flasky容器的links鍵來找出依賴關係,以正確的順序啟動mysqlflasky容器
version: '3'
services:
  flasky:
    build: .
    ports:
      - "8000:5000"
    env_file: .env
    restart: always
    links:
      - mysql:dbserver
  mysql:
    image: "mysql/mysql-server:5.7"
    env_file: .env-mysql
    restart: always

l   .env檔案裡面有下列變數:
FLASK_APP=flasky.py
FLASK_CONFIG=docker
MAIL_USERNAME=<your-gmail-username>
MAIL_PASSWORD=<your-gmail-password>
DATABASE_URL=mysql+pymysql://flasky:<database-password>@dbserver/flasky

l   .env-mysql檔案裡面有下列變數:
MYSQL_RANDOM_ROOT_PASSWORD=yes
MYSQL_DATABASE=flasky
MYSQL_USER=flasky
MYSQL_PASSWORD=<database-password>

l   Docker Composer部署指令:
docker-compose up -d --build

第十七章、部署-傳統的託管方式(17g)

l   傳統的託管方式:
git checkout 17g

l   使用Python套件輔助將.env檔匯入環境:
pip install python-dotenv

l   flasky.py
#
.env檔匯入環境變數
import os
from dotenv import load_dotenv

dotenv_path = os.path.join(os.path.dirname(__file__), ".env")
if os.path.exists(dotenv_path):
    load_dotenv(dotenv_path)
# ...

l   config.py
# ...
# Unix
組態範例
class UnixConfig(ProductionConfig):
    @classmethod
    def init_app(cls, app):
        ProductionConfig.init_app(app)
       
        # log
syslog
        import logging
        from logging.handlers import SysLogHandler
        syslog_handler = SysLogHandler()
        syslog_handler.setLevel(logging.WARNING)
        app.logger.addHandler(syslog_handler)

config = {
    # ...
    "unix": UnixConfig,
    # ...
}

第十八章、其他的資源

l   擴充套件,官方的Flask Extension Registry可尋找其它的擴充套件:
Flask-Babel
:支援國際化與當地化
Marshmallow
:序列化與解序列化Python物件,適合用來表示API資源
Celery
:可處理背景工作的工作佇列
Frozen-Flask
:將Flask app轉換成靜態網站
Flask-DebugToolbar
:在瀏覽器內的除錯工具
Flask-Assets
:合併、縮小與編譯CSSJavaScript資產
Flask-Session
:另一種使用者session的作品,使用伺服器端存儲
Flask-SocketIO
:支援WebSocket與長輪詢的Socket.IO伺服器

沒有留言:

張貼留言