Shell书写规范

很多时候在 shell 脚本的编写过程中大家对于语法和编码都是比较随意的, 对于临时性的脚本并不太需要很关注风格规范, 但是如果长期维护的脚本, 没有统一规范会带来很多后期维护上的不便, 这里可以给出一些编写建议以供参考.

脚本文件命名

要求:

对于 Linux shell 脚本的命名统一使用 .sh 后缀结尾. (windows 环境下批处理程序统一使用 .bat 作为脚本后缀名.)

请使用易于识别的文件名与后缀名, 以方便识别和执行权限检查, 优先使用脚本用途为脚本命名, 请避免使用无意义的字母与没有关联性的单词对脚本文件名命.

脚本解释器 shebang 信息

bash 脚本通常都可以采用 bash script-path/script-name.sh 的方式手动运行. 不同的发行版中 shell 默认的解释器有时是会变化的, 正确的解释器是脚本正常执行的基本, 因此给出明确的解释器信息是十分必要的, 这也是一份规范的可执行脚本必须满足的基本要求.

说明:

#!sharpbang , 也就是 shebang . Shebang 这个符号通常在 Unix 系统的脚本中第一行开头中写到,它指明了执行这个脚本文件的解释程序.

  1. 如果脚本文件中没有 #! 这一行,那么在执行时会默认使用当前shell去解释这个脚本(即$shell环境变量).
  2. 如果 #! 之后的解释程序是一个可执行文件,那么执行这个脚本时, shell 就会把文件名及其参数作为参数传给标示的解释程序去执行.
  3. 如果 #!指定的解释程序没有可执行权限,则会报错 ‘bad interpreter:Permission denied’ (拒绝访问,也就是没有权限).
  4. 如果 #! 指定的解释程序不是一个可执行文件,那么指定的解释程序会被忽略,转而给当前的shell去执行这个脚本.
  5. 如果 #! 指定的解释程序不存在,那么会报错 ‘bad interpret : No such file ordirectory’, 注意: #! 之后的解释程序,需要写其绝对路径(例如:/bin/bash),他是不会自动到 $PATH 中寻找解释器的.
  6. 如果明确指定使用的 ‘bash script_name.sh’ 这样的命令来执行脚本, 那么 #! 这一行将被忽略,解释器将使用命令行中显示式指定的 bash解释器.

要求:

对于需要多次重复使用的脚本中须尽量在脚本的第一行指定解释器信息.

查看本地支持的解释器可以使用 cat /etc/shells

示例:

#!/bin/sh
#!/bin/bash
#!/usr/bin/awk
#!/bin/sed
#!/usr/bin/tcl
#!/usr/bin/expect
#!/usr/bin/perl
#!/usr/bin/env python

对于 bashsh 的使用上推荐使用 bash. 使用 bash 执行可以保证 shell 中的扩展语法正常工作, 如增量赋值, 特殊变量展开语法等在部分系统的 sh 解释器中是不支持的, 这将导致脚本不可预知的执行异常.

部分系统的 sh 为 bash 的软链接, 对于已经验证正常的脚本也允许使用 sh 解释器.

脚本中不推荐下面的写法

#!/usr/share/env bash

建议使用 /bin/ 下的启动文件而非程序的安装路径, 不同版本 Linux 系统, 默认的 env 命令可能安装在不同的系统路径中, 此处的写法具有较弱的通用性.

脚本注释

头部注释

脚本的头部注释通常需要将脚本的后续维护追溯信息进行备注, 建议包含以下信息

  1. 作者(与联系方式)
  2. 写作与修改日期
  3. 脚本的用途
  4. 脚本注意事项
  5. 脚本版本
  6. 开发/执行平台版本
  7. 关联开源软件协议/版权

示例

###########################################################
# Author  : william mei
# Date    : 20181010
# version : cfiojobs0.12.53
# Test platform:
#               kernel     : 3.10.0-514.26.2.el7.x86_64
#               OS release : CentOS 7.3.1611
#               Shell type : GNU Bash-4.2
# description  :
#               send files/command to multy host 
#               parallel fio test on clusters 
# last edit :   20181225
###########################################################

提示:开发环境下可通过配置 ~/.vimrc 配置文件将脚本自动加上以上信息.

注释通用要求

  1. 对于关键执行逻辑请务必说明上下文关联.
  2. 独立函数需要注明是否有全局变量依赖, 参数格式, 输出结果, 返回值.
  3. 非交互式环境中外部环境变量依赖须在脚本头部注释后给出依赖的变量内容与格式说明.
  4. 脚本中的参数传递与接收处理部分务必标注 位置 内容与类型, 并带有参数使用示例.
  5. 对变量命名用词不能完全保证其清晰准确并可读的情况, 复杂变量拼接应尽量使用结果示例作为注释进行标注
  6. 尽量使用英文注释, 防止切换系统环境后出现中文乱码问题.
  7. 尽量将注释信息独占一行, 保持与前后代码的同级缩进

变量命名规则

  1. 变量名必须是以字母或下划线 ‘_’ 开头, 后面跟字母 数字或下划线.
  2. 变量名中间不能有空格, 可以使用下划线连接.
  3. 普通变量一般统一使用小写字母加下划线, 请避免驼峰命名法与下划线混用.
  4. 变量名不要使用问号 ‘?’ 星号 ‘*’ 或其他特殊字符.
  5. 变量名不能使用标点符号, 更不能使用 bash 里的关键字, 例如:if, for, while, do等关键字.
  6. 变量名的命名尽量要有实际意义, 具有明确的内容指示性, 复用性较高的变量避免使用单字母命名.
  7. 变量名中的单词尽量保证拼写正确, 必要的情况下请进行检查后再使用, 不要拼错
  8. 自定义变量尽量不要和系统环境变量冲突.
  9. 函数内局部变量最好使用 local 进行定义, 以避免命名污染.
  10. 联合数组与指数数组都尽量使用大写单词或词组命名.
  11. 外部引入的环境变量或关键全局变量须定义在脚本开头, 以大写字母命名并配置变量检查和使用注释.

参数处理

脚本或脚本中的函数进行参数接收时应遵循以下规范

  1. 格式检查, 当脚本或函数需要接收参数时, 应尽量对参数进行格式规范判断(参数个数, 内容正则匹配检查等), 允许的情况下给出日志输出或者控制台回显.
  2. 位置参数重新赋值, 当脚本或函数对参数较多与处理步骤较多或者调用位置代码跨度较大的情况, 应尽量将接收到的参数赋值为具有实际可读性的变量名, 避免沿用原有数字序号.
  3. 函数中的参数如需赋值到临时变量须优先使用局部变量(如 local declare等), 若使用全局变量请给出注释以说明
  4. 脚本模块化拆分时, 拆分到独立文件公共函数应尽量以纯函数的方式编写, 尽量全部采用局部变量进行处理参数

字符编码

脚本编写时尽量使用 UTF-8 编码, 脚本的代码与标点符号必须为英文, 注释内容允许使用少量中文但整体应尽量保持以英文为主, 日志与控制台输出须尽量采用英文输出, 以避免不必要的编码问题.

跨平台编写脚本时, 若未开启 git 等代码版本管理软件的自动换行符转换(默认开启), 应避免直接使用 windows 系统换行符.

日志与控制台回显

在非临时性脚本的编写中, 须保证脚本的日志输出或控制台回显二者当中至少有一种包含了执行的关键纠错信息, 也可以两者都输出, 如 main |tee -a $logfile .

对于日志和关键输出, 请尽量带有可识别的日志级别标示, 非关键内容则允许过滤处理或者重定向到其他临时输出.

日志级别参考:

  1. DEBUG 指出细粒度信息事件对调试应用程序是非常有帮助的, 主要用于开发过程中打印一些运行信息.
  2. INFO 消息在粗粒度级别上突出强调应用程序的运行过程. 打印一些你感兴趣的或者重要的信息, 这个可以用于生产环境中输出程序运行的一些重要信息, 但是不能滥用, 避免打印过多的日志.
  3. WARN 表明会出现潜在错误的情形, 有些信息不是错误信息, 但是也要给程序员的一些提示.
  4. ERROR 指出虽然发生错误事件, 但仍然不影响系统的继续运行. 打印错误和异常信息, 如果不想输出太多的日志, 可以使用这个级别.
  5. FATAL 指出每个严重的错误事件将会导致应用程序的退出. 这个级别比较高了. 重大错误, 这种级别你可以直接停止程序了.

为了更加清晰的指示执行状态或结果, 脚本允许增加其他自定义日志级别与其他自定义内容输出标示, 但应与非关键输出做出显示上的区分处理, 如颜色, 闪烁等.

代码范式

针对脚本中的代码范式规范如下:

代码换行

单行代码建议在80个字符以内, 当单行代码过长的情况下, 如在调用程序或函数需要处理较长的参数时, 为了控制单行代码长度, 可以使用反斜杠进行分行处理, 如:

./configure \
–prefix=/usr \
–sbin-path=/usr/sbin/nginx \
–conf-path=/etc/nginx/nginx.conf \

注意, 反斜杠前须保留一位空格.

当函数的行数超过100行, 请考虑对函数进行拆分.

代码缩进

对于脚本代码缩进, 我们允许使用以下 3 种缩进单位

  1. 双空格 缩进
  2. 四空格 缩进 (推荐)
  3. tab 制表符 缩进

推荐使用 四个空格 作为标准缩进单位, 同一份脚本中不允许混用多种缩进单位, 同项目中的脚本不允许混用多种缩进单位.

制表符仅针对于遗留脚本进行修改的情形, 若其原有缩进使用制表符则遵循其原有的缩进规则, 新编写的脚本请避免使用制表符.

绝大多数编辑器都支持进行代码的 soft tab 配置, 也就是使用 n 个(通常为2或4个) 空格替代 制表符进行缩进.

变量引用

脚本中的变量引用允许使用一下几种形式:

  1. $parameter 方式
  2. ${parameter} 方式
  3. "$parameter" 方式 (推荐)
  4. "${parameter}" 方式 (推荐)
  5. $"parameter" 方式 (特殊情况允许, 不推荐)

推荐对所有引用的变量添加引号, 以避免赋值动作以外的其他解释错误和不可预知的执行后果, 比如变量中包含分隔符和正则匹配字符的情形.

一般情况下允许前 4 中方式混用, 在非特殊用途的变量展开表达式以外应尽量避免 $"parameter" 的引用写法, 它在不同的发行版中(由解释器关联的系统 POSIX 接口所决定)可能会有不同的展开结果.

推荐在进行变量拼接的过程中使用 ${parameter}"${parameter}" .

数据传递与变量作用域控制

说明:

shell 的变量默认是以全局变量的方式被定义的, 但支持以局部变量的方式在函数中限制变量的作用域.

shell 脚本通常是过程式的, 无法以对象的方式抽象数据和方法, 代码的复用通常是以编写函数的方式进行的, 而函数的数据传递方式对代码的整体可读性和健壮性有较大影响, 在 shell 中常见的数据传递方式有:

  1. 全局变量方式传递
  2. 环境变量方式传递 (不推荐)
  3. 参数方式传递
  4. 管道方式传递
  5. 文件描述符
  6. 信号捕获
要求:

这里, 对于以上几种方式在都允许使用, 但多种方式都能达到同样效果的情况下, 我们建议采用如下选取原则.

调用子脚本时, 允许使用环境变量向子 shell 传递数据, 但更推荐为子脚本添加参数接收支持的处理方式.

首先, 不推荐采用环境变量方式向函数子进程传递数据, 因为这让整个数据传递的过程变得不够清晰, 可读性也相对较差, 若命名也不够清晰规范将大幅度增加排障难度.

其次, 函数中若使用局部变量没有增加较大编码负担的情况下, 应避免使用全局变量, 减少与外部的关联可以很大程度上提高函数代码的稳定性, 推荐使用纯函数进行相关复用代码的编写, 这可以在一定程度上提高代码的健壮性和可读性.

函数中的变量处理优先使用局部变量, 采用如, local declare 等方式进行声明.

  • 环境变量

在脚本中多处使用的环境变量应在头部注释后进行声明(大写字母加下划线命名, 并注释其内容类型加示例).

除了调用程序的特殊流程处理需要(如配置部分编译器的编译环境), 应尽量避免制造过多临时性的环境变量.

如果脚本中没有调用对环境变量有特殊要求的应用程序应尽量避免使用环境变量传递数据.

  • 只读变量

特殊变量如版本号字符串等可以采用 readonly 方式限制为只读, 以避免被修改.

变量展开与字符处理程序调用

在 shell 中针对变量的字符串截取等操作可以有多种实现方式, 最典型的两种是

  1. 内置变量展开表达式, 如 ${parameter:offset:length} ${parameter/pattern/string}
  2. 调用外部字符处理工具, 如, grep sed awk cut

对于相对简单的当字符串的处理, 若两种方式均可实现的情况下, 优先采用内置变量展开, 如

#!/bin/bash
# example of Parameter Expansion

# 1. 变量字符替换
para1="hello_john"

# Parameter Expansion
result1="${para1//john/jack}"
# use sed
result2="$(echo $para1|sed 's/john/jack/g')"

# 2. 剔除路径,获取文件名
full_path="file/path/file_name"

# Parameter Expansion
result3="${full_path##*/}"
# use awk
result4="$(echo $full_path| awk '{print$NF}')"

对于字符处理频度较低的脚本, 两种方式均可.

但在需要频繁字符处理的情况下, 请优先使用内置变量展开方式进行处理, 内置的展开表达执行非常快速, 其耗时几乎可以忽略不计, 并且还可以避免字符处理程序调用带来的大量外部进程, 显著提高脚本的运行速度.

命令输出置换

在 shell 脚本中, 命令置换语法通常有两种形式:

  1. ` command` 双反引号 (尽量避免)
  2. $(command) 美元符加圆括号 (推荐)

这里如果没有特殊需求, 请尽量使用美元符加圆括号的写法.

旧式的双反引号语法有转译符号的特殊处理, 尤其是反斜杠自身的转译, 因此对于引用和字符转译处理并不友好, 如果脚本中的命令存在多层转译会导致可读性变差, 并且将增加 debug 以及维护难度.

流程控制语句

下面两种写法的流程控制语句都是允许的:

如, 将条件判断语句与后面的关键词写在一行的情形

if [[ condition ]]; then
    #statements
fi

或, 将条件判断语句独立一行, 下一个关键词另起一行的写法

if [[ condition ]];
then
    #statements
fi

两种都是允许的, 但是同一种风格的语句尽量不要来回变换, 在同一份脚本中, 请务必保持使用一种风格的写法.

建议, 若脚本中的条件判断语句长度大多都不超过一行的情况下, 尽量使用第一种写法.

示例:

# for 关键词遍历
for name in word ... ; do
    list
done
# C 式for循环
for (( i = 0; i < 10; i++ )); do
    #statements
done
# case 关键词遍历
case word in
    pattern )
        ;;
esac
# select 关键词遍历
select name [ in word ] ; do
    list
done
# while 条件循环
while [[ condition ]]; do
    #statements
done
# until 循环
until [[ condition ]]; do
    #statements
done

文件路径

在脚本的文件路径处理中, 须遵循绝对路径优先原则.

除了 git 等需要相对路径工作的程序外, 脚本中的位置处理应尽量使用绝对路径.

示例:

script_dir=$(cd $(dirname $0) && pwd)
script_dir=$(dirname $(readlink -f $0 ))

脚本优先使用脚本执行路径作为路径依据, 即 $0 所在的位置.

避免直接使用当前 shell 的执行路径, 即 pwd 获取的路径.

条件判别表达式

条件判别表达式通常有以下 3 种常见写法:

  1. [[ condition ]] 双中括号 (推荐)
  2. [ condition ] 单中括号
  3. test condition 使用 test 判别表达式

以上 3 种写法中, 进行变量值的判别时, 我们应优先使用 [[ condition ]] 的写法, 因为双中括号可以有效避免条件表达式的过度展开, 这可以避免空变量导致的脚本异常.

示例:

# 字符串为空
s=
[ $s == a ] || echo "not match"
# 结果异常
bash: [: ==: unary operator expected

双中括号

# 字符串为空
s=
[[ $b == a ]] || echo "not match"
# 结果正常
not match

在含命令置换的条件判别表达式中, 如 [[ $(func_name args) == 'str' ]] , 此时, 双中括号将不会调用嵌套的多层函数, 如果表达式有多层函数嵌套(虽然也不建议这种写法), 需要使用单中括号才能将嵌套函数正常执行.

注意, 部分 shell 并不支持 双中括号 语法, 因此请务必确保 shebang 信息正确, 并使用正确的解释器执行(参考开头的说明).

命令逻辑运算符

shell 脚本中在单行命令长度较短, 判别逻辑简单的情况下 (不含使用 elif 的情形), 使用命令的逻辑运算符可以简化判别逻辑, 使代码更加简洁, 推荐使用逻辑运算符代替 if ... else ... 的写法.

示例:

# 简单 if 判断
if [[ condition ]];then
    command1
else
    command2
fi
# 等价于
[[ condition ]] && command1 || command2

# 连续 And
cmd1 && cmd2 && cmd3

# 连续 Or
cmd1 || cmd2 || cmd3

命令逻辑运算符不能过度使用, 以下情形建议继续使用 if ... else... 或其他流程控制写法.

  1. 采用逻辑运算符后无法在一行内完成代码书写的情况
  2. 单行代码包含不止一种逻辑运算符, 且数量超过3个的情况

函数定义

示例:

# 优先使用显示定义
function func_name(){
    local ret_value
    command
    return $ret_value
}
# 允许
func_name(){
    local ret_value
    command
    return $ret_value
}
# 允许
function func_name {
    local ret_value
    command
    return $ret_value
}

屏幕回显处理

在主动生成新的回显信息的时候通常有两种内置命令可供选择 echoprintf

示例:

# 显示带有红色加粗标识的错误提示
echo -e "\e[1;31m[ERROR] file not found \e[0m"

# 格式化输出
printf "%-10s %-8s %-24s\n" id name url  

这两种方式中, 在简单的回显可以使用 echo 进行处理, 但是格式化输出内容应优先使用 printf , 其功能也更加丰富, 并且绝大多数 shell 的printf 都与 C 程序库中 printf() 相一致, 遵照统一的 POSIX 规范, 脚本可以有较好的移植性.

注意, 一般情况下如果可以通过 prinf 格式化输出的内容, 应尽量避免手动使用空格进行回显的格式化.

其他写法建议:

  1. 处理并发任务时避免使用管道向循环结构体传值.
  2. 使用 fd 控制并发处理时, 子进程异常处理需要保证读取的值被放回队列.
  3. 当需要大量 sedgrep 组合才能完成字符处理的场景, 请考虑完全使用 awk 完成任务.
  4. 避免通过对 ls 的输出处理来获取数据, ls 的结果会带来不确定行, 并且与平台相关.
  5. 尽量为编写的函数配置返回值, 以判断执行状况.
  6. 外部调用与内置命令均能完成任务时, 内部命令优先.
  7. 完成同样的内容请尽量减少使用的命令数, 如 cat 加 管道的处理方式.
  8. 如果有需要 source 子脚本才能正常执行的情况, 请考虑将它们写成函数.
  9. 使用 mktemp 生成临时文件或者临时文件夹, 并在结束后清理.
  10. 使用 /dev/null 过滤无用或其他不友好的输出信息.
  11. 脚本中需要使用文件之前, 应先判断文件是否存在,否则应做好异常处理.
  12. 会使用trap捕获信号,并在接受到终止信号时执行一些收尾工作

书写建议:

  1. 成对内容的一次写出来,防止漏写.例如:{},[],'',``,"".
  2. []中括号两端都要有空格,书写时可留出空格,然后退格书写内容.
  3. 流程控制语句 (如, if [[ condition]] ;then command ; fi ) 一次书写完,在添加内容.
  4. 使用专门的编辑器进行脚本编写(避免留下 windows 文本 BOM 头编码).
  5. 使用静态编码检查工具.