使用FastAPI SQLAlchemy(PostgreSQL)实现CRUD API实现


该动手执行什么

  • FastAPI Docker环境(Nginx容器,应用程序容器,数据库容器)

  • 厌氧环境

    • 数据库迁移工具
  • 用户信息模型

    • 在数据库中创建的表的源。用于迁移工具。
  • 数据访问类

    • 像Dao之类的东西在Java中
  • 用于CRUD用户信息的API

    • 截至2020年10月27日,不考虑密码哈希
  • 请求中间件

    • 在请求之前和之后处理的中间件
  • API测试代码

    • 创建一个测试数据库,为每个测试用例执行回滚该数据库,并在所有测试完成后删除该测试数据库。
  • 避免CORS问题

  • 自定义异常(应用程序异常和系统异常)

建筑

1
2
3
4
python: v3.8.5
postgresql: v12.4
fastapi: v0.60.2
SQLAlchemy: v1.3.18

迁移工具

1
alembic: v1.4.2

什么是FastAPI

我将省略详细信息,因为已经有关于Qiita的choco choco文章。

非常高兴有很多文档(未阅读)并且与Swagger兼容。
(看来性能不错,但是我不确定,因为我还没有测量基准,但这可能很快。)

Django是我接触过的唯一框架,因此我很难使用中间件和测试代码。
(Django是一个全栈框架,它自己执行许多操作,因此我并不真正在乎中间件或测试实现。)

为Python 3.8.5创建伪装环境

如果不包含

pyenv和virtualenv,请参阅以下内容。
Mac Mac
Windows

1
$ pyenv virtualenv 3.8.5 env_fastapi_sample

应用伪装环境

创建项目根

1
$ mkdir fastapi_sample

cd到项目根目录

1
$ cd fastapi_sample

应用伪装环境

1
$ pyenv local env_fastapi_sample

requirements.txt创建/安装

1
$ touch requirements.txt

将fastapi添加到requirements.txt

fastapi_sample / requirements.txt

1
2
fastapi==0.60.2
uvicorn==0.11.8

在伪装环境中安装

1
$ pip3 install -r requirements.txt

创建一个入口点(main.py)并显示Swagger-UI

创建入口点(main.py)

1
$ touch main.py

编辑main.py

fastapi_sample / main.py

1
2
3
4
5
6
7
8
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

Swagger-UI显示

1
$ uvicorn main:app --reload

如果从

浏览器访问" http://127.0.0.1:8000",并且显示{"message":"Hello World"},则操作完成。

码头工人

使应用程序由Nginx的反向代理在https上发布。

基本docker-compose.yml

fastapi_sample / docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
version: '3'
services:
# Nginxコンテナ
  nginx:
    container_name: nginx_fastapi_sample
    image: nginx:alpine
    depends_on:
      - app
      - db
    environment:
      TZ: "Asia/Tokyo"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
      - ./docker/nginx/ssl:/etc/nginx/ssl

# アプリケーションコンテナ
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: app_fastapi_sample
    volumes:
      - '.:/fastapi_sample/'
    environment:
      - LC_ALL=ja_JP.UTF-8
    expose:
      - 8000
    depends_on:
      - db
    entrypoint: /fastapi_sample/docker/wait-for-it.sh db 5432 postgres postgres db_fastapi_sample
    command: bash /fastapi_sample/docker/rundevserver.sh
    restart: always
    tty: true

# DBコンテナ
  db:
    image: postgres:12.4-alpine
    container_name: db_fastapi_sample
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=db_fastapi_sample
      - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --locale=C
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

volumes:
  db_data:
    driver: local

Nginx容器设置

准备配置文件

1
2
$ mkdir -p nginx/conf.d
$ touch nginx/conf.d/app.conf

fastapi_sample / docker / nginx / conf.d / app.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
upstream backend {
    server app:8000;  # appはdocker-compose.ymlの「app」
}

# 80番ポートへのアクセスは443番ポートへのアクセスに強制する
server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    ssl_certificate     /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;
    ssl_protocols        TLSv1.2 TLSv1.3;

    location / {
        proxy_set_header    Host    $http_host;
        proxy_set_header    X-Real-IP    $remote_addr;
        proxy_set_header    X-Forwarded-Host      $http_host;
        proxy_set_header    X-Forwarded-Server    $http_host;
        proxy_set_header    X-Forwarded-Server    $host;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto  $scheme;
        proxy_redirect      http:// https://;

        proxy_pass http://backend;
    }

    # ログを出力したい場合はコメントアウト外してください
    # access_log /var/log/nginx/access.log;
    # error_log /var/log/nginx/error.log;
}

server_tokens off;

准备Nginx

的SSL转换所需的文件

1
$ mkdir -p nginx/conf.d nginx/ssl

使用OpenSSL生成私钥(server.key)

1
2
3
4
5
6
7
8
9
10
# ディレクトリ移動
$ cd nginx/conf.d

# 秘密鍵生成
$ openssl genrsa 2024 > server.key

# 確認
$ ls -ll
total 8
-rw-r--r--  1 user  staff  1647 10 24 13:23 server.key

生成证书签名请求(server.csr)

1
$ openssl req -new -key server.key > server.csr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ openssl req -new -key server.key > server.csr
...
..
.
Country Name (2 letter code) [AU]:JP  # 国を示す2文字のISO略語
State or Province Name (full name) [Some-State]:Tokyo  # 会社が置かれている都道府県
Locality Name (eg, city) []:Chiyodaku  # 会社が置かれている市区町村
Organization Name (eg, company) [Internet Widgits Pty Ltd]:fabeee  # 会社名
Organizational Unit Name (eg, section) []:-  # 部署名(ハイフンにしました。)
Common Name (e.g. server FQDN or YOUR name) []:localhost(ウェブサーバのFQDN。一応localhostにした)
Email Address []:  # 未入力でエンター

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:  # 未入力でエンター
An optional company name []:  # 未入力でエンター
...
..
.
$ ls -ll
total 16
-rw-r--r--  1 user  staff   980 10 24 13:27 server.csr  # 証明書署名要求ができた
-rw-r--r--  1 user  staff  1647 10 24 13:23 server.key

生成服务器证书(server.crt)

1
2
3
4
5
6
7
openssl x509 -req -days 3650 -signkey server.key < server.csr > server.crt

% ls -ll
total 24
-rw-r--r--  1 tabata  staff  1115 10 24 13:40 server.crt  # サーバ証明書ができた
-rw-r--r--  1 tabata  staff   948 10 24 13:29 server.csr
-rw-r--r--  1 tabata  staff  1647 10 24 13:23 server.key

信任伺服器凭证

双击

server.crt
image.png

从"使用此证书时"下拉菜单中选择"始终信任"。
image.png

如果是这种情况,则Nginx设置已完成

1
2
3
4
5
6
7
8
9
10
11
12
fastapi_sample
├── docker
│&nbsp;&nbsp; └── nginx
│&nbsp;&nbsp;     ├── conf.d
│&nbsp;&nbsp;     │&nbsp;&nbsp; └── app.conf
│&nbsp;&nbsp;     └── ssl
│&nbsp;&nbsp;         ├── server.crt
│&nbsp;&nbsp;         ├── server.csr
│&nbsp;&nbsp;         └── server.key
├── docker-compose.yml
├── main.py
└── requirements.txt

应用(FastAPI)容器设置

Dockerfile

图像是Ubuntu 20.04。
容器中的python版本设置为v3.8.5,带有?pyenv
使用apt install安装所需的模块(您可以删除不需要的模块)
在容器内部安装python模块吗?pip3 install -r requirements.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
FROM ubuntu:20.04
ENV DEBIAN_FRONTEND=noninteractive
ENV HOME /root
ENV PYTHONPATH /fastapi_sample/
ENV PYTHON_VERSION 3.8.5
ENV PYTHON_ROOT $HOME/local/python-$PYTHON_VERSION
ENV PATH $PYTHON_ROOT/bin:$PATH
ENV PYENV_ROOT $HOME/.pyenv

RUN mkdir /fastapi_sample \
    && rm -rf /var/lib/apt/lists/*

RUN apt update && apt install -y git curl locales python3-pip python3-dev python3-passlib python3-jwt \
    libssl-dev libffi-dev zlib1g-dev libpq-dev postgresql

RUN echo "ja_JP UTF-8" > /etc/locale.gen \
    && locale-gen

RUN git clone https://github.com/pyenv/pyenv.git $PYENV_ROOT \
    && $PYENV_ROOT/plugins/python-build/install.sh \
    && /usr/local/bin/python-build -v $PYTHON_VERSION $PYTHON_ROOT

WORKDIR /fastapi_sample
ADD . /fastapi_sample/
RUN LC_ALL=ja_JP.UTF-8 \
    && pip3 install -r requirements.txt

等待它.sh

一个shell脚本,它在启动应用程序容器之前等待数据库启动。

容器启动顺序可以由docker-componse中所述的depends_on控制,但是
如果数据库未启动,则应用程序容器中可能会出现错误。
(如果您尝试在应用容器启动时尝试执行操作数据库的命令,则会由于数据库尚未启动而发生错误。)

fastapi_sample /泊坞窗/ wait-for-it.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#!/bin/sh

set -e

# 引数はdocker-compose.ymlで指定している。
# app:
#   ...
#   ..
#   .
#   entrypoint: /fastapi_sample/docker/wait-for-it.sh db 5432 postgres postgres db_fastapi_sample  # ココ
#   ...
host="$1"
shift
port="$1"
shift
user="$1"
shift
password="$1"
shift
database="$1"
shift
cmd="$@"

echo "Waiting for postgresql"
until pg_isready -h"$host" -U"$user" -p"$port" -d"$database"
do
  echo -n "."
  sleep 1
done

>&2 echo "PostgreSQL is up - executing command"
exec $cmd

# 僕は優しいのでMySQL用も書いてあげるのだ
#!/bin/sh

# set -e

# host="$1"
# shift
# user="$1"
# shift
# password="$1"
# shift
# cmd="$@"

# echo "Waiting for mysql"
# until mysqladmin ping -h "$host" --silent
# do
#     echo -n "."
#     sleep 1
# done

# >&2 echo "MySQL is up - executing command"
# exec $cmd

rundevserver.sh

用于启动uvicorn和启动应用程序的shell脚本文件。
?每次docker-compose up 都加载require.txt模块
用uvicorn启动应用程序
热重载吗?-重载(如果更改python文件,它将立即反映出来)
如果您将?-port更改为8000,则还需要在nginx的app.conf

中更改8000。

1
2
3
4
5
6
7
pip3 install -r requirements.txt

uvicorn main:app\
    --reload\
    --port 8000\
    --host 0.0.0.0\
    --log-level debug

最终目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fastapi_sample
├── Dockerfile
├── docker
│&nbsp;&nbsp; ├── nginx
│&nbsp;&nbsp; │&nbsp;&nbsp; ├── conf.d
│&nbsp;&nbsp; │&nbsp;&nbsp; │&nbsp;&nbsp; └── app.conf
│&nbsp;&nbsp; │&nbsp;&nbsp; └── ssl
│&nbsp;&nbsp; │&nbsp;&nbsp;     ├── server.crt
│&nbsp;&nbsp; │&nbsp;&nbsp;     ├── server.csr
│&nbsp;&nbsp; │&nbsp;&nbsp;     └── server.key
│&nbsp;&nbsp; ├── rundevserver.sh
│&nbsp;&nbsp; └── wait-for-it.sh
├── docker-compose.yml
├── main.py
└── requirements.txt

容器启动

1
$ docker-compose up -d

如果出现此错误,则Docker映像可能已用完磁盘,因此请使用docker image prune删除不需要的映像。 (我现在有40GB的可用磁盘空间?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.
..
...
Get:1 http://security.debian.org/debian-security buster/updates InRelease [65.4 kB]
Get:2 http://deb.debian.org/debian buster InRelease [122 kB]
Get:3 http://deb.debian.org/debian buster-updates InRelease [49.3 kB]
Err:1 http://security.debian.org/debian-security buster/updates InRelease
  At least one invalid signature was encountered.
Err:2 http://deb.debian.org/debian buster InRelease
  At least one invalid signature was encountered.
Err:3 http://deb.debian.org/debian buster-updates InRelease
  At least one invalid signature was encountered.
Reading package lists...
W: GPG error: http://security.debian.org/debian-security buster/updates InRelease: At least one invalid signature was encountered.
E: The repository 'http://security.debian.org/debian-security buster/updates InRelease' is not signed.
W: GPG error: http://deb.debian.org/debian buster InRelease: At least one invalid signature was encountered.
E: The repository 'http://deb.debian.org/debian buster InRelease' is not signed.
W: GPG error: http://deb.debian.org/debian buster-updates InRelease: At least one invalid signature was encountered.
E: The repository 'http://deb.debian.org/debian buster-updates InRelease' is not signed.
...
..
.

从浏览器访问

检查容器启动是否完成。
如果STATUS列为" Up ...",则可以。

1
2
3
4
5
$ docker ps
CONTAINER ID        IMAGE                  COMMAND                  CREATED              STATUS              PORTS                                      NAMES
b77fa2465fb1        nginx:alpine           "/docker-entrypoint.…"   About a minute ago   Up About a minute   0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp   nginx_fastapi_sample
eb18438efd2c        fastapi_sample_app     "/fastapi_sample/doc…"   About a minute ago   Up About a minute   8000/tcp                                   app_fastapi_sample
383b0e46af68        postgres:12.4-alpine   "docker-entrypoint.s…"   About a minute ago   Up About a minute   0.0.0.0:5432->5432/tcp                     db_fastapi_sample

访问

https://本地主机,如果显示{"message":"Hello World"},则操作完成。

使用库" pydantic"处理环境变量(.env)

.env文件创建

1
$ touch .env

fastapi_sample / .env

1
2
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample

创建一个配置文件以使用" pydatic"

读取环境变量

库``pydantic''使实现从.env读取值的过程变得容易。
这也是超级方便的,因为它可以根据模具进行铸造。
您无需编写令人讨厌的条件表达式,例如os.getenv('DEBUG') == 'True'。 (distutils.util.strtobool可以使用,但是...)

安装pydantic

fastapi_sample / requirements.txt

1
pydantic[email]==1.6.1  # 追記したらpip3 install

创建一个文件夹" core",并在其下直接创建" config.py"

1
2
$ mkdir core
$ touch core/config.py

fastapi_sample /核心/ config.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import os
from functools import lru_cache
from pydantic import BaseSettings

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


class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    debug: bool  # .envから読み込んだ値をbool型にキャッシュ
    database_url: str

    class Config:
        env_file = os.path.join(PROJECT_ROOT, '.env')


@lru_cache
def get_env():
    """ 「@lru_cache」でディスクから読み込んだ.envの結果をキャッシュする
    """
    return Environment()

准备Alembic(迁移工具)环境和模型

仍然安装

将Alembic和sqlalchemy添加到

requirements.txt并使用pip3 install -r requirements.txt 安装
(对于遵循Docker步骤的人员,为docker-compose restart appdocker-compose exec app pip3 install -r requiements.txt)

fastapi_samepl / requirements.txt

1
2
3
4
5
6
7
8
9
10
alembic==1.4.2  # 追記
.
..
...
psycopg2==2.8.6  # 追記
.
..
...
SQLAlchemy==1.3.18  # 追記
SQLAlchemy-Utils==0.36.8  # 追記

直接在项目根目录

下创建一个alembic环境

1
$ alembic init migrations  # Dockerの手順を踏んだ人はdocker-compose exec app alembic init migrations

将在项目根目录中创建一个Alembic模板。 (Alembic.ini,迁移文件夹)

1
2
3
4
5
6
7
8
9
10
11
fastapi_sample(プロジェクトルート)
├── ...
├── ..
├── .
├── alembic.ini
├── migrations
│&nbsp;&nbsp; ├── README
│&nbsp;&nbsp; ├── env.py
│&nbsp;&nbsp; ├── script.py.mako
│&nbsp;&nbsp; └── versions
└── ...

基本型号可用

首先,实现基本模型。

1
touch models.py

fastapi_sample /迁移/ models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import INTEGER, TIMESTAMP
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.sql.functions import current_timestamp

Base = declarative_base()


class BaseModel(Base):
    """ ベースモデル
    """
    __abstract__ = True

    id = Column(
        INTEGER,
        primary_key=True,
        autoincrement=True,
    )

    created_at = Column(
        'created_at',
        TIMESTAMP(timezone=True),
        server_default=current_timestamp(),
        nullable=False,
        comment='登録日時',
    )

    updated_at = Column(
        'updated_at',
        TIMESTAMP(timezone=True),
        onupdate=current_timestamp(),
        comment='最終更新日時',
    )

    @declared_attr
    def __mapper_args__(cls):
        """ デフォルトのオーダリングは主キーの昇順

        降順にしたい場合
        from sqlalchemy import desc
        # return {'order_by': desc('id')}
        """
        return {'order_by': 'id'}

用户模型可用

模型可以是任何模型,但是我将创建一个用户模型,因为在编写身份验证版本时它似乎可以使用。
我不敢划分模块。 (生成迁移文件很麻烦,因此最好不要这样做?)

fastapi_sample /迁移/ models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from sqlalchemy import (
    BOOLEAN,  # 追加
    Column
    INTEGER,
    TIMESTAMP,
    VARCHAR,  # 追加
)
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.sql.functions import current_timestamp

Base = declarative_base()


class BaseModel(Base):
    ...
    ..
    .


class User(BaseModel):
    __tablename__ = 'users'

    email = Column(VARCHAR(254), unique=True, nullable=False)
    password = Column(VARCHAR(128), nullable=False)
    last_name = Column(VARCHAR(100), nullable=False)
    first_name = Column(VARCHAR(100), nullable=False)
    is_admin = Column(BOOLEAN, nullable=False, default=False)
    is_active = Column(BOOLEAN, nullable=False, default=True)

修改环境

fastapi_sample /迁移/ env.py

1
2
3
4
5
6
7
8
from migrations.models import Base  # 追加
...
..
.
target_metadata = Base.metadata  # メタデータをセット
...
..
.

更改数据库连接目标

现在已经实现了

模型,我想立即执行迁移。
但是,由于alembic仍然是项目模板,因此alembic.ini中的数据库URL也是模板。

fastapi_sample / alembic.ini

1
2
3
4
5
6
7
.
..
...
sqlalchemy.url = driver://user:pass@localhost/dbname
...
..
.

由于它是

,因此,自然迁移文件生成命令等将失败。

1
2
3
4
5
6
7
8
9
10
11
$ docker-compose exec app alembic revision --autogenerate
Traceback (most recent call last):
  File "/root/local/python-3.8.5/bin/alembic", line 8, in <module>
    sys.exit(main())
  ...
  (長いので省略)
  .
    cls = registry.load(name)
  File "/root/local/python-3.8.5/lib/python3.8/site-packages/sqlalchemy/util/langhelpers.py", line 267, in load
    raise exc.NoSuchModuleError(
sqlalchemy.exc.NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:driver

最好直接重写alembic.ini,但这并不令人愉快,因为在开发环境,登台环境和生产环境中会创建不同的alembic.ini文件。
因此,在生成迁移文件或执行迁移时,请尝试使用.envDATABASE_URL临时重写alembic.ini的数据库URL。

安装python-dotenv

fastapi_sample / requirements.txt

1
python-dotenv==0.14.0  # 追記したらインストールする

固定env.py

fastapi_sample /迁移/ env.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import os
from core.config import PROJECT_ROOT
from dotenv import load_dotenv
.
..
...

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

# alembic.iniの'sqlalchemy.url'を.envのDATABASE_URLで書き換える
load_dotenv(dotenv_path=os.path.join(PROJECT_ROOT, '.env'))
config.set_main_option('sqlalchemy.url', os.getenv('DATABASE_URL'))


def run_migrations_offline():
    ...
    ..
    .

再次执行迁移文件生成命令

1
2
3
4
5
6
% docker-compose exec app alembic revision --autogenerate
None /fastapi_sample
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'users'
  Generating /fastapi_sample/migrations/versions/2bc0a23e563c_.py ...  done

迁移文件已生成。

1
2
3
4
5
6
migrations
├── ...
├── ...
├── models.py
└── versions
    └── 2bc0a23e563c_.py  # マイグレーションファイルができた。

但是,如果按原样放置,迁移文件的生成顺序一目了然,并且迁移文件中描述的创建日期(创建日期)仍为UTC,因此我想对其进行更改到日本时间。

修改

alembic.ini。

fastapi_sample / alembic.ini

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.
..
...
[alembic]
# path to migration scripts
script_location = migrations

# ファイルの接頭に「YYYYMMDD_HHMMSS_」がつくようにする
# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s

# タイムゾーンを日本時間に
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
timezone = Asia/Tokyo
...
..
.

删除先前生成的迁移文件,然后再次生成迁移文件。

1
2
3
4
5
6
migrations
├── ...
├── ...
├── models.py
└── versions
    └── 20201024_200033_8a19c4c579bf_.py  # ファイル内のCreate Dateも日本時間になっている

迁移运行

1
$ alembic upgrade head  # Dockerの手順を踏んだ人はdocker-compose exec app alembic upgrade head

用户表已正确创建。
(Alembic_version是alembic用于版本控制的表,并会自动生成)
image.png

允许在容器启动时运行迁移

现在,每次启动容器时,都会对其进行迁移。

fastapi_sample / docker / rundevserver.sh

1
2
3
4
5
6
7
8
9
pip3 install -r requirements.txt

alembic upgrade head  # 追記

uvicorn main:app\
    --reload\
    --port 8000\
    --host 0.0.0.0\
    --log-level debug

数据访问类的创建

创建基础数据访问类

描述所有案例的简单获取,一个案例的获取,注册,更新,删除等。
base.py中描述的generate_db_session()在稍后描述的请求中间件和测试代码执行中起着非常重要的作用,因此您现在不必担心它。

1
2
mkdir crud
touch crud/base.py

fastapi_sample /克鲁德/ base.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
from core.config import get_env
from migrations.models import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session, query
from typing import Generator, List, TypeVarfrom

ModelType = TypeVar("ModelType", bound=Base)

connection = create_engine(
    get_env().database_url,
    echo=get_env().debug,
    encoding='utf-8',
)


def db_session() -> scoped_session:
    """ 新しいDBコネクションを返す
    """
    return scoped_session(sessionmaker(connection))


def generate_db_session() -> Generator[scoped_session, None, None]:
    """ DBコネクションのジェネレータ
    """
    yield get_db_session()


class BaseCRUD:
    """ データアクセスクラスのベース
    """
    model: ModelType = None

    def __init__(self, db_session: scoped_session) -> None:
        self.db_session = db_session
        self.model.query = self.db_session.query_property()

    def get_query(self) -> query.Query:
        """ ベースのクエリ取得
        """
        return self.model.query

    def gets(self) -> List[ModelType]:
        """ 全件取得
        """
        return self.get_query().all()

    def get_by_id(self, id: int) -> ModelType:
        """ 主キーで取得
        """
        return self.get_query().filter_by(id=id).first()

    def create(self, data: dict = {}) -> ModelType:
        """ 新規登録
        """
        obj = self.model()
        for key, value in data.items():
            if hasattr(obj, key):
                setattr(obj, key, value)
        self.db_session.add(obj)
        self.db_session.flush()
        self.db_session.refresh(obj)
        return obj

    def update(self, obj: ModelType, data: dict = {}) -> ModelType:
        """ 更新
        """
        for key, value in data.items():
            if hasattr(obj, key):
                setattr(obj, key, value)
        self.db_session.flush()
        self.db_session.refresh(obj)
        return obj

    def delete_by_id(self, id: int) -> None:
        """ 主キーで削除
        """
        obj = self.get_by_id(id)
        if obj:
            obj.delete()
            self.db_session.flush()
        return None

创建用户数据访问类

1
touch crud/crud_user.py

fastapi_sample /克鲁德/ crud_user.py

1
2
3
4
5
6
7
8
from crud.base import BaseCRUD
from migrations.models import User


class CRUDUser(BaseCRUD):
    """ ユーザーデータアクセスクラスのベース
    """
    model = User

API实施

感谢您的耐心等待,它终于实现了API。

API实施准备

实现依赖关系注入(Depends)功能,该功能将DB会话存储在请求信息中。
这个快速API依赖注入系统非常强大,甚至可以组织参数。
抱歉,我还不完全了解它,但是请参考官方文档了解详细信息。

1
2
mkdir dependencies
touch dependencies/__init__.py

依赖项/ __ init__.py

1
2
3
4
5
6
7
8
9
10
11
12
from fastapi import Depends, Request
from crud.base import generate_db_session
from sqlalchemy.orm import scoped_session


async def set_db_session_in_request(
    request: Request,
    db_session: scoped_session = Depends(generate_db_session)
):
    """ リクエストにDBセッションをセットする
    """
    request.state.db_session = db_session

创建一个实现API

的文件夹

1
mkdir -p api/v1

用户列表获取API实施

1
touch api/v1/user.py

fastapi_sample / api / v1 / user.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from crud.crud_user import CRUDUser
from fastapi import Request
from fastapi.encoders import jsonable_encoder


class UserAPI:
    """ ユーザーに関するAPI
    """
    @classmethod
    def gets(cls, request: Request):
        """ 一覧取得
        """
        # 依存性注入システムを用いて、リクエスト情報にDBセッションをセットしたので、
        # ここで「request.state.db_session」を使用することができる
        return jsonable_encoder(CRUDUser(request.state.db_session).gets())

API路由器实现

路由器dependencies=[Depends(set_db_session_in_request)]在未来将扮演重要角色

1
2
mkdir -p api/endpoints/v1
touch api/endpoints/v1/user.py

fastapi_sample / api /端点/ v1 / user.py

1
2
3
4
5
6
7
8
9
10
11
12
from api.v1.user import UserAPI
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request

router = APIRouter()


@router.get(
    '/',
    dependencies=[Depends(set_db_session_in_request)])
async def gets(request: Request):
    return UserAPI.gets(request)

将路由器注册为入口点

创建API路由器

1
touch api/endpoints/v1/__init__.py

fastapi_sample / api /端点/ v1 / __ init__.py

1
2
3
4
5
6
7
8
from fastapi import APIRouter
from api.endpoints.v1 import user

api_v1_router = APIRouter()
api_v1_router.include_router(
    user.router,
    prefix='users',
    tags=['users'])

编辑main.py

fastapi_sample / main.py

1
2
3
4
5
6
7
8
9
10
11
from api.endpoints.v1 import api_v1_router  # 追記
from fastapi import FastAPI

app = FastAPI()

app.include_router(api_v1_router, prefix='/api/v1')  # 追記


@app.get("/")
async def root():
    return {"message": "Hello World"}

从浏览器访问https:// // localhost / docs,您应该看到已实现的API。
image.png

定义架构(表单?)进行注册/更新

1
2
mkdir api/schemas
touch api/schemas/user.py

fastapi_sample / api /模式/ user.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pydantic import BaseModel, EmailStr
from typing import Optional


class BaseUser(BaseModel):
    email: EmailStr
    last_name: str
    first_name: str
    is_admin: bool


class CreateUser(BaseUser):
    password: str


class UpdateUser(BaseUser):
    password: Optional[str]
    last_name: Optional[str]
    first_name: Optional[str]
    is_admin: bool


class UserInDB(BaseUser):
    class Config:
        orm_mode = True

添加了用于用户注册/更新/删除的API

fastapi_sample / api / v1 / user.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from api.schemas.user import CreateUser, UpdateUser, UserInDB
from crud.crud_user import CRUDUser
from fastapi import Request
# from fastapi.encoders import jsonable_encoder  # 削除
from typing import List


class UserAPI:
    """ ユーザーに関するAPI
    """
    @classmethod
    def gets(cls, request: Request) -> List[UserInDB]:
        """ 一覧取得
        """
        return CRUDUser(request.state.db_session).gets()  # jsonable_encoderは使わない

    @classmethod
    def create(
        cls,
        request: Request,
        schema: CreateUser
    ) -> UserInDB:
        """ 新規登録
        """
        return CRUDUser(request.state.db_session).create(schema.dict())

    @classmethod
    def update(
        cls,
        request: Request,
        id: int,
        schema: UpdateUser
    ) -> UserInDB:
        """ 更新
        """
        crud = CRUDUser(request.state.db_session)
        obj = crud.get_by_id(id)
        return CRUDUser(request.state.db_session).update(obj, schema.dict())

    @classmethod
    def delete(cls, request: Request, id: int) -> None:
        """ 削除
        """
        return CRUDUser(request.state.db_session).delete_by_id(id)

编辑用户的API路由器

fastapi_sample / api /端点/ v1 / user.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from api.schemas.user import CreateUser, UpdateUser, UserInDB
from api.v1.user import UserAPI
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request
from typing import List

router = APIRouter()


@router.get(
    '/',
    response_model=List[UserInDB],  # response_modelを追加
    dependencies=[Depends(set_db_session_in_request)])
def gets(request: Request) -> List[UserInDB]:
    """ 一覧取得
    """
    return UserAPI.gets(request)


@router.post(
    '/',
    response_model=UserInDB,
    dependencies=[Depends(set_db_session_in_request)])
def create(request: Request, schema: CreateUser) -> UserInDB:
    """ 新規登録
    """
    return UserAPI.create(request, schema)


@router.put(
    '/{id}/',
    response_model=UserInDB,
    dependencies=[Depends(set_db_session_in_request)])
def update(request: Request, id: int, schema: UpdateUser) -> UserInDB:
    """ 更新
    """
    return UserAPI.update(request, id, schema)


@router.delete(
    '/{id}/',
    dependencies=[Depends(set_db_session_in_request)])
def delete(request: Request, id: int) -> None:
    """ 削除
    """
    return UserAPI.delete(request, id)

从浏览器访问并检查

您已成功实现CRUD API。
image.png

路由器指定response_model无需开发人员显式实现从数据库中检索到的对象的JSON序列化。 (由于此,我不必使用jsonable_encoder())
它还将反映在swagger-UI上。
*列表获取响应
image.png

实施请求中间件

好的,我能够执行API,但是由于我尚未执行数据库会话的提交,因此即使执行注册/更新/删除操作,数据库也不会更改。
为了反映数据库中的更改,请实施并应用以下请求中间件。
?请求完成后,提交数据库会话,然后放弃该数据库会话
?如果发生错误,请回滚,然后放弃数据库会话

1
2
mkdir middleware
touch middleware/__init__.py

fastapi_sample /中间件/ __ init__.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)

        # 予期せぬ例外
        except Exception as e:
            print(e)
            request.state.db_session.rollback()
            raise e

        # 正常終了時
        else:
            request.state.db_session.commit()
            return response

        # DBセッションの破棄は必ず行う
        finally:
            request.state.db_session.remove()

将中间件应用到入口点

fastapi_sample / main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from api.endpoints.v1 import api_v1_router
from fastapi import FastAPI
from middleware import HttpRequestMiddleware  # 追加

app = FastAPI()

app.include_router(api_v1_router, prefix='/api/v1')

# ミドルウェアの設定
app.add_middleware(HttpRequestMiddleware)  # 追加


@app.get("/")
async def root():
    return {"message": "Hello World"}

通过执行CRUD

进行确认

现在它已安全地反映在数据库中。
image.png

测试代码实现

现在我们已经实现了

CRUD,是时候实现测试代码了。
但是,如果仅从测试代码执行API,则会在数据库中对其进行注册,更新和删除,因此每次执行测试时结果可能会有所不同。
为防止这种情况,请创建一个测试数据库并在运行测试时使用它。

安装pytest

fastapi_sample / requirements.txt

1
pytest==6.1.0  # 追記したらrequirements.txtをインストールし直すのを忘れずに

向环境变量

添加了测试数据库连接信息

fastapi_sample / .env

1
2
3
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample
TEST_DATABASE_URL=postgresql://postgres:postgres@db:5432/test_db_fastapi_sample  # 追加

fastapi_sample /核心/ config.py

1
2
3
4
5
6
class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    debug: bool
    database_url: str
    test_database_url: str  # 追加

创建用于实施测试代码

的文件夹

1
mkdir tests

创建conftest.py

一个实现pytest设置代码的文件。
pytest_sessionstart:执行pytest时调用的函数(在此处创建测试数据库)
pytest_sessionfinish:在所有测试类和测试用例由pytest执行后调用的函数(此处删除测试数据库)

1
$ touch tests/conftest.py

fastapi_sample /测试/ conftest.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import psycopg2
from core.config import get_env
from migrations.models import Base
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy_utils import database_exists, drop_database
from tests.base import test_db_connection

# conftestで初期データを登録する場合はこのSessionを使用する
# Session = scoped_session(
#     sessionmaker(
#         bind=test_db_connection
#     )
# )


def create_test_database():
    # テストDBが削除されずに残ってしまっている場合は削除
    if database_exists(get_env().test_database_url):
        drop_database(get_env().test_database_url)

    # テストDB作成
    _con = \
        psycopg2.connect('host=db user=postgres password=postgres')
    _con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
    _cursor = _con.cursor()
    _cursor.execute('CREATE DATABASE test_db_fastapi_sample')

    # テストDBにテーブル追加
    Base.metadata.create_all(bind=test_db_connection)


def pytest_sessionstart(session):
    """ pytest実行時に一度だけ呼ばれる処理
    """
    # テストDB作成
    create_test_database()


def pytest_sessionfinish(session, exitstatus):
    """ pytest終了時に一度だけ呼ばれる処理
    """
    # テストDB削除
    if database_exists(get_env().test_database_url):
        drop_database(get_env().test_database_url)

创建数据库会话管理类以进行测试

1
$ touch tests/db_session.py

fastapi_sample /测试/ db_session.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
from core.config import get_env
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker, Session
from threading import local as thread_local

_thread_local = thread_local()


def set_current_test_db_session(db_session: scoped_session):
    """ スレッドローカルにDBセッションをセット
    """
    setattr(_thread_local, 'db_session', db_session)


def get_current_test_db_session() -> scoped_session:
    """ スレッドローカルからDBセッションを取得
    """
    return getattr(_thread_local, 'db_session')


class TestingDBSession(Session):
    """ commit()の挙動を変えるため、Sessionクラスをオーバーライド
    """
    def commit(self):
        # データアクセスクラス(fastapi_sample/crud)やAPIの中でflush()は実行する想定なので、
        # ここでflush()はとりあえず不要
        # self.flush()
        self.expire_all()


class test_scoped_session(scoped_session):
    """ リクエストミドルウェアのremove()で何も実行されないように、scoped_sessionクラスをオーバーライド
    """
    def remove(self):
        pass

    def test_db_session_remove(self):
        """ テストDB用のremove()
        """
        if self.registry.has():
            self.registry().close()
        self.registry.clear()


test_db_connection = create_engine(
    get_env().test_database_url,
    encoding='utf8',
    pool_pre_ping=True,
)


def get_test_db_session():
    """ テストDBセッションを返す
    """
    return test_scoped_session(
        sessionmaker(
            bind=test_db_connection,
            class_=TestingDBSession,
            expire_on_commit=False,
        )
    )

创建API基本测试类

1
touch tests/base.py

fastapi_sample /测试/ base.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from crud.base import generate_db_session
from fastapi.testclient import TestClient
from main import app
from tests.db_session import get_test_db_session, set_current_test_db_session


class BaseTestCase:
    def setup_method(self, method):
        """ 前処理
        """
        self.db_session = get_test_db_session()
        set_current_test_db_session(self.db_session)  # スレッドローカルにDBセッションをセット

        # APIクライアントの設定
        self.client = TestClient(app, base_url='https://localhost',)

        # DBをテスト用のDBでオーバーライド
        app.dependency_overrides[generate_db_session] = \
            self.override_generate_db_session

    def teardown_method(self, method):
        """ 後処理
        """
        self.db_session.test_db_session_remove()  # ロールバック

        # オーバーライドしたDBを元に戻す
        app.dependency_overrides[self.override_get_db] = \
            get_db_session

    def override_generate_db_session(self):
        """ DBセッションの依存性オーバーライド関数
        """
        yield self.db_session

点是此数据库覆盖部分。
应用程序引用的数据库将切换到测试数据库。

1
2
# DBをテスト用のDBでオーバーライド
app.dependency_overrides[generate_db_session] = self.override_generate_db_session

image.png
*图像的函数名称为" get_db_session"