• בלוג
  • תשעה טיפים לכתיבת Shell Scripts טובים יותר

תשעה טיפים לכתיבת Shell Scripts טובים יותר

07/07/2019

בשביל לכתוב Shell Script צריך לדעת מעט מאוד - למעשה כבר מהרגע שלמדתם לכתוב פקודת Shell אתם יכולים לשים אותה בקובץ ויש לכם סקריפט. אבל ככל שאנחנו כותבים יותר סקריפטים כך אנחנו נחשפים ליותר יכולות של Bash ולומדים יותר טכניקות שעוזרות לנו גם בעבודה רגילה בשורת הפקודה אבל במיוחד בפיתוח סקריפטים.

הנה תשע טכניקות כאלה. ביום חמישי הקרוב אעביר וובינר של שעה בו אסביר יותר בפירוט על חלק מהטכניקות כאן. ההשתתפות בחינם רק צריך להירשם כאן.

1. החלפת משתנים (התחלה, החלפה וסוף)

בכתיבת סקריפטים אנחנו הולכים לעבוד לא מעט עם משתנים. ב Bash אנחנו קוראים תוכן של משתנה באמצעות כתיבת דולר לפני שמו, כך שהקוד הבא שואל את המשתמש לשמו ומדפיס אותו:

#!/bin/bash

read -p "Who are you? " name
echo "Welcome, $name"

חוץ מקריאה רגילה של שם המשתנה bash יודע לחשב 6 סוגים של ערכים נגזרים:

  1. קריאת שם המשתנה בלי תחילית מסוימת, באמצעות סימן סולמית.
  2. קריאת שם המשתנה בלי תחילית ארוכה יותר, באמצעות סימן סולמית כפולה.
  3. קריאת שם המשתנה בלי סיומת מסוימת, באמצעות סימן אחוז.
  4. קריאת שם המשתנה בלי סיומת ארוכה יותר, באמצעות סימן אחוז כפול.
  5. קריאת שם המשתנה כשתווים מוסימים מוחלפים באחרים, באמצעות סימן לוכסן.
  6. קריאת שם המשתנה כשכל המופעים של תווים מוסימים מוחלפים באחרים, באמצעות סימן לוכסן כפול.

התוכנית הבאה משתמשת ביכולת חיתוך הסיומת כדי לשנות את השמות של כל הקבצים בתיקיה שמסתיימים בסיומת מסוימת לסיומת אחרת:

#!/bin/bash
shopt -s nullglob

source_ext=txt
target_ext=mp3

for file in *.${source_ext}
do
  echo mv "${file}" "${file%.*}.${target_ext}"
done

או דוגמא נוספת - התוכנית הבאה תחליף את שמות כל הקבצים בתיקיה שמכילים רווחים כך שהרווח יוחלף בקו תחתי:

#!/bin/bash
shopt -s nullglob

for file in *\ *
do
  echo mv "${file}" "${file// /_}"
done

האופציה nullglob גורמת ל bash להחזיר תוצאה ריקה כשאין קבצים שמתאימים לתבנית.

2. ספירת שורות בקובץ

הפקודה read יכולה לקרוא שורות ממשתמש או מקובץ ושילוב הפקודה בלולאה מאפשר לנו לקרוא קובץ שורה אחר שורה או אפילו לספור כמה שורות יש בקובץ:

#!/bin/bash
input="$1"

res=0

while IFS= read -r line
do
  (( res++ ))
done < "$input"

echo "file $input had $res lines"

אז נכון אנחנו חייבים לאפס את IFS כדי ש read לא יחלק את השורה שלנו למילים (בפרט זה המפתח כדי ש read לא ימחק לבד את הרווחים מתחילת השורה) ולהשתמש ב -r כדי ש read לא ייחס משמעות מיוחדת לסימן ה \ אבל חוץ מזה די נוח.

מפתיע לגלות שאם נרצה להשתמש באותו מנגנון כדי לספור כמה שורות יש בפלט של תוכנית החיים עלולים להסתבך. התוכנית הבאה לא עובדת ומדפיסה 0:

#!/bin/bash
res=0

ls -l | while IFS= read -r line
do
  (( res++ ))
done

echo "command output had $res lines"

מסתבר שסימן ה | פותח subshell חדש ולכן המשתנה שעודכן בתוך הלולאה לא מוכר מחוץ ללולאה. בשביל לספור שורות מתוך פלט של תוכנית נצטרך להפעיל את התוכנית עם סימן חץ אחורה כמו שהעברנו שם קובץ, ובלי להשתמש ב |. זה נראה כך:

#!/bin/bash
res=0

while IFS= read -r line
do
  (( res++ ))
done < <(ls -l)

echo "command output had $res lines"

3. יציאה מסקריפט כשיש שגיאה

פקודות Shell יכולות להיכשל וכשהן נכשלות אף אחד לא מתרגש מזה והסקריפט ממשיך. התנהגות זו היא הרבה פעמים בעייתית כי אנחנו בונים על תוצאה של פקודה אחת כשכותבים את הפקודה הבאה. הסקריפט הבא לדוגמא יוצר תיקיה ובתוכה שלושה קבצים:

#!/bin/bash

mkdir demo
cd demo
touch one two three

אבל אם ה mkdir נכשל שלושת הקבצים יווצרו במקום הלא נכון.

אנחנו יכולים להשתמש באפשרות -e כדי לגרום לסקריפט לצאת כשיש שגיאה וזה נראה כך:

#!/bin/bash

set -e

mkdir demo
cd demo
touch one two three

4. מניעת קלוברינג

ואם כבר שגיאות אז אחת ההתנהגויות המלחיצות של bash מתוך סקריפט נקראת clobbering. זה המנגנון שגורם ל bash למחוק קובץ כשאתם מפעילים Redirect לתוך הקובץ. בסקריפט אנחנו לא תמיד יודעים יותר מדי על הסביבה בה הסקריפט רץ ולכן יש סכנה שנמחק למשתמש קבצים חשובים.

באמצעות האופציה set -o noclobber אנחנו מבטלים את ה clobbering וגורמים ל bash לדווח שגיאה כשננסה לכתוב לתוך קובץ קיים. במצב זה אנחנו עדיין יכולים לדרוס קובץ קיים אבל צריך להצהיר על זה בפירוש באמצעות סימן |.

זה נראה כך:

$ set -o noclobber
$ touch x

$ echo one > x
bash: x: cannot overwrite existing file

$ echo one >| x
$ cat x
one

5. סקריפט שקורא מ STDIN או מקובץ

הרבה פקודות יוניקס יודעות לקרוא מקובץ אם קיבלו שם קובץ בתור פרמטר, ואם לא יקראו את הקלט שלהן מ STDIN. הנה wc לדוגמא:

$ wc a.sh
       7      11      60 a.sh

$ cat a.sh | wc
       7      11      60

גם אנחנו יכולים לכתוב סקריפט שמתנהג כך באמצעות טריק קטן שנקרא exec. בתחילת הסקריפט נבדוק אם העבירו לנו שם קובץ כפרמטר ואם כן נחבר את הקובץ ל STDIN. לאורך הסקריפט נקרא תמיד מ STDIN והכל יעבוד. זה סקריפט סופר השורות שכתבנו קודם מעודכן עם היכולת לקבל שם קובץ משורת הפקודה (או לקרוא מ STDIN אם לא העבירו שם קובץ):

#!/bin/bash
if [[ ! -z "$1" ]]
then
  exec < "$1"
fi

res=0

while IFS= read -r line
do
  (( res++ ))
done

echo "file had $res lines"

6. קבצים זמניים

הפקודה mktemp מייצרת קובץ זמני וזה עוזר כשהסקריפט שלכם צריך לכתוב מידע לקובץ ואתם לא רוצים לאלתר ולהמציא שם קובץ שאולי כבר קיים. הסקריפט הבא משתמש ב mktemp כדי לשנות כל מופע של dog ל cat בתוך קובץ טקסט:

#!/bin/bash
tmpfile=$(mktemp)

while IFS= read -r line
do
  echo "${line//dog/cat}"
done < "$1" > "$tmpfile"

mv "$tmpfile" "$1"

אבל יש פה בעיה: אם יש תקלה הקובץ הזמני לא נמחק ולאורך זמן אנחנו נישאר עם הרבה קבצים זמניים מפוזרים על הדיסק. הפיתרון הפשוט הוא שימוש בפקודת trap כדי למחוק את הקובץ ביציאה אם עדיין קיים:

#!/bin/bash
tmpfile=$(mktemp)
trap "{ rm -f $tmpfile; }" EXIT

while IFS= read -r line
do
  echo "${line//dog/cat}"
done < "$1" > "$tmpfile"

mv "$tmpfile" "$1"

7. סריקת כל הפרמטרים שסקריפט קיבל

ל bash יש שני משתנים שמחזירים את רשימת כל הפרמטרים שהסקריפט קיבל וההבדל ביניהם עדין אבל חשוב. המשתנה $* הוא היותר טיפש והוא יחזיר את כל הפרמטרים אבל לא יעבוד טוב כשאחד הפרמטרים כולל רווחים, והמשתנה $@ הוא שיודע להתמודד גם עם רווחים בתוך הפרמטרים עצמם. נראה את זה בשתי דוגמאות.

התוכנית מדפיסה את כל הפרמטרים שקיבלה תחילה באמצעות $* ואחר כך עם $@:

#!/bin/bash

echo 'Params passed printed with $*:'
for arg in "$*"
do
  echo "$arg"
done

echo 'Params passed printed with $@:'
for arg in "$@"
do
  echo "$arg"
done

ובהפעלה משורת הפקודה:

$ bash a.sh "one two" three four

Params passed printed with $*:
one two three four

Params passed printed with $@:
one two
three
four

8. שימוש ב **

אנחנו יכולים לכתוב סקריפט bash שעובד על כל הקבצים עם סיומת מסוימת די בקלות עם glob pattern וזה היה קיים מאז ומעולם. בשנים האחרונות שתי הרחבות של glob patterns הפכו פופולריות ועליהן נדבר בשני הטיפים הבאים. ההרחבה הראשונה נקראת globstar והיא אומרת שאפשר להשתמש בסימן ** כדי לחפש את כל ההתאמות בכל התיקיות לכל עומק שהוא.

אם יש לנו בתיקיה את העץ הבא:

one.txt
two.txt
one.mp3
demo/
  two.mp3

music/
  songs/
    three.mp3

אז הפקודה:

ls *.mp3

מתוך התיקיה הראשית תציג רק את הקובץ one.mp3 הראשי. באמצעות globstar נוכל להגיע לכולם. תחילה נפעיל את האופציה מתוך ה shell:

$ shopt -s globstar

ואז נפעיל:

$ ls -l **/*.mp3
-rw-r--r--  1 ynonperek  staff  0 Jul  6 14:46 demo/two.mp3
-rw-r--r--  1 ynonperek  staff  0 Jul  6 14:46 music/songs/three.mp3
-rw-r--r--  1 ynonperek  staff  0 Jul  6 14:46 one.mp3

9. שימוש ב Extgob

אפשרות שניה שעוזרת לנו לבנות תבניות glob נקראת extglob. היא קצת יותר מורכבת מ globstar אבל בהחלט יכולה לעזור במקרים רלוונטים. ה extglob היא הרחבה שמציעה את היכולות הבאות:

  1. שימוש ב | כדי לחפש אחד מתוך מספר רצפים של תווים.

  2. שימוש ב ! כדי לחפש משהו שלא מכיל רצף מסוים של תווים.

  3. שימוש ב ?, *, + ו @ כדי לחפש מספר מופעים משתנה של תבנית מסוימת.

למשל נניח שיש לנו בתיקיה המון קבצים וחלק קטן מהם הם קבצי תמונות עם הסיומות png, jpg או gif. למחוק את כל קבצי התמונות זה קל עם glob רגיל:

$ rm *.jpg *.gif *.png

בעזרת extglob תוכלו לאחד את שלושת התבניות לאחת:

$ rm *.@(jpg|png|gif)

או יותר טוב, למחוק את כל הקבצים האחרים:

$ rm *.!(jpg|png|gif)

ביום חמישי אני מתכנן להציג בוובינר תוכניות דוגמא נוספות והרחבה על כל המנגנונים שהוצגו כאן. מוזמנים בחום (ובחינם) להצטרף באמצעות הרשמה בקישור:

https://www.tocode.co.il/workshops/80