はじめに
この記事では、Go言語のGin Web FrameworkとBun ORMを用いてWeb API作成する際に、その一助となる記事になることを目指しています。
Go言語のはじめの一歩としてはA Tour of GoやProgateなどがあります。こちらを一度行った上でこの記事を読むとより良いかと思います。
また、一部分のみの挙動を確認したい場合は、paiza.ioやThe Go Playgroundを活用すると気軽に確認が可能です。
動作環境
この記事の動作環境は以下のとおりとなっています。
OS: Mac OS Ventura 13.3.1(a)
homebrew: 4.0.18
Go: 1.20.4
Gin: 1.9.0
Bun: 1.1.12
Docker 23.0.5
Docker compose: 2.17.3
golangci-lint: 1.52.2
gci: 0.10.0
目次
- 環境構築
- Dockerを使った開発環境
- Gin Web Framework
- Bun Lightweight Golang ORM
- モデルの作成方法、データベースへのアクセス
- golangci-lintを導入
- おわりに
環境構築
Go言語のインストールとセットアップ
Go言語は公式サイトよりダウンロードが可能です。そちらよりダウンロードしていただき、インストールを行うと良いでしょう。また、homebrewを使われている方はそちらでもインストールが可能です。その場合はこちらのコマンドになります。
$ brew install go
homebrewを用いてインストールを行なった場合、PATHが通っていないため、自身でPATHの設定が必要となります。筆者の環境の場合、shellはzshを用いているため、.zshrcに以下の行を追加しています。他のshellを使っている場合は同様の内容を所定のファイルに記述してください。
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin
以上でGo言語のインストールは完了となります。Go言語がインストールできているか確認してみてください。
$ go version
> go version go1.20.4 darwin/arm64
$ ECHO $GOPATH
> /Users/hoge_user/go
Hello World
まず、Hello Worldを行うためのディレクトリを作成し、モジュールの初期化を行います。
$ mkdir hoge
$ cd hoge
$ go mod init hoge
> go: creating new go.mod: module hoge
$ ls
> go.mod
続いて、hello.go
を作成します。
$ touch hello.go
$ ls
> go.mod hello.go
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
実行する際には、go run
コマンドを使用します。
go run hello.go
> Hello World
また、Go言語はビルドすることでバイナリファイルを生成することができます。その際、オプションにて指定しない場合は初期化したモジュールの名前になります。
$ go build
$ ls
> go.mod hello.go hoge*
$ ./hoge
> Hello World
以上がHello Worldまでの手順となります。
Dockerを使った開発環境
Dockerfileとcompose.yaml
Dockerを使っての開発が主流になっているかと思います。Go言語の開発環境をDockerで構築することで、比較的容易に開発環境を共有することが可能となります。また、今回はRDBMSにMySQLを採用し開発を行なっていきます。docker composeを用いることでGoが動作するコンテナとMySQLが動作するコンテナの2つを管理していきます。
今回のDocker imageには容量は大きいですがDebianベースのイメージを用います。alpineなどでも問題なく開発は可能なので、用途に合わせて選択してください。今回のDockerfileの例は次のようになります。
FROM golang:1.20.4 AS build
CMD ["/bin/bash", "-b"]
ENV GOPATH=/go
RUN apt-get update && apt-get upgrade -y\
&& apt-get autoremove -y\
&& apt-get clean\
&& rm -rf /var/lib/apt/lists/*
WORKDIR /go/src
COPY ./src/go.mod /go/src/go.mod
COPY ./src/go.sum /go/src/go.sum
RUN go mod download
今回の環境ではgo mod init api
にて初期化を行なっています。また、.env
ファイルを用いて環境変数の設定を行なっています。.env
ファイルはcompose.yaml
と同一のディレクトリに格納しておくことをお勧めします。
DB_NAME=hoge
DB_USER=user
DB_USER_PASSWORD=password
DB_HOST=127.0.0.1
DB_ADDRESS=db
DB_COLLATION=utf8mb4_general_ci
DB_TZ=Asia/Tokyo
MYSQL_ROOT_PASSWORD=rootpassword
version: "3"
services:
api:
build:
context: .
dockerfile: Dockerfile
volumes:
- ./src:/go/src
ports:
- "${APP_PORT:-8080}:8080"
depends_on:
- db
env_file:
- ./.env
command: go run main.go
db:
image: mysql:8.0
volumes:
- data-volume:/var/lib/mysql
- ./db:/docker-entrypoint-initdb.d:ro
ports:
- "${DB_PORT:-3306}:3306"
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
TZ: "${DB_TZ}"
DB_USER_NAME: "${DB_USER}"
DB_PW: "${DB_USER_PASSWORD}"
DB_HOST: "${DB_HOST}"
DB_NAME: "${DB_NAME}"
./dbディレクトリにinit.sql
ファイルを配置しています。このファイルをdocker-emptrypoint-initdb.dにバインドすることでMySQLコンテナをビルドした際に実行されます。ユーザーを作成することでMySQLにアクセスする際、root以外のユーザーでアクセスすることができます。init.sqlの内容は次のようになります。
CREATE USER 'user'@'%' IDENTIFIED BY 'password';
GRANT ALTER, CREATE, DELETE, DROP, INSERT, REFERENCES, SELECT, UPDATE ON hoge.* TO 'user'@'%';
FLUSH PRIVILEGES;
CREATE DATABASE IF NOT EXISTS hoge;
docker compose up
コマンドを用いてコンテナを立ち上げます。正しくコンテナが立ち上がった場合、MySQLのコンテナは動作し続けますが、Goのコンテナは終了します。Goのコンテナも動作し続けるためにtty: true
の設定を入れる場合もありますが、後述するGinFWを開発する上では必要ないため今回は設定せずに進みます。
また、この記事ではsrcディレクトリ以下に実装を行なっていきます。最終的なディレクトリ構成は次のようになります。
src
├── app
│ ├── http
│ │ └── controllers
│ ├── models
│ ├── repositories
│ └── router.go
├── config
│ └── env.go
├── go.mod
├── go.sum
└── main.go
Gin Web Framework
Ginのドキュメントは日本語に対応しているため、公式ドキュメントを読みながら開発をしていくと良いかと思います。次の引用文も公式ドキュメントより引用しています。
Ginとは何か?
Gin は、Golang で書かれた Web フレームワークです。
martini に似た API を持ちながら、非常に優れたパフォーマンスを発揮し、最大で40倍高速であることが特徴です。
性能と優れた生産性が必要なら、きっと Gin が好きになれるでしょう。
ヘルスチェックエンドポイントの実装
クイックスタートのページに書かれている内容を踏襲すれば容易に実装することができます。
まず、example.go を作成します。
後述のコードが、example.go のファイルにあるとします。$ touch example.go
次に、下記のコードを example.go に書きます。
example.gopackage main import "github.com/gin-gonic/gin" func main() { r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ "message": "pong", }) }) r.Run() // 0.0.0.0:8080 でサーバーを立てます。 }
そして go run example.go でコードを実行します。
example.go を実行し、ブラウザで 0.0.0.0:8080/ping にアクセスする
$ go run example.go
作成したエンドポイントをcurlを使って叩いてみます。今回はGETメソッドで実装しているため、オプションは必要ありません。
$ curl http://localhost:8080/ping
> {"message":"pong"}%
この内容を踏まえてmain.goを実装していきます。main.goの肥大化を避けるため、ルートの設定をrouter.goで行うようにします。router.goはsrc/app/に配置します。その場合、次のようになります。
package main
import (
"log"
"api/app"
)
func main() {
router := app.SetRouter()
if err := router.Run(":8080"); err != nil {
log.Fatal(err)
}
}
package app
import "github.com/gin-gonic/gin"
func SetRouter() *gin.Engine {
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "Health check OK",
})
})
return r
}
.Run()メソッドは文字列を引数に取り、明示的にポートを指定することが可能です。上の例ではドキュメント同様に8080を指定していますが、他のポートにしたい場合はrouter.Run(":8888")
のように記述することでポート番号の指定が可能となっています。
ルートの設定を行うファイルを切り分けることで、ルートのグループ化において大きな有用性があると考えています。例えば、~/hoge/fuga
、~/hoge/piyo/read
、~/hoge/piyo/write
のように、共通なルートがある場合を考えます。もっとも単純な場合、次のように書くことができます。
package app
import "github.com/gin-gonic/gin"
func SetRouter() *gin.Engine {
r := gin.Default()
r.GET("/hoge/fuga")
r.GET("/hoge/piyo/read")
r.GET("/hoge/piyo/write")
return r
}
エンドポイントの数が多くない場合はこちらでも十分だと思います。しかし多くの場合は、コメントなどで書く順番などのルールを書くことになるかと思います。そこで、Ginではルートのグループ化を行うことでエンドポイントのネストを行うことが可能です。グループ化を行った場合は次のように書くことができます。
package app
import "github.com/gin-gonic/gin"
func SetRouter() *gin.Engine {
r := gin.Default()
hoge := r.Group("/hoge")
{
hoge.GET("/fuga")
hoge.GET("/piyo/*action")
}
return r
}
このように書くことで、エンドポイントのネストがわかりやすくなると思います。また、他にルートを設定していない場合、~/hoge/piyo
にリダイレクトしてくれるため、別途~/hoge/piyo
を記述する必要がない点もポイントです。
Bun Lightweight Golang ORM
Bun Lightweight Golang ORMはSQLite、PostgreSQL、MySQL、MSSQLで動作するORMです。マルチテナントやカスタムタイプに対応しているため、uuidなどを定義することも十分に可能になっています。
この記事で用いるデータベースは次のようになっています。
Bun ORMを使用してデータベースのマイグレーション
Bunを用いてマイグレーションを行う場合、2つのSQLファイルを用意する必要があります。1つはテーブルを作成するSQLファイル、もう一つはテーブルを削除するSQLファイルです。前者はyyyyMMddHHmmss_${hoge}.up.sql
、後者はyyyyMMddHHmmss_${hoge}.down.sql
となります。SQLファイルはmigrationを行うGoファイル以下のディレクトリに置くことが望ましいです。
この記事の場合、以下のようになります。
CREATE TABLE `stores` (
`id` integer PRIMARY KEY AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`phone` varchar(20) UNIQUE NOT NULL,
`address` varchar(255) UNIQUE,
`email` varchar(255) UNIQUE,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL
);
--bun:split
CREATE TABLE `books` (
`store_id` integer NOT NULL,
`id` integer PRIMARY KEY AUTO_INCREMENT,
`title` text NOT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL
);
--bun:split
CREATE TABLE `authors` (
`id` integer PRIMARY KEY AUTO_INCREMENT,
`book_id` integer NOT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL
);
--bun:split
ALTER TABLE `books` ADD FOREIGN KEY (`store_id`) REFERENCES `stores` (`id`);
--bun:split
ALTER TABLE `books` ADD FOREIGN KEY (`id`) REFERENCES `authors` (`book_id`);
SQLファイルでは、--bun:split
でテーブル間を分割する必要があります。他のSQLを使っている場合も同様のファイルを用いることでマイグレーションを行うことができます。また、命名ルールを遵守していれば、複数のSQLファイルを用意しても構いません。
Bunを用いてマイグレーションを行いますが、Bunはdatabase/sql
を前提として動作しています。また、MySQLの場合はそれ以外にgo-sql-driver/mysql
を用いることが前提となっています。こちらを用いてMySQLとGoのコンテナの接続に関して詳しくは説明しませんが、.env
ファイルで設定した環境変数を読み込むenv.go
とデータベースとの接続を行うdatabase.go
の例を示します。
package config
type DatabaseEnv struct {
Name string `env:"DB_NAME"`
User string `env:"DB_USER"`
Password string `env:"DB_USER_PASSWORD"`
Address string `env:"DB_ADDRESS"`
Collation string `env:"DB_COLLATION"`
}
package database
import (
"database/sql"
"fmt"
"os"
"time"
"github.com/caarlos0/env"
"github.com/go-sql-driver/mysql"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/mysqldialect"
"api/config"
)
func Connect() (*bun.DB, error) {
cfg := config.DatabaseEnv{}
if err := env.Parse(&cfg); err != nil {
return nil, err
}
jst, err := time.LoadLocation(os.Getenv("DB_TZ"))
if err != nil {
return nil, err
}
c := mysql.Config{
User: cfg.User,
Passwd: cfg.Password,
Net: "tcp",
Addr: cfg.Address,
DBName: cfg.Name,
Collation: cfg.Collation,
Loc: jst,
ParseTime: true,
AllowNativePasswords: true,
}
sqldb, err := sql.Open("mysql", c.FormatDSN())
if err != nil {
return nil, err
}
if err = sqldb.Ping(); err != nil {
return nil, err
}
db := bun.NewDB(sqldb, mysqldialect.New())
return db, nil
}
Bunを用いたDatabaseとの接続方法の箇所のみを抜き出したものが次になります。
sqldb, err := sql.Open("mysql", c.FormatDSN())
if err != nil {
return nil, err
}
if err = sqldb.Ping(); err != nil {
return nil, err
}
db := bun.NewDB(sqldb, mysqldialect.New())
go-sql-driver/mysql
で接続を確保し、pingを飛ばして接続を確認しています。その後、Bun.NewDB()でラップすることでBunでの接続を作っている形になります。こちらで確保した接続を用いてマイグレーションを行なっていきます。
まずは、bunのmigrationに用いるインスタンスを生成する必要があります。bun/migrate
のmigrate.Migrations()メソッドを使い、インスタンスを作成します。その後DiscoverCaller()メソッドを用いてSQLファイルの検索を行います。その後、Migratorインスタンスを生成し、データベースにmigrationテーブルを作成します。その後にSQLファイルを使用してマイグレーションを行います。実際のコードでは次のようになります。
package database
import (
"context"
"fmt"
"github.com/uptrace/bun/migrate"
"api/database"
)
func Migration() {
var migrations = migrate.NewMigrations()
db, err := database.Connect()
if err != nil {
panic(err)
}
defer db.Close()
if err := migrations.DiscoverCaller(); err != nil {
panic(err)
}
c := context.Background()
migrator := migrate.NewMigrator(db, migrations)
if err := migrator.Init(c); err != nil {
panic(err)
}
group, err := migrator.Migrate(c)
if err != nil {
panic(err)
}
if group.IsZero() {
fmt.Printf("there are no new migrations to run (database is up to date)\n")
panic(err)
}
}
モデルの作成方法、データベースへのアクセス
Bunでデータベースを操作する場合モデルという概念を用います。Goの場合はstructを定義しBunタグを設定することでモデルを表しています。詳しくは公式ドキュメントを参照するのが良いですが、例としてStoresテーブルとBooksテーブルを示します。
package models
import (
"time"
"github.com/uptrace/bun"
)
type Store struct {
bun.BaseModel `bun:"table:stores"`
ID int `bun:"id,pk"`
Name string `bun:"name,notnull"`
Phone string `bun:"phone,notnull,unique"`
Address string `bun:"address,unique"`
Email string `bun:"email,unique"`
CreatedAt time.Time `bun:",nullzero,default:current_timestamp"`
UpdatedAt time.Time `bun:",nullzero,default:current_timestamp"`
Books []*Book `bun:"rel:has-many,join:id=store_id"`
}
package models
import (
"time"
"github.com/uptrace/bun"
)
type Book struct {
bun.BaseModel `bun:"table:books"`
Store *Store `bun:"rel:belongs-to,join:store_id=id"`
StoreID int `bun:"store_id,notnull"`
ID int `bun:"id,pk"`
title string `bun:"title,notnull"`
CreatedAt time.Time `bun:",nullzero,default:current_timestamp"`
UpdatedAt time.Time `bun:",nullzero,default:current_timestamp"`
}
このように書くことができます。StoresテーブルとBooksテーブルは1対多の関係にあるため、Store structにhas-many、Book structにbelongs-toを書くことになります。また、タグ内の""で囲われた箇所はカンマ(,)の後ろにスペースを入れたくなりますが、カンマを入れると正しく動作しないため、間を空けずに記述する必要があります。
次に、この作成したモデルを用いてデータをデータベースから取り出します。1件のみ取り出す場合は作成したモデルの*struct
が必要になります。複数件の場合は[]*struct
を用いて取り出します。以下にデータを取り出す際の例を示します。
db, err := database.Connect()
if err != nil {
return nil, err
}
defer db.Close()
stores := make([]*Store, 0)
if err := db.NewSelect().Relation("Book").Scan(context.Background()); err != nil {
panic(err)
}
取り出すデータの絞り込みに必要なWhereなどもあるため、必要に応じて使っていく必要があります。
golangci-lintを導入
Go言語は標準のformatterが非常に優秀なため、個人開発で行う場合はlinterなどを導入しなくても大きく問題になることは少ないと思います。しかし、複数人で開発を行なっていく場合、importの順番などを統一しておくことでより良い開発環境を整備することが可能になります。そこで導入を推すのがgolangci-lintになります。gciやgofmtなどを個別に管理する必要がないため、有用です。また、Github Actionsで動作するgolangci-lint-actionも準備されているため、pull_requestの際に動作するように設定することで、コードの細かな揺れの抑止につながります。
golangci-lintはデフォルトではgciなどは有効化されていないため、.golangci.yml
ファイルに設定を記述することで必要なlinterを有効化する必要があります。次はその一例になります。
run:
timeout: 5m
skip-dirs:
- "github.com/go-sql-driver/mysql"
skip-dirs-use-default: false
go: '1.20'
linters:
enable:
- bodyclose
- containedctx
- cyclop
- dogsled
- dupl
- errname
- errorlint
- exportloopref
- forcetypeassert
- gci
- gocognit
- goconst
- gofmt
- gosec
- lll
- nilerr
- nilnil
- rowserrcheck
- sqlclosecheck
- tagliatelle
- unconvert
- whitespace
linters-settings:
gci:
sections:
- standard
- default
- prefix(api)
lll:
tab-width: 0
line-length: 120
Github Actionsで動作させるためにはworkflowを設定する必要があります。こちらの設定は公式のREADMEに記載されている内容を踏襲していただければ良いかと思います。golangci-lint.yaml
に設定を行う例になります。
name: golangci
on: [pull_request]
jobs:
#略
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
working-directory: ./src
args: --config=.golangci.yml
CIを導入することで、レビューを行う際などにその一助となります。型や構文のエラー、適切なエラーハンドリングなどは気にする必要性が減少するため、コードの中身や動作に集中することができます。
おわりに
GinやBunを使ってのAPI開発の基本的な方法は以上になります。この記事でも紹介しきれていない内容もあるので、公式のドキュメントなども見た上で開発していくと良いかと思います。
誤った情報などありましたら、ご指摘いただきますと幸いです。