はじめに
これまでWebシステム開発には幅広く関わってきましたが、
ミドルウェアについては
「なんとなく知っている止まり」
でした。
Nginx や Redis も、なぜ使うのかを自分の言葉で説明できない状態です。
そこで今回、ミドルウェアを意識しながら手を動かしてみることにしました。
題材として選んだのは、シンプルなTODO APIアプリです。
UIはあえて作らず、curlで動作確認する構成にし、あわせて最近使い始めたGoの理解も深めていきます。
作ったものの構成
クライアント(curl)
↓
Nginx(ポート80)
- レートリミット
- 死活監視(/health)
↓
Goアプリ(ポート8080)
- Echo フレームワーク
- CRUD API
↓
┌─────────────┬─────────────┐
MySQL(3306) Redis(6379)
永続化 キャッシュ
| 技術 | 役割 |
|---|---|
| Go + Echo | APIサーバー |
| MySQL | データの永続化 |
| Redis | キャッシュ(メモリ上で高速) |
| Nginx | リバースプロキシ・レートリミット |
| Docker Compose | 複数コンテナの管理 |
実装の流れ
Step 1:Go + EchoでCRUD APIを作る
まずGoのプロジェクトを作って、EchoでシンプルなAPIを実装しました。
mkdir todo-app && cd todo-app
go mod init todo-app
go get github.com/labstack/echo/v4
todo-app/
├── cmd/main.go # エントリーポイント・ルーティング
├── handler/todo.go # リクエスト・レスポンスの処理
├── model/todo.go # データの型定義
├── repository/todo.go # DBとのやりとり
├── Dockerfile
└── docker-compose.yml
model/todo.go
データの型を定義します。この時点ではIDや時刻を自前で持たせています。
package model
import "time"
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
repository/todo.go
この時点ではメモリ上にデータを保存する仮実装にしています。
package repository
import "todo-app/model"
var todos []model.Todo
var nextID = 1
func GetAll() []model.Todo {
return todos
}
func Create(title string) model.Todo {
todo := model.Todo{Title: title, Done: false}
todos = append(todos, todo)
nextID++
return todo
}
handler/todo.go
Echoのcontextからリクエストを受け取り、レスポンスを返します。
package handler
import (
"net/http"
"todo-app/repository"
"github.com/labstack/echo/v4"
)
type TodoHandler struct {
repo *repository.TodoRepository
}
func NewTodoHandler(repo *repository.TodoRepository) *TodoHandler {
return &TodoHandler{repo: repo}
}
func (h *TodoHandler) GetTodos(c echo.Context) error {
todos := h.repo.GetAll()
return c.JSON(http.StatusOK, todos) // JSONでレスポンスを返す
}
func (h *TodoHandler) CreateTodo(c echo.Context) error {
title := c.FormValue("title") // リクエストボディからtitleを取得
if title == "" {
return c.JSON(http.StatusBadRequest, "title is required")
}
todo := h.repo.Create(title)
return c.JSON(http.StatusCreated, todo)
}
echo.Context は、Echoが提供するHTTP通信の情報を扱うための型です。
リクエストから値を取得したり、レスポンスを返したりする際に使用します。
今回使用している主なメソッドは以下の通りです。
| メソッド | 意味 |
|---|---|
c.FormValue("title") |
リクエストから title の値を取得する |
c.JSON(status, data) |
指定したHTTPステータスコードとともに、データをJSON形式でレスポンスとして返す |
例えば、c.JSON(http.StatusOK, todos) は、HTTPステータス 200 OK とともに todos をJSON形式で返します。
また、http.StatusOK などはGoの標準ライブラリ net/http で定義されているHTTPステータスコードです。
| 定数 | ステータスコード | 意味 |
|---|---|---|
http.StatusOK |
200 | 成功 |
http.StatusCreated |
201 | 作成成功 |
http.StatusBadRequest |
400 | リクエストに問題がある |
cmd/main.go
Echoのインスタンスにルーティングをつけてサーバーを起動します。
package main
import (
"todo-app/handler"
"todo-app/repository"
"github.com/labstack/echo/v4"
)
func main() {
todoRepo := repository.NewTodoRepository()
todoHandler := handler.NewTodoHandler(todoRepo)
e := echo.New()
e.GET("/todos", todoHandler.GetTodos)
e.POST("/todos", todoHandler.CreateTodo)
e.Start(":8080")
}
curlで動作確認します。
curl -X POST http://localhost:8080/todos -d "title=買い物"
# {"id":1,"title":"買い物","done":false,"created_at":"0001-01-01T00:00:00Z"}
curl http://localhost:8080/todos
# [{"id":1,"title":"買い物","done":false,"created_at":"0001-01-01T00:00:00Z"}]
created_atが0001-01-01になっているのはまだ時刻をセットしていないからです。次のステップでMySQLに繋いだら解決します。
Step 2:MySQLを繋ぐ
GORMというORMライブラリを使ってMySQLに接続しました。
go get gorm.io/gorm
go get gorm.io/driver/mysql
model/todo.go
GORMはgorm.Modelを埋め込むだけでID・CreatedAt・UpdatedAt・DeletedAtを自動で管理してくれます。
package model
import "gorm.io/gorm"
type Todo struct {
gorm.Model // ID, CreatedAt, UpdatedAt, DeletedAt が自動で入る
Title string `json:"title"`
Done bool `json:"done"`
}
repository/todo.go
メモリ仮実装からMySQLを使う実装に書き直します。
package repository
import (
"todo-app/model"
"gorm.io/gorm"
)
type TodoRepository struct {
db *gorm.DB
}
func NewTodoRepository(db *gorm.DB) *TodoRepository {
return &TodoRepository{db: db}
}
func (r *TodoRepository) GetAll() []model.Todo {
var todos []model.Todo
r.db.Find(&todos) // &todosに全件の結果を格納する
return todos
}
func (r *TodoRepository) Create(title string) model.Todo {
todo := model.Todo{Title: title, Done: false}
r.db.Create(&todo) // INSERT INTO todos ...
return todo
}
cmd/main.go
DB接続を追加してrepositoryに渡します。
package main
import (
"fmt"
"todo-app/handler"
"todo-app/model"
"todo-app/repository"
"github.com/labstack/echo/v4"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
dsn := "root:root@tcp(mysql:3306)/todo?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) // MySQLへの接続を確立
if err != nil {
panic(fmt.Sprintf("failed to connect database: %v", err))
}
db.AutoMigrate(&model.Todo{}) // 構造体からテーブルを自動生成
todoRepo := repository.NewTodoRepository(db)
todoHandler := handler.NewTodoHandler(todoRepo)
e := echo.New()
e.GET("/todos", todoHandler.GetTodos)
e.POST("/todos", todoHandler.CreateTodo)
e.Start(":8080")
}
curlで動作確認します。今度はcreated_atに正しい時刻が入っています。
curl -X POST http://localhost:8080/todos -d "title=買い物"
# {"ID":1,"CreatedAt":"2026-05-05T02:37:18.465Z","title":"買い物","done":false}
Step 3:Docker Composeで全部まとめる
各サービスをDocker Composeで管理しました。
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- mysql
- redis
restart: on-failure
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: todo
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7
ports:
- "6379:6379"
nginx:
image: nginx:latest
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
app:
condition: service_started
restart: on-failure
volumes:
mysql_data:
ここで学んだこと:volumesの仕組み
docker compose downするとコンテナが削除されてデータも消えます。それを防ぐためにvolumesを使ってMySQLのデータをコンテナの外に保存するようにしました。
コンテナ削除 → コンテナは消える
→ mysql_dataボリュームは残る
次回起動 → ボリュームをマウント → データが残っている
ここでハマったこと:MySQLの起動タイミング問題
depends_onはコンテナの起動順序を保証するだけで、MySQLが完全に準備できるまで待ってくれません。GoアプリがMySQLに接続しようとしたときにまだ準備中でエラーになりました。
todo-app-app-1 | panic: failed to connect database: connection refused
restart: on-failureを追加して、失敗したら再起動を繰り返すようにしたら解決しました。
Step 4:Nginxをリバースプロキシとして設定する
最初は「Nginxって何のために挟むの?」という疑問がありました。今回はレートリミットを実装することで、Nginxを挟む意味を持たせました。
events {}
http {
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=1r/s;
server {
listen 80;
location /health {
return 200 "nginx is healthy\n";
add_header Content-Type text/plain;
}
location / {
limit_req zone=mylimit burst=5 nodelay;
proxy_pass http://app:8080;
}
}
}
| 設定 | 意味 |
|---|---|
limit_req_zone |
レートリミットのルールを定義 |
rate=1r/s |
1秒に1リクエストまで許可 |
burst=5 |
瞬間的に5リクエストまでは許容 |
nodelay |
burst超えは即座に503を返す |
/health |
Nginxの死活監視エンドポイント |
並列でリクエストを送って動作確認したら、ちゃんと503が返ってきました。
for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:80/todos & done; wait
# 200
# 503
# 503
# ...
GoアプリはNginxの後ろに隠れているので、レートリミットを超えたリクエストはGoアプリに届く前にNginxがブロックしてくれます。
Step 5:Redisでキャッシュを実装する
RedisはメモリにデータをもつKVS(Key-Value Store)です。MySQLより圧倒的に速いので、一度取得したデータをRedisに保存しておいて、次回はRedisから返すキャッシュとして使いました。
repository/todo.go(GetAll部分)
func (r *TodoRepository) GetAll() []model.Todo {
ctx := context.Background()
// Redisからキャッシュを取得
cached, err := r.rdb.Get(ctx, "todos").Result()
if err == nil {
var todos []model.Todo
json.Unmarshal([]byte(cached), &todos)
return todos // MySQLにアクセスしない
}
// キャッシュがなければMySQLから取得
var todos []model.Todo
r.db.Find(&todos)
// Redisに30秒保存
data, _ := json.Marshal(todos)
r.rdb.Set(ctx, "todos", data, 30*time.Second)
return todos
}
追加・更新・削除のときはキャッシュを削除して、次のGETで最新データを取得させるようにしました。
GET /todos
→ Redisにある → そのまま返す(速い)
→ Redisにない → MySQLから取得 → Redisに保存 → 返す
POST/PUT/DELETE
→ MySQLを更新 → Redisのキャッシュ削除
Redisの中身はこのコマンドで確認できます。
docker compose exec redis redis-cli
GET todos
# JSONが返ってきたらキャッシュ成功
やってみて感じたこと ・ 学び
ミドルウェアは難しそうだと思っていましたが、実際に手を動かしてみると、仕組みで理解できるようになりました。
特に印象的だったのは以下の2点です。
-
Nginxを挟む理由
最初は「なくても動く」と思っていましたが、レートリミットを実装して納得しました。
前段に置くことで、不正なリクエストをGoアプリに届く前にブロックでき、アプリは正常なリクエストの処理に集中できます。 -
RedisとMySQLの使い分け
Redisはメモリ上で動くため高速で、キャッシュ用途に適しています。
今回は毎回MySQLに問い合わせるのではなく、取得したデータをRedisにキャッシュすることで、MySQLへのアクセス回数を減らすことができました。