Go 库

驱动接口与 rclone 实现

Driver 接口的契约、rclone.Open 的参数、密码类参数编码、OAuth 令牌写回、crypt 加密层,以及如何自带驱动。

Driver 接口

store/driver 只定义一件事:把字节存到某个后端、取回、查询、删除、列举。

type Driver interface {
	Put(ctx context.Context, key string, r io.Reader, size int64) error
	Open(ctx context.Context, key string) (io.ReadCloser, error)
	Stat(ctx context.Context, key string) (*ObjectInfo, error)
	Delete(ctx context.Context, key string) error
	List(ctx context.Context, dir string) ([]ObjectInfo, error)
}
方法语义对象不存在时
Put把 r 中的 size 个字节流式写入 key。size 必须事先已知;r 由调用方负责关闭—
Open返回对象的读取流,调用方负责 Close返回包裹 ErrNotFound 的错误
Stat返回对象信息返回包裹 ErrNotFound 的错误
Delete删除对象返回包裹 ErrNotFound 的错误,由调用方决定是否当作成功
List递归列举 dir 下的全部对象,dir 为空表示整个存储;返回的键是相对存储根的完整路径目录不存在时返回空结果,不报错

对全部实现都成立的约定:

  • 并发安全:同一个驱动实例可以被多个 goroutine 同时使用。
  • 尊重 ctx:每个方法都受 ctx 的取消与超时约束。
  • 用 errors.Is 判断缺失:实现会给 ErrNotFound 包上对象键等上下文,不要用 == 比较。
  • 键用 / 分隔,形如 photos/2026/a.png,相对于驱动的根路径。

ObjectInfo 是 Stat 与 List 的返回结构:

属性

类型

rclone 实现

store/rclone 用 rclone 的后端实现 Driver。它直接在进程内构造 rclone 的 fs.Fs:参数以映射传给后端, 不读写 rclone 配置文件,不拼连接字符串,也不启动 rclone 子进程。

打开驱动

d, err := rclone.Open(ctx, rclone.Options{
	Backend: "s3",
	Params: map[string]string{
		"provider":          "Cloudflare",
		"access_key_id":     "<access key>",
		"secret_access_key": "<secret key>",
		"endpoint":          "https://<account id>.r2.cloudflarestorage.com",
	},
	Path: "my-bucket/images",
})
if err != nil {
	return err
}
defer func() { _ = d.Close(ctx) }()

rclone.Options 的字段:

属性

类型

Open 返回的错误不携带参数映射,参数里的凭证不会经错误消息泄露到日志。

写入时的 Content-Type

Put 流式写入,驱动本身不缓存整个对象。它先读出数据开头的最多 512 字节,用 net/http.DetectContentType(WHATWG 内容嗅探算法) 判定 Content-Type,随对象一并交给后端,再接着写完其余字节。对象键不必带扩展名:S3 等对象存储里的对象带上真实类型(如 image/png), 而不是 application/octet-stream,浏览器直接打开存储的公开链接时能正常显示,不会变成下载。

  • 加密驱动不把类型交给存储:rclone 的 crypt 层有意不暴露明文的类型,存储里的对象是密文。
  • 不记录 Content-Type 的后端(如 local)忽略这一信息。

s3 默认跳过桶检查

Backend 为 s3 且 Params 里没有 no_check_bucket 时,驱动自动补上 no_check_bucket=true;显式给出的值不会被覆盖。

原因:R2、MinIO 等 S3 兼容存储常用只授权到桶内的凭证,没有建桶权限。rclone 默认在上传前尝试建桶, 桶已存在而凭证无权建桶时返回 403,写入失败;而只读的健康探测(见 多存储容灾)照常通过, 表现为「探测健康却写不进」。跳过桶检查的代价是桶必须事先存在。

EnsureBucket

需要由程序建桶时,用 (*rclone.Driver).EnsureBucket:它对根路径执行一次建目录,对 s3 即创建 Path 所在的桶,桶已存在不报错。

b, err := rclone.Open(ctx, rclone.Options{
	Backend: "s3",
	Params: map[string]string{
		"provider":          "Cloudflare",
		"access_key_id":     "<access key>",
		"secret_access_key": "<secret key>",
		"endpoint":          "https://<account id>.r2.cloudflarestorage.com",
		"no_check_bucket":   "false", // 必须显式关闭,否则建桶被跳过,EnsureBucket 什么也不做
	},
	Path: "my-bucket",
})
if err != nil {
	return err
}
defer func() { _ = b.Close(ctx) }()
if err := b.EnsureBucket(ctx); err != nil {
	return err // 多为凭证没有建桶权限
}

EnsureBucket 不在 Driver 接口里,因为它只对对象存储有意义。应用的做法是:建桶时临时打开一个 no_check_bucket=false 的驱动调用它,日常读写仍用默认配置的驱动。

密码类参数的编码

rclone 要求一部分参数以 rclone obscure 的编码形式提供,例如 webdav、sftp、ftp 的 pass。哪些参数属于这一类, 由后端自己的选项元数据标明。驱动把 Params 原样交给后端,所以这些值必须事先编码:漏编码的明文会被后端当作编码值解出乱码, 表现为凭证正确却认证失败。不是所有密钥都属于这一类,例如 s3 的 secret_access_key 就以原文传入。

包里提供两个函数完成编码。两者都按后端元数据挑出密码类参数、原地修改传入的映射、跳过空值,后端名不认识时返回错误:

ObscureSecretsObscurePlainSecrets
编码哪些值全部密码类参数密码类参数中尚未编码的值
适用场景值确定是明文,例如来自表单输入来源不确定,可能是明文,也可能是从 rclone.conf 复制来的编码值
误判后果已编码的值被再编码一次,认证失败恰好形似编码产物的明文被原样放过,认证失败

ObscurePlainSecrets 判断「已编码」的规则与 rclone 相同:能被 rclone 解码的值即视为已编码,与不带 --obscure / --no-obscure 执行 rclone config create 时的行为一致。遇到它误判的明文,先用 rclone obscure 编码再传入即可。 应用在后台模板表单里用 ObscureSecrets(值确定是明文),在自定义参数里用 ObscurePlainSecrets(两种形式都接受)。

params := map[string]string{
	"url":    "https://dav.example.com/remote.php/dav/files/me",
	"vendor": "nextcloud",
	"user":   "me",
	"pass":   "明文密码",
}
if err := rclone.ObscureSecrets("webdav", params); err != nil {
	return err
}
d, err := rclone.Open(ctx, rclone.Options{Backend: "webdav", Params: params, Path: "images"})

另一个辅助函数 SensitiveParams(backend) 返回后端声明为密码或敏感信息的参数名(选项元数据里的 IsPassword 或 Sensitive), 按名排序、无重复,口径与 rclone config redacted 相同:除密码、密钥与令牌外,访问密钥 ID、账号名这类身份信息也在其中。 回显配置时掩码哪些值、从错误信息里抹除哪些值,可以据此决定;应用的编辑回读与错误抹除用的就是它。

OAuth 令牌写回与 OnChange

Google Drive、OneDrive、Dropbox 等网盘后端用 OAuth 授权。库不包含授权流程:在本机执行 rclone authorize "drive" (后端名按实际替换)取得 JSON 令牌,放进 Params["token"]。授权用的是自建应用时,把 client_id 与 client_secret 一并放进 Params:令牌续期必须用签发它的同一个应用。

Open 只构造后端,不执行 rclone config 在授权之后的配置步骤。有的后端还要这些步骤写下的参数,例如 OneDrive 需要 drive_id 与 drive_type:先在本机用 rclone config 配好,再从 rclone.conf 把它们抄进 Params。

令牌临近过期时,rclone 自己刷新,并把新值写回参数。驱动收到写回后先更新内存中的参数,再调用 OnChange(key, value)。

  • OnChange 为 nil 时,新值只活在内存里,进程重启后又从旧令牌开始。对刷新时会轮换刷新令牌的服务商,旧令牌此时已经作废, 重启后授权随之失效。用网盘后端时应当提供 OnChange 并把值持久化。
  • 回调可能在任意 goroutine 中调用,也可能在 Open 返回之前就被调用(构造期写回);它同步执行在 rclone 的调用路径上,应尽快返回,并自行保证并发安全。
  • 回调没有返回值,持久化失败只能由你自己记录。
  • 不要再另起一个刷新令牌的循环。两个刷新者会让对方的刷新令牌失效。定期调用一次只读操作(例如 failover.Probe)就足以让 rclone 按时刷新。
d, err := rclone.Open(ctx, rclone.Options{
	Backend: "drive",
	Params:  map[string]string{"token": tokenJSON},
	Path:    "imgyoung",
	OnChange: func(key, value string) {
		if err := saveParam(key, value); err != nil { // saveParam:你的持久化函数
			log.Printf("持久化参数 %s 失败: %v", key, err)
		}
	},
})

crypt 加密层

Crypt 不为 nil 时,驱动在后端之上叠加 rclone 的 crypt 层:对象内容流式加密,文件名按 rclone 的 standard 方式加密。 经驱动的读写自动加解密,List 返回的是解密后的键;存储服务商只能看到密文与加密后的文件名。

属性

类型

实现上,Open 先构造内层后端,以随机名(形如 imgyoung-0123abcd)登记进 rclone 的进程级 fs 缓存,再构造以该名为 remote 的 crypt 层。

丢失密码等于丢失数据

Password 与 Salt 是解密的唯一依据,丢失后存储里的对象无法还原。

crypt 是 rclone 的公开格式,不依赖本库也能用标准 rclone 解密:用下一节的 ConfigFile 生成配置文件即可。 应用的渠道存储加密也基于这一层,密码由主密钥派生,见 渠道存储加密。

导出为 rclone 配置文件

func ConfigFile(opts Options, remote, cryptRemote string) (string, error)

ConfigFile 生成一份 rclone 配置文件(rclone.conf)的文本,与 Open(opts) 构造的驱动等价,标准 rclone 凭它就能读写同一份数据:

  • 后端段名为 remote,参数就是驱动实际交给后端的那一份,包括本包补上的默认值(如 s3 的 no_check_bucket = true); 空值省略,因为驱动与 rclone 都把空值当作未设置。密码类参数按 Options 的约定本就是 rclone 编码形式,原样写出。
  • opts.Crypt 不为 nil 时,另写一段名为 cryptRemote 的 crypt 远端:remote 指向 <remote>:<opts.Path>, password 与 password2 是编码后的 Password 与 Salt,filename_encryption = standard,与驱动的 crypt 层一致。 经它读写的是明文。
  • 不加密时,后端段本身不含路径,使用时写成 <remote>:<opts.Path>。OnChange 不参与。
  • 等价性由实现保证:ConfigFile 与 Open 共用同一套默认值补齐与 crypt 参数生成,不会各算各的。
text, err := rclone.ConfigFile(rclone.Options{
	Backend: "s3",
	Params: map[string]string{
		"provider":          "Cloudflare",
		"access_key_id":     "<access key>",
		"secret_access_key": "<secret key>",
		"endpoint":          "https://<account id>.r2.cloudflarestorage.com",
	},
	Path:  "my-bucket/images",
	Crypt: &rclone.CryptOptions{Password: "<password>", Salt: "<salt>"},
}, "mystore", "mystore-crypt")

得到的文本形如(编码带随机 IV,每次生成的 password 与 password2 都不同,都能解出同一个值):

rclone.conf
[mystore]
type = s3
access_key_id = <access key>
endpoint = https://<account id>.r2.cloudflarestorage.com
no_check_bucket = true
provider = Cloudflare
secret_access_key = <secret key>

[mystore-crypt]
type = crypt
filename_encryption = standard
password = <编码后的 Password>
password2 = <编码后的 Salt>
remote = mystore:my-bucket/images
终端
rclone --config rclone.conf ls mystore-crypt:

文本用 rclone 自己读写配置文件的同一个 INI 库序列化,写完再按 rclone 的读法回读核对;有参数无法原样写进文件时, 返回包裹 ErrUnrepresentable 的错误,不会产出与驱动不等价的文件。错误文本只指出段名与参数名,不带参数值:

情形错误文本
某个参数值含换行(\n 或 \r)参数值无法写进 rclone 配置文件: 远端 "<段名>" 的参数 "<参数名>" 含换行
生成的文本按 rclone 的读法无法解析参数值无法写进 rclone 配置文件: 生成的文本按 rclone 的读法无法解析
某个参数读回的值与原值不同(如首尾空白、%(name)s 形式的引用)参数值无法写进 rclone 配置文件: 远端 "<段名>" 的参数 "<参数名>"

其他错误:后端名不认识、段名不合 rclone 的命名规则(只允许字母、数字、_、-、.、+、@ 与空格)、密码编码失败。

文件里是可用的凭证

配置文件含后端凭证与授权令牌;rclone 的编码可以还原,不是加密。加密渠道的文件还含解密密码。按主密钥的标准离线保存。

Close

驱动用完必须调用 Close(ctx):后端提供了关闭钩子(rclone 的 Shutdowner)时,它关闭后端的后台任务与连接池; 加密驱动还会把内层后端从 rclone 的 fs 缓存中移除,移除时由缓存负责关闭内层。Close 之后不能再使用该驱动。

Close 是 *rclone.Driver 的方法,不在 Driver 接口里。以 driver.Driver 接口传递驱动时,要另外保留具体类型的指针用于关闭。

完整示例

下面是仓库里的 store/rclone/example_test.go,随 go test ./store/rclone 执行,输出与各自的 Output 注释一致: ExampleSensitiveParams 查询 webdav 后端的敏感参数;Example 用 rclone 的 memory 后端走一遍写入、查询、读回、删除。

store/rclone/example_test.go
package rclone_test

import (
	"context"
	"errors"
	"fmt"
	"io"
	"strings"

	"github.com/x-haose/imgyoung/store/driver"
	"github.com/x-haose/imgyoung/store/rclone"
)

// ExampleSensitiveParams 查询 webdav 后端的敏感参数:密码(IsPassword)与账号名、令牌(Sensitive)都在其中。
func ExampleSensitiveParams() {
	names, err := rclone.SensitiveParams("webdav")
	if err != nil {
		fmt.Println("sensitive params:", err)
		return
	}
	fmt.Println(strings.Join(names, " "))
	// Output:
	// bearer_token pass user
}

// Example 用 rclone 的内存后端走一遍驱动的基本操作:写入、查询、读回、删除。
func Example() {
	ctx := context.Background()
	d, err := rclone.Open(ctx, rclone.Options{Backend: "memory", Path: "bucket"})
	if err != nil {
		fmt.Println("open:", err)
		return
	}
	defer func() { _ = d.Close(ctx) }()

	if err := d.Put(ctx, "hello.txt", strings.NewReader("hello"), 5); err != nil {
		fmt.Println("put:", err)
		return
	}
	info, err := d.Stat(ctx, "hello.txt")
	if err != nil {
		fmt.Println("stat:", err)
		return
	}
	fmt.Println(info.Key, info.Size)

	rc, err := d.Open(ctx, "hello.txt")
	if err != nil {
		fmt.Println("open object:", err)
		return
	}
	data, err := io.ReadAll(rc)
	_ = rc.Close()
	if err != nil {
		fmt.Println("read:", err)
		return
	}
	fmt.Println(string(data))

	if err := d.Delete(ctx, "hello.txt"); err != nil {
		fmt.Println("delete:", err)
		return
	}
	_, err = d.Stat(ctx, "hello.txt")
	fmt.Println(errors.Is(err, driver.ErrNotFound))
	// Output:
	// hello.txt 5
	// hello
	// true
}

memory 与 local 后端很适合写集成测试:前者数据只在进程内存里,后者配合 t.TempDir() 使用,都不需要任何云账号。

自带驱动

不用 rclone 时,自己实现 driver.Driver 即可与 store/failover 配合。要点:

  1. 对象缺失返回 ErrNotFound:Open、Stat、Delete 找不到对象时,返回能被 errors.Is(err, driver.ErrNotFound) 命中的错误。 failover.Open 靠它区分「对象不存在」与「存储故障」,failover.Probe 把 Stat 返回的 ErrNotFound 视为存储可达。
  2. Put 读满 size 个字节,读不够时返回错误;不要关闭 r。失败后调用方可能以同一个键重试,再次 Put 应整体覆盖之前留下的内容。
  3. List 递归列举:dir 为空表示全部对象;返回相对根的完整键;目录不存在时返回空结果与 nil。
  4. ModTime 填真实的修改时间:调用方会据此判断对象新旧,例如应用的对账只回收修改时间早于宽限期的未知对象。
  5. 并发安全、尊重 ctx。用编译期断言确认实现完整:var _ driver.Driver = (*MemDriver)(nil)。

下面是一个把对象存在进程内存里的实现,可直接用于测试:

package memstore

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"strings"
	"sync"
	"time"

	"github.com/x-haose/imgyoung/store/driver"
)

// MemDriver 把对象存在进程内存里的 Driver 实现。
type MemDriver struct {
	mu      sync.RWMutex
	objects map[string]memObject
}

type memObject struct {
	data    []byte
	modTime time.Time
}

var _ driver.Driver = (*MemDriver)(nil)

func New() *MemDriver {
	return &MemDriver{objects: map[string]memObject{}}
}

func (m *MemDriver) Put(ctx context.Context, key string, r io.Reader, size int64) error {
	data, err := io.ReadAll(io.LimitReader(r, size))
	if err != nil {
		return fmt.Errorf("读取 %q: %w", key, err)
	}
	if int64(len(data)) != size {
		return fmt.Errorf("写入 %q: 期望 %d 字节,实际 %d: %w", key, size, len(data), io.ErrUnexpectedEOF)
	}
	if err := ctx.Err(); err != nil {
		return err
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	m.objects[key] = memObject{data: data, modTime: time.Now()}
	return nil
}

func (m *MemDriver) Open(ctx context.Context, key string) (io.ReadCloser, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	o, ok := m.objects[key]
	if !ok {
		return nil, fmt.Errorf("对象 %q: %w", key, driver.ErrNotFound)
	}
	return io.NopCloser(bytes.NewReader(o.data)), nil
}

func (m *MemDriver) Stat(ctx context.Context, key string) (*driver.ObjectInfo, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	o, ok := m.objects[key]
	if !ok {
		return nil, fmt.Errorf("对象 %q: %w", key, driver.ErrNotFound)
	}
	return &driver.ObjectInfo{Key: key, Size: int64(len(o.data)), ModTime: o.modTime}, nil
}

func (m *MemDriver) Delete(ctx context.Context, key string) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	if _, ok := m.objects[key]; !ok {
		return fmt.Errorf("对象 %q: %w", key, driver.ErrNotFound)
	}
	delete(m.objects, key)
	return nil
}

func (m *MemDriver) List(ctx context.Context, dir string) ([]driver.ObjectInfo, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	prefix := ""
	if dir != "" {
		prefix = strings.TrimSuffix(dir, "/") + "/"
	}
	m.mu.RLock()
	defer m.mu.RUnlock()
	var out []driver.ObjectInfo
	for k, o := range m.objects {
		if strings.HasPrefix(k, prefix) {
			out = append(out, driver.ObjectInfo{Key: k, Size: int64(len(o.data)), ModTime: o.modTime})
		}
	}
	return out, nil
}

本页目录