♻️ Refactor synchronization service
This commit is contained in:
182
internal/syncer/backend/snapshotstore/blob/localfs/store.go
Normal file
182
internal/syncer/backend/snapshotstore/blob/localfs/store.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package localfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"voidraft/internal/syncer/backend/snapshotstore/blob"
|
||||
)
|
||||
|
||||
// Store 提供基于本地目录的 blob 存储实现。
|
||||
type Store struct {
|
||||
rootPath string
|
||||
}
|
||||
|
||||
// New 创建新的 localfs blob 存储。
|
||||
func New(rootPath string) (*Store, error) {
|
||||
if strings.TrimSpace(rootPath) == "" {
|
||||
return nil, errors.New("localfs root path is required")
|
||||
}
|
||||
if err := os.MkdirAll(rootPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create localfs root path: %w", err)
|
||||
}
|
||||
return &Store{rootPath: rootPath}, nil
|
||||
}
|
||||
|
||||
// Get 读取对象内容。
|
||||
func (s *Store) Get(ctx context.Context, key string) (io.ReadCloser, blob.ObjectInfo, error) {
|
||||
_ = ctx
|
||||
|
||||
info, err := s.Stat(ctx, key)
|
||||
if err != nil {
|
||||
return nil, blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
path, err := s.resolvePath(key)
|
||||
if err != nil {
|
||||
return nil, blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
reader, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, blob.ObjectInfo{}, blob.ErrObjectNotFound
|
||||
}
|
||||
return nil, blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
return reader, info, nil
|
||||
}
|
||||
|
||||
// Put 写入对象内容。
|
||||
func (s *Store) Put(ctx context.Context, key string, body io.Reader, options blob.PutOptions) (blob.ObjectInfo, error) {
|
||||
_ = ctx
|
||||
|
||||
path, err := s.resolvePath(key)
|
||||
if err != nil {
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
if options.IfMatch != "" {
|
||||
currentInfo, err := s.Stat(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, blob.ErrObjectNotFound) {
|
||||
return blob.ObjectInfo{}, blob.ErrConditionNotMet
|
||||
}
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
if currentInfo.Revision != options.IfMatch {
|
||||
return blob.ObjectInfo{}, blob.ErrConditionNotMet
|
||||
}
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(filepath.Dir(path), "blob-put-*")
|
||||
if err != nil {
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
tempName := tempFile.Name()
|
||||
|
||||
if _, err := tempFile.Write(data); err != nil {
|
||||
tempFile.Close()
|
||||
_ = os.Remove(tempName)
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
_ = os.Remove(tempName)
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
if err := os.Rename(tempName, path); err != nil {
|
||||
_ = os.Remove(tempName)
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
return blob.ObjectInfo{
|
||||
Key: key,
|
||||
Revision: digest(data),
|
||||
Size: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stat 返回对象元信息。
|
||||
func (s *Store) Stat(ctx context.Context, key string) (blob.ObjectInfo, error) {
|
||||
_ = ctx
|
||||
|
||||
path, err := s.resolvePath(key)
|
||||
if err != nil {
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return blob.ObjectInfo{}, blob.ErrObjectNotFound
|
||||
}
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
size, err := io.Copy(hash, file)
|
||||
if err != nil {
|
||||
return blob.ObjectInfo{}, err
|
||||
}
|
||||
|
||||
return blob.ObjectInfo{
|
||||
Key: key,
|
||||
Revision: hex.EncodeToString(hash.Sum(nil)),
|
||||
Size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Delete 删除指定对象。
|
||||
func (s *Store) Delete(ctx context.Context, key string) error {
|
||||
_ = ctx
|
||||
|
||||
path, err := s.resolvePath(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePath 将对象键转换为安全路径。
|
||||
func (s *Store) resolvePath(key string) (string, error) {
|
||||
normalized := filepath.Clean(filepath.FromSlash(key))
|
||||
if normalized == "." || normalized == string(filepath.Separator) {
|
||||
return "", errors.New("invalid blob key")
|
||||
}
|
||||
|
||||
path := filepath.Join(s.rootPath, normalized)
|
||||
rel, err := filepath.Rel(s.rootPath, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return "", errors.New("blob key escapes root path")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// digest 计算内容摘要。
|
||||
func digest(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package localfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"voidraft/internal/syncer/backend/snapshotstore/blob"
|
||||
)
|
||||
|
||||
// TestStorePutGetStat 验证 localfs blob 存储的基本读写流程。
|
||||
func TestStorePutGetStat(t *testing.T) {
|
||||
store, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
|
||||
info, err := store.Put(context.Background(), "nested/file.txt", bytes.NewReader([]byte("hello")), blob.PutOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("put object: %v", err)
|
||||
}
|
||||
if info.Revision == "" {
|
||||
t.Fatalf("expected revision to be generated")
|
||||
}
|
||||
|
||||
stat, err := store.Stat(context.Background(), "nested/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("stat object: %v", err)
|
||||
}
|
||||
if stat.Revision != info.Revision {
|
||||
t.Fatalf("expected stat revision %s, got %s", info.Revision, stat.Revision)
|
||||
}
|
||||
|
||||
reader, _, err := store.Get(context.Background(), "nested/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("get object: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read object: %v", err)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Fatalf("expected object content hello, got %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorePutIfMatch 验证 localfs blob 存储的条件写入。
|
||||
func TestStorePutIfMatch(t *testing.T) {
|
||||
store, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
|
||||
info, err := store.Put(context.Background(), "file.txt", bytes.NewReader([]byte("v1")), blob.PutOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("put initial object: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.Put(context.Background(), "file.txt", bytes.NewReader([]byte("v2")), blob.PutOptions{IfMatch: "stale"}); !errors.Is(err, blob.ErrConditionNotMet) {
|
||||
t.Fatalf("expected ErrConditionNotMet, got %v", err)
|
||||
}
|
||||
|
||||
nextInfo, err := store.Put(context.Background(), "file.txt", bytes.NewReader([]byte("v2")), blob.PutOptions{IfMatch: info.Revision})
|
||||
if err != nil {
|
||||
t.Fatalf("put with correct if-match: %v", err)
|
||||
}
|
||||
if nextInfo.Revision == info.Revision {
|
||||
t.Fatalf("expected revision to change after overwrite")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user