نوشتن اسکریپتهای Bash که خراب نمیشوند
بیشتر اسکریپتهای شل خوب کار میکنند تا وقتی که نام یک فایل فاصله داشته باشد یا دستوری وسط کار شکست بخورد. حالت سختگیرانه، کوتیشنگذاری، فایلهای موقت امن و trap برای پاکسازی را یاد بگیرید و همه را در یک اسکریپت پشتیبانگیری واقعی کنار هم بگذارید.
در این صفحه
- هر اسکریپت را یکجور شروع کنید
- گزینهٔ set -e: با اولین خطا متوقف شو
- گزینهٔ set -u: متغیرِ تعریفنشده ممنوع
- گزینهٔ set -o pipefail: شکستهای داخل پایپلاین
- متغیرها را داخل کوتیشن بگذارید
- خواندنِ درستِ فایل، خطبهخط
- پاکسازی با trap
- تابعها، آرگومانها و خطاها
- یک مثال کامل: اسکریپت پشتیبانگیری
- بگذارید ShellCheck اسکریپتهایتان را بازبینی کند
- جمعبندی
اسکریپتهای Bash معمولاً با چند دستور شروع میشوند که در یک فایل چسبانده شدهاند. روی کامپیوتر خودتان، با فایلهای خودتان، در یک روز خوب کار میکنند. بعد یک شب نام فایلی فاصله دارد، دیسک پر میشود یا دستوری وسط کار شکست میخورد، و اسکریپت طوری ادامه میدهد که انگار هیچ اتفاقی نیفتاده؛ گاهی هم سر راه چیزهای اشتباهی را پاک میکند.
راهحلها کم و مکانیکیاند. این مقاله عادتهایی را مرور میکند که باعث میشوند اسکریپتها با سروصدا و بهشکل امن شکست بخورند، و بعد همه را در یک اسکریپت پشتیبانگیری که واقعاً میتوانید از آن استفاده کنید ترکیب میکند. فرض بر این است که با دستورهای دستورهای ضروری لینوکس که هر برنامهنویسی باید بداند راحت هستید.
هر اسکریپت را یکجور شروع کنید#
#!/usr/bin/env bash
set -euo pipefailخط اول (که به آن shebang میگویند) مشخص میکند کدام برنامه فایل را اجرا کند. /usr/bin/env bash برنامهٔ bash را هر جا که نصب شده باشد پیدا میکند، که روی macOS و BSD مهم است. یک بار با chmod +x script.sh فایل را اجرایی کنید و آن را با ./script.sh اجرا کنید.
خط دوم چیزی را روشن میکند که معمولاً حالت سختگیرانه (strict mode) نامیده میشود. هر حرف جلوی یک شکستِ بیصدای متفاوت را میگیرد.
گزینهٔ set -e: با اولین خطا متوقف شو#
Bash بهطور پیشفرض شکستها را نادیده میگیرد و به هر حال خط بعدی را اجرا میکند:
cd /var/www/myapp-typo # fails: No such file or directory
rm -rf ./* # ...and now runs in whatever directory you were inبا set -e، اسکریپت به محض اینکه دستوری شکست بخورد (کد خروجِ غیرصفر برگرداند) خارج میشود. شکستهایی که انتظارشان را دارید، مثل grepی که چیزی پیدا نمیکند، را میتوان صریحاً مدیریت کرد:
if grep -q "ERROR" app.log; then
echo "Errors found"
fi
grep -q "ERROR" app.log || echo "No errors" # || also counts as handling itجاهایی که set -e کمکی نمیکند
set -e در شرطها (if، while، &&، ||) نادیده گرفته میشود و دو نقطهٔ کورِ معروف هم دارد:
local out=$(some_command)هرگز شکست نمیخورد، چون کد خروجی که میگیرید مالِlocalاست. اعلان و مقداردهی را در دو خط جدا انجام دهید: اولlocal outو بعدout=$(some_command).((count++))وقتیcountصفر بوده ۱ برمیگرداند، که باset -eاسکریپت را میکُشد. بهجای آن ازcount=$((count + 1))استفاده کنید.
گزینهٔ set -u: متغیرِ تعریفنشده ممنوع#
یک غلط تایپی در نام متغیر معمولاً بیصدا به یک رشتهٔ خالی تبدیل میشود:
name="Ada"
echo "Hello, $nme" # prints "Hello, " without set -uاین در یک echo آزاردهنده است و در rm -rf "$build_dir/"* خطرناک. با set -u، استفاده از متغیری که مقدار ندارد خطاست:
./greet.sh: line 3: nme: unbound variableبرای متغیرهایی که مجازند نباشند، با ${var:-default} مقدار پیشفرض بدهید، یا با ${var:?message} با پیامِ خودتان شکست بخورید:
port=${PORT:-8080} # use $PORT if set, otherwise 8080
: "${API_KEY:?API_KEY must be set}" # stop with this error if it's missingگزینهٔ set -o pipefail: شکستهای داخل پایپلاین#
کد خروجِ یک پایپلاین معمولاً کد خروجِ آخرین دستور آن است؛ پس شکستهای قبلی در پایپ ناپدید میشوند:
$ false | true; echo $?
0
$ set -o pipefail
$ false | true; echo $?
1بدون آن، mysqldump mydb | gzip > backup.sql.gz حتی وقتی dump شکست میخورد «موفق» است، چون gzip با خوشحالی یک ورودیِ خالی را فشرده میکند. با pipefail، اگر هر دستوری در پایپلاین شکست بخورد، کل پایپلاین شکست میخورد.
متغیرها را داخل کوتیشن بگذارید#
این بزرگترین منبعِ باگهای شل است. یک $var بدون کوتیشن روی فاصلهها شکسته میشود و بعد بهعنوان الگوی wildcard گسترش پیدا میکند:
file="my report.txt"
rm $file # rm: cannot remove 'my': No such file or directory
# rm: cannot remove 'report.txt': No such file or directory
rm "$file" # removes "my report.txt"و اگر متغیری * داشته باشد، گسترشِ بدون کوتیشن آن را به فهرستی از فایلهای پوشهٔ فعلی تبدیل میکند. قانون ساده است: همیشه دور متغیرها و جایگزینیهای دستور (command substitution) کوتیشن دوتایی بگذارید: "$file"، "$(date +%F)"، "${files[@]}". استثناهای نادر عمدیاند و یک کامنت توضیحی لازم دارند.
برای فرستادنِ همهٔ آرگومانهای اسکریپت به یک دستور دیگر، از "$@" استفاده کنید. هر آرگومان را دستنخورده نگه میدارد، حتی وقتی فاصله داشته باشد؛ $* و $@ بدون کوتیشن این کار را نمیکنند:
# called as: ./wrapper.sh "a b" c
for arg in "$@"; do echo "[$arg]"; done # [a b] [c]
for arg in $*; do echo "[$arg]"; done # [a] [b] [c]برای شرطها از [[ ]] استفاده کنید
در Bash، [[ -f "$file" ]] را به [ -f "$file" ] ترجیح دهید. کلمهها را نمیشکند، پس یک کوتیشنِ جاافتاده خرابش نمیکند، و از &&، || و تطبیق الگو مثل [[ $name == *.txt ]] پشتیبانی میکند.
خواندنِ درستِ فایل، خطبهخط#
for line in $(cat file) روی کلمهها حلقه میزند، نه خطها، و سر راه wildcardها را هم گسترش میدهد. روش درست این است:
while IFS= read -r line; do
echo "Processing: $line"
done < hosts.txtIFS= فاصلههای ابتدا و انتهای خط را نگه میدارد و -r جلوی تفسیر بکاسلشها بهعنوان کاراکتر escape را میگیرد.
پاکسازی با trap#
اسکریپتها اغلب فایلهای موقت میسازند. mktemp فایلی با نامی یکتا و غیرقابل حدس میسازد (هرگز /tmp/myscript.tmp را ثابت در کد ننویسید)، و trap ... EXIT تضمین میکند که آن فایل هر طور اسکریپت تمام شود پاک شود: بهطور عادی، بهخاطر خطا با set -e، یا وقتی Ctrl+C را میزنید:
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL https://example.com/data.csv -o "$tmpdir/data.csv"
# ...work with the file; no matter what happens, $tmpdir is deleted at the endبه کوتیشن تکی دقت کنید: $tmpdir وقتی trap اجرا میشود گسترش پیدا میکند، نه وقتی تعریف میشود. فقط kill -9 (یعنی SIGKILL) از trap رد میشود، چون هیچ چیزی نمیتواند آن سیگنال را بگیرد.
تابعها، آرگومانها و خطاها#
die() {
echo "Error: $*" >&2 # error messages go to stderr
exit 1
}
require_dir() {
local dir=$1 # local: don't leak into the rest of the script
[[ -d "$dir" ]] || die "'$dir' is not a directory"
}داخل یک تابع، $1، $2… و "$@" آرگومانهای خودِ تابع هستند. برای هر متغیری که تابع میسازد از local استفاده کنید، خطاها را در خروجیِ خطای استاندارد (>&2) چاپ کنید تا با خروجیِ واقعی قاطی نشوند، و با یک کد غیرصفر خارج شوید تا فراخواننده (یک آدم، cron یا CI) بفهمد مشکلی پیش آمده.
یک مثال کامل: اسکریپت پشتیبانگیری#
این اسکریپتی است که از یک پوشه آرشیو میسازد، فقط چند آرشیوِ آخر را نگه میدارد و همهٔ نکتههای بالا را عملاً به کار میگیرد:
#!/usr/bin/env bash
# backup.sh: archive a directory and keep only the newest few archives.
set -euo pipefail
usage() {
echo "Usage: $0 SOURCE_DIR DEST_DIR [KEEP]" >&2
exit 2
}
[[ $# -ge 2 ]] || usage
src=$1
dest=$2
keep=${3:-7}
[[ -d "$src" ]] || { echo "Error: '$src' is not a directory" >&2; exit 1; }
mkdir -p "$dest"
# Write to a temporary file first, so a failed run never leaves a half-written archive behind
tmpfile=$(mktemp "$dest/.backup.XXXXXX")
trap 'rm -f "$tmpfile"' EXIT
name="$(basename "$src")-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$tmpfile" -C "$(dirname "$src")" "$(basename "$src")"
mv "$tmpfile" "$dest/$name"
echo "Created $dest/$name"
# The timestamp in the name sorts alphabetically, so the glob lists the oldest first
archives=("$dest/$(basename "$src")"-*.tar.gz)
excess=$(( ${#archives[@]} - keep ))
for (( i = 0; i < excess; i++ )); do
rm -- "${archives[i]}"
echo "Removed ${archives[i]}"
done$ ./backup.sh "my site" backups 2
Created backups/my site-20261029-081312.tar.gz
$ ./backup.sh "my site" backups 2
Created backups/my site-20261029-081313.tar.gz
$ ./backup.sh "my site" backups 2
Created backups/my site-20261029-081314.tar.gz
Removed backups/my site-20261029-081312.tar.gzچند جزئیات که ارزش دیدن دارند:
- پوشهٔ مبدئی را که نامش فاصله دارد درست مدیریت میکند، چون همهٔ متغیرها داخل کوتیشناند.
- آرشیو با یک نامِ موقت نوشته میشود و در پایان تغییر نام داده میشود. تغییر نام داخل یک پوشه اتمی است؛ پس پوشهٔ پشتیبانها هرگز یک آرشیوِ نیمهکاره ندارد، حتی اگر
tarشکست بخورد یا دیسک پر شود. (mktempهمچنین فایل را فقط برای خودتان خواندنی میکند، که برای پشتیبان مناسب است.) rm --پایانِ گزینهها را مشخص میکند؛ پس نام فایلی که با-شروع میشود با یک گزینه اشتباه گرفته نمیشود.- استفادهٔ غلط با کد ۲ و خطاهای واقعی با کد ۱ خارج میشوند؛ پس یک کار
cronیا پایپلاین CI میتواند آنها را از هم تشخیص دهد.
بگذارید ShellCheck اسکریپتهایتان را بازبینی کند#
ShellCheck یک linter برای اسکریپتهای شل است. کوتیشنهای جاافتاده، تلهٔ local، تفاوت $* و "$@" و دهها اشتباه دیگر را میگیرد و برای هر کدام توضیح میدهد. با sudo apt install shellcheck (یا brew install shellcheck) نصبش کنید، shellcheck backup.sh را اجرا کنید و آن را به ویرایشگر یا CIتان اضافه کنید. سریعترین راه برای جا انداختنِ همهٔ این عادتهاست.
جمعبندی#
- با
#!/usr/bin/env bashوset -euo pipefailشروع کنید و بدانیدset -eبه کجاها نمیرسد. - هر گسترشی را داخل کوتیشن بگذارید:
"$var"،"$(cmd)"،"$@". - برای مقدارهای اختیاری و الزامی از
${var:-default}و${var:?message}استفاده کنید. - فایلهای موقت را با
mktempبسازید و باtrap '...' EXITپاکشان کنید. - خطاها را به
>&2بفرستید، با کدهای خروجِ معنادار خارج شوید و ShellCheck را اجرا کنید.