命令行脚本在很多场景下都会用到,比如自动化任务、流水线、构建镜像等。 本文会结合一些常用命令,记录 POSIX shell(sh)命令行脚本的编写方法与技巧。

POSIX 的全称是 Portable Operating System Interface,定义了操作系统与用户级别 API 的一些标准。 POSIX shell 标准实际上是 KornShell(ksh)的真子集,而 KornShell 则是以 Bourne shell 为基础实现的。

POSIX shell 的解释器一般可以用 /bin/sh,可以在绝大多数 Unix-like 系统里找到。 顺带一提有些系统的 /bin/sh 可能会是一个软链接或者硬链接,会链接到其他解释器,比如常见的 bash 这些。 不过这些解释器一般都要兼容 POSIX shell 的语法。

虽然 sh 已经很古老了,但是它可以在几乎任何系统下使用,可移植性很强,所以还是需要熟悉一些基础用法。

本文会涉及一些代码风格要求,主要参考 https://google.github.io/styleguide/shellguide.html。 注意这个脚本风格指导是针对 bash 的,与本文有所不同。

POSIX shell 的标准可以参考:
https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html

脚本文件

执行 sh 脚本可以直接 sh ./script.sh。 也可以直接当作 executable 执行:./script.sh

如果需要直接执行,脚本文件需要具有读权限与执行权限。 权限可用 ls -l 查看,如果没有执行权限可以这样:chmod +x ./script.sh

文件名命名用 underscore_style,executable 脚本不带后缀名或用 .sh,library 脚本要求带 .sh 后缀。

Shebang

写在脚本第一行,用于指定解释器,比方说 #!/bin/sh

注意到有些脚本会写成类似 #!/usr/bin/env bash 的形式。 这种 shebang 写法不直接指定 bash 的绝对路径,而是要求在 $PATH 中找 bash。 这是为了可移植性,因为不同系统上 bash 的路径会不太一样,比如有些系统会把 bash 放在 /usr/local/bin 里面。 但是对于制定了标准的 POSIX shell 而言,就不存在这种情况,直接用绝对路径 /bin/sh 就行了。

Shebang 其实是指 #!。 因为 # 通常被称为 sharp 或者 hash,而 ! 通常称为 bang。 所以组装一下就叫做 shebang,其实还有很多叫法,比如 sha-bang、hashbang 啥的。

变量

变量定义、赋值与删除

不需要单独定义变量,可以直接赋值,比如 var=1。 注意不要在 = 两边加空格。

对于不需要的变量,可以用 unset 删除变量,比如 unset var

环境变量、只读变量用 ALL_CAPS,并且要求定义在文件顶部。 其他变量命名风格用 underscore_style

变量展开

Variable expansion。

参考:https://unix.stackexchange.com/questions/78914/quoted-vs-unquoted-string-expansion

对于大部分变量,可以用 $ 展开变量再括入双引号内以获取变量的值,比如 echo "The value is: ${var}"

如果不带双引号展开,这个变量的字符串值会被进行两个操作:

  • Field splitting

    对这个变量的字符串值进行 split 操作。 split 的依据是环境变量 ${IFS}
    ${IFS} 的默认值包含空格、制表符和换行符。 特殊字符不便于输入,修改 ${IFS} 可以用这种写法:IFS=$'\b\t\n'

  • Pathname expansion

    把 split 出的每个子串当作一个 pattern 去匹配文件,并用匹配的所有文件作为最终的输出,如果匹配失败将会保持子串原本的值。
    Pathname expansion 默认是开启的,但我们不一定需要它,可以通过 set -fset +f 打开与关闭这个功能。 如果不希望影响全局作用域,那就应当在 subshell 中使用 set -fset +f

比如说对于 files='*.sh *.c',我们用 echo ${files} 不带双引号展开并输出,将会看到当前目录下所有的 .c.sh 文件。 但如果没有匹配的文件,将会看到相应的 pattern 子串本身。 实际使用中这种展开方式常用于循环,比如 for file in ${files}

变量展开还提供了一些语法糖,可以在展开时依据条件对变量做一些修改。 比方说最常见的场景就是,脚本需要获取一个环境变量,但这个环境变量可能未定义或者为空串,所以可以在展开时给它指定一个默认值。 支持的写法如下:

  • ${var:-<cmd>}:输出默认值。

    如果 var 未定义或为空串,将会作为替代去展开 <cmd> 作为展开结果。

  • ${var:=<cmd>}:输出并赋值为默认值。

    如果 var 未定义或为空串,先展开 <cmd> 作为展开结果,再将展开结果赋值给 var

  • ${var:?<cmd>}:指示错误。

    如果 var 未定义或为空串,将 <cmd> 展开结果输出到标准错误流,并以非零值退出脚本。 如果是交互式 shell 则不会退出。

  • ${var:+<cmd>}:输出替代值。

    如果 var 有定义且非空串,将会作为替代去展开 <cmd> 作为展开结果。

举例说明一些常见用法:

# 以字符串作为默认值
echo "${var:-default_value}"

# 展开变量作为默认值
echo "${var:-"${other_var}"}"

# 展开命令作为默认值
echo "${var:-"$(echo 'default_value')"}"

上面写法中的 : 用于决定 var 有定义但为空串情况下的处理方式。 带 : 时空串被认为等同于未定义的情况,均为无效值;不带 : 时空串被认为是一个有效值。 下面提供一个示例作为演示:

# 空串也视为有效值,不替换默认值
var=
other_var="${var-default_value}"
[ "${other_var}" = '' ] && echo "This will print."

# 未定义仍视为无效值,替换为默认值
unset var
other_var="${var-default_value}"
[ "${other_var}" = 'default_value' ] && echo "This will print."

变量展开还可以获取变量中存储的字符串的长度:${#var}

关于代码风格,变量展开时有两种等价的写法:${var}$var

  • 对于自定义的变量,要求使用带大括号的写法,比如 ${var}
  • 对于单字符的位置参数或特殊参数,不加大括号。
    • 位置参数比如 $1$5$9
    • 特殊参数比如 $0$#$@$?$$

环境变量

Environment variable。

export 的变量可以作为环境变量被子进程读取。

举个例子,如果希望在脚本中执行其他子脚本,直接对变量赋值,其他子脚本是获取不到变量的值的。 所以要通过 export 把变量导出为环境变量,这样子脚本中就能获取到变量的值。

可以先对变量进行赋值再设为 export,比如:

ENV_VAR=10
export ENV_VAR

可以在 export 的同时进行赋值,比如 export ENV_VAR=10。 另外,在 export 之后仍可修改变量的值,之后执行子进程获取到的是修改后的环境变量值。

在脚本退出后,export 的环境变量会被销毁。 如果希望在上层脚本或交互式 shell 中保留这些环境变量,可以通过 dot 命令执行该脚本,比如说 . script.sh。 通过 dot 命令执行的脚本会以当前上下文作为执行上下文,因此该脚本对环境变量的修改将会作用到当前上下文。 顺带一提,在 bash 中有一个内建的 source 命令,它与 dot 命令是等价的。 比方说常见的 source .bashrc 其实也可以写成 . .bashrc

只读变量

变量被设为只读后将不可被修改。 只读变量也不可被 unset,除非把 shell 给 kill 掉。

可以先对变量进行赋值,最后再设为只读,比如:

var=10
var="$(( var + 1 ))"
readonly var

也可以在设置只读的同时进行赋值,比如:readonly var=10

位置参数

Positional parameters。

$1$9 是位置参数,分别代表执行脚本时传入的各个参数。 比如 $1 代表第一个参数,$2 代表第二个参数,以此类推。

特殊参数

Special Parameters。

  • $*:传入的各个位置参数,以字符串方式存储
  • $@:传入的各个位置参数,以数组方式存储
  • $#:传入的位置参数数量
  • $?:上一个前台子进程或者 subshell 的退出状态
  • $$:当前脚本进程的 pid
  • $0:当前脚本文件名

退出状态

一个脚本可以视作一个 executable 程序,它会有一个退出状态。 一般退出状态为零代表正常执行,其他取值均为异常值。 退出状态的取值范围是 [0, 255]。 可以通过类似 exit 1 的语句在退出的同时指定脚本的退出状态。 我们在执行该脚本的上层脚本或者交互式 shell 中就能通过特殊变量 $? 获取该脚本的退出状态。 给一个简单的例子:

echo "exit 1" > some_script.sh
sh some_script.sh
echo "$?"

顺带一提,对于 C++ 程序而言,这个退出状态其实就是 main 函数的返回值。 而对于脚本本身或 subshell 来说,它是最后执行的命令的退出状态或 exit 返回的值。 对于形如 { ... } 的复合命令来说,它是最后执行的命令的退出状态。

字符串操作

变量的值与命令的参数都是字符串类型的,比如:echo some_string

引号

但字符串中有可能会包含对于 shell 而言有特殊含义的字符,比如:空格、制表符、换行符、各种引号、各种括号等。 这个时候可以在字符串两端加上单引号或双引号,于是这些特殊字符就能以字面量作为含义。

  • 单引号

    内部所有字符全部保留字面量的值,但单引号中不能出现单引号。
    例如:echo '${var} =' "${var}"

  • 双引号

    大部分字符保留字面量的值,部分字符需要使用反斜杠 \ 转义。 可在双引号内做变量展开与命令展开。
    例如:echo "\"\${var}\" = ${var}"

拼接

直接把两段字符串写在一起就行了。 注意不要在中间加空格,否则会被认为是两段字符串。

提供一个简单示例:

echo '${var} = '"${var}"

长度

可以通过类似 ${#var} 的语法获取字符串变量的长度。

echo 'Length of "${var}" is' "${#var}."

子串处理

POSIX shell 提供几种语法用于截取子串。 它们使用模式匹配标记法,而不是使用正则表达式标记法。

支持的语法如下:

  • ${var%pattern}:通过 pattern 去除 ${var} 的最短后缀。
  • ${var%%pattern}:通过 pattern 去除 ${var} 的最长后缀。
  • ${var#pattern}:通过 pattern 去除 ${var} 的最短前缀。
  • ${var##pattern}:通过 pattern 去除 ${var} 的最长前缀。

举个去除文件后缀名的例子演示一下:

file='file.tar.gz'

if [ "${file%.*}" = 'file.tar' ]; then
  echo "This will print."
fi

if [ "${file%%.*}" = 'file' ]; then
  echo "This wil print."
fi

算术运算

内建的算术运算只支持整数。

var=10
var="$(( var * 10 ))"
echo '"${var}" + 5 =' "$(( var + 5 ))"

要求使用内建的 $(( ... )),避免使用外部的 expr 命令。

浮点数运算

TODO:

正则表达式

TODO:

管道

从左往右,前一条命令的标准输出会作为后一条命令的标准输入。

# A single pipeline
ls | grep ".sh"

# Multiple pipelines
ls \
  | grep ".sh" \
  | grep "some_keyword" \

管道的退出状态会受到 pipefail 选项的影响。 默认 pipefail 是关闭的,这时管道的退出状态就是管道中最后一条命令的退出状态。 当 pipefail 开启后,只有当管道中所有命令的退出状态均为零,管道的退出状态才为零,否则为一。 打开 pipefailset -o pipefail; 关闭 pipefailset +o pipefail; 查看当前选项开关状态:set -o

另外还可以在管道最开始加一个感叹号,将管道的退出状态取反,例如:

! ls | grep "keyword"
echo $?

单条管道不换行,多条管道则需要换行,并使用统一的两个空格作为缩进。

与或列表

与或列表可通过逻辑运算符把命令连接起来,各个命令的退出状态将会作为运算数。 其中与运算跟或运算的优先级相同,结合性是左结合。 与 C++ 语言相同,POSIX shell 的逻辑运算使用短路求值策略。

与或列表的退出状态是列表中执行的最后一条命令的退出状态。

举个例子:

false && echo "This won't print."
echo "$?"

true \
  || echo "This won't print." \
  && echo "This will print."
echo "$?"

跟 lua 语言类似,与或列表可以当作三元运算符使用:

rm "some_file" \
  && echo "Remove succeeded." \
  || echo "Remove failed."

可以在与或列表中使用形如 { ... } 的复合命令或者 subshell。 大括号形式的复合命令记得要在最后一条命令后面加分号。

true \
  && { echo "$?"; false; } \
  || ( echo "$?"; true ) \
  && ( echo "$?" && false ) \
  || echo "$?"

换行风格要求与管道保持一致。

输入输出流重定向

  • cmd > "file.out":将标准输出流重定向到文件。
  • cmd < "file.in":将标准输入流重定向为文件。
  • cmd < "file.in" > "file.out":将标准输入流与标准输出流都分别重定向到各自的文件。
  • cmd >> "file.out":将标准输出流以追加写的方式重定向到文件。
  • cmd 2>> "file.out":将标准错误流以追加写的方式重定向到文件。
  • cmd > "file.out" 2>&1:将标准输出流重定向到文件,并把标准错误流重定向到标准输出流上。
  • cmd >&2:将标准输出流重定向到标准错误流上。

有时会需要输出错误信息,可以这样写:

err() {
  echo "[$(date +'%Y-%m-%d %H:%M:%S')]: $*" >&2
}

if [ -z "${var}" ]; then
  err '"${var}" is empty'
  exit 1
fi

subshell

POSIX shell 没有 bash 里的那种 local 变量。 即使是函数内的变量,作用域也是全局的。 想要模拟局部作用域的效果需要使用 subshell。

Subshell 可以访问外部变量,但它对变量的修改不会影响到外部的作用域。 很简单,直接做个实验吧:

var='global'
echo "${var}"
(
  echo "  ${var}"
  var='subshell'
  echo "  ${var}"
)
echo "${var}"

输出:

global
  global
  subshell
global

Subshell 的退出状态由最后一条命令决定。 也可以在 subshell 中使用 exit 指定。 在外部就可以通过 $? 获取到这个退出状态:

(
  exit 1
)
echo "$?"

命令替换

Command substitution。

可以执行命令或调用函数,将其标准输出获取为字符串。 可以把这坨字符串赋值给某个变量,也可以直接作为另一个命令的参数。

  • dir="$(pwd)" 会通过 pwd 命令输出当前的工作路径赋值到 dir 变量。
  • ls "$(pwd)" 会把 pwd 命令输出的工作路径作为参数传给 ls 从而把路径内的文件都打印出来。

如果需要嵌套,可以用类似这样的写法:some_cmd "$(some_cmd "$(some_cmd)")"。 比如在上面命令的中间再套一个无害的 echo 进去:ls "$(echo "$(pwd)")"。 这种写法不需要使用反斜杠转义。

一些需要注意的地方:

  • 这个小括号实际上就是 subshell。

    因此如果在里面调用函数,函数对变量的修改将不会影响到外部作用域。

  • 命令替换返回的是一个字符串。

    命令替换其实是跟变量展开一样的。 我们用 " 把命令替换的返回值括起来就会得到一个字符串。 不带 " 展开就会跟变量展开一样依次执行 field splitting 与 pathname expansion 操作。 具体可参考变量展开。

    有一种常见的场景是需要把一个命令替换的返回值用变量保存起来,再把这个变量作为其他命令的位置参数传进去。 而命令替换会返回一个字符串,如果这个字符串实际上包含多个参数,一般是用换行符或空格分隔。 这个时候就需要不带 " 对命令替换进行展开。 比如先 files=$(ls),再 cat ${files}

    当然也可以直接把命令替换作为其他命令的输入参数,效果是相同的,比如 cat $(ls)

    需要注意的是展开时 split 的依据是 $IFS,因此需要保证这些参数内不能出现 $IFS 里的字符(比方说最常见的空格),否则这个参数会被 split 成两个参数。 实际上如果有文件名内包含空格,cat $(ls) 是会出问题的。 如果要搜索文件,正确的写法应该是通过 pathname expansion,比如:cat $(echo "*")

要求尽可能用 " 把命令替换括起来(比如 "$(pwd)"),除非有必要。 嵌套在 "$()" 内的双引号不需要反斜杠转义。

不建议使用类似 <cmd> "`<cmd>`" 的写法。 因为如果需要嵌套,就会用到反斜杠转义,可读性差。

任务控制

TODO:

控制流

对于控制流语句,要求把 ; then; do 放在 ifforwhile 的同一行。

缩进统一用两个空格。

循环语句

需要注意变量展开与命令替换,视情况关闭 pathname expansion。

set -f

for arg in $@; do
  echo "${arg}"
done

for i in $(seq 1 10); do
  echo "${i}"
done

set +f

条件语句

if 语句会执行一个复合命令列表,并依据其退出状态进行条件判断:

if ! rm "some_file" || ! rm "other_file"; then
  echo "Remove failed."
else
  echo "Remove succeeded."
fi

test

https://pubs.opengroup.org/onlinepubs/9799919799/utilities/test.html

POSIX 标准提供了 test 工具用于条件判断。 test 能够计算表达式的值,并通过其退出状态表示计算结果。 退出状态为零表示 true,为一表示 false

test 通过命令的位置参数传入表达式中的运算符与运算数:

if test "${var}" = "some_str"; then
  echo '"${var}" is equal to "some_str".'
fi

if test "${var}" -gt 2 && test ! "${var}" -eq 7; then
  echo '"${var}" is greater than 2 and not equal to 7.'
fi

test 工具还有另一种方括号的形式,写法差别不大,比如:[ "${var}" = "some_str" ]。 实际上我们也经常能见到 /usr/bin/[ 这个文件,然后 ls -l 一下可能还会发现这个文件跟 test 就是同一个。 这种方括号形式的实现还是比较巧妙的。 不过要注意,因为 [ 本身就是一个 executable,所以要记得加空格。 列举一些常见写法:

if [ "${var}" = "some_str" ]; then
  echo '"${var}" is equal to "some_str".'
elif [ "${var}" = "other_str" ]; then
  echo '"${var}" is equal to "other_str".'
else
  echo '"${var}" is unknown.'
fi

if [ -z "${var}" ]; then
  echo '"${var}" is empty.'
fi

if [ -n "${var}" ]; then
  echo '"${var}" is not empty.'
fi

if [ "${var}" -gt 2 ] && [ ! "${var}" -eq 7 ]; then
  echo '"${var}" is greater than 2 and not equal to 7.'
fi

提供一下 test 的部分参数列表:

  • -e <path>:路径存在
  • -d <path>:路径是一个目录
  • -f <path>:路径是一个普通文件
  • -h <path>:路径是一个软链接
  • <p1> -ef <p2>:两个路径都存在且为同一文件,支持硬链接与软链接
  • -n <str>:字符串长度不为 0
  • -z <str>:字符串长度为 0
  • <s1> = <s2>:字符串相等
  • <s1> != <s2>:字符串不相等
  • <s1> > <s2>:字典序大于
  • <s2> < <s1>:字典序小于
  • <n1> -eq <n2>:整数相等
  • <n1> -gt <n2>:整数大于
  • <n1> -lt <n2>:整数小于
  • ! <expr>:将 test 命令的最终结果取反

这里有个坑是字典序比较运算用了 ><。 这两个字符直接写会被解释器认为是重定向,所以一般要带上引号,比如 test "${var}" "<" "some_str"

test 的写法比较严谨,但可读性比较弱,建议使用方括号的形式。

函数

函数定义

函数一般通过标准输出配合命令替换输出一个字符串。

count_params() {
  echo "$#"
}

get_the_first_param() {
  echo "$1"
}

echo "Param cnt: $(count_params 1 2 3 4)"

echo "The first param: $(get_the_first_param 1 2 3 4)"

函数实际上还有一个整数类型的退出状态,默认是执行的最后一条命令的退出状态,也可以直接 return 指定退出状态。 比方说可以编写一个函数,它的最后一条命令是 rm 掉某个文件。 如果删除成功,退出状态就是零;如果由于该文件不存在等原因导致删除失败,退出状态就会是非零值。 跟普通的命令一样,这个退出状态也是通过 $? 获取。

remove_file() {
  rm "$1"
}

check_params() {
  if [ "$#" != 0 ]; then
    return 1
  fi
  return 0
}

remove_file "somefile"
echo "$?"

check_params 1 2 3 4
echo "$?"

函数需要集中定义,紧跟在环境变量与常量下方,命名用 underscore_style,左大括号不换行,小括号与函数名间不加空格。 建议在函数中使用 subshell 避免污染全局作用域。

main 函数

sh 脚本其实没有 main 函数的概念,写什么就跑什么。 但是这里可以人为加一个 main 函数给它。 这样做的优点在于可以保证代码的统一性,同样建议在 main 函数中使用 subshell 从而不污染全局作用域。

依据谷歌的脚本风格指导,main 函数要求是最后一个定义的函数。 要求在脚本的最后一条语句用 main "$@" 调用这个 main 函数并把参数传进去。

完整示例

TODO: