サーバーサイドGo入門 #3 ~gin, gormを使う~
前回はこちら prelude.hatenablog.jp
今回作るもの
前回まででnet/http
でリクエストを受け取りdatabase/sql
でDBを扱うところまで書きました。これでアプリケーションとして成立しますが、今回はアプリケーションの振る舞いは変えずに有名なライブラリで書き替えていこうと思います。
使うかどうかに関わらずライブラリの列挙をします。詳細はREADMEを読んで頂ければと思います。スター数は2021/01/10現在の数字です。
net/http関連のライブラリ
- julienschmidt/httprouter(★12.2k)
- gorilla/mux(★13.5k)
- go-chi/chi(★8.7k)
- labstack/echo(★18.9k)
- gin-gonic/gin(★44.7k)
- gofiber/fiber(★11k)
ベンチマーク
Webフレームワークのベンチマークを計測しているリポジトリもあります。 github.com
database/sql関連のライブラリ
database/sql
の軽量ラッパーやORMなどなど。
ライブラリを使って書き換え
今回はスター数の多いgin
とgorm
を使おうと思います。どちらもREADME以外に公式サイトが存在します。
- Documentation | Gin Web Framework
- GORM - The fantastic ORM library for Golang, aims to be developer friendly.
ginの使用
まずはnet/http
をgin
を使ったものに書き替えます。
これまでhttp.HandleFunc
に関数を渡して処理を行っていました。ここをgin.Default
を呼び出して返ってくる*gin.Engine
に関数を渡すように変更します。
それに伴い、渡す関数のシグネチャがfunc (c *gin.Context)
になります。list
やadd
はその通りに変更します。
また、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
型の変数を宣言するように変更しています。それに伴いOpen
やSQLの書き方が若干変わります。
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
によってID
とCreatedAt
だけでなくUpdatedAt
とDeletedAt
も追加されました。
まとめ
今回は有名なライブラリをいくつか紹介して実際に使ってみました。標準パッケージを置き換えるようなライブラリを使いましたが、どれも基礎となるのはnet/http
やdatabase/sql
の知識です。ライブラリ選定時には何が土台となっているのかも確認できると良いですね。
雑談
体重が増えてしまったので散歩をするようにしたら毎日8,000歩程度歩くようになった。けどこのくらいってリモート前なら割と歩いていたような気もするからいかに日常的に歩かないようになってしまったのかがわかる。筋トレもして基礎代謝上げつつもっと歩かないと...。
散歩のお供は主にPodcast。英語の勉強時間を兼ねられると効率的なので歩きながらできるやり方を考えないと。
rebuild.fm
misreading.chat