서론

Spring Framework에서 Spring Bean을 사용하여 객체지향 설계원칙을 지키는 방법에 대해서 공부해본 경험이 있었기때문에 궁금증이 생겼습니다. go에서는 Spring Bean과 같은 IoC(제어의 역전)을 지원해주는 도구는 없는가에 대한 궁금증이.


본론

go에서의 의존성 도구는 아래와 같은 도구들이 있습니다. 

  1. wire
  2. fx(built on top of dig)

wire는 google에서 만들었으며 code generator 방식으로 build-time에 코드를 만들어내는 도구입니다. fx는 uber에서 만들었으며 reflect를 이용해서 runtime에 의존성을 주입하는 도구입니다. 각각의 도구들을 사용해봅시다.


wire

wire는 providers와 injectors라는 핵심 개념이 있다.

아래의 명령어를 실행합니다.

go get github.com/google/wire/cmd/wire

 

Defining Providers

Wire의 기본 메커니즘은 값을 생성할 수 있는 함수인 provide입니다. 
package foobarbaz

type Foo struct {
    X int
}

// ProvideFoo returns a Foo.
func ProvideFoo() Foo {
    return Foo{X: 42}
}

provider 함수는 일반 함수와 마찬가지로 다른 패키지에서 사용하기 위해 내보내야 합니다. provider는 매개변수로 종속성을 지정할 수 있습니다.

package foobarbaz

// ...

type Bar struct {
    X int
}

// ProvideBar returns a Bar: a negative Foo.
func ProvideBar(foo Foo) Bar {
    return Bar{X: -foo.X}
}

providers는 error를 return 할 수도 있습니다.

package foobarbaz

import (
    "context"
    "errors"
)

// ...

type Baz struct {
    X int
}

// ProvideBaz returns a value if Bar is not zero.
func ProvideBaz(ctx context.Context, bar Bar) (Baz, error) {
    if bar.X == 0 {
        return Baz{}, errors.New("cannot provide baz when bar is zero")
    }
    return Baz{X: bar.X}, nil
}

공급자는 공급자 집합으로 그룹화할 수 있습니다. 이는 여러 공급자가 자주 함께 사용되는 경우에 유용합니다. 이러한 공급자들을 SuperSet이라는 새 세트에 추가하려면 wire.NewSet function을 사용하면됩니다.

package foobarbaz

import (
    // ...
    "github.com/google/wire"
)

// ...

var SuperSet = wire.NewSet(ProvideFoo, ProvideBar, ProvideBaz)

다른 공급자 집합을 공급자 집합에 추가할 수도 있습니다.

package foobarbaz

import (
    // ...
    "example.com/some/other/pkg"
)

// ...

var MegaSet = wire.NewSet(SuperSet, pkg.OtherSet)

 

Injectors

애플리케이션은 종속성 순서대로 provider를 호출하는 함수인 injector를 사용하여 wire로 연결합니다. Wire를 사용하면 injector의 서명을 작성하면 Wire가 함수의 본문을 생성합니다.

인젝터는 wire.Build에 대한 호출을 본문으로 하는 함수 선언을 작성하여 선언됩니다. 반환 값은 올바른 유형인 한 중요하지 않습니다. 생성된 코드에서 값 자체는 무시됩니다. 위의 provider가 go/providers/foobarbaz라는 패키지에 정의되어 있다고 가정해 보겠습니다. 다음은 Baz를 얻기 위한 injector를 선언합니다:

// +build wireinject
// The build tag makes sure the stub is not built in the final build.

package main

import (
    "context"

    "github.com/google/wire"
    "go/providers/foobarbaz"
)

func initializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
    wire.Build(foobarbaz.SuperSet)
    return foobarbaz.Baz{}, nil
}

이후 위 코드가 담긴 디렉토리 안에서 아래의 명령어를 실행합니다.

wire

저는 해당 과정에서 zsh: command not found: wire 에러가 발생했고, export PATH=$PATH:$GOPATH/bin 명령어로 path를 수정해주니 정상적으로 돌아갔습니다. 해결과정에서 참고한 링크입니다. 

 

wire 명령어를 치는순간 아래와 같은 코드로 wire_gen.go라는 파일이 자동으로 생성됩니다.

// Code generated by Wire. DO NOT EDIT.

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

package main

import (
	"context"
	"go/providers/foobarbaz"
)

// Injectors from wire.go:

func initializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
	foo := foobarbaz.ProvideFoo()
	bar := foobarbaz.ProvideBar(foo)
	baz, err := foobarbaz.ProvideBaz(ctx, bar)
	if err != nil {
		return foobarbaz.Baz{}, err
	}
	return baz, nil
}

순서대로 잘 생성된 모습을 볼 수 있고 wire를 통해 의존성을 주입하고 최종적으로 의존성을 주입받은 객체를 반환받습니다. 더 자세한 기능들은 링크에서 확인할 수 있습니다.

 

총 코드

foo, bar, baz 코드 

package foobarbaz

import (
    "context"
    "errors"
)

type Foo struct {
    X int
}

type Bar struct {
    X int
}

type Baz struct {
    X int
}

// ProvideFoo returns a Foo.
func ProvideFoo() Foo {
    return Foo{X: 42}
}

// ProvideBar returns a Bar: a negative Foo.
func ProvideBar(foo Foo) Bar {
    return Bar{X: -foo.X}
}

// ProvideBaz returns a value if Bar is not zero.
func ProvideBaz(ctx context.Context, bar Bar) (Baz, error) {
    if bar.X == 0 {
        return Baz{}, errors.New("cannot provide baz when bar is zero")
    }
    return Baz{X: bar.X}, nil
}

wire build 코드

// +build wireinject
// The build tag makes sure the stub is not built in the final build.

package wire

import (
    "context"

    "github.com/google/wire"
    "go/providers/foobarbaz"
)

func InitializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
    wire.Build(foobarbaz.ProvideFoo, foobarbaz.ProvideBar, foobarbaz.ProvideBaz)
    return foobarbaz.Baz{}, nil
}

생성된 wire_gen.go 코드

// Code generated by Wire. DO NOT EDIT.

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

package wire

import (
	"context"
	"go/providers/foobarbaz"
)

// Injectors from wire.go:

func InitializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
	foo := foobarbaz.ProvideFoo()
	bar := foobarbaz.ProvideBar(foo)
	baz, err := foobarbaz.ProvideBaz(ctx, bar)
	if err != nil {
		return foobarbaz.Baz{}, err
	}
	return baz, nil
}

테스트

package main

import (
    "fmt"
    "context"
    
	"go/providers/foobarbaz"
	"go/providers/foobarbaz/wire"
)

func main() {
	foo := foobarbaz.ProvideFoo()
	bar := foobarbaz.ProvideBar(foo)
	baz1, _ := foobarbaz.ProvideBaz(context.Background(), bar)
    fmt.Println(baz1.X)

	baz2, _ := wire.InitializeBaz(context.Background())
	fmt.Println(baz2.X)
}

출력

기존에 생성코드로 만든 baz와 wire로 생성된 baz 모두 같은 값을 출력하는 것을 확인할 수 있다. Foo의 X값을 변경해도 변경된 값으로 출력된다. 의존성과 파라미터, return 형태에 변경만 없다면 내부 로직을 바꾸어도 정상적으로 돌아가는 것을 확인할 수 있다.

하지만 의존성/파라미터/return 형태에 변경이 있다면 코드를 변경해준 뒤 wire 명령어를 다시 입력해야 한다.

 


fx

fx는 서버 애플리케이션의 구성 요소를 생성하고 연결하는 데 도움을 주면서 DI를 지원하는 프레임워크입니다.

사용하면서 느낀건데 go에서 사용되는 다른 web framework들도 DI를 제공합니다. (예를들어, Fiber / Gin / Echo 등이 있다.)

 

사용방법

아래의 명령어를 실행합니다.

go get go.uber.org/fx@v1

main.go

package main

import "go.uber.org/fx"

func main() {
  fx.New().Run()
}

run

go run .

아래와 같은 출력을 확인할 수 있습니다.

[Fx] PROVIDE	fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE	fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE	fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] RUNNING

인자 없이 fx.New를 호출하여 빈 Fx 애플리케이션을 빌드했습니다. 애플리케이션은 일반적으로 fx.New에 인수를 전달해 컴포넌트를 설정하고 App.Run 메서드로 이 애플리케이션을 실행합니다. ctrl+c를 누르면 종료가 됩니다.

Fx는 주로 장기 실행 서버 애플리케이션을 위한 것으로 종료할 때가 되면 배포 시스템으로부터 신호를 받습니다.

 

Add an HTTP server

HTTP 서버를 구축하는 함수를 작성

// NewHTTPServer builds an HTTP server that will begin serving requests
// when the Fx application starts.
func NewHTTPServer(lc fx.Lifecycle) *http.Server {
  srv := &http.Server{Addr: ":8080"}
  return srv
}

하지만 이것만으로는 충분하지 않습니다. Fx에게 HTTP 서버를 시작하는 방법을 알려줘야 합니다. 이것이 바로 추가 fx.Lifecycle 인수의 목적입니다. fx.Lifecycle 객체를 사용하여 애플리케이션에 수명 주기 후크를 추가합니다. 이렇게 하면 Fx가 HTTP 서버를 시작하고 중지하는 방법을 알려줍니다.

func NewHTTPServer(lc fx.Lifecycle) *http.Server {
  srv := &http.Server{Addr: ":8080"}
  lc.Append(fx.Hook{
    OnStart: func(ctx context.Context) error {
      ln, err := net.Listen("tcp", srv.Addr)
      if err != nil {
        return err
      }
      fmt.Println("Starting HTTP server at", srv.Addr)
      go srv.Serve(ln)
      return nil
    },
    OnStop: func(ctx context.Context) error {
      return srv.Shutdown(ctx)
    },
  })
  return srv
}

fx.Provide 추가

func main() {
  fx.New(
    fx.Provide(NewHTTPServer),
  ).Run()
}

돌려보면 정상적으로 돌아가는 것 같지만 위에 적어놓은 fmt.Println("Starting HTTP server at", srv.Addr)부분이 출력되지 않는 것을 확인할 수 있습니다. 서버가 시작되지 않았다는 것인데요. 실제로 curl로 요청을 보내보면 아래와 같은 에러로그가 출력됩니다.

curl: (7) Failed to connect to localhost port 8080 after 14 ms: Couldn't connect to server

이 문제를 해결하려면 생성된 서버를 요청하는 fx.Invoke를 추가해야합니다.

 fx.New(
    fx.Provide(NewHTTPServer),
    fx.Invoke(func(*http.Server) {}),
  ).Run()

이제 돌려보면 정상적으로 돌아갑니다. curl로 요청을 보내면 404가 뜨지만, 적절한 응답이 없어서 뜨는 에러입니다.

404 page not found

그렇다면 적절한 응답을 보내기 위해 Handler를 추가해봅시다.

// EchoHandler is an http.Handler that copies its request body
// back to the response.
type EchoHandler struct{}

// NewEchoHandler builds a new EchoHandler.
func NewEchoHandler() *EchoHandler {
  return &EchoHandler{}
}

// ServeHTTP handles an HTTP request to the /echo endpoint.
func (*EchoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  if _, err := io.Copy(w, r.Body); err != nil {
    fmt.Fprintln(os.Stderr, "Failed to handle request:", err)
  }
}

main 메소드 코드도 변경해야합니다.

    fx.Provide(
      NewHTTPServer,
      NewEchoHandler,
    ),
    fx.Invoke(func(*http.Server) {}),

다음으로 *http.ServeMux를 빌드하는 함수를 작성합니다. http.ServeMux는 서버가 수신한 요청을 다른 핸들러로 라우팅합니다. 우선, 이 함수는 /echo로 전송된 요청을 *EchoHandler로 라우팅하므로 생성자는 *EchoHandler를 인수로 받아들여야 합니다.

// NewServeMux builds a ServeMux that will route requests
// to the given EchoHandler.
func NewServeMux(echo *EchoHandler) *http.ServeMux {
  mux := http.NewServeMux()
  mux.Handle("/echo", echo)
  return mux
}

Provide에도 추가해줘야합니다.

    fx.Provide(
      NewHTTPServer,
      NewServeMux,
      NewEchoHandler,
    ),

NewHTTPServer 메소드에도 ServeMux를 추가합니다.

func NewHTTPServer(lc fx.Lifecycle, mux *http.ServeMux) *http.Server {
  srv := &http.Server{Addr: ":8080", Handler: mux}
  lc.Append(fx.Hook{

그 후 돌려보면 요청에 따라 다른 응답을 보냅니다.

$ curl -X POST -d 'hello kyj' http://localhost:8080/echo
hello kyj

현재는 프로그램을 돌리면 아래와 같은 출력이 발생합니다. 이를 로거로 바꿔주겠습니다. 

[Fx] PROVIDE	*http.Server <= main.NewHTTPServer()
[Fx] PROVIDE	*http.ServeMux <= main.NewServeMux()
[Fx] PROVIDE	*main.EchoHandler <= main.NewEchoHandler()
[Fx] PROVIDE	fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE	fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE	fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] INVOKE		main.main.func1()
[Fx] HOOK OnStart		main.NewHTTPServer.func1() executing (caller: main.NewHTTPServer)
Starting HTTP server at :8080
[Fx] HOOK OnStart		main.NewHTTPServer.func1() called by main.NewHTTPServer ran successfully in 320.824µs
[Fx] RUNNING
^C[Fx] INTERRUPT
[Fx] HOOK OnStop		main.NewHTTPServer.func2() executing (caller: main.NewHTTPServer)
[Fx] HOOK OnStop		main.NewHTTPServer.func2() called by main.NewHTTPServer ran successfully in 241.698µs

여기는 Zap을 사용했습니다. 사용하기 위해서는 아래 명령어를 실행해주고 "go.uber.org/zap"을 import 해야합니다.

go get -u go.uber.org/zap

애플리케이션에 Zap 로거를 제공합니다.

    fx.Provide(
      NewHTTPServer,
      NewServeMux,
      NewEchoHandler,
      zap.NewExample,
    ),

에코핸들러에 로거를 보관할 필드를 추가하고, 새 에코핸들러에서 이 필드를 설정하는 새 로거 인수를 추가합니다.

type EchoHandler struct {
  log *zap.Logger
}

func NewEchoHandler(log *zap.Logger) *EchoHandler {
  return &EchoHandler{log: log}
}

출력대신 로그로 바꿉니다. 

func (h *EchoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  if _, err := io.Copy(w, r.Body); err != nil {
    h.log.Warn("Failed to handle request", zap.Error(err))
  }
}
func NewHTTPServer(lc fx.Lifecycle, mux *http.ServeMux, log *zap.Logger) *http.Server {
  srv := &http.Server{Addr: ":8080", Handler: mux}
  lc.Append(fx.Hook{
    OnStart: func(ctx context.Context) error {
      ln, err := net.Listen("tcp", srv.Addr)
      if err != nil {
        return err
      }
      log.Info("Starting HTTP server", zap.String("addr", srv.Addr))
      go srv.Serve(ln)

Fx의 자체 로그에도 동일한 Zap 로거를 사용할 수 있습니다.

func main() {
  fx.New(
    fx.WithLogger(func(log *zap.Logger) fxevent.Logger {
      return &fxevent.ZapLogger{Logger: log}
    }),

로그로 바뀐 모습

{"level":"info","msg":"provided","constructor":"main.NewHTTPServer()","type":"*http.Server"}
{"level":"info","msg":"provided","constructor":"main.NewServeMux()","type":"*http.ServeMux"}
{"level":"info","msg":"provided","constructor":"main.NewEchoHandler()","type":"*main.EchoHandler"}
{"level":"info","msg":"provided","constructor":"go.uber.org/zap.NewExample()","type":"*zap.Logger"}
{"level":"info","msg":"provided","constructor":"go.uber.org/fx.New.func1()","type":"fx.Lifecycle"}
{"level":"info","msg":"provided","constructor":"go.uber.org/fx.(*App).shutdowner-fm()","type":"fx.Shutdowner"}
{"level":"info","msg":"provided","constructor":"go.uber.org/fx.(*App).dotGraph-fm()","type":"fx.DotGraph"}
{"level":"info","msg":"initialized custom fxevent.Logger","function":"main.main.func1()"}
{"level":"info","msg":"invoking","function":"main.main.func2()"}
{"level":"info","msg":"OnStart hook executing","callee":"main.NewHTTPServer.func1()","caller":"main.NewHTTPServer"}
{"level":"info","msg":"Starting HTTP server","addr":":8080"}
{"level":"info","msg":"OnStart hook executed","callee":"main.NewHTTPServer.func1()","caller":"main.NewHTTPServer","runtime":"378.452µs"}
{"level":"info","msg":"started"}

위의 방식대로 fx를 사용하면 됩니다. 

 

 

 

 

 

Reference

'Go' 카테고리의 다른 글

Import/Export  (0) 2023.12.24
package와 main  (0) 2023.12.24
go로 gRPC Unary 찍먹  (0) 2023.05.01
gRPC란?  (0) 2023.05.01
Golang 설정 및 실행  (0) 2023.04.30

+ Recent posts