26
20

More than 3 years have passed since last update.

go-gin + mysql + gorm + Dockerで開発用APIサーバーを立ててみた

Last updated at Posted at 2021-05-04

簡易なウェブアプリが作りたくて、そのときのメモです。
go-ginでAPIサーバーを立ててデータベースと通信できるようにします。

Github Repositoryを作りました。

OS

  • macOS Catalina

使う技術スタック

  • gin (ウェブサーバー)
  • dlv (デバッグ)
  • air (ホットリロード)
  • gorm (ORMライブラリ)
  • mysql
  • golang-migrate (マイグレーション)
  • docker

あると便利なアプリ

  • postman (rest api通信確認)
  • goland (デバッグ、開発)
  • sequel pro (データベース確認)

ディレクトリ構造

最終的には以下のようになっています。

.
├── backend
│   ├── Dockerfile.local
│   ├── article
│   │   └── article.go
│   ├── go.mod
│   ├── go.sum
│   ├── handler
│   │   ├── articleFunc.go
│   │   └── userFunc.go
│   ├── lib
│   │   └── sql_handler.go
│   ├── main.go
│   ├── migration
│   │   ├── main.go
│   │   └── migrations
│   │       ├── 1_articles.down.sql
│   │       ├── 1_articles.up.sql
│   │       ├── 2_users.down.sql
│   │       └── 2_users.up.sql
│   ├── tmp
│   │   └── main
│   └── user
│       └── user.go
├── docker-compose.yml
└── mysql
    ├── Dockerfile
    └── my.cnf

9 directories, 18 files

気をつけるところ

github.com/自分のアカウント/自分のレポジトリ/main.goが存在するフォルダのところは各自書き換えてください。

ディレクトリ

next-gin-mysqlという名前で立ち上げます。(nextをフロントエンドとして立ち上げたいのですが、記事の長さ的にそれは次回にします)

$ mkdir next-gin-mysql

バックエンド側

まずディレクトリを作ります。

next-gin-mysql $ mkdir backend

go moduleの用意

go modulesの用意をします。
github.com/自分のアカウント/自分のレポジトリ/main.goが存在するフォルダで初期化するとやりやすいと思います。

next-gin-mysql/backend $ go mod init github.com/greenteabiscuit/next-gin-mysql/backend

next-gin-mysql/backend $ go get -u github.com/gin-gonic/gin

これで ginとその依存パッケージがダウンロードされます。

それぞれのファイルの用意

サーバーを立ち上げるmain.goです。データベース用のlib、記事とユーザーのモデル、そしてハンドラはあとで書きます。

main.go
package main

import (
    "os"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/greenteabiscuit/next-gin-mysql/backend/article"
    "github.com/greenteabiscuit/next-gin-mysql/backend/handler"
    "github.com/greenteabiscuit/next-gin-mysql/backend/lib"
    "github.com/greenteabiscuit/next-gin-mysql/backend/user"
    "github.com/joho/godotenv"

    "github.com/gin-contrib/cors"
)

func main() {
    if os.Getenv("USE_HEROKU") != "1" {
        err := godotenv.Load()
        if err != nil {
            panic(err)
        }
    }

    article := article.New()
    user := user.New()

    lib.DBOpen()
    defer lib.DBClose()

    r := gin.Default()

    r.Use(cors.New(cors.Config{
        AllowOrigins: []string{
            "http://localhost:3000",
        },
        AllowMethods: []string{
            "POST",
            "GET",
            "OPTIONS",
        },
        AllowHeaders: []string{
            "Access-Control-Allow-Credentials",
            "Access-Control-Allow-Headers",
            "Content-Type",
            "Content-Length",
            "Accept-Encoding",
            "Authorization",
        },
        AllowCredentials: true,
        MaxAge:           24 * time.Hour,
    }))

    r.GET("/article", handler.ArticlesGet(article))
    r.POST("/article", handler.ArticlePost(article))
    r.POST("/user/login", handler.UserPost(user))

    r.Run(os.Getenv("HTTP_HOST") + ":" + os.Getenv("HTTP_PORT")) // listen and serve on 0.0.0.0:8080
}

記事を扱うarticleのハンドラパッケージです。

handler/articleFunc.go
package handler

import (
    "net/http"

    "github.com/greenteabiscuit/next-gin-mysql/backend/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.Article{
            Title:       requestBody.Title,
            Description: requestBody.Description,
        }
        post.Add(item)

        c.Status(http.StatusNoContent)
    }
}

userのハンドラ関数は以下です。

handler/userFunc.go
package handler

import (
    "net/http"

    "github.com/greenteabiscuit/next-gin-mysql/backend/user"

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

func UsersGet(users *user.Users) gin.HandlerFunc {
    return func(c *gin.Context) {
        result := users.GetAll()
        c.JSON(http.StatusOK, result)
    }
}

type UserPostRequest struct {
    Username string `json:"username"`
    Password string `json:"password"`
}

func UserPost(post *user.Users) gin.HandlerFunc {
    return func(c *gin.Context) {
        requestBody := UserPostRequest{}
        c.Bind(&requestBody)

        item := user.User{
            Username: requestBody.Username,
            Password: requestBody.Password,
        }
        post.Add(item)

        c.Status(http.StatusNoContent)
    }
}

articleの構造体などを定義するファイルです。

article/article.go
package article

import (
    "fmt"

    "github.com/greenteabiscuit/next-gin-mysql/backend/lib"
)

type Article struct {
    Title       string `json:"title"`
    Description string `json:"description"`
}

type Articles struct {
    Items []Article
}

func New() *Articles {
    return &Articles{}
}

func (r *Articles) Add(a Article) {
    r.Items = append(r.Items, a)
    db := lib.GetDBConn().DB
    if err := db.Create(a).Error; err != nil {
        fmt.Println("err!")
    }
}

func (r *Articles) GetAll() []Article {
    db := lib.GetDBConn().DB
    var articles []Article
    if err := db.Find(&articles).Error; err != nil {
        return nil
    }
    return articles
}

userの構造体をまとめたファイルです。ちょっとここはテキトーなのでもう少し直したい、、、

user/user.go
package user

import (
    "fmt"

    "github.com/greenteabiscuit/next-gin-mysql/backend/lib"
)

type User struct {
    Username string `json:"username"`
    Password string `json:"password"`
}

type Users struct {
    Items []User
}

func New() *Users {
    return &Users{}
}

func (r *Users) Add(a User) {
    r.Items = append(r.Items, a)
    db := lib.GetDBConn().DB
    if err := db.Create(a).Error; err != nil {
        fmt.Println("err!")
    }
}

func (r *Users) GetAll() []User {
    db := lib.GetDBConn().DB
    var users []User
    if err := db.Find(&users).Error; err != nil {
        return nil
    }
    return users
}

mysqlと接続を行う sql_handler.goも追加します。

lib/sql_handler.go
package lib

import (
    "fmt"
    "os"
    "time"

    "gorm.io/gorm"

    "gorm.io/driver/mysql"
)

// SQLHandler ...
type SQLHandler struct {
    DB  *gorm.DB
    Err error
}

var dbConn *SQLHandler

// DBOpen は DB connectionを張る。
func DBOpen() {
    dbConn = NewSQLHandler()
}

// DBClose は DB connectionを張る。
func DBClose() {
    sqlDB, _ := dbConn.DB.DB()
    sqlDB.Close()
}

// NewSQLHandler ...
func NewSQLHandler() *SQLHandler {
    user := os.Getenv("DB_USERNAME")
    password := os.Getenv("DB_PASSWORD")
    host := os.Getenv("DB_HOST")
    port := os.Getenv("DB_PORT")
    dbName := os.Getenv("DB_DATABASE")
    fmt.Println(user, password, host, port)

    var db *gorm.DB
    var err error
    // Todo: USE_HEROKU = 1のときと場合分け
    if os.Getenv("USE_HEROKU") != "1" {
        dsn := user + ":" + password + "@tcp(" + host + ":" + port + ")/" + dbName + "?parseTime=true&loc=Asia%2FTokyo"
        db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
        if err != nil {
            panic(err)
        }
    } /*else {
        var (
            instanceConnectionName = os.Getenv("DB_CONNECTION_NAME") // e.g. 'project:region:instance'
        )
        dbURI := fmt.Sprintf("%s:%s@unix(/cloudsql/%s)/%s?parseTime=true", user, password, instanceConnectionName, database)
        // dbPool is the pool of database connections.
        db, err = gorm.Open(mysql.Open(dbURI), &gorm.Config{})
        if err != nil {
            panic(err)
        }
    }*/

    sqlDB, _ := db.DB()
    //コネクションプールの最大接続数を設定。
    sqlDB.SetMaxIdleConns(100)
    //接続の最大数を設定。 nに0以下の値を設定で、接続数は無制限。
    sqlDB.SetMaxOpenConns(100)
    //接続の再利用が可能な時間を設定。dに0以下の値を設定で、ずっと再利用可能。
    sqlDB.SetConnMaxLifetime(100 * time.Second)

    sqlHandler := new(SQLHandler)
    db.Logger.LogMode(4)
    sqlHandler.DB = db

    return sqlHandler
}

// GetDBConn ...
func GetDBConn() *SQLHandler {
    return dbConn
}

// BeginTransaction ...
func BeginTransaction() *gorm.DB {
    dbConn.DB = dbConn.DB.Begin()
    return dbConn.DB
}

// Rollback ...
func RollBack() {
    dbConn.DB.Rollback()
}

ローカル開発環境の整備

docker-composeで一発でサーバーをすべて立ち上げるようにします。このときについでに mysqlの準備とデバッグサーバーの準備もしておきます。

docker-compose.yml
version: '3'
services:
    go:
        build:
            context: ./backend
            dockerfile: Dockerfile.local
        volumes:
            - ./backend:/go/src/backend
        working_dir: /go/src/backend
        environment:
            TZ: Asia/Tokyo
        ports:
            - 8080:8080
            - 2345:2345
        security_opt: 
            - apparmor:unconfined
        cap_add: 
            - SYS_PTRACE

    mysql:
        build: ./mysql
        environment:
            TZ: Asia/Tokyo
            MYSQL_ROOT_PASSWORD: root
            MYSQL_DATABASE: backend
        ports:
            - 13306:3306
        volumes:
            - mysql_volume:/var/lib/mysql

volumes:
    mysql_volume:

go用のDockerfileです。今後デプロイ用のDockerfileと分けるかもしれないので、一応Dockerfile.localにしておきます。

backend/Dockerfile.local
FROM golang:1.15.2
COPY . /go/src/sample
WORKDIR /go/src/sample
RUN go get -u github.com/cosmtrek/air
RUN go get -u github.com/go-delve/delve/cmd/dlv
CMD ["air", "-c", ".air.toml"]**

ホットリロードするために、airを使います。
airの設定ファイルである.air.tomlairの本家レポジトリから引っ張ってくることができます。

backend/.air.toml
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "."
tmp_dir = "tmp"
[build]
# Just plain old shell command. You could use `make` as well.
cmd = "go build -o ./tmp/main ."
# Binary file yields from `cmd`.
bin = "tmp/main"
# Customize binary.
full_bin = "APP_ENV=dev APP_USER=air /go/bin/dlv exec ./tmp/main --headless=true --listen=:2345 --api-version=2 --accept-multiclient"
# Watch these filename extensions.
include_ext = ["go", "tpl", "tmpl", "html"]
# Ignore these filename extensions or directories.
exclude_dir = ["assets", "tmp", "vendor", "frontend/node_modules", "storage"]
# Watch these directories if you specified.
include_dir = []
# Exclude files.
exclude_file = []
# Exclude unchanged files.
exclude_unchanged = true
# This log file places in your tmp_dir.
log = "air.log"
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 1000 # ms
# Stop running old binary when build errors occur.
stop_on_error = true
# Send Interrupt signal before killing process (windows does not support this feature)
send_interrupt = false
# Delay after sending Interrupt signal
kill_delay = 500 # ms
[log]
# Show log time
time = false
[color]
# Customize each part's color. If no color found, use the raw app log.
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
# Delete tmp directory on exit
clean_on_exit = true

mysqlの設定ファイルです。

mysql/my.cnf
# MySQLサーバーへの設定
[mysqld]
# 文字コード/照合順序の設定
character_set_server=utf8mb4
collation_server=utf8mb4_bin

# タイムゾーンの設定
default_time_zone=SYSTEM
log_timestamps=SYSTEM

# デフォルト認証プラグインの設定
default_authentication_plugin=mysql_native_password

# mysqlオプションの設定
[mysql]
# 文字コードの設定
default_character_set=utf8mb4

# mysqlクライアントツールの設定
[client]
# 文字コードの設定
default_character_set=utf8mb4

mysqlのDockerfileです。

FROM mysql:8.0.21
# FROM mysql@sha256:77b7e09c906615c1bb59b2e9d7703f728b1186a5a70e547ce2f1079ef4c1c5ca

RUN echo "USE mysql;" > /docker-entrypoint-initdb.d/timezones.sql &&  mysql_tzinfo_to_sql /usr/share/zoneinfo >> /docker-entrypoint-initdb.d/timezones.sql

COPY ./my.cnf /etc/mysql/conf.d/my.cnf

環境変数も設定しておきます。本番環境では見せないようにしましょう。
DB_HOSTはコンテナの名前で選ぶので、mysqlにしてあります。

backend/.env
HTTP_HOST=""
HTTP_PORT=8080
DB_HOST="mysql"
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=root
DB_DATABASE=backend

立ち上げてみる

サーバーを立ち上げてみましょう。以下のようにエラーが起きずに 2345が出てきたらとりあえずOKです。

docker-compose up --build

mysql_1  | 2021-05-04T16:29:33.735276+09:00 0 [Warning] [MY-011810] [Server] Insecure configuration for --pid-file: Location '/var/run/mysqld' in the path is accessible to all OS users. Consider choosing a different directory.
mysql_1  | 2021-05-04T16:29:33.770064+09:00 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.21'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.
go_1     | watching user
go_1     | building...
go_1     | running...
go_1     | API server listening at: [::]:2345

golandでの設定

golandで開発するのがやりやすいと思います。
今はデバッグのサーバしか立ち上がっていないので、ここで設定を行います。
delveのプロセスを走らせることで、APIサーバーも立ち上がるようになります。

メニューのdebugをクリックし、go remoteを選びます。

Screen Shot 2021-04-24 at 0.03.36.png

適当な名前をつけて、applydebugをクリックします。

Screen Shot 2021-04-24 at 0.03.54.png

これでコマンドラインでもサーバが立ち上がっています。ただまだマイグレーションができていないので、データベースなどからデータが取得できません。

go_1     | API server listening at: [::]:2345
go_1     | [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
go_1     | 
go_1     | [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
go_1     |  - using env:    export GIN_MODE=release
go_1     |  - using code:   gin.SetMode(gin.ReleaseMode)
go_1     | 
go_1     | [GIN-debug] GET    /article                  --> github.com/greenteabiscuit/next-gin-mysql/backend/handler.ArticlesGet.func1 (3 handlers)
go_1     | [GIN-debug] POST   /article                  --> github.com/greenteabiscuit/next-gin-mysql/backend/handler.ArticlePost.func1 (3 handlers)
go_1     | [GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
go_1     | [GIN-debug] Listening and serving HTTP on :8080

マイグレーション

golang-migrateを使ってmysqlデータベースを立ち上げます。migrationというフォルダをbackend直下に作ります。

backend $ mkdir migration

以下のような構造になります。main.goでmigrationの処理を書きます。

migration $ tree
.
├── main.go
└── migrations
    ├── 1_articles.down.sql
    ├── 1_articles.up.sql
    ├── 2_users.down.sql
    └── 2_users.up.sql

migrationの.env

先ほどと同じになりますが、なるべく見せないようにしましょう。今回はローカルなので大丈夫かと思いますが。

migration/.env
HTTP_HOST=""
HTTP_PORT=8080
DB_HOST="mysql"
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=root
DB_DATABASE=backend

migration の main.go

migration/main.go
package main

import (
    "database/sql"
    "flag"
    "fmt"
    "os"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "github.com/golang-migrate/migrate"
    "github.com/golang-migrate/migrate/database/mysql"
    _ "github.com/golang-migrate/migrate/source/file"
    "github.com/joho/godotenv"
    "github.com/pkg/errors"
)

var migrationFilePath = "file://./migrations/"

func main() {
    fmt.Println("start migration")
    flag.Parse()
    command := flag.Arg(0)
    migrationFileName := flag.Arg(1)

    if command == "" {
        showUsage()
        os.Exit(1)
    }

    if os.Getenv("USE_HEROKU") != "1" {
        err := godotenv.Load()
        if err != nil {
            fmt.Println(errors.Wrap(err, "load error .env"))
        }
    }

    m := newMigrate()
    version, dirty, _ := m.Version()
    force := flag.Bool("f", false, "force execute fixed sql")
    if dirty && *force {
        fmt.Println("force=true: force execute current version sql")
        m.Force(int(version))
    }

    switch command {
    case "new":
        newMigration(migrationFileName)
    case "up":
        up(m)
    case "down":
        down(m)
    case "drop":
        drop(m)
    case "version":
        showVersionInfo(m.Version())
    default:
        fmt.Println("\nerror: invalid command '", command, "'")
        showUsage()
        os.Exit(0)
    }
}

func generateDsn() string {
    apiRevision := os.Getenv("API_REVISION")
    var dsn string

    if apiRevision == "release" {
        dsn = os.Getenv("DATABASE_URL") + "&multiStatements=true" // heroku対応
    } else {
        user := os.Getenv("DB_USERNAME")
        pass := os.Getenv("DB_PASSWORD")
        host := os.Getenv("DB_HOST")
        port := os.Getenv("DB_PORT")
        dbName := os.Getenv("DB_DATABASE")

        dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&multiStatements=true", user, pass, host, port, dbName)
    }

    return dsn
}

func newMigrate() *migrate.Migrate {
    dsn := generateDsn()
    db, openErr := sql.Open("mysql", dsn)
    if openErr != nil {
        fmt.Println(errors.Wrap(openErr, "error occurred. sql.Open()"))
        os.Exit(1)
    }

    driver, instanceErr := mysql.WithInstance(db, &mysql.Config{})
    if instanceErr != nil {
        fmt.Println(errors.Wrap(instanceErr, "error occurred. mysql.WithInstance()"))
        os.Exit(1)
    }

    m, err := migrate.NewWithDatabaseInstance(
        migrationFilePath,
        "mysql",
        driver,
    )

    if err != nil {
        fmt.Println(errors.Wrap(err, "error occurred. migrate.NewWithDatabaseInstance()"))
        os.Exit(1)
    }
    return m
}

func showUsage() {
    fmt.Println(`
-------------------------------------
Usage:
  go run migration/main.go <command>
Commands:
  new FILENAME  Create new up & down migration files
  up        Apply up migrations
  down      Apply down migrations
  drop      Drop everything
  version   Check current migrate version
-------------------------------------`)
}

func newMigration(name string) {
    if name == "" {
        fmt.Println("\nerror: migration file name must be supplied as an argument")
        os.Exit(1)
    }
    base := fmt.Sprintf("./migration/migrations/%s_%s", time.Now().Format("20060102030405"), name)
    ext := ".sql"
    createFile(base + ".up" + ext)
    createFile(base + ".down" + ext)
}

func createFile(fname string) {
    if _, err := os.Create(fname); err != nil {
        panic(err)
    }
}

func up(m *migrate.Migrate) {
    fmt.Println("Before:")
    showVersionInfo(m.Version())
    err := m.Up()
    if err != nil {
        if err.Error() != "no change" {
            panic(err)
        }
        fmt.Println("\nno change")
    } else {
        fmt.Println("\nUpdated:")
        version, dirty, err := m.Version()
        showVersionInfo(version, dirty, err)
    }
}

func down(m *migrate.Migrate) {
    fmt.Println("Before:")
    showVersionInfo(m.Version())
    err := m.Steps(-1)
    if err != nil {
        panic(err)
    } else {
        fmt.Println("\nUpdated:")
        showVersionInfo(m.Version())
    }
}

func drop(m *migrate.Migrate) {
    err := m.Drop()
    if err != nil {
        panic(err)
    } else {
        fmt.Println("Dropped all migrations")
        return
    }
}

func showVersionInfo(version uint, dirty bool, err error) {
    fmt.Println("-------------------")
    fmt.Println("version : ", version)
    fmt.Println("dirty   : ", dirty)
    fmt.Println("error   : ", err)
    fmt.Println("-------------------")
}

マイグレーションのためのsqlファイル

1_articles.down.sql
drop table if exists articles
1_articles.up.sql
create table if not exists articles (
    id integer auto_increment primary key,
    title varchar(40),
    description varchar(40)
)
2_users.down.sql
drop table if exists users
2_users.up.sql
create table if not exists users (
    id integer auto_increment primary key,
    username varchar(40),
    password varchar(40)
)

マイグレーションする

dockerコンテナに入ってマイグレーションします。go run main.go upでマイグレーションが行われます。

$ docker exec -it container_name bash
# cd migration
# go run main.go up

start migration
Before:
-------------------
version :  0
dirty   :  false
error   :  no migration
-------------------

Updated:
-------------------
version :  2
dirty   :  false
error   :  <nil>
-------------------

これでdbでもテーブルができているのを確認したらOKです!
Sequel Proなどのアプリで確認できます。

Screen Shot 2021-05-04 at 16.38.57.png

PostmanでのAPI確認

これは Postmanというデスクトップアプリで確認することができます。

Post

JSON形式で送ります。

Screen Shot 2021-04-24 at 0.15.29.png

GET

Screen Shot 2021-04-24 at 0.15.40.png

データベースにも hogehello worldが入っているのが確認できました。

Screen Shot 2021-05-04 at 16.43.26.png

コマンドラインでもカラフルに表示されています。

Screen Shot 2021-05-04 at 16.59.52.png

まとめ

air + gin + gorm + golang-migrate + mysql + delve + dockerで開発用APIサーバーを立ち上げることができました。

今後試してみたいこと

  • herokuにデプロイしてみる
  • userのところの処理がテキトーなままなので直します、、、
  • フロントエンド(next + reactなど)とつなげてみる(そもそもレポジトリ名にnextを含んでいたので繋げようと思ったのですが、記事が長すぎてしまったので次回以降やりたいと思います。)

参考

go-ginでサクッとRESTAPIを構築する

golang,docker,mysqlの環境をherokuにデプロイする

26
20
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
26
20