Golang 學習筆記(四)- archive/tar 實現打包壓縮及解壓

broqiang發表於2018-06-28

這個包比較簡單,就是將檔案進行打包和解包,要是熟悉 Linux 下的 tar 命令這個就很好理解了。 主要是通過 tar.Reader 讀取 tar 包,通過 tar.Writer 寫入 tar
包,在寫入的過程中再設定一下頭,詳細的過程以示例的方式進行展示,可以檢視程式碼裡面的註釋。

參考:

單個檔案操作

這個非常簡單,就是讀取一個檔案,進行打包及解包操作即可。

單個檔案打包

從 /etc/passwd 下複製了一個 passwd 檔案到當前目錄下,用來做壓縮測試。什麼檔案都是可以的,自己隨意寫一個也行。這裡的示例主要為了說明 tar ,沒有處理路徑,所以過程全部假設是在當前目錄下執行。

cp /etc/passwd .

關於檔案的打包直接檢視示例程式碼,已經在示例程式碼中做了詳細的註釋。

示例程式碼( pack_single_file.go ):

package main

import (
    "os"
    "log"
    "archive/tar"
    "fmt"
    "io"
)

func main() {
    // 準備打包的原始檔
    var srcFile = "passwd"
    // 打包後的檔案
    var desFile = fmt.Sprintf("%s.tar",srcFile)

    // 需要注意檔案的開啟即關閉的順序,因為 defer 是後入先出,所以關閉順序很重要
    // 第一次寫這個示例的時候就沒注意,導致寫完的 tar 包不完整

    // ###### 第 1 步,先準備好一個 tar.Writer 結構,然後再向裡面寫入內容。 ######
    // 建立一個檔案,用來儲存打包後的 passwd.tar 檔案
    fw, err := os.Create(desFile)
    ErrPrintln(err)
    defer fw.Close()

    // 通過 fw 建立一個 tar.Writer
    tw := tar.NewWriter(fw)
    // 這裡不要忘記關閉,如果不能成功關閉會造成 tar 包不完整
    // 所以這裡在關閉的同時進行判斷,可以清楚的知道是否成功關閉
    defer func() {
        if err := tw.Close(); err != nil {
            ErrPrintln(err)
        }
    }()

    // ###### 第 2 步,處理檔案資訊,也就是 tar.Header 相關的 ######
    // tar 包共有兩部分內容:檔案資訊和檔案資料
    // 通過 Stat 獲取 FileInfo,然後通過 FileInfoHeader 得到 hdr tar.*Header
    fi, err := os.Stat(srcFile)
    ErrPrintln(err)
    hdr, err := tar.FileInfoHeader(fi, "")
    // 將 tar 的檔案資訊 hdr 寫入到 tw
    err = tw.WriteHeader(hdr)
    ErrPrintln(err)

    // 將檔案資料寫入
    // 開啟準備寫入的檔案
    fr, err := os.Open(srcFile)
    ErrPrintln(err)
    defer fr.Close()

    written, err := io.Copy(tw, fr)
    ErrPrintln(err)

    log.Printf("共寫入了 %d 個字元的資料\n",written)
}

// 定義一個用來列印的函式,少寫點程式碼,因為要處理很多次的 err
// 後面其他示例還會繼續使用這個函式,就不單獨再寫,望看到此函式了解
func ErrPrintln(err error)  {
    if err != nil {
        log.Println(err)
        os.Exit(1)
    }
}

單個檔案解包

這個也很簡單,基本上將上面過程反過來,只需要處理 tar.Reader 即可,詳細的描述見示例。

這裡就用剛剛打包的 passwd.tar 檔案做示例,如果怕結果看不出效果,可以將之前用的 passwd 原始檔刪除。

rm passwd

示例程式碼( unpack_single_file.go ):

package main

import (
    "os"
    "archive/tar"
    "io"
    "log"
)

func main() {

    var srcFile = "passwd.tar"

    // 將 tar 包開啟
    fr, err := os.Open(srcFile)
    ErrPrintln(err)
    defer fr.Close()

    // 通過 fr 建立一個 tar.*Reader 結構,然後將 tr 遍歷,並將資料儲存到磁碟中
    tr := tar.NewReader(fr)

    for hdr, err := tr.Next(); err != io.EOF; hdr, err = tr.Next(){
        // 處理 err != nil 的情況
        ErrPrintln(err)
        // 獲取檔案資訊
        fi := hdr.FileInfo()

        // 建立一個空檔案,用來寫入解包後的資料
        fw, err := os.Create(fi.Name())
        ErrPrintln(err)

        // 將 tr 寫入到 fw
        n, err := io.Copy(fw, tr)
        ErrPrintln(err)
        log.Printf("解包: %s 到 %s ,共處理了 %d 個字元的資料。", srcFile,fi.Name(),n)

        // 設定檔案許可權,這樣可以保證和原始檔案許可權相同,如果不設定,會根據當前系統的 umask 來設定。
        os.Chmod(fi.Name(),fi.Mode().Perm())

        // 注意,因為是在迴圈中,所以就沒有使用 defer 關閉檔案
        // 如果想使用 defer 的話,可以將檔案寫入的步驟單獨封裝在一個函式中即可
        fw.Close()
    }
}

func ErrPrintln(err error){
    if err != nil {
        log.Fatalln(err)
        os.Exit(1)
    }
}

操作整個目錄

我們實際中 tar 很少會去打包單個檔案,一般都是打包整個目錄,並且打包的時候通過 gzip 或者 bzip2 壓縮。

如果要打包整個目錄,可以通過遞迴的方式來實現。這裡只演示了 gzip 方式壓縮,這個實現非常簡單,只需要在 fw 和 tw 之前加上一層壓縮即可,詳情見示例程式碼。

為了測試打包整個目錄,複製了一個 log 目錄到當前路徑下。什麼目錄和檔案都可以,只是因為這個裡面內容比較多,就拿這個來做測試了。

# 出現沒有許可權的錯誤不用管它,複製過來多少是多少吧
cp -r /var/log/ .

詳細的操作會在註釋中說明,不過在之前單檔案中出現過的步驟不再註釋。

打包壓縮

示例程式碼( targz.go ):

package main

import (
    "archive/tar"
    "compress/gzip"
    "fmt"
    "io"
    "log"
    "os"
    "path/filepath"
    "strings"
)

func main() {
    // 修改日誌格式,顯示出錯程式碼的所在行,方便除錯,實際專案中一般不記錄這個。

    var src = "apt"
    var dst = fmt.Sprintf("%s.tar.gz", src)

    // 將步驟寫入了一個函式中,這樣處理錯誤方便一些
    if err := Tar(src, dst); err != nil {
        log.Fatalln(err)
    }
}

func Tar(src, dst string) (err error) {
    // 建立檔案
    fw, err := os.Create(dst)
    if err != nil {
        return
    }
    defer fw.Close()

    // 將 tar 包使用 gzip 壓縮,其實新增壓縮功能很簡單,
    // 只需要在 fw 和 tw 之前加上一層壓縮就行了,和 Linux 的管道的感覺類似
    gw := gzip.NewWriter(fw)
    defer gw.Close()

    // 建立 Tar.Writer 結構
    tw := tar.NewWriter(gw)
    // 如果需要啟用 gzip 將上面程式碼註釋,換成下面的

    defer tw.Close()

    // 下面就該開始處理資料了,這裡的思路就是遞迴處理目錄及目錄下的所有檔案和目錄
    // 這裡可以自己寫個遞迴來處理,不過 Golang 提供了 filepath.Walk 函式,可以很方便的做這個事情
    // 直接將這個函式的處理結果返回就行,需要傳給它一個原始檔或目錄,它就可以自己去處理
    // 我們就只需要去實現我們自己的 打包邏輯即可,不需要再去路徑相關的事情
    return filepath.Walk(src, func(fileName string, fi os.FileInfo, err error) error {
        // 因為這個閉包會返回個 error ,所以先要處理一下這個
        if err != nil {
            return err
        }

        // 這裡就不需要我們自己再 os.Stat 了,它已經做好了,我們直接使用 fi 即可
        hdr, err := tar.FileInfoHeader(fi, "")
        if err != nil {
            return err
        }
        // 這裡需要處理下 hdr 中的 Name,因為預設檔案的名字是不帶路徑的,
        // 打包之後所有檔案就會堆在一起,這樣就破壞了原本的目錄結果
        // 例如: 將原本 hdr.Name 的 syslog 替換程 log/syslog
        // 這個其實也很簡單,回撥函式的 fileName 欄位給我們返回來的就是完整路徑的 log/syslog
        // strings.TrimPrefix 將 fileName 的最左側的 / 去掉,
        // 熟悉 Linux 的都知道為什麼要去掉這個
        hdr.Name = strings.TrimPrefix(fileName, string(filepath.Separator))

        // 寫入檔案資訊
        if err := tw.WriteHeader(hdr); err != nil {
            return err
        }

        // 判斷下檔案是否是標準檔案,如果不是就不處理了,
        // 如: 目錄,這裡就只記錄了檔案資訊,不會執行下面的 copy
        if !fi.Mode().IsRegular() {
            return nil
        }

        // 開啟檔案
        fr, err := os.Open(fileName)
        defer fr.Close()
        if err != nil {
            return err
        }

        // copy 檔案資料到 tw
        n, err := io.Copy(tw, fr)
        if err != nil {
            return err
        }

        // 記錄下過程,這個可以不記錄,這個看需要,這樣可以看到打包的過程
        log.Printf("成功打包 %s ,共寫入了 %d 位元組的資料\n", fileName, n)

        return nil
    })
}

打包及壓縮就搞定了,不過這個程式碼現在我還發現有個問題,就是不能處理軟連結

解包解壓

這個過程基本就是把壓縮的過程返回來,多了些建立目錄的操作

package main

import (
    "archive/tar"
    "compress/gzip"
    "fmt"
    "io"
    "os"
    "path/filepath"
)

func main() {
    var dst = "" // 不寫就是解壓到當前目錄
    var src = "log.tar.gz"

    UnTar(dst, src)
}

func UnTar(dst, src string) (err error) {
    // 開啟準備解壓的 tar 包
    fr, err := os.Open(src)
    if err != nil {
        return
    }
    defer fr.Close()

    // 將開啟的檔案先解壓
    gr, err := gzip.NewReader(fr)
    if err != nil {
        return
    }
    defer gr.Close()

    // 通過 gr 建立 tar.Reader
    tr := tar.NewReader(gr)

    // 現在已經獲得了 tar.Reader 結構了,只需要迴圈裡面的資料寫入檔案就可以了
    for {
        hdr, err := tr.Next()

        switch {
        case err == io.EOF:
            return nil
        case err != nil:
            return err
        case hdr == nil:
            continue
        }

        // 處理下儲存路徑,將要儲存的目錄加上 header 中的 Name
        // 這個變數儲存的有可能是目錄,有可能是檔案,所以就叫 FileDir 了……
        dstFileDir := filepath.Join(dst, hdr.Name)

        // 根據 header 的 Typeflag 欄位,判斷檔案的型別
        switch hdr.Typeflag {
        case tar.TypeDir: // 如果是目錄時候,建立目錄
            // 判斷下目錄是否存在,不存在就建立
            if b := ExistDir(dstFileDir); !b {
                // 使用 MkdirAll 不使用 Mkdir ,就類似 Linux 終端下的 mkdir -p,
                // 可以遞迴建立每一級目錄
                if err := os.MkdirAll(dstFileDir, 0775); err != nil {
                    return err
                }
            }
        case tar.TypeReg: // 如果是檔案就寫入到磁碟
            // 建立一個可以讀寫的檔案,許可權就使用 header 中記錄的許可權
            // 因為作業系統的 FileMode 是 int32 型別的,hdr 中的是 int64,所以轉換下
            file, err := os.OpenFile(dstFileDir, os.O_CREATE|os.O_RDWR, os.FileMode(hdr.Mode))
            if err != nil {
                return err
            }
            n, err := io.Copy(file, tr)
            if err != nil {
                return err
            }
            // 將解壓結果輸出顯示
            fmt.Printf("成功解壓: %s , 共處理了 %d 個字元\n", dstFileDir, n)

            // 不要忘記關閉開啟的檔案,因為它是在 for 迴圈中,不能使用 defer
            // 如果想使用 defer 就放在一個單獨的函式中
            file.Close()
        }
    }

    return nil
}

// 判斷目錄是否存在
func ExistDir(dirname string) bool {
    fi, err := os.Stat(dirname)
    return (err == nil || os.IsExist(err)) && fi.IsDir()
}

到這裡解壓就完成了,只是一個實驗程式碼,還有很多不完善的地方,歡迎提出寶貴的意見。

本文來自 BroQiang 部落格 ,隨便使用及轉載,帶上我就行

相關文章