驱动接口与 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 就以原文传入。
包里提供两个函数完成编码。两者都按后端元数据挑出密码类参数、原地修改传入的映射、跳过空值,后端名不认识时返回错误:
ObscureSecrets | ObscurePlainSecrets | |
|---|---|---|
| 编码哪些值 | 全部密码类参数 | 密码类参数中尚未编码的值 |
| 适用场景 | 值确定是明文,例如来自表单输入 | 来源不确定,可能是明文,也可能是从 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 都不同,都能解出同一个值):
[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/imagesrclone --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 后端走一遍写入、查询、读回、删除。
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 配合。要点:
- 对象缺失返回
ErrNotFound:Open、Stat、Delete找不到对象时,返回能被errors.Is(err, driver.ErrNotFound)命中的错误。failover.Open靠它区分「对象不存在」与「存储故障」,failover.Probe把Stat返回的ErrNotFound视为存储可达。 Put读满size个字节,读不够时返回错误;不要关闭r。失败后调用方可能以同一个键重试,再次Put应整体覆盖之前留下的内容。List递归列举:dir为空表示全部对象;返回相对根的完整键;目录不存在时返回空结果与 nil。ModTime填真实的修改时间:调用方会据此判断对象新旧,例如应用的对账只回收修改时间早于宽限期的未知对象。- 并发安全、尊重
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
}