8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ZOZOAdvent Calendar 2023

Day 12

Goで標準出力を差し替えて遊ぶ

Last updated at Posted at 2023-12-11

この記事は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のインスタンスを返してくれる関数のようです。
してppdoPrintlnを実行し、実行した結果得られたであろうp.bufを第一引数に渡されたio.Writerに書き込んでいます。
doPrintlnは詳細は省きますが、引数で受け取った情報をpaddingしつつbufferに書き込んだ後に改行をbufferに追加する処理をしています。
ここで注目したいのはGoの標準出力を行う代表的な関数であるfmt.Printlnは、最終的に書き込んでいるものはio.Writerであるという点です。
ではここからどうやって標準出力にHelloWorldを出しているか更に追っていきたいと思います。

os.Stdout

先程まで実装を読んできた過程で、fmt.PrintlnFprintln(os.Stdout, a...)を呼び出しているというところまでわかりました。
そして第一引数に渡されたio.Writerに書き込みます。
つまりfmt.Printlnの実装で第一引数に渡しているos.Stdoutに書き込んでいるということです。
ここまでの情報でos.Stdoutio.Writerを実装しているということはわかりますが、更に追っていきましょう。

osパッケージ

osパッケージもfmtパッケージと同じく標準ライブラリなので$GOROOT/src/osにあります。
osパッケージ配下にはファイルがたくさんあるので、ファイルの一覧を載せるのはやめておきますがお目当てのos.Stdoutos/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")
)

NewFileos/type.goに定義されたtype File structのインスタンスを返す関数です。
ここから先の実装はos毎に異なっていて(file_unix.gofile_windows.go)、膨大な文章量になってしまうのでこれ以上追って説明するのはやめておきます。
このFileに書き込むことで標準出力してるんだなという理解で十分です。
ここで注目したいのはos.Stdout*os.Filevarで定義されている という点です。
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.Createtest.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.txtHello, 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で標準出力を差し替えて遊ぶ方法でした。

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?