POSIX shell 脚本使用笔记
命令行脚本在很多场景下都会用到,比如自动化任务、流水线、构建镜像等。
本文会结合一些常用命令,记录 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="$(echo -ne '\n \t')",注意\n不能放在最后,因为$()展开时会把末尾换行符 trim 掉。 如果是bash的话可以这样写:IFS=$' \t\n'。 -
Pathname expansion
把 split 出的每个子串当作一个 pattern 去匹配文件,并用匹配的所有文件作为最终的输出,如果匹配失败将会保持子串原本的值。
Pathname expansion 默认是开启的,但我们不一定需要它,可以通过set -f与set +f打开与关闭这个功能。 如果不希望影响全局作用域,那就应当在 subshell 中使用set -f或set +f。
比如说对于 files='*.sh *.c',我们用 echo ${files} 不带双引号展开并输出,将会看到当前目录下所有的 .c 与 .sh 文件。
但如果没有匹配的文件,将会看到相应的 pattern 子串本身。
实际使用中这种展开方式常用于循环,比如 for file in ${files}。
变量展开还提供了一些语法糖,可以在展开时依据条件对变量做一些修改。 比方说最常见的场景就是,脚本需要获取一个环境变量,但这个环境变量可能未定义或者为空串,所以可以在展开时给它指定一个默认值。 支持的写法如下:
-
${var:-<word>}:输出默认值。如果
var未定义或为空串,将展开<word>作为替代的展开结果。 -
${var:=<word>}:输出并赋值为默认值。如果
var未定义或为空串,先展开<word>作为展开结果,再将展开结果赋值给var。 -
${var:?<word>}:指示错误。如果
var未定义或为空串,将<word>展开结果输出到标准错误流,并以非零值退出脚本。 如果是交互式 shell 则不会退出。 -
${var:+<word>}:输出替代值。如果
var有定义且非空串,将会作为替代去展开<word>作为展开结果。
举例说明一些常见用法:
# 以字符串作为默认值
echo "${var:-default_value}"
# 以 pathname expansion 作为默认值
echo ${var:-*.sh}
# 展开变量作为默认值
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:当前脚本文件名
注意一下 $@,如果需要使用 for 遍历位置参数,必须使用 for i in "$@" 的写法。
这里如果用不带双引号的 for i in $@ 写法,会导致含空格的单个参数在展开时被 split 为多个参数;
加上双引号的 for i in "$@" 并不会将各个位置参数展开为同一个字符串;
顺带一提,$* 在不带双引号时展开结果会与 $@ 相同,带了双引号后就是展开为一段普通字符串了。
举个例子:
echo_params() {
for i in "$@"; do
echo "${i}"
done
echo
for i in $@; do
echo "${i}"
done
echo
for i in "$*"; do
echo "${i}"
done
echo
for i in $*; do
echo "${i}"
done
}
echo_params "1 2" "3"
输出结果为:
1 2
3
1
2
3
1 2 3
1
2
3
退出状态
一个脚本可以视作一个 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 will print."
fi
复杂处理
对于复杂的字符串处理,需要借助外部命令:
grep用于过滤。tr用于逐字符的处理。sed用于逐行的处理。awk用于更复杂的处理。
这里提供一些常用脚本:
# 将空格替换为换行符
cat "some_file" | tr -s ' ' '\n'
# 将连续的空格替换为单个换行符
cat "some_file" | tr -s ' ' '\n'
# 将文件中第 5 行替换为指定字符串,并写回该文件
sed -i "5 c some string" "some_file"
# 正则表达式查询出文件系统为 ext4 的磁盘
df -T | sed -nE "s#(.*) *ext4.*#\\1#p"
# 使用 awk 查询出文件系统为 ext4 的磁盘
df -T | grep "ext4" | awk '{ print $1 }'
# 正则匹配模式串 some_pattern 的行每五行仅输出一行,其余未匹配行照常输出
cat "some_file" | awk '/some_pattern/{ if (++i % 2 == 1) print; next } 1'
算术运算
内建的算术运算只支持整数。
var=10
var="$(( var * 10 ))"
echo '"${var}" + 5 =' "$(( var + 5 ))"
要求使用内建的 $(( ... )),避免使用外部的 expr 命令。
浮点数运算
TODO:
管道
从左往右,前一条命令的标准输出会作为后一条命令的标准输入。
# A single pipeline
ls | grep ".sh"
# Multiple pipelines
ls \
| grep ".sh" \
| grep "some_keyword" \
管道的退出状态会受到 pipefail 选项的影响。
默认 pipefail 是关闭的,这时管道的退出状态就是管道中最后一条命令的退出状态。
当 pipefail 开启后,只有当管道中所有命令的退出状态均为零,管道的退出状态才为零,否则为一。
打开 pipefail:set -o pipefail;
关闭 pipefail:set +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 放在 if、for、while 的同一行。
缩进统一用两个空格。
循环语句
注意一下 POSIX shell 是不支持数组的,但是可以对 field splitting 跟 pathname expansion 的各个展开结果进行遍历。
for arg in "$@"; do
echo "${arg}"
done
# Field splitting
for i in $(seq 1 10); do
echo "${i}"
done
# Pathname expansion
for file in ./*; do
echo "${file}"
done
条件语句
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<str>:字符串不为空串<s1> = <s2>:字符串相等<s1> != <s2>:字符串不相等<s1> > <s2>:字典序大于<s2> < <s1>:字典序小于<n1> -eq <n2>:整数相等<n1> -gt <n2>:整数大于<n1> -lt <n2>:整数小于! <expr>:将test命令的最终结果取反
注意,如果不带引号展开空串,它将不会被作为参数传入 test。
比方说 test -n $(cat /dev/null) && echo 1 仍然会输出 1;
但 test -n "$(cat /dev/null)" && echo 1 就不会;
此时如果不希望带引号展开,可以使用类似 test $(cat /dev/null) && echo 1 的写法。
这里有个坑是字典序比较运算用了 > 和 <。
这两个字符直接写会被解释器认为是重定向,所以一般要带上引号,比如 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 函数并把参数传进去。