מדריך awk

פוסט זה כולל טיפ קצר על כלי עבודה בסביבת Linux. בשביל ללמוד יותר על עבודה בסביבת Linux ו Unix אני ממליץ לכם לבדוק את קורס Linux שיש לנו כאן באתר. הקורס כולל מעל 50 שיעורי וידאו והמון תרגול מעשי ומתאים גם למתחילים.
 

הכלי awk היה לאורך שנים ועודנו אחד הכלים המרכזיים לעיבוד טקסט בסביבת יוניקס. הוא פותח בשנות ה 70 בחברת Bell Labs על ידי שלושה מפתחים ששמותיהם נתנו לכלי את שמו: Alfred Aho, Peter Weinberger ו Brian Kernighan. הייחודיות של awk היא שמצד אחד הוא כולל אלמנטים מתקדמים משפות תכנות כמו משתנים, מערכים, מילונים ואפילו אפשרות להגדיר פונקציות; אבל מצד שני זה כלי סקריפטים שהשימוש בו מאוד ממוקד לפיענוח טקסטים.

היכרות עם awk תאפשר לנו להעלות את הרמה של הסקריפטים שאנחנו כותבים וגם לפתור בעיות הקשורות לטקסט במהירות ומשורת הפקודה.

1. מבנה פקודת awk

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

תוכנית awk היא אוסף של בלוקים, וכש awk מקבל אוסף כזה הוא מנסה להתאים כל שורה בקלט לכל התנאים בכל הבלוקים, ומריץ את הפקודות שמתאימות.

בדוגמה ראשונה הבלוק הבא הוא קוד תקני ל awk:

// { print $1 }

החלק הראשון של הבלוק הוא התנאי - התנאי שכתבתי בדוגמה הוא הסימן //, שמסמן ביטוי רגולארי ריק. כל טקסט שנכתוב בין שני הלוכסנים ייקרא על ידי awk בתור ביטוי רגולארי, והתנאי שהוא מייצג הוא שהשורה מתאימה לאותו ביטוי. ביטוי רגולארי ריק מתאים לכל השורות.

החלק השני הוא הפקודה, ובדוגמה פקודה זו היא print $1. פקודת print מדפיסה משהו למסך. כש awk קורא שורה, על הדרך הוא גם מחלק את השורה למילים (כל מה שמופרד ברווח אחד או יותר), וכל מילה מקבלת מספר. הסימן $1 מייצג את המילה הראשונה בשורת הקלט.

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

  1. הדפסת המילה הראשונה רק בשורות שמתחילות באות d:
/^d/ { print $1 }
  1. הדפסת המילה השלישית בכל שורה שמכילה סיפרה:
/[0-9]/ { print $3 }

הסימן המיוחד $0 מייצג את כל השורה, ולכן הבלוק הבא ידפיס את כל השורות מהקלט שמתחילות באות גדולה:

/[A-Z]/ { print $0 }

2. הפעלת awk

בשביל להפעיל awk אני יכול לכתוב את ה"תוכנית" שלו, כלומר את הבלוק או הבלוקים, בשורת הפקודה. זיכרו שבגלל שהרבה פעמים סימנים ש awk משתמש בהם הם כאלה שיש להם גם משמעות מיוחדת מבחינת ה shell, אנחנו רוצים "להגן" על הסימנים כדי שיגיעו ישירות ל awk ולא יפוענחו על ידי ה shell לפני הפעלת awk. מסיבה זו אני מציע להגן על תוכנית ה awk בגרש מכל צד.

הפקודה הבאה שכבר אפשר להפעיל משורת הפקודה מפעילה את ls -l, ומדפיסה את הבלוק הראשון (בלוק ההרשאות) רק לשורות שמתחילות באות d, כלומר רק לתיקיות:

$ ls -l | awk '/^d/ { print $1 }'

בשינוי פקודת ההדפסה אפשר לקבל מידע אחר או נוסף, לדוגמה הפקודה הבאה תדפיס עבור התיקיות גם את שם המשתמש שהן בבעלותו - בגלל ש ls -l מדפיסה את שם המשתמש בתור המילה השלישית בשורה:

$ ls -l | awk '/^d/ { print $1, $3 }'

3. משתנים ב awk

בואו נתקדם ליכולות נוספות של awk - הראשונה היא משתנים. אנחנו משתמשים במשתנים כדי לשמור חישובים שצריכים קלט מכמה שורות, למשל יכול להיות שנרצה לספור כמה תיקיות יש בקלט. ב awk אני יכול פשוט להתחיל להשתמש במשתנה ולא צריך להגדיר אותו מראש. כל משתנה מתחיל עם הערך הריק - כאשר אם תשתמשו במשתנה בחישוב הערך הריק יהיה 0, ואם תשתמשו באותו משתנה בפעולה טקסטואלית (למשל הדפסה) הערך הריק יהיה מחרוזת ריקה.

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

$ ls -l | awk '/^d/ { count += 1 }'

אבל זה לא ממש עבד. הפלט הוא ריק.

אני יכול להוסיף פקודה נוספת לתנאי קיים על ידי שימוש בסימן ;. ננסה את זה:

$ ls -l | awk '/^d/ { count += 1; print count }'

ועכשיו הפלט אצלי בתיקיה הוא:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

לא רע, אבל עדיין לא מה שרציתי. בגלל שההדפסה של count קורית כל פעם שמוצאים תיקיה אני מקבל הרבה הדפסות מיותרות. אני רק רוצה הדפסה אחת בסוף.

בשביל זה אני צריך להוסיף עוד בלוק ל awk, והפעם התנאי של הבלוק יהיה "הקלט הסתיים". ב awk המילה המיוחדת END מתארת את התנאי הזה בדיוק, ואני יכול לתת ל awk כמה בלוקים על ידי הפרדה שלהם ברווחים:

$ ls -l | awk '/^d/ { count += 1 } END { print count }'

וזה כבר נותן לי הדפסה אחת של המספר 25.

בעזרת משתנים אני יכול לבצע עוד אינסוף חישובים יצירתיים. אם נישאר עם הפלט של ls -l, אני יכול להיזכר שהמילה החמישית בפלט היא גודל הקובץ, ולחשב את סכום הגדלים של כל הקבצים בתיקיה:

$ ls -l | awk '/^-/ { size += $5 } END { print size }'

שימו לב ששיניתי את התנאי כדי לספור גדלים רק של שורות שמייצגות קבצים, כי אני יודע ששורה שמייצגת קובץ מתחילה בסימן -.

4. פקודות הדפסה print, printf

ננסה עוד פקודת הדפסה:

$ ls -l | awk '/^d/ { print "Directory: " $9, "Owned By " $3, "Permissions: " $1 }'

הפעם אני מעביר ל print שלושה ביטויים: הראשון הוא "Directory: " $9, השני הוא "Owned By " $3 והשלישי "Permissions: " $1. הביטויים מופרדים בפסיקים, וכל ביטוי כולל שתי מחרוזות מופרדות ברווח. ב awk רווח הוא בעצם פעולת שרשור מחרוזות ולכן הפלט שאני מקבל הוא:

Directory: app Owned By ynonp Permissions: drwxr-xr-x
Directory: bin Owned By ynonp Permissions: drwxr-xr-x
Directory: config Owned By ynonp Permissions: drwxr-xr-x
Directory: db Owned By ynonp Permissions: drwxr-xr-x
Directory: design Owned By ynonp Permissions: drwxr-xr-x
Directory: doc Owned By ynonp Permissions: drwxr-xr-x
Directory: engines Owned By ynonp Permissions: drwxr-xr-x
Directory: lib Owned By ynonp Permissions: drwxr-xr-x
Directory: log Owned By ynonp Permissions: drwxr-xr-x
Directory: public Owned By ynonp Permissions: drwxr-xr-x
Directory: storage Owned By ynonp Permissions: drwxr-xr-x
Directory: test Owned By ynonp Permissions: drwxr-xr-x
Directory: tmp Owned By ynonp Permissions: drwxr-xr-x
Directory: vendor Owned By ynonp Permissions: drwxr-xr-x

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

הפקודה printf, שאולי מוכרת לכם משפות תכנות אחרות כמו c או perl, מאפשרת להתמודד עם המצב הזה ולכתוב קוד awk שמדפיס דברים קצת יותר מתוחכמים. לדוגמה הפקודה:

$ ls -l | awk '/^d/ { printf "Directory: %-30s | Owned By: %-10s | Permissions: %-10s\n", $9, $3, $1 }'

מדפיסה לי את הפלט:

Directory: app                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: bin                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: config                         | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: db                             | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: design                         | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: doc                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: engines                        | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: lib                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: log                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: public                         | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: storage                        | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: test                           | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: tmp                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: vendor                         | Owned By: ynonp      | Permissions: drwxr-xr-x

למידע נוסף על printf אפשר להסתכל בדף התיעוד כאן: https://alvinalexander.com/programming/printf-format-cheat-sheet/

ב awk פקודת printf לא מוסיפה ירידת שורה בצורה אוטומטית אחרי כל הדפסה, ולכן הוספתי בעצמי את הסימן \n בסוף הטקסט שאני מדפיס דרך printf.

5. משתנים מיוחדים NF, NR, END, BEGIN

ראינו כבר את התנאי המיוחד END, ותשמחו לשמוע שיש לו חבר - התנאי המיוחד BEGIN. ב BEGIN נשתמש כשנרצה לעשות משהו לפני שורת הקלט הראשונה, למשל אני יכול לדמיין באותה פקודה מקודת שאני רוצה להדפיס שורות כותרות מיוחדות לכל עמודה בטבלה. במצב כזה אני משתמש ב BEGIN באופן הבא:

ls -l | awk 'BEGIN { printf "%-41s | %-20s | %-13s\n", "Name", "Owner", "Permissions" } /^d/ { printf "Directory: %-30s | Owned By: %-10s | Permissions: %-10s\n", $9, $3, $1 }'

והתוצאה:

Name                                      | Owner                | Permissions
Directory: app                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: bin                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: config                         | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: db                             | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: design                         | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: doc                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: engines                        | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: lib                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: log                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: public                         | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: storage                        | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: test                           | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: tmp                            | Owned By: ynonp      | Permissions: drwxr-xr-x
Directory: vendor                         | Owned By: ynonp      | Permissions: drwxr-xr-x

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

BEGIN {
  printf "%-41s | %-20s | %-13s\n", "Name", "Owner", "Permissions";
  printf "======================================================================================================\n";
}

/^d/  {
  printf "Directory: %-30s | Owned By: %-10s | Permissions: %-10s\n", $9, $3, $1
}

ומפעיל את ה awk שלי עם הפקודה:

$ ls -l | awk -f info.awk

כשמעבירים -f ואז שם קובץ ל awk אז הוא קורא את התוכנית מקובץ חיצוני.

שני המשתנים המיוחדים הבאים ברשימה שלי, NF ו NR, גם יכולים לעזור לנו בתנאים או בפקודות. המשתנה NR מחזיק תמיד את מספר השורה הנוכחית, ו NF את מספר המילים בשורה.

עכשיו כשאני יודע את מספר השורה הנוכחית אני יכול לכתוב תוכנית awk שתחקה את ההתנהגות של head, כלומר תדפיס רק 10 שורות ראשונות מהקלט:

$ man bash| awk 'NR < 10 { print }'

ושמתם לב אני מקווה איך הורדתי את ה $0 והפעלתי print ללא פרמטרים? ברירת המחדל של print כשלא מעבירים פרמטרים היא להדפיס את השורה המלאה.

ומה עם NF? הוא יעזור לנו בדוגמה הבאה כשנרצה לבנות את wc.

6. הפונקציה length

נמשיך לדוגמה נוספת עם awk, והפעם נרצה לבנות wc בעזרת awk, כלומר אני רוצה תוכנית awk שתדפיס כמה שורות, תווים ומילים יש בקלט.

אני יודע שמספר השורה הנוכחית שלי הוא NR, ולכן בסוף הקלט משתנה זה יחזיק בדיוק את מספר השורות שהיו בקלט.

אני גם יודע שמספר המילים בשורה שלי הוא NF, ולכן אני יכול לסכום את NF של כל שורה ולהדפיס אותו בסוף הקלט כדי לקבל את מספר המילים הכולל בקלט.

שילוב שני הדברים מתרגם ל awk הבא:

$ cat /etc/shells | awk '// { words += NF } END { printf "Lines: %d, Words: %d, Chars: %d\n", NR, words, chars }'

לחישוב מספר התווים אני משתמש בפונקציה length של awk, שמחזירה לי את מספר התווים בשורה או במילה שהיא קיבלה כקלט. במקרה שלנו:

$ cat /etc/shells | awk '// { words += NF; chars += length } END { printf "Lines: %d, Words: %d, Chars: %d\n", NR, words, chars }'

אבל אם תבדקו תגלו שהתוצאה לא זהה ל wc. ככה נראית אצלי הפעלה של wc על אותו קובץ קלט:

$ wc /etc/shells
      12      32     209 /etc/shells

השורות והמילים יצאו בסדר, אבל בתווים יש לי בעיה - הפונקציה length מחזירה כמה תווים יש בשורה אבל לא סופרת את תו ירידת השורה שבסופה, בניגוד ל wc שכן כולל אותו. אני מתקן ומקבל:

$ awk '// { words += NF; chars += 1 + length } END { printf "Lines: %d, Words: %d, Chars: %d\n", NR, words, chars }' /etc/shells
Lines: 12, Words: 32, Chars: 209

ופה אנחנו גם לומדים שאפשר להעביר ל awk שם קובץ, ואז הוא יקרא את השורות מהקובץ במקום מהקלט הסטנדרטי.

7. שינוי תו ההפרדה

ברירת המחדל של awk היא להפריד בין מילים באמצעות רווחים. אם נעביר ל awk את המתג -F נוכל לקבוע כל תו הפרדה אחר שנרצה. לדוגמה כשיש לי רשימת קבצים בתיקיה אני יכול להשתמש בתו הפרדה נקודה כדי להדפיס רק את שם הקובץ בלי הסיומת:

$ ls | awk -F . '{print $1}'

8. הפקודה system ושינוי שמות ברשימה של קבצים

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

$ ls | awk 'END { system("cowsay " NR) }'

ולקבל פלט כמו:

 ____
< 24 >
 ----
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

באותו אופן אני יכול להשתמש ב mv כדי לשנות שמות, ולהפעיל את ה system בכל שורת קלט שיש בה קובץ. הדוגמה הבאה מחליפה את כל הקבצים מסוימת txt לסיומת md:

$ ls *.txt | awk -F . '{ system("mv " $0 " " $1 ".md") }'

הפרמטר שהעברתי ל system הוא מחרוזת אחת שהורכבה בצורה דינמית, בגלל שב awk רווח הוא תו שרשור מחרוזות. הרבה פעמים נוח להשתמש בפונקציה sprintf כדי לבנות מחרוזות כאלה בצורה קצת יותר פשוטה:

$ ls *.txt | awk -F . '{ system(sprintf("mv \"%s\" \"%s%s\"", $0, $1, ".md")) }'

9. לולאות וחישוב תדירות של מילים

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

המשחק כאן הוא לחלק את הקובץ למילים, ואז לרוץ בלולאה על כל המילים ולשמור כל מילה במילון, כאשר המפתח הוא המילה והערך הוא כמה פעמים היא מופיעה. בפעם הראשונה שאנחנו נתקלים במילה היא תיכנס למילון עם ערך 1, ואם היא כבר במילון אז הערך יעלה ב-1 כל פעם שניתקל בה שוב.

כדי להשתמש במילון ב awk אני יכול להוסיף לשם משתנה סוגריים מרובעים בכתיבה. לדוגמה אם אני מסתכל רק על המילה הראשונה אני יכול לכתוב קוד כזה:

$ awk '{ COUNT[$1] += 1 }'

אנחנו כמובן רוצים משהו יותר מתוחכם. במקום לשמור רק את המילה הראשונה אני רוצה לשמור את כל המילים בשורה, ובשביל זה אני משתמש בלולאה. ב awk הפקודה for עובדת בתוך בלוק הפקודה בדיוק כמו שהייתם מצפים מ c, ולכן אני יכול לכתוב:

$ awk '{ for(i=1; i<=NF; i++) { COUNT[$i] += 1 } }'

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

$ awk '{ for(i=1; i<=NF; i++) { COUNT[$i] += 1 } } END { for (word in COUNT) { print word " appeared " COUNT[word] " times" } }'

10. איפה ללמוד עוד

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

  1. הגירסה של awk שמגיעה עם הרבה מערכות יוניקס נקראת gawk. ספר ההדרכה שלהם מעולה: https://www.gnu.org/software/gawk/manual/gawk.html

  2. המדריך Handy One Line Scripts For Awk של אריק פמנט כולל אינסוף רעיונות יצירתיים לשימוש ב awk: https://www.pement.org/awk/awk1line.txt