Elvish `store:` 模块实战指南:命令历史与目录历史的持久化存储访问
Elvish store: 模块实战指南:命令历史与目录历史的持久化存储访问
导读
store: 是 Elvish 提供的持久化数据存储访问模块,负责读写交互式 shell 中积累的两类核心历史数据:命令历史(command history)与目录历史(directory history)。本指南将基于 Elvish 当前源码仓库,完整讲解 store: 模块的全部函数、序列号与分数机制、底层 bbolt 存储实现,以及模块仅在交互模式可用的原因,帮助你理解并直接调用这套与历史记录、目录跳转(location mode)深度绑定的基础设施。
store: 模块概览:它是什么、何时可用
根据 参考文档,store: 模块“提供对 Elvish 持久化数据存储(persistent data store)的访问,目前仅在交互模式下可用”。
在 Elvish 中,普通标准库模块(如 str:、math:、path: 等)会统一通过 pkg/mods/mods.go 的 AddTo 注册,而 store: 不在该清单中。它由交互式 shell 的启动流程按需挂载:在 pkg/shell/interact.go 中,启动交互会话时会激活 daemon 客户端 cl,随后执行:
ev.AddModule("store", store.Ns(cl))
ev.AddModule("daemon", daemon.Ns(cl))
也就是说,只有成功连接到后台 daemon 服务的交互式会话才会拥有 store: 模块;脚本模式(elvish script.elv)或 daemon 连接失败的会话无法使用它。这也解释了参考文档“仅交互模式可用”的限定。
模块与编辑器的关系也值得注意:edit: 模块中的历史列表(histlist)、历史遍历(histwalk)、目录定位(location)等模式,正是消费这些存储数据的上层功能。
模块的 Go 绑定:从接口到命名空间
store: 模块的实现位于 pkg/mods/store/store.go,其核心是一个将 Go 方法映射为 Elvish 函数的命名空间构造器:
func Ns(s storedefs.Store) *eval.Ns {
return eval.BuildNsNamed("store").
AddGoFns(map[string]any{
"next-cmd-seq": s.NextCmdSeq,
"add-cmd": s.AddCmd,
"del-cmd": s.DelCmd,
"cmd": s.Cmd,
"cmds": s.CmdsWithSeq,
"next-cmd": s.NextCmd,
"prev-cmd": s.PrevCmd,
"add-dir": func(dir string) error { return s.AddDir(dir, 1) },
"del-dir": s.DelDir,
"dirs": func() ([]storedefs.Dir, error) { return s.Dirs(storedefs.NoBlacklist) },
}).Ns()
}
从中可以看到 store: 模块对外暴露的全部函数:next-cmd-seq、add-cmd、del-cmd、cmd、cmds、next-cmd、prev-cmd、add-dir、del-dir、dirs。
这里的 s 是 pkg/store/storedefs/storedefs.go 中定义的 Store 接口,它抽象了存储服务的能力:
type Store interface {
NextCmdSeq() (int, error)
AddCmd(text string) (int, error)
DelCmd(seq int) error
Cmd(seq int) (string, error)
CmdsWithSeq(from, upto int) ([]Cmd, error)
NextCmd(from int, prefix string) (Cmd, error)
PrevCmd(upto int, prefix string) (Cmd, error)
AddDir(dir string, incFactor float64) error
DelDir(dir string) error
Dirs(blacklist map[string]struct{}) ([]Dir, error)
}
两个核心数据结构 Cmd 与 Dir 也定义于此:
type Dir struct {
Path string
Score float64
}
type Cmd struct {
Text string
Seq int
}
它们分别对应 Elvish 侧命令历史条目的伪 map(&text= 与 &seq= 字段)和目录历史条目的伪 map(&path= 与 &score= 字段)。Store 接口被设计为独立包,使得只依赖存储 API 的模块无需依赖具体实现(例如内存版 mem_store、混合版 hybrid_store)。
命令历史操作:增、查、删
命令历史是交互式 shell 中最常被读取的数据。store: 模块提供了一组围绕“序列号(seq)+ 内容(text)”模型的函数。以下示例均取自仓库中的真实测试转录 pkg/mods/store/store_test.elvts,可直接在交互式 Elvish 中复现。
获取下一个序列号:next-cmd-seq
store:next-cmd-seq 输出下一条命令历史条目将使用的序列号(不产生新条目)。在空的历史上,序列号从 1 开始:
~> store:next-cmd-seq
▶ (num 1)
添加条目:add-cmd
store:add-cmd $text 向命令历史添加一条内容为 $text 的条目,并输出该条目的序列号。连续添加三条:
~> store:add-cmd foo
▶ (num 1)
~> store:add-cmd bar
▶ (num 2)
~> store:add-cmd baz
▶ (num 3)
~> store:next-cmd-seq
▶ (num 4)
可以看到,next-cmd-seq 会紧随已用序列号递增。
按序列号查询:cmd
store:cmd $seq 输出序列号为 $seq 的条目内容:
~> store:cmd 1
▶ foo
区间查询:cmds
store:cmds $from $upto 输出序列号在 $from(含)到 $upto(不含)之间的所有条目。将 $upto 设为 -1 表示不设上界(查询到历史末尾)。每个条目以伪 map 呈现,含 text 与 seq 两个字段:
~> store:cmds 1 4
▶ [&seq=(num 1) &text=foo]
▶ [&seq=(num 2) &text=bar]
▶ [&seq=(num 3) &text=baz]
~> store:cmds 2 3
▶ [&seq=(num 2) &text=bar]
前缀搜索:next-cmd 与 prev-cmd
这两个函数在参考文档的 .d.elv 声明之外、由 Go 绑定直接暴露,用于按内容前缀在历史中向前/向后搜索:
store:next-cmd $from $prefix:从序列号$from(含)开始,向后找到第一条内容以$prefix开头的条目;store:prev-cmd $upto $prefix:从序列号$upto(不含)开始,向前找到最后一条内容以$prefix开头的条目。
测试转录中的用法:
~> store:next-cmd 1 f
▶ [&seq=(num 1) &text=foo]
~> store:prev-cmd 3 b
▶ [&seq=(num 2) &text=bar]
它们也是 edit: 模块历史搜索功能的基础——当无匹配时,底层返回 storedefs.go 中定义的 ErrNoMatchingCmd("no matching command line")。
删除条目:del-cmd
store:del-cmd $seq 删除序列号为 $seq 的命令历史条目。注意:此命令只删除持久化存储中的条目;如果删除的是当前会话内新添加的条目,删除对当前会话不生效——因为该条目仍存在于会话内存历史中:
~> store:del-cmd 2
~> store:cmds 1 4
▶ [&seq=(num 1) &text=foo]
▶ [&seq=(num 3) &text=baz]
目录历史操作:增、删、按分数排序
目录历史用于记录访问过的目录,是 edit: 中 location 模式(目录快速跳转)的数据来源。
添加目录:add-dir
store:add-dir $path 向目录历史添加(或更新)一个路径。它会同时导致所有其他目录的分数按衰减因子降低:
~> store:add-dir /foo
~> store:add-dir /bar
查询全部目录:dirs
store:dirs 输出全部目录历史条目,按分数降序排列,每个条目为含 path 与 score 字段的伪 map:
~> store:dirs
▶ [&path=/bar &score=(num 10.0)]
▶ [&path=/foo &score=(num 9.86)]
删除目录:del-dir
store:del-dir $path 从目录历史中删除一个路径。与 add-dir 不同,它不会影响其他目录的分数:
~> store:del-dir /foo
~> store:dirs
▶ [&path=/bar &score=(num 10.0)]
实现原理纵深:bbolt 存储与分数算法
store: 模块的持久化实现位于 pkg/store/,底层使用 bbolt(go.etcd.io/bbolt)键值数据库。理解其实现,能更好地解释上文各函数的行为。
命令历史的序列号与键编码
pkg/store/cmd.go 中,命令历史存放在独立的 bbolt bucket(bucketCmd)中:
NextCmdSeq读取 bucket 的Sequence()+1,即“下一个可用序列号”;AddCmd调用b.NextSequence()生成递增序列号,再以marshalSeq(seq)作为键写入命令文本;marshalSeq将序列号编码为 8 字节大端序(big endian)无符号整数:
func marshalSeq(seq uint64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, seq)
return b
}
采用大端序编码保证了序列号即键序:bbolt 的 B+ 树按字节序迭代,cmds $from $upto 的区间查询、next-cmd/prev-cmd 的定位都能通过游标(Cursor)的 Seek/Next/Prev 高效完成,这正是 cmd.go 中 IterateCmds、NextCmd、PrevCmd 的实现思路。
目录历史的分数衰减模型
pkg/store/dir.go 定义了目录分数的三个常量:
const (
DirScoreDecay = 0.986 // roughly 0.5^(1/50)
DirScoreIncrement = 10
DirScorePrecision = 6
)
AddDir 的实现体现了完整的“衰减 + 增量”模型:
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
score := unmarshalScore(v) * DirScoreDecay
b.Put(k, marshalScore(score))
}
// ...
score += DirScoreIncrement * incFactor
即每次添加目录时:先让历史中所有目录的分数乘以 0.986(DirScoreDecay ≈ 0.5^(1/50),约每 50 次添加使分数减半),再为当前目录累加 10 * incFactor 的增量。从 Go 绑定看,store:add-dir 调用时 incFactor 固定为 1(见 store.go 中 return s.AddDir(dir, 1)),因此单次调用为当前目录 +10。
这就是测试转录中“先加 /foo 再加 /bar,最终 /bar 得 10.0、/foo 因经历一次衰减得 9.86”的原因——/foo 的 10 分乘以 0.986 后约为 9.86(分数以科学计数法序列化存储,DirScorePrecision = 6 控制精度)。dirs 的排序则由 sort.Reverse(dirList(dirs)) 按分数降序完成,与“最近的目录分数最高、排在前面”的直觉一致。
数据流全景
一条完整的链路是:交互式会话启动 → interact.go 激活 daemon → 通过 RPC 拿到实现了 storedefs.Store 的客户端 → store.Ns(cl) 注册 store: 模块 → 用户在 edit: 编辑器或命令行中读写历史。上层 edit: 模块的历史列表、目录跳转模式,与底层 pkg/store/ 的 bbolt 持久化,正是经由这一接口解耦的。
使用要点与限制小结
- 仅交互模式可用:
store:模块由交互式会话注册(pkg/shell/interact.go),脚本模式下不存在;daemon 连接失败时该模块亦不可用。 - 序列号约定:从 1 开始递增;
cmds的区间为“含前不含后”,upto = -1表示无上界。 - 删除的会话效应:
del-cmd只影响持久化存储,当前会话新添加的条目删除后仍可见(内存历史未同步)。 - 目录分数的动态特性:
add-dir会衰减所有目录的分数,del-dir则只移除单个路径、不扰动其余分数。 - 返回值形态:命令历史条目是含
seq、text的伪 map;目录历史条目是含path、score的伪 map,均可直接用于 Elvish 脚本的管道与结构化处理。
完整的函数声明与文档注释见 pkg/mods/store/store.d.elv,接口定义见 pkg/store/storedefs/storedefs.go,真实运行示例见 pkg/mods/store/store_test.elvts,需要阅读更底层的持久化实现时可继续深入 pkg/store/cmd.go 与 pkg/store/dir.go。