サーバーサイドGo入門 #3 ~gin, gormを使う~

前回はこちら prelude.hatenablog.jp

今回作るもの

前回まででnet/httpでリクエストを受け取りdatabase/sqlでDBを扱うところまで書きました。これでアプリケーションとして成立しますが、今回はアプリケーションの振る舞いは変えずに有名なライブラリで書き替えていこうと思います。

使うかどうかに関わらずライブラリの列挙をします。詳細はREADMEを読んで頂ければと思います。スター数は2021/01/10現在の数字です。

net/http関連のライブラリ

軽量なルーターライブラリからフレームワークまで存在します。

ベンチマーク

Webフレームワークのベンチマークを計測しているリポジトリもあります。 github.com

database/sql関連のライブラリ

database/sqlの軽量ラッパーやORMなどなど。

ライブラリを使って書き換え

今回はスター数の多いgingormを使おうと思います。どちらもREADME以外に公式サイトが存在します。

ginの使用

まずはnet/httpginを使ったものに書き替えます。

これまでhttp.HandleFuncに関数を渡して処理を行っていました。ここをgin.Defaultを呼び出して返ってくる*gin.Engineに関数を渡すように変更します。
それに伴い、渡す関数のシグネチャfunc (c *gin.Context)になります。listaddはその通りに変更します。
また、APIのレスポンスは引数のresponse http.ResponseWriterを使っていましたが、ginではc *gin.Contextになるためc.JSONとしてレスポンスを返します。書き替えはこれだけです。

ちなみに、今回は使いませんがパスパラメータやクエリパラメータを受け取る場合は下記のように書きます。

func main() {
    router := gin.Default()

    // This handler will match /user/john but will not match /user/ or /user
    router.GET("/user/:name", func(c *gin.Context) {
        name := c.Param("name")
        c.String(http.StatusOK, "Hello %s", name)
    })

    // However, this one will match /user/john/ and also /user/john/send
    // If no other routers match /user/john, it will redirect to /user/john/
    router.GET("/user/:name/*action", func(c *gin.Context) {
        name := c.Param("name")
        action := c.Param("action")
        message := name + " is " + action
        c.String(http.StatusOK, message)
    })

    // For each matched request Context will hold the route definition
    router.POST("/user/:name/*action", func(c *gin.Context) {
        c.FullPath() == "/user/:name/*action" // true
    })

    router.Run(":8080")
}
func main() {
    router := gin.Default()

    // Query string parameters are parsed using the existing underlying request object.
    // The request responds to a url matching:  /welcome?firstname=Jane&lastname=Doe
    router.GET("/welcome", func(c *gin.Context) {
        firstname := c.DefaultQuery("firstname", "Guest")
        lastname := c.Query("lastname") // shortcut for c.Request.URL.Query().Get("lastname")

        c.String(http.StatusOK, "Hello %s %s", firstname, lastname)
    })
    router.Run(":8080")
}

また、パスのグルーピングもnet/httpと異なり簡便です。

func main() {
    router := gin.Default()

    // Simple group: v1
    v1 := router.Group("/v1")
    {
        v1.POST("/login", loginEndpoint)
        v1.POST("/submit", submitEndpoint)
        v1.POST("/read", readEndpoint)
    }

    // Simple group: v2
    v2 := router.Group("/v2")
    {
        v2.POST("/login", loginEndpoint)
        v2.POST("/submit", submitEndpoint)
        v2.POST("/read", readEndpoint)
    }

    router.Run(":8080")
}

他にもバリデーションやリダイレクトなど多くの機能が提供されています。READMEにサンプルコードが書いてありますので詳細はそちらを参照してください。

今回はかなり簡単なAPIでかつ細かく設定していないため、簡単に書き替えることができます。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    _ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

type MetaData struct {
    ID        int       `json:"id"`
    CreatedAt time.Time `json:"createdAt"`
}

type TODO struct {
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

type TODOObject struct {
    MetaData
    TODO
}

func list(c *gin.Context) {
    rows, err := DB.Query("SELECT * FROM todos")
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    defer rows.Close()

    todos := make([]TODOObject, 0)
    for rows.Next() {
        var todo TODOObject
        if err := rows.Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
            return
        }
        todos = append(todos, todo)
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    object := TODOObject{
        TODO: todo,
        MetaData: MetaData{
            CreatedAt: time.Now(),
        },
    }
    result, err := DB.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04"))
    if err != nil {
        panic(err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
        panic(err)
    }
    var responseData TODOObject
    if err := DB.QueryRow("SELECT * FROM todos WHERE id=?", lastInsertID).Scan(&responseData.ID, &responseData.Author, &responseData.Name, &responseData.Description, &responseData.DueDate, &responseData.CreatedAt); err != nil {
        panic(err)
    }

    c.JSON(http.StatusOK, responseData)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos", list)
    router.POST("/todo", add)
    return router
}

func main() {
    var err error
    DB, err = sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer DB.Close()
    if err := DB.Ping(); err != nil {
        panic(err)
    }

    if _, err := DB.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
        panic(err)
    }

    log.Println(setupRouter().Run(":8080"))
}

ライブラリがnet/httpを意識したものなのか、パスと関数を渡すようになっているため直感的に移行できました。

gormの使用

続いてgormを使用してdatabase/sqlを書き替えます。

DBとして*sql.DB型のグローバル変数を保持していましたが、gormで定義されている*gorm.DB型の変数を宣言するように変更しています。それに伴いOpenSQLの書き方が若干変わります。
IDや作成時間を保持していたTODOObjectも廃し、gorm.Modelを利用するように変更しています。 gorm.Modelは下記のような定義になっています。これをTODOに埋め込んで定義します。

type Model struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
}

gormのタグを含めたモデル周りの扱いは下記のドキュメントに記載されています。

Declaring Models | GORM - The fantastic ORM library for Golang, aims to be developer friendly.

// main.go

package main

import (
    "encoding/json"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var gormDB *gorm.DB

type TODO struct {
    gorm.Model
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

func list(c *gin.Context) {
    todos := make([]TODO, 0)
    if err := gormDB.WithContext(c).Find(&todos).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    if err := gormDB.WithContext(c).Create(&todo).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todo)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos", list)
    router.POST("/todo", add)
    return router
}

func main() {
    _gormDB, err := gorm.Open(mysql.Open("root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true"), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    db, err := _gormDB.DB()
    if err != nil {
        panic(err)
    }
    defer db.Close()

    if !_gormDB.Migrator().HasTable(&TODO{}) {
        if err := _gormDB.Migrator().CreateTable(&TODO{}); err != nil {
            panic(err)
        }
    }

    gormDB = _gormDB

    log.Println(setupRouter().Run(":8080"))
}

database/sqlと異なりRowsをループしてScanする必要がなくなり、記述量がかなり減ったことがわかります。また、gormはdatabase/sqlを内部的に利用しているため、CloseするためにDBという関数でdatabase/sql*sql.DBを取得しています。

curlで確認

$ curl -X POST http://localhost:8080/todo -d \
'{
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system",
  "dueDate": "2022-12-31T00:00:00Z"
}' | jq
{
  "ID": 1,
  "CreatedAt": "2021-01-11T12:40:00.365172+09:00",
  "UpdatedAt": "2021-01-11T12:40:00.365172+09:00",
  "DeletedAt": null,
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system",
  "dueDate": "2022-12-31T00:00:00Z"
}

$ curl http://localhost:8080/todos | jq
[
  {
    "ID": 1,
    "CreatedAt": "2021-01-11T03:40:00.365Z",
    "UpdatedAt": "2021-01-11T03:40:00.365Z",
    "DeletedAt": null,
    "author": "Rob",
    "name": "Develop Generics",
    "description": "Add feature of generic type system",
    "dueDate": "2022-12-31T00:00:00Z"
  }
]

gorm.ModelによってIDCreatedAtだけでなくUpdatedAtDeletedAtも追加されました。

まとめ

今回は有名なライブラリをいくつか紹介して実際に使ってみました。標準パッケージを置き換えるようなライブラリを使いましたが、どれも基礎となるのはnet/httpdatabase/sqlの知識です。ライブラリ選定時には何が土台となっているのかも確認できると良いですね。

雑談

体重が増えてしまったので散歩をするようにしたら毎日8,000歩程度歩くようになった。けどこのくらいってリモート前なら割と歩いていたような気もするからいかに日常的に歩かないようになってしまったのかがわかる。筋トレもして基礎代謝上げつつもっと歩かないと...。
散歩のお供は主にPodcast。英語の勉強時間を兼ねられると効率的なので歩きながらできるやり方を考えないと。 rebuild.fm misreading.chat