ทำ Dependency Injection ใน Go ด้วย Wire

Chonlasith Jucksriporn
odds.team
Published in
6 min readMar 22, 2024

ในภาษา Go มี package ที่ช่วยทำ dependency injection หรือ DI อยู่หลายตัว ที่เคยใช้มาก่อนหน้านี้ก็จะเป็น container (https://github.com/golobby/container) จริง ๆ แล้วเคยดูตัวที่เป็น dig ของ uber กับ wire ของ google แต่อ่านแล้วงง เลยพักเอาไว้ แล้วไปหาต่อจนเจอ container ทีนี้ได้มีโอกาสกลับมาลองอ่านทำความเข้าใจกับ wire ของ google อีกครั้ง เลยมาเขียนเป็น blog ไว้หน่อยดีกว่า

ก่อนจะไปดูการทำ DI เรามาลองเริ่มต้นจากอะไรง่าย ๆ ที่ไม่ได้ทำ DI ก่อน เช่น project calculator (https://github.com/chonla/go-calculator-non-di-demo) ที่มีหน้าตาประมาณนี้

// main.go

package main

import (
"calculator/calculator"
"fmt"
)

func main() {
calc := calculator.NewCalculator()

fmt.Println(calc.Add(4, 8))
}
// calculator/calculator.go

package calculator

type Calculator struct {
adder Adder
}

func NewCalculator() Calculator {
return Calculator{
adder: NewAdder(),
}
}

func (c Calculator) Add(x, y int) int {
return c.adder.Add(x, y)
}
// calculator/adder

package calculator

type Adder struct{}

func NewAdder() Adder {
return Adder{}
}

func (a Adder) Add(x, y int) int {
return x + y
}

จะเห็นว่า วิธีตามด้านบน ตอนเรียก NewCalculator() จะสร้าง adder โดยการเรียก NewAdder() จากด้านใน function ซึ่งสถานการณ์นี้เราเรียกว่า Tight coupling ปัญหาคือ มันทำให้เวลามีการเปลี่ยนแปลง dependency ตัวหนึ่ง อาจจะส่งผลกระทบถึงอีกตัวหนึ่งด้วย ตอนแก้ไข dependency รวมไปถึง การเขียน test ตัว Calculator ก็ทำได้ยากขึ้นด้วย เพราะมันเรียกใช้ Adder จริงอยู่ตลอดเวลา

ดังนั้นวิธีแก้ไข เราก็จะปรับหน้าตาโค้ดนิดหน่อยเพื่อให้มันไม่ผูกติดกันมากเกินไป (Loose coupling) เพื่อทำให้เราสามารถ inject Adder เข้ามาตอนสร้าง Calculator ได้ หน้าตาโค้ดใหม่ก็จะเป็นแบบนี้ (https://github.com/chonla/go-calculator-di-demo)

// main.go

package main

import (
"calculator/calculator"
"fmt"
)

func main() {
calc := InitializeCalculator()

fmt.Println(calc.Add(4, 8))
}

func InitializeCalculator() calculator.Calculator {
adder := calculator.NewAdder()
calc := calculator.NewCalculator(adder)

return calc
}
// calculator/calculator.go

package calculator

type Calculator struct {
adder Adder
}

func NewCalculator(adder Adder) Calculator {
return Calculator{
adder,
}
}

func (c Calculator) Add(x, y int) int {
return c.adder.Add(x, y)
}
// calculator/adder.go

package calculator

type Adder struct{}

func NewAdder() Adder {
return Adder{}
}

func (a Adder) Add(x, y int) int {
return x + y
}

จะเห็นว่า โค้ดใหม่จะใช้วิธี inject ผ่าน constructor ซึ่งก็คือ NewCalculator นั่นเอง หลังจากเราปรับโค้ดใหม่ให้เป็น constructor injection แล้ว เราก็พร้อมที่จะไปลอง wire แล้ว

สิ่งที่ควรรู้จักก่อนจะใช้ wire มี 2 เรื่องคือ provider กับ injector

Provider

ทำหน้าที่สร้าง dependency ที่เราต้องการ ซึ่งเขียนเป็น function ธรรมดานี่แหละ จากโค้ดก่อนหน้า NewCalculator กับ NewAdder นี่แหละ ที่ทำหน้าที่เป็น Provider

Injector

ทำหน้าที่ร้อย provider function เข้าด้วยกัน เป็น function ธรรมดาเหมือนกัน จากโค้ดก่อนหน้า InitializeCalculator จะทำหน้าที่เป็น Injector นั่นเอง

Wire

wire ไม่ใช่ library ที่ทำ DI แต่ wire เป็นเครื่องมือประเภท Code generator ที่ช่วยในการทำ dependency injection บนภาษา Go แบบที่เราพยายามทำก่อนหน้าให้ง่ายขึ้น โดย wire จะมี package เอาไว้ให้เรากำหนด provider และ injector ด้วย wire package และ จะมี wire cli ในการแปลง injector ที่กำหนดผ่าน wire package ให้กลายเป็น injector อย่างที่ควรจะเป็นตอน compile time

wire ใช้ความสามารถของ build tag ของ go ในการเลือกว่าจะ include file ไหนบ้างในการ run หรือ build ทำให้เราสามารถกำหนด injector ได้ตามที่เราต้องการว่าตอนไหนจะ inject dependency ที่ทำงานจริง ๆ หรือตอนไหนจะ inject mock เข้ามาแทน

ติดตั้ง wire

ด้วยคำสั่งตามนี้

go install github.com/google/wire/cmd/wire@latest

หลังจากติดตั้งเสร็จ เราจะสามารถใช้ wire cli ได้

Provider และ Injector ใน wire

Provider

เราสามารถใช้ provider เดิมได้เลย ไม่ต้องแก้อะไร

Provider Set

เราสามารถ group provider เข้าด้วยกันได้ด้วยคำสั่ง wire.NewSet(provider1, provider2, ...) เช่น

// ...
providerSet := wire.NewSet(NewCalculator, NewAdder)
// ...

ซึ่งเรายังสามารถสร้าง set ของ provider ที่เป็น set ของ provider set ได้อีกด้วย เช่น

// ...
operatorProviderSet := wire.NewSet(NewAdder, NewSubtractor, NewMultiplier, NewDivider)
providerSet := wire.NewSet(NewCalculator, operatorProviderSet)
// ...

Injector

จะทำผ่านคำสั่ง wire.Build(provider1, provider2, ...) เช่น

func InitializeCalculator() Calculator {
wire.Build(NewCalculator, NewAdder)
return Calculator{}
}

ตรงนี้มีข้อสังเกตสองอย่างคือ

  1. ตอน return ใน injector เราสามารถ return ค่าอะไรก็ได้ ขอแค่ให้มี type ตรงกันกับที่ประกาศไว้ใน injector เท่านั้น ตัว wire มันจะไม่สนค่าที่ return อยู่แล้ว เราใส่ return ไว้เพื่อให้ compiler มันมองว่าไม่เป็น error เท่านั้น ดังนั้นถ้า injector return *Calculator เราสามารถ return nil ได้เช่นเดียวกัน
  2. ถ้าใน injector มีการพยายามสร้าง provider set ตอนที่เรารัน มันจะบ่นประมาณว่า มันเจอว่าใน injector ต้องมีแค่ wire.Build และ return เท่านั้น

ทีนี้เรามาลองดู Calculator แบบใช้ wire กัน (https://github.com/chonla/go-calculator-wire-demo) จะสังเกตได้ว่า เราแยก InitializeCalculator ออกมาไว้ในไฟล์ wire.go ใน package main เหมือนกัน

// main.go

package main

import (
"fmt"
)

func main() {
calc := InitializeCalculator()

fmt.Println(calc.Add(4, 8))
}
// wire.go

package main

import (
"calculator/calculator"

"github.com/google/wire"
)

func InitializeCalculator() calculator.Calculator {
wire.Build(calculator.NewCalculator, calculator.NewAdder)

return calculator.Calculator{}
}
// calculator/calculator.go

package calculator

type Calculator struct {
adder Adder
}

func NewCalculator(adder Adder) Calculator {
return Calculator{
adder,
}
}

func (c Calculator) Add(x, y int) int {
return c.adder.Add(x, y)
}
// calculator/adder.go

package calculator

type Adder struct{}

func NewAdder() Adder {
return Adder{}
}

func (a Adder) Add(x, y int) int {
return x + y
}

จากโค้ดข้างบน เราจะเห็นว่า provider ไม่ได้มีเปลี่ยนอะไร ส่วน injector จะถูกแยกออกมาไว้ในไฟล์ wire.go เพื่อเตรียมให้ wire มาอ่าน และเรามีการกำหนด definition ของ provider ที่เราใช้ผ่าน wire.Build

เมื่อเราทำทุกอย่างพร้อมแล้ว เราก็ไปที่ terminal แล้วสั่งคำสั่ง

wire .

เราจะได้ผลลัพธ์ออกมาประมาณนี้

wire จะทำการอ่านไฟล์ของเราทั้งหมด ถ้าเจอไฟล์ไหนที่มีการกำหนด injector ไว้ wire จะทำการแปลงให้อยู่ในรูป DI แบบที่เราทำไว้ก่อนหน้านี้ ถ้าเราเปิดดูไฟล์ wire_gen.go เราจะเห็นหน้าตาประมาณนี้

// Code generated by Wire. DO NOT EDIT.

//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package main

import (
"calculator/calculator"
)

// Injectors from wire.go:

func InitializeCalculator() calculator.Calculator {
adder := calculator.NewAdder()
calculatorCalculator := calculator.NewCalculator(adder)
return calculatorCalculator
}

เราจะเห็นว่าหน้าตาดูคล้ายคลึงกับอันที่เราทำไว้ก่อนหน้านี้แบบที่ไม่ได้ใช้ wire

เสร็จแล้ว เราลองสั่ง run ด้วยคำสั่ง

go run .

เราจะเจอ error แบบนี้

เพราะว่า go มันเจอว่ามี function InitializeCalculator ซ้ำสองตัว คือตัวนึงอยู่ในไฟล์ wire.go อีกตัวอยู่ในไฟล์ wire_gen.go วิธีแก้ไขคือให้เราใส่ build tag // +build wireinjectไว้ที่บรรทัดแรกของไฟล์ wire.go แบบด้านล่างนี้ แล้วลองสั่ง run ใหม่

// +build wireinject

package main

import (
"calculator/calculator"

"github.com/google/wire"
)

func InitializeCalculator() calculator.Calculator {
wire.Build(calculator.NewCalculator, calculator.NewAdder)

return calculator.Calculator{}
}

เราก็จะได้ผลลัพธ์อย่างที่ควรจะเป็น

Mock

มาถึงส่วนสำคัญอีกส่วนคือตอนที่เราเขียน Test และต้องการใช้ mock ในที่นี้ผมเขียน unit test ด้วย testify ดังนั้นเลยต้องปรับโค้ดเดิมนิดหน่อย โดยการใช้ interface ของ Adder ในการ inject Adder เข้าไปตอน NewCalculator แทนที่จะ inject Adder เข้าไปตรง ๆ ส่วนที่เปลี่ยนไปก็จะประมาณนี้

// calculator/calculator.go

package calculator

type Calculator struct {
adder GeneralAdder
}

func NewCalculator(adder GeneralAdder) Calculator {
return Calculator{
adder,
}
}

func (c Calculator) Add(x, y int) int {
return c.adder.Add(x, y)
}
// calculator/adder.go

package calculator

import "github.com/google/wire"

type GeneralAdder interface {
Add(x, y int) int
}

type Adder struct{}

func NewAdder() *Adder {
return &Adder{}
}

func (a *Adder) Add(x, y int) int {
return x + y
}

หลังจากปรับให้ Calculator รับ Adder เข้ามาเป็น interface แล้ว การ provide ของให้ wire จะเปลี่ยนไป เราจะต้องทำการบอก wire ให้รู้จักว่า ถ้าจะใช้ object จาก interface GeneralAdder ให้ไปสร้างเอาจาก Adder นะ ด้วยคำสั่ง

// calculator/adder.go

// ...
var AdderProvider = wire.NewSet(
NewAdder,
wire.Bind(new(GeneralAdder), new(*Adder)),
)

ผลที่ได้ calculator/adder.go จะมีหน้าตาแบบนี้

// calculator/adder.go

package calculator

import "github.com/google/wire"

type GeneralAdder interface {
Add(x, y int) int
}

type Adder struct{}

func NewAdder() *Adder {
return &Adder{}
}

func (a *Adder) Add(x, y int) int {
return x + y
}

var AdderProvider = wire.NewSet(
NewAdder,
wire.Bind(new(GeneralAdder), new(*Adder)),
)

เสร็จแล้ว ใน injector ใน wire.go ที่ทำไว้ก่อนหน้านี้ ก็แค่เอา AdderProvider ไปใช้แบบนี้

//go:build wireinject
// +build wireinject

package main

import (
"calculator/calculator"

"github.com/google/wire"
)

func InitializeCalculator() calculator.Calculator {
wire.Build(
calculator.AdderProvider,
calculator.NewCalculator,
)

return calculator.Calculator{}
}

เมื่อลองรัน go run . อีกครั้งจะยังได้ผลลัพธ์เหมือนเดิม ตอนนี้ app เราก็พร้อมที่จะเอา mock มาใช้ใน test ของเราแล้ว

เราก็จะเขียน test ได้ประมาณนี้ (https://github.com/chonla/go-calculator-wire-with-mock-demo)

// calculator/calculator_test.go

package calculator_test

import (
"calculator/calculator"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

type MockAdder struct {
mock.Mock
}

func (m *MockAdder) Add(x, y int) int {
args := m.Called(x, y)
return args.Int(0)
}

func TestCalculatorAdderShouldCallAdderAdd(t *testing.T) {
mockAdder := new(MockAdder)

mockAdder.On("Add", 3, 6).Return(500)

calc := calculator.NewCalculator(mockAdder)
result := calc.Add(3, 6)

mockAdder.AssertExpectations(t)
assert.Equal(t, 500, result)
}
// calculator/adder_test.go

package calculator_test

import (
"calculator/calculator"
"testing"

"github.com/stretchr/testify/assert"
)

func TestAddShouldSumValueOfParameters(t *testing.T) {
adder := calculator.NewAdder()

result := adder.Add(1, 2)

assert.Equal(t, 3, result)
}

แล้วลองรัน

น่าจะครบแล้วล่ะ

ส่งท้าย

เท่าที่ไปอ่านใน FAQ มา เจอเรื่องที่น่าสนใจคือ wire เหมาะกับ application ที่ใหญ่ หรือซับซ้อน wire จะช่วยให้การทำ DI สะดวกขึ้นมาก ส่วน application ขนาดเล็ก หรือไม่ซับซ้อน ทำ DI แบบ manual จะง่ายกว่า

อ้างอิง

--

--