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

# 自己紹介

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

HAL大阪3回生です。（2018/2/27現在）

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

# HEW2018 モダンなWebサービスの作成（サーバーサイド編）とは

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

気になる方は[こちら](https://qiita.com/konojunya/items/728357afcab505441724)をご覧ください。

サーバーサイド以外には

- [クライアント編](https://qiita.com/konojunya/items/2a0315e2f2bce236f91a)
- [インフラ編](https://qiita.com/konojunya/items/5fa65c219227cdf9a737)

があります。

## 環境

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** ではこのサービスのコアな情報を先に定義しておきます。
その他、データベースとの接続なども行います。

```go: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` を使って接続済みのインスタンスを使いまわす（シングルトン）でやっていきます。

```go: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をかきます。

```go
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](https://github.com/jinzhu/gorm) を使いました。

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

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

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

の情報を持ちます。

```go
type Products []Product
```

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

```go
type Vote struct {
	Model
	ProductID uint
}
```

投票数です。

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

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

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

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

	return &products
}
```

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

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

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

```go
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の一覧はイミュータブルなので、サーバーを立ち上げた瞬間にキャッシュをしようと思います。

```go:cache/cache.go
package cache

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

var db = model.NewDBConn()

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

```go: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")
}
```

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

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

    return c
}
```

ここで、キャッシュの設定をします。
今回は、HEWというイベントのときに教えるためのコードなので、[redis](https://redis.io/)や[memcached](https://memcached.org/)などを使うのではなく、Goにキャッシュさせます。
使ったのは[go-cache](https://github.com/patrickmn/go-cache)です。

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

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

```go
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`というキーでキャッシュします。

```go
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を作っていきます。

```go:service/service.go
package service

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

var db = model.GetDBConn()
```

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

```go: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`です。

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

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

```go
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
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`で設定していきます。

```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
}
```

`strconv`で`int`に変換してから`uint`にキャストしています。

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

```go: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つです。

```go
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`で落とします。

```go
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

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

```go: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)
}
```

```go:router/router.go
api := r.Group("/api")
apiRouter(api)
```

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

```go: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のルーティングを行っています。

```go
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](https://twitter.com/konojunya)

ソースコードは [konojunya/HEW2018](https://github.com/konojunya/HEW2018) にあります！