FastAPI+Nuxt单域名部署实践:无需子域名的前后端分离解决方案

FastAPI+Nuxt单域名部署实践:无需子域名的前后端分离解决方案

注:此博客写于2024年5月23日。FastAPI已经到0.111.0 版本了。

背景历史

上一个接手网站的人不管了:Wordpress —重写–> Vue

发现Vue做SEO优化很麻烦:Vue —重构–> Nuxt

发现每次改商品数据还要重新上传服务器:Nuxt —增强–> Nuxt + githubActions自动部署

发现还是得做后端:Nuxt + githubActions自动部署 —加后端–> Nuxt + githubActions + FastAPI

回顾纯前端

对于构建一个网站来说,最简单的网站肯定是纯静态网站。

只要将HTML、CSS、JavaScript、图片等资源文件放在某一个服务器的文件夹里,在宝塔里配置好域名,Nginx配置好,就可以通过域名访问到网站。

如果使用了Vue、React、Nuxt、Next等框架,就用 yarn generate pnpm build 等等的命令生成出来的dist或者output文件夹,将这些文件夹里的内容部署到服务器对应域名的文件夹里就可以了。

但这个不能接后端,这只是纯前端。

回顾后端+前端 子域名反向代理法

如果接入后端,以FastAPI举例子,我们可以用FastAPI提供的API接口,然后用Nuxt渲染出前端页面。

以前个人做法是整两个域名,一个是子域名

example.com
api.example.com

然后用SSH连接服务器,单独开一个screen,部署后端上去,单独开一个后端的接口,比如说8000。

然后再在宝塔里开一个api.example.com的站点,设置反向代理,把8000端口的代理到api子域名上。

然后Nuxt前端里就可以用fetch或者axios 请求 api.example.com 接口,获取数据,渲染出页面。

问题

本文提供一个使用FastAPI+Nuxt的方案,可以使用一个域名就把后端和前端都部署到一起。

这个Nuxt应该可以换成React、Vue、Next,都行,只要保证是可以静态打包的就可以。

所以重点不在前端了,重点在FastAPI。

fastAPI很简单,只用 app.mount一句话,就能让某一个路由绑定一个前端文件夹了。

然后我们规定 /api 开头的url全部都走我们自定义的路由函数,不要去访问前端文件夹。

但是但是!!我们如果直接用 / 作为URL绑定那个前端文件夹,经过测试发现我们自定义的api就全被覆盖了。

比如 GET: https://example.com/api/product/all

他会绑定到前端静态文件里面去了,会认为有一个叫api的页面,等等等的。而不是一个返回json的后端接口。

那不把前端绑在 / 上,把前端绑定在 /website 或者 /static 上不就可以了吗?

但这样不好,我们访问的网站url前面就必须全部加上一个 /website 了,如果我们的前端代码里某些js加了当前url的判断,这样可能会出问题。

所以不能用mount直接绑定。还少要写一个if判断。

FastAPI代码

fastAPI项目使用poetry管理。以前发过如何使用poetry的文章,这里不再重复。

backend-fastapi
	ms_backend_fastapi
		certs  # 存放ssl证书
		routers  # 路由层
		utils  # 工具层
		__init__.py
		__main__.py

	.gitignore
	pyproject.toml
	README.md

整个项目结构是这样的。里面必须再套一个。符合模块化。并且里面要有双下划线夹住的init和main。

并且里面这个文件夹不能是短横线了,因为要符合模块命名,改成下划线。前面之所以多个ms只是因为这个是项目名称的缩写。

[tool.poetry]
name = "ms-backend-fastapi"
version = "0.1.0"
。。。后面的就省略了

toml里的name和那个内层文件夹一样,只是短横线和下划线的区别。

然后就是__main__.py

"""
这个模板可以通用在其他fastAPI项目里
"""
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path

from ms_backend_fastapi.utils.path_utils import get_frontend_dir
from ms_backend_fastapi.routers import routers


def main():
    import uvicorn
    import sys
    local = "-l" in sys.argv

    # HTTPS运行
    ssl_files = Path(__file__).parent / "certs"
    app = FastAPI()

    for router in routers:
        app.include_router(router)
    port = 25543
    print(f"http://localhost:{port}")
    uvicorn.run(
        app,
        host="0.0.0.0",
        port=port,
        # pem文件
        ssl_keyfile=None if local else (ssl_files / "website.key").as_posix(),
        # crt文件
        ssl_certfile=None
        if local
        else (ssl_files / "website.pem").as_posix(),
    )


if __name__ == "__main__":
    main()

记得certs文件夹里放ssl证书的key文件和pem文件。网站开https要用的。

之所以写一个 -l 是因为方便在本地测试,本地就不能用https了。

utils里面有一个get_frontend_dir,这个其实就是动态判断前端打包了的文件夹的路径

这个前端文件夹一定不要套在这个fastAPI项目里面,会导致打包巨大!并且也违背了前后端分离开发的初衷了,本来前端有一个仓库,git push之后能自动触发github Actions流水线自动部署到服务器的。

所以get_frontend_dir这个就是一个动态判断当前是Windows还是Linux系统,如果是Linux系统,干脆写死一个绝对路径了。

from functools import lru_cache


@lru_cache(1)
def get_frontend_dir():
    import platform
    os_name = platform.system()
    if os_name == "Windows":
        return r"D:\啊吧啊吧什么东西什么东西\website\.output\public"
    elif os_name == "Linux":
        return r"/www/wwwroot/这里是你baota上的最终部署的那个网站文件夹"
    else:
        raise Exception("Unsupported system: " + os_name)

拿到这个前端文件夹路径就是为了开放一个 / URL的 接口,提供静态文件,形成网页用的。

这里再展开上面文件结构里的routers:

routers
	api
		__init__.py
		example.py  # 随便举个例子
		product.py  # 和产品相关的增删改查接口
		log.py  # 和日志相关的增查接口
	client
		__init__.py
		client_path.py
		root.py

root.py 部分的内容:

from fastapi import APIRouter, HTTPException

router = APIRouter()


@router.get("/")
async def read_root():
    # 直接返回前端的主页内容,而不是重定向
    index_path = Path(get_frontend_dir()) / "index.html"
    if index_path.exists():
        return FileResponse(index_path)
    else:
        raise HTTPException(status_code=404, detail="Index file not found")

client_path.py 里面的内容

from pathlib import Path
from fastapi.responses import FileResponse
from fastapi import APIRouter, HTTPException
from ms_backend_fastapi.utils.path_utils import get_frontend_dir

router = APIRouter()


@router.api_route("/{path:path}", methods=["GET"], include_in_schema=False)
async def frontend_fallback(path: str):
    """
    此路由作为前端的回退路由,用于捕获除 "/api/" 开头以外的所有其他请求,
    并尝试将它们导向静态文件。注意,这应该放在所有其他路由定义之后。
    """
    # 如果请求的路径是API路径,能走到这里说明没找到,前面没有拦截住。
    if path.startswith("api"):
        return {
            "message": f"找不到该API接口 `{path}`",
            "status_code": 404
        }
    # 尝试从静态文件中提供请求的路径,处理嵌入资源(如CSS、JS等)
    static_file_path = Path(get_frontend_dir()) / path
    if static_file_path.exists() and static_file_path.is_file():
        return FileResponse(static_file_path)
    else:
        # 如果静态文件不存在,可以考虑返回404页面或者前端的错误处理页面
        error_path = Path(get_frontend_dir()) / "404.html"
        if error_path.exists():
            return FileResponse(error_path)
        else:
            raise HTTPException(status_code=404, detail="File not found")

可以看到,重点就在上面:遇到api开头的接口

所以要先把api开头的接口写在前面拦截住。

app会注册好几个router路由,因此上面写了一个循环语句。

在router __init__.py文件里可以写:

from typing import List

from fastapi import APIRouter

from .client import client_path, root
from .api import api_router
routers = [
    api_router,
    # ====================== 上面要拦截住,所以和API相关的一定要在前面
    client_path.router,
    root.router,
]

接下来就是api/example.py 里的内容了

from fastapi import APIRouter

router = APIRouter(prefix="/example")

@router.get("")
async def example_api_endpoint():
    return {"message": "/example 被访问了"}


@router.get("/{example_id}")
async def example_api_endpoint_with_id(example_id: int):
    return {"message": f"含有 ID: {example_id}"}


@router.post("/")
async def example_api_endpoint_post(example_data: dict):
    return {"message": f"post 请求数据: {example_data}"}

可以这样写,但还要注意 要用 https://example.com/api/example 才能访问到上面第一个路由函数

所以api文件夹里的__init__.py 文件里要这样写:

import importlib
from pkgutil import iter_modules
from pathlib import Path

from fastapi import APIRouter
from . import example, log

api_router = APIRouter(prefix="/api")
# 获取当前包的绝对路径
package_dir = Path(__file__).resolve().parent

# 自动发现并注册所有router
for (_, module_name, _) in iter_modules([str(package_dir)]):
    # 排除掉__init__.py自身
    if module_name == '__init__':
        continue
    # 动态导入模块
    module = importlib.import_module(f".{module_name}", package=__package__)
    # 尝试从模块中获取router对象并注册
    router = getattr(module, "router", None)
    if router:
        api_router.include_router(router)
        print(f"模块注册成功 {module_name}")
    else:
        print(f"模块{module_name}中没有找到router对象")
        raise ImportError(f"模块{module_name}中没有找到router对象")

这样就能实现动态的导入,以后再开新的接口直接新建py文件就可以了!不用再去点开init文件里再import了。

  • 13
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值