在我最開始管理Linux和Unix伺服器時,經常遇到其他管理員編寫的一大堆臨時指令碼。時常會因為其中某個指令碼突然停止工作而進行故障排查。有時這些指令碼編寫得規範好理解,其他時候則是雜亂且令人困惑。
雖然排查編寫糟糕的指令碼很麻煩,但我從中吸取到了教訓。即使你認為該指令碼只會在今天使用,最好也抱著兩年後還將有人去排查的態度編寫指令碼。因為總會有人檢視,甚至很可能是你自己。
在本篇文章中,我想介紹一些最佳化指令碼的建議,不是為了方便你編寫指令碼,而是方便想要弄清指令碼為何不工作的人。
以釋伴行開頭
Shell指令碼編寫的第一條規則是以釋伴行開頭。雖然聽起來很好笑,但釋伴行卻很重要,它告訴系統使用哪種二進位制作為指令碼的直譯器。沒有釋伴行,系統就不知道使用哪種語言解釋執行指令碼。
一個典型的bash 以釋伴行如下所示:
#!/bin/bash
與本文中其他建議不同,這不僅僅是一條建議,而是一條規定。shell指令碼必須以直譯器行開始;沒有這行,你的指令碼最終將不能工作。我發現很多指令碼沒有這一行,有人認為沒有這行指令碼就不能工作,但事實並非如此。如果沒有指定指令碼直譯器,有些系統會預設使用/bin/sh目錄下的直譯器。如果是bourne shell指令碼,預設/bin/sh路徑沒有問題,如果是KSH或者使用特定bash指令碼而不是bourne,該指令碼可能產生無法預料的結果。
新增指令碼描述頭
當編寫指令碼或者其他程式時,我總會在指令碼開頭描述指令碼的用途,同時新增我的名字。如果這些指令碼是在工作中編寫,我還會加上工作郵箱以及指令碼編寫日期。
下面是一個有指令碼頭的例子:
#!/bin/bash
### Description: Adds users based on provided CSV file
### CSV file must use : as separator
### uid:username:comment:group:addgroups:/home/dir:/usr/shell:passwdage:password
### Written by: Benjamin Cane - ben@example.com on 03-2012
為什麼要新增這些內容?很簡單。這裡的描述是為了向閱讀該指令碼的人解釋指令碼用途並提供他們需要了解的其他資訊。新增名字和郵箱,閱讀該指令碼的人如果有疑問就可以聯絡上我並提問。新增日期,當他們閱讀指令碼時,至少知道該指令碼是多久之前編寫的。日期還能觸動你的懷舊之情,當發現自己很久前編寫的指令碼時,你會問問自己“在編寫該指令碼時,我是怎麼想的?”。
指令碼中的描述頭可以根據自己的想法隨意定製,沒有硬性規定哪些是必須的,哪些不需要。通常只要保證資訊有效並且放置在指令碼開頭即可。
縮排程式碼
程式碼可讀性非常重要,但很多人都會忽略這一點。在深入瞭解縮排為何很重要前,我們來看一個例子:
NEW_UID=$(echo $x | cut -d: -f1)
NEW_USER=$(echo $x | cut -d: -f2)
NEW_COMMENT=$(echo $x | cut -d: -f3)
NEW_GROUP=$(echo $x | cut -d: -f4)
NEW_ADDGROUP=$(echo $x | cut -d: -f5)
NEW_HOMEDIR=$(echo $x | cut -d: -f6)
NEW_SHELL=$(echo $x | cut -d: -f7)
NEW_CHAGE=$(echo $x | cut -d: -f8)
NEW_PASS=$(echo $x | cut -d: -f9)
PASSCHK=$(grep -c ":$NEW_UID:" /etc/passwd)
if [ $PASSCHK -ge 1 ]
then
echo "UID: $NEW_UID seems to exist check /etc/passwd"
else
useradd -u $NEW_UID -c "$NEW_COMMENT" -md $NEW_HOMEDIR -s $NEW_SHELL -g $NEW_GROUP -G $NEW_ADDGROUP $NEW_USER
if [ ! -z $NEW_PASS ]
then
echo $NEW_PASS | passwd --stdin $NEW_USER
chage -M $NEW_CHAGE $NEW_USER
chage -d 0 $NEW_USER
fi
fi
上述程式碼能工作嗎?是的,但這段程式碼寫的並不好,如果這是一個500行bash指令碼,沒有任何縮排,那麼理解該指令碼的用途將非常困難。下面看一下使用縮排後的同一段程式碼:
NEW_UID=$(echo $x | cut -d: -f1)
NEW_USER=$(echo $x | cut -d: -f2)
NEW_COMMENT=$(echo $x | cut -d: -f3)
NEW_GROUP=$(echo $x | cut -d: -f4)
NEW_ADDGROUP=$(echo $x | cut -d: -f5)
NEW_HOMEDIR=$(echo $x | cut -d: -f6)
NEW_SHELL=$(echo $x | cut -d: -f7)
NEW_CHAGE=$(echo $x | cut -d: -f8)
NEW_PASS=$(echo $x | cut -d: -f9)
PASSCHK=$(grep -c ":$NEW_UID:" /etc/passwd)
if [ $PASSCHK -ge 1 ]
then
echo "UID: $NEW_UID seems to exist check /etc/passwd"
else
useradd -u $NEW_UID -c "$NEW_COMMENT" -md $NEW_HOMEDIR -s $NEW_SHELL -g $NEW_GROUP -G $NEW_ADDGROUP $NEW_USER
if [ ! -z $NEW_PASS ]
then
echo $NEW_PASS | passwd --stdin $NEW_USER
chage -M $NEW_CHAGE $NEW_USER
chage -d 0 $NEW_USER
fi
fi
縮排後,很明顯第二個if語句內嵌在第一個if語句內,但如果看未縮排的程式碼,第一眼肯定發現不了。
縮排方式取決於你自己,是使用兩個空格、四個空格,還是就使用一個製表符,這都不重要。重要的是程式碼每次以相同的方式一致縮排。
增加間距
縮排可以增加程式碼的可理解性,而間距可以增加程式碼的可讀性。通常,我喜歡根據程式碼的用途來間隔程式碼,這是個人偏好,其意義在於使程式碼更加可讀並易於理解。
下面是上述程式碼新增行間距後的例子:
NEW_UID=$(echo $x | cut -d: -f1)
NEW_USER=$(echo $x | cut -d: -f2)
NEW_COMMENT=$(echo $x | cut -d: -f3)
NEW_GROUP=$(echo $x | cut -d: -f4)
NEW_ADDGROUP=$(echo $x | cut -d: -f5)
NEW_HOMEDIR=$(echo $x | cut -d: -f6)
NEW_SHELL=$(echo $x | cut -d: -f7)
NEW_CHAGE=$(echo $x | cut -d: -f8)
NEW_PASS=$(echo $x | cut -d: -f9)
PASSCHK=$(grep -c ":$NEW_UID:" /etc/passwd)
if [ $PASSCHK -ge 1 ]
then
echo "UID: $NEW_UID seems to exist check /etc/passwd"
else
useradd -u $NEW_UID -c "$NEW_COMMENT" -md $NEW_HOMEDIR -s $NEW_SHELL -g $NEW_GROUP -G $NEW_ADDGROUP $NEW_USER
if [ ! -z $NEW_PASS ]
then
echo $NEW_PASS | passwd --stdin $NEW_USER
chage -M $NEW_CHAGE $NEW_USER
chage -d 0 $NEW_USER
fi
fi
如你所見,行間距雖不易覺察,但每一處整潔都讓以後的程式碼排錯更簡單。
註釋程式碼
描述頭適合於新增指令碼函式描述,而程式碼註釋適合於解釋程式碼本身的用途。下面仍是上述相同的程式碼片段,但這次我將新增程式碼註釋,解釋程式碼的用途:
### Parse $x (the csv data) and put the individual fields into variables
NEW_UID=$(echo $x | cut -d: -f1)
NEW_USER=$(echo $x | cut -d: -f2)
NEW_COMMENT=$(echo $x | cut -d: -f3)
NEW_GROUP=$(echo $x | cut -d: -f4)
NEW_ADDGROUP=$(echo $x | cut -d: -f5)
NEW_HOMEDIR=$(echo $x | cut -d: -f6)
NEW_SHELL=$(echo $x | cut -d: -f7)
NEW_CHAGE=$(echo $x | cut -d: -f8)
NEW_PASS=$(echo $x | cut -d: -f9)
### Check if the new userid already exists in /etc/passwd
PASSCHK=$(grep -c ":$NEW_UID:" /etc/passwd)
if [ $PASSCHK -ge 1 ]
then
### If it does, skip
echo "UID: $NEW_UID seems to exist check /etc/passwd"
else
### If not add the user
useradd -u $NEW_UID -c "$NEW_COMMENT" -md $NEW_HOMEDIR -s $NEW_SHELL -g $NEW_GROUP -G $NEW_ADDGROUP $NEW_USER
### Check if new_pass is empty or not
if [ ! -z $NEW_PASS ]
then
### If not empty set the password and pass expiry
echo $NEW_PASS | passwd --stdin $NEW_USER
chage -M $NEW_CHAGE $NEW_USER
chage -d 0 $NEW_USER
fi
fi
如果你恰好要閱讀這段bash程式碼,卻又不知道這段程式碼的用途,至少可以透過檢視註釋充分掌握程式碼的實現目標。在程式碼中新增註釋對其他人非常有幫助,甚至對你自己也有幫助。我曾發現在瀏覽自己一個月前編寫的指令碼時不知道指令碼的用途。如果註釋新增合理,可以在日後節省你和他人的很多時間。
建立描述性的變數名
描述性變數名非常直觀,但我發現自己一直都使用通用變數名。通常這些都是臨時變數,從不在該程式碼塊之外使用,但即使是臨時變數,解釋清楚它們的含義也很有用。
下面例子中的變數名大部分是描述性的:
for x in `cat $1`
do
NEW_UID=$(echo $x | cut -d: -f1)
NEW_USER=$(echo $x | cut -d: -f2)
可能賦給$NEW_UID和$NEW_USER的值不是很明顯,$1的值代表什麼以及$x的取值是什麼都不夠清楚。更具描述性的修改程式碼如下:
INPUT_FILE=$1
for CSV_LINE in `cat $INPUT_FILE`
do
NEW_UID=$(echo $CSV_LINE | cut -d: -f1)
NEW_USER=$(echo $CSV_LINE | cut -d: -f2)
從這段重寫的程式碼塊中,很容易看出我們是在讀取一個輸入檔案,該檔名是一個CSV檔案。同時很容易看出我們從什麼地方獲取新的UID和新的USER資訊來儲存在$NEW_UID和$NEW_USER變數中。
上面的例子看上去有點大材小用,但日後會有人感謝你花費額外時間讓變數更具描述性。
使用 $(command) 進行命令替換
如果你想建立一個變數,其值是其他指令的輸出,在bash中有兩種方式實現。第一種是將命令封裝在反引號中,如下所示:
DATE=`date +%F`
第二種是使用一個不同的語法:
DATE=$(date +%F)
雖然兩者都正確,但我個人更喜歡第二種方法。這純粹是個人偏好,但我通常認為$(command)句法比使用反引號更加明顯。假如你在挖掘上百行的bash程式碼;你會發現隨著自己不斷閱讀,那些反引號有時看起來像是單引號。此外,有時單引號看起來像是反引號。最後,所有的建議都與偏好掛鉤。所以使用最適合你的,確保與你所選擇使用的方法一致。
在出錯退出前描述問題
上述示例可以讓程式碼更加易於閱讀和理解,最後一條建議對在排錯過程前找到錯誤點非常有用。在指令碼中新增描述性錯誤資訊,可以在前期節省很多排錯時間。瀏覽下面的程式碼,看看如何能使它更具描述性:
if [ -d $FILE_PATH ]
then
for FILE in $(ls $FILE_PATH/*)
do
echo "This is a file: $FILE"
done
else
exit 1
fi
該指令碼首先檢查$FILE_PATH變數的值是否是一個目錄,如果不是,指令碼將退出,並返回一個錯誤程式碼1。雖然使用退出程式碼能夠告訴其他指令碼該指令碼未成功執行,但卻沒有給執行該指令碼的人做出解釋。
我們讓程式碼變得更加友好些:
if [ -d $FILE_PATH ]
then
for FILE in $(ls $FILE_PATH/*)
do
echo "This is a file: $FILE"
done
else
echo "exiting... provided file path does not exist or is not a directory"
exit 1
fi
如果執行第一個程式碼片段,你將得到大量輸出。如果你得不到輸出,你將不得不開啟指令碼檔案檢視哪些地方可能出錯。但如果你執行第二個程式碼片段,你立刻就能知道是在指令碼指定了無效路徑。僅新增一行程式碼就省去了以後大量的排錯時間。
上述例子僅僅是我在程式設計時嘗試使用的技巧。我相信編寫整潔可讀的bash指令碼還有其他很多好建議,如果你有任何建議,隨時在評論區回覆。很高興能看到其他人提出來的技巧。