콘텐츠로 건너뛰기

Hashicorp plugin system 설계 및 구현

Hashicorp 제품을 살펴보다보면 사용자가 커스텀한 plugin을 만들고 붙여서 확장된 기능을 사용할 수 있는 형태인 것을 느낄 수 있다. Terraform을 이용하여 우리는 잘 알려진 GCP와 관련된 인프라 리소스를 생성하고 편집할 수도 있지만 커스텀한 리소스를 정의하고 생성하여 관리할 수 있다. Vault에는 기본적으로 제공되는 여러 Secret engine들이 있지만 필요하다면 우리가 커스텀한 secret engine을 만들어 그것을 Vault에 plugin 형식으로 붙일 수도 있다.

이처럼 각 제품마다 plugin이 되기 위한 인터페이스가 사전에 정의되어있고 사용자는 해당 인터페이스를 구현한다면 해당 제품의 또다른 plugin으로 동작할 수 있게 된다. 이번 포스팅에서는 이런 Hashicorp 제품에 일반적으로 사용되는 plugin 시스템에 대해서 살펴본다.

‘Abstract Behavior’ 섹션에서는 plugin 시스템 자체가 추상적으로 어떻게 동작하는지 살펴본다. ‘Plugin Design’ 섹션에서는 plugin 시스템이 어떻게 설계되었는지를 살펴보며 개인적으로 생각하는 특징에 대해서 정리한다. ‘Plugin Implementation’ 섹션에서는 구현에 대해서 살펴본다. ‘Behavior in Terraform’에서는 Hashicorp plugin 시스템이 Terraform 컨텍스트에서는 어떻게 동작하는지에 대해서 살펴본다. 마지막으로 ‘ETC’에서는 그 밖에 plugin 시스템을 살펴보며 인상 깊었던 부분들에 대해 정리한다.

Hashicorp Plugin Abstract Behavior

Hashicorp plugin 시스템을 사용하는 메인 서비스에서 호스트가 plugin 서비스를 띄운다. 그리고 메인 서비스는 RPC를 통해 plugin 서비스와 통신한다. 메인 서비스는 plugin 서비스를 child process 형태로 띄우기 때문에 PID와 같은 프로세스 정보를 알고 있고 plugin 서비스가 죽더라도 메인 서비스에는 영향을 미치지 않는 특징이 있다.

Hashicorp에서 지원하는 RPC 클라이언트/서버 종류로는 Golang net/tcp 패키지의 구현체나 gRPC를 지원하고 있다. 그러나 대부분의 Hashicorp 제품에서는 gRPC를 사용하여 plugin 시스템을 사용하고 있다. 다양한 언어로 plugin 서비스를 구현할 수 있다는 점과 많은 테스트, 이용 사례를 통해 입증된 안정성이 그 이유가 아닐까 싶다.

이번 포스팅에서는 net/tcp 패키지를 사용한 RPC 클라이언트/서버로 살펴보겠다 . 테스트가 비교적 간편하고 plugin 시스템의 큰 동작을 이해하는 문제가 없기 때문이다. 포스팅에서 나오는 코드는 모두 여기 (프로젝트명: powerstrip)Terraform 코드에서 살펴볼 수 있다. 해당 코드는 Hashicorp plugin 코드를 포크한 후 불필요한 기능에 대한 코드는 삭제했고, 의존성이 있는 모듈 코드들을 하나로 합쳤다. 그래서 외부 라이브러리에 대한 의존성이 없어서 쉽게 돌려볼 수 있다.

메인 서비스가 시작되면 미리 빌드한 plugin 서비스 바이너리를 실행시킨다. 그리고 두 프로세스는 하나의 file descriptor를 공유하게 되고 해당 fd를 이용해 unix domain socket 통신을 하게 된다. 이 때 통신은 RPC 프로토콜을 통해 이뤄진다.

Hashicorp plugin 시스템에서 main-service가 plugin-service를 생성하고 RPC를 통해 통신한다.
그림 1. Hashicorp plugin 시스템에서 main-service가 plugin-service를 생성하고 RPC를 통해 통신한다.

Hashicorp Plugin Design

조금 더 구체적으로 살펴보자. 먼저 plugin을 만들기 위해서 구현해야하는 인터페이스는 다음과 같다.

type Plugin interface {
    Server(*MuxBroker) (interface{}, error)
    Client(*MuxBroker, *rpc.Client) (interface{}, error)
}

plugin에서 제공해주는 특정 서비스가 있을텐데 plugin은 서비스를 제공해주는 (RPC) 서버와 서비스를 사용할 수 있는 (RPC) 클라이언트를 사용자에게 제공해줄 수 있어야한다.

Greeter 라는 서비스가 있다고 해보자. plugin 개발자는 해당 서비스를 RPC를 통해 이용할 수 있도록 plugin 인터페이스를 구현해야한다.

type Greeter interface {
    Greet() string
}

GreeterPlugin 은 어떤 서버(GreeterRPCServer ) 와 어떤 클라이언트(GreeterRPC) 를 생성해야하는지 알고 있다. GreeterRPCServer 는 어떤 도메인 서비스 인터페이스를 호출해서 값을 전달해야하며, GreeterRPC 는 RPC 요청에 대한 값 처리, 에러 핸들링과 같은 작업을 해야한다.

여기서 ‘GreeterRPCServerGreeterRPC의 로직을 누가 작성해야하나’ 라는 궁금증이 들 수도 있다.

import (
    "net/rpc"
)
// GreeterRPC 는 Greeter 서비스 plugin의 클라이언트 역할을 합니다
type GreeterRPC struct {
    client *rpc.Client
}
func (g *GreeterRPC) Greet() string {
    var resp string
    err := g.client.Call("Plugin.Greet", new(interface{}), &resp)
    if err != nil {
        panic(err)
    }
    return resp
}
// GreeterRPCServer 는 Greeter 서비스 plugin의 서버 역할을 합니다
type GreeterRPCServer struct {
    Impl Greeter
}
func (s *GreeterRPCServer) Greet(args interface{}, resp *string) error {
    *resp = s.Impl.Greet()
    return nil
}
// GreeterPlugin 은 Greeter 서비스를 사용할 수 있는 plugin입니다
type GreeterPlugin struct {
    Impl Greeter
}
func (p *GreeterPlugin) Server(*powerstrip.MuxBroker) (interface{}, error) {
    return &GreeterRPCServer{Impl: p.Impl}, nil
}
func (GreeterPlugin) Client(b *powerstrip.MuxBroker, c *rpc.Client) (interface{}, error) {
    return &GreeterRPC{client: c}, nil
}

지금까지 관점을 plugin 시스템에 둬서 헷갈릴 수 있는데 이제 관점을 plugin 시스템을 사용하는 어떤 제품 개발자로 생각해보자. 이제 plugin 시스템이 아니라 이 사람은 이걸 plugin 모듈이라고 부를 것이다. (아래에서부터 ‘plugin 시스템’과 ‘plugin 모듈’을 같은 의미로 부르겠다)

plugin 모듈을 이용해서 제품 개발자는 정해진 인터페이스를 클라이언트에게 제공해주되 그 구현은 다른 사람에게 맡길 수 있다. 여기까지만 보면 일반적인 코드의 인터페이스와 그 구현체와 다를게 없지만, RPC를 이용하면서 차이가 생긴다.

그림 2와 같이 클라이언트가 제공된 인터페이스를 구현하고 구현체를 제품 코드에 DI 받는 형태로 동작을 한다면 제품 코드를 빌드 할 때 plugin 코드도 같이 빌드하게 된다. 반대도 마찬가지이다. 결국 제품 코드와 plugin 코드의 변경 라이프사이클을 분리하기가 어려워진다.

그림 3과 같이 plugin 시스템을 사용하는 경우에는 제품 코드에서 plugin 바이너리를 실행시키는 형태이기 때문에 제품과 plugin 빌드 시점은 달라도 된다. 또한 별도의 프로세스에서 plugin이 동작하기 때문에 특정 plugin에 문제가 있다 하더라도 전체 제품이 터질일은 없어진다.

이러한 디자인은 제품 개발 사이클과 plugin 개발 사이클이 다를 때, 두 개발팀간의 사일로가 높을 때 유용해보인다. 반면에 두 팀이 긴밀하게 협력을 할 수 있는 환경이거나 plugin이 어플리케이션에 크리티컬한 경우 이러한 구조는 테스트 복잡도를 높이거나 테스트 시간이 늘어날 것이다.

Hashicorp의 Terraform을 생각해봤을 때 GCP, AWS 도메인 지식은 해당 클라우드사에서 많이 가지고 있다. Hashicorp와 클라우드사의 사일로가 높기 때문에 하나의 코드베이스에서 개발을 하는 것은 난이도가 높을 것이다. Hashicorp에서는 기능을 쉽게 확장할 수 있는 plugin 모듈을 제공해주었고, 클라우드사에서는 비교적 적은 코스트로 IaC라는 유용한 개념을 쉽게 사용할 수 있게하여 고객들에게 클라우드 사용, 관리 편리성을 높여줄 수 있었다.

Application이 Service 구현체를 DI 받는 구조
그림 2. Application이 Service 구현체를 DI 받는 구조

Application이 Service 구현체 binary를 통해 실행시키고 통신하는 구조
그림 3. Application이 Service 구현체 binary를 통해 실행시키고 통신하는 구조

Hashicorp Plugin Implementation

이제 plugin 시스템 구현에 대해서 살펴보자. 개인적으로 생각하기에 중요하다고 느끼는 부분에 대해서 정리해보겠다. 먼저 plugin을 사용하는 간단한 예를 살펴보자.

예시에서 plugin에서 제공해주는 서비스 Greeter 의 구현체는 GreeterHello struct이다. 지금은 구현체가 간단해서 그렇지 기능이 복잡하면 수백, 수천줄이 될 수도 있을 것이다.

해당 구현체를 plugin 프레임워크를 이용해서 plugin map을 만든뒤 서빙한다. plugin map의 키에는 해당 plugin의 ID가 될 값을 넣는다. 예시에서는 "greeter"이고 해당 값으로 클라이언트에서는 자신이 원하는 plugin을 찾을 것이다.

프레임워크를 이용하여 코드를 작성하면 go build -o greeter 와 같이 빌드하여 바이너리로 만든다. 이는 그림 1에서 plugin-service 에 해당하는 부분이 될 것이다.

type GreeterHello struct{}
func (g *GreeterHello) Greet() string {
    return "Hello!"
}
func main() {
    greeter := &GreeterHello{}
    var pluginMap = map[string]powerstrip.Plugin{
        "greeter": &common.GreeterPlugin{Impl: greeter},
    }
    powerstrip.Serve(&powerstrip.ServeConfig{
        Plugins: pluginMap,
    })
}

다음은 plugin을 사용하는 부분이다. 마찬가지로 plugin map을 만들고 이 때 ID는 동일해야한다. ClientConfig 에서 plugin map을 넣고 이전 코드에서 만든 바이너리 경로를 넣어준다. 클라이언트는 자신이 시작할 때 해당 바이너리를 실행시켜 프로세스를 띄운다. 그리고 원하는 plugin을 가져와서 원하는 서비스를 호출한다.

이는 Protocol(), Dispense() 메소드를 실행시켜서 수행할 수 있다.

Protocol() 에서는 클라이언트 프로세스와 서버 프로세스가 어떤 방식으로 통신할지를 결정할 수 있다. 포크해서 간단하게 만든 powerstrip 코드에서는 golang의 net/rpc 모듈을 이용한 방식으로만 통신할 수 있지만 Hashicorp에서 제공해주는 plugin 시스템에서는 gRPC 방식도 지원한다. 이 과정에서 서버 프로세스를 띄우고 RPC connection을 마친 뒤 통신할 준비를 끝내놓는다. 그리고 해당 메소드에서 선택한 프로토콜 (여기에서는 net/rpc )을 사용하는 클라이언트를 리턴한다.

클라이언트는 Dispense() 을 통해서 plugin ID를 통해 원하는 plugin을 가져올 수 있다. 예시에서는 raw 를 plugin에서 제공하는 서비스인 common.Greeter 인터페이스로 캐스팅한 뒤 서비스를 호출하고 있다.

var pluginMap = map[string]powerstrip.Plugin{
    "greeter": &common.GreeterPlugin{},
}
func main() {
    client := powerstrip.NewClient(&powerstrip.ClientConfig{
        Plugins: pluginMap,
        Cmd:     exec.Command("./plugin/greeter"),
    })
    defer client.Kill()
    rpcClient, err := client.Protocol()
    if err != nil {
        log.Fatal(err)
    }
    raw, err := rpcClient.Dispense("greeter")
    if err != nil {
        log.Fatal(err)
    }
    greeter := raw.(common.Greeter)
    fmt.Println(greeter.Greet())
}

How client process create server process and connects to it

눈여겨볼만한 부분 중 하나는 클라이언트 프로세스가 서버 프로세스를 만들고 연결하는 부분이다.

Protocol() 을 호출하면 Start() 를 실행하고 net/rpc RPC 클라이언트를 만든다. 그리고 newRPCClient() 를 보면 클라이언트가 서버로 연결을 한다. Start() 에서 서버 프로세스를 실행했고 listening 상태임을 알 수 있다.

func (c *Client) Protocol() (ClientProtocol, error) {
    _, err := c.Start()
    ...
    c.proto, err = newRPCClient(c)
  ...
    return c.proto, nil
}
func newRPCClient(c *Client) (*RPCClient, error) {
    conn, err := net.Dial(c.addr.Network(), c.addr.String())
  ...
}

조금 길지만 하나의 맥락이 담겨 있어서 불필요한 부분을 제외하고 가져왔다. 먼저 서버 프로세스(cmd )의 stdout, stderr fd를 가져오고 실행시킵니다 (cmd.Start()). 그리고 두 개의 goroutine을 실행시킵니다.

첫 번째는 서버 프로세스가 끝나는 것을 기다리고 끝난다면 플래그를 바꿔줍니다. 이는 단순히 플래그를 바꿔주는 것 이외에도 아래 select<-c.doneCtx.Done() 케이스와 같이 서버 프로세스에 연결하기 전에 먼저 종료되는 상황을 핸들링하는 목적도 있습니다.

두 번째는 서버 프로세스의 stdout으로부터 출력되는 문자열을 읽어 linesCh 로 전달해줍니다. 서버 프로세스는 여기서 자신이 어떤 주소로 dial을 걸어야할지 주소 정보를 전달해줍니다.

최종적으로 서버 주소 정보를 얻고 메소드를 종료합니다.

func (c *Client) Start() (net.Addr, error) {
  cmdStdout, err := cmd.StdoutPipe()
  ...
    cmdStderr, err := cmd.StderrPipe()
  ...
  err = cmd.Start()
  ...
  c.proc = cmd.Process
  c.doneCtx, c.ctxCancel = context.WithCancel(context.Background())
  // 서버 프로세스가 끝나는 경우 플래그를 바꿉니다
  c.clientWg.Add(1)
    go func() {
        defer c.ctxCancel()
        defer c.clientWg.Done()
        ...
        err := cmd.Wait()
        ...
        c.exited = true
    }()
  // 서버 프로세스 stdout 에서 출력되는 문자열을 받습니다.
  linesCh := make(chan string)
    c.clientWg.Add(1)
    go func() {
        defer c.clientWg.Done()
        defer close(linesCh)
        sc := bufio.NewScanner(cmdStdout)
        for sc.Scan() {
            linesCh <- sc.Text()
        }
    }()
  select {
    case <-timeout:
        // return with error
    case <-c.doneCtx.Done():
        // return with error
    case line := <-linesCh:
        // get the server address `addr`
    }
  c.addr = addr
    return addr, nil
}

두 번째에서 어떻게 서버가 주소 전달을 하는지 살펴보기 위해 서버쪽을 살펴보면

serverListener() 에서는 unix socket 통신을 하기 위해 파일을 생성 후 fd를 통해 데이터 수신을 준비한다. 이 때 unix socket 통신이외에도 tcp 통신을 사용할 수도 있다.

fmt.Printf 를 통해 stdout으로 "Protocol|Address" 형식의 문자열을 출력한다. 이 문자열은 결국 클라이언트가 linesCh 를 통해서 받게 되고 해당 주소로 클라이언트가 연결 요청을 보낸다.

func Serve(opts *ServeConfig) {
    ...
    lis, err := serverListener()
  ...
  server := &RPCServer{
        Plugins: opts.Plugins,
        Stdout:  stdoutReader,
        Stderr:  stderrReader,
        DoneCh:  doneCh,
    }
  ...
  // "Protocol|Address" 형식으로 문자열을 stdout 으로 출력한다
  fmt.Printf("%s|%s\n",
        lis.Addr().Network(),
        lis.Addr().String())
    os.Stdout.Sync()
  ...
  go server.Serve(lis)
}
func serverListener() (net.Listener, error) {
    tf, err := ioutil.TempFile("", "plugin")
    ...
    path := tf.Name()
  ...
  l, err := net.Listen("unix", path)
  ...
}
func (c *Client) Start() (net.Addr, error) {
  ...
  select {
    case <-timeout:
        // return with error
    case <-c.doneCtx.Done():
        // return with error
    case line := <-linesCh:
        line = strings.TrimSpace(line)
        parts := strings.SplitN(line, "|", 2)
        if len(parts) < 2 {
            return nil, fmt.Errorf("", line)
        }
        switch parts[0] {
        case "tcp":
            addr, err = net.ResolveTCPAddr("tcp", parts[1])
        case "unix":
            addr, err = net.ResolveUnixAddr("unix", parts[1])
        default:
            err = fmt.Errorf("Unknown address type: %s", parts[0])
        }
    }
}

How Client select plugin then call the method

다음으로 살펴볼 부분은 클라이언트가 서버로부터 필요한 Plugin을 가져오는 부분을 살펴보자. (해당 섹션에서 소개되는 코드들은 사실 gRPC 프로토콜을 사용한다면 필요 없는 부분이다. protobuf를 통해 대부분 자동 생성되기 때문이다. 느낌만 보자)

Protocol() 를 통해 얻어낸 RPCClient 로부터 Dispense(name) 을 통해 원하는 plugin을 가져올 수 있다. 내부에서는 먼저 plugin이 존재하는지 체크한 후 "Dispenser.Dispense" RPC method를 통해 어떤 ID를 가져오고 해당 ID로 연결을 맺는다. 그리고 plugin의 클라이언트를 리턴한다.

func (c *RPCClient) Dispense(name string) (interface{}, error) {
    p, ok := c.plugins[name]
    if !ok {
        return nil, fmt.Errorf("unknown plugin type: %s", name)
    }
    var id uint32
    if err := c.control.Call(
        "Dispenser.Dispense", name, &id); err != nil {
        return nil, err
    }
    conn, err := c.broker.Dial(id)
    if err != nil {
        return nil, err
    }
    return p.Client(c.broker, rpc.NewClient(conn))
}

반대편을 봐야 전체 그림이 그려진다.

ServeConn() 에서 "Dispenser" 라는 prefix에 dispenseServer 메소드들을 등록하고있는 것을 알 수 있다. 해당 메소드를 클라이언트가 호출하고 있던 것이다. dispenseServer.Dispense() 를 살펴보면 name 에 해당하는 plugin을 찾은 뒤 구현체를 가져온 후, "Plugin" 이라는 prefix로 plugin 서비스 메소드들을 등록하고 있다.

정리하면 클라이언트가 "Dispenser.Dispense" RPC를 호출했을 때 서버는 원하는 plugin 서비스 메소드를 "Plugin" 이라는 prefix에 등록하게 된다.

MuxBroker 를 잠깐 언급하면 사실 여기도 엄청 재미있는 부분이다! Hashicorp에서 자체적으로 하나의 커넥션을 Stream이라는 단위로 multiplexing 하는 프로토콜을 만들어 구현하였는데 MuxBroker 는 내부에서 해당 프로토콜을 사용하고 있다. 하나의 connection에서 ID를 할당받아 해당 ID의 stream을 구성하는데 다른 stream의 간섭없이 각각의 Stream이 데이터를 주고 받기 때문에 마치 여러 connection을 가지고 있는 것처럼 통신할 수 있게된다. 궁금한 사람들은 여기서 살펴볼 수 있다.

func (s *RPCServer) Serve(lis net.Listener) {
    for {
        conn, err := lis.Accept()
        ...
        go s.ServeConn(conn)
    }
}
func (s *RPCServer) ServeConn(conn io.ReadWriteCloser) {
  ...
    server := rpc.NewServer()
    ...
    server.RegisterName("Dispenser", &dispenseServer{
        broker:  broker,
        plugins: s.Plugins,
    })
    server.ServeConn(control)
}
type dispenseServer struct {
    broker  *MuxBroker
    plugins map[string]Plugin
}
func (d *dispenseServer) Dispense(name string, response *uint32) error {
    p, ok := d.plugins[name]
  ...
    impl, err := p.Server(d.broker)
    ...
    id := d.broker.NextId()
    *response = id
    ...
    go func() {
        conn, err := d.broker.Accept(id)
        ...
        serve(conn, "Plugin", impl)
    }()
    return nil
}
func serve(conn io.ReadWriteCloser, name string, v interface{}) {
    server := rpc.NewServer()
    err := server.RegisterName(name, v)
  ...
    server.ServeConn(conn)
}

Hashicorp Plugin Behavior in Terraform

이런 plugin 모듈이 실제 Hashicorp 제품에서는 어떻게 사용될까? 대표적인 제품 중 하나인 Terraform을 살펴보자. Terraform에서 사용하는 모든 resource, data 타입은 프로바이더들이 사전에 정의해준 것들이다. 프로바이더는 각 자신들이 제공할 resource에 대해 Terraform에서 제공해주는 Plugin 프레임워크을 이용하여 서빙하는 프로그램을 만든다. 그림 1의 관점에서 이것이 plugin-service 가 되고 Terraform이 main-service 가 된다.

프로바이더들은 CRUD를 작성할 때 자신의 SDK를 통해 로직을 작성하게 된다.

출처: https://learn.hashicorp.com/tutorials/terraform/provider-use?in=terraform/providers

Hashicups 예제를 통해서 custom resource를 생성하는 모습을 살펴보자. 사용자는 main.go 에서 hashicups.New 라는 함수를 통해서 tfsdk.Provider 를 리턴하고 terraform-plugin-go에서는 다시 wrapping해서 원하는 형태로 만든 후 plugin에 등록하고 있는 것을 볼 수 있다. gRPC라 형태가 좀 달라보일 수 있지만 초반에 봤던 코드랑 비슷하지 않은가?

프로바이더는 이런 식으로 그림 1의 plugin-service 를 위한 서버 프로그램을 Terraform 프레임워크를 통해서 작성하게 된다.

(terraform-plugin-framework와 terraform-plugin-go 사이의 관계를 보는 것도 재미있다. terraform-plugin-framework는 사용자와 제품 코어 사이의 adapter 역할을 해주며 필요한 에러 핸들링이나 복잡한 설정들을 대신해주는 모습을 볼 수 있다.)

func main() {
    tfsdk.Serve(context.Background(), hashicups.New, tfsdk.ServeOpts{
        Name: "hashicups",
    })
}
func Serve(ctx context.Context, factory func() Provider, opts ServeOpts) error {
    return tf6server.Serve(opts.Name, func() tfprotov6.ProviderServer {
        return &server{
            p: factory(),
        }
    })
}
func Serve(name string, serverFactory func() tfprotov6.ProviderServer, opts ...ServeOpt) error {
    serveConfig := &plugin.ServeConfig{
        ...
        Plugins: plugin.PluginSet{
            "provider": &GRPCProviderPlugin{
                GRPCProvider: serverFactory,
            },
        },
        GRPCServer: plugin.DefaultGRPCServer,
    }
    ...
    plugin.Serve(serveConfig)
    return nil
}

클라이언트는 코드 양이 많기 때문에 관련 코드를 모두 가져오기가 어렵고 가독성이 떨어진다. 그래서 Terraform이 동작할 때 흐름을 간단하게 링크를 걸어서 정리하였다.

Terraform은 terraform plan 과 같은 동작을 수행할 때 Backend로부터 해당 모듈 관련 state를 가져오고 실행과 관련된 Context를 만든다. Context 안에 resource 타입 종류에 따라 해당 resource를 CRUD 해줄 Provider가 map으로 저장되어있다. 그리고 Context와 state를 통해 graph를 만들게 된다.

graphWalker가 graph를 walk() 하면서 graph node 타입에 따라 callback을 실행하는데 executable(plan, destroy, apply, import, … 등의 작업)한 node에 대해서는 node의 context를 통해 해당 작업을 수행하게 된다.

작업을 수행할 때 resource 타입에 따른 provider를 가져오고 필요한 plugin 인터페이스를 호출한다.

func (n *NodeAbstractResourceInstance) plan(ctx EvalContext, ...) {
  provider, providerSchema, err := getProvider(ctx, n.ResolvedProvider)
  ...
  resp := provider.PlanResourceChange(providers.PlanResourceChangeRequest{
        ...
    })
}

ETC

Testing Client

plugin 모듈을 사용하면서 인상깊었던 부분 중 하나가 테스트 코드이다. 바로 클라이언트를 테스트할 때 서버 프로세스를 어떻게 만들까에 대한 궁금증이 있었는데 여기서는 테스트 케이스로 서버 프로세스 테스트 더블을 만들었다.

테스트 코드를 작성할 때 helperProcess() 를 호출한다. 내부에서는 특정 테스트 코드를 실행시키는 cmd를 만든다. 아래에서는 -test,run=TestHelperProcess 를 통해서 TestHelperProcess 테스트 코드를 실행시키는 cmd를 만드는 것을 볼 수 있다.

그리고 타입에 따라서 테스트 더블 테스트 코드에서 어떻게 동작할지를 정의한다. "mock" 타입일 때 TestHelperProcess에서 "tcp|:1234" 를 stdout으로 출력하고 이를 통해 클라이언트는 서버로 연결하는 것을 테스트할 수 있다.

func TestClient(t *testing.T) {
    proc := helperProcess("mock")
  c := NewClient(&ClientConfig{
        Cmd:     proc,
        Plugins: testPluginMap,
    })
    defer c.Kill()
    addr, err := c.Start()
  ...
}
func helperProcess(s ...string) *exec.Cmd {
    cs := []string{"-test.run=TestHelperProcess", "--"}
    cs = append(cs, s...)
    env := []string{
        "GO_WANT_HELPER_PROCESS=1",
    }
    cmd := exec.Command(os.Args[0], cs...)
    cmd.Env = append(env, os.Environ()...)
    return cmd
}
func TestHelperProcess(t *testing.T) {
    if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
        return
    }
  ...
  cmd, args := args[0], args[1:]
    switch cmd {
    case "mock":
        fmt.Printf("tcp|:1234\n")
        <-make(chan int)
  ...
}

Leave a comment