簡易なウェブアプリが作りたくて、そのときのメモです。
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
、記事とユーザーのモデル、そしてハンドラはあとで書きます。
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のハンドラパッケージです。
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のハンドラ関数は以下です。
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の構造体などを定義するファイルです。
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の構造体をまとめたファイルです。ちょっとここはテキトーなのでもう少し直したい、、、
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
も追加します。
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の準備とデバッグサーバーの準備もしておきます。
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にしておきます。
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.toml
はairの本家レポジトリから引っ張ってくることができます。
# 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サーバーへの設定
[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
にしてあります。
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を選びます。
適当な名前をつけて、apply
とdebug
をクリックします。
これでコマンドラインでもサーバが立ち上がっています。ただまだマイグレーションができていないので、データベースなどからデータが取得できません。
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
先ほどと同じになりますが、なるべく見せないようにしましょう。今回はローカルなので大丈夫かと思いますが。
HTTP_HOST=""
HTTP_PORT=8080
DB_HOST="mysql"
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=root
DB_DATABASE=backend
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ファイル
drop table if exists articles
create table if not exists articles (
id integer auto_increment primary key,
title varchar(40),
description varchar(40)
)
drop table if exists users
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などのアプリで確認できます。
PostmanでのAPI確認
これは Postmanというデスクトップアプリで確認することができます。
Post
JSON
形式で送ります。
GET
データベースにも hoge
と hello world
が入っているのが確認できました。
コマンドラインでもカラフルに表示されています。
まとめ
air + gin + gorm + golang-migrate + mysql + delve + dockerで開発用APIサーバーを立ち上げることができました。
今後試してみたいこと
- herokuにデプロイしてみる
- userのところの処理がテキトーなままなので直します、、、
- フロントエンド(next + reactなど)とつなげてみる(そもそもレポジトリ名に
next
を含んでいたので繋げようと思ったのですが、記事が長すぎてしまったので次回以降やりたいと思います。)