2022-02-23 / @syui

goのentでapi serverを作ってみた

最初は、gormを使っていました。

gormは非常に見通しがよく、わかりやすかったです。

package main

import (
	"fmt"
	"time"
	"os"
	"aicard/database"
	"net/http"
	"github.com/labstack/echo/v4"
	"gorm.io/gorm"
)

type User struct {
	Id    int    `json:"id" param:"id" gorm:"primary_key"`
	Name  string `json:"name" gorm:"unique,collate:utf8,length:7`
	CreatedAt time.Time
	UpdatedAt time.Time
	DeletedAt gorm.DeletedAt `gorm:"index"`
}

func hello(c echo.Context) error {
	return c.String(http.StatusOK, "Hello, World!")
}

func getUsers(c echo.Context) error {
	users := []User{}
	database.DB.Find(&users)
	return c.JSON(http.StatusOK, users)
}

func getUser(c echo.Context) error {
	user := User{}
	if err := c.Bind(&user); err != nil {
		return err
	}
	database.DB.Take(&user)
	return c.JSON(http.StatusOK, user)
}

func updateUser(c echo.Context) error {
	user := User{}
	id := c.Param("id")
	if err := c.Bind(&user); err != nil {
		return err
	}

	if err := database.DB.Where("id = ?", id).First(&user).Error; err != nil {
		fmt.Println(err)
	}
	database.DB.Save(&user)
	c.JSON(http.StatusOK, user)
	return nil
}

func createUser(c echo.Context) error {
	user := User{}
	if err := c.Bind(&user); err != nil {
		return err
	}
	database.DB.Create(&user)
	c.JSON(http.StatusCreated, user)
	return nil
}

func deleteUser(c echo.Context) error {
	id := c.Param("id")
	database.DB.Delete(&User{}, id)
	return c.NoContent(http.StatusNoContent)
}

func main() {
	e := echo.New()
	database.Connect()
	sqlDB, _ := database.DB.DB()
	defer sqlDB.Close()
	e.GET("/", hello)
	e.GET("/users", getUsers)
	e.GET("/users/:id", getUser)
	e.PUT("/users/:id", updateUser)
	e.POST("/users", createUser)
	e.DELETE("/users/:id", deleteUser)
	port := os.Getenv("PORT")
	if port == "" {
		port = "1323"
	}
	e.Logger.Fatal(e.Start(":" + port))
}

しかし、ent@0.9.0に追加されたようなonconflictの仕組みがなかったので、entを使いはじめました。

onconflictは、dbに既に一致するdateがある場合、errを出してくれます。これによって、例えば、同じユーザー名では登録できないようにすることが可能です。もちろん、gormでも可能ですが、entのほうが簡単にできます。

b := h.client.User.Create()
b.SetUsername(*d.Username).OnConflict()

それでは、entの基本的な使い方を紹介していきたいと思います。

まずは、project-dirを作り、その中にgo.modを作ります。go getなどする際は依存関連が示されるgo.sumが更新されていきます。

$ mkdir t
$ cd t
$ go mod init $project
$ cat go.mod

なお、herokuにdeployする際は、go.modには以下のようにverを指定してやらなければいけません。

module t

// +heroku goVersion go1.17
go 1.17

go.modのmoduleの名前は、ent/以下のファイルをimportする際に使います。ここでは、project-nameをtとしています。例えば、ent/entryをimportしたい場合は、t/ent/entryというpathを指定することになります。

entはschemaに作成されたファイルからコントロールし、dbのデータをclientと受け渡しする仕組みを構築できます。schemaを書きかえたら都度、go generateでコードファイルを更新できます。追加機能などもこの仕組からコードファイルに追記されていきます。

package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./schema

go mod initでgo.modを作成できたら、entをインストールして、entcコマンドを使えるようにします。通常はgoを使用しているとインストールされるbinaryにはpathが通っているはずですが、通っていなくてもgo runからurlを指定すれば実行可能です。

なお、go mod initent initで指定した名前は各ファイルに書かれますので適切なものにしてください。

$ go get -d entgo.io/ent/cmd/ent
$ ent init Entry
or
$ go run entgo.io/ent/cmd/ent init Entry

$ tree .
.
├── ent
│   ├── generate.go
│   └── schema
│       └── entry.go
├── go.mod
└── go.sum

ここで、entの通常の開発手順としては、(1)entc initしてschemaを作成する、(2)ent/schema/*.goを編集する、(3)go generate ./entを実行する、(4)main.goなどのメインファイルからclientを操作する、(5)go rungo buildする、という流れになります。

ということで、次はent/schema/entry.goを書きます。

package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
)

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.Int("age").
            Positive(),
        field.String("name").
            Default("unknown"),
    }
}

project-rootからgo generate ./entを実行します。すると、先程、initした名前のファイル、entry*.goが作成されています。これらをimportすることで、開発者はschemamainの編集に集中できるという感じになります。

$ go generate ./ent

$ tree .
.
├── ent
│   ├── client.go
│   ├── config.go
│   ├── context.go
│   ├── ent.go
│   ├── entry
│   │   ├── entry.go
│   │   └── where.go
│   ├── entry.go
│   ├── entry_create.go
│   ├── entry_delete.go
│   ├── entry_query.go
│   ├── entry_update.go
│   ├── enttest
│   │   └── enttest.go
│   ├── generate.go
│   ├── hook
│   │   └── hook.go
│   ├── migrate
│   │   ├── migrate.go
│   │   └── schema.go
│   ├── mutation.go
│   ├── predicate
│   │   └── predicate.go
│   ├── runtime
│   │   └── runtime.go
│   ├── runtime.go
│   ├── schema
│   │   └── entry.go
│   └── tx.go
├── go.mod
└── go.sum

次は、clientを操作するmain.goを作成していきます。project-rootにmain.goを置き、それをbuildします。

package main

import (
    "log"

    "t/ent"

    _ "github.com/lib/pq"
)

func main() {
    client, err := ent.Open("postgres","host=<host> port=<port> user=<user> dbname=<database> password=<pass>")
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()
}
$ go build
$ ./t

特に重要になる部分は以下のような書き方がされる箇所です。apiへpostされたdateを受け取り、それをdbに保存し、返すための処理を書くのに使われることが多い。

e := client.Entry.Create()
e.SetUser()

また、各種ファイルでのimportはproject-nameからのpathとなります。

import (
		"t/ent"
		"t/ent/entry"
		)

もちろん、projectをgithubにおいているなら、github.com/$USER/$PROJECT/entとしてもいいです。この場合、go.modのmoduleもそのように書き換えてください。

ここまでがentの基本的な部分です。ここからは、heroku-postgresonconflictに対応します。herokuはportが変わりますので、envから受け取らなければなりません。

まずは、heroku-postgresに対応します。

package schema

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

type Entry struct {
	ent.Schema
}

func (Entry) Fields() []ent.Field {
	return []ent.Field{

		field.String("user").
		MaxLen(8).
		Unique().
		Immutable(),

		field.Int("first").
		Unique().
		Immutable(),

		field.Time("created_at").
		Immutable().
		Default(func() time.Time {
			return time.Now()
		}),

	}
}

func (Entry) Edges() []ent.Edge {
	return nil
}
package main

import (
	"time"
	"t/ent"

	"context"
	"log"
	"os"

	"database/sql"
	entsql "entgo.io/ent/dialect/sql"
	"entgo.io/ent/dialect"
	_ "github.com/jackc/pgx/v4/stdlib"
)

type User struct {
	user string `json:"user"`
	first int `json:"first"`
	created_at time.Time `json:"created_at"`
}

func Open(databaseUrl string) *ent.Client {
	db, err := sql.Open("pgx", databaseUrl)
	if err != nil {
		log.Fatal(err)
	}
	drv := entsql.OpenDB(dialect.Postgres, db)
	return ent.NewClient(ent.Driver(drv))
}

func main() {
	url := os.Getenv("DATABASE_URL") + "?sslmode=require"
	client := Open(url)
	ctx := context.Background()
	if err := client.Schema.Create(ctx); err != nil {
		log.Fatal(err)
	}
	defer client.Close()
}
$ go build

次に、onconflictへの対応です。

package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./schema
$ go generate ./...

これでonconflictが使えるようになります。

b := h.client.Entry.Create()
b.SetUser(*d.User).OnConflict()

例えば、作成したapiにpostすると、同じ名前にはerrを返すようになります。

$ curl -X 'POST' -H 'Content-Type: application/json' -d '{"name":"syui"}' "https://$APP.herokuapp.com/u"
...ok

$ !!
...err

$ heroku logs

herokuにdeployする場合は、.gitignoreを書いて、git pushします。

$ cat .gitignore
*.db
t

$ git init
$ heroku git:remote -a $APP
$ git add .
$ git commit -m "first"
$ git push -u heroku main

ここからは、entで作るopen-apiの作り方を紹介します。基本的には、ent/entc.goを作成し、genarateします。

実は、entでopen-apiの作成はなかなかに面倒で、gormのほうが基本的にはわかりやすく、コードも見通しが良いものになるでしょう。ですが、規模が大きくなると、entのほうがよいapiを作れるのではないかと思います。

//go:build ignore

package main

import (
    "log"

    "entgo.io/contrib/entoas"
    "entgo.io/ent/entc"
    "entgo.io/ent/entc/gen"
    "github.com/ariga/ogent"
    "github.com/ogen-go/ogen"
)

func main() {
    spec := new(ogen.Spec)
    oas, err := entoas.NewExtension(entoas.Spec(spec))
    if err != nil {
        log.Fatalf("creating entoas extension: %v", err)
    }
    ogent, err := ogent.NewExtension(spec)
    if err != nil {
        log.Fatalf("creating ogent extension: %v", err)
    }
    err = entc.Generate("./schema", &gen.Config{}, entc.Extensions(ogent, oas))
    if err != nil {
        log.Fatalf("running ent codegen: %v", err)
    }
}
$ go get entgo.io/contrib/entoas ariga.io/ogent
$ entc init Todo
$ vim ent/schema/todo.go
package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
)

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

// Fields of the Todo.
func (Todo) Fields() []ent.Field {
	return []ent.Field{
		field.String("title"),
			field.Bool("done").
				Optional(),
	}
}

最初に作ったentryは削除します。

$ rm -rf ent/entry*
$ rm -rf ent/schema/entry*

続いて、ent/entc.goからファイルを再構成します。

package ent

//go:generate go run -mod=mod entc.go
$ go generate ./...
package main

import (
	"time"
	"t/ent"
	"net/http"
	"context"
	"log"
	"os"

	"database/sql"
	entsql "entgo.io/ent/dialect/sql"
	"entgo.io/ent/dialect"
	_ "github.com/jackc/pgx/v4/stdlib"
	_ "github.com/lib/pq"
	"t/ent/ogent"
	"entgo.io/ent/dialect/sql/schema"
)

type User struct {
	user string `json:"user"`
	first int `json:"first"`
	created_at time.Time `json:"created_at"`
}

func Open(databaseUrl string) *ent.Client {
	db, err := sql.Open("pgx", databaseUrl)
	if err != nil {
		log.Fatal(err)
	}
	drv := entsql.OpenDB(dialect.Postgres, db)
	return ent.NewClient(ent.Driver(drv))
}

func main() {
	url := os.Getenv("DATABASE_URL") + "?sslmode=require"
	client, err := ent.Open("postgres", url)
	//client, err := Open(url)
	if err := client.Schema.Create(context.Background(), schema.WithAtlas(true)); err != nil {
		log.Fatal(err)
	}
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	srv,err := ogent.NewServer(ogent.NewOgentHandler(client))
	if err != nil {
		log.Fatal(err)
	}
	if err := http.ListenAndServe(":" + port, srv); err != nil {
		log.Fatal(err)
	}
}
$ go get t
$ go run -mod=mod main.go
or
$ go build
$ ./t
--------------------
$ curl -X POST -H "Content-Type: application/json" -d '{"title":"Give ogen and ogent a Star on GitHub"}' localhost:8080/todos
{"id":1,"title":"Give ogen and ogent a Star on GitHub","done":false}

できました。

次は、userを作成するapiを作ってみます。

$ entc init Users
package schema

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

type Users struct {
	ent.Schema
}

func (Users) Fields() []ent.Field {
	return []ent.Field{

		field.String("user").
		NotEmpty().
		Immutable().
		MaxLen(7).
		Unique(),

		field.Int("first").
		Optional(),

		field.Int("draw").
		Optional(),

		field.Time("created_at").
		Optional().
		Default(func() time.Time {
			return time.Now()
		}),

		field.Time("updated_at").
		Optional().
		Default(func() time.Time {
			return time.Now()
		}),

	}
}

func (Users) Edges() []ent.Edge {
	return []ent.Edge{}
}
$ go generate ./...
$ go run -mod=mod main.go
--------------------
$ curl -X POST -H "Content-Type: application/json" -d '{"user":"syui"}' localhost:8080/users
{"id":1,"user":"syui","first":0,"draw":0,"created_at":"2022-02-24T10:15:33Z","updated_at":"2022-02-24T10:15:33Z"}

$ !!
{"code":409,"status":"Conflict","errors":"ent: constraint failed: pq: duplicate key value violates unique constraint \"users_user_key\""}

$ curl localhost:8080/users/1
{"id":1,"user":"syui","first":0,"draw":0,"created_at":"2022-02-24T10:15:33Z","updated_at":"2022-02-24T10:15:33Z"}

以上がentでopen-apiを作成する基本的な手順になると思われます。

entは、最初はとっつきにくいですが、触っているうちに慣れてくると思うので、おすすめです。

ref :

tag: go