見出し画像

Go で .tar.zst 形式の圧縮、解凍処理を実装する


はじめに

こんにちは、くるふとです。
ナビタイムジャパンで、時刻表 API や地図描画 API の 開発・運用業務を主に担当しています。

今回は Go で .tar.zst 形式の圧縮、解凍処理の実装方法を紹介します。

複数ファイルを1つのファイルにアーカイブし、圧縮するような操作はエンジニアの業務でもよくあります。
例を出すと、下記のようなシェルコマンドで圧縮ファイルを作成したりします。

$ find ./sample -type f
./sample/1/a.txt
./sample/3/c.txt
./sample/2/b.txt
$ tar -c sample | zstd > sample.tar.zst
$ zstd -dc sample.tar.zst | tar -x -C ./dst
$ find ./dst -type f
./dst/sample/1/a.txt
./dst/sample/3/c.txt
./dst/sample/2/b.txt

ただ、以下のようなケースだと、シェルではなくよりリッチなプログラミング言語を用いてファイルの圧縮処理を組みたい時もあると思います。

全体の処理が複雑な時

下記のような処理が複雑なケースだと、シェルよりもよりリッチなプログラミング言語に頼りたいと感じることが多いでしょう。

  • 条件分岐が多く、シェルだとエラーハンドリングの実装が難しい場合

  • サブコマンドやリッチなオプションや機能を持った CLI を実装したい場合

関数ごとにテストを書きたい時

他のプログラミング言語にあるユニットテスト機構(Go で言うところの go test 、 Python で言うところの unittest など)を、シェルは標準では持っていません。
サードパーティ製のテストツール(batsshellspec など)は存在します。ただ、標準でのサポートがないことを考慮すると、ユニットテストの分野は他のプログラミング言語に目移りしたくなる箇所かと思います。

特定のコマンドに依存しないような実行ファイルを組みたい時

シェルスクリプトだと zstd コマンド等を実行環境にインストールする必要があります。インストールされていない場合、下記のようにスクリプトが失敗してしまいます。

$ cat compress.sh
#!/bin/bash
set -euo pipefail

function main() {
    tar -c ./target | zstd > target.tar.zstd
}

main "$@"
$ ./compress.sh
./compress.sh: line 5: zstd: command not found

例えば今回紹介する Go などであれば、実行ファイルをシングルバイナリとしてビルドできるため、環境ごとに特定のコマンドをインストールする必要はなくなります。

上記のようなケースに応えるべく、今回の記事では、Go で .tar.zst 形式の圧縮、解凍処理を実装する方法をまとめてみました。

Go における tar, zstandard について

tar

tar 形式のアーカイブについては Go で標準ライブラリとして提供されています。

zstandard

圧縮処理の標準ライブラリは compress という名前で提供されています。

しかし、zstandard は標準ライブラリでの用意はなく、サードパーティー製のライブラリを使用する必要があります。いくつか選択肢がありますが、今回はその中でもスター数が多い klauspost/compress を紹介します。

コード&コマンド例

まず最初に、コードの実装例とコマンドの実行例を載せます。
以下のコードとコマンドによって指定したディレクトリの圧縮、解凍ができます。

main.go

package main

import (
	"archive/tar"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"

	"github.com/klauspost/compress/zstd"
)

func compress(targetDir string, outputFile string) error {
	fmt.Println("start compress...")

	// .tar.zst 形式の Writer を作成
	f, err := os.Create(outputFile)
	if err != nil {
		return err
	}
	defer f.Close()

	zstdw, err := zstd.NewWriter(f)
	if err != nil {
		return err
	}
	defer zstdw.Close()

	tarw := tar.NewWriter(zstdw)
	defer tarw.Close()

	// 指定したディレクトリ配下のファイルを再帰的に参照し、 .tar.zst 形式に圧縮した状態でファイルに書き込む
	filepath.WalkDir(targetDir, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}

		if d.IsDir() {
			return nil
		}

		fmt.Printf("file: %s\n", path)

		fi, err := d.Info()
		if err != nil {
			return err
		}

		// tar のヘッダー情報を定義する
		header, err := tar.FileInfoHeader(fi, path)
		if err != nil {
			return err
		}

		header.Name = path

		if err := tarw.WriteHeader(header); err != nil {
			return err
		}

		// .tar.zst 形式に圧縮しながらファイルに書き込む
		f, err := os.Open(path)
		if err != nil {
			return err
		}
		defer f.Close()

		if _, err := io.Copy(tarw, f); err != nil {
			return err
		}

		return nil
	})

	fmt.Println("compression completed!")

	return nil
}

func decompress(targetFile string, dstDir string) error {
	fmt.Println("start decompress...")

	// .tar.zst 形式の Reader を作成
	f, err := os.Open(targetFile)
	if err != nil {
		return err
	}
	defer f.Close()

	zstdr, err := zstd.NewReader(f)
	if err != nil {
		return err
	}
	defer zstdr.Close()

	tarr := tar.NewReader(zstdr)

	for {
		// Reader から tar のヘッダー情報を取得し、出力先のパスを割り出す
		header, err := tarr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return err
		}

		targetFile := filepath.Join(dstDir, header.Name)

		fmt.Printf("file: %s\n", targetFile)

		if err := os.MkdirAll(filepath.Dir(targetFile), 0777); err != nil {
			return err
		}

		// .tar.zst 形式で解凍しながらファイルに書き込む
		f, err := os.Create(targetFile)
		if err != nil {
			return err
		}
		defer f.Close()

		if _, err := io.Copy(f, tarr); err != nil {
			return err
		}
	}

	fmt.Println("decompression completed!")

	return nil
}

func main() {
	targetDir := "./sample"
	tarZstFile := "./sample.tar.zst"
	dstDir := "./dst"

	if err := compress(targetDir, tarZstFile); err != nil {
		log.Fatal(err)
	}

	if err := decompress(tarZstFile, dstDir); err != nil {
		log.Fatal(err)
	}
}

実行コマンド

sample ディレクトリを .tar.zst 形式に圧縮し、 dst ディレクトリ配下に解凍したファイルを配置しています。

$ find ./sample -type f
./sample/1/a.txt
./sample/3/c.txt
./sample/2/b.txt
$ go run main.go 
start compress...
file: sample/1/a.txt
file: sample/2/b.txt
file: sample/3/c.txt
compression completed!
start decompress...
file: dst/sample/1/a.txt
file: dst/sample/2/b.txt
file: dst/sample/3/c.txt
decompression completed!
$ find ./dst -type f
./dst/sample/1/a.txt
./dst/sample/3/c.txt
./dst/sample/2/b.txt

コード解説

ただコードを載せるだけなのも味気ないので、処理ごとに補足を入れます。

圧縮

	// .tar.zst 形式の Writer を作成
	f, err := os.Create(outputFile)
	if err != nil {
		return err
	}
	defer f.Close()

	zstdw, err := zstd.NewWriter(f)
	if err != nil {
		return err
	}
	defer zstdw.Close()

	tarw := tar.NewWriter(zstdw)
	defer tarw.Close()

圧縮ファイルの作成にあたり、まずは Writer を用意する必要があります。
zstd.NewWriter tar.NewWriter を用いて Writer を作成します。
この Writer によって書き込まれたファイルは .tar.zst 形式となります。

	// 指定したディレクトリ配下のファイルを再帰的に参照し、 .tar.zst 形式に圧縮した状態でファイルに書き込む
	filepath.WalkDir(targetDir, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}

		if d.IsDir() {
			return nil
		}

		fmt.Printf("file: %s\n", path)

		fi, err := d.Info()
		if err != nil {
			return err
		}

		// tar のヘッダー情報を定義する
		header, err := tar.FileInfoHeader(fi, path)
		if err != nil {
			return err
		}

		header.Name = path

		if err := tarw.WriteHeader(header); err != nil {
			return err
		}

		// .tar.zst 形式に圧縮しながらファイルに書き込む
		f, err := os.Open(path)
		if err != nil {
			return err
		}
		defer f.Close()

		if _, err := io.Copy(tarw, f); err != nil {
			return err
		}

		return nil
	})

続いて、 filepath.WalkDir を用いて指定したディレクトリ内のファイル群に対して再帰的に処理を施します。
FileInfoHeader WriteHeader を用いて、 tar のヘッダー情報を定義します。
最後に、 io.Copy を用いて各ファイルの中身を圧縮しながらファイルに書き込みます。

解凍

	// .tar.zst 形式の Reader を作成
	f, err := os.Open(targetFile)
	if err != nil {
		return err
	}
	defer f.Close()

	zstdr, err := zstd.NewReader(f)
	if err != nil {
		return err
	}
	defer zstdr.Close()

	tarr := tar.NewReader(zstdr)

解凍処理の場合、最初に Reader を用意してあげる必要があります。
zstd.NewReader tar.NewReader を用いて Reader を作成します。
この Reader によって .tar.zst 形式のファイルを解凍しながら読み込むことができます。

	for {
		// Reader から tar のヘッダー情報を取得し、出力先のパスを割り出す
		header, err := tarr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return err
		}

		targetFile := filepath.Join(dstDir, header.Name)

		fmt.Printf("file: %s\n", targetFile)

		if err := os.MkdirAll(filepath.Dir(targetFile), 0777); err != nil {
			return err
		}

		// .tar.zst 形式で解凍しながらファイルに書き込む
		f, err := os.Create(targetFile)
		if err != nil {
			return err
		}
		defer f.Close()

		if _, err := io.Copy(f, tarr); err != nil {
			return err
		}
	}

解凍処理は for 文と Reader.Next を用いて実装しています。
tar のヘッダー情報から解凍ファイルの出力先のパスを割り出し、 io.Copy を用いて解凍後のファイルを出力しています。

まとめ

いかがでしたでしょうか?
通常はシェルコマンドを組むことが多いファイルの圧縮、解凍処理ですが、Go での手段も持っておくと選択肢が増えて業務の幅が広がります。

参考文献