はじめに
前回の続編。
サーバレスの走り切り処理を書いたのだから、今度は常駐処理を書いてみよう。
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は以下だけで済む。
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にして返却できる
といったところか。いやこれメチャクチャ簡単だな!
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モジュール
これは特別なことはしていない単純なモジュール
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
}
パッケージ化
パッケージ化も詳細は前回記載済みなので、細かい話は割愛。
今回は以下のように依存関係を定義した。普通にディレクトリ構造を反映しただけ。
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
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
module article
go 1.13
テストプログラム
今回は、以下のようにinitRouter関数をテストした。
testing
ではアサーションが使えないので、github.com/stretchr/testify/assert
を使っている。詳細はこの記事が分かりやすい。
initRouter()
は一度読んであげれば初期化してずっと使えるので、setup()
に切り出している。
その後は、testPattern
構造体に従ってテストシナリオを回す感じだ。
body
や expectedBody
は、ダブルクォートで文字列を囲むとエスケープが面倒なので、バッククォートで囲む。expectedBody は、assert.JSONEq()
でアサーションしているので、アバウトに書いても問題ない。文字列の順序等に厳密性を持たせるなら普通に assert.Equal()
すればよいが、それだとせっかく JSON を使っている意味があまりない。
構造体を配列にしてループを回しても動かせるが、エラー箇所が分かりにくくなるのと、レポート時に1つのテストにまとまってしまってイケていないので、複数のテスト用関数に分けている。
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)
}
ビルド
まずはコンテナ化の前にローカルビルドから。
まあ、何も大したことはやってないけど……。
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は以下のようにする。
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が発表されました!
さて、これを参考にしながら、copilotをインストールしてcopilot init
すると……
えっすごい!本当にあの、画面ポチポチでやっても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!!"}]