上一篇我們為 mytool 加上了 Flag,執行後會把結果印在螢幕上。但在 Shell 中,mytool 和 ps、grep、git 一樣,都能透過標準的輸入輸出互相串接。串接的方式主要有兩種:管道(Pipeline) 用來連接「程式與程式」,重導向(Redirection) 則用來連接「程式與檔案」。
在 Shell 中執行命令時,程式的標準輸入(stdin)預設連接到終端機(讀取鍵盤輸入),標準輸出(stdout)則預設顯示在螢幕上。重導向就是改變這條預設路徑。最常見的是將輸出寫入檔案或讀取檔案,也可以把資料導向特殊裝置(如丟棄輸出的 /dev/null)或另一個串流。
>(覆蓋寫入):把前面的輸出結果存入指定檔案。如果檔案本來有內容,會被完全清空覆蓋。
$ echo "Hello World" > output.txt
這會建立一個名為 output.txt 的檔案,內容是 Hello World。
>>(附加寫入):把輸出結果加到指定檔案的最末端,不會動到原本的內容。
$ echo "New Line" >> output.txt
<(讀取輸入):改變輸入來源,讓程式從檔案讀取資料,而不是從鍵盤。
$ cat < output.txt
<<(Heredoc,多行輸入):輸入來源不是現成的檔案,而是接下來幾行文字,直到遇到指定的結束字串為止,這段文字就直接變成標準輸入。
$ cat <<EOF
第一行
第二行
EOF
EOF 是自訂的結束標記,換成 END、DONE 或其他不會跟內容本身撞名的字串也行。cat 收到的輸入就是 EOF 之間那兩行,不用先把內容存成檔案再用 < 讀進來。實務上常拿來組多行內容直接餵給命令,像是把 SQL 交給 psql:
$ psql -d mydb <<SQL
SELECT * FROM users WHERE active = true;
SQL
結束標記加不加引號,會影響內容裡的變數和命令代換要不要展開。<<EOF(不加引號)裡的 $變數、`指令` 會被展開;<<'EOF'(加引號)則整段當純文字,不做任何展開:
$ name="Evan"
$ cat <<EOF
Hello $name
EOF
Hello Evan
$ cat <<'EOF'
Hello $name
EOF
Hello $name
<<-:允許用 Tab 縮排:一般 Heredoc 對每一行前面的空白很敏感,<<- 允許在每一行前面加 Tab(不是空白)縮排,方便寫在腳本裡本來就有縮排的區塊:
if true; then
cat <<-EOF
縮排後的內容
EOF
fi
<<<(Herestring):只是想把一個字串丟給程式當標準輸入,不需要整段多行內容時用這個:
$ grep "John" <<< "$names"
相較於用 echo "$names" | grep "John" 透過管道傳遞,<<< 會由 Shell 直接把字串內容送進 grep 的標準輸入,寫法更乾淨。
管道的符號是 |。它把前一個命令的輸出結果,直接變成下一個命令的輸入資料。
假設有個檔案 names.txt,裡面是一堆人名,你想找出包含 "John" 的名字,並依字母排序:
$ cat names.txt | grep "John" | sort
cat names.txt:讀取並輸出檔案內容。|:把內容傳給下一關。grep "John":從收到的內容中,篩選出有 "John" 的那幾行。|:再把篩選後的結果傳給下一關。sort:把結果排序,顯示在螢幕上。三個命令是三個獨立的 Process,Shell 把它們同時啟動,資料一邊產生一邊往下流,不用等前一個命令全部跑完。
命令印錯誤訊息走的則是另一條路徑,叫 stderr,不會被 | 接走。如果 cat 或 grep 中途印錯誤訊息,那些字還是會直接顯示在畫面上,不會混進下一個命令收到的資料裡——這也是為什麼我們需要特別把「正常結果」(stdout)和「錯誤訊息」(stderr)分成兩條路徑:分流之後,| 才有辦法只接對那一條。
管道和重導向常常混在一起用,比如把處理好、排序完的 John 們,最後存進檔案:
$ cat names.txt | grep "John" | sort > j_names.txt
前面兩段 | 把資料一路傳到 sort,最後 > 把排序完的結果導進 j_names.txt,畫面上什麼都不會印。串接的順序就是資料流動的順序。
前面提到的 > 預設只會導出正常輸出(stdout),這是有意為之的設計:它能確保存進檔案的資料保持乾淨,不會混入半路的警告或錯誤;同時,錯誤訊息留在畫面上,讓操作者能即時察覺有異常發生。
例如在搜尋檔案時,常會因為部分目錄權限不足而產生錯誤訊息:
$ find / -name "*.log" > result.txt
find: /System/Volumes/Data/.Spotlight-V100: Permission denied
find: /Library/Application Support/com.apple.TCC: Permission denied
...
執行後,result.txt 裡只會保存找到的檔案路徑,不會被報錯文字污染;畫面上顯示的權限錯誤,則即時提醒我們哪些目錄沒有被搜到。
如果我們想進一步控制這些錯誤訊息——例如確定不需要看報錯,想把它們直接丟棄,或是想把正常結果與錯誤訊息一起存檔——就必須手動指定 stderr 的去向。
Shell 是用三個數字(檔案描述符,file descriptor)來代表資料串流的通道:
0:stdin(標準輸入)1:stdout(標準輸出)2:stderr(標準錯誤)平常用的 > 其實是 1> 的簡寫,代表只將編號 1 的 stdout 導向目標。如果想單獨操作 stderr,就把編號 2 寫出來。
單獨丟棄錯誤訊息:2>
如果確定不需要查看錯誤訊息,可以把 stderr 導向 /dev/null(系統的黑洞裝置,任何寫進去的資料都會直接被丟棄):
$ find / -name "*.log" > result.txt 2> /dev/null
這樣搜尋結果會存進 result.txt,不需要的報錯直接丟棄,畫面上不會印出任何干擾。
合併輸出:2>&1
想把 stdout 和 stderr 合併寫進同一個檔案時,可以使用 2>&1:
$ mytool > out.log 2>&1
這裡有兩個關鍵的運作機制:
&?2>1,Shell 會以為你想把錯誤訊息寫進一個檔名叫做「1」的檔案。加上 &,Shell 才會知道後面的 1 指的是檔案描述符(通道 1),而不是檔名。> out.log 再寫 2>&1?由左至右拆解 > out.log 2>&1 的設定過程:
> out.log(即 1> out.log):先把 stdout(1) 改接到 out.log。2>&1:再把 stderr(2) 接到「stdout(1) 當前接的地方」,也就是 out.log。兩步完成後,兩個通道都連向 out.log,正常資料與錯誤訊息順利合併寫進同一個檔案。
如果順序反過來寫成 mytool 2>&1 > out.log:
2>&1:先把 stderr(2) 接到「stdout(1) 當前接的地方」。但這時 stdout(1) 還指著畫面,所以 stderr(2) 也被接到畫面。> out.log:這時才把 stdout(1) 改接去 out.log。結果只有 stdout 寫進檔案,stderr 依然留在畫面上,兩者並沒有合併進同一個檔案。
在較新的 Bash 或 Zsh 中,如果想合併兩者,也可以直接使用簡寫 &>(例如 mytool &> out.log)。但在撰寫需要跨環境相容的 Shell 腳本時,> out.log 2>&1 依然是最通用的標準寫法。
理解了管道與 stderr 的分流機制後,回過頭來看我們自己正在開發的 mytool:當使用者把我們寫的工具接上管道時(例如 mytool list | wc -l 統計筆數,或是 mytool list --json | jq . 解析 JSON),管道預設只會接走 stdout 的內容。
如果我們在程式裡把進度提示(如 Fetching...)或完成訊息直接印在 stdout,這些非資料文字就會混入管道,導致後續命令處理錯誤:
# 錯誤設計:進度與狀態混在 stdout(導致 wc -l 算錯成 4 行)
$ mytool list | wc -l
4
# 正確設計:狀態走 stderr,只有資料留在 stdout
$ mytool list | wc -l
Fetching... # 印在 stderr,畫面上看得見,但不會進入管道
2 # stdout 只留 2 行資料,計算結果正確
只要確保 stdout 只有純粹的資料,stderr 處理進度與日誌,mytool 就能無縫融入 Shell 的管道生態中。
以下整理本篇介紹的常用符號、連接對象與具體作用:
| 機制 | 符號 | 連接對象 | 作用 |
|---|---|---|---|
| 重導向 | > |
命令 → 檔案 | 覆蓋寫入 |
| 重導向 | >> |
命令 → 檔案 | 附加寫入 |
| 重導向 | < |
檔案 → 命令 | 讀取輸入 |
| 重導向 | << |
多行文字 → 命令 | Heredoc,多行輸入 |
| 重導向 | <<- |
多行文字 → 命令 | Heredoc,允許 Tab 縮排 |
| 重導向 | <<< |
字串 → 命令 | Herestring,單一字串輸入 |
| 重導向 | 2> |
命令(stderr) → 檔案 | 單獨導出錯誤訊息 |
| 重導向 | 2>&1 |
命令(stderr) → 命令(stdout 現在接的地方) | 合併 stdout 與 stderr |
| 重導向 | &> |
命令(stdout + stderr) → 檔案 | 合併 stdout 與 stderr(簡寫) |
| 管道 | | |
命令 → 命令 | 把輸出接成下一個命令的輸入 |