FastAPI 工程化

12Tall
  • Python
  • fastapi
  • router
  • asgi
  • jinja2
  • lulu-ui
大约 6 分钟

从asp.NETopen in new window 到前后分离,到vue+koa2,兜兜转转又到了模板渲染。以前开发看逼格,现在开发看速度。SSR 有啥不好的,又不是不能用只要把内容展示出来就好了呗。

FastAPI

Koa2 有各种灵活的插件,FastAPI 继承了路由和参数解析的功能,并且还附带了文档的功能,这样省了接口测试的功夫。并且如果配合Jinja2lulu-ui 完全能做出不错的页面出来。下面是一个最基本的后端代码:

import uvicorn
from fastapi import FastAPI, Request, templating, staticfiles

app = FastAPI()

# 挂载静态资源,前后端的绝对路径能够保持一致
app.mount("/static", staticfiles.StaticFiles(directory="static"), name="static")

# Jinja2 模板的目录
templates = templating.Jinja2Templates(directory='templates')


@app.get('/')
# @app.post('/alias')  ## 多个路由可以匹配同一个处理函数
def index(request: Request):
    return templates.TemplateResponse('index.html', {
        'request': request,
        'warning_text': 'hello world'
    })

if __name__ == "__main__":
    """main 函数与官方写法不一样是为了方便使用Nuitka 打包"""
    uvicorn.run(app, port=9000, host='0.0.0.0')





















 

项目的文件结构如下:
Alt text

这样组织static 文件夹,可以让前后端引用js 的路径一致,便于开发时预览页面效果。

路由

FastAPI 的路由写法还不算反人类,只是需要很多的import,不过要注意路由匹配顺序是自上而下的

from . import *** 这种写法不能用在main 模块中,不然会报错

Alt text

中间件

FastAPI 中间件本质上是一个异步函数,包含request, call_next 两个参数。

中间件可以在main 模块定义:

@app.middleware("http")
# 必须用 async
async def add_process_time_header(request: Request, call_next):
    start_time = time.time()
    # 必须用 await
    response = await call_next(request)
    process_time = time.time() - start_time
    # 自定义请求头
    response.headers["X-Process-Time"] = str(process_time)
    # 返回响应
    return response





 





也可以在单独的文件中定义(此方法的执行效率似乎更高一些):

import time
from fastapi import Request

class TimerMiddleware:
    def __init__(
            self,
            attrs
    ):
        self.attrs = attrs  # 一些可选的属性数据

    async def __call__(self, request: Request, call_next):
        start_time = time.time()
        # 必须用 await
        response = await call_next(request)
        process_time = time.time() - start_time
        # 自定义请求头
        response.headers["X-Process-Time"] = str(process_time)
        response.headers["X-Attrs"] = self.attrs
        # 返回响应
        return response










 
 
 
 
 
 
 
 
 
 

使用app.add_middleware 启用中间件:

from fastapi import FastAPI, Request, templating, staticfiles
from middleware.mw import TimerMiddleware
from starlette.middleware.base import BaseHTTPMiddleware

app = FastAPI()
app.add_middleware(BaseHTTPMiddleware, dispatch=TimerMiddleware("attrs"))

也可以让中间件类直接继承BaseHTTPMiddleware,但是这样似乎会存在一些内存泄露的问题。参见How to write a custom FastAPI middleware classopen in new window。所以还是有必要像路由一样统一管理中间件。

前端

前端采用lulu-ui,因为他采用的是is 方法重新定义组件,相比于vue 或者其他框架来说,它的用法更接近于原生的html+js+css。所见即所得:

Alt text

不太好的地方是后端通过表单获取数据时写法有点复杂,但是可以通过拦截submit 或者其他的手段来曲线救国。

Jinja2

虽然专业的前端设计很漂亮,但是打包工具未免也太复杂了些。而后端渲染的方式简单粗暴,深得我心。尤其是jinja2 既可以实现继承(extend)又可以实现组件化(import/include)。看起来也还挺方便的,只是还不知道使用起来感觉怎么样。

  1. 通过include 导入模板文件
    Alt text
  2. 通过import 导入模板文件的部分内容
    Alt text
  3. 通过extends 继承模板文件
    Alt text

数据库

一般的教程都会推荐SQLAlchemy,但是这个框架着实不好理解。好在FastAPI 的作者基于SQLAlchemy 封装了一个新的库SQLModel,和其他静态语言的数据库组件设计思路很像。如果不考虑数据库迁移的的话,模型和引擎可以不定义在同一个文件中。
Alt text

并且支持SQLAlchemy查询语法open in new window

from sqlmodel import SQLModel, Session, select, func, desc
from model.node import Node
from model.db import engine

with Session(engine) as s:
    statement = select(Node.id, 
                       Node.data,
                       func.count(Node.parent_id).label('count')
                       ).group_by(
                           Node.parent_id
                        )
    nodes = s.exec(statement) 
    print(nodes.all())
    s.commit()

定义树状的数据结构:

from typing import Optional
from sqlmodel import Field, Relationship, Session, SQLModel, create_engine


class Node(SQLModel, table=True):
    __tablename__ = 'node'  # 显式声明数据表

    id: Optional[int] = Field(default=None, primary_key=True)
    data: str
    parent_id: Optional[int] = Field(
        foreign_key='node.id',  # 小写的n 表示外键连
        default=None,
        nullable=True
    )
    parent: Optional['Node'] = Relationship(
        back_populates='children',
        sa_relationship_kwargs=dict(
            remote_side='Node.id'  # 大写的N 表示本表中的字段
        )
    )
    children: list['Node'] = Relationship(back_populates='parent')

递归查询:可以通过remote_side 来实现自递归,项目结构如下:
Alt text

异步

参考Add documentation about how to use the async tools (session, etc)open in new window。 或者FastAPI with Async SQLAlchemy, SQLModel, and Alembicopen in new window

import os

from sqlmodel import SQLModel, create_engine
from sqlmodel.ext.asyncio.session import AsyncSession, AsyncEngine

from sqlalchemy.orm import sessionmaker


DATABASE_URL = os.environ.get("DATABASE_URL")

engine = AsyncEngine(create_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

################################################################################

from fastapi import Depends, FastAPI
from sqlmodel import select
from sqlmodel.ext.asyncio.session 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        

另外,一个好玩的点:

from typing import TYPE_CHECKING  

if TYPE_CHECKING:  
    '''
    代码块里面的代码并不会被执行,但是可以为编辑器提供类型提示
    '''
    import libs  

这篇笔记记录的都是一些可行性的问题,具体项目中的代码肯定还需要很多细节性的问题需要解决。

参考资料

  1. Live Previewopen in new window 实时预览html 的vscode 插件
  2. How To Format Form Data as JSONopen in new window 通过监听表单的submit 事件来自定义处理函数
  3. lulu-uiopen in new window
  4. How to use macros in a included fileopen in new window
  5. SQLModelopen in new window FastAPI 作者开发的ORM 库,应该是见过的最简洁的Python ORM 库了
  6. Pydanticopen in new window 一个比较好用的数据校验工具,尤其是对于JSON 到对象、对象到对象的类型转换非常友好
  7. How to write a custom FastAPI middleware classopen in new window
  8. How do I construct a self-referential/recursive SQLModelopen in new window
  9. Aggregate functions with GROUP BY / HAVINGopen in new window

其实有一个问题: ORM 要不要承担创建表和数据迁移的工作呢?如果不要的话,代码的逻辑应该会非常简单;如果要的话,一不小心就会出现循环导入的问题。

Loading...