646 lines
15 KiB
Markdown
646 lines
15 KiB
Markdown
|
+++
|
||
|
date = "2022-02-23"
|
||
|
tags = ["go"]
|
||
|
title = "goのentでapi serverを作ってみた"
|
||
|
slug = "golang"
|
||
|
+++
|
||
|
|
||
|
最初は、[gorm](https://github.com/go-gorm/gorm)を使っていました。
|
||
|
|
||
|
gormは非常に見通しがよく、わかりやすかったです。
|
||
|
|
||
|
```go:main.go
|
||
|
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](https://entgo.io/ja/docs/code-gen/)を使いはじめました。
|
||
|
|
||
|
`onconflict`は、dbに既に一致するdateがある場合、errを出してくれます。これによって、例えば、同じユーザー名では登録できないようにすることが可能です。もちろん、gormでも可能ですが、entのほうが簡単にできます。
|
||
|
|
||
|
```go:ent/http/create.go
|
||
|
b := h.client.User.Create()
|
||
|
b.SetUsername(*d.Username).OnConflict()
|
||
|
```
|
||
|
|
||
|
それでは、entの基本的な使い方を紹介していきたいと思います。
|
||
|
|
||
|
まずは、project-dirを作り、その中にgo.modを作ります。`go get`などする際は依存関連が示されるgo.sumが更新されていきます。
|
||
|
|
||
|
```sh
|
||
|
$ mkdir t
|
||
|
$ cd t
|
||
|
$ go mod init $project
|
||
|
$ cat go.mod
|
||
|
```
|
||
|
|
||
|
なお、herokuにdeployする際は、`go.mod`には以下のようにverを指定してやらなければいけません。
|
||
|
|
||
|
```go:go.mod
|
||
|
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でコードファイルを更新できます。追加機能などもこの仕組からコードファイルに追記されていきます。
|
||
|
|
||
|
```go:ent/generate.go
|
||
|
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 init`や`ent init`で指定した名前は各ファイルに書かれますので適切なものにしてください。
|
||
|
|
||
|
```sh
|
||
|
$ 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 run`や`go build`する、という流れになります。
|
||
|
|
||
|
ということで、次は`ent/schema/entry.go`を書きます。
|
||
|
|
||
|
```go: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することで、開発者は`schema`と`main`の編集に集中できるという感じになります。
|
||
|
|
||
|
```sh
|
||
|
$ 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します。
|
||
|
|
||
|
```go:main.go
|
||
|
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()
|
||
|
}
|
||
|
```
|
||
|
|
||
|
```sh
|
||
|
$ go build
|
||
|
$ ./t
|
||
|
```
|
||
|
|
||
|
特に重要になる部分は以下のような書き方がされる箇所です。apiへpostされたdateを受け取り、それをdbに保存し、返すための処理を書くのに使われることが多い。
|
||
|
|
||
|
```go:main.go
|
||
|
e := client.Entry.Create()
|
||
|
e.SetUser()
|
||
|
```
|
||
|
|
||
|
また、各種ファイルでのimportはproject-nameからのpathとなります。
|
||
|
|
||
|
```go:main.go
|
||
|
import (
|
||
|
"t/ent"
|
||
|
"t/ent/entry"
|
||
|
)
|
||
|
```
|
||
|
|
||
|
もちろん、projectをgithubにおいているなら、`github.com/$USER/$PROJECT/ent`としてもいいです。この場合、go.modのmoduleもそのように書き換えてください。
|
||
|
|
||
|
ここまでがentの基本的な部分です。ここからは、`heroku-postgres`や`onconflict`に対応します。herokuはportが変わりますので、envから受け取らなければなりません。
|
||
|
|
||
|
まずは、heroku-postgresに対応します。
|
||
|
|
||
|
|
||
|
```ent/schema/entry.go
|
||
|
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
|
||
|
}
|
||
|
```
|
||
|
|
||
|
|
||
|
```go:main.go
|
||
|
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()
|
||
|
}
|
||
|
```
|
||
|
|
||
|
```sh
|
||
|
$ go build
|
||
|
```
|
||
|
|
||
|
次に、`onconflict`への対応です。
|
||
|
|
||
|
```go:ent/generate.go
|
||
|
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`が使えるようになります。
|
||
|
|
||
|
```go:main.go
|
||
|
b := h.client.Entry.Create()
|
||
|
b.SetUser(*d.User).OnConflict()
|
||
|
```
|
||
|
|
||
|
例えば、作成したapiにpostすると、同じ名前にはerrを返すようになります。
|
||
|
|
||
|
```sh
|
||
|
$ 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`します。
|
||
|
|
||
|
```sh
|
||
|
$ 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:ent/entc.go
|
||
|
//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)
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
|
||
|
|
||
|
```sh
|
||
|
$ go get entgo.io/contrib/entoas ariga.io/ogent
|
||
|
$ entc init Todo
|
||
|
$ vim ent/schema/todo.go
|
||
|
```
|
||
|
|
||
|
```go: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は削除します。
|
||
|
|
||
|
```sh
|
||
|
$ rm -rf ent/entry*
|
||
|
$ rm -rf ent/schema/entry*
|
||
|
```
|
||
|
|
||
|
続いて、ent/entc.goからファイルを再構成します。
|
||
|
|
||
|
```go:ent/genarate.go
|
||
|
package ent
|
||
|
|
||
|
//go:generate go run -mod=mod entc.go
|
||
|
```
|
||
|
|
||
|
```sh
|
||
|
$ go generate ./...
|
||
|
```
|
||
|
|
||
|
```go:main.go
|
||
|
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)
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
```sh
|
||
|
$ 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を作ってみます。
|
||
|
|
||
|
```sh
|
||
|
$ entc init Users
|
||
|
```
|
||
|
|
||
|
```go:ent/schema/users.go
|
||
|
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{}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
```sh
|
||
|
$ 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 :
|
||
|
|
||
|
- https://entgo.io/ja/docs/tutorial-setup
|
||
|
|
||
|
- https://entgo.io/ja/blog/2021/08/05/announcing-upsert-api/
|
||
|
|
||
|
- https://entgo.io/ja/blog/2021/09/10/openapi-generator/
|
||
|
|
||
|
- https://entgo.io/ja/blog/2022/02/15/generate-rest-crud-with-ent-and-ogen/
|