前端写多了迟早要碰 shell。CI 里的构建步骤、服务器上的发布脚本、清理日志的定时任务,最后都会落到一个 .sh 文件里。语法看起来简单,if、for、函数都有,跟 JavaScript 一比好像还更少东西要记。
然后你就会遇到这样的事:脚本里某一步明明失败了,后面的步骤还是照常往下跑,最后部署了一个空目录上去;或者路径里带了个空格,rm -rf $DIR 直接把不该删的删了。这些不是你逻辑写错了,是 shell 的默认行为跟大多数语言不一样,它默认「出错也继续」,默认「变量展开后再做一次分词」。
这篇把 shell 编程从变量、条件、循环、函数一路理下来,每个语法点都说清楚它在什么场景用、边界在哪。最后单独用一节讲怎么把一个能跑的脚本变成一个不会坑你的脚本。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- shell 的种类,
#!那一行到底做了什么,两种执行方式的差别 - 变量的定义、只读、删除,以及三种变量作用域
- 字符串和数组的常用操作,单双引号的关键区别
- 位置参数
$1、$#、$@和$*,以及它们在函数里的复用 - 四类运算符,
expr、[ ]、[[ ]]、$(( ))该用哪个 echo和printf的取舍,为什么脚本里推荐printftest命令的数值、字符串、文件三类测试if、for、while、until、case以及break/continue- 函数的定义、传参和返回值,
return和echo的区别 - 重定向和管道,
2>&1的写法顺序为什么不能反 set -euo pipefail每个字母在防什么- 变量不加引号会出什么事,以及几个高频踩坑点
# 一、初识shell
# 1.1 Shell 环境
Shell 是包在内核外面的一层命令解释器,你敲的每一条命令都由它翻译后交给系统执行。Linux 的 Shell 种类众多,常见的有
/usr/bin/sh或/bin/sh/bin/bashC Shell(/usr/bin/csh)K Shell(/usr/bin/ksh)Shell for Root(/sbin/sh)
Bash 在日常工作中被广泛使用,同时它也是大多数 Linux 系统默认的 Shell。
这里有个坑值得先说。/bin/sh 在很多系统上是指向别的 shell 的软链接,Debian 和 Ubuntu 上它指向的是 dash,一个精简的 POSIX shell。你在 bash 里跑得好好的脚本,换成 sh script.sh 执行可能就报语法错误,因为 [[ ]]、数组、+= 这些都是 bash 扩展,dash 不支持。这个我排查过一下午,最后发现只是因为 CI 的镜像里 sh 不是 bash。
用 vi/vim 新建一个文件 test.sh,扩展名为 sh(sh 代表 shell)。扩展名并不影响脚本执行,见名知意就好。
#!/bin/bash
echo "Hello World !"
#! 是一个约定的标记,通常叫 shebang,它告诉系统这个脚本需要什么解释器来执行。echo 命令用于向窗口输出文本。
要写 #!/bin/bash 还是 #!/usr/bin/env bash?后者会去 PATH 里找 bash,在 bash 装在非标准路径的系统(比如 macOS 上用 Homebrew 装的新版 bash)上更通用。脚本只在自家服务器上跑,写死路径没问题;要发出去给别人用,env 那种写法更保险。
# 1.2 运行 Shell 脚本有两种方法
作为可执行程序
chmod +x ./test.sh # 使脚本具有执行权限
./test.sh # 执行脚本
注意一定要写成 ./test.sh,而不是 test.sh。运行其它二进制程序也一样,直接写 test.sh 的话,linux 系统会去 PATH 里找有没有叫 test.sh 的东西,而 PATH 里通常只有 /bin、/sbin、/usr/bin、/usr/sbin 这些目录,你的当前目录不在里面,所以会报「找不到命令」。写成 ./test.sh 是在告诉系统,就在当前目录找。
为什么当前目录不在 PATH 里?这是个安全设计。要是当前目录在 PATH 里,别人往共享目录放一个叫 ls 的恶意脚本,你 cd 进去随手敲个 ls 就中招了。
作为解释器参数
这种方式是直接运行解释器,把脚本文件名当参数传给它。
/bin/sh test.sh
/bin/php test.php
这么跑的脚本不需要在第一行指定解释器信息,写了也不起作用,因为解释器已经在命令行里定死了。同理,这种方式也不需要给脚本加执行权限。
还有第三种执行方式值得提一下,source test.sh(或者简写 . test.sh)。前两种都是开一个子进程去跑脚本,脚本里定义的变量在脚本结束后就没了;source 是在当前 shell 里执行,变量会留下来。所以配环境变量的脚本必须用 source,而不是 ./。「我脚本里明明 export 了,退出来怎么就没了」这个问题,答案就在这。
# 二、shell变量
变量的命名规则和大多数语言差不多,但有一条是 shell 独有的,赋值时等号两边不能有空格。
- 定义变量时,变量名不加美元符号
- 变量名和等号之间不能有空格,等号和值之间也不能有
- 命名只能使用英文字母、数字和下划线,首个字符不能以数字开头
- 中间不能有空格,可以使用下划线(
_) - 不能使用标点符号
- 不能使用
bash里的关键字(可用help命令查看保留关键字)
your_name="poetries" # 等号两边都不能有空格
为什么这么严格?因为 shell 是按空格切分命令的。写成 your_name = "poetries",shell 会理解成「执行 your_name 这个命令,给它两个参数 = 和 poetries」,然后报 command not found。这个规则和 [ $a == $b ] 里必须有空格的规则看起来矛盾,其实是同一个道理,[ 本身就是个命令,它的参数当然要用空格分开。
有效的 Shell 变量名示例如下。
RUNOOB
LD_LIBRARY_PATH
_var
var2
# 2.1 使用变量
使用一个定义过的变量,只要在变量名前面加美元符号即可。
your_name="poetries"
echo $your_name
echo ${your_name}
变量名外面的花括号是可选的,加不加都行,加花括号是为了帮助解释器识别变量的边界。看下面这个例子。
for skill in Ada Coffe Action Java; do
echo "I am good at ${skill}Script"
done
如果不给 skill 加花括号,写成 echo "I am good at $skillScript",解释器会把 $skillScript 整个当成一个变量名(它没被定义过,值为空),输出就成了 I am good at,跟预期完全不一样。
推荐给所有变量都加上花括号,这是个好习惯。花括号还解锁了一批很实用的展开语法,后面几节的字符串截取、长度获取都建立在它上面。
已定义的变量可以被重新定义。
your_name="tom"
echo $your_name
your_name="alibaba"
echo $your_name
# 2.2 只读变量
使用 readonly 命令可以把变量定义为只读,之后它的值就不能再改了。下面这个例子尝试更改只读变量,结果会报错。
#!/bin/bash
myUrl="http://www.w3cschool.cc"
readonly myUrl
myUrl="http://www.runoob.com"
/bin/sh: NAME: This variable is read only.
这个特性在脚本里挺有用。把不该被改的配置项(部署根目录、备份保留天数)声明成 readonly,后面代码里手滑覆盖掉的时候会直接报错,而不是带着错误的值一路跑下去。有点像 JavaScript 里的 const,只不过 shell 里没人强制你用。
# 2.3 删除变量
使用 unset 命令可以删除变量,语法是 unset variable_name。变量被删除后不能再次使用,另外 unset 命令不能删除只读变量。
#!/bin/sh
myUrl="http://www.runoob.com"
unset myUrl
echo $myUrl
要说明的是,shell 里「变量不存在」和「变量为空字符串」是两种状态,但 echo 出来都是空的,肉眼分不出来。真要区分得用 ${var+x} 这类展开语法,或者在开头 set -u(第十二节会讲),让引用未定义变量直接报错。
# 2.4 变量类型
运行 shell 时会同时存在三种变量。
局部变量 在脚本或命令中定义,仅在当前 shell 实例中有效,其他 shell 启动的程序不能访问它。
环境变量 所有程序都能访问,包括 shell 启动的子程序。有些程序需要环境变量才能正常运行,必要的时候脚本里也可以自己定义环境变量。
shell 变量 由 shell 程序自己设置的特殊变量,其中一部分是环境变量,一部分是局部变量,这些变量保证了 shell 的正常运行。
三者的实际区别在于「子进程能不能看到」。普通赋值 FOO=bar 只在当前 shell 有效,加上 export FOO=bar 之后它才会被子进程继承。你在脚本里设了个变量,然后调用另一个脚本却读不到,多半就是漏了 export。
FOO=bar # 只有当前 shell 看得见
export FOO=bar # 子进程也能看见
env | grep FOO # 确认它进了环境变量表
# 2.5 Shell 字符串
字符串是 shell 编程中最常用的数据类型(除了数字和字符串,也没什么别的类型好用了)。字符串可以用单引号,也可以用双引号,还可以不用引号,三种写法的行为完全不同。
单引号
str='this is a string'
单引号里的任何字符都会原样输出,里面的变量不会被展开。单引号字符串中也不能出现单引号,对单引号用转义符也不行。
双引号
author='poetries'
echo "hello,I'm ${author}"
双引号里可以有变量,可以出现转义字符。
这个区别在写脚本时非常关键,一句话记:要变量展开用双引号,要一字不差的原文用单引号。传给 awk、sed 的表达式里全是 $1、$2 这种符号,必须用单引号包住,不然 shell 会先把它们当成自己的变量给替换掉,你会拿到一个空表达式。
至于不加引号,那是最危险的一种,第十二节会专门讲它会出什么事。
# 2.6 拼接字符串
shell 里没有拼接运算符,把两个东西写在一起就是拼接。
your_name="poetries"
greeting="hello, "$your_name" !"
greeting_1="hello, ${your_name} !"
echo $greeting $greeting_1
两种写法结果一样,但第二种可读性明显更好。第一种靠引号的开合来切分,多拼几段之后自己都数不清哪个引号配哪个。拼路径的时候尤其推荐第二种,"${BASE_DIR}/${APP_NAME}/dist" 一眼就看得出结构。