やりたいこと
- 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 |
ディレクトリ構成
├── 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)、日時カラムを用意しました。
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で接続するときはこうなります。
※パスワードは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を指定しました。
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をコピーする。
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を作ってユーザーの権限設定をします。
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を事前にコピーしています。
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をコピーします。
FROM nginx:1.19-alpine
COPY ./default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
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を使用して、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
にあります。内容は参考サイトのほぼコピペになります。
このファイルを実行すれば追加した分の*.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を使うようにしてます。
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でサ-バーを立ち上げています。
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を使います。
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を呼びます。
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を呼びます。
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(¶m); 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(¶m); 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})
}
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接続しときます。
package model_test
import (
"testing"
"github.com/fuhiz/docker-go-sample/app/pkg/connecter"
)
func TestMain(m *testing.M) {
connecter.Setup()
m.Run()
}
ユーザー作成のテスト。
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のような全部入りのフレームワークの偉大さも感じました。
長めの記事でしたが参考にしてもらえたらありがたいです!