Shell命令行展开,我愿称之为 “无敌!”
为什么 rm *.log 能删除所有日志,而 rm "*.log" 却提示文件不存在?
为什么echo * 和 echo "*" 结果完全不同?
为什么老司机写的脚本总是那么简洁高效?
神奇魔法,简洁而不简单
很多人把 Shell 当成一个简单的 “你输入,它执行” 的工具。但事实上,在你敲下回车后,Shell 会在执行命令前对输入进行一系列 “魔法处理”。这个过程就叫 命令行展开(Expansion)。
搞懂它,可助你进阶为 “命令行大神”。今天 opsnot.com 就来揭秘这个 “神奇魔法”。
展开就是 Shell 把你输入的简写,自动展开成全写。
echo a{1,2}b
# 输出: a1b a2b
在 echo 执行前,Shell 已经把 a{1,2}b 展开为 a1b a2b。所以 echo 真正接收到的是两个参数:a1b 和 a2b,而不是原始的 a{1,2}b。
这就是为什么很多命令看起来有 “魔法” 的原因 —— 它们其实只是接收了 Shell 展开后的结果。
场景: 创建 2024-2025 年每月的日志目录。
# 低效做法:一个个mkdir或写循环
# 高效做法:一行搞定 - opsnot
mkdir -p logs/{2024,2025}/{01..12}
# 自动生成:
# logs/2024/01 logs/2024/02 ... logs/2025/12
注意: 花括号展开是字符串层面的操作,不检查文件是否存在,只负责生成字符串组合。
场景: 修改 nginx.conf 前先备份。
# 常规做法(手敲到酸)
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
# opsnot.com 推荐: {,string} 组合,逗号前为空
cp /etc/nginx/nginx.conf{,.bak}
# Shell展开为: cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
这个技巧必须掌握,运维日常操作能省一半字符。
# 复杂的目录结构也能一行搞定 by 加班哥 - opsnot.com
mkdir -p project/{src,test}/{js,css,img}
# 嵌套使用 - opsnot
echo a{A{1,2},B{3,4}}b
# 输出: aA1b aA2b aB3b aB4b
# 数字范围
echo {1..5}
# 输出: 1 2 3 4 5
# 字母范围(支持逆序)
echo {z..a}
# 输出: z y x w v u t s r q p o n m l k j i h g f e d c b a
# 带步长(bash 4.0+)
echo {0..20..2}
# 输出: 0 2 4 6 8 10 12 14 16 18 20
重要特性: 花括号展开至少需要2个元素才生效,否则会被当作普通字符串:
echo {X}
# 输出: {X} (不会展开)
echo {X,Y}
# 输出: X Y (正常展开)
cd ~
# 相当于: cd $HOME
场景: 查看 opsnot 用户的 SSH 配置。
# ~username 会展开为该用户的家目录
cat ~opsnot/.ssh/authorized_keys
# Shell展开为(假设opsnot家目录是/home/opsnot):
# cat /home/opsnot/.ssh/authorized_keys
在多用户服务器环境下特别实用。
# ~- 展开为上一个工作目录(OLDPWD变量)
[root@opsnot /etc]$ cd /var/log
[root@opsnot /var/log]$ cd ~-
/etc
[root@opsnot /etc]$
# 也可以在其他命令中使用
ls ~- # 列出上次目录的文件
加班哥重要提醒:
这比 cd - 更灵活,因为 ~- 可以在任何需要路径的地方使用。
但是,cd - 有一个额外特性是它会在切换后打印出目标目录的路径,而 cd ~- 不会。
# 使用 cd -
[/tmp]$ cd /etc
[/etc]$ cd -
/tmp
[/tmp]$
# 使用 cd ~-
[/tmp]$ cd /etc
[/etc]$ cd ~-
[/tmp]$
# ~+ 展开为当前工作目录(PWD变量)
echo ~+
# 等同于: pwd
场景: 在变量后拼接字符串。
# 错误示例
FILENAME="report_opsnot"
echo "Saving as $FILENAME_backup.zip"
# 输出: Saving as .zip
# 因为Shell尝试展开$FILENAME_backup这个不存在的变量
# 正确做法:用大括号界定变量名
echo "Saving as ${FILENAME}_backup.zip"
# 输出: Saving as report_opsnot_backup.zip
场景: 脚本参数未传入时使用默认值。
# ${VAR:-default} 如果VAR未设置或为空,使用default
APP_ENV=${1:-production}
echo "Running in $APP_ENV"
# ./deploy.sh dev -> Running in dev
# ./deploy.sh -> Running in production
# 实际应用 - opsnot.com
docker run -d -e "APP_ENV=${ENV:-production}" my_app
filename="report_2024_opsnot.pdf"
# 删除前缀(最短匹配)
echo ${filename#report_}
# 输出:2024_opsnot.pdf
# 删除前缀(最长匹配)
echo ${filename##*_}
# 输出:opsnot.pdf
# 删除后缀(最短匹配)
echo ${filename%.pdf}
# 输出:report_2024_opsnot
# 删除后缀(最长匹配)
echo ${filename%%_*}
# 输出:report
# 实战:提取文件名和扩展名
file="/var/log/nginx/access.log"
basename="${file##*/}" # access.log
dirname="${file%/*}" # /var/log/nginx
extension="${file##*.}" # log
name="${basename%.*}" # access
path="/home/opsnot/data/opsnot"
# 替换第一个匹配
echo ${path/opsnot/admin}
# 输出:/home/admin/data/opsnot
# 替换所有匹配
echo ${path//opsnot/admin}
# 输出:/home/admin/data/admin
var="opsnot.com"
echo ${#var}
# 输出: 10
# 实用场景:验证密码长度
read -sp "输入密码: " password
if [ ${#password} -lt 8 ]; then
echo "密码至少需要8位"
fi
命令替换允许你把一个命令的标准输出作为另一个命令的参数。
# 推荐: $() 语法清晰,可嵌套
current_date=$(date +%Y-%m-%d)
# 过时: 反引号 ` 难以阅读,不建议使用
current_date=`date +%Y-%m-%d`
# 嵌套示例(只有$()能这样干净地嵌套)
backup_file="db_$(date +%Y%m%d)_$(hostname).sql"
5.2.1 动态文件名
# 创建带日期的备份
BACKUP="backup_$(date +%Y%m%d_%H%M%S).tar.gz"
tar czf "$BACKUP" /data
# 日志轮转 - opsnot
mv app.log app.log.$(date +%Y%m%d)
5.2.2 批量杀进程
# 低效做法:ps aux | grep java, 复制PID, 再kill
# 高效做法:命令替换一步到位
kill -9 $(pgrep -f "java.*tomcat")
# 更安全的写法(确认后再杀)
ps aux | grep "[t]omcat" # 查看要杀的进程
# 确认无误后:
kill $(pgrep -f "tomcat")
5.2.3 统计信息
echo "当前目录有 $(ls | wc -l) 个文件"
echo "日志文件数: $(ls /var/log/*.log 2>/dev/null | wc -l)"
# 磁盘告警脚本 - opsnot.com
usage=$(df -h / | awk 'NR==2 {print $5}' | sed 's/%//')
if [ $usage -gt 80 ]; then
echo "警告:磁盘使用率${usage}%"
fi
这就是 * 的魔力所在——它不是命令的功能,而是 Shell 的功能!
# by opsnot.com - 运维不加班
# * 匹配0个或多个任意字符
ls *.log
# ? 匹配恰好1个任意字符
ls file_?.txt # 匹配 file_1.txt, file_a.txt
# [] 匹配括号中的任意一个字符
ls file_[abc].txt # 匹配 file_a.txt, file_b.txt, file_c.txt
ls img_[0-9].jpg # 匹配 img_0.jpg 到 img_9.jpg
# [!] 或 [^] 匹配不在括号中的字符
ls file_[!0-9].txt # 匹配像 file_a.txt, file_X.txt,但不匹配 file_1.txt
# opsnot - 加班哥整理
# 需要先启用扩展模式
shopt -s extglob
# ?(pattern) 匹配0次或1次
ls file?(s).txt # 匹配 file.txt 和 files.txt
# *(pattern) 匹配0次或多次
rm *.@(txt|log|bak) # 删除所有txt、log、bak文件
# +(pattern) 匹配1次或多次
ls +(file|data)[0-9].txt
# @(pattern) 精确匹配一次
mv @(test|dev|prod).conf /backup/
# !(pattern) 匹配除此之外的
ls !(*.log) # 列出所有非log文件
# 错误做法
echo .*
# 会匹配到 . 和 .. (当前目录和父目录)
# 正确做法
ls -d .[!.]* # 匹配以.开头,第二个字符不是.的文件
ls -A # 更简单,列出所有隐藏文件(不含.和..)
# 或者用扩展通配符
shopt -s dotglob # 让*也能匹配隐藏文件
echo *
# bash 4.0+ 支持
shopt -s globstar
# ** 递归匹配所有子目录
ls **/*.log # 查找所有子目录的log文件
grep -r "opsnot" **/*.conf # 在所有conf文件中搜索
du -sh **/node_modules # 统计所有node_modules大小
# 基本语法: $((expression))
echo $((5 + 3)) # 8
echo $((10 * 2)) # 20
echo $((17 % 5)) # 2
# 变量运算
count=10
echo $((count + 5)) # 15
((count++)) # count自增1
# 加班哥划重点:
# ((...)) 结构本身会进行算术运算并对变量赋值,无需 $ 和赋值符号,((count++))相当于 let "count++"
# 实战: 循环计数器 - opsnot
for ((i=1; i<=5; i++)); do
echo "第${i}次执行"
done
# 批量重命名加序号
num=1
for file in *.jpg; do
mv "$file" "$(printf 'img_%03d.jpg' $num)"
((num++))
done
前面讲了这么多展开,现在该学习如何控制展开了。引用是选择性禁止展开的机制。
echo text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER
# 输出: text /home/user/file.txt a b foo 4 opsnot
# 所有展开都执行了
双引号禁止以下展开: - 单词分割(word splitting) - 路径名展开(*) - 花括号展开({}) - 波浪线展开(~)
双引号允许以下展开: - 参数展开($VAR) - 命令替换($()) - 算术展开($((2+2)))
echo "text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER"
# 输出: text ~/*.txt {a,b} foo 4 opsnot
# 只有$(),$(()),$VAR执行了
# 重点: 双引号保留空格和换行
echo $(cal)
# 输出: 五月 2025 日 一 二 三 四 五 六 1 2 3 4 5...
# (所有输出挤在一行,因为没有引号时会进行单词分割)
echo "$(cal)"
# 输出: (保留原始格式的日历)
# 五月 2025
# 日 一 二 三 四 五 六
# 1 2 3 4 5 6 7
# ...
关键区别: 没有引号的命令替换会让 Shell 进行单词分割,把输出的每个单词当作独立参数。加了双引号后,整个输出作为一个参数,保留了空格和换行。
单引号禁止所有展开,字面量输出。
echo 'text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER'
# 输出: text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER
# 所有特殊字符都失效,原样输出
### 8.4 转义字符 \
反斜杠可以有选择地禁用单个字符的特殊含义。
# 场景: 输出美元符号
echo "The balance for user $USER is: \$5.00"
# 输出: The balance for user opsnot is: $5.00
# 在文件名中使用空格
touch my\ file.txt
# 等同于: touch "my file.txt"
# 多行命令(转义换行符)
echo "This is a \
very long \
line"
# 输出: This is a very long line
# 实战对比 - opsnot.com
[root@server ~]$ echo text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER
text /root/test.txt a b foo 4 root
[root@server ~]$ echo "text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER"
text ~/*.txt {a,b} foo 4 root
[root@server ~]$ echo 'text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER'
text ~/*.txt {a,b} $(echo foo) $((2+2)) $USER
现在你应该明白了:
echo * 和 echo "*" 不同?# echo *
# 1. Shell看到*,触发路径名展开
# 2. 替换为当前目录所有文件: file1.txt dir1 file2.log
# 3. echo接收到3个参数并输出
# 输出: file1.txt dir1 file2.log
# echo "*"
# 1. Shell看到双引号,禁止路径名展开
# 2. echo接收到一个字面量参数: 字符串 "*"
# 3. echo输出这个字符串
# 输出: *
rm *.log 有效但 rm "*.log" 无效?# rm *.log
# Shell展开为: rm access.log error.log app.log
# rm删除这3个真实存在的文件 ✓
# rm "*.log"
# Shell不展开,rm尝试删除名为 "*.log" 的文件
# 找不到这个文件 ✗
# 输出: rm: cannot remove '*.log': No such file or directory
# 修改扩展名
for f in *.jpeg; do
mv "$f" "${f%.jpeg}.jpg"
done
# 批量添加前缀 - opsnot
for f in *; do
mv "$f" "backup_$f"
done
# opsnot - 加班哥重要提醒:
# 这个循环在处理以点开头的隐藏文件时,行为取决于 shopt -s dotglob 是否设置。默认情况下,* 不匹配隐藏文件。如果意图是备份所有文件(包括隐藏文件),可以使用 for f in * .[!.]*; do 或者先设置 dotglob。
# 替换文件名中的字符串
for f in *old*; do
mv "$f" "${f//old/new}"
done
#!/bin/bash
# 带时间戳的增量备份 - opsnot.com
backup_dir=~/backups/$(date +%Y%m%d_%H%M%S)
source_dir=${1:-.} # 默认当前目录
max_backups=${MAX_BACKUPS:-7}
mkdir -p "$backup_dir"
# 压缩备份,排除不必要的文件
tar czf "${backup_dir}/backup.tar.gz" \
--exclude='*.log' \
--exclude='node_modules' \
--exclude='.git' \
"$source_dir"
# 只保留最近N个备份
cd ~/backups
ls -t | tail -n +$((max_backups + 1)) | xargs rm -rf
echo "✓ 备份完成: $backup_dir"
#!/bin/bash
# 系统信息收集 - by opsnot.com
REPORT_DIR=~/system_reports/$(date +%Y%m%d)
HOSTNAME=$(hostname)
echo "=== 收集系统信息 ==="
# 花括号展开:创建报告目录结构
mkdir -p ${REPORT_DIR}/{system,network,storage,processes}
# 命令替换:收集各种系统信息
echo "收集时间: $(date)" > ${REPORT_DIR}/collection_time.txt
echo "主机名: $HOSTNAME" >> ${REPORT_DIR}/collection_time.txt
# 路径名展开 + 命令替换:备份重要配置
cp /etc/{passwd,group,hosts,fstab} ${REPORT_DIR}/system/ 2>/dev/null
# 参数展开:生成系统概览
{
echo "=== 系统概览 ==="
echo "主机名: $HOSTNAME"
echo "用户: $USER"
echo "家目录: $HOME"
echo "Shell: $SHELL"
echo "语言: $LANG"
} > ${REPORT_DIR}/system_overview.txt
# 花括号展开:创建多个检查点文件
touch ${REPORT_DIR}/checkpoint_{system,network,storage,done}
# 命令替换 + 算术展开:生成报告统计
total_files=$(find ${REPORT_DIR} -type f | wc -l)
total_size=$(du -sh ${REPORT_DIR} | cut -f1)
echo "✅ 系统信息收集完成"
echo "报告目录: ${REPORT_DIR}"
echo "文件数量: ${total_files}"
echo "总大小: ${total_size}"
# 错误:花括号展开发生在变量替换之前
start=1
end=5
echo {$start..$end}
# 输出: {1..5} (没有展开!)
# 正确做法: 用seq或eval
echo $(seq $start $end)
# 输出: 1 2 3 4 5
# 错误处理
for file in $(ls *.mp3); do
mv $file ${file// /_} # 如果文件名有空格会出错
done
# 正确处理: 加引号
for file in *.mp3; do
mv "$file" "${file// /_}"
done
# 危险操作
cd /tmp
rm -rf * # 如果当前目录不是/tmp就悲剧了
# 更安全的做法
cd /tmp && rm -rf *
# 或者
rm -rf /tmp/*
展开是 Shell 最强大的特性,记住这些要点:
1. 展开的顺序:
花括号展开 → 波浪线展开 → 参数展开 → 命令替换 → 算术展开 → 路径名展开
2. 引用控制:
- 无引号: 所有展开生效
- 双引号: 禁用路径/花括号/波浪线,保留变量/命令替换
- 单引号: 禁用所有展开
3. 实用高频:
- cp file{,.bak} 快速备份
- mkdir -p {2024,2025}/{01..12} 批量创建目录
- kill $(pgrep xxx) 批量杀进程
- ${var:-default} 设置默认值
- ${file%.ext} 操作文件名
掌握了展开,你的命令行效率至少提升3倍。下次写 for 循环前,先想想能不能用展开给一行搞定。
更多linux强大工具技巧,请看往期文章:
Strace命令,Linux系统调用追踪神器!
LINUX JSON处理, jq 命令行工具实战指南!
追踪打开文件的瑞士军刀 - lsof 运维实操手册
运维火眼金睛之 - tcpdump抓包实操手册
本文由 opsnot.com 整理,转载请注明出处,喜欢就关注一下吧!