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渲染出前端页面。
以前个人做法是整两个域名,一个是子域名
1 2 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的文章,这里不再重复。
1 2 3 4 5 6 7 8 9 10 11 backend-fastapi ms_backend_fastapi certs routers utils __init__.py __main__.py .gitignore pyproject.toml README.md
整个项目结构是这样的。里面必须再套一个。符合模块化。并且里面要有双下划线夹住的init和main。
并且里面这个文件夹不能是短横线了,因为要符合模块命名,改成下划线。前面之所以多个ms只是因为这个是项目名称的缩写。
1 2 3 4 [tool.poetry] name = "ms-backend-fastapi" version = "0.1.0" 。。。后面的就省略了
toml里的name和那个内层文件夹一样,只是短横线和下划线的区别。
然后就是__main__.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 """ 这个模板可以通用在其他fastAPI项目里 """ from fastapi import FastAPI, HTTPExceptionfrom fastapi.responses import FileResponsefrom pathlib import Pathfrom ms_backend_fastapi.utils.path_utils import get_frontend_dirfrom ms_backend_fastapi.routers import routersdef main (): import uvicorn import sys local = "-l" in sys.argv 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, ssl_keyfile=None if local else (ssl_files / "website.key" ).as_posix(), 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系统,干脆写死一个绝对路径了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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:
1 2 3 4 5 6 7 8 9 10 routers api __init__.py example.py product.py log.py client __init__.py client_path.py root.py
root.py
部分的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from fastapi import APIRouter, HTTPExceptionrouter = 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
里面的内容
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 from pathlib import Pathfrom fastapi.responses import FileResponsefrom fastapi import APIRouter, HTTPExceptionfrom ms_backend_fastapi.utils.path_utils import get_frontend_dirrouter = APIRouter() @router.api_route("/{path:path}" , methods=["GET" ], include_in_schema=False ) async def frontend_fallback (path: str ): """ 此路由作为前端的回退路由,用于捕获除 "/api/" 开头以外的所有其他请求, 并尝试将它们导向静态文件。注意,这应该放在所有其他路由定义之后。 """ if path.startswith("api" ): return { "message" : f"找不到该API接口 `{path} `" , "status_code" : 404 } 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 : 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路由,因此上面写了一个循环语句。
在routers文件夹里可以写:
1 2 3 4 5 6 7 8 9 10 11 12 13 from typing import List from fastapi import APIRouterfrom .client import client_path, rootfrom .api import api_routerrouters = [ api_router, client_path.router, root.router, ]
接下来就是api/example.py
里的内容了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 from fastapi import APIRouterrouter = 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
文件里要这样写:
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 import importlibfrom pkgutil import iter_modulesfrom pathlib import Pathfrom fastapi import APIRouterfrom . import example, logapi_router = APIRouter(prefix="/api" ) package_dir = Path(__file__).resolve().parent for (_, module_name, _) in iter_modules([str (package_dir)]): if module_name == '__init__' : continue module = importlib.import_module(f".{module_name} " , package=__package__) 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了。