5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-07-11

はじめに

前回の続編。
サーバレスの走り切り処理を書いたのだから、今度は常駐処理を書いてみよう。

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は以下だけで済む。

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を定義し、これが各リソース・メソッドの振り分けを、ハンドラ関数を呼び出す形でしてくれる。
もっと簡単に↓こんな感じでも動くのだが、これだとテストコードが上手く組めなさそうだったので少し直した。

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()の部分なんかはこのサイトで詳細が紹介されている。
わざわざグレースフルリスタートにしなくても他にやり方あったっぽいな…。

ハンドラ

こちらもそんなに特別なことはしていない。Ginのフレームワークが簡単に使える理由として、

  • c.Bind()で簡単にコンテキストからBodyを取得できる
  • c.Status(http.StatusXXX)で簡単ステータスコードを設定できる
  • c.JSON(http.StatusXXX, [文字列])で簡単にレスポンスのBodyの文字列をJSONにして返却できる
    といったところか。いやこれメチャクチャ簡単だな!
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モジュール

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

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

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を使っている。詳細はこの記事が分かりやすい。

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

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
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で書けるらしい。素晴らしい。以下の記事に詳しく書かれている。

上記を踏まえて、今回の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の記事で。

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

キャプチャ5.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!!"}]
5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?