golang
gin
go-cache

HEW2018 モダンなWebサービスの作成(サーバーサイド編)

自己紹介

じゅんじゅんと言うニックネームで、関西を拠点に活動しているフロントエンドエンジニアです。

HAL大阪3回生です。(2018/2/27現在)

イベントや、勉強会に参加してるので是非お会いした際はお声掛けください!

HEW2018 モダンなWebサービスの作成(サーバーサイド編)とは

これは僕の学校のイベントの発表をQiitaの記事としてあげておくためのエントリーです。

気になる方はこちらをご覧ください。

サーバーサイド以外には

があります。

環境

Goの構成はこんな感じにしました。アーキテクチャ的にはわかりやすいのでMVCですが、Fat ControllerになるのでServiceという層を追加して MVCS としています。

- main.go
- cache/
    - cache.go
    - product.go
- controller/
    - base.go
    - product.go
- model/
    - db.go
    - type.go
- router/
    - api.go
    - router.go
- service/
    - product.go
    - service.go
- view/
    - index.html

開発

model

まずは model を作っていきたいと思います。

model ではこのサービスのコアな情報を先に定義しておきます。
その他、データベースとの接続なども行います。

model/db.go
package model

import (
    _ "github.com/go-sql-driver/mysql"
    "github.com/jinzhu/gorm"
    "github.com/makki0205/config"
)

var db = NewDBConn()

func NewDBConn() *gorm.DB {
    db, err := gorm.Open(GetDBConfig())
    if err != nil {
        panic(err)
    }

    return db
}

func GetDBConn() *gorm.DB {
    return db
}

func GetDBConfig() (string, string) {
    return config.Env("dialect"), config.Env("datasource")
}

ここでは、データベースとの接続をして今後は GetDBConn を使って接続済みのインスタンスを使いまわす(シングルトン)でやっていきます。

model/type.go
package model

import (
    "sort"
    "time"
)

// Model 標準のmodel
type Model struct {
    ID        uint       `gorm:"primary_key" json:"id"`
    CreatedAt time.Time  `json:"created_at"`
    UpdatedAt time.Time  `json:"updated_at"`
    DeletedAt *time.Time `sql:"index" json:"-"`
}

// Product プロダクト
type Product struct {
    Model
    Thumbnail string `json:"thumbnail"`
    Title     string `json:"title"`
    Author    string `json:"author"`
    Votes     int    `json:"votes" gorm:"-"`
}

type Products []Product

// Vote 投票数
type Vote struct {
    Model
    ProductID uint
}

// SetVote 投票をカウントして入れる
func (p *Product) SetVote() {
    var votes []Vote
    db.Where("product_id = ?", p.ID).Find(&votes)
    p.Votes = len(votes)
}

// FilterZero 0を排除する
func (p *Products) FilterZero() *Products {
    var products Products
    for _, product := range *p {
        if product.Votes != 0 {
            products = append(products, product)
        }
    }

    return &products
}

// SortByVote ランキングしたProductsを返す
func (p *Products) SortByVote() *Products {
    sort.Slice(*p, func(i, j int) bool {
        return (*p)[i].Votes > (*p)[j].Votes
    })
    return p
}

// Cut 指定の数でプロダクトを減らす
func (p *Products) Cut(count int) *Products {
    var products Products
    for key, product := range *p {
        if key == count {
            break
        }
        products = append(products, product)
    }
    return &products
}

次に、サービスで扱うmodelをかきます。

type Model struct {
    ID        uint       `gorm:"primary_key" json:"id"`
    CreatedAt time.Time  `json:"created_at"`
    UpdatedAt time.Time  `json:"updated_at"`
    DeletedAt *time.Time `sql:"index" json:"-"`
}

このモデルはDBなど用のものです。今回、ORMは gorm を使いました。

type Product struct {
    Model
    Thumbnail string `json:"thumbnail"`
    Title     string `json:"title"`
    Author    string `json:"author"`
    Votes     int    `json:"votes" gorm:"-"`
}

これは作品1つ1つのモデルです。

  • サムネイル
  • タイトル
  • 作成者
  • 投票数

の情報を持ちます。

type Products []Product

Productの一覧を表すものです。のちほど、チェーンメソッド的にモデルを扱いたいので、作成しておきます。

type Vote struct {
    Model
    ProductID uint
}

投票数です。

投票を別テーブルにして投票された作品のIdをinsertしていって、あとでcountする形で投票数を取得します。

func (p *Product) SetVote() {
    var votes []Vote
    db.Where("product_id = ?", p.ID).Find(&votes)
    p.Votes = len(votes)
}

投票数を数えて、 ProductモデルのVotesにいれるためのメソッドです。

func (p *Products) FilterZero() *Products {
    var products Products
    for _, product := range *p {
        if product.Votes != 0 {
            products = append(products, product)
        }
    }

    return &products
}

投票数が0の場合、その要素を抜いていくメソッドです。
これはランキングのページをだすために必要なメソッドです。

func (p *Products) SortByVote() *Products {
    sort.Slice(*p, func(i, j int) bool {
        return (*p)[i].Votes > (*p)[j].Votes
    })
    return p
}

よくある配列のソートをするメソッドです。投票数順に並び替えます。

func (p *Products) Cut(count int) *Products {
    var products Products
    for key, product := range *p {
        if key == count {
            break
        }
        products = append(products, product)
    }
    return &products
}

ランキングは上位5名しかだすつもりではなかったので、無駄なレスポンスを返さないようにするために引数に5などの数字をいれるとその個数分だけにして返してくれるメソッドをつくりました。

cache

次にcacheをつくっていきます。

今回、Productの一覧はイミュータブルなので、サーバーを立ち上げた瞬間にキャッシュをしようと思います。

cache/cache.go
package cache

import "github.com/konojunya/HEW2018/model"

var db = model.NewDBConn()

// RefreshAll cacheをリフレッシュする
func RefreshAll() {
    Product.Reload()
}
cache/product.go
package cache

import (
    "errors"
    "time"

    "github.com/konojunya/HEW2018/model"
    gocache "github.com/patrickmn/go-cache"
)

type productCache struct {
    cache *gocache.Cache
}

// Product productsのcache
var Product = newProductCache()

func newProductCache() productCache {
    c := productCache{
        cache: gocache.New(5*time.Minute, 10*time.Minute),
    }
    c.load()

    return c
}

// Reload キャッシュの再ロード
func (p *productCache) Reload() {
    p.load()
}

func (p *productCache) load() {
    products := make(model.Products, 0)
    err := db.Find(&products).Error
    if err != nil {
        panic(err)
    }

    for i, product := range products {
        product.SetVote()
        products[i] = product
    }

    p.cache.Set("products", products, gocache.NoExpiration)
}

// GetAll 全てのproductをキャッシュから取得する
func (p *productCache) GetAll() (model.Products, error) {
    products, found := p.cache.Get("products")
    if found {
        return products.(model.Products), nil
    }
    return nil, errors.New("products not loaded")
}

1つずつ説明していきます。

func newProductCache() productCache {
    c := productCache{
        cache: gocache.New(5*time.Minute, 10*time.Minute),
    }
    c.load()

    return c
}

ここで、キャッシュの設定をします。
今回は、HEWというイベントのときに教えるためのコードなので、redismemcachedなどを使うのではなく、Goにキャッシュさせます。
使ったのはgo-cacheです。

Key-Valueでキャッシュさせることができたのでかなり便利でした。

ここで読んでいる c.load()でキャッシュのセットをしています。

func (p *productCache) load() {
    products := make(model.Products, 0)
    err := db.Find(&products).Error
    if err != nil {
        panic(err)
    }

    for i, product := range products {
        product.SetVote()
        products[i] = product
    }

    p.cache.Set("products", products, gocache.NoExpiration)
}

ここでDBからgormを使って、product一覧をFindします。
取得できたら、modelのときに作ったSetVotesを読んで投票数をProductにいれていきます。

投票数が入った状態のproduct一覧をproductsというキーでキャッシュします。

func (p *productCache) GetAll() (model.Products, error) {
    products, found := p.cache.Get("products")
    if found {
        return products.(model.Products), nil
    }
    return nil, errors.New("products not loaded")
}

キャッシュからデータを取得するには、キーを指定してデータを得ます。

service

ここまで出来れば、あとは普段のMVCとほぼ同じです。上記にもあるように今回はロジックをServiceというパッケージに切り分けています。

なのでボトムアップでつくって行く過程で先にserviceを作っていきます。

service/service.go
package service

import "github.com/konojunya/HEW2018/model"

var db = model.GetDBConn()

serviceの中では、よくDBを触るのでメソッドの中ではなく同パッケージで使えるようにvarでdbのコネクションを持っておきます。

service/product.go
package service

import (
    "github.com/konojunya/HEW2018/cache"
    "github.com/konojunya/HEW2018/model"
)

// GetAll プロダクト一覧とエラーを返す
func GetAll() (model.Products, error) {
    return cache.Product.GetAll()
}

// CreateVote 投票する
func CreateVote(id uint) error {
    err := db.Create(&model.Vote{
        ProductID: id,
    }).Error
    if err != nil {
        return err
    }

    go cache.Product.Reload()

    return nil
}

serviceでは今回はCRUDほどないので2つの関数で実装は終わります。

全件取得するGetAllと投票するCreateVoteです。

func GetAll() (model.Products, error) {
    return cache.Product.GetAll()
}

全件取得は、かなり簡単です。本来はここでDBから全件取得をするんですが、今回はキャッシュを使っているので、cacheのGetAllを使って全件返したいと思います。

func CreateVote(id uint) error {
    err := db.Create(&model.Vote{
        ProductID: id,
    }).Error
    if err != nil {
        return err
    }

    go cache.Product.Reload()

    return nil
}

投票は、productのプライマリーキーを引数にvotesテーブルにinsertするだけなので、上記のような簡素なコードにできます。

ここで重要なのは

go cache.Product.Reload()

キャッシュを再読込するためのメソッドであるReloadを読んでいるんですが、せっかくGoなのでgoroutineを使っていきます。

Goでは、関数の前にgoとつけると非同期にその処理が走ってくれます。

今回、投票したあとに投票数が変わるまでレスポンスを返したいわけでもない(投票数がリアルタイムに重要なデータではない)ので、goroutineにして先に関数を抜けたいと思います。

controller

ここからはMVCのCである、controllerを作っていきます。

ただ今回、controllerはロジックを触らないのでserviceの関数を読んで適切に返すだけになります。

serviceで作ったCreateVoteuintのidを引数にとります。これは、model.ModelのIDがuintで定義されているため、そこに合わせているんですが、例えばブログなどを作る際にもブログの記事を返すAPIをつくるなら/article/1のようなidを使うと思います。
ただURLの中からとったidはstringなので、それをuintに変換するための関数の同パッケージで使えるようにcontroller/base.goで設定していきます。

controller/base.go
package controller

import (
    "strconv"

    "github.com/gin-gonic/gin"
)

func GetUint(c *gin.Context, key string) (uint, error) {
    i, err := strconv.Atoi(c.Param(key))
    return uint(i), err
}

strconvintに変換してからuintにキャストしています。

次にメインのcontrollerを作っていきます。

controller/product.go
package controller

import (
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/konojunya/HEW2018/cache"
    "github.com/konojunya/HEW2018/service"
)

// GetAllProducts プロダクト一覧を返す
func GetAllProducts(c *gin.Context) {
    products, err := service.GetAll()
    if err != nil {
        log.Println(err)
        c.AbortWithStatus(http.StatusInternalServerError)
    }
    c.JSON(http.StatusOK, products)
}

// GetRankedProducts ランキングを返す
func GetRankedProducts(c *gin.Context) {
    products, err := service.GetAll()
    if err != nil {
        log.Println(err)
        c.AbortWithStatus(http.StatusInternalServerError)
    }
    c.JSON(http.StatusOK, products.SortByVote().FilterZero().Cut(5))
}

// CreateVote 投票する
func CreateVote(c *gin.Context) {
    id, err := GetUint(c, "id")
    if err != nil {
        log.Println(err)
        c.AbortWithStatus(http.StatusInternalServerError)
    }
    err = service.CreateVote(id)
    if err != nil {
        log.Println(err)
        c.AbortWithStatus(http.StatusInternalServerError)
    }
    c.AbortWithStatus(http.StatusNoContent)
}

// RefreshCache キャッシュをリフレッシュ
func RefreshCache(c *gin.Context) {
    cache.RefreshAll()
}

主に使うのは

  • GetAllProducts(全件取得)
  • GetRankedProducts(ランキング済みのプロダクト取得)
  • CreateVote(投票)
  • RefreshCache(キャッシュをリロードする)

の4つです。

func GetAllProducts(c *gin.Context) {
    products, err := service.GetAll()
    if err != nil {
        log.Println(err)
        c.AbortWithStatus(http.StatusInternalServerError)
    }
    c.JSON(http.StatusOK, products)
}

全件返すのは、簡単でserviceのGetAllから取得して返します。

正常に返す時はHTTP Status Codeの200をつけて返します。もしエラーが起きた場合、全件取得に失敗する=コードが(DBの設定などが)悪いので500で落とします。

func GetRankedProducts(c *gin.Context) {
    products, err := service.GetAll()
    if err != nil {
        log.Println(err)
        c.AbortWithStatus(http.StatusInternalServerError)
    }
    c.JSON(http.StatusOK, products.SortByVote().FilterZero().Cut(5))
}

ランキング済みの一覧を返す場合も基本的に同じで、serviceのGetAllを使って一覧を取得します。
これも失敗した場合は、500で落とします。

正常に返す場合は、ソートして->0を削って->上位5件をしたやつを返します。

router

最後にルーティングしていきます。

router/api.go
package router

import (
    "github.com/gin-gonic/gin"
    "github.com/konojunya/HEW2018/controller"
)

func apiRouter(api *gin.RouterGroup) {
    api.GET("/products", controller.GetAllProducts)
    api.GET("/ranking", controller.GetRankedProducts)
    api.POST("/products/:id/vote", controller.CreateVote)
    api.POST("/refresh", controller.RefreshCache)
}
router/router.go
api := r.Group("/api")
apiRouter(api)

APIのルーティングには、ginのグルーピングを使ったので実際のURLは/api/productsなどになります。

router/router.go
package router

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

// GetRouter ルーターを設定してgin.Engineを返す
func GetRouter() *gin.Engine {
    r := gin.Default()
    r.Static("/js", "./public/js")
    r.Static("/image", "./public/image")
    r.Static("/css", "./public/css")

    r.LoadHTMLGlob("view/*")
    r.NoRoute(func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", nil)
    })

    api := r.Group("/api")
    apiRouter(api)

    return r
}

GetRouterでは、静的ファイルの設定やwebのルーティングを行っています。

r.NoRoute(func(c *gin.Context) {
  c.HTML(http.StatusOK, "index.html", nil)
})

NoRouteは、ルーティングされていないURLに来た場合、全てindex.htmlをだすようにしています。これはフロントエンドをSPAで作っているのでそちらでルーティングできるようにするためです。

ただこれでは、サーバーから存在しないページを叩いた時に404を返せないので、本来であれば特定のルーティングはindex.html、それ以外はNoRoute404のstatus codeを返しながらview/notfound.htmlなどを返すのも1つの手だと思います。

今回はSPA側でNotFoundページを作っているので大丈夫です。そのほうがかなりカスタムした404を返しやすいのではないかみたいなところから結構僕自身も業務の場合どうするんだろうみたいなとこあります。

seed and migration

DBのマイグレーションや、マスターデータの作成(seed)などはmigration/main.goseed/main.goなどのコードをみてみてください。

あとがき

Twitterしています!ぜひフォローください。 @konojunya

ソースコードは konojunya/HEW2018 にあります!