概要
- 仕事で
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にはそれに当るものが中々見つからなかったため苦しみました。- この記事が私と同じようなことに苦しんでいる方の助けになれば幸いです。