archive/tar 实现打包压缩及解压

作者: BroQiang

发布于 2018-06-28 | 最后更新 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()
}

到这里解压就完成了,只是一个实验代码,还有很多不完善的地方,欢迎提出宝贵的意见。