FastAPI与依赖注入模式

shabbywu大约 11 分钟python

序言

如果说我看得比别人更远些,那是因为我站在巨人的肩膀上。 —— 牛顿

FastAPIopen in new window 是一个完全兼容 OpenAPI 开放标准,主要用于编写 API 的高性能 web 框架。 之所以说 FastAPI 站在巨人的肩膀之上,是因为虽然 FastAPI 是一个 web 框架,但实际上作为 Web 框架所需要的基础设施(Session、Cookie、CORS、ASGI...)完全由底层的 Starletteopen in new window 提供,而对于类型校验和文档生成又依托于 Pydanticopen in new window 来实现,自身则充当了两者的粘合剂。

场景演示

由于 FastAPI 设计精简,文档国际化完善,本文不会介绍 如何使用 FastAPIopen in new window,而是从一个虚构的应用场景介绍 FastAPI 的特性。 设想一个应用场景: 需要封装一个往 Elasticsearch 查询日志的接口,并需要支持部分 DSL 查询语法。

使用 Django REST framework

常见的 Django View 主体逻辑一般可以划分为 3 个部分:参数校验与类型转换业务逻辑返回值序列化。如果要实现虚构常见中的需求,我们需要一个 APIView、一个 DSLSerializer、一个 ResponseSerializer 和一个 ESClient。同时,为了启动项目,还需要一个 urlconf、django app......

## serializers.py
from rest_framework import serializers


class QuerySerializer(serializers.Serializer):
    query_string = serializers.CharField(help_text="使用 `query_string` 语法进行搜索")
    terms = serializers.DictField(help_text="使用 `terms` 精准匹配")
    exclude = serializers.DictField(help_text="使用 `terms` 精准匹配, 但条件取反")


class DSLSerializer(serializers.Serializer):
    query = QuerySerializer()
    sort = serializers.DictField(help="key为排序字段, value 为 desc 或 asc")


class LogSerializer(serializers.Serializer):
    ts = serializers.CharField(help="日志产生的时间戳")
    message = serializers.DictField(help="日志信息")


class ResponseSerializer(serializers.Serializer):
    logs = LogSerializer(many=True)


## client.py
from elasticsearch import Elasticsearch
from django.conf import settings

class ESClient:
    def __init__(self):
        self.client = Elasticsearch(settings.ELASTICSEARCH_HOSTS)
        self.indexes = settings.ELASTICSEARCH_INDEXES
    
    def query(self, dsl):
        q = Search(using=self.client, index=self.indexes).query(dsl)
        resp = q.execute()
        return resp


## view.py
from rest_framework.views import APIView
from rest_framework.response import Response
from elasticsearch_dsl import Index, Search


class LogQueryView(APIView):
    def post(self, request):
        ## 参数校验
        dsl_serializer = DSLSerializer(request.data)
        dsl_serializer.is_valid(raise_exception=True)
        dsl = dsl_serializer.data
        ## 获取 ES client并查询
        client = ESClient()
        logs = client.query(dsl)
        ## 返回值序列化
        return Response(ResponseSerializer(logs=logs).data)

使用 FastAPI

FastAPI 的关键特性在于其通过不同的参数声明实现丰富功能,使代码重复最小化。 与 DRF 相比,FastAPI 可以省下较多累赘的代码,提高开发效率。

## schemas.py
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from fastapi import Depends, FastAPI


class DSLQueryItem(BaseModel):
    query_string: str = Field(None, description="使用 `query_string` 语法进行搜索")
    terms: Dict[str, List[str]] = Field({}, description="多值精准匹配")
    exclude: Dict[str, List[str]] = Field({}, description="terms取反, 非标准 DSL")


class SimpleDomainSpecialLanguage(BaseModel):
    query: DSLQueryItem
    sort: Optional[Dict] = Field({}, description='排序,e.g. {"response_time": "desc", "other": "asc"}')


class LogLine(BaseModel):
    ts: str = Field(..., description="日志产生的时间戳")
    message: str = Field(..., description="日志信息")

## client.py
from elasticsearch import Elasticsearch
from starlette.config import Config

## 从环境变量或文件读取配置
config = Config(env_file=None)


class ESClient:
    def __init__(self):
        self.client = Elasticsearch(config.ELASTICSEARCH_HOSTS)
        self.indexes = config.ELASTICSEARCH_INDEXES
    
    def query(self, dsl) -> List[LogLine]:
        q = Search(using=self.client, index=self.indexes).query(dsl)
        resp = q.execute()
        return [LogLine.parse_obj(line) for line in resp]

## view.py
app = FastAPI()


@app.post("/logs/", response_model=List[LogLine])
async def query_log(dsl: SimpleDomainSpecialLanguage, client: ESClient = Depends(ESClient)):
    return client.query(dsl)


if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app)

与常见的 web 框架不同,FastAPI 的接口函数,给人一种写了没写的感觉,因为实在是太简单了。 它不使用 request 对象来描述单次请求的上下文,而是接口需要什么参数,即声明具体的依赖,不冗余,不累赘。框架在启动前分析出具体接口的依赖关系,在接收请求时即构造对应的依赖对象,最后调用对应的API,完成整个请求调用流程。

FastAPI 是怎样做到的?

FastAPI 的这种行为模式被称之为依赖注入,依赖注入是依据控制反转原则而产生的一种设计模式。想了解依赖注入的原理,就需要先了解控制反转原则。

控制反转

不要打电话给我们,我们会打电话给您。 —— 好莱坞原则

控制反转是设计框架中常见的设计模式,实际上,控制反转通常被视为框架的定义特征。 一般而言,用户定义的函数应该被用户自身的代码所调用,而控制反转模式则提倡用户函数应该被开发框架本身调用,框架在整个应用中充当了主程序的角色,函数调用的控制权被反转了。 例如,在编写 CLI 程序中,如果不使用控制反转原则,常见的流程如下:

import argparse


def summation():
    """输入一个整数列表并计算总和"""
    parser = argparse.ArgumentParser(description='Process some integers.')
    parser.add_argument('integers', metavar='N', type=int, nargs='+',
                        help='an integer for the accumulator')
    args = parser.parse_args()
    print(sum(args.integers))


if __name__ == '__main__':
    summation()

如果使用控制反转, 相关的代码就变成:

import typer  ## typer 是基于 type hints 的 CLI 程序框架
from typing import List


app = typer.Typer()

@app.command()
def summation(intergers: List[int] = typer.Argument(..., help='an integer for the accumulator')):
    """输入一个整数列表并计算总和"""
    print(sum(intergers))


if __name__ == '__main__':
    app()

这两个程序之间的控制流程最大的差异在于,何时执行 summation,当使用框架编程时,调用 summation 的控制权被转移至框架手上,只有参数传递正确时,summation 才会被框架调用,这种现象就是所谓的控制反转

依赖注入与组成根模式

依赖注入控制反转常被相提并论,实际上两则并非同样的概念。控制反转强调的是代码控制流程的反转,而依赖注入强调的是对象初始化的控制权反转。因此,我们可以认为依赖注入是控制转移的具体实现之一。
依赖注入的核心思想是由框架提供一种与类定义无关的构造依赖图的机制,由框架保证依赖的构造时机和顺序。一般而言,依赖注入分为两大步骤,分别是对象构造对象注入

对象构造

依据对象构造的实现方式可以将分成两大类型,分别是 Constructor Injection(基于构造函数注入)Interface Injection(基于接口注入)

Constructor Injection

在基于构造函数注入中,类所需的依赖项作为构造函数的参数提供(FastAPI 实现了该类型的依赖注入)。例如,我们可以如下声明一个接口和对应的依赖:

import datetime
from pydantic import BaseModel
from fastapi import Depends, FastAPI
from typing import Optional, List


class LimitOffsetPagination:
    def __init__(self, limit: Optional[int] = 20, offset: Optional[int] = 10):
        self.limit = limit
        self.offset = offset


app = FastAPI()


@app.get("/list_something/")
async def list_something(pagination: LimitOffsetPagination = Depends(LimitOffsetPagination)):
    ...


if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app)

在 FastAPI 框架中,使用构造函数来决定如何注入一个依赖,在该案例中则是如何构造一个 LimitOffsetPagination 对象。
在接口调用的过程中,依赖的构造和传递均是由 FastAPI 框架完成的,简化了接口调用前置的初始化工作(参数校验和类型转换等), 保证了接口只会出现相应的业务逻辑代码,提高了开发效率,对此 FastAPI 号称能提高功能开发速度约 200% 至 300%

Interface Injection

在基于接口注入中,类所需的依赖项由预先定义的接口进行赋值。与基于构造函数注入最大的不同点在于,基于接口注入的类对象允许为该属性预设默认值

提示

在基于接口注入的具体实现中,常见的一类型是基于 setter 进行赋值,因而也会细分成 Setter InjectionProperty Injection
由于具体依赖注入的实现的差异,会有人将类似 obj.property = value 这样的属性注入认为是不同于 Interface Injection 的另一种实现方式。实际上两则的差异仅在于编程语言的具体实现细节之上,如果认为类属性也是对象,而对属性赋值是隐式调用该属性的 setter 方法时,那么在形式上两则是等同的。

在 Java 等静态语言中,基于接口的依赖注入最为常见,也由此诞生了一个专有名词:JavaBean。所谓 JavaBean,是指遵循以下规范定义的类:

  • 提供一个默认的无参构造函数
  • 包含若干属于 private 级别的实例字段
  • 包含若干属于 public 的 getter 或 setter 方法
  • 可被序列化并实现 Serializable 接口
public class School {
    private String name;
    public String getName() {return this.name;}
    public String setName(String name) {this.name = name;}
}

public class Graduate {
    private String name;
    private School school;
    private Date graduationDate;

    public String getName() {return this.name;}
    public void setName(String name) {this.name = name;}

    public School getSchool() {return this.school;}
    public void setSchool(School school) {this.school = school;}

    public Date getGraduationDate() {return this.graduationDate;}
    public void setGraduationDate(Date graduationDate) {this.graduationDate = graduationDate;}
}

在 Spring 框架中,最常见的依赖注入方式则是 Setter Injection。Spring 框架支持通过多种方式声明对象配置,常见的方案是使用 XML 文件进行配置,例如:

<beans>
    <bean id="School" class="School">
        <property name="name">
            <value>some school name</value>
        </property>
    </bean>
    <bean id="Graduate" class="Graduate">
        <property name="name">
            <value>some body name</value>
        </property>
        <property name="school">
            <ref local="School"/>
        </property>
        <property name="graduationDate">
            <value>yyyy-MM-dd</value>
        </property>
    </bean>
</beans>

组成根模式

对象构造流程中,每个类都通过构造函数或接口声明了其所需的依赖,但这却将注入具体依赖项的行为委托于第三方实现,那这应该由谁维护这些依赖关系呢?为了解决这个问题,依赖注入模式提出了The Composition Root Pattern(组成根模式)
组成根模式认为,应当在最接近应用程序入口的地方提供唯一的组合各模块的切面,也就是说,组成根模式的核心思想在于在程序启动之初即维护一个依赖容器(也可称之为上下文),该容器应当具有构建所有依赖项所需的配置。 在实际实现中,每个类仅负责通过构造函数、接口或其他方式声明所需依赖,当程序启动时实例化依赖容器,借助依赖容器将依赖图“编译”成对象图。 以 FastAPI 框架为例,在程序启动时,FastAPI 会依据接口声明的依赖(Depends)构造依赖图。当请求进入时,FastAPI 依据 OpenAPI 定义的接口规范,解析请求参数,构造出相应的依赖容器,并依据依赖图中的依赖关系构造相应的对象,再传递进接口函数,完成整个调用流程。

总结

在实际编程中,使用依赖注入可以大大简化参数校验和类型转换的代码,使得接口的代码几乎完全是业务的核心逻辑,不仅能提高开发效率,还能降低代码的维护成本
同时,对于接口的集成测试,也无需像一般的 Web 框架一样构造 request 对象,只需构造 API 所需的依赖对象,即可直接进行集成测试
虽然引入依赖注入的好处明显,但是会使得调用链路复杂化。同时引入“”的概念会提高业务模块划分的要求,需避免依赖图中出现“”,否则将使得依赖关系无法解决,因此该类型框架不适合应用于复杂场景

附录:

基于 OpenAPI 的接口文档自动生成

在生成依赖图的过程中,FastAPI 将输入、输出参数转换为 Pydantic.ModelField。借助 Pydantic 将 ModelField 转换为 JSON Schema,而这恰好也是 OpenAPI 所兼容的一种类型描述,因此相对于依赖注入功能,基于 OpenAPI 的接口文档自动生成能力更像是一个附赠品,或者说是依赖图的一种可视化形式。