跳至内容

Wails 静态文件服务器实现

2025-11-09 18:45:01

wails 是基于 Golang 的跨平台开发工具,类似 Electron,但打通了 js 和 Golang 的互调。

wails 官网:https://wails.io/zh-Hans/ 如何安装以及使用请参考官网,本文主要记录如何上传文件以及读取上传的文件。

官方给出的例子为:https://wails.io/zh-Hans/docs/guides/dynamic-assets 但明确提到 vite 5.0.0+ 不受支持
Github 上也有相关的讨论:https://github.com/wailsapp/wails/issues/3240

不同于官方,我的实现是,基于 Middleware,举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
//go:embed all:frontend/dist
var assets embed.FS

func main() {
	// Create an instance of the app structure
	app := NewApp()

	// Create application with options
	wailsApp := application.NewWithOptions(&options.App{
		Title:  "k-note",
		Width:  1024,
		Height: 768,
		AssetServer: &assetserver.Options{
			Assets:  assets,
			Middleware: func(next http.Handler) http.Handler {
				return &files.MyHandler{NextHandler: next}
			},
		},

这里,所有的请求都会经过 files.MyHandler,该实现如下:

 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
// 定义结构体
type MyHandler struct{
	NextHandler http.Handler
}

func enableCORS(w http.ResponseWriter) {
	w.Header().Set("Access-Control-Allow-Origin", "*")
	w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
	w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
	w.Header().Set("Access-Control-Max-Age", "86400")
	w.WriteHeader(http.StatusOK)
}

// 实现 http.Handler 接口的 ServeHTTP 方法
func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method == "OPTIONS" {
		enableCORS(w)
		return
	}

	reqPath := r.URL.Path
	if reqPath == "/files/upload" {
		uploadHandler(w, r)
		return
	}

	if strings.HasPrefix(reqPath, "/files/static/") {
		getHandler(w, r)
		return
	}

	h.NextHandler.ServeHTTP(w, r)
}

核心功能为:如果是 options 请求,则返回跨域相关的 header,避免跨域。若为 /files/upload 或 /files/static/ 则使用自己的 handler 处理,否则让 NextHandler 来处理。

uploadHandler 实现如下:

  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
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
const maxUploadSize = 100 << 20 // 100 MB

func isValidImage(file io.ReadSeeker) (string, bool) {
	buff := make([]byte, 512)
	if _, err := file.Read(buff); err != nil {
		return "", false
	}
	// 重置读取位置
	file.Seek(0, 0)

	mime := http.DetectContentType(buff)
	if mime == "image/jpeg" {
		return "jpg", true
	}
	if mime == "image/png" {
		return "png", true
	}
	return mime, false
}

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	// 限制请求体大小
	r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
	if err := r.ParseMultipartForm(maxUploadSize); err != nil {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]interface{}{
			"success": false,
			"code":    500,
			"message": "文件解析失败:" + err.Error(),
		})
		return
	}

	// 获取文件
	file, _, err := r.FormFile("file")
	if err != nil {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]interface{}{
			"success": false,
			"code":    500,
			"message": "获取文件失败:" + err.Error(),
		})
		return
	}
	defer file.Close()

	fileType, ok := isValidImage(file)

	// 验证文件类型
	if !ok {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]interface{}{
			"success": false,
			"code":    500,
			"message": "文件类型不支持:" + fileType,
		})
		return
	}

	staticPath := filepath.Join(common.GetWorkDir(), "k-note", "static")
	_, err = os.Stat(staticPath)
	if err != nil {
		if os.IsNotExist(err) {
			os.MkdirAll(staticPath, 0755)
		} else {
			w.Header().Set("Content-Type", "application/json")
			json.NewEncoder(w).Encode(map[string]interface{}{
				"success": false,
				"code":    500,
				"message": "读取 static 文件夹失败:" + err.Error(),
			})
			return
		}
	}

	b := make([]byte, 8)
	rand.Read(b)
	filename := fmt.Sprintf("%x", b) + "." + fileType
	filePath := filepath.Join(staticPath, filename)

	// 创建目标文件
	dst, err := os.Create(filePath)
	if err != nil {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]interface{}{
			"success": false,
			"code":    500,
			"message": "创建文件失败:" + err.Error(),
		})
		return
	}
	defer dst.Close()

	// 复制文件内容
	if _, err = io.Copy(dst, file); err != nil {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]interface{}{
			"success": false,
			"code":    500,
			"message": "文件保存失败:" + err.Error(),
		})
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]interface{}{
		"success": true,
		"code":    200,
		"data":    "/files/static/" + filename,
	})
}

getHandler 实现如下:

 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
func getHandler(w http.ResponseWriter, r *http.Request) {
	fileName := r.URL.Path[14:]
	filePath := filepath.Join(common.GetWorkDir(), "k-note", "static", fileName)
	file, err := os.Open(filePath)
	if err != nil {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]interface{}{
			"success": false,
			"code":    500,
			"message": "文件:" + fileName + " 读取失败:" + err.Error(),
		})
		return
	}
	defer file.Close()

	ext := strings.ToLower(filepath.Ext(fileName))
	contentType := map[string]string{
		".jpg":  "image/jpeg",
		".jpeg": "image/jpeg",
		".png":  "image/png",
		".gif":  "image/gif",
	}[ext]
	if contentType == "" {
		contentType = "application/octet-stream"
	}
	w.Header().Set("Content-Type", contentType)
	// Serve file
	stats, _ := file.Stat()
	http.ServeContent(w, r, fileName, stats.ModTime(), file)
}

总结下,核心是实现一个 MyHandler,如果请求的路径能被处理,则处理并返回,不能处理时才使用 NextHandler 来处理。
除了新增 MyHandler 外,还需修改 App 的配置,添加 Middleware 的配置。

最后更新于