使用-exec和xargs命令有什么区别?

尝试学习Bash脚本编程,我想在当前目录下满足特定条件的所有文件上执行一些命令。使用以下方法:
find -name *.flac

具体来说,我想将.flac文件转换为.mp3文件。我可以找到所有的文件。然而,我没有看到使用find命令的-exec选项和使用xargs执行命令之间的区别。例如:
find -name *.flac | xargs -i ffmpeg -i {} {}.mp3

相比之下
find -name *.flac -exec ffmpeg -i {} {}.mp3 \;

有人能指出区别吗?哪种做法更好?有什么优缺点?
另外:如果我想同时删除原始文件,我该如何在上述代码中添加第二个命令?

这里有一个很好的讨论:https://superuser.com/questions/600253/why-is-xargs-necessary - Panther
1而且你似乎不理解引用的用法。比如说,echo find -name *.flac echo file -name '*.flac',看看引用的作用是如何使用的。 - waltinator
3个回答

摘要:

除非你对xargs-exec更熟悉,否则在使用find时,你可能会更倾向于使用-exec

由于xargs是一个单独的程序,调用它可能比使用-exec稍微低效,而-execfind程序的一个特性。通常情况下,如果额外的程序在可靠性、性能或可读性方面没有任何额外的好处,我们不希望调用它。由于find ... -exec ...提供了与参数列表一起运行命令的能力(就像xargs一样),所以在使用find时,使用xargs实际上没有任何优势。在ffmpeg的情况下,我们必须指定输入和输出文件,因此无论使用哪种方法构建参数列表,都无法获得性能提升,并且使用xargs更难删除不合逻辑的原始文件扩展名。

xargs的作用

注意:xargs中的冗长标志(它打印带有参数的构造命令)是-t,而交互标志(导致用户被提示确认对每个参数进行操作)是-p。您可能会发现这两个对于理解和测试其行为很有用。

xargs试图将其标准输入(通常是已经通过管道传输给它的以前命令的标准输出)转换为某个命令的参数列表。

command1 | xargs command2 [output of command1 will be appended here]

由于STDOUT或STDIN只是一串文本流(这也是为什么不能解析ls的输出),因此xargs容易出错。它将参数作为以空格或换行符分隔的内容进行读取。文件名允许包含空格,甚至可能包含换行符,这样的文件名将导致意外行为。假设你有一个名为foo bar的文件。当包含该文件名的列表传输到xargs时,它会尝试在foobar上运行给定的命令。
当你输入command foo bar时,同样的问题会出现,你知道可以通过引用空格或整个名称来避免这个问题,例如command foo\ barcommand "foo bar",但即使我们能够引用传递给xargs的列表,通常我们也不想这样做,因为我们不希望整个列表被视为一个单独的参数。这个问题的标准解决方案是使用空字符作为分隔符,因为文件名中不能包含空字符。
find path test(s) -print0 | xargs -0 command

这会导致find在每个文件名后附加空字符而不是空格,并且xargs只将空字符视为分隔符。
如果命令不接受多个参数或者参数列表非常长,仍然可能出现问题。
在这种情况下,您正在使用ffmpeg,它希望先指定输入文件,然后指定输出文件。我们可以使用-i标志明确告诉ffmpeg要使用哪个文件作为输入,但我们还需要给出输出文件名(通常会猜测格式,但我们也可以指定)。因此,为了构建合适的命令,您需要使用xargs的替换字符串选项(-I-i)来同时指定输入和输出文件:
... | xargs -I{} command {} {}.out

(文档中提到,为了这个目的,-i 已经被弃用,我们应该使用 -I,但我不确定为什么。当使用 -I 时,你必须在选项后面立即指定替换内容(通常使用 {})。使用 -i 时,你可以省略指定替换内容,但默认情况下会理解为 {}。) -I 选项只会根据换行符而不是空格来分割命令列表,所以如果你确定文件名不会包含换行符,那么在使用 -I 时就不需要使用 -print0 | xargs -0。如果你不确定,仍然可以使用更安全的语法:)
find -name "*.flac" -print0 | xargs -0I{} ffmpeg -i {} {}.mp3

然而,在这里,xargs(它使我们能够一次运行一个带有参数列表的命令)的性能优势丧失了,因为对于每一对输入和输出文件,必须分别运行ffmpeg(通过在上述命令前加上echo可以轻松看到这一点)。这还会产生一个不合逻辑的文件名,并且不允许您运行多个命令。要做到后者,您可以调用bash,如dessert's answer所示:
... | xargs -I{} bash -c 'ffmpeg -i {} {}.mp3 && rm {}'

但是重新命名很棘手

-exec的不同之处

当你使用-exec选项来执行find命令时,找到的文件会作为参数传递给-exec后面的命令。它们不会被转换成文本。使用以下语法:

find ... -exec command {} \;

command是针对每个找到的文件运行一次的。语法如下:

find ... -exec command {} +

一个参数列表是从找到的文件构建的,这样我们就可以只运行一次命令(或者根据需要运行多次),并且在多个文件上获得由xargs提供的性能优势。然而,由于文件名参数不是从文本流构建的,使用-exec没有xargs的问题,即在空格和其他特殊字符上断开。
对于ffmpeg,由于与xargs没有任何性能优势相同的原因,我们不能使用+;由于我们需要同时指定输入和输出,必须对每个文件单独运行命令。我们必须使用某种形式的
find -name "*.flac" -exec ffmpeg -i {} {}.out \;

这样做会得到一个名字相当不合逻辑的文件,正如dessert's answer explains所解释的那样,因此您可能希望去掉它,就像dessert's answer中解释的那样,使用字符串操作来实现(在xargs中不容易实现;使用-exec的另一个原因)。它还解释了如何在文件上运行多个命令,以便在成功转换后安全地删除原始文件。
我同意dessert的建议,但我想提供一个替代find的方法,它允许类似于在-exec之后运行bash -c的灵活性;一个bash for循环。
shopt -s globstar           # allow recursive globbing with **
for f in ./**/*.flac; do    # for all files ending with .flac
   # convert them, stripping the original extension from the new filename
   echo ffmpeg -i "$f" "${f%.flac}.mp3" &&
   echo rm -v "$f"          # if that succeeded, delete the original
done
shopt -u globstar           # turn recursive globbing off

在测试后,删除echo以实际操作文件。 ffmpeg不识别--作为选项的结束标记,所以为了避免以-开头的文件名被解释为选项,我们使用./来表示当前目录,而不是以**开头,这样所有路径都以./开头而不是任意文件名。这意味着我们也不需要在rm中使用--(它识别它)。
注意:如果您的-name测试表达式包含任何通配符字符,请将其引用起来,否则shell会在将它们传递给find之前(即在它们与当前目录中的任何文件匹配时)尽可能地展开它们,所以首先使用
find -name "*.flac"

为了防止意外行为。

这是一个很好的回答,非常感谢!楼主提出了第二个问题,你到目前为止还没有回答:如果我想同时删除原始文件,我该如何在上述代码中添加第二个命令? - dessert
@dessert 谢谢你,也谢谢你指出这一点 :) 由于你的答案在我看来是使用find的最佳方式,所以我添加了一个运行多个命令的替代方法。 - Zanna
1如果在性能方面没有提供任何额外的好处,那就是我想要使用xargs -P的地方。我经常使用它来大大加快速度,据我所知,这是无法通过-exec实现的。 - pLumo
@RoVo 这很有趣 - 希望你愿意对此发表一些回答 :) - Zanna
1+1 对于+语法!大多数基本的find示例中没有涵盖到。在我刚刚使用stat对大量文件运行的任务中,使用+相比\;可以加快约300倍的速度。 - Sasgorilla

通常尽量少调用命令,但在你的情况下我认为这是个品味问题 - 我会选择使用-exec,像这样使用它:
find . -name '*.flac' -exec bash -c 'ffmpeg -i "$0" "${0%flac}mp3" && rm "$0"' {} \;

诀窍是使用 -c 选项调用 bash,这样你不仅可以执行多个命令,还可以使用 Bash 参数替换 从文件名中删除 flac 后缀 – 我猜你确实不想以 filename.flac.mp3 命名文件,是吗?

解释

  • bash -c '…' {} – 使用文件名作为第一个参数(可通过 $0 访问),在 bash 中运行命令
  • ${0%flac} – 从文件名末尾去除 flac
  • && rm "$0" – 只有前面的命令成功,才删除原始文件

正如Zanna和dessert已经回答的那样,当不需要使用xargs时,应该优先选择-exec(“如果在可靠性、性能或可读性方面没有任何额外的好处,我们通常不希望调用额外的程序。”)。
虽然这完全正确,但我想补充一点,xargs与-P标志结合使用可以在性能方面提供实质性的好处。
xargs将以并行方式生成进程,实现多线程,类似于但比parallel命令更灵活。
-P max-procs, --max-procs=max-procs
              Run up to max-procs processes at a time; the default is 1.  If max-procs is 0, xargs will run as many processes as possible at a time.  Use the -n option or the -L option with -P; other‐
              wise chances are that only one exec will be done. 
              [...]

这对于那些本身不支持多线程的进程特别有帮助。在你的情况下,ffmpeg会处理多线程,所以使用它不会有帮助,甚至可能对性能产生负面影响。
find . -name "*.ext" -print0 | xargs -0 -i -P 20 command -in {} -out {}.out

小心使用-P!运行find . -type f -print0 | xargs -0 -P0 stat --terse,我得到了几乎正确的输出,但有些行略微混乱--可能是由于竞争条件引起的?我的输出通过| xargs -0 -n1 -P0 stat --terse修复了,但-n1标志消除了-P的所有优势,甚至更多。在这个应用程序中,我最好使用xargs -0(没有-P-n1)。 - Sasgorilla