Skip to content

22. 基于Flask框架的Web服务

此处所指的Web服务,是指任何向客户端提供原始数据的Web应用程序,在接下来的示例中,客户端通常是一个控制台脚本。 我们不关注特定技术,例如 REST(REpresentational 状态传输)或 SOAP(简单对象访问协议),这些技术以明确定义的格式提供或多或少的原始数据。 REST 返回的是 jSON,而 SOAP 返回的则是 XML。 这些技术都详细规定了客户端应如何向服务器发起请求,以及服务器响应应采用何种形式。在本课程中,我们将对客户端请求和服务器响应的性质采取更灵活的态度。不过,所编写的脚本和使用的工具与 REST 技术所用的非常接近。

22.1. 简介

Python脚本可由Web服务器执行。此类脚本将成为可服务于多个客户端的服务器程序。从客户端的角度来看,调用Web服务相当于请求该服务的URL。 客户端可以使用任何语言编写,特别是 Python。在后一种情况下,我们会使用刚才介绍过的互联网函数。此外,我们还需要懂得如何与 Web 服务“对话”,即理解 Web 服务器与其客户端之间的 HTTP 通信协议。 这正是|HTTP协议|这一节的内容。本课程这一部分所介绍的Web客户端,让我们得以了解HTTP协议的一部分。

Image

在最简单的形式下,客户端与服务器的交互如下:

  • 客户端与Web服务器的80端口建立连接;
  • 客户端发起文档请求;
  • Web服务器发送所请求的文档并关闭连接;
  • 客户端随后关闭连接;

文档的类型多种多样:可能是 HTML 格式的文本、图片、视频等。它可以是现成的文档(静态文档),也可以是由脚本即时生成的文档(动态文档)。在后一种情况下,我们称之为 Web 编程。 用于动态生成文档的脚本可以使用多种语言编写:PHP、Python、Perl、Java、Ruby、C#、VB.net等。

接下来,我们将使用 Python 脚本动态生成文本文档。

Image

  • [1] 中,客户端与服务器建立连接,请求一个 Python 脚本,并可选择是否向该脚本发送参数;
  • [3] 中,Web 服务器通过 Python 解释器执行该 Python 脚本。脚本生成一个文档并发送给客户端 [2];
  • 服务器关闭连接。客户端也随之关闭连接;

Web 服务器可以同时处理多个客户端。

接下来,我们将使用两个 Web 服务器:

  • 轻量级 Werkzeug 服务器 [https://werkzeug.palletsprojects.com/en/1.0.x/]。该服务器由 Web 框架 Flask [https://flask.palletsprojects.com/en/1.1.x/] 使用。我们将更常称其为 Flask 服务器;
  • Apache 2 服务器 [https://httpd.apache.org/];

Flask 服务器将用于所有示例。Apache 服务器将用于托管我们即将开发的 Web 应用程序。

Flask 框架采用 Python 语言开发。这是一个需要在终端中安装的模块 PyCharm:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\inet\utilitaires>pip install flask
Collecting flask
  Downloading Flask-1.1.2-py2.py3-none-any.whl (94 kB)
     || 94 kB 1.1 MB/s
Collecting click>=5.1
  Downloading click-7.1.2-py2.py3-none-any.whl (82 kB)
     || 82 kB 5.8 MB/s
Collecting itsdangerous>=0.24
  Downloading itsdangerous-1.1.0-py2.py3-none-any.whl (16 kB)
Collecting Jinja2>=2.10.1
  Downloading Jinja2-2.11.2-py2.py3-none-any.whl (125 kB)
     || 125 kB 6.4 MB/s
Collecting Werkzeug>=0.15
  Downloading Werkzeug-1.0.1-py2.py3-none-any.whl (298 kB)
     || 298 kB 6.4 MB/s
Collecting MarkupSafe>=0.23
  Downloading MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl (16 kB)
Installing collected packages: click, itsdangerous, MarkupSafe, Jinja2, Werkzeug, flask
Successfully installed Jinja2-2.11.2 MarkupSafe-1.1.1 Werkzeug-1.0.1 click-7.1.2 flask-1.1.2 itsdangerous-1.1.0
  • 第 1 行:执行的命令;
  • 第19行:已安装的组件:
    • [flask-1.1.2]:是一个基于Python的Web开发框架;
    • [Werkzeug-1.0.1]:是用于响应客户端请求的Web服务器;
    • [Jinja2-2.11.2]:是一种可在原本静态的网页中插入动态元素的工具;

22.2. [flask/01]脚本:Web编程入门

Image

我们的示例将在以下架构中运行:

Image

  • [1] 中,Python 脚本将像经典的控制台脚本一样被执行;
  • [2] 中,系统会透明地实例化一个 Web 服务器并等待请求。实际上,它只会接受一个 URL;
  • [3] 中,浏览器将向服务器请求其唯一的 URL;
  • [4] 阶段,服务器将执行由控制台 [1] 指定的 Python 脚本;
  • 当请求为 [5] 时,脚本将结果(一个文本文件)返回给 Web 服务器;
  • [6] 中,Web 服务器将该文本文件发送给浏览器;

22.2.1. 脚本 [exemple_01]:HTML 语言基础

网页浏览器可以显示各种文档,最常见的是 HTML 文档(HyperText 标记语言)。该文档是使用 <balise>texte</balise> 形式的标签进行格式化的文本。 因此,文本 <b>important</b> 将把重要内容显示为粗体。还有一些独立的标签,例如标签 <hr/>,它会显示一条水平线。我们不会逐一介绍 HTML 文本中可能出现的标签。 目前有许多 WYSIWYG 软件,可让您无需编写任何 HTML 代码即可构建 WEB 页面。 这些工具会根据用户使用鼠标和预定义控件设计的版面,自动生成相应的代码。 因此,用户可以(通过鼠标)在页面中插入一个表格,然后查看软件生成的代码,从而了解在WEB页面中定义表格应使用的标签。这并不复杂。 此外,掌握HTML语言是必不可少的,因为动态Web应用程序必须自行生成HTML代码并发送给Web客户端。该代码由程序生成,因此必须清楚需要生成什么内容,才能让客户端获得其期望的网页。

总而言之,开始进行网页编程时,完全无需掌握HTML语言的全部内容。 不过,掌握这些知识是必要的,可以通过使用WYSIWYG网页构建软件(如WEB)以及其他数十种类似工具来学习。 探索 HTML 语言精妙之处的另一种方法是浏览网页,并查看那些具有有趣且您尚未了解的特性的网页的源代码。

请看以下示例,其中展示了网页文档中可能出现的若干元素,例如:

  • 一个表格;
  • 一张图片;
  • 一个链接;

Image

HTML文档由<html>…</html>标签包围。它由两部分组成:

  • <head>…</head>:这是文档中不可显示的部分。它向将要显示该文档的浏览器提供信息。其中通常包含 <title>…</title> 标签,用于设定将在浏览器标题栏中显示的文本。此外还可能包含其他标签,特别是定义文档关键词的标签,这些关键词随后会被搜索引擎使用。 该部分还可能包含脚本,通常采用JavaScript或VBScript编写,并将由浏览器执行;
  • <body 属性>…</body>:这是浏览器将显示的部分。该部分包含的标签会向浏览器指示文档的“预期”视觉呈现形式。每个浏览器都会以自己的方式解释这些标签。 因此,同一网页文档在不同浏览器中的显示效果可能存在差异。这通常是网页设计师面临的难题之一;

我们示例文档的代码如下:


<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <title>Quelques balises HTML</title>
</head>

<body style="background-image: url(/static/images/standard.jpg)">
  <h1 style="text-align: left">Quelques balises HTML</h1>
  <hr />

  <table border="1">
    <thead>
      <tr>
        <th>Colonne 1</th>
        <th>Colonne 2</th>
        <th>Colonne 3</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>cellule(1,1)</td>
        <td style="text-align: center;">cellule(1,2)</td>
        <td>cellule(1,3)</td>
      </tr>
      <tr>
        <td>cellule(2,1)</td>
        <td>cellule(2,2)</td>
        <td>cellule(2,3</td>
      </tr>
    </tbody>
  </table>
  <br /><br />
  <table border="0">
    <tr>
      <td>Une image</td>
      <td>
        <img border="0" src="/static/images/cerisier.jpg" />
      </td>
    </tr>
    <tr>
      <td>Le site de Polytech'Angers</td>
      <td><a href="http://www.polytech-angers.fr/fr/index.html">ici</a></td>
    </tr>
  </table>
</body>
</html>
标签
标签与示例 HTML
titre du document
<title>部分标签 HTML</title> (第 5 行)
文本 [Quelques balises HTML] 将显示在浏览器的标题栏中,该浏览器将显示该文档
barre horizontale
<hr />:显示一条水平线(第 10 行)
tableau
<table 属性>….</table>:用于定义表格(第 12、32 行)
<thead>…</thead>:用于定义列标题(第13、19行)
<tbody>…</tbody>:用于定义表格内容(第20、31行)
<tr 属性>…</tr>:用于定义一行(第 21、25 行)
<td 属性>…</td>:用于定义一个单元格(第 22 行)
示例
<table border="1">…</table>:border 属性定义表格边框的粗细
<td style="text-align: center;">单元格(1,2)</td>(第23行):定义一个内容为单元格(1,2)的单元格。该内容将水平居中(text-align: center)。
image
<img border="0" src="/static/images/cerisier.jpg"/> (第 38 行):定义了一张无边框(border="0")的图片,其源文件位于 Web 服务器上的 [/static/images/cerisier.jpg](src="/static/images/cerisier.jpg")。 如果该链接位于通过 URL [http://server/chemin/balises.html] 生成的网页文档中,那么浏览器将请求 URL [http://server/ static/images/cerisier.jpg] 以获取此处引用的图片。
lien
<a href="http://www.polytech-angers.fr/fr/index.html">此处</a> (第43行):使文本 ici 作为链接指向 URL http://www.polytech-angers.fr/fr/index.html
fond de page
<body style="background-image: url(/static/images/standard.jpg)">(第 8 行):表示用作页面背景的图片位于 Web 服务器的 URL [/static/images/standard.jpg] 路径下。 在本例中,浏览器将请求 URL [http://server/static/images/standard.jpg] 以获取该背景图片。

从这个简单示例中可以看出,为了构建完整的文档,浏览器必须向服务器发出三项请求:

  • [http://server/chemin/balises.html] 以获取文档源文件 HTML;
  • [http://server/static/images/cerisier.jpg] 用于获取图片 cerisier.jpg;
  • [http://server/static/images/standard.jpg] 用于获取背景图片 standard.jpg;

脚本 [exemple_01] 将使我们能够显示前一个静态页面 [balises.html]

Image

  • [1] 中,即将执行的脚本 [exemple_01]
  • [3] 中,将由脚本显示的文档 HTML;
  • [2] 中,是文档 HTML 中的图片;

脚本 [exemple_01] 如下:


import os

from flask import Flask, make_response, render_template

# Flask 应用程序
script_dir = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__, template_folder=f"{script_dir}/../templates", static_folder=f"{script_dir}/../static")


# 首页 URL
@app.route('/')
def index():
    # 显示页面
    return make_response(render_template("balises.html"))


# main
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 7 行:实例化一个 Flask 应用程序。Flask 应用程序即 Web 应用程序;
    • 第一个参数是应用程序的名称。可以任意命名。此处使用了预定义属性 [__name__],其值为 [__main__](第 18 行);
    • 第二个参数是命名参数,即其在参数列表中的顺序无关紧要。命名参数 [template_folder] 指定了 Web 应用程序静态页面的存储目录。静态页面将原样发送给浏览器。 在此,静态页面将位于项目目录树中的 [templates] 文件夹内。第 7 行,我们指定了相对路径,指向包含已执行脚本 [exemple_01][script_dir] 文件夹;
    • 第三个参数也是一个命名参数。[static_folder] 指代存放文档 HTML 资源(图片、视频等)的文件夹。 此处同样使用了相对路径,指向包含已执行脚本 [exemple_01] 的文件夹 [script_dir]
  • 第 10-14 行:定义了 Web 应用程序支持的 URL。每个 URL 都关联了一个函数,当 Web 浏览器请求该 URL 时,该函数就会被调用;
  • 第 11 行:该应用程序唯一的 URL 是 URL [/]。 请注意,在 [@app.route('/')] 中,[app] 是第 7 行初始化的变量。 因此,路由的定义(即应用程序管理的各个 URL)必然位于应用程序 [app] 的定义之后。后者的名称可以自由定义;
  • 第12-14行:当向Web应用程序[exemple_01]请求URL [/]时执行的函数;
  • 第12行:与URL关联的函数可以命名为任意名称。该函数有时会带有参数,用于从与其关联的URL中获取元素。此处该函数没有参数;
  • 第 14 行:
    • 函数 [render_template] 返回一个字符串,该字符串即由其参数生成的文本文档。此处的参数为 [balises.html]。 由于第7行中的[template_folder],该文档将在[f"{script_dir}/../templates"]文件夹中被查找。它确实就在那里;
    • 函数 [make_response] 为请求 URL [/] 的浏览器生成响应 HTTP。 我们在 |HTTP 协议| 章节中看到,一个 HTTP 响应包含两个元素:
      • HTTP 头部;
      • 浏览器请求的文档,此处为 HTML 文档;

第 14 行,我们未向 [make_response] 函数传入任何参数来生成 HTTP 头部。因此它将生成默认头部。我们稍后将探讨如何设置这些 HTTP 头部。

  • 最后,当浏览器向 Flask 应用程序请求 URL 时,它将获得页面 [balises.html]
  • 第 17-20 行:这些代码用于启动将执行 [exemple_01] Web 应用程序的 Web 服务器;
    • 第 18 行:只有当 [exemple_01] 脚本在控制台中运行时,该条件才成立;
    • 第19行:配置第7行中的[app]应用程序:
    • 名为 [ENV="development"] 的参数将 Web 服务器置于开发模式:一旦开发人员修改应用程序中的某个元素,该应用程序就会被重新生成并交付给 Web 服务器。开发人员无需请求重新执行;
    • 名为 [DEBUG=True] 的参数允许开发人员在应用程序代码中设置断点;
    • 第20行:启动Web应用程序:实例化一个Web服务器,并将Web应用程序部署到该服务器上,以便响应Web客户端的请求;

以下是一个运行示例:

Image

随后,以下日志将出现在运行控制台中:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/flask/01/main/exemple_01.py
 * Serving Flask app "exemple_01" (lazy loading)
 * Environment: development
 * Debug mode: on
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 334-263-283
* Running on http://127.0.0.1:5000/ (按 CTRL+C 退出)
  • 第 2 行:服务器显示已执行的脚本;
  • 第 3 行:当前处于开发模式;
  • 第 4-5 行:服务器检测到当前以 [debug] 模式启动,因此重新启动(第 5 行)。[debug] 模式会稍微延缓启动过程;
  • 第 8 行:URL 处,已部署的 Web 应用程序 [exemple_01] 可用;

使用网页浏览器访问 URL [http://127.0.0.1:5000/]

Image

确实获得了预期的 [balises.html] 文档。

22.2.2. 脚本 [exemple_02]:动态生成 HTML 文档

Image

脚本 [exemple_02] [1] 将生成以下文档 [exemple_02.html] [2]


<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>{{page.title}}</title>
</head>
<body>
    <b>{{page.contents}}</b>
</body>
</html>

该文档是动态的,因为其内容只有在 Web 服务器提供服务时才完全确定。实际上,在第 5 行和第 8 行中,有两个在编写页面时尚未确定的元素。它们只有在页面发送给客户端时才会确定。届时,它们将被替换为相应的值,这些值是字符串。

  • 第5、8行:{{expression}}语法是Jinja2模板语言的语法[https://jinja.palletsprojects.com/en/2.11.x/]。在页面发送给客户端之前,页面中的动态元素(第5行和第8行)会被求值并替换为相应的值;
  • 第 5 行:使用了语法 [page.title]。因此,我们假设在页面生成并发送之前,已知一个变量 [page],稍后将说明原因。在 {{expression}} 语法中,可以使用任意变量名。 因此,在第 5 行和第 8 行,我们可以使用 {{title}} {{contents}}。这样可以说 [title] [contents] 是该页面的参数。接下来,我们将始终使用相同的技术:
    • 页面的唯一参数将是一个名为 [page] 的字典;
    • 该字典的属性将在页面中使用。此处为第 5 行的 [page.title] 和第 8 行的 [page.contents]

Web 应用程序 [exemple_02.py] 如下所示:


from flask import Flask, make_response, render_template

# Flask 应用程序
script_dir = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__, template_folder=f"{script_dir}/../templates", static_folder=f"{script_dir}/../static")


# 首页 URL
@app.route('/')
def index():
    # 以字典形式呈现的页面内容
    page = {"title": "un titre", "contents": "un contenu"}
    # 显示页面
    return make_response(render_template("exemple_02.html", page=page))


# main
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 我们在前面的示例中已经解释过第4-5行和第18-20行。在后续示例中我们将继续采用这种结构;
  • 第 9 行:Web 应用程序提供的唯一 URL 是 URL /;
  • 第14行:URL / 所返回的文档即为我们刚刚注释过的[exemple_02.html]文档。我们知道它有一个参数,即名为[page]的字典;
  • 第12行:我们定义了将作为参数传递给[exemple_02.html]页面的字典。该字典可以使用任意名称,但必须包含HTML文档中使用的[title, contents]属性
  • 第 14 行:函数 [render_template] 的作用是将文档 [exemple_02.html] 中的字符串转换为 HTML 格式。由于该文档是带参数的,因此需向函数 [render_template] 传递所需的参数。 此处通过为名为 [page] 的参数赋值来实现。在操作 [page=page] 中:
    • 等号左侧是文档 [exemple_02.html] 中使用的参数 [page]
    • 等号右侧是第 12 行定义的值 [page]
    • 一般而言,如果文档 HTML 具有参数 [param1, param2, …, paramn],则会将这些参数的值以 [render_template(document, param1=valeur1, param2=valeur2, …] 的形式传递给函数 [render_template]

在执行 [exemple_02] 之前,我们必须停止 [exemple_01] 的执行:

Image

如果在执行脚本 1 时,您感觉像是脚本 2 在运行,这很可能是因为脚本 2 仍在运行。 要恢复到已知状态,您可以停止 PyCharm 中所有正在运行的进程(位于 PyCharm 窗口的右上角):

Image

现在运行脚本 [exemple_02]

Image

此时控制台日志如下:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/flask/01/main/exemple_02.py
 * Serving Flask app "exemple_02" (lazy loading)
 * Environment: development
 * Debug mode: on
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 334-263-283
* Running on http://127.0.0.1:5000/ (按 CTRL+C 退出)

第 8 行显示了 [exemple_02] 应用程序(第 1 行)在 [localhost] 机器上的部署端口(5000)。由于前面的行始终相同,我们将不再重复显示。

使用浏览器访问 URL [http://localhost:5000/]

Image

  • 表达式 {{page.title}} 生成了 [1]
  • 表达式 {{page.contents}} 生成 [2]

22.2.3. 脚本 [exemple_03]:使用页面片段

Image

  • [1] 中,脚本 [exemple_03.py] 将生成动态文档 [exemple_03.html] [2]。 该文档将基于页面片段 [fragment_01.html, fragment_02.html] 和 [3] 构建;

文档 [exemple_03.html] 将如下所示:


<!DOCTYPE html>
<html lang="fr">
{% include "fragments/fragment_01.html" %}
<body>
{% include "fragments/fragment_02.html" %}
</body>
</html>
  • 第3行和第5行,使用了Jinja2的[include]指令,用于将文档外部的元素包含到文档中;
  • 语法为 {% include … %}。指令 [include] 的参数是待嵌入文档的路径。该路径相对于 Flask 应用程序的参数 [template_folder]

app = Flask(__name__, template_folder="../templates", static_folder="../static")

因此,此处的文档路径均以文件夹 [templates] 为基准。

片段 [fragment_01.html](名称当然可以自定义)如下:


<meta charset="UTF-8">
<title>{{page.title}}</title>

片段 [fragment_02.html] 如下:


<b>{{page.contents}}</b>

如果使用这些片段重建文档 [exemple_03.html],将得到以下代码:


<!DOCTYPE html>
<html lang="fr">
<meta charset="UTF-8">
<title>{{page.title}}</title>
<body>
<b>{{page.contents}}</b>
</body>
</html>

因此,我们得到了一份与 [exemple_02.html] 完全相同的文档,但它是通过片段拼接而成的。

Web脚本 [exemple_03.py] 如下所示:


import os

from flask import Flask, make_response, render_template

# Flask 应用程序
script_dir = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__, template_folder=f"{script_dir}/../templates", static_folder=f"{script_dir}/../static")


# 首页 URL
@app.route('/')
def index():
    # 页面内容
    page = {"title": "un autre titre", "contents": "un autre contenu"}
    # 页面显示
    return make_response(render_template("views/exemple_03.html", page=page))


# main
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()

该代码与 [exemple_02.py] 的代码类似。第 16 行展示了如何引用第 7 行中 [template_folder] 的子文件夹中的文档。

执行脚本 [exemple_03.py] 后,浏览器中显示以下结果:

Image

22.3. 脚本 [flask/02]:日期和时间 Web 服务

Image

文档 [date_time_server.html] 内容如下:


<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Date et heure du moment</title>
</head>
<body>
    <b>Date et heure du moment : {{page.date_heure}}</b>
</body>
</html>
  • 第8行:该页面支持参数 [page.date_heure]

Web 服务 [date_time_server.py] 如下:


# 导入
import os
import time

from flask import Flask, make_response, render_template

# Flask 应用程序
script_dir = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__, template_folder=f"{script_dir}")


# 首页 URL
@app.route('/')
def index():
    # 向客户端发送时间
    # time.localtime:自 1970 年 1 月 1 日以来的毫秒数
    # time.strftime 用于格式化时间和日期
    # 日期和时间显示格式
    # d:两位数的日期
    # m:2位数的月份
    # y:2位数的年份
    # H:小时 0,23
    # M:分钟
    # S:秒

    # 当前日期/时间
    time_of_day = time.strftime('%d/%m/%y %H:%M:%S', time.localtime())
    # 生成要发送给客户的文档
    page = {"date_heure": time_of_day}
    document = render_template("date_time_server.html", page=page)
    print("document", type(document), document)
    # 向客户发送响应 HTTP
    response = make_response(document)
    print("response", type(response), response)
    return response


# 仅主进程
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第13行:该Web应用程序仅提供URL服务;
  • 第15-24行:说明如何获取日期和时间以及如何显示它们;
  • 第 27 行:表示当前日期和时间的字符串;
  • 第28-30行:通过传入第29行的字典[page]来生成动态文档[date_time_server.html];
  • 第31行:显示[document]的类型及其文档本身。目的是展示其为字符串;
  • 第 33 行:生成将发送给客户端的响应 HTTP(该响应尚未发送);
  • 第 34 行:显示其类型和值;
  • 第 35 行:将响应 HTTP 发送给客户端;

脚本执行后,浏览器中显示如下结果:

Image

控制台日志如下:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\flask\02\date_time_server.py
 * Serving Flask app "date_time_server" (lazy loading)
 * Environment: development
 * Debug mode: on
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 334-263-283
* Running on http://127.0.0.1:5000/ (按 CTRL+C 退出)
127.0.0.1 - - [10/Jul/2020 09:32:09] "GET / HTTP/1.1" 200 -
document <class 'str'> <!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Date et heure du moment</title>
</head>
<body>
    <b>Date et heure du moment : 10/07/20 09:42:33</b>
</body>
</html>
response <class 'flask.wrappers.Response'> <Response 195 bytes [200 OK]>
  • 第10行:可见[render_template]返回的类型为[str]。该字符串正是经过解析(第10-19行)后的[date_time_server.html]文档
  • 第 20 行:可以看到 [make_response] 返回的值类型为 [flask.wrappers.Response]。函数 [Response.__str__] 已被隐式调用以显示对象 [Response]。 该函数返回的字符串提供了关于即将生成的 HTTP 响应的两条信息:
    • 发送的文档大小为195字节;
    • 响应 HTTP 的状态码为 [200 OK]。稍后我们将看到可以访问该状态码;

22.4. 脚本 [flask/03]:生成纯文本的 Web 服务

我们在之前的示例中看到,Web服务返回了以下文档:


<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Date et heure du moment</title>
</head>
<body>
    <b>Date et heure du moment : {{page.date_heure}}</b>
</body>
</html>

某个 Web 客户端可能只对第 8 行中的信息 [page.date_heure] 感兴趣,而对周围的包装信息 HTML 并不关心。Web 服务可以将该信息作为简单的字符串返回。下面我们将介绍此类 Web 服务的示例。

22.4.1. 脚本 [main_01]

Image

  • [main_01] 是 Web 服务;
  • [config] 是 Web 应用程序的配置脚本;
  • Web 服务使用了 [2] 中定义的部分实体;

[config]脚本如下:


def configure():
    # 配置中相对路径的绝对路径
    rootDir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"

    # 应用程序依赖项
    absolute_dependencies = [
        # 人员、工具、MyException
        f"{rootDir}/classes/02/entities",

    ]
    # 设置系统路径
    from myutils import set_syspath
    set_syspath(absolute_dependencies)

    # 生成配置
    return {}

此配置的主要作用是定义 Web 服务的 Python 路径。必须能够找到 [2] 中的实体(第 8 行)。

Web脚本 [main_01] 如下:


# 配置应用程序
import config
config=config.configure()

# 导入
from flask import Flask, make_response
from flask_api import status

# 依赖项
from Personne import Personne

# Flask 应用程序(此处无静态文档)
app = Flask(__name__)


# 首页 URL
@app.route('/')
def index():
    # 一个人
    personne = Personne().fromdict({"prénom": "Aglaë", "nom": "de la Hûche", "âge": 87})
    # 响应 HTTP
    response = make_response(str(personne))
    # 头信息 HTTP
    response.headers.set("Content-type", "application/json; charser=utf8")
    # 返回响应 HTTP
    return response, status.HTTP_200_OK


# 仅主程序
if __name__ == '__main__':
    # 启动服务器
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第1-3行:设置应用程序的Python路径;
  • 第 5-10 行:导入脚本所需的组件;
  • 第 17 行:Web 服务仅提供 URL / ;
  • 第 20 行:创建 [Personne] 对象;
  • 第 22 行:使用代表该人的字符串创建一个 HTTP 响应。 将调用函数 [Personne.__str__]。该函数返回该人物字典 [asdict] 中的字符串 jSON(参见 |类 BaseEntity|)。 函数 [make_response] 的参数是发送给客户端的文本文档,即此处某人的字符串 jSON;
  • 第24行: 在响应的 HTTP 头部中,添加一个 [Content-type] 头部,用于告知客户端将接收何种类型的文档,此处为采用 UTF-8 编码的 jSON 文档;
  • 第 26 行:返回一个包含两个元素的元组:
    • 给客户端的响应,包括 HTTP 头部和文档;
    • 响应状态码。此处需返回状态码 [200 OK]。各种状态码由第7行导入的模块 [flask_api] 中的常量定义;

模块 [flask_api] 默认不可用,需要进行安装。可在终端中执行以下操作:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\inet\utilitaires>pip install flask_api
Collecting flask_api
  Downloading Flask_API-2.0-py3-none-any.whl (119 kB)
     || 119 kB 544 kB/s
Requirement already satisfied: Flask>=1.1 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from flask_api) (1.1.2)
Requirement already satisfied: Jinja2>=2.10.1 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Flask>=1.1->flask_api) (2.11.2)
Requirement already satisfied: Werkzeug>=0.15 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Flask>=1.1->flask_api) (1.0.1)
Requirement already satisfied: click>=5.1 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Flask>=1.1->flask_api) (7.1.2)
Requirement already satisfied: itsdangerous>=0.24 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Flask>=1.1->flask_api) (1.1.0)
Requirement already satisfied: MarkupSafe>=0.23 in c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\lib\site-packages (from Jinja2>=2.10.1->Flask>=1.1->flask_api) (1.1.1
)
Installing collected packages: flask-api
Successfully installed flask-api-2.0

执行 Web 脚本 [main_01] 时,浏览器中将显示以下结果:

Image

  • [2] 中,显示接收到的字符串 jSON;
  • [3-4] 中,显示接收到的文档内容。可以看到其中没有任何 HTML 标记,只有字符串 jSON;

现在我们来看看Web服务发送给客户端的[Content-Type]标头的作用。我们将浏览器切换到开发者模式(通常为F12),并再次请求相同的URL。 以下是 Chrome 浏览器的屏幕截图:

Image

  • [1] 中,选择 [Network] 标签页;
  • [2, 4] 中:浏览器请求的 URL;
  • [3] 中,选择 [Headers] 选项卡(HTTP 头部);
  • [5] 中,接收到的响应状态码 HTTP;
  • [6] 中,包含告知客户端即将接收 jSON 文本的标头。这使客户端能够适应响应内容。 因此,Chrome 用于显示 jSON 响应与显示基本文本响应所使用的字体并不相同;

Image

  • [8] 中,选择 [Response] 标签页即可访问 Web 服务发送的文档,此处是一个简单的字符串 jSON;

22.4.2. Postman

[Postman]是用于查询Web应用程序中不同URL的工具。它允许我们:

  • 使用任意 URL:这些是手工生成的;
  • 通过 GET、POST、PUT、OPTIONS 等向 Web 服务器发送请求;
  • 指定 GET 或 POST 的参数;
  • 设置请求的 HTTP 头部;
  • 接收格式为 jSON、XML、HTML 的响应;
  • 访问响应的 HTTP 头部。因此,我们可以访问来自服务器的完整响应 HTTP;

[Postman] 是理解 HTTP 协议中客户端/服务器通信的绝佳教学工具。

[Postman] 可在 URL [https://www.getpostman.com/downloads/] 上获取。请安装您所选的 [Postman] 版本。 安装过程中,系统会要求您创建一个账户:此处无需创建该账户。[Postman]账户用于同步不同设备,以便将一个设备的配置复制到另一个设备上。此处无需进行任何此类操作。

安装完成后,[Postman]将显示以下界面:

Image

  • [2-3] 中,可访问产品设置;

Image

  • [6] 中,即本文档中使用的版本;

我们将在此使用 [Postman] 来测试前面的 Web 服务 jSON:

  • 我们执行脚本 [flask/03/main_01]
  • 随后使用 Postman 向 [http://localhost:5000/] 发起请求; Image
  • [1] 中,创建一个请求;
  • [2] 中,这将是一个针对 HTTP 和 GET 的请求;
  • [3] 中,被查询的 Web 服务的 URL;
  • [4] 中,将请求发送至 Web 服务; Image
  • [5] 中,选择 [Body] 选项卡,该选项卡将显示收到的文档;
  • [6] 中,选择 [Pretty] 选项卡,该选项卡以适当的格式显示接收到的文档,此处采用适合 jSON 字符串的格式;
  • [7] 中,显示接收到的 jSON 文档;
  • [8-9] 中,显示的是未格式化的接收文档; Image
  • [10] 中,显示 Postman 接收的 HTTP 头部信息;
  • [11] 中,显示接收到的响应状态 HTTP;
  • [12] 中,显示接收到的 HTTP 头部信息;
  • [13] 中,包含 [Content-type] 头部,该头部使 Postman 得知即将接收 jSON 字符串。Postman 利用此信息对接收到的文档进行了一定程度的格式化;

还有另一种使用 Postman 的方法,即使用 Postman 控制台(Ctrl-Alt-C)。通过它可以查看客户端与服务器的对话记录。除了使用 Ctrl-Alt-C 组合键外,还可以通过 Postman 主窗口左下角的图标打开控制台:

Image

Postman 控制台会记录在执行 Postman 请求时发生的客户端/服务器对话:

Image

  • [3] 中,列出了 Postman 自启动以来发出的请求。最新请求位于列表底部;
  • [4] 中,是 Postman 发出的请求 HTTP;
  • [5-6] 中,显示了 Web 服务器返回的响应 HTTP;
  • [7] 中,可以看到 [raw] 模式下的日志,即未经过任何格式化处理的原始日志;

[raw] 模式下,控制台窗口显示如下:

Image

  • [8] 中,Postman 向 Web 服务器发出的请求 HTTP;
  • [9] 模式下,显示 Web 服务器返回的响应 HTTP;
  • [10] 中,可以切换回 [pretty logs] 模式;

为便于说明,我们将对从 Postman 控制台获取的行进行编号。

对于客户端:

1
2
3
4
5
6
7
8
GET / HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 70e2acaa-b3e5-46f6-8375-989e6b94e694
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

对于服务器:

1
2
3
4
5
6
HTTP/1.0 200 OK
Content-type: application/json; charser=utf8
Content-Length: 56
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Mon, 13 Jul 2020 17:19:56 GMT
{"prénom": "Aglaë", "nom": "de la Hûche", "âge": 87}

从现在起,我们将主要使用:

  • [Postman] 作为 Web 客户端;
  • [Postman] 控制台(即 [raw mode])用于说明客户端/服务器对话;

22.4.3. 脚本 [main_02]

Image

Web脚本 [main_02] 如下:


# 配置应用程序
import config
config=config.configure()

# 导入
from flask import Flask, make_response
from flask_api import status

# 导入依赖项
from Personne import Personne

# Flask 应用程序
app = Flask(__name__)


# 首页 URL
@app.route('/')
def index():
    # 一个人
    personne = Personne().fromdict({"prénom": "Aglaë", "nom": "de la Hûche", "âge": 87})
    # 内容
    response = make_response(f"personne[{personne.prénom}, {personne.nom}, {personne.âge}]")
    # 头信息 HTTP
    response.headers.set("Content-Type", "text/plain; charset=utf8")
    # 响应 HTTP
    return response, status.HTTP_200_OK


# 仅主
if __name__ == '__main__':
    # 启动服务器
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 脚本 [main_02] 与脚本 [main_01] 类似。两者有两点不同:
    • 第 22 行:发送给客户端的文档是一个原始字符串,而不是 jSON 字符串;
    • 第24行:这一点体现在HTTP的[Content-Type]标头中,该标头指定文档的类型为[text/plain]

我们执行 Web 脚本 [main_02],然后使用 [Postman] 对其进行查询:

Image

  • [1-3] 中,向 Web 服务发起请求;
  • [5] 中,响应的状态为 OK;
  • [4, 6] 中,响应的标头为 HTTP;
  • [7] 中,包含响应的 [Content-Type] 头部;
  • [8-10],Web 服务发送的文档,一个字符串;

Postman 控制台显示以下日志:

客户端请求:

1
2
3
4
5
6
7
8
GET / HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 7c7fc9f3-8df8-49ae-9dc8-53c2d87d111a
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

服务器响应:


HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf8
Content-Length: 34
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Mon, 13 Jul 2020 17:34:22 GMT

personne[Aglaë, de la Hûche, 87]

22.4.4. 脚本 [main_03]

Image

Web脚本 [main_03] 如下:


# 配置应用程序
import config
config = config.configure()

# 导入
from flask import Flask, make_response
from flask_api import status

# 依赖项
from MyException import MyException
from Personne import Personne

# Flask 应用程序
app = Flask(__name__)


# 首页URL
@app.route('/')
def index():
    # 人员信息有误
    msg_erreur = None
    try:
        personne = Personne().fromdict({"prénom": "", "nom": "", "âge": 87})
    except MyException as erreur:
        msg_erreur = f"{erreur}"
    # 错误?
    if msg_erreur:
        response = make_response(msg_erreur)
        status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
    else:
        response = make_response(f"personne[{personne.prénom}, {personne.nom}, {personne.âge}]")
        status_code = status.HTTP_200_OK
    # 头信息 HTTP
    response.headers.set("Content-Type", "text/plain; charset=utf8")
    # 响应 HTTP
    return response, status_code


# 仅主
if __name__ == '__main__':
    # 启动服务器
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 23 行:因实例化了错误的对象而引发错误;
  • 第27-29行:由于上述错误:
    • 第28行:准备了一个HTTP响应,其内容为错误消息;
    • 第29行:将状态码HTTP的值设为错误值[500 Internal Server Error]
  • 第 34 行:告知客户端将发送纯文本;
  • 第 36 行:向客户端发送响应 HTTP;

我们启动 Web 服务 [main_03],并使用 Postman 对其进行查询:

Image

  • [1-3] 中,我们发送请求;
  • [4] 中,我们收到状态码为 [500 INTERNAL SERVER ERROR] 的响应;
  • [5-7] 中:响应内容为描述所发生错误的文本;

Image

  • [8-10] 中,包含 Web 服务响应的 HTTP 头部信息;

在 Postman 控制台中,[raw] 模式下的结果如下:

客户端请求:

1
2
3
4
5
6
7
8
GET / HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 925ff036-a360-47af-adf6-78173c01a247
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

服务器响应:


HTTP/1.0 500 INTERNAL SERVER ERROR
Content-Type: text/plain; charset=utf8
Content-Length: 74
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Mon, 13 Jul 2020 17:39:24 GMT

MyException[11, Le prénom doit être une chaîne de caractères non vide]

22.5. [flask/04] 脚本:请求中封装的信息

Image

脚本 [request_parameters.py] 旨在展示 Web 服务能够访问 Web 客户端请求中封装的各种信息。代码如下:


# 导入
from flask import Flask, make_response, request
from flask_api import status
# Flask 应用程序
app = Flask(__name__)


# 首页URL
@app.route('/', methods=['GET', 'POST'])
def index():
    # 请求参数
    request_data = {}
    request_data["environ"] = f"{request.environ}"
    request_data["path"] = request.path
    request_data["full_path"] = request.full_path
    request_data["script_root"] = request.script_root
    request_data["url"] = request.url
    request_data["base_url"] = request.base_url
    request_data["url_root"] = request.url_root
    request_data["accept_charsets"] = request.accept_charsets
    request_data["accept_encodings"] = request.accept_encodings
    request_data["accept_languages"] = request.accept_languages
    request_data["accept_mimetypes"] = request.accept_mimetypes
    request_data["args"] = request.args
    request_data["content_encoding"] = request.content_encoding
    request_data["content_length"] = request.content_length
    request_data["content_type"] = request.content_type
    request_data["endpoint"] = request.endpoint
    request_data["files"] = request.files
    request_data["form"] = request.form
    request_data["host"] = request.host
    request_data["method"] = request.method
    request_data["query_string"] = request.query_string.decode()
    request_data["referrer"] = request.referrer
    request_data["remote_addr"] = request.remote_addr
    request_data["remote_user"] = request.remote_user
    request_data["scheme"] = request.scheme
    request_data["script_root"] = request.script_root
    request_data["user_agent"] = f"{request.user_agent}"
    request_data["values"] = request.values
    # 响应 HTTP
    response = make_response(request_data)
    # 头信息 HTTP
    response.headers["Content-Type"] = "application/json; charset=utf-8"
    # 发送响应 HTTP
    return response, status.HTTP_200_OK


# 主函数
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 9 行:我们进行了一处修改。我们明确了客户端请求中允许使用的动词。Postman 提供了以下列表:

Image

前两个 [GET, POST] 是最常用的,也是本文中唯一会使用的。回到代码的第 9 行,参数 [methods] 包含上述列表中由 URL 允许的方法列表。 若缺少该参数,则仅允许使用 [GET] 方法。此前的情况正是如此;

  • 第 12 行:我们将构建字典 [request_data]
  • 第13行:客户端的请求存储在预定义对象[request]中(该对象在第2行导入,类型为[werkzeug.local.LocalProxy])。后续几行将获取该对象的各种属性;
  • 与其详细说明 [request] 对象的每个属性,不如直接运行这段代码并查看结果。这样就能更好地理解所显示的各个属性的含义;
  • 第 42 行:字典 [request_data] 将作为响应 HTTP 的内容。请注意,该内容必须是文本。Flask 会自动将字典转换为字符串 jSON;
  • 第 44 行:告知客户端将收到 jSON;
  • 第 46 行:将响应发送给客户端;

使用Postman客户端,我们将以下请求发送至前面的Web服务:

Image

  • [1-2] 中,发送的请求;
  • [2] 中,请求已配置参数。这些参数以 [ ?param1=valeur1&param2=valeur2] 的形式附加在 URL 之后。在 Postman 中输入这些参数有两种方式:
    • 直接在 URL 中输入;
    • 将其写入 [3-4]

这两种方法效果相同;

我们向请求中添加其他参数:

Image

  • [5-7] 中,我们将参数添加到请求正文(=body)中。虽然 URL 中的参数对网页浏览器用户可见,但请求正文中的参数则不可见。 浏览器(此处为 Postman)会在 HTTP 请求头之后将这些参数发送至服务器。 此时,Web客户端的请求与Web服务器的响应具有相同的结构:HTTP头部后跟一个文档。这将在客户端的请求中生成两个新的HTTP头部:
    • [Content-Type]:客户端告知服务器其发送的文档类型;
    • [Content-Length]:文档的大小(以字节为单位);
  • [6] 中,指定用于 [7] 中声明的参数的编码方式。这些参数可以采用多种编码方式。[x-www-form-urlencoded] 是浏览器常用的编码方法;

我们可以看到即将生成的请求:

Image

该请求的响应如下:

Image

  • [1-5] 中,我们收到了字符串 jSON [3]
  • Web服务通常关注的是URL和[ ?param1=valeur1&param2=valeur2]中的参数,以及请求正文(文档)中传递的参数。这通常是客户端向其传递信息的方式。 从 [5] 中可以看出,URL 的参数在 [request.args] 中可用;

响应的其余部分如下:

Image

  • [9] 中,请求正文中包含的参数属性:
    • [content_type] 是随请求附带的文档类型。 我们看到该文档包含以 [x-www-form-urlencoded] 格式编码的 [param=valeur] 类型信息。因此 Postman 生成了一个 HTTP [Content-Type] 头部,用于标识文档的性质;
    • [content_length] 是该文档的字节大小;
  • [10] 中,[request.environ] 属性包含大量关于处理客户端请求的环境信息。其中大部分信息可在 [request] 对象的其他属性中找到;
  • [11] 中,请求正文中的参数可在 [request.form] 属性中获取;
  • [12] 中,用于发送请求的方法(此处为 [GET]);
  • [13] 中,[request.values] 属性是所有参数的字典,包括 URL 中的参数和文档正文中的参数。要获取请求参数,需使用属性:
    • [request.args] 以获取 URL 中的参数;
    • [request.form] 用于获取文档正文中的参数;

在 Postman 控制台中,日志如下:

客户端请求:

GET /?param1=valeur1&param2=valeur2 HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: cbfac6aa-71a0-4076-a0c3-91d36d74a4c0
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 60

nom=s%C3%A9l%C3%A9n%C3%A9&pr%C3%A9nom=agla%C3%AB&%C3%A2ge=77
  • 第 9 行:发送给服务器的文档类型;
  • 第 11 行:请求的 HTTP 头部与发送的文档之间由空行分隔。服务器正是通过此空行识别客户端 HTTP 头部的结束;
  • 第12行:经过“URL编码”的文档。所有带重音的字符均已进行编码;

客户端的响应如下:


HTTP/1.0 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 2433
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Wed, 15 Jul 2020 06:09:09 GMT

{
  "accept_charsets": [], 
  "accept_encodings": [
    [
      "gzip", 
      1
    ], 
    [
      "deflate", 
      1
    ], 
    [
      "br", 
      1
    ]
  ], 
  "accept_languages": [], 
  "accept_mimetypes": [
    [
      "*/*", 
      1
    ]
  ], 
  "args": {
    "param1": "valeur1", 
    "param2": "valeur2"
  }, 
  "base_url": "http://localhost:5000/", 
  "content_encoding": null, 
  "content_length": 60, 
  "content_type": "application/x-www-form-urlencoded", 
  "endpoint": "index", 
  "environ": "{'wsgi.version': (1, 0), 'wsgi.url_scheme': 'http', 'wsgi.input': <_io.BufferedReader name=908>, 'wsgi.errors': <_io.TextIOWrapper name='<stderr>' mode='w' encoding='utf-8'>, 'wsgi.multithread': True, 'wsgi.multiprocess': False, 'wsgi.run_once': False, 'werkzeug.server.shutdown': <function WSGIRequestHandler.make_environ.<locals>.shutdown_server at 0x00000173CA6E5160>, 'SERVER_SOFTWARE': 'Werkzeug/1.0.1', 'REQUEST_METHOD': 'GET', 'SCRIPT_NAME': '', 'PATH_INFO': '/', 'QUERY_STRING': 'param1=valeur1&param2=valeur2', 'REQUEST_URI': '/?param1=valeur1&param2=valeur2', 'RAW_URI': '/?param1=valeur1&param2=valeur2', 'REMOTE_ADDR': '127.0.0.1', 'REMOTE_PORT': 50592, 'SERVER_NAME': '127.0.0.1', 'SERVER_PORT': '5000', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_USER_AGENT': 'PostmanRuntime/7.26.1', 'HTTP_ACCEPT': '*/*', 'HTTP_CACHE_CONTROL': 'no-cache', 'HTTP_POSTMAN_TOKEN': 'cbfac6aa-71a0-4076-a0c3-91d36d74a4c0', 'HTTP_HOST': 'localhost:5000', 'HTTP_ACCEPT_ENCODING': 'gzip, deflate, br', 'HTTP_CONNECTION': 'keep-alive', 'CONTENT_TYPE': 'application/x-www-form-urlencoded', 'CONTENT_LENGTH': '60', 'werkzeug.request': <Request 'http://localhost:5000/?param1=valeur1&param2=valeur2' [GET]>}", 
  "files": {}, 
  "form": {
    "nom": "s\u00e9l\u00e9n\u00e9", 
    "pr\u00e9nom": "agla\u00eb", 
    "\u00e2ge": "77"
  }, 
  "full_path": "/?param1=valeur1&param2=valeur2", 
  "host": "localhost:5000", 
  "method": "GET", 
  "path": "/", 
  "query_string": "param1=valeur1&param2=valeur2", 
  "referrer": null, 
  "remote_addr": "127.0.0.1", 
  "remote_user": null, 
  "scheme": "http", 
  "script_root": "", 
  "url": "http://localhost:5000/?param1=valeur1&param2=valeur2", 
  "url_root": "http://localhost:5000/", 
  "user_agent": "PostmanRuntime/7.26.1", 
  "values": {
    "nom": "s\u00e9l\u00e9n\u00e9", 
    "param1": "valeur1", 
    "param2": "valeur2", 
    "pr\u00e9nom": "agla\u00eb", 
    "\u00e2ge": "77"
  }
}
  • 第1-5行:响应的HTTP头部,以空行结尾;
  • 第41-45行:带重音的字符已进行编码,结果为UTF-8;

如果现在使用方法 [POST] 发送相同的请求并使用相同的参数,将获得相同的响应,只不过在 [12] 中,结果会变成 [‘method’ : ‘POST’]

那么,GET 和 POST 方法之间有什么区别?区别微乎其微,且源于浏览器在历史上的使用习惯:

  • URL中的参数设置非常实用,因为这样配置的URL可以作为HTML文档中的链接。 用户也可以自行更改参数以获得来自服务器的不同响应。在这种情况下,浏览器通常采用 [GET] 方法,且发送给 Web 服务器的请求中不包含正文(content_length=0);
  • 有时我们不希望参数显示在URL中。例如发送给服务器的密码。此外,URL中参数所占的大小是有限制的 (一个URL不能超过一定大小)。请求正文中的参数则没有此限制。此外,URL中过多的参数会导致其难以阅读。 以网站注册表单这一常见场景为例。早先,当网页尚未嵌入JavaScript时,浏览器会通过表单提交用户输入的信息。当时这被称为“POST值”;

因此,在网页编程的早期:

  • GET方法主要用于请求Web服务器提供的信息;
  • 而 POST 方法则更多地用于将信息从浏览器发送至服务器。服务器随后会通过这些信息得到“丰富”;

此后,JavaScript 改变了这一局面。在之前的示例中,开发者无法掌控流程(点击链接必然触发 GET,提交表单必然经过 POST),而 JavaScript 则让开发者重新掌握了主动权。 在此模型中,HTML页面关联了可绕过浏览器的JavaScript代码。因此,点击链接的行为可被JavaScript代码拦截,该代码随后可执行向服务器发送请求的指令。此请求对用户而言是透明的。 用户将无法察觉。该代码是一个Web客户端,正如我们使用Postman所做的那样,开发者可以创建任意请求。以点击链接为例,开发者可以生成POST请求,而默认情况下浏览器本应生成GET请求。 这些变化使得 GET 与 POST 之间的区别不再那么重要。

不过,开发人员通常遵循以下规则:

  • 一个 GET 请求不应改变服务器的状态。在 URL 请求中使用相同参数发出的连续 GET 请求应返回相同的文档。 此外,GET通常没有正文(无关联文档),仅包含URL中的参数;
  • 而 POST 可能改变服务器的状态。参数通常通过请求正文发送,这被称为 POST 值。 表单的示例最为直观:用户输入的值将被放入 POST 的请求主体中,服务器会将其存储在某个位置,通常是数据库中;

在本文档的后续部分中,我们不拘泥于任何特定规则。

22.6. [flask-05]脚本:用户内存管理

22.6.1. 引言

在之前的客户端/服务器示例中,其工作原理如下:

  • 客户端向 Web 服务器的 80 端口建立连接;
  • 发送文本序列:HTTP 头部、空行、[document];
  • 作为响应,服务器发送同类型的字符串;
  • 服务器关闭与客户端的连接;
  • 客户端关闭与服务器的连接;

如果同一客户端随后向Web服务器发出新请求,客户端与服务器之间将建立新的连接。服务器无法判断连接的客户端是否曾访问过,还是首次请求。在两次连接之间,服务器会“忘记”其客户端。因此,HTTP协议被称为无状态协议。 然而,服务器记住其客户端是有益的。例如,如果应用程序是安全的,客户端会向服务器发送用户名和密码以进行身份验证。如果服务器在两次连接之间“忘记”了该客户端,那么客户端在每次新连接时都必须重新验证身份,这显然是不现实的。

为了追踪客户端,服务器可以采取多种方式:

  1. 当收到客户端的首次请求时,服务器会在响应中包含一个标识符,客户端随后需在每次新请求中将其发回。 凭借这个针对每个客户端各不相同的标识符,服务器能够识别客户端。随后,服务器可以为该客户端管理一个存储空间,该空间以唯一关联于客户端标识符的形式存在。例如,PHP等服务便是如此运作的;
  2. 当客户首次发起请求时,服务器在响应中不包含标识符,而是直接包含用户内存本身。 服务器端不保留任何数据。为了维持其内存,Web客户端必须在每次新请求时将该内存发回。该内存会在每次新请求时被修改(或保持不变),并被发回(或不发回)给客户端。这是Flask框架采用的方法;

这两种方法的区别如下:

  • 方法1对带宽的需求较低。客户端与服务器之间仅交换一个标识符。当用户内存增长时,这对标识符没有任何影响,标识符保持不变。而方法2则不同,用户内存会在每次请求时进行交换,并可能随着请求次数的增加而不断扩大;
  • 方法1对内存空间的需求更大。因为服务器会将用户的内存存储在其文件系统中。如果用户达到一百万,这可能会造成问题。方法2则不会在服务器上存储任何数据;

从技术角度看,两种方法的实现过程如下:

  • 在响应新客户端时,服务器会在响应头中包含 HTTP、[Set-Cookie : MotClé=Identifiant] [Set-Cookie : mémoire]。 在方法1中,仅在首次请求时执行此操作。在方法2中,每当用户内存发生变化时都会执行此操作;
  • 在请求中,客户端会系统性地返回其接收到的内容,即一个标识符或一段记忆。它通过 HTTP [Cookie : MotClé=Valeur] 标头实现此操作;

人们可能会疑惑,服务器如何判断自己面对的是新客户端,而非曾访问过的客户端。 这是通过客户端的 HTTP 头部中是否包含 HTTP Cookie 头部来判断的。对于新客户端,该头部不存在。

某个客户端的所有连接统称为一个会话

服务器可能维护其他类型的内存:

Image

  • 在[1]中,请求内存具有特殊性。当Web客户端的请求不是由一个服务(或应用程序)处理,而是由多个服务处理时,就会用到它。为了将信息传递给第i+1个服务,第i个服务可以在处理中的请求中添加这些信息。这就是所谓的请求级内存。 本文中我们将不使用此类内存;
  • [2, 4] 中,即我们刚刚描述的用户内存。它可以在本地实现 [2] 或通过客户端维护 [4]
  • [3] 中,"应用程序"级别的内存通常是只读内存。它由所有用户共享。其中通常包含 Web 应用程序的配置项,这些配置由应用程序的所有用户共享。 使用此类内存时需格外谨慎:写入操作必须在用户尚未发送请求时进行,通常是在应用程序启动时。 随后,当请求到达时,很难向该内存写入数据。当Web服务器同时为多个用户提供服务,且其中两位用户都试图向“应用程序”级别的内存写入数据时,该内存就有可能被破坏。事实上,当用户1开始向“应用程序”级别的内存写入数据时,他可能在写入完成之前就被中断。 此时应用程序内存便处于不完整状态。由于该内存是共享的,用户2读取时可能会获取到错误的状态;

22.6.2. 脚本 [session_scope_01]

Image

脚本 [session_scope_xx] 演示了用户内存的管理。

脚本 [session_scope_01] 如下:


# 配置应用程序
import config
config = config.configure()

# 依赖项
import json
from flask import Flask, make_response, session
from flask_api import status

# Flask 应用程序
app = Flask(__name__)

# 会话密钥
app.secret_key = config["SECRET_KEY"]


@app.route('/set-session', methods=['GET'])
def set_session():
    # 向会话中写入数据
    session['nom'] = 'séléné'
    # 发送空响应
    response = make_response()
    response.headers['Content-Length'] = 0
    return response, status.HTTP_200_OK


@app.route('/get-session', methods=['GET'])
def get_session():
    # 获取会话并发送响应
    response = make_response(json.dumps({"nom": session['nom']}, ensure_ascii=False))
    response.headers['Content-Type'] = 'application/json; charset=utf-8'
    return response, status.HTTP_200_OK


# 仅主程序
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 11 行:实例化了一个 Flask 应用程序;
  • 第 14 行:该应用程序的 [secret_key] 属性从第 1-3 行使用的配置文件中获取值。只有初始化了该属性,才能建立 Flask 会话。该属性可以设置为任意内容,其作用是加密将发送给客户端的“用户内存”的一部分。 通常会设置一些难以猜出的内容。在文件 [config] 中,密钥的定义如下:

    # 返回配置
    config = {
        # Flask 配置
        "SECRET_KEY": "vibnFfrdWYUp?*LQ"
    }
  • 首次,我们定义了一个用于处理除 URL /
    • 第17行:URL [/set-session] 用于初始化用户会话;
    • 第27行:URL [/get-session] 用于检索用户存储(或用户会话);
  • 第20行:将某些内容放入用户的存储区(即会话中),此处是一个名称。会话的管理方式类似于字典。 不能在会话中存储任意内容。所存储的值必须能够转换为 jSON。对于 Python 的预定义类型,此转换无需开发者干预。对于 Python 不认识的自定义对象,则需要自行进行 jSON 转换;
  • 第 22 行:创建一个无内容的 HTTP 响应(即 make_response 没有参数);
  • 第 23 行:告知客户端将收到一个空文档(大小为 0 字节);
  • 第24行:向客户端发送响应HTTP。因此,URL和[/set-session]除了初始化用户会话外,不执行其他操作;
  • 第27行:URL和[/get-session]让用户能够了解其会话中的内容;
  • 第30行:创建一个包含用户会话字符串的响应。 这里我们自己创建了字符串 jSON,而不是让 Flask 自动生成。因为我们不希望重音字符被转义(ensure_ascii=False);
  • 第 31 行:告知客户端将发送 jSON;
  • 第 32 行:向客户端发送响应 HTTP;

本脚本旨在演示用户会话如何将用户的连续请求关联起来:

  • 请求 1 将请求 URL [/set-session]
  • 请求 2 将请求 URL [/get-session],并获取请求 1 初始化的名称;

用于配置 [flask/05] 文件夹中脚本的 [config] 脚本如下:


def configure():
    # 配置中相对路径的绝对路径
    root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"

    # 应用程序依赖项
    absolute_dependencies = [
        # 人员、工具、MyException
        f"{root_dir}/classes/02/entities",
    ]
    # 设置系统路径
    from myutils import set_syspath
    set_syspath(absolute_dependencies)

    # 生成配置
    config = {
        # Flask 配置
        "SECRET_KEY": "vibnFfrdWYUp?*LQ"
    }

    return config

我们运行脚本 [session_scope_01],随后使用 Postman 向 URL 和 [/set-session] 发起请求。在此之前,我们将验证即将发出的请求中的几个要素:

Image

  • [1] 中,访问 Postman 的 Cookie; Image
  • [2-4] 中,检查 Postman 的已知 Cookie 并将其全部删除 [4-5]

现在检查即将生成的请求 HTTP:

Image

  • [9] 中:Postman 将根据我们为该请求所做的配置,在请求中加入的部分头部信息 HTTP。通过此验证,您可以确认是否遗漏了参数,或者反之是否保留了多余的参数;

完成上述操作后,即可执行该查询:

Image

验证结果有多种方法。首先可以查看主窗口:

Image

  • [1-2] 中,查看发送到 Web 服务的请求;
  • [3-6] 表示响应的头部信息;
  • [4] 中,由于代码中未指定响应类型,Flask 默认使用了 [text/html] 类型;
  • [5] 中,客户端知道响应中没有文档;
  • 第 6 行:Flask 服务器发送了 [Set-Cookie] 头部。其值被称为会话 Cookie,由三个部分组成:
    • [session=valeur]:该值以编码形式表示用户的会话数据。该数据可被解码(参见 |https://blog.miguelgrinberg.com/post/how-secure-is-the-flask-user-session|)。 然而,由于服务器使用了密钥,用户无法修改接收到的数据并将其发回给服务器。因此,当服务器接收会话时,可以确保接收到的会话未被篡改;
    • [HttpOnly]:该元素的存在告知接收它的浏览器,该Cookie不应被其显示页面中可能包含的JavaScript访问;
    • [Path=/] 是需要回传会话 Cookie 的路径,即此处指 Web 应用程序的任何路径。 每当用户通过键盘显式(输入 URL)或隐式(点击链接)请求该域名的 URL 时,浏览器都会自动返回其收到的会话 Cookie;

主窗口的缺点在于无法查看导致此响应的完整请求。该窗口中显示的内容容易引起混淆:

Image

  • 在 HTTP 和 [3-4] 头部中,[5] 被呈现为一个会话 Cookie。这可能会让人误以为 Postman 在请求中添加了会话 Cookie,但事实并非如此。 [3] 这些头部实际上代表了 HTTP,根据当前配置,这些头部将在下一次请求中发送。 Postman 刚刚接收到了一个会话 Cookie,它将在下一次请求中将其发回。这就是为什么我们看到 [5]

我们可以通过 Ctrl-Alt-C 调出 Postman 控制台,查看客户端/服务器对话:


GET /set-session HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 3673b73f-7600-4df4-8c4b-c37973e50df8
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 0
Vary: Cookie
Set-Cookie: session=eyJub20iOiJzXHUwMGU5bFx1MDBlOW5cdTAwZTkifQ.Xw6jGQ.y5Icu70wTIN-B0o_hwx0xDH247I; HttpOnly; Path=/
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Wed, 15 Jul 2020 06:32:57 GMT
  • 第 14 行:服务器发送的会话 Cookie;

现在请求 URL [/get-session]

GET /get-session HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: ce991398-2d9a-46d0-9ccd-c7ff3c7f4d6d
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: session=eyJub20iOiJzXHUwMGU5bFx1MDBlOW5cdTAwZTkifQ.Xw6jGQ.y5Icu70wTIN-B0o_hwx0xDH247I

HTTP/1.0 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 20
Vary: Cookie
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Wed, 15 Jul 2020 06:36:52 GMT

{"nom": "séléné"}
  • 第 9 行:Postman 客户端将收到的会话 Cookie 发回给服务器;
  • 第 18 行:服务器发送的字符串 jSON;

此示例向我们展示了以下几点:

  • Postman客户端将从Flask服务器接收的会话cookie发回给服务器。Web浏览器始终如此操作;
  • 我们可以看到,请求 2([/get-session])成功检索到了在请求 1([/set-session])中生成的信息。因此,这里存在用户记忆机制;
  • 第 11-16 行:Flask 服务器未返回会话 Cookie。这并非固定模式。只有当上一次请求修改了用户状态时,Flask 服务器才会返回会话 Cookie;

22.6.3. 脚本 [session_scope_02]

Image

脚本 [session_02] 如下:


# 依赖项
import os

from flask import Flask, make_response, session
from flask_api import status

# Flask 应用程序
app = Flask(__name__)

# 会话密钥
app.secret_key = os.urandom(12).hex()


# 首页URL
@app.route('/', methods=['GET'])
def index():
    # 管理三个计数器
    if session.get('n1') is None:
        session['n1'] = 0
    else:
        session['n1'] = session['n1'] + 1
    if session.get('n2') is None:
        session['n2'] = 10
    else:
        session['n2'] = session['n2'] + 1
    if session.get('n3') is None:
        session['n3'] = 100
    else:
        session['n3'] = session['n3'] + 1
    # 计数器字典
    compteurs = {"n1": session['n1'], "n2": session['n2'], "n3": session['n3']}
    # 发送响应
    response = make_response(compteurs)
    response.headers['Content-Type'] = 'application/json; charset=utf-8'
    return response, status.HTTP_200_OK


# 主程序
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 11 行:此处通过函数生成密钥。该函数的优势在于能随机生成复杂的字符串。需注意,变量 [app] 是第 8 行创建的 Flask 类实例;
  • 第15行:本次仅包含一个路由,即 / 路由;
  • 第17-29行:管理一个包含三个计数器的会话 [n1, n2, n3]。用户首次访问时 [n1, n2, n3]=[0, 10, 100],此后每次访问这些计数器都会递增1;
  • 第18行:在首次请求时,应用程序会话为空。表达式 [session.get(‘clé’)] 返回值 [None]。对于后续请求,该表达式将返回与该键关联的值;
  • 第 31 行:这些计数器被放入一个字典中;
  • 第 33 行:该字典即响应文档 HTTP。需要提醒的是,Flask 会自动将字典转换为字符串 jSON;
  • 第 34 行:告知 Web 客户端将收到 jSON;
  • 第 35 行:将响应 HTTP 发送给客户端;

运行此脚本,并在清除 Postman 客户端的所有 Cookie 后,使用 Postman 查询由此创建的 Web 应用程序 [1-3]

Image

在 Postman 控制台中,客户端与服务器的交互如下:


GET / HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: c7db536d-9352-4aa6-9877-04560e03d935
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

HTTP/1.0 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 41
Vary: Cookie
Set-Cookie: session=eyJuMSI6MCwibjIiOjEwLCJuMyI6MTAwfQ.Xw6nLg.v49CeDWwqP-6Dp9Qt330GAe-dNA; HttpOnly; Path=/
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Wed, 15 Jul 2020 06:50:22 GMT

{
"n1": 0, 
"n2": 10, 
"n3": 100
}
  • [14],即服务器发送的会话cookie;
  • [18-22],服务器以字符串形式返回的响应 jSON;

让我们再次发送相同的请求。日志的变化如下:


GET / HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 8205ad85-37b3-41f2-a171-70dd3b3a1679
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: session=eyJuMSI6MCwibjIiOjEwLCJuMyI6MTAwfQ.Xw6nLg.v49CeDWwqP-6Dp9Qt330GAe-dNA

HTTP/1.0 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 41
Vary: Cookie
Set-Cookie: session=eyJuMSI6MSwibjIiOjExLCJuMyI6MTAxfQ.Xw6nsw.OuxIQnGhmhSsan5Qu_FL3Iyu-9k; HttpOnly; Path=/
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Wed, 15 Jul 2020 06:52:35 GMT

{
"n1": 1, 
"n2": 11, 
"n3": 101
}
  • 第 9 行:Postman 客户端返回其收到的会话 Cookie;
  • 第 15 行:服务器在响应中发送了一个新的会话 Cookie,这是因为客户端的请求修改了用户内存(即会话);
  • 第 19-23 行:计数器的新值;

22.6.4. 脚本 [session_scope_03]

此新脚本旨在展示可以在会话中放置不同类型的 Python 对象:列表、字典、对象。 唯一的限制是,放入会话中的对象必须在 jSON 中可序列化。如果它们默认不可序列化(如列表、字典),则需要自行进行转换,如 jSON 所示。


# 配置应用程序
import config
config = config.configure()

# 依赖项
import json
import os

from flask import Flask, make_response, session
from flask_api import status
from Personne import Personne

# Flask 应用程序
app = Flask(__name__)

# 会话密钥
app.secret_key = os.urandom(12).hex()


# 首页 URL
@app.route('/', methods=['GET'])
def index():
    # 列表管理
    liste = session.get('liste')
    if liste is None:
        # 第一个请求
        liste = [0, 10, 100]
    else:
        # 后续请求
        for i in range(len(liste)):
            liste[i] += 1
    # 将列表放回会话
    session['liste'] = liste

    # 字典管理
    dico = session.get('dico')
    if not dico:
        # 首次请求
        dico = {"un": 0, "deux": 10, "trois": 100}
    else:
        # 后续请求
        dico = session['dico']
        for key in dico.keys():
            dico[key] += 1
    # 将词典放回会话
    session['dico'] = dico

    # 人员管理
    personne_json = session.get('personne')
    if personne_json is None:
        # 首次请求
        personne = Personne().fromdict({"prénom": "aglaë", "nom": "séléné", "âge": 70})
    else:
        # 后续请求
        personne = Personne().fromjson(personne_json)
        personne.âge += 1
    # 将人员重新放入会话
    session['personne'] = personne.asjson()

    # 结果字典
    résultats = {"liste": liste, "dict": dico, "personne": personne.asdict()}

    # 发送响应jSON
    response = make_response(json.dumps(résultats, ensure_ascii=False))
    response.headers['Content-Type'] = 'application/json; charset=utf-8'
    return response, status.HTTP_200_OK


# 主程序
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 1-3 行:配置 Web 应用程序;
  • 第 5-11 行:导入依赖项;
  • 第 14 行:实例化 Flask 应用程序;
  • 第17行:初始化[secret_key]属性。这使得会话功能得以使用;
  • 第 21 行:应用程序的唯一路由;
  • 第 23-33 行:管理会话中的列表。该列表中默认包含可通过 jSON 序列化的元素;
  • 第 35-46 行:管理会话中的字典。其中默认包含可序列化为 jSON 的元素;
  • 第 48-58 行:管理一个人员。默认情况下,[Personne] 对象无法序列化为 jSON。因此需要采取预防措施;
  • 第 58 行:使用 [BaseEntity.asjson] 方法将该人员的字符串 jSON 存储在会话中。 请注意,本可以使用 [personne.asdict],因为 [personne.asdict] 是一个字典,其中包含默认可序列化为 jSON 的值;
  • 第 55 行:由于已在会话中存储了字符串 jSON,因此使用方法 [BaseEntity.fromjson] 从会话中检索该用户;
  • 第 61 行:创建字典 [résultats],该字典将作为响应发送给客户端。 我们知道,在此情况下,Flask 会发送字典中的字符串 jSON。因此,该字典必须仅包含默认可序列化为 jSON 的值;
  • 第 64 行:我们将字典 [résultats] 中的字符串 jSON 显式放入响应 HTTP 中。Flask 默认也会这样做。 但同样默认情况下,它会使用参数 [ensure_ascii=True],这不符合我们的需求;
  • 第 65 行:告知客户端将收到 jSON;
  • 第 66 行:向其发送响应;

启动Web应用。清除Postman客户端的所有Cookie。随后客户端请求URL [http://localhost:5000]。Postman控制台中的客户端/服务器对话如下:


GET / HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 5f8b7c63-aa8a-4429-a2fa-62141423d933
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

HTTP/1.0 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 135
Vary: Cookie
Set-Cookie: session=.eJw9isEKwyAQRH-lzHkPm15K91dqD2mzBMFq0AgF8d-jsRQG9u3MK1jsO0AKFs1fyMSEPQabOjbOHsKV4GzaFfJgmnr4Sdg0puB9a1EMtmgys959-BjIxWBe3XxWLwNq_39IQ3Q_f5zhnHxdtYs3rqgH4gQvMg.Xw6yGw.Bwpt3q-sH03gFLmg2FIPXV_ZNt8; HttpOnly; Path=/
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Wed, 15 Jul 2020 07:36:59 GMT

{"liste": [0, 10, 100], "dict": {"un": 0, "deux": 10, "trois": 100}, "personne": {"prénom": "aglaë", "nom": "séléné", "âge": 70}}

我们再次发送请求:


GET / HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 40fd00ea-d45c-46b7-a51e-d4d433a37b5c
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: session=.eJw9isEKwyAQRH-lzHkPm15K91dqD2mzBMFq0AgF8d-jsRQG9u3MK1jsO0AKFs1fyMSEPQabOjbOHsKV4GzaFfJgmnr4Sdg0puB9a1EMtmgys959-BjIxWBe3XxWLwNq_39IQ3Q_f5zhnHxdtYs3rqgH4gQvMg.Xw6yGw.Bwpt3q-sH03gFLmg2FIPXV_ZNt8

HTTP/1.0 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 135
Vary: Cookie
Set-Cookie: session=.eJw9isEKwyAQRH-lzHkP2kupv9LtIW2WIBgNGqEg_nu3seQ0b2Zew-zfCa5hlvqBs5aw5-SLolGuUaETgi-7wD0sqaHPk7BJLilGXdEYW-ZqjNxjWhnuwpiWMB3Ti0Haz6MMMfz9EcM5-LrIT7zZjv4F5NYvOQ.Xw6ydQ.PMWRCqKx9HNnb_DyK-ha-9pCF7M; HttpOnly; Path=/
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Wed, 15 Jul 2020 07:38:29 GMT

{"liste": [1, 11, 101], "dict": {"deux": 11, "trois": 101, "un": 1}, "personne": {"prénom": "aglaë", "nom": "séléné", "âge": 71}}
  • 第 9 行:客户端返回其收到的会话 Cookie;
  • 第15行:由于会话内容已发生变化(第19行),服务器向客户端返回了另一个cookie。需要说明的是,该内容以加密形式存储在会话cookie中;

22.7. 脚本 [flask/06]:所有用户共享的信息

22.7.1. 简介

本节旨在说明如何管理应用范围内的信息,即所有用户共享的信息。此类信息通常为应用程序的配置信息。我们已了解到,Web 应用程序可以维护不同类型的内存:

Image

本文重点探讨应用程序 [3] 的内存。

22.7.2. 脚本 [application_scope_01]

Image

脚本 [application_scope_01] 展示了一种管理“应用程序”作用域数据的方法:


# 配置应用程序
import config
config = config.configure()

# 依赖项
from flask import Flask, make_response
from flask_api import status

# Flask 应用程序
app = Flask(__name__)


# 首页 URL
@app.route('/', methods=['GET'])
def index():
    # 旨在展示应用程序在不同客户端的请求之间仍保留在内存中
    # 每个客户端都与同一个应用程序交互

    # app_infos 代表应用程序层面的信息,而非会话层面的信息
    # 即该信息涉及所有用户,而非特定某位用户
    # 该信息在此存储于 [config] 中(非强制)

    # 结果字典
    résultats = {"config": config}

    # 发送响应
    response = make_response(résultats)
    response.headers['Content-Type'] = 'application/json; charset=utf-8'
    return response, status.HTTP_200_OK


# 主函数
if __name__ == '__main__':
    # 检查此代码是否被多次执行
    print("application app lancée")
    # 启动Web应用程序
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 1-3 行:从配置中获取字典。我们将证明位于路由函数之外的代码仅执行一次。Flask 应用程序驻留在内存中。所有在路由之外初始化的信息对路由而言是全局的,因此路由可以访问这些信息。 因此,第3行的字典[config]将由路由/(第24行)渲染。我们将证明所有Web客户端都会收到相同的字典,因此该字典被所有客户端共享。这属于“应用程序”作用域的信息;
  • 第35行:添加日志以检查路由函数外的代码(第1-10行、32-38行)是否被多次执行;

配置 [config] 如下:


def configure():
    # 加载配置
    config = {
        # Flask 配置
        "SECRET_KEY""vibnFfrdWYUp?*LQ"
    }

    return config

我们启动了该应用程序。PyCharm控制台中的日志如下:

Image

  • [1] 中,应用程序的初始启动;
  • [2] 时,由于请求了 [Debug] 模式,应用程序以 [Debug] 模式重新启动;

现在使用浏览器(下文为Chrome),请求 URL [http://127.0.0.1:5000/]:

Image

现在使用 Firefox 浏览器:

Image

现在使用 Postman 客户端:

GET / HTTP/1.1
User-Agent: PostmanRuntime/7.26.1
Accept: */*
Cache-Control: no-cache
Postman-Token: 51e75099-8ecb-4f27-ae3b-9386e982ede4
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

HTTP/1.0 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 39
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Wed, 15 Jul 2020 10:34:26 GMT

{
"SECRET_KEY": "vibnFfrdWYUp?*LQ"
}

现在回到 PyCharm 的控制台:[Run]

Image

  • 这两个日志 [1, 2] 依然存在,但没有其他日志,尽管我们可以看到 Web 服务器接收了三个请求;

为了完全确保应用程序不会在每次新请求时重新加载,我们可以在配置中添加一个计数器,并在每次新请求时将其递增。这样我们就会发现,每个客户端看到的计数器状态都是前一个客户端留下的。 但需提醒的是,客户端不应修改应用程序级别的数据,因为这些数据在所有客户端之间共享。在服务器同时服务多个客户端且无法保证单个客户端的请求能完全无中断执行的情况下,如果客户端1发出的请求1在结束前被中断,可能会导致共享数据处于损坏状态,从而影响后续客户端。

22.7.3. 脚本 [application_scope_02]

Image

脚本 [application_scope_02] 将做一件不该做的事:允许客户端修改与其他用户共享的信息。我们将让用户之间共享一个计数器,并由用户对其进行递增。我们将看到,每个用户都能看到其他用户对计数器所做的修改。

脚本如下:


# 依赖项

from flask import Flask, make_response
from flask_api import status

# Flask 应用程序
app = Flask(__name__)

# 应用程序作用域数据
config = {
    "counter": 0
}


# 首页 URL
@app.route('/', methods=['GET'])
def index():
    # 旨在展示字典 [config] 在所有客户端之间共享
    # Web 应用程序

    # 递增计数器
    config["counter"] += 1
    # 发送响应
    response = make_response(config)
    response.headers['Content-Type'] = 'application/json; charset=utf-8'
    return response, status.HTTP_200_OK


# 主程序
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 10-12 行:用户共享的字典 [config]。其中包含一个计数器;
  • 第22行:每当用户请求URL /时,配置中的计数器就会递增;
  • 第23-26行:字典中的字符串jSON会被发送给每个客户端;

运行此脚本。然后使用第一个浏览器请求 URL [http://127.0.0.1:5000/]:

Image

接着使用第二个浏览器执行相同操作:

Image

最后使用 Postman 进行第三次请求:

Image

可以看到,每个客户端获取到的计数器状态都与前一个客户端留下的状态一致。因此,它们确实访问到了相同的信息。

22.7.4. 脚本 [application_scope_03]

脚本 [application_scope_03] 展示了为何用户间共享的信息必须设置为只读。

Image

脚本内容如下:


# 依赖项
import threading
from time import sleep

from flask import Flask, make_response
from flask_api import status

# Flask 应用程序
app = Flask(__name__)

# 应用程序作用域数据
config = {
    "counter": 0
}


# 首页 URL
@app.route('/', methods=['GET'])
def index():
    # 旨在说明字典 [config] 在所有客户端之间共享
    # 且该词典必须为只读

    # 线程名称
    thread_name = threading.current_thread().name
    # 读取计数器
    counter = config["counter"]
    print(f"compteur lu : {counter}, par le thread {thread_name}")
    # 暂停 5 秒——这样其他客户端就能得到服务
    sleep(5)
    # 递增配置计数器
    config["counter"] = counter + 1
    # 日志
    print(f"compteur écrit : {config['counter']}, par le thread {thread_name}")
    # 发送响应
    response = make_response(config)
    response.headers['Content-Type'] = 'application/json; charset=utf-8'
    return response, status.HTTP_200_OK


# 主
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run(threaded=True)
  • 第 43 行:我们更改了 Web 应用程序的执行模式。我们编写了 [threaded=True] 来指示应用程序应同时为用户提供服务。这是通过执行线程实现的:
    • 可能存在多个并行运行的线程,每个线程服务于一名用户;
    • 这些线程共享机器的处理器;
    • 一个线程可能在完成工作之前被中断。它将在稍后被恢复;
  • 第 19 行:函数 [index] 可由多个线程同时执行;
  • 第 24 行:获取正在执行函数 [index] 的线程名称;
  • 第26行:读取计数器的值。为了演示需要,我们将计数器的递增过程分解如下:
    • 步骤 1:线程 1 读取计数器(例如 1);
    • 步骤 2:线程 1 暂停 5 秒(第 29 行)。由于线程 1 请求了暂停,处理器被分配给另一个线程,即线程 2。目的是让这个新线程读取相同的计数器值(=1)。随后它也暂停 5 秒并失去处理器;
    • 步骤3:基于步骤1读取的值(=1)对计数器进行递增(第31行)。线程1最先执行此操作:它将计数器更新为2,随后结束函数[index]的执行。 随后线程2也醒来,并同样根据步骤1读取的值(=1)将计数器更新为2。最终,两个线程执行完毕后,计数器显示为2,而本应为3;
  • 第33行:显示计数器值以供验证;

我们运行脚本,随后使用两个浏览器以及Postman访问URL [http://loaclhost :5000/]。此时PyCharm控制台中的日志如下:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/flask/06/application_scope_03.py
 * Serving Flask app "application_scope_03" (lazy loading)
 * Environment: development
 * Debug mode: on
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 334-263-283
* Running on http://127.0.0.1:5000/ (按 CTRL+C 退出)
compteur lu : 0, par le thread Thread-2
compteur lu : 0, par le thread Thread-4
compteur écrit : 1, par le thread Thread-2
127.0.0.1 - - [16/Jul/2020 08:55:37] "GET / HTTP/1.1" 200 -
compteur écrit : 1, par le thread Thread-4
127.0.0.1 - - [16/Jul/2020 08:55:40] "GET / HTTP/1.1" 200 -
compteur lu : 1, par le thread Thread-5
compteur écrit : 2, par le thread Thread-5
127.0.0.1 - - [16/Jul/2020 08:55:46] "GET / HTTP/1.1" 200 -
  • 第9-10行:前两个线程2和4读取了计数器的相同值0;
  • 第 11 行:线程 2 将计数器值设为 1;
  • 第 13 行:线程 4 将计数器值设为 1。从此时起,计数器值即不正确;
  • 第15-16行:线程5未被中断,并正确处理了计数器的值;

从这个例子中可以看出,Web 应用程序的代码不应修改用户共享的信息值。

22.8. 脚本 [flask/07]:路线管理

Image

本文关注的是应用程序的路由管理,即由 Web 应用程序提供的 URL。

22.8.1. 脚本 [main_01]:配置路由

[main_01]脚本引入了路由参数化功能:


from flask import Flask, make_response
from flask_api import status

# Flask 应用程序
app = Flask(__name__)


# 发送响应
def send_plain_response(réponse: str):
    # 正在发送响应
    response = make_response(réponse)
    response.headers['Content-Type'] = 'text/plain; charset=utf-8'
    return response, status.HTTP_200_OK


# /姓/名
@app.route('/<string:nom>/<string:prenom>', methods=['GET'])
def index(nom, prenom):
    # 响应
    return send_plain_response(f"{prenom} {nom}")


# 初始化会话
@app.route('/init-session/<string:type>', methods=['GET'])
def init_session(type: str):
    # 响应
    return send_plain_response(f"/init-session/{type}")


# 用户认证
@app.route('/authentifier-utilisateur', methods=['POST'])
def authentifier_utilisateur():
    # 响应
    return send_plain_response("/authentifier-utilisateur")


# 计算税款
@app.route('/calculer-impot', methods=['POST'])
def calculer_impot():
    # 响应
    return send_plain_response("/calculer-impot")


# 列出模拟
@app.route('/lister-simulations', methods=['GET'])
def lister_simulations():
    # 回复
    return send_plain_response("/lister-simulations")


# 删除模拟
@app.route('/supprimer-simulation/<int:numero>', methods=['GET'])
def supprimer_simulation(numero: int):
    # 响应
    return send_plain_response(f"/supprimer-simulation/{numero}")


# 结束会话
@app.route('/fin-session', methods=['GET'])
def fin_session():
    # 响应
    return send_plain_response(f"/fin-session")


# 主
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 17 行:指定 URL 参数的类型。这使 Flask 能够进行验证。如果参数类型与预期不符,客户端的请求将被拒绝(400 Bad Request 错误)。因此,Flask 承担了本应由我们完成的部分工作;
  • 第 18 行:对于参数,必须使用第 17 行中参数的精确名称,但不一定需要保持其顺序;
  • 第 20 行:使用函数 [send_plain_response] 将响应发送给 Web 客户端;
  • 第 9 行:函数 [send_plain_response] 接收要发送给客户端的字符串;
  • 第 11 行:构建 HTTP 响应的主体;
  • 第 12 行:告知客户端将发送纯文本;
  • 第 13 行:发送响应 HTTP;
  • 第 23-62 行:其他已配置的路由,将在后续的实践练习中使用;

运行脚本,并使用 Postman 客户端进行请求:

Image

22.8.2. 脚本 [main_02]:路由外部化

在前面的脚本 [main_01] 中,如果路由数量较多,代码可能会变得冗长。脚本 [main_02] 展示了如何将路由外部化。

Image

脚本 [routes_02] 整合了前一个脚本中与路线相关的功能:


from flask import make_response
from flask_api import status


def send_response(réponse: str):
    # 发送响应
    response = make_response(réponse)
    response.headers['Content-Type'] = 'text/plain; charset=utf-8'
    return response, status.HTTP_200_OK


# 首页 URL
def index(nom, prenom):
    # 响应
    return send_response(f"{prenom} {nom}")


# 初始化会话
def init_session(type: str):
    # 响应
    return send_response(f"/init-session/{type}")


# 用户认证
def authentifier_utilisateur():
    # 响应
    return send_response("/authentifier-utilisateur")


# 计算税款
def calculer_impot():
    # 回复
    return send_response("/calculer-impot")


# 列出模拟
def lister_simulations():
    # 回复
    return send_response("/lister-simulations")


# 删除模拟
def supprimer_simulation(numero: int):
    # 响应
    return send_response(f"/supprimer-simulation/{numero}")


# 结束会话
def fin_session():
    # 响应
    return send_response(f"/fin-session")

需要注意的是,脚本 [routes_02] 并非路线脚本,而是一份函数列表。真正负责将路线与函数关联起来的是主脚本 [main_02]


from flask import Flask

# 将路由函数移至各自的脚本中
import routes_02

# Flask 应用程序
app = Flask(__name__)

# 路由与函数的关联
app.add_url_rule('/<string:nom>/<string:prenom>', methods=['GET'], view_func=routes_02.index)
app.add_url_rule('/init-session/<string:type>', methods=['GET'], view_func=routes_02.init_session)
app.add_url_rule('/authentifier-utilisateur', methods=['POST'], view_func=routes_02.authentifier_utilisateur)
app.add_url_rule('/calculer-impot', methods=['POST'], view_func=routes_02.calculer_impot)
app.add_url_rule('/lister-simulations', methods=['GET'], view_func=routes_02.lister_simulations)
app.add_url_rule('/supprimer-simulation/<int:numero>', methods=['GET'], view_func=routes_02.supprimer_simulation)
app.add_url_rule('/fin-session', methods=['GET'], view_func=routes_02.fin_session)

# main
if __name__ == '__main__':
    app.config.update(ENV="development", DEBUG=True)
    app.run()
  • 第 4 行:导入与路由关联的函数脚本;
  • 第 9-16 行:路由与函数的关联;

采用此方法,每个与路由关联的函数在必要时均可作为独立脚本处理。

结果与前面的脚本 [main_01] 所获得的结果相同。