概要
GolangでServiceを作る中で感じたunittestおよびClean Architectureという壁についてまとめてみた。
Golangにおけるテストとモック
『Unittestのために、モジュール間参照はインターフェースを使わなければならない』
Golangでunittestを書こうと思ったとき、困るのがモックだ。
例えばJavaであれば、どんなClassもそれを継承したClassを(無理やり)作る事ができるので、対象のコードの中の依存オブジェクトをモックに差し替える事ができる。
例えばPythonやJavaScriptであれば、そもそも型がないので自由にモックに差し替える事ができる。
ところが、Golangでは、
type Sample struct{}
var hoge = &Sample{}
としてしまうと、hogeにはSample構造体以外のものに差し替える事ができなくなる。構造体への参照には多態性はないのだ!
このことは、あるモジュールをテストするとき、そのモジュールが他のモジュールを構造体直参照していると「モックに差し替える事ができない」事を意味する。
だから、我々はこう書かなければならない。
type Sample interface {
hoge()
}
type sample{} struct
func (s *sample) hoge() {
// hoge
}
var hoge Sample = &sample{}
このように参照を構造体ではなくインターフェースを通した参照にしなければならない。
これがまずGolangでプログラムを書く際のきまりごとである。
Dependency Injection by Constructor
モジュールが別のモジュールを参照しているとき、モジュール間連携は必ずConstructによってDIしよう。
type A interface {
Hoge() string
}
type a struct {
}
func (*a) Hoge() string {
return "hoge"
}
type Sample struct {
a A
}
func NewSample(a A) *Sample {
return &Sample{a}
}
こうすれば、テストコード内でNewSample関数によって自由にmock_aを差し込んだSampleを作成できる。
def main(){
a := &mockA{}
sut := NewSample(a)
}
そして、production codeでは、一番外側のmain.goの中でDIしよう。
DIコンテナが欲しくなる時もあるが、それは別の話で
関数をモックと差し換えるOptionを用意する
例えばメソッドの中でtime.New()
していたりuuid.NewV4()
していたりするコードは多いと思う。これをテストでモックしようと思うと、monkey.Patch
する必要が生じる。だがmonkey.Patch
はどうも動作が不安定な気がしている。これは例えば
type Sample struct {
uuid : func() (uuid.UUID, error)
now : func() time.Time
}
func NewSample(opts ...SampleOption) *Sample {
s := &Sample{
uuid: uuid.NewV4
now: time.Now
}
for _, f := range opts {
f(s)
}
return s
}
type SampleOption func(*Sample)
func WithUUID(uuid : func() (uuid.UUID, error)) SampleOption {
return func(s *Sample){
s.uuid = uuid
}
}
func WithNow(now : func() time.Time) SampleOption {
return func(s *Sample){
s.now = now
}
}
func (s *Sample) Hoge() Fuga {
now := s.now()
id, err := s.uuid()
}
こんな風にして、、テストするときは
s := NewSample(
WithUUID(func() {
return uuid.FromString("aaaa-aaaaa-...aaaaaa-aaaa")
}),
WithNow(func() {
return time.Date(2000, 1, 1)
}),
)
これで、モジュールの依存先がstructであろうがfuncであろうが、コンストラクタを通してモックと差し換える事ができる。
Cyclic importとディレクトリ分割
Golangを書いていると、頻繁にcyclic import(循環参照)が発生する。
最もよく見るのが以下のようなパターン。
interfaceがimplを参照している
// interface
type Sample interface {
hoge(options ...Option)
}
// impl
type sample struct{}
func (s *sample) hoge(option Option){
// hoge
}
type Option func(s Sample)
例えばこんな風に書いてしまうと
interface ==> impl ==> interface
と循環参照になる。つまり
『implは必ずinterfaceを参照するのだから、interfaceはimplを参照してはならない。interfaceに登場するoptionやenumのような値は、interfaceで定義しなければならない』
cyclic import問題から、以下のことが言える(まあこういう言語は珍しくないのでGolang独自とは言えないかもしれないが)
『ディレクトリ内のファイル間では依存性問題は発生しない。ディレクトリを分割するなら、あらかじめディレクトリ間の依存の方向性を決定しておかなければならない』
言わずもがなと思うが、あるディレクトリ下に配置されるファイル間では、インポート問題は発生しないので、cyclic import問題は、プロジェクトを複数のディレクトリに分割しようとしたときにはじめて発生する。
そして、各ディレクトリを定義して、それぞれのディレクトリ間の参照の方向性を事前に決定しなければならない、ということがcyclic import問題から「Golangにおける開発の前提」であるという事が言える。
Clean Architecture in my opinion
以上を見ると、Clean Architectureというのは実はGolang開発の前提条件+αに見えないだろうか?
Clean Architectureについて解説はしないが、
同心円の絵は、結局cyclic importを避けるためにディレクトリ間の依存の方向をコントロールするための提案である。これらの名称であったり、それぞれのモジュールが必要という事ではない。必要ないならhandlerだけでもいいし、handler ==> repository参照していても構わない。
右下の絵はモジュール間連携は必ずinterfaceを経由しようと言っているに過ぎない。Goland開発では当たり前のことだ。
結論
Golangの言語仕様は特殊のため、開発するうえで制限がある。その制限と戦うためには、どうしてもClean Architectureのような概念が必要なのだろうと思う。
Sprint bootやDjangoのようなフレームワークを使えば、フレームワークがディレクトリ構成を規定してくるが、Golang開発ではそういう規定はないっぽい
主要なライブラリ(aws-sdk-goも含めて)には、たいていインターフェースが含まれている事が多いと感じる。使用したい外部ライブラリがあったら、直接参照せずに済まないかインターフェースを探してみよう。