前言
作为互联网从业者,经常需要和Linux打交道,当然不可避免的也要写一些shell,无论是进行CI/CD流水线搭建、数据处理、抑或是进行系统管理,随处可见大量shell脚本的影子。shell有一个很大的特点,也不知道该称之为优点还是缺点,就是它的语法相当灵活,100个人中就有100种写法或是代码风格,究竟该怎么写,可能会逼死强迫症。
为什么是“可靠shell”呢?大家可能都有过shell语言在某些情况下造成的“灾难”,也许是因为我们使用了非预期的变量、产生了非预期的返回,却没有及时判断处理导致了不可预知的问题。
于是花了些功夫查阅资料,给团队做了一次分享,始有此文。在这里,笔者无视那些众多的shell解释器,就以Linux标准的bash为例,斗胆整理了了一份关于shell的部分编写建议作为自己写shell时的参考指南,还请各位读者抱着批判性思维来审阅。
开头指定bash
指定bash的方式有很多,不过建议大家使用下面两种中的一种:
有几点说明:
1、运行./a.sh时,当没有指定shebang时,就会默认用$SHELL指定的解释器,否则就会用shebang指定的解释器
2、#!/bin/bash 的方式限制了代码注入的可能,在某些情况下更安全
3、#!/usr/bin/env bash 的方式通过添加env中间层,使得可以在$PATH中搜索bash,提供灵活性、适应性
用双引号包围变量
考虑如下代码段:
运行会报错,因为等号前后字符串个数不一致。正确的做法是如下代码:
要小心命令行参数中的空格。如果变量要放到if语句中,最好用双引号包围,其他情况下,包围变量也是一个不错的实践。当然,在双引号中继续用{}大括号包围变量,比如"${filename}" ,也是推荐的写法。
全部代码进函数
建议除公共部分外,所有的代码都封装进函数,即使只有一个函数,也定义一个main函数。
最常见到的不规范的写法就是大家的shell全都不在函数中,一条条命令顺序执行(笔者以前也经常这么写)。
定义函数有几种方式:
建议使用标准写法(第一种)。Shell函数在定义时不能指明参数,只在调用时可以传递参数,且传递什么则接收什么。上面的"$@"则是接收命令行参数的写法。
使用readonly定义常量
使用readonly修饰的变量定义会变成只读变量,无法在脚本中被修改,更加安全。
关注变量作用域
1、Shell中默认变量作用域为全局(无论定义在外层还是函数中)
2、强烈建议定义变量时用local、readonly修饰(定义在函数内),有充足理由时可以使用declare(如需定义整型变量)
3、如果必须定义全局变量,则建议全局变量大写
警惕未被初始化的变量
如果运行上面的脚本,参数为空的话,你的根目录的data目录就被删掉了。可以使用nounset标志来防止这种意外情况的发生:
1、set –o nounset的另一种表达方式:set -u
2、当使用了未初始化的变量时,设置set -o nounset,可以让程序强制退出
当然,上面例子只是为说明问题,脚本可不能这么写,太危险。
让代码执行可追踪
不多说,使用set -o xtrace可达到该目的,将每行的执行命令输出。也可以简写为set -x。常用于调试场景,也可以在执行shell时使用sh -x 的方式调试脚本。
防止错误滚雪球
假如这么写shell(假设a.txt不存在):
那么仍然会尝试删除a.txt。
如果我们想判断下上一步的执行结果再决定下一步行动,常用的做法可能是这样:
我们通过$?的值来判断上一步的状态,进而决定是继续删除还是直接退出。
还有另外一种方式也可以达到目的:
1、set -o errexit的另一种表达方式:set -e
2、使用set -o errexit,一但有任何一个语句返回非0值,则退出bash,从而尽早捕获错误
3、此时无法使用$?获取命令执行状态,因为bash无法获得任何非0返回值
4、如果需要让程序即使出错也继续执行,可以在可能出错的语句追加" || true"
学会查路径
强烈建议在脚本开头定义基础目录,如下:
1、基于当前脚本执行路径,指定其他路径;
2、在每个脚本前设置当前工作区、脚本名、工程根目录的只读变量是一个好习惯
3、让脚本在任何目录下都可以正常执行(脚本中所有位置全部使用决定路径,尽量少使用相对路径;)
巧用shift
上面的func2使用了shift,使得所有命令行参数都可以通过$1读取。再举个更实用的例子:
假设该文件命名为test.sh,我们运行时使用:sh test.sh --file a.txt --module module_a 的方式,程序就可以精确获取到每个参数,用于后续的逻辑处理。
封装一些常用指令
假如我们经常需要检查命令执行状态,就可以封装一个函数:
以后在需要的地方调用该函数即可。
提供help信息
不多说,脚本最好提供一个help函数,当用户输入参数异常时能够及时给出反馈。
切换目录的几种方式
假如我们需要临时在某个路径下执行一些指令,为了不改变主程序的执行路径,可以有几种方式:
巧用trap信号
trap func EXIT允许在脚本结束时调用函数,用它注册清理函数。
让脚本可以单独运行任意一个函数
如上编写的脚本(假设test.sh),我们在运行的时候,可以使用sh test.sh --eval start 来单独运行start方法。
一些额外的小tip
1、在条件判断时,尽量使用双中括号"[["而非单中括号"["。单中括号是一个Linux命令,每次使用都会fork一个子进程。双中括号是shell关键字,更加强大,可完全替代单中括号
2、判断时,有个小技巧:[[ "z${var}" = "z" ]] 加入任意前导字符(此处是z)可以防止var变量为空时脚本报错
3、利用/dev/null过滤不需要的输出信息:$ command > /dev/null 2>&1
4、变量可以习惯性使用{}包围,以防意外情况,且用双引号包围是个好习惯,如 "${var}"
5、把then,do等和if、while或者for写在同一行,不换行
6、一行太长时使用 \ 进行换行,换行原则是整齐美观
7、禁止直接操作$1、$2等参数,除非这些变量只用一次
8、整数运算使用$(()),如 echo $((3+4)) ;小数运算使用bc计算器,如 echo "scale=2; 5/3" |bc
9、尽量使用$()将命令结果赋值给变量,而非使用反引号
10、尽量使用绝对路径,不易出错
11、shell脚本main函数接收参数时,尽量使用main "$@" 的形式。以下是各种形式传参的结果:
……
你的脚本可以这样开始
后记
shell作为一门很灵活的语言,编写的规范性也越发显得重要。可靠性作为软件质量模型中的第二个特性,很多时候都体现在代码层面。文章稍长,为了加深印象,使用了较多代码段,提到的建议也只是冰山一角,建议感兴趣的读者有机会自己写代码尝试下,也欢迎提出宝贵意见。