TestDriven-io-博客中文翻译-四-
TestDriven.io 博客中文翻译(四)
使用 Flask、htmx 和 Tailwind CSS 进行快速原型制作
在本教程中,你将学习如何用 htmx 和 Tailwind CSS 设置 Flask。htmx 和 Tailwind 的目标都是简化现代 web 开发,这样您就可以设计和实现交互性,而不会离开 HTML 的舒适和方便。我们还将看看如何使用 Flask-Assets 在 Flask 应用中捆绑和缩小静态资产。
htmx
htmx 是一个库,它允许你直接从 HTML 访问现代浏览器特性,如 AJAX、CSS 转换、WebSockets 和服务器发送的事件,而不是使用 JavaScript。它允许您直接在标记中快速构建用户界面。
htmx 扩展了浏览器已经内置的几个特性,比如发出 HTTP 请求和响应事件。例如,您可以使用 HTML 属性在任何 HTML 元素上发送 GET、POST、PUT、PATCH 或 DELETE 请求,而不仅仅是通过a
和form
元素发出 GET 和 POST 请求:
`<button hx-delete="/user/1">Delete</button>`
您还可以更新页面的某些部分来创建单页应用程序(SPA):
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 RwoJYyx 。
在浏览器的开发工具中打开网络标签。当点击按钮时,一个 XHR 请求被发送到https://v2.jokeapi.dev/joke/Any?format=txt&safe-mode
端点。然后,响应被附加到具有输出的id
的p
元素。
如需更多示例,请查看官方 htmx 文档中的 UI 示例页面。
利弊
优点:
缺点:
- 库成熟度:由于库相当新,文档和示例实现很少。
- 传输数据的大小:通常,SPA 框架(如 React 和 Vue)通过在客户机和服务器之间以 JSON 格式来回传递数据来工作。然后,接收到的数据由客户端呈现。另一方面,htmx 从服务器接收呈现的 HTML,并用响应替换目标元素。呈现格式的 HTML 通常比 JSON 响应更大。
顺风 CSS
Tailwind CSS 是一个“实用优先”的 CSS 框架。它不提供预先构建的组件(像 Bootstrap 和布尔玛这样的框架专门提供这些组件),而是以实用程序类的形式提供构建模块,使人们能够快速、轻松地创建布局和设计。
例如,以下面的 HTML 和 CSS 为例:
`<style> .hello { height: 5px; width: 10px; background: gray; border-width: 1px; border-radius: 3px; padding: 5px; } </style>
<div class="hello">Hello World</div>`
这可以通过顺风实现,如下所示:
`<div class="h-1 w-2 bg-gray-600 border rounded-sm p-1">Hello World</div>`
查看 CSS 顺风转换器将原始 CSS 转换为顺风中的等效实用程序类。比较结果。
利弊
优点:
- 高度可定制:虽然 Tailwind 自带预建类,但是可以使用 tailwind.config.js 文件覆盖它们。
- 优化:你可以配置 Tailwind,通过只加载实际使用的类来优化 CSS 输出。
- 黑暗模式:轻松实现黑暗模式——比如
<div class="bg-white dark:bg-black">
。
缺点:
- 组件 : Tailwind 不提供任何官方预置的组件,比如按钮、卡片、导航条等等。组件必须从头开始创建。有一些社区驱动的组件资源,例如顺风 CSS 组件和顺风工具箱。还有一个强大的组件库,尽管是付费的,由 Tailwind 的开发者开发,名为 Tailwind UI 。
- CSS 是内联的:它将内容和设计结合在一起,增加了页面的大小,使 HTML 变得混乱。
烧瓶-资产
Flask-Assets 是一个扩展,用于管理 Flask 应用程序中的静态资产。通过它,您可以为以下各项创建简单的资产管道:
接下来,让我们看看如何在 Flask 中使用上述工具!
项目设置
首先,为我们的项目创建一个新目录,创建并激活一个新的虚拟环境,并安装 Flask 和 Flask-Assets:
`$ mkdir flask-htmx-tailwind && cd flask-htmx-tailwind
$ python3.10 -m venv venv
$ source venv/bin/activate
(venv)$
(venv)$ pip install Flask==2.1.1 Flask-Assets==2.0`
接下来,让我们安装 pytailwindcss 并下载它的二进制文件:
`(venv)$ pip install pytailwindcss==0.1.4
(venv)$ tailwindcss`
接下来,添加一个 app.py 文件:
`# app.py
from flask import Flask
from flask_assets import Bundle, Environment
app = Flask(__name__)
assets = Environment(app)
css = Bundle("src/main.css", output="dist/main.css")
assets.register("css", css)
css.build()`
在导入了包和环境之后,我们创建了一个新的Environment
,并通过一个Bundle
向它注册了我们的 CSS 资产。
我们创建的包接受 src/main.css 作为输入,当我们运行顺风 CSS CLI 时,它将被处理并输出到 dist/main.css 。
由于默认情况下,所有 Flask 静态文件都位于“static”文件夹中,因此上述“src”和“dist”文件夹位于“static”文件夹中。
有了这个,让我们设置顺风。
首先创建一个顺风配置文件:
该命令在项目的根目录下创建了一个 tailwind.config.js 文件。所有与顺风相关的定制都放在这个文件中。
更新 tailwind.config.js 这样:
`module.exports = { content: [ './templates/**/*.html', ], theme: { extend: {}, }, plugins: [], }`
记下部分的内容。在这里,您可以配置项目 HTML 模板的路径。顺风 CSS 将扫描你的模板,搜索顺风类名。生成的输出 CSS 文件将只包含模板文件中相关类名的 CSS。这有助于保持生成的 CSS 文件较小,因为它们将只包含实际使用的样式。
将以下内容添加到 static/src/main.css 中:
`/* static/src/main.css */ @tailwind base; @tailwind components; @tailwind utilities;`
这里,我们定义了来自 Tailwind CSS 的所有base
、components
和utilities
类。
你现在有烧瓶资产和顺风连线。接下来,我们将看看如何提供一个index.html文件来看看 CSS 的作用。
简单的例子
向 app.py 添加运行 Flask development server 的路径和主程序块,如下所示:
`# app.py
from flask import Flask, render_template
from flask_assets import Bundle, Environment
app = Flask(__name__)
assets = Environment(app)
css = Bundle("src/main.css", output="dist/main.css")
assets.register("css", css)
css.build()
@app.route("/")
def homepage():
return render_template("index.html")
if __name__ == "__main__":
app.run(debug=True)`
创建一个“模板”文件夹。然后,给它添加一个base.html文件:
`<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% assets 'css' %}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{% endassets %}
<title>Flask + htmlx + Tailwind CSS</title>
</head>
<body class="bg-blue-100">
{% block content %}
{% endblock content %}
</body>
</html>`
注意{% assets 'css' %}
块。因为我们在应用程序环境中注册了 CSS 包,所以我们可以使用注册的名称css
来访问它,{{ ASSET_URL }}
将自动使用路径。
此外,我们通过bg-blue-100
给 HTML 主体添加了一些颜色,将背景颜色改为浅蓝色。
添加index.html文件:
`<!-- templates/index.html -->
{% extends "base.html" %}
{% block content %}
<h1>Hello World</h1>
{% endblock content %}`
现在,在项目的根目录下运行以下命令,扫描模板中的类并生成一个 CSS 文件:
`(venv)$ tailwindcss -i ./static/src/main.css -o ./static/dist/main.css --minify`
您应该会在“static”文件夹中看到一个名为“dist”的新目录。
注意生成的 static/dist/main.css 文件。
通过python app.py
启动开发服务器,并在浏览器中导航到 http://localhost:5000 以查看结果。
在配置了 Tailwind 之后,让我们将 htmx 添加到组合中,并构建一个在您键入时显示结果的 live search。
实时搜索示例
与其从 CDN 中获取 htmx 库,不如下载它并使用 Flask-Assets 进行捆绑。
从https://unpkg.com/【邮件保护】 /dist/htmx.js 下载库,保存到“static/src”。
现在,要为我们的 JavaScript 文件创建一个新的包,更新 app.py 如下:
`# app.py
from flask import Flask, render_template
from flask_assets import Bundle, Environment
app = Flask(__name__)
assets = Environment(app)
css = Bundle("src/main.css", output="dist/main.css")
js = Bundle("src/*.js", output="dist/main.js") # new
assets.register("css", css)
assets.register("js", js) # new
css.build()
js.build() # new
@app.route("/")
def homepage():
return render_template("index.html")
if __name__ == "__main__":
app.run(debug=True)`
这里,我们创建了一个名为js
的新包,它输出到 static/dist/main.js 。由于我们在这里没有使用任何过滤器,源文件和目标文件将是相同的。
接下来,将新资产添加到我们的base.html文件中:
`<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% assets 'css' %}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{% endassets %}
<!-- new -->
{% assets 'js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
<title>Flask + htmlx + Tailwind CSS</title>
</head>
<body class="bg-blue-100">
{% block content %}
{% endblock content %}
</body>
</html>`
为了让我们有一些数据可以处理,将https://github . com/testdrivenio/flask-htmx-tailwind/blob/master/todo . py保存到一个名为 todo.py 的新文件中。
我们将添加基于每个待办事项标题的搜索功能。
更新index.html文件是这样的:
`<!-- templates/index.html -->
{% extends 'base.html' %}
{% block content %}
<div class="w-small w-2/3 mx-auto py-10 text-gray-600">
<input
type="text"
name="search"
hx-post="/search"
hx-trigger="keyup changed delay:250ms"
hx-indicator=".htmx-indicator"
hx-target="#todo-results"
placeholder="Search"
class="bg-white h-10 px-5 pr-10 rounded-full text-2xl focus:outline-none"
>
<span class="htmx-indicator">Searching...</span>
</div>
<table class="border-collapse w-small w-2/3 mx-auto">
<thead>
<tr>
<th class="p-3 font-bold uppercase bg-gray-200 text-gray-600 border border-gray-300 hidden lg:table-cell">#</th>
<th class="p-3 font-bold uppercase bg-gray-200 text-gray-600 border border-gray-300 hidden lg:table-cell">Title</th>
<th class="p-3 font-bold uppercase bg-gray-200 text-gray-600 border border-gray-300 hidden lg:table-cell">Completed</th>
</tr>
</thead>
<tbody id="todo-results">
{% include 'todo.html' %}
</tbody>
</table>
{% endblock content %}`
让我们花点时间来看看从 htmx 定义的属性:
`<input
type="text"
name="search"
hx-post="/search"
hx-trigger="keyup changed delay:250ms"
hx-indicator=".htmx-indicator"
hx-target="#todo-results"
placeholder="Search"
class="bg-white h-10 px-5 pr-10 rounded-full text-2xl focus:outline-none"
>`
- 输入向
/search
端点发送一个 POST 请求。 - 该请求通过延迟 250 毫秒的击键事件触发。因此,如果在上一次按键之后 250 毫秒之前输入了新的按键事件,则不会触发请求。
- 来自请求的 HTML 响应显示在
#todo-results
元素中。 - 我们还有一个指示器,它是一个加载元素,在发送请求后出现,在响应返回后消失。
添加模板/todo.html 文件:
`<!-- templates/todo.html -->
{% if todos|length>0 %}
{% for todo in todos %}
<tr class="bg-white lg:hover:bg-gray-100 flex lg:table-row flex-row lg:flex-row flex-wrap lg:flex-no-wrap mb-10 lg:mb-0">
<td class="w-full lg:w-auto p-3 text-gray-800 text-center border border-b block lg:table-cell relative lg:static">{{todo.id}}</td>
<td class="w-full lg:w-auto p-3 text-gray-800 text-center border border-b block lg:table-cell relative lg:static">{{todo.title}}</td>
<td class="w-full lg:w-auto p-3 text-gray-800 text-center border border-b block lg:table-cell relative lg:static">
{% if todo.completed %}
<span class="rounded bg-green-400 py-1 px-3 text-xs font-bold">Yes</span>
{% else %}
<span class="rounded bg-red-400 py-1 px-3 text-xs font-bold">No</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}`
该文件呈现了与我们的搜索查询相匹配的待办事项。
最后,将路由处理程序添加到 app.py :
`@app.route("/search", methods=["POST"])
def search_todo():
search_term = request.form.get("search")
if not len(search_term):
return render_template("todo.html", todos=[])
res_todos = []
for todo in todos:
if search_term in todo["title"]:
res_todos.append(todo)
return render_template("todo.html", todos=res_todos)`
/search
端点搜索 todos,并呈现带有所有结果的todo.html模板。
更新顶部的导入:
`from flask import Flask, render_template, request
from flask_assets import Bundle, Environment
from todo import todos`
接下来,更新输出 CSS 文件:
`(venv)$ tailwindcss -i ./static/src/main.css -o ./static/dist/main.css --minify`
使用python app.py
运行应用程序,并再次导航到 http://localhost:5000 进行测试:
结论
在本教程中,我们学习了如何:
- 设置 Flask-Assets、htmx 和 Tailwind CSS
- 使用 Flask、Tailwind CSS 和 htmx 构建一个实时搜索应用程序
htmx 可以在不重新加载页面的情况下呈现元素。最重要的是,您无需编写任何 JavaScript 就可以实现这一点。虽然这减少了客户端所需的工作量,但从服务器发送的数据可能会更多,因为它发送的是渲染的 HTML。
像这样提供部分 HTML 模板在 21 世纪初很流行。htmx 为这种方法提供了一种现代的变形。总的来说,由于 React 和 Vue 等框架的复杂性,提供部分模板再次变得流行起来。您还可以将 WebSockets 添加到组合中,以交付实时更改。著名的 Phoenix LiveView 也使用了同样的方法。你可以在阅读更多关于 HTML over WebSockets 的内容,Web 软件的未来是 HTML-Over Web sockets和 HTML Over WebSockets 。
图书馆还年轻,但未来看起来很光明。
Tailwind 是一个强大的 CSS 框架,专注于开发人员的生产力。虽然这个教程没有涉及到,但是 Tailwind 是高度可定制的。查看以下资源了解更多信息:
使用 Flask 时,一定要将 htmx 和 Tailwind 与 Flask-Assets 结合使用,以简化静态资产管理。
寻找挑战?
完整的代码可以在 flask-htmx-tailwind 存储库中找到。
使用 Pytest 测试烧瓶应用
这篇文章是用 pytest 测试烧瓶应用的指南。
我们将首先看看为什么测试对于创建可维护的软件很重要,以及在测试时应该关注什么。然后,我们将详细介绍如何:
- 使用 pytest 创建并运行烧瓶特定的单元和功能测试
- 利用夹具来初始化测试功能的状态
- 使用 coverage.py 检查测试的覆盖率
本文中测试的 Flask 应用程序的源代码(以及详细的安装说明)可以在 GitLab 上的https://git lab . com/patkennedy 79/Flask _ user _ management _ example中找到。
目标
完成本文后,您将能够:
- 解释在 Flask 应用程序中测试什么
- 描述 pytest 和 unittest 之间的区别
- 用 pytest 编写烧瓶特定的单元和功能测试函数
- 使用 pytest 运行测试
- 创建用于初始化测试功能状态的夹具
- 用 coverage.py 确定测试的代码覆盖率
为什么要写测试?
总的来说,测试有助于确保你的应用程序能像最终用户期望的那样工作。
具有高测试覆盖率的软件项目从来都不是完美的,但是它是软件质量的一个很好的初始指示器。此外,可测试的代码通常是一个好的软件架构的标志,这就是高级开发人员在整个开发生命周期中考虑测试的原因。
测试可以分为三个层次:
- 单位
- 功能(或集成)
- 端到端
单元测试测试独立于依赖项的单个代码单元的功能。它们是防止代码库中出现错误和不一致的第一道防线。他们从程序员的角度出发,从内到外进行测试。
功能测试对软件产品的多个组件进行测试,以确保这些组件能够正常工作。通常,这些测试关注用户将要使用的功能。他们从最终用户的角度,由外向内进行测试。
单元测试和功能测试都是测试驱动开发(TDD) 过程的基础部分。
测试提高了代码的可维护性。
可维护性是指对您的代码进行错误修复或增强,或者对将来某个时候需要更新您的代码的其他开发人员进行修复或增强。
测试应该与持续集成 (CI)过程相结合,以确保您的测试不断地被执行,最好是在每次提交到您的存储库时。一套可靠的测试对于在开发过程的早期快速捕捉缺陷至关重要,在最终用户在生产中遇到它们之前。
考什么?
你应该测试什么?
同样,单元测试应该集中在孤立地测试小的代码单元。
例如,在 Flask 应用程序中,您可以使用单元测试来测试:
- 数据库模型(通常在 models.py 中定义)
- 视图函数调用的实用函数
同时,功能测试应该关注视图功能是如何操作的。
例如:
- 名义条件(GET、POST 等。)用于查看功能
- 对于视图函数,可以正确处理无效的 HTTP 方法
- 向视图函数传递了无效数据
关注终端用户将与之交互的测试场景。你的产品的用户体验是最重要的!
pytestvs.unittest
pytest 是 Python 的一个测试框架,用于编写、组织和运行测试用例。在建立了基本的测试结构之后,pytest 使得编写测试变得非常容易,并且为运行测试提供了很大的灵活性。pytest 满足良好测试环境的关键方面:
- 编写测试很有趣
- 通过使用助手函数(fixtures ),可以快速编写测试
- 可以用一个命令来执行测试
- 测试运行迅速
pytest 太不可思议了!我强烈推荐使用它来测试任何用 Python 编写的应用程序或脚本。
如果你真的对学习 pytest 的各个方面感兴趣,我强烈推荐 Brian Okken 写的《用 pytest 进行 Python 测试》。
Python 有一个名为 unittest 的内置测试框架,这也是测试的一个很好的选择。unittest 模块的灵感来自于 xUnit 测试框架。
它提供了以下内容:
- 用于构建单元测试的工具,包括一整套用于执行检查的
assert
语句 - 用于开发单元测试和单元测试套件的结构
- 用于执行测试的测试运行器
pytest 和 unittest 的主要区别是:
特征 | pytest | unittest |
---|---|---|
装置 | 第三方库 | 核心标准库的一部分 |
测试设置和拆卸 | 固定装置 | setUp() 和tearDown() 方法 |
断言格式 | 内置断言 | assert* 风格方法 |
结构 | 功能的 | 面向对象 |
这两种框架都适合测试 Flask 项目。但是,我更喜欢 pytest,因为它:
- 需要更少的样板代码,因此您的测试套件将更具可读性。
- 支持简单的
assert
语句,与 unittest 中的assertSomething
方法——如assertEquals
、assertTrue
和assertContains
——相比,它可读性更好,也更容易记住。 - 更新更频繁,因为它不是 Python 标准库的一部分。
- 简化测试状态的设置和拆除。
- 使用功能方法。
- 支撑夹具。
测试
项目结构
我喜欢将所有的测试用例组织在一个单独的“tests”文件夹中,与应用程序文件在同一层。
此外,我非常喜欢通过将单元测试和功能测试作为单独的子文件夹来区分它们。这种结构为您提供了只运行单元测试(或者只运行功能测试)的灵活性。
下面是“测试”目录的结构示例:
`└── tests
├── conftest.py
├── functional
│ ├── __init__.py
│ ├── test_stocks.py
│ └── test_users.py
└── unit
├── __init__.py
└── test_models.py`
下面是“tests”文件夹如何适应一个典型的带有蓝图的 Flask 项目:
`├── app.py
├── project
│ ├── __init__.py
│ ├── models.py
│ └── ...blueprint folders...
├── requirements.txt
├── tests
│ ├── conftest.py
│ ├── functional
│ │ ├── __init__.py
│ │ ├── test_stocks.py
│ │ └── test_users.py
│ └── unit
│ ├── __init__.py
│ └── test_models.py
└── venv`
单元测试示例
我们要编写的第一个测试是针对project/models . py的单元测试,它包含数据库的 SQLAlchemy 接口。
该测试不访问底层数据库;它只检查 SQLAlchemy 使用的接口类。
由于这个测试是单元测试,所以应该在tests/unit/test _ models . py中实现:
`from project.models import User
def test_new_user():
"""
GIVEN a User model
WHEN a new User is created
THEN check the email, hashed_password, and role fields are defined correctly
"""
user = User('[[email protected]](/cdn-cgi/l/email-protection)', 'FlaskIsAwesome')
assert user.email == '[[email protected]](/cdn-cgi/l/email-protection)'
assert user.hashed_password != 'FlaskIsAwesome'
assert user.role == 'user'`
让我们仔细看看这个测试。
导入之后,我们从测试内容的描述开始:
`"""
GIVEN a User model
WHEN a new User is created
THEN check the email, hashed_password, and role fields are defined correctly
"""`
为什么一个测试函数要包含这么多的注释?
我发现测试是项目中最难维护的方面之一。通常,测试套件的代码(包括注释的级别)远没有被测试代码的质量高。
一个用于描述每个测试功能的公共结构有助于可维护性,它使某人(另一个开发人员,您未来的自己)更容易快速理解每个测试的目的。
通常的做法是使用 GIVEN-WHEN-THEN 结构:
- 假设-测试的初始条件是什么?
- 什么时候发生了什么需要测试?
- 那么,预期的反应是什么?
更多信息,请查看马丁·福勒的文章 GivenWhenThen 和布莱恩·奥肯的书 Python 测试与 pytest 。
接下来,我们进行实际测试:
在用构造函数的有效参数创建一个新的user
之后,检查user
的属性以确保它被正确创建。
功能测试示例
我们要编写的第二个测试是对 项目/食谱/路线. py 的功能测试,它包含了对recipes
蓝图的查看功能。
由于这个测试是一个功能测试,所以应该在tests/functional/test _ recipes . py中实现:
`from project import create_app
def test_home_page():
"""
GIVEN a Flask application configured for testing
WHEN the '/' page is requested (GET)
THEN check that the response is valid
"""
flask_app = create_app('flask_test.cfg')
# Create a test client using the Flask application configured for testing
with flask_app.test_client() as test_client:
response = test_client.get('/')
assert response.status_code == 200
assert b"Welcome to the" in response.data
assert b"Flask User Management Example!" in response.data
assert b"Need an account?" in response.data
assert b"Existing user?" in response.data`
这个项目使用应用程序工厂模式来创建 Flask 应用程序。因此,首先需要导入create_app()
函数:
`from project import create_app`
测试函数test_home_page()
以测试内容的给定时间描述开始。接下来,创建一个 Flask 应用程序(flask_app
):
`flask_app = create_app('flask_test.cfg')`
为了创建合适的测试环境,Flask 提供了一个 test_client 助手。这将创建一个 Flask 应用程序的测试版本,我们用它来对“/”URL 进行 GET 调用。然后,我们检查返回的状态代码是否正常(200)以及响应是否包含以下字符串:
- 欢迎使用 Flask 用户管理示例!
- 需要账户吗?
- 现有用户?
这些检查与我们希望用户在导航到“/”URL 时看到的内容相匹配:
非正常功能测试的一个示例是在访问“/”URL 时使用无效的 HTTP 方法(POST ):
`def test_home_page_post():
"""
GIVEN a Flask application configured for testing
WHEN the '/' page is is posted to (POST)
THEN check that a '405' status code is returned
"""
flask_app = create_app('flask_test.cfg')
# Create a test client using the Flask application configured for testing
with flask_app.test_client() as test_client:
response = test_client.post('/')
assert response.status_code == 405
assert b"Flask User Management Example!" not in response.data`
该测试检查对“/”URL 的 POST 请求是否会导致返回错误代码 405(不允许使用方法)。
花点时间回顾一下这两个功能测试...您是否在这两个测试函数之间看到了一些重复的代码?你是否看到了很多初始化测试函数所需状态的代码?我们可以使用夹具来解决这些问题。
固定装置
为了以可预测和可重复的方式运行测试,fixture 将测试初始化到一个已知的状态。
xUnit
编写和执行测试的经典方法遵循 xUnit 类型的测试框架,其中每个测试运行如下:
SetUp()
- ...运行测试用例...
TearDown()
SetUp()
和TearDown()
方法总是为测试套件中的每个单元测试运行。这种方法导致测试套件中的每个测试都处于相同的初始状态,这没有提供太多的灵活性。
固定装置的优点
测试夹具方法比传统的安装/拆卸方法提供了更大的灵活性。
pytest-flask 通过提供一套用于测试 flask 应用程序的通用夹具来方便测试 Flask 应用程序。这个库在本教程中没有用到,因为我想向展示如何创建支持测试 Flask 应用的夹具。
首先,fixtures 被定义为函数(应该有一个描述性的名称来描述它们的用途)。
第二,可以运行多个 fixtures 来设置测试功能的初始状态。事实上,固定物甚至可以调用其他固定物!因此,您可以将它们组合在一起以创建所需的状态。
最后,设备可以在不同的范围内运行:
function
-每个测试功能运行一次(默认范围)class
-每个测试类运行一次module
-每个模块运行一次(例如,一个测试文件)session
-每个会话运行一次
例如,如果您有一个模块范围的 fixture,那么在模块中的测试函数运行之前,该 fixture 将运行一次(且仅运行一次)。
夹具应该在tests/conftest . py中创建。
单元测试示例
为了帮助测试 项目/models.py 中的User
类,我们可以向tests/conftest . py添加一个 fixture,用于创建一个User
对象进行测试:
`from project.models import User
@pytest.fixture(scope='module')
def new_user():
user = User('[[email protected]](/cdn-cgi/l/email-protection)', 'FlaskIsAwesome')
return user`
@pytest.fixture
装饰器指定这个函数是一个具有module
级作用域的 fixture。换句话说,这个夹具将被称为每个测试模块一个。
这个 fixture,new_user
,使用构造函数的有效参数创建了一个User
的实例。user
然后被传递给测试函数(return user
)。
我们可以通过使用tests/unit/test _ models . py中的new_user
fixture 来简化前面的test_new_user()
测试函数:
`def test_new_user_with_fixture(new_user):
"""
GIVEN a User model
WHEN a new User is created
THEN check the email, hashed_password, authenticated, and role fields are defined correctly
"""
assert new_user.email == '[[email protected]](/cdn-cgi/l/email-protection)'
assert new_user.hashed_password != 'FlaskIsAwesome'
assert new_user.role == 'user'`
通过使用 fixture,测试函数被简化为针对User
对象执行检查的assert
语句。
功能测试示例
固定装置
为了帮助测试 Flask 项目中的所有视图函数,可以在tests/conftest . py中创建一个 fixture:
`from project import create_app
@pytest.fixture(scope='module')
def test_client():
flask_app = create_app('flask_test.cfg')
# Create a test client using the Flask application configured for testing
with flask_app.test_client() as testing_client:
# Establish an application context
with flask_app.app_context():
yield testing_client # this is where the testing happens!`
这个 fixture 使用上下文管理器创建测试客户机:
`with flask_app.test_client() as testing_client:`
接下来,应用程序上下文被推送到堆栈上,供测试函数使用:
`with flask_app.app_context():
yield testing_client # this is where the testing happens!`
要了解 Flask 中应用程序上下文的更多信息,请参考以下博客文章:
yield testing_client
语句意味着执行被传递给测试函数。
使用夹具
我们可以用tests/functional/test _ recipes . py中的test_client
fixture 来简化之前的功能测试:
`def test_home_page_with_fixture(test_client):
"""
GIVEN a Flask application configured for testing
WHEN the '/' page is requested (GET)
THEN check that the response is valid
"""
response = test_client.get('/')
assert response.status_code == 200
assert b"Welcome to the" in response.data
assert b"Flask User Management Example!" in response.data
assert b"Need an account?" in response.data
assert b"Existing user?" in response.data
def test_home_page_post_with_fixture(test_client):
"""
GIVEN a Flask application
WHEN the '/' page is is posted to (POST)
THEN check that a '405' status code is returned
"""
response = test_client.post('/')
assert response.status_code == 405
assert b"Flask User Management Example!" not in response.data`
您是否注意到许多重复的代码都不见了?通过利用test_client
fixture,每个测试函数都被简化为 HTTP 调用(GET 或 POST)和检查响应的断言。
我真的发现使用 fixture 有助于将测试功能集中在实际的测试上,因为测试初始化是在 fixture 中处理的。
运行测试
要运行测试,导航到 Flask 项目的顶层文件夹,并通过 Python 解释器运行 pytest:
`(venv)$ python -m pytest
============================= test session starts ==============================
tests/functional/test_recipes.py .... [ 30%]
tests/functional/test_users.py ..... [ 69%]
tests/unit/test_models.py .... [100%]
============================== 13 passed in 0.46s ==============================`
为什么要通过 Python 解释器运行 pytest?
主要优点是当前目录(例如,Flask 项目的顶层文件夹)被添加到系统路径中。这避免了 pytest 找不到源代码的任何问题。
pytest 将递归地搜索您的项目结构,找到以test_*.py
开头的 Python 文件,然后在这些文件中运行以test_
开头的函数。不需要配置来识别测试文件的位置!
要查看已运行测试的更多详细信息:
`(venv)$ python -m pytest -v
============================= test session starts ==============================
tests/functional/test_recipes.py::test_home_page PASSED [ 7%]
tests/functional/test_recipes.py::test_home_page_post PASSED [ 15%]
tests/functional/test_recipes.py::test_home_page_with_fixture PASSED [ 23%]
tests/functional/test_recipes.py::test_home_page_post_with_fixture PASSED [ 30%]
tests/functional/test_users.py::test_login_page PASSED [ 38%]
tests/functional/test_users.py::test_valid_login_logout PASSED [ 46%]
tests/functional/test_users.py::test_invalid_login PASSED [ 53%]
tests/functional/test_users.py::test_valid_registration PASSED [ 61%]
tests/functional/test_users.py::test_invalid_registration PASSED [ 69%]
tests/unit/test_models.py::test_new_user PASSED [ 76%]
tests/unit/test_models.py::test_new_user_with_fixture PASSED [ 84%]
tests/unit/test_models.py::test_setting_password PASSED [ 92%]
tests/unit/test_models.py::test_user_id PASSED [100%]
============================== 13 passed in 0.62s ==============================`
如果您只想运行特定类型的测试:
python -m pytest tests/unit/
python -m pytest tests/functional/
运行中的装置
为了真正了解什么时候运行test_client()
fixture,pytest 可以提供 fixture 和 test 的调用结构,并带有--setup-show
参数:
`(venv)$ python -m pytest --setup-show tests/functional/test_recipes.py
====================================== test session starts =====================================
tests/functional/test_recipes.py
...
SETUP M test_client
functional/test_recipes.py::test_home_page_with_fixture (fixtures used: test_client).
functional/test_recipes.py::test_home_page_post_with_fixture (fixtures used: test_client).
TEARDOWN M test_client
======================================= 4 passed in 0.18s ======================================`
test_client
fixture 有一个“模块”范围,所以它在tests/functional/test _ recipes . py中的两个 _with_fixture 测试之前执行。
如果您将test_client
夹具的范围更改为“功能”范围:
`@pytest.fixture(scope='function')`
然后test_client
夹具将在两个 _with_fixture 测试之前运行:
`(venv)$ python -m pytest --setup-show tests/functional/test_recipes.py
======================================= test session starts ======================================
tests/functional/test_recipes.py
...
SETUP F test_client
functional/test_recipes.py::test_home_page_with_fixture (fixtures used: test_client).
TEARDOWN F test_client
SETUP F test_client
functional/test_recipes.py::test_home_page_post_with_fixture (fixtures used: test_client).
TEARDOWN F test_client
======================================== 4 passed in 0.21s =======================================`
因为我们希望test_client
fixture 在这个模块中只运行一次,所以将作用域恢复为“module”。
代码覆盖率
在开发测试时,最好能了解实际测试了多少源代码。这个概念被称为代码覆盖率。
我需要非常明确的是,拥有一组覆盖 100%源代码的测试并不意味着代码得到了正确的测试。
这个标准意味着有大量的测试,并且在开发测试上投入了大量的精力。测试的质量仍然需要通过代码检查来检查。
也就是说,另一个极端,这是一个最小集合(或者没有!)的测试,要糟糕得多!
有两个优秀的包可以用来确定代码覆盖率: coverage.py 和 pytest-cov 。
我推荐使用 pytest-cov,因为它与 pytest 无缝集成。它构建在来自 Ned Batchelder 的 coverage.py 之上,这是 Python 代码覆盖率的标准。
在检查代码覆盖率时运行 pytest 需要使用--cov
参数来指示要检查哪个 Python 包(Flask 项目结构中的project
)的覆盖率:
`(venv)$ python -m pytest --cov=project
============================= test session starts ==============================
tests/functional/test_recipes.py .... [ 30%]
tests/functional/test_users.py ..... [ 69%]
tests/unit/test_models.py .... [100%]
---------- coverage: platform darwin, python 3.8.5-final-0 -----------
Name Stmts Miss Cover
-------------------------------------------------
project/__init__.py 27 0 100%
project/models.py 32 2 94%
project/recipes/__init__.py 3 0 100%
project/recipes/routes.py 5 0 100%
project/users/__init__.py 3 0 100%
project/users/forms.py 18 1 94%
project/users/routes.py 50 4 92%
-------------------------------------------------
TOTAL 138 7 95%
============================== 13 passed in 0.86s ==============================`
即使在检查代码覆盖率时,参数仍然可以传递给 pytest:
`(venv)$ python -m pytest --setup-show --cov=project`
结论
这篇文章作为测试烧瓶应用程序的指南,重点关注:
- 为什么您应该编写测试
- 你应该测试什么
- 如何编写单元和功能测试
- 如何使用 pytest 运行测试
- 如何创建夹具来初始化测试功能的状态
如果您有兴趣了解有关 Flask 的更多信息,请查看我关于如何构建、测试和部署 Flask 应用程序的课程:
部署 Flask 应用程序进行渲染
本教程演示了如何在 Render 上将带有 PostgreSQL 数据库的 Flask 应用程序部署到生产环境中。
本教程中使用的技术:
- Flask - Python web 框架
- 用于与关系数据库接口的 Python 包
- PostgreSQL -关系数据库
- Gunicorn - Python WSGI HTTP 服务器
- 渲染 -虚拟主机服务
目标
本教程结束时,您将能够:
- 解释如何将 Flask 应用从部署过渡到生产
- 描述 Flask 应用程序如何在 Render 上运行
- 部署 Flask 应用程序进行渲染
- 配置 Flask 应用程序以在渲染时与 PostgreSQL 数据库通信
为什么渲染?
Render 是一个易于使用的平台即服务 (PaaS)解决方案,非常适合托管 Flask 应用。
此外,他们有一个免费层,允许您轻松测试他们的平台。此外,他们有价格合理的选项来托管应用和数据库。
由于 Heroku 将于 2022 年 11 月 28 日停止它的自由层,我摆弄了一些 Heroku 的替代品,发现 Render 是最好的。Render 的开发者体验很好,设置 web 服务和数据库的配置步骤非常直观。
自由层限制
在渲染时使用自由层服务有一些限制:
- PostgreSQL 数据库的 90 天限制
- 更慢的构建和部署时间
- “Web 服务”没有外壳访问权限
较慢的构建和部署时间是意料之中的,因为您正在与其他用户共享资源。
值得注意的是,付费计划中 web 服务的构建和部署时间很快。
烧瓶生产设置
发展中的烧瓶
在项目的开发阶段,开发服务器通常用于本地运行应用程序:
Flask development 服务器运行时使用:
`$ flask --app app --debug run`
其中--app
指定 Flask app 的文件( app.py ),而--debug
启用调试模式(交互式调试器和代码更改时自动重新加载)。
您可以在选择的浏览器中导航至http://127.0.0.1:5000/
来查看应用程序。
生产中的烧瓶
在开发 Flask 应用程序的某个时候,您会希望将 it 应用程序部署到生产环境中,以便其他人可以访问它。
Flask 开发服务器非常适合在本地提供 Flask 应用程序。顾名思义,“开发”服务器并不是用于生产的。相反,你应该使用 Gunicorn ,一个生产级的 WSGI web 应用服务器。
WSGI 代表 web 服务器网关接口,是 web 服务器和 web 应用程序之间的接口,因为 Web 服务器不能直接与 Python 应用程序对话。更多信息,请查看 WSGI 。
下图说明了如何使用 Render 将 Flask 应用程序部署到生产环境中:
当部署渲染时,一个“ Web 服务将运行 WSGI 服务器(Gunicorn)和 Flask 应用程序。Render 提供 web 服务器,将 HTTP 流量路由到 Gunicorn。此外,“ PostgreSQL 服务将运行 PostgreSQL 数据库,Flask 应用程序将与该数据库进行交互。
先决条件
从本地计算机上运行的 Flask 应用程序转移到部署它进行渲染时,有几个注意事项...
格尼科恩
在您的虚拟环境中,安装 Gunicorn:
`# pip
(venv)$ pip install gunicorn
(venv)$ pip freeze > requirements.txt`
你可以随意把 virtualenv 和 pip 换成诗歌或 Pipenv 。更多信息,请查看现代 Python 环境。
如果您使用 pip,请确保将 Python 包依赖关系保存在一个 requirements.txt 文件中,因为该文件应该在渲染的“构建”步骤中使用。
一种数据库系统
对于小型项目和开发工作来说,SQLite 是一个非常好的数据库。然而,一旦您过渡到生产,您将希望使用生产级的关系数据库,比如 PostgreSQL 。
幸运的是,Flask-SQLAlchemy 使得用 PostgreSQL 替换 SQLite 变得很容易。
首先,与 PostgreSQL 数据库交互需要两个 Python 包:
`(venv)$ pip install psycopg2-binary
(venv)$ pip freeze > requirements.txt`
psycopg2 是 Python 的 PostgreSQL 数据库适配器。
此外,您需要确保您的 Flask 应用程序利用环境变量(如DATABASE_URL
)来确定数据库的 URI:
`class Config(object):
...
# Since SQLAlchemy 1.4.x has removed support for the 'postgres://' URI scheme,
# update the URI to the postgres database to use the supported 'postgresql://' scheme
if os.getenv('DATABASE_URL'):
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL').replace("postgres://", "postgresql://", 1)
else:
SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(BASEDIR, 'instance', 'app.db')}"
...`
要进行的一个关键转换是更新 PostgreSQL 数据库的 URI,以使用受支持的postgresql://
方案,而不是postgres://
URI。
你可以在这里看到完整的例子。
记录
在 Render 上运行 Flask 应用程序时,控制台日志将显示来自 Gunicorn logger 的所有日志消息,但不会显示来自 Flask 应用程序的消息。
但是,Flask 应用程序可以配置为利用 Gunicorn 记录器:
`if app.config['LOG_WITH_GUNICORN']:
gunicorn_error_logger = logging.getLogger('gunicorn.error')
app.logger.handlers.extend(gunicorn_error_logger.handlers)
app.logger.setLevel(logging.DEBUG)
else:
... standard logging configuration ...`
你可以在这里看到完整的例子。
数据库初始化
本节仅适用于在 Render 上使用免费层“Web 服务”的情况。
通常,在首次初始化 Flask 应用程序时,应该创建一个 CLI 命令来初始化数据库:
`@app.cli.command('init_db')
def initialize_database():
"""Initialize the database."""
db.drop_all()
db.create_all()
echo('Initialized the database!')`
但是,Render 上的免费层“Web 服务”不支持访问控制台来运行此 CLI 命令。
因此,这个问题的解决方案是在创建 Flask 应用程序时检查数据库是否需要初始化:
`# Check if the database needs to be initialized
engine = sa.create_engine(app.config['SQLALCHEMY_DATABASE_URI'])
inspector = sa.inspect(engine)
if not inspector.has_table("users"):
with app.app_context():
db.drop_all()
db.create_all()
app.logger.info('Initialized the database!')
else:
app.logger.info('Database already contains the users table.')`
假设应用程序工厂函数用于创建 Flask 应用程序,在 Flask 应用程序被实例化和初始化之后,这段代码可以放在应用程序工厂函数中。
你可以在这里看到完整的例子。
渲染部署步骤
网络服务
首先用 Render 创建一个新账户(如果你没有的话)。然后,导航到您的仪表板,点击“新建+”按钮,并选择“Web 服务”。
将您的渲染帐户连接到 GitLab 或 GitHub 帐户。连接后,选择要部署的存储库:
填写部署 Web 服务的配置:
字段:
- 名称-为应用程序选择一个唯一的名称,因为这将用于 URL
- 根目录 -服务的根目录(默认为顶级目录);所有生成命令都将基于此根目录运行
- 环境-选择“Python 3”
- 地区-选择离你最近的地区
更多字段:
- 分支——从您的 git 存储库中选择要部署的分支,通常是“main”或“master”
- 构建命令——让您的应用程序为生产做好准备的命令,例如安装必要的 Python 包——例如
pip install -r requirements.txt
、poetry build
等。 - 启动命令-使用默认值
gunicorn app:app
或使用gunicorn --workers=2 --log-level=info app:app
指定工作人员数量和日志级别
选择要使用的计划。
接下来,您可以通过环境变量设置要使用的特定 Python 版本。要设置环境变量,请单击“高级”按钮。然后,添加一个名为“PYTHON_VERSION”的环境变量,为您的应用程序指定 PYTHON 版本,例如“3.10.7”。
“PYTHON_VERSION”环境变量必须包括主要版本、次要版本和补丁版本,因此“3.10.7”有效,而“3.10”无效。
最后,点击页面底部的“创建 Web 服务”。
然后你会看到所有来自 requirements.txt 的 Python 包都被安装了:
构建成功并部署后,您将看到类似于以下内容的内容:
您可以点击“Logs”选项卡查看 Gunicorn 是否已启动并运行:
此时,您可以导航到应用程序的主页。请记住,我们仍然需要设置 PostgreSQL!
PostgreSQL 服务
要配置 PostgreSQL,在您的仪表板上,再次点击“New +”按钮并选择“PostgreSQL”。
配置
接下来,填写用于部署 PostgreSQL 数据库的配置:
字段:
- 名称-为数据库输入一个友好的(唯一的)名称,该名称将用于在渲染面板中标识该数据库
- 数据库-输入一个‘数据库名’或留空以随机生成
- 用户——输入一个用户名或留空,以自动生成为
<Name>_user
- 地区-选择离你最近的地区
- PostgreSQL 版本-为您的应用程序选择所需的 PostgreSQL 版本(“14”是默认值)
选择要使用的计划。
空闲层数据库将在 90 天后销毁。记住这一点。这个计划仍然是一个很好的实验选择。
点击页面底部的“创建数据库”。
数据库创建完成后,您将看到“状态”更新为“可用”:
此时,您需要向下滚动到“连接”部分,并复制“内部数据库 URL”:
更新环境变量
现在,您需要将数据库 URL 设置为一个环境变量,以便您的应用程序可以使用它。
在仪表板中,选择您刚刚创建的“Web 服务”,然后单击“环境”选项卡。
您应该看到“PYTHON_VERSION”环境变量,这是我们之前设置的。使用“内部数据库 URL”添加“DATABASE_URL”环境变量。根据您如何配置 Flask 应用程序,您可能需要添加额外的环境变量,如“SECRET_KEY”。
通过单击“事件”选项卡检查部署状态:
一旦应用了所有配置更改并更新了服务,您将看到“部署”是活动的。
你可以在https://flask-user-management-app.onrender.com找到我在本教程中使用的 Flask 应用程序。
结论
本教程提供了使用 Render 将带有 PostgreSQL 数据库的 Flask 应用程序部署到生产环境的演练。
Render 为 Flask 应用程序提供了优秀的托管解决方案。部署应用程序是一种很好的体验,免费层非常适合尝试部署。
如果你有兴趣了解更多关于 Flask 的知识,请查看我的课程,学习如何构建、测试和部署 Flask 应用程序:
带有 Redis 的 Flask 中的服务器端会话
本文通过 Flask-Session 和 Redis 来研究如何在 Flask 中利用服务器端会话。
本文是关于如何在 Flask 中使用会话的两部分系列文章的一部分:
- 客户端:Flask 中的个会话
- 服务器端:带 Redis 的 Flask 中的服务器端会话 ( 本文!)
本文假设您之前有使用 Flask 的经验。如果您有兴趣了解有关 Flask 的更多信息,请查看我关于如何构建、测试和部署 Flask 应用程序的课程:
会议
由于 HTTP 是一个无状态协议,每个请求都不知道以前执行过的任何请求:
虽然这极大地简化了客户端/服务器通信,但是当用户与应用程序本身交互时,web 应用程序通常需要一种方法来存储每个请求之间的数据。
例如,在电子商务网站上,您通常会将用户添加到购物车中的商品存储到数据库中,这样一旦他们完成购物,就可以查看他们的购物车来购买这些商品。不过,这个在数据库中存储项目的工作流只对经过身份验证的用户有效。因此,您需要一种方法在请求之间为未经身份验证的用户存储特定于用户的数据。
这就是会话发挥作用的地方。
一个会话用于在用户与 web 应用程序交互时,跨不同请求存储与用户相关的信息。因此,在上面的例子中,购物车商品将被添加到用户的会话中。
为会话存储的数据应被视为临时数据,因为会话最终会过期。为了永久存储数据,你需要利用数据库。
计算机存储在这里是一个很好的类比:计算机上的临时项目存储在 RAM(随机存取存储器)中,很像会话,而永久项目存储在硬盘上,很像数据库。
存储在会话中的数据示例:
- 用户购物车中的商品
- 无论用户是否登录
- 首选项(语言、货币、明暗模式)
存储在数据库中的数据示例:
- 用户凭据(电子邮件、用户名、哈希密码、电子邮件确认布尔值)
- 用户输入的数据(股票数据、食谱、博客文章)
在 Flask 中,您可以在会话期间存储特定于用户的信息。保存数据以供整个会话使用允许 web 应用程序在多次请求中保持数据的持久性,例如,当用户访问 web 应用程序中的不同页面时。
烧瓶中的会话
web 开发中通常使用两种类型的会话:
- 客户端 -会话存储在客户端的浏览器 cookies 中
- 服务器端 -会话存储在服务器端(通常会创建一个会话标识符并存储在客户端的浏览器 cookies 中)
Flask 使用客户端方法作为会话的内置解决方案。
对客户端会话感到好奇?查看 Flask 文章中的部分。
服务器端会话
服务器端会话将与服务器上的会话相关联的数据存储在特定的数据存储解决方案中。来自 Flask 的每个响应中都包含一个加密签名的 cookie,用于指定会话标识符。这个 cookie 在对 Flask 应用程序的下一个请求中返回,然后用于从服务器端存储加载会话数据。
优点:
- 敏感数据存储在服务器上,而不是 web 浏览器中
- 您可以存储任意多的会话数据,而不必担心 cookie 的大小
- Flask 应用程序可以很容易地终止会话
缺点:
- 难以设置和扩展
- 由于必须管理会话状态,增加了复杂性
烧瓶会议
Flask-Session 是 Flask 的扩展,支持服务器端会话。它支持在服务器端存储会话数据的各种解决方案:
- 雷迪斯
- Memcached
- sqllcemy(SQL 语法)
- MongoDB
在本文中,我们将使用 Redis,这是一种内存中的数据结构存储,因为它的读/写速度很快,并且易于设置。
参考 Flask-Session 文档的配置部分,了解如何配置其他数据存储解决方案。
Flask-Session 使用 Flask 的会话接口,它提供了一种简单的方法来替换 Flask 的内置会话实现,因此您可以像通常使用内置客户端会话实现一样继续使用session
对象。
服务器端会话示例
下面的 app.py 文件说明了如何通过 Flask-Session 在 Flask 中使用服务器端会话:
`from datetime import timedelta
import redis
from flask import Flask, render_template_string, request, session, redirect, url_for
from flask_session import Session
# Create the Flask application
app = Flask(__name__)
# Details on the Secret Key: https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY
# NOTE: The secret key is used to cryptographically-sign the cookies used for storing
# the session identifier.
app.secret_key = 'BAD_SECRET_KEY'
# Configure Redis for storing the session data on the server-side
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_PERMANENT'] = False
app.config['SESSION_USE_SIGNER'] = True
app.config['SESSION_REDIS'] = redis.from_url('redis://localhost:6379')
# Create and initialize the Flask-Session object AFTER `app` has been configured
server_session = Session(app)
@app.route('/set_email', methods=['GET', 'POST'])
def set_email():
if request.method == 'POST':
# Save the form data to the session object
session['email'] = request.form['email_address']
return redirect(url_for('get_email'))
return """
<form method="post">
<label for="email">Enter your email address:</label>
<input type="email" id="email" name="email_address" required />
<button type="submit">Submit</button
</form>
"""
@app.route('/get_email')
def get_email():
return render_template_string("""
{% if session['email'] %}
<h1>Welcome {{ session['email'] }}!</h1>
{% else %}
<h1>Welcome! Please enter your email <a href="{{ url_for('set_email') }}">here.</a></h1>
{% endif %}
""")
@app.route('/delete_email')
def delete_email():
# Clear the email stored in the session object
session.pop('email', default=None)
return '<h1>Session deleted!</h1>'
if __name__ == '__main__':
app.run()`
要运行此示例,首先创建并激活一个新的虚拟环境:
`$ mkdir flask-server-side-sessions
$ cd flask-server-side-sessions
$ python3 -m venv venv
$ source venv/bin/activate`
安装并运行 Redis。
启动和运行 Redis 的最快方法是使用 Docker:
$ docker run --name some-redis -d -p 6379:6379 redis
如果您不是 Docker 用户,请查看以下资源:
安装烧瓶、烧瓶会话和 redis-py :
`(venv)$ pip install Flask Flask-Session redis`
由于我们使用 Redis 作为会话数据存储,redis-py 是必需的。
将上述代码保存到一个 app.py 文件中。然后,启动 Flask 开发服务器:
`(venv)$ export FLASK_APP=app.py
(venv)$ export FLASK_ENV=development
(venv)$ python -m flask run`
现在,使用您最喜欢的网络浏览器导航到http://localhost:5000/get _ email:
配置
创建 Flask 应用程序(app
)后,需要指定密钥:
`# Details on the Secret Key: https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY
# NOTE: The secret key is used to cryptographically-sign the cookies used for storing
# the session identifier.
app.secret_key = 'BAD_SECRET_KEY'`
秘密密钥用于对存储会话标识符的 cookies 进行加密签名。
接下来,需要将 Redis 配置为服务器端会话数据的存储解决方案:
`# Configure Redis for storing the session data on the server-side
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_PERMANENT'] = False
app.config['SESSION_USE_SIGNER'] = True
app.config['SESSION_REDIS'] = redis.from_url('redis://localhost:6379')
# Create and initialize the Flask-Session object AFTER `app` has been configured
server_session = Session(app)`
配置变量:
SESSION_TYPE
-指定使用哪种类型的会话接口SESSION_PERMANENT
-表示是否使用永久会话(默认为True
)SESSION_USE_SIGNER
-指示是否签署会话 cookie 标识符(默认为False
)SESSION_REDIS
-指定 Redis 实例(默认连接到127.0.0.1:6379
)
有关所有可用配置变量的详细信息,请参考 Flask-Session 文档的配置部分。
设置会话数据
在本例中,set_email
视图函数在提交表单时处理电子邮件:
`@app.route('/set_email', methods=['GET', 'POST'])
def set_email():
if request.method == 'POST':
# Save the form data to the session object
session['email'] = request.form['email_address']
return redirect(url_for('get_email'))
return """
<form method="post">
<label for="email">Enter your email address:</label>
<input type="email" id="email" name="email_address" required />
<button type="submit">Submit</button
</form>
"""`
这个视图函数支持 GET 和 POST HTTP 方法。当使用 GET 方法时,会返回一个 HTML 表单供您输入电子邮件地址:
当您使用您的电子邮件地址提交表单时(通过 POST 方法),电子邮件将保存在session
对象中:
`# Save the form data to the session object
session['email'] = request.form['email_address']`
请在http://localhost:5000/set _ email中输入您的电子邮件,然后提交表单。
访问会话数据
当电子邮件没有存储在会话中时,get_email
视图功能利用 Jinja 模板引擎显示存储在session
对象中的电子邮件地址或者到set_email()
视图功能的链接:
`@app.route('/get_email')
def get_email():
return render_template_string("""
{% if session['email'] %}
<h1>Welcome {{ session['email'] }}!</h1>
{% else %}
<h1>Welcome! Please enter your email <a href="{{ url_for('set_email') }}">here.</a></h1>
{% endif %}
""")`
session
对象可以在模板文件中使用!
输入您的电子邮件地址后,当您导航到http://localhost:5000/get _ emailURL 时,您的电子邮件将显示:
删除会话数据
存储在session
对象中的电子邮件地址可以通过delete_email
查看功能删除:
`@app.route('/delete_email')
def delete_email():
# Clear the email stored in the session object
session.pop('email', default=None)
return '<h1>Session deleted!</h1>'`
这个视图函数从session
对象中获取email
元素。pop
方法将返回弹出的值,因此在session
对象中没有定义元素的情况下,提供默认值是一个好的做法。
当您导航到“http://localhost:5000/delete _ emailURL”时,您将看到:
由于电子邮件地址不再存储在session
对象中,当您导航到http://localhost:5000/get _ emailURL 时,将再次要求您输入您的电子邮件地址:
会话唯一性
为了演示每个用户的会话数据是如何不同的,请在http://localhost:5000/set _ email再次输入您的电子邮件地址。然后,在不同的浏览器(或当前浏览器中的私人/匿名窗口)中,导航至http://localhost:5000/set _ email并输入不同的电子邮件地址。在你被重定向到http://localhost:5000/get _ email之后,你希望看到什么?
由于使用了不同的网络浏览器,这被认为是 Flask 应用程序的不同用户。因此,将有一个唯一的session
用于该用户。
要更详细地了解这一点,您可以从计算机上的两个不同的 web 浏览器访问 Flask 应用程序后,检查 Redis 数据库中存储的内容:
`$ redis-cli
127.0.0.1:6379> KEYS *
1) "session:8a77d85b-7ed9-4961-958a-510240bcbac4"
2) "session:5ce4b8e2-a2b5-43e4-a0f9-7fa465b7bb0c"
127.0.0.1:6379> exit
$`
Redis 中存储了两个不同的会话,对应于用于访问 Flask 应用程序的两个不同的 web 浏览器:
8a77d85b-7ed9-4961-958a-510240bcbac4
来自火狐session:5ce4b8e2-a2b5-43e4-a0f9-7fa465b7bb0c
来自 Chrome
结论
本文展示了如何使用 Flask-Session 和 Redis 在 Flask 中实现服务器端会话。
如果你想了解更多关于 Flask 中的会话,一定要看看我的课程- 用 Python 和 Flask 开发 Web 应用。
烧瓶中的会话
这篇文章介绍了会话在 Flask 中是如何工作的。
本文是关于如何在 Flask 中使用会话的两部分系列文章的一部分:
- 客户端:烧瓶中的会话 ( 本文!)
- 服务器端:带有 Redis 的 Flask 中的服务器端会话
本文假设您之前有使用 Flask 的经验。如果您有兴趣了解有关 Flask 的更多信息,请查看我关于如何构建、测试和部署 Flask 应用程序的课程:
会议
由于 HTTP 是一个无状态协议,每个请求都不知道以前执行过的任何请求:
虽然这极大地简化了客户端/服务器通信,但是当用户与应用程序本身交互时,web 应用程序通常需要一种方法来存储每个请求之间的数据。
例如,在电子商务网站上,您通常会将用户添加到购物车中的商品存储到数据库中,这样一旦他们完成购物,就可以查看他们的购物车来购买这些商品。不过,这个在数据库中存储项目的工作流只对经过身份验证的用户有效。因此,您需要一种方法在请求之间为未经身份验证的用户存储特定于用户的数据。
这就是会话发挥作用的地方。
一个会话用于在用户与 web 应用程序交互时,跨不同请求存储与用户相关的信息。因此,在上面的例子中,购物车商品将被添加到用户的会话中。
为会话存储的数据应被视为临时数据,因为会话最终会过期。为了永久存储数据,你需要利用数据库。
计算机存储在这里是一个很好的类比:计算机上的临时项目存储在 RAM(随机存取存储器)中,很像会话,而永久项目存储在硬盘上,很像数据库。
存储在会话中的数据示例:
- 用户购物车中的商品
- 无论用户是否登录
- 首选项(语言、货币、明暗模式)
存储在数据库中的数据示例:
- 用户凭据(电子邮件、用户名、哈希密码、电子邮件确认布尔值)
- 用户输入的数据(股票数据、食谱、博客文章)
在 Flask 中,您可以在会话期间存储特定于用户的信息。保存数据以供整个会话使用允许 web 应用程序在多次请求中保持数据的持久性,例如,当用户访问 web 应用程序中的不同页面时。
烧瓶中的会话
web 开发中通常使用两种类型的会话:
- 客户端 -会话存储在客户端的浏览器 cookies 中
- 服务器端 -会话存储在服务器端(通常会创建一个会话标识符并存储在客户端的浏览器 cookies 中)
Cookies 是网络浏览器存储在你电脑上的小块数据,其初衷是在浏览不同网站时记住状态信息。
Flask 使用客户端方法。
优点:
- 验证和创建会话速度很快(无数据存储)
- 易于扩展(无需跨 web 服务器复制会话数据)
缺点:
- 敏感数据不能存储在会话数据中,因为它存储在 web 浏览器中
- 会话数据受限于 cookie 的大小(通常为 4 KB)
- Flask 应用程序不能立即撤销会话
为了跨多个请求存储数据,Flask 利用加密签名的 cookies(存储在 web 浏览器上)来存储会话数据。这个 cookie 和每个请求一起被发送到服务器端的 Flask 应用程序,并在那里被解码。
因为会话数据存储在加密签名的 cookies 中(不是加密的!),会话不应用于存储任何敏感信息。永远不要在会话数据中包含密码或个人信息。
如果你更喜欢使用服务器端会话,可以查看一下 Flask-Session 包以及 Flask with Redis 文章中的服务器端会话。
Flask 中的会话示例
下面的 app.py 文件说明了会话在 Flask 中的工作方式:
`from flask import Flask, render_template_string, request, session, redirect, url_for
# Create the Flask application
app = Flask(__name__)
# Details on the Secret Key: https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY
# NOTE: The secret key is used to cryptographically-sign the cookies used for storing
# the session data.
app.secret_key = 'BAD_SECRET_KEY'
@app.route('/set_email', methods=['GET', 'POST'])
def set_email():
if request.method == 'POST':
# Save the form data to the session object
session['email'] = request.form['email_address']
return redirect(url_for('get_email'))
return """
<form method="post">
<label for="email">Enter your email address:</label>
<input type="email" id="email" name="email_address" required />
<button type="submit">Submit</button
</form>
"""
@app.route('/get_email')
def get_email():
return render_template_string("""
{% if session['email'] %}
<h1>Welcome {{ session['email'] }}!</h1>
{% else %}
<h1>Welcome! Please enter your email <a href="{{ url_for('set_email') }}">here.</a></h1>
{% endif %}
""")
@app.route('/delete_email')
def delete_email():
# Clear the email stored in the session object
session.pop('email', default=None)
return '<h1>Session deleted!</h1>'
if __name__ == '__main__':
app.run()`
要运行此示例,首先创建并激活一个新的虚拟环境:
`$ mkdir flask-session
$ cd flask-session
$ python3 -m venv venv
$ source venv/bin/activate`
安装烧瓶:
`(venv)$ pip install Flask`
将上述代码保存到一个 app.py 文件中。然后,启动 Flask 开发服务器:
`(venv)$ export FLASK_APP=app.py
(venv)$ export FLASK_ENV=development
(venv)$ python -m flask run`
现在,使用您最喜欢的网络浏览器导航到http://localhost:5000/get _ email:
设置会话数据
在本例中,set_email
视图函数在提交表单时处理电子邮件:
`@app.route('/set_email', methods=['GET', 'POST'])
def set_email():
if request.method == 'POST':
# Save the form data to the session object
session['email'] = request.form['email_address']
return redirect(url_for('get_email'))
return """
<form method="post">
<label for="email">Enter your email address:</label>
<input type="email" id="email" name="email_address" required />
<button type="submit">Submit</button
</form>
"""`
这个视图函数支持 GET 和 POST HTTP 方法。当使用 GET 方法时,会返回一个 HTML 表单供您输入电子邮件地址:
当您使用您的电子邮件地址提交表单时(通过 POST 方法),电子邮件将保存在session
对象中:
`# Save the form data to the session object
session['email'] = request.form['email_address']`
请在http://localhost:5000/set _ email中输入您的电子邮件,然后提交表单。
访问会话数据
当电子邮件没有存储在会话中时,get_email
视图功能利用 Jinja 模板引擎显示存储在session
对象中的电子邮件地址或者到set_email()
视图功能的链接:
`@app.route('/get_email')
def get_email():
return render_template_string("""
{% if session['email'] %}
<h1>Welcome {{ session['email'] }}!</h1>
{% else %}
<h1>Welcome! Please enter your email <a href="{{ url_for('set_email') }}">here.</a></h1>
{% endif %}
""")`
session
对象可以在模板文件中使用!
输入您的电子邮件地址后,当您导航到http://localhost:5000/get _ emailURL 时,您的电子邮件将显示:
删除会话数据
存储在session
对象中的电子邮件地址可以通过delete_email
查看功能删除:
`@app.route('/delete_email')
def delete_email():
# Clear the email stored in the session object
session.pop('email', default=None)
return '<h1>Session deleted!</h1>'`
这个视图函数从session
对象中获取email
元素。pop
方法将返回弹出的值,因此在session
对象中没有定义元素的情况下,提供默认值是一个好的做法。
当您导航到“http://localhost:5000/delete _ emailURL”时,您将看到:
由于电子邮件地址不再存储在session
对象中,当您导航到http://localhost:5000/get _ emailURL 时,将再次要求您输入您的电子邮件地址:
会话唯一性
为了演示每个用户的会话数据是如何不同的,请在http://localhost:5000/set _ email再次输入您的电子邮件地址。然后,在不同的浏览器(或当前浏览器中的私人/匿名窗口)中,导航至http://localhost:5000/set _ email并输入不同的电子邮件地址。在你被重定向到http://localhost:5000/get _ email之后,你希望看到什么?
由于使用了不同的网络浏览器,这被认为是 Flask 应用程序的不同用户。因此,将有一个唯一的session
用于该用户。
附加注释
Cookie 大小
Cookies 是小块数据(通常为 4KB)。
如果在session
对象中存储大量数据时遇到意外问题,请对照 web 浏览器支持的大小,检查您的响应中 cookies 的大小。由于 Flask 序列化了存储在session
对象中的数据,并将其存储在一个 cookie 中,因此可能会出现整个 cookie 未被保存的问题。
检测会话数据的更改
基于底层数据类型( Werkzeug。CallbackDict )的对象,它不会自动检测可变数据类型(列表、字典、集合等)的变化。).示例:
`session['shopping_cart'] = []
...
# Since a mutable data type (list) is being modified, this change
# is not automatically detected by the session object
session['shopping_cart'].append('bike')
# Therefore, mark the session object as modified
session.modified = True`
会话寿命
默认情况下,session
对象保持不动,直到浏览器关闭。但是,如果您想要更改session
对象的生命周期,请在创建 Flask app
之后定义PERMANENT _ SESSION _ LIFETIME配置变量:
`import datetime
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=1)`
当设置session
中的数据时,指定会话应该是永久的(时间将基于PERMANENT_SESSION_LIFETIME
):
`# Save the form data to the session object
session['email'] = request.form['email_address']
session.permanent = True`
结论
本文展示了会话如何在 Flask 中工作,并查看了一个在会话对象中存储用户电子邮件地址的示例。
如果你想了解更多关于 Flask 中的会话,一定要看看我的课程- 用 Python 和 Flask 开发 Web 应用。
向 Flask 添加社会身份验证
在本教程中,我们将看看如何使用 GitHub 和 Twitter 向 Flask 应用程序添加 social auth。
社交认证(也称为社交登录或社交登录)是基于第三方服务对用户进行认证的过程,而不依赖于您自己的认证服务。例如,你在许多网站上看到的“用谷歌登录”按钮就是社交认证的最好例子。Google 对用户进行身份验证,并向应用程序提供一个令牌来管理用户的会话。
使用社交认证有其优势。您不需要为 web 应用程序设置 auth,因为它是由第三方 OAuth 提供者处理的。此外,由于像谷歌、脸书和 GitHub 这样的提供商执行广泛的检查来防止对其服务的未经授权的访问,利用社交认证而不是滚动您自己的认证机制可以提高您的应用程序的安全性。
除了 Flask,我们还将使用 Flask-Dance 来启用社交认证, Flask-Login 用于登录和注销用户并管理会话,以及 Flask-SQLAlchemy 用于与数据库交互以存储用户相关数据。
为什么要使用社交认证?
你为什么想要利用社交认证而不是自己的认证呢?
赞成的意见
- 无需启动您自己的身份认证工作流程。
- 提高安全性。第三方认证提供商,如谷歌、脸书等。,高度重视安全性。使用这样的服务可以提高您自己的应用程序的安全性。
- 您可以从身份验证提供程序自动检索用户名、电子邮件和其他数据。这通过消除这一步骤(手动询问他们)改善了注册体验。
骗局
- 您的应用程序现在依赖于您控制之外的另一个应用程序。如果第三方应用关闭,用户将无法注册或登录。
- 人们往往会忽略身份验证提供者请求的权限。一些应用程序甚至可能访问不需要的数据。
- 在您配置的提供商中没有帐户的用户将无法访问您的应用程序。最好的方法是同时实现这两者——即用户名和密码以及社交认证——并让用户选择。
OAuth
社交认证通常是通过 OAuth 来实现的,OAuth 是一种开放的标准认证协议,由第三方认证提供商来验证用户的身份。
最常见的流程(或授权)是授权码:
- 用户试图使用第三方身份验证提供商的帐户登录您的应用程序。
- 它们被重定向到身份验证提供者进行验证。
- 验证后,它们会通过授权码重定向回你的应用。
- 然后,您需要使用访问令牌的授权代码向 auth provider 发出请求。
- 在提供商验证授权码之后,他们发送回访问令牌。
- 然后用户登录,这样他们就可以访问受保护的资源。
- 然后,可以使用访问令牌从身份验证提供者获取数据。
有关 OAuth 的更多信息,请查看OAuth 2 的介绍。
让我们看一个使用 GitHub 的流程的快速示例:
`"""
Import necessary modules.
- `os` to read env variable
- `requests` to make GET/POST requests
- `parse_qs` to parse the response
"""
import os
import requests
from urllib.parse import parse_qs
"""
Define the GITHUB_ID and GITHUB_SECRET environment variables
along with the endpoints.
"""
CLIENT_ID = os.getenv("GITHUB_ID")
CLIENT_SECRET = os.getenv("GITHUB_SECRET")
AUTHORIZATION_ENDPOINT = f"https://github.com/login/oauth/authorize?response_type=code&client_id={os.getenv('GITHUB_ID')}"
TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token"
USER_ENDPOINT = "https://api.github.com/user"
"""
1\. Log in via the browser using the 'Authorization URL' outputted in the terminal.
(If you're already logged in to GitHub, either log out or test in an incognito/private browser window.)
2\. Once logged in, the page will redirect. Grab the code from the redirect URL.
3\. Paste the code in the terminal.
"""
print(f"Authorization URL: {AUTHORIZATION_ENDPOINT}")
code = input("Enter the code: ")
"""
Using the authorization code, we can request an access token.
"""
# Once we get the code, we sent the code to the access token
# endpoint(along with id and secret). The response contains
# the access_token and we parse is using parse_qs
res = requests.post(
TOKEN_ENDPOINT,
data=dict(
client_id=os.getenv("GITHUB_ID"),
client_secret=os.getenv("GITHUB_SECRET"),
code=code,
),
)
res = parse_qs(res.content.decode("utf-8"))
token = res["access_token"][0]
"""
Finally, we can use the access token to obtain information about the user.
"""
user_data = requests.get(USER_ENDPOINT, headers=dict(Authorization=f"token {token}"))
username = user_data.json()["login"]
print(f"You are {username} on GitHub")`
为了进行测试,将这段代码保存到一个名为 oauth.py 的文件中。请务必查看评论。
接下来,您需要创建一个 OAuth 应用程序,并从 GitHub 获取 OAuth 密钥。
登录你的 GitHub 账户,然后导航到https://github.com/settings/applications/new创建一个新的 OAuth 应用:
`Application name: Testing Flask-Dance
Homepage URL: http://127.0.0.1:5000
Callback URL: http://127.0.0.1:5000/login/github/authorized`
点击“注册申请”。你将被重定向至你的应用。记下客户端 ID 和客户端密码:
如果没有生成客户端密码,请点按“生成新的客户端密码”。
将生成的客户端 ID 和客户端密码设置为环境变量:
`$ export GITHUB_ID=<your-github-id>
$ export GITHUB_SECRET=<your-github-secret>
# for windows machine, use `set` instead of `export``
安装请求库,然后运行脚本:
`$ pip install requests
$ python oauth.py`
您应该看到:
`Authorization URL: https://github.com/login/oauth/authorize?response_type=code&client_id=cde067521efaefe0c927
Enter the code:`
导航到该 URL。授权应用程序。然后,从重定向 URL 获取代码。例如:
`http://127.0.0.1:5000/login/github/authorized?code=5e54f2d755e450a64af3`
将代码添加回终端窗口:
`Authorization URL: https://github.com/login/oauth/authorize?response_type=code&client_id=cde067521efaefe0c927
Enter the code: 5e54f2d755e450a64af3`
您应该会看到您的 GitHub 用户名输出如下:
至此,让我们看看如何向 Flask 应用程序添加社交认证。
烧瓶舞
OAuthLib 是一个流行的、维护良好的 Python 库,它实现了 OAuth。虽然你可以单独使用这个库,但是在本教程中,我们将使用烧瓶舞。Flask-Dance 是一个构建在 OAuthLib 之上的库,专门为 Flask 设计。它有一个简单的 API,可以让你快速添加社交认证到 Flask 应用程序中。它也是为 Flask 设计的 OAuth 库中最受欢迎的。
继续创建新的 Flask 应用程序,激活虚拟环境,并安装所需的依赖项:
`$ mkdir flask-social-auth && cd flask-social-auth
$ python3.11 -m venv .venv
$ source .venv/bin/activate
(.venv)$ pip install Flask==2.2.2 Flask-Dance==6.2.0 python-dotenv==0.21.0`
接下来,创建一个 main.py 文件:
`# main.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/ping")
def ping():
return jsonify(ping="pong")
if __name__ == "__main__":
app.run(debug=True)`
运行服务器:
导航到http://127 . 0 . 0 . 1:5000/ping。您应该看到:
GitHub 提供商
继续将您之前创建的 GitHub 客户端 ID 和客户端密码保存到一个新的。env 文件:
`GITHUB_ID=<YOUR_ID_HERE>
GITHUB_SECRET=<YOUR_SECRET_HERE>
OAUTHLIB_INSECURE_TRANSPORT=1`
注意事项:
- 由于我们安装了 python-dotenv ,Flask 会自动设置来自的环境变量。env 文件。
- OAUTHLIB _ unsecure _ TRANSPORT = 1用于测试目的,因为 OAUTHLIB 默认需要 HTTPS。
Flask-Dance 为每个供应商提供 Flask 蓝图。让我们在 app/oauth.py 中为 GitHub 提供者创建一个:
`# app/oauth.py
import os
from flask_dance.contrib.github import make_github_blueprint
github_blueprint = make_github_blueprint(
client_id=os.getenv("GITHUB_ID"),
client_secret=os.getenv("GITHUB_SECRET"),
)`
在 main.py 中导入并注册蓝图,并连接新路线:
`# main.py
from flask import Flask, jsonify, redirect, url_for
from flask_dance.contrib.github import github
from app.oauth import github_blueprint
app = Flask(__name__)
app.secret_key = "supersecretkey"
app.register_blueprint(github_blueprint, url_prefix="/login")
@app.route("/ping")
def ping():
return jsonify(ping="pong")
@app.route("/github")
def login():
if not github.authorized:
return redirect(url_for("github.login"))
res = github.get("/user")
return f"You are @{res.json()['login']} on GitHub"
if __name__ == "__main__":
app.run(debug=True)`
如果用户还没有登录,/github
路由重定向到 GitHub 进行验证。登录后,它会显示用户名。
通过运行python main.py
启动应用程序,导航到http://127 . 0 . 0 . 1:5000/github,测试应用程序。在 GitHub 上验证后,你会被重定向回来。您应该会看到类似如下的内容:
`You are @mjhea0 on GitHub`
用户管理
接下来,让我们连接用于管理用户会话的 Flask-Login 和用于添加 SQLAlchemy 支持的 Flask-SQLAlchemy ,以便在数据库中存储用户相关数据。
安装依赖项:
`(.venv)$ pip install Flask-Login==0.6.2 Flask-SQLAlchemy==3.0.2 SQLAlchemy-Utils==0.38.3`
模型
创建模型以在名为 app/models.py 的新文件中存储用户和 OAuth 信息:
`# app/models.py
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin, LoginManager
from flask_dance.consumer.storage.sqla import OAuthConsumerMixin
db = SQLAlchemy()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(250), unique=True)
class OAuth(OAuthConsumerMixin, db.Model):
user_id = db.Column(db.Integer, db.ForeignKey(User.id))
user = db.relationship(User)
login_manager = LoginManager()
@login_manager.user_loader
def load_user(user_id):
return User.query.get(user_id)`
注意事项:
- Flask-Dance 的 OAuthConsumerMixin 将自动添加必要的字段来存储 OAuth 信息。
- 来自 Flask-Login 的 LoginManager 将从
user
表中获取用户。
这将创建两个表,user
和flask_dance_oauth
:
`# user table
name type
-------- ------------
id INTEGER
username VARCHAR(250)
# flask_dance_oauth table
name type
---------- -----------
id INTEGER
provider VARCHAR(50)
created_at DATETIME
token TEXT
user_id INTEGER`
GitHub 蓝图
接下来,修改之前创建的 GitHub 蓝图,添加OAuth
表作为存储:
`# app/oauth.py
import os
from flask_login import current_user
from flask_dance.contrib.github import make_github_blueprint
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage
from app.models import OAuth, db
github_blueprint = make_github_blueprint(
client_id=os.getenv("GITHUB_ID"),
client_secret=os.getenv("GITHUB_SECRET"),
storage=SQLAlchemyStorage(
OAuth,
db.session,
user=current_user,
user_required=False,
),
)`
在这里,我们通过了:
storage
为 SQLAlchemy 存储与OAuth
型号db.session
,这是一个sqlalchemy.session
- 用户作为
current_user
从 Flask 登录
端点
接下来,让我们在 main.py - login
、logout
和homepage
中定义适当的端点:
`# main.py
from flask import Flask, jsonify, redirect, render_template, url_for
from flask_dance.contrib.github import github
from flask_login import logout_user, login_required
from app.models import db, login_manager
from app.oauth import github_blueprint
app = Flask(__name__)
app.secret_key = "supersecretkey"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///./users.db"
app.register_blueprint(github_blueprint, url_prefix="/login")
db.init_app(app)
login_manager.init_app(app)
with app.app_context():
db.create_all()
@app.route("/ping")
def ping():
return jsonify(ping="pong")
@app.route("/")
def homepage():
return render_template("index.html")
@app.route("/github")
def login():
if not github.authorized:
return redirect(url_for("github.login"))
res = github.get("/user")
username = res.json()["login"]
return f"You are @{username} on GitHub"
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("homepage"))
if __name__ == "__main__":
app.run(debug=True)`
这里,我们初始化了之前在 models.py 中定义的db
和login_manager
。
homepage
视图呈现了index.html模板,我们稍后将添加该模板。接下来,login
视图使用 GitHub 进行认证,并返回用户名。logout
路径将用户注销。
现在所有的路由都已经设置好了,但是我们还没有让用户登录。为此,我们将使用烧瓶信号。
信号
当某些预定义的事件发生时,信号允许您执行操作。在我们的例子中,当 GitHub 认证成功时,我们将让用户登录。
Signals 需要 Binker 才能工作,所以现在就开始安装吧:
`(.venv)$ pip install blinker==1.5`
向 app/oauth.py 添加新的助手:
`# app/oauth.py
import os
from flask_login import current_user, login_user
from flask_dance.consumer import oauth_authorized
from flask_dance.contrib.github import github, make_github_blueprint
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage
from sqlalchemy.orm.exc import NoResultFound
from app.models import db, OAuth, User
github_blueprint = make_github_blueprint(
client_id=os.getenv("GITHUB_ID"),
client_secret=os.getenv("GITHUB_SECRET"),
storage=SQLAlchemyStorage(
OAuth,
db.session,
user=current_user,
user_required=False,
),
)
@oauth_authorized.connect_via(github_blueprint)
def github_logged_in(blueprint, token):
info = github.get("/user")
if info.ok:
account_info = info.json()
username = account_info["login"]
query = User.query.filter_by(username=username)
try:
user = query.one()
except NoResultFound:
user = User(username=username)
db.session.add(user)
db.session.commit()
login_user(user)`
当用户通过github_blueprint
连接时,github_logged_in
功能被执行。它接受两个参数:蓝图和令牌(来自 GitHub)。然后,我们从提供商那里获取用户名,并执行以下两个操作之一:
- 如果用户名已经出现在表中,我们让用户登录
- 如果没有,我们创建一个新用户,然后让该用户登录
模板
最后,让我们添加模板:
`(.venv)$ mkdir templates && cd templates
(.venv)$ touch _base.html
(.venv)$ touch index.html`
_base.html 模板包含总体布局:
`<!-- templates/_base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link
href="https://cdn.jsdelivr.net/npm/[[email protected]](/cdn-cgi/l/email-protection)/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flask Social Login</title>
</head>
<body style="padding-top: 10%;">
{% block content %} {% endblock content %}
</body>
</html>`
接下来,给index.html添加一个“用 GitHub 登录”按钮:
`<!-- templates/index.html -->
{% extends '_base.html' %}
{% block content %}
<div style="text-align:center;">
{% if current_user.is_authenticated %}
<h1>You are logged in as {{current_user.username}}</h1>
<br><br>
<a href="{{url_for('logout')}}" class="btn btn-danger">Logout</a>
{% else %}
<!-- GitHub button starts here -->
<a href="{{url_for('login')}}" class="btn btn-secondary">
<i class="fa fa-github fa-fw"></i>
<span>Login with GitHub</span>
</a>
<!-- GitHub button ends here -->
{% endif %}
</div>
{% endblock content %}`
完成后,启动应用程序并导航至 http://127.0.0.1:5000 。测试验证流。
项目结构:
`├── .env
├── app
│ ├── __init__.py
│ ├── models.py
│ └── oauth.py
├── main.py
└── templates
├── _base.html
└── index.html`
现在您已经知道了连接新的 OAuth 提供者以及配置 Flask-Login 的步骤,您应该能够相当容易地设置新的提供者。
例如,Twitter 的步骤如下:
- 在 Twitter 上创建 OAuth 应用程序
- 在 app/oauth.py 中配置 Twitter 蓝图
- 在 main.py 中设置重定向到 Twitter 登录的路由
- 在 main.py 中为 Twitter 登录(
@app.route("/twitter")
)创建一个新的端点 - 当用户通过 twitter (
@oauth_authorized.connect_via(twitter_blueprint)
)在 app/oauth.py 中授权时,创建一个新的 Flask 信号来登录 - 更新模板/index.html 模板
自己试试这个。
结论
本教程详细介绍了如何使用 Flask-Dance 向 Flask 应用程序添加社交认证。在配置完 GitHub 和 Twitter 之后,您现在应该对如何连接新的社交认证提供商有了很好的理解:
- 通过创建 OAuth 应用程序来获取每个提供者的令牌
- 建立数据库模型来存储用户和 OAuth 数据
- 为每个提供者创建蓝图,并将创建的 OAuth 模型添加为存储
- 添加向提供商验证的路由
- 添加一个信号,以便用户在通过身份验证后登录
寻找额外的挑战?
- 找出如何将多个社交媒体登录链接到单个帐户(因此,如果用户使用不同的社交媒体帐户登录,而不是在
user
表中创建新行,新的社交媒体帐户将链接到现有用户)。 - 通过指定 OAuth 作用域,从社交提供商处获取有关用户的附加信息(例如,电子邮件、语言、国家/地区)。
从 GitHub 上的 flask-social-auth 库获取代码。
使用 Flask 为单页应用程序进行基于会话的身份验证
在本文中,我们将看看如何使用基于会话的认证来认证单页面应用程序 (SPAs)。我们将使用 Flask 作为我们的后端,使用 Flask-Login 来管理会话。前端将使用 Svelte 构建,这是一个 JavaScript 前端框架,旨在构建丰富的用户界面。
随意更换成不同的工具,如 Angular,Vue 或 React。
会话与基于令牌的身份验证
它们是什么?
使用基于会话的身份验证,会生成一个会话,并将 ID 存储在 cookie 中。
登录后,服务器会验证凭据。如果有效,它生成一个会话,存储它,然后将会话 id 发送回浏览器。浏览器将会话 ID 存储为 cookie,每当向服务器发出请求时,就会发送该 cookie。
基于会话的身份验证是有状态的。每次客户端向服务器发出请求时,服务器都必须在内存中定位会话,以便将会话 ID 绑定到相关用户。
另一方面,与基于会话的身份验证相比,基于令牌的身份验证相对较新。随着水疗和 RESTful APIs 的兴起,它获得了牵引力。
使用基于令牌的身份验证,在登录后,服务器验证凭据,如果有效,则创建一个签名的令牌并发送回浏览器。大多数情况下,令牌存储在 localStorage 中。然后,当向服务器发出请求时,客户端会将令牌添加到报头中。假设请求来自授权来源,服务器解码令牌并检查其有效性。
令牌是对用户信息进行编码的字符串。
例如:
`// token header { "alg": "HS256", "typ": "JWT" } // token payload { "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }`
令牌可以被验证和信任,因为它是使用秘密密钥或公钥/私钥对进行数字签名的。最常见的令牌类型是 JSON Web 令牌 (JWT)。
由于令牌包含服务器验证用户身份所需的所有信息,因此基于令牌的身份验证是无状态的。
有关会话和令牌的更多信息,请查看 Stack Exchange 中的会话认证与令牌认证。
安全漏洞
如前所述,基于会话的身份验证在 cookie 中维护客户端的状态。虽然 JWT 可以存储在 localStorage 或 cookie 中,但是大多数基于令牌的 auth 实现都将 JWT 存储在 localStorage 中。这两种方法都存在潜在的安全问题:
CSRF 是一种针对 web 应用程序的攻击,攻击者试图欺骗经过身份验证的用户执行恶意操作。大多数 CSRF 攻击的目标是使用基于 cookie 的身份验证的 web 应用程序,因为 web 浏览器包括与每个请求的特定域相关联的所有 cookie。因此,当发出恶意请求时,攻击者可以很容易地利用存储的 cookies。
要了解更多关于 CSRF 和如何在烧瓶中预防它,请查看烧瓶中的 CSRF 保护文章。
XSS 攻击是一种注入类型,恶意脚本被注入客户端,通常是为了绕过浏览器的同源策略。在 localStorage 中存储令牌的 Web 应用程序容易受到 XSS 攻击。打开浏览器并导航到任何站点。在开发者工具中打开控制台,输入JSON.stringify(localStorage)
。按回车键。这应该以 JSON 序列化的形式打印 localStorage 元素。脚本访问 localStorage 就是这么容易。
关于在哪里存储 jwt 的更多信息,请查看在哪里存储 jwt——cookie 与 HTML5 Web 存储。
设置基于会话的身份验证
本质上有三种不同的方法将 Flask 与前端框架结合起来:
- 通过 Jinja 模板提供框架
- 在同一个域上独立于 Flask 提供框架
- 在不同的域上独立于 Flask 提供框架
同样,你也可以随意选择苗条的前脸,比如,有棱角的,反应的,或者 Vue。
从长颈瓶供应的前端
使用这种方法,我们将构建前端,并用 Flask 提供生成的index.html文件。
假设您已经安装了节点和 npm ,通过瘦项目模板创建一个新项目:
`$ npx degit sveltejs/template flask-spa-jinja
$ cd flask-spa-jinja`
安装依赖项:
创建一个名为 app.py 的文件来保存 flask 应用程序:
安装烧瓶、烧瓶登录和烧瓶 WTF :
`$ python3.10 -m venv env
$ source env/bin/activate
$ pip install Flask==2.2.2 Flask-Login==0.6.2 Flask-WTF==1.0.1`
添加一个“templates”文件夹,并将 public/index.html 文件移动到其中。您的项目结构现在应该如下所示:
`├── .gitignore
├── README.md
├── app.py
├── package-lock.json
├── package.json
├── public
│ ├── favicon.png
│ └── global.css
├── rollup.config.js
├── scripts
│ └── setupTypeScript.js
├── src
│ ├── App.svelte
│ └── main.js
└── templates
└── index.html`
烧瓶后端
该应用程序有以下路线:
/
上菜index.html文件/api/login
让用户登录并生成会话/api/data
获取已验证用户的用户数据/api/getsession
检查会话是否存在/api/logout
注销用户
在这里抓取完整代码并将其添加到 app.py 文件中。
记下/
路线的处理程序:
`@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def home(path):
return render_template("index.html")`
因为 Flask 最终提供 SPA,所以 CSRF cookie 将被自动设置。
转到配置:
`app.config.update(
DEBUG=True,
SECRET_KEY="secret_sauce",
SESSION_COOKIE_HTTPONLY=True,
REMEMBER_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Strict",
)`
设置为True
的HttpOnly
标志阻止任何客户端使用会话 cookie:
`SESSION_COOKIE_HTTPONLY=True,
REMEMBER_COOKIE_HTTPONLY=True,`
我们还通过将SESSION_COOKIE_SAMESITE
设置为Strict
来防止任何外部请求发送 cookies。
有关这些配置选项的更多信息,请查看 Flask 文档中的 Set-Cookie 选项。
确保将
SESSION_COOKIE_SECURE
和REMEMBER_COOKIE_SECURE
设置为True
,以将 cookies 限制为仅用于生产的 HTTPS 流量。
更新 templates/index.html ,通过url_for
加载静态文件:
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<meta name="csrf-token" content="{{ csrf_token() }}" />
<title>Svelte app</title>
<link rel='icon' type='image/png' href="{{url_for('static', filename='favicon.png')}}">
<link rel='stylesheet' href="{{url_for('static', filename='global.css') }}">
<link rel='stylesheet' href="{{url_for('static', filename='build/bundle.css') }}">
<script defer src="{{url_for('static', filename='build/bundle.js') }}"></script>
</head>
<body>
</body>
</html>`
csrf-token
meta 标签保存由 Flask 应用程序生成的 CSRF 令牌。
纤细的前端
前端将有一个单独的组件,显示一个登录表单(当用户未经身份验证时)或一个简单的“您已通过身份验证!”消息(当用户通过身份验证时)。
在这里抓取完整代码并将其添加到 src/App.svelte 文件中。
记下每个fetch
请求的credentials: "same-origin"
。如果 URL 和调用脚本在同一个源上,这将发送 cookies。
例如:
`const whoami = () => { fetch("/api/data", { method: "GET", headers: { "Content-Type": "application/json", "X-CSRFToken": csrf, }, credentials: "same-origin", }) .then((res) => res.json()) .then((data) => { console.log(data); alert(`Welcome, ${data.username}!`); }) .catch((err) => { console.log(err); }); };`
CSRF 代币来自哪里?
我们将它添加到了 templates/index.html 中的 meta 标签中:
`<meta name="csrf-token" content="{{ csrf_token() }}" />`
然后,当App
组件挂载时,我们将 CSRF 令牌分配给csrf
变量:
`let csrf = document.getElementsByName("csrf-token")[0].content;`
接下来,更新 src/main.js :
`import App from './App.svelte'; const app = new App({ target: document.body, }); export default app;`
当应用程序被编译时,来自 App.svelte 文件的代码被分成 JavaScript 和 CSS 文件。这些文件然后被注入到 src/index.html ,这是我们的 SPA。在这种情况下,我们创建了一个新的应用程序,并使用target: document.body
将其加载到整个 HTML 主体中。
试验
就是这样!我们准备好测试了。
创建一个新的构建,然后运行 Flask:
`$ npm run build
$ python app.py`
导航到 http://localhost:5000 。您应该会看到登录表单:
您可以使用以下方式登录:
- 用户名:
test
- 密码:
test
登录后,您可以从控制台看到会话 cookie,并在 HTML 源代码中看到 CSRF 令牌的值:
如果会话 cookie 无效会发生什么?
你可以在这里找到这种方法的最终代码。
单独提供前端服务(同一域)
使用这种方法,我们将构建前端,并在同一个域上独立于 Flask 应用程序提供它。我们将使用 Docker 和 Nginx 在本地同一域上提供这两个应用程序。
这种方法与 Jinja 方法的最大区别是,您必须发出初始请求来获得 CSRF 令牌,因为它不会自动设置。
首先创建一个项目目录:
`$ mkdir flask-spa-same-origin && cd flask-spa-same-origin`
现在,为后端创建一个文件夹:
`$ mkdir backend && cd backend`
创建一个名为 app.py 的文件来保存 flask 应用程序:
添加一个 requirements.txt 文件来安装 Flask、Flask-Login 和 Flask-WTF:
`Flask==2.2.2
Flask-Login==0.6.2
Flask-WTF==1.0.1`
回到项目根,假设您已经安装了节点和 npm ,通过瘦项目模板创建一个新项目:
`$ npx degit sveltejs/template frontend
$ cd frontend`
安装依赖项:
您的项目结构现在应该如下所示:
`├── backend
│ ├── app.py
│ └── requirements.txt
└── frontend
├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── favicon.png
│ ├── global.css
│ └── index.html
├── rollup.config.js
├── scripts
│ └── setupTypeScript.js
└── src
├── App.svelte
└── main.js`
烧瓶后端
该应用程序有以下路线:
/api/ping
进行快速的理智检查/api/getcsrf
在响应头中返回一个 CSRF 令牌/api/login
让用户登录并生成会话/api/data
获取已验证用户的用户数据/api/getsession
检查会话是否存在/api/logout
注销用户
在这里抓取完整代码并将其添加到后端/app.py 文件中。
注意到:
`@app.route("/api/getcsrf", methods=["GET"])
def get_csrf():
token = generate_csrf()
response = jsonify({"detail": "CSRF cookie set"})
response.headers.set("X-CSRFToken", token)
return response`
这里,我们创建了一个 CSRF 令牌,并将其设置在响应头中。
纤细的前端
该组件将再次为未经身份验证的用户显示一个登录表单,或者显示一个简单的“您已通过身份验证!”针对已验证用户的消息。
在这里抓取完整代码并将其添加到 frontend/src/App.svelte 文件中。
由于后端和前端是分离的,我们必须通过/api/getcsrf
端点从后端手动获取令牌,并将其存储在内存中:
`const csrf = () => { fetch("/api/getcsrf", { credentials: "same-origin", }) .then((res) => { csrfToken = res.headers.get(["X-CSRFToken"]); // console.log(csrfToken); }) .catch((err) => { console.log(err); }); }`
这个函数在组件挂载后被调用。
接下来,更新 frontend/src/main.js :
`import App from './App.svelte'; const app = new App({ target: document.body, }); export default app;`
码头工人
接下来,让我们对两个应用程序进行 Dockerize。
前端和后端
前端/Dockerfile :
`# pull the official base image
FROM node:lts-alpine
# set working directory
WORKDIR /usr/src/app
# add `/usr/src/app/node_modules/.bin` to $PATH
ENV PATH /usr/src/app/node_modules/.bin:$PATH
ENV HOST=0.0.0.0
# install and cache app dependencies
COPY package.json .
COPY package-lock.json .
RUN npm ci
RUN npm install [[email protected]](/cdn-cgi/l/email-protection) -g --silent
# start app
CMD ["npm", "run", "dev"]`
后端/Dockerfile :
`# pull the official base image
FROM python:3.10-slim-buster
# set the working directory
WORKDIR /usr/src/app
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# install dependencies
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt
# add app
COPY . .
# start app
CMD ["python", "app.py"]`
向项目根目录添加一个 docker-compose.yml 文件,将两个应用程序绑定在一起:
`version: "3.8" services: backend: build: ./backend volumes: - ./backend:/usr/src/app expose: - 5000 frontend: stdin_open: true build: ./frontend volumes: - ./frontend:/usr/src/app - /usr/src/app/node_modules expose: - 8080 depends_on: - backend`
Nginx
为了在同一个域上运行这两个应用程序,让我们为 Nginx 添加一个作为反向代理的容器。在项目根目录下创建一个名为“nginx”的新文件夹。
nginx/Dockerfile :
`FROM nginx:latest
COPY ./nginx.conf /etc/nginx/nginx.conf`
同样添加一个 nginx/nginx.conf 配置文件。你可以在这里找到它的代码。
注意两个位置块:
`location /api { proxy_pass http://backend:5000; ... } location / { proxy_pass http://frontend:8080; ... }`
对/
的请求将被转发到http://frontend:8080
( frontend
是 Docker 合成文件中的服务名),而对/api
的请求将被转发到http://backend:5000
( backend
是 Docker 合成文件中的服务名)。
将服务添加到 docker_compose.yml 文件中:
`version: "3.8" services: backend: build: ./backend volumes: - ./backend:/usr/src/app expose: - 5000 frontend: stdin_open: true build: ./frontend volumes: - ./frontend:/usr/src/app - /usr/src/app/node_modules expose: - 8080 depends_on: - backend reverse_proxy: build: ./nginx ports: - 81:80 depends_on: - backend - frontend`
您的项目结构现在应该如下所示:
`├── backend
│ ├── Dockerfile
│ ├── app.py
│ └── requirements.txt
├── docker-compose.yml
├── frontend
│ ├── .gitignore
│ ├── Dockerfile
│ ├── README.md
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ │ ├── favicon.png
│ │ ├── global.css
│ │ └── index.html
│ ├── rollup.config.js
│ ├── scripts
│ │ └── setupTypeScript.js
│ └── src
│ ├── App.svelte
│ └── main.js
└── nginx
├── Dockerfile
└── nginx.conf`
试验
构建映像并运行容器:
`$ docker-compose up -d --build`
导航到 http://localhost:81 。您应该会看到登录表单。
登录方式:
- 用户名:
test
- 密码:
test
您可以在这里找到这种方法的最终代码。
单独提供前端服务(跨域)
使用这种方法,我们将构建前端,并在不同的域上独立于 Flask 应用程序提供它。我们将不得不通过使用 Flask-CORS 允许来自前端的跨域请求来稍微放松安全性。
首先创建一个项目目录:
`$ mkdir flask-spa-cross-origin && cd flask-spa-cross-origin`
现在,为后端创建一个文件夹:
`$ mkdir backend && cd backend`
创建一个名为 app.py 的文件来保存 flask 应用程序:
安装烧瓶、烧瓶登录、烧瓶 WTF 和烧瓶 CORS:
`$ python3.9 -m venv env
$ source env/bin/activate
$ pip install Flask==2.2.2 Flask-Login==0.6.2 Flask-WTF==1.0.1 Flask-Cors==3.0.10`
回到项目根,假设您已经安装了节点和 npm ,通过瘦项目模板创建一个新项目:
`$ npx degit sveltejs/template frontend
$ cd frontend`
安装依赖项:
您的项目结构现在应该如下所示:
`├── backend
│ └── app.py
└── frontend
├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── favicon.png
│ ├── global.css
│ └── index.html
├── rollup.config.js
├── scripts
│ └── setupTypeScript.js
└── src
├── App.svelte
└── main.js`
烧瓶后端
该应用程序有以下路线:
/api/ping
进行快速的理智检查/api/getcsrf
在响应头中返回一个 CSRF 令牌/api/login
让用户登录并生成会话/api/data
获取已验证用户的用户数据/api/getsession
检查会话是否存在/api/logout
注销用户
在这里抓取完整代码并将其添加到后端/app.py 文件中。
要启用 CORS,我们必须配置服务器返回适当的头:
`cors = CORS(
app,
resources={r"*": {"origins": "http://localhost:8080"}},
expose_headers=["Content-Type", "X-CSRFToken"],
supports_credentials=True,
)`
注意事项:
resources={r"*": {"origins": "http://localhost:8080"}}
启用来自http://localhost:8080
的所有路由和 HTTP 方法的跨域请求。expose_headers=["Content-Type", "X-CSRFToken"]
表示可以露出Content-Type
和X-CSRFToken
接头。supports_credentials=True
允许跨域发送 cookies。
标题:
`Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Expose-Headers: Content-Type, X-CSRFToken
Access-Control-Allow-Credentials: true`
你有没有注意到SESSION_COOKIE_SAMESITE
被设置为Lax
?
`SESSION_COOKIE_SAMESITE="Lax",`
如果我们让它保持为Strict
,就不会从前端发送 cookies。顾名思义,Lax
稍微放松了安全性,因此对于大多数请求,cookies 将跨域发送。
查看 Flask 文档中的 Set-Cookie 选项以了解更多相关信息。
纤细的前端
在这里抓取完整代码并将其添加到 frontend/src/App.svelte 文件中。
这里与相同域方法的唯一变化是将credentials: "same-origin"
改为credentials: "include"
,这样即使请求 URL 在不同的域上,cookies 仍然会被发送。
接下来,更新 frontend/src/main.js :
`import App from './App.svelte'; const app = new App({ target: document.body, }); export default app;`
试验
旋转烧瓶应用程序:
然后,在不同的终端窗口中,运行 Svelte:
导航到 http://localhost:8080 。您应该会看到登录表单。
登录方式:
- 用户名:
test
- 密码:
test
如果会话 cookie 无效会发生什么?
你可以在这里找到这种方法的最终代码。
结论
本文详细介绍了如何为单页面应用程序设置基于会话的身份验证。无论您使用会话 cookie 还是令牌,当客户端是浏览器时,最好使用 cookie 进行身份验证。虽然最好从同一个域提供这两个应用程序,但您可以通过放宽跨域安全设置,在不同的域上提供它们。
我们研究了三种不同的方法来将 Flask 与具有基于会话的授权的前端框架相结合:
方法 | 前端 | 后端 |
---|---|---|
从长颈瓶供应的前端 | 从 meta 标签中获取 CSRF 令牌,并在获取请求中使用credentials: "same-origin" 。 |
将SESSION_COOKIE_HTTPONLY 和REMEMBER_COOKIE_HTTPONLY 设置为True ,将SESSION_COOKIE_SAMESITE 设置为"Strict" 。 |
单独提供前端服务(同一域) | 获取 CSRF 令牌并在获取请求中使用credentials: "same-origin" 。 |
添加一个路由处理程序,用于生成在响应头中设置的 CSRF 令牌。将SESSION_COOKIE_HTTPONLY 和REMEMBER_COOKIE_HTTPONLY 设置为True ,将SESSION_COOKIE_SAMESITE 设置为"Strict" 。 |
单独提供前端服务(跨域) | 获取 CSRF 令牌并在获取请求中使用credentials: "include" 。 |
启用 CORS 并添加路由处理程序,以生成在响应标头中设置的 CSRF 令牌。将SESSION_COOKIE_HTTPONLY 和REMEMBER_COOKIE_HTTPONLY 设置为True ,将SESSION_COOKIE_SAMESITE 设置为"Lax" 。 |
从 flask-spa-auth repo 中获取代码。
使用 WhiteNoise 和 Amazon CloudFront 从 Flask 提供静态文件
原文:https://testdriven.io/blog/flask-static-files-whitenoise-cloudfront/
由于它使您的 Flask 应用程序能够提供自己的静态文件,因此极大地简化了静态文件管理。将它与像 CloudFront 或 Cloudflare 这样的 CDN 结合起来,这是一个方便的解决方案——即简单性和性能之间的良好平衡——用于在像 Heroku 或 PythonAnywhere 这样的平台即服务(PaaS)上处理静态文件。
本教程详细介绍了如何用 Flask 和 WhiteNoise 管理静态文件。我们还将配置 Amazon CloudFront 以获得最佳性能。
值得注意的是,本教程不包括如何处理用户上传的媒体文件。在学习教程的过程中,您可以随意设置。参考在亚马逊 S3 上存储 Django 静态和媒体文件教程了解更多信息。
白噪声
假设您设置了一个使用应用程序工厂功能模式的 Flask 项目,导入并配置 WhiteNoise:
`import os
from flask import Flask, jsonify
from whitenoise import WhiteNoise
def create_app(script_info=None):
app = Flask(__name__, static_folder="staticfiles")
WHITENOISE_MAX_AGE = 31536000 if not app.config["DEBUG"] else 0
# configure WhiteNoise
app.wsgi_app = WhiteNoise(
app.wsgi_app,
root=os.path.join(os.path.dirname(__file__), "staticfiles"),
prefix="assets/",
max_age=WHITENOISE_MAX_AGE,
)
@app.route("/")
def hello_world():
return jsonify(hello="world")
return app`
配置:
root
是静态文件目录的绝对路径。prefix
是所有静态 URL 的前缀字符串。换句话说,基于上面的配置,在http://localhost:5000/assets/main.css
会有一个 main.css 静态文件。max_age
是浏览器和代理应该缓存静态文件的时间长度,以秒为单位。
查看官方 WhiteNoise 文档中的配置属性部分,了解关于可选参数的更多信息。
在项目根目录中添加一个“静态”目录,出于测试目的,下载一份 boostrap.css 并将其添加到新创建的目录中。将“staticfiles”目录添加到项目根目录中。
您的项目结构现在应该看起来像这样:
`├── app.py
├── static
│ └── bootstrap.css
└── staticfiles`
接下来,将下面的脚本——名为compress . py——添加到您的项目根中,该脚本压缩“static”目录中的文件,然后将它们复制到“staticfiles”目录中:
`import os
import gzip
INPUT_PATH = os.path.join(os.path.dirname(__file__), "static")
OUTPUT_PATH = os.path.join(os.path.dirname(__file__), "staticfiles")
SKIP_COMPRESS_EXTENSIONS = [
# Images
".jpg",
".jpeg",
".png",
".gif",
".webp",
# Compressed files
".zip",
".gz",
".tgz",
".bz2",
".tbz",
".xz",
".br",
# Flash
".swf",
".flv",
# Fonts
".woff",
".woff2",
]
def remove_files(path):
print(f"Removing files from {path}")
for filename in os.listdir(path):
file_path = os.path.join(path, filename)
try:
if os.path.isfile(file_path):
os.unlink(file_path)
except Exception as e:
print(e)
def main():
# remove all files from "staticfiles"
remove_files(OUTPUT_PATH)
for dirpath, dirs, files in os.walk(INPUT_PATH):
for filename in files:
input_file = os.path.join(dirpath, filename)
with open(input_file, "rb") as f:
data = f.read()
# compress if file extension is not part of SKIP_COMPRESS_EXTENSIONS
name, ext = os.path.splitext(filename)
if ext not in SKIP_COMPRESS_EXTENSIONS:
# save compressed file to the "staticfiles" directory
compressed_output_file = os.path.join(OUTPUT_PATH, f"{filename}.gz")
print(f"\nCompressing {filename}")
print(f"Saving {filename}.gz")
output = gzip.open(compressed_output_file, "wb")
try:
output.write(data)
finally:
output.close()
else:
print(f"\nSkipping compression of {filename}")
# save original file to the "staticfiles" directory
output_file = os.path.join(OUTPUT_PATH, filename)
print(f"Saving {filename}")
with open(output_file, "wb") as f:
f.write(data)
if __name__ == "__main__":
main()`
这个脚本:
- 删除“staticfiles”目录中的任何现有文件
- 遍历“静态”目录中的文件并进行压缩,然后将压缩版本与原始的未压缩版本一起保存到“静态文件”目录中
通过提供压缩和未压缩版本,当客户特别要求时,WhiteNoise 将提供压缩版本。您将很快看到一个这样的例子。
要进行测试,首先安装 WhiteNoise,如果您还没有这样做的话:
接下来,将一个伪 PNG 文件添加到“static”目录中,以确保它在压缩脚本中被跳过,然后运行该脚本:
`$ touch static/test.png
$ python compress.py`
您应该看到:
`Removing files from staticfiles
Compressing bootstrap.css
Saving bootstrap.css.gz
Saving bootstrap.css
Skipping compression of test.png
Saving test.png`
“staticfiles”目录现在应该被填充:
`├── app.py
├── compress.py
├── static
│ ├── bootstrap.css
│ └── test.png
└── staticfiles
├── bootstrap.css
├── bootstrap.css.gz
└── test.png`
要验证这是否有效,安装并运行 Gunicorn :
`$ pip install gunicorn
$ gunicorn "app:create_app()" -b 127.0.0.1:5000`
现在,要用 cURL 测试 WhiteNoise 的 gzip 功能,运行:
`$ curl -I -H "Accept-Encoding: gzip" http://localhost:5000/assets/bootstrap.css`
您应该会看到以下响应:
`HTTP/1.1 200 OK
Server: gunicorn
Date: Mon, 13 Feb 2023 18:21:35 GMT
Connection: close
Content-Type: text/css; charset="utf-8"
Cache-Control: max-age=31536000, public
Access-Control-Allow-Origin: *
Vary: Accept-Encoding
Last-Modified: Mon, 13 Feb 2023 18:13:44 GMT
ETag: "63ead238-305f6"
Content-Length: 25881
Content-Encoding: gzip`
记下Content-Encoding: gzip
。这表明提供了文件的 gzip 版本。
云锋
虽然这不是必需的,但是强烈建议使用内容交付网络 (CDN),因为它将在多个地理边缘位置存储静态文件的缓存版本。然后,您的访问者将从离他们最近的位置获得您的静态内容,这将改善 web 服务器的整体响应时间。尤其是 CloudFront,它提供了许多额外的特性,以及针对 DDoS 攻击的保护和访问控制权限等等。
要进行设置,请登录 AWS 控制台并导航到 CloudFront 仪表板。单击“创建分发”。在“Origin domain”字段中添加您的域(没有 http 或 https ),并保留其余的默认值。然后,单击“创建分发”。
如果您没有配置域名,请随意使用 ngrok 在本地测试这个设置。在端口 5000 上启动并运行 Gunicorn 服务器,下载(如有必要),然后启动 ngrok:
一旦启动,您应该会看到一个可以与 CloudFront 一起使用的公共 URL。
想看看这方面的演示吗?看看下面的视频。
CloudFront 完全配置您的发行版通常需要大约 15 分钟。但是,您可以在它被完全分发到所有边缘位置之前进行测试,而创建状态仍然是“进行中”。在开始测试之前,可能还需要几分钟。
要进行测试,获取与 CloudFront 发行版相关的 URL 并运行:
`$ curl -I -H "Accept-Encoding: gzip" https://dxquy3iqeuay6.cloudfront.net/assets/bootstrap.css`
您应该会看到类似如下的内容:
`HTTP/2 200
content-type: text/css; charset="utf-8"
content-length: 25881
access-control-allow-origin: *
cache-control: max-age=31536000, public
content-encoding: gzip
date: Tue, 23 Feb 2021 15:39:01 GMT
etag: "6035739d-305f6"
last-modified: Tue, 23 Feb 2021 15:29:01 GMT
server: gunicorn/20.0.4
vary: Accept-Encoding
x-cache: Miss from cloudfront
via: 1.1 5f09c808a81a33267d5cc58d93ce6353.cloudfront.net (CloudFront)
x-amz-cf-pop: DFW53-C1
x-amz-cf-id: _aLbrgkskBos4G1tjMFR34__rgmmBSkxaCNGiSdMBmxauX4f4CFO1Q==`
现在,您可以使用 Flask 应用程序中提供的 CloudFront 域来处理静态文件请求:
`import os
from urllib.parse import urljoin
from flask import Flask, jsonify, render_template
from whitenoise import WhiteNoise
def create_app(script_info=None):
app = Flask(__name__, static_folder="staticfiles")
WHITENOISE_MAX_AGE = 31536000 if not app.config["DEBUG"] else 0
CDN = "https://dxquy3iqeuay6.cloudfront.net"
app.config["STATIC_URL"] = CDN if not app.config["DEBUG"] else ""
# configure WhiteNoise
app.wsgi_app = WhiteNoise(
app.wsgi_app,
root=os.path.join(os.path.dirname(__file__), "staticfiles"),
prefix="assets/",
max_age=WHITENOISE_MAX_AGE,
)
@app.template_global()
def static_url(prefix, filename):
return urljoin(app.config["STATIC_URL"], f"{prefix}/{filename}")
@app.route("/")
def hello_world():
return jsonify(hello="world")
return app`
在你的模板中,应该使用static_url
而不是 url_for 。
健全性检查
让我们配置一个模板来测试一下。
添加新的处理程序:
`import os
from urllib.parse import urljoin
from flask import Flask, jsonify, render_template
from whitenoise import WhiteNoise
def create_app(script_info=None):
app = Flask(__name__, static_folder="staticfiles")
WHITENOISE_MAX_AGE = 31536000 if not app.config["DEBUG"] else 0
CDN = "https://dxquy3iqeuay6.cloudfront.net"
app.config["STATIC_URL"] = CDN if not app.config["DEBUG"] else ""
# configure WhiteNoise
app.wsgi_app = WhiteNoise(
app.wsgi_app,
root=os.path.join(os.path.dirname(__file__), "staticfiles"),
prefix="assets/",
max_age=WHITENOISE_MAX_AGE,
)
@app.template_global()
def static_url(prefix, filename):
return urljoin(app.config["STATIC_URL"], f"{prefix}/{filename}")
@app.route("/")
def hello_world():
return jsonify(hello="world")
@app.route("/hi")
def index():
return render_template("index.html")
return app`
在项目根目录下创建一个名为“templates”的新目录,并向该目录添加一个index.html文件:
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="{{ static_url('assets', filename='bootstrap.css') }}">
<title>Hello, world!</title>
</head>
<body>
<div class="container" style="padding-top:100px">
<h1>Hello, world!</h1>
</div>
</body>
</html>`
重启 Gunicorn 服务器,然后在 http://localhost:5000/hi 进行测试。
在浏览器的开发工具中
- bootstrap.css 文件应该已经从 CloudFront 加载:
https://dxquy3iqeuay6.cloudfront.net/assets/bootstrap.css
- 文件的 gzipped 版本应该已经送达:
content-encoding: gzip
- 文件也应该已经从边缘位置的缓存中提供:
x-cache: Hit from cloudfront
尝试运行一个 WebPageTest 来确保静态文件被正确压缩和缓存:
演示视频:
https://www.youtube.com/embed/vD-VMMhnqkI
VIDEO
烧瓶条纹订阅
本教程介绍如何使用 Flask 和 Stripe 设置和收取每月定期订阅费用。
需要接受一次性付款?查看烧瓶条纹教程。
条带订阅支付选项
有多种方法可以实现和处理条带订阅,但最常见的两种方法是:
在这两种情况下,您都可以使用 Stripe Checkout (这是一个 Stripe 托管的 Checkout 页面)或 Stripe Elements (这是一组用于构建支付表单的定制 UI 组件)。如果您不介意将您的用户重定向到 Stripe 托管的页面,并希望 Stripe 为您处理大部分支付流程(例如,创建客户和支付意向等),请使用 Stripe Checkout。),否则使用条纹元素。
固定价格方法更容易建立,但是你不能完全控制计费周期和支付。通过使用这种方法,Stripe 将在成功结账后的每个结算周期自动开始向您的客户收费。
固定价格步骤:
- 将用户重定向至条带检验(使用
mode=subscription
) - 创建一个监听
checkout.session.completed
的网络钩子 - 调用 webhook 后,将相关数据保存到数据库中
未来付款方式更难设置,但这种方式可以让您完全控制订阅。您提前收集客户详细信息和付款信息,并在未来某个日期向客户收费。这种方法还允许您同步计费周期,以便您可以在同一天向所有客户收费。
未来付款步骤:
- 将用户重定向到条带结帐(使用
mode=setup
)以收集支付信息 - 创建一个监听
checkout.session.completed
的网络钩子 - 调用 webhook 后,将相关数据保存到数据库中
- 从那里,您可以在将来使用付款意向 API 对付款方式收费
在本教程中,我们将使用带条纹结帐的固定价格方法。
计费周期
在开始之前,值得注意的是 Stripe 没有默认的计费频率。每个条带订阅的记账日期由以下两个因素决定:
- 计费周期锚点(订阅创建的时间戳)
- 重复间隔(每天、每月、每年等。)
例如,每月订阅设置为在每月 2 日循环的客户将始终在 2 日计费。
如果一个月没有锚定日,订阅将在该月的最后一天计费。例如,从 1 月 31 日开始的订阅在 2 月 28 日(或闰年的 2 月 29 日)计费,然后是 3 月 31 日、4 月 30 日等等。
要了解有关计费周期的更多信息,请参考 Stripe 文档中的设置订阅计费周期日期页面。
项目设置
让我们首先为我们的项目创建一个新目录。在该目录中,我们将创建并激活一个新的虚拟环境,并安装 Flask。
`$ mkdir flask-stripe-subscriptions && cd flask-stripe-subscriptions
$ python3.10 -m venv env
$ source env/bin/activate
(env)$ pip install flask`
接下来,创建名为 app.py 的文件,并添加基本“Hello World”应用程序的代码:
`# app.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/hello")
def hello_world():
return jsonify("hello, world!")
if __name__ == "__main__":
app.run()`
启动服务器:
`(env)$ FLASK_ENV=development python app.py`
导航到http://localhost:5000/hello,您应该会看到hello, world!
消息。
添加条纹
准备好基础项目后,让我们添加 Stripe。安装最新版本:
`(env)$ pip install stripe`
接下来,注册一个 Stipe 账户的(如果你还没有注册的话)并导航到仪表板。单击“Developers ”,然后从左侧栏的列表中单击“API keys ”:
每个条带帐户有四个 API 密钥:两个用于测试,两个用于生产。每一对都有一个“秘密密钥”和一个“可公开密钥”。不要向任何人透露密钥;可发布的密钥将被嵌入到任何人都可以看到的页面上的 JavaScript 中。
目前右上角的“查看测试数据”开关表示我们正在使用测试键。这就是我们想要的。
将您的测试 API 键存储为环境变量,如下所示:
`(env)$ export STRIPE_PUBLISHABLE_KEY=<YOUR_STRIPE_PUBLISHABLE_KEY>
(env)$ export STRIPE_SECRET_KEY=<YOUR_STRIPE_SECRET_KEY>`
接下来,将条带密钥添加到您的应用程序中:
`# app.py
import os
import stripe
from flask import Flask, jsonify
app = Flask(__name__)
stripe_keys = {
"secret_key": os.environ["STRIPE_SECRET_KEY"],
"publishable_key": os.environ["STRIPE_PUBLISHABLE_KEY"],
}
stripe.api_key = stripe_keys["secret_key"]
@app.route("/hello")
def hello_world():
return jsonify("hello, world!")
if __name__ == "__main__":
app.run()`
最后,在https://dashboard.stripe.com/settings/account的“账户设置”中指定一个“账户名称”。
创造产品
接下来,让我们创建一个要销售的订阅产品。
单击“产品”,然后单击“添加产品”。
添加产品名称和描述,输入价格,然后选择“重复”:
点击“保存产品”。
接下来,获取价格的 API ID:
将 ID 保存为环境变量,如下所示:
`(env)$ export STRIPE_PRICE_ID=<YOUR_PRICE_API_ID>`
接下来,像这样将它添加到stripe_keys
字典中:
`# app.py
stripe_keys = {
"secret_key": os.environ["STRIPE_SECRET_KEY"],
"publishable_key": os.environ["STRIPE_PUBLISHABLE_KEY"],
"price_id": os.environ["STRIPE_PRICE_ID"], # new
}`
证明
为了在将来将您的用户与 Stripe 客户相关联并实现订阅管理,您可能需要在允许客户订阅服务之前强制执行用户身份验证。Flask-Login 或 Flask-HTTPAuth 是你可以用来管理这个的两个扩展。
查看这个资源,获得 Flask 的 auth 相关扩展的完整列表。
除了身份验证之外,您还需要在与客户相关的数据库中存储一些信息。你的模型看起来会像这样:
`class User(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
email = db.Column(db.String(128), nullable=False, unique=True)
password = db.Column(db.String(200), nullable=False)
created_on = db.Column(db.DateTime, default=func.now(), nullable=False)
class StripeCustomer(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
user_id = database.Column(database.Integer, database.ForeignKey('users.id'))
stripeCustomerId = db.Column(db.String(255), nullable=False)
stripeSubscriptionId = db.Column(db.String(255), nullable=False)`
如果你愿意,现在就开始设置吧。
获取可发布密钥
JavaScript 静态文件
创建一个名为“static”的新文件夹,然后向该文件夹添加一个名为 main.js 的新文件:
`(env)$ mkdir static
(env)$ touch static/main.js`
向新的 main.js 文件添加快速健全检查:
`// static/main.js console.log("Sanity check!");`
接下来,向 app.py 注册一条新路线,该路线提供了【index.html】模板:
`# app.py
@app.route("/")
def index():
# you should force the user to log in/sign up
return render_template("index.html")`
不要忘记像这样在文件的顶部导入render_template
:
`from flask import Flask, jsonify, render_template`
对于模板,在项目根目录下创建一个名为“templates”的新文件夹。在该文件夹中,创建一个名为index.html的新文件,并将以下内容放入其中:
`<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flask + Stripe Subscriptions</title>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" crossorigin="anonymous">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='main.js') }}"></script>
</head>
<body>
<div class="container mt-5">
<button type="submit" class="btn btn-primary" id="submitBtn">Subscribe</button>
</div>
</body>
</html>`
再次运行服务器:
`(env)$ FLASK_ENV=development python app.py`
导航到 http://localhost:5000/ ,打开 JavaScript 控制台。您应该会看到控制台内部的健全性检查。
途径
接下来,向 app.py 添加一个新的路由来处理 AJAX 请求:
`# app.py
@app.route("/config")
def get_publishable_key():
stripe_config = {"publicKey": stripe_keys["publishable_key"]}
return jsonify(stripe_config)`
AJAX 请求
接下来,使用获取 API 向 static/main.js 中的新/config
端点发出 AJAX 请求:
`// static/main.js console.log("Sanity check!"); // new // Get Stripe publishable key fetch("/config") .then((result) => { return result.json(); }) .then((data) => { // Initialize Stripe.js const stripe = Stripe(data.publicKey); });`
来自fetch
请求的响应是一个可读流。result.json()
返回一个承诺,我们将它解析为一个 JavaScript 对象——即data
。然后我们使用点符号来访问publicKey
以获得可发布的密钥。
将 Stripe.js 包含在 templates/index.html 中,就像这样:
`<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flask + Stripe Subscriptions</title>
<script src="https://js.stripe.com/v3/"></script> <!-- new -->
<script src="https://code.jquery.com/jquery-3.5.1.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" crossorigin="anonymous">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='main.js') }}"></script>
</head>
<body>
<div class="container mt-5">
<button type="submit" class="btn btn-primary" id="submitBtn">Subscribe</button>
</div>
</body>
</html>`
现在,在页面加载之后,将调用/config
,它将使用 Stripe publish key 进行响应。然后,我们将使用这个键创建 Stripe.js 的新实例。
创建签出会话
接下来,我们需要将一个事件处理程序附加到按钮的 click 事件,该事件将向服务器发送另一个 AJAX 请求,以生成一个新的结帐会话 ID。
途径
首先,添加新路由:
`# app.py
@app.route("/create-checkout-session")
def create_checkout_session():
domain_url = "http://localhost:5000/"
stripe.api_key = stripe_keys["secret_key"]
try:
checkout_session = stripe.checkout.Session.create(
# you should get the user id here and pass it along as 'client_reference_id'
#
# this will allow you to associate the Stripe session with
# the user saved in your database
#
# example: client_reference_id=user.id,
success_url=domain_url + "success?session_id={CHECKOUT_SESSION_ID}",
cancel_url=domain_url + "cancel",
payment_method_types=["card"],
mode="subscription",
line_items=[
{
"price": stripe_keys["price_id"],
"quantity": 1,
}
]
)
return jsonify({"sessionId": checkout_session["id"]})
except Exception as e:
return jsonify(error=str(e)), 403`
完整的文件现在应该如下所示:
`# app.py
import os
import stripe
from flask import Flask, jsonify, render_template
app = Flask(__name__)
stripe_keys = {
"secret_key": os.environ["STRIPE_SECRET_KEY"],
"publishable_key": os.environ["STRIPE_PUBLISHABLE_KEY"],
"price_id": os.environ["STRIPE_PRICE_ID"],
}
stripe.api_key = stripe_keys["secret_key"]
@app.route("/hello")
def hello_world():
return jsonify("hello, world!")
@app.route("/")
def index():
# you should force the user to log in/sign up
return render_template("index.html")
@app.route("/config")
def get_publishable_key():
stripe_config = {"publicKey": stripe_keys["publishable_key"]}
return jsonify(stripe_config)
@app.route("/create-checkout-session")
def create_checkout_session():
domain_url = "http://localhost:5000/"
stripe.api_key = stripe_keys["secret_key"]
try:
checkout_session = stripe.checkout.Session.create(
# you should get the user id here and pass it along as 'client_reference_id'
#
# this will allow you to associate the Stripe session with
# the user saved in your database
#
# example: client_reference_id=user.id,
success_url=domain_url + "success?session_id={CHECKOUT_SESSION_ID}",
cancel_url=domain_url + "cancel",
payment_method_types=["card"],
mode="subscription",
line_items=[
{
"price": stripe_keys["price_id"],
"quantity": 1,
}
]
)
return jsonify({"sessionId": checkout_session["id"]})
except Exception as e:
return jsonify(error=str(e)), 403
if __name__ == "__main__":
app.run()`
AJAX 请求
将事件处理程序和后续 AJAX 请求添加到 static/main.js :
`// static/main.js console.log("Sanity check!"); // Get Stripe publishable key fetch("/config") .then((result) => { return result.json(); }) .then((data) => { // Initialize Stripe.js const stripe = Stripe(data.publicKey); // new // Event handler document.querySelector("#submitBtn").addEventListener("click", () => { // Get Checkout Session ID fetch("/create-checkout-session") .then((result) => { return result.json(); }) .then((data) => { console.log(data); // Redirect to Stripe Checkout return stripe.redirectToCheckout({sessionId: data.sessionId}) }) .then((res) => { console.log(res); }); }); });`
在这里,在解析了result.json()
承诺之后,我们调用了 redirectToCheckout ,其中的结帐会话 ID 来自解析的承诺。
导航到 http://localhost:5000/ 。点击按钮后,您将被重定向到一个 Stripe Checkout 实例(一个 Stripe 托管页面,用于安全收集支付信息),其中包含订阅信息:
我们可以使用 Stripe 提供的几个测试卡号中的一个来测试表单。还是用4242 4242 4242 4242
吧。请确保到期日期在未来。为 CVC 添加任意 3 个数字,为邮政编码添加任意 5 个数字。输入任何电子邮件地址和名称。如果一切顺利,付款应该被处理,您应该订阅,但重定向将失败,因为我们还没有设置/success/
URL。
用户重定向
接下来,我们将创建成功和取消视图,并在结帐后将用户重定向到适当的页面。
路线:
`# app.py
@app.route("/success")
def success():
return render_template("success.html")
@app.route("/cancel")
def cancelled():
return render_template("cancel.html")`
创建success.html和cancel.html模板。
成功:
`<!-- templates/success.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flask + Stripe Subscriptions</title>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" crossorigin="anonymous">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" crossorigin="anonymous"></script>
</head>
<body>
<div class="container mt-5">
<p>You have successfully subscribed!</p>
</div>
</body>
</html>`
取消:
`<!-- templates/cancel.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flask + Stripe Subscriptions</title>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" crossorigin="anonymous">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" crossorigin="anonymous"></script>
</head>
<body>
<div class="container mt-5">
<p>You have cancelled the checkout.</p>
</div>
</body>
</html>`
如果支付成功,用户将被重定向到/success
,如果支付失败,用户将被重定向到cancel/
。测试一下。
条纹网钩
我们的应用程序在这一点上运行良好,但我们仍然不能以编程方式确认付款。将来,当客户成功订阅时,我们还需要将相关信息保存到数据库中。我们已经在用户结帐后将他们重定向到成功页面,但是我们不能只依赖那个页面,因为付款确认是异步发生的。
一般来说,在条带和编程中有两种类型的事件。同步事件,具有即时的效果和结果(例如,创建一个客户),异步事件,没有即时的结果(例如,确认付款)。因为支付确认是异步完成的,用户可能会在他们的支付被确认之前和我们收到他们的资金之前被重定向到成功页面。
当付款通过时,最简单的通知方法之一是使用回调或所谓的 Stripe webhook。我们需要在应用程序中创建一个简单的端点,每当事件发生时(例如,当用户订阅时),Stripe 将调用这个端点。通过使用 webhooks,我们可以绝对肯定支付成功。
为了使用 webhooks,我们需要:
- 设置 webhook 端点
- 使用条带 CLI 测试端点
- 用条带注册端点
端点
创建一个名为stripe_webhook
的新路由,它将在每次有人订阅时打印一条消息。
如果添加了身份验证,应该在
StripeCustomer
表中创建一条记录,而不是打印一条消息。
`# app.py
@app.route("/webhook", methods=["POST"])
def stripe_webhook():
payload = request.get_data(as_text=True)
sig_header = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, stripe_keys["endpoint_secret"]
)
except ValueError as e:
# Invalid payload
return "Invalid payload", 400
except stripe.error.SignatureVerificationError as e:
# Invalid signature
return "Invalid signature", 400
# Handle the checkout.session.completed event
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
# Fulfill the purchase...
handle_checkout_session(session)
return "Success", 200
def handle_checkout_session(session):
# here you should fetch the details from the session and save the relevant information
# to the database (e.g. associate the user with their subscription)
print("Subscription was successful.")`
不要忘记像这样在文件的顶部导入request
:
`from flask import Flask, jsonify, render_template, request`
stripe_webhook
现在作为我们的 webhook 端点。这里,我们只寻找每当结帐成功时调用的checkout.session.completed
事件,但是您可以对其他条带事件使用相同的模式。
测试 webhook
我们将使用 Stripe CLI 来测试 webhook。
一旦下载并安装了,在新的终端窗口中运行以下命令,登录到您的 Stripe 帐户:
此命令应生成一个配对代码:
`Your pairing code is: peach-loves-classy-cozy
This pairing code verifies your authentication with Stripe.
Press Enter to open the browser (^C to quit)`
通过按 Enter,CLI 将打开您的默认 web 浏览器,并请求访问您的帐户信息的权限。请继续并允许访问。回到您的终端,您应该看到类似于以下内容的内容:
`> Done! The Stripe CLI is configured for Flask Test with account id acct_<ACCOUNT_ID>
Please note: this key will expire after 90 days, at which point you'll need to re-authenticate.`
接下来,我们可以开始侦听条带事件,并使用以下命令将它们转发到我们的端点:
`$ stripe listen --forward-to localhost:5000/webhook`
这也将生成一个 webhook 签名密码:
`> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (^C to quit)`
为了初始化端点,设置一个新的环境变量:
`(env)$ export STRIPE_ENDPOINT_SECRET=<YOUR_STRIPE_ENDPOINT_SECRET>`
接下来,像这样将它添加到stripe_keys
字典中:
`# app.py
stripe_keys = {
"secret_key": os.environ["STRIPE_SECRET_KEY"],
"publishable_key": os.environ["STRIPE_PUBLISHABLE_KEY"],
"price_id": os.environ["STRIPE_PRICE_ID"],
"endpoint_secret": os.environ["STRIPE_ENDPOINT_SECRET"], # new
}`
Stripe 现在会将事件转发到我们的端点。要测试,通过4242 4242 4242 4242
运行另一个测试支付。在您的终端中,您应该会看到Subscription was successful.
消息。
一旦完成,停止stripe listen --forward-to localhost:5000/webhook
过程。
注册端点
最后,在部署你的应用程序后,你可以在 Stripe 仪表板中注册端点,在开发者> Webhooks 下。这将生成一个 webhook 签名密码,用于您的生产应用程序。
例如:
获取订阅数据
如果您添加了身份验证,您可能希望获取用户的订阅数据并显示给他们。
例如:
`@app.route("/")
@login_required # force the user to log in/sign up
def index():
# check if a record exists for them in the StripeCustomer table
customer = StripeCustomer.query.filter_by(user_id=current_user.id).first()
# if record exists, add the subscription info to the render_template method
if customer:
subscription = stripe.Subscription.retrieve(customer.stripeSubscriptionId)
product = stripe.Product.retrieve(subscription.plan.product)
context = {
"subscription": subscription,
"product": product,
}
return render_template("index.html", **context)
return render_template("index.html")`
这里,如果存在一个StripeCustomer
,我们使用subscriptionId
从 Stripe API 获取客户的订阅和产品信息。
接下来,修改index.html模板以向订阅用户显示当前计划:
`<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flask + Stripe Subscriptions</title>
<script src="https://js.stripe.com/v3/"></script>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" crossorigin="anonymous">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='main.js') }}"></script>
</head>
<body>
<div class="container mt-5">
{% if subscription and subscription.status == "active" %}
<h4>Your subscription:</h4>
<div class="card" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{ product.name }}</h5>
<p class="card-text">
{{ product.description }}
</p>
</div>
</div>
{% else %}
<button type="submit" class="btn btn-primary" id="submitBtn">Subscribe</button>
{% endif %}
</div>
</body>
</html>`
我们的订阅客户现在将看到他们当前的订阅计划,而其他人仍将看到订阅按钮:
限制用户访问
如果您想要将对特定视图的访问限制为只有订阅的用户,那么您可以像我们在上一步中所做的那样获取订阅,并检查subscription.status == "active"
。通过执行这项检查,您将确保订阅仍然有效,这意味着它已经支付,并没有被取消。
其他可能的订阅状态有
incomplete
、incomplete_expired
、trialing
、active
、past_due
、canceled
或unpaid
。
结论
我们已经成功地创建了一个 Flask web 应用程序,允许用户订阅我们的服务。客户也将每月自动付费。
这只是最基本的。您仍然需要:
- 实现用户认证,限制部分路由
- 创建保存用户订阅的数据库
- 允许用户管理/取消其当前计划
- 处理未来的付款失败
从 GitHub 上的flask-stripe-subscriptionsrepo 中获取代码。
烧瓶条纹教程
本教程展示了如何将 Stripe 添加到 Flask 应用程序中,以接受一次性付款。
需要处理订阅付款?查看烧瓶条纹订阅。
条纹支付策略
Stripe 目前有三种接受一次性付款的策略:
你应该使用哪种策略?
- 如果您想快速启动并运行,请使用条带检出。如果您已经使用了 Checkout 的旧的模态版本,并且正在寻找一种类似的方法,那么这是一条可行之路。它提供了许多开箱即用的强大功能,支持多种语言,甚至可以用于定期支付。最重要的是,Checkout 为您管理整个付款过程,因此您甚至无需添加任何表单就可以开始接受付款!
- 如果您希望为最终用户定制付款体验,请使用付款意向 API(以及元素)。
收费 API 呢?
- 虽然您仍然可以使用 Charges API,但是如果您是 Stripe 的新手,请不要使用它,因为它不支持最新的银行法规(如 SCA )。如果使用的话,你会看到很高的下降率。如需了解更多信息,请查看官方 Stripe 文档中的费用与付款意向 API页面。
- 还在用收费 API?如果你的大多数客户都在美国或加拿大,你还不需要迁移。查看结帐迁移指南指南了解更多信息。
初始设置
第一步是设置一个基本的 Python 环境并安装 Flask。
创建一个新的项目文件夹,创建并激活一个虚拟环境,并安装 Flask:
`$ mkdir flask-stripe-checkout && cd flask-stripe-checkout
$ python3.10 -m venv env
$ source env/bin/activate
(env)$ pip install flask`
你可以随意把 virtualenv 和 Pip 换成诗歌或 Pipenv 。更多信息,请查看现代 Python 环境。
接下来,创建一个名为 app.py 的文件,并添加一个基本的“Hello World”应用程序的代码:
`# app.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/hello")
def hello_world():
return jsonify("hello, world!")
if __name__ == "__main__":
app.run()`
启动服务器:
`(env)$ FLASK_ENV=development python app.py`
你应该在你的浏览器中看到"hello, world!"
在http://127 . 0 . 0 . 1:5000/hello。
添加条纹
条纹时间到了。从安装开始:
`(env)$ pip install stripe`
接下来,注册一个新的 Stripe 账户(如果你还没有的话),然后导航到仪表盘。点击“开发者”:
然后在左侧栏中点击“API keys”:
每个 Stripe 帐户有四个 API 密钥:两个用于测试,两个用于生产。每一对都有一个“秘密密钥”和一个“可公开密钥”。不要向任何人透露密钥;可发布的密钥将被嵌入到任何人都可以看到的页面上的 JavaScript 中。
目前右上角的“查看测试数据”开关表示我们正在使用测试键。这就是我们想要的。
将您的测试 API 键存储为环境变量,如下所示:
`(env)$ export STRIPE_PUBLISHABLE_KEY=<YOUR_STRIPE_PUBLISHABLE_KEY>
(env)$ export STRIPE_SECRET_KEY=<YOUR_STRIPE_SECRET_KEY>`
接下来,将条带密钥添加到您的应用程序中:
`# app.py
import os
import stripe
from flask import Flask, jsonify
app = Flask(__name__)
stripe_keys = {
"secret_key": os.environ["STRIPE_SECRET_KEY"],
"publishable_key": os.environ["STRIPE_PUBLISHABLE_KEY"],
}
stripe.api_key = stripe_keys["secret_key"]
@app.route("/hello")
def hello_world():
return jsonify("hello, world!")
if __name__ == "__main__":
app.run()`
最后,您需要在https://dashboard.stripe.com/settings/account的“帐户设置”中指定一个“帐户名称”:
创造产品
接下来,我们需要创造一个产品来销售。
单击顶部导航栏中的“产品”,然后单击“添加产品”:
添加产品名称,输入价格,然后选择“一次性”:
点击“保存产品”。
有了 API 密钥和产品设置,我们现在可以开始添加 Stripe Checkout 来处理支付。
工作流程
在用户点击购买按钮后,我们需要做以下事情:
-
获取可发布密钥
- 从客户端向服务器发送 AJAX 请求,请求可发布的密钥
- 用键回应
- 使用键创建 Stripe.js 的新实例
-
创建签出会话
- 向服务器发送另一个 AJAX 请求,请求一个新的结帐会话 ID
- 生成新的签出会话并发回 ID
- 重定向到用户完成购买的结帐页面
-
适当地重定向用户
- 成功付款后重定向到成功页面
- 取消付款后重定向到取消页面
-
用条纹网钩确认付款
- 设置 webhook 端点
- 使用条带 CLI 测试端点
- 用条带注册端点
获取可发布密钥
JavaScript 静态文件
让我们首先创建一个新的静态文件来保存我们所有的 JavaScript。
添加一个名为“static”的新文件夹,然后向该文件夹添加一个名为 main.js 的新文件:
`// static/main.js console.log("Sanity check!");`
接下来,向 app.py 添加一条新路线,该路线提供了一个【index.html】模板:
`# app.py
@app.route("/")
def index():
return render_template("index.html")`
确保也导入render_template
:
`from flask import Flask, jsonify, render_template`
对于模板,首先添加一个名为“templates”的新文件夹,然后添加一个名为base.html的基础模板,其中包含了提供 main.js 静态文件的脚本标签:
`<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flask + Stripe Checkout</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[[email protected]](/cdn-cgi/l/email-protection)/css/bulma.min.css">
<script src="{{ url_for('static', filename='main.js') }}"></script>
<script defer src="https://use.fontawesome.com/releases/v5.14.0/js/all.js"></script>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>`
接下来,向名为index.html的新模板添加一个支付按钮:
`<!-- templates/index.html -->
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<button class="button is-primary" id="submitBtn">Purchase!</button>
</div>
</section>
{% endblock %}`
再次运行开发服务器:
`(env)$ FLASK_ENV=development python app.py`
导航到 http://127.0.0.1:5000 ,打开 JavaScript 控制台。您应该看到健全性检查:
途径
接下来,向 app.py 添加一个新的路由来处理 AJAX 请求:
`# app.py
@app.route("/config")
def get_publishable_key():
stripe_config = {"publicKey": stripe_keys["publishable_key"]}
return jsonify(stripe_config)`
AJAX 请求
接下来,使用获取 API 向 static/main.js 中的新/config
端点发出 AJAX 请求:
`// static/main.js console.log("Sanity check!"); // new // Get Stripe publishable key fetch("/config") .then((result) => { return result.json(); }) .then((data) => { // Initialize Stripe.js const stripe = Stripe(data.publicKey); });`
来自fetch
请求的响应是一个可读流。result.json()
返回一个承诺,我们将它解析为一个 JavaScript 对象——即data
。然后我们使用点符号来访问publicKey
以获得可发布的密钥。
将 Stripe.js 包含在 templates/base.html 中,就像这样:
`<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flask + Stripe Checkout</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[[email protected]](/cdn-cgi/l/email-protection)/css/bulma.min.css">
<script src="https://js.stripe.com/v3/"></script> <!-- new -->
<script src="{{ url_for('static', filename='main.js') }}"></script>
<script defer src="https://use.fontawesome.com/releases/v5.14.0/js/all.js"></script>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>`
现在,在页面加载之后,将调用/config
,它将使用 Stripe publish key 进行响应。然后,我们将使用这个键创建 Stripe.js 的新实例。
工作流程:
-
获取可发布密钥从客户端向服务器发送 AJAX 请求,请求可发布密钥用键响应使用键创建 Stripe.js 的新实例
-
创建签出会话
- 向服务器发送另一个 AJAX 请求,请求一个新的结帐会话 ID
- 生成新的签出会话并发回 ID
- 重定向到用户完成购买的结帐页面
-
适当地重定向用户
- 成功付款后重定向到成功页面
- 取消付款后重定向到取消页面
-
用条纹网钩确认付款
- 设置 webhook 端点
- 使用条带 CLI 测试端点
- 用条带注册端点
创建签出会话
接下来,我们需要将一个事件处理程序附加到按钮的 click 事件,该事件将向服务器发送另一个 AJAX 请求,以生成一个新的结帐会话 ID。
途径
首先,添加新路由:
`# app.py
@app.route("/create-checkout-session")
def create_checkout_session():
domain_url = "http://127.0.0.1:5000/"
stripe.api_key = stripe_keys["secret_key"]
try:
# Create new Checkout Session for the order
# Other optional params include:
# [billing_address_collection] - to display billing address details on the page
# [customer] - if you have an existing Stripe Customer ID
# [payment_intent_data] - capture the payment later
# [customer_email] - prefill the email input in the form
# For full details see https://stripe.com/docs/api/checkout/sessions/create
# ?session_id={CHECKOUT_SESSION_ID} means the redirect will have the session ID set as a query param
checkout_session = stripe.checkout.Session.create(
success_url=domain_url + "success?session_id={CHECKOUT_SESSION_ID}",
cancel_url=domain_url + "cancelled",
payment_method_types=["card"],
mode="payment",
line_items=[
{
"name": "T-shirt",
"quantity": 1,
"currency": "usd",
"amount": "2000",
}
]
)
return jsonify({"sessionId": checkout_session["id"]})
except Exception as e:
return jsonify(error=str(e)), 403`
在这里,我们-
- 定义了一个
domain_url
(用于重定向) - 将条带密钥分配给
stripe.api_key
(因此当我们请求创建新的结帐会话时,它将被自动发送) - 创建了签出会话
- 在响应中发回了 ID
注意使用了domain_url
的success_url
和cancel_url
。在成功支付或取消的情况下,用户将分别被重定向回这些 URL。我们将很快设立/success
和/cancelled
路线。
AJAX 请求
将事件处理程序和后续 AJAX 请求添加到 static/main.js :
`// static/main.js console.log("Sanity check!"); // Get Stripe publishable key fetch("/config") .then((result) => { return result.json(); }) .then((data) => { // Initialize Stripe.js const stripe = Stripe(data.publicKey); // new // Event handler document.querySelector("#submitBtn").addEventListener("click", () => { // Get Checkout Session ID fetch("/create-checkout-session") .then((result) => { return result.json(); }) .then((data) => { console.log(data); // Redirect to Stripe Checkout return stripe.redirectToCheckout({sessionId: data.sessionId}) }) .then((res) => { console.log(res); }); }); });`
这里,在解析了result.json()
承诺之后,我们调用了 redirectToCheckout 方法,该方法带有来自已解析承诺的结帐会话 ID。
导航到 http://127.0.0.1:5000 。点击按钮后,您将被重定向到 Stripe Checkout 实例(一个 Stripe 托管页面,用于安全收集支付信息),其中包含 t 恤产品信息:
我们可以使用 Stripe 提供的几个测试卡号中的一个来测试表单。还是用4242 4242 4242 4242
吧。
- 电子邮件:有效的电子邮件
- 卡号:
4242 4242 4242 4242
- 到期日:未来的任何日期
- CVC:任何三个数字
- 名称:任何东西
- 邮政编码:任意五个数字
如果一切顺利,付款应该被处理,但重定向将失败,因为我们还没有设置/success
URL。
工作流程:
-
获取可发布密钥从客户端向服务器发送 AJAX 请求,请求可发布密钥用键响应使用键创建 Stripe.js 的新实例
-
创建结账会话向服务器发送另一个 AJAX 请求,请求一个新的结帐会话 ID生成新的结账会话并发回 ID重定向到用户完成购买的结账页面
-
适当地重定向用户
- 成功付款后重定向到成功页面
- 取消付款后重定向到取消页面
-
用条纹网钩确认付款
- 设置 webhook 端点
- 使用条带 CLI 测试端点
- 用条带注册端点
适当地重定向用户
最后,让我们连接用于处理成功和取消重定向的模板和路由。
成功模板:
`<!-- templates/success.html -->
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<p>Your payment succeeded.</p>
</div>
</section>
{% endblock %}`
已取消的模板:
`<!-- templates/cancelled.html -->
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<p>Your payment was cancelled.</p>
</div>
</section>
{% endblock %}`
路线:
`# app.py
@app.route("/success")
def success():
return render_template("success.html")
@app.route("/cancelled")
def cancelled():
return render_template("cancelled.html")`
回到 http://127.0.0.1:5000 ,点击支付按钮,再次使用信用卡号4242 4242 4242 4242
以及其余的虚拟信用卡信息。提交付款。你应该被重定向回http://127 . 0 . 0 . 1:5000/success。
要确认实际收费,请点击 Stripe 仪表盘上的“支付”返回:
回顾一下,我们使用密钥在服务器上创建一个惟一的签出会话 ID。这个 ID 随后被用来创建一个结帐实例,最终用户在点击支付按钮后被重定向到这个实例。收费发生后,他们会被重定向回成功页面。
一定要测试出一个被取消的付款。
工作流程:
-
获取可发布密钥从客户端向服务器发送 AJAX 请求,请求可发布密钥用键响应使用键创建 Stripe.js 的新实例
-
创建结账会话向服务器发送另一个 AJAX 请求,请求一个新的结帐会话 ID生成新的结账会话并发回 ID重定向到用户完成购买的结账页面
-
适当地重定向用户支付成功后重定向至成功页面取消付款后重定向至取消页面
-
用条纹网钩确认付款
- 设置 webhook 端点
- 使用条带 CLI 测试端点
- 用条带注册端点
用条纹网钩确认付款
我们的应用程序在这一点上工作得很好,但我们仍然不能以编程方式确认支付,或者在支付成功时运行一些代码。我们已经在用户结帐后将他们重定向到成功页面,但是我们不能只依赖那个页面,因为付款确认是异步发生的。
Stripe 和一般编程中有两种类型的事件:同步事件,具有即时效果和结果(例如,创建一个客户),异步事件,没有即时结果(例如,确认付款)。因为支付确认是异步完成的,用户可能会在他们的支付被确认之前和我们收到他们的资金之前被重定向到成功页面。
当支付完成时,获得通知的最简单的方法之一是使用回调或所谓的 Stripe webhook 。我们需要在应用程序中创建一个简单的端点,每当事件发生时(例如,当用户购买 T 恤衫时),Stripe 就会调用这个端点。通过使用 webhooks,我们可以绝对肯定支付成功。
为了使用 webhooks,我们需要:
- 设置 webhook 端点
- 使用条带 CLI 测试端点
- 用条带注册端点
这一部分由尼克·托马兹奇撰写。
端点
创建一个名为stripe_webhook
的新路径,它会在每次支付成功时打印一条消息:
`# app.py
@app.route("/webhook", methods=["POST"])
def stripe_webhook():
payload = request.get_data(as_text=True)
sig_header = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, stripe_keys["endpoint_secret"]
)
except ValueError as e:
# Invalid payload
return "Invalid payload", 400
except stripe.error.SignatureVerificationError as e:
# Invalid signature
return "Invalid signature", 400
# Handle the checkout.session.completed event
if event["type"] == "checkout.session.completed":
print("Payment was successful.")
# TODO: run some custom code here
return "Success", 200`
stripe_webhook
现在作为我们的 webhook 端点。这里,我们只寻找每当结帐成功时调用的checkout.session.completed
事件,但是您可以对其他条带事件使用相同的模式。
确保将request
导入添加到顶部:
`from flask import Flask, jsonify, render_template, request`
测试 webhook
我们将使用 Stripe CLI 来测试 webhook。
一旦下载并安装了,在新的终端窗口中运行以下命令,登录到您的 Stripe 帐户:
此命令应生成一个配对代码:
`Your pairing code is: peach-loves-classy-cozy
This pairing code verifies your authentication with Stripe.
Press Enter to open the browser (^C to quit)`
通过按 Enter,CLI 将打开您的默认 web 浏览器,并请求访问您的帐户信息的权限。请继续并允许访问。回到您的终端,您应该看到类似于以下内容的内容:
`> Done! The Stripe CLI is configured for Flask Stripe Test with account id acct_<ACCOUNT_ID>
Please note: this key will expire after 90 days, at which point you'll need to re-authenticate.`
接下来,我们可以开始侦听条带事件,并使用以下命令将它们转发到我们的端点:
`$ stripe listen --forward-to 127.0.0.1:5000/webhook`
这也将生成一个 webhook 签名密码:
`> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (^C to quit)`
为了初始化端点,将密码保存为另一个环境变量,如下所示:
`(env)$ export STRIPE_ENDPOINT_SECRET=<YOUR_STRIPE_ENDPOINT_SECRET>`
接下来,像这样将它添加到stripe_keys
字典中:
`# app.py
stripe_keys = {
"secret_key": os.environ["STRIPE_SECRET_KEY"],
"publishable_key": os.environ["STRIPE_PUBLISHABLE_KEY"],
"endpoint_secret": os.environ["STRIPE_ENDPOINT_SECRET"], # new
}`
Stripe 现在会将事件转发到我们的端点。要测试,通过4242 4242 4242 4242
运行另一个测试支付。在您的终端中,您应该会看到Payment was successful.
消息。
一旦完成,停止stripe listen --forward-to 127.0.0.1:5000/webhook
过程。
如果您想识别进行购买的用户,可以使用 client_reference_id 将某种用户标识符附加到条带会话。
例如:
@app.route("/create-checkout-session") def create_checkout_session(): domain_url = "http://127.0.0.1:5000/" stripe.api_key = stripe_keys["secret_key"] try: checkout_session = stripe.checkout.Session.create( # new client_reference_id=current_user.id if current_user.is_authenticated else None, success_url=domain_url + "success?session_id={CHECKOUT_SESSION_ID}", cancel_url=domain_url + "cancelled", payment_method_types=["card"], mode="payment", line_items=[ { "name": "T-shirt", "quantity": 1, "currency": "usd", "amount": "2000", } ] ) return jsonify({"sessionId": checkout_session["id"]}) except Exception as e: return jsonify(error=str(e)), 403
注册端点
最后,在部署你的应用程序后,你可以在 Stripe 仪表板中注册端点,在开发者> Webhooks 下。这将生成一个 webhook 签名密码,用于您的生产应用程序。
例如:
工作流程:
-
获取可发布密钥从客户端向服务器发送 AJAX 请求,请求可发布密钥用键响应使用键创建 Stripe.js 的新实例
-
创建结账会话向服务器发送另一个 AJAX 请求,请求一个新的结帐会话 ID生成新的结账会话并发回 ID重定向到用户完成购买的结账页面
-
适当地重定向用户支付成功后重定向至成功页面取消付款后重定向至取消页面
-
用条纹网钩确认付款设置 webhook 端点使用条带 CLI 测试端点用条带注册端点
后续步骤
在生产中,你需要有 HTTPS,这样你的连接是安全的。您可能还想将domain_url
存储为一个环境变量。最后,在创建结帐会话之前,最好确认在/create-checkout-session
路线中使用了正确的产品和价格。为此,您可以:
- 将您的每个产品添加到数据库中。
- 然后,当您动态创建产品页面时,将产品数据库 ID 和价格存储在 purchase 按钮的数据属性中。
- 更新
/create-checkout-session
路由以仅允许 POST 请求。 - 更新 JavaScript 事件监听器,从数据属性中获取产品信息,并将它们与 AJAX POST 请求一起发送到
/create-checkout-session
路由。 - 在创建结帐会话之前,解析路由处理程序中的 JSON 有效负载,并确认产品存在且价格正确。
干杯!
--
从 GitHub 上的flask-strip-check outrepo 中抓取代码。
烧瓶教程
描述
Flask 是一个 Python web 框架,用于构建 web 应用程序。它是轻量级的,所以它不会为你做很多决定。换句话说,你可以决定你想要如何实现事情。本质上,Flask 简单而可扩展,非常适合开发 RESTful APIs 和微服务。
TestDriven.io 上的教程和文章更接近高级,涵盖了 Docker 容器化、部署、支付处理以及将 Flask 与 React 和 Vue 等前端框架相结合等主题。
通过创建一个静态站点并将其部署到 Netlify,利用 Python 和 Flask 的 JAMstack。
本教程展示了如何使用 Flask、WhiteNoise 和 Amazon CloudFront 管理静态文件。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2023 年 2 月 10 日
常用的 web 身份验证方法。
在本文中,我们将从教育和开发的角度来看 Django 和 Flask 的最佳用例,以及它们的独特之处。
在 Python 应用程序中启用多区域支持。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 11 月 14 日
向 Flask 应用程序添加社交认证。
部署一个带有 PostgreSQL 的 Flask 应用程序进行渲染。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 10 月 14 日
这篇文章着眼于什么是 CSRF 以及如何在 Flask 中防止 CSRF 攻击。
将 Flask 配置为与 Postgres、Nginx 和 Gunicorn 一起在 Docker 上运行。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 9 月 23 日
将基于会话的身份验证添加到由 Flask 和 Svelte 支持的单页面应用程序(SPA)中。
本教程着眼于如何用 Flask 和 Stripe 处理订阅支付。
使用 APIFairy 构建一个 Flask API。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 4 月 18 日
将 htmx 和 Tailwind CSS 添加到 Flask 中,以提高开发人员的工作效率。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 3 月 28 日
有兴趣从 Flask 转到 FastAPI 吗?本文比较和对比了 Flask 和 FastAPI 中的常见模式。
将一个 Flask 应用程序部署到 AWS Elastic Beanstalk。
高级查看应用程序和请求上下文如何在 Flask 中工作。
对 TDD 如何工作感兴趣?本指南将引导您从头到尾使用现代工具和技术完成整个过程。
查看如何配置 Celery 来处理 Flask 应用程序中的长时间运行的任务。
查看如何配置 Redis 队列(RQ)来处理 Flask 应用程序中的长时间运行的任务。
快速将 Stripe 添加到 Flask 应用程序,以便接受付款。
Flask 中应用程序和请求上下文如何工作的概述。
pytest 测试烧瓶应用指南。
使用 Hashicorp 的 Vault 和 Consul 为 Flask web 应用程序创建动态 Postgres 凭据的真实示例。
向 Flask、Redis Queue 和 Amazon SES 的新注册用户发送确认电子邮件。
- 发帖者 郄佳朝 Medlin
- 最后更新于2021 年 11 月 3 日
结合烧瓶和 Vue 的三种不同方法。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2021 年 6 月 18 日
本教程详细介绍了如何配置 Flask 与 Postgres、Gunicorn、Traefik 和 Let's Encrypt 一起在 Docker 上运行。
本文着眼于 Flask 2.0 的新异步功能,以及如何在 Flask 项目中利用它。
如何将基于 Flask 的微服务(以及 Postgres 和 Vue.js)部署到 Kubernetes 集群的分步演练。
了解什么是 Werkzeug,以及 Flask 如何将其用于核心 HTTP 功能。
通过 Flask-Session 和 Redis 了解如何在 Flask 中使用服务器端会话。
这篇文章介绍了如何在 Docker Swarm 上运行 Flask 应用程序。
了解会话如何在 Flask 中工作。
使用 Stripe、Vue.js 和 Flask 开发一个销售产品的 web 应用程序。
使用 Gitlab CI 将 Flask 和 Vue 支持的全栈 web 应用打包并部署到 Heroku。
如何用 Vue 和 Flask 设置一个基本的 CRUD 应用程序的分步演练。
生产中的流水作业
在本教程中,我们将看看如何用 Docker 配置 Flower 在 Nginx 后面运行。我们还将设置基本身份验证。
码头工人
假设您使用 Redis 作为您的消息代理,您的 Docker 编写配置将类似于:
`version: '3.7' services: redis: image: redis expose: - 6379 worker: build: context: . dockerfile: Dockerfile command: ['celery', '-A', 'app.app', 'worker', '-l', 'info'] environment: - BROKER_URL=redis://redis:6379 - RESULT_BACKEND=redis://redis:6379 depends_on: - redis flower: image: mher/flower:0.9.7 command: ['flower', '--broker=redis://redis:6379', '--port=5555'] ports: - 5557:5555 depends_on: - redis`
截至发稿时,官方 Flower Docker 图像还没有版本> 0.9.7 的标签,这就是为什么使用了
0.9.7
标签。更多信息见1 . 0 . 0 版本的 Docker 标签。想检查正在使用的版本吗?运行
docker-compose exec flower pip freeze
。
您可以在 GitHub 上的芹菜花码头 repo 中查看这个样本代码。
app.py :
`import os
from celery import Celery
os.environ.setdefault('CELERY_CONFIG_MODULE', 'celery_config')
app = Celery('app')
app.config_from_envvar('CELERY_CONFIG_MODULE')
@app.task
def add(x, y):
return x + y`
芹菜 _ 配置. py :
`from os import environ
broker_url = environ['BROKER_URL']
result_backend = environ['RESULT_BACKEND']`
Dockerfile :
`FROM python:3.10
WORKDIR /usr/src/app
RUN pip install celery[redis]==5.2.7
COPY app.py .
COPY celery_config.py .`
快速测试:
`$ docker-compose build
$ docker-compose up -d --build
$ docker-compose exec worker python
>> from app import add
>> task = add.delay(5, 5)
>>>
>>> task.status
'SUCCESS'
>>> task.result
10`
您应该能够在 http://localhost:5557/ 查看仪表板。
Nginx
要在 Nginx 之后运行 flower,首先将 Nginx 添加到 Docker Compose 配置中:
`version: '3.7' services: redis: image: redis expose: - 6379 worker: build: context: . dockerfile: Dockerfile command: ['celery', '-A', 'app.app', 'worker', '-l', 'info'] environment: - BROKER_URL=redis://redis:6379 - RESULT_BACKEND=redis://redis:6379 depends_on: - redis flower: image: mher/flower:0.9.7 command: ['flower', '--broker=redis://redis:6379', '--port=5555'] expose: # new - 5555 depends_on: - redis # new nginx: image: nginx:latest volumes: - ./nginx.conf:/etc/nginx/nginx.conf ports: - 80:80 depends_on: - flower`
engine . conf:
`events {} http { server { listen 80; # server_name your.server.url; location / { proxy_pass http://flower:5555; proxy_set_header Host $host; proxy_redirect off; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } }`
快速测试:
`$ docker-compose down
$ docker-compose build
$ docker-compose up -d --build
$ docker-compose exec worker python
>> from app import add
>> task = add.delay(7, 7)
>>>
>>> task.status
'SUCCESS'
>>> task.result
14`
这一次,仪表板应该可以在 http://localhost/ 上看到。
证明
要添加基本认证,首先创建一个 htpasswd 文件。例如:
`$ htpasswd -c htpasswd michael`
接下来,向nginx
服务添加另一个卷,以将 htpasswd 从主机挂载到 /etc/nginx/。容器中的 htpasswd :
`nginx: image: nginx:latest volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./htpasswd:/etc/nginx/.htpasswd # new ports: - 80:80 depends_on: - flower`
最后,为了保护“/”路由,将 auth_basic 和 auth_basic_user_file 指令添加到位置块:
`events {} http { server { listen 80; # server_name your.server.url; location / { proxy_pass http://flower:5555; proxy_set_header Host $host; proxy_redirect off; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; } } }`
有关使用 Nginx 设置基本身份验证的更多信息,请查看使用 HTTP 基本身份验证限制访问指南。
最终测试:
`$ docker-compose down
$ docker-compose build
$ docker-compose up -d --build
$ docker-compose exec worker python
>> from app import add
>> task = add.delay(9, 9)
>>>
>>> task.status
'SUCCESS'
>>> task.result
18`
再次导航到 http://localhost/ 。这一次应该会提示您输入用户名和密码。
前端教程
描述
前端 web 开发(也称为客户端开发)是使用 HTML、CSS 和 JavaScript(以及 JavaScript 库和框架)为 web 和移动应用程序构建用户可以查看和交互的图形用户界面的过程。换句话说:这是一种软件工程,为网络和移动设备上的视觉和交互提供动力。
由于 React、Vue 和 Angular 等现代前端框架,web 开发已经显著转向前端。越来越多的情况是,后端只是为前端提供数据来管理、准备和显示给最终用户。
TestDriven.io 上的教程和文章讲述了如何用 React、Vue、Angular、Svelte、htmx 和 Tailwind CSS 构建和测试现代用户界面。
通过创建一个静态站点并将其部署到 Netlify,利用 Python 和 Flask 的 JAMstack。
如何用 Vue 和 FastAPI 设置一个基本的 CRUD 应用程序的分步演练。
测试 Vue 应用程序中的 Pinia 数据存储。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 9 月 23 日
将基于会话的身份验证添加到由 Flask 和 Svelte 支持的单页面应用程序(SPA)中。
本文作为单元测试 Vue 组件的指南。
提高 Pyodide 代码的可维护性,用 PouchDB 增加一个持久数据层。
将 web workers 配置为使用 Pyodide,并在浏览器中使用 Pandas 分析数据。
用 Python 和 Pyodide 构建单页面应用。
用 FastAPI 构建 CRUD app,React。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 4 月 18 日
将 htmx 和 Tailwind CSS 添加到 Flask 中,以提高开发人员的工作效率。
使用 WebAssembly (WASM)、via Pyodide 和 CodeMirror 在浏览器中构建 Python 代码编辑器。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 4 月 3 日
在 Django 中加入 htmx 和 Tailwind CSS,提高开发者生产力。
- 发帖者 郄佳朝 Medlin
- 最后更新于2021 年 11 月 3 日
结合烧瓶和 Vue 的三种不同方法。
在第 1 部分中,我们将建立整个项目,然后用测试驱动开发来开发 UI。
在第 2 部分中,我们将在开始添加基本的计算器功能之前完成 UI。
本文着眼于实现单页面应用程序(SPA)的新方法——基于 WebSockets 的 HTML。
使用 Stripe、Vue.js 和 Flask 开发一个销售产品的 web 应用程序。
如何用 Vue 和 Flask 设置一个基本的 CRUD 应用程序的分步演练。
- 由 发布尼克·托马齐奇
- 最后更新于2020 年 12 月 14 日
将基于会话的身份验证添加到由 Django 和 React 支持的单页面应用程序(SPA)中。
本文是对 CSS Grid 的介绍,您将看到如何用简单的 CSS 创建复杂的基于网格的布局。
本文着眼于如何使用 useContext 和 useReducer 挂钩来使 React 应用程序及其状态管理变得干净而高效。
React 钩子的介绍。
在下面的教程中,我们将带您了解如何配置 Cypress 来与 CircleCI 并行运行测试。
这篇文章着眼于如何将 Cypress 引入到您的测试驱动开发工作流中。
这篇文章详细介绍了如何使用 Cypress 和 Docker 为 Angular 应用程序添加端到端测试。
这篇文章整理了网上一些从 Heroku 迁移到 AWS 的最佳教程。
从 Travis CI 获取电报通知
原文:https://testdriven.io/blog/getting-telegram-notifications-from-travis-ci/
我最近在 TestDriven.io 上完成了关于 Docker、Flask 和 React 的微服务课程,在课程进行到一半时,我开始有点不耐烦 Travis CI 花了多长时间来完成我的构建,以便我可以将新的 Docker 映像推送到 AWS 并继续下一步。当构建完成时,我需要一个通知,这样我就可以离开去做别的事情,而不是盯着构建日志等待它完成。Travis 自带了几个通知选项,但我想通过电报获得通知,这是不支持的,所以我决定推出自己的通知。
设置电报机器人
Telegram 提供了一个创建看起来相对容易使用的机器人的 API,所以我开始研究官方文档。第一步是创建一个专用的机器人,这实际上是通过 Telegram 应用程序本身,通过与“机器人父亲”进行交互来完成的。如果你一直跟着,我会假设你已经有一个电报帐户,所以点击 BotFather 链接应该会在你的聊天客户端打开一个新的对话。在那里,您可以键入/start
来获得可能的命令列表。
键入/newbot
将开始这个过程,首先要求显示名称(当从您的机器人获得消息时,您将看到这个名称),然后是用户名。对于这个例子,我选择“TestDriven TestBot”作为显示名称,选择“testdriven_test_bot”作为用户名。请注意,您的用户名必须以“bot”的某种变体结尾,正如文档和 BotFather 所概述的那样。一旦完成,BotFather 会给你一个到你的新机器人的链接,以及一个 API 令牌(在这篇文章的其余部分我将把它称为<TOKEN>
),你会想把它保存在某个地方。
首先,这个机器人根本不做什么。在我点击对话链接之前,我从终端使用 HTTPie 检查了状态(你可以使用 curl,或者浏览器,或者你喜欢的任何东西...我只是更喜欢 HTTPie 的格式化输出):
`# curl https://api.telegram.org/bot<TOKEN>/getUpdates
$ http https://api.telegram.org/bot<TOKEN>/getUpdates
HTTP/1.1 200 OK
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Length,Content-Type,Date,Server,Connection
Connection: keep-alive
Content-Length: 23
Content-Type: application/json
Date: Thu, 28 Feb 2019 17:06:45 GMT
Server: nginx/1.12.2
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
{
"ok": true,
"result": []
}`
如果你跟着做,一定要把
<TOKEN>
替换成你自己机器人的令牌,注意是bot<TOKEN>
,而不仅仅是<TOKEN>
。
上面的状态显示机器人还活着,但是没有任何当前对话。我点击了僵尸父亲消息中的链接(t.me/testdriven_test_bot)并开始了对话,然后再次检查了状态:
`# curl https://api.telegram.org/bot<TOKEN>/getUpdates
$ http https://api.telegram.org/bot<TOKEN>/getUpdates
HTTP/1.1 200 OK
...
{
"ok": true,
"result": [
{
"message": {
"chat": {
"first_name": "Anson",
"id": 488404184,
"last_name": "VanDoren",
},
...
}
]
}`
我刚刚开始的对话现在出现了,我需要对话 id(在本例中为488404184
),下面我称之为<CHAT_ID>
。现在我有了令牌和聊天 id,我将测试发送一条消息:
`# curl -s -X POST https://api.telegram.org/bot<TOKEN>/sendMessage \
# -d chat_id=<CHAT_ID> \
# -d text="The bot speaks"
$ http POST https://api.telegram.org/bot<TOKEN>/sendMessage \
chat_id=<CHAT_ID> \
text="The bot speaks"
HTTP/1.1 200 OK
...
{
"ok": true,
"result": {
"chat": {
"first_name": "Anson",
"id": 488404184,
"last_name": "VanDoren",
},
"date": 1551374658,
"from": {
"first_name": "TestDriven TestBot",
"id": 746763956,
"is_bot": true,
"username": "testdriven_test_bot"
},
"message_id": 2,
"text": "The bot speaks"
}
}`
看起来 API 很高兴,我也可以在我的 Telegram 客户端中看到发送的消息:
到目前为止,一切看起来都很好,所以现在是时候将它集成到 Travis 中了。
设置 Travis 环境变量
为了让 Travis 在构建完成时给我发送消息,我需要让它知道我的 Telegram API 令牌和聊天 ID。我不想在我的 .travis.yml 文件或我提交给公共回购的任何其他文件中包含任何一个,所以有两个选择,要么在构建脚本中加密它们,要么将它们设置为 travis 环境变量。关于这两个选项的更多信息可以在 Travis 文档中找到,但是因为我可以对所有的构建/阶段/版本使用相同的脚本,所以我选择了环境变量。
为了创建环境变量,我导航到我的 repo,点击“更多选项>设置”,然后向下滚动到“环境变量”部分。我添加了两个变量,TELEGRAM_CHAT_ID
和TELEGRAM_TOKEN
。这是我在上面使用的两个相同的变量,但是为了避免 Travis 设置中的歧义,我对它们进行了重命名。
修改构建脚本
接下来,我在我的 .travis.yml 文件中添加了一个after_script
步骤。我可以很容易地直接在构建脚本文件本身中完成一个简单的通知,但是我想添加一些额外的细节。因此,我选择编写一个单独的 shell 脚本来封装它,然后在我的构建配置文件中引用该脚本:
`after_script: - bash ./telegram_notification.sh`
编写通知脚本
在我的项目根目录中,我创建了一个名为telegram_notification.sh
的 bash 脚本:
`#!/bin/sh
# Get the token from Travis environment vars and build the bot URL:
BOT_URL="https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage"
# Set formatting for the message. Can be either "Markdown" or "HTML"
PARSE_MODE="Markdown"
# Use built-in Travis variables to check if all previous steps passed:
if [ $TRAVIS_TEST_RESULT -ne 0 ]; then
build_status="failed"
else
build_status="succeeded"
fi
# Define send message function. parse_mode can be changed to
# HTML, depending on how you want to format your message:
send_msg () {
curl -s -X POST ${BOT_URL} -d chat_id=$TELEGRAM_CHAT_ID \
-d text="$1" -d parse_mode=${PARSE_MODE}
}
# Send message to the bot with some pertinent details about the job
# Note that for Markdown, you need to escape any backtick (inline-code)
# characters, since they're reserved in bash
send_msg "
-------------------------------------
Travis build *${build_status}!*
\`Repository: ${TRAVIS_REPO_SLUG}\`
\`Branch: ${TRAVIS_BRANCH}\`
*Commit Msg:*
${TRAVIS_COMMIT_MESSAGE} [Job Log here](${TRAVIS_JOB_WEB_URL})
--------------------------------------
"`
测试它
将您的更改提交到 .travis.yml 和新的 telegram_notification.sh 文件,推送到 Github,等待 travis 完成构建。
这就够了!您可以修改消息以适应您的需要和口味,Travis 有相当多的内置环境变量,如果您愿意,您可以将它们放入您的通知脚本中。为了方便起见,上面的脚本也可以作为 GitHub gist 使用。
一旦你建立并运行了你的机器人,将它混合到其他项目中也是非常容易的。我在跨几种不同的语言、框架和平台的几个不同的部署脚本中使用同一个 bot。在任何可以发出 HTTP 请求的地方,都可以通过 bot 触发通知!
使用 Docker 部署自托管 GitHub 动作运行器
在本教程中,我们将详细介绍如何使用 Docker 将自托管的GitHub Actionsrunner 部署到 DigitalOcean 。我们还将看到如何在垂直方向(通过 Docker Compose)和水平方向(通过 Docker Swarm)扩展跑步者。
依赖关系:
- 文档编号 v19.03.8
- 坞站-复合 v1.29.2
- 对接机 v0.16.2
GitHub 操作
GitHub Actions 是一个持续集成和交付(CI/CD)解决方案,与 GitHub 完全集成。GitHub Actions 工作流中的作业在名为 runners 的应用程序上运行。你既可以使用 GitHub 托管的运行程序,也可以在自己的基础设施上运行自己的自托管运行程序。
跑步者既可以添加到个人存储库中,也可以添加到组织中。我们将采用后一种方法,以便运行人员可以处理来自同一个 GitHub 组织中多个存储库的作业。
开始之前,你需要创建一个个人访问令牌。在您的开发者设置中,点击“个人访问令牌”。然后,单击“生成新令牌”。提供描述性注释并选择repo
、workflow
和admin:org
范围。
数字海洋设置
首先,注册一个数字海洋账户,如果你还没有的话,然后生成一个访问令牌,这样你就可以访问数字海洋 API。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
安装 Docker Machine 如果你的本地机器上还没有的话。
旋转出一个叫做runner-node
的小液滴:
`$ docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--digitalocean-region "nyc1" \
--digitalocean-image "debian-10-x64" \
--digitalocean-size "s-4vcpu-8gb" \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
runner-node;`
Docker 部署
SSH 进入 droplet:
`$ docker-machine ssh runner-node`
添加以下 docker 文件,注意注释:
`# base
FROM ubuntu:18.04
# set the github runner version
ARG RUNNER_VERSION="2.283.3"
# update the base packages and add a non-sudo user
RUN apt-get update -y && apt-get upgrade -y && useradd -m docker
# install python and the packages the your code depends on along with jq so we can parse JSON
# add additional packages as necessary
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
curl jq build-essential libssl-dev libffi-dev python3 python3-venv python3-dev python3-pip
# cd into the user directory, download and unzip the github actions runner
RUN cd /home/docker && mkdir actions-runner && cd actions-runner \
&& curl -O -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
&& tar xzf ./actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
# install some additional dependencies
RUN chown -R docker ~docker && /home/docker/actions-runner/bin/installdependencies.sh
# copy over the start.sh script
COPY start.sh start.sh
# make the script executable
RUN chmod +x start.sh
# since the config and run script for actions are not allowed to be run by root,
# set the user to "docker" so all subsequent commands are run as the docker user
USER docker
# set the entrypoint to the start.sh script
ENTRYPOINT ["./start.sh"]`
用最新版本的 runner 更新
RUNNER_VERSION
变量,可以在这里找到。
同样添加 start.sh 文件:
`#!/bin/bash
ORGANIZATION=$ORGANIZATION
ACCESS_TOKEN=$ACCESS_TOKEN
REG_TOKEN=$(curl -sX POST -H "Authorization: token ${ACCESS_TOKEN}" https://api.github.com/orgs/${ORGANIZATION}/actions/runners/registration-token | jq .token --raw-output)
cd /home/docker/actions-runner
./config.sh --url https://github.com/${ORGANIZATION} --token ${REG_TOKEN}
cleanup() {
echo "Removing runner..."
./config.sh remove --unattended --token ${REG_TOKEN}
}
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
./run.sh & wait $!`
ORGANIZATION
和ACCESS_TOKEN
(GitHub 个人访问令牌)环境变量用于请求跑步者注册令牌。
有关更多信息,请查看文档中的为组织创建注册令牌一节。
注意到:
`cleanup() {
echo "Removing runner..."
./config.sh remove --unattended --token ${REG_TOKEN}
}
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
./run.sh & wait $!`
本质上,当容器停止时,用于在容器停止时移除流道的清理逻辑将会执行。
关于 shell 信号处理和
wait
的更多信息,请查看这个堆栈交换答案。
构建映像并在分离模式下旋转容器:
`$ docker build --tag runner-image .
$ docker run \
--detach \
--env ORGANIZATION=<YOUR-GITHUB-ORGANIZATION> \
--env ACCESS_TOKEN=<YOUR-GITHUB-ACCESS-TOKEN> \
--name runner \
runner-image`
确保分别用您的组织和个人访问令牌替换
<YOUR-GITHUB-ORGANIZATION>
和<YOUR-GITHUB-ACCESS-TOKEN>
。
快速查看一下容器日志:
您应该会看到类似如下的内容:
`--------------------------------------------------------------------------------
| ____ _ _ _ _ _ _ _ _ |
| / ___(_) |_| | | |_ _| |__ / \ ___| |_(_) ___ _ __ ___ |
| | | _| | __| |_| | | | | '_ \ / _ \ / __| __| |/ _ \| '_ \/ __| |
| | |_| | | |_| _ | |_| | |_) | / ___ \ (__| |_| | (_) | | | \__ \ |
| \____|_|\__|_| |_|\__,_|_.__/ /_/ \_\___|\__|_|\___/|_| |_|___/ |
| |
| Self-hosted runner registration |
| |
--------------------------------------------------------------------------------
# Authentication
√ Connected to GitHub
# Runner Registration
Enter the name of the runner group to add this runner to: [press Enter for Default]
Enter the name of runner: [press Enter for 332d0614b5e9]
This runner will have the following labels: 'self-hosted', 'Linux', 'X64'
Enter any additional labels (ex. label-1,label-2): [press Enter to skip]
√ Runner successfully added
√ Runner connection is good
# Runner settings
Enter name of work folder: [press Enter for _work]
√ Settings Saved.
√ Connected to GitHub
2021-10-23 22:36:01Z: Listening for Jobs`
然后,在您的 GitHub 组织名称下,点击“设置”。在左侧栏中,点击“动作”,然后点击“跑步者”。您应该会看到一个注册的跑步者:
为了进行测试,将run-on:[自托管] 添加到存储库的工作流 YAML 文件中。
例如:
`name: Sample Python on: [push] jobs: build: runs-on: [self-hosted] steps: - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - name: Install dependencies run: | python3 -m pip install --upgrade pip pip3 install pytest - name: Test with pytest run: | python3 -m pytest`
然后,运行新的构建。回到您的终端,在 Docker 日志中,您应该看到作业的状态:
`2021-10-24 00:46:26Z: Running job: build
2021-10-24 00:46:34Z: Job build completed with result: Succeeded`
如果您尝试运行作业的存储库是公共的,那么您必须更新默认的 runner 组以允许公共存储库。更多信息,请查看公共存储库的自托管跑步者安全性。
完成后取下容器:
再次查看日志。您应该看到:
`Removing runner...
# Runner removal
√ Runner removed successfully
√ Removed .credentials
√ Removed .runner`
该跑步者在您的 GitHub 组织的操作设置中应该不再可用:
移除容器:
使用 Docker 合成进行垂直缩放
想在一个液滴上旋转多个转轮吗?
首先将下面的 docker-compose.yml 文件添加到框中:
`version: '3' services: runner: build: . environment: - ORGANIZATION=<YOUR-GITHUB-ORGANIZATION> - ACCESS_TOKEN=<YOUR-GITHUB-ACCESS-TOKEN>`
确保分别用您的组织和个人访问令牌替换<YOUR-GITHUB-ORGANIZATION>
和<YOUR-GITHUB-ACCESS-TOKEN>
。
按照官方安装指南在 droplet 上下载安装 Docker Compose,然后构建镜像:
启动两个容器实例:
`$ docker-compose up --scale runner=2 -d`
你应该在 GitHub 上看到两个跑步者:
开始两次构建。打开撰写日志:
您应该会看到类似这样的内容:
`runner_2 | 2021-10-24 00:52:56Z: Running job: build
runner_1 | 2021-10-24 00:52:58Z: Running job: build
runner_2 | 2021-10-24 00:53:04Z: Job build completed with result: Succeeded
runner_1 | 2021-10-24 00:53:11Z: Job build completed with result: Succeeded`
你可以像这样缩小:
`$ docker-compose up --scale runner=1 -d`
退出 SSH 会话并销毁机器/液滴:
`$ docker-machine rm runner-node -y
$ eval $(docker-machine env -u)`
使用 Docker Swarm 进行水平缩放
想要在多个 DigitalOcean 微滴之间进行水平缩放?
配置水滴
将数字海洋访问令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
旋转三个新的数字海洋液滴:
`$ for i in 1 2 3; do
docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--digitalocean-region "nyc1" \
--digitalocean-image "debian-10-x64" \
--digitalocean-size "s-4vcpu-8gb" \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
runner-node-$i;
done`
在第一个节点runner-node-1
上初始化群模式:
`$ docker-machine ssh runner-node-1 -- docker swarm init --advertise-addr $(docker-machine ip runner-node-1)`
使用上一个命令输出中的 join 令牌将剩余的两个节点作为 workers 添加到群中:
`$ for i in 2 3; do
docker-machine ssh runner-node-$i -- docker swarm join --token YOUR_JOIN_TOKEN HOST:PORT;
done`
例如:
`$ for i in 2 3; do
docker-machine ssh runner-node-$i -- docker swarm join --token SWMTKN-1-4a341wv2n8c2c0cn3f9d0nwxndpohwuyr58vtal63wx90spfoo-09vdgcfarp6oqxnncgfjyrh0i 161.35.12.185:2377;
done`
您应该看到:
`This node joined a swarm as a worker.
This node joined a swarm as a worker.`
构建 Docker 映像
这一次,让我们在本地构建映像,并将其推送到 Docker Hub 映像注册中心。
Dockerfile:
`# base
FROM ubuntu:18.04
# set the github runner version
ARG RUNNER_VERSION="2.283.3"
# update the base packages and add a non-sudo user
RUN apt-get update -y && apt-get upgrade -y && useradd -m docker
# install python and the packages the your code depends on along with jq so we can parse JSON
# add additional packages as necessary
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
curl jq build-essential libssl-dev libffi-dev python3 python3-venv python3-dev python3-pip
# cd into the user directory, download and unzip the github actions runner
RUN cd /home/docker && mkdir actions-runner && cd actions-runner \
&& curl -O -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
&& tar xzf ./actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
# install some additional dependencies
RUN chown -R docker ~docker && /home/docker/actions-runner/bin/installdependencies.sh
# copy over the start.sh script
COPY start.sh start.sh
# make the script executable
RUN chmod +x start.sh
# since the config and run script for actions are not allowed to be run by root,
# set the user to "docker" so all subsequent commands are run as the docker user
USER docker
# set the entrypoint to the start.sh script
ENTRYPOINT ["./start.sh"]`
start.sh :
`#!/bin/bash
ORGANIZATION=$ORGANIZATION
ACCESS_TOKEN=$ACCESS_TOKEN
REG_TOKEN=$(curl -sX POST -H "Authorization: token ${ACCESS_TOKEN}" https://api.github.com/orgs/${ORGANIZATION}/actions/runners/registration-token | jq .token --raw-output)
cd /home/docker/actions-runner
./config.sh --url https://github.com/${ORGANIZATION} --token ${REG_TOKEN}
cleanup() {
echo "Removing runner..."
./config.sh remove --unattended --token ${REG_TOKEN}
}
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
./run.sh & wait $!`
建立形象:
`$ docker build --tag <your-docker-hub-username>/actions-image:latest .`
确保用您的 Docker Hub 用户名替换<your-docker-hub-username>
。然后,将映像推送到注册表:
`$ docker push <your-docker-hub-username>/actions-image:latest`
部署
要部署堆栈,首先添加一个 Docker 合成文件:
`version: '3' services: runner: image: <your-docker-hub-username>/actions-image:latest deploy: mode: replicated replicas: 1 placement: constraints: - node.role == worker environment: - ORGANIZATION=<YOUR-GITHUB-ORGANIZATION> - ACCESS_TOKEN=<YOUR-GITHUB-ACCESS-TOKEN>`
再次更新<your-docker-hub-username>
以及环境变量。
将 Docker 守护进程指向runner-node-1
并部署堆栈:
`$ eval $(docker-machine env runner-node-1)
$ docker stack deploy --compose-file=docker-compose.yml actions`
列出堆栈中的服务:
`$ docker stack ps -f "desired-state=running" actions`
您应该会看到类似如下的内容:
`ID NAME IMAGE NODE DESIRED STATE
xhh3r8rfhh46 actions_runner.1 mjhea0/actions-image:latest runner-node-2 Running`
确保跑步者在 GitHub 上:
让我们再添加两个节点:
`$ docker service scale actions_runner=3
actions_runner scaled to 3
overall progress: 3 out of 3 tasks
1/3: running
2/3: running
3/3: running
verify: Service converged`
验证:
开始几项工作,并确保它们成功完成。
缩小到单个流道:
`$ docker service scale actions_runner=1
actions_runner scaled to 1
overall progress: 1 out of 1 tasks
1/1: running
verify: Service converged`
验证:
完成后,将机器/液滴取下:
`$ docker-machine rm runner-node-1 runner-node-2 runner-node-3 -y`
使用 Docker 部署自托管 GitLab CI 运行程序
在本教程中,我们将详细介绍如何使用 Docker 将自托管的 GitLab CI/CD 运行程序部署到数字海洋。
GitLab CI/CD
GitLab CI/CD 是一个持续集成和交付(CI/CD)解决方案,与 GitLab 完全集成。GitLab CI/CD 管道中的作业在称为 runners 的进程上运行。你既可以使用 GitLab 托管的共享运行器,也可以在自己的基础设施上运行自己的自托管运行器。
范围
Runners 可以对 GitLab 实例中的所有项目和组可用,也可以对特定的组或特定的项目(存储库)可用。我们将使用第一种方法,这样我们可以用同一个运行器处理来自多个存储库的作业。
您还可以使用标签来控制运行程序可以运行哪些作业。
关于跑步者范围的更多信息,请看官方文件中的跑步者范围。
码头工人
因为您可能想在工作中运行docker
命令——构建和测试 Docker 容器中运行的应用程序——所以您需要选择以下三种方法之一:
我们将使用套接字绑定方法(Docker-out-of-Docker?)将对接插座捆绑安装到具有容积的容器上。运行 GitLab runner 的容器将能够与 Docker 守护进程通信,从而产生兄弟容器。
虽然我对这些方法没有什么强烈的意见,但我还是推荐阅读为您的 CI 或测试环境使用 Docker-in-Docker?三思而后行。作者 jérme Petazzoni,Docker-in-Docker 的创造者。
如果你对 Docker-in-Docker 方法感兴趣,可以看看定制的 Gitlab CI/CD Runner,用 Docker-in-Docker 进行高速缓存。
数字海洋设置
首先,注册一个数字海洋账户,如果你还没有的话,然后生成一个访问令牌,这样你就可以访问数字海洋 API。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
安装 Docker Machine 如果你的本地机器上还没有的话。
旋转出一个叫做runner-node
的小液滴:
`$ docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--digitalocean-region "nyc1" \
--digitalocean-image "debian-10-x64" \
--digitalocean-size "s-4vcpu-8gb" \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
runner-node;`
Docker 部署
SSH 进入 droplet:
`$ docker-machine ssh runner-node`
创建以下文件和文件夹:
`├── config
│ └── config.toml
└── docker-compose.yml`
将以下内容添加到 docker-compose.yml 文件中:
`version: '3' services: gitlab-runner-container: image: gitlab/gitlab-runner:v14.3.2 container_name: gitlab-runner-container restart: always volumes: - ./config/:/etc/gitlab-runner/ - /var/run/docker.sock:/var/run/docker.sock`
在此,我们:
- 使用了官方 GitLab Runner Docker 图片。
- 增加了 Docker 套接字和“配置”文件夹的容量。
- 将端口 9252 暴露给 Docker 主机。稍后会有更多内容。
按照官方安装指南在 droplet 上下载并安装 Docker Compose,然后旋转容器:
如果您遇到 Docker 编写挂起的问题,请查看这个堆栈溢出问题。
接下来,您需要获得注册令牌和 URL。在您小组的“CI/CD 设置”中,展开“跑步者”部分。一定要禁用共享跑步者。
运行以下命令来注册一个新的跑步者,确保用您的组的注册令牌和 URL 替换<YOUR-GITLAB-REGISTRATION-TOKEN>
和<YOUR-GITLAB-URL>
:
`$ docker-compose exec gitlab-runner-container \
gitlab-runner register \
--non-interactive \
--url <YOUR-GITLAB-URL> \
--registration-token <YOUR-GITLAB-REGISTRATION-TOKEN> \
--executor docker \
--description "Sample Runner 1" \
--docker-image "docker:stable" \
--docker-volumes /var/run/docker.sock:/var/run/docker.sock`
您应该会看到类似如下的内容:
`Runtime platform
arch=amd64 os=linux pid=18 revision=e0218c92 version=14.3.2
Running in system-mode.
Registering runner... succeeded
runner=hvdSfcc1
Runner registered successfully. Feel free to start it, but if it's running already
the config should be automatically reloaded!`
同样,我们使用了 docker 套接字绑定方法,以便docker
命令可以在运行于 runner 上的作业内部运行。
查看 GitLab Runner 命令以了解更多关于
register
命令以及注册和管理跑步者的其他命令。
回到 GitLab,您应该会在您小组的“CI/CD 设置”中看到已注册的跑步者:
通过为您的一个存储库运行 CI/CD 管道来测试它。
回到您的终端,查看一下集装箱日志:
`$ docker logs gitlab-runner-container -f`
您应该会看到作业的状态:
`Checking for jobs... received
job=1721313345 repo_url=https://gitlab.com/testdriven/testing-gitlab-ci.git runner=yK2DqWMQ
Job succeeded
duration_s=32.174537956 job=1721313345 project=30721568 runner=yK2DqWMQ`
配置
记下配置文件 config/config.toml :
`$ cat config/config.toml
concurrent = 1
check_interval = 0
[session_server]
session_timeout = 1800
[[runners]]
name = "Sample Runner 1"
url = "https://gitlab.com/"
token = "yK2DqWMQB1CqPsRx6gwn"
executor = "docker"
[runners.custom_build_dir]
[runners.cache]
[runners.cache.s3]
[runners.cache.gcs]
[runners.cache.azure]
[runners.docker]
tls_verify = false
image = "docker:stable"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
shm_size = 0`
查看高级配置,了解更多可用选项。您可以配置许多东西,例如日志和缓存选项、内存限制和 CPU 数量等等。
因为我们没有利用外部缓存,比如亚马逊 S3 或谷歌云存储,所以删除[runners.cache]
部分。然后,重新启动转轮:
`$ docker-compose exec gitlab-runner-container gitlab-runner restart`
尝试同时运行两个作业。由于并发性设置为 1 - concurrent = 1
-在运行器上一次只能运行一个作业。因此,其中一个作业将保持“挂起”状态,直到第一个作业完成运行。如果您只是为一个小团队设置跑步者,那么您也许可以一次只运行一个任务。随着团队的成长,您会想要尝试并发配置选项:
concurrent
-限制在所有运行程序中全局同时运行的作业数量。limit
-适用于单个跑步者,限制可同时处理的工作数量。默认为0
,表示不应用限制。request_concurrency
-适用于单个跑步者,限制新工作的并发请求数量。默认为1
。
在我们更新并发选项之前,添加一个新的 runner:
`$ docker-compose exec gitlab-runner-container \
gitlab-runner register \
--non-interactive \
--url <YOUR-GITLAB-URL> \
--registration-token <YOUR-GITLAB-REGISTRATION-TOKEN> \
--executor docker \
--description "Sample Runner 2" \
--docker-image "docker:stable" \
--docker-volumes /var/run/docker.sock:/var/run/docker.sock`
然后,像这样更新 config/config.toml :
`concurrent = 4 # NEW check_interval = 0 [session_server] session_timeout = 1800 [[runners]] name = "Sample Runner 1" url = "https://gitlab.com/" token = "yK2DqWMQB1CqPsRx6gwn" executor = "docker" limit = 2 # NEW request_concurrency = 2 # NEW [runners.custom_build_dir] [runners.docker] tls_verify = false image = "docker:stable" privileged = false disable_entrypoint_overwrite = false oom_kill_disable = false disable_cache = false volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"] shm_size = 0 [[runners]] name = "Sample Runner 2" url = "https://gitlab.com/" token = "qi-b3gFzVaX3jRRskJbz" limit = 2 # NEW request_concurrency = 2 # NEW executor = "docker" [runners.custom_build_dir] [runners.cache] [runners.cache.s3] [runners.cache.gcs] [runners.cache.azure] [runners.docker] tls_verify = false image = "docker:stable" privileged = false disable_entrypoint_overwrite = false oom_kill_disable = false disable_cache = false volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"] shm_size = 0`
现在,我们可以跨两个运行程序同时运行四个作业,每个运行程序有两个子流程:
重新启动:
`$ docker-compose exec gitlab-runner-container gitlab-runner restart`
通过运行四个作业来测试它。
请记住,如果您正在构建和测试 Docker 映像,您最终会耗尽磁盘空间。因此,定期删除 Docker 主机上所有未使用的图像和容器是一个好主意。
Example crontab:
`@weekly /usr/bin/docker system prune -f`
--
就是这样!
不要忘记注销跑步者:
`$ docker-compose exec gitlab-runner-container gitlab-runner unregister --all-runners`
然后,回到您的本地机器,关闭机器/droplet:
`$ docker-machine rm runner-node`
基于 Python 的应用程序的 Heroku 替代方案
Heroku 改变了开发人员构建和部署软件的方式,使得构建、部署和扩展应用程序变得更加容易和快速。他们为如何管理云服务制定了各种标准和方法——即十二因素应用——这些标准和方法在今天仍然与基于微服务和云原生应用高度相关。不幸的是,从 2022 年 11 月 28 日开始,Heroku 将停止其免费层。这意味着您将不再能够利用免费的 dynos、Postgres 数据库和 Redis 实例。
关于 Heroku 停止其免费产品层的更多信息,请查看 Heroku 的下一章和对 Heroku 免费资源的反对。
在本文中,您将了解最佳的 Heroku 替代方案(及其优缺点)。
什么是 Heroku?
Heroku 成立于 2007 年,是一个为网络应用提供托管服务的云计算平台。它们提供了抽象的环境,您无需管理底层基础设施,从而轻松管理、部署和扩展 web 应用程序。只需几次点击,您就可以启动并运行您的应用程序,准备接收流量。
在 Heroku 出现之前,运行一个 web 应用程序的过程是相当具有挑战性的,主要是留给经验丰富的系统操作专业人员而不是开发人员。Heroku 提供了一个自以为是的层,抽象出了 web 服务器所需的大部分配置。大多数 web 应用程序可以(并且仍然可以)利用这样的环境,因此较小的公司和团队可以专注于应用程序开发,而不是配置 web 服务器、安装 Linux 包、设置负载平衡器以及在传统服务器上进行基础设施管理的所有其他事情。
Heroku 的利与弊
尽管 Heroku 很受欢迎,但多年来它还是受到了很多批评。
如果你已经熟悉 Heroku,可以跳过这一部分。
赞成的意见
易用性
Heroku 可以说是最用户友好的 PaaS 平台。您只需定义运行 web 应用程序所需的命令,Heroku 就会为您完成剩下的工作,而无需花费数天时间来设置和配置 web 服务器和底层基础设施。您可以在几分钟内启动并运行您的应用程序!
此外,Heroku 利用 git 来版本化和部署应用程序,这使得部署和回滚变得容易。
最后,与大多数 PaaS 平台不同,Heroku 提供了优秀的错误日志,使得调试相对容易。
流行
在成立的头五年,Heroku 几乎没有竞争对手。他们的用户/开发者体验远远领先于其他人,公司需要一段时间来适应。这一点,再加上它们巨大的免费层,意味着大多数面向开发人员的教程都使用 Heroku 作为他们的部署平台。即使到今天,绝大多数 web 开发教程、书籍和课程仍然利用 Heroku 进行部署。
Heroku 还对一些最流行的语言和运行时(通过build pack)提供一流的支持,如 Python、Ruby、Node.js、PHP、Go、Java、Scala 和 Clojure。虽然这些都是官方支持的语言,但您仍然可以将自己的语言或自定义运行时带到 Heroku 平台上。
集成和附加组件
经常被忽视的是,Heroku 提供了数百个附加工具和服务——从数据存储和缓存到监控和分析,再到数据和视频处理。只需点击一个按钮,您就可以通过提供第三方云服务来扩展您的应用,而无需手动安装或配置。
缩放比例
Heroku 允许开发者轻松地纵向和横向扩展他们的应用。可以通过 Heroku 的仪表盘或 CLI 实现缩放。此外,如果你运行更高性能的 dyno,你可以利用免费的自动缩放功能,这将根据当前的流量增加 web dynos 的数量。
骗局
费用
与市场上的其他 PaaS 相比,Heroku 相当昂贵。虽然他们的起始计划是每个 dyno 每月 7 美元,但随着你的应用程序的扩展,你很快就必须升级到更好的 dyno,这需要花费相当多的钱。由于更高性能的 dynos 的价格,Heroku 可能不适合大型高流量应用程序。
Heroku 与 AWS EC2 相比大约贵五倍。
但是请记住,Heroku 是一个 PaaS,它为您做了很多繁重的工作,而 EC2 只是一个 Linux 实例,您必须自己管理它。
缺乏控制和灵活性
Heroku 没有提供足够的控制,也缺乏透明度。通过使用他们的服务,你将高度依赖他们的技术和设计决策。它们的一些限制阻碍了可伸缩性——例如,应用程序只能监听单个端口,函数的最大源代码大小为 500 MB,并且没有办法微调数据库。Heroku 也高度依赖 AWS,这意味着如果一个 AWS 区域宕机,您的服务(托管在该区域)也会宕机。
类似地,Heroku 实际上是为普通的 RESTful APIs 设计的。如果你的应用包括繁重的计算,或者你需要调整基础设施来满足你的特定需求,Heroku 可能不是一个很好的选择。
缺少地区
Heroku 提供两种类型的运行时:
公共运行时只支持两个地区,美国和欧盟,而私有空间运行时支持 6 个地区。
这意味着,如果你不是企业用户,你只能在美国(弗吉尼亚州)或欧盟地区(爱尔兰都柏林)托管你的应用程序。
`$ heroku regions
ID Location Runtime
───────── ─────────────────────── ──────────────
eu Europe Common Runtime
us United States Common Runtime
dublin Dublin, Ireland Private Spaces
frankfurt Frankfurt, Germany Private Spaces
oregon Oregon, United States Private Spaces
sydney Sydney, Australia Private Spaces
tokyo Tokyo, Japan Private Spaces
virginia Virginia, United States Private Spaces`
缺乏新功能
当今世界,发展趋势变化比以往任何时候都快。这迫使托管服务追随潮流,吸引寻找尖端技术的团队。Heroku 的许多竞争对手,我们很快就会谈到,正在推进和增加新功能,如无服务器、边缘计算等。另一方面,Heroku 认为稳定性比特性开发更重要。这并不意味着他们没有增加新功能;他们只是添加新功能的速度比一些竞争对手慢得多。
如果你想知道 Heroku 接下来会发生什么,看看他们的路线图吧。
锁住
一旦你在 Heroku 上运行生产代码,就很难迁移到不同的主机提供商。
请记住,如果您不再使用 PaaS,您将不得不处理 Heroku 自己处理的所有事情,所以请准备好雇用一两个 SysAdmin 或 DevOps。
Heroku 的核心特征
在这一节中,我们将看看 Heroku 的核心特性,这样你就可以了解在寻找替代方案时应该寻找什么。
同样,如果你已经熟悉 Heroku 的特性,可以跳过这一部分。
特征 | 描述 |
---|---|
Heroku 运行时 | Heroku 运行时负责提供和编排 dyno,管理和监控 dyno 的生命周期,提供适当的网络配置、HTTP 路由、日志聚合等等。 |
CI/CD 系统 | 易于使用的 CI/CD,负责构建、测试、部署、增量应用程序更新等。 |
基于 git 的部署 | 使用 git 管理应用部署。 |
数据持久性 | 完全托管的数据服务,如 Postgres 、 Redis 和 Apache Kafka 。 |
缩放功能 | 易于使用的工具,使开发人员能够按需进行水平和垂直扩展。 |
日志记录和应用指标 | 日志记录、监控和应用程序指标。 |
协作功能 | 与他人轻松协作。协作者可以对您的应用程序进行部署、缩放和访问数据等操作。 |
附加组件 | 数百种附加工具和服务–从数据存储和缓存到监控和分析,再到数据和视频处理,无所不包。 |
在寻找替代方案时,您应该优先考虑这些功能。你不可能找到 Heroku 的 1:1 替代品,所以一定要确定哪些功能是“必须拥有”而不是“最好拥有”。
例如:
必备物品
- 实心用户界面
- 构建包
- 基于 git 的部署
- 经过战斗考验的
- 简单缩放
拥有美好事物
- 应用和基础设施监控
- 使用 AWS
- 自由层
- Add-ons
- CI/CD 系统
Heroku 替代方案
最后,在这一节中,我们将看看最好的 Heroku 替代方案以及它们的优缺点。
数字海洋应用平台
App Platform 是 DigitalOcean 用于将应用部署到云的完全托管解决方案。它集成了 CI/CD,可以很好地与 GitHub 和 GitLab 兼容。它原生支持流行的语言和框架,如 Python、Node.js、Django、go 和 PHP。或者,它允许你通过 Docker 部署应用程序。
其他重要特性:
- 水平和垂直缩放
- 内置警报、监控和洞察力
- 零停机部署和回滚
该平台的用户界面/UX 简单明了,给人一种类似 Heroku 的感觉。
DigitalOcean 应用程序平台起价为 5 美元/月,1 个 CPU 和 512 MB 内存。要了解更多关于他们定价的信息,请查看官方定价页面。
赞成的意见
- 使用方便
- 最便宜的 PaaS 之一
- 免费计划,允许您托管多达 3 个静态网站
- 体面的区域支持(8 个区域)
- 托管应用上的 SSL 保护
- DDoS 缓解
骗局
- 相对较新的 PaaS(成立于 2020 年)
- 构建通常需要 15 分钟
- 不支持重复作业(如 cron)
- 缺少文件
想学习如何将 Django 应用程序部署到 DigitalOcean 的应用程序平台?在 DigitalOcean 的 App 平台上查看运行 Django。
提供;给予
2019 年推出的 Render 是 Heroku 的绝佳替代品。它允许你完全免费地托管静态站点、web 服务、PostgreSQL 数据库和 Redis 实例。它极其简单的用户界面/UX 和强大的 git 集成让你可以在几分钟内运行一个应用程序。它具有对 Python、Node.js、Ruby、Elixir、Go 和 Rust 的原生支持。如果这些都不适合您,Render 还可以通过 docker 文件进行部署。
Render 的免费自动缩放功能将确保您的应用程序总是以合适的成本获得必要的资源。此外,Render 上托管的所有内容也可以获得免费的 TLS 证书。
参考他们的官方文件以获得更多关于他们免费计划的信息。
赞成的意见
- 非常适合初学者
- 轻松设置和部署应用
- 自由层
- 与 Heroku 相比,预算适中(便宜约 50%)
- 基于实时 CPU 和内存使用情况的自动扩展
- 出色的客户支持
骗局
- 相对较新的 PaaS(成立于 2019 年)
- 有限的地区支持(仅限俄勒冈州、法兰克福、俄亥俄州和新加坡)
- 免费层应用程序需要很长时间才能启动和运行
- 没有构建包(看看这个问题)
- 缺乏附加产品生态系统
想了解如何部署应用程序进行渲染?查看我们的教程:
Fly.io
Fly.io 是一个流行的、灵活的 PaaS。他们不是转售 AWS 或 GCP 服务,而是将你的应用程序托管在运行于世界各地的物理专用服务器之上。正因为如此,他们能够提供比其他 PaaS 更便宜的主机服务,比如 Heroku。他们的主要关注点是尽可能靠近他们的客户部署应用程序(你可以在 22 个地区中选择)。Fly.io 支持三种构建器:Dockerfile、 buildpacks ,或者预构建的 Docker 映像。
他们还提供缩放和自动缩放功能。
与其他 PaaS 相比,Fly.io 采用不同的方法来管理您的资源。它没有花哨的管理仪表板;相反,所有的工作都是通过他们名为 flyctl 的 CLI 来完成的。
他们的免费计划包括:
- 多达 3 个共享 cpu-1x 256 MB 虚拟机
- 3GB 永久卷存储(总计)
- 160GB 出站数据传输
这应该足够运行一些小应用程序来测试他们的平台了。
赞成的意见
- 小型项目的免费计划
- 巨大的区域支持(在编写本报告时有 22 个区域)
- 出色的文档和完整的 API 文档
- 轻松实现水平和垂直缩放
骗局
- 只能通过 CLI 管理(可能不适合初学者)
- 没有现成的 GitHub 或 GitLab 集成
- 每个地区的定价不同
想学习如何在 Fly.io 上部署 Django 应用程序吗?查看部署 Django 应用程序来飞行。
谷歌应用引擎
Google App Engine (GAE)是一个完全托管的无服务器平台,用于大规模开发和托管网络应用。它具有强大的内置自动扩展功能,可以根据需求自动分配更多/更少的资源。GAE 原生支持用 Python、Node.js、Java、Ruby、C#、Go 和 PHP 编写的应用程序。或者,它通过定制运行时或 Dockerfiles 提供对其他语言的支持。
它具有强大的应用程序诊断功能,您可以结合云监控和日志来监控您的应用程序的健康和性能。
谷歌为新客户提供 300 美元的免费积分,可以为小应用服务几年。
赞成的意见
- 300 美元免费信贷
- 稳定且经过测试,成立于 2008 年
- 强大的应用程序诊断功能(可与其他 GCP/谷歌服务结合使用)
- 它可以扩展到零,这意味着如果没有人使用你的服务,你不用支付任何费用
- 强大的客户支持
骗局
- 相当昂贵
- 如果你不熟悉 GCP,学习曲线会很陡
- 由于谷歌的专有软件,供应商被锁定
- 他们的定价可能更直截了当
想学习如何在 Google App Engine 上部署 Django 应用程序吗?查看将 Django 应用部署到 Google 应用引擎。
Platform.sh
Platform.sh 是一个专门为持续部署而构建的平台即服务。它允许您在云上托管 web 应用程序,同时使您的开发和测试工作流更加高效。它与 GitHub 直接集成,允许开发人员立即从 GitHub 库进行部署。它支持现代开发语言,如 Python、Java、PHP 和 Go,以及许多不同的框架。
Platform.sh 不提供免费计划。他们的开发者计划(不适合生产)起价 10 美元/月。他们的生产就绪计划起价为每月 50 美元。
赞成的意见
- 出色的 CI/CD 以及与 GitHub 的集成
- 您的 GitHub 分支(开发/阶段/生产)反映在 Platform.sh 上
- 通过自动缩放轻松扩展
- 良好的文档
- 出色的客户支持
骗局
- 没有空闲层
- 随着网站的发展,费用会越来越高
- 可能不适合小型企业
AWS 弹性豆茎
AWS Elastic Beanstalk (EB)是一个易于使用的服务,用于部署和扩展 web 应用程序。它连接多个 AWS 服务,例如计算实例( EC2 )、数据库( RDS )、负载平衡器(应用负载平衡器)和文件存储系统( S3 ),等等。EB 可以让你快速部署用 Python、Go、Java、.Net、Node.js、PHP 和 Ruby。它还支持 Docker。
Elastic Beanstalk 通过抽象出底层架构,使应用部署变得更加容易,同时仍然允许实例和数据库的低级配置。它与 git 集成得很好,并允许您进行增量部署。它还支持负载平衡和自动伸缩。
弹性豆茎的伟大之处在于它不需要额外的费用。您只需为应用程序消耗的资源(EC2 实例、RDS 等)付费。).
赞成的意见
- 预算友好
- 如果你已经熟悉 AWS,那就太好了
- 高度可定制,提供高级别的控制
- 自动扩展和多个可用性区域,最大限度地提高应用的可靠性
- 出色的支持
骗局
- 不适合小型项目
- 与其他 PaaS 提供商相比,设置和运营相对困难
- 没有部署失败通知
- 复杂的文档
想了解如何将应用程序部署到 Elastic Beanstalk 吗?查看我们的教程:
微软 Azure 应用服务
Azure App Service 允许您快速轻松地为任何平台或设备创建企业级 web 和移动应用,并将其部署在可扩展且可靠的云基础设施上。它本身支持 Python。网,。NET Core,Node.js,Java,PHP,容器。他们有内置的 CI/CD 和零停机部署。
其他重要特性:
- 用于跟踪和故障排除的日志收集和失败请求跟踪
- 使用 Azure 活动目录进行身份验证
- 监控和警报
如果你是新客户,你可以获得 200 美元的免费积分来测试 Azure。
赞成的意见
- 与 Visual Studio 很好地集成
- Azure Autoscale 可以帮助您优化成本
- 内置 SSL/TLS 证书
- 通过 Azure Monitor 轻松调试和分析
- 稳定,99.95%的正常运行时间
骗局
- 昂贵的
- 不是最直观的 PaaS
- 如果你不熟悉 Azure,学习曲线会很陡
- 复杂的文档
想学习如何在 Azure App Service 上部署 Django 应用?查看将 Django 应用部署到 Azure 应用服务。
数字海洋水滴上的 Dokku
Dokku 声称是你见过的最小的 PaaS 实现。它允许您构建和管理应用程序从构建到扩展的生命周期。它基本上是一个迷你的 Heroku,你可以在你的 Linux 机器上自己运行。Dokku 由 Docker 提供支持,并与 git 很好地集成。
Dokku 提供了一个名为 Dokku PRO 的高级计划,该计划具有用户友好的界面和其他功能。你可以在他们的官网了解更多。
Dokku 的最低系统要求是 1 GB 内存。这意味着你可以以每月 6 美元的价格在 DigitalOcean Droplet 上运行它。
赞成的意见
- 完全免费和开源
- 易于部署的应用
- 丰富的命令行界面
- 各种插件
骗局
- 必须是自托管的
- 需要初始配置
- 文件可以改进
- 缩放并不容易
想学习如何在 Dokku 上部署 Django 应用程序吗?查看在数字海洋水滴上将 Django 应用程序部署到 Dokku。
皮顿 Anywhere
PythonAnywhere 是基于 Python 编程语言的在线集成开发环境(IDE)和 web 托管服务(PaaS)。它为 Django 、 web2py 、烧瓶和瓶子提供了开箱即用的部署选项。与列表中的其他 PaaS 相比,PythonAnywhere 的行为更像一个传统的 web 服务器。您可以访问它的文件系统,并可以通过 SSH 进入控制台来查看日志等等。
它提供了一个免费计划,这对初学者或者只是想测试不同 Python 框架的人来说是很棒的。免费计划允许您在your_username.pythonanywhere.com
托管一个 web 应用。您还可以使用免费计划来启动 MySQL 实例。
其他相对便宜的付费计划可以在他们的定价页面上看到。
赞成的意见
- 一个小项目的免费托管
- 好用,基本没有学习曲线
- 为 Django、web2py、烧瓶和瓶子预先配置
- 一键免费 SSL
- 强大的客户支持
骗局
- 不支持 CI/CD
- 仅支持 Python 应用程序
- 没有 ASGI 支持
- 无自动缩放
发动机场
Engine Yard 是一个 PaaS 解决方案,允许开发人员在云中规划、构建、部署和管理应用。Engine Yard 还为部署、管理 AWS、支持数据库和微服务容器开发提供服务。它主要关注 Ruby on Rails,但也支持其他语言,如 Python、PHP 和 Node.js。
Engine Yard 通过自动对托管环境进行堆栈更新和安全修补,简化了云上的应用管理。还可以通过应用指标来扩展应用的资源。
赞成的意见
- 可以部署到任何 AWS 区域
- 快速简单的部署
- 自动化数据库管理
- 旨在扩展
- 良好的客户支持
骗局
- 昂贵的
- 免费试用仅 14 天
- Python 不是主要焦点,因为他们关注的是 Ruby on Rails 应用程序
维克塞尔
Vercel 是一个静态站点和无服务器功能的云平台。它主要用于前端项目,但也支持 Python、Node.js、Ruby、Go 和 Docker。Vercel 使开发人员能够托管即时部署、自动扩展、几乎不需要监管的网站和 web 服务——所有这些都不需要配置。它也有一个漂亮而直观的用户界面。
Vercel 提供免费计划,包括:
- 100 GB 带宽
- 内置 CI/CD
- 自动 HTTPS/SSL
- 每次 git 推送的预览
赞成的意见
骗局
- 不支持许多框架
- Python、Go、Ruby 只能作为无服务器函数使用
- 不提供太多的控制
Netlify
Netlify 是一个面向网络开发者和企业的基于云的开发平台。它允许开发者托管静态站点和无服务器功能。它支持 Python、Node.js、Go、PHP、Ruby、Rust 和 Swift。它无疑是前端项目使用最多的托管平台之一。
Netlify 有一个直观的用户界面,非常容易使用,因为它不需要任何配置。
其免费计划包括:
- 100 GB 带宽
- 每月 300 分钟构建时间
- 现场预览
- 即时回滚到任何版本
- 静态资产和动态无服务器功能的部署
赞成的意见
骗局
- 不支持许多框架
- 它的大多数本地支持的语言只能用作无服务器功能
- 有限控制
铁路. app
Railway.app 是一个鲜为人知的基础设施平台,允许您提供基础设施,在本地使用该基础设施进行开发,然后将其部署到云中。无论项目规模大小,它都适用于每种语言。
其特点包括:
- 自动缩放
- 使用指标
- 自动构建
- 协作功能
赞成的意见
- 适合开发和原型制作
- 与 GitHub 的良好集成
- 模板适用于几乎所有的框架
骗局
- 相对较新的平台
- 不如本文中的其他 PaaS 解决方案流行
- 由于公司名称的原因,很难找到任何与 Railway.app 相关的内容
红帽 OpenShift
OpenShift 是红帽的云计算 PaaS 产品。这是一个构建在云中 Kubernetes 之上的应用程序平台,应用程序开发人员和团队可以在其中构建、测试、部署和运行他们的应用程序。
OpenShift 拥有无缝的 DevOps 工作流,可以水平和垂直扩展,并且可以自动扩展。
赞成的意见
- 稳定,2011 年发布
- 与 GitHub 和 Docker 的强大集成
- 直观的 UI
骗局
- 可能会很贵
- 可以改进监测和故障排除
- 缓慢的客户支持
应用程序
Appliku 是一个 PaaS 平台,它使用您的云服务器来部署您的应用。您可以通过 Appliku 的仪表板链接您的 DigitalOcean 或 AWS 帐户并配置服务器。虽然他们的主要焦点是基于 Python 的应用程序,但是您可以通过利用 Docker 来部署用任何语言构建的应用程序。Appliku 的定价基于托管服务器的数量,因此您可以根据需要部署任意多的应用。他们确实提供免费层。
赞成的意见
- 专为 Python/Django 构建
- 经济高效地运行多个应用
- 使用任何云提供商
- CI/CD、GitHub 和 GitLab 集成
- 让我们加密集成
- 轻松访问服务器日志
骗局
- 更新的平台(2019 年)
- 不如其他一些平台受欢迎
结论
Heroku 是一个成熟、久经考验且稳定的平台。它为您做了很多繁重的工作,并且将为您节省大量的时间和金钱,尤其是对于小型团队。Heroku 让您可以专注于您的产品,而不是摆弄您的服务器的配置选项和雇用 DevOps 工程师或系统管理员。
它可能不是最便宜的选择,但它仍然是市场上最好的 PaaS 之一。因此,如果你已经在使用 Heroku,你应该有一个很强的理由放弃它。
虽然市场上有许多替代方案,但没有一个能与 Heroku 的开发人员体验相匹配。目前,Heroku 最有希望的替代品是数字海洋应用平台和 T2 渲染。这两个平台的问题是它们相对较新,还没有经过实战考验。如果你只是想找一个地方来免费托管你的应用程序,那就用 Render 吧。
Heroku 教程
描述
Heroku 是一个平台即服务(PaaS ),为 web 应用程序提供托管服务。它提供了抽象的环境,您不必管理底层基础设施,从而使管理、部署和扩展 web 应用程序变得容易。Heroku 信奉“包含电池”的哲学。这是一个固执己见的环境,但也是一个您不必管理的环境——因此您可以专注于应用程序开发,而不是支持它的环境。
TestDriven.io 上的教程和文章教授高级 Heroku 部署策略和工作流,通常使用 Docker 进行持续集成和部署。
- 由 发布尼克·托马齐奇
- 最后更新于2022 年 11 月 8 日
了解什么是最好的 Heroku 替代方案(及其利弊)。
用 FastAPI 和 MongoDB 开发一个异步 API。
如何通过 Heroku 容器运行时使用 Docker 将 Django 应用程序部署到 Heroku。
开发一个生产就绪的 RESTful API,用 FastAPI 提供一个机器学习模型。
使用 Gitlab CI 将 Flask 和 Vue 支持的全栈 web 应用打包并部署到 Heroku。
简化在 Heroku 上部署、维护和扩展生产级 Django 应用的流程。
这篇文章整理了网上一些从 Heroku 迁移到 AWS 的最佳教程。
网络套接字上的超文本标记语言
本文着眼于实现单页面应用程序(SPA)的新方法——基于 WebSockets 的 HTML。
本文由原文西班牙语版本翻译改编而成。
这是什么?
实现单页面应用程序(SPA)的传统方法是在后端和前端之间划分职责:
- 后端通过 JSON RESTful API 提供信息
- 前端通过 API 调用从后端异步获取数据
不幸的是,这种模式成本很高,需要两个专门的开发人员配置文件,并且由于您必须开发和维护两个不同的应用程序(通常使用两种不同的语言),因此会减慢发布周期。这也使得快速原型制作和 SEO 变得困难。自 21 世纪初以来,这种模式一直占据主导地位。为了交付具有桌面应用外观和感觉的 web 应用,这是我们必须付出的代价。然而,这种情况正在开始改变,因为许多团队认识到收益不会超过成本,并且正在尝试交付 SPA 的新模式。一种开始流行的模式是通过 WebSocket 提供 HTML 而不是 JSON。
它是如何工作的?
Chris MC cord,Phoenix(Elixir生态系统中最受欢迎的框架)的创建者,在 ElixirConf 2019 上展示了一项名为 LiveView 的技术,该技术通过 WebSockets 提供 HTML 服务。不久之后,他演示了如何在不使用 React、Angular 或 Vue 等客户端 JavaScript 框架的情况下,用 LiveView 在 15 分钟内构建一个实时 Twitter 克隆。他展示了从后端框架交付一个流畅的实时 UI 是可能的。这启发了其他开发人员使用其他语言和 web 框架创建自己的实现。
McCord 的解决方案是通过 WebSockets 将 HTML 而不是 JSON 发送到前端。这种方法不仅简化了开发,而且性能也得到了很好的提升,因为呈现逻辑由后端处理,前端不需要发出任何新的 HTTP 请求来获取新数据。
传统方法
同样,使用这种方法,浏览器首先从前端发出一个 HTTP 请求,获得一个带有原始的、预处理过的信息的 JSON 响应。然后前端负责处理信息并创建相应的 HTML。
基于 WebSockets 方法的 HTML
使用这种方法,浏览器或服务器可以开始工作,因为 WebSockets 提供双向通信。
例子
让我们看一个显示博客文章的快速例子。
-
连接:我们从一个连接开始。WebSockets 支持客户端和服务器之间的双向通信,您只需建立一次。
-
组件请求:客户端请求与
/article/2
路线相关联的特定文章的内容。 -
后端逻辑:服务器为模板生成相关的 HTML、CSS、JavaScript,使用模板系统(比如 Jinja),通过 WebSocket 通道返回模板片段。
-
更新 DOM :最后前端用模板片段更新 DOM,显示博客文章。
Django 演示
我用 Django 创建了一个 HTML over WebSockets 方法的原型,它使用 Django 通道来支持 WebSocket。这个应用程序本身相当简单。它只是一个有文章和评论的博客。也就是说,这是一个完整的 SPA,所以页面更改不需要页面刷新。
你可以在这里看到 WebSockets 的威力:
你可以在 GitHub 上找到代码。
优点和缺点
好处:
- HTML 呈现/处理只发生在后端
- 实时
- WebSockets 协议比 HTTP 快
- 使用慢速连接工作
- 几乎不用任何 JavaScript 就能创建一个 SPA
- 简单的搜索引擎优化
缺点:
- 服务器需要更多的资源,因为您必须为每个客户端打开一个 WebSocket
- 新生的生态系统——很少的框架、教程和文档
资源
您可以从以下资源开始:
- Web 软件的未来是 HTML-over-WebSockets
- 凤凰城现场直播
- Hotwire -通过网络发送 HTML 来构建现代网络应用
- 反应器-Django 的实时视图库
- sock puppet——使用您已经了解并喜爱的 Django 工具构建反应式应用程序
- Action Cable——将 WebSockets 与你的 Ruby 和 Rails 应用程序的其余部分无缝集成
- 用 Ruby 和 Rails 构建快速、可靠的实时应用
结论
虽然这种方法仍然很新,但值得一看。采用在增长,每个月都有越来越多的工具和例子出现。就我个人而言,我对它如此鲜为人知感到惊讶。我想这与 JavaScript 生态系统的强大有很大关系。
Kubernetes 教程
描述
Kubernetes 是一个容器编排工具,用于管理大型动态环境中容器化应用程序和组件的生命周期。容器编排工具(如 Kubernetes、Amazon ECS 和 Docker Swarm)使得在微服务环境中部署、管理和自动化容器化服务变得更加容易。
TestDriven.io 上的教程和文章重点是在 Kubernetes 上运行 Flask 和 Vue、HashiCorp 的 Vault 和 Consul、Node.js、Apache Spark。
将节点微服务部署到 Google Kubernetes 引擎上的 Kubernetes 集群。
本教程演示了如何在 Kubernetes 集群上部署 Spark。
本教程演示了如何在 DigitalOcean 上使用 Python 和 Fabric 自动建立 Kubernetes 集群。
在下面的教程中,我们将带你了解如何在 Kubernetes 上设置 Hashicorp 的金库和领事。
如何将基于 Flask 的微服务(以及 Postgres 和 Vue.js)部署到 Kubernetes 集群的分步演练。
机器学习可靠性工程导论
原文:https://testdriven.io/blog/machine-learning-reliability-engineering/
机器学习可靠性工程(MLRE)是一个即将到来的网站可靠性工程(SRE)的专业化。
在本文中,我将向您介绍为什么在 SRE 需要专业化,以及其他一些已经存在的专业化。我还将讨论 MLRE 的角色和职责,并简要介绍不同的工程职能部门将如何与这一新角色互动。
在整篇文章中,我将使用 MLRE 来指代机器学习可靠性工程领域,以及机器学习可靠性工程师。
背景
15 年前,谷歌通过将软件工程的 T2 原则应用于 DevOps,首次提出了 SRE 的想法。从那时起,这个新领域已经形成了自己的形态,与 DevOps 共存。虽然 DevOps 已经分支到几个专业化领域,如 DataOps、DevSecOps 和 MLOps,但 SRE 领域尚未完全大规模分支到专业化领域。
随着时间的推移,随着数据科学、机器学习、安全工程和人工智能等其他专业领域的成熟,专业基础设施、工具和流程也将存在——这将导致 SRE 领域内的专业化。目前,SRE 在过去几年里蓬勃发展的唯一分支是数据库可靠性工程。这是因为数据库、数据仓库、数据湖、ETL 和其他相关技术已经大规模应用了几十年。
正如数据库可靠性工程师需要深入了解数据库的高可用性、复制拓扑、数据库迁移等。,MLRE 将需要拥有与机器学习相关的特定领域的知识。例如,针对GPU和 TPUs 的监控和警报。MLRE 的作用和责任基于与 SRE 相同的理念。
现在,让我们来看看这些年来 SRE 出现的不同分支:
作用 | 涉及 | 构思 | 参与的团队 |
---|---|---|---|
SRE | 应用程序、整体基础架构 | 2010 | 工程开发 |
DBRE | 数据库、湖泊和仓库 | 2015 | 数据操作,数据工程 |
小姐 | 机器学习 | 2020 | 机器学习工程 |
根据设计,可靠性工程需要多面手。可靠性工程师需要对整个系统有一个清晰的认识,并且能够在需要时理解和处理不同的组件。除了核心职责之外,MLREs 还与其他工程职能部门分担职责。
核心职责
- 确保机器学习基础设施高度可用、可靠,并符合服务水平协议(SLA)。
- 设置系统以主动监控计算、内存、网络延迟等。
- 通过优化设计和工作流程控制机器学习基础设施的成本。
共同责任:
- 机器学习工程师-通过减少特征漂移、偏差、欺诈等,确保模型尽可能准确。
- 与其他工程职能部门——就更大的目标达成一致,并确保机器学习团队所做工作的输出是有用的,并且与业务目标相关。
现在,让我们谈谈 MLRE 背后的原则,这些原则是上述角色和职责的基础。
原则
作为 SRE 的一个分支,MLRE 也遵循同样的原则,如-
在前面的列表中已经很好地确定了 MLRE 人本质上是基础设施的所有者。除此之外,他们还负责控制成本,在基础设施可能超出预算时发出警告,等等。所有这些都需要对 MLRE 必须应对的底层基础设施有深刻的理解。
随着每一个主要的云平台引入机器学习功能,随着机器学习和人工智能专用硬件的出现,深入理解这一切都需要专门的努力。随着云平台引入越来越多的服务,知识缺口会越来越大。在数据工程领域,这已经催生了专门针对云的职位,如 GCP 数据工程师、Azure 数据工程师和 AWS 数据工程师。类似的进化在机器学习和可靠性工程中也是完全可能的。
另一个伟大的想法是让一切重复和自动化。从长远来看,这节省了大量的时间和挫折。谷歌 SRE 卡拉·盖瑟(Carla Geisser )说,“如果一个人类操作员在正常操作中需要接触你的系统,你就有一个漏洞。随着系统的增长,正常变化的定义。”
特定知识
如前一节所述,MLRE 需要很好地理解基础设施。他们还需要理解使机器学习工作成为可能的操作过程。虽然机器学习工程师必须具备数据收集、数据验证、特征工程、元数据管理、模型分析等方面的深入知识,但 MLOps 工程师需要了解 DevOps 方面的内容,包括身份管理、角色、授权、许可、源代码控制和 CI/CD 管道。
MLOps 将 DevOps 的最佳实践(协作、版本控制、自动化测试、合规性、安全性和 CI/CD)应用于生产机器学习。
尽管 MLOps 工程师负责上述所有事情,但他们通常不能确保底层基础设施正常工作。这就是 MLRE 的用武之地。这就给我们带来了 MLRE 需要知道的事情,以做好他们的工作。
技能组合
所有新的和即将出现的领域在很大程度上都来源于现有的领域。我已经讲过 SRE 是如何从德文衍生而来,DBRE 和 MLRE 是如何从 SRE 衍生而来的。在这个过程中还有很多其他的影响。由于这种复杂的血统,在这些不同领域工作所需的技能有很大程度的重叠。所以,尽管我提到 MLRE 是一个专业,但这并不是从技能的角度。即使是看似专业的工作,也需要广泛的技能。罗伯特·A·海因莱因,一个现代文艺复兴时期的人,写下了拥有广泛生活技能的需要:
一个人应该能够换尿布,策划入侵,杀猪,造船,设计建筑,写十四行诗,结算账目,建墙,接骨,安慰垂死的人,接受命令,发号施令,合作,独自行动,解方程,分析新问题,扔粪肥,给计算机编程,做一顿美味的饭菜,高效地战斗,英勇地死去。特殊化是为了昆虫。
罗伯特·海因莱茵的想法适用于计算机工程、数据科学、机器学习和人工智能。这就给我们带来了 MLRE 高效完成工作并为团队贡献价值所需的各种技能。让我们开始吃吧。
机器学习
MLRE 应该对机器学习的基础和目的有很好的理解。他们必须知道组成机器学习系统的组件,尤其是最关键和最昂贵的组件。
例如,它有助于理解数据收集、争论和初始处理比模型训练和参数调整等更多计算和内存密集型步骤花费更少。很多机器学习项目都是实验性的,可能非常昂贵。只有理解了底层流程,才能理解成本。
脚本和编程
对基于 Unix 的系统有一个很好的理解总是很有用的,并且是这项工作所必需的。这要求 MLRE 人能够轻松地编写 shell 脚本。他们还需要熟悉软件工程。如前所述,SRE 背后的整个想法是在未知投入生产之前应用软件工程的原则来充实它们。MLRE 必须能够编写代码,以便建立管道,配置机器学习堆栈,启动和拆除基础设施,等等。
DevOps 和 MLOps
这就把我们带到了 MLRE 最关键的领域之一:开发商运营。该领域要求人们了解如何使用 CI/CD 工具构建机器学习管道,同时还要考虑机器学习中从数据收集到生产化预测模型的所有步骤,即模型验证、特征存储、元数据管理和源代码控制管理等。正如前面提到的,虽然不需要对每个步骤都有深入的了解,但理解机器学习管道中的数据流以及底层基础设施是至关重要的。
基础设施即代码
加快基础设施建设不一定是 MLRE 的工作,但它确实属于德沃普斯和 SRE 的职责范围。不严格地说,提供对资源的访问是 MLOps 工程师的工作,而为机器学习工程师提供启动基础设施的有效方法是 MLRE 的工作。出于这个原因,他们需要知道如何使用像 Terraform 、 Pulumi 或 AWS CloudFormation 这样的工具来编写基础设施代码。这样的工具使 MLREs 能够编写可重用的插件、模板和模块,供机器学习工程师使用。这触及了 SRE 诞生背后的核心思想。
数据工程
从某种意义上说,数据科学、机器学习和人工智能都源于数据工程,并严重依赖于数据工程。每当数据从一个地方移动到另一个地方时,它都必须被处理、清理和转换。这就是数据工程思想发挥作用的地方。由于许多角色与许多其他职能有大量重叠,责任界限变得模糊不清。因此,MLRE 需要对数据工程系统如何工作有一个很好的了解。SQL 工作知识是必不可少的。有关系数据库、数据仓库和/或 ETL(提取、转换、加载)框架经验者优先。
自动化测试
测试机器学习模型与测试典型软件产品的方式截然不同。根本区别在于,机器学习模型是非确定性的。
在基本层面上,可以有两种类型的测试来测试机器学习模型:
- 测试模型的编码逻辑
- 测试模型的输出/精度
有很多方法可以测试后者。如前所述,您可以通过创建与训练数据集相对应的测试数据集来测试准确性,训练数据集测试训练前后的数据。另一方面,您并不总是有有效的甚至是正确的方法来测试模型的编码逻辑。这又回到了模型的非确定性。当你不知道会发生什么时,你如何测试代码?
关于测试机器学习模型的深入阅读,请阅读杰瑞米·乔登的博客文章,机器学习系统的有效测试。
测试宜早不宜迟。这种向左移动的基本原则已经流行了一段时间。这个想法是为了确保在开发周期中尽可能早地以自动化的方式完成集成测试。这减少了你以后必须处理的未知数的数量。因此,左移还将帮助您避免以后处理技术债务,因为您将尽早进行设计和基础结构更改。
合作
关于各种工程功能之间的共享技能集的对话是讨论这些不同的工程功能如何协作的一个很好的方式。所有不同的团队都需要某种程度的持续协作。除了技能上的重叠,当不同的公司对一个特定的角色如何运作有不同的想法时,另一个层次的复杂性就出现了。这通常是公司与公司之间(甚至内部团队与团队之间)协作过程不同的原因。下面是我们讨论过的各种角色职责的鸟瞰图:
作用 | 责任 |
---|---|
开发工程师 | 身份管理,资源访问,开发人员需要的任何东西。 |
物流工程师 | 专门针对机器学习工程师的 DevOps,构建 CI/CD 管道。 |
SRE | 基础设施、应用、服务、数据库等的可靠性。 |
DBRE | 数据库、数据仓库和湖泊基础设施、服务等的可靠性。 |
小姐 | 机器学习相关基础设施、应用和服务的可靠性。 |
数据工程师 | 需要数据的所有其他团队的数据可用性。 |
一个团队的工作成果通常是另一个团队的工作成果。在一个理想的场景中,您将自动完成大部分工作。
结论
我们已经讨论了可靠性工程诞生背后的一些原因,以及它是如何与不同的工程功能融合在一起的。
机器学习可靠性工程是 SRE 的一个即将到来的分支,我们很快就会看到那些希望建立基于机器学习和人工智能的可扩展解决方案的公司。可靠性团队将专注于保持系统正常运行,而机器学习工程师将专注于改进模型和做出更好的预测。
机器学习教程
本文着眼于 MLOps 如何适应机器学习生命周期,重点关注用于开发、部署和服务 ML 模型的工具。
使用 Vault 和 Consul 管理秘密
原文:https://testdriven.io/blog/managing-secrets-with-vault-and-consul/
下面的教程详细介绍了如何设置和使用哈希公司的金库和领事项目来安全地存储和管理机密。
我们将从在 Docker 容器中构建一个 Vault 实例开始,然后开始管理静态和动态秘密以及 Vault 的“加密即服务”特性。然后,我们将把 Consul 添加到这个组合中,看看如何扩展 Vault。
这是一个中级教程。假设你对 Docker 有基本的工作知识。还建议您通读官方文档中的简介、内部和基本概念指南,以便在开始之前熟悉跳马。
主要依赖:
- 文档 v20.10.8
- 坞站-复合 v1.29.2
- 保险库版本 1.8.2
- 领事 v1.10.2
目标
学完本教程后,您应该能够:
- 解释什么是保险库,以及为什么您可能想要使用它
- 描述基本的 Vault 架构以及动态和静态机密、各种后端(存储、机密、验证、审计),以及如何将 Vault 用作“加密即服务”
- 配置并运行 Vault,并咨询码头工人
- 使用文件系统后端启动 Vault
- 初始化并解封保险库
- 根据保管库进行身份验证
- 配置审核后端以记录与 Vault 的所有交互
- 通过 CLI、HTTP API 和 UI 处理静态和动态机密
- 创建存储策略以限制对特定路径的访问
- 将传输后端用作“加密即服务”
- 设置 Consul 使用 Vault 作为机密的存储后端
- 定义机密的自定义租期,并在租期结束前撤销机密
什么是跳马?
Vault 是一款开源工具,用于安全存储和管理机密。
什么是秘密?在本教程的上下文中,秘密是安全敏感的或个人可识别的信息,如数据库凭证、SSH 密钥、用户名和密码、AWS IAM 凭证、API 令牌、社会安全号、信用卡号等等。
花点时间想想您的团队目前是如何管理和传播秘密的:
- 谁能接触到它们?
- 谁管理他们?
- 你如何控制谁可以访问它们?
- 您的应用程序如何获得它们?
- 它们是如何更新的?
- 它们是如何被撤销的?
Vault 为这些问题提供了答案,并有助于解决以下与机密管理相关的问题:
问题 | 跳马的目标 |
---|---|
秘密无处不在。 | 金库是所有秘密真相的唯一来源。 |
它们通常不加密。 | Vault 管理开箱即用的加密(在传输期间和静止时)。 |
很难动态地生成它们。 | 秘密可以动态生成。 |
租赁和撤销它们就更难了。 | 秘密可以出租和撤销。 |
没有审计记录。 | 秘密的产生和使用都有审计记录。 |
Vault 有许多可移动的部分,因此可能需要一些时间来适应整体架构。花点时间回顾一下架构指南,注意以下后端:
后端 | 使用 | 例子 |
---|---|---|
存储 | 秘密储存的地方 | 咨询* ,文件系统* ,内存中,PostgreSQL,S3 |
秘密 | 处理静态或动态机密 | AWS * ,数据库,键/值* ,RabbitMQ,SSH |
认证 | 处理身份验证和授权 | AWS,Azure,Google Cloud,GitHub,令牌* ,用户名&密码 |
审计 | 记录所有请求和响应 | 文件* ,系统日志,套接字 |
本教程中使用的*
有了这些,我们开始使用 Vault。
文件系统后端
为了快速启动并运行,我们将使用文件系统后端来存储静态秘密。
文件系统后端应仅用于本地开发或单服务器 Vault 部署,因为它不支持高可用性。
创建新的项目目录:
`$ mkdir vault-consul-docker && cd vault-consul-docker`
然后添加以下文件夹:
`└── vault
├── config
├── data
├── logs
└── policies`
将 Dockerfile 添加到“vault”目录:
`# base image
FROM alpine:3.14
# set vault version
ENV VAULT_VERSION 1.8.2
# create a new directory
RUN mkdir /vault
# download dependencies
RUN apk --no-cache add \
bash \
ca-certificates \
wget
# download and set up vault
RUN wget --quiet --output-document=/tmp/vault.zip https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip && \
unzip /tmp/vault.zip -d /vault && \
rm -f /tmp/vault.zip && \
chmod +x /vault
# update PATH
ENV PATH="PATH=$PATH:$PWD/vault"
# add the config file
COPY ./config/vault-config.json /vault/config/vault-config.json
# expose port 8200
EXPOSE 8200
# run vault
ENTRYPOINT ["vault"]`
接下来,将一个 docker-compose.yml 文件添加到项目根:
`version: '3.8' services: vault: build: context: ./vault dockerfile: Dockerfile ports: - 8200:8200 volumes: - ./vault/config:/vault/config - ./vault/policies:/vault/policies - ./vault/data:/vault/data - ./vault/logs:/vault/logs environment: - VAULT_ADDR=http://127.0.0.1:8200 - VAULT_API_ADDR=http://127.0.0.1:8200 command: server -config=/vault/config/vault-config.json cap_add: - IPC_LOCK`
将名为 vault-config.json 的配置文件添加到“vault/config”中:
`{ "backend": { "file": { "path": "vault/data" } }, "listener": { "tcp":{ "address": "0.0.0.0:8200", "tls_disable": 1 } }, "ui": true }`
在这里,我们将 Vault 配置为使用文件系统后端,为 Vault 定义了监听器,禁用了 TLS ,并启用了 Vault UI 。查看文档了解更多关于配置保险库的信息。
现在我们可以构建图像并旋转容器:
`$ docker-compose up -d --build`
调出 Docker 日志,确保构建中没有错误:
您应该会看到类似如下的内容:
`Attaching to vault-consul-docker_vault_1
vault_1 | ==> Vault server configuration:
vault_1 |
vault_1 | Api Address: http://127.0.0.1:8200
vault_1 | 2021-09-08T14:48:35.014Z [INFO] proxy environment: http_proxy="" https_proxy="" no_proxy=""
vault_1 | Cgo: disabled
vault_1 | Cluster Address: https://127.0.0.1:8201
vault_1 | Go Version: go1.16.7
vault_1 | Listener 1: tcp (addr: "0.0.0.0:8200", cluster address: "0.0.0.0:8201", max_request_duration: "1m30s", max_request_size: "33554432", tls: "disabled")
vault_1 | Log Level: info
vault_1 | Mlock: supported: true, enabled: true
vault_1 | Recovery Mode: false
vault_1 | Storage: file
vault_1 | Version: Vault v1.8.2
vault_1 | Version Sha: aca76f63357041a43b49f3e8c11d67358496959f
vault_1 |
vault_1 | ==> Vault server started! Log data will stream in below:
vault_1 |`
初始化和解封
在运行的容器中启动 bash 会话:
`$ docker-compose exec vault bash`
在 shell 中,初始化 Vault:
`bash-5.1# vault operator init`
记下解封密钥和初始根令牌。每次重新密封或重新启动 Vault 服务器时,您都需要提供三个解封密钥。
为什么是三把钥匙?回顾沙米尔的秘密分享。
现在,您可以使用以下三个密钥解封保险库:
`bash-5.1# vault operator unseal
Unseal Key (will be hidden):`
运行这个命令两次以上,每次使用不同的密钥。一旦完成,确保Sealed
是false
:
`Key Value
--- -----
Seal Type shamir
Initialized true
Sealed false
Total Shares 5
Threshold 3
Version 1.8.2
Storage Type file
Cluster Name vault-cluster-8fcf9d05
Cluster ID d86e0274-ad9c-d2c1-d6ec-baeab410797b
HA Enabled false`
使用根令牌,您现在可以进行身份验证:
`bash-5.1# vault login
Token (will be hidden):`
您应该会看到类似如下的内容:
`Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.
Key Value
--- -----
token s.c0kYHWiOTqQvtR8JuSeTz6sZ
token_accessor 3FQJVxOY5C1brzlHHQSFaCdZ
token_duration ∞
token_renewable false
token_policies ["root"]
identity_policies []
policies ["root"]`
请记住,这使用了根策略。在生产中,您会希望设置具有不同访问级别的策略。我们很快就会看到如何做到这一点。
保险库现已解封,可以使用了。
审计
在我们测试功能之前,让我们启用一个审计设备:
`bash-5.1# vault audit enable file file_path=/vault/logs/audit.log
Success! Enabled the file audit device at: file/`
现在,您应该能够在“vault/logs”中本地查看日志。要进行测试,请运行以下命令来查看所有启用的审核设备:
`bash-5.1# vault audit list
Path Type Description
---- ---- -----------
file/ file n/a`
请求和后续响应应记录在 vault/logs/audit.log 中。看一看。
秘密
-
静态机密(比如加密的 Redis 或 Memcached)有刷新间隔,但除非明确撤销,否则不会过期。它们预先用键/值后端(以前的“通用”后端)定义,然后共享。
-
动态秘密按需生成。他们有强制租约,通常在短期内到期。因为它们在被访问之前是不存在的,所以暴露的机会更少——所以动态秘密更安全。Vault 附带了许多动态后端——即 AWS 、数据库、谷歌云、领事和 RabbitMQ 。
查看为什么我们需要动态秘密的博客文章,了解更多关于使用动态秘密的优势的信息。
静态秘密
可以通过 CLI 、 HTTP API 或 UI 管理 Vault。
硬币指示器 (coin-levelindicator 的缩写)命令行界面(Command Line Interface for batch scripting)
仍然在容器的 bash 会话中,我们可以创建、读取、更新和删除秘密。我们还将了解如何版本化和回滚机密。
使用以下命令启用机密:
`bash-5.1# vault secrets enable kv
Success! Enabled the kv secrets engine at: kv/`
在kv/foo
路径中创建一个密钥为bar
值为precious
的新秘密:
`bash-5.1# vault kv put kv/foo bar=precious
Success! Data written to: kv/foo`
阅读:
`bash-5.1# vault kv get kv/foo
=== Data ===
Key Value
--- -----
bar precious`
要使用特定键的不同版本,我们需要升级到键/值后端的 v2 :
`bash-5.1# vault kv enable-versioning kv/
Success! Tuned the secrets engine at: kv/`
通过将值更新为copper
来添加版本 2:
`bash-5.1# vault kv put kv/foo bar=copper
Key Value
--- -----
created_time 2021-09-08T18:23:14.4154928Z
deletion_time n/a
destroyed false
version 2`
阅读版本 1:
`bash-5.1# vault kv get -version=1 kv/foo
====== Metadata ======
Key Value
--- -----
created_time 2021-09-08T18:22:37.2548824Z
deletion_time n/a
destroyed false
version 1
=== Data ===
Key Value
--- -----
bar precious`
阅读版本 2:
`bash-5.1# vault kv get -version=2 kv/foo
====== Metadata ======
Key Value
--- -----
created_time 2021-09-08T18:23:14.4154928Z
deletion_time n/a
destroyed false
version 2
=== Data ===
Key Value
--- -----
bar copper`
删除最新版本(如版本 2):
`bash-5.1# vault kv delete kv/foo
Success! Data deleted (if it existed) at: kv/foo`
删除版本 1:
`bash-5.1# vault kv delete -versions=1 kv/foo
Success! Data deleted (if it existed) at: kv/foo`
您也可以取消删除:
`bash-5.1# vault kv undelete -versions=1 kv/foo
Success! Data written to: kv/undelete/foo`
删除类似于软删除。如果您想删除底层元数据,您必须使用 destroy 命令:
`bash-5.1# vault kv destroy -versions=1 kv/foo
Success! Data written to: kv/destroy/foo`
记下审计日志。上面的每个请求都被记录了!
应用程序接口
您还可以通过 HTTP API 与 Vault 进行交互。我们将针对 API 的 v2 提出请求。打开一个新的终端选项卡,然后将根令牌设置为环境变量:
`$ export VAULT_TOKEN=your_token_goes_here`
创建一个名为foo
的新秘密,其值为world
:
`$ curl \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d '{ "data": { "foo": "world" } }' \
http://127.0.0.1:8200/v1/kv/data/hello`
阅读秘密:
`$ curl \
-H "X-Vault-Token: $VAULT_TOKEN" \
-X GET \
http://127.0.0.1:8200/v1/kv/data/hello`
JSON 响应应该包含一个data
键,其值类似于:
`"data": {
"data":{
"foo": "world"
},
"metadata": {
"created_time": "2021-09-08T18:30:32.5140484Z",
"deletion_time": "",
"destroyed": false,
"version": 1
}
}`
尝试自己添加、删除和销毁新版本。
用户界面
UI 应该在http://localhost:8200/UI/vault上运行。使用根令牌登录。然后,自己探索键/值后端:
政策
到目前为止,我们一直使用根策略与 API 交互。让我们设置一个只有读权限的策略。
将名为 app-policy.json 的新配置文件添加到“vault/policies”中:
`{ "path": { "kv/data/app/*": { "policy": "read" } } }`
在 bash 会话中创建一个新策略:
`bash-5.1# vault policy write app /vault/policies/app-policy.json
Success! Uploaded policy: app`
然后,创建一个新令牌:
`bash-5.1# vault token create -policy=app
Key Value
--- -----
token s.ZOUMx3RIhVRhI4ijlZg8KXRQ
token_accessor TT53xOxbIfGjI7l4392gjXcg
token_duration 768h
token_renewable true
token_policies ["app" "default"]
identity_policies []
policies ["app" "default"]`
在另一个新的终端选项卡中(现在应该有三个了),添加带有新令牌的VAULT_TOKEN
环境变量:
`$ export VAULT_TOKEN=your_token_goes_here`
试着读出我们之前设定的foo
秘密:
`$ curl \
-H "X-Vault-Token: $VAULT_TOKEN" \
-X GET \
http://127.0.0.1:8200/v1/kv/data/hello`
您应该没有查看该机密的正确权限:
`{
"errors":[
"1 error occurred:\n\t* permission denied\n\n"
]
}`
为什么我们连它都看不懂?跳回到 vault-config.json 中的策略配置。kv/data/app/*
表示策略只能从app
路径读取。
你可能已经注意到了,Vault 中的几乎所有东西都是基于路径的。
回到容器中的 bash 会话,向app/test
路径添加一个新的秘密:
`bash-5.1# vault kv put kv/app/test ping=pong
Key Value
--- -----
created_time 2021-09-08T18:40:35.2694047Z
deletion_time n/a
destroyed false
version 1`
您应该能够使用与app
策略相关联的令牌来查看秘密:
`$ curl \
-H "X-Vault-Token: $VAULT_TOKEN" \
-X GET \
http://127.0.0.1:8200/v1/kv/data/app/test`
也可以从用户界面管理策略:
加密即服务
在我们研究动态秘密之前,让我们快速回顾一下传输后端,它可以作为“加密即服务”用于:
- 加密和解密“传输中”的数据,而不将其存储在保险库中
- 轻松将加密集成到您的应用程序工作流程中
回到容器中的 bash 会话,启用传输:
`bash-5.1# vault secrets enable transit
Success! Enabled the transit secrets engine at: transit/`
配置命名加密密钥:
`bash-5.1# vault write -f transit/keys/foo
Success! Data written to: transit/keys/foo`
加密:
`bash-5.1# vault write transit/encrypt/foo plaintext=$(base64 <<< "my precious")
Key Value
--- -----
ciphertext vault:v1:cFnk5AQLE9Mg+mZ7Ej17vRmYT5aqheikdZQ1FC4vre5jAod0L/uHDA==`
解密:
`bash-5.1# vault write transit/decrypt/foo ciphertext=vault:v1:cFnk5AQLE9Mg+mZ7Ej17vRmYT5aqheikdZQ1FC4vre5jAod0L/uHDA==
Key Value
--- -----
plaintext bXkgcHJlY2lvdXMK`
解码:
`bash-5.1# base64 -d <<< "bXkgcHJlY2lvdXMK"
my precious`
也在用户界面中进行测试:
动态秘密
如前所述,Vault 支持许多动态秘密后端,用于在需要时动态生成秘密。例如,使用 AWS 和 Google Cloud 后端,您可以基于 IAM 策略创建访问凭证。与此同时,数据库后端基于配置的角色生成数据库凭证。
动态秘密:
- 按需生成
- 基于角色具有有限的访问权限
- 被租赁一段时间
- 可以撤销
- 附带一份审计记录
让我们看看如何使用 AWS 后端生成 AWS 凭证。
AWS 凭据
启用 AWS 机密后端:
`bash-5.1# vault secrets enable -path=aws aws
Success! Enabled the aws secrets engine at: aws/`
认证:
`bash-5.1# vault write aws/config/root access_key=foo secret_key=bar
Success! Data written to: aws/config/root`
确保分别用您的 AWS 访问密钥 id 和秘密密钥替换
foo
和bar
。
创建角色:
`bash-5.1# vault write aws/roles/ec2-read credential_type=iam_user policy_document=-<<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stmt1426528957000",
"Effect": "Allow",
"Action": [
"ec2:*"
],
"Resource": [
"*"
]
}
]
}
EOF
Success! Data written to: aws/roles/ec2-read`
这里,我们基于AmazonEC2ReadOnlyAccess
创建了一个新角色,这是一个 AWS 管理的策略。顾名思义,它给予用户对 EC2 控制台的只读访问权;他们不能执行任何操作或创建新资源。您还可以使用内嵌策略根据您的个人需求创建自定义角色。我们很快就会看到一个例子。更多信息请参考 AWS 秘密引擎文档。
记住:动态秘密只有在被请求时才会生成(例如,一个 web 应用程序请求访问 S3)。在此之前,商店里没有这些东西。
创建一组新的凭据:
`bash-5.1# vault read aws/creds/ec2-read
Key Value
--- -----
lease_id aws/creds/ec2-read/9KdO6J7KVBiSwOPEvwrqqALG
lease_duration 768h
lease_renewable true
access_key AKIAZ4DZAKZKEULSDW5A
secret_key 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
security_token <nil>`
现在,您应该能够在 AWS 上的 IAM 控制台的“用户”部分看到该用户:
租赁和撤销
在这一节中,我们将快速看一下如何定义一个自定义的租期,并在租期结束前撤销一个秘密。
创建新的 AWS 角色:
`bash-5.1# vault write aws/roles/foo credential_type=iam_user policy_document=-<<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stmt1426528957000",
"Effect": "Allow",
"Action": [
"ec2:*"
],
"Resource": [
"*"
]
}
]
}
EOF
Success! Data written to: aws/roles/foo`
创建新的 AWS 凭证时,请注意lease_duration
:
`bash-5.1# vault read aws/creds/foo
Key Value
--- -----
lease_id aws/creds/foo/F0oBbnBIHEoz0ywVVtbuJB7r
lease_duration 768h
lease_renewable true
access_key AKIAZ4DZAKZKLJKB7CPX
secret_key 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
security_token <nil>`
如果您只希望所有 AWS IAM 动态机密的租期为 30 分钟,会怎么样?
`bash-5.1# vault write aws/config/lease lease=1800s lease_max=1800s`
在本例中,由于lease_max
与lease
相同,您将无法续订令牌。如果你将lease_max
设置为3600s
,你就可以续租一次。有关更多信息,请查看令牌和租赁指南。
创建新凭据:
`bash-5.1# vault read aws/creds/foo
Key Value
--- -----
lease_id aws/creds/foo/xQlJpKDS1ljE9Awz0aywXgbB
lease_duration 30m
lease_renewable true
access_key AKIAZ4DZAKZKJPL5OM5W
secret_key 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
security_token <nil>`
想要快速吊销此凭据吗?抓住lease_id
然后跑:
`bash-5.1# vault lease revoke aws/creds/foo/xQlJpKDS1ljE9Awz0aywXgbB`
想要撤销所有 AWS 信用?
`bash-5.1# vault lease revoke -prefix aws/`
有关这些概念的更多信息,请参考租赁、续订和撤销指南。
领事后端
到目前为止,我们一直在使用文件系统后端。这将无法扩展到单台服务器之外,因此无法利用 Vault 的高可用性。幸运的是,有许多其他的存储后端,像领事后端,是为分布式系统设计的。
要设置consult,首先要更新 docker-compose.yml 文件:
`version: '3.8' services: vault: build: context: ./vault dockerfile: Dockerfile ports: - 8200:8200 volumes: - ./vault/config:/vault/config - ./vault/policies:/vault/policies - ./vault/data:/vault/data - ./vault/logs:/vault/logs environment: - VAULT_ADDR=http://127.0.0.1:8200 - VAULT_API_ADDR=http://127.0.0.1:8200 command: server -config=/vault/config/vault-config.json cap_add: - IPC_LOCK depends_on: - consul consul: build: context: ./consul dockerfile: Dockerfile ports: - 8500:8500 command: agent -server -bind 0.0.0.0 -client 0.0.0.0 -bootstrap-expect 1 -config-file=/consul/config/config.json volumes: - ./consul/config/consul-config.json:/consul/config/config.json - ./consul/data:/consul/data`
在项目根目录中添加一个名为“consul”的新目录,然后在这个新创建的目录中添加一个新的 Dockerfile :
`# base image
FROM alpine:3.14
# set consul version
ENV CONSUL_VERSION 1.10.2
# create a new directory
RUN mkdir /consul
# download dependencies
RUN apk --no-cache add \
bash \
ca-certificates \
wget
# download and set up consul
RUN wget --quiet --output-document=/tmp/consul.zip https://releases.hashicorp.com/consul/${CONSUL_VERSION}/consul_${CONSUL_VERSION}_linux_amd64.zip && \
unzip /tmp/consul.zip -d /consul && \
rm -f /tmp/consul.zip && \
chmod +x /consul/consul
# update PATH
ENV PATH="PATH=$PATH:$PWD/consul"
# add the config file
COPY ./config/consul-config.json /consul/config/config.json
# expose ports
EXPOSE 8300 8400 8500 8600
# run consul
ENTRYPOINT ["consul"]`
接下来,在“consul”目录中添加两个新目录:“config”和“data”。然后,在“config”中,添加一个名为 consul-config.json 的配置文件:
`{ "datacenter": "localhost", "data_dir": "/consul/data", "log_level": "DEBUG", "server": true, "ui": true, "ports": { "dns": 53 } }`
请务必查看咨询文档中的配置选项,了解有关上述选项的更多信息。
“领事”目录现在应该是这样的:
`├── Dockerfile
├── config
│ └── consul-config.json
└── data`
退出 bash 会话。关闭容器,然后更新 Vault 配置文件:
`{ "backend": { "consul": { "address": "consul:8500", "path": "vault/" } }, "listener": { "tcp":{ "address": "0.0.0.0:8200", "tls_disable": 1 } }, "ui": true }`
所以,现在我们使用的是领事后端,而不是文件系统。我们使用服务名consul
作为地址的一部分。path
键定义了 Consul 的键/值存储中存储 Vault 数据的路径。
清除“vault/data”目录中的所有文件和文件夹,以删除文件系统后端。构建新映像并旋转容器:
`$ docker-compose down
$ docker-compose up -d --build`
在浏览器中导航到 http://localhost:8500/ui ,确保一切正常:
从 CLI 或 UI 对此进行测试。
硬币指示器 (coin-levelindicator 的缩写)命令行界面(Command Line Interface for batch scripting)
在 Vault 容器中创建新的 bash 会话:
`$ docker-compose exec vault bash`
然后,运行:
`# Init
bash-5.1# vault operator init
# Unseal
bash-5.1# vault operator unseal
# Authenticate
bash-5.1# vault login
# Enable secrets
bash-5.1# vault secrets enable kv
# Add a new static secret
bash-5.1# vault kv put kv/foo bar=precious
# Read it back
bash-5.1# vault kv get kv/foo`
用户界面
请注意“vault/data”中没有文件或文件夹。你认为这是为什么?
想再添加一个 Consul 服务器吗?向 docker-compose.yml 添加新服务:
`consul-worker: build: context: ./consul dockerfile: Dockerfile command: agent -server -join consul -config-file=/consul/config/config.json volumes: - ./consul/config/consul-config.json:/consul/config/config.json depends_on: - consul`
这里,我们使用了 join 命令将这个代理连接到一个现有的集群。注意,我们只需要引用服务名:consul
。
然后:
- 退出 bash 会话(如有必要)
- 把集装箱拿下来
- 清空“领事/数据”中的数据目录(为什么?)
- 旋转容器并测试
结论
在本教程中,我们讨论了如何在 Docker 容器中设置和运行 Vault 和 Consul。现在,您应该对如何与 Vault 交互以及如何执行基本操作有了清晰的了解。
想要更多吗?看看下面的帖子吧:
将 Masonite ORM 与 FastAPI 集成
在本教程中,你将学习如何使用 Masonite ORM 和 FastAPI 。
目标
学完本教程后,您应该能够:
- 将 Masonite ORM 与 FastAPI 集成
- 使用 Masonite ORM 与 Postgres、MySQL 和 SQLite 进行交互
- 用 Masonite ORM 声明数据库应用程序中的关系
- 用 pytest 测试 FastAPI 应用程序
为什么使用 Masonite ORM
Masonite ORM 是一个干净的、易于使用的对象关系映射库,它是为 Masonite web 框架构建的。Masonite ORM 建立在演说家 ORM 的基础上,这是一个活跃的记录 ORM,它在很大程度上受到了 Laravel 的雄辩 ORM 的启发。
Masonite ORM 被开发出来作为 astorator ORM 的替代品,因为 astorator 不再接收更新和错误修复。
尽管 Masonite ORM 是为 Masonite web 项目设计的,但是您也可以将 mason ite ORM 用于其他 Python web 框架或项目。
FastAPI
FastAPI 是一个现代的、高性能的、内置电池的 Python web 框架,非常适合构建 RESTful APIs。它可以处理同步和异步请求,并内置了对数据验证、JSON 序列化、身份验证和授权以及 OpenAPI 的支持。
有关 FastAPI 的更多信息,请查看我们的 FastAPI 摘要页面。
我们正在建造的东西
我们将使用以下模型构建一个简单的博客应用程序:
- 用户
- 邮件
- 评论
用户将与帖子有一对多的关系,而帖子也将与评论有一对多的关系。
API 端点:
/api/v1/users
-获取所有用户的详细信息/api/v1/users/<user_id>
-获取单个用户的详细信息/api/v1/posts
-获取所有帖子/api/v1/posts/<post_id>
-获得一个帖子- 从一篇文章中获取所有评论
项目设置
创建一个目录来保存名为“fastapi-masonite”的项目:
`$ mkdir fastapi-masonite
$ cd fastapi-masonite`
创建虚拟环境并激活它:
`$ python3.10 -m venv .env
$ source .env/bin/activate
(.env)$`
你可以随意把 virtualenv 和 Pip 换成诗歌或 Pipenv 。更多信息,请查看现代 Python 环境。
创建一个 requirements.txt 文件,并向其中添加以下需求:
`fastapi==0.89.1
uvicorn==0.20.0`
uvicon是一个 ASGI (异步服务器网关接口)兼容服务器,将用于启动 FastAPI。
安装要求:
`(.env)$ pip install -r requirements.txt`
在项目的根文件夹中创建一个 main.py 文件,并添加以下几行:
`from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def say_hello():
return {"msg": "Hello World"}`
使用以下命令运行 FastAPI 服务器:
`(.env)$ uvicorn main:app --reload`
打开您选择的网络浏览器并导航至 http://127.0.0.1:8000 。您应该会看到以下 JSON 响应:
Masonite ORM
将以下需求添加到 requirements.txt 文件中:
`masonite-orm==2.18.6
psycopg2-binary==2.9.5`
安装新的依赖项:
`(.env)$ pip install -r requirements.txt`
创建以下文件夹:
`models
databases/migrations
config`
“models”文件夹将包含我们的模型文件,“databases/migrations”文件夹将包含我们的迁移文件,“config”文件夹将保存我们的 Masonite 数据库配置文件。
数据库配置
在“config”文件夹中,创建一个 database.py 文件。Masonite ORM 需要这个文件,因为这是我们声明数据库配置的地方。
欲了解更多信息,请访问文档。
在 database.py 文件中,我们需要添加DATABASE
变量和一些连接信息,从masonite-orm.connections
导入ConnectionResolver
,并注册连接细节:
`# config/database.py
from masoniteorm.connections import ConnectionResolver
DATABASES = {
"default": "postgres",
"mysql": {
"host": "127.0.0.1",
"driver": "mysql",
"database": "masonite",
"user": "root",
"password": "",
"port": 3306,
"log_queries": False,
"options": {
#
}
},
"postgres": {
"host": "127.0.0.1",
"driver": "postgres",
"database": "test",
"user": "test",
"password": "test",
"port": 5432,
"log_queries": False,
"options": {
#
}
},
"sqlite": {
"driver": "sqlite",
"database": "db.sqlite3",
}
}
DB = ConnectionResolver().set_connection_details(DATABASES)`
这里,我们定义了三种不同的数据库设置:
- 关系型数据库
- Postgres
- SQLite
我们将默认连接设置为 Postgres。
注意:确保您已经启动并运行了 Postgres 数据库。如果要使用 MySQL,将默认连接改为
mysql
。
Masonite 型号
要创建一个新的样板文件 Masonite 模型,从终端的项目根文件夹中运行下面的masonite-orm
命令:
`(.env)$ masonite-orm model User --directory models`
您应该会看到一条成功消息:
`Model created: models/User.py`
因此,该命令应该在“models”目录中创建一个包含以下内容的 User.py 文件:
`""" User Model """
from masoniteorm.models import Model
class User(Model):
"""User Model"""
pass`
如果您收到一个
FileNotFoundError
,检查以确保“模型”文件夹存在。
对帖子和评论模型运行相同的命令:
`(.env)$ masonite-orm model Post --directory models
> Model created: models/Post.py
(.env)$ masonite-orm model Comment --directory models
> Model created: models/Comment.py`
接下来,我们可以创建初始迁移:
`(.env)$ masonite-orm migration migration_for_user_table --create users`
我们添加了--create
标志来告诉 Masonite 将要创建的迁移文件是针对我们的users
表的,并且应该在迁移运行时创建数据库表。
在“数据库/迁移”文件夹中,应该已经创建了一个新文件:
<timestamp>_migration_for_user_table.py
内容:
`"""MigrationForUserTable Migration."""
from masoniteorm.migrations import Migration
class MigrationForUserTable(Migration):
def up(self):
"""
Run the migrations.
"""
with self.schema.create("users") as table:
table.increments("id")
table.timestamps()
def down(self):
"""
Revert the migrations.
"""
self.schema.drop("users")`
创建剩余的迁移文件:
`(.env)$ masonite-orm migration migration_for_post_table --create posts
> Migration file created: databases/migrations/2022_05_04_084820_migration_for_post_table.py
(.env)$ masonite-orm migration migration_for_comment_table --create comments
> Migration file created: databases/migrations/2022_05_04_084833_migration_for_comment_table.py`
接下来,让我们填充每个数据库表的字段。
数据库表
users
表应该有以下字段:
- 名字
- 电子邮件(唯一)
- 地址(可选)
- 电话号码(可选)
- 性别(可选)
将与用户模型相关联的迁移文件更改为:
`"""MigrationForUserTable Migration."""
from masoniteorm.migrations import Migration
class MigrationForUserTable(Migration):
def up(self):
"""
Run the migrations.
"""
with self.schema.create("users") as table:
table.increments("id")
table.string("name")
table.string("email").unique()
table.text("address").nullable()
table.string("phone_number", 11).nullable()
table.enum("sex", ["male", "female"]).nullable()
table.timestamps()
def down(self):
"""
Revert the migrations.
"""
self.schema.drop("users")`
有关表方法和列类型的更多信息,请查看文档中的模式&迁移。
接下来,更新帖子和评论模型的字段,注意这些字段。
帖子:
`"""MigrationForPostTable Migration."""
from masoniteorm.migrations import Migration
class MigrationForPostTable(Migration):
def up(self):
"""
Run the migrations.
"""
with self.schema.create("posts") as table:
table.increments("id")
table.integer("user_id").unsigned()
table.foreign("user_id").references("id").on("users")
table.string("title")
table.text("body")
table.timestamps()
def down(self):
"""
Revert the migrations.
"""
self.schema.drop("posts")`
评论:
`"""MigrationForCommentTable Migration."""
from masoniteorm.migrations import Migration
class MigrationForCommentTable(Migration):
def up(self):
"""
Run the migrations.
"""
with self.schema.create("comments") as table:
table.increments("id")
table.integer("user_id").unsigned().nullable()
table.foreign("user_id").references("id").on("users")
table.integer("post_id").unsigned().nullable()
table.foreign("post_id").references("id").on("posts")
table.text("body")
table.timestamps()
def down(self):
"""
Revert the migrations.
"""
self.schema.drop("comments")`
注意到:
`table.integer("user_id").unsigned()
table.foreign("user_id").references("id").on("users")`
上面几行创建了一个从posts
/ comments
表到users
表的外键。user_id
列引用users
表上的id
列
要应用迁移,请在终端中运行以下命令:
`(.env)$ masonite-orm migrate`
您应该会看到关于每个迁移的成功消息:
`Migrating: 2022_05_04_084807_migration_for_user_table
Migrated: 2022_05_04_084807_migration_for_user_table (0.08s)
Migrating: 2022_05_04_084820_migration_for_post_table
Migrated: 2022_05_04_084820_migration_for_post_table (0.04s)
Migrating: 2022_05_04_084833_migration_for_comment_table
Migrated: 2022_05_04_084833_migration_for_comment_table (0.02s)`
到目前为止,我们已经在表中添加并引用了外键,这些外键是在数据库中创建的。但是,我们仍然需要告诉 Masonite 每个模型之间的关系类型。
表关系
为了定义一对多的关系,我们需要从模型/User.py 中的masoniteorm.relationships
导入has_many
,并将其作为装饰者添加到我们的函数中:
`# models/User.py
from masoniteorm.models import Model
from masoniteorm.relationships import has_many
class User(Model):
"""User Model"""
@has_many("id", "user_id")
def posts(self):
from .Post import Post
return Post
@has_many("id", "user_id")
def comments(self):
from .Comment import Comment
return Comment`
请注意,has_many
有两个参数:
- 将在另一个表中引用的主表上的主键列的名称
- 将作为外键引用的列的名称
在users
表中,id
是主键列,而user_id
是引用users
表记录的posts
表中的列。
对型号/Post.py 进行同样的操作:
`# models/Post.py
from masoniteorm.models import Model
from masoniteorm.relationships import has_many
class Post(Model):
"""Post Model"""
@has_many("id", "post_id")
def comments(self):
from .Comment import Comment
return Comment`
配置好数据库后,让我们用 FastAPI 连接我们的 API。
FastAPI RESTful API
迂腐的
FastAPI 严重依赖 Pydantic 来操作(读取和返回)数据。
在根文件夹中,创建一个名为 schema.py 的新 Python 文件:
`# schema.py
from pydantic import BaseModel
from typing import Optional
class UserBase(BaseModel):
name: str
email: str
address: Optional[str] = None
phone_number: Optional[str] = None
sex: Optional[str] = None
class UserCreate(UserBase):
email: str
class UserResult(UserBase):
id: int
class Config:
orm_mode = True`
这里,我们为用户对象定义了一个基本模型,然后添加了两个 Pydantic 模型,一个用于读取数据,另一个用于从 API 返回数据。我们对可空值使用了Optional
类型。
你可以在这里阅读更多关于 Pydantic 模型。
在UserResult
Pydantic 类中,我们增加了一个Config
类,并将orm_mode
设置为True
。这告诉 Pydantic 不仅要将数据作为 dict 读取,还要作为具有属性的对象读取。因此,您可以选择:
`user_id = user["id"] # as a dict
user_id = user.id # as an attribute`
接下来,为帖子和评论对象添加模型:
`# schema.py
from pydantic import BaseModel
from typing import Optional
class UserBase(BaseModel):
name: str
email: str
address: Optional[str] = None
phone_number: Optional[str] = None
sex: Optional[str] = None
class UserCreate(UserBase):
email: str
class UserResult(UserBase):
id: int
class Config:
orm_mode = True
class PostBase(BaseModel):
user_id: int
title: str
body: str
class PostCreate(PostBase):
pass
class PostResult(PostBase):
id: int
class Config:
orm_mode = True
class CommentBase(BaseModel):
user_id: int
body: str
class CommentCreate(CommentBase):
pass
class CommentResult(CommentBase):
id: int
post_id: int
class Config:
orm_mode = True`
API 端点
现在,让我们添加 API 端点。在 main.py 文件中,导入 Pydantic 模式和 Masonite 模型:
`import schema
from models.Post import Post
from models.User import User
from models.Comment import Comment`
要获取所有用户,可以使用 Masonite 的。集合实例上用户模型的所有方法调用返回:
`@app.get("/api/v1/users", response_model=List[schema.UserResult])
def get_all_users():
users = User.all()
return users.all()`
确保导入typing.List
:
要添加用户,请添加以下发布端点:
`@app.post("/api/v1/users", response_model=schema.UserResult)
def add_user(user_data: schema.UserCreate):
user = User.where("email", user_data.email).get()
if user:
raise HTTPException(status_code=400, detail="User already exists")
user = User()
user.email = user_data.email
user.name = user_data.name
user.address = user_data.address
user.sex = user_data.sex
user.phone_number = user_data.phone_number
user.save() # saves user details to the database.
return user`
导入http 异常:
`from fastapi import FastAPI, HTTPException`
检索单个用户:
`@app.get("/api/v1/users/{user_id}", response_model=schema.UserResult)
def get_single_user(user_id: int):
user = User.find(user_id)
return user`
发布端点:
`@app.get("/api/v1/posts", response_model=List[schema.PostResult])
def get_all_posts():
all_posts = Post.all()
return all_posts.all()
@app.get("/api/v1/posts/{post_id}", response_model=schema.PostResult)
def get_single_post(post_id: int):
post = Post.find(post_id)
return post
@app.post("/api/v1/posts", response_model=schema.PostResult)
def add_new_post(post_data: schema.PostCreate):
user = User.find(post_data.user_id)
if not user:
raise HTTPException(status_code=400, detail="User not found")
post = Post()
post.title = post_data.title
post.body = post_data.body
post.user_id = post_data.user_id
post.save()
user.attach("posts", post)
return post`
我们将来自 API 的数据保存到数据库上的posts
表中,然后为了将帖子链接到用户,我们附加了它,以便当我们调用user.posts()
时,我们可以获得用户的所有帖子。
注释端点:
`@app.post("/api/v1/{post_id}/comments", response_model=schema.CommentResult)
def add_new_comment(post_id: int, comment_data: schema.CommentCreate):
post = Post.find(post_id)
if not post:
raise HTTPException(status_code=400, detail="Post not found")
user = User.find(comment_data.user_id)
if not user:
raise HTTPException(status_code=400, detail="User not found")
comment = Comment()
comment.body = comment_data.body
comment.user_id = comment_data.user_id
comment.post_id = post_id
comment.save()
user.attach("comments", comment)
post.attach("comments", comment)
return comment
@app.get("/api/v1/posts/{post_id}/comments", response_model=List[schema.CommentResult])
def get_post_comments(post_id):
post = Post.find(post_id)
return post.comments.all()
@app.get("/api/v1/users/{user_id}/comments", response_model=List[schema.CommentResult])
def get_user_comments(user_id):
user = User.find(user_id)
return user.comments.all()`
如果 FastAPI 服务器尚未运行,请启动它:
`(.env)$ uvicorn main:app --reload`
导航到http://localhost:8000/docs查看所有端点的 Swagger/OpenAPI 文档。测试每个端点以查看响应。
试验
因为我们是好公民,我们会增加一些测试。
固定装置
让我们为上面的代码编写测试。因为我们将使用 pytest ,继续将依赖项添加到 requirements.txt 文件中:
我们还需要 HTTPX 库,因为 FastAPI 的 TestClient 基于它。也将其添加到需求文件中:
安装:
`(.env)$ pip install -r requirements.txt`
接下来,让我们为测试创建一个单独的配置文件,这样我们就不会覆盖主开发数据库中的数据。在“config”文件夹中,创建一个名为 test_config.py 的新文件:
`# config/test_config.py
from masoniteorm.connections import ConnectionResolver
DATABASES = {
"default": "sqlite",
"sqlite": {
"driver": "sqlite",
"database": "db.sqlite3",
}
}
DB = ConnectionResolver().set_connection_details(DATABASES)`
注意,它类似于我们在 config/database.py 文件中的内容。唯一的区别是我们将default
设置为sqlite
,因为我们想要使用 SQLite 进行测试。
为了将我们的测试套件设置为总是使用我们在 test_config.py 配置中的配置,而不是默认的 database.py 文件,我们可以使用 pytest 的 autouse fixture。
创建一个名为“tests”的新文件夹,并在这个新文件夹中创建一个 conftest.py 文件:
`import pytest
from masoniteorm.migrations import Migration
@pytest.fixture(autouse=True)
def setup_database():
config_path = "config/test_config.py"
migrator = Migration(config_path=config_path)
migrator.create_table_if_not_exists()
migrator.refresh()`
这里,我们将 Masonite 的迁移配置路径设置为 config/test_config.py 文件,创建了migration
表(如果之前还没有创建的话),然后刷新所有的迁移。因此,每个测试都将从数据库的一个干净副本开始。
现在,让我们为用户、帖子和评论定义一些装置:
`import pytest
from masoniteorm.migrations import Migration
from models.Comment import Comment
from models.Post import Post
from models.User import User
@pytest.fixture(autouse=True)
def setup_database():
config_path = "config/test_config.py"
migrator = Migration(config_path=config_path)
migrator.create_table_if_not_exists()
migrator.refresh()
@pytest.fixture(scope="function")
def user():
user = User()
user.name = "John Doe"
user.address = "United States of Nigeria"
user.phone_number = 123456789
user.sex = "male"
user.email = "[[email protected]](/cdn-cgi/l/email-protection)"
user.save()
return user
@pytest.fixture(scope="function")
def post(user):
post = Post()
post.title = "Test Title"
post.body = "this is the post body and can be as long as possible"
post.user_id = user.id
post.save()
user.attach("posts", post)
return post
@pytest.fixture(scope="function")
def comment(user, post):
comment = Comment()
comment.body = "This is a comment body"
comment.user_id = user.id
comment.post_id = post.id
comment.save()
user.attach("comments", comment)
post.attach("comments", comment)
return comment`
至此,我们现在可以开始编写一些测试了。
测试规格
在“tests”文件夹中创建一个名为 test_views.py 的新测试文件。
首先添加以下代码来实例化一个 TestClient :
`from fastapi.testclient import TestClient
from main import app # => FastAPI app created in our main.py file
client = TestClient(app)`
现在,我们将测试添加到:
- 保存用户
- 获取所有用户
- 使用用户 ID 获取单个用户
代码:
`from fastapi.testclient import TestClient
from main import app # => FastAPI app created in our main.py file
from models.User import User
client = TestClient(app)
def test_create_new_user():
assert len(User.all()) == 0 # Asserting that there's no user in the database
payload = {
"name": "My name",
"email": "[[email protected]](/cdn-cgi/l/email-protection)",
"address": "My full Address",
"sex": "male",
"phone_number": 123456789
}
response = client.post("/api/v1/users", json=payload)
assert response.status_code == 200
assert len(User.all()) == 1
def test_get_all_user_details(user):
response = client.get("/api/v1/users")
assert response.status_code == 200
result = response.json()
assert type(result) is list
assert len(result) == 1
assert result[0]["name"] == user.name
assert result[0]["email"] == user.email
assert result[0]["id"] == user.id
# Test to get a single user
def test_get_single_user(user):
response = client.get(f"/api/v1/users/{user.id}")
assert response.status_code == 200
result = response.json()
assert type(result) is dict
assert result["name"] == user.name
assert result["email"] == user.email
assert result["id"] == user.id`
运行测试以确保它们通过:
尝试为帖子和评论视图编写测试。
结论
在本教程中,我们介绍了如何将 Masonite ORM 与 FastAPI 一起使用。Masonite ORM 是一个相对较新的 ORM 库,有一个活跃的社区。如果您有使用 astorar ORM(或者任何其他基于 Python 的 ORM)的经验,Masonite ORM 应该很容易使用。
微服务和团队文化
微服务很可能成为过去十年的热门词汇之一。在设计系统时,这种软件架构已经成为一种流行的选择,许多公司选择基于这种范例来构建他们的应用程序。然而,微服务模式不仅仅基于技术转变,也是一种文化转变,它严重依赖于团队的结构。
在本文中,我将解释微服务的概念以及成功采用这种架构设计方法所需的团队文化或实践。
什么是微服务?
基于微服务的架构是一种软件设计,它将系统模块化为服务和子模块的集合。将软件模块分离成服务的范例并不新鲜。微服务是一种类似于面向服务的架构(SOA)的方法,它也需要将一个整体架构分解成更小的模块。两者之间的一个关键区别是,SOA 是为执行多个业务任务而构建的,而微服务是为了完成一个业务任务。
这些软件模块本身可能服务于一个单一的目的,但是作为一个整体,它们适合应用程序。该软件设计的技术目标是创建具有以下特征的网络可访问服务:
- 围绕单一用途的功能组织
- 高度可维护和可测试
- 松散耦合
- 可独立部署
- 由一个小团队拥有
我举一个电子商务网站的例子。这些系统通常包括订单管理、运输、支付和库存管理。当基于微服务方法构建这样的平台时,每个微服务都被视为一个黑盒,可通过其 API 从外部访问。
团队文化和生产力
与一些人的想法相反,微服务不仅仅是一种技术架构。范式本身在某种程度上是一种文化,或者至少需要文化的改变。不幸的是,这并没有得到像技术实现那样多的关注和重视。毫无疑问,它应该放在任何编程或代码细节之前。这通常被认为不太令人兴奋,但是对于企业来说,理解微服务在自主性、责任性、灵活性和技术自由等方面给开发团队带来的转变是很重要的。当一个公司采用这种范例时,松散耦合的和独立的团队对他们特定的产品或业务领域服务从头到尾都有一个清晰的理解,这反过来产生了一个更好质量的产品。
微服务团队可以专注于特定的服务,而不是整个应用程序。这使得定制需求和优化功能以获得更好的最终产品变得更加容易。在单个模块上工作使团队能够专注于业务能力以及如何最好地转化工作成果。
团队结构符合业务领域
梅尔文·康威在他的出版物中创造了一个短语“委员会如何发明?”这是由 Fred Brooks 在他的书《神话人月》中推广的,他称之为康威定律。康威定律(1967)指出,“设计系统的组织被限制生产这些组织的通信结构的副本的设计。”简而言之,康威提出了这样一个观点,即企业创建的系统反映了特定企业的组织结构。
这如何转化到软件开发领域?当您考虑业务中存在的挑战和约束时,就其通信结构而言,这些相同的问题将被投射到软件的开发方式上。这包括团队用来协调、交流和交付软件的方法。所有这些都会对最终产品产生影响。
许多数字公司倾向于根据部门(如营销、UX 设计、前端开发、移动应用开发、后端开发、数据库管理、IT 运营等)将团队组织成孤岛。这种传统的方法不一定是错的或坏的,这取决于你想要达到的目标。微服务文化应该影响团队的跨职能性。在这种模型中,独立团队将由拥有所有相关技能的人组成,以提供端到端的特定服务。
这种结构提高了对业务需求、团队节奏和软件交付的理解。
独立部署团队
正如前面提到的,微服务团队处理的服务独立于他们所适应的更大系统的其他组件。因此,团队可以采用诸如持续集成和持续交付(CI/CD)的实践,以及更敏捷的软件开发方法。此外,跨职能团队可以独立开发、测试、解决问题、部署和更新服务,从而加快部署和故障排除的周转时间。
例如,开发应用程序中支持支付的服务的团队可以添加对新支付方法的支持,并独立地将其发布到实际的应用程序环境中。
技术异质性
微服务架构最大的吸引力之一是它所呈现的技术异构性。这样做的好处是,开发团队可以独立选择最适合各自微服务的技术堆栈。因此,团队有更多的自主权来拼凑他们认为会增强他们所负责的服务的工具。然而,重要的是要注意,技术多样性在强所有权的团队模型中工作得最好,而不是集体所有权。
在强所有权中,拥有微服务的团队在编程范例、技术决策、部署实践和工具方面发号施令。这种方法给予团队很大的自主权,这有很多好处,但也增加了团队的责任。
另一方面,在集体所有权模型中,微服务可以由许多团队中的任何一个来改变。这样做的主要好处是,你可以把人们转移到需要他们的地方。
考虑到这一点,如果一家公司对其微服务有一个集体所有权模型,那么必须根据负责不同服务的所有团队的技能来选择技术,以避免关键人员依赖的陷阱。
两个披萨团队
说到微服务,一个经常被问到的问题是,“单个团队应该有多大?”你会失望地发现没有适合所有情况的神奇数字。在这个领域已经完成的研究更倾向于由 5 - 10 人组成的团队。诞生于亚马逊的一个流行原则是两块披萨规则。杰夫·贝索斯解释说,这个规则是基于这样一个想法,即每个团队不应该太大,以至于不能用两个披萨来喂饱它。
Amazon 采用了团队拥有他们管理的系统的整个生命周期的做法,目标是团队更加快速和高效。另一个例子是网飞。网飞显然从亚马逊树立的同样的例子中吸取了教训,围绕小型独立团队构建自己,这样创建的服务将相互独立。这有助于确保系统架构针对变化速度进行了优化。
结论
微服务架构在软件设计方面有很多优势。然而,这种模型的成功实现很大程度上依赖于负责开发和交付的团队的结构。
从 Heroku 迁移到 AWS
在本教程中,我抓住了两个主要目标:
- 给我的个人应用一个更专业的 UX
- 将我的总体托管成本降低 50%
我一直在使用 Heroku 的免费层来提供演示应用和创建沙盒教程。这是一个很好的服务,易于使用且免费,但它在初始页面加载时会有很长的滞后时间(大约 7 秒)。以任何人的标准来看,这都是一段很长的时间。根据 akamai.com和 kissmetrics 的说法,7 秒的加载时间,超过 25%的用户会在你的第一个 div 出现之前就放弃你的页面。我不想简单地升级到 Heroku 的付费等级,我想探索我的选择,并在这个过程中学习一些有用的技能。
更重要的是,我还有一个关于 Ghost 的托管博客。这是一个非常好的平台,但是有点贵。幸运的是,他们提供了他们的开源软件,并提供了一个很好的使用 Node 和 MySQL 的教程。你只需要一个地方来托管它。
通过与我的托管博客分道扬镳,从一台服务器上提供多种资源,我可以为我的个人应用程序提供更好的 UX,同时节省一些钱。这篇文章组织了网上一些最好的教程来快速安全地完成这项工作。
这需要几种不同的技术协同工作来实现目标:
技术 | 目的 |
---|---|
EC2 | 提供廉价、可靠的云计算能力 |
人的本质 | 处理我们程序运行的操作系统 |
码头工人 | 隔离层提供一致的执行环境 |
Nginx | 以可靠和安全的方式处理请求 |
Certbot | 提供 SSL/HTTPS 安全的 web 应用程序,进而提高 SSO(搜索引擎优化) |
《人鬼情未了》 | 提供一个具有 GUI 和持久性的简单博客 |
反应 | 允许快速、可组合的 web 应用程序 |
目标
- 主办个人项目,投资组合网站,博客->便宜,没有加载滞后时间
- 熟悉 Nginx
- 服务 HTTPS 加密网站
- Dockerize 反应
使用的技术
- 亚马逊 EC2
- 人的本质
- Nginx
- 反应
- 让我们加密和认证(SSL)
- 码头工人
- Ghost 博客平台
外卖食品
完成本教程后,您将能够:
- 设置 EC2 实例
- 设置 Nginx
- 用子域配置您的 DNS
- 在 EC2 实例上设置 Ghost 博客平台
- 对静态 React 应用程序进行 Dockerize
- 为静态站点服务
- 用加密和证书来配置 SSL
财务状况
当前托管解决方案(无延迟时间)
自托管选项
因此,对于一个托管解决方案,对于一个博客和一个应用程序,我每月将支付 26 美元,每个新应用程序每月将增加 7 美元。每年,每个额外的应用程序需要 312 美元+84 美元。通过这篇文章中概述的一些跑腿工作,我以每月不到 10 美元的费用托管了多个应用程序和一个博客。
我决定采用 AWS 解决方案。虽然它更贵,但它是一种超级受欢迎的企业技术,我想更加熟悉它。
谢谢
非常感谢所有参考资料的作者。这篇文章的大部分内容由链接和证明效果良好的资源片段组成,并包含了为满足我的需要而需要的细微修改。
也谢谢你的阅读。我们开始吧!
EC2 设置
下面是如何创建一个新的 EC2 实例。
资源:【https://www.nginx.com/blog/setting-up-nginx】T2
您真正需要的是上面的教程,以便设置 EC2 实例和安装 Nginx。自从 Nginx 在 Ghost 博客平台安装期间安装后,我就停止了 EC2 的创建。
弹性 IP
资源:https://docs . AWS . Amazon . com/AWS ec2/latest/user guide/elastic-IP-addresses-EIP . html
接下来,您将把 DNS(域名系统)指向 EC2 实例的公共 IP 地址。这意味着您不希望它因为任何原因而改变(例如,停止和启动实例)。有两种方法可以实现这一点:
这两个选项都提供了一个免费的静态 IP 地址。在本教程中,我使用了弹性 IP 来实现这个目标,因为在设置好服务器之后,添加到服务器上真的很简单。
按照上述资源中的步骤创建一个弹性 IP 地址,并将其与您的 EC2 实例相关联。
SSH 密钥
资源:https://www . digital ocean . com/community/tutorials/initial-server-setup-with-Ubuntu-16-04
我跟随这个教程到了 T...非常有效。您将使用自己的 SSH 密钥设置自己的超级用户,并创建一个防火墙来限制传入流量,只允许 SSH。
一分钟后,您将为请求打开 HTTP 和 HTTPS。
DNS 配置
我用 Name.com 的作为我的 DNS 主机,因为他们有一个不错的用户界面,并且在丹佛(我居住的地方)本地。我已经拥有了petej.org
,并且一直指向一个 github pages 托管的静态站点。我决定为博客建立一个子域-blog.petej.org-使用 A 记录指向我的 EC2 实例的公共 IP 地址。我创建了两个 A 记录,一个处理www
前缀,另一个处理空 URL:
现在通过命令行,使用dig
实用程序来检查新的 A 记录是否正在工作。这可以从本地机器或 EC2 实例完成:
`$ dig A blog.petej.org
; <<>> DiG 9.9.7-P3 <<>> A blog.petej.org
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 44050
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;blog.petej.org. IN A
;; ANSWER SECTION:
blog.petej.org. 300 IN A 35.153.44.46
;; Query time: 76 msec
;; SERVER: 75.75.75.75#53(75.75.75.75)
;; WHEN: Sat Jan 27 10:13:50 MST 2018
;; MSG SIZE rcvd: 59`
注意:A 记录几乎立即生效,但可能需要一个小时来解决以前使用该 URL 时产生的缓存问题。所以,如果你已经设置好了域名,这可能需要一点时间。
Nice:域名- > √。现在您需要让 EC2 实例提供一些内容!
Ghost 博客平台
资源:【https://docs.ghost.org/install/ubuntu/】T2
又一个很棒的教程。我一路跟着它,它是金色的。有些步骤我们已经在上面介绍过了,比如设置 Ubuntu 实例的最佳实践,所以你可以跳过这些步骤。确保从更新包部分开始(在服务器设置下)。
注意:严格按照这个顺序进行设置。第一次,我忽略了为 MySQL 数据库设置用户,最后不得不从机器上移除 Ghost,重新安装,然后从头开始。
在逐步完成 Ghost 安装过程后,您现在应该有一个在您的域名下运行的博客了——在浏览器中查看一下吧!
中途回顾
你完成了什么?
- Ubuntu 服务器启动并运行
- SSH 访问我们的服务器
- 安装了 Ghost 平台
- Nginx 处理传入流量
- 自主持博客,up!
那么下一步是什么?
您现在将:
- 安装 git 并设置对 GitHub 帐户的 SSH 访问
- 对静态 React 应用程序进行 Dockerize
- 在 EC2 实例上设置 Docker
- 配置 Nginx 反向代理层,将流量路由到 React 应用程序
- 将 SSL 证书与您的博客和 react 应用程序相关联,以便可以在 HTTPS 提供这些证书
向前...
必须有饭桶
在 EC2 实例上安装 git:
`$ sudo apt-get install git`
专门为 GitHub 访问创建一个新的 SSH 密钥:https://help . GitHub . com/articles/generating-a-new-SSH-key-and-add-it-to-the-SSH-agent
因为你之前为 Ubuntu 服务器设置了用户,所以/根目录和你的 ~目录(用户的主目录)是不一样的。考虑到这一点,在ssh-add
步骤中改为这样做:
`cp /root/.ssh/id_rsa ~/.ssh/id_rsa
cd ~/.ssh
ssh-add`
复制输出并将其作为一个新的 SSH 密钥添加到 GitHub 中,详见下面的链接。
从开始第二步->https://help . github . com/articles/add-a-new-ssh-key-to-your-github-account
您已经设置好git
。克隆,然后提交一个 repo,以确保一切都连接正确。
静态反应应用程序
资源:https://medium . com/ai2-blog/dockerizing-a-react-application-3563688 a 2378
一旦你用 Docker 在本地运行了 React 应用程序,将图片上传到 Docker Hub :
你需要一个 Docker Hub 账户->https://hub.docker.com
`$ docker login
Username:
Password:`
`$ docker tag <image-name> <username>/<image-name>:<tag-name>
$ docker push <username>/<image-name>`
这需要一段时间。大约 5 分钟。咖啡时间...
我们回来了。继续登录 GitHub,确保你的图片已经上传。
现在回到您的 EC2 实例。嘘它。
安装 docker:
`$ sudo apt install docker.io`
在本地拉下您最近上传的 Docker 图像:
`$ sudo docker pull <username>/<image-name>`
获取图像 id 并使用它启动应用程序:
`$ sudo docker images
# Copy the image ID
$ sudo docker run -d -it -p 5000:5000 <image-id>`
既然 React 应用程序已经运行,那么让我们通过设置 Nginx 配置来公开它。
React 应用程序的 Nginx 设置
资源:https://www . digital ocean . com/community/tutorials/how-to-install-nginx-on-Ubuntu-16-04
注意:我没有像教程建议的那样使用/etc/nginx/sites-available/default,而是专门为 URL 制作了一个> 文件,完全不用理会默认文件。
我们还需要建立一个符号链接:
`$ sudo ln -s /etc/nginx/sites-available/circle-grid.petej.org.conf /etc/nginx/sites-enabled/`
注意:为什么是符号链接?如你所见,如果你在 /etc/nginx/nginx.conf 中查找,只有 /sites-enabled 中的文件被考虑在内。符号链接将通过在 sites_available 文件中表示这个文件,使它能够被 Nginx 发现,来为我们处理这个问题。如果您以前使用过 Apache,您将会熟悉这种模式。您也可以像删除文件一样删除符号链接:
rm ./path/to/symlink
。
关于“符号链接”的更多信息:http://man pages . Ubuntu . com/man pages/xenial/en/man 7/symlink . 7 . html
让我们用 Certbot 加密
现在,要确保 Certbot 配置了一个 cron 作业来自动续订您的证书,请运行以下命令:
如果有一个 certbot 文件在里面,你就可以开始了。
如果没有,请按照下列步骤操作:
-
手动测试续订流程:
$ sudo certbot renew --dry-run
-
如果成功,则:
$ nano /etc/cron.d/certbot
-
将这一行添加到文件中:
0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && perl -e 'sleep int(rand(3600))' && certbot -q renew
-
省省吧,都搞定了。
现在,您已经将任务配置为每 12 小时运行一次,该任务将升级任何在 30 天内过期的证书。
结论
您现在应该能够:
- 设置 EC2 实例
- 设置 Nginx
- 用子域配置您的 DNS
- 搭建一个幽灵博客平台
- 将 React 应用程序归档
- 提供一个静态 React 应用程序
- 配置 SSL ->让我们加密和认证
我希望这是一个有用的链接和教程集合,可以帮助你使用个人应用服务器。如有任何问题或意见,请随时联系我。
感谢阅读。
MLOps 工具包
与通用应用编程相比,机器学习(ML)是一个相对较新的工作领域。现在,硬件和软件都存在,以支持大规模的 ML 项目,使公司更好地做出决策,技术领域已经出现了 ML 的工具和解决方案。这就产生了一个新的领域,叫做 MLOps。正如机器学习可靠性工程简介中所述,MLOps 将 DevOps 中的最佳实践——协作、版本控制、自动化测试、合规性、安全性和 CI/CD——应用于生产 ML。
考虑到这一点,在本文中,我们将回顾您开始 ML 开发所需的主要工具和技术,特别关注 MLOps 以及 MLOps 如何对高效地生产 ML 模型至关重要。
ml 生命周期
尽管 MLOps 领域出现的时间不长,但是由于 ML 生命周期的复杂性,MLOps 的技术前景是广阔的。在本文中,我们将讨论 ML 开发中的以下主题:
- 数据和特征管理
- 数据存储
- 数据准备
- 数据探索
- 专题报道
- 模型开发
- 模型注册表
- 模型训练和验证
- 版本控制
- 操作化
- 自动化测试
- 持续集成、部署和培训(CI/CD/CT)
- 模型服务基础设施
- 可解释性
- 再现性
- 监视
- 业务绩效监控
- 模型监控
- 技术/系统监控
- 发信号
- 自动再培训
记住这些主题,典型的 ML 生命周期由以下阶段、子阶段和组件组成:
请记住,上面介绍的 ML 生命周期是期望的生命周期。由于 MLOps 还不是一个成熟的领域,因此许多列出的组件在大多数生命周期中都没有使用。公司在构建和扩展他们的 ML 产品时,应该致力于完善他们的 ML 生命周期。
让我们更详细地看看每个阶段和组件以及相关的 MLOps 工具。
数据和特征管理
数据存储
ML 工程师消耗来自数据库、仓库和湖泊的数据。他们还直接使用来自第三方工具、API、电子表格、文本文件等等的数据。ML 工作通常包括在清理、旋转、丰富等之后存储多个版本的数据。为了保存 ML 开发过程中的中间步骤生成的数据,您可以使用下面列出的一个或多个数据存储:
数据存储 | 目的 | 例子 |
---|---|---|
数据库 | 存储事务性和非事务性非分析性数据 | 关系型 ( MySQL , Oracle , PostgreSQL , MS SQL Server ),非关系型 ( MongoDB , InfluxDB , Cassandra , Elasticsearch , CosmosDB ,nep |
数据仓库 | 存储 OLAP 的分析数据 | 红移,雪花,火弩箭, Azure SQL DW , Teradata |
数据湖 | 存储各种数据以供发现和下游交付 | Azure Blob Storage ,亚马逊 S3 |
流媒体 | 临时存储实时数据供消费者使用(发布/订阅,MQ) | 卡夫卡, AWS Kinesis , SQS , RabbitMQ , ZeroMQ |
第三方集成 | 业务不可或缺的 SaaS 软件产品 | Salesforce , Pipedrive , SurveyMonkey , Google Forms |
API | 作为 API 公开的基于数据的服务或 SaaS 软件产品 | 谷歌地图 API ,比特币基地 API ,雅虎财经 API |
电子表格和平面文件 | 广泛使用的文件格式,因为易于操作和采用 | 微软 Excel ,谷歌工作表 |
数据准备
一旦收集了数据,下一步就是为消费做准备。这一步包括必要的数据重组、格式化和清理。接下来是勘探和分析,以及提高数据质量。有些工作可以完全手动完成,但其余的可以自动完成并内置到 ML 管道中。
数据探索
要连接到前面提到的数据源,您需要一种 ML 编程语言、一个编写程序的好的代码编辑器(或 IDE)以及专门为处理大量 ML 数据而构建的工具。有了它们,在开始开发 ML 模型时,您可以开始探索数据、编写 SQL 查询和构建 PySpark 转换。
资源类型 | 目的 | 例子 |
---|---|---|
语言 | 编写 ML 代码的框架 | Python , Julia , R , Scala , Java |
5 月 | 集成开发环境丰富了工具、快捷方式和集成,有助于更快地编写和调试代码 | VSCode , Atom , Zeppelin , Jupyter , Databricks , Floyd , SageMaker |
包转发率 | 利用分布式计算能力的大规模并行处理系统 | Hadoop , Spark |
云平台 | AWS 和 GCP 等主要云提供商以及 DataRobot 等小众厂商为 ML 提供的基于云的产品 | BigML , Azure ML ,大台库, H20 , DataRobot |
使用这些工具中的两个或更多的组合,您将处于开始编写 ML 模型的最佳位置。我们将在这里讨论在哪里存储您准备输入到您的模型中的数据,以及您的 ML 模型的实际代码。
专题报道
在清理、准备和分析数据之后,您可以开始构建一个特征库,它是一个用于创建和训练 ML 模型的数据属性集合(通常是表格形式)。它可以在数据库、数据仓库、对象存储、本地实例等等中创建。
模型开发
模型注册表
创建 ML 模型是一个渐进的过程。核心思想是不断改进模型,这就是为什么 MLOps 增加了另一个关键的连续性支柱,称为持续培训(CT) 。除了持续集成(CI)和持续部署(CD),持续培训是 ML 开发生命周期的核心。为了维护模型血统、源代码版本和注释,您可以使用模型注册中心。
模型注册中心由前面提到的所有信息和给定模型可用的所有其他信息组成。你可以使用像 MLFlow model registry 或者 SageMaker model registry 这样的工具来存储关于你的模型的信息。
在 ML 流水线的每一步,一个组件做出决策;这些决定被传递到管道的下一步。ML 元数据存储帮助您存储和检索关于管道中不同步骤的元数据。元数据可以帮助您追溯所做的决策、所使用的超参数以及用于训练模型的数据。你可以使用像谷歌的 ML 元数据(MLMD)和 T2 的元数据(T3)组件这样的工具和库作为元数据存储。
模型训练和验证
您可以使用一个或多个数据探索部分的资源来定型和验证模型。例如,如果您选择用 Python 编写代码,您可以访问特定的 Python 库进行训练和验证,比如 TensorFlow、PyTorch、Theano、PySpark 等。
库名 | 因...闻名 | 语言 |
---|---|---|
TensorFlow | 大规模 ML 和神经网络部署 | Python,Java,JavaScript |
PySpark/Spark | 大规模类似 SQL 的数据分析 | Python,Scala |
Keras | 深度学习,神经网络 | 计算机编程语言 |
Scikit-Learn | Python 的通用 ML 库 | 计算机编程语言 |
Brain.js | 用于 ML 和神经网络的 JavaScript 库 | Java Script 语言 |
mllib | 在 Spark 上运行 | Python,Scala |
OpenCV | 专门研究计算机视觉的 ML 和 AI 库 | Java、Python |
版本控制
源代码版本管理是任何软件开发中最关键的领域之一。它使团队能够在相同的代码上工作,因此每个人都可以同时对进度做出贡献。几十年前,Subversion、Mercurial 和 CVS 是主要的版本控制系统。现在,Git 是版本控制的事实上的标准。Git 是开源软件(OSS ),可以安装在服务器上。你也可以选择使用一个基于云的服务。以下是一些主要的问题:
ML 的专用源代码控制
您可能还想看看数据版本控制 (DVC),这是另一个用于源代码控制的开源软件工具,专门为数据科学和 ML 项目开发。DVC 是 Git 兼容的,通过使用基于 DAG 的格式对完整的 ML 项目进行端到端的版本控制来支持 ML 开发。如果底层数据在 DAG 中的特定步骤之前没有变化,这有助于防止重复的数据处理。
虽然 Git 是为版本化应用程序代码而编写的,但它并不意味着存储 ML 模型使用的大量数据或支持 ML 管道。DVC 试图解决这两个问题。DVC 对存储层是不可知的,可以用于任何源代码控制服务,如 GitHub、GitLab 等。
操作化
自动化测试
正如在机器学习可靠性工程介绍中提到的,有两种方法来测试 ML 模型:
- 测试模型的编码逻辑
- 测试模型的输出/精度
前者或多或少类似于测试软件应用程序,你应该以同样的方式自动化。然而,后者涉及测试模型置信度、重要模型度量的性能等。ML 测试通常以混合的方式进行,其中一些测试是自动化的,是流水线的一部分,而其他的则完全是手工的。
基于 ML 发展的阶段,测试可以分为两种不同的类型——培训前和培训后。
训练前测试不需要训练参数。这些测试可以检查(a)模型输出是否位于特定的预期范围内,以及(b)ML 模型的形状是否与数据集对齐。您可以手动执行其中的许多测试。
训练后的测试更有影响力,也更有意义,因为它们是在经过训练的模型工件上运行的,这意味着 ML 模型在运行测试之前已经完成了它的工作。这让我们对模型的行为有了更深入的了解。
测试是开发过程中不可或缺的一部分。您可以在 ML 开发工作流程的不同阶段进行不同类型的测试。下面列出了一些可能的测试:
测试类型 | 在哪里测试 | 目的 |
---|---|---|
单元测试 | 当地发展环境 | 即时反馈 |
集成测试 | 当地发展环境 | 捕捉再衰退 |
单元和集成测试 | 较低的非生产环境 | 代码升级 |
模型验证测试 | 较低的非生产环境 | 部分验证测试 |
端到端测试 | 非生产环境 | 系统健全性 |
模型验证测试 | 非生产环境 | 完成验证测试 |
端到端测试 | 生产前环境 | 系统健全,生产就绪 |
模型验证测试 | 生产前环境 | 完整的验证测试,准确性 |
模糊测试 | 生产前环境 | 系统可靠性 |
A/B 测试 | 生产前环境 | 模型比较 |
监视 | 生产前和生产环境 | 实时反馈 |
持续集成、部署和培训(CI/CD/CT)
不像在软件应用程序中,应用程序生成的数据与应用程序工件分离,在 ML 开发中,训练数据在概念上是模型工件的一部分。没有训练,你不能测试模型的有效性或模型的良好性。这就是为什么通常的 CI/CD 过程在 ML 开发中通过添加另一个组成部分——持续培训而得到增强。代码集成和部署之后总是要进行培训。一旦模型被训练,循环继续。
模型服务基础设施
在创建您的 ML 模型之后,您必须决定在哪里部署您的模型——例如,您将使用哪个平台来服务您的 ML 模型。许多选项可用于从您的本地服务器提供模型。本地服务器可能适用于某些用例。对其他人来说,大规模管理可能会变得极其昂贵和困难。或者,你可以使用一个或多个基于云的平台,比如 AWS、Azure 和 GCP,来服务你的模型。
无论您使用本地服务器、云平台还是混合方法,您仍然可以选择将您的模型作为可执行文件直接部署到您的实例或容器中。存在许多试图解决端到端 ML 模型服务问题的 PaaS 产品。这通常是 ML 模型管道的最后一步。在此之后,像模型监控这样的部署后活动仍然存在。
一些常见的部署选项包括:
部署方式 | 部署在哪里 |
---|---|
、、、、、、、【kfserving】、【ml】、 | 任何地方 |
AWS Lambda , AWS SageMaker , AWS ECS , AWS 弹性接口 | 自动警报系统 |
Azure 功能, Azure 容器实例 | 蔚蓝的 |
谷歌云运行 | 谷歌 |
监视
监控模型涉及三个方面:
- 技术/系统监控查看模型基础设施是否正常运行,以及模型是否得到正确服务。这包括通过跟踪服务级别指标来处理服务级别协议,并由机器学习可靠性工程师来处理。
- 模型监控同时通过其输出监控模型,将输出与实际输入数据进行比较。模型监控是通过检查假阳性和假阴性、精确度、召回率、F1 分数、R 平方、偏差等等来不断验证预测的准确性。
- 有了业务绩效监控,一切都归结于模型是否对业务有帮助。你应该能够监控由不同团队运行的实验的影响。您应该监控在一个版本中推进的模型变更的影响,并将它们与先前的结果进行比较。
在高度发达的 MLOps 工作流程中,监控应该是主动的,而不是被动的。您不仅应该为基础设施相关的指标定义服务级别目标,还应该为模型相关的指标定义服务级别目标。对这些指标的严格监控意味着定义服务级别目标,并跟踪服务级别指标是否违反服务级别协议。这样做使您能够不断地调整和改进模型。这是 ML 工作流难题的最后一步。开发周期从这里开始重复。
发信号
现代系统如此复杂,以至于手动监控毫无意义。你需要软件来判断其他软件是否运行正常。您应该定义监控数据的规则,而不是手动浏览应用程序日志、决策树和建议日志。这些规则基于服务级别目标和服务级别协议。当违反规则条件时,您可以设置触发器。例如:
- 向 PagerDuty 之类的寻呼机应用程序发送推送通知。
- 在 Slack、Microsoft Teams 等协作通信工具中发送警报。
- 发送电子邮件或短信。
发送警报的渠道取决于您用于管理生产问题的应用程序和流程。警报需要小心处理;否则,发送太多警报会让你的生活变得一团糟。穿过噪音很有挑战性。为了让你的警报系统正常工作,你需要做出有意识的、深思熟虑的选择。
自动再培训
ML 模型只有不断地被训练和再训练才能保持相关性。当有新的真实数据输入系统时,可以进行训练和再训练,并根据真实输出测试模型的预测。监控为警报引擎提供信息,但您也可以配置监控系统来为模型本身提供信息,从而在整个 ML 生命周期中创建一个反馈循环。这是 ML 模型可以得到的最有价值的反馈,因为反馈是在生产部署之后得到的。
根据数据模型的实现或项目的架构,许多新的范例,如 AutoML ,AutoAI,自调优系统变得非常重要。您可以从众多工具中选择一个适合您的需求,并找到一种有效的方法来重新训练您的模型。请记住,重新培训不会导致代码变化;它只向 ML 模型本身提供新的生产数据和 ML 的模型输出。
结论
本文研究了 MLOps 如何融入 ML 生命周期。我们还研究了在开发、部署和服务 ML 模型时需要了解和使用的各种工具。
更多工具和框架,请查看牛逼的生产机器学习 repo。
在评估 MLOps 工具时,请记住大多数工具相对较新,因此几乎不存在端到端工作流。正因为如此,不要定居在单一平台。确保工具可以根据您的需求移入和移出。
使用 Cypress 进行现代前端测试
原文:https://testdriven.io/blog/modern-frontend-testing-with-cypress/
Cypress 是一个现代的 web 自动化测试框架,旨在简化浏览器测试。虽然它以 Selenium 替代品而闻名,但它不仅仅是一个端到端的测试自动化工具。Cypress 是一个开发工具,是开发人员主动使用的,而不是专注于事后测试的非技术 QA 团队。
这篇文章着眼于当你用 Flask 和 React 构建一个应用程序时,如何将 Cypress 引入到你的测试驱动开发工作流中。
示例应用程序
我们将使用 Flask 和 React 构建一个基本的 todo 应用程序,基于以下用户案例:
- 作为用户,我可以看到列表中的所有待办事项
- 作为用户,我可以在列表中添加新的待办事项
- 作为用户,我可以切换每个待办事项的完成状态
假设我们只关注客户端。换句话说,我们需要创建一个 React todo 应用程序,它通过 AJAX 与 Flask 后端交互,以获取和添加 todo。如果您想继续编码,克隆出 flask-react-cypress repo,然后将 v1 标记签出到主分支:
`$ git clone https://github.com/testdrivenio/flask-react-cypress --branch v1 --single-branch
$ cd flask-react-cypress
$ git checkout tags/v1 -b master`
工作流程
这个工作流程关注于集成测试,其中开发和测试使用类似 TDD 的方法同时进行:
- 将用户故事、需求和验收标准转化为部分测试规范
- 添加设备并切断网络呼叫
- 运行 Cypress GUI,并在代码编辑器旁边打开它
- 使用。只需要关注和迭代单个测试
- 确保测试失败
- 编码直到测试通过(红色、绿色、重构)
- 重复前面的三个步骤,直到所有测试都是绿色的
- 可选:通过删除网络存根将集成测试转换为端到端测试
想看看这个工作流程的运行情况吗?查看我的 Cypress 工作流程视频。
初始设置
步骤:
- 将用户故事、需求和验收标准转化为部分测试规范
- 添加设备并切断网络呼叫
- 运行 Cypress GUI,并在代码编辑器旁边打开它
创建部分测试规格
将部分测试规范添加到名为client/cypress/integration/todos . spec . js的新文件中:
`describe('todo app', () => { beforeEach(() => { cy.visit('/'); cy.get('h1').contains('Todo List'); }); it('should display the todo list', () => {}); it('should add a new todo to the list', () => {}); it('should toggle a todo correctly', () => {}); });`
然后,在一个env
键下添加baseUrl
和serverUrl
——服务器端 Flask 应用程序的 URL 这样它就可以作为一个环境变量被 client/cypress.json 访问:
`{ "baseUrl": "http://localhost:3000", "env": { "serverUrl": "http://localhost:5009" } }`
添加装置
在我们处理请求之前,让我们添加 fixture 文件,这些文件将用于模拟从以下服务器端端点返回的数据:
- 获取-/待办事项-获取所有待办事项
- 发布/待办事项-添加待办事项
client/cypress/fixtures/todos/all _ before . JSON:
`{ "data": { "todos": [ { "complete": false, "created_date": "Mon, 28 Jan 2019 15:32:28 GMT", "id": 1, "name": "go for a walk" }, { "complete": false, "created_date": "Mon, 28 Jan 2019 15:32:28 GMT", "id": 2, "name": "go for a short run" }, { "complete": true, "created_date": "Mon, 28 Jan 2019 15:32:28 GMT", "id": 3, "name": "clean the stereo" } ] }, "status": "success" }`
client/cypress/fixtures/todos/add . JSON:
`{ "name": "make coffee" }`
这个最后的 fixture 是为了在添加一个新的 todo 后获取所有的 todo。
client/cypress/fixtures/todos/all _ after . JSON:
`{ "data": { "todos": [ { "complete": false, "created_date": "Mon, 28 Jan 2019 15:32:28 GMT", "id": 1, "name": "go for a walk" }, { "complete": false, "created_date": "Mon, 28 Jan 2019 15:32:28 GMT", "id": 2, "name": "go for a short run" }, { "complete": true, "created_date": "Mon, 28 Jan 2019 15:32:28 GMT", "id": 3, "name": "clean the stereo" }, { "complete": false, "created_date": "Mon, 28 Jan 2019 17:22:35 GMT", "id": 4, "name": "drink a beverage" } ] }, "status": "success" }`
然后,将夹具添加到测试规范中的beforeEach
中:
`beforeEach(() => { // fixtures cy.fixture('todos/all_before.json').as('todosJSON'); cy.fixture('todos/add.json').as('addTodoJSON'); cy.fixture('todos/all_after.json').as('updatedJSON'); cy.visit('/'); cy.get('h1').contains('Todo List'); });`
存根网络呼叫
`beforeEach(() => { // fixtures cy.fixture('todos/all_before.json').as('todosJSON'); cy.fixture('todos/add.json').as('addTodoJSON'); cy.fixture('todos/all_after.json').as('updatedJSON'); // network stub cy.server(); cy.route('GET', `${serverUrl}/todos`, '@todosJSON').as('getAllTodos'); cy.visit('/'); cy.wait('@getAllTodos'); cy.get('h1').contains('Todo List'); });`
将环境变量serverUrl
的值赋给一个变量:
`const serverUrl = Cypress.env('serverUrl');`
向should add a new todo to the list
测试添加存根:
`it('should add a new todo to the list', () => { // network stubs cy.server(); cy.route('GET', `${serverUrl}/todos`, '@updatedJSON').as('getAllTodos'); cy.route('POST', `${serverUrl}/todos`, '@addTodoJSON').as('addTodo'); });`
开放的柏树
在一个终端窗口中运行 React 应用:
`$ cd client
$ npm install
$ npm start`
然后,在不同的窗口中打开 Cypress GUI:
`$ cd client
$ ./node_modules/.bin/cypress open`
发展
步骤:
- 使用。只需要关注和迭代单个测试
- 确保测试失败
- 编码直到测试通过(红色、绿色、重构)
- 重复前面的三个步骤,直到所有测试都是绿色的
显示所有待办事项
更新测试:
`it.only('should display the todo list', () => { cy.get('li').its('length').should('eq', 3); cy.get('li').eq(0).contains('go for a walk'); });`
然后,更新App
组件:
`import React, { Component } from 'react';
import axios from 'axios';
class App extends Component {
constructor() {
super();
this.state = {
todos: []
};
};
componentDidMount() {
this.getTodos();
};
getTodos() {
axios.get('http://localhost:5009/todos')
.then((res) => { this.setState({ todos: res.data.data.todos }); })
.catch((err) => { });
};
render() {
return (
<div className="App">
<section className="section">
<div className="container">
<div className="columns">
<div className="column is-half">
<h1 className="title is-1">Todo List</h1>
<hr/>
<ul type="1">
{this.state.todos.map(todo =>
<li key={ todo.id } style={{ fontSize: '1.5rem' }}>{ todo.name }</li>
)}
</ul>
</div>
</div>
</div>
</section>
</div>
);
};
};
export default App;`
在componentDidMount
中,一个 AJAX 请求被发送到服务器端以获取所有的 todos。当响应返回时,在成功处理程序中调用setState
,该处理程序重新呈现组件,显示 todo 列表。
测试现在应该通过了:
添加待办事项
从通过的测试中删除.only
,并更新下一个测试:
`it.only('should add a new todo to the list', () => { // network stubs cy.server(); cy.route('GET', 'http://localhost:5009/todos', '@updatedJSON').as('getAllTodos'); cy.route('POST', 'http://localhost:5009/todos', '@addTodoJSON').as('addTodo'); // asserts cy.get('.input').type('drink a beverage'); cy.get('.button').contains('Submit').click(); cy.wait('@addTodo'); cy.wait('@getAllTodos'); cy.get('li').its('length').should('eq', 4); cy.get('li').eq(0).contains('go for a walk'); cy.get('li').eq(3).contains('drink a beverage'); });`
再次更新组件:
`import React, { Component } from 'react';
import axios from 'axios';
class App extends Component {
constructor() {
super();
this.state = {
todos: [],
input: ''
};
this.handleChange = this.handleChange.bind(this);
this.addTodo = this.addTodo.bind(this);
};
componentDidMount() {
this.getTodos();
};
getTodos() {
axios.get('http://localhost:5009/todos')
.then((res) => { this.setState({ todos: res.data.data.todos }); })
.catch((err) => { });
};
handleChange(e) {
this.setState({ input: e.target.value });
};
addTodo() {
if(this.state.input.length) {
axios.post('http://localhost:5009/todos', { name: this.state.input })
.then((res) => { this.getTodos(); })
.catch((err) => { });
}
};
render() {
return (
<div className="App">
<section className="section">
<div className="container">
<div className="columns">
<div className="column is-half">
<h1 className="title is-1">Todo List</h1>
<hr/>
<div className="content">
<div className="field has-addons">
<div className="control">
<input
className="input"
type="text"
placeholder="Add a todo"
onChange={ this.handleChange }
/>
</div>
<div className="control" onClick={ this.addTodo }>
<button className="button is-info">Submit</button>
</div>
</div>
<ul type="1">
{this.state.todos.map(todo =>
<li key={ todo.id } style={{ fontSize: '1.5rem' }}>{ todo.name }</li>
)}
</ul>
</div>
</div>
</div>
</div>
</section>
</div>
);
};
};
export default App;`
现在可以通过输入字段添加待办事项。每当输入值改变时,触发onChange
事件,更新状态。单击 submit 按钮后,AJAX POST 请求和输入字段中的值一起被发送到服务器。当返回成功的响应时,todo 列表被更新。
切换完成状态
测试:
`it.only('should toggle a todo correctly', () => { cy .get('li') .eq(0) .contains('go for a walk') .should('have.css', 'text-decoration', 'none solid rgb(74, 74, 74)'); cy.get('li').eq(0).contains('go for a walk').click(); cy .get('li') .eq(0).contains('go for a walk') .should('have.css', 'text-decoration', 'line-through solid rgb(74, 74, 74)'); });`
组件:
`import React, { Component } from 'react';
import axios from 'axios';
class App extends Component {
constructor() {
super();
this.state = {
todos: [],
input: ''
};
this.handleChange = this.handleChange.bind(this);
this.addTodo = this.addTodo.bind(this);
this.handleClick = this.handleClick.bind(this);
};
componentDidMount() {
this.getTodos();
};
getTodos() {
axios.get('http://localhost:5009/todos')
.then((res) => { this.setState({ todos: res.data.data.todos }); })
.catch((err) => { });
};
handleChange(e) {
this.setState({ input: e.target.value });
};
addTodo() {
if(this.state.input.length) {
axios.post('http://localhost:5009/todos', { name: this.state.input })
.then((res) => { this.getTodos(); })
.catch((err) => { });
}
};
handleClick(id) {
this.setState ({
todos: this.state.todos.map (todo => {
if (todo.id === id) {
todo.complete = !todo.complete;
}
return todo;
}),
});
};
render() {
return (
<div className="App">
<section className="section">
<div className="container">
<div className="columns">
<div className="column is-half">
<h1 className="title is-1">Todo List</h1>
<hr/>
<div className="content">
<div className="field has-addons">
<div className="control">
<input
className="input"
type="text"
placeholder="Add a todo"
onChange={ this.handleChange }
/>
</div>
<div className="control" onClick={ this.addTodo }>
<button className="button is-info">Submit</button>
</div>
</div>
<ul type="1">
{this.state.todos.map(todo =>
<li
key={ todo.id }
style={{
textDecoration: todo.complete ? 'line-through' : 'none',
fontSize: '1.5rem',
}}
onClick={() => this.handleClick(todo.id)}
>
{ todo.name }
</li>
)}
</ul>
</div>
</div>
</div>
</div>
</section>
</div>
);
};
};
export default App;`
当 todo li
被点击时,handleClick
被触发,然后切换todo.complete
布尔值的值。如果布尔值为true
,那么待办事项的text-direction
将被设置为line-through
。
从最终测试中移除.only
。
端到端测试
最后,让我们通过移除网络存根和设备,将集成测试转换为端到端测试。我们也会做一些重构。
client/cypress/integration/todos-e2e . spec . js:
`const serverUrl = Cypress.env('serverUrl'); describe('todo app - e2e', () => { beforeEach(() => { // network call cy.server(); cy.route('GET', `${serverUrl}/todos`).as('getAllTodos'); cy.visit('/'); cy.wait('@getAllTodos'); cy.get('h1').contains('Todo List'); }); it('should display the todo list', () => { cy.get('li').its('length').should('eq', 2); cy.get('li').eq(0).contains('walk'); }); it('should toggle a todo correctly', () => { cy .get('li') .eq(0) .contains('walk') .should('have.css', 'text-decoration', 'none solid rgb(74, 74, 74)'); cy.get('li').eq(0).contains('walk').click(); cy .get('li') .eq(0).contains('walk') .should('have.css', 'text-decoration', 'line-through solid rgb(74, 74, 74)'); }); });`
在运行这些之前,您需要启动服务器端的 Flask 应用程序和 Postgres,并创建和播种数据库:
`$ cd server
$ docker-compose up -d --build
$ docker-compose exec web python manage.py recreate_db
$ docker-compose exec web python manage.py seed_db`
现在,由于完整的端到端测试需要运行 Flask 应用程序,您可能不希望在开发时在本地运行它们。要忽略它们,将ignoreTestFiles
配置变量添加到 cypress.json 文件中:
`{ "baseUrl": "http://localhost:3000", "env": { "serverUrl": "http://localhost:5009" }, "ignoreTestFiles": "*e2e*" }`
然后,您可以通过使用不同的配置文件在其他环境中运行它们。
结论
Cypress 是一个强大的工具,它使设置、编写、运行和调试测试变得容易。希望这篇文章向您展示了将 Cypress 整合到您的开发工作流中是多么容易。
资源:
- 最终代码
- 我的柏树工作流程视频
- 柏树助你获得更好的睡眠幻灯片
- 滑下测试金字塔博文
Python 中的现代测试驱动开发
测试产品级代码很难。有时候,在特性开发过程中,它会占用你几乎所有的时间。更重要的是,即使你有 100%的覆盖率并且测试是绿色的,你仍然不能确信新的特性将在生产中正常工作。
本指南将带你通过使用测试驱动开发 (TDD)来开发应用程序。我们将看看您应该如何测试以及测试什么。我们将使用 pytest 进行测试,使用 pydantic 验证数据并减少所需的测试数量,使用 Flask 通过 RESTful API 为我们的客户提供接口。最后,您将拥有一个可以用于任何 Python 项目的可靠模式,这样您就可以相信通过测试实际上意味着软件可以工作。
完整 Python 指南:
目标
完成本文后,您将能够:
- 解释你应该如何测试你的软件
- 配置 pytest 并为测试设置一个项目结构
- 用迂腐定义数据库模型
- 使用 pytest fixtures 来管理测试状态和执行副作用
- 根据 JSON 模式定义验证 JSON 响应
- 用命令(修改状态,有副作用)和查询(只读,无副作用)组织数据库操作
- 使用 pytest 编写单元、集成和端到端测试
- 解释为什么将测试工作集中在测试行为上而不是实现细节上很重要
我应该如何测试我的软件?
软件开发人员往往对测试非常固执己见。正因为如此,他们对测试的重要性和如何进行测试有不同的看法。也就是说,让我们看看三条指导方针,(希望)大多数开发人员都会同意,它们将帮助您编写有价值的测试:
-
测试应该告诉你被测单元的预期行为。因此,建议保持简短和切题。给定,WHEN,THEN 结构可以对此有所帮助:
- 假设-测试的初始条件是什么?
- 什么时候发生了什么需要测试?
- 那么,预期的反应是什么?
因此,您应该为测试准备好环境,执行行为,并在最后检查输出是否符合预期。
-
每个行为都应该测试一次——而且只能测试一次。多次测试相同的行为并不意味着你的软件更有可能工作。测试也需要维护。如果你对你的代码库做了一个小的改变,然后二十个测试中断了,你怎么知道哪个功能中断了?当只有一个测试失败时,找到 bug 就容易多了。
-
每个测试必须独立于其他测试。否则,您将很难维护和运行测试套件。
这位导游也固执己见。不要把任何东西当成圣杯或银弹。欢迎在 Twitter ( @jangiacomelli )上联系,讨论与本指南相关的任何事情。
基本设置
就这样,让我们把手弄脏。您已经准备好了解所有这些在现实世界中意味着什么。pytest 最简单的测试如下所示:
`def another_sum(a, b):
return a + b
def test_another_sum():
assert another_sum(3, 2) == 5`
这个例子你可能已经见过至少一次了。首先,你永远不会在你的代码库中编写测试,所以让我们把它分成两个文件和包。
为此项目创建一个新目录,并移入其中:
`$ mkdir testing_project
$ cd testing_project`
接下来,创建(并激活)一个虚拟环境。
关于管理依赖关系和虚拟环境的更多信息,请查看现代 Python 环境。
第三,安装 pytest:
`(venv)$ pip install pytest`
之后,创建一个名为“sum”的新文件夹。添加一个 init。py 到新文件夹,把它变成一个包,连同一个的另一个 _sum.py 文件:
`def another_sum(a, b):
return a + b`
添加另一个名为“tests”的文件夹,并添加以下文件和文件夹:
`└── tests
├── __init__.py
└── test_sum
├── __init__.py
└── test_another_sum.py`
您现在应该已经:
`├── sum
│ ├── __init__.py
│ └── another_sum.py
└── tests
├── __init__.py
└── test_sum
├── __init__.py
└── test_another_sum.py`
在 test_another_sum.py 中添加:
`from sum.another_sum import another_sum
def test_another_sum():
assert another_sum(3, 2) == 5`
接下来,在“测试”文件夹中添加一个空的 conftest.py 文件,用于存储 pytest 夹具。
最后,添加一个pytest . ini——一个 py test 配置文件——到“tests”文件夹,这个文件夹也可以是空的。
完整的项目结构现在应该看起来像这样:
`├── sum
│ ├── __init__.py
│ └── another_sum.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_sum
├── __init__.py
└── test_another_sum.py`
将您的测试放在一个包中可以让您:
- 在所有测试中重用 pytest 配置
- 在所有测试中重用夹具
- 简化测试的运行
您可以使用以下命令运行所有测试:
`(venv)$ python -m pytest tests`
您应该会看到测试的结果,在本例中是针对test_another_sum
:
`============================== test session starts ==============================
platform darwin -- Python 3.10.1, pytest-7.0.1, pluggy-1.0.0
rootdir: /testing_project/tests, configfile: pytest.ini
collected 1 item
tests/test_sum.py/test_another_sum.py . [100%]
=============================== 1 passed in 0.01s ===============================`
真实应用
既然您已经对如何设置和构建测试有了基本的概念,让我们构建一个简单的博客应用程序。我们将使用 TDD 来构建它,以查看实际测试。我们将使用 Flask 作为我们的 web 框架,为了专注于测试,我们将使用 SQLite 作为我们的数据库。
我们的应用程序将有以下要求:
- 可以创建文章
- 文章可以拿来
- 文章可以列出来
首先,让我们创建一个新项目:
`$ mkdir blog_app
$ cd blog_app`
其次,创建(并激活)一个虚拟环境。
第三,安装 pytest 和 pydantic ,一个数据解析和验证库:
`(venv)$ pip install pytest && pip install "pydantic[email]"`
pip install "pydantic[email]"
安装 pydantic 和电子邮件验证器,用于验证电子邮件地址。
接下来,创建以下文件和文件夹:
`blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ └── models.py
└── tests
├── __init__.py
├── conftest.py
└── pytest.ini`
将以下代码添加到 models.py 中,用 pydantic 定义一个新的Article
模型:
`import os
import sqlite3
import uuid
from typing import List
from pydantic import BaseModel, EmailStr, Field
class NotFound(Exception):
pass
class Article(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
author: EmailStr
title: str
content: str
@classmethod
def get_by_id(cls, article_id: str):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles WHERE id=?", (article_id,))
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
@classmethod
def get_by_title(cls, title: str):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles WHERE title = ?", (title,))
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
@classmethod
def list(cls) -> List["Article"]:
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute("SELECT * FROM articles")
records = cur.fetchall()
articles = [cls(**record) for record in records]
con.close()
return articles
def save(self) -> "Article":
with sqlite3.connect(os.getenv("DATABASE_NAME", "database.db")) as con:
cur = con.cursor()
cur.execute(
"INSERT INTO articles (id,author,title,content) VALUES(?, ?, ?, ?)",
(self.id, self.author, self.title, self.content)
)
con.commit()
return self
@classmethod
def create_table(cls, database_name="database.db"):
conn = sqlite3.connect(database_name)
conn.execute(
"CREATE TABLE IF NOT EXISTS articles (id TEXT, author TEXT, title TEXT, content TEXT)"
)
conn.close()`
这是一个活动记录样式的模型,它提供了存储、获取单篇文章和列出所有文章的方法。
您可能想知道为什么我们没有编写测试来覆盖这个模型。我们很快就会知道为什么。
创建新文章
接下来,我们来看看我们的业务逻辑。我们将编写一些助手命令和查询来将我们的逻辑从模型和 API 中分离出来。因为我们使用 pydantic,所以我们可以很容易地基于我们的模型验证数据。
在“tests”文件夹中创建一个“test_article”包。然后,向其中添加一个名为 test_commands.py 的文件。
`blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ └── models.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_article
├── __init__.py
└── test_commands.py`
将以下测试添加到 test_commands.py 中:
`import pytest
from blog.models import Article
from blog.commands import CreateArticleCommand, AlreadyExists
def test_create_article():
"""
GIVEN CreateArticleCommand with valid author, title, and content properties
WHEN the execute method is called
THEN a new Article must exist in the database with the same attributes
"""
cmd = CreateArticleCommand(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super awesome article"
)
article = cmd.execute()
db_article = Article.get_by_id(article.id)
assert db_article.id == article.id
assert db_article.author == article.author
assert db_article.title == article.title
assert db_article.content == article.content
def test_create_article_already_exists():
"""
GIVEN CreateArticleCommand with a title of some article in database
WHEN the execute method is called
THEN the AlreadyExists exception must be raised
"""
Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super extra awesome article"
).save()
cmd = CreateArticleCommand(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super awesome article"
)
with pytest.raises(AlreadyExists):
cmd.execute()`
这些测试涵盖以下业务用例:
- 应该为有效数据创建文章
- 文章标题必须是唯一的
从您的项目目录运行测试,查看它们是否失败:
`(venv)$ python -m pytest tests`
现在我们可以执行我们的命令了。
将一个 commands.py 文件添加到“博客”文件夹中:
`from pydantic import BaseModel, EmailStr
from blog.models import Article, NotFound
class AlreadyExists(Exception):
pass
class CreateArticleCommand(BaseModel):
author: EmailStr
title: str
content: str
def execute(self) -> Article:
try:
Article.get_by_title(self.title)
raise AlreadyExists
except NotFound:
pass
article = Article(
author=self.author,
title=self.title,
content=self.content
).save()
return article`
测试夹具
我们可以使用 pytest fixtures 在每次测试后清空数据库,并在每次测试前创建一个新的。Fixtures 是用@pytest.fixture
装饰器装饰的函数。它们通常位于 conftest.py 中,但是也可以添加到实际的测试文件中。默认情况下,这些功能会在每次测试前执行。
一种选择是在测试中使用它们的返回值。例如:
`import random
import pytest
@pytest.fixture
def random_name():
names = ["John", "Jane", "Marry"]
return random.choice(names)
def test_fixture_usage(random_name):
assert random_name`
因此,要在测试中使用从 fixture 返回的值,您只需要将 fixture 函数的名称作为参数添加到测试函数中。
另一个选择是执行一个副作用,比如创建一个数据库或者模仿一个模块。
您也可以使用yield
而不是return
在测试前和测试后运行夹具的一部分。例如:
`@pytest.fixture
def some_fixture():
# do something before your test
yield # test runs here
# do something after your test`
现在,将下面的 fixture 添加到 conftest.py 中,这将在每次测试之前创建一个新的数据库,并在测试之后删除它:
`import os
import tempfile
import pytest
from blog.models import Article
@pytest.fixture(autouse=True)
def database():
_, file_name = tempfile.mkstemp()
os.environ["DATABASE_NAME"] = file_name
Article.create_table(database_name=file_name)
yield
os.unlink(file_name)`
将autouse
标志设置为True
,以便在测试套件中的每个测试之前(和之后)默认自动使用。因为我们使用数据库进行所有的测试,所以使用这个标志是有意义的。这样,您就不必显式地将设备名称作为参数添加到每个测试中。
如果您碰巧不需要访问数据库进行测试,您可以使用测试标记禁用
autouse
。你可以在这里看到这个的例子。
再次运行测试:
`(venv)$ python -m pytest tests`
他们应该通过。
如您所见,我们的测试只测试了CreateArticleCommand
命令。我们不测试实际的Article
模型,因为它不负责业务逻辑。我们知道该命令按预期工作。因此,没有必要编写任何额外的测试。
列出所有文章
下一个要求是列出所有文章。我们在这里将使用查询而不是命令,因此将名为 test_queries.py 的新文件添加到“test_article”文件夹中:
`from blog.models import Article
from blog.queries import ListArticlesQuery
def test_list_articles():
"""
GIVEN 2 articles stored in the database
WHEN the execute method is called
THEN it should return 2 articles
"""
Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super extra awesome article"
).save()
Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="Another Article",
content="Super awesome article"
).save()
query = ListArticlesQuery()
assert len(query.execute()) == 2`
运行测试:
`(venv)$ python -m pytest tests`
他们应该失败。
将一个 queries.py 文件添加到“博客”文件夹中:
`blog_app
├── blog
│ ├── __init__.py
│ ├── app.py
│ ├── commands.py
│ ├── models.py
│ └── queries.py
└── tests
├── __init__.py
├── conftest.py
├── pytest.ini
└── test_article
├── __init__.py
├── test_commands.py
└── test_queries.py`
现在我们可以实现我们的查询了:
`from typing import List
from pydantic import BaseModel
from blog.models import Article
class ListArticlesQuery(BaseModel):
def execute(self) -> List[Article]:
articles = Article.list()
return articles`
尽管这里没有参数,但为了一致性,我们继承了BaseModel
。
再次运行测试:
`(venv)$ python -m pytest tests`
他们现在应该通过了。
按 ID 获取文章
通过 ID 获取一篇文章的方法与列出所有文章的方法类似。为GetArticleByIDQuery
添加一个新的测试到 test_queries.py 。:
`from blog.models import Article
from blog.queries import ListArticlesQuery, GetArticleByIDQuery
def test_list_articles():
"""
GIVEN 2 articles stored in the database
WHEN the execute method is called
THEN it should return 2 articles
"""
Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super extra awesome article"
).save()
Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="Another Article",
content="Super awesome article"
).save()
query = ListArticlesQuery()
assert len(query.execute()) == 2
def test_get_article_by_id():
"""
GIVEN ID of article stored in the database
WHEN the execute method is called on GetArticleByIDQuery with an ID
THEN it should return the article with the same ID
"""
article = Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super extra awesome article"
).save()
query = GetArticleByIDQuery(
id=article.id
)
assert query.execute().id == article.id`
运行测试以确保它们失败:
`(venv)$ python -m pytest tests`
接下来,将GetArticleByIDQuery
添加到 queries.py :
`from typing import List
from pydantic import BaseModel
from blog.models import Article
class ListArticlesQuery(BaseModel):
def execute(self) -> List[Article]:
articles = Article.list()
return articles
class GetArticleByIDQuery(BaseModel):
id: str
def execute(self) -> Article:
article = Article.get_by_id(self.id)
return article`
测试现在应该通过了:
`(venv)$ python -m pytest tests`
很好。我们已经满足了上述所有要求:
- 可以创建文章
- 文章可以拿来
- 文章可以列出来
它们都覆盖着测试。因为我们在运行时使用 pydantic 进行数据验证,所以我们不需要很多测试来涵盖业务逻辑,因为我们不需要编写验证数据的测试。如果author
不是有效的电子邮件,pydantic 将引发一个错误。所需要做的就是将author
属性设置为EmailStr
类型。我们也不需要测试它,因为 pydantic 的维护者已经在测试它了。
这样,我们就可以通过一个 Flask RESTful API 向外界公开这个功能了。
用烧瓶暴露 API
我们将介绍满足这一要求的三个端点:
/create-article/
-创建新文章/article-list/
-检索所有文章/article/<article_id>/
-取一件物品
首先,在“test_article”中创建一个名为“schemas”的文件夹,并向其中添加两个 JSON 模式, Article.json 和 ArticleList.json 。
文章. json :
`{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Article", "type": "object", "properties": { "id": { "type": "string" }, "author": { "type": "string" }, "title": { "type": "string" }, "content": { "type": "string" } }, "required": ["id", "author", "title", "content"] }`
ArticleList.json :
`{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "ArticleList", "type": "array", "items": {"$ref": "file:Article.json"} }`
JSON 模式用于定义来自 API 端点的响应。在继续之前,安装 jsonschema Python 库,它将用于根据定义的模式验证 JSON 有效负载,并安装 Flask:
`(venv)$ pip install jsonschema Flask`
接下来,让我们为我们的 API 编写集成测试。
将名为 test_app.py 的新文件添加到“test_article”中:
`import json
import pathlib
import pytest
from jsonschema import validate, RefResolver
from blog.app import app
from blog.models import Article
@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client
def validate_payload(payload, schema_name):
"""
Validate payload with selected schema
"""
schemas_dir = str(
f"{pathlib.Path(__file__).parent.absolute()}/schemas"
)
schema = json.loads(pathlib.Path(f"{schemas_dir}/{schema_name}").read_text())
validate(
payload,
schema,
resolver=RefResolver(
"file://" + str(pathlib.Path(f"{schemas_dir}/{schema_name}").absolute()),
schema # it's used to resolve the file inside schemas correctly
)
)
def test_create_article(client):
"""
GIVEN request data for new article
WHEN endpoint /create-article/ is called
THEN it should return Article in json format that matches the schema
"""
data = {
'author': "[[email protected]](/cdn-cgi/l/email-protection)",
"title": "New Article",
"content": "Some extra awesome content"
}
response = client.post(
"/create-article/",
data=json.dumps(
data
),
content_type="application/json",
)
validate_payload(response.json, "Article.json")
def test_get_article(client):
"""
GIVEN ID of article stored in the database
WHEN endpoint /article/<id-of-article>/ is called
THEN it should return Article in json format that matches the schema
"""
article = Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super extra awesome article"
).save()
response = client.get(
f"/article/{article.id}/",
content_type="application/json",
)
validate_payload(response.json, "Article.json")
def test_list_articles(client):
"""
GIVEN articles stored in the database
WHEN endpoint /article-list/ is called
THEN it should return list of Article in json format that matches the schema
"""
Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super extra awesome article"
).save()
response = client.get(
"/article-list/",
content_type="application/json",
)
validate_payload(response.json, "ArticleList.json")`
那么,这里发生了什么?
- 首先,我们将 Flask 测试客户机定义为一个 fixture,以便它可以在测试中使用。
- 接下来,我们添加了一个验证有效负载的函数。它需要两个参数:
payload
-来自 API 的 JSON 响应schema_name
—“模式”目录中模式文件的名称
- 最后,有三个测试,每个端点一个。在每个测试中,都有一个对 API 的调用和对返回的有效负载的验证
运行测试以确保它们在这一点上失败:
`(venv)$ python -m pytest tests`
现在我们可以编写 API 了。
更新 app.py 这样:
`from flask import Flask, jsonify, request
from blog.commands import CreateArticleCommand
from blog.queries import GetArticleByIDQuery, ListArticlesQuery
app = Flask(__name__)
@app.route("/create-article/", methods=["POST"])
def create_article():
cmd = CreateArticleCommand(
**request.json
)
return jsonify(cmd.execute().dict())
@app.route("/article/<article_id>/", methods=["GET"])
def get_article(article_id):
query = GetArticleByIDQuery(
id=article_id
)
return jsonify(query.execute().dict())
@app.route("/article-list/", methods=["GET"])
def list_articles():
query = ListArticlesQuery()
records = [record.dict() for record in query.execute()]
return jsonify(records)
if __name__ == "__main__":
app.run()`
我们的路由处理器非常简单,因为所有的逻辑都被命令和查询覆盖了。具有副作用的可用操作(如突变)由命令表示——例如,创建一篇新文章。另一方面,没有副作用的动作,那些只是读取当前状态的动作,被查询所覆盖。
本文中使用的命令和查询模式是 CQRS 模式的简化版本。我们把 CQRS 和克鲁德结合在一起。
上面的
.dict()
方法是由 pydantic 的BaseModel
提供的,我们所有的模型都继承了它。
测试应该通过:
`(venv)$ python -m pytest tests`
我们已经讨论了快乐之路的场景。在现实世界中,我们必须预料到客户不会总是像预期的那样使用 API。例如,当一个创建文章的请求在没有title
的情况下发出时,CreateArticleCommand
命令将引发一个ValidationError
,这将导致一个内部服务器错误和一个 HTTP 状态 500。这是我们想要避免的事情。因此,我们需要处理这样的错误,优雅地通知用户错误的请求。
让我们编写测试来涵盖这样的情况。将以下内容添加到 test_app.py :
`@pytest.mark.parametrize(
"data",
[
{
"author": "John Doe",
"title": "New Article",
"content": "Some extra awesome content"
},
{
"author": "John Doe",
"title": "New Article",
},
{
"author": "John Doe",
"title": None,
"content": "Some extra awesome content"
}
]
)
def test_create_article_bad_request(client, data):
"""
GIVEN request data with invalid values or missing attributes
WHEN endpoint /create-article/ is called
THEN it should return status 400
"""
response = client.post(
"/create-article/",
data=json.dumps(
data
),
content_type="application/json",
)
assert response.status_code == 400
assert response.json is not None`
我们使用 pytest 的参数化选项,这简化了将多个输入传递给单个测试的过程。
此时测试应该会失败,因为我们还没有处理ValidationError
:
`(venv)$ python -m pytest tests`
因此,让我们在 app.py 中为 Flask 应用程序添加一个错误处理程序:
`from pydantic import ValidationError
# Other code ...
app = Flask(__name__)
@app.errorhandler(ValidationError)
def handle_validation_exception(error):
response = jsonify(error.errors())
response.status_code = 400
return response
# Other code ...`
ValidationError
有一个errors
方法,该方法返回每个字段的所有错误列表,这些错误要么是缺失的,要么是传递了一个没有通过验证的值。我们可以简单地在主体中返回它,并将响应的状态设置为 400。
既然错误得到了适当的处理,所有测试都应该通过:
`(venv)$ python -m pytest tests`
代码覆盖率
现在,我们的应用程序已经测试完毕,是时候检查代码覆盖率了。因此,让我们为覆盖率安装一个名为 pytest-cov 的 pytest 插件:
`(venv)$ pip install pytest-cov`
安装插件后,我们可以像这样检查我们的博客应用程序的代码覆盖率:
`(venv)$ python -m pytest tests --cov=blog`
您应该会看到类似如下的内容:
`---------- coverage: platform darwin, python 3.10.1-final-0 ----------
Name Stmts Miss Cover
--------------------------------------
blog/__init__.py 0 0 100%
blog/app.py 25 1 96%
blog/commands.py 16 0 100%
blog/models.py 57 1 98%
blog/queries.py 12 0 100%
--------------------------------------
TOTAL 110 2 98%`
98%的覆盖率够好吗?可能是吧。尽管如此,记住一件事:高覆盖率固然很好,但是测试的质量更重要。如果只有 70%或更少的代码被覆盖,你应该考虑增加覆盖率。但是编写从 98%到 100%的测试通常是没有意义的。(同样,测试需要维护,就像您的业务逻辑一样!)
端到端测试
在这一点上,我们有一个经过全面测试的工作 API。我们现在可以看看如何编写一些端到端(e2e)测试。因为我们有一个简单的 API,所以我们可以编写一个 e2e 测试来涵盖以下场景:
- 创建新文章
- 列出文章
- 从列表中获取第一篇文章
首先,安装请求库:
`(venv)$ pip install requests`
其次,向 test_app.py 添加一个新的测试:
`import requests
# other code ...
@pytest.mark.e2e
def test_create_list_get(client):
requests.post(
"http://localhost:5000/create-article/",
json={
"author": "[[email protected]](/cdn-cgi/l/email-protection)",
"title": "New Article",
"content": "Some extra awesome content"
}
)
response = requests.get(
"http://localhost:5000/article-list/",
)
articles = response.json()
response = requests.get(
f"http://localhost:5000/article/{articles[0]['id']}/",
)
assert response.status_code == 200`
在运行这个测试之前,我们需要做两件事...
首先,通过将以下代码添加到 pytest.ini 中,向 pytest 注册一个名为e2e
的标记:
`[pytest] markers = e2e: marks tests as e2e (deselect with '-m "not e2e"')`
pytest 标记用于排除某些测试的运行,或者包括与其位置无关的选定测试。
要仅运行 e2e 测试,请运行:
`(venv)$ python -m pytest tests -m 'e2e'`
运行除 e2e 之外的所有测试:
`(venv)$ python -m pytest tests -m 'not e2e'`
e2e 测试的运行成本更高,而且需要启动并运行应用程序,所以你可能不想一直运行它们。
由于我们的 e2e 测试击中了一个现场服务器,我们需要旋转应用程序。在新的终端窗口中导航到项目,激活虚拟环境,然后运行应用程序:
`(venv)$ FLASK_APP=blog/app.py python -m flask run`
现在我们可以运行我们的 e2e 测试:
`(venv)$ python -m pytest tests -m 'e2e'`
您应该会看到一个 500 错误。为什么?单元测试没有通过吗?是的。问题是我们没有创建数据库表。我们在测试中使用了夹具来完成这项工作。所以让我们创建一个表和一个数据库。
向“博客”文件夹添加一个 init_db.py 文件:
`if __name__ == "__main__":
from blog.models import Article
Article.create_table()`
运行新脚本并再次启动服务器:
`(venv)$ python blog/init_db.py
(venv)$ FLASK_APP=blog/app.py python -m flask run`
如果您在运行 init_db.py 时遇到任何问题,您可能需要设置 Python 路径:
export PYTHONPATH=$PYTHONPATH:$PWD
。
测试现在应该通过了:
`(venv)$ python -m pytest tests -m 'e2e'`
测试金字塔
我们从单元测试(测试命令和查询)开始,然后是集成测试(测试 API 端点),最后是 e2e 测试。在简单的应用程序中,就像在这个例子中,您可能会以类似数量的单元测试和集成测试结束。一般来说,越复杂,就单元、集成和 e2e 测试之间的关系而言,你越应该看到一个金字塔形状。这就是“测试金字塔”这个术语的由来。
测试金字塔是一个可以帮助开发者创建高质量软件的框架。
使用测试金字塔作为指导,您通常希望测试套件中 50%的测试是单元测试,30%是集成测试,20%是 e2e 测试。
定义:
- 单元测试——测试单个代码单元
- 集成测试——多个单元一起工作的测试
- e2e——在一个类似生产的服务器上测试整个应用程序
你在金字塔中的位置越高,你的测试就越脆弱,越不可预测。更重要的是,e2e 测试是迄今为止运行最慢的,所以即使它们可以让你确信你的应用程序正在做你所期望的事情,你也不应该像单元测试或集成测试那样多。
什么是单位?
集成和 e2e 测试看起来很简单。关于单元测试有更多的讨论,因为你首先必须定义“单元”实际上是什么。大多数测试教程都展示了一个测试单个函数或方法的单元测试示例。生产代码从来没有这么简单。
首先,在定义一个单元是什么之前,让我们看看测试的意义是什么,应该测试什么。
为什么要测试?
我们编写测试的目的是:
- 确保我们的代码按预期运行
- 保护我们的软件不受退化的影响
尽管如此,当反馈周期太长时,开发人员往往会开始更多地考虑要编写的测试类型,因为时间是软件开发中的一个主要约束。这就是为什么我们希望有比其他类型的测试更多的单元测试。我们希望尽快找到并修复缺陷。
考什么?
现在你知道了为什么我们应该测试,我们现在必须看看我们应该测试什么。
我们应该测试我们软件的行为。(而且,是的:这仍然适用于 TDD,而不仅仅是 BDD 。)这是因为您不应该在每次代码库发生变化时都必须改变您的测试。
回想一下真实世界应用程序的例子。从测试的角度来看,我们不关心文章存储在哪里。它可以是一个文本文件,一些其他的关系数据库,或者一个键/值存储——这没关系。同样,我们的应用程序有以下要求:
- 可以创建文章
- 文章可以拿来
- 文章可以列出来
只要这些需求不变,存储介质的变化就不会破坏我们的测试。类似地,我们知道只要那些测试通过,我们就知道我们的软件满足那些需求——所以它在工作。
那么什么是单位呢?
每个函数/方法在技术上是一个单元,但是我们仍然不应该测试它们中的每一个。相反,应该将精力集中在测试模块/包中公开的函数和方法上。
在我们的例子中,这些是execute
方法。我们不期望直接从 Flask API 调用Article
模型,所以不要花太多精力(如果有的话)测试它。更准确地说,在我们的例子中,应该被测试的“单元”是来自命令和查询的execute
方法。如果某个方法不打算从我们软件的其他部分或最终用户直接调用,那么它可能是实现细节。因此,我们的测试抵制对实现细节的重构,这是优秀测试的品质之一。
例如,如果我们将get_by_id
和get_by_title
的逻辑包装在一个叫做_get_by_attribute
的“受保护”方法中,我们的测试仍然应该通过:
`# other code ...
class Article(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
author: EmailStr
title: str
content: str
@classmethod
def get_by_id(cls, article_id: str):
return cls._get_by_attribute("SELECT * FROM articles WHERE id=?", (article_id,))
@classmethod
def get_by_title(cls, title: str):
return cls._get_by_attribute("SELECT * FROM articles WHERE title = ?", (title,))
@classmethod
def _get_by_attribute(cls, sql_query: str, sql_query_values: tuple):
con = sqlite3.connect(os.getenv("DATABASE_NAME", "database.db"))
con.row_factory = sqlite3.Row
cur = con.cursor()
cur.execute(sql_query, sql_query_values)
record = cur.fetchone()
if record is None:
raise NotFound
article = cls(**record) # Row can be unpacked as dict
con.close()
return article
# other code ..`
另一方面,如果你在Article
内部做了一个突破性的改变,测试将会失败。这正是我们想要的。在这种情况下,我们既可以恢复重大变更,也可以在命令或查询中适应它。
因为有一件事我们正在努力:通过测试意味着工作软件。
什么时候应该使用模拟?
我们在测试中没有使用任何模拟,因为我们不需要它们。模仿模块或包中的方法或类会产生无法抵抗重构的测试,因为它们与实现细节相关联。这种测试经常出错,而且维护成本很高。另一方面,当速度成为问题时,模仿外部资源是有意义的(调用外部 API、发送电子邮件、长时间运行的异步进程等)。).
例如,我们可以单独测试Article
模型,并在我们对CreateArticleCommand
的测试中模拟它,如下所示:
`def test_create_article(monkeypatch):
"""
GIVEN CreateArticleCommand with valid properties author, title and content
WHEN the execute method is called
THEN a new Article must exist in the database with same attributes
"""
article = Article(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super awesome article"
)
monkeypatch.setattr(
Article,
"save",
lambda self: article
)
cmd = CreateArticleCommand(
author="[[email protected]](/cdn-cgi/l/email-protection)",
title="New Article",
content="Super awesome article"
)
db_article = cmd.execute()
assert db_article.id == article.id
assert db_article.author == article.author
assert db_article.title == article.title
assert db_article.content == article.content`
是的,这样做很好,但是我们现在有更多的测试要维护——也就是说,之前的所有测试加上Article
中方法的所有新测试。除此之外,test_create_article
现在唯一测试的就是save
返回的文章和execute
返回的文章是一样的。当我们打破Article
内部的东西时,这个测试仍然会通过,因为我们嘲笑它。这是我们想要避免的事情:我们想要测试软件行为,以确保它按预期工作。在这种情况下,行为被破坏,但我们的测试不会显示这一点。
外卖食品
- 没有唯一正确的方法来测试你的软件。尽管如此,当逻辑不与数据库耦合时,测试逻辑会更容易。您可以使用带有命令和查询的活动记录模式(CQRS)来帮助解决这个问题。
- 关注代码的商业价值。
- 不要测试方法只是为了说它们被测试过。你需要工作的软件,而不是经过测试的方法。TDD 只是一种工具,可以更快、更可靠地交付更好的软件。代码覆盖率也是如此:尽量保持高覆盖率,但不要为了 100%的覆盖率而增加测试。
- 一个测试只有当它保护你不回归,允许你重构,并且提供你快速的反馈时才有价值。因此,您应该努力使您的测试看起来像一个金字塔形状(50%单元,30%集成,20% e2e)。虽然,在简单的应用程序中,它可能看起来更像一所房子(40%的单元,40%的集成,20%的 e2e),这很好。
- 你越快发现回归,你就能越快拦截和纠正它们。你越快纠正它们,开发周期就越短。为了加速反馈,你可以在开发过程中使用 pytest 标记来排除 e2e 和其他缓慢的测试。您可以减少运行它们的频率。
- 只有在必要的时候才使用模拟(比如第三方 HTTP APIs)。它们使您的测试设置更加复杂,并且总体上降低了您的测试对重构的抵抗力。此外,它们可能导致假阳性。
- 再说一次,你的测试是一种负担而不是资产;他们应该涵盖你的软件的行为,但不要过度测试。
结论
这里有很多东西需要消化。请记住,这些只是用来展示想法的例子。你可以将同样的想法用于领域驱动设计(DDD)(BDD),以及许多其他方法。请记住,测试应该像对待任何其他代码一样对待:它们是负债而不是资产。编写测试来保护你的软件不受 bug 的影响,但是不要让它浪费你的时间。
想了解更多?
完整 Python 指南:
从 Flask 转移到 FastAPI
Python 是最流行的编程语言之一。从脚本到 API 开发再到机器学习——Python 都有足迹。它的受欢迎程度是由它对开发人员体验的关注和它提供的工具推动的。web 框架 Flask 就是这样一个工具,在机器学习社区中很流行。它也广泛用于 API 开发。但是一个新的框架正在崛起: FastAPI 。与 Flask 不同,FastAPI 是一个 ASGI(异步服务器网关接口)框架。与 Go 和 NodeJS 一样,FastAPI 是最快的基于 Python 的 web 框架之一。
这篇文章的目标读者是那些对从 Flask 迁移到 FastAPI 感兴趣的人,文章比较和对比了 Flask 和 FastAPI 中的常见模式。
FastAPI vs Flask
FastAPI 构建时考虑了这三个主要问题:
- 速度
- 开发者体验
- 开放标准
你可以把 FastAPI 看作是将 Starlette 、 Pydantic 、 OpenAPI 和 JSON 模式结合在一起的粘合剂。
- 在底层,FastAPI 使用 Pydantic 进行数据验证,使用 Starlette 进行工具验证,这使得它比 Flask 快得多,在 Node 或 Go 中提供了与高速 web APIs 相当的性能。
- starlette+uvicon提供异步请求能力,这是 Flask 所缺乏的。
- 使用 Pydantic 和类型提示,您可以获得一个良好的自动完成编辑器体验。您还可以获得数据验证、序列化和反序列化(用于构建 API),以及自动文档(通过 JSON Schema 和 OpenAPI)。
也就是说,Flask 的使用范围更广,所以它经过了考验,并且有更大的社区支持它。由于这两个框架都是可扩展的,Flask 是明显的赢家,因为它有巨大的插件生态系统。
建议:
- 如果您对上述三个问题有共鸣,厌倦了 Flask 扩展的过多选择,希望利用异步请求,或者只想建立一个 RESTful API,请使用 FastAPI。
- 如果您对 FastAPI 的成熟度不满意,需要使用服务器端模板构建一个全栈应用程序,或者离不开一些社区维护的 Flask 扩展,请使用 Flask。
入门指南
装置
像任何其他 Python 包一样,安装相当简单。
烧瓶
`pip install flask
# or
poetry add flask
pipenv install flask
conda install flask`
FastAPI
`pip install fastapi uvicorn
# or
poetry add fastapi uvicorn
pipenv install fastapi uvicorn
conda install fastapi uvicorn -c conda-forge`
与 Flask 不同,FastAPI 没有内置的开发服务器,所以需要一个像uvicon或 Daphne 这样的 ASGI 服务器。
“Hello World”应用程序
烧瓶
`# flask_code.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return {"Hello": "World"}
if __name__ == "__main__":
app.run()`
FastAPI
`# fastapi_code.py
import uvicorn
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def home():
return {"Hello": "World"}
if __name__ == "__main__":
uvicorn.run("fastapi_code:app")`
像reload=True
这样的参数可以传递到uvicorn.run()
中,以便为开发启用热重装。
或者,您可以直接从终端启动服务器:
`uvicorn run fastapi_code:app`
对于热重装:
`uvicorn run fastapi_code:app --reload`
配置
Flask 和 FastAPI 都提供了许多选项来处理不同环境的不同配置。两者都支持以下模式:
- 环境变量
- 配置文件
- 实例文件夹
- 类和继承
有关更多信息,请参考各自的文档:
烧瓶
`import os
from flask import Flask
class Config(object):
MESSAGE = os.environ.get("MESSAGE")
app = Flask(__name__)
app.config.from_object(Config)
@app.route("/settings")
def get_settings():
return { "message": app.config["MESSAGE"] }
if __name__ == "__main__":
app.run()`
现在,在运行服务器之前,设置适当的环境变量:
`export MESSAGE="hello, world"`
FastAPI
`import uvicorn
from fastapi import FastAPI
from pydantic import BaseSettings
class Settings(BaseSettings):
message: str
settings = Settings()
app = FastAPI()
@app.get("/settings")
def get_settings():
return { "message": settings.message }
if __name__ == "__main__":
uvicorn.run("fastapi_code:app")`
同样,在运行服务器之前,设置适当的环境变量:
`export MESSAGE="hello, world"`
路线、模板和视图
HTTP 方法
烧瓶
`from flask import request
@app.route("/", methods=["GET", "POST"])
def home():
# handle POST
if request.method == "POST":
return {"Hello": "POST"}
# handle GET
return {"Hello": "GET"}`
FastAPI
`@app.get("/")
def home():
return {"Hello": "GET"}
@app.post("/")
def home_post():
return {"Hello": "POST"}`
FastAPI 为每个方法提供了单独的装饰器:
`@app.get("/")
@app.post("/")
@app.delete("/")
@app.patch("/")`
URL 参数
通过管理状态的 URL(如/employee/1
)传递信息:
烧瓶
`@app.route("/employee/<int:id>")
def home():
return {"id": id}`
FastAPI
`@app.get("/employee/{id}")
def home(id: int):
return {"id": id}`
URL 参数的指定类似于 f 字符串表达式。此外,您可以利用类型提示。这里,我们在运行时告诉 Pydantic,id
的类型是int
。在开发中,这也可以导致更好的代码完成。
查询参数
与 URL 参数一样,查询参数(如/employee?department=sales
)也可以用于管理状态(通常用于过滤或排序):
烧瓶
`from flask import request
@app.route("/employee")
def home():
department = request.args.get("department")
return {"department": department}`
FastAPI
`@app.get("/employee")
def home(department: str):
return {"department": department}`
模板
烧瓶
`from flask import render_template
@app.route("/")
def home():
return render_template("index.html")`
默认情况下,Flask 在“templates”文件夹中查找模板。
FastAPI
你需要安装 Jinja :
实施:
`from fastapi import Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
def home(request: Request):
return templates.TemplateResponse("index.html", {"request": request})`
对于 FastAPI,您需要显式定义“templates”文件夹。然后,对于每个响应,需要提供请求上下文。
静态文件
烧瓶
默认情况下,Flask 从“static”文件夹中提供静态文件。
FastAPI
在 FastAPI 中,您需要为静态文件挂载一个文件夹:
`from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")`
异步任务
烧瓶
从 Flask 2.0 开始,您可以使用async
/ await
创建异步路由处理程序:
`@app.route("/")
async def home():
result = await some_async_task()
return result`
有关 Flask 中异步视图的更多信息,请查看 Flask 2.0 中的异步文章。
flask 中的异步也可以通过使用线程(并发)或多处理(并行)来实现,或者通过像 Celery 或 RQ 这样的工具来实现:
FastAPI
FastAPI 极大地简化了异步任务,因为它本身支持 asyncio。要使用,只需在视图函数中添加关键字async
:
`@app.get("/")
async def home():
result = await some_async_task()
return result`
FastAPI 还有一个后台任务特性,可以用来定义在返回响应后运行的后台任务。这对于不需要在发送回响应之前完成的操作非常有用。
`from fastapi import BackgroundTasks
def process_file(filename: str):
# process file :: takes minimum 3 secs (just an example)
pass
@app.post("/upload/{filename}")
async def upload_and_process(filename: str, background_tasks: BackgroundTasks):
background_tasks.add_task(process_file, filename)
return {"message": "processing file"}`
在这里,响应会立即发送,而不会让用户等待文件处理完成。
当您需要执行繁重的后台计算时,或者如果您需要一个任务队列来管理任务和工作者时,您可能希望使用 Celery 而不是BackgroundTasks
。更多信息,请参考【FastAPI 和 Celery 的异步任务。
依赖注入
烧瓶
尽管您可以实现自己的依赖注入解决方案,但 Flask 在默认情况下并没有真正的一流支持。相反,你会想要使用一个像烧瓶注射器的外部包。
FastAPI
另一方面,FastAPI 有一个强大的解决方案来处理依赖注入。
例如:
`from databases import Database
from fastapi import Depends
from starlette.requests import Request
from db_helpers import get_all_data
def get_db(request: Request):
return request.app.state._db
@app.get("/data")
def get_data(db: Database = Depends(get_db)):
return get_all_data(db)`
因此,get_db
将在应用程序的启动事件处理程序中获取对数据库连接创建的引用。依赖然后用于向 FastAPI 指示该路线“依赖于get_db
。因此,它应该在路由处理程序中的代码之前执行,并且结果应该“注入”到路由本身中。
数据有效性
烧瓶
Flask 没有任何内部数据验证支持。您可以通过 Flask-Pydantic 使用强大的 Pydantic 包进行数据验证。
FastAPI
FastAPI 如此强大的原因之一是它支持 Pydantic。
`from pydantic import BaseModel
app = FastAPI()
class Request(BaseModel):
username: str
password: str
@app.post("/login")
async def login(req: Request):
if req.username == "testdriven.io" and req.password == "testdriven.io":
return {"message": "success"}
return {"message": "Authentication Failed"}`
这里,我们接受模型Request
的输入。有效负载必须包含用户名和密码。
`# correct payload format
✗ curl -X POST 'localhost:8000/login' \
--header 'Content-Type: application/json' \
--data-raw '{\"username\": \"testdriven.io\",\"password\":\"testdriven.io\"}'
{"message":"success"}
# incorrect payload format
✗ curl -X POST 'localhost:8000/login' \
--header 'Content-Type: application/json' \
--data-raw '{\"username\": \"testdriven.io\",\"passwords\":\"testdriven.io\"}'
{"detail":[{"loc":["body","password"],"msg":"field required","type":"value_error.missing"}]}`
记下这个请求。我们将passwords
作为键而不是password
传入。Pydantic 模型自动告诉用户缺少password
字段。
序列化和反序列化
烧瓶
最简单的序列化方法是使用 jsonify :
`from flask import jsonify
from data import get_data_as_dict
@app.route("/")
def send_data():
return jsonify(get_data_as_dict)`
对于复杂的对象,Flask 开发者经常使用 Flask-Marshmallow 。
FastAPI
FastAPI 自动序列化任何返回的dict
。对于更复杂和结构化的数据,使用 Pydantic:
`from pydantic import BaseModel
app = FastAPI()
class Request(BaseModel):
username: str
email: str
password: str
class Response(BaseModel):
username: str
email: str
@app.post("/login", response_model=Response)
async def login(req: Request):
if req.username == "testdriven.io" and req.password == "testdriven.io":
return req
return {"message": "Authentication Failed"}`
这里,我们添加了一个有三个输入的Request
模型:用户名、电子邮件和密码。我们还定义了一个只有用户名和电子邮件的Response
模型。输入Request
模型处理反序列化,而输出Response
模型处理对象序列化。然后,响应模型通过 response_model 参数传递给装饰器。
现在,如果我们将请求本身作为响应返回,Pydantic
将省略password
,因为我们定义的响应模型不包含密码字段。
示例:
`# output
✗ curl -X POST 'localhost:8000/login' \
--header 'Content-Type: application/json' \
--data-raw '{\"username\":\"testdriven.io\",\"email\":\"[[email protected]](/cdn-cgi/l/email-protection)\",\"password\":\"testdriven.io\"}'
{"username":"testdriven.io","email":"[[email protected]](/cdn-cgi/l/email-protection)"}`
中间件
中间件用于在视图功能处理请求之前对每个请求应用逻辑。
烧瓶
`class middleware:
def __init__(self, app) -> None:
self.app = app
def __call__(self, environ, start_response):
start = time.time()
response = self.app(environ, start_response)
end = time.time() - start
print(f"request processed in {end} s")
return response
app = Flask(__name__)
app.wsgi_app = middleware(app.wsgi_app)`
FastAPI
`from fastapi import Request
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
print(f"request processed in {process_time} s")
return response`
@app.middleware("http")
装饰器是在 FastAPI 中创建中间件所必须的。上述中间件计算处理一个请求所花费的时间。在 view 函数处理完请求后,计算总处理时间并作为响应头发送回去。
`# flask output(logs)
request processed in 0.0010077953338623047 s
127.0.0.1 - - [22/Sep/2020 18:56:21] "GET / HTTP/1.1" 200 -
# fastapi output(logs)
request processed in 0.0009925365447998047 s
INFO: 127.0.0.1:51123 - "GET / HTTP/1.1" 200 OK`
模块性
随着一个应用程序的增长,在某个时候你会想要将相似的视图、模板、静态文件和模型组合在一起,以帮助将应用程序分解成更小的组件。
烧瓶
在 Flask 中,蓝图用于模块化:
`# blueprints/product/views.py
from flask import Blueprint
product = Blueprint("product", __name__)
@product.route("/product1")
...`
`# main.py
from blueprints.product.views import product
app.register_blueprint(product)`
FastAPI
同时,使用 FastAPI,通过一个 APIRouter 实现模块化:
`# routers/product/views.py
from fastapi import APIRouter
product = APIRouter()
@product.get("/product1")
...`
`# main.py
from routers.product.views import product
app.include_router(product)`
附加功能
自动文档
烧瓶
Flask 不会自动创建现成的 API 文档。然而,有几个扩展可以处理这个问题,比如 flask-swagger 和 Flask RESTX ,但是它们需要额外的设置。
FastAPI
FastAPI 默认支持 OpenAPI 以及 Swagger UI 和 ReDoc 。这意味着每一个端点都是从与该端点相关联的元数据中自动记录的。
All the registered endpoints are listed here
Alternative documentation
管理应用程序
烧瓶
Flask 有一个广泛使用的第三方管理包,叫做 Flask-Admin ,用于快速对你的模型执行 CRUD 操作。
FastAPI
在撰写本文时,有两个流行的 FastAPI 扩展:
- FastAPI Admin -功能管理面板,提供一个用户界面,用于对数据执行 CRUD 操作。
- SQLAlchemy Admin-FastAPI/Starlette 的管理面板,用于 SQLAlchemy 模型。
证明
烧瓶
虽然 Flask 没有原生解决方案,但有几个第三方扩展可用。
FastAPI
FastAPI 通过fastapi.security
包本地支持许多安全和认证工具。通过几行代码,您可以将基本的 HTTP 身份验证添加到您的应用程序中:
`import secrets
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
def get_current_username(credentials: HTTPBasicCredentials = Depends(security)):
correct_username = secrets.compare_digest(credentials.username, "stanleyjobson")
correct_password = secrets.compare_digest(credentials.password, "swordfish")
if not (correct_username and correct_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return credentials.username
@app.get("/whoami")
def who_ami_i(username: str = Depends(get_current_username)):
return {"username": username}`
FastAPI 通过 OpenAPI 标准实现 OAuth2 和 OpenID Connect 。
有关更多信息,请查看官方文档中的以下资源:
附加资源
克-奥二氏分级量表
CORS(跨来源资源共享)中间件检查请求是否来自允许的来源。如果是,请求将被传递到下一个中间件或视图功能。如果不是,它会拒绝请求,并向调用者返回一个错误响应。
烧瓶
Flask 需要一个名为 Flask-CORS 的外部包来支持 CORS:
基本实现:
`from flask_cors import CORS
app = Flask(__name__)
CORS(app)`
FastAPI
FastAPI 本机支持 CORS:
`from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
origins = ["*"]
app.add_middleware(CORSMiddleware, allow_origins=origins)`
测试
烧瓶
`import pytest
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return {"message": "OK"}
def test_hello():
res = app.test_client().get("/")
assert res.status_code == 200
assert res.data == b'{"message":"OK"}\n'`
FastAPI
`from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/")
async def home():
return {"message": "OK"}
client = TestClient(app)
def test_home():
res = client.get("/")
assert res.status_code == 200
assert res.json() == {"message": "OK"}`
FastAPI 提供了一个测试客户端。有了它,你可以直接用 FastAPI 运行pytest
。欲了解更多信息,请查阅官方文件中的测试指南。
部署
生产服务器
烧瓶
Flask 默认运行一个开发 WSGI (Web 服务器网关接口)应用服务器。对于生产,你需要使用生产级的 WSGI 应用服务器,比如 Gunicorn 、 uWSGI 或 mod_wsgi
安装 Gunicorn:
启动服务器:
`# main.py
# app = Flask(__name__)
gunicorn main:app`
FastAPI
由于 FastAPI 没有开发服务器,您将使用 Uvicorn(或 Daphne)进行开发和生产。
安装 Uvicorn:
启动服务器:
`# main.py
# app = FastAPI()
uvicorn main:app`
您可能希望使用 Gunicorn 来管理 uvicon,以便利用并发性(通过 uvicon)和并行性(通过 Gunicorn workers):
`# main.py
# app = FastAPI()
gunicorn -w 3 -k uvicorn.workers.UvicornWorker main:app`
码头工人
烧瓶
`FROM python3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "main:app"]`
这是 Flask 最简单的 docker 文件之一。要了解如何为生产进行全面配置,请查看带有 Postgres、Gunicorn 和 Nginx 的Dockerizing Flask教程。
FastAPI
`FROM python3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app"]`
同样,这是一个非常简单的配置。FastAPI 作者提供了几个生产就绪的 docker 文件。更多信息,请查看官方 FastAPI 文档以及dockering FastAPI with Postgres、Uvicorn 和 Traefik 教程。
结论
退一步说,Django 和 Flask 是两个最流行的基于 Python 的 web 框架(FastAPI 是第三流行的)。然而,他们(姜戈和弗拉斯克)有着非常不同的哲学。Flask 相对于 Django 的优势在于 Flask 是一个微框架。程序结构由程序员决定,并不强制执行。开发人员可以添加第三方扩展来改进他们认为合适的代码。也就是说,通常情况下,随着代码库的增长,几乎所有的 web 应用程序都需要一些通用的特性。这些特性与框架的紧密集成大大减少了最终开发人员需要自己创建和维护的代码。
本文中的代码示例传达了同样的意思。换句话说,FastAPI 包含了许多必需的特性。它还遵循严格的标准,使您的代码易于生产和维护。FastAPI 也是有据可查的。
虽然 FastAPI 可能不像 Flask 那样久经考验,但越来越多的开发人员正转向它来提供机器学习模型或开发 RESTful API。切换到 FastAPI 是一个可靠的选择。
官方文件
额外资源
支持 Django 的多种语言
Django 提供了现成的多语言支持。事实上,《姜戈》被翻译成 100 多种语言。本教程着眼于如何在 Django 项目中添加多语言支持。
目标
学完本教程后,您应该能够:
- 解释国际化和本地化的区别
- 向 URL 添加语言前缀
- 翻译模板
- 允许用户在语言之间切换
- 翻译模型
- 添加区域设置支持
项目设置
下面是对您将要构建的应用程序的快速浏览:
这看起来可能很简单,但是它会让你适应向 Django 添加国际化。
首先,从 django-lang repo 中克隆出 base 分支:
`$ git clone https://github.com/Samuel-2626/django-lang --branch base --single-branch
$ cd django-lang`
接下来,创建并激活虚拟环境,安装项目的依赖项,应用迁移,并创建超级用户:
`$ python3.9 -m venv env
$ source env/bin/activate
(env)$ pip install -r requirements.txt
(env)$ python manage.py makemigrations
(env)$ python manage.py migrate
(env)$ python manage.py createsuperuser`
你可以随意把 virtualenv 和 Pip 换成诗歌或 Pipenv 。更多信息,请查看现代 Python 环境。
注意 course/models.py 中的Course
型号:
`from django.db import models
class Course(models.Model):
title = models.CharField(max_length=90)
description = models.TextField()
date = models.DateField()
price = models.DecimalField(max_digits=10, decimal_places=2)
def __str__(self):
return self.title`
运行以下管理命令,向数据库添加一些数据:
`$ python manage.py add_courses`
在下一节中,我们将简要地看一下国际化和本地化。
国际化与本地化
国际化和本地化是一个硬币的两面。总之,它们允许您将 web 应用程序的内容交付给不同的地区。
- 国际化,用 i18n (18 是 I 和 n 之间的字母数)表示,是开发应用程序以供不同地区使用的过程。这个过程通常由开发人员处理。
- 本地化,用 l10n 表示(10 是 l 和 n 之间的字母数),另一方面是将你的应用程序翻译成特定语言和地区的过程。这一般由翻译来处理。
更多信息,请查看 W3C 的本地化与国际化。
回想一下,Django 通过其国际化框架已经被翻译成超过 100 种语言:
通过国际化框架,我们可以在 Python 代码和模板中轻松标记要翻译的字符串。它利用 GNU gettext 工具包来生成和管理一个纯文本文件,该文件代表一种被称为消息文件的语言。消息文件以结尾。po 作为其延伸。翻译完成后,会为每种语言生成另一个文件,以结束。mo 扩展。这就是所谓的编译翻译。
让我们从安装 gettext 工具包开始。
在 macOS 上,建议使用自制:
`$ brew install gettext
$ brew link --force gettext`
对于大多数 Linux 发行版,它是预安装的。最后,对于 Windows,安装步骤可以在这里找到。
在下一节中,我们将为国际化和本地化准备 Django 项目。
Django 的国际化框架
Django 在 settings.py 文件中提供了一些默认的国际化设置:
`# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True`
第一个设置是LANGUAGE_CODE
。默认情况下,它设置为美国英语(en-us)。这是一个特定于语言环境的名称。让我们将其更新为一个通用名称,英语(en)。
更多信息见语言标识符列表。
为了使 LANGUAGE_CODE 生效, USE_I18N 必须为True
,这将启用 Django 的翻译系统。
记下其余的设置:
`TIME_ZONE = 'UTC'
USE_L10N = True
USE_TZ = True`
注意事项:
让我们添加一些附加设置来补充现有设置:
`from django.utils.translation import gettext_lazy as _
LANGUAGES = (
('en', _('English')),
('fr', _('French')),
('es', _('Spanish')),
)`
这里发生了什么事?
- 我们指定了希望项目可用的语言。如果没有指定,Django 会认为我们的项目应该可以使用所有支持的语言。
- 该语言设置由语言代码和语言名称组成。回想一下,语言代码可以是特定于地区的,如“en-gb”,也可以是通用的,如“en”。
- 此外,
gettext_lazy
用于翻译语言名称,而不是gettext
以防止循环导入。当你在全局范围内时,你应该几乎总是使用 gettext_lazy 。
将django.middleware.locale.LocaleMiddleware
添加到MIDDLEWARE
设置列表中。这个中间件应该在SessionMiddleware
之后,因为LocaleMiddleware
需要使用会话数据。它也应该放在CommonMiddleware
之前,因为CommonMiddleware
需要活动语言来解析被请求的 URL。因此,顺序非常重要。
`MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', # new
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]`
该中间件用于根据请求数据确定当前语言。
为您的应用程序添加消息文件将驻留的区域设置路径目录:
`LOCALE_PATHS = [
BASE_DIR / 'locale/',
]`
Django 查看翻译文件的 LOCALE_PATHS 设置。请记住,最先出现的区域设置路径具有最高的优先级。
您需要在根项目中创建“locale”目录,并为每种语言添加一个新文件夹:
`locale
├── en
├── es
└── fr`
打开 shell 并从项目目录运行以下命令来创建一个。每种语言的 po 消息文件:
`(env)$ django-admin makemessages --all --ignore=env`
您现在应该已经:
`locale
├── en
│ └── LC_MESSAGES
│ └── django.po
├── es
│ └── LC_MESSAGES
│ └── django.po
└── fr
└── LC_MESSAGES
└── django.po`
记下其中一个。po 消息文件:
msgid
:表示源代码中出现的翻译字符串。msgstr
:表示语言翻译,默认为空。您必须为任何给定的字符串提供实际的翻译。
目前,只有我们的 settings.py 文件中的LANGUAGES
被标记为待翻译。因此,对于“fr”和“es”目录下的每个msgstr
,分别手动输入该单词的法语或西班牙语等价物。你可以编辑。来自常规代码编辑器的 po 文件;但是,建议使用专门为设计的编辑器。po 喜欢 Poedit 。
对于本教程,请进行以下更改:
`# locale/fr/LC_MESSAGES/django.po
msgid "English"
msgstr "Anglais"
msgid "French"
msgstr "Français"
msgid "Spanish"
msgstr "Espagnol"
# locale/es/LC_MESSAGES/django.po
msgid "English"
msgstr "Inglés"
msgid "French"
msgstr "Francés"
msgid "Spanish"
msgstr "Español"`
Poedit 示例:
接下来,让我们通过运行以下命令来编译消息:
`(env)$ django-admin compilemessages --ignore=env`
一个。已经为每种语言生成了 mo 编译消息文件;
`locale
├── en
│ └── LC_MESSAGES
│ ├── django.mo
│ └── django.po
├── es
│ └── LC_MESSAGES
│ ├── django.mo
│ └── django.po
└── fr
└── LC_MESSAGES
├── django.mo
└── django.po`
--
这一节到此为止!
到目前为止,您已经介绍了很多,在继续介绍其他概念之前,让我们回顾一下。回想一下,本教程的目标是教您如何在 Django 项目中添加多语言支持。在第一部分中,您设置了项目并查看了您将要构建的内容。然后,您学习了国际化和本地化之间的区别,以及 Django 国际化框架是如何工作的。最后,我们将项目配置为允许多语言支持,并看到了它的实际应用:
- 为我们的 Django 项目增加了国际化
- 设置我们希望项目可用的语言
- 通过
gettext_lazy
生成消息文件 - 手动添加翻译
- 编译了翻译
翻译模板、模型和表单
您可以通过使用gettext
或gettext_lazy
函数将模型字段名称和表单标记为翻译来翻译它们:
像这样编辑 course/models.py 文件:
`from django.db import models
from django.utils.translation import gettext_lazy as _
class Course(models.Model):
title = models.CharField(_('title'), max_length=90)
description = models.TextField(_('description'))
date = models.DateField(_('date'))
price = models.DecimalField(_('price'), max_digits=10, decimal_places=2)
def __str__(self):
return self.title`
`(env)$ django-admin makemessages --all --ignore=env`
随意手动或使用 Poedit 界面更新法语和西班牙语的msgstr
翻译,然后编译消息
`(env)$ django-admin compilemessages --ignore=env`
我们也可以通过添加标签来为表单做这件事。
例如:
`from django import forms
from django.utils.translation import gettext_lazy as _
class ExampleForm(forms.Form):
first_name = forms.CharField(label=_('first name'))`
为了翻译我们的模板,Django 提供了{% trans %}
和{% blocktrans %}
模板标签来翻译字符串。你必须在 HTML 文件的顶部添加{% load i18n %}
来使用翻译模板标签。
{% trans %}
模板标签允许你标记一个要翻译的文字。Django 只是在内部对给定的文本执行gettext
函数。
{% trans %}
标签对于简单的翻译字符串很有用,但是它不能处理包含变量的翻译内容。
另一方面,{% blocktrans %}
模板标签允许您标记包含文字和变量的内容。
更新course/templates/index . html文件中的以下元素以查看其运行情况:
`<h1>{% trans "TestDriven.io Courses" %}</h1>`
不要忘记在文件的顶部添加{% load i18n %}
。
`(env)$ django-admin makemessages --all --ignore=env`
更新以下msgstr
翻译:
`# locale/fr/LC_MESSAGES/django.po
msgid "TestDriven.io Courses"
msgstr "Cours TestDriven.io"
# locale/es/LC_MESSAGES/django.po
msgid "TestDriven.io Courses"
msgstr "Cursos de TestDriven.io"`
编译消息:
`(env)$ django-admin compilemessages --ignore=env`
使用 Rosetta 翻译接口
我们将使用名为 Rosetta 的第三方库,使用与 Django 管理站点相同的界面来编辑翻译。编辑很容易。po 文件,它会自动为您更新编译好的翻译文件。
Rosetta 已经作为依赖项的一部分安装;因此,您只需将它添加到您已安装的应用程序中:
`INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'course.apps.CourseConfig',
'rosetta', # NEW
]`
您还需要将 Rosetta 的 URL 添加到主 URL 配置中的 django_lang/urls.py :
`urlpatterns = [
path('admin/', admin.site.urls),
path('rosetta/', include('rosetta.urls')), # NEW
path('', include('course.urls')),
]`
创建并应用迁移,然后运行服务器:
`(env)$ python manage.py makemigrations
(env)$ python manage.py migrate
(env)$ python manage.py runserver`
确保您以管理员身份登录,然后在浏览器中导航至http://127 . 0 . 0 . 1:8000/Rosetta/:
在项目下,单击每个应用程序以编辑翻译。
编辑完翻译后,点击“保存并翻译下一个模块”按钮,将翻译保存到各自的中。po 文件。Rosetta 然后会编译消息文件,所以不需要手动运行django-admin compilemessages --ignore=env
命令。
请注意,在生产环境中添加新的翻译后,您必须在运行
django-admin compilemessages --ignore=env
命令或使用 Rosetta 保存翻译后重新加载您的服务器,以使更改生效。
向 URL 添加语言前缀
使用 Django 的国际化框架,您可以在不同的 URL 扩展下提供每种语言版本。例如,你网站的英文版可以放在/en/
下,法文版放在/fr/
下,等等。这种方法使网站针对搜索引擎进行了优化,因为每个 URL 都将针对每种语言进行索引,这反过来将对每种语言进行更好的排名。为此,Django 国际化框架需要从请求的 URL 中识别当前语言;因此,LocalMiddleware
需要添加到您项目的MIDDLEWARE
设置中,我们已经完成了。
接下来,将i18n_patterns
函数添加到 django_lang/urls.py :
`from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import path, include
from django.utils.translation import gettext_lazy as _
urlpatterns = i18n_patterns(
path(_('admin/'), admin.site.urls),
path('rosetta/', include('rosetta.urls')),
path('', include('course.urls')),
)`
再次运行开发服务器,并在浏览器中导航到 http://127.0.0.1:8000/ 。您将被重定向到所请求的 URL,并带有相应的语言前缀。看看你浏览器里的网址;它现在应该看起来像http://127 . 0 . 0 . 1:8000/en/。
将请求的 URL 从
en
更改为fr
或es
。标题应该改变。
用 django-parler 翻译模型
Django 的国际化框架不支持开箱即用的模型翻译,所以我们将使用名为 django-parler 的第三方库。有许多插件执行这个功能;不过,这是比较受欢迎的一种。
它是如何工作的?
django-parler 将为每个包含翻译的模型创建一个单独的数据库表。此表包括所有已翻译的字段。它还有一个外键来链接到原始对象。
django-parler 已经作为依赖项的一部分安装,所以只需将其添加到您已安装的应用程序中:
`INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'course.apps.CourseConfig',
'rosetta',
'parler', # NEW
]`
另外,将以下代码添加到您的设置中:
`PARLER_LANGUAGES = {
None: (
{'code': 'en',}, # English
{'code': 'fr',}, # French
{'code': 'es',}, # Spanish
),
'default': {
'fallbacks': ['en'],
'hide_untranslated': False,
}
}`
在这里,您为 django-parler 定义了可用的语言(英语、法语、西班牙语)。您还指定英语为默认语言,并指出 django-parler 不应该隐藏未翻译的内容。
知道什么?
- django-parler 提供了一个
TranslatableModel
模型类和一个TranslatedFields
包装器来翻译模型字段。 - django-parler 通过为每个可翻译模型生成另一个模型来管理翻译。
注意,因为 Django 使用单独的表进行翻译,所以有些 Django 特性是不能使用的。此外,该迁移将删除数据库中以前的记录。
再次更新 course/models.py 如下所示:
`from django.db import models
from parler.models import TranslatableModel, TranslatedFields
class Course(TranslatableModel):
translations = TranslatedFields(
title=models.CharField(max_length=90),
description=models.TextField(),
date=models.DateField(),
price=models.DecimalField(max_digits=10, decimal_places=2),
)
def __str__(self):
return self.title`
接下来,创建迁移:
`(env)$ python manage.py makemigrations`
继续之前,替换新创建的迁移文件中的以下行:
`bases=(parler.models.TranslatedFieldsModelMixin, models.Model),`
用下面的一个:
`bases = (parler.models.TranslatableModel, models.Model)`
在 django-parler 中发现了一个我们刚刚解决的小问题。不这样做将会阻止迁移的应用。
接下来,应用迁移:
`(env)$ python manage.py migrate`
django_parler 的一个令人惊叹的特性是它可以与 django 管理站点顺利集成。它包含一个TranslatableAdmin
类,覆盖了 Django 提供的用于管理翻译的ModelAdmin
类。
编辑 course/admin.py 如下:
`from django.contrib import admin
from parler.admin import TranslatableAdmin
from .models import Course
admin.site.register(Course, TranslatableAdmin)`
运行以下管理命令,再次向数据库添加一些数据:
`(env)$ python manage.py add_courses`
运行服务器,然后在浏览器中导航到http://127 . 0 . 0 . 1:8000/admin/。选择其中一门课程。对于每门课程,每种语言都有一个单独的字段。
请注意,对于 django-parler 可以为我们实现的功能,我们仅仅触及了皮毛。请参考文档了解更多信息。
允许用户切换语言
在这一节中,我们将展示如何允许用户在我们的主页上切换语言。
更新index.html文件是这样的:
`{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
href="https://cdn.jsdelivr.net/npm/[[email protected]](/cdn-cgi/l/email-protection)/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
crossorigin="anonymous"
/>
<title>TestDriven.io</title>
<style> h1, h3 { color: #266150; } li { display: inline; text-decoration: none; padding: 5px; } a { text-decoration: none; color: #DDAF94; } a:hover { color: #4F4846; } .active { background-color: #266150; padding: 5px; text-align: right; border-radius: 7px; } </style>
</head>
<body>
<div class="container">
<h1>{% trans "TestDriven.io Courses" %}</h1>
{% get_current_language as CURRENT_LANGUAGE %}
{% get_available_languages as AVAILABLE_LANGUAGES %}
{% get_language_info_list for AVAILABLE_LANGUAGES as languages %}
<div class="languages">
<p>{% trans "Language" %}:</p>
<ul class="languages">
{% for language in languages %}
<li>
<a href="/{{ language.code }}/"
{% if language.code == CURRENT_LANGUAGE %} class="active"{% endif %}>
{{ language.name_local }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% for course in courses %}
<div class="card p-4">
<h3>
{{ course.title }}
<em style="font-size: small">{{ course.date }}</em>
</h3>
<p>{{ course.description }}</p>
<strong>Price: $ {{ course.price }}</strong>
</div>
<hr />
{% empty %}
<p>Database is empty</p>
{% endfor %}
</div>
</body>
</html>`
这里发生了什么事?
我们:
- 使用
{% load i18n %}
加载国际化标签。 - 使用
{% get_current_language %}
标签检索当前语言。 - 还通过
{% get_available_languages %}
模板标签获得了在LANGUAGES
设置中定义的可用语言。 - 然后使用
{% get_language_info_list %}
标签来启用语言属性。 - 构建了一个 HTML 列表来显示所有可用的语言,并向当前活动的语言添加了一个 active class 属性来突出显示活动的语言。
在您的浏览器中导航到 http://127.0.0.1:8000/ 以查看更改。在多种语言之间切换,并注意每种语言的 URL 前缀是如何变化的。
添加区域设置支持
还记得我们如何将USE_L10N
设置为True
吗?这样,每当 Django 在模板中输出一个值时,它都会尝试使用特定于地区的格式。因此,根据用户的区域设置,日期、时间和数字将采用不同的格式。
在浏览器中导航回 http://127.0.0.1:8000/ 以查看更改,您会注意到日期格式发生了变化。十进制数字在英文版的网站上显示,小数点位置用点分隔,而在西班牙文和法文版的网站上,则用逗号分隔。这是由于每种语言的地区格式不同。
结论
在本教程中,您了解了国际化和本地化,以及如何通过 Django 的国际化框架为国际化配置 Django 项目。我们还使用 Rosetta 来简化消息文件的更新和编译,使用 django-parler 来翻译我们的模型。
从 GitHub 上的 django-lang repo 获取完整的代码。
Python 中的 OAuth2
在本文中,我们将首先看看什么是 OAuth 。然后,我们将使用 OAuthLib 和请求库来实现 OAuth2 。
目标
完成本文后,您将能够:
- 解释什么是 OAuth 和 OAuth2,以及如何使用它们
- 描述 web 客户端和服务器之间的 OAuth2 流
- 通过 Web 应用程序流实现 OAuth2(也称为授权码授予)
OAuth 是什么?
OAuth 是一个安全的开放协议,用于在不相关的服务之间授权用户。换句话说,它使一个服务能够访问托管在其他服务上的资源,而不必共享用户凭证,如用户名和密码。
这都是关于授权的:
- 一个服务(客户端)代表用户从另一个服务(资源服务器)访问资源。
- 用户不必与客户端共享他们的凭证。
参与方:
- 资源所有者/用户 -允许访问由第三方提供商托管的受保护资源的人
- 客户端 -代表用户访问由第三方提供商托管的资源的 web 应用程序
- 授权服务器 -客户端联系的第三方服务器,显示提示用户授权客户端代表用户行事
- 资源服务器 -托管用户受保护资源的第三方服务器
授权服务器和资源服务器可以是同一个实体。
OAuth2 是 OAuth 协议的最新版本,由谷歌、Spotify、Trello 和 Vimeo 等服务使用。
OAuth2 Web 应用程序流
OAuth2 协议可用于不同类型的应用程序,但它最常用于 web、移动和桌面应用程序。本节将介绍 OAuth2 在 web 应用程序中的部署,也称为 Web 应用程序流(或授权码授予)。
下图显示了一个简化的 Web 应用程序流程:
这个流程基本上是客户机(web 应用程序)和服务器(OAuth2 提供者)之间的通信和信息交换,它由三个步骤组成:
- 批准
- 获取访问令牌
- 获取用户信息
在实现这个流程之前,web 开发人员通常必须向 OAuth2 提供者注册他们的 web 应用程序,并提供以下信息:
- 应用程序的名称
- 托管应用程序的位置
- URI 端点(也称为重定向 URI 或回叫 URL)
注册后,OAuth2 提供商将向注册人提供以下信息:
- 客户端 ID -唯一标识 web 应用程序的公共凭证,类似于用户名
- 客户端秘密 -传递给服务器的私有凭证,类似于密码
- 授权服务器 URL -客户端请求授权的 URL
- 访问令牌 URL -客户端用授权码交换访问令牌的 URL
- 资源服务器 URL -客户端使用访问令牌访问受保护资源的 URL,该令牌也可以与授权服务器相同
我们现在将更深入地研究 Web 应用程序流中涉及的三个步骤。
请注意,术语“客户端”将与“web 应用程序”互换使用。
第一步:授权
第一步 authorize 通常在登录过程开始时调用。在此步骤中,web 应用程序向用户请求权限,以授权访问他们在第三方 OAuth2 提供商处托管的帐户。
客户端-请求权限
首先,web 应用程序使用以下信息构建一个 URL:
- 响应类型 -告知授权服务器使用哪个流程或授予(网络应用流程使用
code
- 客户端 ID -标识网络应用
- 重定向 URI -将用户重定向回哪里
- Scope -指定 web 应用程序希望访问用户配置文件的哪一部分
- State -是 web 应用程序提供的随机生成的字符串,授权服务器将简单地将该字符串传递回去,以便 web 应用程序可以验证该字符串以减少欺诈
以下是一个示例 URL:
`https://api.authorization-server.com/authorize
?response_type=code
&client_id=123
&redirect_uri=https://your-web-app.com/redirect
&scope=photos
&state=1234-zyxa-9134-wpst`
然后,用户被重定向到该 URL,授权服务器将提示他们是否要授权该应用程序的请求:
授权服务器-重定向回
在用户授权 web 应用程序访问其第三方帐户后,授权服务器将通过包含以下信息的重定向 URL 将用户重定向回 web 应用程序:
- Code——web 应用程序期望从服务器获得的短期授权代码
- 状态——先前从客户端传递的状态凭证
下面是一个重定向 URL 示例:
`https://your-web-app.com/redirect
?code=123456
&state=1234-zyxa-9134-wpst`
在这一步中,web 应用程序应该验证状态值是否与之前发送给授权服务器的值相匹配。这样做将有助于防止黑客的任何恶意企图和 CSRF 的攻击。
步骤 2:获取访问令牌
第二步是用授权码交换访问令牌。
客户交换
web 应用程序向授权服务器的令牌端点发送一个 HTTP POST 请求,包含以下内容:
- 授权类型——再次告诉授权服务器使用哪个流或授权(使用
authorization_code
用于 Web 应用程序流) - 代码-web 应用从授权服务器接收的授权代码
- 重定向 URI -将用户重定向回哪里
- 客户端 ID -与授权步骤中使用的客户端标识符相同
- 客户端密码 -注册期间由 OAuth2 提供者提供的密码等价物
下面是一个请求 URL 示例:
`https://api.authorization-server.com/token
grant_type=authorization_code
&code=123456
&redirect_uri=https://your-web-app.com/redirect
&client_id=123
&client_secret=456`
授权服务器-授权令牌
令牌端点将验证请求中的所有参数,确保代码没有过期,并且客户端 ID 和密码匹配。如果一切正常,它将生成一个访问令牌并在响应中返回它!
假设授权码有效,授权服务器将生成一个访问令牌,并将其返回给客户端。
例如:
`{ "access_token": "KtsgPkCR7Y9b8F3fHo8MKg83ECKbJq31clcB", "expires_in": 3600, "token_type": "bearer" }`
附加的属性(如scope
和refresh_token
)可能会在响应中返回,这取决于 OAuth2 提供者。
步骤 3:获取用户信息
最后,web 应用程序可以使用访问令牌代表用户访问受保护的资源。
客户端请求资源
web 应用程序通常使用以下凭证向资源服务器发送 HTTP GET 请求,这些凭证在 HTTP Authorization header 中带有访问令牌。
例如:
`GET /user HTTP/1.1
Host: api.resource-server.com
Authorization: Bearer access_token`
头类型(上例中的Bearer
)根据 OAuth2 提供者的不同而不同。
资源服务器-发送资源
资源服务器(有时与授权服务器相同)验证访问令牌。如果有效,资源服务器发送回请求的数据。
例如:
`{ "name": "Aaron Smith", "bio": "Software Engineer", "avatar_url": "http://api.resource-server.com/image/aaron_smith" }`
履行
OAuthLib 是一个流行的 Python 框架,它实现了 OAuth1 和 OAuth2 的通用的、符合规范的和全面的接口。 Requests 是一个流行的 Python HTTP 库,它使得发送 HTTP/1.1 请求变得相当简单。它们可以一起用于实现 OAuth2 Web 应用程序流。
第一步:授权
OAuthLib 提供了一个 WebApplicationClient 类来实现上面描述的 Web 应用程序流。在向 OAuth2 提供者注册并获得客户机 ID 之后,在 web 应用程序中创建一个新的WebApplicationClient
实例。
例如:
`from oauthlib.oauth2 import WebApplicationClient
client_id = 'xxxxx'
client = WebApplicationClient(client_id)`
为了方便 Web 应用程序流中的授权步骤,WebApplicationClient
类提供了一个 prepare_request_uri() 方法,该方法采用授权服务器 URL 及其相应的凭证来形成一个完整的 URL。
例如:
`authorization_url = 'https://api.authorization-server.com/authorize'
url = client.prepare_request_uri(
authorization_url,
redirect_uri = 'https://your-web-app.com/redirect',
scope = ['read:user'],
state = 'D8VAo311AAl_49LAtM51HA'
)`
打印时,url
将返回:
`https://api.authorization-server.com/authorize
?response_type=code
&client_id=xxxxx
&redirect_uri=https://your-web-app.com/redirect
&scope=read:user
&state=D8VAo311AAl_49LAtM51HA`
web 应用程序可以将用户重定向到该 URL。此时,授权服务器将显示一个提示,要求用户授权该请求。
步骤 2:获取访问令牌
同样,在这个步骤中,在用户批准请求后,他们会被重定向回客户端,并得到一个包含授权代码和状态的响应。
在提取并验证状态值的准确性之后,web 应用程序可以利用WebApplicationClient
的 prepare_request_body() 方法来构造从授权服务器获取访问令牌所需的 URL。
例如:
`data = client.prepare_request_body(
code = 'yyyyyyy',
redirect_uri = 'https://your-web-app.com/redirect',
client_id = 'xxxxx',
client_secret = 'zzzzzzz'
)`
打印时,data
将返回:
`grant_type=authorization_code
&client_id=xxxxx
&client_secret=zzzzzzz
&code=yyyyyyy
&redirect_uri=https//your-web-app.com/redirect`
注意,grant_type=authorization_code
的键值对被prepare_request_body()
方便地前置。
此时,我们可以利用请求库向 OAuth2 提供者提供的令牌 URL 发送一个 HTTP POST 请求。
例如:
`token_url = 'https://api.authorization-server.com/token'
response = requests.post(token_url, data=data)`
OAuthLib 的WebApplicationClient
类还提供了一个parse _ request _ body _ response()方法来帮助我们将响应数据作为 Python 字典进行管理。例如,我们可以将response.text
传递给这个方法,它会将字典保存在client.token
中:
`client.parse_request_body_response(response.text)`
client.token
的值可能如下所示:
`{
'access_token': 'KtsgPkCR7Y9b8F3fHo8MKg83ECKbJq31clcB',
'scope': ['read:user'],
'expires_at': 1619473575.641959,
'expires_in': 3599,
'token_type': 'bearer'
}`
步骤 3:获取用户信息
Web 应用程序流的最后一步是从资源服务器检索所需的受保护资源。我们需要准备一个具有正确类型和值的 HTTP 授权头。根据 OAuth2 提供者的实现,授权头类型可以是Token
或Bearer
。我们可以使用请求库中的get()
方法向资源服务器发送一个带有正确格式的Authorization
头的 HTTP GET 请求。
例如:
`header = {
'Authorization': 'Bearer {}'.format(client.token['access_token'])
}
response = requests.get('https://api.resource-server.com/user', headers=header)`
通过response.json()
,JSON 格式的响应可能如下所示:
`{ "name": "Aaron Smith", "bio": "Software Engineer", "avatar_url": "http://api.resource-server.com/user/images/aaron_smith/" }`
例子
在这个使用 GitHub 作为 OAuth2 提供者的 Django 应用程序中可以找到一个使用 OAuthLib 和请求的 OAuth2 实现的真实例子。你可以在这里探索它的实现。
要了解更多关于将 OAuth2 集成到您的 web 应用程序中的信息,请访问以下链接:
结论
OAuth2 集成可能首先显得具有挑战性,但是本文以及使用友好的库(如 OAuthLib 和 Requests)将帮助您巩固对 OAuth2 的理解。尝试使用 GitHub 作为第一个提供者来实现一个简单的例子。慢慢地,但肯定地,当你添加更多的提供者到你的清单中时,你会在这方面做得更好。
设置私有 PyPI 服务器
在本教程中,我们将看看如何使用 pypiserver 、一个 PyPI 兼容服务器和 Docker 来建立你自己的私有 PyPI 服务器。我们将在 AWS EC2 实例上托管服务器。
AWS 设置
让我们首先设置一个 EC2 实例,它将用于托管您的 PyPI 服务器。
设置您的第一个 AWS 帐户?
创建一个非根 IAM 用户是一个好主意,具有“管理员访问”和“计费”策略,并通过 CloudWatch 发出计费警报,以便在您的 AWS 使用成本超过一定金额时提醒您。有关更多信息,请查看锁定您的 AWS 帐户 Root 用户访问密钥和分别创建计费警报。
EC2
登录 AWS 控制台,导航到 EC2 控制台,点击左侧边栏的“实例”。然后,单击“启动实例”按钮:
接下来,坚持使用基本的亚马逊 Linux AMI:
使用一个t2.micro
实例类型:
创建一个新的密钥对,这样就可以通过 SSH 连接到实例。保存这个。pem 文件放在安全的地方。
在 Mac 或 Linux 机器上?建议保存。pem 文件保存到“/用户/$用户/”。ssh "目录。一定要设置适当的权限,例如
chmod 400 ~/.ssh/pypi.pem
。
在“网络设置”下,我们将坚持使用默认的 VPC,以保持本教程的简单,但可以随时更新。
接下来,创建一个名为pypi-security-group
的新安全组(类似于防火墙),确保至少打开端口 22(用于 SSH)和 8080(用于 HTTP 流量)。
单击“启动实例”创建新实例。在“启动状态”页面上,单击“查看所有实例”。然后,在主实例页面上,获取新创建的实例的公共 IP:
码头工人
实例启动并运行后,我们现在可以在其上安装 Docker 了。
使用您的密钥对 SSH 到实例中,如下所示:
首先安装并启动 Docker 的最新版本和 Docker Compose 的 2.7.0 版本:
`[ec2-user]$ sudo yum update -y
[ec2-user]$ sudo yum install -y docker
[ec2-user]$ sudo service docker start
[ec2-user]$ sudo curl -L "https://github.com/docker/compose/releases/download/v2.7.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
[ec2-user]$ sudo chmod +x /usr/local/bin/docker-compose
[ec2-user]$ docker --version
Docker version 20.10.13, build a224086
[ec2-user]$ docker-compose --version
Docker Compose version v2.7.0`
将ec2-user
添加到docker
组,这样您就可以执行 Docker 命令,而不必使用sudo
:
`[ec2-user]$ sudo usermod -a -G docker ec2-user`
为了使更改生效,您需要退出 SSH 会话并重新登录。
pypi 设置
如果您还没有这样做,请 SSH 回到实例中。
首先为 PyPI 服务器创建一个新的项目目录:
`[ec2-user]$ mkdir /home/ec2-user/pypi
[ec2-user]$ cd /home/ec2-user/pypi`
添加 a 坞站-复合. yml 文件:
`version: '3.7' services: pypi-server: image: pypiserver/pypiserver:latest ports: - 8080:8080 volumes: - type: volume source: pypi-server target: /data/packages command: -P . -a . /data/packages restart: always volumes: pypi-server:`
注意事项:
- 我们定义了一个名为
pypi-server
的服务,它使用了 pypiserver Docker 映像。 - 我们还定义了一个名为
pypi-server
的命名卷,它映射到容器中的“/data/packages”文件夹。如果容器由于某种原因关闭,卷和上传的 PyPI 包将会持续存在。 -P . -a .
允许未经授权的访问,而/data/packages
表示将从容器中的“/data/packages”文件夹提供包。
继续旋转容器:
`[ec2-user]$ docker-compose up -d --build`
完成后,获取实例 IP,并在浏览器中导航到http://<PUBLIC-IP-ADDRESS>:8080
。您应该会看到类似如下的内容:
上传和下载(无授权)
如果您还没有想要用来测试您的 PyPI 服务器的包,那么继续克隆 private-pypi repo。
转到“sample-package”文件夹,创建并激活一个虚拟环境,然后压缩该包:
安装捆绳:
然后,将包上传到您的 PyPI 服务器:
`$ twine upload --repository-url http://<PUBLIC-IP-ADDRESS>:8080 dist/* # example: # twine upload --repository-url http://3.101.143.245:8080 dist/*`
系统将提示您输入用户名和密码。因为我们目前允许未经授权的访问,所以现在让它们都保持空白。如果一切顺利,您应该会看到如下内容:
`Uploading distributions to http://3.101.143.245:8080
Enter your username:
Enter your password:
Uploading muddy_wave-0.1.tar.gz
100%|█████████████████████████████████████████████| 3.64k/3.64k [00:00<00:00, 30.6kB/s]`
要安装您的软件包,请运行:
`$ pip install --index-url http://<PUBLIC-IP-ADDRESS>:8080 muddy_wave --trusted-host <PUBLIC-IP-ADDRESS>
# example:
# pip install --index-url http://3.101.143.245:8080 muddy_wave --trusted-host 3.101.143.245`
您应该会看到类似这样的内容:
`Looking in indexes: http://3.101.143.245:8080
Collecting muddy_wave
Downloading http://3.101.143.245:8080/packages/muddy_wave-0.1.tar.gz (1.0 kB)
Using legacy 'setup.py install' for muddy-wave, since package 'wheel' is not installed.
Installing collected packages: muddy-wave
Running setup.py install for muddy-wave ... done
Successfully installed muddy-wave-0.1`
您可以在 shell 中测试包:
`>>> import muddy_wave
>>> muddy_wave.hello_world()
hello, world!`
您还应该能够在浏览器中查看http://<PUBLIC-IP-ADDRESS>:8080/packages/
的包。
至此,让我们看看如何添加身份验证。
证明
对于 auth,我们将使用带有 htpasswd 的基本认证。
SSH 回到实例,并通过 httpd-tools 安装 htpasswd:
`[ec2-user]$ sudo yum install -y httpd-tools`
创建一个“授权”文件夹:
`[ec2-user]$ mkdir /home/ec2-user/pypi/auth
[ec2-user]$ cd /home/ec2-user/pypi/auth`
现在,创建您的第一个用户:
`[ec2-user]$ htpasswd -sc .htpasswd <SOME-USERNAME>`
使用
htpasswd -s .htpasswd <SOME-USERNAME>
添加其他用户。
接下来,更新 Docker 合成文件,如下所示:
`version: '3.7' services: pypi-server: image: pypiserver/pypiserver:latest ports: - 8080:8080 volumes: - type: bind source: /home/ec2-user/pypi/auth target: /data/auth - type: volume source: pypi-server target: /data/packages command: -P /data/auth/.htpasswd -a update,download,list /data/packages restart: always volumes: pypi-server:`
注意事项:
- 我们定义了一个绑定挂载来挂载“/home/ec2-user/pypi/auth”文件夹(其中的)。htpasswd 文件驻留)到容器内的“/data/auth”。
- 我们还更新了命令,使 /data/auth/。htpasswd 用作密码文件(
-P /data/auth/.htpasswd
),更新、下载和列表命令需要认证(-a update,download,list
)。
更新容器:
`[ec2-user]$ cd /home/ec2-user/pypi
[ec2-user]$ docker-compose up -d --build`
要进行测试,请导航至http://<PUBLIC-IP-ADDRESS>:8080/packages/
。应该会提示您输入用户名和密码。
上传和下载(带授权)
要上传,请在 sample-package/setup.py 中插入版本:
`from setuptools import setup
setup(
name='muddy_wave',
packages=['muddy_wave'],
description='Hello, world!',
version='0.2', # updated
url='http://github.com/testdrivenio/private-pypi/sample-package',
author='Michael Herman',
author_email='[[email protected]](/cdn-cgi/l/email-protection)',
keywords=['pip', 'pypi']
)`
删除与以前版本相关联的文件和文件夹:
`$ rm -rf build dist muddy_wave.egg-info .eggs`
创建新版本:
上传包:
`$ twine upload --repository-url http://<PUBLIC-IP-ADDRESS>:8080 dist/* # example: # twine upload --repository-url http://3.101.143.245:8080 dist/*`
确保在提示时输入您的用户名和密码。
如果您不想每次都添加 URL 或者输入您的用户名和密码,那么将 PyPI 服务器的配置添加到您的中。pypirc 文件,它应该位于您的主目录中——例如 ~/。pypirc 。
例如:
`[distutils] index-servers= pypi aws [pypi] username: michael password: supersecret [aws] repository: http://3.101.143.245:8080 username: michael password: supersecret`
要上传,请运行:
`$ twine upload --repository aws dist/*`
要安装新版本,请运行:
`$ pip install --index-url http://<PUBLIC-IP-ADDRESS>:8080 muddy_wave==0.2 --trusted-host <PUBLIC-IP-ADDRESS>
# example:
# pip install --index-url http://3.101.143.245:8080 muddy_wave==0.2 --trusted-host 3.101.143.245`
系统将提示您输入用户名和密码:
`Looking in indexes: http://3.101.143.245:8080
User for 3.101.143.245:8080: michael
Password:
Collecting muddy_wave==0.2
Downloading http://3.101.143.245:8080/packages/muddy_wave-0.2.tar.gz (1.0 kB)
Using legacy 'setup.py install' for muddy-wave, since package 'wheel' is not installed.
Installing collected packages: muddy-wave
Attempting uninstall: muddy-wave
Found existing installation: muddy-wave 0.1
Uninstalling muddy-wave-0.1:
Successfully uninstalled muddy-wave-0.1
Running setup.py install for muddy-wave ... done
Successfully installed muddy-wave-0.2`
如果您不想每次都添加--index-url http://3.101.143.245:8080
和-trusted-host 3.101.143.245
,那么可以将 PyPI 服务器的配置添加到您的 pip.conf 文件中,该文件也应该位于您的主目录中——例如, ~/。pip/pip.conf 。
例如:
`[global] extra-index-url = http://3.101.143.245:8080 trusted-host = 3.101.143.245`
现在,要安装,运行:
`$ pip install muddy_wave==0.2`
输出:
`Looking in indexes: https://pypi.org/simple, http://3.101.143.245:8080
User for 3.101.143.245:8080: mjhea0
Password:
Collecting muddy_wave==0.2
Downloading http://3.101.143.245:8080/packages/muddy_wave-0.2.tar.gz (1.0 kB)
Using legacy 'setup.py install' for muddy-wave, since package 'wheel' is not installed.
Installing collected packages: muddy-wave
Attempting uninstall: muddy-wave
Found existing installation: muddy-wave 0.3
Uninstalling muddy-wave-0.3:
Successfully uninstalled muddy-wave-0.3
Running setup.py install for muddy-wave ... done
Successfully installed muddy-wave-0.2`
支持 HTTPS
建议配置 HTTPS,因为用户名和密码是通过 HTTP 明文发送的。有许多不同的方法可以实现这一点。几个例子:
第三种方法是最简单的。您需要用证书管理器设置一个 SSL 证书,用应用负载平衡器创建一个 HTTPS 监听器,然后通过目标组将 443 流量代理到实例上的 HTTP 端口 80。
在 Heroku 部署 Django 生产
原文:https://testdriven.io/blog/production-django-deployments-on-heroku/
我们使用 Heroku 来托管 TestDriven.io 学习平台,以便我们可以专注于应用程序开发,而不是配置 web 服务器、安装 Linux 包、设置负载平衡器以及传统服务器上基础架构管理的所有其他工作。
本文旨在简化在 Heroku 上部署、维护和扩展生产级 Django 应用程序的过程。
我们还将回顾一些简化部署过程的技巧和诀窍。最后,您会发现一份用于将新应用部署到生产环境的生产清单。
Heroku
为什么是 Heroku?像姜戈一样,Heroku 信奉“包含电池”的哲学。这是一个固执己见的环境,但也是一个您不必管理的环境——因此您可以专注于应用程序开发,而不是支持它的环境。
如果你使用自己的基础设施或基础设施即服务(IaaS)解决方案,例如 DigitalOcean Droplets 、亚马逊 EC2 、谷歌计算引擎,你必须雇用一名系统管理/开发运维工程师或自己承担这一角色。前者要花钱,而后者会降低你的速度。Heroku 的托管成本可能会高于 IaaS 解决方案,但您将节省资金,因为您不需要雇用人员来管理基础架构,并且您可以更快地运行应用程序,这是最终最重要的。
小贴士:
- 确保你使用的 Heroku 是最新的 Heroku 栈。
- 使用 uWSGI 或 Gunicorn 作为你的生产 WSGI 服务器。哪一个都可以。如果您不知道为什么您更喜欢一个 WSGI 服务器而不是另一个,这并不重要。以后再切换也不难。
- 使用 Celery 或 RQ 以及 Heroku Redis 插件,在 web 应用程序之外异步运行长时间运行或 CPU 密集型流程,如电子邮件交付或 PDF 报告生成。作为参考,我们使用 Django-RQ 。
- 运行至少两个 web 和后台进程以实现冗余。
- 使用 SSL 。
- 遵循十二因素 App 方法。
- 添加缓存。
数据库ˌ资料库
小贴士:
- 使用 Heroku 标准(或更高)层 Postgres 数据库。回顾每层的磁盘空间、内存和并发连接限制,以及 Django 文章中的并发和数据库连接。
- 通过 Heroku PGBackups 安排生产数据库的每日备份。
- 通过不时压缩或重置迁移,保持迁移的整洁和可管理性。
持续集成和交付
Heroku 运行时是无状态和不可变的,这有助于实现连续交付。在每次应用程序部署时,都会构建、配置一个新的虚拟机,并将其投入生产。
正因为如此,你无需担心:
- 当 Heroku 通过一个 Dyno 管理器为你处理时,使用一个进程管理器来支持你的服务。
- 配置用于更新和重新启动应用程序的部署机制。
Heroku 与许多持续集成(CI)服务合作,如 Circle 和 Travis ,他们也提供自己的 CI 解决方案- Heroku CI 。
小贴士:
- 设置自动部署。由于人为错误,手动部署容易出错。
- 在您的生产 CI 构建中运行 Django 部署清单 (
manage.py check --deploy
)。 - 在 TestDriven.io 中,我们使用了一种形式的 GitOps ,其中应用程序的状态始终保存在 git 中,对试运行和生产环境的更改仅发生在 CI 中。考虑使用这种方法来帮助加速开发并引入稳定的回滚系统。
- 定期部署,在开发人员有空的预定时间部署,以防出现问题。
- 使用 release 标签,这样你就能确切地知道哪个版本的代码正在生产中运行——也就是
git tag -a "$ENVIRONMENT/${VERSION}"
。
小贴士:
- 对静态文件使用whiten noise然后在它前面扔一个 CDN,像 Cloudflare 或者 CloudFront 。
- 对于用户上传的媒体文件,使用 S3 和 django-storages 。
环境
小贴士:
- 对于暂存,使用不同的 Heroku 应用程序。确保在不使用时打开维护模式,这样谷歌爬虫就不会无意中发现它。
测试
编写测试。测试是一种保护措施,因此您不会意外地更改应用程序的功能。从您的测试套件中本地捕获 bug 比在生产环境中由客户捕获要好得多。
小贴士:
- 忽略传统的测试金字塔。花一半的时间写 Django 单元测试(同时用 pytest 和假设)。另一半时间用 Cypress 编写基于浏览器的集成和端到端测试。与 Selenium 相比,Cypress 测试更容易编写和维护。我们建议将 Cypress 整合到您的日常 TDD 工作流程中。查看现代前端测试和 Cypress 了解更多信息。
监控和记录
监控和日志记录是应用可靠性的重要组成部分,使其更容易:
- 在早期阶段发现错误。
- 了解你的应用是如何工作的。
- 分析性能。
- 确定您的应用程序是否正常运行。
您的日志应该总是有时间戳和日志级别。它们也应该是人类可读且易于解析的。
在监控方面,设置警报来帮助减少和抢先停机。设置通知,以便您可以在客户开始抱怨之前解决问题和瓶颈。
如你所见,Heroku 通过附加系统提供了许多服务。这个系统是你从 Heroku 获得的强大工具之一。您可以随意使用数百个服务,只需几分钟即可完成配置,其中许多服务对于日志记录、监控和错误跟踪非常有用。
小贴士:
- Heroku 只保留最近的 1500 行合并日志,这只是几秒钟的日志。因此,您需要将日志发送到一个远程日志服务,比如 Logentries ,以聚合您的所有日志。
- 使用 Scout 进行应用程序性能监控,以便跟踪性能问题。
- 使用 Sentry 进行异常监控,以便在应用程序出现错误时获得通知。
- 您可以直接从 Heroku 的应用程序指标仪表板监控内存使用和 CPU 负载等基本情况。
- 使用 Uptime Robot ,它没有 Heroku 附加组件,以确保您的网站正常运行。
安全性
说到安全,人一般是最薄弱的环节。您的开发团队应该知道一些更常见的安全漏洞。工程师安全培训和 Heroku 的安全指南以及以下 OWASP 备忘单都是很好的起点:
小贴士:
- 使用 Snyk 保持您的依赖关系最新。
- 引入节流机制,如 Django Ratelimit ,以限制 DDoS 攻击的影响。
- 将应用程序的配置与代码分开,以防止敏感凭据被签入源代码管理。
- 监控和记录可疑行为,例如来自特定来源的多次失败登录尝试和异常流量峰值。查看用于实时安全监控的加急 WAF 附件。
- 使用 Bandit 检查您的 Python 代码中的常见安全问题。
- 部署完成后,在Pony check通过自动安全检查运行您的站点。
- 验证上传文件内容类型和大小。
结论
希望本文提供了一些有用的信息,有助于简化在 Heroku 上部署和维护 Django 生产应用程序的过程。
记住:Web 开发是复杂的,因为所有的移动部分都是复杂的。您可以通过以下方式应对这一问题:
- 把问题分解成小的、容易理解的子问题。理想情况下,您可以将这些子问题转化为已经解决的问题。
- 通过使用 Django 和 Heroku 来完全去除碎片——这两者都使得开发和部署安全、可伸缩和可维护的 web 应用程序变得更加容易,因为它们包含了稳定性和“包含电池”的理念。
好奇 Heroku 的完整架构是什么样子?
一旦配置好 Celery 和 Gunicorn,您就可以将大部分时间(如果不是全部的话)集中在开发应用程序上——其他一切都是附加的。
推荐资源:
生产清单
向 Heroku 部署新的 Django 应用程序?查看以下清单以获取帮助。确保在整个过程中记录部署工作流。
部署前
前端:
- 拼写检查 Django 模板。
- 设置收藏夹图标。
- 自定义默认错误视图。
- 添加一个 robots.txt 文件。
- 创建一个 sitemap.xml 文件。
- 压缩优化所有图像。
- 设置谷歌分析。
- 配置 SSL。
- 为前端资产配置 CDN 提供商,如 Cloudflare 。
姜戈:
- 匿名化生产 Django 管理 URL。
- 可选地添加 django-cors-headers 应用程序,将跨源资源共享(cors)头添加到响应中。
- 考虑使用 ATOMIC_REQUESTS 。
- 为生产配置以下 Django 设置:
持续集成:
- 设置 CI 服务。
- 根据生产设置运行
python manage.py check --deploy
。 - 配置要运行的任何其他 linters 和/或代码分析工具。
- 测试 CI 流程。
- 配置自动部署。
英雄:
- 确保使用最新的 Heroku 栈和 Python 版本。
- 配置 Postgres 和 Redis 附加组件。
- 设置数据库备份。
- 配置剩余的 Heroku 插件——即日志条目、侦察兵、哨兵和发送网格。
- 设置环境变量。
- 为冗余设置至少两个 web 和工作进程。
部署后
前端:
- 运行 Mozilla Observatory 、 Google PageSpeed 、 Google Mobile-Friendly 、 webhint 和 Netsparker 安全标题扫描。
- 使用 WAVE 工具来测试你的页面是否符合可访问性标准。
- 查看前端清单。
- 运行一个 SSL 服务器测试。
- 运行自动化测试(如果有的话),或者从浏览器手动测试你的应用。
- 验证 301 重定向配置是否正确。
- 设置谷歌标签管理器。
- 配置正常运行时间机器人。
干杯!
初学 Pytest
自动化测试是开发过程中必不可少的一部分。
尽管一开始编写测试看起来像是延长了开发过程,但从长远来看,它为您节省了大量时间。
编写良好的测试通过确保您的代码如您所愿,降低了生产环境中出现问题的可能性。测试还能帮助你覆盖边缘案例,使重构更容易。
在本文中,我们将了解如何使用 pytest ,这样您就能够自己使用它来改进您的开发过程,并遵循更高级的 pytest 教程。
目标
在本文结束时,您将能够:
- 解释 pytest 是什么以及如何使用它
- 用 pytest 自己编写一个测试
- 跟随使用 pytest 的更复杂的教程
- 准备测试所需的数据和/或文件
- 将测试参数化
- 测试所需的模拟功能
为什么选择 pytest
虽然经常被忽视,但测试是如此重要,以至于 Python 自带了一个名为 unittest 的内置测试框架。不过,在 unittest 中编写测试可能会很复杂,所以近年来, pytest 框架已经成为标准。
pytest 的一些显著优势是:
- 需要更少的样板代码,使您的测试套件更具可读性
- 使用普通的 assert 语句,而不是 unittest 的 assertSomething 方法(例如,
assertEquals
、assertTrue
) - 夹具系统简化了测试状态的设置和拆除
- 功能方法
- 大型社区维护的插件生态系统
入门指南
因为这是指南而不是教程,所以我们准备了一个简单的 FastAPI 应用程序,您可以在阅读本文时参考。可以从 GitHub 中克隆。
在基本分支上,我们的 API 有 4 个端点(在 main.py 中定义),它们使用 calculations.py 中的函数返回对两个整数执行某种基本算术运算(+
/ -
/ *
/ /
)的结果。在 advanced_topics 分支上,增加了两个功能:
CalculationsStoreJSON
(在 store_calculations.py 内部)类——允许你在 JSON 文件中存储和检索计算结果。get_number_fact
(insidenumber _ facts . py)——调用远程 API 来检索关于某个数字的事实。
理解本文不需要 FastAPI 知识。
在本文的第一部分,我们将使用基础知识分支。
创建并激活虚拟环境,并安装要求:
`$ python3.10 -m venv venv
$ source venv/bin/activate
(venv)$ pip install -r requirements.txt`
组织和命名
为了组织您的测试,您可以使用三种可能性,所有这些都在示例项目中使用:
组织于 | 例子 |
---|---|
Python 包(文件夹包含一个 init。py 文件) | "测试 _ 计算" |
组件 | 测试 _ 交换 _ 操作. py |
班级 | TestCalculationEndpoints |
说到组织测试的最佳实践,每个程序员都有自己的偏好。
本文的目的不是展示最佳实践,而是向您展示所有的可能性。
如果您遵守以下约定,pytest 将自己发现测试:
- 您将您的测试添加到一个以
test_
开始或者以_test.py
结束的文件中(例如test_foo.py
或者foo_test.py
- 您可以给测试函数加上前缀
test_
(例如def test_foo()
) - 如果您正在使用类,您可以将您的测试作为方法添加到以
Test
(例如,class TestFoo
)为前缀的类中
不遵循命名约定的测试将不会被发现,所以要小心命名。
测试解剖学
让我们看看test_return_sum
(在test _ calculation _ endpoints . py文件中)测试函数是什么样子的:
`# tests/test_endpoints/test_calculation_endpoints.py
def test_return_sum(self):
# Arrange
test_data = {
"first_val": 10,
"second_val": 8
}
client = TestClient(app)
# Act
response = client.post("/sum/", json=test_data)
# Assert
assert response.status_code == 200
assert response.json() == 18`
根据 pytest 文档,每个测试功能由四个步骤组成:
- 安排——你为考试准备一切的地方(
test_data = {"first_val": 10, "second_val": 8}
) - 动作——启动你想要测试的行为的奇异的、改变状态的动作(
client.post("/sum/", json=test_data)
) - 断言——将动作的结果与期望的结果(
assert response.json() == 18
)进行比较 - 清理——清理特定于测试的数据(通常在测试更复杂特性的测试中,你可以在我们的提示中看到一个例子)
运行测试
pytest 为您提供了很多控制,让您可以决定要运行哪些测试:
- 所有的测试
- 特定包
- 特定模块
- 特定类别
- 特效试验
- 对应于特定关键字的测试
让我们看看这是如何工作的...
如果您正在使用我们的示例应用程序,那么如果您安装了需求,那么已经安装了
pytest
。对于您自己的项目,
pytest
可以作为任何其他带有 pip 的包安装:`(venv)$ pip install pytest`
运行所有的测试
运行pytest
命令将简单地运行 pytest 可以找到的所有测试:
`(venv)$ python -m pytest
=============================== test session starts ===============================
platform darwin -- Python 3.10.4, pytest-7.1.2, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/pytest_for_beginners_test_project
plugins: anyio-3.6.1
collected 8 items
tests/test_calculations/test_anticommutative_operations.py .. [ 25%]
tests/test_calculations/test_commutative_operations.py .. [ 50%]
tests/test_endpoints/test_calculation_endpoints.py .... [100%]
================================ 8 passed in 5.19s ================================`
pytest 将通知您找到了多少个测试,以及这些测试是在哪个模块中找到的。在我们的示例应用程序中,pytest 找到了 8 个测试,它们都通过了。
在消息的底部,您可以看到有多少测试通过/失败。
不正确的命名模式
正如已经讨论过的,不遵守正确命名约定的测试将不会被发现。错误命名的测试不会产生任何错误,所以您需要注意这一点。
例如,如果您将TestCalculationEndpoints
类重命名为CalculationEndpointsTest
,那么其中的所有测试都不会运行:
`=============================== test session starts ===============================
platform darwin -- Python 3.10.4, pytest-7.1.2, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/pytest_for_beginners_test_project
plugins: anyio-3.6.1
collected 4 items
tests/test_calculations/test_anticommutative_operations.py .. [ 50%]
tests/test_calculations/test_commutative_operations.py .. [100%]
================================ 4 passed in 0.15s ================================`
在继续之前,将名称改回TestCalculationEndpoints
。
测试失败
你的测试不会总是第一次就通过。
破坏test_calculate_sum
中assert
语句的预测输出,看看失败测试的输出是什么样的:
`# tests/test_calculations/test_commutative_operations.py
def test_calculate_sum():
calculation = calculate_sum(5, 3)
assert calculation == 7 # whops, a mistake`
运行测试。您应该会看到类似如下的内容:
`=============================== test session starts ===============================
platform darwin -- Python 3.10.4, pytest-7.1.2, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/pytest_for_beginners_test_project
plugins: anyio-3.6.1
collected 8 items
tests/test_calculations/test_anticommutative_operations.py .. [ 25%]
tests/test_calculations/test_commutative_operations.py F. [ 50%]
tests/test_endpoints/test_calculation_endpoints.py .... [100%]
==================================== FAILURES =====================================
_______________________________ test_calculate_sum ________________________________
def test_calculate_sum():
calculation = calculate_sum(5, 3)
> assert calculation == 7
E assert 8 == 7
tests/test_calculations/test_commutative_operations.py:8: AssertionError
============================= short test summary info =============================
FAILED tests/test_calculations/test_commutative_operations.py::test_calculate_sum
=========================== 1 failed, 7 passed in 0.26s ===========================`
在消息的底部,您可以看到一个简短测试摘要信息部分。这将告诉您哪个测试失败了,失败在哪里。在这种情况下,实际输出- 8
-与预期输出- 7
不匹配。
如果你向上滚动一点,失败的测试会详细显示出来,所以更容易指出哪里出错了(对更复杂的测试有帮助)。
在继续之前修复这个测试。
在特定的包或模块中运行测试
要运行一个特定的包或模块,您只需要在 pytest 命令中添加一个特定测试集的完整相对路径。
对于一个包:
`(venv)$ python -m pytest tests/test_calculations`
该命令将运行“tests/test_calculations”包中的所有测试。
对于模块:
`(venv)$ python -m pytest tests/test_calculations/test_commutative_operations.py`
该命令将运行测试/测试 _ 计算/测试 _ 交换 _ 操作. py 模块中的所有测试。
两者的输出将与前一个相似,除了执行的测试数量会更少。
在特定类中运行测试
要在 pytest 中访问一个特定的类,您需要写一个到它的模块的相对路径,然后在::
之后添加这个类:
`(venv)$ python -m pytest tests/test_endpoints/test_calculation_endpoints.py::TestCalculationEndpoints`
这个命令将执行TestCalculationEndpoints
类中的所有测试。
运行特定测试
您可以像访问类一样访问特定的测试,在相对路径后加上两个冒号,后跟测试名称:
`(venv)$ python -m pytest tests/test_calculations/test_commutative_operations.py::test_calculate_sum`
如果您希望运行的函数在一个类中,则需要以如下形式运行一个测试:
relative_path_to_module::TestClass::test_method
例如:
`(venv)$ python -m pytest tests/test_endpoints/test_calculation_endpoints.py::TestCalculationEndpoints::test_return_sum`
按关键字运行测试
现在,假设您只想运行与组织相关的测试。因为我们在处理除法的测试名称中包含了单词“divided ”,所以您可以像这样运行这些测试:
`(venv)$ python -m pytest -k "dividend"`
因此,8 个测试中的 2 个将运行:
`=============================== test session starts ===============================
platform darwin -- Python 3.10.4, pytest-7.1.2, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/pytest_for_beginners_test_project
plugins: anyio-3.6.1
collected 8 items / 6 deselected / 2 selected
tests/test_calculations/test_anticommutative_operations.py . [ 50%]
tests/test_endpoints/test_calculation_endpoints.py . [100%]
========================= 2 passed, 6 deselected in 0.18s =========================`
这些并不是选择特定测试子集的唯一方法。更多信息请参考官方文档。
值得记住的 pytest 标志
pytest 包括许多标志;您可以使用pytest --help
命令将它们全部列出。
其中最有用的有:
pytest -v
增加一级详细度,pytest -vv
增加两级详细度。例如,当使用参数化(用不同的输入/输出多次运行相同的测试)时,只运行pytest
会通知您有多少测试版本通过,有多少失败,同时添加-v
也会输出使用了哪些参数。如果你添加-vv
,你会看到每个测试版本的输入参数。您可以在 pytest 文档上看到更详细的示例。pytest -lf
仅重新运行上次运行失败的测试。如果没有失败,所有的测试都将运行。- 添加
-x
标志会导致 pytest 在第一次出错或测试失败时立即退出。
参数化
我们讨论了基础知识,现在开始讨论更高级的话题。
如果你正在跟进回购,将分支从基础切换到高级 _ 主题 (
git checkout advanced_topics
)。
有时候,一个简单的输入就足够了,但是也有很多时候你想要测试多个输入——例如,电子邮件、密码等等。
你可以通过@pytest.mark.parametrize
装饰器用参数化来添加多个输入和它们各自的输出。
例如,对于反交换运算,传递的数字的顺序很重要。明智的做法是涵盖更多情况,以确保该函数在所有情况下都能正常工作:
`# tests/test_calculations/test_anticommutative_operations.py
import pytest
from calculations import calculate_difference
@pytest.mark.parametrize(
"first_value, second_value, expected_output",
[
(10, 8, 2),
(8, 10, -2),
(-10, -8, -2),
(-8, -10, 2),
]
)
def test_calculate_difference(first_value, second_value, expected_output):
calculation = calculate_difference(first_value, second_value)
assert calculation == expected_output`
@pytest.mark.parametrize
具有严格的结构形式:
- 您向装饰者传递两个参数:
- 参数名称以逗号分隔的字符串
- 参数值的列表,其位置对应于参数名的位置
- 您将参数名传递给测试函数(它们不依赖于位置)
如果您运行该测试,它将运行 4 次,每次使用不同的输入和输出:
`(venv)$ python -m pytest -v tests/test_calculations/test_anticommutative_operations.py::test_calculate_difference
=============================== test session starts ===============================
platform darwin -- Python 3.10.4, pytest-7.1.2, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/pytest_for_beginners_test_project
plugins: anyio-3.6.1
collected 4 items
tests/test_calculations/test_anticommutative_operations.py::test_calculate_difference[10-8-2] PASSED [ 25%]
tests/test_calculations/test_anticommutative_operations.py::test_calculate_difference[8-10--2] PASSED [ 50%]
tests/test_calculations/test_anticommutative_operations.py::test_calculate_difference[-10--8--2] PASSED [ 75%]
tests/test_calculations/test_anticommutative_operations.py::test_calculate_difference[-8--10-2] PASSED [100%]
================================ 4 passed in 0.01s ================================`
固定装置
当排列步骤在多个测试中完全相同时,或者如果它太复杂以至于损害了测试的可读性时,将排列(以及随后的清理)步骤移动到一个单独的夹具功能是一个好主意。
创造
用@pytest.fixture
装饰器将一个函数标记为 fixture。
旧版本的TestCalculationEndpoints
在每个方法中都有一个创建TestClient
的步骤。
例如:
`# tests/test_endpoints/test_calculation_endpoints.py
def test_return_sum(self):
test_data = {
"first_val": 10,
"second_val": 8
}
client = TestClient(app)
response = client.post("/sum/", json=test_data)
assert response.status_code == 200
assert response.json() == 18`
在 advanced_topics 分支中,您会看到该方法现在看起来更加清晰:
`# tests/test_endpoints/test_calculation_endpoints.py
def test_return_sum(self, test_app):
test_data = {
"first_val": 10,
"second_val": 8
}
response = test_app.post("/sum/", json=test_data)
assert response.status_code == 200
assert response.json() == 18`
后两个保持原样,所以你可以比较它们(不要在现实生活中这样做;毫无意义)。
test_return_sum
现在使用一个名为test_app
的夹具,你可以在 conftest.py 文件中看到:
`# tests/conftest.py
import pytest
from starlette.testclient import TestClient
from main import app
@pytest.fixture(scope="module")
def test_app():
client = TestClient(app)
return client`
这是怎么回事?
@pytest.fixture()
装饰器将函数test_app
标记为 fixture。当 pytest 读取该模块时,它会将该函数添加到 fixtures 列表中。测试函数可以使用列表中的任何 fixture。- 这个 fixture 是一个返回
TestClient
的简单函数,因此可以执行测试 API 调用。 - 将测试函数参数与一系列夹具进行比较。如果参数的值与 fixture 的名称匹配,fixture 将被解析,其返回值将作为参数写入测试函数。
- test 函数使用 fixture 的结果进行测试,使用它的方式与使用任何其他变量值的方式相同。
另一个需要注意的重要事情是,函数不是传递给 fixture 本身,而是传递给 fixture 值。
范围
Fixtures 是在测试第一次请求时创建的,但是它们是基于它们的作用域而被销毁的。夹具被破坏后,如果另一个试验需要,需要再次调用它;因此,您需要注意耗时的固定程序(例如,API 调用)的范围。
有五个可能的范围,从最窄到最宽:
范围 | 描述 |
---|---|
功能(默认) | 测试结束时,夹具被破坏。 |
班级 | 在级最后一次测试的拆卸过程中,夹具被破坏。 |
组件 | 在拆卸模块中的最后一次测试时,夹具被破坏。 |
包裹 | 在拆卸包装中的最后一次测试时,夹具被破坏。 |
会议 | 夹具在测试阶段结束时被销毁。 |
要更改上例中的范围,只需设置scope
参数:
`# tests/conftest.py
import pytest
from starlette.testclient import TestClient
from main import app
@pytest.fixture(scope="function") # scope changed
def test_app():
client = TestClient(app)
return client`
定义尽可能小的范围有多重要取决于 fixture 的耗时程度。创建一个
TestClient
不是很耗时,所以改变范围不会缩短测试运行。但是,例如,使用调用外部 API 的 fixture 运行 10 个测试可能非常耗时,所以最好使用module
作用域。
临时文件
当您的生产代码必须处理文件时,您的测试也是如此。
为了避免多个测试文件之间的干扰,甚至是应用程序的其余部分和额外的清理过程,最好使用一个唯一的临时目录。
在示例应用程序中,我们存储了在 JSON 文件上执行的所有操作,以供将来分析。现在,因为您肯定不希望在测试运行期间更改生产文件,所以您需要创建一个单独的临时 JSON 文件。
要测试的代码可以在 store_calculations.py 中找到:
`# store_calculations.py
import json
class CalculationsStoreJSON:
def __init__(self, json_file_path):
self.json_file_path = json_file_path
with open(self.json_file_path / "calculations.json", "w") as file:
json.dump([], file)
def add(self, calculation):
with open(self.json_file_path/"calculations.json", "r+") as file:
calculations = json.load(file)
calculations.append(calculation)
file.seek(0)
json.dump(calculations, file)
def list_operation_usages(self, operation):
with open(self.json_file_path / "calculations.json", "r") as file:
calculations = json.load(file)
return [calculation for calculation in calculations if calculation['operation'] == operation]`
注意,在初始化CalculationsStoreJSON
时,您必须提供一个json_file_path
,JSON 文件将存储在这里。这可以是磁盘上的任何有效路径;对于产品代码和测试,您可以用同样的方式传递路径。
幸运的是,pytest 提供了许多内置夹具,其中一个我们可以在这里使用,叫做 tmppath :
`# tests/test_advanced/test_calculations_storage.py
from store_calculations import CalculationsStoreJSON
def test_correct_calculations_listed_from_json(tmp_path):
store = CalculationsStoreJSON(tmp_path)
calculation_with_multiplication = {"value_1": 2, "value_2": 4, "operation": "multiplication"}
store.add(calculation_with_multiplication)
assert store.list_operation_usages("multiplication") == [{"value_1": 2, "value_2": 4, "operation": "multiplication"}]`
该测试检查在使用CalculationsStoreJSON.add()
方法将计算保存到 JSON 文件时,我们是否可以使用UserStoreJSON.list_operation_usages()
检索某些操作的列表。
我们将tmp_path
fixture 传递给这个测试,它返回一个 path ( pathlib.Path
)对象,该对象指向基目录中的一个临时目录。
当使用tmp_path
时,pytest 创建一个:
- 基本临时目录
- 对每个测试函数调用来说唯一的临时目录(在基目录中)
值得注意的是,为了帮助调试,pytest 会在每个测试会话期间创建一个新的基本临时目录,而旧的基本目录会在 3 个会话之后被删除。
猴子补丁
使用 monkeypatching ,您可以在运行时动态修改一段代码的行为,而无需实际更改源代码。
虽然它不一定仅限于测试,但在 pytest 中,它用于修改被测单元内部代码部分的行为。它通常用于替换昂贵的函数调用,比如对 API 的 HTTP 调用,使用一些预定义的快速且易于控制的虚拟行为。
例如,不是调用真正的 API 来获得响应,而是返回一些在测试中使用的硬编码响应。
让我们深入了解一下。在我们的应用程序中,有一个函数返回从公共 API 中检索到的某个数字的事实:
`# number_facts.py
import requests
def get_number_fact(number):
url = f"http://numbersapi.com/{number}?json"
response = requests.get(url)
json_resp = response.json()
if json_resp["found"]:
return json_resp["text"]
return "No fact about this number."`
您不想在测试期间调用 API,因为:
- 它很慢
- 这很容易出错(API 可能会关闭,您的互联网连接可能很差,...)
在这种情况下,您想要模拟响应,所以它返回我们感兴趣的部分,而不需要实际发出 HTTP 请求:
`# tests/test_advanced/test_number_facts.py
import requests
from number_facts import get_number_fact
class MockedResponse:
def __init__(self, json_body):
self.json_body = json_body
def json(self):
return self.json_body
def mock_get(*args, **kwargs):
return MockedResponse({
"text": "7 is the number of days in a week.",
"found": "true",
})
def test_get_number_fact(monkeypatch):
monkeypatch.setattr(requests, 'get', mock_get)
number = 7
fact = '7 is the number of days in a week.'
assert get_number_fact(number) == fact`
这里发生了很多事情:
- 测试函数中使用了 pytest 的内置 monkeypatch 夹具。
- 使用
monkeypatch.setattr
,我们用自己的函数mock_get
覆盖了requests
包的get
函数。在这个测试的执行过程中,应用程序代码中对requests.get
的所有调用现在都将实际调用mock_get
。 mock_get
函数返回一个MockedResponse
实例,用我们在mock_get
函数({'"text": "7 is the number of days in a week.", "found": "true",}
)中分配的值替换json_body
。- 每次测试被调用时,不是像产品代码(
get_number_fact
)那样执行requests.get("http://numbersapi.com/7?json")
,而是返回一个带有硬编码事实的MockedResponse
。
这样,您仍然可以验证函数的行为(从 API 响应中获得一个数字的事实),而无需真正调用 API。
结论
pytest 在过去几年中成为标准有许多原因,最值得注意的是:
- 它简化了测试的编写。
- 由于其全面的输出,可以很容易地查明哪些测试失败以及失败的原因。
- 它为重复或复杂的测试准备、为测试目的创建文件以及测试隔离提供了解决方案。
pytest 提供了比我们在本文中讨论的更多的东西。
他们的文档包括有用的操作指南,深入涵盖了我们在这里浏览的大部分内容。他们还提供了一些的例子。
pytest 还附带了一个广泛的插件列表,您可以用它来扩展 pytest 的功能。
这里有一些你可能会觉得有用的:
- pytest-cov 增加了对检查代码覆盖率的支持。
- pytest-django 增加了一套测试 django 应用程序的有用工具。
- pytest-xdist 允许您并行运行测试,从而缩短测试运行所需的时间。
- pytest-random以随机的顺序运行测试,防止它们意外地相互依赖。
- pytest-asincio 让测试异步程序变得更加容易。
- pytest-mock 提供了一个 mocker fixture,它是标准 unittest 模拟包和附加实用程序的包装器。
本文应该有助于您理解 pytest 库是如何工作的,以及使用它可以完成什么。然而,理解 pytest 如何工作和测试如何工作是不一样的。学习编写有意义的测试需要实践和理解你期望你的代码做什么。
Python 代码质量
到底什么是代码质量?我们如何衡量它?我们如何提高代码质量,清理我们的 Python 代码?
代码质量一般指你的代码功能性和可维护性如何。在以下情况下,代码被视为高质量:
- 这符合它的目的
- 它的行为是可以测试的
- 它遵循一贯的风格
- 可以理解
- 它不包含安全漏洞
- 这是有据可查的
- 很容易维护
因为我们已经在 Python 中的测试和 Python 中的现代测试驱动开发文章中解决了前两点,所以本文的重点是第三至第七点。
本文探讨了如何使用 linters、代码格式化器和安全漏洞扫描器来提高 Python 代码的质量。
完整 Python 指南:
棉短绒
Linters 通过源代码分析标记编程错误、bug、风格错误和可疑结构。林挺工具易于设置,提供合理的默认值,并通过消除对风格有不同意见的开发人员之间的摩擦来改善整体开发体验。
虽然林挺是一种常见的实践,但它仍然被许多开发人员所不喜欢,因为开发人员往往非常固执己见。
让我们看一个简单的例子。
版本一:
`numbers = []
while True:
answer = input('Enter a number: ')
if answer != 'quit':
numbers.append(answer)
else:
break
print('Numbers: %s' % numbers)`
版本二:
`numbers = []
while (answer := input("Enter a number: ")) != "quit":
numbers.append(answer)
print(f"Numbers: {numbers}")`
版本三:
`numbers = []
while True:
answer = input("Enter a number: ")
if answer == "quit":
break
numbers.append(answer)
print(f"Numbers: {numbers}")`
哪个更好?
就功能而言,它们是相同的。
你喜欢哪一个?你的项目合作者更喜欢哪一个?
作为一名软件开发人员,你很可能在团队中工作。而且,在团队环境中,所有开发人员遵循相同的编码标准是非常重要的。不然看别人的代码就难多了。代码审查的重点应该是更高层次的问题,而不是普通的语法格式问题。
例如,如果你决定用感叹号结束每一句话,读者将很难推断出语气。如果你更进一步,忽略像大写和间距规则这样的通用标准,你的句子将很难阅读。阅读你的作品需要更多的脑力。你会失去读者和合作者。代码也类似。我们使用风格指南使我们的开发伙伴(包括我们自己)更容易推断意图并与我们合作。
作为 Python 开发人员,我们很幸运能够拥有 PEP-8 风格指南,它提供了一组约定、指南和最佳实践,使我们的代码更容易阅读和维护。它主要关注命名约定、代码注释和布局问题(比如缩进和空白)。几个例子:
就林挺工具而言,虽然有很多这样的工具,但大部分都是在代码逻辑或强制代码标准中寻找错误:
- 代码逻辑——这些检查编程错误,强制执行代码标准,搜索代码气味,并检查代码复杂性。 Pyflakes 和 McCabe (复杂度检查器)是林挺代码逻辑最流行的工具。
- 代码风格——这些只是执行代码标准(基于 PEP-8)。 pycodestyle 就属于这一类。
薄片 8
Flake8 是 Pyflakes、pycodestyle 和 McCabe 的包装器。
它可以像任何其他 PyPI 包一样安装:
假设您将以下代码保存到一个名为 my_module.py 的文件中:
`from requests import *
def get_error_message(error_type):
if error_type == 404:
return 'red'
elif error_type == 403:
return 'orange'
elif error_type == 401:
return 'yellow'
else:
return 'blue'
def main():
res = get('https://api.github.com/events')
STATUS = res.status_code
if res.ok:
print(f'{STATUS}')
else:
print(get_error_message(STATUS))
if __name__ == '__main__':
main()`
要 lint 这个文件,您只需运行:
`$ python -m flake8 my_module.py`
这将产生以下输出:
`my_module.py:1:1: F403 'from requests import *' used; unable to detect undefined names
my_module.py:3:1: E302 expected 2 blank lines, found 1
my_module.py:15:11: F405 'get' may be undefined, or defined from star imports: requests
my_module.py:25:1: E303 too many blank lines (4)`
根据代码编辑器的配置,您可能还会看到一个
my_module.py:26:11: W292 no newline at end of file
错误。
对于每个违规,都会打印一行,其中包含以下数据:
- 文件路径(相对于运行 Flake8 的目录)
- 行数
- 列号
- 违反的规则的 ID
- 规则描述
以F
开头的违例是来自 Pyflakes 的错误,而以E
开头的违例来自 pycodestyle 。
纠正违规后,您应该:
`from requests import get
def get_error_message(error_type):
if error_type == 404:
return 'red'
elif error_type == 403:
return 'orange'
elif error_type == 401:
return 'yellow'
else:
return 'blue'
def main():
res = get('https://api.github.com/events')
STATUS = res.status_code
if res.ok:
print(f'{STATUS}')
else:
print(get_error_message(STATUS))
if __name__ == '__main__':
main()`
除了 PyFlakes 和 pycodestyle,您还可以使用 Flake8 来检查圈复杂度。
例如,get_error_message
函数的复杂度为 4,因为有四个可能的分支(或代码路径):
`def get_error_message(error_type):
if error_type == 404:
return 'red'
elif error_type == 403:
return 'orange'
elif error_type == 401:
return 'yellow'
else:
return 'blue'`
要强制最大复杂性为 3 或更低,请运行:
`$ python -m flake8 --max-complexity 3 my_module.py`
Flake8 应失败,出现以下情况:
`my_module.py:4:1: C901 'get_error_message' is too complex (4)`
像这样重构代码:
`def get_error_message(error_type):
colors = {
404: 'red',
403: 'orange',
401: 'yellow',
}
return colors[error_type] if error_type in colors else 'blue'`
薄片 8 现在应该通过:
`$ python -m flake8 --max-complexity 3 my_module.py`
你可以通过它强大的插件系统给 Flake8 添加额外的检查。例如,为了实施 PEP-8 命名约定,安装 pep8-naming :
`$ pip install pep8-naming`
运行:
`$ python -m flake8 my_module.py`
您应该看到:
`my_module.py:15:6: N806 variable 'STATUS' in function should be lowercase`
修复:
`def main():
res = get('https://api.github.com/events')
status = res.status_code
if res.ok:
print(f'{status}')
else:
print(get_error_message(status))`
查看牛逼的 Flake8 扩展获得最受欢迎的扩展列表。
Pylama 也是一种流行的林挺工具,像 Flake8 一样,它可以将几块棉绒粘合在一起。
代码格式化程序
虽然 linters 只是检查代码中的问题,但代码格式化程序实际上是基于一组标准来重新格式化代码的。
让你的代码保持正确的格式是一项必要而枯燥的工作,应该由计算机来完成。
为什么有必要?
遵循一致性风格指南的格式良好的代码更容易阅读,这使得找到 bug 和新开发人员更容易。它还减少了合并冲突。
可读性很重要。
–Python 的禅
同样,因为这是一项枯燥的工作,开发人员常常固执己见(制表符对空格,单引号对双引号,等等)。),使用代码格式化工具根据一组标准自动重新格式化您的代码。
伊索特
isort 用于自动将代码中的导入分成以下几组:
- 标准程序库
- 第三方的
- 当地的
然后,成组的导入按字母顺序逐个排列。
`# standard library
import datetime
import os
# third-party
import requests
from flask import Flask
from flask.cli import AppGroup
# local
from your_module import some_method`
安装:
如果你更喜欢 Flake8 插件,请查看 flake8-isort 。薄片 8-进口订单也很受欢迎。
对当前目录和子目录中的文件运行它:
要对单个文件运行它:
`$ python -m isort my_module.py`
之前:
`import os
import datetime
from your_module import some_method
from flask.cli import AppGroup
import requests
from flask import Flask`
之后:
`import datetime
import os
import requests
from flask import Flask
from flask.cli import AppGroup
from your_module import some_method`
要检查您的导入是否正确排序,并且不做任何更改,请使用--check-only
标志:
`$ python -m isort my_module.py --check-only
ERROR: my_module.py Imports are incorrectly sorted and/or formatted.`
要查看更改,而不应用更改,请使用--diff
标志:
`$ python -m isort my_module.py --diff
--- my_module.py:before 2022-02-28 22:04:45.977272
+++ my_module.py:after 2022-02-28 22:04:48.254686
@@ -1,6 +1,7 @@
+import datetime
import os
-import datetime
+
+import requests
+from flask import Flask
+from flask.cli import AppGroup
from your_module import some_method
-from flask.cli import AppGroup
-import requests
-from flask import Flask`
使用 isort 和 Black 时,您应该使用--profile black
选项,以避免代码风格冲突:
`$ python -m isort --profile black .`
黑色
Black 是一个 Python 代码格式化程序,用于根据 Black 的代码风格指南重新格式化你的代码,它非常接近 PEP-8。
更喜欢 Flake8 插件?检查一下 flake8-black 。
要递归编辑当前目录中的文件:
它也可以针对单个文件运行:
`$ python -m black my_module.py`
之前:
`import pytest
@pytest.fixture(scope="module")
def authenticated_client(app):
client = app.test_client()
client.post("/login", data=dict(email="[[email protected]](/cdn-cgi/l/email-protection)", password="notreal"), follow_redirects=True)
return client`
之后:
`import pytest
@pytest.fixture(scope="module")
def authenticated_client(app):
client = app.test_client()
client.post(
"/login",
data=dict(email="[[email protected]](/cdn-cgi/l/email-protection)", password="notreal"),
follow_redirects=True,
)
return client`
如果您只想检查您的代码是否遵循 Black code 风格标准,您可以使用--check
标志:
`$ python -m black my_module.py --check
would reformat my_module.py
Oh no! 💥 💔 💥
1 file would be reformatted.`
```py
与此同时,`--diff`标志显示了当前代码和重新格式化后的代码之间的差异:
`$ python -m black my_module.py --diff
--- my_module.py 2022-02-28 22:04:45.977272 +0000
+++ my_module.py 2022-02-28 22:05:15.124565 +0000
@@ -1,7 +1,12 @@
import pytest
+
@pytest.fixture(scope="module")
def authenticated_client(app):
client = app.test_client()
- client.post("/login", data=dict(email="[email protected]", password="notreal"), follow_redirects=True)
- return client
\ No newline at end of file
- client.post(
-
"/login",
-
data=dict(email="[[email protected]](/cdn-cgi/l/email-protection)", password="notreal"),
-
follow_redirects=True,
- )
- return client
would reformat my_module.py
All done! ✨ 🍰 ✨
1 file would be reformatted.`
> [YAPF](https://github.com/google/yapf) 和 [autopep8](https://github.com/hhatto/autopep8) 是类似于 Black 的代码格式化程序,也值得一看。
## 安全漏洞扫描器
安全漏洞可以说是代码质量最重要的方面,然而它们经常被忽视。您的代码的安全性取决于它最薄弱的环节。幸运的是,有许多工具可以帮助检测我们代码中可能的漏洞。让我们来看看其中的两个。
### 强盗
[Bandit](https://github.com/PyCQA/bandit) 是一款旨在发现 Python 代码中常见安全[问题](https://bandit.readthedocs.io/en/latest/plugins/index.html#complete-test-plugin-listing)的工具,比如硬编码的密码字符串、反序列化不可信代码、在 except 块中使用`pass`等等。
> 更喜欢 Flake8 插件?检查一下 [flake8-bandit](https://github.com/tylerwince/flake8-bandit) 。
像这样运行它:
代码:
`evaluate = 'print("Hi!")'
eval(evaluate)
evaluate = 'open("secret_file.txt").read()'
eval(evaluate)`
您应该会看到以下警告:
`>> Issue: [B307:blacklist] Use of possibly insecure function - consider using safer
ast.literal_eval.
Severity: Medium Confidence: High
Location: my_module.py:2
More Info:
https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b307-eval
1 evaluate = 'print("Hi!")'
2 eval(evaluate)
3
Issue: [B307:blacklist] Use of possibly insecure function - consider using safer
ast.literal_eval.
Severity: Medium Confidence: High
Location: my_module.py:6
More Info:
https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b307-eval
5 evaluate = 'open("secret_file.txt").read()'
6 eval(evaluate)
--------------------------------------------------`
### 安全
安全是另一个让你的代码免于安全问题的工具。
它用于对照[安全数据库](https://github.com/pyupio/safety-db)检查您已安装的依赖项中已知的安全漏洞,安全数据库是 Python 包中已知安全漏洞的数据库。
激活虚拟环境后,您可以像这样运行它:
安装烧瓶 v0.12.2 时的样品输出:
+==============================================================================+ | | | /$$$$$$ /$$ | | /$$__ $$ | $$ | | /$$$$$$$ /$$$$$$ | $$ \__//$$$$$$ /$$$$$$ /$$ /$$ | | /$$_____/ |____ $$| $$$$ /$$__ $$|_ $$_/ | $$ | $$ | | | $$$$$$ /$$$$$$$| $$_/ | $$$$$$$$ | $$ | $$ | $$ | | \____ $$ /$$__ $$| $$ | $$_____/ | $$ /$$| $$ | $$ | | /$$$$$$$/| $$$$$$$| $$ | $$$$$$$ | $$$$/| $$$$$$$ | | |_______/ \_______/|__/ \_______/ \___/ \____ $$ | | /$$ | $$ | | | $$$$$$/ | | by pyup.io \______/ | | | +==============================================================================+ | REPORT | | checked 37 packages, using default DB | +============================+===========+==========================+==========+ | package | installed | affected | ID | +============================+===========+==========================+==========+ | flask | 0.12.2 | <0.12.3 | 36388 | | flask | 0.12.2 | <1.0 | 38654 | +==============================================================================+
现在你已经知道了工具,下一个问题是:什么时候应该使用它们?
通常,这些工具会运行:
1. 编码时(在 ide 或代码编辑器中)
2. 提交时(使用预提交挂钩)
3. 当代码签入源代码管理时(通过 CI 管道)
### 在您的 ide 或代码编辑器中
最好尽早并经常检查可能对质量有负面影响的问题。因此,强烈建议在开发过程中对代码进行 lint 和格式化。许多流行的 ide 都内置了 linters 和 formatters。你可以为你的代码编辑器找到一个插件,用于上面提到的大多数工具。此类插件会实时警告您代码风格违规和潜在的编程错误。
资源:
1. [林挺](https://code.visualstudio.com/docs/python/linting)和[在 Visual Studio 代码中格式化](https://code.visualstudio.com/docs/python/editing#_formatting) Python
2. [黑色编辑器集成](https://black.readthedocs.io/en/stable/integrations/editors.html)
3. [崇高文字包查找器](https://packagecontrol.io/)
4. [Atom 包](https://atom.io/packages)
### 提交前挂钩
由于您在编码时不可避免地会遗漏一些警告,所以在提交时用预提交 git 挂钩检查质量问题是一个好的实践。在 lint 之前,可以先格式化代码。这样,您可以避免在 CI 管道中提交无法通过代码质量检查的代码。
推荐使用[预提交](https://pre-commit.com/)框架来管理 git 挂钩。
安装完成后,添加一个名为*的预提交配置文件。将 pre-commit-config.yaml* 提交到项目中。要运行 Flake8,请添加以下配置:
repos: - repo: https://gitlab.com/PyCQA/flake8 rev: 4.0.1 hooks: - id: flake8
最后,要设置 git 挂钩脚本,运行:
(venv)$ pre-commit install
现在,每次运行`git commit` Flake8 都会在实际提交之前运行。如果有任何问题,提交将被中止。
### CI 管道
尽管您可能在代码编辑器和预提交钩子中使用代码质量工具,但您不能总是指望您的队友和其他合作者也这样做。因此,您应该在 CI 管道中运行代码质量检查。此时,您应该运行 linters 和安全漏洞检测器,并确保代码遵循特定的代码风格。您可以在测试的同时运行这样的检查。
## 真实项目
让我们创建一个简单的项目来看看所有这些是如何工作的。
首先,创建一个新文件夹:
$ mkdir flask_example $ cd flask_example
接下来,用[诗歌](https://python-poetry.org)初始化你的项目:
`$ poetry init
Package name [flask_example]:
Version [0.1.0]:
Description []:
Author [Your name <[email protected]>, n to skip]:
License []:
Compatible Python versions [^3.10]:
Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Do you confirm generation? (yes/no) [yes]`
之后,添加 Flask、pytest、Flake8、Black、isort、Bandit 和 Safety:
$ poetry add flask $ poetry add --dev pytest flake8 black isort safety bandit
创建一个文件来保存名为 *test_app.py* 的测试:
`from app import app
import pytest
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def test_home(client):
response = client.get('/')
assert response.status_code == 200`
接下来,为 Flask 应用程序添加一个名为 *app.py* 的文件:
`from flask import Flask
app = Flask(name)
@app.route('/')
def home():
return 'OK'
if name == 'main':
app.run()`
现在,我们准备添加预提交配置。
首先,初始化一个新的 git 存储库:
接下来,安装预提交并设置 git 挂钩脚本:
$ poetry add --dev pre-commit $ poetry run pre-commit install
为名为*的配置创建一个文件。预提交配置. yaml* :
repos: - repo: https://gitlab.com/PyCQA/flake8 rev: 4.0.1 hooks: - id: flake8
在提交之前,运行 isort 和 Black:
$ poetry run isort . --profile black $ poetry run black .
提交您的更改以触发预提交挂钩:
$ git add . $ git commit -m 'Initial commit'
最后,让我们通过 [GitHub Actions](https://github.com/features/actions) 配置一个 CI 管道。
创建以下文件和文件夹:
.github └── workflows └── main.yaml
*。github/workflows/main.yaml* :
name: CI on: [push] jobs: test: strategy: fail-fast: false matrix: python-version: [3.10.2] poetry-version: [1.1.13] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) with: python-version: ${{ matrix.python-version }} - name: Run image uses: abatilo/[[email protected]](/cdn-cgi/l/email-protection) with: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies run: poetry install - name: Run tests run: poetry run pytest code-quality: strategy: fail-fast: false matrix: python-version: [3.10.2] poetry-version: [1.1.13] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) with: python-version: ${{ matrix.python-version }} - name: Run image uses: abatilo/[[email protected]](/cdn-cgi/l/email-protection) with: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies run: poetry install - name: Run black run: poetry run black . --check - name: Run isort run: poetry run isort . --check-only --profile black - name: Run flake8 run: poetry run flake8 . - name: Run bandit run: poetry run bandit . - name: Run saftey run: poetry run safety check
这种配置:
* 每次推送时运行- `on: [push]`
* 运行在最新版本的 Ubuntu - `ubuntu-latest`
* 使用 Python 3.10.2 - `python-version: [3.10.2]`,`python-version: ${{ matrix.python-version }}`
* 1.1.13 -使用诗歌版本`poetry-version: [1.1.13]`,`poetry-version: ${{ matrix.poetry-version }}`
定义了两个作业:`test`和`code-quality`。顾名思义,测试在`test`作业中运行,而我们的代码质量检查在`code-quality`作业中运行。
将配置项配置添加到 git 并提交:
$ git add .github/workflows/main.yaml $ git commit -m 'Add CI config'
在 [GitHub](https://github.com/new) 上创建一个新的资源库,并将您的项目推送到新创建的 remote。
例如:
$ git remote add origin [[email protected]](/cdn-cgi/l/email-protection):jangia/flask_example.git $ git branch -M main $ git push -u origin main
您应该在 GitHub 存储库的 *Actions* 选项卡上看到您的工作流正在运行。
## 结论
代码质量是软件开发中最具争议的话题之一。尤其是代码风格,在开发人员中是一个敏感的问题,因为我们花费了大量的开发时间来阅读代码。当代码具有符合 PEP-8 标准的一致风格时,阅读和推断意图要容易得多。由于这是一个枯燥、平凡的过程,它应该由计算机通过代码格式化程序如 Black 和 isort 来处理。同样,Flake8、Bandit 和 Safety 有助于确保您的代码安全无误。
* * *
> [完整 Python](/guides/complete-python/) 指南:
>
> 1. [现代 Python 环境——依赖性和工作空间管理](/blog/python-environments/)
> 2. [Python 中的测试](/blog/testing-python/)
> 3. [Python 中的现代测试驱动开发](/blog/modern-tdd/)
> 4. [Python 代码质量](/blog/python-code-quality/)(本文!)
> 5. [Python 类型检查](/blog/python-type-checking/)
> 6. [记录 Python 代码和项目](/blog/documenting-python/)
> 7. [Python 项目工作流程](/blog/python-project-workflow/)
# Python 中的并行性、并发性和异步性——示例
> 原文:<https://testdriven.io/blog/python-concurrency-parallelism/>
本教程着眼于如何通过多处理、线程和异步来加速 CPU 绑定和 IO 绑定操作。
## 并发性与并行性
并发和并行是相似的术语,但它们不是一回事。
并发是指在 CPU 上同时运行多个任务的能力。任务可以在重叠的时间段内开始、运行和完成。在单个 CPU 的情况下,多个任务在[上下文切换](https://en.wikipedia.org/wiki/Context_switch)的帮助下运行,其中存储了一个进程的状态,以便稍后调用和执行。
同时,并行性是指在多个 CPU 内核上同时运行多个任务的能力。
虽然它们可以提高应用程序的速度,但是并发和并行不应该到处使用。用例取决于任务是 CPU 受限还是 IO 受限。
受 CPU 限制的任务是受 CPU 限制的。例如,数学计算是受 CPU 限制的,因为计算能力随着计算机处理器数量的增加而增加。并行性适用于 CPU 受限的任务。理论上,如果一个任务被分成 n 个子任务,这 n 个任务中的每一个都可以并行运行,以有效地将时间减少到原来非并行任务的 1/n。并发是 IO 绑定任务的首选,因为您可以在获取 IO 资源的同时做其他事情。
CPU 密集型任务的最好例子是在数据科学中。数据科学家处理大量的数据。对于数据预处理,他们可以将数据分成多个批处理并并行运行,从而有效地减少处理的总时间。增加内核数量可以提高处理速度。
网页抓取是 IO 绑定的。因为该任务对 CPU 的影响很小,因为大部分时间都花在从网络读取数据和向网络写入数据上。其他常见的 IO 绑定任务包括数据库调用以及向磁盘读写文件。像 Django 和 Flask 这样的 Web 应用程序都是 IO 绑定的应用程序。
> 如果您有兴趣了解更多关于 Python 中线程、多处理和异步的区别,请查看文章[用并发、并行和异步加速 Python。](/blog/concurrency-parallelism-asyncio/)
## 方案
至此,让我们来看看如何加速以下任务:
```py
`# tasks.py
import os
from multiprocessing import current_process
from threading import current_thread
import requests
def make_request(num):
# io-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
requests.get("https://httpbin.org/ip")
async def make_request_async(num, client):
# io-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
await client.get("https://httpbin.org/ip")
def get_prime_numbers(num):
# cpu-bound
pid = os.getpid()
thread_name = current_thread().name
process_name = current_process().name
print(f"{pid} - {process_name} - {thread_name}")
numbers = []
prime = [True for i in range(num + 1)]
p = 2
while p * p <= num:
if prime[p]:
for i in range(p * 2, num + 1, p):
prime[i] = False
p += 1
prime[0] = False
prime[1] = False
for p in range(num + 1):
if prime[p]:
numbers.append(p)
return numbers`
本教程中的所有代码示例都可以在parallel-concurrent-examples-pythonrepo 中找到。
注意事项:
make_request
向https://httpbin.org/ip发出 X 次 HTTP 请求。make_request_async
与 HTTPX 异步发出相同的 HTTP 请求。get_prime_numbers
通过厄拉多塞方法的筛子,计算从 2 到给定极限的素数。
我们将使用标准库中的以下库来加速上述任务:
IO 绑定操作
同样,IO 绑定任务在 IO 上花费的时间比在 CPU 上花费的时间多。
由于 web 抓取是 IO 绑定的,我们应该使用线程来加快处理速度,因为检索 HTML (IO)比解析它(CPU)要慢。
场景:如何加速一个基于 Python 的网页抓取和爬取脚本?
同步示例
先说一个基准。
`# io-bound_sync.py
import time
from tasks import make_request
def main():
for num in range(1, 101):
make_request(num)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")`
这里,我们使用make_request
函数发出了 100 个 HTTP 请求。因为请求是同步发生的,所以每个任务都是按顺序执行的。
`Elapsed run time: 15.710984757 seconds.`
因此,每个请求大约需要 0.16 秒。
线程示例
`# io-bound_concurrent_1.py
import threading
import time
from tasks import make_request
def main():
tasks = []
for num in range(1, 101):
tasks.append(threading.Thread(target=make_request, args=(num,)))
tasks[-1].start()
for task in tasks:
task.join()
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")`
在这里,同一个make_request
函数被调用 100 次。这次使用threading
库为每个请求创建一个线程。
`Elapsed run time: 1.020112515 seconds.`
总时间从大约 16s 减少到大约 1s。
由于我们对每个请求使用单独的线程,您可能会奇怪为什么整个过程没有花 0.16 秒就完成了。这些额外的时间是管理线程的开销。Python 中的全局解释器锁 (GIL)确保一次只有一个线程使用 Python 字节码。
并发.未来示例
`# io-bound_concurrent_2.py
import time
from concurrent.futures import ThreadPoolExecutor, wait
from tasks import make_request
def main():
futures = []
with ThreadPoolExecutor() as executor:
for num in range(1, 101):
futures.append(executor.submit(make_request, num))
wait(futures)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")`
这里我们使用了concurrent.futures.ThreadPoolExecutor
来实现多线程。在创建了所有的未来/承诺之后,我们使用wait
来等待它们全部完成。
`Elapsed run time: 1.340592231 seconds`
concurrent.futures.ThreadPoolExecutor
实际上是围绕multithreading
库的一个抽象,这使得它更容易使用。在前面的例子中,我们将每个请求分配给一个线程,总共使用了 100 个线程。但是ThreadPoolExecutor
默认工作线程的数量为min(32, os.cpu_count() + 4)
。ThreadPoolExecutor 的存在是为了简化实现多线程的过程。如果你想对多线程有更多的控制,请使用multithreading
库。
AsyncIO 示例
`# io-bound_concurrent_3.py
import asyncio
import time
import httpx
from tasks import make_request_async
async def main():
async with httpx.AsyncClient() as client:
return await asyncio.gather(
*[make_request_async(num, client) for num in range(1, 101)]
)
if __name__ == "__main__":
start_time = time.perf_counter()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
end_time = time.perf_counter()
elapsed_time = end_time - start_time
print(f"Elapsed run time: {elapsed_time} seconds")`
此处使用
httpx
,因为requests
不支持异步操作。
这里,我们使用了asyncio
来实现并发。
`Elapsed run time: 0.553961068 seconds`
asyncio
比其他方法更快,因为threading
利用了 OS(操作系统)线程。所以线程由操作系统管理,线程切换由操作系统抢占。asyncio
使用由 Python 解释器定义的协程。使用协程,程序决定何时以最佳方式切换任务。这由 asyncio 中的even_loop
处理。
CPU 限制的操作
场景:如何加速一个简单的数据处理脚本?
同步示例
同样,让我们从一个基准开始。
`# cpu-bound_sync.py
import time
from tasks import get_prime_numbers
def main():
for num in range(1000, 16000):
get_prime_numbers(num)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")`
这里,我们对从 1000 到 16000 的数字执行了get_prime_numbers
函数。
`Elapsed run time: 17.863046316 seconds.`
多重处理示例
`# cpu-bound_parallel_1.py
import time
from multiprocessing import Pool, cpu_count
from tasks import get_prime_numbers
def main():
with Pool(cpu_count() - 1) as p:
p.starmap(get_prime_numbers, zip(range(1000, 16000)))
p.close()
p.join()
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")`
在这里,我们使用multiprocessing
来计算质数。
`Elapsed run time: 2.9848740599999997 seconds.`
并发.未来示例
`# cpu-bound_parallel_2.py
import time
from concurrent.futures import ProcessPoolExecutor, wait
from multiprocessing import cpu_count
from tasks import get_prime_numbers
def main():
futures = []
with ProcessPoolExecutor(cpu_count() - 1) as executor:
for num in range(1000, 16000):
futures.append(executor.submit(get_prime_numbers, num))
wait(futures)
if __name__ == "__main__":
start_time = time.perf_counter()
main()
end_time = time.perf_counter()
print(f"Elapsed run time: {end_time - start_time} seconds.")`
这里,我们使用concurrent.futures.ProcessPoolExecutor
实现了多重处理。一旦作业被添加到 futures 中,wait(futures)
就会等待它们完成。
`Elapsed run time: 4.452427557 seconds.`
concurrent.futures.ProcessPoolExecutor
是围绕multiprocessing.Pool
的包装器。它和ThreadPoolExecutor
有同样的局限性。如果你想对多重处理有更多的控制,使用multiprocessing.Pool
。concurrent.futures
提供了对多处理和线程的抽象,使得两者之间的切换变得容易。
结论
值得注意的是,使用多重处理来执行make_request
函数将比线程方式慢得多,因为进程需要等待 IO。不过,多处理方法将比同步方法更快。
类似地,与并行性相比,对 CPU 受限的任务使用并发性是不值得的。
也就是说,使用并发性或并行性来执行脚本会增加复杂性。您的代码通常更难阅读、测试和调试,所以只有在长时间运行的脚本绝对需要时才使用它们。
我通常从这里开始,因为-
- 在并发和并行之间来回切换很容易
- 从属库不需要支持 asyncio (
requests
vshttpx
) - 与其他方法相比,它更清晰、更容易阅读
从 GitHub 的parallel-concurrent-examples-pythonrepo 中抓取代码。
Python 依赖注入
编写干净、可维护的代码是一项具有挑战性的任务。幸运的是,有许多模式、技术和可重用的解决方案可以让我们更容易地完成这项任务。依赖注入是这些技术中的一种,用于编写松散耦合但高度内聚的代码。
在本文中,我们将向您展示如何在开发绘制历史天气数据的应用程序时实现依赖注入。使用测试驱动开发开发初始应用程序后,您将使用依赖注入来分解应用程序的各个部分,使其更易于测试、扩展和维护。
到本文结束时,您应该能够解释什么是依赖注入,并使用测试驱动开发(TDD)在 Python 中实现它。
什么是依赖注入?
在软件工程中,依赖注入是一种一个对象接收它所依赖的其他对象的技术。
- 它被引入来管理一个人的代码库的复杂性。
- 它有助于简化测试、扩展代码和维护。
- 大多数允许将对象和函数作为参数传递的语言都支持它。不过,在 Java 和 C#中,你会听到更多关于依赖注入的内容,因为它很难实现。另一方面,由于 Python 的动态类型化和它的鸭子类型化系统,它很容易实现,因此不太引人注意。Django、Django REST 框架和 FastAPI 都利用了依赖注入。
好处:
- 方法更容易测试
- 依赖性更容易被模仿
- 每当我们扩展应用程序时,测试不必改变
- 扩展应用程序更容易
- 维护应用程序更容易
更多内容,请参考马丁·福勒的 T2 形式的依赖注入文章。
要了解它的实际应用,让我们看几个真实的例子。
绘制历史天气数据
场景:
- 您已经决定构建一个应用程序,用于根据天气历史数据绘制图表。
- 你已经下载了伦敦 2009 年每小时的温度数据。
- 你的目标是绘制数据图,看看温度是如何随时间变化的。
基本思想
首先,创建(并激活)一个虚拟环境。然后,安装 pytest 和 Matplotlib :
`(venv)$ pip install pytest matplotlib`
用两种方法开始一个类似乎是合理的:
read
-从 CSV 读取数据draw
-画一个情节
从 CSV 读取数据
因为我们需要从 CSV 文件中读取历史天气数据,所以read
方法应该满足以下标准:
- 给定一个
App
类 - 当用 CSV 文件名调用
read
方法时 - 然后来自 CSV 的数据应该返回到一个字典中,其中键是 ISO 8601 格式的日期时间字符串(
'%Y-%m-%dT%H:%M:%S.%f'
),而值是当时测量的温度
创建一个名为 test_app.py 的文件:
`import datetime
from pathlib import Path
from app import App
BASE_DIR = Path(__file__).resolve(strict=True).parent
def test_read():
app = App()
for key, value in app.read(file_name=Path(BASE_DIR).joinpath('london.csv')).items():
assert datetime.datetime.fromisoformat(key)
assert value - 0 == value`
因此,该测试检查:
- 每个键都是一个 ISO 8601 格式的日期时间字符串(使用
datetime
包中的fromisoformat
函数) - 每个值都是数字(使用数字的属性
x - 0 = x
)
Python 3.7 中添加了来自
datetime
包的fromisoformat
方法。参考官方 Python 文档了解更多信息。
运行测试以确保失败:
`(venv)$ python -m pytest .`
您应该看到:
`E ModuleNotFoundError: No module named 'app'`
现在要实现read
方法,为了让测试通过,添加名为 app.py 的新文件:
`import csv
import datetime
from pathlib import Path
BASE_DIR = Path(__file__).resolve(strict=True).parent
class App:
def read(self, file_name):
temperatures_by_hour = {}
with open(Path(BASE_DIR).joinpath(file_name), 'r') as file:
reader = csv.reader(file)
next(reader) # Skip header row.
for row in reader:
hour = datetime.datetime.strptime(row[0], '%d/%m/%Y %H:%M').isoformat()
temperature = float(row[2])
temperatures_by_hour[hour] = temperature
return temperatures_by_hour`
这里,我们添加了一个带有read
方法的App
类,该方法将文件名作为参数。在打开和读取 CSV 的内容之后,适当的键(日期)和值(温度)被添加到最终返回的字典中。
假设您已经下载了作为 london.csv 的天气数据,测试现在应该通过了:
`(venv)$ python -m pytest .
================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael.herman/repos/testdriven/dependency-injection-python/app
collected 1 item
test_app.py . [100%]
================================= 1 passed in 0.11s =====================================`
绘制情节
接下来,draw
方法应满足以下标准:
- 给定一个
App
类 - 当使用字典调用
draw
方法时,其中键是 ISO 8601 格式的日期时间字符串('%Y-%m-%dT%H:%M:%S.%f'
),而值是当时测量的温度 - 然后将数据绘制成线图,X 轴表示时间,Y 轴表示温度
为此向 test_app.py 添加一个测试:
`def test_draw(monkeypatch):
plot_date_mock = MagicMock()
show_mock = MagicMock()
monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)
app = App()
hour = datetime.datetime.now().isoformat()
temperature = 14.52
app.draw({hour: temperature})
_, called_temperatures = plot_date_mock.call_args[0]
assert called_temperatures == [temperature] # check that plot_date was called with temperatures as second arg
show_mock.assert_called() # check that show is called`
像这样更新导入:
`import datetime
from pathlib import Path
from unittest.mock import MagicMock
import matplotlib.pyplot
from app import App`
由于我们不想在测试运行期间显示实际的图表,我们使用了monkeypatch
来模拟matplotlib
中的plot_date
函数。然后,用单一温度调用待测方法。最后,我们检查了plot_date
是否被正确调用(X 和 Y 轴)以及show
是否被调用。
你可以在这里阅读更多关于用 pytest 打猴子补丁的内容,在这里阅读更多关于嘲笑的内容。
让我们转到方法实现:
- 它接受一个参数
temperatures_by_hour
,该参数应该是与read
方法的输出具有相同结构的字典。 - 它必须将这个字典转换成可以在绘图中使用的两个向量:日期和温度。
- 应该使用
matplotlib.dates.date2num
将日期转换成数字,以便在绘图中使用。
`def draw(self, temperatures_by_hour):
dates = []
temperatures = []
for date, temperature in temperatures_by_hour.items():
dates.append(datetime.datetime.fromisoformat(date))
temperatures.append(temperature)
dates = matplotlib.dates.date2num(dates)
matplotlib.pyplot.plot_date(dates, temperatures, linestyle='-')
matplotlib.pyplot.show()`
进口:
`import csv
import datetime
from pathlib import Path
import matplotlib.dates
import matplotlib.pyplot`
测试现在应该通过了:
`(venv)$ python -m pytest .
================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items
test_app.py .. [100%]
================================= 2 passed in 0.37s =====================================`
app.py :
`import csv
import datetime
from pathlib import Path
import matplotlib.dates
import matplotlib.pyplot
BASE_DIR = Path(__file__).resolve(strict=True).parent
class App:
def read(self, file_name):
temperatures_by_hour = {}
with open(Path(BASE_DIR).joinpath(file_name), 'r') as file:
reader = csv.reader(file)
next(reader) # Skip header row.
for row in reader:
hour = datetime.datetime.strptime(row[0], '%d/%m/%Y %H:%M').isoformat()
temperature = float(row[2])
temperatures_by_hour[hour] = temperature
return temperatures_by_hour
def draw(self, temperatures_by_hour):
dates = []
temperatures = []
for date, temperature in temperatures_by_hour.items():
dates.append(datetime.datetime.fromisoformat(date))
temperatures.append(temperature)
dates = matplotlib.dates.date2num(dates)
matplotlib.pyplot.plot_date(dates, temperatures, linestyle='-')
matplotlib.pyplot.show()`
test_app.py :
`import datetime
from pathlib import Path
from unittest.mock import MagicMock
import matplotlib.pyplot
from app import App
BASE_DIR = Path(__file__).resolve(strict=True).parent
def test_read():
app = App()
for key, value in app.read(file_name=Path(BASE_DIR).joinpath('london.csv')).items():
assert datetime.datetime.fromisoformat(key)
assert value - 0 == value
def test_draw(monkeypatch):
plot_date_mock = MagicMock()
show_mock = MagicMock()
monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)
app = App()
hour = datetime.datetime.now().isoformat()
temperature = 14.52
app.draw({hour: temperature})
_, called_temperatures = plot_date_mock.call_args[0]
assert called_temperatures == [temperature] # check that plot_date was called with temperatures as second arg
show_mock.assert_called() # check that show is called`
运行应用程序
您已经具备了运行应用程序所需的一切,可以从选定的 CSV 文件中按小时绘制温度图。
让我们使我们的应用程序可以运行。
打开 app.py 并在底部添加以下代码片段:
`if __name__ == '__main__':
import sys
file_name = sys.argv[1]
app = App()
temperatures_by_hour = app.read(file_name)
app.draw(temperatures_by_hour)`
当 app.py 运行时,它首先从分配给file_name
的命令行参数中读取 CSV 文件,然后绘制图形。
运行应用程序:
`(venv)$ python app.py london.csv`
你应该会看到这样一个情节:
如果遇到
Matplotlib is currently using agg, which is a non-GUI backend, so cannot show the figure.
,查这个栈溢出答案。
解耦数据源
好吧。我们完成了应用程序绘制历史天气数据的初始迭代。它像预期的那样工作,我们很高兴使用它。也就是说,它与 CSV 紧密相关。如果您想使用不同的数据格式呢?比如来自 API 的 JSON 负载。这就是依赖注入发挥作用的地方。
让我们把阅读部分从我们的主 app 中分离出来。
首先,创建名为test _ urban _ climate _ CSV . py的新文件:
`import datetime
from pathlib import Path
from app import App
from urban_climate_csv import DataSource
BASE_DIR = Path(__file__).resolve(strict=True).parent
def test_read():
app = App()
for key, value in app.read(file_name=Path(BASE_DIR).joinpath('london.csv')).items():
assert datetime.datetime.fromisoformat(key)
assert value - 0 == value`
这里的测试与我们在 test_app.py 中对test_read
的测试相同。
其次,添加一个名为 urban_climate_csv.py 的新文件。在该文件中,用一个read
方法创建一个名为DataSource
的类:
`import csv
import datetime
from pathlib import Path
BASE_DIR = Path(__file__).resolve(strict=True).parent
class DataSource:
def read(self, **kwargs):
temperatures_by_hour = {}
with open(Path(BASE_DIR).joinpath(kwargs['file_name']), 'r') as file:
reader = csv.reader(file)
next(reader) # Skip header row.
for row in reader:
hour = datetime.datetime.strptime(row[0], '%d/%m/%Y %H:%M').isoformat()
temperature = float(row[2])
temperatures_by_hour[hour] = temperature
return temperatures_by_hour`
这与我们最初的应用程序中的read
方法相同,但有一点不同:我们使用kwargs
是因为我们希望所有的数据源都有相同的接口。因此,我们可以根据数据源添加新的阅读器。
例如:
`from open_weather_csv import DataSource
from open_weather_json import DataSource
from open_weather_api import DataSource
csv_reader = DataSource()
reader.read(file_name='foo.csv')
json_reader = DataSource()
reader.read(file_name='foo.json')
api_reader = DataSource()
reader.read(url='https://foo.bar')`
测试现在应该通过了:
`(venv)$ python -m pytest .
================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items
test_app.py .. [ 66%]
test_urban_climate_csv.py . [100%]
================================= 3 passed in 0.48s =====================================`
现在,我们需要更新我们的App
类。
首先,更新 test_app.py 中read
的测试:
`def test_read():
hour = datetime.datetime.now().isoformat()
temperature = 14.52
temperature_by_hour = {hour: temperature}
data_source = MagicMock()
data_source.read.return_value = temperature_by_hour
app = App(
data_source=data_source
)
assert app.read(file_name='something.csv') == temperature_by_hour`
那么是什么改变了呢?我们将data_source
注入到我们的App
中。这简化了测试,因为read
方法只有一个任务:从数据源返回结果。这是依赖注入的第一个好处的例子:测试更容易,因为我们可以注入底层的依赖。
也更新对draw
的测试。同样,我们需要将数据源注入到App
中,它可以是具有预期接口的“任何东西”——所以 MagicMock 会做:
`def test_draw(monkeypatch):
plot_date_mock = MagicMock()
show_mock = MagicMock()
monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)
app = App(MagicMock())
hour = datetime.datetime.now().isoformat()
temperature = 14.52
app.draw({hour: temperature})
_, called_temperatures = plot_date_mock.call_args[0]
assert called_temperatures == [temperature] # check that plot_date was called with temperatures as second arg
show_mock.assert_called() # check that show is called`
同样更新App
类:
`import datetime
import matplotlib.dates
import matplotlib.pyplot
class App:
def __init__(self, data_source):
self.data_source = data_source
def read(self, **kwargs):
return self.data_source.read(**kwargs)
def draw(self, temperatures_by_hour):
dates = []
temperatures = []
for date, temperature in temperatures_by_hour.items():
dates.append(datetime.datetime.fromisoformat(date))
temperatures.append(temperature)
dates = matplotlib.dates.date2num(dates)
matplotlib.pyplot.plot_date(dates, temperatures, linestyle='-')
matplotlib.pyplot.show(block=True)`
首先,我们添加了一个__init__
方法,以便可以注入数据源。其次,我们更新了read
方法以使用self.data_source
和**kwargs
。看看这个界面简单了多少。App
不再伴随着数据的读取了。
最后,我们需要在实例创建时将我们的数据源注入到App
中。
`if __name__ == '__main__':
import sys
from urban_climate_csv import DataSource
file_name = sys.argv[1]
app = App(DataSource())
temperatures_by_hour = app.read(file_name=file_name)
app.draw(temperatures_by_hour)`
再次运行你的应用程序,以确保它仍按预期运行:
`(venv)$ python app.py london.csv`
更新test _ urban _ climate _ CSV . py中的test_read
:
`import datetime
from urban_climate_csv import DataSource
def test_read():
reader = DataSource()
for key, value in reader.read(file_name='london.csv').items():
assert datetime.datetime.fromisoformat(key)
assert value - 0 == value`
测试通过了吗?
`(venv)$ python -m pytest .
================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items
test_app.py .. [ 66%]
test_urban_climate_csv.py . [100%]
================================= 3 passed in 0.40s =====================================`
添加新的数据源
既然我们已经将App
从数据源中分离出来,我们可以很容易地添加一个新的数据源。
让我们使用来自 OpenWeather API 的数据。继续从 API 下载预先下载的响应:这里。另存为 moscow.json 。
如果你愿意,可以随意注册 OpenWeather API 并获取不同城市的历史数据。
添加一个名为test _ open _ weather _ JSON . py的新文件,并为一个read
方法编写一个测试:
`import datetime
from open_weather_json import DataSource
def test_read():
reader = DataSource()
for key, value in reader.read(file_name='moscow.json').items():
assert datetime.datetime.fromisoformat(key)
assert value - 0 == value`
由于我们使用相同的接口来应用依赖注入,这个测试看起来应该非常类似于 test_urban_climate_csv 中的test_read
。
在静态类型的语言中,像 Java 和 C#,所有的数据源都应该实现相同的接口——即IDataSource
。多亏了 Python 中的 duck typing ,我们可以实现同名的方法,这些方法为我们的每个数据源采用相同的参数(**kwargs
):
`def read(self, **kwargs):
return self.data_source.read(**kwargs)`
接下来,让我们继续实施。
添加名为 open_weather_json.py 的新文件。:
`import json
import datetime
class DataSource:
def read(self, **kwargs):
temperatures_by_hour = {}
with open(kwargs['file_name'], 'r') as file:
json_data = json.load(file)['hourly']
for row in json_data:
hour = datetime.datetime.fromtimestamp(row['dt']).isoformat()
temperature = float(row['temp'])
temperatures_by_hour[hour] = temperature
return temperatures_by_hour`
所以,我们使用了json
模块来读取和加载一个 JSON 文件。然后,我们以与之前类似的方式提取数据。这次我们使用了fromtimestamp
函数,因为测量时间是以 Unix 时间戳格式编写的。
测试应该会通过。
接下来,更新 app.py 来使用这个数据源:
`if __name__ == '__main__':
import sys
from open_weather_json import DataSource
file_name = sys.argv[1]
app = App(DataSource())
temperatures_by_hour = app.read(file_name=file_name)
app.draw(temperatures_by_hour)`
在这里,我们只是更改了导入。
使用 moscow.json 作为参数再次运行您的应用程序:
`(venv)$ python app.py moscow.json`
您应该会看到一个包含来自所选 JSON 文件的数据的图。
这是依赖注入的第二个好处的例子:扩展代码要简单得多。
我们可以看到:
- 现有的测试没有改变
- 为新数据源编写测试很简单
- 为新数据源实现一个接口也相当简单(您只需要知道数据的形状)
- 我们不需要对
App
类做任何修改
因此,我们现在可以用简单且可预测的步骤来扩展代码库,而不必接触已经编写好的测试或更改主应用程序。那是强大的。现在,您可以让开发人员专注于添加新的数据源,而无需了解主应用程序的上下文。也就是说,如果你需要一个新的开发人员,他需要了解整个项目的背景,由于分离,他们可能需要更长的时间来适应。
分离绘图库
接下来,让我们将绘图部分从应用程序中分离出来,这样我们可以更容易地添加新的绘图库。因为这将是一个类似于数据源解耦的过程,所以在阅读本节的其余部分之前,请自己考虑一下这些步骤。
看看 test_app.py 中的draw
方法的测试:
`def test_draw(monkeypatch):
plot_date_mock = MagicMock()
show_mock = MagicMock()
monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)
app = App(MagicMock())
hour = datetime.datetime.now().isoformat()
temperature = 14.52
app.draw({hour: temperature})
_, called_temperatures = plot_date_mock.call_args[0]
assert called_temperatures == [temperature] # check that plot_date was called with temperatures as second arg
show_mock.assert_called() # check that show is called`
正如我们所见,它与 Matplotlib 相结合。对绘图库的更改将需要对测试进行更改。这是你真正想要避免的事情。
那么,我们该如何改善这一点呢?
让我们将应用程序的绘图部分提取到它自己的类中,就像我们读取数据源一样。
添加一个名为 test_matplotlib_plot.py 的新文件:
`import datetime
from unittest.mock import MagicMock
import matplotlib.pyplot
from matplotlib_plot import Plot
def test_draw(monkeypatch):
plot_date_mock = MagicMock()
show_mock = MagicMock()
monkeypatch.setattr(matplotlib.pyplot, 'plot_date', plot_date_mock)
monkeypatch.setattr(matplotlib.pyplot, 'show', show_mock)
plot = Plot()
hours = [datetime.datetime.now()]
temperatures = [14.52]
plot.draw(hours, temperatures)
_, called_temperatures = plot_date_mock.call_args[0]
assert called_temperatures == temperatures # check that plot_date was called with temperatures as second arg
show_mock.assert_called() # check that show is called`
为了实现Plot
类,添加一个名为 matplotlib_plot.py 的新文件:
`import matplotlib.dates
import matplotlib.pyplot
class Plot:
def draw(self, hours, temperatures):
hours = matplotlib.dates.date2num(hours)
matplotlib.pyplot.plot_date(hours, temperatures, linestyle='-')
matplotlib.pyplot.show(block=True)`
这里,draw
方法有两个参数:
hours
-日期时间对象的列表temperatures
-一列数字
这是我们未来所有Plot
类的界面外观。因此,在这种情况下,只要这个接口和底层的matplotlib
方法保持不变,我们的测试就会保持不变。
运行测试:
`(venv)$ python -m pytest .
================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items
test_app.py .. [ 40%]
test_matplotlib_plot.py . [ 60%]
test_open_weather_json.py . [ 80%]
test_urban_climate_csv.py . [100%]
================================= 5 passed in 0.38s =====================================`
接下来,我们来更新一下App
类。
首先,像这样更新 test_app.py :
`import datetime
from unittest.mock import MagicMock
from app import App
def test_read():
hour = datetime.datetime.now().isoformat()
temperature = 14.52
temperature_by_hour = {hour: temperature}
data_source = MagicMock()
data_source.read.return_value = temperature_by_hour
app = App(
data_source=data_source,
plot=MagicMock()
)
assert app.read(file_name='something.csv') == temperature_by_hour
def test_draw():
plot_mock = MagicMock()
app = App(
data_source=MagicMock,
plot=plot_mock
)
hour = datetime.datetime.now()
iso_hour = hour.isoformat()
temperature = 14.52
temperature_by_hour = {iso_hour: temperature}
app.draw(temperature_by_hour)
plot_mock.draw.assert_called_with([hour], [temperature])`
由于test_draw
不再与 Matplotlib 耦合,我们在调用draw
方法之前将 plot 注入到App
。只要注入的Plot
接口符合预期,测试就应该通过。因此,我们可以在测试中使用MagicMock
。然后我们检查了是否如预期的那样调用了draw
方法。我们还把剧情注入了test_read
。仅此而已。
更新App
类:
`import datetime
class App:
def __init__(self, data_source, plot):
self.data_source = data_source
self.plot = plot
def read(self, **kwargs):
return self.data_source.read(**kwargs)
def draw(self, temperatures_by_hour):
dates = []
temperatures = []
for date, temperature in temperatures_by_hour.items():
dates.append(datetime.datetime.fromisoformat(date))
temperatures.append(temperature)
self.plot.draw(dates, temperatures)`
重构后的draw
方法现在简单多了。它只是:
- 将字典转换成两个列表
- 将 ISO 日期字符串转换为 datetime 对象
- 调用
Plot
实例的draw
方法
测试:
`(venv)$ python -m pytest .
================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 2 items
test_app.py .. [ 40%]
test_matplotlib_plot.py . [ 60%]
test_open_weather_json.py . [ 80%]
test_urban_climate_csv.py . [100%]
================================= 5 passed in 0.39s =====================================`
更新再次运行应用程序的代码片段:
`if __name__ == '__main__':
import sys
from open_weather_json import DataSource
from matplotlib_plot import Plot
file_name = sys.argv[1]
app = App(DataSource(), Plot())
temperatures_by_hour = app.read(file_name=file_name)
app.draw(temperatures_by_hour)`
我们为Plot
添加了一个新的导入,并将其注入到App
中。
再次运行你的应用程序,查看它是否仍在工作:
`(venv)$ python app.py moscow.json`
添加 Plotly
开始安装 Plotly :
`(venv)$ pip install plotly`
接下来,向名为 test_plotly_plot.py 的新字段添加一个新测试:
`import datetime
from unittest.mock import MagicMock
import plotly.graph_objects
from plotly_plot import Plot
def test_draw(monkeypatch):
figure_mock = MagicMock()
monkeypatch.setattr(plotly.graph_objects, 'Figure', figure_mock)
scatter_mock = MagicMock()
monkeypatch.setattr(plotly.graph_objects, 'Scatter', scatter_mock)
plot = Plot()
hours = [datetime.datetime.now()]
temperatures = [14.52]
plot.draw(hours, temperatures)
call_kwargs = scatter_mock.call_args[1]
assert call_kwargs['y'] == temperatures # check that plot_date was called with temperatures as second arg
figure_mock().show.assert_called() # check that show is called`
和 matplotlib Plot
测试基本相同。主要的变化是如何模仿 Plotly 的对象和方法。
其次,添加名为 plotly_plot.py 的文件:
`import plotly.graph_objects
class Plot:
def draw(self, hours, temperatures):
fig = plotly.graph_objects.Figure(
data=[plotly.graph_objects.Scatter(x=hours, y=temperatures)]
)
fig.show()`
这里,我们用plotly
画了一个带日期的图。就是这样。
测试应该通过:
`(venv)$ python -m pytest .
================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 6 items
test_app.py .. [ 33%]
test_matplotlib_plot.py . [ 50%]
test_open_weather_json.py . [ 66%]
test_plotly_plot.py . [ 83%]
test_urban_climate_csv.py . [100%]
================================= 6 passed in 0.46s =====================================`
更新运行片段以使用plotly
:
`if __name__ == '__main__':
import sys
from open_weather_json import DataSource
from plotly_plot import Plot
file_name = sys.argv[1]
app = App(DataSource(), Plot())
temperatures_by_hour = app.read(file_name=file_name)
app.draw(temperatures_by_hour)`
使用 moscow.json 运行您的应用程序,在浏览器中查看新的地块:
`(venv)$ python app.py moscow.json`
添加配置
此时,我们可以轻松地在应用程序中添加和使用不同的数据源和绘图库。我们的测试不再与实现相结合。也就是说,我们仍然需要对代码进行编辑,以添加新的数据源或绘图库:
`if __name__ == '__main__':
import sys
from open_weather_json import DataSource
from plotly_plot import Plot
file_name = sys.argv[1]
app = App(DataSource(), Plot())
temperatures_by_hour = app.read(file_name=file_name)
app.draw(temperatures_by_hour)`
尽管它只是一小段代码,但我们可以将依赖注入向前推进一步,不再需要修改代码。相反,我们将使用一个配置文件来选择数据源和绘图库。
我们将使用一个简单的 JSON 对象来配置应用程序:
`{ "data_source": { "name": "urban_climate_csv" }, "plot": { "name": "plotly_plot" } }`
将它添加到一个名为 config.json 的新文件中。
向 test_app.py 添加新的测试:
`def test_configure():
app = App.configure(
'config.json'
)
assert isinstance(app, App)`
这里,我们检查了从configure
方法返回的App
的实例。该方法将读取配置文件并加载选中的DataSource
和Plot
。
将configure
添加到App
类:
`import datetime
import json
class App:
...
@classmethod
def configure(cls, filename):
with open(filename) as file:
config = json.load(file)
data_source = __import__(config['data_source']['name']).DataSource()
plot = __import__(config['plot']['name']).Plot()
return cls(data_source, plot)
if __name__ == '__main__':
import sys
from open_weather_json import DataSource
from plotly_plot import Plot
file_name = sys.argv[1]
app = App(DataSource(), Plot())
temperatures_by_hour = app.read(file_name=file_name)
app.draw(temperatures_by_hour)`
因此,在加载 JSON 文件之后,我们从配置文件中定义的相应模块中导入了DataSource
和Plot
。
__import__
用于动态导入模块。例如,将config['data_source']['name']
设置为urban_climate_csv
相当于:
`import urban_climate_csv
data_source = urban_climate_csv.DataSource()`
运行测试:
`(venv)$ python -m pytest .
================================ test session starts ====================================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/python-dependency-injection
collected 6 items
test_app.py ... [ 42%]
test_matplotlib_plot.py . [ 57%]
test_open_weather_json.py . [ 71%]
test_plotly_plot.py . [ 85%]
test_urban_climate_csv.py . [100%]
================================= 6 passed in 0.46s =====================================`
最后,更新 app.py 中的代码片段以使用新添加的方法:
`if __name__ == '__main__':
import sys
config_file = sys.argv[1]
file_name = sys.argv[2]
app = App.configure(config_file)
temperatures_by_hour = app.read(file_name=file_name)
app.draw(temperatures_by_hour)`
消除导入后,您可以快速地将一个数据源或绘图库替换为另一个。
再次运行您的应用程序:
`(venv)$ python app.py config.json london.csv`
更新配置以使用open_weather_json
作为数据源:
`{
"data_source": {
"name": "open_weather_json"
},
"plot": {
"name": "plotly_plot"
}
}`
运行应用程序:
`(venv)$ python app.py config.json moscow.json`
不同的观点
主App
类最初是一个无所不知的对象,负责从 CSV 读取数据并绘制图形。我们使用依赖注入来分离读取和绘制功能。App
类现在是一个容器,它有一个简单的接口来连接读取和绘制部分。实际的读取和绘制逻辑在专门的类中处理,这些类只负责一件事。
好处:
- 方法更容易测试
- 依赖性更容易被模仿
- 每当我们扩展应用程序时,测试不必改变
- 扩展应用程序更容易
- 维护应用程序更容易
我们做了什么特别的事吗?不完全是。除了软件工程之外,依赖注入背后的思想在工程界非常普遍。
例如,建造房屋外墙的木匠通常会为窗户和门留出空槽,以便专门从事窗户和门安装的人可以安装它们。当房子完工,业主搬进来的时候,他们需要拆掉房子的一半,仅仅是为了改变一个现有的窗户吗?不。他们可以修理坏了的窗户。只要窗口具有相同的界面(例如,宽度、高度、深度等)。),他们可以安装和使用它们。他们能在安装之前打开窗户吗?当然了。他们能在安装之前测试窗户是否被打破吗?是的。这也是依赖注入的一种形式。
在软件工程中看到和使用依赖注入可能不太自然,但它和其他工程专业一样有效。
后续步骤
想要更多吗?
- 扩展应用程序以获取名为
open_weather_api
的新数据源。这个源获取一个城市,进行 API 调用,然后以正确的形式为draw
方法返回数据。 - 添加散景进行绘图。
结论
本文展示了如何在真实的应用程序中实现依赖注入。
尽管依赖注入是一种强大的技术,但它并不是银弹。再想想房子的类比:房子的外壳和门窗是松散耦合的。帐篷也是如此吗?不。如果帐篷的门损坏得无法修复,你可能会想买一个新帐篷,而不是试图修复损坏的门。因此,你不能将依赖注入分离并应用到所有的事情上。事实上,如果做得太早,它会把你拖入过早优化的地狱。虽然它更容易维护,但是对于项目的新手来说,它有更多的表面区域,解耦的代码可能更难理解。
所以在你跳进去之前,问问你自己:
- 我的代码是“帐篷”还是“房子”?
- 在这个特定领域使用依赖注入的好处(和坏处)是什么?
- 我该如何向一个项目新人解释?
如果你能轻松回答这些问题,而且利大于弊,那就去做吧。否则目前可能不适合使用。
编码快乐!
现代 Python 环境——依赖性和工作空间管理
一旦您经历了为单个“hello world”风格的应用程序设置 Python 环境的痛苦,您将需要经历一个更加困难的过程,弄清楚如何为多个 Python 项目管理多个环境。一些项目可能是新的,而另一些则是十年前的陈旧代码。幸运的是,有许多工具可以帮助简化依赖性和工作空间管理。
在本文中,我们将回顾用于依赖性和工作空间管理的可用工具,以便解决以下问题:
- 在同一台机器上安装和切换不同版本的 Python
- 管理依赖关系和虚拟环境
- 复制环境
完整 Python 指南:
安装 Python
虽然您可以从官方的二进制文件或通过系统的包管理器下载并安装 Python,但是您应该避开这些方法,除非您足够幸运地在当前和未来的项目中使用相同版本的 Python。由于情况可能并非如此,我们建议用 pyenv 安装 Python。
pyenv 是一个工具,它简化了在同一台机器上不同版本 Python 之间的安装和切换。它保持 Python 的系统版本不变,这是一些操作系统正常运行所必需的,同时仍然可以根据特定项目的需求轻松切换 Python 版本。
不幸的是,pyenv 不能在 Linux 的 Windows 子系统之外的 Windows 上工作。如果你是这种情况,请查看 pyenv-win 。
一旦安装了,你就可以轻松地安装 Python 的特定版本,如下所示:
`$ pyenv install 3.8.5
$ pyenv install 3.8.6
$ pyenv install 3.9.0
$ pyenv install 3.10.2
$ pyenv versions
* system
3.8.5
3.8.6
3.9.0
3.10.2`
然后,您可以像这样设置您的全局 Python 版本:
`$ pyenv global 3.8.6
$ pyenv versions
system
3.8.5
* 3.8.6 (set by /Users/michael/.pyenv/version)
3.9.0
3.10.2
$ python -V
Python 3.8.6`
请记住,这不会在系统级修改或影响 Python。
以类似的方式,您可以为当前文件夹设置 Python 解释器:
`$ pyenv local 3.10.2
$ pyenv versions
system
3.8.5
3.8.6
3.9.0
* 3.10.2 (set by /Users/michael/repos/testdriven/python-environments/.python-version)
$ python -V
Python 3.10.2`
现在,每次在该文件夹中运行 Python 时,都将使用版本 3.10.2。
如果您发现您不能在不同版本的 Python 之间切换,您可能需要重新配置您的 shell 配置文件。详见安装指南。
管理依赖关系
在本节中,我们将了解几个用于管理依赖关系和虚拟环境的工具。
venv + pip
venv 和pip(ppackageIinstaller forpython),它们预装在 Python 的大多数版本中,分别是最流行的管理虚拟环境和包的工具。它们使用起来相当简单。
虚拟环境可以防止依赖性版本冲突。您可以在不同的虚拟环境中安装同一依赖项的不同版本。
您可以在当前文件夹中创建一个名为 my_venv 的新虚拟环境,如下所示:
创建好环境后,您仍然需要通过在虚拟环境中获取 activate 脚本来激活它:
`$ source my_venv/bin/activate
(my_venv)$`
停用运行
deactivate
。然后,为了重新激活,在根项目目录中运行source my_venv/bin/activate
。
在虚拟环境激活时运行which python
将返回虚拟环境内 Python 解释器的路径:
`(my_venv)$ which python
/Users/michael/repos/testdriven/python-environments/my_venv/bin/python`
您可以通过在激活虚拟环境的情况下运行pip install <package-name>
来安装项目的本地包:
`(my_venv)$ python -m pip install requests`
pip 从 PyPI (Python 包索引)下载包,然后在虚拟环境中提供给 Python 解释器。
为了环境的可再现性,您通常希望在一个 requirements.txt 文件中保存一个项目所需包的列表。您可以手动创建文件并添加它们,或者使用 pip 冻结命令来生成它:
`(my_venv)$ python -m pip freeze > requirements.txt
(my_venv)$ cat requirements.txt
certifi==2021.10.8
charset-normalizer==2.0.12
idna==3.3
requests==2.27.1
urllib3==1.26.8`
想要只获取顶级依赖项(例如,
requests==2.27.1
)?检查一下 pip-chill 。
虽然 venv 和 pip 都很容易使用,但与更现代的工具如poem和 Pipenv 相比,它们还是非常原始的。venv 和 pip 对它使用的 Python 版本一无所知。您必须手动管理所有依赖关系和虚拟环境。你必须自己创建和管理 requirements.txt 文件。更重要的是,你必须手工分离开发(pytest,black,isort,...)和生产(Flask,Django,FastAPI,..)依赖关系,使用一个 requirements-dev.txt 文件。
需求-开发文本:
`# prod
-r requirements.txt
# dev
black==22.1.0
coverage==6.3.2
flake8==4.0.1
ipython==8.0.1
isort==5.10.1
pytest-django==4.5.2
pytest-cov==3.0.0
pytest-xdist==2.5.0
pytest-mock==3.7.0`
需求. txt :
`Django==4.0.2
django-allauth==0.49.0
django-crispy-forms==1.14.0
django-rq==2.5.1
django-rq-email-backend==0.1.3
gunicorn==20.1.0
psycopg2-binary==2.9.3
redis==4.1.4
requests==2.27.1
rq==1.10.1
whitenoise==6.0.0`
诗歌和 Pipenv 结合了 venv 和 pip 的功能。它们还使分离开发和生产依赖变得容易,并通过锁文件实现确定性构建。他们与 pyenv 合作得很好。
锁定文件锁定(或锁定)整个依赖关系树中的所有依赖关系版本。
诗意
诗歌可以说是 Python 中功能最丰富的依赖管理工具。它附带了一个用于创建和管理 Python 项目的强大的 CLI。一旦安装了,脚手架上一个新的项目开始运行:
`$ poetry new sample-project
$ cd sample-project`
这将创建以下文件和文件夹:
`sample-project
├── README.rst
├── pyproject.toml
├── sample_project
│ └── __init__.py
└── tests
├── __init__.py
└── test_sample_project.py`
依赖项在 pyproject.toml 文件中进行管理:
`[tool.poetry] name = "sample-project" version = "0.1.0" description = "" authors = ["John Doe <[[email protected]](/cdn-cgi/l/email-protection)>"] [tool.poetry.dependencies] python = "^3.10" [tool.poetry.dev-dependencies] pytest = "^5.2" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api"`
更多关于 pyproject.toml 的信息,新的 Python 包配置文件将“每个项目都视为一个包”,查看py project . toml 到底是什么?文章。
要添加新的依赖项,只需运行:
`$ poetry add [--dev] <package name>`
--dev
标志表示该依赖关系仅用于开发模式。默认情况下,不会安装开发依赖项。
例如:
这将从 PyPI 下载并安装 Flask 到 poem 管理的虚拟环境中,将它和所有子依赖项一起添加到poem . lock文件中,并自动将它(一个顶级依赖项)添加到 pyproject.toml :
`[tool.poetry.dependencies] python = "^3.10" Flask = "^2.0.3"`
注意版本约束 : "^2.0.3"
。
要在虚拟环境中运行一个命令,在命令前加上前缀poem run。例如,使用pytest
运行测试:
`$ poetry run python -m pytest`
poetry run <command>
将在虚拟环境内运行命令。不过,它不会激活虚拟环境。要激活诗歌的虚拟环境,你需要运行poetry shell
。要停用它,你可以简单地运行exit
命令。因此,您可以在进行项目之前激活您的虚拟环境,并在完成后停用,或者您可以在整个开发过程中使用poetry run <command>
。
最后,诗歌与 pyenv 配合得很好。查看官方文档中的管理环境,了解更多相关信息。
Pipenv
Pipenv 试图解决与诗歌相同的问题:
- 管理依赖关系和虚拟环境
- 复制环境
一旦安装了,要用 Pipenv 创建一个新项目,运行:
`$ mkdir sample-project
$ cd sample-project
$ pipenv --python 3.10.2`
这将创建一个新的虚拟环境,并将一个 Pipfile 添加到项目中:
`[[source]] name = "pypi" url = "https://pypi.org/simple" verify_ssl = true [dev-packages] [packages] [requires] python_version = "3.10"`
一个 Pipfile 的工作方式很像诗歌世界中的 pyproject.toml 文件。
您可以像这样安装一个新的依赖项:
`$ pipenv install [--dev] <package name>`
--dev
标志表示该依赖关系仅用于开发模式。默认情况下,不会安装开发依赖项。
例如:
与诗歌一样,Pipenv 在虚拟环境中下载并安装 Flask,将所有子依赖项固定在 Pipfile.lock 文件中,并将顶级依赖项添加到 Pipfile 中。
要在 Pipenv 管理的虚拟环境中运行脚本,您需要使用 pipenv run 命令来运行它。例如,要用pytest
运行测试,运行:
`$ pipenv run python -m pytest`
像诗歌一样,pipenv run <command>
将从虚拟环境内部运行命令。要激活 Pipenv 的虚拟环境,您需要运行pipenv shell
。要停用它,你可以运行exit
。
Pipenv 与 pyenv 配合也很好。例如,当您想从尚未安装的 Python 版本创建一个虚拟环境时,它会询问您是否想先用 pyenv 安装它:
`$ pipenv --python 3.7.5
Warning: Python 3.7.5 was not found on your system…
Would you like us to install CPython 3.7.5 with Pyenv? [Y/n]: Y`
推荐
我应该使用哪个?
- venv 和 pip
- 诗意
- Pipenv
建议从 venv 和 pip 开始。他们是最容易共事的。熟悉他们,自己想清楚他们擅长什么,欠缺什么。
诗歌还是 Pipenv?
因为他们都解决相同的问题,这归结为个人偏好。
注意事项:
- 用诗歌发布到 PyPI 要容易得多,所以如果你正在创建一个 Python 包,就用诗歌吧。
- 这两个项目在依赖关系解析方面都相当慢,所以如果你正在使用 Docker,你可能想避开它们。
- 从开源开发的角度来看,诗歌的速度更快,可以说对用户的反馈更敏感。
除了上述工具之外,还可以查看以下内容,以帮助您在同一台计算机上安装不同版本的 Python 并在不同版本之间进行切换,管理依赖关系和虚拟环境,以及再现环境:
- Docker 是一个构建、部署和管理容器化应用的平台。它非常适合创造可复制的环境。
- 在数据科学和机器学习社区中非常受欢迎的 Conda ,可以帮助管理依赖性和虚拟环境,以及复制环境。
- 当你只需要简化虚拟环境之间的切换并在一个地方管理它们时, virtualenvwrapper 和 pyenv 插件 pyenv-virtualenv 值得一看。
- pip-tools 简化了依赖性管理和环境再现性。它经常与 venv 结合。
Python 版本 | 依赖性管理 | 虚拟环境 | 环境再现性 | |
---|---|---|---|---|
pyenv | ✅ | ❌ | ❌ | ❌ |
venv + pip | ❌ | ✅ | ✅ | ❌ |
venv+pip-工具 | ❌ | ✅ | ✅ | ✅ |
诗意 | ❌ | ✅ | ✅ | ✅ |
Pipenv | ❌ | ✅ | ✅ | ✅ |
码头工人 | ❌ | ❌ | ❌ | ✅ |
康达 | ✅ | ✅ | ✅ | ❌ |
管理项目
让我们来看看如何使用 pyenv 和 poems 管理一个 Flask 项目。
首先,创建一个名为“flask_example”的新目录,并在其中移动:
`$ mkdir flask_example
$ cd flask_example`
其次,用 pyenv 为项目设置 Python 版本:
接下来,用诗歌初始化一个新的 Python 项目:
`$ poetry init
Package name [flask_example]:
Version [0.1.0]:
Description []:
Author [Your name <[[email protected]](/cdn-cgi/l/email-protection)>, n to skip]:
License []:
Compatible Python versions [^3.10]:
Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Do you confirm generation? (yes/no) [yes]`
添加烧瓶:
最后但同样重要的是,添加 pytest 作为开发依赖项:
`$ poetry add --dev pytest`
现在我们已经建立了一个基本的环境,我们可以为单个端点编写一个测试。
添加一个名为 test_app.py 的文件:
`import pytest
from app import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def test_health_check(client):
response = client.get('/health-check/')
assert response.status_code == 200`
之后,将一个基本的 Flask 应用程序添加到一个名为 app.py 的新文件中:
`from flask import Flask
app = Flask(__name__)
@app.route('/health-check/')
def health_check():
return 'OK'
if __name__ == '__main__':
app.run()`
现在,要运行测试,运行:
`$ poetry run python -m pytest`
您可以像这样运行开发服务器:
`$ poetry run python -m flask run`
命令在 poems 的虚拟环境中运行一个命令。
结论
本文研究了解决以下与依赖关系和工作空间管理相关的问题的最流行的工具:
- 在同一台机器上安装和切换不同版本的 Python
- 管理依赖关系和虚拟环境
- 复制环境
您在工作流程中使用的具体工具并不重要,重要的是您能够解决这些问题。挑选一些工具,让你在 Python 中轻松开发。实验。它们的存在是为了让你的日常开发工作流程变得更简单,这样你就可以尽可能地高效工作。尝试所有这些方法,并使用适合您的开发风格的方法。不做评判。
快乐编码。
完整 Python 指南:
多区域 Python 应用程序
本文着眼于如何在 Python 应用程序中启用多区域支持。
问题
假设您刚刚使用 Django 完成了基于 Python 的 web 应用程序的开发,并将其投入生产。一段时间后,一些用户报告说应用程序很慢。您可以检查日志和服务器指标,以了解资源使用情况以及其他情况——一切看起来都很好。CPU、内存或磁盘使用率没有峰值。您在本地启动您的本地开发环境,并试图重现该问题,但它工作得很好。这是怎么回事?
你忘记了一些重要但容易忽视的东西:支配我们宇宙的法则。信息从一个地方传到另一个地方只能这么快。
作为开发人员,我们的主要责任是确保信息系统正常运行。这些系统涉及数据在时间和空间中的移动,我们的工作就是管理和协调这一过程。然而,就像任何其他系统一样,有一些基本的规则来管理数据的传输,这可能会使该过程不那么即时。这是潜在问题可能出现的地方,我们的职责是处理和解决这些问题。
例如,当你在浏览器中打开这个页面时,信息是在时间和空间上传递的——这需要一些时间来加载。
请记住,我们的本地开发环境在这种情况下没有帮助我们,因为它位于我们的本地机器上,所以 A 点和 B 点彼此非常接近,因为它们之间的距离非常小。所以传递信息的时间很短。但是在生产中,我们的应用程序位于世界上某个地方的服务器上,我们的浏览器和服务器之间的距离要大得多。因此,传输信息所需的时间要长得多。
哪些工具可以帮助我们测量信息从服务器传输到浏览器所需的时间?我们如何使用这个工具来提高我们的应用程序性能?这是我们将在下一节讨论的内容。
本文假设您已经排除了冗长的过程、低效的数据库查询以及其他导致性能下降的潜在原因。换句话说,您已经确定您的用户所经历的延迟可能是由数据包从浏览器到服务器来回传输的距离所导致的延迟。这可能是由多种因素造成的,包括物理距离、网络拥塞和用于传输数据的基础设施的限制。
基准
在开始寻找解决方案之前,您应该测量当前的传输速度,即请求+响应时间,并设置一个基准来衡量:
因此,我们需要一个工具,它可以给出向服务器发送请求,然后在浏览器中接收响应所需的总时间。这就是平的概念。ping 测量从发送主机发送到目的计算机的消息回显到源计算机的往返时间。
幸运的是,对于每一个最流行的 Python web 框架- Django 、 Flask 和 FastAPI -很容易建立一个视图或路由处理程序来解决这个问题。让我们看一些例子...
姜戈
`from django.http import JsonResponse
def ping(request):
data = {
'message': request.GET.get('ECHOMSG', '')
}
return JsonResponse(data)`
瓶
`from flask import Flask, request, jsonify
@app.route('/ping')
def ping():
data = {
'message': request.args.get('ECHOMSG', '')
}
return jsonify(data)`
FastAPI
`from fastapi import Request
from fastapi.responses import JSONResponse
@app.get('/ping')
def ping(request: Request):
data = {
'message': request.query_params.get('ECHOMSG', '')
}
return JSONResponse(content=data)`
客户端示例
您还需要建立一种机制来计算请求和响应时间。如果您的客户端是浏览器,那么您可以设置一个简单的 JavaScript 函数,如下所示:
`function ping() { const startTime = new Date().getTime(); // start the timer const xhr = new XMLHttpRequest(); // create a new request xhr.open("GET", "/ping", true); // call the ping endpoint xhr.onreadystatechange = function () { if (xhr.readyState == 4) { const endTime = new Date().getTime(); // stop the timer const time = endTime - startTime; // calculate the time it took to get the response console.log(time); } } xhr.send(); }`
完整示例
如果您想看完整的 Django 实例,请查看 web 诊断 repo。按照自述文件中的说明将其复制下来并设置项目。
随着 Django 开发服务器的运行,在您选择的浏览器中访问 http://localhost:8000 。然后,点击诊断和 Ping 按钮后,您应该在浏览器中看到 Ping 时间:
记下时间,单位为毫秒(ms)。对我来说是 5 到 11 ms 之间,传输时间真的很少,因为客户端和服务器离得很近。
现在访问https://we b-Diagnostic . fly . dev并打开诊断并点击 Ping 按钮。该服务器位于迈阿密地区,因此根据您的位置,ping 时间应该更长。
例如:
`Time: 71ms Time: 76ms Time: 65ms Time: 67ms Time: 64ms Time: 75ms Time: 71ms Time: 70ms Time: 72ms Time: 75ms`
如果你有一个 VPN,你可以看到世界各地用户的响应时间。以下是西班牙某 IP 的样子:
`Time: 324ms Time: 320ms Time: 320ms Time: 330ms Time: 324ms Time: 319ms Time: 326ms Time: 321ms Time: 320ms Time: 324ms`
如您所见,响应时间变得更长了,因为服务器位于美国,而用户在西班牙。所以信息从服务器传到用户手中需要更多的时间。
多区域支持
虽然有多种方式来实现多地区支持,但我们将重点关注顶级云提供商:亚马逊网络服务(AWS)谷歌云平台 (GCP),以及微软 Azure 。这些提供商提供了一系列服务,可以帮助我们为 web 应用程序实现多区域架构。
一般来说,部署多区域应用程序的体系结构包括在世界各地的不同区域设置应用程序的多个实例。然后,这些实例可以连接到负载均衡器,负载均衡器根据用户的位置将传入流量分配到最近的实例。
这些提供商提供了一些服务来帮助实现这种架构:
亚马逊网络服务
- Amazon EC2 :允许我们在全球多个地区启动和管理虚拟服务器。
- 亚马逊弹性负载均衡器(Amazon Elastic Load Balancer)(ELB):帮助在不同地区的多个应用实例之间分配传入流量。
- 亚马逊 Route 53 :一种域名系统(DNS)服务,可以根据用户的位置将用户路由到最近的实例。
AWS 提供了一系列服务,可以帮助您管理多个地区的基础设施。虽然您可以使用上述服务启动和管理您自己的多区域 EC2 集群和负载平衡器,但是您也可以选择使用像弹性容器服务 (ECS)和弹性豆茎这样的服务来为您管理基础设施。这些服务为在不同地区部署和扩展应用程序提供了一个方便且全面管理的解决方案。
谷歌云平台
- 谷歌计算引擎 (GCE):在全球多个地区提供虚拟机。如果您的应用程序是 Dockerized,而不是 GCE,您可以使用 Google Kubernetes 引擎 (GKE),它允许您运行和管理世界上多个地区的 Kubernetes 集群。
- Google Load Balancer :在不同地区的应用程序的多个实例之间分配输入流量。
- Google Cloud DNS :一种 DNS 服务,可以根据用户的位置将用户路由到最近的实例。
微软 Azure
- Azure 虚拟机(Azure Virtual Machines):允许我们在全球多个地区启动和管理虚拟服务器。如果您的应用程序是 Docker 化的,而不是虚拟机,那么您可以使用容器实例,这允许您在全球多个地区快速部署和管理 Docker 容器。
- Azure 负载均衡器:帮助在不同地区的应用程序的多个实例之间分配传入流量。
- Azure Traffic Manager :基于 DNS 的流量管理服务,根据用户的位置将用户路由到最近的实例。
结论
当我们将应用程序部署到云中时,有时我们会忽略区域选项,而选择默认选项。到目前为止,您应该意识到让您的应用程序尽可能靠近您的用户以优化性能是多么重要。
许多应用程序的用户遍布世界各地。作为开发人员,我们的责任是问:“我的下一个用户可能在哪里?”。更重要的是,我们应该记住,互联网正在快速发展,我们应该找到合适的架构来支持快速全球连接的需求,以便以用户满意的方式传递信息。
Python 项目工作流
到目前为止,在这个系列中,我们已经介绍了:
在本文中,当您从头到尾开发一个项目时,您将把所有东西粘在一起。开发基本项目后,您将:
您可以随意将 GitHub 操作替换为类似的 CI/CD 工具,如 GitLab CI 、 Bitbucket Pipelines 或 CircleCI 。
项目设置
让我们构建一个随机报价生成器,从一组报价中返回随机选择的报价。
初始化项目
首先,让我们为我们的项目创建一个新文件夹:
`$ mkdir random-quote-generator
$ cd random-quote-generator`
用诗歌初始化项目:
`$ poetry init
Package name [random_quote_generator]:
Version [0.1.0]:
Description []:
Author [Your name <[[email protected]](/cdn-cgi/l/email-protection)>, n to skip]:
License []:
Compatible Python versions [^3.10]:
Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Do you confirm generation? (yes/no) [yes]`
关于诗歌的更多内容,请查看现代 Python 环境——依赖性和工作空间管理文章。
您的项目名称必须是唯一的,因为您将把它上传到 PyPI 。因此,为了避免名称冲突,在 pyproject.toml 中为包名添加一个唯一的字符串。
例如:
`[tool.poetry] name = "random-quote-generator-9308" version = "0.1.0" description = "" authors = ["Michael Herman <[[email protected]](/cdn-cgi/l/email-protection)>"] [tool.poetry.dependencies] python = "^3.10" [tool.poetry.dev-dependencies] [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api"`
在 GitHub 上创建一个新的资源库:
接下来,初始化项目中的 git 存储库:
`$ git init
$ git add pyproject.toml
$ git commit -m "first commit"
$ git branch -M main
$ git remote add origin [[email protected]](/cdn-cgi/l/email-protection):<your-github-username>/random-quote-generator.git
$ git fetch
$ git branch --set-upstream-to=origin/main main
$ git pull origin main --rebase
$ git push -u origin main`
基本设置完成后,让我们继续添加以下开发依赖项:
查看 Python 代码质量一文,了解关于这些依赖性的详细信息。
安装:
`$ poetry add --dev pytest pytest-cov black isort flake8 bandit safety`
将新的poem . lock文件以及更新后的 pyproject.toml 文件添加到 git:
`$ git add poetry.lock pyproject.toml`
构建项目
之后,创建一个名为“random_quote_generator”的新文件夹。在该文件夹中,添加一个 init。py 文件,所以它被当作一个模块,还有一个 quotes.py 文件。
`random-quote-generator
├── poetry.lock
├── pyproject.toml
└── random_quote_generator
├── __init__.py
└── quotes.py`
在 quotes.py 中,添加:
`quotes = [
{
"quote": "A long descriptive name is better than a short "
"enigmatic name. A long descriptive name is better "
"than a long descriptive comment.",
"author": "Robert C. Martin",
},
{
"quote": "You should name a variable using the same "
"care with which you name a first-born child.",
"author": "Robert C. Martin",
},
{
"quote": "Any fool can write code that a computer "
"can understand. Good programmers write code"
" that humans can understand.",
"author": "Martin Fowler",
},
]`
那里没什么特别的。只是一个字典列表,每个引用一个。接下来,在项目根目录下创建一个名为“tests”的新文件夹,并添加以下文件:
`tests
├── __init__.py
└── test_get_quote.py`
test_get_quote.py :
`from random_quote_generator import get_quote
from random_quote_generator.quotes import quotes
def test_get_quote():
"""
GIVEN
WHEN get_quote is called
THEN random quote from quotes is returned
"""
quote = get_quote()
assert quote in quotes`
运行测试:
`$ poetry run python -m pytest tests`
它应该会失败:
`E ImportError: cannot import name 'get_quote' from 'random_quote_generator'`
接下来,向“random_quote_generator”添加一个名为 get_quote.py 的新文件:
`import random
from random_quote_generator.quotes import quotes
def get_quote() -> dict:
"""
Get random quote
Get randomly selected quote from database our programming quotes
:return: selected quote
:rtype: dict
"""
return quotes[random.randint(0, len(quotes) - 1)]`
因此,通过生成一个随机整数来选择一个报价,该整数的值为 0 到最后一个索引之间的 random.randint。
在random _ quote _ generator/_ _ init _ 中导出函数。py :
`"""
Random Quote Generator
======================
Get random quote from our database of programming wisdom
"""
from .get_quote import get_quote
__all__ = ["get_quote"]`
该函数被导入并在__all__
属性中列出,该属性是模块的公共对象列表。换句话说,当有人使用from random_quote_generator import *
时,只会导入__all__
中列出的名字。
测试现在应该通过了:
`$ poetry run python -m pytest tests`
创建一个。gitignore 项目根目录中的文件:
将“random_quote_generator”和“tests”文件夹与一起添加到 git 中。gitignore 文件:
`$ git add random_quote_generator/ tests/ .gitignore`
就是这样。包裹已准备好交付。
记录项目
我们的软件包工作,但我们的用户将不得不检查它的源代码,看看如何使用它。我们已经包含了 docstrings,因此我们可以使用 Sphinx 轻松创建独立的项目文档。
如果您不熟悉作为独立资源的 docstrings 或文档,请阅读文档化 Python 代码和项目文章。
假设您已经安装了 Sphinx,运行下面的命令在项目根目录中为 Sphinx 搭建文件和文件夹:
你将被提升一些问题:
`> Separate source and build directories (y/n) [n]: n
> Project name: Random Quote Generator
> Author name(s): Your Name
> Project release []: 0.1.0
> Project language [en]: en`
接下来,让我们更新项目配置。打开 docs/conf.py 并替换它:
`# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))`
有了这个:
`import os
import sys
sys.path.insert(0, os.path.abspath('..'))`
现在, autodoc ,用于从 docstrings 中拉入文档,会在“docs”的父文件夹中搜索模块。
将下列扩展名添加到扩展名列表中:
`extensions = [
'sphinx.ext.autodoc',
]`
更新 docs/index.rst 如下:
`.. Random Quote Generator documentation master file, created by
sphinx-quickstart on Mon Dec 21 22:27:23 2020.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Random Quote Generator's documentation!
==================================================
.. automodule:: random_quote_generator
:members:
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search``
这个文件应该从 Flake8 中排除,我们将很快添加它。因此,在项目根目录下创建一个 .flake8 文件:
`[flake8] exclude = docs/conf.py,`
将“docs”文件夹和 .flake8 添加到 git:
GitHub 操作
接下来,让我们用 GitHub 动作连接 CI 管道。
将以下文件和文件夹添加到项目根目录中:
`.github
└── workflows
└── branch.yaml`
在 branch.yaml 中,添加:
`name: Push on: [push] jobs: test: strategy: fail-fast: false matrix: python-version: ['3.10'] poetry-version: ['1.1.13'] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) with: python-version: ${{ matrix.python-version }} - name: Run image uses: abatilo/[[email protected]](/cdn-cgi/l/email-protection) with: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies run: poetry install - name: Run tests run: poetry run pytest --cov=./ --cov-report=xml - name: Upload coverage to Codecov uses: codecov/[[email protected]](/cdn-cgi/l/email-protection) code-quality: strategy: fail-fast: false matrix: python-version: ['3.10'] poetry-version: ['1.1.13'] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) with: python-version: ${{ matrix.python-version }} - name: Run image uses: abatilo/[[email protected]](/cdn-cgi/l/email-protection) with: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies run: poetry install - name: Run black run: poetry run black . --check - name: Run isort run: poetry run isort . --check-only --profile black - name: Run flake8 run: poetry run flake8 . - name: Run bandit run: poetry run bandit . - name: Run saftey run: poetry run safety check`
这种配置:
- 在每个分支的每次推送时运行-
on: [push]
- 运行在最新版本的 Ubuntu -
ubuntu-latest
- 使用 Python 3.10 -
python-version: [3.10]
,python-version: ${{ matrix.python-version }}
- 1.1.13 -使用诗歌版本
poetry-version: [1.1.13]
,poetry-version: ${{ matrix.poetry-version }}
定义了两个作业:test
和code-quality
。顾名思义,测试在测试作业中运行,而我们的代码质量检查在代码质量作业中运行。
现在,每次推送 GitHub 库时,测试和代码质量工作都会运行。
添加”。github "到 git:
运行所有代码质量检查:
`$ poetry run black .
$ poetry run isort . --profile black
$ poetry run flake8 .
$ poetry run bandit .
$ poetry run safety check`
确保添加任何可能已经更改为 git 的文件。然后,将您的更改提交并推送到 GitHub:
`$ git add docs/ random_quote_generator/ tests/
$ git commit -m 'Package ready'
$ git push -u origin main`
您应该在 GitHub 存储库的“Actions”选项卡上看到您的工作流正在运行。在继续前进之前确保它通过。
CodeCov
接下来,我们将配置 CodeCov 来跟踪代码覆盖率。导航到 http://codecov.io/的,用你的 GitHub 账号登录,找到你的库。
查看快速入门指南,获得使用 CodeCov 的帮助。
再次运行 GitHub 操作工作流程。完成后,您应该能够在 CodeCov:
现在,每次您的工作流运行时,都会生成一个覆盖报告并上传到 CodeCov。您可以分析分支、提交和拉请求的覆盖率的变化,重点关注覆盖率随时间的增加和减少。
阅读文件
我们将使用Read Docs来存放我们的文档。导航到 https://readthedocs.org 的,使用你的 GitHub 账户登录。
如果您刚刚注册,请确保在继续之前验证您的电子邮件地址。
接下来,点击“导入项目”。之后,刷新您的项目并添加随机报价生成器项目。打开项目并导航到“Admin”部分。然后,在“高级设置”下,将默认分支设置为main
。不要忘记保存您的更改。
阅读文档并构建文档需要几分钟时间。一旦完成,您应该能够在https://your-project-slug-on-readthedocs.readthedocs.io
查看您的项目文档。
默认情况下,文档将在每次推送到main
分支时重新构建。这样,剩下的唯一事情就是将您的包发布到 PyPI。
好吧
最后,为了使项目“可安装 pip ”,我们将把它发布到 PyPI 。
首先将以下部分添加到 pyproject.toml 中,以便将“random_quote_generator”模块包含在 PyPI 的发行版中:
`packages = [ { include = "random_quote_generator" }, ]`
示例文件:
`[tool.poetry] name = "random-quote-generator-93618" packages = [ { include = "random_quote_generator" }, ] version = "0.1.0" description = "" authors = ["Amir Tadrisi <[[email protected]](/cdn-cgi/l/email-protection)>"] [tool.poetry.dependencies] python = "^3.10" [tool.poetry.dev-dependencies] pytest = "^7.1.2" pytest-cov = "^3.0.0" black = "^22.3.0" isort = "^5.10.1" flake8 = "^4.0.1" bandit = "^1.7.4" safety = "^1.10.3" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api"`
添加一个名为 release.yaml 的新文件到”。github/工作流”:
`name: Release on: release: types: - created jobs: publish: strategy: fail-fast: false matrix: python-version: ['3.10'] poetry-version: ['1.1.13'] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - uses: actions/[[email protected]](/cdn-cgi/l/email-protection) with: python-version: ${{ matrix.python-version }} - name: Run image uses: abatilo/[[email protected]](/cdn-cgi/l/email-protection) with: poetry-version: ${{ matrix.poetry-version }} - name: Publish env: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} run: | poetry config pypi-token.pypi $PYPI_TOKEN poetry publish --build`
因此,当一个新的版本被创建时,这个包将被发布到 PyPI。
接下来,我们需要创建一个 PyPI 令牌。如果你还没有的话,在 PyPI 上创建一个账户。然后,一旦登录,点击“帐户设置”并添加一个新的 API 令牌。复制令牌。现在您需要将它添加到您的 GitHub 库的秘密中。在回购中,单击“设置”选项卡,然后单击“秘密”并选择“操作”。使用PYPI_TOKEN
作为秘密名称,使用令牌值作为秘密值。
现在您已经准备好创建您的第一个版本了。
将 release.yaml 文件以及更新后的 pyproject.toml 文件添加到 git,提交并推送:
`$ git add .github/workflows/release.yaml pyproject.toml
$ git commit -m 'Ready for first release'
$ git push -u origin main`
要创建新版本,请导航至https://github.com/<username>/<project-name>/releases/
。单击“创建新版本”。输入0.1.0
作为标签,输入Initial Release
作为标题。
这将在 GitHub 操作上触发一个新的工作流。一旦完成,您应该会在 PyPI 上看到您的包。
现在您应该能够从 PyPI 安装和使用您的包了:
`>>> from random_quote_generator import get_quote
>>>
>>> print(get_quote())
{'quote': 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', 'author': 'Martin Fowler'}`
结论
我们为发布到 PyPI 的 Python 包设置了一个简单的 CI/CD 管道。通过测试和代码质量来检查每个分支上的代码。您可以在 CodeCov 中检查代码覆盖率。在发布时,新版本被部署到 PyPI,文档被更新。这可以大大简化你的生活。
像这样的自动化管道有助于确保您的工作流程日复一日保持一致。尽管如此,你仍然应该关心你的团队的工作文化。测试在推送时运行,但它们必须存在。如果您没有任何测试要运行,自动化测试运行不会有太大帮助。自动化也不会对您的变更大小产生太大的影响。尽量保持小规模,经常合并到main
。伴随着测试驱动开发(TDD)和 CI/CD 管道的小变化会对你所交付的软件的质量产生巨大的影响。只是不要忘记,它总是以团队文化开始和结束。
完整 Python 指南:
Python 类型检查
什么是类型检查?我们为什么需要它?静态和运行时类型检查有什么区别?
Python 是一种强类型、动态编程语言。由于它是动态类型的,类型是动态推断的,所以您可以直接设置变量值,而不像在静态类型编程语言(如 Java)中那样定义变量类型。
强的和动态的意味着类型在运行时被推断,但是你不能混合类型。比如
a = 1 + '0'
在 Python 中会引发一个错误。另一方面,JavaScript 是弱的和动态的,所以类型是在运行时推断的,并且您可以混合使用类型。例如,a = 1 + '0'
会将a
设置为10
。
虽然动态类型带来了灵活性,但它并不总是令人满意的。因此,最近有很多人尝试将静态类型推理引入动态语言。
在这篇文章中,我们将看看什么是类型提示,以及它们如何给你带来好处。我们还将深入探讨如何使用 Python 的类型系统通过 mypy 进行静态类型检查,通过 pydantic、marshmallow 和 typeguard 进行运行时类型检查。
完整 Python 指南:
有许多工具使用类型提示进行静态和运行时类型检查。
静态打字
运行时类型检查/数据验证
特定项目
- pydantic-django
- django-stubs
- 型长江
- 烧瓶-pydantic
- 烧瓶-棉花糖
- fastapi (pydantic 内置——耶!)
查看牛逼的 Python Typing 获得完整的工具列表。
类型提示
Python 3.5 版中添加了类型提示。
它们允许开发人员在 Python 代码中注释变量、函数参数和函数返回值的预期类型。虽然 Python 解释器并不强制执行这种类型——同样,Python 是一种动态类型语言——但它们确实提供了许多好处。首先,使用类型提示,您可以更好地表达您的代码正在做什么以及如何使用它的意图。更好的理解导致更少的错误。
例如,假设您有以下函数来计算日平均温度:
`def daily_average(temperatures):
return sum(temperatures) / len(temperatures)`
只要您像这样提供一个温度列表,该函数就会按预期工作,并返回预期的结果:
`average_temperature = daily_average([22.8, 19.6, 25.9])
print(average_temperature) # => 22.76666666666667`
如果用字典调用函数,其中键是测量的时间戳,值是温度,会发生什么?
`average_temperature = daily_average({1599125906: 22.8, 1599125706: 19.6, 1599126006: 25.9})
print(average_temperature) # => 1599125872.6666667`
本质上,这个函数现在返回键的总和/键的数量,这显然是错误的。由于函数调用没有引发错误,这可能会被检测到,特别是如果最终用户提供了温度。
为了避免这种混淆,可以通过注释参数和返回值来添加类型提示:
`def daily_average(temperatures: list[float]) -> float:
return sum(temperatures) / len(temperatures)`
现在函数定义告诉我们:
temperatures
应该是一个浮动列表:temperatures: list[float]
- 该函数应该返回一个浮点数:
-> float
`print(daily_average.__annotations__)
# {'temperatures': list[float], 'return': <class 'float'>}`
类型提示启用静态类型检查工具。代码编辑器和 ide 也使用它们,根据类型提示,当特定函数或方法的使用不符合预期时,它们会发出警告,并提供强大的自动完成功能。
所以,类型提示实际上只是“提示”。换句话说,它们不像静态类型语言中的类型定义那样严格。也就是说,即使它们相当灵活,它们仍然通过更清楚地表达意图来帮助提高代码质量。除此之外,你可以使用许多工具从中获益更多。
类型批注与类型提示
类型注释只是注释函数输入、函数输出和变量的语法:
`def sum_xy(x: 'an integer', y: 'another integer') -> int:
return x + y
print(sum_xy.__annotations__)
# {'x': 'an integer', 'y': 'another integer', 'return': <class 'int'}`
类型提示建立在注释之上,使它们更有用。提示和注释经常互换使用,但它们是不同的。
Python 的类型模块
您可能想知道为什么有时会看到这样的代码:
`from typing import List
def daily_average(temperatures: List[float]) -> float:
return sum(temperatures) / len(temperatures)`
它使用内置的float
来定义函数返回类型,但是List
是从类型化模块中导入的。
在 Python 3.9 之前,Python 解释器不支持使用内置参数进行类型提示。
例如,可以使用 list 作为类型提示,如下所示:
`def daily_average(temperatures: list) -> float:
return sum(temperatures) / len(temperatures)`
但是如果没有类型模块,就不可能定义列表元素的预期类型(list[float]
)。对于字典和其他序列和复杂类型也是如此:
`from typing import Tuple, Dict
def generate_map(points: Tuple[float, float]) -> Dict[str, int]:
return map(points)`
除此之外,打字模块允许你定义新类型、类型别名、类型任意等许多事情。
例如,您可能希望允许多种类型。为此,您可以使用 Union :
`from typing import Union
def sum_ab(a: Union[int, float], b: Union[int, float]) -> Union[int, float]:
return a + b`
从 Python 3.9 开始,您可以像这样使用内置函数:
`def sort_names(names: list[str]) -> list[str]:
return sorted(names)`
用 mypy 进行静态类型检查
mypy 是一个在编译时进行类型检查的工具。
您可以像安装任何其他 Python 包一样安装它:
要检查 Python 模块,您可以像这样运行它:
`$ python -m mypy my_module.py`
所以,让我们再来看一下daily_average
的例子:
`def daily_average(temperatures):
return sum(temperatures) / len(temperatures)
average_temperature = daily_average(
{1599125906: 22.8, 1599125706: 19.6, 1599126006: 25.9}
)`
当使用 mypy 对此类代码进行类型检查时,不会报告任何错误,因为该函数不使用类型提示:
`Success: no issues found in 1 source file`
在以下位置添加类型提示:
`def daily_average(temperatures: list[float]) -> float:
return sum(temperatures) / len(temperatures)
average_temperature = daily_average(
{1599125906: 22.8, 1599125706: 19.6, 1599126006: 25.9}
)`
再次运行 mypy:
`$ python -m mypy my_module.py`
您应该看到:
`my_module.py:6: error: Argument 1 to "daily_average" has incompatible
type "Dict[int, float]"; expected "List[float]" [arg-type]
Found 1 error in 1 file (checked 1 source file)`
mypy 识别出函数调用不正确。它报告了文件名、行号和错误描述。将类型提示与 mypy 结合使用有助于减少因误用函数、方法和类而导致的错误。这导致了更快的反馈循环。您不需要运行所有的测试,甚至不需要部署整个应用程序。这种错误会立即通知您。
在合并或部署代码之前,将 mypy 添加到 CI 管道中来检查类型也是一个好主意。有关这方面的更多信息,请查看 Python 代码质量一文。
尽管就代码质量而言这是一个很大的改进,但静态类型检查并不在运行时强制类型,因为您的程序正在运行。这就是为什么我们也有运行时类型检查器,我们将在接下来看到。
mypy 附带了 typeshed ,其中包含 Python 标准库的外部类型注释和 Python 内置以及第三方包。
mypy 检查 Python 程序,基本没有运行时开销。虽然它检查类型,鸭式打字仍然发生。因此,它不能用于编译 CPython 扩展。
运行时类型检查
迂腐的
静态类型检查器在处理来自应用程序用户等外部来源的数据时没有帮助。这就是运行时类型检查器发挥作用的地方。一个这样的工具是 pydantic ,用于验证数据。当提供的数据与用类型提示定义的类型不匹配时,它会引发验证错误。
pydantic 使用类型转换来转换输入数据,以强制其符合预期的类型。
其实用起来挺简单的。例如,让我们用几个属性定义一个Song
类:
`from datetime import date
from pydantic import BaseModel
class Song(BaseModel):
id: int
name: str
release: date
genres: list[str]`
现在,当我们用有效数据初始化一个新的Song
时,一切都按预期工作:
`song = Song(
id=101,
name='Bohemian Rhapsody',
release='1975-10-31',
genres=[
'Hard Rock',
'Progressive Rock'
]
)
print(song)
# id=101 name='Bohemian Rhapsody' release=datetime.date(1975, 10, 31)
# genres=['Hard Rock', 'Progressive Rock']`
然而,当我们试图用无效数据('1975-31-31'
)初始化新的Song
时,会产生一个ValidationError
:
`song = Song(
id=101,
name='Bohemian Rhapsody',
release='1975-31-31',
genres=[
'Hard Rock',
'Progressive Rock'
]
)
print(song)
# pydantic.error_wrappers.ValidationError: 1 validation error for Song
# release
# invalid date format (type=value_error.date)`
使用 pydantic,我们可以确保在我们的应用程序中只使用与定义的类型相匹配的数据。这不仅会导致更少的错误,而且您需要编写更少的测试。通过使用像 pydantic 这样的工具,我们不需要为用户发送完全错误的数据的情况编写测试。它由 pydantic 处理——引发了一个ValidationError
。例如, FastAPI 用 pydantic 验证 HTTP 请求和响应主体:
`from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.post("/items/", response_model=Item)
async def create_item(item: Item):
return item`
create_item
处理程序需要一个带有name
(字符串)和price
(浮点)的有效载荷。响应对象应该看起来相同。现在,如果所提供的有效负载有问题,就会立即出现错误。延迟引发会使调试和确定错误类型的数据的来源变得更加困难。另外,因为它是由 pydantic 处理的,所以您可以保持您的路由处理程序干净。
除了利用类型提示进行数据验证之外,您还可以添加自定义验证器来确保数据的正确性。为属性添加自定义验证相当容易。例如,为了防止在Song
类中出现风格重复,您可以像这样添加验证:
`from datetime import date
from pydantic import BaseModel, validator
class Song(BaseModel):
id: int
name: str
release: date
genres: list[str]
@validator('genres')
def no_duplicates_in_genre(cls, v):
if len(set(v)) != len(v):
raise ValueError(
'No duplicates allowed in genre.'
)
return v
song = Song(
id=101,
name='Bohemian Rhapsody',
release='1975-10-31',
genres=[
'Hard Rock',
'Progressive Rock',
'Progressive Rock',
]
)
print(song)
# pydantic.error_wrappers.ValidationError: 1 validation error for Song
# genre
# No duplicates allowed in genre. (type=value_error)`
因此,验证方法no_duplicates_in_genre
必须用validator
修饰,它将属性名作为参数。验证方法必须是类方法,因为验证发生在创建实例之前。对于没有通过验证的数据,它应该引发一个标准的 Python ValueError
。
您还可以使用验证器方法在验证发生之前更改值。为此,将pre=True
和always=True
添加到validator
装饰器中:
`@validator('genres', pre=True, always=True)`
例如,您可以将流派转换为小写,如下所示:
`from datetime import date
from pydantic import BaseModel, validator
class Song(BaseModel):
id: int
name: str
release: date
genres: list[str]
@validator('genres', pre=True, always=True)
def to_lower_case(cls, v):
return [genre.lower() for genre in v]
@validator('genres')
def no_duplicates_in_genre(cls, v):
if len(set(v)) != len(v):
raise ValueError(
'No duplicates allowed in genre.'
)
return v
song = Song(
id=101,
name='Bohemian Rhapsody',
release='1975-10-31',
genres=[
'Hard Rock',
'PrOgReSsIvE ROCK',
'Progressive Rock',
]
)
print(song)
# pydantic.error_wrappers.ValidationError: 1 validation error for Song
# genre
# No duplicates allowed in genre. (type=value_error)`
to_lower_case
将genres
列表中的每个元素转换成小写。因为pre
被设置为True
,所以这个方法在 pydantic 验证类型之前被调用。所有泛型都被转换成小写,然后用no_duplicates_in_genre
进行验证。
pydantic 还提供了更严格的类型,如PositiveInt
和EmailStr
,以使您的验证更好。查看文档中的字段类型,了解更多相关信息。
棉花糖
另一个值得一提的工具是 marshmallow ,它有助于验证复杂数据,并从/向本机 Python 类型加载/转储数据。安装与任何其他 Python 包一样:
`$ pip install marshmallow`
像 pydantic 一样,您可以向类添加类型验证:
`from marshmallow import Schema, fields, post_load
class Song:
def __init__(
self,
id,
name,
release,
genres
):
self.id = id
self.name = name
self.release = release
self.genres = genres
def __repr__(self):
return (
f'<Song(id={self.id}, name={self.name}), '
f'release={self.release.isoformat()}, genres={self.genres}>'
)
class SongSchema(Schema):
id = fields.Int()
name = fields.Str()
release = fields.Date()
genres = fields.List(fields.String())
@post_load
def make_song(self, data, **kwargs):
return Song(**data)
external_data = {
'id': 101,
'name': 'Bohemian Rhapsody',
'release': '1975-10-31',
'genres': ['Hard Rock', 'Progressive Rock']
}
song = SongSchema().load(external_data)
print(song)
# <Song(id=101, name=Bohemian Rhapsody), release=1975-10-31, genres=['Hard Rock', 'Progressive Rock']>`
与 pydantic 不同,marshmallow 不使用类型转换,所以需要分别定义模式和类。例如,external_data
中的发布日期必须是 ISO 字符串。它不适用于datetime
对象。
要将数据反序列化到一个Song
对象中,需要向模式中添加一个用@post_load
decorator 修饰的方法:
`class SongSchema(Schema):
id = fields.Int()
name = fields.Str()
release = fields.Date()
genres = fields.List(fields.String(), validate=no_duplicates)
@post_load
def make_song(self, data, **kwargs):
return Song(**data)`
该模式验证数据,如果所有字段都有效,它通过用验证过的数据调用make_song
来创建该类的一个实例。
像 pydantic 一样,您可以为模式中的每个属性添加自定义验证。例如,您可以防止重复,如下所示:
`import datetime
from marshmallow import Schema, fields, post_load, ValidationError
class Song:
def __init__(
self,
id,
name,
release,
genres
):
self.id = id
self.name = name
self.release = release
self.genres = genres
def __repr__(self):
return (
f'<Song(id={self.id}, name={self.name}), '
f'release={self.release.isoformat()}, genres={self.genres}>'
)
def no_duplicates(genres):
if isinstance(genres, list):
genres = [
genre.lower()
for genre in genres
if isinstance(genre, str)
]
if len(set(genres)) != len(genres):
raise ValidationError(
'No duplicates allowed in genres.'
)
class SongSchema(Schema):
id = fields.Int()
name = fields.Str()
release = fields.Date()
genres = fields.List(fields.String(), validate=no_duplicates)
@post_load
def make_song(self, data, **kwargs):
return Song(**data)
external_data = {
'id': 101,
'name': 'Bohemian Rhapsody',
'release': '1975-10-31',
'genres': ['Hard Rock', 'Progressive Rock', 'ProgressivE Rock']
}
song = SongSchema().load(external_data)
print(song)
# marshmallow.exceptions.ValidationError:
# {'genres': ['No duplicates allowed in genres.']}`
如您所见,您可以使用 pydantic 或 marshmallow 来确保数据在应用程序运行时具有正确的类型。挑一个更符合你风格的。
打字警卫
pydantic 和 marshmallow 专注于数据验证和序列化,而 typeguard 专注于在调用函数时检查类型。mypy 只做静态类型检查,而 typeguard 在程序运行时强制执行类型。
让我们来看看和之前一样的例子——一个Song
类。这次我们用类型提示参数定义它的__init__
方法:
`from datetime import date
from typeguard import typechecked
@typechecked
class Song:
def __init__(
self,
id: int,
name: str,
release: date,
genres: list[str]
) -> None:
self.id = id
self.name = name
self.release = release
self.genres = genres
song = Song(
id=101,
name='Bohemian Rhapsody',
release=date(1975, 10, 31),
genres={
'Hard Rock',
'Progressive Rock',
}
)
print(song)
# TypeError: type of argument "genres" must be a list; got set instead`
当您想在运行时执行类型检查时,typechecked
decorator 可以用于类和函数。运行这段代码将引发一个TypeError
,因为流派是一个集合而不是一个列表。您可以类似地对函数使用装饰器,如下所示:
`from typeguard import typechecked
@typechecked
def sum_ab(a: int, b: int) -> int:
return a + b`
它还附带了一个 pytest 插件。要在运行测试时检查包my_package
的类型,您可以运行以下命令:
`$ python -m pytest --typeguard-packages=my_package`
当用 pytest 运行时,你不需要使用@typechecked
装饰器。因此,您可以修饰您的函数和类,以便在运行时或仅在测试运行时强制类型。无论哪种方式,typeguard 都可以成为您的应用程序的强大安全网,以确保它按预期运行。
迂腐的烧瓶
因此,让我们将所有这些放在一个 web 应用程序中。如上所述,FastAPI 默认使用 pydantic。尽管 Flask 没有对 pydantic 的内置支持,但我们可以使用绑定将它添加到我们的 API 中。因此,让我们创建一个新的 Flask 项目来看看它的运行情况。
首先,创建一个新文件夹:
`$ mkdir flask_example
$ cd flask_example`
接下来,用诗歌初始化您的项目:
`$ poetry init
Package name [flask_example]:
Version [0.1.0]:
Description []:
Author [Your name <[[email protected]](/cdn-cgi/l/email-protection)>, n to skip]:
License []:
Compatible Python versions [^3.7]: >3.7
Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Do you confirm generation? (yes/no) [yes]`
之后,加入烧瓶、 Flask-Pydantic 和 pytest:
`$ poetry add flask Flask-Pydantic
$ poetry add --dev pytest`
创建一个名为 test_app.py 的文件来保存我们的测试:
`import json
import pytest
from app import app
@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client
def test_create_todo(client):
response = client.post(
"/todos/",
data=json.dumps(
{
'title': 'Wash the dishes',
'done': False,
'deadline': '2020-12-12'
}
),
content_type='application/json'
)
assert response.status_code == 201
def test_create_todo_bad_request(client):
response = client.post(
"/todos/",
data=json.dumps(
{
'title': 'Wash the dishes',
'done': False,
'deadline': 'WHENEVER'
}
),
content_type='application/json'
)
assert response.status_code == 400`
这里,我们有两个创建新 todos 的测试。当一切正常时,检查是否返回状态 201。另一个检查当提供的数据不符合预期时是否返回状态 400。
接下来,为 Flask 应用程序添加一个名为 app.py 的文件:
`import datetime
from flask import Flask, request
from flask_pydantic import validate
from pydantic import BaseModel
app = Flask(__name__)
class CreateTodo(BaseModel):
title: str
done: bool
deadline: datetime.date
class Todo(BaseModel):
title: str
done: bool
deadline: datetime.date
created_at: datetime.datetime
@app.route("/todos/", methods=['POST'])
@validate(body=CreateTodo)
def todos():
todo = Todo(
title=request.body_params.title,
done=request.body_params.done,
deadline=request.body_params.deadline,
created_at=datetime.datetime.now()
)
return todo, 201
if __name__ == "__main__":
app.run()`
我们已经定义了一个创建 todos 的端点,以及一个名为CreateTodo
的请求模式和一个名为Todo
的响应模式。现在,当数据被发送到与请求模式不匹配的 API 时,将返回一个状态 400,在主体中包含验证错误。您现在可以运行测试来检查您的 API 是否确实如所描述的那样运行:
跑步型跳棋
现在你已经知道了工具,下一个问题是:什么时候应该使用它们?
与代码质量工具非常相似,您通常会运行类型检查器:
- 编码时(在 ide 或代码编辑器中)
- 提交时(使用预提交挂钩)
- 当代码签入源代码管理时(通过 CI 管道)
- 程序运行期间(运行时检查器)
在您的 ide 或代码编辑器中
最好尽早并经常检查可能对质量有负面影响的问题。因此,建议在开发过程中静态检查您的代码。许多流行的 ide 都内置了 mypy 或类似 mypy 的静态类型检查器。对于那些没有,可能有一个插件可用。这样的插件会实时警告你类型冲突和潜在的编程错误。
资源:
提交前挂钩
由于您在编码时不可避免地会遗漏一些警告,所以在提交时用预提交 git 挂钩检查静态类型问题是一个好的实践。这样,您可以避免提交无法通过 CI 管道内部类型检查的代码。
推荐使用预提交框架来管理 git 挂钩。
安装后,添加一个名为。将 pre-commit-config.yaml 提交到项目。要运行 mypy,请添加以下配置:
`repos: - repo: https://github.com/pre-commit/mirrors-mypy rev: 'v0.790' hooks: - id: mypy`
最后,要设置 git 挂钩脚本:
`(venv)$ pre-commit install`
现在,每次运行git commit
mypy 都会在实际提交之前运行。如果有任何问题,提交将被中止。
CI 管道
在 CI 管道中运行静态类型检查是有意义的,这样可以防止类型问题合并到代码库中。这可能是运行 mypy 或其他静态类型检查器的最重要的时候。
使用 mypy 运行静态类型检查时可能会遇到问题,尤其是在使用没有类型提示的第三方库时。这可能是许多人避免在 CI 管道中运行 mypy 检查的主要原因。
在程序运行期间
所有以前运行的时间都是在程序实际运行之前。这是静态类型检查器的工作。对于动态类型检查器,你需要一个运行程序。如前所述,使用它们将需要更少的测试,产生更少的错误,并帮助您尽早发现错误。您可以使用它们进行数据验证(使用 pydantic 和 marshmallow)以及在程序运行期间强制类型(使用 typeguard)。
结论
当代码库很小的时候,类型检查可能看起来没有必要,但是代码库越大,类型检查就越重要。这是保护我们免受容易预防的错误的又一层保护。虽然类型提示不是由解释器强制执行的,但它有助于更好地表达变量、函数或类的意图。大多数现代 ide 和代码编辑器都提供插件,根据类型提示通知开发人员类型不匹配的情况。为了实施它们,我们可以将 mypy 包含到我们的工作流中,静态地检查方法的使用是否与它们的类型提示相匹配。虽然静态分析可以改进您的代码,但是您必须考虑到我们的软件正在与外部世界进行通信。因此,鼓励添加运行时类型的检查器,如 pydantic 或 marshmallow。它们有助于验证用户输入,并在可能的最早阶段引发错误。你越快发现错误,就越容易改正并继续前进。
完整 Python 指南:
使用 WebAssembly 在浏览器中运行 Python
Python 社区长期以来一直在讨论让 Python 成为现代 web 浏览器中的一等公民的最佳方式。最大的挑战是 web 浏览器实际上只支持一种编程语言:JavaScript。然而,随着网络技术的进步,我们已经将越来越多的应用程序推向网络,如游戏、科学可视化以及音频和视频编辑软件。这意味着我们给网络带来了繁重的计算——这不是 JavaScript 的设计初衷。所有这些挑战都提出了对一种能够提供快速、可移植、紧凑和安全执行的低级 web 语言的需求。因此,主要浏览器供应商致力于这一想法,并在 2017 年向世界推出了 WebAssembly 。
在本教程中,我们将了解 WebAssembly 如何帮助您在浏览器中运行 Python 代码。
明确地说,JavaScript 本身就是一种强大的编程语言。只是不适合某些事情。关于这方面的更多信息,请查看 ASM 的。JS to WebAssembly 作者 Brendan Eich,JavaScript 的创造者。
我们正在建造的东西
假设你想教一门 Python 课程。为了使您的课程更有趣,每节课之后,您都要为学生准备一个练习,这样他们就可以练习他们所学的内容。
这里的问题是,学生需要通过安装特定版本的 Python、创建和激活虚拟环境以及安装所有必要的包来准备开发环境。这会耗费大量的时间和精力。因为每台机器都不一样,所以很难提供准确的说明。
虽然您可以创建一个后端来运行 Docker 容器或 AWS Lambda 函数中提交的代码,但您选择保持堆栈简单,并在课程内容中添加一个 Python 编辑器,该编辑器可以在客户端、web 浏览器中运行 Python 代码,并向用户显示结果。这正是您将在本教程中构建的内容:
点击查看现场演示。你也可以在 wasmeditor.com 的看到它的 React 版本。
web 程序集
根据 Mozilla 开发者网络(MDN)文档的定义, WebAssembly (WASM)是:
一种新型代码,可以在现代网络浏览器中运行,并提供了新的功能和主要的性能提升。它主要不是用来手工编写的,而是被设计成 C、C++、Rust 等源语言的有效编译目标。
因此,WASM 让我们在浏览器中运行用不同语言编写的代码(不仅仅是 JavaScript ),这样做有以下好处:
- 它快速、高效、便携。
- 它是安全的,因为代码是在安全的沙盒执行环境中运行的。
- 它可以在客户端运行。
因此,在我们上面的例子中,我们不需要担心用户是否在我们的服务器上运行代码,我们也不需要担心成千上万的学生尝试练习代码,因为代码执行发生在客户端,在 web 浏览器中。
WebAssembly 并不是为了杀死 JavaScript 而设计的。它是 JavaScript 的补充。当 JavaScript 不是合适的工具时,可以使用它,比如游戏、图像识别和图像/视频编辑等等。
Pyodide
本教程使用 Pyodide 库来运行 Python 代码,该代码将 CPython 解释器编译成 WebAssembly,并在浏览器的 JavaScript 环境中运行二进制文件。它附带了许多预装的 Python 包。你也可以使用micro tip来使用更多默认不提供的软件包。
你好世界
使用以下代码创建一个新的 HTML 文件:
`<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
<script> async function main() { let pyodide = await loadPyodide({ indexURL : "https://cdn.jsdelivr.net/pyodide/v0.20.0/full/" }); console.log(pyodide.runPython("print('Hello, world from the browser!')")); }; main(); </script>
</head>`
在浏览器中打开文件。然后,在您浏览器的开发工具的控制台中,您应该执行如下操作:
`Loading distutils
Loading distutils from https://cdn.jsdelivr.net/pyodide/v0.20.0/full/distutils.js
Loaded distutils
Python initialization complete
Hello, world from the browser!`
如您所见,最后一行是浏览器中 Python 代码执行的结果。
让我们快速看一下上面的代码:
- 首先,你可以使用 CDN 或者直接从 GitHub 版本下载并安装 Pyodide。
- loadPyodide 加载并初始化 Pyodide wasm 模块。
- pyodide.runPython 以 Python 代码为字符串,返回代码的结果。
优点
在前面的例子中,您看到了安装 Pyodide 并开始使用它是多么容易。你只需要从 CDN 导入 pyodide.js 并通过loadPyodide
初始化即可。之后,您可以使用pyodide.runPython("Your Python Code Here")
在浏览器中运行您的 Python 代码。
当你第一次下载 Pyodide 时,下载量很大,因为你下载的是完整的 CPython 解释器,但是你的浏览器会缓存它,你不需要再次下载。
还有一个很大的、活跃的社区在研究 Pyodide:
脓毒性限制
第一次加载 Pyodide 需要四五秒钟(取决于你的连接),因为你需要下载大约 10MB。此外,Pyodide 代码的运行速度比原生 Python 慢 3 到 5 倍。
其他选项
通常,如果您想在浏览器中运行 Python,有两种方法可用:
- 使用 transpiler 将 Python 转换为 JavaScript。 Brython 、 Transcrypt 、 Skulpt 都采用这种方式。
- 转换 Python 运行时以便在浏览器中使用。Pyodide 和 PyPy.js 使用这种方法。
选项一和选项二的一个主要区别是,选项一中提到的库不支持 Python 包。也就是说,它们的下载大小比选项二中的库要小得多,因此速度更快。
我们在本教程中使用 Pyodide,因为它的语法更简单,并且支持 Python 包。如果您对其他选项感兴趣,请随意查看它们的文档。
Python 代码编辑器
在本节中,我们将创建一个简单的 Python 编辑器,它可以使用以下代码在浏览器中运行代码:
创建新项目:
`$ mkdir python_editor_wasm
$ cd python_editor_wasm`
创建并激活虚拟环境:
`$ python3.10 -m venv env
$ source env/bin/activate
(env)$`
安装烧瓶:
在项目的根目录下创建一个名为 app.py 的文件,并添加以下代码:
`from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
if __name__ == '__main__':
app.run(debug=True)`
在我们项目的根目录下创建一个“模板”文件夹,并在它下面添加index.html文件。
模板/索引. html :
`<!doctype html>
<html class="h-full bg-slate-900">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- install tailwindcss from cdn, don't do this for production application -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- install pyodide version 0.20.0 -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
<!-- import codemirror stylings -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.48.4/codemirror.min.css" />
<!-- install codemirror.js version /5.63.3 from cdn -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.3/codemirror.min.js"
integrity="sha512-XMlgZzPyVXf1I/wbGnofk1Hfdx+zAWyZjh6c21yGo/k1zNC4Ve6xcQnTDTCHrjFGsOrVicJsBURLYktVEu/8vQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<!-- install codemirror python language support -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.3/mode/python/python.min.js"
integrity="sha512-/mavDpedrvPG/0Grj2Ughxte/fsm42ZmZWWpHz1jCbzd5ECv8CB7PomGtw0NAnhHmE/lkDFkRMupjoohbKNA1Q=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<!-- import codemirror dracula theme styles from cdn -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.3/theme/dracula.css"/>
<style> /* set codemirror ide height to 100% of the textarea */ .CodeMirror { height: 100%; } </style>
</head>
<body class="h-full overflow-hidden max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
<p class="text-slate-200 text-3xl my-4 font-extrabold mx-2 pt-8">Run Python in your browser</p>
<div class="h-3/4 flex flex-row">
<div class="grid w-2/3 border-dashed border-2 border-slate-500 mx-2">
<!-- our code editor, where codemirror renders it's editor -->
<textarea id="code" name="code" class="h-full"></textarea>
</div>
<div class="grid w-1/3 border-dashed border-2 border-slate-500 mx-2">
<!-- output section where we show the stdout of the python code execution -->
<textarea readonly class="p-8 text-slate-200 bg-slate-900" id="output" name="output"></textarea>
</div>
</div>
<!-- run button to pass the code to pyodide.runPython() -->
<button onclick="evaluatePython()" type="button" class="mx-2 my-4 h-12 px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm bg-green-700 hover:bg-green-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-700 text-slate-300">Run</button>
<!-- clean the output section -->
<button onclick="clearHistory()" type="button" class="mx-2 my-4 h-12 px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm bg-red-700 hover:bg-red-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-700 text-slate-300">Clear History</button>
<script src="/static/js/main.js"></script>
</body>
</html>`
在index.html文件的头部,我们导入了用于样式的 Tailwind CSS、Pyodide.js 版本0.20.0
,以及 CodeMirror 及其依赖项。
UI 有三个重要的组件:
- 编辑器:用户可以在这里编写 Python 代码。这是一个带有
code
的id
的textarea
HTML 元素。当我们初始化codemirror
时,我们让它知道我们想要使用这个元素作为代码编辑器。 - 输出:显示代码的输出。这是一个带有
output
的id
的textarea
元素。Pyodide 执行 Python 代码时,会将结果输出到这个元素。我们也在这个元素中显示了一个错误消息。 - 运行按钮:当用户点击这个按钮时,我们获取编辑器元素的值,并将其作为字符串传递给
pyodide.runPython
。当pyodide.runPython
返回结果时,我们在输出元素中显示它。
现在在项目的根目录下,创建“static/js”文件夹。然后,在“js”文件夹下,创建一个名为 main.js 的新文件。
static/js/main.js :
`// find the output element const output = document.getElementById("output"); // initialize codemirror and pass configuration to support Python and the dracula theme const editor = CodeMirror.fromTextArea( document.getElementById("code"), { mode: { name: "python", version: 3, singleLineStringErrors: false, }, theme: "dracula", lineNumbers: true, indentUnit: 4, matchBrackets: true, } ); // set the initial value of the editor editor.setValue("print('Hello world')"); output.value = "Initializing...\n"; // add pyodide returned value to the output function addToOutput(stdout) { output.value += ">>> " + "\n" + stdout + "\n"; } // clean the output section function clearHistory() { output.value = ""; } // init pyodide and show sys.version when it's loaded successfully async function main() { let pyodide = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/v0.20.0/full/", }); output.value = pyodide.runPython(`
import sys
sys.version
`); output.value += "\n" + "Python Ready !" + "\n"; return pyodide; } // run the main function let pyodideReadyPromise = main(); // pass the editor value to the pyodide.runPython function and show the result in the output section async function evaluatePython() { let pyodide = await pyodideReadyPromise; try { pyodide.runPython(`
import io
sys.stdout = io.StringIO()
`); let result = pyodide.runPython(editor.getValue()); let stdout = pyodide.runPython("sys.stdout.getvalue()"); addToOutput(stdout); } catch (err) { addToOutput(err); } }`
在此,我们:
- 初始化 CodeMirror,支持 Python 和吸血鬼主题。
- 已初始化 Pyodide。
- 添加了一个名为
evaluatePython
的函数,当用户点击Run
按钮时执行。它将code
元素的值传递给pyodide.runPython
,并通过addToOutput
在output
元素中显示结果。 - 添加了一个名为
clearHistory
的函数,当用户点击Clear History
按钮时,该函数清除output
元素。
要在本地运行 Flask development server,请运行:
服务器现在应该运行在端口 5000 上。在浏览器中导航到 http://127.0.0.1:5000 来测试代码编辑器。
结论
在本教程中,我们仅仅触及了 Pyodide 和 WebAssembly 的冰山一角。我们看到了如何使用 WebAssembly 在浏览器中运行 Python 代码,但是 WebAssembly 通常涵盖更广泛的用例。
我们的部署平台比以往更加多样化,我们根本负担不起不断为多个平台重写软件的时间和金钱。WebAssembly 可以影响客户端 web 开发、服务器端开发、游戏、教育、云计算、移动平台、物联网、无服务器等等。
WebAssembly 的目标是交付快速、安全、可移植和紧凑的软件。
在这里可以找到代码 repo 。
Python 教程
描述
Python 是一种开源的通用高级编程语言。它的灵活性允许你用它做大大小小的事情。从简单的脚本到复杂的大规模企业应用程序,它都可以使用。它通常用于:
- 后端 web 开发
- 人工智能和机器学习
- 数据分析和可视化
- Web 抓取和爬行
- 桌面图形界面
- 自动化、测试和部署
也就是说,它的社区使它在其他编程语言中真正脱颖而出。有大量的开源工具和库供您使用。还有大量的教程和其他社区维护的资源。
关于 TestDriven.io 的教程和文章更偏向于中高级,侧重于使用测试驱动开发(TDD)开发基于 Python 的应用程序、编写干净的代码以及利用并发性和并行性。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2023 年 2 月 10 日
常用的 web 身份验证方法。
本文研究了为什么应该记录 Python 代码,以及如何用 Sphinx 和 OpenAPI 生成项目文档。
本文着眼于什么是类型提示,以及它们如何为您带来好处。我们还将深入探讨如何使用 Python 的类型系统进行类型检查。
- 由 发布尼克·托马齐奇
- 最后更新于2022 年 11 月 8 日
了解什么是最好的 Heroku 替代方案(及其利弊)。
查看 Python 3.11 中的新特性以及如何实现它们。
使用 pytest 测试 Python 代码的基础知识。
用 PyPI server 和 Docker 设置自己的私有 PyPI 服务器。
- 发帖者 发帖者阿玛尔沙姬
- 最后更新于2022 年 7 月 5 日
本教程着眼于如何通过多处理、线程和异步来加速 CPU 绑定和 IO 绑定操作。
提高 Pyodide 代码的可维护性,用 PouchDB 增加一个持久数据层。
将 web workers 配置为使用 Pyodide,并在浏览器中使用 Pandas 分析数据。
用 Python 和 Pyodide 构建单页面应用。
本文着眼于如何配置 GitHub 操作来将 Python 包分发到 PyPI 并阅读文档。
使用 WebAssembly (WASM)、via Pyodide 和 CodeMirror 在浏览器中构建 Python 代码编辑器。
使用 linters、代码格式化程序和安全漏洞扫描器提高 Python 代码的质量。
本文着眼于 Python 中用于依赖性和工作空间管理的可用工具。
对 TDD 如何工作感兴趣?本指南将引导您从头到尾使用现代工具和技术完成整个过程。
- 发帖者 郄佳朝 Medlin
- 最后更新于2022 年 1 月 18 日
详细介绍了 Python 中的并发和并行编程,并展示了使用多线程、concurrent.futures 和 asyncio 的实际例子。
着眼于如何使用依赖注入来分离和改进 Python 应用程序的设计
如何使用 Python 多重处理库和 Redis 实现几个异步任务队列?
本文着眼于如何使用 Bazel 来创建可重复的、密封的构建。
查看测试驱动开发如何提高代码质量的示例。
查看 Python 3.10 中的新特性以及如何实现它们。
如何用 Python 写干净的代码?
本文首先概述了 Oauth2 的概念,然后研究了如何用 OAuthLib 实现 OAuth2。
本文着眼于一些有助于简化 Python 测试的工具和技术。
查看 Python 3.9 中的新特性以及如何实现它们。
Python 3.10:新特性
Python 3.10 发布于2021 年10 月 4 日。本文着眼于 Python 语言中最有趣的新增内容,这些内容将有助于使您的 Python 代码更加整洁:
- 结构模式匹配
- 带括号的上下文管理器
- 更清晰的错误消息
- 改进的类型注释
- 拉链的严格论证
安装 Python 3.10
如果您有 Docker,您可以快速构建一个 Python 3.10 shell 来使用本文中的示例,如下所示:
`$ docker run -it --rm python:3.10`
不用 Docker?我们建议用 pyenv 安装 Python 3.10:
从Modern Python Environments-dependency and workspace management文章中,您可以了解更多关于使用 pyenv 管理 Python 的信息。
结构模式匹配
在所有的新特性中,结构模式匹配最受关注,也是最有争议的。它在 Python 语言中引入了一个match/case
语句,看起来非常像其他编程语言中的switch/case
语句。使用结构模式匹配,您可以根据一个或多个模式测试对象,以确定比较对象的结构是否与给定的模式之一匹配。
快速示例:
`code = 404
match code:
case 200:
print("OK")
case 404:
print("Not found")
case 500:
print("Server error")
case _:
print("Code not found")`
图案可以是:
- 文字
- 捕获
- 通配符
- 价值观念
- 组
- 顺序
- 绘图
- 班
上面的例子使用了文字模式。
想去掉这个例子中的幻数吗?像这样利用值模式:
`from http import HTTPStatus
code = 404
match code:
case HTTPStatus.OK:
print("OK")
case HTTPStatus.NOT_FOUND:
print("Not found")
case HTTPStatus.INTERNAL_SERVER_ERROR:
print("Server error")
case _:
print("Code not found")
# => "Not found"`
您也可以使用或模式组合多个模式:
`from http import HTTPStatus
code = 400
match code:
case HTTPStatus.OK:
print("OK")
case HTTPStatus.NOT_FOUND | HTTPStatus.BAD_REQUEST:
print("You messed up")
case HTTPStatus.INTERNAL_SERVER_ERROR | HTTPStatus.BAD_GATEWAY:
print("Our bad")
case _:
print("Code not found")
# => "You messed up"`
结构化模式匹配与其他语言中的switch/case
语法的不同之处在于,您可以解包复杂的数据类型,并基于结果数据执行操作:
`point = (0, 10)
match point:
case (0, 0):
print("Origin")
case (0, y):
print(f"Y={y}")
case (x, 0):
print(f"X={x}")
case (x, y):
print(f"X={x}, Y={y}")
case _:
raise ValueError("Not a point")
# => Y=10`
这里,值(10
)从主题((0, 10)
)绑定到案例内部的变量。这是捕获模式。
你可以用类模式实现几乎同样的事情:
`from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
point = Point(10, 10)
match point:
case Point(x=0, y=0):
print("Origin")
case Point(x=0, y=y):
print(f"On Y axis with Y={y}")
case Point(x=x, y=0):
print(f"On X axis with X={x}")
case Point(x=x, y=y):
print(f"Somewhere in a X, Y plane with X={x}, Y={y}")
case _:
print("Not a point")
# => Somewhere in a X, Y plane with X=10, Y=10`
您可以使用保护符来添加 if 子句,如下所示:
`from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
point = Point(7, 0)
match point:
case Point(x, y) if x == y:
print(f"The point is located on the diagonal Y=X at {x}.")
case Point(x, y):
print(f"Point is not on the diagonal.")
# => Point is not on the diagonal.`
因此,当 guard 为False
时,将评估下一个案例。
值得注意的是,
match
和case
是软关键字,所以你仍然可以在你现有的代码中使用它们作为变量名。
更多信息,请参考正式文件以及相关 pep:
带括号的上下文管理器
使用上下文管理器时,Python 现在支持跨多行的延续:
`with (
CtxManager1() as ctx1,
CtxManager2() as ctx2
):`
在以前的版本中,你必须把所有东西都放在同一行或者嵌套with
语句。
Python < 3.10:
`import unittest
from unittest import mock
from my_module import my_function
class Test(unittest.TestCase):
def test(self):
with mock.patch("my_module.secrets.token_urlsafe") as a, mock.patch("my_module.string.capwords") as b, mock.patch("my_module.collections.defaultdict") as c:
my_function()
a.assert_called()
b.assert_called()
c.assert_called()
def test_same(self):
with mock.patch("my_module.secrets.token_urlsafe") as a:
with mock.patch("my_module.string.capwords") as b:
with mock.patch("my_module.collections.defaultdict") as c:
my_function()
a.assert_called()
b.assert_called()
c.assert_called()`
Python >= 3.10:
`class Test(unittest.TestCase):
def test(self):
with (
mock.patch("my_module.secrets.token_urlsafe") as a,
mock.patch("my_module.string.capwords") as b,
mock.patch("my_module.collections.defaultdictl") as c,
):
my_function()
a.assert_called()
b.assert_called()
c.assert_called()`
值得注意的是,在 Python 3.9 中,Python 切换到基于 PEG 的解析器支持了这个特性。在可预见的未来,我们应该会在每个新的 Python 版本中看到像这样的新特性和变化,这应该 (1)导致更优雅的语法和(2)让所有人感到不安。
更多信息:
更清晰的错误消息
Python 3.10 改进了错误消息,提供了关于错误和错误实际发生位置的更精确的信息。
例如,在 Python 3.10 之前,如果您缺少了一个右括号}
`import datetime
expected = {'Jan', 'Mike', 'Marry',
today = datetime.datetime.today()`
-您将看到以下错误消息:
`File "example.py", line 5
today = datetime.datetime.today()
^
SyntaxError: invalid syntax`
使用 Python 3.10,您将看到:
`File "example.py", line 3
expected = {'Jan', 'Mike', 'Marry',
^
SyntaxError: '{' was never closed`
正如您所看到的,使用新的错误消息更容易发现实际问题。这对初学者特别有帮助,使其更容易识别错误的真正原因
- 缺少关键字
- 不正确或拼写错误的关键字或变量名
- 缺少冒号
- 不正确的缩进
- 缺少右括号或大括号
更多信息请参考官方文件。
看起来 Python 3.11 也将发布对错误消息的另一个改进。
改进的类型注释
Python 3.10 提供了许多与类型注释相关的改进:
- PEP 604:允许将联合类型写成 X | Y
- PEP 613:显式类型别名
- PEP 647:用户定义的类型保护
- PEP 612:参数规范变量
联合运算符
首先,您还可以使用一个新的类型联合操作符|
,这样您就可以表示 X 或 Y 类型,而不必从typing
模块导入Union
:
`# before
from typing import Union
def sum_xy(x: Union[int, float], y: Union[int, float]) -> Union[int, float]:
return x + y
# after
def sum_xy(x: int | float, y: int | float) -> int | float:
return x + y`
更多信息:
键入别名
另一个好处是显式定义类型别名的能力。静态类型检查器和其他开发人员有时会在区分变量赋值和类型别名方面遇到问题。
例如:
`StrCache = "Cache[str]" # a type alias
LOG_PREFIX = "LOG[DEBUG]" # a module constant`
在 Python 3.10 中,可以使用TypeAlias
显式定义类型别名:
`StrCache: TypeAlias = "Cache[str]" # a type alias
LOG_PREFIX = "LOG[DEBUG]" # a module constant`
这将为类型检查者和阅读您代码的其他开发人员理清思路。
更多信息:
防护类型
类型保护有助于类型收缩,这是将类型从不太精确的类型(基于其定义)移动到更精确的类型(在程序的代码流中)的过程。
以下面两种风格的is_employee
为例:
`# without type guards
def is_employee(user: User) -> bool:
return isinstance(user, Employee)
# with type guards
from typing import TypeGuard
def is_employee(user: User) -> TypeGuard[Employee]:
return isinstance(user, Employee)`
因此,使用第二种风格的is_employee
,当它返回True
时,类型检查器将能够把user
从User
缩小到Employee
。
更多信息:
参数规格变量
为了支持更高阶函数(例如 decorators)的真正注释,Python 3.10 增加了typing.ParamSpec
和typing.Concatenate
。
例如(从 PEP-612 ):
`from typing import Awaitable, Callable, TypeVar
R = TypeVar("R")
def add_logging(f: Callable[..., R]) -> Callable[..., Awaitable[R]]:
async def inner(*args: object, **kwargs: object) -> R:
await log_to_database()
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
await takes_int_str(1, "A")
await takes_int_str("B", 2) # fails at runtime`
在这个例子中,当使用不正确的参数调用修饰函数时,您的代码将在运行时失败。
现在,您可以通过使用typing.ParamSpec
来强制参数类型:
`from typing import Awaitable, Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def add_logging(f: Callable[P, R]) -> Callable[P, Awaitable[R]]:
async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
await log_to_database()
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
await takes_int_str(1, "A") # Accepted
await takes_int_str("B", 2) # Correctly rejected by the type checker`
类型检查器,比如 mypy ,会在分析代码时捕捉错误。
更多信息:
注释的延期评估
不是在函数定义时评估注释,注释的延迟评估建议将它们作为字符串保存在内置的 annotations 字典中。在运行时利用注释的工具将需要通过typing.get_type_hints()
显式地评估注释,而不是依赖已经被评估的注释。
这原本是 Python 3.10 的一部分,但是由于 Python 的作用域规则,像 pydantic 这样的工具很难利用typing.get_type_hints()
来获得类型。所以, Python 指导委员会决定推迟改变。我们可能会在 Python 3.11 中看到。
更多信息:
- PEP 563,PEP 649,以及 Python 类型注释的未来
- 重要提示:PEP 563、PEP 649 和 pydantic 的未来
- FastAPI 和 Pydantic 的未来一片光明
- PEP-563
拉链的严格论证
当你试图压缩两个长度不同的可重复项时会发生什么?
`names = ["Jan", "Mike", "Marry", "Daisy"]
grades = ["B+", "A", "A+"]
for name, grade in zip(names, grades):
print(name, grade)
"""
Jan B+
Mike A
Marry A+
"""`
当到达较短的 iterable 末尾时,迭代停止。
Python 3.10 引入了一个新的strict
关键字参数,以确保在运行时所有的 iterables 都具有相同的长度:
`names = ["Jan", "Mike", "Marry", "Daisy"]
grades = ["B+", "A", "A+"]
for name, grade in zip(names, grades, strict=True):
print(name, grade)
# ValueError: zip() argument 2 is shorter than argument 1`
参考 PEP-618 了解更多信息。
其他更新和优化
distutils 包是弃用的,将在 Python 3.12 中被完全移除。其功能存在于设置工具和包装中。
最后,Python 3.10 引入了几个导致性能提高的优化。对于小对象,构造函数str()
、bytes()
和bytearray()
要快 30%到 40% 。 runpy 模块现在导入更少的模块,所以python -m module-name
平均快 1.4 倍。
结论
如您所见,Python 3.10 带来了许多新特性。一些旧的,很少使用的功能已经贬值或完全删除。本文只是简单介绍了该语言的新特性和变化。请务必查看所有更改的官方发行说明:Python 3.10 中的新特性。
快乐的蟒蛇!
Python 3.11:新特性
Python 3.11 发布于2021 年10 月 24 日。本文着眼于 Python 语言中最有趣的新增内容,这些内容将有助于使您的 Python 代码更加整洁:
- 更快的 CPython
- 改进的类型提示
- 更好的错误消息
- 异常注释
- TOML 库
安装 Python 3.11
如果您有 Docker,您可以快速构建一个 Python 3.11 shell 来使用本文中的示例,如下所示:
`$ docker run -it --rm python:3.11`
不用 Docker?我们建议用 pyenv 安装 Python 3.11:
从Modern Python Environments-dependency and workspace management文章中,您可以了解更多关于使用 pyenv 管理 Python 的信息。
更快的 CPython
Python 3.11 比以往任何时候都快!正如发布说明中所说,Python 3.11 比 Python 3.10 快 10 - 60%。平均来说,要快 25%。Python 的核心开发人员在许多方面都做了很好的工作,改善了执行时间。
更快启动
启动方面,不是读取__pycache__
- >解组- >堆分配的代码对象- >求值,Python 3.11 使用了新的进程。Python 解释器启动所必需的核心模块现在被冻结在解释器中——它们的代码是静态分配的。新的流程是静态分配代码对象- >评估。后者快 10 - 15%。
更快的运行时间
每次在 Python 中调用函数时,都会创建一个框架。它保存关于函数执行的信息。核心开发人员简化了他们的创建过程、内部信息和内存分配。另一个改进发生在内联函数调用上。从现在开始,大多数 Python 函数调用都不消耗 C 堆栈空间。
也许在这方面最重要的改进是 PEP 659:专门化自适应解释器。这个 PEP 是 fast Python 的关键元素之一。它的主要思想是 Python 代码有类型很少改变的区域。在这些区域中,当 Python 发现某些操作总是只针对特定类型的数据时,它可以通过使用更专门化的类型来优化操作。例如,如果只使用整数,它可以对整数使用乘法,而不是使用一般的乘法。另一个例子是直接调用底层 C 实现的常见内置函数,如len
和str
,而不是通过内部调用约定。
您可以通过检查生成的字节码来观察这一点:
`import dis
from random import random
def dollars_to_pounds(dollars):
return 0.87 * dollars
dis.dis(dollars_to_pounds, adaptive=True)
# 5 0 RESUME 0
#
# 6 2 LOAD_CONST 1 (0.87)
# 4 LOAD_FAST 0 (dollars)
# 6 BINARY_OP 5 (*)
# 10 RETURN_VALUE
for _ in range(8):
dollars_to_pounds(random() * 100)
dis.dis(dollars_to_pounds, adaptive=True)
# 5 0 RESUME_QUICK 0
#
# 6 2 LOAD_CONST__LOAD_FAST 1 (0.87)
# 4 LOAD_FAST 0 (dollars)
# 6 BINARY_OP_MULTIPLY_FLOAT 5 (*) # <-- CHANGED!
# 10 RETURN_VALUE`
正如您所看到的,在使用 float 调用了 8 次dollars_to_pounds
之后,bytcode 得到了优化。它没有使用BINARY_OP
,而是使用了专门的BINARY_OP_MULTIPLY_FLOAT
,在乘以浮点数时速度更快。如果你用一个旧的 Python 版本运行同样的东西,不会有任何不同。
更多信息,请参考官方文档以及相关的 PEP 659 -专业自适应解释器。
改进的类型提示
与前几个版本一样,在类型提示方面有所改进。
自身类型
Python 终于支持了一个Self
类型。因此,现在您可以轻松地键入类方法和 dunder 方法:
`from typing import Self
from dataclasses import dataclass
@dataclass
class Car:
manufacture: str
model: str
@classmethod
def from_dict(cls, car_data: dict[str, str]) -> Self:
return cls(manufacture=car_data["manufacture"], model=car_data["model"])
print(Car.from_dict({"manufacture": "Alfa Romeo", "model": "Stelvio"}))
# Car(manufacture='Alfa Romeo', model='Stelvio')`
更多信息:
TypedDict 不需要
另一个改进是用于类型化词典的NotRequired
类型:
`from typing import TypedDict, NotRequired
class Car(TypedDict):
manufacture: str
model: NotRequired[str]
car1: Car = {"manufacture": "Alfa Romeo", "model": "Stelvio"} # OK
car2: Car = {"manufacture": "Alfa Romeo"} # model (model is not required)
car3: Car = {"model": "Stelvio"} # ERROR (missing required field manufacture)`
更多信息:
文字字符串类型
还有一个新的LiteralString
类型,允许文字字符串和从其他文字字符串创建的字符串。这可用于在执行 SQL 和 shell 命令时进行类型检查,以增加防止注入攻击的另一层安全性。
举例:
`def run_query(sql: LiteralString) -> ...
...
def caller(
arbitrary_string: str,
query_string: LiteralString,
table_name: LiteralString,
) -> None:
run_query("SELECT * FROM students") # ok
run_query(query_string) # ok
run_query("SELECT * FROM " + table_name) # ok
run_query(arbitrary_string) # type checker error
run_query( # type checker error
f"SELECT * FROM students WHERE name = {arbitrary_string}"
)`
更多信息:
--
还有一些与类型提示相关的其他改进。你可以在官方发行说明中读到它们。
更好的错误消息
令人兴奋的新特性之一是更具描述性的回溯。Python 3.10 引入了更好、更具描述性的错误,带来了一些改进。Python 3.11 更进了一步,改进了对确切错误位置的描述。让我们看一个例子。
有语法错误的代码:
`def average_grade(grades):
return sum(grades) / len(grades)
average_grade([])`
Python 3.10 中的错误:
`return sum(grades) / len(grades)
ZeroDivisionError: division by zero`
Python 3.11 中的错误:
`return sum(grades) / len(grades)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero`
来点更复杂的怎么样?
Python 3.10:
`def send_email_to_contact(contact, subject, content):
print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
contact = {
"first_name": "Lightning",
"last_name": "McQueen",
"emails": [
{
"address": "[[email protected]](/cdn-cgi/l/email-protection)",
"display_name": "Lightning McQueen"
}
]
}
send_email_to_contact(contact, "Hello", "Hello there! Long time no see.")
# print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
# TypeError: list indices must be integers or slices, not str
send_email_to_contact({}, "Hello", "Hello there! Long time no see.")
# print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
# KeyError: 'emails'
send_email_to_contact({"emails": {"address": "[[email protected]](/cdn-cgi/l/email-protection)"}}, None, "Hello there! Long time no see.")
# print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
# AttributeError: 'NoneType' object has no attribute 'title'`
Python 3.11:
`def send_email_to_contact(contact, subject, content):
print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
contact = {
"first_name": "Lightning",
"last_name": "McQueen",
"emails": [
{
"address": "[[email protected]](/cdn-cgi/l/email-protection)",
"display_name": "Lightning McQueen"
}
]
}
send_email_to_contact(contact, "Hello", "Hello there! Long time no see.")
# print(f"Sending email to {contact['emails']['address']} with {subject=} and {content=}")
# ~~~~~~~~~~~~~~~~~^^^^^^^^^^^
# TypeError: list indices must be integers or slices, not str
send_email_to_contact({}, "Hello", "Hello there! Long time no see.")
# print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
# ~~~~~~~^^^^^^^^^^
# KeyError: 'emails'
send_email_to_contact({"emails": {"address": "[[email protected]](/cdn-cgi/l/email-protection)"}}, None, "Hello there! Long time no see.")
# print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
# ^^^^^^^^^^^^^
# AttributeError: 'NoneType' object has no attribute 'title'`
正如您所看到的,通过新的回溯可以更容易地找到错误所在。异常行中的确切问题点被很好地标记出来。在旧版本中,您只能看到异常本身和引发异常的行。
更多信息:
异常注释
add_note
被添加到BaseExceptions
。这允许您在创建异常后向其添加额外的上下文。例如:
`try:
raise ValueError()
except ValueError as exc:
exc.add_note("When this happened my dog was barking and my kids were sleeping.")
raise
# raise ValueError()
# ValueError
# When this happened my dog was barking and my kids were sleeping.`
更多信息:
TOML 库
Python 现在有了一个用于解析 TOML 文件的库,名为 tomllib 。它的用法和内置的json
库非常相似。
例如,假设您有下面的 pyproject.toml 文件:
`[tool.poetry] name = "example" version = "0.1.0" description = "" authors = [] [tool.poetry.dependencies] python = "^3.11" [tool.poetry.dev-dependencies] [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api"`
您可以像这样加载文件:
`import pprint
import tomllib
with open("pyproject.toml", "rb") as f:
data = tomllib.load(f)
pp = pprint.PrettyPrinter(depth=4)
pp.pprint(data)
"""
{'build-system': {'build-backend': 'poetry.core.masonry.api',
'requires': ['poetry-core>=1.0.0']},
'tool': {'poetry': {'authors': [],
'dependencies': {'python': '^3.11'},
'description': '',
'dev-dependencies': {},
'name': 'example',
'version': '0.1.0'}}}
"""`
更多信息:
结论
如您所见,Python 3.11 带来了许多令人兴奋的改进和特性。另一方面,一些遗留模块被弃用,另一些则被完全删除。因为这篇文章只涵盖了最有趣的新特性和变化,所以请务必查看所有变化的官方发布说明。
快乐的蟒蛇!
Python 3.9:新特性
Python 3.9 于 2020 年 10 月 5 日发布,没有带来任何重大的新特性,但仍然有一些重大的变化,特别是在语言的开发和发布方面。
开发周期
这是来自新发布理念的第一个 Python 版本,其中主要发布每 12 个月(10 月)而不是每 18 个月进行一次。随着发布越来越频繁,变化应该越来越小——这正是我们在 Python 3.9 中看到的。
所有新版本都有 1.5 年的全面支持和 3.5 年的安全修复。
更多信息:https://www.python.org/dev/peps/pep-0602/
词典联盟
合并词典
我们现在有一个用于执行字典联合的合并操作符:|
。它的工作方式与a.update(b)
或{**a, **b}
相同,只有一点不同:它适用于dict
子类的任何实例。
如果你有 Docker,你可以通过
docker run -it --rm python:3.9
快速构建一个 Python 3.9 shell 来使用本文中的例子。
您可以像这样合并两个字典:
`user = {'name': 'John', 'surname': 'Doe'}
address = {'street': 'Awesome street 42', 'city': 'Huge city', 'post': '420000'}
user_with_address = user | address
print(user_with_address)
# {'name': 'John', 'surname': 'Doe', 'street': 'Awesome street 42', 'city': 'Huge city', 'post': '420000'}`
现在,我们有了一个新的字典叫做user_with_address
,它是user
和address
的结合。
如果字典中有重复的键,那么输出将显示第二个(最右边的)键-值对:
`user_1 = {'name': 'John', 'surname': 'Doe'}
user_2 = {'name': 'Joe', 'surname': 'Doe'}
users = user_1 | user_2
print(users)
# {'name': 'Joe', 'surname': 'Doe'}`
运算符的意思是并集,而不是或。它不做任何位运算,也不充当逻辑运算符。它用于创建两个字典的并集。因为它看起来类似于其他语言中的 or 条件,所以您可能希望在代码评审期间仔细检查它是如何使用的。
更新词典
至于更新,现在有了操作符|=
。这在适当的地方工作。
您可以用第二个字典中的键和值更新第一个字典,如下所示:
`grades = {'John': 'A', 'Marry': 'B+'}
grades_second_try = {'Marry': 'A', 'Jane': 'C-', 'James': 'B'}
grades |= grades_second_try
print(grades)
# {'John': 'A', 'Marry': 'A', 'Jane': 'C-', 'James': 'B'}`
它适用于任何带有keys
和__getitem__
的对象或者带有键值对的可迭代对象:
`# example 1
grades = {'John': 'A', 'Marry': 'B+'}
grades_second_try = [('Marry', 'A'), ('Jane', 'C-'), ('James', 'B')]
grades |= grades_second_try
print(grades)
# {'John': 'A', 'Marry': 'A', 'Jane': 'C-', 'James': 'B'}
# example 2
x = {0: 0, 1: 1}
y = ((i, i**2) for i in range(2,6))
x |= y
print(x)
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# example 3
x | y
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for |: 'dict' and 'generator'`
更多信息:https://www.python.org/dev/peps/pep-0584/
生成随机字节
random 库现在可以用来通过 randbytes 生成随机字节。
例如,要生成十个随机字节:
`import random
print(random.Random().randbytes(10))
b'CO\x0e\x0e~\x12\x0c\xa4\xa0p'`
更多信息:https://bugs.python.org/issue40286
字符串方法
向str
对象添加了两个方法:
移除前缀
第一种方法从另一个字符串的开头删除输入的字符串。
例如:
`file_name = 'DOCUMENT_001.pdf'
print(file_name.removeprefix('DOCUMENT_'))
# 001.pdf`
如果字符串不是以输入字符串开头,将返回原始字符串的副本:
`file_name = 'DOCUMENT_001.pdf'
print(file_name.removeprefix('DOC_'))
# DOCUMENT_001.pdf`
删除后缀
类似地,我们可以用第二种方法从选中的字符串中删除后缀。
要从文件名中删除文件扩展名.pdf
:
`file_name = 'DOCUMENT_001.pdf'
print(file_name.removesuffix('.pdf'))
# DOCUMENT_001
file_name = 'DOCUMENT_001.pdf'
print(file_name.removesuffix('.csv'))
# DOCUMENT_001.pdf`
更多信息:https://www.python.org/dev/peps/pep-0616/
IANA 时区支持
添加了 zoneinfo 模块来支持 IANA 时区数据库。
例如,要创建支持时区的时间戳,可以向 datetime 方法添加tz
或tzinfo
参数:
`import datetime
from zoneinfo import ZoneInfo
datetime.datetime(2020, 10, 7, 1, tzinfo=ZoneInfo('America/Los_Angeles'))
# datetime.datetime(2020, 10, 7, 1, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles'))`
您也可以轻松地在时区之间转换:
`import datetime
from zoneinfo import ZoneInfo
start = datetime.datetime(2020, 10, 7, 1, tzinfo=ZoneInfo('America/Los_Angeles'))
start.astimezone(ZoneInfo('Europe/London'))
datetime.datetime(2020, 10, 7, 9, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London'))`
更多信息:https://www.python.org/dev/peps/pep-0615/
泛型类型批注
从现在开始,您可以对类型注释使用泛型类型。您可以使用list
或dict
内置集合类型作为泛型类型,而不必使用typing.List
或typing.Dict
`def sort_names(names: list[str]):
return sorted(names)`
Python 是动态类型化的,类型是动态推断的。由于这并不总是可取的,类型提示可以用来指定类型。这是在 Python 3.5 中引入的。能够将内置集合类型用作泛型类型极大地简化了类型提示。
更多信息:https://www.python.org/dev/peps/pep-0585/
取消并行期货
一个名为cancel_futures
的新参数被添加到concurrent.futures.Executor.shutdown()
中。当设置为True
时,取消所有未开始运行的未决期货。在 3.9 版本之前,进程会在关闭执行器之前等待它们完成。
更多信息:https://bugs.python.org/issue30966
导入错误
在以前的版本中,__import__
和importlib.util.resolve_name()
在相对导入通过其顶层包时引发ValueError
。现在您将得到一个ImportError
,它更好地描述了正在处理的情况。
更多信息:https://bugs.python.org/issue37444
字符串替换修复
修复了空字符串替换的问题。
在以前的版本中:
`"".replace("", "prefix", 1)
# ''`
从现在开始:
`"".replace("", "prefix", 1)
# 'prefix'`
更多信息:https://bugs.python.org/issue28029
新解析器
引入了一个新的更加灵活的基于 PEG (解析表达式语法)的解析器。虽然您可能不会注意到,但这是 Python 这个版本最显著的变化。
基于 PEG 的解析器的性能可与旧的 LL(1) (从左到右解析器)相媲美,但它更灵活的形式主义应该更容易设计新的语言特性。
更多信息:https://www.python.org/dev/peps/pep-0617
表演
最后,Python 3.8 中引入的 vectorcall 协议现在已经扩展到了几个内置的,包括 range、tuple、set、frozenset、list 和 dict。简而言之,vectorcall 通过减少为调用创建的临时对象的数量来减少开销,从而使许多常见的函数调用变得更快。
更多信息:https://docs . python . org/3.9/c-API/call . html # the-vector call-protocol
结论
这篇文章仅仅触及了语言的主要变化。完整的变更列表可以在这里找到。
编码快乐!
react Hooks——以 useContext 和 useReducer 为特色的更深入的探讨
本文的主要目的是了解useContext
和useReducer
以及它们如何一起工作来使 React 应用程序及其状态管理变得干净和高效。
新的 Hooks API 允许跨 React 应用访问一些惊人的特性,这可能会消除对状态管理库的需要,如 Redux 或 Flux。更好的是,我们可以用功能组件来实现这一点。不需要类组件,只需要 JavaScript。
首先,我们将对 React 应用程序中的“预挂钩”上下文 API、最佳实践和实现进行概述。一旦奠定了基础并描绘了一个示例,该示例将被重构以实现useContext
。从useContext
开始,重点关注围绕 reducer 函数的概念,这将导致useReducer
的实现以及如何使用它来管理复杂状态。在对这两个钩子有了深刻的理解之后,我们将把它们结合起来创建一个只支持 React 的状态管理工具。
这是两部分系列的第二部分:
- 反应钩上的底漆
- (本文)React Hooks——以 useContext 和 useReducer 为特色的深入探讨
观众
这是面向 React 开发人员的中级教程,对以下内容有基本了解:
- 反应数据流
useState
和useEffect
挂钩- 全局状态管理工具和模式(如 Redux 和 Flux)
- React 上下文 API
如果你是 React 钩子的新手,看看 React 钩子上的底漆。
学习目标
本教程结束时,您将能够:
- 解释什么是语境
- 确定何时应实施上下文
- 通过
useContext
钩子实现上下文 - 确定何时应该实施
useReducer
- 实现
useReducer
组件状态管理 - 利用
useReducer
和useContext
作为应用状态管理工具 - 通过实现钩子来简化 React
项目概述
演示的项目将是一个“race series”管理工具,到本文结束时,它将为应用程序的状态管理实现useContext
和useReducer
。本文开头的项目的当前状态没有使用任何状态管理库,也没有实现上下文或钩子 API。它只是通过道具传递逻辑,并利用 React 的渲染道具。本文不会对整个项目进行分解,而是关注于实现状态管理的useContext
和useReducer
所必需的重构部分。
对于完整的项目访问它的回购 这里 。
什么是赛事系列管理工具?
简单来说,一个比赛系列有多个比赛和多个参与者(用户)。不是所有的用户都在每个种族。该应用程序将作为一个管理工具,允许管理员访问一个种族列表和一个系列内的用户列表。当管理员登录并选择查看比赛列表时,他们将能够看到该系列中的所有比赛。从列表中选择一个比赛将显示该比赛的注册用户。相反,当选择查看系列时,管理员将能够从用户中进行选择,并查看特定用户正在参加的比赛。
综述:基本钩子
如果您不熟悉 Hooks API,请从这里开始。钩子允许您访问状态和生命周期方法,以及许多其他在功能组件中使用的技巧。不需要写类组件。同样,不需要编写类组件!如果你正在寻找一个坚实的基础,从useState
和useEffect
开始。
-
使用状态 : 允许访问和控制功能组件内的状态。以前,只有基于类的组件才能访问状态。
-
useEffect : 允许抽象访问功能组件内 React 的生命周期方法。您不会按名称调用各个生命周期方法,但是您可以通过更多的功能和更干净的代码获得类似的控制。
示例:
`import React, { useState, useEffect } from 'react'; export const SomeComponent = () => { const [ DNAMatch, setDNAMatch ] = useState(false); const [ name, setName ] = useState(null); useEffect(() => { if (name) { setDNAMatch(true); setName(name); localStorage.setItem('dad', name); } }, [ DNAMatch ]); return ( // ... ); };`
同样,关于基本挂钩的更多细节,请阅读初级读本:React 挂钩初级读本。
使用上下文
在深入上下文挂钩之前,让我们先看一下上下文 API 的概述,以及它在挂钩 API 之前是如何实现的。这将有助于更好地理解useContext
钩子,以及更深入地了解何时应该使用上下文。
上下文 API 概述
React 中一个众所周知的缺陷是访问全局状态对象。当数据需要深入嵌套的组件树(主题、UI 样式或用户授权)时,通过 props 在几个可能需要也可能不需要该数据的组件之间传输数据会非常麻烦;组件变成了数据骡子。首选的解决方案是像 Redux 或 Flux 这样的状态管理库。这些库非常强大,但是在设置、保持有组织性以及知道何时需要实现它们时,可能会有点困难。
这里有一个例子,前上下文 API,道具被偷偷带入子组件的生命深处。
`// NO CONTEXT YET - just prop smuggling import React, { Component, useReducer, useContext } from 'react'; const Main = (props) => ( <div className={'main'}> {/* // Main hires a Component Mule (ListContainer) to smuggle data */} <List isAuthenticated={props.isAuthenticated} toggleAuth={props.toggleAuth} /> </div> ); const List = ({ isAuthenticated, toggleAuth, shake }) => ( isAuthenticated ? ( <div className={'title'} > "secure" list, check. </div >) : ( <div className={'list-login-container'}> {/* // And List hires a Component Mule (AdminForm) to smuggle data */} <AdminForm shake={shake} toggleAuth={toggleAuth} /> </div>) ); class AdminForm extends Component { constructor(props) { super(props); this.state = {}; this.handleSubmit = this.handleSubmit.bind(this); } handleSubmit(event, toggleAuth) { event.preventDefault(); return toggleAuth(true); } render() { return ( <div className={'form-container'}> <form onSubmit={event => this.handleSubmit(event, this.props.toggleAuth)} className={'center-content login-form'} > // ... form logic </form> </div> ); } } class App extends Component { constructor(props) { super(props); this.state = { isAuthenticated: false, }; this.toggleAuth = this.toggleAuth.bind(this); } toggleAuth(isAuthenticated) { return isAuthenticated ? this.setState({ isAuthenticated: true }) : alert('Bad Credentials!'); } render() { return ( <div className={'App'}> <Main isAuthenticated={this.state.isAuthenticated} toggleAuth={this.toggleAuth} > </Main> </div> ); } } export default App;`
实现上下文 API
创建 Context API 是为了解决这个全球数据危机,并阻止数据走私、滥用无辜子组件的道具——如果你愿意,这是一个国家紧急情况。太好了,让我们看看。
下面是使用上下文 API 重构的同一个示例。
`// CONTEXT API import React, { Component, createContext } from 'react'; // We used 'null' because the data we need // resides in App's state. const AuthContext = createContext(null); // You can also destructure above // const { Provider, Consumer } = createContext(null) const Main = (props) => ( <div className={'main'}> <List /> </div> ); const List = (props) => ( <AuthContext.Consumer> auth => { auth ? ( <div className={'list'}> // ... map over some sensitive data for a beautiful secure list </div> ) : ( <div className={'form-container'}> // And List hires a Component Mule to smuggle data <AdminForm /> </div> ) } </AuthContext.Consumer>); class AdminForm extends Component { constructor(props) { super(props); this.state = {}; this.handleSubmit = this.handleSubmit.bind(this); } handleSubmit(event, toggleAuth) { event.preventDefault(); return toggleAuth(true); } render() { return ( <AdminContext.Consumer> {state => ( <div> <form onSubmit={event => this.handleSubmit(event, state.toggleAuth)} className={'center-content login-form'} > // ... form logic </form> </div> )} </AdminContext.Consumer> ); } } class App extends Component { constructor(props) { super(props); this.state = { isAuthenticated: false, }; this.toggleAuth = this.toggleAuth.bind(this); } toggleAuth(isAuthenticated) { this.setState({ isAuthenticated: true }); } render() { return ( <div> <AuthContext.Provider value={this.state.isAuthenticated}> <Main /> </AuthContext.Provider> </div> ); } } export default App;`
随着钩子的引入,这种使用上下文 API 的方法仍然有效。
考虑上下文
在实现上下文时,还需要考虑其他一些事情。上下文 API 将重新呈现作为提供者后代的所有组件。如果你不小心,你可能会在每次点击或击键时重新渲染整个应用程序。解决方案?你打赌!
在提供者中包装组件时要考虑周全。控制台日志不会杀死猫。
创建一个只接受子道具的新组件。将提供程序及其必要的数据移动到该组件中。这使提供者的子道具在渲染之间保持相等。
`// Navigate.js import React, { useReducer, useContext } from 'react'; const AppContext = React.createContext(); const reducer = (state, action) => { switch (action.type) { case 'UPDATE_PATH': return { ...state, pathname: action.pathname, }; default: return state; } }; export const AppProvider = ({ children }) => { const [state, dispatch] = useReducer(reducer, { pathname: window.location.pathname, navigate: (pathname) => { window.history.pushState(null, null, pathname); return dispatch({ type: 'UPDATE_PATH', pathname }); }, }); return ( <AppContext.Provider value={state}> {children} </AppContext.Provider> ); }; export const LinkItem = ({ activeStyle, ...props }) => { const context = useContext(AppContext); console.log(context, 'CONTEXT [[email protected]](/cdn-cgi/l/email-protection)'); return ( <div> <a {...props} style={{ ...props.style, ...(context.pathname === props.href ? activeStyle : {}), }} onClick={(e) => { e.preventDefault(); context.navigate(props.href); }} /> </div> ); }; export const Route = ({ children, href }) => { const context = useContext(AppContext); return ( <div> {context.pathname === href ? children : null} </div> ); };`
如果你在下面的模式中使用上述方法,你将不会有不必要的重新渲染(渲染道具<AppProvider>
里面的一切)。
`// App.js import React, { useContext } from 'react'; import { AppProvider, LinkItem, Route } from './Navigate.js'; export const AppLayout = ({ children }) => ( <div> <LinkItem href="/participants/" activeStyle={{ color: 'red' }}> Participants </LinkItem> <LinkItem href="/races/" activeStyle={{ color: 'red' }}> Races </LinkItem> <main> {children} </main> </div> ); export const App = () => { return ( <AppProvider> <AppLayout> <Route href="/races/"> <h1>Off to the Races</h1> </Route> <Route href="/participants/"> <h1>Off with their Heads!</h1> </Route> </AppLayout> </AppProvider> ); };`
将您的消费元素包装在更高阶的组件中。
`const withAuth = (Component) => (props) => { const context = useContext(AppContext) return ( <div> {<Component {...props} {...context} />} </div> ); } class AdminForm extends Component { // ... } export withAuth(AdminForm);`
Context 永远不会完全取代 Redux 这样的状态管理库。例如,Redux 有助于轻松调试,允许使用中间件进行定制,保持单一状态,并使用connect
遵循纯函数实践。语境很有能力;它可以保存状态,而且功能惊人地多,但是它最适合作为数据的提供者,其中的组件消费。
Render Props 是传递数据的很好的解决方案,但是它可以创建一个有趣的组件树层次结构。想想回调地狱,但与 HTML 标签。如果需要在一个组件树的深处获得数据,可以考虑使用上下文。这也可以提高性能,因为当数据更改时,父组件不会重新呈现。
实现 useContext
useContext
钩子使得消费上下文数据的实现变得容易,并且有助于使组件可重用。为了说明上下文 API 可能带来的困难,我们将展示一个使用多个上下文的组件。在挂钩之前,多上下文消费组件变得难以重用和理解。这就是为什么上下文应该少用的一个原因。
这是上面的一个例子,但是使用了额外的上下文。
`const AuthContext = createContext(null); const ShakeContext = createContext(null); class AdminForm extends Component { // ... render() { return ( // this multiple context consumption is not a good look. <ShakeContext.Consumer> {shake => ( <AdminContext.Consumer> {state => ( // ... consume! )} </AdminContext.Consumer> )} </ShakeContext.Consumer> ); } } class App extends Component { // ... <ShakeContext.Provider value={() => this.shake()}> <AuthContext.Provider value={this.state}> <Main /> </AuthContext.Provider> </ShakeContext.Provider> } export default App;`
现在想象三个或更多的上下文来消费...[头部爆炸:场景结束]
在游过上下文的海洋之后,深呼吸,陶醉于你的新知识的便捷。这是一个漫长的旅程,如果你回顾我们的努力,它们仍然可见。但随着时间的推移,水面平静下来,屈服于重力。
Context 也做了同样的事情,屈服于 JavaScript。输入useContext
。
消费上下文的新挂钩并没有改变上下文的概念,因此出现了上面的暴跌。这个上下文挂钩只是给了我们一个额外的、更漂亮的方法来使用上下文。当将它应用于使用多种上下文的组件时,它非常有用。
这里我们有一个上述组件的重构版本,用钩子消耗多个上下文!
`const AdminForm = () => { const shake = useContext(ShakeContext); const auth = useContext(AuthContext); // you have access to the data within each context // the context still needs be in scope of the consuming component return ( <div className={ shake ? 'shake' : 'form-container' }> { auth.isAuthenticated ? user.name : auth.errorMessage } </div> ); };`
就是这样!无论您的上下文包含什么,无论是对象、数组还是函数,您都可以通过useContext
访问它。太神奇了。接下来,让我们深入减速器,讨论它的优点,然后实现useReducer
钩子。
useReducer
为了使围绕实现的决策更容易,我们将回顾相关概念、实际用例,然后是实现。我们将使用useState
从一个类到功能组件。接下来,我们将实现useReducer
。
围绕用户的概念
如果您至少熟悉以下三个概念中的两个,您可以跳到下一节。
如果你不相信你对以上任何一点都足够满意,请回顾以下几点进行更深入的研究:
useReducer 和 useState:useState
钩子允许你访问一个功能组件中的一个状态变量,用一个方法来更新它——也就是setCount
。useReducer
使更新状态更加灵活和隐式。就像Array.prototype.map
和Array.prototype.reduce
可以解决类似的问题一样,Array.prototype.reduce
的用途要多得多。
useState
用途useReducer
引擎盖下。
下面的例子将任意演示拥有多个useState
方法的不便之处。
`const App = () => { const [ isAdminLoading, setIsAdminLoading] = useState(false); const [ isAdmin, setIsAdmin] = useState(false); const [ isAdminErr, setIsAdminErr] = useState({ error: false, msg: null }); // there's a lot more overhead with this approach, more variables to deal with const adminStatus = (loading, success, error) => { // you must have your individual state on hand // it would be easier to pass your intent // and have your pre-concluded outcome initialized, typed with intent if (error) { setIsAdminLoading(false); // could be set with intent in reducer setIsAdmin(false); setIsAdminErr({ error: true, msg: error.msg, }); throw error; } else if (loading && !error && !success) { setIsAdminLoading(loading); } else if (success && !error && !loading) { setIsAdminLoading(false); // .. these intents are convoluted setIsAdmin(true); } }; };`
useReducer 和 Array.prototype.reduce: useReducer
的行为与Array.protoType.reduce
非常相似。它们都采用非常相似的参数;一个回调和一个初始值。在我们的例子中,我们称它们为reducer
和initialState
。它们都接受两个参数并返回一个值。最大的不同是useReducer
随着派遣的回归增加了更多的功能。
useReducer 和 Redux Reducer: Redux 是一个复杂得多的应用状态管理工具。Redux reducers 通常通过调度、props、连接的类组件、动作和(可能的)服务来访问,并在 Redux 存储中维护。然而,实现useReducer
将所有的复杂性抛在脑后。
我相信许多流行的 React 包会有令人难以置信的更新,这将使我们不带 Redux 的 React 开发更加有趣。对这些新实践的了解将有助于我们轻松过渡到即将到来的新奇更新。
用户的机会
Hooks API 简化了组件逻辑,去掉了关键字class
。它可以在您的组件中根据需要进行多次初始化。困难的部分,很像上下文,是知道什么时候使用它。
机遇
您可能想看看在以下情况下实现useReducer
:
- 组件需要复杂的状态对象
- 组件的属性将用于计算组件状态的下一个值
- 特定的
useState
更新方法取决于另一个useState
值
在这些情况下应用useReducer
将会减轻这种模式所带来的视觉和心理上的复杂性,并随着应用程序的增长提供持续的喘息空间。简而言之:你最小化了分散在组件中的依赖于状态的逻辑的数量,并增加了在更新状态时表达你的意图的能力,而不是表达结果的能力。
优势
- 组件和容器分离不需要
- 在我们的组件范围内,
dispatch
函数很容易访问
** 使用第三个参数、initialAction
在初始渲染时操作状态的能力*
*让我们开始编码吧!
实现 useReducer
让我们来看上面第一个上下文例子的一小部分。
`class App extends React.Component { constructor(props) { super(props); this.state = { isAuthenticated: false, }; this.toggleAuth = this.toggleAuth.bind(this); } toggleAuth(success) { success ? this.setState({ isAuthenticated: true }) : 'Potential errorists threat. Alert level: Magenta?'; } render() { // ... } }`
这是上面用useState
重构的例子。
`const App = () => { const [isAuthenticated, setAuth] = useState(false); const toggleAuth = (success) => success ? setAuth(true) : 'Potential errorists threat. Alert level: Magenta?'; return ( // ... ); };`
令人惊讶的是这看起来是如此的漂亮和熟悉。现在,随着我们向这个应用程序添加更多的逻辑,useReducer
将变得有用。让我们从useState
开始附加逻辑。
`const App = () => { const [isAuthenticated, setAuth] = useState(false); const [shakeForm, setShakeForm] = useState(false); const [categories, setCategories] = useState(['Participants', 'Races']); const [categoryView, setCategoryView] = useState(null); const toggleAuth = () => setAuth(true); const toggleShakeForm = () => setTimeout(() => setShakeForm(false), 500); const handleShakeForm = () => setShakeForm(shakeState => !shakeFormState ? toggleShakeForm() : null); return ( // ... ); };`
这要复杂得多。用useReducer
会是什么样子?
`// Here's our reducer and initialState // outside of the App component const reducer = (state, action) => { switch (action.type) { case 'IS_AUTHENTICATED': return { ...state, isAuthenticated: true, }; // ... you can image the other cases default: return state; } }; const initialState = { isAuthenticated: false, shake: false, categories: ['Participants', 'Races'], };`
我们将在带有useReducer
的应用程序组件的顶层使用这些变量。
`const App = () => { const [state, dispatch] = useReducer(reducer, initialState); const toggleAuth = (success) => success ? dispatch({ type: 'IS_AUTHENTICATED' }) // BAM! : alert('Potential errorists threat. Alert level: Magenta?'); return ( // ... ); };`
这个代码很好地表达了我们的意图。这很好,但是我们需要将这个逻辑放到上下文中,这样我们就可以沿着树向下使用它。让我们看看我们的身份验证提供者,看看我们如何提供它的逻辑。
`const App = () => { // ... <AdminContext.Provider value={{ isAuthenticated: state.isAuthenticated, toggle: toggleAuth, }}> <Main /> </AdminContext.Provider> // ... };`
有很多方法可以做到这一点,但是我们有一个状态和一个函数来分派传递给提供者的动作。除了逻辑的位置和组织不同之外,这与前面的很相似。大呼小叫...对吗?
如果您将value={{ state, dispatch }}
传递给提供商的价值主张会怎样?如果我们将提供者提取到一个包装函数中会怎么样?您可以消除组件与其他组件紧密耦合的逻辑。进一步说,传递你的意图(行动)比传递你打算怎么做(逻辑)要好得多。
带挂钩的状态管理
这里我们有和以前一样的App
、reducer
和initialState
,除了我们能够移除通过上下文传递的逻辑。相反,我们将关注组件中的逻辑,这将反过来执行我们的意图。
`const App = () => { const [state, dispatch] = useReducer(reducer, initialState); // ... <AdminContext.Provider value={{ state, dispatch }}> <Main /> </AdminContext.Provider> // ... };`
这是我们意图的必要逻辑,它应该在哪里。
`const AdminForm = () => { const auth = useContext(AdminContext); const handleSubmit = (event) => { event.preventDefault(); authResult() ? dispatch({ type: 'IS_AUTHENTICATED' }) : alert('Potential errorists threat. Alert level: Magenta?'); }; const authResult = () => setTimeout(() => true, 500); return ( <div className={'form-container'}> <form onSubmit={event => handleSubmit(event)} className={'center-content login-form'} > // ... form stuff.. </form> </div > ); };`
但是我们可以做得更好。用包装器提取提供者组件将为我们提供一种有效的方式来传递数据,而无需不必要的重新呈现。
`// AdminContext.js const reducer = (state, action) => { switch (action.type) { case 'IS_AUTHENTICATED': return { ...state, isAuthenticated: true, }; // ... you can image other cases default: return state; } }; const initialState = { isAuthenticated: false, // ... imagine so much more! }; const ComponentContext = React.createContext(initialState); export const AdminProvider = (props) => { const [ state, dispatch ] = useReducer(reducer, initialState); return ( <ComponentContext.Provider value={{ state, dispatch }}> {props.children} </ComponentContext.Provider> ); };`
`// App.js // ... const App = () => { // ... <AdminProvider> <Main /> </AdminProvider> // ... };`
所以它稍微有点“复杂”,但是它组织得很好,可读性很强,也很容易理解。当您的应用程序增长并且需要更多上下文时,它也更容易管理。你可以按照组件或者你认为合适的方式来拆分你的上下文文件。这是您发挥和探索最适合您、您的团队和您的应用程序的时间!去找他!
结论
在我看来,你绝对应该考虑在你的应用程序中使用钩子进行状态管理。正如useState
非常适合简单组件的状态管理一样,useReducer
也能够处理一组组件。“应用程序的整体状态”会让位于“鸟巢的微观状态”吗?这种模式会像设计良好的微服务舰队一样创建更易于管理的微状态吗?
目前,hooks 还不能完全取代 Redux,但当你冒险进入大型生产应用程序的未知领域时,它们可以让你的旅程变得更容易。
实现useReducer
相当简单,没有痛苦。从useReducer
到 Redux 的重构可能比从头开始更容易。你手边会有大部分你需要的东西。因此,我建议在中小型应用程序上使用 hooks 进行状态管理,并期待看到新的方法,以更少的开销高效地实现健壮的、可管理的状态管理模式,如 Redux。谢谢反应,也谢谢阅读。
钩子 vs Redux:思想和观点
你是否在考虑通过像 Redux 这样更结构化的框架来实现状态管理的钩子?请注意以下利弊:
优点
- 较少样板文件
- 更快的开发(在一定程度上,对于中小型应用程序)
- 主要的重构可以变得不那么复杂和痛苦
- 更多的控制,但你必须考虑与大规模重新渲染相关的性能问题
- 更好的是,如果你需要在这里和那里撒一些状态
- React 核心 API 的一部分
缺点
- 较少的开发人员工具支持(如时间旅行调试)
- 没有标准全局状态对象
- 更少的资源和约定
- 对于刚从 Redux 提供结构中获益的开发人员来说,这并不太好
- 对于较大的应用程序,很难只提取和传递相关数据
对于那些刚开始在现有项目上工作的人来说,hooks 有可能不那么令人难以招架,术语也不那么多。因此,对于中小型应用程序,新开发人员可能会更好地使用 hooks 而不是 Redux。另一方面,对于较大的应用程序,如果有足够多的复杂性被抽象到已经建立的 Redux 商店中,那么就有一个很容易传达给任何开发人员的约定。
Redux 有明显的好处,主要是由于广泛的采用和成熟。在约定、资源(比如博客帖子和堆栈溢出问题等)、库和更好的开发工具领域。如果你知道你在做什么,用 Redux 调试一个讨厌的复杂状态对象可能会简单一些。换句话说,仅仅因为一个开发人员可以看到一个问题,并不一定意味着该开发人员知道如何跟踪并修复它,或者甚至可以访问问题逻辑。此外,对于大型应用程序,商店可能会变得不堪重负。这就像在全食超市挑选橄榄油一样——要吸收的东西太多了。钩子允许我们将数据/归约器/逻辑划分到特定的嵌套,这些嵌套可以在任何地方共享。Redux 的性能应该更好,尤其是对于较大的应用。这并不意味着这是一个保证。正确实现 Redux,勤于基准测试,关注状态结构,这些都取决于开发人员。使用钩子,在 Redux 之前,您可能会遇到性能问题,但是从我使用钩子的有限时间来看,我觉得使用上面提到的一些技巧可以更容易地跟踪和修复这些问题。这也是一个实现模仿mapStateToProps
和mapDispatchToProps
的定制逻辑的机会,这将随着应用的增长而提高性能。这取决于你进行实验,并自己决定什么最适合当前的情况。记住:这不是一个“非此即彼”的情况——你可以同时使用 hooks 和 Redux 来管理你的应用程序的状态。*
React 挂钩上的底漆
React 很棒,但是在开发更大的应用程序时,需要解决一些稍微令人恼火的架构原则。例如,使用复杂的高阶组件来重用有状态逻辑,以及依赖触发不相关逻辑的生命周期方法。嗯,反应钩有助于缓解这种恶化...让我们开始吧。
这是两部分系列的第一部分:
- (本文)React 挂钩上的底漆
- React Hooks——以 useContext 和 useReducer 为特色的深入探讨
学习目标
在本文结束时,你将能够回答以下问题:
- 什么是钩子?
- 为什么要在 React 中实现它们?
- 钩子是如何使用的?
- 使用钩子有什么规则吗?
- 什么是自定义挂钩?
- 什么时候应该使用定制挂钩?
- 使用定制钩子有什么好处?
什么是钩子?
挂钩允许您:
- 在功能组件中使用状态和“挂钩”生命周期方法。
- 在组件之间重用有状态逻辑,这简化了组件逻辑,最重要的是,让您可以跳过编写类。
为什么要在 React 中实现它们?
如果你用过 React,你就知道有状态逻辑有多复杂,对吗?当应用程序增加了几个新特性来增强功能时,就会出现这种情况。为了尝试和简化这个问题,React 背后的智囊们试图找到一种方法来解决这个问题。
-
重用组件间的有状态逻辑
钩子允许开发人员编写简单的、有状态的、功能性的组件,并且在开发时花费更少的时间来设计和重构组件层次结构。怎么会?使用钩子,你可以提取和共享组件之间的有状态逻辑。
-
简化组件逻辑
当不可避免的逻辑指数增长出现在您的应用程序中时,简单的组件就变成了令人眩晕的有状态逻辑和副作用的深渊。生命周期方法变得与不相关的方法混杂在一起。一个组件的职责不断增长,变得不可分割。反过来,这使得编码繁琐,测试困难。
-
可以逃课
类是 React 架构的重要组成部分。上课有很多好处,但是给初学者造成了入门的障碍。对于类,您还必须记住将
this
绑定到事件处理程序,因此代码会变得冗长且有点多余。编码的未来也不会很好地与类一起玩,因为它们可能会鼓励倾向于落后于其他设计模式的模式。
钩子是如何使用的?
React 版本 16.8 有钩子。
`import { useState, useEffect } from 'react';`
很简单,但是你实际上如何使用这些新方法呢?下面的例子很简单,但是这些方法的能力非常强大。
useState 挂钩方法
使用状态挂钩的最佳方式是将其析构并设置初始值。第一个参数将用于存储状态,第二个用于更新状态。
例如:
`const [weight, setWeight] = useState(150); onClick={() => setWeight(weight + 15)}`
weight
是状态setWeight
是一种用于更新状态的方法useState(150)
是用来设置初始值的方法(任何原始类型)
值得注意的是,您可以在单个组件中多次析构状态挂钩:
`const [age, setAge] = useState(42); const [month, setMonth] = useState('February'); const [todos, setTodos] = useState([{ text: 'Eat pie' }]);`
因此,该组件可能类似于:
`import React, { useState } from 'react';
export default function App() {
const [weight, setWeight] = useState(150);
const [age] = useState(42);
const [month] = useState('February');
const [todos] = useState([{ text: 'Eat pie' }]);
return (
<div className="App">
<p>Current Weight: {weight}</p>
<p>Age: {age}</p>
<p>Month: {month}</p>
<button onClick={() => setWeight(weight + 15)}>
{todos[0].text}
</button>
</div>
);
}`
useEffect 挂钩方法
最好像使用任何常见的生命周期方法一样使用效果挂钩,如componentDidMount
、componentDidUpdate
和componentWillUnmount
。
例如:
`// similar to the componentDidMount and componentDidUpdate methods useEffect(() => { document.title = `You clicked ${count} times`; });`
每当组件更新时,useEffect
将在渲染后被调用。现在,如果您只想在变量计数发生变化时更新useEffect
,您只需将该事实添加到数组中方法的末尾,类似于高阶reduce
方法末尾的累加器。
`// check out the variable count in the array at the end... useEffect(() => { document.title = `You clicked ${count} times`; }, [ count ]);`
让我们结合两个例子:
`const [weight, setWeight] = useState(150); useEffect(() => { document.title = `You weigh ${weight}, you ok with that?`; }, [ weight ]); onClick={() => setWeight(weight + 15)}`
因此,当onClick
被触发时,useEffect
方法也将被调用,并在 DOM 更新后不久在文档标题中呈现新的权重。
示例:
`import React, { useState, useEffect } from 'react';
export default function App() {
const [weight, setWeight] = useState(150);
const [age] = useState(42);
const [month] = useState('February');
const [todos] = useState([{ text: 'Eat pie' }]);
useEffect(() => {
document.title = `You weigh ${weight}, you ok with that?`;
});
return (
<div className="App">
<p>Current Weight: {weight}</p>
<p>Age: {age}</p>
<p>Month: {month}</p>
<button onClick={() => setWeight(weight + 15)}>
{todos[0].text}
</button>
</div>
);
}`
useEffect
非常适合进行 API 调用:
`useEffect(() => { fetch('https://jsonplaceholder.typicode.com/todos/1') .then(results => results.json()) .then((data) => { setTodos([{ text: data.title }]); }); }, []);`
React 钩子看起来很棒,但是如果你花一分钟,你可能会意识到在多个组件中重新初始化多个钩子方法,比如useState
和useEffect
,可能会轻视我们都珍视的 DRY 原则。好吧,让我们看看如何通过创建定制钩子来重用这些美妙的新内置方法。在我们开始一些涉及钩子的新技巧之前,我们先来看看使用钩子的规则,让我们的定制钩子之旅更加愉快。
使用钩子有什么规则吗?
是的,React 钩子是有规则的。乍一看,这些规则似乎不合常规,但是一旦你理解了 React 钩子是如何启动的,这些规则就很容易遵循了。另外,React 有一个棉绒来防止你违反规则。
(1)钩子必须以相同的顺序调用,在顶层。
钩子创建一个钩子调用数组来保持秩序。这种顺序有助于 React 区分,例如,单个组件中或整个应用程序中的多个useState
和useEffect
方法调用之间的差异。
例如:
`// This is good! function ComponentWithHooks() { // top-level! const [age, setAge] = useState(42); const [month, setMonth] = useState('February'); const [todos, setTodos] = useState([{ text: 'Eat pie' }]); return ( //... ) }`
- 第一次渲染时,
42
、February
、[{ text: 'Eat pie'}]
都被推入一个状态数组。 - 当组件重新呈现时,
useState
方法参数被忽略。 age
、month
和todos
的值从组件的状态中检索,该状态是前述的状态数组。
(2)不能在条件语句或循环中调用钩子。
由于钩子的启动方式,它们不允许在条件语句或循环中使用。对于钩子,如果在重新渲染时初始化的顺序改变了,你的应用程序很有可能不能正常工作。您仍然可以在组件中使用条件语句和循环,但是不能在代码块中使用钩子。
例如:
`// DON'T DO THIS!! const [DNAMatch, setDNAMatch] = useState(false) if (name) { setDNAMatch(true) const [name, setName] = useState(name) useEffect(function persistFamily() { localStorage.setItem('dad', name); }, []); }`
`// DO THIS!! const [DNAMatch, setDNAMatch] = useState(false) const [name, setName] = useState(null) useEffect(() => { if (name) { setDNAMatch(true) setName(name) localStorage.setItem('dad', name); } }, []);`
(3)钩子不能用在类组件中。
钩子必须在函数组件或自定义钩子函数中初始化。自定义钩子函数只能在一个功能组件中调用,并且必须遵循与非自定义钩子相同的规则。
您仍然可以在同一个应用程序中使用类组件。你可以用钩子把你的功能组件作为一个类组件的子组件。
(4)定制挂钩应以单词开头,使用并采用驼色外壳。
这与其说是一个规则,不如说是一个强有力的建议,但是它将有助于应用程序的一致性。您还会知道,当您看到以单词use
为前缀的函数时,它可能是一个定制的钩子。
什么是自定义挂钩?
自定义钩子只是遵循与非自定义钩子相同规则的函数。它们允许您整合逻辑、共享数据以及跨组件重用钩子。
什么时候应该使用定制挂钩?
当您需要在组件之间共享逻辑时,最好使用定制挂钩。在 JavaScript 中,当您想要在两个独立的函数之间共享逻辑时,您可以创建另一个函数来支持它。和组件一样,钩子也是函数。您可以提取钩子逻辑,在应用程序的各个组件之间共享。在编写定制钩子时,您命名它们(再次以单词use
开始),设置参数,并告诉它们应该返回什么(如果有的话)。
例如:
`import { useEffect, useState } from 'react'; const useFetch = ({ url, defaultData = null }) => { const [data, setData] = useState(defaultData); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch(url) .then(res => res.json()) .then((res) => { setData(res); setLoading(false); }) .catch((err) => { setError(err); setLoading(false); }); }, []); const fetchResults = { data, loading, error, }; return fetchResults; }; export default useFetch;`
你是不是想出一个需要自定义钩子的情况?发挥你的想象力。虽然在钩子旁边有非常规的规则,但它们仍然非常灵活,并且刚刚开始展示它们的潜力。
使用定制钩子有什么好处?
钩子允许你随着应用程序的增长抑制复杂性,编写更容易理解的代码。下面的代码是具有相同功能的两个组件的比较。在第一次比较之后,我们将展示在带有容器的组件中使用自定义钩子的更多好处。
下面的类组件应该看起来很熟悉:
`import React from 'react';
class OneChanceButton extends React.Component {
constructor(props) {
super(props);
this.state = {
clicked: false,
};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
return this.setState({ clicked: true });
}
render() {
return (
<div>
<button
onClick={this.handleClick}
disabled={this.state.clicked}
>
You Have One Chance to Click
</button>
</div>
);
}
}
export default OneChanceButton;`
用钩子实现相同的功能来简化代码和增加可读性怎么样:
`import React, { useState } from 'react';
function OneChanceButton(props) {
const [clicked, setClicked] = useState(false);
function doClick() {
return setClicked(true);
}
return (
<div>
<button
onClick={clicked ? undefined : doClick}
disabled={clicked}
>
You Have One Chance to Click
</button>
</div>
);
}
export default OneChanceButton;`
更复杂的比较
想要更多吗?
- media-query-custom-hooks——这个比较将展示利用
useState
和useEffect
方法实现定制挂钩的强大功能 - media-query-custom-hooks——一个使用
useRef
和useReducer
的更复杂的例子
结论
React 挂钩是一个惊人的新功能!实施的理由是正当的;此外,我相信这将极大地降低 React 中的编码壁垒,并使其保持在最受欢迎的框架列表的顶端。看到这如何改变第三方库的工作方式,尤其是状态管理工具和路由器,将是非常令人兴奋的。
总之,React 挂钩:
- 无需使用类组件就可以轻松“挂钩”React 的生命周期方法
- 通过提高可重用性和抽象复杂性来帮助减少代码
- 帮助简化组件之间共享数据的方式
我迫不及待地想看到 React 钩子如何被利用的更有力的例子。感谢阅读!
准备好了吗?查看下一部分:React Hooks——以 useContext 和 useReducer 为特色的更深入的探讨。
React 教程
描述
React 是一个开源的、声明式的、基于组件的 JavaScript 库,用于构建用户界面。由脸书维护,它主要用于开发丰富的单页(SPAs)和移动应用程序。
TestDriven.io 上的教程和文章讲述了如何使用测试驱动开发(TDD)开发 React 应用程序,使用 React 挂钩管理状态,以及将 React 与 Django 和 FastAPI 集成。
用 FastAPI 构建 CRUD app,React。
在第 1 部分中,我们将建立整个项目,然后用测试驱动开发来开发 UI。
在第 2 部分中,我们将在开始添加基本的计算器功能之前完成 UI。
- 由 发布尼克·托马齐奇
- 最后更新于2020 年 12 月 14 日
将基于会话的身份验证添加到由 Django 和 React 支持的单页面应用程序(SPA)中。
本文着眼于如何使用 useContext 和 useReducer 挂钩来使 React 应用程序及其状态管理变得干净而高效。
React 钩子的介绍。
这篇文章整理了网上一些从 Heroku 迁移到 AWS 的最佳教程。
自动重试失败的芹菜任务
在本文中,我们将看看如何自动重试失败的芹菜任务。
姜戈+芹菜系列:
- 与 Django 和 Celery 的异步任务
- 在 Django 用芹菜和码头工人处理定期任务
- 自动重试失败的芹菜任务(本文!)
- 处理芹菜和数据库事务
目标
阅读后,您应该能够:
- 使用
retry
方法和装饰器参数重试失败的芹菜任务 - 重试失败的任务时使用指数补偿
- 使用基于类的任务来重用重试参数
芹菜任务
你可以在 GitHub 上找到这篇文章的源代码。
假设我们有这样一个芹菜任务:
`@shared_task
def task_process_notification():
if not random.choice([0, 1]):
# mimic random error
raise Exception()
requests.post('https://httpbin.org/delay/5')`
在现实世界中,这可能会调用内部或外部的第三方服务。不考虑服务,假设它是非常不可靠的,尤其是在高峰期。我们如何处理失败?
值得注意的是,许多芹菜初学者对为什么有些文章使用
app.task
而有些文章使用shared_task
感到困惑。嗯,shared_task
让您定义 Celery 任务,而不必导入 Celery 实例,这样可以使您的任务代码更加可重用。
解决方案 1:使用 Try/Except 块
我们可以使用 try/except 块来捕捉异常并引发retry
:
`@shared_task(bind=True)
def task_process_notification(self):
try:
if not random.choice([0, 1]):
# mimic random error
raise Exception()
requests.post('https://httpbin.org/delay/5')
except Exception as e:
logger.error('exception raised, it would be retry after 5 seconds')
raise self.retry(exc=e, countdown=5)`
注意事项:
- 由于我们将
bind
设置为True
,这是一个绑定的任务,因此任务的第一个参数将始终是当前任务实例(self
)。正因为如此,我们可以调用self.retry
来重试失败的任务。 - 请记住
raise
由self.retry
方法返回的异常以使其工作。 - 通过将
countdown
参数设置为 5,任务将在延迟 5 秒后重试。
让我们在 Python shell 中运行下面的代码:
`>>> from polls.tasks import task_process_notification
>>> task_process_notification.delay()`
您应该会在 Celery worker 终端输出中看到如下输出:
`Task polls.tasks.task_process_notification[06e1f985-90d4-4453-9870-fab57c5885c4] retry: Retry in 5s: Exception()
Task polls.tasks.task_process_notification[06e1f985-90d4-4453-9870-fab57c5885c4] retry: Retry in 5s: Exception()
Task polls.tasks.task_process_notification[06e1f985-90d4-4453-9870-fab57c5885c4] succeeded in 3.3638455480104312s: None`
可以看到,芹菜任务失败了两次,第三次成功了。
解决方案 2:任务重试装饰器
Celery 4.0 增加了对重试的内置支持,因此您可以让异常冒泡并在装饰器中指定如何处理它:
`@shared_task(bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 7, 'countdown': 5})
def task_process_notification(self):
if not random.choice([0, 1]):
# mimic random error
raise Exception()
requests.post('https://httpbin.org/delay/5')`
注意事项:
autoretry_for
获取您想要重试的异常类型的列表/元组。retry_kwargs
获取附加选项的字典,用于指定如何执行自动重试。在上面的例子中,任务将在 5 秒钟的延迟后重试(通过countdown
),并且最多允许 7 次重试尝试(通过max_retries
)。芹菜将在 7 次尝试失败后停止重试,并引发异常。
指数后退
如果您的芹菜任务需要向第三方服务发送请求,那么使用指数回退来避免服务不堪重负是个好主意。
Celery 默认支持这一点:
`@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={'max_retries': 5})
def task_process_notification(self):
if not random.choice([0, 1]):
# mimic random error
raise Exception()
requests.post('https://httpbin.org/delay/5')`
在此示例中,第一次重试应该在 1 秒后运行,第二次在 2 秒后运行,第三次在 4 秒后运行,第四次在 8 秒后运行,依此类推:
`[02:09:59,014: INFO/ForkPoolWorker-8] Task polls.tasks.task_process_notification[fbe041b6-e6c1-453d-9cc9-cb99236df6ff] retry: Retry in 1s: Exception()
[02:10:00,210: INFO/ForkPoolWorker-2] Task polls.tasks.task_process_notification[fbe041b6-e6c1-453d-9cc9-cb99236df6ff] retry: Retry in 2s: Exception()
[02:10:02,291: INFO/ForkPoolWorker-4] Task polls.tasks.task_process_notification[fbe041b6-e6c1-453d-9cc9-cb99236df6ff] retry: Retry in 4s: Exception()`
您也可以将retry_backoff
设置为一个数字,用作延迟因子:
`@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=5, retry_kwargs={'max_retries': 5})
def task_process_notification(self):
if not random.choice([0, 1]):
# mimic random error
raise Exception()
requests.post('https://httpbin.org/delay/5')`
示例:
`[02:21:45,887: INFO/ForkPoolWorker-8] Task polls.tasks.task_process_notification[6a0b2682-74f5-410b-af1e-352069238f3d] retry: Retry in 5s: Exception()
[02:21:55,170: INFO/ForkPoolWorker-2] Task polls.tasks.task_process_notification[6a0b2682-74f5-410b-af1e-352069238f3d] retry: Retry in 10s: Exception()
[02:22:15,706: INFO/ForkPoolWorker-4] Task polls.tasks.task_process_notification[6a0b2682-74f5-410b-af1e-352069238f3d] retry: Retry in 20s: Exception()
[02:22:55,450: INFO/ForkPoolWorker-6] Task polls.tasks.task_process_notification[6a0b2682-74f5-410b-af1e-352069238f3d] retry: Retry in 40s: Exception()`
默认情况下,指数补偿还会引入随机抖动,以避免所有任务同时运行。
随机性
当您为 Celery 任务(需要向另一个服务发送请求)构建自定义重试策略时,您应该在延迟计算中添加一些随机性,以防止所有任务同时执行导致蜂拥。
芹菜也给你盖上了retry_jitter
:
`@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=5, retry_jitter=True, retry_kwargs={'max_retries': 5})
def task_process_notification(self):
if not random.choice([0, 1]):
# mimic random error
raise Exception()
requests.post('https://httpbin.org/delay/5')`
该选项默认设置为True
,这有助于防止当您使用 Celery 的内置retry_backoff
时出现雷群问题。
任务基类
如果您发现自己在 Celery 任务装饰器中编写了相同的重试参数,您可以(从 Celery 4.4 开始)在一个基类中定义重试参数,然后您可以将它用作 Celery 任务中的基类:
`class BaseTaskWithRetry(celery.Task):
autoretry_for = (Exception, KeyError)
retry_kwargs = {'max_retries': 5}
retry_backoff = True
@shared_task(bind=True, base=BaseTaskWithRetry)
def task_process_notification(self):
raise Exception()`
因此,如果您在 Python shell 中运行该任务,您将看到以下内容:
`[03:12:29,002: INFO/ForkPoolWorker-8] Task polls.tasks.task_process_notification[3231ef9b-00c7-4ab1-bf0b-2fdea6fa8348] retry: Retry in 1s: Exception()
[03:12:30,445: INFO/ForkPoolWorker-8] Task polls.tasks.task_process_notification[3231ef9b-00c7-4ab1-bf0b-2fdea6fa8348] retry: Retry in 2s: Exception()
[03:12:33,080: INFO/ForkPoolWorker-8] Task polls.tasks.task_process_notification[3231ef9b-00c7-4ab1-bf0b-2fdea6fa8348] retry: Retry in 3s: Exception()`
结论
在这篇芹菜文章中,我们研究了如何自动重试失败的芹菜任务。
同样,本文的源代码可以在 GitHub 上找到。
感谢您的阅读。如果您有任何问题,请随时联系我。
姜戈+芹菜系列:
- 与 Django 和 Celery 的异步任务
- 在 Django 用芹菜和码头工人处理定期任务
- 自动重试失败的芹菜任务(本文!)
- 处理芹菜和数据库事务