最近搞了一台NAS,使用了两只珍藏多年的500G组Raid1(废物利用),把积累多年的照片放了上去,发现有100G多,其中半数重复,经年累月备份的结果,于是想释放这些空间,能节省就节省。
老早前使用golang写过一个去重工具,过于久远,源码找不到了,就重新撸了一个,以后随时使用。
定义以下结构体保存读到的文件信息,并为后续文件处理提供方便。
// FileInfo 文件信息保存
type FileInfo struct {
FullPath string // 文件的全路径
Sha256 string // 文件的sha256值 用于文件排重
Size int64 // 文件的大小 单位字节
fullRead bool // 计算sha256时是否读取了文件的全部内容
}
FullPath 是文件的全路径名即绝对路径名,在确定是重复的文件时执行删除操作时用的到。
Sha256 是文件内容的sha256摘要,用于确定此文件与其他文件内容上是否相同,为了速度这里参与计算的文件内容并不是文件内容的全部,只是文件开头的一部分。这样不用读取全部,但存在问题,两个文件如果只有后部分不同则无法有效判断。我这里只有照片问题不大,但为了保险,还是做了相应处理。
Size 是文件的大小,单位字节,这也是判断文件内容是否一样的重要依据之一。
为了解决上述内容前部分相同的问题,添加了fullRead字段,用于指示在计算sha256时是否主动读取了全部文件,仅在文件大小不一样且sha256一样时才会主动读取全部信息重新计算sha256。
使用filepath.Walk遍历文件。返回所有文件信息,供后续处理。
func readAllFile() []FileInfo {
// 当前文件夹
var pwd = GetWorkDir()
// 文件信息保存
var fileInfos = make([]FileInfo, 0, 10000)
// 文件序号 在遍历的过程中输出信息
var index int
err := filepath.Walk("./", func(path string, info os.FileInfo, err error) error {
// 有错误直接退出
if err != nil {
panic(err)
}
// 是否是文件夹 是文件夹直接跳过
if info.IsDir() {
return nil
}
// 是否是正常文件,如果是文件夹、设备文件、链接文件或其他非常规文件则直接跳过。
if !info.Mode().IsRegular() {
// 可能是链接文件 也可能是文件夹
println("get no regular file and pass:" + path)
return nil
}
// 使用当前文件夹路径拼接得到文件的绝对路径
var fullPath = fmt.Sprintf("%v%c%v", pwd, os.PathSeparator, path)
fileInfos = append(fileInfos, FileInfo{
FullPath: fullPath,
Sha256: GenSha256For32KB(fullPath), // 开始只读取文件开头的32kb内容,不足32k全读取
Size: info.Size(),
fullRead: false, // 默认false
})
// 通过序号每50个文件输出一条提示
index++
if index%50 == 0 {
fmt.Printf("%v-%v\n", index, fullPath)
}
return nil
})
if err != nil {
println(err)
}
return fileInfos
}
以下为获取当前文件夹的函数,如果出现错误则直接退出。os get work dir。
func GetWorkDir() string {
wd, err := os.Getwd()
if err != nil {
panic(err)
}
return wd
}
是否是文件夹,文件夹跳过,文件夹中的内容并没有跳过。
// 是否是文件夹 是文件夹直接跳过
if info.IsDir() {
return nil
}
判断是否是常规文件 ls -alh文件属性 第一个字符为‘-’的是常规文件。d为文件夹,l为链接文件,链接文件可以链接到文件夹也可以链接到文件。
// 是否是正常文件,如果是文件夹、设备文件、链接文件或其他非常规文件则直接跳过。
if !info.Mode().IsRegular() {
// 可能是链接文件 也可能是文件夹
println("get no regular file and pass:" + path)
return nil
}
使用golang标准库中的工具,读取文件与计算摘要。以下只读取32KB大小。返回的是十六进制的字面字符串。
func GenSha256For32KB(fullPath string) string {
// 打开文件
open, err := os.Open(fullPath)
if err != nil {
panic(err)
}
// 关闭文件
defer func() {
err := open.Close()
if err != nil {
println("关闭文件错误:" + err.Error())
}
}()
// 读取Buff 长度32KB
var readLimit = [1024 * 32]byte{} // 32KB
// 读取文件
readLen, err := open.Read(readLimit[:])
if err != nil && err != io.EOF {
panic(err)
}
// 计算sha256
sum256 := sha256.Sum256(readLimit[:readLen])
// 转化为十六进制字符串形式反回
return hex.EncodeToString(sum256[:])
}
读取全文件,与上相同,只在读取时有区别,使用ioutil工具把文件直接整个读到内存中。同样如果有错误产生则直接结束运行。
func GenSha256FullRead(fullPath string) string {
open, err := os.Open(fullPath)
if err != nil {
panic(err)
}
defer func() {
err := open.Close()
if err != nil {
println("关闭文件错误:" + err.Error())
}
}()
allContent, err := ioutil.ReadAll(open)
if err != nil {
panic(err)
}
sum256 := sha256.Sum256(allContent)
return hex.EncodeToString(sum256[:])
}
如果小于1024字节,就显示字节数,其他类推。%.3f 格式化输出float类型,保留三位小数。
func ToHumanReadSize(size int64) string {
if size < 1024 {
return strconv.FormatInt(size, 10) + "Byte"
} else if size < 1024*1024 {
return fmt.Sprintf("%.3fKB", float32(size)/1024.0)
} else if size < 1024*1024*1024 {
return fmt.Sprintf("%.3fMB", float32(size)/(1024.0*1024))
} else {
return fmt.Sprintf("%.3fGB", float32(size)/(1024.0*1024*1024))
}
}
对于sha256摘要相同,而文件大小不同的文件,采取重新读取全部内容计算摘要的方式,保证对比的准确。对于照片来说,这足够了。文件内容不同而摘要相同的概率极小。这里使用函数递归,每处理一个文件后即要从头重新对比。
func CheckSha256OnFullRead(files []FileInfo) {
println("check size and sha256")
// 是否要重新检查
var reCheck = false
// 文件map记录 记录sha256相同的文件
var fileMap = make(map[string][]*FileInfo)
// 遍历文件
for index := range files {
// 检查文件map记录
fileSlice, ok := fileMap[files[index].Sha256]
if ok {
// 有相同sha256摘要的文件 检查文件大小
if files[index].Size != fileSlice[0].Size {
println("need recheck size and sha256")
reCheck = true // 重新检查
// 读取全部文件计算摘要
if !fileSlice[0].fullRead {
fileSlice[0].Sha256 = GenSha256FullRead(fileSlice[0].FullPath) // 读取全文件
fileSlice[0].fullRead = true // 设置读取了全文件
fmt.Printf("full read first %v %v\n", fileSlice[0].Size, fileSlice[0].FullPath)
}
if !files[index].fullRead {
files[index].Sha256 = GenSha256FullRead(files[index].FullPath) // 读取全文件
files[index].fullRead = true // 设置读取了全文件
fmt.Printf("full read other %v %v\n", files[index].Size, files[index].FullPath)
}
}
// 添加文件到组中
fileMap[files[index].Sha256] = append(fileSlice, &files[index])
} else {
// 添加此摘要组第一个文件到组中
fileMap[files[index].Sha256] = []*FileInfo{&files[index]}
}
}
// 重新检查
if reCheck {
println("begin recheck")
CheckSha256OnFullRead(files)
}
}
把相同sha256的文件分组,备后续处理。与上代码类似。此时组内都是内容一样的文件,为了保险,处理时判断一下文件大小,文件大小不一致则文件内容肯定不一样。
func GenToHashMapVarSha(files []FileInfo) map[string][]*FileInfo {
var fileMap = make(map[string][]*FileInfo)
for index := range files {
fileSlice, ok := fileMap[files[index].Sha256]
if ok {
// 检查文件大小
if files[index].Size != fileSlice[0].Size {
fmt.Printf("%v %v %v\n", fileSlice[0].Size, fileSlice[0].Sha256, fileSlice[0].FullPath)
fmt.Printf("%v %v %v\n", files[index].Size, files[index].Sha256, files[index].FullPath)
panic("同组内文件大小不一致,构建删除map失败")
}
fileMap[files[index].Sha256] = append(fileSlice, &files[index])
} else {
fileMap[files[index].Sha256] = []*FileInfo{&files[index]}
}
}
return fileMap
}
使用三方xls库,这个库简单好用,功能够用。
"github.com/tealeg/xlsx"
输出必要的信息,人肉查看有无疏漏。
func GenDuplicateFileToFile(files map[string][]*FileInfo) {
file := xlsx.NewFile()
sheet, err := file.AddSheet("Sheet1")
if err != nil {
fmt.Printf(err.Error())
}
row := sheet.AddRow()
row.AddCell().Value = "总序号"
row.AddCell().Value = "组内序号"
row.AddCell().Value = "大小Human"
row.AddCell().Value = "SHA256"
row.AddCell().Value = "文件路径"
row.AddCell().Value = "大小Byte"
row.AddCell().Value = "完整读"
var totalIndex = 0
for _, fileInfos := range files {
for index, fileInfo := range fileInfos {
row := sheet.AddRow()
totalIndex++
row.AddCell().Value = strconv.Itoa(totalIndex)
row.AddCell().Value = strconv.Itoa(index)
row.AddCell().Value = ToHumanReadSize(fileInfo.Size)
row.AddCell().Value = fileInfo.Sha256
row.AddCell().Value = fileInfo.FullPath
row.AddCell().Value = strconv.FormatInt(fileInfo.Size, 10)
row.AddCell().Value = fmt.Sprintf("%v", fileInfo.fullRead)
}
}
var statInfo = CalStatisticInfo(files)
row = sheet.AddRow()
row.AddCell().Value = fmt.Sprintf("总文件数:%d个\n", statInfo.TotalFileCount)
row = sheet.AddRow()
row.AddCell().Value = fmt.Sprintf("不重复文件数:%d个\n", statInfo.NoDumpFileCount)
row = sheet.AddRow()
row.AddCell().Value = fmt.Sprintf("重复文件数:%d个\n", statInfo.DumpFileCount)
row = sheet.AddRow()
row.AddCell().Value = fmt.Sprintf("总空间占用:%v\n", ToHumanReadSize(statInfo.TotalSize))
row = sheet.AddRow()
row.AddCell().Value = fmt.Sprintf("重复总空间占用:%v\n", ToHumanReadSize(statInfo.DumpSize))
row = sheet.AddRow()
row.AddCell().Value = fmt.Sprintf("不重复空间占用:%v\n", ToHumanReadSize(statInfo.NoDumpSize))
err = file.Save("dump_file.xlsx")
if err != nil {
fmt.Printf(err.Error())
}
}
dryRun=true 就只显示要删除的文件而不进行实际删除。确定正确后再执行删除。
func DeleteDuplicateFile(files map[string][]*FileInfo, dryRun bool) {
for sha256Key, fileItems := range files {
fmt.Printf("==sha256:%v=\n", sha256Key)
for index, file := range fileItems {
if file.Sha256 != sha256Key {
panic("err map format")
}
if index == 0 {
fmt.Printf(">>>>> first %v %v %v\n", index, ToHumanReadSize(file.Size), file.FullPath)
} else {
// 文件已经存在 删除
fmt.Printf(">>>>> other %v %v %v\n", index, ToHumanReadSize(file.Size), file.FullPath)
if !dryRun {
err := os.Remove(file.FullPath)
if err != nil {
fmt.Printf("删除失败:path-%v %v \n", file.FullPath, err.Error())
} else {
fmt.Printf(">>>>> >>delete-ok %v\n", index)
}
}
}
}
}
}
第二个参数是do则执行实际删除操作,否则不执行删除操作。
var args = os.Args
if len(args) == 2 && args[1] == "do" {
DeleteDuplicateFile(mappedFiles, false) // 执行删除
} else {
DeleteDuplicateFile(mappedFiles, true) // 打印数据不删除
}
golang接受键盘输入,做到按任意键继续功能
func PauseWhileAnyKey() {
println("按任意键继续...")
var anyKey string
_, err := fmt.Scanln(&anyKey)
if err != nil {
return
}
}
由于NAS是x86架构linux系统,通过ssh连接,所以交叉编译,复制可执行文件上去执行就可以了。以下是交叉编译的代码,本是放在build_linux.bat文件中的。
SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=amd64
go build
还有在docker中编译程序的build_binary.sh脚本
#!/bin/bash
docker run -it -v "$PWD":/app -v "$PWD"/.cache/gopath:/go -v "$PWD"/.cache/gocache:/root/.cache/ golang:1.17 \
/bin/sh -c "cd /app && export CGO_ENABLED=0 && export GOPROXY=https://goproxy.cn,direct && go build -v"
if [ "$?" -eq 0 ];then
echo "build ok"
else
echo "no"
exit 1
fi
使用这个工具程序删除了50多G的重复文件。心里舒畅了。威联通NAS也有去重工具,查资料说要自行从安装包安装,懒得弄了,自己动手,提升能力也解决问题,挺好。
https://download.csdn.net/download/a34ErxV/80226203https://download.csdn.net/download/a34ErxV/80226203