TestDriven-io-博客中文翻译-一-
TestDriven.io 博客中文翻译(一)
接受 Stripe、Vue.js 和 Flask 支付
原文:https://testdriven.io/blog/accepting-payments-with-stripe-vuejs-and-flask/
在本教程中,我们将使用 Stripe (用于支付处理) Vue.js (客户端应用)和 Flask (服务器端 API)开发一个销售图书的 web 应用。
这是一个中级教程。它假设你有 Vue 和 Flask 的基本工作知识。查看以下资源以了解更多信息:
- Flask:Flask、测试驱动开发(TDD)和 JavaScript 简介
- 用 Flask 和 Vue.js 开发单页应用
- 通过构建和部署 CRUD 应用程序学习 Vue
最终应用:

主要依赖:
- 视图 v2.6.11
- CLI 视图 v4.5.11
- 节点 v15.7.0
- 国家预防机制 7.4.3 版
- 烧瓶 v1.1.2
- python 3 . 9 . 1 版
目标
本教程结束时,您将能够:
- 使用现有的 CRUD 应用,由 Vue 和 Flask 提供支持
- 创建订单结帐 Vue 组件
- 验证信用卡信息,并通过 Stripe Checkout 处理支付
项目设置
从 flask-vue-crud repo 中克隆基础 Flask 和 Vue 项目:
`$ git clone https://github.com/testdrivenio/flask-vue-crud flask-vue-stripe
$ cd flask-vue-stripe`
创建并激活虚拟环境,然后启动 Flask 应用程序:
`$ cd server
$ python3.9 -m venv env
$ source env/bin/activate
(env)$
(env)$ pip install -r requirements.txt
(env)$ python app.py`
上述用于创建和激活虚拟环境的命令可能会因您的环境和操作系统而异。
将您选择的浏览器指向http://localhost:5000/ping。您应该看到:
然后,安装依赖项并在不同的终端选项卡中运行 Vue 应用程序:
`$ cd client
$ npm install
$ npm run serve`
导航到 http://localhost:8080 。确保基本 CRUD 功能按预期工作:

想学习如何构建这个项目吗?查看用 Flask 和 Vue.js 开发单页应用的博文。
我们在建造什么?
我们的目标是建立一个允许最终用户购买书籍的 web 应用程序。
客户端 Vue 应用程序将显示可供购买的书籍,并通过 Stripe.js 和 Stripe Checkout 将最终用户重定向到结帐表单。支付过程完成后,用户将被重定向到同样由 Vue 管理的成功或失败页面。
与此同时,Flask 应用程序使用 Stripe Python 库与 Stripe API 进行交互,以创建一个结帐会话。

像之前的教程一样,用 Flask 和 Vue.js 开发单页应用程序,我们将只处理应用程序中的快乐之路。通过加入适当的错误处理来检查你的理解。
书籍垃圾
首先,让我们在服务器端的现有图书列表中添加购买价格,并在客户端更新适当的 CRUD 函数——GET、POST 和 PUT。
得到
首先将price添加到 server/app.py 中BOOKS列表的每个字典中:
`BOOKS = [
{
'id': uuid.uuid4().hex,
'title': 'On the Road',
'author': 'Jack Kerouac',
'read': True,
'price': '19.99'
},
{
'id': uuid.uuid4().hex,
'title': 'Harry Potter and the Philosopher\'s Stone',
'author': 'J. K. Rowling',
'read': False,
'price': '9.99'
},
{
'id': uuid.uuid4().hex,
'title': 'Green Eggs and Ham',
'author': 'Dr. Seuss',
'read': True,
'price': '3.99'
}
]`
然后,更新Books组件中的表,client/src/components/books . vue,以显示购买价格:
`<table class="table table-hover">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Read?</th>
<th scope="col">Purchase Price</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(book, index) in books" :key="index">
<td>{{ book.title }}</td>
<td>{{ book.author }}</td>
<td>
<span v-if="book.read">Yes</span>
<span v-else>No</span>
</td>
<td>${{ book.price }}</td>
<td>
<div class="btn-group" role="group">
<button type="button"
class="btn btn-warning btn-sm"
v-b-modal.book-update-modal
@click="editBook(book)">
Update
</button>
<button type="button"
class="btn btn-danger btn-sm"
@click="onDeleteBook(book)">
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>`
您现在应该看到:

邮政
在addBookModal中增加一个新的b-form-group,介于作者和读者b-form-group之间 s:
`<b-form-group id="form-price-group"
label="Purchase price:"
label-for="form-price-input">
<b-form-input id="form-price-input"
type="number"
step="0.01"
v-model="addBookForm.price"
required
placeholder="Enter price">
</b-form-input>
</b-form-group>`
模式现在应该看起来像这样:
`<!-- add book modal -->
<b-modal ref="addBookModal"
id="book-modal"
title="Add a new book"
hide-footer>
<b-form @submit="onSubmit" @reset="onReset" class="w-100">
<b-form-group id="form-title-group"
label="Title:"
label-for="form-title-input">
<b-form-input id="form-title-input"
type="text"
v-model="addBookForm.title"
required
placeholder="Enter title">
</b-form-input>
</b-form-group>
<b-form-group id="form-author-group"
label="Author:"
label-for="form-author-input">
<b-form-input id="form-author-input"
type="text"
v-model="addBookForm.author"
required
placeholder="Enter author">
</b-form-input>
</b-form-group>
<b-form-group id="form-price-group"
label="Purchase price:"
label-for="form-price-input">
<b-form-input id="form-price-input"
type="number"
step="0.01"
v-model="addBookForm.price"
required
placeholder="Enter price">
</b-form-input>
</b-form-group>
<b-form-group id="form-read-group">
<b-form-checkbox-group v-model="addBookForm.read" id="form-checks">
<b-form-checkbox value="true">Read?</b-form-checkbox>
</b-form-checkbox-group>
</b-form-group>
<b-button-group>
<b-button type="submit" variant="primary">Submit</b-button>
<b-button type="reset" variant="danger">Reset</b-button>
</b-button-group>
</b-form>
</b-modal>`
然后,将price添加到状态:
`addBookForm: { title: '', author: '', read: [], price: '', },`
状态现在被绑定到表单的输入值。想想这意味着什么。当状态被更新时,表单输入也将被更新,反之亦然。下面是一个使用 vue-devtools 浏览器扩展的例子:

将price添加到onSubmit方法中的payload中,如下所示:
`onSubmit(evt) { evt.preventDefault(); this.$refs.addBookModal.hide(); let read = false; if (this.addBookForm.read[0]) read = true; const payload = { title: this.addBookForm.title, author: this.addBookForm.author, read, // property shorthand price: this.addBookForm.price, }; this.addBook(payload); this.initForm(); },`
在最终用户提交表单或点击“重置”按钮后,更新initForm以清除值:
`initForm() { this.addBookForm.title = ''; this.addBookForm.author = ''; this.addBookForm.read = []; this.addBookForm.price = ''; this.editForm.id = ''; this.editForm.title = ''; this.editForm.author = ''; this.editForm.read = []; },`
最后,更新 server/app.py 中的路线:
`@app.route('/books', methods=['GET', 'POST'])
def all_books():
response_object = {'status': 'success'}
if request.method == 'POST':
post_data = request.get_json()
BOOKS.append({
'id': uuid.uuid4().hex,
'title': post_data.get('title'),
'author': post_data.get('author'),
'read': post_data.get('read'),
'price': post_data.get('price')
})
response_object['message'] = 'Book added!'
else:
response_object['books'] = BOOKS
return jsonify(response_object)`
测试一下!

不要忘记处理客户端和服务器端的错误!
放
编辑一本书时,自己做同样的事情:
- 向模式添加新的表单输入
- 状态更新
editForm - 在
onSubmitUpdate方法中,将price添加到payload中 - 更新
initForm - 更新服务器端路由
需要帮助吗?再次回顾前一节。你也可以从 flask-vue-stripe repo 中抓取最终代码。
购买按钮
在Books组件中添加一个“购买”按钮,就在“删除”按钮的下面:
`<td>
<div class="btn-group" role="group">
<button type="button"
class="btn btn-warning btn-sm"
v-b-modal.book-update-modal
@click="editBook(book)">
Update
</button>
<button type="button"
class="btn btn-danger btn-sm"
@click="onDeleteBook(book)">
Delete
</button>
<button type="button"
class="btn btn-primary btn-sm"
@click="purchaseBook(book.id)">
Purchase
</button>
</div>
</td>`
接下来,将purchaseBook添加到组件的methods中:
`purchaseBook(bookId) { console.log(bookId); },`
测试一下:

条纹钥匙
注册一个 Stripe 账户,如果你还没有的话。
计算机网络服务器
安装条带 Python 库:
`(env)$ pip install stripe==2.55.1`
从 Stripe 仪表盘中抓取测试模式 API 键:

在运行服务器的终端窗口中将它们设置为环境变量:
`(env)$ export STRIPE_PUBLISHABLE_KEY=<YOUR_STRIPE_PUBLISHABLE_KEY>
(env)$ export STRIPE_SECRET_KEY=<YOUR_STRIPE_SECRET_KEY>`
将条带库导入到 server/app.py 中,并将密钥分配给stripe.api_key,以便在与 API 交互时自动使用它们:
`import os
import uuid
import stripe
from flask import Flask, jsonify, request
from flask_cors import CORS
...
# configuration
DEBUG = True
# instantiate the app
app = Flask(__name__)
app.config.from_object(__name__)
# configure stripe
stripe_keys = {
'secret_key': os.environ['STRIPE_SECRET_KEY'],
'publishable_key': os.environ['STRIPE_PUBLISHABLE_KEY'],
}
stripe.api_key = stripe_keys['secret_key']
# enable CORS
CORS(app, resources={r'/*': {'origins': '*'}})
...
if __name__ == '__main__':
app.run()`
接下来,添加一个返回可发布密钥的新路由处理程序:
`@app.route('/config')
def get_publishable_key():
stripe_config = {'publicKey': stripe_keys['publishable_key']}
return jsonify(stripe_config)`
这将在客户端用于配置 Stripe.js 库。
客户
转向客户端,将 Stripe.js 添加到 client/public/index.html :
`<!DOCTYPE html>
<html lang="">
<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 rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>`
接下来,向名为getStripePublishableKey的Books组件添加一个新方法:
`getStripePublishableKey() { fetch('http://localhost:5000/config') .then((result) => result.json()) .then((data) => { // Initialize Stripe.js this.stripe = Stripe(data.publicKey); // eslint-disable-line no-undef }); },`
在created钩子中调用这个方法:
`created() { this.getBooks(); this.getStripePublishableKey(); },`
现在,在创建实例之后,将调用http://localhost:5000/config,它将使用 Stripe publish key 进行响应。然后,我们将使用这个键创建 Stripe.js 的新实例。
发货到生产?您将希望使用一个环境变量来动态设置基本的服务器端 URL(当前是
http://localhost:5000)。查看文档了解更多信息。
将stripe添加到“状态:
`data() { return { books: [], addBookForm: { title: '', author: '', read: [], price: '', }, message: '', showMessage: false, editForm: { id: '', title: '', author: '', read: [], price: '', }, stripe: null, }; },`
条带检验
接下来,我们需要在服务器端生成一个新的签出会话 ID。单击购买按钮后,一个 AJAX 请求将被发送到服务器以生成这个 ID。服务器将发送回 ID,用户将被重定向到收银台。
计算机网络服务器
添加以下路由处理程序:
`@app.route('/create-checkout-session', methods=['POST'])
def create_checkout_session():
domain_url = 'http://localhost:8080'
try:
data = json.loads(request.data)
# get book
book_to_purchase = ''
for book in BOOKS:
if book['id'] == data['book_id']:
book_to_purchase = book
# create new checkout session
checkout_session = stripe.checkout.Session.create(
success_url=domain_url +
'/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url=domain_url + '/canceled',
payment_method_types=['card'],
mode='payment',
line_items=[
{
'name': book_to_purchase['title'],
'quantity': 1,
'currency': 'usd',
'amount': round(float(book_to_purchase['price']) * 100),
}
]
)
return jsonify({'sessionId': checkout_session['id']})
except Exception as e:
return jsonify(error=str(e)), 403`
在这里,我们-
- 定义了一个
domain_url,用于在购买完成后将用户重定向回客户端 - 获得图书信息
- 创建了签出会话
- 在响应中发回了 ID
记下success_url和cancel_url。在成功支付或取消的情况下,用户将分别被重定向回这些 URL。我们将很快在客户端设置/success和/cancelled路线。
另外,你注意到我们通过round(float(book_to_purchase['price']) * 100)把浮点数转换成整数了吗?Stripe 只允许价格的整数值。对于生产代码,您可能希望将价格作为整数值存储在数据库中——例如,$3.99 应该存储为399。
将导入添加到顶部:
客户
在客户端,更新purchaseBook方法:
`purchaseBook(bookId) { // Get Checkout Session ID fetch('http://localhost:5000/create-checkout-session', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ book_id: bookId }), }) .then((result) => result.json()) .then((data) => { console.log(data); // Redirect to Stripe Checkout return this.stripe.redirectToCheckout({ sessionId: data.sessionId }); }) .then((res) => { console.log(res); }); },`
这里,在解析了result.json()承诺之后,我们调用了 redirectToCheckout 方法,该方法带有来自已解析承诺的结帐会话 ID。
让我们来测试一下。导航到 http://localhost:8080 。单击其中一个购买按钮。您将被重定向到一个 Stripe Checkout 实例(一个 Stripe 托管页面,用于安全收集支付信息),其中包含基本产品信息:

您可以使用 Stripe 提供的几个测试卡号中的一个来测试表单。还是用4242 4242 4242 4242吧。
- 电子邮件:有效的电子邮件
- 卡号:
4242 4242 4242 4242 - 到期日:未来的任何日期
- CVC:任何三个数字
- 名称:任何东西
- 邮政编码:任意五个数字
应该可以成功处理支付,但是重定向会失败,因为我们还没有设置/success路线。
您应该会在条纹仪表盘中看到购买信息:

重定向页面
最后,让我们设置用于处理成功支付或取消的路由和组件。
成功
当支付成功时,我们会将用户重定向到订单完成页面,感谢他们进行购买。
将名为 OrderSuccess.vue 的新组件文件添加到“client/src/components”中:
`<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Thanks for purchasing!</h1>
<hr><br>
<router-link to="/" class="btn btn-primary btn-sm">Back Home</router-link>
</div>
</div>
</div>
</template>`
更新客户端/src/路由器中的路由器:
`import Vue from 'vue'; import Router from 'vue-router'; import Books from '../components/Books.vue'; import OrderSuccess from '../components/OrderSuccess.vue'; import Ping from '../components/Ping.vue'; Vue.use(Router); export default new Router({ mode: 'history', base: process.env.BASE_URL, routes: [ { path: '/', name: 'Books', component: Books, }, { path: '/ping', name: 'Ping', component: Ping, }, { path: '/success', name: 'OrderSuccess', component: OrderSuccess, }, ], });`

最后,您可以使用session_id查询参数显示关于购买的信息:
`http://localhost:8080/success?session_id=cs_test_a1qw4pxWK9mF2SDvbiQXqg5quq4yZYUvjNkqPq1H3wbUclXOue0hES6lWl`
您可以像这样访问它:
`<script> export default { mounted() { console.log(this.$route.query.session_id); }, }; </script>`
从这里开始,您需要在服务器端设置一个路由处理器,通过stripe.checkout.Session.retrieve(id)查找会话信息。自己尝试一下。
取消
对于/canceled重定向,添加一个名为client/src/components/order canceled . vue的新组件:
`<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Your payment was cancelled.</h1>
<hr><br>
<router-link to="/" class="btn btn-primary btn-sm">Back Home</router-link>
</div>
</div>
</div>
</template>`
然后,更新路由器:
`import Vue from 'vue'; import Router from 'vue-router'; import Books from '../components/Books.vue'; import OrderCanceled from '../components/OrderCanceled.vue'; import OrderSuccess from '../components/OrderSuccess.vue'; import Ping from '../components/Ping.vue'; Vue.use(Router); export default new Router({ mode: 'history', base: process.env.BASE_URL, routes: [ { path: '/', name: 'Books', component: Books, }, { path: '/ping', name: 'Ping', component: Ping, }, { path: '/success', name: 'OrderSuccess', component: OrderSuccess, }, { path: '/canceled', name: 'OrderCanceled', component: OrderCanceled, }, ], });`
最后一次测试。
结论
就是这样!确保从头开始回顾目标。你可以在 GitHub 上的 flask-vue-stripe repo 中找到最终代码。
想要更多吗?
- 添加客户端和服务器端单元和集成测试。
- 创建一个购物车,以便客户可以一次购买多本书。
- 添加 Postgres 来存储书籍和订单。
- 用 Docker 将 Vue 和 Flask(以及 Postgres,如果你添加的话)容器化,以简化开发工作流程。
- 将图片添加到书籍中,并创建一个更强大的产品页面。
- 捕获电子邮件并发送电子邮件确认(查看使用 Flask、Redis Queue 和 Amazon SES 发送确认电子邮件)。
- 将客户端静态文件部署到 AWS S3,将服务器端应用程序部署到 EC2 实例。
- 投产?思考更新条带键的最佳方式,以便它们可以根据环境动态变化。
API 教程
描述
应用程序编程接口(API)是使应用程序能够与其他应用程序交互的中介。关于 web 开发,RESTful APIs 是一种流行的 API,它允许 web 应用程序通过机器对机器的通信与其他应用程序进行交互。
TestDriven.io 上与 API 相关的文章和教程主要关注如何使用 Flask、FastAPI、Django REST framework、GraphQL、React 和 Vue.js 等流行框架开发、测试和部署 RESTful APIs。
用 FastAPI 和 GraphQL 搭建一个 CRUD app。
用 JSON Web 令牌保护 FastAPI 应用程序。
如何用 Vue 和 FastAPI 设置一个基本的 CRUD 应用程序的分步演练。
使用测试驱动开发(TDD)使用 FastAPI、Postgres、pytest 和 Docker 开发和测试异步 API。
使用 APIFairy 构建一个 Flask API。
用 FastAPI,MongoDB,Beanie 开发一个异步 API。
用 FastAPI 构建 CRUD app,React。
用 FastAPI 和 MongoDB 开发一个异步 API。
深入了解 Django REST 框架最强大的视图 ViewSets。
使用 Django REST Framework 的通用视图来防止一遍又一遍地重复某些模式。
深入探讨 Django REST 框架的视图是如何工作的,以及它最基本的视图 APIView。
- 由 发布
尼克·托马齐奇 - 最后更新于2021 年 8 月 31 日
查看如何将 Django REST 框架与 Elasticsearch 集成。
开发一个生产就绪的 RESTful API,用 FastAPI 提供一个机器学习模型。
如何在 Django REST 框架中构建自定义权限类?
Django REST 框架中内置权限类的工作方式。
通过使用 Django 和 Aloe 编写一个示例特性,引导您完成行为驱动开发(BDD)开发周期。
Django REST 框架中权限的工作方式。
深入研究 Django REST 框架(DRF)序列化程序。
- 由 发布
尼克·托马齐奇 - 最后更新于2021 年 2 月 2 日
将 Pydantic 与 Django 应用程序集成。
如何用 Vue 和 Flask 设置一个基本的 CRUD 应用程序的分步演练。
架构教程
本文着眼于 MLOps 如何适应机器学习生命周期,重点关注用于开发、部署和服务 ML 模型的工具。
猎鹰和芹菜的异步任务
原文:https://testdriven.io/blog/asynchronous-tasks-with-falcon-and-celery/
异步任务用于将容易失败的密集、耗时的流程转移到后台,以便可以立即向客户端返回响应。
本教程着眼于如何将异步任务队列 Celery 集成到基于 Python 的 Falcon web 框架中。我们还将使用 Docker 和 Docker Compose 将所有内容联系在一起。最后,我们将看看如何用单元测试和集成测试来测试 Celery 任务。
学习目标
学完本教程后,您应该能够:
- 将芹菜整合到猎鹰网络应用程序中。
- 用码头工人把猎鹰、芹菜和红萝卜装进集装箱。
- 使用单独的工作进程在后台执行任务。
- 将芹菜日志保存到文件中。
- 设置 Flower 来监控和管理芹菜作业和工人。
- 用单元测试和集成测试来测试芹菜任务。
后台任务
同样,为了改善用户体验,长时间运行的流程应该在正常的 HTTP 请求/响应流程之外,在后台进程中运行。
示例:
- 发送确认电子邮件
- 刮擦和爬行
- 分析数据
- 图像处理
- 制作每日报告
- 运行机器学习模型
当你构建一个应用程序时,试着区分应该在请求/响应生命周期中运行的任务(比如 CRUD 操作)和应该在后台运行的任务。
猎鹰框架
Falcon 是一个微型 Python web 框架,非常适合创建后端 RESTful APIs。Falcon 感觉很像 Flask,但是在开发和性能方面都要快很多。
Falcon 是一个极简主义的 WSGI 库,用于构建快速的 web APIs 和应用程序后端。我们喜欢将 Falcon 视为 web 框架的迪特·拉姆斯。
当涉及到构建 HTTP APIs 时,其他框架会用大量的依赖性和不必要的抽象来拖累你。Falcon 以简洁的设计切入正题,它包含了 HTTP 和 REST 架构风格。
请务必查看官方文档了解更多信息。
项目设置
克隆基础项目:
`$ git clone https://github.com/testdrivenio/falcon-celery --branch base --single-branch
$ cd falcon-celery`
快速浏览一下代码和项目结构,然后使用 Docker 启动应用程序:
`$ docker-compose up -d --build`
构建和运行映像应该只需要一点时间。一旦完成,该应用程序应该在http://localhost:8000/ping上运行。
确保测试通过:
`$ docker-compose run web python test.py
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK`
芹菜
现在有趣的部分来了——添加芹菜!首先将芹菜和 Redis 添加到 requirements.txt 文件中:
`celery==5.2.7
falcon==3.1.0
gunicorn==20.1.0
redis==4.3.4`
创建任务
向“项目/应用程序”目录添加一个名为 tasks.py 的新文件:
`# project/app/tasks.py
import os
from time import sleep
import celery
CELERY_BROKER = os.environ.get('CELERY_BROKER')
CELERY_BACKEND = os.environ.get('CELERY_BACKEND')
app = celery.Celery('tasks', broker=CELERY_BROKER, backend=CELERY_BACKEND)
@app.task
def fib(n):
sleep(2) # simulate slow computation
if n < 0:
return []
elif n == 0:
return [0]
elif n == 1:
return [0, 1]
else:
results = fib(n - 1)
results.append(results[-1] + results[-2])
return results`
在这里,我们创建了一个新的芹菜实例,并定义了一个名为fib的新芹菜任务T2,它根据给定的数字计算斐波那契数列。
Celery 使用消息代理来促进 Celery worker 和 web 应用程序之间的通信。消息被添加到代理中,然后由工作人员进行处理。一旦完成,结果被添加到后端。
Redis 将被用作代理和后端。将 Redis 和芹菜工人添加到 docker-compose.yml 文件中:
`version: '3.8' services: web: build: ./project image: web container_name: web ports: - 8000:8000 volumes: - ./project:/usr/src/app command: gunicorn -b 0.0.0.0:8000 app:app environment: - CELERY_BROKER=redis://redis:6379/0 - CELERY_BACKEND=redis://redis:6379/0 depends_on: - redis celery: image: web volumes: - ./project:/usr/src/app command: celery -A app.tasks worker --loglevel=info environment: - CELERY_BROKER=redis://redis:6379/0 - CELERY_BACKEND=redis://redis:6379/0 depends_on: - web - redis redis: image: redis:7-alpine`
添加一个新的路由处理程序,将fib任务启动到 init。py :
`class CreateTask(object):
def on_post(self, req, resp):
raw_json = req.stream.read()
result = json.loads(raw_json)
task = fib.delay(int(result['number']))
resp.status = falcon.HTTP_200
result = {
'status': 'success',
'data': {
'task_id': task.id
}
}
resp.text = json.dumps(result)`
注册路线:
`app.add_route('/create', CreateTask())`
导入任务:
`from app.tasks import fib`
构建映像并旋转容器:
`$ docker-compose up -d --build`
测试:
`$ curl -X POST http://localhost:8000/create \
-d '{"number":"4"}' \
-H "Content-Type: application/json"`
您应该会看到类似这样的内容:
`{
"status": "success",
"data": {
"task_id": "d935fa51-44ad-488f-b63d-6b0e178700a8"
}
}`
检查任务状态
接下来,添加一个新的路由处理程序来检查任务的状态:
`class CheckStatus(object):
def on_get(self, req, resp, task_id):
task_result = AsyncResult(task_id)
result = {'status': task_result.status, 'result': task_result.result}
resp.status = falcon.HTTP_200
resp.text = json.dumps(result)`
注册路线:
`app.add_route('/status/{task_id}', CheckStatus())`
导入异步结果:
`from celery.result import AsyncResult`
更新容器:
`$ docker-compose up -d --build`
触发新任务:
`$ curl -X POST http://localhost:8000/create \
-d '{"number":"3"}' \
-H "Content-Type: application/json"
{
"status": "success",
"data": {
"task_id": "65a1c427-ee08-4fb1-9842-d0f90d081c54"
}
}`
然后,使用返回的task_id检查状态:
`$ curl http://localhost:8000/status/65a1c427-ee08-4fb1-9842-d0f90d081c54
{
"status": "SUCCESS", "result": [0, 1, 1, 2]
}`
日志
更新celery服务,以便将芹菜日志转储到一个日志文件:
`celery: image: web volumes: - ./project:/usr/src/app - ./project/logs:/usr/src/app/logs # add this line command: celery -A app.tasks worker --loglevel=info --logfile=logs/celery.log # update this line environment: - CELERY_BROKER=redis://redis:6379/0 - CELERY_BACKEND=redis://redis:6379/0 depends_on: - web - redis`
更新:
`$ docker-compose up -d --build`
因为我们设置了一个卷,所以您应该在本地的 logs/celery.log 中看到日志文件:
`[2022-11-15 17:44:31,471: INFO/MainProcess] Connected to redis://redis:6379/0
[2022-11-15 17:44:31,476: INFO/MainProcess] mingle: searching for neighbors [2022-11-15 17:44:32,488: INFO/MainProcess] mingle: all alone [2022-11-15 17:44:32,503: INFO/MainProcess] celery@80a00f0c917e ready. [2022-11-15 17:44:32,569: INFO/MainProcess] Received task: app.tasks.fib[0b161c4d-5e1c-424a-ae9f-5c3e84de5043] [2022-11-15 17:44:32,593: INFO/ForkPoolWorker-1] Task app.tasks.fib[0b161c4d-5e1c-424a-ae9f-5c3e84de5043] succeeded in 6.018030700040981s: [0, 1, 1, 2]`
花
Flower 是一个基于网络的芹菜实时监控工具。您可以监控当前正在运行的任务,增加或减少工作池,查看图表和一些统计数据,等等。
添加到 requirements.txt:
`celery==5.2.7
falcon==3.1.0
flower==1.2.0
gunicorn==20.1.0
redis==4.3.4`
然后将服务添加到 docker-compose.yml :
`monitor: image: web ports: - 5555:5555 command: celery flower -A app.tasks --port=5555 --broker=redis://redis:6379/0 environment: - CELERY_BROKER=redis://redis:6379/0 - CELERY_BACKEND=redis://redis:6379/0 depends_on: - web - redis`
测试一下:
`$ docker-compose up -d --build`
导航到 http://localhost:5555 查看仪表板。您应该看到一名员工准备就绪:

触发更多的任务:

流动
在编写任何测试之前,让我们后退一步,看看整个工作流程。
本质上,HTTP POST 请求命中了/create。在路由处理程序中,消息被添加到代理中,Celery worker 进程从队列中获取消息并处理任务。同时,web 应用程序继续正常执行和运行,向客户机发回一个带有任务 ID 的响应。然后,客户机可以用 HTTP GET 请求点击/status/<TASK_ID>端点来检查任务的状态。

试验
让我们从单元测试开始:
`class TestCeleryTasks(unittest.TestCase):
def test_fib_task(self):
self.assertEqual(tasks.fib.run(-1), [])
self.assertEqual(tasks.fib.run(1), [0, 1])
self.assertEqual(tasks.fib.run(3), [0, 1, 1, 2])
self.assertEqual(tasks.fib.run(5), [0, 1, 1, 2, 3, 5])`
将上面的测试用例添加到 project/test.py 中,然后更新导入:
`import unittest
from falcon import testing
from app import app, tasks`
运行:
`$ docker-compose run web python test.py`
应该需要大约 20 秒来运行:
`..
----------------------------------------------------------------------
Ran 2 tests in 20.038s
OK`
值得注意的是,在上面的断言中,我们使用了.run方法(而不是.delay)来直接运行任务,而没有芹菜工人。
想模拟芹菜任务吗?
`class TestCeleryTasks(unittest.TestCase):
# def test_fib_task(self):
# self.assertEqual(tasks.fib.run(-1), [])
# self.assertEqual(tasks.fib.run(1), [0, 1])
# self.assertEqual(tasks.fib.run(3), [0, 1, 1, 2])
# self.assertEqual(tasks.fib.run(5), [0, 1, 1, 2, 3, 5])
@patch('app.tasks.fib')
def test_mock_fib_task(self, mock_fib):
mock_fib.run.return_value = []
self.assertEqual(tasks.fib.run(-1), [])
mock_fib.run.return_value = [0, 1]
self.assertEqual(tasks.fib.run(1), [0, 1])
mock_fib.run.return_value = [0, 1, 1, 2]
self.assertEqual(tasks.fib.run(3), [0, 1, 1, 2])
mock_fib.run.return_value = [0, 1, 1, 2, 3, 5]
self.assertEqual(tasks.fib.run(5), [0, 1, 1, 2, 3, 5])`
添加导入:
`from unittest.mock import patch`
`$ docker-compose run web python test.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.002s
OK`
好多了!
您还可以通过运行以下脚本,从容器外部运行完整的集成测试:
`#!/bin/bash
# trigger jobs
test=`curl -X POST http://localhost:8000/create \
-d '{"number":"2"}' \
-H "Content-Type: application/json" \
-s \
| jq -r '.data.task_id'`
# get status
check=`curl http://localhost:8000/status/${test} -s | jq -r '.status'`
while [ "$check" != "SUCCESS" ]
do
check=`curl http://localhost:8000/status/${test} -s | jq -r '.status'`
echo $(curl http://localhost:8000/status/${test} -s)
done`

请记住,这与开发中使用的代理和后端是一样的。您可能想要实例化一个新的 Celery 应用程序来进行测试:
`app = celery.Celery('tests', broker=CELERY_BROKER, backend=CELERY_BACKEND)`
后续步骤
寻找一些挑战?
- 启动 DigitalOcean 并使用 Docker Swarm 或 Kubernetes 将该应用程序部署到多个 droplets 上。
- 使用 React、Angular、Vue 或普通 JavaScript 添加一个基本客户端。允许最终用户开始一项新任务。建立一个轮询机制来检查任务的状态。
从回购中抓取代码。
使用 Flask 和 Redis 队列的异步任务
原文:https://testdriven.io/blog/asynchronous-tasks-with-flask-and-redis-queue/
如果一个长时间运行的任务是你的应用程序工作流的一部分,你应该在正常流程之外的后台处理它。
也许您的 web 应用程序要求用户在注册时提交一个缩略图(可能需要重新调整大小)并确认他们的电子邮件。如果您的应用程序处理了图像并直接在请求处理程序中发送了确认电子邮件,那么最终用户将不得不等待两者都完成。相反,您会希望将这些任务传递给任务队列,并让一个单独的工作进程来处理它,这样您就可以立即将响应发送回客户端。最终用户可以在客户端做其他事情,您的应用程序可以自由地响应其他用户的请求。
本教程着眼于如何配置 Redis 队列 (RQ)来处理 Flask 应用程序中长时间运行的任务。
芹菜也是一种可行的解决方案。查看使用 Flask 和 Celery 的异步任务了解更多信息。
目标
本教程结束时,您将能够:
- 将 Redis 队列集成到 Flask 应用程序中,并创建任务。
- 用容器装烧瓶并用码头工人重新分发。
- 使用单独的工作进程在后台运行长时间运行的任务。
- 设置 RQ 仪表板来监控队列、作业和工人。
- 用 Docker 缩放工人计数。
工作流程
我们的目标是开发一个 Flask 应用程序,它与 Redis Queue 一起处理正常请求/响应周期之外的长时间运行的流程。
- 最终用户通过向服务器端发送 POST 请求开始一项新任务
- 在视图中,一个任务被添加到队列中,任务 id 被发送回客户端
- 使用 AJAX,当任务本身在后台运行时,客户机继续轮询服务器以检查任务的状态

最终,该应用程序将如下所示:

项目设置
想跟着去吗?克隆基础项目,然后检查代码和项目结构:
`$ git clone https://github.com/mjhea0/flask-redis-queue --branch base --single-branch
$ cd flask-redis-queue`
因为我们总共需要管理三个进程(Flask、Redis、worker),所以我们将使用 Docker 来简化我们的工作流,以便可以从一个终端窗口管理它们。
要进行测试,请运行:
`$ docker-compose up -d --build`
打开浏览器进入 http://localhost:5004 。您应该看到:

触发任务
在project/client/static/main . js中设置了一个事件处理程序,它监听一个按钮点击,并用适当的任务类型向服务器发送一个 AJAX POST 请求:1、2或3。
`$('.btn').on('click', function() { $.ajax({ url: '/tasks', data: { type: $(this).data('type') }, method: 'POST' }) .done((res) => { getStatus(res.data.task_id); }) .fail((err) => { console.log(err); }); });`
在服务器端,已经在project/server/main/views . py中配置了一个视图来处理请求:
`@main_blueprint.route("/tasks", methods=["POST"])
def run_task():
task_type = request.form["type"]
return jsonify(task_type), 202`
我们只需要接通 Redis 队列。
重复队列
因此,我们需要启动两个新流程:Redis 和一个 worker。将它们添加到 docker-compose.yml 文件中:
`version: '3.8' services: web: build: . image: web container_name: web ports: - 5004:5000 command: python manage.py run -h 0.0.0.0 volumes: - .:/usr/src/app environment: - FLASK_DEBUG=1 - APP_SETTINGS=project.server.config.DevelopmentConfig depends_on: - redis worker: image: web command: python manage.py run_worker volumes: - .:/usr/src/app environment: - APP_SETTINGS=project.server.config.DevelopmentConfig depends_on: - redis redis: image: redis:6.2-alpine`
将任务添加到“项目/服务器/主目录”中名为 tasks.py 的新文件中:
`# project/server/main/tasks.py
import time
def create_task(task_type):
time.sleep(int(task_type) * 10)
return True`
更新视图以连接到 Redis,对任务进行排队,并使用 id 进行响应:
`@main_blueprint.route("/tasks", methods=["POST"])
def run_task():
task_type = request.form["type"]
with Connection(redis.from_url(current_app.config["REDIS_URL"])):
q = Queue()
task = q.enqueue(create_task, task_type)
response_object = {
"status": "success",
"data": {
"task_id": task.get_id()
}
}
return jsonify(response_object), 202`
不要忘记进口:
`import redis
from rq import Queue, Connection
from flask import render_template, Blueprint, jsonify, request, current_app
from project.server.main.tasks import create_task`
更新BaseConfig:
`class BaseConfig(object):
"""Base configuration."""
WTF_CSRF_ENABLED = True
REDIS_URL = "redis://redis:6379/0"
QUEUES = ["default"]`
你注意到我们在REDIS_URL中引用了redis服务(来自 docker-compose.yml )而不是localhost或其他 IP 吗?查看 Docker Compose 文档以获得更多关于通过主机名连接到其他服务的信息。
最后,我们可以使用一个 Redis 队列 worker ,来处理队列顶部的任务。
manage.py :
`@cli.command("run_worker")
def run_worker():
redis_url = app.config["REDIS_URL"]
redis_connection = redis.from_url(redis_url)
with Connection(redis_connection):
worker = Worker(app.config["QUEUES"])
worker.work()`
这里,我们设置了一个定制的 CLI 命令来触发 worker。
值得注意的是,当命令被执行时,@cli.command()装饰器将提供对应用程序上下文以及来自 project/server/config.py 的相关配置变量的访问。
也添加导入:
`import redis
from rq import Connection, Worker`
将依赖项添加到需求文件中:
构建并旋转新容器:
`$ docker-compose up -d --build`
要触发新任务,请运行:
`$ curl -F type=0 http://localhost:5004/tasks`
您应该会看到类似这样的内容:
`{
"data": {
"task_id": "bdad64d0-3865-430e-9cc3-ec1410ddb0fd"
},
"status": "success"
}`
任务状态
回到客户端的事件处理程序:
`$('.btn').on('click', function() { $.ajax({ url: '/tasks', data: { type: $(this).data('type') }, method: 'POST' }) .done((res) => { getStatus(res.data.task_id); }) .fail((err) => { console.log(err); }); });`
一旦最初的 AJAX 请求返回响应,我们就继续每秒调用带有任务 id 的getStatus()。如果响应成功,一个新行被添加到 DOM 上的表中。
`function getStatus(taskID) { $.ajax({ url: `/tasks/${taskID}`, method: 'GET', }) .done((res) => { const html = `
<tr>
<td>${res.data.task_id}</td>
<td>${res.data.task_status}</td>
<td>${res.data.task_result}</td>
</tr>`; $('#tasks').prepend(html); const taskStatus = res.data.task_status; if (taskStatus === 'finished' || taskStatus === 'failed') return false; setTimeout(function () { getStatus(res.data.task_id); }, 1000); }) .fail((err) => { console.log(err); }); }`
更新视图:
`@main_blueprint.route("/tasks/<task_id>", methods=["GET"])
def get_status(task_id):
with Connection(redis.from_url(current_app.config["REDIS_URL"])):
q = Queue()
task = q.fetch_job(task_id)
if task:
response_object = {
"status": "success",
"data": {
"task_id": task.get_id(),
"task_status": task.get_status(),
"task_result": task.result,
},
}
else:
response_object = {"status": "error"}
return jsonify(response_object)`
向队列添加新任务:
`$ curl -F type=1 http://localhost:5004/tasks`
然后,从响应中获取task_id并调用更新的端点来查看状态:
`$ curl http://localhost:5004/tasks/5819789f-ebd7-4e67-afc3-5621c28acf02
{
"data": {
"task_id": "5819789f-ebd7-4e67-afc3-5621c28acf02",
"task_result": true,
"task_status": "finished"
},
"status": "success"
}`
也在浏览器中测试一下:

仪表盘
RQ Dashboard 是一个轻量级的、基于 web 的 Redis 队列监控系统。
要进行设置,首先在“项目”目录中添加一个名为“仪表板”的新目录。然后,将新的 Dockerfile 添加到新创建的目录中:
`FROM python:3.10-alpine
RUN pip install rq-dashboard
# https://github.com/rq/rq/issues/1469
RUN pip uninstall -y click
RUN pip install click==7.1.2
EXPOSE 9181
CMD ["rq-dashboard"]`
简单地将服务添加到 docker-compose.yml 文件中,如下所示:
`version: '3.8' services: web: build: . image: web container_name: web ports: - 5004:5000 command: python manage.py run -h 0.0.0.0 volumes: - .:/usr/src/app environment: - FLASK_DEBUG=1 - APP_SETTINGS=project.server.config.DevelopmentConfig depends_on: - redis worker: image: web command: python manage.py run_worker volumes: - .:/usr/src/app environment: - APP_SETTINGS=project.server.config.DevelopmentConfig depends_on: - redis redis: image: redis:6.2-alpine dashboard: build: ./project/dashboard image: dashboard container_name: dashboard ports: - 9181:9181 command: rq-dashboard -H redis depends_on: - redis`
构建映像并旋转容器:
`$ docker-compose up -d --build`
导航到 http://localhost:9181 查看仪表板:

开始几项工作来全面测试仪表板:

试着增加几个工人,看看会有什么影响:
`$ docker-compose up -d --build --scale worker=3`
结论
这是关于如何配置 Redis 队列以在 Flask 应用程序中运行长时间运行的任务的基本指南。您应该让队列处理任何可能阻塞或减慢面向用户的代码的进程。
寻找一些挑战?
- 使用 Kubernetes 或 Docker Swarm 在多个digital oceandroplet 上部署该应用程序。
- 为新的端点编写单元测试。(用 fakeredis 模拟 Redis 实例)
- 尝试使用 Flask-SocketIO 打开一个 websocket 连接,而不是轮询服务器。
从回购中抓取代码。
AWS 教程
配置 Django,通过一个亚马逊 S3 桶加载和提供公共和私有的静态和媒体文件。
使用 Bazel 进行可重复的构建
如果您使用相同的源代码和相同的提交在两台不同的机器上运行两个构建,您希望得到相同的结果吗?
嗯,在大多数情况下你不会!
在本文中,我们将找出大多数构建过程中不确定性的来源,并看看如何使用 Bazel 来创建可重复的、密封的构建。然后我们将创建一个可重复的 Flask 应用程序,它可以用 Bazel 构建,这样 Python 解释器和所有依赖项都是密封的。
可重现的构建
根据可再生构建项目,“如果给定相同的源代码、构建环境和构建指令,任何一方都可以重新创建所有指定工件的逐位相同副本,那么构建就是可再生的”。这意味着为了实现可重现的构建,您必须移除所有不确定性的来源。虽然这可能很困难,但有几个好处:
- 由于在大型构建图中缓存了中间构建工件,它可以大大加快构建时间。
- 您可以可靠地确定工件的二进制起源,比如它是从什么来源构建的。
- 可复制的代码更加安全,并减少了攻击面。
气密性
不确定性最常见的原因之一是对构建的输入。我指的是除源代码之外的所有东西:编译器、构建工具、第三方库以及任何其他可能影响构建的输入。为了使您的构建是密封的,所有的引用必须是明确的,无论是完全解析的版本号还是散列。密封信息应该作为源代码的一部分签入。
密封建筑可以采摘樱桃。假设您想要修复运行在生产环境中的旧版本中的一个 bug。如果你有一个封闭的构建过程,你可以检查旧版本,修复错误,然后重新构建代码。由于 hermeticity,所有的构建工具都在源代码库中进行了版本化,所以两个月前构建的项目不会使用今天版本的编译器,因为它可能与两个月前的源代码不兼容。
内部随机性
内部随机性是您在实现可重现构建之前必须解决的一个问题,这是一个很难解决的问题。
时间戳是内部随机性的一个常见来源。它们通常用于跟踪构建完成的时间。摆脱他们。对于可重现的构建,时间戳是不相关的,因为您已经在使用源代码控制跟踪您的构建环境。
对于不初始化值的语言,您需要显式地这样做,以避免由于从内存中捕获随机字节而导致构建中的随机性。
没有简单的方法可以绕过它——您必须检查您的代码!
使用 Bazel 进行可重复的构建
这一切听起来可能有点让人不知所措,但实际上并没有听起来那么复杂。Bazel 让这一过程变得更加简单。
我们现在来看一个使用 Bazel 编译和分发 Flask 应用程序的例子。
装置
Bazel 是创建可重复的密封构件的最佳解决方案之一。它支持许多语言,如 Python、Java、C、C++、Go 等等。从安装 Bazel 开始。
为了构建我们的 Flask 应用程序,我们需要指导 Bazel 密封地使用 python 3.8.3。这意味着我们不能依赖安装在主机上的 Python 版本——我们必须从头编译它!
工作空间
在创建一个保存项目的文件夹后,开始设置一个工作区,它保存项目的源代码和 Bazel 的构建输出。
创建一个名为工作空间的文件:
`workspace(name = "my_flask_app")
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
_configure_python_based_on_os = """
if [[ "$OSTYPE" == "darwin"* ]]; then
./configure --prefix=$(pwd)/bazel_install --with-openssl=$(brew --prefix openssl)
else
./configure --prefix=$(pwd)/bazel_install
fi
"""
# Fetch Python and build it from scratch
http_archive(
name = "python_interpreter",
build_file_content = """
exports_files(["python_bin"])
filegroup(
name = "files",
srcs = glob(["bazel_install/**"], exclude = ["**/* *"]),
visibility = ["//visibility:public"],
)
""",
patch_cmds = [
"mkdir $(pwd)/bazel_install",
_configure_python_based_on_os,
"make",
"make install",
"ln -s bazel_install/bin/python3 python_bin",
],
sha256 = "dfab5ec723c218082fe3d5d7ae17ecbdebffa9a1aea4d64aa3a2ecdd2e795864",
strip_prefix = "Python-3.8.3",
urls = ["https://www.python.org/ftp/python/3.8.3/Python-3.8.3.tar.xz"],
)
# Fetch official Python rules for Bazel
http_archive(
name = "rules_python",
sha256 = "b6d46438523a3ec0f3cead544190ee13223a52f6a6765a29eae7b7cc24cc83a0",
url = "https://github.com/bazelbuild/rules_python/releases/download/0.1.0/rules_python-0.1.0.tar.gz",
)
load("@rules_python//python:repositories.bzl", "py_repositories")
py_repositories()`
我们首先使用 http_archive 规则获取 Python 源代码档案,然后从头开始构建它。
这样,我们就可以确保对 Python 二进制文件和版本的控制。请记住:您不希望使用安装在主机上的 Python 版本,否则您的构建将无法重现。这里的密封性由urls字段和sha256字段来保证,前者告诉 Bazel 在哪里可以找到依赖关系,后者是它的唯一标识符。每个构建都将使用相同的明确的 Python 版本。
接下来,我们获取正式的 Python Bazel 规则。这里,sha256被用作标识符。稍后我们将使用这些规则来创建构建并测试目标。在此之前,我们必须定义我们的工具链。
接下来,我们将配置一个构建文件。
创建一个名为 BUILD 的文件:
`load("@rules_python//python:defs.bzl", "py_runtime", "py_runtime_pair")
py_runtime(
name = "python3_runtime",
files = ["@python_interpreter//:files"],
interpreter = "@python_interpreter//:python_bin",
python_version = "PY3",
visibility = ["//visibility:public"],
)
py_runtime_pair(
name = "py_runtime_pair",
py2_runtime = None,
py3_runtime = ":python3_runtime",
)
toolchain(
name = "py_3_toolchain",
toolchain = ":py_runtime_pair",
toolchain_type = "@bazel_tools//tools/python:toolchain_type",
)`
该配置将从我们在工作区中定义的 Python 解释器创建一个 Python 运行时,然后在工具链中使用。
最后,为了注册工具链,将下面一行添加到工作空间文件的末尾:
`# The Python toolchain must be registered ALWAYS at the end of the file
register_toolchains("//:py_3_toolchain")`
不错!现在,您已经建立了一个密封的 Bazel 构建环境。不要只相信我的话,让我们写一个测试。
试验
为了用 Python 编写测试,我们需要 pytest ,所以让我们添加一个 requirements.txt 文件:
`attrs==20.3.0 --hash=sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6
more-itertools==8.2.0 --hash=sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c
packaging==20.3 --hash=sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752
pluggy==0.13.1 --hash=sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d
py==1.8.1 --hash=sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0
pyparsing==2.0.2 --hash=sha256:17e43d6b17588ed5968735575b3983a952133ec4082596d214d7090b56d48a06
pytest==5.4.1 --hash=sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172
six==1.15.0 --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced
wcwidth==0.1.9 --hash=sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1`
除了 pytest,我们还添加了所有的子依赖项。我们还添加了版本和 sha256 散列作为 hermeticity 的标识符。
现在,我们可以通过添加用于处理依赖关系的pip_install规则来再次修改工作区。在register_toolchain之前添加以下内容:
`# Third party libraries
load("@rules_python//python:pip.bzl", "pip_install")
pip_install(
name = "py_deps",
python_interpreter_target = "@python_interpreter//:python_bin",
requirements = "//:requirements.txt",
)`
您现在应该已经:
`workspace(name = "my_flask_app")
load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
_configure_python_based_on_os = """
if [[ "$OSTYPE" == "darwin"* ]]; then
./configure --prefix=$(pwd)/bazel_install --with-openssl=$(brew --prefix openssl)
else
./configure --prefix=$(pwd)/bazel_install
fi
"""
# Fetch Python and build it from scratch
http_archive(
name = "python_interpreter",
build_file_content = """
exports_files(["python_bin"])
filegroup(
name = "files",
srcs = glob(["bazel_install/**"], exclude = ["**/* *"]),
visibility = ["//visibility:public"],
)
""",
patch_cmds = [
"mkdir $(pwd)/bazel_install",
_configure_python_based_on_os,
"make",
"make install",
"ln -s bazel_install/bin/python3 python_bin",
],
sha256 = "dfab5ec723c218082fe3d5d7ae17ecbdebffa9a1aea4d64aa3a2ecdd2e795864",
strip_prefix = "Python-3.8.3",
urls = ["https://www.python.org/ftp/python/3.8.3/Python-3.8.3.tar.xz"],
)
# Fetch official Python rules for Bazel
http_archive(
name = "rules_python",
sha256 = "b6d46438523a3ec0f3cead544190ee13223a52f6a6765a29eae7b7cc24cc83a0",
url = "https://github.com/bazelbuild/rules_python/releases/download/0.1.0/rules_python-0.1.0.tar.gz",
)
load("@rules_python//python:repositories.bzl", "py_repositories")
py_repositories()
# Third party libraries
load("@rules_python//python:pip.bzl", "pip_install")
pip_install(
name = "py_deps",
python_interpreter_target = "@python_interpreter//:python_bin",
requirements = "//:requirements.txt",
)
# The Python toolchain must be registered ALWAYS at the end of the file
register_toolchains("//:py_3_toolchain")`
我们现在准备好编写测试了。
创建一个名为“test”的新文件夹,并添加一个名为 compiler_version_test.py 的新测试文件:
`import os
import platform
import sys
import pytest
class TestPythonVersion:
def test_version(self):
assert(os.path.abspath(os.path.join(os.getcwd(),"..", "python_interpreter", "python_bin")) in sys.executable)
assert(platform.python_version() == "3.8.3")
if __name__ == "__main__":
import pytest
raise SystemExit(pytest.main([__file__]))`
这将测试 Python 可执行文件是否存在以及版本是否正确。
为了将它包含在构建过程中,将一个构建文件添加到“test”文件夹中:
`load("@rules_python//python:defs.bzl", "py_test")
load("@py_deps//:requirements.bzl", "requirement")
py_test(
name = "compiler_version_test",
srcs = ["compiler_version_test.py"],
deps = [
requirement("attrs"),
requirement("more-itertools"),
requirement("packaging"),
requirement("pluggy"),
requirement("py"),
requirement("pytest"),
requirement("wcwidth"),
],
)`
这里我们定义了一个名为compiler_version_test的 py_test 规则、源文件和所需的依赖项。一切都很清楚。
此时,您应该会看到这样的内容:
`├── BUILD
├── WORKSPACE
├── requirements.txt
└── test
├── BUILD
└── compiler_version_test.py`
这样,我们就可以运行我们的第一个“baz elized”Python 测试了!
从项目根目录运行:
`$ bazel test //test:compiler_version_test`
输出:
`Starting local Bazel server and connecting to it...
INFO: Analyzed target //test:compiler_version_test (31 packages loaded, 8550 targets configured).
INFO: Found 1 test target...
Target //test:compiler_version_test up-to-date:
bazel-bin/test/compiler_version_test
INFO: Elapsed time: 172.459s, Critical Path: 3.10s
INFO: 2 processes: 2 darwin-sandbox.
INFO: Build completed successfully, 2 total actions
//test:compiler_version_test PASSED in 0.6s
Executed 1 out of 1 test: 1 test passes.
INFO: Build completed successfully, 2 total actions`
至此,您已经有了一个密封配置的工作 Python 环境。
瓶
我们现在准备开发 Flask 应用程序。
创建一个“src”文件夹。然后,向其中添加一个名为 flask_app.py 的文件:
`import platform
import subprocess
import sys
from flask import Flask
def cmd(args):
process = subprocess.Popen(args, stdout=subprocess.PIPE)
out, _ = process.communicate()
return out.decode('ascii').strip()
app = Flask(__name__)
@app.route('/')
def python_versions():
bazel_python_path = f'Python executable used by Bazel is: {sys.executable} <br/><br/>'
bazel_python_version = f'Python version used by Bazel is: {platform.python_version()} <br/><br/>'
host_python_path = f'Python executable on the HOST machine is: {cmd(["which", "python3"])} <br/><br/>'
host_python_version = f'Python version on the HOST machine is: {cmd(["python3", "-c", "import platform; print(platform.python_version())"])}'
python_string = (
bazel_python_path
+ bazel_python_version
+ host_python_path
+ host_python_version
)
return python_string
if __name__ == '__main__':
app.run()`
这是一个简单的 Flask 应用程序,将显示主机的二进制路径和 Python 版本以及 Bazel 使用的版本。
要构建它,我们需要向“src”添加一个构建文件:
`load("@rules_python//python:defs.bzl", "py_binary")
load("@py_deps//:requirements.bzl", "requirement")
py_binary(
name = "flask_app",
srcs = ["flask_app.py"],
python_version = "PY3",
deps = [
requirement("flask"),
requirement("jinja2"),
requirement("werkzeug"),
requirement("itsdangerous"),
requirement("click"),
],
)`
我们还需要用以下内容扩展 requirements.txt 文件:
`click==5.1 --hash=sha256:0c22a2cd5a1d741e993834df99133de07eff6cc1bf06f137da2c5f3bab9073a6
flask==1.1.2 --hash=sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557
itsdangerous==0.24 --hash=sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519
Jinja2==2.10.0 --hash=sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd
MarkupSafe==0.23 --hash=sha256:a4ec1aff59b95a14b45eb2e23761a0179e98319da5a7eb76b56ea8cdc7b871c3
Werkzeug==0.15.5 --hash=sha256:87ae4e5b5366da2347eb3116c0e6c681a0e939a33b2805e2c0cbd282664932c4`
完整文件:
`attrs==20.3.0 --hash=sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6
click==5.1 --hash=sha256:0c22a2cd5a1d741e993834df99133de07eff6cc1bf06f137da2c5f3bab9073a6
flask==1.1.2 --hash=sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557
itsdangerous==0.24 --hash=sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519
Jinja2==2.10.0 --hash=sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd
MarkupSafe==0.23 --hash=sha256:a4ec1aff59b95a14b45eb2e23761a0179e98319da5a7eb76b56ea8cdc7b871c3
more-itertools==8.2.0 --hash=sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c
packaging==20.3 --hash=sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752
pluggy==0.13.1 --hash=sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d
py==1.8.1 --hash=sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0
pyparsing==2.0.2 --hash=sha256:17e43d6b17588ed5968735575b3983a952133ec4082596d214d7090b56d48a06
pytest==5.4.1 --hash=sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172
six==1.15.0 --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced
wcwidth==0.1.9 --hash=sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1
Werkzeug==0.15.5 --hash=sha256:87ae4e5b5366da2347eb3116c0e6c681a0e939a33b2805e2c0cbd282664932c4`
然后,要运行该应用程序,请运行:
`$ bazel run //src:flask_app`
您应该看到:
`INFO: Analyzed target //src:flask_app (10 packages loaded, 184 targets configured).
INFO: Found 1 target...
Target //src:flask_app up-to-date:
bazel-bin/src/flask_app
INFO: Elapsed time: 7.430s, Critical Path: 1.12s
INFO: 4 processes: 4 internal.
INFO: Build completed successfully, 4 total actions
INFO: Build completed successfully, 4 total actions
* Serving Flask app "flask_app" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)`
现在,应用程序正在本地主机上运行。打开浏览器并导航至 http://127.0.0.1:5000/ 。您应该会看到类似如下的内容:
`Python executable used by Bazel is: /private/var/tmp/_bazel_michael/0c5c16dff39796b913e37a926dff4861/execroot/my_flask_app/bazel-out/darwin-fastbuild/bin/src/flask_app.runfiles/python_interpreter/python_bin Python version used by Bazel is: 3.8.3 Python executable in the HOST machine is: /Users/michael/.pyenv/versions/3.9.0/bin/python3 Python version in the HOST machine is: 3.9.0`
正如我们所料,Bazel 使用的是我们从头编译的 Python 版本 3.8.3,而不是我主机上的 Python 3.9.0。
再现性测试
最后,我们确定构建是可重复的吗?
要进行测试,请运行两次构建,并通过比较 md5 哈希来检查输出二进制文件是否有任何差异:
`$ md5sum $(bazel info bazel-bin)/src/flask_app
2075a7ec4e8eb7ced16f0d9b3d8c5619 /private/var/tmp/_bazel_michael/0c5c16dff39796b913e37a926dff4861/execroot/my_flask_app/bazel-out/darwin-fastbuild/bin/src/flask_app
$ bazel clean --expunge_async
# or 'bazel clean --expunge' on non-linux platforms
INFO: Starting clean.
$ bazel build //...
Starting local Bazel server and connecting to it...
INFO: Analyzed 4 targets (38 packages loaded, 8729 targets configured).
INFO: Found 4 targets...
INFO: Elapsed time: 183.923s, Critical Path: 1.65s
INFO: 7 processes: 7 internal.
INFO: Build completed successfully, 7 total actions
$ md5sum $(bazel info bazel-bin)/src/flask_app
2075a7ec4e8eb7ced16f0d9b3d8c5619 /private/var/tmp/_bazel_michael/0c5c16dff39796b913e37a926dff4861/execroot/my_flask_app/bazel-out/darwin-fastbuild/bin/src/flask_app`
这里,我们计算了刚刚构建的二进制文件的散列,清理了所有构建工件和依赖项,并再次运行构建。新的二进制文件和旧的一模一样!
--
所以你的体型是密封的,对吗?
好吧,其实并不是完全可复制的,我们来看看为什么。
跳回工作区文件。在这个文件中,我们试图在 Bazel 内部构建 Python,以实现完全的可再现性。然而,使用http_archive的patch_cmds意味着 Python 是使用运行构建的主机的编译器构建的。Python 解释器被固定在一个精确的版本上,它将依赖于机器的 GCC 和系统库,而这些库并没有以任何方式被固定或控制。换句话说,构建是不完全可复制的。
不过,这是有解决办法的!
示例:
- 您可以使用固定的 GCC 版本从 Docker 容器运行
bazel build,然后在您的项目中签入 Docker 信息。这是 CI 系统中的一种常见方法。 - 您可以使用预编译的二进制可执行文件,将其签入源代码控制,并将其固定在构建版本上,而不是从头开始编译 Python。
- 您可以使用类似于 Nix 的工具,它允许将外部依赖项(如系统库)密封地导入 Bazel。
结论
总结最大的收获:
- 不要想当然地认为你的构建是可重复的
- 密封性使樱桃采摘成为可能
- 构建的输入必须用源代码进行版本控制
- 内部随机性可能是偷偷摸摸的,但必须消除
- 多亏了 Bazel,您有了一个密封的 Python 工作环境
- 您已经看到了如何以可重现的方式编译 Flask 应用程序
既然您已经熟悉了可复制的、密封的构建的含义,那么您的使您的构建可复制的旅程就开始了。
测试你目前正在做的项目的二进制文件的 md5,让我知道结果。
Django 和 Aloe 的行为驱动开发
原文:https://testdriven.io/blog/behavior-driven-development-with-django-and-aloe/
假设你是一名 Django 开发人员,正在为一家精益创业公司构建一个社交网络。CEO 向你的团队施压,要求获得 MVP。工程师们已经同意使用行为驱动开发 (BDD)来构建产品,以交付快速高效的结果。产品负责人给你第一个特性请求,并遵循所有好的编程方法的实践,你通过编写测试开始 BDD 过程。接下来,您编写一些功能代码以通过测试,然后考虑您的设计。最后一步要求您分析特征本身。它属于你的应用吗?
我们不能回答你这个问题,但是我们可以教你什么时候问这个问题。在下面的教程中,我们通过使用 Django 和Aloe编程一个示例特性,带你走过 BDD 开发周期。继续学习如何使用 BDD 过程来帮助快速捕捉和修复糟糕的设计,同时编写稳定的应用程序。
目标
完成本教程后,您应该能够:
- 描述并实践行为驱动开发(BDD)
- 解释如何在新项目中实施 BDD
- 使用芦荟测试您的 Django 应用程序
项目设置
当你阅读这篇文章时,你想要建立这个项目吗?
开始于:
- 添加项目目录。
- 创建和激活虚拟环境。
然后,安装以下依赖项并启动一个新的 Django 项目:
`(venv)$ pip install \
django==3.2.4 \
djangorestframework==3.12.4 \
aloe_django==0.2.0
(venv)$ django-admin startproject example_bdd .
(venv)$ python manage.py startapp example`
如果在尝试安装
aloe_django时出现此错误,您可能需要手动安装 setuptools-scm (pip install setuptools-scm):distutils.errors.DistutilsError: Could not find suitable distribution for Requirement.parse('setuptools_scm')
更新 settings.py 中的INSTALLED_APPS列表:
`INSTALLED_APPS = [
...
'aloe_django',
'rest_framework',
'example',
]`
只是在找代码吗?从回购中抢过来。
BDD 概述
行为驱动开发是一种测试代码的方式,它挑战你不断地重新审视你的设计。当你写一个测试时,你要回答这个问题我的代码做了我期望它做的事情吗?通过断言。失败的测试暴露了代码中的错误。使用 BDD,你分析一个特性:用户体验是我期望的那样吗?没有什么比失败的测试更能暴露糟糕的特性了,但是带来糟糕体验的后果却是实实在在的。
将 BDD 作为测试开发周期的一部分来执行。用测试画出一个特性的功能边界。创建在细节中着色的代码。退一步考虑你的设计。然后再重新做一遍,直到画面完整。
查看下面的帖子以获得对 BDD 更深入的解释。
您的第一个功能请求
"用户应该能够登录应用程序,看到他们的朋友名单."
这就是你的产品经理开始谈论这款应用的第一个功能的方式。虽然不多,但是你可以用它来写一个测试。她实际上要求两项功能:
- 用户认证
- 在用户之间形成关系的能力。
这里有一个经验法则:像对待灯塔一样对待一个连词,警告你不要试图一次测试太多东西。如果你在测试语句中看到“and”或“or ”,你应该把测试分成更小的部分。
记住这一点,利用特性请求的前半部分,编写一个测试场景:用户可以登录应用程序。为了支持用户身份验证,您的应用程序必须存储用户凭据,并为用户提供使用这些凭据访问其数据的方法。以下是你如何将这些标准转化为芦荟。特征文件。
例子/特色/友谊.特色
`Feature: Friendships
Scenario: A user can log into the app
Given I empty the "User" table
And I create the following users:
| id | email | username | password |
| 1 | [[email protected]](/cdn-cgi/l/email-protection) | Annie | pAssw0rd! |
When I log in with username "Annie" and password "pAssw0rd!"
Then I am logged in`
一个芦荟测试用例被称为一个特性。你使用两个文件编程特征:一个特征文件和一个步骤文件。
- 特性文件由用简单英语编写的语句组成,这些语句描述了如何配置、执行和确认测试结果。使用
Feature关键字来标记特性,使用Scenario关键字来定义您计划测试的用户情景。在上面的示例中,该场景定义了一系列步骤,解释如何填充用户数据库表,让用户登录应用程序,并验证登录。所有的步骤语句都必须以四个关键字之一开头:Given、When、Then或And。 - 步骤文件包含使用正则表达式映射到特征文件步骤的 Python 函数。
您可能需要添加一个 init。py 文件到“特征”目录,以便解释器正确加载友谊 _ 步骤. py 文件。
运行python manage.py harvest并查看以下输出。
`nosetests --verbosity=1
Creating test database for alias 'default'...
E
======================================================================
ERROR: A user can log into the app (example.features.friendships: Friendships)
----------------------------------------------------------------------
Traceback (most recent call last):
File "django-aloe-bdd/venv/lib/python3.9/site-packages/aloe/registry.py", line 151, in wrapped
return function(*args, **kwargs)
File "django-aloe-bdd/example/features/friendships.feature", line 5, in A user can log into the app
Given I empty the "User" table
File "django-aloe-bdd/venv/lib/python3.9/site-packages/aloe/registry.py", line 151, in wrapped
return function(*args, **kwargs)
File "django-aloe-bdd/venv/lib/python3.9/site-packages/aloe/exceptions.py", line 44, in undefined_step
raise NoDefinitionFound(step)
aloe.exceptions.NoDefinitionFound: The step r"Given I empty the "User" table" is not defined
-------------------- >> begin captured logging << --------------------
asyncio: DEBUG: Using selector: KqueueSelector
--------------------- >> end captured logging << ---------------------
----------------------------------------------------------------------
Ran 1 test in 0.506s
FAILED (errors=1)
Destroying test database for alias 'default'...`
测试失败是因为您没有将 step 语句映射到 Python 函数。在以下文件中执行此操作。
示例/功能/友谊 _ 步骤. py
`from aloe import before, step, world
from aloe.tools import guess_types
from aloe_django.steps.models import get_model
from django.contrib.auth.models import User
from rest_framework.test import APIClient
@before.each_feature
def before_each_feature(feature):
world.client = APIClient()
@step('I empty the "([^"]+)" table')
def step_empty_table(self, model_name):
get_model(model_name).objects.all().delete()
@step('I create the following users:')
def step_create_users(self):
for user in guess_types(self.hashes):
User.objects.create_user(**user)
@step('I log in with username "([^"]+)" and password "([^"]+)"')
def step_log_in(self, username, password):
world.is_logged_in = world.client.login(username=username, password=password)
@step('I am logged in')
def step_confirm_log_in(self):
assert world.is_logged_in`
每个语句都通过一个@step()装饰器映射到一个 Python 函数。例如,Given I empty the "User" table将触发step_empty_table()功能运行。在这种情况下,字符串"User"将被捕获并作为model_name参数传递给函数。Aloe API 包括一个名为world的特殊全局变量,可以用来在测试步骤之间存储和检索数据。注意world.is_logged_in变量是如何在step_log_in()中创建,然后在step_confirm_log_in()中访问的。Aloe 还定义了一个特殊的@before decorator 来在测试运行之前执行函数。
最后一件事:考虑以下语句的结构:
`And I create the following users:
| id | email | username | password |
| 1 | [[email protected]](/cdn-cgi/l/email-protection) | Annie | pAssw0rd! |`
有了 Aloe,您可以使用表格结构来表示字典列表。然后,您可以使用self.hashes来访问数据。在guess_types()函数中包装self.hashes会返回一个列表,其中包含正确输入的字典值。在这个例子中,guess_types(self.hashes)返回这个代码。
`[{'id': 1, 'email': '[[email protected]](/cdn-cgi/l/email-protection)', 'username': 'Annie', 'password': 'pAssw0rd!'}]`
用下面的命令运行 Aloe 测试套件,看到所有测试都通过了。
`(venv)$ python manage.py harvest`
`nosetests --verbosity=1
Creating test database for alias 'default'... . ---------------------------------------------------------------------- Ran 1 test in 0.512s
OK
Destroying test database for alias 'default'...`
为功能请求的第二部分编写一个测试场景:用户可以看到朋友列表。
例子/特色/友谊.特色
`Scenario: A user can see a list of friends
Given I empty the "Friendship" table
When I get a list of friends
Then I see the following response data:
| id | email | username |`
在运行 Aloe 测试套件之前,修改第一个场景,使用关键字Background代替Scenario。背景是一种特殊类型的场景,在由特征文件中的Scenario定义的每个程序块之前运行一次。每个场景都需要从头开始,每次运行时使用Background都会刷新数据。
例子/特色/友谊.特色
`Feature: Friendships
Background: Set up common data
Given I empty the "User" table
And I create the following users:
| id | email | username | password |
| 1 | [[email protected]](/cdn-cgi/l/email-protection) | Annie | pAssw0rd! |
| 2 | [[email protected]](/cdn-cgi/l/email-protection) | Brian | pAssw0rd! |
| 3 | [[email protected]](/cdn-cgi/l/email-protection) | Casey | pAssw0rd! |
When I log in with username "Annie" and password "pAssw0rd!"
Then I am logged in
Scenario: A user can see a list of friends
Given I empty the "Friendship" table
And I create the following friendships:
| id | user1 | user2 |
| 1 | 1 | 2 |
# Annie and Brian are now friends.
When I get a list of friends
Then I see the following response data:
| id | email | username |
| 2 | [[email protected]](/cdn-cgi/l/email-protection) | Brian |`
现在您正在处理多个用户之间的友谊,开始时向数据库添加几个新的用户记录。新的场景从“Friendship”表中清除所有条目,并创建一条新记录来定义 Annie 和 Brian 之间的友谊。然后,它调用一个 API 来检索 Annie 的朋友列表,并确认响应数据包括 Brian。
第一步是创建一个Friendship模型。很简单:它只是将两个用户链接在一起。
example/models.py
`from django.conf import settings
from django.db import models
class Friendship(models.Model):
user1 = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='user1_friendships'
)
user2 = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='user2_friendships'
)`
进行迁移并运行它。
`(venv)$ python manage.py makemigrations
(venv)$ python manage.py migrate`
接下来,为I create the following friendships:语句创建一个新的测试步骤。
示例/功能/友谊 _ 步骤. py
`@step('I create the following friendships:')
def step_create_friendships(self):
Friendship.objects.bulk_create([
Friendship(
id=data['id'],
user1=User.objects.get(id=data['user1']),
user2=User.objects.get(id=data['user2'])
) for data in guess_types(self.hashes)
])`
将Friendship模型导入添加到文件中。
`from ..models import Friendship`
创建一个 API 来获取登录用户的朋友列表。创建一个序列化程序来处理User资源的表示。
example/serializer . py
`from django.contrib.auth.models import User
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ('id', 'email', 'username',)
read_only_fields = fields`
创建一个管理器来处理您的Friendship模型的表级功能。
example/models.py
`# New import!
from django.db.models import Q
class FriendshipManager(models.Manager):
def friends(self, user):
"""Get all users that are friends with the specified user."""
# Get all friendships that involve the specified user.
friendships = self.get_queryset().select_related(
'user1', 'user2'
).filter(
Q(user1=user) |
Q(user2=user)
)
def other_user(friendship):
if friendship.user1 == user:
return friendship.user2
return friendship.user1
return map(other_user, friendships)`
friends()函数检索指定用户与其他用户共享的所有友谊。然后它返回其他用户的列表。将objects = FriendshipManager()添加到Friendship模型中。
创建一个简单的ListAPIView来返回您的User资源的 JSON 序列化列表。
example/views.py
`from rest_framework.generics import ListAPIView
from .models import Friendship
from .serializers import UserSerializer
class FriendsView(ListAPIView):
serializer_class = UserSerializer
def get_queryset(self):
return Friendship.objects.friends(self.request.user)`
最后,添加一个 URL 路径。
example_bdd/urls.py
`from django.urls import path
from example.views import FriendsView
urlpatterns = [
path('friends/', FriendsView.as_view(), name='friends'),
]`
创建剩余的 Python 步骤函数:一个调用新的 API,另一个通用函数确认响应负载数据。(我们可以重用这个函数来检查任何有效载荷。)
示例/功能/友谊 _ 步骤. py
`@step('I get a list of friends')
def step_get_friends(self):
world.response = world.client.get('/friends/')
@step('I see the following response data:')
def step_confirm_response_data(self):
response = world.response.json()
if isinstance(response, list):
assert guess_types(self.hashes) == response
else:
assert guess_types(self.hashes)[0] == response`
运行测试并观察它们是否通过。
`(venv)$ python manage.py harvest`
想想另一个测试场景。没有朋友的用户在调用 API 时应该会看到一个空列表。
例子/特色/友谊.特色
`Scenario: A user with no friends sees an empty list
Given I empty the "Friendship" table
# Annie has no friends.
When I get a list of friends
Then I see the following response data:
| id | email | username |`
不需要新的 Python 函数。您可以重复使用所有步骤!测试无需任何干预即可通过。
您还需要最后一项功能来实现这一特性。用户可以获得他们的朋友列表,但是他们如何结交新朋友呢?这里有一个新的场景:“一个用户应该能够将另一个用户添加为好友。”用户应该能够调用 API 来与另一个用户建立友谊。您知道,如果在数据库中创建了记录,API 就会工作。
例子/特色/友谊.特色
`Scenario: A user can add a friend
Given I empty the "Friendship" table
When I add the following friendship:
| user1 | user2 |
| 1 | 2 |
Then I see the following rows in the "Friendship" table:
| user1 | user2 |
| 1 | 2 |`
创建新的阶跃函数。
示例/功能/友谊 _ 步骤. py
`@step('I add the following friendship:')
def step_add_friendship(self):
world.response = world.client.post('/friendships/', data=guess_types(self.hashes[0]))
@step('I see the following rows in the "([^"]+)" table:')
def step_confirm_table(self, model_name):
model_class = get_model(model_name)
for data in guess_types(self.hashes):
has_row = model_class.objects.filter(**data).exists()
assert has_row`
扩展管理器并做一些重构。
example/models.py
`class FriendshipManager(models.Manager):
def friendships(self, user):
"""Get all friendships that involve the specified user."""
return self.get_queryset().select_related(
'user1', 'user2'
).filter(
Q(user1=user) |
Q(user2=user)
)
def friends(self, user):
"""Get all users that are friends with the specified user."""
friendships = self.friendships(user)
def other_user(friendship):
if friendship.user1 == user:
return friendship.user2
return friendship.user1
return map(other_user, friendships)`
添加一个新的序列化程序来呈现Friendship资源。
example/serializer . py
`class FriendshipSerializer(serializers.ModelSerializer):
class Meta:
model = Friendship
fields = ('id', 'user1', 'user2',)
read_only_fields = ('id',)`
添加新视图。
example/views.py
`class FriendshipsView(ModelViewSet):
serializer_class = FriendshipSerializer
def get_queryset(self):
return Friendship.objects.friendships(self.request.user)`
添加新的 URL。
example/urls.py
`path('friendships/', FriendshipsView.as_view({'post': 'create'})),`
您的代码工作,测试通过!
分析特征
既然您已经成功地对您的特性进行了编程和测试,那么是时候对其进行分析了。当一个用户添加另一个用户时,两个用户成为朋友。这不是理想的行为。也许另一个用户不想成为朋友-他们没有发言权吗?一个用户应该请求与另一个用户建立友谊,而另一个用户应该能够接受或拒绝这个友谊。
修改用户添加另一个用户为好友的场景:“一个用户应该能够请求与另一个用户成为朋友。”
用这个替换Scenario: A user can add a friend。
例子/特色/友谊.特色
`Scenario: A user can request a friendship with another user
Given I empty the "Friendship" table
When I request the following friendship:
| user1 | user2 |
| 1 | 2 |
Then I see the following response data:
| id | user1 | user2 | status |
| 3 | 1 | 2 | PENDING |`
重构您的测试步骤以使用新的 API,/friendship-requests/。
示例/功能/友谊 _ 步骤. py
`@step('I request the following friendship:')
def step_request_friendship(self):
world.response = world.client.post('/friendship-requests/', data=guess_types(self.hashes[0]))`
首先向Friendship模型添加一个新的status字段。
example/models.py
`class Friendship(models.Model):
PENDING = 'PENDING'
ACCEPTED = 'ACCEPTED'
REJECTED = 'REJECTED'
STATUSES = (
(PENDING, PENDING),
(ACCEPTED, ACCEPTED),
(REJECTED, REJECTED),
)
objects = FriendshipManager()
user1 = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='user1_friendships'
)
user2 = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='user2_friendships'
)
status = models.CharField(max_length=8, choices=STATUSES, default=PENDING)`
友谊可以是ACCEPTED或REJECTED。如果其他用户没有采取行动,那么默认状态是PENDING。
进行迁移并迁移数据库。
`(venv)$ python manage.py makemigrations
(venv)$ python manage.py migrate`
将FriendshipsView重命名为FriendshipRequestsView。
example/views.py
`class FriendshipRequestsView(ModelViewSet):
serializer_class = FriendshipSerializer
def get_queryset(self):
return Friendship.objects.friendships(self.request.user)`
用新的 URL 路径替换旧的路径。
example/urls.py
`path('friendship-requests/', FriendshipRequestsView.as_view({'post': 'create'}))`
添加新的测试场景来测试接受和拒绝操作。
例子/特色/友谊.特色
`Scenario: A user can accept a friendship request
Given I empty the "Friendship" table
And I create the following friendships:
| id | user1 | user2 | status |
| 1 | 2 | 1 | PENDING |
When I accept the friendship request with ID "1"
Then I see the following response data:
| id | user1 | user2 | status |
| 1 | 2 | 1 | ACCEPTED |
Scenario: A user can reject a friendship request
Given I empty the "Friendship" table
And I create the following friendships:
| id | user1 | user2 | status |
| 1 | 2 | 1 | PENDING |
When I reject the friendship request with ID "1"
Then I see the following response data:
| id | user1 | user2 | status |
| 1 | 2 | 1 | REJECTED |`
添加新的测试步骤。
示例/功能/友谊 _ 步骤. py
`@step('I accept the friendship request with ID "([^"]+)"')
def step_accept_friendship_request(self, pk):
world.response = world.client.put(f'/friendship-requests/{pk}/', data={
'status': Friendship.ACCEPTED
})
@step('I reject the friendship request with ID "([^"]+)"')
def step_reject_friendship_request(self, pk):
world.response = world.client.put(f'/friendship-requests/{pk}/', data={
'status': Friendship.REJECTED
})`
再添加一个 URL 路径。用户需要针对他们想要接受或拒绝的特定友谊。
example/urls.py
`path('friendship-requests/<int:pk>/', FriendshipRequestsView.as_view({'put': 'partial_update'}))`
更新Scenario: A user can see a list of friends以包含新的status字段。
例子/特色/友谊.特色
`Scenario: A user can see a list of friends
Given I empty the "Friendship" table
And I create the following friendships:
| id | user1 | user2 | status |
| 1 | 1 | 2 | ACCEPTED |
# Annie and Brian are now friends.
When I get a list of friends
Then I see the following response data:
| id | email | username |
| 2 | [[email protected]](/cdn-cgi/l/email-protection) | Brian |`
在Scenario: A user can see a list of friends之后再添加一个场景来测试状态过滤。用户的朋友包括已经接受用户的友谊请求的人。没有采取行动或拒绝请求的人不予考虑。
例子/特色/友谊.特色
`Scenario: A user with no accepted friendship requests sees an empty list
Given I empty the "Friendship" table
And I create the following friendships:
| id | user1 | user2 | status |
| 1 | 1 | 2 | PENDING |
| 2 | 1 | 3 | REJECTED |
When I get a list of friends
Then I see the following response data:
| id | email | username |`
编辑step_create_friendships()函数,在Friendship模型上实现status字段。
示例/功能/友谊 _ 步骤. py
`@step('I create the following friendships:')
def step_create_friendships(self):
Friendship.objects.bulk_create([
Friendship(
id=data['id'],
user1=User.objects.get(id=data['user1']),
user2=User.objects.get(id=data['user2']),
status=data['status']
) for data in guess_types(self.hashes)
])`
并编辑FriendshipSerializer序列化器来实现Friendship模型上的状态字段。
example/serializer . py
`class FriendshipSerializer(serializers.ModelSerializer):
class Meta:
model = Friendship
fields = ('id', 'user1', 'user2', 'status',)
read_only_fields = ('id',)`
通过调整管理器上的friends()方法完成过滤。
example/models.py
`def friends(self, user):
"""Get all users that are friends with the specified user."""
friendships = self.friendships(user).filter(status=Friendship.ACCEPTED)
def other_user(friendship):
if friendship.user1 == user:
return friendship.user2
return friendship.user1
return map(other_user, friendships)`
功能完成!
结论
如果你从这篇文章中得到了什么,我希望是这个:行为驱动的开发不仅是关于编写、测试和设计代码,也是关于特性分析。没有这个关键的步骤,你就不是在创造软件,你只是在编程。BDD 不是生产软件的唯一方法,但它是一个很好的方法。如果你正在用 Django 项目实践 BDD,试试芦荟吧。
从回购中抓取代码。
用 Python 和 Pyodide 构建单页面应用程序——第 1 部分
如果你和一个人用他能理解的语言交谈,那会进入他的大脑。如果你用他自己的语言和他交谈,那会触及他的内心。
纳尔逊·曼德拉
WebAssembly (WASM)为许多语言在不同的环境中使用打开了大门——比如浏览器、云、无服务器和区块链,仅举几个例子——这些语言以前不可能在这些环境中使用。例如,使用利用 WASM 的 Pyodide ,您可以在浏览器中运行 Python。你也可以使用像 Wasmer 这样的运行时将 Python 编译成 WebAssembly,将其容器化,然后在 edge、IoT、操作系统等不同的目的地运行容器。
在本教程中,您将使用 Python 和 Pyodide 构建一个单页面应用程序来操作 DOM 和管理状态。
--
Python 单页面应用系列:
- 第一部分(本教程!):学习 Pyodide 的基础知识并创建基础应用程序
- 第二部分 :用 Pandas 分析和操作数据,使用 web worker 加速应用程序
- 第三部分 :创建 Python 包,添加附加特性,添加持久化数据层
目标
学完本教程后,您应该能够:
- 使用 Pyodide 和 JavaScript 在两者之间共享和访问对象
- 直接从 Python 代码操作 DOM
- 在浏览器中运行 Python 强大的数据科学库
- 创建一个单页面应用程序(SPA)应用程序,它从远程文件中获取数据,用 Pandas 处理数据,并在浏览器中呈现数据
浏览器中的 Python
这是什么意思?
在浏览器中运行 Python 意味着我们可以直接在客户端执行 Python 代码,而不需要在服务器上托管和执行代码。这是通过 WebAssembly 实现的,它允许我们构建真正的“无服务器”应用。
为什么它很重要?为什么不用 JavaScript 呢?
在浏览器中运行 Python 的主要目标不是取代 JavaScript,而是将两种语言结合在一起,让每个社区都能使用彼此强大的工具和库。例如,在本教程中,我们将使用 Python 的 Pandas 库和 JavaScript。
我们正在建造的东西
在本系列教程中,我们将构建一个无服务器的单页应用程序(SPA ),它获取网飞电影并显示数据集,并使用 Pandas 来读取、整理、操作和分析数据。然后,结果显示在 DOM 上,供最终用户查看。
我们的最终项目是一个 SPA,显示电影和电视节目列表、推荐的电影和节目以及有趣的事实。最终用户将能够删除和过滤电影和节目。数据保存在 PouchDB 中。
在这一部分,我们将重点关注:
- 学习大熊猫的基本知识
- 在 Python 和 JavaScript 之间共享对象和方法
- 从 Python 代码操作 DOM
- 构建基本的应用程序结构

您可以在这里找到您将在第一部分中创建的应用程序的现场演示。
用 Python 操作 DOM
在我们开始构建应用程序之前,让我们快速看一下如何使用 Python 与 DOM API 进行交互来直接操作它。
首先,创建一个名为index.html的新 HTML 文件:
`<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
</head>
<body>
<p id="title">My first Pyodide app</p>
<script> async function main() { let pyodide = await loadPyodide(); pyodide.runPython(`print('Hello world, from the browser!')`); }; main(); </script>
</body>
</html>`
在这里,我们从 CDN 加载了主要的 Pyodide 运行时以及 Pyodide 的内置包,并使用 runPython 方法运行了一个简单的 Python 脚本。
在浏览器中打开文件。然后,在浏览器的开发者工具的控制台中,您应该会看到:
`Loading distutils
Loaded distutils
Python initialization complete
Hello world, from the browser!`
最后一行显示我们的 Python 代码是在浏览器中执行的。现在让我们看看如何访问 DOM。为此,我们可以导入 js 库来访问 JavaScript 范围。
像这样更新 HTML 文件:
`<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
</head>
<body>
<p id="title">My first Pyodide app</p>
<script> async function main() { let pyodide = await loadPyodide(); pyodide.runPython(`
print('Hello world, from the browser!')
import js
js.document.title = "Hello from Python"
`); }; main(); </script>
</body>
</html>`
因此,js表示全局对象window,它可以用来直接操作 DOM 和访问全局变量和函数。我们用它将浏览器/文档标题改为“来自 Python 的你好”。
刷新浏览器以确保其正常工作。
接下来,我们把段落内文从“我的第一个 Pyodide app”更新为“被 Python 取代”:
`<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
</head>
<body>
<p id="title">My first Pyodide app</p>
<script> async function main() { let pyodide = await loadPyodide(); pyodide.runPython(`
print('Hello world, from the browser!')
import js
js.document.title = "Hello from Python"
js.document.getElementById("title").innerText = "Replaced by Python"
`); }; main(); </script>
</body>
</html>`
保存文件,并再次刷新页面。
类型翻译
Pyodide 的一个很棒的特性是你可以在 Python 和 JavaScript 之间传递对象。有两种翻译方法:
隐式转换
如前所述,基本数据类型将在 Python 和 JavaScript 之间直接转换,无需创建特殊对象。
| 计算机编程语言 | Java Script 语言 |
|---|---|
int |
Number或BigInt |
float |
Number |
str |
String |
bool |
Boolean |
None |
undefined |
| Java Script 语言 | 计算机编程语言 |
|---|---|
Number |
int或float |
BigInt |
int |
String |
str |
Boolean |
bool |
undefined |
None |
null |
None |
让我们看一个例子。
首先向script标签添加一个名为name的新变量:
`<script> var name = "John Doe"; // NEW! async function main() { let pyodide = await loadPyodide(); pyodide.runPython(`
print('Hello world, from the browser!')
import js
js.document.title = "Hello from Python"
js.document.getElementById("title").innerText = "Replaced by Python"
`); }; main(); </script>`
接下来,让我们看看使用 js 的类型和值:
`<script> var name = "John Doe"; async function main() { let pyodide = await loadPyodide(); pyodide.runPython(`
print('Hello world, from the browser!')
import js
js.document.title = "Hello from Python"
js.document.getElementById("title").innerText = "Replaced by Python"
`); // NEW !! pyodide.runPython(`
import js
name = js.name
print(name, type(name))
`); }; main(); </script>`
刷新浏览器。在控制台中,您应该会看到以下输出:
如您所见,我们可以直接访问这个变量的值,它从 JavaScript String转换为 Python str。
代理
正如您所看到的,基本类型可以直接转换成目标语言中的等价类型。另一方面,“非基本”类型需要转换成代理对象。有两种类型的代理对象:
- JSProxy 是一个代理,用于让 JavaScript 对象表现得像 Python 对象一样。换句话说,它允许您从 Python 代码中引用内存中的 JavaScript 对象。您可以使用 to_py() 方法将代理转换为本地 Python 对象。
- PyProxy 是一个让 Python 对象表现得像 JavaScript 对象的代理,允许你从 JavaScript 代码中引用内存中的 Python 对象。您可以使用 toJs() 方法将对象转换为本地 JavaScript 对象。
要将 Python 字典转换为 JavaScript 对象,请使用值为
Object.fromEntries的dict_converter参数:`dictionary_name.toJs({ dict_converter: Object.fromEntries })`如果没有这个参数,
toJs()将把字典转换成一个 JavaScript Map 对象。
JSProxy 示例
创建一个名为products的新变量:
`var products = [{ id: 1, name: "product 1", price: 100, }, { id: 2, name: "Product 2", price: 300, }];`
将其导入runPython并检查类型:
`pyodide.runPython(`
import js
print(type(js.products))
`);`
完整的script标签:
`<script> var name = "John Doe"; // NEW !! var products = [{ id: 1, name: "product 1", price: 100, }, { id: 2, name: "Product 2", price: 300, }]; async function main() { let pyodide = await loadPyodide(); pyodide.runPython(`
print('Hello world, from the browser!')
import js
js.document.title = "Hello from Python"
js.document.getElementById("title").innerText = "Replaced by Python"
`); pyodide.runPython(`
import js
name = js.name
print(name, type(name))
`); // NEW !! pyodide.runPython(`
import js
print(type(js.products))
`); }; main(); </script>`
刷新页面后,您应该在控制台中看到结果是<class 'pyodide.JsProxy'>。通过这个代理,我们可以从 Python 代码中访问内存中的 JavaScript 对象。
像这样更新新添加的pyodide.runPython块:
`pyodide.runPython(`
import js
products = js.products
products.append({
"id": 3,
"name": "Product 3",
"price": 400,
})
for p in products:
print(p)
`);`
您应该会在浏览器中看到一个AttributeError: append错误,因为 JSProxy 对象没有一个append方法。
如果把
.append改成.push会怎么样?
要操作此对象,可以使用to_py()方法将其转换为 Python 对象:
`pyodide.runPython(`
import js
products = js.products.to_py()
products.append({
"id": 3,
"name": "Product 3",
"price": 400,
})
for p in products:
print(p)
`);`
您现在应该看到:
`{'id': 1, 'name': 'product 1', 'price': 100}
{'id': 2, 'name': 'Product 2', 'price': 300}
{'id': 3, 'name': 'Product 3', 'price': 400}`
phproxy 示例
像这样更新script标签:
`<script> async function main() { let pyodide = await loadPyodide(); pyodide.runPython(`
import js
products = [
{
"id": 1,
"name": "new name",
"price": 100,
"votes": 2
},
{
"id": 2,
"name": "new name",
"price": 300,
"votes": 2
}
]
`); let products = pyodide.globals.get("products"); console.log(products.toJs({ dict_converter: Object.fromEntries })); }; main(); </script>`
这里,我们访问了 Python 变量,然后通过.toJs({ dict_converter: Object.fromEntries })将它从 Python 字典转换成 JavaScript 对象。
刷新页面后,您应该会在控制台中看到以下输出:
`{id: 1, name: 'new name', price: 100, votes: 2} {id: 2, name: 'new name', price: 300, votes: 2}`
有了这些,让我们把你新发现的 Pyodide 知识用于构建一个应用程序吧!
网飞数据集
我们将开发一个获取网飞数据集的无服务器 SPA 应用程序。然后,我们将使用 Pandas 来读取、整理、操作和分析数据。最后,我们将把结果传递给 DOM,以便向最终用户显示分析后的数据。
数据集是一个 CSV,它包括以下几列:
| 名字 | 描述 |
|---|---|
| 身份证明 | 上的标题 ID just watch。 |
| 标题 | 标题的名称。 |
| 展览型 | 电视节目或电影。 |
| 描述 | 简单描述一下。 |
| 发布年份 | 发行年份。 |
| 年龄证明 | 年龄证明。 |
| 运行时间 | 剧集(节目)或电影的长度。 |
| 体裁 | 流派列表。 |
| 生产国 | 出版这本书的国家名单。 |
| 季节 | 如果是一部剧的话有多少季。 |
| IMDB ID | IMDB 上的标题 ID。 |
| IMDB 分数 | 在 IMDB 上评分。 |
| IMDB 投票 | IMDB 上的投票。 |
| imdb 流行度 | TMDB 的受欢迎程度。 |
| imdb 分数 | TMDB 得分。 |
安装 Pyodide 和 TailwindCSS
像这样更新你的index.html文件的内容:
`<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-900">
<div id="app" class="relative h-full max-w-7xl mx-auto my-16"></div>
<script> </script>
</body>
</html>`
正如你所看到的,我们从 CDN 加载了 Pyodide 和 Tailwind CSS 进行造型。我们还定义了一个带有app的id的<div>元素来保存我们接下来要构建的应用组件。
创建应用程序组件
将以下 JavaScript 代码添加到script标签中:
`class App { state = { titles:[], } view() { return `<p class="text-slate-100">Hello, World!</p>` } render() { app.innerHTML = this.view(); } }`
这里,我们定义了一个名为App的对象。我们将它称为组件,因为它是一段独立的、可重用的代码。
组件App有一个保存数据的状态对象,以及两个嵌套的函数view()和render()。render()简单地将从view()输出的 HTML 代码附加到 DOM,用app的id附加到div。
让我们创建一个名为appComponent的App的新实例,并在其上调用render()。在App的class声明后添加以下代码:
`var appComponent = new App(); appComponent.render();`
在浏览器中打开文件。你应该看到“你好,世界!”。
添加示例数据
接下来,让我们将示例电影添加到state中。在script标记中,就在调用appComponent.render();之前,用以下内容更新状态:
`appComponent.state.titles = [ { "id": 1, "title": "The Shawshank Redemption", "release_year": 1994, "type": "MOVIE", "genres": [ "Crime", "Drama" ], "production_countries": [ "USA" ], "imdb_score": 9.3, "imdb_votes": 93650, "tmdb_score": 9.3, }, { "id": 2, "title": "The Godfather", "release_year": 1972, "type": "MOVIE", "genres": [ "Crime", "Drama" ], "production_countries": [ "USA" ], "imdb_score": 9.2, "imdb_votes": 93650, "tmdb_score": 9.3, } ];`
现在,我们可以通过更新我们的App类中的view()来构建一个表来显示数据,如下所示:
`view() { return (`
<div class="px-4 sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-4xl font-semibold text-gray-200">Netflix Movies and Shows</h1>
</div>
</div>
<!-- Start of Titles --!>
<div class="mt-8 flex flex-col">
<div class="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Title</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Release Year</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Genre</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Production Country</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
${this.state.titles.length > 0 ? this.state.titles.map(function (title) { return (`
<tr id=${title.id}>
<td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-500 sm:pl-6">${title.title}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-gray-900">${title.type}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.release_year}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.genres}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.production_countries}</td>
</tr>
`) }).join('') : (` <tr> <td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-500 sm:pl-6">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-gray-900">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> </tr> `)
}
</tbody>
</table>
<div>
</div>
</div>
</div>
<!-- End of Titles --!>
</div>
`) }`
所以,我们-
- 添加了一个表格元素,包含标题、类型、发行年份、流派和生产国家的列。
- 已检查
state.titles数组长度,查看它是否包含任何标题。如果有标题,我们遍历它们并为每个标题创建一个表格行。如果没有,我们创建一个带有加载消息的表行。
在浏览器中刷新页面。
熊猫数据处理
安装熊猫
要在 Pyodide 中加载 Python 包,可以在初始化 Pyodide 后立即使用 loadPackage 函数。
例如:
`let pyodide = await loadPyodide(); await pyodide.loadPackage("requests");`
您可以使用列表加载多个包:
`await pyodide.loadPackage(["requests", "pandas", "numpy"]);`
回到您的 HTML 文件,在appComponent.render();之后添加一个main函数:
`async function main() { let pyodide = await loadPyodide(); await pyodide.loadPackage("pandas"); }`
别忘了叫它:
在浏览器中刷新页面。您应该会在控制台中看到以下内容:
`Loading pandas, numpy, python-dateutil, six, pytz, setuptools, pyparsing Loaded python-dateutil, six, pytz, pyparsing, setuptools, numpy, pandas`
于是,Pandas 和相关的子依赖项被加载到浏览器中!
读取和操作数据
在这一节中,我们将从互联网上获取一个 CSV 文件,将它读入一个 Pandas 数据帧,对它进行整理和操作,最后将它传递给 state。
Python 代码:
`import js
import pandas as pd
from pyodide.http import pyfetch
# 1\. fetching CSV from and write it to memory
response = await pyfetch("https://raw.githubusercontent.com/amirtds/kaggle-netflix-tv-shows-and-movies/main/titles.csv")
if response.status == 200:
with open("titles.csv", "wb") as f:
f.write(await response.bytes())
# 2\. load the csv file
all_titles = pd.read_csv("titles.csv")
# 3\. sanitize the data
# drop unnecessary columns
all_titles = all_titles.drop(
columns=[
"age_certification",
"seasons",
"imdb_id",
]
)
# drop rows with null values for important columns
sanitized_titles = all_titles.dropna(
subset=[
"id",
"title",
"release_year",
"genres",
"production_countries",
"imdb_score",
"imdb_votes",
"tmdb_score",
"tmdb_popularity",
]
)
# Convert the DataFrame to a JSON object. ('orient="records"' returns a list of objects)
titles_list = sanitized_titles.head(10).to_json(orient="records")
# 4\. set titles to first 10 titles to the state
js.window.appComponent.state.titles = titles_list
js.window.appComponent.render()`
记下代码注释。
将此代码添加到main中的 runPythonAsync 方法中:
`async function main() { let pyodide = await loadPyodide(); await pyodide.loadPackage("pandas"); await pyodide.runPythonAsync(`
// add the code here
`); }`
接下来,移除appComponent.state.titles。此外,我们需要在view方法中更改这一行:
`${this.state.titles.length > 0 ? this.state.titles.map(function (title) {`
收件人:
`${this.state.titles.length > 0 ? JSON.parse(this.state.titles).map(function (title) {`
为什么?
titles_list ( titles_list = sanitized_titles.head(10).to_json(orient="records"))是一个 JSON 字符串,所以为了迭代它,我们需要反序列化它。
在浏览器中刷新页面。您应该首先在表中看到一条加载消息。Pyodide 加载后,Pandas 导入,脚本执行完成后,您应该会看到完整的电影列表。
结论
我们在本教程中讨论了很多!我们了解了 Pyodide 如何让您在浏览器中运行 Python 代码,让您能够:
- 直接在浏览器中加载和使用 Python 包。(我们用 Pandas 读取并分析了一个 CSV 文件。)
- 从 Python 代码中访问和操作 DOM。(在我们的 Python 代码中导入 js 使我们能够访问 DOM。)
- 在 Python 和 JavaScript 之间共享和访问对象和名称空间。(在我们的 Javascript 代码中,我们创建了一个可以在 Python 代码中访问的组件,以便管理它的状态和调用它的方法。)
你可以在这里找到这个教程的源代码。
--
但是,我们仍然缺少一些东西,我们需要解决一些问题:
- 首先,我们没有对导入的 CSV 文件做太多处理。Pandas 给了我们很大的能力来轻松地分析和操作数据。
- 其次,Pyodide 可能需要一些时间来初始化和运行 Python 脚本。由于它当前运行在主线程中,它会使应用程序瘫痪,直到它运行完毕。我们应该把 Pyodide 和 Python 脚本转移到一个 web worker 来防止这种情况。
- 第三,我们还没有看到完全的 SPA 式行为。我们仍然需要更新组件以添加事件侦听器来响应用户操作。
- 最后,Python 脚本部分在代码编辑器中没有突出显示语法。另外,它开始变得难以阅读。我们应该将这段代码移动到一个 Python 包中,并导入到 Pyodide 中。这将使维护和扩展变得更加容易。
我们将在接下来的教程中讨论这四件事!
--
Python 单页面应用系列:
- 第一部分(本教程!):学习 Pyodide 的基础知识并创建基础应用程序
- 第二部分 :用 Pandas 分析和操作数据,使用 web worker 加速应用程序
- 第三部分 :创建 Python 包,添加附加特性,添加持久化数据层
用 Python 和 Pyodide 构建单页面应用程序——第 2 部分
在本系列的第一篇教程中,我们使用 Python 和 Pyodide 构建了一个单页面应用程序来加载 Pandas,获取网飞数据集,并对数据执行基本计算。我们还研究了如何使用 Pyodide 通过 Python 直接操作 DOM。在我们构建的应用程序中,我们将经过处理的网飞数据传递给一个 JavaScript 组件,并直接从 Python 代码中呈现出来。
正如在第一部分的结论中提到的,应用程序缺少一些特性,我们需要解决一些问题。在第二部分中,我们将:
- 用熊猫更好地分析和处理数据
- 使用 web worker 来加速应用程序
--
Python 单页面应用系列:
- 第 1 部分 :学习 Pyodide 的基础知识,创建基础应用
- 第二部分(本教程!):使用 Pandas 分析和操作数据,并使用 web worker 来加速应用程序
- 第三部分 :创建 Python 包,添加附加特性,添加持久化数据层
目标
学完本教程后,您应该能够:
- 使用 Pandas 的更多高级功能来分析和处理数据
- 使用 web workers 改善用户体验和性能
我们正在建造的东西
首先,我们将通过使用一个 web worker 来改善用户体验和应用程序性能。我们还将深入熊猫图书馆,分析和处理网飞数据,以便根据给定的标题创建推荐,并添加随机的电影和表演事实。

你可以在这里找到该应用的现场演示。
用熊猫分析网飞数据集
在第一部分中,加载网飞 CSV 文件后,我们删除了一些不必要的列,并将结果作为 JSON 返回。如您所见,我们还没有对数据进行太多的分析或处理。我们现在就来看看。
如果你需要第一部分的代码,你可以在这里找到它。
创建推荐列表
净化的数据帧有以下几列:
- 身份证明(identification)
- 标题
- 发布年份
- 体裁
- 生产 _ 国家
- imdb_score
- imdb _ 投票
- tmdb_score
- tmdb _ 人气
让我们为使用熊猫的电影和节目创建一个推荐列表。为此,我们将向 DataFrame 添加一个名为recommendation_score的新列,其值是imdb_votes、imdb_score、tmdb_score和tmdb_popularity的加权和:
`recommended_titles["recommendation_score"] = (
sanitized_titles["imdb_votes"] * 0.3 +
sanitized_titles["imdb_score"] * 0.3 +
sanitized_titles["tmdb_score"] * 0.2 +
sanitized_titles["tmdb_popularity"] * 0.2
)`
在您选择的代码编辑器中打开index.html文件,并在titles_list = sanitized_titles.head(10).to_json(orient="records")后添加以下代码
`# 4\. Create recommendation list for Shows and Movies
# 4.1 Copy the sanitized_titles to add new column to it
recommended_titles = sanitized_titles.copy()
# 4.2 Add new column to the sanitized_titles
recommended_titles["recommendation_score"] = (
sanitized_titles["imdb_votes"] * 0.3 +
sanitized_titles["imdb_score"] * 0.3 +
sanitized_titles["tmdb_score"] * 0.2 +
sanitized_titles["tmdb_popularity"] * 0.2
)
print(recommended_titles.head(5))`
在浏览器中打开文件。然后,在你浏览器的开发者工具的控制台中,你应该会看到前五个标题。注意recommendation_score栏:
`id title ... tmdb_score recommendation_score
tm84618 Taxi Driver ... 8.2 238576.2524
tm127384 Monty Python and the Holy Grail ... 7.8 159270.7632
tm70993 Life of Brian ... 7.8 117733.1610
tm190788 The Exorcist ... 7.7 117605.6374
ts22164 Monty Python's Flying Circus ... 8.3 21875.3838`
这样,让我们创建两个新的数据帧,一个用于电影,另一个用于节目,然后按recommendation_score降序排序:
`recommended_movies = (
recommended_titles.loc[recommended_titles["type"] == "MOVIE"]
.sort_values(by="recommendation_score", ascending=False)
.head(5)
.to_json(orient="records")
)
recommended_shows = (
recommended_titles.loc[recommended_titles["type"] == "SHOW"]
.sort_values(by="recommendation_score", ascending=False)
.head(5)
.to_json(orient="records")
)`
这里,我们使用了 loc 和 sort_values 方法,分别按照type列过滤标题和按照recommendation_score降序排序。
用这些新列表替换print(recommended_titles.head(5)):
`# 4\. Create recommendation list for Shows and Movies
# 4.1 Copy the sanitized_titles to add new column to it
recommended_titles = sanitized_titles.copy()
# 4.2 Add new column to the sanitized_titles
recommended_titles["recommendation_score"] = (
sanitized_titles["imdb_votes"] * 0.3 +
sanitized_titles["imdb_score"] * 0.3 +
sanitized_titles["tmdb_score"] * 0.2 +
sanitized_titles["tmdb_popularity"] * 0.2
)
recommended_movies = (
recommended_titles.loc[recommended_titles["type"] == "MOVIE"]
.sort_values(by="recommendation_score", ascending=False)
.head(5)
.to_json(orient="records")
)
recommended_shows = (
recommended_titles.loc[recommended_titles["type"] == "SHOW"]
.sort_values(by="recommendation_score", ascending=False)
.head(5)
.to_json(orient="records")
)`
要在我们的应用程序中使用这些列表,首先我们需要向App的状态添加新的键,以便能够保存和操作数据:
`state = { titles: [], recommendedMovies: [], recommendedShows: [], }`
现在,为了更新状态,在js.window.appComponent.state.titles = titles_list之后添加以下内容:
`js.window.appComponent.state.recommendedMovies = recommended_movies
js.window.appComponent.state.recommendedShows = recommended_shows`
最后,为了向最终用户显示建议,将以下内容添加到view(),就在<!-- End of Titles --!>行的下面:
`<!-- Start of Recommended title --!> <div class="flex"> <!-- Start of Recommended title --!> <div class="px-4 sm:px-6 lg:px-8 my-8 w-1/2"> <p class="text-4xl font-semibold text-slate-100">Recommended Movies</p> <ul role="list" class="divide-y divide-gray-200"> ${this.state.recommendedMovies.length > 0 ? JSON.parse(this.state.recommendedMovies).map(function (movie) { return `
<li class="relative bg-white py-5 px-4 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600 rounded-md my-2">
<div class="flex justify-between space-x-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-gray-900 truncate">${movie.title}</p>
<p class="text-sm text-gray-500 truncate">${movie.description}</p>
</div>
<time datetime="" class="flex-shrink-0 whitespace-nowrap text-sm text-gray-500">${movie.release_year}</time>
</div>
</li>
` }).join('') : `
<li class="relative bg-white py-5 px-4 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600">
<div class="flex justify-between space-x-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">Loading...</p>
</div>
</div>
</li>
</ul>
` } </div> <!-- End of Recommended titles --!> <!-- Start of Recommended Shows --!> <div class="px-4 sm:px-6 lg:px-8 my-8 w-1/2"> <p class="text-4xl font-semibold text-slate-100">Recommended Shows</p> <ul role="list" class="divide-y divide-gray-200"> ${this.state.recommendedShows.length > 0 ? JSON.parse(this.state.recommendedShows).map(function (show) { return `
<li class="relative bg-white py-5 px-4 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600 rounded-md my-2">
<div class="flex justify-between space-x-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-gray-900 truncate">${show.title}</p>
<p class="text-sm text-gray-500 truncate">${show.description}</p>
</div>
<time datetime="" class="flex-shrink-0 whitespace-nowrap text-sm text-gray-500">${show.release_year}</time>
</div>
</li>
` }).join('') : `
<li class="relative bg-white py-5 px-4 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600">
<div class="flex justify-between space-x-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">Loading...</p>
</div>
</div>
</li>
</ul>
`} </div> <!-- Start of Recommended shows --!> </div> <!-- End of Recommended titles --!>`
回到您的浏览器,您现在应该可以看到推荐的电影和节目。
电影和表演事实
在本节中,我们将从 Python 代码开始查找制作最多电影和节目的年份:
`# 5\. Movie and Show Facts
facts_movies = (
sanitized_titles.loc[sanitized_titles["type"] == "MOVIE"]
.groupby("release_year")
.count()["id"]
.sort_values(ascending=False)
.head(1)
.to_json(orient="table")
)
facts_shows = (
sanitized_titles.loc[sanitized_titles["type"] == "SHOW"]
.groupby("release_year")
.count()["id"]
.sort_values(ascending=False)
.head(1)
.to_json(orient="table")
)`
这里,我们使用了:
- groupby 方法将标题按
release_year分组。 - count 统计每年的冠军数量。
- sort_values 按每年的书目数量降序排列书目。
将上面的代码添加到index.html文件中,就在推荐部分的下面。
再次更新App的状态:
`state = { titles: [], recommendedMovies: [], recommendedShows: [], factsMovies: [], factsShows: [], }`
更新状态:
`# 6\. set titles to first 10 titles to the state, update remaining state, and render
js.window.appComponent.state.titles = titles_list
js.window.appComponent.state.recommendedMovies = recommended_movies
js.window.appComponent.state.recommendedShows = recommended_shows
js.window.appComponent.state.factsMovies = facts_movies # NEW
js.window.appComponent.state.factsShows = facts_shows # NEW
js.window.appComponent.render()`
通过在<!-- End of Recommended Shows --!>之后添加以下内容再次更新view():
`<!-- Start of Facts --!> <div class="px-4 sm:px-6 lg:px-8 my-8"> <div> <h3 class="text-4xl font-semibold text-slate-100">Interesting Facts</h3> <dl class="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3"> <div class="px-4 py-5 bg-white shadow rounded-lg overflow-hidden sm:p-6"> ${this.state.factsMovies.length > 0 ? `
<dt class="text-sm font-medium text-gray-500 truncate">Movies produced in ${JSON.parse(this.state.factsMovies).data[0].release_year}</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">${JSON.parse(this.state.factsMovies).data[0].id}</dd>
` : `
Loading...
`} </div> <div class="px-4 py-5 bg-white shadow rounded-lg overflow-hidden sm:p-6"> ${this.state.factsShows.length > 0 ? `
<dt class="text-sm font-medium text-gray-500 truncate">Shows produced in ${JSON.parse(this.state.factsShows).data[0].release_year}</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">${JSON.parse(this.state.factsShows).data[0].id}</dd>
` : `
Loading...
`} </div> </dl> </div> </div> <!-- End of Facts --!>`
在浏览器中重新加载index.html页面。您应该看到有趣的事实部分,其中显示了制作最多电影和节目的年份中制作的电影和节目的数量。
提高性能
当前实现的一个问题是,我们将昂贵的操作放在浏览器的主线程中。这样做的后果是,在 Pyodide 完成加载和执行代码之前,其他操作将被阻塞。这可能会对应用程序的性能和用户体验产生负面影响。
网络工作者
为了解决这个问题,我们可以使用 web workers 将繁重的操作——在本例中是 Pyodide 和 Python 脚本——卸载到后台的一个独立线程,让浏览器的主线程继续运行其他操作,而不会变慢或被锁定。
网络工作者的主要组成部分是:
- Worker() 构造函数:创建一个 web worker 的新实例,我们可以将一个脚本传递给它,它将在一个单独的线程中运行
- onmessage() 事件:当 worker 收到另一个线程的消息时触发
- 方法:向工作人员发送一条消息
- terminate() 方法:终止工人
让我们看一个简单的例子。
在项目的根目录下创建一个名为 worker.js 的新文件:
`self.onmessage = function(message) { console.log(message.data); }`
这个文件包含了工人将要运行的代码。
在 index.html 的中创建一个新的script标记,就在结束body标记之前:
`<script> const worker = new Worker("./worker.js"); worker.postMessage("Hello from the main thread!"); </script>`
由于安全原因,web worker 文件不能使用file://协议从您的本地文件系统导入。我们需要运行一个本地 web 服务器来运行这个项目。在终端中,导航到项目的根目录。然后,运行 Python 的 http 服务器:
在服务器运行的情况下,在浏览器中导航至 http://localhost:8000/ 。您应该在开发人员控制台中看到Hello from the main thread!。
将 Pyodide 移至网络工作者
我们在这一部分的目标是:
- 在 web worker 中加载并初始化 Pyodide 及其包
- 在 web worker 中运行我们的 Python 脚本,并将结果发送到主线程,以便进行渲染
首先,删除函数定义并调用index.html中的main()。然后,将 worker.js 中的所有代码替换为:
`// load pyodide.js importScripts("https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"); // Initialize pyodide and load Pandas async function initialize(){ self.pyodide = await loadPyodide(); await self.pyodide.loadPackage("pandas"); } let initialized = initialize();`
现在,将以下代码添加到 worker.js 文件中,以便在 worker 初始化时运行我们的脚本:
`self.onmessage = async function (e) { await initialized; response = await fetch( "https://raw.githubusercontent.com/amirtds/kaggle-netflix-tv-shows-and-movies/main/titles.csv" ); response.ok && response.status === 200 ? (titles = await response.text()) : (titles = ""); // define global variable called titles to make it accessible by Python self.pyodide.globals.set("titlesCSV", titles); let titlesList = await self.pyodide.runPythonAsync(`
import pandas as pd
import io
# 1\. create csv buffer to make it readable by pandas
csv_buffer = io.StringIO(titlesCSV)
# 2\. load the csv file
all_titles = pd.read_csv(csv_buffer)
# 3\. sanitize the data
# drop unnecessary columns
all_titles = all_titles.drop(
columns=[
"age_certification",
"seasons",
"imdb_id",
]
)
# drop rows with null values for important columns
sanitized_titles = all_titles.dropna(
subset=[
"id",
"title",
"release_year",
"genres",
"production_countries",
"imdb_score",
"imdb_votes",
"tmdb_score",
"tmdb_popularity",
]
)
# Convert the DataFrame to a JSON object. ('orient="records"' returns a list of objects)
titles_list = sanitized_titles.head(10).to_json(orient="records")
titles_list
`); let recommendations = await self.pyodide.runPythonAsync(`
# Create recommendation list for Shows and Movies
# 1\. Copy the sanitized_titles to add new column to it
recommended_titles = sanitized_titles.copy()
# 2\. Add new column to the sanitized_titles
recommended_titles["recommendation_score"] = (
sanitized_titles["imdb_votes"] * 0.3 +
sanitized_titles["imdb_score"] * 0.3 +
sanitized_titles["tmdb_score"] * 0.2 +
sanitized_titles["tmdb_popularity"] * 0.2
)
# 3\. Create Recommended movies list
recommended_movies = recommended_titles.loc[recommended_titles["type"] == "MOVIE"].sort_values(
by="recommendation_score", ascending=False
).head(5).to_json(orient="records")
# 4\. Create Recommended shows list
recommended_shows = recommended_titles.loc[recommended_titles["type"] == "SHOW"].sort_values(
by="recommendation_score", ascending=False
).head(5).to_json(orient="records")
recommendations = {
"movies": recommended_movies,
"shows": recommended_shows
}
recommendations
`); let facts = await self.pyodide.runPythonAsync(`
# Create facts list for Movies and Shows
facts_movies = sanitized_titles.loc[sanitized_titles["type"] == "MOVIE"].groupby("release_year").count()["id"].sort_values(ascending=False).head(1).to_json(orient="table")
facts_shows = sanitized_titles.loc[sanitized_titles["type"] == "SHOW"].groupby("release_year").count()["id"].sort_values(ascending=False).head(1).to_json(orient="table")
facts = {
"movies": facts_movies,
"shows": facts_shows
}
facts
`); self.postMessage({ titles: titlesList, recommendedMovies: recommendations.toJs({ dict_converter: Object.fromEntries, }).movies, recommendedShows: recommendations.toJs({ dict_converter: Object.fromEntries, }).shows, factsMovies: facts.toJs({ dict_converter: Object.fromEntries }).movies, factsShows: facts.toJs({ dict_converter: Object.fromEntries }).shows, }); };`
这里,在分析了网飞数据之后,我们使用postMessage将结果提交给主线程。
接下来,在index.html文件的const worker = new Worker("./worker.js");之后,添加以下代码:
`worker.postMessage("Running Pyodide"); worker.onmessage = function (event) { event.data.titles !== undefined ? appComponent.state.titles = event.data.titles : []; event.data.recommendedMovies !== undefined ? appComponent.state.recommendedMovies = event.data.recommendedMovies : []; event.data.recommendedShows !== undefined ? appComponent.state.recommendedShows = event.data.recommendedShows : []; event.data.factsMovies !== undefined ? appComponent.state.factsMovies = event.data.factsMovies : []; event.data.factsShows !== undefined ? appComponent.state.factsShows = event.data.factsShows : []; appComponent.render() }`
停止并重启 Python HTTP 服务器。刷新浏览器。
您应该会看到与之前相同的结果,但是 Pyodide 的执行和 Python 代码被卸载到一个单独的线程。
结论
在本教程中,我们讲述了如何使用 Pandas 对我们的网飞标题 CSV 数据进行数据操作,以创建电影和节目的推荐分数和列表。我们还做了一些数据分析,以找出大多数电影和节目是在哪一年制作的。
我们还通过将 Pyodide 和 Python 代码的执行转移到一个 web worker 来提高我们的应用程序性能。
你可以在这里找到这个教程的源代码。
在下一个教程中,我们将为我们的应用程序添加更多的 SPA 特性,比如删除和编辑电影和节目。我们还将添加一个持久性数据层,这样远程数据只需被提取一次。
--
Python 单页面应用系列:
- 第 1 部分 :学习 Pyodide 的基础知识,创建基础应用
- 第二部分(本教程!):使用 Pandas 分析和操作数据,并使用 web worker 来加速应用程序
- 第三部分 :创建 Python 包,添加附加特性,添加持久化数据层
用 Python 和 Pyodide 构建单页面应用程序——第 3 部分
在本系列的第二部分中,我们通过将 Pyodide 和 Python 代码执行卸载给 Web worker 来改善用户体验。我们还创建了一个关于熊猫的建议和事实的列表。
在这最后一部分中,我们将看看如何打包 Python 代码以使其更具可读性和可维护性,添加一个搜索栏和一个删除按钮,并使用 PouchDB 添加一个持久数据层。
--
Python 单页面应用系列:
- 第 1 部分 :学习 Pyodide 的基础知识,创建基础应用
- 第二部分 :用 Pandas 分析和操作数据,使用 web worker 加速应用程序
- 第三部分(本教程!):创建一个 Python 包,添加附加功能,并添加一个持久数据层
目标
学完本教程后,您应该能够:
- 打包您的 Python 代码,并将其导入 Pyodide
- 使用 PouchDB 向应用程序添加数据层
我们正在建造的东西
首先,我们将把 Python 代码打包到一个单独的 Python 模块中。这将使我们的代码污染更少,可读性更好。我们将修改 web worker 以在 Pyodide 中导入 Python 文件。我们还将添加一个搜索栏和一个删除按钮。我们最后的任务是添加一个持久数据层来存储 PouchDB 数据库中的数据,这将使我们的应用程序在页面重新加载时更快。
你可以在这里找到该应用的现场演示。
提高代码可维护性
至此,我们已经将 Python 代码直接添加到了runPythonAsync方法中。正如您可能已经知道的,这对于小代码片段来说是没问题的,但是随着代码的增长,它变得越来越难以维护和扩展。
为了改善现状,我们会-
- 将 Python 代码分离到一个模块中。
- 取 worker.js 中的 Python 模块代码,将结果写入浏览器的虚拟内存,导入包在 Pyodide 中使用。
打包 Python 代码
在项目的根目录下创建一个名为 main.py 的新文件:
`import io
import pandas as pd
def analyze_titles(titlesCSV):
# 1\. create csv buffer to make it readable by pandas
csv_buffer = io.StringIO(titlesCSV)
# 2\. load the csv file
all_titles = pd.read_csv(csv_buffer)
# 3\. sanitize the data
# drop unnecessary columns
all_titles = all_titles.drop(
columns=[
"age_certification",
"seasons",
"imdb_id",
]
)
# drop rows with null values for important columns
sanitized_titles = all_titles.dropna(
subset=[
"id",
"title",
"release_year",
"genres",
"production_countries",
"imdb_score",
"imdb_votes",
"tmdb_score",
"tmdb_popularity",
]
)
# Convert the DataFrame to a JSON object. ('orient="records"' returns a list of objects)
titles_list = sanitized_titles.head(10).to_json(orient="records")
# 4\. Create recommendation list for Shows and Movies
# 4.1 Copy the sanitized_titles to add new column to it
recommended_titles = sanitized_titles.copy()
# 4.2 Add new column to the sanitized_titles
recommended_titles["recommendation_score"] = (
sanitized_titles["imdb_votes"] * 0.3
+ sanitized_titles["imdb_score"] * 0.3
+ sanitized_titles["tmdb_score"] * 0.2
+ sanitized_titles["tmdb_popularity"] * 0.2
)
# 4.3 Create Recommended movies list
recommended_movies = (
recommended_titles.loc[recommended_titles["type"] == "MOVIE"]
.sort_values(by="recommendation_score", ascending=False)
.head(5)
.to_json(orient="records")
)
# 4.4 Create Recommended shows list
recommended_shows = (
recommended_titles.loc[recommended_titles["type"] == "SHOW"]
.sort_values(by="recommendation_score", ascending=False)
.head(5)
.to_json(orient="records")
)
recommendations = {"movies": recommended_movies, "shows": recommended_shows}
# 5\. Create facts list for Movies and Shows
facts_movies = (
sanitized_titles.loc[sanitized_titles["type"] == "MOVIE"]
.groupby("release_year")
.count()["id"]
.sort_values(ascending=False)
.head(1)
.to_json(orient="table")
)
facts_shows = (
sanitized_titles.loc[sanitized_titles["type"] == "SHOW"]
.groupby("release_year")
.count()["id"]
.sort_values(ascending=False)
.head(1)
.to_json(orient="table")
)
facts = {"movies": facts_movies, "shows": facts_shows}
return titles_list, recommendations, facts`
这里没什么新东西。请自行复习。
将 Python 文件传递给 Pyodide
接下来,我们需要对 worker.js 进行一些修改:
`// load pyodide.js importScripts("https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"); // Initialize pyodide and load Pandas async function initialize() { self.pyodide = await loadPyodide(); await self.pyodide.loadPackage("pandas"); } let initialized = initialize(); self.onmessage = async function (e) { await initialized; response = await fetch( "https://raw.githubusercontent.com/amirtds/kaggle-netflix-tv-shows-and-movies/main/titles.csv" ); response.ok && response.status === 200 ? (titles = await response.text()) : (titles = ""); // fetch main.py, save it in browser memory await self.pyodide.runPythonAsync(`
from pyodide.http import pyfetch
response = await pyfetch("main.py")
with open("main.py", "wb") as f:
f.write(await response.bytes())
`) // Importing fetched py module pkg = pyodide.pyimport("main"); // Run the analyze_titles function from main.py and assign the result to a variable let analyzedTitles = pkg.analyze_titles(titles); // convert the Proxy object to Javascript object analyzedTitles = analyzedTitles.toJs({ dict_converter: Object.fromEntries, }); // Set variables to corresponding values from the analyzedTitles object let titlesList = analyzedTitles[0]; let recommendedMovies = analyzedTitles[1].movies let recommendedShows = analyzedTitles[1].shows let factsMovies = analyzedTitles[2].movies let factsShows = analyzedTitles[2].shows self.postMessage({ titles: titlesList, recommendedMovies: recommendedMovies, recommendedShows: recommendedShows, factsMovies: factsMovies, factsShows: factsShows, }); };`
记下runPythonAsync方法中的 Python 代码。我们使用 pyfetch 获取本地 main.py 文件,并保存在浏览器的虚拟内存中以备后用。
在您的终端中运行 Python 的 http 服务器:
然后,在浏览器中导航到 http://localhost:8000/ 。
您应该会看到和以前一样的结果,但是 Python 代码的执行现在封装在一个单独的模块中,使得代码更具可读性和可维护性。另外,您现在可以利用代码编辑器中的语法突出显示,并为其编写自动化测试。
水疗特色
随着应用程序性能的提高和代码被分离到一个模块中,让我们将注意力转回到特性开发上来。
在这一节中,我们将添加一个删除按钮和搜索功能,以根据名称过滤标题。
删除按钮
要添加删除功能,我们需要:
- 在每个标题旁边的 DOM 中添加一个按钮
- 设置一个事件侦听器,以便在单击按钮时触发删除
- 创建一个函数来处理实际的删除
首先在index.html的表格标题中添加一个新列:
`<thead class="bg-gray-50">
<tr>
<th scope="col" class="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Title</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Release Year</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Genre</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Production Country</th>
<!-- NEW -->
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900"></th>
</tr>
</thead>`
接下来,对表体进行以下更改:
`<tbody class="divide-y divide-gray-200 bg-white">
${this.state.titles.length > 0 ? JSON.parse(this.state.titles).map(function (title) {
return (`
<tr id=${title.id}>
<td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-500 sm:pl-6">${title.title}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-gray-900">${title.type}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.release_year}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.genres}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.production_countries}</td>
<!-- NEW -->
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">
<button id=${title.id} class="delete text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`)
}).join('') : (`
<tr>
<td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-500 sm:pl-6">Titles are loading...</td>
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-gray-900">Titles are loading...</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td>
<!-- NEW -->
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td>
</tr>
`)
}
</tbody>`
在这里,我们添加了删除按钮,并在标题仍在加载时添加了额外的加载消息。
继续向前,向index.html中的App类添加两个新方法:
`setupEvents() { let deleteButtons = document.querySelectorAll(".delete") .forEach((button) => { button.addEventListener("click", () => this.deleteTitle(button.id)) }) } deleteTitle(id) { this.state.titles = JSON.stringify(JSON.parse(this.state.titles).filter(title => title.id != id)); this.render() }`
注意事项:
setupEvents用于将事件侦听器添加到删除按钮,以便当用户单击按钮时,调用deleteTitle方法。deleteTitle用于从列表中删除标题。
最后更新render调用setupEvents:
`render() { app.innerHTML = this.view(); this.setupEvents(); // NEW }`
测试一下。
想挑战吗?删除标题后,尝试更新建议和事实。
搜索框
接下来,让我们添加一个搜索框来过滤电影,并按标题显示列表。
在index.html的开始body标签下添加搜索框的 HTML:
`<!-- Start Search box -->
<div class="absolute top-1 right-0 mr-20 z-50">
<div class="h-full max-w-7xl mt-16 px-4 sm:px-6 lg:px-8 flex flex-row-reverse">
<label for="search" class="sr-only">Search</label>
<div class="relative text-gray-400 focus-within:text-gray-600">
<div class="pointer-events-none absolute inset-y-0 left-0 pl-3 flex items-center">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
</div>
<input id="search" class="block w-full bg-white py-2 pl-10 pr-3 border border-transparent rounded-md leading-5 text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white focus:border-white sm:text-sm" placeholder="Search" type="search" name="search" autofocus>
</div>
</div>
</div>
<!-- End Search box -->`
接下来,给App的状态添加一个新值:
`state = { titles: [], recommendedMovies: [], recommendedShows: [], factsMovies: [], factsShows: [], filteredTitles: [], // NEW }`
在view方法中,我们需要遍历filteredTitles状态,而不是遍历titles状态。
所以,改变:
`${this.state.titles.length > 0 ? JSON.parse(this.state.titles).map(function (title) {`
收件人:
`${this.state.filteredTitles.length > 0 ? JSON.parse(this.state.filteredTitles).map(function (title) {`
更新deleteTitle方法以使用filteredTitles状态而不是titles状态:
`deleteTitle(id) { this.state.filteredTitles = JSON.stringify( JSON.parse(this.state.filteredTitles).filter(function (title) { return title.id !== id; }) ); this.render(); }`
添加一个名为searchTitle的新方法来处理搜索逻辑:
`searchTitle(name) { this.state.filteredTitles = JSON.stringify( JSON.parse(this.state.titles).filter((title) => title.title.toLowerCase().includes(name.toLowerCase()) ) ); this.render(); }`
为搜索框的setupEvents添加一个新的事件监听器,以便当用户在搜索框中键入时调用searchTitle:
`setupEvents() { let deleteButtons = document.querySelectorAll(".delete").forEach((button) => { button.addEventListener("click", () => this.deleteTitle(button.id)); }); let searchBox = document .querySelector("#search") .addEventListener("keyup", (e) => { this.searchTitle(e.target.value); }); }`
最后,将filteredTitles状态设置为titles状态,以便在worker.onmessage中使用一些初始值:
`worker.onmessage = function (event) { event.data.titles !== undefined ? appComponent.state.titles = event.data.titles : []; event.data.recommendedMovies !== undefined ? appComponent.state.recommendedMovies = event.data.recommendedMovies : []; event.data.recommendedShows !== undefined ? appComponent.state.recommendedShows = event.data.recommendedShows : []; event.data.factsMovies !== undefined ? appComponent.state.factsMovies = event.data.factsMovies : []; event.data.factsShows !== undefined ? appComponent.state.factsShows = event.data.factsShows : []; // NEW event.data.titles !== undefined ? appComponent.state.filteredTitles = event.data.titles : []; appComponent.render() }`
在浏览器中测试一下!
持久数据层
我们将添加的最后一个功能是保存数据的持久日期层,这样我们就不必在每次页面重新加载时远程获取 CSV 文件并分析和处理数据。当标题被删除时,我们也会保存结果。
对于持久性,我们将使用 PouchDB 。
PouchDB
PouchDB 一个为浏览器设计的开源数据库,允许你在浏览器中本地保存数据。因为所有数据都存储在 IndexedDB 中,所以它为我们的应用程序带来了离线支持。所有现代浏览器都支持 T2。
设置
更新index.html文件的头,就在顺风 CSS 之后,安装::
`<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<!-- NEW -->
<script src="https://cdn.jsdelivr.net/npm/[[email protected]](/cdn-cgi/l/email-protection)/dist/pouchdb.min.js"></script>
</head>`
现在,我们可以为标题、推荐和事实创建不同的数据库:
`<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/[[email protected]](/cdn-cgi/l/email-protection)/dist/pouchdb.min.js"></script>
<!-- NEW -->
<script> // Setup PouchDB databases var titlesDB = new PouchDB('titles'); var recommendedMoviesDB = new PouchDB('recommendedMovies'); var recommendedShowsDB = new PouchDB('recommendedShows'); var factsMoviesDB = new PouchDB('factsMovies'); var factsShowsDB = new PouchDB('factsShows'); var remoteCouch = false; </script>
</head>`
持久数据
为了避免在每次页面加载时执行大量的获取和操作数据的操作,我们可以简单地在初始页面加载时运行 worker.js 文件,将结果保存到 PouchDB 数据库,然后在后续页面加载时从数据库中获取数据。
在index.html中,更新最终的脚本标签,如下所示:
`<script> titlesDB.info().then(function (info) { if (info.doc_count == 0) { const worker = new Worker("worker.js"); worker.postMessage("Running Pyodide"); worker.onmessage = function (event) { event.data.titles !== undefined ? (appComponent.state.titles = event.data.titles) : []; event.data.titles !== undefined ? (appComponent.state.filteredTitles = event.data.titles) : []; event.data.recommendedMovies !== undefined ? (appComponent.state.recommendedMovies = event.data.recommendedMovies) : []; event.data.recommendedShows !== undefined ? (appComponent.state.recommendedShows = event.data.recommendedShows) : []; event.data.factsMovies !== undefined ? (appComponent.state.factsMovies = event.data.factsMovies) : []; event.data.factsShows !== undefined ? (appComponent.state.factsShows = event.data.factsShows) : []; appComponent.render(); // Add titles to database appComponent.state.titles.length > 0 ? titlesDB .bulkDocs(JSON.parse(appComponent.state.titles)) .then(function (result) { // handle result }) .catch(function (err) { console.log("titles DB:", err); }) : console.log("No titles to add to database"); // Add recommended movies to database appComponent.state.recommendedMovies.length > 0 ? recommendedMoviesDB .bulkDocs(JSON.parse(appComponent.state.recommendedMovies)) .then(function (result) { // handle result }) .catch(function (err) { console.log("recommendedMovies DB:", err); }) : console.log("No recommended movies to add to database"); // Add recommended shows to database appComponent.state.recommendedShows.length > 0 ? recommendedShowsDB .bulkDocs(JSON.parse(appComponent.state.recommendedShows)) .then(function (result) { // handle result }) .catch(function (err) { console.log("recommendedShows DB:", err); }) : console.log("No recommended shows to add to database"); // Add facts movies to database appComponent.state.factsMovies.length > 0 ? factsMoviesDB .bulkDocs(JSON.parse(appComponent.state.factsMovies).data) .then(function (result) { // handle result }) .catch(function (err) { console.log("factsMovies DB:", err); }) : console.log("No facts movies to add to database"); // Add facts shows to database appComponent.state.factsShows.length > 0 ? factsShowsDB .bulkDocs(JSON.parse(appComponent.state.factsShows).data) .then(function (result) { // handle result }) .catch(function (err) { console.log("factsShows DB:", err); }) : console.log("No facts shows to add to database"); }; } else { console.log("Database already populated"); } }); </script>`
这里,我们检查了标题数据库是否为空:
- 如果是,我们获取并保存数据
- 如果没有,我们只需在控制台上记录一条“数据库已经填充”的消息
继续在浏览器中测试。
在浏览器的开发者工具中,在“应用”选项卡的“IndexedDB”下,您应该会看到以下数据库:
_pouch_titles_pouch_factsMovies_pouch_factsShows_pouch_recommendedMovies_pouch_recommendedShows

重新加载页面。会发生什么?什么都没有,除了唯一的控制台日志,对吗?我们仍然需要从本地数据库加载数据。为此,用以下内容更新else块:
`// use database to populate app state // setting titles state titlesDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const titles = doc.rows.map(function(row) { return row.doc }) appComponent.state.titles = JSON.stringify(titles); appComponent.state.filteredTitles = JSON.stringify(titles); appComponent.render() }); // setting recommended movies state recommendedMoviesDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const recommendedMovies = doc.rows.map(function(row) { return row.doc }) appComponent.state.recommendedMovies = JSON.stringify(recommendedMovies); appComponent.render() }); // setting recommended shows state recommendedShowsDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const recommendedShows = doc.rows.map(function(row) { return row.doc }) appComponent.state.recommendedShows = JSON.stringify(recommendedShows); appComponent.render() }); // setting facts movies state factsMoviesDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const factsMovies = doc.rows.map(function(row) { return row.doc }) appComponent.state.factsMovies = JSON.stringify({ data: factsMovies }); appComponent.render() }); // setting facts shows state factsShowsDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const factsShows = doc.rows.map(function(row) { return row.doc }) appComponent.state.factsShows = JSON.stringify({ data: factsShows }); appComponent.render() });`
回到你的浏览器,让我们测试新的东西。删除“应用程序”选项卡的“索引数据库”部分中的所有数据库。重新加载页面。数据填充需要几秒钟时间。随后的重新加载应该只是瞬间加载。
处理删除
要在用户删除标题时正确删除标题,请更新deleteTitle,如下所示:
`deleteTitle(id) { const title = JSON.parse(this.state.titles).find((title) => title.id == id); titlesDB.remove(title); this.state.titles = JSON.stringify( JSON.parse(this.state.titles).filter(function (title) { return title.id !== id; }) ); this.state.filteredTitles = JSON.stringify( JSON.parse(this.state.filteredTitles).filter(function (title) { return title.id !== id; }) ); this.render(); }`
回到您的浏览器,测试删除。请通过重新加载页面来确保它持续存在。
结论
就是这样!
在本系列教程中,您学习了如何使用 Python 构建单页面应用程序。用 Pyodide 在浏览器中执行 Python 打开了许多以前不可能的新大门。例如,您:
- 可以在浏览器中使用像熊猫这样强大的库
- 使用 Python 可以直接访问所有 Web APIs
- 甚至可以使用 Python 来操作 DOM
此外,您可以将 Python 与 JavaScript 结合使用,以最大限度地发挥这两种语言(及其丰富的库)的优势!
我们还看到了如何通过使用 web workers 在后台运行繁重的计算,从主线程中分离出来,从而提高应用程序的性能。通过将 Python 脚本移动到一个单独的模块中,我们减少了应用程序的污染,并且更易于维护和扩展。
我们通过使用 PouchDB 向我们的应用程序添加一个数据层来完成这个系列。该功能通过减少页面负载和引入离线功能改善了用户体验。
你可以在这里找到这个教程的源代码。
我希望你喜欢这个系列。如果您有任何问题或意见,请随时联系我。干杯!
--
Python 单页面应用系列:
- 第 1 部分 :学习 Pyodide 的基础知识,创建基础应用
- 第二部分 :用 Pandas 分析和操作数据,使用 web worker 加速应用程序
- 第三部分(本教程!):创建一个 Python 包,添加附加功能,并添加一个持久数据层
用 Python 和 Selenium 构建并发 Web Scraper
原文:https://testdriven.io/blog/building-a-concurrent-web-scraper-with-python-and-selenium/
本文着眼于如何通过concurrent.futures模块用多线程加速 Python web 抓取和爬行脚本。我们还将分解脚本本身,并展示如何用 pytest 测试解析功能。
完成本文后,您将能够:
- 用 Selenium 抓取网站,用美汤解析 HTML
- 设置 pytest 来测试抓取和解析功能
- 与
concurrent.futures模块同时执行网络刮刀 - 使用 Selenium 为 ChromeDriver 配置无头模式
项目设置
如果你想继续的话,复制回购协议。从命令行运行以下命令:
`$ git clone [[email protected]](/cdn-cgi/l/email-protection):testdrivenio/concurrent-web-scraping.git
$ cd concurrent-web-scraping
$ python -m venv env
$ source env/bin/activate
(env)$ pip install -r requirements.txt`
根据您的环境,上述命令可能会有所不同。
全局安装 ChromeDriver 。(我们使用的是版本 96.0.4664.45 )。
脚本概述
该脚本向 Wikipedia:Random - https://en.wikipedia.org/wiki/Special:Random发出 20 个请求,请求关于每篇文章的信息,使用 Selenium 自动与站点交互,使用 Beautiful Soup 解析 HTML。
script.py :
`import datetime
import sys
from time import sleep, time
from scrapers.scraper import connect_to_base, get_driver, parse_html, write_to_file
def run_process(filename, browser):
if connect_to_base(browser):
sleep(2)
html = browser.page_source
output_list = parse_html(html)
write_to_file(output_list, filename)
else:
print("Error connecting to Wikipedia")
if __name__ == "__main__":
# headless mode?
headless = False
if len(sys.argv) > 1:
if sys.argv[1] == "headless":
print("Running in headless mode")
headless = True
# set variables
start_time = time()
current_attempt = 1
output_timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
output_filename = f"output_{output_timestamp}.csv"
# init browser
browser = get_driver(headless=headless)
# scrape and crawl
while current_attempt <= 20:
print(f"Scraping Wikipedia #{current_attempt} time(s)...")
run_process(output_filename, browser)
current_attempt = current_attempt + 1
# exit
browser.quit()
end_time = time()
elapsed_time = end_time - start_time
print(f"Elapsed run time: {elapsed_time} seconds")`
让我们从主块开始。在确定 Chrome 是否应该在 headless 模式下运行并定义一些变量后,浏览器通过 scrapers/scraper.py 中的get_driver()进行初始化:
`if __name__ == "__main__":
# headless mode?
headless = False
if len(sys.argv) > 1:
if sys.argv[1] == "headless":
print("Running in headless mode")
headless = True
# set variables
start_time = time()
current_attempt = 1
output_timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
output_filename = f"output_{output_timestamp}.csv"
########
# here #
########
# init browser
browser = get_driver(headless=headless)
# scrape and crawl
while current_attempt <= 20:
print(f"Scraping Wikipedia #{current_attempt} time(s)...")
run_process(output_filename, browser)
current_attempt = current_attempt + 1
# exit
browser.quit()
end_time = time()
elapsed_time = end_time - start_time
print(f"Elapsed run time: {elapsed_time} seconds")`
然后配置一个while回路来控制整个刮刀的流量。
`if __name__ == "__main__":
# headless mode?
headless = False
if len(sys.argv) > 1:
if sys.argv[1] == "headless":
print("Running in headless mode")
headless = True
# set variables
start_time = time()
current_attempt = 1
output_timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
output_filename = f"output_{output_timestamp}.csv"
# init browser
browser = get_driver(headless=headless)
########
# here #
########
# scrape and crawl
while current_attempt <= 20:
print(f"Scraping Wikipedia #{current_attempt} time(s)...")
run_process(output_filename, browser)
current_attempt = current_attempt + 1
# exit
browser.quit()
end_time = time()
elapsed_time = end_time - start_time
print(f"Elapsed run time: {elapsed_time} seconds")`
在这个循环中,run_process()被调用,它管理 WebDriver 的连接和抓取功能。
`def run_process(filename, browser):
if connect_to_base(browser):
sleep(2)
html = browser.page_source
output_list = parse_html(html)
write_to_file(output_list, filename)
else:
print("Error connecting to Wikipedia")`
在run_process()中,浏览器实例传递给了connect_to_base()。
`def run_process(filename, browser):
########
# here #
########
if connect_to_base(browser):
sleep(2)
html = browser.page_source
output_list = parse_html(html)
write_to_file(output_list, filename)
else:
print("Error connecting to wikipedia")`
这个函数试图连接到 wikipedia,然后使用 Selenium 的显式等待功能来确保带有id='content'的元素在继续之前已经加载。
`def connect_to_base(browser):
base_url = "https://en.wikipedia.org/wiki/Special:Random"
connection_attempts = 0
while connection_attempts < 3:
try:
browser.get(base_url)
# wait for table element with id = 'content' to load
# before returning True
WebDriverWait(browser, 5).until(
EC.presence_of_element_located((By.ID, "content"))
)
return True
except Exception as e:
print(e)
connection_attempts += 1
print(f"Error connecting to {base_url}.")
print(f"Attempt #{connection_attempts}.")
return False`
查看 Selenium 文档了解更多关于显式等待的信息。
为了模拟人类用户,在浏览器连接到维基百科后会调用sleep(2)。
`def run_process(filename, browser):
if connect_to_base(browser):
########
# here #
########
sleep(2)
html = browser.page_source
output_list = parse_html(html)
write_to_file(output_list, filename)
else:
print("Error connecting to Wikipedia")`
一旦页面被加载并且sleep(2)被执行,浏览器获取 HTML 源,然后传递给parse_html()。
`def run_process(filename, browser):
if connect_to_base(browser):
sleep(2)
########
# here #
########
html = browser.page_source
########
# here #
########
output_list = parse_html(html)
write_to_file(output_list, filename)
else:
print("Error connecting to Wikipedia")`
使用 Beautiful Soup 解析 HTML,生成包含适当数据的字典列表。
`def parse_html(html):
# create soup object
soup = BeautifulSoup(html, "html.parser")
output_list = []
# parse soup object to get wikipedia article url, title, and last modified date
article_url = soup.find("link", {"rel": "canonical"})["href"]
article_title = soup.find("h1", {"id": "firstHeading"}).text
article_last_modified = soup.find("li", {"id": "footer-info-lastmod"}).text
article_info = {
"url": article_url,
"title": article_title,
"last_modified": article_last_modified,
}
output_list.append(article_info)
return output_list`
该函数还将文章 URL 传递给get_load_time(),由它加载 URL 并记录随后的加载时间。
`def get_load_time(article_url):
try:
# set headers
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36"
}
# make get request to article_url
response = requests.get(
article_url, headers=headers, stream=True, timeout=3.000
)
# get page load time
load_time = response.elapsed.total_seconds()
except Exception as e:
print(e)
load_time = "Loading Error"
return load_time`
输出被添加到 CSV 文件中。
`def run_process(filename, browser):
if connect_to_base(browser):
sleep(2)
html = browser.page_source
output_list = parse_html(html)
########
# here #
########
write_to_file(output_list, filename)
else:
print("Error connecting to Wikipedia")`
write_to_file():
`def write_to_file(output_list, filename):
for row in output_list:
with open(Path(BASE_DIR).joinpath(filename), "a") as csvfile:
fieldnames = ["url", "title", "last_modified"]
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writerow(row)`
最后,回到while循环中,current_attempt递增,过程再次开始。
`if __name__ == "__main__":
# headless mode?
headless = False
if len(sys.argv) > 1:
if sys.argv[1] == "headless":
print("Running in headless mode")
headless = True
# set variables
start_time = time()
current_attempt = 1
output_timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
output_filename = f"output_{output_timestamp}.csv"
# init browser
browser = get_driver(headless=headless)
# scrape and crawl
while current_attempt <= 20:
print(f"Scraping Wikipedia #{current_attempt} time(s)...")
run_process(output_filename, browser)
########
# here #
########
current_attempt = current_attempt + 1
# exit
browser.quit()
end_time = time()
elapsed_time = end_time - start_time
print(f"Elapsed run time: {elapsed_time} seconds")`
想测试一下吗?点击获取完整脚本。
运行大约需要 57 秒:
`(env)$ python script.py
Scraping Wikipedia #1 time(s)...
Scraping Wikipedia #2 time(s)...
Scraping Wikipedia #3 time(s)...
Scraping Wikipedia #4 time(s)...
Scraping Wikipedia #5 time(s)...
Scraping Wikipedia #6 time(s)...
Scraping Wikipedia #7 time(s)...
Scraping Wikipedia #8 time(s)...
Scraping Wikipedia #9 time(s)...
Scraping Wikipedia #10 time(s)...
Scraping Wikipedia #11 time(s)...
Scraping Wikipedia #12 time(s)...
Scraping Wikipedia #13 time(s)...
Scraping Wikipedia #14 time(s)...
Scraping Wikipedia #15 time(s)...
Scraping Wikipedia #16 time(s)...
Scraping Wikipedia #17 time(s)...
Scraping Wikipedia #18 time(s)...
Scraping Wikipedia #19 time(s)...
Scraping Wikipedia #20 time(s)...
Elapsed run time: 57.36561393737793 seconds`
明白了吗?太好了!让我们添加一些基本的测试。
测试
要在不启动浏览器的情况下测试解析功能,从而向 Wikipedia 发出重复的 GET 请求,您可以下载页面的 HTML ( test/test.html )并在本地解析它。这有助于避免在编写和测试解析函数时,由于太快发出太多请求而导致 IP 被阻塞的情况,同时也节省了时间,因为不必在每次运行脚本时都打开浏览器。
test/test_scraper.py :
`from pathlib import Path
import pytest
from scrapers import scraper
BASE_DIR = Path(__file__).resolve(strict=True).parent
@pytest.fixture(scope="module")
def html_output():
with open(Path(BASE_DIR).joinpath("test.html"), encoding="utf-8") as f:
html = f.read()
yield scraper.parse_html(html)
def test_output_is_not_none(html_output):
assert html_output
def test_output_is_a_list(html_output):
assert isinstance(html_output, list)
def test_output_is_a_list_of_dicts(html_output):
assert all(isinstance(elem, dict) for elem in html_output)`
确保一切正常:
`(env)$ python -m pytest test/test_scraper.py
================================ 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/async-web-scraping
collected 3 items
test/test_scraper.py ... [100%]
================================= 3 passed in 0.19 ==================================`
想模仿get_load_time()绕过 GET 请求?
test/test _ scraper _ mock . py:
`from pathlib import Path
import pytest
from scrapers import scraper
BASE_DIR = Path(__file__).resolve(strict=True).parent
@pytest.fixture(scope="function")
def html_output(monkeypatch):
def mock_get_load_time(url):
return "mocked!"
monkeypatch.setattr(scraper, "get_load_time", mock_get_load_time)
with open(Path(BASE_DIR).joinpath("test.html"), encoding="utf-8") as f:
html = f.read()
yield scraper.parse_html(html)
def test_output_is_not_none(html_output):
assert html_output
def test_output_is_a_list(html_output):
assert isinstance(html_output, list)
def test_output_is_a_list_of_dicts(html_output):
assert all(isinstance(elem, dict) for elem in html_output)`
测试:
`(env)$ python -m pytest test/test_scraper_mock.py
================================ 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/async-web-scraping
collected 3 items
test/test_scraper.py ... [100%]
================================= 3 passed in 0.27s =================================`
配置多线程
现在有趣的部分来了!通过对脚本进行一些修改,我们可以加快速度:
`import datetime
import sys
from concurrent.futures import ThreadPoolExecutor, wait
from time import sleep, time
from scrapers.scraper import connect_to_base, get_driver, parse_html, write_to_file
def run_process(filename, headless):
# init browser
browser = get_driver(headless)
if connect_to_base(browser):
sleep(2)
html = browser.page_source
output_list = parse_html(html)
write_to_file(output_list, filename)
# exit
browser.quit()
else:
print("Error connecting to Wikipedia")
browser.quit()
if __name__ == "__main__":
# headless mode?
headless = False
if len(sys.argv) > 1:
if sys.argv[1] == "headless":
print("Running in headless mode")
headless = True
# set variables
start_time = time()
output_timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
output_filename = f"output_{output_timestamp}.csv"
futures = []
# scrape and crawl
with ThreadPoolExecutor() as executor:
for number in range(1, 21):
futures.append(
executor.submit(run_process, output_filename, headless)
)
wait(futures)
end_time = time()
elapsed_time = end_time - start_time
print(f"Elapsed run time: {elapsed_time} seconds")`
有了concurrent.futures库,ThreadPoolExecutor被用来产生一个线程池来异步执行run_process函数。提交方法接受该函数及其参数,并返回一个未来对象。 wait 用于阻塞执行,直到所有任务完成。
值得注意的是,您可以通过ProcessPoolExecutor轻松切换到多处理,因为ProcessPoolExecutor和ThreadPoolExecutor实现了相同的接口:
`# scrape and crawl
with ProcessPoolExecutor() as executor:
for number in range(1, 21):
futures.append(
executor.submit(run_process, output_filename, headless)
)`
为什么是多线程而不是多重处理?
Web 抓取是 I/O 绑定的,因为获取 HTML (I/O)比解析它(CPU)慢。关于这一点以及并行性(多处理)和并发性(多线程)之间的区别的更多信息,请回顾文章用并发性、并行性和异步性加速 Python。
运行:
`(env)$ python script_concurrent.py
Elapsed run time: 11.831077098846436 seconds`
点击查看完整的脚本。
为了进一步加快速度,我们可以通过传入headless命令行参数在无头模式下运行 Chrome:
`(env)$ python script_concurrent.py headless
Running in headless mode
Elapsed run time: 6.222846269607544 seconds`
结论
通过对原始代码进行少量修改,我们能够并发执行 web scraper,将脚本的运行时间从大约 57 秒缩短到 6 秒多一点。在这个特定场景中,速度提高了大约 90%,这是一个巨大的进步。
我希望这对你的剧本有所帮助。你可以在回购中找到代码。干杯!
Django REST 框架中内置的权限类
原文:https://testdriven.io/blog/built-in-permission-classes-drf/
本文着眼于 Django REST 框架(DRF)中内置的权限类是如何工作的。
--
Django REST 框架权限系列:
目标
完成本文后,您应该能够:
- 解释 DRF 的七个内置权限类之间的区别
- 设置特定模型和对象的权限
- 使用内置权限类来设置全局权限策略
内置类
虽然你可以创建自己的权限类,但 DRF 提供了七个内置类,旨在让你的生活更轻松:
- 允许使用
- 被认证
- isauthenticedorreadonly
- 是管理员
- 姜戈模型权限
- djangodelpermissionsoranonreadonly
- DjangoObjectPermissions
使用它们就像在特定 API 视图的permission_classes列表中包含类一样简单。它们从完全开放(AllowAny)到只授予管理员用户的访问权限(IsAdminUser)。只需很少的额外工作,您就可以使用它们来实现细粒度的访问控制——无论是在模型上还是在对象级别上。您还可以为所有 API 端点全局设置权限。
除了最后一个类DjangoObjectPermissions,所有这些类都只覆盖了has_permission方法,并从BasePermission类继承了has_object_permission。BasePermission类中的has_object_permission总是返回True,所以它对对象级访问限制没有影响:
| 权限类别 | 拥有 _ 权限 | 拥有 _ 对象 _ 权限 |
|---|---|---|
| 允许任何 | ✓ | ✗ |
| 已认证 | ✓ | ✗ |
| isauthentaicatedorreadonly | ✓ | ✗ |
| IsAdminUser | ✓ | ✗ |
| DjangoModelPermissions | ✓ | ✗ |
| djangodelpermissionsoranonreadonly | ✓ | ✗ |
| DjangoObjectPermissions | 通过扩展DjangoModelPermissions |
✓ |
关于
has_permission与has_object_permission的更多信息,请务必阅读本系列的第一篇文章,Django REST 框架中的权限。
允许任何
最开放的权限是AllowAny。AllowAny上的has_permission和has_object_permission方法总是不做任何检查就返回True。没有必要使用它(通过不设置 permission 类,您隐式地设置了这个类),但您仍然应该使用它,因为它使意图变得明确,并有助于保持整个应用程序的一致性。
通过在视图中包含permission_classes来指定它:
`from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from .models import Message
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [AllowAny] # built-in permission class used
queryset = Message.objects.all()
serializer_class = MessageSerializer`
任何人,甚至未经身份验证的用户,都可以使用任何 HTTP 请求方法访问 API 端点:

已认证
IsAuthenticated检查请求是否有用户,以及该用户是否被认证。将permission_classes设置为IsAuthenticated意味着只有经过认证的用户才能使用任何请求方法访问 API 端点。
`from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Message
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated] # permission class changed
queryset = Message.objects.all()
serializer_class = MessageSerializer`
未经身份验证的用户现在被拒绝访问:

isauthentaicatedorreadonly
当权限设置为IsAuthenticatedOrReadOnly时,请求必须或者有一个经过验证的用户,或者使用一个安全/只读的 HTTP 请求方法(GET、HEAD、OPTIONS)。这意味着每个用户将能够看到所有的消息,但是只有登录的用户能够添加、更改或删除对象。
`from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .models import Message
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticatedOrReadOnly] # ReadOnly added
queryset = Message.objects.all()
serializer_class = MessageSerializer`
未经验证的用户可以查看由经过验证的用户发布的消息,但是他们不能对其进行任何操作或添加他们自己的消息:

IsAdminUser
权限设置为IsAdminUser意味着请求需要有一个用户,并且该用户必须将的 is_staff 设置为True。这意味着只有管理员用户可以查看、添加、更改或删除对象。
`from rest_framework import viewsets
from rest_framework.permissions import IsAdminUser
from .models import Message
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [IsAdminUser] # only for admin users
queryset = Message.objects.all()
serializer_class = MessageSerializer`
有趣的是,未经身份验证的用户和没有管理员权限的经过身份验证的用户会得到不同的错误。
对于未经验证的用户,会引发一个NotAuthenticated异常:

同时,对于没有管理员访问权限的已验证用户,会引发一个PermissionDenied异常:

DjangoModelPermissions
DjangoModelPermissions允许我们分别为每个用户设置任意权限组合。然后权限检查用户是否通过了身份验证,以及他们是否对模型拥有add、change或delete用户权限。
`from rest_framework import viewsets
from rest_framework.permissions import DjangoModelPermissions
from .models import Message
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [DjangoModelPermissions]
queryset = Message.objects.all()
serializer_class = MessageSerializer`
与其他权限不同,这并不是设置权限的结束。您需要为特定用户设置权限:

如果您查看帖子的单一视图,您可以看到该特定用户可以编辑帖子,但不能删除它:

DjangoModelPermissions不一定需要分配给单个用户。你可以用它获得组的权限也可以。以下是允许删除邮件的群组:

您可以在这里看到,该组的成员可以删除该消息:

DjangoModelPermissions必须仅应用于具有queryset属性或get_queryset()方法的视图。例如, CreateAPIView 通用视图不需要 queryset,所以如果你在它上面设置
DjangoModelPermissions,你会得到一个断言错误。但是,如果您执行查询,即使您没有使用 queryset,DjangoModelPermissions也将工作:class NewMessage(generics.CreateAPIView): queryset = Message.objects.all() permission_classes = [DjangoModelPermissions] serializer_class = MessageSerializer
djangodelpermissionsoranonreadonly
DjangoModelPermissionsOrAnonReadOnly扩展了DjangoModelPermissions,只改变了一件事:将authenticated_users_only设置为False。
`from rest_framework import viewsets
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
from .models import Message
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
queryset = Message.objects.all()
serializer_class = MessageSerializer`
匿名用户可以看到这些对象,但不能与之交互:

DjangoObjectPermissions
虽然DjangoModelPermissions限制了用户与模型(所有实例)交互的权限,但是DjangoObjectPermissions限制了与模型的单个实例(一个对象)的交互。要使用DjangoObjectPermissions,你需要一个支持对象级权限的权限后端。我们将看看姜戈-卫报。
虽然有相当多的包覆盖了 Django 权限, DRF 明确提到了 django-guardian ,这就是为什么我们在本文中使用它。
处理对象级权限的其他包:
- drf-扩展
- 姜戈-奥索
- 姜戈-规则
- django-角色-权限
安装 django-guardian
要使用 django-guardian,首先需要安装它:
`$ pip install django-guardian`
然后,将其添加到INSTALLED_APPS和AUTHENTICATION_BACKENDS:
`# settings.py
INSTALLED_APPS = [
# ...
'rest_framework',
'guardian',
]
# ...
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'guardian.backends.ObjectPermissionBackend',
)`
最后,运行迁移:
`(venv)$ python manage.py migrate`
如果你想查看单个对象的权限,你需要使用 GuardedModelAdmin 而不是 ModelAdmin 。你可以在一个 admin.py 文件中这样做:
`# admin.py
from django.contrib import admin
from guardian.admin import GuardedModelAdmin
from tutorial.models import Message
class MessageAdmin(GuardedModelAdmin):
pass
admin.site.register(Message, MessageAdmin)`
现在,如果你在管理面板中打开一个单独的对象,在右上角会有一个新的按钮叫做“对象权限”。单击它,将打开“对象权限”面板:

使用 DjangoObjectPermissions
现在知道has_permission和has_object_permission 的区别将会派上用场。简而言之,DRF 首先检查请求是否有访问模型的权限。如果没有,DRF 不关心对象级权限。

这意味着如果您想要检查对象级权限,用户必须拥有模型权限。对象级权限的一个很好的用例是只允许对象的所有者更改或删除它。这里有一个视图,只允许对象的创建者删除它:
`# views.py
from guardian.shortcuts import assign_perm
from rest_framework import viewsets
from rest_framework.permissions import DjangoObjectPermissions
from .models import Message
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [DjangoObjectPermissions] # class changed
queryset = Message.objects.all()
serializer_class = MessageSerializer
def perform_create(self, serializer): # new function
instance = serializer.save()
assign_perm("delete_message", self.request.user, instance)`
如您所见,有两个重要的变化:
- 第一,权限类是
DjangoObjectPermissions。 - 接下来,我们开始创建模型实例。我们不能给一个不存在的对象分配权限,所以我们首先需要创建实例,然后通过 django-guardian 中的 assign_perm 快捷方式给它分配对象权限。
assign_perm是一个 django-guardian 函数,用于向某些用户或组分配权限。它需要三个参数:
- 权限:
delete_message - 用户或组:
self.request.user - 对象(默认为
None):instance
同样,为了使对象权限起作用,用户必须拥有相应模型的模型级权限。假设您有两个对模型拥有相同权限的用户:

您使用上面的代码将权限只分配给创建者。 user_1 -对象的创建者-可以删除它,但是 user_2 不能删除它,尽管他们有模型级别的权限:

如果我们删除 user_1 在模型上删除任何对象的权限:

他们不能删除他们的邮件:

即使他们拥有删除该特定对象的权限:

全局权限
您可以使用内置的权限类在您的 settings.py 文件中轻松设置全局权限。例如:
`# settings.py
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
]
}`
DEFAULT_PERMISSION_CLASSES仅适用于没有明确设置权限的视图或对象。
这里不一定需要使用内置类。你也可以使用你自己的定制类。
结论
就是这样。您现在应该知道如何使用 Django REST 框架的七个内置权限类。它们从完全打开(AllowAny)到基本关闭(IsAdminUser)不等。
您可以在模型(DjangoModelPermissions)或单个对象(DjangoObjectPermissions)上设置全局权限。还有一些类允许你限制“不安全”的 HTTP 方法,但是允许任何人使用安全的方法(IsAuthenticatedOrReadOnly、DjangoModelPermissionsOrAnonReadOnly)。
如果您没有任何特定的需求,内置的类应该可以满足大多数情况。另一方面,如果你有一些特殊的要求,你应该建立自己的权限类。
--
Django REST 框架权限系列:
使用 Celery 和 Django 数据库事务
在本文中,我们将研究如何在数据库提交事务之前阻止依赖于 Django 数据库事务的 Celery 任务执行。这是一个相当普遍的问题。
姜戈+芹菜系列:
- 与 Django 和 Celery 的异步任务
- 在 Django 用芹菜和码头工人处理定期任务
- 自动重试失败的芹菜任务
- 处理芹菜和数据库事务(本文!)
目标
阅读后,您应该能够:
- 描述什么是数据库事务以及如何在 Django 中使用它
- 解释为什么芹菜工会出现
DoesNotExist错误,以及如何解决 - 防止任务在数据库提交事务之前执行
什么是数据库事务?
数据库事务是作为一个单元提交(应用于数据库)或回滚(从数据库中撤消)的工作单元。
大多数数据库使用以下模式:
- 开始交易。
- 执行一组数据操作和/或查询。
- 如果没有错误发生,则提交事务。
- 如果出现错误,则回滚事务。
如您所见,事务是让您的数据远离混乱的一种非常有用的方式。
如何在 Django 中使用数据库事务
你可以在 Github 上找到这篇文章的源代码。如果您在阅读本文时决定使用这段代码,请记住用户名必须是惟一的。你可以使用一个随机的用户名生成器进行测试,比如 Faker 。
让我们先来看看 Django 的观点:
`def test_view(request):
user = User.objects.create_user('john', '[[email protected]](/cdn-cgi/l/email-protection)', 'johnpassword')
logger.info(f'create user {user.pk}')
raise Exception('test')`
当你访问这个视图时会发生什么?
默认行为
Django 的默认行为是自动提交:每个查询都直接提交给数据库,除非事务是活动的。换句话说,通过自动提交,每个查询都会启动一个事务,并提交或回滚该事务。如果您有一个包含三个查询的视图,那么每个查询将一个接一个地运行。如果一个失败了,另外两个就会被提交。
因此,在上面的视图中,在提交事务后引发异常,创建用户john。
显式控制
如果您希望对数据库事务有更多的控制,您可以用 transaction.atomic 覆盖默认行为。在这种模式下,在调用视图函数之前,Django 启动一个事务。如果响应没有问题,Django 就提交事务。另一方面,如果视图产生异常,Django 回滚事务。如果有三个查询,其中一个失败了,那么所有查询都不会提交。
因此,让我们使用transaction.atomic重写视图:
`def transaction_test(request):
with transaction.atomic():
user = User.objects.create_user('john1', '[[email protected]](/cdn-cgi/l/email-protection)', 'johnpassword')
logger.info(f'create user {user.pk}')
raise Exception('force transaction to rollback')`
现在user create操作会在异常出现时回滚,所以最终不会创建用户。
是一个非常有用的工具,可以让你的数据有条理,尤其是当你需要在模型中操作数据的时候。
它也可以像这样用作装饰器:
`@transaction.atomic
def transaction_test2(request):
user = User.objects.create_user('john1', '[[email protected]](/cdn-cgi/l/email-protection)hebeatles.com', 'johnpassword')
logger.info(f'create user {user.pk}')
raise Exception('force transaction to rollback')`
因此,如果视图中出现了一些错误,而我们没有发现它,那么事务将会回滚。
如果您想将transaction.atomic用于所有视图功能,您可以在 Django 设置文件中将ATOMIC_REQUESTS设置为True:
`ATOMIC_REQUESTS=True
# or
DATABASES["default"]["ATOMIC_REQUESTS"] = True`
然后,您可以覆盖该行为,以便视图以自动提交模式运行:
`@transaction.non_atomic_requests`
不存在异常
如果您对 Django 如何管理数据库事务没有很好的理解,当您在 Celery worker 中遇到与数据库相关的随机错误时,可能会很困惑。
让我们看一个例子:
`@transaction.atomic
def transaction_celery(request):
username = random_username()
user = User.objects.create_user(username, '[[email protected]](/cdn-cgi/l/email-protection)', 'johnpassword')
logger.info(f'create user {user.pk}')
task_send_welcome_email.delay(user.pk)
time.sleep(1)
return HttpResponse('test')`
任务代码如下所示:
`@shared_task()
def task_send_welcome_email(user_pk):
user = User.objects.get(pk=user_pk)
logger.info(f'send email to {user.email} {user.pk}')`
- 因为视图使用了
transaction.atomic装饰器,所以所有的数据库操作只有在视图中没有出现错误时才会被提交,包括芹菜任务。 - 这个任务相当简单:我们创建一个用户,然后将主键传递给发送欢迎电子邮件的任务。
time.sleep(1)用于引入竞争条件。
运行时,您会看到以下错误:
`django.contrib.auth.models.User.DoesNotExist: User matching query does not exist.`
为什么?
- 任务入队后,我们暂停 1 秒钟。
- 由于任务立即执行,
user = User.objects.get(pk=user_pk)失败,因为 Django 中的事务尚未提交,用户不在数据库中。
解决办法
有三种方法可以解决这个问题:
-
禁用数据库事务,这样 Django 就可以使用
autocommit特性。为此,您可以简单地移除transaction.atomic装饰器。然而,不推荐这样做,因为原子数据库事务是一个强大的工具。 -
强制芹菜任务在一段时间后运行。
例如,要暂停 10 秒钟:
task_send_welcome_email.apply_async(args=[user.pk], countdown=10) -
Django 有一个名为
transaction.on_commit的回调函数,在事务成功提交后执行。要使用它,请按如下方式更新视图:@transaction.atomic def transaction_celery2(request): username = random_username() user = User.objects.create_user(username, '[[email protected]](/cdn-cgi/l/email-protection)', 'johnpassword') logger.info(f'create user {user.pk}') # the task does not get called until after the transaction is committed transaction.on_commit(lambda: task_send_welcome_email.delay(user.pk)) time.sleep(1) return HttpResponse('test')现在,直到数据库事务提交之后才调用该任务。所以,当 Celery worker 找到用户时,它可以被找到,因为 worker 中的代码总是在 Django 数据库事务成功提交后的运行。
这是推荐的方案。
值得注意的是,您可能不希望事务立即提交,尤其是在大规模环境中运行时。如果数据库或实例处于高利用率状态,强制提交只会增加现有的使用率。在这种情况下,您可能希望使用第二种解决方案,并等待足够长的时间(也许 20 秒),以确保在任务执行之前对数据库进行了更改。
测试
Django 的TestCase将每个测试包装在一个事务中,然后在每个测试后回滚。因为没有事务被提交,on_commit()也不会运行。因此,如果您需要测试在on_commit回调中触发的代码,您可以在测试代码中使用 TransactionTestCase 或test case . captureoncommitcallbacks()。
芹菜任务中的数据库事务
如果您的 Celery 任务需要更新数据库记录,那么在 Celery 任务中使用数据库事务是有意义的。
一个简单的方法是with transaction.atomic():
`@shared_task()
def task_transaction_test():
with transaction.atomic():
from .views import random_username
username = random_username()
user = User.objects.create_user(username, '[[email protected]](/cdn-cgi/l/email-protection)', 'johnpassword')
user.save()
logger.info(f'send email to {user.pk}')
raise Exception('test')`
更好的方法是编写一个支持transaction的自定义decorator:
`class custom_celery_task:
"""
This is a decorator we can use to add custom logic to our Celery task
such as retry or database transaction
"""
def __init__(self, *args, **kwargs):
self.task_args = args
self.task_kwargs = kwargs
def __call__(self, func):
@functools.wraps(func)
def wrapper_func(*args, **kwargs):
try:
with transaction.atomic():
return func(*args, **kwargs)
except Exception as e:
# task_func.request.retries
raise task_func.retry(exc=e, countdown=5)
task_func = shared_task(*self.task_args, **self.task_kwargs)(wrapper_func)
return task_func
...
@custom_celery_task(max_retries=5)
def task_transaction_test():
# do something`
结论
本文研究了如何让 Celery 很好地与 Django 数据库事务一起工作。
本文的源代码可以在 GitHub 上找到。
感谢您的阅读。如果您有任何问题,请随时联系我。
姜戈+芹菜系列:
- 与 Django 和 Celery 的异步任务
- 在 Django 用芹菜和码头工人处理定期任务
- 自动重试失败的芹菜任务
- 处理芹菜和数据库事务(本文!)
Python 中的干净代码
在本文中,我们将讨论干净的代码——它的好处,不同的代码标准和原则,以及如何编写干净代码的一般准则。
什么是干净代码?
干净的代码是一组规则和原则,有助于保持代码的可读性、可维护性和可扩展性。这是编写高质量软件的最重要的方面之一。我们(开发人员)花在阅读代码上的时间比实际写代码的时间要多得多,这就是为什么我们写好代码很重要。
编写代码很容易,但是编写好的、干净的代码却很难。
我们写的代码应该简单,有表现力,并且没有很多重复。表达性代码意味着,即使我们只是向计算机提供指令,当人类阅读时,它仍然应该是可读的,并清楚地传达其意图。
干净代码的重要性
编写干净的代码有很多好处。例如,干净的代码是:
- 容易理解
- 更有效率
- 更易于维护、扩展、调试和重构
它也倾向于需要更少的文档。
代码标准
代码标准是编码规则、指南和最佳实践的集合。每种编程语言都有自己的编码标准,为了编写更简洁的代码,应该遵循这些标准。他们通常会处理:
- 文件组织
- 编程-实践和原则
- 代码格式(缩进、声明、语句)
- 命名规格
- 评论
PEP 8 (Python 增强提案)
PEP 8 是一个描述 Python 编码标准的风格指南。这是 Python 社区中最受欢迎的指南。最重要的规则如下:
PEP 8 命名约定:
- 类名应该是驼色(
MyClass) - 变量名应该是 snake_case 并且全部小写(
first_name) - 函数名应该是 snake_case 并且全部小写(
quick_sort()) - 常量应该是 snake_case 并且全部大写(
PI = 3.14159) - 模块应该有简短的蛇名和小写字母(
numpy) - 单引号和双引号被同等对待(只需选择一个并保持一致)
PEP 8 线条格式:
- 使用 4 个空格缩进(空格优先于制表符)
- 行数不应超过 79 个字符
- 避免在同一行出现多个语句
- 顶级函数和类定义用两个空行包围
- 类中的方法定义由一个空行包围
- 导入应该在单独的行上
PEP 8 空白:
- 避免括号或大括号内有多余的空格
- 避免尾随空白
- 始终用一个空格将二元运算符括起来
- 如果使用具有不同优先级的操作符,可以考虑在优先级最低的操作符周围添加空格
- 当用于表示关键字参数时,不要在=符号周围使用空格
PEP 8 评论:
- 注释不应与代码相矛盾
- 评论应该是完整的句子
- 注释的#号后面应该有一个空格,第一个单词大写
- 函数中使用的多行注释(文档字符串)应该有一个简短的单行描述,后面跟着更多的文本
如果你想了解更多,请阅读官方 PEP 8 参考资料。
Pythonic 代码
Python 代码是 Python 社区采用的一组习惯用法。这仅仅意味着你很好地使用了 Python 的习惯用法和范例,以使你的代码更加清晰、易读和高性能。
Pythonic 代码包括:
- 可变戏法
- 列表操作(初始化、切片)
- 处理函数
- 显式代码
写 Python 代码和写 Python 代码有很大的区别。要编写 Python 代码,你不能只是习惯性地将另一种语言(如 Java 或 C++)翻译成 Python 你需要用 Python 来思考。
让我们看一个例子。我们必须像这样把前 10 个数字加在一起。
非 Pythonic 解决方案应该是这样的:
`n = 10
sum_all = 0
for i in range(1, n + 1):
sum_all = sum_all + i
print(sum_all) # 55`
一个更 Pythonic 化的解决方案可能是这样的:
`n = 10
sum_all = sum(range(1, n + 1))
print(sum_all) # 55`
第二个例子对于有经验的 Python 开发人员来说更容易理解,但是它需要对 Python 的内置函数和语法有更深的理解。编写 Python 代码最简单的方法是在编写代码时牢记 Python 的禅,并逐步学习 Python 的标准库。
Python 的禅
Python 的禅是用 Python 写计算机程序的 19 个“指导原则”的集合。这本集子是软件工程师蒂姆·彼得斯在 1999 年写的。它作为复活节彩蛋包含在 Python 解释器中。
您可以通过执行以下命令来查看它:
`>>> import this
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!`
如果你对这首“诗”的含义感到好奇,请查看Python 的禅宗,解释,它提供了逐行的解释。
代码原则
为了编写更好的代码,您可以遵循许多编码原则,每种原则都有自己的优缺点和权衡。这篇文章涵盖了四个比较流行的原则:干,吻,SoC,和固体。
干(不重复)
每一项知识都必须在系统中有一个单一的、明确的、权威的表示。
这是最简单的编码原则之一。它唯一的规则是代码不能重复。不要复制行,要找到一种使用迭代的算法。干代码易于维护。您可以在模型/数据抽象中进一步应用这一原则。
DRY 原则的缺点是,你最终会有太多的抽象、外部依赖和复杂的代码。如果你试图改变你的代码库的一大块,DRY 也会导致复杂。这就是为什么你应该避免过早地干燥你的代码。拥有一些重复的代码段总比错误的抽象要好。
亲吻(保持简单,笨蛋)
大多数系统如果保持简单,而不是变得复杂,就会工作得最好。
KISS 原则指出,如果系统保持简单而不是变得复杂,大多数系统会工作得最好。简单应该是设计中的一个关键目标,应该避免不必要的复杂。
关注点分离
SoC 是一种设计原则,用于将计算机程序分成不同的部分,以便每个部分解决一个单独的问题。关注点是影响计算机程序代码的一组信息。
SoC 的一个很好的例子就是 MVC (模型-视图-控制器)。
如果你决定采用这种方法,注意不要把你的应用分成太多的模块。您应该只在有意义的时候创建新模块。更多的模块意味着更多的问题。
固体
SOLID 是五个设计原则的首字母缩写词,旨在使软件设计更易于理解、灵活和维护。
SOLID 在编写 OOP 代码时极其有用。它谈到了将你的类分成多个子类、继承、抽象、接口等等。
它由以下五个概念组成:
代码格式化程序
代码格式化程序通过自动格式化来加强编码风格,并帮助实现和维护干净的代码。它们中的大多数允许您创建一个可以与同事共享的样式配置文件。
最流行的 Python 代码格式化程序有:
大多数现代 ide 还包括 linters,它在您键入时在后台运行,帮助您识别小的编码错误、错误和危险的代码模式,并保持您的代码格式化。有两种类型的短绒:逻辑和风格。
最受欢迎的蟒蛇皮有:
关于林挺和代码格式化的更多信息,请查看 Python 代码质量。
命名规格
编写干净代码的一个最重要的方面是命名约定。你应该总是使用有意义的和揭示意图的名字。使用长的、描述性的名字总比带注释的短名字好。
`# This is bad
# represents the number of active users
au = 55
# This is good
active_user_amount = 55`
在接下来的两节中,我们将会看到更多的例子。
变量
1.使用名词作为变量名
2.使用描述性/揭示意图的名称
其他开发人员应该能够通过读取变量的名称来计算出变量存储的内容。
`# This is bad
c = 5
d = 12
# This is good
city_counter = 5
elapsed_time_in_days = 12`
3.使用易发音的名字
你应该总是使用容易发音的名字;否则,你将很难大声解释你的算法。
`from datetime import datetime
# This is bad
genyyyymmddhhmmss = datetime.strptime('04/27/95 07:14:22', '%m/%d/%y %H:%M:%S')
# This is good
generation_datetime = datetime.strptime('04/27/95 07:14:22', '%m/%d/%y %H:%M:%S')`
4.避免使用模糊的缩写
不要试图想出自己的缩写。变量最好有一个更长的名字,而不是一个容易混淆的名字。
`# This is bad
fna = 'Bob'
cre_tmstp = 1621535852
# This is good
first_name = 'Bob'
creation_timestamp = 1621535852`
5.总是使用相同的词汇
命名变量时避免使用同义词。
`# This is bad
client_first_name = 'Bob'
customer_last_name = 'Smith'
# This is good
client_first_name = 'Bob'
client_last_name = 'Smith'`
6.不要使用“神奇的数字”
幻数是代码中出现的奇怪数字,没有明确的含义。让我们来看一个例子:
`import random
# This is bad
def roll():
return random.randint(0, 36) # what is 36 supposed to represent?
# This is good
ROULETTE_POCKET_COUNT = 36
def roll():
return random.randint(0, ROULETTE_POCKET_COUNT)`
我们可以不使用幻数,而是将它们提取到一个有意义的变量中。
7.使用解决方案域名
如果你在你的算法或者类中使用了很多不同的数据类型,并且你不能从变量名本身中找出它们,不要害怕给你的变量名加上数据类型后缀。例如:
`# This is good
score_list = [12, 33, 14, 24]
word_dict = {
'a': 'apple',
'b': 'banana',
'c': 'cherry',
}`
这里有一个不好的例子(因为你不能从变量名中判断出数据类型):
`# This is bad
names = ["Nick", "Mike", "John"]`
8.不要添加多余的上下文
不要在变量名中添加不必要的数据,尤其是在处理类的时候。
`# This is bad
class Person:
def __init__(self, person_first_name, person_last_name, person_age):
self.person_first_name = person_first_name
self.person_last_name = person_last_name
self.person_age = person_age
# This is good
class Person:
def __init__(self, first_name, last_name, age):
self.first_name = first_name
self.last_name = last_name
self.age = age`
我们已经在Person类中,所以没有必要给每个类变量添加前缀person_。
功能
1.函数名使用动词
2.不要用不同的词来表达同一个概念
为每个概念选择一个词,并坚持下去。对同一个概念使用不同的词会引起混淆。
`# This is bad
def get_name(): pass
def fetch_age(): pass
# This is good
def get_name(): pass
def get_age(): pass`
3.编写简短的函数
4.函数应该只执行一项任务
如果你的函数包含关键字“and ”,你可以把它分成两个函数。让我们看一个例子:
`# This is bad
def fetch_and_display_personnel():
data = # ...
for person in data:
print(person)
# This is good
def fetch_personnel():
return # ...
def display_personnel(data):
for person in data:
print(person)`
函数应该做一件事,作为读者,它们做你期望它们做的事。
一个好的经验法则是,任何给定的函数都不应该花超过几分钟的时间来理解。回顾一下你几个月前写的一些旧代码。你可能应该重构任何需要超过五分钟才能理解的函数。这毕竟是你的代码。想想另一个开发者要多久才能理解。
5.将你的论点保持在最低限度
函数中的参数应该保持最小。理想情况下,您的函数应该只有一到两个参数。如果您需要为函数提供更多的参数,您可以创建一个 config 对象,将它传递给函数或将它分成多个函数。
示例:
`# This is bad
def render_blog_post(title, author, created_timestamp, updated_timestamp, content):
# ...
render_blog_post("Clean code", "Nik Tomazic", 1622148362, 1622148362, "...")
# This is good
class BlogPost:
def __init__(self, title, author, created_timestamp, updated_timestamp, content):
self.title = title
self.author = author
self.created_timestamp = created_timestamp
self.updated_timestamp = updated_timestamp
self.content = content
blog_post1 = BlogPost("Clean code", "Nik Tomazic", 1622148362, 1622148362, "...")
def render_blog_post(blog_post):
# ...
render_blog_post(blog_post1)`
6.不要在函数中使用标志
标志是传递给函数的变量(通常是布尔值),函数用它来决定自己的行为。它们被认为是糟糕的设计,因为函数应该只执行一个任务。避免标记的最简单的方法是把你的函数分成更小的函数。
`text = "This is a cool blog post."
# This is bad
def transform(text, uppercase):
if uppercase:
return text.upper()
else:
return text.lower()
uppercase_text = transform(text, True)
lowercase_text = transform(text, False)
# This is good
def uppercase(text):
return text.upper()
def lowercase(text):
return text.lower()
uppercase_text = uppercase(text)
lowercase_text = lowercase(text)`
7.避免副作用
如果一个函数除了接受一个值并返回另一个或多个值之外,还做其他任何事情,它就会产生副作用。例如,副作用可能是写入文件或修改全局变量。
无论我们如何努力去写干净的代码,你的程序中仍然会有一些部分需要额外的解释。注释允许我们快速地告诉其他开发人员(以及我们未来的自己)为什么我们以这样的方式编写它。请记住,添加太多的注释会使你的代码比没有注释时更混乱。
代码注释和文档有什么区别?
| 类型 | 答案 | 利益相关者 |
|---|---|---|
| 证明文件 | 何时和如何 | 用户 |
| 代码注释 | 为什么 | 开发商 |
| 干净的代码 | 什么 | 开发商 |
要了解代码注释和文档之间的更多区别,请查看记录 Python 代码和项目一文。
注释糟糕的代码——也就是# TODO: RE-WRITE THIS TO BE BETTER——只能在短期内帮助你。迟早你的一个同事将不得不处理你的代码,在花了几个小时试图弄清楚它做了什么之后,他们最终会重写它。
如果你的代码足够易读,你就不需要注释。添加无用的注释只会降低代码的可读性。这里有一个不好的例子:
`# This checks if the user with the given ID doesn't exist.
if not User.objects.filter(id=user_id).exists():
return Response({
'detail': 'The user with this ID does not exist.',
})`
一般来说,如果你需要添加评论,它们应该解释你为什么做某事,而不是正在发生什么。
不要添加对代码没有任何价值的注释。这很糟糕:
`numbers = [1, 2, 3, 4, 5]
# This variable stores the average of list of numbers.
average = sum(numbers) / len(numbers)
print(average)`
这也不好:

大多数编程语言都有不同的注释类型。了解它们的区别,并相应地使用它们。您还应该学习注释文档语法。一个很好的例子:
`def model_to_dict(instance, fields=None, exclude=None):
"""
Returns a dict containing the data in ``instance`` suitable for passing as
a Form's ``initial`` keyword argument.
``fields`` is an optional list of field names. If provided, return only the
named.
``exclude`` is an optional list of field names. If provided, exclude the
named from the returned dict, even if they are listed in the ``fields``
argument.
"""
opts = instance._meta
data = {}
for f in chain(opts.concrete_fields, opts.private_fields, opts.many_to_many):
if not getattr(f, 'editable', False):
continue
if fields is not None and f.name not in fields:
continue
if exclude and f.name in exclude:
continue
data[f.name] = f.value_from_object(instance)
return data`
你能做的最糟糕的事情就是在你的程序中把代码注释掉。在进入版本控制系统之前,所有的调试代码或调试消息都应该被删除,否则,你的同事会害怕删除它,而你的注释代码会永远留在那里。
装饰器、上下文管理器、迭代器和生成器
在这一节中,我们将了解一些 Python 概念和技巧,我们可以用它们来编写更好的代码。
装修工
装饰器是 Python 中一个非常强大的工具,它允许我们为函数添加一些自定义功能。本质上,它们只是被称为内部函数的函数。通过使用它们,我们利用了 SoC(关注点分离)原则,并使我们的代码更加模块化。学习它们,你将踏上通往 Pythonic 代码的道路!
假设我们有一台受密码保护的服务器。我们可以在每个服务器方法中询问密码,或者创建一个装饰器来保护我们的服务器方法,如下所示:
`def ask_for_passcode(func):
def inner():
print('What is the passcode?')
passcode = input()
if passcode != '1234':
print('Wrong passcode.')
else:
print('Access granted.')
func()
return inner
@ask_for_passcode
def start():
print("Server has been started.")
@ask_for_passcode
def end():
print("Server has been stopped.")
start() # decorator will ask for password
end() # decorator will ask for password`
我们的服务器现在会在每次调用start()或end()时询问密码。
上下文管理器
上下文管理器简化了我们与外部资源(如文件和数据库)的交互方式。最常见的用法是with语句。它们的好处是可以自动释放块外的内存。
让我们看一个例子:
`with open('wisdom.txt', 'w') as opened_file:
opened_file.write('Python is cool.')
# opened_file has been closed.`
如果没有上下文管理器,我们的代码将如下所示:
`file = open('wisdom.txt', 'w')
try:
file.write('Python is cool.')
finally:
file.close()`
迭代程序
迭代器是包含可数个值的对象。迭代器允许对象被迭代,这意味着您可以遍历所有的值。
假设我们有一个名字列表,我们想遍历它。我们可以使用next(names)循环遍历它:
`names = ["Mike", "John", "Steve"]
names_iterator = iter(names)
for i in range(len(names)):
print(next(names_iterator))`
或者使用增强循环:
`names = ["Mike", "John", "Steve"]
for name in names:
print(name)`
在增强循环中,避免使用像
item或value这样的变量名,因为这样会很难判断一个变量存储了什么,尤其是在嵌套的增强循环中。
发电机
生成器是 Python 中的一个函数,它返回一个迭代器对象,而不是一个单一的值。普通函数和生成器的主要区别在于,生成器使用yield关键字,而不是return。迭代器中的每个下一个值都是使用next(generator)获取的。
假设我们想要生成第一个x的倍数n。我们的生成器看起来会像这样:
`def multiple_generator(x, n):
for i in range(1, n + 1):
yield x * i
multiples_of_5 = multiple_generator(5, 3)
print(next(multiples_of_5)) # 5
print(next(multiples_of_5)) # 10
print(next(multiples_of_5)) # 15`
模块化和类
为了让你的代码尽可能的有条理,你应该把它分成多个文件,然后这些文件被分成不同的目录。如果你用面向对象的语言编写代码,你也应该遵循基本的面向对象的原则,比如封装、抽象、继承和多态。
将代码分成多个类将使你的代码更容易理解和维护。一个文件或一个类应该有多长并没有固定的规则,但是尽可能保持它们很小(最好在 200 行以下)。
Django 的默认项目结构是一个很好的例子,说明了你的代码应该如何构建:
`awesomeproject/
├── main/
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── blog/
│ ├── migrations/
│ │ └── __init__.py
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
└── templates`
Django 是一个 MTV(模型-模板-视图)框架,类似于我们之前讨论的 MVC 框架。这种模式将程序逻辑分成三个相互联系的部分。你可以看到每个应用程序都在一个单独的目录中,每个文件都服务于一个特定的事物。如果你的项目被分成多个应用程序,你应该确保这些应用程序不会过于依赖彼此。
测试
没有测试就没有高质量的软件。测试软件可以让我们在软件部署之前发现软件中的错误。测试和生产代码一样重要,你应该花相当多的时间在它们上面。
有关测试干净代码和编写干净测试代码的更多信息,请查看以下文章:
结论
编写干净的代码很难。没有一个简单的方法可以让你写出好的、干净的代码。需要时间和经验去掌握。我们已经了解了一些编码标准和通用指南,它们可以帮助您编写更好的代码。我能给你的最好的建议之一是保持一致性,尽量写易于测试的简单代码。如果你发现你的代码很难测试,它可能很难使用。
如果您想了解更多,请查看完整的 Python 开发指南,在那里您将从实践中学习如何编写干净的代码。
组合烧瓶和 Vue
如何将 Vue.js 与 Flask 结合起来?
因此,您终于有了 Flask,并且对 JavaScript 并不陌生。你甚至开发了一些 web 应用程序,但是你开始意识到一些事情——你有很好的功能,但是你的 UX 有点乏味。你今天在很多热门网站和应用上看到的应用流程和无缝导航在哪里?如何实现这一目标?
随着你对网站和网络应用的投入越来越多,你可能会想给它们添加更多的客户端功能和反应。现代 web 开发通常通过使用前端框架来实现这一点,其中一个流行度迅速上升的框架是 Vue (也称为 Vue.js 或 VueJS)。
根据您的项目需求,有几种不同的方法来使用 Flask 和 Vue 构建 web 应用程序,并且每种方法都涉及不同级别的后端/前端分离。
在本文中,我们将看看结合 Flask 和 Vue 的三种不同方法:
- Jinja 模板:将 Vue 导入 Jinja 模板
- 单页应用:构建一个单页应用(SPA)来完全分离 Flask 和 Vue
- 烧瓶蓝图:从烧瓶蓝图中提供 Vue,使两者部分分离

我们将分析每种方法的优缺点,查看它们的最佳用例,并详细说明如何设置它们。
Jinja 模板
无论您使用 React、Vue 还是 Angular,这都是过渡到使用前端框架的最简单方式。
在很多情况下,当你为你的 web 应用构建一个前端时,你是围绕着前端框架本身来设计的。然而,使用这种方法,重点仍然是您的后端 Flask 应用程序。如果需要的话,你仍然可以使用 Jinja 和服务器端模板,以及 Vue 的一些反应功能。
你可以通过内容交付网络 (CDN)或者通过你的应用程序自带的方式导入 Vue 库,同时像平常一样设置和路由 Flask。
赞成的意见
- 你可以按照自己的方式构建应用,而不是在 Vue 的基础上进行调整。
- 搜索引擎优化(SEO)不需要任何额外的配置。
- 您可以利用基于 cookie 的身份验证来代替基于令牌的身份验证。这往往更容易,因为您不需要处理前端和后端之间的异步通信。
骗局
- 你必须导入 Vue 并单独设置每个页面,如果你开始在越来越多的页面上添加 Vue,这可能会很困难。这可能还需要一些变通方法,因为这并不是真正想要使用 Flask 或 Vue 的方式。
最适合
- 小型 web 应用程序实际上使用一两个 HTML 页面(与拥有自己的动态路由的 SPA 相反——参见 SPA 方法了解更多信息)。
- 将功能构建到现有的 web 应用程序中。
- 在不完全致力于前端框架的情况下,为应用程序增加一些反应性。
- 不需要通过 AJAX 频繁与后端通信的 Web 应用程序。
其他依赖项
这种方法只需要 Vue 库,可以通过 CDN 添加:
`<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>`
设置
在所有方法中,这种设置是最简单的。
创建一个文件夹来存放您的应用程序的所有代码。在该文件夹中,像平常一样创建一个 app.py 文件:
`from flask import Flask, render_template # These are all we need for our purposes
app = Flask(__name__)
@app.route("/")
def index():
return render_template("index.html", **{"greeting": "Hello from Flask!"})`
我们只需要从flask导入Flask和render_template。
当我们看如何在同一个文件中同时使用 Jinja 和 Vue 渲染变量时,greeting变量将会再次出现。
接下来,创建一个“模板”文件夹来保存我们的 HTML 文件。在这个文件夹中,创建一个index.html文件。在我们的 HTML 文件主体中,创建一个 id 为vm的容器 div。
值得注意的是,
vm只是一个常见的命名标准。它代表视图模型。你想叫它什么都可以;不需要是vm。
在div中,创建两个p标记作为 Flask 和 Vue 变量的占位符:
- 其中一个
div应该包含用大括号括起来的单词“greeting”:{{ greeting }}。 - 另一个应该包含用括号括起来的“问候”:
[[ greeting ]]。
如果不使用单独的分隔符,在默认设置下,Flask 会用您传递的任何变量替换这两个问候语(例如,“来自 Flask 的 Hello!”).
以下是我们目前掌握的情况:
`<body>
<!-- The id 'vm' is just for consistency - it can be anything you want -->
<div id="vm">
<p>{{ greeting }}</p>
<p>[[ greeting ]]</p>
</div>
</body>`
在 body 标签结束之前,从官方 CDN 导入 Vue,同时导入一个脚本来保存我们的 JavaScript 代码:
`<body>
<!-- The id 'vm' is just for consistency - it can be anything you want -->
<div id="vm">
<p>{{ greeting }}</p>
<p>[[ greeting ]]</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="{{ url_for('static', filename='index.js') }}"></script>
</body>`
向上导航一个目录,创建一个“静态”文件夹。在名为 index.js 的文件夹中添加一个新的 JavaScript 文件。
在这个文件中,创建 Vue 上下文,将我们实例的el设置为'#vm',将默认分隔符从'{{', '}}'更改为'[[', ']]':
`const vm = new Vue({ // Again, vm is our Vue instance's name for consistency. el: '#vm', delimiters: ['[[', ']]'] })`
实际上,我们可以使用任何想要的东西作为分隔符。事实上,如果你喜欢,你可以在 Flask 中改变你的 Jinja 模板的分隔符。
最后,添加一个键/值为greeting: 'Hello, Vue!'的数据元素:
`const vm = new Vue({ // Again, vm is our Vue instance's name for consistency. el: '#vm', delimiters: ['[[', ']]'], data: { greeting: 'Hello, Vue!' } })`
现在我们完成了那个文件。最终的文件夹结构应该如下所示:
`├───app.py
├───static
│ └───index.js
└───templates
└───index.html`
现在你可以回到你的根项目文件夹,用flask run运行应用程序。在浏览器中导航到该站点。第一行和第二行应分别替换为 Flask 和 Vue:
`Hello from Flask!
Hello, Vue!`
就是这样!您可以随意混合搭配 JSON 端点和 HTML 端点,但是要注意这可能会很快变得很难看。对于更易于管理的替代方法,参见烧瓶蓝图方法。
对于每个额外的 HTML 页面,您必须要么导入相同的 JavaScript 文件并考虑可能不适用于它的变量和元素,要么为每个页面创建一个新的 Vue 对象。一个真正的 SPA 将是困难的,但不是不可能的——理论上你可以写一个小的 JavaScript 库,异步地获取 Flask 提供的 HTML 页面/元素。
实际上,我之前已经为此创建了自己的 JavaScript 库。这是一个大麻烦,老实说不值得,特别是考虑到 JavaScript 不会运行以这种方式导入的脚本标记,除非您自己构建功能。你也将重新发明轮子。
如果你想看看我对这个方法的实现,你可以在 GitHub 上找到它。该库获取一个给定的 HTML 块,并用它替换页面上指定的 HTML。如果给定的 HTML 不包含
<script>元素(它使用 regex 进行检查),它简单地使用HTMLElement.innerHTML来替换它。如果它包含<script>元素,它递归地添加节点,重新创建出现的任何<script>节点,允许您的 JavaScript 运行。将类似这样的东西与 History API 结合使用,可以帮助您构建一个文件非常小的小型 SPA。您甚至可以创建自己的服务器端呈现(SSR)功能,在页面加载时提供完整的 HTML 页面,然后通过 AJAX 请求提供部分页面。您可以在使用 Nuxt 方法的 SPA 中了解有关 SSR 的更多信息。
单页应用程序
如果你想构建一个具有无缝用户体验(UX)的完全动态的 web 应用,你可以将 Flask 后端与 Vue 前端完全分离。如果你不熟悉现代前端框架,这可能需要学习一种全新的 web 应用程序设计思维方式。
把你的应用开发成一个 SPA 可能会降低你的搜索引擎优化。在过去,这种打击会更加引人注目,但 Googlebot 如何索引网站的更新至少在某种程度上否定了这一点。然而,它可能仍然会对不显示 JavaScript 的非谷歌搜索引擎或那些过早抓取页面的搜索引擎产生更大的影响——如果你的网站得到了很好的优化,后一种情况应该不会发生。
关于现代水疗中 SEO 的更多信息,这篇文章展示了 Googlebot 如何索引 JavaScript 渲染的网站。此外,这篇文章深入讨论了同样的事情,以及关于其他搜索引擎的 SEO 的其他有用提示。
通过这种方法,您将希望使用 Vue CLI 工具生成一个完全独立的 Vue 应用程序。然后,Flask 将用于提供一个 JSON RESTful API,您的 Vue SPA 将通过 AJAX 与之通信。
赞成的意见
- 您的前端和后端将完全相互独立,因此您可以对其中一个进行更改,而不会影响另一个。
- 这使得它们可以单独部署、开发和维护。
- 如果您愿意,您甚至可以设置许多其他前端来与您的 Flask API 进行交互。
- 你的前端体验会流畅很多,更无缝。
骗局
- 还有更多的东西需要设置和学习。
- 部署困难。
- 如果没有进一步的干预,SEO 可能会受到影响(更多细节请参见带 Nuxt 方法的 SPA)。
- 身份验证要复杂得多,因为您必须不断地将您的身份验证令牌( JWT 或帕斯托)传递到您的后端。
最适合
- UX 比搜索引擎优化更重要的应用。
- 需要被多个前端访问的后端。
其他依赖项
- 节点/国家预防机制
- CLI 视图
- 弗拉斯克-CORS
部署和容器化超出了本文的范围,但是将这种设置进行 Dockerize 以简化部署并不十分困难。
设置
因为我们完全将 Vue 从 Flask 中分离出来,所以这个方法需要更多的设置。我们需要在 Flask 中启用跨源资源共享(CORS ),因为我们的前端和后端将在不同的端口上提供服务。为了快速简单地完成这个,我们将使用 Flask-CORS Python 包。
出于安全原因,现代 web 浏览器不允许客户端 JavaScript 访问来自不同于脚本来源的资源(如 JSON 数据),除非它们包含特定的响应头,让浏览器知道这是可以的。
如果你还没有安装弗拉斯克-CORS,用皮普安装。
让我们从我们的 Flask API 开始。
首先,创建一个文件夹来保存项目代码。在里面,创建一个名为“api”的文件夹。在文件夹中创建一个 app.py 文件。用你喜欢的文本编辑器打开文件。这次我们需要从flask导入Flask,从flask_cors导入CORS。因为我们使用flask_cors来实现跨源资源共享,所以用CORS : CORS(app)包装 app 对象(不设置新变量)。这就是我们所要做的,使 CORS 在我们所有的路线上的任何来源。
尽管这对于演示来说很好,但您可能不希望任何应用程序或网站都能访问您的 API。在这种情况下,您可以使用 kwarg‘origins’和 CORS 函数来添加一个可接受的原点列表,即
CORS(app, origins=["origin1", "origin2"])关于跨源资源共享的更多信息,MDN 上有一些很棒的文档。
最后,在/greeting创建一个问候路由,返回一个带有单个键/值的 JSON 对象:
`{"greeting": "Hello from Flask!"}`
以下是您应该得到的结果:
`from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
@app.route("/greeting")
def greeting():
return {"greeting": "Hello from Flask!"}`
这就是我们需要用 Python 做的一切。
接下来,我们将设置我们的 Vue webapp。从终端打开项目的根文件夹。使用 Vue CLI 创建一个名为“web app”(vue create webapp)的 Vue 项目。您几乎可以使用任何您喜欢的选项,但是如果您在 TypeScript 中使用基于类的组件,语法看起来会有点不同。
当你的项目创建完成后,打开 App.vue 。
因为我们的目标只是看看 Vue 和 Flask 如何相互交互,所以在页面顶部,删除 id 为app的div中的所有元素。你应该只剩下:
`<template>
<div id="app">
</div>
</template>`
在#app中,创建两个p元素:
- 第一条的内容应该是
{{ greeting }}。 - 秒的内容应该是
{{ flaskGreeting }}。
你最终的 HTML 应该是这样的:
`<template>
<div id="app">
<p>{{ greeting }}</p>
<p>{{ flaskGreeting }}</p>
</div>
</template>`
在我们的script中,让我们添加逻辑来显示一个纯粹的客户端问候(greeting)和一个来自我们的 API 的问候(flaskGreeting)。
在 Vue 对象中(以export default开始),创建一个data键。让它成为一个返回对象的函数。然后,在这个对象中,再创建两个键:greeting和flaskGreeting。greeting的值应该是'Hello, Vue!',而flaskGreeting的值应该是空字符串。
以下是我们目前掌握的情况:
`export default { name: 'App', components: { HelloWorld }, data: function(){ return { greeting: 'Hello, Vue!', flaskGreeting: '' } } }`
最后,让我们给我们的 Vue 对象一个created生命周期钩子。只有当 DOM 被加载并且我们的 Vue 对象被创建时,这个钩子才会运行。这允许我们使用fetch API 并与 Vue 交互,而没有任何冲突:
`export default { components: { Logo }, data: function(){ return { greeting: 'Hello, Vue!', flaskGreeting: '' } }, created: async function(){ const gResponse = await fetch("http://localhost:5000/greeting"); const gObject = await gResponse.json(); this.flaskGreeting = gObject.greeting; } }`
查看代码,我们正在对 API 的“问候”端点(http://localhost:5000/greeting)进行响应,对该响应的异步.json()响应进行await,并将 Vue 对象的flaskGreeting变量设置为返回的 JSON 对象的greeting键的值。
对于那些不熟悉 JavaScript 相对较新的 Fetch API 的人来说,它基本上是一个原生的 AXIOS 杀手(至少就客户端而言 Node 不支持它,但 Deno 会支持它)。此外,如果您喜欢一致性,您也可以查看同构获取包,以便在服务器端使用获取。
我们结束了。因为我们的前端和后端是分开的,所以我们需要分别运行两个应用程序。
让我们在两个独立的终端窗口中打开项目的根文件夹。
首先,进入“api”目录,然后运行flask run。如果一切顺利,Flask API 应该正在运行。在第二个终端中,切换到“webapp”目录并运行npm run serve。
一旦 Vue 应用启动,您应该能够从localhost:8080访问它。如果一切正常,您应该会收到两次问候——一次是 Vue,另一次是 Flask API:
`Hello, Vue!
Hello from Flask!`
最终的文件树应该是这样的:
`├───app.py
├───api
│ └───app.py
└───webapp
... {{ Vue project }}`
使用 Nuxt 的单页应用程序
如果 SEO 对您来说像 UX 一样重要,那么您可能希望以某种格式实现服务器端呈现(SSR)。
SSR 使搜索引擎更容易导航和索引你的 Vue 应用,因为你将能够给他们一种不需要 JavaScript 生成的应用形式。它还可以让用户更快地与你的应用程序互动,因为你的大部分初始内容会在发送给他们之前呈现出来。换句话说,用户不必等待所有的内容都被异步加载。
具有服务器端渲染的单页应用程序也称为通用应用程序。
尽管可以手动实现 SSR,但在本文中我们将使用 Nuxt ;它大大简化了事情。
就像使用 SPA 方法一样,你的前端和后端将完全分离;你将只是使用 Nuxt 而不是 Vue CLI。
赞成的意见
- 除了服务器端渲染之外, SPA 方法的所有优点。
骗局
- 与 SPA 方法一样难以设置。
- 从概念上讲,还有更多东西需要学习,因为 Nuxt 本质上只是 Vue 之上的另一层。
最适合
- SEO 和 UX 一样重要的应用。
其他依赖项
- 节点/国家预防机制
- Nuxt
- 弗拉斯克-CORS
设置
这将非常类似于 SPA 方法。事实上,烧瓶部分是完全相同的。继续这样做,直到创建了 Flask API。
一旦您的 API 完成,在您的终端中,打开您的项目的根文件夹并运行命令npx create-nuxt-app webapp。这将让您以交互方式生成一个新的 Nuxt 项目,而无需安装任何全局依赖项。
这里任何选项都可以。
一旦你的项目生成完成,进入你的新“webapp”文件夹。在“pages”文件夹中,在文本编辑器中打开 index.vue 。类似地,删除div中包含类container的所有内容。在div内部,创建两个变量相同的p标签:{{ greeting }}和{{ flaskGreeting }}。
它应该是这样的:
`<template>
<div class="container">
<p>{{ greeting }}</p>
<p>{{ flaskGreeting }}</p>
</div>
</template>`
现在是我们的剧本:
- 添加一个
data键,返回一个带有变量greeting和flaskGreeting的对象 - 添加一个
created生命周期挂钩:awaitfetch从我们的 API 获取 JSON 问候(在端口 5000 上,除非您更改了它)await``json()方法从 API 的响应中异步获取 JSON 数据- 将 Vue 实例的
flaskGreeting设置为响应的 JSON 对象中的greeting键
Vue 对象应该类似于:
`export default { components: { Logo }, data: function(){ return { greeting: 'Hello, Vue!', flaskGreeting: '' } }, created: async function(){ const gResponse = await fetch("http://localhost:5000/greeting"); const gObject = await gResponse.json(); this.flaskGreeting = gObject.greeting; } }`
运行 Nuxt/Vue 应用程序和 Flask API 看起来也非常类似于 SPA 方法。
打开两个终端窗口。在第一个内,切换到“api”并运行flask run命令。在第二秒钟内,切换到“webapp”并运行npm run dev,为您的 Nuxt 项目启动一个开发服务器。
Nuxt 应用程序启动后,您应该能够从localhost:3000访问它:
`Hello, Vue!
Hello from Flask!`
在生产环境中,您可以运行
npm run build然后运行npm run start来启动生产服务器。
我们最后的圣诞树:
`├───app.py
├───api
│ └───app.py
└───webapp
... {{ Nuxt project }}`
奖金:Vue 与 Nuxt 搜索引擎优化比较
我在本文前面提到了 SEO 的好处,但是为了向您展示我的意思,我按原样运行了两个 web 应用程序,并获得了两个应用程序的 SEO 分数。
两个应用程序都没有改变,下面是我们得到的结果:

同样,你可以做一些事情来提高你的纯 Vue SEO 分数。Chrome 的开发工具中的 Lighthouse 提到了添加一个元描述,但在没有额外干预的情况下,Nuxt 给了我们一个完美的 SEO 分数。
此外,您实际上可以看到 Nuxt 的 SSR 和 vanilla Vue 的完全异步方法之间的区别。如果你同时运行这两个应用程序,导航到它们各自的原点,localhost:8080和localhost:3000,Vue 应用程序的初始问候会在你得到响应后几毫秒发生,而 Nuxt 的初始问候已经呈现。
有关 Nuxt 和 Vue 之间的差异的更多信息,您可以查看以下文章:
烧瓶蓝图
也许你已经开发了一个小 Flask 应用程序,你想开发一个 Vue 应用程序,更多的是作为一种手段,而不是作为主要事件。
示例:
- 向你的雇主或客户演示功能的原型(你可以随时替换它,或者以后把它交给前端开发人员)。
- 您只是不想处理部署完全分离的前端和后端时可能导致的潜在挫折。
在这种情况下,你可以通过保留你的 Flask 应用程序,但在它自己的 Flask 蓝图内构建 Vue 前端来折中。
这看起来很像 Jinja 模板方法,但是代码会更有条理。
赞成的意见
- 如果没有必要,就不需要构建复杂的前端。
- 类似于 Jinja 模板方法,增加了更好的代码组织的好处。
- 以后,您可以随时根据需要扩展前端和后端。
骗局
- 可能需要变通办法来实现完整的 SPA。
- 从一个单独的前端(比如一个移动应用)访问 API 可能会稍微麻烦一些,因为前端和后端并不是完全分开的。
最适合
- 功能比 UI 更重要的项目。
- 你正在一个已经存在的 Flask 应用上构建一个前端。
- 仅由几个 HTML 页面组成的小型 web 应用程序。
其他依赖项
类似于 Jinja 模板方法,我们将使用 CDN 来拉入 Vue 库:
`<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>`
设置
像其他方法一样,创建一个新文件夹来存放您的代码。在其中,创建两个文件夹:“api”和“client”。直观地说,这些将分别包含我们的 API 和客户端(Vue)的蓝图。
让我们深入“api”文件夹。
创建一个名为 api.py 的文件。这将包含与我们的 API 相关的所有代码。此外,因为我们将把这个文件/文件夹作为一个模块来访问,所以创建一个 init。py 文件:
`from flask import Blueprint
api_bp = Blueprint('api_bp', __name__) # "API Blueprint"
@api_bp.route("/greeting") # Blueprints don't use the Flask "app" context. They use their own blueprint's
def greeting():
return {'greeting': 'Hello from Flask!'}`
Blueprint的第一个参数是 Flask 的路由系统。第二个,__name__,相当于 Flask app 的第一个参数(Flask(__name__))。
这就是我们的 API 蓝图。
好吧。让我们深入到之前创建的“client”文件夹。这个比我们的 API 蓝图稍微复杂一点,但是不会比普通的 Flask 应用程序复杂。
同样,像一个普通的 Flask 应用程序一样,在这个文件夹中,创建一个“静态”文件夹和一个“模板”文件夹。创建一个名为 client.py 的文件,并在文本编辑器中打开它。
这一次,我们将向我们的Blueprint传递更多的参数,这样它就知道在哪里可以找到正确的静态文件和模板:
`client_bp = Blueprint('client_bp', __name__, # 'Client Blueprint'
template_folder='templates', # Required for our purposes
static_folder='static', # Again, this is required
static_url_path='/client/static' # Flask will be confused if you don't do this
)`
添加路线并提供index.html模板:
`from flask import Blueprint, render_template
client_bp = Blueprint("client_bp", __name__, # 'Client Blueprint'
template_folder="templates", # Required for our purposes
static_folder="static", # Again, this is required
static_url_path="/client/static" # Flask will be confused if you don't do this
)
@client_bp.route("/")
def index():
return render_template("index.html")`
非常好。我们的客户端蓝图现在已经完成。退出文件,转到蓝图的“模板”文件夹。创建一个index.html文件:
`<body>
<!-- The id 'vm' is just for consistency - it can be anything you want -->
<div id="vm" class="container">
<p>[[ greeting ]]</p>
<p>[[ flaskGreeting ]]</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/[[email protected]](/cdn-cgi/l/email-protection)"></script>
<script src="{{ url_for('client_bp.static', filename='index.js') }}"></script>
</body>`
你有没有注意到我们用括号代替了大括号?这是因为我们需要更改分隔符,以防止 Flask 先捕获它们。
一旦准备好,greeting将由 Vue 呈现,而flaskGreeting将从我们异步请求的 Flask 响应中获取。
完成了。向“静态”文件夹添加一个名为 index.js 的新文件。创建一个名为apiEndpoint的变量,并将其设置为api_v1。如果我们以后决定改变我们的终点,这只会使一切变得更加枯燥:
`const apiEndpoint = '/api_v1/';`
我们还没有为我们的端点创建逻辑。那将在最后一步到来。
接下来,首先让 Vue 上下文看起来与 Jinja 模板方法中的上下文相同:
`const apiEndpoint = '/api_v1/'; const vm = new Vue({ // Again, vm is our Vue instance's name for consistency. el: '#vm', delimiters: ['[[', ']]'], data: { greeting: 'Hello, Vue!' } })`
同样,我们创建了 Vue 上下文,将实例的el设置为'#vm',将默认分隔符从'{{', '}}'更改为'[[', ']]',并添加了一个键/值为greeting的数据元素:'Hello, Vue!'。
因为我们还将从 API 中提取一个问候语,所以创建一个名为flaskGreeting的数据占位符,其值为一个空字符串:
`const apiEndpoint = '/api_v1/'; const vm = new Vue({ el: '#vm', delimiters: ['[[', ']]'], data: { greeting: 'Hello, Vue!', flaskGreeting: '' } })`
让我们给我们的 Vue 对象一个异步的created生命周期钩子:
`const apiEndpoint = '/api_v1/'; const vm = new Vue({ el: '#vm', delimiters: ['[[', ']]'], data: { greeting: 'Hello, Vue!', flaskGreeting: '' }, created: async function(){ const gResponse = await fetch(apiEndpoint + 'greeting'); const gObject = await gResponse.json(); this.flaskGreeting = gObject.greeting; } })`
看看代码,我们从 API 的“问候”端点(/api_v1/greeting)得到一个响应,await得到该响应的异步.json()响应,并将 Vue 对象的flaskGreeting变量设置为返回的 JSON 对象的greeting键的值。它基本上是方法 1 和方法 2 的 Vue 对象之间的混搭。
非常好。只剩下一件事要做:让我们通过添加一个 app.py 到项目根来把所有的东西放在一起。在文件中,将flask与蓝图一起导入:
`from flask import Flask
from api.api import api_bp
from client.client import client_bp`
像平常一样创建一个 Flask 应用程序,并使用app.register_blueprint()注册蓝图:
`from flask import Flask
from api.api import api_bp
from client.client import client_bp
app = Flask(__name__)
app.register_blueprint(api_bp, url_prefix='/api_v1')
app.register_blueprint(client_bp)`
最终文件树:
`├───app.py
├───api
│ └───__init__.py
│ └───api.py
└───client
├───__init__.py
├───static
│ └───index.js
└───templates
└───index.html`
就是这样!如果你用flask run运行你的新应用,你应该会收到两次问候——一次是 Vue 本身,另一次是 Flask API 的响应。
摘要
使用 Vue 和 Flask 构建 web 应用程序有许多不同的方式。这完全取决于你手头的情况。
要问的一些问题:
- SEO 有多重要?
- 你的开发团队是什么样的?如果您没有开发运维团队,您是否希望承担额外的复杂性,分别部署前端和后端?
- 你只是快速成型吗?
希望这篇文章能引导你走向正确的方向,让你知道如何组合 Vue 和 Flask 应用程序。
你可以从 GitHub 上的combining-flask-with-vuerepo 中获取最终代码。
用并发、并行和异步加速 Python
原文:https://testdriven.io/blog/concurrency-parallelism-asyncio/
什么是并发和并行,它们如何应用于 Python?
您的应用程序运行缓慢有许多原因。有时这是由于糟糕的算法设计或者数据结构的错误选择。然而,有时这是由于我们无法控制的力量,例如硬件限制或网络的怪癖。这就是并发性和并行性适合的地方。它们允许你的程序一次做多件事,要么同时做,要么尽可能不浪费时间等待繁忙的任务。
无论您是处理外部 web 资源、读取和写入多个文件,还是需要使用不同参数多次使用计算密集型函数,本文都将帮助您最大限度地提高代码的效率和速度。
首先,我们将使用标准库(如线程、多处理和 asyncio)深入研究什么是并发和并行,以及它们如何适应 Python 领域。本文的最后一部分将比较 Python 对async / await的实现和其他语言是如何实现的。
你可以在 GitHub 上的concurrency-parallelism-and-asynciorepo 中找到本文的所有代码示例。
要完成本文中的示例,您应该已经知道如何处理 HTTP 请求。
目标
在本文结束时,您应该能够回答以下问题:
- 什么是并发?
- 什么是线程?
- 当某个东西是非阻塞的时候是什么意思?
- 什么是事件循环?
- 什么是回调?
- 为什么 asyncio 方法总是比线程方法快一点?
- 什么时候应该使用线程,什么时候应该使用 asyncio?
- 什么是并行?
- 并发和并行有什么区别?
- 有没有可能将 asyncio 和多处理结合起来?
- 什么时候应该使用多处理而不是异步或线程?
- 多处理、异步和并发之间有什么区别?
- 如何用 pytest 测试 asyncio?
并发
什么是并发?
并发的一个有效定义是“能够同时执行多项任务”。不过这有点误导,因为任务可能会也可能不会在完全相同的时间执行。相反,一个进程可能会开始,然后一旦它在等待一个特定的指令完成,就切换到一个新的任务,只有当它不再等待时才返回。一旦一个任务完成,它再次切换到一个未完成的任务,直到它们都被执行。任务异步开始,异步执行,然后异步完成。

如果这让你感到困惑,让我们来打个比方:假设你想做一个 BLT 。首先,你要用中低火将培根放入锅中。在熏肉烹饪的时候,你可以拿出你的西红柿和生菜,开始准备(清洗和切)它们。与此同时,你继续检查,偶尔翻转你的培根。
此时,您已经开始了一项任务,然后同时开始并完成了另外两项任务,而此时您仍在等待第一项任务。
最终你把面包放进了烤面包机。在烤面包的时候,你继续检查你的熏肉。当一块块完成后,你把它们拿出来放在盘子里。一旦你的面包烤好了,你就把你选择的三明治涂在上面,然后你可以开始在你的西红柿,生菜上分层,然后,一旦它烤好了,你的培根。只有当所有的东西都做好,准备好,分层后,你才能把最后一片吐司放到你的三明治上,切片(可选),然后吃。
因为它需要你同时执行多项任务,所以制作 BLT 本质上是一个并发的过程,即使你没有立刻将全部注意力放在每一项任务上。对于所有意图和目的,在下一节中,我们将把这种形式的并发称为“并发”我们将在本文后面区分它。
由于这个原因,并发性对于 I/O 密集型进程非常有用——包括等待 web 请求或文件读/写操作的任务。
在 Python 中,有几种不同的方法来实现并发性。我们首先要看的是线程库。
对于本节中的示例,我们将构建一个小的 Python 程序,该程序从 Binary Jazz 的 Genrenator API 中随机抓取一个音乐流派五次,将该流派打印到屏幕上,并将每个流派放入自己的文件中。
要在 Python 中使用线程,您需要的唯一导入是threading,但是对于本例,我还导入了urllib来处理 HTTP 请求、time来确定函数完成需要多长时间,以及json来轻松转换从 Genrenator API 返回的 json 数据。
你可以在这里找到这个例子的代码。
让我们从一个简单的函数开始:
`def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
req = Request("https://binaryjazz.us/wp-json/genrenator/v1/genre/", headers={"User-Agent": "Mozilla/5.0"})
genre = json.load(urlopen(req))
with open(file_name, "w") as new_file:
print(f"Writing '{genre}' to '{file_name}'...")
new_file.write(genre)`
检查上面的代码,我们向 Genrenator API 发出请求,加载它的 JSON 响应(一种随机的音乐类型),打印它,然后写入一个文件。
如果没有“用户代理”标题,您将收到一个 304。
我们真正感兴趣的是下一部分,实际的线程处理发生在这里:
`threads = []
for i in range(5):
thread = threading.Thread(
target=write_genre,
args=[f"./threading/new_file{i}.txt"]
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()`
我们首先从一个列表开始。然后我们继续迭代五次,每次创建一个新线程。接下来,我们启动每个线程,将它添加到我们的“threads”列表中,然后最后一次迭代我们的列表以加入每个线程。
说明:用 Python 创建线程很容易。
要创建新线程,请使用threading.Thread()。您可以将 kwarg (keyword argument) target与您希望在该线程上运行的任何函数的值一起传递给它。但是只传入函数的名称,而不是它的值(就我们的目的而言,意味着write_genre而不是write_genre())。要传递参数,请传入“kwargs”(它接受您的 kwargs 的 dict)或“args”(它接受包含您的 args 的 iterable 在本例中是一个列表)。
然而,创建线程不同于启动线程。要启动线程,请使用{the name of your thread}.start()。启动一个线程意味着“开始它的执行”
最后,当我们用thread.join()连接线程时,我们所做的就是确保线程在继续我们的代码之前已经完成。
线
但是到底什么是线呢?
线程是一种允许计算机将单个进程/程序分解成许多并行执行的轻量级程序的方式。有点令人困惑的是,Python 的标准线程实现将线程限制为一次只能执行一个,这是由于一种叫做全局解释器锁 (GIL)的东西。GIL 是必要的,因为 CPython(Python 的默认实现)的内存管理不是线程安全的。由于这个限制,Python 中的线程是并发的,而不是并行的。为了解决这个问题,Python 有一个独立的multiprocessing模块,它不受 GIL 的限制,可以旋转独立的进程,实现代码的并行执行。使用multiprocessing模块几乎等同于使用threading模块。
我们将很快更深入地了解 Python 中的多处理。
在我们展示非线程代码的潜在速度提升之前,我冒昧地创建了同一程序的非线程版本(同样,可在 GitHub 上获得)。它不是创建一个新线程并加入每一个线程,而是在一个迭代五次的 for 循环中调用write_genre。
为了比较速度基准,我还导入了time库来计时我们脚本的执行:
`Starting...
Writing "binary indoremix" to "./sync/new_file0.txt"...
Writing "slavic aggro polka fusion" to "./sync/new_file1.txt"...
Writing "israeli new wave" to "./sync/new_file2.txt"...
Writing "byzantine motown" to "./sync/new_file3.txt"...
Writing "dutch hate industrialtune" to "./sync/new_file4.txt"...
Time to complete synchronous read/writes: 1.42 seconds`
在运行脚本时,我们看到它花了我的电脑大约 1.49 秒(以及经典音乐流派,如“荷兰仇恨工业调”)。不算太坏。
现在让我们运行使用线程的版本:
`Starting...
Writing "college k-dubstep" to "./threading/new_file2.txt"...
Writing "swiss dirt" to "./threading/new_file0.txt"...
Writing "bop idol alternative" to "./threading/new_file4.txt"...
Writing "ethertrio" to "./threading/new_file1.txt"...
Writing "beach aust shanty français" to "./threading/new_file3.txt"...
Time to complete threading read/writes: 0.77 seconds`
第一件事可能会引起你的注意,那就是函数没有按顺序完成:2 - 0 - 4 - 1 - 3
这是因为线程的异步特性:当一个函数等待时,另一个函数开始,依此类推。因为我们能够在等待其他人完成任务的同时继续执行任务(由于网络或文件 I/O 操作),所以您可能已经注意到我们将时间大约减少了一半:0.77 秒。尽管这在现在看起来并不多,但很容易想象构建一个需要向文件写入更多数据或与更复杂的 web 服务交互的 web 应用程序的真实情况。
那么,如果线程如此伟大,我们为什么不在这里结束文章呢?
因为有更好的方法来同时执行任务。
Asyncio
让我们看一个使用 asyncio 的例子。对于这种方法,我们将使用pip安装 aiohttp 。这将允许我们使用即将介绍的async / await语法发出非阻塞请求并接收响应。它还有一个额外的好处,即转换 JSON 响应的函数不需要导入json库。我们还将安装并导入 aiofiles ,它允许非阻塞文件操作。除了aiohttp和aiofiles,导入asyncio,自带 Python 标准库。
“非阻塞”是指一个程序在等待时允许其他线程继续运行。这与“阻塞”代码相反,后者完全停止程序的执行。正常的同步 I/O 操作受到这种限制。
你可以在这里找到这个例子的代码。
导入就绪后,让我们看看 asyncio 示例中的异步版本的write_genre函数:
`async def write_genre(file_name):
"""
Uses genrenator from binaryjazz.us to write a random genre to the
name of the given file
"""
async with aiohttp.ClientSession() as session:
async with session.get("https://binaryjazz.us/wp-json/genrenator/v1/genre/") as response:
genre = await response.json()
async with aiofiles.open(file_name, "w") as new_file:
print(f'Writing "{genre}" to "{file_name}"...')
await new_file.write(genre)`
对于那些不熟悉其他现代语言中常见的async / await语法的人来说,async声明函数、for循环或with语句必须异步使用。要调用一个异步函数,你必须从另一个异步函数中使用await关键字,或者直接从事件循环中调用create_task(),这个事件循环可以从asyncio.get_event_loop()中获取——例如loop = asyncio.get_event_loop()。
此外:
async with允许等待异步响应和文件操作。async for(此处未使用)迭代一个异步流。
事件循环
事件循环是异步编程固有的构造,允许异步执行任务。当你阅读这篇文章时,我可以有把握地假设你可能不太熟悉这个概念。然而,即使您从未编写过异步应用程序,您也会在每次使用计算机时体验到事件循环。无论您的计算机是在监听键盘输入,还是在玩在线多人游戏,或者在后台复制文件时浏览 Reddit,事件循环都是保持一切顺利高效工作的驱动力。从本质上来说,事件循环是一个等待触发器的过程,一旦这些触发器被满足,就执行特定的(编程的)动作。它们通常返回某种“promise”(JavaScript 语法)或“future”(Python 语法)来表示任务已经被添加。一旦任务完成,promise 或 future 返回一个从被调用的函数传回的值(假设函数确实返回值)。
执行一个函数来响应另一个函数的想法被称为“回调”
对于回调和事件的另一个例子,这里有一个关于堆栈溢出的很好的答案。
下面是我们函数的一个演练:
我们使用async with来异步打开我们的客户端会话。aiohttp.ClientSession()类允许我们发出 HTTP 请求,并在不阻止代码执行的情况下保持与源的连接。然后,我们向 Genrenator API 发出一个异步请求,并等待 JSON 响应(一个随机的音乐流派)。在下一行中,我们再次使用async with和aiofiles库来异步打开一个新文件来写入我们的新流派。我们打印流派,然后写入文件。
与常规的 Python 脚本不同,用 asyncio 编程使用某种“main”函数来强制执行*。
*除非你在@asyncio.coroutine decorator 中使用不推荐使用的“yield”语法,将在 Python 3.10 中被移除。
这是因为为了使用“await”语法,您需要使用“async”关键字,而“await”语法是实际运行其他异步函数的唯一方法。
这是我们的主要功能:
`async def main():
tasks = []
for i in range(5):
tasks.append(write_genre(f"./async/new_file{i}.txt"))
await asyncio.gather(*tasks)`
如你所见,我们已经用“async”声明了它然后我们创建一个名为“tasks”的空列表来存放我们的异步任务(调用 Genrenator 和我们的文件 I/O)。我们将任务添加到列表中,但是它们还没有真正运行。在我们用await asyncio.gather(*tasks)安排好之前,这些电话实际上不会打出去。这将运行我们列表中的所有任务,并在继续程序的其余部分之前等待它们完成。最后,我们使用asyncio.run(main())来运行我们的“主”函数。.run()函数是我们程序的入口点,,它应该通常每个进程只调用一次 。
对于那些不熟悉的人来说,任务前面的
*叫做“参数解包”。顾名思义,它将我们的列表分解成函数的一系列参数。在这种情况下,我们的函数是asyncio.gather()。
这就是我们需要做的。现在,运行我们的程序(其源代码包括同步和线程示例的相同计时功能)...
`Writing "albuquerque fiddlehaus" to "./async/new_file1.txt"...
Writing "euroreggaebop" to "./async/new_file2.txt"...
Writing "shoedisco" to "./async/new_file0.txt"...
Writing "russiagaze" to "./async/new_file4.txt"...
Writing "alternative xylophone" to "./async/new_file3.txt"...
Time to complete asyncio read/writes: 0.71 seconds`
...我们看到它甚至更快了。而且,一般来说,asyncio 方法总是比线程方法快一点。这是因为当我们使用“await”语法时,我们实际上是告诉我们的程序“稍等,我马上回来”,但是我们的程序跟踪我们完成我们正在做的事情需要多长时间。一旦我们完成了,我们的程序就会知道,并尽快恢复。Python 中的线程允许异步,但我们的程序理论上可能会跳过可能还没有准备好的不同线程,如果有线程准备好继续运行,就会浪费时间。
那么什么时候应该使用线程,什么时候应该使用 asyncio 呢?
当你写新代码时,使用 asyncio。如果您需要与较旧的库或不支持 asyncio 的库进行交互,那么使用线程可能会更好。
使用 pytest 测试 asyncio
事实证明,用 pytest 测试异步函数和测试同步函数一样简单。只需用pip安装 pytest-asyncio 包,用async关键字标记您的测试,并应用一个装饰器让pytest知道它是异步的:@pytest.mark.asyncio。让我们看一个例子。
首先,让我们在一个名为 hello_asyncio.py 的文件中编写一个任意的异步函数:
`import asyncio
async def say_hello(name: str):
""" Sleeps for two seconds, then prints 'Hello, {{ name }}!' """
try:
if type(name) != str:
raise TypeError("'name' must be a string")
if name == "":
raise ValueError("'name' cannot be empty")
except (TypeError, ValueError):
raise
print("Sleeping...")
await asyncio.sleep(2)
print(f"Hello, {name}!")`
该函数采用单个字符串参数:name。在确保name是长度大于 1 的字符串后,我们的函数异步休眠两秒钟,然后将"Hello, {name}!"打印到控制台。
asyncio.sleep()和time.sleep()的区别在于asyncio.sleep()是非阻塞的。
现在我们用 pytest 来测试一下。在与 hello_asyncio.py 相同的目录中,创建一个名为 test_hello_asyncio.py,的文件,然后在您喜欢的文本编辑器中打开它。
让我们从我们的进口开始:
`import pytest # Note: pytest-asyncio does not require a separate import
from hello_asyncio import say_hello`
然后,我们将创建一个具有适当输入的测试:
`@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
@pytest.mark.asyncio
async def test_say_hello(name):
await say_hello(name)`
需要注意的事项:
- 装饰器让 pytest 异步工作
- 我们的测试使用了
async语法 - 我们正在调用我们的异步函数,就像我们在测试之外运行它一样
现在让我们用详细的-v选项运行我们的测试:
`pytest -v
...
collected 3 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 33%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 66%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [100%]`
看起来不错。接下来,我们将编写几个带有错误输入的测试。回到 test_hello_asyncio.py 的内部,让我们创建一个名为TestSayHelloThrowsExceptions的类:
`class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
@pytest.mark.asyncio
async def test_say_hello_value_error(self, name):
with pytest.raises(ValueError):
await say_hello(name)
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
@pytest.mark.asyncio
async def test_say_hello_type_error(self, name):
with pytest.raises(TypeError):
await say_hello(name)`
同样,我们用@pytest.mark.asyncio修饰我们的测试,用async语法标记我们的测试,然后用await调用我们的函数。
再次运行测试:
`pytest -v
...
collected 7 items
test_hello_asyncio.py::test_say_hello[Robert Paulson] PASSED [ 14%]
test_hello_asyncio.py::test_say_hello[Seven of Nine] PASSED [ 28%]
test_hello_asyncio.py::test_say_hello[x \xc6 a-12] PASSED [ 42%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_value_error[] PASSED [ 57%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[19] PASSED [ 71%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name1] PASSED [ 85%]
test_hello_asyncio.py::TestSayHelloThrowsExceptions::test_say_hello_type_error[name2] PASSED [100%]`
没有 pytest-asyncio
除了 pytest-asyncio,您还可以创建一个 pytest fixture 来产生一个 asyncio 事件循环:
`import asyncio
import pytest
from hello_asyncio import say_hello
@pytest.fixture
def event_loop():
loop = asyncio.get_event_loop()
yield loop`
然后,不使用async / await语法,而是像普通的同步测试一样创建测试:
`@pytest.mark.parametrize("name", [
"Robert Paulson",
"Seven of Nine",
"x Æ a-12"
])
def test_say_hello(event_loop, name):
event_loop.run_until_complete(say_hello(name))
class TestSayHelloThrowsExceptions:
@pytest.mark.parametrize("name", [
"",
])
def test_say_hello_value_error(self, event_loop, name):
with pytest.raises(ValueError):
event_loop.run_until_complete(say_hello(name))
@pytest.mark.parametrize("name", [
19,
{"name", "Diane"},
[]
])
def test_say_hello_type_error(self, event_loop, name):
with pytest.raises(TypeError):
event_loop.run_until_complete(say_hello(name))`
如果你感兴趣,这里有一个关于 asyncio 测试的更高级的教程。
进一步阅读
如果你想了解更多关于 Python 的线程和异步实现的区别,这里有一篇来自 Medium 的好文章。
对于 Python 中线程化的更好的例子和解释,这里有科里·斯查费的视频,它更深入,包括使用concurrent.futures库。
最后,为了深入了解 asyncio 本身,这里有一篇来自 Real Python 的完全致力于此的文章。
额外收获:你可能会感兴趣的另一个库叫做 Unsync ,尤其是如果你想轻松地将你当前的同步代码转换成异步代码。要使用它,用 pip 安装这个库,用from unsync import unsync导入它,然后用@unsync修饰任何当前同步的函数,使它异步。为了等待它并得到它的返回值(你可以在任何地方做——不一定要在异步/非同步函数中),只需在函数调用后调用.result()。
平行
什么是排比?
并行性与并发性密切相关。事实上,并行性是并发性的一个子集:一个并发进程同时执行多个任务,不管它们是否被转移了全部注意力,而一个并行进程实际上是同时执行多个任务。一个很好的例子是一边开车,一边听音乐,同时吃我们在上一节做的三明治。

因为它们不需要太多的努力,你可以一次做完所有的事情,而不需要等待或转移你的注意力。
现在让我们看看如何用 Python 实现这一点。我们可以使用multiprocessing库,但是让我们使用concurrent.futures库——它消除了手动管理进程数量的需要。因为多处理的主要好处发生在您执行多个 cpu 密集型任务时,所以我们将计算 100 万(1000000)到 100 万 16 (1000016)的平方。
你可以在这里找到这个例子的代码。
我们唯一需要的导入是concurrent.futures:
`import concurrent.futures
import time
if __name__ == "__main__":
pow_list = [i for i in range(1000000, 1000016)]
print("Starting...")
start = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
futures = [executor.submit(pow, i, i) for i in pow_list]
for f in concurrent.futures.as_completed(futures):
print("okay")
end = time.time()
print(f"Time to complete: {round(end - start, 2)}")`
因为我是在 Windows 机器上开发,所以用的是
if __name__ == "main"。这是必要的,因为 Windows 没有 Unix 系统固有的fork系统调用。因为 Windows 没有这种能力,它求助于为每个试图导入主模块的进程启动一个新的解释器。如果主模块不存在,它会重新运行整个程序,导致递归混乱。
所以看看我们的主函数,我们使用 list comprehension 创建一个从 100 万到 100 万的列表,我们用 concurrent.futures 打开一个 ProcessPoolExecutor,我们使用 list comprehension 和ProcessPoolExecutor().submit()开始执行我们的进程,并将它们放入一个名为“futures”的列表中
如果我们想用线程代替,我们也可以用
ThreadPoolExecutor()-concurrent。
这就是异步性的来源:“结果”列表实际上并不包含运行我们函数的结果。相反,它包含“未来”,类似于 JavaScript 的“承诺”概念。为了让我们的程序继续运行,我们取回这些代表一个值的占位符的期货。如果我们试图打印未来,取决于它是否运行完毕,我们将返回一个“未决”或“完成”的状态。一旦完成,我们可以使用var.result()获得返回值(假设有一个)。在这种情况下,我们的 var 将是“结果”
然后我们迭代我们的未来列表,但是不是打印我们的值,而是简单地打印出“好的”这仅仅是因为由此产生的计算量非常大。
和以前一样,我构建了一个同步完成这项工作的比较脚本。而且,就像之前一样,你可以在 GitHub 上找到。
运行我们的控制程序,其中也包括为我们的程序计时的功能,我们得到:
`Starting...
okay
...
okay
Time to complete: 54.64`
哇哦。54.64 秒是相当长的一段时间。让我们看看我们的多处理版本是否做得更好:
`Starting...
okay
...
okay
Time to complete: 6.24`
我们的时间已经大大减少了。我们现在是原来时间的九分之一。
那么,如果我们改用线程技术会怎么样呢?
我相信你能猜到——这不会比同步做快多少。事实上,它可能会更慢,因为它仍然需要一点时间和精力来旋转新的线程。但是不要相信我的话,下面是我们用ThreadPoolExecutor()替换ProcessPoolExecutor()时得到的结果:
`Starting...
okay
...
okay
Time to complete: 53.83`
正如我前面提到的,线程化允许您的应用在其他应用等待的时候专注于新的任务。在这种情况下,我们绝不会袖手旁观。另一方面,多处理会产生全新的服务,通常在独立的 CPU 内核上,准备好做你要求它做的任何事情,完全与你的脚本正在做的任何事情协同工作。这就是为什么多处理版本花费大约 1/9 的时间是有意义的——我的 CPU 有 8 个内核。
既然我们已经讨论了 Python 中的并发性和并行性,我们终于可以把这些术语说清楚了。如果您难以区分这两个术语,您可以放心而准确地将我们之前对“并行性”和“并发性”的定义分别理解为“并行并发性”和“非并行并发性”。
进一步阅读
Real Python 有一篇关于并发与并行的很棒的文章。
Engineer Man 有一个很好的线程与多处理的视频对比。
科里·斯查费也有一个很好的多处理视频,和他的线程视频一样。
如果你只看一个视频,那就看雷蒙德·赫廷格的这个精彩演讲。他出色地解释了多处理、线程和异步之间的区别。
将异步与多处理相结合
如果我需要将许多 I/O 操作与繁重的计算结合起来怎么办?
我们也能做到。假设您需要从 100 个网页中搜集一条特定的信息,然后您需要将这条信息保存在一个文件中以备后用。我们可以通过让每个进程抓取页面的一小部分来将计算能力分散到计算机的每个内核上。
对于这个脚本,让我们安装美汤来帮助我们轻松地刮我们的页面:pip install beautifulsoup4。这次我们实际上有相当多的进口货。它们在这里,这就是我们使用它们的原因:
`import asyncio # Gives us async/await
import concurrent.futures # Allows creating new processes
import time
from math import floor # Helps divide up our requests evenly across our CPU cores
from multiprocessing import cpu_count # Returns our number of CPU cores
import aiofiles # For asynchronously performing file I/O operations
import aiohttp # For asynchronously making HTTP requests
from bs4 import BeautifulSoup # For easy webpage scraping`
你可以在这里找到这个例子的代码。
首先,我们将创建一个异步函数,向 Wikipedia 发出请求以获取随机页面。我们将使用BeautifulSoup抓取每一页的标题,然后将它附加到一个给定的文件中;我们将用制表符分隔每个标题。该函数将接受两个参数:
- num_pages -请求和抓取标题的页数
- output _ file——将标题附加到的文件
`async def get_and_scrape_pages(num_pages: int, output_file: str):
"""
Makes {{ num_pages }} requests to Wikipedia to receive {{ num_pages }} random
articles, then scrapes each page for its title and appends it to {{ output_file }},
separating each title with a tab: "\\t"
#### Arguments
---
num_pages: int -
Number of random Wikipedia pages to request and scrape
output_file: str -
File to append titles to
"""
async with \
aiohttp.ClientSession() as client, \
aiofiles.open(output_file, "a+", encoding="utf-8") as f:
for _ in range(num_pages):
async with client.get("https://en.wikipedia.org/wiki/Special:Random") as response:
if response.status > 399:
# I was getting a 429 Too Many Requests at a higher volume of requests
response.raise_for_status()
page = await response.text()
soup = BeautifulSoup(page, features="html.parser")
title = soup.find("h1").text
await f.write(title + "\t")
await f.write("\n")`
我们都在异步打开一个 aiohttp ClientSession和我们的输出文件。模式a+意味着追加到文件中,如果文件不存在,就创建它。将我们的字符串编码为 utf-8 可以确保我们的标题包含国际字符时不会出错。如果我们得到一个错误响应,我们将引发它而不是继续(在高请求量时,我得到一个 429 太多请求)。我们异步地从响应中获取文本,然后解析标题,异步地将它附加到我们的文件中。在我们添加了所有标题之后,我们添加了新的一行:" \n "。
我们的下一个函数是我们将从每个新进程开始的函数,它允许异步运行它:
`def start_scraping(num_pages: int, output_file: str, i: int):
""" Starts an async process for requesting and scraping Wikipedia pages """
print(f"Process {i} starting...")
asyncio.run(get_and_scrape_pages(num_pages, output_file))
print(f"Process {i} finished.")`
现在我们的主要功能。让我们从一些常量(以及我们的函数声明)开始:
`def main():
NUM_PAGES = 100 # Number of pages to scrape altogether
NUM_CORES = cpu_count() # Our number of CPU cores (including logical cores)
OUTPUT_FILE = "./wiki_titles.tsv" # File to append our scraped titles to
PAGES_PER_CORE = floor(NUM_PAGES / NUM_CORES)
PAGES_FOR_FINAL_CORE = PAGES_PER_CORE + NUM_PAGES % PAGES_PER_CORE # For our final core`
现在的逻辑是:
`futures = []
with concurrent.futures.ProcessPoolExecutor(NUM_CORES) as executor:
for i in range(NUM_CORES - 1):
new_future = executor.submit(
start_scraping, # Function to perform
# v Arguments v
num_pages=PAGES_PER_CORE,
output_file=OUTPUT_FILE,
i=i
)
futures.append(new_future)
futures.append(
executor.submit(
start_scraping,
PAGES_FOR_FINAL_CORE, OUTPUT_FILE, NUM_CORES-1
)
)
concurrent.futures.wait(futures)`
我们创建一个数组来存储我们的未来,然后我们创建一个ProcessPoolExecutor,设置它的max_workers等于我们的核心数。我们在等于内核数减 1 的范围内迭代,用我们的start_scraping函数运行一个新进程。然后我们把它添加到我们的未来列表中。我们的最终内核可能会有额外的工作要做,因为它将抓取与其他内核相同数量的页面,但还会抓取与我们将要抓取的页面总数除以 cpu 内核总数所得的余数相同数量的页面。
确保实际运行您的主要功能:
`if __name__ == "__main__":
start = time.time()
main()
print(f"Time to complete: {round(time.time() - start, 2)} seconds.")`
在我的 8 核 CPU 上运行程序后(以及基准测试代码):
这个版本( asyncio 多处理):
`Time to complete: 5.65 seconds.`
`Time to complete: 8.87 seconds.`
`Time to complete: 47.92 seconds.`
完全同步:
`Time to complete: 88.86 seconds.`
事实上,我很惊讶地发现,asyncio 的多处理性能比单纯的多处理性能提升得并没有我想象的那么大。
回顾:何时使用多处理,何时使用异步或线程
- 当你需要做许多繁重的计算时,使用多重处理,你可以把它们分开。
- 在执行 I/O 操作时使用异步或线程——与外部资源通信或读写文件。
- 多处理和 asyncio 可以一起使用,但是一个好的经验法则是在线程化/使用 asyncio 之前分叉一个进程,而不是相反——与进程相比,线程相对便宜。
其他语言的异步/等待
async / await和类似的语法也存在于其他语言中,在其中一些语言中,它的实现可能会有很大的不同。
。NET: F#到 C
第一种使用async语法的编程语言(早在 2007 年)是微软的 F#。然而,它并不完全使用await来等待函数调用,而是使用特定的语法,如let!和do!以及System模块中包含的专有Async函数。
你可以在微软的 F#文档中找到更多关于异步编程的内容。
他们的 C#团队随后建立了这个概念,这就是我们现在熟悉的async / await关键词的由来:
`using System; // Allows the "Task" return type
using System.Threading.Tasks; public class Program { // Declare an async function with "async"
private static async Task<string> ReturnHello() { return "hello world"; } // Main can be async -- no problem
public static async Task Main() { // await an async string
string result = await ReturnHello(); // Print the string we got asynchronously
Console.WriteLine(result); } }`
我们确保我们是using System.Threading.Tasks,因为它包括了Task类型,并且,一般来说,等待的异步函数需要Task类型。C#最酷的一点是,只需用async声明主函数,就可以让它异步,而且不会有任何问题。
如果你有兴趣了解更多关于 C#中的
async/await,微软的 C#文档有一个很好的页面。
Java Script 语言
在 ES6 中首次引入的async / await语法本质上是对 JavaScript 承诺的抽象(类似于 Python 期货)。然而,与 Python 不同的是,只要你不等待,你就可以正常地调用一个异步函数,而不需要像 Python 的asyncio.start()那样的特定函数:
`// Declare a function with async async function returnHello(){ return "hello world"; } async function printSomething(){ // await an async string const result = await returnHello(); // print the string we got asynchronously console.log(result); } // Run our async code printSomething();`
关于 JavaScript 中
async/await的更多信息,请参见 MDN。
锈
Rust 现在也允许使用async / await语法,它的工作方式类似于 Python、C#和 JavaScript:
`// Allows blocking synchronous code to run async code
use futures::executor::block_on; // Declare an async function with "async"
async fn return_hello() -> String { "hello world".to_string() } // Code that awaits must also be declared with "async"
async fn print_something(){ // await an async String
let result: String = return_hello().await; // Print the string we got asynchronously
println!("{0}", result); } fn main() { // Block the current synchronous execution to run our async code
block_on(print_something()); }`
为了使用异步函数,我们必须首先将futures = "0.3"添加到我们的 Cargo.toml 中。然后我们导入带有use futures::executor::block_on - block_on的block_on函数,这是从我们的同步main函数运行我们的异步函数所必需的。
你可以在 Rust 文档中找到 Rust 中
async/await的更多信息。
去
Go 使用了“goroutines”和“channels”,而不是我们之前讨论过的所有语言固有的传统语法async / await您可以将通道想象成类似于 Python 的未来。在 Go 中,你通常发送一个通道作为函数的参数,然后使用go并发运行函数。每当你需要确保函数已经完成时,你使用<-语法,你可以认为这是更常见的await语法。如果您的 goroutine(您正在异步运行的函数)有一个返回值,就可以这样获取它。
`package main import "fmt" // "chan" makes the return value a string channel instead of a string func returnHello(result chan string){ // Gives our channel a value result <- "hello world" } func main() { // Creates a string channel result := make(chan string) // Starts execution of our goroutine go returnHello(result) // Awaits and prints our string fmt.Println(<- result) }`
有关 Go 中并发性的更多信息,请参阅 Caleb Doxsey 的a Introduction to Programming in Go。
红宝石
与 Python 类似,Ruby 也有全局解释器锁限制。它所没有的是语言内置的并发性。然而,在 Ruby 中有一个社区创建的 gem 允许并发,你可以在 GitHub 上找到它的源代码。
Java 语言(一种计算机语言,尤用于创建网站)
像 Ruby 一样,Java 没有内置的async / await语法,但是它有使用java.util.concurrent模块的并发能力。然而,电子艺界写了一个异步库,允许使用await作为方法。它与 Python/C#/JavaScript/Rust 并不完全相同,但是如果您是一名 Java 开发人员并且对这种功能感兴趣,那么它是值得研究的。
C++
虽然 C++也没有async / await语法,但是它有能力使用futures模块使用期货来并发运行代码:
`#include <iostream> #include <string> // Necessary for futures
#include <future> // No async declaration needed
std::string return_hello() { return "hello world"; } int main () { // Declares a string future
std::future<std::string> fut = std::async(return_hello); // Awaits the result of the future
std::string result = fut.get(); // Prints the string we got asynchronously
std::cout << result << '\n'; }`
没有必要用任何关键字来声明一个函数来表示它是否能够或者应该异步运行。相反,你可以在需要的时候用std::future<{{ function return type }}>声明你的初始未来,并将其设置为等于std::async(),包括你想要异步执行的函数名以及它所带的任何参数——例如std::async(do_something, 1, 2, "string")。为了等待未来的值,对它使用.get()语法。
你可以在 cplusplus.com 上找到 C++ 中关于 async 的文档。
摘要
无论您正在处理异步网络或文件操作,还是正在执行大量复杂的计算,都有几种不同的方法可以最大化代码的效率。
如果你正在使用 Python,你可以使用asyncio或threading来充分利用 I/O 操作或用于 CPU 密集型代码的multiprocessing模块。
还要记住,
concurrent.futures模块可以用来代替threading或multiprocessing。
如果你使用的是另一种编程语言,很可能也有一个async / await的实现。
想看更多并行、并发和异步的例子吗?查看 Python 中的并行性、并发性和异步性——示例文章。
基于 Selenium Grid 和 Docker Swarm 的并行 Web 抓取
原文:https://testdriven.io/blog/concurrent-web-scraping-with-selenium-grid-and-docker-swarm/
在本教程中,我们将看看如何与 Selenium Grid 和 Docker 并行运行基于 Python 和 Selenium 的 web scraper。我们还将了解如何使用 Docker Swarm 快速扩展 DigitalOcean 上的 Selenium 网格,以提高刮刀的效率。最后,我们将创建一个 bash 脚本,自动启动和关闭数字海洋上的资源。
依赖关系:
- 文档 v20.10.13
- python 3 . 10 . 4 版
- 硒 4.1.3 版
学习目标
本教程结束时,您将能够:
- 配置 Selenium Grid 以使用 Docker
- 通过 Docker 机器将 Selenium 网格部署到数字海洋
- 创建一个 Docker 集群
- 在 Docker 集群上扩展 Selenium 网格
- 自动部署 Selenium Grid 和 Docker Swarm
入门指南
从使用 web 抓取脚本克隆基础项目开始,创建并激活一个虚拟环境,并安装依赖项:
`$ git clone https://github.com/testdrivenio/selenium-grid-docker-swarm.git --branch base --single-branch
$ cd selenium-grid-docker-swarm
$ python3.10 -m venv env
$ source env/bin/activate
(env)$ pip install -r requirements.txt`
根据您的环境,上述命令可能会有所不同。
测试刮刀:
`(env)$ python project/script.py`
您应该会看到类似如下的内容:
`Scraping random Wikipedia page...
[
{
'url': 'https://en.wikipedia.org/wiki/Andreas_Reinke',
'title': 'Andreas Reinke',
'last_modified': ' This page was last edited on 10 January 2022, at 23:11\xa0(UTC).'
}
]
Finished!`
本质上,该脚本向 Wikipedia:Random - https://en.wikipedia.org/wiki/Special:Random -请求关于随机文章的信息,使用 Selenium 自动与站点交互,使用 Beautiful Soup 解析 HTML。
它是在用 Python 和 Selenium 构建并发 Web scraper】教程中构建的 Scraper 的修改版本。请查看教程以及脚本中的代码以获取更多信息。
配置 Selenium 网格
接下来,让我们启动 Selenium Grid 来简化脚本在多台机器上的并行运行。我们还将使用 Docker 和 Docker Compose 以最少的安装和配置来管理这些机器。
向根目录添加一个 docker-compose.yml 文件:
`version: '3.8' services: hub: image: selenium/hub:4.1.3 ports: - 4442:4442 - 4443:4443 - 4444:4444 chrome: image: selenium/node-chrome:4.1.3 depends_on: - hub environment: - SE_EVENT_BUS_HOST=hub - SE_EVENT_BUS_PUBLISH_PORT=4442 - SE_EVENT_BUS_SUBSCRIBE_PORT=4443`
这里,我们使用官方的 Selenium Docker 图片来建立一个基本的 Selenium 网格,它由一个 hub 和一个 Chrome 节点组成。我们使用了4.1.3标签,它与以下版本的 Selenium、WebDriver、Chrome 和 Firefox 相关联:
- 硒:4.1.3
- 谷歌浏览器
- chrome 驱动程序:99.0.4844.51
- Mozilla Firefox: 98.0.2
- 壁虎:0 . 3 . 0
想用不同的版本?从发布页面中找到合适的标签。
提取并运行图像:
在您的浏览器中导航到 http://localhost:4444 以确保 hub 启动并运行一个 Chrome 节点:

由于 Selenium Hub 运行在不同的机器上(在 Docker 容器中),我们需要在项目/scrapers/scraper.py 中配置远程驱动程序:
`def get_driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless")
# initialize driver
driver = webdriver.Remote(
command_executor='http://localhost:4444/wd/hub',
desired_capabilities=DesiredCapabilities.CHROME)
return driver`
添加导入:
`from selenium.webdriver.common.desired_capabilities import DesiredCapabilities`
再次运行刮刀:
`(env)$ python project/script.py`
当 scraper 运行时,您应该看到“Sessions”变为 1,表明它正在使用中:

部署到数字海洋
如果您还没有 DigitalOcean 的帐户,请注册该帐户。要使用数字海洋 API ,你还需要生成一个访问令牌。
在此获得 10 美元的数字海洋信用点数。
将令牌作为环境变量添加:
`(env)$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_token]`
使用 Docker Machine 供应新的 droplet:
`(env)$ 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" \
selenium-hub;`
--engine-install-url是必需的,因为在撰写本文时,Docker v20.10.13 不能与 Docker 机器一起使用。
接下来,将 Docker 守护进程指向新创建的机器,并将其设置为活动机器:
`(env)$ docker-machine env selenium-hub
(env)$ eval $(docker-machine env selenium-hub)`
旋转液滴上的两个容器:
`(env)$ docker-compose up -d`
一旦启动,获取液滴的 IP:
`(env)$ docker-machine ip selenium-hub`
确保 Selenium Grid 在 http://YOUR_IP:4444 打开,然后更新project/scrapers/scraper . py中的 IP 地址:
`command_executor='http://YOUR_IP:4444/wd/hub',`
运行刮刀:
`(env)$ python project/script.py`
同样,导航到网格仪表板并确保会话处于活动状态。您应该在终端中看到以下输出:
`Scraping random Wikipedia page...
[
{
'url': 'https://en.wikipedia.org/wiki/David_Hidalgo',
'title': 'David Hidalgo',
'last_modified': ' This page was last edited on 11 November 2021, at 01:24\xa0(UTC).'
}
]
Finished!`
到目前为止,我们只在维基百科上搜集了一篇文章。如果我们想抓取多篇文章呢?
`(env)$ for i in {1..21}; do {
python project/script.py &
};
done
wait`
再次导航到网格仪表板。您应该看到其中一个请求与 20 个排队请求一起运行:

因为我们只有一个节点在运行,所以需要一段时间才能完成(在我这边只需要 1.5 分钟)。我们可以增加几个节点的实例,但是每个节点都必须争用 droplet 上的资源。最好在几个 droplets 上部署 hub 和一些节点。这就是 Docker Swarm 发挥作用的地方。
运行码头群
因此,使用 Docker Swarm (或者“docker swarm mode”,如果你想更准确的话),我们可以在许多机器上部署单个 Selenium 网格。
首先在当前机器上初始化 Docker Swarm:
`(env)$ docker swarm init --advertise-addr [YOUR_IP]`
您应该会看到类似这样的内容:
`Swarm initialized: current node (mky1a6z8rjaeaeiucvzyo355l) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-2136awhbig93jh8xunp8yp2wn0pw9i946dvmfrpi05tnpbxula-633h28mn97sxhbfn8479mmpx5 134.122.20.39:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.`
请注意 join 命令,因为它包含一个令牌,我们需要它来将工人添加到集群中。
查看官方文档获取更多关于添加节点到群组的信息。
接下来,在数字海洋上旋转三个新的水滴:
`(env)$ 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" \
node-$i;
done`
然后把它们作为工人加入到蜂群中:
`(env)$ for i in 1 2 3; do
docker-machine ssh node-$i \
-- docker swarm join --token YOUR_JOIN_TOKEN;
done`
您应该会看到类似这样的内容:
`(env)$ for i in 1 2 3; do
docker-machine ssh node-$i \
-- docker swarm join --token SWMTKN-1-2136awhbig93jh8xunp8yp2wn0pw9i946dvmfrpi05tnpbxula-633h28mn97sxhbfn8479mmpx5 134.122.20.39:2377
done
This node joined a swarm as a worker.
This node joined a swarm as a worker.
This node joined a swarm as a worker.`
更新 docker-compose.yml 文件,以 Swarm 模式部署 Selenium 网格:
`version: '3.8' services: hub: image: selenium/hub:4.1.3 ports: - 4442:4442 - 4443:4443 - 4444:4444 deploy: mode: replicated replicas: 1 placement: constraints: - node.role == worker chrome: image: selenium/node-chrome:4.1.3 depends_on: - hub environment: - SE_EVENT_BUS_HOST=hub - SE_EVENT_BUS_PUBLISH_PORT=4442 - SE_EVENT_BUS_SUBSCRIBE_PORT=4443 - NODE_MAX_SESSION=1 entrypoint: bash -c 'SE_OPTS="--host $$HOSTNAME" /opt/bin/entry_point.sh' deploy: replicas: 1 placement: constraints: - node.role == worker`
主要变化:
- 布局约束:我们设置了
node.role == worker的布局约束,这样所有的任务都将在 worker 节点上运行。通常最好让管理器节点远离 CPU 和/或内存密集型任务。 - Entrypoint :这里,我们更新了 entry_point.sh 脚本中的
SE_OPTS中的主机集,这样运行在不同主机上的节点将能够成功链接回 hub。
这样,我们就可以部署堆栈了:
`(env)$ docker stack deploy --compose-file=docker-compose.yml selenium`
让我们再添加几个节点:
`(env)$ docker service scale selenium_chrome=5
selenium_chrome scaled to 5
overall progress: 5 out of 5 tasks
1/5: running [==================================================>]
2/5: running [==================================================>]
3/5: running [==================================================>]
4/5: running [==================================================>]
5/5: running [==================================================>]
verify: Service converged`
您可以像这样检查堆栈的状态:
`(env)$ docker stack ps selenium`
您还需要获得运行 hub 的机器的 IP 地址:
`(env)$ docker-machine ip $(docker service ps --format "{{.Node}}" selenium_hub)`
再次更新project/scrapers/scraper . py中的 IP 地址:
`command_executor='http://YOUR_IP:4444/wd/hub',`
测试一下:
`(env)$ for i in {1..21}; do {
python project/script.py &
};
done
wait`
回到网格仪表板上的 http://YOUR_IP:4444/ ,您应该看到五个节点,每个节点运行一个会话。还应该有 16 个排队的请求:

这应该运行得更快了。在我这边,花了 25 秒才跑完。
命令
想要查看服务吗?
要获得关于 Chrome 节点的更多信息以及每个节点的运行位置,请运行:
`(env)$ docker service ps selenium_chrome`
移除服务:
`(env)$ docker service rm selenium_chrome
(env)$ docker service rm selenium_hub`
旋转水滴:
`(env)$ docker-machine rm node-1 node-2 node-3
(env)$ docker-machine rm selenium-hub`
自动化工作流程
现在,我们必须手动上下旋转资源。让我们来自动化这个过程,以便当您想要运行一个刮擦作业时,资源会自动旋转起来,然后被拆除。
project/create.sh :
`#!/bin/bash
echo "Spinning up four droplets..."
for i in 1 2 3 4; 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" \
node-$i;
done
echo "Initializing Swarm mode..."
docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1)
echo "Adding the nodes to the Swarm..."
TOKEN=`docker-machine ssh node-1 docker swarm join-token worker | grep token | awk '{ print $5 }'`
docker-machine ssh node-2 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
docker-machine ssh node-3 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
docker-machine ssh node-4 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
echo "Deploying Selenium Grid to http://$(docker-machine ip node-1):4444"
eval $(docker-machine env node-1)
docker stack deploy --compose-file=docker-compose.yml selenium
docker service scale selenium_chrome=5`
project/destroy.sh :
`#!/bin/bash
echo "Bringing down the services"
docker service rm selenium_chrome
docker service rm selenium_hub
echo "Bringing down the droplets"
docker-machine rm node-1 node-2 node-3 node-4 -y`
更新项目/scrapers/scraper.py 中的get_driver()获取地址:
`def get_driver(address):
options = webdriver.ChromeOptions()
options.add_argument("--headless")
# initialize driver
driver = webdriver.Remote(
command_executor=f'http://{address}:4444/wd/hub',
desired_capabilities=DesiredCapabilities.CHROME)
return driver`
更新 project/script.py 中的主块:
`if __name__ == '__main__':
browser = get_driver(sys.argv[1])
data = run_process(browser)
print(data)
browser.quit()
print(f'Finished!')`
考验的时候到了!
`(env)$ sh project/create.sh`
运行刮刀:
`(env)$ docker-machine env node-1
(env)$ eval $(docker-machine env node-1)
(env)$ NODE=$(docker service ps --format "{{.Node}}" selenium_hub)
(env)$ for i in {1..21}; do {
python project/script.py $(docker-machine ip $NODE) &
};
done
wait`
完成后将资源带下来:
`(env)$ sh project/destroy.sh`
后续步骤
尝试这些挑战:
- 目前,我们没有对收集到的数据做任何事情。尝试运行一个数据库,并向抓取脚本添加一个函数,将数据写入数据库。
- Selenium 还用于基于浏览器的端到端测试。有了 Selenium Grid,你可以在不同的操作系统上运行不同版本的 Chrome 和 Firefox。换句话说,你可以运行多个节点,每个节点都有不同版本的 Chrome 和 Firefox,你可以对它们进行测试。自己尝试一下。查看使用 Selenium Grid 和 Docker 的分布式测试教程,看看这是怎么回事!
- 将 Docker Swarm 从混合中分离出来,并添加 Kubernetes。
和往常一样,你可以在 repo 中找到代码。
敏捷世界中的持续交付
作为软件专业人员,我们面临的最重要的问题是:如果有人想到了一个好主意,我们如何尽快将它交付给用户?
那是 2010 年。这就是 Jez Humble 和 David Farley 如何打开他们的书连续交付。事情会很快改变。连续交付是可测量和可演示的,组织进行了彻底的改变来采用这种新的方法。
在本文中,我们将看看什么是持续交付,为什么它是一个竞争优势,以及这个过程是什么样子的。
目标
- 描述什么是连续交付以及这个过程是什么样子的
- 确定当不使用连续交付时,组织在部署期间面临一些反模式
- 解释构成可靠持续交付流程基础的两大支柱
- 描述持续交付的关键原则
什么是持续交付?
连续交付 (CD)是将软件从开发阶段可靠、安全、尽可能快地交付到用户手中的过程。对于软件,我指的是从源代码到配置、数据和环境的一切。
CD 的重点是部署管道,它包括应用程序的构建、部署、测试和发布过程中的所有步骤。每一步都必须自动化。通过这样做,构建、部署、测试和发布的过程对于组织中的每个人来说都是可见和清晰的,增强了开发人员之间支持和协作的 T2 文化。以自动化的方式测试每一个变化将会为开发人员产生快速反馈,这样他们就可以在所需努力最少的时候立即识别并解决问题。CD 使团队能够在他们需要的时候部署和发布他们的软件,并且没有部署的痛苦。
如果你正确地实现了 CD,你可以通过按一个按钮或者运行一个命令来发布你的软件。不幸的是,在许多组织中,这是不正确的,发布日通常是痛苦的一天(或更久)。在组织中可以发现一些常见的反模式:
- 手动部署软件。软件发布往往是一个漫长的手工过程,需要由一个工程师团队来执行。在这个过程中,很多事情都会出错;而且,如果一个步骤执行得不完美,将会导致整个过程失败。当有很多文档需要遵循来准备一个发布,需要一个手动测试来确保一切正常工作,和/或整个发布过程花费超过几分钟时,您通常可以识别出这个反模式。这个反模式可能会产生许多问题。部署过程不可重复且不可靠,无法测试,并且容易出错。文档的大小会随着时间的推移而增加,难以维护,并且会变得过时。但最关键的是,测试你的部署过程的唯一方法就是去做,这本身就很危险。
- 只有在整个开发周期完成后,软件才能部署到类似生产的环境中。这通常发生在有两个团队时:一个开发团队和一个运营团队。有了这个反模式,即使软件从未在生产环境中测试过,部署文档从未测试过,操作人员在发布日第一次看到软件,软件也被认为是完成了。生产中的第一次部署总是最麻烦的。此外,开发人员做出的假设可能是错误的,而且这么长的发布周期没有机会修正它们。这种场景以几十封邮件、罚单和“这是别人的责任”结束。
- 手动管理生产环境的配置。当以下任一条件成立时,就会出现此反模式:
- 生产环境中需要的更改是手动完成的,并且有更改记录
- 准备生产环境需要大量时间
- 不可能回滚到以前的版本。
您在您公司的常规发布过程中认识到这些反模式吗?在这种情况下,您需要让每个人都注意到这一点,并在您转向更敏捷的环境时开始解决每个反模式。
软件部署过程必须完全自动化,由每个人使用,并且用于每个部署。这将确保部署脚本将被反复测试,并在发布日可靠地工作。
生产环境的部署应该集成到开发工作流中,使用持续集成 (CI)作为测试软件和部署的方法。这种纪律确保了当你准备好发布你的软件时,它将很少或没有风险地发生,因为它已经被一遍又一遍地测试了。
配置必须作为代码进行管理。您环境的每个方面(特别是操作系统配置、虚拟机配置、第三方元素、基础架构配置和您的应用程序堆栈)都必须签入版本控制。您应该能够以自动化的方式重新创建您的环境。
持续交付基础
速度是关键,因为不交付软件是有成本的。你错过了一个机会,因为你只有在释放后才能开始学习。在你的客户开始使用它之前,你设计的那个奇特的功能是没有价值的。你越快得到反馈,你的客户就能越快验证你的软件。减少反馈回路是第一个 pilar 。
第二个支柱是质量。我们希望交付高质量的软件。如果您有一个手动测试套件,甚至在运行之前需要五分钟来设置和配置,这是一个明显的信号,表明有什么地方出错了。
要实现这两大支柱,你需要自动化和频率。如果构建、部署、测试和发布过程不是自动化的,那么它是不可重复的。每次经历的过程都会不一样。手动步骤容易出错,难以记录,并且一旦完成就无法复查。如果你不自动化,你将永远无法控制发布过程。发布软件不应该是一门艺术,而应该是一个枯燥单调的工程过程。如果你的发布是频繁的,它们之间的差别将会很小。这将有助于极大地降低与发布本身相关的风险,并且更容易回滚。频繁发布可以提供更快的反馈,而反馈对于良好的 CD 流程至关重要。
反馈程序
对可执行代码、配置、主机环境和/或数据的每一个改变都必须触发反馈过程。因此,这些组件中的每一个都必须保持在版本控制之下,每一个变化都必须进行测试。
当对源代码进行更改时,必须以自动化的方式构建和测试生成的二进制文件。这种实践被称为持续集成。
如果您的环境发生变化,必须将差异作为配置信息捕获,并且应该测试配置中的任何变化。这同样适用于部署应用程序的环境。此外,如果数据的结构发生变化,必须测试这种变化。
反馈过程是怎样的?它包括尽可能以完全自动化的方式测试每一个变化。实际上,这意味着代码库中的每一个变化:
- 源代码的构建必须成功
- 单元测试必须通过
- 必须满足代码质量要求
- 软件验收测试必须通过
- 软件的非功能测试必须通过(安全性测试,可用性测试,等等。)
自动化是快速反馈的关键。依赖于人的手动流程容易出错,而且耗时较长。此外,他们是无聊和重复的。通过自动化,我们可以把人们从有趣的事情中解放出来,把重复留给机器。
我们可以将 CD 管道设置为两个测试阶段:
- 第一个将非常快,它可能不会覆盖整个代码库,但会处理关键路径,以便如果任何测试失败,我们的应用程序将永远不会发布。它可以在中性环境中运行。
- 另一方面,第二阶段可能会慢一些,即使有些测试失败了,我们也可能想发布。它应该在类似生产的环境中运行。
这种设置将确保资源优化:第一阶段可以在廉价的硬件上运行,如果失败,进程将停止;第二阶段将在要求更高的硬件上运行,但可以并行化。
参与软件交付的每个人都应该参与到反馈过程中,他们应该每天一起工作来改进软件交付。这种迭代过程对于快速提高发布软件的质量是必不可少的。
关键原则
在这一点上,你可能对裁谈会的哲学有了一个概念,它可以用一系列原则来概括。
为发布软件创建一个可重复的、可靠的过程
发布软件应该很容易,因为你已经对过程的每一步都测试过几次了。可重复性和可靠性来自自动化和版本控制的使用,这将使发布软件像按一个按钮一样容易。
几乎一切自动化
当然,有些事情是无法自动化的。向用户演示软件是无法自动化的。出于合规目的的批准无法自动化。你还想起什么了吗?
许多团队没有自动化他们的发布,因为这看起来是一项巨大的工作;相反,他们依赖手动步骤。努力是巨大的,但是值得的,当你执行第 10 个版本的时候,它会得到回报(可能甚至在第 5 个版本之后,但是你得到了要点)。
让一切都在版本控制中
构建、部署、测试和发布应用程序所需的一切都应该在版本控制之下。一个新的团队成员应该能够签出您的存储库,运行一个命令来构建,并部署整个项目。
如果疼,那就更频繁地做,并把疼痛提前
集成通常是一件痛苦的事,所以你应该多做一些。测试和发布是痛苦的。甚至创建新文档也很困难。你应该建立一种哲学,在这个过程中,痛苦的事情要尽早去做。
将质量建立在
您越早发现缺陷,修复它们的成本就越低。这意味着测试不是在开发之后开始的,也不仅仅是团队的责任。像测试驱动开发这样的方法可以帮助实现这个原则。
完成意味着释放
你有“完成了”的用户故事和其他“完成了”的用户故事吗?“完成”的定义必须清晰,它理想地意味着发布到产品中。这可能并不总是可行的,所以我们可以使用下面的 done 定义:当一个特性在类似生产的环境中被成功测试时,它就完成了。你可以想象要“完成”一个功能不是一个人的工作,而是一个团队的努力。还要记住,没有什么比“90%”更好的了,因为现实地估计剩余的百分比是不可能的。
每个人都对交付过程负责
对于小型创业公司,这很容易实现,但在较大的组织中,这可能需要大量的工作。有时现实是各部门在“筒仓”中工作,因此他们最终会因为 bug 而互相指责。这是 DevOps 运动的支柱之一。
持续改进
在您的生产应用程序的第一个版本发布之后,随着更多版本的发布,您的过程得到改进是非常重要的。这可以通过组织中的每个人参加回顾会议来收集想法并付诸行动来实现。
持续交付的影响
已经进行了几项研究来评估组织中连续交付的影响。
DevOps 报告称实践 CD 的团队在新工作上花费的时间比不实践 CD 的团队多 44%。
2013 年,Nicole Forsgren、Jez Humble 和 Gene Kim 开始了一次公司间的旅行,以了解软件交付的过程和性能。结果发表在加速书上。
他们试图评估和衡量以下能力:
- 对应用程序代码、系统配置、应用程序配置和构建配置脚本使用版本控制
- 可靠、易于修复、定期运行的全面测试自动化
- 部署自动化
- 连续累计
- 安全性的左移:在软件交付过程中引入安全性——以及安全性团队——而不是作为下游阶段
- 使用基于主干的开发,而不是长期特性分支
- 有效的测试数据管理
结果很惊人。综合起来看,这些能力对软件交付性能有很大的积极影响。它们有助于减少部署痛苦和团队倦怠。此外,在 CD 方面做得好的团队也更加认同他们工作的组织。
他们想回答的另一个关键问题是:CD 能提高质量吗?特别地,他们关注于:应用程序的质量,如那些工作在其上的人所感知的,花费在返工上的时间,以及花费在最终用户所识别的缺陷上的时间。分析发现,应用上述关键点的团队与高软件交付绩效相关联。这样的团队在返工上花费的时间也最少。
持续交付是什么样子的
这是一个部署管道的示例:

它始于开发人员将代码提交到版本控制系统和持续集成系统中,触发管道新实例的执行。
流水线的第一步被称为提交阶段。这个阶段的目标是消除不适合生产的构建,并尽快提供反馈,因为我们希望在明显有问题的版本上投入最少的时间。我们还需要防止上下文切换。因此,提交新代码的开发人员在进入下一个任务之前会等待结果。理想情况下,我们希望我们的开发人员等待所有测试通过,以便他们可以立即修复任何问题。实际上,这对于整个管道来说并不实际,但是可以在提交阶段实现,因此一旦提交阶段完成,开发人员就可以自由地转移到下一个任务。
在这个阶段,我们想要做几件事情:构建代码、运行单元测试、在工件存储库中创建和存储二进制文件以备后用,以及执行代码分析。
如果第一阶段成功完成,就意味着单元测试通过了。请记住,单元测试只是测试开发人员对问题解决方案的观点。从用户的角度来看,它们并没有真正涵盖应用程序是否做了它应该做的事情。如果我们想测试我们的应用程序是否为我们的用户提供了价值,我们需要另一种形式的测试。这些测试构成了第二阶段:自动化验收测试阶段。
这个阶段可以被分成不同的测试套件并被并行化。它还作为一个回归测试套件,验证新的变更没有将错误引入到现有的行为中。在执行验收测试时,考虑您的应用程序将在生产中遇到的环境是很重要的。
一旦自动化验收测试阶段完成,我们就有了一个成功的发布候选。在这一点上,管道分支支持容量测试、探索性测试、可用性测试,以及对各种环境的独立部署。在这一阶段可以自动化操作,以确保生产的自动化部署,但对于许多组织来说,在发布之前某种形式的手动测试是可取的,它可以在这一阶段完成,因此人们将决定是否应该提升发布候选。
实用的建议
根据您的软件交付过程的当前状态,您现在可能会感到有点不知所措,但是我想分享一些实用的建议,我希望当您开始您的 CD 之旅时,这些建议会对您有用。
一步一步来
实施 CD 流程不是一日之功。这需要时间和奉献,需要对你的日常工作流程做很多改变,所以我的建议是:一次专注于一件事。
您应该从首先实现持续集成开始。仅此一项就能极大地提高生产力,并提高项目的质量。
提供可操作的错误消息
当一个发布被拒绝时,错误消息必须清楚地解释出了什么问题以及如何解决这个问题。例如,避免类似“单元测试失败”的错误消息,而是提供失败测试的名称和日志消息的链接。
测量和使用数据
您的部署管道可以提供大量您应该测量的数据。记录从提交到发布的周期时间以及每个阶段花费的时间。这将有助于评估流程的状态并识别瓶颈。
包括展开破碎玻璃
有时可能需要绕过部署管道。例如,一个改变可能花费了太长的时间或者卡在了你的管道中。绕过管道级的碎玻璃机制可以让工程师快速解决停机问题。
结论
总结最大的收获:
- 你的目标是为你的用户提供价值
- 发布过程应该是快速的,并提供持续的反馈
- 发布软件不应该是一门艺术,而是一个枯燥单调的工程过程
- 如果不是自动化的,你就无法控制它
- 发现问题后立即解决
- 持续交付的第一步是持续集成
你的发布过程是什么样的?在推特上给我打电话,让我知道。干杯!
使用 Python 和 Fabric 在 DigitalOcean 上创建 Kubernetes 集群
原文:https://testdriven.io/blog/creating-a-kubernetes-cluster-on-digitalocean/
在本教程中,我们将使用 Ubuntu 20.04digital oceandroplets 构建一个三节点 Kubernetes 集群。我们还将看看如何使用 Python 和 Fabric 来自动设置 Kubernetes 集群。
请随意将 DigitalOcean 更换为不同的云托管提供商或您自己的内部环境。
依赖关系:
- Docker v20.10
- Kubernetes v1.21 版
什么是面料?
Fabric 是一个 Python 库,用于自动化 SSH 上的例行 shell 命令,我们将使用它来自动化 Kubernetes 集群的设置。
安装:
`$ pip install fabric==2.6.0`
验证版本:
`$ fab --version
Fabric 2.6.0
Paramiko 2.8.0
Invoke 1.6.0`
通过将以下代码添加到名为 fabfile.py 的新文件中进行测试:
`from fabric import task
@task
def ping(ctx, output):
"""Sanity check"""
print("pong!")
print(f"hello {output}!")`
尝试一下:
`$ fab ping --output="world"
pong!
hello world!`
更多信息,请查看官方网站文档。
水滴设置
首先,在 DigitalOcean 上注册账户(如果你还没有的话),给你的账户添加一个公共 SSH 密钥,然后生成一个访问令牌,这样你就可以访问 DigitalOcean API 了。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=<YOUR_DIGITAL_OCEAN_ACCESS_TOKEN>`
接下来,为了以编程方式与 API 交互,安装 python-digitalocean 模块:
`$ pip install python-digitalocean==1.17.0`
现在,让我们创建另一个任务来旋转三个 droplets:一个用于 Kubernetes master,两个用于 workers。更新 fabfile.py 这样:
`import os
from digitalocean import Droplet, Manager
from fabric import task
DIGITAL_OCEAN_ACCESS_TOKEN = os.getenv("DIGITAL_OCEAN_ACCESS_TOKEN")
# tasks
@task
def ping(ctx, output):
"""Sanity check"""
print("pong!")
print(f"hello {output}!")
@task
def create_droplets(ctx):
"""
Create three new DigitalOcean droplets -
node-1, node-2, node-3
"""
manager = Manager(token=DIGITAL_OCEAN_ACCESS_TOKEN)
keys = manager.get_all_sshkeys()
for num in range(3):
node = f"node-{num + 1}"
droplet = Droplet(
token=DIGITAL_OCEAN_ACCESS_TOKEN,
name=node,
region="nyc3",
image="ubuntu-20-04-x64",
size="s-2vcpu-4gb",
tags=[node],
ssh_keys=keys,
)
droplet.create()
print(f"{node} has been created.")`
注意传递给 Droplet 类的参数。本质上,这将在 NYC3 区域创建三个 Ubuntu 20.04 droplets,每个都有 4 GB 的内存。它还会给每个 droplet 添加 所有 SSH 密钥。您可能希望更新它,以便只包含您专门为此项目创建的 SSH 密钥:
`@task
def create_droplets(ctx):
"""
Create three new DigitalOcean droplets -
node-1, node-2, node-3
"""
manager = Manager(token=DIGITAL_OCEAN_ACCESS_TOKEN)
# Get ALL SSH keys
all_keys = manager.get_all_sshkeys()
keys = []
for key in all_keys:
if key.name == "<ADD_YOUR_KEY_NAME_HERE>":
keys.append(key)
for num in range(3):
node = f"node-{num + 1}"
droplet = Droplet(
token=DIGITAL_OCEAN_ACCESS_TOKEN,
name=node,
region="nyc3",
image="ubuntu-20-04-x64",
size="s-2vcpu-4gb",
tags=[node],
ssh_keys=keys,
)
droplet.create()
print(f"{node} has been created.")`
创造水滴:
`$ fab create-droplets
node-1 has been created.
node-2 has been created.
node-3 has been created.`
接下来,让我们添加一个任务来检查每个 droplet 的状态,以确保在开始安装 Docker 和 Kubernetes 之前,每个 droplet 都已启动并准备就绪:
`@task
def wait_for_droplets(ctx):
"""Wait for each droplet to be ready and active"""
for num in range(3):
node = f"node-{num + 1}"
while True:
status = get_droplet_status(node)
if status == "active":
print(f"{node} is ready.")
break
else:
print(f"{node} is not ready.")
time.sleep(1)`
添加get_droplet_status辅助函数:
`def get_droplet_status(node):
"""Given a droplet's tag name, return the status of the droplet"""
manager = Manager(token=DIGITAL_OCEAN_ACCESS_TOKEN)
droplet = manager.get_all_droplets(tag_name=node)
return droplet[0].status`
不要忘记重要的一点:
在我们测试之前,添加另一个任务来销毁液滴:
`@task
def destroy_droplets(ctx):
"""Destroy the droplets - node-1, node-2, node-3"""
manager = Manager(token=DIGITAL_OCEAN_ACCESS_TOKEN)
for num in range(3):
node = f"node-{num + 1}"
droplets = manager.get_all_droplets(tag_name=node)
for droplet in droplets:
droplet.destroy()
print(f"{node} has been destroyed.")`
摧毁我们刚刚创造的三个液滴:
`$ fab destroy-droplets
node-1 has been destroyed.
node-2 has been destroyed.
node-3 has been destroyed.`
然后,调出三个新液滴,并验证它们是否可以运行:
`$ fab create-droplets
node-1 has been created.
node-2 has been created.
node-3 has been created.
$ fab wait-for-droplets
node-1 is not ready.
node-1 is not ready.
node-1 is not ready.
node-1 is not ready.
node-1 is not ready.
node-1 is not ready.
node-1 is ready.
node-2 is not ready.
node-2 is not ready.
node-2 is ready.
node-3 is ready.`
供应机器
需要在每个微滴上运行以下任务...
设置地址
首先添加一个任务,在hosts环境变量中设置主机地址:
`@@task
def get_addresses(ctx, type):
"""Get IP address"""
manager = Manager(token=DIGITAL_OCEAN_ACCESS_TOKEN)
if type == "master":
droplet = manager.get_all_droplets(tag_name="node-1")
print(droplet[0].ip_address)
hosts.append(droplet[0].ip_address)
elif type == "workers":
for num in range(2, 4):
node = f"node-{num}"
droplet = manager.get_all_droplets(tag_name=node)
print(droplet[0].ip_address)
hosts.append(droplet[0].ip_address)
elif type == "all":
for num in range(3):
node = f"node-{num + 1}"
droplet = manager.get_all_droplets(tag_name=node)
print(droplet[0].ip_address)
hosts.append(droplet[0].ip_address)
else:
print('The "type" should be either "master", "workers", or "all".')
print(f"Host addresses - {hosts}")`
在顶部定义以下变量,就在DIGITAL_OCEAN_ACCESS_TOKEN = os.getenv('DIGITAL_OCEAN_ACCESS_TOKEN')下面:
运行:
`$ fab get-addresses --type=all
165.227.96.238
134.122.8.106
134.122.8.204
Host addresses - ['165.227.96.238', '134.122.8.106', '134.122.8.204']`
这样,我们就可以开始安装 Docker 和 Kubernetes 依赖项了。
安装依赖项
安装 Docker 和-
- kubeadm -引导一个 Kubernetes 集群
- 配置容器在主机上运行
- 用于管理集群的命令行工具
添加一个将 Docker 安装到 fabfile 的任务:
`@task
def install_docker(ctx):
"""Install Docker"""
print(f"Installing Docker on {ctx.host}")
ctx.sudo("apt-get update && apt-get install -qy docker.io")
ctx.run("docker --version")
ctx.sudo("systemctl enable docker.service")`
让我们禁用交换文件:
`@task
def disable_selinux_swap(ctx):
"""
Disable SELinux so kubernetes can communicate with other hosts
Disable Swap https://github.com/kubernetes/kubernetes/issues/53533
"""
ctx.sudo('sed -i "/ swap / s/^/#/" /etc/fstab')
ctx.sudo("swapoff -a")`
安装库:
`@task
def install_kubernetes(ctx):
"""Install Kubernetes"""
print(f"Installing Kubernetes on {ctx.host}")
ctx.sudo("apt-get update && apt-get install -y apt-transport-https")
ctx.sudo(
"curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -"
)
ctx.sudo(
'echo "deb http://apt.kubernetes.io/ kubernetes-xenial main" | \
tee -a /etc/apt/sources.list.d/kubernetes.list && apt-get update'
)
ctx.sudo(
"apt-get update && apt-get install -y kubelet=1.21.1-00 kubeadm=1.21.1-00 kubectl=1.21.1-00"
)
ctx.sudo("apt-mark hold kubelet kubeadm kubectl")`
与其分别运行这些任务,不如创建一个主provision_machines任务:
`@task
def provision_machines(ctx):
for conn in get_connections(hosts):
install_docker(conn)
disable_selinux_swap(conn)
install_kubernetes(conn)`
添加get_connections辅助函数:
`def get_connections(hosts):
for host in hosts:
yield Connection(
f"{user}@{host}",
)`
更新导入:
`from fabric import Connection, task`
运行:
`$ fab get-addresses --type=all provision-machines`
安装所需的软件包需要几分钟时间。
配置主节点
初始化 Kubernetes 集群并部署法兰绒网络:
`@task
def configure_master(ctx):
"""
Init Kubernetes
Set up the Kubernetes Config
Deploy flannel network to the cluster
"""
ctx.sudo("kubeadm init")
ctx.sudo("mkdir -p $HOME/.kube")
ctx.sudo("cp -i /etc/kubernetes/admin.conf $HOME/.kube/config")
ctx.sudo("chown $(id -u):$(id -g) $HOME/.kube/config")
ctx.sudo(
"kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml"
)`
保存加入令牌:
`@task
def get_join_key(ctx):
sudo_command_res = ctx.sudo("kubeadm token create --print-join-command")
token = re.findall("^kubeadm.*$", str(sudo_command_res), re.MULTILINE)[0]
with open("join.txt", "w") as f:
with stdout_redirected(f):
print(token)`
添加以下导入内容:
`import re
import sys
from contextlib import contextmanager`
创建stdout_redirected上下文管理器:
`@contextmanager
def stdout_redirected(new_stdout):
save_stdout = sys.stdout
sys.stdout = new_stdout
try:
yield None
finally:
sys.stdout = save_stdout`
再次添加一个父任务来运行这些任务:
`@task
def create_cluster(ctx):
for conn in get_connections(hosts):
configure_master(conn)
get_join_key(conn)`
运行它:
`$ fab get-addresses --type=master create-cluster`
这将需要一两分钟来运行。一旦完成,join token 命令应该输出到屏幕并保存到一个 join.txt 文件:
`kubeadm join 165.227.96.238:6443 --token mvk32y.7z7i5x3viga4f4kn --discovery-token-ca-cert-hash sha256:f358dfc00ae7160fff3cb8fa3e3a3c8865f3c5b83c1f242fc9e51efe94108960`
配置工作节点
使用上面保存的 join 命令,添加一个任务,将 workers“加入”到 master:
`@task
def configure_worker_node(ctx):
"""Join a worker to the cluster"""
with open("join.txt") as f:
join_command = f.readline()
for conn in get_connections(hosts):
conn.sudo(f"{join_command}")`
在两个工作节点上运行此命令:
`$ fab get-addresses --type=workers configure-worker-node`
健全性检查
最后,为了确保集群启动并运行,添加一个任务来查看节点:
`@task
def get_nodes(ctx):
for conn in get_connections(hosts):
conn.sudo("kubectl get nodes")`
运行:
`$ fab get-addresses --type=master get-nodes`
您应该会看到类似如下的内容:
`NAME STATUS ROLES AGE VERSION
node-1 Ready control-plane,master 3m6s v1.21.1
node-2 Ready <none> 84s v1.21.1
node-3 Ready <none> 77s v1.21.1`
完成后清除水滴:
`$ fab destroy-droplets
node-1 has been destroyed.
node-2 has been destroyed.
node-3 has been destroyed.`
自动化脚本
最后一件事:添加一个 create.sh 脚本来自动化整个过程:
`#!/bin/bash
echo "Creating droplets..."
fab create-droplets
fab wait-for-droplets
sleep 20
echo "Provision the droplets..."
fab get-addresses --type=all provision-machines
echo "Configure the master..."
fab get-addresses --type=master create-cluster
echo "Configure the workers..."
fab get-addresses --type=workers configure-worker-node
sleep 20
echo "Running a sanity check..."
fab get-addresses --type=master get-nodes`
尝试一下:
就是这样!
您可以在 GitHub 上的 kubernetes-fabric repo 中找到这些脚本。
烧瓶中的 CSRF 保护
这篇文章着眼于如何防止烧瓶中的 CSRF 攻击。在这个过程中,我们将看看 CSRF 是什么,一个 CSRF 攻击的例子,以及如何通过 Flask-WTF 防范 CSRF。
什么是 CSRF?
CSRF 代表跨站点请求伪造,是一种针对 web 应用程序的攻击,攻击者试图欺骗经过身份验证的用户执行恶意操作。大多数 CSRF 攻击的目标是使用基于 cookie 的身份验证的 web 应用程序,因为 web 浏览器在每个请求中都包含与特定域相关联的所有 cookie。因此,当恶意请求来自同一个浏览器时,攻击者可以很容易地利用存储的 cookies。
这种攻击通常是通过诱骗用户点击按钮或提交表单来实现的。例如,假设您的银行 web 应用程序容易受到 CSRF 攻击。攻击者可以创建一个包含以下形式的银行网站的克隆:
`<form action="https://centralbank.com/api/account" method="POST">
<input type="hidden" name="transaction" value="transfer">
<input type="hidden" name="amount" value="100">
<input type="hidden" name="account" value="999">
<input type="submit" value="Check your statement now">
</form>`
然后,攻击者向您发送一封看似来自您的银行(cemtralbenk.com 而非 centralbank.com)的电子邮件,表明您的银行对账单已经可以查看了。单击电子邮件中的链接后,您会被带到带有表单的恶意网站。你点击按钮检查你的陈述。然后,浏览器将自动发送身份验证 cookie 和 POST 请求。由于您已经过身份验证,攻击者将能够执行您被允许执行的任何操作。在这种情况下,100 美元从您的帐户转移到帐号 999。
想想你每天收到的垃圾邮件。其中有多少包含隐藏的 CSRF 攻击?
烧瓶示例
接下来,让我们看一个容易受到 CSRF 攻击的 Flask 应用程序的例子。同样,我们将使用银行网站场景。
该应用程序具有以下功能:
- 创建用户会话的登录表单
- 显示帐户余额和转帐表单的帐户页面
- 注销按钮以清除会话
它使用 Flask-Login 来处理 auth 和管理用户会话。
你可以从 csrf-example repo 的csrf-flask-insecurity分支中克隆这个应用。按照自述文件上的说明安装依赖项,并在 http://localhost:5000 上运行应用程序:
确保您可以使用以下身份登录:
- 用户名:
test - 密码:
test
登录后,您将被重定向到http://localhost:5000/accounts。记下会话 cookie:

浏览器将随每个后续请求一起向localhost:5000域发送 cookie。记下与 app.py 中的账户页面相关联的路线:
`@app.route("/accounts", methods=["GET", "POST"])
@login_required
def accounts():
user = get_user(current_user.id)
if request.method == "POST":
amount = int(request.form.get("amount"))
account = int(request.form.get("account"))
transfer_to = get_user(account)
if amount <= user["balance"] and transfer_to:
user["balance"] -= amount
transfer_to["balance"] += amount
return render_template(
"accounts.html",
balance=user["balance"],
username=user["username"],
)`
这里没有什么太复杂的:在 POST 请求中,从用户的余额中减去提供的金额,并添加到与提供的帐号相关联的余额中。当用户通过身份验证时,银行服务器基本上信任来自浏览器的请求。由于这个路由处理程序无法抵御 CSRF 攻击,攻击者可以利用这种信任,诱使某人在不知情的情况下在银行服务器上执行操作。这正是 hacker/index.html 页面所做的:
`<form hidden id="hack" target="csrf-frame" action="http://localhost:5000/accounts" method="POST" autocomplete="off">
<input type="number" name="amount" value="2000">
</form>
<iframe hidden name="csrf-frame"></iframe>
<h3>You won $100,000</h3>
<button onClick="hack();" id="button">Click to claim</button>
<br>
<div id="warning"></div>
<script> function hack() { document.getElementById("hack").submit(); document.getElementById("warning").innerHTML="check your bank balance!"; } </script>`
您可以在 http://localhost:8002 上提供此页面,方法是导航到项目目录,并在新的终端窗口中运行以下命令:
`$ python -m http.server --directory hacker 8002`

除了糟糕的设计,在普通人看来,没有什么值得怀疑的。但在幕后,有一个隐藏的表单在后台执行,从用户的帐户中删除所有的钱。
攻击者可以通过电子邮件发送该页面的链接,伪装成某种奖品。现在,在打开页面并点击“点击认领”按钮后,一个 POST 请求被发送到http://localhost:5000/accounts,该请求利用了银行和 web 浏览器之间建立的信任。

如何预防 CSRF?
通过使用 CSRF 令牌——一个随机的、不可猜测的字符串——来验证请求来源,可以防止 CSRF 攻击。对于具有副作用的不安全请求,如 HTTP POST 表单提交,您必须提供有效的 CSRF 令牌,以便服务器可以验证请求的来源以获得 CSRF 保护。
CSRF 令牌工作流

- 客户端发送一个 POST 请求,其中包含要进行身份验证的凭据。
- 如果凭据正确,服务器将生成一个会话和 CSRF 令牌。
- 请求被发送回客户端,会话存储在 cookie 中,而令牌呈现在隐藏的表单字段中。
- 客户端将会话 cookie 和 CSRF 令牌包含在表单提交中。
- 服务器验证会话和 CSRF 令牌,并接受或拒绝请求。
现在让我们看看如何使用 Flask-WTF 扩展在我们的示例应用程序中实现 CSRF 保护。
从安装依赖项开始:
接下来,在 app.py 中全局注册 CSRFProtect :
`from flask import Flask, Response, abort, redirect, render_template, request, url_for
from flask_login import (
LoginManager,
UserMixin,
current_user,
login_required,
login_user,
logout_user,
)
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config.update(
DEBUG=True,
SECRET_KEY="secret_sauce",
)
login_manager = LoginManager()
login_manager.init_app(app)
csrf = CSRFProtect()
csrf.init_app(app)
...`
现在,默认情况下,所有 POST、PUT、PATCH 和 DELETE 方法都受到保护,不会被 CSRF 攻击。请注意这一点。永远不要通过 GET 请求产生副作用,比如改变数据库中的数据。
接下来,将带有 CSRF 令牌的隐藏输入字段添加到表单中。
模板/索引. html :
`<form action='/' method='POST' autocomplete="off">
<input type='text' name='username' id='email' placeholder='username'/>
<input type='password' name='password' id='password' placeholder='password'/>
<input type='submit' name='submit' value='login'/>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>`
模板/账户. html :
`<h3>Central bank account of {{username}}</h3> <a href="/logout">logout</a>
<br><br>
<p>Balance: ${{balance}}</p>
<form action="/accounts" method="POST" autocomplete="off">
<p>Transfer Money</p>
<input type="text" name="account" placeholder="accountid">
<input type="number" name="amount" placeholder="amount">
<input type="submit" value="send">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>`
就是这样。这能帮你搞定 CSRF。现在,让我们看看这是否能阻止攻击。再次运行两台服务器。登录银行 app,然后尝试“点击认领”按钮。您应该会看到一个 400 错误:

如果在 hacker/index.html 中的表单中添加相同的隐藏字段会怎么样?
`<form hidden id="hack" target="csrf-frame" action="http://localhost:5000/accounts" method="POST" autocomplete="off">
<input type="number" name="amount" value="2000">
<input type="number" name="account" value="2">
<input type="hidden" name="csrf_token" value="123">
</form>`
它仍然应该失败,并显示 400。我们成功阻止了 CSRF 的袭击。

CORS 和 JSON APIs
对于 JSON APIs,拥有一个正确配置的跨源资源共享 (CORS)策略是很重要的,但是它本身并不能防止 CSRF 攻击。事实上,如果 CORS 配置不正确,它会使您更容易受到 CSRF 的攻击。
对于预检请求 CORS 策略定义了谁可以和不可以访问特定资源。当使用 XMLHttpRequest 或 Fetch 时,此类请求由浏览器触发。
当触发预检请求时,浏览器向服务器发送请求,询问 CORS 策略(允许的来源、允许的请求类型等)。).然后,浏览器根据原始请求检查响应。如果请求不符合要求,浏览器将拒绝它。
同时,简单的请求,比如来自基于浏览器的表单提交的 POST 请求,不会触发预检请求,因此 CORS 策略无关紧要。
因此,如果您有 JSON API,限制允许的来源或完全消除 CORS 是防止不必要请求的一个好方法。在那种情况下,你不需要使用 CSRF 代币。如果你对原产地有更开放的 CORS 政策,使用 CSRF 代币是个好主意。
结论
我们已经看到了攻击者如何在用户不知情的情况下伪造请求并执行操作。随着浏览器变得越来越安全,JSON APIs 被越来越多地使用,幸运的是 CSRF 变得越来越不令人担心。也就是说,对于像表单中的 POST 请求这样的简单请求,确保处理此类请求的路由处理程序的安全是至关重要的,尤其是当 Flask-WTF 使得抵御 CSRF 攻击变得如此容易时。
CSS 网格:没有废话布局
本文的目的是揭示 CSS 网格背后的力量。您将学习如何通过实用的、基于示例的方法创建基于网格的布局。
观众
这是面向前端开发人员的初级到中级教程,了解以下内容:
- 基本 CSS
- 流行的 CSS 布局方法和解决方案
- 其他 CSS 布局方法的缺陷
目标
本教程结束时,您将能够:
- 解释常见的 CSS 布局方法和解决方案
- 描述 CSS 网格如何工作,以及它与其他 CSS 布局方法的比较
- 通过示例确定用于使 CSS 网格更加强大的概念和属性
- 为二维布局实现 CSS 网格
CSS 网格和其他布局方法
通过接触其他布局方法,我们可以深入了解为什么网格获得了如此大的吸引力,如何以及何时使用它,以及它如何增强跨设备的用户体验。
以下是创建和操作网页/应用程序布局的所有方法的简要总结。
| 方法 | 描述 |
|---|---|
| 正常流量 | 浏览器通常如何处理布局(无 CSS) |
| 显示属性 | 该属性的值用于创建应用程序布局:inline、block、flex、grid |
| flex box(flex box)的缩写形式 | 包含行和列的一维布局 |
| 格子 | 具有行和列以及显式放置的项目的二维布局 |
| 漂浮物 | 更改浮动项和后面的块元素的行为 |
| 配置 | 将元素从正常流程移动到特定位置:absolute、fixed、relative |
| 表格布局 | CSS 中的 HTML 表格标记 |
| 多列布局 | 很像报纸上的多栏文章 |
显然,这里的主要焦点是 CSS Grid,但是重要的是要注意,对于特定的情况,这些方法中的许多都工作得很好——并且在一起工作得很好。Grid 半最近得到了一些惊人的浏览器兼容性更新。随着这种采用,开发人员有机会利用被忽略的 CSS 特性,如calc()和minmax()方法,以及fr度量。我们稍后会谈到这些
首先,让我们快速浏览一下 CSS 网格生态系统。
CSS 网格基础
要使用 CSS 网格,必须将display的 CSS 样式属性设置为grid。这用于管理 2D 中带有行和列的布局。它可以嵌套在任何其他display属性的上面或下面。它非常动态和强大,尤其是当应用程序的布局很复杂的时候。
关键词
能够理解组成 CSS 网格环境的组件将确保从概念到完成实现复杂布局时阻力最小。使用 CSS Grid 有很多方法可以达到同样的效果,所以理解生态系统、概念和定义它们的关键字将会让你在使用 CSS Grid 时有更好的决策和更多的自由。
当你刚刚熟悉网格的时候,你可能很难想象它。弄清楚浏览器的隐式网格算法相对于你的显式风格在做什么可能会造成很多混乱。精确定位已定义的区域、操作内容,或者了解哪些轨迹和细胞正在做什么、在哪里以及为什么做,可能会让人不知所措。此外,我们正在创建一个在不知道合适的工具的情况下很难看到或检查的系统。在处理我的“网格中心之旅”时,浏览器开发工具让我认识到理解上面列出的关键词的重要性。
让我们从回顾以下关键词开始。
- 网格容器
- 网格轨迹
- 网格线
- 网格单元
- 网格项
- 网格区域
- 栅格间隙
这些关键字将帮助我们理解在实现特定的网格属性时实际发生了什么。在我们理解了核心概念之后,我们将在下一节中讨论具体的属性和值。
每个项目后面的 CodePen 链接不仅仅显示代码和结果输出——代码输出中还显示了解释,以帮助更好地理解概念。请随意使用本文中链接的任何示例。
网格容器
一个网格容器是一个 HTML 元素,其 CSS 属性 display 的值被设置为 grid。所有的直接子元素都将应用来自其父容器的 CSS 网格布局规则。如果它们不是直接的,父容器要么不知道,不关心,要么已经抛弃了它们的子容器(它们不知道)。通过将 display 设置为 grid,您可以选择对您的直接子元素进行微观管理。你必须明确每个元素来操作布局样式。这使得网格是隐式的。让我们确定一些关键字,然后我们将明确我们的父容器,让它做所有的委托。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 yWQZJV 。
网格线
行和列的结构是由分割线引导的。那些线被称为网格线,它们组成了网格轨迹。了解这一点很重要,因为您可以明确地告诉父节点特定子节点的开始和结束位置。这里有一个双关语。同样,网格线从左到右,从上到下排序,从 1 开始,而不是 0。垂直和水平行组有单独的数字行,因此行从顶部的数字 1 开始,列从左侧的数字 1 开始。两列两行的网格系统会给你三条垂直线和三条水平线。从上到下,第 1、2 和 3 行,分别从左到右,第 1、2 和 3 行。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 YbRBJo 。
网格轨迹
这是两条网格线之间的空间,由明确的网格属性定义。本质上,整行或整列。要显式显示父元素,请在网格容器元素上使用以grid-template-_____或简写grid开头的属性。网格轨迹让父母可以很容易地给孩子分配方向网格项目——例如,在哪里以及如何行动。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 GawemN 。
网格单元
网格的最小单位。当你建立你的行和/或列时,由这些网格轨迹和网格线创建的矩形创建一个网格单元。我们将用下面的网格项目来演示这一点。
网格项目
一个网格容器的每个直接子节点是一个网格项目。它们总是被显式或隐式地放置在网格单元中。项目可以是一个或多个,也可以是一个容器,它自己的子容器应用了一组不同的网格规则。
如果行和/或列不是由父元素建立的,那么子元素就由 Al - aka 叔叔自动放置算法来操作。呀
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 joQRjP 。
网格间隙
一个网格间隙是放置在两个网格单元之间的空间。在某些浏览器中,gap属性或grid-gap是grid-row-gap和grid-column-gap属性的简称。任何东西都不能放入带网格的缝隙中。我们将用下面的网格区域来演示这一点。
网格区域
我的最爱!网格区域是一个或多个网格单元,它们组成了父网格容器中的一个矩形区域。它们允许您使用文本来命名和组织网格容器中的网格项目。通过在指定的网格项目或网格子项目上应用grid-area: <name here>属性来实现这一点。您可以用文本命名每个网格项目,然后在父网格容器中按照您想要的方式排列它们。至于在父属性上设置的属性grid-template-areas的值,每个引用的集计为一行。在每个报价集中,列名以空格分隔。您可以在列名之间使用所有需要的空格,使它看起来更好。如果你想要一个空白或未命名的空间,使用句号,.。你也可以想拥有多少就拥有多少,正如你将在下面的演示中看到的。
此示例还显示了嵌套网格。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 NVEVod 。
接下来,我们将更深入地研究属性和值。最后,我们将展示一些有趣而复杂的例子。但首先,给你个提示。
铬:
你可以在 Chrome 的 DevTools 中使用检查器模式来检查网格布局。要打开检查器模式,只需按下 CMD+SHIFT+C (Mac)或 CTRL+SHIFT+C (Windows 和 Linux),这是一个打开或关闭浏览器 DOM 检查器工具的快捷键。
这对 CSS Grid 很有帮助,因为当你悬停在一个网格容器上时,你会看到它的结构。这包括组成列和行的轨道和单元格,以及边距、填充和间隙。您还可以在 DevTools 中操纵 CSS 来测试变更,而无需提交。这是学习 CSS 网格工作原理的好地方。

火狐:
想要顶级的检查员体验吗?火狐的开发者工具有一个网格检查器功能,这是目前远远优于任何其他浏览器的。布局部分列出了 DOM 中显示的所有网格容器。它允许您选择一个、两个或所有容器,并在浏览器中切换网格线、编号、网格区域和名称。

要了解更多信息,请查看以下资源:
属性和值
我们将简要介绍两组属性,后面是将与这些属性一起使用的值和函数。
下面的属性是操纵网格布局的最常见和最实用的方法。它们非常强大并且易于实现。使用下面的第三个属性——grid简写——很好,但是我相信你会为将来的自己和开发人员牺牲一点可读性。如果你是网格一族的新手,就从前两个开始。你可以使用任何尺寸值/单位度量来使它们明确。我们很快会看一些好的。
提示:您可以将该属性设置为
none来让浏览器隐式地完成这项工作,或者覆盖来自竞争媒体查询的值。
grid-template-columns定义网格容器中列的大小和数量。grid-template-rows定义网格容器的大小和行数。grid或grid-template(取决于浏览器)是上述两者的简称。grid: < grid-template-rows > / < grid-template-columns >。
现在举个例子,看起来应该很熟悉。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 YbRBJo 。
接下来,让我们看看一些属性,这些属性允许网格线作为你的布局指南。
利用以下属性是通过创建网格区域 ( 未命名)来定制您的网格容器外观的好方法。该样式将应用于网格项目本身,即网格容器的子项目。它们模仿上面“网格区域”一节中提到的grid-template-areas,只是语法不同。如果你刚刚开始创建网格区域这是一个很好的起点。grid-template-areas语法可能很漂亮优雅,但是对于第一次使用的人来说可能会有点快。很了解网格线?这些是你的财产。
网格-列-开始
- 识别网格项目(网格容器的子节点)相对于其相关网格线(y 轴)的起始位置。
- 应该应用于网格容器的子元素。
- 定义一个网格区域(跨越一个网格单元的区域)。
1是网格容器的左边缘。
网格-列-结束
- 确定网格项目相对于其相关网格线的结束位置。
- 应该应用于网格容器的子元素。
- 定义一个网格区域。
网格-行-开始
- 识别网格项目相对于其相关网格线 (x 轴)的起始位置。
- 应应用于网格容器的子元素,值为整数。
1是网格容器的顶部。- 定义一个网格区域。
网格-行尾
- 确定网格项目相对于其相关网格线的结束位置。
- 应应用于网格容器的子元素,值为整数。
- 定义一个网格区域。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 YbRBJo 。
现在,让我们看看一些有用的度量单位和 CSS 函数。
以下是非常有用的值,可以使 power CSS 网格及其属性更加动态和宽容。以下函数可以单独使用,也可以作为多值接受属性中的一个或多个值使用。
度量单位
rem (“根 em”的简称)
这个测量很棒!在用户通过将浏览器字体设置得更大来应用辅助功能的屏幕上,这是非常宽容的。这个度量单位基于根元素的字体大小创建长度和大小。
fr (“分数”的简称)
这定义了一个网格容器中可用的空间单位。它允许你用简单的分数来划分容器中的可用空间。分数有助于避免使用66.66%这样的值。应用于grid-template-rows和/或grid-template-columns时最有用。
fr可单独使用或组合使用:
grid-template-columns: 1fr;等于整个容器到一列。就当是 1/1 吧。grid-template-columns: 1fr 1fr;将把容器分成两个均匀间隔的列。想想 1/2。如果你把所有的值加起来,你得到的是分母,实际值可以看作是分子。grid-template-columns: 1fr 3fr;将把容器分成四个部分:第一列是 1 个部分,第二列是剩余的 3 个部分。grid-template-columns: 1fr 1fr 1fr;将把容器分成三个均匀间隔的列。参见网格线码笔示例。
CSS 函数
最小最大值
是一个非常有用的 CSS 函数,它接受两个参数:首先是最小值,然后是最大值。它对宽度和高度最有用,但它定义了任何尺寸范围。它可用于简单的媒体查询,用作参数的单位可具有混合测量值,如minmax(1fr, 25%)、minmax(auto, 25%)和minmax(10rem, max-content)。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 RmEwKa 。
重复
repeat()是一个速记功能,允许您重复给定的测量单位。它可以单独使用,也可以与其他大小声明一起使用。它还可以有多个空格分隔的第二个参数——换句话说,就是而不是逗号分隔的——并将连续重复该组值。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 VOqwye 。
calc
calc()计算指定等式的值。这个很厉害。事实上,几乎不可能找到一种更有效的方法来进行这些计算,尤其是对于混合单元。它使用加法、减法、乘法和除法。如果有必要,您也可以嵌套calc()函数。如果用于字体大小,最好指定一个单位作为相对长度单位,如rem或vw。目前,没有预处理器能够像本地calc()一样运行,因为它发生在渲染时。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 xNmbrR 。
最后,让我们看一个示例 CodePen 项目,它使用了上面所有的属性和值。有很多事情在进行,所以可以玩玩值和属性。检查你的理解。黑客快乐!
不要担心这个代码笔中的 JavaScript 部分。它用于使
rem单元匹配 CodePen 显示的根元素,而不是浏览器的根元素。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 oRJgpe 。
CSS 网格示例
这里有一些例子,我希望能帮助你理解 CSS 网格的概念,并理解它所产生的力量。
示例 1:“空白电视-无连接”
网格容器非常强大,但是想想看,在网格容器的position设置为absolute的情况下,你可以在网格上做的所有事情。下面的代码笔展示了grid-template-area、grid-template-rows和grid-template-columns属性以及fr和repeat属性的例子。它使用了两个层叠在一起的网格容器。一个容器有一个absolute的位置,将它放在主容器的顶部。
参见 CodePen 上 Michael Herman ( @mjhea0 )的 Pen TV 无信号屏幕- CSS 网格。
示例 2:“Alamo draft house-座位表”
你去过阿拉莫制图室吗?这是一个电影院。太棒了。无论如何,如果你在网上预订,有一个漂亮的小座位表可供选择。以下示例模拟了这种布局。它使用多个嵌套网格和网格线将最后一行居中移动。JavaScript 部分中有一些 jQuery 只是克隆了每个部分的座位,但这只是将我们从非常混乱的 HTML 部分中拯救出来。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔Alamo draft house Seating-CSS Grid。
示例 3:“应用程序布局响应”
这个例子是一个带有嵌套网格的基本 web 应用程序布局。它包括标题、导航、正文和页脚。主内容区是可滚动的,也有带图片的文章,使用了float。
有两个断点可以改变网格布局。有grid-template-areas、grid-template-rows、grid-template-columns、grid、gap等例子。
见 CodePen 上 Michael Herman ( @mjhea0 )的笔 ZNVGja 。
结论
CSS 网格牛逼又强大。能够控制行和列给你带来二维空间是非常棒的。这是一个不可思议的工具,但是当你着手一个正在进行的项目时,你应该有一整套的技巧。Grid 与上面的属性和值结合在一起,将使您在创建设计模板或模型时更加轻松。我发现自己甚至在使用包含布局助手(如 Bootstrap)的 CSS 框架时也在使用网格,并且发现它更容易、更快、更灵活。去看看 CodePen,搜索 CSS Gird,看看有哪些很酷的项目可以借鉴!
Django REST 框架中的自定义权限类
原文:https://testdriven.io/blog/custom-permission-classes-drf/
本文着眼于如何在 Django REST 框架(DRF)中构建自定义权限类。
--
Django REST 框架权限系列:
目标
完成本文后,您应该能够:
- 创建自定义权限类
- 解释在您的自定义权限类中何时使用
has_permission和has_object_permission - 当权限被拒绝时返回自定义错误消息
- 使用 and、OR 和 NOT 运算符组合和排除权限类
自定义权限类
如果您的应用程序有一些特殊要求,而内置权限类不能满足这些要求,那么是时候开始构建您自己的自定义权限了。
创建自定义权限允许您根据用户是否经过身份验证、请求方法、用户所属的组、对象属性、IP 地址...或者它们的任意组合。
所有权限类,无论是自定义的还是内置的,都是从BasePermission类扩展而来的:
`class BasePermission(metaclass=BasePermissionMetaclass):
"""
A base class from which all permission classes should inherit.
"""
def has_permission(self, request, view):
"""
Return `True` if permission is granted, `False` otherwise.
"""
return True
def has_object_permission(self, request, view, obj):
"""
Return `True` if permission is granted, `False` otherwise.
"""
return True`
BasePermission有两个方法,has_permission和has_object_permission,它们都返回True。权限类覆盖这些方法中的一个或两个,以有条件地返回 T4。如果不覆盖这些方法,它们将总是返回True,授予无限制的访问权限。
关于
has_permission与has_object_permission的更多信息,请务必阅读本系列的第一篇文章,Django REST 框架中的权限。
按照惯例,您应该将自定义权限放在一个 permissions.py 文件中。这只是一个约定,所以如果您需要以不同的方式组织权限,您不必这样做。
与内置权限一样,如果视图中使用的任何权限类从has_permission或has_object_permission返回False,则会引发PermissionDenied异常。若要更改与异常关联的错误信息,可以直接在自定义权限类上设置消息属性。
至此,让我们来看一些例子。
自定义权限示例
用户属性
您可能希望根据不同用户的属性为他们授予不同级别的访问权限,例如,他们是对象的创建者还是员工?
假设您不希望员工能够编辑对象。这种情况下的自定义权限类可能是这样的:
`# permissions.py
from rest_framework import permissions
class AuthorAllStaffAllButEditOrReadOnly(permissions.BasePermission):
edit_methods = ("PUT", "PATCH")
def has_permission(self, request, view):
if request.user.is_authenticated:
return True
def has_object_permission(self, request, view, obj):
if request.user.is_superuser:
return True
if request.method in permissions.SAFE_METHODS:
return True
if obj.author == request.user:
return True
if request.user.is_staff and request.method not in self.edit_methods:
return True
return False`
这里,AuthorAllStaffAllButEditOrReadOnly类扩展了BasePermission并覆盖了has_permission和has_object_permission。
拥有 _ 权限:
在has_permission中,只检查一件事:用户是否被认证。如果不是,则引发NotAuthenticated异常并拒绝访问。
拥有 _ 对象 _ 权限:
因为您不应该限制超级用户的访问,所以第一个检查- request.user.is_superuser -授予超级用户访问权限。
接下来,我们检查请求方法是否是“安全”的方法之一- request.method in permissions.SAFE_METHODS。安全方法在rest _ framework/permissions . py中定义:
`SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS')`
这些方法对对象没有影响;他们只能读它。
乍一看,SAFE_METHODS检查似乎应该在has_permission方法中。如果你只是检查请求方法,那么它就应该在那里。但是在这种情况下,不会执行其他检查:

因为我们想在方法是安全方法之一时授予访问权限或当用户是对象的作者或当用户是职员时,我们需要在相同的级别上检查它。换句话说,由于我们无法在has_permission级别检查所有者,所以我们需要在has_object_permission级别检查所有内容。
最后一种可能性是用户是职员:除了我们定义为edit_methods的方法之外,他们可以使用所有的方法。
最后,转回类名:AuthorAllStaffAllButEditOrReadOnly。您应该总是尝试尽可能多地命名权限类。
请记住,对于列表视图(无论从哪个视图扩展)或者当请求方法为
POST时,永远不会执行has_object_permission(因为对象尚不存在)。
使用自定义权限类的方式与使用内置权限类的方式相同:
`# views.py
from rest_framework import viewsets
from .models import Message
from .permissions import AuthorAllStaffAllButEditOrReadOnly
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [AuthorAllStaffAllButEditOrReadOnly] # Custom permission class used
queryset = Message.objects.all()
serializer_class = MessageSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)`
对象的作者对其具有完全访问权限。同时,员工可以删除对象,但不能编辑它:

经过身份验证的用户可以查看该对象,但不能编辑或删除它:

对象属性
虽然我们在前面的例子中简单地提到了对象的属性,但是重点更多地放在了用户的属性上(例如,对象的作者)。在这个例子中,我们将关注对象的属性。
一个或多个对象属性如何影响权限?
- 与前面的示例一样,您可以将访问权限仅限于对象的所有者。您还可以限制对所有者所属群组的访问。
- 对象可能有过期日期,因此您可以将对早于 n 的对象的访问权限仅限于某些用户。
- 您可以将 DELETE 实现为一个标志(这样它就不会真正从数据库中删除)。然后,您可以禁止访问带有删除标志的对象。
假设您希望限制除超级用户之外的所有人对超过 10 分钟的对象的访问:
`# permissions.py
from datetime import datetime, timedelta
from django.utils import timezone
from rest_framework import permissions
class ExpiredObjectSuperuserOnly(permissions.BasePermission):
def object_expired(self, obj):
expired_on = timezone.make_aware(datetime.now() - timedelta(minutes=10))
return obj.created < expired_on
def has_object_permission(self, request, view, obj):
if self.object_expired(obj) and not request.user.is_superuser:
return False
else:
return True`
在这个权限类中,has_permission方法没有被覆盖——所以它总是返回True。
因为唯一重要的属性是对象的创建时间,所以检查发生在has_object_permission(因为我们在has_permission中不能访问对象的属性)。
因此,如果用户想要访问过期的对象,就会引发异常PermissionDenied:

同样,和前面的例子一样,我们可以检查用户是否是has_permission中的超级用户,但是如果他们不是,那么对象的属性永远不会被检查。
记下错误消息。信息量不大。用户不知道为什么他们的访问被拒绝。我们可以通过向权限类添加一个message属性来创建一个定制的错误消息:
`class ExpiredObjectSuperuserOnly(permissions.BasePermission):
message = "This object is expired." # custom error message
def object_expired(self, obj):
expired_on = timezone.make_aware(datetime.now() - timedelta(minutes=10))
return obj.created < expired_on
def has_object_permission(self, request, view, obj):
if self.object_expired(obj) and not request.user.is_superuser:
return False
else:
return True`
现在,用户清楚地看到了权限被拒绝的原因:

组合和排除权限类
通常,当使用多个权限类时,可以在视图中定义它们,如下所示:
`permission_classes = [IsAuthenticated, IsStaff, SomeCustomPermissionClass]`
这种方法将它们结合在一起,这样只有当所有的类都返回True时才授予许可。
从 DRF 版本 3.9.0 开始,您还可以使用 AND ( &)或 OR ( |)逻辑操作符来组合多个类。另外,从 3.9.2 开始,支持 NOT ( ~)运算符。
这些运算符不限于自定义权限类。它们也可以和内置的一起使用。
您可以创建更简单的类并将它们与前面提到的操作符结合起来,而不是创建许多彼此相似的复杂权限类。
例如,您可能对不同的组组合拥有不同的权限。假设您想要以下权限:
- A 组或 B 组的权限
- B 组或 C 组的权限
- B 和 C 成员的权限
- 除 A 以外的所有组的权限
虽然四个许可类看起来不多,但这不会扩展得很好。如果你有八个不同的组- A,B,C,D,E,F,G,会怎么样?它会迅速膨胀到一个无法理解和维持的程度。
您可以简化它,并通过首先为组 A、B 和 c 创建权限类,将它们与操作符结合起来。
permission_classes = [PermGroupA | PermGroupB]permission_classes = [PermGroupB | PermGroupC]permission_classes = [PermGroupB & PermGroupC]permission_classes = [~PermGroupA]
当涉及到 OR (
|)时,事情会变得稍微复杂一些。错误经常会被忽略。更多信息,请查看关于权限的讨论:允许权限被组合拉请求。
逻辑积算符
并且是权限类的默认行为,通过使用,实现:
`permission_classes = [IsAuthenticated, IsStaff, SomeCustomPermissionClass]`
也可以用&写:
`permission_classes = [IsAuthenticated & IsStaff & SomeCustomPermissionClass]`
OR 运算符
使用 OR ( |),当任何权限类返回True时,该权限被授予。您可以使用 OR 运算符提供多种可能性,让用户获得权限。
让我们看一个例子,对象的所有者或职员都可以编辑或删除对象。
我们需要两节课:
IsStaff返回True如果用户is_staff- 如果用户与
obj.author相同,则IsOwner返回True
代码:
`class IsStaff(permissions.BasePermission):
def has_permission(self, request, view):
if request.user.is_staff:
return True
return False
def has_object_permission(self, request, view, obj):
if request.user.is_staff:
return True
return False
class IsOwner(permissions.BasePermission):
def has_permission(self, request, view):
if request.user.is_authenticated:
return True
return False
def has_object_permission(self, request, view, obj):
if obj.author == request.user:
return True
return False`
这里有相当多的冗余,但这是必要的。
为什么?
-
用于覆盖列表视图
同样,列表视图不检查
has_object_permission。然而,每个创建的权限都需要是独立的。您不应该创建需要与另一个权限类组合来覆盖列表视图的权限类。IsOwner限制对has_permission中认证用户的访问——因此,如果IsOwner是唯一使用的类,对 API 的访问仍然是受控的。
*** 默认情况下,这两种方法都返回True
当使用 OR 时,如果不提供`has_object_permission`方法,用户将可以访问对象,尽管他们不应该这样做。
> 注意事项:
>
> * 如果您省略了`IsOwner`类中的`has_permission`,任何人都可以看到或创建列表。
>
>
> * 如果在`IsStaff`上省略`has_object_permission`,用`or`与`IsOwner`组合,则二者必有一个返回`True`。这样,既不是所有者也不是员工的注册用户就可以更改内容。**
**现在,当我们很好地设计了权限类后,就很容易将它们组合起来:
`from rest_framework import viewsets
from .models import Message
from .permissions import IsStaff, IsOwner
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [IsStaff | IsOwner] # or operator used
queryset = Message.objects.all()
serializer_class = MessageSerializer`
在这里,我们允许员工或对象的所有者更改或删除它。
IsOwner对列表视图的唯一要求是用户被认证。这意味着不是职员的认证用户将能够创建对象。
“非”算符
NOT 运算符导致与定义的权限类完全相反的结果。换句话说,除了来自权限类的用户之外,权限被授予所有的用户。
假设您有三组用户:
- 技术
- 管理
- 资产
这些组中的每一个都应该能够访问只属于他们特定组的 API 端点。
下面是一个权限类,它只授予财务组成员访问权限:
`class IsFinancesMember(permissions.BasePermission):
def has_permission(self, request, view):
if request.user.groups.filter(name="Finances").exists():
return True`
现在,假设您有一个新视图,它是为不属于财务组的所有用户准备的。您可以使用 NOT 运算符来实现这一点:
`from rest_framework import viewsets
from .models import Message
from .permissions import IsFinancesMember
from .serializers import MessageSerializer
class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [~IsFinancesMember] # using not operator
queryset = Message.objects.all()
serializer_class = MessageSerializer`
因此,只有财务组的成员不能访问。
小心点!如果您只使用 NOT 操作符,其他所有人都将被允许访问,包括未认证的用户!如果这不是您想要做的,您可以通过添加另一个类来解决这个问题,如下所示:
permission_classes = [~IsFinancesMember & IsAuthenticated]
圆括号
在permission_classes中,你也可以使用圆括号(())来控制哪个表达式先被解析。
快速示例:
`class MessageViewSet(viewsets.ModelViewSet):
permission_classes = [(IsFinancesMember | IsTechMember) & IsOwner] # using parentheses
queryset = Message.objects.all()
serializer_class = MessageSerializer`
在本例中,(IsFinancesMember | IsTechMember)将首先被解析。然后,其结果将与& IsOwner一起使用,例如ResultsFromFinancesOrTech & IsOwner。这意味着技术组或财务组的成员以及对象的所有者将被授予访问权限。
结论
尽管有各种各样的内置权限类,但有些情况下它们不符合您的需要。这时自定义权限类就派上了用场。
对于自定义权限类,您必须重写以下一种或两种方法:
has_permissionhas_object_permission
如果在has_permission方法中没有授予权限,那么在has_object_permission中写什么都不重要——权限被拒绝。如果你不覆盖它们中的一个(或两个),你需要考虑到默认情况下,方法将总是返回True。
您可以使用 and、OR 和 NOT 运算符组合和排除权限类。您甚至可以用括号决定权限解析的顺序。
--
Django REST 框架权限系列:
使用 Kubernetes 将节点应用部署到 Google Cloud
原文:https://testdriven.io/blog/deploying-a-node-app-to-google-cloud-with-kubernetes/
让我们看看如何在谷歌 Kubernetes 引擎 (GKE)上将节点/快速微服务(以及 Postgres)部署到 Kubernetes 集群。
依赖关系:
- 文档版本 20.10.10
- Kubectl v1.20.8
- 谷歌云 SDK v365.0.1
本文假设您对 Docker 有基本的了解,并且对微服务有一个总体的了解。查看包含 Docker、Flask 和 React 课程包的微服务,了解更多信息。
目标
学完本教程后,您应该能够:
- 解释什么是容器编排,以及为什么需要使用编排工具
- 讨论与 Docker Swarm 和 AWS 弹性容器服务(ECS)等其他编排工具相比,使用 Kubernetes 的利弊
- 解释以下 Kubernetes 原语:节点、Pod、服务、标签、部署、入口和卷
- 使用 Docker Compose 在本地构建基于节点的微服务
- 配置一个 Kubernetes 集群在谷歌云平台(GCP)上运行
- 设置一个卷来保存 Kubernetes 集群中的 Postgres 数据
- 使用 Kubernetes 的秘密来管理敏感信息
- 在 Kubernetes 上运行 Node 和 Postgres
- 通过负载平衡器向外部用户公开节点 API
什么是容器编排?
当您从在单台机器上部署容器转移到在多台机器上部署容器时,您将需要一个编排工具来管理(并自动化)容器在整个系统中的安排、协调和可用性。
编排工具有助于:
- 跨服务器容器通信
- 水平缩放
- 服务发现
- 负载平衡
- 安全性/TLS
- 零停机部署
- 卷回
- 记录
- 监视
这就是 Kubernetes 与其他一些编排工具的契合之处,比如 T2、Docker Swarm、T4、ECS、Mesos 和 Nomad。
你应该用哪一个?
- 如果您需要管理大型、复杂的集群,请使用 Kubernetes
- 如果您刚刚起步和/或需要管理中小型集群,请使用 Docker Swarm
- 如果您已经在使用一些 AWS 服务,请使用 ECS
| 工具 | 赞成的意见 | 骗局 |
|---|---|---|
| 库伯内特斯 | 大型社区,灵活,大多数功能,时尚 | 复杂的设置、高学习曲线、hip |
| 码头工人群 | 易于设置,非常适合小型集群 | 受 Docker API 的限制 |
| 精英公司 | 全面管理的服务,与 AWS 集成 | 供应商锁定 |
市场上还有许多由 Kubernetes 管理的服务:
- 谷歌 Kubernetes 引擎 (GKE)
- 弹性集装箱服务 (EKS)
- Azure Kubernetes 服务公司
更多信息,请查看选择正确的容器化和集群管理工具博文。
不可思议的概念
在开始之前,让我们先来看看一些您必须使用的来自 Kubernetes API 的基本构件:
- 一个 节点 是一个用于运行 Kubernetes 的工作机。每个节点都由 Kubernetes 主节点管理。
- 一个 Pod 是在一个节点上运行的一组逻辑紧密耦合的应用程序容器。Pod 中的容器部署在一起并共享资源(如数据量和网络地址)。多个单元可以在一个节点上运行。
- 一个 服务 是执行类似功能的一组逻辑单元。它支持负载平衡和服务发现。它是豆荚上的一个抽象层;豆荚是短暂的,而服务是持久的。
- 部署 用于描述 Kubernetes 的期望状态。它们规定了如何创建、部署和复制 pod。
- 标签 是附属于资源(如 pod)的键/值对,用于组织相关资源。你可以把它们想象成 CSS 选择器。例如:
- 环境 -
dev,test,prod - App 版本 -
beta,1.2.1 - 类型 -
client,server,db
- 环境 -
- Ingress 是一组路由规则,用于根据请求主机或路径控制外部对服务的访问。
- 卷 用于保存容器寿命之外的数据。它们对于像 Redis 和 Postgres 这样的有状态应用程序尤其重要。
更多信息,请查看学习 Kubernetes 基础知识教程。
项目设置
从从https://github.com/testdrivenio/node-kubernetes回购克隆应用程序开始:
`$ git clone https://github.com/testdrivenio/node-kubernetes
$ cd node-kubernetes`
构建映像并旋转容器:
`$ docker-compose up -d --build`
应用迁移并为数据库设定种子:
`$ docker-compose exec web knex migrate:latest
$ docker-compose exec web knex seed:run`
测试以下端点...
获取所有待办事项:
`$ curl http://localhost:3000/todos
[
{
"id": 1,
"title": "Do something",
"completed": false
},
{
"id": 2,
"title": "Do something else",
"completed": false
}
]`
添加新的待办事项:
`$ curl -d '{"title":"something exciting", "completed":"false"}' \
-H "Content-Type: application/json" -X POST http://localhost:3000/todos
"Todo added!"`
获取一个待办事项:
`$ curl http://localhost:3000/todos/3
[
{
"id": 3,
"title": "something exciting",
"completed": false
}
]`
更新待办事项:
`$ curl -d '{"title":"something exciting", "completed":"true"}' \
-H "Content-Type: application/json" -X PUT http://localhost:3000/todos/3
"Todo updated!"`
删除待办事项:
`$ curl -X DELETE http://localhost:3000/todos/3`
在继续之前,快速浏览一下代码:
`├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── docker-compose.yml
├── knexfile.js
├── kubernetes
│ ├── node-deployment-updated.yaml
│ ├── node-deployment.yaml
│ ├── node-service.yaml
│ ├── postgres-deployment.yaml
│ ├── postgres-service.yaml
│ ├── secret.yaml
│ ├── volume-claim.yaml
│ └── volume.yaml
├── package-lock.json
├── package.json
└── src
├── db
│ ├── knex.js
│ ├── migrations
│ │ └── 20181009160908_todos.js
│ └── seeds
│ └── todos.js
└── server.js`
Google 云设置
在这一部分,我们将-
在开始之前,你需要一个谷歌云平台 (GCP)账户。如果你是 GCP 的新用户,谷歌提供了一个价值 300 美元的免费试用。
从安装谷歌云 SDK 开始。
如果你在 Mac 上,我们建议安装带有自制软件的 SDK:
$ brew update $ brew install google-cloud-sdk --cask
测试:
`$ gcloud --version
Google Cloud SDK 365.0.1
bq 2.0.71
core 2021.11.19
gsutil 5.5`
安装后,运行gcloud init来配置 SDK,以便它可以访问您的 GCP 凭证。你还需要选择一个现有的 GCP 项目或者创建一个新的项目。
设置项目:
`$ gcloud config set project <PROJECT_ID>`
最后,安装kubectl:
`$ gcloud components install kubectl`
不可思议的群集
接下来,我们在 Kubernetes 引擎上创建一个集群:
`$ gcloud container clusters create node-kubernetes \
--num-nodes=3 --zone us-central1-a --machine-type g1-small`
这将在具有g1-small 机器的us-central1-a 区域中创建一个名为node-kubernetes的三节点集群。旋转起来需要几分钟。
`$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
gke-node-kubernetes-default-pool-139e0343-0hbt Ready <none> 75s v1.21.5-gke.1302
gke-node-kubernetes-default-pool-139e0343-p4s3 Ready <none> 75s v1.21.5-gke.1302
gke-node-kubernetes-default-pool-139e0343-rxnc Ready <none> 75s v1.21.5-gke.1302`

将kubectl客户端连接到集群:
`$ gcloud container clusters get-credentials node-kubernetes --zone us-central1-a
Fetching cluster endpoint and auth data.
kubeconfig entry generated for node-kubernetes.`
有关 Kubernetes 引擎的帮助,请查看官方文档。
坞站注册表
使用gcr.io/<PROJECT_ID>/<IMAGE_NAME>:<TAG> Docker 标签格式,为节点 API 构建本地 Docker 映像,然后将其推送到容器注册表:
`$ gcloud auth configure-docker
$ docker build -t gcr.io/<PROJECT_ID>/node-kubernetes:v0.0.1 .
$ docker push gcr.io/<PROJECT_ID>/node-kubernetes:v0.0.1`
确保用项目的 ID 替换
<PROJECT_ID>。

节点设置
kubernetes/node-deployment . YAML:
`apiVersion: apps/v1 kind: Deployment metadata: name: node labels: name: node spec: replicas: 1 selector: matchLabels: app: node template: metadata: labels: app: node spec: containers: - name: node image: gcr.io/<PROJECT_ID>/node-kubernetes:v0.0.1 env: - name: NODE_ENV value: "development" - name: PORT value: "3000" restartPolicy: Always`
同样,一定要用项目的 ID 替换
<PROJECT_ID>。
这里发生了什么事?
metadataname字段定义了部署名称-nodelabels为部署定义标签-name: node
specreplicas定义要运行的 pod 数量-1selector指定窗格的标签(必须与.spec.template.metadata.labels匹配)templatemetadatalabels指出哪些标签应该分配给 pod -app: node
speccontainers定义与每个 pod 相关的容器restartPolicy定义了重启策略 -Always
因此,这将通过我们刚刚上传的gcr.io/<PROJECT_ID>/node-kubernetes:v0.0.1图像旋转一个名为node的 pod。
创建:
`$ kubectl create -f ./kubernetes/node-deployment.yaml`
验证:
`$ kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
node 1/1 1 1 32s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
node-59646c8856-72blj 1/1 Running 0 18s`
您可以通过kubectl logs <POD_NAME>查看容器日志:
`$ kubectl logs node-6fbfd984d-7pg92
> start
> nodemon src/server.js
[nodemon] 2.0.15
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node src/server.js`
Listening on port: 3000`
您也可以从 Google Cloud 控制台查看这些资源:

为了从外部访问您的 API,让我们通过一个服务创建一个负载平衡器。
库柏/节点服务。yaml :
`apiVersion: v1 kind: Service metadata: name: node labels: service: node spec: selector: app: node type: LoadBalancer ports: - port: 3000`
这将创建一个名为node的 serviced,它将找到任何带有标签node的 pod,并将端口暴露给外界。
创建:
`$ kubectl create -f ./kubernetes/node-service.yaml`
这将在 Google Cloud 上创建一个新的负载平衡器:

获取外部 IP:
`$ kubectl get service node
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
node LoadBalancer 10.40.10.162 35.222.45.193 3000:31315/TCP 78s`
测试一下:
当您点击第二个端点时,您应该会看到"Something went wrong.",因为数据库还没有设置。
秘密
秘密用于管理敏感信息,如密码、API 令牌和 SSH 密钥。我们将利用一个秘密来存储我们的 Postgres 数据库凭证。
立方/秘密。yaml :
`apiVersion: v1 kind: Secret metadata: name: postgres-credentials type: Opaque data: user: c2FtcGxl password: cGxlYXNlY2hhbmdlbWU=`
用户和密码字段是 base64 编码的字符串:
`$ echo -n "pleasechangeme" | base64
cGxlYXNlY2hhbmdlbWU=
$ echo -n "sample" | base64
c2FtcGxl`
创造秘密:
`$ kubectl apply -f ./kubernetes/secret.yaml`
验证:
`$ kubectl describe secret postgres-credentials
Name: postgres-credentials
Namespace: default
Labels: <none>
Annotations: <none>
Type: Opaque
Data
====
password: 14 bytes
user: 6 bytes`

卷
由于容器是短暂的,我们需要配置一个卷,通过一个 PersistentVolume 和一个 PersistentVolumeClaim 来存储 pod 外部的 Postgres 数据。如果没有卷,当 pod 关闭时,您将会丢失数据。
创建一个持久磁盘:
`$ gcloud compute disks create pg-data-disk --size 50GB --zone us-central1-a`

立方/体积。yaml :
`apiVersion: v1 kind: PersistentVolume metadata: name: postgres-pv labels: name: postgres-pv spec: capacity: storage: 50Gi storageClassName: standard accessModes: - ReadWriteOnce gcePersistentDisk: pdName: pg-data-disk fsType: ext4`
该配置将创建一个 50gb 的 PersistentVolume,其访问模式为 ReadWriteOnce ,这意味着该卷可以由单个节点以读写方式装载。
创建卷:
`$ kubectl apply -f ./kubernetes/volume.yaml`
检查状态:
`$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
postgres-pv 50Gi RWO Retain Available standard 6s`

立方/体积索赔. yaml :
`apiVersion: v1 kind: PersistentVolumeClaim metadata: name: postgres-pvc labels: type: local spec: accessModes: - ReadWriteOnce resources: requests: storage: 50Gi volumeName: postgres-pv`
这将在 PersistentVolume(我们刚刚创建的)上创建一个声明,Postgres pod 将能够使用该声明来连接一个卷。
创建:
`$ kubectl apply -f ./kubernetes/volume-claim.yaml`
查看:
`$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
postgres-pvc Bound postgres-pv 50Gi RWO standard 6s`

Postgres 设置
随着数据库凭证和卷的建立,我们现在可以配置 Postgres 数据库本身。
库柏人/研究生部署。yaml :
`apiVersion: apps/v1 kind: Deployment metadata: name: postgres labels: name: database spec: replicas: 1 selector: matchLabels: service: postgres template: metadata: labels: service: postgres spec: containers: - name: postgres image: postgres:14-alpine volumeMounts: - name: postgres-volume-mount mountPath: /var/lib/postgresql/data subPath: postgres env: - name: POSTGRES_USER valueFrom: secretKeyRef: name: postgres-credentials key: user - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-credentials key: password restartPolicy: Always volumes: - name: postgres-volume-mount persistentVolumeClaim: claimName: postgres-pvc`
在这里,除了通过postgres:14-alpine映像构建一个新的 pod,这个配置还将 PersistentVolumeClaim 从volumes部分安装到在volumeMounts部分定义的“/var/lib/postgresql/data”目录中。
创建:
`$ kubectl create -f ./kubernetes/postgres-deployment.yaml`
验证:
`$ kubectl get pods
NAME READY STATUS RESTARTS AGE
node-59646c8856-72blj 1/1 Running 0 20m
postgres-64d485d86b-vtrlh 1/1 Running 0 25s`

创建todos数据库:
`$ kubectl exec <POD_NAME> --stdin --tty -- createdb -U sample todos`
立方/研究生服务。yaml :
`apiVersion: v1 kind: Service metadata: name: postgres labels: service: postgres spec: selector: service: postgres type: ClusterIP ports: - port: 5432`
这将创建一个 ClusterIP 服务,以便其他 pods 可以连接到它。它不会在群集外部提供。
创建服务:
`$ kubectl create -f ./kubernetes/postgres-service.yaml`

更新节点部署
接下来,将数据库凭据添加到节点部署中:
kubernetes/node-deployment-updated . YAML:
`apiVersion: apps/v1 kind: Deployment metadata: name: node labels: name: node spec: replicas: 1 selector: matchLabels: app: node template: metadata: labels: app: node spec: containers: - name: node image: gcr.io/<PROJECT_ID>/node-kubernetes:v0.0.1 # update env: - name: NODE_ENV value: "development" - name: PORT value: "3000" - name: POSTGRES_USER valueFrom: secretKeyRef: name: postgres-credentials key: user - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-credentials key: password restartPolicy: Always`
创建:
`$ kubectl delete -f ./kubernetes/node-deployment.yaml
$ kubectl create -f ./kubernetes/node-deployment-updated.yaml`
验证:
`$ kubectl get pods
NAME READY STATUS RESTARTS AGE
node-64c45d449b-9m7pf 1/1 Running 0 9s
postgres-64d485d86b-vtrlh 1/1 Running 0 4m7s`
使用节点窗格更新数据库:
`$ kubectl exec <POD_NAME> knex migrate:latest
$ kubectl exec <POD_NAME> knex seed:run`
再次测试:
您现在应该可以看到 todos:
`[
{
"id": 1,
"title": "Do something",
"completed": false
},
{
"id": 2,
"title": "Do something else",
"completed": false
}
]`
结论
在这篇文章中,我们看了如何和 GKE 一起在 Kubernetes 上运行基于节点的微服务。现在,您应该对 Kubernetes 的工作原理有了基本的了解,并且能够将运行有应用程序的集群部署到 Google Cloud。
完成后,确保关闭资源(集群、永久磁盘、容器注册表上的映像),以避免产生不必要的费用:
`$ kubectl delete -f ./kubernetes/node-service.yaml
$ kubectl delete -f ./kubernetes/node-deployment-updated.yaml
$ kubectl delete -f ./kubernetes/secret.yaml
$ kubectl delete -f ./kubernetes/volume-claim.yaml
$ kubectl delete -f ./kubernetes/volume.yaml
$ kubectl delete -f ./kubernetes/postgres-deployment.yaml
$ kubectl delete -f ./kubernetes/postgres-service.yaml
$ gcloud container clusters delete node-kubernetes --zone us-central1-a
$ gcloud compute disks delete pg-data-disk --zone us-central1-a
$ gcloud container images delete gcr.io/<PROJECT_ID>/node-kubernetes:v0.0.1`
其他资源:
你可以在 GitHub 上的节点-kubernetes repo 中找到代码。
通过 Docker 和 GitHub 操作不断将 Django 部署到数字海洋
原文:https://testdriven.io/blog/deploying-django-to-digitalocean-with-docker-and-github-actions/
在本教程中,我们将了解如何配置 GitHub 操作,以便将 Django 和 Docker 应用程序持续部署到 DigitalOcean。
依赖关系:
- Django v3.2.4
- Docker v20.04
- python 3 . 9 . 5 版
目标
本教程结束时,您将能够:
- 使用 Docker 将 Django 部署到数字海洋
- 配置 GitHub 操作以持续将 Django 部署到数字海洋
- 使用 GitHub 包来存储 Docker 图像
- 设置无密码 SSH 登录
- 为数据持久性配置数字海洋的托管数据库
项目设置
除了 Django 和 Docker,我们将使用的演示项目还包括 Postgres 、 Nginx 和 Gunicorn 。
好奇这个项目是怎么开发出来的?查看 Postgres、Gunicorn 和 Nginx 的博客文章。
从克隆基础项目开始:
`$ git clone https://github.com/testdrivenio/django-github-digitalocean.git --branch base --single-branch
$ cd django-github-digitalocean`
要进行本地测试,构建映像并旋转容器:
`$ docker-compose up -d --build`
导航到 http://localhost:8000/ 。您应该看到:
GitHub 包
GitHub Packages 是一个包管理服务,与 GitHub 完全集成。它允许你公开或私下托管你的软件包,在你的 GitHub 项目中使用。我们将使用它来存储 Docker 图像。
假设您在 GitHub 上有一个帐户,为这个项目创建一个新的存储库,并更新本地项目的 origin remote 以指向您刚刚创建的存储库。
要进行本地测试,您需要创建一个个人访问令牌。在您的开发者设置中,点击“个人访问令牌”。然后,单击“生成新令牌”。提供描述性注释并选择以下范围:
write:packagesread:packagesdelete:packages

记下令牌。
构建并标记图像:
`$ docker build -f app/Dockerfile -t ghcr.io/<USERNAME>/<REPOSITORY_NAME>/web:latest ./app
# example:
# docker build -f app/Dockerfile -t ghcr.io/testdrivenio/django-github-digitalocean/web:latest ./app`
接下来,使用您的个人访问令牌,向 Docker 认证GitHub 包:
`$ docker login ghcr.io -u <USERNAME> -p <TOKEN>
# example:
# docker login ghcr.io -u testdrivenio -p ce70f1d4a3a906ce8ac24caa6870fd29f2273d30`
将图像推送到 GitHub 包的容器注册表:
`$ docker push ghcr.io/<USERNAME>/<REPOSITORY_NAME>/web:latest
# example:
# docker push ghcr.io/testdrivenio/django-github-digitalocean/web:latest`
现在,您应该可以在以下网址之一看到该包(取决于您使用的是个人帐户还是组织):
`https://github.com/orgs/<USERNAME>/packages
https://github.com/<USERNAME>?tab=packages`

数字海洋
让我们设置 DigitalOcean 来使用我们的应用程序。
首先,你需要注册一个数字海洋账户(如果你还没有的话),然后生成一个访问令牌,这样你就可以访问数字海洋 API 。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
微滴
接下来,使用预装的 Docker 创建一个新的 Droplet:
`$ curl -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
-d '{"name":"django-docker","region":"sfo3","size":"s-2vcpu-4gb","image":"docker-20-04"}' \
"https://api.digitalocean.com/v2/droplets"`
检查状态:
`$ curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/droplets?name=django-docker"`
如果您安装了 jq ,那么您可以像这样解析 JSON 响应:
`$ curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/droplets?name=django-docker" \
| jq '.droplets[0].status'`
root 密码应该会通过电子邮件发送给您。找回它。然后,一旦 droplet 的状态为active,就以 root 身份 SSH 到实例中,并在提示时更新密码。
接下来,生成一个新的 SSH 密钥:
将密钥保存到 /root/。ssh/id_rsa 并且不设置密码。这将分别生成一个公钥和私钥- id_rsa 和 id_rsa.pub 。要设置无密码 SSH 登录,请将公钥复制到 authorized_keys 文件中,并设置适当的权限:
`$ cat ~/.ssh/id_rsa.pub
$ vi ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/id_rsa`
复制私钥的内容:
退出 SSH 会话,然后将密钥设置为本地计算机上的环境变量:
`export PRIVATE_KEY='-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA04up8hoqzS1+APIB0RhjXyObwHQnOzhAk5Bd7mhkSbPkyhP1
...
iWlX9HNavcydATJc1f0DpzF0u4zY8PY24RVoW8vk+bJANPp1o2IAkeajCaF3w9nf
q/SyqAWVmvwYuIhDiHDaV2A==
-----END RSA PRIVATE KEY-----'`
将密钥添加到 ssh-agent 中:
`$ ssh-add - <<< "${PRIVATE_KEY}"`
要进行测试,请运行:
然后,为应用程序创建一个新目录:
数据库ˌ资料库
接下来,让我们通过数字海洋的托管数据库建立一个生产 Postgres 数据库:
`$ curl -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
-d '{"name":"django-docker-db","region":"sfo3","engine":"pg","version":"13","size":"db-s-2vcpu-4gb","num_nodes":1}' \
"https://api.digitalocean.com/v2/databases"`
检查状态:
`$ curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/databases?name=django-docker-db" \
| jq '.databases[0].status'`
它应该需要几分钟才能旋转起来。一旦状态为online,获取连接信息:
`$ curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/databases?name=django-docker-db" \
| jq '.databases[0].connection'`
示例响应:
`{
"protocol": "postgresql",
"uri": "postgresql://doadmin:[[email protected]](/cdn-cgi/l/email-protection)locean.com:25060/defaultdb?sslmode=require",
"database": "defaultdb",
"host": "django-docker-db-do-user-778274-0.a.db.ondigitalocean.com",
"port": 25060,
"user": "doadmin",
"password": "v60qcyaito1i0h66",
"ssl": true
}`
GitHub 操作
要配置 GitHub 动作,首先添加一个名为。github”在您的项目的根目录中。在该目录中添加另一个名为“workflows”的目录。现在,要配置由一个或多个作业组成的工作流,在“工作流”目录中创建一个名为 main.yml 的新文件。
构建作业
`name: Continuous Integration and Delivery on: [push] env: WEB_IMAGE: ghcr.io/$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')/web NGINX_IMAGE: ghcr.io/$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')/nginx jobs: build: name: Build Docker Images runs-on: ubuntu-latest steps: - name: Checkout master uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - name: Add environment variables to .env run: | echo DEBUG=0 >> .env echo SQL_ENGINE=django.db.backends.postgresql >> .env echo DATABASE=postgres >> .env echo SECRET_KEY=${{ secrets.SECRET_KEY }} >> .env echo SQL_DATABASE=${{ secrets.SQL_DATABASE }} >> .env echo SQL_USER=${{ secrets.SQL_USER }} >> .env echo SQL_PASSWORD=${{ secrets.SQL_PASSWORD }} >> .env echo SQL_HOST=${{ secrets.SQL_HOST }} >> .env echo SQL_PORT=${{ secrets.SQL_PORT }} >> .env - name: Set environment variables run: | echo "WEB_IMAGE=$(echo ${{env.WEB_IMAGE}} )" >> $GITHUB_ENV echo "NGINX_IMAGE=$(echo ${{env.NGINX_IMAGE}} )" >> $GITHUB_ENV - name: Log in to GitHub Packages run: echo ${PERSONAL_ACCESS_TOKEN} | docker login ghcr.io -u ${{ secrets.NAMESPACE }} --password-stdin env: PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - name: Pull images run: | docker pull ${{ env.WEB_IMAGE }} || true docker pull ${{ env.NGINX_IMAGE }} || true - name: Build images run: | docker-compose -f docker-compose.ci.yml build - name: Push images run: | docker push ${{ env.WEB_IMAGE }} docker push ${{ env.NGINX_IMAGE }}`
这里,我们定义了一个build 作业,它将在我们:
- 设置全局环境变量
WEB_IMAGE和NGINX_IMAGE - 签出存储库,以便作业可以访问它
- 给一个添加环境变量。环境文件
- 用设置
WEB_IMAGE和NGINX_IMAGE环境变量,这样就可以在 Docker 合成文件中访问它们 - 登录 GitHub 包
- 如果图像存在,请提取图像
- 构建图像
- 将图片上传到 GitHub 包
你注意到那些秘密了吗?
secrets.SECRET_KEYsecrets.SQL_DATABASEsecrets.SQL_USERsecrets.SQL_PASSWORDsecrets.SQL_HOSTsecrets.SQL_PORTsecrets.NAMESPACEsecrets.PERSONAL_ACCESS_TOKEN
这些需要在你的储存库的秘密中设置(设置>秘密)。根据上面的数据库连接信息,添加前六个变量。
例如:
SECRET_KEY:9zYGEFk2mn3mWB8Bmg9SAhPy6F4s7cCuT8qaYGVEnu7huGRKW9SQL_DATABASE:defaultdbSQL_HOST:django-docker-db-do-user-778274-0.a.db.ondigitalocean.comSQL_PORT:25060SQL_USER:doadminSQL_PASSWORD:v60qcyaito1i0h66
对NAMESPACE使用您的 Github 用户名或您的组织名称,对PERSONAL_ACCESS_TOKEN变量使用您的个人访问令牌。

完成后,提交代码并上传到 GitHub 以触发新的构建。确保它通过。您应该会看到 GitHub 包中的图像:

部署作业
接下来,添加一个deploy任务:
`deploy: name: Deploy to DigitalOcean runs-on: ubuntu-latest needs: build steps: - name: Checkout master uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - name: Add environment variables to .env run: | echo DEBUG=0 >> .env echo SQL_ENGINE=django.db.backends.postgresql >> .env echo DATABASE=postgres >> .env echo SECRET_KEY=${{ secrets.SECRET_KEY }} >> .env echo SQL_DATABASE=${{ secrets.SQL_DATABASE }} >> .env echo SQL_USER=${{ secrets.SQL_USER }} >> .env echo SQL_PASSWORD=${{ secrets.SQL_PASSWORD }} >> .env echo SQL_HOST=${{ secrets.SQL_HOST }} >> .env echo SQL_PORT=${{ secrets.SQL_PORT }} >> .env echo WEB_IMAGE=${{ env.WEB_IMAGE }} >> .env echo NGINX_IMAGE=${{ env.NGINX_IMAGE }} >> .env echo NAMESPACE=${{ secrets.NAMESPACE }} >> .env echo PERSONAL_ACCESS_TOKEN=${{ secrets.PERSONAL_ACCESS_TOKEN }} >> .env - name: Add the private SSH key to the ssh-agent env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | mkdir -p ~/.ssh ssh-agent -a $SSH_AUTH_SOCK > /dev/null ssh-keyscan github.com >> ~/.ssh/known_hosts ssh-add - <<< "${{ secrets.PRIVATE_KEY }}" - name: Build and deploy images on DigitalOcean env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | scp -o StrictHostKeyChecking=no -r ./.env ./docker-compose.prod.yml [[email protected]](/cdn-cgi/l/email-protection)${{ secrets.DIGITAL_OCEAN_IP_ADDRESS }}:/app ssh -o StrictHostKeyChecking=no [[email protected]](/cdn-cgi/l/email-protection)${{ secrets.DIGITAL_OCEAN_IP_ADDRESS }} << 'ENDSSH' cd /app source .env docker login ghcr.io -u $NAMESPACE -p $PERSONAL_ACCESS_TOKEN docker pull $WEB_IMAGE docker pull $NGINX_IMAGE docker-compose -f docker-compose.prod.yml up -d ENDSSH`
因此,在deploy作业中,只有当build作业成功完成时才会运行(通过needs: build,我们:
- 签出存储库,以便作业可以访问它
- 给一个添加环境变量。环境文件
- 将私有 SSH 密钥添加到 ssh-agent 并运行代理
- 复制完。env 和 docker-compose.prod.yml 文件到远程服务器
- SSH 到数字海洋上的删除服务器
- 导航到部署目录并设置环境变量
- 登录 GitHub 包
- 调出图像
- 旋转容器
- 结束 SSH 会话
将DIGITAL_OCEAN_IP_ADDRESS和PRIVATE_KEY秘密添加到 GitHub。然后,将服务器的 IP 地址添加到 Django 设置中的ALLOWED_HOSTS列表中。
提交并推送您的代码以触发新的构建。构建通过后,导航到实例的 IP。您应该看到:
试验
最后,通过在needs: build下面添加if: github.ref == 'refs/heads/master'来更新deploy作业,以便它只在对master分支进行更改时运行:
`deploy: name: Deploy to DigitalOcean runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/master' steps: - name: Checkout master uses: actions/[[email protected]](/cdn-cgi/l/email-protection) - name: Add environment variables to .env run: | echo DEBUG=0 >> .env echo SQL_ENGINE=django.db.backends.postgresql >> .env echo DATABASE=postgres >> .env echo SECRET_KEY=${{ secrets.SECRET_KEY }} >> .env echo SQL_DATABASE=${{ secrets.SQL_DATABASE }} >> .env echo SQL_USER=${{ secrets.SQL_USER }} >> .env echo SQL_PASSWORD=${{ secrets.SQL_PASSWORD }} >> .env echo SQL_HOST=${{ secrets.SQL_HOST }} >> .env echo SQL_PORT=${{ secrets.SQL_PORT }} >> .env echo WEB_IMAGE=${{ env.WEB_IMAGE }} >> .env echo NGINX_IMAGE=${{ env.NGINX_IMAGE }} >> .env echo NAMESPACE=${{ secrets.NAMESPACE }} >> .env echo PERSONAL_ACCESS_TOKEN=${{ secrets.PERSONAL_ACCESS_TOKEN }} >> .env - name: Add the private SSH key to the ssh-agent env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | mkdir -p ~/.ssh ssh-agent -a $SSH_AUTH_SOCK > /dev/null ssh-keyscan github.com >> ~/.ssh/known_hosts ssh-add - <<< "${{ secrets.PRIVATE_KEY }}" - name: Build and deploy images on DigitalOcean env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | scp -o StrictHostKeyChecking=no -r ./.env ./docker-compose.prod.yml [[email protected]](/cdn-cgi/l/email-protection)${{ secrets.DIGITAL_OCEAN_IP_ADDRESS }}:/app ssh -o StrictHostKeyChecking=no [[email protected]](/cdn-cgi/l/email-protection)${{ secrets.DIGITAL_OCEAN_IP_ADDRESS }} << 'ENDSSH' cd /app source .env docker login ghcr.io -u $NAMESPACE -p $PERSONAL_ACCESS_TOKEN docker pull $WEB_IMAGE docker pull $NGINX_IMAGE docker-compose -f docker-compose.prod.yml up -d ENDSSH`
为了测试,创建一个新的develop分支。在 urls.py 中的world后加一个感叹号:
`def home(request):
return JsonResponse({"hello": "world!"})`
将您的更改提交并推送到 GitHub。确保仅运行build作业。一旦构建通过,打开一个针对master分支的 PR 并合并变更。这将触发两个阶段的新构建- build和deploy。确保部署按预期工作:
--
就是这样!你可以在 django-github-digital oceanrepo 中找到最终代码。
与 Docker 和 GitLab 一起将 Django 持续部署到数字海洋
原文:https://testdriven.io/blog/deploying-django-to-digitalocean-with-docker-and-gitlab/
在本教程中,我们将了解如何配置 GitLab CI,以便将 Django 和 Docker 应用程序持续部署到 DigitalOcean。
依赖关系:
- Django v3.2.4
- Docker v20.04
- python 3 . 9 . 5 版
目标
本教程结束时,您将能够:
- 使用 Docker 将 Django 部署到数字海洋
- 配置 GitLab CI 以持续将 Django 部署到数字海洋
- 设置无密码 SSH 登录
- 为数据持久性配置数字海洋的托管数据库
项目设置
除了 Django 和 Docker,我们将使用的演示项目还包括 Postgres 、 Nginx 和 Gunicorn 。
好奇这个项目是怎么开发出来的?查看 Postgres、Gunicorn 和 Nginx 的博客文章。
从克隆基础项目开始:
`$ git clone https://gitlab.com/testdriven/django-gitlab-digitalocean.git --branch base --single-branch
$ cd django-gitlab-digitalocean`
要进行本地测试,构建映像并旋转容器:
`$ docker-compose up -d --build`
导航到 http://localhost:8000/ 。您应该看到:
数字海洋
让我们设置 DigitalOcean 来使用我们的应用程序。
首先,你需要注册一个数字海洋账户(如果你还没有的话),然后生成一个访问令牌,这样你就可以访问数字海洋 API 。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
微滴
接下来,使用预装的 Docker 创建一个新的 Droplet:
`$ curl -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
-d '{"name":"django-docker","region":"sfo3","size":"s-2vcpu-4gb","image":"docker-20-04"}' \
"https://api.digitalocean.com/v2/droplets"`
检查状态:
`$ curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/droplets?name=django-docker"`
如果您安装了 jq ,那么您可以像这样解析 JSON 响应:
`$ curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/droplets?name=django-docker" \
| jq '.droplets[0].status'`
root 密码应该会通过电子邮件发送给您。找回它。然后,一旦 droplet 的状态为active,就以 root 身份 SSH 到实例中,并在提示时更新密码。
接下来,生成一个新的 SSH 密钥:
将密钥保存到 /root/。ssh/id_rsa 并且不设置密码。这将分别生成一个公钥和私钥- id_rsa 和 id_rsa.pub 。要设置无密码 SSH 登录,请将公钥复制到 authorized_keys 文件中,并设置适当的权限:
`$ cat ~/.ssh/id_rsa.pub
$ vi ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/id_rsa`
复制私钥的内容:
将其设置为本地计算机上的环境变量:
`export PRIVATE_KEY='-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA04up8hoqzS1+APIB0RhjXyObwHQnOzhAk5Bd7mhkSbPkyhP1
...
iWlX9HNavcydATJc1f0DpzF0u4zY8PY24RVoW8vk+bJANPp1o2IAkeajCaF3w9nf
q/SyqAWVmvwYuIhDiHDaV2A==
-----END RSA PRIVATE KEY-----'`
将密钥添加到 ssh-agent 中:
`$ ssh-add - <<< "${PRIVATE_KEY}"`
要进行测试,请运行:
然后,为应用程序创建一个新目录:
数据库ˌ资料库
接下来,让我们通过数字海洋的托管数据库建立一个生产 Postgres 数据库:
`$ curl -X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
-d '{"name":"django-docker-db","region":"sfo3","engine":"pg","version":"13","size":"db-s-2vcpu-4gb","num_nodes":1}' \
"https://api.digitalocean.com/v2/databases"`
检查状态:
`$ curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/databases?name=django-docker-db" \
| jq '.databases[0].status'`
它应该需要几分钟才能旋转起来。一旦状态为online,获取连接信息:
`$ curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/databases?name=django-docker-db" \
| jq '.databases[0].connection'`
示例响应:
`{
"protocol": "postgresql",
"uri": "postgresql://doadmin:[[email protected]](/cdn-cgi/l/email-protection)locean.com:25060/defaultdb?sslmode=require",
"database": "defaultdb",
"host": "django-docker-db-do-user-778274-0.a.db.ondigitalocean.com",
"port": 25060,
"user": "doadmin",
"password": "na9tcfew9jw13a2m",
"ssl": true
}`
GitLab CI
注册一个 GitLab 账号(如果需要的话),然后创建一个新项目(再次,如果需要的话)。
构建阶段
接下来,添加一个名为的 GitLab CI/CD 配置文件。gitlab-ci.yml 到项目根:
`image: name: docker/compose:1.29.1 entrypoint: [""] services: - docker:dind stages: - build variables: DOCKER_HOST: tcp://docker:2375 DOCKER_DRIVER: overlay2 build: stage: build before_script: - export IMAGE=$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME - export WEB_IMAGE=$IMAGE:web - export NGINX_IMAGE=$IMAGE:nginx script: - apk add --no-cache bash - chmod +x ./setup_env.sh - bash ./setup_env.sh - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY - docker pull $IMAGE:web || true - docker pull $IMAGE:nginx || true - docker-compose -f docker-compose.ci.yml build - docker push $IMAGE:web - docker push $IMAGE:nginx`
在这里,我们定义了单个build 阶段,在这里我们:
- 设置
IMAGE、WEB_IMAGE和NGINX_IMAGE环境变量 - 安装 bash
- 为 setup_env.sh 设置适当的权限
- 运行 setup_env.sh
- 登录到 GitLab 容器注册表
- 如果图像存在,请提取图像
- 构建图像
- 将图像上传到注册表
将 setup_env.sh 文件添加到项目根目录:
`#!/bin/sh
echo DEBUG=0 >> .env
echo SQL_ENGINE=django.db.backends.postgresql >> .env
echo DATABASE=postgres >> .env
echo SECRET_KEY=$SECRET_KEY >> .env
echo SQL_DATABASE=$SQL_DATABASE >> .env
echo SQL_USER=$SQL_USER >> .env
echo SQL_PASSWORD=$SQL_PASSWORD >> .env
echo SQL_HOST=$SQL_HOST >> .env
echo SQL_PORT=$SQL_PORT >> .env`
这个文件将创建所需的。env 文件,基于在 GitLab 项目的 CI/CD 设置中找到的环境变量(设置> CI / CD >变量)。根据连接信息添加变量。
例如:
SECRET_KEY:9zYGEFk2mn3mWB8Bmg9SAhPy6F4s7cCuT8qaYGVEnu7huGRKW9SQL_DATABASE:defaultdbSQL_HOST:django-docker-db-do-user-778274-0.a.db.ondigitalocean.comSQL_PASSWORD:na9tcfew9jw13a2mSQL_PORT:25060SQL_USER:doadmin

完成后,提交代码并上传到 GitLab 以触发新的构建。确保它通过。您应该会在 GitLab 容器注册表中看到这些图像:

部署阶段
接下来,给增加一个deploy阶段。gitlab-ci.yml 并创建一个用于两个阶段的全局before_script:
`image: name: docker/compose:1.29.1 entrypoint: [""] services: - docker:dind stages: - build - deploy variables: DOCKER_HOST: tcp://docker:2375 DOCKER_DRIVER: overlay2 before_script: - export IMAGE=$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME - export WEB_IMAGE=$IMAGE:web - export NGINX_IMAGE=$IMAGE:nginx - apk add --no-cache openssh-client bash - chmod +x ./setup_env.sh - bash ./setup_env.sh - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY build: stage: build script: - docker pull $IMAGE:web || true - docker pull $IMAGE:nginx || true - docker-compose -f docker-compose.ci.yml build - docker push $IMAGE:web - docker push $IMAGE:nginx deploy: stage: deploy script: - mkdir -p ~/.ssh - echo "$PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - cat ~/.ssh/id_rsa - chmod 700 ~/.ssh/id_rsa - eval "$(ssh-agent -s)" - ssh-add ~/.ssh/id_rsa - ssh-keyscan -H 'gitlab.com' >> ~/.ssh/known_hosts - chmod +x ./deploy.sh - scp -o StrictHostKeyChecking=no -r ./.env ./docker-compose.prod.yml [[email protected]](/cdn-cgi/l/email-protection)$DIGITAL_OCEAN_IP_ADDRESS:/app - bash ./deploy.sh`
因此,在deploy阶段,我们:
- 将私有 ssh 密钥添加到 SSH 代理中
- 复制完。env 和 docker-compose.prod.yml 文件到远程服务器
- 为 deploy.sh 设置适当的权限
- 运行 deploy.sh
将 deploy.sh 添加到项目根:
`#!/bin/sh
ssh -o StrictHostKeyChecking=no [[email protected]](/cdn-cgi/l/email-protection)$DIGITAL_OCEAN_IP_ADDRESS << 'ENDSSH'
cd /app
export $(cat .env | xargs)
docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY
docker pull $IMAGE:web
docker pull $IMAGE:nginx
docker-compose -f docker-compose.prod.yml up -d
ENDSSH`
因此,在登录到服务器后,我们
- 导航到部署目录
- 添加环境变量
- 登录 GitLab 容器注册表
- 调出图像
- 旋转容器
将DIGITAL_OCEAN_IP_ADDRESS和PRIVATE_KEY环境变量添加到 GitLab 中。
更新 setup_env.sh 文件:
`#!/bin/sh
echo DEBUG=0 >> .env
echo SQL_ENGINE=django.db.backends.postgresql >> .env
echo DATABASE=postgres >> .env
echo SECRET_KEY=$SECRET_KEY >> .env
echo SQL_DATABASE=$SQL_DATABASE >> .env
echo SQL_USER=$SQL_USER >> .env
echo SQL_PASSWORD=$SQL_PASSWORD >> .env
echo SQL_HOST=$SQL_HOST >> .env
echo SQL_PORT=$SQL_PORT >> .env
echo WEB_IMAGE=$IMAGE:web >> .env
echo NGINX_IMAGE=$IMAGE:nginx >> .env
echo CI_REGISTRY_USER=$CI_REGISTRY_USER >> .env
echo CI_JOB_TOKEN=$CI_JOB_TOKEN >> .env
echo CI_REGISTRY=$CI_REGISTRY >> .env
echo IMAGE=$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME >> .env`
接下来,将服务器的 IP 添加到 Django 设置中的ALLOWED_HOSTS列表。
提交并推送您的代码以触发新的构建。构建通过后,导航到实例的 IP。您应该看到:
试验
最后,更新deploy阶段,使其仅在对master分支进行更改时运行:
`deploy: stage: deploy script: - mkdir -p ~/.ssh - echo "$PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - cat ~/.ssh/id_rsa - chmod 700 ~/.ssh/id_rsa - eval "$(ssh-agent -s)" - ssh-add ~/.ssh/id_rsa - ssh-keyscan -H 'gitlab.com' >> ~/.ssh/known_hosts - chmod +x ./deploy.sh - scp -o StrictHostKeyChecking=no -r ./.env ./docker-compose.prod.yml [[email protected]](/cdn-cgi/l/email-protection)$DIGITAL_OCEAN_IP_ADDRESS:/app - bash ./deploy.sh only: - master`
为了测试,创建一个新的develop分支。在 urls.py 中的world后加一个感叹号:
`def home(request):
return JsonResponse({'hello': 'world!'})`
将您的更改提交并推送到 GitLab。确保只运行build阶段。一旦构建通过,打开一个针对master分支的 PR 并合并变更。这将触发一个包含两个阶段的新管道- build和deploy。确保部署按预期工作:
--
就是这样!你可以在 django-git lab-digital oceanrepo 中找到最终代码。
使用 Docker 和 GitLab 将 Django 持续部署到 AWS EC2
原文:https://testdriven.io/blog/deploying-django-to-ec2-with-docker-and-gitlab/
在本教程中,我们将了解如何配置 GitLab CI,以便将 Django 和 Docker 应用程序持续部署到 Amazon Web Services (AWS) EC2。
依赖关系:
- Django v3.2.5
- 文档 v20.10.4
- python 3 . 9 . 6 版
目标
本教程结束时,您将能够:
- 设置新的 EC2 实例
- 配置 AWS 安全组
- 在 EC2 实例上安装 Docker
- 设置无密码 SSH 登录
- 为数据持久性配置 AWS RDS
- 使用 Docker 将 Django 部署到 AWS EC2
- 配置 GitLab CI 以持续将 Django 部署到 EC2
项目设置
除了 Django 和 Docker,我们将使用的演示项目还包括 Postgres 、 Nginx 和 Gunicorn 。
好奇这个项目是怎么开发出来的?查看 Postgres、Gunicorn 和 Nginx 的博客文章。
从克隆基础项目开始:
`$ git clone https://gitlab.com/testdriven/django-gitlab-ec2.git --branch base --single-branch
$ cd django-gitlab-ec2`
要进行本地测试,构建映像并旋转容器:
`$ docker-compose up -d --build`
导航到 http://localhost:8000/ 。您应该看到:
AWS 设置
让我们首先设置一个 EC2 实例来部署我们的应用程序,并配置 RDS。
设置您的第一个 AWS 帐户?
创建一个非根 IAM 用户是一个好主意,具有“管理员访问”和“计费”策略,并通过 CloudWatch 发出计费警报,以便在您的 AWS 使用成本超过一定金额时提醒您。有关更多信息,请查看锁定您的 AWS 帐户 Root 用户访问密钥和分别创建计费警报。
EC2
登录 AWS 控制台,导航到 EC2 控制台,点击左侧边栏的“实例”。然后,单击“启动实例”按钮:

接下来,使用基本的亚马逊 Linux AMI 和t2.micro 实例类型:


单击“下一步:配置实例详细信息”。为了使本教程简单,我们将坚持使用默认的 VPC,但也可以随时更新。

多次单击“下一步”按钮,直到进入“配置安全组”步骤。创建一个名为django-security-group的新安全组(类似于防火墙),确保至少 HTTP 80 和 SSH 22 是开放的。

点击“查看并启动”。
在下一页,单击“启动”。在 modal 上,创建一个新的密钥对,这样就可以通过 SSH 连接到实例。保存这个。pem 文件放在安全的地方。

在 Mac 或 Linux 机器上?建议保存。pem 文件到“/用户/$用户/”。ssh "目录。一定要设置适当的权限,例如
chmod 400 ~/.ssh/django.pem。
单击“启动实例”创建新实例。在“启动状态”页面上,单击“查看实例”。然后,在主实例页面上,获取新创建的实例的公共 IP:

码头工人
实例启动并运行后,我们现在可以在其上安装 Docker 了。
使用您的密钥对 SSH 到实例中,如下所示:
首先安装并启动最新版本的 Docker 和版本 1.29.2 的 Docker Compose:
`[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/1.29.2/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.4, build d3cb89e
[ec2-user]$ docker-compose --version
docker-compose version 1.29.2, build 5becea4c`
将ec2-user添加到docker组,这样您就可以执行 Docker 命令,而不必使用sudo:
`[ec2-user]$ sudo usermod -a -G docker ec2-user`
接下来,生成一个新的 SSH 密钥:
`[ec2-user]$ ssh-keygen -t rsa`
将密钥保存到 /home/ec2-user/。ssh/id_rsa 并且不设置密码。这将分别生成一个公钥和私钥- id_rsa 和 id_rsa.pub 。要设置无密码 SSH 登录,请将公钥复制到 authorized_keys 文件中,并设置适当的权限:
`[ec2-user]$ cat ~/.ssh/id_rsa.pub
[ec2-user]$ vi ~/.ssh/authorized_keys
[ec2-user]$ chmod 600 ~/.ssh/authorized_keys
[ec2-user]$ chmod 600 ~/.ssh/id_rsa`
复制私钥的内容:
`[ec2-user]$ cat ~/.ssh/id_rsa`
退出远程 SSH 会话。将密钥设置为本地计算机上的环境变量:
`$ export PRIVATE_KEY='-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA04up8hoqzS1+APIB0RhjXyObwHQnOzhAk5Bd7mhkSbPkyhP1
...
iWlX9HNavcydATJc1f0DpzF0u4zY8PY24RVoW8vk+bJANPp1o2IAkeajCaF3w9nf
q/SyqAWVmvwYuIhDiHDaV2A==
-----END RSA PRIVATE KEY-----'`
将密钥添加到 ssh-agent 中:
`$ ssh-add - <<< "${PRIVATE_KEY}"`
要进行测试,请运行:
然后,为应用程序创建一个新目录:
`$ ssh -o StrictHostKeyChecking=no [[email protected]](/cdn-cgi/l/email-protection)<YOUR_INSTANCE_IP> mkdir /home/ec2-user/app
# example:
# ssh -o StrictHostKeyChecking=no [[email protected]](/cdn-cgi/l/email-protection).143 mkdir /home/ec2-user/app`
无线电数据系统
接下来,让我们通过 AWS 关系数据库服务(RDS)构建一个生产 Postgres 数据库。
导航到 Amazon RDS ,点击侧边栏上的“数据库”,然后点击“创建数据库”按钮。

对于“引擎选项”,选择“PostgreSQL”引擎和PostgreSQL 12.7-R1版本。
使用“自由层”模板。
有关免费层的更多信息,请查看 AWS 免费层指南。

在“设置”下:
- “数据库实例标识符”:
djangodb - “主用户名”:
webapp - “主密码”:勾选“自动生成密码”

向下滚动到“连接”部分。坚持使用默认的“VPC”并选择django-security-group安全组。关闭“公共可访问性”。

在“附加配置”下,将“初始数据库名称”更改为django_prod,然后创建新数据库。

单击“查看凭据详细信息”按钮查看生成的密码。记下它。
RDS 实例启动需要几分钟时间。一旦它可用,记下端点。例如:
`djangodb.c7kxiqfnzo9e.us-west-1.rds.amazonaws.com`
完整的 URL 如下所示:
`postgres://webapp:YOUR_PASSWORD@djangodb.c7kxiqfnzo9e.us-west-1.rds.amazonaws.com:5432/django_prod`
请记住,您无法在 VPC 之外访问数据库。因此,如果您想直接连接到它,您需要使用 SSH 隧道通过 SSH 进入 EC2 实例并从那里连接到数据库。我们很快就会看到如何做到这一点。
GitLab CI
注册一个 GitLab 账号(如果需要的话),然后创建一个新项目(再次,如果需要的话)。
构建阶段
接下来,添加一个名为的 GitLab CI/CD 配置文件。gitlab-ci.yml 到项目根:
`image: name: docker/compose:1.29.2 entrypoint: [""] services: - docker:dind stages: - build variables: DOCKER_HOST: tcp://docker:2375 DOCKER_DRIVER: overlay2 build: stage: build before_script: - export IMAGE=$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME - export WEB_IMAGE=$IMAGE:web - export NGINX_IMAGE=$IMAGE:nginx script: - apk add --no-cache bash - chmod +x ./setup_env.sh - bash ./setup_env.sh - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY - docker pull $IMAGE:web || true - docker pull $IMAGE:nginx || true - docker-compose -f docker-compose.ci.yml build - docker push $IMAGE:web - docker push $IMAGE:nginx`
在这里,我们定义了单个build 阶段,在这里我们:
- 设置
IMAGE、WEB_IMAGE和NGINX_IMAGE环境变量 - 安装 bash
- 为 setup_env.sh 设置适当的权限
- 运行 setup_env.sh
- 登录到 GitLab 容器注册表
- 如果图像存在,请提取图像
- 构建图像
- 将图像上传到注册表
将 setup_env.sh 文件添加到项目根目录:
`#!/bin/sh
echo DEBUG=0 >> .env
echo SQL_ENGINE=django.db.backends.postgresql >> .env
echo DATABASE=postgres >> .env
echo SECRET_KEY=$SECRET_KEY >> .env
echo SQL_DATABASE=$SQL_DATABASE >> .env
echo SQL_USER=$SQL_USER >> .env
echo SQL_PASSWORD=$SQL_PASSWORD >> .env
echo SQL_HOST=$SQL_HOST >> .env
echo SQL_PORT=$SQL_PORT >> .env`
这个文件将创建所需的。env 文件,基于在 GitLab 项目的 CI/CD 设置中找到的环境变量(设置> CI / CD >变量)。根据上面的 RDS 连接信息添加变量。
例如:
SECRET_KEY:9zYGEFk2mn3mWB8Bmg9SAhPy6F4s7cCuT8qaYGVEnu7huGRKW9SQL_DATABASE:djangodbSQL_HOST:djangodb.c7kxiqfnzo9e.us-west-1.rds.amazonaws.comSQL_PASSWORD:3ZQtN4vxkZp2kAa0vinVSQL_PORT:5432SQL_USER:webapp

完成后,提交代码并上传到 GitLab 以触发新的构建。确保它通过。您应该会在 GitLab 容器注册表中看到这些图像:

AWS 安全组
接下来,在将部署添加到 CI 流程之前,我们需要更新“安全组”的入站端口,以便可以从 EC2 实例访问端口 5432。为什么这是必要的?转到 app/entrypoint.prod.sh :
`#!/bin/sh
if [ "$DATABASE" = "postgres" ]
then
echo "Waiting for postgres..."
while ! nc -z $SQL_HOST $SQL_PORT; do
sleep 0.1
done
echo "PostgreSQL started"
fi
exec "[[email protected]](/cdn-cgi/l/email-protection)"`
在这里,我们在启动 Gunciorn 之前,通过测试与 netcat 的连接,等待 Postgres 实例变得健康。如果端口 5432 没有打开,循环将永远继续下去。
因此,再次导航到 EC2 控制台,点击左侧边栏上的“安全组”。选择django-security-group安全组,点击“编辑入站规则”:

单击“添加规则”。在类型下,选择“PostgreSQL ”,并在源下选择django-security-group安全组:

现在,与该组相关联的任何 AWS 服务都可以通过端口 5432 访问 RDS 实例。单击“保存规则”。
GitLab CI:部署阶段
接下来,给增加一个deploy阶段。gitlab-ci.yml 并创建一个用于两个阶段的全局before_script:
`image: name: docker/compose:1.29.2 entrypoint: [""] services: - docker:dind stages: - build - deploy variables: DOCKER_HOST: tcp://docker:2375 DOCKER_DRIVER: overlay2 before_script: - export IMAGE=$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME - export WEB_IMAGE=$IMAGE:web - export NGINX_IMAGE=$IMAGE:nginx - apk add --no-cache openssh-client bash - chmod +x ./setup_env.sh - bash ./setup_env.sh - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY build: stage: build script: - docker pull $IMAGE:web || true - docker pull $IMAGE:nginx || true - docker-compose -f docker-compose.ci.yml build - docker push $IMAGE:web - docker push $IMAGE:nginx deploy: stage: deploy script: - mkdir -p ~/.ssh - echo "$PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - cat ~/.ssh/id_rsa - chmod 700 ~/.ssh/id_rsa - eval "$(ssh-agent -s)" - ssh-add ~/.ssh/id_rsa - ssh-keyscan -H 'gitlab.com' >> ~/.ssh/known_hosts - chmod +x ./deploy.sh - scp -o StrictHostKeyChecking=no -r ./.env ./docker-compose.prod.yml [[email protected]](/cdn-cgi/l/email-protection)$EC2_PUBLIC_IP_ADDRESS:/home/ec2-user/app - bash ./deploy.sh`
因此,在deploy阶段,我们:
- 将私有 ssh 密钥添加到 SSH 代理中
- 复制完。env 和 docker-compose.prod.yml 文件到远程服务器
- 为 deploy.sh 设置适当的权限
- 运行 deploy.sh
将 deploy.sh 添加到项目根:
`#!/bin/sh
ssh -o StrictHostKeyChecking=no [[email protected]](/cdn-cgi/l/email-protection)$EC2_PUBLIC_IP_ADDRESS << 'ENDSSH'
cd /home/ec2-user/app
export $(cat .env | xargs)
docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY
docker pull $IMAGE:web
docker pull $IMAGE:nginx
docker-compose -f docker-compose.prod.yml up -d
ENDSSH`
因此,在登录到服务器后,我们
- 导航到部署目录
- 添加环境变量
- 登录 GitLab 容器注册表
- 调出图像
- 旋转容器
将EC2_PUBLIC_IP_ADDRESS和PRIVATE_KEY环境变量添加到 GitLab 中。
更新 setup_env.sh 文件:
`#!/bin/sh
echo DEBUG=0 >> .env
echo SQL_ENGINE=django.db.backends.postgresql >> .env
echo DATABASE=postgres >> .env
echo SECRET_KEY=$SECRET_KEY >> .env
echo SQL_DATABASE=$SQL_DATABASE >> .env
echo SQL_USER=$SQL_USER >> .env
echo SQL_PASSWORD=$SQL_PASSWORD >> .env
echo SQL_HOST=$SQL_HOST >> .env
echo SQL_PORT=$SQL_PORT >> .env
echo WEB_IMAGE=$IMAGE:web >> .env
echo NGINX_IMAGE=$IMAGE:nginx >> .env
echo CI_REGISTRY_USER=$CI_REGISTRY_USER >> .env
echo CI_JOB_TOKEN=$CI_JOB_TOKEN >> .env
echo CI_REGISTRY=$CI_REGISTRY >> .env
echo IMAGE=$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME >> .env`
接下来,将服务器的 IP 添加到 Django 设置中的ALLOWED_HOSTS列表。
提交并推送您的代码以触发新的构建。构建通过后,导航到实例的 IP。您应该看到:
通过 SSH 隧道的 PostgreSQL
需要访问数据库?
宋承宪入框:
安装 Postgres:
`[ec2-user]$ sudo amazon-linux-extras install postgresql12 -y`
然后,运行psql,像这样:
`[ec2-user]$ psql -h <YOUR_RDS_ENDPOINT> -U webapp -d django_prod
# example:
# psql -h djangodb.c7vzuyfvhlgz.us-east-1.rds.amazonaws.com -U webapp -d django_prod`
输入密码。
`psql (12.7)
SSL connection (protocol: TLSv1.2, cipher: ECDHE-RSA-AES256-GCM-SHA384, bits: 256, compression: off)
Type "help" for help.
django_prod=> \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-------------+----------+----------+-------------+-------------+-----------------------
django_prod | webapp | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
postgres | webapp | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
rdsadmin | rdsadmin | UTF8 | en_US.UTF-8 | en_US.UTF-8 | rdsadmin=CTc/rdsadmin
template0 | rdsadmin | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =c/rdsadmin +
| | | | | rdsadmin=CTc/rdsadmin
template1 | webapp | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =c/webapp +
| | | | | webapp=CTc/webapp
(5 rows)
django_prod=> \q`
完成后,退出 SSH 会话。
更新 GitLab CI
最后,更新deploy阶段,使其仅在对master分支进行更改时运行:
`deploy: stage: deploy script: - mkdir -p ~/.ssh - echo "$PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - cat ~/.ssh/id_rsa - chmod 700 ~/.ssh/id_rsa - eval "$(ssh-agent -s)" - ssh-add ~/.ssh/id_rsa - ssh-keyscan -H 'gitlab.com' >> ~/.ssh/known_hosts - chmod +x ./deploy.sh - scp -o StrictHostKeyChecking=no -r ./.env ./docker-compose.prod.yml [[email protected]](/cdn-cgi/l/email-protection)$EC2_PUBLIC_IP_ADDRESS:/home/ec2-user/app - bash ./deploy.sh only: - master`
为了测试,创建一个新的develop分支。在 urls.py 中的world后加一个感叹号:
`def home(request):
return JsonResponse({'hello': 'world!'})`
将您的更改提交并推送到 GitLab。确保只运行build阶段。一旦构建通过,打开一个针对master分支的 PR 并合并变更。这将触发一个包含两个阶段的新管道- build和deploy。确保部署按预期工作:
后续步骤
本教程介绍了如何配置 GitLab CI,以便将 Django 和 Docker 应用程序持续部署到 AWS EC2。
此时,您可能希望使用域名而不是 IP 地址。为此,您需要:
想挑战吗?为了使整个过程自动化,因此您不需要每次都手动提供一个新实例并在其上安装 Docker,设置弹性容器服务。有关这方面的更多信息,请查看将 Flask 和 React 微服务部署到 AWS ECS 课程和使用 Terraform 将 Django 部署到 AWS ECS教程。
你可以在 django-gitlab-ec2 repo 中找到最终代码。
使用 Terraform 将 Django 部署到 AWS ECS
原文:https://testdriven.io/blog/deploying-django-to-ecs-with-terraform/
在本教程中,我们将了解如何使用 Terraform 将 Django 应用程序部署到 AWS ECS。
依赖关系:
- Django v3.2.9
- 文档版本 20.10.10
- python 3 . 9 . 0 版
- Terraform v1.0.11
目标
本教程结束时,您将能够:
- 解释什么是 Terraform,以及如何使用它编写代码形式的基础设施
- 利用 ECR Docker 图像注册表存储图像
- 创建启动 ECS 群集所需的地形配置
- 通过 Terraform 提升 AWS 基础设施
- 将 Django 应用程序部署到由 ECS 集群管理的 EC2 实例集群中
- 使用 Boto3 更新 ECS 服务
- 为数据持久性配置 AWS RDS
- 为 AWS 负载平衡器创建 HTTPS 监听器
将(行星)地球化(以适合人类居住)
Terraform 是一个基础设施 as code (IaC)工具,用于通过代码构建、更改和版本化基础设施。它使用高级声明式配置语言,让您描述运行应用程序所需的云或本地基础设施的状态。可以把它看作是您的基础设施的唯一来源,它使安全有效地创建、更新和删除资源变得容易。在描述了基础设施的最终状态后,Terraform 会生成一个计划,然后执行它——例如,提供和启动必要的基础设施。
如果你是 Terraform 的新手,请阅读 Terraform 简介文章和 T2 入门指南。
继续之前,请确保您安装了 Terraform:
`$ terraform -v Terraform v1.0.11 on darwin_amd64`
在本教程中,我们将使用 Terraform 开发将 Django 应用程序部署到 ECS 所需的高级配置文件。配置完成后,我们将运行一个命令来设置以下 AWS 基础设施:
- 网络:
- VPC
- 公共和私有子网
- 路由表
- 互联网网关
- 密钥对
- 安全组
- 负载平衡器、侦听器和目标组
- IAM 角色和策略
- ECS:
- 任务定义(有多个容器)
- 串
- 服务
- 启动配置和自动缩放组
- 无线电数据系统
- 健康检查和日志
亚马逊的弹性容器服务 (ECS)是一个完全托管的容器编排平台,用于管理和运行 EC2 实例集群上的容器化应用。
如果您不熟悉 ECS,建议首先在 web 控制台中进行试验。让 ECS 为您创建这些内容,而不是手动配置所有底层网络资源、IAM 角色和策略以及日志。您只需要设置 ECS、负载平衡器、监听器、目标组和 RDS。一旦你觉得舒服了,就把基础设施作为编码工具,比如 Terraform。
架构图:

项目设置
让我们从建立一个快速的 Django 项目开始。
创建一个新的项目目录和一个新的 Django 项目:
`$ mkdir django-ecs-terraform && cd django-ecs-terraform
$ mkdir app && cd app
$ python3.10 -m venv env
$ source env/bin/activate
(env)$ pip install django==3.2.9
(env)$ django-admin startproject hello_django .
(env)$ python manage.py migrate
(env)$ python manage.py runserver`
导航到 http://localhost:8000/ 查看 Django 欢迎屏幕。完成后关闭服务器,然后退出虚拟环境。继续并删除它。我们现在有了一个简单的 Django 项目。
添加一个 requirements.txt 文件:
`Django==3.2.9
gunicorn==20.1.0`
同样添加一个 Dockerfile :
`# pull official base image
FROM python:3.9.0-slim-buster
# set work 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
# copy project
COPY . .`
出于测试目的,将DEBUG设置为True,并允许 settings.py 文件中的所有主机:
`DEBUG = True
ALLOWED_HOSTS = ['*']`
接下来,构建并标记图像,并旋转一个新的容器:
`$ docker build -t django-ecs .
$ docker run \
-p 8007:8000 \
--name django-test \
django-ecs \
gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000`
确保您可以在 http://localhost:8007/ 再次查看欢迎屏幕。
完成后,停止并移除容器:
`$ docker stop django-test
$ docker rm django-test`
加一个。gitignore 文件到项目根目录:
`__pycache__ .DS_Store *.sqlite3`
您的项目结构现在应该如下所示:
`├── .gitignore
└── app
├── Dockerfile
├── hello_django
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
└── requirements.txt`
要更详细地了解如何将 Django 应用程序容器化,请查看使用 Postgres、Gunicorn 和 Nginx 的博客文章。
electroniccashregister 电子现金出纳机
在进入 Terraform 之前,让我们将 Docker 映像推送到一个私有的 Docker 映像注册表Elastic Container Registry(ECR)。
导航到 ECR 控制台,添加一个名为“django-app”的新存储库。保持标签的可变性。关于这方面的更多内容,请查看图像标签可变性指南。
回到您的终端,再次构建并标记图像:
`$ docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest .`
确保用您的 AWS 帐户 ID 替换
<AWS_ACCOUNT_ID>。我们将在整个课程中使用
us-west-1区域。如果你愿意,请随意更改。
验证 Docker CLI 以使用 ECR 注册表:
`$ aws ecr get-login --region us-west-1 --no-include-email`
该命令将提供一个身份验证令牌。复制并粘贴整个docker login命令进行验证。
推送图像:
`$ docker push <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest`
地形设置
将“terraform”文件夹添加到项目的根目录。我们将把每个 Terraform 配置文件添加到这个文件夹中。
接下来,向“terraform”添加一个名为 01_provider.tf 的新文件:
`provider "aws" {
region = var.region
}`
这里,我们定义了 AWS 提供者。你需要提供你的 AWS 证书以便验证。将它们定义为环境变量:
`$ export AWS_ACCESS_KEY_ID="YOUR_AWS_ACCESS_KEY_ID"
$ export AWS_SECRET_ACCESS_KEY="YOUR_AWS_SECRET_ACCESS_KEY"`
我们为region使用了一个字符串插值,它将从 variables.tf 文件中读入。继续将该文件添加到“terraform”文件夹中,并向其中添加以下变量:
`# core
variable "region" {
description = "The AWS region to create resources in."
default = "us-west-1"
}`
在学习本教程的过程中,您可以根据自己的具体需求随意更新变量。
运行terraform init创建一个新的 Terraform 工作目录并下载 AWS 提供程序。
这样我们就可以开始定义 AWS 基础设施的每一部分。
AWS 资源
接下来,让我们配置以下 AWS 资源:
- 网络:
- VPC
- 公共和私有子网
- 路由表
- 互联网网关
- 密钥对
- 安全组
- 负载平衡器、侦听器和目标组
- IAM 角色和策略
- ECS:
- 任务定义(有多个容器)
- 串
- 服务
- 启动配置和自动缩放组
- 健康检查和日志
您可以在 GitHub 上的django-ECS-Terraformrepo 中找到每个 terra form 配置文件。
网络资源
让我们在名为 02_network.tf 的新文件中定义我们的网络资源:
`# Production VPC
resource "aws_vpc" "production-vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
}
# Public subnets
resource "aws_subnet" "public-subnet-1" {
cidr_block = var.public_subnet_1_cidr
vpc_id = aws_vpc.production-vpc.id
availability_zone = var.availability_zones[0]
}
resource "aws_subnet" "public-subnet-2" {
cidr_block = var.public_subnet_2_cidr
vpc_id = aws_vpc.production-vpc.id
availability_zone = var.availability_zones[1]
}
# Private subnets
resource "aws_subnet" "private-subnet-1" {
cidr_block = var.private_subnet_1_cidr
vpc_id = aws_vpc.production-vpc.id
availability_zone = var.availability_zones[0]
}
resource "aws_subnet" "private-subnet-2" {
cidr_block = var.private_subnet_2_cidr
vpc_id = aws_vpc.production-vpc.id
availability_zone = var.availability_zones[1]
}
# Route tables for the subnets
resource "aws_route_table" "public-route-table" {
vpc_id = aws_vpc.production-vpc.id
}
resource "aws_route_table" "private-route-table" {
vpc_id = aws_vpc.production-vpc.id
}
# Associate the newly created route tables to the subnets
resource "aws_route_table_association" "public-route-1-association" {
route_table_id = aws_route_table.public-route-table.id
subnet_id = aws_subnet.public-subnet-1.id
}
resource "aws_route_table_association" "public-route-2-association" {
route_table_id = aws_route_table.public-route-table.id
subnet_id = aws_subnet.public-subnet-2.id
}
resource "aws_route_table_association" "private-route-1-association" {
route_table_id = aws_route_table.private-route-table.id
subnet_id = aws_subnet.private-subnet-1.id
}
resource "aws_route_table_association" "private-route-2-association" {
route_table_id = aws_route_table.private-route-table.id
subnet_id = aws_subnet.private-subnet-2.id
}
# Elastic IP
resource "aws_eip" "elastic-ip-for-nat-gw" {
vpc = true
associate_with_private_ip = "10.0.0.5"
depends_on = [aws_internet_gateway.production-igw]
}
# NAT gateway
resource "aws_nat_gateway" "nat-gw" {
allocation_id = aws_eip.elastic-ip-for-nat-gw.id
subnet_id = aws_subnet.public-subnet-1.id
depends_on = [aws_eip.elastic-ip-for-nat-gw]
}
resource "aws_route" "nat-gw-route" {
route_table_id = aws_route_table.private-route-table.id
nat_gateway_id = aws_nat_gateway.nat-gw.id
destination_cidr_block = "0.0.0.0/0"
}
# Internet Gateway for the public subnet
resource "aws_internet_gateway" "production-igw" {
vpc_id = aws_vpc.production-vpc.id
}
# Route the public subnet traffic through the Internet Gateway
resource "aws_route" "public-internet-igw-route" {
route_table_id = aws_route_table.public-route-table.id
gateway_id = aws_internet_gateway.production-igw.id
destination_cidr_block = "0.0.0.0/0"
}`
这里,我们定义了以下资源:
还添加以下变量:
`# networking
variable "public_subnet_1_cidr" {
description = "CIDR Block for Public Subnet 1"
default = "10.0.1.0/24"
}
variable "public_subnet_2_cidr" {
description = "CIDR Block for Public Subnet 2"
default = "10.0.2.0/24"
}
variable "private_subnet_1_cidr" {
description = "CIDR Block for Private Subnet 1"
default = "10.0.3.0/24"
}
variable "private_subnet_2_cidr" {
description = "CIDR Block for Private Subnet 2"
default = "10.0.4.0/24"
}
variable "availability_zones" {
description = "Availability zones"
type = list(string)
default = ["us-west-1b", "us-west-1c"]
}`
运行terraform plan生成并显示基于已定义配置的执行计划。
安全组
接下来,为了保护 Django 应用程序和 ECS 集群,让我们在一个名为 03_securitygroups.tf 的新文件中配置安全组:
`# ALB Security Group (Traffic Internet -> ALB)
resource "aws_security_group" "load-balancer" {
name = "load_balancer_security_group"
description = "Controls access to the ALB"
vpc_id = aws_vpc.production-vpc.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# ECS Security group (traffic ALB -> ECS, ssh -> ECS)
resource "aws_security_group" "ecs" {
name = "ecs_security_group"
description = "Allows inbound access from the ALB only"
vpc_id = aws_vpc.production-vpc.id
ingress {
from_port = 0
to_port = 0
protocol = "-1"
security_groups = [aws_security_group.load-balancer.id]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}`
注意与端口 22 的 ECS 集群相关联的安全组上的入站规则。这样我们就可以 SSH 到 EC2 实例中运行初始数据库迁移并添加一个超级用户。
负载平衡
接下来,让我们配置一个应用负载平衡器 (ALB)以及适当的目标组和监听器。
04 _ load balancer . TF:
`# Production Load Balancer
resource "aws_lb" "production" {
name = "${var.ecs_cluster_name}-alb"
load_balancer_type = "application"
internal = false
security_groups = [aws_security_group.load-balancer.id]
subnets = [aws_subnet.public-subnet-1.id, aws_subnet.public-subnet-2.id]
}
# Target group
resource "aws_alb_target_group" "default-target-group" {
name = "${var.ecs_cluster_name}-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.production-vpc.id
health_check {
path = var.health_check_path
port = "traffic-port"
healthy_threshold = 5
unhealthy_threshold = 2
timeout = 2
interval = 5
matcher = "200"
}
}
# Listener (redirects traffic from the load balancer to the target group)
resource "aws_alb_listener" "ecs-alb-http-listener" {
load_balancer_arn = aws_lb.production.id
port = "80"
protocol = "HTTP"
depends_on = [aws_alb_target_group.default-target-group]
default_action {
type = "forward"
target_group_arn = aws_alb_target_group.default-target-group.arn
}
}`
添加所需的变量:
`# load balancer
variable "health_check_path" {
description = "Health check path for the default target group"
default = "/ping/"
}
# ecs
variable "ecs_cluster_name" {
description = "Name of the ECS cluster"
default = "production"
}`
因此,我们配置了负载平衡器和监听器来监听端口 80 上的 HTTP 请求。这是暂时的。在我们验证我们的基础设施和应用程序设置正确后,我们将更新负载平衡器,以侦听端口 443 上的 HTTPS 请求。
记下运行状况检查的路径 URL:/ping/。
IAM 角色
05_iam.tf :
`resource "aws_iam_role" "ecs-host-role" {
name = "ecs_host_role_prod"
assume_role_policy = file("policies/ecs-role.json")
}
resource "aws_iam_role_policy" "ecs-instance-role-policy" {
name = "ecs_instance_role_policy"
policy = file("policies/ecs-instance-role-policy.json")
role = aws_iam_role.ecs-host-role.id
}
resource "aws_iam_role" "ecs-service-role" {
name = "ecs_service_role_prod"
assume_role_policy = file("policies/ecs-role.json")
}
resource "aws_iam_role_policy" "ecs-service-role-policy" {
name = "ecs_service_role_policy"
policy = file("policies/ecs-service-role-policy.json")
role = aws_iam_role.ecs-service-role.id
}
resource "aws_iam_instance_profile" "ecs" {
name = "ecs_instance_profile_prod"
path = "/"
role = aws_iam_role.ecs-host-role.name
}`
在“terraform”中添加一个名为“policies”的新文件夹。然后,添加以下角色和策略定义:
ecs-role.json :
`{ "Version": "2008-10-17", "Statement": [ { "Action": "sts:AssumeRole", "Principal": { "Service": [ "ecs.amazonaws.com", "ec2.amazonaws.com" ] }, "Effect": "Allow" } ] }`
ECS-instance-role-policy . JSON:
`{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "ecs:*", "ec2:*", "elasticloadbalancing:*", "ecr:*", "cloudwatch:*", "s3:*", "rds:*", "logs:*" ], "Resource": "*" } ] }`
ECS-service-role-policy . JSON:
`{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "elasticloadbalancing:Describe*", "elasticloadbalancing:DeregisterInstancesFromLoadBalancer", "elasticloadbalancing:RegisterInstancesWithLoadBalancer", "ec2:Describe*", "ec2:AuthorizeSecurityGroupIngress", "elasticloadbalancing:RegisterTargets", "elasticloadbalancing:DeregisterTargets" ], "Resource": [ "*" ] } ] }`
日志
06_logs.tf :
`resource "aws_cloudwatch_log_group" "django-log-group" {
name = "/ecs/django-app"
retention_in_days = var.log_retention_in_days
}
resource "aws_cloudwatch_log_stream" "django-log-stream" {
name = "django-app-log-stream"
log_group_name = aws_cloudwatch_log_group.django-log-group.name
}`
添加变量:
`# logs
variable "log_retention_in_days" {
default = 30
}`
密钥对
07 _ key pair . TF:
`resource "aws_key_pair" "production" {
key_name = "${var.ecs_cluster_name}_key_pair"
public_key = file(var.ssh_pubkey_file)
}`
变量:
`# key pair
variable "ssh_pubkey_file" {
description = "Path to an SSH public key"
default = "~/.ssh/id_rsa.pub"
}`
精英公司
现在,我们可以配置我们的 ECS 集群了。
08_ecs.tf :
`resource "aws_ecs_cluster" "production" {
name = "${var.ecs_cluster_name}-cluster"
}
resource "aws_launch_configuration" "ecs" {
name = "${var.ecs_cluster_name}-cluster"
image_id = lookup(var.amis, var.region)
instance_type = var.instance_type
security_groups = [aws_security_group.ecs.id]
iam_instance_profile = aws_iam_instance_profile.ecs.name
key_name = aws_key_pair.production.key_name
associate_public_ip_address = true
user_data = "#!/bin/bash\necho ECS_CLUSTER='${var.ecs_cluster_name}-cluster' > /etc/ecs/ecs.config"
}
data "template_file" "app" {
template = file("templates/django_app.json.tpl")
vars = {
docker_image_url_django = var.docker_image_url_django
region = var.region
}
}
resource "aws_ecs_task_definition" "app" {
family = "django-app"
container_definitions = data.template_file.app.rendered
}
resource "aws_ecs_service" "production" {
name = "${var.ecs_cluster_name}-service"
cluster = aws_ecs_cluster.production.id
task_definition = aws_ecs_task_definition.app.arn
iam_role = aws_iam_role.ecs-service-role.arn
desired_count = var.app_count
depends_on = [aws_alb_listener.ecs-alb-http-listener, aws_iam_role_policy.ecs-service-role-policy]
load_balancer {
target_group_arn = aws_alb_target_group.default-target-group.arn
container_name = "django-app"
container_port = 8000
}
}`
看一下aws_launch_configuration中的user_data字段。简而言之, user_data 是一个在启动新的 EC2 实例时运行的脚本。为了让 ECS 集群发现新的 EC2 实例,需要将集群名称添加到实例内的 /etc/ecs/ecs.config 配置文件中的ECS_CLUSTER环境变量中。换句话说,以下脚本将在引导新实例时运行,从而允许群集发现该实例:
`#!/bin/bash
echo ECS_CLUSTER='production-cluster' > /etc/ecs/ecs.config`
有关这个发现过程的更多信息,请查看 Amazon ECS 容器代理配置指南。
在“terraform”文件夹中添加一个“templates”文件夹,然后添加一个名为 django_app.json.tpl 的新模板文件:
`[ { "name": "django-app", "image": "${docker_image_url_django}", "essential": true, "cpu": 10, "memory": 512, "links": [], "portMappings": [ { "containerPort": 8000, "hostPort": 0, "protocol": "tcp" } ], "command": ["gunicorn", "-w", "3", "-b", ":8000", "hello_django.wsgi:application"], "environment": [], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/django-app", "awslogs-region": "${region}", "awslogs-stream-prefix": "django-app-log-stream" } } } ]`
这里,我们定义了与 Django 应用程序相关联的容器定义。
还添加以下变量:
`# ecs
variable "ecs_cluster_name" {
description = "Name of the ECS cluster"
default = "production"
}
variable "amis" {
description = "Which AMI to spawn."
default = {
us-west-1 = "ami-0bd3976c0dbacc605"
}
}
variable "instance_type" {
default = "t2.micro"
}
variable "docker_image_url_django" {
description = "Docker image to run in the ECS cluster"
default = "<AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest"
}
variable "app_count" {
description = "Number of Docker containers to run"
default = 2
}`
同样,确保用您的 AWS 帐户 ID 替换
<AWS_ACCOUNT_ID>。参考 Linux Amazon ECS 优化的 ami指南,找到预装 Docker 的 ami 列表。
因为我们添加了模板提供程序,所以再次运行terraform init来下载新的提供程序。
自动缩放
09_auto_scaling.tf :
`resource "aws_autoscaling_group" "ecs-cluster" {
name = "${var.ecs_cluster_name}_auto_scaling_group"
min_size = var.autoscale_min
max_size = var.autoscale_max
desired_capacity = var.autoscale_desired
health_check_type = "EC2"
launch_configuration = aws_launch_configuration.ecs.name
vpc_zone_identifier = [aws_subnet.private-subnet-1.id, aws_subnet.private-subnet-2.id]
}`
新变量:
`# auto scaling
variable "autoscale_min" {
description = "Minimum autoscale (number of EC2)"
default = "1"
}
variable "autoscale_max" {
description = "Maximum autoscale (number of EC2)"
default = "10"
}
variable "autoscale_desired" {
description = "Desired autoscale (number of EC2)"
default = "4"
}`
试验
outputs.tf :
`output "alb_hostname" {
value = aws_lb.production.dns_name
}`
这里,我们配置了一个 outputs.tf 文件以及一个名为alb_hostname的输出值。在我们执行 Terraform 计划之后,为了启动 AWS 基础设施,负载平衡器的 DNS 名称将被输出到终端。
准备好了吗?!?查看然后执行计划:
`$ terraform plan
$ terraform apply`
您应该看到运行状况检查失败,并显示 404:
`service production-service (instance i-013f1192da079b0bf) (port 49153)
is unhealthy in target-group production-tg due to
(reason Health checks failed with these codes: [404])`
这是意料之中的,因为我们还没有在应用程序中设置/ping/ handler。
姜戈健康检查
将以下中间件添加到app/hello _ django/middleware . py:
`from django.http import HttpResponse
from django.utils.deprecation import MiddlewareMixin
class HealthCheckMiddleware(MiddlewareMixin):
def process_request(self, request):
if request.META['PATH_INFO'] == '/ping/':
return HttpResponse('pong!')`
将类添加到 settings.py 中的中间件配置中:
`MIDDLEWARE = [
'hello_django.middleware.HealthCheckMiddleware', # new
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]`
这个中间件用于在检查ALLOWED_HOSTS之前处理对/ping/ URL 的请求。为什么这是必要的?
健康检查请求来自 EC2 实例。由于我们事先不知道私有 IP,这将确保/ping/ route 总是返回一个成功的响应,即使在我们限制ALLOWED_HOSTS之后。
值得注意的是,您可以将 Nginx 放在 Gunicorn 前面,并在 Nginx 配置中处理健康检查,如下所示:
location /ping/ { access_log off; return 200; }
要进行本地测试,构建新的映像,然后启动容器:
`$ docker build -t django-ecs .
$ docker run \
-p 8007:8000 \
--name django-test \
django-ecs \
gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000`
确保http://localhost:8007/ping/按预期工作:
完成后,停止并移除容器:
`$ docker stop django-test
$ docker rm django-test`
接下来,更新 ECR:
`$ docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest .
$ docker push <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest`
让我们添加一个快速脚本来更新任务定义和服务,以便新任务使用我们刚刚推送的新映像。
在项目根目录下创建一个“部署”文件夹。然后,将一个 update-ecs.py 文件添加到新创建的文件夹中:
`import boto3
import click
def get_current_task_definition(client, cluster, service):
response = client.describe_services(cluster=cluster, services=[service])
current_task_arn = response["services"][0]["taskDefinition"]
response = client.describe_task_definition(taskDefinition=current_task_arn)
return response
@click.command()
@click.option("--cluster", help="Name of the ECS cluster", required=True)
@click.option("--service", help="Name of the ECS service", required=True)
def deploy(cluster, service):
client = boto3.client("ecs")
response = get_current_task_definition(client, cluster, service)
container_definition = response["taskDefinition"]["containerDefinitions"][0].copy()
response = client.register_task_definition(
family=response["taskDefinition"]["family"],
volumes=response["taskDefinition"]["volumes"],
containerDefinitions=[container_definition],
)
new_task_arn = response["taskDefinition"]["taskDefinitionArn"]
response = client.update_service(
cluster=cluster, service=service, taskDefinition=new_task_arn,
)
if __name__ == "__main__":
deploy()`
因此,该脚本将创建任务定义的新修订版,然后更新服务,使其使用修订后的任务定义。
`$ pip install boto3 click`
添加您的 AWS 凭据以及默认区域:
`$ export AWS_ACCESS_KEY_ID="YOUR_AWS_ACCESS_KEY_ID"
$ export AWS_SECRET_ACCESS_KEY="YOUR_AWS_SECRET_ACCESS_KEY"
$ export AWS_DEFAULT_REGION="us-west-1"`
像这样运行脚本:
`$ python update-ecs.py --cluster=production-cluster --service=production-service`
服务应该根据修改后的任务定义启动两个新任务,并将它们注册到相关的目标组。这一次健康检查应该会通过。现在,您应该能够使用输出到终端的 DNS 主机名来查看您的应用程序了:
`Outputs:
alb_hostname = production-alb-1008464563.us-west-1.elb.amazonaws.com`
无线电数据系统
接下来,让我们配置 RDS ,这样我们就可以将 Postgres 用于我们的生产数据库。
向 03_securitygroups.tf 添加一个新的安全组,以确保只有来自 ECS 实例的流量可以与数据库对话:
`# RDS Security Group (traffic ECS -> RDS)
resource "aws_security_group" "rds" {
name = "rds-security-group"
description = "Allows inbound access from ECS only"
vpc_id = aws_vpc.production-vpc.id
ingress {
protocol = "tcp"
from_port = "5432"
to_port = "5432"
security_groups = [aws_security_group.ecs.id]
}
egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
}`
接下来,添加一个名为 10_rds.tf 的新文件,用于设置数据库本身:
`resource "aws_db_subnet_group" "production" {
name = "main"
subnet_ids = [aws_subnet.private-subnet-1.id, aws_subnet.private-subnet-2.id]
}
resource "aws_db_instance" "production" {
identifier = "production"
name = var.rds_db_name
username = var.rds_username
password = var.rds_password
port = "5432"
engine = "postgres"
engine_version = "12.3"
instance_class = var.rds_instance_class
allocated_storage = "20"
storage_encrypted = false
vpc_security_group_ids = [aws_security_group.rds.id]
db_subnet_group_name = aws_db_subnet_group.production.name
multi_az = false
storage_type = "gp2"
publicly_accessible = false
backup_retention_period = 7
skip_final_snapshot = true
}`
变量:
`# rds
variable "rds_db_name" {
description = "RDS database name"
default = "mydb"
}
variable "rds_username" {
description = "RDS database username"
default = "foo"
}
variable "rds_password" {
description = "RDS database password"
}
variable "rds_instance_class" {
description = "RDS instance type"
default = "db.t2.micro"
}`
请注意,我们保留了密码的默认值。稍后会有更多的介绍。
因为我们需要知道 Django 应用程序中实例的地址,所以在 08_ecs.tf 的aws_ecs_task_definition中添加一个depends_on参数:
`resource "aws_ecs_task_definition" "app" {
family = "django-app"
container_definitions = data.template_file.app.rendered
depends_on = [aws_db_instance.production]
}`
接下来,我们需要更新 settings.py 中的DATABASES配置:
`if 'RDS_DB_NAME' in os.environ:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': os.environ['RDS_DB_NAME'],
'USER': os.environ['RDS_USERNAME'],
'PASSWORD': os.environ['RDS_PASSWORD'],
'HOST': os.environ['RDS_HOSTNAME'],
'PORT': os.environ['RDS_PORT'],
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}`
添加导入:
更新 django_app.json.tpl 模板中的environment部分:
`"environment": [ { "name": "RDS_DB_NAME", "value": "${rds_db_name}" }, { "name": "RDS_USERNAME", "value": "${rds_username}" }, { "name": "RDS_PASSWORD", "value": "${rds_password}" }, { "name": "RDS_HOSTNAME", "value": "${rds_hostname}" }, { "name": "RDS_PORT", "value": "5432" } ],`
更新 08_ecs.tf 中传递给模板的变量:
`data "template_file" "app" {
template = file("templates/django_app.json.tpl")
vars = {
docker_image_url_django = var.docker_image_url_django
region = var.region
rds_db_name = var.rds_db_name
rds_username = var.rds_username
rds_password = var.rds_password
rds_hostname = aws_db_instance.production.address
}
}`
将 Psycopg2 添加到需求文件中:
`Django==3.2.9
gunicorn==20.1.0
psycopg2-binary==2.9.2`
更新 docker 文件以安装 Psycopg2 所需的相应软件包:
`# pull official base image
FROM python:3.9.0-slim-buster
# set work directory
WORKDIR /usr/src/app
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# install psycopg2 dependencies
RUN apt-get update \
&& apt-get -y install gcc postgresql \
&& apt-get clean
# install dependencies
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt
# copy project
COPY . .`
好吧。构建 Docker 映像并将其推送到 ECR。然后,要更新 ECS 任务定义、创建 RDS 资源并更新服务,请运行:
因为我们没有设置默认密码,所以会提示您输入一个密码:
`var.rds_password
RDS database password
Enter a value:`
不必每次都传递一个值,您可以像这样设置一个环境变量:
`$ export TF_VAR_rds_password=foobarbaz
$ terraform apply`
请记住,这种使用环境变量的方法将敏感变量排除在之外。tf 文件,但它们仍然以纯文本形式存储在 terraform.tfstate 文件中。因此,一定要将这个文件置于版本控制之外。因为如果你团队中的其他人需要访问它,将它置于版本控制之外是行不通的,所以要么加密秘密,要么使用像金库或 AWS 秘密管理器这样的秘密存储。
在新任务注册到目标组之后,SSH 进入一个 EC2 实例,其中一个任务正在运行:
通过docker ps获取容器 ID,并使用它来应用迁移:
`$ docker exec -it <container-id> python manage.py migrate
# docker exec -it 73284cda8a87 python manage.py migrate`
您可能还想创建一个超级用户。完成后,退出 SSH 会话。如果您不再需要 SSH 访问,您可能需要从 ECS 安全组中删除以下入站规则:
`ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}`
域和 SSL 证书
假设您已经从 AWS 证书管理器生成并验证了一个新的 SSL 证书,将证书的 ARN 添加到您的变量中:
`# domain
variable "certificate_arn" {
description = "AWS Certificate Manager ARN for validated domain"
default = "ADD YOUR ARN HERE"
}`
在 04_loadbalancer.tf 中更新与负载平衡器关联的默认监听器,以便它监听端口 443 上的 HTTPS 请求(与端口 80 上的 HTTP 相反):
`# Listener (redirects traffic from the load balancer to the target group)
resource "aws_alb_listener" "ecs-alb-http-listener" {
load_balancer_arn = aws_lb.production.id
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08"
certificate_arn = var.certificate_arn
depends_on = [aws_alb_target_group.default-target-group]
default_action {
type = "forward"
target_group_arn = aws_alb_target_group.default-target-group.arn
}
}`
应用更改:
确保使用 CNAME 记录将您的域指向负载平衡器。确保您可以查看您的应用程序。
Nginx
接下来,让我们将 Nginx 添加到组合中,以适当地处理对静态文件的请求。
在项目根目录中,创建以下文件和文件夹:
`└── nginx
├── Dockerfile
└── nginx.conf`
Dockerfile :
`FROM nginx:1.19.0-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d
EXPOSE 80`
engine . conf:
`upstream hello_django { server django-app:8000; } server { listen 80; location / { proxy_pass http://hello_django; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_redirect off; } }`
这里,我们设置了一个位置块,将所有流量路由到 Django 应用程序。在下一节中,我们将为静态文件设置一个新的位置块。
在 ECR 中创建一个名为“nginx”的新 repo,然后构建并推送新映像:
`$ docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/nginx:latest .
$ docker push <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/nginx:latest`
将以下变量添加到变量文件的 ECS 部分:
`variable "docker_image_url_nginx" {
description = "Docker image to run in the ECS cluster"
default = "<AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/nginx:latest"
}`
将新的容器定义添加到 django_app.json.tpl 模板:
`[ { "name": "django-app", "image": "${docker_image_url_django}", "essential": true, "cpu": 10, "memory": 512, "links": [], "portMappings": [ { "containerPort": 8000, "hostPort": 0, "protocol": "tcp" } ], "command": ["gunicorn", "-w", "3", "-b", ":8000", "hello_django.wsgi:application"], "environment": [ { "name": "RDS_DB_NAME", "value": "${rds_db_name}" }, { "name": "RDS_USERNAME", "value": "${rds_username}" }, { "name": "RDS_PASSWORD", "value": "${rds_password}" }, { "name": "RDS_HOSTNAME", "value": "${rds_hostname}" }, { "name": "RDS_PORT", "value": "5432" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/django-app", "awslogs-region": "${region}", "awslogs-stream-prefix": "django-app-log-stream" } } }, { "name": "nginx", "image": "${docker_image_url_nginx}", "essential": true, "cpu": 10, "memory": 128, "links": ["django-app"], "portMappings": [ { "containerPort": 80, "hostPort": 0, "protocol": "tcp" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/nginx", "awslogs-region": "${region}", "awslogs-stream-prefix": "nginx-log-stream" } } } ]`
将变量传递给 08_ecs.tf 中的模板:
`data "template_file" "app" {
template = file("templates/django_app.json.tpl")
vars = {
docker_image_url_django = var.docker_image_url_django
docker_image_url_nginx = var.docker_image_url_nginx
region = var.region
rds_db_name = var.rds_db_name
rds_username = var.rds_username
rds_password = var.rds_password
rds_hostname = aws_db_instance.production.address
}
}`
将新日志添加到 06_logs.tf :
`resource "aws_cloudwatch_log_group" "nginx-log-group" {
name = "/ecs/nginx"
retention_in_days = var.log_retention_in_days
}
resource "aws_cloudwatch_log_stream" "nginx-log-stream" {
name = "nginx-log-stream"
log_group_name = aws_cloudwatch_log_group.nginx-log-group.name
}`
更新服务,使其指向nginx容器,而不是django-app:
`resource "aws_ecs_service" "production" {
name = "${var.ecs_cluster_name}-service"
cluster = aws_ecs_cluster.production.id
task_definition = aws_ecs_task_definition.app.arn
iam_role = aws_iam_role.ecs-service-role.arn
desired_count = var.app_count
depends_on = [aws_alb_listener.ecs-alb-http-listener, aws_iam_role_policy.ecs-service-role-policy]
load_balancer {
target_group_arn = aws_alb_target_group.default-target-group.arn
container_name = "nginx"
container_port = 80
}
}`
应用更改:
确保仍可从浏览器访问该应用程序。
现在我们正在处理两个容器,让我们更新 deploy 函数来处理 update-ecs.py 中的多个容器定义:
`@click.command()
@click.option("--cluster", help="Name of the ECS cluster", required=True)
@click.option("--service", help="Name of the ECS service", required=True)
def deploy(cluster, service):
client = boto3.client("ecs")
container_definitions = []
response = get_current_task_definition(client, cluster, service)
for container_definition in response["taskDefinition"]["containerDefinitions"]:
new_def = container_definition.copy()
container_definitions.append(new_def)
response = client.register_task_definition(
family=response["taskDefinition"]["family"],
volumes=response["taskDefinition"]["volumes"],
containerDefinitions=container_definitions,
)
new_task_arn = response["taskDefinition"]["taskDefinitionArn"]
response = client.update_service(
cluster=cluster, service=service, taskDefinition=new_task_arn,
)`
静态文件
在你的 settings.py 文件中设置STATIC_ROOT:
`STATIC_URL = '/staticfiles/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')`
此外,关闭调试模式:
更新 Dockerfile,以便它在最后运行collectstatic命令:
`# pull official base image
FROM python:3.9.0-slim-buster
# set work directory
WORKDIR /usr/src/app
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# install psycopg2 dependencies
RUN apt-get update \
&& apt-get -y install gcc postgresql \
&& apt-get clean
# install dependencies
RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt
# copy project
COPY . .
# collect static files
RUN python manage.py collectstatic --no-input`
接下来,让我们将一个共享卷添加到任务定义中,并更新 Nginx conf 文件。
将新的位置块添加到 nginx.conf :
`upstream hello_django { server django-app:8000; } server { listen 80; location /staticfiles/ { alias /usr/src/app/staticfiles/; } location / { proxy_pass http://hello_django; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_redirect off; } }`
将卷添加到 08_ecs.tf 中的aws_ecs_task_definition:
`resource "aws_ecs_task_definition" "app" {
family = "django-app"
container_definitions = data.template_file.app.rendered
depends_on = [aws_db_instance.production]
volume {
name = "static_volume"
host_path = "/usr/src/app/staticfiles/"
}
}`
将卷添加到 django_app.json.tpl 模板中的容器定义中:
`[ { "name": "django-app", "image": "${docker_image_url_django}", "essential": true, "cpu": 10, "memory": 512, "links": [], "portMappings": [ { "containerPort": 8000, "hostPort": 0, "protocol": "tcp" } ], "command": ["gunicorn", "-w", "3", "-b", ":8000", "hello_django.wsgi:application"], "environment": [ { "name": "RDS_DB_NAME", "value": "${rds_db_name}" }, { "name": "RDS_USERNAME", "value": "${rds_username}" }, { "name": "RDS_PASSWORD", "value": "${rds_password}" }, { "name": "RDS_HOSTNAME", "value": "${rds_hostname}" }, { "name": "RDS_PORT", "value": "5432" } ], "mountPoints": [ { "containerPath": "/usr/src/app/staticfiles", "sourceVolume": "static_volume" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/django-app", "awslogs-region": "${region}", "awslogs-stream-prefix": "django-app-log-stream" } } }, { "name": "nginx", "image": "${docker_image_url_nginx}", "essential": true, "cpu": 10, "memory": 128, "links": ["django-app"], "portMappings": [ { "containerPort": 80, "hostPort": 0, "protocol": "tcp" } ], "mountPoints": [ { "containerPath": "/usr/src/app/staticfiles", "sourceVolume": "static_volume" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/nginx", "awslogs-region": "${region}", "awslogs-stream-prefix": "nginx-log-stream" } } } ]`
现在,每个容器将共享一个名为“staticfiles”的目录。
构建新图像,并将其上传至 ECR:
`$ docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest .
$ docker push <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest
$ docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/nginx:latest .
$ docker push <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/nginx:latest`
应用更改:
静态文件现在应该可以正确加载了。
允许的主机
最后,让我们锁定我们的生产应用程序:
`ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split()`
将ALLOWED_HOSTS环境变量添加到容器定义中:
`"environment": [ { "name": "RDS_DB_NAME", "value": "${rds_db_name}" }, { "name": "RDS_USERNAME", "value": "${rds_username}" }, { "name": "RDS_PASSWORD", "value": "${rds_password}" }, { "name": "RDS_HOSTNAME", "value": "${rds_hostname}" }, { "name": "RDS_PORT", "value": "5432" }, { "name": "ALLOWED_HOSTS", "value": "${allowed_hosts}" } ],`
将变量传递给 08_ecs.tf 中的模板:
`data "template_file" "app" {
template = file("templates/django_app.json.tpl")
vars = {
docker_image_url_django = var.docker_image_url_django
docker_image_url_nginx = var.docker_image_url_nginx
region = var.region
rds_db_name = var.rds_db_name
rds_username = var.rds_username
rds_password = var.rds_password
rds_hostname = aws_db_instance.production.address
allowed_hosts = var.allowed_hosts
}
}`
将变量添加到变量文件的 ECS 部分,确保添加您的域名:
`variable "allowed_hosts" {
description = "Domain name for allowed hosts"
default = "YOUR DOMAIN NAME"
}`
建立新的图像并将其提交给 ECR:
`$ docker build -t <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest .
$ docker push <AWS_ACCOUNT_ID>.dkr.ecr.us-west-1.amazonaws.com/django-app:latest`
应用:
最后一次测试。
完成后立即关闭基础架构:
结论
本教程介绍了如何使用 Terraform 来构建在 ECS 上运行 Django 应用程序所需的 AWS 基础设施。
虽然初始配置很复杂,但具有复杂基础设施需求的大型团队将从 Terraform 中受益。它为您的基础设施提供了一个可读的、集中的事实来源,这将导致更快的反馈周期。
后续步骤:
- 为容器的伸缩配置 CloudWatch 警报。
- 在亚马逊 S3 上存储用户上传的文件
- 建立多阶段 Docker 构建,并在 Docker 容器中使用非根用户
- 不要将端口 80 上的流量路由到 Nginx,而是为端口 443 添加一个监听器。
- 浏览整个 Django 部署清单。
- 如果您计划托管多个应用程序,您可能希望将跨应用程序共享的任何“公共”资源移动到单独的 Terraform 堆栈,以便如果您定期进行修改,您的核心 AWS 服务不会不受影响。
- 看一看 ECS Fargate 。这可以简化您的基础架构,因为您不必管理实际的集群。
你可以在 django-ecs-terraform repo 中找到最终代码。
与 Docker 一起将 Django 部署到 Heroku
原文:https://testdriven.io/blog/deploying-django-to-heroku-with-docker/
本文着眼于如何通过 Heroku 容器运行时使用 Docker 将 Django 应用程序部署到 Heroku。
目标
本教程结束时,您将能够:
- 解释为什么你可能想使用 Heroku 的容器运行时来运行一个应用程序
- Dockerize 一个 Django 应用
- 在 Heroku 上的 Docker 容器中部署并运行 Django 应用程序
- 配置 GitLab CI 以将 Docker 映像部署到 Heroku
- 使用 WhiteNoise 管理静态资产
- 配置 Postgres 在 Heroku 上运行
- 创建使用多级 Docker 版本的生产 Docker 文件
- 使用 Heroku 容器注册表并构建清单,以便将 Docker 部署到 Heroku
Heroku 容器运行时
除了传统的 Git plus slug 编译器部署 ( git push heroku master),Heroku 还通过 Heroku 容器运行时支持基于 Docker 的部署。
容器运行时是管理和运行容器的程序。如果您想更深入地了解容器运行时,请查看低级 Linux 容器运行时的历史。
基于 Docker 的部署
与传统方法相比,基于 Docker 的部署有许多优势:
- 没有内存块限制 : Heroku 允许传统的基于 Git 的部署使用最大 500MB 的内存块大小。另一方面,基于 Docker 的部署没有这个限制。
- 对操作系统的完全控制:你可以完全控制操作系统,并且可以用 Docker 安装任何你想安装的软件包,而不是被 Heroku buildpacks 安装的软件包所限制。
- 更强的开发/生产对等性:基于 Docker 的构建在开发和生产之间具有更强的对等性,因为底层环境是相同的。
- 更少的供应商锁定:最后,Docker 让用户更容易切换到不同的云托管提供商,如 AWS 或 GCP。
一般来说,基于 Docker 的部署为您提供了更大的灵活性和对部署环境的控制。您可以在所需的环境中部署所需的应用程序。也就是说,你现在负责安全更新。对于传统的基于 Git 的部署,Heroku 负责这一点。他们将相关的安全更新应用到他们的堆栈中,并根据需要将您的应用迁移到新的堆栈中。请记住这一点。
目前有两种方式将 Docker 的应用部署到 Heroku:
- 容器注册表:将预构建的 Docker 映像部署到 Heroku
- 构建清单:给定一个 Docker 文件,Heroku 构建并部署 Docker 映像
这两者之间的主要区别在于,使用后一种方法——例如,通过构建清单——您可以访问管道、评审和发布特性。因此,如果你正在将一个应用从基于 Git 的部署转换到 Docker,并且正在使用这些特性,那么你应该使用构建清单的方法。
请放心,我们将在本文中研究这两种方法。
在这两种情况下,你仍然可以访问 Heroku CLI ,所有强大的插件,以及仪表盘。换句话说,所有这些特性都与容器运行时一起工作。
| 部署类型 | 部署机制 | 安全更新(谁负责) | 进入管道,审查,放行 | 访问 CLI、插件和仪表板 | 段塞尺寸限制 |
|---|---|---|---|---|---|
| Git + Slug 编译器 | Git Push | Heroku | 是 | 是 | 是 |
| Docker +容器运行时 | 码头推送 | 你们 | 不 | 是 | 不 |
| Docker +构建清单 | Git Push | 你们 | 是 | 是 | 不 |
请记住,基于 Docker 的部署受到与基于 Git 的部署相同的限制。例如,不支持持久卷,因为文件系统是短暂的,而 web 进程仅支持 HTTP(S)请求。有关这方面的更多信息,请查看 Dockerfile 命令和运行时。
坞站与 Heroku 概念
| 码头工人 | Heroku |
|---|---|
| Dockerfile | 构建包 |
| 图像 | 鼻涕虫 |
| 容器 | 绝妙的 |
项目设置
创建一个项目目录,创建并激活一个新的虚拟环境,并安装 Django:
`$ mkdir django-heroku-docker
$ cd django-heroku-docker
$ python3.10 -m venv env
$ source env/bin/activate
(env)$ pip install django==3.2.9`
你可以随意把 virtualenv 和 Pip 换成诗歌或 Pipenv 。更多信息,请查看现代 Python 环境。
接下来,创建一个新的 Django 项目,应用迁移,并运行服务器:
`(env)$ django-admin startproject hello_django .
(env)$ python manage.py migrate
(env)$ python manage.py runserver`
导航到 http://localhost:8000/ 查看 Django 欢迎屏幕。完成后,关闭服务器并退出虚拟环境。
码头工人
将 Dockerfile 文件添加到项目根目录:
`# pull official base image
FROM python:3.10-alpine
# set work directory
WORKDIR /app
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0
# install psycopg2
RUN apk update \
&& apk add --virtual build-essential gcc python3-dev musl-dev \
&& apk add postgresql-dev \
&& pip install psycopg2
# install dependencies
COPY ./requirements.txt .
RUN pip install -r requirements.txt
# copy project
COPY . .
# add and run as non-root user
RUN adduser -D myuser
USER myuser
# run gunicorn
CMD gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT`
这里,我们从 Python 3.10 的一个基于 Alpine 的 Docker 镜像开始。然后,我们设置一个工作目录和两个环境变量:
PYTHONDONTWRITEBYTECODE:防止 Python 将 pyc 文件写入光盘PYTHONUNBUFFERED:防止 Python 缓冲 stdout 和 stderr
接下来,我们安装了系统级的依赖项和 Python 包,复制了项目文件,创建并切换到非根用户(这是 Heroku 推荐的,并使用 CMD 在容器运行时加速时运行 Gunicorn 。注意$PORT变量。本质上,任何在容器运行时上运行的 web 服务器都必须在$PORT环境变量中监听 HTTP 流量,该变量由 Heroku 在运行时设置。
创建一个 requirements.txt 文件:
`Django==3.2.9
gunicorn==20.1.0`
然后加一个。dockerignore 文件:
`__pycache__
*.pyc
env/
db.sqlite3`
更新 settings.py 中的SECRET_KEY、DEBUG和ALLOWED_HOSTS变量:
`SECRET_KEY = os.environ.get('SECRET_KEY', default='foo')
DEBUG = int(os.environ.get('DEBUG', default=0))
ALLOWED_HOSTS = ['localhost', '127.0.0.1']`
不要忘记重要的一点:
要进行本地测试,构建映像并运行容器,确保传入适当的环境变量:
`$ docker build -t web:latest .
$ docker run -d --name django-heroku -e "PORT=8765" -e "DEBUG=1" -p 8007:8765 web:latest`
确保应用程序正在浏览器中的 http://localhost:8007/ 上运行。完成后,停止并移除正在运行的容器:
`$ docker stop django-heroku
$ docker rm django-heroku`
添加 a 。git ignore〔t1〕:
`__pycache__
*.pyc
env/
db.sqlite3`
接下来,让我们创建一个快速 Django 视图,以便在调试模式关闭时轻松测试应用程序。
在“hello_django”目录中添加一个 views.py 文件:
`from django.http import JsonResponse
def ping(request):
data = {'ping': 'pong!'}
return JsonResponse(data)`
接下来,更新 urls.py :
`from django.contrib import admin
from django.urls import path
from .views import ping
urlpatterns = [
path('admin/', admin.site.urls),
path('ping/', ping, name="ping"),
]`
在调试模式关闭的情况下再次测试:
`$ docker build -t web:latest .
$ docker run -d --name django-heroku -e "PORT=8765" -e "DEBUG=0" -p 8007:8765 web:latest`
验证http://localhost:8007/ping/是否按预期工作:
完成后,停止并移除正在运行的容器:
`$ docker stop django-heroku
$ docker rm django-heroku`
白噪声
如果您想使用whiten noise来管理您的静态资产,首先将该包添加到 requirements.txt 文件中:
`Django==3.2.9
gunicorn==20.1.0
whitenoise==5.3.0`
像这样更新 settings.py 中的中间件:
`MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # new
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]`
然后用STATIC_ROOT配置静态文件的处理:
`STATIC_ROOT = BASE_DIR / 'staticfiles'`
最后,添加压缩和缓存支持:
`STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'`
将collectstatic命令添加到 Dockerfile 文件:
`# pull official base image
FROM python:3.10-alpine
# set work directory
WORKDIR /app
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0
# install psycopg2
RUN apk update \
&& apk add --virtual build-essential gcc python3-dev musl-dev \
&& apk add postgresql-dev \
&& pip install psycopg2
# install dependencies
COPY ./requirements.txt .
RUN pip install -r requirements.txt
# copy project
COPY . .
# collect static files
RUN python manage.py collectstatic --noinput
# add and run as non-root user
RUN adduser -D myuser
USER myuser
# run gunicorn
CMD gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT`
要进行测试,构建新的映像并旋转新的容器:
`$ docker build -t web:latest .
$ docker run -d --name django-heroku -e "PORT=8765" -e "DEBUG=1" -p 8007:8765 web:latest`
运行以下命令时,您应该能够查看静态文件:
`$ docker exec django-heroku ls /app/staticfiles
$ docker exec django-heroku ls /app/staticfiles/admin`
停止,然后再次移除运行中的容器:
`$ docker stop django-heroku
$ docker rm django-heroku`
Postgres
为了启动并运行 Postgres,我们将使用 dj_database_url 包,根据DATABASE_URL环境变量为 Django 设置生成适当的数据库配置字典。
将依赖项添加到需求文件中:
`Django==3.2.9
dj-database-url==0.5.0
gunicorn==20.1.0
whitenoise==5.3.0`
然后,如果DATABASE_URL存在,对设置进行以下更改以更新数据库配置:
`DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
DATABASE_URL = os.environ.get('DATABASE_URL')
db_from_env = dj_database_url.config(default=DATABASE_URL, conn_max_age=500, ssl_require=True)
DATABASES['default'].update(db_from_env)`
因此,如果DATABASE_URL不存在,仍然会使用 SQLite。
将导入也添加到顶部:
我们将在 Heroku 上建立一个 Postgres 数据库后对此进行测试。
Heroku 设置
注册 Heroku 账号(如果你还没有),然后安装 Heroku CLI (如果你还没有)。
创建新应用程序:
`$ heroku create
Creating app... done, ⬢ limitless-atoll-51647
https://limitless-atoll-51647.herokuapp.com/ | https://git.heroku.com/limitless-atoll-51647.git`
添加SECRET_KEY环境变量:
`$ heroku config:set SECRET_KEY=SOME_SECRET_VALUE -a limitless-atoll-51647`
将
SOME_SECRET_VALUE改为至少 50 个字符的随机生成的字符串。
将上述 Heroku 网址添加到 hello_django/settings.py 中的ALLOWED_HOSTS列表中,如下所示:
`ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'limitless-atoll-51647.herokuapp.com']`
确保将上述每个命令中的
limitless-atoll-51647替换为您的应用程序名称。
Heroku Docker 部署
此时,我们已经准备好开始将 Docker 映像部署到 Heroku。你决定要采取哪种方法了吗?
- 容器注册表:将预构建的 Docker 映像部署到 Heroku
- 构建清单:给定一个 Docker 文件,Heroku 构建并部署 Docker 映像
不确定?两个都试试!
方法 1:容器注册
如果您使用构建清单方法,请跳过这一节。
同样,使用这种方法,您可以将预构建的 Docker 映像部署到 Heroku。
登录到 Heroku 容器注册表,向 Heroku 表明我们想要使用容器运行时:
重新构建 Docker 映像,并用以下格式对其进行标记:
`registry.heroku.com/<app>/<process-type>`
确保将<app>替换为您刚刚创建的 Heroku 应用程序的名称,将<process-type>替换为web,因为这将用于 web 流程。
例如:
`$ docker build -t registry.heroku.com/limitless-atoll-51647/web .`
将图像推送到注册表:
`$ docker push registry.heroku.com/limitless-atoll-51647/web`
发布图像:
`$ heroku container:release -a limitless-atoll-51647 web`
这将运行容器。您应该可以在https://APP _ name . heroku APP . com查看应用程序。它应该会返回 404。
尝试运行
heroku open -a limitless-atoll-51647在默认浏览器中打开应用程序。
验证https://APP _ name . heroku APP . com/ping是否工作正常:
您还应该能够查看静态文件:
`$ heroku run ls /app/staticfiles -a limitless-atoll-51647
$ heroku run ls /app/staticfiles/admin -a limitless-atoll-51647`
确保将上述每个命令中的
limitless-atoll-51647替换为您的应用程序名称。
完成后,跳到“Postgres 测试”部分。
方法 2:构建清单
如果您正在使用容器注册方法,请跳过这一节。
同样,使用构建清单方法,您可以让 Heroku 基于 heroku.yml 清单文件构建和部署 Docker 映像。
将应用程序的堆栈设置为容器:
`$ heroku stack:set container -a limitless-atoll-51647`
将一个 heroku.yml 文件添加到项目根目录:
`build: docker: web: Dockerfile`
在这里,我们只是告诉 Heroku 使用哪个 Dockerfile 来构建图像。
除了build,您还可以定义以下阶段:
setup用于定义 Heroku 插件和配置变量,以便在应用供应期间创建。release用于定义您希望在发布期间执行的任务。run用于定义为 web 和 worker 进程运行哪些命令。
请务必查看 Heroku 文档以了解关于这四个阶段的更多信息。
值得注意的是,
gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT命令可以从 docker 文件中删除,并添加到run阶段下的 heroku.yml 文件中:`build: docker: web: Dockerfile run: web: gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT`
另外,一定要将“collectstatic”命令放在 docker 文件中。不要移到
release阶段。关于这方面的更多信息,请查看这个堆栈溢出问题。
接下来,从 beta CLI 通道安装heroku-manifest插件:
`$ heroku update beta
$ heroku plugins:install @heroku-cli/plugin-manifest`
这样,初始化一个 Git repo 并创建一个提交。
然后,添加 Heroku 遥控器:
`$ heroku git:remote -a limitless-atoll-51647`
将代码推送到 Heroku 以构建映像并运行容器:
您应该可以在https://APP _ name . heroku APP . com查看应用程序。它应该会返回 404。
尝试运行
heroku open -a limitless-atoll-51647在默认浏览器中打开应用程序。
验证https://APP _ name . heroku APP . com/ping是否工作正常:
您还应该能够查看静态文件:
`$ heroku run ls /app/staticfiles -a limitless-atoll-51647
$ heroku run ls /app/staticfiles/admin -a limitless-atoll-51647`
确保将上述每个命令中的
limitless-atoll-51647替换为您的应用程序名称。
Postgres 测验
创建数据库:
`$ heroku addons:create heroku-postgresql:hobby-dev -a limitless-atoll-51647`
该命令自动为容器设置
DATABASE_URL环境变量。
数据库启动后,运行迁移:
`$ heroku run python manage.py makemigrations -a limitless-atoll-51647
$ heroku run python manage.py migrate -a limitless-atoll-51647`
然后,跳转到 psql 来查看新创建的表:
`$ heroku pg:psql -a limitless-atoll-51647
# \dt
List of relations
Schema | Name | Type | Owner
--------+----------------------------+-------+----------------
public | auth_group | table | siodzhzzcvnwwp
public | auth_group_permissions | table | siodzhzzcvnwwp
public | auth_permission | table | siodzhzzcvnwwp
public | auth_user | table | siodzhzzcvnwwp
public | auth_user_groups | table | siodzhzzcvnwwp
public | auth_user_user_permissions | table | siodzhzzcvnwwp
public | django_admin_log | table | siodzhzzcvnwwp
public | django_content_type | table | siodzhzzcvnwwp
public | django_migrations | table | siodzhzzcvnwwp
public | django_session | table | siodzhzzcvnwwp
(10 rows)
# \q`
同样,确保将上述每个命令中的
limitless-atoll-51647替换为 Heroku 应用的名称。
GitLab CI
注册一个 GitLab 账号(如果需要的话),然后创建一个新项目(再次,如果需要的话)。
取回您的 Heroku 认证令牌:
然后,在项目的 CI/CD 设置中将令牌保存为一个名为HEROKU_AUTH_TOKEN的新变量:Settings > CI / CD > Variables。

接下来,我们需要添加一个名为的 GitLab CI/CD 配置文件。gitlab-ci.yml 到项目根。该文件的内容将根据所使用的方法而有所不同。
方法 1:容器注册
如果您使用构建清单方法,请跳过这一节。
。gitlab-ci.yml :
`image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay2 HEROKU_APP_NAME: <APP_NAME> HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web stages: - build_and_deploy build_and_deploy: stage: build_and_deploy script: - apk add --no-cache curl - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com - docker pull $HEROKU_REGISTRY_IMAGE || true - docker build --cache-from $HEROKU_REGISTRY_IMAGE --tag $HEROKU_REGISTRY_IMAGE --file ./Dockerfile "." - docker push $HEROKU_REGISTRY_IMAGE - chmod +x ./release.sh - ./release.sh`
release.sh :
`#!/bin/sh
IMAGE_ID=$(docker inspect ${HEROKU_REGISTRY_IMAGE} --format={{.Id}})
PAYLOAD='{"updates": [{"type": "web", "docker_image": "'"$IMAGE_ID"'"}]}'
curl -n -X PATCH https://api.heroku.com/apps/$HEROKU_APP_NAME/formation \
-d "${PAYLOAD}" \
-H "Content-Type: application/json" \
-H "Accept: application/vnd.heroku+json; version=3.docker-releases" \
-H "Authorization: Bearer ${HEROKU_AUTH_TOKEN}"`
在这里,我们定义了单个build_and_deploy 阶段,在这里我们:
- 安装卷曲
- 登录 Heroku 容器注册表
- 提取之前推送的图像(如果存在)
- 构建并标记新图像
- 将图像上传到注册表
- 使用 release.sh 脚本中的映像 ID,通过 Heroku API 创建一个新版本
确保将
<APP_NAME>替换为 Heroku 应用的名称。
这样,初始化 Git repo,提交,添加 GitLab remote,将您的代码推送到 GitLab 以触发新的管道。这将作为单个作业运行build_and_deploy阶段。一旦完成,Heroku 上将自动创建一个新版本。
方法 2:构建清单
如果您正在使用容器注册方法,请跳过这一节。
。gitlab-ci.yml :
`variables: HEROKU_APP_NAME: <APP_NAME> stages: - deploy deploy: stage: deploy script: - apt-get update -qy - apt-get install -y ruby-dev - gem install dpl - dpl --provider=heroku --app=$HEROKU_APP_NAME --api-key=$HEROKU_AUTH_TOKEN`
在这里,我们定义了单个deploy 阶段,在这里我们:
- 安装 Ruby 和一个名为 dpl 的 gem
- 用 dpl 将代码部署到 Heroku
确保将
<APP_NAME>替换为 Heroku 应用的名称。
提交,添加 GitLab remote,并将您的代码推送到 GitLab,以触发新的管道。这将作为单个作业运行deploy阶段。一旦完成,代码应该被部署到 Heroku。
高级 CI
除了构建 Docker 映像和在 GitLab CI 上创建一个版本,我们还可以运行 Django 测试、 Flake8 、 Black 和 isort 。
同样,这将取决于您使用的方法。
方法 1:容器注册
如果您使用构建清单方法,请跳过这一节。
更新。gitlab-ci.yml 像这样:
`stages: - build - test - deploy variables: IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} build: stage: build image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay2 script: - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY - docker pull $IMAGE:latest || true - docker build --cache-from $IMAGE:latest --tag $IMAGE:latest --file ./Dockerfile "." - docker push $IMAGE:latest test: stage: test image: $IMAGE:latest services: - postgres:latest variables: POSTGRES_DB: test POSTGRES_USER: runner POSTGRES_PASSWORD: "" DATABASE_URL: postgresql://[[email protected]](/cdn-cgi/l/email-protection):5432/test script: - python manage.py test - flake8 hello_django --max-line-length=100 - black hello_django --check - isort hello_django --check --profile black deploy: stage: deploy image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay2 HEROKU_APP_NAME: <APP_NAME> HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web script: - apk add --no-cache curl - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com - docker pull $HEROKU_REGISTRY_IMAGE || true - docker build --cache-from $HEROKU_REGISTRY_IMAGE --tag $HEROKU_REGISTRY_IMAGE --file ./Dockerfile "." - docker push $HEROKU_REGISTRY_IMAGE - chmod +x ./release.sh - ./release.sh`
确保将
<APP_NAME>替换为 Heroku 应用的名称。
所以,我们现在有三个阶段:build、test和deploy。
在build阶段,我们:
- 登录到 GitLab 容器注册表
- 提取之前推送的图像(如果存在)
- 构建并标记新图像
- 将图像上传到 GitLab 容器注册表
然后,在test阶段,我们配置 Postgres ,设置DATABASE_URL环境变量,然后使用前一阶段构建的映像运行 Django 测试、Flake8、Black 和 isort。
在deploy阶段,我们:
- 安装卷曲
- 登录 Heroku 容器注册表
- 提取之前推送的图像(如果存在)
- 构建并标记新图像
- 将图像上传到注册表
- 使用 release.sh 脚本中的映像 ID,通过 Heroku API 创建一个新版本
将新的依赖项添加到需求文件中:
`# prod
Django==3.2.9
dj-database-url==0.5.0
gunicorn==20.1.0
whitenoise==5.3.0
# dev and test
black==21.11b1
flake8==4.0.1
isort==5.10.1`
在推进到 GitLab 之前,在本地运行 Django 测试:
`$ source env/bin/activate
(env)$ pip install -r requirements.txt
(env)$ python manage.py test
System check identified no issues (0 silenced).
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK`
确保 Flake8 通过,然后根据 Black 和 isort 的建议更新源代码:
`(env)$ flake8 hello_django --max-line-length=100
(env)$ black hello_django
(env)$ isort hello_django --profile black`
再次提交和推送代码。确保所有阶段都通过。
方法 2:构建清单
如果您正在使用容器注册方法,请跳过这一节。
更新。gitlab-ci.yml 像这样:
`stages: - build - test - deploy variables: IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} build: stage: build image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay2 script: - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY - docker pull $IMAGE:latest || true - docker build --cache-from $IMAGE:latest --tag $IMAGE:latest --file ./Dockerfile "." - docker push $IMAGE:latest test: stage: test image: $IMAGE:latest services: - postgres:latest variables: POSTGRES_DB: test POSTGRES_USER: runner POSTGRES_PASSWORD: "" DATABASE_URL: postgresql://[[email protected]](/cdn-cgi/l/email-protection):5432/test script: - python manage.py test - flake8 hello_django --max-line-length=100 - black hello_django --check - isort hello_django --check --profile black deploy: stage: deploy variables: HEROKU_APP_NAME: <APP_NAME> script: - apt-get update -qy - apt-get install -y ruby-dev - gem install dpl - dpl --provider=heroku --app=$HEROKU_APP_NAME --api-key=$HEROKU_AUTH_TOKEN`
确保将
<APP_NAME>替换为 Heroku 应用的名称。
所以,我们现在有三个阶段:build、test和deploy。
在build阶段,我们:
- 登录到 GitLab 容器注册表
- 提取之前推送的图像(如果存在)
- 构建并标记新图像
- 将图像上传到 GitLab 容器注册表
然后,在test阶段,我们配置 Postgres ,设置DATABASE_URL环境变量,然后使用前一阶段构建的映像运行 Django 测试、Flake8、Black 和 isort。
在deploy阶段,我们:
- 安装 Ruby 和一个名为 dpl 的 gem
- 用 dpl 将代码部署到 Heroku
将新的依赖项添加到需求文件中:
`# prod
Django==3.2.9
dj-database-url==0.5.0
gunicorn==20.1.0
whitenoise==5.3.0
# dev and test
black==21.11b1
flake8==4.0.1
isort==5.10.1`
在推进到 GitLab 之前,在本地运行 Django 测试:
`$ source env/bin/activate
(env)$ pip install -r requirements.txt
(env)$ python manage.py test
System check identified no issues (0 silenced).
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK`
确保 Flake8 通过,然后根据 Black 和 isort 的建议更新源代码:
`(env)$ flake8 hello_django --max-line-length=100
(env)$ black hello_django
(env)$ isort hello_django --profile black`
再次提交和推送代码。确保所有阶段都通过。
多级码头建造
最后,像这样更新 Dockerfile 以使用多阶段构建来减小最终的图像大小:
`FROM python:3.10-alpine AS build-python
RUN apk update && apk add --virtual build-essential gcc python3-dev musl-dev postgresql-dev
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY ./requirements.txt .
RUN pip install -r requirements.txt
FROM python:3.10-alpine
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=build-python /opt/venv /opt/venv
RUN apk update && apk add --virtual build-deps gcc python3-dev musl-dev postgresql-dev
RUN pip install psycopg2-binary
WORKDIR /app
COPY . .
RUN python manage.py collectstatic --noinput
RUN adduser -D myuser
USER myuser
CMD gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT`
接下来,我们需要更新 GitLab 配置,以利用 Docker 层缓存。
方法 1:容器注册
如果您使用构建清单方法,请跳过这一节。
。gitlab-ci.yml :
`stages: - build - test - deploy variables: IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} HEROKU_APP_NAME: <APP_NAME> HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web build: stage: build image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay2 script: - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY - docker pull $IMAGE:build-python || true - docker pull $IMAGE:production || true - docker build --target build-python --cache-from $IMAGE:build-python --tag $IMAGE:build-python --file ./Dockerfile "." - docker build --cache-from $IMAGE:production --tag $IMAGE:production --tag $HEROKU_REGISTRY_IMAGE --file ./Dockerfile "." - docker push $IMAGE:build-python - docker push $IMAGE:production test: stage: test image: $IMAGE:production services: - postgres:latest variables: POSTGRES_DB: test POSTGRES_USER: runner POSTGRES_PASSWORD: "" DATABASE_URL: postgresql://[[email protected]](/cdn-cgi/l/email-protection):5432/test script: - python manage.py test - flake8 hello_django --max-line-length=100 - black hello_django --check - isort hello_django --check --profile black deploy: stage: deploy image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay2 script: - apk add --no-cache curl - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY - docker pull $IMAGE:build-python || true - docker pull $IMAGE:production || true - docker build --target build-python --cache-from $IMAGE:build-python --tag $IMAGE:build-python --file ./Dockerfile "." - docker build --cache-from $IMAGE:production --tag $IMAGE:production --tag $HEROKU_REGISTRY_IMAGE --file ./Dockerfile "." - docker push $IMAGE:build-python - docker push $IMAGE:production - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com - docker push $HEROKU_REGISTRY_IMAGE - chmod +x ./release.sh - ./release.sh`
确保将
<APP_NAME>替换为 Heroku 应用的名称。
自己回顾一下变化。然后,最后一次测试它。
有关这种缓存模式的更多信息,请查看文章使用 Docker 缓存加快 CI 构建速度中的“多阶段”部分。
方法 2:构建清单
如果您正在使用容器注册方法,请跳过这一节。
。gitlab-ci.yml :
`stages: - build - test - deploy variables: IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} HEROKU_APP_NAME: <APP_NAME> build: stage: build image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay2 script: - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY - docker pull $IMAGE:build-python || true - docker pull $IMAGE:production || true - docker build --target build-python --cache-from $IMAGE:build-python --tag $IMAGE:build-python --file ./Dockerfile "." - docker build --cache-from $IMAGE:production --tag $IMAGE:production --file ./Dockerfile "." - docker push $IMAGE:build-python - docker push $IMAGE:production test: stage: test image: $IMAGE:production services: - postgres:latest variables: POSTGRES_DB: test POSTGRES_USER: runner POSTGRES_PASSWORD: "" DATABASE_URL: postgresql://[[email protected]](/cdn-cgi/l/email-protection):5432/test script: - python manage.py test - flake8 hello_django --max-line-length=100 - black hello_django --check - isort hello_django --check --profile black deploy: stage: deploy script: - apt-get update -qy - apt-get install -y ruby-dev - gem install dpl - dpl --provider=heroku --app=$HEROKU_APP_NAME --api-key=$HEROKU_AUTH_TOKEN`
确保将
<APP_NAME>替换为 Heroku 应用的名称。
自己回顾一下变化。然后,最后一次测试它。
有关这种缓存模式的更多信息,请查看文章使用 Docker 缓存加快 CI 构建速度中的“多阶段”部分。
结论
在本文中,我们介绍了两种使用 Docker 将 Django 应用程序部署到 Heroku 的方法——容器注册和构建清单。
那么,什么时候应该考虑使用 Heroku 容器运行时而不是传统的 Git 和 slug 编译器来进行部署呢?
当您需要对生产部署环境进行更多控制时。
示例:
- 您的应用程序和依赖项超过了 500MB 的最大 slug 限制。
- 您的应用程序需要常规 Heroku 构建包没有安装的包。
- 您希望更好地保证您的应用程序在开发中的行为与在生产中的行为相同。
- 你真的真的很喜欢和 Docker 一起工作。
--
您可以在 GitLab 的以下存储库中找到代码:
- 集装箱登记方法- django-heroku-docker
- 构建清单 Aproach-django-heroku-docker-build-Manifest
最好!
通过 Docker 和 GitHub 操作不断将 Django 部署到 Linode
原文:https://testdriven.io/blog/deploying-django-to-linode-with-docker-and-github-actions/
在本教程中,我们将了解如何配置 GitHub 动作来持续地将 Django 和 Docker 应用程序部署到 Linode 。
目标
学完本教程后,您应该能够:
- 将 Django 应用程序部署到 Linode
- 使用 GitHub 包来存储 Docker 图像
- 建立一个 Linode CPU 实例和一个 Linode 管理的数据库
- 启用无密码 SSH 验证
- 使用 GitHub 操作持续部署您的应用程序
项目设置
首先克隆 GitHub repo 并将当前目录更改为其根目录:
`$ git clone [[email protected]](/cdn-cgi/l/email-protection):testdrivenio/django-github-linode.git --branch base --single-branch
$ cd django-github-linode`
好奇这个项目是怎么开发出来的?查看 Postgres、Gunicorn 和 Nginx 的博客文章。
要进行本地测试,构建映像并旋转容器:
`$ docker-compose up -d --build`
如果你的容器以
exec /usr/src/app/entrypoint.sh: no such file or directory退出,打开 app/entrypoint.sh 脚本,执行从CR LF到LF的 EOL 转换。之后再运行docker-compose up -d --build。
导航到 http://localhost:8000/ 。您应该看到:
GitHub 包
GitHub Packages 是一个托管和管理包的平台,包括容器和其他依赖项。它允许你在源代码旁边的 GitHub 上托管你的软件包。我们将使用它来存储 Docker 图像。
要使用 GitHub 包,首先必须创建一个个人访问令牌。导航至开发者设置并选择“个人访问令牌”。点击“生成新令牌”。
给它一个名称/注释-例如,“GitHub actions”-并添加以下权限:
write:packagesread:packagesdelete:packages
接下来,单击“生成令牌”按钮。

您将只能看到令牌一次,因此请将其存放在安全的地方。
构建并标记容器:
`$ docker build -f app/Dockerfile -t ghcr.io/<USERNAME>/<REPOSITORY_NAME>/web:latest ./app
# Example
$ docker build -f app/Dockerfile -t ghcr.io/testdrivenio/django-github-linode/web:latest ./app`
接下来,使用您的个人访问令牌登录 GitHub 包:
`$ docker login ghcr.io -u <USERNAME> -p <TOKEN>
# Example
$ docker login ghcr.io -u testdrivenio -p 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`
最后,将图像推送到 GitHub 包的容器注册表中。
`$ docker push ghcr.io/<USERNAME>/<REPOSITORY_NAME>/web:latest
# Example
$ docker push ghcr.io/testdrivenio/django-github-linode/web:latest`
现在,您应该可以在以下网址之一看到该包(取决于您使用的是个人帐户还是组织):
`# Personal account
https://github.com/<USERNAME>?tab=packages
# Organization
https://github.com/orgs/<ORGANIZATION_NAME>/packages`

Linode CPU
如果您还没有 Linode 帐户,导航到他们的网站并点击“注册”。
首先,我们需要创建一个 Linode CPU 实例。
登录 Linode 仪表盘并选择侧边栏上的“Linodes”。然后点击“创建 Linode”。

要创建一个预装 Docker 的 Linode,我们可以使用 Linode Marketplace 。在导航栏中选择“市场”,在应用程序中选择“Docker”

向下滚动到“选择图像”。对于图像,选择“LTS Ubuntu 20.04”。对于地区,请选择离您的客户最近的地区。然后根据你的项目需求选择一个“Linode 计划”。
如果您正在部署 CPU 密集型 web 应用程序,请选择专用 CPU。如果您预算有限,并且正在部署一个简单的 web 应用程序,请放心使用共享 CPU。有关更多信息,请查看专用与共享 CPU 实例。

让我们把我们的 Linode 命名为“django-docker”。将标签留空。然后选择一个强根密码。您应该记下来,但是没有必要记住它,因为我们很快就会启用无密码 SSH。
出于身份验证的目的,我们将使用 SSH 密钥。要生成密钥,请运行:
将密钥保存到 /root/。ssh/id_rsa 并且不设置密码短语。这将分别生成一个公钥和私钥- id_rsa 和 id_rsa.pub 。要设置无密码 SSH 登录,请将公钥复制到 authorized_keys 文件中,并设置适当的权限:
如果您愿意,可以随意使用现有的 SSH 密钥。
`$ cat ~/.ssh/id_rsa.pub
$ vi ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/id_rsa`
导航回到 Linode 仪表板,点击“添加 SSH 密钥”。给它一个标签并粘贴你的公钥。

将其他内容留空,滚动到底部。点击“创建 Linode”。
您将被重定向到您的 Linode 详细信息。Linode 需要几分钟的时间进行配置。准备就绪后,其状态将变为“正在运行”。

获取 Linode 的 IP,让我们测试一下是否一切正常。
要连接到新创建的 Linode 实例并检查 Docker 版本,请运行:
`$ ssh -o StrictHostKeyChecking=no [[email protected]](/cdn-cgi/l/email-protection)<YOUR_INSTANCE_IP> docker --version
Docker version 20.10.17, build 100c701`
实例运行后,Docker 可能还需要几分钟才能完成安装。
然后,为应用程序创建一个新目录:
很好,Linode CPU 实例现在已经准备好了。在下一步中,我们将设置一个托管数据库。
数据库ˌ资料库
为了托管我们的数据库,我们将使用 Linode 数据库。
在侧边栏上,导航到“数据库”并单击“创建数据库集群”。

将其标记为“django-docker-db”,选择“PostgreSQL v13.2”作为引擎,并为该地区选择最接近您的客户的引擎。根据您的项目需求选择 CPU 计划。
对于一个玩具项目,使用共享的 CPU,因为专用的 CPU 数据库非常昂贵。

一个节点应该绰绰有余。对于访问控制,添加您的 Linode CPU 实例 IP 地址。
要允许任何 IP 连接到数据库,您可以将
0.0.0.0/0添加到访问控制列表中。请记住,这是一种糟糕的安全做法,只应该出于测试目的而这样做。不要像这样公开生产数据。
单击“创建数据库集群”。数据库启动大约需要 10-15 分钟。一旦它准备好了,就获取它的连接细节。

GitHub 操作
GitHub Actions 允许您在 GitHub 存储库中自动化、定制和执行软件开发工作流。当代码被推送到 GitHub repo 时,我们将使用它来构建和部署 Docker 映像。
要配置 GitHub 动作,首先需要创建一个“.github”目录。接下来,在该目录中创建“工作流”目录,并在“工作流”中创建 main.yml :
`.github
└── workflows
└── main.yml`
构建作业
让我们从创建构建作业开始。
将以下内容放入。github/workflows/main.yml :
`name: Continuous Integration and Delivery on: [push] env: WEB_IMAGE: ghcr.io/$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')/web NGINX_IMAGE: ghcr.io/$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')/nginx jobs: build: name: Build Docker Images runs-on: ubuntu-latest steps: - name: Checkout master uses: actions/checkout@v1 - name: Add environment variables to .env run: | echo "DEBUG=0" >> .env echo "SQL_ENGINE=django.db.backends.postgresql" >> .env echo "DATABASE=postgres" >> .env echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env echo "SQL_DATABASE=${{ secrets.SQL_DATABASE }}" >> .env echo "SQL_USER=${{ secrets.SQL_USER }}" >> .env echo "SQL_PASSWORD=${{ secrets.SQL_PASSWORD }}" >> .env echo "SQL_HOST=${{ secrets.SQL_HOST }}" >> .env echo "SQL_PORT=${{ secrets.SQL_PORT }}" >> .env - name: Set environment variables run: | echo "WEB_IMAGE=$(echo ${{env.WEB_IMAGE}} )" >> $GITHUB_ENV echo "NGINX_IMAGE=$(echo ${{env.NGINX_IMAGE}} )" >> $GITHUB_ENV - name: Log in to GitHub Packages run: echo ${PERSONAL_ACCESS_TOKEN} | docker login ghcr.io -u ${{ secrets.NAMESPACE }} --password-stdin env: PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - name: Pull images run: | docker pull ${{ env.WEB_IMAGE }} || true docker pull ${{ env.NGINX_IMAGE }} || true - name: Build images run: | docker-compose -f docker-compose.ci.yml build - name: Push images run: | docker push ${{ env.WEB_IMAGE }} docker push ${{ env.NGINX_IMAGE }}`
首先,我们用on命名工作流并定义它何时运行。接下来,我们设置环境变量并定义build作业。一个任务由一个或多个顺序运行的命令组成。
为了避免泄露像密码和密钥这样的私有变量,我们使用了秘密。因此,为了成功完成这项工作,您需要向 GitHub 库添加一些秘密。
导航至:
`https://github.com/<USERNAME|ORGANIZATION_NAME>/<REPOSITORY_NAME></settings
# Example
https://github.com/testdrivenio/django-github-linode/settings`
点击“秘密”,然后点击“操作”,添加以下秘密:
SECRET_KEY:^8!w90zymm9_0z3h4!_n637hw$^-7g%5-l0npq+zbmqz!v22q9SQL_DATABASE:postgresSQL_USER:linpostgresSQL_PASSWORD:yjNajtqytZU1mp1tSQL_HOST:lin-7098-1320-pgsql-primary.servers.linodedb.netSQL_PORT:5432NAMESPACE:您的 GitHub 用户名或您的组织名称PERSONAL_ACCESS_TOKEN:您的 GitHub 个人访问令牌
确保用您自己的凭据替换 SQL 连接信息。

接下来,在 Django 设置中将您的 Linode 的 IP 地址添加到ALLOWED_HOSTS:
`# app/hello_django/settings.py
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '<your Linode IP>']`
提交您的更改并将您的代码上传到 GitHub 以触发新的构建。确保它成功完成。

如果您检查您的包,您会注意到创建了两个映像,web和nginx。 NGINX 作为反向代理服务于 web 应用和静态/媒体文件。
部署作业
接下来,在build作业之后添加一个名为deploy的新作业:
`deploy: name: Deploy to Linode runs-on: ubuntu-latest needs: build steps: - name: Checkout master uses: actions/checkout@v1 - name: Add environment variables to .env run: | echo "DEBUG=0" >> .env echo "SQL_ENGINE=django.db.backends.postgresql" >> .env echo "DATABASE=postgres" >> .env echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env echo "SQL_DATABASE=${{ secrets.SQL_DATABASE }}" >> .env echo "SQL_USER=${{ secrets.SQL_USER }}" >> .env echo "SQL_PASSWORD=${{ secrets.SQL_PASSWORD }}" >> .env echo "SQL_HOST=${{ secrets.SQL_HOST }}" >> .env echo "SQL_PORT=${{ secrets.SQL_PORT }}" >> .env echo "WEB_IMAGE=${{ env.WEB_IMAGE }}" >> .env echo "NGINX_IMAGE=${{ env.NGINX_IMAGE }}" >> .env echo "NAMESPACE=${{ secrets.NAMESPACE }}" >> .env echo "PERSONAL_ACCESS_TOKEN=${{ secrets.PERSONAL_ACCESS_TOKEN }}" >> .env - name: Add the private SSH key to the ssh-agent env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | mkdir -p ~/.ssh ssh-agent -a $SSH_AUTH_SOCK > /dev/null ssh-keyscan github.com >> ~/.ssh/known_hosts ssh-add - <<< "${{ secrets.PRIVATE_KEY }}" - name: Build and deploy images on Linode env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | scp -o StrictHostKeyChecking=no -r ./.env ./docker-compose.prod.yml root@${{ secrets.LINODE_IP_ADDRESS }}:/app ssh -o StrictHostKeyChecking=no root@${{ secrets.LINODE_IP_ADDRESS }} << 'ENDSSH' cd /app source .env docker login ghcr.io -u $NAMESPACE -p $PERSONAL_ACCESS_TOKEN docker pull $WEB_IMAGE docker pull $NGINX_IMAGE sudo docker-compose -f docker-compose.prod.yml up -d ENDSSH`
只有当build任务成功完成(通过needs: build)时,该任务才会运行。
作业步骤:
- 检查存储库。
- 将环境变量(包括秘密)添加到一个中。env 文件。
- 初始化 SSH 代理并添加 Linode 的私有 SSH 密钥。
- 复制。env 和 docker-compose.prod 到 Linode。
- 嘘去里诺德。
- 更改活动目录。
- 登录到容器注册表并提取图像。
- 使用 Docker Compose 部署图像。
将以下两个秘密添加到您的回购中:
LINODE_IP_ADDRESS:您的链接的 IP 地址PRIVATE_KEY:你的 SSH 私钥
为了测试,提交然后推送你的代码。
确保两个作业都成功完成。然后,导航到您的网站。您应该看到:
试验
最后,为了确保部署作业只在对主分支进行更改时运行,在needs: build下面添加if: github.ref == 'refs/heads/master'。
如果你使用
main作为默认分支,确保用main替换master。
`deploy: name: Deploy to Linode runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/master' steps: - name: Checkout master uses: actions/checkout@v1 - name: Add environment variables to .env run: | echo "DEBUG=0" >> .env echo "SQL_ENGINE=django.db.backends.postgresql" >> .env echo "DATABASE=postgres" >> .env echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env echo "SQL_DATABASE=${{ secrets.SQL_DATABASE }}" >> .env echo "SQL_USER=${{ secrets.SQL_USER }}" >> .env echo "SQL_PASSWORD=${{ secrets.SQL_PASSWORD }}" >> .env echo "SQL_HOST=${{ secrets.SQL_HOST }}" >> .env echo "SQL_PORT=${{ secrets.SQL_PORT }}" >> .env echo "WEB_IMAGE=${{ env.WEB_IMAGE }}" >> .env echo "NGINX_IMAGE=${{ env.NGINX_IMAGE }}" >> .env echo "NAMESPACE=${{ secrets.NAMESPACE }}" >> .env echo "PERSONAL_ACCESS_TOKEN=${{ secrets.PERSONAL_ACCESS_TOKEN }}" >> .env - name: Add the private SSH key to the ssh-agent env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | mkdir -p ~/.ssh ssh-agent -a $SSH_AUTH_SOCK > /dev/null ssh-keyscan github.com >> ~/.ssh/known_hosts ssh-add - <<< "${{ secrets.PRIVATE_KEY }}" - name: Build and deploy images on Linode env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: | scp -o StrictHostKeyChecking=no -r ./.env ./docker-compose.prod.yml root@${{ secrets.LINODE_IP_ADDRESS }}:/app ssh -o StrictHostKeyChecking=no root@${{ secrets.LINODE_IP_ADDRESS }} << 'ENDSSH' cd /app source .env docker login ghcr.io -u $NAMESPACE -p $PERSONAL_ACCESS_TOKEN docker pull $WEB_IMAGE docker pull $NGINX_IMAGE sudo docker-compose -f docker-compose.prod.yml up -d ENDSSH`
为了测试它是否工作,创建一个新的dev分支。然后将 app/hello_django/urls.py 中的hello world消息改为hello linode:
`def home(request):
return JsonResponse({"hello": "linode"})`
将您的更改提交并推送到 GitHub。确保仅运行build作业。一旦构建通过,打开一个针对master(或者main)分支的 PR 并合并变更。这将触发两个阶段的新构建,build和deploy。确保部署按预期工作:
--
就是这样。你可以从django-github-Li noderepo 中抓取最终的源代码。
使用 Docker 和 Gitlab CI 将 Flask 和 Vue 应用程序部署到 Heroku
原文:https://testdriven.io/blog/deploying-flask-to-heroku-with-docker-and-gitlab/
这篇文章着眼于如何封装一个由 Flask 和 Vue 支持的全栈 web 应用,并使用 Gitlab CI 将其部署到 Heroku。
这是一个中级教程。它假设你有 Vue、Flask 和 Docker 的基本工作知识。查看以下资源以了解更多信息:
- Flask:Flask、测试驱动开发(TDD)和 JavaScript 简介
- 用 Flask 和 Vue.js 开发单页应用
- 通过构建和部署 CRUD 应用程序学习 Vue
- Docker 入门
目标
本教程结束时,您将能够:
- 使用多级构建,用单个 Dockerfile 将烧瓶和 Vue 容器化
- 使用 Docker 将应用程序部署到 Heroku
- 配置 GitLab CI 以将 Docker 映像部署到 Heroku
项目设置
如果你想继续,从 GitHub 克隆出 flask-vue-crud repo,创建并激活一个虚拟环境,然后启动 flask 应用程序:
`$ git clone https://github.com/testdrivenio/flask-vue-crud
$ cd flask-vue-crud
$ cd server
$ python3.9 -m venv env
$ source env/bin/activate
(env)$
(env)$ pip install -r requirements.txt
(env)$ python app.py`
上述用于创建和激活虚拟环境的命令可能会因您的环境和操作系统而异。
将您选择的浏览器指向http://localhost:5000/ping。您应该看到:
然后,安装依赖项并在不同的终端选项卡中运行 Vue 应用程序:
`$ cd client
$ npm install
$ npm run serve`
导航到 http://localhost:8080 。确保基本的 CRUD 功能按预期工作,然后关闭两个应用程序:

想学习如何构建这个项目吗?查看用 Flask 和 Vue.js 开发单页应用的博文。
码头工人
先说 Docker。
将以下 Dockerfile 文件添加到项目根目录。
`# build
FROM node:15.7.0-alpine3.10 as build-vue
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
COPY ./client/package*.json ./
RUN npm install
COPY ./client .
RUN npm run build
# production
FROM nginx:stable-alpine as production
WORKDIR /app
RUN apk update && apk add --no-cache python3 && \
python3 -m ensurepip && \
rm -r /usr/lib/python*/ensurepip && \
pip3 install --upgrade pip setuptools && \
if [ ! -e /usr/bin/pip ]; then ln -s pip3 /usr/bin/pip ; fi && \
if [[ ! -e /usr/bin/python ]]; then ln -sf /usr/bin/python3 /usr/bin/python; fi && \
rm -r /root/.cache
RUN apk update && apk add postgresql-dev gcc python3-dev musl-dev
COPY --from=build-vue /app/dist /usr/share/nginx/html
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
COPY ./server/requirements.txt .
RUN pip install -r requirements.txt
RUN pip install gunicorn
COPY ./server .
CMD gunicorn -b 0.0.0.0:5000 app:app --daemon && \
sed -i -e 's/$PORT/'"$PORT"'/g' /etc/nginx/conf.d/default.conf && \
nginx -g 'daemon off;'`
这里发生了什么事?
- 我们使用了多阶段构建来缩小最终的图像尺寸。本质上,
build-vue是一个临时映像,用于生成 Vue 应用程序的生产版本。然后,生产静态文件被复制到production映像,而build-vue映像被丢弃。 production镜像通过安装 Python,从build-vue镜像复制静态文件,复制我们的 nginx 配置,安装需求,运行 Gunicorn 和 Nginx 来扩展 nginx:stable-alpine 镜像。- 记下
sed -i -e 's/$PORT/'"$PORT"'/g' /etc/nginx/conf.d/default.conf命令。这里,我们用 Heroku 提供的环境变量PORT替换default.conf文件中的$PORT。
接下来,向项目根目录添加一个名为“nginx”的新文件夹,然后向该文件夹添加一个名为 default.conf 的新配置文件:
`server { listen $PORT; root /usr/share/nginx/html; index index.html index.html; location / { try_files $uri /index.html =404; } location /ping { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_redirect default; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $server_name; } location /books { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_redirect default; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $server_name; } }`
要进行本地测试,首先删除client/src/components/books . vue和client/src/components/ping . vue中的http://localhost:5000的所有实例。例如,Books组件中的getBooks方法现在应该是这样的:
`getBooks() { const path = '/books'; axios.get(path) .then((res) => { this.books = res.data.books; }) .catch((error) => { // eslint-disable-next-line console.error(error); }); },`
接下来,构建映像并在分离模式下运行容器:
`$ docker build -t web:latest .
$ docker run -d --name flask-vue -e "PORT=8765" -p 8007:8765 web:latest`
注意我们是如何传入一个名为PORT的环境变量的。如果一切顺利,那么我们应该在运行容器中的 default.conf 文件中看到这个变量:
`$ docker exec flask-vue cat ../etc/nginx/conf.d/default.conf`
确保 Nginx 正在监听端口 8765: listen 8765;。此外,确保该应用程序正在浏览器中的 http://localhost:8007 上运行。完成后,停止并移除正在运行的容器:
`$ docker stop flask-vue
$ docker rm flask-vue`
Heroku
注册一个 Heroku 账号(如果你还没有的话),然后安装 Heroku CLI (如果你还没有的话)。
创建新应用程序:
`$ heroku create
Creating app... done, ⬢ lit-savannah-00898
https://lit-savannah-00898.herokuapp.com/ | https://git.heroku.com/lit-savannah-00898.git`
登录到 Heroku 容器注册表:
重建图像,并使用以下格式对其进行标记:
`registry.heroku.com/<app>/<process-type>`
确保将<app>替换为您刚刚创建的 Heroku 应用的名称,将<process-type>替换为web,因为这将是一个 web dyno 。
例如:
`$ docker build -t registry.heroku.com/lit-savannah-00898/web .`
将图像推送到注册表:
`$ docker push registry.heroku.com/lit-savannah-00898/web`
发布图像:
`$ heroku container:release --app lit-savannah-00898 web`
确保将上述每个命令中的
lit-savannah-00898替换为您的应用程序名称。
这将运行容器。您应该可以在https://APP _ name . heroku APP . com查看应用程序。
GitLab CI
注册一个 GitLab 账号(如果需要的话),然后创建一个新项目(再次,如果需要的话)。
取回您的 Heroku 认证令牌:
然后,在项目的 CI/CD 设置中将令牌保存为一个名为HEROKU_AUTH_TOKEN的新变量:Settings > CI / CD > Variables。

接下来,添加一个名为的 GitLab CI/CD 配置文件。gitlab-ci.yml 到项目根:
`image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay HEROKU_APP_NAME: <APP_NAME> HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web stages: - build docker-build: stage: build script: - apk add --no-cache curl - docker build --tag $HEROKU_REGISTRY_IMAGE --file ./Dockerfile "." - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com - docker push $HEROKU_REGISTRY_IMAGE - chmod +x ./release.sh - ./release.sh`
release.sh :
`#!/bin/sh
IMAGE_ID=$(docker inspect ${HEROKU_REGISTRY_IMAGE} --format={{.Id}})
PAYLOAD='{"updates": [{"type": "web", "docker_image": "'"$IMAGE_ID"'"}]}'
curl -n -X PATCH https://api.heroku.com/apps/$HEROKU_APP_NAME/formation \
-d "${PAYLOAD}" \
-H "Content-Type: application/json" \
-H "Accept: application/vnd.heroku+json; version=3.docker-releases" \
-H "Authorization: Bearer ${HEROKU_AUTH_TOKEN}"`
在这里,我们定义了单个build 阶段,在这里我们:
- 安装卷曲
- 构建并标记新图像
- 登录 Heroku 容器注册表
- 将图像上传到注册表
- 使用 release.sh 脚本中的映像 ID,通过 Heroku API 创建一个新版本
确保将
<APP_NAME>替换为 Heroku 应用的名称。
这样,提交并把你的修改推送到 GitLab 来触发一个新的管道。这将作为单个作业运行build阶段。一旦完成,Heroku 上将自动创建一个新版本。
最后,更新配置脚本以利用 Docker 层缓存:
`image: docker:stable services: - docker:dind variables: DOCKER_DRIVER: overlay HEROKU_APP_NAME: <APP_NAME> CACHE_IMAGE: ${CI_REGISTRY}/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME} HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/web stages: - build docker-build: stage: build script: - apk add --no-cache curl - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY - docker pull $CACHE_IMAGE:build-vue || true - docker pull $CACHE_IMAGE:production || true - docker build --target build-vue --cache-from $CACHE_IMAGE:build-vue --tag $CACHE_IMAGE:build-vue --file ./Dockerfile "." - docker build --cache-from $CACHE_IMAGE:production --tag $CACHE_IMAGE:production --tag $HEROKU_REGISTRY_IMAGE --file ./Dockerfile "." - docker push $CACHE_IMAGE:build-vue - docker push $CACHE_IMAGE:production - docker login -u _ -p $HEROKU_AUTH_TOKEN registry.heroku.com - docker push $HEROKU_REGISTRY_IMAGE - chmod +x ./release.sh - ./release.sh`
现在,安装 cURL 后,我们:
- 登录到 GitLab 容器注册表
- 提取之前推送的图像(如果它们存在)
- 构建并标记新图像(包括
build-vue和production) - 将图像上传到 GitLab 容器注册表
- 登录 Heroku 容器注册表
- 将
production图像上传到注册表 - 使用 release.sh 脚本中的映像 ID,通过 Heroku API 创建一个新版本
有关这种缓存模式的更多信息,请查看用 Docker 缓存加快 CI 构建的文章中的“多阶段”部分。
对一个 Vue 组件进行快速更改。提交您的代码,并再次将其推送到 GitLab。您的应用程序应该自动部署到 Heroku!
在 Kubernetes 上部署 Spark
原文:https://testdriven.io/blog/deploying-spark-on-kubernetes/
这篇文章详细介绍了如何在 Kubernetes 集群上部署 Spark。
依赖关系:
- 文档版本 20.10.10
- Minikube v1.24.0
- spark 3 . 2 . 0 版
- Hadoop 版本 3.3.1
迷你库贝
Minikube 是一个用于在本地运行单节点 Kubernetes 集群的工具。
遵循官方的安装 Minikube 指南,将其与虚拟机管理程序(如 VirtualBox 或 HyperKit )一起安装,以管理虚拟机,并与 Kubectl 一起安装,以在 Kubernetes 上部署和管理应用。
默认情况下,Minikube 虚拟机配置为使用 1GB 内存和 2 个 CPU 内核。这对于 Spark 作业来说是不够的,所以一定要在你的 Docker 客户端(对于 HyperKit)或者直接在 VirtualBox 中增加内存。然后,当您启动 Minikube 时,将内存和 CPU 选项传递给它:
`$ minikube start --vm-driver=hyperkit --memory 8192 --cpus 4
or
$ minikube start --memory 8192 --cpus 4`
码头工人
接下来,让我们为 Spark 3.2.0 构建一个定制的 Docker 映像,它是为 Spark 单机模式设计的。
Dockerfile :
`# base image
FROM openjdk:11
# define spark and hadoop versions
ENV SPARK_VERSION=3.2.0
ENV HADOOP_VERSION=3.3.1
# download and install hadoop
RUN mkdir -p /opt && \
cd /opt && \
curl http://archive.apache.org/dist/hadoop/common/hadoop-${HADOOP_VERSION}/hadoop-${HADOOP_VERSION}.tar.gz | \
tar -zx hadoop-${HADOOP_VERSION}/lib/native && \
ln -s hadoop-${HADOOP_VERSION} hadoop && \
echo Hadoop ${HADOOP_VERSION} native libraries installed in /opt/hadoop/lib/native
# download and install spark
RUN mkdir -p /opt && \
cd /opt && \
curl http://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/spark-${SPARK_VERSION}-bin-hadoop2.7.tgz | \
tar -zx && \
ln -s spark-${SPARK_VERSION}-bin-hadoop2.7 spark && \
echo Spark ${SPARK_VERSION} installed in /opt
# add scripts and update spark default config
ADD common.sh spark-master spark-worker /
ADD spark-defaults.conf /opt/spark/conf/spark-defaults.conf
ENV PATH $PATH:/opt/spark/bin`
你可以在 GitHub 的 spark-kubernetes repo 中找到上面的 Dockerfile 以及 Spark 配置文件和脚本。
建立形象:
`$ eval $(minikube docker-env)
$ docker build -f docker/Dockerfile -t spark-hadoop:3.2.0 ./docker`
如果你不想花时间在本地构建映像,请随意使用我从Docker Hub:mj hea 0/Spark-Hadoop:3 . 2 . 0中预先构建的 Spark 映像。
查看:
`$ docker image ls spark-hadoop
REPOSITORY TAG IMAGE ID CREATED SIZE
spark-hadoop 3.2.0 8f3ccdadd795 11 minutes ago 1.12GB`
火花大师
spark-master-deployment . YAML:
`kind: Deployment apiVersion: apps/v1 metadata: name: spark-master spec: replicas: 1 selector: matchLabels: component: spark-master template: metadata: labels: component: spark-master spec: containers: - name: spark-master image: spark-hadoop:3.2.0 command: ["/spark-master"] ports: - containerPort: 7077 - containerPort: 8080 resources: requests: cpu: 100m`
spark-master-service . YAML:
`kind: Service apiVersion: v1 metadata: name: spark-master spec: ports: - name: webui port: 8080 targetPort: 8080 - name: spark port: 7077 targetPort: 7077 selector: component: spark-master`
创建 Spark 主部署并启动服务:
`$ kubectl create -f ./kubernetes/spark-master-deployment.yaml
$ kubectl create -f ./kubernetes/spark-master-service.yaml`
验证:
`$ kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
spark-master 1/1 1 1 2m55s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
spark-master-dbc47bc9-tlgfs 1/1 Running 0 3m8s`
星火工人
spark-worker-deployment . YAML:
`kind: Deployment apiVersion: apps/v1 metadata: name: spark-worker spec: replicas: 2 selector: matchLabels: component: spark-worker template: metadata: labels: component: spark-worker spec: containers: - name: spark-worker image: spark-hadoop:3.2.0 command: ["/spark-worker"] ports: - containerPort: 8081 resources: requests: cpu: 100m`
创建 Spark worker 部署:
`$ kubectl create -f ./kubernetes/spark-worker-deployment.yaml`
验证:
`$ kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
spark-master 1/1 1 1 6m35s
spark-worker 2/2 2 2 7s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
spark-master-dbc47bc9-tlgfs 1/1 Running 0 6m53s
spark-worker-795dc47587-fjkjt 1/1 Running 0 25s
spark-worker-795dc47587-g9n64 1/1 Running 0 25s`
进入
你有没有注意到我们在 8080 端口暴露了 Spark web UI?为了在集群外部访问它,让我们配置一个入口对象。
minikube-ingress.yaml :
`apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: minikube-ingress annotations: spec: rules: - host: spark-kubernetes http: paths: - pathType: Prefix path: / backend: service: name: spark-master port: number: 8080`
启用入口插件:
`$ minikube addons enable ingress`
创建入口对象:
`$ kubectl apply -f ./kubernetes/minikube-ingress.yaml`
接下来,您需要更新您的 /etc/hosts 文件,以便将请求从我们定义的主机spark-kubernetes路由到 Minikube 实例。
将条目添加到/etc/hosts:
`$ echo "$(minikube ip) spark-kubernetes" | sudo tee -a /etc/hosts`
在浏览器中进行测试,网址为 http://spark-kubernetes/ :

试验
要进行测试,从主容器运行 PySpark shell:
`$ kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
spark-master-dbc47bc9-t6v84 1/1 Running 0 7m35s 172.17.0.6 minikube <none> <none>
spark-worker-795dc47587-5ch8f 1/1 Running 0 7m24s 172.17.0.9 minikube <none> <none>
spark-worker-795dc47587-fvcf6 1/1 Running 0 7m24s 172.17.0.7 minikube <none> <none>
$ kubectl exec spark-master-dbc47bc9-t6v84 -it -- \
pyspark --conf spark.driver.bindAddress=172.17.0.6 --conf spark.driver.host=172.17.0.6`
然后在 PySpark 提示符出现后运行以下代码:
`words = 'the quick brown fox jumps over the\
lazy dog the quick brown fox jumps over the lazy dog'
sc = SparkContext.getOrCreate()
seq = words.split()
data = sc.parallelize(seq)
counts = data.map(lambda word: (word, 1)).reduceByKey(lambda a, b: a + b).collect()
dict(counts)
sc.stop()`
您应该看到:
`{'brown': 2, 'lazy': 2, 'over': 2, 'fox': 2, 'dog': 2, 'quick': 2, 'the': 4, 'jumps': 2}`
就是这样!
你可以在 GitHub 的 spark-kubernetes repo 中找到这些脚本。干杯!
部署保险库和领事
我们来看看如何用 Docker Swarm 部署 Hashicorp 的金库和领事到数字海洋。
本教程假设您具备使用 Vault 和 Consul 管理机密的基本工作知识。更多信息请参考保管库管理机密和咨询教程。
完成后,您将能够:
- 使用 Docker 机器在 DigitalOcean 上配置主机
- 配置一个 Docker 群集群在数字海洋上运行
- 在码头群上运行金库和领事
主要依赖:
- 文档 v20.10.8
- 坞站-复合 v1.29.2
- 对接机 v0.16.2
- 保险库版本 1.8.3
- 领事 v1.10.3
领事
创建新的项目目录:
`$ mkdir vault-consul-swarm && cd vault-consul-swarm`
然后,将一个 docker-compose.yml 文件添加到项目根:
`version: "3.8" services: server-bootstrap: image: consul:1.10.3 ports: - 8500:8500 command: "agent -server -bootstrap-expect 3 -ui -client 0.0.0.0 -bind '{{ GetInterfaceIP \"eth0\" }}'" server: image: consul:1.10.3 command: "agent -server -retry-join server-bootstrap -client 0.0.0.0 -bind '{{ GetInterfaceIP \"eth0\" }}'" deploy: replicas: 2 depends_on: - server-bootstrap client: image: consul:1.10.3 command: "agent -retry-join server-bootstrap -client 0.0.0.0 -bind '{{ GetInterfaceIP \"eth0\" }}'" deploy: replicas: 2 depends_on: - server-bootstrap networks: default: external: true name: core`
这种配置应该看起来很熟悉。
- 参考 Docker Swarm 博客文章运行 Flask 的合成文件部分,了解更多关于使用 Docker Swarm 模式合成文件的信息。
- 查看 Consul 和 Docker 指南,了解上述 Consul 配置的信息。
码头工人群
注册一个数字海洋账户(如果你还没有的话),然后生成一个访问令牌,这样你就可以访问数字海洋 API 了。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
旋转三个液滴:
`$ for i in 1 2 3; do
docker-machine create \
--driver digitalocean \
--digitalocean-region "nyc1" \
--digitalocean-image=debian-10-x64 \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
node-$i;
done`
在第一个节点node-1上初始化群模式:
`$ docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1)`
使用上一个命令输出中的 join 令牌将剩余的两个节点作为 workers 添加到群中:
`$ for i in 2 3; do
docker-machine ssh node-$i -- docker swarm join --token YOUR_JOIN_TOKEN HOST:PORT;
done`
例如:
`for i in 2 3; do
docker-machine ssh node-$i -- docker swarm join --token SWMTKN-1-18xrfgcgq7k6krqr7tvav3ydx5c5104y662lzh4pyct2t0ror3-e3ed1ggivhf8z15i40z6x55g5 67.205.165.166:2377;
done`
您应该看到:
`This node joined a swarm as a worker.
This node joined a swarm as a worker.`
将 Docker 守护进程指向node-1,创建一个可连接的覆盖网络(称为core),并部署堆栈:
`$ eval $(docker-machine env node-1)
$ docker network create -d overlay --attachable core
$ docker stack deploy --compose-file=docker-compose.yml secrets`
列出堆栈中的服务:
`$ docker stack ps -f "desired-state=running" secrets`
您应该会看到类似如下的内容:
`ID NAME IMAGE NODE DESIRED STATE CURRENT STATE
b5f5eycrhf3o secrets_client.1 consul:1.10.3 node-1 Running Running 7 seconds ago
zs7a5t8khcew secrets_server.1 consul:1.10.3 node-2 Running Running 9 seconds ago
qnhtlan6m0sp secrets_server-bootstrap.1 consul:1.10.3 node-1 Running Running 7 seconds ago
u61eycesmsl7 secrets_client.2 consul:1.10.3 node-2 Running Running 9 seconds ago
vgpql8lfy5fi secrets_server.2 consul:1.10.3 node-3 Running Running 9 seconds ago`
抓取与node-1关联的 IP:
`$ docker-machine ip node-1`
然后,在http://YOUR _ MACHINE _ IP:8500/UI的浏览器中测试 Consul UI。应该有三个正在运行的服务和五个节点。


跳跃
将vault服务添加到 docker-compose.yml :
`vault: image: vault:1.8.3 deploy: replicas: 1 ports: - 8200:8200 environment: - VAULT_ADDR=http://127.0.0.1:8200 - VAULT_LOCAL_CONFIG={"backend":{"consul":{"address":"http://server-bootstrap:8500","path":"vault/"}},"listener":{"tcp":{"address":"0.0.0.0:8200","tls_disable":1}},"ui":true, "disable_mlock":true} command: server depends_on: - consul`
记下VAULT_LOCAL_CONFIG环境变量:
`{ "backend": { "consul": { "address": "http://server-bootstrap:8500", "path": "vault/" } }, "listener": { "tcp": { "address": "0.0.0.0:8200", "tls_disable": 1 } }, "ui": true, "disable_mlock": true }`
查看领事后端部分,从用 Vault 和领事管理秘密的博文中获取更多信息。此外,对于生产环境,不建议将 disable_mlock 设置为true;但是,由于--cap-add在 Docker 群组模式下不可用,因此必须将其启用。有关详细信息,请参见以下 GitHub 问题:
试验
重新部署堆栈:
`$ docker stack deploy --compose-file=docker-compose.yml secrets`
等待几秒钟,让服务开始运转,然后检查状态:
`$ docker stack ps -f "desired-state=running" secrets`
同样,您应该看到类似于以下内容的内容:
`ID NAME IMAGE NODE DESIRED STATE CURRENT STATE
xtfsetfrbrs7 secrets_client.1 consul:1.10.3 node-3 Running Running 19 minutes ago
ydqxexgiyzb2 secrets_client.2 consul:1.10.3 node-1 Running Running 19 minutes ago
izlku3y6j8rp secrets_server-bootstrap.1 consul:1.10.3 node-2 Running Running 19 minutes ago
zqpkcrhrix2x secrets_server.1 consul:1.10.3 node-1 Running Running 19 minutes ago
kmlxuhxw1akv secrets_server.2 consul:1.10.3 node-2 Running Running 19 minutes ago
wfmscoj53m39 secrets_vault.1 vault:1.8.3 node-3 Running Running about a minute ago`
接下来,确保 Vault 列在 Consul UI 的“服务”部分:

现在,您应该能够通过 CLI、HTTP API 和 UI 与 Vault 进行交互。从初始化和解封保险库开始。然后,登录并创建一个新的秘密。
完成后删除节点:
`$ docker-machine rm node-1 node-2 node-3 -y`
自动化脚本
最后,让我们创建一个快速脚本来自动化部署过程:
- 用 Docker 机器提供三个数字海洋液滴
- 配置 Docker 群组模式
- 向群集添加节点
- 部署堆栈
将名为 deploy.sh 的新文件添加到项目根目录:
`#!/bin/bash
echo "Spinning up three droplets..."
for i in 1 2 3; do
docker-machine create \
--driver digitalocean \
--digitalocean-region "nyc1" \
--digitalocean-image=debian-10-x64 \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
node-$i;
done
echo "Initializing Swarm mode..."
docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1)
echo "Adding the nodes to the Swarm..."
TOKEN=`docker-machine ssh node-1 docker swarm join-token worker | grep token | awk '{ print $5 }'`
for i in 2 3; do
docker-machine ssh node-$i \
-- docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377;
done
echo "Creating networking..."
eval $(docker-machine env node-1)
docker network create -d overlay --attachable core
echo "Deploying the stack..."
docker stack deploy --compose-file=docker-compose.yml secrets`
试试吧!
完成后将水滴带下来:
`$ docker-machine rm node-1 node-2 node-3 -y`
代码可以在 vault-consul-swarm repo 中找到。干杯!
用 FastAPI 和 Vue.js 开发单页应用
原文:https://testdriven.io/blog/developing-a-single-page-app-with-fastapi-and-vuejs/
以下是如何使用 FastAPI、Vue、Docker 和 Postgres 构建和封装一个基本 CRUD 应用程序的分步演练。我们将从后端开始,开发一个由 Python、FastAPI 和 Docker 支持的 RESTful API,然后转移到前端。我们还将连接基于令牌的身份验证。
最终应用:

主要依赖:
- 视图 v3.2.45
- CLI 视图 v5.0.8
- 节点 v18.12.1
- npm v8.19.2
- FastAPI v0.88.0
- python 3 . 11 . 1 版
这是一个中级教程,重点是分别用 FastAPI 和 Vue 开发后端和前端 app。除了应用程序本身,您还可以添加身份验证,并将它们集成在一起。假设您有使用 FastAPI、Vue 和 Docker 的经验。请参见 FastAPI 和 Vue 部分,了解学习上述工具和技术的推荐资源。
目标
本教程结束时,您将能够:
- 解释什么是 FastAPI
- 解释什么是 Vue,以及它与其他 UI 库和前端框架(如 React 和 Angular)相比如何
- 用 FastAPI 开发 RESTful API
- 使用 Vue CLI 搭建 Vue 项目
- 在浏览器中创建和渲染 Vue 组件
- 使用 Vue 组件创建单页面应用程序(SPA)
- 将 Vue 应用程序连接到 FastAPI 后端
- 使用引导程序设计 Vue 组件
- 使用 Vue 路由器创建路线并渲染组件
- 使用基于令牌的身份验证管理用户身份验证
FastAPI 和视图
让我们快速看一下每个框架。
什么是 FastAPI?
FastAPI 是一个包含电池的现代 Python web 框架,非常适合构建 RESTful APIs。它可以处理同步和异步请求,并内置了对数据验证、JSON 序列化、认证和授权以及 OpenAPI 文档的支持。
亮点:
- 受 Flask 的启发,它有一种轻量级微框架的感觉,支持类似 Flask 的 route decorators。
- 它利用 Python 类型提示进行参数声明,支持数据验证(通过 pydantic )和 OpenAPI/Swagger 文档。
- 它建立在 Starlette 之上,支持异步 API 的开发。
- 已经快了。由于 async 比传统的同步线程模型更有效,所以在性能方面它可以与 Node 和 Go 竞争。
- 因为它基于并完全兼容 OpenAPI 和 JSON Schema,所以它支持许多强大的工具,比如 Swagger UI。
- 它有惊人的文档。
第一次用 FastAPI?查看以下资源:
Vue 是什么?
Vue 是一个开源的 JavaScript 框架,用于构建用户界面。它采用了 React 和 Angular 的一些最佳实践。也就是说,与 React 和 Angular 相比,它要平易近人得多,因此初学者可以快速上手并运行。它也同样强大,因此它提供了创建现代前端应用程序所需的所有功能。
有关 Vue 的更多信息,以及使用它与 React 和 Angular 相比的优缺点,请查看以下文章:
第一次用 Vue?
- 花点时间通读官方 Vue 指南中的介绍。
- 查看 Learn Vue,构建并部署 CRUD App 课程。
我们在建造什么?
我们的目标是为两种资源——用户和 notes——设计一个后端 RESTful API,由 Python 和 FastAPI 提供支持。API 本身应该遵循 RESTful 设计原则,使用基本的 HTTP 动词:GET、POST、PUT 和 DELETE。
我们还将使用 Vue 设置一个前端应用程序,与后端 API 进行交互:

核心功能:
- 经过身份验证的用户将能够查看、添加、更新和删除注释
- 经过身份验证的用户也可以查看他们的用户信息并删除他们自己
本教程主要是讨论快乐之路。对于读者来说,处理不愉快/异常路径是一个单独的练习。检查您的理解,并为前端和后端添加适当的错误处理。
FastAPI Setup
首先创建一个名为“fastapi-vue”的新项目文件夹,并添加以下文件和文件夹:
`fastapi-vue
├── docker-compose.yml
└── services
└── backend
├── Dockerfile
├── requirements.txt
└── src
└── main.py`
以下命令将创建项目结构:
`$ mkdir fastapi-vue && \ cd fastapi-vue && \ mkdir -p services/backend/src && \ touch docker-compose.yml services/backend/Dockerfile && \ touch services/backend/requirements.txt services/backend/src/main.py`
接下来,将以下代码添加到服务/后端/Dockerfile 中:
`FROM python:3.11-buster
RUN mkdir app
WORKDIR /app
ENV PATH="${PATH}:/root/.local/bin"
ENV PYTHONPATH=.
COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
COPY src/ .`
将以下依赖项添加到服务/后端/需求. txt 文件中:
`fastapi==0.88.0
uvicorn==0.20.0`
更新坞站-复合. yml 如:
`version: '3.8' services: backend: build: ./services/backend ports: - 5000:5000 volumes: - ./services/backend:/app command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000`
在我们构建映像之前,让我们添加一个到services/back end/src/main . py的测试路径,这样我们就可以快速测试应用是否构建成功:
`from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def home():
return "Hello, World!"`
在您的终端中构建映像:
`$ docker-compose up -d --build`
完成后,在您选择的浏览器中导航至 http://127.0.0.1:5000/ 。您应该看到:
可以在http://localhost:5000/docs查看 Swagger UI。
接下来,添加中间件:
`from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware # NEW
app = FastAPI()
# NEW
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def home():
return "Hello, World!"`
CORSMiddleware需要发出跨来源请求——即来自不同协议、IP 地址、域名或端口的请求。这是必要的,因为前端将在http://localhost:8080运行。
视图设置
从我们的前端开始,我们将使用 Vue CLI 搭建一个项目。
确保您使用的是 Vue CLI 的版本 5.0.8 :
接下来,从“fastapi-vue/services”文件夹中,搭建出一个新的 vue 项目:
选择Default ([Vue 3] babel, eslint)。
脚手架搭好之后,添加路由器(对历史模式说是),并安装所需的依赖项:
我们将很快讨论这些依赖项。
要在本地提供 Vue 应用程序,请运行:
导航至 http://localhost:8080/ 查看您的应用。
关掉服务器。
接下来,在services/frontend/src/main . js中连接 Axios 和 Bootstrap 的依赖关系:
`import 'bootstrap/dist/css/bootstrap.css'; import { createApp } from "vue"; import axios from 'axios'; import App from './App.vue'; import router from './router'; const app = createApp(App); axios.defaults.withCredentials = true; axios.defaults.baseURL = 'http://localhost:5000/'; // the FastAPI backend app.use(router); app.mount("#app");`
向“服务/前端”添加一个 Dockerfile :
`FROM node:lts-alpine
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
RUN npm install @vue/[[email protected]](/cdn-cgi/l/email-protection) -g
COPY package.json .
COPY package-lock.json .
RUN npm install
CMD ["npm", "run", "serve"]`
增加 a 服务至码头-化合物. yml :
`version: '3.8' services: backend: build: ./services/backend ports: - 5000:5000 volumes: - ./services/backend:/app command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000 frontend: build: ./services/frontend volumes: - './services/frontend:/app' - '/app/node_modules' ports: - 8080:8080`
构建新映像并旋转容器:
`$ docker-compose up -d --build`
确保 http://localhost:8080/ 仍然工作。
接下来,更新services/frontend/src/components/hello world . vue如下:
`<template>
<div>
<p>{{ msg }}</p>
</div>
</template>
<script> import axios from 'axios'; export default { name: 'Ping', data() { return { msg: '', }; }, methods: { getMessage() { axios.get('/') .then((res) => { this.msg = res.data; }) .catch((error) => { console.error(error); }); }, }, created() { this.getMessage(); }, }; </script>`
Axios ,这是一个 HTTP 客户端,用于向后端发送 AJAX 请求。在上面的组件中,我们从后端的响应中更新了msg的值。
最后,在services/frontend/src/app . vue中,移除导航以及相关的样式:
`<template>
<div id="app">
<router-view/>
</div>
</template>
<style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; } </style>`
现在,您应该会在浏览器的中看到Hello, World!。
您的完整项目结构现在应该如下所示:
`├── docker-compose.yml
└── services
├── backend
│ ├── Dockerfile
│ ├── requirements.txt
│ └── src
│ └── main.py
└── frontend
├── .gitignore
├── Dockerfile
├── README.md
├── babel.config.js
├── jsconfig.json
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── App.vue
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ └── HelloWorld.vue
│ ├── main.js
│ ├── router
│ │ └── index.js
│ └── views
│ ├── AboutView.vue
│ └── HomeView.vue
└── vue.config.js`
模型和迁移
我们将使用 Tortoise 来管理 ORM(对象关系映射器),使用 Aerich 来管理数据库迁移。
更新后端依赖关系:
`aerich==0.7.1
asyncpg==0.27.0
fastapi==0.88.0
tortoise-orm==0.19.2
uvicorn==0.20.0`
首先,让我们为 Postgres 添加一个新服务到 docker-compose.yml :
`version: '3.8' services: backend: build: ./services/backend ports: - 5000:5000 environment: - DATABASE_URL=postgres://hello_fastapi:[[email protected]](/cdn-cgi/l/email-protection):5432/hello_fastapi_dev volumes: - ./services/backend:/app command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000 depends_on: - db frontend: build: ./services/frontend volumes: - './services/frontend:/app' - '/app/node_modules' ports: - 8080:8080 db: image: postgres:15.1 expose: - 5432 environment: - POSTGRES_USER=hello_fastapi - POSTGRES_PASSWORD=hello_fastapi - POSTGRES_DB=hello_fastapi_dev volumes: - postgres_data:/var/lib/postgresql/data/ volumes: postgres_data:`
注意db中的环境变量以及backend服务中新的DATABASE_URL环境变量。
接下来,在“services/backend/src”文件夹中创建一个名为“database”的文件夹,并向其中添加一个名为 models.py 的新文件:
`from tortoise import fields, models
class Users(models.Model):
id = fields.IntField(pk=True)
username = fields.CharField(max_length=20, unique=True)
full_name = fields.CharField(max_length=50, null=True)
password = fields.CharField(max_length=128, null=True)
created_at = fields.DatetimeField(auto_now_add=True)
modified_at = fields.DatetimeField(auto_now=True)
class Notes(models.Model):
id = fields.IntField(pk=True)
title = fields.CharField(max_length=225)
content = fields.TextField()
author = fields.ForeignKeyField("models.Users", related_name="note")
created_at = fields.DatetimeField(auto_now_add=True)
modified_at = fields.DatetimeField(auto_now=True)
def __str__(self):
return f"{self.title}, {self.author_id} on {self.created_at}"`
Users和Notes类将在我们的数据库中创建两个新表。请注意,author列与用户相关联,创建了一个一对多的关系(一个用户可以有多个注释)。
在“services/backend/src/database”文件夹中创建一个 config.py 文件:
`import os
TORTOISE_ORM = {
"connections": {"default": os.environ.get("DATABASE_URL")},
"apps": {
"models": {
"models": [
"src.database.models", "aerich.models"
],
"default_connection": "default"
}
}
}`
这里,我们为 Tortoise 和 Aerich 指定了配置。
简而言之,我们:
- 通过
DATABASE_URL环境变量定义数据库连接 - 注册我们的模型,
src.database.models(用户和注释)和aerich.models(迁移元数据)
将一个 register.py 文件添加到“服务/后端/src/数据库”中:
`from typing import Optional
from tortoise import Tortoise
def register_tortoise(
app,
config: Optional[dict] = None,
generate_schemas: bool = False,
) -> None:
@app.on_event("startup")
async def init_orm():
await Tortoise.init(config=config)
if generate_schemas:
await Tortoise.generate_schemas()
@app.on_event("shutdown")
async def close_orm():
await Tortoise.close_connections()`
register_tortoise是一个将用于配置我们的应用程序和 Tortoise 模型的函数。它接受我们的应用程序、一个配置字典和一个generate_schema布尔值。
该函数将在 main.py 中用我们的配置字典调用:
`from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.database.register import register_tortoise # NEW
from src.database.config import TORTOISE_ORM # NEW
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# NEW
register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)
@app.get("/")
def home():
return "Hello, World!"`
构建新映像并旋转容器:
`$ docker-compose up -d --build`
容器启动并运行后,运行:
`$ docker-compose exec backend aerich init -t src.database.config.TORTOISE_ORM
Success create migrate location ./migrations
Success write config to pyproject.toml
$ docker-compose exec backend aerich init-db
Success create app migrate location migrations/models
Success generate schema for app "models"`
第一个命令告诉 Aerich 配置字典在哪里,用于初始化模型和数据库之间的连接。这创建了一个服务/后端/pyproject.toml 配置文件和一个“服务/后端/迁移”文件夹。
接下来,我们在“服务/后端/迁移/模型”中为我们的三个模型——用户、notes 和 aerich——生成了一个迁移文件。这些也应用于数据库。
让我们将 pyproject.toml 文件和“migrations”文件夹复制到容器中。为此,更新 Dockerfile ,如下所示:
`FROM python:3.11-buster
RUN mkdir app
WORKDIR /app
ENV PATH="${PATH}:/root/.local/bin"
ENV PYTHONPATH=.
COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
# for migrations
COPY migrations .
COPY pyproject.toml .
COPY src/ .`
更新:
`$ docker-compose up -d --build`
现在,当您对模型进行更改时,您可以运行以下命令来更新数据库:
`$ docker-compose exec backend aerich migrate
$ docker-compose exec backend aerich upgrade`
CRUD 操作
现在让我们连接基本的 CRUD 操作:创建、读取、更新和删除。
首先,因为我们需要定义模式来序列化和反序列化我们的数据,所以在“services/backend/src”中创建两个文件夹,分别称为“crud”和“schemas”。
为了确保我们的序列化程序能够读取模型之间的关系,我们需要初始化 main.py 文件中的模型:
`from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from tortoise import Tortoise # NEW
from src.database.register import register_tortoise
from src.database.config import TORTOISE_ORM
# enable schemas to read relationship between models
Tortoise.init_models(["src.database.models"], "models") # NEW
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)
@app.get("/")
def home():
return "Hello, World!"`
现在,对任何对象的查询都可以从相关表中获取数据。
接下来,在“schemas”文件夹中,添加两个名为 users.py 和 notes.py 的文件。
服务/后端/src/模式/用户. py :
`from tortoise.contrib.pydantic import pydantic_model_creator
from src.database.models import Users
UserInSchema = pydantic_model_creator(
Users, name="UserIn", exclude_readonly=True
)
UserOutSchema = pydantic_model_creator(
Users, name="UserOut", exclude=["password", "created_at", "modified_at"]
)
UserDatabaseSchema = pydantic_model_creator(
Users, name="User", exclude=["created_at", "modified_at"]
)`
pydantic_model_creator 是一个乌龟助手,它允许我们从乌龟模型中创建 pydantic 模型,我们将用它来创建和检索数据库记录。它接受Users模型和一个name。还可以exclude具体栏目。
架构:
UserInSchema用于创建新用户。UserOutSchema用于检索将在应用程序外使用的用户信息,用于返回给最终用户。UserDatabaseSchema用于检索在应用程序中使用的用户信息,用于验证用户。
服务/后端/src/schemas/notes.py :
`from typing import Optional
from pydantic import BaseModel
from tortoise.contrib.pydantic import pydantic_model_creator
from src.database.models import Notes
NoteInSchema = pydantic_model_creator(
Notes, name="NoteIn", exclude=["author_id"], exclude_readonly=True)
NoteOutSchema = pydantic_model_creator(
Notes, name="Note", exclude =[
"modified_at", "author.password", "author.created_at", "author.modified_at"
]
)
class UpdateNote(BaseModel):
title: Optional[str]
content: Optional[str]`
架构:
NoteInSchema用于创建新的便笺。NoteOutSchema用于检索笔记。UpdateNote用于更新笔记。
接下来,将 users.py 和 notes.py 文件添加到“服务/后端/src/crud”文件夹中。
服务/后端/src/crud/users.py :
`from fastapi import HTTPException
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist, IntegrityError
from src.database.models import Users
from src.schemas.users import UserOutSchema
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def create_user(user) -> UserOutSchema:
user.password = pwd_context.encrypt(user.password)
try:
user_obj = await Users.create(**user.dict(exclude_unset=True))
except IntegrityError:
raise HTTPException(status_code=401, detail=f"Sorry, that username already exists.")
return await UserOutSchema.from_tortoise_orm(user_obj)
async def delete_user(user_id, current_user):
try:
db_user = await UserOutSchema.from_queryset_single(Users.get(id=user_id))
except DoesNotExist:
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
if db_user.id == current_user.id:
deleted_count = await Users.filter(id=user_id).delete()
if not deleted_count:
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
return f"Deleted user {user_id}"
raise HTTPException(status_code=403, detail=f"Not authorized to delete")`
这里,我们定义了用于创建和删除用户的助手函数:
create_user接收一个用户,加密user.password,然后将该用户添加到数据库中。delete_user从数据库中删除用户。它还通过确保请求由当前经过身份验证的用户发起来保护用户。
将所需的依赖项添加到services/back end/requirements . txt:
`aerich==0.7.1
asyncpg==0.27.0
bcrypt==4.0.1
passlib==1.7.4
fastapi==0.88.0
tortoise-orm==0.19.2
uvicorn==0.20.0`
服务/后端/src/crud/notes.py :
`from fastapi import HTTPException
from tortoise.exceptions import DoesNotExist
from src.database.models import Notes
from src.schemas.notes import NoteOutSchema
async def get_notes():
return await NoteOutSchema.from_queryset(Notes.all())
async def get_note(note_id) -> NoteOutSchema:
return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
async def create_note(note, current_user) -> NoteOutSchema:
note_dict = note.dict(exclude_unset=True)
note_dict["author_id"] = current_user.id
note_obj = await Notes.create(**note_dict)
return await NoteOutSchema.from_tortoise_orm(note_obj)
async def update_note(note_id, note, current_user) -> NoteOutSchema:
try:
db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
except DoesNotExist:
raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
if db_note.author.id == current_user.id:
await Notes.filter(id=note_id).update(**note.dict(exclude_unset=True))
return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
raise HTTPException(status_code=403, detail=f"Not authorized to update")
async def delete_note(note_id, current_user):
try:
db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
except DoesNotExist:
raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
if db_note.author.id == current_user.id:
deleted_count = await Notes.filter(id=note_id).delete()
if not deleted_count:
raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
return f"Deleted note {note_id}"
raise HTTPException(status_code=403, detail=f"Not authorized to delete")`
在这里,我们创建了助手函数来实现 notes 资源的所有 CRUD 操作。记下update_note和delete_note助手。我们添加了一个检查来确保请求来自笔记作者。
您的文件夹结构现在应该如下所示:
`├── docker-compose.yml
└── services
├── backend
│ ├── Dockerfile
│ ├── migrations
│ │ └── models
│ │ └── 0_20221212182213_init.py
│ ├── pyproject.toml
│ ├── requirements.txt
│ └── src
│ ├── crud
│ │ ├── notes.py
│ │ └── users.py
│ ├── database
│ │ ├── config.py
│ │ ├── models.py
│ │ └── register.py
│ ├── main.py
│ └── schemas
│ ├── notes.py
│ └── users.py
└── frontend
├── .gitignore
├── Dockerfile
├── README.md
├── babel.config.js
├── jsconfig.json
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── App.vue
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ └── HelloWorld.vue
│ ├── main.js
│ ├── router
│ │ └── index.js
│ └── views
│ ├── AboutView.vue
│ └── HomeView.vue
└── vue.config.js`
这是一个停下来的好时机,回顾一下到目前为止你已经完成了什么,并连接 pytest 来测试 CRUD 助手。需要帮助吗?回顾用 FastAPI 和 Pytest 开发和测试异步 API。
JWT 认证
在添加路由处理程序之前,让我们连接身份验证来保护特定的路由。
首先,我们需要在“services/backend/src/schemas”文件夹中名为 token.py 的新文件中创建一些 pydantic 模型:
`from typing import Optional
from pydantic import BaseModel
class TokenData(BaseModel):
username: Optional[str] = None
class Status(BaseModel):
message: str`
我们定义了两种模式:
TokenData用于确保令牌中的用户名是一个字符串。Status用于向最终用户发回状态消息。
在“服务/后端/src”文件夹中创建另一个名为“auth”的文件夹。然后,向其中添加两个新文件,分别名为 jwthandler.py 和 users.py 。
services/back end/src/auth/jwthandler . py:
`import os
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, Request
from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
from fastapi.security import OAuth2
from fastapi.security.utils import get_authorization_scheme_param
from jose import JWTError, jwt
from tortoise.exceptions import DoesNotExist
from src.schemas.token import TokenData
from src.schemas.users import UserOutSchema
from src.database.models import Users
SECRET_KEY = os.environ.get("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
class OAuth2PasswordBearerCookie(OAuth2):
def __init__(
self,
token_url: str,
scheme_name: str = None,
scopes: dict = None,
auto_error: bool = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(password={"tokenUrl": token_url, "scopes": scopes})
super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
async def __call__(self, request: Request) -> Optional[str]:
authorization: str = request.cookies.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
return param
security = OAuth2PasswordBearerCookie(token_url="/login")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(security)):
credentials_exception = HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
try:
user = await UserOutSchema.from_queryset_single(
Users.get(username=token_data.username)
)
except DoesNotExist:
raise credentials_exception
return user`
注意事项:
OAuth2PasswordBearerCookie是一个继承自OAuth2类的类,用于读取受保护路由的请求报头中发送的 cookie。它确保 cookie 存在,然后从 cookie 中返回令牌。create_access_token函数接收用户的用户名,用到期时间对其进行编码,并从中生成一个令牌。get_current_user解码令牌并验证用户。
python-jose 用于对 JWT 令牌进行编码和解码。将包添加到需求文件中:
`aerich==0.7.1
asyncpg==0.27.0
bcrypt==4.0.1
passlib==1.7.4
fastapi==0.88.0
python-jose==3.3.0
tortoise-orm==0.19.2
uvicorn==0.20.0`
将SECRET_KEY环境变量添加到 docker-compose.yml :
`version: '3.8' services: backend: build: ./services/backend ports: - 5000:5000 environment: - DATABASE_URL=postgres://hello_fastapi:[[email protected]](/cdn-cgi/l/email-protection):5432/hello_fastapi_dev - SECRET_KEY=09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7 volumes: - ./services/backend:/app command: uvicorn src.main:app --reload --host 0.0.0.0 --port 5000 depends_on: - db frontend: build: ./services/frontend volumes: - './services/frontend:/app' - '/app/node_modules' ports: - 8080:8080 db: image: postgres:15.1 expose: - 5432 environment: - POSTGRES_USER=hello_fastapi - POSTGRES_PASSWORD=hello_fastapi - POSTGRES_DB=hello_fastapi_dev volumes: - postgres_data:/var/lib/postgresql/data/ volumes: postgres_data:`
服务/后端/src/auth/users.py :
`from fastapi import HTTPException, Depends, status
from fastapi.security import OAuth2PasswordRequestForm
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist
from src.database.models import Users
from src.schemas.users import UserDatabaseSchema
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
async def get_user(username: str):
return await UserDatabaseSchema.from_queryset_single(Users.get(username=username))
async def validate_user(user: OAuth2PasswordRequestForm = Depends()):
try:
db_user = await get_user(user.username)
except DoesNotExist:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
if not verify_password(user.password, db_user.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
return db_user`
注意事项:
validate_user用于验证用户登录时的身份。如果用户名或密码不正确,它会向用户抛出一个401_UNAUTHORIZED错误。
最后,让我们更新 CRUD 助手,以便它们使用Status pydantic 模型:
`class Status(BaseModel):
message: str`
服务/后端/src/crud/users.py :
`from fastapi import HTTPException
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist, IntegrityError
from src.database.models import Users
from src.schemas.token import Status # NEW
from src.schemas.users import UserOutSchema
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def create_user(user) -> UserOutSchema:
user.password = pwd_context.encrypt(user.password)
try:
user_obj = await Users.create(**user.dict(exclude_unset=True))
except IntegrityError:
raise HTTPException(status_code=401, detail=f"Sorry, that username already exists.")
return await UserOutSchema.from_tortoise_orm(user_obj)
async def delete_user(user_id, current_user) -> Status: # UPDATED
try:
db_user = await UserOutSchema.from_queryset_single(Users.get(id=user_id))
except DoesNotExist:
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
if db_user.id == current_user.id:
deleted_count = await Users.filter(id=user_id).delete()
if not deleted_count:
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
return Status(message=f"Deleted user {user_id}") # UPDATED
raise HTTPException(status_code=403, detail=f"Not authorized to delete")`
服务/后端/src/crud/notes.py :
`from fastapi import HTTPException
from tortoise.exceptions import DoesNotExist
from src.database.models import Notes
from src.schemas.notes import NoteOutSchema
from src.schemas.token import Status # NEW
async def get_notes():
return await NoteOutSchema.from_queryset(Notes.all())
async def get_note(note_id) -> NoteOutSchema:
return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
async def create_note(note, current_user) -> NoteOutSchema:
note_dict = note.dict(exclude_unset=True)
note_dict["author_id"] = current_user.id
note_obj = await Notes.create(**note_dict)
return await NoteOutSchema.from_tortoise_orm(note_obj)
async def update_note(note_id, note, current_user) -> NoteOutSchema:
try:
db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
except DoesNotExist:
raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
if db_note.author.id == current_user.id:
await Notes.filter(id=note_id).update(**note.dict(exclude_unset=True))
return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
raise HTTPException(status_code=403, detail=f"Not authorized to update")
async def delete_note(note_id, current_user) -> Status: # UPDATED
try:
db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
except DoesNotExist:
raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
if db_note.author.id == current_user.id:
deleted_count = await Notes.filter(id=note_id).delete()
if not deleted_count:
raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
return Status(message=f"Deleted note {note_id}") # UPDATED
raise HTTPException(status_code=403, detail=f"Not authorized to delete")`
按指定路线发送
随着 pydantic 模型、CRUD 助手和 JWT 认证的建立,我们现在可以用路由处理程序将所有东西粘在一起。
在我们的“src”文件夹中创建一个“routes”文件夹,并添加两个文件, users.py 和 notes.py 。
users.py :
`from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordRequestForm
from tortoise.contrib.fastapi import HTTPNotFoundError
import src.crud.users as crud
from src.auth.users import validate_user
from src.schemas.token import Status
from src.schemas.users import UserInSchema, UserOutSchema
from src.auth.jwthandler import (
create_access_token,
get_current_user,
ACCESS_TOKEN_EXPIRE_MINUTES,
)
router = APIRouter()
@router.post("/register", response_model=UserOutSchema)
async def create_user(user: UserInSchema) -> UserOutSchema:
return await crud.create_user(user)
@router.post("/login")
async def login(user: OAuth2PasswordRequestForm = Depends()):
user = await validate_user(user)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
token = jsonable_encoder(access_token)
content = {"message": "You've successfully logged in. Welcome back!"}
response = JSONResponse(content=content)
response.set_cookie(
"Authorization",
value=f"Bearer {token}",
httponly=True,
max_age=1800,
expires=1800,
samesite="Lax",
secure=False,
)
return response
@router.get(
"/users/whoami", response_model=UserOutSchema, dependencies=[Depends(get_current_user)]
)
async def read_users_me(current_user: UserOutSchema = Depends(get_current_user)):
return current_user
@router.delete(
"/user/{user_id}",
response_model=Status,
responses={404: {"model": HTTPNotFoundError}},
dependencies=[Depends(get_current_user)],
)
async def delete_user(
user_id: int, current_user: UserOutSchema = Depends(get_current_user)
) -> Status:
return await crud.delete_user(user_id, current_user)`
这里发生了什么事?
get_current_user隶属于read_users_me和delete_user以保护航线。除非用户以current_user身份登录,否则他们将无法访问它们。/register利用crud.create_user助手创建一个新用户并将其添加到数据库中。/login通过来自OAuth2PasswordRequestForm的包含用户名和密码的表单数据接收用户。然后,它调用用户的validate_user函数,或者在None时抛出异常。从create_access_token函数生成一个访问令牌,然后作为 cookie 附加到响应头。/users/whoami接收get_current_user并返回结果作为响应。/user/{user_id}是一个动态路由,它接收user_id并将其与来自current_user的结果一起发送给crud.delete_user助手。
OAuth2PasswordRequestForm需要 Python-Multipart 。添加到services/back end/requirements . txt:
`aerich==0.7.1
asyncpg==0.27.0
bcrypt==4.0.1
passlib==1.7.4
fastapi==0.88.0
python-jose==3.3.0
python-multipart==0.0.5
tortoise-orm==0.19.2
uvicorn==0.20.0`
在用户成功认证后,通过响应头中的 Set-cookie 发送回一个 Cookie。当用户发出后续请求时,它被附加到请求头。
注意到:
`response.set_cookie(
"Authorization",
value=f"Bearer {token}",
httponly=True,
max_age=1800,
expires=1800,
samesite="Lax",
secure=False,
)`
注意事项:
- cookie 的名称是
Authorization,值是Bearer {token},而token是实际的令牌。它在 1800 秒(30 分钟)后过期。 - 为了安全起见,httponly 被设置为
True,这样客户端脚本将无法访问 cookie。这有助于防止跨站脚本 (XSS)攻击。 - 当 samesite 设置为
Lax时,浏览器只在一些 HTTP 请求上发送 cookies。这有助于防止跨站点请求伪造 (CSRF)攻击。 - 最后,
secure被设置为False,因为我们将在本地测试,没有 HTTPS。确保在生产中将此设置为True。
notes.py :
`from typing import List
from fastapi import APIRouter, Depends, HTTPException
from tortoise.contrib.fastapi import HTTPNotFoundError
from tortoise.exceptions import DoesNotExist
import src.crud.notes as crud
from src.auth.jwthandler import get_current_user
from src.schemas.notes import NoteOutSchema, NoteInSchema, UpdateNote
from src.schemas.token import Status
from src.schemas.users import UserOutSchema
router = APIRouter()
@router.get(
"/notes",
response_model=List[NoteOutSchema],
dependencies=[Depends(get_current_user)],
)
async def get_notes():
return await crud.get_notes()
@router.get(
"/note/{note_id}",
response_model=NoteOutSchema,
dependencies=[Depends(get_current_user)],
)
async def get_note(note_id: int) -> NoteOutSchema:
try:
return await crud.get_note(note_id)
except DoesNotExist:
raise HTTPException(
status_code=404,
detail="Note does not exist",
)
@router.post(
"/notes", response_model=NoteOutSchema, dependencies=[Depends(get_current_user)]
)
async def create_note(
note: NoteInSchema, current_user: UserOutSchema = Depends(get_current_user)
) -> NoteOutSchema:
return await crud.create_note(note, current_user)
@router.patch(
"/note/{note_id}",
dependencies=[Depends(get_current_user)],
response_model=NoteOutSchema,
responses={404: {"model": HTTPNotFoundError}},
)
async def update_note(
note_id: int,
note: UpdateNote,
current_user: UserOutSchema = Depends(get_current_user),
) -> NoteOutSchema:
return await crud.update_note(note_id, note, current_user)
@router.delete(
"/note/{note_id}",
response_model=Status,
responses={404: {"model": HTTPNotFoundError}},
dependencies=[Depends(get_current_user)],
)
async def delete_note(
note_id: int, current_user: UserOutSchema = Depends(get_current_user)
):
return await crud.delete_note(note_id, current_user)`
自己复习一下这个。
最后,我们需要在 main.py 中连接我们的路线:
`from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from tortoise import Tortoise
from src.database.register import register_tortoise
from src.database.config import TORTOISE_ORM
# enable schemas to read relationship between models
Tortoise.init_models(["src.database.models"], "models")
"""
import 'from src.routes import users, notes' must be after 'Tortoise.init_models'
why?
https://stackoverflow.com/questions/65531387/tortoise-orm-for-python-no-returns-relations-of-entities-pyndantic-fastapi
"""
from src.routes import users, notes
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(users.router)
app.include_router(notes.router)
register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)
@app.get("/")
def home():
return "Hello, World!"`
更新映像以安装新的依赖项:
`$ docker-compose up -d --build`
导航到http://localhost:5000/docs查看 Swagger UI:

现在,您可以手动测试每条路线。
你应该测试什么?
| 途径 | 方法 | 快乐之路 | 不愉快的道路 |
|---|---|---|---|
| /注册 | 邮政 | 您可以注册新用户 | 重复的用户名,缺少用户名或密码字段 |
| /登录 | 邮政 | 您可以让用户登录 | 用户名或密码不正确 |
| /用户/whoami | 得到 | 验证后返回用户信息 | 没有Authorization cookie 或令牌无效 |
| /user/ | 删除 | 您可以在鉴定后删除用户,并且您正在尝试删除当前用户 | 找不到用户,用户存在但无权删除 |
| /备注 | 得到 | 通过验证后,您可以获得所有笔记 | 未认证 |
| /备注 | 邮政 | 经过验证后,您可以添加注释 | 未认证 |
| /note/ | 得到 | 当经过验证并且它存在时,您可以获得该便笺 | 未验证,已验证,但便条不存在 |
| /note/ | 删除 | 当通过身份验证、注释存在并且当前用户创建了注释时,您可以删除注释 | 未验证,已验证但笔记不存在,不存在但未授权删除 |
| /note/ | 修补 | 当经过身份验证、注释存在且当前用户创建了注释时,您可以更新注释 | 未验证,已验证,但注释不存在,不存在但未授权更新 |
这需要大量繁琐的手工测试。用 pytest 添加自动化测试是一个好主意。再次回顾使用 FastAPI 和 Pytest 开发和测试异步 API,以获得这方面的帮助。
说到这里,让我们把注意力转向前端。
武契特
Vuex 是 Vue 的状态管理模式和库。它全局管理状态。在 Vuex 中,突变,由动作调用,用于改变状态。
在“服务/前端/src”中添加一个名为“store”的新文件夹。在“存储”中,添加以下文件和文件夹:
`services/frontend/src/store
├── index.js
└── modules
├── notes.js
└── users.js`
服务/前端/src/商店/索引. js :
`import { createStore } from "vuex"; import notes from './modules/notes'; import users from './modules/users'; export default createStore({ modules: { notes, users, } });`
这里,我们用两个模块创建了一个新的 Vuex 商店, notes.js 和 users.js 。
服务/前端/src/商店/模块/notes.js :
`import axios from 'axios'; const state = { notes: null, note: null }; const getters = { stateNotes: state => state.notes, stateNote: state => state.note, }; const actions = { async createNote({dispatch}, note) { await axios.post('notes', note); await dispatch('getNotes'); }, async getNotes({commit}) { let {data} = await axios.get('notes'); commit('setNotes', data); }, async viewNote({commit}, id) { let {data} = await axios.get(`note/${id}`); commit('setNote', data); }, // eslint-disable-next-line no-empty-pattern async updateNote({}, note) { await axios.patch(`note/${note.id}`, note.form); }, // eslint-disable-next-line no-empty-pattern async deleteNote({}, id) { await axios.delete(`note/${id}`); } }; const mutations = { setNotes(state, notes){ state.notes = notes; }, setNote(state, note){ state.note = note; }, }; export default { state, getters, actions, mutations };`
注意事项:
state-note和notes都默认为null。它们将分别被更新为一个对象和一个对象数组。getters-检索state.note和state.notes的值。actions-每个动作都通过 Axios 进行 HTTP 调用,然后其中一些动作会产生副作用——例如,调用相关的突变来更新状态或不同的动作。mutations-两者都对状态进行更改,从而更新state.note和state.notes。
服务/前端/src/商店/模块/用户. js :
`import axios from 'axios'; const state = { user: null, }; const getters = { isAuthenticated: state => !!state.user, stateUser: state => state.user, }; const actions = { async register({dispatch}, form) { await axios.post('register', form); let UserForm = new FormData(); UserForm.append('username', form.username); UserForm.append('password', form.password); await dispatch('logIn', UserForm); }, async logIn({dispatch}, user) { await axios.post('login', user); await dispatch('viewMe'); }, async viewMe({commit}) { let {data} = await axios.get('users/whoami'); await commit('setUser', data); }, // eslint-disable-next-line no-empty-pattern async deleteUser({}, id) { await axios.delete(`user/${id}`); }, async logOut({commit}) { let user = null; commit('logout', user); } }; const mutations = { setUser(state, username) { state.user = username; }, logout(state, user){ state.user = user; }, }; export default { state, getters, actions, mutations };`
注意事项:
isAuthenticated-如果state.user不是null,则返回true,否则返回false。stateUser-返回state.user的值。register——向我们在后端创建的/register端点发送一个 POST 请求,创建一个 FormData 实例,并将其分派给logIn动作,让注册用户登录。
最后,将存储连接到services/frontend/src/main . js中的根实例:
`import 'bootstrap/dist/css/bootstrap.css'; import { createApp } from "vue"; import axios from 'axios'; import App from './App.vue'; import router from './router'; import store from './store'; // New const app = createApp(App); axios.defaults.withCredentials = true; axios.defaults.baseURL = 'http://localhost:5000/'; // the FastAPI backend app.use(router); app.use(store); // New app.mount("#app");`
零部件、视图和管线
接下来,我们将开始添加组件和视图。
成分
导航条
服务/前端/src/组件/NavBar.vue :
`<template>
<header>
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">FastAPI + Vue</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul v-if="isLoggedIn" class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<router-link class="nav-link" to="/">Home</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/dashboard">Dashboard</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/profile">My Profile</router-link>
</li>
<li class="nav-item">
<a class="nav-link" @click="logout">Log Out</a>
</li>
</ul>
<ul v-else class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<router-link class="nav-link" to="/">Home</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/register">Register</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/login">Log In</router-link>
</li>
</ul>
</div>
</div>
</nav>
</header>
</template>
<script> import { defineComponent } from 'vue'; export default defineComponent({ name: 'NavBar', computed: { isLoggedIn: function() { return this.$store.getters.isAuthenticated; } }, methods: { async logout () { await this.$store.dispatch('logOut'); this.$router.push('/login'); } }, }); </script>
<style scoped> a { cursor: pointer; } </style>`
NavBar用于导航到应用程序中的其他页面。isLoggedIn属性用于检查用户是否从商店登录。如果他们已登录,他们可以访问仪表板和简档,包括注销链接。
logout函数分派logOut动作并将用户重定向到/login路线。
应用
接下来,让我们将NavBar组件添加到主App组件中。
服务/前端/src/app . view:
`<template>
<div id="app">
<NavBar />
<div class="main container">
<router-view/>
</div>
</div>
</template>
<script> // @ is an alias to /src import NavBar from '@/components/NavBar.vue' export default { components: { NavBar } } </script>
<style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #2c3e50; } .main { padding-top: 5em; } </style>`
您现在应该可以在 http://localhost:8080/ 看到新的导航栏。
视图
主页
services/frontend/src/views/home view . vue:
`<template>
<section>
<p>This site is built with FastAPI and Vue.</p>
<div v-if="isLoggedIn" id="logout">
<p id="logout">Click <a href="/dashboard">here</a> to view all notes.</p>
</div>
<p v-else>
<span><a href="/register">Register</a></span>
<span> or </span>
<span><a href="/login">Log In</a></span>
</p>
</section>
</template>
<script> import { defineComponent } from 'vue'; export default defineComponent({ name: 'HomeView', computed : { isLoggedIn: function() { return this.$store.getters.isAuthenticated; } }, }); </script>`
在这里,根据isLoggedIn属性的值,向最终用户显示所有注释的链接或注册/登录的链接。
接下来,将视图连接到我们在services/frontend/src/router/index . js中的路由:
`import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue'; const routes = [ { path: '/', name: "Home", component: HomeView, } ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) export default router`
导航到 http://localhost:8080/ 。您应该看到:

注册
服务/前端/src/views/RegisterView.vue :
`<template>
<section>
<form @submit.prevent="submit">
<div class="mb-3">
<label for="username" class="form-label">Username:</label>
<input type="text" name="username" v-model="user.username" class="form-control" />
</div>
<div class="mb-3">
<label for="full_name" class="form-label">Full Name:</label>
<input type="text" name="full_name" v-model="user.full_name" class="form-control" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password:</label>
<input type="password" name="password" v-model="user.password" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</section>
</template>
<script> import { defineComponent } from 'vue'; import { mapActions } from 'vuex'; export default defineComponent({ name: 'Register', data() { return { user: { username: '', full_name: '', password: '', }, }; }, methods: { ...mapActions(['register']), async submit() { try { await this.register(this.user); this.$router.push('/dashboard'); } catch (error) { throw 'Username already exists. Please try again.'; } }, }, }); </script>`
表单接受用户名、全名和密码,所有这些都是user对象的属性。通过 mapActions 将Register动作映射(导入)到组件中。然后,this.Register被调用并传递给user对象。如果结果是成功的,用户将被重定向到/dashboard。
更新路由器:
`import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue'; import RegisterView from '@/views/RegisterView.vue'; const routes = [ { path: '/', name: "Home", component: HomeView, }, { path: '/register', name: 'Register', component: RegisterView, }, ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) export default router`
测试http://localhost:8080/register,确保可以注册新用户。

注册
services/frontend/src/views/loginvue . vue:
`<template>
<section>
<form @submit.prevent="submit">
<div class="mb-3">
<label for="username" class="form-label">Username:</label>
<input type="text" name="username" v-model="form.username" class="form-control" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password:</label>
<input type="password" name="password" v-model="form.password" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</section>
</template>
<script> import { defineComponent } from 'vue'; import { mapActions } from 'vuex'; export default defineComponent({ name: 'Login', data() { return { form: { username: '', password:'', } }; }, methods: { ...mapActions(['logIn']), async submit() { const User = new FormData(); User.append('username', this.form.username); User.append('password', this.form.password); await this.logIn(User); this.$router.push('/dashboard'); } } }); </script>`
在提交时,调用logIn动作。如果成功,用户将被重定向到/dashboard。
更新路由器:
`import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue'; import RegisterView from '@/views/RegisterView.vue'; import LoginView from '@/views/LoginView.vue'; const routes = [ { path: '/', name: "Home", component: HomeView, }, { path: '/register', name: 'Register', component: RegisterView, }, { path: '/login', name: 'Login', component: LoginView, }, ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) export default router`
测试http://localhost:8080/log in,确保可以让注册用户登录。
仪表盘
services/frontend/src/views/dashboard view . vue:
`<template>
<div>
<section>
<h1>Add new note</h1>
<hr/><br/>
<form @submit.prevent="submit">
<div class="mb-3">
<label for="title" class="form-label">Title:</label>
<input type="text" name="title" v-model="form.title" class="form-control" />
</div>
<div class="mb-3">
<label for="content" class="form-label">Content:</label>
<textarea
name="content"
v-model="form.content"
class="form-control"
></textarea>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</section>
<br/><br/>
<section>
<h1>Notes</h1>
<hr/><br/>
<div v-if="notes.length">
<div v-for="note in notes" :key="note.id" class="notes">
<div class="card" style="width: 18rem;">
<div class="card-body">
<ul>
<li><strong>Note Title:</strong> {{ note.title }}</li>
<li><strong>Author:</strong> {{ note.author.username }}</li>
<li><router-link :to="{name: 'Note', params:{id: note.id}}">View</router-link></li>
</ul>
</div>
</div>
<br/>
</div>
</div>
<div v-else>
<p>Nothing to see. Check back later.</p>
</div>
</section>
</div>
</template>
<script> import { defineComponent } from 'vue'; import { mapGetters, mapActions } from 'vuex'; export default defineComponent({ name: 'Dashboard', data() { return { form: { title: '', content: '', }, }; }, created: function() { return this.$store.dispatch('getNotes'); }, computed: { ...mapGetters({ notes: 'stateNotes'}), }, methods: { ...mapActions(['createNote']), async submit() { await this.createNote(this.form); }, }, }); </script>`
仪表板显示来自 API 的所有注释,还允许用户创建新注释。注意到:
`<router-link :to="{name: 'Note', params:{id: note.id}}">View</router-link>`
我们将很快在这里配置路由和视图,但要注意的关键是,路由接收注释 ID,并将用户发送到相应的路由——例如,note/1、note/2、note/10、note/101等等。
在创建组件的过程中调用created函数,该函数与组件生命周期挂钩。在其中,我们称映射的getNotes动作。
路由器:
`import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue'; import RegisterView from '@/views/RegisterView.vue'; import LoginView from '@/views/LoginView.vue'; import DashboardView from '@/views/DashboardView.vue'; const routes = [ { path: '/', name: "Home", component: HomeView, }, { path: '/register', name: 'Register', component: RegisterView, }, { path: '/login', name: 'Login', component: LoginView, }, { path: '/dashboard', name: 'Dashboard', component: DashboardView, meta: { requiresAuth: true }, }, ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) export default router`
确保在您注册或登录后,您会被重定向到仪表板,并且它现在会正确显示:

轮廓
服务/前端/src/视图/ProfileView.vue :
`<template>
<section>
<h1>Your Profile</h1>
<hr/><br/>
<div>
<p><strong>Full Name:</strong> <span>{{ user.full_name }}</span></p>
<p><strong>Username:</strong> <span>{{ user.username }}</span></p>
<p><button @click="deleteAccount()" class="btn btn-primary">Delete Account</button></p>
</div>
</section>
</template>
<script> import { defineComponent } from 'vue'; import { mapGetters, mapActions } from 'vuex'; export default defineComponent({ name: 'Profile', created: function() { return this.$store.dispatch('viewMe'); }, computed: { ...mapGetters({user: 'stateUser' }), }, methods: { ...mapActions(['deleteUser']), async deleteAccount() { try { await this.deleteUser(this.user.id); await this.$store.dispatch('logOut'); this.$router.push('/'); } catch (error) { console.error(error); } } }, }); </script>`
“删除帐户”按钮调用deleteUser,它将user.id发送给deleteUser动作,注销用户,然后将用户重定向回主页。
路由器:
`import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue'; import RegisterView from '@/views/RegisterView.vue'; import LoginView from '@/views/LoginView.vue'; import DashboardView from '@/views/DashboardView.vue'; import ProfileView from '@/views/ProfileView.vue'; const routes = [ { path: '/', name: "Home", component: HomeView, }, { path: '/register', name: 'Register', component: RegisterView, }, { path: '/login', name: 'Login', component: LoginView, }, { path: '/dashboard', name: 'Dashboard', component: DashboardView, meta: { requiresAuth: true }, }, { path: '/profile', name: 'Profile', component: ProfileView, meta: { requiresAuth: true }, }, ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) export default router`
确保您可以在http://localhost:8080/profile查看您的个人资料。也测试一下删除功能。

注意
服务/前端/src/views/NoteView.vue :
`<template>
<div v-if="note">
<p><strong>Title:</strong> {{ note.title }}</p>
<p><strong>Content:</strong> {{ note.content }}</p>
<p><strong>Author:</strong> {{ note.author.username }}</p>
<div v-if="user.id === note.author.id">
<p><router-link :to="{name: 'EditNote', params:{id: note.id}}" class="btn btn-primary">Edit</router-link></p>
<p><button @click="removeNote()" class="btn btn-secondary">Delete</button></p>
</div>
</div>
</template>
<script> import { defineComponent } from 'vue'; import { mapGetters, mapActions } from 'vuex'; export default defineComponent({ name: 'Note', props: ['id'], async created() { try { await this.viewNote(this.id); } catch (error) { console.error(error); this.$router.push('/dashboard'); } }, computed: { ...mapGetters({ note: 'stateNote', user: 'stateUser'}), }, methods: { ...mapActions(['viewNote', 'deleteNote']), async removeNote() { try { await this.deleteNote(this.id); this.$router.push('/dashboard'); } catch (error) { console.error(error); } } }, }); </script>`
该视图加载从路由中作为属性传递给它的任何注释 ID 的注释细节。
在创建的生命周期挂钩中,我们从商店的props向viewNote动作传递了id。stateUser和stateNote通过映射器映射到组件中,分别为user和note。“Delete”按钮触发了deleteNote方法,该方法依次调用deleteNote动作并将用户重定向回/dashboard路线。
只有当note.author与登录用户相同时,我们才使用 if 语句来显示“编辑”和“删除”按钮。
路由器:
`import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue'; import RegisterView from '@/views/RegisterView.vue'; import LoginView from '@/views/LoginView.vue'; import DashboardView from '@/views/DashboardView.vue'; import ProfileView from '@/views/ProfileView.vue'; import NoteView from '@/views/NoteView.vue'; const routes = [ { path: '/', name: "Home", component: HomeView, }, { path: '/register', name: 'Register', component: RegisterView, }, { path: '/login', name: 'Login', component: LoginView, }, { path: '/dashboard', name: 'Dashboard', component: DashboardView, meta: { requiresAuth: true }, }, { path: '/profile', name: 'Profile', component: ProfileView, meta: { requiresAuth: true }, }, { path: '/note/:id', name: 'Note', component: NoteView, meta: { requiresAuth: true }, props: true, }, ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) export default router`
因为这个路由是动态的,所以我们将props设置为true,以便将注释 ID 作为一个属性从 URL 传递给视图。
EditNote
services/frontend/src/views/editnoteview . vue:
`<template>
<section>
<h1>Edit note</h1>
<hr/><br/>
<form @submit.prevent="submit">
<div class="mb-3">
<label for="title" class="form-label">Title:</label>
<input type="text" name="title" v-model="form.title" class="form-control" />
</div>
<div class="mb-3">
<label for="content" class="form-label">Content:</label>
<textarea
name="content"
v-model="form.content"
class="form-control"
></textarea>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</section>
</template>
<script> import { defineComponent } from 'vue'; import { mapGetters, mapActions } from 'vuex'; export default defineComponent({ name: 'EditNote', props: ['id'], data() { return { form: { title: '', content: '', }, }; }, created: function() { this.GetNote(); }, computed: { ...mapGetters({ note: 'stateNote' }), }, methods: { ...mapActions(['updateNote', 'viewNote']), async submit() { try { let note = { id: this.id, form: this.form, }; await this.updateNote(note); this.$router.push({name: 'Note', params:{id: this.note.id}}); } catch (error) { console.log(error); } }, async GetNote() { try { await this.viewNote(this.id); this.form.title = this.note.title; this.form.content = this.note.content; } catch (error) { console.error(error); this.$router.push('/dashboard'); } } }, }); </script>`
该视图显示一个预加载的表单,其中包含注释标题和内容,供作者编辑和更新。类似于Note视图,注释的id作为道具从路由器对象传递到页面。
getNote方法用于加载带有注释信息的表单。它将id传递给viewNote动作,并使用note getter 值来填充表单。当创建组件时,调用getNote函数。
路由器:
`import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue'; import RegisterView from '@/views/RegisterView.vue'; import LoginView from '@/views/LoginView.vue'; import DashboardView from '@/views/DashboardView.vue'; import ProfileView from '@/views/ProfileView.vue'; import NoteView from '@/views/NoteView.vue'; import EditNoteView from '@/views/EditNoteView.vue'; const routes = [ { path: '/', name: "Home", component: HomeView, }, { path: '/register', name: 'Register', component: RegisterView, }, { path: '/login', name: 'Login', component: LoginView, }, { path: '/dashboard', name: 'Dashboard', component: DashboardView, meta: { requiresAuth: true }, }, { path: '/profile', name: 'Profile', component: ProfileView, meta: { requiresAuth: true }, }, { path: '/note/:id', name: 'Note', component: NoteView, meta: { requiresAuth: true }, props: true, }, { path: '/editnote/:id', name: 'EditNote', component: EditNoteView, meta: { requiresAuth: true }, props: true, }, ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) export default router`
从仪表板中,添加新注释:

然后,单击链接查看新的便笺。确保“编辑”和“删除”按钮仅在登录用户是便笺创建者时显示:

此外,在继续之前,请确保您还可以编辑和删除注释。
未授权用户和过期令牌
未经授权的用户
您是否注意到有些路线附带了meta: {requiresAuth: true},?未经验证的用户不应该访问这些路由。
例如,如果您在未通过身份验证的情况下导航到http://localhost:8080/profile,会发生什么?您应该能够查看页面,但没有数据加载,对不对?让我们改变一下,让用户被重定向到/login路线。
所以,为了防止未经授权的访问,让我们给services/frontend/src/router/index . js添加一个导航守卫:
`import { createRouter, createWebHistory } from 'vue-router' import HomeView from '@/views/HomeView.vue'; import RegisterView from '@/views/RegisterView.vue'; import LoginView from '@/views/LoginView.vue'; import DashboardView from '@/views/DashboardView.vue'; import ProfileView from '@/views/ProfileView.vue'; import NoteView from '@/views/NoteView.vue'; import EditNoteView from '@/views/EditNoteView.vue'; import store from '@/store'; // NEW const routes = [ ... ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) // NEW router.beforeEach((to, _, next) => { if (to.matched.some(record => record.meta.requiresAuth)) { if (store.getters.isAuthenticated) { next(); return; } next('/login'); } else { next(); } }); export default router`
注销。然后,再次测试http://localhost:8080/profile。您应该被重新引导回/login路线。
过期令牌
请记住,令牌会在三十分钟后过期:
`ACCESS_TOKEN_EXPIRE_MINUTES = 30`
发生这种情况时,用户应该被注销并重定向到登录页面。为了处理这个问题,让我们给服务/前端/src/main.js 添加一个 Axios 拦截器:
`import 'bootstrap/dist/css/bootstrap.css'; import { createApp } from "vue"; import axios from 'axios'; import App from './App.vue'; import router from './router'; import store from './store'; const app = createApp(App); axios.defaults.withCredentials = true; axios.defaults.baseURL = 'http://localhost:5000/'; // the FastAPI backend // NEW axios.interceptors.response.use(undefined, function (error) { if (error) { const originalRequest = error.config; if (error.response.status === 401 && !originalRequest._retry) { originalRequest._retry = true; store.dispatch('logOut'); return router.push('/login') } } }); app.use(router); app.use(store); app.mount("#app");`
如果你想测试,把ACCESS_TOKEN_EXPIRE_MINUTES = 30改成类似ACCESS_TOKEN_EXPIRE_MINUTES = 1的。请记住,饼干本身仍可持续 30 分钟。是令牌过期了。
结论
本教程涵盖了使用 Vue 和 FastAPI 设置 CRUD 应用程序的基础知识。除了这些应用程序,您还使用 Docker 来简化开发和添加身份验证。
从头开始回顾目标并应对下面的每个挑战,检查您的理解情况。
你可以在 GitHub 上的 fastapi-vue repo 中找到源代码。干杯!
--
寻找更多?
- 测试所有的东西。别黑了。开始确保您的应用程序按预期工作。需要帮助吗?查看以下资源:
- 添加警报以向最终用户显示正确的成功和错误消息。查看来自用 Flask 和 Vue.js 开发单页应用的警报组件部分,了解更多关于用 Bootstrap 和 Vue 设置警报的信息。
- 向后端添加一个新的端点,当用户注销时会调用这个端点来更新 cookie ,就像这样。
用 Flask 和 Vue.js 开发单页应用
原文:https://testdriven.io/blog/developing-a-single-page-app-with-flask-and-vuejs/
以下是如何使用 Vue 和 Flask 设置基本 CRUD 应用程序的分步演练。我们首先用 Vue CLI 搭建一个新的 Vue 应用程序,然后通过 Python 和 Flask 支持的后端 RESTful API 执行基本的 CRUD 操作。
最终应用:

主要依赖:
- 视图 v2.6.11
- CLI 视图 v4.5.11
- 节点 v15.7.0
- 国家预防机制 7.4.3 版
- 烧瓶 v1.1.2
- python 3 . 9 . 1 版
目标
本教程结束时,您将能够:
- 解释什么是烧瓶
- 解释什么是 Vue,以及它与其他 UI 库和前端框架(如 React 和 Angular)相比如何
- 使用 Vue CLI 搭建 Vue 项目
- 在浏览器中创建和渲染 Vue 组件
- 使用 Vue 组件创建单页面应用程序(SPA)
- 将 Vue 应用程序连接到 Flask 后端
- 用 Flask 开发 RESTful API
- 使用引导程序设计 Vue 组件
- 使用 Vue 路由器创建路线并渲染组件
烧瓶和 Vue
让我们快速看一下每个框架。
烧瓶是什么?
Flask 是一个简单而强大的 Python 微型 web 框架,非常适合构建 RESTful APIs。像 Sinatra (Ruby)和 Express (Node)一样,它是最小和灵活的,所以你可以从小处着手,根据需要构建一个更复杂的应用。
第一次用烧瓶?查看以下两个资源:
Vue 是什么?
Vue 是一个开源的 JavaScript 框架,用于构建用户界面。它采用了 React 和 Angular 的一些最佳实践。也就是说,与 React 和 Angular 相比,它要平易近人得多,因此初学者可以快速上手并运行。它也同样强大,因此它提供了创建现代前端应用程序所需的所有功能。
有关 Vue 的更多信息,以及使用它与 React 和 Angular 相比的优缺点,请查看参考资料:
第一次和 Vue 在一起?花点时间通读官方 Vue 指南中的介绍。
烧瓶设置
首先创建一个新的项目目录:
`$ mkdir flask-vue-crud
$ cd flask-vue-crud`
在“flask-vue-crud”中,创建一个名为“server”的新目录。然后,在“服务器”目录中创建并激活虚拟环境:
`$ python3.9 -m venv env
$ source env/bin/activate
(env)$`
根据您的环境,上述命令可能会有所不同。
将烧瓶连同烧瓶-CORS 延长件一起安装;
`(env)$ pip install Flask==1.1.2 Flask-Cors==3.0.10`
向新创建的“服务器”目录添加一个 app.py 文件:
`from flask import Flask, jsonify
from flask_cors import CORS
# configuration
DEBUG = True
# instantiate the app
app = Flask(__name__)
app.config.from_object(__name__)
# enable CORS
CORS(app, resources={r'/*': {'origins': '*'}})
# sanity check route
@app.route('/ping', methods=['GET'])
def ping_pong():
return jsonify('pong!')
if __name__ == '__main__':
app.run()`
我们为什么需要弗拉斯克-CORS?为了进行跨来源请求——例如,来自不同协议、IP 地址、域名或端口的请求——您需要启用跨来源资源共享 (CORS)。弗拉斯克-CORS 公司为我们处理此事。
值得注意的是,上面的设置允许来自任何域、协议或端口的所有路由上的跨来源请求。在生产环境中,您应该仅允许来自托管前端应用程序的域的跨来源请求。参考烧瓶-CORS 文档了解更多信息。
运行应用程序:
要进行测试,将您的浏览器指向http://localhost:5000/ping。您应该看到:
回到终端,按 Ctrl+C 终止服务器,然后导航回项目根目录。现在,让我们把注意力转向前端,设置 Vue。
视图设置
我们将使用强大的 Vue CLI 来生成定制的项目样板文件。
全局安装:
第一次用 npm?查看关于 npm 的官方指南。
然后,在“flask-vue-crud”中,运行以下命令来初始化一个名为client的新 vue 项目:
这需要你回答几个关于这个项目的问题。
`Vue CLI v4.5.11
? Please pick a preset: (Use arrow keys)
❯ Default ([Vue 2] babel, eslint)
Default (Vue 3 Preview) ([Vue 3] babel, eslint)
Manually select features`
使用向下箭头键选中“手动选择功能”,然后按 enter 键。接下来,您需要选择想要安装的功能。对于本教程,选择“选择 Vue 版本”、“Babel”、“Router”和“Linter / Formatter”,如下所示:
`Vue CLI v4.5.11
? Please pick a preset: Manually select features
? Check the features needed for your project:
❯◉ Choose Vue version
◉ Babel
◯ TypeScript
◯ Progressive Web App (PWA) Support
◉ Router
◯ Vuex
◯ CSS Pre-processors
◉ Linter / Formatter
◯ Unit Testing
◯ E2E Testing`
按回车键。
为 Vue 版本选择“2.x”。对路由器使用历史模式。为 linter 选择“ESLint + Airbnb 配置”和“保存时 Lint”。最后,选择“In package.json”选项,以便将配置放在 package.json 文件中,而不是单独的配置文件中。
您应该会看到类似如下的内容:
`Vue CLI v4.5.11
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, Router, Linter
? Choose a version of Vue.js that you want to start the project with 2.x
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a linter / formatter config: Airbnb
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In package.json
? Save this as a preset for future projects? (y/N) No`
再次按 enter 配置项目结构并安装依赖项。
快速查看一下生成的项目结构。这看起来似乎很多,但是我们将只处理“src”文件夹中的文件和文件夹,以及在“public”文件夹中找到的index.html文件。
index.html 文件是我们的 Vue 应用程序的起点。
`<!DOCTYPE html>
<html lang="">
<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 rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>`
注意带有app的id的<div>元素。这是一个占位符,Vue 将使用它来附加生成的 HTML 和 CSS 以生成 UI。
请注意“src”文件夹中的文件夹:
`client/src
├── App.vue
├── assets
│ └── logo.png
├── components
│ └── HelloWorld.vue
├── main.js
├── router
│ └── index.js
└── views
├── About.vue
└── Home.vue`
情绪完全失控
| 名字 | 目的 |
|---|---|
| main.js | app 入口点,它加载并初始化 Vue 以及根组件 |
| app . view | 根组件,这是渲染所有其他组件的起点 |
| "组件" | 存储 UI 组件的位置 |
| 路由器/index.js | 其中定义了 URL 并将其映射到组件 |
| "观点" | 其中存储了与路由器相关联的 UI 组件 |
| "资产" | 存储静态资产(如图像和字体)的地方 |
查看client/src/components/hello world . vue文件。这是一个单文件组件,它被分成三个不同的部分:
- 模板:针对特定于组件的 HTML
- 脚本:组件逻辑通过 JavaScript 实现
- 样式:针对 CSS 样式
启动开发服务器:
`$ cd client
$ npm run serve`
在您选择的浏览器中导航到 http://localhost:8080 。您应该看到以下内容:

为了简单起见,删除“client/src/views”文件夹。然后,在“client/src/components”文件夹中添加一个名为 Ping.vue 的新组件:
`<template>
<div>
<p>{{ msg }}</p>
</div>
</template>
<script> export default { name: 'Ping', data() { return { msg: 'Hello!', }; }, }; </script>`
更新client/src/router/index . js,将“/ping”映射到Ping组件,如下所示:
`import Vue from 'vue'; import Router from 'vue-router'; import Ping from '../components/Ping.vue'; Vue.use(Router); export default new Router({ mode: 'history', base: process.env.BASE_URL, routes: [ { path: '/ping', name: 'Ping', component: Ping, }, ], });`
最后,在 client/src/App.vue 中,移除导航和样式:
`<template>
<div id="app">
<router-view/>
</div>
</template>`
你现在应该在浏览器的中看到Hello!http://localhost:8080/ping。
为了连接客户端 Vue 应用程序和后端 Flask 应用程序,我们可以使用 axios 库来发送 AJAX 请求。
从安装开始:
在 Ping.vue 中更新组件的script部分,如下所示:
`<script> import axios from 'axios'; export default { name: 'Ping', data() { return { msg: '', }; }, methods: { getMessage() { const path = 'http://localhost:5000/ping'; axios.get(path) .then((res) => { this.msg = res.data; }) .catch((error) => { // eslint-disable-next-line console.error(error); }); }, }, created() { this.getMessage(); }, }; </script>`
在新的终端窗口启动 Flask 应用程序。您应该会在浏览器中看到pong!。本质上,当一个响应从后端返回时,我们将响应对象中的msg设置为data的值。
自举设置
接下来,让我们将 Bootstrap(一个流行的 CSS 框架)添加到应用程序中,这样我们就可以快速添加一些样式。
安装:
忽略
jquery和popper.js的警告。不要将这两者添加到项目中。稍后将详细介绍。
将引导样式导入到 client/src/main.js :
`import Vue from 'vue'; import App from './App.vue'; import router from './router'; import 'bootstrap/dist/css/bootstrap.css'; Vue.config.productionTip = false; new Vue({ router, render: (h) => h(App), }).$mount('#app');`
更新 client/src/App.vue 中的style部分:
`<style> #app { margin-top: 60px } </style>`
`<template>
<div class="container">
<button type="button" class="btn btn-primary">{{ msg }}</button>
</div>
</template>`
运行开发服务器:
您应该看到:

接下来,在名为 Books.vue 的新文件中添加一个名为Books的新组件:
`<template>
<div class="container">
<p>books</p>
</div>
</template>`
更新路由器:
`import Vue from 'vue'; import Router from 'vue-router'; import Books from '../components/Books.vue'; import Ping from '../components/Ping.vue'; Vue.use(Router); export default new Router({ mode: 'history', base: process.env.BASE_URL, routes: [ { path: '/', name: 'Books', component: Books, }, { path: '/ping', name: 'Ping', component: Ping, }, ], });`
测试:
最后,让我们向Books组件添加一个快速的、引导样式的表格:
`<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Books</h1>
<hr><br><br>
<button type="button" class="btn btn-success btn-sm">Add Book</button>
<br><br>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Read?</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>foo</td>
<td>bar</td>
<td>foobar</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-warning btn-sm">Update</button>
<button type="button" class="btn btn-danger btn-sm">Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>`
您现在应该看到:

现在我们可以开始构建 CRUD 应用程序的功能了。
我们在建造什么?
我们的目标是为一个单一的资源书籍设计一个后端 RESTful API,由 Python 和 Flask 提供支持。API 本身应该遵循 RESTful 设计原则,使用基本的 HTTP 动词:GET、POST、PUT 和 DELETE。
我们还将设置一个使用 Vue 的前端应用程序,它使用后端 API:

本教程只讨论快乐之路。处理错误是一个单独的练习。检查您的理解,并在前端和后端添加适当的错误处理。
获取路线
计算机网络服务器
将图书列表添加到 server/app.py :
`BOOKS = [
{
'title': 'On the Road',
'author': 'Jack Kerouac',
'read': True
},
{
'title': 'Harry Potter and the Philosopher\'s Stone',
'author': 'J. K. Rowling',
'read': False
},
{
'title': 'Green Eggs and Ham',
'author': 'Dr. Seuss',
'read': True
}
]`
添加路由处理程序:
`@app.route('/books', methods=['GET'])
def all_books():
return jsonify({
'status': 'success',
'books': BOOKS
})`
运行 Flask 应用程序,如果它还没有运行的话,然后在http://localhost:5000/books上手动测试路线。
寻找额外的挑战?为此编写一个自动化测试。查看这个资源,了解更多关于测试 Flask 应用的信息。
客户
更新组件:
`<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Books</h1>
<hr><br><br>
<button type="button" class="btn btn-success btn-sm">Add Book</button>
<br><br>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Read?</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(book, index) in books" :key="index">
<td>{{ book.title }}</td>
<td>{{ book.author }}</td>
<td>
<span v-if="book.read">Yes</span>
<span v-else>No</span>
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-warning btn-sm">Update</button>
<button type="button" class="btn btn-danger btn-sm">Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script> import axios from 'axios'; export default { data() { return { books: [], }; }, methods: { getBooks() { const path = 'http://localhost:5000/books'; axios.get(path) .then((res) => { this.books = res.data.books; }) .catch((error) => { // eslint-disable-next-line console.error(error); }); }, }, created() { this.getBooks(); }, }; </script>`
组件初始化后,通过创建的生命周期钩子调用getBooks()方法,从我们刚刚设置的后端端点获取书籍。
查看实例生命周期挂钩以获得更多关于组件生命周期和可用方法的信息。
在模板中,我们通过 v-for 指令遍历图书列表,在每次迭代中创建一个新的表行。索引值被用作键。最后, v-if 用于渲染Yes或No,表示用户是否读过这本书。

引导程序视图
在下一节中,我们将使用一个模态来添加一本新书。我们将为此添加 Bootstrap Vue 库,它提供了一组 Vue 组件,使用基于 Bootstrap 的 HTML 和 CSS。
为什么选择 Bootstrap Vue?Bootstrap 的模态组件使用 jQuery ,你应该避免在同一个项目中与 Vue 一起使用,因为 Vue 使用虚拟 Dom 来更新 Dom。换句话说,如果您确实使用 jQuery 来操作 DOM,Vue 不会知道。至少,如果你绝对需要使用 jQuery,不要在同一个 DOM 元素上同时使用 Vue 和 jQuery。
安装:
启用 client/src/main.js 中的 Bootstrap Vue 库:
`import BootstrapVue from 'bootstrap-vue'; import Vue from 'vue'; import App from './App.vue'; import router from './router'; import 'bootstrap/dist/css/bootstrap.css'; Vue.use(BootstrapVue); Vue.config.productionTip = false; new Vue({ router, render: (h) => h(App), }).$mount('#app');`
邮寄路线
计算机网络服务器
更新现有的路由处理程序,以处理添加新书的 POST 请求:
`@app.route('/books', methods=['GET', 'POST'])
def all_books():
response_object = {'status': 'success'}
if request.method == 'POST':
post_data = request.get_json()
BOOKS.append({
'title': post_data.get('title'),
'author': post_data.get('author'),
'read': post_data.get('read')
})
response_object['message'] = 'Book added!'
else:
response_object['books'] = BOOKS
return jsonify(response_object)`
更新导入:
`from flask import Flask, jsonify, request`
当 Flask 服务器运行时,您可以在新的终端选项卡中测试 POST 路由:
`$ curl -X POST http://localhost:5000/books -d \
'{"title": "1Q84", "author": "Haruki Murakami", "read": "true"}' \
-H 'Content-Type: application/json'`
您应该看到:
`{
"message": "Book added!",
"status": "success"
}`
您还应该在来自http://localhost:5000/books端点的响应中看到这本新书。
标题已经存在怎么办?或者一个书名有多个作者怎么办?通过处理这些案例来检查你的理解。此外,当
title、author和/或read丢失时,如何处理无效的有效载荷?
客户
在客户端,现在让我们添加一个用于向Books组件添加新书的模型,从 HTML:
`<b-modal ref="addBookModal"
id="book-modal"
title="Add a new book"
hide-footer>
<b-form @submit="onSubmit" @reset="onReset" class="w-100">
<b-form-group id="form-title-group"
label="Title:"
label-for="form-title-input">
<b-form-input id="form-title-input"
type="text"
v-model="addBookForm.title"
required
placeholder="Enter title">
</b-form-input>
</b-form-group>
<b-form-group id="form-author-group"
label="Author:"
label-for="form-author-input">
<b-form-input id="form-author-input"
type="text"
v-model="addBookForm.author"
required
placeholder="Enter author">
</b-form-input>
</b-form-group>
<b-form-group id="form-read-group">
<b-form-checkbox-group v-model="addBookForm.read" id="form-checks">
<b-form-checkbox value="true">Read?</b-form-checkbox>
</b-form-checkbox-group>
</b-form-group>
<b-button type="submit" variant="primary">Submit</b-button>
<b-button type="reset" variant="danger">Reset</b-button>
</b-form>
</b-modal>`
在结束的div标签前添加这个。快速浏览一下代码。v-model是用于将输入值绑定回状态的指令。您将很快看到这一点。
hide-footer是做什么的?在 Bootstrap Vue docs 中自己回顾一下。
更新script部分:
`<script> import axios from 'axios'; export default { data() { return { books: [], addBookForm: { title: '', author: '', read: [], }, }; }, methods: { getBooks() { const path = 'http://localhost:5000/books'; axios.get(path) .then((res) => { this.books = res.data.books; }) .catch((error) => { // eslint-disable-next-line console.error(error); }); }, addBook(payload) { const path = 'http://localhost:5000/books'; axios.post(path, payload) .then(() => { this.getBooks(); }) .catch((error) => { // eslint-disable-next-line console.log(error); this.getBooks(); }); }, initForm() { this.addBookForm.title = ''; this.addBookForm.author = ''; this.addBookForm.read = []; }, onSubmit(evt) { evt.preventDefault(); this.$refs.addBookModal.hide(); let read = false; if (this.addBookForm.read[0]) read = true; const payload = { title: this.addBookForm.title, author: this.addBookForm.author, read, // property shorthand }; this.addBook(payload); this.initForm(); }, onReset(evt) { evt.preventDefault(); this.$refs.addBookModal.hide(); this.initForm(); }, }, created() { this.getBooks(); }, }; </script>`
这里发生了什么事?
addBookForm是否通过v-model将绑定到表单输入。换句话说,当一个被更新时,另一个也会被更新。这叫做双向绑定。请花点时间阅读一下这里。想想这件事的后果。你认为这使国家管理更容易还是更难?React 和 Angular 如何处理这个问题?在我看来,双向绑定(以及可变性)使得 Vue 比 React 更容易接近。- 当用户成功提交表单时,触发
onSubmit。在提交时,我们阻止正常的浏览器行为(evt.preventDefault()),关闭模态(this.$refs.addBookModal.hide()),触发addBook方法,并清除表单(initForm())。 addBook向/books发送 POST 请求以添加新书。
根据需要参考 Vue 文档,自行检查其余的变更。
您能想到客户端或服务器上的任何潜在错误吗?自己处理这些以改善用户体验。
最后,更新模板中的“Add Book”按钮,以便在单击按钮时显示模式:
`<button type="button" class="btn btn-success btn-sm" v-b-modal.book-modal>Add Book</button>`
该组件现在应该如下所示:
`<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Books</h1>
<hr><br><br>
<button type="button" class="btn btn-success btn-sm" v-b-modal.book-modal>Add Book</button>
<br><br>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Read?</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(book, index) in books" :key="index">
<td>{{ book.title }}</td>
<td>{{ book.author }}</td>
<td>
<span v-if="book.read">Yes</span>
<span v-else>No</span>
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-warning btn-sm">Update</button>
<button type="button" class="btn btn-danger btn-sm">Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<b-modal ref="addBookModal"
id="book-modal"
title="Add a new book"
hide-footer>
<b-form @submit="onSubmit" @reset="onReset" class="w-100">
<b-form-group id="form-title-group"
label="Title:"
label-for="form-title-input">
<b-form-input id="form-title-input"
type="text"
v-model="addBookForm.title"
required
placeholder="Enter title">
</b-form-input>
</b-form-group>
<b-form-group id="form-author-group"
label="Author:"
label-for="form-author-input">
<b-form-input id="form-author-input"
type="text"
v-model="addBookForm.author"
required
placeholder="Enter author">
</b-form-input>
</b-form-group>
<b-form-group id="form-read-group">
<b-form-checkbox-group v-model="addBookForm.read" id="form-checks">
<b-form-checkbox value="true">Read?</b-form-checkbox>
</b-form-checkbox-group>
</b-form-group>
<b-button-group>
<b-button type="submit" variant="primary">Submit</b-button>
<b-button type="reset" variant="danger">Reset</b-button>
</b-button-group>
</b-form>
</b-modal>
</div>
</template>
<script> import axios from 'axios'; export default { data() { return { books: [], addBookForm: { title: '', author: '', read: [], }, }; }, methods: { getBooks() { const path = 'http://localhost:5000/books'; axios.get(path) .then((res) => { this.books = res.data.books; }) .catch((error) => { // eslint-disable-next-line console.error(error); }); }, addBook(payload) { const path = 'http://localhost:5000/books'; axios.post(path, payload) .then(() => { this.getBooks(); }) .catch((error) => { // eslint-disable-next-line console.log(error); this.getBooks(); }); }, initForm() { this.addBookForm.title = ''; this.addBookForm.author = ''; this.addBookForm.read = []; }, onSubmit(evt) { evt.preventDefault(); this.$refs.addBookModal.hide(); let read = false; if (this.addBookForm.read[0]) read = true; const payload = { title: this.addBookForm.title, author: this.addBookForm.author, read, // property shorthand }; this.addBook(payload); this.initForm(); }, onReset(evt) { evt.preventDefault(); this.$refs.addBookModal.hide(); this.initForm(); }, }, created() { this.getBooks(); }, }; </script>`
测试一下!尝试添加一本书:

警报组件
接下来,让我们添加一个 Alert 组件,在添加新书后向最终用户显示一条消息。我们将为此创建一个新组件,因为您可能会在许多组件中使用该功能。
将名为 Alert.vue 的新文件添加到“客户端/src/组件”中:
`<template>
<p>It works!</p>
</template>`
然后,将其导入到Books组件的script部分,并注册该组件:
`<script> import axios from 'axios'; import Alert from './Alert.vue'; ... export default { data() { return { books: [], addBookForm: { title: '', author: '', read: [], }, }; }, components: { alert: Alert, }, ... }; </script>`
现在,我们可以在template部分引用新组件:
`<template>
<b-container>
<b-row>
<b-col col sm="10">
<h1>Books</h1>
<hr><br><br>
<alert></alert>
<button type="button" class="btn btn-success btn-sm" v-b-modal.book-modal>Add Book</button>
...
</b-col>
</b-row>
</b-container>
</template>`
刷新浏览器。您现在应该看到:

查看官方 Vue 文档中的 Composing with Components 以获得更多关于在其他组件中使用组件的信息。
接下来,让我们添加实际的 b-alert 组件client/src/components/alert . vue:
`<template>
<div>
<b-alert variant="success" show>{{ message }}</b-alert>
<br>
</div>
</template>
<script> export default { props: ['message'], }; </script>`
注意script部分的道具选项。我们可以像这样从父组件(Books)向下传递消息:
`<alert message="hi"></alert>`
试试这个:

查看文档了解更多关于道具的信息。
为了使其动态,以便传递自定义消息,在 Books.vue 中使用一个绑定表达式:
`<alert :message="message"></alert>`
将message添加到data选项中,同样在书籍中。vue 中:
`data() { return { books: [], addBookForm: { title: '', author: '', read: [], }, message: '', }; },`
然后,在addBook内,更新消息:
`addBook(payload) { const path = 'http://localhost:5000/books'; axios.post(path, payload) .then(() => { this.getBooks(); this.message = 'Book added!'; }) .catch((error) => { // eslint-disable-next-line console.log(error); this.getBooks(); }); },`
最后,添加一个v-if,这样只有当showMessage为真时才会显示警告:
`<alert :message=message v-if="showMessage"></alert>`
将showMessage添加到data中:
`data() { return { books: [], addBookForm: { title: '', author: '', read: [], }, message: '', showMessage: false, }; },`
再次更新addBook,将showMessage设置为true:
`addBook(payload) { const path = 'http://localhost:5000/books'; axios.post(path, payload) .then(() => { this.getBooks(); this.message = 'Book added!'; this.showMessage = true; }) .catch((error) => { // eslint-disable-next-line console.log(error); this.getBooks(); }); },`
测试一下!

挑战:
- 想想
showMessage应该设置在哪里false。更新您的代码。- 尝试使用警报组件来显示错误。
- 将警报重构为可解除。
放置路线
计算机网络服务器
对于更新,我们将需要使用一个唯一的标识符,因为我们不能依赖标题是唯一的。我们可以使用来自 Python 标准库的uuid。
更新 server/app.py 中的BOOKS:
`BOOKS = [
{
'id': uuid.uuid4().hex,
'title': 'On the Road',
'author': 'Jack Kerouac',
'read': True
},
{
'id': uuid.uuid4().hex,
'title': 'Harry Potter and the Philosopher\'s Stone',
'author': 'J. K. Rowling',
'read': False
},
{
'id': uuid.uuid4().hex,
'title': 'Green Eggs and Ham',
'author': 'Dr. Seuss',
'read': True
}
]`
不要忘记重要的一点:
添加新书时,重构all_books以考虑唯一 id:
`@app.route('/books', methods=['GET', 'POST'])
def all_books():
response_object = {'status': 'success'}
if request.method == 'POST':
post_data = request.get_json()
BOOKS.append({
'id': uuid.uuid4().hex,
'title': post_data.get('title'),
'author': post_data.get('author'),
'read': post_data.get('read')
})
response_object['message'] = 'Book added!'
else:
response_object['books'] = BOOKS
return jsonify(response_object)`
添加新的路由处理程序:
`@app.route('/books/<book_id>', methods=['PUT'])
def single_book(book_id):
response_object = {'status': 'success'}
if request.method == 'PUT':
post_data = request.get_json()
remove_book(book_id)
BOOKS.append({
'id': uuid.uuid4().hex,
'title': post_data.get('title'),
'author': post_data.get('author'),
'read': post_data.get('read')
})
response_object['message'] = 'Book updated!'
return jsonify(response_object)`
添加助手:
`def remove_book(book_id):
for book in BOOKS:
if book['id'] == book_id:
BOOKS.remove(book)
return True
return False`
花点时间想想你会如何处理一个不存在的
id的情况。有效载荷不正确怎么办?在 helper 中重构 for 循环,使它更 Pythonic 化。
客户
步骤:
- 添加模态和形式
- 处理更新按钮单击
- 连接 AJAX 请求
- 警告用户
- 处理取消按钮单击
(1)添加情态和形式
首先,在模板中添加一个新的模态,就在第一个模态的下面:
`<b-modal ref="editBookModal"
id="book-update-modal"
title="Update"
hide-footer>
<b-form @submit="onSubmitUpdate" @reset="onResetUpdate" class="w-100">
<b-form-group id="form-title-edit-group"
label="Title:"
label-for="form-title-edit-input">
<b-form-input id="form-title-edit-input"
type="text"
v-model="editForm.title"
required
placeholder="Enter title">
</b-form-input>
</b-form-group>
<b-form-group id="form-author-edit-group"
label="Author:"
label-for="form-author-edit-input">
<b-form-input id="form-author-edit-input"
type="text"
v-model="editForm.author"
required
placeholder="Enter author">
</b-form-input>
</b-form-group>
<b-form-group id="form-read-edit-group">
<b-form-checkbox-group v-model="editForm.read" id="form-checks">
<b-form-checkbox value="true">Read?</b-form-checkbox>
</b-form-checkbox-group>
</b-form-group>
<b-button-group>
<b-button type="submit" variant="primary">Update</b-button>
<b-button type="reset" variant="danger">Cancel</b-button>
</b-button-group>
</b-form>
</b-modal>`
将表单状态添加到script部分的data部分:
`editForm: { id: '', title: '', author: '', read: [], },`
挑战:不要使用新的模式,尝试使用相同的模式来处理 POST 和 PUT 请求。
(2)点击处理更新按钮
更新表格中的“更新”按钮:
`<button
type="button"
class="btn btn-warning btn-sm"
v-b-modal.book-update-modal
@click="editBook(book)">
Update
</button>`
添加一个新方法来更新editForm中的值:
`editBook(book) { this.editForm = book; },`
然后,添加一个方法来处理表单提交:
`onSubmitUpdate(evt) { evt.preventDefault(); this.$refs.editBookModal.hide(); let read = false; if (this.editForm.read[0]) read = true; const payload = { title: this.editForm.title, author: this.editForm.author, read, }; this.updateBook(payload, this.editForm.id); },`
(3)连接 AJAX 请求
`updateBook(payload, bookID) { const path = `http://localhost:5000/books/${bookID}`; axios.put(path, payload) .then(() => { this.getBooks(); }) .catch((error) => { // eslint-disable-next-line console.error(error); this.getBooks(); }); },`
(4)提醒用户
更新updateBook:
`updateBook(payload, bookID) { const path = `http://localhost:5000/books/${bookID}`; axios.put(path, payload) .then(() => { this.getBooks(); this.message = 'Book updated!'; this.showMessage = true; }) .catch((error) => { // eslint-disable-next-line console.error(error); this.getBooks(); }); },`
(5)点击手柄取消按钮
添加方法:
`onResetUpdate(evt) { evt.preventDefault(); this.$refs.editBookModal.hide(); this.initForm(); this.getBooks(); // why? },`
更新initForm:
`initForm() { this.addBookForm.title = ''; this.addBookForm.author = ''; this.addBookForm.read = []; this.editForm.id = ''; this.editForm.title = ''; this.editForm.author = ''; this.editForm.read = []; },`
请确保在继续之前检查代码。完成后,测试应用程序。确保单击按钮时显示模式,并且正确填充输入值。

删除路线
计算机网络服务器
更新路由处理程序:
`@app.route('/books/<book_id>', methods=['PUT', 'DELETE'])
def single_book(book_id):
response_object = {'status': 'success'}
if request.method == 'PUT':
post_data = request.get_json()
remove_book(book_id)
BOOKS.append({
'id': uuid.uuid4().hex,
'title': post_data.get('title'),
'author': post_data.get('author'),
'read': post_data.get('read')
})
response_object['message'] = 'Book updated!'
if request.method == 'DELETE':
remove_book(book_id)
response_object['message'] = 'Book removed!'
return jsonify(response_object)`
客户
像这样更新“删除”按钮:
`<button
type="button"
class="btn btn-danger btn-sm"
@click="onDeleteBook(book)">
Delete
</button>`
添加方法来处理按钮点击,然后删除书:
`removeBook(bookID) { const path = `http://localhost:5000/books/${bookID}`; axios.delete(path) .then(() => { this.getBooks(); this.message = 'Book removed!'; this.showMessage = true; }) .catch((error) => { // eslint-disable-next-line console.error(error); this.getBooks(); }); }, onDeleteBook(book) { this.removeBook(book.id); },`
现在,当用户单击删除按钮时,onDeleteBook方法被触发,这又触发了removeBook方法。该方法将删除请求发送到后端。当响应返回时,显示警告信息并运行getBooks。
挑战:
- 添加一个确认警告,而不是单击按钮删除。
- 显示一条信息,比如“没有书!请加一个。”,没有书的时候。

结论
这篇文章讲述了用 Vue 和 Flask 设置 CRUD 应用程序的基础知识。
通过回顾这篇文章开头的目标和经历每个挑战来检查你的理解。
你可以在 flask-vue-crud repo 中找到源代码。感谢阅读。
寻找更多?
- 查看用 Stripe、Vue.js 和 Flask 接受付款的博文,该博文从本文停止的地方开始。
- 想了解如何将此应用部署到 Heroku?查看使用 Docker 和 Gitlab CI 将 Flask 和 Vue 应用程序部署到 Heroku。
用 Python 开发异步任务队列
原文:https://testdriven.io/blog/developing-an-asynchronous-task-queue-in-python/
本教程着眼于如何使用 Python 的多重处理库和 Redis 实现几个异步任务队列。
队列数据结构
- 在尾部添加一个项目(入队)
- 在头部移除一个项目(出列)

当您编写本教程中的示例时,您会在实践中看到这一点。
工作
让我们从创建一个基本任务开始:
`# tasks.py
import collections
import json
import os
import sys
import uuid
from pathlib import Path
from nltk.corpus import stopwords
COMMON_WORDS = set(stopwords.words("english"))
BASE_DIR = Path(__file__).resolve(strict=True).parent
DATA_DIR = Path(BASE_DIR).joinpath("data")
OUTPUT_DIR = Path(BASE_DIR).joinpath("output")
def save_file(filename, data):
random_str = uuid.uuid4().hex
outfile = f"{filename}_{random_str}.txt"
with open(Path(OUTPUT_DIR).joinpath(outfile), "w") as outfile:
outfile.write(data)
def get_word_counts(filename):
wordcount = collections.Counter()
# get counts
with open(Path(DATA_DIR).joinpath(filename), "r") as f:
for line in f:
wordcount.update(line.split())
for word in set(COMMON_WORDS):
del wordcount[word]
# save file
save_file(filename, json.dumps(dict(wordcount.most_common(20))))
proc = os.getpid()
print(f"Processed {filename} with process id: {proc}")
if __name__ == "__main__":
get_word_counts(sys.argv[1])`
因此,get_word_counts从给定的文本文件中找到 20 个最常用的单词,并将它们保存到输出文件中。它还使用 Python 的 os 库打印当前进程标识符(或 pid)。
跟着一起走?
创建一个项目目录和一个虚拟环境。然后,使用 pip 安装 NLTK :
`(env)$ pip install nltk==3.6.5`
安装完成后,调用 Python shell 并下载stopwords 文集:
`>>> import nltk
>>> nltk.download("stopwords")
[nltk_data] Downloading package stopwords to
[nltk_data] /Users/michael/nltk_data...
[nltk_data] Unzipping corpora/stopwords.zip.
True`
如果您遇到 SSL 错误,请参考这篇文章。
示例修复:
>>> import nltk >>> nltk.download('stopwords') [nltk_data] Error loading stopwords: <urlopen error [SSL: [nltk_data] CERTIFICATE_VERIFY_FAILED] certificate verify failed: [nltk_data] unable to get local issuer certificate (_ssl.c:1056)> False >>> import ssl >>> try: ... _create_unverified_https_context = ssl._create_unverified_context ... except AttributeError: ... pass ... else: ... ssl._create_default_https_context = _create_unverified_https_context ... >>> nltk.download('stopwords') [nltk_data] Downloading package stopwords to [nltk_data] /Users/michael.herman/nltk_data... [nltk_data] Unzipping corpora/stopwords.zip. True
将上面的 tasks.py 文件添加到您的项目目录中,但是不要运行它。
多重处理池
我们可以使用多处理库并行运行这个任务:
`# simple_pool.py
import multiprocessing
import time
from tasks import get_word_counts
PROCESSES = multiprocessing.cpu_count() - 1
def run():
print(f"Running with {PROCESSES} processes!")
start = time.time()
with multiprocessing.Pool(PROCESSES) as p:
p.map_async(
get_word_counts,
[
"pride-and-prejudice.txt",
"heart-of-darkness.txt",
"frankenstein.txt",
"dracula.txt",
],
)
# clean up
p.close()
p.join()
print(f"Time taken = {time.time() - start:.10f}")
if __name__ == "__main__":
run()`
这里,使用池类,我们用两个进程处理了四个任务。
你注意到map_async方法了吗?将任务映射到流程基本上有四种不同的方法。当选择一个时,您必须考虑多参数、并发性、阻塞和排序:
| 方法 | 多参数 | 并发 | 阻塞 | 有序结果 |
|---|---|---|---|---|
map |
不 | 是 | 是 | 是 |
map_async |
不 | 不 | 不 | 是 |
apply |
是 | 不 | 是 | 不 |
apply_async |
是 | 是 | 不 | 不 |
没有close和join,垃圾收集可能不会发生,这可能导致内存泄漏。
close告知池不接受任何新任务join告知池在所有任务完成后退出
跟着一起走?从简单任务队列 repo 中的“数据”目录中抓取项目古腾堡样本文本文件,然后添加一个“输出”目录。
您的项目目录应该如下所示:
├── data │ ├── dracula.txt │ ├── frankenstein.txt │ ├── heart-of-darkness.txt │ └── pride-and-prejudice.txt ├── output ├── simple_pool.py └── tasks.py
运行时间应该不到一秒钟:
`(env)$ python simple_pool.py
Running with 15 processes!
Processed heart-of-darkness.txt with process id: 50510
Processed frankenstein.txt with process id: 50515
Processed pride-and-prejudice.txt with process id: 50511
Processed dracula.txt with process id: 50512
Time taken = 0.6383581161`
这个脚本运行在 16 核的 i9 Macbook Pro 上。
因此,多重处理Pool类为我们处理排队逻辑。它非常适合运行 CPU 密集型任务或任何可以独立分解和分配的任务。如果您需要对队列进行更多的控制,或者需要在多个进程之间共享数据,您可能想看看Queue类。
关于这一点以及并行性(多处理)和并发性(多线程)之间的区别的更多信息,请回顾文章用并发性、并行性和异步性加速 Python。
多重处理队列
让我们看一个简单的例子:
`# simple_queue.py
import multiprocessing
def run():
books = [
"pride-and-prejudice.txt",
"heart-of-darkness.txt",
"frankenstein.txt",
"dracula.txt",
]
queue = multiprocessing.Queue()
print("Enqueuing...")
for book in books:
print(book)
queue.put(book)
print("\nDequeuing...")
while not queue.empty():
print(queue.get())
if __name__ == "__main__":
run()`
同样来自多处理库的队列类是一个基本的 FIFO(先进先出)数据结构。它类似于队列。Queue 类,但是是为进程间通信设计的。我们使用put将一个项目加入队列,使用get将一个项目出队。
查看
Queue源代码可以更好地理解这个类的机制。
现在,让我们看看更高级的例子:
`# simple_task_queue.py
import multiprocessing
import time
from tasks import get_word_counts
PROCESSES = multiprocessing.cpu_count() - 1
NUMBER_OF_TASKS = 10
def process_tasks(task_queue):
while not task_queue.empty():
book = task_queue.get()
get_word_counts(book)
return True
def add_tasks(task_queue, number_of_tasks):
for num in range(number_of_tasks):
task_queue.put("pride-and-prejudice.txt")
task_queue.put("heart-of-darkness.txt")
task_queue.put("frankenstein.txt")
task_queue.put("dracula.txt")
return task_queue
def run():
empty_task_queue = multiprocessing.Queue()
full_task_queue = add_tasks(empty_task_queue, NUMBER_OF_TASKS)
processes = []
print(f"Running with {PROCESSES} processes!")
start = time.time()
for n in range(PROCESSES):
p = multiprocessing.Process(target=process_tasks, args=(full_task_queue,))
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"Time taken = {time.time() - start:.10f}")
if __name__ == "__main__":
run()`
这里,我们将 40 个任务(每个文本文件 10 个)放入队列,通过Process类创建单独的进程,使用start开始运行进程,最后,使用join完成进程。
运行时间应该不到一秒钟。
挑战:通过添加另一个队列来保存已完成的任务,检查您的理解。您可以在
process_tasks函数中对它们进行排队。
记录
多处理库也支持日志记录:
`# simple_task_queue_logging.py
import logging
import multiprocessing
import os
import time
from tasks import get_word_counts
PROCESSES = multiprocessing.cpu_count() - 1
NUMBER_OF_TASKS = 10
def process_tasks(task_queue):
logger = multiprocessing.get_logger()
proc = os.getpid()
while not task_queue.empty():
try:
book = task_queue.get()
get_word_counts(book)
except Exception as e:
logger.error(e)
logger.info(f"Process {proc} completed successfully")
return True
def add_tasks(task_queue, number_of_tasks):
for num in range(number_of_tasks):
task_queue.put("pride-and-prejudice.txt")
task_queue.put("heart-of-darkness.txt")
task_queue.put("frankenstein.txt")
task_queue.put("dracula.txt")
return task_queue
def run():
empty_task_queue = multiprocessing.Queue()
full_task_queue = add_tasks(empty_task_queue, NUMBER_OF_TASKS)
processes = []
print(f"Running with {PROCESSES} processes!")
start = time.time()
for w in range(PROCESSES):
p = multiprocessing.Process(target=process_tasks, args=(full_task_queue,))
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"Time taken = {time.time() - start:.10f}")
if __name__ == "__main__":
multiprocessing.log_to_stderr(logging.ERROR)
run()`
要进行测试,请将task_queue.put("dracula.txt")更改为task_queue.put("drakula.txt")。您应该会在终端中看到以下错误输出十次:
`[ERROR/Process-4] [Errno 2] No such file or directory:
'simple-task-queue/data/drakula.txt'`
想要记录到光盘吗?
`# simple_task_queue_logging.py
import logging
import multiprocessing
import os
import time
from tasks import get_word_counts
PROCESSES = multiprocessing.cpu_count() - 1
NUMBER_OF_TASKS = 10
def create_logger():
logger = multiprocessing.get_logger()
logger.setLevel(logging.INFO)
fh = logging.FileHandler("process.log")
fmt = "%(asctime)s - %(levelname)s - %(message)s"
formatter = logging.Formatter(fmt)
fh.setFormatter(formatter)
logger.addHandler(fh)
return logger
def process_tasks(task_queue):
logger = create_logger()
proc = os.getpid()
while not task_queue.empty():
try:
book = task_queue.get()
get_word_counts(book)
except Exception as e:
logger.error(e)
logger.info(f"Process {proc} completed successfully")
return True
def add_tasks(task_queue, number_of_tasks):
for num in range(number_of_tasks):
task_queue.put("pride-and-prejudice.txt")
task_queue.put("heart-of-darkness.txt")
task_queue.put("frankenstein.txt")
task_queue.put("dracula.txt")
return task_queue
def run():
empty_task_queue = multiprocessing.Queue()
full_task_queue = add_tasks(empty_task_queue, NUMBER_OF_TASKS)
processes = []
print(f"Running with {PROCESSES} processes!")
start = time.time()
for w in range(PROCESSES):
p = multiprocessing.Process(target=process_tasks, args=(full_task_queue,))
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"Time taken = {time.time() - start:.10f}")
if __name__ == "__main__":
run()`
同样,通过更改其中一个文件名来导致错误,然后运行它。看一下 process.log 。因为 Python 日志库不使用进程间的共享锁,所以它并不像它应该的那样有组织。为了解决这个问题,我们让每个进程写入自己的文件。为了保持有序,请在项目文件夹中添加一个日志目录:
`# simple_task_queue_logging_separate_files.py
import logging
import multiprocessing
import os
import time
from tasks import get_word_counts
PROCESSES = multiprocessing.cpu_count() - 1
NUMBER_OF_TASKS = 10
def create_logger(pid):
logger = multiprocessing.get_logger()
logger.setLevel(logging.INFO)
fh = logging.FileHandler(f"logs/process_{pid}.log")
fmt = "%(asctime)s - %(levelname)s - %(message)s"
formatter = logging.Formatter(fmt)
fh.setFormatter(formatter)
logger.addHandler(fh)
return logger
def process_tasks(task_queue):
proc = os.getpid()
logger = create_logger(proc)
while not task_queue.empty():
try:
book = task_queue.get()
get_word_counts(book)
except Exception as e:
logger.error(e)
logger.info(f"Process {proc} completed successfully")
return True
def add_tasks(task_queue, number_of_tasks):
for num in range(number_of_tasks):
task_queue.put("pride-and-prejudice.txt")
task_queue.put("heart-of-darkness.txt")
task_queue.put("frankenstein.txt")
task_queue.put("dracula.txt")
return task_queue
def run():
empty_task_queue = multiprocessing.Queue()
full_task_queue = add_tasks(empty_task_queue, NUMBER_OF_TASKS)
processes = []
print(f"Running with {PROCESSES} processes!")
start = time.time()
for w in range(PROCESSES):
p = multiprocessing.Process(target=process_tasks, args=(full_task_queue,))
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"Time taken = {time.time() - start:.10f}")
if __name__ == "__main__":
run()`
雷迪斯
接下来,我们不使用内存队列,而是将 Redis 添加到混合队列中。
跟着一起走? 下载并安装 Redis,如果你还没有安装的话。然后,安装 Python 接口:
(env)$ pip install redis==4.0.2
我们将把逻辑分成四个文件:
- redis_queue.py 分别通过
SimpleQueue和SimpleTask类创建新的队列和任务。 - redis_queue_client 调查新任务。
- redis_queue_worker 出队并处理任务。
- redis_queue_server 产生工作进程。
`# redis_queue.py
import pickle
import uuid
class SimpleQueue(object):
def __init__(self, conn, name):
self.conn = conn
self.name = name
def enqueue(self, func, *args):
task = SimpleTask(func, *args)
serialized_task = pickle.dumps(task, protocol=pickle.HIGHEST_PROTOCOL)
self.conn.lpush(self.name, serialized_task)
return task.id
def dequeue(self):
_, serialized_task = self.conn.brpop(self.name)
task = pickle.loads(serialized_task)
task.process_task()
return task
def get_length(self):
return self.conn.llen(self.name)
class SimpleTask(object):
def __init__(self, func, *args):
self.id = str(uuid.uuid4())
self.func = func
self.args = args
def process_task(self):
self.func(*self.args)`
这里,我们定义了两个类,SimpleQueue和SimpleTask:
- 创建一个新队列,入队,出队,并获取队列的长度。
SimpleTask创建新任务,由SimpleQueue类的实例用来将新任务排队,并处理新任务。
好奇
lpush()、brpop()、llen()?参见命令参考页。(The brpop()函数特别酷,因为它阻塞连接,直到有值存在才弹出!)
`# redis_queue_client.py
import redis
from redis_queue import SimpleQueue
from tasks import get_word_counts
NUMBER_OF_TASKS = 10
if __name__ == "__main__":
r = redis.Redis()
queue = SimpleQueue(r, "sample")
count = 0
for num in range(NUMBER_OF_TASKS):
queue.enqueue(get_word_counts, "pride-and-prejudice.txt")
queue.enqueue(get_word_counts, "heart-of-darkness.txt")
queue.enqueue(get_word_counts, "frankenstein.txt")
queue.enqueue(get_word_counts, "dracula.txt")
count += 4
print(f"Enqueued {count} tasks!")`
这个模块将创建一个 Redis 和SimpleQueue类的新实例。然后,它将 40 个任务排队。
`# redis_queue_worker.py
import redis
from redis_queue import SimpleQueue
def worker():
r = redis.Redis()
queue = SimpleQueue(r, "sample")
if queue.get_length() > 0:
queue.dequeue()
else:
print("No tasks in the queue")
if __name__ == "__main__":
worker()`
如果任务可用,则调用dequeue方法,然后反序列化任务并调用process_task方法(在 redis_queue.py 中)。
`# redis_queue_server.py
import multiprocessing
from redis_queue_worker import worker
PROCESSES = 4
def run():
processes = []
print(f"Running with {PROCESSES} processes!")
while True:
for w in range(PROCESSES):
p = multiprocessing.Process(target=worker)
processes.append(p)
p.start()
for p in processes:
p.join()
if __name__ == "__main__":
run()`
run方法产生了四个新的工作进程。
您可能不希望四个进程一直同时运行,但有时您可能需要四个或更多的进程。考虑如何根据需求有计划地增加和减少额外的工作人员。
要进行测试,请在单独的终端窗口中运行 redis_queue_server.py 和 redis_queue_client.py :
通过将日志添加到上面的应用程序中,再次检查您的理解。
结论
在本教程中,我们研究了 Python 中的许多异步任务队列实现。如果需求足够简单,以这种方式开发队列可能会更容易。也就是说,如果您正在寻找更高级的功能——如任务调度、批处理、作业优先级和失败任务的重试——您应该寻找一个成熟的解决方案。看看芹菜, RQ ,或者 Huey 。
从简单任务队列 repo 中获取最终代码。
DevOps 教程
描述
DevOps 是一种结合了应用程序开发和操作的软件开发策略,有助于在软件开发人员、质量保证(QA)工程师和系统管理员之间架起一座桥梁。虽然焦点倾向于工具,但是 DevOps 与文化(关于人和过程)以及工具和技术一样重要。
TestDriven.io 上的教程和文章的重点是利用 Docker 和 Kubernetes 等工具来简化开发、测试和部署,以缩短软件开发的生命周期。
- 由 发布
尼克·托马齐奇 - 最后更新于2023 年 2 月 28 日
将 Django 应用程序部署到 Azure App Service。
通过创建一个静态站点并将其部署到 Netlify,利用 Python 和 Flask 的 JAMstack。
- 由 发布
尼克·托马齐奇 - 最后更新于2023 年 2 月 7 日
将 Django 应用程序部署到 Google App Engine。
配置 Django,通过一个亚马逊 S3 桶加载和提供公共和私有的静态和媒体文件。
在 Python 应用程序中启用多区域支持。
- 由 发布
尼克·托马齐奇 - 最后更新于2022 年 12 月 15 日
部署一个 Django 应用程序来呈现。
- 由 发布
尼克·托马齐奇 - 最后更新于2022 年 11 月 9 日
将 Django 应用程序部署到 Fly.io。
- 由 发布
尼克·托马齐奇 - 最后更新于2022 年 11 月 8 日
了解什么是最好的 Heroku 替代方案(及其利弊)。
部署一个带有 PostgreSQL 的 Flask 应用程序进行渲染。
- 由 发布
尼克·托马齐奇 - 最后更新于2022 年 10 月 10 日
在 DigitalOcean droplet 上将 Django 应用程序部署到 Dokku。
- 由 发布
尼克·托马齐奇 - 最后更新于2022 年 9 月 2 日
配置 GitHub 动作,以持续地将 Django 和 Docker 应用程序部署到 Linode。
用 Docker 在 Nginx 后面运行基本认证配置 Flower。
用 PyPI server 和 Docker 设置自己的私有 PyPI 服务器。
本文着眼于如何配置 GitHub 操作来将 Python 包分发到 PyPI 并阅读文档。
将基于 Python 和 Selenium 的 web scraper 与 Selenium Grid 和 Docker Swarm 并行运行。
将 FastAPI 应用程序部署到 AWS Elastic Beanstalk。
将一个 Flask 应用程序部署到 AWS Elastic Beanstalk。
- 由 发布
尼克·托马齐奇 - 最后更新于2022 年 2 月 28 日
将 Django 应用程序部署到 AWS Elastic Beanstalk。
如何将 Django 应用程序部署到 DigitalOcean 的 App 平台。
本文着眼于如何使用 Bazel 来创建可重复的、密封的构建。
使用 Terraform 将 Django 应用程序部署到 AWS ECS。
将节点微服务部署到 Google Kubernetes 引擎上的 Kubernetes 集群。
本教程演示了如何在 Kubernetes 集群上部署 Spark。
使用 Hashicorp 的 Vault 和 Consul 为 Flask web 应用程序创建动态 Postgres 凭据的真实示例。
向 Flask、Redis Queue 和 Amazon SES 的新注册用户发送确认电子邮件。
使用 Docker 将自托管 GitLab CI/CD 运行程序部署到数字海洋。
使用 Docker 和 Docker Swarm 将自托管 GitHub Actions runners 部署到数字海洋。
本教程演示了如何在 DigitalOcean 上使用 Python 和 Fabric 自动建立 Kubernetes 集群。
配置一个在 EC2 实例上运行的容器化 Django 应用程序,将日志发送到 CloudWatch。
使用 Docker 层缓存和 BuildKit,在 CircleCI、GitLab CI 和 GitHub 操作上加速基于 Docker 的构建。
本教程展示了如何使用 Docker Swarm 部署 Vault 和 Consul。
设置和使用 Hashicorp 的保险库和 Consul 来安全地存储和管理机密。
配置 Django,通过 DigitalOcean Spaces 加载和提供公共和私有的静态和媒体文件。
使用加密 SSL 证书来保护运行在 HTTPS Nginx 代理后面的容器化 Django 应用程序。
用 Docker 部署一个 Django app 到 AWS EC2,让我们加密。
配置 GitLab CI 以持续地将 Django 和 Docker 应用程序部署到 AWS EC2。
配置 GitHub 操作,以持续将 Django 和 Docker 应用程序部署到 DigitalOcean。
配置 GitLab CI 以持续将 Django 和 Docker 应用程序部署到 DigitalOcean。
在下面的教程中,我们将带你了解如何在 Kubernetes 上设置 Hashicorp 的金库和领事。
如何将基于 Flask 的微服务(以及 Postgres 和 Vue.js)部署到 Kubernetes 集群的分步演练。
这篇文章详细介绍了如何将 Apache Spark 部署到 DigitalOcean 上的 Docker Swarm 集群。
这篇文章介绍了如何在 Docker Swarm 上运行 Flask 应用程序。
使用 Gitlab CI 将 Flask 和 Vue 支持的全栈 web 应用打包并部署到 Heroku。
简化在 Heroku 上部署、维护和扩展生产级 Django 应用的流程。
什么是连续交货?为什么说是竞争优势?流程是什么样的?
在本帖中,我们将了解如何配置 Travis CI 向 Telegram messenger 发送构建通知。
使用 Selenium Grid 和 Docker 进行分布式测试
原文:https://testdriven.io/blog/distributed-testing-with-selenium-grid/
对于希望实现频繁交付方法(比如持续集成和交付)或者总体上加速开发周期的软件开发团队来说,减少测试执行时间是关键。在频繁构建和测试是常态的环境中,开发人员根本无法承受连续几个小时等待测试完成。将测试分布在许多机器上是这个问题的一个解决方案。
本文着眼于如何使用 Selenium Grid 和 Docker Swarm 将自动化测试分布到多个机器上。
我们还将了解如何在多种浏览器上运行测试,并自动配置和取消配置机器以降低成本。
目标
完成本教程后,您将能够:
- 用 Docker 将硒网格装箱
- 在 Selenium Grid 上运行自动化测试
- 描述分布式计算和并行计算的区别
- 通过 Docker Compose 和 Machine 将 Selenium 网格部署到数字海洋
- 自动供应和取消供应数字海洋上的资源
项目设置
让我们从 Python 中的基本 Selenium 测试开始:
`import time
import unittest
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
class HackerNewsSearchTest(unittest.TestCase):
def setUp(self):
self.browser = webdriver.Chrome()
def test_hackernews_search_for_testdrivenio(self):
browser = self.browser
browser.get('https://news.ycombinator.com')
search_box = browser.find_element_by_name('q')
search_box.send_keys('testdriven.io')
search_box.send_keys(Keys.RETURN)
time.sleep(3) # simulate long running test
self.assertIn('testdriven.io', browser.page_source)
def test_hackernews_search_for_selenium(self):
browser = self.browser
browser.get('https://news.ycombinator.com')
search_box = browser.find_element_by_name('q')
search_box.send_keys('selenium')
search_box.send_keys(Keys.RETURN)
time.sleep(3) # simulate long running test
self.assertIn('selenium', browser.page_source)
def test_hackernews_search_for_testdriven(self):
browser = self.browser
browser.get('https://news.ycombinator.com')
search_box = browser.find_element_by_name('q')
search_box.send_keys('testdriven')
search_box.send_keys(Keys.RETURN)
time.sleep(3) # simulate long running test
self.assertIn('testdriven', browser.page_source)
def test_hackernews_search_with_no_results(self):
browser = self.browser
browser.get('https://news.ycombinator.com')
search_box = browser.find_element_by_name('q')
search_box.send_keys('?*^^%')
search_box.send_keys(Keys.RETURN)
time.sleep(3) # simulate long running test
self.assertNotIn('<em>', browser.page_source)
def tearDown(self):
self.browser.quit() # quit vs close?
if __name__ == '__main__':
unittest.main()`
跟着一起走?
- 创建新的项目目录。
- 将上述代码保存在一个名为 test.py 的新文件中。
- 创建并激活虚拟环境。
- 装硒:
pip install selenium==3.141.0。 - 全局安装 ChromeDriver 。(我们正在使用版本 88.0.4324.96 。)
- 确保它工作:
python test.py。
在这个测试中,我们导航到https://news.ycombinator.com,执行四次搜索,然后断言搜索结果页面被适当地呈现。没什么特别的,但是足够用了。请随意使用你自己的硒测试来代替这个测试。
执行时间:约 25 秒
`$ python test.py
....
----------------------------------------------------------------------
Ran 4 tests in 24.533s
OK`
硒栅
说到分布式测试, Selenium Grid 是最强大和最流行的开源工具之一。有了它,我们可以将测试负载分散到多台机器上,并跨浏览器运行它们。
假设你有一套 90 个测试,在你的笔记本电脑上针对一个版本的 Chrome 运行。也许运行这些测试需要六分钟。使用 Selenium Grid,您可以旋转三台不同的机器来运行它们,这将减少(大约)三分之一的测试执行时间。您也可以在不同的浏览器和平台上运行相同的测试。因此,您不仅节省了时间,而且还有助于确保您的 web 应用程序在不同的浏览器和环境中呈现时表现和外观都是一样的。
Selenium Grid 使用客户机-服务器模型,包括一个中心和多个节点(运行测试的浏览器)。

例如,您可以将三个节点连接到集线器,每个节点运行不同的浏览器。然后,当您使用特定的远程 WebDriver 运行您的测试时,WebDriver 请求被发送到中心 hub,它搜索与指定标准匹配的可用节点(例如,像浏览器版本)。一旦找到一个节点,就发送脚本并运行测试。
我们可以使用来自 Selenium Docker 的官方映像来启动一个中心和几个节点,而不是处理手动配置和安装 Selenium Grid 的麻烦。
要启动并运行,请将以下代码添加到项目根目录下名为 docker-compose.yml 的新文件中:
`version: '3.8' services: hub: image: selenium/hub:3.141.59 ports: - 4444:4444 chrome: image: selenium/node-chrome:3.141.59 depends_on: - hub environment: - HUB_HOST=hub firefox: image: selenium/node-firefox:3.141.59 depends_on: - hub environment: - HUB_HOST=hub`
我们使用了3.141.59标签,它与以下版本的 Selenium、WebDriver、Chrome 和 Firefox 相关联:
`Selenium: 3.141.59 Chrome: 88.0.4324.96 ChromeDriver: 88.0.4324.96 Firefox: 85.0 GeckoDriver: 0.29.0`
如果你想使用不同版本的 Chrome 或 Firefox,请参考发布版页面
提取并运行图像:
本指南使用 Docker 版本 18.09.2。
完成后,打开浏览器并导航到 Selenium Grid 控制台(http://localhost:4444/Grid/console),确保一切正常:

通过更新setUp方法在测试文件中配置远程驱动程序:
`def setUp(self):
caps = {'browserName': os.getenv('BROWSER', 'chrome')}
self.browser = webdriver.Remote(
command_executor='http://localhost:4444/wd/hub',
desired_capabilities=caps
)`
确保也添加导入:
通过 Chrome 节点上的 Selenium Grid 运行测试:
`$ export BROWSER=chrome && python test.py
....
----------------------------------------------------------------------
Ran 4 tests in 21.054s
OK`
也试试 Firefox:
`$ export BROWSER=firefox && python test.py
....
----------------------------------------------------------------------
Ran 4 tests in 25.058s
OK`
为了模拟更长的测试运行,让我们连续运行 20 次相同的测试——10 次在 Chrome 上,10 次在 Firefox 上。
将名为 sequential_test_run.py 的新文件添加到项目根:
`from subprocess import check_call
for counter in range(10):
chrome_cmd = 'export BROWSER=chrome && python test.py'
firefox_cmd = 'export BROWSER=firefox && python test.py'
check_call(chrome_cmd, shell=True)
check_call(firefox_cmd, shell=True)`
运行测试:
`$ python sequential_test_run.py`
执行时间:约 8 分钟
分布式与并行
这很好,但是测试仍然没有并行运行。
这可能会引起混淆,因为“并行”和“分布式”经常被测试人员和开发人员互换使用。查看分布式与并行计算了解更多信息。
到目前为止,我们只处理了在多台机器上分发测试,这是由 Selenium Grid 处理的。测试运行器或框架,如 pytest 或 nose ,负责并行运行测试。为了简单起见,我们将使用子流程模块,而不是完整的框架。值得注意的是,如果你正在使用 pytest 或 nose,请分别查看 pytest-xdist 插件或使用 nose 的并行测试指南以获得并行执行的帮助。
并行运行
将名为 parallel_test_run.py 的新文件添加到项目根:
`from subprocess import Popen
processes = []
for counter in range(10):
chrome_cmd = 'export BROWSER=chrome && python test.py'
firefox_cmd = 'export BROWSER=firefox && python test.py'
processes.append(Popen(chrome_cmd, shell=True))
processes.append(Popen(firefox_cmd, shell=True))
for counter in range(10):
processes[counter].wait()`
这将同时运行测试文件二十次,每次都使用单独的进程。
`$ python parallel_test_run.py`
执行时间:约 4 分钟
这将花费不到四分钟的时间来运行,与顺序运行所有二十个测试相比,将执行时间减少了一半。我们可以通过注册更多的节点来进一步加快速度。
数字海洋
让我们旋转一个数字海洋液滴,这样我们就有更多的核心来工作。
如果你还没有账户,先从注册开始,然后生成一个访问令牌,这样我们就可以使用数字海洋 API 。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
使用 Docker Machine 供应新的 droplet:
`$ docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
selenium-grid;`
需要
--engine-install-url,因为在撰写本文时,Docker v20.10.0 无法与 Docker Machine 一起使用。
完成后,将 Docker 守护进程指向该机器,并将其设置为活动机器:
`$ docker-machine env selenium-grid
$ eval $(docker-machine env selenium-grid)`
旋转 droplet 上的三个容器——中心和两个节点:
抓取水滴的 IP:
`$ docker-machine ip selenium-grid`
确保 Selenium Grid 在http://YOUR _ IP:4444/Grid/console启动并运行,然后更新测试文件中的 IP 地址:
`command_executor='http://YOUR_IP:4444/wd/hub',`
再次并行运行测试:
`$ python parallel_test_run.py`
刷新网格控制台。两个测试应该正在运行,而剩余的 18 个测试正在排队:

同样,这应该需要大约四分钟来运行。
码头工人群体模式
继续前进,我们应该旋转更多的节点来运行测试。然而,由于 droplet 上的资源有限,让我们添加一些 droplet 供节点驻留。这就是 Docker Swarm 发挥作用的地方。
为了创建 Swarm 集群,让我们从头开始,首先旋转旧的液滴:
`$ docker-machine rm selenium-grid`
然后,旋转五个新的液滴:
`$ for i in 1 2 3 4 5; do
docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
node-$i;
done`
在node-1初始化群模式:
`$ docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1)`
您应该会看到类似如下的内容:
`Swarm initialized: current node (ae0iz7lqwz6g9p0oso4f5g6sd) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-54ca6zbkpya4mw15mctnmnkp7uzqmtcj8hm354ym2qqr8n5iyq-2v63f4ztawazzzitiibgpnh39 134.209.115.249:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.`
请注意 join 命令,因为它包含一个令牌,我们需要这个令牌来将节点 workers 添加到群中。
如果忘记了,随时可以跑
docker-machine ssh node-1 -- docker swarm join-token worker。
将剩余的四个节点作为工人添加到群中:
`$ for i in 2 3 4 5; do
docker-machine ssh node-$i \
-- docker swarm join --token YOUR_JOIN_TOKEN;
done`
更新 docker-compose.yml 文件,以 Swarm 模式部署 Selenium 网格:
`version: '3.8' services: hub: image: selenium/hub:3.141.59 ports: - 4444:4444 deploy: mode: replicated replicas: 1 placement: constraints: - node.role == worker chrome: image: selenium/node-chrome:3.141.59 volumes: - /dev/urandom:/dev/random depends_on: - hub environment: - HUB_PORT_4444_TCP_ADDR=hub - HUB_PORT_4444_TCP_PORT=4444 - NODE_MAX_SESSION=1 entrypoint: bash -c 'SE_OPTS="-host $$HOSTNAME -port 5555" /opt/bin/entry_point.sh' ports: - 5555:5555 deploy: replicas: 1 placement: constraints: - node.role == worker firefox: image: selenium/node-firefox:3.141.59 volumes: - /dev/urandom:/dev/random depends_on: - hub environment: - HUB_PORT_4444_TCP_ADDR=hub - HUB_PORT_4444_TCP_PORT=4444 - NODE_MAX_SESSION=1 entrypoint: bash -c 'SE_OPTS="-host $$HOSTNAME -port 5556" /opt/bin/entry_point.sh' ports: - 5556:5556 deploy: replicas: 1 placement: constraints: - node.role == worker`
主要变化:
- 布局约束:我们设置了
node.role == worker的布局约束,这样所有的任务都将在 worker 节点上运行。通常最好让管理器节点远离 CPU 和/或内存密集型任务。 - Entrypoint :这里,我们更新了 entry_point.sh 脚本中的
SE_OPTS中的主机集,这样运行在不同主机上的节点将能够成功链接回 hub。
这样,将 Docker 守护进程指向node-1并部署堆栈:
`$ eval $(docker-machine env node-1)
$ docker stack deploy --compose-file=docker-compose.yml selenium`
再添加几个节点:
`$ docker service scale selenium_chrome=4 selenium_firefox=4`
查看堆栈:
`$ docker stack ps selenium`
您应该会看到类似这样的内容:
`ID NAME IMAGE NODE DESIRED STATE CURRENT STATE
99filw99x8bc selenium_chrome.1 selenium/node-chrome:3.141.59 node-3 Running Running 41 seconds ago
9ff9cwx1dmqw selenium_chrome.2 selenium/node-chrome:3.141.59 node-4 Running Running about a minute ago
ige7rlnj1e03 selenium_chrome.3 selenium/node-chrome:3.141.59 node-5 Running Running 59 seconds ago
ewsg5mxiy9eg selenium_chrome.4 selenium/node-chrome:3.141.59 node-2 Running Running 56 seconds ago
y3ud4iojz8u0 selenium_firefox.1 selenium/node-firefox:3.141.59 node-4 Running Running about a minute ago
bvpizrfdhlq0 selenium_firefox.2 selenium/node-firefox:3.141.59 node-5 Running Running about a minute ago
0jdw3sr7ld62 selenium_firefox.3 selenium/node-firefox:3.141.59 node-3 Running Running 50 seconds ago
4esw9a2wvcf3 selenium_firefox.4 selenium/node-firefox:3.141.59 node-2 Running Running about a minute ago
3dd04mt1t7n8 selenium_hub.1 selenium/hub:3.141.59 node-5 Running Running about a minute ago`
然后,获取运行集线器的节点的名称和 IP 地址,并将其设置为环境变量:
`$ NODE=$(docker service ps --format "{{.Node}}" selenium_hub)
$ export NODE_HUB_ADDRESS=$(docker-machine ip $NODE)`
再次更新setUp方法:
`def setUp(self):
caps = {'browserName': os.getenv('BROWSER', 'chrome')}
address = os.getenv('NODE_HUB_ADDRESS')
self.browser = webdriver.Remote(
command_executor=f'http://{address}:4444/wd/hub',
desired_capabilities=caps
)`
测试!
`$ python parallel_test_run.py`

执行时间:约 1.5 分钟
去除水滴:
`$ docker-machine rm node-1 node-2 node-3 node-4 node-5 -y`
概括地说,为了创建一个群体,我们:
- 旋转出新的水滴
- 在其中一个液滴上初始化群体模式(本例中为
node-1) - 将节点作为工人添加到群体中
自动化脚本
因为让 droplet 闲置,等待客户端运行测试是不划算的,所以我们应该在测试运行之前自动供应 droplet,然后在运行之后取消供应。
让我们编写一个脚本:
- 用 Docker 机器提供液滴
- 配置 Docker 群组模式
- 向群集添加节点
- 部署硒网格
- 运行测试
- 旋转水滴
create.sh :
`#!/bin/bash
echo "Spinning up five droplets..."
for i in 1 2 3 4 5; do
docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
node-$i;
done
echo "Initializing Swarm mode..."
docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1)
docker-machine ssh node-1 -- docker node update --availability drain node-1
echo "Adding the nodes to the Swarm..."
TOKEN=`docker-machine ssh node-1 docker swarm join-token worker | grep token | awk '{ print $5 }'`
docker-machine ssh node-2 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
docker-machine ssh node-3 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
docker-machine ssh node-4 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
docker-machine ssh node-5 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
echo "Deploying Selenium Grid to http://$(docker-machine ip node-1):4444..."
eval $(docker-machine env node-1)
docker stack deploy --compose-file=docker-compose.yml selenium
docker service scale selenium_chrome=2 selenium_firefox=2`
destroy.sh :
`#!/bin/bash
docker-machine rm node-1 node-2 node-3 node-4 node-5 -y`
测试!
`$ sh create.sh
$ eval $(docker-machine env node-1)
$ NODE=$(docker service ps --format "{{.Node}}" selenium_hub)
$ export NODE_HUB_ADDRESS=$(docker-machine ip $NODE)
$ python parallel_test_run.py
$ sh destroy.sh`
结论
本文研究了如何使用 Docker 和 Docker Swarm 配置 Selenium Grid,以便在多台机器上分布测试。
完整的代码可以在selenium-grid-docker-swarm-test库中找到。
寻找一些挑战?
- 通过在不同的 Selenium 网格节点上并行运行所有测试方法,尝试进一步减少测试执行时间。
- 在 Travis 或 Jenkins(或其他 CI 工具)上配置测试的运行,使它们成为持续集成过程的一部分。
在 Django 中使用 AJAX
AJAX ,代表异步 JavaScript 和 XML,是一组在客户端使用的技术,用于异步发送和从服务器检索数据。
AJAX 允许我们对网页内容进行修改,而不需要用户重新加载整个页面。例如,这对于搜索栏中的自动完成或表单验证非常有用。如果使用得当,您可以提高网站的性能,减少服务器负载,并改善整体用户体验。
在本文中,我们将看看如何在 Django 中执行 GET、POST、PUT 和 DELETE AJAX 请求的例子。虽然重点将放在获取 API 上,我们也将展示 jQuery 的例子。
什么是 AJAX?
AJAX 是一种编程实践,它使用 XMLHttpRequest (XHR)对象与服务器异步通信并构建动态网页。尽管 AJAX 和 XMLHttpRequest 经常互换使用,但它们是不同的事物。
为了向 web 服务器发送数据和从 web 服务器接收数据,AJAX 使用以下步骤:
- 创建一个 XMLHttpRequest 对象。
- 使用 XMLHttpRequest 对象在客户端和服务器之间异步交换数据。
- 使用 JavaScript 和 DOM 来处理数据。
通过使用 ajax 方法,AJAX 可以与 jQuery 一起使用,但是本机 Fetch API 要好得多,因为它有一个干净的接口,不需要第三方库。
获取 API 的一般结构如下所示:
`fetch('http://some_url.com') .then(response => response.json()) // converts the response to JSON .then(data => { console.log(data); // do something (like update the DOM with the data) });`
参考 MDN 文档中的使用 Fetch 和windoworworkerglobalscope . Fetch()获取更多示例以及
fetch方法可用的完整选项。
什么时候应该使用 AJAX?
同样,AJAX 有助于提高站点的性能,同时降低服务器负载,改善整体用户体验。也就是说,它增加了应用程序的复杂性。正因为如此,除非你使用的是一个单页面应用(SPA)——比如 React、Angular 或 Vue——你应该只在绝对必要的时候使用 AJAX。
您可能会考虑使用 AJAX 的一些例子:
- 搜索自动完成
- 表单验证
- 表格排序和过滤
- 验证码
- 调查和民意测验
一般来说,如果内容需要根据用户交互进行大量更新,您可能希望使用 AJAX 来管理 web 页面的更新部分,而不是通过页面刷新来管理整个页面。
CRUD 资源
本文中的例子可以应用于任何 CRUD 资源。示例 Django 项目使用 todos 作为它的资源:
| 方法 | 统一资源定位器 | 描述 |
|---|---|---|
| 得到 | /todos/ |
返回所有待办事项 |
| 邮政 | /todos/ |
添加待办事项 |
| 放 | /todos/<todo-id>/ |
更新待办事项 |
| 删除 | /todos/<todo-id>/ |
删除待办事项 |
示例项目可以在 GitHub 上找到:
- 获取版本:https://github.com/testdrivenio/django-ajax-xhr
- jQuery 版本:https://github.com/testdrivenio/django-ajax-xhr/tree/jquery
获取请求
让我们从简单的获取数据的 GET 请求开始。
获取 API
示例:
`fetch(url, { method: "GET", headers: { "X-Requested-With": "XMLHttpRequest", } }) .then(response => response.json()) .then(data => { console.log(data); });`
唯一必需的参数是您希望从中获取数据的资源的 URL。如果 URL 需要关键字参数或查询字符串,可以使用 Django 的{% url %}标签。
你注意到标题了吗?这是通知服务器您正在发送一个 AJAX 请求所必需的。
fetch返回包含 HTTP 响应的承诺。我们使用.then方法首先从响应中提取 JSON 格式的数据(通过response.json()),然后访问返回的数据。在上面的例子中,我们只是在控制台中输出数据。
https://github . com/testdrivenio/django-Ajax-xhr/blob/main/static/main . js # L19-L39
jQuery AJAX
等效的 jQuery 代码:
`$.ajax({ url: url, type: "GET", dataType: "json", success: (data) => { console.log(data); }, error: (error) => { console.log(error); } });`
https://github . com/test rivieno/django-Ajax-xhr/blob/jquery/static/main . js # l19-l41
姜戈观点
在 Django 方面,虽然有几种方法可以处理视图中的 AJAX 请求,但最简单的方法是使用基于函数的视图:
`from django.http import HttpResponseBadRequest, JsonResponse
from todos.models import Todo
def todos(request):
# request.is_ajax() is deprecated since django 3.1
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
if is_ajax:
if request.method == 'GET':
todos = list(Todo.objects.all().values())
return JsonResponse({'context': todos})
return JsonResponse({'status': 'Invalid request'}, status=400)
else:
return HttpResponseBadRequest('Invalid request')`
在本例中,我们的资源是 todos。因此,在从数据库获取 todos 之前,我们验证了我们正在处理一个 AJAX 请求,并且请求方法是 GET。如果两者都为真,我们序列化数据并使用JsonResponse类发送响应。由于QuerySet对象不是 JSON 可序列化的(在本例中是 todos),我们使用values方法将 QuerySet 作为一个字典返回,然后将其包装在list中。最终结果是一个字典列表。
https://github . com/testdrivenio/django-Ajax-xhr/blob/main/todos/views . py # L13-L28
发布请求
接下来,让我们看看如何处理 POST 请求。
获取 API
示例:
`fetch(url, { method: "POST", credentials: "same-origin", headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRFToken": getCookie("csrftoken"), }, body: JSON.stringify({payload: "data to send"}) }) .then(response => response.json()) .then(data => { console.log(data); });`
我们需要指定如何在请求中发送凭证。
在上面的代码中,我们使用了"same-origin"(默认值)的值来指示浏览器,如果所请求的 URL 与 fetch 调用在同一个源上,就发送凭证。
在前端和后端托管在不同服务器上的情况下,您必须将credentials设置为"include"(它总是随每个请求发送凭证),并在后端启用跨源资源共享。您可以使用 django-cors-headers 包将 cors 报头添加到 django 应用程序的响应中。
想进一步了解如何处理同一域和跨域的 AJAX 请求吗?查看 Django 基于会话的单页面应用认证文章。
这次我们在请求的body中向服务器发送数据。
注意X-CSRFToken标题。如果没有它,您将在终端中从服务器得到 403 禁止响应:
`Forbidden (CSRF token missing or incorrect.): /todos/`
这是因为在发布请求时必须包含 CSRF 令牌,以防止跨站请求伪造攻击。
我们可以通过将每个XMLHttpRequest上的X-CSRFToken头设置为 CSRF 令牌的值来包含 CSRF 令牌。
Django 文档为我们提供了一个很好的函数,允许我们获取令牌,从而简化了我们的生活:
`function getCookie(name) { let cookieValue = null; if (document.cookie && document.cookie !== "") { const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); // Does this cookie string begin with the name we want? if (cookie.substring(0, name.length + 1) === (name + "=")) { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } } } return cookieValue; }`
https://github . com/testdrivenio/django-Ajax-xhr/blob/main/static/main . js # L42-L56
jQuery AJAX
带有 jQuery 的 AJAX POST 请求与 GET 请求非常相似:
`$.ajax({ url: url, type: "POST", dataType: "json", data: JSON.stringify({payload: payload,}), headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRFToken": getCookie("csrftoken"), // don't forget to include the 'getCookie' function }, success: (data) => { console.log(data); }, error: (error) => { console.log(error); } });`
https://github . com/test rivieno/django-Ajax-xhr/blob/jquery/static/main . js # l44-l61
姜戈观点
在服务器端,视图需要从请求中获取 JSON 格式的数据,所以您需要使用json模块来加载它。
`import json
from django.http import HttpResponseBadRequest, JsonResponse
from todos.models import Todo
def todos(request):
# request.is_ajax() is deprecated since django 3.1
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
if is_ajax:
if request.method == 'POST':
data = json.load(request)
todo = data.get('payload')
Todo.objects.create(task=todo['task'], completed=todo['completed'])
return JsonResponse({'status': 'Todo added!'})
return JsonResponse({'status': 'Invalid request'}, status=400)
else:
return HttpResponseBadRequest('Invalid request')`
在验证我们正在处理一个 AJAX 请求并且请求方法是 POST 之后,我们反序列化请求对象并提取有效负载对象。然后,我们创建了一个新的 todo 并发回了适当的响应。
https://github . com/testdrivenio/django-Ajax-xhr/blob/main/todos/views . py # L13-L28
上传请求
获取 API
示例:
`fetch(url, { method: "PUT", credentials: "same-origin", headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRFToken": getCookie("csrftoken"), // don't forget to include the 'getCookie' function }, body: JSON.stringify({payload: "data to send"}) }) .then(response => response.json()) .then(data => { console.log(data); });`
这应该类似于 POST 请求。唯一的区别是 URL 的形状:
- 后-
/todos/ - 放-
/todos/<todo-id>/
https://github . com/testdrivenio/django-Ajax-xhr/blob/main/static/main . js # L59-L73
jQuery AJAX
等效 jQuery:
`$.ajax({ url: url, type: "PUT", dataType: "json", data: JSON.stringify({payload: payload,}), headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRFToken": getCookie("csrftoken"), // don't forget to include the 'getCookie' function }, success: (data) => { console.log(data); }, error: (error) => { console.log(error); } });`
https://github . com/test rivieno/django-Ajax-xhr/blob/jquery/static/main . js # l64-l81
姜戈观点
示例:
`import json
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404
from todos.models import Todo
def todo(request, todoId):
# request.is_ajax() is deprecated since django 3.1
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
if is_ajax:
todo = get_object_or_404(Todo, id=todoId)
if request.method == 'PUT':
data = json.load(request)
updated_values = data.get('payload')
todo.task = updated_values['task']
todo.completed = updated_values['completed']
todo.save()
return JsonResponse({'status': 'Todo updated!'})
return JsonResponse({'status': 'Invalid request'}, status=400)
else:
return HttpResponseBadRequest('Invalid request')`
https://github . com/testdrivenio/django-Ajax-xhr/blob/main/todos/views . py # L31-L53
删除请求
获取 API
示例:
`fetch(url, { method: "DELETE", credentials: "same-origin", headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRFToken": getCookie("csrftoken"), // don't forget to include the 'getCookie' function } }) .then(response => response.json()) .then(data => { console.log(data); });`
https://github . com/testdrivenio/django-Ajax-xhr/blob/main/static/main . js # L76-L89
jQuery AJAX
jQuery 代码:
`$.ajax({ url: url, type: "DELETE", dataType: "json", headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRFToken": getCookie("csrftoken"), }, success: (data) => { console.log(data); }, error: (error) => { console.log(error); } });`
https://github . com/test rivieno/django-Ajax-xhr/blob/jquery/static/main . js # l84-l100
姜戈观点
查看:
`from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404
from todos.models import Todo
def todo(request, todoId):
# request.is_ajax() is deprecated since django 3.1
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
if is_ajax:
todo = get_object_or_404(Todo, id=todoId)
if request.method == 'DELETE':
todo.delete()
return JsonResponse({'status': 'Todo deleted!'})
return JsonResponse({'status': 'Invalid request'}, status=400)
else:
return HttpResponseBadRequest('Invalid request')`
https://github . com/testdrivenio/django-Ajax-xhr/blob/main/todos/views . py # L31-L53
摘要
AJAX 允许我们执行异步请求来更改页面的某些部分,而不必重新加载整个页面。
在本文中,您看到了如何使用 Fetch API 和 jQuery 在 Django 中执行 GET、POST、PUT 和 DELETE AJAX 请求的详细示例。
示例项目可以在 GitHub 上找到:
- 获取版本:https://github.com/testdrivenio/django-ajax-xhr
- jQuery 版本:https://github.com/testdrivenio/django-ajax-xhr/tree/jquery
Django 和 Celery 的异步任务
如果长时间运行的流程是应用程序工作流的一部分,而不是阻塞响应,您应该在后台处理它,在正常的请求/响应流之外。
也许您的 web 应用程序要求用户在注册时提交一个缩略图(可能需要重新调整大小)并确认他们的电子邮件。如果您的应用程序处理了图像并直接在请求处理程序中发送了确认电子邮件,那么最终用户将不得不在页面加载或更新之前不必要地等待他们完成处理。相反,您会希望将这些进程传递给任务队列,并让一个单独的工作进程来处理它,这样您就可以立即将响应发送回客户端。最终用户可以在处理过程中在客户端做其他事情。您的应用程序还可以自由地响应来自其他用户和客户端的请求。
为了实现这一点,我们将带您完成设置和配置 Celery 和 Redis 的过程,以便在 Django 应用程序中处理长时间运行的流程。我们还将使用 Docker 和 Docker Compose 将所有内容联系在一起。最后,我们将看看如何用单元测试和集成测试来测试 Celery 任务。
姜戈+芹菜系列:
目标
完成本文后,您将能够:
- 将芹菜集成到 Django 应用程序中并创建任务
- 集装箱化姜戈、Celery,并与码头工重归于好
- 使用单独的工作进程在后台运行进程
- 将芹菜日志保存到文件中
- 设置 Flower 来监控和管理芹菜作业和工人
- 用单元测试和集成测试来测试芹菜任务
后台任务
同样,为了改善用户体验,长时间运行的流程应该在正常的 HTTP 请求/响应流程之外,在后台进程中运行。
示例:
- 运行机器学习模型
- 发送确认电子邮件
- 刮擦和爬行
- 分析数据
- 处理图像
- 生成报告
当你构建一个应用程序时,试着区分应该在请求/响应生命周期中运行的任务(比如 CRUD 操作)和应该在后台运行的任务。
工作流程
我们的目标是开发一个 Django 应用程序,它与 Celery 一起处理正常请求/响应周期之外的长时间运行的流程。
- 最终用户通过向服务器端发送 POST 请求开始一项新任务。
- 在视图中,一个任务被添加到队列中,任务 id 被发送回客户端。
- 使用 AJAX,当任务本身在后台运行时,客户机继续轮询服务器以检查任务的状态。

项目设置
从 django-celery repo 中克隆出基础项目,然后将 v1 标签签出到主分支:
`$ git clone https://github.com/testdrivenio/django-celery --branch v1 --single-branch
$ cd django-celery
$ git checkout v1 -b master`
由于我们总共需要管理三个进程(Django、Redis、worker),我们将使用 Docker 来简化我们的工作流,方法是将它们连接起来,以便它们都可以通过一个命令从一个终端窗口运行。
从项目根目录,创建映像并启动 Docker 容器:
`$ docker-compose up -d --build`
构建完成后,导航到 http://localhost:1337 :

确保测试也通过:
`$ docker-compose exec web python -m pytest
=============================== test session starts ===============================
platform linux -- Python 3.11.1, pytest-7.2.0, pluggy-1.0.0
django: settings: core.settings (from ini)
rootdir: /usr/src/app, configfile: pytest.ini
plugins: django-4.5.2
collected 1 item
tests/test_tasks.py . [100%]
================================ 1 passed in 0.17s ================================`
在继续之前,快速浏览一下项目结构:
`├── .gitignore
├── LICENSE
├── README.md
├── docker-compose.yml
└── project
├── Dockerfile
├── core
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── entrypoint.sh
├── manage.py
├── pytest.ini
├── requirements.txt
├── static
│ ├── bulma.min.css
│ ├── jquery-3.4.1.min.js
│ ├── main.css
│ └── main.js
├── tasks
│ ├── __init__.py
│ ├── apps.py
│ ├── migrations
│ │ └── __init__.py
│ ├── templates
│ │ └── home.html
│ └── views.py
└── tests
├── __init__.py
└── test_tasks.py`
想学习如何构建这个项目吗?查看关于 Django 与 Postgres、Gunicorn 和 Nginx 的文章。
触发任务
在 project/static/main.js 中设置了一个事件处理程序来监听按钮点击。点击时,一个 AJAX POST 请求被发送到具有适当任务类型的服务器:1、2或3。
`$('.button').on('click', function() { $.ajax({ url: '/tasks/', data: { type: $(this).data('type') }, method: 'POST', }) .done((res) => { getStatus(res.task_id); }) .fail((err) => { console.log(err); }); });`
在服务器端,已经配置了一个视图来处理 project/tasks/views.py 中的请求:
`@csrf_exempt
def run_task(request):
if request.POST:
task_type = request.POST.get("type")
return JsonResponse({"task_type": task_type}, status=202)`
现在有趣的部分来了:给芹菜布线!
芹菜装置
首先将 Celery 和 Redis 添加到 project/requirements.txt 文件中:
`Django==4.1.4
pytest==7.2.0
pytest-django==4.5.2
celery==5.2.7
redis==4.4.0`
Celery 使用消息代理 - RabbitMQ 、 Redis 或 AWS 简单队列服务(SQS) -来促进 Celery worker 和 web 应用程序之间的通信。消息被添加到代理中,然后由工作人员进行处理。一旦完成,结果被添加到后端。
Redis 将被用作代理和后端。将 Redis 和芹菜工人添加到 docker-compose.yml 文件中,如下所示:
`version: '3.8' services: web: build: ./project command: python manage.py runserver 0.0.0.0:8000 volumes: - ./project:/usr/src/app/ ports: - 1337:8000 environment: - DEBUG=1 - SECRET_KEY=dbaa1_i7%*[[email protected]](/cdn-cgi/l/email-protection)(-a_r([[email protected]](/cdn-cgi/l/email-protection)%m - DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] - CELERY_BROKER=redis://redis:6379/0 - CELERY_BACKEND=redis://redis:6379/0 depends_on: - redis celery: build: ./project command: celery --app=core worker --loglevel=info volumes: - ./project:/usr/src/app environment: - DEBUG=1 - SECRET_KEY=dbaa1_i7%*[[email protected]](/cdn-cgi/l/email-protection)(-a_r([[email protected]](/cdn-cgi/l/email-protection)%m - DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] - CELERY_BROKER=redis://redis:6379/0 - CELERY_BACKEND=redis://redis:6379/0 depends_on: - web - redis redis: image: redis:7-alpine`
请注意celery --app=core worker --loglevel=info:
在项目的设置模块中,在底部添加以下内容,告诉 Celery 使用 Redis 作为代理和后端:
`CELERY_BROKER_URL = os.environ.get("CELERY_BROKER", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_BROKER", "redis://redis:6379/0")`
接下来,在“项目/任务”中创建一个名为 sample_tasks.py 的新文件:
`# project/tasks/sample_tasks.py
import time
from celery import shared_task
@shared_task
def create_task(task_type):
time.sleep(int(task_type) * 10)
return True`
这里,使用 shared_task 装饰器,我们定义了一个名为create_task的新芹菜任务函数。
请记住,任务本身将而不是从 Django 流程中执行;它将由芹菜工人来执行。
现在,向“项目/核心”添加一个 celery.py 文件:
`import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
app = Celery("core")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()`
这里发生了什么事?
- 首先,我们为环境变量
DJANGO_SETTINGS_MODULE设置一个默认值,这样 Celery 将知道如何找到 Django 项目。 - 接下来,我们创建了一个名为
core的新 Celery 实例,并将值赋给一个名为app的变量。 - 然后我们从
django.conf的 settings 对象中加载 celery 配置值。我们使用了namespace="CELERY"来防止与其他 Django 场景的冲突。换句话说,芹菜的所有配置设置都必须以CELERY_为前缀。 - 最后,
app.autodiscover_tasks()告诉 Celery 从settings.INSTALLED_APPS中定义的应用程序中寻找 Celery 任务。
更新项目/核心/init。py 以便在 Django 启动时自动导入 Celery 应用程序:
`from .celery import app as celery_app
__all__ = ("celery_app",)`
触发任务
更新视图以启动任务,并使用 id:
`# project/tasks/views.py
@csrf_exempt
def run_task(request):
if request.POST:
task_type = request.POST.get("type")
task = create_task.delay(int(task_type))
return JsonResponse({"task_id": task.id}, status=202)`
不要忘记导入任务:
`from tasks.sample_tasks import create_task`
构建映像并旋转新容器:
`$ docker-compose up -d --build`
要触发新任务,请运行:
`$ curl -F type=0 http://localhost:1337/tasks/`
您应该会看到类似这样的内容:
`{
"task_id": "6f025ed9-09be-4cbb-be10-1dce919797de"
}`
任务状态
回到客户端的事件处理程序:
`// project/static/main.js $('.button').on('click', function() { $.ajax({ url: '/tasks/', data: { type: $(this).data('type') }, method: 'POST', }) .done((res) => { getStatus(res.task_id); }) .fail((err) => { console.log(err); }); });`
当响应从最初的 AJAX 请求返回时,我们继续每秒调用带有任务 id 的getStatus():
`function getStatus(taskID) { $.ajax({ url: `/tasks/${taskID}/`, method: 'GET' }) .done((res) => { const html = `
<tr>
<td>${res.task_id}</td>
<td>${res.task_status}</td>
<td>${res.task_result}</td>
</tr>` $('#tasks').prepend(html); const taskStatus = res.task_status; if (taskStatus === 'SUCCESS' || taskStatus === 'FAILURE') return false; setTimeout(function() { getStatus(res.task_id); }, 1000); }) .fail((err) => { console.log(err) }); }`
如果响应成功,一个新行被添加到 DOM 上的表中。
更新get_status视图以返回状态:
`# project/tasks/views.py
@csrf_exempt
def get_status(request, task_id):
task_result = AsyncResult(task_id)
result = {
"task_id": task_id,
"task_status": task_result.status,
"task_result": task_result.result
}
return JsonResponse(result, status=200)`
导入异步结果:
`from celery.result import AsyncResult`
更新容器:
`$ docker-compose up -d --build`
触发新任务:
`$ curl -F type=1 http://localhost:1337/tasks/`
然后,从响应中获取task_id并调用更新的端点来查看状态:
`$ curl http://localhost:1337/tasks/25278457-0957-4b0b-b1da-2600525f812f/
{
"task_id": "25278457-0957-4b0b-b1da-2600525f812f",
"task_status": "SUCCESS",
"task_result": true
}`
也在浏览器中测试一下:

芹菜原木
更新 docker-compose.yml 中的celery服务,以便将芹菜日志转储到一个日志文件:
`celery: build: ./project command: celery --app=core worker --loglevel=info --logfile=logs/celery.log volumes: - ./project:/usr/src/app environment: - DEBUG=1 - SECRET_KEY=dbaa1_i7%*[[email protected]](/cdn-cgi/l/email-protection)(-a_r([[email protected]](/cdn-cgi/l/email-protection)%m - DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] - CELERY_BROKER=redis://redis:6379/0 - CELERY_BACKEND=redis://redis:6379/0 depends_on: - web - redis`
向“项目”添加一个名为“日志”的新目录。然后,将名为 celery.log 的新文件添加到新创建的目录中。
更新:
`$ docker-compose up -d --build`
由于我们设置了一个卷,您应该看到日志文件在本地被填满:
`[2022-12-15 18:15:20,338: INFO/MainProcess] Connected to redis://redis:6379/0
[2022-12-15 18:15:21,328: INFO/MainProcess] mingle: searching for neighbors
[2022-12-15 18:15:23,342: INFO/MainProcess] mingle: all alone
[2022-12-15 18:15:24,214: WARNING/MainProcess] /usr/local/lib/python3.11/site-packages/celery/fixups/django.py:203: UserWarning: Using settings.DEBUG leads to a memory
leak, never use this setting in production environments!
warnings.warn('''Using settings.DEBUG leads to a memory
[2022-12-15 18:15:25,080: INFO/MainProcess] [[email protected]](/cdn-cgi/l/email-protection) ready.
[2022-12-15 18:15:40,132: INFO/MainProcess] Task tasks.sample_tasks.create_task[c4589a0d-f718-4d75-a673-fe3626827385] received
[2022-12-15 18:15:40,153: INFO/ForkPoolWorker-1] Task tasks.sample_tasks.create_task[c4589a0d-f718-4d75-a673-fe3626827385] succeeded in 0.016174572000181797s: True`
花卉仪表板
Flower 是一个轻量级的、实时的、基于网络的芹菜监控工具。您可以监控当前正在运行的任务,增加或减少工作池,查看图表和一些统计数据,等等。
添加到 requirements.txt :
`Django==4.1.4
pytest==7.2.0
pytest-django==4.5.2
celery==5.2.7
redis==4.4.0
flower==1.2.0`
然后,向 docker-compose.yml 添加一个新服务:
`dashboard: build: ./project command: celery flower -A core --port=5555 --broker=redis://redis:6379/0 ports: - 5555:5555 environment: - DEBUG=1 - SECRET_KEY=dbaa1_i7%*[[email protected]](/cdn-cgi/l/email-protection)(-a_r([[email protected]](/cdn-cgi/l/email-protection)%m - DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] - CELERY_BROKER=redis://redis:6379/0 - CELERY_BACKEND=redis://redis:6379/0 depends_on: - web - redis - celery`
测试一下:
`$ docker-compose up -d --build`
导航到 http://localhost:5555 查看仪表板。您应该看到一名员工准备就绪:

开始几项任务来全面测试仪表板:

试着增加几个工人,看看会有什么影响:
`$ docker-compose up -d --build --scale celery=3`
试验
让我们从最基本的测试开始:
`def test_task():
assert sample_tasks.create_task.run(1)
assert sample_tasks.create_task.run(2)
assert sample_tasks.create_task.run(3)`
将上述测试用例添加到project/tests/test _ tasks . py中,然后添加以下导入:
`from tasks import sample_tasks`
单独运行测试:
`$ docker-compose exec web python -m pytest -k "test_task and not test_home"`
运行应该需要大约一分钟:
`=============================== test session starts ===============================
platform linux -- Python 3.11.1, pytest-7.2.0, pluggy-1.0.0
django: settings: core.settings (from ini)
rootdir: /usr/src/app, configfile: pytest.ini
plugins: django-4.5.2
collected 2 items / 1 deselected / 1 selected
tests/test_tasks.py . [100%]
=================== 1 passed, 1 deselected in 60.69s (0:01:00) ====================`
值得注意的是,在上面的断言中,我们使用了.run方法(而不是.delay)来直接运行任务,而没有芹菜工人。
想要模仿.run方法来加快速度吗?
`@patch("tasks.sample_tasks.create_task.run")
def test_mock_task(mock_run):
assert sample_tasks.create_task.run(1)
sample_tasks.create_task.run.assert_called_once_with(1)
assert sample_tasks.create_task.run(2)
assert sample_tasks.create_task.run.call_count == 2
assert sample_tasks.create_task.run(3)
assert sample_tasks.create_task.run.call_count == 3`
导入:
`from unittest.mock import patch`
测试:
`$ docker-compose exec web python -m pytest -k "test_mock_task"
=============================== test session starts ===============================
platform linux -- Python 3.11.1, pytest-7.2.0, pluggy-1.0.0
django: settings: core.settings (from ini)
rootdir: /usr/src/app, configfile: pytest.ini
plugins: django-4.5.2
collected 3 items / 2 deselected / 1 selected
tests/test_tasks.py . [100%]
========================= 1 passed, 2 deselected in 0.64s =========================`
快多了!
全面整合测试怎么样?
`def test_task_status(client):
response = client.post(reverse("run_task"), {"type": 0})
content = json.loads(response.content)
task_id = content["task_id"]
assert response.status_code == 202
assert task_id
response = client.get(reverse("get_status", args=[task_id]))
content = json.loads(response.content)
assert content == {"task_id": task_id, "task_status": "PENDING", "task_result": None}
assert response.status_code == 200
while content["task_status"] == "PENDING":
response = client.get(reverse("get_status", args=[task_id]))
content = json.loads(response.content)
assert content == {"task_id": task_id, "task_status": "SUCCESS", "task_result": True}`
请记住,这个测试使用开发中使用的相同的代理和后端。您可能想要实例化一个新的 Celery 应用程序来进行测试:
`app = celery.Celery('tests', broker=CELERY_TEST_BROKER, backend=CELERY_TEST_BACKEND)`
添加导入:
确保测试通过。
结论
这是关于如何在 Django 应用程序中配置 Celery 来运行长时间运行的任务的基本指南。您应该让队列处理任何可能阻塞或减慢面向用户的代码的进程。
Celery 还可以用于执行可重复的任务,分解复杂的资源密集型任务,以便将计算工作量分布到多个机器上,从而减少(1)完成时间和(2)处理客户端请求的机器上的负载。
最后,如果你想知道如何使用 WebSockets(通过 Django 通道)来检查芹菜任务的状态,而不是使用 AJAX 轮询,请查看芹菜和 Django 课程的权威指南。
从回购中抓取代码。
姜戈+芹菜系列:
姜戈和迂腐
在本文中,我们将看看如何使用 Pydantic-Django 和 Django Ninja 包将 Pydantic 与 Django 应用程序集成。
Pydantic
Pydantic 是一个基于 Python 类型提示的用于数据验证和设置管理的 Python 包。它在运行时强制执行类型提示,提供用户友好的错误,允许自定义数据类型,并且与许多流行的 ide 配合良好。它非常快,也很容易使用!
让我们看一个例子:
`from pydantic import BaseModel
class Song(BaseModel):
id: int
name: str`
这里,我们定义了一个具有两个属性的Song模型,这两个属性都是必需的:
id是一个整数name是字符串
然后在初始化时进行验证:
`>>> song = Song(id=1, name='I can almost see you')
>> song.name
'I can almost see you'
>> Song(id='1')
pydantic.error_wrappers.ValidationError: 1 validation error for Song
name
field required (type=value_error.missing)
>>> Song(id='foo', name='I can almost see you')
pydantic.error_wrappers.ValidationError: 1 validation error for Song
id
value is not a valid integer (type=type_error.integer)`
要了解更多关于 Pydantic 的信息,请务必阅读官方文档中的概述页面。
Pydantic 和 Django
当与 Django 结合使用时,我们可以使用 Pydantic 来确保在我们的应用程序中只使用与定义的模式相匹配的数据。因此,我们将定义验证请求和响应的模式,当验证错误发生时,我们将简单地返回一条用户友好的错误消息。
虽然您可以在没有任何第三方包的情况下将 Pydantic 与 Django 集成在一起,但是我们将利用以下包来简化这个过程:
- Pydantic-Django -为验证模型数据添加 Pydantic 支持
- Django Ninja——除了 Pydantic,这个包还提供了许多额外的功能,比如自动生成的 API 文档(通过 OpenAPI 和 JSON 模式)、序列化和 API 版本控制
《姜戈忍者》受到了 FastAPI 的极大启发。如果您喜欢 FastAPI,但仍然想利用 Django 提供的大部分功能,请查看一下。
Pydantic-Django
现在您已经对 Pydantic 有了一个基本的概念,让我们看一个实际的例子。我们将使用 Django 和 Pydantic-Django 创建一个简单的 RESTful API,它允许我们获取、列出和创建文章。
基本设置
首先建立一个新的 Django 项目:
`$ mkdir django-with-pydantic && cd django-with-pydantic
$ python3.9 -m venv env
$ source env/bin/activate
(env)$ pip install django==3.1.5
(env)$ django-admin.py startproject core .`
之后,创建一个名为blog的新应用:
`(env)$ python manage.py startapp blog`
在INSTALLED_APPS下的 core/settings.py 中注册 app:
`# core/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'blog.apps.BlogConfig', # new
]`
创建数据库模型
接下来,让我们创建一个Article模型。
将以下内容添加到 blog/models.py :
`# blog/models.py
from django.contrib.auth.models import User
from django.db import models
class Article(models.Model):
author = models.ForeignKey(to=User, on_delete=models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
title = models.CharField(max_length=512, unique=True)
content = models.TextField()
def __str__(self):
return f'{self.author.username}: {self.title}'`
创建然后应用迁移:
`(env)$ python manage.py makemigrations
(env)$ python manage.py migrate`
在 blog/admin.py 中注册模型,这样就可以从 Django 管理面板访问它:
`# blog/admin.py
from django.contrib import admin
from blog.models import Article
admin.site.register(Article)`
安装 Pydantic-Django 并创建模式
安装 Pydantic 和 Pydantic-Django:
`(env)$ pip install pydantic==1.7.3 pydantic-django==0.0.7`
现在,我们可以定义一个模式,它将用于-
- 验证来自请求负载的字段,然后使用数据创建新的模型对象
- 检索和验证响应对象的模型对象
创建一个名为 blog/schemas.py 的新文件:
`# blog/schemas.py
from pydantic_django import ModelSchema
from blog.models import Article
class ArticleSchema(ModelSchema):
class Config:
model = Article`
这是最简单的可能模式,它来自我们的模型。
Django 模型需要在模式之前加载,因此模式必须位于一个单独的文件中,以避免模型加载错误。
使用模式,您还可以通过将exclude或include传递给Config来定义特定模型中应该包含和不应该包含哪些字段。例如,排除author:
`class ArticleSchema(ModelSchema):
class Config:
model = Article
exclude = ['author']
# or
class ArticleSchema(ModelSchema):
class Config:
model = Article
include = ['created', 'title', 'content']`
您还可以通过更改模式中的字段来使用模式覆盖 Django 模型属性。例如:
`class ArticleSchema(ModelSchema):
title: Optional[str]
class Config:
model = Article`
视图和 URL
接下来,让我们设置以下端点:
/blog/articles/create/创建新文章- 获取一篇文章
/blog/articles/列出所有文章
将以下视图添加到 blog/views.py :
`# blog/views.py
import json
from django.contrib.auth.models import User
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from blog.models import Article
from blog.schemas import ArticleSchema
@csrf_exempt # testing purposes; you should always pass your CSRF token with your POST requests (+ authentication)
@require_http_methods('POST')
def create_article(request):
try:
json_data = json.loads(request.body)
# fetch the user and pass it to schema
author = User.objects.get(id=json_data['author'])
schema = ArticleSchema.create(
author=author,
title=json_data['title'],
content=json_data['content']
)
return JsonResponse({
'article': schema.dict()
})
except User.DoesNotExist:
return JsonResponse({'detail': 'Cannot find a user with this id.'}, status=404)
def get_article(request, article_id):
try:
article = Article.objects.get(id=article_id)
schema = ArticleSchema.from_django(article)
return JsonResponse({
'article': schema.dict()
})
except Article.DoesNotExist:
return JsonResponse({'detail': 'Cannot find an article with this id.'}, status=404)
def get_all_articles(request):
articles = Article.objects.all()
data = []
for article in articles:
schema = ArticleSchema.from_django(article)
data.append(schema.dict())
return JsonResponse({
'articles': data
})`
注意我们使用模式的领域,ArticleSchema:
ArticleSchema.create()创建一个新的Article对象schema.dict()返回我们传递给JsonResponse的字段和值的字典ArticleSchema.from_django()从一个Article对象生成一个模式
记住:
create()和from_django()也将根据模式验证数据。
在“博客”中添加一个 urls.py 文件,并定义以下 URL:
`# blog/urls.py
from django.urls import path
from blog import views
urlpatterns = [
path('articles/create/', views.create_article),
path('articles/<str:article_id>/', views.get_article),
path('articles/', views.get_all_articles),
]`
现在,让我们将我们的应用程序 URL 注册到基础项目:
`# core/urls.py
from django.contrib import admin
from django.shortcuts import render
from django.urls import path, include # new import
urlpatterns = [
path('admin/', admin.site.urls),
path('blog/', include('blog.urls')), # new
]`
健全性检查
要进行测试,首先创建一个超级用户:
`(env)$ python manage.py createsuperuser`
然后,运行开发服务器:
`(env)$ python manage.py runserver`
在新的终端窗口中,用 cURL 添加新文章:
`$ curl --header "Content-Type: application/json" --request POST \
--data '{"author":"1","title":"Something Interesting", "content":"Really interesting."}' \
http://localhost:8000/blog/articles/create/`
您应该会看到类似这样的内容:
`{
"article": {
"id": 1,
"author": 1,
"created": "2021-02-01T20:01:35.904Z",
"title": "Something Interesting",
"content": "Really interesting."
}
}`
然后,您应该能够在http://127 . 0 . 0 . 1:8000/blog/articles/1/和http://127 . 0 . 0 . 1:8000/blog/articles/查看文章。
响应模式
想要从所有文章的回复中删除created字段吗?
向 blog/schemas.py 添加新模式:
`class ArticleResponseSchema(ModelSchema):
class Config:
model = Article
exclude = ['created']`
然后,更新视图:
`def get_all_articles(request):
articles = Article.objects.all()
data = []
for article in articles:
schema = ArticleResponseSchema.from_django(article)
data.append(schema.dict())
return JsonResponse({
'articles': data
})`
不要忘记在进口:
`from blog.schemas import ArticleSchema, ArticleResponseSchema`
在http://127 . 0 . 0 . 1:8000/blog/articles/测试一下。
姜戈忍者
Django Ninja 是一个使用 Django 和基于 Python 的类型提示构建 API 的工具。如上所述,它带有许多的强大功能。是“学的快,码的快,跑的快”。
基本设置
创建新的 Django 项目:
`$ mkdir django-with-ninja && cd django-with-ninja
$ python3.9 -m venv env
$ source env/bin/activate
(env)$ pip install django==3.1.5
(env)$ django-admin.py startproject core .`
创建一个名为blog的新应用:
`(env)$ python manage.py startapp blog`
在INSTALLED_APPS下的 core/settings.py 中注册 app:
`# core/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'blog.apps.BlogConfig', # new
]`
创建数据库模型
接下来,向 blog/models.py 添加一个Article模型:
`# blog/models.py
from django.contrib.auth.models import User
from django.db import models
class Article(models.Model):
author = models.ForeignKey(to=User, on_delete=models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
title = models.CharField(max_length=512, unique=True)
content = models.TextField()
def __str__(self):
return f'{self.author.username}: {self.title}'`
创建和应用迁移:
`(env)$ python manage.py makemigrations
(env)$ python manage.py migrate`
在 blog/admin.py 中注册模型:
`# blog/admin.py
from django.contrib import admin
from blog.models import Article
admin.site.register(Article)`
安装 Django Ninja 并创建模式
安装:
`(env)$ pip install django-ninja==0.10.1`
与 Pydantic-Django 一样,您需要创建模式来验证您的请求和响应。也就是说,从 Django 模型自动生成模式看起来会在某个时候到来。
有关自动模式生成支持的更多信息,请查看 Django 模型中的模式。
将以下内容添加到 blog/schemas.py :
`from datetime import datetime
from ninja import Schema
class UserSchema(Schema):
id: int
username: str
class ArticleIn(Schema):
author: int
title: str
content: str
class ArticleOut(Schema):
id: int
author: UserSchema
created: datetime
title: str
content: str`
这里,我们创建了三种不同的模式:
UserSchema验证 Django 用户模型的数据,并将数据转换成 Django 用户模型的数据ArticleIn验证和反序列化传递给创建文章的 API 的数据ArticleOut验证并序列化来自Article模型的数据
Django Ninja 有一个路由器的概念,用于将一个 API 拆分成多个模块。
创建一个 blog/api.py 文件:
`# blog/api.py
from typing import List
from django.contrib.auth.models import User
from django.shortcuts import get_object_or_404
from ninja import Router
from blog.models import Article
from blog.schemas import ArticleOut, ArticleIn
router = Router()
@router.post('/articles/create')
def create_article(request, payload: ArticleIn):
data = payload.dict()
try:
author = User.objects.get(id=data['author'])
del data['author']
article = Article.objects.create(author=author, **data)
return {
'detail': 'Article has been successfully created.',
'id': article.id,
}
except User.DoesNotExist:
return {'detail': 'The specific user cannot be found.'}
@router.get('/articles/{article_id}', response=ArticleOut)
def get_article(request, article_id: int):
article = get_object_or_404(Article, id=article_id)
return article
@router.get('/articles', response=List[ArticleOut])
def get_articles(request):
articles = Article.objects.all()
return articles`
这里,我们创建了三个函数作为我们的视图。Django Ninja 使用 HTTP 操作装饰器来定义 URL 结构、路径参数,以及可选的请求和响应模式。
注意事项:
get_article使用ArticleOut作为其响应模式。ArticleOut将自动用于验证和序列化模型中的数据。- 在
get_articles中,Django 查询集——例如articles = Article.objects.all()——将通过List[ArticleOut]进行正确验证。
注册 API 端点
我们要做的最后一件事是创建一个新的NinjaAPI实例,并在 core/urls.py 中注册我们的 API 路由器:
`# core/urls.py
from django.contrib import admin
from django.urls import path
from ninja import NinjaAPI
from blog.api import router as blog_router
api = NinjaAPI()
api.add_router('/blog/', blog_router)
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', api.urls),
]`
这样,Django Ninja 将自动创建以下端点:
/blog/articles/create/创建新文章- 获取一篇文章
/blog/articles/列出所有文章
健全性检查
创建超级用户,然后运行开发服务器:
`(env)$ python manage.py createsuperuser
(env)$ python manage.py runserver`
导航到http://localhost:8000/api/docs查看自动生成的交互式 API 文档:

在这里,您可以看到注册的端点并与之交互。
尝试添加新文章:

自己尝试其余的端点。
结论
在本文中,我们首先看了什么是 Pydantic,然后看了如何使用 Pydantic-Django 和 Django Ninja 将其与 Django 应用程序集成。
这两个包目前都在积极开发中。请记住,虽然这两个包都不成熟,但 Pydantic-Django 仍处于试验阶段(截至本文撰写之时),所以请谨慎使用。
您可以在 GitHub 上获得完整代码:


尼克·托马齐奇

浙公网安备 33010602011771号