기능개선
This commit is contained in:
@@ -1,61 +1,263 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/copy"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/constants"
|
||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/copy"
|
||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/logger"
|
||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/path"
|
||||
)
|
||||
|
||||
// RunBackup: 백업 실행 함수
|
||||
func RunBackup(src, dst string, dryRun, verbose, force bool) error {
|
||||
if !dryRun {
|
||||
// 백업 폴더 생성
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
return fmt.Errorf("백업 폴더 생성 실패: %w", err)
|
||||
type CompareMode int
|
||||
|
||||
const (
|
||||
CompareTime CompareMode = iota
|
||||
CompareHash
|
||||
)
|
||||
|
||||
type Progress struct {
|
||||
CurrentFile string
|
||||
ProcessedFiles int
|
||||
TotalFiles int
|
||||
}
|
||||
|
||||
type ProgressCallback func(Progress)
|
||||
|
||||
type BackupOptions struct {
|
||||
DryRun bool
|
||||
Verbose bool
|
||||
Force bool
|
||||
Incremental bool
|
||||
CompareMode CompareMode
|
||||
Logger *logger.Logger
|
||||
Progress ProgressCallback
|
||||
dirCache *path.DirCache
|
||||
}
|
||||
|
||||
type BackupMeta struct {
|
||||
LastBackup time.Time `json:"lastBackup"`
|
||||
Files map[string]string `json:"files"` // 파일 경로 -> 해시 또는 수정 시간
|
||||
}
|
||||
|
||||
func loadBackupMeta(dst string) (*BackupMeta, error) {
|
||||
metaPath := filepath.Join(dst, constants.MetaFileName)
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &BackupMeta{
|
||||
Files: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("메타 파일을 읽을 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
var meta BackupMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, fmt.Errorf("메타 파일을 파싱할 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
if meta.Files == nil {
|
||||
meta.Files = make(map[string]string)
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func saveBackupMeta(dst string, meta *BackupMeta) error {
|
||||
metaPath := filepath.Join(dst, constants.MetaFileName)
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("메타 데이터를 직렬화할 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(metaPath, data, constants.DefaultFileMode); err != nil {
|
||||
return fmt.Errorf("메타 파일을 저장할 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func calculateFileHash(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func(f *os.File) {
|
||||
err := f.Close()
|
||||
if err != nil {
|
||||
|
||||
}
|
||||
}(f)
|
||||
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func RunBackup(ctx context.Context, src, dst string, opts BackupOptions) error {
|
||||
if opts.dirCache == nil {
|
||||
opts.dirCache = path.NewDirCache()
|
||||
}
|
||||
|
||||
// 대상 디렉토리 생성
|
||||
if !opts.DryRun {
|
||||
// 대상 디렉토리 생성
|
||||
if err := opts.dirCache.EnsureDir(dst); err != nil {
|
||||
return fmt.Errorf("대상 디렉토리를 생성할 수 없습니다: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
// 증분 백업을 위한 메타데이터 로드
|
||||
var meta *BackupMeta
|
||||
var err error
|
||||
if opts.Incremental {
|
||||
meta, err = loadBackupMeta(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 목록 수집
|
||||
var files []string
|
||||
err = filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(dst, relPath)
|
||||
|
||||
// 디렉토리는 건너뜀
|
||||
if info.IsDir() {
|
||||
if verbose {
|
||||
fmt.Println("디렉터리 생성:", targetPath)
|
||||
}
|
||||
if !dryRun {
|
||||
return os.MkdirAll(targetPath, info.Mode())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("복사 예정 파일:", targetPath)
|
||||
// 메타파일은 건너뜀
|
||||
if filepath.Base(path) == constants.MetaFileName {
|
||||
return nil
|
||||
}
|
||||
|
||||
// force 옵션이 없으면 수정 시간 비교
|
||||
if !force {
|
||||
dstInfo, err := os.Stat(targetPath)
|
||||
if err == nil && !info.ModTime().After(dstInfo.ModTime()) {
|
||||
if verbose {
|
||||
fmt.Println("복사 스킵 (업데이트 필요 없음):", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Println("파일 복사:", path, "→", targetPath)
|
||||
}
|
||||
return copy.RunCopy(path, targetPath)
|
||||
files = append(files, path)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("파일 목록을 수집할 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
// 진행 상황 초기화
|
||||
progress := Progress{
|
||||
TotalFiles: len(files),
|
||||
}
|
||||
|
||||
// 파일 복사 함수
|
||||
copyFile := func(srcPath string, dstPath string) error {
|
||||
if opts.DryRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 대상 디렉토리 생성
|
||||
dstDir := filepath.Dir(dstPath)
|
||||
if err := opts.dirCache.EnsureDir(dstDir); err != nil {
|
||||
return fmt.Errorf("대상 디렉토리를 생성할 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
return copy.RunCopy(srcPath, dstPath)
|
||||
}
|
||||
|
||||
// 각 파일 처리
|
||||
for i, srcPath := range files {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return context.Canceled
|
||||
default:
|
||||
}
|
||||
|
||||
// 진행 상황 업데이트
|
||||
progress.CurrentFile = srcPath
|
||||
progress.ProcessedFiles = i + 1
|
||||
if opts.Progress != nil {
|
||||
opts.Progress(progress)
|
||||
}
|
||||
|
||||
// 상대 경로 계산
|
||||
relPath, err := filepath.Rel(src, srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("상대 경로를 계산할 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
// 파일이 이미 존재하는지 확인
|
||||
dstInfo, err := os.Stat(dstPath)
|
||||
fileExists := err == nil
|
||||
|
||||
if fileExists && !opts.Force {
|
||||
if opts.Incremental {
|
||||
// 증분 백업 모드에서는 메타데이터를 확인
|
||||
srcInfo, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("원본 파일 정보를 읽을 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
oldValue, exists := meta.Files[relPath]
|
||||
var newValue string
|
||||
|
||||
switch opts.CompareMode {
|
||||
case CompareTime:
|
||||
newValue = srcInfo.ModTime().String()
|
||||
if exists && oldValue == newValue {
|
||||
opts.Logger.Printf("건너뜀 (시간 동일): %s\n", relPath)
|
||||
continue
|
||||
}
|
||||
case CompareHash:
|
||||
newValue, err = calculateFileHash(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("파일 해시를 계산할 수 없습니다: %w", err)
|
||||
}
|
||||
if exists && oldValue == newValue {
|
||||
opts.Logger.Printf("건너뜀 (해시 동일): %s\n", relPath)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
meta.Files[relPath] = newValue
|
||||
} else {
|
||||
// 일반 모드에서는 크기와 수정 시간만 비교
|
||||
srcInfo, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("원본 파일 정보를 읽을 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
if srcInfo.Size() == dstInfo.Size() && srcInfo.ModTime().Equal(dstInfo.ModTime()) {
|
||||
opts.Logger.Printf("건너뜀 (동일): %s\n", relPath)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 복사
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
return fmt.Errorf("파일을 복사할 수 없습니다: %w", err)
|
||||
}
|
||||
|
||||
opts.Logger.Printf("복사됨: %s\n", relPath)
|
||||
}
|
||||
|
||||
// 증분 백업 메타데이터 저장
|
||||
if opts.Incremental && !opts.DryRun {
|
||||
meta.LastBackup = time.Now()
|
||||
if err := saveBackupMeta(dst, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
28
internal/constants/constants.go
Normal file
28
internal/constants/constants.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package constants
|
||||
|
||||
// 그룹화 옵션
|
||||
const (
|
||||
GroupByYear = "year"
|
||||
GroupByMonth = "mon"
|
||||
GroupByDay = "day"
|
||||
GroupByHour = "hour"
|
||||
GroupByMin = "min"
|
||||
GroupBySec = "sec"
|
||||
)
|
||||
|
||||
// 비교 모드 옵션
|
||||
const (
|
||||
CompareTime = "time"
|
||||
CompareHash = "hash"
|
||||
)
|
||||
|
||||
// 버퍼 크기
|
||||
const (
|
||||
CopyBufferSize = 1024 * 1024 // 1MB
|
||||
)
|
||||
const (
|
||||
DefaultGroupBy = "day"
|
||||
DefaultCompare = "time"
|
||||
MetaFileName = ".backup_meta.json"
|
||||
DefaultFileMode = 0755
|
||||
)
|
||||
@@ -1,48 +1,70 @@
|
||||
package copy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/constants"
|
||||
)
|
||||
|
||||
// CopyFile 복사 함수: srcFile → dstFile
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return make([]byte, constants.CopyBufferSize)
|
||||
},
|
||||
}
|
||||
|
||||
func RunCopy(srcFile, dstFile string) error {
|
||||
// 소스 파일 정보 확인
|
||||
srcInfo, err := os.Stat(srcFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("source file does not exist: %s", srcFile)
|
||||
}
|
||||
return fmt.Errorf("failed to get source file info: %w", err)
|
||||
}
|
||||
|
||||
// 소스 파일 열기
|
||||
src, err := os.Open(srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to open source file: %w", err)
|
||||
}
|
||||
|
||||
defer func(src *os.File) {
|
||||
err := src.Close()
|
||||
if err != nil {
|
||||
|
||||
defer func() {
|
||||
if cerr := src.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("failed to close source file: %w", cerr)
|
||||
}
|
||||
}(src)
|
||||
}()
|
||||
|
||||
// 대상 파일 생성
|
||||
dst, err := os.Create(dstFile)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to create destination file: %w", err)
|
||||
}
|
||||
|
||||
defer func(dst *os.File) {
|
||||
err := dst.Close()
|
||||
if err != nil {
|
||||
|
||||
defer func() {
|
||||
if cerr := dst.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("failed to close destination file: %w", cerr)
|
||||
}
|
||||
}(dst)
|
||||
}()
|
||||
|
||||
_, err = io.Copy(dst, src)
|
||||
if err != nil {
|
||||
return err
|
||||
// 버퍼 풀에서 가져오기
|
||||
buf := bufferPool.Get().([]byte)
|
||||
defer bufferPool.Put(buf)
|
||||
|
||||
// 파일 내용 복사
|
||||
if _, err = io.CopyBuffer(dst, src, buf); err != nil {
|
||||
return fmt.Errorf("failed to copy file contents: %w", err)
|
||||
}
|
||||
|
||||
// 원본 파일의 권한을 복사
|
||||
info, err := os.Stat(srcFile)
|
||||
if err == nil {
|
||||
err := os.Chmod(dstFile, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 파일 권한과 시간 정보 복사
|
||||
if err := os.Chmod(dstFile, srcInfo.Mode()); err != nil {
|
||||
return fmt.Errorf("failed to copy file permissions: %w", err)
|
||||
}
|
||||
|
||||
// 수정 시간과 접근 시간 복사
|
||||
if err := os.Chtimes(dstFile, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
|
||||
return fmt.Errorf("failed to copy file timestamps: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
32
internal/logger/logger.go
Normal file
32
internal/logger/logger.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
verbose bool
|
||||
writer *bufio.Writer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(verbose bool) *Logger {
|
||||
return &Logger{
|
||||
verbose: verbose,
|
||||
writer: bufio.NewWriter(os.Stdout),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Printf(format string, args ...interface{}) {
|
||||
if !l.verbose {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
_, _ = fmt.Fprintf(l.writer, format, args...)
|
||||
_ = l.writer.Flush()
|
||||
l.mu.Unlock()
|
||||
}
|
||||
@@ -2,23 +2,36 @@ package path
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.lhk.o-r.kr/freerer2/simple_backup/internal/constants"
|
||||
)
|
||||
|
||||
// GetGroupByFolder: group-by 옵션에 따라 백업 경로 생성
|
||||
// GetGroupByFolder generates a backup path based on the groupBy option and time.
|
||||
// If groupBy is invalid or empty, it returns the basePath unchanged.
|
||||
//
|
||||
// Parameters:
|
||||
// - basePath: the base directory path for backup
|
||||
// - groupBy: grouping option (year, mon, day, hour, min, sec)
|
||||
// - t: time to use for path generation
|
||||
//
|
||||
// Returns:
|
||||
// - string: generated path based on groupBy option
|
||||
func GetGroupByFolder(basePath, groupBy string, t time.Time) string {
|
||||
switch groupBy {
|
||||
case "year":
|
||||
case constants.GroupByYear:
|
||||
return fmt.Sprintf("%s/%d", basePath, t.Year())
|
||||
case "mon":
|
||||
case constants.GroupByMonth:
|
||||
return fmt.Sprintf("%s/%d%02d", basePath, t.Year(), t.Month())
|
||||
case "day":
|
||||
case constants.GroupByDay:
|
||||
return fmt.Sprintf("%s/%d%02d%02d", basePath, t.Year(), t.Month(), t.Day())
|
||||
case "hour":
|
||||
case constants.GroupByHour:
|
||||
return fmt.Sprintf("%s/%d%02d%02d_%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour())
|
||||
case "min":
|
||||
case constants.GroupByMin:
|
||||
return fmt.Sprintf("%s/%d%02d%02d_%02d%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute())
|
||||
case "sec":
|
||||
case constants.GroupBySec:
|
||||
return fmt.Sprintf("%s/%d%02d%02d_%02d%02d%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second())
|
||||
case "":
|
||||
return basePath
|
||||
@@ -27,3 +40,39 @@ func GetGroupByFolder(basePath, groupBy string, t time.Time) string {
|
||||
return basePath
|
||||
}
|
||||
}
|
||||
|
||||
// 디렉토리 캐시
|
||||
type DirCache struct {
|
||||
sync.RWMutex
|
||||
exists map[string]bool
|
||||
}
|
||||
|
||||
func NewDirCache() *DirCache {
|
||||
return &DirCache{
|
||||
exists: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DirCache) EnsureDir(path string) error {
|
||||
c.RLock()
|
||||
if c.exists[path] {
|
||||
c.RUnlock()
|
||||
return nil
|
||||
}
|
||||
c.RUnlock()
|
||||
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
// 다시 확인 (다른 고루틴이 생성했을 수 있음)
|
||||
if c.exists[path] {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path, constants.DefaultFileMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.exists[path] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user