技術メモ

技術メモ

ラフなメモ

GoでHTTPサーバのログを出力する方法

Go で 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)
}

上記のサイトでは、以下のように独自の構造体の loggingResponseWriterWriteHeader を実装することで 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を用いるとポインタ演算されてしまう
  • インターフェースを介した処理
    • どう代入されるかわからない
  • リフレクションを介した動作
    • 動的に決まるので難しい

パッケージの全体像

image

ソースコードを堪能せよ 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 型に含まれる XY フィールドもまた Value インターフェースであるため、定数かどうかは *Const 型であるかどうか型アサーションをすることになる。このあたりは割と愚直な印象である。

Go製のマルチプラットフォームなクリップボードライブラリを作った

はじめに

Go でマルチプラットフォームで動く CLI を作っていて、その過程でクリップボードにコピーしたり、ペーストできるようなライブラリがほしくなった。

マルチプラットフォームで動く Go のクリップボードライブラリを探してみると、ほとんど出てこなくて、たぶん以下のみ

https://github.com/atotto/clipboard

これも Windows は WindowsAPI を使って外部パッケージに依存せずに動作できるようになっているが、LinuxmacOS については xclip や pbcopy などの外部パッケージのコマンドを叩くだけのラッパーになっている。

WSL の ubuntu で動かしてみたのだが動作せずに exit status 1 などと cmd.Exec の外部コマンドを実行したときのエラーのみが返ってきて、困っていた。LinuxmacOSクリップボードについても、外部パッケージを使うのではなく、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

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

が、以下の不思議なサイト1Objective-C を cgo でラップしている呼び出している面白い実装があった。

https://git.wow.st/gmp/clip

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

WindowsmacOS の仕組みとは異なり、Linuxカーネルそのものにはクリップボードの仕組みは提供されておらず、X11 に依存する。また Wayland (私は初めて知ったが)というディスプレイサーバプロトコルクリップボードを提供できるよう。

Linux だと xclipxsel というツールがよく使われるようで、実際 atotto/clipboard でも xclip, xsel のコマンドを叩くようになっている。

いずれもは C 言語で書かれていて、コマンドラインクリップボードのコピーとペーストを提供している。コードは 1000 行くらい。コードの読みやすさとしては xsel のほうが読みやすかった。

X11 では selection という概念があって、Selection を使ってウィンドウとデータを交換することができる。

クリップボードでは PRIMARY と CLIPBOARD と SECONDARY という selection を使うことができる

領域 説明
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] や裏技的にシステムコールを発行することはできる。

Goでデーモンを実装する

今回は sevlyar/go-daemon を用いてプロセスを生成した。単一のアプリケーションとして動作させる場合はうまく動作した。しかしライブラリとして組み込まれる場合は機能しなかった。これは os.Args[0] で外部プロセスを起動する仕組みであるために、他のリポジトリに組み込まれることは想定しておらず、組み込まれた場合は機能しない。VividCortex/godaemonsyscall.RawSyscall(syscall.SYS_FORK, 0, 0, 0) を実行する方法も試したが、結局思った動作は得られなかった。

  • イベントでプロセス終了

SelectionClear のイベントを受け取ったタイミングでプロセスが終了する実装は簡単である。channel と goroutine を組み合わせて、SelectionClear のイベントを受け取ったタイミングで doneCh <- struct{}{} などとし、待っている側のゴルーチンは <- doneCh としておけばよい。

まとめ

気軽にやってみよう、と取り組んでみたが xlib や Windows API など普段の業務よりも低レベルなプログラミングが要求されて、力不足を感じた。ないものは作る精神なので、低レベルなアプリも実装できる技術力を身につけていきたい。


  1. Go 製の GitHub 風のバージョン管理システムを用いている:

orelangをGoで実装した

以下の記事を読んで私も orelang を実装してみました。実装は記事に沿った形にしています。

qiita.com

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 を使っています。

  • テスト

テストではカバレッジも取得するようにしています。またテスト環境としては macOSubuntu で回しています。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 の慣習に従うのが良いでしょう。このようなエラーハンドリング方法も役に立つかもしれません。