简洁的Bash Programming技巧(三)

简洁的Bash Programming技巧(三)

这一系列的文章专门介绍 Bash 编程中一些简洁的技巧,帮助大家提高平时 Bash 编程的效率。

1. 替换语法${parameter/pattern/string}的妙用

${parameter/pattern/string}将parameter中匹配pattern的部分替换成string,例如下面的例子将字符串中的e替换成x:

$ str="three"
$ echo "${str/e/x}"
thrxe

如果pattern部分以/开头,表示替换parameter中所有匹配的内容,例如:

$ str="three"
$ echo "${str//e/x}"  # thrxx

如果pattern部分以#开头,表示仅当parameter开始处匹配pattern的时候替换,例如:

str="three"
$ echo "${str/#e/x}" # three
$ echo "${str/#t/x}" # xhree

与此对应地是,如果pattern部分以%开头,表示仅当parameter结尾处匹配pattern的时候替换,例如:

$ str="three"
$ echo "${str/%e/x}" # threx

如果string部分为空,匹配pattern的部分被删除(替换为空),例如:

$ str="three"
$ echo "${str/h/}"  # tree

这个时候第二个斜杠可以删除,即:echo "${str/h}" 如果parameter是一个数组会怎么样呢?有兴趣的可以看看Bash的man手册说明:

man -P 'less -p "\\$\{parameter/pattern/string}"' bash

2. +=运算符

有一天,我看到这样一个用法:

$ arr=(1 2 3)
$ arr+=(4 5)

原来数组还可以这样相加,后来我看了下Bash的手册,确实有一段这么说明的,这里未引用这段文字,有兴趣的可以查看Bash Reference Manual 自然地我们会想到如果一个变量是数字,是否也可以用+=作运算呢?

$ i=1
$ i+=1

但是,运行后你会发现i的结果并不为2,而是11,这里bash并不认为i是一个整数,而是作为字符串。 这时可以通过declare声明一个变量为整数,上面的问题就解决了:

$ declare -i int=1
$ int+=1
$ echo $int
2

3. Here document不为人知的用法

Shell学得越多,越会发现一些神奇的用法,每天都觉得自己实在是一个刚入门的菜鸟。

  1. Here Document 是在Linux Shell 中的一种特殊的重定向方式,它的基本的形式如下
cmd << delimiter
  Here Document Content
delimiter

它的作用就是将两个 delimiter 之间的内容(Here Document Content 部分) 传递给cmd 作为输入参数。

  1. 比如在终端中输入cat << EOF ,系统会提示继续进行输入,输入多行信息再输入EOF,中间输入的信息将会显示在屏幕上。如下:
$ cat << EOF
>  First Line
> Second Line
> Third Line EOF
> EOF
 First Line
Second Line
Third Line EOF

注⚠️: >这个符号是终端产生的提示输入信息的标识符
这里要注意几点

  1. EOF 只是一个标识而已,可以替换成任意的合法字符
  2. 作为结尾的delimiter一定要顶格写,前面不能有任何字符
  3. 作为结尾的delimiter后面也不能有任何的字符(包括空格)
  4. 作为起始的delimiter前后的空格会被省略掉
    Here Document 不仅可以在终端上使用,在shell 文件中也可以使用,例如下面的here.sh 文件
cat << EOF > output.sh
echo "hello"
echo "world"
EOF

使用 bash here.sh 运行这个脚本文件,会得到output.sh 这个新文件,里面的内容如下

echo "hello"
echo "world"
  1. delimiter 与变量
    在Here Document 的内容中,不仅可以包括普通的字符,还可以在里面使用变量,例如将上面的here.sh改为
$ cat here.sh
#!/bin/bash

cat << EOF > output.sh  #没有引号
echo "hello, $USER"
EOF

$ bash here.sh
$ cat output.sh
echo "hello, shuke"

使用bash here.sh HereDocument 运行脚本得到output.sh的内容

$ cat output.sh
echo "hello $USER"

在这里 $1 被展开成为了脚本的参数 HereDocument
但是有时候不想展开这个变量怎么办呢,可以通过在起始的 delimiter的前后添加 " 来实现,将EOF有引号括起来就可以:

$ cat here.sh
#!/bin/bash

cat << "EOF"     #注意引号
echo "hello, $USER"
EOF

$ bash here.sh
echo "hello, $USER"

4. « 变为 «-

Here Document 还有一个用法就是将 '«' 变为 '«-'。 使用 <<- 的唯一变化就是Here Document 的内容部分每行前面的 tab (制表符)将会被删除掉,这种用法是为了编写Here Document的时候可以将内容部分进行缩进,方便阅读代码.
如果你有强迫症,有时候使用here document的时候会很不爽,因为here document里面每行首部的空格都会被保留,而如果要顶格写,在缩进的地方又会有点打乱结构,例如:

$ cat b.sh
# part 1
if :; then
    cat << EOF
    hello, $USER    
EOF
fi

# part 2
if :; then
    if :; then
        cat << EOF
hello, $USER    
EOF
    fi

上面的脚本执行的结果为:

$ sh b.sh 
    hello, kodango   # part 1 result
hello, kodango       # part 2 result

有没有办法既兼顾到缩进又能不保留行首空格呢?

答案也是肯定的,只不过语法又要稍稍变一下,现在在<<的后面加一个短横,这个用法下,行首的Tab字符都会被忽略:

$ cat b.sh 
if :; then
    cat <<- EOF
    hello, $USER    
EOF
fi
$ sh b.sh 
hello, shuke
fi

一定要是Tab键哦,空格也是不可以的,在vim里面还要注意如果设置了smarttab选项,行首插入的Tab键会替换成相应个数的空格(这里可以按ctrl+v tab插入实际的空格)

5. 使用内置命令declare显示脚本中定义的函数

declare的-F选项可以列出脚本中定义的函数名称:

$ cat fun.sh
#!/bin/bash


function one()
{
    :
}

function two()
{
    :
}

declare -F | sed 's/declare -f //'
shuke@MacBooKPro:/tmp
$ bash fun.sh
one
two

6. 嵌套函数还可以这么用

Bash中可以嵌套函数定义,即在一个函数中定义另外一个函数,例如:

$ cat fun.sh
#!/bin/bash

function out()
{
    echo "out"

    function inner() {
        echo "inner"
    }

}
inner
out
inner

这里out函数里面定义了inner函数,形成嵌套函数。但是,执行上面的例子会出错(nest.sh: line 12: inner: command not found),这是因为这是后inner函数还没定义。一旦out函数执行之后,inner函数就被定义了。整个例子的执行结果是这样的:

$ bash fun.sh
fun.sh: line 13: inner: command not found
out
inner

看到这里,你可能会想嵌套函数有什么用?事实上,在大多数情况下,我们基本不会用到嵌套函数。但是它并非一无是处,比如下面的例子就向我们展示了嵌套函数的神奇用法。

假设,我们要定义一个调试函数,同时需要一个开关控制该函数是否输出调试日志,最简单的写法是:

function log()
{
    if [ "$verbose" = "1" ]; then
        echo "$@"
    fi
}

它可以完成任务,但是唯一美中不足的是,每次调用该函数都要判断verbose的值是否为1。这时候可以使用嵌套函数来弥补这个不足:

$ cat fun.sh
#!/bin/bash

verbose=${1:-1}

function log()
{
    if [ $verbose -eq 1 ]; then
        function log() {
            echo "$@"
        }

        echo "$@"
    else
        function log() {
            :
        }
    fi
}

log what is your name
log my name is shuke

$ bash fun.sh
what is your name
my name is shuke

上面的例子中,根据verbose的值定义了两个同名的log函数来覆盖之前的旧函数,以后调用的函数就都是后定义的函数了。

7. 删除ps auxf | grep python结果中的grep进程

在shell脚本中,经常需要利用ps和grep命令一起在查找进程相关的信息,尤其是针对python/java/shell等脚本进程,因为pidof本身不大支持查找脚本进程对应的pid。

在用ps auxf | grep python的时候,一个很恼人的事情是,经常会出现多余的grep进程:

$ ps aux | grep python
shuke            76718   0.1  0.5  4341192  42404 s001  S+   12:52下午   0:01.32 /Users/zhao/anaconda3/envs/sloth/bin/python manage.py runserver 8000
shuke            76732   0.0  0.0  4284136    884 s003  S+   12:53下午   0:00.00 grep python
shuke            76716   0.0  0.4  4329332  32936 s001  S+   12:52下午   0:00.68 python manage.py runserver 8000

所以我们需要再加一个grep -v grep来排除它。

$ ps aux | grep python | grep -v grep
shuke            76718   0.1  0.5  4341192  42404 s001  R+   12:52下午   0:02.13 /Users/zhao/anaconda3/envs/sloth/bin/python manage.py runserver 8000
shuke            76716   0.0  0.4  4329332  32936 s001  S+   12:52下午   0:00.68 python manage.py runserver 8000

还有一个解决方法是巧用正则表达式:

$ ps aux | grep [p]ython
shuke            76718   0.5  0.5  4341192  42412 s001  S+   12:52下午   0:03.58 /Users/zhao/anaconda3/envs/sloth/bin/python manage.py runserver 8000
shuke            76716   0.0  0.4  4329332  32936 s001  S+   12:52下午   0:00.68 python manage.py runserver 8000

一个很好的解释: shell在执行以上命令的时候,其实创建了一个管道,并且fork了两个子进程:ps auxf与grep python,并且将管道读的这一端绑定到grep的标准输入,管道写的这一段绑定到ps的标准输出。ps将自己的输出写到管道,grep从管道中读取输入。可能在这个时候,ps与grep是同时执行的,所以ps的结果中也会包含grep进程的信息。

8. Shell如何实现timeout功能

有时候我们不希望某个命令执行太久,所以如果在给定的时间内没有完成,能够杀掉这个命令对应的进程,这就是timeout功能,可惜bash没有提供该功能。所以就得我们自己来实现。

实现代码如下所示:

function timeout()
{
    local time cmd pid

    if echo "$1" | grep -Eq '^[0-9]+'; then
        time=$1
        shift && cmd="$@"
    else
        time=5
        cmd="$@"
    fi

    $cmd &
    pid=$!

    while kill -0 $pid &>/dev/null; do
        sleep 1
        let time-=1

        if [ "$time" = "0" ]; then
            kill -9 $pid &>/dev/null
            wait $pid &>/dev/null
        fi
    done
}

假设有一个测试脚本,内容如下:

$ cat sleep.sh
echo "sleep $1 seconds"
sleep $1
echo "awake from sleep"

现在利用我们写的timeout函数来达到超时kill功能:

$ time bash timeout.sh 2 'sh sleep.sh 100'
sleep 100 seconds

real    0m2.005s
user    0m0.002s
sys    0m0.001s

看最终执行的时间,差不多就是2秒钟。
上面timeout函数实现的代码中,利用了两个技巧:

  1. kill -0 $pid:发送信号0给进程,可以检查进程是否存活,如果进程不存在或者没有权限,则返回错误,错误码为1;
  2. wait $pid &>/dev/null:等待某个进程退出返回,这样相对比较优雅,同时将错误重定向到黑洞,从而隐藏后台进程被kill的错误输出;

9. 利用/etc/inittab实现watchdog

还在为实现watch dog而头疼吗,其实inittab中已经包含了该功能。可以将自己的脚本或者程序写到inittab文件中:

tt:2345:respawn:/home/kodango/sleep.sh 100

然后执行telinit q使其生效,ps看下该脚本是否已经在运行了,尝试kill后,又会被起起来。

10. 慎用波浪号展开

在shell中对比下面两种用法:

$ home1=~shuke
$ home2="~shuke"
$ echo -e "$home1\n$home2"
/Users/zhao
~shuke

第一个变量赋值,波浪号正确展开,所以我们得到了kodango用户的家目录地址;第二个变量,我们使用了双引号,这个时候波波浪号并没有展开。这是一个比较容易出错的地方。

还有一点要注意的地方是,波浪号展开只在:或者=号后面才会执行。所以:

$ path=1~shuke
$ echo "$path"
1~shuke

$ path=1:~shuke
$ echo "$path"
1:/Users/shuke

为什么要在:后面也可以展开呢?想想PATH的定义吧。

11. pushd/popd

这个在脚本中非常好用,特别是在循环中
如下所示,假设你正在写一个进入退出文件夹的for循环:

for d1 in $(ls -d */)
do
 # Store original working directory.
 original_wd="$(pwd)"
 cd "$d1"
 for d2 in $(ls -d */)
 do
   pushd "$d2"
   # Do something
   popd
 done
 # Return to original working directory
 cd "${original_wd}"
done

你可以像这样使用pushd栈来重写上方代码:

for d1 in $(ls -d *)
do
 pushd "$d1"
 for d2 in $(ls  -d */)
 do
   pushd "$d2"
   # Do something
   popd
 done
 popd
done

它可以追踪记录你切换的目录并进行入栈或出栈
注意,当使用pushd出现错误时,可能会丢失栈的记录并且popd多次。因此你可能会想要在脚本中使用set -e(见上一篇文章)

当然也可以用cd -,但是它不会使用栈——仅仅返回前一个目录

cd ~
cd /tmp
cd blah
cd - # Back to /tmp
cd - # Back to 'blah'
cd - # Back to /tmp
cd - # Back to 'blah' ...

12.shopt vs set

这两个命令困扰了我一阵子。
两者之间有什么不同呢?
set在之前的文章已经介绍过了,而shopt看起来与之相似。只输入shopt会显示一系列选项:

$ shopt
cdable_vars    off
cdspell        on
checkhash      off
checkwinsize   on
cmdhist        on
compat31       off
dotglob        off

从根本上说,似乎有一系列的bash(和其他shells)建立在sh之上,而添加shopt命令则为设置额外的shell选项提供了一种方式

13. Here Docs 与 Here Strings

"Here Docs"是在shell中用一些语句创建的文件。

“诀窍”很简单。定义一个用于结束的单词,则在这个单词单独出现在一行之前的所有输入行将构成文件。

像这样:

$ cat > afile << SOMEENDSTRING
> here is a doc
> it has three lines
> SOMEENDSTRING alone on a line will save the doc
> SOMEENDSTRING
$ cat afile
here is a doc
it has three lines
SOMEENDSTRING alone on a line will save the doc
$

注意:

  • 如果结束单词不是“单独”出现在一行中,那它可以构成文件
  • SOMEENDSTRING通常是END,但这仅仅只是习惯
    更鲜为人知的是"here string":
$ cat > asd <<< 'This file has one line'

14. 字符串变量的操作

以前你可能是像下面展示的那样写代码,用sed一类的工具来操作字符串:

$ VAR='HEADERMy voice is my passwordFOOTER'
$ PASS="$(echo $VAR | sed 's/^HEADER(.*)FOOTER/1/')"
$ echo $PASS

但是你可能不知道bash本身也是可以的。
这意味着你可以省去大量的sed和awk。
一种重写上述代码的方式如下所示:

$ VAR='HEADERMy voice is my passwordFOOTER'
$ PASS="${VAR#HEADER}"
$ PASS="${PASS%FOOTER}"
$ echo $PASS
  • 表示"从字符串开头开始匹配并删除所给的模式串"

  • %表示"从字符串结尾开始匹配并删除所给的模式串"

在我的电脑上,后一种方法比前一种快两倍。并且(令我吃惊的是),他的速度跟类似功能的python脚本速度大致相当
如果你想使用通配符(见前文)模式串并采用贪婪模式,你需要双写:

$ VAR='HEADERMy voice is my passwordFOOTER'
$ echo ${VAR##HEADER*}
$ echo ${VAR%%*FOOTER}

15.变量的默认值

这些对写脚本来说非常好用。
如果你有一个没有赋值的变量,你可以像这样给它“赋默认值”
创建一个default.sh文件,写入如下内容:

#!/bin/bash
FIRST_ARG="${1:-no_first_arg}"
SECOND_ARG="${2:-no_second_arg}"
THIRD_ARG="${3:-no_third_arg}"
echo ${FIRST_ARG}
echo ${SECOND_ARG}
echo ${THIRD_ARG}

现在执行chmod +x default.sh并用./default.sh first second来运行脚本:
观察第三个参数的默认值是如何被分配的,而不是前两个。
你也可以直接用${VAR:=defaultval}(等号,不是破折号),但是注意这不适用于脚本或函数中的位置变量。尝试修改上面的脚本来看它是如何失败的。

16. Traps

当一个信号被送到脚本时,内建的trap可以用于“捕获”
下面是我用在自己的chepci脚本中的一个例子:

function cleanup() {
   rm -rf "${BUILD_DIR}"
   rm -f "${LOCK_FILE}"
   # get rid of /tmp detritus, leaving anything accessed 2 days ago+
   find "${BUILD_DIR_BASE}"/* -type d -atime +1 | rm -rf
   echo "cleanup done"                                                     }
trap cleanup TERM INT QUIT

任何使用TERM信号的CTRL-C,CTRL-或终止程序的操作将会首先调用cleanup
⚠️ 注意:

  • trap的逻辑可能非常棘手(例如处理信号竞争条件)
  • KILL信号不能以这种方式捕获
    但是大多数情况下,我会把它用于类似上述的‘cleanup’中,来达成函数的目的。

17. Shell变量

了解可用的标准shell变量是非常值得的。这些是我最喜欢的。
RANDOM
不要依赖这个来加密堆栈,但你可以生成随机数字,例如在脚本中创建临时文件时:

$ echo ${RANDOM}
16313
$ # Not enough digits?
$ echo ${RANDOM}${RANDOM}
113610703
$ NEWFILE=/tmp/newfile_${RANDOM}
$ touch $NEWFILE

REPLY
不在需要给read一个变量名称

$ read
my input
$ echo ${REPLY}

LINENO 与 SECONDS
方便调试

$ echo ${LINENO}
115
$ echo ${SECONDS}; sleep 1; echo ${SECONDS}; echo $LINENO
174380
174381
116

注意,即便使用;来隔开命令,上面的代码也要分两行

TMOUT
可以用来超时读取,在一些脚本中真的很好用

#!/bin/bash
TMOUT=5
echo You have 5 seconds to respond...
read
echo ${REPLY:-noreply}

Extglobs

如果你真的沉迷bash不能自拔,那么你可能想要增强你的通配功能。你可以通过设置shell中的extglob选项。这是设置方法:

shopt -s extglob
A="12345678901234567890"
B="  ${A}  "

现在来看看你是否能指出以下这些语句各自的功能:

echo "B      |${B}|"
echo "B#+( ) |${B#+( )}|"
echo "B#?( ) |${B#?( )}|"
echo "B#*( ) |${B#*( )}|"
echo "B##+( )|${B##+( )}|"
echo "B##*( )|${B##*( )}|"
echo "B##?( )|${B##?( )}|"

虽然它可能很有用,但是很难想象出一种你必须要用这种方式的情况。通常你会使用一些更适合相应任务的工具(像sed)或者直接放弃bash去使用一些像python那样的“合适的”编程语言。

18.关联数组

谈到移植到其他语言,一条重要的规则是,如果我需要用到数组,那么我会放弃bash,使用python(为此我甚至创建了一个Docker Container来运行一个专门的工具)

知道读到它我才知道,在bash中有关联数组

以下是演示:

$ declare -A MYAA=([one]=1 [two]=2 [three]=3)
$ MYAA[one]="1"
$ MYAA[two]="2"
$ echo $MYAA
$ echo ${MYAA[one]}
$ MYAA[one]="1"
$ WANT=two
$ echo ${MYAA[$WANT]}

注意仅适用于bash4.x+版本

19. 实用的shell文章

BashPitfalls - Greg's Wiki
ProcessManagement - Greg's Wiki
BashGuide - Greg's Wiki
BashFAQ - Greg's Wiki

原文参考地址

Sed&awk笔记系列-博客

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,163评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,301评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,089评论 0 352
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,093评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,110评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,079评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,005评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,840评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,278评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,497评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,667评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,394评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,980评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,628评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,649评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,548评论 2 352

推荐阅读更多精彩内容