Golangはじめて物語(第2話: Gin＋ECS＋Fargateといっしょ編)

# はじめに
[前回](https://qiita.com/neruneruo/items/1ef52f908e4497103a59)の続編。
サーバレスの走り切り処理を書いたのだから、今度は常駐処理を書いてみよう。

JavaではWebアプリケーションサーバはSpringがデファクトになりつつあるが、GoのWebアプリケーションフレームワークはGinが良いという話を聞いた。今回は、Ginを試してみつつ、せっかく作ったのだから常駐のサーバレスコンテナで動かしてみよう。

統合開発環境は前回に引き続き、VSCode＋Remote Development Extension Pack＋EC2を使った。
Cloud9でいいじゃんという話もありつつ、ここはもう好みの問題だということで、こっちを使い続けた。
※Cloud9も使いやすくて好き。

Go言語ランタイムのインストールなんかも、前回の記事を参照。

# 全体構成
以下のようになる。アプリケーションの仕様は以下の通り。
※Ginのサンプルプロジェクトを少しだけ改造。

- /articleのリソースにPOST(BODYにJSONで```{"Title": "hoge", "Description": "hige"}```を渡す)と、内部の構造体に該当の情報を格納する
- /articleのリソースへのPOSTはバリデーションチェックを行い、JSONの情報に不足がある場合は400応答する
- /articleのリソースにGETすると、```{"Title": "hoge", "Description": "hige"}```なJSONを返す。ただし起動直後は何も登録されていなくてnullを返す

```
.
├── docker-compose.yml
├── Dockerfile
├── .gitignore
├── go.mod
├── handlers
│   ├── articleFunc.go
│   └── go.mod
├── main.go
├── main_test.go
├── Makefile
└── modules
    └── article
        ├── article.go
        └── go.mod
```

# Webアプリケーションサーバの実装
## メイン処理
すごい。Ginかなり簡単。main.goは以下だけで済む。

```golang:main.go
package main

import (
    "local.packages/handlers"

    "local.packages/modules/article"

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

func initRouter() *gin.Engine {
    article := article.New()

    router := gin.Default()
    router.GET("/article", handler.ArticlesGet(article))
    router.POST("/article", handler.ArticlePost(article))

    return router
}

func main() {
    endless.ListenAndServe(":8080", initRouter())
}
```

initRouter()では```gin.Default()```でrouterを定義し、これが各リソース・メソッドの振り分けを、ハンドラ関数を呼び出す形でしてくれる。
もっと簡単に↓こんな感じでも動くのだが、これだとテストコードが上手く組めなさそうだったので少し直した。

```golang
func main() {
    article := article.New()
    r := gin.Default()
    r.GET("/article", handler.ArticlesGet(article))
    r.POST("/article", handler.ArticlePost(article))

    r.Run() // listen and serve on 0.0.0.0:8080

}
```

mainの```endless.ListenAndServe()```の部分なんかは[このサイト](http://sekitaka-1214.hatenablog.com/entry/2016/08/11/153816)で詳細が紹介されている。
わざわざグレースフルリスタートにしなくても他にやり方あったっぽいな…。

## ハンドラ
こちらもそんなに特別なことはしていない。Ginのフレームワークが簡単に使える理由として、
- ```c.Bind()```で簡単にコンテキストからBodyを取得できる
- ```c.Status(http.StatusXXX)```で簡単ステータスコードを設定できる
- ```c.JSON(http.StatusXXX, [文字列])```で簡単にレスポンスのBodyの文字列をJSONにして返却できる
といったところか。いやこれメチャクチャ簡単だな！

```golang:handlers/articleFunc.go
package handler

import (
    "net/http"

    "local.packages/modules/article"

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

func ArticlesGet(articles *article.Articles) gin.HandlerFunc {
    return func(c *gin.Context) {
        result := articles.GetAll()
        c.JSON(http.StatusOK, result)
    }
}

type ArticlePostRequest struct {
    Title       string `json:"title"`
    Description string `json:"description"`
}

func ArticlePost(post *article.Articles) gin.HandlerFunc {
    return func(c *gin.Context) {
        requestBody := ArticlePostRequest{}
        c.Bind(&requestBody)

        item := article.Item{
            Title:       requestBody.Title,
            Description: requestBody.Description,
        }

        if err := post.Check(item); err != nil {
            c.Status(http.StatusBadRequest)
        } else {
            post.Add(item)
            c.Status(http.StatusNoContent)
        }
    }
}
```

## Articleモジュール
これは特別なことはしていない単純なモジュール

```golang:modules/article/article.go
package article

import (
    "log"
    "errors"
)

type Item struct {
    Title       string `json:"title"`
    Description string `json:"description"`
}

type Articles struct {
    Items []Item
}

func New() *Articles {
    return &Articles{}
}

func (r *Articles) Check(item Item) error {
    if item.Title == "" {
        log.Println("Title is not specified")
        log.Println(item)
        return errors.New("Request Parameter Error")
    }
    if item.Description == "" {
        log.Println("Description is not specified")
        log.Println(item)
        return errors.New("Request Parameter Error")
    }

    return nil
}

func (r *Articles) Add(item Item) {
    r.Items = append(r.Items, item)
}

func (r *Articles) GetAll() []Item {
    return r.Items
}
```

# パッケージ化
パッケージ化も詳細は前回記載済みなので、細かい話は割愛。
今回は以下のように依存関係を定義した。普通にディレクトリ構造を反映しただけ。

```go.mod
module go-container-test

go 1.13

require (
	github.com/fvbock/endless v0.0.0-20170109170031-447134032cb6
	github.com/gin-gonic/gin v1.6.3
	github.com/stretchr/testify v1.4.0
	local.packages/handlers v0.0.0-00010101000000-000000000000
	local.packages/modules/article v0.0.0-00010101000000-000000000000
)

replace local.packages/handlers => ./handlers

replace local.packages/modules/article => ./modules/article

```

```handlers:go.mod
module handler

go 1.13

require (
	github.com/gin-gonic/gin v1.6.3
	local.packages/modules/article v0.0.0-00010101000000-000000000000
)

replace local.packages/modules/article => ../modules/article
```

```modules/article/go.mod
module article

go 1.13
```

# テストプログラム
今回は、以下のようにinitRouter関数をテストした。
```testing```ではアサーションが使えないので、```github.com/stretchr/testify/assert```を使っている。詳細は[この記事](https://qiita.com/JpnLavender/items/21b4574a7513472903ea)が分かりやすい。

```initRouter()``` は一度読んであげれば初期化してずっと使えるので、```setup()``` に切り出している。
その後は、```testPattern``` 構造体に従ってテストシナリオを回す感じだ。
```body``` や ```expectedBody``` は、ダブルクォートで文字列を囲むとエスケープが面倒なので、バッククォートで囲む。expectedBody は、```assert.JSONEq()``` でアサーションしているので、アバウトに書いても問題ない。文字列の順序等に厳密性を持たせるなら普通に ```assert.Equal()``` すればよいが、それだとせっかく JSON を使っている意味があまりない。
構造体を配列にしてループを回しても動かせるが、エラー箇所が分かりにくくなるのと、レポート時に1つのテストにまとまってしまってイケていないので、複数のテスト用関数に分けている。

```golang:test_main.go
package main

import (
	"io"
	"os"
	"strings"
	"testing"

	"net/http"
	"net/http/httptest"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
)

var (
	router *gin.Engine
)

const ()

func setup() error {
	router = initRouter()
	return nil
}

func teardown() error {
	return nil
}

type testPattern struct {
	method       string
	url          string
	body         io.Reader
	expectedCode int
	expectedBody string
}

func executionTest(tp testPattern, t *testing.T) {
	w := httptest.NewRecorder()
	request, _ := http.NewRequest(tp.method, tp.url, tp.body)
	request.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(w, request)

	assert.Equal(t, tp.expectedCode, w.Code)
	assert.JSONEq(t, tp.expectedBody, w.Body.String())
}

func executionTestEmpty(tp testPattern, t *testing.T) {
	w := httptest.NewRecorder()
	request, _ := http.NewRequest(tp.method, tp.url, tp.body)
	request.Header.Set("Content-Type", "application/json")
	router.ServeHTTP(w, request)

	assert.Equal(t, tp.expectedCode, w.Code)
	assert.Empty(t, w.Body.String())
}

func TestInitRouter00001(t *testing.T) {
	tp := testPattern{
		method:       http.MethodGet,
		url:          "/article",
		body:         nil,
		expectedCode: 200,
		expectedBody: `null`,
	}

	executionTest(tp, t)
}

func TestInitRouter00002(t *testing.T) {
	tp := testPattern{
		method:       http.MethodPost,
		url:          "/article",
		body:         strings.NewReader(`{"hoge1":"hige1", "hoge2":"hige2"}`),
		expectedCode: 400,
		expectedBody: `null`,
	}

	executionTestEmpty(tp, t)
}

func TestInitRouter00003(t *testing.T) {
	tp := testPattern{
		method:       http.MethodPost,
		url:          "/article",
		body:         strings.NewReader(`{"Title":"test", "hoge2":"hige2"`),
		expectedCode: 400,
		expectedBody: `null`,
	}

	executionTestEmpty(tp, t)
}

func TestInitRouter00004(t *testing.T) {
	tp := testPattern{
		method:       http.MethodPost,
		url:          "/article",
		body:         strings.NewReader(`{"hoge1":"hige1", "Description":"test desc."}`),
		expectedCode: 400,
		expectedBody: `null`,
	}

	executionTestEmpty(tp, t)
}

func TestInitRouter00005(t *testing.T) {
	tp := testPattern{
		method:       http.MethodPost,
		url:          "/article",
		body:         strings.NewReader(`{"Title":"test", "Description":"test desc."}`),
		expectedCode: 204,
		expectedBody: `null`,
	}

	executionTestEmpty(tp, t)
}

func TestInitRouter00006(t *testing.T) {
	tp := testPattern{
		method:       http.MethodGet,
		url:          "/article",
		body:         nil,
		expectedCode: 200,
		expectedBody: `[{"description":"test desc.", "title":"test"}]`,
	}

	executionTest(tp, t)
}

func TestMain(m *testing.M) {
	setup()
	ret := m.Run()
	teardown()
	os.Exit(ret)
}
```

# ビルド
まずはコンテナ化の前にローカルビルドから。
まあ、何も大したことはやってないけど……。

```Makefile:Makefile
build:
	GOARCH=amd64 GOOS=linux go build -o artifact/go-container-test
.PHONY: build

test:
	go test ./...
.PHONY: test

clean:
	rm -rf artifact
.PHONY: clean
```

で、しっかりテストまで動いたら、今度はDockerで固める。
JavaでSpringBootなFatJarをコピーするノリで、上記の```make build```したものをCOPYしたら動かなかった。そりゃそうだ。バイナリなんだから、しっかり実行環境を合わせてビルドしないとね……。

ということで、探してみたところ、泥臭いビルド用コンテナでビルドして```extract```してからビルド用コンテナに```COPY```して、ということをせずとも、「Docker multistage build」をやれば一つのDockerfileで書けるらしい。素晴らしい。以下の記事に詳しく書かれている。

- [ECSとGoとDocker multistage build](https://tech.speee.jp/entry/2017/08/22/110000)

上記を踏まえて、今回のDockerfileは以下のようにする。

```Dockerfile:Dockerfile
FROM golang:1.13-alpine3.12 as build

ENV GOPATH /go

RUN apk add --update --no-cache git

COPY . /go/src
WORKDIR /go/src

RUN go build -o go-container-test .

FROM alpine:3.12

RUN mkdir /app
WORKDIR /app
COPY --from=build /go/src/go-container-test /app/go-container-test

CMD ["/app/go-container-test"]
```

これを```docker build```して、```docker run```すればバッチリ動作するぞ！ちゃんと```-p 8080:8080```するのは忘れないように。

ちなみに、この方法で作ったDockerコンテナのイメージサイズは以下の通り20MBとめちゃくちゃ小さい。Javaのコンテナサイズに慣れてると、かなり凄いと感じる。

```
REPOSITORY         TAG     IMAGE ID      CREATED             SIZE
go-container-test  latest  XXXXXXXXXXXX  About an hour ago   21.1MB
```

# ECSで動かす
もうここまでやってだいぶ疲れたので、先日急にリリースされたCopilotを使ってみる。
Copilotの詳細は以下のClassMethodの記事で。

- 【Developers.IO】[ECSのオペレーションを劇的に簡略化するAWS Copilotが発表されました！](https://dev.classmethod.jp/articles/aws-copilot/)

さて、これを参考にしながら、copilotをインストールして```copilot init```すると……

![キャプチャ5.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/c2c90fb2-6356-c4ec-f04f-f109ed0b4b60.png)

えっすごい！本当にあの、画面ポチポチでやってもTerraformでやってもCloudFormationでやってもクソ面倒臭いECSとALBの設定がほんのちょっとの質問に答えて10分待つくらいで動いた！
今回は試していないけど、どうやらCI/CDパイプラインまで作れるとか、凄すぎるぜ……。
※ちなみに、今回のアプリはパスが/だと404応答になってしまい、デフォルトの設定ではALBのヘルスチェックが通らなかったので、少しだけ手直しはした。

しかし、あまりに動作がブラックボックスすぎてアンコントローラブルにならないのかこれは…。

# 動かしてみる
やったー動いたー！

```
$ curl -i -X POST -H "Content-Type: application/json" -d '{"Title":"Hello", "Description":"Hello Gin!!"}' http://go-co-Publi-XXXXXXXXXX.ap-northeast-1.elb.amazonaws.com/article
HTTP/1.1 204 No Content
Date: Sat, 11 Jul 2020 13:22:36 GMT
Connection: keep-alive

$ curl -i http://go-co-Publi-XXXXXXXXXX.ap-northeast-1.elb.amazonaws.com/article
HTTP/1.1 200 OK
Date: Sat, 11 Jul 2020 13:22:39 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 47
Connection: keep-alive

[{"title":"Hello","description":"Hello Gin!!"}]
```