从一个 shell 脚本中导入函数

89

我有一个shell脚本,想要用shUnit进行测试。由于这样可以使安装更加容易,因此脚本(以及所有函数)都在单个文件中。

script.sh的示例:

#!/bin/sh

foo () { ... }
bar () { ... }

code

我想写一个第二个文件(不需要分发和安装),以测试在 script.sh 中定义的函数。

类似于 run_tests.sh 这样的东西。

#!/bin/sh

. script.sh

# Unit tests

现在问题出在 . (或Bash中的source)。它不仅解析函数定义,还执行脚本中的代码。

由于没有参数的脚本不会有任何问题,因此我可以……

. script.sh > /dev/null 2>&1

但我在想是否有更好的方法来实现我的目标。

编辑

如果源脚本调用exit,那么我提出的解决方法将无法工作,因此我必须捕获退出。

#!/bin/sh

trap run_tests ERR EXIT

run_tests() {
   ...
}

. script.sh

调用了run_tests函数,但是一旦我重定向源命令的输出,脚本中的函数就不会被解析并且在陷阱处理程序中也不可用。

这个方法可以运行,但是我会得到script.sh的输出:

#!/bin/sh
trap run_tests ERR EXIT
run_tests() {
   function_defined_in_script_sh
}
. script.sh

这不会打印输出,但我会收到一个错误,指出该函数未定义:

#!/bin/sh
trap run_tests ERR EXIT
run_tests() {
   function_defined_in_script_sh
}
. script.sh | grep OUTPUT_THAT_DOES_NOT_EXISTS

这不会打印输出,也不会调用run_tests陷阱处理程序:
#!/bin/sh
trap run_tests ERR EXIT
run_tests() {
   function_defined_in_script_sh
}
. script.sh > /dev/null

参见:什么是bash中等同于Python的if __name__ == '__main__'的语句?。我在那里添加了一个非常类似Python的答案,供有兴趣的人参考。 - Gabriel Staples
6个回答

126
根据 bash manpage 中的“Shell Builtin Commands”部分,. 也被称为 source,它需要一个可选的参数列表,这些参数会传递给被引用的脚本。你可以使用这个命令来引入一个无意义的选项。例如,script.sh 可以是:
#!/bin/sh

foo() {
    echo foo $1
}

main() {
    foo 1
    foo 2
}

if [ "${1}" != "--source-only" ]; then
    main "${@}"
fi

并且 unit.sh 可能是:

#!/bin/bash

. ./script.sh --source-only

foo 3

然后script.sh将正常工作,而unit.sh将可以访问来自script.sh的所有函数,但不会调用main()代码。

请注意,对于source的额外参数不符合POSIX标准,因此/bin/sh可能无法处理它-这就是为什么在unit.sh开头使用#!/bin/bash的原因。


1
你可能想要“移动”参数列表,这样main就不会与--source-only接触到。 - Hubert Grzeskowiak
@HubertGrzeskowiak 很好的建议,已经修复。感谢您的建议! - andrewdotn
3
“shift” 在那里没有意义,因为 main 只有在 --source-only 不是第一个参数时才运行。 - Helder Pereira
@HelderPereira 很好的发现!感谢您报告这个问题。 - andrewdotn
我的回答就像 Python 一样,神奇地起作用。无需额外的“--source-only”参数。另请参阅我在此处的其他答案中的解释:What is the bash equivalent to Python's if __name__ == '__main__'?。我还验证了我的技术即使在嵌套脚本中也可以工作,在这种情况下,一个bash脚本调用或源另一个脚本,这个脚本又调用或源另一个脚本。 - Gabriel Staples

23

我从Python中学到了这个技巧,但是在bash或任何其他shell中这个概念也能很好地工作...

这个想法是将我们脚本的主代码部分转换为一个函数。然后在脚本的末尾,我们放置了一个 'if' 语句,只有在执行脚本而不是执行源文件时才调用该函数。然后我们明确地从我们的 'runtests' 脚本中调用 script() 函数,该脚本已经引用了 'script' 脚本,因此包含了所有它的函数。

这取决于以下事实:如果我们引用脚本,由 bash 维护的环境变量 $0,即正在执行的脚本的名称,将是调用(父)脚本(此处为 runtests)的名称,而不是被引用的脚本的名称。

(我将 script.sh 重命名为 script,因为 .sh 是多余的并且会让我感到困惑。:-)

下面是两个脚本。一些注释...

  • $@ 会将传递给函数或脚本的所有参数作为独立的字符串进行评估。如果我们使用 $*,所有参数将被连接成一个字符串。
  • RUNNING="$(basename $0)" 是必需的,因为 $0 总是包含当前目录前缀,如 ./script
  • 测试语句 if [[ "$RUNNING" == "script" ]]... 是这个魔法,只有当直接从命令行运行 script 时,script 才会调用 script() 函数。

脚本:

#!/bin/bash

foo ()    { echo "foo()"; }

bar ()    { echo "bar()"; }

script () {
  ARG1=$1
  ARG2=$2
  #
  echo "Running '$RUNNING'..."
  echo "script() - all args:  $@"
  echo "script() -     ARG1:  $ARG1"
  echo "script() -     ARG2:  $ARG2"
  #
  foo
  bar
}

RUNNING="$(basename $0)"

if [[ "$RUNNING" == "script" ]]
then
  script "$@"
fi

运行测试

#!/bin/bash

source script 

# execute 'script' function in sourced file 'script'
script arg1 arg2 arg3

4
这有两个不足之处:每当您重命名脚本时,都必须更改其内容;而且它不适用于符号链接(关于别名不确定)。 - Hubert Grzeskowiak
如果从命令行执行,则会抛出错误:basename: illegal option -- b - Daniel C. Sobral

17
如果您正在使用Bash,则可以通过使用BASH_SOURCE数组来实现类似于@andrewdotn方法的解决方案(但无需额外的标志或依赖于脚本名称)。
script.sh:
#!/bin/bash

foo () { ... }
bar () { ... }

main() {
    code
}

if [[ "${#BASH_SOURCE[@]}" -eq 1 ]]; then
    main "$@"
fi

run_tests.sh:

#!/bin/bash

. script.sh

# Unit tests

这很不错!我个人会将测试条件更改为(( ${#BASH_SOURCE[@]} == 1 )); 短条件(( ${#BASH_SOURCE[@]} == 1 )) && main "$@"也可以工作,并且如果您不想让它执行任何其他操作,那么它就足够了。 - Martin - マーチン

4
现在问题出在.(或Bash中的source)。它不仅解析函数定义,还执行脚本中的代码。
以下是我喜欢的一种方法,可以确保只有在执行文件时才运行我的Bash代码。
我喜欢让它看起来更像Python,而不是其他答案。我做了一些额外的工作,以便最终得到if [ "$__name__" = "__main__" ]; then这一行。请参见我在此处的答案:What is the bash equivalent to Python's if __name__ == '__main__'?,其中我描述了这个过程。
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
    # This script is being run.
    __name__="__main__"
else
    # This script is being sourced.
    __name__="__source__"
fi

# Code entry point. Only run `main` if this script is being **run**, NOT
# sourced (imported).
# - See my answer: https://dev59.com/rV0a5IYBdhLWcg3wxbHm#70662116
if [ "$__name__" = "__main__" ]; then
    main "$@"
fi

让我们更深入地了解一下,我会提供一个完整的库示例和各种导入方式:

详细示例:如何在Bash中编写、导入、使用和测试库?

这里是一种非常漂亮、几乎类似Python风格的方法,我在多年的编写和使用Bash库中总结出来的。Bash是一种美丽的“粘合”类型语言,可以轻松地将来自许多语言的可执行文件组合在一起。考虑到Bash已经存在很长时间了,我不确定为什么以下样式不太受欢迎,但也许之前没有人想过或以这种方式使用。所以,来吧,我认为你一定会发现它非常有用。

您还可以在我的eRCaGuy_hello_world存储库中的hello_world_best.sh文件中看到我用于所有bash脚本的一般起点。

您可以在浮点数数学.sh中查看完整的库示例。

library_basic_example.sh:

#!/usr/bin/env bash

RETURN_CODE_SUCCESS=0
RETURN_CODE_ERROR=1

# Add your library functions here. Ex:

my_func1() {
    echo "100.1"
}

my_func2() {
    echo "200"
}

my_func3() {
    echo "hello world"
}

# Note: make "private" functions begin with an underscore `_`, like in Python,
# so that users know they are not intended for use outside this library.

# Assert that the two input argument strings are equal, and exit if they are not
_assert_eq() {
    if [ "$1" != "$2" ]; then
        echo "Error: assertion failed. Arguments not equal!"
        echo "  arg1 = $1; arg2 = $2"
        echo "Exiting."
        exit $RETURN_CODE_ERROR
    fi
}

# Run some unit tests of the functions found herein
_test() {
    printf "%s\n\n" "Running tests."

    printf "%s\n" "Running 'my_func1'"
    result="$(my_func1)"
    printf "%s\n\n" "result = $result"
    _assert_eq "$result" "100.1"

    printf "%s\n" "Running 'my_func2'"
    result="$(my_func2)"
    printf "%s\n\n" "result = $result"
    _assert_eq "$result" "200"

    printf "%s\n" "Running 'my_func3'"
    result="$(my_func3)"
    printf "%s\n\n" "result = $result"
    _assert_eq "$result" "hello world"

    echo "All tests passed!"
}

main() {
    _test
}

# Determine if the script is being sourced or executed (run).
# See:
# 1. "eRCaGuy_hello_world/bash/if__name__==__main___check_if_sourced_or_executed_best.sh"
# 1. My answer: https://dev59.com/rV0a5IYBdhLWcg3wxbHm#70662116
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
    # This script is being run.
    __name__="__main__"
else
    # This script is being sourced.
    __name__="__source__"
fi

# Code entry point. Only run `main` if this script is being **run**, NOT
# sourced (imported).
# - See my answer: https://dev59.com/rV0a5IYBdhLWcg3wxbHm#70662116
if [ "$__name__" = "__main__" ]; then
    main "$@"
fi

运行库以运行其单元测试

现在,使文件可执行。运行它将运行其内部单元测试:

# make it executable
chmod +x library_basic_example.sh

# run it
./library_basic_example.sh

示例运行命令和输出:

eRCaGuy_hello_world$ bash/library_basic_example.sh 
Running tests.

Running 'my_func1'
result = 100.1

Running 'my_func2'
result = 200

Running 'my_func3'
result = hello world

All tests passed!

导入(源)库

要导入Bash库,您可以使用source.(更好)命令进行“源”操作。在这里阅读有关此问题的更多信息:{{link1:source . )与export(以及末尾的一些文件锁[flock]内容)}}。

1. 使用手动设置的导入路径

您可以直接在bash终端中执行此操作,也可以在自己的bash脚本中执行此操作。现在就在您自己的终端中尝试吧!:

source "path/to/library_basic_example.sh"

# Or (better, since it's Posix-compliant)
. "path/to/library_basic_example.sh"

一旦你导入了Bash库(import),你就可以直接调用其中的函数。下面是一个完整的示例运行和输出,展示了我可以像my_func1my_func2等一样,在终端内直接调用这个Bash库一旦我已经导入了它!:

eRCaGuy_hello_world$ . bash/library_basic_example.sh
eRCaGuy_hello_world$ my_func1
100.1
eRCaGuy_hello_world$ my_func2
200
eRCaGuy_hello_world$ my_func3
hello world
eRCaGuy_hello_world$ _test
Running tests.

Running 'my_func1'
result = 100.1

Running 'my_func2'
result = 200

Running 'my_func3'
result = hello world

All tests passed!

2. 使用 BASHLIBS 环境变量,使导入您的 Bash 库更加容易

您可以从 任何路径 导入您的 bash 库。但是,像 BASHLIBS 这样的环境变量会使它更容易。将其添加到您的 ~/.bashrc 文件底部:

if [ -d "$HOME/libs_bash/libraries" ] ; then
    export BASHLIBS="$HOME/libs_bash/libraries"
fi

现在,您可以将Bash库的符号链接创建到该目录中,就像这样:
# symlink my `library_basic_example.sh` file into the `~/libs_bash/libraries`
# dir
mkdir -p ~/libs_bash/libraries
cd path/to/dir_where_my_library_file_of_interest_is_stored
# make the symlink
ln -si "$(pwd)/library_basic_example.sh" ~/libs_bash/libraries/

现在,我的library_basic_example.sh文件的符号链接存储在~/libs_bash/libraries/中,并且已经设置了BASHLIBS环境变量并导出到我的环境中。因此,我可以将我的库导入到任何Bash终端或在终端内运行的脚本中,就像这样:

. "$BASHLIBS/library_basic_example.sh"

3. [我最常用的技巧!] 使用相对导入路径

这个技巧非常漂亮和强大。看看这个例子吧!

假设您有以下目录布局:

dir1/
    my_lib_1.sh

    dir2/
        my_lib_2.sh
        my_script.sh
    
        dir3/
            my_lib_3.sh

以上的表示方式在大型程序或工具链中很容易存在,其中您设置了散乱的可运行脚本和库文件,特别是在您的各种bash脚本之间共享(源/导入)代码时,无论它们在何处。

假设您有以下约束/要求:

  1. 上述整个目录结构都存储在单个GitHub仓库中。
  2. 您需要任何人都能够git clone此repo并让所有脚本和导入等内容为每个人“神奇地工作”!
    1. 这意味着您需要在将bash脚本相互导入时使用“相对导入”。
  3. 例如,您将运行my_script.sh
  4. 您必须能够从任何地方调用my_script.sh ,这意味着:您应该能够在调用此脚本运行时进入您整个文件系统中的任何目录。
  5. my_script.sh 必须使用相对导入来导入my_lib_1.sh my_lib_2.sh my_lib_3.sh

这就是方法!基本上,我们要找到运行脚本的路径,然后将该路径用作相对起点,以处理该脚本周围的其他脚本!

阅读我的完整答案以获取更多细节:如何获取正在运行或引用的任何脚本的完整文件路径、完整目录和基本文件名...即使被调用的脚本是从另一个bash函数或脚本中调用的,或者在使用嵌套引用时!

完整示例:

my_script.sh:

#!/usr/bin/env bash

# Get the path to the directory this script is in.
FULL_PATH_TO_SCRIPT="$(realpath "${BASH_SOURCE[-1]}")"
SCRIPT_DIRECTORY="$(dirname "$FULL_PATH_TO_SCRIPT")"

# Now, source the 3 Bash libraries of interests, using relative paths to this
# script!
. "$SCRIPT_DIRECTORY/../my_lib_1.sh"
. "$SCRIPT_DIRECTORY/my_lib_2.sh"
. "$SCRIPT_DIRECTORY/dir3/my_lib_3.sh"

# Now you've sourced (imported) all 3 of those scripts!

就是这样!如果你知道命令,那么非常容易!

Python 能做到这一点吗?至少原生的 Python 不能。在这方面,Bash 比 Python 更加简单!当 import 时,Python 不会直接使用文件系统路径。它比那复杂和曲折得多。但是,我正在开发一个名为 import_helper.py 的 Python 模块,以使 Python 中的此类操作变得容易。我很快也会发布它。

更进一步

  1. 在我的eRCaGuy_hello_world存储库中查看我的完整且非常有用的Bash浮点库,链接在这里:floating_point_math.sh
  2. 在我的自述文件中查看我关于Bash库安装和使用的一些备选笔记,链接在这里:https://github.com/ElectricRCAircraftGuy/eRCaGuy_hello_world/tree/master/bash/libraries

另请参阅

  1. 测试Bash shell脚本 - 这个答案提到了这个assert.sh Bash仓库, 看起来非常有用,可以进行更加健壮的Bash单元测试!
  2. Bash和测试驱动开发
  3. 对Bash脚本进行单元测试

注意:我将这个答案从这里迁移过来,那里我不小心创建了一个重复的问答。


1
如果您使用的是Bash,则另一种解决方案可能是:

#!/bin/bash

foo () { ... }
bar () { ... }

[[ "${FUNCNAME[0]}" == "source" ]] && return
code

有趣的想法,但一些解释会很有帮助。看起来这是在寻找调用堆栈顶部内置的源。然而,这只对我有用,如果我使用'.'命令来源脚本,而不是打出“source scriptname”,这似乎很奇怪。 - shawn1874
FYI:FUNCNAME无法处理嵌套脚本,其中一个脚本调用或引用另一个脚本。请参阅我的答案这里这里中的注释和替代方法。 - Gabriel Staples

0

我设计了这个。假设我们的shell库文件是以下文件,名为aLib.sh:

funcs=("a" "b" "c")                   # File's functions' names
for((i=0;i<${#funcs[@]};i++));        # Avoid function collision with existing
do
        declare -f "${funcs[$i]}" >/dev/null
        [ $? -eq 0 ] && echo "!!ATTENTION!! ${funcs[$i]} is already sourced"
done

function a(){
        echo function a
}
function b(){
        echo function b
}
function c(){
        echo function c
}


if [ "$1" == "--source-specific" ];      # Source only specific given as arg
then    
        for((i=0;i<${#funcs[@]};i++));
        do      
                for((j=2;j<=$#;j++));
                do      
                        anArg=$(eval 'echo ${'$j'}')
                        test "${funcs[$i]}" == "$anArg" && continue 2
                done    
                        unset ${funcs[$i]}
        done
fi
unset i j funcs

一开始,它会检查并警告是否检测到任何函数名称冲突。

最后,Bash已经引用了所有的函数,所以它会释放内存并仅保留所选的函数。

可以像这样使用:

 user@pc:~$ source aLib.sh --source-specific a c
 user@pc:~$ a; b; c
 function a
 bash: b: command not found
 function c

~


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接