🍸

【Go】GinでREST APIを作ってみる(curlコマンドで動作確認)

2022/03/22に公開

はじめに

みんながこぞってgo1.18のジェネリクスしている中、
書いている途中だったこの記事を仕上げます。
Ginの記事はたくさんありますが、Dockerで立てたDBに接続するところまでやっている
記事はあまり見かけなかったので、まとめてみました。
今回はcurlコマンドで動作確認までやっています。

環境

  • go 1.17
  • github.com/gin-gonic/gin v1.7.7
  • github.com/go-sql-driver/mysql v1.4.1
  • github.com/go-xorm/xorm v0.7.9

前提

DBはDockerで立てたMySQLを使います。
ORMはXormです。
Ginの概要などは、既にたくさんの記事で紹介されていますし、日本語訳の公式がある為、ここでは書かないです。
Ginについて詳しく知りたい方は公式をご参考ください。

クイックスタート

公式にも記載がありますが、簡単に触れたい場合は、こんな感じで使えます。

main.go

r := gin.Default()
	r.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message":"Hello World",
		})
	})
	r.Run() // 0.0.0.0:8080 でサーバーを立てます。

こちらをmain.goに記載して、go run main.goでサーバーを立てて、
ブラウザでlocalhost:8080/を入力すると、{"message":"Hello World"}と表示されます。

ゴール

  • go run main.goでサーバーを立てて、curlコマンドでCRUDを実行し、Dockerで立てたDBのデータを操作する事ができる。

作ってみた

ソースツリー

こんな感じになりました。
なんちゃってレイヤードアーキテクチャです。
main.goでルーティング→handler層→service層でCRUD処理という感じです。

.
├── README.md
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   └── user.go
├── infra
│   └── xorm.go
├── main.go
├── model
│   └── user.go
└── service
    ├── service.go
    └── user.go

docker-compose.yml

DBはMySQLを使います。

version: "3"
services:
  db:
    image: mysql:5.7
    environment:
    - MYSQL_DATABASE=sample_db
    - MYSQL_ROOT_PASSWORD=root
    command: >
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_general_ci
    ports:
      - 3306:3306

main.go

main.goでやっていること。

  • 1.infra層でXormを使ってMySQLに接続してます。
  • 2.ルーティングで、service層まで処理が走りますが、engineを渡す為に、NewServiceでサービスを別で作成し、engineを渡しています。factoryという名前でサービスを作成してます。
  • 3.最後にengineを閉めます。
  • 4.Ginはgin.Newgin.Defaultで初期化出来ます。gin.Newがすっぴんで、gin.DefaultはNewにloggerと、recoveryのミドルウェアが入っているものです。
  • 5.2で作ったfactoryをginにMiddlewareとして渡します。
  • 6.v1というグループを作成してます。グループ毎にmiddlewareとか設定出来るのですが、今回はやっていない為、グループになっている意味はありません。(v1というpathが増えるだけ)
  • 7.RESTのルーティングです。POST:新規作成,GETALL:全件取得,GETONE:単体取得,PUT:更新,DELETE:削除の5つを実装してます。
  • 8.デフォルトのポートは8080ですが、下記のように3000とか指定出来ます。
func main() {
	// 1.engineを作成します
	engine := infra.DBInit()

	// 2.サービスを作成します
	factory := service.NewService(engine)

	// 3.最後にengineを閉めます
	defer func() {
		log.Println("engine closed")
		engine.Close()
	}()

	// 4.Ginを作成します
	g := gin.Default()

	// 5.サービス層のMiddlewareを作成します
	g.Use(service.ServiceFactoryMiddleware(factory))

	// 6.v1というグループを作成します
	routes := g.Group("/v1")

	// 7.ルーティングです
	{
		routes.POST("/users", handler.Create)
		routes.GET("/users", handler.GetAll)
		routes.GET("/users/:user-id", handler.GetOne)
		routes.PUT("/users/:user-id", handler.Update)
		routes.DELETE("/users/:user-id", handler.Delete)
	}
	// 8.デフォルトは8080です
	g.Run(":3000")
}

infra/xorm.go

ここで、MySQLと接続してます。

func DBInit() *xorm.Engine {
	engine, err := xorm.NewEngine("mysql", "root:root@tcp([127.0.0.1]:3306)/sample_db?charset=utf8mb4&parseTime=true")
	if err != nil {
		log.Fatal(err)
	}

	// xormで使ったSQLをログに吐きます
	engine.ShowSQL(true)

	// ユーザーテーブルが存在するかチェック
	exist, err := engine.IsTableExist("users")
	if err != nil {
		log.Fatal(err)
	}

	// 存在しなければテーブルを作成します
	if !exist {
		engine.CreateTables(&model.Users{})
	}

	return engine
}

model/user.go

input用の構造体と、DBにinsertする用の構造体を分けています。
構造体の名前が複数形なのは、XormのCreateTableの時に、user構造体だったら、userテーブルという名前になってしまうから...。
なんか方法あるんだろうけど、そこまで調べれていません。

package model

import "time"

type Users struct {
	ID        int       `xorm:"id pk autoincr"`
	Name      string    `xorm:"name"`
	Address   string    `xorm:"address"`
	CreatedAt time.Time `xorm:"created_at"`
	UpdatedAt time.Time `xorm:"updated_at"`
}

type UserInput struct {
	Name    string `json:"name" binding:"required" xorm:"name"`
	Address string `json:"address" binding:"required" xorm:"address"`
}

service/service.go

ginのMiddlewereにenginを渡す為にコネコネしてます。
もっと簡単に実装出来ると思いますが、一旦これで..。

type Service struct {
	engine *xorm.Engine
}

func NewService(engine *xorm.Engine) Servicer {
	return &Service{engine}
}

type Servicer interface {
	NewUser() *Users
}

func (s *Service) NewUser() *Users {
	return NewUsers(s.engine)
}

const (
	ServiceKey = "service_factory"
)

func ServiceFactoryMiddleware(svc Servicer) gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Set(ServiceKey, svc)
		c.Next()
	}
}

handler/user.go

バインドするには、数種類メソッドがあります。
ShouldBindShouldBindWithの違いは、複数回呼び出せるかどうかです。
ShouldBindは複数回呼び出せないので、下記のCreateメソッドの中で、2回使用すると、2回目は常にエラーとなります。(https://gin-gonic.com/ja/docs/examples/bind-body-into-dirrerent-structs/)
他にもMustBindとかもあります。こちらはエラーハンドリング要らないようです。(https://qiita.com/ko-watanabe/items/64134c0a3871856fdc17)

func Create(c *gin.Context) {
	input := model.UserInput{}

	// ヘッダーのJSONをinputにバインドします
	err := c.ShouldBindWith(&input, binding.JSON)
	if err != nil {
		c.String(http.StatusBadRequest, "Bad request")
		return
	}

	// サービス層をNewします
	service := c.MustGet(service.ServiceKey).(service.Servicer)
	user := service.NewUser()

	// サービス層のCreateメソッドを呼び出します
	payload, err := user.Create(&input)
	if err != nil {
		log.Fatal(err)
	}

	// ステータス200と、payloadを返します
	c.JSON(http.StatusOK, payload)
}

func GetAll(c *gin.Context) {
	service := c.MustGet(service.ServiceKey).(service.Servicer)
	user := service.NewUser()
	payload, err := user.GetAll()
	if err != nil {
		log.Fatal(err)
	}

	c.JSON(http.StatusOK, payload)
}

func GetOne(c *gin.Context) {
	// user-idのパラメータを数字に変換します
	userID, err := strconv.Atoi((c.Param("user-id")))
	if err != nil {
		log.Fatal(err)
	}
	service := c.MustGet(service.ServiceKey).(service.Servicer)
	user := service.NewUser()
	payload, err := user.GetOne(userID)
	if err != nil {
		log.Fatal(err)
	}

	c.JSON(http.StatusOK, payload)
}

func Update(c *gin.Context) {
	userID, err := strconv.Atoi((c.Param("user-id")))
	if err != nil {
		log.Fatal(err)
	}
	input := model.UserInput{}
	err = c.ShouldBindWith(&input, binding.JSON)
	if err != nil {
		c.String(http.StatusBadRequest, "Bad request")
		return
	}
	service := c.MustGet(service.ServiceKey).(service.Servicer)
	user := service.NewUser()

	payload, err := user.Update(&input, userID)
	if err != nil {
		log.Fatal(err)
	}

	c.JSON(http.StatusOK, payload)
}

func Delete(c *gin.Context) {
	userID, err := strconv.Atoi((c.Param("user-id")))
	if err != nil {
		log.Fatal(err)
	}
	service := c.MustGet(service.ServiceKey).(service.Servicer)
	user := service.NewUser()
	err = user.Delete(userID)
	if err != nil {
		log.Fatal(err)
	}

	c.JSON(http.StatusOK, "削除されました")
}

service/user.go

ここが、DBに直接作用するところです。
基本的なXormでのCRUDになっていると思います。

type Users struct {
	engine *xorm.Engine
}

func NewUsers(engine *xorm.Engine) *Users {
	u := Users{
		engine: engine,
	}
	return &u
}

func (u *Users) Create(input *model.UserInput) (*model.Users, error) {
	now := time.Now()
	user := model.Users{
		Name:      input.Name,
		Address:   input.Address,
		CreatedAt: now,
		UpdatedAt: now,
	}
	_, err := u.engine.Table("users").Insert(&user)
	if err != nil {
		return nil, err
	}

	return &user, nil
}

func (u *Users) GetOne(userID int) (*model.Users, error) {
	user := model.Users{}
	_, err := u.engine.Table("users").Where("id = ?", userID).Get(&user)
	if err != nil {
		return nil, err
	}
	return &user, nil
}

func (u *Users) GetAll() ([]*model.Users, error) {
	users := []*model.Users{}
	err := u.engine.Table("users").Find(&users)
	if err != nil {
		return nil, err
	}
	return users, nil
}

func (u *Users) Update(input *model.UserInput, userID int) (*model.Users, error) {
	user := model.Users{
		Name:    input.Name,
		Address: input.Address,
	}
	_, err := u.engine.Table("users").Where("id = ?", userID).Update(&user)
	if err != nil {
		return nil, err
	}
	return u.GetOne(userID)
}

func (u *Users) Delete(userID int) error {
	user := model.Users{}
	_, err := u.engine.Table("users").Where("id = ?", userID).Delete(&user)
	if err != nil {
		return err
	}
	return nil
}

curlコマンドで実行してみる。

POST

コマンド

curl -X POST "http://localhost:3000/v1/users" -H "Content-Type: application/json" -d '{"name": "太郎", "address": "大阪府"}'

結果

{"ID":1,"Name":"太郎","Address":"大阪府","CreatedAt":"2022-03-22T00:04:30.927013+09:00","UpdatedAt":"2022-03-22T00:04:30.927013+09:00"} 
[xorm] [info]  2022/03/22 00:04:30.927194 [SQL] INSERT INTO `users` (`name`,`address`,`created_at`,`updated_at`) VALUES (?, ?, ?, ?) []interface {}{"太郎", "大阪府", "2022-03-22 00:04:30", "2022-03-22 00:04:30"}
[GIN] 2022/03/22 - 00:04:30 | 200 |   61.976591ms |             ::1 | POST     "/v1/users"

GET

コマンド

curl -X GET "http://localhost:3000/v1/users/1"

結果

{"ID":1,"Name":"太郎","Address":"大阪府","CreatedAt":"2022-03-22T00:04:30+09:00","UpdatedAt":"2022-03-22T00:04:30+09:00"}             
[xorm] [info]  2022/03/22 00:06:27.162554 [SQL] SELECT `id`, `name`, `address`, `created_at`, `updated_at` FROM `users` WHERE (id = ?) LIMIT 1 []interface {}{1}
[GIN] 2022/03/22 - 00:06:27 | 200 |   24.046224ms |             ::1 | GET      "/v1/users/1"

コマンド

curl -X GET "http://localhost:3000/v1/users"

結果

{"ID":1,"Name":"太郎","Address":"大阪府","CreatedAt":"2022-03-22T00:04:30+09:00","UpdatedAt":"2022-03-22T00:04:30+09:00"} 
[GIN-debug] redirecting request 301: /v1/users --> /v1/users
[xorm] [info]  2022/03/22 00:08:56.867604 [SQL] SELECT `id`, `name`, `address`, `created_at`, `updated_at` FROM `users`
[GIN] 2022/03/22 - 00:08:56 | 200 |   10.582069ms |             ::1 | GET      "/v1/users"

PUT

コマンド

curl -X PUT -H "Content-Type: application/json" -d '{"name": "次郎", "address": "東京都"}' "http://localhost:3000/v1/users/1"

結果

{"ID":1,"Name":"次郎","Address":"東京都","CreatedAt":"2022-03-21T20:11:26+09:00","UpdatedAt":"2022-03-21T20:11:26+09:00"}               
[xorm] [info]  2022/03/22 00:11:32.780754 [SQL] UPDATE `users` SET `name` = ?, `address` = ? WHERE (id = ?) []interface {}{"次郎", "東京都", 1}
[xorm] [info]  2022/03/22 00:11:32.791332 [SQL] SELECT `id`, `name`, `address`, `created_at`, `updated_at` FROM `users` WHERE (id = ?) LIMIT 1 []interface {}{1}
[GIN] 2022/03/22 - 00:11:32 | 200 |   20.979005ms |             ::1 | PUT      "/v1/users/1"

DELETE

コマンド

curl -X DELETE "http://localhost:3000/v1/users/1"

結果

"削除されました"
[xorm] [info]  2022/03/22 00:12:56.459418 [SQL] DELETE FROM `users` WHERE (id = ?) []interface {}{1}
[GIN] 2022/03/22 - 00:12:56 | 200 |   15.044561ms |             ::1 | DELETE   "/v1/users/1"

さいごに

一通り、curlコマンドで動きました。
次はジェネリクスの波に乗りたいと思います。
改善点あると思いますので、ご指摘頂けたら嬉しいです。

参考

https://gin-gonic.com/ja/docs/

Discussion