9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

gin,gormを使ってgolangでRailsっぽい構成のAPIを作った

Last updated at Posted at 2020-03-10

#概要

  • 仕事でgolangを使ってみようという話になりAPIを作りました。
  • 自分自身も他メンバーもRails使いが多いのでRailsに寄せてMVC構成で作りました。
  • golangに慣れたメンバーが少なかったのでできるだけメジャーで情報が多そうなフレームワークを使おうと考えてginを採用しました。
  • 同様にORMもgormがメジャーなようだったので採用しました。
  • golang自体が手探りの状態だったので主に以下を参考にさせて頂きました。
  • 自分でもサンプルAPIとしてgin-gorm-rails-like-sample-apiを作ってみましたので紹介のためにこの記事を書きました。
    • 想定読者として自分のようにRails辺りで開発してきてgolangに手を出してみようと考えている方です。参考になりましたら幸いです。

全体構成

├── README.md
├── config
│   ├── config.go
│   └── development.yml
├── controller
│   ├── book_controller.go
│   └── shop_controller.go
├── db
│   └── db.go
├── docker
│   └── go
│       └── Dockerfile
├── docker-compose.yml
├── etc
│   └── development
│       ├── mysql
│       │   └── my.cnf
│       └── nginx.conf.d
│           └── routing.conf
├── go.mod
├── go.sum
├── main.go
├── model
│   ├── book.go
│   ├── entity
│   │   ├── book.go
│   │   └── shop.go
│   └── shop.go
├── server
│   └── server.go
├── sql
│   └── mysql
│       └── seed.sql
└── view
    ├── book.go
    ├── convert_function.go
    ├── schemas.go
    └── shop.go

  • 基本的に参考にさせて頂いたオレオレ構成 APIと同じです。

    • 大きく違う点としてはdocker-compose.ymlなどdocker関連のファイルが追加されていることです。
    • 他に違う点としてビジネスロジックを書く場所をserviceからmodelに置き換えている点ですね。
      • 概要に書いた通りRailsを使っているメンバーが多いので名称も合わせました。
      • またmodelの中でリソース毎に細かくパッケージを分けずに一つのmodelパッケージにまとめました。
        • パッケージをまとめることで特に問題が発生せず、また循環参照が発生しにくくなるというメリットが会ったためです。
          • 逆にまとめることのデメリットがよく分かっていないためコメント欄でご指摘頂けると幸いです。
      • またviewも追加しています。
        • 後で見ますがmodelではsqlの結果を取ることに留めて置いてviewで実際に返却するjsonの値を設定しています。
        • Railsのviewに当たる箇所で有り責務を分解できたかなと思います。
          • 「このAPIの戻り値ではこのキーはいらない!」といった要求がクライアントサイドから会ったときにAPI単位で柔軟に対応できるメリットがあると思います。
  • 以下では内容を抜粋しながら細かく見ていきます。

Main: main.go

func main() {
	db.Init()
	server.Init()
	db.Close()
}

  • goのコンテナからこのファイルが実行されます。
  • DB(mysqlコンテナ)への接続情報が記載されたdbとルーティングが記載されたserverが実行されます。

DB接続: db.go

func Init() {
	configs, err := config.GetConfigs()
	if err != nil {
		panic(err)
	}

	Db, err = gorm.Open(configs.Database.Dialect, configs.Database.DataSource)
	Db.LogMode(true)
	if err != nil {
		panic(err)
	}
	autoMigration()
}

func autoMigration() {
	Db.AutoMigrate(&entity.Shop{})
	Db.AutoMigrate(&entity.Book{})
}

  • DB接続情報が記載されていますcongigに記載された内容をconfig.GetConfigs()で取得してgorm.Open(...)mysqlに接続しています。
  • 他にautoMigrationmodel/entityに記載されたDBのスキーマをマイグレーションしています。

Router: server.go

func router() *gin.Engine {
	r := gin.Default()

	apiv1 := r.Group("/api/v1")
	{
		shopCtrl := controller.ShopController{}
		apiv1.GET("/shops", shopCtrl.IndexShop)
		apiv1.GET("/shops/:id", shopCtrl.ShowShop)
		apiv1.POST("/shops", shopCtrl.CreateShop)
		apiv1.PUT("/shops/:id", shopCtrl.UpdateShop)
		apiv1.DELETE("/shops/:id", shopCtrl.DeleteShop)

		bookCtrl := controller.BookController{}
		apiv1.GET("/books", bookCtrl.IndexBook)
		apiv1.GET("/books/:id", bookCtrl.ShowBook)
		apiv1.POST("/books", bookCtrl.CreateBook)
		apiv1.PUT("/books/:id", bookCtrl.UpdateBook)
		apiv1.DELETE("/books/:id", bookCtrl.DeleteBook)
	}
	return r
}
  • ここではルーティングを記載しています
  • apiv1 := r.Group("/api/v1")/api/v1のパスを切っています
    • 他バージョンのapiを作ることになった場合は同様にしてapiv2 := r.Group("/api/v2")と切ることで対応できます。
  • shopCtrl := controller.ShopController{}controllerで定義したShopController構造体をコントローラーとして指定できるようにしています。
    • したがってapiv1.GET("/shops", shopCtrl.IndexShop)として指定すればlocalhost/api/v1/shopsをGETでアクセスすればIndexShopがコントローラーとして実行されます。
    • 次はIndexShopを見ていきましょう。

Controller: controller/shop_controoler.go

// IndexShop action: GET /shops
func (pc ShopController) IndexShop(c *gin.Context) {
	shops, err := model.GetShopAll()

	if err != nil {
		c.AbortWithStatus(404)
		fmt.Println(err)
	} else {
		view.RenderShops(c, shops)
	}
}

  • ここは MVCCすなわちコントローラーになります
  • shops, err := model.GetShopAll()Modelから取得したSQLの結果をview.RenderShops(c, shops)Viewに渡しています。
  • 次はModelGetShopAllentityを一緒に見ましょう。

Model: model/shop.go, model/entity/shop.go

# 
// GetShopAll is get all Shop
func GetShopAll() ([]*entity.Shop, error) {
	db := db.GetDB()
	var u []*entity.Shop
	if err := db.Find(&u).Error; err != nil {
		return nil, err
	}

	return u, nil
}

# model/entity/shop.go
// Shop is shop models property
type Shop struct {
	ID              uint64 `gorm:"primary_key"`
	ShopName        string
	ShopDescription string
	CreatedAt       time.Time
	DeletedAt       *time.Time
}

  • ここは MVCMすなわちモデルになります。
  • model/entity/shop.goで定義した構造体にdb.Find(&u)の結果を当てはめている感じですね。
  • また、ここはIndexなので[]*entity.Shopという形でslice(可変長配列のようなもの)で取るようにしています。
  • ここで取得した値がmodelに返却されてview.RenderShops(c, shops)viewに渡されます。次はRenderShopsが記載されているview/shop.goと一緒にview/convert_function.goview/schemas.goを一緒に見ます。

View: view/shop.go,view/convert_function.go,view/schemas.go

# view/shop.go
type responseShops struct {
	Shops []shop `json:"shops"`
}

// RenderShops render shops.
func RenderShops(c *gin.Context, shops []*entity.Shop) {
	c.JSON(200, responseShops{Shops: convertToViewShops(shops)})
}

// RenderShop render shop.
func RenderShop(c *gin.Context, shop *entity.Shop) {
	c.JSON(200, convertToViewShop(shop))
}

# view/convert_function.go

func convertToViewShops(before []*entity.Shop) []shop {
	after := make([]shop, len(before))
	for i, p := range before {
		after[i] = convertToViewShop(p)
	}
	return after
}

func convertToViewShop(before *entity.Shop) shop {
	return shop{
		ID:              before.ID,
		ShopName:        before.ShopName,
		ShopDescription: before.ShopDescription,
	}
}

# view/schemas.go

type shop struct {
	ID              uint64 `json:"id"`
	ShopName        string `json:"shop_name"`
	ShopDescription string `json:"shop_description"`
}

  • ここは MVCVすなわちビューになります。

  • 複雑ですが以下のような処理の流れになっています

    • RenderShops(statusコードを指定してjsonを返却)-> responseShops(indexなのでsliceを返す) -> convertToViewShops(モデルで取得した複数のレコードについて一つずつconvertToViewShopを呼び出す)->convertToViewShop(shop構造体に取得したレコードの値を当てはめる)->shop(実際に返却されるjsonの形式を構造体で定義しています)
  • 以上のような流れで処理を行った結果以下のようなレスポンスが帰ってきます。

$ curl localhost:10080/api/v1/shops | jq
{
  "shops": [
    {
      "id": 1,
      "shop_name": "whisky shop",
      "shop_description": "Shop of liquors."
    },
    {
      "id": 2,
      "shop_name": "shusuky no mise",
      "shop_description": "Sake suki oyaji no mise."
    }
  ]
}

  • 以上のようにして処理できますがRailsのインスタンスメソッドのようにモデルに生やしたメソッドの処理結果をviewから呼び出してjsonに埋め込みたいという需要も有ると思います。
  • 別APIで切ったbooksの方でそのような対応を行っているので紹介したいと思います。

Railsのインスタンスメソッドのような処理

# model/entity/book.go
// Book is book models property
type Book struct {
	ID              uint64 `gorm:"primary_key"`
	BookName        string
	BookDescription string
	Sales           uint
	CreatedAt       time.Time
	DeletedAt       *time.Time
}

// Rank calculate rank of Book by sales.
func (book *Book) Rank() *string {

	if book.Sales > 10000 {
		rank := "ベストセラー"
		return &rank
	} else if book.Sales > 1000 {
		rank := "売れ筋"
		return &rank
	} else if book.Sales > 100 {
		rank := "そこそこ"
		return &rank
	} else if book.Sales > 10 {
		rank := "ちょいちょい"
		return &rank
	}
	return nil
}


  • Railsのインスタンスメソッドのような処理を構造体にメソッドを生やすことで実現しました。
  • ここの例ではmodelに定義したBookRankを生やしています。
    • レコードのSalesの値に応じて数値またはnilを返す処理です。(あまりキレイな処理じゃないかもしれませんが。。。)
    • このRankGET http://localhost:10080/api/v1/books/:idで使用しています
    • viewでは以下のように単純に呼び出すことで使用しています。
# view/book.go
// RenderBook render book.
func RenderBook(c *gin.Context, book *entity.Book) {
	bookWithRank := bookWithRank{
		ID:              book.ID,
		BookName:        book.BookName,
		BookDescription: book.BookDescription,
		Sales:           book.Sales,
		Rank:            book.Rank(),
	}
	c.JSON(200, bookWithRank)
}

  • Rank: book.Rank(),の箇所でレコードの値のように単純に関数を呼び出して呼び出しています。
  • そうすると以下のように関数の戻り値がjsonのrankキーに入って来ることが確認できます。
  • こうしてインスタンス変数のような役割を実現することに成功しました。
$ curl localhost:10080/api/v1/books/1 | jq
{
  "id": 1,
  "book_name": "whisky diary",
  "book_description": "Secret diary of whisky!",
  "sales": 0,
  "rank": null
}

$ curl localhost:10080/api/v1/books/2 | jq
{
  "id": 2,
  "book_name": "shusuky diary",
  "book_description": "Secret diary of whisky!",
  "sales": 20,
  "rank": "ちょいちょい"
}

  • ちなみにcurl localhost:10080/api/v1/books/1 | jqの方ではnullが返ってきています。
  • golangではstring型にnullを指定することはできませんが以下のように構造体で*stringRankstring型のポインタを指定することで実現しています。
  • ここら辺のnullを返す対応については他の記事でも書いたので良かったらご確認ください。
type bookWithRank struct {
	ID              uint64  `json:"id"`
	BookName        string  `json:"book_name"`
	BookDescription string  `json:"book_description"`
	Sales           uint    `json:"sales"`
	Rank            *string `json:"rank"`
}

#感想・その他

  • 記事では書いていませんがアプリ名を長くしたのは失敗でした
    • importする際に import "gin-gorm-rails-like-sample-api/controller"とパッケージ名が非常に長くなるからです。
    • 何か作るときは気をつけた方が良さそうです。。。
  • RailsではRails チュートリアルで手取り足取りMVCのアプリ作成を教えてもらうことができましたが、ginにはそれに当るものが中々見つからなかったため苦しみました。
    • この記事が私と同じようなことに苦しんでいる方の助けになれば幸いです。
9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?