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=$'\b\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:-<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
开启后,只有当管道中所有命令的退出状态均为零,管道的退出状态才为零,否则为一。
打开 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
的同一行。
缩进统一用两个空格。
循环语句
需要注意变量展开与命令替换,视情况关闭 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: