基于 Go1.16 實現(xiàn)靜態(tài)文件的 HTTP Cache
閱讀本文大概需要 15 分鐘。
大家好,我是站長 polarisxu。
之前寫過一篇文章:《提前試用將在 Go1.16 中發(fā)布的內(nèi)嵌靜態(tài)資源功能》,如果之前沒閱讀,建議繼續(xù)看本文前先閱讀下該文。
現(xiàn)在 Go 1.16 Beta 已經(jīng)發(fā)布,離正式版發(fā)布不遠了,在 GitHub 發(fā)現(xiàn)了一個庫,它實現(xiàn)了 io/fs.FS 接口,它能夠計算文件的 SHA256 哈希值并附加到文件名中以允許進行 HTTP Cache:即控制靜態(tài)文件的版本。本文對其進行介紹并順帶講解一些涉及到的其他內(nèi)容。
溫馨提示:本文內(nèi)容基于 Go 1.16 Beta,之前版本不支持!
01 hashfs 包
包地址:https://github.com/benbjohnson/hashfs,有效代碼函數(shù)不到 200。
對于給定的一個文件,比如 scripts/main.js,hashfs.FS 文件系統(tǒng)處理后會生成一個帶 hash 的文件,類似 scripts/main-b633a..d628.js(中間有省略),客戶端請求該文件時,可以選擇讓客戶端緩存。hash 算法使用的是 SHA256。當(dāng)文件內(nèi)容發(fā)生變化時,hash 值也會變。
該包默認提供對 net/http 的兼容。通過例子看看具體怎么使用。
02 基于 net/http 的使用
創(chuàng)建一個目錄,使用 module:
$?mkdir?~/embed
$?cd?~/embed
$?go?mod?init?gtihub.com/polaris1119/embed
為了基于同一個項目演示不同使用方式,創(chuàng)建如下目錄結(jié)構(gòu):
├──?cmd
│???├──?std
│???│???└──?main.go
├──?embed.go
├──?go.mod
├──?go.sum
├──?static
│???└──?main.js?//?主要處理該文件的嵌入、hash
├──?template
│???└──?index.html
其中 embed.go 的作用在本文開頭文章提到過,內(nèi)容如下:
package?embed
import?(
?"embed"
?"github.com/benbjohnson/hashfs"
)
//go:embed?static
var?embedFS?embed.FS
//?帶?hash?功能的?fs.FS
var?Fsys?=?hashfs.NewFS(embedFS)
再說一句,因為 //go:embed 只能相對當(dāng)前源文件所在目錄,所以單獨創(chuàng)建這個文件以便和 static 在同一級目錄。
index.html 和 main.js 的內(nèi)容很簡單。
index.html:
<html>
??<head>
????<title>測試?Embed?Hashtitle>
????<script?src="/assets/{{.mainjs}}">script>
??head>
??<body>
????<h1>測試?Embed?Hashh1>
????<hr>
????<div>
??????以下內(nèi)容來自 JS:
????div>
????<p?id="content"?style="color:?red;">p>
??body>
html>
該模板中有一個變量:mainjs。
main.js:
window.onload?=?function()?{
????document.querySelector('#content').innerHTML?=?"我是?JS?內(nèi)容";
}
如果一切正常,看到的頁面如下:

在 cmd/std/main.go 中寫上如下代碼:
package?main
import?(
?"fmt"
?"html/template"
?"log"
?"net/http"
?"github.com/benbjohnson/hashfs"
?"github.com/polaris1119/embed"
)
func?main()?{
?http.Handle("/assets/",?http.StripPrefix("/assets/",?hashfs.FileServer(embed.Fsys)))
?http.HandleFunc("/",?func(w?http.ResponseWriter,?r?*http.Request)?{
??tpl,?err?:=?template.New("index.html").ParseFiles("template/index.html")
??if?err?!=?nil?{
???fmt.Fprint(w,?err.Error())
???return
??}
??err?=?tpl.Execute(w,?map[string]interface{}{
???"mainjs":?embed.Fsys.HashName("static/main.js"),
??})
??if?err?!=?nil?{
???fmt.Fprint(w,?err.Error())
???return
??}
?})
?log.Fatal(http.ListenAndServe(":8080",?nil))
}
特意為靜態(tài)資源加上 /assets/前綴,后文解釋;hashfs.FileServer(embed.Fsys))是 hashfs 包對 net/http 的支持,即 hashfs.FileServer 是一個 http.Handler;embed.Fsys.HashName("static/main.js")將文件生成為帶 hash 的;
執(zhí)行 go run ./cmd/std/main.go,打開瀏覽器訪問:http://localhost:8080 即可看到上面截圖的頁面,審查元素可以看到如下信息,緩存一年。(見代碼:https://github.com/benbjohnson/hashfs/blob/main/hashfs.go#L200)

當(dāng)你再次刷新瀏覽器,看到 js 文件直接從緩存獲取的。

當(dāng) main.js 的內(nèi)容發(fā)生變化,main-xxx.js 中的 hash 部分也會變化,你可以自行試驗。(注意,因為資源內(nèi)嵌了,修改了 js 的內(nèi)容,需要重新 go run)。
03 關(guān)于服務(wù)靜態(tài)文件
這塊有必要單獨拿出來說下,因為比較容易搞錯。比如上面的一行代碼改為這樣:
http.Handle("/assets",?http.StripPrefix("/assets",?hashfs.FileServer(embed.Fsys)))
再次運行結(jié)果就不對(沒有 “我是 JS 內(nèi)容”)。(注意禁用瀏覽器緩存,否則看不到效果)
如果是 Echo 框架,則可以:
e.Static("/assets",?".")
Gin 框架,也可以:
router.Static("/assets",?".")
關(guān)于其中的細節(jié),大家有興趣可以查閱相關(guān)源碼。這里只要記住,服務(wù)目錄,末尾加上 /,(目錄嘛,應(yīng)該有 /),即:
http.Handle("/assets/",?...)
04 基于 Echo 的使用
在 cmd 目錄下創(chuàng)建 echo/main.go 文件:
package?main
import?(
?"bytes"
?"fmt"
?"io"
?"mime"
?"net/http"
?"net/url"
?"os"
?"path"
?"strconv"
?"text/template"
?"github.com/benbjohnson/hashfs"
?"github.com/labstack/echo/v4"
?"github.com/polaris1119/embed"
)
func?main()?{
?e?:=?echo.New()
?e.GET("/assets/*",?func(ctx?echo.Context)?error?{
??filename,?err?:=?url.PathUnescape(ctx.Param("*"))
??if?err?!=?nil?{
???return?err
??}
??isHashed?:=?false
??if?base,?hash?:=?hashfs.ParseName(filename);?hash?!=?""?{
???if?embed.Fsys.HashName(base)?==?filename?{
????filename?=?base
????isHashed?=?true
???}
??}
??f,?err?:=?embed.Fsys.Open(filename)
??if?os.IsNotExist(err)?{
???return?echo.ErrNotFound
??}?else?if?err?!=?nil?{
???return?echo.ErrInternalServerError
??}
??defer?f.Close()
??//?Fetch?file?info.?Disallow?directories?from?being?displayed.
??fi,?err?:=?f.Stat()
??if?err?!=?nil?{
???return?echo.ErrInternalServerError
??}?else?if?fi.IsDir()?{
???return?echo.ErrForbidden
??}
??contentType?:=?"text/plain"
??//?Determine?content?type?based?on?file?extension.
??if?ext?:=?path.Ext(filename);?ext?!=?""?{
???contentType?=?mime.TypeByExtension(ext)
??}
??//?Cache?the?file?aggressively?if?the?file?contains?a?hash.
??if?isHashed?{
???ctx.Response().Header().Set("Cache-Control",?`public,?max-age=31536000`)
??}
??//?Set?content?length.
??ctx.Response().Header().Set("Content-Length",?strconv.FormatInt(fi.Size(),?10))
??//?Flush?header?and?write?content.
??buf?:=?new(bytes.Buffer)
??if?ctx.Request().Method?!=?"HEAD"?{
???io.Copy(buf,?f)
??}
??return?ctx.Blob(http.StatusOK,?contentType,?buf.Bytes())
?})
?e.GET("/",?func(ctx?echo.Context)?error?{
??tpl,?err?:=?template.New("index.html").ParseFiles("template/index.html")
??if?err?!=?nil?{
???return?err
??}
??var?buf?=?new(bytes.Buffer)
??err?=?tpl.Execute(buf,?map[string]interface{}{
???"mainjs":?embed.Fsys.HashName("static/main.js"),
??})
??if?err?!=?nil?{
???return?err
??}
??return?ctx.HTML(http.StatusOK,?buf.String())
?})
?e.Logger.Fatal(e.Start(":8080"))
}
服務(wù)靜態(tài)文件的代碼: e.GET("/assets/*", func(ctx echo.Context) error {,主要參照了 https://github.com/benbjohnson/hashfs/blob/main/hashfs.go#L162 的實現(xiàn);首頁的路由和 net/http 基本一樣,關(guān)注 mainjs 模板變量;
簡單解釋下服務(wù)靜態(tài)文件的實現(xiàn)原理:
獲取請求的路徑( *部分);通過 hashfs.ParseName 解析出文件的 base 和 hash 兩部分; 使用 fs.FS 打開文件,判斷文件類型、大小,并將內(nèi)容返回給客戶端,如果有緩存,設(shè)置 HTTP Cache;
運行 go run ./cmd/echo/main.go,不出意外和 net/http 版本一樣的效果。
05 基于 Gin 的使用
其實知道了如何基于 Echo 框架使用,其他框架參照著實現(xiàn)即可。因為 Gin 框架用戶多,因此也實現(xiàn)下。
在 cmd 目錄下創(chuàng)建文件:gin/main.go
package?main
import?(
?"bytes"
?"io"
?"mime"
?"net/http"
?"net/url"
?"os"
?"path"
?"strconv"
?"strings"
?"github.com/benbjohnson/hashfs"
?"github.com/gin-gonic/gin"
?"github.com/polaris1119/embed"
)
func?main()?{
?r?:=?gin.Default()
?r.GET("/assets/*filepath",?func(ctx?*gin.Context)?{
??filename,?err?:=?url.PathUnescape(ctx.Param("filepath"))
??if?err?!=?nil?{
???ctx.AbortWithError(http.StatusInternalServerError,?err)
???return
??}
??filename?=?strings.TrimPrefix(filename,?"/")
??isHashed?:=?false
??if?base,?hash?:=?hashfs.ParseName(filename);?hash?!=?""?{
???if?embed.Fsys.HashName(base)?==?filename?{
????filename?=?base
????isHashed?=?true
???}
??}
??f,?err?:=?embed.Fsys.Open(filename)
??if?os.IsNotExist(err)?{
???ctx.AbortWithError(http.StatusNotFound,?err)
???return
??}?else?if?err?!=?nil?{
???ctx.AbortWithError(http.StatusInternalServerError,?err)
???return
??}
??defer?f.Close()
??//?Fetch?file?info.?Disallow?directories?from?being?displayed.
??fi,?err?:=?f.Stat()
??if?err?!=?nil?{
???ctx.AbortWithError(http.StatusInternalServerError,?err)
???return
??}?else?if?fi.IsDir()?{
???ctx.AbortWithError(http.StatusForbidden,?err)
???return
??}
??contentType?:=?"text/plain"
??//?Determine?content?type?based?on?file?extension.
??if?ext?:=?path.Ext(filename);?ext?!=?""?{
???contentType?=?mime.TypeByExtension(ext)
??}
??//?Cache?the?file?aggressively?if?the?file?contains?a?hash.
??if?isHashed?{
???ctx.Writer.Header().Set("Cache-Control",?`public,?max-age=31536000`)
??}
??//?Set?content?length.
??ctx.Writer.Header().Set("Content-Length",?strconv.FormatInt(fi.Size(),?10))
??//?Flush?header?and?write?content.
??buf?:=?new(bytes.Buffer)
??if?ctx.Request.Method?!=?"HEAD"?{
???io.Copy(buf,?f)
??}
??ctx.Data(http.StatusOK,?contentType,?buf.Bytes())
?})
?r.LoadHTMLGlob("template/*")
?r.GET("/",?func(ctx?*gin.Context)?{
??ctx.HTML(http.StatusOK,?"index.html",?gin.H{
???"mainjs":?embed.Fsys.HashName("static/main.js"),
??})
?})
?r.Run(":8080")
}
服務(wù)靜態(tài)文件的內(nèi)容和 Echo 框架基本一樣,除了各自框架特有的。
因為 Gin 框架提供了 LoadHTMLGlob,首頁路由的處理函數(shù)代碼很簡單。
運行 go run ./cmd/gin/main.go,不出意外和 net/http 版本一樣的效果。
06 總結(jié)
舉一反三,在學(xué)習(xí)過程中可以讓你更好的掌握某個知識點。
之前有讀者問到 module 如何使用 vendor(沒網(wǎng)情況下使用)。今天試驗這個就是用了 vendor。其實它的使用很簡單,在項目下執(zhí)行:go mod vendor 即可。不過需要注意的是,加入了新的依賴,就應(yīng)該執(zhí)行一次 go mod vendor。
今天介紹的這個庫在這個時代用到的可能性不高,不過也有可能會用得到。更重要的是希望這篇文章可以作為一個小項目實踐下。希望你能從頭自己編碼實現(xiàn)。
另外還留了一個問題給你:index.html 文件沒有內(nèi)嵌,請你自己完成。(提示:html/template 增加了對 io/fs.Fs 的支持)
本項目完整代碼:https://github.com/polaris1119/embed。
歡迎關(guān)注我
