1.前言 📝
在上一篇文章中,我们完成了书签的导入导出功能。本篇文章我们研究如何处理嵌入资源,方便后续将资源打包到一个可执行文件中。
2.embed介绍 🎯
Go 1.16 引入了革命性的 embed
包,彻底改变了静态资源管理的模式。下面简单介绍一下embed包的用法,后面出文章详细介绍。
2.1. 嵌入单个文件
package main
import (
_ "embed"
"fmt"
)
//go:embed version.txt
var version string // 自动推断为字符串类型
func main() {
fmt.Println("App Version:", version)
}
2.2. 嵌入二进制文件
//go:embed logo.png
var logoBytes []byte // 适用于二进制文件
func saveLogo() error {
return os.WriteFile("logo_copy.png", logoBytes, 0644)
}
2.3. 嵌入文件集合
//go:embed templates/*.html
var templateFS embed.FS // 文件系统接口
func loadTemplates() {
// 遍历嵌入的HTML模板
dirEntries, _ := templateFS.ReadDir("templates")
for _, entry := range dirEntries {
if !entry.IsDir() {
data, _ := templateFS.ReadFile("templates/" + entry.Name())
fmt.Printf("Loaded template: %s (%d bytes)\n",
entry.Name(), len(data))
}
}
}
3.embed改造 🛠️
3.1创建resources文件夹
在根目录下创建resources
文件夹,然后创建static
文件夹,用于存放前端文件。我们将resources
整体作为文件系统嵌入,后续有其它需要嵌入的资源可以统一在resources
处理。
resources和staic按自己喜好起,能区分即可。
需要运行npm build
将dist文件夹内文件复制到static目录中
3.2 创建assets.go
创建assets.go
,用于全局存储嵌入的资源文件
// assets/assets.go
package assets
import "embed"
var Resources embed.FS
3.2 嵌入resources资源文件
修改main.go
// main.go
package main
import (
"embed"
"github.com/ciclebyte/aibookmark/assets"
"github.com/ciclebyte/aibookmark/cmd"
)
//go:embed resources
var resources embed.FS
func main() {
assets.Resources = resources
cmd.Execute()
}
- 需要引入embed包
- 将resources交由assets管理,否则后面使用容易出现循环引用。
3.2 修改gin前端资源处理方式
func NewServer(db *gorm.DB) *Server {
server := &Server{db: db}
router := gin.New()
// 配置中间件
router.Use(gin.Logger())
router.Use(gin.Recovery())
// 禁用重定向
router.RedirectTrailingSlash = false
router.RedirectFixedPath = false
router.HandleMethodNotAllowed = true
// 打印嵌入资源文件结构
fmt.Println("=== 嵌入资源文件结构 ===")
fs.WalkDir(assets.Resources, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Printf("访问路径 %s 时出错: %v\n", path, err)
return err
}
if d.IsDir() {
fmt.Printf("目录: %s\n", path)
} else {
fmt.Printf("文件: %s\n", path)
}
return nil
})
fmt.Println("=====================")
// 配置CORS
router.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// 添加请求日志中间件
router.Use(func(c *gin.Context) {
// 开始时间
start := time.Now()
path := c.Request.URL.Path
method := c.Request.Method
// 处理请求
c.Next()
// 结束时间
end := time.Now()
latency := end.Sub(start)
// 获取状态码
statusCode := c.Writer.Status()
// 打印请求日志
fmt.Printf("[%s] %s %s %d %v\n", method, path, c.ClientIP(), statusCode, latency)
})
staticFS, _ := fs.Sub(assets.Resources, "resources/static")
fmt.Println("=== 静态资源文件结构 ===")
fs.WalkDir(staticFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Printf("访问路径 %s 时出错: %v\n", path, err)
return err
}
if d.IsDir() {
fmt.Printf("目录: %s\n", path)
} else {
fmt.Printf("文件: %s\n", path)
}
return nil
})
fmt.Println("=====================")
// 设置静态文件服务
router.StaticFS("/assets", http.FS(staticFS))
// 添加一个处理 favicon 的路由
router.GET("/favicon.ico", func(c *gin.Context) {
c.FileFromFS("/favicon.ico", http.FS(staticFS))
})
// 读取 index.html 内容
indexContent, err := fs.ReadFile(staticFS, "index.html")
if err != nil {
fmt.Printf("读取 index.html 失败: %v\n", err)
} else {
fmt.Printf("成功读取 index.html,长度: %d\n", len(indexContent))
}
// 添加根路径处理
router.GET("/", func(c *gin.Context) {
fmt.Printf("处理根路径请求: %s\n", c.Request.URL.Path)
fmt.Printf("请求头: %v\n", c.Request.Header)
// 设置响应头
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
// 直接写入响应
c.Writer.WriteHeader(200)
c.Writer.Write(indexContent)
})
// API路由分组
api := router.Group("/api")
{
category := api.Group("/categories")
{
category.POST("", server.createCategory)
category.GET("", server.listCategories)
category.GET("/all", server.getAllCategories)
category.GET("/:id", server.getCategory)
category.PUT("/:id", server.updateCategory)
category.DELETE("/:id", server.deleteCategory)
}
bookmark := api.Group("/bookmarks")
{
bookmark.POST("", server.createBookmark)
bookmark.GET("", server.listBookmarks)
bookmark.GET("/:id", server.getBookmark)
bookmark.PUT("/:id", server.updateBookmark)
bookmark.DELETE("/:id", server.deleteBookmark)
bookmark.POST("/ai", server.createAIBookmark)
bookmark.POST("/import", server.importBookmarks)
bookmark.POST("/export", server.exportBookmarks)
}
}
// 添加Swagger路由
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// SPA 前端路由兜底
router.NoRoute(func(c *gin.Context) {
// 只对 GET 请求兜底
if c.Request.Method == "GET" {
fmt.Printf("处理前端路由请求: %s\n", c.Request.URL.Path)
// 检查是否是静态资源请求
if strings.HasPrefix(c.Request.URL.Path, "/assets/") {
// 获取文件扩展名
ext := filepath.Ext(c.Request.URL.Path)
contentType := "application/octet-stream"
// 根据扩展名设置 Content-Type
switch ext {
case ".js":
contentType = "application/javascript"
case ".css":
contentType = "text/css"
case ".html":
contentType = "text/html; charset=utf-8"
case ".json":
contentType = "application/json"
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".svg":
contentType = "image/svg+xml"
case ".ico":
contentType = "image/x-icon"
}
c.Header("Content-Type", contentType)
c.FileFromFS(c.Request.URL.Path[1:], http.FS(staticFS))
return
}
// 其他请求返回 index.html
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Writer.WriteHeader(200)
c.Writer.Write(indexContent)
} else {
fmt.Printf("404 Not Found: %s %s\n", c.Request.Method, c.Request.URL.Path)
c.Status(404)
}
})
server.router = router
return server
}
4.启动
我们还是先启动后端服务
go run main.go serve
但是这次我们无需再启动前端服务,可以直接通过后端服务进行访问
往期系列
- Ai书签管理工具开发全记录(一):项目总览与技术蓝图
- Ai书签管理工具开发全记录(二):项目基础框架搭建
- AI书签管理工具开发全记录(三):配置及数据系统设计
- AI书签管理工具开发全记录(四):日志系统设计与实现
- AI书签管理工具开发全记录(五):后端服务搭建与API实现
- AI书签管理工具开发全记录(六):前端管理基础框框搭建 Vue3+Element Plus
- AI书签管理工具开发全记录(七):页面编写与接口对接
- AI书签管理工具开发全记录(八):Ai创建书签功能实现
- AI书签管理工具开发全记录(九):用户端页面集成与展示
- AI书签管理工具开发全记录(十):命令行中结合ai高效添加书签
- AI书签管理工具开发全记录(十一):MCP集成
- AI书签管理工具开发全记录(十二):MCP集成查询
- AI书签管理工具开发全记录(十三):TUI基本框架搭建
- AI书签管理工具开发全记录(十四):TUI基本界面完善
- AI书签管理工具开发全记录(十五):TUI基本逻辑实现与数据展示
- AI书签管理工具开发全记录(十六):Sun-Panel接口分析
- AI书签管理工具开发全记录(十七):Sun-Panel书签同步实现
- AI书签管理工具开发全记录(十八):书签导入导出