buy the book ribbon

附录 B:模板项目结构

[chapter_04_service_layer] 附近,我们从仅仅将所有内容放在一个文件夹中转变为更结构化的树形结构,并且我们认为概述一下各个组成部分可能会很有趣。

提示

此附录的代码位于 appendix_project_structure 分支 on GitHub

git clone https://github.com/cosmicpython/code.git
cd code
git checkout appendix_project_structure

基本文件夹结构如下所示

项目树
.
├── Dockerfile  (1)
├── Makefile  (2)
├── README.md
├── docker-compose.yml  (1)
├── license.txt
├── mypy.ini
├── requirements.txt
├── src  (3)
│   ├── allocation
│   │   ├── __init__.py
│   │   ├── adapters
│   │   │   ├── __init__.py
│   │   │   ├── orm.py
│   │   │   └── repository.py
│   │   ├── config.py
│   │   ├── domain
│   │   │   ├── __init__.py
│   │   │   └── model.py
│   │   ├── entrypoints
│   │   │   ├── __init__.py
│   │   │   └── flask_app.py
│   │   └── service_layer
│   │       ├── __init__.py
│   │       └── services.py
│   └── setup.py  (3)
└── tests  (4)
    ├── conftest.py  (4)
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   └── test_repository.py
    ├── pytest.ini  (4)
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py
  1. 我们的 docker-compose.ymlDockerfile 是运行我们应用程序的容器的主要配置,它们也可以运行测试 (用于 CI)。更复杂的项目可能需要多个 Dockerfile,尽管我们发现尽量减少镜像的数量通常是一个好主意。[1]

  2. Makefile 为开发人员(或 CI 服务器)在正常工作流程中可能想要运行的所有典型命令提供入口点:make buildmake test 等。[2] 这是可选的。您可以直接使用 docker-composepytest,但至少,最好将所有“常用命令”放在某处的列表中,并且与文档不同,Makefile 是代码,因此它不太可能过时。

  3. 我们应用程序的所有源代码,包括领域模型、Flask 应用程序和基础设施代码,都位于 src 中的 Python 包中,[3] 我们使用 pip install -esetup.py 文件安装它。这使得导入变得容易。目前,此模块中的结构完全扁平,但对于更复杂的项目,您应该期望增长一个文件夹层次结构,其中包括 domain_model/infrastructure/services/api/

  4. 测试位于它们自己的文件夹中。子文件夹区分不同的测试类型,并允许您分别运行它们。我们可以将共享 fixtures (conftest.py) 保留在主 tests 文件夹中,并在需要时嵌套更具体的 fixtures。这也是保留 pytest.ini 的位置。

提示
pytest 文档在测试布局和可导入性方面非常出色。

让我们更详细地查看其中一些文件和概念。

环境变量、12-Factor 以及容器内外配置

我们在这里尝试解决的基本问题是,我们需要以下不同的配置设置

  • 直接从您自己的开发机器运行代码或测试,可能与来自 Docker 容器的映射端口通信

  • 在容器本身上运行,使用“真实”端口和主机名

  • 不同的容器环境(开发、暂存、生产等)

通过环境变量进行配置,正如 12-factor manifesto 建议的那样,将解决此问题,但具体而言,我们如何在我们的代码和容器中实现它?

Config.py

每当我们的应用程序代码需要访问某些配置时,它都会从名为 config.py 的文件中获取它。以下是我们应用程序中的几个示例

示例配置函数 (src/allocation/config.py)
import os


def get_postgres_uri():  #(1)
    host = os.environ.get("DB_HOST", "localhost")  #(2)
    port = 54321 if host == "localhost" else 5432
    password = os.environ.get("DB_PASSWORD", "abc123")
    user, db_name = "allocation", "allocation"
    return f"postgresql://{user}:{password}@{host}:{port}/{db_name}"


def get_api_url():
    host = os.environ.get("API_HOST", "localhost")
    port = 5005 if host == "localhost" else 80
    return f"http://{host}:{port}"
  1. 我们使用函数来获取当前配置,而不是在导入时可用的常量,因为这允许客户端代码在需要时修改 os.environ

  2. config.py 还定义了一些默认设置,旨在在从开发人员的本地机器运行代码时工作。[4]

如果您厌倦了手动编写自己的基于环境的配置函数,那么值得关注一个名为 environ-config 的优雅 Python 包。

提示
不要让这个 config 模块成为一个垃圾场,里面充满了仅与配置略微相关的东西,然后被导入到各处。保持事物不可变,并且仅通过环境变量修改它们。如果您决定使用 bootstrap 脚本,您可以使其成为唯一导入配置的位置(测试除外)。

Docker-Compose 和容器配置

我们使用一个名为 docker-compose 的轻量级 Docker 容器编排工具。它的主要配置是通过 YAML 文件 (叹气):[5]

docker-compose 配置文件 (docker-compose.yml)
version: "3"
services:

  app:  #(1)
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      - postgres
    environment:  #(3)
      - DB_HOST=postgres  (4)
      - DB_PASSWORD=abc123
      - API_HOST=app
      - PYTHONDONTWRITEBYTECODE=1  #(5)
    volumes:  #(6)
      - ./src:/src
      - ./tests:/tests
    ports:
      - "5005:80"  (7)


  postgres:
    image: postgres:9.6  #(2)
    environment:
      - POSTGRES_USER=allocation
      - POSTGRES_PASSWORD=abc123
    ports:
      - "54321:5432"
  1. docker-compose 文件中,我们定义了我们的应用程序需要的不同 services(容器)。通常,一个主镜像包含我们所有的代码,我们可以使用它来运行我们的 API、我们的测试或任何其他需要访问领域模型的服务。

  2. 您可能还有其他基础设施服务,包括数据库。在生产环境中,您可能不会为此使用容器;您可能有一个云提供商,但 docker-compose 为我们提供了一种为开发或 CI 生成类似服务的方法。

  3. environment 节允许您为您的容器设置环境变量,从 Docker 集群内部看到的主机名和端口。如果您有足够的容器,这些信息开始在这些部分中重复,您可以改用 environment_file。我们通常称我们的为 container.env

  4. 在集群内部,docker-compose 设置网络,以便容器可以通过以其服务名称命名的主机名相互访问。

  5. 专业提示:如果您正在挂载卷以在本地开发机器和容器之间共享源文件夹,则 PYTHONDONTWRITEBYTECODE 环境变量告诉 Python 不要写入 .pyc 文件,这将使您免于在本地文件系统上到处散布数百万个 root 拥有的文件,除了引起奇怪的 Python 编译器错误之外,删除它们也很烦人。

  6. 将我们的源代码和测试代码作为 volumes 挂载意味着我们不需要每次进行代码更改时都重建我们的容器。

  7. ports 部分允许我们将端口从容器内部暴露给外部世界[6]—这些对应于我们在 config.py 中设置的默认端口。

注意
在 Docker 内部,其他容器可以通过以其服务名称命名的主机名访问。在 Docker 外部,它们可以在 localhost 上访问,端口在 ports 部分中定义。

将您的源代码作为包安装

我们所有的应用程序代码(真正地,除了测试之外的所有内容)都位于 src 文件夹中

src 文件夹
  1. 子文件夹定义顶级模块名称。如果您愿意,您可以拥有多个。

  2. setup.py 是您需要使其可 pip 安装的文件,如下所示。

三行中的可 pip 安装模块 (src/setup.py)
from setuptools import setup

setup(
    name="allocation", version="0.1", packages=["allocation"],
)

这就是您所需要的全部。packages= 指定您要安装为顶级模块的子文件夹的名称。name 条目只是装饰性的,但它是必需的。对于永远不会真正到达 PyPI 的包,它会做得很好。[7]

Dockerfile

Dockerfiles 将非常特定于项目,但以下是您期望看到的一些关键阶段

我们的 Dockerfile (Dockerfile)
FROM python:3.9-slim-buster

(1)
# RUN apt install gcc libpq (no longer needed bc we use psycopg2-binary)

(2)
COPY requirements.txt /tmp/
RUN pip install -r /tmp/requirements.txt

(3)
RUN mkdir -p /src
COPY src/ /src/
RUN pip install -e /src
COPY tests/ /tests/

(4)
WORKDIR /src
ENV FLASK_APP=allocation/entrypoints/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1
CMD flask run --host=0.0.0.0 --port=80
  1. 安装系统级依赖项

  2. 安装我们的 Python 依赖项(您可能想要将您的开发依赖项与生产依赖项分开;为了简单起见,我们在这里没有这样做)

  3. 复制和安装我们的源代码

  4. 可选地配置默认启动命令(您可能会经常从命令行覆盖它)

提示
需要注意的一件事是,我们按照它们可能更改的频率顺序安装东西。这使我们能够最大限度地重用 Docker 构建缓存。我无法告诉您此教训背后有多少痛苦和挫折。有关此和更多 Python Dockerfile 改进技巧,请查看 “Production-Ready Docker Packaging”

测试

我们的测试与所有其他内容一起保存,如下所示

Tests 文件夹树
└── tests
    ├── conftest.py
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   └── test_repository.py
    ├── pytest.ini
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py

这里没有什么特别聪明的,只是一些不同测试类型的分离,您可能希望单独运行这些测试类型,以及一些用于常见 fixtures、config 等的文件。

在 test 文件夹中没有 src 文件夹或 setup.py,因为我们通常不需要使测试可 pip 安装,但是如果您在导入路径方面遇到困难,您可能会发现它有所帮助。

总结

这些是我们的基本构建块

  • src 文件夹中的源代码,使用 setup.py 可 pip 安装

  • 一些 Docker 配置,用于启动尽可能镜像生产环境的本地集群

  • 通过环境变量进行配置,集中在一个名为 config.py 的 Python 文件中,默认值允许在容器外部运行

  • 一个 Makefile,用于有用的命令行,嗯,命令

我们怀疑是否有人最终会得到与我们完全相同的解决方案,但我们希望您能在这里找到一些灵感。


1. 为生产和测试分离镜像有时是一个好主意,但我们倾向于发现,进一步尝试为不同类型的应用程序代码(例如,Web API 与 pub/sub 客户端)分离不同的镜像通常最终会带来更多麻烦而不是好处;在复杂性和更长的重建/CI 时间方面的成本太高。YMMV。
2. Makefiles 的纯 Python 替代方案是 Invoke,如果您的团队中的每个人都了解 Python(或者至少比 Bash 更了解它!),则值得查看。
3. Hynek Schlawack 的 “Testing and Packaging” 提供了有关 src 文件夹的更多信息。
4. 这为我们提供了一个“开箱即用”(尽可能多)的本地开发设置。您可能更喜欢在缺少环境变量时硬性失败,特别是如果任何默认值在生产环境中都不安全。
5. Harry 有点 YAML 疲劳。它无处不在,但他总是记不住语法或它应该如何缩进。
6. 在 CI 服务器上,您可能无法可靠地暴露任意端口,但这只是为了本地开发的便利。您可以找到使这些端口映射成为可选的方法(例如,使用 docker-compose.override.yml)。
7. 有关更多 setup.py 提示,请参阅 Hynek 的 这篇关于打包的文章