0
Help us understand the problem. What are the problem?

posted at

【Golang】Goa✖️golang-migration✖️gorm でCRUD APIを実装する。

まえがき

前記事でGoaプロジェクトの環境構築を行なった。本記事ではgoaプロジェクトでgolang-migrationgormを利用して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テーブル作成&データ追加されていることを確認👍
image.png

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テーブルは削除されず、レコードのみ削除される。
image.png

各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関数の違い

https://www.geeksforgeeks.org/main-and-init-function-in-golang/#:~:text=The%20main%20purpose%20of%20the,initialized%20in%20the%20global%20context.

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
0
Help us understand the problem. What are the problem?