如果你把一个 FastAPI 服务部署到公司内网,大概率会遇到一个让人头疼的场景:业务代码跑得好好的,但打开/docs页面时一片空白,F12 里飘着十几个红色加载失败。问题十有八九出在 Swagger UI 的 CDN 资源上——FastAPI 默认从公共 CDN 拉取 swagger-ui 的 JS 和 CSS,内网安全策略一旦限制出网访问,文档页面就直接瘫痪。这篇内容会从 FastAPI 的资源加载链路讲起,带你走一遍换 CDN、接管路由、完全离线化、自定义 HTML 模板这几条路,每一条都有可直接复制的代码,适合正在被内网部署、离线交付或文档白屏折磨的 Python 后端同学。
1. 默认 Swagger UI 的资源加载链路,以及你什么时候该动它
1.1 FastAPI 是从哪里拿 Swagger UI 资源的
FastAPI 的/docs页面并不是把 swagger-ui 的代码打进安装包里,而是动态生成一个 HTML 页面,页面通过<script>和<link>标签从公共 CDN 加载资源。这个渲染逻辑集中在fastapi.openapi.docs模块的get_swagger_ui_html函数里,你正常使用 FastAPI 时,应用初始化阶段会自动注册一个/docs路由,请求到来时返回渲染好的HTMLResponse。
默认资源地址在常见的 0.110.x 系列版本里是这样的:
| 资源 | 默认 URL |
|---|---|
| JS 主文件 | https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js |
| CSS 样式 | https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css |
| favicon | https://fastapi.tiangolo.com/img/favicon.png |
注意这里的@5是一种浮动版本标记,它表示"跟随最新的 5.x 版本"。也就是说,同样的应用,过一阵子再部署,浏览器实际拉到的资源可能已经不是当初测试时的那份了。这个浮动版本问题在后续章节还会展开。
1.2 什么场景下必须动手自定义
不是所有项目都需要动这些默认值。如果你的服务跑在公网、用户浏览器能正常访问 jsdelivr,那保持默认完全没问题。但下面这些情况,不改就是事故现场:
- 内网部署:企业安全策略限制公网出站,
cdn.jsdelivr.net根本连不通,文档页面直接白屏。 - 离线交付:客户现场是隔离网络,或者要求交付物不依赖任何外部资源,Swagger UI 必须随应用一起走。
- 访问不稳定:部分地区访问 jsdelivr 延迟高、丢包率大,打开文档页要等十几秒,体验很差。
- 合规管控:安全团队要求所有第三方前端资源走白名单域名,公共 CDN 不在许可清单里。
- 版本固定:希望 UI 行为可预期,不想被上游 CDN 的更新波及。
我自己判断的标准很简单:先问一句"这个服务将来运行的环境能不能保证公网连通"。如果答案是不确定或不能,那就直接按离线方案来做,省的后面补锅。
2. 换一个 CDN:FastAPI 实例属性的最小改动方案
2.1 其实三行代码就够了
FastAPI 实例上有三个和 Swagger UI 资源地址直接绑定的公开属性:swagger_ui_js_url、swagger_ui_css_url、swagger_ui_favicon_url。在我们常用的版本里,/docs路由的处理函数每次请求都会读取这些属性的当前值,所以在启动前修改它们,就能让文档页用的新的资源地址。
from fastapi import FastAPI app = FastAPI() # 在 uvicorn 启动之前修改这三个属性 app.swagger_ui_js_url = "https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-bundle.js" app.swagger_ui_css_url = "https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui.css" app.swagger_ui_favicon_url = "https://unpkg.com/swagger-ui-dist@5.17.14/favicon-32x32.png"这段代码放的位置很随意,只要保证它在进程启动后、第一个请求进来前执行就行。我更推荐把它放在create_app()之类的应用工厂函数里,这样逻辑集中,后面要改成读环境变量也方便。
这种方式的好处是改动最小,不需要动任何路由,/docs、/redoc的默认行为都保留。缺点则是灵活性有限:你只能换 URL,不能改 HTML 结构,也不能在老版本 FastAPI 上保证有效。如果你用的 FastAPI 版本比较老,或者改完属性之后发现文档页还是走默认地址,那就直接跳到第 3 章,用接管路由的方式兜底。
2.2 别再用 @5 这种浮动版本了
我见过不少项目直接把默认的@5当成稳定的 CDN 地址用,结果某天上游发布了新版本,Swagger UI 的界面布局变了、初始化参数废弃了,文档页突然出现各种奇怪行为。生产环境的文档页也算对外输出的一部分,资源版本必须钉死。
锁版本的正确姿势是写全swagger-ui-dist@5.17.14这样的完整版本号。在动手之前,可以去 npm 上确认当前最新的稳定版本:
npm view swagger-ui-dist version当然,没有 Node 环境也没关系,直接访问 jsdelivr 或 unpkg 的项目页面也能看到版本列表。拿到具体版本号后,把上面代码里的@5替换成@5.17.14(或者你确认的版本号)即可。
如果你希望资源版本还能通过部署环境动态切换,可以把它做成环境变量:
import os SWAGGER_UI_VERSION = os.getenv("SWAGGER_UI_VERSION", "5.17.14") CDN_BASE_URL = os.getenv("CDN_BASE_URL", "https://unpkg.com/swagger-ui-dist") app.swagger_ui_js_url = f"{CDN_BASE_URL}@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js" app.swagger_ui_css_url = f"{CDN_BASE_URL}@{SWAGGER_UI_VERSION}/swagger-ui.css" app.swagger_ui_favicon_url = f"{CDN_BASE_URL}@{SWAGGER_UI_VERSION}/favicon-32x32.png"这样测试环境用默认的 unpkg,生产环境运维可以通过环境变量把资源源切到公司内部静态服务,代码本身不需要跟着变。
2.3 常见 CDN 源怎么选
公共 CDN 各有特点,没有绝对的好坏,只有适不适合你的网络环境。我整理了一份简单的对比:
| CDN 源 | 特点 | 适合场景 | 需要注意的点 |
|---|---|---|---|
| jsdelivr | 全球节点多、npm/GitHub 均可加速 | 大多数公网场景 | 国内部分网络连通性不稳定 |
| unpkg | npm 官方生态、资源同步快 | 海外部署、开发调试 | 大陆访问速度波动较大 |
| staticfile.org | 国内加速、收录常见开源库 | 面向国内用户的公网服务 | 收录版本更新有延迟 |
| 公司内部静态资源服务 | 完全自主控制 | 内网部署、合规管控 | 需要自己维护可用性和备份 |
选择时最忌讳的是"听别人说哪个好就直接换上"。每个团队的网络环境不一样,正确的流程是:先在要部署的网络环境里用curl -I实际测一下候选地址的响应头和耗时,然后再决定。下面这条命令可以快速看资源是否可达:
curl -I https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-bundle.js看返回状态码是否为200,以及Content-Length是否正常。不要等部署完了再发现文档页白屏,这个成本很低,顺手就做了。
3. 接管 /docs 路由:当属性覆盖不够用时的通用解法
3.1 什么时候必须走到接管路由这一步
直接改属性虽然方便,但在三种场景下会不够用:第一,你用的 FastAPI 版本比较老,实例上没有这些属性;第二,你的应用部署在网关子路径后面,需要手动拼接openapi_url和oauth2_redirect_url的完整路径;第三,你想把swagger_ui_parameters的控制权也拿回来,在文档页面里注入个性化的初始化配置。
这个时候,最干净的做法是让 FastAPI 不生成默认的/docs路由,然后自己定义一个同样路径的路由,用get_swagger_ui_html手动控制返回内容。
from fastapi import FastAPI, Request from fastapi.openapi.docs import ( get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html, ) app = FastAPI(docs_url=None, redoc_url=None) CDN_BASE = "https://unpkg.com/swagger-ui-dist" SWAGGER_UI_VERSION = "5.17.14" @app.get("/docs", include_in_schema=False) async def custom_swagger_ui(request: Request): root_path = request.scope.get("root_path", "").rstrip("/") openapi_url = root_path + app.openapi_url oauth2_redirect_url = root_path + app.swagger_ui_oauth2_redirect_url return get_swagger_ui_html( openapi_url=openapi_url, title=f"{app.title} - Swagger UI", oauth2_redirect_url=oauth2_redirect_url, swagger_js_url=f"{CDN_BASE}@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js", swagger_css_url=f"{CDN_BASE}@{SWAGGER_UI_VERSION}/swagger-ui.css", swagger_favicon_url=f"{CDN_BASE}@{SWAGGER_UI_VERSION}/favicon-32x32.png", swagger_ui_parameters=app.swagger_ui_parameters, )这段代码里有两个细节值得展开说。
第一个是root_path。如果你的服务通过 Nginx 的/api/子路径转发到 FastAPI 容器,且设置了app = FastAPI(root_path="/api"),那么request.scope["root_path"]就会是/api。此时如果不手动拼上这个前缀,openapi_url仍然指向/openapi.json,但浏览器实际访问的文档页在/api/docs,相对解析后请求的是/api/openapi.json,也就是靠 Nginx 转发才能命中。手动把 root_path 拼进去,能让浏览器直接请求正确的完整路径。
第二个是swagger_ui_parameters。FastAPI 初始化时传入的swagger_ui_parameters不会因为你自定义了/docs就自动生效,必须在手动调用get_swagger_ui_html时显式传进去。
3.2 oauth2-redirect 回调不能漏
如果你的 API 用到 OAuth2 授权码流程,那么 Swagger UI 需要一个专门的页面来处理授权回调,默认路径是/docs/oauth2-redirect。FastAPI 在生成默认文档页时会把oauth2_redirect_url注入,但你把docs_url置为None之后,这个路径的路由也不会自动创建了。
所以接管的代码里必须补上:
@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False) async def swagger_ui_redirect(): return get_swagger_ui_oauth2_redirect_html()注意这里直接用app.swagger_ui_oauth2_redirect_url作为路径,既保证了和配置一致,也避免你手写路径时拼错。如果项目确实没有 OAuth2 流程,这个路由不补也问题不大,但补上是最稳的,成本就一行。
3.3 接管路由时保持参数一致
接管路由容易犯的一个错误是:改了自己的 URL,却忘了把 FastAPI 实例上已有的其他配置一起带上。比如有些团队习惯初始化时传swagger_ui_parameters={"docExpansion": "none", "displayRequestDuration": True},接管之后这些参数就不生效了。
与其把参数写死在自定义路由里,不如在自定义路由函数中引用app.swagger_ui_parameters,保证和 FastAPI 初始化时的配置保持同一个来源。这样后续改初始化参数,文档页会自动跟着变,不会出现两处配置脱节的情况。
4. 完全离线:把 swagger-ui-dist 变成项目自身的静态资源
4.1 先下载需要的资源文件
离线化是解决一切 CDN 问题的终极方案。不管内网隔离还是客户现场离线交付,只要 Swagger UI 的 JS/CSS 跟着应用走,文档页就一定能打开。
下载资源的方式很简单。如果你本地有 Node 环境,可以用 npm 直接取包:
mkdir -p static/swagger-ui cd static npm pack swagger-ui-dist@5.17.14 tar -xzf swagger-ui-dist-5.17.14.tgz cp package/swagger-ui-bundle.js package/swagger-ui.css package/favicon-32x32.png swagger-ui/没有 Node 环境就用 curl 逐个下,效果一样:
mkdir -p static/swagger-ui cd static/swagger-ui curl -L -O https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js curl -L -O https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css curl -L -O https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/favicon-32x32.png这里我建议只挑必要的三个文件,不用把整个swagger-ui-dist包都塞进项目。swagger-ui-bundle.js已经包含了 Swagger UI 的主体功能和默认 preset,swagger-ui.css负责样式,favicon 只是锦上添花。如果你后面打算自定义完整 HTML 模板并且想要官方那个带输入框的顶栏,可以顺便把swagger-ui-standalone-preset.js也下载下来。
4.2 用 StaticFiles 挂载到应用里
下载完成后,把静态目录通过StaticFiles挂载上去,再把文档路由指向这些本地文件。
from fastapi import FastAPI, Request from fastapi.openapi.docs import get_swagger_ui_html from fastapi.staticfiles import StaticFiles app = FastAPI(docs_url=None, redoc_url=None) # 建议用绝对路径,避免 uvicorn 启动目录变化导致找不到文件 import pathlib STATIC_DIR = pathlib.Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.get("/docs", include_in_schema=False) async def custom_swagger_ui(request: Request): root_path = request.scope.get("root_path", "").rstrip("/") return get_swagger_ui_html( openapi_url=root_path + app.openapi_url, title=f"{app.title} - Swagger UI", swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js", swagger_css_url="/static/swagger-ui/swagger-ui.css", swagger_favicon_url="/static/swagger-ui/favicon-32x32.png", )这段代码有一个很值得留意的坑:StaticFiles(directory="static")用的是相对路径,它受 uvicorn 启动时工作目录的影响。如果你在一个目录下启动命令,但代码文件在另一个目录,相对路径很容易找不到资源。我习惯用pathlib.Path(__file__).parent拼出基于代码文件的绝对路径,这样不管从哪里启动都不会错。
4.3 Docker 镜像里做离线部署
离线部署最常见的形式就是 Docker 镜像。构建镜像时把静态资源复制进镜像,运行时容器内没有外网也完全没问题。
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app/ ./app COPY static/ ./static CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]这里的COPY static/ ./static就保证了资源进了镜像。如果你用的是多阶段构建,可以在某个带 Node 环境的阶段里执行npm pack下载,最后只把下载好的文件复制到运行阶段,这样运行镜像里不需要 Node 环境,体积也不会膨胀。
4.4 浏览器缓存的隐形坑
本地化资源之后还有一个经常被忽视的问题:浏览器缓存。swagger-ui-bundle.js这种文件名一旦被浏览器缓存,等你升级了 swagger-ui 版本、文件内容变了,但 URL 没变,浏览器还会继续用旧缓存,导致页面出现样式错乱或行为异常。
解决办法是在 URL 后面加版本号参数:
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js?v=5.17.14", swagger_css_url="/static/swagger-ui/swagger-ui.css?v=5.17.14",StaticFiles处理请求时会自动忽略 query string,所以这种写法不会干扰文件返回。版本号一变,URL 就变,浏览器自然拉取新文件。
5. 再进一步:直接输出自定义 Swagger UI HTML
5.1 为什么需要走到这一步
属性覆盖和接管路由都解决不了"改页面结构"的需求。比如你想引入官方 Swagger UI 那个带大输入框的StandaloneLayout,想在文档页面里注入公司统一 header,想给静态资源加上 SRI 完整性校验,这时候必须自己写 HTML 模板。
get_swagger_ui_html返回的 HTML 结构是 FastAPI 写死的,我们只能传参数,不能改结构。自己写模板则拥有完全控制权,代价是代码量略增,但灵活性是前几种方案比不了的。
5.2 自定义 HTML 模板的完整实现
下面这份模板覆盖了大部分项目会遇到的需求:支持自定义标题、favicon、本地资源路径、Swagger UI 初始化参数,还引入了 standalone preset,界面和官方完全一致。
from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse import json import pathlib app = FastAPI(docs_url=None, redoc_url=None) CUSTOM_SWAGGER_HTML = """ <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>__TITLE__</title> <link rel="stylesheet" href="__CSS_URL__" /> <link rel="icon" type="image/png" href="__FAVICON_URL__" /> </head> <body> <div id="swagger-ui"></div> <script src="__JS_URL__"></script> <script src="__PRESET_URL__"></script> <script> window.onload = function () { const config = __SWAGGER_CONFIG__; window.ui = SwaggerUIBundle(config); }; </script> </body> </html> """ @app.get("/docs", include_in_schema=False) async def custom_swagger_ui(request: Request): root_path = request.scope.get("root_path", "").rstrip("/") swagger_config = { "url": root_path + app.openapi_url, "dom_id": "#swagger-ui", "deepLinking": True, "presets": [ "SwaggerUIBundle.presets.apis", "SwaggerUIBundle.SwaggerUIStandalonePreset", ], "layout": "StandaloneLayout", "displayRequestDuration": True, "docExpansion": "none", "persistAuthorization": True, } html = ( CUSTOM_SWAGGER_HTML .replace("__TITLE__", f"{app.title} - Swagger UI") .replace("__CSS_URL__", "/static/swagger-ui/swagger-ui.css") .replace("__FAVICON_URL__", "/static/swagger-ui/favicon-32x32.png") .replace("__JS_URL__", "/static/swagger-ui/swagger-ui-bundle.js") .replace("__PRESET_URL__", "/static/swagger-ui/swagger-ui-standalone-preset.js") .replace("__SWAGGER_CONFIG__", json.dumps(swagger_config)) ) return HTMLResponse(html)注意这里的__SWAGGER_CONFIG__是一个 JSON 字符串,json.dumps负责把 Python 字典转成合法的 JS 对象字面量。这个方法比手拼字符串安全得多,不用操心引号和花括号的转义问题。
模板里有两个值得注意的点。第一个是presets和layout:SwaggerUIStandalonePreset会启用官方那个带 URL 输入框的顶栏,配合StandaloneLayout得到和petstore.swagger.io几乎一致的界面。第二个是persistAuthorization:开启后浏览器本地存储授权信息,刷新页面不会丢 Token,这对内部调试特别友好。
5.3 给静态资源加 SRI 完整性校验
SRI(Subresource Integrity)是一种安全机制,浏览器加载外部脚本时,会先校验文件内容的哈希是否和integrity属性一致,不一致就拒绝执行。这在从公共 CDN 加载资源时尤其重要,可以防止 CDN 被篡改带来的供应链攻击。
首先生成资源的 SHA-384 哈希。在本地有了文件的情况下,一条命令就能算出来:
openssl dgst -sha384 -binary swagger-ui-bundle.js | openssl base64 -A然后把输出结果拼进模板的<script>标签:
<script src="__JS_URL__" integrity="sha384-生成的哈希值" crossorigin="anonymous" ></script>这里crossorigin="anonymous"不能省略,跨域脚本启用 SRI 时必须带这个属性,浏览器才会在 CORS 模式下做完整性校验。jsdelivr 和 unpkg 都返回Access-Control-Allow-Origin: *响应头,所以可以正常配合。
加了 SRI 之后有个副作用要提前知道:只要 CDN 上的文件内容和哈希对不上,浏览器会直接拒绝加载。这意味着你升级 swagger-ui 版本时,必须同步更新哈希,否则页面就会白屏。我通常把版本号和哈希放在同一个配置区块里,保证它们一起变更。
6. 验证方法与部署踩坑记录
6.1 三步确认自定义是否生效
换完 CDN 或本地化之后,别急着关页面,按下面三步快速验证:
- 浏览器打开
/docs,右键查看网页源码,搜索swagger-ui-bundle,确认src已经指向你配置的地址。 - 打开开发者工具的 Network 面板,刷新页面,确认 JS/CSS 资源请求返回
200而不是404或failed。 - 直接访问资源 URL,看内容是否为正常的 JS/CSS 文件。如果是 JSON 报错或空文件,说明路径或网关转发有问题。
这三步走完,基本能排除绝大多数配置问题。
6.2 我在实际部署中踩过的几个坑
样式错乱但功能正常,大概率是 CSS 和 JS 版本不一致。比如 JS 换了新版本、CSS 还是旧的,两者对 DOM 结构的预期不同,页面就会乱。解决方式就是永远给 JS 和 CSS 锁同一个版本号。
白屏且控制台出现Mixed Content报错,说明页面是 HTTPS,但你配置的资源地址是 HTTP。公共 CDN 全都支持 HTTPS,把 URL 改成https://即可。公司内部自建静态服务也要注意配好 HTTPS 证书。
favicon 404 不影响功能,但会在标签页上留下一个破碎图标。如果你本地化了资源,记得从swagger-ui-dist包里把favicon-32x32.png一并带上;如果还在用默认配置,可以像我一样把它也换掉,统一成公司 logo。
网关子路径部署时,/openapi.json返回 404 是最常见的问题。根源就是第 3 章提到的root_path拼接。如果用了 Nginx 转发,务必在 FastAPI 里设置匹配的root_path,并在自定义路由里用request.scope.get("root_path", "")拼接。
6.3 我现在的标准配置套路
踩过这么多次坑之后,我目前的习惯是:内部服务一律走"固定版本 + 本地静态资源 + URL 版本号参数",外部公网服务走"固定版本公共 CDN + SRI 完整性校验"。版本号永远钉死,不管是本地文件还是外部引用,都不允许出现@5这种浮动标记。另外,所有 CDN 地址和 SRI 哈希都集中放在一个配置模块里,运维出网策略变化时,改一处就能整体切换。这套做法在十几个部署环境里跑下来都很稳,你可以直接参考。