跳到内容

代码结构和多文件

让我们停下来思考一下如何组织代码结构,特别是在具有多个文件的大型项目中。

循环导入

Hero 在内部引用了类 Team

但是类 Team 也引用了类 Hero

因此,如果这两个类位于不同的文件中,并且您尝试在彼此的文件中直接导入这些类,则会导致循环导入。 🔄

Python 将无法处理它,并会抛出一个错误。 🚨

但实际上我们想要表达的是循环引用,因为在我们的代码中,我们可以做一些疯狂的事情,例如:

hero.team.heroes[0].team.heroes[1].team.heroes[2].name

而这种循环引用正是我们用这些关系属性表达的,即:

  • 一个 hero 可以有一个 team
    • 一个 team 可以有一个 hero 列表
      • 每个 hero 都可以有一个 team
        • ...等等。

让我们看看不同的策略来组织代码结构,以解决这个问题。

模型的单个模块

这是最简单的方法。 ✨

在这个解决方案中,我们仍然使用多个文件,分别用于 modelsdatabaseapp

并且我们可以拥有任何其他必要的文件

但在第一个例子中,所有的模型都将存在于一个单个文件中。

项目的目录结构可以是:

.
├── project
    ├── __init__.py
    ├── app.py
    ├── database.py
    └── models.py

我们有 3 个 Python 模块(或文件):

  • app
  • database
  • models

并且我们还有一个空的 __init__.py 文件,使这个项目成为一个 “Python 包”(Python 模块的集合)。 这样我们就可以在 app.py 文件/模块中使用相对导入,例如:

from .models import Hero, Team
from .database import engine

我们可以使用这些相对导入,因为例如,在 app.py 文件(app 模块)中,Python 知道它是我们 Python 包的一部分,因为它与 __init__.py 文件在同一目录下。 并且同一目录下的所有 Python 文件也是同一 Python 包的一部分。

模型文件

您可以将所有数据库模型放在一个单独的 Python 模块(单个 Python 文件)中,例如 models.py

from typing import List, Optional

from sqlmodel import Field, Relationship, SQLModel


class Team(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    headquarters: str

    heroes: List["Hero"] = Relationship(back_populates="team")


class Hero(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    secret_name: str
    age: Optional[int] = Field(default=None, index=True)

    team_id: Optional[int] = Field(default=None, foreign_key="team.id")
    team: Optional[Team] = Relationship(back_populates="heroes")

这样,您就不必处理其他模型的循环导入问题。

然后,您可以从应用程序中的任何其他文件/模块导入此文件/模块中的模型。

数据库文件

然后,您可以将创建 engine 的代码和创建所有表的函数(如果您不使用迁移)放在另一个文件 database.py 中。

from sqlmodel import SQLModel, create_engine

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

您的应用程序代码也将导入此文件,以使用共享的 engine 并获取和调用函数 create_db_and_tables()

应用文件

最后,您可以将创建 app 的代码放在另一个文件 app.py 中。

from sqlmodel import Session

from .database import create_db_and_tables, engine
from .models import Hero, Team


def create_heroes():
    with Session(engine) as session:
        team_z_force = Team(name="Z-Force", headquarters="Sister Margaret's Bar")

        hero_deadpond = Hero(
            name="Deadpond", secret_name="Dive Wilson", team=team_z_force
        )
        session.add(hero_deadpond)
        session.commit()

        session.refresh(hero_deadpond)

        print("Created hero:", hero_deadpond)
        print("Hero's team:", hero_deadpond.team)


def main():
    create_db_and_tables()
    create_heroes()


if __name__ == "__main__":
    main()

在这里,我们导入模型、引擎和创建所有表的函数,然后我们可以在内部使用它们。

顺序很重要

还记得调用 SQLModel.metadata.create_all()顺序很重要吗?

文档中该部分的关键在于,您必须在调用 SQLModel.metadata.create_all() 之前导入包含模型的模块。

我们在这里这样做了,我们在 app.py 中导入模型,之后我们创建数据库和表,所以我们没问题,一切正常。 👌

在命令行中运行它

因为现在这是一个更大的项目,包含一个 Python 包,而不是单个 Python 文件,我们不能像以前那样仅传递单个文件名来调用它:

$ python app.py

现在我们必须告诉 Python 我们要执行一个属于包的模块

$ python -m project.app

-m 是告诉 Python 调用一个模块。 接下来我们传递的是一个字符串 project.app,这与我们在 import 中使用的格式相同:

import project.app

然后 Python 将执行该包内部的模块,并且由于 Python 直接执行它,我们在 app.py 中拥有的主代码块的相同技巧仍然有效:

if __name__ == '__main__':
    main()

所以,输出将是:

$ python -m project.app

Created hero: id=1 secret_name='Dive Wilson' team_id=1 name='Deadpond' age=None
Hero's team: name='Z-Force' headquarters='Sister Margaret's Bar' id=1

使循环导入工作

假设出于某种原因,您讨厌将所有数据库模型放在单个文件中的想法,并且您真的希望拥有单独的文件,例如 hero_model.py 文件和 team_model.py 文件。

您也可以这样做。 😎 有几件事需要记住。 🤓

警告

这有点高级。

如果上面的解决方案已经对您有效,那么这可能对您来说已经足够了,您可以继续下一章。 🤓

假设现在的目录结构是:

.
├── project
    ├── __init__.py
    ├── app.py
    ├── database.py
    ├── hero_model.py
    └── team_model.py

循环导入和类型注解

循环导入的问题在于 Python 无法在 运行时 解析它们。

但是,当使用 Python 类型注解时,通常需要使用从其他文件导入的类来声明某些变量的类型。

并且包含这些类的文件可能也需要从第一个文件导入更多内容。

这最终需要与 Python 在运行时不支持的循环导入相同。

类型注解和运行时

但是我们想要声明的这些类型注解运行时是不需要的。

实际上,还记得我们使用带有字符串 "Hero"list["Hero"] 吗?

对于 Python 来说,在运行时,这只是一个字符串

因此,如果我们能够使用字符串版本添加我们需要的类型注解,Python 就不会有问题。

但是,如果我们只是在类型注解中放置字符串,而不导入任何内容,编辑器将不知道我们的意思,并且无法帮助我们进行自动补全内联错误

因此,如果有一种方法可以“导入”某些东西,使其仅在编辑代码时充当“已导入”,而不是在运行时,那就可以解决问题了... 而且它确实存在! 正是如此。 🎉

仅在编辑时使用 TYPE_CHECKING 导入

为了解决这个问题,typing 模块中有一个特殊的技巧,使用一个特殊的变量 TYPE_CHECKING

对于使用类型注解分析代码的编辑器和工具,它的值为 True

但是当 Python 执行时,它的值是 False

因此,我们可以在 if 代码块中使用它,并在 if 代码块内部导入内容。 它们将仅为编辑器“导入”,而不是在运行时导入。

Hero 模型文件

使用 TYPE_CHECKING 的技巧,我们可以在 hero_model.py 中“导入” Team

from typing import TYPE_CHECKING, Optional

from sqlmodel import Field, Relationship, SQLModel

if TYPE_CHECKING:
    from .team_model import Team


class Hero(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    secret_name: str
    age: Optional[int] = Field(default=None, index=True)

    team_id: Optional[int] = Field(default=None, foreign_key="team.id")
    team: Optional["Team"] = Relationship(back_populates="heroes")

请记住,现在我们必须Team 的注解写成字符串:"Team",这样 Python 在运行时就不会出错。

Team 模型文件

我们在 team_model.py 文件中使用相同的技巧:

from typing import TYPE_CHECKING, List, Optional

from sqlmodel import Field, Relationship, SQLModel

if TYPE_CHECKING:
    from .hero_model import Hero


class Team(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    headquarters: str

    heroes: List["Hero"] = Relationship(back_populates="team")

现在我们获得了编辑器支持、自动补全、内联错误,并且 SQLModel 仍然可以正常工作。 🎉

App 文件

现在,为了完整起见,app.py 文件将从两个模块导入模型:

from sqlmodel import Session

from .database import create_db_and_tables, engine
from .hero_model import Hero
from .team_model import Team


def create_heroes():
    with Session(engine) as session:
        team_z_force = Team(name="Z-Force", headquarters="Sister Margaret's Bar")

        hero_deadpond = Hero(
            name="Deadpond", secret_name="Dive Wilson", team=team_z_force
        )
        session.add(hero_deadpond)
        session.commit()

        session.refresh(hero_deadpond)

        print("Created hero:", hero_deadpond)
        print("Hero's team:", hero_deadpond.team)


def main():
    create_db_and_tables()
    create_heroes()


if __name__ == "__main__":
    main()

当然,所有关于 TYPE_CHECKING 和字符串类型注解的技巧仅在具有循环导入的文件中才需要

由于 app.py 没有循环导入,我们可以在这里像往常一样使用正常的导入和类。

运行它会达到与之前相同的结果:

$ python -m project.app

Created hero: id=1 age=None name='Deadpond' secret_name='Dive Wilson' team_id=1
Hero's team: id=1 name='Z-Force' headquarters='Sister Margaret's Bar'

回顾

对于最简单的情况(对于大多数情况),您可以将所有模型放在单个文件中,并根据需要将应用程序的其余部分(包括设置 engine)组织到任意数量的文件中。

对于真正需要将所有模型分隔到不同文件中的复杂情况,您可以使用 TYPE_CHECKING 使一切正常工作,并且仍然拥有最佳的开发者体验和最佳的编辑器支持。 ✨