DEV Community

vultor-xyz
vultor-xyz

Posted on • Updated on

go(golang) ORM 찾기 #3 - ent

시리즈

Introduction

ent는 Facebook Open Source로 시작된 프로젝트이지만 현재는 Linux Foundation으로 이동한 프로젝트입니다. code first 전략을 취하고 있기 때문에 모델을 코드로 작성하면 그에 맞는 여러 가지 코드를 생성해 주는 방식을 취하고 있습니다.

Setting

Install Packages

패키지는 ORM인 ent와 마이그레이션을 담당하는 atlas 두 가지를 설치하겠습니다.

go get entgo.io/ent/cmd/ent@latest
go get ariga.io/atlas@latest
Enter fullscreen mode Exit fullscreen mode

Create Models

아래의 명령어로 모델을 생성하면 ./ent/schema 에 모델의 기본이 되는 코드가 생성됩니다.

go run entgo.io/ent/cmd/ent init User
Enter fullscreen mode Exit fullscreen mode

그 후 코드를 아래와 같이 작성하면 모델을 생성합니다. Fields에는 모델의 필드들에 대한 정의를 Edges에는 관계를 맺을 다른 모델에 대한 정의를 작성하게 됩니다.

package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/dialect"
    "entgo.io/ent/schema/edge"
    "entgo.io/ent/schema/field"
    "time"
)

// User holds the schema definition for the User entity.
type User struct {
    ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("first_name"),
        field.String("last_name"),
        field.Time("birthday").
            Optional().
            SchemaType(map[string]string{
                dialect.Postgres: "DATE",
            }),
        field.Time("updated_at").
            Default(time.Now).
            UpdateDefault(time.Now),
        field.Time("created_at").
            Default(time.Now).
            Immutable(),
    }
}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("tasks", Task.Type),
    }
}
Enter fullscreen mode Exit fullscreen mode

이 모델로부터 migration 코드가 생성되기 때문에 자세히 정의할수록 sql을 따로 조작할 필요성이 없게 됩니다. 그리고 SchemaType과 같은 기능을 이용하여 각각의 데이터베이스마다 특정 타입을 지정할 수 있다는 점도 긍정적으로 판단됩니다.

Migration

마이그레이션은 ent에서 직접 하는 것이 아니라 atlas라는 다른 패키지를 사용하여 진행하게 됩니다. 마이그레이션을 할 수 있는 방식은 declarative, versioned 방식을 지원하는데 declarative의 경우 자동으로 원하는 상태로 마이그레이션을 해주는 방식이기 때문에 개인적으로는 어떤 동작을 할 것인지 예상이 가지 않아서 좀 불안한 마음이 있습니다. 그래서 versioned migration으로 제작해 보았습니다.

Versioned으로 진행하게 되면 atlas에서는 sql 파일을 생성해 줍니다. 이 파일을 기반으로 실제 마이그레이션은 sqlboiler 예제에서도 사용한 golang-migrate를 사용하여 진행하였습니다. (다른 migration 툴도 지원하기 때문에 취향에 맞는 것을 사용하면 됩니다)

공식 문서를 보면 from client, from graph 방식을 지원하는데 테스트한 날(2022/06/10) 기준으로 from client 방식을 사용하려면 master branch를 기준으로 패키지를 설치해야지 정상적으로 작동했기 때문에 from graph 방식으로 진행해야 합니다.

아래와 같은 코드를 작성하면 마이그레이션을 위한 sql 파일을 생성할 수 있습니다.

func GenerateMigration() {
    basePath := "./example/ente"

    graphPath := basePath + "/ent/schema"
    graph, err := entc.LoadGraph(graphPath, &gen.Config{})
    if err != nil {
        log.Fatalln(err)
    }

    tbls, err := graph.Tables()
    if err != nil {
        log.Fatalln(err)
    }

    migrationPath := basePath + "/migrations"
    dir, err := migrate.NewLocalDir(migrationPath)
    if err != nil {
        log.Fatalln(err)
    }

    dlct, err := sql.Open(config.GetDBInfo())
    if err != nil {
        log.Fatalln(err)
    }

    m, err := schema.NewMigrate(dlct, schema.WithDir(dir))
    if err != nil {
        log.Fatalln(err)
    }

    if err := m.Diff(context.Background(), tbls...); err != nil {
        log.Fatalln(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

위의 내용을 실행하면 접속할 데이터베이스, migrations 폴더에 있는 sql 파일 내용, 작성된 모델을 비교하여 적절한 코드를 아래와 같은 파일을 생성해 줍니다.

-- create "users" table
CREATE TABLE "users"
(
    "id"         bigint            NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "first_name" character varying NOT NULL,
    "last_name"  character varying NOT NULL,
    "birthday"   date              NULL,
    "updated_at" timestamptz       NOT NULL,
    "created_at" timestamptz       NOT NULL,
    PRIMARY KEY ("id")
);
Enter fullscreen mode Exit fullscreen mode

또한 many-to-many 관계의 경우 중간에 두 테이블을 연결해주는 테이블을 자동으로 migration에서 생성해 주게 됩니다.

Code Generation

아래의 명령어를 수행하면 각종 쿼리를 수행하기 위한 파일을 생성합니다. (생성되는 파일이 매우 많기 때문에 생성된 파일은 생략하겠습니다)

go generate ./ent
Enter fullscreen mode Exit fullscreen mode

Usage

Connection

ent에서 connection을 맺을 때는 sqlboiler와 같이 기본 sql 패키지를 사용하지는 않습니다. 그렇다고 복잡하지는 않고 간단하게 연결됩니다.

func makeConnection() *ent.Client {
    conn, err := ent.Open(config.GetDBInfo())
    internal.LogFatal(err)

    return conn
}
Enter fullscreen mode Exit fullscreen mode

CRUD

모델에 값을 설정할 때는 set함수를 이용하여 설정합니다. where 부분의 경우 각각의 연산에 대하여 미리 정의된 함수를 제공하여 type safe한 방식으로 작성이 가능합니다.

func crud(ctx context.Context, c *ent.Client) {
    // Create
    newUser, err := c.User.Create().
        SetFirstName("Sample").
        SetLastName("User").
        SetBirthday(time.Now().AddDate(-30, 0, 0)).
        Save(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(newUser)

    // Read
    gotUser, err := c.User.
        Query().
        Where(user.FirstNameEQ("Sample")).
        First(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(gotUser)

    // Read List
    gotUsers, err := c.User.Query().
        Where(user.FirstNameEQ("Sample")).
        All(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(gotUsers)

    // Update
    updateRowsAff, err := c.User.Update().
        Where(user.LastNameEQ("User")).
        SetLastName("Unknown").
        Save(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(updateRowsAff)

    // Delete
    deleteRowsAff, err := c.User.Delete().
        Exec(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(deleteRowsAff)
}
Enter fullscreen mode Exit fullscreen mode

다른 ORM들과 크게 다르지 않고 type safe한 방식을 제공한다는 점을 긍정적으로 생각됩니다.

Relationship

모델 간 관계가 있는 경우에는 ent의 다른 필드들과 마찬가지로 set 함수를 이용하여 연결하고자 하는 모델을 지정하면 자동으로 연결해주기 때문에 손쉽게 사용이 가능하였습니다.

Eager loading의 경우 함수 형태로 어떤 관계를 불러올지 지정하면 출력하였을 때 불러온 관계를 함께 출력하는 부분은 간편하여 좋았습니다.

func queryWithRelation(ctx context.Context, c *ent.Client) {
    // Create with relationship
    newUser, err := c.User.Create().
        SetFirstName("Sample").
        SetLastName("User").
        SetBirthday(time.Now().AddDate(-30, 0, 0)).
        Save(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(newUser)

    newTask, err := c.Task.Create().
        SetUser(newUser).
        SetTitle("task 1").
        SetNote("note 1").
        SetStatus(task.StatusTodo).
        Save(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(newTask)

    // Read with relationship
    gotTask, err := c.Task.Get(ctx, newTask.ID)
    internal.LogFatal(err)
    internal.PrintJSONLog(gotTask)

    gotTaskUser, err := gotTask.QueryUser().First(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(gotTaskUser)

    // Eager Loading
    tasks, err := c.Task.Query().
        WithUser().
        All(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(tasks)
}

// Eager Loading Output
// {
//     "id": 5563,
//     "title": "task 1",
//     "note": "note 1",
//     "status": "todo",
//     "updated_at": "2022-06-10T07:44:50.994279Z",
//     "created_at": "2022-06-10T07:44:50.994279Z",
//     "edges": {
//         "user": {
//             "id": 5622,
//             "first_name": "Sample",
//             "last_name": "User",
//             "birthday": "1992-06-10T00:00:00Z",
//             "updated_at": "2022-06-10T07:44:50.992357Z",
//             "created_at": "2022-06-10T07:44:50.992358Z",
//             "edges": {}
//         }
//     }
// }
Enter fullscreen mode Exit fullscreen mode

Seed

ent는 bulk insert를 지원합니다. 그래서 대량으로 모델을 추가할 때 유용하게 사용할 수 있습니다.

func seed(ctx context.Context, c *ent.Client, count int) {
    if count <= 0 {
        return
    }

    rand.Seed(time.Now().UnixNano())

    var bulkUsers []*ent.UserCreate
    for i := 0; i < count; i++ {
        bulkUsers = append(bulkUsers, c.User.Create().
            SetFirstName("Sample").
            SetLastName("User").
            SetBirthday(time.Now().AddDate(-30, 0, 0)),
        )
    }
    newUsers, err := c.User.CreateBulk(bulkUsers...).Save(ctx)
    internal.LogFatal(err)

    var bulkTasks []*ent.TaskCreate
    for i := 0; i < count; i++ {
        bulkTasks = append(bulkTasks, c.Task.Create().
            SetUser(newUsers[rand.Intn(len(newUsers))]).
            SetTitle(fmt.Sprintf("Task %d", i)).
            SetNote(fmt.Sprintf("Note %d", i)).
            SetStatus(task.StatusTodo),
        )
    }
    _, err = c.Task.CreateBulk(bulkTasks...).Save(ctx)
    internal.LogFatal(err)

    log.Println("Seed finished")
}
Enter fullscreen mode Exit fullscreen mode

Aggregation

aggregation 쿼리도 큰 어려움 없이 손쉽게 구현 가능합니다. 디테일한 조작이 필요한 경우에는 func(s *sql.Selector) 를 제공하여 기본 sql 패키지를 이용한 조작도 가능합니다.

func aggregate(ctx context.Context, c *ent.Client) {
    var result []struct {
        Count     int `json:"count"`
        UserTasks int `json:"user_tasks"`
    }

    err := c.Task.Query().
        Order(func(s *sql.Selector) {
            s.OrderBy(sql.Desc("count"))
        }).
        Limit(5).
        GroupBy(task.UserColumn).
        Aggregate(ent.Count()).
        Scan(ctx, &result)

    internal.LogFatal(err)
    internal.PrintJSONLog(result)
}
Enter fullscreen mode Exit fullscreen mode

Pagination

pagination에 대하여 ent에서 특별히 제공하는 기능은 없고 일반적인 수준의 기능을 제공합니다. 다만 limit, offset을 사용할 때 정렬 기준을 명시하지 않으면 뭔가 이상동작(?)을 하는 경우가 있어서 이 부분은 신경 써주어야 합니다.

func pagination(ctx context.Context, c *ent.Client) {
    // Limit & Offset
    // Must use order for id based pagination
    for i := 0; i < 5; i++ {
        users, err := c.User.Query().
            Limit(3).
            Offset(i * 3).
            Order(ent.Asc(user.FieldID)).
            All(ctx)
        internal.LogFatal(err)
        internal.PrintJSONLog(users)
    }

    // Cursor
    lastUserID := 0
    for i := 0; i < 5; i++ {
        users, err := c.User.Query().
            Where(user.IDGT(lastUserID)).
            Limit(3).
            All(ctx)
        internal.LogFatal(err)
        internal.PrintJSONLog(users)
        lastUserID = users[len(users)-1].ID
    }
}
Enter fullscreen mode Exit fullscreen mode

Transform

ent에서 column을 가리는 방식은 크게 두 가지가 있습니다. 1) 모델 정의 시 Sensitive를 넣어서 가리기 2) 모델 쿼리시 Select를 사용하여 필요한 칼럼만 표기, 제 생각에는 비밀번호와 같이 어떤 경우에도 아웃풋으로 나가면 안 되는 칼럼에 대하여 1번 방법을, 임시로 모델의 output을 조작할 때는 2번의 방법을 사용하면 좋을 것 같습니다.

아래 코드에서는 모델의 칼럼을 숨기는 두 가지 방식에 대한 예제 코드입니다.

// Hide column in model

func (Task) Fields() []ent.Field {
    return []ent.Field{
        field.String("title"),
        field.String("note").
            Sensitive(),
        field.Enum("status").
            Values(
                "todo",
                "in_progress",
                "done",
            ),
        field.Time("updated_at").
            Default(time.Now).
            UpdateDefault(time.Now),
        field.Time("created_at").
            Default(time.Now).
            Immutable(),
    }
}
Enter fullscreen mode Exit fullscreen mode
// Hide column using select statement

func transform(ctx context.Context, c *ent.Client) {
    gotTask, err = c.Task.Query().
        Select(task.FieldID, task.FieldTitle).
        WithUser().
        First(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(gotTask)
}
Enter fullscreen mode Exit fullscreen mode

모델과 다른 모델의 관계를 출력할 때는 비교적 간단합니다. With{model} 구조로 호출해주면 자동으로 불러온 모델을 출력해 줍니다.

func transform(ctx context.Context, c *ent.Client) {
    // Print with relation
    gotTask, err := c.Task.Query().WithUser().First(ctx)
    internal.LogFatal(err)
    internal.PrintJSONLog(gotTask)
}
Enter fullscreen mode Exit fullscreen mode

transform의 경우에는 제가 생각하는 기본 기능과 거의 동일하게 동작하기 때문에 개인적으로는 방향성이 동일하여 좋았습니다.

Raw Query

ent에서 raw 쿼리를 사용하기 위해서는 ent/generate.go 파일을 수정하여 sql/execquery, sql/modifier 기능을 활성화해줘야 합니다. 따라서 아래와 같이 내용을 추가 후 코드를 한 번 더 생성해 줍니다.

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/execquery,sql/modifier ./schema
Enter fullscreen mode Exit fullscreen mode

위의 기능을 활성화해주면 QueryContext, ExecContext, Modifer 3가지 기능을 사용할 수 있게 됩니다. 이중 QueryContext, Contexture 는 기본 sql 패키지의 동일한 이름의 기능과 대응된다고 생각하시면 됩니다.

func rawQuery(ctx context.Context, c *ent.Client) {
    // QueryContext way
    rows, err := c.QueryContext(
        ctx,
        "SELECT id, title FROM tasks",
    )
    internal.LogFatal(err)
    for rows.Next() {
        var id int
        var title string
        if err := rows.Scan(&id, &title); err != nil {
            internal.LogFatal(err)
        }
        internal.PrintJSONLog(struct {
            ID    int    `json:"id"`
            Title string `json:"title"`
        }{
            id,
            title,
        })
    }
    err = rows.Close()
    internal.LogFatal(err)

    // Modifier way
    var result []struct {
        Count   string    `json:"count"`
        Created time.Time `json:"created"`
    }
    err = c.Task.Query().
        Modify(func(s *sql.Selector) {
            s.Select(
                sql.As(sql.Count("*"), "count"),
                sql.As("DATE(created_at)", "created"),
            ).GroupBy("DATE(created_at)")
        }).
        Scan(ctx, &result)
    internal.LogFatal(err)
    internal.PrintJSONLog(result)

    // ExecContext way
    _, err = c.ExecContext(ctx, "TRUNCATE TABLE  project_tasks, tasks, users")
    internal.LogFatal(err)
    log.Println("Truncate completed")
}
Enter fullscreen mode Exit fullscreen mode

QueryContext는 raw query를 수행한 다음 어떤 결과를 받아와야 할 때 사용한다고 보면 쉽습니다. 다만 위의 예제처럼 사용하기 굉장히 번거롭기 때문에 결과를 받아와야 하는 종류의 raw query를 사용한다면 차라리 Modifer 기능을 사용하는 것이 조금 더 편해 보입니다.

Modifer는 ent 쿼리에서 기본 sql 패키지를 사용하여 더 다양한 조작이 가능하게 해주는 기능입니다. 따라서 위의 예제와 같이 완전 raw query는 아니더라도 그에 상응하는 기능을 구현할 수 있다고 보면 좋습니다.

마지막으로 ExecContext는 결과를 받아올 필요가 없는 raw query를 사용할 때 사용하는 기능이라고 볼 수 있습니다. 몇몇 특수한 경우에 사용될 수 있을 것 같네요.

ent의 raw query는 정말 특수한 경우를 제외하고 raw query를 사용할 생각은 안 하는 게 좋다고 말하는 것 같은 인상을 받았습니다. 사실 orm을 사용하면서 raw query를 작성할 경우는 드물기는 하지만 또 없으면 어떤 일이 생길지 모르기 때문에 일종의 보험 역할이라고 생각하여 이 정도 기능을 제공한다면 나쁘지는 않아 보입니다.

Hook

ent에서는 2가지 방식으로 hook을 설정할 수 있습니다. 1) 모델 정의 부분에서 hook에 대한 정의 2) ent.Client에 직접 훅을 설정, 각각의 용도에 맞게 사용할 수 있도록 다양한 옵션을 제공하는 점을 좋다고 봅니다.

// Define hook in model

// Hook of the Task.
func (Task) Hook() []ent.Hook {
    return []ent.Hook{
        hook.On(
            func(next ent.Mutator) ent.Mutator {
                return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
                    v, err := next.Mutate(ctx, m)
                    if err != nil {
                        return nil, err
                    }
                    internal.PrintJSONLog(v)
                    return v, err
                })
            },
            ent.OpCreate,
        ),
    }
}
Enter fullscreen mode Exit fullscreen mode
// Define hook in ent.Client

import (
     entgo"entgo.io/ent"
    "project_path/ent"
)

func hook(ctx context.Context, c *ent.Client) {
    // You can do this in schema Hook() [].ent.Hook function
    c.Use(func(next entgo.Mutator) entgo.Mutator {
        return ent.MutateFunc(func(ctx context.Context, m entgo.Mutation) (entgo.Value, error) {
            v, err := next.Mutate(ctx, m)
            if err != nil {
                return nil, err
            }
            internal.PrintJSONLog(v)
            return v, err
        })
    })

    _, err := c.Task.Create().
        SetTitle("task 1").
        SetNote("note 1").
        SetStatus(task.StatusTodo).
        Save(ctx)
    internal.LogFatal(err)
}
Enter fullscreen mode Exit fullscreen mode

hook을 설정하면 모델이 실제로 데이터베이스에 접근하기 전에 조작할 수 있고 또 데이터베이스를 조작한 후의 결과를 조회할 수 있는 부분도 쉽게 구현할 수 있기 때문에 유용한 기능으로 보이네요.

Summary

개인적으로 ent는 code first 전략을 취하고 있기 때문에 성향과 잘 맞는 패키지입니다. 특히 다양한 데이터베이스를 지원하고 그에 대한 DDL을 습득할 필요 없이 패키지에서 생성해 준다는 점, 관계 데이터를 불러오고 출력할 때의 기본 행동이 제가 추구하는 방향과 비슷하다는 것들은 장점으로 볼 수 있습니다.

또한 여기에서는 소개하지 않았지만 정의된 모델에 몇 가지 설정하면 rest api + swagger, graphql, rpc 관련 기본 코드를 자동으로 생성해주는 기능이 있어서 사실 ORM뿐만 아니라 이를 기반으로 생태계를 확장하는 방향을 취하고 있습니다.

저는 비교적 간단한 기능들은 모두 자동화하고 조금 더 중요한 로직을 만드는데 시간을 사용하는 것을 추구하기 때문에 이런 생태계의 확장은 환영하는 입장입니다.

다만 이런 식의 다양한 기능을 제공하는 패키지에 관하여 거부감을 품고 계시는 분들도 있을 것 같은데요. 그렇다면 사실 사용하지 않을 옵션을 제공하기 때문에 ORM으로서 기능을 제한하여 사용하시면 될 것 같습니다.

Top comments (0)