在多程式分享資源的環境下,資料一致性問題時常發生。檔案鎖定機制提供了一個有效的解決方案,避免多個程式同時修改同一檔案,造成資料損壞或不一致。lockfile 命令提供可靠的檔案鎖定功能,解決了簡單鎖定機制中存在的競爭條件問題。本文提供的 filelock 指令碼,利用 lockfile 命令,實作了更靈活的鎖定和解鎖功能,方便在 Shell 指令碼中使用。此外,文章也說明瞭如何使用 ANSI 色彩序列,使終端輸出更具可讀性,並介紹瞭如何透過參照檔案的方式,建構 Shell 指令碼函式庫,提升程式碼的模組化和可重用性。

檔案鎖定機制:確保資料完整性的關鍵技術

在多程式平行執行的環境中,如何確保分享資源的安全存取是一個重要的技術挑戰。檔案鎖定(File Locking)機制提供了一種有效的解決方案,用於防止多個程式同時修改同一檔案而導致的資料損壞或不一致問題。

鎖設定檔案的重要性

當多個指令碼或程式需要讀取或寫入同一個檔案時,例如日誌檔案或分享資料函式庫,檔案鎖定機制就顯得尤為重要。沒有適當的鎖定機制,不同程式可能會同時嘗試修改檔案,導致資料遺失或損壞。

簡單鎖定機制的缺陷

許多看似可靠的鎖定機制在實際應用中卻會失敗。以下是一個常見的錯誤範例:

while [ -f $lockfile ] ; do
  sleep 1
done
touch $lockfile

這個指令碼看似可以正常運作:它會不斷檢查鎖設定檔案是否存在,如果不存在,則建立一個鎖設定檔案以確保獨佔存取。然而,這種方法存在一個嚴重的問題:在檢查鎖設定檔案和建立鎖設定檔案之間存在一個時間視窗,在此期間其他程式可能會檢查到鎖設定檔案不存在並建立自己的鎖設定檔案,從而導致多個程式認為自己擁有獨佔存取權。

使用 lockfile 命令實作可靠的鎖定

幸運的是,有一個名為 lockfile 的命令列工具,可以安全可靠地在 Shell 指令碼中實作檔案鎖定。許多 Unix 發行版,包括 GNU/Linux 和 OS X,都預裝了 lockfile 命令。您可以透過執行 man 1 lockfile 來檢查系統是否已安裝此命令。

filelock 指令碼:一個靈活的檔案鎖定機制

下面是一個名為 filelock 的指令碼,它利用 lockfile 命令提供了一個靈活的檔案鎖定機制。

程式碼解析

#!/bin/bash
# filelock--A flexible file-locking mechanism
retries="10" # 預設重試次數
action="lock" # 預設動作
nullcmd="'which true'" # lockfile 的空命令

while getopts "lur:" opt; do
  case $opt in
    l ) action="lock" ;;
    u ) action="unlock" ;;
    r ) retries="$OPTARG" ;;
  esac
done

shift $(($OPTIND - 1))

if [ $# -eq 0 ] ; then 
  cat << EOF >&2
Usage: $0 [-l|-u] [-r retries] LOCKFILE
Where -l requests a lock (the default), -u requests an unlock, -r X
specifies a max number of retries before it fails (default = $retries).
EOF
  exit 1
fi

# 檢查 lockfile 命令是否存在
if [ -z "$(which lockfile | grep -v '^no ')" ] ; then
  echo "$0 failed: 'lockfile' utility not found in PATH." >&2
  exit 1
fi

if [ "$action" = "lock" ] ; then
  if ! lockfile -1 -r $retries "$1" 2> /dev/null; then
    echo "$0: Failed: Couldn't create lockfile in time." >&2
    exit 1
  fi
else # 解鎖
  if [ ! -f "$1" ] ; then
    echo "$0: Warning: lockfile $1 doesn't exist to unlock." >&2
    exit 1
  fi
  rm -f "$1"
fi

exit 0

#### 內容解密:

  1. while getopts 迴圈:解析命令列引數,支援 -l(鎖定)、-u(解鎖)和 -r(重試次數)選項。
  2. shift 命令:將引數指標向前移動,以便處理鎖設定檔案名稱。
  3. 錯誤處理:檢查是否提供了鎖設定檔案名稱,並顯示使用方法訊息。
  4. lockfile 命令檢查:驗證系統是否安裝了 lockfile 命令。
  5. 鎖定與解鎖邏輯:根據指定的動作(鎖定或解鎖)執行相應的操作,使用 lockfile 命令確保原子性操作。

ANSI色彩序列與檔案鎖定機制

在探討技術細節之前,必須先了解如何在Shell指令碼中實作檔案鎖定以及如何使用ANSI色彩序列來增強終端輸出的可讀性。

檔案鎖定機制(filelock)

檔案鎖定是確保在多程式環境中,特定資源不會被多個程式同時存取,從而導致資料不一致或損壞。filelock指令碼利用系統的lockfile命令來實作檔案鎖定。

程式邏輯解析

while getopts ":l:u:r:" opt; do
  case $opt in
    l) lockfile="$OPTARG";;
    u) unlockfile="$OPTARG";;
    r) retries="$OPTARG";;
    \?) echo "Invalid option: -$OPTARG"; exit 1;;
  esac
done

shift $(($OPTIND - 1))

if [ -n "$lockfile" ]; then
  lockfile -r $retries $lockfile || { echo "Failed to create lockfile"; exit 1; }
elif [ -n "$unlockfile" ]; then
  lockfile -u $unlockfile || { echo "Failed to remove lockfile"; exit 1; }
fi

內容解密:

  1. getopts函式用於解析命令列引數:這裡用於處理-l-u-r選項,分別對應鎖設定檔案、解鎖檔案和重試次數。
  2. shift $(($OPTIND - 1)):此命令用於將已經處理過的引數從命令列引數列表中移除,以便後續處理其他引數。
  3. lockfile命令的使用:根據使用者輸入的引數,決定是否鎖定或解鎖檔案。如果鎖定失敗或解鎖失敗,指令碼會輸出錯誤訊息並離開。

使用ANSI色彩序列增強終端輸出

ANSI色彩序列允許開發者在終端中使用不同的顏色和格式來顯示文字,從而提高輸出內容的可讀性。

程式邏輯解析

initializeANSI() {
  esc="\033"
  
  # 前景顏色
  blackf="${esc}[30m"; redf="${esc}[31m"; greenf="${esc}[32m"
  yellowf="${esc}[33m"; bluef="${esc}[34m"; purplef="${esc}[35m"
  cyanf="${esc}[36m"; whitef="${esc}[37m"

  # 背景顏色
  blackb="${esc}[40m"; redb="${esc}[41m"; greenb="${esc}[42m"
  yellowb="${esc}[43m"; blueb="${esc}[44m"; purpleb="${esc}[45m"
  cyanb="${esc}[46m"; whiteb="${esc}[47m"

  # 粗體、斜體、下劃線和反轉樣式切換
  boldon="${esc}[1m"; boldoff="${esc}[22m"
  italicson="${esc}[3m"; italicsoff="${esc}[23m"
  ulon="${esc}[4m"; uloff="${esc}[24m"
  invon="${esc}[7m"; invoff="${esc}[27m"
  reset="${esc}[0m"
}

內容解密:

  1. initializeANSI函式定義了多個變數,這些變數對應不同的ANSI色彩序列,用於控制終端輸出的顏色和格式。
  2. 使用${reset}重置所有樣式:在每次使用顏色或格式後,必須使用${reset}來重置,否則後續輸出會繼承之前的樣式。
  3. 範例使用方法:呼叫initializeANSI後,可以使用定義的變數來輸出不同顏色和格式的文字,如${yellowf}黃色文字${reset}

建構Shell指令碼函式庫

許多本章的指令碼被寫成函式而非獨立的指令碼,以便於將它們輕易地整合到其他指令碼中,而不必承擔系統呼叫的額外負擔。雖然Shell指令碼中沒有像C語言那樣的#include功能,但有一項極其重要的功能稱為參照檔案(sourcing a file),它達到了同樣的目的,允許你將其他指令碼作為函式庫函式包含進來。

為何需要參照檔案

要理解這一點的重要性,讓我們考慮一下替代方案。如果你直接執行一個Shell指令碼,預設情況下,該指令碼會在其自己的子Shell中執行。你可以透過以下實驗驗證這一點:

$ echo "test=2" >> tinyscript.sh
$ chmod +x tinyscript.sh
$ test=1
$ ./tinyscript.sh
$ echo $test
1

指令碼tinyscript.sh改變了變數test的值,但僅僅是在執行該指令碼的子Shell中,因此當前Shell環境中的test變數值並未受到影響。如果你使用點號(.)符號來參照該指令碼,那麼它就會如同直接在當前Shell中輸入指令碼中的每個命令一樣被處理:

$ . tinyscript.sh
$ echo $test
2

正如你所預期的,如果你參照的指令碼中包含exit 0命令,它將離開當前Shell並登出視窗,因為參照操作使被參照的指令碼成為主要的執行程式。如果你是在子Shell中執行的指令碼,那麼它會在不停止主指令碼的情況下離開。這是一個重大的差異,也是選擇使用.source(稍後我們會解釋exec)來參照指令碼的原因之一。在bash中,.符號實際上與source命令是等效的;我們使用.是因為它在不同的POSIX Shell之間更具可移植性。

建構函式庫

要將本章中的函式轉換為可在其他指令碼中使用的函式庫,請提取所有函式以及所需的全域變數或陣列(即多個函式共用的值),並將它們串聯到一個大檔案中。如果你將這個檔案命名為library.sh,你可以使用以下測試指令碼來存取本章中編寫的所有函式,並檢查它們是否正常工作,如清單1-28所示。

#!/bin/bash
# 函式庫測試指令碼
# 首先參照(讀入)library.sh檔案。
. library.sh
initializeANSI # 設定所有ANSI轉義序列。

# 測試validint功能。
echon "首先,你的路徑中有echo嗎?(1=是, 2=否) "
read answer
while ! validint $answer 1 2 ; do
    echon "${boldon}再試一次${boldoff}。你的路徑中有echo嗎?(1=是, 2=否) "
    read answer
done

# 檢查路徑中的命令是否正常工作?
if ! checkForCmdInPath "echo" ; then
    echo "不,沒有找到echo命令。"
else
    echo "echo命令在PATH中。"
fi

echo ""
echon "輸入你認為可能是閏年的年份: "
read year

# 使用validint檢查指定的年份是否在1到9999之間。
while ! validint $year 1 9999 ; do
    echon "請輸入${boldon}正確${boldoff}格式的年份: "
    read year
done

# 現在測試它是否確實是閏年。
if isLeapYear $year ; then
    echo "${greenf}你說對了!$year是閏年${reset}"
else
    echo "${redf}不,那不是閏年。${reset}"
fi

exit 0

清單1-28:參照先前實作的函式作為單一函式庫並呼叫它們

工作原理

請注意,函式庫被包含進來,所有函式都被讀入並包含在指令碼的執行環境中,這一切都透過X處的單一行程式碼實作。這種處理多個指令碼的有用方法可以在需要時重複利用。只要確保所包含的函式庫檔案在你的PATH中可存取,這樣.命令就能找到它。

執行指令碼

要執行測試指令碼,像執行其他任何指令碼一樣,在命令列中呼叫它,就像清單1-29所示。

程式碼解析:

  1. initializeANSI:初始化ANSI轉義序列,用於設定終端輸出的顏色和樣式。

    • 作用:允許指令碼輸出帶有不同顏色和格式的文字,提升可讀性。
    • 邏輯:透過定義不同的ANSI轉義序列來改變文字顏色和樣式。
  2. validint $answer 1 2:檢查輸入是否為有效的整數,並且是否在指定範圍內(1到2之間)。

    • 作用:確保使用者輸入的是有效的數字,並且符合預期範圍。
    • 邏輯:該函式檢查輸入字串是否為數字,並判斷其是否在給定的最小值和最大值之間。
  3. checkForCmdInPath "echo":檢查指定的命令(此處為"echo")是否存在於系統PATH中。

    • 作用:驗證某個命令是否可執行,確保該命令存在於系統的搜尋路徑中。
    • 邏輯:遍歷PATH環境變數中的所有目錄,檢查是否有指定的可執行檔案。
  4. isLeapYear $year:判斷給定的年份是否為閏年。

    • 作用:根據年份判斷是否符合閏年的條件。
    • 邏輯:閏年的判斷規則是:能被4整除但不能被100整除,或者能被400整除。
  5. echon:輸出文字但不換行,用於提示使用者輸入。

    • 作用:控制輸出格式,讓使用者可以在同一行輸入回應。
    • 邏輯:使用特定的輸出函式,避免自動換行。
  6. while ! validint $year 1 9999 ; do:持續檢查使用者輸入的年份是否有效,直到輸入正確。

    • 作用:確保使用者輸入合法的年份範圍。
    • 邏輯:迴圈檢查輸入,直到符合條件為止。
  7. echo "${greenf}你說對了!$year是閏年。${reset}":輸出帶顏色的文字,表示正確的結果。

    • 作用:使用顏色突顯正確或錯誤的結果,提升使用者經驗。
    • 邏輯:使用ANSI顏色程式碼設定輸出文字的顏色,並在結束後重置終端顏色。
  8. . library.sh:參照外部的函式庫檔案,將其中的函式匯入到當前指令碼中使用。

    • 作用:重用已有的函式,避免重複編寫相同的程式碼。
    • 邏輯:透過.命令將外部檔案中的函式和變數匯入當前環境,使其可用。

此圖示說明瞭整個測試流程:

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title 檔案鎖定機制確保資料完整性

package "安全架構" {
    package "網路安全" {
        component [防火牆] as firewall
        component [WAF] as waf
        component [DDoS 防護] as ddos
    }

    package "身份認證" {
        component [OAuth 2.0] as oauth
        component [JWT Token] as jwt
        component [MFA] as mfa
    }

    package "資料安全" {
        component [加密傳輸 TLS] as tls
        component [資料加密] as encrypt
        component [金鑰管理] as kms
    }

    package "監控審計" {
        component [日誌收集] as log
        component [威脅偵測] as threat
        component [合規審計] as audit
    }
}

firewall --> waf : 過濾流量
waf --> oauth : 驗證身份
oauth --> jwt : 簽發憑證
jwt --> tls : 加密傳輸
tls --> encrypt : 資料保護
log --> threat : 異常分析
threat --> audit : 報告生成

@enduml

此圖示展示了測試指令碼的主要步驟,包括參照函式庫、初始化、檢查輸入以及輸出結果。

除錯 Shell 指令碼的基礎

雖然本文並不包含真正的指令碼,但我們想花幾頁的篇幅談談除錯 Shell 指令碼的一些基礎知識,因為錯誤總是會悄悄溜進來!

建構與測試

根據我們的經驗,最好的除錯策略是逐步建構指令碼。有些指令碼程式設計師過於樂觀,以為一切都會第一次就正常運作,但從小處著手可以真正有所幫助。此外,你應該大量使用 echo 陳述式來追蹤變數,並使用 bash -x 明確呼叫你的指令碼以顯示除錯輸出,如下所示:

$ bash -x myscript.sh

或者,你可以在執行前執行 set -x 以啟用除錯功能,執行後執行 set +x 以停止除錯,如下所示:

$ set -x
$ ./myscript.sh
$ set +x

內容解密:

  • bash -x myscript.sh:使用 -x 選項執行指令碼,可以顯示每行命令的執行過程,幫助開發者追蹤指令碼的執行邏輯。
  • set -xset +x:這兩個命令用於在指令碼中啟用和停用除錯模式,方便開發者定位問題。

簡單數字猜測遊戲的除錯範例

讓我們來除錯一個簡單的數字猜測遊戲,如清單 1-30 所示。

程式碼範例

#!/bin/bash
# hilow--一個簡單的數字猜測遊戲
biggest=100 # 可能的最大數字
guess=0 # 玩家猜測的數字
guesses=0 # 猜測次數
number=$(( $RANDOM % $biggest + 1 )) # 1 到 $biggest 之間的隨機數字
echo "猜一個介於 1 和 $biggest 之間的數字"
while [ "$guess" -ne $number ]; do
  /bin/echo -n "猜猜看? " ; read answer
  guess=$answer
  if [ "$guess" -lt $number ]; then
    echo "... 更大!"
  elif [ "$guess" -gt $number ]; then
    echo "... 更小!"
  fi
  guesses=$(( $guesses + 1 ))
done
echo "正確!在 $guesses 次猜測中猜到了 $number。"
exit 0

內容解密:

  1. number=$(( $RANDOM % $biggest + 1 )):使用 $RANDOM 環境變數生成一個隨機數字,並將其限制在 1 到 $biggest 之間。這種方法比使用 $$(當前 Shell 的 PID)更優,因為 $RANDOM 每次被參照時都會給出不同的值。
  2. 遊戲邏輯:遊戲生成一個隨機數字,讓玩家猜測。每次猜測後,程式會提示玩家猜的數字是太高還是太低,直到玩家猜對為止。
  3. guesses=$(( $guesses + 1 )):記錄玩家猜測的次數,每次迴圈迭代時加一。

常見錯誤與解決方法

在執行清單 1-30 的指令碼時,可能會遇到語法錯誤,例如:

./013-hilow.sh: line 19: unexpected EOF while looking for matching '"'
./013-hilow.sh: line 22: syntax error: unexpected end of file

這些錯誤通常是由於引號或括號不匹配引起的。解決方法是檢查指令碼中的引號和括號是否正確配對。

疑難排解步驟:

  1. 使用 grep 命令搜尋指令碼中所有包含引號的行,並過濾掉那些有成對引號的行,以找出問題所在。
    $ grep '"' 013-hilow.sh | egrep -v '.*".*".*'
    
  2. 修改發現的問題,例如補全缺失的引號或括號。
  3. 重新執行指令碼,直到不再出現語法錯誤。