요즘 새로 투입될 업무 때문에
규격 분석만 하고 코딩할 틈이 없어
토이 프로젝트를 시작했습니다
프로젝트에서 golang은 DI(Dependency Injection)를
어떻게 쓰는 게 좋은 방법일지 고민해보았습니다
사내에선 golang에서 Google Guice같이 DI를 사용할 수 있는
아래의 소스를 이용하고 있었습니다
사내 공통모듈에서 해당 소스를 import하고 wrapping해 DI를 하고 있었고
호환성을 맞추기 위해 저도 그대로 사용해왔었습니다
이 포스팅 중에 우리 회사는 왜 이걸 선택했는지,
golang에서는 어떻게 DI를 적용하는게 좋을지
DI의 개념과 함께 정리해보았습니다
1.Dependency Injection
Dependency(의존성) + Injection(주입)
이걸 모를때도 코딩을 했습니다
왜 사용할까요?
여러 가지 이유가 있지만 가장 큰 건
Loose Coupling(느슨한 결합) 때문입니다
Loose Coupling
왜 필요할까?
예를 들어
Owner 객체가 Dog 객체를 내부 변수로 가지고 있는 객체이면
Dog의 내용에 따라 Owner의 구현이 크게 변합니다
type Dog struct {
name string
age int
}
type Owner struct {
dog Dog
}
즉, Owner는 Dog에 의존성을 갖게 되는 것이고
Owner가 Dog 객체의 값이나 기능을 많이 사용할수록 높은 의존성을 가지는 것이죠
여기서 문제가 생깁니다
Dog가 Cat이나 Fish가 되면 구현에 따라 Owner의 전체 구현을 변경해야 할 수도 있죠
Dog를 수정하면 Dog에 의존하는 Owner도 많은 부분에 영향을 미치는 것이죠(의존성이 클수록)
Dog와 Owner 두 가지일 땐 간단해 보이지만
Owner, Dog, Apartment, Music, ... 많은 객체들이 서로 의존성이 있으면
코드의 유지보수에 큰 악영향을 미칩니다
이를 위해 개발자들은
서로 연관된 A, B 컴포넌트에서
A 컴포넌트의 변경이 B 컴포넌트의 변경에 미치는 영향을
최소화하기 위해 결합(Coupling)을 느슨하게(Loose) 하려 노력합니다
그럼 코드를 유지 보수하기 수월해지겠죠
(가독성, 유연성 up, 수정 비용 down)
DI는 어떻게 Loose Coupling을?
의존성을 줄이는 방법은 여러 가지가 있습니다
그중에 DI(Dependency Injection)가 있는 것이죠
위의 코드에서 Owner는 Dog에 의존성을 가진다 했습니다
그럼 의존성 '주입'은 어떻게 하는 것인가!
주입(Injection)은 외부에서 객체를 생성해서 넣어주는 것입니다
(껍데기에 주사기로 내용물을 채워준다고 생각해봅시다)
즉, 비어있는 Owner를 따로 생성하고 이후에 Dog를 Owner에 넣어주면 되는 것이죠
type Pet interface {
eat()
sleep()
}
type Dog struct {
name string
age int
}
func (d *Dog)eat() {}
func (d *Dog)sleep() {}
type Owner struct {
pet Pet
}
Owner가 비어있는 상태로 생성이 되도록
Dog 대신 Pet interface를 갖도록 변경했습니다
이후에 Pet에 Dog나 Cat이나 Fish 등을 넣어주더라도
Owner의 코드를 바꿀 필요가 없게 되는 것이죠
단순히 DI의 원리를 설명하자면 이렇지만
단순히 interface 방식으로 구현하는 것을 DI라고 하지는 않습니다
실제 DI는 어떻게 동작?
위처럼 기존의 의존성은 코드 구현 내부에서 정해졌지만
프레임워크, 코드 외부의 설정 파일 등을 통해 의존성을 제어할 수 있게 되는 것을
IoC(Inversion of Control)이라고 합니다
IoC는 '제어의 역전'이라고 불리며 이를 달성할 수 있어야 DI입니다
(코드 내부에서 정했던 의존성을 밖에서 제어하니 역전되었다 하는 것이죠!)
예를 들어
ServiceA, ServiceB를 가진 Client를 만들기 위해선
일반적으론 아래와 같은 순서를 거칩니다
ServiceA 생성, Service B 생성 -> ServiceA, ServiceB를 이용해 Client 생성
DI를 이용하면 순서가 바뀝니다
비어있는 Client 생성 -> ServiceA, Service B 생성 -> Client에 ServiceA, ServiceB 주입
여기서 주입을 프레임워크(injector)를 통해서 수행하는 것이죠
Injector가 내용물을 껍데기에 주입을 하니 IoC가 달성됩니다
아래 그림처럼 말이죠
즉, 이전의 Dog와 Owner 코드는 Pet과 Owner 코드로 변환하여
interface를 통한 Loose coupling을 달성해놓고
Pet에 들어갈 구현이 Dog인지 Cat인지 Fish인지는
injector를 통해 나중에 결정한다 생각하면 됩니다
그렇다면 injector는 이미 만들어진 객체에
값을 어떻게 주입을 할까요?
Pet에 구현을 주입하는 방법은 세 가지가 있습니다
1. Constructor Injection
- 생성자를 통한 전달
- Owner 생성자의 인자로 주입
- 프레임워크가 NewOwner를 실행할 때 설정한 Cat을 인자로 넣어 실행
type Owner struct {
pet Pet
}
func NewOwner(pet Pet) *Owner {
owner := Owner{pet}
return &owner
}
2. Method(Setter) Injection
- setter()를 통한 전달
- Owner를 생성하고 setter의 인자로 주입
type Owner struct {
pet Pet
}
func NewOwner() *Owner {
return &Onwer{}
}
func (o *Owner) SetPet(p Pet) {
o.pet = p
}
3. Interface Injection
- 인터페이스를 통한 전달
- 함수를 포함한 인터페이스를 작성하고 해당 인터페이스를 구현하고 프레임워크에 의해 runtime에 구현을 주입
type Pet interface {
eat()
sleep()
}
type Dog struct {
name string
age int
}
func (d *Dog)eat() {}
func (d *Dog)sleep() {}
type Cat struct {
name stirng
age int
}
func (d *Cat)eat() {}
func (d *Dog)sleep() {}
type Owner struct {
pet Pet
}
그렇다면 프레임워크는 어떻게 구현(provider)을 정해서 주입할까요?
보통 xml, annotation 등의 설정 파일을 이용해 선언합니다
'난 Owner의 Pet에 Cat을 주입할 거야'
이러한 파일을 IoC Container라고 합니다
이는 더 뒤에서 실제 사용 예제에서 살펴봅시다
DI의 장점
- 의존 관계 설정이 컴파일 시가 아닌 실행 시에 이루어짐 -> 결합도 down
- 컴포넌트 간의 의존성을 파악하기 쉬움 -> 가독성 up
- 코드의 수정 없이 모듈을 여러 곳에서 재사용 가능 -> 재사용성 up
- 구현을 감추고 사용하는 부분만 노출 -> 가독성, 협업 효율 up
- 구현을 쉽게 변경할 수 있음. 예) DB 변경 : MySQL 구현 -> MongoDB 구현으로 변경
- mockup을 이용한 단위 테스트 등에 용이함 -> 편의성 up
2.Golang DI
대표적인 DI는 JAVA의 스프링 프레임워크와 구글 Guice가 있습니다
여기선 golang에서 DI를 하는 방법을 알아보도록 합니다
도입부에 말했듯이 사내에서는
Guice의 방식으로 DI를 할 수 있는 라이브러리를 사용하였습니다
기존에 사내에서는 Guice를 사용하고 있었고,
해당 형식에 익숙한 개발자들이
빠른 개발 투입을 위해 golang에서 Guice 방식을
사용할 수 있도록 하였다고 하네요
(러닝 커브를 줄이는 것이 가장 큰 목적)
하지만 실제로 golang에서 널리 알려진 DI는 따로 있었으니
지금부터 살펴보도록 하겠습니다
golang에서 가장 널리 사용되는 DI는 아래의 세 가지인데
- Google의 Wire
- Facebook의 Facebookgo
- Uber의 Dig
이들은 두 가지로 분류할 수 있어요
1. Compile time DI : Google의 Wire
2. Runtime DI(go의 reflection을 이용해 구현) : Facebook의 Inject, Uber의 Dig
뭐가 어떤지 알려면 역시 맛을 봐야겠죠
단순한 example까지 살펴보도록 하겠습니다
Wire
이는 특이하게 Compile Time에 DI를 수행합니다
(분명 앞의 설명에서는 런타임에 주입하는 것을 DI라고 한다고 정리했는데 말이지..)
Wire는 Dig나 Facebook Inject가 존재하는 상태에서 만들어졌고
Java Dagger 2 에 영감을 받았다고 합니다
실제로 현재 많이 사용되고 있고
google에서 만든 golang 라이브러리로 go 적합성에 믿음이 가는 편입니다
Compile Inector인 Wire의 장점
- 런타임 DI는 dependency 그래프가 복잡해질수록 디버그 하기 어려움
- DI가 컴파일되는 코드로 동작하는 것은 더 이해하기 쉽게 하고, 디버그 하기에도 유리함
- 종속성 오류 등이 컴파일 오류로 나타나므로 런타임에서 발생하는 문제보다 해결하기 쉬움
- Service locator 패턴처럼 서비스 등록을 위해 이름이나 키를 지정할 필요 없음
- Wire는 단지 go type과 컴포넌트를 연결한다
- 필요한 dependency만 import하여 불필요한 펌핑 피하기 유리함
- runtime에 사용되지 않을 dependency 체크
- Wire의 depndency 그래프는 정적이므로 도구화나 시각화하기 유리함
Wire의 동작
Wire는 provider, injector로 나뉨
- provider
- 주어진 dependency 값을 제공하는 go func
// NewUserStore is the same function we saw above; it is a provider for UserStore, // with dependencies on *Config and *mysql.DB. func NewUserStore(cfg *Config, db *mysql.DB) (*UserStore, error) {...} // NewDefaultConfig is a provider for *Config, with no dependencies. func NewDefaultConfig() *Config {...} // NewDB is a provider for *mysql.DB based on some connection info. func NewDB(info *ConnectionInfo) (*mysql.DB, error) {...}
- 즉, injection 되어질 값
- ProviderSet으로 묶을 수 있음
- 예를 들어 NewUserStore를 생성할 때,
default config를 사용하는 것이 보통이므로 아래와 같이 묶음
var UserStoreSet = wire.ProviderSet(NewUserStore, NewDefaultConfig)
- 예를 들어 NewUserStore를 생성할 때,
- 주어진 dependency 값을 제공하는 go func
- injector
- depndency inject를 위해 provider를 호출하는 함수
func initUserStore(info ConnectionInfo) (*UserStore, error) { wire.Build(UserStoreSet, NewDB) return nil, nil // These return values are ignored. }
- UserStore를 생성하기 위해 필요한 Dependency를 Inject합니다
- Dependency(ConnectionInfo와 NewDB, UserStoreSet)
- 위와 같이 wire.go 파일을 작성하고 Wire 명령을 수행하면
아래와 같은 injector가 generate됩니다
// File: wire_gen.go // Code generated by Wire. DO NOT EDIT. //go:generate wire //+build !wireinject func initUserStore(info ConnectionInfo) (*UserStore, error) { defaultConfig := NewDefaultConfig() db, err := NewDB(info) if err != nil { return nil, err } userStore, err := NewUserStore(defaultConfig, db) if err != nil { return nil, err } return userStore, nil }
- depndency inject를 위해 provider를 호출하는 함수
위의 예시를 종합적으로 다시 정리해봅니다
DI를 통해 Provider에 필요한 Dependency를 Injection 해야 합니다
아래 코드는 Provider들을 정의합니다
// UserStore Provider, Dependency : Config와 mysql.DB
func NewUserStore(cfg *Config, db *mysql.DB) (*UserStore, error) {...}
// Config Provider, No Dependency
func NewDefaultConfig() *Config {...}
// mysql.DB Provider, Dependency : ConnectionInfo
func NewDB(info *ConnectionInfo) (*mysql.DB, error) {...}
// NewUserStore의 Config Dependency에 기본적으로 NewDefaultConfig Provider를 사용
var UserStoreSet = wire.ProviderSet(NewUserStore, NewDefaultConfig)
아래 코드가 DI를 명시하는 코드(building block)입니다
// File : wire.go
//+build wireinject
...
// UserStore를 만들기 위해 ConnectionInfo를 인자로 받고 NewDB와 UserStoreSet도 같이 사용하자
func initUserStore(info ConnectionInfo) (*UserStore, error) {
wire.Build(UserStoreSet, NewDB)
return nil, nil // These return values are ignored.
}
위의 코드에 wire 명령을 수행하면 아래와 같은 코드가 생성됩니다
빌드 시엔 기존의 wire.go 파일은 //+build wireinject 주석에 의해 무시되고,
아래의 wire_gen.go 파일을 이용합니다
// File: wire_gen.go
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
func initUserStore(info ConnectionInfo) (*UserStore, error) {
defaultConfig := NewDefaultConfig()
db, err := NewDB(info)
if err != nil {
return nil, err
}
userStore, err := NewUserStore(defaultConfig, db)
if err != nil {
return nil, err
}
return userStore, nil
}
wire 명령은 간단합니다
- wire {wire.go 파일}
사용 예시
(tool로 generate된 코드를 이용해 빌드하는 게 protobuf와 비슷한 느낌)
이외에도 interface binding 같이 다양한 DI 로직들이 구현되어 있습니다
런타임은 아니지만 런타임 직전에 미리 확인하는 작업을 하게 되어 큰 거부감은 없네요!
아래는 바로 사용할 수 있는 간단한 wire 예제 코드입니다
필요하면 참고하세요!
Facebookgo
이번엔 Facebookgo의 DI를 살펴봅시다
앞에서 언급했듯이 reflect base의 injector입니다
이를 사용하기 위해서는 json이나 xml 등에 사용되는
struct tag를 사용해야 합니다
`inject:""`
`inject:"private"`
`inject:"dev logger"`
- 첫 번째는 singletone
- 두 번째는 연관된 타입을 위한 인스턴스 생성 트리거
- 세 번째는 named dependency
아래의 예제 코드를 살펴봅시다
package main
import (
"fmt"
"net/http"
"os"
"github.com/facebookgo/inject"
)
// Our Awesome Application renders a message using two APIs in our fake
// world.
type HomePlanetRenderApp struct {
// The tags below indicate to the inject library that these fields are
// eligible for injection. They do not specify any options, and will
// result in a singleton instance created for each of the APIs.
NameAPI *NameAPI `inject:""`
PlanetAPI *PlanetAPI `inject:""`
}
func (a *HomePlanetRenderApp) Render(id uint64) string {
return fmt.Sprintf(
"%s is from the planet %s.",
a.NameAPI.Name(id),
a.PlanetAPI.Planet(id),
)
}
// Our fake Name API.
type NameAPI struct {
// Here and below in PlanetAPI we add the tag to an interface value.
// This value cannot automatically be created (by definition) and
// hence must be explicitly provided to the graph.
HTTPTransport http.RoundTripper `inject:""`
}
func (n *NameAPI) Name(id uint64) string {
// in the real world we would use f.HTTPTransport and fetch the name
return "Spock"
}
// Our fake Planet API.
type PlanetAPI struct {
HTTPTransport http.RoundTripper `inject:""`
}
func (p *PlanetAPI) Planet(id uint64) string {
// in the real world we would use f.HTTPTransport and fetch the planet
return "Vulcan"
}
func main() {
// Typically an application will have exactly one object graph, and
// you will create it and use it within a main function:
var g inject.Graph
// We provide our graph two "seed" objects, one our empty
// HomePlanetRenderApp instance which we're hoping to get filled out,
// and second our DefaultTransport to satisfy our HTTPTransport
// dependency. We have to provide the DefaultTransport because the
// dependency is defined in terms of the http.RoundTripper interface,
// and since it is an interface the library cannot create an instance
// for it. Instead it will use the given DefaultTransport to satisfy
// the dependency since it implements the interface:
var a HomePlanetRenderApp
err := g.Provide(
&inject.Object{Value: &a},
&inject.Object{Value: http.DefaultTransport},
)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// Here the Populate call is creating instances of NameAPI &
// PlanetAPI, and setting the HTTPTransport on both to the
// http.DefaultTransport provided above:
if err := g.Populate(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// There is a shorthand API for the simple case which combines the
// three calls above is available as inject.Populate:
//
// inject.Populate(&a, http.DefaultTransport)
//
// The above API shows the underlying API which also allows the use of
// named instances for more complex scenarios.
fmt.Println(a.Render(42))
}
- HomePlanetRenderApp 구조체 정의
- NameAPI, PlanetAPI가 필요
- 둘 다 singletone으로 inject를 하겠다는 의미로 tag를 달아두었음
- NameAPI, PlanetAPI 구조체 정의
- HTTP.RoundTripper가 필요
- singletone으로 inject하겠다는 의미로 tag 달아두었음
- main
- inject.Graph 생성
- 비어있는 HomePlanetRenderApp을 생성
- HomePlatnetRenderApp을 채우기 위해
inject.Graph의 Provide 함수로 필요한 object들의 inject 선언- &inject.Object{Value: &a}
- &inject.Object{Value: http.DefaultTransport}
- inject.Graph의 Populate 함수를 호출하여 HomePlatnetRenderApp에 inject 수행
- NameAPI, PlanetAPI instance 생성되어 설정
- http.DefaultTransport 설정
실행 결과는 아래와 같습니다
문서 자체도 단순하고, 최대한 심플하게 설계한 노력이 엿보입니다
구현하기 쉽고 이해하기 쉬운 장점이 있네요
아래도 facebookgo에서 제공하는 간단한 예제입니다
package main
import (
"fmt"
"github.com/facebookgo/inject"
"strings"
)
type ILog interface {
Log(string)
}
//------------------------------------------------------------------------------
// IVehicle describes ...
type IVehicle interface {
MinimumAge() int
Wheels() int
}
type ILicenseCheck interface {
CanDrive(Person, IVehicle) bool
}
type DefaultLicenseCheck struct {}
func (d *DefaultLicenseCheck) CanDrive(p Person, v IVehicle) bool {
return p.Age >= v.MinimumAge()
}
//------------------------------------------------------------------------------
// Car is ...
type Car struct {
Log ILog `inject:""`
engine string
wheels int
}
// NewCar creates ...
func NewCar() *Car {
return &Car{wheels: 4}
}
// Wheels return ...
func (c *Car) Wheels() int {
c.Log.Log("Car wheels")
return c.wheels
}
func (c *Car) MinimumAge() int {
return 16
}
//------------------------------------------------------------------------------
// Bike is ...
type Bike struct {
Log ILog `inject:""`
wheels int
}
// NewBike creates ...
func NewBike() *Bike {
return &Bike{wheels: 2}
}
// Wheels return ...
func (b *Bike) Wheels() int {
b.Log.Log("Bike wheels")
return b.wheels
}
func (b *Bike) MinimumAge() int {
return 4
}
//------------------------------------------------------------------------------
// Person is ...
type Person struct {
Vehicle IVehicle `inject:""`
Log ILog `inject:""`
LicenseCheck ILicenseCheck `inject:""`
Age int
}
// NewPerson creates ...
func NewPerson(age int) *Person {
return &Person{
Age: age,
}
}
func (person *Person) Drive() {
if !person.LicenseCheck.CanDrive(*person, person.Vehicle) {
person.Log.Log("Waah! I can't drive this yet!")
} else {
person.Log.Log(fmt.Sprintf("Driving around on %d wheels", person.Vehicle.Wheels()))
}
}
//------------------------------------------------------------------------------
type ConsoleLog struct {
}
func (consoleLog *ConsoleLog) Log(info string) {
fmt.Println(info)
}
//==============================================================================
var (
person = NewPerson(12)
car = NewCar()
bike = NewBike()
)
func init() {
log := &ConsoleLog{}
licenseCheck := &DefaultLicenseCheck{}
inject.Populate(person, log, car, licenseCheck)
}
func main() {
person.Drive()
mockTest()
}
type MockILicenseCheck struct {
timesCalled int
WhatToReturn bool
}
func (m *MockILicenseCheck) CanDrive(p Person, v IVehicle) bool {
m.timesCalled++
return m.WhatToReturn
}
func (m *MockILicenseCheck) Reset() {
m.timesCalled = 0
}
type MockILog struct {
timesCalled int
LastMessage string
}
func (m *MockILog) Log(value string) {
m.timesCalled++
m.LastMessage = value
}
func (m *MockILog) Reset() {
m.timesCalled = 0
m.LastMessage = ""
}
func mockTest() {
log := &MockILog{}
licenseCheck := &MockILicenseCheck{WhatToReturn: true}
p := NewPerson(12)
b := NewBike()
inject.Populate(log, licenseCheck, b, p)
p.Drive()
fmt.Printf("Expect 1, actual %d\n", licenseCheck.timesCalled)
fmt.Printf("Expect false, actual %t\n", strings.HasPrefix(log.LastMessage, "Waah"))
log.Reset()
licenseCheck.Reset()
licenseCheck.WhatToReturn = false
p.Drive()
fmt.Printf("Expect true, actual %t\n", strings.HasPrefix(log.LastMessage, "Waah"))
}
한번 살펴봤으니 간단히 보면
전역변수로 person, car, bike를 만들고
init에서 person에 log, car, licenseCheck를 주입하였습니다
mockTest에서는 bike를 주입하여 다시 테스트하였네요
실행결과는 아래와 같습니다
만약 Populate를 호출하지 않고 실행하면 결과는 아래와 같습니다
구현이 Injection되지 않아서 empty 상태인 interface를 사용하니
nil pointer panic이 발생한 거죠!
개인적으로는 가장 일반적인 DI 방법이고 사용하기 간편해 마음에 드네요
하지만 런타임에 발생할 수 있는 에러들을 컴파일 타임에서 잡아주는 Wire의 장점도 다시 한번 느껴지네요
(nullpointer exception의 지옥이 얼마나 무서운지 잘 아니까요...)
Dig & Fx
그럼 이번엔 Dig의 DI를 살펴봅시다
앞에서 언급했듯이 facebookgo와 마찬가지로 reflect base의 injector입니다
방식은 Guice와 좀 더 유사합니다
다만 문서화나 관련 정보는 위의 둘에 비해 잘 정리되어 있지 않은 편이네요
Uber에서는 Dig 자체를 사용하기보단
Fx라는 Uber의 DI기반 App 프레임워크를 통해 사용하기를 권고합니다
댓글에 Fx 관련한 내용도 있으면 좋겠다고하여 간단하게 추가했습니다
Fx 전에 먼저 Dig부터 살펴봅시다
Dig는 Provide, Invoke로 나뉩니다
먼저 Dependency 그래프를 표현하는 Container 객체를 생성합니다
container := dig.New()
필요한 객체를 생성하는 함수를 Container의 Provide에 등록합니다
type HandlerResult struct {
Handler Handler
}
func NewHello1Handler() HandlerResult {
...
}
container.Provide(NewHello1Handler)
Provide에 등록된 Provider를 사용할 함수를 Invoke에 등록합니다
func RunServer(param HandlerResult) error {
...
}
container.Invoke(RunServer)
Invoke에 등록된 함수의 인자에 Provide에 등록한 값이 들어가겠죠
전체 코드는 아래와 같으며 마저 살펴보겠습니다
package main
import (
"fmt"
"net/http"
"go.uber.org/dig"
)
type Handler struct {
Greeting string
Path string
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s from %s", h.Greeting, h.Path)
}
func NewHello1Handler() HandlerResult {
return HandlerResult{
Handler: Handler{
Path: "/hello1",
Greeting: "welcome",
},
}
}
type HandlerResult struct {
Handler Handler
}
func RunServer(param HandlerResult) error {
mux := http.NewServeMux()
mux.Handle(param.Handler.Path, param.Handler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
if err := server.ListenAndServe(); err != nil {
return err
}
return nil
}
func main() {
container := dig.New()
container.Provide(NewHello1Handler)
container.Invoke(RunServer)
}
- HandlerResult를 제공하는 NewHello1handler를 Provide에 등록
- HandlerResult를 인자로 사용하는 RunServer를 Invoke에 등록
- 필요한 인자가 DI에 의해 주입되어 Invoke의 등록된 함수 정상 수행
Dig에도 다양한 기능이 있으니 자세한 건 Document를 참고해보시면 되겠습니다
그리고
Fx는 Dig를 이용해 DI를 수행하도록 개발하는 Uber의 Application Framework를 제공합니다
내용이 길어지니 공식 예제와 특징만 간단히 보도록 하겠습니다
자세한 사항은 나중에 추가로 다뤄볼수도 있을것 같네요
package main
import (
"context"
"log"
"net/http"
"os"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
)
func NewLogger() *log.Logger {
logger := log.New(os.Stdout, "" /* prefix */, 0 /* flags */)
logger.Print("Executing NewLogger.")
return logger
}
func NewHandler(logger *log.Logger) (http.Handler, error) {
logger.Print("Executing NewHandler.")
return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
logger.Print("Got a request.")
}), nil
}
func NewMux(lc fx.Lifecycle, logger *log.Logger) *http.ServeMux {
logger.Print("Executing NewMux.")
// First, we construct the mux and server. We don't want to start the server
// until all handlers are registered.
mux := http.NewServeMux()
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
logger.Print("Starting HTTP server.")
go server.ListenAndServe()
return nil
},
OnStop: func(ctx context.Context) error {
logger.Print("Stopping HTTP server.")
return server.Shutdown(ctx)
},
})
return mux
}
func Register(mux *http.ServeMux, h http.Handler) {
mux.Handle("/", h)
}
func main() {
app := fx.New(
fx.Provide(
NewLogger,
NewHandler,
NewMux,
),
fx.Invoke(Register),
fx.WithLogger(
func() fxevent.Logger {
return fxevent.NopLogger
},
),
)
startCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := app.Start(startCtx); err != nil {
log.Fatal(err)
}
if _, err := http.Get("http://localhost:8080/"); err != nil {
log.Fatal(err)
}
stopCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := app.Stop(stopCtx); err != nil {
log.Fatal(err)
}
}
- NewLogger, NewHandler, NewServeMux 세개의 provider 존재
- main에서 fx.Provider를 이용해 사용할 provider들을 등록
- provider들은 construct lazily(lazy loading) 적용
- lazy loading(디자인 패턴)
- DI에서 일반적으로 사용되는 lazy loading이라는 개념이 사용
- 일반적으로 lazy란 단어가 사용되면
해당 부분이 필요해질때까지 초기화를 지연시킴
- 일반적으로 lazy란 단어가 사용되면
- 해당 provider가 필요해질때만 해당 객체를 초기화
- NewHandler를 분석(reflect)하는 순간에 logger가 필요하여 provide된 NewLogger 생성자를 실행
- 해당 객체의 생성 비용이 높은 경우,
생성하는 경우가 많지 않은 경우에 성능 향상을 위해 사용 - 한번 사용되어 생성되면 계속 재사용하기 때문에
singleton 처리를 위해 주로 사용되는 개념
- DI에서 일반적으로 사용되는 lazy loading이라는 개념이 사용
- main에서 invoke eagerly
- invoke에 등록되는 함수는 eagerly(eager loading) 적용
- eagar loading(디자인 패턴)
- App이 시작함과 동시에 해당 객체를 초기화
- 일반적으로 동작을 위해 미리 준비(server listening)해야하거나
Thread-safe하게 생성할 객체를 위해 사용 - 앱 시작 시, 미리 생성하여 계속 사용하기 때문에
singleton 처리를 위해 주로 사용되는 개념
- 위의 코드에선 Register 함수가 eagerly하게 실행되고
http.ServeMux와 http.Handler가 필요하도록 등록해 두었으므로
lazy한 해당 provider들이 필요해져 곧바로 생성되어 mux.Handle("/", h)를 수행
간단하게 DI 개념만 훑어보았으나 Fx는 Application Framework이기 때문에
더 상세히 알만한 내용이 많습니다
관심 있으면 직접 Document를 참고하시면 되겠습니다
직접 확인해보려하니 공식적으로 제공하는 방법들로 Fx 설치가 안되네요
사용법은 깔끔하나 단순 DI 용도로는 무거워보이기도하고
접근성이 좋지않고 관리가 잘되는것 같진 않아 개인적으로 사용감이 좋진 않네요
이외에도 go에서 사용할 수 있는 각종 DI 라이브러리들이 있습니다
현재로썬 Facebookgo가 가장 많이 사용되는 것 같고
구글의 Wire가 유망한 전망을 가지고 사용되는 것으로 보입니다
Wire만 더 자세히 정리하게 되었는데
실제로 관련 공식 문서가 Wire가 가장 잘 정리되어 있습니다
Wire 같은 경우는 문서가 잘 정리되어 있지만
Facebookgo와 Dig는 바로 실행시켜볼 수 있는
단순한 예제 코드를 보는 게 더 이해가 쉽습니다
셋 다 바로 실행할 수 있는 예제 코드를 넣었으니
직접 실행해보면 빠르게 이해하는데 도움이 될 거예요
Facebookgo나 Dig의 방식이 전형적이고 간편하지만
경험상 런타임에서 DI를 수행하다 발생하는 문제를 찾거나 해결하기가 꽤나 까다롭습니다
안정적인 개발과 디버그를 위해서는 Wire를 사용하는 것이 끌리네요
프로젝트에는 Wire를 사용해보기로 결정하였습니다
'Programming > Go' 카테고리의 다른 글
Go/Golang Release & pkg.go.dev package update (0) | 2021.10.06 |
---|---|
Go/Golang HTTP 성능 튜닝 (0) | 2021.09.24 |
Go/Golang Scheduling (8) | 2021.09.02 |
Go/Golang Test (2) | 2021.08.26 |
Go/Golang Memory Management (16) | 2021.08.22 |