原文地址
Shell是用户与Linux或Unix内核通信的工具,shell编程指的并不是编写这个工具,而是指利用现有的shell工具进行编程,写出来的程序是轻量级的脚本,我们叫做shell脚本。
Shell的语法是从C语言继承过来的,因此我们在写shell脚本的时候往往能看到c语言的影子。
Shell脚本实在是太灵活了,相比标准的Java、C、C++ 等,它不过是一些现有命令的堆叠,这是他的优势也是他的劣势,太灵活导致不容易书写规范。本人在写shell脚本的过程中形成了自己一些规范,这些规范还在实践中,在此分享出来,以期更多的人来帮助我完善。
一些收集整理的shell脚本,在此项目的基础上大家可以轻松的构建自己的shell脚本,希望多多提意见
https://github.com/chen-shang/baseshell
首先来了解一下shell
关于命名
- 命名只能使用字母,数字和下划线,首个字符不能以数字开头。
- 中间不能有空格,不能使用标点符号,不能使用汉字,可以使用下划线
_
,所以我们往往使用_
作为分词的标识 例如 user_name、city_id 等等 - 不能使用bash里的关键字(可用help命令查看保留关键字)。
- 脚本中的所有变量风格统一使用下划线命名风格。(不强制,视情况而定)
统一的风格是好的编程习惯的开始,这样程序给人一种清爽的感觉,至于使用
驼峰
格式还是使用下划线
格式,仁者见仁智者见智。
local userName="chenshang"
local user_name="陈尚"
function log_info(){
# todo
}
function logInfo(){
# todo
}
function get_user_info(){
# todo
}
function getUserInfo(){
# todo
}
对比一下就感觉 userName
比 user_name
简洁; 函数名 log_info
比 logInfo
更加贴切,
我们Java中打印日志的时候一般这样写 log.info("")
或者 log.error("")
,所以在shell中使用 log_info ""
这种写法更像Java的习惯; getUserInfo
比 get_user_info
更加紧凑,使用起来更像Java的函数命名风格。
虽然这样,但是本规约还是规定使用下划线风格(特殊情况特殊处理,不强制),因为你可以看看Linux自带的一些脚本,定义变量的时候都使用下划线分割。
关于首行
- 使用#!/usr/bin/env bash
我们往往看到大多数shell脚本的第一行是 #!/bin/bash
这句话,当然也有 #!/bin/sh
、#!/usr/bin/bash
,这几种写法也都算是正确,当然还有一些野路子的写法,为了避免误导这里就不示例了。本shell规约并不推荐使用上面的任何一种,而是使用 #!/usr/bin/env bash
这种。
#!/usr/bin/env bash
# 主函数 []<-()
function main(){
echo "Hello World!!!"
}
shell脚本的第一行用来指定执行脚本的时候使用的默认解析器是什么, #!/bin/bash
这样写就是指定使用 /bin
目录下的 bash
来解析。大多数 linux 发行版中默认的shell就是bash,不同的shell下可用的命令不同,比如sh 就比bash 可用的基础命令少很多,这也就是为什么虽然sh是始祖却用的人很少,而它的增强版bash能够后来居上的原因。
shell脚本是逐行解释执行的,在遇到第一行是 #!/bin/bash
的时候就会加载 bash 相关的环境,在遇到 #!/bin/sh
就会加载 sh 相关的环境,避免在执行脚本的时候遇到意想不到的错误。但一开始我并不知道我电脑上安装了哪些shell,默认使用的又是哪一个shell,我脚本移植到别人的计算机上执行,我更不可能知道别人的计算机是Ubuntu还是Arch或是Centos。为了提高程序的移植性,本shell规约规定使用 #!/usr/bin/env bash
, #!/usr/bin/env bash
会自己判断使用的shell是什么,并加载相应的环境变量。
我们看一下下面一段脚本,在改变第一行头部的时候,shellcheck给出的建议是什么
$ cat test.sh
function main(){
local string="Hello World!!!"
echo $string
}
使用 #!/bin/bash 或 #!/usr/bin/env bash
$ shellchek test.sh
In base_file.sh line 4:
echo $string
^-- SC2086: Double quote to prevent globbing and word splitting.
使用 #!/bin/zsh
$ shellchek test.sh
In base_file.sh line 1:
#!/bin/zsh
^-- SC1071: ShellCheck only supports sh/bash/dash/ksh scripts. Sorry!
使用 #!/bin/sh
$ shellchek test.sh
In base_file.sh line 2:
function main(){
^-- SC2112: 'function' keyword is non-standard. Delete it.
In base_file.sh line 3:
local string="Hello World!!!"
^-- SC2039: In POSIX sh, 'local' is undefined.
In base_file.sh line 4:
echo $string
^-- SC2086: Double quote to prevent globbing and word splitting.
这一行不写大多数时候我们运行脚本的时候也没有问题,但在使用shellcheck进行检查的时候,会提示
^-- SC2148: Tips depend on target shell and yours is unknown. Add a shebang.
如果使用Intellij IDEA 也会提示
shebang.jpg
当你点击 Add shebangline 的时候它会自动添加 #!/usr/bin/env bash
,这也是为什么本shell规约推荐使用 #!/usr/bin/env bash
的原因之一
shebang 维基百科
在计算机科学中,Shebang(也称为 Hashbang )是一个由井号和叹号构成的字符序列 #! ,其出现在文本文件的第一行的前两个字符。 在文件中存在 Shebang 的情况下,类 Unix 操作系统的程序载入器会分析 Shebang 后的内容,将这些内容作为解释器指令,并调用该指令,并将载有 Shebang 的文件路径作为该解释器的参数[1]。
例如,以指令
#!/bin/sh
开头的文件在执行时会实际调用/bin/sh
程序(通常是 Bourne shell 或兼容的 shell,例如 bash、dash 等)来执行。这行内容也是 shell 脚本的标准起始行。
关于注释
- 除脚本首行外,所有以
#
开头的语句都将成为注释。 - 函数必须有注释标识该函数的用途、入参变量、函数的返回值类型,且必须简单在一行内写完。
- 函数的注释
#
顶格写, 井号后面紧跟一个空格,对于该格式的要求是为了最后生成函数的帮助文档是用的(markdown语法),然后是注释的内容,注释尽量简短且在一行,最后跟的是函数的类型。
- 函数内注释
#
与缩进格式对整齐 - 变量的注释紧跟在变量的后面
# 主函数 []<-() <-------函数注释这样写
function main(){
local var="Hello World!!!"
echo ${var}
}
# info级别的日志 []<-(msg:String) <-------带入参的函数注释
log_info(){
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: [info] $*" >&2
}
# error级别的日志 []<-(msg:String) <-------带入参的函数注释
log_error(){
# todo [error]用红色显示 <------函数内注释
local msg=$1 # 将要输出的日志内容 <------变量的注释紧跟在变量的后面
if [[ x"${msg}" != x"" ]];then
# 注释 <-------函数内注释 `#` 与缩进格式对整齐
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]:[error] $*" >&2
fi
}
关于缩进
- 使用两个空格进行缩进,不适用tab缩进
- 不在一行的时候使用
\
进行换行,使用\
换行的原则是整齐美观
#!/usr/bin/env bash
# 脚本使用帮助文档 []<-()
manual(){
cat "$0"|grep -v "less \"\$0\"" \
|grep -B1 "function " \
|grep -v "\\--" \
|sed "s/function //g" \
|sed "s/(){//g" \
|sed "s/#//g" \
|sed 'N;s/\n/ /' \
|column -t \
|awk '{print $1,$3,$2}' \
|column -t
}
function search_user_info(){
local result=$(httpclient_get --cookie "${cookie}" \
"${url}/userName=${user_name}")
}
关于变量
- 变量赋值使用
=
等号,左右不能留有空格 - 使用变量的值用
$
取值符号 - 使用变量的时候,变量名一定要用
{}
包裹 - 使用变量的时候一定要用 双引号
"${}"
包裹
var1="Hello World" #正确,推荐使用双引号
var2='Hello World' #正确,不推荐使用单引号
var3="${var1}" #应用前面定义的变量的时候也要使用双引号包裹
var4=6
var5=6.70 #小数
var3=${var1} #正确,不推荐
注意: 单引号和双引号的区别
单引号里的任何字符都会原样输出,单引号字符串中的变量是无效的,单引号字串中不能出现单引号(对单引号使用转义符后也不行)。
双引号中的普通字符都会原样输出,单引号中的使用$取值的变量会替换成响应变量的真实值得,然后在进行输出,双引号中可以出现单引号
-
常量一定要定义成readonly,这种变量不能使用source跨shell使用
比如一个a.sh 中定义了一个全局的变量 readonly TURE=0,b.sh 中在一开始使用 source a.sh 引入的a.sh 的内容,则在 b.sh 中无需重复定义 readonly local TRUE=0,否则会报错 -
函数中的变量要用local修饰,定义成局部变量,这样在外部遇到重名的变量也不会影响
web="www.chen-shang.github.io"
function main(){
local name="chenshang" #这里使用local定义一个局部变量
local web="${web}" #这里${}内的web是全局变量,之后在函数中在使用web变量都是使用的局部变量
local web2="${web}" #对于全局变量,虽然在使用的时候直接使用即可,但还是推荐使用一个局部变量进行接收,然后使用局部变量,以防止在多线程操作的时候出现异常(相当于java中的静态变量在多线程中的时候需要注意线程安全一样,但常量除外)
}
- 变量一经定义,不允许删除(也就是禁用unset命令,因为到目前我还没遇到过什么情况必须unset的)
关于变量类型
先在这儿声明一下,shell脚本并没有什么复杂的数据类型和数据结构,shell 的数据类型仅仅包括 字符串、数值。shell并没有像 布尔类型、长整型、短整形等Java中的基本数据类型,数值类型也没有明确的 float和 double 之分。shell比较复杂的数据类型就是数组和Map了,但Map这种数据类型或者说数据结构只有在高版本的shell解析器才拥有,像我们现在使用的Linux系统往往是不支持Map的。
shell 中变量的基本类型就是 String 、数值(可以自己看做 Int、Double之类的) 、Boolean。Boolean 其实是 Int类型的变种, 在shell中 0代表真、非0代表假, 所以往往 我会在 shell脚本中 用 readonly TURN=0 && readonly FALSE=1
- 定义在函数中的我们称之为函数局部变量;定义在函数外部,shell脚本中变量我们称之为脚本全局变量
- 环境变量 所有的程序,包括shell启动的程序,都能访问环境变量,有些程序需要环境变量来保证其正常运行。必要的时候shell脚本也可以定义环境变量。
关于函数
函数的写法
函数定义的形式是
function main(){
#函数执行的操作
#函数的返回结果
}
或
main(){
#函数执行的操作
#函数的返回结果
}
- 使用关键字
function
显示定义的函数为 public 的函数,可以供 外部脚本以sh 脚本 函数 函数入参
的形式调用 - 未使用关键字
function
显示定义的函数为 privat 的函数, 仅供本脚本内部调用,注意这种privat是人为规定的,并不是shell的语法,不推荐以sh 脚本 函数 函数入参
的形式调用,注意是不推荐而不是不能。
本shell规约这样做的目的就在于使脚本具有一定的封装性,看到
function
修饰的就知道这个函数能被外部调用, 没有被修饰的函数就仅供内部调用。你就知道如果你修改了改函数的影响范围. 如果是 被function 修饰的函数, 修改后可能影响到外部调用他的脚本, 而修改未被function修饰的函数的时候,仅仅影响本文件中其他函数。
如 core.sh 脚本内容如下是
# 重新设置DNS地址 []<-()
function set_name_server(){
> /etc/resolv.conf
echo nameserver 114.114.114.114 >> /etc/resolv.conf
echo nameserver 8.8.8.8 >> /etc/resolv.conf
cat /etc/resolv.conf
}
# info级别的日志 []<-(msg:String)
log_info(){
echo -e "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: \033[32m [info] \033[0m $*" >&2
}
# error级别的日志 []<-(msg:String)
log_error(){
# todo [error]用红色显示
echo -e "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: \033[31m [error] \033[0m $*" >&2
}
则我可以使用 sh core.sh set_name_server
的形式调用 set_name_server
函数,但就不推荐使用 sh core.sh log_info "Hello World"
的形式使用 log_info
和 log_error
函数,注意是不推荐不是不能。
如何使用脚本单独调用函数中的某个函数
#!/usr/bin/env bash
# shellcheck disable=SC1091,SC2155
readonly local TRUE=0 && readonly local FALSE=1
# 脚本使用帮助文档
manual(){
cat "$0"|grep -v "less \"\$0\"" \
|grep -B1 "function " \
|grep -v "\\--" \
|sed "s/function //g" \
|sed "s/(){//g" \
|sed "s/#//g" \
|sed 'N;s/\n/ /' \
|column -t \
|awk '{print $1,$3,$2}' \
|column -t
}
######################################################################
# 主函数
main(){
(manual)
}
######################################################################
# 执行函数 [Any]<-(function_name:String,function_parameters:List<Any>)
execute(){
function_name=$1
shift # 参数列表以空格为分割左移一位,相当于丢弃掉第一个参数
function_parameters=$*
(${function_name} "${function_parameters}")
}
case $1 in
"-h" | "--help" | "?") (manual);;
"") (main) ;;
*) (execute "$@") ;;
esac
使用如上的框架,只需要在 两个 ######################################################################
之间写函数,就可以使用 sh 脚本名称 脚本中的某个函数 脚本中的某个函数的入参
的形式调用函数了。
使用 sh 脚本名称 ?
或者 sh 脚本名称 -h/--help
就可以查看这个脚本中的函数说明了。
- 在函数内部首先使用有意义的变量名接受参数,然后在使用这些变量进行操作,禁止直接操作$1,$2 等,除非这些变量只用一次
- 函数的注释
函数类型的概念是从函数编程语言中的概念偷过来的,shell函数的函数类型指的是函数的输入到函数的输入的映射关系
# 主函数 []<-() <-------函数注释这样写
function main(){
local var="Hello World!!!"
echo ${var}
}
# info级别的日志 []<-(msg:String) <-------带入参的函数注释
log_info(){
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')][$$]: [info] $*" >&2
}
main函数的函数类型是 []<-() , <- 左侧表的是函数的返回值类型 用[]包裹, 右侧是函数的参数类型 用()包裹,多个参数用 ',' 分隔,
参数的描述是从 Scala 语言中偷过来, 先是参数名称 然后是参数类型 中间用:分隔
对于main函数的注释来说, #
顶格写,后面紧跟一个空格,其实这样写是遵循的markdown的语法, 后面再跟一个空格,然后是 []<-(),代表这个函数没有入参也没有返回值,这个函数的目的就是执行这个这个函数中的命令,但我不关心这个函数的返回值。也就是利用函数的副作用来完成我们想要的操作。
对于log_info 也是一样 不过 最后的函数类型是 []<-(msg:String) 代表入参是一个string类型的信息,然后也没有返回值。
关于函数的返回值,我理解的函数的返回值有两种形式,一种是显示的return一种是隐式的echo
以下是几种常见的写法
[]<-()
[String]<-(var1:String,var2:String)
[Boolean]<-(var1:String,var2:Int)
[]<-(var1:String)
关于返回值
shell 函数的返回值比较复杂,获取函数的返回值又有多种方式
我们先来说,我们执行一条命令的时候, 比如 pwd 正常情况下它输出的结果是 当前所处的目录
Last login: Sat Jan 20 17:39:16 on ttys000
chenshang@chenshangMacBook-Pro:~$ pwd
/Users/chenshang
注意我说的是正常情况下,那异常情况下呢?输出的结果又是什么?输出的结果有可能五花八门!
所以,shell中必然有一种状态来标识一条命令是否执行成功,也就是命令执行结果的状态。
那这种状态是怎么标识的的,这就引出了shell中一个反人类的规定,也不算反人类,只能是在变种语言中算是另类,就是 0代表真、成功的含义。非零代表假、失败的含义。
所以 pwd 这条命令如果执行成功的话,命令的执行结果状态一定是0,然后返回值才是当前目录。如果这条命令执行失败的话,命令的执行结果状态一定不是0,有可能是1 代表命令不存在,然后输出 not found,也有可能执行结果状态是2 代表超时,然后什么也不输出。(不要以为pwd这种linux内带的命令就一定执行成功,有可能你拿到的就是一台阉割版的linux呢)
那怎么获取这个命令的执行结果和执行结果的状态呢?
function main(){
pwd
}
执行main函数就会在控制台输出当前目录
如果想要将pwd的内容获取到变量中以供后续使用呢
function main(){
local dir=$(pwd)
echo "dir is ${dir}"
}
如果想要获取pwd的执行结果的状态呢
function main(){
local dir=$(pwd)
local status=$?
echo "pwd run status is ${status}" #这个stauts一定有值,且是int类型,取值范围在0-255之间
echo "dir is ${dir}"
}
显示return
return 用来显示的返回函数的返回结果,例如
# 检查当前系统版本 [Integer]<-()
function check_version(){
(log_info "check_version ...") #log_info是我写的工具类中的一个函数
local version #这里是先定义变量,在对变量进行赋值,我们往往是直接初始化,而不是像这样先定义在赋值,这里只是告诉大家可以这么用
version=$(sed -r 's/.* ([0-9]+)\..*/\1/' /etc/redhat-release)
(log_info "centos version is ${version}")
return "${version}"
}
这样这个函数的返回值是一个数值类型,我在脚本的任何地方调用 check_version 这个函数后,使用 $? 获取返回值
check_version
local version=$?
echo "${version}"
注意这里不用 local version=$(check_version) 这种形式获取结果,这样也是获取不到结果的,因为显示的return结果,返回值只能是[0-255]的数值,这对于我们一般的函数来说就足够了,因为我们使用显示return的时候往往是知道返回结果一定是数字且在[0-255]之间的,常常用在状态判断的时候。
所以
本shell规约规定:
- 明确返回结果是在[0-255]之间的数值类型的时候使用显示 reuturn 返回结果
- 返回结果类型是Boolean类型,也就是说函数的功能是起判断作用,返回结果是真或者假的时候使用显示 return 返回结果
例如
# 检查网络 [Boolean]<-()
function check_network(){
(log_info "check_network ...")
for((i=1;i<=3;i++));do
http_code=$(curl -I -m 10 -o /dev/null -s -w %\{http_code\} www.baidu.com)
if [[ ${http_code} -eq 200 ]];then
(log_info "network is ok")
return ${TRUE}
fi
done
(log_error "network is not ok")
return ${FALSE}
}
# 获取数组中指定元素的下标 [int]<-(TABLE:Array,target:String)
function get_index_of(){
readonly local array=($1)
local target=$2
local index=-1 # -1其实是255
local size=${#array[@]}
for ((i=0;i<${size};i++));do
if [[ ${array[i]} == ${target} ]];then
return ${i}
fi
done
return ${index}
}
隐式echo
return 用来显示的返回函数的返回结果,例如
# 将json字符串格式化树形结构 [String]<-(json_string:String)
function json_format(){
local json_string=$1
echo "${json_string}"|jq . #jq是shell中处理json的一个工具
}
函数中所有的echo照理都应该输出到控制台上 例如
json_format "{\"1\":\"one\"}"
你会在控制台上看到如下输出
{
"1": "one"
}
但是一旦你用变量接收函数的返回值,这些本该输出到控制台的结果就都会存储到你定义的变量中 例如
json=$(json_format "{\"1\":\"one\"}")
echo "${json}" #如果没有这句,上面的语句执行完成后,不会在控制台有任何的输出
我们把 json_format 改造一下
# 将json字符串格式化树形结构 [String]<-(json_string:String)
function json_format(){
local json_string=$1
echo "为格式化之前:${json_string}" #其实新添加的只一句只是用来记录一行日志的
echo "${json_string}"|jq . #jq是shell中处理json的一个工具
}
echo "为格式化之前:${json_string}" 其实新添加的只一句只是用来记录一行日志的,但是json=$(json_format "{"1":"one"}")
json 也会将这句话作为返回结果进行接收,但这是我不想要看到的。
解决这个问题需要了解 父子shell的 概念
shell中 子shell 可以理解成闭包,可以捕获父shell的变量,但不能改变父shell的变量,使用()将代码块包裹,包裹的代码块将在子shell中运行,子shell相当于独立的一个环境,其中的代码爱怎么玩儿怎么玩,不会影响到父shell的结果
所以如果我不想让 echo "为格式化之前:${json_string}" 这句话也作为结果的话,我就只需要用()将代码块儿包裹即可
# 将json字符串格式化树形结构 [String]<-(json_string:String)
function json_format(){
local json_string=$1
(echo "为格式化之前:${json_string}") #其实新添加的只一句只是用来记录一行日志的
echo "${json_string}"|jq . #jq是shell中处理json的一个工具
}
关于计算
- 整数计算使用
$(())
- 小数计算使用 bc 计算器
关于分支
HEAD_KEYWORD parameters; BODY_BEGIN
BODY_COMMANDS
BODY_END
- 将HEAD_KEYWORD和初始化命令或者参数放在第一行;
- 将BODY_BEGIN同样放在第一行;
- 复合命令中的BODY部分以2个空格缩进;
- BODY_END部分独立一行放在最后;
- if
if 的一般模式
if [[ condition ]]; then
# statements
fi
if [[ condition ]]; then
# statements
else
# statements
fi
if [[ condition ]]; then
# statements
elif [[ condition ]]; then
# statements
else
# statements
fi
- if 后面的判断 使用 双中括号
[[]]
-
if [[ condition ]]; then
写在一行
- while
while [[ condition ]]; do
# statements
done
while read -r item ;do
# statements
done < 'file_name'
- until
until [[ condition ]]; do
# statements
done
- for
for (( i = 0; i < 10; i++ )); do
# statements
done
for item in ${array}; do
# statements
done
- case
case word in
pattern )
#statements
;;
*)
#statements
;;
esac
网友评论