最近有同学在群里问,Go 项目里怎么把 Swagger 文档打开,顺便把默认那套蓝白 UI 换成自己团队的风格。这个问题拆开其实是两步:先让接口文档跑起来,再把文档页面外层模板改掉。这篇就沿着这个顺序写,以 Gin + swaggo/gin-swagger 的组合为例,讲讲怎么快速开启 Swagger、默认模板藏在哪个环节,以及怎么用本地自定义模板彻底换掉它。适合正在给 Go 项目接 API 文档,或者对内部系统统一 UI 有要求的同学。
整套流程跑通之后,你会得到一个完全由自己控制的本地文档页面,不依赖外部 CDN,还能顺手把标题、logo、折叠状态、授权参数全部按团队习惯调好。
1. 先搞清楚:你说的 Swagger 到底指哪一层
很多人把 Swagger 当成“接口文档”这个整体,其实它至少包含三层东西:OpenAPI 规范文件、负责渲染的 Swagger UI、以及生成规范或代码的工具链。Go 项目里“开启 Swagger”通常是把 OpenAPI 规范文件用中间件暴露出来,再配一个 Swagger UI 页面;而“更换模板”听起来像是换 UI 皮肤,实际要动的可能是 HTML 结构、CSS、JS 资源,甚至是后端返回页面的模板字符串。
我见过两种常见的 Go 接入方式,先分清楚会少踩很多坑。
1.1 为什么我推荐 swaggo 而不是 go-swagger
Go 社区里两条路线经常被搞混:一条是swaggo/swag,通过解析 Go 源码里的注释来生成 OpenAPI 文档,再搭配gin-swagger暴露页面;另一条是go-swagger/go-swagger,它更偏 API-First,从一个swagger.yaml规范文件反过来生成服务端和客户端代码。
对于大多数已经写好的业务服务,最省事的方案是前者。你不需要为了文档去调整项目结构,只用在现有 Handler 上方补注释,跑一条命令就能生成文档,运行时再挂一个路由就行。后者适合新项目设计阶段,但侵入性明显更强,模板定制也主要集中在代码生成模板上,和浏览器里看到的页面模板不是一回事。
所以下面全部以 swaggo 生态为例。如果你后续改用 go-swagger,会发现“更换模板”变成了swagger generate的--template参数,解决的是代码生成样式问题,不是页面样式问题。
1.2 “开 Swagger”和“换模板”的动作拆解
开启 Swagger 可以拆成三步:在代码里写注解、跑swag init生成docs/swagger.json、在路由里注册中间件。完成后浏览器访问/swagger/index.html,默认页面就出来了。
更换模板的动作就不一样,它发生在“中间件已经返回页面”这个环节。默认情况下,gin-swagger会在内存里拼一段写死的 HTML,再把它返回给浏览器,浏览器再去加载 swagger-ui 的 JS 和 CSS。你想换模板,要么改这段 HTML,要么把 swagger-ui 的静态资源整体换成自己的,要么直接复制一份 swagger-ui 到项目里重新改。
把这些层次想明白,后面每一步就不会迷糊。
2. 从零开启 Swagger:先把默认页面跑起来
在碰模板之前,先把默认页面跑通。我默认你已经有 Go 环境和 Gin 项目,版本至少是 Go 1.16 以上,因为后面要用的embed是这个版本才稳定的。
2.1 初始化项目与安装依赖
项目初始化命令:
mkdir demo-api && cd demo-api go mod init demo-api安装依赖:
go get -u github.com/gin-gonic/gin go get -u github.com/swaggo/swag/cmd/swag go get -u github.com/swaggo/gin-swagger go get -u github.com/swaggo/files注意swag是一个命令行工具,不是运行库,最好用go install github.com/swaggo/swag/cmd/swag@latest装到$GOPATH/bin里。要确认命令是否可用,直接跑:
swag -v如果提示找不到,先检查$GOPATH/bin是否在PATH环境变量里。这一步卡住的人很多,因为go get版本不同,装出来的二进制位置不一样。
2.2 用注释描述接口
Swagger 注解的触发点有两个:一个是main.go里的全局信息,一个是每个 Handler 上面的接口信息。先看全局信息,比如我在main.go顶部写:
package main // @title 示例项目 API // @version 1.0.0 // @description 这是一个用于演示 Swagger 接入与模板替换的项目 // @host localhost:8080 // @BasePath /api/v1 func main() { r := gin.Default() // ... _ = r.Run(":8080") }@host和@BasePath生成的文档会在页面左上角显示,生产环境记得改成真实域名和统一前缀。
再看一个典型的 Handler 注解:
// @Summary 获取用户信息 // @Description 根据用户 ID 返回昵称和头像地址 // @Tags 用户 // @Produce json // @Security ApiKeyAuth // @Param id path int true "用户 ID" // @Success 200 {object} model.User // @Failure 404 {object} model.ErrorResponse // @Router /users/{id} [get] func GetUser(c *gin.Context) { // 业务逻辑 }这些注解不是随便写的,swag解析时会严格对应字段。@Param必须写清楚参数位置是path、query还是body;{object}后面的类型需要能被包扫描到。如果model.User定义在另一个包,注释里要写完整包名路径。
2.3 生成 docs 目录
在项目根目录执行:
swag init -g main.go -o docs-g main.go指定入口文件,-o docs指定输出目录。成功后会生成三个文件:docs/swagger.json、docs/swagger.yaml、docs/docs.go。其中docs.go是给程序读取用的。
这一步如果提示“cannot find type definition”,多半是某个{object}引用的结构体路径没写对,或者swag没有找到对应包。我喜欢在执行前先go build ./...确认代码能编过,这样解析报错时能快速排除语法问题。
2.4 注册 Swagger 路由
在需要挂载的路由文件里写:
import ( "github.com/gin-gonic/gin" ginSwagger "github.com/swaggo/gin-swagger" swaggerFiles "github.com/swaggo/files" _ "demo-api/docs" ) func RegisterSwagger(r *gin.Engine) { r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) }这里必须匿名导入demo-api/docs,因为docs.go里有init()会注册变量,不导入就无法读取生成的文档数据。
启动服务后访问http://localhost:8080/swagger/index.html,默认的 Swagger UI 页面就出来了。能看到页面只是第一步,我们要的“更换模板”还没开始。
2.5 不改模板的基础玩法:用配置项调页面参数
如果暂时不想动模板,又想调整默认页面行为,gin-swagger提供了不少配置项。最常用的是这几个:
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("http://localhost:8080/swagger/doc.json"), ginSwagger.DocExpansion("none"), ginSwagger.PersistAuthorization(true), ginSwagger.DefaultModelsExpandDepth(-1), ))URL指定文档加载地址;DocExpansion控制接口默认展开还是收起,可选值有list、none、full;PersistAuthorization让页面刷新后保留鉴权 token;DefaultModelsExpandDepth(-1)可以直接把底部的 Models 区域隐藏掉。
这些配置最终会变成 Swagger UI 的 JavaScript 参数,属于“给默认页面换配置”,和换模板不完全一样。如果你只需要改标题、收起接口、隐藏模型,这套方案就够了,完全不用碰源码。
3. 更换模板前,先看默认模板的“零件”在哪
明白了基础玩法,下一步就是真正换模板。这里有个核心问题:默认模板到底藏在哪里?很多人在项目里找不到,因为它在依赖包里。
3.1 swaggo/files 与 gin-swagger 的分工
从依赖关系上看,swaggerFiles.Handler负责提供静态资源文件,它的底层是embed.FS,把 swagger-ui 的 HTML、JS、CSS 都封装在github.com/swaggo/files包里。ginSwagger.WrapHandler做的事情更简单:拿到请求后,把一段内置的index.html字符串渲染出来,返回给浏览器。浏览器再根据这段 HTML 里的资源引用,去/swagger路径下请求 JS、CSS。
也就是说,默认模板由两部分组成:后端返回的 HTML 内容,以及前端静态资源文件。你只改其中一个,页面都可能出现样式错乱或功能缺失。
3.2 默认模板的三大可替换点
第一是 HTML 结构。默认模板给 Swagger UI 一个挂载点<div id="swagger-ui">,然后在script里设置SwaggerUIBundle的初始化参数。你想改标题、logo、布局,都要动这一层。
第二是静态资源。默认页面加载的 JS、CSS 可以来自依赖包内嵌文件,也可以来自本地目录,甚至可以指向 CDN。内网环境访问不到外网 CDN 时,这就是最大的坑。
第三是初始化参数。docExpansion、persistAuthorization、deepLinking这些配置直接决定页面交互体验,换模板时很容易被忽略。
3.3 替换模板的四种方案对比
我用一张表把常见方案的取舍列出来,方便你按场景选:
| 方案 | 实现方式 | 隔离性 | 维护成本 | 适用场景 |
|---|---|---|---|---|
| 配置项微调 | 只用 gin-swagger 提供的参数 | 高,依赖包不受影响 | 极低 | 只想收起接口、隐藏模型、保留鉴权 |
| 修改依赖缓存里的资源 | 直接去 GOPATH 或 vendor 里改 files 包 | 低,重新go mod download就丢失 | 低,但不可控 | 本地临时看效果,不推荐提交 |
| 本地静态目录 + 自定义 HTML | 把 swagger-ui dist 拷贝到项目,自己写页面 | 高,完全可控 | 中,需要维护资源文件 | 内网部署、团队统一 UI、深度定制 |
| 复制 gin-swagger 模板源码修改 | 把模板字符串和 WrapHandler 逻辑复制到项目里 | 高,彻底摆脱依赖 | 较高,依赖包升级后要手动同步 | 需要修改后端输出 HTML 的极少数场景 |
我的建议很直接:要真换模板,就选第三种。把 swagger-ui 的静态资源放进项目里,用go:embed打包进二进制,再写一个简单的静态文件路由。这样 CDN 问题、项目结构问题、团队协作问题都能一起解决。
4. 实操:用 go:embed 把自定义 swagger-ui 模板打进二进制
下面是一套我实际用过的方案,目标是让/swagger/index.html变成我们自己写的页面,同时保留doc.json的动态加载能力。
4.1 把 swagger-ui 的 dist 资源拷进项目
先去官方发行渠道下载 swagger-ui 的dist目录,或者从依赖包github.com/swaggo/files的源码里把静态资源解出来。我建议直接拿官方 dist,版本和项目搭配合适就行。
把dist目录里的内容放到项目web/swagger-ui下,目录结构大致这样:
web/swagger-ui/ ├── index.html ├── swagger-ui.css ├── swagger-ui-bundle.js ├── swagger-ui-standalone-preset.js ├── favicon-16x16.png ├── favicon-32x32.png └── ...如果 dist 里有oauth2-redirect.html,也一并留着,OAuth2 流程会用到。
4.2 改 index.html:标题、样式、资源引用
打开web/swagger-ui/index.html,重点改几个地方。第一是标题:
<title>示例项目 API 文档</title>第二是资源引用。官方 dist 的index.html可能引用./swagger-ui-bundle.js这类相对路径,这没问题。如果你之前的模板直接引用了外部 CDN 地址,务必换成相对路径:
<link rel="stylesheet" href="./swagger-ui.css"> <script src="./swagger-ui-bundle.js"></script>第三是初始化参数。找到SwaggerUIBundle的配置文件,改成想要的行为。比如我一般这么配:
<script> window.onload = function () { const ui = SwaggerUIBundle({ url: "/swagger/doc.json", dom_id: "#swagger-ui", deepLinking: true, docExpansion: "none", persistAuthorization: true, presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.standalonePreset], layout: "BaseLayout" }) window.ui = ui } </script>这里url用的是相对路径/swagger/doc.json,而不是http://localhost:8080/...。因为不同环境域名不一样,写死 localhost 会导致部署后文档加载失败。
如果还想改页面顶部样式,可以在swagger-ui.css后面追加一段覆盖样式:
.swagger-ui .topbar { background-color: #1f2d3d; } .swagger-ui .topbar .download-url-wrapper { display: none; }隐藏掉顶部下载入口,可以让页面看起来更像内部系统。
4.3 用 go:embed 嵌入静态文件
不推荐用http.Dir("./web/swagger-ui")这种方式,因为部署二进制时还需要额外带资源目录,很不方便。直接用embed.FS把资源打进二进制。
在项目里新建embed.go:
package main import "embed" //go:embed all:web/swagger-ui var swaggerUI embed.FS注意我写了all:web/swagger-ui而不是web/swagger-ui/*。前者会把swagger-ui目录下所有子目录都递归打包进去,后者只能打包一层文件。官方 dist 里可能有子目录或者嵌套资源,保险起见用all:前缀。
4.4 注册可以“以假乱真”的路由
有了swaggerUI这个embed.FS,接下来在 Gin 里挂静态服务。embed.FS不能直接传给 Gin,需要先用io/fs.Sub切出子目录,再转成http.FS:
import ( "embed" "io/fs" "net/http" "github.com/gin-gonic/gin" ) //go:embed all:web/swagger-ui var swaggerUI embed.FS func RegisterCustomSwagger(r *gin.Engine) { subFS, err := fs.Sub(swaggerUI, "web/swagger-ui") if err != nil { panic(err) } // doc.json 必须优先注册,Gin 会先匹配具体路由 r.GET("/swagger/doc.json", func(c *gin.Context) { c.File("docs/swagger.json") }) // 静态页面挂在 /swagger 下,访问 /swagger 会自动跳转到 /swagger/index.html r.StaticFS("/swagger", http.FS(subFS)) }这里有个小细节:r.StaticFS("/swagger", ...)会在访问/swagger时自动重定向到/swagger/index.html,正好符合大家的使用习惯。而/swagger/doc.json因为注册在StaticFS之前,会优先生效。这样既能提供页面,又能动态返回 Swagger 生成的文档数据。
如果你想再挂一个纯资源目录,也可以同时加:
r.StaticFS("/swagger-assets", http.FS(subFS))然后把 index.html 里的脚本路径改成/swagger-assets/swagger-ui-bundle.js。这样做的好处是页面路径和资源路径完全分离,后续配 Nginx 更干净。不过一般情况下直接挂/swagger就够了。
4.5 让默认页面支持自定义鉴权
很多内部接口文档需要登录后才能看到内容。一个常见的做法是在反向代理层做鉴权,但如果你想在模板层面实现,可以在SwaggerUIBundle初始化前先请求一次会话接口,拿到 token 后塞进authorizations:
<script> async function getToken() { const res = await fetch("/api/v1/auth/token") const data = await res.json() return data.token } window.onload = async function () { const token = await getToken() const ui = SwaggerUIBundle({ url: "/swagger/doc.json", dom_id: "#swagger-ui", deepLinking: true, docExpansion: "none", persistAuthorization: true, presets: [SwaggerUIBundle.presets.apis], layout: "BaseLayout", authorizations: { BearerAuth: { value: "Bearer " + token } } }) window.ui = ui } </script>这种方法适合临时验证,正式的敏感接口应该配合网关或更严密的鉴权策略。模板里直接把 token 写进前端,毕竟只做了展示用途,不要让它承担过多安全责任。
4.6 构建并验证
完成代码后,执行:
go mod tidy go build -o demo-api . ./demo-api然后访问:
curl http://localhost:8080/swagger/doc.json curl http://localhost:8080/swagger/index.html第一个请求应该返回 JSON 文档,第二个请求返回我们修改后的 HTML。如果这两个都正常,说明自建 Swagger 已经完整跑起来了。
5. 常见问题与排查技巧实录
这块内容是多次踩坑后的记录,建议收藏。几乎每个问题我都亲手遇到过,尤其是第一次切自定义模板的时候。
5.1 页面出现“Failed to load API definition”
这个报错通常是doc.json没访问到,或者访问到的是错误格式。排查步骤很简单:先直接访问/swagger/doc.json,浏览器里看到 JSON 就继续下一步;如果看到 404,检查路由注册顺序和docs包是否被匿名导入。如果是 200 但页面仍报错,多半是 Swagger 版本和 swagger-ui 版本不兼容,检查swag生成的文档格式是不是 OpenAPI 3.0,而 swagger-ui 版本太老,优先升级swagger-ui资源版本。
5.2 文档数据是旧的,新接口没出现
swag init不是实时监控,没修改注解后重新执行,文档不会自动更新。我习惯把命令固定成:
swag init -g main.go -o docs --parseDependency --parseInternal--parseDependency会解析依赖包里的注解,--parseInternal会解析内部包。如果接口分散在多个模块,这两个参数能避免“部分接口没生成”的问题。
5.3 模板资源加载不出来,样式全乱
页面能打开但 CSS 或 JS 是空白的,第一反应看浏览器 Network 面板。404 的原因是资源路径不对,常见于 index.html 里用了绝对路径但没带前缀,或者embed目录层级没切对。这时可以打印一下fs.Sub后的目录结构,确认文件路径是否和代码里一致。
5.4 改完模板页面不变,是一直在缓存吗
Swagger UI 静态资源很容易被浏览器缓存。开发调试时打开开发者工具的 Network,勾选 Disable cache,然后强制刷新。如果是部署环境,可以在index.html里的资源地址后面加版本参数:
<script src="./swagger-ui-bundle.js?v=20240101"></script>后端也可以给静态资源设置Cache-Control: no-cache,避免线上更新后用户拿着旧页面反复报错。
5.5 内网环境下外部 CDN 永远加载不出来
这是切换本地模板最常见的动机。确认下面三点:index.html 中没有http://开头的外部资源;所有 js、css、图标都放到了本地目录;go:embed打包时确实包含了这些文件。如果只替换了 HTML,忘拷 CSS 文件,页面会非常难看。
5.6 浏览器访问 /swagger 时重定向登录页
如果你在 Nginx 层做了统一登录,/swagger可能被拦截。两个思路:给 Swagger 页面单独开一个内部跳板路径,或者把静态页面同样纳入鉴权体系,再在模板里接入统一 token。前者适合开发环境,后者适合正式环境。看你们团队的安全策略决定。
5.7 同一项目想挂多套 Swagger 文档
网关类项目经常需要给不同模块分别看文档。Gin 里可以用ginSwagger.InstanceName("admin")的方式注册多个实例,同时为每个部分单独生成 doc.json。自定义模板方案里,你可以把模板复制出多份,分别指定不同的url指向不同路由。例如admin/doc.json和order/doc.json,再映射到不同 HTML 页面,结构很清晰。
6. 写在最后的实际操作体会
个人经验里最值得分享的一条:不要一上来就想改模板,先评估自己是不是只需要配置项。很多团队抱怨“默认 UI 太丑”,实际只是想要隐藏模型、收起接口、换标题,这些用gin-swagger自带参数三分钟就搞定,没必要引入一整套本地资源。
真的到了要统一品牌风格、放内部 logo、隐藏某些入口的时候,再用本地静态目录方案也不迟。我通常在项目根目录建一个web/文件夹,与代码、配置文件分开,资源归资源,代码归代码。go:embed打包后部署非常省心,单二进制文件带着文档一起走,不需要额外拷贝资源目录。
还有个小技巧:每次升级 swagger-ui 资源版本后,记得在浏览器里完整过一遍“接口列表展开、参数填写、调用请求”三个流程。UI 升级可能带来接口交互变化,比如某些版本默认隐藏了 Try it out 按钮,或者授权弹窗行为不一样。只有实测过,才算真正换好模板。