Shell高级编程技巧


一、Shell重定向

介绍

就像我们平时写的程序一样,一段程序会处理外部的输入,然后将运算结果输出到指定的位置。在交互式的程序中,输入来自用户的键盘和鼠标,结果输出到用户的屏幕,甚至播放设备中。shell脚本也一样,但是我们一般在使用shell命令的时候,更多地还是通过键盘输入,然后在屏幕上查看命令的执行结果。如果某些情况下,我们需要将shell命令的执行结果存储到文件中,那么我们就需要使用输入输出的重定向功能。

文件描述符

当执行shell命令时,会默认打开3个文件,每个打开文件都有对应的文件描述符来方便我们使用:

chenshang@chenshangdeMacBook-Pro:~$ lsof -p $$
COMMAND   PID      USER   FD   TYPE DEVICE SIZE/OFF                NODE NAME
bash    25660 chenshang  cwd    DIR    1,4     1504         12884930381 /Users/chenshang
bash    25660 chenshang  txt    REG    1,4  1296704 1152921500312764811 /bin/bash
bash    25660 chenshang  txt    REG    1,4  2547760 1152921500312767057 /usr/lib/dyld
bash    25660 chenshang    0u   CHR   16,4    0t142                 819 /dev/ttys004
bash    25660 chenshang    1u   CHR   16,4    0t142                 819 /dev/ttys004
bash    25660 chenshang    2u   CHR   16,4    0t142                 819 /dev/ttys004
bash    25660 chenshang  255u   CHR   16,4    0t142                 819 /dev/ttys004

如上图,lsof -p $$ 其中 $$ 代表当前进程,这段指令的意思是输出当前进程的打开文件描述符都有哪些. 其中FD列代表文件表述负号。目前我们看到的只有上述几个,但是FD的类型非常多,不过我们并不是全部都需要关心和使用,我们只需要知道文件描述符其实是一个整数而已,这个整数如果在没有特殊配置的情况下默认最大是255,也就是说一个进程最多打开255个文件,一般是够用了,但是总有贪心的时候,我们可以用 ulimit -n 来设置进程打开的最大文件描述符的大小。

类型 描述
cwd 表示 current work dirctory,即:应用程序的当前工作目录,这是该应用程序启动的目录,除非它本身对这个目录进行更改
txt 该类型的文件是程序代码,如应用程序二进制文件本身或共享库,如上列表中显示的 /sbin/init 程序
lnn library references (AIX)
er FD information error (see NAME column)
jld jail directory(FreeBSD)
ltx shared library text(code and data)
mxx hex memory-mapped type number xx.
m86 DOS Merge mapped file
mem memory-mapped file
mmap memory-mapped device
pd parent directory
rtd root directory
tr kernel trace file (OpenBSD)
v86 VP/ix mapped file
0 表示标准输出
1 表示标准输入
2 表示标准错误

我们现在先关心 0、1、2 这三个文件描述符

类型 文件描述符 默认情况 对应文件句柄位置
标准输入(standard input) 0 从键盘获得输入 /proc/self/fd/0
标准输出(standard output) 1 输出到屏幕(即控制台) /proc/self/fd/1
错误输出(error output) 2 输出到屏幕(即控制台) /proc/self/fd/2

输出重定向

我们使用>或者>>对输出进行重定向。符号的左边表示文件描述符,如果没有的话表示1,也就是标准输出,符号的右边可以是一个文件,也可以是一个输出设备。当使用>时,会判断右边的文件存不存在,如果存在的话就先删除,然后创建一个新的文件,不存在的话则直接创建。但是当使用>>进行追加时,则不会删除原来已经存在的文件。语法如下所示:

command1 > file1

输入重定向

我们使用<对输入做重定向,如果符号左边没有写值,那么默认就是0。和输出重定向一样,Unix 命令也可以从文件获取输入,语法为:

command1 < file1

Here Document

Here Document 是 Shell 中的一种特殊的重定向方式,用来将输入重定向到一个交互式 Shell 脚本或程序。
它的基本的形式如下:

command << delimiter
    document
delimiter

它的作用是将两个 delimiter 之间的内容(document) 作为输入传递给 command。

注意:
结尾的delimiter 一定要顶格写,前面不能有任何字符,后面也不能有任何字符,包括空格和 tab 缩进。
开始的delimiter前后的空格会被忽略掉。

重定向绑定

好了,在有了以上知识的基础上,我们经常见到这样的写法 >/dev/null 2>&1。这条命令其实分为两命令,一个是>/dev/null,另一个是2>&1

>/dev/null

这条命令的作用是将标准输出1重定向到/dev/null中。/dev/null代表linux的空设备文件,所有往这个文件里面写入的内容都会丢失,俗称“黑洞”。那么执行了>/dev/null之后,标准输出就会不再存在,没有任何地方能够找到输出的内容。

2>&1

这条命令用到了重定向绑定,采用&可以将两个输出绑定在一起。这条命令的作用是错误输出将和标准输出同用一个文件描述符,说人话就是错误输出将会和标准输出输出到同一个地方。

linux在执行shell命令之前,就会确定好所有的输入输出位置,并且从左到右依次执行重定向的命令,所以>/dev/null 2>&1的作用就是让标准输出重定向到/dev/null中(丢弃标准输出),然后错误输出由于重用了标准输出的描述符,所以错误输出也被定向到了/dev/null中,错误输出同样也被丢弃了。执行了这条命令之后,该条shell命令将不会输出任何信息到控制台,也不会有任何信息输出到文件中。

这样的用法主要使用在我们想执行某条指令,而且也知道这条指令不会产生错误或者我们就根本不关心这条这令的执行结果的时候使用,例如我们先清空一下内存,然后在执行某某操作,我们清理内存的目的只是让程序执行的空间多一点,执行的速度快一点,至于是否真的释放了多少空间,我们是不关心,这时候就可以用这个命令。

在BaseShell中的实战应用
在函数中既想打印日志,有不想影响函数的返回值,因为我么知道一个函数中的标准输出都将作为函数的返回值输出,即使是中间的日志结果。为此我们可以用到重定向来解决此问题.

function log_info(){
  if [[ ${log_level} -ge 2 ]];then
    echo -e "\\033[37m[$(date +%Y-%m-%dT%H:%M:%S)][$$ $BASHPID] [INFO] [${FUNCNAME[1]}]:    $*\\033[0m"|trim 1>&2
    echo -e "[$(date +%Y-%m-%dT%H:%M:%S)][$$ $BASHPID] [INFO] [${FUNCNAME[1]}]:    $*"|trim >> "${LOG_DIR}/$(date +%Y-%m-%d).info.log"  2>&1
    echo -e "[$(date +%Y-%m-%dT%H:%M:%S)][$$ $BASHPID] [INFO] [${FUNCNAME[1]}]:    $*"|trim >> "${LOG_DIR}/$(date +%Y-%m-%d).log"  2>&1
  fi
}

上面的 2>&1 会把所有的标准输出当成标准错误输出,标准错误是不会被认为函数的返回值的。

二、进程间通信

进程间通信,无外乎 管道、有名管道、共享存储、信号、信号量、消息队列、套接字 这几种,进程间通信从来都不能直接通信,必须找一个中间媒介。中间媒介就是上面说的几种,他们都要一个特点就是:一个进程的信息先存起来,然后另一个进程在读出来,这样就达到了进程间通信的目的。线程间通信也一样,我们学Java的多线程的时候简单很多,只有两种,想想线程间是怎么通信的:什么共享内存啊(就是搞个votail的共享变量,类似进程通信的共享存储)、什么wait、notify啊,Java叫通知机制(就是搞个信号,类似进程通信的信号)

管道

管道是进程间通信的主要手段之一。一个管道实际上就是个只存在于内存中的文件,对这个文件的操作要通过两个已经打开文件进行,它们分别代表管道的两端。管道是一种特殊的文件,它不属于某一种文件系统,而是一种独立的文件系统,有其自己的数据结构。根据管道的适用范围将其分为:无名管道和命名管道。

无名管道

主要用于父进程与子进程之间,或者两个兄弟进程之间。在linux系统中可以通过系统调用建立起一个单向的通信管道,且这种关系只能由父进程来建立。管道是将前一个命令的输出作为后一个命令的输入 命令1 | 命令2 | 命令3 | ...

在BaseShell中的实战应用
如何写一个命令,让他支持使用管道的形式接受参数呢。例如 我想计算某个字符串的 hashCode. 我期望的形式是 echo " 123 "|hashCode 然后就能得到结果。 而在计算 hashCode 之前,我还期望先trim一下。所以我更希望的写法是 echo " 123 "|trim|hashCode。我的BaseShell是如下实现的

# 这是一个辅助函数,意思是被其他函数调用的函数,以扩展原来函数的功能
# 1. 有参数的时候直接走 _action 否则执行2
# 2. 从标准输出中获取参数,并执行 _action
# 该方法扩展原函数,使其具备从标准输出获取参数的能力,因此原函数可以类似管道似的调用.
# @see BaseString.sh trim|string_length
# @attention 从标准输入读取的参数是以空格分隔的 echo "1 2" "3 4"|trim 最终读取到的参数是 "1 2 3 4" 而不是 "1 2" 和 "3 4"
# 适用于明确只有一个参数的情况
function pip(){
  local param=$*
  #参数长度==0 尝试从标准输出获取参数
  if [[ ${#param} -eq 0 ]];then
    # timeout 设置1秒的超时
    param=$(timeout 1 cat <&0)
  fi
  _action "${param}"
}

# 去掉字符串前后空格 [String]<-(param:String)
function trim(){
  local param=$*
  _action(){
    local param=$*
    echo -e "${param}" | grep -o "[^ ]\+\( \+[^ ]\+\)*"
  }
  pip "${param}"
}

# 哈希code  [String]<-(str:String)
function hashCode(){
  local param=$1
  _action(){
    local param=$1
    local hash=0
    local i;for (( i=0; i<${#param}; i++ )); do
      printf -v val "%d" "'${param:$i:1}" # val is ASCII val
      if ((31 * hash + val > 2147483647)); then
        # hash scheme
        hash=$((- 2147483648 + ( 31 * hash + val ) % 2147483648))
      elif ((31 * hash + val < - 2147483648)); then
        hash=$((2147483648 - ( 31 * hash + val ) % 2147483648))
      else
        hash=$((31 * hash + val))
      fi
    done
    printf "%d" "${hash}" # final hashCode in decimal
  }
  pip "${param}"
}

我么可以看到 pip 函数,此外 BaseShell中还有实现 pip2 和 pip3 等既可以接收管道入参也可以接收直接入参

有名管道

命名管道是建立在实际的磁盘介质或文件系统(而不是只存在于内存中)上有自己名字的文件,任何进程可以在任何时间通过文件名或路径名与该文件建立联系。为了实现命名管道,引入了一种新的文件类型——FIFO文件(遵循先进先出的原则)。实现一个命名管道实际上就是实现一个FIFO文件。命名管道一旦建立,之后它的读、写以及关闭操作都与普通管道完全相同。虽然FIFO文件的inode节点在磁盘上,但是仅是一个节点而已,文件的数据还是存在于内存缓冲页面中,和普通管道相同。管道可以想象成一个一端进入一端读取的队列,可以理解成一个先进先出的数据结构,通过 mkfifo 可以创建一个有名管道

我们执行 mkfifo queue 就可以创建一个有名管道,这个有名管道和上面的匿名管道一样,只不过匿名管道,么有名字,有名管道有名字而已。匿名管道主要应用与父子进程之间,而有名管道除了用在父子进程上之外,还可以用在兄弟进程之间。有名管道的特点就是,一端写入,如果另一端么有人读取的话,那么写入端阻塞。同理,如果一段读取,另一端写入,如果没有写入,那么读取端就会阻塞。利用这个特性我们可以用于控制进程并行的数量,模拟多线程控制。

在BaseShell中的实战应用
并发工具包 Concurrent 中的实现

管道实现机制

管道是由内核管理的一个缓冲区,相当于我们放入内存中的一个纸条。管道的一端连接一个进程的输出。这个进程会向管道中放入信息。管道的另一端连接一个进程的输入,这个进程取出被放入管道的信息。一个缓冲区不需要很大一般为4K大小,它被设计成为环形的数据结构,以便管道可以被循环利用。当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。

信号

信号是另一种进程间通信机制,它给应用程序提供一种异步的软件中断,使应用程序有机会接受其他程序活终端发送的命令(即信号)。应用程序收到信号后,有三种处理方式:忽略,默认,或捕捉。进程收到一个信号后,会检查对该信号的处理机制。如果是SIG_IGN,就忽略该信号;如果是SIG_DFT,则会采用系统默认的处理动作,通常是终止进程或忽略该信号;如果给该信号指定了一个处理函数(捕捉),则会中断当前进程正在执行的任务,转而去执行该信号的处理函数,返回后再继续执行被中断的任务。

在有些情况下,我们不希望自己的shell脚本在运行时刻被中断,比如说我们写得shell脚本设为某一用户的默认shell,使这一用户进入系统后只能作某一项工作,如数据库备份, 我们可不希望用户使用 Ctrl+C 等方法进入到shell状态做我们不希望做的事情。这便用到了信号处理。

以下是一些你可能会遇到的常见信号:

信号名称 信号数 描述
SIGHUP 1 本信号在用户终端连接(正常或非正常)结束时发出,通常是在终端的控制进程结束时,通知同一session内的各个作业,这时它们与控制终端不再关联。登录Linux时,系统会分配给登录用户一个终端(Session)。在这个终端运行的所有程序,包括前台进程组和后台进程组,一般都属于这个Session。当用户退出Linux登录时,前台进程组和后台有对终端输出的进程将会收到SIGHUP信号。这个信号的默认操作为终止进程,因此前台进程组和后台有终端输出的进程就会中止。对于与终端脱离关系的守护进程,这个信号用于通知它重新读取配置文件。
SIGINT 2 程序终止(interrupt)信号,在用户键入 Ctrl+C 时发出。
SIGQUIT 3 和SIGINT类似,但由QUIT字符(通常是Ctrl /)来控制。进程在因收到SIGQUIT退出时会产生core文件,在这个意义上类似于一个程序错误信号。
SIGFPE 8 在发生致命的算术运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为0等其它所有的算术错误。
SIGKILL 9 用来立即结束程序的运行。本信号不能被阻塞,处理和忽略。
SIGALRM 14 时钟定时信号,计算的是实际的时间或时钟时间。alarm 函数使用该信号。
SIGTERM 15 程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理. 通常用来要求程序自己正常退出;kill 命令缺省产生这个信号。

那怎么用呢? 我们如何实现我们脚本在行过程当中,当我们主动按下 Ctrl+C的时候主动终止呢?

加入下面一行就可以了

trap "echo 'exit...';exit" 2

意思就是说 捕捉信号2 然后执行 echo 'exit...';exit

我们可以学习一下trap这个指令。等我出教程。

三、copy on write

大家都知道 redis 的写时复制技术,我们看看Linux是怎么做的。在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,linux中引入了“写时复制“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。当我在父进程中开启一个子进程的时候,同样会复制一份父进程的文件描述符,只有当子进程改变文件描述符的内容时候,这个文件描述符才会分裂,所以redis的写时复制技术其实就是依赖的Linux本身就自带的机制。这跟我们多线程中的理解是不同的,因此也会造成一些误会,Java中多线程对共享变量的修改是可见的,但是在Shell的多进程里面则是相反的,你在子进程对父进程变量所做的修改是无论如何也不可的。我们来证明一下

#!/bin/bash
a=10 # 先定义一个变量
f1(){
  echo "父进程:${a}"
  (
    a=100
    while :; do
       echo "子进程:${a}"
       sleep 2
    done
  ) &  #通过 () 便可开启一个子进程 ,在这个子进程里,我们改变变量a的值,然后不停的输出变量a的值
  echo "父进程:${a}" #我们在父进程中打印一下,发现根本没有变
  a=1000
  echo "父进程:${a}" #我们在父进程中改变一下变量的值,发现子进程输出也不会改变
  wait
}
f1

四、并发控制

令牌控制

# 注意这个方法是阻塞方法
# 提交一个任务 []<-(task:Function)
function threadPool_submit(){ _NotBlank "$1" "thread pool can not be null"
  local fd=$1 ;shift ;local task=$*
  #获取执行令牌,获取不到令牌则阻塞,直到有任务结束执行归还令牌
  read -r -u "${fd}" token
  {
    eval "${task}" #开始执行耗时操作
    eval "echo ${token} >& ${fd}" #归还令牌
  } &
}

队列控制

# coreSize:核心线程数
# keepAliveTime:线程的存活时间
# 任务队列使用的是无限的任务队列
# eg: new_ThreadPoolExecutor && local pool=$?
# 新建线程池 [int]<-(coreSize:Integer,keepAliveTime:Long)
function new_ThreadPoolExecutor(){ _NotBlank "$1" "core size can not be null" && _Natural "$1" && _Min "0" "$1"
  local coreSize=$1 #核心线程数
  local keepAliveTime=${2:-1} #线程的存活时间

  local lock=$(new_fd)
  new_lock "${lock}"

  local fd=$(new_fd)
  new_fifo "${fd}"

  for((i=0;i<coreSize;i++));do
    {
      while :;do
        trap 'echo you hit Ctrl-C/Ctrl-\, now exiting.....; exit' SIGINT SIGQUIT
        lock_tryLock "${lock}" #这个地方必须加锁,防止read并发读导致task错乱
        read -t "${keepAliveTime}" -r -u "${fd}" task
        lock_unLock "${lock}"
        isBlank "${task}" && exit
        eval "${task}"
      done
    } &
  done

  return "${fd}"
}

五、调试技巧

  1. 使用 set -x 可以打印出脚本执行的中间结果
  2. 使用 bashdb 工具可以控制脚本一步一步执行
  3. 使用 Idea 的bashsuport插件【推荐】

六、锁

在 Linux 中的两条进程同时编辑文件产生并发冲突时, 我们第一反应就会想到使用 锁 来解决这个问题, 这里就介绍 flock。

flock

flock 有三种写法 :

  1. flock [options] | […]
  2. flock [options] | -c
  3. flock [options] <file descriptor number(fd)>

将锁加在文件上以防止冲突

flock 可以将锁加在某个文件上 (以及文件夹), 来防止并发冲突, 其中一个用途就是用在定时任务中。例如每分钟执行一次检查任务,如果agent没有启动则启动agent.如果第一个任务还没有结束,然后又启动了一个检查任务,最后可能启动多个agent实例。所以我们需要再检查的时候如果agent没有启动才启动,如果检查任务还没有结束我们就不开启下一个检查任务。当然我们的实现方式有很多中。如下使用flock是一种,flock可以保证一个进程在启动的时候在某个文件上加上一把锁,可以是排它锁,其他进程如果先要获取这个锁必须等上一个进程结束才行。

$ echo "* * * * * cd ${coreDir}; /usr/bin/flock -xn /tmp/checkAndFixAgent.lock -c '/bin/bash core.sh checkAndFixAgent > /dev/null 2>&1'"

#检查并修复agent
checkAndFixAgent(){
  local success=$(checkAgent)
  isFalse "${success}" && {
    mount -o rw,remount /
    startAgent
  }
}

当然flock也有他的弊端,这个我们很少遇到,我目前还没有理解,等我真实遇到在补充。

将锁加在 文件描述符 (fd) 上

flock 也可以对某个 fd 加锁, 这种通常用于一个进程的子进程中. 关于 fd, 由于 Linux 一切皆文件的哲学, 所以我们常用的 stdout 就是 fd 1, stderr 就是 fd 2, 我们也可以自定义 fd

# 获取一个可用的文件描述符号
function new_fd(){
  {
    flock 3
    local find=${NULL}
    for((fd=4;fd<1024;fd++));do
      local rco="$(true 2>/dev/null >& ${fd}; echo $?)"
      local rci="$(true 2>/dev/null <& ${fd}; echo $?)"
      [[ "${rco}${rci}" == "11" ]] && find=${fd} && break
    done
    echo "${find}"
  } 3<>/tmp/base_shell.lock
}

这样我们就可以避免两个进程同时对一个文件的读写而产生错误了.

附录

Options - -
-s –shared 共享锁
-x –exclusive 独占锁,默认类型
-u –unlock 解锁, (通常不需要手动解锁, 默认 -c 后的命令退出时, FD 会关闭, 会将文件解锁)
-n –nonblock 非阻塞,若指定的文件正在被其他进程锁定,则立即以失败 (1) 返回
-w –timeout 若指定的文件正在被其他进程锁定,则等待指定的秒数;指定为 0 将被视为非阻塞
-o –close 锁定文件后与执行命令前,关闭用于引用加锁文件的文件描述符
-E –conflict-exit-code 若指定 - n 时请求加锁的文件正在被其他进程锁定,或指定 - w 时等待超时,则以该选项的参数作为返回值
-F –no-fork 不 fork 执行命令
-c –command 运行的命令

自定义锁

参考 BaseShell 框架 BaseLock 部分

七、函数编程

Shell 可以像 函数语言一样,将函数作为参数进行传递的。

我们现在想要在执行函数前打印一个时间,执行函数后打印一个时间,然后统计这个函数的执行时长。类似于切面,我们看看用Shell可以怎么实现

#!/bin/bash
add(){
  echo "你正在执行add操作 ing"
  sleep 5
}

sub(){
  echo "你正在执行sub操作 ing"
  sleep 3
}

aop(){
  local function=$1

  local start=$(date +%s)
  echo "开始执行${function}前:$start"

  eval "${function}"

  local end=$(date +%s)
  echo "执行${function}后:$end,耗时L:$((end-start))s"
}

main(){
  aop "add" &
  aop "sub" &
  wait
}

main

八、import 其他脚本

使用 source.
例如: source ./../../BaseShell/Starter/BaseHeader.sh 就可以引入 BaseHeader.sh 中定义的各种函数和变量
如何解决 A.sh->B.sh 然后 B.sh->A.sh 这样循环引用的尴尬境地的,因为这样就死循环了,会撑爆进程栈的。编译行语言Java是很好解决这个问题的,因为他是编译型,编译的过程是不执行程序的,用的方法叫双亲委派原则。单Shell不一样,我用的是下面这样的方法,基本解决思路是,在脚本执行实现先检查一下当前脚本是否已经加载过,加载过就直接return.

#===============================================================
import="$(basename "${BASH_SOURCE[0]}" .sh)_$$"
if [[ $(eval echo '$'"${import}") == 0 ]]; then return; fi
eval "${import}=0"
#===============================================================

在脚本的开头只要加上上面的代码,就可以解决循环引用的问题。这是目前我想到的比较简单的方式,不排除以后还有新的方式,敬请期待,例如可以多线程并行检查等优化手段,先卖个关子,目前还没有想好。

九、单元测试

有人认为单元测试什么的不重要,这你就大错特错了,如果只是自己写几个脚本用,那没关系,因为不会影响别人。但如果要是像我一样提供一些脚本给大家用,那就需要单元测试了。因为脚本的移植性本来就很差,稍稍改动一点就坑很多,如果没有单元测试保障,回头改动某个函数过后导致大家使用出现问题,那就是不负责任了。

参考 BaseShell使用教程 中的 测试【Utils】章节

十、动态创建函数

我们可以动态的创建函数。应用如下面的例子我们可以替换一个函数的名字为另一函数,这样做我们可以实现类似的面向对象编程

# 重命名函数
function new_function(){ _NotBlank "$1" "source function name can not be null" && _NotBlank "$2" "target function name can not be null"
  test -n "$(declare -f $1)" || return
  eval "${_/$1/$2}"
}

例如 BaseArrayList.sh 中的实现

# 这里会新建一个List,建议用在多线程模式下
  new_arrayList number #创建一个名为number的List
  number_add 1         #向个number中添加一个元素
  number_add 2         #向个number中添加一个元素

十一、递归

十二、设计模式


评论
  目录