まえがき
前記事でGoaプロジェクトの環境構築を行なった。本記事ではgoaプロジェクトでgolang-migrationとgormを利用してCRUD APIを実装してみる。
① docker compose up でPostgreSQLコンテナ起動
② golang-migration
でDBスキーマ作成
③ gorm
でEntity作成・DB接続・CRUD処理実装
④ 動作確認
ソースだけ見たい人向け。
goaのGitHubにexampleプロジェクトがいくつかあるので、プロジェクト構成やコーディング等参考になるのでリンクを貼っておく。
プロジェクト構成
/api
/model
/database
/repository
product.go【DBのCRUD処理】
/entity
product.go【Entity定義】
connection.go【DB接続処理】
/svc
/design
product.go【デザイン定義】
/request
product.go【リクエストパラメータ】
/response
product.go【レスポンスパラメータ】
/gen ← goa gen で生成される。手でいじらない。
/cmd ← goa example で生成される。
/inventory_system
main.go【アプリ起動時に実行されるmain関数あり】
inventory.go ← goa example で生成される。
/db/migrations【golang-migration用ファイルを格納】
000001_create_product_table.up.sql
000001_create_product_table.down.sql
000002_add_product.up.sql
000002_add_product.down.sql
docker-compose.yml
.env【godotenvでアプリ起動時に読み込む/docker-compose up時に読み込む】
.env_template ※ .envはGitHubにあげずこれをあげる。利用する時は「.env」にリネーム
① docker compose up でPostgreSQLコンテナ起動
・コンテナ起動用のYAMLと.envを任意のディレクトリに配置してdocker compose up -d
で起動。
docker-compose.yml
version: "3.8"
services:
dbms:
image: postgres:latest
restart: always
environment:
TZ: ${OS_TIMEZONE}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d postgres -U postgres"]
interval: 10s
timeout: 10s
retries: 5
ports:
- ${POSTGRES_PORT}:5432
.env ※リポジトリにあげる際は「.env.template」にリネームしてUPする。
OS_TIMEZONE=Asia/Tokyo
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5435
② golang-migrationでDBスキーマ作成
golang-migrationの使い方、1ミリを知らないので公式のGetting Startedに沿っていく。
migrate CLIインストール
# Mac
brew install golang-migrate
# インストール確認
% migrate -version
v4.15.2
Migrationファイル作成
migrate CLIで提供されているコマンドを実行することで、migration用のSQLファイルを生成できる。
productテーブル
migrate create -ext sql -dir db/migrations -seq create_product_table
・上記コマンドを実行すると、upとdownの2ファイルが作成される。
000001_create_product_table.up.sql
000001_create_product_table.down.sql
・中身は空なので、それぞれ定義する。
000001_create_product_table.up.sql
CREATE TABLE IF NOT EXISTS product(
product_id serial PRIMARY KEY,
product_name VARCHAR (50) UNIQUE NOT NULL,
product_description VARCHAR(500),
product_min_stock INTEGER
);
000001_create_product_table.down.sql
DROP TABLE IF EXISTS product;
productデータ
migrate create -ext sql -dir db/migrations -seq add_product
・productテーブル同様にコマンドでSQLファイル作成。
000002_add_product.up.sql
000002_add_product.down.sql
・中身は空なので、それぞれ定義する。
000002_add_product.up.sql
INSERT INTO product
(product_name, product_description, product_min_stock)
VALUES
('製品A', '製品Aの説明', '10'),
('製品B', '製品Bの説明', '20'),
('製品C', '製品Cの説明', '30');
000002_add_product.down.sql
DELETE FROM product;
Migration実行(up)
# フォーマット
migrate -database ${POSTGRESQL_URL} -path db/migrations up
# 実行コマンド
migrate -database "postgres://postgres:postgres@localhost:5435/postgres?sslmode=disable" -path db/migrations up
実行結果
1/u create_product_table (42.524774ms)
2/u add_product (81.520087ms)
productテーブル作成&データ追加されていることを確認👍
Migration実行(down)
upで作成したテーブル/データをdownで取り消す。
% migrate -database "postgres://postgres:postgres@localhost:5435/postgres?sslmode=disable" -path db/migrations down
実行結果
Are you sure you want to apply all down migrations? [y/N]
y
Applying all down migrations
2/d add_product (39.820023ms)
1/d create_product_table (73.951284ms)
上記の通り「V2(データ削除)→V1(テーブル削除)」の順番でdownマイグレーションが実行される。
schema_migrationsテーブルは削除されず、レコードのみ削除される。
各DBの接続URLフォーマット
③ gormでEntity作成・DB接続・CRUD処理実装
GolangのORM人気度が下記リポジトリでまとまっている。現時点(2022/05/02)で2位のgorm
を今回利用していく。
DB接続
・必要な依存をインストール
go get gorm.io/gorm
go get gorm.io/driver/postgres
・inventory-system/api/model/database/connection.go を作成する。
package database
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var Db *gorm.DB
var err error
// アプリ起動時にこの関数を呼び出す。
func SetupDb() {
var host = os.Getenv("DB_HOST")
var user = os.Getenv("DB_USER")
var password = os.Getenv("DB_PASSWORD")
var name = os.Getenv("DB_NAME")
var port = os.Getenv("DB_PORT")
var timezone = os.Getenv("OS_TIMEZONE")
dsn := "host=" + host + " user=" + user + " password=" + password + " dbname=" + name + " port=" + port + " sslmode=disable TimeZone=" + timezone
Db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("FAILED TO CONNECT TO DB")
} else {
log.Println("CONNECTED TO DB")
}
}
ルートディレクトリ/.env
DB_HOST=localhost
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=postgres
DB_PORT=5435
OS_TIMEZONE=Asia/Tokyo
※グローバル変数の注意点
gorm.Open()で返されるDBオブジェクトについて
・DBオブジェクトの正体はGolang標準ライブラリのdatabase/sqlのDB structである。
・1つのDBオブジェクトが複数goroutinesからの利用・Connection Poolingに対応しているため、gorm.Open()でDBオブジェクトを取得するのは1アプリ内で1箇所だけでいい。
The returned DB is safe for concurrent use by multiple goroutines and maintains its own pool of idle connections. Thus, the Open function should be called just once. It is rarely necessary to close a DB.
Entity定義
・inventory-system/api/model/database/entity/product.go を作成する。
package entity
type Product struct {
ProductID uint `gorm:"primaryKey"`
ProductName string
ProductDescription string
ProductMinStock int32
}
CRUD実装
Request, Response作成
Designを定義する前に、Create, Update用のリクエストパラメータ、Find用のレスポンスパラメータ を先に作成する。
・inventory-system/api/svc/design/request/product.go を作成する。
package request
import (
. "goa.design/goa/v3/dsl"
)
var CreateProductPayload = Type("CreateProductPayload", func() {
Field(1, "productName", String)
Field(2, "productDescription", String)
Field(3, "productMinStock", Int32)
Required("productName")
})
var UpdateProductPayload = Type("UpdateProductPayload", func() {
Field(1, "productId", Int)
Field(2, "productName", String)
Field(3, "productDescription", String)
Field(4, "productMinStock", Int32)
Required("productId")
})
・inventory-system/api/svc/design/response/product.go を作成する。
package response
import (
. "goa.design/goa/v3/dsl"
)
var FindProductResult = ResultType("FindProductResult", func() {
Field(1, "productId", Int)
Field(2, "productName", String)
Field(3, "productDescription", String)
Field(4, "productMinStock", Int32)
})
design作成
・inventory-system/api/svc/design/product.go を作成する。
・productsに対するCRUDのデザインを定義する。
登録 /products POST
更新 /products/{productId} PUT
取得 /products/{productId} GET
削除 /products/{productId} DELETE
package design
import (
"inventory-system/api/svc/design/request"
"inventory-system/api/svc/design/response"
. "goa.design/goa/v3/dsl"
)
// APIサーバ定義
var _ = API("inventory-system", func() {
// API の説明(タイトルと説明)
Title("Inteventory System Service")
Description("Service for inventory")
// ホスト情報
Server("inventory-system", func() {
Host("localhost", func() {
URI("http://localhost:8008") // HTTP REST API
URI("grpc://localhost:8088") // gRPC
})
})
})
// サービス定義
var _ = Service("inventory", func() {
Description("The inventory service")
// Create
Method("create", func() {
Payload(request.CreateProductPayload)
Result(String)
HTTP(func() {
POST("/products")
})
GRPC(func() {
Response(CodeOK)
})
})
// Update
Method("update", func() {
Payload(request.UpdateProductPayload)
Result(String)
HTTP(func() {
PUT("/products/{productId}")
})
GRPC(func() {
Response(CodeOK)
})
})
// Find
Method("find", func() {
Payload(func(){
Field(1, "productId", Int)
Required("productId")
})
Result(response.FindProductResult)
HTTP(func() {
GET("/products/{productId}")
})
GRPC(func() {
Response(CodeOK)
})
})
// Delete
Method("delete", func() {
Payload(func(){
Field(1, "productId", Int)
Required("productId")
})
Result(String)
HTTP(func() {
DELETE("/products/{productId}")
})
GRPC(func() {
Response(CodeOK)
})
})
})
designよりコード作成
前工程で作成したdesignをもとに、goaで雛形ファイルを生成する。
// genフォルダ生成
goa gen inventory-system/api/svc/design
// cmdフォルダ + inventory.go生成
goa example inventory-system/api/svc/design
雛形ファイルであるinventory.goはこんな感じ。
package inventorysystem
import (
"context"
"inventory-system/api/model/database/repository"
inventory "inventory-system/gen/inventory"
"log"
)
// inventory service example implementation.
// The example methods log the requests and return zero values.
type inventorysrvc struct {
logger *log.Logger
}
// NewInventory returns the inventory service implementation.
func NewInventory(logger *log.Logger) inventory.Service {
return &inventorysrvc{logger}
}
// Create implements create.
func (s *inventorysrvc) Create(ctx context.Context, p *inventory.CreateProductPayload) (res string, err error) {
s.logger.Print("inventory.create")
return
}
// Update implements update.
func (s *inventorysrvc) Update(ctx context.Context, p *inventory.UpdateProductPayload) (res string, err error) {
s.logger.Print("inventory.update")
return
}
// Find implements find.
func (s *inventorysrvc) Find(ctx context.Context, p *inventory.FindPayload) (res *inventory.Findproductresult, err error) {
s.logger.Print("inventory.find")
return
}
// Delete implements delete.
func (s *inventorysrvc) Delete(ctx context.Context, p *inventory.DeletePayload) (res string, err error) {
s.logger.Print("inventory.delete")
return
}
APIのIF(リクエスト/レスポンス)が確定した状態のものが生成される。
開発者はデザインで確定されたIFを実現できるよう、この雛形ファイルに対して中身のロジックを実装していく。
cmd/inventory_system/main.go に①DB接続処理 ②.env読込処理 を追加
goa example
コマンドで生成されたcmd/inventory_system/main.go
内のmain関数はアプリ起動時に実行される関数である。
①DB接続処理 ②.env読込処理 をアプリ起動時に実行したいので、このmain関数内に処理を追加する。
①DB接続処理
package main
import (
"inventory-system/api/model/database"
// その他import割愛
)
func main() {
// ①DB接続処理
database.SetupDb()
}
②.env読込処理
godotenv
を利用して、ルートディレクトリ/.envを読み込む。
インストール
go get github.com/joho/godotenv
main関数に追加
package main
import (
"github.com/joho/godotenv"
// その他import割愛
)
func main() {
// ②.envロード処理
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
} else {
log.Println("LOAD .env file")
}
}
最終的にはこんな感じ。
package main
import (
"inventory-system/api/model/database"
"github.com/joho/godotenv"
)
func main() {
// Load .env file
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
} else {
log.Println("LOAD .env file")
}
// Setup Database
database.SetupDb()
}
repository作成
・inventory-system/api/model/database/repository/product.go を作成する。
package repository
import (
"inventory-system/api/model/database"
"inventory-system/api/model/database/entity"
inventory "inventory-system/gen/inventory"
"log"
)
func SaveProduct(p *inventory.CreateProductPayload) {
product := entity.Product{
ProductName: p.ProductName,
ProductDescription: *p.ProductDescription,
ProductMinStock: *p.ProductMinStock,
}
database.Db.Create(&product)
}
func UpdateProduct(p *inventory.UpdateProductPayload) {
// find
var product entity.Product
if err := database.Db.Where("product_id = ?", p.ProductID).First(&product).Error; err != nil {
log.Fatal("NOT FOUND PRODUCT")
}
// update
product.ProductName = *p.ProductName
product.ProductDescription = *p.ProductDescription
product.ProductMinStock = *p.ProductMinStock
database.Db.Save(&product)
}
func FindProduct(id int) entity.Product {
var product entity.Product
if err := database.Db.Where("product_id = ?", id).First(&product).Error; err != nil {
log.Fatal("NOT FOUND PRODUCT")
}
return product
}
func DeleteProduct(id int) {
// find
var product entity.Product
if err := database.GetDb().Where("product_id = ?", id).First(&product).Error; err != nil {
log.Fatal("NOT FOUND PRODUCT")
}
// delete
database.Db.Delete(&product)
}
inventory.go(雛形ファイル)を編集
・各エンドポイントよりrepositoryが提供するCRUD処理を呼び出すように修正する。
package inventorysystem
import (
"context"
"inventory-system/api/model/database/repository"
inventory "inventory-system/gen/inventory"
"log"
)
// inventory service example implementation.
// The example methods log the requests and return zero values.
type inventorysrvc struct {
logger *log.Logger
}
// NewInventory returns the inventory service implementation.
func NewInventory(logger *log.Logger) inventory.Service {
return &inventorysrvc{logger}
}
// Create implements create.
func (s *inventorysrvc) Create(ctx context.Context, p *inventory.CreateProductPayload) (res string, err error) {
repository.SaveProduct(p)
s.logger.Print("inventory.create")
return
}
// Update implements update.
func (s *inventorysrvc) Update(ctx context.Context, p *inventory.UpdateProductPayload) (res string, err error) {
repository.UpdateProduct(p)
s.logger.Print("inventory.update")
return
}
// Find implements find.
func (s *inventorysrvc) Find(ctx context.Context, p *inventory.FindPayload) (res *inventory.Findproductresult, err error) {
var product = repository.FindProduct(p.ProductID)
res = &inventory.Findproductresult{
ProductID: &p.ProductID,
ProductName: &product.ProductName,
ProductDescription: &product.ProductDescription,
ProductMinStock: &product.ProductMinStock,
}
s.logger.Print("inventory.find")
return
}
// Delete implements delete.
func (s *inventorysrvc) Delete(ctx context.Context, p *inventory.DeletePayload) (res string, err error) {
repository.DeleteProduct(p.ProductID)
s.logger.Print("inventory.delete")
return
}
gormの使い方は以下参照。
④ 動作確認
ものは揃ったので、ビルドして実行してみる。
// 実行ファイル(inventory_system)を生成
go build ./cmd/inventory_system
// 実行
./inventory_system
実行!
% go build ./cmd/inventory_system
% ./inventory_system
2022/05/05 16:01:49 LOAD .env file
2022/05/05 16:01:49 CONNECTED TO DB
[inventorysystem] 16:01:49 HTTP "Create" mounted on POST /products
[inventorysystem] 16:01:49 HTTP "Update" mounted on PUT /products/{productId}
[inventorysystem] 16:01:49 HTTP "Find" mounted on GET /products/{productId}
[inventorysystem] 16:01:49 HTTP "Delete" mounted on DELETE /products/{productId}
[inventorysystem] 16:01:49 serving gRPC method inventory.Inventory/Create
[inventorysystem] 16:01:49 serving gRPC method inventory.Inventory/Update
[inventorysystem] 16:01:49 serving gRPC method inventory.Inventory/Find
[inventorysystem] 16:01:49 serving gRPC method inventory.Inventory/Delete
[inventorysystem] 16:01:49 HTTP server listening on "localhost:8008"
[inventorysystem] 16:01:49 gRPC server listening on "localhost:8088"
・アプリ起動時に.envファイル読込処理が走っていること
・アプリ起動時にDB接続処理が走っていること
が確認できる。
以下のcurlコマンドで動作確認実施。
create
curl -X POST -H "Content-Type: application/json" -d '{"productName": "createTest","productDescription": "createTest","productMinStock": 111}' localhost:8008/products
update
curl -X PUT -H "Content-Type: application/json" -d '{"productName": "updateTest","productDescription": "updateTest","productMinStock": 99911}' localhost:8008/products/1
find
curl localhost:8008/products/1
delete
curl -X DELETE localhost:8008/products/1
自分用メモ
init関数、main関数の違い