この記事はZOZO AdventCalender 2023シリーズ5の12日目の記事です。
Goの標準出力
皆さんはGo言語で始めて書いたプログラムはどんなプログラムでしょうか?
多くの人はHelloWorldではないかと思います。
説明するまでもないですが、こうですね
package main
import fmt
func main() {
fmt.Println("Hello, World!!")
}
この一見シンプルな入門用プログラムも紐解いていけば発見に溢れていて色々と面白いアイディアが生まれます。
というわけでシンプルだけど奥深いGoの世界にDeepDiveしていきましょう。
fmt.Println
Goでとりあえず何かを出力したいなら真っ先に使うのが、このfmt.Println
です。
HelloWorldでも使われてGoの初学者が最初に呼び出す関数です。
この記念すべき初めて呼び出す関数からGoのコードを紐解いていきます。
Goの標準ライブラリのコードを読む
fmt.Println
はGoの標準ライブラリの一つであるfmt
パッケージに実装された関数です。
さっそく読んでいきたいと思いますが、読み始める前にGoの標準ライブラリのコードがどこにあるか説明しておきたいと思います。
- local
localで標準ライブラリがある場所は、$GOROOT/src
になります。
また多くのエディターではLSPの設定などを適切にしていれば、定義ジャンプで標準ライブラリの実装にも飛べるはずです。 - web上で確認
Go本体のコードは正規のリポジトリとしては、https://go.googlesource.com/go というGoogleが独自に運用しているリポジトリにあります。
そしてGithubにあるリポジトリはこのリポジトリのミラーになっています。
最近のGithubはコードを追うための機能も充実しているので、web上で読むのはGithub上で読むのが良いと思います。
というわけで読んでいきましょう。
fmtパッケージ
fmt
パッケージは$GOROOT/src/fmt
にあります。
このディレクトリの中を覗いてみると以下のようなファイルがあります。
[~] $ ls -ltr $GOROOT/src/fmt
total 472
-rw-r--r-- 1 xxxxx staff 2156 4 27 2023 stringer_test.go
-rw-r--r-- 1 xxxxx staff 551 4 27 2023 stringer_example_test.go
-rw-r--r-- 1 xxxxx staff 1477 4 27 2023 state_test.go
-rw-r--r-- 1 xxxxx staff 40245 4 27 2023 scan_test.go
-rw-r--r-- 1 xxxxx staff 32670 4 27 2023 scan.go
-rw-r--r-- 1 xxxxx staff 32652 4 27 2023 print.go
-rw-r--r-- 1 xxxxx staff 1584 4 27 2023 gostringer_example_test.go
-rw-r--r-- 1 xxxxx staff 13919 4 27 2023 format.go
-rw-r--r-- 1 xxxxx staff 59653 4 27 2023 fmt_test.go
-rw-r--r-- 1 xxxxx staff 219 4 27 2023 export_test.go
-rw-r--r-- 1 xxxxx staff 12099 4 27 2023 example_test.go
-rw-r--r-- 1 xxxxx staff 3706 4 27 2023 errors_test.go
-rw-r--r-- 1 xxxxx staff 1725 4 27 2023 errors.go
-rw-r--r-- 1 xxxxx staff 14871 4 27 2023 doc.go
[~] $
お目当てのfmt.Println
はprint.goにありそうですね。
さっそく中身を見てみましょう。
print.go
print.goの中を読んでいくと、他パッケージのimport、const定義、interface定義、そして関数やstructの定義と続いていきます。
読み進めていくとお目当てのPrintln
関数が見つかるはずです。
// Println formats using the default formats for its operands and writes to standard output.
// Spaces are always added between operands and a newline is appended.
// It returns the number of bytes written and any write error encountered.
func Println(a ...any) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
ありました。Println
の実装はFprintln
の第一引数にos.Stdout
を渡して、あとは自身の引数をそのまま引き継いで渡しているように見えます。
Fprintln
の実装はこうなっています。
// Fprintln formats using the default formats for its operands and writes to w.
// Spaces are always added between operands and a newline is appended.
// It returns the number of bytes written and any write error encountered.
func Fprintln(w io.Writer, a ...any) (n int, err error) {
p := newPrinter()
p.doPrintln(a)
n, err = w.Write(p.buf)
p.free()
return
}
newPrinter()
は同じファイルの中に定義してあるpp
というstructのインスタンスを返してくれる関数のようです。
してpp
のdoPrintln
を実行し、実行した結果得られたであろうp.buf
を第一引数に渡されたio.Writer
に書き込んでいます。
doPrintln
は詳細は省きますが、引数で受け取った情報をpaddingしつつbufferに書き込んだ後に改行をbufferに追加する処理をしています。
ここで注目したいのはGoの標準出力を行う代表的な関数であるfmt.Println
は、最終的に書き込んでいるものはio.Writer
であるという点です。
ではここからどうやって標準出力にHelloWorldを出しているか更に追っていきたいと思います。
os.Stdout
先程まで実装を読んできた過程で、fmt.Println
はFprintln(os.Stdout, a...)
を呼び出しているというところまでわかりました。
そして第一引数に渡されたio.Writer
に書き込みます。
つまりfmt.Println
の実装で第一引数に渡しているos.Stdout
に書き込んでいるということです。
ここまでの情報でos.Stdout
はio.Writer
を実装しているということはわかりますが、更に追っていきましょう。
osパッケージ
os
パッケージもfmt
パッケージと同じく標準ライブラリなので$GOROOT/src/os
にあります。
os
パッケージ配下にはファイルがたくさんあるので、ファイルの一覧を載せるのはやめておきますがお目当てのos.Stdout
はos/file.go
に定義されています。
// Stdin, Stdout, and Stderr are open Files pointing to the standard input,
// standard output, and standard error file descriptors.
//
// Note that the Go runtime writes to standard error for panics and crashes;
// closing Stderr may cause those messages to go elsewhere, perhaps
// to a file opened later.
var (
Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)
NewFile
はos/type.go
に定義されたtype File struct
のインスタンスを返す関数です。
ここから先の実装はos毎に異なっていて(file_unix.go
やfile_windows.go
)、膨大な文章量になってしまうのでこれ以上追って説明するのはやめておきます。
このFileに書き込むことで標準出力してるんだなという理解で十分です。
ここで注目したいのはos.Stdout
は*os.File
でvar
で定義されている という点です。
const
ではなく、var
なので変数です。そして先頭が大文字なので外部公開です。
つまり差し替えられます。
標準出力を差し替えて遊ぶ
ここでタイトル回収です。
os.Stdout
が差し替えられることがわかったので、どんなことができるか試してみましょう。
シンプルにファイル出力
package main
import (
"fmt"
"os"
)
func main() {
// *os.Fileを作成して
f, err := os.Create("test.txt")
if err != nil {
fmt.Println(err)
return
}
defer f.Close()
// 差し替える
os.Stdout = f
// printしてみる
fmt.Println("Hello, World!")
}
os.Create
でtest.txt
に書き込む*os.File
を作成して、差し替えてみます。
[~/asobiba] $ ll
total 8
-rw-r--r-- 1 xxxxx staff 198 11 9 23:05 test.go
[~/asobiba] $
[~/asobiba] $ go run test.go
[~/asobiba] $ ll
total 16
-rw-r--r-- 1 xxxxx staff 198 11 9 23:05 test.go
-rw-r--r--@ 1 xxxxx staff 14 11 9 23:07 test.txt
[~/asobiba] $
[~/asobiba] $ cat test.txt
Hello, World!
[~/asobiba] $
fmt.Println
を使っているのに標準出力されずにtest.txt
にHello, World!
が書き込まれています。
期待通りに標準出力をファイル出力に差し替えることができました!
読み取りたい
ファイルに出力することはできましたが、このままでは出来ることはせいぜい書き込む先を変えるぐらいのものです。
もっと遊び倒すにはどうしたらいいでしょう?
そうですね。書き込まれたものを読み取れれば色々できることが増えそうです。
というわけでやっていきます。
package main
import (
"bytes"
"fmt"
"io"
"os"
"strings"
)
// 標準出力をキャプチャする君
type StdoutCapture struct {
orgStdout *os.File
bufChan chan string
writer *os.File
reader *os.File
}
// 初期化。ここで*os.Fileを生成してos.Stdoutを差し替える
func (s *StdoutCapture) Init() error {
var err error
// 元のos.Stdoutを保存しておく
s.orgStdout = os.Stdout
// os.Pipeでio.Writerとio.Readerをつなげる
s.reader, s.writer, err = os.Pipe()
if err != nil {
return err
}
// os.Stdoutをwriterに差し替える
os.Stdout = s.writer
s.bufChan = make(chan string)
// goroutineでreaderから読み込んでbufChanに書き込む
go func() {
var b bytes.Buffer
io.Copy(&b, s.reader)
// たとえばここで末尾にニャンを追加してbufferに書き込む
s.bufChan <- strings.Replace(b.String(), "\n", "ニャン\n", -1)
}()
return nil
}
func (s *StdoutCapture) Close() {
s.writer.Close()
s.reader.Close()
os.Stdout = s.orgStdout
}
func (s *StdoutCapture) PrintAll() {
s.writer.Close()
// 標準出力を元に戻す
os.Stdout = s.orgStdout
// ここでbufChanから読み込んで出力する
fmt.Print(<-s.bufChan)
s.Init()
}
func main() {
s := &StdoutCapture{}
if err := s.Init(); err != nil {
fmt.Println(err)
return
}
defer s.Close()
fmt.Println("Hello, World!")
fmt.Println("腹減った")
hoge()
s.PrintAll()
}
func hoge() {
fmt.Println("ここでPrintlnしてもちゃんと差し替えが効く")
}
実行してみます。
[~/asobiba] $ go run test.go
Hello, World!ニャン
腹減ったニャン
ここでPrintlnしてもちゃんと差し替えが効くニャン
[~/asobiba] $
os.Pipe
でwriterに書き込まれたものをreaderに渡せる2つの*os.File
が取得できます。
これを利用して標準出力をキャプチャして好きに弄り倒すことができます。
実装例では必ず末尾にニャンを追加して出力するようにしてみました。
他にもPrintAll
で標準出力している箇所に例えばSlackに送信するコードを追加すれば、CLIツールでfmt.Println
で標準出力していたログをSlackにも送信するなんてことが出来たりします。
アイディア次第で色々なことが出来そうですね!
最後に余談ですが実はこの手法は標準出力をテストする方法として良く紹介される手法だったりします。
また今回はfmt.Pritntln
の出力先を切り替える手法を紹介しましたが、fmt
パッケージには出力先のio.Writer
を引数で渡せるfmt.Fprintln
がある(fmt.Println
もこれを実行していましたね)ので、
実装時点で出力先を切り替える要件がわかっているのであれば、始めからfmt.Fprintln
を使っておきましょう。
以上、Goで標準出力を差し替えて遊ぶ方法でした。