لینوکسمتوسط

نوشتن اسکریپت‌های Bash که خراب نمی‌شوند

بیشتر اسکریپت‌های شل خوب کار می‌کنند تا وقتی که نام یک فایل فاصله داشته باشد یا دستوری وسط کار شکست بخورد. حالت سخت‌گیرانه، کوتیشن‌گذاری، فایل‌های موقت امن و trap برای پاک‌سازی را یاد بگیرید و همه را در یک اسکریپت پشتیبان‌گیری واقعی کنار هم بگذارید.

یک ترمینال که اسکریپت Bashی را اجرا می‌کند که با set -euo pipefail شروع می‌شود
در این صفحه

اسکریپت‌های Bash معمولاً با چند دستور شروع می‌شوند که در یک فایل چسبانده شده‌اند. روی کامپیوتر خودتان، با فایل‌های خودتان، در یک روز خوب کار می‌کنند. بعد یک شب نام فایلی فاصله دارد، دیسک پر می‌شود یا دستوری وسط کار شکست می‌خورد، و اسکریپت طوری ادامه می‌دهد که انگار هیچ اتفاقی نیفتاده؛ گاهی هم سر راه چیزهای اشتباهی را پاک می‌کند.

راه‌حل‌ها کم و مکانیکی‌اند. این مقاله عادت‌هایی را مرور می‌کند که باعث می‌شوند اسکریپت‌ها با سروصدا و به‌شکل امن شکست بخورند، و بعد همه را در یک اسکریپت پشتیبان‌گیری که واقعاً می‌توانید از آن استفاده کنید ترکیب می‌کند. فرض بر این است که با دستورهای دستورهای ضروری لینوکس که هر برنامه‌نویسی باید بداند راحت هستید.

هر اسکریپت را یک‌جور شروع کنید#

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 ⁧اشتباه⁩ cd /bad/path rm -rf ./* ⁧بدون آن⁩ ⁧⁦rm⁩ در جای اشتباه اجرا می‌شود⁩ ⁧با آن⁩ ⁧اسکریپت بعد از ⁦cd⁩ می‌ایستد⁩ set -u ⁧اشتباه⁩ echo "Hi, $nme" ⁧بدون آن⁩ ⁧«Hi,» را چاپ می‌کند و ادامه می‌دهد⁩ ⁧با آن⁩ ⁧خطای ⁦unbound variable⁩⁩ set -o pipefail ⁧اشتباه⁩ mysqldump db | gzip ⁧بدون آن⁩ ⁧خطا پنهان می‌ماند: کد ۰⁩ ⁧با آن⁩ ⁧کد خروج ۱: متوجه می‌شوید⁩
حالت سخت‌گیرانه چه چیزهایی را می‌گیرد: set -e بعد از یک cd ناموفق متوقف می‌شود، set -u یک متغیرِ غلط‌نوشته را به خطا تبدیل می‌کند و pipefail باعث می‌شود اگر هر دستوری در یک پایپ‌لاین شکست بخورد، کل پایپ‌لاین شکست بخورد

گزینهٔ set -e: با اولین خطا متوقف شو#

Bash به‌طور پیش‌فرض شکست‌ها را نادیده می‌گیرد و به هر حال خط بعدی را اجرا می‌کند:

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ی که چیزی پیدا نمی‌کند، را می‌توان صریحاً مدیریت کرد:

Bash
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: متغیرِ تعریف‌نشده ممنوع#

یک غلط تایپی در نام متغیر معمولاً بی‌صدا به یک رشتهٔ خالی تبدیل می‌شود:

Bash
name="Ada"
echo "Hello, $nme"            # prints "Hello, " without set -u

این در یک echo آزاردهنده است و در rm -rf "$build_dir/"* خطرناک. با set -u، استفاده از متغیری که مقدار ندارد خطاست:

Text
./greet.sh: line 3: nme: unbound variable

برای متغیرهایی که مجازند نباشند، با ${var:-default} مقدار پیش‌فرض بدهید، یا با ${var:?message} با پیامِ خودتان شکست بخورید:

Bash
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: شکست‌های داخل پایپ‌لاین#

کد خروجِ یک پایپ‌لاین معمولاً کد خروجِ آخرین دستور آن است؛ پس شکست‌های قبلی در پایپ ناپدید می‌شوند:

Bash
$ false | true; echo $?
0
$ set -o pipefail
$ false | true; echo $?
1

بدون آن، mysqldump mydb | gzip > backup.sql.gz حتی وقتی dump شکست می‌خورد «موفق» است، چون gzip با خوشحالی یک ورودیِ خالی را فشرده می‌کند. با pipefail، اگر هر دستوری در پایپ‌لاین شکست بخورد، کل پایپ‌لاین شکست می‌خورد.

متغیرها را داخل کوتیشن بگذارید#

این بزرگ‌ترین منبعِ باگ‌های شل است. یک $var بدون کوتیشن روی فاصله‌ها شکسته می‌شود و بعد به‌عنوان الگوی wildcard گسترش پیدا می‌کند:

Bash
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[@]}". استثناهای نادر عمدی‌اند و یک کامنت توضیحی لازم دارند.

برای فرستادنِ همهٔ آرگومان‌های اسکریپت به یک دستور دیگر، از "$@" استفاده کنید. هر آرگومان را دست‌نخورده نگه می‌دارد، حتی وقتی فاصله داشته باشد؛ $* و $@ بدون کوتیشن این کار را نمی‌کنند:

Bash
# 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ها را هم گسترش می‌دهد. روش درست این است:

Bash
while IFS= read -r line; do
    echo "Processing: $line"
done < hosts.txt

IFS= فاصله‌های ابتدا و انتهای خط را نگه می‌دارد و -r جلوی تفسیر بک‌اسلش‌ها به‌عنوان کاراکتر escape را می‌گیرد.

پاک‌سازی با trap#

اسکریپت‌ها اغلب فایل‌های موقت می‌سازند. mktemp فایلی با نامی یکتا و غیرقابل حدس می‌سازد (هرگز /tmp/myscript.tmp را ثابت در کد ننویسید)، و trap ... EXIT تضمین می‌کند که آن فایل هر طور اسکریپت تمام شود پاک شود: به‌طور عادی، به‌خاطر خطا با set -e، یا وقتی Ctrl+C را می‌زنید:

Bash
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 رد می‌شود، چون هیچ چیزی نمی‌تواند آن سیگنال را بگیرد.

تابع‌ها، آرگومان‌ها و خطاها#

Bash
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) بفهمد مشکلی پیش آمده.

یک مثال کامل: اسکریپت پشتیبان‌گیری#

این اسکریپتی است که از یک پوشه آرشیو می‌سازد، فقط چند آرشیوِ آخر را نگه می‌دارد و همهٔ نکته‌های بالا را عملاً به کار می‌گیرد:

backup.shBash
#!/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
Bash
$ ./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 را اجرا کنید.