Go 中如何高效遍歷目錄?探索幾種方法
目錄遍歷是一個很常見的操作,它的使用場景有如文件目錄查看(最典型的應用如 ls 命令)、文件系統清理、日志分析、項目構建等。
本文將嘗試逐步介紹在 Go 中幾種遍歷目錄文件的方法,從傳統的 ioutil.ReadDir 函數開始,逐漸深入。
圖片
文中也會提供示例代碼、提供一些性能剖析,以便于大家更好地理解。
ioutil.ReadDir
首先,Go 中目錄文件遍歷的第一種方式是 ioutil.ReadDir 函數。
在 Go 1.16 版本前,ioutil.ReadDir 就是遍歷目錄的標準方法,它的返回結構是目錄中文件的 FileInfo 列表,簡單直接。
示例代碼:
func main() {
files, err := ioutil.ReadDir(".")
if err != nil {
log.Fatal(err)
}
for _, f := range files {
fmt.Println(f.Name())
}
}
但它的缺點也非常明顯,性能不高。導致它的主要原因有如下幾點:
完全加載
這就導致了 ioutil.ReadDir 在返回結果前,會將目錄下所有文件的信息完全加載到內存中。對于包含大量文件的目錄,它就需要在內存中存儲大量的 FileInfo 對象,毫無疑問,這會增加內存使用。
FileInfo 開銷
由于是完全加載,每個 FileInfo 對象都包含了文件的詳細信息,如文件名、大小、修改時間等都會在返回之前都已經加載完成。但獲取這些信息需進行系統調用。而每個文件都要做這樣的調用,當文件數量很多時,這些系統調用的累積開銷可以變得不容忽視了。
無法分批處理
由于 ioutil.ReadDir 是一次性返回所有文件信息,沒有提供分批處理的能力。無論目錄中有多少文件,都要等待所有文件信息讀取完成,這在處理目錄中包含大量文件的場景中,也就無法提前并行處理,效率是可想而知的。
這一點其實和我們前面的一篇文章,介紹的 GO 中按行(或者說按塊)讀取文件的邏輯是類似的,一次加載全部內容,有潛在的性能問題。
由于 ioutil.ReadDir 有這么多的缺點,所以它在 Go 1.16 及更高版本已經被棄用了。
那現在我們該用什么方法呢?
os.ReadDir
從 Go 1.16 版本起,標準庫針對目錄遍歷查看提供了新的函數 os.ReadDir,以用來簡化和提高遍歷目錄文件的效率。
函數簽名如下:
func ReadDir(name string) ([]DirEntry, error)
os.ReadDir 函數返回一個按文件名排序的 DirEntry 類型切片。如果在讀取目錄項時遇到錯誤,它也會盡量返回已讀取內容。這種設計同時兼顧了效率和錯誤處理的需要。
示例代碼:
func main() {
files, err := os.ReadDir(".")
if err != nil {
log.Fatal(err)
}
for _, file := range files {
fmt.Println(file.Name())
}
}
os.ReadDir 相比于舊方法 ioutil.ReadDir 的有什么優勢?為什么丟棄 ioutil.ReadDir 而引入這個新的 os.ReadDir。
如果對比兩者源碼,會發現差異主要在返回的類型上。os.ReadDir 返回的 []DirEntry 而非 []FileInfo。它還具有性能優勢。
為什么?
因為 DirEntry 允許按需獲取文件詳情,即懶加載,而非是遍歷目錄時立即加載所有文件屬性。很多場景下,我們并不需要
我在 MacOS 系統下測試的 DirEntry 接口的實際變量類型為 os.unixDirent。
它的源碼如下:
func (d *unixDirent) Name() string { return d.name }
func (d *unixDirent) IsDir() bool { return d.typ.IsDir() }
func (d *unixDirent) Type() FileMode { return d.typ }
func (d *unixDirent) Info() (FileInfo, error) {
if d.info != nil {
return d.info, nil
}
return lstat(d.parent + "/" + d.name)
}
我們只有在調用 Info 方法時,才會真正通過 lstat 發起系統調用。
如果你有將舊代碼遷移到 DirEntry 的需求, Go 1.17 還引入了 fs.FileInfoToDirEntry 函數,允許我們將 FileInfo 對象轉換為 DirEntry 對象。
info, _ := os.Stat("somefile")dirEntry := fs.FileInfoToDirEntry(info)
看到這,對于認真思考的朋友,或許已經發現我們還有一個問題沒解決,即 os.ReadDir 不是也不支持分批處理的能力嗎?
繼續往下看吧,我將介紹一個更底層的方法。
os.File 的 ReadDir 方法
我們知道 os.Open 是用于打開文件的,但其實它也可用于打開目錄。如果 os.Open 打開的是目錄,我們在它返回的 os.File 上調用 ReadDir 以查看目錄內容。
示例代碼:
func main() {
dir, err := os.Open(".")
if err != nil {
log.Fatal(err)
}
defer dir.Close()
files, err := dir.ReadDir(-1)
if err != nil {
log.Fatal(err)
}
for _, file := range files {
fmt.Println(file.Name())
}
}
如上的代碼其實類似于 os.ReadDir 內容的實現代碼。
os.ReadDir 源碼如下:
func ReadDir(name string) ([]DirEntry, error) {
f, err := Open(name)
if err != nil {
return nil, err
}
defer f.Close()
dirs, err := f.ReadDir(-1)
sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() })
return dirs, err
}
這種方法更底層,提供了更多的靈活性。我們就可以用它分批讀取目標。
如何實現呢?
核心就是那句的 dir.ReadDir(-1),它的入參指定了每次讀取文件的數量,而 -1 表示讀取目錄的所有內容。我們只要將 -1 改為分批讀取的數量即可,多次循環即可。
示例代碼:
func main() {
dir, err := os.Open(".")
if err != nil {
log.Fatal(err)
}
defer dir.Close()
for {
files, err := dir.ReadDir(10) // 每批讀取10個條目
if err == io.EOF {
break // 遍歷完成
}
if err != nil {
log.Fatal(err) // 處理其他錯誤
}
for _, file := range files {
fmt.Println(file.Name())
}
}
}
這段代碼演示了如何使用 File.ReadDir 分批處理目錄中的文件。通過這種方式,可以更有效地管理內存使用。
補充一點
在寫這篇文章時,我發現 os.File 有兩個查看目錄的方法,分別是 Readdir 和 ReadDir。功能上的區別是新的 ReadDir 返回的是 []DirEntry,而 Readdir 返回的是 []FileInfo。
換句話說,ReadDir 本質上是 Readdir 的升級版。
它們的函數簽名,如下所示:
func (f *File) Readdir(n int) ([]FileInfo, error)
func (f *File) ReadDir(n int) ([]DirEntry, error)
這算是不支持可選參數和重載,但要解決兼容問題采取的措施嗎?真的是蚌埠住了。
目錄的遞歸遍歷
現在,還差最后一個內容沒有介紹,那就是遞歸目錄遍歷。
針對目錄的遞歸遍歷,Go 中提供了一個專門的函數,filepath.Walk。它可以遍歷指定目錄下的所有子目錄。
示例代碼:
func main() {
err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
fmt.Println(path)
return nil
})
if err != nil {
fmt.Printf("error walking the path %v: %v\n", ".", err)
}
}
我們通過遍歷的回調函數中在處理每個文件。它簡化了目錄的遞歸遍歷,但對于大型或深層次的目錄結構,同樣存在著提前加載 FileInfo 的問題。
針對這個問題,在 Go1.16 版本也引入了基于 DirEntry 版的 filepath.WalkDir 函數。
filepath.WalkDir 的函數簽名如下:
func WalkDir(root string, fn fs.WalkDirFunc) error
fs.WalkDirFunc 的定義如下:
type WalkDirFunc func(path string, d DirEntry, err error) error
新函數的遍歷回調參數是 DirEntry,而非 FileInfo。現在,filepath.WalkDir 也有了延遲加載 FileInfo 的能力了。
現在,我們再來看下這張圖。
圖片
總結
在本文中,我們系統介紹了 Go 中多種遍歷目錄文件的方法。從傳統的 ioutil.ReadDir,到 Go 1.16 引入的 os.ReadDir,os.File 的 ReadDir 方法。每種方法適用于不同的場景,如何選擇要取決于你的需求、Go 版本、性能。如果你需要遞歸遍歷,也可以使用基于 DirEntry 的 filepath.WalkDir 實現,提高遍歷的性能。
引用鏈接
[1] Go 中如何遍歷目錄?探索幾種方法: https://www.poloxue.com/2024-02-22-list-directory-in-golang/