我刚上手命令行那会儿,第一次在终端里敲下ls,屏幕刷出一屏文件名,第一反应是"这玩意儿有什么意义,图形界面点两下不就完事了"。真正让我改变看法的是一个批量改名的活儿:几百个文件要按统一规则重命名,手动点了整整一个下午才弄完一半,旁边一位老哥瞟了一眼,敲了一行for循环,三秒收工。那一刻我才反应过来,shell 基础这东西的价值不在炫技,而在于把那些"重复、机械、容易手滑"的活儿从人手里拿走,交给机器一遍一遍不出错地跑。
这篇我按自己带新人的顺序来讲:shell 到底是什么、哪些活儿该交给它、哪些别硬塞给它;然后是变量、引号、位置参数这几座新手必摔的山头;再往下是条件判断、循环、管道重定向、文本处理三件套;最后是函数封装、调试手段,以及我这些年踩出来的坑,整理成一张能直接查的表。命令都能复制去跑,环境是最常见的 Linux 发行版,脚本以 bash 为主。适合刚拿到机器权限、对着黑框框发怵的新人,也适合写了几年脚本但一直靠"搜一段改改"过日子的朋友,回头把底层认知补齐。
1. 先把shell这件事说透:它到底解决什么问题
1.1 shell是站在你和系统之间的一层"翻译官"
很多人学了很久都没搞明白一件事:shell 不是操作系统的一部分,它只是一个普通程序。你敲的每一行命令,本质上是一串字符串,shell 读到之后做三件事——解析(拆成命令名和参数)、查找(去PATH里找到对应的可执行文件)、执行(fork 一个子进程跑起来,把结果吐回终端)。内核才是真正干活的那一层,shell 只是替你把"我想干什么"翻译成系统调用。
用餐厅打个比方:你是顾客,内核是后厨,shell 就是那个服务员。你跟服务员说"来份番茄炒蛋",他不会自己下厨,而是把单子递进厨房,再把菜端出来。这个类比能解释 shell 语法为什么看起来那么"松散"——它设计出来是给人快速下单用的,不是给编译器看的。所以你会看到ls -l和ls -l /tmp都能跑,参数顺序有时候无所谓,空格多一个少一个又天差地别,这些都源于它"面向人、面向交互"的出身。
理解这一层有个直接好处:当脚本报出command not found,你第一反应应该是"shell 在 PATH 里没找到这个程序",而不是"脚本写错了"。当脚本输出一堆奇怪的No such file or directory,你该想到"参数被空格拆开了"。定位问题的方向,完全取决于你对这层翻译官的理解深度。
1.2 什么活儿该交给shell,什么活儿别硬塞
这条边界感,比记住多少命令都重要。我见过太多人用 shell 写了上千行的"业务系统",里面套着多层循环、字符串拼接、浮点计算,最后谁都不敢动,改一行崩三处。
shell 的主场其实很清楚:文件与目录的批量操作(批量改名、按规则归档、清理临时文件)、日志与文本的快速筛查(统计某个关键字出现次数、提取特定字段)、环境初始化与部署编排(装依赖、拉代码、起服务、做健康检查)、定时任务与流水线胶水(把几个现成工具串起来按顺序跑)。这些活儿的共同点是:调用现成命令、按顺序执行、对性能要求不高、逻辑分支简单。
反过来,这几类活儿我建议直接换工具:涉及复杂数据结构(嵌套字典、对象)的,上 Python;需要精确浮点运算的,shell 里$(( ))只支持整数,bc能用但写起来费劲;大文本的复杂聚合统计,awk比 shell 循环快一个数量级;需要并发和错误重试逻辑的,脚本会迅速失控。判断标准说白了就一句:如果你发现自己在 shell 里"模拟"某种数据结构或者状态机,说明工具选错了。
我在一个模拟项目里见过最典型的反面案例:有人用 shell 解析 JSON,靠grep和cut一层层剥,遇到字段顺序变化就崩。其实一行jq就解决了。工具选型的成本,往往在三个月后才显现出来。
1.3 从"能用"到"能改"的学习路径
shell 基础的学习曲线有个特点:入门极快,深水区极深。但好消息是,日常 90% 的场景只需要一小块知识面。
我自己总结的最小可用集是:20 个左右高频命令(ls cd pwd cp mv rm mkdir find grep sed awk cat less head tail sort uniq wc cut xargs ps kill df du chmod)、管道与重定向、变量与引号规则、条件与循环、函数与退出码。这五块拿下,你就能读懂绝大多数现成脚本,也能自己写。
再往上一层是"能改":看懂别人脚本里的set -euo pipefail是干嘛的、trap怎么捕获中断、IFS怎么影响read、[[ ]]和[ ]差在哪里。这一层不是靠背,是靠踩坑积累的,下面我尽量把坑提前挖给你看。
2. 环境准备与第一次执行:别在第一步就翻车
2.1 选哪个shell:bash够用,别一上来折腾
新手最常见的纠结是"我该学 bash 还是 zsh 还是 fish"。我的建议很直接:脚本一律写 bash,交互环境随你喜欢。原因很实际——你写的脚本大概率要跑到别人的机器、容器镜像、CI 环境里去,那些地方的/bin/sh可能是 dash,可能是 busybox,只有 bash 的覆盖面最广、资料最多。
这里有个高频坑必须点出来:脚本第一行的 shebang 写#!/bin/sh还是#!/bin/bash,行为完全不一样。在不少发行版上,/bin/sh实际指向dash或者精简版 shell,它不支持[[ ]]、数组、local、$(( ))里的一些写法、source的某些形式。你本地用 bash 测得好好的脚本,换台机器就报[[: not found,八成就是这个原因。
#!/usr/bin/env bash # 推荐这么写:让 env 去 PATH 里找 bash,比硬编码 /bin/bash 更适应不同系统 set -euo pipefailset -euo pipefail这行我几乎每个脚本都加,三个开关分别管三件事:-e遇到非零退出码立刻停,别让错误滚雪球;-u引用未定义变量时报错,防止rm -rf $DIR/里DIR是空的时候把根目录删了;pipefail让管道的退出码取最后一个失败的命令,而不是永远取最后一个命令的状态。这三条能挡掉大量"脚本跑完了但结果不对"的隐性事故。
注意:
set -e在if条件、&&||左侧、!取反这几种场景下不会触发退出,这是设计如此,不是 bug。别因为它"偶尔不生效"就放弃用它。
2.2 三种执行方式的区别,很多人一直没搞清
写好一个test.sh,你有三种跑法,效果差得远:
| 执行方式 | 是否需要执行权限 | 在哪个进程里跑 | 对环境变量的影响 |
|---|---|---|---|
./test.sh | 需要chmod +x | 新开子 shell | 脚本内的变量和cd不影响当前终端 |
bash test.sh | 不需要 | 新开子 shell | 同上 |
source test.sh或. test.sh | 不需要 | 当前 shell | 变量、函数、cd全部留在当前会话 |
这个区别在实战里非常关键。我经常看到有人写了export PATH=...放在脚本里,然后./setup.sh一跑,回头发现PATH根本没变——因为脚本在子 shell 里执行的,改的是子 shell 的环境,跑完就销毁了。想让它生效,必须source。
反过来的坑也存在:source一个带exit的脚本,会把你当前终端直接关掉。所以初始化类的脚本我一般会判断一下是不是被 source 了:
# 判断是否被 source 执行 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then echo "请用 source 执行本脚本" exit 1 fi2.3 权限与换行符:新手必踩的两个坑
第一个是权限。刚拉下来的脚本./run.sh报Permission denied,chmod +x run.sh就行。这里有个细节值得说:chmod 755和chmod +x的区别在于前者会重置全部权限位,后者只是加上执行位。团队协作时我倾向用chmod 755,保证所有人的权限一致,避免出现"我这儿能跑你那儿不能跑"的玄学。
第二个更隐蔽:脚本明明存在,却报/bin/bash^M: bad interpreter: No such file or directory。这个^M是 Windows 换行符\r\n在作祟——文件从 Windows 传过来,每行末尾多了个回车符,shell 把\r当成了解释器路径的一部分。解决办法两条:
# 方法一:用 dos2unix(需要安装) dos2unix script.sh # 方法二:直接用 sed 删掉行尾的 \r sed -i 's/\r$//' script.sh我现在接手任何从外部传进来的脚本,第一件事就是file script.sh看一眼是不是 CRLF,能省掉半小时的懵圈。
3. 变量、引号与参数:shell语法的三座大山
3.1 变量赋值:等号两边不能有空格
这是新手第一个必踩的坑:NAME = "abc"会报NAME: command not found。原因在 1.1 讲过——shell 靠空格切分命令和参数,NAME被当成了命令名,=和"abc"成了它的参数,自然找不到这个命令。
正确写法是NAME="abc",等号紧贴。引用的时候用$NAME或${NAME}。这里我强烈建议养成用${}的习惯,因为它在拼接时更明确:
FILE="log" echo "$FILE.txt" # 输出:.txt —— shell 以为你要取变量 FILE.txt echo "${FILE}.txt" # 输出:log.txt —— 正确变量作用域也值得一提。默认变量是全局的,函数里改了外面也能看到;想限制在函数内,用local:
process_file() { local path="$1" # 只在函数内有效 local count=0 # ... }这个local不是可有可无的装饰。我见过一个排查了两小时的 bug:两个函数都用了i做循环变量,其中一个函数内部调用了另一个,循环直接乱掉。加了local之后问题消失。函数内除了确实要往外传的变量,其余一律加local,这条我写进了团队的脚本规范里。
环境变量是另一回事,export VAR=value才能传给子进程。不加export的变量只在本 shell 可见,子进程读不到——这也是为什么脚本里export完PATH,还得source才能生效。
3.2 引号决定生死:单引号、双引号、反引号、$()
shell 里引号的规则,是我认为最值得优先吃透的一块,因为它直接决定脚本"看起来能跑但结果莫名其妙"。
单引号里的一切都是字面量,$、反引号、反斜杠全部不生效。echo '$HOME'输出就是$HOME。什么时候用?写正则、写 SQL、写带$的固定字符串,一律单引号。
双引号里变量会展开、命令替换会执行、反斜杠会转义。echo "$HOME"输出你的家目录。
反引号和$()都是命令替换,取命令执行的输出。区别在于反引号不能嵌套、内部的反斜杠规则很怪,$()可以随便套。所以我现在一律用$(),反引号只在读老代码时认得就行。
today=$(date +%F) echo "今天是 ${today}" # 嵌套也没问题 echo "当前目录文件数:$(ls -1 | wc -l)"真正决定生死的是"加不加双引号"。不加双引号的变量会发生分词和通配符展开:
FILE="my report.txt" rm $FILE # 等价于 rm my report.txt —— 删两个文件! rm "$FILE" # 正确,删一个文件这个坑在上传文件名带空格、路径带空格时超高频。我现在的原则简单粗暴:只要是变量,一律加双引号,除非你明确知道自己要分词(比如想把一个空格分隔的字符串拆成多个参数)。
还有个小众但实用的:$'...'形式支持转义序列,$'a\tb'里的\t会变成真正的制表符。处理带特殊字符的文件名时会用到。
3.3 位置参数与特殊变量:$@ 和 $* 的区别
脚本接收参数靠位置参数:$0是脚本名本身,$1到$9是第 1 到第 9 个参数,超过 9 个用${10}这种形式。配套的特殊变量还有一串,值得列个表:
| 变量 | 含义 | 典型用途 |
|---|---|---|
$# | 参数个数 | if [[ $# -lt 1 ]]; then usage; fi |
$@ | 所有参数,带引号时等价于"$1" "$2" ... | 原样透传给下一个命令 |
$* | 所有参数,带引号时合并成一个字符串 | 拼成一句话输出 |
$? | 上一条命令的退出码 | if [ $? -ne 0 ]; then ... |
$$ | 当前 shell 的 PID | 生成临时文件名 |
$! | 最后一个后台进程的 PID | 记录后台任务、后续 kill |
$@和$*的区别是高频率面试题,也是实战坑。不加引号时两者行为一样(都会被分词);加引号后,"$@"会保留每个参数独立的边界,"$*"会把所有参数拼成一整个字符串(用IFS的第一个字符连接)。所以透传参数必须用"$@":
# 正确:每个参数原样传给 wrapper wrapper() { echo "收到 $# 个参数" some_command "$@" } wrapper "a b" "c" # some_command 收到两个参数:"a b" 和 "c" # 错误:变成一个参数 wrapper() { some_command "$*"; } wrapper "a b" "c" # some_command 只收到一个参数:"a b c"这个差别在写包装脚本时尤其致命。我曾经写过一个批量处理脚本,用"$*"把路径传进去,结果文件名带空格的图片全部报"文件不存在",排查了二十分钟才想起来这一条。
4. 流程控制与文本处理:把重复劳动交给脚本
4.1 条件判断:test、[ ]、[[ ]] 到底选哪个
shell 的条件判断有三种写法,混乱程度堪称一绝:test、[ ]、[[ ]]。底层关系是:[其实是个命令(就是test的别名,所以[后面和]前面必须有空格),而[[ ]]是 bash 的语法关键字,不是命令。
这三者的区别直接决定你踩不踩坑:
| 写法 | 支持方 | 变量为空时的表现 | 支持&&|| | 支持正则=~ |
|---|---|---|---|---|
[ -f $FILE ] | POSIX 全支持 | 报unary operator expected | 不支持 | 不支持 |
[ -f "$FILE" ] | POSIX 全支持 | 正常返回假 | 不支持 | 不支持 |
[[ -f $FILE ]] | bash/ksh/zsh | 正常返回假 | 支持 | 支持 |
[[ ]]有几个明显优势:变量不用加引号也不会因为空值报错(它内部做了处理)、支持&&||而不用写成-a-b、支持=~正则匹配、<>可以正常做字符串比较而不用转义。既然脚本都指定 bash 了,我基本全用[[ ]]。
至于数值比较,可以用[ 1 -lt 2 ],但更清爽的是(( )):
count=5 if (( count > 3 )); then echo "超过 3 了" fi # 也能直接做算术 (( count++ )) echo $count # 6(( ))内部用的是 C 语言式的运算符,><==都能直接用,不用写成-gt-lt。唯一要注意的是它只支持整数,涉及小数得换bc或awk。
常用的文件判断符也整理一下:-e存在、-f是普通文件、-d是目录、-r可读、-w可写、-x可执行、-s非空。字符串判断:-z为空、-n非空。这几个够覆盖日常了。
4.2 循环:for、while、read逐行读的正确姿势
for循环最常用的两种形态,一种是遍历列表或通配符展开,一种是 C 语言风格:
# 遍历当前目录的 .log 文件 for f in *.log; do echo "处理 ${f}" done # C 语言风格:适合计数循环 for ((i = 0; i < 10; i++)); do echo "第 ${i} 次" done这里有个隐蔽陷阱:for f in *.log在没有匹配文件时,f的值会是字面量*.log本身,循环照样跑一次。要么加shopt -s nullglob,要么先判断[[ -e "$f" ]]。我第一次写清理脚本时就被这个坑到,明明目录是空的,却报"文件不存在"。
while循环的经典用法是逐行读文件,但这里面坑最多:
# 推荐写法 while IFS= read -r line; do echo "$line" done < input.txt三个细节解释一下:IFS=保留行首行尾的空格(默认IFS会吃掉它们);-r禁止反斜杠转义,否则\t这种会被当成转义序列吃掉;最后一行如果没有换行符结尾,默认的read会返回非零把最后一行丢掉,想兼容这种文件得写成while IFS= read -r line || [[ -n "$line" ]]。
还有一个更常见的错误:把while read接在管道后面,然后在循环里修改变量,出来发现变量没变。
count=0 cat file.txt | while read -r line; do (( count++ )) done echo $count # 输出 0!因为管道每一段都在子 shell 里跑 # 正确写法:用重定向 while read -r line; do (( count++ )) done < file.txt echo $count # 正常输出这个"管道创建子 shell,变量修改不回流"的机制,是 shell 脚本里最容易让人怀疑人生的坑之一。记住一句话:循环里要改变量,就别用管道喂数据,用重定向。
5. 管道、重定向与文本处理:把几把小刀合成一把瑞士军刀
5.1 重定向全家桶与那个经典的顺序问题
shell 的重定向符号不多,但组合起来变化很多:>覆盖写、>>追加、<读入、2>错误输出、&>全部输出、<<heredoc、<<<herestring。
真正的高频坑是2>&1的位置。这两个符号的顺序决定了结果:
# 正确:先把标准错误指向标准输出当前的位置(终端),再把标准输出指向文件 # 结果:正常日志进文件,错误信息打到终端 cmd > out.log 2>&1 # 实际上最常见的意图是这个:两个都进文件 cmd > out.log 2>&1 # 等价于 &> out.log # 错误:先让 stderr 指向终端,再把 stdout 指向文件 # 结果:错误信息还是打到终端,只有正常输出进文件 cmd 2>&1 > out.log背法很简单:重定向是从左往右依次生效的。2>&1的含义是"让 fd2 指向 fd1 此刻指向的地方",所以它必须写在> out.log后面,才能把 stderr 也导到文件里。这个顺序搞反的人非常多,包括不少写了几年脚本的。
&>是 bash 的简写,等价于> file 2>&1,省事但不符合 POSIX,只在自己机器上跑没问题。丢弃输出用> /dev/null 2>&1,这个组合几乎每个脚本里都能见到。
heredoc 也顺带说一下,写多行配置内容特别顺手:
cat > config.ini <<EOF [server] host = 127.0.0.1 port = 8080 EOF注意<<EOF里的变量会被展开,想完全字面量就用<<'EOF'(加引号)。这个细节在生成模板文件时很关键。
5.2 管道与退出码:pipefail 到底解决什么问题
管道|把前一个命令的标准输出接到后一个命令的标准输入。写起来很爽,但有个默认行为必须知道:管道的退出码取最后一个命令的。
# grep 没匹配到内容会返回 1,但 wc 成功返回 0 # 整条管道的退出码是 0,脚本会以为"成功" grep "ERROR" app.log | wc -l在set -e的脚本里,这种"前半段失败、后半段成功"的情况会被静默吞掉,导致你以为过滤成功了,实际上根本没匹配到。解决办法就是开头提到的那行:
set -o pipefail开了之后,管道里任意一个命令失败,整条管道的退出码就是非零。配合set -e,问题立刻暴露。
顺带一提,管道里每个命令都在独立子 shell里执行,所以上一节说的变量不回流问题,根源就在这里。理解了这个机制,很多"玄学 bug"就变成常识了。
5.3 grep、sed、awk、find、xargs 的组合拳
这五个命令是 shell 文本处理的骨架,各自的定位是:grep找行、sed改行、awk按列处理、find找文件、xargs把结果转成参数。单独用都简单,组合起来才有意思。
统计日志里某个错误出现的次数:
grep -c "Connection refused" app.log # 或者按小时聚合 grep "Connection refused" app.log | awk -F'[: ]' '{print $2}' | sort | uniq -c | sort -rnawk -F指定分隔符,$2取第二列,sort | uniq -c是统计频次的标准套路,最后sort -rn按数字倒序。这一套我用了不知道多少次,日志分析里几乎万能。
批量改名:
for f in *.jpeg; do mv -- "$f" "${f%.jpeg}.jpg" done${f%.jpeg}是参数展开,%表示从右边删掉最短匹配的后缀。同理#是从左边删前缀,%%和##是删最长匹配。这几个展开语法比sed高效得多,因为不启动子进程。
find 配合 xargs 批量处理:
# 找出 7 天前的日志并压缩 find /var/log/app -name "*.log" -mtime +7 -print0 | xargs -0 gzip # 谨慎使用 -delete,先打印确认 find . -name "*.tmp" -print-print0和-0是配套的,用\0分隔文件名而不是换行,这样带空格、带换行的文件名都不会出问题。我以前用find ... | xargs rm,被一个带空格的文件名坑过一次,删错东西的感觉相当酸爽。
find -exec也能干同样的事,区别是-exec {} +会批量传参,-exec {} \;每个文件起一个进程。文件多的时候前者快一个数量级。
6. 函数封装、调试与排错实录
6.1 函数:return 返回的不是值
shell 函数有个反直觉的设计:return只能返回0 到 255 的整数,而且这个数字的语义是"状态码"而不是"返回值"。你写return 300,实际拿到的是300 % 256 = 44。
想从函数拿到字符串结果,标准做法有两种:
# 方式一:命令替换(推荐,干净) get_timestamp() { date +%Y%m%d_%H%M%S } ts=$(get_timestamp) # 方式二:全局变量 parse_config() { CONFIG_HOST="$1" CONFIG_PORT="$2" }函数定义还有两种写法:func() { ... }和function func { ... },前者符合 POSIX,后者是 bash 扩展。我统一用前者,可移植性好一些。
参数传递跟脚本一样用$1$2,$#是参数个数。判断参数齐不齐,开头写一段守卫逻辑是良好习惯:
log_msg() { local level="$1" local msg="$2" if [[ -z "$level" || -z "$msg" ]]; then echo "用法: log_msg LEVEL MESSAGE" >&2 return 1 fi printf '[%s] [%s] %s\n' "$(date +%F' '%T)" "$level" "$msg" }这个log_msg是我每个脚本都带的,好处是输出格式统一,后面 grep 日志方便。注意>&2把错误提示打到标准错误,这样调用方重定向标准输出时不会把提示混进去。
6.2 调试三板斧:bash -n、set -x、shellcheck
脚本出错时,我按这个顺序排查:
第一步,bash -n script.sh做语法检查,不执行。括号没闭合、if少了fi、done多了,这一步就能拦住,比跑起来报一堆错快得多。
第二步,set -x打开执行追踪。每一行执行前会打印出展开后的实际命令,前缀是+:
set -x cp "$SRC" "$DST" set +x这一步最有价值的地方,是能看到变量展开后的样子——很多 bug 都是"我以为它是 A,实际它是空"。配合PS4='+ ${LINENO}: '可以显示行号,定位更快。
第三步,shellcheck做静态检查。这是个独立工具,能查出一堆你自己想不到的隐患:未加引号的变量、[ ]里没加引号、cd失败没判断、for遍历ls输出、rm -rf变量可能为空等等。我现在的习惯是提交前跑一遍,能消掉大部分低级错误。
shellcheck -S warning script.sh-S指定严重级别,warning及以上才报,避免被大量风格建议淹没。
还有一招是trap捕获退出,做清理工作:
cleanup() { rm -f "$TMP_FILE" } trap cleanup EXIT不管脚本正常结束还是被中断,EXIT陷阱都会触发,临时文件不会残留。这个习惯在写会创建临时文件的脚本时非常必要。
6.3 常见问题速查表
下面这张表是我这些年攒下来的,基本覆盖了新手 80% 的报错:
| 报错/现象 | 根本原因 | 解决方式 |
|---|---|---|
command not found | PATH 里没有这个命令,或变量名被当成命令 | 检查拼写,which确认路径,赋值等号别加空格 |
Permission denied | 缺少执行权限 | chmod +x script.sh |
bad interpreter: ^M | 文件是 Windows 换行 | sed -i 's/\r$//' script.sh |
[[: not found | shebang 是/bin/sh,实际指向 dash | 改成#!/usr/bin/env bash |
unary operator expected | [ ]里变量为空没加引号 | 变量一律加双引号,或改用[[ ]] |
too many arguments | 同上,空变量被拆成多个参数 | 同上 |
| 循环里改变量不生效 | 用了管道,子 shell 隔离 | 改用重定向< file |
脚本里cd后外部目录没变 | 子 shell 执行 | 用source执行 |
No such file or directory但文件明明在 | 路径含空格未引号,或\r干扰 | 加引号,检查换行符 |
| 脚本静默失败无输出 | set -e提前退出,或管道吞了错误 | 加set -x追踪,开pipefail |
6.4 几条踩坑换来的经验
第一条,别在脚本里cd之后就不管了。cd "$DIR" || exit 1是标准写法,cd失败还继续往下跑,后面的rm可能就在错误的目录里执行。我见过最惨的一次,是cd到一个不存在的目录,然后rm -rf *删了当前工作目录的内容。
第二条,删除类操作永远先 echo 再执行。批量删除、批量移动,我都习惯先跑一遍echo mv -- "$f" "$target",看清楚要动哪些文件,确认无误再把echo去掉。这个习惯救过我至少三次。
第三条,脚本只做一件事。一个大脚本包揽部署、备份、清理、发通知,出问题时你根本不知道是哪一步崩的。拆成小脚本,用一个主脚本串起来,每个脚本独立可测,排查成本直线下降。
第四条,重要脚本加个--dry-run开关。判断逻辑就一行:[[ "${DRY_RUN:-0}" == "1" ]] && echo "将执行:$cmd" && return。这个开关在给别人用你的脚本时特别有用,对方可以先空跑一遍看看效果。
第五条,别追求一行流。网上那些一行命令看起来很酷,但三个月后你自己都读不懂。写脚本是给人看的,机器只是顺便执行一下。可读性永远优先于短小。
7. 从基础到日常:几个能直接用的小工具
7.1 一个带日志和错误处理的模板
把前面讲的拼起来,就是一个我日常用的脚本骨架:
#!/usr/bin/env bash set -euo pipefail readonly SCRIPT_NAME="${0##*/}" readonly LOG_FILE="/tmp/${SCRIPT_NAME%.sh}.log" log() { local level="$1"; shift printf '[%s] [%-5s] %s\n' "$(date +%F' '%T)" "$level" "$*" \ | tee -a "$LOG_FILE" >&2 } die() { log ERROR "$*" exit 1 } main() { [[ $# -ge 1 ]] || die "用法: ${SCRIPT_NAME} <目标目录>" local target="$1" [[ -d "$target" ]] || die "目录不存在: ${target}" log INFO "开始处理 ${target}" # 业务逻辑 } main "$@"这个骨架有几个好处:日志统一格式且同时写文件和终端、出错有统一的die出口、参数校验集中在main开头、readonly防止关键变量被意外覆盖。main "$@"这个写法也值得养成习惯,所有逻辑收在函数里,方便测试和复用。
7.2 几个高频操作片段
按扩展名归档文件:
for f in *; do [[ -f "$f" ]] || continue ext="${f##*.}" mkdir -p "sorted/${ext}" mv -- "$f" "sorted/${ext}/" done检查服务是否存活(用退出码而非解析输出):
if curl -sf -o /dev/null --max-time 3 "http://127.0.0.1:8080/health"; then log INFO "服务正常" else die "服务无响应" fi-s静默模式、-f遇到 4xx/5xx 返回非零、-o /dev/null丢弃响应体。用退出码判断比grep输出可靠得多,因为输出格式可能变,退出码是契约。
定时任务里的坑:
# crontab 环境变量极简,务必写全路径 0 3 * * * /usr/bin/bash /opt/scripts/backup.sh >> /var/log/backup.log 2>&1crontab 执行时的PATH和交互式 shell 完全不同,脚本里用到的命令最好写绝对路径,或者在脚本开头显式设置PATH。我最早写定时任务时,脚本在终端跑得好好的,一进 crontab 就报command not found,查了半天才发现是这个原因。
7.3 这套基础能撑多远
把上面这些东西吃透,你会发现日常遇到的绝大多数自动化需求都能覆盖:批量处理文件、巡检日志、定时备份、部署编排、CI 里的构建脚本、开发环境的初始化。再往上走,方向就比较清晰了——想处理结构化数据就学jq和awk的高级用法,想写复杂的流程控制就考虑换 Python,想做更精细的进程和信号管理就研究trap、wait、作业控制。
我个人在实际操作中的体会是,shell 这门东西的"会"和"会用"之间隔着一层东西,那层东西就是对失败方式的熟悉程度。你写得越多,越知道哪些写法看着能跑其实埋雷,哪些地方看着多余其实必须加防御。上面那些注意事项和速查表,本质上都是在提前帮你把这层熟悉度补上。真正写脚本时,别急着一次写完,先跑通最小可用的三五行,再一点点往上叠,每叠一步验证一次,比对着屏幕憋一个两百行的大脚本然后一次性调试要轻松得多。