有时我会一遍又一遍地运行相同的、相当昂贵的命令,以获得相同的输出。例如,ffprobe
获取有关媒体文件的信息。给定相同的输入,应该总是产生相同的输出——因此缓存应该是可能的。
我见过记忆/缓存命令行输出但我正在寻找一种更彻底的实现:特别是,该实现似乎只是比较命令行 - 如果传递的文件之一被修改,它不会注意到。 (它还有一堆固定长度的缓冲区,这让我很怀疑,而且奇怪的是它是一个守护进程。)
在我开始写自己的文章之前,我很好奇是否已经存在。关键要求:
- 如果任何输入文件(在命令行上)发生更改,则必须重新运行该命令
- 如果任何命令行选项发生更改,则必须重新运行该命令
- 我同意(并且诚实地期望)命令以“非交互”方式运行:例如,使用
/dev/null
标准输入,以及两个不同的文件作为标准输出和标准错误。 - 如果命令出错,我可以选择将其与退出代码一起缓存,或者根本不缓存。
- 鉴于上述情况,应尽可能频繁地返回缓存的内容。但正确性是第一位的。
- 如果缓存可以在多台机器(全部在共同控制下)之间共享,例如通过 NFS,则更好。
基本上我想要做的,如果我自己写的话,是(为了简洁而跳过一些锁定和错误检查):获取命令行+命令行上每个项目的统计结果(错误或dev、inode、大小、mtime) )并通过 SHA-512 或 SHA-256 传递整个混乱。这将给出一个固定大小的密钥,但如果命令或文件发生更改,该密钥也会发生变化(除非有人进行了保留大小和运行时间的更改,在这种情况下,他们应得的)。检查该密钥是否在缓存目录中。如果它已经存在,请将其内容复制到 stdout 和 stderr。否则,在子进程中使用 stdin /dev/null 和两个文件(作为 stdout 和 stderr)运行该命令。如果成功,将文件放入缓存目录。然后将其内容复制到 stdout 和 stderr。如果结果是我自己写了,欢迎设计反馈。结果将是免费软件。
答案1
在很多情况下,您想要的东西不起作用,以至于您找不到能够提供真正良好结果的通用工具:
- 访问不在命令行上的文件的命令。 (
locate myfile
) - 访问网络的命令。 (
wget http://news.example.com/headlines
) - 取决于时间的命令。 (
date
) - 具有随机输出的命令。 (
pwgen
) - ……
如果您负责决定在哪些命令上应用该工具,那么您需要的是构建工具:一种在命令的输出不是最新的情况下运行命令的工具。尊贵者制作不会很好:您必须手动定义依赖项,特别是您需要仔细分离不同命令的缓存,并在更改命令时手动撤销缓存,并且需要将每个缓存存储在单独的文件中,即不方便。中的一个许多选择也许更能胜任这项任务SCons它支持基于校验和和基于时间戳的依赖性分析,在此之上有一个缓存机制,并且可以通过编写 Python 代码进行调整。
答案2
这更像是一个大脑转储而不是真正的答案,但对于评论来说太长了。如果不合适我会删除它。请告诉我。耸肩
首先,我认为主要问题是您从“命令-->结果”的角度来思考。如果是“文件 --> 结果”,您可以使用make
.如果只有少量固定数量的命令从文件引导到结果,您仍然可以使用:为每个命令make
编写一个目标。make
如果您坚持应该是“任意命令 --> 结果”,首先想到的是某种 REPL,或者 shell-in-language-X。如今这些东西并不缺乏,似乎每两周左右就会出现一个新的。重点是,这些可以让你与结构化的数据,而不仅仅是一个字符串(命令)和多个文件。
dev
获取+ inode
+ size
+的校验mtime
和似乎是明智的。如果您担心误报,您始终可以进行完整比较(附带说明:完整比较始终比对每个文件采用 SHA-* 并比较结果更快)。对于后端,您可以使用 SQLite,但您需要某种机制来使旧记录过期。
如果您能够指出命令和/或文件的更多限制,事情可能会更容易。旨在实现“命令-->结果”的完全通用缓存,但仍然跟踪输入文件中的更改似乎有点过于雄心勃勃。
答案3
我在出于大致相同的目的编写自己的脚本时发现了这个问题,并且您关于使用 dev+inode+size+mtime 缓存文件的想法看起来非常有用,所以我添加了它。你的想法和我的实现不同,因为我很晚才偶然发现这个页面并决定不重写所有内容:
为了简单起见,该脚本将缓存条目存储在单个 YAML 文件中。您仍然可以在多台计算机上共享此文件,但存在 RCE 风险,而且由于 YAML 文件上的 TOCTOU,您还需要编写锁定包装器。
它可能只能在 Linux 上运行,如果幸运的话,也可以在其他 Unix 上运行。
使用风险自负。您的缓存内容将不受保护。
先跑gem install chronic_duration
。
#!/usr/bin/env ruby
# Usage: memoize [-D DATABASE] [-T TIMEOUT] [-F] [--] COMMAND [ARG]...
# or memoize [-D DATABASE] --cleanup
#
# OPTIONS
# -D DATABASE Store entries in YAML format in DATABASE file.
# -T TIMEOUT Invalidate memoized entries older than TIMEOUT.
# -F Track file changes (dev+inode+size+mtime).
# --cleanup Remove all stale entries.
require 'date'
require 'optparse'
require 'digest'
require 'yaml'
require 'chronic_duration'
require 'open3'
MYSELF = File.basename(__FILE__)
DEFAULT_DBFILE = "#{Dir.home}/.config/memoize.yml"
DEFAULT_TIMEOUT = '1 week'
def fc(fpath) # File characteristic
return [:dev, :ino, :size, :mtime].map do |s|
Digest::SHA1.digest(Integer(File.stat(fpath).send(s)).to_s.b)
end.join
end
def cmdline_checksum(cmdline, fchanges)
pre_cksum_bytes = "".b
cmdline.each do |c|
characteristic = (File.exists?(c) and fchanges) ? fc(c) : c
pre_cksum_bytes += Digest::SHA1.digest(characteristic)
end
return Digest::SHA1.digest(pre_cksum_bytes)
end
def timed_out?(entry)
return (entry[:timestamp] + Integer(entry[:timeout])) < Time.now
end
def pluralize(n, singular, plural)
return (n % 100 == 11 || n % 10 != 1) ? plural : singular
end
fail "memoize: FATAL: this is a script, not a library" unless __FILE__ == $0
$dbfile = DEFAULT_DBFILE
$timeout = DEFAULT_TIMEOUT
$fchanges = false
$cleanup = false
$retcode = 0
$replay = false
ARGV.options do |o|
o.version = '2018.06.23'
o.banner = "Usage: memoize [OPTION]... [--] COMMAND [ARG]...\n"+
"Cache results of COMMAND and replay its output"
o.separator ""
o.separator "OPTIONS"
o.summary_indent = " "
o.summary_width = 17
o.on('-D=DATABASE', "Default: #{DEFAULT_DBFILE}") { |d| $dbfile = d }
o.on('-T=TIMEOUT', "Default: #{DEFAULT_TIMEOUT}") { |t| $timeout = t }
o.on('-F', "Track file changes (dev+inode+size+mtime)") { $fchanges = true }
o.on('--cleanup', "Remove all stale entries") { $cleanup = true }
end.parse!
begin
File.open($dbfile, 'a') {}
File.chmod(0600, $dbfile)
end unless File.exists?($dbfile)
db = (YAML.load(File.read($dbfile)) or {})
cmdline = ARGV
cksum = cmdline_checksum(cmdline, $fchanges)
entry = {
cmdline: cmdline,
timestamp: Time.now,
timeout: '1 week',
stdout: "",
stderr: "",
retcode: 0,
}
if $cleanup
entries = db.keys.select{|k| timed_out?(db[k]) }
c = entries.count
entries.each do |k|
db.delete(k)
end
STDERR.puts "memoize: NOTE: #{c} stale #{pluralize(c, "entry", "entries")} removed"
File.open($dbfile, 'w') { |f| f << YAML.dump(db) }
exit
end
$replay = db.key?(cksum) && (not timed_out?(db[cksum]))
if $replay
entry = db[cksum]
else
Open3.popen3(*cmdline) do |i, o, e, t|
i.close
entry[:stdout] = o.read
entry[:stderr] = e.read
entry[:retcode] = t.value.exitstatus
end
entry[:timestamp] = Time.now
entry[:timeout] = Integer(ChronicDuration.parse($timeout))
db[cksum] = entry
end
$retcode = entry[:retcode]
STDOUT.write(entry[:stdout]) # NOTE: we don't record or replay stream timing
STDERR.write(entry[:stderr])
STDOUT.flush
STDERR.flush
File.open($dbfile, 'w') { |f| f << YAML.dump(db) }
exit! $retcode