#概要
- 仕事で
golang
を使ってみようという話になりAPIを作りました。 - 自分自身も他メンバーも
Rails
使いが多いのでRailsに寄せてMVC構成で作りました。 -
golang
に慣れたメンバーが少なかったのでできるだけメジャーで情報が多そうなフレームワークを使おうと考えてgin
を採用しました。 - 同様にORMも
gorm
がメジャーなようだったので採用しました。 -
golang
自体が手探りの状態だったので主に以下を参考にさせて頂きました。- 日本語のQiitaの記事Gin と GORM で作るオレオレ構成 API
- Starが多かった深センの方が作ったAPI
- 自分でもサンプル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
関連のファイルが追加されていることです。- これにより
sudo docker-compose up
でnginx
,mysql
,go
のコンテナが立ち上がり動作させることができます(詳細な使用方法はGithubのREADMEに記載しています)- ローカルでコンテナ上で開発することでスムーズにAWSの
FARGATE
にデプロイすることができました。(ちなみにデプロイ時のことはこちらの記事に記載しました)
- ローカルでコンテナ上で開発することでスムーズにAWSの
- これにより
- 他に違う点としてビジネスロジックを書く場所を
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
に接続しています。 - 他に
autoMigration
でmodel/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")
と切ることで対応できます。
- 他バージョンのapiを作ることになった場合は同様にして
-
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)
}
}
- ここは MVCのCすなわちコントローラーになります
-
shops, err := model.GetShopAll()
でModelから取得したSQLの結果をview.RenderShops(c, shops)
でViewに渡しています。 - 次はModelの
GetShopAll
とentity
を一緒に見ましょう。
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
}
- ここは MVCのMすなわちモデルになります。
-
model/entity/shop.go
で定義した構造体にdb.Find(&u)
の結果を当てはめている感じですね。 - また、ここはIndexなので
[]*entity.Shop
という形でslice
(可変長配列のようなもの)で取るようにしています。 - ここで取得した値が
model
に返却されてview.RenderShops(c, shops)
でview
に渡されます。次はRenderShops
が記載されているview/shop.go
と一緒にview/convert_function.go
とview/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"`
}
-
ここは MVCのVすなわちビューになります。
-
複雑ですが以下のような処理の流れになっています
-
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
に定義したBook
にRank
を生やしています。- レコードのSalesの値に応じて数値またはnilを返す処理です。
(あまりキレイな処理じゃないかもしれませんが。。。) - この
Rank
をGET http://localhost:10080/api/v1/books/:id
で使用しています -
view
では以下のように単純に呼び出すことで使用しています。
- レコードのSalesの値に応じて数値またはnilを返す処理です。
# 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を指定することはできませんが以下のように構造体で*string
とRank
にstring型のポインタ
を指定することで実現しています。 - ここら辺の
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にはそれに当るものが中々見つからなかったため苦しみました。- この記事が私と同じようなことに苦しんでいる方の助けになれば幸いです。