Go Web 編程之 靜態文件


概述

在 Web 開發中,需要處理很多靜態資源文件,如 css/js 和圖片文件等。本文將介紹在 Go 語言中如何處理文件請求。
接下來,我們將介紹兩種處理文件請求的方式:原始方式和http.FileServer方法。

原始方式

原始方式比較簡單粗暴,直接讀取文件,然后返回給客戶端。

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/static/", fileHandler)

  server := &http.Server {
    Addr:    ":8080",
    Handler: mux,
  }
  if err := server.ListenAndServe(); err != nil {
    log.Fatal(err)
  }
}

上面我們創建了一個文件處理器,將它掛載到路徑/static/上。一般地,靜態文件的路徑有一個共同的前綴,以便與其它路徑區分。如這里的/static/,還有一些常用的,例如/public/等。
代碼的其它部分與程序模板沒什么不同,這里就不贅述了。

另外需要注意的是,這里的注冊路徑/static/最后的/不能省略。我們在前面的文章程序結構中介紹過,如果請求的路徑沒有精確匹配的處理,會逐步去掉路徑最后部分再次查找。
靜態文件的請求路徑一般為/static/hello.html這種形式。沒有精確匹配的路徑,繼而查找/static/,這個路徑與/static是不能匹配的。

接下來,我們看看文件處理器的實現:

func fileHandler(w http.ResponseWriter, r *http.Request) {
  path := "." + r.URL.Path
  fmt.Println(path)

  f, err := os.Open(path)
  if err != nil {
    Error(w, toHTTPError(err))
    return
  }
  defer f.Close()

  d, err := f.Stat()
  if err != nil {
    Error(w, toHTTPError(err))
    return
  }

  if d.IsDir() {
    DirList(w, r, f)
    return
  }

  data, err := ioutil.ReadAll(f)
  if err != nil {
    Error(w, toHTTPError(err))
    return
  }

  ext := filepath.Ext(path)
  if contentType := extensionToContentType[ext]; contentType != "" {
    w.Header().Set("Content-Type", contentType)
  }

  w.Header().Set("Content-Length", strconv.FormatInt(d.Size(), 10))
  w.Write(data)
}

首先我們讀出請求路徑,再加上相對可執行文件的路徑。一般地,static目錄與可執行文件在同一個目錄下。然后打開該路徑,查看信息。
如果該路徑表示的是一個文件,那么根據文件的后綴設置Content-Type,讀取文件的內容並返回。代碼中簡單列舉了幾個后綴對應的Content-Type

var extensionToContentType = map[string]string {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "application/javascript",
  ".xml": "text/xml; charset=utf-8",
  ".jpg":  "image/jpeg",
}

如果該路徑表示的是一個目錄,那么返回目錄下所有文件與目錄的列表:

func DirList(w http.ResponseWriter, r *http.Request, f http.File) {
  dirs, err := f.Readdir(-1)
  if err != nil {
    Error(w, http.StatusInternalServerError)
    return
  }
  sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() })

  w.Header().Set("Content-Type", "text/html; charset=utf-8")
  fmt.Fprintf(w, "<pre>\n")
  for _, d := range dirs {
    name := d.Name()
    if d.IsDir() {
      name += "/"
    }
    url := url.URL{Path: name}
    fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", url.String(), name)
  }
  fmt.Fprintf(w, "</pre>\n")
}

上面的函數先讀取目錄下第一層的文件和目錄,然后按照名字排序。最后拼裝成包含超鏈接的 HTML 返回。用戶可以點擊超鏈接訪問對應的文件或目錄。

如何上述過程中出現錯誤,我們使用toHTTPError函數將錯誤轉成對應的響應碼,然后通過Error回復給客戶端。

func toHTTPError(err error) int {
  if os.IsNotExist(err) {
    return http.StatusNotFound
  }
  if os.IsPermission(err) {
    return http.StatusForbidden
  }
  return http.StatusInternalServerError
}

func Error(w http.ResponseWriter, code int) {
  w.WriteHeader(code)
}

同級目錄下static目錄內容:

static
├── folder
│   ├── file1.txt
│   └── file2.txt
│   └── file3.txt
├── hello.css
├── hello.html
├── hello.js
└── hello.txt

運行程序看看效果:

$ go run main.go

打開瀏覽器,請求localhost:8080/static/hello.html

可以看到頁面hello.html已經呈現了:

<!-- hello.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Go Web 編程之 靜態文件</title>
  <link rel="stylesheet" href="/static/hello.css">
</head>
<body>
  <p class="greeting">Hello World!</p>
  <script src="/static/hello.js"></script>
</body>
</html>

html 使用的 css 和 js 文件也是通過/static/路徑請求的,兩個文件都比較簡單:

.greeting {
  font-family: sans-serif;
  font-size: 15px;
  font-style: italic;
  font-weight: bold;
}
console.log("Hello World!")

"Hello World!"字體顯示為 css 設置的樣式,通過觀察控制台也能看到 js 打印的信息。

再來看看文件目錄瀏覽,在瀏覽器中請求localhost:8080/static/

可以依次點擊列表中的文件查看其內容。

點擊hello.css

點擊hello.js

依次點擊folderfile1.txt

靜態文件的請求路徑也會輸出到運行服務器的控制台中:

$ go run main.go 
./static/
./static/hello.css
./static/hello.js
./static/folder/
./static/folder/file1.txt

原始方式的實現有一個缺點,實現邏輯復雜。上面的代碼盡管我們已經忽略很多情況的處理了,代碼量還是不小。自己編寫很繁瑣,而且容易產生 BUG。
靜態文件服務的邏輯其實比較一致,應該通過庫的形式來提供。為此,Go 語言提供了http.FileServer方法。

http.FileServer

先來看看如何使用:

package main

import (
  "log"
  "net/http"
)

func main() {
  mux := http.NewServeMux()
  mux.Handle("/static/", http.FileServer(http.Dir("")))


  server := &http.Server {
    Addr: ":8080",
    Handler: mux,
  }

  if err := server.ListenAndServe(); err != nil {
    log.Fatal(err)
  }
}

上面的代碼使用http.Server方法,幾行代碼就實現了與原始方式相同的效果,是不是很簡單?這就是使用庫的好處!

http.FileServer接受一個http.FileSystem接口類型的變量:

// src/net/http/fs.go
type FileSystem interface {
  Open(name string) (File, error)
}

傳入http.Dir類型變量,注意http.Dir是一個類型,其底層類型為string,並不是方法。因而http.Dir("")只是一個類型轉換,而非方法調用:

// src/net/http/fs.go
type Dir string

http.Dir表示文件的起始路徑,空即為當前路徑。調用Open方法時,傳入的參數需要在前面拼接上該起始路徑得到實際文件路徑。

http.FileServer的返回值類型是http.Handler,所以需要使用Handle方法注冊處理器。http.FileServer將收到的請求路徑傳給http.DirOpen方法打開對應的文件或目錄進行處理。
在上面的程序中,如果請求路徑為/static/hello.html,那么拼接http.Dir的起始路徑.,最終會讀取路徑為./static/hello.html的文件。

有時候,我們想要處理器的注冊路徑和http.Dir的起始路徑不相同。有些工具在打包時會將靜態文件輸出到public目錄中。
這時需要使用http.StripPrefix方法,該方法會將請求路徑中特定的前綴去掉,然后再進行處理:

package main

import (
  "log"
  "net/http"
)

func main() {
  mux := http.NewServeMux()
  mux.Handle("/static/", http.StripPrefix("/static", http.FileServer(http.Dir("./public"))))


  server := &http.Server {
    Addr: ":8080",
    Handler: mux,
  }

  if err := server.ListenAndServe(); err != nil {
    log.Fatal(err)
  }
}

這時,請求localhost:8080/static/hello.html將會返回./public/hello.html文件。
路徑/static/index.html經過處理器http.StripPrefix去掉了前綴/static得到/index.html,然后又加上了http.Dir的起始目錄./public得到文件最終路徑./public/hello.html

除此之外,http.FileServer還會根據請求文件的后綴推斷內容類型,更全面:

// src/mime/type.go
var builtinTypesLower = map[string]string{
  ".css":  "text/css; charset=utf-8",
  ".gif":  "image/gif",
  ".htm":  "text/html; charset=utf-8",
  ".html": "text/html; charset=utf-8",
  ".jpeg": "image/jpeg",
  ".jpg":  "image/jpeg",
  ".js":   "application/javascript",
  ".mjs":  "application/javascript",
  ".pdf":  "application/pdf",
  ".png":  "image/png",
  ".svg":  "image/svg+xml",
  ".wasm": "application/wasm",
  ".webp": "image/webp",
  ".xml":  "text/xml; charset=utf-8",
}

如果文件后綴無法推斷,http.FileServer將讀取文件的前 512 個字節,根據內容來推斷內容類型。感興趣可以看一下源碼src/net/http/sniff.go

http.ServeContent

除了直接使用http.FileServer之外,net/http庫還暴露了ServeContent方法。這個方法可以用在處理器需要返回一個文件內容的時候,非常易用。

例如下面的程序,根據 URL 中的file參數返回對應的文件內容:

package main

import (
  "fmt"
  "log"
  "net/http"
  "os"
  "time"
)

func ServeFileContent(w http.ResponseWriter, r *http.Request, name string, modTime time.Time) {
  f, err := os.Open(name)
  if err != nil {
    w.WriteHeader(500)
    fmt.Fprint(w, "open file error:", err)
    return
  }
  defer f.Close()

  fi, err := f.Stat()
  if err != nil {
    w.WriteHeader(500)
    fmt.Fprint(w, "call stat error:", err)
    return
  }

  if fi.IsDir() {
    w.WriteHeader(400)
    fmt.Fprint(w, "no such file:", name)
    return
  }

  http.ServeContent(w, r, name, fi.ModTime(), f)
}

func fileHandler(w http.ResponseWriter, r *http.Request) {
  query := r.URL.Query()
  filename := query.Get("file")

  if filename == "" {
    w.WriteHeader(400)
    fmt.Fprint(w, "filename is empty")
    return
  }

  ServeFileContent(w, r, filename, time.Time{})
}

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/show", fileHandler)

  server := &http.Server {
    Addr:    ":8080",
    Handler: mux,
  }

  if err := server.ListenAndServe(); err != nil {
    log.Fatal(err)
  }
}

http.ServeContent除了接受參數http.ResponseWriterhttp.Request,還需要文件名name,修改時間modTimeio.ReadSeeker接口類型的參數。

modTime參數是為了設置響應的Last-Modified首部。如果請求中攜帶了If-Modified-Since首部,ServeContent方法會根據modTime判斷是否需要發送內容。
如果需要發送內容,ServeContent方法從io.ReadSeeker接口重讀取內容。*os.File實現了接口io.ReadSeeker

使用場景

Web 開發中的靜態資源都可以使用http.FileServer來處理。除此之外,http.FileServer還可以用於實現一個簡單的文件服務器,瀏覽或下載文件:

package main

import (
  "flag"
  "log"
  "net/http"
)

var (
  ServeDir string
)

func init() {
  flag.StringVar(&ServeDir, "sd", "./", "the directory to serve")
}

func main() {
  flag.Parse()

  mux := http.NewServeMux()
  mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(ServeDir))))


  server := &http.Server {
    Addr:    ":8080",
    Handler: mux,
  }

  if err := server.ListenAndServe(); err != nil {
    log.Fatal(err)
  }
}

在上面的代碼中,我們構建了一個簡單的文件服務器。編譯之后,將想瀏覽的目錄作為參數傳給命令行選項,就可以瀏覽和下載該目錄下的文件了:

$ ./main.exe -sd D:/code/golang

可以將端口也作為命令行選項,這樣做出一個通用的文件服務器,編譯之后就可以在其它機器上使用了😀。

總結

本文介紹了如何處理靜態文件,依次介紹了原始方式、http.FileServerhttp.ServeContent。最后使用http.FileServer實現了一個簡單的文件服務器,可供日常使用。

參考

  1. Go Web 編程
  2. net/http文檔

我的博客

歡迎關注我的微信公眾號【GoUpUp】,共同學習,一起進步~

本文由博客一文多發平台 OpenWrite 發布!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM