对于习惯了使用高级语言编程的程序员来说,shell 脚本的语法实在是有点原始。但是,shell 脚本开箱即用的特点使得它尤其适合在 Linux 服务器上运行。而例如 Python、NodeJS等语言都需要宿主上安装运行环境才可以使用。

最近出于工作的需要,写了不少 shell 脚本,这里简单记录下编写 shell 过程中的一些实践。

一. 模块化

许多 shell 脚本从头到尾都是命令的堆积,一个脚本上千行,一个命令做了N件事情,到处定义和引用全局变量。

其实 shell 脚本也需要按照功能划分模块,良好的模块拆分带来更好的代码可读性和可维护性,同时也方便测试。

1.1 定义main函数

一个shell脚本,可以像 Python 一样定义一个__main__函数

#!/usr/bin/env bash

func1(){
#do sth
}

func2(){
#do sth
}

main(){
func1
func2
}

main "$@"

这样可以方便的找到程序的入口和退出点。

1.2 功能函数化

正如一个类只负责一件事,在 shell 中一个 function 也应该只做一件事,并且我们要尽可能的将事情的步骤分解为function。

在写 shell function 时,和其他语言不一样,shell 中的变量声明默认是全剧作用域的。所以在定义函数内变量时总是要指定local关键字,如果该变量是只读(readonly)的常量,还可以加上-r

test1() {
local -r var="$1"
var="balabala" # error: var: readonly variable
}

shell 的函数定义不能指定入参名称和个数,也不能定义函数的返回值,参数及返回值的取值是按照下表约定的。

$参数 说明
$0 当前脚本的名称
$1 命令的第一个参数
$2 命令的第二个参数
以此类推
$# 参数的个数
$* 单字符串表示的所有参数
$@ 多字符串表示的所有参数
$? 返回值,0 正常退出

函数的返回值有4种方式返回。

  • 在函数内将返回结果赋值给全局变量。
  • 在函数内将结果通过echo输出到标准输出,由于标准输出是会被shell子进程继承的,所以这种方式支持子进程向父进程返回结果。
  • 在函数内 return 0/1 ,通过$?可以获取return的值。
  • 通过写result文件返回结果,这种思路和赋值给全局变量一样,不过写文件很方便传递多行字符串结果。

1.3 脚本引用

多个脚本之间复用的函数和公共参数可以抽为单独的文件,通过source utils.sh 来引入。不建议使用shexec 的方式调用别的shell脚本,除非你明确知道你在做什么。

二. 错误处理

脚本中如果有运行失败的命令,shell 会默认继续执行,这个默认特性会给错误处理造成麻烦。

例如有时候,我们需要区分系统错误和业务错误:当碰到系统出错,脚本需要停止继续执行;当遇到业务错误,在处理完错误后,脚本可能需要继续执行。下面有几种方式可以控制 shell 的错误处理逻辑。

2.1 使用短路逻辑运算符

command || exit 1

等价于

command
if [ "$?" -ne 0 ]; then echo "command failed"; exit 1; fi

如果command正常退出,返回0,|| 运算符右半部分被短路,脚本继续执行。
如果command异常退出,返回非0, 运算符右半部分执行,脚本exit 1

2.2 使用 set -e

set -e 等价于-o errexit,表示打开 shell 脚本的错误退出选项,当脚本运行出现非零返回值时,不再继续运行下面的命令。

set +e 表示关闭返回值判断选项,总是继续运行出错命令后面的命令。

配合这两个条命令,可以灵活的控制 shell 脚本处理业务错误和系统错误。

2.3 使用 trap 捕获信号量

shell 脚本在运行期间会产生三种“伪信号量”,通过 trap ‘command’ signal 可以捕获这些信号。

信号名 何时产生
EXIT 从一个函数中退出或整个脚本执行完毕
ERR 当一条命令返回非零状态时(代表命令执行不成功)
DEBUG 脚本中每一条命令执行之前

其中监听 ERR 可以在使得我们在 shell 出错后,有机会做一些比如清理工作现场、发送错误、报警日志到IM等工作。

三. 调试

shell 脚本没法直接调试,只能依靠print大法(有个开源工具bashdb,可以单步调试shell,对于大型的shell脚本可能有帮助),因此一个清晰的日志可以帮助排查很多问题。

3.1 log 日志

  • 按照日期时间组织log文件
  • 标准输出到terminal时,使用color
  • 使用 tee 在标准输出的同时重定向到文件
LOG_LEVEL_ALL=-1
LOG_LEVEL_DEBUG=100
LOG_LEVEL_INFO=200
LOG_LEVEL_WARN=300
LOG_LEVEL_ERROR=400
LOG_LEVEL_OFF=9000

if [[ abc${LOG_LEVEL} = "abc" ]]; then
LOG_LEVEL=${LOG_LEVEL_ALL}
fi

if [[ abc${LOG_4_SH_DIR} = "abc" ]]; then
LOG_4_SH_DIR=${WORK_PATH}/logs
fi

# 根据日期获取当天日志名称
function getLogFile() {
if [[ ! -e ${LOG_4_SH_DIR} ]]; then
mkdir -p ${LOG_4_SH_DIR}
fi
local logDate=$(date +"%Y%m%d")
local todayLogFile=${LOG_4_SH_DIR}/log_${logDate}.log
if [[ ! -e ${todayLogFile} ]]; then
touch ${todayLogFile}
echo "############################ $(date +%Y-%m-%d) #######################" >> ${todayLogFile}
fi
echo ${todayLogFile}
}

function debug_color_print {
echo -e "\033[0;32m$1\033[0m"
}

function logDebug() {
if [[ ${LOG_LEVEL} -le ${LOG_LEVEL_DEBUG} ]]; then
local message=" [ "$0" ] (DEBUG) "$*
debug_color_print "$message"
echo ${message} >> $(getLogFile)
fi
}

$0 代表当前shell脚本的名字
$LINENO 代表shell脚本的当前行号
$FUNCNAME[] 代表当前命令所属的函数名,通过遍历$FUNCNAME[]数组可以知道当前的函数调用栈。

3.2 set -x

set -x 进入跟踪方式,显示所执行的每一条命令。配合trap command DEBUG可以在显示执行命令前打印一些有用的帮助信息。

通过设置export PS4='+{$LINENO:${FUNCNAME[0]}} '还可以改变set -x模式下的输出格式,可以在每一条实际执行的命令前面显示其行号以及所属的函数名。

四. 一些有用的code snipper

4.1 获取当前工作目录

function get_working_dir(){
local CURRENT_DIR=$(pwd)
local WORK_DIR=$(dirname $0)
cd ./${WORK_DIR}
WORK_DIR=$(pwd)
cd ${CURRENT_DIR}
echo ${WORK_DIR}
}

4.2 json处理

简单的 json 的处理,可以直接使用awk,sed、grep。如果有Python 环境,还可以用Python来帮助解析。

repo_id=$(echo $repo_info_response | python -c "import sys, json; print(json.load(sys.stdin)['id'])")

不过最终还是推荐直接使用 jq 。jq提供了一套完整的命令行环境下的json解析功能。使用jq时,如果有语法不熟悉的,可以用这个网站测试—— https://jqplay.org

  • json decode
# 取 { "id": "value"} 中的id
repo_id=$(echo ${repo_info_response} | jq -r '.id')

# 取 [ { "web_url": "value"} ] 中的数组第一个
mr_iid=$(echo ${mr_iid_response} | ${JQ} -r '.[0]')
# 取 [ { "web_url": "value"} ] 中的数组第一个对象的web_url
mr_web_url=$(echo ${mr_iid_response} | ${JQ} -r '.[0].web_url')
  • json 数组遍历
for row in $(echo "${response}" | ${JQ} -r '.[] | @base64'); do
_jq() {
echo ${row} | base64 --decode | ${JQ} -r ${1}
}

local language_repo_id=$(_jq '.id')
local language_repo_name=$(_jq '.name')
done

注意这里 base64 是用来将json数组中的某一个多行元素转化为一行输出,方便 for 循环处理。

  • json encode
message_json="$(${JQ} -nc --arg str "$message_text" '{"text": $str}')"

上面的代码将$message_text encode输出到{"text":"message_text"}中。

五. 参考链接

http://www.ruanyifeng.com/blog/2017/11/bash-set.html
https://www.ibm.com/developerworks/cn/linux/l-cn-shell-debug/index.html