Go + MySQL + nginxの開発環境をDocker(docker-compose)で作る

# やりたいこと

- Go、MySQL、nginxの開発環境をDocker(docker-compose)で作る
- Goの外部パッケージはGo Modulesで管理
- Goのプロジェクトを実践的なものにする
- DBのテーブル管理はマイグレーションを使う
- testはテスト用のDBを使う

# こんな人におすすめ

- GoとMySQLでAPIサーバーの開発がしたい
- 環境構築はDockerで手っ取り早く済ませたい
- 拡張しやすいGoのプロジェクトが欲しい

# 使用するフレームワーク、バージョン

|    |  バージョン  |
| ---- | ---- |
|  Go  |  1.15 |
|  MySQL  |  5.7  |
|  nginx  |  1.19 |

Goのフレームワークは[Gin](https://github.com/gin-gonic/gin)、ORMに[GORM](https://gorm.io/docs/)を使用。

# ディレクトリ構成

```
├── docker-compose.yml
├── Dockerfile
├── app
│   ├── cmd
│   │   ├── migrate
│   │   │   └── main.go
│   │   └── server
│   │       └── main.go
│   ├── db
│   │   └── migrations
│   │       ├── 1_create_users.down.sql
│   │       └── 1_create_users.up.sql
│   ├── go.mod
│   ├── go.sum
│   └── pkg
│       ├── connecter
│       │   └── connecter.go
│       ├── controller
│       │   ├── router.go
│       │   └── users.go
│       └── model
│           ├── main_test.go
│           ├── user.go
│           └── user_test.go
├── mysql
│   ├── Dockerfile
│   ├── docker-entrypoint-initdb.d
│   │   └── init.sql
│   └── my.cnf
└── nginx
    ├── Dockerfile
    └── default.conf
```

ルートディレクトリにあるDockerfileがGoのコンテナ用です。

# 使い方

GitHubレポジトリはこちらにあります。
https://github.com/fuhiz/docker-go-sample

まずはdocker-compose.ymlがあるディレクトリでコンテナを立ち上げます。

```
$ docker-compose up -d --build
```

Goのコンテナに入ります。

```
$ docker-compose exec web bash
```

Goのコンテナの/appでマイグレーションを実行します。
実行されるSQLはapp/db/migrationsのファイルです。
usersとマイグレーション管理のためのschema_migrationsが作られます。

```
$ go run cmd/migrate/main.go -exec up
```

usersには名前(name)、年齢(age)、日時カラムを用意しました。

```sql:/db/migrations/1_create_users.up.sql
CREATE TABLE `users` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL,
  `age` int NOT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  `deleted_at` datetime,
  PRIMARY KEY (`id`)
) DEFAULT CHARSET=utf8mb4;
```

サーバーを起動します。

```
$ go run cmd/server/main.go
```

これでlocalhost:8082でGoのAPIにつながるようになります。

# APIを使ってみる

DBにはusersテーブルがあって、ユーザーのCRUD機能が使えるようになっているので、curlで確認します。

- ユーザー作成

```
$ curl localhost:8082/api/v1/users -X POST -H "Content-Type: application/json" -d '{"name": "test", "age":30}'
```

- ユーザー一覧

```
$ curl localhost:8082/api/v1/users
{"users":[{"ID":1,"CreatedAt":"2021-01-09T11:09:31+09:00","UpdatedAt":"2021-01-09T11:09:31+09:00","DeletedAt":null,"name":"test","age":30}]}%
```
先ほど作ったユーザーが取得できます。

- ユーザー更新

```
$ curl localhost:8082/api/v1/users/1 -X PATCH -H "Content-Type: application/json" -d '{"name": "update", "age":31}'
```

- ユーザー削除

```
$ curl localhost:8082/api/v1/users/1 -X DELETE
```

# docker-compose.yml

ここから環境構築の細かいところを見ていきます。

docker-compose.ymlの基本的な書き方には触れないので、参考にされる方はこちらを。
https://qiita.com/zembutsu/items/9e9d80e05e36e882caaa

それぞれのserviceについてはこのようになっております。

#### db

- MySQLコンテナ
- ユーザー名やパスワードなどを環境変数で定義。
- docker-entrypoint-initdb.dをマウントして、コンテナ起動時にdocker-entrypoint-initdb.d/init.sqlが実行されるようにします。init.sqlでgo_sampleとgo_sample_testというデータベースを作ります。
/docker-entrypoint-initdb.dはMySQLのDockerイメージに備わっているディレクトリで初期データを作ることができます。
- ホスト側のポートが3310なのはローカルで動かすMySQLと被らないようにするためです。
- Sequel Proで接続するときはこうなります。

![スクリーンショット 2021-01-03 11.24.13 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323635/b0fbe8a8-e317-056d-dcc8-04900f171c1e.png)
※パスワードはlocalpass。データベースは空でも構わないです。

#### web

- Goコンテナ
- 起動後すぐにコンテナが閉じてしまわないようにtty: trueでコンテナを永続化します。
(サーバー起動をDockerfileに書かず、コンテナの中で手動で実行するためです)
- Goプロジェクト内で使う環境変数を定義。Goのコードで`os.Getenv("DB_PASSWORD")`とすればこの値が読み込めます。DB_HOSTのdbはMySQLコンテナのサービス名です。GORMでDB接続するときにこのサービス名で接続できます。
- Goプロジェクトがある./app(ホスト)を/app(コンテナ)にマウントします。コンテナ内の/appはDockerfileのWORKDIRで指定したときに作成されます。

#### proxy
- nginxはリバースプロキシによってURLを転送します。ここでは`http://localhost`がGoのAPIになるように設定する目的で使います。
- ホスト側のportは8082を指定しました。

```docker-compose.yml
version: "3"

services:
  db:
    build: ./mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: localuser
      MYSQL_PASSWORD: localpass
      TZ: Asia/Tokyo
    volumes:
      - ./mysql/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
    ports:
      - "3310:3306"

  web:
    build: .
    tty: true
    environment:
      APP_MODE: local
      DB_PASSWORD: localpass
    volumes:
      - "./go:/app"
    depends_on:
      - db

  proxy:
    build: ./nginx
    ports:
      - 8082:80
    depends_on:
      - web
```

# MySQLコンテナ
MySQLのDockerfileはこれらの一般的な設定です。

タイムゾーンをAsia/Tokyoにする。
設定ファイルのmy.cnfをコピーする。
起動時に実行するinit.sqlをコピーする。

```docker:mysql/Dockerfile
FROM mysql:5.7

ENV TZ Asia/Tokyo
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && chown -R mysql:root /var/lib/mysql/

COPY my.cnf /etc/mysql/conf.d/my.cnf
COPY docker-entrypoint-initdb.d/init.sql /docker-entrypoint-initdb.d/

CMD ["mysqld"]

EXPOSE 3306
```

init.sqlではgo_sampleとテスト用のgo_sample_testを作ってユーザーの権限設定をします。

```sql:mysql/docker-entrypoint-initdb.d/init.sql
CREATE DATABASE IF NOT EXISTS `go_sample` COLLATE 'utf8mb4_general_ci' ;
CREATE DATABASE IF NOT EXISTS `go_sample_test` COLLATE 'utf8mb4_general_ci' ;

GRANT ALL ON `go_sample`.* TO 'localuser'@'%' ;
GRANT ALL ON `go_sample_test`.* TO 'localuser'@'%' ;

FLUSH PRIVILEGES ;
```

# Goコンテナ

内容はコメントの通りで、外部パッケージをダウンロードするためにgo.modとgo.sumを事前にコピーしています。

```docker:Dockerfile
FROM golang:1.15

## 作業ディレクトリ
WORKDIR /app

# モジュール管理のファイルをコピー
COPY go/go.mod .
COPY go/go.sum .

# 外部パッケージのダウンロード
RUN go mod download

EXPOSE 9000
```

# nginxコンテナ

nginx.confで読み込むdefault.confをコピーします。

```docker:nginx/Dockerfile
FROM nginx:1.19-alpine

COPY ./default.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
```

```nginx/default.conf
server {
    listen 80;
    server_name localhost;

    location / {
        proxy_pass http://web:9000;
    }
}
```

nginxの設定ファイルである/etc/nginx/nginx.confで/etc/nginx/conf.d/配下の*.confを読み込むようになっているので、読み込まれる部分だけを作っています。

nginxの設定はこちらが参考になります。
https://qiita.com/morrr/items/7c97f0d2e46f7a8ec967

大事なのは`proxy_pass http://web:9000;`の部分で、ここで`http://localhost`を`http://web:9000`に置き換えています。
webはdocker-compose.ymlで定義したGoコンテナのサービス名です。
docker-composeはサービス間でネットワーク通信できるので、このような指定ができます。
Goプロジェクトはポートを9000でサーバーを立ち上げているのでポートはそれに合わせます。

また、docker-compose.ymlのnginxコンテナでポートを8082:80としているので、ホストからは`http://localhost:8082`でアクセスします。

ややこしいですが、とどのつまりは`http://localhost:8082`でGoのAPIが叩けることになります。

# Goのプロジェクト概要

Goのコードはなるべく実践的に使えるものを意識して作りました。
ディレクトリ構造はこちらを参考にしています。
https://qiita.com/sueken/items/87093e5941bfbc09bea8

**cmd**
アプリケーションのエントリーポイント。
サーバー起動とマイグレーション機能を配置。

**db**
マイグレーションで実行したいsqlファイルを配置。

**pkg**
アプリケーションの挙動に関わる部分。
モデル(model)、コントローラー(controller)、接続(connecter)を作成。

# マイグレーション

マイグレーション周りはこちらを参考にさせていただきました。
https://qiita.com/tanden/items/7b4fb1686a61dd5f580d

[golang-migrate](https://github.com/golang-migrate/migrate)を使用して、db/migrationsにあるsqlファイルでDBを管理します。

ファイル名のルールは{version}を昇順にすれば、番号でもタイムスタンプでも問題ありません。
https://github.com/golang-migrate/migrate/blob/master/MIGRATIONS.md

```
{version}_{title}.up.{extension}
{version}_{title}.down.{extension}
```

ここではusersテーブルを作成する`1_create_users.up.sql`とテーブル削除用の`1_create_users.down.sql`を作成しました。

マイグレーション管理のファイルは`cmd/migrate/main.go`にあります。内容は[参考サイト](https://qiita.com/tanden/items/7b4fb1686a61dd5f580d)のほぼコピペになります。

このファイルを実行すれば追加した分の*.up.sqlだけが走ります。

```
$ go run cmd/migrate/main.go -exec up
```

戻したいときはオプションをdownにすれば、全ての*.down.sqlが実行されます。

```
$ go run cmd/migrate/main.go -exec down
```

test用のデータベースに接続したいときはAPP_MODE=testで環境変数つきで実行します。

```
$ APP_MODE=test go run cmd/migrate/main.go -exec up
```

cmd/migrate/main.goのinit()でAPP_MODEがtestなら、データベースはDB_NAME_TESTを使うようにしてます。

```cmd/migrate/main.go
func init() {
	// database name decide by APP_MODE
	dbName := os.Getenv("DB_NAME")
	if os.Getenv("APP_MODE") == "test"{
		dbName = os.Getenv("DB_NAME_TEST")
	}

	Database = fmt.Sprintf("mysql://%s:%s@tcp(%s:%s)/%s",
		os.Getenv("DB_USER"),
		os.Getenv("DB_PASSWORD"),
		os.Getenv("DB_HOST"),
		os.Getenv("DB_PORT"),
		dbName)
}
```

# GoのAPIの処理の流れ

エントリーポイントとなるファイルはcmd/server/main.go。
ginを使って、ポート9000でサ－バーを立ち上げています。

```cmd/server/main.go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"

	"github.com/fuhiz/docker-go-sample/app/pkg/connecter"
	"github.com/fuhiz/docker-go-sample/app/pkg/controller"
)

func main() {
	// gormのDB接続
	connecter.Setup()

	router := gin.Default()

	// apiの疎通確認用
	router.GET("/", func(c *gin.Context) {
		c.String(http.StatusOK, "Response OK")
	})

	// routing
	r := router.Group("/api/v1")
	controller.Setup(r)

	router.Run(":9000")
}

```

細かい処理は/pkgのcontrollerなどを使っていきます。

gormのDB接続はpkg/connecter/connecter.goで行います。
変数dbに*gorm.DBを格納して、DB()で呼び出せる形になっています。

接続の仕方は公式を見れば大体把握できます。
https://gorm.io/docs/connecting_to_the_database.html

データベースの各パラメータはdocker-compose.ymlで定めた環境変数から取得しています。
ここでもAPP_MODEがtestならDB_NAME_TESTを使います。

```pkg/connecter/connecter.go
package connecter

import (
	"fmt"
	"os"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

var db *gorm.DB

func Setup() {
	// APP_MODEからデータベース名を決める
	dbName := os.Getenv("DB_NAME")
	if os.Getenv("APP_MODE") == "test"{
		dbName = os.Getenv("DB_NAME_TEST")
	}

	// DB接続 (https://gorm.io/docs/connecting_to_the_database.html)
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&loc=%s",
		os.Getenv("DB_USER"),
		os.Getenv("DB_PASSWORD"),
		os.Getenv("DB_HOST"),
		os.Getenv("DB_PORT"),
		dbName,
		os.Getenv("DB_LOC"))
	gormDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

	if err != nil {
		panic(err)
	}

	db = gormDB
}

func DB() *gorm.DB {
	return db
}
```

ルーティングはpkg/controllers/router.goに書きます。
それぞれpkg/controllers/users.goのfuncを呼びます。

```pkg/controllers/router.go
package controller

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

func Setup(r *gin.RouterGroup) {
	users := r.Group("/users")
	{
		u := UserController{}
		users.GET("", u.Index)
		users.GET("/:id", u.GetUser)
		users.POST("", u.CreateUser)
		users.PATCH("/:id", u.UpdateUser)
		users.DELETE("/:id", u.DeleteUser)
	}
}
```

pkg/controllers/users.goでは処理に応じて/pkg/model/user.goのfuncを呼びます。

```pkg/controllers/users.go
package controller

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"

	"github.com/fuhiz/docker-go-sample/app/pkg/connecter"
	"github.com/fuhiz/docker-go-sample/app/pkg/model"
)

type UserController struct{}

type UserParam struct {
	Name string `json:"name" binding:"required,min=1,max=50"`
	Age  int    `json:"age" binding:"required,number"`
}

// ユーザー取得
func (self *UserController) GetUser(c *gin.Context) {
	ID := c.Params.ByName("id")
	userID, _ := strconv.Atoi(ID)
	user, err := model.GetUserById(connecter.DB(), userID)

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"user": user})
}

// ユーザー一覧
func (self *UserController) Index(c *gin.Context) {
	users, err := model.GetUsers(connecter.DB())

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "user search failed"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"users": users})
}

// ユーザー作成
func (self *UserController) CreateUser(c *gin.Context) {
	var param UserParam
	if err := c.BindJSON(&param); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	newUser := model.NewUser(param.Name, param.Age)
	user, err := model.CreateUser(connecter.DB(), newUser)

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "user create failed"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"user": user})
}

// ユーザー更新
func (self *UserController) UpdateUser(c *gin.Context) {
	ID := c.Params.ByName("id")
	userID, _ := strconv.Atoi(ID)
	user, err := model.GetUserById(connecter.DB(), userID)

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
		return
	}

	var param UserParam
	if err := c.BindJSON(&param); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	updateParam := map[string]interface{}{
		"name": param.Name,
		"age":  param.Age,
	}

	_, err = user.Update(connecter.DB(), updateParam)

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "user update failed"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"user": user})
}

// ユーザー削除
func (self *UserController) DeleteUser(c *gin.Context) {
	ID := c.Params.ByName("id")
	userID, _ := strconv.Atoi(ID)
	user, err := model.GetUserById(connecter.DB(), userID)

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
		return
	}

	_, err = user.Delete(connecter.DB())

	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "user delete failed"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"deleted": true})
}
```

```/pkg/model/user.go
package model

import (
	"gorm.io/gorm"
)

type User struct {
	gorm.Model
	Name string `json:"name"`
	Age  int    `json:"age"`
}

func NewUser(name string, age int) *User {
	return &User{
		Name: name,
		Age:  age}
}

func CreateUser(db *gorm.DB, user *User) (*User, error) {
	result := db.Create(&user)

	return user, result.Error
}

func GetUsers(db *gorm.DB) ([]*User, error) {
	users := []*User{}
	result := db.Find(&users)

	return users, result.Error
}

func GetUserById(db *gorm.DB, ID int) (*User, error) {
	user := User{}
	result := db.First(&user, ID)

	return &user, result.Error
}

func (user *User) Update(db *gorm.DB, param map[string]interface{}) (*User, error) {
	result := db.Model(&user).Updates(param)

	return user, result.Error
}

func (user *User) Delete(db *gorm.DB) (*User, error) {
	result := db.Delete(&user)

	return user, result.Error
}
```

# テスト

テストはGoのコンテナ内でAPP_MODE=testをつけて実行します。
以下手順。

マイグレーション(up)でgo_sample_testにテーブルを作成。

```
$ APP_MODE=test go run cmd/migrate/main.go -exec up
```

/pkgをテスト。

```
$ APP_MODE=test go test -v ./pkg/...
```

次のテストのためにgo_sample_testを戻す。

```
$ APP_MODE=test go run cmd/migrate/main.go -exec down
```


テストファイルは/pkg/modelにmain_test.goとuser_test.goがあります。
TestMainが最初に実行されるので、そこでDB接続しときます。

```/pkg/model/main_test.go
package model_test

import (
	"testing"

	"github.com/fuhiz/docker-go-sample/app/pkg/connecter"
)

func TestMain(m *testing.M) {
	connecter.Setup()
	m.Run()
}
```

ユーザー作成のテスト。

```/pkg/model/user_test.go
package model_test

import (
	"testing"

	"github.com/fuhiz/docker-go-sample/app/pkg/connecter"
	"github.com/fuhiz/docker-go-sample/app/pkg/model"
)

func TestCreateUser(t *testing.T) {
	newUser := model.NewUser("test_user", 30)
	user, _ := model.CreateUser(connecter.DB(), newUser)

	if user.Name != "test_user" {
		t.Fatal("model.CreateUser Failed")
	}
}
```


# まとめ

ローカル環境としてはそれなりに使える環境が整えられたと思います。
自動テストやデプロイにも対応できるかは今後検証していきたいです。

Goはまだまだベストプレクティスが確立されていないようでテスト環境の切り分けは苦労しました。
改めてLaravelやRailsのような全部入りのフレームワークの偉大さも感じました。

長めの記事でしたが参考にしてもらえたらありがたいです！