GoでHTTPサーバのログを出力する方法
Go で HTTP サーバを構築したときにログを取得する方法を考えてみます。
ログに出力したい情報
- HTTP ステータスコード
- レスポンスボディ
HTTP ステータスコードを取得する方法は、以下の記事が詳しいです。本記事では http.ResponseWriter
を用いてレスポンスボディをロギングする方法を紹介します。
つまり、ログを出力する処理をミドルウェアパターンとして実装するというアプローチです。
エッセンスは以下の構造体によって示されます。これは http.ResponseWriter
インターフェースを埋め込むことで http.ResponseWriter
インターフェースを満たした独自の構造体を定義しています。
type loggingResponseWriter struct { http.ResponseWriter statusCode int }
http.ResponseWriter
は匿名フィールドとして与えているため、http.ResponseWriter
インターフェースが持つ以下の 3 つのメソッドを独自の loggingResponseWriter
で宣言しなかった場合は透過的に埋め込まれている http.ResponseWriter
のメソッドが用いられます。
type ResponseWriter interface { Header() Header Write([]byte) (int, error) WriteHeader(statusCode int) }
上記のサイトでは、以下のように独自の構造体の loggingResponseWriter
に WriteHeader
を実装することで HTTP ステータスコードを取得していました。
func (lrw *loggingResponseWriter) WriteHeader(code int) { lrw.statusCode = code lrw.ResponseWriter.WriteHeader(code) }
このアプローチを拡張して、レスポンスボディもロギングすることを試みます。WriteHeader
メソッドを独自の構造体に実装するアプローチと同様に Write
メソッドを実装することで実現することができます。
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) { log.Printf("Body: %v", string(b)) return lrw.ResponseWriter.Write(b) }
もとの ResponseWriter.Write
の処理はそのまま移譲しますが、呼び出すまでにレスポンスボディをログに出力することができます。
上記の HTTP サーバにリクエストを送信すると、サーバ側に以下のようなログが出力されることが分かります。
2020/06/14 18:46:27 --> GET / 2020/06/14 18:46:27 Body: Hello, World! 2020/06/14 18:46:27 <-- 200 OK
素晴らしいですね。Write
メソッドをわずかに拡張することで透過的にレスポンスボディをロギングすることができました。
なお、レスポンスボディが大きくなることがあるため、レスポンスボディをロギングする際は nytimes/gziphandler などを用いてレスポンスボディを事前に圧縮することをおすすめします。
Goで静的単一代入(SSA)形式に入門する
静的単一代入(SSA)形式
SSA形式とは、変数の定義がプログラムの字面上で唯一になるようにしたプログラムの表現形式である。静的とは、プログラムの字面上で、という意味である。変数の定義が唯一になるように変数の名前替えを行うが、これは普通変数の添字をつけて表す。この結果、SSA形式ではプログラムの上で、変数の定義が1箇所だけになる。
参考: http://coins-compiler.osdn.jp/050303/ssa/ssa-nyumon.pdf
Go言語のSSA
コンパイラで用いるSSAと解析用のSSAのパッケージは異なる。静的解析用のパッケージを使う。
静的解析用
SSA形式でわかること/わからないこと
参考: ソースコードを堪能せよ
わかること
- コントロールグラフ
- 単一の代入であることが保証されている
- 同じ値に対する処理を見つけやすい
- ある値のメソッドを呼び出しているか
- 同じ値に対する処理を見つけやすい
わからないこと
- 公開された変数への代入
- 外部パッケージから変更される可能性がある
- ポインタを介した変更
- どう変更されるかわからない
- unsafe.Pointerを用いるとポインタ演算されてしまう
- インターフェースを介した処理
- どう代入されるかわからない
- リフレクションを介した動作
- 動的に決まるので難しい
パッケージの全体像
ソースコードを堪能せよ P.34 より引用
実装例
ディレクトリ構成は以下を想定する。
. ├── cmd │ └── sample-analysis │ └── main.go ├── go.mod ├── go.sum ├── sample-analysis.go ├── sample-analysis_test.go └── testdata └── src └── a └── a.go
a.go が検査したいコードである。
- a.go
package a func f() { n := 10 if n < 10 { println("n < 10") } else { println("n >= 10") } }
まずはSSA形式でどのように出力されるか確認してみる。
- sample-analysis.go
package testanalysis import ( "fmt" "os" "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/buildssa" ) var Analyzer = &analysis.Analyzer{ Name: "sample-analysis", Doc: Doc, Run: run, Requires: []*analysis.Analyzer{ buildssa.Analyzer, }, } const Doc = "sample-analysis is ..." func run(pass *analysis.Pass) (interface{}, error) { funcs := pass.ResultOf[buildssa.Analyzer].(*buildssa.SSA).SrcFuncs for i := range funcs { // デバッグ出力 funcs[i].WriteTo(os.Stdout) for _, b := range funcs[i].Blocks { fmt.Printf("funcs[i].Blocks -> %#v\n", funcs[i].Blocks) // すべての命令を調べる for _, instr := range b.Instrs { fmt.Printf("instr -> %#v\n", instr) } } } return nil, nil }
- sample-analysis_test.go
package testanalysis_test import ( "testing" "github.com/d-tsuji/testanalysis" "golang.org/x/tools/go/analysis/analysistest" ) func Test(t *testing.T) { testdata := analysistest.TestData() analysistest.Run(t, testdata, testanalysis.Analyzer, "a") }
テストとして実行すると、以下のように出力されることがわかる。
$ go test # Name: a.f # Package: a # Location: C:\Users\dramt\go\src\github.com\d-tsuji\testanlysis\testdata\src\a\a.go:3:6 func f(): 0: entry P:0 S:2 t0 = 10:int < 10:int bool if t0 goto 1 else 3 1: if.then P:1 S:1 t1 = println("n < 10":string) () jump 2 2: if.done P:2 S:0 return 3: if.else P:1 S:1 t2 = println("n >= 10":string) () jump 2 funcs[i].Blocks -> []*ssa.BasicBlock{(*ssa.BasicBlock)(0xc0002d2210), (*ssa.BasicBlock)(0xc0002d22c0), (*ssa.BasicBlock)(0xc0002d2370), (*ssa.BasicBlock)(0xc0002d2420)} instr -> &ssa.BinOp{register:ssa.register{anInstruction:ssa.anInstruction{block:(*ssa.BasicBlock)(0xc0002d2210)}, num:0, typ:(*types.Basic)(0xa87ea0), pos:38, referrers:[]ssa.Instruction{(*ssa.If)(0xc000187c40)}}, Op:40, X:(*ssa.Const)(0xc000187ba0), Y:(*ssa.Const)(0xc000187c20)} instr -> &ssa.If{anInstruction:ssa.anInstruction{block:(*ssa.BasicBlock)(0xc0002d2210)}, Cond:(*ssa.BinOp)(0xc0001c81c0)} funcs[i].Blocks -> []*ssa.BasicBlock{(*ssa.BasicBlock)(0xc0002d2210), (*ssa.BasicBlock)(0xc0002d22c0), (*ssa.BasicBlock)(0xc0002d2370), (*ssa.BasicBlock)(0xc0002d2420)} instr -> &ssa.Call{register:ssa.register{anInstruction:ssa.anInstruction{block:(*ssa.BasicBlock)(0xc0002d22c0)}, num:1, typ:(*types.Tuple)(nil), pos:0, referrers:[]ssa.Instruction(nil)}, Call:ssa.CallCommon{Value:(*ssa.Builtin)(0xc000187c60), Method:(*types.Func)(nil), Args:[]ssa.Value{(*ssa.Const)(0xc000187c80)}, pos:54}} instr -> &ssa.Jump{anInstruction:ssa.anInstruction{block:(*ssa.BasicBlock)(0xc0002d22c0)}} funcs[i].Blocks -> []*ssa.BasicBlock{(*ssa.BasicBlock)(0xc0002d2210), (*ssa.BasicBlock)(0xc0002d22c0), (*ssa.BasicBlock)(0xc0002d2370), (*ssa.BasicBlock)(0xc0002d2420)} instr -> &ssa.Return{anInstruction:ssa.anInstruction{block:(*ssa.BasicBlock)(0xc0002d2370)}, Results:[]ssa.Value(nil), pos:0} funcs[i].Blocks -> []*ssa.BasicBlock{(*ssa.BasicBlock)(0xc0002d2210), (*ssa.BasicBlock)(0xc0002d22c0), (*ssa.BasicBlock)(0xc0002d2370), (*ssa.BasicBlock)(0xc0002d2420)} instr -> &ssa.Call{register:ssa.register{anInstruction:ssa.anInstruction{block:(*ssa.BasicBlock)(0xc0002d2420)}, num:2, typ:(*types.Tuple)(nil), pos:0, referrers:[]ssa.Instruction(nil)}, Call:ssa.CallCommon{Value:(*ssa.Builtin)(0xc000187cc0), Method:(*types.Func)(nil), Args:[]ssa.Value{(*ssa.Const)(0xc000187ce0)}, pos:84}} instr -> &ssa.Jump{anInstruction:ssa.anInstruction{block:(*ssa.BasicBlock)(0xc0002d2420)}} --- PASS: Test (0.15s) PASS
ここからわかることは、全部で4つのBlockに分かれているということだ。いくつか最適化されている気がする。例えば n := 10
という宣言が出力されていない。if
文の条件を締めているブロック 0
にはリテラルとして 10:int
と表示されている。return
の条件が使い回されているのが面白い。つまりブロック1からも3からも最終的にブロック2の return
を呼び出している。
余談だが、ブラウザ上でソースコードをSSA形式に変換して表示してくれるサイトがあった。面白いし、役に立つ。
https://golang-ssaview.herokuapp.com/
ドキュメント
- BasicBlock
BasicBlock
は以下のような構造体である。
type BasicBlock struct { Index int // index of this block within Parent().Blocks Comment string // optional label; no semantic significance parent *Function // parent function Instrs []Instruction // instructions in order Preds, Succs []*BasicBlock // predecessors and successors succs2 [2]*BasicBlock // initial space for Succs dom domInfo // dominator tree info gaps int // number of nil Instrs (transient) rundefers int // number of rundefers (transient) }
Instruction
を内包していて Instruction
は以下のようなメソッドをもつインターフェースである。(ドキュメントを除く)
- Instruction
type Instruction interface { String() string Parent() *Function Block() *BasicBlock setBlock(*BasicBlock) Operands(rands []*Value) []*Value Pos() token.Pos }
Instruction
インターフェースを満たしている構造体は以下の通りである。同様に Value
インターフェースや Member
インターフェースを満たしている構造体もGoDocに示されている。
https://godoc.org/golang.org/x/tools/go/ssa
Value? | Instruction? | Member? | |
---|---|---|---|
*Alloc | ✔ | ✔ | |
*BinOp | ✔ | ✔ | |
*Builtin | ✔ | ||
*Call | ✔ | ✔ | |
*ChangeInterface | ✔ | ✔ | |
*ChangeType | ✔ | ✔ | |
*Const | ✔ | ||
*Convert | ✔ | ✔ | |
*DebugRef | ✔ | ||
*Defer | ✔ | ||
*Extract | ✔ | ✔ | |
*Field | ✔ | ✔ | |
*FieldAddr | ✔ | ✔ | |
*FreeVar | ✔ | ||
*Function | ✔ | ✔ | (func) |
*Global | ✔ | ✔ | (var) |
*Go | ✔ | ||
*If | ✔ | ||
*Index | ✔ | ✔ | |
*IndexAddr | ✔ | ✔ | |
*Jump | ✔ | ||
*Lookup | ✔ | ✔ | |
*MakeChan | ✔ | ✔ | |
*MakeClosure | ✔ | ✔ | |
*MakeInterface | ✔ | ✔ | |
*MakeMap | ✔ | ✔ | |
*MakeSlice | ✔ | ✔ | |
*MapUpdate | ✔ | ||
*NamedConst | ✔ | (const) | |
*Next | ✔ | ✔ | |
*Panic | ✔ | ||
*Parameter | ✔ | ||
*Phi | ✔ | ✔ | |
*Range | ✔ | ✔ | |
*Return | ✔ | ||
*RunDefers | ✔ | ||
*Select | ✔ | ✔ | |
*Send | ✔ | ||
*Slice | ✔ | ✔ | |
*Store | ✔ | ||
*Type | ✔ | (type) | |
*TypeAssert | ✔ | ✔ | |
*UnOp | ✔ | ✔ |
BasicBlock
のグラフを遷移するには Instruction
を型アサーションして、型に応じた処理をすることになるだろう。
例えば nilerr
というチェックツールでは以下のようにして、条件を取り出している。if文の条件を取得する実装は b.Instrs[len(b.Instrs)-1].(*ssa.If)
である。*If
の型であることがわかると、Condフィールド(これも Value
インターフェースを満たしている)が取得できるため、これが *BinOp
型であるかどうか型アサーションをして確かめ、*BinOp
型であれば、必要なフィールドを用いて、ロジックを実装することになる。
func binOpErrNil(b *ssa.BasicBlock, op token.Token) ssa.Value { if len(b.Instrs) == 0 { return nil } ifinst, ok := b.Instrs[len(b.Instrs)-1].(*ssa.If) if !ok { return nil } binop, ok := ifinst.Cond.(*ssa.BinOp) if !ok { return nil } if binop.Op != op { return nil } if !types.Implements(binop.X.Type(), errType) { return nil } if !types.Implements(binop.Y.Type(), errType) { return nil } xIsConst, yIsConst := isConst(binop.X), isConst(binop.Y) switch { case !xIsConst && yIsConst: // err != nil or err == nil return binop.X case xIsConst && !yIsConst: // nil != err or nil == err return binop.Y } return nil } func isConst(v ssa.Value) bool { _, ok := v.(*ssa.Const) return ok }
https://github.com/gostaticanalysis/nilerr/blob/master/nilerr.go#L58-L99
*BinOp
型に含まれる X
や Y
フィールドもまた Value
インターフェースであるため、定数かどうかは *Const
型であるかどうか型アサーションをすることになる。このあたりは割と愚直な印象である。
Go製のマルチプラットフォームなクリップボードライブラリを作った
はじめに
Go でマルチプラットフォームで動く CLI を作っていて、その過程でクリップボードにコピーしたり、ペーストできるようなライブラリがほしくなった。
マルチプラットフォームで動く Go のクリップボードライブラリを探してみると、ほとんど出てこなくて、たぶん以下のみ
https://github.com/atotto/clipboard
これも Windows は WindowsAPI を使って外部パッケージに依存せずに動作できるようになっているが、Linux と macOS については xclip や pbcopy などの外部パッケージのコマンドを叩くだけのラッパーになっている。
WSL の ubuntu で動かしてみたのだが動作せずに exit status 1
などと cmd.Exec の外部コマンドを実行したときのエラーのみが返ってきて、困っていた。Linux や macOS のクリップボードについても、外部パッケージを使うのではなく、Go のみで実装できると安定するな、と思い、作成方法を模索し始めた。
できたもの(失敗作)
現状は Linux/Unix 環境用で動かすことが実現できていない。
https://github.com/d-tsuji/clipboard
Windows
Windows には Clipboard を操作する API が用意されている。
https://docs.microsoft.com/ja-jp/windows/win32/dataxchg/using-the-clipboard
全体の流れはこれがわかりやすいように思える。
http://wisdom.sakura.ne.jp/system/winapi/win32/win90.html
- クリップボードにデータを転送する専用のメモリの確保
- クリップボードは OpenClipboard を用いて書き込む権利を取得
- EmptyClipboard でクリップボードを空にする
- CloseClipboard でクリップボードを閉じる
- SetClipboardData でクリップボードにデータを渡す
atotto/clipboard ではどうなっていたかというと以下のように各種のプロシージャを取得している。
user32 = syscall.MustLoadDLL("user32") openClipboard = user32.MustFindProc("OpenClipboard") closeClipboard = user32.MustFindProc("CloseClipboard") emptyClipboard = user32.MustFindProc("EmptyClipboard") getClipboardData = user32.MustFindProc("GetClipboardData") setClipboardData = user32.MustFindProc("SetClipboardData")
openClipboard を使ってクリップボードを開いている。雰囲気は分かるが細部はわからず。。Windows のデベロッパーサイトには C++ の例は記載されている。
HANDLE GetClipboardData( UINT uFormat );
しかし Go の例は記載されていないので、自前でシステムコールを扱う必要があるのだが、どのように調べるのだろうか。わからず。
atotto/clipboard での実装は以下のような感じ
func waitOpenClipboard() error { started := time.Now() limit := started.Add(time.Second) var r uintptr var err error for time.Now().Before(limit) { r, _, err = openClipboard.Call(0) if r != 0 { return nil } time.Sleep(time.Millisecond) } return err }
func readAll() (string, error) { err := waitOpenClipboard() if err != nil { return "", err } defer closeClipboard.Call() h, _, err := getClipboardData.Call(cfUnicodetext) if h == 0 { return "", err } l, _, err := globalLock.Call(h) if l == 0 { return "", err } text := syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(l))[:]) r, _, err := globalUnlock.Call(h) if r == 0 { return "", err } return text, nil }
Windows API のラッパーに関してはいろいろリポジトリが揃っているようだったので、それのうちの 1 つを用いることにした。
例えば以下
今回は上の lxn/walk を使っている。これのおかげで直接はシステムコールを扱わずともクリップボードを扱える
func get() (string, error) { c := walk.Clipboard() return c.Text() }
OSX
Windows と同様に Apple が提供しているクリップボード用の API が存在する
https://developer.apple.com/documentation/appkit/nspasteboard?language=objc
Swift か Objective-C 用のライブラリでクリップボードに対して Write, Read はできる。が当然だが Go には存在しない。少し調べたが、Go でのラッパーもほとんど見つからなかった。WindowsAPI のようにシステムコールを発行して、Pure Go で実装できるのであろうか。わからない。
Go の実験的なリポジトリとして一部の cocoa の実装があったが NSPasteboard の仕組みは提供されていなかった。
https://github.com/golang/exp/blob/master/shiny/driver/gldriver/cocoa.go
が、以下の不思議なサイト1に Objective-C を cgo でラップしている呼び出している面白い実装があった。
Go のランタイムの仕組みには cgo という仕組みが合って、C のコードを Go で呼び出すことができる。が、ビルド時に C コンパイラが必要になるため、ライブラリとして提供するのは筋が悪いように思えた。
gmp/clip では以下のようにして Objective-C のコードを Go で呼び出すことに成功している。
package ns /* #cgo CFLAGS: -x objective-c -fno-objc-arc #cgo LDFLAGS: -framework AppKit -framework Foundation #pragma clang diagnostic ignored "-Wformat-security" #import <Foundation/Foundation.h> #import <AppKit/NSPasteboard.h> void NSObject_inst_Release(void* o) { @autoreleasepool { [(NSObject*)o release]; } } // ... void* _Nullable NSPasteboard_inst_GetString(void* o) { NSString* _Nullable ret; @autoreleasepool { ret = [(NSPasteboard*)o stringForType:NSPasteboardTypeString]; if (ret != nil && ret != o) { [ret retain]; } } return ret; } */ import "C" import ( "unsafe" "runtime" ) // ...
実際 GitHub Actions で macOS の OS 上でのテストが通ったので、上記のコードは正常に動くだろう。手元に masOS のパソコンがないため実際には試しせていない。
しかし当然ながら cgo を用いているため、ライブラリとして組み込んだ場合に C コンパイラが要求される。README には記載しているがライブラリとしては筋が悪い気がする。macOS に詳しくないので何も言及できないが、 WindowsAPI のときと同様にシステムコールを自前で発行すれば、cgo を用いてなくても実装できるのではないか?
Linux
Windows や macOS の仕組みとは異なり、Linux のカーネルそのものにはクリップボードの仕組みは提供されておらず、X11 に依存する。また Wayland (私は初めて知ったが)というディスプレイサーバプロトコルもクリップボードを提供できるよう。
Linux だと xclip や xsel というツールがよく使われるようで、実際 atotto/clipboard でも xclip, xsel のコマンドを叩くようになっている。
いずれもは C 言語で書かれていて、コマンドラインでクリップボードのコピーとペーストを提供している。コードは 1000 行くらい。コードの読みやすさとしては xsel のほうが読みやすかった。
X11 では selection という概念があって、Selection を使ってウィンドウとデータを交換することができる。
クリップボードでは PRIMARY と CLIPBOARD と SECONDARY という selection を使うことができる
- 公式マニュアル: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.1
- 日本語訳: http://www.maroontress.com/ICCCM/ja-icccm.pdf
領域 | 説明 |
---|---|
PRIMARY | 引数を 1 つだけとるすべてのコマンドに対して使用され、セレクションのメカニズムを用いるクライアント間のコミュニケーションの主要な手段。PRIMARY でマウスのクリックでペーストできるような仕組みも実現している。 |
SECONDARY | PRIMARY セレクションが存在して、ユーザがそれを乱したくないときに、データを獲得する手段として用いられる。 |
CLIPBOARD | アンドペーストやコピーアンドペーストするデータを保持するのに用いる。最も馴染みがある領域。 |
というもの
いずれにしても X11 サーバに対してプロトコル通信する必要がある。cgo を使って既存の X クライアントの実装を移植するのも一つの手だが、大変である。既存の実装は 1000 行くらいでそんなに多くはないが、cgo 自体が大変そうで、メモリ管理などを C 同様に明示的に実装する必要があったり、その他いろいろありそう。最初の取っ掛かりとしては重すぎる。
調べていたら X クライアントの Go 実装が存在した。もともと Google のコードだったが、メンテされていないから移植したらしい。
https://github.com/BurntSushi/xgb
で、このライブラリを使った X のクリップボードの実装があった。リポジトリ自体は Go 製のテキストエディタを実装することを目的としている。
https://github.com/aarzilli/nucular
aarzilli/nucular の以下の実装を多少修正すればライブラリとしても使えるので、これを使うことにした。
未解決の問題
未解決の問題があって、X に値をセットしたときに、セットしたプログラムが終了してしまうと、セットした値はなくなってしまう。xclip や xsel ではどうしているかというと、プロセスを fork()
して、fork した子プロセスから値をセットするようになっている。Wayland 用のクリップボードライブラリである wl-clipboard の実装もみてみたが、やはり fork()
するようになっている。また、子プロセスは、selection へ他のプロセスから割り込まれた(Ctrl+C など)ときに SelectionClear のイベントを受け取って、プロセスが終了するようになっている。
- デーモン化
Go では基本的に fork() の仕組みは提供されていないが os.Args[0]
や裏技的にシステムコールを発行することはできる。
今回は sevlyar/go-daemon を用いてプロセスを生成した。単一のアプリケーションとして動作させる場合はうまく動作した。しかしライブラリとして組み込まれる場合は機能しなかった。これは os.Args[0]
で外部プロセスを起動する仕組みであるために、他のリポジトリに組み込まれることは想定しておらず、組み込まれた場合は機能しない。VividCortex/godaemon や syscall.RawSyscall(syscall.SYS_FORK, 0, 0, 0)
を実行する方法も試したが、結局思った動作は得られなかった。
- イベントでプロセス終了
SelectionClear のイベントを受け取ったタイミングでプロセスが終了する実装は簡単である。channel と goroutine を組み合わせて、SelectionClear のイベントを受け取ったタイミングで doneCh <- struct{}{}
などとし、待っている側のゴルーチンは <- doneCh
としておけばよい。
まとめ
気軽にやってみよう、と取り組んでみたが xlib や Windows API など普段の業務よりも低レベルなプログラミングが要求されて、力不足を感じた。ないものは作る精神なので、低レベルなアプリも実装できる技術力を身につけていきたい。
-
Go 製の GitHub 風のバージョン管理システムを用いている: ↩
orelangをGoで実装した
以下の記事を読んで私も orelang を実装してみました。実装は記事に沿った形にしています。
https://github.com/d-tsuji/orelang
字句解析を JSON パーサにおまかせしているので、やっていることはパースした JSON に含まれる文字を見て順番に評価していくことです。構文解析は適当なので、invalid な JSON を与えると Go でエラーがおきます。
式クラスを定義しておいて、引数に応じてオペレータを呼び出すのか、即値の評価をするのか...ということを繰り返し行っていきます。文字に対応するオペレータを定義するところがポイントです。
言語処理系を実装する書籍だと、Go言語でつくるインタプリタが有名ですが、JSON パーサを用いることで簡単に、だが重要な点は学べそうで、とても良いと思います。
Goで再帰的にディレクトリのファイルを取得する方法
はじめに
ディレクトリにあるファイルの一覧を取得したい機会があると思います。シェルの find
みたいな動作を Go で実現する方法の一つとして ioutil.ReadDir を用いて深さ優先探索をする方法があります。その他には filepath.Walk を用いる方法が考えられます。
ioutil.ReadDir を用いた実装例として以下の記事に記載があります。
しかし、どちらの実装も呼び出したときのエラーハンドリングとして panic
ないしは log.Fatal
のいずれかを用いています。もちろんサンプルのプログラムとして記載しているためにこのような実装になっていると想像しています。しかし Go をはじめたばかりの方だとこのようなエラーハンドリングをしているプログラムをそのまま使ってしまう可能性があるなぁ...と思っています。Go では呼び出し元に error を返却して main.go などのエントリーポイントでエラーを出力するのが正しいエラーハンドリングです。
そこで本記事では ioutil.ReadDir を用いて正しくエラーハンドリングする実装例を紹介します。
実装例
例として以下のように 3 つのファイルがあるディレクトリを走査したいと思います。
mypkg ├── dir_b │ └── file_b ├── dir_c │ └── file_c └── file_a
- dir.go
ポイントはディレクトリの場合は再帰的に Dirwalk を呼び出して、ディレクトリツリーの葉(ファイル)に到達するまで探索することです。そして、葉に到達したパスを順次呼び出し元のパスにマージしていきます。
- dir_test.go
以下のようなテストで想定通りファイルの一覧を取得できていることが確認できます。
$ go test -v === RUN TestDirwalk --- PASS: TestDirwalk (0.01s) PASS ok github.com/d-tsuji/go-sandbox/mypkg 0.016s
おわりに
ディレクトリ配下のすべてのファイルを取得でき、丁寧にエラーハンドリングできる実装例を記載しました。皆さんの参考になれば幸いです。
Qiisync開発雑記
はじめに
Qiita の記事を CLI で投稿したり、更新することができるクライアントツールを作りました。
https://github.com/d-tsuji/qiisync
機能的な紹介は README が詳しいので、このブログでは開発に関する雑記を記したいと思います。
CLIの設計
ユーザからどのようなパラメータを受け取ってどのような処理を実現するか?という点です。Qiisync を作っていて一番気になったのが、記事を投稿するコマンドオプションをどのように入力させるか、という点です。
記事を投稿するためには本文のマークダウンの他に、タイトル、タグ、限定公開記事にするかどうか?という 3 点を少なくとも指定する必要があります。これらをコマンドラインのオプションで指定するのか、標準入力からインタラクティブに受け取るのか、迷いました。
3 つも( --private
はデフォルトで false
にしておけば 2 つ)必須で入力させるのはどうかな?初見のユーザでちょっと使いにくいかもしれない...と思い、今回は標準入力からインタラクティブに受け取る方針にしています。
qiisync post <filepath>
コマンドラインからパラメータを受け取る場合は以下のように考えていました。
qiisync post <filepath> --title xxx --tag Go:1.14 --private false
ただ、標準入力からインタラクティブに受け取るようにすると、いちいち入力を求められるので、それはそれで面倒、というユーザもいると思っていて、このあたりの感覚はまだつかめていないです。
フォルダ構成
最初は main.go も含めてすべてのファイルを main パッケージとして実装していました。がまぁ、起動のエントリーポイントと実装の詳細は分離したほうがいいだろう、と思い直し、コマンドの実装の詳細は qiisync パッケージとして切り出しました。main.go に関しては ./cmd/qiisync/main.go としてよくある cmd フォルダ配下に格納しました。これはよく馴染んでいると思っています。
以下のような構成です。
. ├── article.go ├── article_test.go ├── cmd │ └── qiisync │ └── main.go ...
テスト
基本的に Qiita が提供している API を用いて処理を組み立てることになるので、API のコールは Qiisync の中心的な役割になります。単体テストはしっかりやっておきたいな、と思っていて一番しっくりきたのが go-github の実装です。Qiisync の HTTP クライアントの実装方法は go-github を参考にしています。
以下のように構造体にリクエストの URL を保持しておいて、リクエストの引数としてパス文字列を受け取る方針です。
func (b *Broker) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) { if !strings.HasSuffix(b.BaseURL.Path, "/") { return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", b.BaseURL) } // ... }
このようにしておくと setup() 内で httptest によるモックサーバを立ち上げることができ、かつパスをマルチプレクサに登録でき、テスタブルな HTTP クライアントになります。
func Test_fetchRemoteArticle(t *testing.T) { broker, mux, _, teardown := setup() defer teardown() mux.HandleFunc("/api/v2/items/c686397e4a0f4f11683d", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") fmt.Fprint(w, ` { "rendered_body": "<h1>Example</h1>", "body": "# Example", "coediting": false, } `) })
Makefile
まだカチッと Makefile が定まっていないのですが、だいたい普段使いするコマンドは決まるはずなので、もう少し悩まずに予め決めておきたいです。いまのところ以下のような Makefile になっています。
.PHONY: all build test lint clean deps devel-deps BIN := qiisync BUILD_LDFLAGS := "-s -w" GOBIN ?= $(shell go env GOPATH)/bin export GO111MODULE=on all: clean build deps: go mod tidy devel-deps: deps GO111MODULE=off go get -u \ golang.org/x/lint/golint build: go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) ./cmd/qiisync test: deps go test -v -count=1 ./... test-cover: deps go test -v -count=1 ./... -cover -coverprofile=c.out go tool cover -html=c.out -o coverage.html lint: devel-deps go vet ./... $(GOBIN)/golint -set_exit_status ./... clean: rm -rf $(BIN) go clean
リリース
GoRelease がめちゃくちゃ便利です。.gorelease.yaml
も少し悩んだので、テンプレ的な感じにしておきたいです。今回初めて Homebrew の設定を含めました。
project_name: qiisync before: hooks: - go mod tidy builds: - goos: - linux - darwin - windows goarch: - amd64 - 386 main: ./cmd/qiisync binary: qiisync ldflags: - -s -w - "-X main.version={{.Version}}" archives: - format: zip name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" files: - LICENSE - README.md nfpms: - formats: - deb - rpm dependencies: - rpm vendor: "d-tsuji" homepage: "https://github.com/d-tsuji/qiisync" maintainer: "Tsuji Daishiro" description: "Qiita CLI tool, support push and pull from/to local filesystem and Qiita." license: "MIT" file_name_template: "{{.ProjectName}}_{{.Version}}_{{.Os}}-{{.Arch}}" replacements: amd64: 64-bit 386: 32-bit darwin: macOS linux: Tux brews: - description: "Qiita CLI tool, support push and pull from/to local filesystem and Qiita." github: owner: d-tsuji name: homebrew-qiisync commit_author: name: goreleaserbot email: goreleaser@carlosbecker.com homepage: "https://github.com/d-tsuji/qiisync" install: | bin.install "qiisync" test: | system "#{bin}/qiisync" checksum: name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
GitHub Actions
これもめちゃくちゃ便利。いまのところ push したときに起動するテスト用と、タグを打ったときに実行されるリリース用の 2 つの GitHub Actions を使っています。
- テスト
テストではカバレッジも取得するようにしています。またテスト環境としては macOS と ubuntu で回しています。Windows でも回したいのですが、これは実装側を Windows 対応する必要があって、対応できていないです。
name: test on: [push, pull_request] jobs: test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: - ubuntu-latest - macOS-latest steps: - name: Checkout code uses: actions/checkout@master - name: Setup Go uses: actions/setup-go@v1 with: go-version: 1.x - name: Add $GOPATH/bin to $PATH run: echo "::add-path::$(go env GOPATH)/bin" - name: Test run: make test-cover - name: Lint run: make lint - name: Send coverage uses: shogo82148/actions-goveralls@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-profile: c.out parallel: true job-number: ${{ strategy.job-index }} finish: runs-on: ubuntu-latest needs: test steps: - name: finish coverage report uses: shogo82148/actions-goveralls@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true
- リリース
ちょっとだけハマったのが、homebrew に登録するときに GitHub にリポジトリを作成して、そのリポジトリにコミットするようにします。goreleaserでHomebrewのFormulaを自動生成する の内容の話です。最初、GITHUB_TOKEN に権限がないトークンを指定していて、書き込めないという事象でちょっと悩みました。これは Write 権限がある ACCESS_TOKEN を発行することで解決しました。
name: release on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+" jobs: release: runs-on: ubuntu-latest steps: - name: setup go uses: actions/setup-go@v1 with: go-version: 1.14.x - name: checkout uses: actions/checkout@v2 - name: run GoReleaser uses: goreleaser/goreleaser-action@v1 env: GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} with: version: latest args: release --rm-dist
まとめ
CLI ツールの作成は楽しいです。私にとって少し便利になるツールを作りました。これが少しでも誰かの役に立つと嬉しいです。
APIサーバのおけるGoのエラーハンドリングについて考えてみる
Go のエラーハンドリングについて考えてみます。ここではライブラリの中で扱うようなエラーハンドリングではなく、Web アプリケーション、とりわけ API サーバとして振る舞うようなアプリケーションでのエラーハンドリングについて考えます。前提として Error handling and Go は読んでいるものとします。また標準ライブラリで構成するサーバを想定し、WAF を用いたハンドリング方法は紹介しません。
例として、以下のような 2 つのパスを持つ API サーバを考えます。
- /user/create
- /user/delete
package main import ( "database/sql" "log" "net/http" ) type UserHandle struct { db *sql.DB } func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Create")) // ハンドラの処理 } func (u *UserHandle) Delete(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Delete")) // ハンドラの処理 } func NewUserHandle(db *sql.DB) *UserHandle { return &UserHandle{db: db} } func RegisterUserHandler(db *sql.DB, prefix string, mux *http.ServeMux) { u := NewUserHandle(db) mux.HandleFunc(prefix+"/create", u.Create) mux.HandleFunc(prefix+"/delete", u.Delete) } func main() { mux := http.NewServeMux() var db *sql.DB // db のセットアップはしてある想定 RegisterUserHandler(db, "/user", mux) if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatal(err) } }
このような場合、エラーのハンドリングが冗長になる傾向があります。Create メソッドの処理を考えます。Create メソッドの中でクライアントに返却するエラーとロギングをする必要があります。ロギングは任意ですが、通常の場合必要です。
func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) { err := Hoge() if err != nil { writeLog() http.Error(w, fmt.Sprintf("...: %w", err) , 400) } err := Foo() if err != nil { writeLog() http.Error(w, fmt.Sprintf("...: %w", err) , 500) } err := Hoo() if err != nil { writeLog() http.Error(w, fmt.Sprintf("...: %w", err) , 500) } }
あっという間に似たようなエラーハンドリングの実装が並んでしまい、メンテンスが悪くなってしまいます。これを回避するためには以下のようにハンドラをラップする層が必要になります。
Create メソッドを CreateHandler という名前に変更しておきましょう。
実際のビジネスロジックを担う Create メソッドのシグネチャを以下のように変更して、Create メソッドから error を返せるようにします。
-func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) { +func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) error { // ... }
このようにすると実装は以下のようにエラーを呼び出し元に返すことができます。
func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) error { err := Hoge() if err != nil { return err } err := Foo() if err != nil { return err } err := Hoo() if err != nil { return err } }
このとき CreateHandler は以下のような実装になります。
func (u *UserHandle) CreateHandler(w http.ResponseWriter, r *http.Request) { err := u.Create(w, r) if err != nil { writeLog() http.Error(w, fmt.Sprintf("...: %w", err) , 500) } }
さて、CreateHandler では、Create メソッドのエラーの種類に応じて、クライアントに返却するレスポンスコードを変える必要があります。error の値に応じて処理を分岐するイディオムが Go にはあって、errors.As を用いると良いです。(Go1.13 以前の場合は Error handling and Go にもあるように型アサーションをしていました。)そのためにアプリケーション独自のエラーを定義しておきましょう。
error は以下のように定義できるでしょう。struct に保持するフィールドは必要に応じて増えていきます。例えばスタックトレースの情報など。今回は単なる error のフィールドを埋め込んでおくだけにします。error インターフェースを埋め込むことでエラーの構造体を生成できます。AppError インターフェースは error インターフェースを満たしています。
type AppError interface { error Code() string } type XError struct { Err error } func (e *XError) Error() string { msg := "x error" if e.Err != nil { return msg + ": " + e.Error() } return msg } func (e *XError) Unwrap() error { return e.Err } func (e *XError) Code() string { return "XXX" } type YError struct { Err error } func (e *YError) Error() string { msg := "y error" if e.Err != nil { return msg + ": " + e.Error() } return msg } func (e *YError) Unwrap() error { return e.Err } func (e *YError) Code() string { return "YYY" }
Create メソッドからはアプリケーション独自のエラー型にラップして、呼び出し元にエラーを返却します。
func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) error { err := Hoge() if err != nil { return &XError{err} } err := Foo() if err != nil { return &XError{err} } err := Hoo() if err != nil { return &YError{err} } return nil }
そうすると呼び出し元のハンドラでは err 変数について errors.As でエラーの型を検査し、呼び出し元からのエラー型に応じた処理ができます。
func (u *UserHandle) CreateHandler(w http.ResponseWriter, r *http.Request) { err := u.Create(w, r) if err != nil { writeLog() var xe *XError if errors.As(err, &xe) { http.Error(w, fmt.Sprintf("...: %w", err) , 400) return } var ye *YError if errors.As(err, &ye) { http.Error(w, fmt.Sprintf("...: %w", err) , 500) return } // application unknown error http.Error(w, fmt.Sprintf("...: %w", err) , 500) return } }
サマリ
- エラーを扱うハンドラを用いることで、ハンドラからエラーを返すことができる
- 呼び出し元となるハンドラではエラーを errors.As でチェックすることで、エラーに応じた処理ができる
このようにラップするハンドラを用意するくらいであれば WAF を用いたほうが良いのでは、という声もあると思います。ここでは紹介しませんでしたが、プロジェクトに応じて WAF を導入していることも多いと思います。その場合は WAF の慣習に従うのが良いでしょう。このようなエラーハンドリング方法も役に立つかもしれません。