Go 寫腳本

Unix shell 就像魔法,通過正確的 shell 代碼可以管理文件、處理文本、計算數據,並將任何程序的輸出作爲其他程序的輸入。所謂一行正確的 shell 代碼可實現魔法般的功能。

系統程序

並不是 shell 本身很聰明。作爲一種編程語言,至少對於複雜任務來說,它顯然是笨拙的。但它優雅的設計使它成爲完美的腳本語言:短小、專注於操作文件、進程或文本,爲管理計算機系統服務。換句話說,寫系統程序方便。

目前有各種 shell 腳本,其中 bash shell 具有任務控制和基於文本的用戶交互接口。這裏存在的問題就是,有許多不同的、相互不兼容的 shell 腳本。本文我們將所有這些都稱爲 “shell”。

爲什麼 Go 不能像 shell 腳本那樣寫系統程序呢?

爲什麼使用 Go 寫腳本?

如果 shell 是傳統方式寫系統程序的話, 那麼使用 Go 這樣的語言有什麼意義呢?
Go 有很多優勢:快速、可伸縮、編寫方便,也可以由大型團隊長期維護。

Go 作爲一種強類型編譯語言,爲我們編寫正確的程序提供了很多支持。它還以一種難以忽視的方式暴露錯誤,提倡健壯的程序。然而,儘管 shell 針對腳本和控制特定任務進行了優化,但 Go 是一種通用語言,用於各種不同的應用程序。這並不意味着我們不能將 go 用於系統編程。只是因爲它缺少很多內置的工具來方便編寫這樣的程序。至少,可能不像 shell 那麼簡單。

例如,考慮一個典型的運維任務,計算日誌文件中匹配某個字符串 (比如 error) 的行數。大多數有經驗的 Unix 用戶會編寫某種 shell 腳本來完成。例如:

grep error log.txt |wc -l

該命令的總體效果是打印 log.txt 中匹配字符串 "error" 的行數。shell 可以方便地組合多個命令,如 grep 和 wc,來實現這一目標。

一個典型任務

shell 也可以執行一些複雜任務。假設我們有一個 web 服務器訪問日誌需要分析。下面就是一行訪問日誌:包含客戶端 IP 地址、時間戳和各種請求信息。

203.0.113.17 - - [30/Jun/2019:17:06:15 +0000] "GET / HTTP/1.1" 200 2028 "https://example.com/ "Mozilla/5.0..."

假設我們想找到訪問服務器最多的 10 個用戶。我們該如何實現?每一行訪問日誌表示一個請求,因此需要計算每個 IP 有多少條日誌信息,然後將它們降序排列,找出前面 10 個。下面的 shell 腳本可以實現該功能:

cut -d' ' -f 1 access.log |sort |uniq -c |sort -rn |head

上面的腳本使用 cut 命令從每行日誌中提取 IP 地址,uniq -c 計算每個不同 IP 數量,然後用 sort -rn 降排序,最後使用 head 命令打印前 10 條內容。

由於幾乎所有 Unix 命令都可以接受標準輸入中的數據,並將結果寫入標準輸出,一些非常複雜命令可以用這種方式構造,只需使用 shell 的管道操作符即可。這是一種非常強大和靈活的編程範式,它在很大程度上解釋了 Unix 模型今天的主導地位。因此值得花一點時間學習如何最大限度地發揮 shell 能力。

那麼我們是否可以使用 Go 來實現這個功能?試試用 Go 實現是否簡單。

複雜實現方式

雖然要實現的功能已經明確,但用 Go 編寫這個程序並不容易。顯然,很難做到像 shell 版本那樣簡潔。下面的 go 程序可以實現類似功能:

func main() {
    f, err := os.Open("log.txt")
    if err != nil {
        panic(err)
    }

    defer f.Close()
    scanner := bufio.NewScanner(f)
    uniques := map[string]int{}
    for scanner.Scan() {
        fields := strings.Fields(scanner.Text())
        if len(fields) > 0 {
            uniques[fields[0]]++
        }
    }
    type freq struct {
        addr  string
        count int
    }
    freqs := make([]freq, 0, len(uniques))
    for addr, count := range uniques {
        freqs = append(freqs, freq{addr, count})
    }
    sort.Slice(freqs, func(i, j int) bool {
        return freqs[i].count > freqs[j].count
    })
    fmt.Printf("%-16s%s\n", "Address", "Requests")
    for i, f := range freqs {
        if i > 9 {
            break
        }
        fmt.Printf("%-16s%d\n", f.addr, f.count)
    }
}

這個程序有幾個問題,尤其是它相當複雜。你可能想弄清楚它是如何工作的來測試自己的代碼閱讀能力,但我絕不推薦它作爲 Go 的實現方式。它只是一個快速、未經測試的程序,而這只是部分原因。在 devops 中,我們經常被要求快速解決問題,而不是優雅地解決問題。服務器現在可能出問題了,我們得查出是哪個 IP 地址導致的。

這不是一個令人滿意的結果。如果 Go 這麼棒,爲什麼它不適合解決這個問題?我們該怎麼寫代碼才能和 shell 一樣優雅?

流水線方式

考慮到問題的本質,我們更傾向於將解決方案表示爲流水線,就像 shell 程序一樣。我們怎麼在 Go 中表達類似 shell 中管道操作呢?

File("log").Column(1).Freq().First(10).Stdout()

換句話說,讀取文件日誌,取其第一列,按頻率排序,獲得前 10 個結果,並將它們打印到標準輸出。這不僅非常簡潔,而且可以說比 shell 管道更清晰。例如,初學者不一定知道 cut -d' ' - f1 是幹什麼的。但如果他們看到 Column(1),我想他們會理解的。

這行代碼實現了我們前面用笨拙的 30 多行代碼完成相同的功能。不錯。甚至經驗豐富的 shell 開發者也開始認爲用 Go 編寫系統程序是值得的。

腳本庫

但是這個例子看起來甚至不像 Go 代碼!如何用 Go 實現呢?答案是一個叫做 script 的庫:

import "github.com/bitfield/script"

script 是一個 Go 庫,用於完成 shell 腳本擅長的任務:讀取文件、執行子進程、計算行數、匹配字符串等等。作者喜歡 shell 管道的優雅和簡潔,但更喜歡 Go。現在,您可以在 Go 中構建漂亮的腳本程序,而不必進行冗長的掃描、排序、切片和循環。讓我們看幾個例子。

假設您想以字符串的形式讀取文件的內容。這是它在腳本中的樣子:

data, err := script.File("test.txt").String()

這看起來非常簡單,但是假設您現在想要計算該文件中的行數。

n, err := script.File("test.txt").CountLines()

對於一些更具挑戰性的任務,讓我們試着計算文件中匹配字符串 "Error" 的行數:

n, err := script.File("test.txt").Match("Error").CountLines()

但是,如果我們不讀取特定的文件,而是簡單地將輸入輸送到這個程序,並讓它只輸出匹配的行 (如 grep),該怎麼辦呢?

script.Stdin().Match("Error").Stdout()

這簡直太容易了!因此,讓我們在命令行上傳遞一個文件列表,並讓我們的程序依次讀取它們,並輸出匹配的行:

script.Args().Concat().Match("Error").Stdout()

我們可能只對前 10 個匹配行感興趣呢?沒問題:

script.Args().Concat().Match("Error").First(10).Stdout()

您希望將輸出重定向到文件中,而不是將其打印到終端?

script.Args().Concat().Match("Error").First(10).AppendFile("/var/log/errors.txt")

用戶工具

shell 腳本強大的原因之一不僅僅是 shell 語言本身,shell 是非常基礎的。主要是包含豐富的可用工具,如 grep、awk、cat、find、head 等。但是我們可以使用 script 實現這些工具的大部分功能。下面是一個程序,它只是將輸入轉到輸出,類似 cat 命令:

script.Stdin().Stdout()

下面是一個連接所有給出的文件,並將它們輸出,同樣像 cat:

script.Args().Concat().Stdout()

那 echo 命令可以實現嗎?當然沒問題:

script.Args().Join().Stdout()

以這種方式可以實現大多數熟悉的 Unix 工具。對於腳本中沒有提供的任何東西,我們可以使用工具本身:

script.Exec("open info.pdf")

因爲我們可以運行任何外部程序,所以我們也可以使用 shell 的工具。

script.Exec("bash -c 'echo hello from bash'").Stdout()

shell 腳本中的一個常見操作是使用 find 工具生成遞歸目錄清單。我們也可以這樣做:

script.FindFiles("/backup").Stdout()

假設我們想要對每個以這種方式發現的文件做一些操作。該怎麼處理呢?

script.FindFiles("*.go").ExecForEach("gofmt -w ")

你可能會發現 ExecForEach 的參數是一個 Go 模板;FindFiles 生成的每個文件名將依次被替換到該命令中。

實現原理

上面這些鏈式函數調用看起來有點奇怪。實現原理是怎麼樣的呢?Unix shell 和它的許多模仿者的優點之一是,你可以將操作組合到多個管道中。

cat test.txt | grep Error | wc -l

管道的每個階段的輸出都提供給下一個階段,您可以將每個階段看作一個過濾器,只將其輸入的某些部分轉到輸出。相比之下,在 Go 中編寫類似 shell 的腳本就不那麼方便了,因爲您所做的所有操作都返回不同的數據類型,並且您必須 (或至少應該) 在每次操作之後要檢查錯誤。在系統管理腳本中,我們經常希望以這樣一種快捷方便的方式組合不同的操作。如果在管道的某個地方發生了錯誤,我們希望在最後檢查一次,而不是在每個操作都做檢查。

一切皆管道

script 庫允許我們這樣做,因爲所有東西都是管道 (特別是 script.pipe)。要創建管道,請從 File 這樣的源文件開始:

p := script.File("test.txt")

如果打開文件有問題,您可能希望 File 返回一個錯誤,但它沒有。我們想要對 File 的結果調用一個方法鏈,如果它也返回一個錯誤,那麼這樣做是不方便的。因此 File 返回一個管道。因爲 File 返回一個管道,你可以在它上面調用任何你喜歡的方法。例如,匹配方法:

p.Match("what I'm looking for")

結果是另一個管道 (只包含來自 test.txt 的匹配行),依此類推。你不需要把所有的方法都鏈接到一行上,但是如果你想的話,也非常簡潔的。

錯誤處理

哇, 哇!等等,我們還沒有對錯誤做任何處理。我們知道優秀的 Go 程序員總是會檢查錯誤的。這是因爲我們在程序中所做的所有事情都可能出錯,而程序的健壯性幾乎都與它的錯誤處理有關。

如果在創建管道時打開文件時出現錯誤,該怎麼辦?如果讀取不存在的文件,Match 不會 panic 嗎?它不會。這是因爲如果 File 遇到錯誤,它會在管道上設置一個標誌,表示 “有些地方出錯了”。通常,當 Go 函數遇到錯誤時,它們會返回類似於 nil 對象和一個錯誤值來表明問題。相反,File 返回一個有效的管道,一個設置了錯誤標誌的管道。

所有管道操作在執行任何操作之前都會檢查這個錯誤標誌。如果設置了,則它們沒有有效數據,因此它們直接報錯,不做任何工作就立即返回。

只要任何管道階段遇到錯誤,會在管道上設置錯誤標誌,管道上的所有後續操作都停止。這意味着您不需要在每個階段檢查錯誤:相反,您可以在最後或任何您需要的時候檢查。

你可以通過調用一個管道的 error 方法來檢查它的 error 狀態:

if err
:= p.Error(
); err != nil {
    return fmt.Errorf("oh no: %w", err)
}

關閉管道

如果你在 Go 中處理過文件,就會知道需要在處理完一個文件後關閉它。否則,程序將保留所謂的文件句柄 (表示打開文件的內核數據結構)。對於一個給定的程序和整個系統,打開的文件句柄總數是有限制的,因此泄漏文件句柄的程序最終會崩潰,並在此期間浪費資源。文件並不是惟一需要在讀取後關閉的東西:網絡連接、HTTP 響應 body 等等也需要關閉。

script 如何處理這個問題的?簡單。與管道相關聯的數據源在被完全讀取後將自動關閉。因此,調用任何讀取管方法 (如 String) 都將關閉其數據源。需要在管道上顯式調用 Close 的唯一情況是,當您沒有從管道讀取數據,或者由於某些原因沒有將其讀取到輸出的地方。

如果管道是從不需要關閉的對象 (比如字符串) 創建的,那麼調用 Close 不會執行任何操作。

使用 Go 寫腳本優點

Go 在標準庫中內置了很棒的測試框架。它有一個極好的標準庫,以及數千個高質量的第三方包,幾乎可以實現您所能想象到的任何功能。它是編譯的,所以速度快,而且是靜態類型的,所以是可靠。這是有效的和內存安全。Go 程序可以作爲單一的二進制文件發佈。Go 可以實現大規模項目 (例如 Kubernetes)。

script 完全使用 Go 實現,不需要任何外部其他程序。因此可以將 script 程序編譯爲一個單獨的二進制文件快速構建、部署和運行,而且節省資源。

但是這並不是說 shell 腳本已經過時了。我自己仍然使用很多 shell 腳本。在很多問題上,shell 絕對是正確的選擇。但是小程序往往會發展成大程序,當這種情況發生時,能夠使用一種爲大規模編程而設計的語言的功能是很好的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3VLAIHsCFFQjUTqDjDsklA