在 Shell 脚本编程中,我们经常会依赖 exec
命令来执行参数化的命令,例如
$ exec 'java' '-version'
openjdk version "1.8.0_212"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_212-b03)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.212-b03, mixed mode)
但是,用户传递参数化命令的时候,未必总是传递解析后的命令,而是再次使用参数,例如
$ exec '$JAVA_HOME/bin/java' '-version'
zsh: no such file or directory: $JAVA_HOME/bin/java
可以看到,在这种情况下,我们的方法就失效了。因为 exec
命令忠实地解释它所看到的命令,而不会擅自去解析这里的 $JAVA_HOME
参数。
为了处理这个问题,我们先快速看到最终的解决方案。我们编写一个 main.sh
的辅助脚本如下。
#!/usr/bin/env bash
CMD=("$@")
for ((i=0; i<$#; i++))
do
CMD[i]=$(eval echo ${CMD[i]})
done
exec "${CMD[@]}"
随后,即可在命令行中调用以下命令,观察到 $JAVA_HOME
参数被解析。
$ ./main.sh '$JAVA_HOME/bin/java' '-version'
openjdk version "1.8.0_212"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_212-b03)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.212-b03, mixed mode)
现在我们来讲解如何想到 $(eval echo ${CMD[i]})
这样的解法。
我们的问题是,参数输入是一个字符串,我们需要解析其中的变量。当 $JAVA_HOME
作为参数传入时,它并不参与 Shell 脚本解析命令的过程,而是作为一个普通的字符串被传递。因此,总的想法是我们需要一轮额外的命令解析过程,而要引入这样一个过程最自然的就是再次复用 Shell 的能力。
我们知道,将文本字符串作业 Shell 脚本执行,可以使用 eval
命令来达成。同时,这里我们只是想展开参数,而不是直接运行它们。因此我们不能直接写 $(eval ${CMD[i]})
(这会导致命令原地执行),而是再引入 echo
命令来打印我们想要的命令。在 $(eval ...)
的环境中,我们再次执行了一个脚本,即我们拼接出来的 echo ${CMD[i]}
。在被 exec
执行之前,在当前 Shell 中,我们将 ${CMD[i]}
先解析为 $JAVA_HOME/bin/java
和 -version
,而在 $(eval ...)
中,它们分别被 eval
进一步解释成实际的 java
路径和不需要解析的 -version
,这样,我们就做到了再次解析参数的目的。
这里我们实现的方法能够再次解析 Shell 标准中规定的所有解析动作,包括花括号解析、字符串解析、通配符解析等等。实际脚本编程中,还有一种常见的解析需求,即变量名的二次解析。换句话说,定义一个值为变量名的变量,例如
REF=F
然后通过某种方式从 REF
中取到 F
的值。这在需要动态的获取变量名的情景下会非常有用。除了用刚才的 Eval Echo 方案以外,Shell 还为此提供了专门的语法,两种方法的对比如下。
RESOLVE_F=$(eval echo $$REF)
echo $RESOLVE_F # 42
RESOLVE_F=${!REF}
echo $RESOLVE_F # 42
这种技术本质上是在运行时查找符号表。在 Perl 语言中,可以简单地通过两次访问符号表来达成类似的效果。
$F = 42;
$REF = 'F';
# or $REF = F;
print $$REF; # 42
而在 Java 等语言中,可能就需要一定的反射手段才能达到类似的目的。