自己紹介
じゅんじゅんと言うニックネームで、関西を拠点に活動しているフロントエンドエンジニアです。
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 ではこのサービスのコアな情報を先に定義しておきます。
その他、データベースとの接続なども行います。
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
を使って接続済みのインスタンスを使いまわす(シングルトン)でやっていきます。
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の一覧はイミュータブルなので、サーバーを立ち上げた瞬間にキャッシュをしようと思います。
package cache
import "github.com/konojunya/HEW2018/model"
var db = model.NewDBConn()
// RefreshAll cacheをリフレッシュする
func RefreshAll() {
Product.Reload()
}
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というイベントのときに教えるためのコードなので、redisやmemcachedなどを使うのではなく、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を作っていきます。
package service
import "github.com/konojunya/HEW2018/model"
var db = model.GetDBConn()
serviceの中では、よくDBを触るのでメソッドの中ではなく同パッケージで使えるようにvarでdbのコネクションを持っておきます。
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で作ったCreateVote
はuint
のidを引数にとります。これは、model.Model
のIDがuint
で定義されているため、そこに合わせているんですが、例えばブログなどを作る際にもブログの記事を返すAPIをつくるなら/article/1
のようなidを使うと思います。
ただURLの中からとったidはstring
なので、それをuint
に変換するための関数の同パッケージで使えるように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
}
strconv
でint
に変換してからuint
にキャストしています。
次にメインのcontrollerを作っていきます。
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
最後にルーティングしていきます。
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)
}
api := r.Group("/api")
apiRouter(api)
APIのルーティングには、ginのグルーピングを使ったので実際のURLは/api/products
などになります。
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
、それ以外はNoRoute
で404
のstatus codeを返しながらview/notfound.html
などを返すのも1つの手だと思います。
今回はSPA側でNotFoundページを作っているので大丈夫です。そのほうがかなりカスタムした404を返しやすいのではないかみたいなところから結構僕自身も業務の場合どうするんだろうみたいなとこあります。
seed and migration
DBのマイグレーションや、マスターデータの作成(seed)などはmigration/main.go
やseed/main.go
などのコードをみてみてください。
あとがき
Twitterしています!ぜひフォローください。 @konojunya
ソースコードは konojunya/HEW2018 にあります!