讲一个让我抓狂的下午。
我在维护着一个已有三年时间的公司部署脚本, 该脚本是用Bash进行编写的, 其长度大概为200行。某次需要给这个脚本添加一个小功能, 即「在失败的时候回滚到上一个版本」, 我当时认为半个小时就能够将其搞定。
结果我改了三个小时。
并非是逻辑具备复杂性, 而是Bash将我从事的每一步操作都狠狠地按在地面之上摩擦。存在变幻莫测的变量类型陷阱, 有着对空格极为敏感的状况, 存在那些条件判断时所遵循的诡异规则, 有着错误处理方面呈现出的反人类设计, 还有字符串进行拼接时遭遇的引号仿若噩梦的境况……在修改完毕之后, 我望着这多达200行的代码, 做出了一项决定: 全部予以重写。
过后的一天, 我耗费了 4 个小时完成了对它的重新编写, 新产生的版本脚本有 280 行, 相较于 Bash 要长出 40% , 然而其可读性提高了 5 倍, 新增的功能从本来的几小时缩减到了几分钟。
今儿个说说, 我基于何种缘由要脱离 Bash , 以及 Bash 在哪些情形之下仍会被保留着。
Bash 把我逼疯的几个时刻
不是Bash不行, 它于50行以内的脚本里头堪称无敌, 问题在于大部分真实场景之中, 脚本会逐渐变长, 一旦超过100行, Bash的每一个“特性”均化成阻碍。
时刻 1:变量没有类型,全是字符串
COUNT=10
COUNT=$((COUNT + 1)) # 11
COUNT="hello" # 现在 COUNT 是字符串
将其改写为, 等于, 左括号, 计数, 加, 一, 右括号, 井号, 报错冒号, 你好冒号, 找不到, 命令句号。
# 程序崩了你看着报错很懵
Bash之中,所有的变量皆是字符串, 那被你视作数字的事物, 有可能陡然间转变为字符串, 于编译期以及运行期均不会对你作出提醒, 起码要运行至那一行的时候才会有所报错, 然而Bash会令你对自己产生怀疑, 仿佛身处迷雾困局之中。
时刻 2:空格敏感到要命
# 这是赋值
NAME="张三"
# 这是命令调用(因为等号两边有空格)
NAME = "张三"
# → 报错: 找不到 NAME 命令
# if 语句里:
if
$NAME == "张三"
# 中括号两边要空格
if
$NAME == "张三"
# 报错
if
$NAME=="张三"
# 也错(逻辑变了)
# 数组取值
echo ${arr} #
echo ${arr } # 错
echo $arr # 取 $arr 然后输出字符串
写Bash之际, 你百分之三十的时间用于处理空格调试, 代码看似无误, 运行却出问题, 需反复核查每个空格, 标点符号。
时刻 3:错误处理是地狱
# 默认行为:命令失败也继续往下跑
mkdir /tmp/wat
先将 /tmp/wat/ 进行复制操作, # 上一步存在着有可能失败的情况, 而这一步存在着有可能成功的情况, 同时也存在着有可能失败的情况。
echo "OK" # 永远会打这一句,不管中间有没有错
# 标准做法:开 mode
set -euo
# -e 任一命令失败就退出
# -u 用未定义变量报错
# -o 管道任一环节失败就算失败
# 但 set -e 有大坑:有些场景它不生效
check {
在名为 log.txt 的文件里, 使用 grep 程序去查找包含"ERROR" 的内容, 若查找不到也算作(把 grep 退出码 1 当作)是"失败" 的情况, 这种"失败" 情况会触发 set -e 执行退出的操作。
check # 你可能并不想因为没找到 ERROR 就退出
bash的错误处理并非是关于写不写错误处理的问题, 而是即便写了也难以做到万无一失, 除使用set -e之外,还需要补充的设置有trap, 还需要补充的内容是|| true, 还需要补充各种各样的边界判断。
时刻 4:数据结构匮乏
Bash 4+ 有 ( array),但用起来非常诡异:
-A user
user="张三"
user=30
# 取值
echo ${user}
# 遍历(看清这个语法)
for key in "${!user}"; do
echo "$key = ${user}"
done
是什么意思呢? 那个“${!user}”, 它所代表的含义是, 去获取名为“user”的这个具有关联性的数组当中的所有的键。
# 第一次写这种代码要查 5 次文档
想有嵌套结构(map of map、list of map)吗? 直接把Bash抛一边去, 压根表达不了, 这可是它的硬伤啊。
时刻 5:字符串处理鬼画符
# 截取字符串
NAME=""
打出, 把${NAME%_*}的内容显示出来, # hello (在去掉最后一个 _ 之后)。
echo ${NAME#*_} # world
echo ${NAME//l/L} #
echo ${NAME:6:5} # world
# 这种语法写一遍能记住的我服你
同样的事:
name = ""
将name进行通过下划线来划分为多段的操作, 然后输出划分后的结果打印出来, # hello。
打印, 名字, 通过下划线进行分割, # 世界。
print(name.("l", "L")) #
print(name) # world
哪个易读哪个难读,不用我多说。
那个 200 行 Bash 改成 之后
来对比一下我那段代码的核心部分。
#!/bin/bash
# Bash 版:部署 + 失败回滚
set -euo
="myapp"
="/opt/${}"
="$1"
# 备份当前版本
=${}除以2, 将结果赋值, 若出现错误信息重定向到/dev/null不可见文件, 否则输出空字符串。
if
-z "$"
; then
echo "首次部署"
fi
# 拉新版本
mkdir -p ${}//${}
wget -q "${}/${}.tar.gz" \
-O /tmp/${}.tar.gz || {
echo "下载失败"; exit 1
tar, 将 /tmp/ 下以 ${} 命名的.tar.gz 文件解压, 解压到 ${} 下的 ${}的目录中。
# 切换软链接
ln -sfn ${}//${} ${}/.new
mv -T ${}/.new ${}/
# 重启服务
${} || {
echo "重启失败,回滚..."
if
-n "$"
; then
ln -sfn $ ${}/
${}
fi
exit 1
# 健康检查
sleep 5
for i in {1..5}; do
如果, 使用curl工具, 以静默模式, 访问端口8080的路径, 其输出结果重定向到/dev/null设备中, 那么。
echo "部署成功"
exit 0
fi
sleep 2
done
echo "健康检查失败,回滚"
# ... 重复的回滚代码
exit 1
版:
#!/usr/bin/env
"""部署脚本,失败自动回滚"""
time
.
from Path
sys
= "myapp"
= Path(f"/opt/{}")
定义函数运行命令项并需核对是否正常 -> 运行由列表形式呈现的命令, 同时设置核对为真, 该过程返回某种特定结果。
运行(命令, 进行检查时参数设为检查, 等于真值, 文本值为真值)。
def () -> str | None:
link = / ""
若存在链接的某种操作结果, 那就取该链接的某种操作后的名称, 若不存在链接的某种操作结果, 那就是无。
def (: str):
= / "" /
.mkdir(=True, =True)
print(f"下载 {}...")
tar = Path(f"/tmp/{}.tar.gz")
..(
f"{}/{}.tar.gz",
tar,
run(
将“tar”, 采用“xzf”方式, 对“str(tar)”进行操作, “-C”后接“str()”。
print("切换软链接...")
link = / ""
= / ".new"
if .():
.()
.()
.(link)
def ():
run(
"", "",
def (=5, =2) -> bool:
time.sleep(3)
for i in range():
try:
with ..(":8080/", =2) as r:
if r. == 200:
True
as e:
(“空格健康检查第, 我加一的结果次失败冒号太空格, e”)。
time.sleep()
False
def (prev: str):
print(f"回滚到 {prev}...")
link = / ""
if link.():
link.()
link.( / "" / prev)
()
def main():
if len(sys.argv) < 2:
print("用法: .py ")
sys.exit(1)
= sys.argv
prev = ()
try:
()
()
if not ():
raise ("健康检查失败")
print(f"部署成功: {}")
as e:
print(f"部署失败: {e}")
if prev and prev != :
(prev)
sys.exit(1)
if == "":
main()
版的好处一目了然:
啥时候 Bash 还是首选
不是我讲的要全面去禁用Bash, 在下面这些场景当中, Bash仍然属于最佳可选的选择范畴:
1. 具有较少行数的, 可以直接在Bash中运用的, 简洁直白的命令组合(限定在10行以内)。
# 这种不要写
ps -a -- "=" -q | xargs rm
2. 环境较简单之地域, 容器入口还有所涉及的系统启动脚本, 仅 Bash 层面便可满足相关所需啦!
3. 一次性的运维操作:临时跑一下就丢,没必要写 。
4. 位于CI/CD步骤当中的那个被称作「胶水」的东西, 它存在于CI里多个步骤的组合之内, 把Bash直接书写在yaml里面是最为便利的。
判断的标准是, 这段脚本在以后会不会遭受反复修改, 会不会需要展开测试, 会不会有别人接手去维护呢? 倘若这三者之中有一个答案为“要”, 那就予以考虑。要是这三者的答案均为“不要”, 那么Bash是完全没问题的。
一些迁移技巧
Bash 转 的过程中,有几个等价替换很常用:
# Bash: ls *.txt
glob
files = glob.glob("*.txt")
# 或更现代
from Path
# Bash: rm -rf /tmp/foo
.("/tmp/foo", =True)
对打开文件“file”中的每一行, 若该行中存在“ERROR”, 则对其进行1的累加, 最后求总和。
使用Bash, 查找。匹配名为“*.log”的文件, 文件修改时间大于7天。
from Path
time
now = time.time()
=
当下的时间减去, 进程状态统计信息如果大于, 七乘以八万六千四百秒这样的情况。
用.open("out.tar.gz""w:gz")这种方式, 以t作为操作对象。
t.add("dir/")
# Bash: curl
., json
对..("")进行读取操作, 将读取结果通过json.loads函数处理后赋值给data。
对所有shell操作而言, 标准库基本都做了覆盖了。刚开始写的时候要多写一点, 等习惯之后就没问题。
收尾
那次, 完成了从 Bash 的更改之后, 我们组, 其他几个 Bash 脚本, 也就是数据库备份脚本、日志归档脚本、监控采集脚本, 也都相继迁移过来了。
虽然迁移成本并非处于较低水平, 平均每个脚本需要耗费两至三个小时, 然而之后维护成本会呈断崖式下降, 菜鸟也能够上手, 尽管没人乐意去学习Bash那些怪异扭曲的语法, 不过一周时间便能够写得像模像样。
最后引用一句我所看到的话语: 「Bash的语法乃是1970年代的设计, 而另个语法是1990年代的设计。倘若你在2025年依旧运用1970年代的事物去书写复杂逻辑, 那么你或许在浪费自身的时间。」。
改为 100 行的 Bash 代码, 进行修改, 或许这就是你为自己未来三年减轻负担的, 最具性价比的一项投资?