Help us understand the problem. What is going on with this article?

はじめてのGolang Webアプリケーション ~ テスト, Dockerコンテナ化まで

More than 1 year has passed since last update.

概要

こんにちは。kwashiです。最近、コンテナ関連の技術にハマリ気味で、Golangの勉強を始めました。いままで、Pythonばかり使っていたのですが、すっかり、Golangにハマってしまいました。そこで、復習も兼ねて、GolangのインストールからWebアプリケーションの作成、テスト、Dockerコンテナ化までを対象に記事にしてみました。Golangの構文はわかったけど、次どうしようと思っている方にはちょうどいいかもしれません。

Golangのメリットは、以下のようなことが挙げられます。

  • Dockerイメージが軽量 例えば、本記事で作成したアプリケーションは、以下のように22.6MBとかなり軽量です。
  REPOSITORY                                  TAG                 IMAGE ID            CREATED             SIZE
  kwashizaki/example-golang-rest-api          v1.0.0              8d92d819d8ad        8 days ago          22.6MB
  • パフォーマンス
    JavaやC++に匹敵するぐらい性能(処理速度)が良いです。

  • 可読性
    Gofmtにより強制的にコードフォーマットされ、一貫したフォーマットになります。

  • コンパイル時間が早い
    C++やJavaと比較してもかなり早いです。

  • gRPCのサポート
    昨今、マイクロサービスのはやりとともに、RPC経由の通信が増えています。
    GolangによるgRPCに関しては、Golangで始めるgRPCにて記事にしたので参考にしてみてください。

  • 並列処理が書きやすい
    "go"というキーワードを関数呼び出し時に付け加えることで、goroutineというGoの軽量なスレッドを簡単に実行することができます。また、channelとうgoroutine間で通信する方法も用意されています。

個人的には、機械学習やデータ解析など複雑なことをする場合は、Pythonを選択します。一方で、データのCRUDや外部APIとの連携などのサービス(機能)を作成する場合は、Golangを選択します。

※ 注意

本記事では、Webアプリケーションのフレームワークとして、ginを用います。また、Mac OS上でのみ動作を確認しています。
また、git, Docker for mac, VSCodeも既にインストールされているものとします。

git: k-washi/example-golang-rest-apiの/health関連部分が本記事に対応しています。
Docker image: kwashizaki/example-golang-rest-api

作成するWeb アプリケーションについて

アプリケーションは、ベースのURL + pathで表現されるURLへの問い合わせに対応したAPIです。
/health/は単純にGETに対してstatus 200を返します。

Base URL = http://localhost:8080

- path: /health/
  - GET: 
    res: {"health": 200}

Goのインストールと設定

1. インストール

Golangからダウンロードして展開してください。

以下のコマンドでインストールされたか確認してください。

go version

#go version go1.12.7 darwin/amd64

2. 環境設定

環境変数として、以下の変数を設定してください。
GOPATHは、goのプロジェクトを構築する環境です。また、GO111MODULEは、Go1.11から導入され始めたGoの新しいバージョン管理ツールGo Modules(vgo)を使用を管理するためのものです。適宜、個人の環境に合うように設定してください。

export GOPATH=$(go env GOPATH)
export PATH=$PATH:$GOPATH/bin
export GOPATH=/Users/xxxxxxxx/Documents/workspaces/golang
export GO111MODULE=on

この環境変数を毎回設定するのが面倒なので、.bash_profileに書き込みます。

vi ~/.bash_profile
cat ~/.bash_profile
#...
#export GOPATH=$(go env GOPATH)
#export PATH=$PATH:$GOPATH/bin
#export GOPATH=/Users/wxxxxxx/Documents/workspaces/golang
#export GO111MODULE=on

また、GOPATH内にソースフォルダー(src)などを作成します。

tree -L 2
.
├── bin
│   ├── gobump
│   ├── ...
│   └── protoc-gen-go
├── pkg
│   └── mod
└── src
    ├── github.com
    └── golang.org

今回は、github上でソースを管理することを想定します。例えば、gitbubにk-washi/example-golang-rest-apiというリポジトリを作成したとします。$GOPATH/src/github.com/k-washi/で、git clone https://github.com/k-washi/example-golang-rest-api.git とすることでgithubに対応したプロジェクトの開始ができます。 こちらも個人の設定に合わせてください。

3. VSCodeの設定

ExtensionのGoをインストールする。

実装

本章では、 http://localhost/health/ 
でstatus 200が返ってくるWeb アプリケーションを作成します。

Webフレームワークとして、軽量かつシンプルなGinを使用するので、以下のコマンドでダウンロードします。

go get -u github.com/gin-gonic/gin

まず、mainの処理を実装します。
versionはビルド時に使用します。

src/main.go
package main

import (
    "github.com/k-washi/example-golang-rest-api/src/app"
)

const version = "0.1.0"

func main() {
    app.StartApp()
}

StartApp()はsrc/appに以下のように実装しています。package appになっていることに注目してください。このappのパッケージがmain.goで呼び出されています。

  • router.Use() - ミドルウェア処理を追加(ルーティングされる前に処理されます)
  • mapUrls() - app.goの下にプログラムを記載しています。同じディレクトリで同じパッケージ名にてプログラムを始めているので、routerという変数が共有して使用できていることが見て取れます。ここでは、routerにGETなどのメソッド、ルーティングするパス、実行する処理(health pkgのGetHealthStatusOK関数)を登録しています。
  • router.Run(:8080)で8080ポートでサーバーを実行しています。
src/app/app.go
package app

import (
    "github.com/gin-gonic/gin"
    "github.com/k-washi/example-golang-rest-api/src/middleware"
)

var (
    router *gin.Engine
)

func init() {
    router = gin.Default()
}

//StartApp call by main for starting app.
func StartApp() {
    router.Use(middleware.OptionsMethodResponse())
    mapUrls()

    if err := router.Run(":8080"); err != nil {
        panic(err)
    }

}
src/app/url_mappings.go
package app

import (
    "github.com/k-washi/example-golang-rest-api/src/controllers/health"
)

func mapUrls() {
    router.GET("/health", health.GetHealthStatusOK)
}

次に、ミドルウェアについて説明します。ミドルウェアでは、CORSの対策のため、レスポンスのHeader設定とOPTIONメソッドへの対応を行っています。
設定する内容は、以下のコードに記載しています。アプリケーションに合わせて適宜設定が必要となります。
メソッドがOPTIONのとき、Abord()を用いることで、URLに合わせたルーティングの実行を行わずにstatus 200を返答しています。
OPTIONではないとき、Next()を用いて、ルーティングの実行に移ります。

CORSに関しては, CORSまとめが参考になりました。

src/middleware/options_middleware.go
package middleware

import (
    "net/http"

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

//OptionsMethodResponse CROS options response
func OptionsMethodResponse() gin.HandlerFunc {
    return func(c *gin.Context) {
    //アクセスを許可するドメインを設定
        c.Writer.Header().Set("Access-Control-Allow-Origin", "http://localhost")
    //リクエストに使用可能なメソッド
        c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
    //Access-Control-Allow-Methods 及び Access-Control-Allow-Headers ヘッダーに含まれる情報をキャッシュすることができる時間の長さ(seconds)
        c.Writer.Header().Set("Access-Control-Max-Age", "86400")
    //リクエストに使用可能なHeaders
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization, Origin, X-Requested-With, Content-Type, Accept")
    //認証を使用するかの可否
        c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
    //レスポンスのContent-Typeを設定する
        c.Writer.Header().Set("Content-Type", "application/json")

        if c.Request.Method != "OPTIONS" {
            c.Next()
        } else {
            c.JSON(http.StatusOK, gin.H{"status": "ok"})
            c.Abort()
        }
        return

    }
}

次に、/health/へのルーティング時に呼ばれる関数GetHealthStatusOK()を実装します。
サービスHealthService.GetHealth()を実行して、エラーがなければ、結果を返します。

src/controllers/health/health_contoroller.go
package health

import (
    "net/http"
    "github.com/k-washi/example-golang-rest-api/src/services"
    "github.com/gin-gonic/gin"
)

//GetHealthStatusOK status 200 response.
func GetHealthStatusOK(c *gin.Context) {
    result, err := services.HealthService.GetHealth()
    if err != nil {
        c.JSON(err.GetStatus(), err)
        return
    }
    c.JSON(http.StatusOK, result)

}

MVCモデルを考えた場合、先程実装したhealth/controller.goはContorollerにあたります。次にMVCモデルのModelにあたる機能(Service)を実装します。
SOLIDパターンにならって、HealthServiceを公開し、関数呼び出し時の初期化で構造体healthServiceを挿入します。また、HealthServiceの型として、healthServiceInterfaceを実装しています(この実装によりテスト時にMock作成が簡単になります)。このInterfaceにもhealthServiceに埋め込んだ関数を記載しておきます。

また、JSONで返答するデータを以下のような構造体としています。

src/domain/health/create_health.go
package health

type CreateHealthResponse struct {
    Status      int    `json:"status"`
    Description string `json:"description"`
}

※エラーに関しては、独自に作成したものを使用しています。git: example-golang-rest-api/src/utils/errors/を参考にしてください。

※SOLIDパターンに関しては、Hands-On Software Architecture with Golangを参考にしました。

src/services/health_service.go
package services

import (
    "net/http"

    "github.com/k-washi/example-golang-rest-api/src/domain/health"
    "github.com/k-washi/example-golang-rest-api/src/utils/errors"
)

type healthServiceInterface interface {
    GetHealth() (*health.CreateHealthResponse, errors.APIError)
}

var (
    //HealthService health check service
    HealthService healthServiceInterface
)

type healthService struct{}

func init() {
    HealthService = &healthService{}
}

func (s *healthService) GetHealth() (*health.CreateHealthResponse, errors.APIError) {
    result := health.CreateHealthResponse{
        Status:      http.StatusOK,
        Description: "Health check OK",
    }
    return &result, nil
}

実行

go run src/main.go

#もしappという名前でコンパイルするなら
go build -o app src/main.go 

テスト

ここでは、上記で実装したhealth/contoroller.goのGetHealthStatusOK関数のテストを行います。テストにはtestifyというライブラリを使用します。
テストのため、ファイル名の最後に_test.goを追加したファイルに実装します。また、テストを実行する関数にはTestという接頭語をつける必要があります。

まず、テスト対象がhealthService.GetHealth()と依存関係があるため、healthServiceのMockを作成します。そして、

func init() {
    HealthService = &healthService{}
} 

としていたHealthServiceにテストのために新しく作成したhealthServiceMockを挿入します。
実際にテストを行うTestGetHealthStatusOKというテスト関数の最初でhealthService.GetHealth()が返すデータを作成し、MockのGetHealth関数が返すgetHealthFunction関数の返り値として設定しています。この結果、healthService.GetHealthは、作成した返り値を返すように処理されます。

そして、/health/へのリクエストを作成し、GetHealthStatusOKを実行します。
テストは、実行後のresponseをtestifyの機能を用いて評価しています。

src/contorollers/health/health_contoroller_test.go
package health

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/assert"

    "github.com/gin-gonic/gin"

    "github.com/k-washi/example-golang-rest-api/src/domain/health"
    "github.com/k-washi/example-golang-rest-api/src/services"
    "github.com/k-washi/example-golang-rest-api/src/utils/errors"
)

/*
#依存関係のあるhealthService.GetHealthのモックを作成
*/
type healthServiceMock struct {
}

var (
    getHealthFunction func() (*health.CreateHealthResponse, errors.APIError)
)

func (m *healthServiceMock) GetHealth() (*health.CreateHealthResponse, errors.APIError) {
    return getHealthFunction()
}

func init() {
    services.HealthService = &healthServiceMock{}
}

//TestGetHealthStatusOK test of status 200 with service mock
func TestGetHealthStatusOK(t *testing.T) {
    exsistHealthResponse := health.CreateHealthResponse{
        Status:      http.StatusOK,
        Description: "Health check OK",
    }

    getHealthFunction = func() (*health.CreateHealthResponse, errors.APIError) {
        return &exsistHealthResponse, nil
    }

    response := httptest.NewRecorder()

    c, _ := gin.CreateTestContext(response)

    request, _ := http.NewRequest(http.MethodGet, "/health", nil)
    c.Request = request

    GetHealthStatusOK(c)

    assert.EqualValues(t, http.StatusOK, response.Code)

    //Conver the JSON response to a map
    var healthJSONResponse health.CreateHealthResponse
    err := json.Unmarshal([]byte(response.Body.String()), &healthJSONResponse)

    //Grab the balue & whether or not it exists
    statusVal := healthJSONResponse.Status
    descriptionVal := healthJSONResponse.Description

    assert.Nil(t, err)
    assert.Equal(t, exsistHealthResponse.Status, statusVal)
    assert.Equal(t, exsistHealthResponse.Description, descriptionVal)

}

テストの実行

以下のコマンドでテストを実行する。

go test ./...

Docker image & コンテナ化

本章では、Makefile、Dockerfile、Docker imageの作成を行い、コンテナ化して実際にヘルスチェックを実行します。

まず、以下のようなMakefileを作成します。
make helpで実行できるコマンドが表示されます。
また、make buildormake bin/appでコンパイルでき、make devel-depsでその他開発に必要なライブラリをインストールできます。

Makefile
NAME := example-golang-rest-api
VERSION := $(gobump show -r)
REVISION := $(shell git rev-parse --short HEAD)
LDFLAGS := "-X main.revision=$(REVISION)"

export GO111MODULE=on

## Install dependencies
.PHONY: deps
deps:
    go get -v -d

# 開発に必用な依存をインストールする
## Setup
.PHONY: deps
devel-deps: deps
    GO111MODULE=off go get \
        golang.org/x/lint/golint \
        github.com/motemen/gobump/cmd/gobump \
        github.com/Songmu/make2help/cmd/make2help

# テストの実行
## Run tests
.PHONY: test
test: deps
    go test ./...

## Lint
.PHONY: lint
lint: devel-deps
    go vet ./...
    golint -set_exit_status ./...

## build binaries ex. make bin/myproj
bin/%: ./src/main.go deps
    go build -ldflags $(LDFLAGS) -o $@ $<

## build binary
.PHONY: build
build: bin/app 

##Show heop
.PHONY: help
help:
    @make2help $(MAKEFILE_LIST)

次に、Dockerfileを作成します。
アプリケーションを実行するイメージを軽量化するため、ビルドイメージと実行イメージに分けマルチステージビルドを行います(今回のアプリケーションのDocker imageは20MB程度になる)。

ビルドステージでは、おそらくプロジェクト配下にgo.modファイルが作成されているであろうgo.mod, go.sumをコピーしてgo mod downloadで依存関係をインストールします。
そして、makeファイルでビルドしています。

実行ステージでは、コンパイル結果のappをビルドステージからコピーして、コンテナ化時の実行エントリーポイントと、8080ポートで公開するように設定しています。

FROM golang:1.12.7-alpine3.10 as build-step

RUN apk add --update --no-cache ca-certificates git make

ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64

WORKDIR /go-app
COPY go.mod .
COPY go.sum .

RUN go mod download
COPY . .

RUN make devel-deps
RUN make bin/app

#runtime image
FROM alpine
COPY --from=build-step /go-app/bin/app /app
ENTRYPOINT ["./app"]

EXPOSE 8080

以下のコマンドで、Docker image化できます。

docker build -t kwashizaki/example-golang-rest-api:v1.0.0 .

そして、以下のコマンドでコンテナ化できます。

docker run -it -p 8080:8080 --rm --name example-golang-rest-api kwashizaki/example-golang-rest-api:v1.0.0

以下のコマンドで実際にJSONが返ってくるか確認できます。

curl http://localhost:8080/health
#{"status":200,"description":"Health check OK"}

お疲れさまです。以上で、簡単ですが、GolangでWebアプリケーションを作成できました。

参考文献

kwashi
現在無職やめました。python, golang が好きです。最近興味があるトピックはARと教師なし学習です。プログラミングはほぼ初心者なので、勉強の際に悩んだところなどを共有できればと思います。
fusic
個性をかき集めて、驚きの角度から世の中をアップデートしつづける。
https://fusic.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away