TestDriven-io-博客中文翻译-三-

TestDriven.io 博客中文翻译(三)

原文:TestDriven.io Blog

协议:CC BY-NC-SA 4.0

初学者码头工人

原文:https://testdriven.io/blog/docker-for-beginners/

Docker 是开发者用来简化开发和发布应用程序的工具。

根据 Stack Overflow 的 2021 年开发者调查,它是最受欢迎的开发工具之一。

本文将带您了解 Docker 的基础知识,重点关注:

  1. 核心 Docker 概念和组件
  2. docker 文件是什么样子,它最常见的指令是做什么的
  3. 什么是图像和容器,它们是如何创建的,以及如何管理它们

这篇文章可以是只读的。但是,如果您想继续学习,我提供了一个基本的 HelloWorld 应用程序,您可以使用它自己尝试所有的命令。在阅读本文的过程中,您可以随意将其复制下来并运行命令。

虽然容器和图像命令独立于应用程序的语言,但 docker 文件中通常有特定于语言的结构。也就是说,尽管所提供的例子是基于 Python 的,但是您可以很容易地将新学到的知识应用到其他语言中。

目标

完成本文后,您应该能够:

  1. 解释 Docker 是什么以及它是如何工作的
  2. 描述并区分以下概念和组件:Docker 引擎、Docker 桌面、Docker 文件、Docker 映像和 Docker 容器
  3. 跟随使用 Docker 的更复杂的教程

容器和虚拟机

在进入 Docker 之前,理解容器和虚拟机之间的区别是很重要的。

容器和虚拟机是相似的,因为它们允许多个应用程序在同一服务器上运行,具有不同的软件需求——例如,不同的 Python 版本、不同的库等。它们的主要区别在于操作系统。虽然 containers 使用主机的操作系统,但是每个虚拟机在主机操作系统之上都有自己的客户操作系统。

在这张现在几乎很出名的图片中,您可以看到 Docker 与虚拟机的对比:

Containers and Virtual Machines

因此,如果您有一个需要在不同操作系统上运行的应用程序,那么虚拟机是一个不错的选择。但是如果这不是一个要求,Docker 比虚拟机有多种优势:

  1. 重量较轻
  2. 构建速度更快
  3. 可以很容易地跨不同平台移植
  4. 资源密集度较低
  5. 放大和复制更容易

所有这些优势都是由于 Docker 容器不需要自己的操作系统。

码头工人

码头引擎

当人们提到 Docker 时,他们通常指的是 Docker 引擎

Docker Engine 是用于构建、管理和运行容器化应用程序的底层开源容器化技术。它是一个客户端-服务器应用程序,具有以下组件:

  1. Docker 守护进程(称为Docker)是一个在后台运行的服务,它监听 Docker 引擎 API 请求并管理 Docker 对象,如图像和容器。
  2. Docker 引擎 API 是一个 RESTful API,用于与 Docker 守护进程交互。
  3. Docker 客户端(称为 docker )是用于与 Docker 守护进程交互的命令行接口。因此,当您使用类似于docker build的命令时,您正在使用 Docker 客户端,它反过来利用 Docker 引擎 API 与 Docker 守护进程进行通信。

Docker 桌面

这些天,当你试图安装 Docker 时,你会遇到 Docker 桌面。虽然 Docker Engine 包含在 Docker Desktop 中,但重要的是要理解 Docker Desktop 与 Docker Engine 是而非相同的。Docker 桌面是 Docker 容器的集成开发环境。它使您的操作系统配置为使用 Docker 变得更加容易。

如果您还没有安装 Docker Desktop,请继续安装:

Docker 概念

Docker 的核心有三个核心概念:

  1. docker file——一个文本文件,作为你的容器的蓝图。在其中,您定义了 Docker 用来构建映像的指令列表。
  2. Image-docker file 的只读实现。它由组成——每一层对应 docker 文件中的一行指令。
  3. 运行 Docker 映像会产生一个容器,它是应用程序的受控环境。如果我们把它与面向对象编程相提并论,容器就是 Docker 映像的一个实例。

Dockerfile vs Docker Image vs Docker Container

Docker 文件用于创建 Docker 图像,然后用于创建(多个)Docker 容器。

在接下来的几节中,我们将详细研究这三个核心概念。

强烈建议在继续之前通读 Docker 概述

Dockerfile

同样, Dockerfile 是一个文本文件,包含 Docker 如何构建映像的指令。默认情况下,Dockerfile 没有扩展名,但是如果需要多个扩展名,您可以添加一个——例如, Dockerfile.prod

下面是一个非常简单的 Dockerfile 文件的例子:

`FROM  python:3.10-slim-buster

WORKDIR  /usr/src/app

ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

RUN  pip install --upgrade pip
COPY  ./requirements.txt .
RUN  pip install -r requirements.txt

COPY  . .

CMD  uvicorn main:app --reload --host 0.0.0.0 --port 8000` 

这个例子非常简单,所以你很容易理解。它并不遵循最佳实践,但是你应该在你的 does 文件中尝试遵循它们。

Dockerfile 本质上是一个由以下形式的命令组成的列表:INSTRUCTION arguments。大多数最广泛使用的命令可以在上面的 Dockerfile 文件中看到。让我们详细看一下每一个...

所有 docker 文件都包含一个父映像/基础映像,新映像将在此基础上构建。您使用来自指令的来定义父图像:

`FROM  python:3.10-slim-buster` 

一个有效的 Dockerfile 总是包含一个FROM指令。

虽然映像术语有时会互换使用,但它们之间还是有区别的。父映像有自己的父映像。同时,基础映像没有父映像;从FROM scratch开始。

Alpine image 是基本图像, Python:alpine 是父图像(其父(基本)图像实际上是 Alpine 图像)。

您可以在自己的上创建一个基础映像,但是您需要自己的映像的可能性很小。

您可以在 Docker Hub 上找到父映像,这是 Docker 的 Docker 映像库/注册表。你可以把它想象成 Docker 图片的 GitHub。你可能会想要使用官方图片或来自可靠来源的验证图片,因为它们更可能符合 Docker 最佳实践并包含最新的安全修复。

在上面的例子中,我们使用了官方的 Python 父映像,具体来说就是python:3.10-slim-buster

关于python:3.10-slim-buster的说明:

  • 这个数字告诉你这个映像使用了哪个版本的技术(例如,python:3.11.0a5映像使用 Python 版本 3.11.0a5 ,而node:18.9.0使用节点版本 18.9.0 )。你可能想要避免任何带有rc的版本(例如python:3.11.0rc2,因为 rc 意味着发布候选
  • 巴斯特牛眼alpine 这样的名字告诉你这个映像使用了哪些 OS 映像(巴斯特牛眼指的是 Debian 版本,而 alpine 是一个轻量级 Linux 发行版)。此外,还有像slimslim-buster这样的标签,它们使用完整图像的浅色版本。

查看使用小型 Docker 基本映像了解使用哪种基本映像的最佳实践。

奔跑

RUN 指令在当前图像之上的新层中执行任何命令,并提交结果。

示例:

`RUN  mkdir /home/app/web

RUN  python manage.py collectstatic --noinput` 

包封/包围(动词 envelop 的简写)

您使用 ENV 指令来设置一个环境变量。该变量将在所有后续指令中可用。

示例:

`ENV  TZ=UTC

ENV  HOME=/home/app` 

CMD 和入口点

有些 Docker 指令非常相似,很难理解为什么需要这两个命令。这些“夫妇”中的一对是 CMDENTRYPOINT

首先,对于相似之处:

  • CMDENTRYPOINT都指定了运行容器时将执行的命令/可执行文件。与立即执行命令的RUN不同(结果用于图像层),编译中的CMD / ENTRYPOINT命令指定了仅在容器启动时使用的命令。
  • 一个 docker 文件中只能有一条CMD / ENTRYPOINT指令,但它可以指向一个更复杂的可执行文件。

这些指令之间实际上只有一个区别:

  • 可以从 Docker CLI 轻松覆盖。

如果您想在启动容器时根据需要灵活地运行不同的可执行文件,那么您应该使用CMD。如果您想清楚地传达命令不应该被覆盖并防止意外更改它,请使用ENTRYPOINT

你可能会用其中的一个。如果你不使用它,容器会立即停止,因为它没有存在的理由(如果你也在使用 Docker Compose 的话例外)。

您也可以在同一个 docker 文件中同时使用CMDENTRYPOINT,在这种情况下,CMD将作为ENTRYPOINT的默认参数。

一个 docker 文件中只能有一个CMD指令,但是它可以指向一个更复杂的可执行文件。如果有多个CMD,只有最后一个CMD会生效。这同样适用于ENTRYPOINT指令。

CMD指令使用示例:

`CMD  gunicorn core.wsgi:application --bind 0.0.0.0:$PORT` 

您很有可能将ENTRYPOINT参数视为可执行文件,因为应该执行的命令通常不止一行。

ENTRYPOINT作为可执行文件使用的例子:

`ENTRYPOINT ["./entrypoint.sh"]` 

这就是 entrypoint.sh 文件的样子:

`#!/bin/sh

python manage.py migrate
python manage.py collectstatic --noinput` 

理解CMDENTRYPOINT的区别很重要。更多信息,请查看了解 ENTRYPOINT 和 CMD 的区别以及官方文件

添加并复制

另一对相似的是添加复制

这两条指令都将新文件或目录从路径复制到位于路径的镜像文件系统:

`ADD  <src> <dest>
COPY  <src> <dest>` 

此外,ADD可以从远程文件 URL(例如,它允许直接向映像添加 git 存储库)和直接从压缩的归档文件中复制(ADD会自动将内容解压缩到给定的位置)。

你应该更喜欢 COPY 而不是 ADD ,除非你特别需要 ADD 的两个附加特性中的一个——例如,下载示例文件或解压压缩文件

ADDCOPY指令用法示例:

`# copy local files on the host to the destination
COPY  /source/path  /destination/path
COPY  ./requirements.txt .

# download external file and copy to the destination
ADD  http://external.file/url  /destination/path
ADD  --keep-git-dir=true https://github.com/moby/buildkit.git#v0.10.1 /buildkit

# copy and extract local compresses files
ADD  source.file.tar.gz /destination/path` 

图像

一个图像可能是三个概念中最令人困惑的。您创建了一个 Dockerfile,然后使用了一个容器,但是在这两者之间有一个图像。

因此,图像是 Docker 文件的只读实现,用于创建 Docker 容器。它由组成——docker 文件中的每一行构成一层。你不能直接改变一个图像;你可以通过改变 Dockerfile 文件来改变它。你也不直接使用图像;您使用从图像创建的容器。

最重要的图像相关任务有:

  1. 从 docker 文件构建图像
  2. 列出所有构建的图像
  3. 删除图像

从 2017 年开始,Docker 从更短的命令(即docker <command>)切换到更具描述性的格式(即docker <top-level command> <command>)。尽管 Docker 用户被鼓励使用新版本,甚至官方教程也使用更短的版本。到目前为止,旧版本仍然有效,我还没有找到任何旧命令被弃用的证据。更重要的是,人们(甚至文档)开始称它为“速记”。

使用新版本的优点是,您将更好地理解命令处理的是三个概念中的哪一个。这些命令也更容易在文档中找到。旧版本的优点是它更短,文档更全面。

在本文中,我将使用命令的描述形式。在本文的结尾,您可以找到所有命令及其简写版本。

在这里,你可以找到所有处理图像的新命令。

建筑物

要从 docker 文件构建映像,您可以使用 docker 映像构建命令。该命令需要一个参数:上下文的路径或 URL。

此图像将使用当前目录作为上下文:

你可以提供许多选项。例如,-f用于当您有多个 Dockerfile(如Dockerfile.prod)或者Dockerfile不在当前目录中(如docker image build . -f docker/Dockerfile.prod)时指定一个具体的 docker file。

可能最重要的是用于命名/标记图像的-t标签。

当您构建一个映像时,它会被分配一个 ID。与您预期的相反,id 并不是唯一的。如果你想能够方便地引用你的图片,你应该命名/标记它。使用-t,你可以给它指定一个名字和一个标签

这里有一个创建三个图像的例子:一个没有使用-t,一个指定了名称,一个指定了名称和标签。

`$ docker image build .
$ docker image build . -t hello_world
$ docker image build . -t hello_world:67d19c27b60bd782c9d3600ae914604a94bddfd4

$ docker image ls
REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
REPOSITORY    TAG                                        IMAGE ID       CREATED          SIZE
hello_world   67d19c27b60bd782c9d3600ae914604a94bddfd4   e03784993f22   25 minutes ago   181MB
hello_world   latest                                     e03784993f22   26 minutes ago   181MB
<none>        <none>                                     7a615d108866   29 minutes ago   181MB` 

注意事项:

  1. 对于构建时没有名称或标签的图像,您只能通过其图像 ID 来引用它。不仅很难记住,而且,它可能不是唯一的(如上所述)。你应该避免这种情况。
  2. 对于只有名称(-t hello_world)的图像,标签自动设置为latest。你也应该避免这种情况。更多信息,请查看版本 Docker 图片

列表

docker image ls 命令列出了所有构建的图像。

示例:

`$ docker image ls

REPOSITORY      TAG       IMAGE ID       CREATED         SIZE
hello_world     latest    c50405e84d39   9 minutes ago   245MB
<none>          <none>    2700a62cd8f1   42 hours ago    245MB
alpine/git      latest    692618a0d74d   2 weeks ago     43.4MB
todo_app        test      999740882932   3 weeks ago     1.03GB` 

消除

移除图像有两种使用情形:

  1. 您想要删除一个或多个选定的图像
  2. 您希望删除所有未使用的图像(您不关心具体是哪些图像)

对于第一种情况,你用docker image rm;对于第二种情况,您使用docker image prune

去除

docker image rm 删除并取消标记所选图像。它需要一个参数:对要删除的图像的引用。您可以通过名称或短/长 ID 来引用它。

如果你回想一下图像标记的解释...可以有多个名称不同但 ID 相同的图像。如果您试图通过图像 ID 删除图像,并且存在多个具有该 ID 的图像,您将得到一个image is referenced in multiple repositories错误。在这种情况下,您必须通过名称引用来删除它。如果您希望删除具有相同 ID 的所有图像,您可以使用-f标志。

不成功和成功的图像移除示例:

`$ docker image ls

REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
test1        latest    4659ba97837b   41 seconds ago   245MB
test2        latest    4659ba97837b   41 seconds ago   245MB
test         latest    4659ba97837b   41 seconds ago   245MB

$ docker rmi 4659ba97837b

Error response from daemon: conflict: unable to delete 4659ba97837b (must be forced) - image is referenced in multiple repositories

$ docker rmi test2
Untagged: test2:latest

$ docker image ls

REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
test1        latest    4659ba97837b   4 minutes ago   245MB
test         latest    4659ba97837b   4 minutes ago   245MB` 

减少

docker 图像修剪移除悬空图像。因为prune是一个可以用来清理容器、映像、卷和网络的命令,所以这个命令没有更短的版本。如果使用-a标志,所有未使用的图像将被删除(即docker image prune -a)。

悬挂图像是一种未被标记且未被任何容器引用的图像。

未使用的图像是与至少一个容器没有关联的图像。

示例:

`$ docker image prune

WARNING! This will remove all dangling images.
Are you sure you want to continue? [y/N]

Deleted Images:
deleted: sha256:c9a6625eb29593463ea43aab4053090427bf29cc59bc97917b3298fda6a94e8a
deleted: sha256:284f940f39c3ef5be09440e23fdefdb00df0791344db5c340a9d11979a98039e
deleted: sha256:1934187bf17ccf4e754842a4ceeacf5c14aaa63ba7a04c0c520f53946426c902` 

还有一些额外的命令,但是您可能不会经常使用它们;在官方文档中可以看到所有与图像相关的命令。

容器

您需要理解的第三个概念是一个容器,它是您的应用程序的受控环境。当图像在 Docker 引擎上运行时,它就变成了一个容器。这是最终目标:使用 Docker,这样您就可以为您的应用程序提供一个容器。

您可以对容器执行的主要操作有

  1. 运行一个容器
  2. 列出所有的容器
  3. 停止集装箱
  4. 移除一个容器

您可以在正式文档中看到与容器相关的所有命令。

运转

你可以创建一个新的图像容器并运行它,或者你可以 T2 启动一个已经停止的容器。

奔跑

docker 容器运行命令实际上结合了另外两个命令, docker 容器创建docker 容器启动

因此,下面的内容基本上给出了相同的结果:

`$ docker container run my_image

# the same as:

$ docker container create my_image
88ce9c60aeabbb970012b5f8dbae6f34581fa61ec20bd6d87c6831fbb5999263
$ docker container start 88ce9c60aeabbb970012b5f8dbae6f34581fa61ec20bd6d87c6831fbb5999263` 

您需要提供一个参数:您希望用于容器的图像。

运行 run 命令时,Docker 会在指定的图像上创建一个可写的容器层,然后使用指定的命令(Docker 文件中的CMD / ENTRYPOINT)启动它。

除非存储容器,否则在可写层中所做的更改在删除容器后不会持续。Docker 有两个选项用于存储数据

因为您可以覆盖许多默认值,所以有许多选项。你可以在官方文件中看到它们。最重要的选项是--publish / -p,用于对外发布端口。尽管在技术上可以运行没有端口的容器,但这并不是很有用,因为在容器内部运行的服务在容器外部是不可访问的。创建和运行命令都可以使用--publish / -p:

下面是一个例子:

`$ docker container run -p 8000:8000 my_image` 

您可以在官方文档中了解更多关于港口发布的信息。

您可以使用--detach / -d在分离模式下运行您的容器,这允许您继续使用终端。

如果在分离模式下运行容器,Docker 将只返回容器 ID:

`$ docker container run -p 8000:8000 -d my_image

0eb20b715f42bc5a053dc7878b3312c761058a25fc1efaffb7920b3b4e48df03` 

默认情况下,您的容器有一个独特、古怪的名称,但是您可以指定自己的名称:

`$ docker container run -p 8000:8000 --name my_great_container my_image` 

开始

要启动一个停止的或刚刚创建的容器,可以使用 docker 容器启动命令。因为使用这个命令,您将启动一个现有的容器,所以您必须指定容器,而不是图像(与docker container run一样)。

docker container run的另一个区别是docker container start默认以分离模式运行容器。你可以用--attach / -a(与docker container run -d相反)来连接它。

示例:

`$ docker container start -a reverent_sammet` 

列表

你可以用 docker 容器 ls 列出所有正在运行的容器。

示例:

`$ docker container ls

CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS                    NAMES
0f21395ec96c   9973e9c65229   "/bin/sh -c 'gunicor…"   6 minutes ago   Up 6 minutes   0.0.0.0:80->8000/tcp     shopping
73bd69d041ae   my_image       "/bin/sh -c 'uvicorn…"   2 hours ago     Up 2 hours     0.0.0.0:8000->8000/tcp   my_great_container` 

如果您还想查看停止的集装箱,您可以添加-a标志:

`$ docker container ls -a

CONTAINER ID   IMAGE          COMMAND                  CREATED              STATUS                     PORTS                    NAMES
0f21395ec96c   9973e9c65229   "/bin/sh -c 'gunicor…"   About a minute ago   Up About a minute          0.0.0.0:80->8000/tcp     shopping
73bd69d041ae   my_image       "/bin/sh -c 'uvicorn…"   2 hours ago          Up 2 hours                 0.0.0.0:8000->8000/tcp   my_great_container
0eb20b715f42   my_image       "/bin/sh -c 'uvicorn…"   2 hours ago          Exited (137) 2 hours ago                            agitated_gagarin
489a02b8cfac   my_image       "/bin/sh -c 'uvicorn…"   2 hours ago          Created                                             vigorous_poincare` 

让我们来看看以下各项的输出:

`CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS                    NAMES
73bd69d041ae   my_image       "/bin/sh -c 'uvicorn…"   2 hours ago     Up 2 hours     0.0.0.0:8000->8000/tcp   my_great_container` 
  1. CONTAINER ID ( 73bd69d041ae)和它的NAMES ( my_great_container)都是唯一的,所以你可以用它们来访问容器。
  2. IMAGE ( my_image)告诉您哪个图像用于运行容器。
  3. CREATED是不言自明的:当容器被创建时(2 hours ago)。
  4. 我们已经讨论了为启动容器指定命令的必要性...COMMAND告诉你使用了哪个命令("/bin/sh -c 'uvicorn…")。
  5. 当你不知道为什么你的容器不工作时,STATUS是有用的(Up 2 hours意味着你的容器正在运行,ExitedCreated意味着它不工作)

一些信息被截断了。如果想要未截断的版本,添加--no-trunc

填料

要停止集装箱,使用码头集装箱停止。然后返回停止的容器的名称或 ID。

示例:

`$ docker container stop my_great_container
my_great_container

$ docker container stop 73bd69d041ae
73bd69d041ae` 

可以用docker container start再次启动容器。

消除

与图像类似,要删除容器,您可以:

  1. 通过码头集装箱 rm 移除一个或多个选定的集装箱。
  2. 通过 docker 容器修剪移除所有停止的容器

docker container rm的例子:

`$ docker container rm festive_euclid
festive_euclid` 

docker container prune的例子:

`$ docker container prune

WARNING! This will remove all stopped containers.
Are you sure you want to continue? [y/N] y
Deleted Containers:
0f21395ec96c28b443bad8aac40197fe0468d24e0eed49e5f56011de1c81b589
80c693693f3d99999925eae5f4bbfc03236cde670db509797d83f50e732fcf31
0eb20b715f42bc5a053dc7878b3312c761058a25fc1efaffb7920b3b4e48df03
1273cf44c551f8ab9302e6d090e3c4e135ca6f7e1ab3d90a62bcbf5e83ba9342` 

命令

在本文中,我们讨论了相当多的命令。为了帮助你浏览你读到的内容,我准备了一个表格,列出了我们遇到的所有命令。该表包括描述性命令以及速记别名。

命令 别名 使用
docker 映像构建 码头工人建造 从 Dockerfile 文件构建映像
ls 图像坞站 docker 图像 列出图像
docker 图像室 rmi 坞站 移除选定的图像
docker 图像修剪 不适用的 移除未使用的图像
码头集装箱运输 码头运行 创建容器并启动它
码头集装箱开始 码头开始 启动现有容器
ls 容器对接器 docker ps 列出所有容器
码头集装箱停靠站 码头停车 拦住集装箱
码头集装箱室 码头工人室 移走容器
码头集装箱修剪 不适用的 移除所有停止的容器

结论

总而言之,Docker 中最基本的概念是 Dockerfile、image 和 container。

使用 Dockerfile 文件作为蓝图,构建一个映像。然后,可以使用这些图像构建其他图像,并且可以在 Docker Hub 上找到这些图像。运行映像会为您的应用程序产生一个受控的环境,称为容器。

这篇文章的目的是向你解释 Docker 的基础知识。如果你想阅读更多的实践教程,你可以查看我们与 Docker 相关的文章的广泛列表。如果你是一名 Python 开发者,一定要看看 Docker 针对 Python 开发者的最佳实践

下一步是什么?

Docker 是一个复杂的系统。这篇文章只是触及了皮毛。在使用 Docker 时,您仍然需要学习大量的概念、特性和工具。这里有一些你可能会很快遇到的:

  1. 和 git 一样,Docker 也有一个名为的忽略文件。dockerignore 在这里你可以定义哪些文件和文件夹你不想添加到图像(由于安全,大小等原因。).
  2. 正如我在文章中提到的,如果容器被移除,来自容器可写层的数据就不会持久。您可以使用绑定挂载在主机上存储文件。
  3. 如果您的应用程序需要多个容器(例如,如果您的 Django 应用程序使用 Postgres),您可以使用 Docker Compose 来简化它们的生命周期。

Docker 教程

原文:https://testdriven.io/blog/topics/docker/

概述了 Docker 是什么,如何使用它,以及基本的 Docker 命令,这样您就可以快速开始使用 Docker。

用 Postgres、Gunicorn 和 Nginx 编写 Django 文档

原文:https://testdriven.io/blog/dockerizing-django-with-postgres-gunicorn-and-nginx/

这是一个循序渐进的教程,详细介绍了如何配置 Django,使其在带有 Postgres 的 Docker 上运行。对于生产环境,我们将添加 Nginx 和 Gunicorn。我们还将看看如何通过 Nginx 提供 Django 静态和媒体文件。

依赖关系:

  1. Django v3.2.6
  2. 文档 v20.10.8
  3. python 3 . 9 . 6 版

姜戈码头系列:

  1. 用 Postgres、Gunicorn、Nginx (本文!)
  2. 用加密保护容器化的 Django 应用
  3. 用 Docker 将 Django 部署到 AWS,让我们加密

项目设置

创建一个新的项目目录和一个新的 Django 项目:

`$ mkdir django-on-docker && cd django-on-docker
$ mkdir app && cd app
$ python3.9 -m venv env
$ source env/bin/activate
(env)$

(env)$ pip install django==3.2.6
(env)$ django-admin.py startproject hello_django .
(env)$ python manage.py migrate
(env)$ python manage.py runserver` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

导航到 http://localhost:8000/ 查看 Django 欢迎屏幕。一旦完成就杀死服务器。然后,退出并删除虚拟环境。我们现在有了一个简单的 Django 项目。

在“app”目录下创建一个 requirements.txt 文件,并添加 Django 作为依赖项:

因为我们将转移到 Postgres,所以继续从“app”目录中删除 db.sqlite3 文件。

您的项目目录应该如下所示:

`└── app
    ├── hello_django
    │   ├── __init__.py
    │   ├── asgi.py
    │   ├── settings.py
    │   ├── urls.py
    │   └── wsgi.py
    ├── manage.py
    └── requirements.txt` 

码头工人

安装 Docker ,如果你还没有,那么在“app”目录下添加一个 Dockerfile :

`# pull official base image
FROM  python:3.9.6-alpine

# 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  . .` 

所以,我们从 Python 3.9.6 的基于 AlpineDocker 镜像开始。然后我们设置一个工作目录以及两个环境变量:

  1. PYTHONDONTWRITEBYTECODE:防止 Python 将 pyc 文件写入磁盘(相当于python -B 选项
  2. PYTHONUNBUFFERED:防止 Python 缓冲 stdout 和 stderr(相当于python -u 选项

最后,我们更新了 Pip,复制了 requirements.txt 文件,安装了依赖项,并复制了 Django 项目本身。

查看Docker for Python Developers了解更多关于构造 Docker 文件的信息,以及为基于 Python 的开发配置 Docker 的一些最佳实践。

接下来,将一个 docker-compose.yml 文件添加到项目根:

`version:  '3.8' services: web: build:  ./app command:  python manage.py runserver 0.0.0.0:8000 volumes: -  ./app/:/usr/src/app/ ports: -  8000:8000 env_file: -  ./.env.dev` 

查看合成文件参考,了解该文件如何工作的信息。

更新 settings.py 中的SECRET_KEYDEBUGALLOWED_HOSTS变量:

`SECRET_KEY = os.environ.get("SECRET_KEY")

DEBUG = int(os.environ.get("DEBUG", default=0))

# 'DJANGO_ALLOWED_HOSTS' should be a single string of hosts with a space between each.
# For example: 'DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]'
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ")` 

确保将导入添加到顶部:

然后,在项目根目录下创建一个 .env.dev 文件来存储开发环境变量:

`DEBUG=1
SECRET_KEY=foo
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]` 

建立形象:

构建映像后,运行容器:

导航到 http://localhost:8000/ 再次查看欢迎屏幕。

如果这不起作用,通过docker-compose logs -f检查日志中的错误。

Postgres

要配置 Postgres,我们需要向 docker-compose.yml 文件添加一个新服务,更新 Django 设置,并安装 Psycopg2

首先,向 docker-compose.yml 添加一个名为db的新服务:

`version:  '3.8' services: web: build:  ./app command:  python manage.py runserver 0.0.0.0:8000 volumes: -  ./app/:/usr/src/app/ ports: -  8000:8000 env_file: -  ./.env.dev depends_on: -  db db: image:  postgres:13.0-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ environment: -  POSTGRES_USER=hello_django -  POSTGRES_PASSWORD=hello_django -  POSTGRES_DB=hello_django_dev volumes: postgres_data:` 

为了在容器的生命周期之外保存数据,我们配置了一个卷。这个配置将把postgres_data绑定到容器中的“/var/lib/postgresql/data/”目录。

我们还添加了一个环境键来定义默认数据库的名称,并设置用户名和密码。

查看 Postgres Docker Hub 页面的“环境变量”部分了解更多信息。

我们还需要为web服务添加一些新的环境变量,所以像这样更新 .env.dev :

`DEBUG=1
SECRET_KEY=foo
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=hello_django_dev
SQL_USER=hello_django
SQL_PASSWORD=hello_django
SQL_HOST=db
SQL_PORT=5432` 

更新 settings.py 中的DATABASES dict:

`DATABASES = {
    "default": {
        "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
        "NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"),
        "USER": os.environ.get("SQL_USER", "user"),
        "PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
        "HOST": os.environ.get("SQL_HOST", "localhost"),
        "PORT": os.environ.get("SQL_PORT", "5432"),
    }
}` 

这里,数据库是基于我们刚刚定义的环境变量进行配置的。记下默认值。

更新 docker 文件以安装 Psycopg2 所需的相应软件包:

`# pull official base image
FROM  python:3.9.6-alpine

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# install psycopg2 dependencies
RUN  apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev

# install dependencies
RUN  pip install --upgrade pip
COPY  ./requirements.txt .
RUN  pip install -r requirements.txt

# copy project
COPY  . .` 

将 Psycopg2 添加到 requirements.txt :

`Django==3.2.6
psycopg2-binary==2.9.1` 

查看本期 GitHub了解更多关于在基于 Alpine 的 Docker 映像中安装 Psycopg2 的信息。

构建新的映像并旋转两个容器:

`$ docker-compose up -d --build` 

运行迁移:

`$ docker-compose exec web python manage.py migrate --noinput` 

得到以下错误?

django.db.utils.OperationalError: FATAL:  database "hello_django_dev" does not exist 

运行docker-compose down -v移除卷和容器。然后,重新构建映像,运行容器,并应用迁移。

确保创建了默认的 Django 表:

`$ docker-compose exec db psql --username=hello_django --dbname=hello_django_dev

psql (13.0)
Type "help" for help.

hello_django_dev=# \l
                                          List of databases
       Name       |    Owner     | Encoding |  Collate   |   Ctype    |       Access privileges
------------------+--------------+----------+------------+------------+-------------------------------
 hello_django_dev | hello_django | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres         | hello_django | UTF8     | en_US.utf8 | en_US.utf8 |
 template0        | hello_django | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_django              +
                  |              |          |            |            | hello_django=CTc/hello_django
 template1        | hello_django | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_django              +
                  |              |          |            |            | hello_django=CTc/hello_django
(4 rows)

hello_django_dev=# \c hello_django_dev
You are now connected to database "hello_django_dev" as user "hello_django".

hello_django_dev=# \dt
                     List of relations
 Schema |            Name            | Type  |    Owner
--------+----------------------------+-------+--------------
 public | auth_group                 | table | hello_django
 public | auth_group_permissions     | table | hello_django
 public | auth_permission            | table | hello_django
 public | auth_user                  | table | hello_django
 public | auth_user_groups           | table | hello_django
 public | auth_user_user_permissions | table | hello_django
 public | django_admin_log           | table | hello_django
 public | django_content_type        | table | hello_django
 public | django_migrations          | table | hello_django
 public | django_session             | table | hello_django
(10 rows)

hello_django_dev=# \q` 

您也可以通过运行以下命令来检查该卷是否已创建:

`$ docker volume inspect django-on-docker_postgres_data` 

您应该会看到类似如下的内容:

`[
    {
        "CreatedAt": "2021-08-23T15:49:08Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "django-on-docker",
            "com.docker.compose.version": "1.29.2",
            "com.docker.compose.volume": "postgres_data"
        },
        "Mountpoint": "/var/lib/docker/volumes/django-on-docker_postgres_data/_data",
        "Name": "django-on-docker_postgres_data",
        "Options": null,
        "Scope": "local"
    }
]` 

接下来,在应用迁移并运行 Django 开发服务器之前,将 entrypoint.sh 文件添加到“app”目录中,以验证 Postgres 是否健康:

`#!/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

python manage.py flush --no-input
python manage.py migrate

exec "[[email protected]](/cdn-cgi/l/email-protection)"` 

在本地更新文件权限:

`$ chmod +x app/entrypoint.sh` 

然后,更新 Docker 文件以复制覆盖 entrypoint.sh 文件,并将其作为 Docker entrypoint 命令运行:

`# pull official base image
FROM  python:3.9.6-alpine

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# install psycopg2 dependencies
RUN  apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev

# install dependencies
RUN  pip install --upgrade pip
COPY  ./requirements.txt .
RUN  pip install -r requirements.txt

# copy entrypoint.sh
COPY  ./entrypoint.sh .
RUN  sed -i 's/\r$//g' /usr/src/app/entrypoint.sh
RUN  chmod +x /usr/src/app/entrypoint.sh

# copy project
COPY  . .

# run entrypoint.sh
ENTRYPOINT  ["/usr/src/app/entrypoint.sh"]` 

DATABASE环境变量添加到 .env.dev :

`DEBUG=1
SECRET_KEY=foo
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=hello_django_dev
SQL_USER=hello_django
SQL_PASSWORD=hello_django
SQL_HOST=db
SQL_PORT=5432
DATABASE=postgres` 

再次测试:

  1. 重建图像
  2. 运行容器
  3. 试试 http://localhost:8000/

笔记

首先,尽管添加了 Postgres,只要DATABASE环境变量没有设置为postgres,我们仍然可以为 Django 创建一个独立的 Docker 映像。要进行测试,构建一个新的映像,然后运行一个新的容器:

`$ docker build -f ./app/Dockerfile -t hello_django:latest ./app
$ docker run -d \
    -p 8006:8000 \
    -e "SECRET_KEY=please_change_me" -e "DEBUG=1" -e "DJANGO_ALLOWED_HOSTS=*" \
    hello_django python /usr/src/app/manage.py runserver 0.0.0.0:8000` 

您应该能够在 http://localhost:8006 查看欢迎页面

其次,您可能希望注释掉 entrypoint.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

# python manage.py flush --no-input
# python manage.py migrate

exec "[[email protected]](/cdn-cgi/l/email-protection)"` 

相反,您可以在容器旋转后手动运行它们,如下所示:

`$ docker-compose exec web python manage.py flush --no-input
$ docker-compose exec web python manage.py migrate` 

格尼科恩

接下来,对于生产环境,让我们将 Gunicorn ,一个生产级的 WSGI 服务器,添加到需求文件中:

`Django==3.2.6
gunicorn==20.1.0
psycopg2-binary==2.9.1` 

对 WSGI 和 Gunicorn 很好奇?回顾来自构建你自己的 Python Web 框架课程的 WSGI 章节。

由于我们仍然希望在开发中使用 Django 的内置服务器,因此为生产创建一个名为 docker-compose.prod.yml 的新合成文件:

`version:  '3.8' services: web: build:  ./app command:  gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000 ports: -  8000:8000 env_file: -  ./.env.prod depends_on: -  db db: image:  postgres:13.0-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ env_file: -  ./.env.prod.db volumes: postgres_data:` 

如果您有多个环境,您可能希望使用一个docker-compose . override . yml配置文件。使用这种方法,您可以将您的基本配置添加到一个 docker-compose.yml 文件中,然后使用一个docker-compose . override . yml文件根据环境覆盖那些配置设置。

记下默认值command。我们运行的是 Gunicorn,而不是 Django 开发服务器。我们还从web服务中删除了这个卷,因为我们在生产中不需要它。最后,我们使用单独的环境变量文件来定义两个服务的环境变量,它们将在运行时传递给容器。

.env.prod :

`DEBUG=0
SECRET_KEY=change_me
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=hello_django_prod
SQL_USER=hello_django
SQL_PASSWORD=hello_django
SQL_HOST=db
SQL_PORT=5432
DATABASE=postgres` 

.env.prod.db :

`POSTGRES_USER=hello_django
POSTGRES_PASSWORD=hello_django
POSTGRES_DB=hello_django_prod` 

将这两个文件添加到项目根目录。您可能想让它们不受版本控制,所以将它们添加到一个中。gitignore 文件。

下放到开发容器(以及带有-v标志的相关卷):

然后,构建生产映像并启动容器:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

验证hello_django_prod数据库是和默认的 Django 表一起创建的。在http://localhost:8000/admin测试管理页面。静态文件不再被加载。这是意料之中的,因为调试模式已关闭。我们会尽快解决这个问题。

同样,如果容器启动失败,通过docker-compose -f docker-compose.prod.yml logs -f检查日志中的错误。

生产文档

您是否注意到我们仍然在运行数据库 flush (清空数据库)并在每次运行容器时迁移命令?这在开发中很好,但是让我们为生产创建一个新的入口点文件。

entry point . 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)"` 

在本地更新文件权限:

`$ chmod +x app/entrypoint.prod.sh` 

要使用这个文件,创建一个名为 Dockerfile.prod 的新 Dockerfile,用于生产构建:

`###########
# BUILDER #
###########

# pull official base image
FROM  python:3.9.6-alpine  as  builder

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# install psycopg2 dependencies
RUN  apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev

# lint
RUN  pip install --upgrade pip
RUN  pip install flake8==3.9.2
COPY  . .
RUN  flake8 --ignore=E501,F401 .

# install dependencies
COPY  ./requirements.txt .
RUN  pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt

#########
# FINAL #
#########

# pull official base image
FROM  python:3.9.6-alpine

# create directory for the app user
RUN  mkdir -p /home/app

# create the app user
RUN  addgroup -S app && adduser -S app -G app

# create the appropriate directories
ENV  HOME=/home/app
ENV  APP_HOME=/home/app/web
RUN  mkdir $APP_HOME
WORKDIR  $APP_HOME

# install dependencies
RUN  apk update && apk add libpq
COPY  --from=builder /usr/src/app/wheels /wheels
COPY  --from=builder /usr/src/app/requirements.txt .
RUN  pip install --no-cache /wheels/*

# copy entrypoint.prod.sh
COPY  ./entrypoint.prod.sh .
RUN  sed -i 's/\r$//g'  $APP_HOME/entrypoint.prod.sh
RUN  chmod +x  $APP_HOME/entrypoint.prod.sh

# copy project
COPY  . $APP_HOME

# chown all the files to the app user
RUN  chown -R app:app $APP_HOME

# change to the app user
USER  app

# run entrypoint.prod.sh
ENTRYPOINT  ["/home/app/web/entrypoint.prod.sh"]` 

在这里,我们使用了一个 Docker 多阶段构建来缩小最终的图像尺寸。本质上,builder是一个用于构建 Python 轮子的临时图像。然后车轮被复制到最终产品图像中,而builder图像被丢弃。

你可以将多阶段构建方法更进一步,使用单个docker 文件而不是创建两个 docker 文件。思考在两个不同的文件上使用这种方法的利弊。

您是否注意到我们创建了一个非 root 用户?默认情况下,Docker 在容器内部以 root 用户身份运行容器进程。这是一种不好的做法,因为如果攻击者设法突破容器,他们可以获得 Docker 主机的根用户访问权限。如果您是容器中的 root 用户,那么您将是主机上的 root 用户。

更新 docker-compose.prod.yml 文件中的web服务,用 Dockerfile.prod 构建:

`web: build: context:  ./app dockerfile:  Dockerfile.prod command:  gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000 ports: -  8000:8000 env_file: -  ./.env.prod depends_on: -  db` 

尝试一下:

`$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py migrate --noinput` 

Nginx

接下来,让我们将 Nginx 添加进来,充当 Gunicorn 的反向代理来处理客户端请求以及提供静态文件。

将服务添加到 docker-compose.prod.yml :

`nginx: build:  ./nginx ports: -  1337:80 depends_on: -  web` 

然后,在本地项目根目录中,创建以下文件和文件夹:

`└── nginx
    ├── Dockerfile
    └── nginx.conf` 

Dockerfile :

`FROM  nginx:1.21-alpine

RUN  rm /etc/nginx/conf.d/default.conf
COPY  nginx.conf /etc/nginx/conf.d` 

engine . conf:

`upstream hello_django {
    server web: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;
    }

}` 

查看使用 NGINX 和 NGINX Plus 作为 uWSGI 和 Django 的应用网关,了解更多关于配置 NGINX 与 Django 协同工作的信息。

然后,更新web服务,在 docker-compose.prod.yml 中,用expose替换ports:

`web: build: context:  ./app dockerfile:  Dockerfile.prod command:  gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000 expose: -  8000 env_file: -  ./.env.prod depends_on: -  db` 

现在,端口 8000 只在内部对其他 Docker 服务公开。该端口将不再发布到主机上。

关于端口与暴露的更多信息,请查看这个堆栈溢出问题。

再测试一次。

`$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py migrate --noinput` 

确保应用程序在 http://localhost:1337 启动并运行。

您的项目结构现在应该看起来像这样:

`├── .env.dev
├── .env.prod
├── .env.prod.db
├── .gitignore
├── app
│   ├── Dockerfile
│   ├── Dockerfile.prod
│   ├── entrypoint.prod.sh
│   ├── entrypoint.sh
│   ├── hello_django
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   ├── manage.py
│   └── requirements.txt
├── docker-compose.prod.yml
├── docker-compose.yml
└── nginx
    ├── Dockerfile
    └── nginx.conf` 

完成后将容器拿下来:

`$ docker-compose -f docker-compose.prod.yml down -v` 

由于 Gunicorn 是一个应用服务器,它不会提供静态文件。那么,在这种特定的配置中,应该如何处理静态文件和媒体文件呢?

静态文件

更新 settings.py :

`STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"` 

发展

现在,对http://localhost:8000/static/*的任何请求都将从“staticfiles”目录得到服务。

为了进行测试,首先重新构建映像,并像往常一样旋转新的容器。确保静态文件仍然在http://localhost:8000/admin上被正确地提供。

生产

对于生产,向 docker-compose.prod.yml 中的webnginx服务添加一个卷,这样每个容器将共享一个名为“staticfiles”的目录:

`version:  '3.8' services: web: build: context:  ./app dockerfile:  Dockerfile.prod command:  gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000 volumes: -  static_volume:/home/app/web/staticfiles expose: -  8000 env_file: -  ./.env.prod depends_on: -  db db: image:  postgres:13.0-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ env_file: -  ./.env.prod.db nginx: build:  ./nginx volumes: -  static_volume:/home/app/web/staticfiles ports: -  1337:80 depends_on: -  web volumes: postgres_data: static_volume:` 

我们还需要在 Dockerfile.prod 中创建“/home/app/web/staticfiles”文件夹:

`...

# create the appropriate directories
ENV  HOME=/home/app
ENV  APP_HOME=/home/app/web
RUN  mkdir $APP_HOME
RUN  mkdir $APP_HOME/staticfiles
WORKDIR  $APP_HOME

...` 

为什么这是必要的?

Docker Compose 通常将命名的卷挂载为根卷。由于我们使用的是非根用户,如果目录不存在,那么当运行collectstatic命令时,我们会得到一个权限被拒绝的错误

要解决这个问题,您可以:

  1. 在 Dockerfile ( source )中创建文件夹
  2. 挂载后更改目录的权限( source )

我们用的是前者。

接下来,更新 Nginx 配置,将静态文件请求路由到“staticfiles”文件夹:

`upstream hello_django {
    server web: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;
    }

    location /static/ {
        alias /home/app/web/staticfiles/;
    }

}` 

降低开发容器的转速:

测试:

`$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py migrate --noinput
$ docker-compose -f docker-compose.prod.yml exec web python manage.py collectstatic --no-input --clear` 

同样,对http://localhost:1337/static/*的请求将由“staticfiles”目录提供。

导航到http://localhost:1337/admin并确保静态资产正确加载。

您还可以通过docker-compose -f docker-compose.prod.yml logs -f在日志中验证对静态文件的请求是否通过 Nginx 成功提供:

`nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /admin/ HTTP/1.1" 302 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /admin/login/?next=/admin/ HTTP/1.1" 200 2214 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /static/admin/css/base.css HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /static/admin/css/nav_sidebar.css HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /static/admin/css/responsive.css HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /static/admin/css/login.css HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /static/admin/js/nav_sidebar.js HTTP/1.1" 304 0 "http://localhost:1337/admin/login/?next=/admin/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /static/admin/css/fonts.css HTTP/1.1" 304 0 "http://localhost:1337/static/admin/css/base.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /static/admin/fonts/Roboto-Regular-webfont.woff HTTP/1.1" 304 0 "http://localhost:1337/static/admin/css/fonts.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"
nginx_1  | 192.168.144.1 - - [23/Aug/2021:20:11:00 +0000] "GET /static/admin/fonts/Roboto-Light-webfont.woff HTTP/1.1" 304 0 "http://localhost:1337/static/admin/css/fonts.css" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-"` 

完成后带上容器:

`$ docker-compose -f docker-compose.prod.yml down -v` 

要测试媒体文件的处理,首先要创建一个新的 Django 应用程序:

`$ docker-compose up -d --build
$ docker-compose exec web python manage.py startapp upload` 

将新应用添加到 settings.py 中的INSTALLED_APPS列表:

`INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",

    "upload",
]` 

app/upload/views.py :

`from django.shortcuts import render
from django.core.files.storage import FileSystemStorage

def image_upload(request):
    if request.method == "POST" and request.FILES["image_file"]:
        image_file = request.FILES["image_file"]
        fs = FileSystemStorage()
        filename = fs.save(image_file.name, image_file)
        image_url = fs.url(filename)
        print(image_url)
        return render(request, "upload.html", {
            "image_url": image_url
        })
    return render(request, "upload.html")` 

在“app/upload”目录下添加一个“templates”,然后添加一个名为upload.html的新模板:

`{% block content %}

  <form action="{% url "upload" %}" method="post" enctype="multipart/form-data">
    {% csrf_token %}
    <input type="file" name="image_file">
    <input type="submit" value="submit" />
  </form>

  {% if image_url %}
    <p>File uploaded at: <a href="{{ image_url }}">{{ image_url }}</a></p>
  {% endif %}

{% endblock %}` 

app/hello_django/urls.py :

`from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static

from upload.views import image_upload

urlpatterns = [
    path("", image_upload, name="upload"),
    path("admin/", admin.site.urls),
]

if bool(settings.DEBUG):
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)` 

app/hello _ django/settings . py:

`MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "mediafiles"` 

发展

测试:

`$ docker-compose up -d --build` 

你应该可以在 http://localhost:8000/ 上传一张图片,然后在http://localhost:8000/media/IMAGE _ FILE _ NAME查看图片。

生产

对于生产,向webnginx服务添加另一个卷:

`version:  '3.8' services: web: build: context:  ./app dockerfile:  Dockerfile.prod command:  gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000 volumes: -  static_volume:/home/app/web/staticfiles -  media_volume:/home/app/web/mediafiles expose: -  8000 env_file: -  ./.env.prod depends_on: -  db db: image:  postgres:13.0-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ env_file: -  ./.env.prod.db nginx: build:  ./nginx volumes: -  static_volume:/home/app/web/staticfiles -  media_volume:/home/app/web/mediafiles ports: -  1337:80 depends_on: -  web volumes: postgres_data: static_volume: media_volume:` 

Dockerfile.prod 中创建“/home/app/web/mediafiles”文件夹:

`...

# create the appropriate directories
ENV  HOME=/home/app
ENV  APP_HOME=/home/app/web
RUN  mkdir $APP_HOME
RUN  mkdir $APP_HOME/staticfiles
RUN  mkdir $APP_HOME/mediafiles
WORKDIR  $APP_HOME

...` 

再次更新 Nginx 配置:

`upstream hello_django {
    server web: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;
    }

    location /static/ {
        alias /home/app/web/staticfiles/;
    }

    location /media/ {
        alias /home/app/web/mediafiles/;
    }

}` 

重建:

`$ docker-compose down -v

$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py migrate --noinput
$ docker-compose -f docker-compose.prod.yml exec web python manage.py collectstatic --no-input --clear` 

最后一次测试:

  1. 上传一张图片 http://localhost:1337/
  2. 然后在http://localhost:1337/media/IMAGE _ FILE _ NAME查看图片。

如果您看到一个413 Request Entity Too Large错误,您将需要在 Nginx 配置中的服务器或位置上下文中增加客户端请求主体的最大允许大小。

示例:

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;
    client_max_body_size 100M;
} 

结论

在本教程中,我们介绍了如何使用 Postgres 来封装 Django web 应用程序以进行开发。我们还创建了一个生产就绪的 Docker Compose 文件,将 Gunicorn 和 Nginx 添加到混合文件中,以处理静态和媒体文件。现在,您可以在本地测试生产设置。

就生产环境的实际部署而言,您可能希望使用:

  1. 完全托管的数据库服务——像 RDS云 SQL——而不是在一个容器中管理你自己的 Postgres 实例。
  2. dbnginx服务的非根用户

关于其他生产技巧,请查看本讨论

你可以在 django-on-docker repo 中找到代码。

这里还有一个更老的 Pipenv 版本的代码。

感谢阅读!

姜戈码头系列:

  1. 用 Postgres、Gunicorn、Nginx (本文!)
  2. 用加密保护容器化的 Django 应用
  3. 用 Docker 将 Django 部署到 AWS,让我们加密

装有 Postgres、Gunicorn 和 Nginx 的对接烧瓶

原文:https://testdriven.io/blog/dockerizing-flask-with-postgres-gunicorn-and-nginx/

这是一个循序渐进的教程,详细介绍了如何配置 Flask 运行在 Docker 与 Postgres。对于生产环境,我们将添加 Nginx 和 Gunicorn。我们还将看看如何通过 Nginx 提供静态和用户上传的媒体文件。

依赖关系:

  1. 烧瓶 v2.2.2
  2. 文档 v20.10.17
  3. python 3 . 10 . 7 版

项目设置

创建一个新的项目目录并安装 Flask:

`$ mkdir flask-on-docker && cd flask-on-docker
$ mkdir services && cd services
$ mkdir web && cd web
$ mkdir project

$ python3.10 -m venv env
$ source env/bin/activate

(env)$ pip install flask==2.2.2` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

接下来,让我们创建一个新的 Flask 应用程序。

添加一个 init。py 文件到" project "目录并配置第一条路由:

`from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/")
def hello_world():
    return jsonify(hello="world")` 

然后,要配置 Flask CLI 工具从命令行运行和管理应用程序,请将一个 manage.py 文件添加到“web”目录:

`from flask.cli import FlaskGroup

from project import app

cli = FlaskGroup(app)

if __name__ == "__main__":
    cli()` 

这里,我们创建了一个新的FlaskGroup实例,用与 Flask 应用程序相关的命令来扩展普通 CLI。

从“web”目录运行服务器:

`(env)$ export FLASK_APP=project/__init__.py
(env)$ python manage.py run` 

导航到 http://localhost:5000/ 。您应该看到:

一旦完成就杀死服务器。退出,然后也删除虚拟环境。

在“web”目录下创建一个 requirements.txt 文件,并添加 Flask 作为依赖项:

您的项目结构应该是这样的:

`└── services
    └── web
        ├── manage.py
        ├── project
        │   └── __init__.py
        └── requirements.txt` 

码头工人

安装 Docker ,如果你还没有,那么在“web”目录下添加一个 Dockerfile :

`# pull official base image
FROM  python:3.10.7-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 /usr/src/app/requirements.txt
RUN  pip install -r requirements.txt

# copy project
COPY  . /usr/src/app/` 

所以,我们从 Python 3.10.7 的基于slim-busterDocker 镜像开始。然后我们设置一个工作目录以及两个环境变量:

  1. PYTHONDONTWRITEBYTECODE:防止 Python 将 pyc 文件写入磁盘(相当于python -B 选项
  2. PYTHONUNBUFFERED:防止 Python 缓冲 stdout 和 stderr(相当于python -u 选项

最后,我们更新了 Pip,复制了 requirements.txt 文件,安装了依赖项,并复制了 Flask 应用程序本身。

查看 Docker 针对 Python 开发人员的最佳实践,了解更多关于构造 Docker 文件的信息,以及为基于 Python 的开发配置 Docker 的一些最佳实践。

接下来,将一个 docker-compose.yml 文件添加到项目根:

`version:  '3.8' services: web: build:  ./services/web command:  python manage.py run -h 0.0.0.0 volumes: -  ./services/web/:/usr/src/app/ ports: -  5000:5000 env_file: -  ./.env.dev` 

查看合成文件参考,了解该文件如何工作的信息。

然后,在项目根目录下创建一个 .env.dev 文件来存储开发环境变量:

`FLASK_APP=project/__init__.py
FLASK_DEBUG=1` 

建立形象:

构建映像后,运行容器:

导航到 http://localhost:5000/ 再次查看 hello world 健全性检查。

如果这不起作用,通过docker-compose logs -f检查日志中的错误。

Postgres

要配置 Postgres,我们需要在 docker-compose.yml 文件中添加一个新服务,设置 Flask-SQLAlchemy ,安装 Psycopg2

首先,向 docker-compose.yml 添加一个名为db的新服务:

`version:  '3.8' services: web: build:  ./services/web command:  python manage.py run -h 0.0.0.0 volumes: -  ./services/web/:/usr/src/app/ ports: -  5000:5000 env_file: -  ./.env.dev depends_on: -  db db: image:  postgres:13-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ environment: -  POSTGRES_USER=hello_flask -  POSTGRES_PASSWORD=hello_flask -  POSTGRES_DB=hello_flask_dev volumes: postgres_data:` 

为了在容器的生命周期之外保存数据,我们配置了一个卷。这个配置将把postgres_data绑定到容器中的“/var/lib/postgresql/data/”目录。

我们还添加了一个环境键来定义默认数据库的名称,并设置用户名和密码。

查看 Postgres Docker Hub 页面的“环境变量”部分了解更多信息。

.env.dev 添加一个DATABASE_URL环境变量:

`FLASK_APP=project/__init__.py FLASK_DEBUG=1 DATABASE_URL=postgresql://hello_flask:hello_flask@db:5432/hello_flask_dev` 

然后,将一个名为 config.py 的新文件添加到“项目”目录中,在这里我们将定义特定于环境的配置变量:

`import os

basedir = os.path.abspath(os.path.dirname(__file__))

class Config(object):
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite://")
    SQLALCHEMY_TRACK_MODIFICATIONS = False` 

这里,数据库是基于我们刚刚定义的DATABASE_URL环境变量配置的。记下默认值。

更新 init。py 在 init 上拉入配置:

`from flask import Flask, jsonify

app = Flask(__name__)
app.config.from_object("project.config.Config")

@app.route("/")
def hello_world():
    return jsonify(hello="world")` 

Flask-SQLAlchemyPsycopg2 添加到 requirements.txt :

`Flask==2.2.2
Flask-SQLAlchemy==2.5.1
psycopg2-binary==2.9.4` 

更新 init。py 再次创建一个新的SQLAlchemy实例并定义一个数据库模型:

`from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config.from_object("project.config.Config")
db = SQLAlchemy(app)

class User(db.Model):
    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(128), unique=True, nullable=False)
    active = db.Column(db.Boolean(), default=True, nullable=False)

    def __init__(self, email):
        self.email = email

@app.route("/")
def hello_world():
    return jsonify(hello="world")` 

最后,更新 manage.py :

`from flask.cli import FlaskGroup

from project import app, db

cli = FlaskGroup(app)

@cli.command("create_db")
def create_db():
    db.drop_all()
    db.create_all()
    db.session.commit()

if __name__ == "__main__":
    cli()` 

这向 CLI 注册了一个新命令create_db,以便我们可以从命令行运行它,稍后我们将使用它将模型应用到数据库。

构建新的映像并旋转两个容器:

`$ docker-compose up -d --build` 

创建表格:

`$ docker-compose exec web python manage.py create_db` 

得到以下错误?

sqlalchemy.exc.OperationalError: (psycopg2.OperationalError)
FATAL:  database "hello_flask_dev" does not exist 

运行docker-compose down -v移除卷和容器。然后,重新构建映像,运行容器,并应用迁移。

确保users表已创建:

`$ docker-compose exec db psql --username=hello_flask --dbname=hello_flask_dev

psql (13.8)
Type "help" for help.

hello_flask_dev=# \l
                                        List of databases
      Name       |    Owner    | Encoding |  Collate   |   Ctype    |      Access privileges
-----------------+-------------+----------+------------+------------+-----------------------------
 hello_flask_dev | hello_flask | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres        | hello_flask | UTF8     | en_US.utf8 | en_US.utf8 |
 template0       | hello_flask | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_flask             +
                 |             |          |            |            | hello_flask=CTc/hello_flask
 template1       | hello_flask | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_flask             +
                 |             |          |            |            | hello_flask=CTc/hello_flask
(4 rows)

hello_flask_dev=# \c hello_flask_dev
You are now connected to database "hello_flask_dev" as user "hello_flask".

hello_flask_dev=# \dt
          List of relations
 Schema | Name  | Type  |    Owner
--------+-------+-------+-------------
 public | users | table | hello_flask
(1 row)

hello_flask_dev=# \q` 

您也可以通过运行以下命令来检查该卷是否已创建:

`$ docker volume inspect flask-on-docker_postgres_data` 

您应该会看到类似如下的内容:

`[
    {
        "CreatedAt": "2022-10-11T14:43:49Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "flask-on-docker",
            "com.docker.compose.version": "2.10.2",
            "com.docker.compose.volume": "postgres_data"
        },
        "Mountpoint": "/var/lib/docker/volumes/flask-on-docker_postgres_data/_data",
        "Name": "flask-on-docker_postgres_data",
        "Options": null,
        "Scope": "local"
    }
]` 

接下来,将一个 entrypoint.sh 文件添加到“web”目录,以在创建数据库表并运行 Flask 开发服务器之前,验证 Postgres 是否已启动是否健康:

`#!/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

python manage.py create_db

exec "[[email protected]](/cdn-cgi/l/email-protection)"` 

记下环境变量。

在本地更新文件权限:

`$ chmod +x services/web/entrypoint.sh` 

然后,更新 Docker 文件安装 Netcat ,复制 entrypoint.sh 文件,运行该文件作为 Docker entrypoint 命令:

`# pull official base image
FROM  python:3.10.7-slim-buster

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# install system dependencies
RUN  apt-get update && apt-get install -y netcat

# install dependencies
RUN  pip install --upgrade pip
COPY  ./requirements.txt /usr/src/app/requirements.txt
RUN  pip install -r requirements.txt

# copy project
COPY  . /usr/src/app/

# run entrypoint.sh
ENTRYPOINT  ["/usr/src/app/entrypoint.sh"]` 

entrypoint.sh 脚本的SQL_HOSTSQL_PORTDATABASE环境变量添加到 .env.dev :

`FLASK_APP=project/__init__.py FLASK_DEBUG=1 DATABASE_URL=postgresql://hello_flask:hello_flask@db:5432/hello_flask_dev SQL_HOST=db SQL_PORT=5432 DATABASE=postgres` 

再次测试:

  1. 重建图像
  2. 运行容器
  3. 试试 http://localhost:5000/

让我们添加一个 CLI 种子命令,用于将示例用户添加到 manage.py 中的users表中:

`from flask.cli import FlaskGroup

from project import app, db, User

cli = FlaskGroup(app)

@cli.command("create_db")
def create_db():
    db.drop_all()
    db.create_all()
    db.session.commit()

@cli.command("seed_db")
def seed_db():
    db.session.add(User(email="[[email protected]](/cdn-cgi/l/email-protection)"))
    db.session.commit()

if __name__ == "__main__":
    cli()` 

尝试一下:

`$ docker-compose exec web python manage.py seed_db

$ docker-compose exec db psql --username=hello_flask --dbname=hello_flask_dev

psql (13.8)
Type "help" for help.

hello_flask_dev=# \c hello_flask_dev
You are now connected to database "hello_flask_dev" as user "hello_flask".

hello_flask_dev=# select * from users;
 id |        email        | active
----+---------------------+--------
  1 | [[email protected]](/cdn-cgi/l/email-protection) | t
(1 row)

hello_flask_dev=# \q` 

尽管添加了 Postgres,我们仍然可以通过不设置DATABASE_URL环境变量来为 Flask 创建一个独立的 Docker 映像。要进行测试,构建一个新的映像,然后运行一个新的容器:

$ docker build -f ./services/web/Dockerfile -t hello_flask:latest ./services/web
$ docker run -p 5001:5000 \
    -e "FLASK_APP=project/__init__.py" -e "FLASK_DEBUG=1" \
    hello_flask python /usr/src/app/manage.py run -h 0.0.0.0 

您应该能够在 http://localhost:5001 查看 hello world 健全性检查。

格尼科恩

接下来,对于生产环境,让我们将 Gunicorn ,一个生产级的 WSGI 服务器,添加到需求文件中:

`Flask==2.2.2
Flask-SQLAlchemy==2.5.1
gunicorn==20.1.0
psycopg2-binary==2.9.4` 

因为我们仍然希望在开发中使用 Flask 的内置服务器,所以为生产创建一个名为 docker-compose.prod.yml 的新合成文件:

`version:  '3.8' services: web: build:  ./services/web command:  gunicorn --bind 0.0.0.0:5000 manage:app ports: -  5000:5000 env_file: -  ./.env.prod depends_on: -  db db: image:  postgres:13-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ env_file: -  ./.env.prod.db volumes: postgres_data_prod:` 

如果您有多个环境,您可能希望使用一个docker-compose . override . yml配置文件。使用这种方法,您可以将您的基本配置添加到一个 docker-compose.yml 文件中,然后使用一个docker-compose . override . yml文件根据环境覆盖那些配置设置。

记下默认值command。我们运行的是 Gunicorn,而不是 Flask 开发服务器。我们还从web服务中删除了这个卷,因为我们在生产中不需要它。最后,我们使用单独的环境变量文件来定义两个服务的环境变量,它们将在运行时传递给容器。

.env.prod :

`FLASK_APP=project/__init__.py FLASK_DEBUG=0 DATABASE_URL=postgresql://hello_flask:hello_flask@db:5432/hello_flask_prod SQL_HOST=db SQL_PORT=5432 DATABASE=postgres` 

.env.prod.db :

`POSTGRES_USER=hello_flask
POSTGRES_PASSWORD=hello_flask
POSTGRES_DB=hello_flask_prod` 

将这两个文件添加到项目根目录。您可能想让它们不受版本控制,所以将它们添加到一个中。gitignore 文件。

下放到开发容器(以及带有-v标志的相关卷):

然后,构建生产映像并启动容器:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

验证hello_flask_prod数据库是与users表一起创建的。测试出 http://localhost:5000/

同样,如果容器启动失败,通过docker-compose -f docker-compose.prod.yml logs -f检查日志中的错误。

生产文档

您是否注意到我们仍然在运行create_db命令,每次运行容器时,该命令会删除所有现有的表,然后从模型中创建表。这在开发中很好,但是让我们为生产创建一个新的入口点文件。

entry point . 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)"` 

或者,不创建新的入口点文件,您可以修改现有的文件,如下所示:

#!/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

if [ "$FLASK_DEBUG" = "1" ]
then
    echo "Creating the database tables..."
    python manage.py create_db
    echo "Tables created"
fi

exec "[[email protected]](/cdn-cgi/l/email-protection)" 

在本地更新文件权限:

`$ chmod +x services/web/entrypoint.prod.sh` 

要使用这个文件,创建一个名为 Dockerfile.prod 的新 Dockerfile,用于生产构建:

`###########
# BUILDER #
###########

# pull official base image
FROM  python:3.10.7-slim-buster  as  builder

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# install system dependencies
RUN  apt-get update && \
    apt-get install -y --no-install-recommends gcc

# lint
RUN  pip install --upgrade pip
RUN  pip install flake8==5.0.4
COPY  . /usr/src/app/
RUN  flake8 --ignore=E501,F401 .

# install python dependencies
COPY  ./requirements.txt .
RUN  pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt

#########
# FINAL #
#########

# pull official base image
FROM  python:3.10.7-slim-buster

# create directory for the app user
RUN  mkdir -p /home/app

# create the app user
RUN  addgroup --system app && adduser --system --group app

# create the appropriate directories
ENV  HOME=/home/app
ENV  APP_HOME=/home/app/web
RUN  mkdir $APP_HOME
WORKDIR  $APP_HOME

# install dependencies
RUN  apt-get update && apt-get install -y --no-install-recommends netcat
COPY  --from=builder /usr/src/app/wheels /wheels
COPY  --from=builder /usr/src/app/requirements.txt .
RUN  pip install --upgrade pip
RUN  pip install --no-cache /wheels/*

# copy entrypoint-prod.sh
COPY  ./entrypoint.prod.sh $APP_HOME

# copy project
COPY  . $APP_HOME

# chown all the files to the app user
RUN  chown -R app:app $APP_HOME

# change to the app user
USER  app

# run entrypoint.prod.sh
ENTRYPOINT  ["/home/app/web/entrypoint.prod.sh"]` 

在这里,我们使用了一个 Docker 多阶段构建来缩小最终的图像尺寸。本质上,builder是一个用于构建 Python 轮子的临时图像。然后车轮被复制到最终产品图像中,而builder图像被丢弃。

你可以将多阶段构建方法更进一步,使用单个docker 文件而不是创建两个 docker 文件。思考在两个不同的文件上使用这种方法的利弊。

您是否注意到我们创建了一个非 root 用户?默认情况下,Docker 在容器内部以 root 用户身份运行容器进程。这是一种不好的做法,因为如果攻击者设法突破容器,他们可以获得 Docker 主机的根用户访问权限。如果您是容器中的 root 用户,那么您将是主机上的 root 用户。

更新 docker-compose.prod.yml 文件中的web服务,用 Dockerfile.prod 构建:

`web: build: context:  ./services/web dockerfile:  Dockerfile.prod command:  gunicorn --bind 0.0.0.0:5000 manage:app ports: -  5000:5000 env_file: -  ./.env.prod depends_on: -  db` 

尝试一下:

`$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py create_db` 

Nginx

接下来,让我们将 Nginx 添加进来,充当 Gunicorn 的反向代理来处理客户端请求以及提供静态文件。

将服务添加到 docker-compose.prod.yml :

`nginx: build:  ./services/nginx ports: -  1337:80 depends_on: -  web` 

然后,在“服务”目录中,创建以下文件和文件夹:

`└── nginx
    ├── Dockerfile
    └── nginx.conf` 

Dockerfile :

`FROM  nginx:1.23-alpine

RUN  rm /etc/nginx/conf.d/default.conf
COPY  nginx.conf /etc/nginx/conf.d` 

engine . conf:

`upstream hello_flask {
    server web:5000;
}

server {

    listen 80;

    location / {
        proxy_pass http://hello_flask;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

}` 

回顾如何为 Flask Web 应用配置 NGINX以获得更多关于配置 NGINX 与 Flask 一起工作的信息。

然后,更新web服务,在 docker-compose.prod.yml 中,用expose替换ports:

`web: build: context:  ./services/web dockerfile:  Dockerfile.prod command:  gunicorn --bind 0.0.0.0:5000 manage:app expose: -  5000 env_file: -  ./.env.prod depends_on: -  db` 

现在,端口 5000 只在内部对其他 Docker 服务公开。该端口将不再发布到主机上。

关于端口与暴露的更多信息,请查看这个堆栈溢出问题。

再次测试:

`$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py create_db` 

确保应用程序在 http://localhost:1337 启动并运行。

您的项目结构现在应该看起来像这样:

`├── .env.dev
├── .env.prod
├── .env.prod.db
├── .gitignore
├── docker-compose.prod.yml
├── docker-compose.yml
└── services
    ├── nginx
    │   ├── Dockerfile
    │   └── nginx.conf
    └── web
        ├── Dockerfile
        ├── Dockerfile.prod
        ├── entrypoint.prod.sh
        ├── entrypoint.sh
        ├── manage.py
        ├── project
        │   ├── __init__.py
        │   └── config.py
        └── requirements.txt` 

完成后将容器拿下来:

`$ docker-compose -f docker-compose.prod.yml down -v` 

由于 Gunicorn 是一个应用服务器,它不会提供静态文件。那么,在这种特定的配置中,应该如何处理静态文件和媒体文件呢?

静态文件

首先在“服务/web/项目”文件夹中创建以下文件和文件夹:

hello.txt 添加一些文字:

init 添加一个新的路由处理程序。py :

`@app.route("/static/<path:filename>")
def staticfiles(filename):
    return send_from_directory(app.config["STATIC_FOLDER"], filename)` 

不要忘记导入发送自目录:

`from flask import Flask, jsonify, send_from_directory` 

最后,将STATIC_FOLDER配置添加到服务/web/project/config.py

`import os

basedir = os.path.abspath(os.path.dirname(__file__))

class Config(object):
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite://")
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    STATIC_FOLDER = f"{os.getenv('APP_FOLDER')}/project/static"` 

发展

APP_FOLDER环境变量添加到 .env.dev :

`FLASK_APP=project/__init__.py FLASK_DEBUG=1 DATABASE_URL=postgresql://hello_flask:hello_flask@db:5432/hello_flask_dev SQL_HOST=db SQL_PORT=5432 DATABASE=postgres APP_FOLDER=/usr/src/app` 

为了进行测试,首先重新构建映像,并像往常一样旋转新的容器。一旦完成,确保http://localhost:5000/static/hello . txt正确地提供文件。

生产

对于生产,向 docker-compose.prod.yml 中的webnginx服务添加一个卷,这样每个容器将共享一个名为“static”的目录:

`version:  '3.8' services: web: build: context:  ./services/web dockerfile:  Dockerfile.prod command:  gunicorn --bind 0.0.0.0:5000 manage:app volumes: -  static_volume:/home/app/web/project/static expose: -  5000 env_file: -  ./.env.prod depends_on: -  db db: image:  postgres:13-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ env_file: -  ./.env.prod.db nginx: build:  ./services/nginx volumes: -  static_volume:/home/app/web/project/static ports: -  1337:80 depends_on: -  web volumes: postgres_data_prod: static_volume:` 

接下来,更新 Nginx 配置,将静态文件请求路由到“static”文件夹:

`upstream hello_flask {
    server web:5000;
}

server {

    listen 80;

    location / {
        proxy_pass http://hello_flask;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

    location /static/ {
        alias /home/app/web/project/static/;
    }

}` 

APP_FOLDER环境变量添加到 .env.prod :

`FLASK_APP=project/__init__.py FLASK_DEBUG=0 DATABASE_URL=postgresql://hello_flask:hello_flask@db:5432/hello_flask_prod SQL_HOST=db SQL_PORT=5432 DATABASE=postgres APP_FOLDER=/home/app/web` 

这个目录路径来自哪里?将该路径与添加到 .env.dev 的路径进行比较。它们为什么不同呢?

降低开发容器的转速:

测试:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

同样,对http://localhost:1337/static/*的请求将由“静态”目录提供服务。

导航到http://localhost:1337/static/hello . txt并确保静态资产被正确加载。

您还可以通过docker-compose -f docker-compose.prod.yml logs -f在日志中验证对静态文件的请求是否通过 Nginx 成功提供:

`192.168.80.1 - - [11/Oct/2022:15:20:25 +0000] "GET /static/hello.txt HTTP/1.1" 200 4 "-"
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36" "-"` 

完成后带上容器:

`$ docker-compose -f docker-compose.prod.yml down -v` 

为了测试对用户上传的媒体文件的处理,向 init 添加两个新的路由处理程序。py :

`@app.route("/media/<path:filename>")
def mediafiles(filename):
    return send_from_directory(app.config["MEDIA_FOLDER"], filename)

@app.route("/upload", methods=["GET", "POST"])
def upload_file():
    if request.method == "POST":
        file = request.files["file"]
        filename = secure_filename(file.filename)
        file.save(os.path.join(app.config["MEDIA_FOLDER"], filename))
    return """
 <!doctype html>
 <title>upload new File</title>
 <form action="" method=post enctype=multipart/form-data>
 <p><input type=file name=file><input type=submit value=Upload>
 </form>
 """` 

也更新导入:

`import os

from flask import (
    Flask,
    jsonify,
    send_from_directory,
    request,
)
from flask_sqlalchemy import SQLAlchemy
from werkzeug.utils import secure_filename` 

MEDIA_FOLDER配置添加到services/web/project/config . py:

`import os

basedir = os.path.abspath(os.path.dirname(__file__))

class Config(object):
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite://")
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    STATIC_FOLDER = f"{os.getenv('APP_FOLDER')}/project/static"
    MEDIA_FOLDER = f"{os.getenv('APP_FOLDER')}/project/media"` 

最后,在“项目”文件夹中创建一个名为“媒体”的新文件夹。

发展

测试:

`$ docker-compose up -d --build` 

你应该可以在http://localhost:5000/upload上传一张图片,然后在http://localhost:5000/media/IMAGE _ FILE _ NAME查看图片。

生产

对于生产,向webnginx服务添加另一个卷:

`version:  '3.8' services: web: build: context:  ./services/web dockerfile:  Dockerfile.prod command:  gunicorn --bind 0.0.0.0:5000 manage:app volumes: -  static_volume:/home/app/web/project/static -  media_volume:/home/app/web/project/media expose: -  5000 env_file: -  ./.env.prod depends_on: -  db db: image:  postgres:13-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ env_file: -  ./.env.prod.db nginx: build:  ./services/nginx volumes: -  static_volume:/home/app/web/project/static -  media_volume:/home/app/web/project/media ports: -  1337:80 depends_on: -  web volumes: postgres_data_prod: static_volume: media_volume:` 

接下来,更新 Nginx 配置,将媒体文件请求路由到“media”文件夹:

`upstream hello_flask {
    server web:5000;
}

server {

    listen 80;

    location / {
        proxy_pass http://hello_flask;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

    location /static/ {
        alias /home/app/web/project/static/;
    }

    location /media/ {
        alias /home/app/web/project/media/;
    }

}` 

重建:

`$ docker-compose down -v

$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py create_db` 

最后一次测试:

  1. http://localhost:1337/upload上传一张图片。
  2. 然后在http://localhost:1337/media/IMAGE _ FILE _ NAME查看图片。

结论

在本教程中,我们介绍了如何用 Postgres 封装 Flask 应用程序以进行开发。我们还创建了一个生产就绪的 Docker Compose 文件,将 Gunicorn 和 Nginx 添加到混合文件中,以处理静态和媒体文件。现在,您可以在本地测试生产设置。

就生产环境的实际部署而言,您可能希望使用:

  1. 完全托管的数据库服务——像 RDS云 SQL——而不是在一个容器中管理你自己的 Postgres 实例。
  2. dbnginx服务的非根用户

你可以在码头上的烧瓶报告中找到代码。

感谢阅读!

用 Postgres、Gunicorn 和 Nginx 对 Masonite 进行对接

原文:https://testdriven.io/blog/dockerizing-masonite-with-postgres-gunicorn-and-nginx/

这是一个循序渐进的教程,详细介绍了如何配置 Masonite ,一个基于 Python 的 web 框架,在 Docker 和 Postgres 上运行。对于生产环境,我们将添加 Nginx 和 Gunicorn。我们还将看看如何通过 Nginx 提供静态和用户上传的媒体文件。

Masonite 是一个现代的、以开发者为中心的、包含电池的 Python web 框架。如果你是这个框架的新手,可以看看人们选择 Masonite 而不是 Django 的 5 个原因文章。

依赖关系:

  1. mason ite 4 . 16 . 2 版
  2. 文档 v20.10.17
  3. python 3 . 10 . 5 版

项目设置

创建项目目录,安装 Masonite,并创建新的 Masonite 项目:

`$ mkdir masonite-on-docker && cd masonite-on-docker
$ python3.10 -m venv env
$ source env/bin/activate

(env)$ pip install masonite==4.16.2
(env)$ project start web
(env)$ cd web
(env)$ project install
(env)$ python craft serve` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

导航到 http://localhost:8000/ 查看 Masonite 欢迎屏幕。完成后,关闭服务器并退出虚拟环境。继续并删除虚拟环境。我们现在有了一个简单的 Masonite 项目。

接下来,在添加 Docker 之前,让我们稍微清理一下项目结构:

  1. 拆下。环境示例。gitignore 来自“web”目录的文件
  2. 移动。env 文件到项目根目录,并将其重命名为 .env.dev

您的项目结构现在应该如下所示:

`├── .env.dev
└── web
    ├── .env.testing
    ├── Kernel.py
    ├── app
    │   ├── __init__.py
    │   ├── controllers
    │   │   ├── WelcomeController.py
    │   │   └── __init__.py
    │   ├── middlewares
    │   │   ├── AuthenticationMiddleware.py
    │   │   ├── VerifyCsrfToken.py
    │   │   └── __init__.py
    │   ├── models
    │   │   └── User.py
    │   └── providers
    │       ├── AppProvider.py
    │       └── __init__.py
    ├── config
    │   ├── __init__.py
    │   ├── application.py
    │   ├── auth.py
    │   ├── broadcast.py
    │   ├── cache.py
    │   ├── database.py
    │   ├── exceptions.py
    │   ├── filesystem.py
    │   ├── mail.py
    │   ├── notification.py
    │   ├── providers.py
    │   ├── queue.py
    │   ├── security.py
    │   └── session.py
    ├── craft
    ├── databases
    │   ├── migrations
    │   │   ├── 2021_01_09_033202_create_password_reset_table.py
    │   │   └── 2021_01_09_043202_create_users_table.py
    │   └── seeds
    │       ├── __init__.py
    │       ├── database_seeder.py
    │       └── user_table_seeder.py
    ├── makefile
    ├── package.json
    ├── pyproject.toml
    ├── requirements.txt
    ├── resources
    │   ├── css
    │   │   └── app.css
    │   └── js
    │       ├── app.js
    │       └── bootstrap.js
    ├── routes
    │   └── web.py
    ├── setup.cfg
    ├── storage
    │   ├── .gitignore
    │   └── public
    │       ├── favicon.ico
    │       ├── logo.png
    │       └── robots.txt
    ├── templates
    │   ├── __init__.py
    │   ├── base.html
    │   ├── errors
    │   │   ├── 403.html
    │   │   ├── 404.html
    │   │   └── 500.html
    │   ├── maintenance.html
    │   └── welcome.html
    ├── tests
    │   ├── TestCase.py
    │   ├── __init__.py
    │   └── unit
    │       └── test_basic_testcase.py
    ├── webpack.mix.js
    └── wsgi.py` 

码头工人

安装 Docker ,如果你还没有,那么在“web”目录下添加一个 Dockerfile :

`# pull official base image
FROM  python:3.10.5-alpine

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1
ENV  TZ=UTC

# install system dependencies
RUN  apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev openssl-dev cargo

# install dependencies
RUN  pip install --upgrade pip
COPY  ./requirements.txt .
RUN  pip install -r requirements.txt

# copy project
COPY  . .` 

所以,我们从 Python 3.10.5 的基于 AlpineDocker 镜像开始。然后我们设置一个工作目录以及三个环境变量:

  1. PYTHONDONTWRITEBYTECODE:防止 Python 将 pyc 文件写入磁盘(相当于python -B 选项
  2. PYTHONUNBUFFERED:防止 Python 缓冲 stdout 和 stderr(相当于python -u 选项
  3. TZ=UTC将容器中的时区设置为 UTC,这是日志记录所必需的

接下来,我们安装了 Python 所需的一些系统级依赖项。记下openssl-devcargo的依赖关系。这些是必需的,因为密码术现在依赖于 Rust 。更多信息,请查看在 Linux 上构建加密技术

最后,我们更新了 Pip,复制了 requirements.txt 文件,安装了依赖项,并复制了 Masonite 应用程序本身。

查看 Docker 针对 Python 开发人员的最佳实践,了解更多关于构造 Docker 文件的信息,以及为基于 Python 的开发配置 Docker 的一些最佳实践。

接下来,将一个 docker-compose.yml 文件添加到项目根:

`version:  '3.8' services: web: build:  ./web command:  python craft serve -p 8000 -b 0.0.0.0 volumes: -  ./web/:/usr/src/app/ ports: -  8000:8000 env_file: -  .env.dev` 

查看合成文件参考,了解该文件如何工作的信息。

让我们通过删除任何未使用的变量来简化 .env.dev :

`APP_DEBUG=True
APP_ENV=local
APP_KEY=zWDMwC0aNfVk8Ao1NyVJC_LiGD9tHJtVn_uCPeaaTNY=
APP_URL=http://localhost:8000
HASHING_FUNCTION=bcrypt

MAIL_DRIVER=terminal

DB_CONNECTION=sqlite
SQLITE_DB_DATABASE=masonite.sqlite3
DB_HOST=127.0.0.1
DB_USERNAME=root
DB_PASSWORD=root
DB_DATABASE=masonite
DB_PORT=3306
DB_LOG=True

QUEUE_DRIVER=async` 

建立形象:

构建映像后,运行容器:

导航到 http://localhost:8000/ 再次查看欢迎屏幕。

如果这不起作用,通过docker-compose logs -f检查日志中的错误。

要测试自动重新加载,首先打开 Docker 日志- docker-compose logs -f -然后在本地对 web/routes/web.py 进行更改:

保存后,您应该会看到应用程序在您的终端中重新加载,如下所示:

`* Detected change in '/usr/src/app/routes/web.py', reloading
* Restarting with watchdog (inotify)` 

确保http://localhost:8000/sample按预期工作。

Postgres

要配置 Postgres,我们需要向 docker-compose.yml 文件添加一个新服务,更新环境变量,并安装 Psycopg2

首先,向 docker-compose.yml 添加一个名为db的新服务:

`version:  '3.8' services: web: build:  ./web command:  python craft serve -p 8000 -b 0.0.0.0 volumes: -  ./web/:/usr/src/app/ ports: -  8000:8000 env_file: -  .env.dev depends_on: -  db db: image:  postgres:14.4-alpine volumes: -  postgres_data_dev:/var/lib/postgresql/data/ environment: -  POSTGRES_USER=hello_masonite -  POSTGRES_PASSWORD=hello_masonite -  POSTGRES_DB=hello_masonite_dev volumes: postgres_data_dev:` 

为了在容器的生命周期之外保存数据,我们配置了一个卷。这个配置将把postgres_data_dev绑定到容器中的“/var/lib/postgresql/data/”目录。

我们还添加了一个环境键来定义默认数据库的名称,并设置用户名和密码。

查看 Postgres Docker Hub 页面的“环境变量”部分了解更多信息。

.env.dev 文件中更新以下与数据库相关的环境变量:

`DB_CONNECTION=postgres
DB_HOST=db
DB_PORT=5432
DB_DATABASE=hello_masonite_dev
DB_USERNAME=hello_masonite
DB_PASSWORD=hello_masonite` 

查看 web/config/database.py 文件,了解如何根据为 Masonite 项目定义的环境变量来配置数据库。

更新 docker 文件以安装 Psycopg2 所需的相应软件包:

`# pull official base image
FROM  python:3.10.5-alpine

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1
ENV  TZ=UTC

# install system dependencies
RUN  apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev openssl-dev cargo \
    postgresql-dev

# install dependencies
RUN  pip install --upgrade pip
COPY  ./requirements.txt .
RUN  pip install -r requirements.txt

# copy project
COPY  . .` 

将 Psycopg2 添加到 web/requirements.txt :

`masonite>=4.0,<5.0
masonite-orm>=2.0,<3.0
psycopg2-binary==2.9.3` 

查看本期 GitHub了解更多关于在基于 Alpine 的 Docker 映像中安装 Psycopg2 的信息。

构建新的映像并旋转两个容器:

`$ docker-compose up -d --build` 

应用迁移(从“web/数据库/迁移”文件夹):

`$ docker-compose exec web python craft migrate` 

您应该看到:

`Migrating: 2021_01_09_033202_create_password_reset_table
Migrated: 2021_01_09_033202_create_password_reset_table (0.01s)
Migrating: 2021_01_09_043202_create_users_table
Migrated: 2021_01_09_043202_create_users_table (0.02s)` 

确保users表已创建:

`$ docker-compose exec db psql --username=hello_masonite --dbname=hello_masonite_dev

psql (14.4)
Type "help" for help.

hello_masonite_dev=# \l
                                              List of databases
        Name        |     Owner      | Encoding |  Collate   |   Ctype    |         Access privileges
--------------------+----------------+----------+------------+------------+-----------------------------------
 hello_masonite_dev | hello_masonite | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres           | hello_masonite | UTF8     | en_US.utf8 | en_US.utf8 |
 template0          | hello_masonite | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_masonite                +
                    |                |          |            |            | hello_masonite=CTc/hello_masonite
 template1          | hello_masonite | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_masonite                +
                    |                |          |            |            | hello_masonite=CTc/hello_masonite
(4 rows)

hello_masonite_dev=# \c hello_masonite_dev
You are now connected to database "hello_masonite_dev" as user "hello_masonite".

hello_masonite_dev=# \dt
              List of relations
 Schema |    Name         | Type  |     Owner
--------+-----------------+-------+----------------
 public | migrations      | table | hello_masonite
 public | password_resets | table | hello_masonite
 public | users           | table | hello_masonite
(3 rows)

hello_masonite_dev=# \q` 

您也可以通过运行以下命令来检查该卷是否已创建:

`$ docker volume inspect masonite-on-docker_postgres_data_dev` 

您应该会看到类似如下的内容:

`[
    {
        "CreatedAt": "2022-07-22T20:07:50Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "masonite-on-docker",
            "com.docker.compose.version": "2.6.1",
            "com.docker.compose.volume": "postgres_data_dev"
        },
        "Mountpoint": "/var/lib/docker/volumes/masonite-on-docker_postgres_data_dev/_data",
        "Name": "masonite-on-docker_postgres_data_dev",
        "Options": null,
        "Scope": "local"
    }
]` 

接下来,将一个 entrypoint.sh 文件添加到“web”目录,以在应用迁移和运行 Masonite 开发服务器之前,验证 Postgres 是否启动健康:

`#!/bin/sh

if [ "$DB_CONNECTION" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $DB_HOST $DB_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

python craft migrate:refresh  # you may want to remove this
python craft migrate

exec "[[email protected]](/cdn-cgi/l/email-protection)"` 

记下环境变量。

然后,更新 Dockerfile 来运行 entrypoint.sh 文件,作为 Docker entrypoint 命令:

`# pull official base image
FROM  python:3.10.5-alpine

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1
ENV  TZ=UTC

# install system dependencies
RUN  apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev openssl-dev cargo \
    postgresql-dev

# install dependencies
RUN  pip install --upgrade pip
COPY  ./requirements.txt .
RUN  pip install -r requirements.txt

# copy project
COPY  . .

# run entrypoint.sh
ENTRYPOINT  ["/usr/src/app/entrypoint.sh"]` 

在本地更新文件权限:

`$ chmod +x web/entrypoint.sh` 

再次测试:

  1. 重建图像
  2. 运行容器
  3. 试试 http://localhost:8000/

想播种一些用户?

`$ docker-compose exec web python craft seed:run

$ docker-compose exec db psql --username=hello_masonite --dbname=hello_masonite_dev

psql (14.4)
Type "help" for help.

hello_masonite_dev=# \c hello_masonite_dev
You are now connected to database "hello_masonite_dev" as user "hello_masonite".

hello_masonite_dev=# select count(*) from users;
 count
-------
     1
(1 row)

hello_masonite_dev=# \q` 

格尼科恩

接下来,对于生产环境,让我们将 Gunicorn ,一个生产级的 WSGI 服务器,添加到需求文件中:

`masonite>=4.0,<5.0
masonite-orm>=2.0,<3.0
psycopg2-binary==2.9.3
gunicorn==20.1.0` 

由于我们仍然希望在开发中使用 Masonite 的内置服务器,因此为生产创建一个名为 docker-compose.prod.yml 的新合成文件:

`version:  '3.8' services: web: build:  ./web command:  gunicorn --bind 0.0.0.0:8000 wsgi:application ports: -  8000:8000 env_file: -  .env.prod depends_on: -  db db: image:  postgres:14.4-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ env_file: -  .env.prod.db volumes: postgres_data_prod:` 

如果您有多个环境,您可能希望使用一个docker-compose . override . yml配置文件。使用这种方法,您可以将您的基本配置添加到一个 docker-compose.yml 文件中,然后使用一个docker-compose . override . yml文件根据环境覆盖那些配置设置。

记下默认值command。我们运行的是 Gunicorn 而不是 Masonite 开发服务器。我们还从web服务中删除了这个卷,因为我们在生产中不需要它。最后,我们使用单独的环境变量文件来定义两个服务的环境变量,它们将在运行时传递给容器。

.env.prod :

`APP_DEBUG=False
APP_ENV=prod
APP_KEY=GM28x-FeI1sM72tgtsgikLcT-AryyVOiY8etOGr7q7o=
APP_URL=http://localhost:8000
HASHING_FUNCTION=bcrypt

MAIL_DRIVER=terminal

DB_CONNECTION=postgres
DB_HOST=db
DB_PORT=5432
DB_DATABASE=hello_masonite_prod
DB_USERNAME=hello_masonite
DB_PASSWORD=hello_masonite
DB_LOG=True

QUEUE_DRIVER=async` 

.env.prod.db :

`POSTGRES_USER=hello_masonite
POSTGRES_PASSWORD=hello_masonite
POSTGRES_DB=hello_masonite_prod` 

将这两个文件添加到项目根目录。您可能想让它们不受版本控制,所以将它们添加到一个中。gitignore 文件。

下放到开发容器(以及带有-v标志的相关卷):

然后,构建生产映像并启动容器:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

验证hello_masonite_prod数据库是与users表一起创建的。测试出 http://localhost:8000/

同样,如果容器启动失败,通过docker-compose -f docker-compose.prod.yml logs -f检查日志中的错误。

生产文档

您是否注意到,每次运行容器时,我们仍然在运行 migrate:refresh (清空数据库)和 migrate 命令?这在开发中很好,但是让我们为生产创建一个新的入口点文件。

entry point . prod . sh:

`#!/bin/sh

if [ "$DB_CONNECTION" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $DB_HOST $DB_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

exec "[[email protected]](/cdn-cgi/l/email-protection)"` 

或者,不创建新的入口点文件,您可以修改现有的文件,如下所示:

#!/bin/sh

if [ "$DB_CONNECTION" = "postgres" ]
then
    echo "Waiting for postgres..."

    while ! nc -z $DB_HOST $DB_PORT; do
      sleep 0.1
    done

    echo "PostgreSQL started"
fi

if [ "$APP_ENV" = "local" ]
then
    echo "Refreshing the database..."
    craft migrate:refresh  # you may want to remove this
    echo "Applying migrations..."
    craft migrate
    echo "Tables created"
fi

exec "[[email protected]](/cdn-cgi/l/email-protection)" 

要使用这个文件,创建一个名为 Dockerfile.prod 的新 Dockerfile,用于生产构建:

`###########
# BUILDER #
###########

# pull official base image
FROM  python:3.10.5-alpine  as  builder

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1
ENV  TZ=UTC

# install system dependencies
RUN  apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev openssl-dev cargo \
    postgresql-dev

# lint
RUN  pip install --upgrade pip
RUN  pip install flake8==4.0.1
COPY  . .
RUN  flake8 --ignore=E501,F401,E303,E402 .

# install python dependencies
COPY  ./requirements.txt .
RUN  pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt

#########
# FINAL #
#########

# pull official base image
FROM  python:3.10.5-alpine

# create directory for the app user
RUN  mkdir -p /home/app

# create the app user
RUN  addgroup -S app && adduser -S app -G app

# create the appropriate directories
ENV  HOME=/home/app
ENV  APP_HOME=/home/app/web
RUN  mkdir $APP_HOME
WORKDIR  $APP_HOME

# set environment variables
ENV  TZ=UTC

# install dependencies
RUN  apk update && apk --no-cache add \
    libressl-dev libffi-dev gcc musl-dev python3-dev openssl-dev cargo \
    postgresql-dev
COPY  --from=builder /usr/src/app/wheels /wheels
COPY  --from=builder /usr/src/app/requirements.txt .
RUN  pip install --no-cache /wheels/*

# copy project
COPY  . $APP_HOME
RUN  chmod +x /home/app/web/entrypoint.prod.sh

# chown all the files to the app user
RUN  chown -R app:app $APP_HOME

# change to the app user
USER  app

# run entrypoint.prod.sh
ENTRYPOINT  ["/home/app/web/entrypoint.prod.sh"]` 

在这里,我们使用了一个 Docker 多阶段构建来缩小最终的图像尺寸。本质上,builder是一个用于构建 Python 轮子的临时图像。然后车轮被复制到最终产品图像中,而builder图像被丢弃。

你可以将多阶段构建方法更进一步,使用单个docker 文件而不是创建两个 docker 文件。思考在两个不同的文件上使用这种方法的利弊。

您是否注意到我们创建了一个非 root 用户?默认情况下,Docker 在容器内部以 root 用户身份运行容器进程。这是一种不好的做法,因为如果攻击者设法突破容器,他们可以获得 Docker 主机的根用户访问权限。如果您是容器中的 root 用户,那么您将是主机上的 root 用户。

更新 docker-compose.prod.yml 文件中的web服务,用 Dockerfile.prod 构建:

`web: build: context:  ./web dockerfile:  Dockerfile.prod command:  gunicorn --bind 0.0.0.0:8000 wsgi:application ports: -  8000:8000 env_file: -  .env.prod depends_on: -  db` 

尝试一下:

`$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python craft migrate` 

Nginx

接下来,让我们将 Nginx 添加进来,充当 Gunicorn 的反向代理来处理客户端请求以及提供静态文件。

将服务添加到 docker-compose.prod.yml :

`nginx: build:  ./nginx ports: -  1337:80 depends_on: -  web` 

然后,在本地项目根目录中,创建以下文件和文件夹:

`└── nginx
    ├── Dockerfile
    └── nginx.conf` 

Dockerfile :

`FROM  nginx:1.23.1-alpine

RUN  rm /etc/nginx/conf.d/default.conf
COPY  nginx.conf /etc/nginx/conf.d` 

engine . conf:

`upstream hello_masonite {
    server web:8000;
}

server {

    listen 80;

    location / {
        proxy_pass http://hello_masonite;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

}` 

查看了解 Nginx 配置文件结构和配置上下文以了解 Nginx 配置文件的更多信息。

然后,更新web服务,在 docker-compose.prod.yml 中,用expose替换ports:

`web: build: context:  ./web dockerfile:  Dockerfile.prod command:  gunicorn --bind 0.0.0.0:8000 wsgi:application expose: -  8000 env_file: -  .env.prod depends_on: -  db` 

现在,端口 8000 只在内部对其他 Docker 服务公开。该端口将不再发布到主机上。

关于端口与暴露的更多信息,请查看这个堆栈溢出问题。

再测试一次。

`$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python craft migrate` 

确保应用程序在 http://localhost:1337 启动并运行。

您的项目结构现在应该看起来像这样:

`├── .env.dev
├── .env.prod
├── .env.prod.db
├── .gitignore
├── docker-compose.prod.yml
├── docker-compose.yml
├── nginx
│   ├── Dockerfile
│   └── nginx.conf
└── web
    ├── .env.testing
    ├── Dockerfile
    ├── Dockerfile.prod
    ├── Kernel.py
    ├── app
    │   ├── __init__.py
    │   ├── controllers
    │   │   ├── WelcomeController.py
    │   │   └── __init__.py
    │   ├── middlewares
    │   │   ├── AuthenticationMiddleware.py
    │   │   ├── VerifyCsrfToken.py
    │   │   └── __init__.py
    │   ├── models
    │   │   └── User.py
    │   └── providers
    │       ├── AppProvider.py
    │       └── __init__.py
    ├── config
    │   ├── __init__.py
    │   ├── application.py
    │   ├── auth.py
    │   ├── broadcast.py
    │   ├── cache.py
    │   ├── database.py
    │   ├── exceptions.py
    │   ├── filesystem.py
    │   ├── mail.py
    │   ├── notification.py
    │   ├── providers.py
    │   ├── queue.py
    │   ├── security.py
    │   └── session.py
    ├── craft
    ├── databases
    │   ├── migrations
    │   │   ├── 2021_01_09_033202_create_password_reset_table.py
    │   │   └── 2021_01_09_043202_create_users_table.py
    │   └── seeds
    │       ├── __init__.py
    │       ├── database_seeder.py
    │       └── user_table_seeder.py
    ├── entrypoint.prod.sh
    ├── entrypoint.sh
    ├── makefile
    ├── package.json
    ├── pyproject.toml
    ├── requirements.txt
    ├── resources
    │   ├── css
    │   │   └── app.css
    │   └── js
    │       ├── app.js
    │       └── bootstrap.js
    ├── routes
    │   └── web.py
    ├── setup.cfg
    ├── storage
    │   ├── .gitignore
    │   └── public
    │       ├── favicon.ico
    │       ├── logo.png
    │       └── robots.txt
    ├── templates
    │   ├── __init__.py
    │   ├── base.html
    │   ├── errors
    │   │   ├── 403.html
    │   │   ├── 404.html
    │   │   └── 500.html
    │   ├── maintenance.html
    │   └── welcome.html
    ├── tests
    │   ├── TestCase.py
    │   ├── __init__.py
    │   └── unit
    │       └── test_basic_testcase.py
    ├── webpack.mix.js
    └── wsgi.py` 

完成后将容器拿下来:

`$ docker-compose -f docker-compose.prod.yml down -v` 

由于 Gunicorn 是一个应用服务器,它不会提供静态文件。那么,在这种特定的配置中,应该如何处理静态文件和媒体文件呢?

静态文件

首先,更新 web/config/filesystem.py 中的STATICFILES配置:

`STATICFILES = {
    # folder          # template alias
    "storage/static": "static/",
    "storage/compiled": "static/",
    "storage/uploads": "uploads/",
    "storage/public": "/",
}` 

本质上,存储在“存储/静态”(常规 CSS 和 JS 文件)和“存储/编译”(SASS 和更少的文件)目录中的所有静态文件都将从/static/ URL 提供。

为了启用资产编译,假设您已经安装了 NPM ,安装依赖项:

然后,要编译资产,运行:

有关资产复杂性的更多信息,请查看 Masonite 文档中的编译资产

接下来,为了测试一个常规的静态资产,向“web/storage/static”添加一个名为 hello.txt 的文本文件:

发展

为了进行测试,首先重新构建映像,并像往常一样旋转新的容器。完成后,确保正确加载以下静态资产:

  1. http://localhost:8000/robots . txt(root静态资产)
  2. http://localhost:8000/static/hello . txt(常规静态资产)
  3. http://localhost:8000/static/CSS/app . CSS(编译后的静态资产)

生产

对于生产,向 docker-compose.prod.yml 中的webnginx服务添加一个卷,这样每个容器将共享“存储”目录:

`version:  '3.8' services: web: build: context:  ./web dockerfile:  Dockerfile.prod command:  gunicorn --bind 0.0.0.0:8000 wsgi:application volumes: -  storage_volume:/home/app/web/storage expose: -  8000 env_file: -  .env.prod depends_on: -  db db: image:  postgres:14.4-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ env_file: -  .env.prod.db nginx: build:  ./nginx volumes: -  storage_volume:/home/app/web/storage ports: -  1337:80 depends_on: -  web volumes: postgres_data_prod: storage_volume:` 

接下来,更新 Nginx 配置,将静态文件请求路由到适当的文件夹:

`upstream hello_masonite {
    server web:8000;
}

server {

    listen 80;

    location /static/ {
        alias /home/app/web/storage/static/;
    }

    location ~ ^/(favicon.ico|robots.txt)/  {
        alias /home/app/web/storage/public/;
    }

    location / {
        proxy_pass http://hello_masonite;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

}` 

现在,对静态文件的请求将被适当地提供:

请求 URL 文件夹
/static/* “存储/静态”、“存储/编译”
/favicon.ico,/robots.txt "存储/公共"

降低开发容器的转速:

测试:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

同样,请确保正确加载了以下静态资产:

  1. http://localhost:1337/robots . txt
  2. http://localhost:1337/static/hello . txt
  3. http://localhost:1337/static/CSS/app . CSS

您还可以通过docker-compose -f docker-compose.prod.yml logs -f在日志中验证对静态文件的请求是否通过 Nginx 成功提供:

`nginx_1  | 172.28.0.1 - - [2022-07-20:01:39:43 +0000] "GET /robots.txt HTTP/1.1" 200 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36" "-"
nginx_1  | 172.28.0.1 - - [2022-07-20:01:39:52 +0000] "GET /static/hello.txt HTTP/1.1" 200 4 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36" "-"
nginx_1  | 172.28.0.1 - - [2022-07-20:01:39:59 +0000] "GET /static/css/app.css HTTP/1.1" 200 649 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36" "-"` 

完成后带上容器:

`$ docker-compose -f docker-compose.prod.yml down -v` 

要测试对用户上传的媒体文件的处理,请更新web/templates/welcome . html模板中的内容块:

`{% block content %}
<html>
  <body>
  <form action="/" method="POST" enctype="multipart/form-data">
    {{ csrf_field }}
    <input type="file" name="image_upload">
    <input type="submit" value="submit" />
  </form>
  {% if image_url %}
    <p>File uploaded at: <a href="{{ image_url }}">{{ image_url }}</a></p>
  {% endif %}
  </body>
</html>
{% endblock %}` 

web/app/controllers/welcome controller . py中给WelcomeController添加一个名为upload的新方法:

`def upload(self, storage: Storage, view: View, request: Request):
    filename = storage.disk("local").put_file("image_upload", request.input("image_upload"))
    return view.render("welcome", {"image_url": f"/framework/filesystem/{filename}"})` 

不要忘记进口:

`from masonite.filesystem import Storage
from masonite.request import Request` 

接下来,将控制器连接到 web/routes/web.py 中的新路线:

发展

测试:

`$ docker-compose up -d --build` 

你应该可以在 http://localhost:8000/ 上传一张图片,然后在http://localhost:8000/uploads/IMAGE _ FILE _ NAME查看图片。

生产

对于生产,更新 Nginx 配置以将媒体文件请求路由到“上传”文件夹:

`upstream hello_masonite {
    server web:8000;
}

server {

    listen 80;

    location /static/ {
        alias /home/app/web/storage/static/;
    }

    location ~ ^/(favicon.ico|robots.txt)/  {
        alias /home/app/web/storage/public/;
    }

    location /uploads/ {
        alias /home/app/web/storage/framework/filesystem/image_upload/;
    }

    location / {
        proxy_pass http://hello_masonite;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

}` 

重建:

`$ docker-compose down -v

$ docker-compose -f docker-compose.prod.yml up -d --build` 

最后一次测试:

  1. http://localhost:1337 上传一张图片。
  2. 然后在http://localhost:1337/uploads/IMAGE _ FILE _ NAME查看图片。

结论

在本教程中,我们介绍了如何使用 Postgres 来封装 Masonite 应用程序以进行开发。我们还创建了一个生产就绪的 Docker Compose 文件,将 Gunicorn 和 Nginx 添加到混合文件中,以处理静态和媒体文件。现在,您可以在本地测试生产设置。

就生产环境的实际部署而言,您可能希望使用:

  1. 完全托管的数据库服务——像 RDS云 SQL——而不是在一个容器中管理你自己的 Postgres 实例。
  2. dbnginx服务的非根用户

你可以在 masonite-on-docker repo 中找到代码。

感谢阅读!

记录 Python 代码和项目

原文:https://testdriven.io/blog/documenting-python/

为什么您需要记录您的 Python 代码?你的项目文档应该包括什么?你如何编写和生成文档?

文档是软件开发的重要组成部分。没有适当的文档,内部和外部的涉众很难或者不可能使用和/或维护您的代码。这也使得招募新的开发者变得更加困难。更进一步说,如果没有一种记录和学习的文化,你将会一次又一次地犯同样的错误。不幸的是,许多开发人员将文档视为事后的想法——就像黑胡椒一样,没有经过太多的考虑。

本文着眼于为什么您应该记录您的 Python 代码,以及如何着手去做。

完整 Python 指南:

  1. 现代 Python 环境——依赖性和工作空间管理
  2. Python 中的测试
  3. Python 中的现代测试驱动开发
  4. Python 代码质量
  5. Python 类型检查
  6. 记录 Python 代码和项目(本文!)
  7. Python 项目工作流程

代码注释和文档有什么区别?

文档是一个独立的资源,可以帮助其他人使用你的 API、包、库或框架,而不必阅读源代码。另一方面,注释是为阅读你的源代码的开发者准备的。文档是应该一直存在的东西,但是注释就不一样了。拥有它们很好,但不是必须的。文档应该告诉其他人如何何时使用某物,而注释应该回答为什么的问题:

  1. 为什么要这样做?
  2. 为什么这是这里而不是那里?

哪些问题应该由你的干净代码来回答:

  1. 这是什么?
  2. 这个方法是做什么的?
类型 答案 利益相关者
证明文件 何时和如何 用户
代码注释 为什么 开发商
干净的代码 什么 开发商

文档字符串

正如 PEP-257 所规定的,Python 文档字符串(或 docstring)是一种特殊的“作为模块、函数、类或方法定义中的第一条语句出现的字符串文字”来形成给定对象的__doc__属性。它允许您将文档直接嵌入到源代码中。

例如,假设您有一个名为 temperature.py 的模块,它有一个计算日平均温度的函数。使用 docstrings,您可以像这样记录它:

`"""
The temperature module: Manipulate your temperature easily

Easily calculate daily average temperature
"""

from typing import List

class HighTemperature:
    """Class representing very high temperatures"""

    def __init__(self, value: float):
        """
 :param value: value of temperature
 """

        self.value = value

def daily_average(temperatures: List[float]) -> float:
    """
 Get average daily temperature

 Calculate average temperature from multiple measurements

 :param temperatures: list of temperatures
 :return: average temperature
 """

    return sum(temperatures)/len(temperatures)` 

您可以通过访问__doc__属性来查看为daily_average函数指定的文档字符串:

`>>> from temperature import daily_average
>>>
>>> print(daily_average.__doc__)

    Get average daily temperature

    :param temperatures: list of temperatures
    :return: average temperature` 

您还可以通过使用内置的 help 函数来查看完整的模块级文档字符串:

`>>> import temperature
>>>
>>> help(temperature)` 

值得注意的是,您可以使用带有内置关键字(int、float、def 等等)、类、函数和模块的help函数。

单线与多线

文档字符串可以是单行或多行。无论哪种方式,第一行总是被视为摘要。摘要行可能会被自动索引工具使用,所以它适合一行是很重要的。当使用单行文档字符串时,所有内容都应该在同一行:开始引号、摘要和结束引号。

`class HighTemperature:
    """Class representing very high temperatures"""

    # code starts here` 

当使用多行文档字符串时,结构是这样的:开始引号、摘要、空行、更详细的描述和结束引号。

`def daily_average(temperatures: List[float]) -> float:
    """
 Get average daily temperature

 Calculate average temperature from multiple measurements

 :param temperatures: list of temperatures
 :return: average temperature
 """

    return sum(temperatures) / len(temperatures)` 

除了描述特定函数、类、方法或模块的作用,您还可以指定:

  1. 函数参数
  2. 函数返回
  3. 类别属性
  4. 引发的错误
  5. 限制
  6. 代码示例

格式

四种最常见的格式是:

  1. 谷歌
  2. 重组文本
  3. NumPy
  4. Epytext

选择一个最适合你的,并在整个项目中保持一致。

通过使用 docstrings,你可以用口语明确地表达你的意图来帮助别人(和你未来的自己!)更好地理解何时、何地以及如何使用某些代码。

林挺

您可以像处理代码一样处理文档字符串。Linters 确保文档字符串格式良好,并且与实际实现相匹配,这有助于保持文档的新鲜。

Darglint 是一个流行的 Python 文档 linter。

让我们对 temperature.py 模块进行 lint 处理:

`def daily_average(temperatures: List[float]) -> float:
    """
 Get average daily temperature

 Calculate average temperature from multiple measurements

 :param temperatures: list of temperatures
 :return: average temperature
 """

    return sum(temperatures) / len(temperatures)` 

棉绒:

`$ darglint --docstring-style sphinx temperature.py` 

如果把参数名从temperatures改成temperatures_list会怎么样?

`$ darglint --docstring-style sphinx temperature.py

temperature.py:daily_average:27: DAR102: + temperatures
temperature.py:daily_average:27: DAR101: - temperatures_list` 

代码示例

还可以向 docstrings 添加代码示例,显示函数、方法或类的示例用法。

例如:

`def daily_average(temperatures: List[float], new_param=None) -> float:
    """
 Get average daily temperature

 Calculate average temperature from multiple measurements

 >>> daily_average([10.0, 12.0, 14.0])
 12.0

 :param temperatures: list of temperatures
 :return: Average temperature
 """

    return sum(temperatures)/len(temperatures)` 

代码示例也可以由 pytest 通过 doctest 像任何其他测试一样执行。与林挺一起,这也有助于确保您的文档保持最新,与代码同步。

查看 doctest —通过文档进行测试,了解更多关于doctest的信息。

因此,在上面的例子中,pytest 将断言daily_average([10.0, 12.0, 14.0])等于12.0。要将这个代码示例作为测试运行,您只需使用 doctest-modules 选项运行 pytest:

`$ python -m pytest --doctest-modules temperature.py

=============================== test session starts ===============================
platform darwin -- Python 3.11.0, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/documenting-python
collected 1 item

temperature.py .                                                            [100%]

================================ 1 passed in 0.01s ================================` 

如果将代码示例更改为:

`>>> daily_average([10.0, 12.0, 14.0])
13.0` 
`$ python -m pytest --doctest-modules temperature.py

=============================== test session starts ===============================
platform darwin -- Python 3.11.0, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/documenting-python
collected 1 item

temperature.py F                                                            [100%]

==================================== FAILURES =====================================
_______________________ [doctest] temperature.daily_average _______________________
022
023     Get average daily temperature
024
025     Calculate average temperature from multiple measurements
026
027     >>> daily_average([10.0, 12.0, 14.0])
Expected:
    13.0
Got:
    12.0

/Users/michael/repos/testdriven/documenting-python/temperature.py:27: DocTestFailure
============================= short test summary info =============================
FAILED temperature.py::temperature.daily_average
================================ 1 failed in 0.01s ================================` 

关于 pytest 的更多信息,请查看 Python 文章中的测试。

狮身人面像

将 docstrings 添加到代码中很好,但是您仍然需要将它呈现给用户。

这就是像 SphinxEpydocMKDocs 这样的工具发挥作用的地方,它们将把你的项目的文档字符串转换成 HTML 和 CSS。

斯芬克斯是迄今为止最受欢迎的。它用于为许多开源项目生成文档,如 PythonFlask 。它也是由Read Docs支持的文档工具之一,被数以千计的开源项目使用,例如 RequestsFlake8pytest 等等。

让我们看看它的实际效果。首先按照官方指南下载并安装 Sphinx。

`$ sphinx-quickstart --version

sphinx-quickstart 6.1.3` 

创建新的项目目录:

`$ mkdir sphinx_example
$ cd sphinx_example` 

接下来,添加一个名为 temperature.py 的新文件:

`"""
The temperature module: Manipulate your temperature easily

Easily calculate daily average temperature
"""

from typing import List

class HighTemperature:
    """Class representing very high temperatures"""

    def __init__(self, value: float):
        """
 :param value: value of temperature
 """

        self.value = value

def daily_average(temperatures: List[float]) -> float:
    """
 Get average daily temperature

 :param temperatures: list of temperatures
 :return: average temperature
 """

    return sum(temperatures)/len(temperatures)` 

要为 Sphinx 搭建文件和文件夹,以便在项目根目录中为 temperature.py 创建文档,请运行:

你会被提升几个问题:

`> Separate source and build directories (y/n) [n]: n
> Project name: Temperature
> Author name(s): Your Name
> Project release []: 1.0.0
> Project language [en]: en` 

完成后,“docs”目录应该包含以下文件和文件夹:

`docs
├── Makefile
├── _build
├── _static
├── _templates
├── conf.py
├── index.rst
└── make.bat` 

接下来,让我们更新项目配置。打开 docs/conf.py ,在顶部添加以下内容:

`import os
import sys
sys.path.insert(0, os.path.abspath('..'))` 

现在, autodoc ,用于从 docstrings 中拉入文档,会在“docs”的父文件夹中搜索模块。

将以下扩展名添加到extensions列表中:

`extensions = [
    'sphinx.ext.autodoc',
]` 

打开 docs/index.rst 并编辑它,如下所示:

`Welcome to Temperature documentation!
=====================================

.. automodule:: temperature
    :members:

Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`` 

index.rst 的内容写在 reStructuredText 中,这是一种类似于 Markdown 的文本数据文件格式,但功能更强大,因为它是为编写技术文档而设计的。

注意事项:

  1. 标题是通过在标题下加一个=字符(至少和文本一样长)来创建的:
  2. automodule 指令用于从 Python 模块中收集文档字符串。因此,.. automodule:: temperature告诉 Sphinx 从 temperature.py 模块收集文档字符串。
  3. genindexmodindexsearch指令被用于分别生成通用索引、文档模块索引和搜索页面。

从“docs”目录中,构建文档:

在浏览器中打开docs/_ build/html/index . html。您应该看到:

Sphinx docs

现在,你可以使用类似于 Netlify 的工具或者通过类似于Read Docs的服务自己提供文件。

API 文档

当谈到文档时,不要忘记 API 的文档。您拥有端点及其 URL、URL 参数、查询参数、状态代码、请求正文和响应正文。即使一个简单的 API 也可能有许多难以记忆的参数。

OpenAPI 规范(以前的 Swagger 规范)提供了一种描述、生产、消费和可视化 RESTful APIs 的标准格式。该规范用于使用 Swagger UIReDoc 生成文档。也可以导入到 Postman 之类的工具中。您可以使用像 Swagger CodegenOpenAPI Generator 这样的工具生成服务器存根和客户端 SDK。

要获得 OpenAPI 的编辑器、linters、解析器、代码生成器、文档、测试和模式/数据验证工具的完整列表,请查看 OpenAPI 工具

规范本身必须用 YAML 或 JSON 编写。例如:

`--- openapi:  3.0.2 info: title:  Swagger Petstore - OpenAPI 3.0 description:  |- This is a sample Open API version:  1.0.0 servers: -  url:  "/api/v3" paths: "/pet": post: summary:  Add a new pet to the store description:  Add a new pet to the store operationId:  addPet requestBody: description:  Create a new pet in the store content: application/json: schema: "$ref":  "#/components/schemas/Pet" required:  true responses: '200': description:  Successful operation content: application/json: schema: "$ref":  "#/components/schemas/Pet" '405': description:  Invalid input components: schemas: Pet: required: -  name -  photoUrls type:  object properties: id: type:  integer format:  int64 example:  10 name: type:  string example:  doggie photoUrls: type:  array items: type:  string status: type:  string description:  pet status in the store enum: -  available -  pending -  sold requestBodies: Pet: description:  Pet object that needs to be added to the store content: application/json: schema: "$ref":  "#/components/schemas/Pet"` 

手工编写这样的模式非常枯燥,而且容易出错。幸运的是,有许多工具可以帮助自动化这一过程:

作为文档的测试

到目前为止,我们已经讨论了用户文档(项目文档)和开发人员文档(代码注释)。开发人员的另一种文档来自测试本身。

作为一名项目开发人员,你需要知道的不仅仅是如何使用一个方法。你需要知道它是否像预期的那样工作,以及如何使用它来进一步开发。虽然在 docstrings 中添加代码示例会有所帮助,但是这些示例只是简单的示例。您需要添加测试来覆盖不仅仅是函数的快乐路径。

测试记录了三件事:

  1. 给定输入的预期输出是什么
  2. 如何处理异常路径
  3. 如何使用给定的函数、方法或类

当你编写测试时,一定要使用正确的命名,并清楚地说明你要测试的是什么。这将使开发人员更容易审查测试套件,以便找出应该如何使用特定的功能或方法。

更重要的是,在编写测试时,您基本上定义了应该在您的文档字符串中包含什么。将给定,WHEN,THEN 结构可以很容易地转换成函数的 docstrings。

例如:

  • 给定温度测量列表-> :param temperatures: list of temperatures
  • 当调用“每日平均”时-> >>> daily_average([10.0, 12.0, 14.0])
  • 然后返回平均温度-> Get average temperature, :return: Average temperature
`def daily_average(temperatures: List[float]) -> float:
    """
 Get average temperature

 Calculate average temperature from multiple measurements

 >>> daily_average([10.0, 12.0, 14.0])
 12.0

 :param temperatures: list of temperatures
 :return: Average temperature
 """

    return sum(temperatures)/len(temperatures)` 

因此,您可以将测试驱动开发 (TDD)视为文档驱动开发的一种形式,方法是将您的文档字符串创建为代码:

  1. 写一个测试
  2. 确保测试失败
  3. 写代码
  4. 确保测试通过
  5. 重构并添加文档字符串

关于 TDD 的更多信息,请查看 Python 中的现代测试驱动开发文章。

记录 Flask REST API

到目前为止,我们已经讨论了理论,所以让我们来看一个真实的例子。我们将用 Flask 创建一个 RESTful API 来测量温度。每次测量都有以下属性:时间戳、温度、注释。 Flask-RESTX 将用于自动生成 OpenAPI 规范。

那么,我们开始吧。首先,创建一个新文件夹:

`$ mkdir flask_temperature
$ cd flask_temperature` 

接下来,用诗歌初始化你的项目:

`$ poetry init
Package name [flask_temperature]:
Version [0.1.0]:
Description []:
Author [Your name <[[email protected]](/cdn-cgi/l/email-protection)>, n to skip]:
License []:
Compatible Python versions [^3.11]:

Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Do you confirm generation? (yes/no) [yes]` 

之后,添加 Flask 和 Flask-RESTX:

`$ poetry add flask flask-restx` 

现在,让我们创建文档化的 API。为 Flask 应用程序添加一个名为 app.py 的文件:

`import uuid

from flask import Flask, request
from flask_restx import Api, Resource

app = Flask(__name__)
api = Api(app)

measurements = []

@api.route('/measurements')
class Measurement(Resource):
    def get(self):
        return measurements

    def post(self):
        measurement = {
            'id': str(uuid.uuid4()),
            'timestamp': request.json['timestamp'],
            'temperature': request.json['temperature'],
            'notes': request.json.get('notes'),
        }
        measurements.append(measurement)

        return measurement

if __name__ == '__main__':
    app.run()` 

Flask-RESTX 使用基于类的视图来组织资源、路由和 HTTP 方法。在上面的例子中,Measurement类支持 HTTP GET 和 POST 方法。其他方法,会返回一个MethodNotAllowed错误。当应用程序运行时,Flask-RESTX 还将生成 OpenAPI 模式。

可以在http://localhost:5000/swagger . JSON看到模式。您还可以在 http://localhost:5000 查看可浏览的 API。

SwaggerUI

目前,该架构仅包含端点。我们可以定义请求和响应主体来告诉用户对他们的期望以及将返回的内容。

更新 app.py :

`import uuid

from flask import Flask, request
from flask_restx import Api, Resource, fields

app = Flask(__name__)
api = Api(app)

measurements = []

add_measurement_request_body = api.model(
    'AddMeasurementRequestBody', {
        'timestamp': fields.Integer(
            description='Timestamp of measurement',
            required=True,
            example=1606509272
        ),
        'temperature': fields.Float(
            description='Measured temperature',
            required=True, example=22.3),
        'notes': fields.String(
            description='Additional notes',
            required=False, example='Strange day'),
    }
)

measurement_model = api.model(
    'Measurement', {
        'id': fields.String(
            description='Unique ID',
            required=False,
            example='354e405c-136f-4e03-b5ce-5f92e3ed3ff8'
        ),
        'timestamp': fields.Integer(
            description='Timestamp of measurement',
            required=True,
            example=1606509272
        ),
        'temperature': fields.Float(
            description='Measured temperature',
            required=True,
            example=22.3
        ),
        'notes': fields.String(
            description='Additional notes',
            required=True,
            example='Strange day'
        ),
    }
)

@api.route('/measurements')
class Measurement(Resource):
    @api.doc(model=[measurement_model])
    def get(self):
        return measurements

    @api.doc(model=[measurement_model], body=add_measurement_request_body)
    def post(self):
        measurement = {
            'id': str(uuid.uuid4()),
            'timestamp': request.json['timestamp'],
            'temperature': request.json['temperature'],
            'notes': request.json.get('notes'),
        }
        measurements.append(measurement)

        return measurement

if __name__ == '__main__':
    app.run()` 

为了定义我们的响应和请求体的模型,我们使用了api.model。我们定义了名称和适当的字段。对于每个字段,我们定义了类型、描述、示例以及是否需要。

Swagger UI models

为了将模型添加到端点,我们使用了@api.doc装饰器。参数body定义请求体,而model定义响应体。

Swagger UI models

现在你应该对如何用 Flask-RestX 编写 Flask RESTful API 有了基本的了解。这只是触及了表面。查看 Swagger 文档,了解如何定义认证信息、URL 参数、状态代码等更多细节。

结论

我们中的大多数人,如果不是所有人,都可以在编写文档方面做得更好。幸运的是,有很多工具可以简化编写过程。编写包和库时,使用 Sphinx 来组织和帮助从 docstrings 生成文档。当使用 RESTful API 时,使用生成 OpenAPI 模式的工具,因为该模式可以被大量工具使用——从数据验证器到代码生成器。寻找灵感?条纹烧瓶柏树FastAPI 都是做好文档的优秀例子。

完整 Python 指南:

  1. 现代 Python 环境——依赖性和工作空间管理
  2. Python 中的测试
  3. Python 中的现代测试驱动开发
  4. Python 代码质量
  5. Python 类型检查
  6. 记录 Python 代码和项目(本文!)
  7. Python 项目工作流程

Django REST 框架中的权限

原文:https://testdriven.io/blog/drf-permissions/

本文着眼于 Django REST 框架(DRF)中权限的工作方式。

--

Django REST 框架权限系列:

  1. Django REST 框架中的权限(本文!)
  2. Django REST 框架中内置的权限类
  3. Django REST 框架中的自定义权限类

目标

在本文结束时,你应该能够解释:

  1. DRF 权限如何工作
  2. has_permissionhas_object_permission的异同
  3. 何时使用has_permissionhas_object_permission

DRF 权限

在 DRF,权限,以及认证节流,用于授予或拒绝不同类别的用户访问 API 的不同部分。

身份验证和授权是相辅相成的。身份验证总是在授权之前执行。

身份验证是检查用户身份的过程(发出请求的用户、签名的令牌),而授权是检查请求用户是否有执行请求的必要权限的过程(他们是超级用户,还是对象的创建者)。

DRF 的授权过程是由权限覆盖的。

查看权限

APIView 有两种检查权限的方法:

  1. check_permissions根据请求数据检查请求是否被允许
  2. check_object_permissions根据请求和对象数据的组合检查请求是否被允许
`# rest_framework/views.py

class APIView(View):
    # other methods
    def check_permissions(self, request):
        """
 Check if the request should be permitted.
 Raises an appropriate exception if the request is not permitted.
 """
        for permission in self.get_permissions():
            if not permission.has_permission(request, self):
                self.permission_denied(
                    request,
                    message=getattr(permission, 'message', None),
                    code=getattr(permission, 'code', None)
                )

    def check_object_permissions(self, request, obj):
        """
 Check if the request should be permitted for a given object.
 Raises an appropriate exception if the request is not permitted.
 """
        for permission in self.get_permissions():
            if not permission.has_object_permission(request, self, obj):
                self.permission_denied(
                    request,
                    message=getattr(permission, 'message', None),
                    code=getattr(permission, 'code', None)
                )` 

当请求进来时,执行身份验证。如果认证不成功,就会出现一个NotAuthenticated错误。之后,在一个循环中检查权限,如果其中任何一个失败,就会引发一个PermissionDenied错误。最后,对请求执行节流检查。

在执行视图处理程序之前调用check_permissions,而不执行check_object_permissions,除非您显式调用它。例如:

`class MessageSingleAPI(APIView):

    def get(self, request, pk):
        message = get_object_or_404(Message.objects.all(), pk=pk)
        self.check_object_permissions(request, message) # explicitly called
        serializer = MessageSerializer(message)
        return Response(serializer.data)` 

使用视图集通用视图,从数据库中检索所有详细视图的对象后,调用check_object_permissions

`# rest_framework/generics.py

class GenericAPIView(views.APIView):
    # other methods
    def get_object(self):
        """
 Returns the object the view is displaying.

 You may want to override this if you need to provide non-standard
 queryset lookups.  Eg if objects are referenced using multiple
 keyword arguments in the url conf.
 """
        queryset = self.filter_queryset(self.get_queryset())

        # Perform the lookup filtering.
        lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field

        assert lookup_url_kwarg in self.kwargs, (
            'Expected view %s to be called with a URL keyword argument '
            'named "%s". Fix your URL conf, or set the `.lookup_field` '
            'attribute on the view correctly.' %
            (self.__class__.__name__, lookup_url_kwarg)
        )

        filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
        obj = get_object_or_404(queryset, **filter_kwargs)

        # May raise a permission denied
        self.check_object_permissions(self.request, obj)  # HERE

        return obj` 

对所有权限进行检查,如果其中任何一个权限返回False,就会引发PermissionDenied错误。

权限类别

DRF 中的权限被定义为权限类列表。你可以创建自己的或者使用七个内置类中的一个。所有权限类,无论是自定义的还是内置的,都是从BasePermission类扩展而来的:

`class BasePermission(metaclass=BasePermissionMetaclass):

    def has_permission(self, request, view):
        return True

    def has_object_permission(self, request, view, obj):
        return True` 

如您所见,BasePermission有两个方法,has_permissionhas_object_permission,它们都返回True。权限类覆盖一个或两个方法来有条件地返回True

回到文章开头的check_permissionscheck_object_permissions方法:

  • check_permissions为每个权限调用has_permission
  • check_object_permissions为每个权限调用has_object_permission

拥有 _ 权限

has_permission用于决定是否允许请求和用户访问特定视图

例如:

  • 是否允许请求方法?
  • 用户是否经过身份验证?
  • 用户是管理员还是超级用户?

拥有关于请求的知识,但不知道请求的对象。

如开头所解释的,has_permission(由check_permissions调用)在视图处理程序执行之前被执行,而不是显式调用它。

拥有 _ 对象 _ 权限

has_object_permission用于决定是否允许特定用户与特定对象进行交互

例如:

  • 谁创建了该对象?
  • 它是什么时候创建的?
  • 该对象属于哪个组?

除了请求的知识之外,has_object_permission还拥有关于请求对象的数据。方法在从数据库中检索到对象后执行。

has_permission不同,has_object_permission并不总是默认执行:

  • 使用APIView,必须显式调用check_object_permission来为所有权限类执行has_object_permission
  • 对于ViewSets(如ModelViewSet)或通用视图(如RetrieveAPIView),通过开箱即用的get_object方法中的check_object_permission来执行has_object_permission
  • 对于列表视图(无论从哪个视图扩展)或当请求方法为POST时(因为对象尚不存在),从不执行has_object_permission
  • 当任何一个has_permission返回False时,has_object_permission不会被检查。该请求立即被拒绝。

has _ permission vs has _ object _ permission

Django REST 框架中的has_permissionhas_object_permission有什么区别?

DRF Permissions Execution

同样,对于:

  • 列表视图,只有has_permission被执行,请求被允许或拒绝访问。如果访问被拒绝,对象就永远不会被检索到。
  • 详细视图,执行has_permission,然后仅如果许可,则在检索对象后执行has_object_permission

内置 DRF 权限类

关于内置的 DRF 权限类,它们都覆盖了has_permission,只有DjangoObjectPermissions覆盖了has_object_permission:

权限类别 拥有 _ 权限 拥有 _ 对象 _ 权限
允许任何
已认证
isauthentaicatedorreadonly
IsAdminUser
DjangoModelPermissions
djangodelpermissionsoranonreadonly
DjangoObjectPermissions 通过扩展DjangoModelPermissions

有关内置权限类的更多信息,请务必阅读本系列的第二篇文章,Django REST 框架中的内置权限类

自定义权限类

对于自定义权限类,可以重写一个或两个方法。如果您只覆盖其中一个权限,您需要小心,尤其是当您使用的权限很复杂或者您正在组合多个权限时。has_permissionhas_object_permission都默认为True,所以如果你没有显式地设置它们中的一个,请求的拒绝依赖于你显式设置的那个。

关于定制权限类的更多信息,请务必阅读本系列的第二篇文章,Django REST 框架中的定制权限类。

正确用法

让我们看一个简单的例子:

`from rest_framework import permissions

class AuthorOrReadOnly(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` 

此权限类别仅允许对象的作者访问它:

  1. has_permission中,我们只拒绝未认证用户的许可。此时,我们无法访问该对象,因此我们不知道发出请求的用户是否是所需对象的作者。
  2. 如果用户通过了身份验证,那么在检索到对象后,has_object_permission将被调用,在这里我们检查对象的作者是否与用户相同。

结果:

| | 列表视图 | 详细视图 |
| 拥有 _ 权限 | 向经过身份验证的用户授予权限 | 向经过身份验证的用户授予权限 |
| 拥有 _ 对象 _ 权限 | 没有影响 | 授予对象作者权限 |
| 结果 | 授予认证用户的访问权限 | 如果对象的所有者通过验证,则授予他们访问权限 |

不正确的用法

为了更好地理解发生了什么,让我们来看看一个不符合我们要求的许可:

`from rest_framework import permissions

class AuthenticatedOnly(permissions.BasePermission):

    def has_object_permission(self, request, view, obj):
        if request.user.is_authenticated:
            return True
        return False` 

该权限拒绝未经认证的用户访问,但是检查是在has_object_permission而不是has_permission中完成的。

未经验证的用户的详细视图:

DRF Browsable API Response detail view

即使自动生成的可浏览 API 显示了 delete 按钮,未经身份验证的用户也不能删除消息。

以及未经验证的用户的列表视图:

DRF Browsable API Response list view

到底怎么回事?

  1. 列表视图只检查has_permission。所以,由于自定义类没有,它从BasePermission中检查has_permission,后者无条件返回True
  2. 细节视图首先检查has_permission(同样,总是True)。然后它检查has_object_permission,拒绝未经授权的用户访问。

这就是为什么在这个例子中,未经身份验证的请求不能访问细节视图,但是可以访问列表视图。

| | 列表视图 | 详细视图 |
| 拥有 _ 权限 | 使用无条件授予权限的默认函数 | 使用无条件授予权限的默认函数 |
| 拥有 _ 对象 _ 权限 | 没有影响 | 向经过身份验证的用户授予权限 |
| 结果 | 许可总是被授予 | 被认证的用户被授予权限 |

创建这个权限类只是为了展示这两种方法是如何工作的。你应该使用内置的类IsAuthenticated而不是创建你自己的类。

结论

Django REST 框架中的所有权限,无论是自定义的还是内置的,都利用has_permissionhas_object_permission或两者来限制对 API 端点的访问。

虽然has_permission对于何时可以使用它没有限制,但是它不能访问所需的对象。因此,它更像是一种“通用”权限检查,以确保请求和用户可以访问视图。另一方面,由于has_object_permission可以访问对象,所以标准可以更加具体,但是对于何时可以使用它有许多限制。

请记住,如果您不覆盖这些方法,它们将总是返回True,授予无限制的访问权限。只有has_permission影响对列表视图的访问,而它们都影响对细节视图的访问。

在创建自定义权限类时,了解和理解这两种方法的工作方式尤为重要。

--

Django REST 框架权限系列:

  1. Django REST 框架中的权限(本文!)
  2. Django REST 框架中内置的权限类
  3. Django REST 框架中的自定义权限类

有效地使用 Django REST 框架序列化程序

原文:https://testdriven.io/blog/drf-serializers/

在本文中,我们将通过例子来看看如何更有效地使用 Django REST 框架 (DRF)序列化器。一路上,我们将深入一些高级概念,比如使用source关键字、传递上下文、验证数据等等。

本文假设您已经对 Django REST 框架有了相当的了解。

包括什么?

本文涵盖:

  1. 在字段或对象级别验证数据
  2. 自定义序列化和反序列化输出
  3. 保存时传递附加数据
  4. 将上下文传递给序列化程序
  5. 重命名序列化程序输出字段
  6. 将序列化程序函数响应附加到数据
  7. 从一对一模型中获取数据
  8. 将数据附加到序列化输出
  9. 创建单独的读写序列化程序
  10. 设置只读字段
  11. 处理嵌套序列化

本文中介绍的概念相互之间没有联系。我建议把这篇文章作为一个整体来读,但是你也可以自由地钻研你特别感兴趣的概念。

自定义数据验证

DRF 在反序列化过程中强制执行数据验证,这就是为什么您需要在访问经过验证的数据之前调用is_valid()。如果数据无效,错误将被追加到序列化程序的error属性中,并抛出一个ValidationError

有两种类型的自定义数据验证器:

  1. 自定义字段
  2. 对象级

让我们看一个例子。假设我们有一个Movie模型:

`from django.db import models

class Movie(models.Model):
    title = models.CharField(max_length=128)
    description = models.TextField(max_length=2048)
    release_date = models.DateField()
    rating = models.PositiveSmallIntegerField()

    us_gross = models.IntegerField(default=0)
    worldwide_gross = models.IntegerField(default=0)

    def __str__(self):
        return f'{self.title}'` 

我们的型号有titledescriptionrelease_dateratingus_grossworldwide_gross

我们还有一个简单的ModelSerializer,它序列化所有的字段:

`from rest_framework import serializers
from examples.models import Movie

class MovieSerializer(serializers.ModelSerializer):
    class Meta:
        model = Movie
        fields = '__all__'` 

假设只有当这两个条件都成立时,模型才有效:

  1. rating介于 1 和 10 之间
  2. us_gross小于worldwide_gross

我们可以为此使用定制的数据验证器。

自定义字段验证

自定义字段验证允许我们验证特定的字段。我们可以通过向序列化程序添加validate_<field_name>方法来使用它,如下所示:

`from rest_framework import serializers
from examples.models import Movie

class MovieSerializer(serializers.ModelSerializer):
    class Meta:
        model = Movie
        fields = '__all__'

    def validate_rating(self, value):
        if value < 1 or value > 10:
            raise serializers.ValidationError('Rating has to be between 1 and 10.')
        return value` 

我们的validate_rating方法将确保评分始终保持在 1 到 10 之间。

对象级验证

有时,为了验证字段,您必须将它们相互比较。这时您应该使用对象级验证方法。

示例:

`from rest_framework import serializers
from examples.models import Movie

class MovieSerializer(serializers.ModelSerializer):
    class Meta:
        model = Movie
        fields = '__all__'

    def validate(self, data):
        if data['us_gross'] > data['worldwide_gross']:
            raise serializers.ValidationError('worldwide_gross cannot be bigger than us_gross')
        return data` 

validate方法将确保us_gross永远不会大于worldwide_gross

您应该避免通过self.initial_data访问定制字段验证器中的附加字段。该字典包含原始数据,这意味着您的数据类型不一定匹配所需的数据类型。DRF 还会将验证错误附加到错误的字段中。

功能验证器

如果我们在多个序列化器中使用同一个验证器,我们可以创建一个函数验证器,而不是反复编写相同的代码。让我们编写一个验证器来检查数字是否在 1 到 10 之间:

`def is_rating(value):
    if value < 1:
        raise serializers.ValidationError('Value cannot be lower than 1.')
    elif value > 10:
        raise serializers.ValidationError('Value cannot be higher than 10')` 

我们现在可以像这样把它附加到我们的MovieSerializer中:

`from rest_framework import serializers
from examples.models import Movie

class MovieSerializer(serializers.ModelSerializer):
    rating = IntegerField(validators=[is_rating])
    ...` 

自定义输出

BaseSerializer类中,我们可以覆盖的两个最有用的函数是to_representation()to_internal_value()。通过重写它们,我们可以分别更改序列化和反序列化行为,以追加额外的数据、提取数据和处理关系。

  1. to_representation()允许我们改变序列化输出
  2. to_internal_value()允许我们更改反序列化输出

假设您有以下模型:

`from django.contrib.auth.models import User
from django.db import models

class Resource(models.Model):
    title = models.CharField(max_length=256)
    content = models.TextField()
    liked_by = models.ManyToManyField(to=User)

    def __str__(self):
        return f'{self.title}'` 

每个资源都有一个titlecontentliked_by字段。liked_by代表喜欢该资源的用户。

我们的序列化程序是这样定义的:

`from rest_framework import serializers
from examples.models import Resource

class ResourceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Resource
        fields = '__all__'` 

如果我们序列化一个资源并访问它的data属性,我们将得到以下输出:

`{ "id":  1, "title":  "C++ with examples", "content":  "This is the resource's content.", "liked_by":  [ 2, 3 ] }` 

至表示法()

现在,假设我们想给序列化数据添加一个总的赞数。实现这一点最简单的方法是在我们的序列化程序类中实现to_representation方法:

`from rest_framework import serializers
from examples.models import Resource

class ResourceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Resource
        fields = '__all__'

    def to_representation(self, instance):
        representation = super().to_representation(instance)
        representation['likes'] = instance.liked_by.count()

        return representation` 

这段代码获取当前的表示,将likes附加到它上面,然后返回它。

如果我们序列化另一个资源,我们将得到以下结果:

`{ "id":  1, "title":  "C++ with examples", "content":  "This is the resource's content.", "liked_by":  [ 2, 3 ], "likes":  2 }` 

至内部值()

假设使用我们的 API 的服务在创建资源时向端点附加了不必要的数据:

`{ "info":  { "extra":  "data", ... }, "resource":  { "id":  1, "title":  "C++ with examples", "content":  "This is the resource's content.", "liked_by":  [ 2, 3 ], "likes":  2 } }` 

如果我们尝试序列化这些数据,我们的序列化程序将会失败,因为它将无法提取资源。

我们可以覆盖to_internal_value()来提取资源数据:

`from rest_framework import serializers
from examples.models import Resource

class ResourceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Resource
        fields = '__all__'

    def to_internal_value(self, data):
        resource_data = data['resource']

        return super().to_internal_value(resource_data)` 

耶!我们的序列化程序现在可以正常工作了。

序列化程序保存

调用save()将创建一个新实例或更新一个现有实例,这取决于在实例化序列化程序类时是否传递了一个现有实例:

`# this creates a new instance
serializer = MySerializer(data=data)

# this updates an existing instance
serializer = MySerializer(instance, data=data)` 

将数据直接传递到保存

有时,您会希望在保存实例时传递额外的数据。这些附加数据可能包括当前用户、当前时间或请求数据等信息。

您可以通过在调用save()时包含额外的关键字参数来做到这一点。例如:

`serializer.save(owner=request.user)` 

请记住,传递给save()的值不会被验证。

序列化程序上下文

有些情况下,您需要向序列化程序传递额外的数据。您可以通过使用 serializer context属性来做到这一点。然后,您可以在序列化器(如to_representation)中或者在验证数据时使用这些数据。

您通过关键字context将数据作为字典传递:

`from rest_framework import serializers
from examples.models import Resource

resource = Resource.objects.get(id=1)
serializer = ResourceSerializer(resource, context={'key': 'value'})` 

然后,您可以从self.context字典中的序列化程序类中获取它,如下所示:

`from rest_framework import serializers
from examples.models import Resource

class ResourceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Resource
        fields = '__all__'

    def to_representation(self, instance):
        representation = super().to_representation(instance)
        representation['key'] = self.context['key']

        return representation` 

我们的串行化器输出现在将包含带有valuekey

源关键字

DRF 序列化器附带了source关键字,它非常强大,可以在多种情况下使用。我们可以用它来:

  1. 重命名序列化程序输出字段
  2. 将序列化程序函数响应附加到数据
  3. 从一对一模型中提取数据

假设您正在构建一个社交网络,每个用户都有自己的UserProfile,它与User模型有一对一的关系:

`from django.contrib.auth.models import User
from django.db import models

class UserProfile(models.Model):
    user = models.OneToOneField(to=User, on_delete=models.CASCADE)
    bio = models.TextField()
    birth_date = models.DateField()

    def __str__(self):
        return f'{self.user.username} profile'` 

我们使用一个ModelSerializer来序列化我们的用户:

`class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email', 'is_staff', 'is_active']` 

让我们序列化一个用户:

`{ "id":  1, "username":  "admin", "email":  "[[email protected]](/cdn-cgi/l/email-protection)", "is_staff":  true, "is_active":  true }` 

重命名序列化程序输出字段

要重命名序列化程序输出字段,我们需要向序列化程序添加一个新字段,并将其传递给fields属性。

`class UserSerializer(serializers.ModelSerializer):
    active = serializers.BooleanField(source='is_active')

    class Meta:
        model = User
        fields = ['id', 'username', 'email', 'is_staff', 'active']` 

我们的活动字段现在将被命名为active而不是is_active

将序列化程序函数响应附加到数据

我们可以使用source添加一个等于函数返回的字段。

`class UserSerializer(serializers.ModelSerializer):
    full_name = serializers.CharField(source='get_full_name')

    class Meta:
        model = User
        fields = ['id', 'username', 'full_name', 'email', 'is_staff', 'active']` 

get_full_name()是 Django 用户模型中的一个方法,它连接了user.first_nameuser.last_name

我们的响应现在将包含full_name

从一对一模型追加数据

现在让我们假设我们也想在UserSerializer中包含用户的biobirth_date。我们可以通过使用 source 关键字向序列化程序添加额外的字段来做到这一点。

让我们修改序列化程序类:

`class UserSerializer(serializers.ModelSerializer):
    bio = serializers.CharField(source='userprofile.bio')
    birth_date = serializers.DateField(source='userprofile.birth_date')

    class Meta:
        model = User
        fields = [
            'id', 'username', 'email', 'is_staff',
            'is_active', 'bio', 'birth_date'
        ]  # note we also added the new fields here` 

我们可以访问userprofile.<field_name>,因为它与我们的用户是一对一的关系。

这是我们最终的 JSON 回应:

`{ "id":  1, "username":  "admin", "email":  "", "is_staff":  true, "is_active":  true, "bio":  "This is my bio.", "birth_date":  "1995-04-27" }` 

SerializerMethodField

SerializerMethodField是一个只读字段,它通过调用它所附加到的序列化程序类上的方法来获取其值。它可用于将任何类型的数据附加到对象的序列化表示中。

SerializerMethodField通过调用get_<field_name>获取其数据。

如果我们想给我们的User序列化器添加一个full_name属性,我们可以这样实现:

`from django.contrib.auth.models import User
from rest_framework import serializers

class UserSerializer(serializers.ModelSerializer):
    full_name = serializers.SerializerMethodField()

    class Meta:
        model = User
        fields = '__all__'

    def get_full_name(self, obj):
        return f'{obj.first_name}  {obj.last_name}'` 

这段代码创建了一个用户序列化器,它也包含了get_full_name()函数的结果full_name

不同的读写序列化程序

如果您的序列化程序包含大量的嵌套数据,这对于写操作不是必需的,您可以通过创建单独的读和写序列化程序来提高 API 性能。

您可以这样做,在您的ViewSet中覆盖get_serializer_class()方法,如下所示:

`from rest_framework import viewsets

from .models import MyModel
from .serializers import MyModelWriteSerializer, MyModelReadSerializer

class MyViewSet(viewsets.ModelViewSet):
    queryset = MyModel.objects.all()

    def get_serializer_class(self):
        if self.action in ["create", "update", "partial_update", "destroy"]:
            return MyModelWriteSerializer

        return MyModelReadSerializer` 

这段代码检查使用了什么 REST 操作,并为写操作返回MyModelWriteSerializer,为读操作返回MyModelReadSerializer

只读字段

序列化器字段带有read_only选项。通过将它设置为True,DRF 在 API 输出中包含该字段,但是在创建和更新操作中忽略它:

`from rest_framework import serializers

class AccountSerializer(serializers.Serializer):
    id = IntegerField(label='ID', read_only=True)
    username = CharField(max_length=32, required=True)` 

设置idcreate_date等字段。只读将会在写入操作时提高性能。

如果您想将多个字段设置为read_only,您可以使用Meta中的read_only_fields来指定它们,如下所示:

`from rest_framework import serializers

class AccountSerializer(serializers.Serializer):
    id = IntegerField(label='ID')
    username = CharField(max_length=32, required=True)

    class Meta:
        read_only_fields = ['id', 'username']` 

嵌套序列化程序

ModelSerializer处理嵌套序列化有两种不同的方式:

  1. 明确定义
  2. 使用depth字段

明确定义

显式定义的工作方式是将一个外部的Serializer作为一个字段传递给我们的主序列化程序。

让我们看一个例子。我们有一个Comment,它是这样定义的:

`from django.contrib.auth.models import User
from django.db import models

class Comment(models.Model):
    author = models.ForeignKey(to=User, on_delete=models.CASCADE)
    datetime = models.DateTimeField(auto_now_add=True)
    content = models.TextField()` 

假设您有以下序列化程序:

`from rest_framework import serializers

class CommentSerializer(serializers.ModelSerializer):
    author = UserSerializer()

    class Meta:
        model = Comment
        fields = '__all__'` 

如果我们序列化一个Comment,你会得到如下输出:

`{ "id":  1, "datetime":  "2021-03-19T21:51:44.775609Z", "content":  "This is an interesting message.", "author":  1 }` 

如果我们还想序列化用户(而不是只显示他们的 ID),我们可以向我们的Comment添加一个author序列化器字段:

`from rest_framework import serializers

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username']

class CommentSerializer(serializers.ModelSerializer):
    author = UserSerializer()

    class Meta:
        model = Comment
        fields = '__all__'` 

再次连载,你会得到这个:

`{ "id":  1, "author":  { "id":  1, "username":  "admin" }, "datetime":  "2021-03-19T21:51:44.775609Z", "content":  "This is an interesting message." }` 

使用深度场

谈到嵌套序列化,depth字段是最强大的特性之一。假设我们有三个模型- ModelAModelBModelCModelA取决于ModelB,而ModelB取决于ModelC。它们是这样定义的:

`from django.db import models

class ModelC(models.Model):
    content = models.CharField(max_length=128)

class ModelB(models.Model):
    model_c = models.ForeignKey(to=ModelC, on_delete=models.CASCADE)
    content = models.CharField(max_length=128)

class ModelA(models.Model):
    model_b = models.ForeignKey(to=ModelB, on_delete=models.CASCADE)
    content = models.CharField(max_length=128)` 

我们的ModelA序列化器是顶级对象,看起来像这样:

`from rest_framework import serializers

class ModelASerializer(serializers.ModelSerializer):
    class Meta:
        model = ModelA
        fields = '__all__'` 

如果我们序列化一个示例对象,我们将得到以下输出:

`{ "id":  1, "content":  "A content", "model_b":  1 }` 

现在假设我们也想在序列化ModelA时包含ModelB的内容。我们可以将显式定义添加到我们的ModelASerializer中,或者使用depth字段。

当我们在序列化器中将depth改为1时,如下所示:

`from rest_framework import serializers

class ModelASerializer(serializers.ModelSerializer):
    class Meta:
        model = ModelA
        fields = '__all__'
        depth = 1` 

输出更改为以下内容:

`{ "id":  1, "content":  "A content", "model_b":  { "id":  1, "content":  "B content", "model_c":  1 } }` 

如果我们将其更改为2,我们的序列化程序将更深入地序列化:

`{ "id":  1, "content":  "A content", "model_b":  { "id":  1, "content":  "B content", "model_c":  { "id":  1, "content":  "C content" } } }` 

缺点是你无法控制孩子的序列化。换句话说,使用depth将包括子节点上的所有字段。

结论

在本文中,您了解了许多更有效地使用 DRF 序列化程序的技巧和诀窍。

总结我们具体涉及的内容:

概念 方法
在字段或对象级别验证数据 validate_<field_name>validate
自定义序列化和反序列化输出 to_representationto_internal_value
保存时传递附加数据 serializer.save(additional=data)
将上下文传递给序列化程序 SampleSerializer(resource, context={'key': 'value'})
重命名序列化程序输出字段 source关键字
将序列化程序函数响应附加到数据 source关键字
从一对一模型中获取数据 source关键字
将数据附加到序列化输出 SerializerMethodField
创建单独的读写序列化程序 get_serializer_class()
设置只读字段 read_only_fields
处理嵌套序列化 depth字段

Django REST 框架视图- APIViews

原文:https://testdriven.io/blog/drf-views-part-1/

Django REST 框架(DRF)从 Django 的View类继承了自己的视图风格。这个由三部分组成的系列深入探讨了 DRF 视图的所有可能性——从一个简单的视图(您需要自己做很多工作)到ModelViewSet(您只需几行代码就可以让视图运行起来)。因为视图是建立在彼此之上的,所以本系列也解释了它们是如何交织在一起的。

在这篇文章中,我们看看 DRF 的观点是如何工作的,并了解最基本的观点。

--

Django REST 框架视图系列:

  1. APIViews (本文!)
  2. 通用视图
  3. 视图集

目标

完成本文后,您应该能够:

  1. 解释 DRF 观点是如何工作的
  2. 解释APIView类的用途以及它与 Django 的View类有何不同
  3. 使用基于函数和类的视图
  4. 利用策略装饰器(对于基于函数的视图)和策略属性(对于基于类的视图)

DRF 观点

DRF 视图的基本组件是APIView类,它是 Django 的View类的子类。

class 是您可能选择在 DRF 应用程序中使用的所有视图的基础。

不管是-

  • 基于功能的视图
  • 基于类的视图
  • 混合蛋白
  • 通用视图类
  • viewster

-他们都使用APIView类。

正如你从下面的图片中所看到的,你所拥有的关于 DRF 风景的选项相互交织,相互延伸。您可以将视图视为构成更大构建块的构建块。这样,您可能会比其他人更多地使用一些构建块,如 APIViews、concrete views 和(只读)ModelViewSets,如 mixins 和 GenericViewSets。当然,这完全取决于您特定应用的需求。

DRF Views Overview

扩展提供了最大的自由,但也给你留下了更多的工作。如果您需要控制视图的每个方面,或者如果您有非常复杂的视图,这是一个很好的选择。

使用通用视图类,您可以更快地开发,并且仍然对 API 端点有相当多的控制。

使用ModelViewSet s,你可以用五行代码得到一个 API(三行用于你的视图,两行用于 URL)。

上面提到的所有视图也可以定制。

至于用什么没有正确的答案。你甚至不用在单个 app 中使用相同的视图类型;你可以随意混合搭配组合。也就是说,可预测是好的,所以只有在绝对必要的时候才偏离视图类型。

文档中的 DRF 视图分为三个部分。本系列的文章遵循相同的组织结构。

  1. 【APIViews 的文档(本系列的第 1 部分)
  2. 通用视图的文档(本系列的第 2 部分)
  3. 视图集的文档(本系列的第 3 部分)

值得注意的是,官方文档将每个视图视为一个单独的章节,而不是如您所料,从单个视图章节的子章节。

除了 API 指南,还有涵盖所有三种视图的官方教程:

  1. 使用 APIViews 的教程
  2. 使用通用视图的教程
  3. 使用视图集的教程

让我们从最基本的视图APIView开始,然后解释视图是如何工作的。

基于类的视图

基于类的视图扩展了APIView类。通过它们,您可以决定如何处理请求,以及您将使用哪些策略属性。

例如,假设您的购物清单 API 有一个Item类:

`class Item(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    name = models.CharField(max_length=100)
    done = models.BooleanField()` 

这是一个允许用户一次删除所有项目的视图:

`from rest_framework.response import Response
from rest_framework.views import APIView

class DeleteAllItems(APIView):

    def delete(self, request):

        Item.objects.all().delete()

        return Response(status=status.HTTP_204_NO_CONTENT)` 

这是一个列出所有项目的视图:

`from rest_framework.response import Response
from rest_framework.views import APIView

class ListItems(APIView):

    def get(self, request):
        items = Item.objects.all()
        serializer = ItemSerializer(items, many=True)
        return Response(serializer.data)` 

如您所见,对数据库的调用是在处理函数内部完成的。它们是根据请求的 HTTP 方法选择的(例如,GET -> get,DELETE -> delete)。

我们稍后将深入讨论这些视图是如何工作的。

正如您所看到的,我们已经在第二个视图中设置了一个序列化程序。序列化程序负责将复杂的数据(例如,查询和模型实例)转换为本机 Python 数据类型,然后再将其呈现为 JSON、XML 或其他内容类型。

您可以在有效使用 Django REST 框架序列化程序的文章中了解更多关于 DRF 序列化程序的信息。

策略属性

如果您想覆盖基于类的视图的默认设置,您可以使用策略属性

可以设置的策略属性有:

属性 使用 例子
renderer_classes 已确定响应返回的媒体类型 JSONRendererBrowsableAPIRenderer
parser_classes 确定允许不同媒体类型的哪些数据分析器 JSONParserFileUploadParser
authentication_classes 确定允许使用哪些身份验证模式来识别用户 TokenAuthenticationSessionAuthentication
throttle_classes 根据请求率确定是否应该授权请求 AnonRateThrottleUserRateThrottle
permission_classes 确定是否应根据用户凭据授权请求 IsAuthenticatedDjangoModelPermissions
content_negotiation_class 选择资源的多种可能表示形式之一返回给客户机(不太可能需要设置它) 仅自定义内容协商类

请务必阅读 Django REST Framework 文章中的自定义权限类,以了解关于权限类的更多信息。

在下面的示例中,我们使用permission_classesrenderer_classes策略属性更改了权限以及响应的呈现方式:

`from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.views import APIView

class ItemsNotDone(APIView):

    permission_classes = [IsAuthenticated]  # policy attribute
    renderer_classes = [JSONRenderer]       # policy attribute

    def get(self, request):

        user_count = Item.objects.filter(done=False).count()
        content = {'not_done': user_count}

        return Response(content)` 

基于功能的视图

直接实现APIView有两种方式:用函数或者用类。如果您正在以函数的形式编写视图,您将需要使用@api_view装饰器。

@api_view是一个装饰器,它将一个基于函数的视图转换成一个APIView子类(从而提供了ResponseRequest类)。它将视图允许的方法列表作为参数。

好奇 DRF 是如何将基于函数的视图转换成 APIView 子类的吗?

`# https://github.com/encode/django-rest-framework/blob/3.12.4/rest_framework/decorators.py#L16

def api_view(http_method_names=None):
    http_method_names = ['GET'] if (http_method_names is None) else http_method_names
    def decorator(func):
        WrappedAPIView = type(
            'WrappedAPIView',
            (APIView,),
            {'__doc__': func.__doc__}
        )

        # ...

        return WrappedAPIView.as_view()` 

这是一个基于函数的视图,与之前编写的基于类的视图一样,用于删除所有项目:

`from rest_framework.decorators import api_view
from rest_framework.response import Response

@api_view(['DELETE'])
def delete_all_items(request):
    Item.objects.all().delete()
    return Response(status=status.HTTP_200_OK)` 

这里,我们用@api_view装饰器将delete_all_items转换成了一个APIView子类。只允许使用DELETE方法。其他方法将响应“不允许 405 方法”。

忽略类和函数编写方式的差异,我们可以访问相同的属性,因此两个代码片段可以获得相同的结果。

政策装饰者

如果想要覆盖基于函数的视图的默认设置,可以使用策略装饰器。您可以使用以下一个或多个选项:

  • @renderer_classes
  • @parser_classes
  • @authentication_classes
  • @throttle_classes
  • @permission_classes

那些装饰器对应于 APIView 子类。因为@api_view装饰器检查是否使用了以下任何一个装饰器,所以需要将它们添加到api_view装饰器的下面的

如果我们使用与策略属性相同的例子,我们可以像这样实现装饰器,以获得相同的结果:

`from rest_framework.decorators import api_view, permission_classes, renderer_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response

@api_view(['GET'])
@permission_classes([IsAuthenticated])  # policy decorator
@renderer_classes([JSONRenderer])       # policy decorator
def items_not_done(request):
    user_count = Item.objects.filter(done=False).count()
    content = {'not_done': user_count}

    return Response(content)` 

DRF 观点是如何工作的?

当一个请求命中一个视图时,视图首先初始化一个请求对象,这是 Django 的一个 DRF 增强的HttpRequest

与 Django 的HttpRequest相比,它具有以下优势:

  1. 内容根据Content-Type头自动解析,并作为request.data提供。
  2. 它支持上传和修补方法(包括文件上传)。( Django 只支持GETPOST方法。)
  3. 通过临时重写请求上的方法,它根据其他 HTTP 方法检查权限。

在创建了Request实例之后,视图使用提供的(或默认的)内容协商器和呈现器将接受的信息存储在请求中。之后,视图执行身份验证,然后检查权限和任何限制。

身份验证本身不会返回任何错误。它只是确定请求的用户是谁。权限和节流检查需要该信息。在检查权限时,如果认证不成功,则引发NotAuthenticated异常。如果请求不被允许,就会产生一个PermissionDenied异常。在检查节流时,如果请求被节流,就会引发Throttled异常,并通知用户需要等待多长时间请求才能被允许。

权限检查实际上有两部分:check_permissionscheck_object_permissions

在执行视图处理程序之前,调用覆盖一般权限的check_permissions。如果你只是扩展APIViewcheck_object_permissions不会被执行,除非你明确地调用它。如果您正在使用通用视图或视图集,则为详细视图调用check_object_permissions

有关 DRF 权限的更多信息,请查看 Django REST 框架文章中的权限。

在身份验证、授权/许可和限制检查之后,视图检查请求方法是否是以下方法之一:

  • 得到
  • 邮政
  • 修补
  • 删除
  • 选择权
  • 找到;查出

如果是,它检查请求方法是否对应于视图中的方法并执行它。如果其中一个方法不被允许或者没有在被调用的视图中定义,就会引发MethodNotAllowed异常。

APIView类中的dispatch方法检查方法并根据方法名选择一个处理程序:

`# https://github.com/encode/django-rest-framework/blob/3.12.4/rest_framework/views.py#L485

class APIView(View):

    # ...

    def dispatch(self, request, *args, **kwargs):

        # ...

        try:
            self.initial(request, *args, **kwargs)

            # Get the appropriate handler method
            if request.method.lower() in self.http_method_names:
                handler = getattr(self, request.method.lower(),
                                  self.http_method_not_allowed)
            else:
                handler = self.http_method_not_allowed

            response = handler(request, *args, **kwargs)` 

允许的方法在 DRF 没有定义,但取自 Django:

`# https://github.com/django/django/blob/stable/3.2.x/django/views/generic/base.py#L36

class View:
    http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']` 

最后,返回的不是 Django 的HttpResponse响应对象。Django 的HttpResponse和 DRF 的Response的区别在于Response是用未呈现的数据初始化的,允许根据客户端请求将内容呈现为多种内容类型。

结论

在 DRF 有多种类型的观点。最广泛使用的有:

  1. 扩展了APIView类的基于类的视图
  2. 具体的观点
  3. ModelViewSet

它们在可定制性和易用性方面有所不同。对于基于类的视图,您可以在视图内部设置策略(即节流、权限),对于基于函数的视图,您可以使用 decorators 来设置策略。

扩展APIView给了你最大的自由来定制视图本身。

深入了解 Django REST 框架视图系列:

  1. APIViews (本文!)
  2. 通用视图
  3. 视图集

Django REST 框架视图-通用视图

原文:https://testdriven.io/blog/drf-views-part-2/

与数据库模型密切相关的常用视图(比如创建一个模型实例、删除它、列出实例等。)已经预先构建在 Django REST 框架视图(DRF)中。这些可重用的行为被称为通用视图

您可以使用通用视图作为构建块- GenericAPIView和 mixins -来创建您自己的视图。或者您可以使用已经结合了GenericAPIView和适当的 mixins 的即插即用具体视图。

--

Django REST 框架视图系列:

  1. 观点
  2. 通用观点(本文!)
  3. 视图集

目标

在本文结束时,你应该能够解释:

  1. 什么是混合,以及它们是如何一起创建具体视图的
  2. 你使用哪种混音,它们能做什么
  3. 如何创建自定义 mixin 以及如何使用它
  4. 您可以使用哪些通用视图以及它们的作用

通用视图

通用视图是一组常用的模式。

它们构建在APIView类的基础上,我们在本系列的上一篇文章中介绍过这个类。

它们的目的是让您快速构建与您的数据库模型紧密对应的 API 视图,而无需重复自己的工作。

它们由GenericAPIView、混合和具体视图组成:

  1. GenericAPIViewAPIView的更加载版本。它本身并不真正有用,但是可以用来创建可重用的动作。
  2. 混合是一些常见的行为。没有GenericAPIView他们就没用了。
  3. 具体视图将GenericAPIView与适当的 mixins 结合起来,创建 API 中经常使用的视图。

DRF 对具体观点使用不同的名称。在文档和代码注释中,它们可以被发现为具体视图类具体通用视图具体视图

由于名字如此相似,很容易把它们混淆。通用视图是一个既代表混合视图又代表具体视图的词。当使用通用视图时,具体视图可能是您将要使用的级别。

genericapixivw

generic piview是所有其他通用视图的基类。它提供了方法,如get_object / get_querysetget_serializer。尽管它被设计为与 mixins 结合使用(因为它是在通用视图中使用的),但它也可以单独使用:

`from rest_framework.generics import GenericAPIView
from rest_framework.response import Response

class RetrieveDeleteItem(GenericAPIView):

    serializer_class = ItemSerializer
    queryset = Item.objects.all()

    def get(self, request, *args, **kwargs):
        instance = self.get_object()
        serializer = self.get_serializer(instance)
        return Response(serializer.data)

    def delete(self, request, *args, **kwargs):
        instance = self.get_object()
        instance.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)` 

延伸GenericAPIView时,必须设置querysetserializer_class。或者,你可以覆盖get_queryset() / get_serializer_class()

因为有几个 mixins 可以和GenericAPIView一起使用,所以我不建议单独使用它来重新发明轮子。

混合蛋白

Mixins 提供了一些常见的行为。它们不能单独使用;它们必须与GenericAPIView成对出现,以构成功能视图。虽然 mixin 类提供了创建/检索/更新/删除动作,但是您仍然需要将适当的动作绑定到方法上。

可用混音:

米欣 使用
CreateModelMixin 创建模型实例
ListModelMixin 列出查询集
RetrieveModelMixin 检索模型实例
UpdateModelMixin 更新模型实例
DestroyModelMixin 删除模型实例

您可以只使用其中的一种,也可以将它们组合起来以达到预期的效果。

下面是一个 mixin 看起来像什么的例子:

`class RetrieveModelMixin:
    """
 Retrieve a model instance.
 """
    def retrieve(self, request, *args, **kwargs):
        instance = self.get_object()
        serializer = self.get_serializer(instance)
        return Response(serializer.data)` 

如您所见,RetrieveModelMixin提供了一个名为retrieve的函数(一个动作),它从数据库中检索一个对象,并以序列化的形式返回它。

ListModelMixin 和 CreateModelMixin

ListModelMixin 实现一个动作,返回 queryset 的序列化表示(可选分页)。

CreateModelMixin 实现了一个创建并保存新模型实例的动作。

通常,它们一起用于创建一个列表创建 API 端点:

`from rest_framework import mixins
from rest_framework.generics import GenericAPIView

class CreateListItems(mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView):

    serializer_class = ItemSerializer
    queryset = Item.objects.all()

    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)` 

CreateListItems中,我们使用了GenericAPIView提供的serializer_classqueryset

我们自己定义了getpost方法,它们使用了 mixins 提供的listcreate动作:

  • CreateModelMixin提供了一个create动作
  • ListModelMixin提供了一个list动作

将操作绑定到方法

你负责将动作绑定到方法上。

理论上来说,这意味着你可以将 POST 方法与 list 动作绑定,将 GET 方法与 create 动作绑定,这样事情就“有点”正常了。

例如:

`from rest_framework import mixins
from rest_framework.generics import GenericAPIView

class CreateList(mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView):

    serializer_class = ItemSerializer
    queryset = Item.objects.all()

    def get(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
       return self.list(request, *args, **kwargs)` 

这将产生以下结果...

使用 GET 方法:

DRF Browsable API

使用 POST 方法:

DRF Browsable API

免责声明:仅仅因为这是可能的,并不意味着我建议你这么做。这样做的唯一目的是向您展示绑定方法和操作是如何工作的。

RetrieveModelMixin、UpdateModelMixin 和 DestroyModelMixin

RetrieveModelMixinUpdateModelMixinDestroyModelMixin 都处理单个模型实例。

RetrieveModelMixinUpdateModelMixin都返回对象的序列化表示,而DestroyModelMixin在成功的情况下返回HTTP_204_NO_CONTENT

你可以使用其中的一种,也可以根据自己的需要组合使用。

在本例中,我们将所有三者结合成一个端点,用于详细视图的每个可能的操作:

`from rest_framework import mixins
from rest_framework.generics import GenericAPIView

class RetrieveUpdateDeleteItem(
    mixins.RetrieveModelMixin,
    mixins.UpdateModelMixin,
    mixins.DestroyModelMixin,
    GenericAPIView
):

    serializer_class = ItemSerializer
    queryset = Item.objects.all()

    def get(self, request, *args, **kwargs):
        return self.retrieve(request, *args, **kwargs)

    def put(self, request, *args, **kwargs):
        return self.update(request, *args, **kwargs)

    def patch(self, request, *args, **kwargs):
        return self.partial_update(request, *args, **kwargs)

    def delete(self, request, *args, **kwargs):
        return self.destroy(request, *args, **kwargs)` 

提供的行动:

  • RetrieveModelMixin提供了一个retrieve动作
  • UpdateModelMixin提供updatepartial_update动作
  • DestroyModelMixin提供了一个destroy动作

因此,使用RetrieveUpdateDeleteItem端点,用户可以检索、更新或删除单个项目。

您还可以将视图限制为特定操作:

`from rest_framework import mixins
from rest_framework.generics import GenericAPIView

class RetrieveUpdateItem(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, GenericAPIView):

    serializer_class = ItemSerializer
    queryset = Item.objects.all()

    def get(self, request, *args, **kwargs):
        return self.retrieve(request, *args, **kwargs)

    def put(self, request, *args, **kwargs):
        return self.update(request, *args, **kwargs)` 

在这个例子中,我们省略了DestroyModelMixin,只使用了来自UpdateModelMixinupdate动作。

分组混合

最好用一个视图来处理所有实例——列出所有实例并添加一个新实例——用另一个视图来处理单个实例——检索、更新和删除单个实例。

也就是说,你可以按照你认为合适的方式组合混音。例如,你可以组合RetrieveModelMixinCreateModelMixin混音:

`from rest_framework import mixins
from rest_framework.generics import GenericAPIView

class RetrieveCreate(mixins.RetrieveModelMixin, mixins.CreateModelMixin, GenericAPIView):

    serializer_class = ItemSerializer
    queryset = Item.objects.all()

    def get(self, request, *args, **kwargs):
        return self.retrieve(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)` 

这会产生一个端点,用于检索单个实例和添加新实例:

DRF Browsable API

免责声明:同样,仅仅因为这是可能的,并不意味着这是一个好主意。上述示例仅用于教育目的。我不建议在生产代码中使用它。

自定义混音

在现实生活中的应用程序中,您很可能需要一些自定义行为,并且您会希望它出现在不止一个地方。您可以创建一个定制的 mixin,这样您就不需要重复您的代码并将其包含在您的视图类中。

假设您希望根据请求方法使用不同的序列化程序。您可以向视图中添加一些 if 语句,但是这很快就会引起混乱。此外,两个月后,您将添加另一个模型,您需要再次做类似的事情。

在这种情况下,创建一个自定义 mixin 来将序列化器映射到请求方法是一个好主意:

`class SerializerByMethodMixin:
    def get_serializer_class(self, *args, **kwargs):

        return self.serializer_map.get(self.request.method, self.serializer_class)` 

这里,我们重写了来自GenericAPIView类的get_serializer_class方法。

也许你想覆盖其他方法,比如get_querysetget_object?看一看generic piview类中的代码,DRF 的创建者在那里指定了您可能想要覆盖的方法。

现在您只需要将SerializerByMethodMixin添加到您的视图中,并设置serializer_map属性:

`class ListCreateItems(SerializerByMethodMixin, ListCreateAPIView):

    queryset = Item.objects.all()
    serializer_map = {
        'GET': GetItemSerializer,
        'POST': PostItemSerializer,
    }` 

确保将 mixin 作为第一个参数,这样它的方法就不会被覆盖(高优先级优先)。

自定义基类

如果您多次对同一类型的视图使用 mixin,您甚至可以创建一个自定义基类

例如:

`class BaseCreateListView((MixinSingleOrListSerializer, ListCreateAPIView)):
    pass` 

具体的观点

具体观点使用APIView完成了大部分我们需要自己完成的工作。他们使用 mixins 作为基本的构建模块,将构建模块与GenericAPIView结合起来,并将动作绑定到方法上。

快速看一下其中一个具体视图类的代码,ListCreateAPIView:

`# https://github.com/encode/django-rest-framework/blob/3.12.4/rest_framework/generics.py#L232

class ListCreateAPIView(mixins.ListModelMixin,
                        mixins.CreateModelMixin,
                        GenericAPIView):

    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)` 

正如您所看到的,它相当简单,看起来非常类似于我们在使用 mixins 时自己创建的东西。它们扩展了适当的 mixins 和GenericAPIView。它们还定义了每个相关的方法,并将适当的操作绑定到这些方法上。

除非你需要高度定制的行为,否则如果你使用的是通用视图,这是一个合适的视图。

共有九个类,每个类都提供了您可能需要的行为组合:

班级 使用 方法处理程序 扩展混合
CreateAPIView 仅创建 post CreateModelMixin
ListAPIView 对于多个实例为只读 get ListModelMixin
RetrieveAPIView 对于单个实例为只读 get RetrieveModelMixin
DestroyAPIView 删除-仅用于单个实例 delete DestroyModelMixin
UpdateAPIView 仅更新单个实例 putpatch UpdateModelMixin
ListCreateAPIView 多个实例的读写 getpost CreateModelMixinListModelMixin
RetrieveUpdateAPIView 单个实例的读取-更新 getputpatch RetrieveModelMixinUpdateModelMixin
RetrieveDestroyAPIView 单个实例的读-删除 getdelete RetrieveModelMixinDestroyModelMixin
RetrieveUpdateDestroyAPIView 单个实例的读取-更新-删除 getputpatchdelete RetrieveModelMixinUpdateModelMixinDestroyModelMixin

下面是另一个有用的表格,显示了特定的方法处理程序映射回哪个类:

班级 get post put / patch delete
CreateAPIView
ListAPIView
RetrieveAPIView
DestroyAPIView
UpdateAPIView
ListCreateAPIView
RetrieveUpdateAPIView
RetrieveDestroyAPIView
RetrieveUpdateDestroyAPIView

所有从具体视图扩展的类都需要:

  • 查询集
  • 序列化类

此外,您可以提供策略属性,如本系列的第一篇文章中所述。

接下来,我们将看看 9 个具体视图中每一个的实例。

CreateAPIView

在这里,通过扩展CreateAPIView,我们创建了一个端点,用户可以在这里创建一个新项目:

`from rest_framework.generics import CreateAPIView
from rest_framework.permissions import IsAdminUser

class CreateItem(CreateAPIView):
    permission_classes = [IsAdminUser]

    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

我们添加了一个策略属性,以便只有管理员才能访问端点。

listapixivw

这里,我们扩展了ListAPIView来创建一个端点,其中列出了所有“未完成”的项目:

`from rest_framework.generics import ListAPIView

class ItemsNotDone(ListAPIView):

    queryset = Item.objects.all().filter(done=False)
    serializer_class = ItemSerializer` 

我们的 queryset 是基于done字段过滤的。它不包含任何附加策略,因此任何用户都可以访问它。

请记住,这些视图中的每一个都需要单独包含在 URL 中:

`# urls.py

from django.urls import path
from .views import ListItems

urlpatterns = [
   path('all-items', ListItems.as_view())
]` 

retrieveapixivw

ListAPIView返回所有项目的列表时,RetrieveAPIView用于检索单个项目:

`from rest_framework.generics import RetrieveAPIView

class SingleItem(RetrieveAPIView):

    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

RetrieveAPIView的示例 urls.py (以及单个实例的其他视图):

`from django.urls import path
from .views import SingleItem
>
urlpatterns = [
   path('items/<pk>', SingleItem.as_view())
]` 

毁灭视图

扩展DestroyAPIView创建一个端点,唯一的目的是删除一个条目:

`from rest_framework.generics import DestroyAPIView
from rest_framework.permissions import IsAuthenticated

class DeleteItem(DestroyAPIView):
    permission_classes = [IsAuthenticated]

    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

只有经过认证的用户才能访问。

updateapixivw

用于更新单个项目的端点:

`from rest_framework.generics import UpdateAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.throttling import UserRateThrottle

class UpdateItem(UpdateAPIView):

    permission_classes = [IsAuthenticated]
    throttle_classes = [UserRateThrottle]

    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

这里,使用策略属性,我们限制了请求的数量,并将端点限制为经过身份验证的用户。

listcreateapixivw

ListCreateAPIView是第一个具有多个责任的具体视图类,列出了所有项目创建一个新项目:

`from rest_framework.generics import ListCreateAPIView

class ListCreateItems(ListCreateAPIView):

    authentication_classes = [TokenAuthentication]

    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

在本例中,用户使用令牌进行身份验证。

retrieveupdateapixivw

扩展RetrieveUpdateAPIView为检索和更新单个项目的创建一个端点:

`from rest_framework.generics import RetrieveUpdateAPIView

class RetrieveUpdateItem(RetrieveUpdateAPIView):
    renderer_classes = [JSONRenderer]

    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

这里,我们使用策略属性以 JSON 格式返回数据。

检索 DestroyAPIView

在这里,通过扩展RetrieveDestroyAPIView,我们创建了一个端点来检索和删除单个项目的:

`from rest_framework.generics import RetrieveDestroyAPIView

class RetrieveDeleteItem(RetrieveDestroyAPIView):

    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

RetrieveUpdateDestroyAPIView

这里,通过扩展RetrieveUpdateDestroyAPIView,我们创建了一个端点,在这里可以访问单个项目的所有可能的操作:检索、(部分)更新和删除。

`from rest_framework.generics import RetrieveUpdateDestroyAPIView

class RetrieveUpdateDeleteItem(RetrieveUpdateDestroyAPIView):

    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

结论

通用视图提供了各种预构建的解决方案。

如果你没有特别的要求,具体的观点(即RetrieveDestroyAPIView)是一个很好的方法。如果您需要不太严格的东西,您可以使用具体的类作为构建块- GenericAPIView和 mixins(例如,UpdateModelMixin)。这仍然会比仅仅使用APIView类节省一些工作。

DRF Generic Views Overview

Django REST 框架视图系列:

  1. 观点
  2. 通用观点(本文!)
  3. 视图集

Django REST 框架视图-视图集

原文:https://testdriven.io/blog/drf-views-part-3/

到目前为止,我们已经介绍了使用 APIViews通用视图创建单独的视图。通常,将一组相关视图的视图逻辑合并到一个类中是有意义的。这可以在 Django REST 框架(DRF)中通过扩展一个视图集类来实现。

视图集类消除了对额外代码行的需求,当与路由器结合使用时,有助于保持 URL 的一致性。

--

Django REST 框架视图系列:

  1. 观点
  2. 通用视图
  3. ViewSets (本文!)

viewster

视图集是一种基于类的视图。

它不像.get().post()那样提供方法处理程序,而是提供动作,像.list().create()

视图集最显著的优点是 URL 构造是自动处理的(使用路由器类)。这有助于整个 API 中 URL 约定的一致性,并最小化您需要编写的代码量。

从最基本到最强大,共有四种类型的视图集:

  1. 视图集
  2. GenericViewSet
  3. ReadOnlyModelViewSet
  4. 模型视图集

它们大多是基于您在本系列的前一篇文章中了解的类构建的:

DRF ViewSets Overview

是一个所有“奇迹发生”的班级。这是所有四个视图集共享的唯一一个类。它覆盖了as_view方法,并将该方法与适当的动作相结合。

方法 列表/详细信息 行动
post 目录 create
get 目录 list
get 详述 retrieve
put 详述 update
patch 详述 partial_update
delete 详述 destroy

视图集类

ViewSet类利用了APIView类的优势。默认情况下,它不提供任何操作,但是您可以使用它来创建自己的视图集:

`from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet

class ItemViewSet(ViewSet):
    queryset = Item.objects.all()

    def list(self, request):
        serializer = ItemSerializer(self.queryset, many=True)
        return Response(serializer.data)

    def retrieve(self, request, pk=None):
        item = get_object_or_404(self.queryset, pk=pk)
        serializer = ItemSerializer(item)
        return Response(serializer.data)` 

这个视图集提供了GET HTTP 方法,映射到一个list动作(用于列出所有实例)和一个retrieve动作(用于检索单个实例)。

行动

默认情况下,路由器类处理以下动作:

  1. list
  2. create
  3. retrieve(需要 pk)
  4. update(需要 pk)
  5. partial_update(需要 pk)
  6. destroy(需要 pk)

您还可以用@action装饰器创建定制动作。

例如:

`from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet

class ItemsViewSet(ViewSet):

    queryset = Item.objects.all()

    def list(self, request):
        serializer = ItemSerializer(self.queryset, many=True)
        return Response(serializer.data)

    def retrieve(self, request, pk=None):
        item = get_object_or_404(self.queryset, pk=pk)
        serializer = ItemSerializer(item)
        return Response(serializer.data)

    @action(detail=False, methods=['get'])
    def items_not_done(self, request):
        user_count = Item.objects.filter(done=False).count()

        return Response(user_count)` 

这里,我们定义了一个名为items_not_done的定制动作。

允许的 HTTP 方法是 GET。

我们已经在这里显式地设置了它,但是缺省情况下允许 GET。

methods参数是可选的,而detail参数不是。如果动作针对单个对象,则detail参数应设置为True,如果针对所有对象,则应设置为False

默认情况下,可通过以下 URL 访问此操作:/items_not_done。要更改这个 URL,您可以在装饰器中设置url_path参数。

如果你已经使用视图集有一段时间了,你可能会记得@list_route@detail_route装饰器而不是@action。从3.9 版开始,这些已经被弃用。

处理 URL

尽管可以像映射其他视图一样映射视图集的 URL,但这不是视图集的重点。

视图集没有使用 Django 的 urlpatterns ,而是附带了一个路由器类,可以自动生成 URL 配置。

DRF 有两个开箱即用的路由器:

  1. 简单路由器
  2. 默认路由器

它们之间的主要区别是 DefaultRouter 包括一个默认的 API 根视图:

DRF Browsable API

默认的 API 根视图列出了超链接列表视图,这使得在应用程序中导航更加容易。

也可以创建一个定制路由器

路由器也可以与 urlpatterns 结合使用:

`# urls.py

from django.urls import path, include
from rest_framework import routers

from .views import ChangeUserInfo, ItemsViewSet

router = routers.DefaultRouter()
router.register(r'custom-viewset', ItemsViewSet)

urlpatterns = [
    path('change-user-info', ChangeUserInfo.as_view()),
    path('', include(router.urls)),
]` 

这里,我们创建了一个路由器(使用 DefaultRouter,因此我们获得了默认的 API 视图)并向它注册了ItemsViewSet。创建路由器时,必须提供两个参数:

  1. 视图的 URL 前缀
  2. 视图集本身

然后,我们把路由器包含在urlpatterns里面。

这不是包含路由器的唯一方式。更多选项请参考路由器文档。

在开发过程中,在http://127.0.0.1:8000/custom-viewset/可以访问项目列表,在http://127.0.0.1:8000/custom-viewset/{id}/可以访问单个项目。

因为我们只在我们的ItemsViewSet中定义了listretrieve动作,所以唯一允许的方法是 GET。

我们的自定义操作将在http://127.0.0.1:8000/custom-viewset/items_not_done/可用。

路由器是如何将方法映射到操作的:

`# https://github.com/encode/django-rest-framework/blob/3.12.4/rest_framework/routers.py#L83

routes = [
        # List route.
        Route(
            url=r'^{prefix}{trailing_slash}$',
            mapping={
                'get': 'list',
                'post': 'create'
            },
            name='{basename}-list',
            detail=False,
            initkwargs={'suffix': 'List'}
        ),
        # Dynamically generated list routes. Generated using
        # @action(detail=False) decorator on methods of the viewset.
        DynamicRoute(
            url=r'^{prefix}/{url_path}{trailing_slash}$',
            name='{basename}-{url_name}',
            detail=False,
            initkwargs={}
        ),
        # Detail route.
        Route(
            url=r'^{prefix}/{lookup}{trailing_slash}$',
            mapping={
                'get': 'retrieve',
                'put': 'update',
                'patch': 'partial_update',
                'delete': 'destroy'
            },
            name='{basename}-detail',
            detail=True,
            initkwargs={'suffix': 'Instance'}
        ),
        # Dynamically generated detail routes. Generated using
        # @action(detail=True) decorator on methods of the viewset.
        DynamicRoute(
            url=r'^{prefix}/{lookup}/{url_path}{trailing_slash}$',
            name='{basename}-{url_name}',
            detail=True,
            initkwargs={}
        ),
    ]` 

GenericViewSet

ViewSet延伸APIView时,GenericViewSet延伸GenericAPIView

GenericViewSet 类提供了通用视图行为的基本集合以及get_objectget_queryset方法。

这就是ViewSetGenericViewSet类的创建方式:

`# https://github.com/encode/django-rest-framework/blob/3.12.4/rest_framework/viewsets.py#L210
class ViewSet(ViewSetMixin, views.APIView):
   pass

# https://github.com/encode/django-rest-framework/blob/3.12.4/rest_framework/viewsets.py#L217
class GenericViewSet(ViewSetMixin, generics.GenericAPIView):
   pass` 

如你所见,它们都扩展了ViewSetMixinAPIViewGenericAPIView。除此之外,没有额外的代码。

要使用一个GenericViewSet类,您需要覆盖该类,或者使用 mixin 类,或者显式定义动作实现,以获得想要的结果。

将 GenericViewSet 与 Mixins 一起使用

`from rest_framework import mixins, viewsets

class ItemViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):

    serializer_class = ItemSerializer
    queryset = Item.objects.all()` 

这个GenericViewSetListModelMixinRetrieveModelMixin混音组合在一起。因为这是一个视图集,路由器负责 URL 映射,mixins 为列表和细节视图提供动作。

通过显式操作实现使用 genericviewset

使用 mixins 时,只需要提供serializer_classqueryset属性;否则,您将需要自己实现这些操作。

为了强调GenericViewSet相对于ViewSet的优势,我们将使用一个稍微复杂一点的例子:

`from rest_framework import status
from rest_framework.permissions import DjangoObjectPermissions
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet

class ItemViewSet(GenericViewSet):
    serializer_class = ItemSerializer
    queryset = Item.objects.all()
    permission_classes = [DjangoObjectPermissions]

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save(serializer)

        return Response(serializer.data, status=status.HTTP_201_CREATED)

    def list(self, request):
        serializer = self.get_serializer(self.get_queryset(), many=True)
        return self.get_paginated_response(self.paginate_queryset(serializer.data))

    def retrieve(self, request, pk):
        item = self.get_object()
        serializer = self.get_serializer(item)
        return Response(serializer.data)

    def destroy(self, request):
        item = self.get_object()
        item.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)` 

这里,我们创建了一个允许createlistretrievedestroy动作的视图集。

由于我们延长了GenericViewSet,我们:

  1. 使用了DjangoObjectPermissions并且不需要自己检查对象权限
  2. 返回了分页的响应

模型视图集

ModelViewSet 提供了默认的createretrieveupdatepartial_updatedestroylist动作,因为它使用了GenericViewSet和所有可用的混音。

ModelViewSet是所有视图中最容易使用的。您只需要三行代码:

`class ItemModelViewSet(ModelViewSet):
    serializer_class = ItemSerializer
    queryset = Item.objects.all()` 

然后,在您将视图注册到路由器之后,您就可以开始了!

`# urls.py

from django.urls import path, include
from rest_framework import routers

from .views import ChangeUserInfo, ItemsViewSet, ItemModelViewSet

router = routers.DefaultRouter()
router.register(r'custom-viewset', ItemsViewSet)
router.register(r'model-viewset', ItemModelViewSet) # newly registered ViewSet

urlpatterns = [
    path('change-user-info', ChangeUserInfo.as_view()),
    path('', include(router.urls)),
]` 

现在,您可以:

  1. 创建一个项目并列出所有项目
  2. 检索、更新和删除单个项目

ReadOnlyModelViewSet

ReadOnlyModelViewSet 是一个视图集,通过将GenericViewSetRetrieveModelMixinListModelMixin混合在一起,只提供listretrieve动作。

ModelViewSetReadOnlyModelViewSet只需要querysetserializer_class属性就可以工作:

`from rest_framework.viewsets import ReadOnlyModelViewSet

class ItemReadOnlyViewSet(ReadOnlyModelViewSet):

    serializer_class = ItemSerializer
    queryset = Item.objects.all()` 

API 视图、通用视图和视图集摘要

系列摘要:

  • 第一篇第二篇第二篇文章分别介绍了如何通过扩展APIView和通用视图来创建 API 视图。
  • 在本文中,我们介绍了如何用视图集创建 API 视图。

为了深入理解这些视图,我们覆盖了所有的构建模块;但是,在现实生活中,您最有可能使用以下方法之一:

  1. APIView
  2. 具体的观点
  3. ModelViewSet / ReadOnlyModelViewSet

为了快速看出它们之间的区别,让我们来看一下这三者实现相同目标的例子。

三个端点:

  1. 列出所有项目
  2. 创建新项目
  3. 检索、更新和删除单个项目

以下是如何通过扩展 APIView 来实现这一点:

`class ItemsList(APIView):

    def get(self, request, format=None):
        items = Item.objects.all()
        serializer = ItemSerializer(items, many=True)
        return Response(serializer.data)

    def post(self, request, format=None):
        serializer = ItemSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

class ItemDetail(APIView):

    def get(self, request, pk, format=None):
        item = get_object_or_404(Item.objects.all(), pk=pk)
        serializer = ItemSerializer(item)

        return Response(serializer.data)

    def put(self, request, pk, format=None):
        item = get_object_or_404(Item.objects.all(), pk=pk)
        serializer = ItemSerializer(item, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    def delete(self, request, pk, format=None):
        item = get_object_or_404(Item.objects.all(), pk=pk)
        item.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)` 

下面是你如何用具体的通用视图做同样的事情:

`class ItemsListGeneric(ListCreateAPIView):
    queryset = Item.objects.all()
    serializer_class = ItemSerializer

class ItemDetailGeneric(RetrieveUpdateDestroyAPIView):
    queryset = Item.objects.all()
    serializer_class = ItemSerializer` 

以下是您需要使用ModelViewSet的代码行:

`class ItemsViewSet(ModelViewSet):
    serializer_class = ItemSerializer
    queryset = Item.objects.all()` 

最后,下面是各个 URL 配置的样子:

`# APIView

from django.urls import path
from views import ItemsList, ItemDetail

urlpatterns = [
    path('api-view', ItemsList.as_view()),
    path('api-view/<pk>', ItemDetail.as_view()),
]

# generic views

from django.urls import path,
from views import ItemsListGeneric, ItemDetailGeneric

urlpatterns = [
    path('generic-view', ItemsListGeneric.as_view()),
    path('generic-view/<pk>', ItemDetailGeneric.as_view()),
]

# ViewSet

from django.urls import path, include
from rest_framework import routers
from views import ItemsViewSet

router = routers.DefaultRouter()
router.register(r'viewset', ItemsViewSet)

urlpatterns = [
    path('', include(router.urls)),
]` 

结论

DRF 的观点是一张复杂纠结的网:

DRF Views Overview

使用它们有三种核心方法,并有几种子可能性:

  1. 扩展APIView
    • 装饰器也可以使用基于函数的视图
  2. 使用通用视图
    • GenericAPIView是基地
    • GenericAPIView可与一种或多种 mixins 结合
    • 具体视图类已经涵盖了以所有广泛使用的方式将GenericAPIView与 mixins 结合起来
  3. 将所有可能的动作组合成一个类
    • GenericViewSet比基础ViewSet更强大
    • ModelViewSetReadOnlyModelViewSet用最少的代码提供最多的功能

以上所有这些都提供了允许轻松定制的挂钩。

大多数时候,您会发现自己在使用APIView(一个具体的视图类)或(ReadOnly)ModelViewSet。也就是说,当您试图开发一个定制的解决方案时,理解视图是如何构建的以及祖先的优点是很有帮助的。

Django REST 框架视图系列:

  1. 观点
  2. 通用视图
  3. ViewSets (本文!)

基于金库和烧瓶的动态秘密生成

原文:https://testdriven.io/blog/dynamic-secret-generation-with-vault-and-flask/

在本教程中,我们将查看一个快速的真实示例,使用哈希公司的 Vaultconsult为 Flask web 应用程序创建动态 Postgres 凭证。

先决条件

开始之前,您应该:

  1. 对金库和顾问的秘密管理有基本的工作知识。请参考用 Vault 管理秘密和咨询博客帖子了解更多信息。
  2. 使用存储后端部署的 Vault 实例。查看部署金库和领事的帖子,了解如何通过 Docker Swarm 将金库和领事部署到数字海洋。保险存储也应该初始化和解封。
  3. 部署了 Postgres 服务器。如果您没有运行 Postgres,请使用 AWS RDS 自由层
  4. 以前和 Flask 和 Docker 合作过。查看使用 Python、Flask 和 Docker 的测试驱动开发课程,了解更多信息。

入门指南

让我们从一个基本的 Flask web 应用程序开始。

如果你想继续,复制下金库-领事-烧瓶回购,然后查看 v1 分支:

`$ git clone https://github.com/testdrivenio/vault-consul-flask --branch v1 --single-branch
$ cd vault-consul-flask` 

快速浏览一下代码:

`├── .gitignore
├── Dockerfile
├── README.md
├── docker-compose.yml
├── manage.py
├── project
│   ├── __init__.py
│   ├── api
│   │   ├── __init__.py
│   │   ├── main.py
│   │   ├── models.py
│   │   └── users.py
│   └── config.py
└── requirements.txt` 

本质上,为了让这个应用程序工作,我们需要向一个添加以下环境变量。env 文件(我们将很快完成):

  1. DB_USER
  2. DB_PASSWORD
  3. DB_SERVER

项目/配置文件:

`import os

USER = os.environ.get('DB_USER')
PASSWORD = os.environ.get('DB_PASSWORD')
SERVER = os.environ.get('DB_SERVER')

class ProductionConfig():
    """Production configuration"""
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_DATABASE_URI = f'postgresql://{USER}:{PASSWORD}@{SERVER}:5432/users_db'` 

配置保管库

同样,如果您想跟进,您应该有一个部署了存储后端的 Vault 实例。这个实例也应该被初始化和解封。想要快速启动并运行集群吗?从 vault-consul-swarm 运行 deploy.sh 脚本,将 vault 和 consul 集群部署到三个 DigitalOcean droplets。调配和部署只需不到五分钟的时间!

首先,登录 Vault(如有必要),然后从 Vault CLI 启用数据库秘密后端:

`$ vault secrets enable database

Success! Enabled the database secrets engine at: database/` 

添加 Postgres 连接以及数据库引擎插件信息:

`$ vault write database/config/users_db \
    plugin_name="postgresql-database-plugin" \
    connection_url="postgresql://{{username}}:{{password}}@<ENDPOINT>:5432/users_db" \
    allowed_roles="mynewrole" \
    username="<USERNAME>" \
    password="<PASSWORD>"` 

您是否注意到 URL 中有usernamepassword的模板?这用于防止对密码的直接读取访问,并启用凭据轮换。

确保更新数据库端点以及用户名和密码。例如:

`$ vault write database/config/users_db \
    plugin_name="postgresql-database-plugin" \
    connection_url="postgresql://{{username}}:{{password}}@users-db.c7vzuyfvhlgz.us-east-1.rds.amazonaws.com:5432/users_db" \
    allowed_roles="mynewrole" \
    username="vault" \
    password="lOfon7BA3uzZzxGGv36j"` 

这在“database/config/users_db”中创建了一个新的机密路径:

`$ vault list database/config

Keys
----
users_db` 

接下来,创建一个名为mynewrole的新角色:

`$ vault write database/roles/mynewrole \
    db_name=users_db \
    creation_statements="CREATE ROLE \"{{name}}\" \
 WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
 GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
    default_ttl="1h" \
    max_ttl="24h"

Success! Data written to: database/roles/mynewrole` 

这里,我们将 Vault 中的mynewrole名称映射到一个 SQL 语句,该语句在运行时将创建一个拥有数据库中所有权限的新用户。请记住,这实际上还没有创建新用户。记下默认值和最大 TTL。

现在我们准备创建新用户。

创建凭据

快速查看一下psql中有哪些用户:

在项目根目录下创建一个名为 run.sh 的新文件:

`#!/bin/sh

rm -f .env

echo DB_SERVER=<DB_ENDPOINT> >> .env

user=$(curl  -H "X-Vault-Token: $VAULT_TOKEN" \
        -X GET http://<VAULT_ENDPOINT>:8200/v1/database/creds/mynewrole)
echo DB_USER=$(echo $user | jq -r .data.username) >> .env
echo DB_PASSWORD=$(echo $user | jq -r .data.password) >> .env

docker-compose up -d --build` 

因此,这将调用 Vault API 从/creds端点生成一组新的凭证。随后的响应通过 JQ 进行解析,并且凭证被添加到一个中。env 文件。确保更新数据库(DB_ENDPOINT)和 Vault ( VAULT_ENDPOINT)端点。

添加VAULT_TOKEN环境变量:

`$ export VAULT_TOKEN=<YOUR_VAULT_TOKEN>` 

构建映像并旋转容器:

验证环境变量是否已成功添加:

`$ docker-compose exec web env` 

您还应该在数据库中看到该用户:

`Role name                                   | Attributes                                  | Member of
--------------------------------------------+---------------------------------------------+----------
 v-root-mynewrol-jC8Imdx2sMTZj03-1533704364 | Password valid until 2018-08-08 05:59:29+00 | {}` 

创建并植入数据库users表:

`$ docker-compose run web python manage.py recreate-db
$ docker-compose run web python manage.py seed-db` 

在浏览器中进行测试,网址为http://localhost:5000/users:

`{ "status":  "success", "users":  [{ "active":  true, "admin":  false, "email":  "[[email protected]](/cdn-cgi/l/email-protection)", "id":  1, "username":  "michael" }] }` 

完成后取下容器:

结论

就是这样!

请记住,在这个示例中,凭据仅在一个小时内有效。这非常适合短暂的、动态的、一次性的任务。如果您有更长的任务,您可以设置一个 cron 作业来每小时触发一次 run.sh 脚本来获取新的凭证。请记住,最大 TTL 设置为 24 小时。

您可能还想看看如何使用 envconsul 将凭证放入 Flask 的环境中。它甚至可以在凭证更新时重启 Flask。

你可以在金库-领事-烧瓶回购中找到最终代码。

FastAPI 和 Celery 的异步任务

原文:https://testdriven.io/blog/fastapi-and-celery/

如果长时间运行的流程是应用程序工作流的一部分,而不是阻塞响应,您应该在后台处理它,在正常的请求/响应流之外。

也许您的 web 应用程序要求用户在注册时提交一个缩略图(可能需要重新调整大小)并确认他们的电子邮件。如果您的应用程序处理了图像并直接在请求处理程序中发送了确认电子邮件,那么最终用户将不得不在页面加载或更新之前不必要地等待他们完成处理。相反,您会希望将这些进程传递给任务队列,并让一个单独的工作进程来处理它,这样您就可以立即将响应发送回客户端。最终用户可以在处理过程中在客户端做其他事情。您的应用程序还可以自由地响应来自其他用户和客户端的请求。

为了实现这一点,我们将带您完成设置和配置 Celery 和 Redis 的过程,以便在 FastAPI 应用程序中处理长时间运行的流程。我们还将使用 Docker 和 Docker Compose 将所有内容联系在一起。最后,我们将看看如何用单元测试和集成测试来测试 Celery 任务。

目标

本教程结束时,您将能够:

  1. 将芹菜集成到 FastAPI 应用程序中,并创建任务。
  2. 集装箱 FastAPI、Celery 和 Redis with Docker。
  3. 使用单独的工作进程在后台运行进程。
  4. 将芹菜日志保存到文件中。
  5. 设置 Flower 来监控和管理芹菜作业和工人。
  6. 用单元测试和集成测试来测试芹菜任务。

后台任务

同样,为了改善用户体验,长时间运行的流程应该在正常的 HTTP 请求/响应流程之外,在后台进程中运行。

示例:

  1. 运行机器学习模型
  2. 发送确认电子邮件
  3. 刮擦和爬行
  4. 分析数据
  5. 处理图像
  6. 生成报告

当你构建一个应用程序时,试着区分应该在请求/响应生命周期中运行的任务(比如 CRUD 操作)和应该在后台运行的任务。

值得注意的是,你可以利用 FastAPI 的 BackgroundTasks 类在后台运行任务,该类直接来自 Starlette

例如:

`from fastapi import BackgroundTasks

def send_email(email, message):
    pass

@app.get("/")
async def ping(background_tasks: BackgroundTasks):
    background_tasks.add_task(send_email, "[[email protected]](/cdn-cgi/l/email-protection)", "Hi!")
    return {"message": "pong!"}` 

那么,什么时候应该用芹菜代替BackgroundTasks

  1. CPU 密集型任务 : Celery 应该用于执行繁重后台计算的任务,因为BackgroundTasks运行在服务于应用程序请求的同一个事件循环中。
  2. 任务队列:如果你需要一个任务队列来管理任务和工作者,那么你应该使用芹菜。通常,您会希望检索一个作业的状态,然后根据该状态执行一些操作——例如,发送一封错误电子邮件,启动一个不同的后台任务,或者重试该任务。芹菜为你管理这一切。

工作流程

我们的目标是开发一个与 Celery 协同工作的 FastAPI 应用程序,以处理正常请求/响应周期之外的长时间运行的流程。

  1. 最终用户通过向服务器端发送 POST 请求开始一项新任务。
  2. 在路由处理程序中,一个任务被添加到队列中,任务 ID 被发送回客户端。
  3. 使用 AJAX,当任务本身在后台运行时,客户机继续轮询服务器以检查任务的状态。

fastapi and celery queue user flow

项目设置

fastapi-celery repo 中克隆出基础项目,然后将 v1 标签签出到主分支:

`$ git clone https://github.com/testdrivenio/fastapi-celery --branch v1 --single-branch
$ cd fastapi-celery
$ git checkout v1 -b master` 

由于我们总共需要管理三个进程(FastAPI、Redis、Celery worker),我们将使用 Docker 来简化我们的工作流,方法是将它们连接起来,以便它们都可以通过一个命令从一个终端窗口运行。

从项目根目录,创建映像并启动 Docker 容器:

`$ docker-compose up -d --build` 

构建完成后,导航到 http://localhost:8004 :

fastapi project

确保测试也通过:

`$ docker-compose exec web python -m pytest

================================== test session starts ===================================
platform linux -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /usr/src/app
collected 1 item

tests/test_tasks.py .                                                               [100%]

=================================== 1 passed in 0.06s ====================================` 

在继续之前,快速浏览一下项目结构:

`├── .gitignore
├── LICENSE
├── README.md
├── docker-compose.yml
└── project
    ├── Dockerfile
    ├── main.py
    ├── requirements.txt
    ├── static
    │   ├── main.css
    │   └── main.js
    ├── templates
    │   ├── _base.html
    │   ├── footer.html
    │   └── home.html
    └── tests
        ├── __init__.py
        ├── conftest.py
        └── test_tasks.py` 

触发任务

project/templates/home . html中设置了一个onclick事件处理程序来监听按钮点击:

`<div class="btn-group" role="group" aria-label="Basic example">
  <button type="button" class="btn btn-primary" onclick="handleClick(1)">Short</a>
  <button type="button" class="btn btn-primary" onclick="handleClick(2)">Medium</a>
  <button type="button" class="btn btn-primary" onclick="handleClick(3)">Long</a>
</div>` 

onclick调用 project/static/main.js 中的handleClick,它向服务器发送一个 AJAX POST 请求,并带有适当的任务类型:123

`function  handleClick(type)  { fetch('/tasks',  { method:  'POST', headers:  { 'Content-Type':  'application/json' }, body:  JSON.stringify({  type:  type  }), }) .then(response  =>  response.json()) .then(res  =>  getStatus(res.data.task_id)); }` 

在服务器端,已经配置了一个路由来处理 project/main.py 中的请求:

`@app.post("/tasks", status_code=201)
def run_task(payload = Body(...)):
    task_type = payload["type"]
    return JSONResponse(task_type)` 

现在有趣的部分来了——给芹菜布线!

芹菜装置

首先将 Celery 和 Redis 添加到 requirements.txt 文件中:

`aiofiles==0.6.0
celery==4.4.7
fastapi==0.64.0
Jinja2==2.11.3
pytest==6.2.4
redis==3.5.3
requests==2.25.1
uvicorn==0.13.4` 

本教程使用芹菜 v 4.4.7 因为花不支持芹菜 5

Celery 使用消息代理 - RabbitMQRedisAWS 简单队列服务(SQS) -来促进 Celery worker 和 web 应用程序之间的通信。消息被添加到代理中,然后由工作人员进行处理。一旦完成,结果被添加到后端。

Redis 将被用作代理和后端。将 Redis 和芹菜工人添加到 docker-compose.yml 文件中,如下所示:

`version:  '3.8' services: web: build:  ./project ports: -  8004:8000 command:  uvicorn main:app --host 0.0.0.0 --reload volumes: -  ./project:/usr/src/app environment: -  CELERY_BROKER_URL=redis://redis:6379/0 -  CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: -  redis worker: build:  ./project command:  celery worker --app=worker.celery --loglevel=info volumes: -  ./project:/usr/src/app environment: -  CELERY_BROKER_URL=redis://redis:6379/0 -  CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: -  web -  redis redis: image:  redis:6-alpine` 

请注意celery worker --app=worker.celery --loglevel=info:

  1. celery worker是用来开动芹菜的工人
  2. --app=worker.celery运行芹菜应用程序(我们将很快对其进行定义)
  3. --loglevel=info记录级别设置为信息

接下来,在“项目”中创建一个名为 worker.py 的新文件:

`import os
import time

from celery import Celery

celery = Celery(__name__)
celery.conf.broker_url = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379")
celery.conf.result_backend = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379")

@celery.task(name="create_task")
def create_task(task_type):
    time.sleep(int(task_type) * 10)
    return True` 

这里,我们创建了一个新的 Celery 实例,并使用任务装饰器,我们定义了一个名为create_task的新 Celery 任务函数。

请记住,任务本身将由芹菜工人执行。

触发任务

更新路由处理程序以启动任务,并使用任务 ID 进行响应:

`@app.post("/tasks", status_code=201)
def run_task(payload = Body(...)):
    task_type = payload["type"]
    task = create_task.delay(int(task_type))
    return JSONResponse({"task_id": task.id})` 

不要忘记导入任务:

`from worker import create_task` 

构建映像并旋转新容器:

`$ docker-compose up -d --build` 

要触发新任务,请运行:

`$ curl http://localhost:8004/tasks -H "Content-Type: application/json" --data '{"type": 0}'` 

您应该会看到类似这样的内容:

`{
  "task_id": "14049663-6257-4a1f-81e5-563c714e90af"
}` 

任务状态

回到客户端的handleClick功能:

`function  handleClick(type)  { fetch('/tasks',  { method:  'POST', headers:  { 'Content-Type':  'application/json' }, body:  JSON.stringify({  type:  type  }), }) .then(response  =>  response.json()) .then(res  =>  getStatus(res.data.task_id)); }` 

当响应从最初的 AJAX 请求返回时,我们继续每秒调用带有任务 ID 的getStatus():

`function  getStatus(taskID)  { fetch(`/tasks/${taskID}`,  { method:  'GET', headers:  { 'Content-Type':  'application/json' }, }) .then(response  =>  response.json()) .then(res  =>  { const  html  =  `
 <tr>
 <td>${taskID}</td>
 <td>${res.data.task_status}</td>
 <td>${res.data.task_result}</td>
 </tr>`; document.getElementById('tasks').prepend(html); const  newRow  =  document.getElementById('table').insertRow(); newRow.innerHTML  =  html; const  taskStatus  =  res.data.task_status; if  (taskStatus  ===  'finished'  ||  taskStatus  ===  'failed')  return  false; setTimeout(function()  { getStatus(res.data.task_id); },  1000); }) .catch(err  =>  console.log(err)); }` 

如果响应成功,一个新行被添加到 DOM 上的表中。

更新get_status路线处理器以返回状态:

`@app.get("/tasks/{task_id}")
def get_status(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)` 

导入异步结果:

`from celery.result import AsyncResult` 

更新容器:

`$ docker-compose up -d --build` 

触发新任务:

`$ curl http://localhost:8004/tasks -H "Content-Type: application/json" --data '{"type": 1}'` 

然后,从响应中获取task_id并调用更新的端点来查看状态:

`$ curl http://localhost:8004/tasks/f3ae36f1-58b8-4c2b-bf5b-739c80e9d7ff

{
  "task_id": "455234e0-f0ea-4a39-bbe9-e3947e248503",
  "task_result": true,
  "task_status": "SUCCESS"
}` 

也在浏览器中测试一下:

fastapi, celery, docker

芹菜原木

更新 docker-compose.yml 中的worker服务,以便将芹菜日志转储到一个日志文件:

`worker: build:  ./project command:  celery worker --app=worker.celery --loglevel=info --logfile=logs/celery.log volumes: -  ./project:/usr/src/app environment: -  CELERY_BROKER_URL=redis://redis:6379/0 -  CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: -  web -  redis` 

向“项目”添加一个名为“日志”的新目录。然后,将名为 celery.log 的新文件添加到新创建的目录中。

更新:

`$ docker-compose up -d --build` 

由于我们设置了一个卷,您应该看到日志文件在本地被填满:

`[2021-05-08 15:32:24,407: INFO/MainProcess] Connected to redis://redis:6379/0
[2021-05-08 15:32:24,415: INFO/MainProcess] mingle: searching for neighbors
[2021-05-08 15:32:25,434: INFO/MainProcess] mingle: all alone
[2021-05-08 15:32:25,448: INFO/MainProcess] [[email protected]](/cdn-cgi/l/email-protection) ready.
[2021-05-08 15:32:29,834: INFO/MainProcess]
    Received task: create_task[013df48c-4548-4a2b-9b22-7267da215361]
[2021-05-08 15:32:39,825: INFO/ForkPoolWorker-7]
    Task create_task[013df48c-4548-4a2b-9b22-7267da215361]
    succeeded in 10.02114040000015s: True` 

花卉仪表板

Flower 是一个轻量级的、实时的、基于网络的芹菜监控工具。您可以监控当前正在运行的任务,增加或减少工作池,查看图表和一些统计数据,等等。

添加到 requirements.txt :

`aiofiles==0.6.0
celery==4.4.7
fastapi==0.64.0
flower==0.9.7
Jinja2==2.11.3
pytest==6.2.4
redis==3.5.3
requests==2.25.1
uvicorn==0.13.4` 

然后,向 docker-compose.yml 添加一个新服务:

`dashboard: build:  ./project command:  flower --app=worker.celery --port=5555 --broker=redis://redis:6379/0 ports: -  5556:5555 environment: -  CELERY_BROKER_URL=redis://redis:6379/0 -  CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: -  web -  redis -  worker` 

测试一下:

`$ docker-compose up -d --build` 

导航到 http://localhost:5556 查看仪表板。您应该看到一名员工准备就绪:

flower dashboard

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

flower dashboard

试着增加几个工人,看看会有什么影响:

`$ docker-compose up -d --build --scale worker=3` 

试验

让我们从最基本的测试开始:

`def test_task():
    assert create_task.run(1)
    assert create_task.run(2)
    assert create_task.run(3)` 

将上述测试用例添加到project/tests/test _ tasks . py中,然后添加以下导入:

`from worker import create_task` 

单独运行测试:

`$ docker-compose exec web python -m pytest -k "test_task and not test_home"` 

运行应该需要大约一分钟:

`================================== test session starts ===================================
platform linux -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /usr/src/app
plugins: celery-4.4.7
collected 2 items / 1 deselected / 1 selected

tests/test_tasks.py .                                                               [100%]

====================== 1 passed, 1 deselected in 60.05s (0:01:00)  ========================` 

值得注意的是,在上面的断言中,我们使用了.run方法(而不是.delay)来直接运行任务,而没有芹菜工人。

想要模仿.run方法来加快速度吗?

`@patch("worker.create_task.run")
def test_mock_task(mock_run):
    assert create_task.run(1)
    create_task.run.assert_called_once_with(1)

    assert create_task.run(2)
    assert create_task.run.call_count == 2

    assert create_task.run(3)
    assert create_task.run.call_count == 3` 

导入:

`from unittest.mock import patch, call` 

测试:

`$ docker-compose exec web python -m pytest -k "test_mock_task"

================================== test session starts ===================================
platform linux -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /usr/src/app
plugins: celery-4.4.7
collected 3 items / 2 deselected / 1 selected

tests/test_tasks.py .                                                               [100%]

============================ 1 passed, 2 deselected in 0.13s =============================` 

快多了!

全面整合测试怎么样?

`def test_task_status(test_app):
    response = test_app.post(
        "/tasks",
        data=json.dumps({"type": 1})
    )
    content = response.json()
    task_id = content["task_id"]
    assert task_id

    response = test_app.get(f"tasks/{task_id}")
    content = response.json()
    assert content == {"task_id": task_id, "task_status": "PENDING", "task_result": None}
    assert response.status_code == 200

    while content["task_status"] == "PENDING":
        response = test_app.get(f"tasks/{task_id}")
        content = response.json()
    assert content == {"task_id": task_id, "task_status": "SUCCESS", "task_result": True}` 

请记住,这个测试使用开发中使用的相同的代理和后端。您可能想要实例化一个新的 Celery 应用程序来进行测试。

添加导入:

确保测试通过。

结论

这是关于如何配置 Celery 在 FastAPI 应用程序中运行长时间运行的任务的基本指南。您应该让队列处理任何可能阻塞或减慢面向用户的代码的进程。

Celery 还可以用于执行可重复的任务,分解复杂的资源密集型任务,以便将计算工作量分布到多个机器上,从而减少(1)完成时间和(2)处理客户端请求的机器上的负载。

回购中抓取代码。

用 FastAPI、MongoDB 和 Beanie 构建 CRUD 应用程序

原文:https://testdriven.io/blog/fastapi-beanie/

在本教程中,你将学习如何用 FastAPIMongoDB 开发异步 API。我们将使用 Beanie ODM 库与 MongoDB 进行异步交互。

目标

本教程结束时,您将能够:

  1. 解释什么是 Beanie ODM 以及为什么您可能想要使用它
  2. 使用 Beanie ODM 与 MongoDB 异步交互
  3. 用 Python 和 FastAPI 开发 RESTful API

为什么是 Beanie ODM?

Beanie 是 MongoDB 的异步对象文档映射器(ODM ),它支持开箱即用的数据和模式迁移。它使用马达作为异步数据库引擎,使用活塞

虽然您可以简单地使用 Motor,但是 Beanie 提供了一个额外的抽象层,使得与 Mongo 数据库中的集合进行交互更加容易。

想只用电机?查看用 FastAPI 和 MongoDB 构建 CRUD 应用程序。

初始设置

首先创建一个新文件夹来保存名为“fastapi-beanie”的项目:

`$ mkdir fastapi-beanie
$ cd fastapi-beanie` 

接下来,创建并激活虚拟环境:

`$ python3.10 -m venv venv
$ source venv/bin/activate
(venv)$ export PYTHONPATH=$PWD` 

随意把 venv 和 Pip 换成诗歌Pipenv 。更多信息,请查看现代 Python 环境

接下来,创建以下文件和文件夹:

`├── app
│   ├── __init__.py
│   ├── main.py
│   └── server
│       ├── app.py
│       ├── database.py
│       ├── models
│       └── routes
└── requirements.txt` 

将以下依赖项添加到您的 requirements.txt 文件中:

`beanie==1.11.0
fastapi==0.78.0
uvicorn==0.17.6` 

从终端安装依赖项:

`(venv)$ pip install -r requirements.txt` 

app/main.py 文件中,定义运行应用程序的入口点:

`import uvicorn

if __name__ == "__main__":
    uvicorn.run("server.app:app", host="0.0.0.0", port=8000, reload=True)` 

这里,我们指示文件在端口 8000 上运行一个uvicon服务器,并在每次文件更改时重新加载。

在通过入口点文件启动服务器之前,在 app/server/app.py 中创建一个基本路由:

`from fastapi import FastAPI

app = FastAPI()

@app.get("/", tags=["Root"])
async def read_root() -> dict:
    return {"message": "Welcome to your beanie powered app!"}` 

从控制台运行入口点文件:

`(venv)$ python app/main.py` 

在浏览器中导航至 http://localhost:8000 。您应该看到:

`{ "message":  "Welcome to your beanie powered app!" }` 

我们在建造什么?

我们将构建一个产品评论应用程序,它允许我们执行以下操作:

  • 创建评论
  • 阅读评论
  • 更新评论
  • 删除评论

在开始编写路线之前,让我们使用 Beanie 来配置应用程序的数据库模型。

数据库模式

Beanie 允许您创建文档,这些文档可以用来与数据库中的集合进行交互。文档代表您的数据库模式。它们可以通过创建从 Beanie 继承Document类的子类来定义。Document类由 Pydantic 的BaseModel提供支持,这使得定义集合和数据库模式以及交互式 Swagger 文档页面中显示的示例数据变得容易。

示例:

`from beanie import Document

class TestDrivenArticle(Document):
    title: str
    content: str
    date: datetime
    author: str` 

定义的文档表示文章将如何存储在数据库中。然而,它是一个普通的文档类,没有与之相关联的数据库集合。要关联一个集合,只需添加一个Settings类作为子类:

`from beanie import Document

class TestDrivenArticle(Document):
    title: str
    content: str
    date: datetime
    author: str

    class Settings:
        name = "testdriven_collection"` 

现在我们已经知道了模式是如何创建的,我们将为我们的应用程序创建模式。在“app/server/models”文件夹中,创建一个名为 product_review.py 的新文件:

`from datetime import datetime

from beanie import Document
from pydantic import BaseModel
from typing import Optional

class ProductReview(Document):
    name: str
    product: str
    rating: float
    review: str
    date: datetime = datetime.now()

    class Settings:
        name = "product_review"` 

由于Document类是由 Pydantic 支持的,我们可以定义示例模式数据,使开发人员更容易从交互式 Swagger 文档中使用 API。

像这样添加Config子类:

`from datetime import datetime

from beanie import Document
from pydantic import BaseModel
from typing import Optional

class ProductReview(Document):
    name: str
    product: str
    rating: float
    review: str
    date: datetime = datetime.now()

    class Settings:
        name = "product_review"

    class Config:
        schema_extra = {
            "example": {
                "name": "Abdulazeez",
                "product": "TestDriven TDD Course",
                "rating": 4.9,
                "review": "Excellent course!",
                "date": datetime.now()
            }
        }` 

因此,在上面的代码块中,我们定义了一个名为ProductReview的 Beanie 文档,它表示产品评论将如何存储。我们还定义了集合product_review,数据将存储在其中。

我们将在路由中使用这个模式来实施正确的请求体。

最后,让我们定义更新产品评论的模式:

`class UpdateProductReview(BaseModel):
    name: Optional[str]
    product: Optional[str]
    rating: Optional[float]
    review: Optional[str]
    date: Optional[datetime]

    class Config:
        schema_extra = {
            "example": {
                "name": "Abdulazeez Abdulazeez",
                "product": "TestDriven TDD Course",
                "rating": 5.0,
                "review": "Excellent course!",
                "date": datetime.now()
            }
        }` 

上面的UpdateProductReview类属于类型 BaseModel ,它允许我们只对请求体中的字段进行修改。

有了模式之后,让我们在继续编写路由之前设置 MongoDB 和我们的数据库。

MongoDB

在这一节中,我们将连接 MongoDB 并配置我们的应用程序与之通信。

维基百科介绍,MongoDB 是一个跨平台的面向文档的数据库程序。作为一个 NoSQL 数据库程序,MongoDB 使用带有可选模式的类似 JSON 的文档。

MongoDB 设置

如果您的机器上没有安装 MongoDB,请参考文档中的安装指南。安装完成后,继续按照指南运行 mongod 守护进程。一旦完成,您就可以通过使用mongo shell 命令连接到实例来验证 MongoDB 已经启动并正在运行:

作为参考,本教程使用 MongoDB 社区版 v5.0.7。

`$ mongo --version
MongoDB shell version v5.0.7

Build Info: {
 "version": "5.0.7",
 "gitVersion": "b977129dc70eed766cbee7e412d901ee213acbda",
 "modules": [],
 "allocator": "system",
 "environment": {
 "distarch": "x86_64",
 "target_arch": "x86_64"
 }
}` 

设置数据库

database.py 中,添加以下内容:

`from beanie import init_beanie
import motor.motor_asyncio

from app.server.models.product_review import ProductReview

async def init_db():
    client = motor.motor_asyncio.AsyncIOMotorClient(
        "mongodb://localhost:27017/productreviews"
    )

    await init_beanie(database=client.db_name, document_models=[ProductReview])` 

在上面的代码块中,我们导入了 init_beanie 方法,该方法负责初始化由 motor.motor_asyncio 驱动的数据库引擎。init_beanie方法有两个参数:

  1. database -要使用的数据库的名称。
  2. document_models——定义的文档模型列表——在我们的例子中是ProductReview模型。

init_db函数将在应用程序启动事件中被调用。更新 app.py 以包含启动事件:

`from fastapi import FastAPI

from app.server.database import init_db

app = FastAPI()

@app.on_event("startup")
async def start_db():
    await init_db()

@app.get("/", tags=["Root"])
async def read_root() -> dict:
    return {"message": "Welcome to your beanie powered app!"}` 

现在我们已经有了数据库配置,让我们来写路线。

路线

在本节中,我们将构建从应用程序对数据库执行 CRUD 操作的路线:

  1. 事后审查
  2. 获取单个评论和获取所有评论
  3. 提交单个评论
  4. 删除单个评论

在“routes”文件夹中,创建名为 product_review.py 的文件:

`from beanie import PydanticObjectId
from fastapi import APIRouter, HTTPException
from typing import List

from app.server.models.product_review import ProductReview, UpdateProductReview

router = APIRouter()` 

在上面的代码块中,我们导入了PydanticObjectId,它将在检索单个请求时用于类型提示 ID 参数。我们还导入了负责处理路由操作的APIRouter类。我们还导入了之前定义的模型类。

Beanie 文档模型允许我们用更少的代码直接与数据库交互。例如,要检索数据库集合中的所有记录,我们所要做的就是:

`data = await ProductReview.find_all().to_list()
return data # A list of all records in the collection.` 

在我们开始为 CRUD 操作编写 route 函数之前,让我们在 app.py 中注册路由:

`from fastapi import FastAPI

from app.server.database import init_db
from app.server.routes.product_review import router as Router

app = FastAPI()
app.include_router(Router, tags=["Product Reviews"], prefix="/reviews")

@app.on_event("startup")
async def start_db():
    await init_db()

@app.get("/", tags=["Root"])
async def read_root() -> dict:
    return {"message": "Welcome to your beanie powered app!"}` 

创造

routes/product_review.py 中,添加以下内容:

`@router.post("/", response_description="Review added to the database")
async def add_product_review(review: ProductReview) -> dict:
    await review.create()
    return {"message": "Review added successfully"}` 

这里,我们定义了 route 函数,它接受一个类型为ProductReview的参数。如前所述,文档类可以直接与数据库交互。

新记录是通过调用 create() 方法创建的。

上面的路由需要类似的有效负载,如下所示:

`{ "name":  "Abdulazeez", "product":  "TestDriven TDD Course", "rating":  4.9, "review":  "Excellent course!", "date":  "2022-05-17T13:53:17.196135" }` 

测试路线:

`$ curl -X 'POST' \
  'http://0.0.0.0:8000/reviews/' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
 "name": "Abdulazeez",
 "product": "TestDriven TDD Course",
 "rating": 4.9,
 "review": "Excellent course!",
 "date": "2022-05-17T13:53:17.196135"
}'` 

上面的请求应该会返回一条成功的消息:

`{ "message":  "Review added successfully" }` 

阅读

接下来是使我们能够检索数据库中存在的单个评论和所有评论的路线:

`@router.get("/{id}", response_description="Review record retrieved")
async def get_review_record(id: PydanticObjectId) -> ProductReview:
    review = await ProductReview.get(id)
    return review

@router.get("/", response_description="Review records retrieved")
async def get_reviews() -> List[ProductReview]:
    reviews = await ProductReview.find_all().to_list()
    return reviews` 

在上面的代码块中,我们定义了两个函数:

  1. 在第一个函数中,该函数接受一个类型为ObjectiD的 ID,这是 MongoDB IDs 的默认编码。使用 get() 方法检索记录。
  2. 第二,我们使用 find_all() 方法检索所有的评论。追加了to_list()方法,因此结果以列表的形式返回。

另一种可以用来检索单个条目的方法是采用条件的 find_one() 方法。例如:

`# Return a record who has a rating of 4.0
await ProductReview.find_one(ProductReview.rating == 4.0)` 

让我们测试检索所有记录的第一条路线:

`$ curl -X 'GET' \
  'http://0.0.0.0:8000/reviews/' \
  -H 'accept: application/json'` 

回应:

`[ { "_id":  "62839ad1d9a88a040663a734", "name":  "Abdulazeez", "product":  "TestDriven TDD Course", "rating":  4.9, "review":  "Excellent course!", "date":  "2022-05-17T13:53:17.196000" } ]` 

接下来,让我们测试检索与提供的 ID 匹配的单个记录的路径:

`$ curl -X 'GET' \
  'http://0.0.0.0:8000/reviews/62839ad1d9a88a040663a734' \
  -H 'accept: application/json'` 

回应:

`{ "_id":  "62839ad1d9a88a040663a734", "name":  "Abdulazeez", "product":  "TestDriven TDD Course", "rating":  4.9, "review":  "Excellent course!", "date":  "2022-05-17T13:53:17.196000" }` 

更新

接下来,我们来写一下更新复习记录的路线:

`@router.put("/{id}", response_description="Review record updated")
async def update_student_data(id: PydanticObjectId, req: UpdateProductReview) -> ProductReview:
    req = {k: v for k, v in req.dict().items() if v is not None}
    update_query = {"$set": {
        field: value for field, value in req.items()
    }}

    review = await ProductReview.get(id)
    if not review:
        raise HTTPException(
            status_code=404,
            detail="Review record not found!"
        )

    await review.update(update_query)
    return review` 

在这个函数中,我们过滤掉了没有更新的字段,以防止用None覆盖现有字段。

要更新记录,需要更新查询。我们定义了一个更新查询,用请求体中传递的数据覆盖现有字段。然后我们检查记录是否存在。如果存在,它将被更新并返回更新后的记录,否则将引发 404 异常。

让我们测试一下路线:

`$ curl -X 'PUT' \
  'http://0.0.0.0:8000/reviews/62839ad1d9a88a040663a734' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
 "name": "Abdulazeez Abdulazeez",
 "product": "TestDriven TDD Course",
 "rating": 5
}'` 

回应:

`{ "_id":  "62839ad1d9a88a040663a734", "name":  "Abdulazeez Abdulazeez", "product":  "TestDriven TDD Course", "rating":  5.0, "review":  "Excellent course!", "date":  "2022-05-17T13:53:17.196000" }` 

删除

最后,让我们编写负责删除记录的路由:

`@router.delete("/{id}", response_description="Review record deleted from the database")
async def delete_student_data(id: PydanticObjectId) -> dict:
    record = await ProductReview.get(id)

    if not record:
        raise HTTPException(
            status_code=404,
            detail="Review record not found!"
        )

    await record.delete()
    return {
        "message": "Record deleted successfully"
    }` 

因此,在删除记录之前,我们首先检查记录是否存在。通过调用 delete() 方法删除记录。

让我们测试一下路线:

`$ curl -X 'DELETE' \
  'http://0.0.0.0:8000/reviews/62839ad1d9a88a040663a734' \
  -H 'accept: application/json'` 

回应:

`{ "message":  "Record deleted successfully" }` 

我们已经成功构建了一个由 FastAPI、MongoDB 和 Beanie ODM 支持的 CRUD 应用程序。

结论

在本教程中,您学习了如何使用 FastAPI、MongoDB 和 Beanie ODM 创建 CRUD 应用程序。通过回顾本教程开头的目标来进行快速自检,您可以在 GitHub 上找到本教程中使用的代码。

想要更多吗?

  1. 用 pytest 设置单元和集成测试。
  2. 添加其他路线。
  3. 为您的应用程序创建一个 GitHub repo,并使用 GitHub 操作配置 CI/CD。

查看测试驱动的 FastAPI 开发和 Docker 课程,了解有关测试和设置 FastAPI 应用的 CI/CD 的更多信息。

干杯!

用 FastAPI 和 Pytest 开发和测试异步 API

原文:https://testdriven.io/blog/fastapi-crud/

本教程着眼于如何使用测试驱动开发 (TDD)用 FastAPI、Postgres、pytest 和 Docker 开发和测试一个异步 API。我们还将使用 Databases 包与 Postgres 进行异步交互。

依赖关系:

  1. FastAPI v0.88.0
  2. 文档 v20.10.21
  3. python 3 . 11 . 0 版
  4. pytest v7.2.0
  5. 数据库版本 0.6.2

目标

学完本教程后,您应该能够:

  1. 用 Python 和 FastAPI 开发异步 RESTful API
  2. 实践测试驱动的开发
  3. 用 pytest 测试 FastAPI 应用程序
  4. 与 Postgres 数据库异步交互
  5. 将 FastAPI 和 Postgres 封装到 Docker 容器中
  6. 用 pytest 参数化测试函数和测试中的模拟功能
  7. 用 Swagger/OpenAPI 记录 RESTful API

FastAPI

FastAPI 是一个现代的、高性能的、内置电池的 Python web 框架,非常适合构建 RESTful APIs。它可以处理同步和异步请求,并内置了对数据验证、JSON 序列化、身份验证和授权以及 OpenAPI (撰写本文时版本为 3.0.2 )文档的支持。

亮点:

  1. 受 Flask 的启发,它有一种轻量级微框架的感觉,支持类似 Flask 的 route decorators。
  2. 它利用 Python 类型提示进行参数声明,支持数据验证(通过 Pydantic )和 OpenAPI/Swagger 文档。
  3. 它建立在 Starlette 之上,支持异步 API 的开发。
  4. 它很快。由于 async 比传统的同步线程模型更有效,所以在性能方面它可以与 Node 和 Go 竞争。

查看官方文档中的功能指南,了解更多信息。我们也鼓励大家回顾一下的替代方案、灵感和比较,其中详细介绍了 FastAPI 与其他 web 框架和技术的比较。

项目设置

首先创建一个名为“fastapi-crud”的文件夹来保存您的项目。然后,向项目根目录添加一个 docker-compose.yml 文件和一个“src”文件夹。在“src”文件夹中,添加一个 Dockerfilerequirements.txt 文件,以及一个“app”文件夹。最后,将以下文件添加到“app”文件夹: init。pymain.py

以下命令将创建项目结构:

`$ mkdir fastapi-crud && \
    cd fastapi-crud && \
    touch docker-compose.yml && \
    mkdir src && \
    cd src && \
    touch Dockerfile && \
    touch requirements.txt && \
    mkdir app && \
    cd app && \
    touch __init__.py && \
    touch main.py` 

您现在应该已经:

`fastapi-crud
    ├── docker-compose.yml
    └── src
        ├── Dockerfile
        ├── app
        │   ├── __init__.py
        │   └── main.py
        └── requirements.txt` 

与 Django 或 Flask 不同,FastAPI 没有内置的开发服务器。因此,我们将使用uvicon,一个 ASGI 服务器,来提供 FastAPI。

不熟悉 ASGI?通读优秀的ASGI 简介:异步 Python Web 生态系统的出现文章。

将 FastAPI 和 Uvicorn 添加到需求文件中:

`fastapi==0.88.0
uvicorn==0.20.0` 

在我看来,FastAPI 没有附带开发服务器的事实既是一个优点,也是一个缺点。一方面,在开发模式下提供应用程序确实需要更多的时间。另一方面,这有助于从概念上将 web 框架与 web 服务器分开,这对于初学者来说常常是一个困惑的来源,当一个 web 框架从开发进入生产时,它确实有一个内置的开发服务器(像 Django 或 Flask)。

然后,在 main.py 中,创建一个新的 FastAPI 实例,并设置一个健全性检查路径:

`from fastapi import FastAPI

app = FastAPI()

@app.get("/ping")
def pong():
    return {"ping": "pong!"}` 

安装 Docker ,如果你还没有的话,然后更新“src”目录下的 Dockerfile :

`# pull official base image
FROM  python:3.11.0-alpine

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# copy requirements file
COPY  ./requirements.txt /usr/src/app/requirements.txt

# install dependencies
RUN  set -eux \
    && apk add --no-cache --virtual .build-deps build-base \
         openssl-dev libffi-dev gcc musl-dev python3-dev \
    && pip install --upgrade pip setuptools wheel \
    && pip install -r /usr/src/app/requirements.txt \
    && rm -rf /root/.cache/pip

# copy project
COPY  . /usr/src/app/` 

所以,我们从 Python 3.11.0 的基于 AlpineDocker 镜像开始。然后我们设置一个工作目录以及两个环境变量:

  1. PYTHONDONTWRITEBYTECODE:防止 Python 将 pyc 文件写入磁盘(相当于python -B 选项
  2. PYTHONUNBUFFERED:防止 Python 缓冲 stdout 和 stderr(相当于python -u 选项

最后,我们复制了 requirements.txt 文件,安装了一些系统级的依赖项,更新了 Pip,安装了需求,并复制了 FastAPI 应用程序本身。

查看 Docker 针对 Python 开发人员的最佳实践,了解更多关于构造 Docker 文件的信息,以及为基于 Python 的开发配置 Docker 的一些最佳实践。

接下来,将以下内容添加到项目根目录下的 docker-compose.yml 文件中:

`version:  '3.8' services: web: build:  ./src command:  uvicorn app.main:app --reload --workers 1 --host 0.0.0.0 --port 8000 volumes: -  ./src/:/usr/src/app/ ports: -  8002:8000` 

因此,当容器旋转起来时,Uvicorn 将按照以下设置运行:

  1. --reload启用自动重新加载,这样服务器将在对代码库进行更改后重新启动。
  2. --workers 1提供单个工作进程。
  3. --host 0.0.0.0定义托管服务器的地址。
  4. --port 8000定义托管服务器的端口。

app.main:app告诉 Uvicorn 在哪里可以找到 FastAPI ASGI 应用程序——例如,“在‘app’模块中,您会在‘main . py’文件中找到 ASGI 应用程序app = FastAPI()

有关 Docker 合成文件配置的更多信息,请查看合成文件参考

构建映像并旋转容器:

`$ docker-compose up -d --build` 

导航到http://localhost:8002/ping。您应该看到:

您还可以在http://localhost:8002/docs上查看由 Swagger UI 支持的交互式 API 文档:

swagger ui

测试设置

在“src”中创建一个“tests”文件夹,然后添加一个 init。py 文件与一个 test_main.py 文件一起“测试”:

`from starlette.testclient import TestClient

from app.main import app

client = TestClient(app)

def test_ping():
    response = client.get("/ping")
    assert response.status_code == 200
    assert response.json() == {"ping": "pong!"}` 

这里,我们导入了 Starlette 的 TestClient ,它使用 httpx 库对 FastAPI 应用程序发出请求。

将 pytest 和 httpx 添加到 requirements.txt :

`fastapi==0.88.0
uvicorn==0.20.0

# dev
pytest==7.2.0
httpx==0.23.1` 

更新图像,然后运行测试:

`$ docker-compose up -d --build
$ docker-compose exec web pytest .` 

您应该看到:

`=============================== test session starts ===============================
platform linux -- Python 3.11.0, pytest-7.2.0, pluggy-1.0.0
rootdir: /usr/src/app
plugins: anyio-3.6.2
collected 1 item

tests/test_main.py .                                                        [100%]

================================ 1 passed in 0.31s ================================` 

在继续之前,添加一个test_app pytest fixture 到一个名为 src/tests/conftest.py 的新文件中:

`import pytest
from starlette.testclient import TestClient

from app.main import app

@pytest.fixture(scope="module")
def test_app():
    client = TestClient(app)
    yield client  # testing happens here` 

更新测试文件,以便它使用 fixture:

`def test_ping(test_app):
    response = test_app.get("/ping")
    assert response.status_code == 200
    assert response.json() == {"ping": "pong!"}` 

您的项目结构现在应该如下所示:

`fastapi-crud
    ├── docker-compose.yml
    └── src
        ├── Dockerfile
        ├── app
        │   ├── __init__.py
        │   └── main.py
        ├── requirements.txt
        └── tests
            ├── __init__.py
            ├── conftest.py
            └── test_main.py` 

异步处理程序

让我们将同步处理程序转换成异步处理程序。

FastAPI 使异步交付路线变得很容易,而不必经历创建任务队列(如 Celery 或 RQ)或利用线程的麻烦。只要处理程序中没有任何阻塞的 I/O 调用,就可以简单地通过添加关键字async将处理程序声明为异步的,如下所示:

`@app.get("/ping")
async def pong():
    # some async operation could happen here
    # example: `notes = await get_all_notes()`
    return {"ping": "pong!"}` 

就是这样。更新代码中的处理程序,然后确保测试仍然通过:

`=============================== test session starts ===============================
platform linux -- Python 3.11.0, pytest-7.2.0, pluggy-1.0.0
rootdir: /usr/src/app
plugins: anyio-3.6.2
collected 1 item

tests/test_main.py .                                                        [100%]

================================ 1 passed in 0.06s ================================` 

查看并发和异步/等待指南,深入了解异步技术。

路线

接下来,让我们按照 RESTful 最佳实践设置基本的 CRUD 路径:

端点 HTTP 方法 CRUD 方法 结果
/备注/ 得到 阅读 获取所有笔记
/notes/:id/ 得到 阅读 得到一个音符
/备注/ 邮政 创造 添加注释
/notes/:id/ 更新 更新注释
/notes/:id/ 删除 删除 删除便笺

对于每条路线,我们将:

  1. 写一个测试
  2. 运行测试,确保失败(红色)
  3. 编写足够通过测试的代码(绿色)
  4. 重构(如有必要)

在开始之前,让我们添加一些结构,以便用 FastAPI 的 APIRouter 更好地组织 CRUD 路由。

您可以分解和模块化更大的项目,并使用APIRouter将版本控制应用到您的 API。如果你熟悉 Flask,它相当于一个蓝图

首先,在“app”文件夹中添加一个名为“api”的新文件夹。添加一个 init。py 文件到新创建的文件夹中。

现在我们可以将/ping路径移动到一个名为 src/app/api/ping.py 的新文件中:

`from fastapi import APIRouter

router = APIRouter()

@router.get("/ping")
async def pong():
    # some async operation could happen here
    # example: `notes = await get_all_notes()`
    return {"ping": "pong!"}` 

然后,像这样更新 main.py 以删除旧路由,并将路由器连接到我们的主应用程序:

`from fastapi import FastAPI

from app.api import ping

app = FastAPI()

app.include_router(ping.router)` 

test_main.py 重命名为 test_ping.py

确保http://localhost:8002/pinghttp://localhost:8002/docs仍然工作。此外,在继续之前,确保测试仍然通过。

`fastapi-crud
    ├── docker-compose.yml
    └── src
        ├── Dockerfile
        ├── app
        │   ├── __init__.py
        │   ├── api
        │   │   ├── __init__.py
        │   │   └── ping.py
        │   └── main.py
        ├── requirements.txt
        └── tests
            ├── __init__.py
            ├── conftest.py
            └── test_ping.py` 

Postgres 设置

要配置 Postgres,我们需要向 docker-compose.yml 文件添加一个新服务,添加适当的环境变量,并安装 asyncpg

首先,向 docker-compose.yml 添加一个名为db的新服务:

`version:  '3.8' services: web: build:  ./src command:  | bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; uvicorn app.main:app --reload --workers 1 --host 0.0.0.0 --port 8000' volumes: -  ./src/:/usr/src/app/ ports: -  8002:8000 environment: -  DATABASE_URL=postgresql://hello_fastapi:[[email protected]](/cdn-cgi/l/email-protection)/hello_fastapi_dev db: image:  postgres:15.1-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ expose: -  5432 environment: -  POSTGRES_USER=hello_fastapi -  POSTGRES_PASSWORD=hello_fastapi -  POSTGRES_DB=hello_fastapi_dev volumes: postgres_data:` 

为了在容器的生命周期之外保存数据,我们配置了一个卷。这个配置将把postgres_data绑定到容器中的“/var/lib/postgresql/data/”目录。

我们还添加了一个环境键来定义默认数据库的名称,并设置用户名和密码。

查看 Postgres Docker Hub 页面的“环境变量”部分了解更多信息。

更新 docker 文件以安装 asyncpg 所需的适当软件包:

`# pull official base image
FROM  python:3.11.0-alpine

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# copy requirements file
COPY  ./requirements.txt /usr/src/app/requirements.txt

# install dependencies
RUN  set -eux \
    && apk add --no-cache --virtual .build-deps build-base \
         openssl-dev libffi-dev gcc musl-dev python3-dev \
        postgresql-dev bash \
    && pip install --upgrade pip setuptools wheel \
    && pip install -r /usr/src/app/requirements.txt \
    && rm -rf /root/.cache/pip

# copy project
COPY  . /usr/src/app/` 

将 asyncpg 添加到 src/requirements.txt :

`asyncpg==0.27.0
fastapi==0.88.0
uvicorn==0.20.0

# dev
pytest==7.2.0
httpx==0.23.1` 

接下来,在“src/app”中添加一个 db.py 文件:

`import os

from databases import Database
from sqlalchemy import create_engine, MetaData

DATABASE_URL = os.getenv("DATABASE_URL")

# SQLAlchemy
engine = create_engine(DATABASE_URL)
metadata = MetaData()

# databases query builder
database = Database(DATABASE_URL)` 

这里,使用我们刚刚在 Docker Compose 文件中配置的数据库 URI 和凭证,我们创建了一个 SQLAlchemy 引擎(用于与数据库通信)以及一个元数据实例(用于创建数据库模式)。我们还从数据库中创建了一个新的数据库实例。

数据库是一个异步 SQL 查询构建器,它工作在 SQLAlchemy 核心表达式语言之上。它支持以下方法:

  1. database.fetch_all(query)
  2. database.fetch_one(query)
  3. database.iterate(query)
  4. database.execute(query)
  5. database.execute_many(query)

查看异步 SQL(关系)数据库指南和 Starlette 数据库文档,了解关于异步使用数据库的更多细节。

更新要求:

`asyncpg==0.27.0 databases[postgresql]==0.6.2 fastapi==0.88.0 psycopg2-binary==2.9.5 SQLAlchemy==1.4.41 uvicorn==0.20.0 #  dev pytest==7.2.0 httpx==0.23.1` 

我们正在安装 Psycopg ,因为我们将使用 create_all ,这是一个同步 SQLAlchemy 函数。

模型

SQLAlchemy 模型

src/app/db.py 添加一个notes模型:

`import os

from sqlalchemy import (
    Column,
    DateTime,
    Integer,
    MetaData,
    String,
    Table,
    create_engine
)
from sqlalchemy.sql import func

from databases import Database

DATABASE_URL = os.getenv("DATABASE_URL")

# SQLAlchemy
engine = create_engine(DATABASE_URL)
metadata = MetaData()
notes = Table(
    "notes",
    metadata,
    Column("id", Integer, primary_key=True),
    Column("title", String(50)),
    Column("description", String(50)),
    Column("created_date", DateTime, default=func.now(), nullable=False),
)

# databases query builder
database = Database(DATABASE_URL)` 

连接数据库和 main.py 中的模型,添加启动和关闭事件处理程序,用于连接和断开数据库;

`from fastapi import FastAPI

from app.api import ping
from app.db import engine, database, metadata

metadata.create_all(engine)

app = FastAPI()

@app.on_event("startup")
async def startup():
    await database.connect()

@app.on_event("shutdown")
async def shutdown():
    await database.disconnect()

app.include_router(ping.router)` 

构建新的映像并旋转两个容器:

`$ docker-compose up -d --build` 

确保notes表已创建:

`$ docker-compose exec db psql --username=hello_fastapi --dbname=hello_fastapi_dev

psql (15.1)
Type "help" for help.

hello_fastapi_dev=# \l
                                            List of databases
       Name        |     Owner     | Encoding |  Collate   |   Ctype    |        Access privileges
-------------------+---------------+----------+------------+------------+---------------------------------
 hello_fastapi_dev | hello_fastapi | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres          | hello_fastapi | UTF8     | en_US.utf8 | en_US.utf8 |
 template0         | hello_fastapi | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_fastapi               +
                   |               |          |            |            | hello_fastapi=CTc/hello_fastapi
 template1         | hello_fastapi | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_fastapi               +
                   |               |          |            |            | hello_fastapi=CTc/hello_fastapi
(4 rows)

hello_fastapi_dev=# \c hello_fastapi_dev
You are now connected to database "hello_fastapi_dev" as user "hello_fastapi".

hello_fastapi_dev=# \dt
           List of relations
 Schema | Name  | Type  |     Owner
--------+-------+-------+---------------
 public | notes | table | hello_fastapi
(1 row)

hello_fastapi_dev=# \q` 

Pydantic 模型

第一次使用 Pydantic?查看官方文档中的概述指南。

在“src/app/api”中一个名为 models.py 的新文件中创建一个NoteSchema Pydantic 模型,其中有两个必填字段titledescription:

`from pydantic import BaseModel

class NoteSchema(BaseModel):
    title: str
    description: str` 

NoteSchema将用于验证创建和更新便笺的有效负载。

邮寄路线

让我们打破第一条路由的正常 TDD 流程,以建立我们将用于其余路由的编码模式。

密码

在“src/app/api”文件夹中创建一个名为 notes.py 的新文件:

`from fastapi import APIRouter, HTTPException

from app.api import crud
from app.api.models import NoteDB, NoteSchema

router = APIRouter()

@router.post("/", response_model=NoteDB, status_code=201)
async def create_note(payload: NoteSchema):
    note_id = await crud.post(payload)

    response_object = {
        "id": note_id,
        "title": payload.title,
        "description": payload.description,
    }
    return response_object` 

在这里,我们定义了一个处理程序,它需要一个有效负载payload: NoteSchema,带有一个标题和一个描述。

本质上,当路由遇到 POST 请求时,FastAPI 将读取请求的主体并验证数据:

  • 如果有效,数据将在payload参数中可用。FastAPI 还生成 JSON 模式定义,然后用于自动生成 OpenAPI 模式和 API 文档。
  • 如果无效,将立即返回错误。

查看请求正文文档了解更多信息。

值得注意的是,我们在这里使用了async声明,因为数据库通信将是异步的。换句话说,在处理程序中没有阻塞的 I/O 操作。

接下来,在“src/app/api”文件夹中创建一个名为 crud.py 的新文件:

`from app.api.models import NoteSchema
from app.db import notes, database

async def post(payload: NoteSchema):
    query = notes.insert().values(title=payload.title, description=payload.description)
    return await database.execute(query=query)` 

我们添加了一个名为post的实用函数,用于创建新的 notes,它接受一个有效载荷对象,然后:

  1. 创建一个 SQLAlchemy insert 对象表达式查询
  2. 执行查询并返回生成的 ID

接下来,我们需要定义一个新的 Pydantic 模型作为 response_model :

`@router.post("/", response_model=NoteDB, status_code=201)` 

更新 models.py 这样:

`from pydantic import BaseModel

class NoteSchema(BaseModel):
    title: str
    description: str

class NoteDB(NoteSchema):
    id: int` 

NoteDB模型继承了NoteSchema模型,增加了一个id字段。

main.py 中连接新路由器:

`from fastapi import FastAPI

from app.api import notes, ping
from app.db import database, engine, metadata

metadata.create_all(engine)

app = FastAPI()

@app.on_event("startup")
async def startup():
    await database.connect()

@app.on_event("shutdown")
async def shutdown():
    await database.disconnect()

app.include_router(ping.router)
app.include_router(notes.router, prefix="/notes", tags=["notes"])` 

注意前缀 URL 和"notes" 标签,它们将应用于 OpenAPI 模式(用于分组操作)。

用 curl 或 HTTPie 测试一下:

`$ http --json POST http://localhost:8002/notes/ title=foo description=bar` 

您应该看到:

`HTTP/1.1 201 Created
content-length: 42
content-type: application/json
date: Wed, 23 Nov 2022 18:14:51 GMT
server: uvicorn

{
    "description": "bar",
    "id": 1,
    "title": "foo"
}` 

还可以在http://localhost:8002/docs与端点进行交互。

试验

将以下测试添加到名为 src/tests/test_notes.py 的新测试文件中:

`import json

import pytest

from app.api import crud

def test_create_note(test_app, monkeypatch):
    test_request_payload = {"title": "something", "description": "something else"}
    test_response_payload = {"id": 1, "title": "something", "description": "something else"}

    async def mock_post(payload):
        return 1

    monkeypatch.setattr(crud, "post", mock_post)

    response = test_app.post("/notes/", content=json.dumps(test_request_payload),)

    assert response.status_code == 201
    assert response.json() == test_response_payload

def test_create_note_invalid_json(test_app):
    response = test_app.post("/notes/", content=json.dumps({"title": "something"}))
    assert response.status_code == 422` 

这个测试使用 pytest monkeypatch 夹具来模拟crud.post函数。然后,我们断言端点用预期的状态代码和响应体进行响应。

`$ docker-compose exec web pytest .

=============================== test session starts ===============================
platform linux -- Python 3.11.0, pytest-7.2.0, pluggy-1.0.0
rootdir: /usr/src/app
plugins: anyio-3.6.2
collected 3 items

tests/test_notes.py ..                                                      [ 66%]
tests/test_ping.py .                                                        [100%]

================================ 3 passed in 0.08s ================================` 

这样,我们就可以使用测试驱动开发来配置剩余的 CRUD 路径。

`fastapi-crud
    ├── docker-compose.yml
    └── src
        ├── Dockerfile
        ├── app
        │   ├── __init__.py
        │   ├── api
        │   │   ├── __init__.py
        │   │   ├── crud.py
        │   │   ├── models.py
        │   │   ├── notes.py
        │   │   └── ping.py
        │   ├── db.py
        │   └── main.py
        ├── requirements.txt
        └── tests
            ├── __init__.py
            ├── conftest.py
            ├── test_notes.py
            └── test_ping.py` 

获取路线

试验

将以下测试添加到 src/tests/test_notes.py 中:

`def test_read_note(test_app, monkeypatch):
    test_data = {"id": 1, "title": "something", "description": "something else"}

    async def mock_get(id):
        return test_data

    monkeypatch.setattr(crud, "get", mock_get)

    response = test_app.get("/notes/1")
    assert response.status_code == 200
    assert response.json() == test_data

def test_read_note_incorrect_id(test_app, monkeypatch):
    async def mock_get(id):
        return None

    monkeypatch.setattr(crud, "get", mock_get)

    response = test_app.get("/notes/999")
    assert response.status_code == 404
    assert response.json()["detail"] == "Note not found"` 

他们应该失败:

`=============================== test session starts ===============================
platform linux -- Python 3.11.0, pytest-7.2.0, pluggy-1.0.0
rootdir: /usr/src/app
plugins: anyio-3.6.2
collected 5 items

tests/test_notes.py ..FF                                                    [ 80%]
tests/test_ping.py .                                                        [100%]

==================================== FAILURES =====================================
_________________________________ test_read_note __________________________________

test_app = <starlette.testclient.TestClient object at 0x7f29072dc390>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f290734d5d0>

    def test_read_note(test_app, monkeypatch):
        test_data = {"id": 1, "title": "something", "description": "something else"}

        async def mock_get(id):
            return test_data

>       monkeypatch.setattr(crud, "get", mock_get)
E       AttributeError: <module 'app.api.crud' from '/usr/src/app/app/api/crud.py'> has no attribute 'get'

tests/test_notes.py:35: AttributeError
___________________________ test_read_note_incorrect_id ___________________________

test_app = <starlette.testclient.TestClient object at 0x7f29072dc390>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f290729ead0>

    def test_read_note_incorrect_id(test_app, monkeypatch):
        async def mock_get(id):
            return None

>       monkeypatch.setattr(crud, "get", mock_get)
E       AttributeError: <module 'app.api.crud' from '/usr/src/app/app/api/crud.py'> has no attribute 'get'

tests/test_notes.py:46: AttributeError
============================= short test summary info =============================
FAILED tests/test_notes.py::test_read_note - AttributeError: <module 'app.api.crud' from '/usr/src/app/app/api/crud.py'> ha...
FAILED tests/test_notes.py::test_read_note_incorrect_id - AttributeError: <module 'app.api.crud' from '/usr/src/app/app/api/crud.py'> ha...
=========================== 2 failed, 3 passed in 0.11s ===========================` 

密码

将处理函数添加到 src/app/api/notes.py :

`@router.get("/{id}/", response_model=NoteDB)
async def read_note(id: int):
    note = await crud.get(id)
    if not note:
        raise HTTPException(status_code=404, detail="Note not found")
    return note` 

这里,处理程序需要一个来自路径的整数id,而不是一个有效载荷——例如/notes/5/。

get实用函数添加到 crud.py 中:

`async def get(id: int):
    query = notes.select().where(id == notes.c.id)
    return await database.fetch_one(query=query)` 

在继续之前,确保测试通过,并使用 curl 或 HTTPie 和/或通过 API 文档在浏览器中手动测试新端点。

试验

接下来,添加一个阅读所有笔记的测试:

`def test_read_all_notes(test_app, monkeypatch):
    test_data = [
        {"title": "something", "description": "something else", "id": 1},
        {"title": "someone", "description": "someone else", "id": 2},
    ]

    async def mock_get_all():
        return test_data

    monkeypatch.setattr(crud, "get_all", mock_get_all)

    response = test_app.get("/notes/")
    assert response.status_code == 200
    assert response.json() == test_data` 

同样,确保测试失败。

密码

将处理函数添加到 src/app/api/notes.py :

`@router.get("/", response_model=List[NoteDB])
async def read_all_notes():
    return await crud.get_all()` 

从 Python 的类型模块导入列表:

response_model是一个带有NoteDB子类型的List

添加 CRUD 实用程序:

`async def get_all():
    query = notes.select()
    return await database.fetch_all(query=query)` 

确保自动化测试通过。也手动测试这个端点。

放置路线

试验

`def test_update_note(test_app, monkeypatch):
    test_update_data = {"title": "someone", "description": "someone else", "id": 1}

    async def mock_get(id):
        return True

    monkeypatch.setattr(crud, "get", mock_get)

    async def mock_put(id, payload):
        return 1

    monkeypatch.setattr(crud, "put", mock_put)

    response = test_app.put("/notes/1/", content=json.dumps(test_update_data))
    assert response.status_code == 200
    assert response.json() == test_update_data

@pytest.mark.parametrize(
    "id, payload, status_code",
    [
        [1, {}, 422],
        [1, {"description": "bar"}, 422],
        [999, {"title": "foo", "description": "bar"}, 404],
    ],
)
def test_update_note_invalid(test_app, monkeypatch, id, payload, status_code):
    async def mock_get(id):
        return None

    monkeypatch.setattr(crud, "get", mock_get)

    response = test_app.put(f"/notes/{id}/", content=json.dumps(payload),)
    assert response.status_code == status_code` 

这个测试使用 pytest 参数化装饰器来参数化test_update_note_invalid函数的参数。

密码

处理人:

`@router.put("/{id}/", response_model=NoteDB)
async def update_note(id: int, payload: NoteSchema):
    note = await crud.get(id)
    if not note:
        raise HTTPException(status_code=404, detail="Note not found")

    note_id = await crud.put(id, payload)

    response_object = {
        "id": note_id,
        "title": payload.title,
        "description": payload.description,
    }
    return response_object` 

实用工具:

`async def put(id: int, payload: NoteSchema):
    query = (
        notes
        .update()
        .where(id == notes.c.id)
        .values(title=payload.title, description=payload.description)
        .returning(notes.c.id)
    )
    return await database.execute(query=query)` 

删除路线

试验

`def test_remove_note(test_app, monkeypatch):
    test_data = {"title": "something", "description": "something else", "id": 1}

    async def mock_get(id):
        return test_data

    monkeypatch.setattr(crud, "get", mock_get)

    async def mock_delete(id):
        return id

    monkeypatch.setattr(crud, "delete", mock_delete)

    response = test_app.delete("/notes/1/")
    assert response.status_code == 200
    assert response.json() == test_data

def test_remove_note_incorrect_id(test_app, monkeypatch):
    async def mock_get(id):
        return None

    monkeypatch.setattr(crud, "get", mock_get)

    response = test_app.delete("/notes/999/")
    assert response.status_code == 404
    assert response.json()["detail"] == "Note not found"` 

密码

处理人:

`@router.delete("/{id}/", response_model=NoteDB)
async def delete_note(id: int):
    note = await crud.get(id)
    if not note:
        raise HTTPException(status_code=404, detail="Note not found")

    await crud.delete(id)

    return note` 

实用工具:

`async def delete(id: int):
    query = notes.delete().where(id == notes.c.id)
    return await database.execute(query=query)` 

确保所有测试都通过:

`=============================== test session starts ===============================
platform linux -- Python 3.11.0, pytest-7.2.0, pluggy-1.0.0
rootdir: /usr/src/app
plugins: anyio-3.6.2
collected 12 items

tests/test_notes.py ...........                                             [ 91%]
tests/test_ping.py .                                                        [100%]

=============================== 12 passed in 0.13s ================================` 

附加验证

让我们为路由添加一些额外的验证,检查:

  1. 对于读取单个笔记、更新笔记和删除笔记,id大于 0
  2. 来自请求有效负载的titledescription字段必须具有长度> = 3 和< = 50,以便添加和更新注释

得到

更新test_read_note_incorrect_id测试:

`def test_read_note_incorrect_id(test_app, monkeypatch):
    async def mock_get(id):
        return None

    monkeypatch.setattr(crud, "get", mock_get)

    response = test_app.get("/notes/999")
    assert response.status_code == 404
    assert response.json()["detail"] == "Note not found"

    response = test_app.get("/notes/0")
    assert response.status_code == 422` 

测试应该会失败:

`>       assert response.status_code == 422
E       assert 404 == 422
E        +  where 404 = <Response [404]>.status_code` 

更新处理程序:

`@router.get("/{id}/", response_model=NoteDB)
async def read_note(id: int = Path(..., gt=0),):
    note = await crud.get(id)
    if not note:
        raise HTTPException(status_code=404, detail="Note not found")
    return note` 

确保导入Path:

`from fastapi import APIRouter, HTTPException, Path` 

因此,我们向带有路径的参数添加了以下元数据:

  1. ... -该值是必需的(省略号)
  2. gt -该值必须大于 0 的

测试应该会通过。也尝试一下 API 文档:

swagger ui

邮政

更新test_create_note_invalid_json测试:

`def test_create_note_invalid_json(test_app):
    response = test_app.post("/notes/", content=json.dumps({"title": "something"}))
    assert response.status_code == 422

    response = test_app.post("/notes/", content=json.dumps({"title": "1", "description": "2"}))
    assert response.status_code == 422` 

为了让测试通过,像这样更新NoteSchema模型:

`class NoteSchema(BaseModel):
    title: str = Field(..., min_length=3, max_length=50)
    description: str = Field(..., min_length=3, max_length=50)` 

在这里,我们用字段向 Pydantic 模型添加了额外的验证。它的工作原理和Path一样。

添加导入:

`from pydantic import BaseModel, Field` 

test_update_note_invalid添加三个场景:

`@pytest.mark.parametrize(
    "id, payload, status_code",
    [
        [1, {}, 422],
        [1, {"description": "bar"}, 422],
        [999, {"title": "foo", "description": "bar"}, 404],
        [1, {"title": "1", "description": "bar"}, 422],
        [1, {"title": "foo", "description": "1"}, 422],
        [0, {"title": "foo", "description": "bar"}, 422],
    ],
)
def test_update_note_invalid(test_app, monkeypatch, id, payload, status_code):
    async def mock_get(id):
        return None

    monkeypatch.setattr(crud, "get", mock_get)

    response = test_app.put(f"/notes/{id}/", content=json.dumps(payload),)
    assert response.status_code == status_code` 

处理人:

`@router.put("/{id}/", response_model=NoteDB)
async def update_note(payload: NoteSchema, id: int = Path(..., gt=0),):
    note = await crud.get(id)
    if not note:
        raise HTTPException(status_code=404, detail="Note not found")

    note_id = await crud.put(id, payload)

    response_object = {
        "id": note_id,
        "title": payload.title,
        "description": payload.description,
    }
    return response_object` 

删除

测试:

`def test_remove_note_incorrect_id(test_app, monkeypatch):
    async def mock_get(id):
        return None

    monkeypatch.setattr(crud, "get", mock_get)

    response = test_app.delete("/notes/999/")
    assert response.status_code == 404
    assert response.json()["detail"] == "Note not found"

    response = test_app.delete("/notes/0/")
    assert response.status_code == 422` 

处理人:

`@router.delete("/{id}/", response_model=NoteDB)
async def delete_note(id: int = Path(..., gt=0)):
    note = await crud.get(id)
    if not note:
        raise HTTPException(status_code=404, detail="Note not found")

    await crud.delete(id)

    return note` 

测试应该通过:

`=============================== test session starts ===============================
platform linux -- Python 3.11.0, pytest-7.2.0, pluggy-1.0.0
rootdir: /usr/src/app
plugins: anyio-3.6.2
collected 15 items

tests/test_notes.py ..............                                          [ 93%]
tests/test_ping.py .                                                        [100%]

=============================== 15 passed in 0.14s ================================` 

同步示例

我们为这个 API 构建了一个同步版本,因此您可以比较这两个模型。您可以从 fastapi-crud-sync repo 中获取代码。尝试用 ApacheBench 对这两个版本进行一些性能测试。

结论

在本教程中,我们介绍了如何使用测试驱动开发,用 FastAPI、Postgres、pytest 和 Docker 开发和测试异步 API。

凭借 Flask 般的简单性、Django 般的电池和 Go/Node 般的性能,FastAPI 是一个强大的框架,使构建 RESTful APIs 变得简单而有趣。通过回顾本教程开头的目标和下面的每个挑战来检查你的理解。

想要更多的挑战吗?

  1. 复习官方教程。这本书很长,但很值得一读。
  2. 实现异步后台任务数据库迁移授权
  3. 将应用程序配置提取到单独的文件中。
  4. 在生产环境中,您可能想让 Gunicorn 来管理 Uvicorn。查看与 Gunicorn 一起运行和部署指南了解更多信息。看看官方的uvicon-guni corn-fastapiDocker 图片。
  5. 最后,查看测试驱动开发与 FastAPI 和 Docker 课程以及我们的其他 FastAPI 课程了解更多!

您可以在 fastapi-crud-async repo 中找到源代码。感谢阅读!

用 Postgres、Uvicorn 和 Traefik 对 FastAPI 进行 Dockerizing

原文:https://testdriven.io/blog/fastapi-docker-traefik/

在本教程中,我们将看看如何用 Postgres、Uvicorn 和 Docker 设置 FastAPI。对于生产环境,我们将添加 Gunicorn、Traefik,并进行加密。

项目设置

首先创建一个项目目录:

`$ mkdir fastapi-docker-traefik && cd fastapi-docker-traefik
$ python3.11 -m venv venv
$ source venv/bin/activate` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

然后,创建以下文件和文件夹:

`├── app
│   ├── __init__.py
│   └── main.py
└── requirements.txt` 

以下命令将创建项目结构:

`$ mkdir app && \
  touch app/__init__.py app/main.py requirements.txt` 

将 ASGI 服务器 FastAPIuvicon添加到 requirements.txt :

`fastapi==0.89.1
uvicorn==0.20.0` 

安装它们:

`(venv)$ pip install -r requirements.txt` 

接下来,让我们在 app/main.py 中创建一个简单的 FastAPI 应用程序:

`# app/main.py

from fastapi import FastAPI

app = FastAPI(title="FastAPI, Docker, and Traefik")

@app.get("/")
def read_root():
    return {"hello": "world"}` 

运行应用程序:

`(venv)$ uvicorn app.main:app` 

导航至 127.0.0.1:8000 。您应该看到:

一旦完成就杀死服务器。退出,然后也删除虚拟环境。

码头工人

安装 Docker ,如果你还没有的话,那么添加一个 Dockerfile 到项目根目录:

`# Dockerfile

# pull the official docker image
FROM  python:3.11.1-slim

# set work directory
WORKDIR  /app

# set env variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# install dependencies
COPY  requirements.txt .
RUN  pip install -r requirements.txt

# copy project
COPY  . .` 

所以,我们从 Python 3.11.1 的slim Docker 镜像开始。然后我们设置了一个工作目录以及两个环境变量:

  1. PYTHONDONTWRITEBYTECODE:防止 Python 将 pyc 文件写入磁盘(相当于python -B 选项
  2. PYTHONUNBUFFERED:防止 Python 缓冲 stdout 和 stderr(相当于python -u 选项

最后,我们复制了 requirements.txt 文件,安装了依赖项,并复制了项目。

查看 Docker 针对 Python 开发人员的最佳实践,了解更多关于构造 Docker 文件的信息,以及为基于 Python 的开发配置 Docker 的一些最佳实践。

接下来,将一个 docker-compose.yml 文件添加到项目根:

`# docker-compose.yml version:  '3.8' services: web: build:  . command:  uvicorn app.main:app --host 0.0.0.0 volumes: -  .:/app ports: -  8008:8000` 

查看合成文件参考,了解该文件如何工作的信息。

建立形象:

构建映像后,运行容器:

导航到 http://localhost:8008 再次查看 hello world 健全性检查。

如果这不起作用,通过docker-compose logs -f检查日志中的错误。

Postgres

要配置 Postgres,我们需要在 docker-compose.yml 文件中添加一个新服务,设置一个 ORM,并安装 asyncpg

首先,向 docker-compose.yml 添加一个名为db的新服务:

`# docker-compose.yml version:  '3.8' services: web: build:  . command:  bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; uvicorn app.main:app --host 0.0.0.0' volumes: -  .:/app ports: -  8008:8000 environment: -  DATABASE_URL=postgresql://fastapi_traefik:[[email protected]](/cdn-cgi/l/email-protection):5432/fastapi_traefik depends_on: -  db db: image:  postgres:15-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ expose: -  5432 environment: -  POSTGRES_USER=fastapi_traefik -  POSTGRES_PASSWORD=fastapi_traefik -  POSTGRES_DB=fastapi_traefik volumes: postgres_data:` 

为了在容器的生命周期之外保存数据,我们配置了一个卷。这个配置将把postgres_data绑定到容器中的“/var/lib/postgresql/data/”目录。

我们还添加了一个环境键来定义默认数据库的名称,并设置用户名和密码。

查看 Postgres Docker Hub 页面的“环境变量”部分了解更多信息。

注意web服务中的新命令:

`bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; uvicorn app.main:app --host 0.0.0.0'` 

将持续到 Postgres 完成。一旦启动,uvicorn app.main:app --host 0.0.0.0就会运行。

接下来,将一个名为 config.py 的新文件添加到“app”目录中,在这里我们将定义特定于环境的配置变量:

`# app/config.py

import os

from pydantic import BaseSettings, Field

class Settings(BaseSettings):
    db_url: str = Field(..., env='DATABASE_URL')

settings = Settings()` 

这里,我们用一个db_url属性定义了一个Settings类。来自 pydantic 的 BaseSettings 验证数据,这样当我们创建一个Settings的实例时,db_url将自动从环境变量中加载。

我们本来可以使用os.getenv(),但是随着环境变量数量的增加,这变得非常重复。通过使用BaseSettings,您可以指定环境变量名,它将被自动加载。

你可以在这里了解更多关于 pydantic 设置管理

我们将使用 ormar 与数据库通信。

requirements.txt 中添加 ormar ,一个 Python 的异步迷你 ORM,以及 asyncpg 和 psycopg2:

`asyncpg==0.27.0
fastapi==0.89.1
ormar==0.12.1
psycopg2-binary==2.9.5
uvicorn==0.20.0` 

请随意将 ormar 换成您选择的 ORM。寻找一些异步选项?看看令人敬畏的 FastAPI repo这个 Twitter 帖子

接下来,创建一个 app/db.py 文件来建立一个模型:

`# app/db.py

import databases
import ormar
import sqlalchemy

from .config import settings

database = databases.Database(settings.db_url)
metadata = sqlalchemy.MetaData()

class BaseMeta(ormar.ModelMeta):
    metadata = metadata
    database = database

class User(ormar.Model):
    class Meta(BaseMeta):
        tablename = "users"

    id: int = ormar.Integer(primary_key=True)
    email: str = ormar.String(max_length=128, unique=True, nullable=False)
    active: bool = ormar.Boolean(default=True, nullable=False)

engine = sqlalchemy.create_engine(settings.db_url)
metadata.create_all(engine)` 

这将创建一个 pydanic 模型和一个 SQLAlchemy 表。

ormar 使用 SQLAlchemy 创建数据库/表格并构建数据库查询,使用数据库异步执行查询,使用 pydantic 进行数据验证。注意,每个ormar.Model也是一个pydantic.BaseModel,所以所有的 pydantic 方法在一个模型上也是可用的。由于这些表是使用 SQLAlchemy(在幕后)创建的,所以可以通过 Alembic 进行数据库迁移。

查看官方 ormar 文档中的 Alembic 用法,了解更多关于使用 Alembic 和 ormar 的信息。

接下来,更新 app/main.py 以连接到数据库并添加一个虚拟用户:

`# app/main.py

from fastapi import FastAPI

from app.db import database, User

app = FastAPI(title="FastAPI, Docker, and Traefik")

@app.get("/")
async def read_root():
    return await User.objects.all()

@app.on_event("startup")
async def startup():
    if not database.is_connected:
        await database.connect()
    # create a dummy entry
    await User.objects.get_or_create(email="[[email protected]](/cdn-cgi/l/email-protection)")

@app.on_event("shutdown")
async def shutdown():
    if database.is_connected:
        await database.disconnect()` 

这里,我们使用 FastAPI 的事件处理程序来创建一个数据库连接。@app.on_event("startup")在应用程序启动前创建数据库连接池。

一旦建立了连接,startup 事件中的上面一行就会向我们的表中添加一个虚拟条目。确保条目仅在不存在时才被创建。

shutdown 事件关闭所有与数据库的连接。我们还添加了一条路由来显示users表中的所有条目。

构建新的映像并旋转两个容器:

`$ docker-compose up -d --build` 

确保users表已创建:

`$ docker-compose exec db psql --username=fastapi_traefik --dbname=fastapi_traefik

psql (15.1)
Type "help" for help.

fastapi_traefik=# \l
                                              List of databases
      Name       |      Owner      | Encoding |  Collate   |   Ctype    |          Access privileges
-----------------+-----------------+----------+------------+------------+-------------------------------------
 fastapi_traefik | fastapi_traefik | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres        | fastapi_traefik | UTF8     | en_US.utf8 | en_US.utf8 |
 template0       | fastapi_traefik | UTF8     | en_US.utf8 | en_US.utf8 | =c/fastapi_traefik                 +
                 |                 |          |            |            | fastapi_traefik=CTc/fastapi_traefik
 template1       | fastapi_traefik | UTF8     | en_US.utf8 | en_US.utf8 | =c/fastapi_traefik                 +
                 |                 |          |            |            | fastapi_traefik=CTc/fastapi_traefik
(4 rows)

fastapi_traefik=# \c fastapi_traefik
You are now connected to database "fastapi_traefik" as user "fastapi_traefik".

fastapi_traefik=# \dt
            List of relations
 Schema | Name  | Type  |      Owner
--------+-------+-------+-----------------
 public | users | table | fastapi_traefik
(1 row)

fastapi_traefik=# \q` 

您也可以通过运行以下命令来检查该卷是否已创建:

`$ docker volume inspect fastapi-docker-traefik_postgres_data` 

您应该会看到类似如下的内容:

`[
    {
        "CreatedAt": "2023-01-31T15:59:10Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "fastapi-docker-traefik",
            "com.docker.compose.version": "2.12.2",
            "com.docker.compose.volume": "postgres_data"
        },
        "Mountpoint": "/var/lib/docker/volumes/fastapi-docker-traefik_postgres_data/_data",
        "Name": "fastapi-docker-traefik_postgres_data",
        "Options": null,
        "Scope": "local"
    }
]` 

导航至 127.0.0.1:8008 。您应该看到:

生产文档

为了部署我们的应用程序,我们需要添加一个 WSGI 服务器 Gunicorn ,以生成 Uvicorn 的实例。我们不用编写自己的产品 Dockerfile ,我们可以利用uvicon-gunicorn,这是一个预建的 Docker 映像,包含 uvicon 和 guni corn,用于由核心 FastAPI 作者维护的高性能 web 应用程序。

创建一个名为 Dockerfile.prod 的新 Dockerfile,用于生产构建:

`# Dockerfile.prod

FROM  tiangolo/uvicorn-gunicorn:python3.11-slim

COPY  requirements.txt .
RUN  pip install -r requirements.txt

COPY  . .` 

就是这样。tiangolo/uvicorn-gunicorn:python3.11.1-slim 图像为我们做了很多工作。我们只是复制了 requirements.txt 文件,安装了依赖项,然后复制了所有的项目文件。

接下来,为生产创建一个名为 docker-compose.prod.yml 的新合成文件:

`# docker-compose.prod.yml version:  '3.8' services: web: build: context:  . dockerfile:  Dockerfile.prod ports: -  8009:80 environment: -  DATABASE_URL=postgresql://fastapi_traefik_prod:[[email protected]](/cdn-cgi/l/email-protection):5432/fastapi_traefik_prod depends_on: -  db db: image:  postgres:15-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ expose: -  5432 environment: -  POSTGRES_USER=fastapi_traefik_prod -  POSTGRES_PASSWORD=fastapi_traefik_prod -  POSTGRES_DB=fastapi_traefik_prod volumes: postgres_data_prod:` 

将这个文件与 docker-compose.yml 进行比较。有什么不同?

我们使用的uvicorn-gunicorn Docker 映像使用一个 prestart.sh 脚本在应用程序启动前运行命令。我们可以用这个来等待 Postgres。

修改 Dockerfile.prod 如下:

`# Dockerfile.prod

FROM  tiangolo/uvicorn-gunicorn:python3.11-slim

RUN  apt-get update && apt-get install -y netcat

COPY  requirements.txt .
RUN  pip install -r requirements.txt

COPY  . .` 

然后,将一个 prestart.sh 文件添加到项目的根目录:

`# prestart.sh

echo "Waiting for postgres connection"

while ! nc -z db 5432; do
    sleep 0.1
done

echo "PostgreSQL started"

exec "[[email protected]](/cdn-cgi/l/email-protection)"` 

在本地更新文件权限:

下放到开发容器(以及带有-v标志的相关卷):

然后,构建生产映像并启动容器:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

测试 127.0.0.1:8009 是否工作。

Traefik

接下来,让我们添加 Traefik ,一个反向代理

刚接触 Traefik?查看官方入门指南。

Traefik vs Nginx : Traefik 是一个现代的、HTTP 反向代理和负载平衡器。它经常被比作 Nginx ,一个网络服务器和反向代理。由于 Nginx 主要是一个网络服务器,它可以用来提供网页,也可以作为一个反向代理和负载平衡器。总的来说,Traefik 的启动和运行更简单,而 Nginx 的功能更丰富。

Traefik :

  1. 反向代理和负载平衡器
  2. 通过开箱即用的让我们加密,自动发布和更新 SSL 证书
  3. 将 Traefik 用于简单的、基于 Docker 的微服务

Nginx :

  1. Web 服务器、反向代理和负载平衡器
  2. 比 trafik 稍快
  3. 对复杂的服务使用 Nginx

添加一个名为 traefik.dev.toml 的新文件:

`# traefik.dev.toml # listen on port 80 [entryPoints] [entryPoints.web] address  =  ":80" # Traefik dashboard over http [api] insecure  =  true [log] level  =  "DEBUG" [accessLog] # containers are not discovered automatically [providers] [providers.docker] exposedByDefault  =  false` 

在这里,由于我们不想公开db服务,我们将 exposedByDefault 设置为false。要手动公开服务,我们可以将"traefik.enable=true"标签添加到 Docker 组合文件中。

接下来,更新 docker-compose.yml 文件,以便 Traefik 发现我们的web服务并添加一个新的traefik服务:

`# docker-compose.yml version:  '3.8' services: web: build:  . command:  bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; uvicorn app.main:app --host 0.0.0.0' volumes: -  .:/app expose:  # new -  8000 environment: -  DATABASE_URL=postgresql://fastapi_traefik:[[email protected]](/cdn-cgi/l/email-protection):5432/fastapi_traefik depends_on: -  db labels:  # new -  "traefik.enable=true" -  "traefik.http.routers.fastapi.rule=Host(`fastapi.localhost`)" db: image:  postgres:15-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ expose: -  5432 environment: -  POSTGRES_USER=fastapi_traefik -  POSTGRES_PASSWORD=fastapi_traefik -  POSTGRES_DB=fastapi_traefik traefik:  # new image:  traefik:v2.9.6 ports: -  8008:80 -  8081:8080 volumes: -  "./traefik.dev.toml:/etc/traefik/traefik.toml" -  "/var/run/docker.sock:/var/run/docker.sock:ro" volumes: postgres_data:` 

首先,web服务只对端口8000上的其他容器公开。我们还为web服务添加了以下标签:

  1. traefik.enable=true使 Traefik 能够发现服务
  2. traefik.http.routers.fastapi.rule=Host(fastapi.localhost)当请求有Host=fastapi.localhost时,请求被重定向到该服务

记下traefik服务中的卷:

  1. 将本地配置文件映射到容器中的配置文件,以便保持设置同步
  2. /var/run/docker.sock:/var/run/docker.sock:ro使 Traefik 能够发现其他容器

要进行测试,首先取下任何现有的容器:

`$ docker-compose down -v
$ docker-compose -f docker-compose.prod.yml down -v` 

构建新的开发映像并启动容器:

`$ docker-compose up -d --build` 

导航到http://fastapi.localhost:8008/您应该看到:

您也可以通过 cURL 进行测试:

`$ curl -H Host:fastapi.localhost http://0.0.0.0:8008` 

接下来,在 fastapi.localhost:8081 查看仪表盘:

traefik dashboard

完成后,将容器和体积拿下来:

让我们加密

我们已经在开发模式下成功地创建了 FastAPI、Docker 和 Traefik 的工作示例。对于生产,您需要配置 Traefik 来通过 Let's Encrypt 管理 TLS 证书。简而言之,Traefik 将自动联系证书颁发机构来颁发和续订证书。

因为 Let's Encrypt 不会为localhost颁发证书,所以你需要在云计算实例(比如 DigitalOcean droplet 或 AWS EC2 实例)上运行你的生产容器。您还需要一个有效的域名。如果你没有,你可以在 Freenom 创建一个免费域名。

我们使用一个 DigitalOcean droplet 为 Docker 提供一个计算实例,并部署生产容器来测试 Traefik 配置。

假设您配置了一个计算实例并设置了一个自由域,那么现在就可以在生产模式下设置 Traefik 了。

首先将 Traefik 配置的生产版本添加到名为 traefik.prod.toml 的文件中:

`# traefik.prod.toml [entryPoints] [entryPoints.web] address  =  ":80" [entryPoints.web.http] [entryPoints.web.http.redirections] [entryPoints.web.http.redirections.entryPoint] to  =  "websecure" scheme  =  "https" [entryPoints.websecure] address  =  ":443" [accessLog] [api] dashboard  =  true [providers] [providers.docker] exposedByDefault  =  false [certificatesResolvers.letsencrypt.acme] email  =  "[[email protected]](/cdn-cgi/l/email-protection)" storage  =  "/certificates/acme.json" [certificatesResolvers.letsencrypt.acme.httpChallenge] entryPoint  =  "web"` 

确保用您的实际电子邮件地址替换[[email protected]](/cdn-cgi/l/email-protection)

这里发生了什么:

  1. 将我们不安全的 HTTP 应用程序的入口点设置为端口 80
  2. 将我们的安全 HTTPS 应用程序的入口点设置为端口 443
  3. 将所有不安全的请求重定向到安全端口
  4. exposedByDefault = false取消所有服务
  5. dashboard = true启用监控仪表板

最后,请注意:

`[certificatesResolvers.letsencrypt.acme] email  =  "[[email protected]](/cdn-cgi/l/email-protection)" storage  =  "/certificates/acme.json" [certificatesResolvers.letsencrypt.acme.httpChallenge] entryPoint  =  "web"` 

这是让我们加密配置的地方。我们定义了证书将被存储在哪里以及验证类型,这是一个 HTTP 挑战

接下来,假设您更新了域名的 DNS 记录,创建两个新的 A 记录,它们都指向您的计算实例的公共 IP:

  1. fastapi-traefik.your-domain.com -用于网络服务
  2. dashboard-fastapi-traefik.your-domain.com -用于 Traefik 仪表板

确保用您的实际域名替换your-domain.com

接下来,像这样更新 docker-compose.prod.yml :

`# docker-compose.prod.yml version:  '3.8' services: web: build: context:  . dockerfile:  Dockerfile.prod expose:  # new -  80 environment: -  DATABASE_URL=postgresql://fastapi_traefik_prod:[[email protected]](/cdn-cgi/l/email-protection):5432/fastapi_traefik_prod depends_on: -  db labels:  # new -  "traefik.enable=true" -  "traefik.http.routers.fastapi.rule=Host(`fastapi-traefik.your-domain.com`)" -  "traefik.http.routers.fastapi.tls=true" -  "traefik.http.routers.fastapi.tls.certresolver=letsencrypt" db: image:  postgres:15-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ expose: -  5432 environment: -  POSTGRES_USER=fastapi_traefik_prod -  POSTGRES_PASSWORD=fastapi_traefik_prod -  POSTGRES_DB=fastapi_traefik_prod traefik:  # new build: context:  . dockerfile:  Dockerfile.traefik ports: -  80:80 -  443:443 volumes: -  "/var/run/docker.sock:/var/run/docker.sock:ro" -  "./traefik-public-certificates:/certificates" labels: -  "traefik.enable=true" -  "traefik.http.routers.dashboard.rule=Host(`dashboard-fastapi-traefik.your-domain.com`)  &&  (PathPrefix(`/`)" -  "traefik.http.routers.dashboard.tls=true" -  "traefik.http.routers.dashboard.tls.certresolver=letsencrypt" -  "[[email protected]](/cdn-cgi/l/email-protection)" -  "traefik.http.routers.dashboard.middlewares=auth" -  "traefik.http.middlewares.auth.basicauth.users=testuser:$$apr1$$jIKW.bdS$$eKXe4Lxjgy/rH65wP1iQe1" volumes: postgres_data_prod: traefik-public-certificates:` 

同样,确保用您的实际域名替换your-domain.com

这里有什么新鲜事?

web服务中,我们添加了以下标签:

  1. traefik.http.routers.fastapi.rule=Host(fastapi-traefik.your-domain.com)将主机更改为实际的域
  2. traefik.http.routers.fastapi.tls=true启用 HTTPS
  3. traefik.http.routers.fastapi.tls.certresolver=letsencrypt将证书颁发者设置为让我们加密

接下来,对于traefik服务,我们为证书目录添加了适当的端口和一个卷。该卷确保即使容器关闭,证书仍然有效。

至于标签:

  1. traefik.http.routers.dashboard.rule=Host(dashboard-fastapi-traefik.your-domain.com)定义仪表板主机,因此可以在$Host/dashboard/访问
  2. traefik.http.routers.dashboard.tls=true启用 HTTPS
  3. traefik.http.routers.dashboard.tls.certresolver=letsencrypt将证书解析器设置为“让我们加密”
  4. traefik.http.routers.dashboard.middlewares=auth启用HTTP BasicAuth中间件
  5. traefik.http.middlewares.auth.basicauth.users定义用于登录的用户名和散列密码

您可以使用 htpasswd 实用程序创建新的密码哈希:

`# username: testuser
# password: password

$ echo $(htpasswd -nb testuser password) | sed -e s/\\$/\\$\\$/g
testuser:$$apr1$$jIKW.bdS$$eKXe4Lxjgy/rH65wP1iQe1` 

随意使用一个env_file来存储用户名和密码作为环境变量

`USERNAME=testuser
HASHED_PASSWORD=$$apr1$$jIKW.bdS$$eKXe4Lxjgy/rH65wP1iQe1` 

最后,添加一个名为 Dockerfile.traefik 的新 Dockerfile:

`# Dockerfile.traefik

FROM  traefik:v2.9.6

COPY  ./traefik.prod.toml ./etc/traefik/traefik.toml` 

接下来,旋转新容器:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

确保这两个 URL 有效:

  1. https://fastapi-traefik.your-domain.com
  2. https://dashboard-fastapi-traefik.your-domain.com/dashboard

此外,请确保当您访问上述网址的 HTTP 版本时,您会被重定向到 HTTPS 版本。

最后,让我们加密有效期为 90 天的证书。Treafik 将在后台自动为您处理证书更新,这样您就少了一件担心的事情!

结论

在本教程中,我们介绍了如何用 Postgres 容器化一个 FastAPI 应用程序进行开发。我们还创建了一个生产就绪的 Docker Compose 文件,设置了 Traefik 和让我们加密,以便通过 HTTPS 为应用程序提供服务,并启用了一个安全的仪表板来监控我们的服务。

就生产环境的实际部署而言,您可能希望使用:

  1. 完全托管的数据库服务——像 RDS云 SQL——而不是在一个容器中管理你自己的 Postgres 实例。
  2. 服务的非根用户

您可以在fastapi-docker-traefikrepo 中找到代码。

将 FastAPI 应用程序部署到 Elastic Beanstalk

原文:https://testdriven.io/blog/fastapi-elastic-beanstalk/

在本教程中,我们将逐步完成将 FastAPI 应用程序部署到 AWS Elastic Beanstalk 的过程。

目标

本教程结束时,您将能够:

  1. 解释什么是弹性豆茎
  2. 初始化和配置弹性豆茎
  3. 对运行在 Elastic Beanstalk 上的应用程序进行故障排除
  4. 将弹性豆茎与 RDS 结合
  5. 通过 AWS 证书管理器获取 SSL 证书
  6. 使用 SSL 证书在 HTTPS 上提供您的应用程序

什么是弹性豆茎?

AWS Elastic Beanstalk (EB)是一个易于使用的服务,用于部署和扩展 web 应用程序。它连接多个 AWS 服务,例如计算实例( EC2 )、数据库( RDS )、负载平衡器(应用负载平衡器)和文件存储系统( S3 ),等等。EB 允许您快速开发和部署 web 应用程序,而无需考虑底层基础设施。它支持用 Go、Java、.NET、Node.js、PHP、Python 和 Ruby。如果您需要配置自己的软件栈或部署用 EB 目前不支持的语言(或版本)开发的应用程序,EB 也支持 Docker。

典型的弹性豆茎设置:

Elastic Beanstalk Architecture

AWS 弹性豆茎不另收费。您只需为应用程序消耗的资源付费。

要了解更多关于弹性豆茎的信息,请查看什么是 AWS 弹性豆茎?来自官方 AWS 弹性豆茎文档

弹性豆茎概念

在开始学习教程之前,让我们先来看看与 Elastic Beanstalk 相关的几个关键概念:

  1. 一个 应用 是弹性 Beanstalk 组件的逻辑集合,包括环境、版本和环境配置。一个应用程序可以有多个版本
  2. 一个 环境 是运行一个应用版本的 AWS 资源的集合。
  3. 一个 平台 是操作系统、编程语言运行时、web 服务器、应用服务器和弹性 Beanstalk 组件的组合。

这些术语将在整个教程中使用。

项目设置

在本教程中,我们将部署一个简单的 FastAPI 应用程序,名为 fastapi-songs

按照教程进行操作时,通过部署您自己的应用程序来检查您的理解。

首先,从 GitHub 上的库获取代码:

创建新的虚拟环境并激活它:

`$ python3 -m venv venv && source venv/bin/activate` 

安装需求并初始化数据库:

`(venv)$ pip install -r requirements.txt
(venv)$ python init_db.py` 

运行服务器:

`(venv)$ uvicorn main:app --reload` 

打开您最喜欢的 web 浏览器,导航至:

  1. http://localhost:8000 -应该显示“fastapi-songs”文本
  2. http://localhost:8000/songs-应该显示歌曲列表

弹性豆茎 CLI

在继续之前,请务必在注册一个 AWS 帐户。通过创建一个账户,你可能也有资格加入 AWS 免费等级

Elastic Beanstalk 命令行界面 (EB CLI)允许您执行各种操作来部署和管理您的 Elastic Beanstalk 应用程序和环境。

有两种安装 EB CLI 的方法:

  1. 通过 EB CLI 安装程序
  2. pip (awsebcli)

建议使用安装程序(第一个选项)全局安装 EB CLI(任何特定虚拟环境之外),以避免可能的依赖冲突。更多详情请参考本解释

安装 EB CLI 后,您可以通过运行以下命令来检查版本:

`$ eb --version

EB CLI 3.20.3 (Python 3.10.)` 

如果该命令不起作用,您可能需要将 EB CLI 添加到$PATH中。

EB CLI 命令列表及其描述可在 EB CLI 命令参考中找到。

初始化弹性豆茎

一旦我们运行了 EB CLI,我们就可以开始与 Elastic Beanstalk 交互了。让我们初始化一个新项目和一个 EB 环境。

初始化

在项目根目录(“fastapi-songs”)中,运行:

你会被提示一些问题。

默认区域

您的弹性 Beanstalk 环境的 AWS 区域(和资源)。如果您不熟悉不同的 AWS 区域,请查看 AWS 区域和可用区域。一般来说,你应该选择离你的客户最近的地区。请记住,资源价格因地区而异。

应用程序名称

这是您的弹性 Beanstalk 应用程序的名称。我建议按下回车键,使用默认设置:“fastapi-songs”。

平台和平台分支

EB CLI 将检测到您正在使用 Python 环境。之后,它会给你不同的 Python 版本和 Amazon Linux 版本供你使用。选择“运行在 64 位亚马逊 Linux 2 上的 Python 3.8”。

代码提交

CodeCommit 是一个安全的、高度可伸缩的、托管的源代码控制服务,托管私有的 Git 存储库。我们不会使用它,因为我们已经在使用 GitHub 进行源代码控制。所以说“不”。

为了稍后连接到 EC2 实例,我们需要设置 SSH。出现提示时,说“是”。

密钥对

为了连接到 EC2 实例,我们需要一个 RSA 密钥对。继续生成一个,它将被添加到您的“~/”中。ssh”文件夹。

回答完所有问题后,您会注意到项目根目录下有一个隐藏的目录,名为。elasticbeanstalk”。该目录应该包含一个 config.yml 文件,其中包含您刚才提供的所有数据。

`.elasticbeanstalk
└── config.yml` 

该文件应包含类似以下内容:

`branch-defaults: master: environment:  null group_suffix:  null global: application_name:  fastapi-songs branch:  null default_ec2_keyname:  aws-eb default_platform:  Python 3.8 running on 64bit Amazon Linux 2 default_region:  us-west-2 include_git_submodules:  true instance_profile:  null platform_name:  null platform_version:  null profile:  eb-cli repository:  null sc:  git workspace_type:  Application` 

创造

接下来,让我们创建弹性 Beanstalk 环境并部署应用程序:

同样,系统会提示您几个问题。

环境名称

这表示 EB 环境的名称。我建议坚持使用默认值:“fastapi-songs-env”。

└-env└-dev后缀添加到您的环境中被认为是一种很好的做法,这样您就可以很容易地将 EB 应用程序与环境区分开来。

DNS CNAME 前缀

您的 web 应用程序将在%cname%.%region%.elasticbeanstalk.com可访问。同样,使用默认值。

负载平衡

负载平衡器在您的环境实例之间分配流量。选择“应用程序”。

如果您想了解不同的负载平衡器类型,请查看适用于您的弹性 Beanstalk 环境的负载平衡器。

现货车队请求

Spot Fleet 请求允许您根据自己的标准按需启动实例。我们不会在本教程中使用它们,所以说“不”。

--

有了它,环境将会旋转起来:

  1. 你的代码将被压缩并上传到一个新的 S3 桶。
  2. 之后,将创建各种 AWS 资源,如负载平衡器、安全和自动伸缩组以及 EC2 实例。

还将部署一个新的应用程序。

这将需要大约三分钟,所以请随意拿一杯咖啡。

部署完成后,EB CLI 将修改。elasticbeanstalk/config.yml

您的项目结构现在应该如下所示:

`|-- .elasticbeanstalk
|   └-- config.yml
|-- .gitignore
|-- README.md
|-- database.py
|-- default.db
|-- init_db.py
|-- main.py
|-- models.py
└-- requirements.txt` 

状态

部署应用后,您可以通过运行以下命令来检查其状态:

`$ eb status

Environment details for: fastapi-songs-env
  Application name: fastapi-songs
  Region: us-west-2
  Deployed Version: app-82fb-220311_171256090207
  Environment ID: e-nsizyek74z
  Platform: arn:aws:elasticbeanstalk:us-west-2::platform/Python 3.8 running on 64bit Amazon Linux 2/3.3.11
  Tier: WebServer-Standard-1.0
  CNAME: fastapi-songs-env.us-west-2.elasticbeanstalk.com
  Updated: 2022-03-11 23:16:03.822000+00:00
  Status: Launching
  Health: Red` 

您可以看到我们环境的当前健康状况是Red,这意味着出现了问题。暂时不要担心这个问题,我们将在接下来的步骤中解决它。

您还可以看到,AWS 为我们分配了一个 CNAME,这是我们的 EB 环境的域名。我们可以通过打开浏览器并导航到 CNAME 来访问 web 应用程序。

打开

此命令将打开您的默认浏览器并导航到 CNAME 域。你会看到502 Bad Gateway,我们很快会在这里修复它。

安慰

该命令将在您的默认浏览器中打开 Elastic Beanstalk 控制台:

Elastic Beanstalk Console

同样,您可以看到环境的健康状况是“严重的”,我们将在下一步中解决这个问题。

配置环境

在上一步中,我们尝试访问我们的应用程序,它返回了502 Bad Gateway。背后有三个原因:

  1. Python 需要PYTHONPATH来在我们的应用程序中找到模块。
  2. 默认情况下,Elastic Beanstalk 试图从不存在的 application.py 启动 WSGI 应用程序。
  3. 如果没有特别说明,Elastic Beanstalk 会尝试使用 Gunicorn 为 Python 应用程序提供服务。Gunicorn 本身与 FastAPI 不兼容,因为 FastAPI 使用最新的 ASGI 标准。

让我们修复这些错误。

在项目根目录下创建一个名为“”的新文件夹。ebextensions”。在新创建的文件夹中创建一个名为 01_fastapi.config 的文件:

`# .ebextensions/01_fastapi.config option_settings: aws:elasticbeanstalk:application:environment: PYTHONPATH:  "/var/app/current:$PYTHONPATH" aws:elasticbeanstalk:container:python: WSGIPath:  "main:app"` 

注意事项:

  1. 我们将PYTHONPATH设置为 EC2 实例上的 Python 路径( docs )。
  2. 我们将WSGIPath更改为我们的 WSGI 应用程序( docs )。

EB 如何。config 文件管用吗?

  1. 你想要多少就有多少。
  2. 它们按以下顺序加载:01_x、02_x、03_x 等。
  3. 您不必记住这些设置;您可以通过运行eb config列出您的所有环境设置。

如果您想了解更多关于高级环境定制的信息,请查看带有配置文件的高级环境定制

由于 FastAPI 本身不支持 Gunicorn,我们将不得不修改我们的web process 命令来使用一个 Uvicorn 工人类

uvicon是 FastAPI 推荐的 ASGI web 应用服务器。

在项目根目录下创建一个名为 Procfile 的新文件:

`# Procfile

web: gunicorn main:app --workers=4 --worker-class=uvicorn.workers.UvicornWorker` 

如果您正在部署自己的应用程序,请确保将uvicorn作为依赖项添加到 requirements.txt 中。

有关 Procfile 的更多信息,请查看用 Procfile 配置 WSGI 服务器。

最后,我们必须告诉 Elastic Beanstalk 在部署新的应用程序版本时初始化数据库。将以下内容添加到的末尾。EB extensions/01 _ fastapi . config:

`# .ebextensions/01_fastapi.config container_commands: 01_initdb: command:  "source /var/app/venv/*/bin/activate && python3 init_db.py" leader_only:  true` 

现在,每当我们部署一个新的应用程序版本时,EB 环境都会执行上面的命令。我们使用了leader_only,所以只有第一个 EC2 实例执行它们(以防我们的 EB 环境运行多个 EC2 实例)。

弹性 Beanstalk 配置支持两个不同的命令部分,命令容器 _ 命令。它们之间的主要区别在于它们在部署过程中的运行时间:

  1. commands在设置应用程序和 web 服务器以及提取应用程序版本文件之前运行。
  2. container_commands在应用程序和 web 服务器已设置且应用程序版本存档已提取之后,但在应用程序版本部署之前(在文件从暂存文件夹移动到其最终位置之前)运行。

此时,您的项目结构应该如下所示:

`|-- .ebextensions
|   └-- 01_fastapi.config
|-- .elasticbeanstalk
|   └-- config.yml
|-- .gitignore
|-- Procfile
|-- README.md
|-- database.py
|-- default.db
|-- init_db.py
|-- main.py
|-- models.py
`-- requirements.txt` 

将更改提交给 git 并部署:

`$ git add .
$ git commit -m "updates for eb"

$ eb deploy` 

您会注意到,如果您不提交,Elastic Beanstalk 不会检测到这些变化。这是因为 EB 与 git 集成,并且只检测提交的(更改的)文件。

部署完成后,运行eb open看看是否一切正常。之后,将/songs追加到 URL,看看歌曲是否还能显示。

耶!我们的应用程序的第一个版本现已部署。

配置 RDS

如果你正在部署 fastapi-songs ,你会注意到它默认使用一个 SQLite 数据库。虽然这对于开发来说是完美的,但是对于生产来说,您通常会希望迁移到更健壮的数据库,比如 Postgres 或 MySQL。让我们看看如何将 SQLite 替换为 Postgres

本地邮政汇票

首先,让 Postgres 在本地运行。您可以从 PostgreSQL Downloads 下载它,或者启动 Docker 容器:

`$ docker run --name fastapi-songs-postgres -p 5432:5432 \
    -e POSTGRES_USER=fastapi-songs -e POSTGRES_PASSWORD=complexpassword123 \
    -e POSTGRES_DB=fastapi-songs -d postgres` 

检查容器是否正在运行:

`$ docker ps -f name=fastapi-songs-postgres

CONTAINER ID   IMAGE      COMMAND                  CREATED              STATUS              PORTS                    NAMES
c05621dac852   postgres   "docker-entrypoint.s…"   About a minute ago   Up About a minute   0.0.0.0:5432->5432/tcp   fastapi-songs-postgres` 

现在,让我们尝试用 FastAPI 应用程序连接到它。

database.py 里面把DATABASE_URL改成这样:

`# database.py

DATABASE_URL = \
    'postgresql://{username}:{password}@{host}:{port}/{database}'.format(
        username='fastapi-songs',
        password='complexpassword123',
        host='localhost',
        port='5432',
        database='fastapi-songs',
    )` 

之后,从create_engine中删除connect_args,因为check_same_thread仅在 SQLite 中需要。

`# database.py

engine = create_engine(
    DATABASE_URL,
)` 

接下来,安装 Postgres 所需的 psycopg2-binary :

`(venv)$ pip install psycopg2-binary==2.9.3` 

添加到 requirements.txt :

`fastapi==0.75.0 psycopg2-binary==2.9.3 SQLAlchemy==1.4.32 uvicorn[standard]==0.17.6` 

删除现有数据库 default.db ,然后初始化新数据库:

`(venv)$ python init_db.py` 

运行服务器:

`(venv)$ uvicorn main:app --reload` 

通过查看http://localhost:8000/songs,确保歌曲仍然可以正确播放。

AWS RDS Postgres

要为生产设置 Postgres,首先运行以下命令打开 AWS 控制台:

单击左侧栏上的“配置”,向下滚动到“数据库”,然后单击“编辑”。

使用以下设置创建一个数据库,然后单击“应用”:

  • 引擎:postgres
  • 引擎版本:12.9(自 db.t2.micro 以来的旧 Postgres 版本在 13.1+版本中不可用)
  • 实例类:db.t2.micro
  • 存储:5 GB(应该绰绰有余)
  • 用户名:选择一个用户名
  • 密码:选择一个强密码

如果你想留在 AWS 免费层内,确保你选择 db.t2.micro. RDS 价格会根据你选择的实例类呈指数增长。如果你不想和micro一起去,一定要复习 AWS PostgreSQL 定价

RDS settings

环境更新完成后,EB 会自动将以下数据库凭证传递给我们的 FastAPI 应用程序:

`RDS_DB_NAME
RDS_USERNAME
RDS_PASSWORD
RDS_HOSTNAME
RDS_PORT` 

我们现在可以在 database.py 中使用这些变量来连接我们的数据库。将DATABASE_URL替换为以下内容:

`if 'RDS_DB_NAME' in os.environ:
    DATABASE_URL = \
        'postgresql://{username}:{password}@{host}:{port}/{database}'.format(
            username=os.environ['RDS_USERNAME'],
            password=os.environ['RDS_PASSWORD'],
            host=os.environ['RDS_HOSTNAME'],
            port=os.environ['RDS_PORT'],
            database=os.environ['RDS_DB_NAME'],
        )
else:
    DATABASE_URL = \
        'postgresql://{username}:{password}@{host}:{port}/{database}'.format(
            username='fastapi-songs',
            password='complexpassword123',
            host='localhost',
            port='5432',
            database='fastapi-songs',
        )` 

不要忘记导入 database.py 顶部的os包:

你最终的 database.py 文件应该看起来像这个

将更改提交给 git 并部署:

`$ git add .
$ git commit -m "updates for eb"

$ eb deploy` 

等待部署完成。完成后,运行eb open在新的浏览器标签中打开你的应用。通过在/songs列出歌曲来确保一切正常运行。

HTTPS 与证书管理器

教程的这一部分要求您有一个域名。

需要一个便宜的域名来练习?几个域名注册商有特殊优惠。“xyz”域。或者,您可以在 Freenom 创建一个免费域名。如果你没有域名,但仍然想使用 HTTPS,你可以创建并签署一个 X509 证书

要通过 HTTPS 为您的申请提供服务,我们需要:

  1. 请求并验证 SSL/TLS 证书
  2. 把你的域名指向你的 EB CNAME
  3. 修改负载平衡器以服务于 HTTPS
  4. 修改您的应用程序设置

请求并验证 SSL/TLS 证书

导航到 AWS 证书管理器控制台。单击“申请证书”。将证书类型设置为“公共”,然后单击“下一步”。在表单输入中输入您的全限定域名,设置“验证方式”为“DNS 验证”,点击“请求”。

AWS Request Public Certificate

然后,您将被重定向到一个页面,在那里您可以看到您的所有证书。您刚刚创建的证书应该具有“待验证”状态。

为了让 AWS 颁发证书,你首先必须证明你是这个域名的所有者。在表中,单击证书以查看“证书详细信息”。注意“CNAME 的名字”和“CNAME 的价值”。要验证域的所有权,您需要在域的 DNS 设置中创建“CNAME 记录”。为此使用“CNAME 名称”和“CNAME 价值”。一旦完成,Amazon 将需要几分钟的时间来获取域更改并颁发证书。状态应该从“等待验证”更改为“已发布”。

将域名指向 EB CNAME

接下来,您需要将您的域(或子域)指向您的 EB 环境 CNAME。回到您域名的 DNS 设置,添加另一个 CNAME 记录,其值为您的 EB CNAME -例如,fastapi-songs-dev.us-west-2.elasticbeanstalk.com

等待几分钟,让您的 DNS 刷新,然后在浏览器中测试您的域名的http://风格。

修改负载平衡器以服务于 HTTPS

回到弹性豆茎控制台,点击“配置”。然后,在“负载平衡器”类别中,单击“编辑”。单击“添加监听程序”并使用以下详细信息创建监听程序:

  1. 端口- 443
  2. 议定书- HTTPS
  3. SSL 证书-选择您刚刚创建的证书

点击“添加”。然后,滚动到页面底部,单击“应用”。环境更新需要几分钟时间。

修改您的应用程序设置

接下来,我们需要对 FastAPI 应用程序进行一些更改。

我们需要将所有流量从 HTTP 重定向到 HTTPS。有多种方法可以做到这一点,但最简单的方法是将 Apache 设置为代理主机。我们可以通过在中的option_settings末尾添加以下内容来编程实现这一点。EB extensions/01 _ fastapi . config:

`# .ebextensions/01_fastapi.config

option_settings:
  # ...
  aws:elasticbeanstalk:environment:proxy:  # new
    ProxyServer: apache                    # new` 

您最终的 01_fastapi.config 文件现在应该是这样的:

`# .ebextensions/01_fastapi.config option_settings: aws:elasticbeanstalk:application:environment: PYTHONPATH:  "/var/app/current:$PYTHONPATH" aws:elasticbeanstalk:container:python: WSGIPath:  "main:app" aws:elasticbeanstalk:environment:proxy: ProxyServer:  apache container_commands: 01_initdb: command:  "source /var/app/venv/*/bin/activate && python3 init_db.py" leader_only:  true` 

接下来,创建一个”。平台"文件夹中,并添加以下文件和文件夹:

`└-- .platform
    └-- httpd
        └-- conf.d
            └-- ssl_rewrite.conf` 

ssl_rewrite.conf :

`# .platform/httpd/conf.d/ssl_rewrite.conf

RewriteEngine On
<If "-n '%{HTTP:X-Forwarded-Proto}' && %{HTTP:X-Forwarded-Proto} != 'https'">
RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R,L]
</If>` 

您的项目结构现在应该如下所示:

`|-- .ebextensions
|   └-- 01_fastapi.config
|-- .elasticbeanstalk
|   └-- config.yml
|-- .gitignore
├── .platform
│   └── httpd
│       └── conf.d
│           └── ssl_rewrite.conf
|-- Procfile
|-- README.md
|-- database.py
|-- default.db
|-- init_db.py
|-- main.py
|-- models.py
└-- requirements.txt` 

将更改提交给 git 并部署:

`$ git add .
$ git commit -m "updates for eb"

$ eb deploy` 

现在,在你的浏览器中,你的应用程序的https://风格应该工作了。试试去http://味的。你应该被重定向到https://风味。确保证书也正确加载:

secure app

环境变量

在生产中,最好将特定于环境的配置存储在环境变量中。使用 Elastic Beanstalk,您可以用两种不同的方式设置自定义环境变量。

通过 EB CLI 的环境变量

您可以执行一个命令来设置它们,如下所示:

`$ eb setenv VARIABLE_NAME='variable value'` 

您可以用一个命令设置多个环境变量,用空格分隔它们。这是推荐的方法,因为它只需要对 EB 环境进行一次更新。

然后,您可以通过os.environ在您的 Python 环境中访问这些变量。

例如:

`VARIABLE_NAME = os.environ['VARIABLE_NAME']` 

通过 EB 控制台的环境变量

通过eb open进入弹性豆茎控制台。导航至“配置”>“软件”>“编辑”。然后,向下滚动到“环境属性”。

AWS Elastic Beanstalk Environment Variables

完成后,单击“应用”,您的环境将会更新。

同样,您可以通过os.environ访问 Python 中的环境变量。

调试弹性豆茎

当使用 Elastic Beanstalk 时,如果您不知道如何访问日志文件,那么找出问题所在会非常令人沮丧。在本节中,我们将研究这一点。

有两种方法可以访问日志:

  1. 弹性 Beanstalk CLI 或控制台
  2. SSH 到 EC2 实例

从个人经验来看,我已经能够用第一种方法解决所有问题。

弹性 Beanstalk CLI 或控制台

CLI:

该命令将从以下文件中获取最后 100 行:

`/var/log/web.stdout.log /var/log/eb-hooks.log /var/log/nginx/access.log /var/log/nginx/error.log /var/log/eb-engine.log` 

运行eb logs相当于登录 EB 控制台,导航到“日志”。

我建议将日志传送到 CloudWatch 。运行以下命令来启用此功能:

`$ eb logs --cloudwatch-logs enable` 

您通常会在 /var/log/web.stdout.log/var/log/eb-engine.log 中找到 FastAPI 错误。

要了解更多关于弹性 Beanstalk 日志的信息,请查看来自 Amazon EC2 实例的日志。

SSH 到 EC2 实例

要连接到运行 FastAPI 应用程序的 EC2 实例,请运行:

第一次会提示您将主机添加到已知主机。答应吧。这样,您现在就可以完全访问 EC2 实例了。请随意检查上一节中提到的一些日志文件。

请记住,Elastic Beanstalk 会自动伸缩和部署新的 EC2 实例。您在这个特定 EC2 实例上所做的更改不会反映在新启动的 EC2 实例上。一旦这个特定的 EC2 实例被替换,您的更改将被清除。

结论

在本教程中,我们介绍了将 FastAPI 应用程序部署到 AWS Elastic Beanstalk 的过程。到目前为止,您应该对弹性豆茎的工作原理有了一个大致的了解。通过回顾本教程开头的目标,快速进行自我检查。

后续步骤:

  1. 你应该考虑创建两个独立的 EB 环境(devproduction)。
  2. 查看用于您的弹性 Beanstalk 环境的自动伸缩组,了解如何配置触发器来自动伸缩您的应用程序。

要删除我们在整个教程中创建的所有 AWS 资源,首先要终止 Elastic Beanstalk 环境:

您需要手动删除 SSL 证书。

最后,你可以在 GitHub 上的fastapi-elastic-beanstalkrepo 中找到代码的最终版本。

用 FastAPI 和 GraphQL 开发 API

原文:https://testdriven.io/blog/fastapi-graphql/

在本教程中,你将学习如何用 FastAPIGraphQLMasonite ORM 构建 CRUD 应用。

目标

本教程结束时,您将能够:

  1. 解释为什么你可能想使用 GraphQL 而不是 REST
  2. 使用 Masonite ORM 与 Postgres 数据库进行交互
  3. 描述 GraphQL 中有哪些模式、变异和查询
  4. 使用石墨烯将 GraphQL 集成到 FastAPI 应用程序中
  5. 用 Graphene 和 pytest 测试 GraphQL API

为什么是 GraphQL?

(为什么 GraphQL 优于传统的 REST?)

REST 是构建 web APIs 的事实上的标准。使用 REST,每个 CRUD 操作都有多个端点:GET、POST、PUT、DELETE。通过访问多个端点来收集数据。

例如,如果您想获得特定用户的个人资料信息以及他们的帖子和相关评论,您需要调用四个不同的端点:

  1. /users/<id>返回初始用户数据
  2. /users/<id>/posts返回给定用户的所有帖子
  3. /users/<post_id>/comments返回每篇文章的评论列表
  4. /users/<id>/comments返回每个用户的评论列表

这可能导致请求过度提取,因为你可能不得不获取比你需要的多得多的数据。

此外,由于一个客户的需求可能与其他客户的需求大相径庭,请求过度提取和提取不足在 REST 中是很常见的。

同时,GraphQL 是一种用于从 API 中检索数据的查询语言。GraphQL 不是有多个端点,而是围绕单个端点构建,其返回值取决于客户端想要什么,而不是端点返回什么

在 GraphQL 中,您可以像这样构建一个查询来获取用户的个人资料、帖子和评论:

`query {
  User(userId: 2){
    name
    posts {
      title
      comments {
        body
      }
    }
    comments {
      body
    }
  }
}` 

瞧啊。您可以在一个请求中获得所有数据,而不会过度提取,因为我们确切地指定了我们想要的数据。

FastAPI 通过 StarletteGraphene 支持 GraphQL。当您不使用异步请求处理程序时,Starlette 默认在单独的线程中执行 GraphQL 查询!

为什么选择 Masonite ORM?

Masonite ORM 是一个干净的、易于使用的对象关系映射库,它是为 Masonite web 框架构建的。它基于演说家 ORM ,一个活动记录 ORM

Masonite ORM 被开发出来作为 astorator ORM 的替代品,因为 astorator 不再接收更新和错误修复。

它类似于其他流行的活动记录实现,如 Django 的 ORM ,Laravel 的雄辩,adon isjs’Lucid,以及 Ruby On Rails 中的活动记录。由于支持 MySQL、Postgres 和 SQLite,它强调约定胜于配置,这使得创建模型变得容易,因为您不必显式定义每个方面。关系是一件轻而易举的事,也很容易处理。

尽管 Masonite ORM 是为 Masonite web 项目设计的,但是您也可以将 mason ite ORM 用于其他 Python web 框架或项目。

有关 Masonite ORM 以及如何使用 FastAPI 的更多信息,请查看mason ite ORM 与 FastAPI 的集成

项目设置

创建一个目录来保存名为“fastapi-graphql”的项目:

`$ mkdir fastapi-graphql
$ cd fastapi-graphql` 

创建虚拟环境并激活它:

`$ python3.11 -m venv env
$ source env/bin/activate

(env)$` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

在“fastapi-graphql”目录中创建以下文件:

将以下要求添加到 requirements.txt 文件中:

`fastapi==0.92.0
uvicorn==0.20.0` 

uvicon是一个 ASGI (异步服务器网关接口)兼容的服务器,将用于站立 FastAPI。

安装依赖项:

`(env)$ pip install -r requirements.txt` 

main.py 文件中,添加以下行来启动服务器:

`from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def ping():
    return {"ping": "pong"}` 

要启动服务器,请打开终端,导航到项目目录,然后输入以下命令:

`(env)$ uvicorn main:app --reload` 

在您选择的浏览器中导航到 http://localhost:8000 。您应该会看到响应:

您已经成功启动了一个简单的 FastAPI 服务器。要查看 FastAPI 为我们准备的漂亮的文档,请导航至http://localhost:8000/docs:

Swagger Docs

以及http://localhost:8000/redoc:

Redoc Docs

Masonite ORM

将以下需求添加到 requirements.txt 文件中:

`masonite-orm==2.18.6
psycopg2-binary==2.9.5` 

安装新的依赖项:

`(env)$ pip install -r requirements.txt` 

创建以下文件夹:

`models
databases/migrations
config` 

“models”文件夹将包含我们的模型文件,“databases/migrations”文件夹将包含我们的迁移文件,“config”文件夹将保存我们的 Masonite 数据库配置文件。

数据库配置

在“config”文件夹中,创建一个 database.py 文件。Masonite ORM 需要这个文件,因为这是我们声明数据库配置的地方。

欲了解更多信息,请访问文档

database.py 文件中,我们需要添加DATABASE变量和一些连接信息,从masonite-orm.connections导入ConnectionResolver,并注册连接细节:

`# config/database.py

from masoniteorm.connections import ConnectionResolver

DATABASES = {
  "default": "postgres",
  "mysql": {
    "host": "127.0.0.1",
    "driver": "mysql",
    "database": "masonite",
    "user": "root",
    "password": "",
    "port": 3306,
    "log_queries": False,
    "options": {
      #
    }
  },
  "postgres": {
    "host": "127.0.0.1",
    "driver": "postgres",
    "database": "test",
    "user": "test",
    "password": "test",
    "port": 5432,
    "log_queries": False,
    "options": {
      #
    }
  },
  "sqlite": {
    "driver": "sqlite",
    "database": "db.sqlite3",
  }
}

DB = ConnectionResolver().set_connection_details(DATABASES)` 

这里,我们定义了三种不同的数据库设置:

  1. 关系型数据库
  2. Postgres
  3. SQLite

我们将默认连接设置为 Postgres。

注意:确保您已经启动并运行了 Postgres 数据库。请随意更改默认的数据库连接。

Masonite 型号

要创建一个新的样板文件 Masonite 模型,从终端的项目根文件夹中运行下面的masonite-orm命令:

`(env)$ masonite-orm model User --directory models` 

您应该会看到一条成功消息:

`Model created: models/User.py` 

因此,该命令应该在“models”目录中创建一个包含以下内容的 User.py 文件:

`""" User Model """

from masoniteorm.models import Model

class User(Model):
    """User Model"""

    pass` 

如果您收到一个FileNotFoundError,检查以确保“模型”文件夹存在。

对帖子和评论模型运行相同的命令:

`(env)$ masonite-orm model Post --directory models
> Model created: models/Post.py

(env)$ masonite-orm model Comment --directory models
> Model created: models/Comment.py` 

接下来,我们可以创建初始迁移:

`(env)$ masonite-orm migration migration_for_user_table --create users` 

我们添加了--create标志来告诉 Masonite 将要创建的迁移文件是针对我们的users表的,并且应该在迁移运行时创建数据库表。

在“数据库/迁移”文件夹中,应该已经创建了一个新文件:

<timestamp>_migration_for_user_table.py

内容:

`"""MigrationForUserTable Migration."""

from masoniteorm.migrations import Migration

class MigrationForUserTable(Migration):
    def up(self):
        """
 Run the migrations.
 """
        with self.schema.create("users") as table:
            table.increments("id")

            table.timestamps()

    def down(self):
        """
 Revert the migrations.
 """
        self.schema.drop("users")` 

创建剩余的迁移文件:

`(env)$ masonite-orm migration migration_for_post_table --create posts
> Migration file created: databases/migrations/2022_05_04_084820_migration_for_post_table.py

(env)$ masonite-orm migration migration_for_comment_table --create comments
> Migration file created: databases/migrations/2022_05_04_084833_migration_for_comment_table.py` 

数据库表

users表应该有以下字段:

  1. 名字
  2. 电子邮件(唯一)
  3. 地址(可选)
  4. 电话号码(可选)
  5. 性别(可选)

将与用户模型相关联的迁移文件更改为:

`"""MigrationForUserTable Migration."""

from masoniteorm.migrations import Migration

class MigrationForUserTable(Migration):
    def up(self):
        """
 Run the migrations.
 """
        with self.schema.create("users") as table:
            table.increments("id")
            table.string("name")
            table.string("email").unique()
            table.text("address").nullable()
            table.string("phone_number", 11).nullable()
            table.enum("sex", ["male", "female"]).nullable()
            table.timestamps()

    def down(self):
        """
 Revert the migrations.
 """
        self.schema.drop("users")` 

有关表方法和列类型的更多信息,请查看文档中的模式&迁移

接下来,更新帖子和评论模型的字段,注意这些字段。

帖子:

`"""MigrationForPostTable Migration."""

from masoniteorm.migrations import Migration

class MigrationForPostTable(Migration):
    def up(self):
        """
 Run the migrations.
 """
        with self.schema.create("posts") as table:
            table.increments("id")
            table.integer("user_id").unsigned()
            table.foreign("user_id").references("id").on("users")
            table.string("title")
            table.text("body")
            table.timestamps()

    def down(self):
        """
 Revert the migrations.
 """
        self.schema.drop("posts")` 

评论:

`"""MigrationForCommentTable Migration."""

from masoniteorm.migrations import Migration

class MigrationForCommentTable(Migration):
    def up(self):
        """
 Run the migrations.
 """
        with self.schema.create("comments") as table:
            table.increments("id")
            table.integer("user_id").unsigned().nullable()
            table.foreign("user_id").references("id").on("users")
            table.integer("post_id").unsigned().nullable()
            table.foreign("post_id").references("id").on("posts")
            table.text("body")
            table.timestamps()

    def down(self):
        """
 Revert the migrations.
 """
        self.schema.drop("comments")` 

注意到:

`table.integer("user_id").unsigned()
table.foreign("user_id").references("id").on("users")` 

上面几行创建了一个从posts / comments表到users表的外键。user_id列引用users表上的id

要应用迁移,请在终端中运行以下命令:

`(env)$ masonite-orm migrate` 

您应该会看到关于每个迁移的成功消息:

`Migrating: 2022_05_04_084807_migration_for_user_table
Migrated: 2022_05_04_084807_migration_for_user_table (0.08s)
Migrating: 2022_05_04_084820_migration_for_post_table
Migrated: 2022_05_04_084820_migration_for_post_table (0.04s)
Migrating: 2022_05_04_084833_migration_for_comment_table
Migrated: 2022_05_04_084833_migration_for_comment_table (0.02s)` 

到目前为止,我们已经在表中添加并引用了外键,这些外键是在数据库中创建的。但是,我们仍然需要告诉 Masonite 每个模型之间的关系类型。

表关系

为了定义一对多的关系,我们需要从模型/User.py 中的masoniteorm.relationships导入has_many,并将其作为装饰者添加到我们的函数中:

`# models/User.py

from masoniteorm.models import Model
from masoniteorm.relationships import has_many

class User(Model):
    """User Model"""

    @has_many("id", "user_id")
    def posts(self):
        from .Post import Post

        return Post

    @has_many("id", "user_id")
    def comments(self):
        from .Comment import Comment

        return Comment` 

请注意,has_many有两个参数:

  1. 将在另一个表中引用的主表上的主键列的名称
  2. 将作为外键引用的列的名称

users表中,id是主键列,而user_id是引用users表记录的posts表中的列。

型号/Post.py 进行同样的操作:

`# models/Post.py

from masoniteorm.models import Model
from masoniteorm.relationships import has_many

class Post(Model):
    """Post Model"""

    @has_many("id", "post_id")
    def comments(self):
        from .Comment import Comment

        return Comment` 

GraphQL

虽然有许多 GraphQL 库可以与 FastAPI 一起工作,但是 Strawberry推荐的库,因为它利用了与 FastAPI 非常相似的数据类和类型提示。

strawberry-graphql[fastapi]添加到您的 requirement.txt 文件中:

`strawberry-graphql[fastapi]==0.158.0` 

安装:

`(env)$ pip install -r requirements.txt` 

接下来,像这样更新 main.py 文件:

`import strawberry  # new
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter  # new

# new
@strawberry.type
class Query:
  @strawberry.field
  def hello(self) -> str:
    return "Hello World"

schema = strawberry.Schema(Query)  # new
graphql_app = GraphQLRouter(schema)  # new
app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")  # new

@app.get("/")
def ping():
    return {"ping": "pong"}` 

启动您的服务器:

`(env)$ uvicorn main:app --reload` 

导航到http://localhost:8000/graph QL。你应该去看看 GraphQL 游乐场:

Strawberry Playground

键入一个快速查询以确保一切正常:

您应该看到:

`{
  "data": {
    "hello": "Hello World"
  }
}` 

(计划或理论的)纲要

一个模式是每个 GraphQL 应用程序的构建块。GraphQL 服务器使用它们来描述数据的形状。它充当应用程序的核心,将所有其他部分粘合在一起,如突变和查询。

在项目根目录下创建以下三个新文件:

  1. 这个文件将保存我们的草莓类型。Strawberry 支持代码优先的模式,这看起来很像 Python 数据类。
  2. controller . py——将保存我们执行数据库操作的所有逻辑。这个文件中定义的函数将在我们稍后创建突变和查询时充当我们的解析器。
  3. core . py——会把所有东西绑在一起。在这里,我们将为读写操作定义查询变异类,然后我们的 GraphQL 服务器可以执行这些操作。

schema.py 文件中,让我们定义我们的类型:

`import strawberry

from typing import List, Optional

@strawberry.type
class CommentsType:
    id: int
    user_id: int
    post_id: int
    body: str

@strawberry.type
class PostType:
    id: int
    user_id: int
    title: str
    body: str
    comments: Optional[List[CommentsType]]

@strawberry.type
class UserType:
    id: int
    name: str
    address: str
    phone_number: str
    sex: str
    posts: Optional[List[PostType]]
    comments: Optional[List[CommentsType]]` 

至此,让我们添加创建、读取、更新和删除数据库中的用户、帖子和评论的基本 CRUD 操作。

突变

突变在 GraphQL 中用于修改数据——即创建、更新和删除数据。我们将使用一个变异来创建UserPostComment对象,并将它们保存在数据库中。

输入类型

在我们创建变异之前,我们将创建一些草莓输入类型。输入类型使我们更容易定义希望用作输入的字段,而不是在函数中将它们作为参数传递。要定义输入类型,可以使用strawberry.input装饰器。

将以下输入类型添加到 schema.py 文件中:

`@strawberry.input
class UserInput:
    name: str
    email: str
    address: str
    phone_number: str
    sex: str

@strawberry.input
class PostInput:
    user_id: int
    title: str
    body: str

@strawberry.input
class CommentInput:
    user_id: int
    post_id: int
    body: str` 

添加用户突变

现在,在我们的控制器. py 类中,让我们添加添加用户的逻辑。创建一个 mutate 类,并在该类中包含一个add_user方法,该方法接受一个类型为UserInput的参数:

`from models.User import User
from schema import UserInput

class CreateMutation:

    def add_user(self, user_data: UserInput):
        user = User.where("email", user_data.email).get()
        if user:
            raise Exception("User already exists")

        user = User()

        user.name = user_data.name
        user.email = user_data.email
        user.address = user_data.address
        user.phone_number = user_data.phone_number
        user.sex = user_data.sex

        user.save()

        return user` 

现在,为了将这个类绑定到我们的变异中,将以下内容添加到 core.py :

`import strawberry

from controller import CreateMutation
from schema import UserType, PostType, CommentsType

@strawberry.type
class Mutation:
    add_user: UserType = strawberry.mutation(resolver=CreateMutation.add_user)` 

现在,我们只需将我们的突变添加到strawberry.Schema实例化中。打开 main.py 文件,更改模式实例化,如下所示:

`schema = strawberry.Schema(query=Query, mutation=Mutation)` 

不要忘记重要的一点:

`from core import Mutation` 

剩余突变

将其他 mutate 方法添加到 controller.py 文件中:

`from models.Comment import Comment
from models.Post import Post
from models.User import User
from schema import CommentInput, PostInput, UserInput

class CreateMutation:

    def add_user(self, user_data: UserInput):
        user = User.where("email", user_data.email).get()
        if user:
            raise Exception("User already exists")

        user = User()

        user.name = user_data.name
        user.email = user_data.email
        user.address = user_data.address
        user.phone_number = user_data.phone_number
        user.sex = user_data.sex

        user.save()

        return user

    def add_post(self, post_data: PostInput):
        user = User.find(post_data.user_id)
        if not user:
            raise Exception("User not found")
        post = Post()
        post.title = post_data.title
        post.body = post_data.body
        post.user_id = post_data.user_id
        post.save()

        user.attach("posts", post)

        return post

    def add_comment(self, comment_data: CommentInput):
        post = Post.find(comment_data.post_id)
        if not post:
            raise Exception("Post not found")
        user = User.find(comment_data.user_id)
        if not user:
            raise Exception("User not found")

        comment = Comment()
        comment.body = comment_data.body
        comment.user_id = comment_data.user_id
        comment.post_id = comment_data.post_id

        comment.save()

        user.attach("comments", comment)
        post.attach("comments", comment)

        return comment` 

同时更新 core.py :

`@strawberry.type
class Mutation:
    add_user: UserType = strawberry.mutation(resolver=CreateMutation.add_user)
    add_post: PostType = strawberry.mutation(resolver=CreateMutation.add_post)
    add_comment: CommentsType = strawberry.mutation(resolver=CreateMutation.add_comment)` 

测试

再次点燃 Uvicorn。重新加载您的浏览器,在 GraphQL Playground 的http://localhost:8000/graph QL,执行addUser突变:

`mutation {
  addUser(userData:{
    name: "John Doe",
    email: "[[email protected]](/cdn-cgi/l/email-protection)",
    address: "My home address",
    phoneNumber: "1234567890",
    sex: "male"
  }){
    id
    name
    address
  }
}` 

您应该得到这样一个用户对象:

`{
  "data": {
    "addUser": {
      "id": 1,
      "name": "John Doe",
      "address": "My home address"
    }
  }
}` 

尝试使用相同的电子邮件再次添加相同的用户,现在应该会显示一个错误列表,其中数据键为空:

`{
  "data": null,
  "errors": [
    {
      "message": "User already exists",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "addUser"
      ]
    }
  ]
}` 

执行addPost突变也是为了创建一个新帖子:

`mutation addPost {
  addPost(postData: {
    userId: 1,
    title: "My first Post",
    body: "This is a Post about myself"
  })
  {
    id
  }
}` 

您应该看到:

`{
  "data": {
    "addPost": {
      "id": 1
    }
  }
}` 

最后,执行createComment变异来创建一个新的注释:

`mutation createComment {
  addComment(commentData: {
    userId: 1,
    postId: 1,
    body: "Another Comment"
  })
  {
    id
    body
  }
}` 

问题

为了检索数据,我们需要创建一个查询类,然后我们可以将它传递给 main.py 文件中的模式实例化。

controller.py 中,添加一个查询类,其中所有函数都将是我们的查询解析器:

`class Queries:

    def get_all_users(self) -> List[UserType]:
        return User.all()` 

更新顶部的导入:

`from typing import List

from models.Comment import Comment
from models.Post import Post
from models.User import User
from schema import CommentInput, CommentsType, PostInput, PostType, UserInput, UserType` 

我们已经定义了模式和解析器。

现在,让我们通过更新 core.py 将它们联系起来,如下所示:

`from typing import List, Optional  # new

import strawberry

from controller import CreateMutation, Queries  # updated
from schema import UserType, PostType, CommentsType

@strawberry.type
class Mutation:
    add_user: UserType = strawberry.mutation(resolver=CreateMutation.add_user)
    add_post: PostType = strawberry.mutation(resolver=CreateMutation.add_post)
    add_comment: CommentsType = strawberry.mutation(resolver=CreateMutation.add_comment)

# new
@strawberry.type
class Query:
    users: List[UserType] = strawberry.field(resolver=Queries.get_all_users)` 

更新 main.py :

`import strawberry
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter

from core import Mutation, Query  # updated

schema = strawberry.Schema(query=Query, mutation=Mutation)
graphql_app = GraphQLRouter(schema)
app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")

@app.get("/")
def ping():
    return {"ping": "pong"}` 

启动您的服务器:

`(env)$ uvicorn main:app --reload` 

再次导航到http://localhost:8000/graph QL,并执行以下查询以返回用户列表:

`query getAllUsers {
  users{
    id
    name
    posts {
      title
    }
  }
}` 

结果:

`{
  "data": {
    "users": [
      {
        "id": 1,
        "name": "John Doe",
        "posts": [
          {
            "title": "My first Post"
          }
        ]
      }
    ]
  }
}` 

要检索单个用户,再次更新Queries类,添加以下解析器方法:

`class Queries:

    def get_all_users(self) -> List[UserType]:
        return User.all()

    # new
    def get_single_user(self, user_id: int) -> UserType:
        user = User.find(user_id)
        if not user:
            raise Exception("User not found")
        return user` 

接下来,像这样更新 core.py 中的Query类:

`@strawberry.type
class Query:
    users: List[UserType] = strawberry.field(resolver=Queries.get_all_users)
    get_single_user: UserType = strawberry.field(resolver=Queries.get_single_user)  # new` 

尝试一下:

`query getUser {
  getSingleUser(userId: 1) {
    name
    posts {
      title
      comments {
        body
      }
    }
    comments {
      body
    }
  }
}` 

该查询应该返回一个帖子列表,该列表又应该包含每个帖子对象的评论列表:

`{
  "data": {
    "getSingleUser": {
      "name": "John Doe",
      "posts": [
        {
          "title": "My first Post",
          "comments": [
            {
              "body": "Another Comment"
            }
          ]
        }
      ],
      "comments": [
        {
          "body": "Another Comment"
        }
      ]
    }
  }
}` 

如果不需要帖子或评论,可以从查询中删除帖子和评论块:

`query getUser {
  getSingleUser(userId: 1) {
    name
  }
}` 

结果:

`{
  "data": {
    "getSingleUser": {
      "name": "John Doe"
    }
  }
}` 

尝试使用不正确的用户 ID:

`query getUser {
  getSingleUser(userId: 5999) {
    name
  }
}` 

它应该会返回一个错误:

`{
  "data": null,
  "errors": [
    {
      "message": "User not found",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "getSingleUser"
      ]
    }
  ]
}` 

注意异常是如何被设计成消息的。

试验

Graphene 提供了一个测试客户端,用于创建测试 Graphene 应用程序的虚拟 GraphQL 客户端。

我们将使用 pytest,因此将依赖项添加到您的需求文件中:

我们还需要 HTTPX 库,因为 FastAPI 的 TestClient 是基于它的。也将其添加到需求文件中:

安装:

`(env)$ pip install -r requirements.txt` 

接下来,让我们为测试创建一个单独的配置文件,这样我们就不会覆盖主开发数据库中的数据。在“config”文件夹中,创建一个名为 test_config.py 的新文件:

`from masoniteorm.connections import ConnectionResolver

DATABASES = {
  "default": "sqlite",
  "sqlite": {
    "driver": "sqlite",
    "database": "db.sqlite3",
  }
}

DB = ConnectionResolver().set_connection_details(DATABASES)` 

接下来,创建一个“tests”文件夹,并在该文件夹中添加一个 conftest.py 文件:

`import pytest
from masoniteorm.migrations import Migration

@pytest.fixture(autouse=True)
def setup_database():
    config_path = "config/test_config.py"

    migrator = Migration(config_path=config_path)
    migrator.create_table_if_not_exists()

    migrator.refresh()` 

接下来,添加用于创建用户、帖子和评论的装置:

`@pytest.fixture(scope="function")
def user():
    user = User()
    user.name = "John Doe"
    user.address = "United States of Nigeria"
    user.phone_number = 123456789
    user.sex = "male"
    user.email = "[[email protected]](/cdn-cgi/l/email-protection)"
    user.save()

    return user

@pytest.fixture(scope="function")
def post(user):
    post = Post()
    post.title = "Test Title"
    post.body = "this is the post body and can be as long as possible"
    post.user_id = user.id
    post.save()

    user.attach("posts", post)
    return post

@pytest.fixture(scope="function")
def comment(user, post):
    comment = Comment()
    comment.body = "This is a comment body"
    comment.user_id = user.id
    comment.post_id = post.id

    comment.save()

    user.attach("comments", comment)
    post.attach("comments", comment)

    return comment` 

不要忘记模型导入:

`from models.Comment import Comment
from models.Post import Post
from models.User import User` 

您的 conftest.py 文件现在应该是这样的:

`import pytest
from masoniteorm.migrations import Migration

from models.Comment import Comment
from models.Post import Post
from models.User import User

@pytest.fixture(autouse=True)
def setup_database():
    config_path = "config/test_config.py"

    migrator = Migration(config_path=config_path)
    migrator.create_table_if_not_exists()

    migrator.refresh()

@pytest.fixture(scope="function")
def user():
    user = User()
    user.name = "John Doe"
    user.address = "United States of Nigeria"
    user.phone_number = 123456789
    user.sex = "male"
    user.email = "[[email protected]](/cdn-cgi/l/email-protection)"
    user.save()

    return user

@pytest.fixture(scope="function")
def post(user):
    post = Post()
    post.title = "Test Title"
    post.body = "this is the post body and can be as long as possible"
    post.user_id = user.id
    post.save()

    user.attach("posts", post)
    return post

@pytest.fixture(scope="function")
def comment(user, post):
    comment = Comment()
    comment.body = "This is a comment body"
    comment.user_id = user.id
    comment.post_id = post.id

    comment.save()

    user.attach("comments", comment)
    post.attach("comments", comment)

    return comment` 

现在,我们可以开始添加一些测试。

创建一个名为 test_query.py 的测试文件。

首先创建一个TestClient的实例:

`from fastapi.testclient import TestClient

from main import app  # => FastAPI app created in our main.py file

client = TestClient(app)` 

现在,我们将测试添加到:

  1. 添加用户
  2. 获取所有用户
  3. 使用用户 ID 获取单个用户

测试:

`def test_create_user():
    query = """
 mutation {
 addUser(userData: {
 name: "Test User",
 email: "[[email protected]](/cdn-cgi/l/email-protection)",
 sex: "male",
 address: "My Address",
 phoneNumber: "123456789",
 })
 {
 id
 name
 address
 }
 }
 """

    response = client.post("/graphql", json={"query": query})
    assert response is not None
    assert response.status_code == 200

    result = response.json()
    assert result["data"]["addUser"]["name"] == "Test User"
    assert result["data"]["addUser"]["address"] == "My Address"

def test_get_user_list(user):
    query = """
 query {
 users {
 name
 address
 }
 }
 """

    response = client.post("/graphql", json={"query": query})
    assert response is not None
    assert response.status_code == 200

    result = response.json()
    assert type(result['data']['users']) == list
    assert result["data"]["users"][0]["name"] == user.name

def test_get_single_user(user):
    query = """
 query {
 getSingleUser(userId: %s) {
 name
 address
 }
 }
 """ % user.id

    response = client.post("/graphql", json={"query": query})
    assert response is not None
    assert response.status_code == 200

    result = response.json()
    assert type(result['data']['getSingleUser']) == dict
    assert result["data"]["getSingleUser"]["name"] == user.name` 

运行测试:

这将执行所有的测试。它们都应该通过:

`=============================== test session starts ===============================
platform darwin -- Python 3.10.3, pytest-7.2.1, pluggy-1.0.0
rootdir: /Users/michael/repos/testdriven/fastapi-graphql
plugins: anyio-3.6.2, Faker-13.16.0
collected 3 items

tests/test_query.py ...                                                     [100%]

================================ 3 passed in 0.47s ================================` 

遵循同样的模式,自己为PostComment编写测试。

结论

在本教程中,我们介绍了如何使用 FastAPI、Strawberry、Masonite ORM 和 pytest 开发和测试 GraphQL API。我们讨论了如何创建 GraphQL 模式、查询和变异。最后,我们用 pytest 测试了我们的 GraphQL API。

fastapi-graphql repo 中获取代码。

使用假设和图式测试 FastAPI

原文:https://testdriven.io/blog/fastapi-hypothesis/

测试是开发软件的必要部分。具有高测试覆盖率的软件项目从来都不是完美的,但是它是软件质量的一个很好的初始指示器。为了鼓励测试的编写,测试应该有趣且易于编写。它们也应该像代码库中的其他代码一样被小心对待。因此,在添加新测试时,您需要考虑维护测试套件的成本。在可读性和可维护性之间取得平衡,同时确保测试覆盖广泛的场景并不容易

在本文中,我们将看看基于属性的测试是如何帮助解决这个问题的。我们首先来看看什么是基于属性的测试,以及为什么你可能想要使用它。然后,我们将展示如何使用假设模式对 FastAPI 应用基于属性的测试。

基于属性的测试

什么是基于属性的测试?

基于属性的测试基于给定函数或程序的属性。这些测试有助于确保被测试的函数或程序符合其属性。

利益

为什么要使用基于属性的测试?

  1. 作用域:基于属性的测试不需要为每个要测试的参数编写不同的测试用例,而是允许您为单个测试中的每个参数测试一系列参数。这有助于提高测试套件的稳健性,同时减少测试冗余。简而言之,您的测试代码将更干净、更 DRY,并且总体上更高效,同时更有效,因为您将能够更容易地测试所有这些边缘情况。
  2. Reproducibility :测试代理保存测试用例以及它们的结果,在失败的情况下,这些结果可以用于重现和重放测试。

让我们看一个简单的例子来说明这一点:

`def factorial(num: int) -> int:
    if num < 0:
        raise ValueError("Number must be >= 0")

    total = 1
    for _ in range(1, num + 1):
        total *= _
    return total

# test

import pytest

def test_factorial_less_than_0():
    with pytest.raises(ValueError):
        assert factorial(-1) == 1

def test_factorial():
    assert factorial(0) == 1
    assert factorial(1) == 1
    assert factorial(3) == 6
    assert factorial(7) == 5040
    assert factorial(12) == 479001600
    assert factorial(44) == 2658271574788448768043625811014615890319638528000000000` 

这有什么不好?

  1. 测试用例写起来很无聊
  2. 很难找到随机的、无偏见的测试例子
  3. 测试套件的大小会迅速膨胀,因此很难阅读和维护
  4. 还是那句话,没意思!
  5. 很难充实边缘案例

人类不应该在这上面浪费时间。这是最适合计算机完成的任务。

基于属性的假设测试

假设是在 Python 中进行基于属性的测试的工具。假设使得编写测试和找到所有边缘案例变得容易。

它的工作原理是生成与您的规格相匹配的任意数据,并检查您的保证在这种情况下仍然有效。如果它找到了一个没有的例子,它就把这个例子缩小,简化它,直到找到一个更小的例子,这个例子仍然会引起问题。然后它保存这个例子,以便以后一旦发现你的代码有问题,以后不会忘记。

这里最重要的部分是所有失败的测试都将被尝试,即使错误已经被修复!

快速启动

假设集成到您正常的 pytest 或 unittest 工作流中。

从安装库开始:

接下来,您需要定义一个策略,这是一个生成随机数据的方法。

示例:

战略 它会产生什么
二进制的 字节字符串
文本 用线串
整数 整数
漂浮物 漂浮物
分数 分数实例

策略应该组合在一起,以生成复杂的测试输入数据。因此,与其编写和维护自己的数据生成器,不如让 Hypothesis 为您管理所有这些。

让我们将上面的test_factorial重构为一个基于属性的测试:

`from hypothesis import given
from hypothesis.strategies import integers

@given(integers(min_value=1, max_value=30))
def test_factorial(num: int):
    result = factorial(num) / factorial(num - 1)
    assert num == result` 

这个测试现在断言一个数的阶乘除以该数的阶乘减一就是原始数。

在这里,我们将整数策略传递给了@given装饰器,这是假设的入口点。这个装饰器本质上把测试函数变成了一个参数化的函数,这样当它被调用时,从策略生成的数据将被传递到测试中。

如果已经发现故障,假设使用收缩来找到最小的故障情况。

示例:

`from hypothesis import Verbosity, given, settings
from hypothesis import strategies as st

@settings(verbosity=Verbosity.verbose)
@given(st.integers())
def test_shrinking(num: int):
    assert num >= -2` 

测试输出:

`...

Trying example: test_shrinking(
    num=-4475302896957925906,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=2872,
)
Trying example: test_shrinking(
    num=-93,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=14443,
)
Trying example: test_shrinking(
    num=56,
)
Trying example: test_shrinking(
    num=-13873,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=23519,
)
Trying example: test_shrinking(
    num=-91,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=-93,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=0,
)
Trying example: test_shrinking(
    num=0,
)
Trying example: test_shrinking(
    num=-29,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=-13,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=-5,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=-1,
)
Trying example: test_shrinking(
    num=-2,
)
Trying example: test_shrinking(
    num=-4,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=-3,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=3,
)
Trying example: test_shrinking(
    num=-3,
)
Traceback (most recent call last):
  File "shrinking.py", line 8, in test_shrinking
    assert num >= -2
AssertionError

Trying example: test_shrinking(
    num=0,
)
Trying example: test_shrinking(
    num=-1,
)
Trying example: test_shrinking(
    num=-2,
)
Trying example: test_shrinking(
    num=3,
)
Falsifying example: test_shrinking(
    num=-3,
)` 

这里,我们针对整数池测试了表达式num >= -2。假设从第一个失败案例num = -4475302896957925906开始,这是一个相当大的数字。然后收缩num的值,直到假设发现值num = -3是最小的失败案例。

对 FastAPI 使用假设

假设已被证明是一个简单而强大的测试工具。让我们看看如何在 FastAPI 中使用它。

`# server.py

import uvicorn
from fastapi import FastAPI

app = FastAPI()

@app.get("/api/{s}")
def homepage(s: int):
    return {"message": s * s}

if __name__ == "__main__":
    uvicorn.run(app)` 

因此,/api/{s}路由接受一个名为s的 URL 参数,它应该是一个整数。

`# test_server.py

from hypothesis import given, strategies as st
from fastapi.testclient import TestClient

from server import app

client = TestClient(app)

@given(st.integers())
def test_home(s):
    res = client.get(f"/api/{s}")

    assert res.status_code == 200
    assert res.json() == {"message": s * s}` 

就像之前我们使用integers策略生成随机整数,正的和负的,用于测试。

图式

概要是一个基于 OpenAPIGraphQL 规范的现代 API 测试工具。它使用幕后假设将基于属性的测试应用于 API 模式。换句话说,给定一个模式,Schemathesis 可以自动为您生成测试用例。由于 FastAPI 是基于 OpenAPI 标准的,Schemathesis 与它配合得很好。

如果您运行上面的 server.py 文件并导航到http://localhost:8000/openapi . JSON,您应该会看到由 FastAPI 生成的 open API 规范。它定义了所有的端点及其输入类型。使用这个规范,Schemathesis 可以用来生成测试数据。

使用 CLI 快速入门

安装:

`$ pip install schemathesis` 

一旦安装完毕,运行测试最简单的方法就是通过 schemathesis 命令。当 Uvicorn 在一个终端窗口中运行时,打开一个新窗口并运行:

`$ schemathesis run http://localhost:8000/openapi.json` 

您应该看到:

`========================= Schemathesis test session starts ========================
Schema location: http://localhost:8000/openapi.json
Base URL: http://localhost:8000/
Specification version: Open API 3.0.2
Workers: 1
Collected API operations: 1

GET /api/{s} .                                                               [100%]

===================================== SUMMARY =====================================

Performed checks:
    not_a_server_error                    100 / 100 passed          PASSED

================================ 1 passed in 0.61s ================================` 

请注意,这只针对not_a_server_error进行了检查。图式有五个内置的检查:

  1. not_a_server_error:响应具有 5xx HTTP 状态
  2. status_code_conformance:API 模式中未定义响应状态
  3. content_type_conformance:API 模式中未定义响应内容类型
  4. response_schema_conformance:响应内容不符合为此特定响应定义的模式
  5. response_headers_conformance:响应头不包含所有已定义的头。

您可以使用--checks all选项执行所有内置检查:

`$ schemathesis run --checks all http://localhost:8000/openapi.json

========================= Schemathesis test session starts ========================
Schema location: http://localhost:8000/openapi.json
Base URL: http://localhost:8000/
Specification version: Open API 3.0.2
Workers: 1
Collected API operations: 1

GET /api/{s} .                                                               [100%]

===================================== SUMMARY =====================================

Performed checks:
    not_a_server_error                              100 / 100 passed          PASSED
    status_code_conformance                         100 / 100 passed          PASSED
    content_type_conformance                        100 / 100 passed          PASSED
    response_headers_conformance                    100 / 100 passed          PASSED
    response_schema_conformance                     100 / 100 passed          PASSED

================================ 1 passed in 0.87s ================================` 

附加选项

您可以测试一个特定的端点或 HTTP 方法,而不是整个应用程序:

`$ schemathesis run --endpoint /api/. http://localhost:8000/openapi.json

$ schemathesis run --method GET http://localhost:8000/openapi.json` 

最大响应时间可用于帮助充实可能降低端点速度的边缘情况。时间以毫秒为单位。

`$ schemathesis run --max-response-time=50 HTTP://localhost:8000/openapi.json` 

您的一些端点需要授权吗?

`$ schemathesis run -H "Authorization: Bearer TOKEN" http://localhost:8000/openapi.json

$ schemathesis run -H "Authorization: ..." -H "X-API-Key: ..." HTTP://localhost:8000/openapi.json` 

您可以使用多个工作人员来加快测试速度:

`$ schemathesis run --workers 8 http://localhost:8000/openapi.json` 

通常,Schemathesis 为每个端点生成随机数据。状态测试确保数据来自之前的测试/响应:

`$ schemathesis run --stateful=links http://localhost:8000/openapi.json` 

最后,重放测试很简单,因为每个测试用例都与一个种子值相关联。当一个测试用例失败时,它会提供种子,这样您就可以重现失败的用例:

`$ schemathesis run http://localhost:8000/openapi.json

============================ Schemathesis test session starts ============================
platform Darwin -- Python 3.10.6, schemathesis-3.17.2, hypothesis-6.54.4,
    hypothesis_jsonschema-0.22.0, jsonschema-4.15.0
rootdir: /hypothesis-examples
hypothesis profile 'default' ->
    database=DirectoryBasedExampleDatabase('/hypothesis-examples/.hypothesis/examples')
Schema location: http://localhost:8000/openapi.json
Base URL: http://localhost:8000/
Specification version: Open API 3.0.2
Workers: 1
collected endpoints: 1

GET /api/{s} F                                                                      [100%]

======================================== FAILURES ========================================
_____________________________________ GET: /api/{s} ______________________________________
1. Received a response with 5xx status code: 500

Path parameters : {'s': 0}

Run this Python code to reproduce this failure:
  requests.get('http://localhost:8000/api/0', headers={'User-Agent': 'schemathesis/2.6.0'})

Or add this option to your command line parameters:
    --hypothesis-seed=135947773389980684299156880789978283847
======================================== SUMMARY =========================================

Performed checks:
    not_a_server_error                    0 / 3 passed          FAILED

=================================== 1 passed in 0.10s ====================================` 

然后,要重现,运行:

`$ schemathesis run http://localhost:8000/openapi.json --hypothesis-seed=135947773389980684299156880789978283847` 

Python 测试

您也可以在测试中使用模式:

`import schemathesis

schema = schemathesis.from_uri("http://localhost:8000/openapi.json")

@schema.parametrize()
def test_api(case):
    case.call_and_validate()` 

Schemathesis 还支持直接调用 ASGI(即 Uvicorn 和 Daphne)和 WSGI(即 Gunicorn 和 uWSGI)应用程序,而不是通过网络:

`import schemathesis
from schemathesis.specs.openapi.loaders import from_asgi

from server import app

schema = from_asgi("/openapi.json", app)

@schema.parametrize()
def test_api(case):
    response = case.call_asgi()
    case.validate_response(response)` 

结论

希望你能看到基于属性的测试有多强大。它可以使您的代码更加健壮,而不会降低样板测试代码的可读性。收缩以找到最简单的失败案例和重放等功能提高了工作效率。基于属性的测试将减少花费在编写手动测试上的时间,同时增加测试覆盖率。

像 Hypothesis 这样基于属性的测试工具应该是几乎所有 Python 工作流的一部分。Schemathesis 用于基于 OpenAPI 标准的自动化 API 测试,这在与 FastAPI 等以 API 为中心的框架结合使用时非常有用。

通过基于 JWT 令牌的身份验证保护 FastAPI

原文:https://testdriven.io/blog/fastapi-jwt-auth/

在本教程中,您将了解如何通过使用 JSON Web 令牌(jwt)启用身份验证来保护 FastAPI 应用程序。我们将使用 PyJWT 对 JWT 令牌进行签名、编码和解码。

FastAPI 中的身份验证

身份验证是在授予用户访问安全资源的权限之前对用户进行验证的过程。当用户被认证时,用户被允许访问不对公众开放的安全资源。

我们将研究如何使用不记名(或基于令牌)认证来认证 FastAPI 应用程序,这涉及到生成称为不记名令牌的安全令牌。在这种情况下,承载令牌将是 jwt。

FastAPI中的认证也可以由 OAuth 处理。

初始设置

首先创建一个新文件夹来保存名为“fastapi-jwt”的项目:

`$ mkdir fastapi-jwt && cd fastapi-jwt` 

接下来,创建并激活虚拟环境:

`$ python3.11 -m venv venv
$ source venv/bin/activate

(venv)$ export PYTHONPATH=$PWD` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

安装 FastAPI 和uvicon:

`(venv)$ pip install fastapi==0.89.1 uvicorn==0.20.0` 

接下来,创建以下文件和文件夹:

`fastapi-jwt
├── app
│   ├── __init__.py
│   ├── api.py
│   ├── auth
│   │   └── __init__.py
│   └── model.py
└── main.py` 

以下命令将创建项目结构:

`(venv)$ mkdir app && \
        mkdir app/auth && \
        touch app/__init__.py app/api.py && \
        touch app/auth/__init__.py app/model.py main.py` 

main.py 文件中,定义运行应用程序的入口点:

`# main.py

import uvicorn

if __name__ == "__main__":
    uvicorn.run("app.api:app", host="0.0.0.0", port=8081, reload=True)` 

这里,我们指示文件在端口 8081 上运行 Uvicorn 服务器,并在每次文件更改时重新加载。

在通过入口点文件启动服务器之前,在 app/api.py 中创建一个基本路径:

`# app/api.py

from fastapi import FastAPI

app = FastAPI()

@app.get("/", tags=["root"])
async def read_root() -> dict:
    return {"message": "Welcome to your blog!"}` 

从终端运行入口点文件:

在浏览器中导航至 http://localhost:8081 。您应该看到:

`{ "message":  "Welcome to your blog!" }` 

我们在建造什么?

在本教程的剩余部分,您将构建一个安全的微型博客 CRUD 应用程序来创建和阅读博客文章。最后,您将拥有:

final app

模型

在我们继续之前,让我们为帖子定义一个 pydantic 模型

model.py 中,添加:

`# app/model.py

from pydantic import BaseModel, Field, EmailStr

class PostSchema(BaseModel):
    id: int = Field(default=None)
    title: str = Field(...)
    content: str = Field(...)

    class Config:
        schema_extra = {
            "example": {
                "title": "Securing FastAPI applications with JWT.",
                "content": "In this tutorial, you'll learn how to secure your application by enabling authentication using JWT. We'll be using PyJWT to sign, encode and decode JWT tokens...."
            }
        }` 

路线

获取路线

首先导入PostSchema,然后在 app/api.py 中添加一个虚拟帖子列表和一个空用户列表变量:

`# app/api.py

from app.model import PostSchema

posts = [
    {
        "id": 1,
        "title": "Pancake",
        "content": "Lorem Ipsum ..."
    }
]

users = []` 

然后,添加路由处理程序,通过 ID 获取所有帖子和单个帖子:

`# app/api.py

@app.get("/posts", tags=["posts"])
async def get_posts() -> dict:
    return { "data": posts }

@app.get("/posts/{id}", tags=["posts"])
async def get_single_post(id: int) -> dict:
    if id > len(posts):
        return {
            "error": "No such post with the supplied ID."
        }

    for post in posts:
        if post["id"] == id:
            return {
                "data": post
            }` 

app/api.py 现在应该是这样的:

`# app/api.py

from fastapi import FastAPI

from app.model import PostSchema

posts = [
    {
        "id": 1,
        "title": "Pancake",
        "content": "Lorem Ipsum ..."
    }
]

users = []

app = FastAPI()

@app.get("/", tags=["root"])
async def read_root() -> dict:
    return {"message": "Welcome to your blog!"}

@app.get("/posts", tags=["posts"])
async def get_posts() -> dict:
    return { "data": posts }

@app.get("/posts/{id}", tags=["posts"])
async def get_single_post(id: int) -> dict:
    if id > len(posts):
        return {
            "error": "No such post with the supplied ID."
        }

    for post in posts:
        if post["id"] == id:
            return {
                "data": post
            }` 

手动测试http://localhost:8081/postshttp://localhost:8081/posts/1的路由

邮寄路线

在 GET routes 的正下方,添加以下用于创建新帖子的处理程序:

`# app/api.py

@app.post("/posts", tags=["posts"])
async def add_post(post: PostSchema) -> dict:
    post.id = len(posts) + 1
    posts.append(post.dict())
    return {
        "data": "post added."
    }` 

在后端运行的情况下,通过位于http://localhost:8081/docs的交互文档测试 POST 路径。

您也可以使用 curl 进行测试:

`$ curl -X POST http://localhost:8081/posts \
    -d  '{ "id": 2, "title": "Lorem Ipsum tres", "content": "content goes here"}' \
    -H 'Content-Type: application/json'` 

您应该看到:

`{ "data":  [ "post added." ] }` 

JWT 认证

在这一节中,我们将创建一个 JWT 令牌处理程序和一个处理无记名令牌的类。

开始之前,安装 PyJWT ,用于编码和解码 JWT。我们还将使用和 python 解耦来读取环境变量:

`(venv)$ pip install PyJWT==2.6.0 python-decouple==3.7` 

JWT·汉德勒

JWT 处理程序将负责签名、编码、解码和返回 JWT 令牌。在“auth”文件夹中,创建一个名为 auth_handler.py 的文件:

`# app/auth/auth_handler.py

import time
from typing import Dict

import jwt
from decouple import config

JWT_SECRET = config("secret")
JWT_ALGORITHM = config("algorithm")

def token_response(token: str):
    return {
        "access_token": token
    }` 

在上面的代码块中,我们导入了timetypingjwtdecouple模块。time模块负责设置令牌的到期时间。每个 JWT 都有失效日期和/或失效时间。jwt模块负责对生成的令牌串进行编码和解码。最后,token_response函数是一个助手函数,用于返回生成的令牌。

JSON Web 令牌被编码成来自字典负载的字符串。

JWT 秘密和算法

接下来,创建一个名为的环境文件。基本目录中的 env :

`secret=please_please_update_me_please
algorithm=HS256` 

环境文件中的秘密应该替换为更强的内容,并且不应该被泄露。例如:

`>>> import os
>>> import binascii
>>> binascii.hexlify(os.urandom(24))
b'deff1952d59f883ece260e8683fed21ab0ad9a53323eca4f'` 

秘密密钥用于编码和解码 JWT 字符串。

另一方面,算法值是编码过程中使用的算法类型。

签署和解码 JWT

回到 auth_handler.py ,添加 JWT 字符串签名函数:

`# app/auth/auth_handler.py

def signJWT(user_id: str) -> Dict[str, str]:
    payload = {
        "user_id": user_id,
        "expires": time.time() + 600
    }
    token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

    return token_response(token)` 

signJWT函数中,我们定义了有效载荷、一个包含传递给函数的user_id的字典,以及一个从生成之时起十分钟的到期时间。接下来,我们创建了一个由有效负载、秘密和算法类型组成的令牌字符串,然后返回它。

接下来,添加decodeJWT功能:

`# app/auth/auth_handler.py

def decodeJWT(token: str) -> dict:
    try:
        decoded_token = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        return decoded_token if decoded_token["expires"] >= time.time() else None
    except:
        return {}` 

decodeJWT函数获取令牌并在jwt模块的帮助下对其进行解码,然后将其存储在decoded_token变量中。接下来,如果到期时间有效,我们返回decoded_token,否则,我们返回None

JWT 没有加密。它是基于 64 位编码和签名的。因此任何人都可以解码令牌并使用其数据。但是只有服务器可以使用JWT_SECRET来验证它的真实性。

用户注册和登录

接下来,让我们为处理用户注册和登录连接路由、模式和助手。

model.py 中,添加用户模式:

`# app/model.py

class UserSchema(BaseModel):
    fullname: str = Field(...)
    email: EmailStr = Field(...)
    password: str = Field(...)

    class Config:
        schema_extra = {
            "example": {
                "fullname": "Abdulazeez Abdulazeez Adeshina",
                "email": "[[email protected]](/cdn-cgi/l/email-protection)",
                "password": "weakpassword"
            }
        }

class UserLoginSchema(BaseModel):
    email: EmailStr = Field(...)
    password: str = Field(...)

    class Config:
        schema_extra = {
            "example": {
                "email": "[[email protected]](/cdn-cgi/l/email-protection)",
                "password": "weakpassword"
            }
        }` 

接下来,更新 app/api.py 中的导入:

`# app/api.py

from fastapi import FastAPI, Body

from app.model import PostSchema, UserSchema, UserLoginSchema
from app.auth.auth_handler import signJWT` 

添加用户注册路径:

`# app/api.py

@app.post("/user/signup", tags=["user"])
async def create_user(user: UserSchema = Body(...)):
    users.append(user) # replace with db call, making sure to hash the password first
    return signJWT(user.email)` 

因为我们使用的是电子邮件验证器EmailStr,安装电子邮件验证器:

`(venv)$ pip install "pydantic[email]"` 

运行服务器:

通过位于http://localhost:8081/docs的交互文档对其进行测试。

sign user up

在生产环境中,在将用户保存到数据库之前,确保使用 bcryptpasslib 散列您的密码。

接下来,定义一个助手函数来检查用户是否存在:

`# app/api.py

def check_user(data: UserLoginSchema):
    for user in users:
        if user.email == data.email and user.password == data.password:
            return True
    return False` 

在使用用户的电子邮件创建 JWT 之前,上面的函数会检查用户是否存在。

接下来,定义登录路径:

`# app/api.py

@app.post("/user/login", tags=["user"])
async def user_login(user: UserLoginSchema = Body(...)):
    if check_user(user):
        return signJWT(user.email)
    return {
        "error": "Wrong login details!"
    }` 

通过首先创建一个用户,然后登录来测试登录路径:

log user in

因为用户存储在内存中,所以每次应用程序重新加载以测试登录时,您都必须创建一个新用户。

保护路线

身份验证就绪后,让我们来保护创建路由。

JWT 持票人

现在我们需要通过检查请求是否被授权来验证受保护的路由。这是通过扫描Authorization报头中的 JWT 请求来完成的。FastAPI 通过HTTPBearer类提供基本的验证。我们可以使用这个类来提取和解析令牌。然后,我们将使用 app/auth/auth_handler.py 中定义的decodeJWT函数来验证它。

在“auth”文件夹中创建一个名为 auth_bearer.py 的新文件:

`# app/auth/auth_bearer.py

from fastapi import Request, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

from .auth_handler import decodeJWT

class JWTBearer(HTTPBearer):
    def __init__(self, auto_error: bool = True):
        super(JWTBearer, self).__init__(auto_error=auto_error)

    async def __call__(self, request: Request):
        credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request)
        if credentials:
            if not credentials.scheme == "Bearer":
                raise HTTPException(status_code=403, detail="Invalid authentication scheme.")
            if not self.verify_jwt(credentials.credentials):
                raise HTTPException(status_code=403, detail="Invalid token or expired token.")
            return credentials.credentials
        else:
            raise HTTPException(status_code=403, detail="Invalid authorization code.")

    def verify_jwt(self, jwtoken: str) -> bool:
        isTokenValid: bool = False

        try:
            payload = decodeJWT(jwtoken)
        except:
            payload = None
        if payload:
            isTokenValid = True
        return isTokenValid` 

因此,JWTBearer类是 FastAPI 的 HTTPBearer 类的一个子类,它将用于在我们的路由上保持身份验证。

初始化

__init__方法中,我们通过将布尔值 auto_error 设置为True来启用自动错误报告。

打电话

__call__方法中,我们定义了一个名为credentials的变量,类型为HTTPAuthorizationCredentials,它是在调用JWTBearer类时创建的。然后,我们继续检查在调用该类的过程中传递的凭证是否有效:

  1. 如果凭据方案不是承载方案,我们会引发无效令牌方案的异常。
  2. 如果传递了不记名令牌,我们验证 JWT 是有效的。
  3. 如果没有收到凭证,我们会引发无效授权错误。

核实

verify_jwt方法验证令牌是否有效。该方法获取一个jwtoken字符串,然后将其传递给decodeJWT函数,并基于decodeJWT的结果返回一个布尔值。

依赖注入

为了保护路由,我们将通过 FastAPI 的 Depends 来利用依赖注入。

首先通过添加JWTBearer类和Depends来更新导入:

`# app/api.py

from fastapi import FastAPI, Body, Depends

from app.model import PostSchema, UserSchema, UserLoginSchema
from app.auth.auth_bearer import JWTBearer
from app.auth.auth_handler import signJWT` 

add_post路线中,将dependencies参数添加到@app属性中,如下所示:

`# app/api.py

@app.post("/posts", dependencies=[Depends(JWTBearer())], tags=["posts"])
async def add_post(post: PostSchema) -> dict:
    post.id = len(posts) + 1
    posts.append(post.dict())
    return {
        "data": "post added."
    }` 

刷新交互式文档页面:

swagger ui

通过尝试在不传入令牌的情况下访问受保护的路由来测试身份验证:

add user unauthenticated

创建新用户并复制生成的访问令牌:

access token

复制后,单击右上角的授权按钮并粘贴令牌:

authorize

现在,您应该能够使用受保护的路线了:

add user authenticated

结论

本教程讲述了使用 JSON Web 令牌保护 FastAPI 应用程序的过程。您可以在 fastapi-jwt 存储库中找到源代码。感谢阅读。

寻找一些挑战?

  1. 在使用 bcryptpasslib 保存密码之前,对密码进行哈希处理。
  2. 将用户和帖子从临时存储转移到 MongoDB 或 Postgres 之类的数据库。您可以按照使用 FastAPI 和 MongoDB 构建 CRUD 应用程序中的步骤来设置 MongoDB 数据库并部署到 Heroku。
  3. 添加刷新令牌,以便在新 jwt 过期时自动颁发它们。不知道从哪里开始?看看《烧瓶》的作者 JWT 对此的解释。
  4. 添加更新和删除帖子的路线。

使用 FastAPI 和 Heroku 部署和托管机器学习模型

原文:https://testdriven.io/blog/fastapi-machine-learning/

假设你是一名数据科学家。按照典型的机器学习工作流程,您将根据业务需求定义问题陈述以及目标。然后,您将开始查找和清理数据,接着分析收集的数据并构建和训练您的模型。一旦训练完毕,你将评估结果。这个查找和清理数据、训练模型以及评估结果的过程将会继续,直到您对结果满意为止。然后,您将重构代码,并将其与依赖项一起打包到一个模块中,为测试和部署做准备。

接下来会发生什么?您会将模型交给另一个团队来测试和部署吗?还是要自己处理?无论哪种方式,理解部署模型时会发生什么都很重要。有一天你可能不得不自己部署这个模型。或者你可能有一个副业项目,你只是想站在生产,并提供给最终用户。

在本教程中,我们将看看如何使用 FastAPI 在 Heroku 上将预测股票价格的机器学习模型作为 RESTful API 部署到生产中。

目标

在这篇文章结束时,你应该能够:

  1. 用 Python 和 FastAPI 开发 RESTful API
  2. 建立一个基本的机器学习模型来预测股票价格
  3. 将 FastAPI 应用程序部署到 Heroku
  4. 使用 Heroku 容器注册中心将 Docker 部署到 Heroku

FastAPI

FastAPI 是一个现代的、高性能的、内置电池的 Python web 框架,非常适合构建 RESTful APIs。它可以处理同步和异步请求,并内置了对数据验证、JSON 序列化、认证和授权以及 OpenAPI 的支持。

亮点:

  1. 受 Flask 的启发,它有一种轻量级微框架的感觉,支持类似 Flask 的 route decorators。
  2. 它利用 Python 类型提示进行参数声明,支持数据验证(通过 pydantic )和 OpenAPI/Swagger 文档。
  3. 它建立在 Starlette 之上,支持异步 API 的开发。
  4. 它很快。由于 async 比传统的同步线程模型更有效,所以在性能方面它可以与 Node 和 Go 竞争。

查看官方文档中的功能指南,了解更多信息。我们也鼓励大家回顾一下的替代方案、灵感和比较,其中详细介绍了 FastAPI 与其他 web 框架和技术的比较。

项目设置

创建一个名为“fastapi-ml”的项目文件夹:

`$ mkdir fastapi-ml
$ cd fastapi-ml` 

然后,创建并激活新的虚拟环境:

`$ python3.8 -m venv env
$ source env/bin/activate
(env)$` 

增加两个新文件: requirements.txtmain.py

与 Django 或 Flask 不同,FastAPI 没有内置的开发服务器。因此,我们将使用uvicon,一个 ASGI 服务器,来提供 FastAPI。

不熟悉 ASGI?通读精彩的 ASGI 简介:异步 Python Web 生态系统的出现。

将 FastAPI 和 Uvicorn 添加到需求文件中:

`fastapi==0.68.0
uvicorn==0.14.0` 

安装依赖项:

`(env)$ pip install -r requirements.txt` 

然后,在 main.py 中,创建一个新的 FastAPI 实例,并设置一个快速测试路径:

`from fastapi import FastAPI

app = FastAPI()

@app.get("/ping")
def pong():
    return {"ping": "pong!"}` 

启动应用程序:

`(env)$ uvicorn main:app --reload --workers 1 --host 0.0.0.0 --port 8008` 

因此,我们为 Uvicorn 定义了以下设置:

  1. --reload启用自动重新加载,这样服务器将在对代码库进行更改后重新启动。
  2. --workers 1提供单个工作进程。
  3. --host 0.0.0.0定义托管服务器的地址。
  4. --port 8008定义托管服务器的端口。

main:app告诉 Uvicorn 在哪里可以找到 FastAPI ASGI 应用程序——例如,“在‘main . py’文件中,您会找到 ASGI 应用程序,app = FastAPI()

导航到http://localhost:8008/ping。您应该看到:

ML 模型

我们将部署的模型使用 Prophet 来预测股票市场价格。

添加以下函数来训练模型并生成一个预测到名为 model.py 的新文件中:

`import datetime
from pathlib import Path

import joblib
import pandas as pd
import yfinance as yf
from fbprophet import Prophet

BASE_DIR = Path(__file__).resolve(strict=True).parent
TODAY = datetime.date.today()

def train(ticker="MSFT"):
    # data = yf.download("^GSPC", "2008-01-01", TODAY.strftime("%Y-%m-%d"))
    data = yf.download(ticker, "2020-01-01", TODAY.strftime("%Y-%m-%d"))
    data.head()
    data["Adj Close"].plot(title=f"{ticker} Stock Adjusted Closing Price")

    df_forecast = data.copy()
    df_forecast.reset_index(inplace=True)
    df_forecast["ds"] = df_forecast["Date"]
    df_forecast["y"] = df_forecast["Adj Close"]
    df_forecast = df_forecast[["ds", "y"]]
    df_forecast

    model = Prophet()
    model.fit(df_forecast)

    joblib.dump(model, Path(BASE_DIR).joinpath(f"{ticker}.joblib"))

def predict(ticker="MSFT", days=7):
    model_file = Path(BASE_DIR).joinpath(f"{ticker}.joblib")
    if not model_file.exists():
        return False

    model = joblib.load(model_file)

    future = TODAY + datetime.timedelta(days=days)

    dates = pd.date_range(start="2020-01-01", end=future.strftime("%m/%d/%Y"),)
    df = pd.DataFrame({"ds": dates})

    forecast = model.predict(df)

    model.plot(forecast).savefig(f"{ticker}_plot.png")
    model.plot_components(forecast).savefig(f"{ticker}_plot_components.png")

    return forecast.tail(days).to_dict("records")

def convert(prediction_list):
    output = {}
    for data in prediction_list:
        date = data["ds"].strftime("%m/%d/%Y")
        output[date] = data["trend"]
    return output` 

这里,我们定义了三个函数:

  1. trainyfinance 下载历史股票数据,创建一个新的 Prophet 模型,将模型拟合到股票数据,然后将模型序列化保存为 Joblib 文件
  2. predict加载并反序列化保存的模型,生成新的预测,创建预测图和预测组件的图像,并以字典列表的形式返回预测中包含的日期。
  3. convertpredict获取字典列表,并输出日期和预测值的字典(即{"07/02/2020": 200})。

这个模型是由安德鲁·克拉克开发的。

更新需求文件:

`# pystan must be installed before prophet
# you may need to pip install it on it's own
# before installing the remaining requirements
# pip install pystan==2.19.1.1

pystan==2.19.1.1

fastapi==0.68.0
uvicorn==0.14.0

fbprophet==0.7.1
joblib==1.0.1
pandas==1.3.1
plotly==5.1.0
yfinance==0.1.63` 

安装新的依赖项:

`(env)$ pip install -r requirements.txt` 

如果在你的机器上安装依赖项有问题,你可以使用 Docker 来代替。有关如何使用 Docker 运行应用程序的说明,请查看 GitHub 上的 fastapi-ml repo 上的自述文件。

要进行测试,请打开一个新的 Python shell 并运行以下命令:

`(env)$ python

>>> from model import train, predict, convert
>>> train()
>>> prediction_list = predict()
>>> convert(prediction_list)` 

您应该会看到类似如下的内容:

`{
    '08/12/2021': 282.99012951691776,
    '08/13/2021': 283.31354121099446,
    '08/14/2021': 283.63695290507127,
    '08/15/2021': 283.960364599148,
    '08/16/2021': 284.2837762932248,
    '08/17/2021': 284.6071879873016,
    '08/18/2021': 284.93059968137834
}` 

这是微软公司(MSFT)未来七天的预测价格。记下保存的 MSFT.joblib 模型以及两个图像:

plot

components

继续训练更多的模型来工作。例如:

`>>> train("GOOG")
>>> train("AAPL")
>>> train("^GSPC")` 

退出外壳。

至此,让我们连接我们的 API。

路线

通过更新 main.py 添加一个/predict端点,如下所示:

`from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from model import convert, predict

app = FastAPI()

# pydantic models

class StockIn(BaseModel):
    ticker: str

class StockOut(StockIn):
    forecast: dict

# routes

@app.get("/ping")
async def pong():
    return {"ping": "pong!"}

@app.post("/predict", response_model=StockOut, status_code=200)
def get_prediction(payload: StockIn):
    ticker = payload.ticker

    prediction_list = predict(ticker)

    if not prediction_list:
        raise HTTPException(status_code=400, detail="Model not found.")

    response_object = {"ticker": ticker, "forecast": convert(prediction_list)}
    return response_object` 

因此,在新的get_prediction视图函数中,我们向模型的predict函数传递了一个 ticker,然后使用convert函数为响应对象创建输出。我们还利用 pydantic 模式将 JSON 有效负载转换为StockIn对象模式。这提供了自动类型验证。响应对象使用StockOut模式对象将 Python dict - {"ticker": ticker, "forecast": convert(prediction_list)} -转换为 JSON,并再次进行验证。

对于 web 应用程序,我们只需在 JSON 中输出预测。注释掉predict中的以下几行:

`# model.plot(forecast).savefig(f"{ticker}_plot.png")
# model.plot_components(forecast).savefig(f"{ticker}_plot_components.png")` 

全功能:

`def predict(ticker="MSFT", days=7):
    model_file = Path(BASE_DIR).joinpath(f"{ticker}.joblib")
    if not model_file.exists():
        return False

    model = joblib.load(model_file)

    future = TODAY + datetime.timedelta(days=days)

    dates = pd.date_range(start="2020-01-01", end=future.strftime("%m/%d/%Y"),)
    df = pd.DataFrame({"ds": dates})

    forecast = model.predict(df)

    # model.plot(forecast).savefig(f"{ticker}_plot.png")
    # model.plot_components(forecast).savefig(f"{ticker}_plot_components.png")

    return forecast.tail(days).to_dict("records")` 

运行应用程序:

`(env)$ uvicorn main:app --reload --workers 1 --host 0.0.0.0 --port 8008` 

然后,在新的终端窗口中,使用 curl 测试端点:

`$ curl \
  --header "Content-Type: application/json" \
  --request POST \
  --data '{"ticker":"MSFT"}' \
  http://localhost:8008/predict` 

您应该会看到类似这样的内容:

`{ "ticker":"MSFT", "forecast":{ "08/12/2021":  282.99012951691776, "08/13/2021":  283.31354121099446, "08/14/2021":  283.63695290507127, "08/15/2021":  283.960364599148, "08/16/2021":  284.2837762932248, "08/17/2021":  284.6071879873016, "08/18/2021":  284.93059968137834 } }` 

如果 ticker 模型不存在会怎么样?

`$ curl \
  --header "Content-Type: application/json" \
  --request POST \
  --data '{"ticker":"NONE"}' \
  http://localhost:8008/predict

{
  "detail": "Model not found."
}` 

Heroku 部署

Heroku 是一个平台即服务(PaaS ),为网络应用提供托管服务。它们提供了抽象的环境,您无需管理底层基础设施,从而轻松管理、部署和扩展 web 应用程序。只需几次点击,您就可以启动并运行您的应用程序,准备接收流量。

注册一个 Heroku 账号(如果你还没有的话),然后安装 Heroku CLI (如果你还没有的话)。

接下来,通过 CLI 登录您的 Heroku 帐户:

系统会提示您按任意键打开 web 浏览器以完成登录。

在 Heroku 上创建一个新应用程序:

您应该会看到类似如下的内容:

`Creating app... done, ⬢ tranquil-cliffs-74287
https://tranquil-cliffs-74287.herokuapp.com/ | https://git.heroku.com/tranquil-cliffs-74287.git` 

接下来,我们将使用 Heroku 的容器注册表来部署带有 Docker 的应用程序。简单地说,通过容器注册中心,您可以将预构建的 Docker 映像部署到 Heroku。

为什么是 Docker?我们希望最小化生产和开发环境之间的差异。这对于这个项目尤其重要,因为它依赖于许多具有非常具体的系统要求的数据科学依赖项。

登录 Heroku 容器注册表,向 Heroku 表明我们想要使用容器运行时:

向项目根目录添加一个 Dockerfile 文件:

`FROM  python:3.8

WORKDIR  /app

RUN  apt-get -y update  && apt-get install -y \
  python3-dev \
  apt-utils \
  python-dev \
  build-essential \
&& rm -rf /var/lib/apt/lists/*

RUN  pip install --upgrade setuptools
RUN  pip install \
    cython==0.29.24 \
    numpy==1.21.1 \
    pandas==1.3.1 \
    pystan==2.19.1.1

COPY  requirements.txt .
RUN  pip install -r requirements.txt

COPY  . .

CMD  gunicorn -w 3 -k uvicorn.workers.UvicornWorker main:app --bind 0.0.0.0:$PORT` 

在这里,在提取 Python 3.8 基础映像后,我们安装了适当的依赖项,复制了应用程序,并运行了生产级 WSGI 应用服务器 Gunicorn ,以管理具有三个工作进程的 Uvicorn。这种配置利用了并发性(通过 Uvicorn)和并行性(通过 Gunicorn workers)。

将 Gunicorn 添加到 requirements.txt 文件中:

`# pystan must be installed before prophet
# you may need to pip install it on it's own
# before installing the remaining requirements
# pip install pystan==2.19.1.1

pystan==2.19.1.1

fastapi==0.68.0
gunicorn==20.1.0
uvicorn==0.14.0

fbprophet==0.7.1
joblib==1.0.1
pandas==1.3.1
plotly==5.1.0
yfinance==0.1.63` 

加一个。dockerignore 文件也一样:

构建 Docker 映像,并用以下格式对其进行标记:

`registry.heroku.com/<app>/<process-type>` 

确保将<app>替换为您刚刚创建的 Heroku 应用程序的名称,将<process-type>替换为web,因为这将用于 web 流程

例如:

`$ docker build -t registry.heroku.com/tranquil-cliffs-74287/web .` 

安装fbprophet需要几分钟时间。耐心点。你应该看到它挂在这里一段时间:

`Running setup.py install for fbprophet: started` 

完成后,您可以像这样运行映像:

`$ docker run --name fastapi-ml -e PORT=8008 -p 8008:8008 -d registry.heroku.com/tranquil-cliffs-74287/web:latest` 

确保http://localhost:8008/ping按预期工作。完成后,停止并移除容器:

`$ docker stop fastapi-ml
$ docker rm fastapi-ml` 

将图像推送到注册表:

`$ docker push registry.heroku.com/tranquil-cliffs-74287/web` 

发布图像:

`$ heroku container:release -a tranquil-cliffs-74287 web` 

这将运行容器。您现在应该可以查看您的应用程序了。确保测试/predict终点:

`$ curl \
  --header "Content-Type: application/json" \
  --request POST \
  --data '{"ticker":"MSFT"}' \
  https://<YOUR_HEROKU_APP_NAME>.herokuapp.com/predict` 

最后,查看 FastAPI 在https://<YOUR_HEROKU_APP_NAME>.herokuapp.com/docs自动生成的交互式 API 文档:

swagger ui

结论

本教程介绍了如何在 Heroku 上使用 FastAPI 将一个用于预测股票价格的机器学习模型作为 RESTful API 部署到生产中。

下一步是什么?

  1. 建立数据库以保存预测结果
  2. 创建一个生产 Docker 文件,该文件使用多级 Docker 构建来减小生产映像的大小
  3. 添加日志记录和监控
  4. 将视图函数和模型预测函数转换为异步函数
  5. 将预测作为后台任务运行,以防止阻塞
  6. 添加测试
  7. 将训练好的模型存储到 AWS S3,在 Heroku 的短暂文件系统之外

查看以下资源以获得上述内容的帮助:

  1. 使用 FastAPI 和 Pytest 开发和测试异步 API
  2. 用 FastAPI 和 Docker 进行测试驱动开发

如果您正在部署一个非平凡的模型,我建议添加模型版本控制和对反事实分析的支持以及模型监控(模型和特性漂移,偏差检测)。查看 Monitaur 平台以获得这些方面的帮助。

您可以在 fastapi-ml repo 中找到代码。

用 FastAPI 和 MongoDB 构建 CRUD 应用程序

原文:https://testdriven.io/blog/fastapi-mongo/

在本教程中,你将学习如何用 FastAPIMongoDB 开发异步 API。我们将使用马达包与 MongoDB 进行异步交互。

目标

本教程结束时,您将能够:

  1. 用 Python 和 FastAPI 开发 RESTful API
  2. 与 MongoDB 异步交互
  3. 使用 MongoDB Atlas 在云中运行 MongoDB
  4. 将 FastAPI 应用程序部署到 Heroku

初始设置

首先创建一个新文件夹来保存名为“fastapi-mongo”的项目:

`$ mkdir fastapi-mongo
$ cd fastapi-mongo` 

接下来,创建并激活虚拟环境:

`$ python3.9 -m venv venv
$ source venv/bin/activate
$ export PYTHONPATH=$PWD` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

接下来,创建以下文件和文件夹:

`├── app
│   ├── __init__.py
│   ├── main.py
│   └── server
│       ├── app.py
│       ├── database.py
│       ├── models
│       └── routes
└── requirements.txt` 

将以下依赖项添加到您的 requirements.txt 文件中:

`fastapi==0.73.0
uvicorn==0.17.4` 

安装它们:

`(venv)$ pip install -r requirements.txt` 

app/main.py 文件中,定义运行应用程序的入口点:

`import uvicorn

if __name__ == "__main__":
    uvicorn.run("server.app:app", host="0.0.0.0", port=8000, reload=True)` 

这里,我们指示文件在端口 8000 上运行一个uvicon服务器,并在每次文件更改时重新加载。

在通过入口点文件启动服务器之前,在 app/server/app.py 中创建一个基本路由:

`from fastapi import FastAPI

app = FastAPI()

@app.get("/", tags=["Root"])
async def read_root():
    return {"message": "Welcome to this fantastic app!"}` 

标签是用于对路线进行分组的标识符。具有相同标签的路线被分组到 API 文档的一个部分中。

从控制台运行入口点文件:

`(venv)$ python app/main.py` 

在浏览器中导航至 http://localhost:8000 。您应该看到:

`{ "message":  "Welcome to this fantastic app!" }` 

也可以在http://localhost:8000/docs查看交互 API 文档:

fastapi swagger ui

路线

我们将构建一个简单的应用程序,通过以下 CRUD 途径存储学生数据:

crud routes

在我们开始编写路由之前,让我们首先定义相关的模式并配置 MongoDB。

(计划或理论的)纲要

让我们定义我们的数据将基于的模式,它将表示数据如何存储在 MongoDB 数据库中。

Pydantic 模式用于验证数据以及序列化(JSON -> Python)和反序列化(Python -> JSON)。换句话说,它不充当 Mongo 模式验证器

在“app/server/models”文件夹中,创建一个名为 student.py 的新文件:

`from typing import Optional

from pydantic import BaseModel, EmailStr, Field

class StudentSchema(BaseModel):
    fullname: str = Field(...)
    email: EmailStr = Field(...)
    course_of_study: str = Field(...)
    year: int = Field(..., gt=0, lt=9)
    gpa: float = Field(..., le=4.0)

    class Config:
        schema_extra = {
            "example": {
                "fullname": "John Doe",
                "email": "[[email protected]](/cdn-cgi/l/email-protection)",
                "course_of_study": "Water resources engineering",
                "year": 2,
                "gpa": "3.0",
            }
        }

class UpdateStudentModel(BaseModel):
    fullname: Optional[str]
    email: Optional[EmailStr]
    course_of_study: Optional[str]
    year: Optional[int]
    gpa: Optional[float]

    class Config:
        schema_extra = {
            "example": {
                "fullname": "John Doe",
                "email": "[[email protected]](/cdn-cgi/l/email-protection)x.edu.ng",
                "course_of_study": "Water resources and environmental engineering",
                "year": 4,
                "gpa": "4.0",
            }
        }

def ResponseModel(data, message):
    return {
        "data": [data],
        "code": 200,
        "message": message,
    }

def ErrorResponseModel(error, code, message):
    return {"error": error, "code": code, "message": message}` 

在上面的代码中,我们定义了一个名为StudentSchema的 Pydantic 模式,它表示学生数据将如何存储在 MongoDB 数据库中。

在 Pydantic 中,省略号...表示某个字段是必填的。它可以被替换为None或默认值。在StudentSchema中,每个字段都有一个省略号,因为每个字段都很重要,如果没有设置值,程序就不能继续运行。

StudentSchemagpayear字段中,我们添加了验证器 gtltle:

  1. year字段中的gtlt保证传递的值大于 0 小于 9 。因此,诸如 01011 的值将导致错误。
  2. gpa字段中的le验证器确保传递的值小于或等于 4.0

这个模式将帮助用户以适当的形式向 API 发送 HTTP 请求——即,要发送的数据类型和发送方式。

FastAPI 使用 Pyantic 模式结合 Json 模式自动记录数据模型。 Swagger UI 然后从生成的数据模型中渲染数据。你可以在这里阅读更多关于 FastAPI 如何生成 API 文档

由于我们使用了EmailStr,我们需要安装电子邮件验证器

将其添加到需求文件中:

安装:

`(venv)$ pip install -r requirements.txt` 

有了模式之后,让我们在为 API 编写路由之前设置 MongoDB。

MongoDB

在这一节中,我们将连接 MongoDB 并配置我们的应用程序与之通信。

维基百科介绍,MongoDB 是一个跨平台的面向文档的数据库程序。作为一个 NoSQL 数据库程序,MongoDB 使用带有可选模式的类似 JSON 的文档。

MongoDB 设置

如果您的机器上没有安装 MongoDB,请参考文档中的安装指南。安装完成后,继续按照指南运行 mongod 守护进程。一旦完成,您就可以通过使用mongo shell 命令连接到实例来验证 MongoDB 已经启动并正在运行:

作为参考,本教程使用 MongoDB 社区版 v5.0.6。

`$ mongo --version

MongoDB shell version v5.0.6

Build Info: {
    "version": "5.0.6",
    "gitVersion": "212a8dbb47f07427dae194a9c75baec1d81d9259",
    "modules": [],
    "allocator": "system",
    "environment": {
        "distarch": "x86_64",
        "target_arch": "x86_64"
    }
}` 

电机设置

接下来,我们将配置异步 MongoDB 驱动程序 Motor 来与数据库交互。

首先将依赖项添加到需求文件中:

安装:

`(venv)$ pip install -r requirements.txt` 

回到应用程序,将数据库连接信息添加到 app/server/database.py :

`import motor.motor_asyncio

MONGO_DETAILS = "mongodb://localhost:27017"

client = motor.motor_asyncio.AsyncIOMotorClient(MONGO_DETAILS)

database = client.students

student_collection = database.get_collection("students_collection")` 

在上面的代码中,我们导入了 Motor,定义了连接细节,并通过 AsyncIOMotorClient 创建了一个客户端。

然后我们引用了一个名为students的数据库和一个名为students_collection的集合(类似于关系数据库中的一个表)。因为这些只是引用而不是实际的 I/O,所以也不需要await表达式。当进行第一次 I/O 操作时,如果数据库和集合尚不存在,则将创建它们。

接下来,创建一个快速助手函数,用于将数据库查询的结果解析成 Python dict。

将此内容也添加到 database.py 文件中:

`import motor.motor_asyncio

MONGO_DETAILS = "mongodb://localhost:27017"

client = motor.motor_asyncio.AsyncIOMotorClient(MONGO_DETAILS)

database = client.students

student_collection = database.get_collection("students_collection")

# helpers

def student_helper(student) -> dict:
    return {
        "id": str(student["_id"]),
        "fullname": student["fullname"],
        "email": student["email"],
        "course_of_study": student["course_of_study"],
        "year": student["year"],
        "GPA": student["gpa"],
    }` 

接下来,让我们编写 CRUD 数据库操作。

数据库 CRUD 操作

首先从 database.py 文件顶部的 bson 包中导入ObjectId方法:

`from bson.objectid import ObjectId` 

bson 作为电机的附属设备安装。

接下来,为 CRUD 操作添加以下每个函数:

`# Retrieve all students present in the database
async def retrieve_students():
    students = []
    async for student in student_collection.find():
        students.append(student_helper(student))
    return students

# Add a new student into to the database
async def add_student(student_data: dict) -> dict:
    student = await student_collection.insert_one(student_data)
    new_student = await student_collection.find_one({"_id": student.inserted_id})
    return student_helper(new_student)

# Retrieve a student with a matching ID
async def retrieve_student(id: str) -> dict:
    student = await student_collection.find_one({"_id": ObjectId(id)})
    if student:
        return student_helper(student)

# Update a student with a matching ID
async def update_student(id: str, data: dict):
    # Return false if an empty request body is sent.
    if len(data) < 1:
        return False
    student = await student_collection.find_one({"_id": ObjectId(id)})
    if student:
        updated_student = await student_collection.update_one(
            {"_id": ObjectId(id)}, {"$set": data}
        )
        if updated_student:
            return True
        return False

# Delete a student from the database
async def delete_student(id: str):
    student = await student_collection.find_one({"_id": ObjectId(id)})
    if student:
        await student_collection.delete_one({"_id": ObjectId(id)})
        return True` 

在上面的代码中,我们定义了异步操作来通过 motor 创建、读取、更新和删除数据库中的学生数据。

在更新和删除操作中,在数据库中搜索学生以决定是否执行操作。返回值指导如何向用户发送响应,这将在下一节中讨论。

CRUD 路线

在本节中,我们将添加路由来补充数据库文件中的数据库操作。

在“routes”文件夹中,创建一个名为 student.py 的新文件,并向其中添加以下内容:

`from fastapi import APIRouter, Body
from fastapi.encoders import jsonable_encoder

from app.server.database import (
    add_student,
    delete_student,
    retrieve_student,
    retrieve_students,
    update_student,
)
from app.server.models.student import (
    ErrorResponseModel,
    ResponseModel,
    StudentSchema,
    UpdateStudentModel,
)

router = APIRouter()` 

我们将使用 FastAPI 的 JSON 兼容编码器将我们的模型转换成 JSON 兼容的格式。

接下来,在 app/server/app.py 中连线学生路线:

`from fastapi import FastAPI

from app.server.routes.student import router as StudentRouter

app = FastAPI()

app.include_router(StudentRouter, tags=["Student"], prefix="/student")

@app.get("/", tags=["Root"])
async def read_root():
    return {"message": "Welcome to this fantastic app!"}` 

创造

回到 routes 文件,添加以下用于创建新学生的处理程序:

`@router.post("/", response_description="Student data added into the database")
async def add_student_data(student: StudentSchema = Body(...)):
    student = jsonable_encoder(student)
    new_student = await add_student(student)
    return ResponseModel(new_student, "Student added successfully.")` 

因此,该路由期望一个匹配StudentSchema格式的有效载荷。示例:

`{ "fullname":  "John Doe", "email":  "[[email protected]](/cdn-cgi/l/email-protection)", "course_of_study":  "Water resources engineering", "year":  2, "gpa":  "3.0", }` 

启动 Uvicorn 服务器:

`(venv)$ python app/main.py` 

并在http://localhost:8000/docs刷新交互 API 文档页面查看新路由:

swagger ui

也测试一下:

swagger ui

因此,当一个请求被发送到端点时,在调用add_student数据库方法和将响应存储在new_student变量之前,它将 JSON 编码的请求体存储在变量student中。然后通过ResponseModel返回来自数据库的响应。

也测试一下验证器:

  1. 年份必须大于 0 小于 10
  2. GPA 必须小于或等于 4.0

swagger ui

阅读

向右移动,添加以下路线来检索所有学生和单个学生:

`@router.get("/", response_description="Students retrieved")
async def get_students():
    students = await retrieve_students()
    if students:
        return ResponseModel(students, "Students data retrieved successfully")
    return ResponseModel(students, "Empty list returned")

@router.get("/{id}", response_description="Student data retrieved")
async def get_student_data(id):
    student = await retrieve_student(id)
    if student:
        return ResponseModel(student, "Student data retrieved successfully")
    return ErrorResponseModel("An error occurred.", 404, "Student doesn't exist.")` 

swagger ui

如果您没有为 id 传递一个有效的 ObjectId(例如,1)来检索单个学生路由,会发生什么情况?如何在应用程序中更好地处理这个问题?

当实现删除操作时,您将有机会测试空数据库的响应。

更新

接下来,编写更新学生数据的单独路径:

`@router.put("/{id}")
async def update_student_data(id: str, req: UpdateStudentModel = Body(...)):
    req = {k: v for k, v in req.dict().items() if v is not None}
    updated_student = await update_student(id, req)
    if updated_student:
        return ResponseModel(
            "Student with ID: {} name update is successful".format(id),
            "Student name updated successfully",
        )
    return ErrorResponseModel(
        "An error occurred",
        404,
        "There was an error updating the student data.",
    )` 

swagger ui

删除

最后,添加删除路径:

`@router.delete("/{id}", response_description="Student data deleted from the database")
async def delete_student_data(id: str):
    deleted_student = await delete_student(id)
    if deleted_student:
        return ResponseModel(
            "Student with ID: {} removed".format(id), "Student deleted successfully"
        )
    return ErrorResponseModel(
        "An error occurred", 404, "Student with id {0} doesn't exist".format(id)
    )` 

检索您之前创建的用户的 ID,并测试删除路由:

swagger ui

移除任何剩余的学生,并再次测试读取路线,确保响应适用于空数据库。

部署

在这一节中,我们将把应用程序部署到 Heroku,并为 MongoDB 配置一个云数据库。

蒙戈布地图集

在部署之前,我们需要设置 MongoDB Atlas ,这是一个云数据库服务,用于 MongoDB 托管我们的数据库。

遵循入门指南,您将创建一个帐户,部署一个免费层集群,设置一个用户,并将一个 IP 地址列入白名单。

出于测试目的,将0.0.0.0/0用于白名单中的 IP,以允许来自任何地方的访问。对于生产应用程序,您需要限制对静态 IP 的访问。

完成后,通过单击“Connect”按钮从集群中获取数据库连接信息:

mongodb atlas

单击第二个选项“连接到您的应用程序”:

mongodb atlas

复制连接 URL,确保更新密码。将默认数据库也设置为“学生”。它将类似于:

`mongodb+srv://foobar:foobar@cluster0.0reol.mongodb.net/students?retryWrites=true&w=majority` 

我们将定义它有一个环境变量,而不是在我们的应用程序中硬编码这个值。创建一个名为的新文件。项目根中的 env 以及到它的连接信息:

`MONGO_DETAILS=your_connection_url` 

确保用复制的 URL 替换your_connection_url

接下来,为了简化应用程序中环境变量的管理,让我们安装 Python Decouple 包。将它添加到您的需求文件中,如下所示:

安装:

`(venv)$ pip install -r requirements.txt` 

app/server/database.py 文件中,导入库:

`from decouple import config` 

导入的config方法在根目录中扫描一个。env 文件并读取传递给它的内容。因此,在我们的例子中,它将读取MONGO_DETAILS变量。

接下来,将MONGO_DETAILS变量改为:

`MONGO_DETAILS = config("MONGO_DETAILS")  # read environment variable` 

本地测试

在部署之前,让我们使用云数据库在本地测试应用程序,以确保连接配置正确。重启您的 Uvicorn 服务器,并从位于http://localhost:8000/docs的交互文档中测试每条路由。

您应该能够在 Atlas 仪表盘上看到数据:

mongodb atlas

部署到 Heroku

最后,让我们将应用程序部署到 Heroku

Heroku 是一个云平台即服务(PaaS ),用于部署和扩展应用程序。

如有必要,注册一个 Heroku 账号,安装 Heroku CLI

在继续之前,创建一个。gitignore 项目中的文件,防止签入“venv”文件夹和。要 git 的 env 文件:

添加以下内容:

接下来,将一个 Procfile 添加到您的项目的根目录:

`web:  uvicorn  app.server.app:app  --host  0.0.0.0  --port=$PORT` 

注意事项:

  1. Procfile 是一个文本文件,放在项目的根目录下,指导 Heroku 如何运行你的应用程序。因为我们提供的是 web 应用程序,所以我们定义了流程类型web以及提供 Uvicorn 的命令。
  2. Heroku 为您的应用程序动态公开一个在部署时运行的端口,这是通过$PORT环境变量公开的。

您的项目现在应该有以下文件和文件夹:

`├── .env
├── .gitignore
├── Procfile
├── app
│   ├── __init__.py
│   ├── main.py
│   └── server
│       ├── app.py
│       ├── database.py
│       ├── models
│       │   └── student.py
│       └── routes
│           └── student.py
└── requirements.txt` 

在项目的根目录中,初始化一个新的 git 存储库:

`(venv)$ git init
(venv)$ git add .
(venv)$ git commit -m "My fastapi and mongo application"` 

现在,我们可以在 Heroku 上创建一个新应用程序:

除了创建一个新的应用程序,这个命令还会在 Heroku 上创建一个远程 git 存储库,以便我们将应用程序推送到该存储库进行部署。然后,它会自动在本地存储库上将其设置为远程。

您可以通过运行git remote -v来验证遥控器是否已设置。

记下应用程序的 URL。

既然我们没有加上。env 文件到 git,我们需要在 Heroku 环境中设置环境变量:

`(venv)$ heroku config:set MONGO_DETAILS="your_mongo_connection_url"` 

同样,确保用真实的连接 URL 替换your_connection_url

将您的代码推送到 Heroku,并确保至少有一个应用程序实例正在运行:

`(venv)$ git push heroku master
(venv)$ heroku ps:scale web=1` 

运行heroku open在默认浏览器中打开你的应用。

您已经成功地将您的应用程序部署到 Heroku。测试一下。

结论

在本教程中,您学习了如何使用 FastAPI 和 MongoDB 创建一个 CRUD 应用程序,并将其部署到 Heroku。通过回顾本教程开头的目标,快速进行自我检查。你可以在 GitHub 上找到本教程使用的代码。

想要更多吗?

  1. 用 pytest 设置单元和集成测试。
  2. 添加其他路线。
  3. 为您的应用程序创建一个 GitHub repo,并使用 GitHub 操作配置 CI/CD。
  4. 使用 Fixie Socks 在 Heroku 上配置一个静态 IP,并限制对 MongoDB Atlas 数据库的访问。
  5. 回顾一下用 FastAPI、MongoDB 和 Beanie 构建 CRUD 应用教程,看看如何利用 Beanie ODM ,它在 Motor 上提供了一个额外的抽象层,使得与 Mongo 数据库中的集合进行交互变得更加容易。

查看测试驱动的 FastAPI 开发和 Docker 课程,了解有关测试和设置 FastAPI 应用的 CI/CD 的更多信息。

干杯!

使用 FastAPI 和 React 开发单页面应用程序

原文:https://testdriven.io/blog/fastapi-react/

在本教程中,你将使用 FastAPIReact 构建一个 CRUD 应用。在用 FastAPI 构建后端 RESTful API 之前,我们将首先用 Create React app CLI 搭建一个新的 React App。最后,我们将开发后端 CRUD 路径以及前端 React 组件。

最终应用:

Final Todo App

依赖关系:

  • React v18.1.0
  • 创建 React 应用版本 5.0.1
  • 节点 v18.2.0
  • npm 版本 8.9.0
  • npx v8.9.0
  • FastAPI v0.78.0
  • Python v3.10 版

在开始本教程之前,您应该熟悉 React 是如何工作的。要快速复习 React,请查看主要概念指南或React 简介教程。

目标

本教程结束时,您将能够:

  1. 用 Python 和 FastAPI 开发 RESTful API
  2. 使用创建 React 应用程序搭建 React 项目
  3. 用 React 上下文 API 和钩子管理状态操作
  4. 在浏览器中创建并渲染 React 组件
  5. 将 React 应用程序连接到 FastAPI 后端

什么是 FastAPI?

FastAPI 是一个 Python web 框架,旨在构建快速高效的后端 API。它处理同步和异步操作,并内置了对数据验证、认证和由 OpenAPI 驱动的交互式 API 文档的支持。

有关 FastAPI 的更多信息,请查看以下资源:

  1. 正式文件
  2. FastAPI 教程

什么是反应?

React 是一个开源的、基于组件的 JavaScript UI 库,用于构建前端应用程序。

更多信息,请查看官方文档中的入门指南

设置 FastAPI

首先创建一个新文件夹来保存名为“fastapi-react”的项目:

`$ mkdir fastapi-react
$ cd fastapi-react` 

在“fastapi-react”文件夹中,创建一个新文件夹来存放后端:

`$ mkdir backend
$ cd backend` 

接下来,创建并激活虚拟环境:

`$ python3.10 -m venv venv
$ source venv/bin/activate
$ export PYTHONPATH=$PWD` 

你可以随意把 venv 和 Pip 换成诗歌Pipenv 。更多信息,请查看现代 Python 环境

Install FastAPI:

`(venv)$ pip install fastapi==0.78.0 uvicorn==0.17.6` 

uvicon是一个 ASGI (异步服务器网关接口)兼容服务器,将用于支持后端 API。

接下来,在“后端”文件夹中创建以下文件和文件夹:

`└── backend
    ├── main.py
    └── app
        ├── __init__.py
        └── api.py` 

main.py 文件中,定义运行应用程序的入口点:

`import uvicorn

if __name__ == "__main__":
    uvicorn.run("app.api:app", host="0.0.0.0", port=8000, reload=True)` 

在这里,我们指示该文件在端口 8000 上运行一个 Uvicorn 服务器,并在每次文件更改时重新加载。

在通过入口点文件启动服务器之前,在 backend/app/api.py 中创建一个基本路由:

`from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins = [
    "http://localhost:3000",
    "localhost:3000"
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"]
)

@app.get("/", tags=["root"])
async def read_root() -> dict:
    return {"message": "Welcome to your todo list."}` 

为什么我们需要中间件?为了进行跨来源请求——即来自不同协议、IP 地址、域名或端口的请求——您需要启用跨来源资源共享 (CORS)。FastAPI 的内置CORSMiddleware为我们处理这个问题。

上述配置将允许来自我们前端域和端口的跨来源请求,这些请求将在localhost:3000运行。

关于 FastAPI 中 CORS 处理的更多信息,请查看官方文档

从控制台运行入口点文件:

在浏览器中导航至 http://localhost:8000 。您应该看到:

`{ "message":  "Welcome to your todo list." }` 

设置 React

同样,我们将使用 Create React App CLI 工具通过 npx 搭建一个新的 React 应用。

在新的终端窗口中,导航到项目目录,然后生成新的 React 应用程序:

如果这是您第一次使用 Create React App 工具搭建 React 应用程序,请查看文档

为了简单起见,删除“src”文件夹中除了 index.js 文件之外的所有文件。 index.js 是我们的基础组件。

接下来,安装一个名为 Chakra UI 的 UI 组件库:

安装完成后,在“src”文件夹中创建一个名为“components”的新文件夹,用于存放应用程序的组件,以及两个组件, Header.jsxTodos.jsx :

`$ cd src
$ mkdir components
$ cd components
$ touch {Header,Todos}.jsx` 

我们将从 Header.jsx 文件中的Header组件开始:

`import React from "react";
import { Heading, Flex, Divider } from "@chakra-ui/react";

const Header = () => {
  return (
    <Flex
      as="nav"
      align="center"
      justify="space-between"
      wrap="wrap"
      padding="0.5rem"
      bg="gray.400"
    >
      <Flex align="center" mr={5}>
        <Heading as="h1" size="sm">Todos</Heading>
        <Divider />
      </Flex>
    </Flex>
  );
};

export default Header;` 

从 Chakra UI 导入 React 和 HeadingFlexDivider 组件后,我们定义了一个组件来呈现一个基本的 header。然后,该元件被导出以用于基础元件。

接下来,让我们重写 index.js 中的基本组件。将前面的代码替换为:

`import React from "react";
import { render } from 'react-dom';
import { ChakraProvider } from "@chakra-ui/react";

import Header from "./components/Header";

function App() {
  return (
    <ChakraProvider>
      <Header />
    </ChakraProvider>
  )
}

const rootElement = document.getElementById("root")
render(<App />, rootElement)` 

ChakraProvider ,从 Chakra UI 库中导入,作为使用 Chakra UI 的其他组件的父组件。它通过 React 的上下文 API 为所有子组件(本例中为Header)提供一个主题。

从终端启动 React 应用程序:

这将在您的默认浏览器中打开 React 应用程序,位于 http://localhost:3000 。您应该看到:

Todo App

我们在建造什么?

在本教程的剩余部分,您将构建一个 todo CRUD 应用程序来创建、读取、更新和删除 todo。最终,您的应用程序将如下所示:

Final Todo App

获取路线

后端

首先将待办事项列表添加到 backend/app/api.py :

`todos = [
    {
        "id": "1",
        "item": "Read a book."
    },
    {
        "id": "2",
        "item": "Cycle around town."
    }
]` 

上面的列表只是本教程使用的虚拟数据。这些数据只是简单地表示了各个 todos 的结构。请随意连接数据库并在那里存储 todos。

然后,添加路由处理程序:

`@app.get("/todo", tags=["todos"])
async def get_todos() -> dict:
    return { "data": todos }` 

http://localhost:8000/todo手动测试新路由。也可以在http://localhost:8000/docs查看交互文档:

App Docs

前端

Todos.jsx 组件中,首先导入 React、useState()useEffect()钩子,以及一些 Chakra UI 组件:

`import  React,  {  useEffect,  useState  }  from  "react"; import  { Box, Button, Flex, Input, InputGroup, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Stack, Text, useDisclosure }  from  "@chakra-ui/react";` 

useState钩子负责管理我们的应用程序的本地状态,而useEffect钩子允许我们执行数据获取等操作。

关于 React 钩子的更多信息,请查阅 React 钩子的入门教程和官方文档中的介绍钩子的 T2 教程。

接下来,创建一个跨所有组件管理全局状态活动的上下文:

`const  TodosContext  =  React.createContext({ todos:  [],  fetchTodos:  ()  =>  {} })` 

在上面的代码块中,我们通过 createContext 定义了一个上下文对象,它接受两个提供者值:todosfetchTodosfetchTodos函数将在下一个代码块中定义。

想了解更多关于使用 React 上下文 API 管理状态的信息吗?查看 React 上下文 API:轻松管理状态文章。

接下来,添加Todos组件:

`export  default  function  Todos()  { const  [todos,  setTodos]  =  useState([]) const  fetchTodos  =  async  ()  =>  { const  response  =  await  fetch("http://localhost:8000/todo") const  todos  =  await  response.json() setTodos(todos.data) } }` 

这里,我们创建了一个空的状态变量数组todos和一个状态方法setTodos,因此我们可以更新状态变量。接下来,我们定义了一个名为fetchTodos的函数,从后端异步检索 todos,并在函数结束时更新todo状态变量。

接下来,在Todos组件中,使用fetchTodos函数检索 todos,并通过迭代 todos 状态变量来呈现数据:

`useEffect(()  =>  { fetchTodos() },  []) return  ( <TodosContext.Provider  value={{todos,  fetchTodos}}> <Stack  spacing={5}> {todos.map((todo)  =>  ( <b>{todo.item}</b> ))} </Stack> </TodosContext.Provider> )` 

Todos.jsx 现在应该是这样的:

`import React, { useEffect, useState } from "react";
import {
    Box,
    Button,
    Flex,
    Input,
    InputGroup,
    Modal,
    ModalBody,
    ModalCloseButton,
    ModalContent,
    ModalFooter,
    ModalHeader,
    ModalOverlay,
    Stack,
    Text,
    useDisclosure
} from "@chakra-ui/react";

const TodosContext = React.createContext({
  todos: [], fetchTodos: () => {}
})

export default function Todos() {
  const [todos, setTodos] = useState([])
  const fetchTodos = async () => {
    const response = await fetch("http://localhost:8000/todo")
    const todos = await response.json()
    setTodos(todos.data)
  }
  useEffect(() => {
    fetchTodos()
  }, [])
  return (
    <TodosContext.Provider value={{todos, fetchTodos}}>
      <Stack spacing={5}>
        {todos.map((todo) => (
          <b>{todo.item}</b>
        ))}
      </Stack>
    </TodosContext.Provider>
  )
}` 

导入 index.js 文件中的Todos组件并渲染:

`import React from "react";
import { render } from 'react-dom';
import { ChakraProvider } from "@chakra-ui/react";

import Header from "./components/Header";
import Todos from "./components/Todos";  // new

function App() {
  return (
    <ChakraProvider>
      <Header />
      <Todos />  {/* new */}
    </ChakraProvider>
  )
}

const rootElement = document.getElementById("root")
render(<App />, rootElement)` 

您在 http://localhost:3000 的应用程序现在应该是这样的:

Todo App

尝试在 backend/app/api.py 中的todos列表中添加一个新的 todo。刷新浏览器。您应该会看到新的 todo。这样,我们就完成了检索所有 todos 的 GET 请求。

邮寄路线

后端

首先添加一个新的路由处理程序来处理向 backend/app/api.py 添加新 todo 的 POST 请求:

`@app.post("/todo", tags=["todos"])
async def add_todo(todo: dict) -> dict:
    todos.append(todo)
    return {
        "data": { "Todo added." }
    }` 

随着后端的运行,您可以使用curl在新的终端选项卡中测试 POST 路由:

`$ curl -X POST http://localhost:8000/todo -d \
    '{"id": "3", "item": "Buy some testdriven courses."}' \
    -H 'Content-Type: application/json'` 

您应该看到:

`{ "data: [
 "Todo  added."
 ]" }` 

您还应该在来自http://localhost:8000/todo端点以及 http://localhost:3000 的响应中看到新的 todo。

作为练习,实现一个检查来防止添加重复的 todo 项。

前端

首先添加用于向frontend/src/components/todos . jsx添加新 todo 的 shell:

`function  AddTodo()  { const  [item,  setItem]  =  React.useState("") const  {todos,  fetchTodos}  =  React.useContext(TodosContext) }` 

这里,我们创建了一个新的状态变量来保存表单中的值。我们还检索了上下文值,todosfetchTodos

接下来,向AddTodo添加从表单获取输入和处理表单提交的函数:

`const  handleInput  =  event  =>  { setItem(event.target.value) } const  handleSubmit  =  (event)  =>  { const  newTodo  =  { "id":  todos.length  +  1, "item":  item } fetch("http://localhost:8000/todo",  { method:  "POST", headers:  {  "Content-Type":  "application/json"  }, body:  JSON.stringify(newTodo) }).then(fetchTodos) }` 

handleSubmit函数中,我们添加了一个 POST 请求,并用 todo 信息将数据发送到服务器。然后我们调用fetchTodos来更新todos

就在handleSubmit函数之后,返回要呈现的表单:

`return (
  <form onSubmit={handleSubmit}>
    <InputGroup size="md">
      <Input
        pr="4.5rem"
        type="text"
        placeholder="Add a todo item"
        aria-label="Add a todo item"
        onChange={handleInput}
      />
    </InputGroup>
  </form>
)` 

在上面的代码块中,我们将表单onSubmit事件监听器设置为我们之前创建的handleSubmit函数。通过onChange监听器,当输入值改变时,todo 项值也会更新。

完整的AddTodo组件现在应该看起来像这样:

`function AddTodo() {
  const [item, setItem] = React.useState("")
  const {todos, fetchTodos} = React.useContext(TodosContext)

  const handleInput = event  => {
    setItem(event.target.value)
  }

  const handleSubmit = (event) => {
    const newTodo = {
      "id": todos.length + 1,
      "item": item
    }

    fetch("http://localhost:8000/todo", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(newTodo)
    }).then(fetchTodos)
  }

  return (
    <form onSubmit={handleSubmit}>
      <InputGroup size="md">
        <Input
          pr="4.5rem"
          type="text"
          placeholder="Add a todo item"
          aria-label="Add a todo item"
          onChange={handleInput}
        />
      </InputGroup>
    </form>
  )
}` 

接下来,将AddTodo组件添加到Todos组件,如下所示:

`export default function Todos() {
  const [todos, setTodos] = useState([])
  const fetchTodos = async () => {
    const response = await fetch("http://localhost:8000/todo")
    const todos = await response.json()
    setTodos(todos.data)
  }
  useEffect(() => {
    fetchTodos()
  }, [])
  return (
    <TodosContext.Provider value={{todos, fetchTodos}}>
      <AddTodo />  {/* new */}
      <Stack spacing={5}>
        {todos.map((todo) => (
          <b>{todo.item}</b>
        ))}
      </Stack>
    </TodosContext.Provider>
  )
}` 

前端应用程序应该如下所示:

Todo App

通过添加 todo 来测试表单:

Add new todo

放置路线

后端

添加更新路由:

`@app.put("/todo/{id}", tags=["todos"])
async def update_todo(id: int, body: dict) -> dict:
    for todo in todos:
        if int(todo["id"]) == id:
            todo["item"] = body["item"]
            return {
                "data": f"Todo with id {id} has been updated."
            }

    return {
        "data": f"Todo with id {id} not found."
    }` 

因此,我们检查 ID 与所提供的 ID 相匹配的 todo,如果找到,就用来自请求体的值更新 todo 的项目。

前端

首先在frontend/src/components/todos . jsx中定义组件UpdateTodo,并向其传递两个属性值itemid:

`function  UpdateTodo({item,  id})  { const  {isOpen,  onOpen,  onClose}  =  useDisclosure() const  [todo,  setTodo]  =  useState(item) const  {fetchTodos}  =  React.useContext(TodosContext) }` 

上面的状态变量是用于模态的,我们将很快创建它,并保存要更新的 todo 值。还导入了fetchTodos上下文值,用于在做出更改后更新todos

现在,让我们编写负责发送 PUT 请求的函数。在UpdateTodo组件主体中,在状态和上下文变量之后,添加以下内容:

`const  updateTodo  =  async  ()  =>  { await  fetch(`http://localhost:8000/todo/${id}`,  { method:  "PUT", headers:  {  "Content-Type":  "application/json"  }, body:  JSON.stringify({  item:  todo  }) }) onClose() await  fetchTodos() }` 

在上面的异步函数中,一个 PUT 请求被发送到后端,然后调用onClose()方法来关闭模态。然后调用fetchTodos()

接下来,渲染模态:

`return (
  <>
    <Button h="1.5rem" size="sm" onClick={onOpen}>Update Todo</Button>
    <Modal isOpen={isOpen} onClose={onClose}>
      <ModalOverlay/>
      <ModalContent>
        <ModalHeader>Update Todo</ModalHeader>
        <ModalCloseButton/>
        <ModalBody>
          <InputGroup size="md">
            <Input
              pr="4.5rem"
              type="text"
              placeholder="Add a todo item"
              aria-label="Add a todo item"
              value={todo}
              onChange={event => setTodo(event.target.value)}
            />
          </InputGroup>
        </ModalBody>

        <ModalFooter>
          <Button h="1.5rem" size="sm" onClick={updateTodo}>Update Todo</Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  </>
)` 

在上面的代码中,我们使用 Chakra UI 的模态组件创建了一个模态。在模态体中,我们监听对文本框的更改,并更新了状态对象todo。最后,当点击按钮“Update Todo”时,函数updateTodo()被调用,我们的 Todo 被更新。

完整的组件现在应该看起来像这样:

`function UpdateTodo({item, id}) {
  const {isOpen, onOpen, onClose} = useDisclosure()
  const [todo, setTodo] = useState(item)
  const {fetchTodos} = React.useContext(TodosContext)

  const updateTodo = async () => {
    await fetch(`http://localhost:8000/todo/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ item: todo })
    })
    onClose()
    await fetchTodos()
  }

  return (
    <>
      <Button h="1.5rem" size="sm" onClick={onOpen}>Update Todo</Button>
      <Modal isOpen={isOpen} onClose={onClose}>
        <ModalOverlay/>
        <ModalContent>
          <ModalHeader>Update Todo</ModalHeader>
          <ModalCloseButton/>
          <ModalBody>
            <InputGroup size="md">
              <Input
                pr="4.5rem"
                type="text"
                placeholder="Add a todo item"
                aria-label="Add a todo item"
                value={todo}
                onChange={e => setTodo(e.target.value)}
              />
            </InputGroup>
          </ModalBody>

          <ModalFooter>
            <Button h="1.5rem" size="sm" onClick={updateTodo}>Update Todo</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  )
}` 

在将组件添加到Todos组件之前,让我们添加一个用于渲染 todos 的助手组件来稍微清理一下:

`function TodoHelper({item, id, fetchTodos}) {
  return (
    <Box p={1} shadow="sm">
      <Flex justify="space-between">
        <Text mt={4} as="div">
          {item}
          <Flex align="end">
            <UpdateTodo item={item} id={id} fetchTodos={fetchTodos}/>
          </Flex>
        </Text>
      </Flex>
    </Box>
  )
}` 

在上面的组件中,我们呈现了传递给组件的 todo,并为其附加了一个 update 按钮。

替换Todos组件内return块中的代码:

`return (
  <TodosContext.Provider value={{todos, fetchTodos}}>
    <AddTodo />
    <Stack spacing={5}>
      {
        todos.map((todo) => (
          <TodoHelper item={todo.item} id={todo.id} fetchTodos={fetchTodos} />
        ))
      }
    </Stack>
  </TodosContext.Provider>
)` 

浏览器应该有一个刷新的外观:

Todo App

验证它是否工作:

Update todo

删除路线

后端

最后,添加删除路径:

`@app.delete("/todo/{id}", tags=["todos"])
async def delete_todo(id: int) -> dict:
    for todo in todos:
        if int(todo["id"]) == id:
            todos.remove(todo)
            return {
                "data": f"Todo with id {id} has been removed."
            }

    return {
        "data": f"Todo with id {id} not found."
    }` 

前端

让我们编写一个用于删除 todo 的组件,它将在TodoHelper组件中使用:

`function DeleteTodo({id}) {
  const {fetchTodos} = React.useContext(TodosContext)

  const deleteTodo = async () => {
    await fetch(`http://localhost:8000/todo/${id}`, {
      method: "DELETE",
      headers: { "Content-Type": "application/json" },
      body: { "id": id }
    })
    await fetchTodos()
  }

  return (
    <Button h="1.5rem" size="sm" onClick={deleteTodo}>Delete Todo</Button>
  )
}` 

这里,我们从调用全局状态对象的fetchTodos函数开始。接下来,我们创建了一个异步函数,它向服务器发送一个删除请求,然后通过再次调用fetchTodos来更新 todos 列表。最后,我们渲染了一个按钮,当点击时,触发deleteTodo()

接下来,将DeleteTodo组件添加到TodoHelper中:

`function TodoHelper({item, id, fetchTodos}) {
  return (
    <Box p={1} shadow="sm">
      <Flex justify="space-between">
        <Text mt={4} as="div">
          {item}
          <Flex align="end">
            <UpdateTodo item={item} id={id} fetchTodos={fetchTodos}/>
            <DeleteTodo id={id} fetchTodos={fetchTodos}/>  {/* new */}
          </Flex>
        </Text>
      </Flex>
    </Box>
  )
}` 

客户端应用程序应该自动更新:

Todo App

现在,测试删除按钮:

Remove todo

结论

本教程讲述了使用 FastAPI 和 React 设置 CRUD 应用程序的基础知识。

通过回顾本教程开头的目标来检查您的理解。您可以在 fastapi-react repo 中找到源代码。感谢阅读。

寻找一些挑战?

  1. 使用本指南将 React 应用程序部署到 Netlify,并在后端更新 CORS 对象,以便使用环境变量对其进行动态配置。
  2. 将后端 API 服务器部署到 Heroku,并替换前端的连接 URL。同样,为此使用一个环境变量。你可以从用 FastAPI 和 Heroku 部署和托管机器学习模型教程中学习将 FastAPI 部署到 Heroku 的基础知识。如果想了解更多的基础知识,可以去看看的测试驱动开发与 FastAPI 和 Docker 课程。
  3. 用 pytest 为后端设置单元和集成测试,用 React 测试库为前端设置测试。使用 FastAPI 和 Docker 的测试驱动开发课程涵盖了如何使用 pytest 测试 FastAPI,而使用 Flask、React 和 Docker 的认证详细介绍了如何使用 Jest 和 React 测试库测试 React 应用。

带有异步 SQLAlchemy、SQLModel 和 Alembic 的 FastAPI

原文:https://testdriven.io/blog/fastapi-sqlmodel/

本教程着眼于如何通过 SQLModel 和 FastAPI 异步地使用 SQLAlchemy。我们还将配置 Alembic 来处理数据库迁移。

本教程假设您有使用 Docker 处理 FastAPI 和 Postgres 的经验。需要帮助快速掌握 FastAPI、Postgres 和 Docker 吗?从以下资源开始:

  1. 使用 FastAPI 和 Pytest 开发和测试异步 API
  2. 用 FastAPI 和 Docker 进行测试驱动开发

项目设置

从从fastapi-SQL model-alem BICrepo 克隆基础项目开始:

`$ git clone -b base https://github.com/testdrivenio/fastapi-sqlmodel-alembic
$ cd fastapi-sqlmodel-alembic` 

从项目根目录,创建映像并启动 Docker 容器:

`$ docker-compose up -d --build` 

构建完成后,导航到http://localhost:8004/ping。您应该看到:

在继续之前,快速浏览一下项目结构。

SQLModel

接下来,让我们添加 SQLModel ,这是一个用于通过 Python 对象从 Python 代码与 SQL 数据库进行交互的库。基于 Python 类型注释,它本质上是一个位于 pydanticSQLAlchemy 之上的包装器,使得两者都能轻松工作。

我们还需要心理医生

将两个依赖项添加到 project/requirements.txt :

`fastapi==0.68.1
psycopg2-binary==2.9.1
sqlmodel==0.0.4
uvicorn==0.15.0` 

在“项目/app”中新建两个文件, db.pymodels.py

project/app/models.py :

`from sqlmodel import SQLModel, Field

class SongBase(SQLModel):
    name: str
    artist: str

class Song(SongBase, table=True):
    id: int = Field(default=None, primary_key=True)

class SongCreate(SongBase):
    pass` 

这里,我们定义了三个模型:

  1. SongBase是其他人继承的基础模型。它有两个属性,nameartist,都是字符串。这是一个纯数据模型,因为它缺少table=True,这意味着它只能用作 pydantic 模型。
  2. 与此同时,Song向基本模型添加了一个id属性。这是一个表格模型,所以它是一个 pydantic 和 SQLAlchemy 模型。它代表一个数据库表。
  3. SongCreate是一个纯数据的 pydantic 模型,将用于创建新的歌曲实例。

项目/app/db.py :

`import os

from sqlmodel import create_engine, SQLModel, Session

DATABASE_URL = os.environ.get("DATABASE_URL")

engine = create_engine(DATABASE_URL, echo=True)

def init_db():
    SQLModel.metadata.create_all(engine)

def get_session():
    with Session(engine) as session:
        yield session` 

在此,我们:

  1. 使用 SQLModel 中的create_engine初始化新的 SQLAlchemy 引擎。SQLModel 的create_engine和 SQLAlchemy 的版本之间的主要区别在于,SQLModel 版本添加了类型注释(用于编辑器支持)并启用了SQLAlchemy“2.0”风格的引擎和连接。此外,我们传入了echo=True,这样我们可以在终端中看到生成的 SQL 查询。出于调试目的,在开发模式下启用这一点总是好的。
  2. 创建了 SQLAlchemy 会话

接下来,在 project/app/main.py 中,让我们使用 startup 事件在启动时创建表:

`from fastapi import FastAPI

from app.db import init_db
from app.models import Song

app = FastAPI()

@app.on_event("startup")
def on_startup():
    init_db()

@app.get("/ping")
async def pong():
    return {"ping": "pong!"}` 

值得注意的是from app.models import Song是必填项。没有它,就不会创建歌单。

要进行测试,请关闭旧容器和卷,重建映像,并启动新容器:

`$ docker-compose down -v
$ docker-compose up -d --build` 

通过docker-compose logs web打开容器日志。您应该看到:

`web_1  | CREATE TABLE song (
web_1  |    name VARCHAR NOT NULL,
web_1  |    artist VARCHAR NOT NULL,
web_1  |    id SERIAL,
web_1  |    PRIMARY KEY (id)
web_1  | )` 

打开 psql:

`$ docker-compose exec db psql --username=postgres --dbname=foo

psql (13.4 (Debian 13.4-1.pgdg100+1))
Type "help" for help.

foo=# \dt

        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+----------
 public | song | table | postgres
(1 row)

foo=# \q` 

有了这个表,让我们给 project/app/main.py 添加一些新的路由:

`from fastapi import Depends, FastAPI
from sqlalchemy import select
from sqlmodel import Session

from app.db import get_session, init_db
from app.models import Song, SongCreate

app = FastAPI()

@app.on_event("startup")
def on_startup():
    init_db()

@app.get("/ping")
async def pong():
    return {"ping": "pong!"}

@app.get("/songs", response_model=list[Song])
def get_songs(session: Session = Depends(get_session)):
    result = session.execute(select(Song))
    songs = result.scalars().all()
    return [Song(name=song.name, artist=song.artist, id=song.id) for song in songs]

@app.post("/songs")
def add_song(song: SongCreate, session: Session = Depends(get_session)):
    song = Song(name=song.name, artist=song.artist)
    session.add(song)
    session.commit()
    session.refresh(song)
    return song` 

添加歌曲:

`$ curl -d '{"name":"Midnight Fit", "artist":"Mogwai"}' -H "Content-Type: application/json" -X POST http://localhost:8004/songs

{
  "id": 1,
  "name": "Midnight Fit",
  "artist": "Mogwai"
}` 

在浏览器中,导航到http://localhost:8004/songs。您应该看到:

`{ "id":  1, "name":  "Midnight Fit", "artist":  "Mogwai" }` 

异步 SQLModel

接下来,让我们为 SQLModel 添加异步支持。

首先,把容器和体积拿下来:

更新 docker-compose.yml 中的数据库 URI,在+asyncpg中增加:

接下来,用 asyncpg 替换 Psycopg:

`asyncpg==0.24.0
fastapi==0.68.1
sqlmodel==0.0.4
uvicorn==0.15.0` 

更新 project/app/db.py :使用 SQLAlchemy 引擎和会话的异步风格:

`import os

from sqlmodel import SQLModel

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker

DATABASE_URL = os.environ.get("DATABASE_URL")

engine = create_async_engine(DATABASE_URL, echo=True, future=True)

async def init_db():
    async with engine.begin() as conn:
        # await conn.run_sync(SQLModel.metadata.drop_all)
        await conn.run_sync(SQLModel.metadata.create_all)

async def get_session() -> AsyncSession:
    async_session = sessionmaker(
        engine, class_=AsyncSession, expire_on_commit=False
    )
    async with async_session() as session:
        yield session` 

注意事项:

  1. 我们使用了 SQLAlchemy 构造——例如, create_async_engineasync session——因为在撰写本文时,SQLModel 还没有它们的包装器。
  2. 我们通过传入expire_on_commit=False来禁用提交时过期行为。
  3. metadata.create_all不异步执行,所以我们使用 run_sync 在异步函数中同步执行。

on_startup变成 project/app/main.py 中的异步函数:

`@app.on_event("startup")
async def on_startup():
    await init_db()` 

就是这样。重建图像并旋转容器:

`$ docker-compose up -d --build` 

确保已经创建了表。

最后,更新 project/app/main.py 中的路由处理程序以使用异步执行:

`from fastapi import Depends, FastAPI
from sqlalchemy.future import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db import get_session, init_db
from app.models import Song, SongCreate

app = FastAPI()

@app.on_event("startup")
async def on_startup():
    await init_db()

@app.get("/ping")
async def pong():
    return {"ping": "pong!"}

@app.get("/songs", response_model=list[Song])
async def get_songs(session: AsyncSession = Depends(get_session)):
    result = await session.execute(select(Song))
    songs = result.scalars().all()
    return [Song(name=song.name, artist=song.artist, id=song.id) for song in songs]

@app.post("/songs")
async def add_song(song: SongCreate, session: AsyncSession = Depends(get_session)):
    song = Song(name=song.name, artist=song.artist)
    session.add(song)
    await session.commit()
    await session.refresh(song)
    return song` 

添加一首新歌,并确保http://localhost:8004/songs按预期工作。

蒸馏器

最后,让我们添加 Alembic 来正确处理数据库模式变化。

将其添加到需求文件中:

`alembic==1.7.1
asyncpg==0.24.0
fastapi==0.68.1
sqlmodel==0.0.4
uvicorn==0.15.0` 

project/app/main.py 中移除启动事件,因为我们不再需要在启动时创建的表:

`@app.on_event("startup")
async def on_startup():
    await init_db()` 

同样,关闭现有的容器和卷:

向上旋转容器:

`$ docker-compose up -d --build` 

在构建新图像时,快速浏览一下使用带有 Alembic 的 Asyncio】。

一旦容器备份完毕,用异步模板初始化 Alembic:

`$ docker-compose exec web alembic init -t async migrations` 

在生成的“项目/迁移”文件夹中,将 SQLModel 导入到 script.py.mako 中,这是一个樱井真子模板文件:

`"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from alembic import op
import sqlalchemy as sa
import sqlmodel             # NEW
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}

def upgrade():
    ${upgrades if upgrades else "pass"}

def downgrade():
    ${downgrades if downgrades else "pass"}` 

现在,当生成新的迁移文件时,它将包含import sqlmodel

接下来,我们需要更新project/migrations/env . py的顶部,如下所示:

`import asyncio
from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlmodel import SQLModel                       # NEW

from alembic import context

from app.models import Song                         # NEW

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata             # UPDATED

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.

...` 

这里,我们导入了 SQLModel 和我们的歌曲模型。然后,我们将target_metadata设置为模型的元数据SQLModel.metadata。关于target_metadata论点的更多信息,请查看官方 Alembic 文档中的自动生成迁移

更新项目/alembic.ini 中的sqlalchemy.url:

`sqlalchemy.url  =  postgresql+asyncpg://postgres:postgres@db:5432/foo` 

要生成第一个迁移文件,请运行:

`$ docker-compose exec web alembic revision --autogenerate -m "init"` 

如果一切顺利,您应该会在“项目/迁移/版本”中看到一个新的迁移文件,如下所示:

`"""init

Revision ID: f9c634db477d
Revises:
Create Date: 2021-09-10 00:24:32.718895

"""
from alembic import op
import sqlalchemy as sa
import sqlmodel

# revision identifiers, used by Alembic.
revision = 'f9c634db477d'
down_revision = None
branch_labels = None
depends_on = None

def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('song',
    sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
    sa.Column('artist', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
    sa.Column('id', sa.Integer(), nullable=True),
    sa.PrimaryKeyConstraint('id')
    )
    op.create_index(op.f('ix_song_artist'), 'song', ['artist'], unique=False)
    op.create_index(op.f('ix_song_id'), 'song', ['id'], unique=False)
    op.create_index(op.f('ix_song_name'), 'song', ['name'], unique=False)
    # ### end Alembic commands ###

def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_index(op.f('ix_song_name'), table_name='song')
    op.drop_index(op.f('ix_song_id'), table_name='song')
    op.drop_index(op.f('ix_song_artist'), table_name='song')
    op.drop_table('song')
    # ### end Alembic commands ###` 

应用迁移:

`$ docker-compose exec web alembic upgrade head` 

确保您可以添加歌曲。

让我们快速测试一个模式变更。更新项目/app/models.py 中的SongBase模型:

`class SongBase(SQLModel):
    name: str
    artist: str
    year: Optional[int] = None` 

不要忘记重要的一点:

`from typing import Optional` 

创建新的迁移文件:

`$ docker-compose exec web alembic revision --autogenerate -m "add year"` 

从自动生成的迁移文件中更新upgradedowngrade函数,如下所示:

`def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.add_column('song', sa.Column('year', sa.Integer(), nullable=True))
    op.create_index(op.f('ix_song_year'), 'song', ['year'], unique=False)
    # ### end Alembic commands ###

def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_index(op.f('ix_song_year'), table_name='song')
    op.drop_column('song', 'year')
    # ### end Alembic commands ###` 

应用迁移:

`$ docker-compose exec web alembic upgrade head` 

更新路线处理程序:

`@app.get("/songs", response_model=list[Song])
async def get_songs(session: AsyncSession = Depends(get_session)):
    result = await session.execute(select(Song))
    songs = result.scalars().all()
    return [Song(name=song.name, artist=song.artist, year=song.year, id=song.id) for song in songs]

@app.post("/songs")
async def add_song(song: SongCreate, session: AsyncSession = Depends(get_session)):
    song = Song(name=song.name, artist=song.artist, year=song.year)
    session.add(song)
    await session.commit()
    await session.refresh(song)
    return song` 

测试:

`$ curl -d '{"name":"Midnight Fit", "artist":"Mogwai", "year":"2021"}' -H "Content-Type: application/json" -X POST http://localhost:8004/songs` 

结论

在本教程中,我们介绍了如何配置 SQLAlchemy、SQLModel 和 Alembic 来异步使用 FastAPI。

如果你正在寻找更多的挑战,请查看我们所有的 FastAPI 教程课程

您可以在fastapi-SQL model-alem BICrepo 中找到源代码。干杯!

使用 FastAPI 和 Streamlit 为机器学习模型提供服务

原文:https://testdriven.io/blog/fastapi-streamlit/

机器学习是目前的热门话题。随着科技公司朝着人工智能和机器学习的方向发展,以尽早兑现,该领域已经变得非常大。这些公司中的许多都创建了自己的机器学习解决方案,并使用基于订阅的模式将其出售给其他人。

由于大多数机器学习模型都是用 Python 开发的,因此为它们提供服务的 web 框架通常也是基于 Python 的。很长一段时间,Flask 这个微框架就是 goto 框架。但这种情况正在改变。一个旨在弥补 Flask 几乎所有不足的新框架正变得越来越流行。它叫做 FastAPI

FastAPI 比 Flask 快,因为它将异步函数处理程序带到了表中:

Web Framework Benchmarks

来源: TechEmpower Web 框架基准

从上图可以看出,FastAPI 几乎比 Flask 快 3 倍。

第三个位置由小明星担任,FastAPI 就是建立在这个位置上的。

FastAPI 还支持通过 pydantic自动 API 文档进行数据验证。

查看官方文档中的功能指南,了解更多信息。我们也鼓励大家回顾一下的替代方案、灵感和比较,其中详细介绍了 FastAPI 与其他 web 框架和技术的比较。

与此同时,Streamlit 是一个应用程序框架,使数据科学家和机器学习工程师可以轻松创建与机器学习模型交互的强大用户界面。

虽然 Streamlit 可以用于生产,但它最适合快速原型制作。通过使用 FastAPI 提供模型,在原型获得批准后,您可以使用 Dash 或 React 快速转移到生产就绪的 UI。

这样,我们将基于实时风格转换的感知损失和超分辨率论文以及贾斯廷·约翰逊预训练模型构建一个风格转换应用程序。我们将使用 FastAPI 作为后端来服务我们的预测,Streamlit 用于用户界面,而 OpenCV 用于进行实际的预测。Docker 也将被使用。

OpenCV 的深度神经网络(DNN)模块的一个强大功能是,它可以从 Torch、TensorFlow 和 Caffe 加载训练好的模型,有效地节省了我们安装这些依赖项的麻烦。

目标

本教程结束时,您将能够:

  1. 用 Python 和 FastAPI 开发异步 API
  2. 用 FastAPI 提供机器学习模型
  3. 使用 Streamlit 开发用户界面
  4. 用 Docker 容器化 FastAPI 和简化 it
  5. 利用 asyncio 在请求/响应流之外的后台执行代码

项目设置

创建名为“style-transfer”的项目文件夹:

`$ mkdir style-transfer
$ cd style-transfer` 

然后,用“style-transfer”新建两个文件夹:

`$ mkdir frontend
$ mkdir backend` 

添加 init。py 文件到每个文件夹。

FastAPI Backend

向“后端”添加一个名为 main.py 的新文件:

`# backend/main.py

import uuid

import cv2
import uvicorn
from fastapi import File
from fastapi import FastAPI
from fastapi import UploadFile
import numpy as np
from PIL import Image

import config
import inference

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Welcome from the API"}

@app.post("/{style}")
def get_image(style: str, file: UploadFile = File(...)):
    image = np.array(Image.open(file.file))
    model = config.STYLES[style]
    output, resized = inference.inference(model, image)
    name = f"/storage/{str(uuid.uuid4())}.jpg"
    cv2.imwrite(name, output)
    return {"name": name}

if __name__ == "__main__":
    uvicorn.run("main:app", host="0.0.0.0", port=8080)` 

这是我们的服务器。FastAPI 创建了两个端点,一个是虚拟的("/"),另一个是为我们的预测服务的("/{style}")。服务端点接受一个名称作为 URL 参数。我们使用九个不同的训练模型来执行风格转换,因此 path 参数会告诉我们选择哪个模型。该图像通过 POST 请求作为文件被接受,并发送给inference函数。一旦推理完成,文件就存储在本地文件系统中,路径作为响应发送。

接下来,将以下配置添加到名为 backend/config.py 的新文件中:

`# backend/config.py

MODEL_PATH = "./models/"

STYLES = {
    "candy": "candy",
    "composition 6": "composition_vii",
    "feathers": "feathers",
    "la_muse": "la_muse",
    "mosaic": "mosaic",
    "starry night": "starry_night",
    "the scream": "the_scream",
    "the wave": "the_wave",
    "udnie": "udnie",
}` 

引入时,风格转移是一个游戏改变者。唯一的缺点是,必须对图像进行训练以获得一种风格。这意味着,要得到一个有风格的图像,你需要在得到一个更好的结果之前多次运行原始图像。2016 年,实时风格传输和超分辨率的感知损失论文介绍了快速风格传输,这意味着你可以在一次通过中对任何图像进行风格化。我们将对作者提供的经过训练的模型使用相同的技术。

现在,我们需要下载模型。向名为 download_models.sh 的项目根目录添加一个脚本:

`BASE_URL="https://cs.stanford.edu/people/jcjohns/fast-neural-style/models/"

mkdir -p backend/models/
cd backend/models/
curl -O "$BASE_URL/instance_norm/candy.t7"
curl -O "$BASE_URL/instance_norm/la_muse.t7"
curl -O "$BASE_URL/instance_norm/mosaic.t7"
curl -O "$BASE_URL/instance_norm/feathers.t7"
curl -O "$BASE_URL/instance_norm/the_scream.t7"
curl -O "$BASE_URL/instance_norm/udnie.t7"
curl -O "$BASE_URL/eccv16/the_wave.t7"
curl -O "$BASE_URL/eccv16/starry_night.t7"
curl -O "$BASE_URL/eccv16/la_muse.t7"
curl -O "$BASE_URL/eccv16/composition_vii.t7"` 

下载:

inference函数添加到 backend/inference.py :

`# backend/inference.py

import config
import cv2

def inference(model, image):
    model_name = f"{config.MODEL_PATH}{model}.t7"
    model = cv2.dnn.readNetFromTorch(model_name)

    height, width = int(image.shape[0]), int(image.shape[1])
    new_width = int((640 / height) * width)
    resized_image = cv2.resize(image, (new_width, 640), interpolation=cv2.INTER_AREA)

    # Create our blob from the image
    # Then perform a forward pass run of the network
    # The Mean values for the ImageNet training set are R=103.93, G=116.77, B=123.68

    inp_blob = cv2.dnn.blobFromImage(
        resized_image,
        1.0,
        (new_width, 640),
        (103.93, 116.77, 123.68),
        swapRB=False,
        crop=False,
    )

    model.setInput(inp_blob)
    output = model.forward()

    # Reshape the output Tensor,
    # add back the mean substruction,
    # re-order the channels
    output = output.reshape(3, output.shape[2], output.shape[3])
    output[0] += 103.93
    output[1] += 116.77
    output[2] += 123.68

    output = output.transpose(1, 2, 0)
    return output, resized_image` 

这里,我们加载了 Torch 模型,执行了大小调整,并将其转换为所需的 blob 格式。然后,我们将预处理后的图像传递到网络/模型中,并获得输出。后处理的图像和调整大小的图像作为输出返回。

最后,将依赖项添加到需求文件中:

`# backend/requirements.txt

fastapi
numpy
opencv-python
pillow
python-multipart
uvicorn` 

后端到此为止。让我们配置 Docker,然后进行测试。

Docker 设置

首先,将一个 Dockerfile 添加到“后端”文件夹:

`# backend/Dockerfile

FROM  python:3.10.1-slim

WORKDIR  /app

RUN  apt-get update
RUN  apt-get install \
    'ffmpeg'\
    'libsm6'\
    'libxext6'  -y

COPY  requirements.txt .
RUN  pip install -r requirements.txt

COPY  . .

EXPOSE  8080

CMD  ["python",  "main.py"]` 

OpenCV 需要 ffmpeg ',' libsm6 '和' libxext6 '。

从终端的“后端”文件夹中,构建映像:

`$ docker build -t backend .` 

运行容器:

`$ docker run -p 8080:8080 backend

INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)` 

在浏览器中,导航至 http://localhost:8080/ 。您应该看到:

`{ "message":  "Welcome from the API" }` 

完成后杀死容器。

细流前端

对于 UI,添加一个 main.py 文件到“前端”文件夹:

`# frontend/main.py

import requests
import streamlit as st
from PIL import Image

STYLES = {
    "candy": "candy",
    "composition 6": "composition_vii",
    "feathers": "feathers",
    "la_muse": "la_muse",
    "mosaic": "mosaic",
    "starry night": "starry_night",
    "the scream": "the_scream",
    "the wave": "the_wave",
    "udnie": "udnie",
}

# https://discuss.streamlit.io/t/version-0-64-0-deprecation-warning-for-st-file-uploader-decoding/4465
st.set_option("deprecation.showfileUploaderEncoding", False)

# defines an h1 header
st.title("Style transfer web app")

# displays a file uploader widget
image = st.file_uploader("Choose an image")

# displays the select widget for the styles
style = st.selectbox("Choose the style", [i for i in STYLES.keys()])

# displays a button
if st.button("Style Transfer"):
    if image is not None and style is not None:
        files = {"file": image.getvalue()}
        res = requests.post(f"http://backend:8080/{style}", files=files)
        img_path = res.json()
        image = Image.open(img_path.get("name"))
        st.image(image, width=500)` 

注意上面的代码注释。简而言之,我们创建了一个上传图像小部件和一个显示来自STYLES字典的每种样式的选择下拉列表。我们还添加了一个按钮,当按下该按钮时,会将图像作为 POST 请求负载发送到后端的http://backend:8080/{style}。在从后端接收到响应中的图像路径时,图像被打开并显示。

参考 Streamlit 的入门指南和 API 参考以获得显示文本和数据以及添加与小部件的基本交互的帮助。

Streamlit 依赖关系添加到 requirements.txt 文件中:

`# frontend/requirements.txt

streamlit==1.2.0` 

复合坞站

接下来,让我们对接前端,并用 Docker Compose 将两个容器连接在一起。

前端/Dockerfile :

`# frontend/Dockerfile

FROM  python:3.10.1-slim

WORKDIR  /app

COPY  requirements.txt .
RUN  pip install -r requirements.txt

COPY  . .

EXPOSE  8501

CMD  ["streamlit",  "run",  "main.py"]` 

码头-化合物. yml :

`version:  '3' services: frontend: build:  frontend ports: -  8501:8501 depends_on: -  backend volumes: -  ./storage:/storage backend: build:  backend ports: -  8080:8080 volumes: -  ./storage:/storage` 

这里最重要的是,我们将主机的存储映射到每个容器的存储。这对于共享路径很重要,对于在容器旋转时持久化数据也很重要。

因此,后端和前端都可以从同一个共享卷访问映像:

`# backend
name = f"/storage/{str(uuid.uuid4())}.jpg"
cv2.imwrite(name, output)
return {"name": name}

# frontend
img_path = res.json()
image = Image.open(img_path.get("name"))` 

要进行测试,从项目根目录开始,构建映像并启动两个容器:

`$ docker-compose up -d --build` 

导航到 http://localhost:8501 :

Demo App

异步模型服务

既然您已经看到了如何使用 FastAPI、Streamlit 和 OpenCV 来执行样式转换,那么让我们做一个小实验。

FastAPI 最强大的特性之一是它支持异步函数。因此,让我们利用一个异步函数将输入图像转换成多种样式。我们将同步处理第一种样式,然后在后台处理其余模型时发回响应。

backend/main.py 添加以下函数:

`# backend/main.py

async def generate_remaining_models(models, image, name: str):
    executor = ProcessPoolExecutor()
    event_loop = asyncio.get_event_loop()
    await event_loop.run_in_executor(
        executor, partial(process_image, models, image, name)
    )

def process_image(models, image, name: str):
    for model in models:
        output, resized = inference.inference(models[model], image)
        name = name.split(".")[0]
        name = f"{name.split('_')[0]}_{models[model]}.jpg"
        cv2.imwrite(name, output)` 

generate_remaining_models函数使用 asyncio 生成其余的每种风格。

查看用并发性、并行性和 asyncio 加速 Python 的速度一文,了解关于 asyncio 的更多信息。

添加以下导入内容:

`import asyncio

from concurrent.futures import ProcessPoolExecutor

from functools import partial` 

更新get_image函数,以便它在发送回响应之前创建异步任务:

`# backend/main.py

@app.post("/{style}")
async def get_image(style: str, file: UploadFile = File(...)):
    image = np.array(Image.open(file.file))
    model = config.STYLES[style]
    start = time.time()
    output, resized = inference.inference(model, image)
    name = f"/storage/{str(uuid.uuid4())}.jpg"
    cv2.imwrite(name, output)
    models = config.STYLES.copy()
    del models[style]
    asyncio.create_task(generate_remaining_models(models, image, name))
    return {"name": name, "time": time.time() - start}` 

一旦做出第一个预测,我们将从原始样式的副本中删除该样式。然后将剩余的样式传递给generate_remaining_models

添加导入:

接下来,更新 frontend/main.py 中下面的if语句块:

`# frontend/main.py

if st.button("Style Transfer"):
    if image is not None and style is not None:
        files = {"file": image.getvalue()}
        res = requests.post(f"http://backend:8080/{style}", files=files)
        img_path = res.json()
        image = Image.open(img_path.get("name"))
        st.image(image)

        displayed_styles = [style]
        displayed = 1
        total = len(STYLES)

        st.write("Generating other models...")

        while displayed < total:
            for style in STYLES:
                if style not in displayed_styles:
                    try:
                        path = f"{img_path.get('name').split('.')[0]}_{STYLES[style]}.jpg"
                        image = Image.open(path)
                        st.image(image, width=500)
                        time.sleep(1)
                        displayed += 1
                        displayed_styles.append(style)
                    except:
                        pass` 

将导入添加到顶部:

因此,在显示了第一个样式之后,我们继续检查其余的样式,显示每一个样式,直到页面上显示了所有的九个样式。

更新容器并测试:

`$ docker-compose up -d --build` 

现在,剩下的样式将异步显示,不会阻塞初始响应。

multiple style transfer

结论

FastAPI 是 Flask 的一个现代异步替代方案。它有很多 Flask 没有的特性,而且比 Flask 快,因为它利用了 Starlette 并支持异步函数处理程序。FastAPI 有很多额外的特性,比如数据验证、自动化 API 文档、后台任务以及一个强大的依赖注入系统。此外,由于您最有可能利用 Python 类型提示(因此您可以利用数据验证),由于编辑器自动完成和自动错误检查,您将能够更快地进行开发。

你可以在 GitHub 上的 style-transfer repo 中找到最终代码。

FastAPI 教程

原文:https://testdriven.io/blog/topics/fastapi/

描述

FastAPI 是一个现代的、高性能的、内置电池的 Python web 框架,非常适合构建 RESTful APIs。它可以处理同步和异步请求,并内置了对数据验证、JSON 序列化、身份验证和授权以及 OpenAPI 的支持。

亮点:

  1. 受 Flask 的启发,它有一种轻量级微框架的感觉,支持类似 Flask 的 route decorators。
  2. 它利用 Python 类型提示进行参数声明,从而支持数据验证(通过 Pydantic)和 OpenAPI/Swagger 文档。
  3. 它构建在 Starlette 之上,支持异步 API 的开发。
  4. 它很快。由于 async 比传统的同步线程模型更有效,所以在性能方面它可以与 Node 和 Go 竞争。

TestDriven.io 上的教程和文章专注于开发和测试生产就绪的 RESTful APIs,将 FastAPI 与 Vue 和 React 集成,以及提供机器学习模型。

用 FastAPI 和 GraphQL 搭建一个 CRUD app。

常用的 web 身份验证方法。

配置 FastAPI 与 Postgres、Uvicorn、Traefik 和 Let's Encrypt 一起在 Docker 上运行。

用 JSON Web 令牌保护 FastAPI 应用程序。

将 Masonite ORM 与 FastAPI 一起使用。

在 Python 应用程序中启用多区域支持。

如何用 Vue 和 FastAPI 设置一个基本的 CRUD 应用程序的分步演练。

使用测试驱动开发(TDD)使用 FastAPI、Postgres、pytest 和 Docker 开发和测试异步 API。

如何通过假设和图式进行基于属性的测试来测试 FastAPI

用 FastAPI,MongoDB,Beanie 开发一个异步 API。

用 FastAPI 构建 CRUD app,React。

有兴趣从 Flask 转到 FastAPI 吗?本文比较和对比了 Flask 和 FastAPI 中的常见模式。

将 FastAPI 应用程序部署到 AWS Elastic Beanstalk。

用 FastAPI 和 MongoDB 开发一个异步 API。

用 FastAPI 和 Streamlit 提供一个风格转移机器学习模型。

将 SQLAlchemy、SQLModel 和 Alembic 配置为异步使用 FastAPI。

开发一个生产就绪的 RESTful API,用 FastAPI 提供一个机器学习模型。

这篇文章着眼于如何配置 Celery 来处理 FastAPI 应用程序中的长时间运行的任务。

通过 Docker 层缓存和构建工具包加快 CI 构建

原文:https://testdriven.io/blog/faster-ci-builds-with-docker-cache/

本文着眼于如何使用 Docker 层缓存和构建工具包来加速您在 CircleCIGitLab CIGitHub Actions 上基于 Docker 的构建。

Docker 层缓存

Docker 会在构建映像时缓存每一层,只有在自上次构建以来该层或其上的层发生了变化时,才会重新构建每一层。因此,您可以使用 Docker 缓存显著加快构建速度。让我们看一个简单的例子。

Dockerfile :

`# pull base image
FROM  python:3.9.7-slim

# install netcat
RUN  apt-get update && \
    apt-get -y install netcat && \
    apt-get clean

# set working directory
WORKDIR  /usr/src/app

# install requirements
COPY  ./requirements.txt .
RUN  pip install -r requirements.txt

# add app
COPY  . .

# run server
CMD  gunicorn -b 0.0.0.0:5000 manage:app` 

您可以在 GitHub 上的 docker-ci-cache repo 中找到该项目的完整源代码。

第一次 Docker 构建可能需要几分钟才能完成,这取决于您的连接速度。后续构建应该只需要几秒钟,因为层在第一次构建后会被缓存:

`[+] Building 0.4s (12/12) FINISHED
 => [internal] load build definition from Dockerfile                                                                     0.0s
 => => transferring dockerfile: 37B                                                                                      0.0s
 => [internal] load .dockerignore                                                                                        0.0s
 => => transferring context: 35B                                                                                         0.0s
 => [internal] load metadata for docker.io/library/python:3.9.7-slim                                                     0.3s
 => [internal] load build context                                                                                        0.0s
 => => transferring context: 555B                                                                                        0.0s
 => [1/7] FROM docker.io/library/python:[[email protected]](/cdn-cgi/l/email-protection):bdefda2b80c5b4d993ef83d2445d81b2b894bf627b62bd7b0f01244de2b6a  0.0s
 => CACHED [2/7] RUN apt-get update &&     apt-get -y install netcat &&     apt-get clean                                0.0s
 => CACHED [3/7] WORKDIR /usr/src/app                                                                                    0.0s
 => CACHED [4/7] COPY ./requirements.txt .                                                                               0.0s
 => CACHED [5/7] RUN pip install -r requirements.txt                                                                     0.0s
 => CACHED [6/7] COPY project .                                                                                          0.0s
 => CACHED [7/7] COPY manage.py .                                                                                        0.0s
 => exporting to image                                                                                                   0.0s
 => => exporting layers                                                                                                  0.0s
 => => writing image sha256:2b8b7c5a6d1b77d5bcd689ab265b0281ad531bd2e34729cff82285f5abdcb59f                             0.0s
 => => naming to docker.io/library/cache                                                                                 0.0s` 

即使您对源代码进行了更改,也应该只需要几秒钟就可以完成构建,因为不需要下载依赖项。只有最后两层需要重建,换句话说:

 `=> [6/7] COPY project .
 => [7/7] COPY manage.py .` 

要避免缓存失效:

  1. 用不太可能改变的命令开始你的 docker 文件
  2. 尽可能晚地放置更有可能改变的命令(如COPY . .)
  3. 仅添加必要的文件(使用)。dockerignore 文件)

要获得更多的技巧和最佳实践,请查看针对 Python 开发人员的 Docker 最佳实践文章。

构建工具包

如果你使用的是 Docker 版本> = 19.03 ,你可以使用 BuildKit,一个容器映像构建器,来代替 Docker 引擎中传统的映像构建器后端。如果没有 BuildKit,如果本地图像注册表中不存在图像,您需要在构建之前提取远程图像,以便利用 Docker 层缓存。

示例:

`$ docker pull mjhea0/docker-ci-cache:latest

$ docker docker build --tag mjhea0/docker-ci-cache:latest .` 

使用 BuildKit,您不需要在构建之前提取远程映像,因为它会在映像注册表中缓存每个构建层。然后,当您构建映像时,在构建过程中会根据需要下载每个层。

要启用 BuildKit,请将DOCKER_BUILDKIT环境变量设置为1。然后,打开内嵌层缓存,使用BUILDKIT_INLINE_CACHE构建参数。

示例:

`export DOCKER_BUILDKIT=1

# Build and cache image
$ docker build --tag mjhea0/docker-ci-cache:latest --build-arg BUILDKIT_INLINE_CACHE=1 .

# Build image from remote cache
$ docker build --cache-from mjhea0/docker-ci-cache:latest .` 

CI 环境

因为 CI 平台为每个构建提供了一个全新的环境,所以您需要使用一个远程图像注册表作为 BuildKit 的层缓存的缓存源。

步骤:

  1. 登录图像注册中心(如码头中心弹性集装箱注册中心 (ECR)、以及码头等等)。

    值得注意的是,GitLab 和 GitHub 都有自己的注册表,可以在平台上的库(公共和私有)中使用,分别是 GitLab 容器注册表GitHub 包

  2. 使用 Docker build 的--cache-from 选项将现有图像用作缓存源。

  3. 如果构建成功,将新的映像推送到注册表中。

让我们看看如何在 CircleCI、GitLab CI 和 GitHub Actions 上做到这一点,使用单级和多级 Docker 构建,使用和不使用 Docker Compose。每个示例都使用 Docker Hub 作为映像注册中心,并将REGISTRY_USERREGISTRY_PASS设置为 CI 构建中的变量,以便向注册中心推送数据或从中提取数据。

确保在构建环境中将REGISTRY_USERREGISTRY_PASS设置为环境变量:

  1. 循环
  2. GitLab CI
  3. GitHub 动作

单阶段构建

圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈:

`# _config-examples/single-stage/circle.yml version:  2.1 jobs: build: machine: image:  ubuntu-2004:202010-01 environment: CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 steps: -  checkout -  run: name:  Log in to docker hub command:  docker login -u $REGISTRY_USER -p $REGISTRY_PASS -  run: name:  Build from dockerfile command:  | docker build \ --cache-from $CACHE_IMAGE:latest \ --tag $CACHE_IMAGE:latest \ --build-arg BUILDKIT_INLINE_CACHE=1 \ "." -  run: name:  Push to docker hub command:  docker push $CACHE_IMAGE:latest` 

GitLab CI:

`# _config-examples/single-stage/.gitlab-ci.yml image:  docker:stable services: -  docker:dind variables: DOCKER_DRIVER:  overlay2 CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 stages: -  build docker-build: stage:  build before_script: -  docker login -u $REGISTRY_USER -p $REGISTRY_PASS script: -  docker build --cache-from $CACHE_IMAGE:latest --tag $CACHE_IMAGE:latest --file ./Dockerfile --build-arg BUILDKIT_INLINE_CACHE=1 "." after_script: -  docker push $CACHE_IMAGE:latest` 

GitHub 操作:

`# _config-examples/single-stage/github.yml name:  Docker Build on:  [push] env: CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 jobs: build: name:  Build Docker Image runs-on:  ubuntu-latest steps: -  name:  Checkout master uses:  actions/[[email protected]](/cdn-cgi/l/email-protection) -  name:  Log in to docker hub run:  docker login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASS }} -  name:  Build from dockerfile run:  | docker build \ --cache-from $CACHE_IMAGE:latest \ --tag $CACHE_IMAGE:latest \ --build-arg BUILDKIT_INLINE_CACHE=1 \ "." -  name:  Push to docker hub run:  docker push $CACHE_IMAGE:latest` 

构成

如果您正在使用 Docker Compose,您可以将cache_from 选项添加到 Compose 文件,当您运行docker-compose build时,它会映射回docker build --cache-from <image>命令。

示例:

`version:  '3.8' services: web: build: context:  . cache_from: -  mjhea0/docker-ci-cache:latest image:  mjhea0/docker-ci-cache:latest` 

为了利用 BuildKit,请确保您使用的是 Docker Compose >= 1.25.0 版本。要启用 BuildKit,请将DOCKER_BUILDKITCOMPOSE_DOCKER_CLI_BUILD环境变量设置为1。然后,再次打开内嵌层缓存,使用BUILDKIT_INLINE_CACHE build 参数。

圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈:

`# _config-examples/single-stage/compose/circle.yml version:  2.1 jobs: build: machine: image:  ubuntu-2004:202010-01 environment: CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 COMPOSE_DOCKER_CLI_BUILD:  1 steps: -  checkout -  run: name:  Log in to docker hub command:  docker login -u $REGISTRY_USER -p $REGISTRY_PASS -  run: name:  Build images command:  docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 -  run: name:  Push to docker hub command:  docker push $CACHE_IMAGE:latest` 

GitLab CI:

`# _config-examples/single-stage/compose/.gitlab-ci.yml image:  docker/compose:latest services: -  docker:dind variables: DOCKER_DRIVER:  overlay2 CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 COMPOSE_DOCKER_CLI_BUILD:  1 stages: -  build docker-build: stage:  build before_script: -  docker login -u $REGISTRY_USER -p $REGISTRY_PASS script: -  docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 after_script: -  docker push $CACHE_IMAGE:latest` 

GitHub 操作:

`# _config-examples/single-stage/compose/github.yml name:  Docker Build on:  [push] env: CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 COMPOSE_DOCKER_CLI_BUILD:  1 jobs: build: name:  Build Docker Image runs-on:  ubuntu-latest steps: -  name:  Checkout master uses:  actions/[[email protected]](/cdn-cgi/l/email-protection) -  name:  Log in to docker hub run:  docker login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASS }} -  name:  Build Docker images run:  docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1 -  name:  Push to docker hub run:  docker push $CACHE_IMAGE:latest` 

多阶段构建

使用多阶段构建模式,您必须对每个中间阶段应用相同的工作流(构建,然后推送),因为这些映像在最终映像创建之前就被丢弃了。--target 选项可用于单独构建多阶段构建的每个阶段。

Dockerfile.multi :

`# base
FROM  python:3.9.7  as  base
COPY  ./requirements.txt /
RUN  pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt

# stage
FROM  python:3.9.7-slim
RUN  apt-get update && \
    apt-get -y install netcat && \
    apt-get clean
WORKDIR  /usr/src/app
COPY  --from=base /wheels /wheels
COPY  --from=base requirements.txt .
RUN  pip install --no-cache /wheels/*
COPY  . /usr/src/app
CMD  gunicorn -b 0.0.0.0:5000 manage:app` 

圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈:

`# _config-examples/multi-stage/circle.yml version:  2.1 jobs: build: machine: image:  ubuntu-2004:202010-01 environment: CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 steps: -  checkout -  run: name:  Log in to docker hub command:  docker login -u $REGISTRY_USER -p $REGISTRY_PASS -  run: name:  Build base from dockerfile command:  | docker build \ --target base \ --cache-from $CACHE_IMAGE:base \ --tag $CACHE_IMAGE:base \ --file ./Dockerfile.multi \ --build-arg BUILDKIT_INLINE_CACHE=1 \ "." -  run: name:  Build stage from dockerfile command:  | docker build \ --cache-from $CACHE_IMAGE:base \ --cache-from $CACHE_IMAGE:stage \ --tag $CACHE_IMAGE:stage \ --file ./Dockerfile.multi \ --build-arg BUILDKIT_INLINE_CACHE=1 \ "." -  run: name:  Push base image to docker hub command:  docker push $CACHE_IMAGE:base -  run: name:  Push stage image to docker hub command:  docker push $CACHE_IMAGE:stage` 

GitLab CI:

`# _config-examples/multi-stage/.gitlab-ci.yml image:  docker:stable services: -  docker:dind variables: DOCKER_DRIVER:  overlay2 CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 stages: -  build docker-build: stage:  build before_script: -  docker login -u $REGISTRY_USER -p $REGISTRY_PASS script: -  docker build --target base --cache-from $CACHE_IMAGE:base --tag $CACHE_IMAGE:base --file ./Dockerfile.multi --build-arg BUILDKIT_INLINE_CACHE=1 "." -  docker build --cache-from $CACHE_IMAGE:base --cache-from $CACHE_IMAGE:stage --tag $CACHE_IMAGE:stage --file ./Dockerfile.multi --build-arg BUILDKIT_INLINE_CACHE=1 "." after_script: -  docker push $CACHE_IMAGE:stage` 

GitHub 操作:

`# _config-examples/multi-stage/github.yml name:  Docker Build on:  [push] env: CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 jobs: build: name:  Build Docker Image runs-on:  ubuntu-latest steps: -  name:  Checkout master uses:  actions/[[email protected]](/cdn-cgi/l/email-protection) -  name:  Log in to docker hub run:  docker login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASS }} -  name:  Build base from dockerfile run:  | docker build \ --target base \ --cache-from $CACHE_IMAGE:base \ --tag $CACHE_IMAGE:base \ --file ./Dockerfile.multi \ --build-arg BUILDKIT_INLINE_CACHE=1 \ "." -  name:  Build stage from dockerfile run:  | docker build \ --cache-from $CACHE_IMAGE:base \ --cache-from $CACHE_IMAGE:stage \ --tag $CACHE_IMAGE:stage \ --file ./Dockerfile.multi \ --build-arg BUILDKIT_INLINE_CACHE=1 \ "." -  name:  Push base image to docker hub run:  docker push $CACHE_IMAGE:base -  name:  Push stage image to docker hub run:  docker push $CACHE_IMAGE:stage` 

构成

示例合成文件:

`version:  '3.8' services: web: build: context:  . cache_from: -  mjhea0/docker-ci-cache:stage image:  mjhea0/docker-ci-cache:stage` 

圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈圈:

`# _config-examples/multi-stage/compose/circle.yml version:  2.1 jobs: build: machine: image:  ubuntu-2004:202010-01 environment: CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 COMPOSE_DOCKER_CLI_BUILD:  1 steps: -  checkout -  run: name:  Log in to docker hub command:  docker login -u $REGISTRY_USER -p $REGISTRY_PASS -  run: name:  Build base from dockerfile command:  | docker build \ --target base \ --cache-from $CACHE_IMAGE:base \ --tag $CACHE_IMAGE:base \ --file ./Dockerfile.multi \ --build-arg BUILDKIT_INLINE_CACHE=1 \ "." -  run: name:  Build Docker images command:  docker-compose -f docker-compose.multi.yml build --build-arg BUILDKIT_INLINE_CACHE=1 -  run: name:  Push base image to docker hub command:  docker push $CACHE_IMAGE:base -  run: name:  Push stage image to docker hub command:  docker push $CACHE_IMAGE:stage` 

GitLab CI:

`# _config-examples/multi-stage/compose/.gitlab-ci.yml image:  docker/compose:latest services: -  docker:dind variables: DOCKER_DRIVER:  overlay CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 COMPOSE_DOCKER_CLI_BUILD:  1 stages: -  build docker-build: stage:  build before_script: -  docker login -u $REGISTRY_USER -p $REGISTRY_PASS script: -  docker build --target base --cache-from $CACHE_IMAGE:base --tag $CACHE_IMAGE:base --file ./Dockerfile.multi --build-arg BUILDKIT_INLINE_CACHE=1 "." -  docker-compose -f docker-compose.multi.yml build --build-arg BUILDKIT_INLINE_CACHE=1 after_script: -  docker push $CACHE_IMAGE:base -  docker push $CACHE_IMAGE:stage` 

GitHub 操作:

`# _config-examples/multi-stage/compose/github.yml name:  Docker Build on:  [push] env: CACHE_IMAGE:  mjhea0/docker-ci-cache DOCKER_BUILDKIT:  1 COMPOSE_DOCKER_CLI_BUILD:  1 jobs: build: name:  Build Docker Image runs-on:  ubuntu-latest steps: -  name:  Checkout master uses:  actions/[[email protected]](/cdn-cgi/l/email-protection) -  name:  Log in to docker hub run:  docker login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASS }} -  name:  Build base from dockerfile run:  | docker build \ --target base \ --cache-from $CACHE_IMAGE:base \ --tag $CACHE_IMAGE:base \ --file ./Dockerfile.multi \ --build-arg BUILDKIT_INLINE_CACHE=1 \ "." -  name:  Build images run:  docker-compose -f docker-compose.multi.yml build --build-arg BUILDKIT_INLINE_CACHE=1 -  name:  Push base image to docker hub run:  docker push $CACHE_IMAGE:base -  name:  Push stage image to docker hub run:  docker push $CACHE_IMAGE:stage` 

结论

本文概述的缓存策略应该适用于单阶段构建和包含两个或三个阶段的多阶段构建。

添加到构建步骤的每个阶段都需要一个新的构建,并为每个父阶段添加--cache-from选项。因此,每个新阶段都会增加更多的混乱,使得 CI 文件越来越难以阅读。幸运的是,BuildKit 支持多阶段构建,Docker 层缓存使用单阶段构建。有关这种高级构建工具包模式的更多信息,请阅读以下文章:

  1. 高级 docker 文件:使用 BuildKit 和多阶段构建实现更快的构建和更小的映像
  2. Docker 用 BuildKit 和 buildx 在多主机上构建缓存共享
  3. 利用 Buildkit 的注册表缓存加速 CI/CD 中的多级 Docker 构建

最后,需要注意的是,虽然缓存可能会加快您的 CI 构建速度,但您应该不时地在没有缓存的情况下重建您的映像,以便下载最新的操作系统补丁和安全更新。关于这方面的更多信息,请查看这个线程

--

代码可以在 docker-ci-cache repo 中找到:

  1. 单级示例
  2. 多阶段示例

干杯!

烧瓶和芹菜的异步任务

原文:https://testdriven.io/blog/flask-and-celery/

如果长时间运行的流程是应用程序工作流的一部分,而不是阻塞响应,您应该在后台处理它,在正常的请求/响应流之外。

也许您的 web 应用程序要求用户在注册时提交一个缩略图(可能需要重新调整大小)并确认他们的电子邮件。如果您的应用程序处理图像并直接在请求处理程序中发送确认电子邮件,那么最终用户将不得不在页面加载或更新之前不必要地等待它们完成处理。相反,您会希望将这些进程传递给任务队列,让一个独立的工作进程来处理它,这样您就可以立即将响应发送回客户端。在处理过程中,最终用户可以在客户端做其他事情。您的应用程序也可以自由响应其他用户和客户的请求。

为了实现这一点,我们将带您完成设置和配置 Celery 和 Redis 的过程,以便在 Flask 应用程序中处理长时间运行的流程。我们还将使用 Docker 和 Docker Compose 将所有内容联系在一起。最后,我们将看看如何用单元测试和集成测试来测试 Celery 任务。

Redis 队列也是一个可行的解决方案。查看带有 Flask 和 Redis 队列的异步任务了解更多信息。

目标

本教程结束时,您将能够:

  1. 将芹菜集成到 Flask 应用程序中,并创建任务。
  2. 用容器装烧瓶,芹菜,和 Redis 与 Docker。
  3. 使用单独的工作进程在后台运行进程。
  4. 将芹菜日志保存到文件中。
  5. 设置 Flower 来监控和管理芹菜作业和工人。
  6. 用单元测试和集成测试来测试芹菜任务。

后台任务

同样,为了改善用户体验,长时间运行的流程应该在正常的 HTTP 请求/响应流程之外,在后台进程中运行。

示例:

  1. 运行机器学习模型
  2. 发送确认电子邮件
  3. 刮擦和爬行
  4. 分析数据
  5. 处理图像
  6. 生成报告

当你构建一个应用程序时,试着区分应该在请求/响应生命周期中运行的任务(比如 CRUD 操作)和应该在后台运行的任务。

工作流程

我们的目标是开发一个 Flask 应用程序,它与 Celery 一起处理正常请求/响应周期之外的长时间运行的流程。

  1. 最终用户通过向服务器端发送 POST 请求开始一项新任务。
  2. 在路由处理程序中,一个任务被添加到队列中,任务 ID 被发送回客户端。
  3. 使用 AJAX,当任务本身在后台运行时,客户机继续轮询服务器以检查任务的状态。

flask and celery queue user flow

项目设置

烧瓶-芹菜 repo 中克隆出基础项目,然后将 v1 标签签出到主分支:

`$ git clone https://github.com/testdrivenio/flask-celery --branch v1 --single-branch
$ cd flask-celery
$ git checkout v1 -b master` 

由于我们总共需要管理三个进程(Flask、Redis、Celery worker),我们将使用 Docker 来简化我们的工作流,方法是将它们连接起来,以便它们都可以通过一个命令从一个终端窗口运行。

从项目根目录,创建映像并启动 Docker 容器:

`$ docker-compose up -d --build` 

构建完成后,导航到 http://localhost:5004 :

flask project

确保测试也通过:

`$ docker-compose exec web python -m pytest

================================== test session starts ===================================
platform linux -- Python 3.10.2, pytest-7.0.1, pluggy-1.0.0
rootdir: /usr/src/app
collected 1 item

project/tests/test_tasks.py .                                                       [100%]

=================================== 1 passed in 0.34s ====================================` 

在继续之前,快速浏览一下项目结构:

`├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── docker-compose.yml
├── manage.py
├── project
│   ├── __init__.py
│   ├── client
│   │   ├── static
│   │   │   ├── main.css
│   │   │   └── main.js
│   │   └── templates
│   │       ├── _base.html
│   │       ├── footer.html
│   │       └── main
│   │           └── home.html
│   ├── server
│   │   ├── __init__.py
│   │   ├── config.py
│   │   └── main
│   │       ├── __init__.py
│   │       └── views.py
│   └── tests
│       ├── __init__.py
│       ├── conftest.py
│       └── test_tasks.py
└── requirements.txt` 

想学习如何构建这个项目吗?查看带有 Postgres、Gunicorn 和 Nginx 文章的dockering 烧瓶。

触发任务

project/client/templates/main/home . html中设置了一个onclick事件处理程序来监听按钮点击:

`<div class="btn-group" role="group" aria-label="Basic example">
  <button type="button" class="btn btn-primary" onclick="handleClick(1)">Short</button>
  <button type="button" class="btn btn-primary" onclick="handleClick(2)">Medium</button>
  <button type="button" class="btn btn-primary" onclick="handleClick(3)">Long</button>
</div>` 

onclick调用project/client/static/main . js中的handleClick,它向服务器发送一个 AJAX POST 请求,并带有适当的任务类型:123

`function  handleClick(type)  { fetch('/tasks',  { method:  'POST', headers:  { 'Content-Type':  'application/json' }, body:  JSON.stringify({  type:  type  }), }) .then(response  =>  response.json()) .then(data  =>  getStatus(data.task_id)); }` 

在服务器端,已经在project/server/main/views . py中配置了一个路由来处理请求:

`@main_blueprint.route("/tasks", methods=["POST"])
def run_task():
    content = request.json
    task_type = content["type"]
    return jsonify(task_type), 202` 

现在有趣的部分来了——给芹菜布线!

芹菜装置

首先将 Celery 和 Redis 添加到 requirements.txt 文件中:

`celery==5.2.3
Flask==2.0.3
Flask-WTF==1.0.0
pytest==7.0.1
redis==4.1.4` 

Celery 使用消息代理 - RabbitMQRedisAWS 简单队列服务(SQS) -来促进 Celery worker 和 web 应用程序之间的通信。消息被添加到代理中,然后由工作人员进行处理。一旦完成,结果被添加到后端。

Redis 将被用作代理和后端。将 Redis 和芹菜工人添加到 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 -  CELERY_BROKER_URL=redis://redis:6379/0 -  CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: -  redis worker: build:  . command:  celery --app project.server.tasks.celery worker --loglevel=info volumes: -  .:/usr/src/app environment: -  FLASK_DEBUG=1 -  APP_SETTINGS=project.server.config.DevelopmentConfig -  CELERY_BROKER_URL=redis://redis:6379/0 -  CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: -  web -  redis redis: image:  redis:6-alpine` 

请注意celery --app project.server.tasks.celery worker --loglevel=info:

  1. celery worker是用来开动芹菜的工人
  2. --app=project.server.tasks.celery运行芹菜应用程序(我们将很快对其进行定义)
  3. --loglevel=info记录级别设置为信息

接下来,在“项目/服务器”中创建一个名为 tasks.py 的新文件:

`import os
import time

from celery import Celery

celery = Celery(__name__)
celery.conf.broker_url = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379")
celery.conf.result_backend = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379")

@celery.task(name="create_task")
def create_task(task_type):
    time.sleep(int(task_type) * 10)
    return True` 

这里,我们创建了一个新的 Celery 实例,并使用任务装饰器,我们定义了一个名为create_task的新 Celery 任务函数。

请记住,任务本身将由芹菜工人执行。

触发任务

更新路由处理程序以启动任务,并使用任务 ID 进行响应:

`@main_blueprint.route("/tasks", methods=["POST"])
def run_task():
    content = request.json
    task_type = content["type"]
    task = create_task.delay(int(task_type))
    return jsonify({"task_id": task.id}), 202` 

不要忘记导入任务:

`from project.server.tasks import create_task` 

构建映像并旋转新容器:

`$ docker-compose up -d --build` 

要触发新任务,请运行:

`$ curl http://localhost:5004/tasks -H "Content-Type: application/json" --data '{"type": 0}'` 

您应该会看到类似这样的内容:

`{
  "task_id": "14049663-6257-4a1f-81e5-563c714e90af"
}` 

任务状态

回到客户端的handleClick功能:

`function  handleClick(type)  { fetch('/tasks',  { method:  'POST', headers:  { 'Content-Type':  'application/json' }, body:  JSON.stringify({  type:  type  }), }) .then(response  =>  response.json()) .then(data  =>  getStatus(data.task_id)); }` 

当响应从最初的 AJAX 请求返回时,我们继续每秒调用带有任务 ID 的getStatus():

`function  getStatus(taskID)  { fetch(`/tasks/${taskID}`,  { method:  'GET', headers:  { 'Content-Type':  'application/json' }, }) .then(response  =>  response.json()) .then(res  =>  { const  html  =  `
 <tr>
 <td>${taskID}</td>
 <td>${res.task_status}</td>
 <td>${res.task_result}</td>
 </tr>`; const  newRow  =  document.getElementById('tasks').insertRow(0); newRow.innerHTML  =  html; const  taskStatus  =  res.task_status; if  (taskStatus  ===  'SUCCESS'  ||  taskStatus  ===  'FAILURE')  return  false; setTimeout(function()  { getStatus(res.task_id); },  1000); }) .catch(err  =>  console.log(err)); }` 

如果响应成功,一个新行被添加到 DOM 上的表中。

更新get_status路线处理器以返回状态:

`@main_blueprint.route("/tasks/<task_id>", methods=["GET"])
def get_status(task_id):
    task_result = AsyncResult(task_id)
    result = {
        "task_id": task_id,
        "task_status": task_result.status,
        "task_result": task_result.result
    }
    return jsonify(result), 200` 

导入异步结果:

`from celery.result import AsyncResult` 

更新容器:

`$ docker-compose up -d --build` 

触发新任务:

`$ curl http://localhost:5004/tasks -H "Content-Type: application/json" --data '{"type": 1}'` 

然后,从响应中获取task_id并调用更新的端点来查看状态:

`$ curl http://localhost:5004/tasks/f3ae36f1-58b8-4c2b-bf5b-739c80e9d7ff

{
  "task_id": "455234e0-f0ea-4a39-bbe9-e3947e248503",
  "task_result": true,
  "task_status": "SUCCESS"
}` 

也在浏览器中测试一下:

flask, celery, docker

芹菜原木

更新 docker-compose.yml 中的worker服务,以便将芹菜日志转储到一个日志文件:

`worker: build:  . command:  celery --app project.server.tasks.celery worker --loglevel=info --logfile=project/logs/celery.log volumes: -  .:/usr/src/app environment: -  FLASK_DEBUG=1 -  APP_SETTINGS=project.server.config.DevelopmentConfig -  CELERY_BROKER_URL=redis://redis:6379/0 -  CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: -  web -  redis` 

向“项目”添加一个名为“日志”的新目录。然后,将名为 celery.log 的新文件添加到新创建的目录中。

更新:

`$ docker-compose up -d --build` 

由于我们设置了一个卷,您应该看到日志文件在本地被填满:

`[2022-02-16 21:01:09,961: INFO/MainProcess]  Connected  to  redis://redis:6379/0 [2022-02-16 21:01:09,965: INFO/MainProcess]  mingle:  searching  for  neighbors [2022-02-16 21:01:10,977: INFO/MainProcess]  mingle:  all  alone [2022-02-16 21:01:10,994: INFO/MainProcess]  celery@f9921f0e0b83  ready. [2022-02-16 21:01:23,349: INFO/MainProcess] Task  create_task[ceb6cffc-e426-4970-a5df-5a1fac4478cc]  received [2022-02-16 21:01:33,378: INFO/ForkPoolWorker-7] Task  create_task[ceb6cffc-e426-4970-a5df-5a1fac4478cc] succeeded  in  10.025073800003156s:  True` 

花卉仪表板

Flower 是一个轻量级的、实时的、基于网络的芹菜监控工具。您可以监控当前正在运行的任务,增加或减少工作池,查看图表和一些统计数据,等等。

添加到 requirements.txt :

`celery==5.2.3
Flask==2.0.3
Flask-WTF==1.0.0
flower==1.0.0
pytest==7.0.1
redis==4.1.4` 

然后,向 docker-compose.yml 添加一个新服务:

`dashboard: build:  . command:  celery --app project.server.tasks.celery flower --port=5555 --broker=redis://redis:6379/0 ports: -  5556:5555 environment: -  FLASK_DEBUG=1 -  APP_SETTINGS=project.server.config.DevelopmentConfig -  CELERY_BROKER_URL=redis://redis:6379/0 -  CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: -  web -  redis -  worker` 

测试一下:

`$ docker-compose up -d --build` 

导航到 http://localhost:5556 查看仪表板。您应该看到一名员工准备就绪:

flower dashboard

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

flower dashboard

试着增加几个工人,看看会有什么影响:

`$ docker-compose up -d --build --scale worker=3` 

试验

让我们从最基本的测试开始:

`def test_task():
    assert create_task.run(1)
    assert create_task.run(2)
    assert create_task.run(3)` 

将上述测试用例添加到project/tests/test _ tasks . py中,然后添加以下导入:

`from project.server.tasks import create_task` 

单独运行测试:

`$ docker-compose exec web python -m pytest -k "test_task and not test_home"` 

运行应该需要大约一分钟:

`================================== test session starts ===================================
platform linux -- Python 3.10.2, pytest-7.0.1, pluggy-1.0.0
rootdir: /usr/src/app
collected 2 items / 1 deselected / 1 selected

project/tests/test_tasks.py .                                                       [100%]

====================== 1 passed, 1 deselected in 60.28s (0:01:00) ========================` 

值得注意的是,在上面的断言中,我们使用了.run方法(而不是.delay)来直接运行任务,而没有芹菜工人。

想要模仿.run方法来加快速度吗?

`@patch("project.server.tasks.create_task.run")
def test_mock_task(mock_run):
    assert create_task.run(1)
    create_task.run.assert_called_once_with(1)

    assert create_task.run(2)
    assert create_task.run.call_count == 2

    assert create_task.run(3)
    assert create_task.run.call_count == 3` 

导入:

`from unittest.mock import patch, call` 

测试:

`$ docker-compose exec web python -m pytest -k "test_mock_task"

================================== test session starts ===================================
platform linux -- Python 3.10.2, pytest-7.0.1, pluggy-1.0.0
rootdir: /usr/src/app
collected 3 items / 2 deselected / 1 selected

project/tests/test_tasks.py .                                                       [100%]

============================ 1 passed, 2 deselected in 0.37s =============================` 

快多了!

全面整合测试怎么样?

`def test_task_status(test_app):
    client = test_app.test_client()

    resp = client.post(
        "/tasks",
        data=json.dumps({"type": 0}),
        content_type='application/json'
    )
    content = json.loads(resp.data.decode())
    task_id = content["task_id"]
    assert resp.status_code == 202
    assert task_id

    resp = client.get(f"tasks/{task_id}")
    content = json.loads(resp.data.decode())
    assert content == {"task_id": task_id, "task_status": "PENDING", "task_result": None}
    assert resp.status_code == 200

    while content["task_status"] == "PENDING":
        resp = client.get(f"tasks/{task_id}")
        content = json.loads(resp.data.decode())
    assert content == {"task_id": task_id, "task_status": "SUCCESS", "task_result": True}` 

请记住,这个测试使用开发中使用的相同的代理和后端。您可能想要实例化一个新的 Celery 应用程序来进行测试。

添加导入:

确保测试通过。

结论

这是关于如何配置 Celery 在 Flask 应用程序中运行长时间运行的任务的基本指南。您应该让队列处理任何可能阻塞或减慢面向用户的代码的进程。

Celery 还可以用于执行可重复的任务,分解复杂的资源密集型任务,以便将计算工作量分布到多个机器上,从而减少(1)完成时间和(2)处理客户端请求的机器上的负载。

最后,如果你想知道如何使用 WebSockets 来检查芹菜任务的状态,而不是使用 AJAX 轮询,请查看芹菜和烧瓶的权威指南课程。

回购中抓取代码。

用 APIFairy 构建 Flask API

原文:https://testdriven.io/blog/flask-apifairy/

本教程演示了如何使用 FlaskAPIFairy 轻松创建 RESTful API。

目标

本教程结束时,您将能够:

  1. 使用 APIFairy 提供的装饰器在 Flask 中创建 API 端点
  2. 利用 Flask-Marshmallow 定义 API 端点的输入/输出模式
  3. 使用 APIFairy 生成 API 文档
  4. 将关系数据库与 API 端点集成在一起
  5. 使用 Flask-HTTPAuth 实现基本和令牌认证

什么是蜂仙?

APIFairy 是一个由 T2 编写的 API 框架,允许用 Flask 轻松创建 API。

APIFairy 为在 Flask 中轻松创建 API 提供了四个关键组件:

  1. 装修工
  2. 计划
  3. 证明
  4. 证明文件

让我们详细探索每一个...

装修工

APIFairy 提供了一组装饰器,用于定义每个 API 端点的输入、输出和认证:

APIFairy Decorators

APIFairy 提供了五个核心装饰器:

  1. @arguments -指定 URL 的查询字符串中的输入参数
  2. @body -将输入的 JSON 主体指定为模式
  3. @response -将输出 JSON 主体指定为模式
  4. @other_responses -指定可以返回的附加响应(通常是错误)(仅限文档)
  5. @authenticate -指定认证过程

计划

API 端点的输入(使用@body装饰器)和输出(使用@response装饰器)被定义为模式:

`class EntrySchema(ma.Schema):
    """Schema defining the attributes in a journal entry."""
    id = ma.Integer()
    entry = ma.String()
    user_id = ma.Integer()` 

模式利用棉花糖将数据类型定义为类。

证明

@authenticate装饰器用于检查每个 API 端点的 URL 请求中提供的认证头。身份验证方案是使用 Flask-HTTPAuth 实现的,它也是由 Miguel Grinberg 创建的。

典型的 API 认证方法是定义基本认证来保护获取认证令牌的路径:

`basic_auth = HTTPBasicAuth()

@basic_auth.verify_password
def verify_password(email, password):
    user = User.query.filter_by(email=email).first()
    if user.is_password_correct(password):
        return user` 

并且还定义了令牌认证,用于基于时间敏感认证令牌保护大多数路由:

`token_auth = HTTPTokenAuth()

@token_auth.verify_token
def verify_token(auth_token):
    return User.verify_auth_token(auth_token)` 

证明文件

APIFairy 的一个伟大特性是自动生成的漂亮的 API 文档:

API Documentation - Main Page

文档是基于源代码中的文档字符串以及以下配置变量生成的:

  1. APIFAIRY_TITLE -项目名称
  2. APIFAIRY_VERSION -项目的版本字符串
  3. APIFAIRY_UI-API 文件的格式

对于APIFAIRY_UI,您可以从以下 OpenAPI 文档渲染器之一生成模板:

  1. Swagger UI
  2. ReDoc
  3. RapiDoc
  4. 元素

有关可用配置变量的完整列表,请参考配置文档。

我们在建造什么?

在本教程中,您将开发一个日志 API,允许用户每天记录事件。您可以在 GitLab 上的 flask-journal-api 资源库中找到完整的源代码。

使用的关键 Python 包:

  1. 烧瓶:Python web 应用开发的微框架
  2. API fairy:Flask 的 API 框架,使用-
  3. 烧瓶的 ORM(对象关系映射器)

您将逐步开发 API:

  1. 创建用于处理日记条目的 API 端点
  2. 生成 API 文档
  3. 添加一个关系数据库来存储日志条目
  4. 添加身份验证以保护 API 端点

API 端点

让我们开始使用 Flask 和 APIFairy 创建一个 API...

项目初始化

首先创建一个新的项目文件夹和一个虚拟环境:

`$ mkdir flask-journal-api
$ cd flask-journal-api
$ python3 -m venv venv
$ source venv/bin/activate
(venv)$` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

继续添加下列文件和文件夹:

`├── app.py
├── instance
│   └── .gitkeep
├── project
│   ├── __init__.py
│   └── journal_api
│       ├── __init__.py
│       └── routes.py
└── requirements.txt` 

接下来,为了安装必要的 Python 包,将依赖项添加到项目根目录下的 requirements.txt 文件中:

`apifairy==0.9.1
Flask==2.1.2
Flask-SQLAlchemy==2.5.1
marshmallow-sqlalchemy==0.28.0` 

安装:

`(venv)$ pip install -r requirements.txt` 

该烧瓶项目将利用烧瓶应用的两个最佳实践:

  1. 应用工厂 -用于在函数中创建 Flask 应用
  2. 蓝图 -用于组织一组相关的视图

应用工厂

首先在项目/init 中定义应用工厂函数。py :

`from apifairy import APIFairy
from flask import Flask, json
from flask_marshmallow import Marshmallow

# -------------
# Configuration
# -------------

# Create the instances of the Flask extensions in the global scope,
# but without any arguments passed in. These instances are not
# attached to the Flask application at this point.
apifairy = APIFairy()
ma = Marshmallow()

# ----------------------------
# Application Factory Function
# ----------------------------

def create_app():
    # Create the Flask application
    app = Flask(__name__)

    initialize_extensions(app)
    register_blueprints(app)
    return app

# ----------------
# Helper Functions
# ----------------

def initialize_extensions(app):
    # Since the application instance is now created, pass it to each Flask
    # extension instance to bind it to the Flask application instance (app)
    apifairy.init_app(app)
    ma.init_app(app)

def register_blueprints(app):
    # Import the blueprints
    from project.journal_api import journal_api_blueprint

    # Since the application instance is now created, register each Blueprint
    # with the Flask application instance (app)
    app.register_blueprint(journal_api_blueprint, url_prefix='/journal')` 

定义好应用工厂函数后,可以在项目顶层文件夹的 app.py 中调用:

`from project import create_app

# Call the application factory function to construct a Flask application
# instance using the development configuration
app = create_app()` 

蓝图

让我们定义一下journal_api蓝图。首先在项目/journal_api/_init 中定义journal_api蓝图。py :

`"""
The 'journal_api' blueprint handles the API for managing journal entries.
Specifically, this blueprint allows for journal entries to be added, edited,
and deleted.
"""
from flask import Blueprint

journal_api_blueprint = Blueprint('journal_api', __name__, template_folder='templates')

from . import routes` 

现在是时候在project/journal _ API/routes . py中为日志定义 API 端点了。

从必要的导入开始:

`from apifairy import body, other_responses, response
from flask import abort

from project import ma
from . import journal_api_blueprint` 

对于 Flask Journal API 的这个初始版本,数据库将是一个日志条目列表:

`# --------
# Database
# --------

messages = [
    dict(id=1, entry='The sun was shining when I woke up this morning.'),
    dict(id=2, entry='I tried a new fruit mixture in my oatmeal for breakfast.'),
    dict(id=3, entry='Today I ate a great sandwich for lunch.')
]` 

接下来,定义创建新日志条目和返回日志条目的模式:

`# -------
# Schemas
# -------

class NewEntrySchema(ma.Schema):
    """Schema defining the attributes when creating a new journal entry."""
    entry = ma.String(required=True)

class EntrySchema(ma.Schema):
    """Schema defining the attributes in a journal entry."""
    id = ma.Integer()
    entry = ma.String()

new_entry_schema = NewEntrySchema()
entry_schema = EntrySchema()
entries_schema = EntrySchema(many=True)` 

这两个模式类都继承自 ma。图式,由 Flask-Marshmallow 提供。创建这些模式的对象也是一个好主意,因为这允许您定义一个可以返回多个条目的模式(使用many=True参数)。

现在我们已经准备好定义 API 端点了!

路线

从检索所有日志条目开始:

`@journal_api_blueprint.route('/', methods=['GET'])
@response(entries_schema)
def journal():
    """Return all journal entries"""
    return messages` 

这个视图函数使用@response装饰器来定义返回多个条目。view 函数返回日志条目的完整列表(return messages)。

接下来,创建用于添加新日志条目的 API 端点:

`@journal_api_blueprint.route('/', methods=['POST'])
@body(new_entry_schema)
@response(entry_schema, 201)
def add_journal_entry(kwargs):
    """Add a new journal entry"""
    new_message = dict(**kwargs, id=messages[-1]['id']+1)
    messages.append(new_message)
    return new_message` 

这个视图函数使用@body装饰器来定义 API 端点的输入,使用@response装饰器来定义 API 端点的输出。

@body装饰器解析的输入数据作为kwargs(keywordarguments)参数传递给add_journal_entry()视图函数。然后,该数据用于创建新的日志条目,并将其添加到数据库中:

`new_message = dict(**kwargs, id=messages[-1]['id']+1)
messages.append(new_message)` 

然后返回新创建的日志条目(return new_message)。注意@response decorator 如何将返回代码定义为 201 (Created ),以表示日志条目被添加到了数据库中。

创建用于检索特定日记条目的 API 端点:

`@journal_api_blueprint.route('/<int:index>', methods=['GET'])
@response(entry_schema)
@other_responses({404: 'Entry not found'})
def get_journal_entry(index):
    """Return a journal entry"""
    if index >= len(messages):
        abort(404)

    return messages[index]` 

这个视图函数使用@other_responses装饰器来指定非标准响应。

装饰器仅用于文档目的!它不提供任何返回错误代码的功能。

创建用于更新日记帐分录的 API 端点:

`@journal_api_blueprint.route('/<int:index>', methods=['PUT'])
@body(new_entry_schema)
@response(entry_schema)
@other_responses({404: 'Entry not found'})
def update_journal_entry(data, index):
    """Update a journal entry"""
    if index >= len(messages):
        abort(404)

    messages[index] = dict(data, id=index+1)
    return messages[index]` 

这个视图函数使用@body@response装饰器来定义这个 API 端点的输入和输出。另外,如果没有找到日志条目,@other_responses装饰器定义了非标准响应。

最后,创建用于删除日志条目的 API 端点:

`@journal_api_blueprint.route('/<int:index>', methods=['DELETE'])
@other_responses({404: 'Entry not found'})
def delete_journal_entry(index):
    """Delete a journal entry"""
    if index >= len(messages):
        abort(404)

    messages.pop(index)
    return '', 204` 

这个视图函数不使用@body@response装饰器,因为这个 API 端点没有输入或输出。如果日志条目被成功删除,则返回 204(无内容)状态代码,并且不包含任何数据。

运行烧瓶应用程序

为了进行测试,在一个终端窗口中,配置 Flask 应用程序并运行开发服务器:

`(venv) $ export FLASK_APP=app.py
(venv) $ export FLASK_ENV=development
(venv) $ flask run` 

然后,在不同的终端窗口中,您可以与 API 进行交互。在这里你可以随意使用你选择的工具,比如 cURL、 HTTPieRequests 或者 Postman

请求示例:

`$ python3

>>> import requests
>>>
>>> r = requests.get('http://127.0.0.1:5000/journal/')
>>> print(r.text)
>>>
>>> post_data = {'entry': "some message"}
>>> r = requests.post('http://127.0.0.1:5000/journal/', json=post_data)
>>> print(r.text)` 

想要更容易地测试 API 端点吗?查看这个脚本,它添加了 CLI 命令,用于与 API 端点进行交互,以检索、创建、更新和删除日志条目。

证明文件

APIFairy 的一个令人难以置信的特性是自动创建 API 文档!

配置 API 文档有三个关键方面:

  1. API 端点的文档字符串(即视图函数)
  2. 整个 API 项目的 Docstring
  3. 用于指定 API 文档外观的配置变量

我们已经在前一节中讨论了第一项,因为我们包括了每个视图函数的文档字符串。例如,journal()视图函数对这个 API 端点的用途有一个简短的描述:

`@journal_api_blueprint.route('/', methods=['GET'])
@response(entries_schema)
def journal():
    """Return all journal entries"""
    return messages` 

接下来,我们需要在项目/init 的顶部包含描述整个项目的 docstring。py 文件:

`"""
Welcome to the documentation for the Flask Journal API!

## Introduction

The Flask Journal API is an API (Application Programming Interface) for creating a **daily journal** that documents events that happen each day.

## Key Functionality

The Flask Journal API has the following functionality:

1\. Work with journal entries:
 * Create a new journal entry
 * Update a journal entry
 * Delete a journal entry
 * View all journal entries
2\. <More to come!>

## Key Modules

This project is written using Python 3.10.1.

The project utilizes the following modules:

* **Flask**: micro-framework for web application development which includes the following dependencies:
 * **click**: package for creating command-line interfaces (CLI)
 * **itsdangerous**: cryptographically sign data
 * **Jinja2**: templating engine
 * **MarkupSafe**: escapes characters so text is safe to use in HTML and XML
 * **Werkzeug**: set of utilities for creating a Python application that can talk to a WSGI server
* **APIFairy**: API framework for Flask which includes the following dependencies:
 * **Flask-Marshmallow** - Flask extension for using Marshmallow (object serialization/deserialization library)
 * **Flask-HTTPAuth** - Flask extension for HTTP authentication
 * **apispec** - API specification generator that supports the OpenAPI specification
* **pytest**: framework for testing Python projects
"""
...` 

这个 docstring 用于描述整个项目,包括提供的关键功能和项目使用的关键 Python 包。

最后,需要定义一些配置变量来指定 API 文档的外观。更新项目/init 中的create_app()函数。py :

`def create_app():
    # Create the Flask application
    app = Flask(__name__)

    # Configure the API documentation
    app.config['APIFAIRY_TITLE'] = 'Flask Journal API'
    app.config['APIFAIRY_VERSION'] = '0.1'
    app.config['APIFAIRY_UI'] = 'elements'

    initialize_extensions(app)
    register_blueprints(app)

    return app` 

准备好查看项目文档了吗?通过flask run启动 Flask 开发服务器,然后导航到http://127 . 0 . 0 . 1:5000/docs查看 APIFairy 创建的 API 文档:

API Documentation - Main Page

在左侧窗格中,有一个针对journal_api蓝图的 API 端点列表。单击其中一个端点会显示该端点的所有详细信息:

API Documentation - Get Journal Entry API Endpoint

这个 API 文档的惊人之处在于能够看到 API 端点是如何工作的(假设 Flask development server 正在运行)。在文档的右侧窗格中,输入日志条目索引,然后单击“发送 API 请求”。然后显示 API 响应:

API Documentation - Get Journal Entry API Response

这个交互式文档让用户很容易理解 API!

数据库ˌ资料库

出于演示目的,本教程将使用一个 SQLite 数据库。

配置

由于本教程开始时已经安装了 Flask-SQLAlchemy ,我们需要在项目/init 中配置它。py 文件。

首先在“配置”部分创建一个SQLAlchemy()对象:

`...

from apifairy import APIFairy
from flask import Flask, json
from flask_marshmallow import Marshmallow
from flask_sqlalchemy import SQLAlchemy  # <-- NEW!!

# -------------
# Configuration
# -------------

# Create the instances of the Flask extensions in the global scope,
# but without any arguments passed in. These instances are not
# attached to the Flask application at this point.
apifairy = APIFairy()
ma = Marshmallow()
database = SQLAlchemy()  # <-- NEW!!

...` 

接下来,更新create_app()函数以指定必要的配置变量:

`def create_app():
    # Create the Flask application
    app = Flask(__name__)

    # Configure the API documentation
    app.config['APIFAIRY_TITLE'] = 'Flask Journal API'
    app.config['APIFAIRY_VERSION'] = '0.1'
    app.config['APIFAIRY_UI'] = 'elements'

    # NEW!
    # Configure the SQLite database (intended for development only!)
    app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{os.path.join(os.getcwd(), 'instance', 'app.db')}"
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    initialize_extensions(app)
    register_blueprints(app)

    return app` 

将导入添加到顶部:

SQLALCHEMY_DATABASE_URI配置变量对于识别 SQLite 数据库的位置至关重要。对于本教程,数据库存储在 instance/app.db 中。

最后,更新initialize_extensions()函数来初始化 Flask-SQLAlchemy 对象:

`def initialize_extensions(app):
    # Since the application instance is now created, pass it to each Flask
    # extension instance to bind it to the Flask application instance (app)
    apifairy.init_app(app)
    ma.init_app(app)
    database.init_app(app)  # <-- NEW!!` 

想进一步了解这个 Flask 应用程序是如何连接在一起的吗?查看我关于如何构建、测试和部署 Flask 应用程序的课程:

数据库模型

创建一个新的 project/models.py 文件来定义数据库表以表示日志条目:

`from project import database

class Entry(database.Model):
    """Class that represents a journal entry."""
    __tablename__ = 'entries'

    id = database.Column(database.Integer, primary_key=True)
    entry = database.Column(database.String, nullable=False)

    def __init__(self, entry: str):
        self.entry = entry

    def update(self, entry: str):
        self.entry = entry

    def __repr__(self):
        return f'<Entry: {self.entry}>'` 

这个新类Entry指定entries数据库表将包含两个元素(目前!)来表示日志条目:

  1. id -表的主键(primary_key=True),这意味着它是表中每个元素(行)的唯一标识符
  2. entry -用于存储日志条目文本的字符串

虽然 models.py 定义了数据库表,但它并不在 SQLite 数据库中创建表。要创建表,请在终端窗口中启动 Flask shell:

`(venv)$ flask shell

>>> from project import database
>>> database.drop_all()
>>> database.create_all()
>>> quit()

(venv)$` 

日志 API 更新

因为我们正在使用 SQLite 数据库,所以从删除在project/journal _ API/routes . py中定义的临时database (Python 列表)开始:

`# --------
# Database
# --------

messages = [
    dict(id=1, entry='The sun was shining when I woke up this morning.'),
    dict(id=2, entry='I tried a new fruit mixture in my oatmeal for breakfast.'),
    dict(id=3, entry='Today I ate a great sandwich for lunch.')
]` 

接下来,我们需要更新每个 API 端点(即视图函数)以利用 SQLite 数据库。

首先更新journal()视图功能:

`@journal_api_blueprint.route('/', methods=['GET'])
@response(entries_schema)
def journal():
    """Return all journal entries"""
    return Entry.query.all()` 

现在可以从 SQLite 数据库中检索日志条目的完整列表。注意这个视图函数的模式或装饰器不需要改变...只有改变用户的底层过程!

添加导入:

`from project.models import Entry` 

接下来,更新add_journal_entry()视图功能:

`@journal_api_blueprint.route('/', methods=['POST'])
@body(new_entry_schema)
@response(entry_schema, 201)
def add_journal_entry(kwargs):
    """Add a new journal entry"""
    new_message = Entry(**kwargs)
    database.session.add(new_message)
    database.session.commit()
    return new_message` 

该视图功能的输入由new_entry_schema指定:

`class NewEntrySchema(ma.Schema):
    """Schema defining the attributes when creating a new journal entry."""
    entry = ma.String(required=True)

new_entry_schema = NewEntrySchema()` 

entry字符串用于创建Entry类的一个新实例(在 models.py 中定义),然后这个日志条目被添加到数据库中。

添加导入:

`from project import database` 

接下来,更新get_journal_entry():

`@journal_api_blueprint.route('/<int:index>', methods=['GET'])
@response(entry_schema)
@other_responses({404: 'Entry not found'})
def get_journal_entry(index):
    """Return a journal entry"""
    entry = Entry.query.filter_by(id=index).first_or_404()
    return entry` 

该函数现在尝试查找指定的日志条目(基于index):

`entry = Entry.query.filter_by(id=index).first_or_404()` 

如果条目存在,则返回给用户。如果条目不存在,则返回 404(未找到)错误。

接下来,更新update_journal_entry():

`@journal_api_blueprint.route('/<int:index>', methods=['PUT'])
@body(new_entry_schema)
@response(entry_schema)
@other_responses({404: 'Entry not found'})
def update_journal_entry(data, index):
    """Update a journal entry"""
    entry = Entry.query.filter_by(id=index).first_or_404()
    entry.update(data['entry'])
    database.session.add(entry)
    database.session.commit()
    return entry` 

update_journal_entry()视图功能现在试图检索指定的日志条目:

`entry = Entry.query.filter_by(id=index).first_or_404()` 

如果日记条目存在,则用新文本更新该条目,然后保存到数据库。

最后,更新delete_journal_entry():

`@journal_api_blueprint.route('/<int:index>', methods=['DELETE'])
@other_responses({404: 'Entry not found'})
def delete_journal_entry(index):
    """Delete a journal entry"""
    entry = Entry.query.filter_by(id=index).first_or_404()
    database.session.delete(entry)
    database.session.commit()
    return '', 204` 

如果找到了指定的日记条目,则将其从数据库中删除。

运行开发服务器。测试每个端点以确保它们仍然工作。

错误处理

由于这个 Flask 项目是一个 API,错误代码应该以 JSON 格式返回,而不是典型的 HTML 格式。

在 Flask 项目中,这可以通过使用自定义错误处理程序来完成。在 project/init。py ,在文件底部定义一个新函数(register_error_handlers()):

`def register_error_handlers(app):
    @app.errorhandler(HTTPException)
    def handle_http_exception(e):
        """Return JSON instead of HTML for HTTP errors."""
        # Start with the correct headers and status code from the error
        response = e.get_response()
        # Replace the body with JSON
        response.data = json.dumps({
            'code': e.code,
            'name': e.name,
            'description': e.description,
        })
        response.content_type = 'application/json'
        return response` 

这个函数注册了一个新的错误处理程序,用于当一个HTTPException被引发时将输出转换成 JSON 格式。

添加导入:

`from werkzeug.exceptions import HTTPException` 

另外,更新应用程序工厂函数create_app(),以调用这个新函数:

`def create_app():
    # Create the Flask application
    app = Flask(__name__)

    # Configure the API documentation
    app.config['APIFAIRY_TITLE'] = 'Flask Journal API'
    app.config['APIFAIRY_VERSION'] = '0.1'
    app.config['APIFAIRY_UI'] = 'elements'

    # Configure the SQLite database (intended for development only!)
    app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{os.path.join(os.getcwd(), 'instance', 'app.db')}"
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    initialize_extensions(app)
    register_blueprints(app)
    register_error_handlers(app)  # NEW!!

    return app` 

证明

身份验证是验证试图访问系统的用户身份的过程,在本例中是 API。

另一方面,授权是验证特定用户应该访问哪些特定资源的过程。

APIFairy 利用 Flask-HTTPAuth 进行认证支持。在本教程中,我们将以两种方式使用 Flask-HTTPAuth:

  1. 基本认证 -用于根据用户的电子邮件/密码生成令牌
  2. 令牌认证 -用于在所有其他 API 端点上认证用户

通过 Flask-HTTPAuth 使用的令牌认证通常被称为无记名认证,因为该过程调用对令牌“无记名”的授权访问。令牌必须包含在授权头的 HTTP 头中,例如“授权:载体”。

下图说明了新用户如何与应用程序交互以检索身份验证令牌的典型流程:

Flask Journal API Flow Diagram

配置

由于在本教程开始安装 APIFairy 时已经安装了 Flask-HTTPAuth,所以我们只需要在项目/init 中配置它。py 文件。

首先为基本身份验证和令牌身份验证创建单独的对象:

`...

import os

from apifairy import APIFairy
from flask import Flask, json
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth  # NEW!!
from flask_marshmallow import Marshmallow
from flask_sqlalchemy import SQLAlchemy
from werkzeug.exceptions import HTTPException

# -------------
# Configuration
# -------------

# Create the instances of the Flask extensions in the global scope,
# but without any arguments passed in. These instances are not
# attached to the Flask application at this point.
apifairy = APIFairy()
ma = Marshmallow()
database = SQLAlchemy()
basic_auth = HTTPBasicAuth()  # NEW!!
token_auth = HTTPTokenAuth()  # NEW!!

...` 

项目/init 中不需要进一步更新。py

数据库模型

project/models.py 中,需要创建一个新的User模型来代表一个用户:

`class User(database.Model):
    __tablename__ = 'users'

    id = database.Column(database.Integer, primary_key=True)
    email = database.Column(database.String, unique=True, nullable=False)
    password_hashed = database.Column(database.String(128), nullable=False)
    entries = database.relationship('Entry', backref='user', lazy='dynamic')
    auth_token = database.Column(database.String(64), index=True)
    auth_token_expiration = database.Column(database.DateTime)

    def __init__(self, email: str, password_plaintext: str):
        """Create a new User object."""
        self.email = email
        self.password_hashed = self._generate_password_hash(password_plaintext)

    def is_password_correct(self, password_plaintext: str):
        return check_password_hash(self.password_hashed, password_plaintext)

    def set_password(self, password_plaintext: str):
        self.password_hashed = self._generate_password_hash(password_plaintext)

    @staticmethod
    def _generate_password_hash(password_plaintext):
        return generate_password_hash(password_plaintext)

    def generate_auth_token(self):
        self.auth_token = secrets.token_urlsafe()
        self.auth_token_expiration = datetime.utcnow() + timedelta(minutes=60)
        return self.auth_token

    @staticmethod
    def verify_auth_token(auth_token):
        user = User.query.filter_by(auth_token=auth_token).first()
        if user and user.auth_token_expiration > datetime.utcnow():
            return user

    def revoke_auth_token(self):
        self.auth_token_expiration = datetime.utcnow()

    def __repr__(self):
        return f'<User: {self.email}>'` 

添加导入:

`import secrets
from datetime import datetime, timedelta

from werkzeug.security import check_password_hash, generate_password_hash` 

User模型使用werkzeug.security在将用户密码存储到数据库之前对其进行哈希处理。

记住:不要将明文密码存储在数据库中!

User模型使用secrets为特定用户生成认证令牌。该令牌在generate_auth_token()方法中创建,包含未来 60 分钟的到期日期/时间:

`def generate_auth_token(self):
    self.auth_token = secrets.token_urlsafe()
    self.auth_token_expiration = datetime.utcnow() + timedelta(minutes=60)
    return self.auth_token` 

有一个静态方法verify_auth_token(),用于验证身份验证令牌(同时考虑到期时间)并从有效令牌返回用户:

`@staticmethod
def verify_auth_token(auth_token):
    user = User.query.filter_by(auth_token=auth_token).first()
    if user and user.auth_token_expiration > datetime.utcnow():
        return user` 

另一个有趣的方法是revoke_auth_token(),它用于撤销特定用户的认证令牌:

`def revoke_auth_token(self):
    self.auth_token_expiration = datetime.utcnow()` 

入门模型

为了在用户(“一”)和他们的条目(“多”)之间建立一对多的关系,需要更新Entry模型以将entriesusers表链接在一起:

`class Entry(database.Model):
    """Class that represents a journal entry."""
    __tablename__ = 'entries'

    id = database.Column(database.Integer, primary_key=True)
    entry = database.Column(database.String, nullable=False)
    user_id = database.Column(database.Integer, database.ForeignKey('users.id'))  # <-- NEW!!

    def __init__(self, entry: str):
        self.entry = entry

    def update(self, entry: str):
        self.entry = entry

    def __repr__(self):
        return f'<Entry: {self.entry}>'` 

User模型已经包含了返回到entries表的链接:

`entries = database.relationship('Entry', backref='user', lazy='dynamic')` 

用户 API 蓝图

Flask 项目的用户管理功能将在名为users_api_blueprint的单独蓝图中定义。

首先在“project”中创建一个名为“users_api”的新目录。在该目录中创建一个 init。py 文件:

`from flask import Blueprint

users_api_blueprint = Blueprint('users_api', __name__)

from . import authentication, routes` 

这个新蓝图需要在项目/init 中用烧瓶app注册。register_blueprints()功能内的 py :

`def register_blueprints(app):
    # Import the blueprints
    from project.journal_api import journal_api_blueprint
    from project.users_api import users_api_blueprint  # NEW!!

    # Since the application instance is now created, register each Blueprint
    # with the Flask application instance (app)
    app.register_blueprint(journal_api_blueprint, url_prefix='/journal')
    app.register_blueprint(users_api_blueprint, url_prefix='/users')  # NEW!!` 

认证功能

要使用 Flask-HTTPAuth,需要定义几个函数来检查用户凭证。

创建一个新的项目/users _ API/authentic ation . py文件来处理基本认证和令牌认证。

对于基本身份验证(检查用户的电子邮件和密码):

`from werkzeug.exceptions import Forbidden, Unauthorized

from project import basic_auth, token_auth
from project.models import User

@basic_auth.verify_password
def verify_password(email, password):
    user = User.query.filter_by(email=email).first()
    if user is None:
        return None

    if user.is_password_correct(password):
        return user

@basic_auth.error_handler
def basic_auth_error(status=401):
    error = (Forbidden if status == 403 else Unauthorized)()
    return {
        'code': error.code,
        'message': error.name,
        'description': error.description,
    }, error.code, {'WWW-Authenticate': 'Form'}` 

verify_password()功能用于检查用户是否存在以及他们的密码是否正确。当需要基本认证时,Flask-HTTPAuth 将使用这个函数来验证密码(感谢@basic_auth.verify_password装饰器)。)

此外,为基本身份验证定义了一个错误处理程序,它以 JSON 格式返回有关错误的信息。

对于令牌身份验证(处理令牌以确定用户是否有效):

`@token_auth.verify_token
def verify_token(auth_token):
    return User.verify_auth_token(auth_token)

@token_auth.error_handler
def token_auth_error(status=401):
    error = (Forbidden if status == 403 else Unauthorized)()
    return {
        'code': error.code,
        'message': error.name,
        'description': error.description,
    }, error.code` 

verify_token()函数用于检查认证令牌是否有效。当需要令牌认证时,Flask-HTTPAuth 将使用该函数来验证令牌(感谢@token_auth.verify_token装饰器)。)

此外,还为令牌身份验证定义了一个错误处理程序,它以 JSON 格式返回有关错误的信息。

用户路线

users_api_blueprint中,会有两条路线:

  1. 注册新用户
  2. 检索身份验证令牌

首先,需要在projects/users _ API/routes . py中定义一组新的模式(使用 marshmallow):

`from project import ma

from . import users_api_blueprint

# -------
# Schemas
# -------

class NewUserSchema(ma.Schema):
    """Schema defining the attributes when creating a new user."""
    email = ma.String()
    password_plaintext = ma.String()

class UserSchema(ma.Schema):
    """Schema defining the attributes of a user."""
    id = ma.Integer()
    email = ma.String()

class TokenSchema(ma.Schema):
    """Schema defining the attributes of a token."""
    token = ma.String()

new_user_schema = NewUserSchema()
user_schema = UserSchema()
token_schema = TokenSchema()` 

这些模式将用于定义该文件中定义的视图函数的输入和输出。

注册新用户

接下来,定义注册新用户的视图函数:

`@users_api_blueprint.route('/', methods=['POST'])
@body(new_user_schema)
@response(user_schema, 201)
def register(kwargs):
    """Create a new user"""
    new_user = User(**kwargs)
    database.session.add(new_user)
    database.session.commit()
    return new_user` 

添加导入:

`from apifairy import authenticate, body, other_responses, response

from project import basic_auth, database, ma
from project.models import User` 

这个 API 端点使用new_user_schema来指定电子邮件和密码是输入。

注意:由于电子邮件和密码被发送到这个 API 端点,现在应该记住在开发测试期间使用 HTTP 是可以接受的,但是在生产中应该始终使用 HTTPS(安全)。

然后,电子邮件和密码(定义为kwargs -关键字参数)被解包以创建一个新的User对象,该对象被保存到数据库:

`new_user = User(**kwargs)
database.session.add(new_user)
database.session.commit()` 

API 端点的输出由user_schema定义,它是新用户的 ID 和电子邮件。

检索身份验证令牌

projects/users _ API/routes . py中定义的另一个视图函数用于检索认证令牌:

`@users_api_blueprint.route('/get-auth-token', methods=['POST'])
@authenticate(basic_auth)
@response(token_schema)
@other_responses({401: 'Invalid username or password'})
def get_auth_token():
    """Get authentication token"""
    user = basic_auth.current_user()
    token = user.generate_auth_token()
    database.session.add(user)
    database.session.commit()
    return dict(token=token)` 

在本教程中第一次使用了@authenticate decorator,它指定了应该使用基本认证来保护这条路线:

`@authenticate(basic_auth)` 

当用户想要检索他们的身份验证令牌时,他们需要向这个 API 端点发送 POST 请求,并在“Authorization”头中嵌入电子邮件和密码。例如,可以对这个 API 端点使用以下使用 Requests 包的 Python 命令:

`>>> import requests
>>> r = requests.post(
    'http://127.0.0.1:5000/users/get-auth-token',
    auth=('[[email protected]](/cdn-cgi/l/email-protection)', 'FlaskIsAwesome123')
)` 

如果基本认证成功,view 函数使用 Flask-HTTPAuth 提供的current_user()方法检索当前用户:

`user = basic_auth.current_user()` 

将为该用户创建一个新的身份验证令牌:

`token = user.generate_auth_token()` 

并且该令牌被保存到数据库中,以便将来可以使用它来认证用户(至少在接下来的 60 分钟内!).

最后,为用户返回新的身份验证令牌,以便为所有后续 API 调用保存。

API 端点更新

有了身份验证过程之后,是时候为现有的 API 端点添加一些保护措施,以确保只有合法用户才能访问应用程序。

这些更新是针对projects/journal _ API/routes . py中定义的视图函数的。

首先,更新journal()以便只返回当前用户的日志条目:

`@journal_api_blueprint.route('/', methods=['GET'])
@authenticate(token_auth)
@response(entries_schema)
def journal():
    """Return journal entries"""
    user = token_auth.current_user()
    return Entry.query.filter_by(user_id=user.id).all()` 

像这样更新顶部的导入:

`from apifairy import authenticate, body, other_responses, response
from flask import abort

from project import database, ma, token_auth
from project.models import Entry

from . import journal_api_blueprint` 

@authenticate decorator 指定在访问这个 API 端点时需要使用令牌认证。例如,下面的 GET 请求可以使用 Requests ( 在认证令牌被检索之后):

`>>> import requests
>>> headers = {'Authorization': f'Bearer {auth_token}'}
>>> r = requests.get('http://127.0.0.1:5000/journal/', headers=headers)` 

用户通过身份验证后,将根据用户 ID 从数据库中检索日志条目的完整列表:

`user = token_auth.current_user()
return Entry.query.filter_by(user_id=user.id).all()` 

这个 API 端点的输出由@response装饰器定义,它是一个日志条目列表(ID、entry、user ID)。

接下来,更新add_journal_entry():

`@journal_api_blueprint.route('/', methods=['POST'])
@authenticate(token_auth)
@body(new_entry_schema)
@response(entry_schema, 201)
def add_journal_entry(kwargs):
    """Add a new journal entry"""
    user = token_auth.current_user()
    new_message = Entry(user_id=user.id, **kwargs)
    database.session.add(new_message)
    database.session.commit()
    return new_message` 

与前面的视图函数一样,@authenticate装饰器用于指定在访问这个 API 端点时需要使用令牌认证。此外,现在通过指定应该与日记条目相关联的用户 ID 来添加日记条目:

`user = token_auth.current_user()
new_message = Entry(user_id=user.id, **kwargs)` 

新的日志条目被保存到数据库,并且日志条目被返回(由@response装饰器定义)。

接下来,更新get_journal_entry():

`@journal_api_blueprint.route('/<int:index>', methods=['GET'])
@authenticate(token_auth)
@response(entry_schema)
@other_responses({403: 'Forbidden', 404: 'Entry not found'})
def get_journal_entry(index):
    """Return a journal entry"""
    user = token_auth.current_user()
    entry = Entry.query.filter_by(id=index).first_or_404()

    if entry.user_id != user.id:
        abort(403)
    return entry` 

添加@authenticate装饰器是为了指定访问这个 API 端点需要令牌认证。

当试图检索一个日志条目时,需要进行额外的检查,以确保试图访问该日志条目的用户是该条目的实际“所有者”。如果没有,那么通过函数abort()从 Flask 返回一个 403(禁止)错误代码:

`if entry.user_id != user.id:
        abort(403)` 

注意,这个 API 端点有两个由@other_responses装饰器指定的异常响应:

`@other_responses({403: 'Forbidden', 404: 'Entry not found'})` 

提醒:@other_responses装饰器仅用于文档;提出这些错误是视图函数的责任。

接下来,更新update_journal_entry():

`@journal_api_blueprint.route('/<int:index>', methods=['PUT'])
@authenticate(token_auth)
@body(new_entry_schema)
@response(entry_schema)
@other_responses({403: 'Forbidden', 404: 'Entry not found'})
def update_journal_entry(data, index):
    """Update a journal entry"""
    user = token_auth.current_user()
    entry = Entry.query.filter_by(id=index).first_or_404()

    if entry.user_id != user.id:
        abort(403)

    entry.update(data['entry'])
    database.session.add(entry)
    database.session.commit()
    return entry` 

此视图功能的更新类似于本节中的其他视图功能:

  1. 装饰者指定需要令牌认证来访问这个 API 端点
  2. 只有“拥有”日志条目的用户才被允许更新该条目(否则,403(禁止))

最后,更新delete_journal_entry():

`@journal_api_blueprint.route('/<int:index>', methods=['DELETE'])
@authenticate(token_auth)
@other_responses({403: 'Forbidden', 404: 'Entry not found'})
def delete_journal_entry(index):
    """Delete a journal entry"""
    user = token_auth.current_user()
    entry = Entry.query.filter_by(id=index).first_or_404()

    if entry.user_id != user.id:
        abort(403)

    database.session.delete(entry)
    database.session.commit()
    return '', 204` 

结论

本教程提供了如何使用 APIFairy 在 Flask 中轻松快速地构建 API 的演练。

装饰器是定义 API 端点的关键:

  • 输入:
    • @arguments -从 URL 的查询字符串中输入参数
    • @body-JSON 请求的结构
  • 输出:
    • @response-JSON 响应的结构
  • 认证:
    • @authenticate -使用 Flask 的认证方法-HTTPAuth
  • 错误:
    • @other_responses -非正常响应,如 HTTP 错误代码

另外,APIFairy 生成的 API 文档非常优秀,为应用程序的用户提供了关键信息。

如果您有兴趣了解有关 Flask 的更多信息,请查看我关于如何构建、测试和部署 Flask 应用程序的课程:

烧瓶 2.0 中的异步

原文:https://testdriven.io/blog/flask-async/

2021 年 5 月 11 日发布的【Flask 2.0,增加了对异步路由、错误处理程序、前后请求函数、拆机回调的内置支持!

本文着眼于 Flask 2.0 的新异步功能,以及如何在 Flask 项目中利用它。

本文假设您之前有使用 Flask 的经验。如果您有兴趣了解有关 Flask 的更多信息,请查看我关于如何构建、测试和部署 Flask 应用程序的课程:

用 Python 和 Flask 开发 Web 应用

烧瓶 2.0 异步

从 Flask 2.0 开始,您可以使用async / await创建异步路由处理程序:

`import asyncio

async def async_get_data():
    await asyncio.sleep(1)
    return 'Done!'

@app.route("/data")
async def get_data():
    data = await async_get_data()
    return data` 

创建异步路由与创建同步路由一样简单:

  1. 你只需要通过pip install "Flask[async]"安装带有额外async的烧瓶。
  2. 然后,您可以将关键字async添加到您的函数中,并使用await

这是如何工作的?

下图说明了如何在 Flask 2.0 中执行异步代码:

Flask 2.x Asynchronous Diagram

为了在 Python 中运行异步代码,需要一个事件循环来运行协程。Flask 2.0 负责创建 asyncio 事件循环——通常用asyncio.run()完成——以运行协程。

如果您有兴趣了解更多关于 Python 中线程、多处理和异步的区别,请查看用并发、并行和异步加速 Python 的文章

当处理一个async route 函数时,将创建一个新的子线程。在这个子线程中,将执行一个 asyncio 事件循环来运行路由处理程序(协程)。

这个实现利用 Django 使用的asgiref库(特别是 AsyncToSync 功能)来运行异步代码。

更多实现细节,请参考 Flask 源代码中的 async_to_sync()

这个实现的伟大之处在于它允许 Flask 在任何工作类型(线程、gevent、eventlet 等)下运行。).

在 Flask 2.0 之前运行异步代码需要在每个路由处理程序中创建一个新的 asyncio 事件循环,这就需要使用基于线程的工作器来运行 Flask 应用程序。更多细节将在本文后面介绍...

此外,异步路由处理程序的使用是向后兼容的。您可以在 Flask 应用程序中使用异步和同步路由处理程序的任意组合,而不会影响性能。这允许您在现有的 Flask 项目中立即开始原型化单个异步路由处理程序。

为什么不需要 ASGI?

按照设计,Flask 是一个实现 WSGI (Web 服务器网关接口)协议的同步 web 框架。

WSGI 是 web 服务器和基于 Python 的 web 应用程序之间的接口。WSGI (Web 服务器网关接口)服务器(如 Gunicorn 或 uWSGI)是 Python web 应用程序所必需的,因为 Web 服务器不能直接与 Python 通信。

想了解更多关于 WSGI 的知识吗?

看看‘Python 中的 Gunicorn 是什么?’看看构建 Python Web 框架课程。

在 Flask 中处理请求时,每个请求都在一个 worker 中单独处理。添加到 Flask 2.0 的异步功能总是在被处理的单个请求中:

Flask 2.0 - Worker Running Async Event Loop

请记住,尽管异步代码可以在 Flask 中执行,但它是在同步框架的上下文中执行的。换句话说,虽然您可以在单个请求中执行各种异步任务,但是每个异步任务必须在响应被发回之前完成。因此,在有限的情况下,异步路由实际上是有益的。还有其他 Python web 框架支持 ASGI(异步服务器网关接口),它支持异步调用栈,因此路由可以并发运行:

什么时候应该使用异步?

虽然异步执行往往会主导讨论并成为头条新闻,但它并不是每种情况下的最佳方法。

当这两个条件都满足时,它是受 I/O 限制的操作的理想选择:

  1. 有很多手术
  2. 每个操作不到几秒钟就能完成

例如:

  1. 进行 HTTP 或 API 调用
  2. 与数据库交互
  3. 使用文件系统

不适合后台和长时间运行的任务以及 cpu 受限的操作,比如:

  1. 运行机器学习模型
  2. 处理图像或 pdf
  3. 执行备份

使用像 Celery 这样的任务队列来管理单独的长时间运行的任务会更好地实现这样的任务。

异步 HTTP 调用

当您需要向外部网站或 API 发出多个 HTTP 请求时,异步方法确实有好处。对于每一个请求,都需要很长时间才能收到响应。这种等待时间会让你的用户觉得你的 web 应用程序运行缓慢。

通过利用async / await,您可以大大加快流程,而不是一次发出一个外部请求(通过请求包)。

Synchronous vs. Asynchronous Call Diagram

在同步方法中,进行外部 API 调用(比如 GET ),然后应用程序等待返回响应。获得响应所需的时间称为延迟,它因互联网连接和服务器响应时间而异。在这种情况下,每个请求的延迟可能在 0.2 - 1.5 秒的范围内。

在异步方法中,进行外部 API 调用,然后处理继续进行下一个 API 调用。一旦收到来自外部服务器的响应,就会对其进行处理。这是一种更有效的资源利用方式。

一般来说,异步编程非常适合这样的情况,在这种情况下,需要进行多个外部调用,并且需要等待大量的 I/O 响应。

异步路由处理器

aiohttp 是一个使用 asyncio 创建异步 http 客户端和服务器的包。如果您熟悉用于同步执行 http 调用的请求包,aiohttp 是一个类似的包,它专注于异步 HTTP 调用。

这是一个在烧瓶路线中使用的 aiohttp 的例子:

`urls = ['https://www.kennedyrecipes.com',
        'https://www.kennedyrecipes.com/breakfast/pancakes/',
        'https://www.kennedyrecipes.com/breakfast/honey_bran_muffins/']

# Helper Functions

async def fetch_url(session, url):
    """Fetch the specified URL using the aiohttp session specified."""
    response = await session.get(url)
    return {'url': response.url, 'status': response.status}

# Routes

@app.route('/async_get_urls_v2')
async def async_get_urls_v2():
    """Asynchronously retrieve the list of URLs."""
    async with ClientSession() as session:
        tasks = []
        for url in urls:
            task = asyncio.create_task(fetch_url(session, url))
            tasks.append(task)
        sites = await asyncio.gather(*tasks)

    # Generate the HTML response
    response = '<h1>URLs:</h1>'
    for site in sites:
        response += f"<p>URL: {site['url']} --- Status Code: {site['status']}</p>"

    return response` 

你可以在 GitLab 上的 flask-async repo 中找到这个例子的源代码。

async_get_urls_v2()协程使用一种常见的 asyncio 模式:

  1. 创建多个异步任务(asyncio.create_task())
  2. 并发运行它们(asyncio.gather())

测试异步路由

您可以像通常使用 pytest 一样测试异步路由处理程序,因为 Flask 处理所有的异步处理:

`@pytest.fixture(scope='module')
def test_client():
    # Create a test client using the Flask application
    with app.test_client() as testing_client:
        yield testing_client  # this is where the testing happens!

def test_async_get_urls_v2(test_client):
    """
 GIVEN a Flask test client
 WHEN the '/async_get_urls_v2' page is requested (GET)
 THEN check that the response is valid
 """
    response = test_client.get('/async_get_urls_v2')
    assert response.status_code == 200
    assert b'URLs' in response.data` 

这是使用test_client fixture 对来自/async_get_urls_v2 URL 的有效响应的基本检查。

更多异步示例

在 Flask 2.0 中,请求回调也可以是异步的:

`# Helper Functions

async def load_user_from_database():
    """Mimics a long-running operation to load a user from an external database."""
    app.logger.info('Loading user from database...')
    await asyncio.sleep(1)

async def log_request_status():
    """Mimics a long-running operation to log the request status."""
    app.logger.info('Logging status of request...')
    await asyncio.sleep(1)

# Request Callbacks

@app.before_request
async def app_before_request():
    await load_user_from_database()

@app.after_request
async def app_after_request(response):
    await log_request_status()
    return response` 

错误处理程序还有:

`# Helper Functions

async def send_error_email(error):
    """Mimics a long-running operation to log the error."""
    app.logger.info('Logging status of error...')
    await asyncio.sleep(1)

# Error Handlers

@app.errorhandler(500)
async def internal_error(error):
    await send_error_email(error)
    return '500 error', 500` 

烧瓶 1.x 异步

通过使用asyncio.run()管理 asyncio 事件循环,您可以在 Flask 1.x 中模拟 Flask 2.0 异步支持:

`# Helper Functions

async def fetch_url(session, url):
    """Fetch the specified URL using the aiohttp session specified."""
    response = await session.get(url)
    return {'url': response.url, 'status': response.status}

async def get_all_urls():
    """Retrieve the list of URLs asynchronously using aiohttp."""
    async with ClientSession() as session:
        tasks = []
        for url in urls:
            task = asyncio.create_task(fetch_url(session, url))
            tasks.append(task)
        results = await asyncio.gather(*tasks)

    return results

# Routes

@app.route('/async_get_urls_v1')
def async_get_urls_v1():
    """Asynchronously retrieve the list of URLs (works in Flask 1.1.x when using threads)."""
    sites = asyncio.run(get_all_urls())

    # Generate the HTML response
    response = '<h1>URLs:</h1>'
    for site in sites:
        response += f"<p>URL: {site['url']} --- Status Code: {site['status']}</p>"
    return response` 

get_all_urls()协程实现了类似于async_get_urls_v2()路由处理程序的功能。

这是如何工作的?

为了让 asyncio 事件循环在 Flask 1.x 中正确运行,Flask 应用程序必须使用线程运行(Gunicorn、uWSGI 和 Flask 开发服务器的默认工作类型):

Flask 1.x Asynchronous Diagram

当请求被处理时,每个线程将运行 Flask 应用程序的一个实例。在每个线程中,为运行任何异步操作创建一个单独的 asyncio 事件循环。

测试协程

您可以使用 pytest-asyncio 来测试异步代码,如下所示:

`@pytest.mark.asyncio
async def test_fetch_url():
    """
 GIVEN an `asyncio` event loop
 WHEN the `fetch_url()` coroutine is called
 THEN check that the response is valid
 """
    async with aiohttp.ClientSession() as session:
        result = await fetch_url(session, 'https://www.kennedyrecipes.com/baked_goods/bagels/')

    assert str(result['url']) == 'https://www.kennedyrecipes.com/baked_goods/bagels/'
    assert int(result['status']) == 200` 

这个测试函数使用了@pytest.mark.asyncio decorator,它告诉 pytest 使用 asyncio 事件循环将协程作为 asyncio 任务执行。

结论

Flask 2.0 中添加的异步支持是一个惊人的特性!然而,只有当异步代码比等效的同步代码更有优势时,才应该使用异步代码。正如您所看到的,异步执行有意义的一个例子是当您必须在一个路由处理程序中进行多个 HTTP 调用时。

--

我使用 Flask 2.0 异步函数(async_get_urls_v2())和等效的同步函数进行了一些计时测试。我给每条路线打了十个电话:

类型 平均时间(秒) 平均时间(秒)
同步的 4.071443 3.419016
异步的 0.531841 0.406068

异步版本大约快 8 倍!因此,如果您必须在一个路由处理程序中进行多个外部 HTTP 调用,那么使用 asyncio 和 aiohttp 增加的复杂性肯定是合理的,因为执行时间显著减少了。

如果你想了解更多关于 Flask 的知识,一定要看看我的课程- 用 Python 和 Flask 开发 Web 应用

深入研究 Flask 的应用程序和请求上下文

原文:https://testdriven.io/blog/flask-contexts-advanced/

本文探讨了应用程序和请求上下文在 Flask 中是如何工作的。


这是关于 Flask 上下文的两部分系列的第二部分:

  1. 基础知识 : 理解 Flask 中的应用和请求上下文
  2. 高级 : 深入探讨 Flask 的应用和需求背景(本文!)

虽然您不必从第一篇文章开始,但建议至少回顾一下,为本文提供一点背景知识。

目标

在本文结束时,你应该能够解释:

  1. 什么是语境
  2. 哪些数据存储在应用程序和请求上下文中
  3. 在 Flask 中处理请求时,处理应用程序和请求上下文所需的步骤
  4. 如何使用应用程序和请求上下文的代理
  5. 如何在视图函数中使用current_apprequest代理
  6. 什么是局部上下文

什么是语境?

为了执行你写的代码,它需要数据来处理。这些数据可以是配置数据、输入数据、来自数据库的数据等等。

上下文用于跟踪你的代码需要执行的数据。

在 Flask 中,上下文用于提供必要的数据来处理请求和命令行界面(CLI)命令。

虽然本文的重点是处理请求,但是所介绍的概念也适用于 CLI 命令。

请求处理

让我们从一个高层次的请求是如何处理的开始:

Web Server, WSGI Server, and Flask App Diagram

因此,从浏览器向 web 服务器(如 Nginx 或 Apache)发送一个请求,请求一个特定的 URL(上图中的“/”URL)。然后,web 服务器将这个请求路由到 WSGI 服务器进行处理。

WSGI 代表 web 服务器网关接口,是 web 服务器和基于 Python 的 Web 应用程序之间的接口。这是必需的,因为网络服务器不能直接与 Python 应用程序对话。更多信息,请查看 WSGI

WSGI 服务器告诉 Flask 应用程序处理请求。

Flask 应用程序生成一个响应,该响应被发送回 WSGI 服务器和 web 服务器,并最终返回到 web 浏览器。

这些步骤描述了请求-响应周期,这是如何通过 web 服务器、WSGI 应用服务器和 web 应用程序处理请求的关键功能。

烧瓶中的上下文

当接收到请求时,Flask 提供两个上下文:

语境 描述 可用对象
应用 跟踪应用程序级数据(配置变量、记录器、数据库连接) current_appg
请求 跟踪请求级数据(URL、HTTP 方法、头、请求数据、会话信息) requestsession

值得注意的是,上述每个对象通常被称为“代理”。这仅仅意味着它们是对象全局风格的代理。我们稍后将对此进行更深入的探讨。

当收到请求时,Flask 处理这些上下文的创建。它们会造成混乱,因为根据应用程序所处的状态,您并不总是能够访问特定的对象。

概览图

下图说明了处理请求时如何处理上下文:

Request Processing - Overview

该图中有很多内容,因此我们将详细介绍每一步。

步骤 1 - Web 和 WSGI 服务器

当 web 服务器收到请求时,一切都开始了:

Request Processing - Step 1

web 服务器的工作是将传入的 HTTP 请求路由到一个 WSGI 服务器。

Apache 和 Nginx 是两种常见的 web 服务器,而 GunicornuWSGImod_wsgi 是流行的 WSGI 服务器。

值得注意的是,虽然 Flask 开发服务器是一个 WSGI 服务器,但它并不打算用于生产。

第二步-工人

为了处理请求,WSGI 服务器产生一个工作器来处理请求:

Request Processing - Step 2

工作者可以是线程、进程或协程。例如,如果您使用 Flask Development Server 的默认配置,那么工作线程就是线程。

如果您有兴趣了解更多关于 Python 中线程、多处理和异步的区别,请查看用并发、并行和异步加速 Python 的文章和 Python 中的并发视频

对于这个解释,工作者类型并不重要;关于 worker 的关键点是它一次处理一个请求(因此需要不止一个 worker)。

步骤 3 -背景

一旦执行切换到 Flask 应用,Flask 就创建应用请求上下文,并将它们推送到各自的堆栈上:

Request Processing - Step 3

回顾一下,应用程序上下文存储应用程序级别的数据,例如配置变量、数据库连接和记录器。同时,请求上下文存储需要处理的特定于请求的数据,以便生成响应。

这可能令人惊讶,但是两个栈都是作为全局对象实现的(这将在下一节中变得更清楚)。

第 4 步-代理

既然 Flask 应用程序已经准备好处理数据(在视图函数中),并且数据已经在应用程序和请求上下文堆栈中准备好了,我们需要一种方法来连接这两个部分...代理人来救援了!

Request Processing - Step 4

视图函数使用代理来访问应用程序(存储在应用程序上下文堆栈中)和请求上下文(存储在请求上下文堆栈中):

  • current_app -工作者应用程序上下文的代理
  • request -工作者请求上下文的代理

乍一看,这个序列可能会令人困惑,因为 view 函数似乎是通过代理访问全局对象(应用程序和请求上下文堆栈)。如果是这样的话,这个操作就会有问题,因为它不是线程安全的。您也可能认为这些堆栈(作为全局对象)可以被任何工作者访问,这是一个安全问题。

不过,这种设计是 Flask 的一大特色...栈被实现为上下文本地对象。

关于代理的更多信息,请查看 Flask 文档中关于代理的注释和 T2 的代理模式文章。

上下文局部变量

Python 有一个线程本地数据的概念,用于存储特定于线程的数据,这既是“线程安全的,也是线程唯一的”。换句话说,每个线程都能够以线程安全的方式访问数据,并且数据对于特定的线程总是唯一的。

Flask 实现了类似的行为(上下文局部变量),但是以一种更通用的方式允许工作线程、进程或协程。

Context-locals 实际上是在 Werkzeug 中实现的,它是组成 Flask 的关键包之一。为简单起见,在讨论上下文局部变量时,我们将引用 Flask。

当数据存储在本地上下文对象中时,数据的存储方式只有一个工作线程可以检索。因此,如果两个独立的工作者访问一个上下文本地对象,他们将各自得到自己的特定数据,这些数据对每个工作者来说是唯一的。

下一节将给出一个使用上下文本地对象的例子。

总的来说,current_apprequest代理在每个视图函数中都是可用的,它们用于从各自的堆栈中访问上下文,这些堆栈存储为上下文本地对象。

在应用程序和请求上下文堆栈的上下文中使用“stack”使得这个概念更加混乱。这些“堆栈”通常只存储一个上下文。

使用的数据结构是一个堆栈,因为有非常高级的场景(例如,内部重定向)需要不止一个元素。

Flask 中代理的好处

如果您要从头开始创建自己的 web 框架,您可以考虑将应用程序和请求上下文传递给每个视图函数,如下所示:

`@app.route('/add_item', methods=['GET', 'POST'])
def add_item(application_context, request_context):  # contexts passed in!
   if request_context.method == 'POST':
       # Save the form data to the database
       ...
       application_context.logger.info(f"Added new item ({ request_context.form['item_name'] })!")
       ...` 

事实上,许多 web 框架都是这样工作的(包括 Django )。

然而,Flask 提供了current_apprequest代理,它们最终看起来像视图函数的全局变量:

`from flask import current_app, request

@app.route('/add_item', methods=['GET', 'POST'])
def add_item():
   if request.method == 'POST':
       # Save the form data to the database
       ...
       current_app.logger.info(f"Added new item ({ request.form['item_name'] })!")
       ...` 

通过使用这种方法,view 函数不需要作为参数传入的上下文;这种方法有助于简化视图函数定义。这可能会造成混乱,因为您并不总是能够访问current_apprequest代理,这取决于您的应用程序所处的状态。

提醒:current_apprequest代理实际上不是全局变量;它们指向作为上下文局部变量实现的全局对象,所以代理对于每个工作者来说总是唯一的

第五步-清理

生成响应后,请求和应用程序上下文从各自的堆栈中弹出:

Request Processing - Step 5

这一步清理堆栈。

然后,响应被发送回 web 浏览器,从而完成对该请求的处理。

上下文-本地

上下文本地对象是使用本地对象实现的,可以这样创建:

`$ python

>>> from werkzeug.local import Local
>>> data = Local()
>>> data.user = '[[email protected]](/cdn-cgi/l/email-protection)'` 

每个上下文(即上一节中讨论的“工人”)都可以访问一个Local对象来存储上下文特有的数据。所访问的数据对于上下文是唯一的,并且只能由该上下文访问。

LocalStack 对象类似于Local对象,但是保留一个对象栈以允许push()pop()操作。

在上一节中,我们学习了在 Flask 中处理请求时如何利用应用程序上下文堆栈和请求上下文堆栈。这些堆栈在 Flask 的全局内存中被实现为LocalStack对象。

为了帮助巩固上下文局部变量的工作方式,我们来看一个例子,在全局内存中创建一个LocalStack对象,然后让三个单独的线程访问它:

LocalStack Example with Three Threads

下面是这个例子的完整脚本:

`"""
Example script to illustrate how a global `LocalStack` object can be used
when working with multiple threads.
"""
import random
import threading
import time

from werkzeug.local import LocalStack

# Create a global LocalStack object for storing data about each thread
thread_data_stack = LocalStack()

def long_running_function(thread_index: int):
    """Simulates a long-running function by using time.sleep()."""

    thread_data_stack.push({'index': thread_index, 'thread_id': threading.get_native_id()})
    print(f'Starting thread #{thread_index}... {thread_data_stack}')

    time.sleep(random.randrange(1, 11))

    print(f'LocalStack contains: {thread_data_stack.top}')
    print(f'Finished thread #{thread_index}!')
    thread_data_stack.pop()

if __name__ == "__main__":
    threads = []

    # Create and start 3 threads that each run long_running_function()
    for index in range(3):
        thread = threading.Thread(target=long_running_function, args=(index,))
        threads.append(thread)
        thread.start()

    # Wait until each thread terminates before the script exits by
    # 'join'ing each thread
    for thread in threads:
        thread.join()

    print('Done!')` 

这个文件创建了一个LocalStack对象(thread_data_stack),用于存储将要创建的每个线程的数据。

thread_data_stack模仿 Flask 中的应用上下文栈或请求上下文栈。

long_running_function在每个线程中运行:

`def long_running_function(thread_index: int):
    """Simulates a long-running function by using time.sleep()."""

    thread_data_stack.push({'index': thread_index, 'thread_id': threading.get_native_id()})
    print(f'Starting thread #{thread_index}... {thread_data_stack}')

    time.sleep(random.randrange(1, 11))

    print(f'LocalStack contains: {thread_data_stack.top}')
    print(f'Finished thread #{thread_index}!')
    thread_data_stack.pop()` 

该函数将关于线程的数据推送到全局内存中的thread_data_stack对象:

`thread_data_stack.push({'index': thread_index, 'thread_id': threading.get_native_id()})` 

这个操作模拟了应用程序或请求上下文被推送到它们各自的堆栈。

time.sleep()功能完成后,来自thread_data_stack的数据被访问:

`print(f'LocalStack contains: {thread_data_stack.top}')` 

这个操作模仿使用app_contextrequest代理,因为这些代理访问它们各自栈顶的数据。

函数结束时,数据从thread_data_stack弹出:

该操作模拟从各自的堆栈中弹出应用程序或请求上下文。

当脚本运行时,它将启动 3 个线程:

`# Create and start 3 threads that each run long_running_function()
for index in range(3):
    thread = threading.Thread(target=long_running_function, args=(index,))
    threads.append(thread)
    thread.start()` 

并且join每个线程,因此脚本等待直到每个线程完成执行:

`# Wait until each thread terminates before the script exits by
# 'join'ing each thread
for thread in threads:
    thread.join()` 

让我们运行这个脚本来看看会发生什么:

`$ python app.py

Starting thread #0... <werkzeug.local.LocalStack object at 0x109cebc40>
Starting thread #1... <werkzeug.local.LocalStack object at 0x109cebc40>
Starting thread #2... <werkzeug.local.LocalStack object at 0x109cebc40>
LocalStack contains: {'index': 0, 'thread_id': 320270}
Finished thread #0!
LocalStack contains: {'index': 1, 'thread_id': 320271}
Finished thread #1!
LocalStack contains: {'index': 2, 'thread_id': 320272}
Finished thread #2!
Done!` 

每个线程真正有趣的是它们都指向内存中同一个LocalStack对象:

`Starting thread #0... <werkzeug.local.LocalStack object at 0x109cebc40>
Starting thread #1... <werkzeug.local.LocalStack object at 0x109cebc40>
Starting thread #2... <werkzeug.local.LocalStack object at 0x109cebc40>` 

当每个线程访问thread_data_stack时,这个访问对于那个线程来说是唯一的!这就是LocalStack(和Local)的神奇之处——它们允许上下文特有的访问:

`LocalStack contains: {'index': 0, 'thread_id': 320270}
LocalStack contains: {'index': 1, 'thread_id': 320271}
LocalStack contains: {'index': 2, 'thread_id': 320272}` 

与典型的全局内存访问不同,对thread_data_stack的访问也是线程安全的。

结论

Flask 的一个强大(但令人困惑)的方面是如何处理应用程序和请求上下文。希望这篇文章对这个话题有所澄清!

应用程序和请求上下文在处理请求或 CLI 命令时提供必要的数据。确保使用current_apprequest代理来访问应用程序上下文和请求上下文。

想了解更多?看看下面这个来自 FlaskCon 2020 的视频:

如果您有兴趣了解有关 Flask 的更多信息,请查看我关于如何构建、测试和部署 Flask 应用程序的课程:

了解 Flask 中的应用程序和请求上下文

原文:https://testdriven.io/blog/flask-contexts/

这篇文章的目的是阐明应用程序和请求上下文在 Flask 中是如何工作的。


这是关于烧瓶环境的两部分系列的第一部分:

  1. 基础知识 : 理解 Flask 中的应用和请求上下文(本文!)
  2. 高级 : 深入了解 Flask 的应用和请求上下文

目标

在这篇文章结束时,你应该能够解释:

  1. Flask 如何处理请求对象,以及这与其他 web 框架有何不同
  2. 应用程序和请求上下文是什么
  3. 哪些数据存储在应用程序和请求上下文中
  4. 如何在正确的上下文中使用current_apptest_request_contexttest_client

您还应该能够修复以下错误:

`RuntimeError: Working outside of application context.

This typically means that you attempted to use functionality that needed
to interface with the current application object in some way. To solve
this, set up an application context with app.app_context().` 

烧瓶中的上下文

与 Django 和其他 web 框架不同,Flask view 函数不接受包含 HTTP 请求元数据的请求对象。

Django 示例:

`def users(request):
    if request.method == 'POST':
         # Save the form data to the database
         # Send response
   else:
         # Get all users from the database
         # Send response` 

使用 Flask,您可以像这样导入请求对象:

`from flask import request

@app.route('/users', methods=['GET', 'POST'])
def users():
    if request.method == 'POST':
         # Save the form data to the database
         # Send response
    else:
         # Get all users from the database
         # Send response` 

在 Flask 示例中,请求对象看起来、感觉起来和行为起来都像一个全局变量,但它不是。

如果请求对象是一个全局变量,您将无法运行多线程 Flask 应用程序,因为全局变量不是线程安全的。

相反,Flask 使用 contexts 来使许多对象像全局对象一样“行动”,只针对正在使用的特定上下文(线程、进程或协程)。在 Flask 中,这被称为上下文本地

上下文本地类似于 Python 的线程本地实现,用于存储特定于一个线程的数据,但本质上不同。Flask 的实现更加通用,允许工作线程、进程或协程。

存储在 Flask 上下文中的数据

当接收到请求时,Flask 提供两个上下文:

语境 描述 可用对象
应用 跟踪应用程序级数据(配置变量、记录器、数据库连接) current_appg
请求 跟踪请求级数据(URL、HTTP 方法、头、请求数据、会话信息) requestsession

值得注意的是,上述每个对象通常被称为“代理”。这仅仅意味着它们是对象全局风格的代理。关于这方面的更多信息,请查看本系列的第二篇文章。

当收到请求时,Flask 处理这些上下文的创建。它们会造成混乱,因为根据应用程序所处的状态,您并不总是能够访问特定的对象。

我们来看几个例子。

应用程序上下文示例

假设您有以下 Flask 应用程序:

`from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Welcome!'

if __name__ == '__main__':
    app.run()` 

首先,让我们看看如何使用 current_app 对象来访问应用程序上下文。

在 Python shell 中,如果您试图在视图函数之外访问current_app.config对象,您应该会看到以下错误:

`$ python
>>> from flask import current_app
>>> current_app.config

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "werkzeug/local.py", line 347, in __getattr__
    return getattr(self._get_current_object(), name)
  File "werkzeug/local.py", line 306, in _get_current_object
    return self.__local()
  File "flask/globals.py", line 52, in _find_app
    raise RuntimeError(_app_ctx_err_msg)
RuntimeError: Working outside of application context.

This typically means that you attempted to use functionality that needed
to interface with the current application object in some way. To solve
this, set up an application context with app.app_context().  See the
documentation for more information.` 

要访问应用程序公开的对象并请求视图函数之外的上下文,您需要首先创建适当的上下文:

`# without a context manager
$ python

>>> from app import app
>>> from flask import current_app
>>>
>>> app_ctx = app.app_context()
>>> app_ctx.push()
>>>
>>> current_app.config["ENV"]
'production'
>>> app_ctx.pop()
>>>` 
`# with a context manager
$ python

>>> from app import app
>>> from flask import current_app
>>>
>>> with app.app_context():
...     current_app.config["ENV"]
...
'production'
>>>` 

请求上下文示例

您可以使用 test_request_context 方法来创建一个请求上下文:

`# without a context manager
$ python

>>> from app import app
>>> from flask import request
>>>
>>> request_ctx = app.test_request_context()
>>> request_ctx.push()
>>>
>>> request.method
'GET'
>>>
>>> request.path
'/'
>>>
>>> request_ctx.pop()
>>>` 
`# with a context manager
$ python

>>> from app import app
>>> from flask import request
>>>
>>> with app.test_request_context('/'):
...     request.method
...     request.path
...
'GET'
'/'
>>>` 

当您想要使用请求数据而没有完整请求的开销时,通常在测试期间使用test_request_context

测试示例

应用程序和请求上下文最常遇到的问题是当您的应用程序处于测试状态时:

`import pytest
from flask import current_app

from app import app

@pytest.fixture
def client():
    with app.test_client() as client:
        assert current_app.config["ENV"] == "production"  # Error!
        yield client

def test_index_page(client):
   response = client.get('/')

   assert response.status_code == 200
   assert b'Welcome!' in response.data` 

运行时,测试将在夹具中失败:

`$ pytest
________________________ ERROR at setup of test_index_page _____________________

@pytest.fixture
def client():
    with app.test_client() as client:
>       assert current_app.config["ENV"] == "production"
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
    def _find_app():
        top = _app_ctx_stack.top
        if top is None:
>           raise RuntimeError(_app_ctx_err_msg)
E           RuntimeError: Working outside of application context.
E
E           This typically means that you attempted to use functionality that needed
E           to interface with the current application object in some way. To solve
E           this, set up an application context with app.app_context().  See the
E           documentation for more information.
================================= 1 error in 0.13s =================================` 

要解决这个问题,请在访问current_app之前创建一个应用程序上下文:

`import pytest
from flask import current_app

from app import app

@pytest.fixture
def client():
    with app.test_client() as client:
        with app.app_context():  # New!!
            assert current_app.config["ENV"] == "production"
        yield client

def test_index_page(client):
   response = client.get('/')

   assert response.status_code == 200
   assert b'Welcome!' in response.data` 

摘要

总而言之,在视图函数、CLI 命令和测试函数中使用以下对象:

目标 语境 常见错误 解决办法
current_app 应用程序上下文 在应用程序上下文之外工作 with app.app_context():
g 应用程序上下文 在应用程序上下文之外工作 with app.test_request_context('/'):
request 请求上下文 在请求上下文之外工作 with app.test_request_context('/'):
session 请求上下文 在请求上下文之外工作 with app.test_request_context('/'):

测试期间应使用以下方法:

烧瓶法 描述
test_client Flask 应用程序的测试客户端
test_request_context 用于测试的推送请求上下文

结论

这篇博文只是触及了应用程序和请求上下文的表面。请务必阅读本系列的第二部分以了解更多:深入了解 Flask 的应用和请求上下文

查看以下课程,了解如何构建、测试和部署 Flask 应用程序:

带有 Postgres、Gunicorn 和 Traefik 的多用途烧瓶

原文:https://testdriven.io/blog/flask-docker-traefik/

在本教程中,我们将看看如何用 Postgres 和 Docker 设置 Flask。对于生产环境,我们将添加 Gunicorn、Traefik,并进行加密。

项目设置

首先创建一个项目目录:

`$ mkdir flask-docker-traefik && cd flask-docker-traefik
$ python3.9 -m venv venv
$ source venv/bin/activate
(venv)$` 

你可以随意把 virtualenv 和 Pip 换成诗歌 Pipenv 。更多信息,请查看现代 Python 环境

然后,创建以下文件和文件夹:

`└── services
    └── web
        ├── manage.py
        ├── project
        │   └── __init__.py
        └── requirements.txt` 

烧瓶添加到要求. txt 中:

从“服务/web”安装软件包:

`(venv)$ pip install -r requirements.txt` 

接下来,让我们在 init.py 中创建一个简单的 Flask 应用程序:

`from flask import Flask, jsonify

app = Flask(__name__)

@app.get("/")
def read_root():
    return jsonify(hello="world")` 

然后,要配置 Flask CLI 工具从命令行运行和管理应用程序,请将以下内容添加到 services/web/manage.py :

`from flask.cli import FlaskGroup

from project import app

cli = FlaskGroup(app)

if __name__ == "__main__":
    cli()` 

这里,我们创建了一个新的FlaskGroup实例,用与 Flask 应用程序相关的命令来扩展普通 CLI。

从“web”目录运行服务器:

`(venv)$ export FLASK_APP=project/__init__.py
(venv)$ python manage.py run` 

导航到 127.0.0.1:5000 ,您应该看到:

一旦完成就杀死服务器。退出虚拟环境,并将其删除。

码头工人

安装 Docker ,如果你还没有,那么在“web”目录下添加一个 Dockerfile :

`# pull the official docker image
FROM  python:3.9.5-slim

# set work directory
WORKDIR  /app

# set env variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# install dependencies
COPY  requirements.txt .
RUN  pip install -r requirements.txt

# copy project
COPY  . .` 

所以,我们从 Python 3.9.5 的基于slimDocker 镜像开始。然后我们设置一个工作目录以及两个环境变量:

  1. PYTHONDONTWRITEBYTECODE:防止 Python 将 pyc 文件写入磁盘(相当于python -B 选项
  2. PYTHONUNBUFFERED:防止 Python 缓冲 stdout 和 stderr(相当于python -u 选项

最后,我们复制了 requirements.txt 文件,安装了依赖项,并复制了 Flask 应用程序本身。

查看Docker for Python Developers了解更多关于构造 Docker 文件的信息,以及为基于 Python 的开发配置 Docker 的一些最佳实践。

接下来,将一个 docker-compose.yml 文件添加到项目根:

`version:  '3.8' services: web: build:  ./services/web command:  python manage.py run -h 0.0.0.0 volumes: -  ./services/web/:/app ports: -  5000:5000 environment: -  FLASK_APP=project/__init__.py -  FLASK_ENV=development` 

查看合成文件参考,了解该文件如何工作的信息。

建立形象:

构建映像后,运行容器:

导航到 http://127.0.0.1:5000/ 再次查看 hello world 健全性检查。

如果这不起作用,通过docker-compose logs -f检查日志中的错误。

Postgres

要配置 Postgres,我们需要在 docker-compose.yml 文件中添加一个新服务,设置 Flask-SQLAlchemy ,安装 Psycopg2

首先,向 docker-compose.yml 添加一个名为db的新服务:

`version:  '3.8' services: web: build:  ./services/web command:  bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; python manage.py run -h 0.0.0.0' volumes: -  ./services/web/:/app ports: -  5000:5000 environment: -  FLASK_APP=project/__init__.py -  FLASK_ENV=development -  DATABASE_URL=postgresql://hello_flask:[[email protected]](/cdn-cgi/l/email-protection):5432/hello_flask_dev depends_on: -  db db: image:  postgres:13-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ environment: -  POSTGRES_USER=hello_flask -  POSTGRES_PASSWORD=hello_flask -  POSTGRES_DB=hello_flask_dev volumes: postgres_data:` 

为了在容器的生命周期之外保存数据,我们配置了一个卷。这个配置将把postgres_data绑定到容器中的“/var/lib/postgresql/data/”目录。

我们还添加了一个环境键来定义默认数据库的名称,并设置用户名和密码。

查看 Postgres Docker Hub 页面的“环境变量”部分了解更多信息。

注意web服务中的新命令:

`bash  -c  'while !</dev/tcp/db/5432; do sleep 1; done; python manage.py run -h 0.0.0.0'` 

将持续到 Postgres 完成。一旦启动,python manage.py run -h 0.0.0.0就会运行。

然后,将一个名为 config.py 的新文件添加到“项目”目录中,在这里我们将定义特定于环境的配置变量:

`import os

class Config(object):
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite://")
    SQLALCHEMY_TRACK_MODIFICATIONS = False` 

这里,数据库是基于我们刚刚定义的DATABASE_URL环境变量配置的。记下默认值。

更新 init。py 在 init 上拉入配置:

`from flask import Flask, jsonify

app = Flask(__name__)
app.config.from_object("project.config.Config")

@app.get("/")
def read_root():
    return jsonify(hello="world")` 

Flask-SQLAlchemyPsycopg2 添加到 requirements.txt :

`Flask==2.0.1
Flask-SQLAlchemy==2.5.1
psycopg2-binary==2.8.6` 

更新 init。py 再次创建一个新的SQLAlchemy实例并定义一个数据库模型:

`from dataclasses import dataclass

from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config.from_object("project.config.Config")
db = SQLAlchemy(app)

@dataclass
class User(db.Model):
    id: int = db.Column(db.Integer, primary_key=True)
    email: str = db.Column(db.String(120), unique=True, nullable=False)
    active: bool = db.Column(db.Boolean(), default=True, nullable=False)

    def __init__(self, email: str) -> None:
        self.email = email

@app.get("/")
def read_root():
    users = User.query.all()
    return jsonify(users)` 

在数据库模型上使用 dataclass decorator 有助于我们序列化数据库对象。

最后,更新 manage.py :

`from flask.cli import FlaskGroup

from project import app, db

cli = FlaskGroup(app)

@cli.command("create_db")
def create_db():
    db.drop_all()
    db.create_all()
    db.session.commit()

if __name__ == "__main__":
    cli()` 

这向 CLI 注册了一个新命令create_db,以便我们可以从命令行运行它,稍后我们将使用它将模型应用到数据库。

构建新的映像并旋转两个容器:

`$ docker-compose up -d --build` 

创建表格:

`$ docker-compose exec web python manage.py create_db` 

得到以下错误?

sqlalchemy.exc.OperationalError: (psycopg2.OperationalError)
FATAL:  database "hello_flask_dev" does not exist 

运行docker-compose down -v移除卷和容器。然后,重新构建映像,运行容器,并应用迁移。

确保users表已创建:

`$ docker-compose exec db psql --username=hello_flask --dbname=hello_flask_dev

psql (13.3)
Type "help" for help.

hello_flask_dev=# \l
                                        List of databases
      Name       |    Owner    | Encoding |  Collate   |   Ctype    |      Access privileges
-----------------+-------------+----------+------------+------------+-----------------------------
 hello_flask_dev | hello_flask | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres        | hello_flask | UTF8     | en_US.utf8 | en_US.utf8 |
 template0       | hello_flask | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_flask             +
                 |             |          |            |            | hello_flask=CTc/hello_flask
 template1       | hello_flask | UTF8     | en_US.utf8 | en_US.utf8 | =c/hello_flask             +
                 |             |          |            |            | hello_flask=CTc/hello_flask
(4 rows)

hello_flask_dev=# \c hello_flask_dev
You are now connected to database "hello_flask_dev" as user "hello_flask".

hello_flask_dev=# \dt
          List of relations
 Schema | Name | Type  |    Owner
--------+------+-------+-------------
 public | user | table | hello_flask
(1 row)

hello_flask_dev=# \q` 

您也可以通过运行以下命令来检查该卷是否已创建:

`$ docker volume inspect flask-docker-traefik_postgres_data` 

您应该会看到类似如下的内容:

`[
    {
        "CreatedAt": "2021-06-05T14:12:52Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "flask-docker-traefik",
            "com.docker.compose.version": "1.29.1",
            "com.docker.compose.volume": "postgres_data"
        },
        "Mountpoint": "/var/lib/docker/volumes/flask-docker-traefik_postgres_data/_data",
        "Name": "flask-docker-traefik_postgres_data",
        "Options": null,
        "Scope": "local"
    }
]` 

导航到 http://127.0.0.1:5000 。健全性检查显示一个空列表。这是因为我们还没有填充users表。让我们添加一个 CLI 种子命令,用于将样本users添加到 manage.py 中的用户表:

`from flask.cli import FlaskGroup

from project import User, app, db

cli = FlaskGroup(app)

@cli.command("create_db")
def create_db():
    db.drop_all()
    db.create_all()
    db.session.commit()

@cli.command("seed_db") # new
def seed_db():
    db.session.add(User(email="[[email protected]](/cdn-cgi/l/email-protection)"))
    db.session.add(User(email="[[email protected]](/cdn-cgi/l/email-protection)"))
    db.session.commit()

if __name__ == "__main__":
    cli()` 

尝试一下:

`$ docker-compose exec web python manage.py seed_db` 

再次导航到 http://127.0.0.1:5000 。您现在应该看到:

格尼科恩

接下来,对于生产环境,让我们将 Gunicorn ,一个生产级的 WSGI 服务器,添加到需求文件中:

`Flask==2.0.1
Flask-SQLAlchemy==2.5.1
gunicorn==20.1.0
psycopg2-binary==2.8.6` 

因为我们仍然希望在开发中使用 Flask 的内置服务器,所以在项目根目录中创建一个名为 docker-compose.prod.yml 的新合成文件用于生产:

`version:  '3.8' services: web: build:  ./services/web command:  bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; gunicorn --bind 0.0.0.0:5000 manage:app' ports: -  5000:5000 environment: -  FLASK_APP=project/__init__.py -  FLASK_ENV=production -  DATABASE_URL=postgresql://hello_flask:[[email protected]](/cdn-cgi/l/email-protection):5432/hello_flask_prod depends_on: -  db db: image:  postgres:13-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ environment: -  POSTGRES_USER=hello_flask -  POSTGRES_PASSWORD=hello_flask -  POSTGRES_DB=hello_flask_prod volumes: postgres_data_prod:` 

如果您有多个环境,您可能希望使用一个docker-compose . override . yml配置文件。使用这种方法,您可以将您的基本配置添加到一个 docker-compose.yml 文件中,然后使用一个docker-compose . override . yml文件根据环境覆盖那些配置设置。

记下默认值command。我们运行的是 Gunicorn,而不是 Flask 开发服务器。我们还从web服务中删除了这个卷,因为我们在生产中不需要它。

关闭开发容器(以及带有-v 标志的相关卷):

然后,构建生产映像并启动容器:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

创建表格并应用种子:

`$ docker-compose -f docker-compose.prod.yml exec web python manage.py create_db
$ docker-compose -f docker-compose.prod.yml exec web python manage.py seed_db` 

验证hello_flask_prod数据库是与users表一起创建的。测试出 http://127.0.0.1:5000/

同样,如果容器启动失败,通过docker-compose -f docker-compose.prod.yml logs -f检查日志中的错误。

生产文档

在“web”目录中创建一个名为 Dockerfile.prod 的新 Dockerfile,用于生产构建:

`###########
# BUILDER #
###########

# pull official base image
FROM  python:3.9.5-slim  as  builder

# set work directory
WORKDIR  /usr/src/app

# set environment variables
ENV  PYTHONDONTWRITEBYTECODE 1
ENV  PYTHONUNBUFFERED 1

# install system dependencies
RUN  apt-get update && \
    apt-get install -y --no-install-recommends gcc

# lint
RUN  pip install --upgrade pip
RUN  pip install flake8==3.9.1
COPY  . .
RUN  flake8 --ignore=E501,F401 .

# install python dependencies
COPY  ./requirements.txt .
RUN  pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt

#########
# FINAL #
#########

# pull official base image
FROM  python:3.9.5-slim

# create directory for the app user
RUN  mkdir -p /home/app

# create the app user
RUN  addgroup --system app && adduser --system --group app

# create the appropriate directories
ENV  HOME=/home/app
ENV  APP_HOME=/home/app/web
RUN  mkdir $APP_HOME
WORKDIR  $APP_HOME

# install dependencies
RUN  apt-get update && apt-get install -y --no-install-recommends netcat
COPY  --from=builder /usr/src/app/wheels /wheels
COPY  --from=builder /usr/src/app/requirements.txt .
RUN  pip install --upgrade pip
RUN  pip install --no-cache /wheels/*

# copy project
COPY  . $APP_HOME

# chown all the files to the app user
RUN  chown -R app:app $APP_HOME

# change to the app user
USER  app` 

在这里,我们使用了一个 Docker 多阶段构建来缩小最终的图像尺寸。本质上,builder是一个用于构建 Python 轮子的临时图像。然后车轮被复制到最终产品图像中,而builder图像被丢弃。

您可以将多阶段构建方法更进一步,使用单个 docker 文件,而不是创建两个 docker 文件。思考在两个不同的文件上使用这种方法的利弊。

您是否注意到我们创建了一个非 root 用户?默认情况下,Docker 在容器内部以 root 用户身份运行容器进程。这是一种不好的做法,因为如果攻击者设法突破容器,他们可以获得 Docker 主机的根用户访问权限。如果您是容器中的 root 用户,那么您将是主机上的 root 用户。

Dockerfile.prod 更新 docker-compose.prod.yml 文件中的web服务:

`web: build: context:  ./services/web dockerfile:  Dockerfile.prod command:  bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; gunicorn --bind 0.0.0.0:5000 manage:app' ports: -  5000:5000 environment: -  FLASK_APP=project/__init__.py -  FLASK_ENV=production -  DATABASE_URL=postgresql://hello_flask:[[email protected]](/cdn-cgi/l/email-protection):5432/hello_flask_prod depends_on: -  db` 

尝试一下:

`$ docker-compose -f docker-compose.prod.yml down -v
$ docker-compose -f docker-compose.prod.yml up -d --build
$ docker-compose -f docker-compose.prod.yml exec web python manage.py create_db
$ docker-compose -f docker-compose.prod.yml exec web python manage.py seed_db` 

Traefik

接下来,让我们添加 Traefik ,一个反向代理

刚接触 Traefik?查看官方入门指南。

Traefik vs Nginx : Traefik 是一个现代的、HTTP 反向代理和负载平衡器。它经常被比作 Nginx ,一个网络服务器和反向代理。由于 Nginx 主要是一个网络服务器,它可以用来提供网页,也可以作为一个反向代理和负载平衡器。总的来说,Traefik 的启动和运行更简单,而 Nginx 的功能更丰富。

Traefik :

  1. 反向代理和负载平衡器
  2. 通过开箱即用的让我们加密,自动发布和更新 SSL 证书
  3. 将 Traefik 用于简单的、基于 Docker 的微服务

Nginx :

  1. Web 服务器、反向代理和负载平衡器
  2. 比 Traefik 稍快
  3. 对复杂的服务使用 Nginx

将名为“traefik”的新文件夹与以下文件一起添加到“services”目录中:

`traefik
├── Dockerfile.traefik
├── traefik.dev.toml
└── traefik.prod.toml` 

您的项目结构现在应该如下所示:

`├── docker-compose.prod.yml
├── docker-compose.yml
└── services
    ├── traefik
    │   ├── Dockerfile.traefik
    │   ├── traefik.dev.toml
    │   └── traefik.prod.toml
    └── web
        ├── Dockerfile
        ├── Dockerfile.prod
        ├── manage.py
        ├── project
        │   ├── __init__.py
        │   └── config.py
        └── requirements.txt` 

将以下内容添加到 traefik.dev.toml 中:

`# listen on port 80 [entryPoints] [entryPoints.web] address  =  ":80" # Traefik dashboard over http [api] insecure  =  true [log] level  =  "DEBUG" [accessLog] # containers are not discovered automatically [providers] [providers.docker] exposedByDefault  =  false` 

在这里,由于我们不想公开db服务,我们将 exposedByDefault 设置为false。要手动公开服务,我们可以将"traefik.enable=true"标签添加到 Docker 组合文件中。

接下来,更新 docker-compose.yml 文件,以便 Traefik 发现我们的web服务并添加一个新的traefik服务:

`version:  '3.8' services: web: build:  ./services/web command:  bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; python manage.py run -h 0.0.0.0' volumes: -  ./services/web/:/app expose:  # new -  5000 environment: -  FLASK_APP=project/__init__.py -  FLASK_ENV=development -  DATABASE_URL=postgresql://hello_flask:[[email protected]](/cdn-cgi/l/email-protection):5432/hello_flask_dev depends_on: -  db labels:  # new -  "traefik.enable=true" -  "traefik.http.routers.flask.rule=Host(`flask.localhost`)" db: image:  postgres:13-alpine volumes: -  postgres_data:/var/lib/postgresql/data/ environment: -  POSTGRES_USER=hello_flask -  POSTGRES_PASSWORD=hello_flask -  POSTGRES_DB=hello_flask_dev traefik:  # new image:  traefik:v2.2 ports: -  80:80 -  8081:8080 volumes: -  "./services/traefik/traefik.dev.toml:/etc/traefik/traefik.toml" -  "/var/run/docker.sock:/var/run/docker.sock:ro" volumes: postgres_data:` 

首先,web服务只对端口5000上的其他容器公开。我们还为web服务添加了以下标签:

  1. traefik.enable=true使 Traefik 能够发现服务
  2. traefik.http.routers.flask.rule=Host(flask.localhost)当请求有Host=flask.localhost时,请求被重定向到该服务

记下traefik服务中的卷:

  1. 将本地配置文件映射到容器中的配置文件,以便保持设置同步
  2. /var/run/docker.sock:/var/run/docker.sock:ro使 Traefik 能够发现其他容器

要进行测试,首先取下任何现有的容器:

`$ docker-compose down -v
$ docker-compose -f docker-compose.prod.yml down -v` 

构建新的开发映像并启动容器:

`$ docker-compose up -d --build` 

创建表格并应用种子:

`$ docker-compose exec web python manage.py create_db
$ docker-compose exec web python manage.py seed_db` 

导航到 http://flask.localhost 。您应该看到:

您也可以通过 cURL 进行测试:

`$ curl -H Host:flask.localhost http://0.0.0.0` 

接下来,在查看仪表盘http://flask . localhost:8081:

traefik dashboard

完成后,将容器和体积拿下来:

让我们加密

我们已经在开发模式下成功地创建了 Flask、Docker 和 Traefik 的工作示例。对于生产,您需要配置 Traefik 来通过 Let's Encrypt 管理 TLS 证书。简而言之,Traefik 将自动联系证书颁发机构来颁发和续订证书。

因为 Let's Encrypt 不会为localhost颁发证书,所以你需要在云计算实例(比如 DigitalOcean droplet 或 AWS EC2 实例)上运行你的生产容器。您还需要一个有效的域名。如果你没有,你可以在 Freenom 创建一个免费域名。

我们使用了一个 DigitalOcean droplet 和 Docker machine 来快速配置 Docker 的计算实例,并部署了生产容器来测试 Traefik 配置。查看 Docker 文档中的 DigitalOcean 示例,了解更多关于使用 Docker 机器供应 droplet 的信息。

假设您配置了一个计算实例并设置了一个自由域,那么现在就可以在生产模式下设置 Traefik 了。

首先将 Traefik 配置的生产版本添加到 traefik.prod.toml :

`[entryPoints] [entryPoints.web] address  =  ":80" [entryPoints.web.http] [entryPoints.web.http.redirections] [entryPoints.web.http.redirections.entryPoint] to  =  "websecure" scheme  =  "https" [entryPoints.websecure] address  =  ":443" [accessLog] [api] dashboard  =  true [providers] [providers.docker] exposedByDefault  =  false [certificatesResolvers.letsencrypt.acme] email  =  "[[email protected]](/cdn-cgi/l/email-protection)" storage  =  "/certificates/acme.json" [certificatesResolvers.letsencrypt.acme.httpChallenge] entryPoint  =  "web"` 

确保用您的实际电子邮件地址替换[[email protected]](/cdn-cgi/l/email-protection)

这里发生了什么:

  1. 将我们不安全的 HTTP 应用程序的入口点设置为端口 80
  2. 将我们的安全 HTTPS 应用程序的入口点设置为端口 443
  3. 将所有不安全的请求重定向到安全端口
  4. exposedByDefault = false取消所有服务
  5. dashboard = true启用监控仪表板

最后,请注意:

`[certificatesResolvers.letsencrypt.acme] email  =  "[[email protected]](/cdn-cgi/l/email-protection)" storage  =  "/certificates/acme.json" [certificatesResolvers.letsencrypt.acme.httpChallenge] entryPoint  =  "web"` 

这是让我们加密配置的地方。我们定义了证书的存储位置(T1)和验证类型(T3),这是一个 HTTP 挑战(T5)。

接下来,假设您更新了域名的 DNS 记录,创建两个新的 A 记录,它们都指向您的计算实例的公共 IP:

  1. flask-traefik.your-domain.com -用于网络服务
  2. dashboard-flask-traefik.your-domain.com -用于 Traefik 仪表板

确保用您的实际域名替换your-domain.com

接下来,像这样更新 docker-compose.prod.yml :

`version:  '3.8' services: web: build: context:  ./services/web dockerfile:  Dockerfile.prod command:  bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; gunicorn --bind 0.0.0.0:5000 manage:app' expose:  # new -  5000 environment: -  FLASK_APP=project/__init__.py -  FLASK_ENV=production -  DATABASE_URL=postgresql://hello_flask:[[email protected]](/cdn-cgi/l/email-protection):5432/hello_flask_prod depends_on: -  db labels:  # new -  "traefik.enable=true" -  "traefik.http.routers.flask.rule=Host(`flask-traefik.your-domain.com`)" -  "traefik.http.routers.flask.tls=true" -  "traefik.http.routers.flask.tls.certresolver=letsencrypt" db: image:  postgres:13-alpine volumes: -  postgres_data_prod:/var/lib/postgresql/data/ environment: -  POSTGRES_USER=hello_flask -  POSTGRES_PASSWORD=hello_flask -  POSTGRES_DB=hello_flask_prod traefik:  # new build: context:  ./services/traefik dockerfile:  Dockerfile.traefik ports: -  80:80 -  443:443 volumes: -  "/var/run/docker.sock:/var/run/docker.sock:ro" -  "./traefik-public-certificates:/certificates" labels: -  "traefik.enable=true" -  "traefik.http.routers.dashboard.rule=Host(`dashboard-flask-traefik.your-domain.com`)" -  "traefik.http.routers.dashboard.tls=true" -  "traefik.http.routers.dashboard.tls.certresolver=letsencrypt" -  "[[email protected]](/cdn-cgi/l/email-protection)" -  "traefik.http.routers.dashboard.middlewares=auth" -  "traefik.http.middlewares.auth.basicauth.users=testuser:$$apr1$$jIKW.bdS$$eKXe4Lxjgy/rH65wP1iQe1" volumes: postgres_data_prod: traefik-public-certificates:` 

同样,确保用您的实际域名替换your-domain.com

这里有什么新鲜事?

web服务中,我们添加了以下标签:

  1. traefik.http.routers.flask.rule=Host(flask-traefik.your-domain.com)将主机更改为实际的域
  2. traefik.http.routers.flask.tls=true启用 HTTPS
  3. traefik.http.routers.flask.tls.certresolver=letsencrypt将证书颁发者设置为让我们加密

接下来,对于traefik服务,我们为证书目录添加了适当的端口和一个卷。该卷确保即使容器关闭,证书仍然有效。

至于标签:

  1. traefik.http.routers.dashboard.rule=Host(dashboard-flask-traefik.your-domain.com)定义仪表板主机,因此可以在$Host/dashboard/访问
  2. traefik.http.routers.dashboard.tls=true启用 HTTPS
  3. traefik.http.routers.dashboard.tls.certresolver=letsencrypt将证书解析器设置为“让我们加密”
  4. traefik.http.routers.dashboard.middlewares=auth启用HTTP BasicAuth中间件
  5. traefik.http.middlewares.auth.basicauth.users定义用于登录的用户名和散列密码

您可以使用 htpasswd 实用程序创建新的密码哈希:

`# username: testuser
# password: password

$ echo $(htpasswd -nb testuser password) | sed -e s/\\$/\\$\\$/g
testuser:$$apr1$$jIKW.bdS$$eKXe4Lxjgy/rH65wP1iQe1` 

随意使用一个env_file来存储用户名和密码作为环境变量

`USERNAME=testuser
HASHED_PASSWORD=$$apr1$$jIKW.bdS$$eKXe4Lxjgy/rH65wP1iQe1` 

Update Dockerfile.traefik :

`FROM  traefik:v2.2

COPY  ./traefik.prod.toml ./etc/traefik/traefik.toml` 

接下来,旋转新容器:

`$ docker-compose -f docker-compose.prod.yml up -d --build` 

创建表格并应用种子:

`$ docker-compose -f docker-compose.prod.yml exec web python manage.py create_db
$ docker-compose -f docker-compose.prod.yml exec web python manage.py seed_db` 

确保这两个 URL 有效:

  1. https://flask-traefik.your-domain.com
  2. https://dashboard-flask-traefik.your-domain.com/dashboard/

此外,请确保当您访问上述网址的 HTTP 版本时,您会被重定向到 HTTPS 版本。

最后,让我们加密有效期为 90 天的证书。Treafik 将在后台自动为您处理证书更新,这样您就少了一件担心的事情!

结论

在本教程中,我们介绍了如何用 Postgres 封装 Flask 应用程序以进行开发。我们还创建了一个生产就绪的 Docker 组合文件,设置了 Traefik 和 Let's Encrypt 来通过 HTTPS 为应用程序提供服务,并启用了一个安全的仪表板来监控我们的服务。

就生产环境的实际部署而言,您可能希望使用:

  1. 完全托管的数据库服务——像 RDS云 SQL——而不是在一个容器中管理你自己的 Postgres 实例。
  2. 服务的非根用户

你可以在flask-docker-traefikrepo 中找到代码。

将烧瓶应用程序部署到弹性豆茎

原文:https://testdriven.io/blog/flask-elastic-beanstalk/

在本教程中,我们将逐步完成将一个 Flask 应用程序部署到 AWS Elastic Beanstalk 的过程。

目标

本教程结束时,您将能够:

  1. 解释什么是弹性豆茎
  2. 初始化和配置弹性豆茎
  3. 对运行在 Elastic Beanstalk 上的应用程序进行故障排除
  4. 将弹性豆茎与 RDS 结合
  5. 通过 AWS 证书管理器获取 SSL 证书
  6. 使用 SSL 证书在 HTTPS 上提供您的应用程序

什么是弹性豆茎?

AWS Elastic Beanstalk (EB)是一个易于使用的服务,用于部署和扩展 web 应用程序。它连接多个 AWS 服务,例如计算实例( EC2 )、数据库( RDS )、负载平衡器(应用负载平衡器)和文件存储系统( S3 ),等等。EB 允许您快速开发和部署 web 应用程序,而无需考虑底层基础设施。它支持用 Go、Java、.NET、Node.js、PHP、Python 和 Ruby。如果您需要配置自己的软件栈或部署用 EB 目前不支持的语言(或版本)开发的应用程序,EB 也支持 Docker。

典型的弹性豆茎设置:

Elastic Beanstalk Architecture

AWS 弹性豆茎不另收费。您只需为应用程序消耗的资源付费。

要了解更多关于弹性豆茎的信息,请查看什么是 AWS 弹性豆茎?来自官方 AWS 弹性豆茎文档

弹性豆茎概念

在开始学习教程之前,让我们先来看看与 Elastic Beanstalk 相关的几个关键概念:

  1. 一个 应用 是弹性 Beanstalk 组件的逻辑集合,包括环境、版本和环境配置。一个应用程序可以有多个版本
  2. 一个 环境 是运行一个应用版本的 AWS 资源的集合。
  3. 一个 平台 是操作系统、编程语言运行时、web 服务器、应用服务器和弹性 Beanstalk 组件的组合。

这些术语将在整个教程中使用。

项目设置

在本教程中,我们将部署一个简单的 Flask 应用程序,名为 flask-movies

按照教程进行操作时,通过部署您自己的应用程序来检查您的理解。

首先,从 GitHub 上的库获取代码:

创建新的虚拟环境并激活它:

`$ python3 -m venv venv && source venv/bin/activate` 

安装需求并初始化数据库:

`(venv)$ pip install -r requirements.txt
(venv)$ python init_db.py` 

运行服务器:

打开您最喜欢的 web 浏览器,导航至:

  1. http://localhost:5000 -应该显示“烧瓶-电影”文本
  2. http://localhost:5000/API/movies-应该显示电影列表

弹性豆茎 CLI

在继续之前,请务必在注册一个 AWS 帐户。通过创建一个账户,你可能也有资格加入 AWS 免费等级

Elastic Beanstalk 命令行界面 (EB CLI)允许您执行各种操作来部署和管理您的 Elastic Beanstalk 应用程序和环境。

有两种安装 EB CLI 的方法:

  1. 通过 EB CLI 安装程序
  2. pip (awsebcli)

建议使用安装程序(第一个选项)全局安装 EB CLI(任何特定虚拟环境之外),以避免可能的依赖冲突。更多详情请参考本解释

安装 EB CLI 后,您可以通过运行以下命令来检查版本:

`$ eb --version

EB CLI 3.20.3 (Python 3.10.)` 

如果该命令不起作用,您可能需要将 EB CLI 添加到$PATH中。

EB CLI 命令列表及其描述可在 EB CLI 命令参考中找到。

初始化弹性豆茎

一旦我们运行了 EB CLI,我们就可以开始与 Elastic Beanstalk 交互了。让我们初始化一个新项目和一个 EB 环境。

初始化

在项目根目录(“flask-movies”)中,运行:

你会被提示一些问题。

默认区域

您的弹性 Beanstalk 环境的 AWS 区域(和资源)。如果您不熟悉不同的 AWS 区域,请查看 AWS 区域和可用区域。一般来说,你应该选择离你的客户最近的地区。请记住,资源价格因地区而异。

应用程序名称

这是您的弹性 Beanstalk 应用程序的名称。我建议按下回车键,使用默认设置:“flask-movies”。

平台和平台分支

EB CLI 将检测到您正在使用 Python 环境。之后,它会给你不同的 Python 版本和 Amazon Linux 版本供你使用。选择“运行在 64 位亚马逊 Linux 2 上的 Python 3.8”。

代码提交

CodeCommit 是一个安全的、高度可伸缩的、托管的源代码控制服务,托管私有的 Git 存储库。我们不会使用它,因为我们已经在使用 GitHub 进行源代码控制。所以说“不”。

为了稍后连接到 EC2 实例,我们需要设置 SSH。出现提示时,说“是”。

密钥对

为了连接到 EC2 实例,我们需要一个 RSA 密钥对。继续生成一个,它将被添加到您的“~/”中。ssh”文件夹。

回答完所有问题后,您会注意到项目根目录下有一个隐藏的目录,名为。elasticbeanstalk”。该目录应该包含一个 config.yml 文件,其中包含您刚才提供的所有数据。

`.elasticbeanstalk
└── config.yml` 

该文件应包含类似以下内容:

`branch-defaults: master: environment:  null group_suffix:  null global: application_name:  flask-movies branch:  null default_ec2_keyname:  aws-eb default_platform:  Python 3.8 running on 64bit Amazon Linux 2 default_region:  us-west-2 include_git_submodules:  true instance_profile:  null platform_name:  null platform_version:  null profile:  eb-cli repository:  null sc:  git workspace_type:  Application` 

创造

接下来,让我们创建弹性 Beanstalk 环境并部署应用程序:

同样,系统会提示您几个问题。

环境名称

这表示 EB 环境的名称。我建议坚持默认设置:“flask-movies-env”。

└-env└-dev后缀添加到您的环境中被认为是一种很好的做法,这样您就可以很容易地将 EB 应用程序与环境区分开来。

DNS CNAME 前缀

您的 web 应用程序将在%cname%.%region%.elasticbeanstalk.com可访问。同样,使用默认值。

负载平衡

负载平衡器在您的环境实例之间分配流量。选择“应用程序”。

如果您想了解不同的负载平衡器类型,请查看适用于您的弹性 Beanstalk 环境的负载平衡器。

现货车队请求

Spot Fleet 请求允许您根据自己的标准按需启动实例。我们不会在本教程中使用它们,所以说“不”。

--

有了它,环境将会旋转起来:

  1. 你的代码将被压缩并上传到一个新的 S3 桶。
  2. 之后,将创建各种 AWS 资源,如负载平衡器、安全和自动伸缩组以及 EC2 实例。

还将部署一个新的应用程序。

这将需要大约三分钟,所以请随意拿一杯咖啡。

部署完成后,EB CLI 将修改。elasticbeanstalk/config.yml

您的项目结构现在应该如下所示:

`|-- .elasticbeanstalk
|   └-- config.yml
|-- .gitignore
|-- README.md
|-- app.py
|-- default.db
|-- init_db.py
└-- requirements.txt` 

状态

部署应用后,您可以通过运行以下命令来检查其状态:

`$ eb status

Environment details for: flask-movies-env
  Application name: flask-movies
  Region: us-west-2
  Deployed Version: app-82fb-220311_171256090207
  Environment ID: e-nsizyek74z
  Platform: arn:aws:elasticbeanstalk:us-west-2::platform/Python 3.8 running on 64bit Amazon Linux 2/3.3.11
  Tier: WebServer-Standard-1.0
  CNAME: flask-movies-env.us-west-2.elasticbeanstalk.com
  Updated: 2022-03-11 23:16:03.822000+00:00
  Status: Launching
  Health: Red` 

您可以看到我们环境的当前健康状况是Red,这意味着出现了问题。暂时不要担心这个问题,我们将在接下来的步骤中解决它。

您还可以看到,AWS 为我们分配了一个 CNAME,这是我们的 EB 环境的域名。我们可以通过打开浏览器并导航到 CNAME 来访问 web 应用程序。

打开

此命令将打开您的默认浏览器并导航到 CNAME 域。你会看到502 Bad Gateway,我们将在这里很快修复它

安慰

该命令将在您的默认浏览器中打开 Elastic Beanstalk 控制台:

Elastic Beanstalk Console

同样,您可以看到环境的健康状况是“严重的”,我们将在下一步中解决这个问题。

配置环境

在上一步中,我们尝试访问我们的应用程序,它返回了502 Bad Gateway。背后有两个原因:

  1. Python 需要PYTHONPATH来在我们的应用程序中找到模块。
  2. 默认情况下,Elastic Beanstalk 试图从不存在的 application.py 启动 WSGI 应用程序。

默认情况下,Elastic Beanstalk 为 Python 应用程序提供了 Gunicorn 。EB 在部署过程中自动安装 Gunicorn,因此我们不必将其添加到 requirements.txt 中。如果你想用别的东西替换 Gunicorn,看看用 Procfile 配置 WSGI 服务器的

让我们修复这些错误。

在项目根目录下创建一个名为“”的新文件夹。ebextensions”。在新创建的文件夹中创建一个名为 01_flask.config 的文件:

`# .ebextensions/01_flask.config option_settings: aws:elasticbeanstalk:application:environment: PYTHONPATH:  "/var/app/current:$PYTHONPATH" aws:elasticbeanstalk:container:python: WSGIPath:  "app:app"` 

注意事项:

  1. 我们将PYTHONPATH设置为 EC2 实例上的 Python 路径( docs )。
  2. 我们将WSGIPath更改为我们的 WSGI 应用程序( docs )。

EB 如何。config 文件管用吗?

  1. 你想要多少就有多少。
  2. 它们按以下顺序加载:01_x、02_x、03_x 等。
  3. 您不必记住这些设置;您可以通过运行eb config列出您的所有环境设置。

如果您想了解更多关于高级环境定制的信息,请查看带有配置文件的高级环境定制

接下来,我们必须告诉 Elastic Beanstalk 在部署新的应用程序版本时初始化数据库。将以下内容添加到的末尾。EB extensions/01 _ flask . config:

`# .ebextensions/01_flask.config container_commands: 01_initdb: command:  "source /var/app/venv/*/bin/activate && python3 init_db.py" leader_only:  true` 

现在,每当我们部署一个新的应用程序版本时,EB 环境都会执行上面的命令。我们使用了leader_only,所以只有第一个 EC2 实例执行它们(以防我们的 EB 环境运行多个 EC2 实例)。

弹性 Beanstalk 配置支持两个不同的命令部分,命令容器 _ 命令。它们之间的主要区别在于它们在部署过程中的运行时间:

  1. commands在设置应用程序和 web 服务器以及提取应用程序版本文件之前运行。
  2. container_commands在应用程序和 web 服务器已设置且应用程序版本存档已提取之后,但在应用程序版本部署之前(在文件从暂存文件夹移动到其最终位置之前)运行。

此时,您的项目结构应该如下所示:

`|-- .ebextensions
|   └-- 01_flask.config
|-- .elasticbeanstalk
|   └-- config.yml
|-- .gitignore
|-- README.md
|-- app.py
|-- default.db
|-- init_db.py
└-- requirements.txt` 

将更改提交给 git 并部署:

`$ git add .
$ git commit -m "updates for eb"

$ eb deploy` 

您会注意到,如果您不提交,Elastic Beanstalk 不会检测到这些变化。这是因为 EB 与 git 集成,并且只检测提交的(更改的)文件。

部署完成后,运行eb open看看是否一切正常。之后,将/api/movies追加到 URL,看看电影是否还能显示。

耶!我们的应用程序的第一个版本现已部署。

配置 RDS

如果你正在部署 flask-movies ,你会注意到它默认使用一个 SQLite 数据库。虽然这对于开发来说是完美的,但是对于生产来说,您通常会希望迁移到更健壮的数据库,比如 Postgres 或 MySQL。让我们看看如何将 SQLite 替换为 Postgres

本地邮政汇票

首先,让 Postgres 在本地运行。您可以从 PostgreSQL Downloads 下载它,或者启动 Docker 容器:

`$ docker run --name flask-movies-postgres -p 5432:5432 \
    -e POSTGRES_USER=flask-movies -e POSTGRES_PASSWORD=complexpassword123 \
    -e POSTGRES_DB=flask-movies -d postgres` 

检查容器是否正在运行:

`$ docker ps -f name=flask-movies-postgres

CONTAINER ID   IMAGE      COMMAND                  CREATED              STATUS              PORTS                    NAMES
c05621dac852   postgres   "docker-entrypoint.s…"   About a minute ago   Up About a minute   0.0.0.0:5432->5432/tcp   flask-movies-postgres` 

现在,让我们尝试用 Flask 应用程序连接到它。

app.py 里面的SQLALCHEMY_DATABASE_URI改成这样:

`app.config['SQLALCHEMY_DATABASE_URI'] = \
    'postgresql://{username}:{password}@{host}:{port}/{database}'.format(
    username='flask-movies',
    password='complexpassword123',
    host='localhost',
    port='5432',
    database='flask-movies',
)` 

接下来,安装 Postgres 所需的 psycopg2-binary :

`(venv)$ pip install psycopg2-binary==2.9.3` 

添加到 requirements.txt :

`Flask==2.0.3
Flask-SQLAlchemy==2.5.1
psycopg2-binary==2.9.3` 

删除现有数据库 default.db ,然后初始化新数据库:

`(venv)$ python init_db.py` 

运行服务器:

通过查看http://localhost:5000/API/movies,确保电影仍然可以正确播放。

AWS RDS Postgres

要为生产设置 Postgres,首先运行以下命令打开 AWS 控制台:

单击左侧栏上的“配置”,向下滚动到“数据库”,然后单击“编辑”。

使用以下设置创建一个数据库,然后单击“应用”:

  • 引擎:postgres
  • 引擎版本:12.9(自 db.t2.micro 以来的旧 Postgres 版本在 13.1+版本中不可用)
  • 实例类:db.t2.micro
  • 存储:5 GB(应该绰绰有余)
  • 用户名:选择一个用户名
  • 密码:选择一个强密码

如果你想留在 AWS 免费层内,确保你选择 db.t2.micro. RDS 价格会根据你选择的实例类呈指数增长。如果你不想和micro一起去,一定要复习 AWS PostgreSQL 定价

RDS settings

环境更新完成后,EB 会自动将以下数据库凭证传递给我们的 flask 应用程序:

`RDS_DB_NAME
RDS_USERNAME
RDS_PASSWORD
RDS_HOSTNAME
RDS_PORT` 

我们现在可以使用 app.py 中的这些变量来连接我们的数据库:

`if 'RDS_DB_NAME' in os.environ:
    app.config['SQLALCHEMY_DATABASE_URI'] = \
        'postgresql://{username}:{password}@{host}:{port}/{database}'.format(
        username=os.environ['RDS_USERNAME'],
        password=os.environ['RDS_PASSWORD'],
        host=os.environ['RDS_HOSTNAME'],
        port=os.environ['RDS_PORT'],
        database=os.environ['RDS_DB_NAME'],
    )
else:
    app.config['SQLALCHEMY_DATABASE_URI'] = \
        'postgresql://{username}:{password}@{host}:{port}/{database}'.format(
        username='flask-movies',
        password='complexpassword123',
        host='localhost',
        port='5432',
        database='flask-movies',
    )` 

不要忘记导入 app.py 顶部的os包:

将更改提交给 git 并部署:

`$ git add .
$ git commit -m "updates for eb"

$ eb deploy` 

等待部署完成。完成后,运行eb open在新的浏览器标签中打开你的应用。通过在/api/movies列出电影来确保一切正常运行。

HTTPS 与证书管理器

教程的这一部分要求您有一个域名。

需要一个便宜的域名来练习?几个域名注册商有特殊优惠。“xyz”域。或者,您可以在 Freenom 创建一个免费域名。如果你没有域名,但仍然想使用 HTTPS,你可以创建并签署一个 X509 证书

要通过 HTTPS 为您的申请提供服务,我们需要:

  1. 请求并验证 SSL/TLS 证书
  2. 把你的域名指向你的 EB CNAME
  3. 修改负载平衡器以服务于 HTTPS
  4. 修改您的应用程序设置

请求并验证 SSL/TLS 证书

导航到 AWS 证书管理器控制台。单击“申请证书”。将证书类型设置为“公共”,然后单击“下一步”。在表单输入中输入您的全限定域名,设置“验证方式”为“DNS 验证”,点击“请求”。

AWS Request Public Certificate

然后,您将被重定向到一个页面,在那里您可以看到您的所有证书。您刚刚创建的证书应该具有“待验证”状态。

为了让 AWS 颁发证书,你首先必须证明你是这个域名的所有者。在表格中,单击证书以查看“证书详细信息”。注意“CNAME 的名字”和“CNAME 的价值”。要验证域的所有权,您需要在域的 DNS 设置中创建“CNAME 记录”。为此使用“CNAME 名称”和“CNAME 价值”。一旦完成,Amazon 将需要几分钟的时间来获取域更改并颁发证书。状态应该从“等待验证”更改为“已发布”。

将域名指向 EB CNAME

接下来,您需要将您的域(或子域)指向您的 EB 环境 CNAME。回到您的域名的 DNS 设置,添加另一个 CNAME 记录,其值为您的 EB CNAME -例如,flask-movies-dev.us-west-2.elasticbeanstalk.com

等待几分钟,让您的 DNS 刷新,然后在浏览器中测试您的域名的http://风格。

修改负载平衡器以服务于 HTTPS

回到弹性豆茎控制台,点击“配置”。然后,在“负载平衡器”类别中,单击“编辑”。单击“添加监听程序”并使用以下详细信息创建监听程序:

  1. 端口- 443
  2. 议定书- HTTPS
  3. SSL 证书-选择您刚刚创建的证书

点击“添加”。然后,滚动到页面底部,单击“应用”。环境更新需要几分钟时间。

修改您的应用程序设置

接下来,我们需要对 Flask 应用程序进行一些修改。

我们需要将所有流量从 HTTP 重定向到 HTTPS。有多种方法可以做到这一点,但最简单的方法是将 Apache 设置为代理主机。我们可以通过在中的option_settings末尾添加以下内容来编程实现这一点。EB extensions/01 _ flask . config:

`# .ebextensions/01_flask.config

option_settings:
  # ...
  aws:elasticbeanstalk:environment:proxy:  # new
    ProxyServer: apache                    # new` 

您最终的 01_flask.config 文件现在应该是这样的:

`# .ebextensions/01_flask.config option_settings: aws:elasticbeanstalk:application:environment: PYTHONPATH:  "/var/app/current:$PYTHONPATH" aws:elasticbeanstalk:container:python: WSGIPath:  "app:app" aws:elasticbeanstalk:environment:proxy: ProxyServer:  apache container_commands: 01_initdb: command:  "source /var/app/venv/*/bin/activate && python3 init_db.py" leader_only:  true` 

接下来,创建一个”。平台"文件夹中,并添加以下文件和文件夹:

`└-- .platform
    └-- httpd
        └-- conf.d
            └-- ssl_rewrite.conf` 

ssl_rewrite.conf :

`# .platform/httpd/conf.d/ssl_rewrite.conf

RewriteEngine On
<If "-n '%{HTTP:X-Forwarded-Proto}' && %{HTTP:X-Forwarded-Proto} != 'https'">
RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R,L]
</If>` 

您的项目结构现在应该如下所示:

`|-- .ebextensions
|   └-- 01_flask.config
|-- .elasticbeanstalk
|   └-- config.yml
|-- .gitignore
|-- .platform
|   └-- httpd
|       └-- conf.d
|           └-- ssl_rewrite.conf
|-- README.md
|-- app.py
|-- default.db
|-- init_db.py
└-- requirements.txt` 

将更改提交给 git 并部署:

`$ git add .
$ git commit -m "updates for eb"

$ eb deploy` 

现在,在你的浏览器中,你的应用程序的https://风格应该工作了。试试去http://味的。你应该被重定向到https://风味。确保证书也正确加载:

secure app

环境变量

在生产中,最好将特定于环境的配置存储在环境变量中。使用 Elastic Beanstalk,您可以用两种不同的方式设置自定义环境变量。

通过 EB CLI 的环境变量

让我们把 Flask SECRET_KEY变成一个环境变量。

从跑步开始:

`$ eb setenv FLASK_SECRET_KEY='<replace me with your own secret key>'` 

您可以用一个命令设置多个环境变量,用空格分隔它们。这是推荐的方法,因为它只需要对 EB 环境进行一次更新。

相应更改 app.py 中的SECRET_KEY:

`# app.py

app.config['SECRET_KEY'] = os.environ.get(
    'FLASK_SECRET_KEY',
    '<replace me with your own fallback secret key>'
)` 

将更改提交给 git 并部署:

`$ git add .
$ git commit -m "updates for eb"

$ eb deploy` 

通过 EB 控制台的环境变量

通过eb open进入弹性豆茎控制台。导航至“配置”>“软件”>“编辑”。然后,向下滚动到“环境属性”。

AWS Elastic Beanstalk Environment Variables

完成后,单击“应用”,您的环境将会更新。

然后,您可以通过os.environ在您的 Python 环境中访问这些变量。

例如:

`VARIABLE_NAME = os.environ['VARIABLE_NAME']` 

调试弹性豆茎

当使用 Elastic Beanstalk 时,如果您不知道如何访问日志文件,那么找出问题所在会非常令人沮丧。在本节中,我们将研究这一点。

有两种方法可以访问日志:

  1. 弹性 Beanstalk CLI 或控制台
  2. SSH 到 EC2 实例

从个人经验来看,我已经能够用第一种方法解决所有问题。

弹性 Beanstalk CLI 或控制台

CLI:

该命令将从以下文件中获取最后 100 行:

`/var/log/web.stdout.log /var/log/eb-hooks.log /var/log/nginx/access.log /var/log/nginx/error.log /var/log/eb-engine.log` 

运行eb logs相当于登录 EB 控制台,导航到“日志”。

我建议将日志传送到 CloudWatch 。运行以下命令来启用此功能:

`$ eb logs --cloudwatch-logs enable` 

您通常会在 /var/log/web.stdout.log/var/log/eb-engine.log 中找到 Flask 错误。

要了解更多关于弹性 Beanstalk 日志的信息,请查看来自 Amazon EC2 实例的日志。

SSH 到 EC2 实例

要连接到运行 Flask 应用程序的 EC2 实例,请运行:

第一次会提示您将主机添加到已知主机。答应吧。这样,您现在就可以完全访问 EC2 实例了。请随意检查上一节中提到的一些日志文件。

请记住,Elastic Beanstalk 会自动伸缩和部署新的 EC2 实例。您在这个特定 EC2 实例上所做的更改不会反映在新启动的 EC2 实例上。一旦这个特定的 EC2 实例被替换,您的更改将被清除。

结论

在本教程中,我们介绍了将 Flask 应用程序部署到 AWS Elastic Beanstalk 的过程。到目前为止,您应该对弹性豆茎的工作原理有了一个大致的了解。通过回顾本教程开头的目标,快速进行自我检查。

后续步骤:

  1. 你应该考虑创建两个独立的 EB 环境(devproduction)。
  2. 查看用于您的弹性 Beanstalk 环境的自动伸缩组,了解如何配置触发器来自动伸缩您的应用程序。

要删除我们在整个教程中创建的所有 AWS 资源,首先要终止 Elastic Beanstalk 环境:

您需要手动删除 SSL 证书。

最后,你可以在 GitHub 上的flask-elastic-beanstalkrepo 中找到代码的最终版本。

posted @ 2024-11-03 11:41  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报