0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ミドルウェアの役割を理解するために、シンプルなTODO APIを作ってみた

0
Last updated at Posted at 2026-05-05

はじめに

これまで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へのアクセス回数を減らすことができました。


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?