背景
AWSのECSを使用してAPIを提供してます。
「とりあえず」ということで、手っ取り早く手慣れているECSで実装しました。しかし、この構成はAPIへの通信がなくてもECSでコンテナを起動し続ける必要があります。コンテナが起動しているイコール課金で、支払いがもったいなく感じます。
そこで、ひとつの方法として、ECSをやめてAPI-GW+Lambdaを使用してAPIを実装することを検討しています。これなら通信がAPIに到達したときにLambdaを動かすので、無風時はコストがかからないはずです。さらにAPIのコール100万回(回数はうろ覚えなので後で調べる)は無料で、お小遣いに優しくなると期待できます。
そこで、もう少しAPIの実装を深堀りして、効率的な実装をしたいと考えました。さらに、可能な限り抽象化してテストしやすくしようと考えました。というのも、頻繁に触ってリリースすればいいのですが、時間が経過して機能追加しようとしたらすっかり忘れてデグレしてしまうようなことが怖いためです。なので、テストで担保できる仕組みにしようと考えました。
フレームワークを検討
そこで調べると、APIを実装するにはフレームワークを使用するのが良さそうというのがわかりました。(いまさら、それを調べる?感はある。。。)
以下の記事を参考に比較しました。
Ginが良さそう
初心者な自分にとって、複雑なものは避けたいです。
上の記事を読むと、シンプルなGinが良さそうです。広く使われてそうですし。
ファイルの構成
ここからは調べた内容をメモ。
このようなファイル構成により、エントリーポイント(cmd/main.go)はサーバーの起動や設定の読み込みに専念し、 内部処理(internal/以下)はルーティング、ハンドリング、DB接続、データモデル、ミドルウェアなどの各責務ごとに分離されます。 これにより、コードの再利用性、テスト容易性、保守性が向上し、プロジェクトの拡張や変更がしやすくなります。
アプリケーション設定(例:DB接続情報、サーバーポートなど)を管理し、main.go で読み込みます。設定の読み込みには、例えば Viper などのライブラリを利用すると便利なようです。
ファイル構成は以下。
/project-root
├── cmd/
│ └── main.go // アプリケーションのエントリーポイント。Gin ルーターの初期化とサーバー起動を行う
├── internal/
│ ├── api/
│ │ ├── routes.go // Gin のルーティング設定。エンドポイントとハンドラーの紐付けを記述
│ │ └── handlers.go // 各エンドポイントのリクエスト処理(GET/POSTなど)を実装
│ ├── db/
│ │ └── db.go // データベース接続の初期化、クエリ実行、トランザクション管理などの DB ロジックを実装
│ ├── models/
│ │ └── model.go // アプリケーション内で使用するデータ構造体(例:User, Item, Stock など)の定義
│ └── middleware/
│ └── logging.go // 共通ミドルウェア(例:リクエストロギング、認証、エラーハンドリング)を実装
├── config/
│ └── config.yaml // アプリケーション設定(DB接続情報、サーバーポート、環境変数など)を管理
├── go.mod
└── go.sum
各ファイルの役割・概要
cmd/main.go
アプリケーションの起動ファイルです。ここでは設定ファイルの読み込み、DB の初期化(internal/db/db.go の呼び出し)、そして Gin ルーターの初期化(internal/api/routes.go の利用)を行い、サーバーを起動します。
package main
import (
"log"
"project-root/config"
"project-root/internal/api"
"project-root/internal/db"
)
func main() {
// 設定のロード
cfg, err := config.LoadConfig("config/config.yaml")
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// DB接続の初期化
if err := db.InitDB(cfg.Database.DSN); err != nil {
log.Fatalf("failed to initialize DB: %v", err)
}
// Ginルーターの初期化
router := api.SetupRouter()
// サーバー起動
if err := router.Run(":" + cfg.Server.Port); err != nil {
log.Fatalf("failed to run server: %v", err)
}
}
internal/api/routes.go
Gin のルーティング設定を担当します。たとえば、API のバージョン毎にグループ分けし、各エンドポイントと対応するハンドラー(internal/api/handlers.go 内)を登録します。
package api
import (
"github.com/gin-gonic/gin"
"project-root/internal/api/handlers"
)
func SetupRouter() *gin.Engine {
r := gin.Default()
// 必要に応じてミドルウェアの登録(例:logging, CORSなど)
// r.Use(middleware.LoggingMiddleware())
// APIルーティング例(バージョニングなど)
v1 := r.Group("/v1")
{
v1.GET("/items", handlers.GetItems)
v1.POST("/items", handlers.CreateItem)
v1.GET("/items/:id", handlers.GetItem)
}
return r
}
internal/api/handlers.go
各エンドポイントのリクエスト処理を実装します。たとえば、リクエストパラメータの取得、JSON のバインディング、DB 層(internal/db/db.go)の呼び出し、レスポンス生成などを行います。
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"project-root/internal/db"
"project-root/internal/models"
)
// GetItems は GET /v1/items のハンドラー例
func GetItems(c *gin.Context) {
items, err := db.FetchItems()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, items)
}
// CreateItem は POST /v1/items のハンドラー例
func CreateItem(c *gin.Context) {
var item models.Item
if err := c.ShouldBindJSON(&item); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := db.InsertItem(&item); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, item)
}
// GetItem は GET /v1/items/:id のハンドラー例
func GetItem(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
item, err := db.FetchItemByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, item)
}
internal/db/db.go
データベースへの接続初期化、クエリ実行、トランザクション処理などを実装します。テストを容易にするために、依存性注入を意識した設計にするのがおすすめです。
package db
import (
"database/sql"
_ "github.com/go-sql-driver/mysql" // MySQL ドライバーの例
"project-root/internal/models"
)
var DB *sql.DB
// InitDB は DSN を元に DB 接続を初期化します
func InitDB(dsn string) error {
var err error
DB, err = sql.Open("mysql", dsn)
if err != nil {
return err
}
return DB.Ping()
}
// FetchItems は全アイテムを DB から取得する例
func FetchItems() ([]models.Item, error) {
rows, err := DB.Query("SELECT id, name, price FROM items")
if err != nil {
return nil, err
}
defer rows.Close()
var items []models.Item
for rows.Next() {
var item models.Item
if err := rows.Scan(&item.ID, &item.Name, &item.Price); err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
// InsertItem はアイテムを DB に挿入する例
func InsertItem(item *models.Item) error {
stmt, err := DB.Prepare("INSERT INTO items (name, price) VALUES (?, ?)")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(item.Name, item.Price)
return err
}
// FetchItemByID は ID からアイテムを取得する例
func FetchItemByID(id int) (*models.Item, error) {
var item models.Item
err := DB.QueryRow("SELECT id, name, price FROM items WHERE id = ?", id).
Scan(&item.ID, &item.Name, &item.Price)
if err != nil {
return nil, err
}
return &item, nil
}
internal/models/model.go
API や DB で利用するデータモデル(例:Item)の定義を行います。
package models
// Item はサンプルデータモデルです
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price"`
}
internal/middleware/logging.go
必要に応じて、リクエストロギングやエラーハンドリングなどの共通処理用ミドルウェアを実装します。
package middleware
import (
"github.com/gin-gonic/gin"
"log"
"time"
)
// LoggingMiddleware はシンプルなリクエストロギングの例です
func LoggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
log.Printf("method=%s path=%s status=%d duration=%s",
c.Request.Method,
c.Request.URL.Path,
c.Writer.Status(),
time.Since(start),
)
}
}
config/config.yaml
アプリケーション設定(例:DB接続情報、サーバーポートなど)を管理し、main.go で読み込みます。