توی این درس یاد میگیری وقتی اسکریپت کار نمیکند یا کار عجیبی میکند، بهجای حدس زدن، ببینی دقیقاً چه اتفاقی میافتد. با bash -n خطای نحو را بدون اجرا پیدا میکنی، با حالت trace (bash -x و set -x) هر دستور را بعد از گسترش متغیرها میبینی، با PS4 شمارهی خط را کنار هر قدم میگذاری، و با ShellCheck دهها باگ رایج را قبل از اجرا میگیری. آخر درس با shfmt و چند قاعدهی سبک نوشتن اسکریپتی مینویسی که شش ماه بعد خودت هم بفهمیاش.
مسئله: «روی سیستم من کار میکرد!»
Section titled “مسئله: «روی سیستم من کار میکرد!»”اسکریپتی که دیروز درست کار میکرد، امروز روی یک فایل خاص اشتباه میکند. پیام خطا هم ندارد؛ فقط نتیجه غلط است. معمولاً اولین واکنش این است که چند echo اینطرف و آنطرف بگذاری و حدس بزنی. ولی در Bash بیشتر باگها از جایی میآیند که چیزی که نوشتی با چیزی که اجرا شد فرق دارد: متغیری خالی بود، اسم فایلی فاصله داشت و دو تکه شد، یک * به فهرست فایلها باز شد. برای دیدن «چیزی که واقعاً اجرا شد» ابزار داری.
تشبیه: دوربین مداربسته و بازرس فنی
Section titled “تشبیه: دوربین مداربسته و بازرس فنی”دو جور ابزار داری. حالت trace مثل دوربین مداربسته است: اسکریپت را اجرا میکنی و همهچیز را همانطور که واقعاً اتفاق افتاد ضبط میکند؛ بعد فیلم را عقب و جلو میکنی. ShellCheck مثل بازرس فنی است: بدون اینکه ماشین را روشن کنی، نقشه را میخواند و میگوید «این پیچ شل است، این سیم بیروکش است». بازرس جلوی خیلی از خرابیها را میگیرد؛ دوربین برای وقتی است که خرابی رخ داده و باید ببینی چرا.
سه لایهی دیباگ
Section titled “سه لایهی دیباگ”| ابزار | کی | چه چیزی را میگیرد |
|---|---|---|
bash -n script.sh |
قبل از اجرا | فقط خطای نحو (if بیfi، کوتیشن بستهنشده) |
shellcheck script.sh |
قبل از اجرا | خطای نحو، بهعلاوهی باگهای منطقی رایج (کوتیشن، cd بدون بررسی، $? گمراهکننده، …) |
bash -x / set -x |
حین اجرا | هر دستور بعد از گسترش؛ برای باگهایی که به داده و محیط بستگی دارند |
مثالهای عملی
Section titled “مثالهای عملی”مثال ۱: bash -n، بررسی نحو بدون اجرا
Section titled “مثال ۱: bash -n، بررسی نحو بدون اجرا”bash -n (no-exec) فایل را فقط میخواند و تجزیه میکند؛ هیچ دستوری اجرا نمیشود. برای اسکریپتی که کار خطرناکی میکند (پاککردن، دیپلوی)، اولین قدم امن است:
cd ~/bashlabcat > broken.sh <<'EOF'#!/usr/bin/env bashecho "start: this line would run first"for f in *.log; do if [[ -s $f ]]; then echo "non-empty: $f"doneecho "end"EOFecho "--- 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 firstbroken.sh: line 6: syntax error near unexpected token `done'broken.sh: line 6: `done'کد خروج: 2if بدون fi است. bash -n همین را گفت و هیچ چیز اجرا نشد. ولی به اجرای واقعی نگاه کن: خط اول (echo) اجرا شد و بعد Bash به خطای نحو رسید. Bash اسکریپت را دستور به دستور میخواند و اجرا میکند؛ خطای نحوی که پایینتر است، فقط وقتی کشف میشود که نوبتش برسد. اگر آن خط اول یک rm یا دیپلوی بود، نیمی از کار انجام شده بود. پس قبل از اجرای اسکریپت تازه، bash -n بزن.
bash -n فقط نحو را میبیند: اسم دستور اشتباه (ech) یا متغیر غلط را نمیگیرد:
cd ~/bashlabprintf '#!/usr/bin/env bash\nech "hello"\nrm -rf "$UNSET_VAR/tmp"\n' > logic.shbash -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 چاپ میکند، با یک + اول خط:
cd ~/bashlabmkdir -p "reports/Monthly Reports"touch "reports/Monthly Reports/old.log"cat > clean.sh <<'EOF'#!/usr/bin/env bashdir="reports/Monthly Reports"count=$(ls "$dir" | wc -l)echo "found $count file(s)"rm -f $dir/*.logEOFbash -x clean.shecho "--- هنوز هست؟"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 ~/bashlabcat > partial.sh <<'EOF'#!/usr/bin/env bashname="report"ext="csv"echo "preparing..."
set -xfile="${name}_$(date +%Y).${ext}"size=${#file}set +x
echo "file=$file size=$size"EOFbash partial.shpreparing...++ date +%Y+ file=report_2026.csv+ size=15+ set +xfile=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 ~/bashlabcat > ps4.sh <<'EOF'#!/usr/bin/env bashgreet() { local who=$1 echo "hello $who"}total=0for n in 3 4; do total=$((total + n))donegreet "Ali"EOFPS4='+ ${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 ~/bashlabcat > traced.sh <<'EOF'#!/usr/bin/env bash# send the trace to a separate file when DEBUG=1if [[ ${DEBUG:-0} == 1 ]]; then exec 7> "trace-$(date +%F).log" BASH_XTRACEFD=7 set -xfisrc=/etc/hostnamedest=/tmp/lx-copycp "$src" "$dest"echo "copied $(wc -c < "$dest") bytes"cp /etc/no-such-file "$dest"EOFDEBUG=1 bash traced.shecho "--- محتوای فایل trace:"cat trace-*.logrm -f /tmp/lx-copycopied 3 bytescp: 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.)
#!/bin/bash# messy.sh: count lines in every .txt file of a directorydir=$1cd $dirfor f in $(ls *.txt); do lines=`wc -l < $f` echo $f has $lines linesdoneread answerif [ $answer == "yes" ]; then echo "done"ficd ~/bashlabshellcheck messy.shecho "کد خروج 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) اعمال شود:
cd ~/bashlabshellcheck -f diff messy.sh > fix.diffcat fix.diffpatch -p1 < fix.diffecho "--- بعد از اصلاح خودکار:"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 ~/bashlabcat > messy.sh <<'EOF'#!/usr/bin/env bash# messy.sh: count lines in every .txt file of a directoryset -euo pipefaildir=${1:?usage: messy.sh <directory>}cd "$dir" || exitfor f in ./*.txt; do [[ -e $f ]] || continue lines=$(wc -l < "$f") echo "$f has $lines lines"doneread -r -p "continue? " answerif [[ $answer == "yes" ]]; then echo "done"fiEOFshellcheck messy.sh && echo "shellcheck: clean"mkdir -p notes && printf 'a\nb\n' > "notes/two lines.txt" && echo x > notes/one.txtecho yes | bash messy.sh notesshellcheck: clean./one.txt has 1 lines./two lines.txt has 2 linesdoneگاهی هشداری داری که آگاهانه نادیدهاش میگیری (مثل کلمهشکنی عمدی که در درسهای قبل دیدی). با یک کامنت دستوری درست بالای همان خط ساکتش کن و دلیلش را بنویس:
cd ~/bashlabcat > split.sh <<'EOF'#!/usr/bin/env bashtools="tar gzip"# Word splitting is intended here: $tools is a space-separated list.# shellcheck disable=SC2086command -v $toolsEOFshellcheck split.sh && echo "shellcheck: clean (SC2086 disabled on purpose)"shellcheck: clean (SC2086 disabled on purpose)disable فقط روی دستور بعدی اثر دارد. اگر بالای فایل (بعد از shebang) بیاید، روی کل فایل اثر میکند؛ از آن پرهیز کن.
مثال ۸: shfmt و سبک نوشتن
Section titled “مثال ۸: shfmt و سبک نوشتن”shfmt اسکریپت را با یک قالب ثابت مرتب میکند (تورفتگی، فاصلهها)، مثل Prettier برای جاوااسکریپت. (نصب: sudo apt install shfmt.) گزینهی -d فقط تفاوت را نشان میدهد و -w فایل را بازنویسی میکند:
cd ~/bashlabcat > ugly.sh <<'EOF'#!/usr/bin/env bashcheck(){if [[ -d $1 ]];thenecho "dir: $1" else echo "missing: $1" >&2return 1fi}for d in /etc /nope;do check "$d"||echo " (skipped)";doneEOFshfmt -i 4 -d ugly.shshfmt -i 4 -w ugly.shecho "--- بعد از shfmt -w:"cat ugly.shbash 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 bashcheck() { 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)"; donedir: /etcmissing: /nope (skipped)-i 4 یعنی تورفتگی چهار فاصله (پیشفرض tab است). کد عوض نشد، فقط شکلش. وقتی همهی تیم shfmt بزنند، در diffهای Git فقط تغییرهای واقعی دیده میشوند، نه بحث سر فاصله.
پشت پرده: trace چطور ساخته میشود؟
Section titled “پشت پرده: trace چطور ساخته میشود؟”trace کاری نیست که Bash «کنار» اجرا بکند؛ همان دستوری است که Bash بعد از همهی مراحل گسترش آمادهی اجرا کرده. مراحل گسترش به ترتیب: آکولاد ({a,b})، تیلدا (~)، پارامتر ($x)، جایگزینی دستور ($(...))، محاسبه ($(( )))، شکستن کلمهها (در فاصلهها، اگر کوتیشن نباشد)، wildcard (*)، و حذف کوتیشنها. trace درست بعد از همهی اینها چاپ میشود:
cd ~/bashlabmkdir -p exp && cd exp && touch a.txt b.txtv="x y"set -xecho {1..3} ~ $v "$v" $(echo sub) $((2 * 3)) *.txtset +x++ echo sub+ echo 1 2 3 /home/ali x y 'x y' sub 6 a.txt b.txt1 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 است:
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?!"; fideclare -p name list agesname 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" یک عضو است).
جدولهای مرجع
Section titled “جدولهای مرجع”ابزارهای دیباگ:
| دستور | کار |
|---|---|
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) است |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) دیباگ با echo روی stdout
Section titled “۱) دیباگ با echo روی stdout”cd ~/bashlabcat > getsize.sh <<'EOF'#!/usr/bin/env bashget_size() { echo "DEBUG: checking $1" stat -c %s "$1"}size=$(get_size /etc/hostname)echo "size is: [$size]"EOFbash getsize.shsize is: [DEBUG: checking /etc/hostname3]پیام دیباگ داخل $(...) گرفته شد و جزو داده شد. راهحل: پیامهای دیباگ همیشه روی 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 ~/bashlabcat > arr.sh <<'EOF'#!/bin/shitems=(a b c)echo "${items[1]}"EOFshellcheck arr.shsh 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 پیدا کن و درستش کن.
#!/usr/bin/env bashdir="old files"count=0for f in $dir/*.bak; do [[ -f $f ]] && count=$((count + 1))doneecho "backups: $count"دیدن جواب
cd ~/bashlabmkdir -p "old files"touch "old files/a.bak" "old files/b.bak"cat > count-bak.sh <<'EOF'#!/usr/bin/env bashdir="old files"count=0for f in $dir/*.bak; do [[ -f $f ]] && count=$((count + 1))doneecho "backups: $count"EOFbash -x count-bak.shecho "--- اصلاح:"sed -i 's|for f in $dir/\*.bak|for f in "$dir"/*.bak|' count-bak.shbash 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: 2trace نشان داد حلقه روی دو کلمهی old و files/*.bak چرخید (هیچکدام فایل نبودند). کوتیشن دور "$dir" (و نه دور *) هم مسیر را سالم نگه میدارد و هم wildcard را باز میکند.
تمرین اصلی درس: این اسکریپت پر از ایراد است. با ShellCheck همهی ایرادهایش را پیدا کن و درستش کن تا ShellCheck هیچ هشداری ندهد؛ بعد ثابت کن روی پوشهای با اسم دارای فاصله درست کار میکند.
#!/bin/bashtarget=$1cd $targetecho "Report for `pwd`"for f in $(ls); do size=$(du -sh $f | cut -f1) echo $f: $sizedonetotal=$(du -sh . | cut -f1)if [ $total == "" ]; then echo "empty"; fiecho Total: $totalدیدن جواب
cd ~/bashlabcat > disk-report.sh <<'EOF'#!/bin/bashtarget=$1cd $targetecho "Report for `pwd`"for f in $(ls); do size=$(du -sh $f | cut -f1) echo $f: $sizedonetotal=$(du -sh . | cut -f1)if [ $total == "" ]; then echo "empty"; fiecho Total: $totalEOFshellcheck -f gcc disk-report.shecho "=== نسخهی اصلاحشده:"cat > disk-report.sh <<'EOF'#!/usr/bin/env bash# disk-report.sh: size of every entry in a directoryset -euo pipefail
target=${1:?usage: disk-report.sh <directory>}cd "$target" || exitecho "Report for $(pwd)"for f in ./*; do [[ -e $f ]] || continue size=$(du -sh -- "$f" | cut -f1) echo "${f#./}: $size"donetotal=$(du -sh . | cut -f1)if [[ -z $total ]]; then echo "empty"; fiecho "Total: $total"EOFshellcheck 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: cleanReport for /home/ali/bashlab/my siteimg: 56Kindex.html: 4.0KTotal: 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 نشان بده که خروجی عادی یکی است.
دیدن جواب
cd ~/bashlabcat > debug.sh <<'EOF'# debug.sh: source this file; set DEBUG=1 to enable tracingdebug() { 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 -xfiEOFcat > sum.sh <<'EOF'#!/usr/bin/env bashset -euo pipefailsource "$(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 "$@")"EOFchmod +x sum.shecho "=== DEBUG=0:"./sum.sh 2 5 10echo "=== DEBUG=1 (stdout فقط):"DEBUG=1 ./sum.sh 2 5 10 2>/dev/nullecho "=== DEBUG=1 (stderr):"DEBUG=1 ./sum.sh 2 5 10 > /dev/nullecho "=== فایل trace:"cat /tmp/sum.tracerm -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 با ++ (یک سطح عمیقتر) آمدهاند.
آزمونک
Section titled “آزمونک”کدام ابزار متغیر بیکوتیشن را قبل از اجرا (بدون اجرای اسکریپت) پیدا میکند؟
bash -n فقط نحو را میبیند و bash -x اسکریپت را اجرا میکند؛ shellcheck تحلیل ایستا است و SC2086 را گزارش میدهد.
در خروجی bash -x، خطی با ++ شروع شده. یعنی چه؟
هر سطح تودرتو یک کاراکتر اول PS4 (+) اضافه میکند.
چرا PS4 را با کوتیشن تکی تعریف میکنیم: PS4='+ ${LINENO}: '؟
با کوتیشن دوتایی همهی خطها شمارهی خط تعریف PS4 را نشان میدهند.
تابعی که خروجیاش با $(...) گرفته میشود، یک echo "DEBUG ..." دارد و نتیجه خراب شده. راهحل؟
$(...) فقط stdout را میگیرد؛ stderr روی صفحه میماند.
bash -n script.sh بدون خطا تمام شد. یعنی چه؟
برای باگهای رایج منطقی shellcheck لازم است.
میخواهی هشدار SC2086 را فقط برای یک خط که عمداً کلمهشکنی دارد ساکت کنی. کجا چه مینویسی؟
بالای فایل روی کل فایل اثر دارد و باگهای واقعی را هم پنهان میکند.
اسکریپتی با set -x در CI اجرا میشود و یک توکن API در دستور curl دارد. خطر چیست؟
دور خطهای حساس set +x بگذار یا trace را فقط با DEBUG روشن کن.
جمعبندی
Section titled “جمعبندی”- حدس نزن، ببین. بیشتر باگهای Bash فاصلهی «چیزی که نوشتی» با «چیزی که اجرا شد» است.
bash -n: فقط نحو، بدون اجرا. ShellCheck: نحو و باگهای رایج (SC2086 کوتیشن، SC2164cd، SC2045ls، SC2006 بکتیک، SC2162read -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 +x | trace فقط یک بخش |
PS4='+ ${BASH_SOURCE##*/}:${LINENO}: ' | فایل و شمارهی خط در trace |
exec 7>trace.log; BASH_XTRACEFD=7 | trace در فایل جدا |
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 | دیدن و اعمال قالببندی |