Go에서 반복문은 오로지 for만 사용할 수 있다. Java는 for, while, map 등 반복문에 여러 기능이 가능했지만 Go에서는 for로만 사용 가능하다. 오히려 더 간편한 느낌이다.
테스트
func main() {
fmt.Print(superAdd(1,2,3,4,5,6))
}
func superAdd(numbers ...int) int {
sum := 0
for index, number := range numbers { // index(0,1...,n-1)와 number라는 index에 해당하는 값
fmt.Println(index, number)
}
return sum
}
물론, index가 필요하지 않은 경우 이전에 배웠던 _를 통하여 사용하지 않음을 명시해줄 수 있다.
for i:= 0; i< len(numbers); i++ {
fmt.Println(i, numbers[i])
}
function이 끝날 때, 추가적으로 무엇인가 동작할 수 있도록 하는 기능 Java에서 못봤던 기능인 것 같은데, 유용하게 쓰일 수 있는 기능 같다. ex) 이미지를 열거나 파일을 생성하고나서 defer로 이미지, 파일을 닫던가 삭제하는, API로 요청을 보낸다던가 등
타입은 위와같이 작성해주면 된다. 당연하게도 string 대신 bool, int와 같은 타입은 작성해준다면 아래와 같은 에러를 발생해주며 코드 작성을 도와준다.
또한, 당연하게도 상수는 재할당을 할 수 없기때문에 할당을 하려는 코드를 작성하면 아래와 같은 에러 문구로 코드 작성을 도와준다.
Variables
contstants와 비슷하다. const를 var로만 바꾸면 된다. constants 때 사용한 코드에서 const -> var로만 변경해주었다.
constants와 다른 점은 재할당이 가능하다는 점에서 다르다.
하지만, 변수를 선언해줄 때의 코드가 너무 길다는 단점이 존재한다. 이러한 불편한 점을 Go 개발자들도 인지하였는지 아래와 같은 코드로도 작성이 가능하다.
name := "qazyj"
이게 뭐지 싶겠지만 name에 마우스 커서를 위치시켜 보면 아래와 같은 문구를 볼 수 있다. var와 type을 생략할 수 있도록 도와준다. :=를 사용하면 Go에서 자동으로 적절한 type을 설정해준다. 이를 부르는 용어는 Go의 짧은 변수 선언이라고 한다.
그렇다면, 의문점이 생긴다. Go에서 선언된 값을 통하여 변수를 설정해주기 때문에 시간의 차이가 있지 않을까? 라는 생각이 들지만 알아본 바에 의하면 성능 차이는 거의 존재하지 않는다고 한다. var name string = "qazyj"는 지양하고, name := "qazyj"를 지향하면 보다 코드를 작성하는 시간을 단축시킬 수 있겠다.
가장 기본적으로 Go 파일을 컴파일하기 위해서는 프로그램의 시작점 Package를 main으로 설정해야 합니다.
main
packge main은 프로그램의 시작점입니다.
그렇기때문에, package main으로 작성된 .go 파일은 해당 파일을 컴파일 할 것이고 시작 패키지는 packge main입니다.
package 명이 main이 아닌 패키지는 기능을 패키징화 한다는 등의 이유가 될 수 있습니다.
그렇다면, 위와같이 package만 작성하고 컴파일을 하면 컴파일러가 정상적으로 컴파일 할 수 있을까요?
그렇지 않습니다. 에러를 읽어보면 아래와 같은 runtime 에러 로그를 보여줍니다.
function main is undeclared in the main packge
main package 안에 function main이 선언되어있지 않다는 친절하고 명확한 에러로 개발을 편리하게 도와줍니다.
package main
func main() {
}
에러의 안내대로 위와 같이 func main() {}을 생성해주고 돌려보면 정상적으로 돌아갑니다. 이러한 경험대로 Go라는 언어는 컴파일 하기 위해서는 main package가 패키지의 시작점이고, main package안에서 func main() {}이 프로그램을 읽는 출발점이라고 볼 수 있습니다.
Spring Framework에서 Spring Bean을 사용하여 객체지향 설계원칙을 지키는 방법에 대해서 공부해본 경험이 있었기때문에 궁금증이 생겼습니다. go에서는 Spring Bean과 같은 IoC(제어의 역전)을 지원해주는 도구는 없는가에 대한 궁금증이.
본론
go에서의 의존성 도구는 아래와 같은 도구들이 있습니다.
wire
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을 사용하면됩니다.
애플리케이션은 종속성 순서대로 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
}
기존에 생성코드로 만든 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 서버를 시작하고 중지하는 방법을 알려줍니다.
돌려보면 정상적으로 돌아가는 것 같지만 위에 적어놓은 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
이제 돌려보면 정상적으로 돌아갑니다. 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)
}
}
다음으로 *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
}
$ 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 해야합니다.
위 명령어로 컴파일을 하면 grpc.pb.go 파일이 생성된다. 후에 이 파일의 인터페이스로 코드를 작성하면 된다.
생성된 코드를 확인해보면 다음과 같다.
// 테스트
service Test {
rpc Show(TestRequest) returns (TestResponse);
}
.proto 파일에서 작성한 service가 client와 server 각각 interface로 생성되어있다.
// TestClient is the client API for Test service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type TestClient interface {
Show(ctx context.Context, in *TestRequest, opts ...grpc.CallOption) (*TestResponse, error)
}
// TestServer is the server API for Test service.
// All implementations must embed UnimplementedTestServer
// for forward compatibility
type TestServer interface {
Show(context.Context, *TestRequest) (*TestResponse, error)
mustEmbedUnimplementedTestServer()
}
그리고 아래와 같이 rpc로 생성한 기능이 생성된 것을 볼 수 있다. gRPC Client와 Server로 함수처럼 이용이 가능하며 내부 로직을 몰라도 사용이 가능하다. 매우 Simple하다.
// Client
type testClient struct {
cc grpc.ClientConnInterface
}
func NewTestClient(cc grpc.ClientConnInterface) TestClient {
return &testClient{cc}
}
func (c *testClient) Show(ctx context.Context, in *TestRequest, opts ...grpc.CallOption) (*TestResponse, error) {
out := new(TestResponse)
err := c.cc.Invoke(ctx, "/grpc.Test/Show", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// Server
// UnimplementedTestServer must be embedded to have forward compatible implementations.
type UnimplementedTestServer struct {
}
func (UnimplementedTestServer) Show(context.Context, *TestRequest) (*TestResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Show not implemented")
}
func (UnimplementedTestServer) mustEmbedUnimplementedTestServer() {}
그렇다면, gRPC Client와 Server의 코드를 작성해보자.
파일의 구조는 위와 같다.
server.go
package main
import (
"context"
"log"
"net"
pb "go/grpc"
"google.golang.org/grpc"
)
type server struct {
pb.TestServer
}
func (s *server) Show(ctx context.Context, in *pb.TestRequest) (*pb.TestResponse, error) {
log.Printf("Received: %v", in.GetRequestString())
return &pb.TestResponse{ResponseString: "Hello " + in.GetRequestString()}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterTestServer(s, &server{})
log.Printf("server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
server 측에선 특정 port로 연결을 대기해놓는다. 참고로 REST API의 경우 요청마다 연결을 성립하지만 gRPC의 경우 한번 연결을 해놓으면 해당 연결을 재사용하며 API를 주고 받는다.
그렇다면? gRPC는 하나의 server당 하나의 client만 연결할 수 있는가?? (후에 정리)
client.go
package main
import (
"context"
"log"
"time"
pb "go/grpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
// Set up a connection to the server.
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewTestClient(conn)
// Contact the server and print out its response.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r, err := c.Show(ctx, &pb.TestRequest{RequestString: "KYJ"})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Test: %s", r.GetResponseString())
}
Client에서는 주소:port로 connection을 만들고 connection으로 Client를 생성합니다. 그리고 해당 Client의 interface에 정의된 함수로 요청을 Server측으로 보낼 수 있습니다.