رفتن به محتوا
LoopX

دیباگ و کیفیت کد

توی این درس یاد می‌گیری وقتی اسکریپت کار نمی‌کند یا کار عجیبی می‌کند، به‌جای حدس زدن، ببینی دقیقاً چه اتفاقی می‌افتد. با bash -n خطای نحو را بدون اجرا پیدا می‌کنی، با حالت trace (bash -x و set -x) هر دستور را بعد از گسترش متغیرها می‌بینی، با PS4 شماره‌ی خط را کنار هر قدم می‌گذاری، و با ShellCheck ده‌ها باگ رایج را قبل از اجرا می‌گیری. آخر درس با shfmt و چند قاعده‌ی سبک نوشتن اسکریپتی می‌نویسی که شش ماه بعد خودت هم بفهمی‌اش.

مسئله: «روی سیستم من کار می‌کرد!»

Section titled “مسئله: «روی سیستم من کار می‌کرد!»”

اسکریپتی که دیروز درست کار می‌کرد، امروز روی یک فایل خاص اشتباه می‌کند. پیام خطا هم ندارد؛ فقط نتیجه غلط است. معمولاً اولین واکنش این است که چند echo این‌طرف و آن‌طرف بگذاری و حدس بزنی. ولی در Bash بیشتر باگ‌ها از جایی می‌آیند که چیزی که نوشتی با چیزی که اجرا شد فرق دارد: متغیری خالی بود، اسم فایلی فاصله داشت و دو تکه شد، یک * به فهرست فایل‌ها باز شد. برای دیدن «چیزی که واقعاً اجرا شد» ابزار داری.

تشبیه: دوربین مداربسته و بازرس فنی

Section titled “تشبیه: دوربین مداربسته و بازرس فنی”

دو جور ابزار داری. حالت trace مثل دوربین مداربسته است: اسکریپت را اجرا می‌کنی و همه‌چیز را همان‌طور که واقعاً اتفاق افتاد ضبط می‌کند؛ بعد فیلم را عقب و جلو می‌کنی. ShellCheck مثل بازرس فنی است: بدون اینکه ماشین را روشن کنی، نقشه را می‌خواند و می‌گوید «این پیچ شل است، این سیم بی‌روکش است». بازرس جلوی خیلی از خرابی‌ها را می‌گیرد؛ دوربین برای وقتی است که خرابی رخ داده و باید ببینی چرا.

ابزار کی چه چیزی را می‌گیرد
bash -n script.sh قبل از اجرا فقط خطای نحو (if بی‌fi، کوتیشن بسته‌نشده)
shellcheck script.sh قبل از اجرا خطای نحو، به‌علاوه‌ی باگ‌های منطقی رایج (کوتیشن، cd بدون بررسی، $? گمراه‌کننده، …)
bash -x / set -x حین اجرا هر دستور بعد از گسترش؛ برای باگ‌هایی که به داده و محیط بستگی دارند
روند دیباگ یک اسکریپت: اول ابزارهای ایستا که بدون اجرا کار می‌کنند (bash -n و shellcheck)، بعد اجرای کنترل‌شده با trace روی همان ورودی که مشکل دارد، بعد اصلاح و دوباره shellcheck. هر باگی که پیدا شد، یک آزمون کوچک برایش بنویس تا برنگردد.

مثال ۱: bash -n، بررسی نحو بدون اجرا

Section titled “مثال ۱: bash -n، بررسی نحو بدون اجرا”

bash -n (no-exec) فایل را فقط می‌خواند و تجزیه می‌کند؛ هیچ دستوری اجرا نمی‌شود. برای اسکریپتی که کار خطرناکی می‌کند (پاک‌کردن، دیپلوی)، اولین قدم امن است:

cd ~/bashlab
cat > broken.sh <<'EOF'
#!/usr/bin/env bash
echo "start: this line would run first"
for f in *.log; do
if [[ -s $f ]]; then
echo "non-empty: $f"
done
echo "end"
EOF
echo "--- bash -n:"
bash -n broken.sh; echo "کد خروج: $?"
echo "--- اجرای واقعی:"
bash broken.sh; echo "کد خروج: $?"
خروجی
--- bash -n:
broken.sh: line 6: syntax error near unexpected token `done'
broken.sh: line 6: `done'
کد خروج: 2
--- اجرای واقعی:
start: this line would run first
broken.sh: line 6: syntax error near unexpected token `done'
broken.sh: line 6: `done'
کد خروج: 2

if بدون fi است. bash -n همین را گفت و هیچ چیز اجرا نشد. ولی به اجرای واقعی نگاه کن: خط اول (echo) اجرا شد و بعد Bash به خطای نحو رسید. Bash اسکریپت را دستور به دستور می‌خواند و اجرا می‌کند؛ خطای نحوی که پایین‌تر است، فقط وقتی کشف می‌شود که نوبتش برسد. اگر آن خط اول یک rm یا دیپلوی بود، نیمی از کار انجام شده بود. پس قبل از اجرای اسکریپت تازه، bash -n بزن.

bash -n فقط نحو را می‌بیند: اسم دستور اشتباه (ech) یا متغیر غلط را نمی‌گیرد:

Terminal window
cd ~/bashlab
printf '#!/usr/bin/env bash\nech "hello"\nrm -rf "$UNSET_VAR/tmp"\n' > logic.sh
bash -n logic.sh && echo "bash -n: no syntax errors (but the script is still wrong!)"
خروجی
bash -n: no syntax errors (but the script is still wrong!)

مثال ۲: bash -x، دیدن هر دستور بعد از گسترش

Section titled “مثال ۲: bash -x، دیدن هر دستور بعد از گسترش”

bash -x script.sh قبل از اجرای هر دستور، همان دستور را بعد از گسترش متغیرها، $(...) و wildcardها روی stderr چاپ می‌کند، با یک + اول خط:

Terminal window
cd ~/bashlab
mkdir -p "reports/Monthly Reports"
touch "reports/Monthly Reports/old.log"
cat > clean.sh <<'EOF'
#!/usr/bin/env bash
dir="reports/Monthly Reports"
count=$(ls "$dir" | wc -l)
echo "found $count file(s)"
rm -f $dir/*.log
EOF
bash -x clean.sh
echo "--- هنوز هست؟"
ls "reports/Monthly Reports"
خروجی
+ dir='reports/Monthly Reports'
++ ls 'reports/Monthly Reports'
++ wc -l
+ count=1
+ echo 'found 1 file(s)'
found 1 file(s)
+ rm -f reports/Monthly 'Reports/*.log'
--- هنوز هست؟
old.log

خط‌های + اجرای واقعی‌اند:

  • ++ ls 'reports/Monthly Reports' با دو +: دستوری که داخل $(...) (یک سطح عمیق‌تر) اجرا شد.
  • + count=1: انتساب، با مقدار نهایی.
  • + rm -f reports/Monthly 'Reports/*.log': این‌جا باگ پیداست. rm دو آرگومان گرفت: reports/Monthly و Reports/*.log. دومی با هیچ فایلی جور نشد، پس wildcard باز نشد و همان متن * ماند (برای همین Bash آن را داخل کوتیشن نشان داده). چون rm گزینه‌ی -f دارد، بی‌صدا هیچ‌کدام را پیدا نکرد و فایل سر جایش ماند. با "$dir"/*.log درست می‌شود.

Bash در خروجی trace، آرگومانی را که فاصله دارد داخل کوتیشن تکی نشان می‌دهد ('reports/Monthly Reports')؛ پس نبودن کوتیشن در خط rm یعنی آن‌ها جدا جدا رفته‌اند.

مثال ۳: set -x و set +x، فقط بخش مشکوک

Section titled “مثال ۳: set -x و set +x، فقط بخش مشکوک”

اسکریپت بزرگ با bash -x هزاران خط trace می‌دهد. با set -x و set +x فقط یک تکه را روشن کن:

cd ~/bashlab
cat > partial.sh <<'EOF'
#!/usr/bin/env bash
name="report"
ext="csv"
echo "preparing..."
set -x
file="${name}_$(date +%Y).${ext}"
size=${#file}
set +x
echo "file=$file size=$size"
EOF
bash partial.sh
خروجی
preparing...
++ date +%Y
+ file=report_2026.csv
+ size=15
+ set +x
file=report_2026.csv size=15

فقط دو انتساب بین set -x و set +x (به‌علاوه‌ی date که داخل $(...) اجرا شد و ++ گرفت) trace شدند؛ خودِ set +x هم یک خط + دارد (چون هنوز trace روشن بود). اگر بخواهی بدون دستکاری فایل trace کنی، bash -x از بیرون؛ اگر بخواهی بخشی را برای مدت طولانی‌تر زیر نظر بگیری، set -x داخل فایل. (اسم بلند: set -o xtrace.)

مثال ۴: PS4، شماره‌ی خط و اسم تابع کنار هر قدم

Section titled “مثال ۴: PS4، شماره‌ی خط و اسم تابع کنار هر قدم”

+ اول خط‌های trace در واقع مقدار متغیر PS4 است. اگر آن را عوض کنی، می‌توانی اسم فایل، شماره‌ی خط و اسم تابع را هم ببینی:

cd ~/bashlab
cat > ps4.sh <<'EOF'
#!/usr/bin/env bash
greet() {
local who=$1
echo "hello $who"
}
total=0
for n in 3 4; do
total=$((total + n))
done
greet "Ali"
EOF
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' bash -x ps4.sh
خروجی
+ ps4.sh:6:main: total=0
+ ps4.sh:7:main: for n in 3 4
+ ps4.sh:8:main: total=3
+ ps4.sh:7:main: for n in 3 4
+ ps4.sh:8:main: total=7
+ ps4.sh:10:main: greet Ali
+ ps4.sh:3:greet: local who=Ali
+ ps4.sh:4:greet: echo 'hello Ali'
hello Ali

حالا هر خط می‌گوید از کدام فایل، کدام خط و کدام تابع آمده؛ برای اسکریپت چندصدخطی که چند فایل را source می‌کند، طلاست. چند نکته:

  • PS4 باید در کوتیشن تکی تعریف شود تا ${LINENO} موقع چاپ هر خط باز شود، نه یک بار موقع تعریف.
  • اینجا PS4=... را جلوی خود دستور نوشتیم تا فقط برای همان bash -x (پروسه‌ی فرزند) تنظیم شود. داخل اسکریپت هم می‌شود قبل از set -x نوشت.
  • ${FUNCNAME[0]:-main}: اسم تابع فعلی، و بیرون از تابع، main.

مثال ۵: trace در فایل جدا با BASH_XTRACEFD

Section titled “مثال ۵: trace در فایل جدا با BASH_XTRACEFD”

trace روی stderr می‌رود و با پیام‌های خطای واقعی قاطی می‌شود. با BASH_XTRACEFD می‌توانی آن را به یک فایل جدا بفرستی، مثلاً برای اسکریپتی که با cron اجرا می‌شود و فقط وقتی مشکلی پیش آمد می‌خواهی ببینی چه شد:

cd ~/bashlab
cat > traced.sh <<'EOF'
#!/usr/bin/env bash
# send the trace to a separate file when DEBUG=1
if [[ ${DEBUG:-0} == 1 ]]; then
exec 7> "trace-$(date +%F).log"
BASH_XTRACEFD=7
set -x
fi
src=/etc/hostname
dest=/tmp/lx-copy
cp "$src" "$dest"
echo "copied $(wc -c < "$dest") bytes"
cp /etc/no-such-file "$dest"
EOF
DEBUG=1 bash traced.sh
echo "--- محتوای فایل trace:"
cat trace-*.log
rm -f /tmp/lx-copy
خروجی
copied 3 bytes
cp: cannot stat '/etc/no-such-file': No such file or directory
--- محتوای فایل trace:
+ src=/etc/hostname
+ dest=/tmp/lx-copy
+ cp /etc/hostname /tmp/lx-copy
++ wc -c
+ echo 'copied 3 bytes'
+ cp /etc/no-such-file /tmp/lx-copy

روی صفحه فقط خروجی عادی و پیام خطای واقعی (cp: cannot stat) آمد؛ همه‌ی trace در فایل است. exec 7> فایل یک توصیف‌گر فایل (file descriptor) شماره‌ی ۷ را برای کل اسکریپت به آن فایل باز می‌کند و BASH_XTRACEFD=7 به Bash می‌گوید trace را آنجا بنویسد.

مثال ۶: ShellCheck، بازرس قبل از اجرا

Section titled “مثال ۶: ShellCheck، بازرس قبل از اجرا”

ShellCheck اسکریپت را بدون اجرا تحلیل می‌کند و برای هر مشکل یک کد (SC و یک عدد) با توضیح و پیشنهاد اصلاح می‌دهد. (نصب: sudo apt install shellcheck؛ روی مک brew install shellcheck.)

/home/ali/bashlab/messy.sh
#!/bin/bash
# messy.sh: count lines in every .txt file of a directory
dir=$1
cd $dir
for f in $(ls *.txt); do
lines=`wc -l < $f`
echo $f has $lines lines
done
read answer
if [ $answer == "yes" ]; then
echo "done"
fi
Terminal window
cd ~/bashlab
shellcheck messy.sh
echo "کد خروج shellcheck: $?"
خروجی
In messy.sh line 4:
cd $dir
^-----^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
cd "$dir" || exit
In messy.sh line 5:
for f in $(ls *.txt); do
^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.
In messy.sh line 6:
lines=`wc -l < $f`
^----------^ SC2006 (style): Use $(...) notation instead of legacy backticks `...`.
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
lines=$(wc -l < "$f")
In messy.sh line 7:
echo $f has $lines lines
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^----^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
echo "$f" has "$lines" lines
In messy.sh line 9:
read answer
^--^ SC2162 (info): read without -r will mangle backslashes.
In messy.sh line 10:
if [ $answer == "yes" ]; then
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
if [ "$answer" == "yes" ]; then
For more information:
https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...
https://www.shellcheck.net/wiki/SC2035 -- Use ./*glob* or -- *glob* so name...
کد خروج shellcheck: 1

هر مورد سه بخش دارد: کجا (شماره‌ی خط و ^---^ زیر همان تکه)، چه (کد و توضیح) و چطور (Did you mean:). سطح‌ها: error (تقریباً حتماً باگ)، warning (احتمالاً باگ)، info و style (عادت بد، پیشنهاد). کد خروج ShellCheck اگر مشکلی پیدا کند ۱ است؛ پس می‌شود آن را در CI یا قبل از commit (درس Git) گذاشت تا اسکریپت با مشکل وارد مخزن نشود.

مهم‌ترین‌هایی که در این خروجی دیدی و در عمل زیاد می‌بینی:

کد مشکل راه‌حل
SC2086 متغیر بدون کوتیشن (شکستن در فاصله و wildcard) "$var"
SC2164 cd بدون بررسی شکست cd "$dir" || exit
SC2045 حلقه روی خروجی ls for f in ./*.txt
SC2006 بک‌تیک قدیمی `cmd` $(cmd)
SC2162 read بدون -r read -r

مثال ۷: اصلاح خودکار و سکوت آگاهانه

Section titled “مثال ۷: اصلاح خودکار و سکوت آگاهانه”

ShellCheck می‌تواند پیشنهادهایش را به‌صورت diff بدهد که مستقیم با patch (یا git apply) اعمال شود:

Terminal window
cd ~/bashlab
shellcheck -f diff messy.sh > fix.diff
cat fix.diff
patch -p1 < fix.diff
echo "--- بعد از اصلاح خودکار:"
shellcheck messy.sh; echo "کد خروج: $?"
خروجی
--- a/messy.sh
+++ b/messy.sh
@@ -1,12 +1,12 @@
#!/bin/bash
# messy.sh: count lines in every .txt file of a directory
dir=$1
-cd $dir
+cd "$dir" || exit
for f in $(ls *.txt); do
- lines=`wc -l < $f`
- echo $f has $lines lines
+ lines=$(wc -l < "$f")
+ echo "$f" has "$lines" lines
done
read answer
-if [ $answer == "yes" ]; then
+if [ "$answer" == "yes" ]; then
echo "done"
fi
patching file messy.sh
--- بعد از اصلاح خودکار:
In messy.sh line 5:
for f in $(ls *.txt); do
^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.
In messy.sh line 9:
read answer
^--^ SC2162 (info): read without -r will mangle backslashes.
For more information:
https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
https://www.shellcheck.net/wiki/SC2035 -- Use ./*glob* or -- *glob* so name...
https://www.shellcheck.net/wiki/SC2162 -- read without -r will mangle backs...
کد خروج: 1

اصلاح خودکار فقط کارهای مکانیکی (کوتیشن، || exit، $(...)) را می‌کند؛ مشکل‌های طراحی (حلقه روی ls، read بدون -r) را باید خودت درست کنی. نسخه‌ی تمیز:

cd ~/bashlab
cat > messy.sh <<'EOF'
#!/usr/bin/env bash
# messy.sh: count lines in every .txt file of a directory
set -euo pipefail
dir=${1:?usage: messy.sh <directory>}
cd "$dir" || exit
for f in ./*.txt; do
[[ -e $f ]] || continue
lines=$(wc -l < "$f")
echo "$f has $lines lines"
done
read -r -p "continue? " answer
if [[ $answer == "yes" ]]; then
echo "done"
fi
EOF
shellcheck messy.sh && echo "shellcheck: clean"
mkdir -p notes && printf 'a\nb\n' > "notes/two lines.txt" && echo x > notes/one.txt
echo yes | bash messy.sh notes
خروجی
shellcheck: clean
./one.txt has 1 lines
./two lines.txt has 2 lines
done

گاهی هشداری داری که آگاهانه نادیده‌اش می‌گیری (مثل کلمه‌شکنی عمدی که در درس‌های قبل دیدی). با یک کامنت دستوری درست بالای همان خط ساکتش کن و دلیلش را بنویس:

cd ~/bashlab
cat > split.sh <<'EOF'
#!/usr/bin/env bash
tools="tar gzip"
# Word splitting is intended here: $tools is a space-separated list.
# shellcheck disable=SC2086
command -v $tools
EOF
shellcheck split.sh && echo "shellcheck: clean (SC2086 disabled on purpose)"
خروجی
shellcheck: clean (SC2086 disabled on purpose)

disable فقط روی دستور بعدی اثر دارد. اگر بالای فایل (بعد از shebang) بیاید، روی کل فایل اثر می‌کند؛ از آن پرهیز کن.

shfmt اسکریپت را با یک قالب ثابت مرتب می‌کند (تورفتگی، فاصله‌ها)، مثل Prettier برای جاوااسکریپت. (نصب: sudo apt install shfmt.) گزینه‌ی -d فقط تفاوت را نشان می‌دهد و -w فایل را بازنویسی می‌کند:

cd ~/bashlab
cat > ugly.sh <<'EOF'
#!/usr/bin/env bash
check(){
if [[ -d $1 ]];then
echo "dir: $1"
else
echo "missing: $1" >&2
return 1
fi
}
for d in /etc /nope;do check "$d"||echo " (skipped)";done
EOF
shfmt -i 4 -d ugly.sh
shfmt -i 4 -w ugly.sh
echo "--- بعد از shfmt -w:"
cat ugly.sh
bash ugly.sh
خروجی
--- ugly.sh.orig
+++ ugly.sh
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
-check(){
-if [[ -d $1 ]];then
-echo "dir: $1"
- else
- echo "missing: $1" >&2
-return 1
-fi
+check() {
+ if [[ -d $1 ]]; then
+ echo "dir: $1"
+ else
+ echo "missing: $1" >&2
+ return 1
+ fi
}
-for d in /etc /nope;do check "$d"||echo " (skipped)";done
+for d in /etc /nope; do check "$d" || echo " (skipped)"; done
--- بعد از shfmt -w:
#!/usr/bin/env bash
check() {
if [[ -d $1 ]]; then
echo "dir: $1"
else
echo "missing: $1" >&2
return 1
fi
}
for d in /etc /nope; do check "$d" || echo " (skipped)"; done
dir: /etc
missing: /nope
(skipped)

-i 4 یعنی تورفتگی چهار فاصله (پیش‌فرض tab است). کد عوض نشد، فقط شکلش. وقتی همه‌ی تیم shfmt بزنند، در diffهای Git فقط تغییرهای واقعی دیده می‌شوند، نه بحث سر فاصله.

پشت پرده: trace چطور ساخته می‌شود؟

Section titled “پشت پرده: trace چطور ساخته می‌شود؟”

trace کاری نیست که Bash «کنار» اجرا بکند؛ همان دستوری است که Bash بعد از همه‌ی مراحل گسترش آماده‌ی اجرا کرده. مراحل گسترش به ترتیب: آکولاد ({a,b})، تیلدا (~)، پارامتر ($x)، جایگزینی دستور ($(...))، محاسبه ($(( )))، شکستن کلمه‌ها (در فاصله‌ها، اگر کوتیشن نباشد)، wildcard (*)، و حذف کوتیشن‌ها. trace درست بعد از همه‌ی این‌ها چاپ می‌شود:

Terminal window
cd ~/bashlab
mkdir -p exp && cd exp && touch a.txt b.txt
v="x y"
set -x
echo {1..3} ~ $v "$v" $(echo sub) $((2 * 3)) *.txt
set +x
خروجی
++ echo sub
+ echo 1 2 3 /home/ali x y 'x y' sub 6 a.txt b.txt
1 2 3 /home/ali x y x y sub 6 a.txt b.txt
+ set +x

در یک خط trace همه‌ی مراحل را می‌بینی: {1..3} ← 1 2 3، ~ ← /home/ali، $v بدون کوتیشن ← دو کلمه‌ی x و y، "$v" ← یک آرگومان 'x y'، $(echo sub) ← sub (و خودِ دستور داخلی با ++)، $((2 * 3)) ← 6، *.txt ← فهرست فایل‌ها. هر وقت شک داری یک خط پیچیده واقعاً چه می‌کند، همین‌طور trace‌اش کن.

یک ابزار کمکی دیگر برای دیدن وضعیت (نه دستورها): declare -p که هر متغیر را با نوع و مقدار دقیقش نشان می‌دهد؛ برای آرایه‌ها و رشته‌هایی که کاراکتر نامرئی دارند بهتر از echo است:

Terminal window
name=$'Ali\r'
list=("a b" c)
declare -A ages=([ali]=30)
if [[ $name == Ali ]]; then echo "name is Ali"; else echo "name is NOT Ali?!"; fi
declare -p name list ages
خروجی
name is NOT Ali?!
declare -- name=$'Ali\r'
declare -a list=([0]="a b" [1]="c")
declare -A ages=([ali]="30" )

مقدار name روی صفحه «Ali» به نظر می‌رسد ولی مقایسه شکست خورد! declare -p علت را نشان داد: آخرش یک \r (پایان خط ویندوزی، مثلاً از فایلی که روی ویندوز ساخته شده) چسبیده که echo آن را نشان نمی‌دهد. آرایه را هم با مرز دقیق عضوها نشان داد ("a b" یک عضو است).

ابزارهای دیباگ:

دستور کار
bash -n script.sh فقط بررسی نحو، بدون اجرا
bash -x script.sh اجرا با trace کامل
set -x / set +x روشن و خاموش کردن trace در یک بخش
bash -v script.sh (یا set -v) چاپ هر خط همان‌طور که نوشته شده، قبل از گسترش
PS4='+ ${BASH_SOURCE}:${LINENO}: ' فایل و شماره‌ی خط در trace
exec 7>trace.log; BASH_XTRACEFD=7 فرستادن trace به فایل
declare -p var نوع و مقدار دقیق متغیر
echo "DEBUG: x=$x" >&2 چاپ موقت، روی stderr تا خروجی داده خراب نشود
trap 'echo "line $LINENO"' ERR گزارش خط خطا (درس مدیریت خطا)

گزینه‌های ShellCheck:

دستور کار
shellcheck script.sh تحلیل با خروجی کامل
shellcheck -f gcc script.sh خروجی یک‌خطی (file:line:col: level: msg [SCxxxx])
shellcheck -f diff script.sh پیشنهادهای قابل اعمال با patch یا git apply
shellcheck -S warning script.sh فقط warning و error (بدون info/style)
shellcheck -s sh script.sh بررسی برای sh (POSIX) به‌جای bash
# shellcheck disable=SC2086 ساکت کردن یک هشدار برای دستور بعدی

قواعد سبک (برگرفته از راهنماهای رایج، مثل Google Shell Style Guide):

قاعده چرا
shebang #!/usr/bin/env bash و set -euo pipefail اجرای قابل‌پیش‌بینی و توقف در خطا
همه‌ی گسترش‌ها داخل کوتیشن: "$var"، "$(cmd)"، "${arr[@]}" جلوگیری از شکستن کلمه و wildcard
[[ ]] به‌جای [ ]، $(...) به‌جای بک‌تیک امن‌تر و خواناتر
اسم متغیر محلی با حروف کوچک (backup_dir)، ثابت و متغیر محیطی با حروف بزرگ (readonly MAX_DAYS=7) جدا شدن از متغیرهای سیستم
تابع‌ها بالای فایل، با local برای همه‌ی متغیرهایشان؛ یک تابع main "$@" در انتها خوانایی و جلوگیری از نشت متغیر
پیام خطا روی stderr، کد خروج معنادار، -h قابل استفاده در اسکریپت‌های دیگر
کامنت برای «چرا»، نه برای «چه» کد خودش «چه» را می‌گوید
اسکریپت بیشتر از چندصد خط؟ احتمالاً وقت یک زبان دیگر (Python) است
cd ~/bashlab
cat > getsize.sh <<'EOF'
#!/usr/bin/env bash
get_size() {
echo "DEBUG: checking $1"
stat -c %s "$1"
}
size=$(get_size /etc/hostname)
echo "size is: [$size]"
EOF
bash getsize.sh
خروجی
size is: [DEBUG: checking /etc/hostname
3]

پیام دیباگ داخل $(...) گرفته شد و جزو داده شد. راه‌حل: پیام‌های دیباگ همیشه روی stderr: echo "DEBUG: ..." >&2.

۲) جا ماندن set -x در اسکریپت نهایی

Section titled “۲) جا ماندن set -x در اسکریپت نهایی”

trace در اسکریپتی که با cron اجرا می‌شود، صندوق نامه یا لاگ را پر می‌کند و ممکن است راز چاپ کند. راه‌حل: trace را با یک متغیر روشن کن ([[ ${DEBUG:-0} == 1 ]] && set -x) یا از بیرون با bash -x.

۳) فکر کردن که bash -n همه‌چیز را می‌گیرد

Section titled “۳) فکر کردن که bash -n همه‌چیز را می‌گیرد”

مثال ۱: ech و متغیر تعریف‌نشده از bash -n رد شدند. راه‌حل: bash -n و بعد ShellCheck.

۴) ساکت کردن هشدار به‌جای فهمیدنش

Section titled “۴) ساکت کردن هشدار به‌جای فهمیدنش”

# shellcheck disable=SC2086 بالای هر خطی که هشدار دارد، باگ را پنهان می‌کند. راه‌حل: اول صفحه‌ی ویکی آن کد را بخوان (https://www.shellcheck.net/wiki/SC2086)؛ فقط وقتی رفتار را عمداً می‌خواهی ساکتش کن و دلیل را در کامنت بنویس.

۵) shebang که با shell مورد نظر نمی‌خواند

Section titled “۵) shebang که با shell مورد نظر نمی‌خواند”
cd ~/bashlab
cat > arr.sh <<'EOF'
#!/bin/sh
items=(a b c)
echo "${items[1]}"
EOF
shellcheck arr.sh
sh arr.sh; echo "کد خروج: $?"
خروجی
In arr.sh line 2:
items=(a b c)
^-----^ SC3030 (warning): In POSIX sh, arrays are undefined.
In arr.sh line 3:
echo "${items[1]}"
^---------^ SC3054 (warning): In POSIX sh, array references are undefined.
For more information:
https://www.shellcheck.net/wiki/SC3030 -- In POSIX sh, arrays are undefined.
https://www.shellcheck.net/wiki/SC3054 -- In POSIX sh, array references are...
arr.sh: 2: Syntax error: "(" unexpected
کد خروج: 2

اسکریپت با #!/bin/sh شروع می‌شود ولی آرایه دارد که مال bash است. روی Ubuntu، sh در واقع dash است و خطای نحو می‌دهد. ShellCheck از روی shebang فهمید و همین را گفت. راه‌حل: #!/usr/bin/env bash برای اسکریپتی که از امکانات bash استفاده می‌کند.

✎ تمرینآسان

این اسکریپت باید فایل‌های .bak پوشه‌ی old files را بشمارد ولی همیشه ۰ می‌گوید. اول با bash -x اجرایش کن، علت را از روی trace پیدا کن و درستش کن.

count-bak.sh
#!/usr/bin/env bash
dir="old files"
count=0
for f in $dir/*.bak; do
[[ -f $f ]] && count=$((count + 1))
done
echo "backups: $count"
دیدن جواب
Terminal window
cd ~/bashlab
mkdir -p "old files"
touch "old files/a.bak" "old files/b.bak"
cat > count-bak.sh <<'EOF'
#!/usr/bin/env bash
dir="old files"
count=0
for f in $dir/*.bak; do
[[ -f $f ]] && count=$((count + 1))
done
echo "backups: $count"
EOF
bash -x count-bak.sh
echo "--- اصلاح:"
sed -i 's|for f in $dir/\*.bak|for f in "$dir"/*.bak|' count-bak.sh
bash count-bak.sh
خروجی
+ dir='old files'
+ count=0
+ for f in $dir/*.bak
+ [[ -f old ]]
+ for f in $dir/*.bak
+ [[ -f files/*.bak ]]
+ echo 'backups: 0'
backups: 0
--- اصلاح:
backups: 2

trace نشان داد حلقه روی دو کلمه‌ی old و files/*.bak چرخید (هیچ‌کدام فایل نبودند). کوتیشن دور "$dir" (و نه دور *) هم مسیر را سالم نگه می‌دارد و هم wildcard را باز می‌کند.

✎ تمرینمتوسط

تمرین اصلی درس: این اسکریپت پر از ایراد است. با ShellCheck همه‌ی ایرادهایش را پیدا کن و درستش کن تا ShellCheck هیچ هشداری ندهد؛ بعد ثابت کن روی پوشه‌ای با اسم دارای فاصله درست کار می‌کند.

disk-report.sh
#!/bin/bash
target=$1
cd $target
echo "Report for `pwd`"
for f in $(ls); do
size=$(du -sh $f | cut -f1)
echo $f: $size
done
total=$(du -sh . | cut -f1)
if [ $total == "" ]; then echo "empty"; fi
echo Total: $total
دیدن جواب
cd ~/bashlab
cat > disk-report.sh <<'EOF'
#!/bin/bash
target=$1
cd $target
echo "Report for `pwd`"
for f in $(ls); do
size=$(du -sh $f | cut -f1)
echo $f: $size
done
total=$(du -sh . | cut -f1)
if [ $total == "" ]; then echo "empty"; fi
echo Total: $total
EOF
shellcheck -f gcc disk-report.sh
echo "=== نسخه‌ی اصلاح‌شده:"
cat > disk-report.sh <<'EOF'
#!/usr/bin/env bash
# disk-report.sh: size of every entry in a directory
set -euo pipefail
target=${1:?usage: disk-report.sh <directory>}
cd "$target" || exit
echo "Report for $(pwd)"
for f in ./*; do
[[ -e $f ]] || continue
size=$(du -sh -- "$f" | cut -f1)
echo "${f#./}: $size"
done
total=$(du -sh . | cut -f1)
if [[ -z $total ]]; then echo "empty"; fi
echo "Total: $total"
EOF
shellcheck disk-report.sh && echo "shellcheck: clean"
mkdir -p "my site/img"
head -c 3000 /dev/zero > "my site/index.html"
head -c 50000 /dev/zero > "my site/img/logo big.png"
bash disk-report.sh "my site"
خروجی
disk-report.sh:3:1: warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails. [SC2164]
disk-report.sh:3:4: note: Double quote to prevent globbing and word splitting. [SC2086]
disk-report.sh:4:18: note: Use $(...) notation instead of legacy backticks `...`. [SC2006]
disk-report.sh:5:10: error: Iterating over ls output is fragile. Use globs. [SC2045]
disk-report.sh:6:17: note: Double quote to prevent globbing and word splitting. [SC2086]
disk-report.sh:7:8: note: Double quote to prevent globbing and word splitting. [SC2086]
disk-report.sh:7:12: note: Double quote to prevent globbing and word splitting. [SC2086]
disk-report.sh:10:6: note: Double quote to prevent globbing and word splitting. [SC2086]
disk-report.sh:11:13: note: Double quote to prevent globbing and word splitting. [SC2086]
=== نسخه‌ی اصلاح‌شده:
shellcheck: clean
Report for /home/ali/bashlab/my site
img: 56K
index.html: 4.0K
Total: 64K

ایرادها و اصلاح‌ها: متغیرهای بی‌کوتیشن (SC2086) ← کوتیشن؛ cd بدون بررسی (SC2164) ← || exit؛ بک‌تیک (SC2006) ← $(...)؛ حلقه روی ls (SC2045) ← ./* با بررسی -e برای پوشه‌ی خالی؛ [ $total == "" ] که با مقدار خالی خطای نحو می‌دهد ← [[ -z $total ]]. به‌علاوه: shebang قابل‌حمل، set -euo pipefail، پیام usage برای آرگومان جاافتاده و -- قبل از اسم فایل (برای اسم‌هایی که با - شروع می‌شوند).

✎ تمرینسخت

یک «کتابخانه‌ی دیباگ» کوچک بنویس (debug.sh) که با source به هر اسکریپتی اضافه شود: اگر متغیر DEBUG=1 بود، PS4 را با اسم فایل، شماره‌ی خط و تابع تنظیم کند، trace را به فایل /tmp/<اسم-اسکریپت>.trace بفرستد و set -x را روشن کند؛ و یک تابع debug بدهد که فقط در حالت DEBUG، پیام را با زمان روی stderr چاپ کند. با یک اسکریپت نمونه، در دو حالت DEBUG=0 و DEBUG=1 نشان بده که خروجی عادی یکی است.

دیدن جواب
Terminal window
cd ~/bashlab
cat > debug.sh <<'EOF'
# debug.sh: source this file; set DEBUG=1 to enable tracing
debug() {
if [[ ${DEBUG:-0} == 1 ]]; then
echo "[debug $(date +%T)] $*" >&2
fi
}
if [[ ${DEBUG:-0} == 1 ]]; then
_trace_file="/tmp/$(basename "$0" .sh).trace"
exec 9> "$_trace_file"
BASH_XTRACEFD=9
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
debug "tracing to $_trace_file"
set -x
fi
EOF
cat > sum.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/debug.sh"
add_all() {
local total=0 n
for n in "$@"; do
total=$((total + n))
done
echo "$total"
}
debug "numbers: $*"
echo "sum = $(add_all "$@")"
EOF
chmod +x sum.sh
echo "=== DEBUG=0:"
./sum.sh 2 5 10
echo "=== DEBUG=1 (stdout فقط):"
DEBUG=1 ./sum.sh 2 5 10 2>/dev/null
echo "=== DEBUG=1 (stderr):"
DEBUG=1 ./sum.sh 2 5 10 > /dev/null
echo "=== فایل trace:"
cat /tmp/sum.trace
rm -f /tmp/sum.trace
خروجی
=== DEBUG=0:
sum = 17
=== DEBUG=1 (stdout فقط):
sum = 17
=== DEBUG=1 (stderr):
[debug 11:49:24] tracing to /tmp/sum.trace
[debug 11:49:24] numbers: 2 5 10
=== فایل trace:
+ sum.sh:13:main: debug 'numbers: 2 5 10'
+ debug.sh:3:debug: [[ 1 == 1 ]]
++ debug.sh:4:debug: date +%T
+ debug.sh:4:debug: echo '[debug 11:49:24] numbers: 2 5 10'
++ sum.sh:14:main: add_all 2 5 10
++ sum.sh:6:add_all: local total=0 n
++ sum.sh:7:add_all: for n in "$@"
++ sum.sh:8:add_all: total=2
++ sum.sh:7:add_all: for n in "$@"
++ sum.sh:8:add_all: total=7
++ sum.sh:7:add_all: for n in "$@"
++ sum.sh:8:add_all: total=17
++ sum.sh:10:add_all: echo 17
+ sum.sh:14:main: echo 'sum = 17'

نکته‌ها: ۱) خروجی عادی (sum = 17) در هر دو حالت یکی است؛ همه‌ی اطلاعات دیباگ روی stderr یا در فایل trace می‌رود. ۲) exec 9> فایل را برای کل عمر اسکریپت باز می‌کند. ۳) چون debug.sh با source آمده، $0 اسم اسکریپت اصلی است و ${BASH_SOURCE##*/} در هر خط trace نشان می‌دهد آن خط از کدام فایل است. ۴) add_all داخل $(...) اجرا شد و خط‌هایش در trace با ++ (یک سطح عمیق‌تر) آمده‌اند.

⚡ بررسی سریع

کدام ابزار متغیر بی‌کوتیشن را قبل از اجرا (بدون اجرای اسکریپت) پیدا می‌کند؟

؟ آزمونک
  1. در خروجی bash -x، خطی با ++ شروع شده. یعنی چه؟

  2. چرا PS4 را با کوتیشن تکی تعریف می‌کنیم: PS4='+ ${LINENO}: '؟

  3. تابعی که خروجی‌اش با $(...) گرفته می‌شود، یک echo "DEBUG ..." دارد و نتیجه خراب شده. راه‌حل؟

  4. bash -n script.sh بدون خطا تمام شد. یعنی چه؟

  5. می‌خواهی هشدار SC2086 را فقط برای یک خط که عمداً کلمه‌شکنی دارد ساکت کنی. کجا چه می‌نویسی؟

  6. اسکریپتی با set -x در CI اجرا می‌شود و یک توکن API در دستور curl دارد. خطر چیست؟

  • حدس نزن، ببین. بیشتر باگ‌های Bash فاصله‌ی «چیزی که نوشتی» با «چیزی که اجرا شد» است.
  • bash -n: فقط نحو، بدون اجرا. ShellCheck: نحو و باگ‌های رایج (SC2086 کوتیشن، SC2164 cd، SC2045 ls، SC2006 بک‌تیک، SC2162 read -r)، با -f diff برای اصلاح خودکار و # shellcheck disable= (با دلیل) برای استثنا. در CI یا قبل از commit اجرایش کن.
  • bash -x یا set -x/set +x: هر دستور بعد از همه‌ی گسترش‌ها؛ ++ یعنی سطح عمیق‌تر؛ کوتیشن تکی دور آرگومان یعنی یک آرگومان با فاصله.
  • PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' (کوتیشن تکی) برای فایل، خط و تابع؛ BASH_XTRACEFD برای فرستادن trace به فایل.
  • declare -p برای دیدن مقدار دقیق (کاراکترهای نامرئی، مرز عضوهای آرایه).
  • پیام دیباگ روی stderr؛ trace را با DEBUG=1 روشن کن و مراقب رازها باش.
  • shfmt -i 4 -w برای قالب یکسان؛ سبک: کوتیشن همه‌جا، [[ ]]، $(...)، local، تابع main، ثابت‌ها با حروف بزرگ.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
bash -n script.shبررسی نحو بدون اجرا
bash -x script.shاجرا با trace کامل
set -x ... set +xtrace فقط یک بخش
PS4='+ ${BASH_SOURCE##*/}:${LINENO}: 'فایل و شماره‌ی خط در trace
exec 7>trace.log; BASH_XTRACEFD=7trace در فایل جدا
declare -p varنوع و مقدار دقیق متغیر
echo "DEBUG: $x" >&2پیام دیباگ روی stderr
shellcheck script.shتحلیل ایستا
shellcheck -f diff s.sh > fix.diff; patch -p1 < fix.diffاعمال اصلاح‌های خودکار
# shellcheck disable=SC2086ساکت کردن آگاهانه‌ی یک هشدار
shfmt -i 4 -d s.sh shfmt -i 4 -w s.shدیدن و اعمال قالب‌بندی