Go filesystems and file embedding

Ben Hoyt

Go 檔案系統與檔案嵌入

原文由 Ben Hoyt 發布,訂閱此部落格

Go 團隊最近發表了數份草案設計,提議對語言、標準函式庫與工具進行修改:我們在六月曾報導過其中關於泛型的那一份。上週,Go 團隊又發表了兩份與檔案相關的草案設計:一份是全新的唯讀檔案系統介面,定義了檔案系統的最小介面,另一份則提議以標準方式將檔案嵌入到 Go 執行檔中(以前者為基礎)。將檔案嵌入 Go 執行檔的目的,是透過把程式的所有資源打包進單一執行檔來簡化部署;而檔案系統介面的設計,主要就是作為實現此功能的基礎。這兩份草案引發了大量討論,整體反應大致正面,但也存在一些重大的疑慮。

Russ Cox(Go 團隊的技術負責人)與 Rob Pike(Go 語言的創建者之一)是檔案系統介面設計的作者。Cox 也與長期的 Go 貢獻者 Brad Fitzpatrick 共同撰寫了檔案嵌入的設計。此外,Cox 還為偏好影音形式的讀者分別製作了兩份設計的 YouTube 簡報影片(檔案系統介面影片檔案嵌入影片)。兩份設計都很快強調,它們(目前)還不是正式提案:

這是一份草案設計,而非正式的 Go 提案,因為它描述的是一項潛在的大型變更,旨在解決與許多第三方套件相同的需求,並可能影響這些套件的實作(希望是讓它們變得更簡單!)。發布這份草案設計的目的,是為了蒐集回饋,以形塑未來預計提出的正式提案。

許多較小的語言與函式庫變更會在 GitHub issue tracker 上討論,但對於這類較大型的議題,Go 團隊嘗試改用 r/golang 的 Reddit 討論串來擴大討論規模——因為 GitHub issues 沒有任何形式的討論串功能,多個對話同時進行時很難追蹤。每份草案都有各自的 Reddit 討論串——檔案系統介面討論串檔案嵌入討論串——兩者都有相當多的留言。此外,還有一串很長的 Hacker News 討論串在討論檔案嵌入的設計。

檔案系統介面

檔案系統介面設計的核心,是在全新的 io/fs 標準函式庫套件中,一個僅有單一方法的介面,名稱為 FS

    type FS interface {
        Open(name string) (File, error)
    }

這表示每個檔案系統實作至少都必須實作依名稱開啟檔案的能力,並回傳一個 File 以及一個錯誤。File 介面的定義如下:

    type File interface {
        Stat() (os.FileInfo, error)
        Read(buf []byte) (int, error)
        Close() error
    }

換句話說,一個檔案具有以下特性:能夠提供如同 stat() 所回傳的檔案資訊、能夠被讀取,以及可以被關閉。這些是符合規範的檔案系統所需提供的最基本功能,但實作「亦可提供其他方法來最佳化操作或新增功能」。標準函式庫的檔案型別(os.File)本身就已經實作了這三個方法,因此它就是一個符合 fs.File 的實作。

如果一個 File 實際上是目錄,Stat() 所回傳的檔案資訊就會標示這一點;在這種情況下,Open() 所回傳的 File 除了 File 介面之外,還必須實作 Readdir() 方法。Readdir() 會回傳一個 os.FileInfo 物件清單,代表該目錄底下的檔案。

檔案系統實作可以透過設計中所稱的「extension interface」來提供額外功能,這是一種「嵌入基礎介面並加入一個或多個額外方法,藉此指定基礎介面實例可能提供的選用功能」的介面。舉例來說,一次讀取整個檔案是很常見的操作,而對於記憶體內的檔案系統實作來說,若透過 Open()、多次呼叫 Read() 再加上 Close() 來完成,可能會沒有效率。在這種情況下,開發者可以依照ReadFileFS 擴充介面所定義的來實作 ReadFile() 方法:

    type ReadFileFS interface {
        FS  // embed the filesystem interface (Open method)
        ReadFile(name string) ([]byte, error)
    }

除了擴充介面之外,設計還在 io/fs 套件中新增了一個 ReadFile() 輔助函式,它會檢查檔案系統是否具備 ReadFileFS 擴充,若有就直接使用,否則就退回執行開啟/讀取/關閉的流程。草案中還定義了其他多種擴充介面,包括 StatFSReadDirFSGlobFS。該設計並未提供重新命名或寫入檔案的方式,但這些功能也可以透過擴充來實現。

除了 io/fs 中新的型別與輔助函式外,該設計也建議修改多個標準函式庫套件以運用新的 FS 介面。舉例來說,在 html/template 套件中新增一個 ParseFS() 方法,以便從記憶體內的檔案系統解析樣板,或是 archive/zip 套件實作 FS,讓開發者能將 zip 檔視為檔案系統,在任何允許使用 FS 的地方加以運用。

Reddit 討論中的多數回饋是正面的,看起來這類介面正是開發者想要的。不過,有幾個人提出的批評之一,是關於擴充介面的缺點。「Acln0」總結了這些疑慮:

我只有一個觀察要提出,與擴充介面和擴充模式有關。這讓我想到 http.ResponseWriter 以及 http 套件所使用的選用介面。由於這些選用介面的存在,要包裝 http.ResponseWriter 變得很困難。要「通用地」做到這一點,會涉及選用介面的組合爆炸,而且很容易以這種方式出錯:「我們透過包裝 http.ResponseWriter 加入了狀態日誌記錄,結果現在 HTTP/2 push 不能用了,因為我們的包裝器把下游處理器需要的 Push 方法藏起來了」。

知名的 Go 部落客與講者 Peter Bourgon 認為,這種對擴充介面的使用方式意味著「要使用(極為有用的)裝飾者模式將變得不可行。這真的很可惜。對我而言,這讓這份提案幾乎無法成為起點;裝飾者模式太有用了,不該以這種方式被破壞。」裝飾者模式會包裝一個介面並加入一些功能。它常被用於網頁伺服器的日誌記錄或驗證中介軟體;在檔案系統的脈絡中,它可能會被用來加入快取或轉換層。如果中介軟體的作者沒有考量到各種選用介面,最終的包裝器就無法支援它們。以 Go 撰寫的雲端儲存工具 Rclone 的作者 Nick Craig-Wood 喜歡這份提案,但也表達了類似的擔憂:「擴充(或如我通常所稱的選用)介面是很大的維護負擔——要包裝它們真的很困難」。

設計中指出「讓這類中介軟體得以實現是這份草案設計的一個關鍵目標」,因此設計的作者們正面處理這個問題似乎是明智之舉。Cox 尚未提出解決方案,但他承認了這個問題:「這是真的——擴充與包裝器之間確實存在張力。我還沒看過任何完美的解法。」。

另一個疑慮來自「TheSwedeheart」,與 contexts(在 Go 中用來沿著呼叫鏈明確傳遞逾時、取消訊號與請求範圍數值的標準方式)有關:「要把[他的虛擬檔案系統]遷移到這個設計上,我缺少的一件事是對每個操作傳遞 contexts 以支援取消的功能。」。Cox 回覆表示,函式庫作者「大概可以把 context 傳給建構函式,讓它回傳一個內嵌該 context 的 FS,然後讓該 context 套用到使用該特定 FS 所進行的呼叫上。」正如「lobster_johnson」指出的,這違背了 context 套件的指導原則——應將 context 作為第一個函式參數明確傳遞,而不是將 context 儲存在結構體內。然而,Cox 以 http.Request 做了類似事情為例反駁:「那些比較像是守則而非規則。[...] 有時候這樣做確實有道理。

當然,也少不了常見的針對命名吹毛求疵的討論串;「olegkovalov」:「我有點擔心 io/fs 這個名稱,fs 是個很好的變數名稱,當 io/fs 出現時會給使用者帶來很多麻煩」。經過一番來回討論後,Cox 強調了使用簡短名稱的必要性,以讓焦點保持在應用程式開發者身上,而非檔案系統的實作者:

你關注的是檔案系統的實作者而非使用者。像 os.FileInfo、os.ModeDir、os.PathError、os.ErrNotExist 這類的程式碼,未來都將正規地改為參照 fs.FileInfo、fs.ModeDir、fs.PathError、fs.ErrNotExist。這些看起來比比如 filesystem.ErrNotExist 好得多。而且,參照這些名稱的程式碼會遠比實作檔案系統的程式碼多得多。

將檔案嵌入執行檔

另一份草案設計提議了一種將檔案(或稱「靜態資源」)嵌入 Go 執行檔並在執行時讀取其內容的方法。這簡化了發布與部署,因為開發者只需複製一個不含外部相依性的大型執行檔即可(用於 SQL 片段、HTML 樣板、網頁應用程式的 CSS 與 JavaScript 資源等)。如文件所指出的,已經有十多種第三方工具可以做到這一點,但「為嵌入的基本功能在 go 指令中加入直接支援,將能消除對其中一些工具的需求,至少也能簡化其他工具的實作」。將嵌入功能納入標準的 go 工具,也意味著不再需要把檔案轉換為 Go 原始碼中資料的預先建置步驟,也無需將那些產生的檔案提交到版本控制中。

設計的作者們明確表示,這是一項工具層面的變更,而非 Go 語言層面的變更:

另一個明確的目標是避免對語言進行變更。對我們而言,嵌入靜態資源似乎是工具的問題,而非語言的問題。避免語言變更也意味著我們無需更新許多處理 Go 程式碼的工具,其中包括 goimports、gopls 與 staticcheck。

go 工具本來就會在 Go 原始碼檔案中尋找各種特殊註解,包括用來僅在特定架構上包含某些檔案的 // +build 標籤,以及告訴 go generate 為了程式碼產生目的該執行哪些指令的 //go:generate 註解。這份檔案嵌入設計提議了一種新的 //go:embed 註解指令,它直接置於變數宣告的上方,並告訴 go build 將那些檔案包含到與該變數相關聯的最終執行檔中。以下是一個具體範例:

    // The "content" variable holds our static web server content.
    //go:embed image/* template/*
    //go:embed html/index.html
    var content embed.Files

這會讓 go buildimagetemplate 目錄下的所有檔案,以及 html/index.html 檔案包含進來,並透過 content 變數(其型別為 embed.Files)來存取。embed 套件是一個新提議的標準函式庫套件,其中包含了存取嵌入檔案的 API。此外,embed.Files 型別實作了前述檔案系統設計中的 fs.FS 介面,讓嵌入的檔案可以直接與其他標準函式庫套件(如 net/httphtml/template)以及任何支援新檔案系統介面的第三方套件搭配使用。

該設計在一個重要的方面限縮了提案的範圍。在將檔案中的資料包含到執行檔之前,有許多可以轉換這些資料的方式:資料壓縮、TypeScript 編譯、影像縮放等等。這份設計採取了一種簡單的做法,僅包含檔案的原始資料:

要讓 go 指令預期或包含所有可能需要的轉換是不可行的。go 指令本身也不是一個通用的建置系統;特別要記住的設計限制是,它在建置期間絕不會執行使用者程式。這類轉換最好留給外部建置系統,例如 Make 或 Bazel,由它們寫出 go 指令應該嵌入的確切位元組。

同樣地,Reddit 討論串上的回饋大多是正面的,例如來自「bojanz」的這則留言:「這看起來是個很棒的開始。感謝你們處理這個問題。」也有一些較小的建議,例如「zikaeroh」的一則留言,贊成加入更強大的路徑匹配 API,支援以雙星號進行遞迴路徑匹配,就像 在 Python 中glob('**/*.png', recursive=True) 那樣。身為某個檔案嵌入套件維護者的 Kevin Burke 則建議,也儲存每個檔案內容的加密雜湊值,這樣開發者就不需要在執行時再對檔案做雜湊:「這對於例如靜態檔案伺服器上的快取清除很有用」。

其中一個反覆出現的批評,來自不喜歡以特殊的 //go:embed 語法讓原始碼註解負擔過重的開發者。「Saturn_vk」直言表示:「我真的不喜歡註解被濫用來做實際工作這件事」,而 Hacker News 的留言者「breakingcups」則強烈主張應使用專案檔來取代註解中的指令:

又是更多魔法註解。

提議的功能很棒,但 Go 團隊不願使用一個獨立、定義明確的專案檔,或至少在程式碼檔案中使用獨立的語法,導致他們把每個額外功能都塞進註解裡——一個原本屬於人類筆記的空間。

Cox 以以下這則留言總結了他對此事的看法,並將該語法與 C 語言的 #pragma 相比:

姑且不論其他,我們已經有了 //go:generate 以及其他幾個較少人知的指令。而且還有一份獨立的草案設計,打算以 // +build 取代 //go:build。到那時我們就會完全一致:這類指令皆以 //go: 開頭。重點在於,看起來要夠像註解,讓不需要理會它的工具可以忽略它,但又要夠不像註解,以向人們表明有特別的事情正在發生。

C 語言用 #pragma foo 來做這件事。Go 只是把 #pragma 拼寫成 //go:

後續發展

兩份草案設計都獲得了相當程度的社群支持,尤其是更面向使用者的檔案嵌入提案。許多開發者已經在使用第三方檔案嵌入函式庫來簡化部署,而這些努力將使相關工具標準化。這些設計很可能會經過進一步完善並轉為正式提案。隨著 Go 1.15 預計於 8 月 1 日發布,這些提案有可能趕上 Go 1.16(預定六個月後推出),但若還需要另一輪回饋——例如關於擴充介面的問題——則更有可能在一年後的 Go 1.17 中才會納入。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言