📓 memotty

Go x Chi x Go Template x Redisで学ぶキャッシュ入門チュートリアル

このチュートリアルでは、以下の構成で 段階的に Redis キャッシュを導入していきます。

  • バックエンド: Go
  • ルーティング: chi
  • テンプレートエンジン: Go template(*.html.tmpl
  • キャッシュ: Redis(go-redis

ゴール:何を作るか #

最終的に、次のような挙動をする Web アプリを作ります。

  • GET /articles/{id} で記事ページを表示する
  • DB の代わりに「500ms かかる重い処理」を擬似的に用意する
  • 2回目以降のアクセスは Redis キャッシュから即座に返す
  • 画面に FetchedAt(取得時刻)を表示して、
    キャッシュされているかどうかを目視で確認できるようにする

Step 0. 最低限のディレクトリ & セットアップ #

まずはディレクトリと go.mod を用意します。

mkdir redis-chi-tutorial
cd redis-chi-tutorial
go mod init example.com/redis-chi-tutorial

ディレクトリ構成(最初は 1 ファイル構成にしておきます):

.
├── main.go
└── templates
    ├── layout.html.tmpl
    └── article.html.tmpl

使用ライブラリ:

go get github.com/go-chi/chi/v5

Step 1. chi + Go template で「キャッシュなし」のページを表示 #

まずは キャッシュなし の状態で、
/articles/{id} にアクセスすると HTML を返すところまで作ります。

1-1. テンプレートを書く #

templates/layout.html.tmpl

<!doctype html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <title>{{ block "title" . }}Redis Cache Tutorial{{ end }}</title>
</head>
<body>
<header>
    <h1>Redis Cache Tutorial</h1>
    <hr>
</header>

<main>
    {{ block "content" . }}{{ end }}
</main>

<footer>
    <hr>
    <small>&copy; 2025 Demo</small>
</footer>
</body>
</html>

templates/article.html.tmpl

{{ define "title" }}記事: {{ .Title }}{{ end }}

{{ template "layout.html.tmpl" . }}

{{ define "content" }}
<article>
    <h2>{{ .Title }}</h2>
    <p>ID: {{ .ID }}</p>
    <p>Body: {{ .Body }}</p>
    <p>FetchedAt: {{ .FetchedAt }}</p>
    <p>Cached: {{ .Cached }}</p>
</article>
{{ end }}
  • .Cached は、後で「キャッシュから取れたかどうか」を表示するためのフラグです(今は false 固定)。

1-2. main.go で chi + template をつなぐ #

main.go

package main

import (
	"html/template"
	"log"
	"net/http"
	"path/filepath"
	"time"

	"github.com/go-chi/chi/v5"
)

type Article struct {
	ID        int
	Title     string
	Body      string
	FetchedAt time.Time
	Cached    bool
}

var tmpl *template.Template

func main() {
	// テンプレート読み込み
	t, err := template.ParseGlob(filepath.Join("templates", "*.html.tmpl"))
	if err != nil {
		log.Fatalf("template parse error: %v", err)
	}
	tmpl = t

	r := chi.NewRouter()

	// GET /articles/{id}
	r.Get("/articles/{id}", handleShowArticle)

	log.Println("listening on :8080")
	if err := http.ListenAndServe(":8080", r); err != nil {
		log.Fatal(err)
	}
}

func handleShowArticle(w http.ResponseWriter, r *http.Request) {
	// ひとまずは固定データ
	article := &Article{
		ID:        1,
		Title:     "サンプル記事",
		Body:      "これは Redis キャッシュのチュートリアル用の記事です。",
		FetchedAt: time.Now(),
		Cached:    false,
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := tmpl.ExecuteTemplate(w, "article.html.tmpl", article); err != nil {
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
}
go run ./...

ブラウザで http://localhost:8080/articles/1 にアクセスすると記事ページが表示されます。
この時点ではまだ Redis もキャッシュもありません。


Step 2. 「遅い処理」を入れてキャッシュの必要性を体感する #

次に、DB の代わりとして わざと 500ms 待つ処理 を挟みます。
これにより「毎回遅いからキャッシュしたくなる」状態を作ります。

2-1. 擬似 DB 関数を追加 #

main.go に以下を追加します。

import (
	// 省略している既存の import に加えて
	"strconv"
)

// 擬似的な「DB から記事を取得する」関数
func fetchArticleFromDB(id int) (*Article, error) {
	// 本当は DB にアクセスするイメージ
	time.Sleep(500 * time.Millisecond) // 重い処理のふり

	return &Article{
		ID:        id,
		Title:     "サンプル記事",
		Body:      "これは Redis キャッシュのチュートリアル用の記事です。",
		FetchedAt: time.Now(),
		Cached:    false,
	}, nil
}

2-2. ハンドラを fetchArticleFromDB に差し替え #

func handleShowArticle(w http.ResponseWriter, r *http.Request) {
	idStr := chi.URLParam(r, "id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}

	article, err := fetchArticleFromDB(id)
	if err != nil {
		http.Error(w, "failed to fetch article", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := tmpl.ExecuteTemplate(w, "article.html.tmpl", article); err != nil {
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
}

再度 go run ./... して /articles/1 を F5 連打すると、
毎回 500ms 待たされる のがわかるはずです。
ここで初めて「Redis でキャッシュしたくなる」状況ができました。


Step 3. Redis を用意する(実行環境) #

3-1. Docker で Redis を起動 #

ローカルに Docker があれば、以下で Redis を起動できます。

docker run --name redis-cache-demo -p 6379:6379 redis:7-alpine

3-2. Go 側に Redis クライアントを追加 #

go get github.com/redis/go-redis/v9

Step 4. Redis クライアントと簡単なラッパを実装 #

ここではチュートリアルなので、まずは main.go にベタ書き します。
(実務では internal/cache に切り出すイメージで見てもらえればOKです)

4-1. Redis クライアントのセットアップ #

main.go の import に追加:

import (
	// 既存 + これ
	"context"

	"github.com/redis/go-redis/v9"
)

グローバル変数を追加:

var (
	tmpl        *template.Template
	redisClient *redis.Client
)

main() 内で Redis クライアントを初期化します。

func main() {
	// テンプレート読み込み
	t, err := template.ParseGlob(filepath.Join("templates", "*.html.tmpl"))
	if err != nil {
		log.Fatalf("template parse error: %v", err)
	}
	tmpl = t

	// Redis クライアント作成
	redisClient = redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
		DB:   0,
	})
	if err := redisClient.Ping(context.Background()).Err(); err != nil {
		log.Fatalf("failed to connect redis: %v", err)
	}

	r := chi.NewRouter()
	r.Get("/articles/{id}", handleShowArticle)

	log.Println("listening on :8080")
	if err := http.ListenAndServe(":8080", r); err != nil {
		log.Fatal(err)
	}
}

4-2. 便利ラッパ関数(Get/Set) #

const articleHTMLTTL = 10 * time.Minute

func cacheGet(ctx context.Context, key string) (string, bool, error) {
	val, err := redisClient.Get(ctx, key).Result()
	if err == redis.Nil {
		return "", false, nil
	}
	if err != nil {
		return "", false, err
	}
	return val, true, nil
}

func cacheSet(ctx context.Context, key, value string, ttl time.Duration) error {
	return redisClient.Set(ctx, key, value, ttl).Err()
}
  • bool で「キャッシュヒットしたかどうか」を返しています。
  • TTL(有効期限)は 10 分にしています(用途に合わせて調整してください)。

Step 5. cache-aside パターンで HTML をキャッシュする #

いよいよハンドラに Redis キャッシュを組み込みます。
ここでは 「HTMLそのもの」をキャッシュ するパターンを扱います。

5-1. ハンドラの流れ(文章で把握) #

  1. URL から id を取得する
  2. キャッシュキー article:html:{id} を作る
  3. Redis から HTML を取得する
    • あればそのままレスポンス(テンプレートも DB も触らない)
  4. キャッシュに無ければ:
    • 擬似 DB から記事を取得(500ms)
    • Cached: false のままテンプレートを bytes.Buffer に描画
    • できた HTML を Redis に保存
    • レスポンスとして返す

5-2. 実装コード #

main.gobytes を import 追加します。

import (
	"bytes"
	// 他は省略
)

handleShowArticle をキャッシュ対応版に書き換えます。

func handleShowArticle(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	idStr := chi.URLParam(r, "id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}

	cacheKey := "article:html:" + idStr

	// 1. キャッシュをまず見る(cache-aside)
	if html, ok, err := cacheGet(ctx, cacheKey); err == nil && ok {
		// キャッシュヒット
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		_, _ = w.Write([]byte(html))
		return
	} else if err != nil {
		// Redis エラーが出てもサービス全体を落とさないようにする
		log.Printf("redis get error: %v", err)
	}

	// 2. キャッシュミス → 「DB」から記事取得(=遅い処理)
	article, err := fetchArticleFromDB(id)
	if err != nil {
		http.Error(w, "failed to fetch article", http.StatusInternalServerError)
		return
	}

	// キャッシュからではないので false のまま
	article.Cached = false

	// 3. テンプレートを bytes.Buffer に描画
	var buf bytes.Buffer
	if err := tmpl.ExecuteTemplate(&buf, "article.html.tmpl", article); err != nil {
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}

	html := buf.String()

	// 4. Redis に HTML をキャッシュ(TTL 10分)
	if err := cacheSet(ctx, cacheKey, html, articleHTMLTTL); err != nil {
		log.Printf("redis set error: %v", err)
	}

	// 5. クライアントへレスポンス
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = w.Write([]byte(html))
}

5-3. 動作確認のポイント #

  1. go run ./... で起動
  2. /articles/1 にアクセス
    • 初回は 500ms 程度遅い
    • 画面の FetchedAt に現在時刻が表示される
  3. 少し間を空けずに F5 連打
    • 2回目以降は一瞬で返る(Redis キャッシュが効いている)
    • FetchedAt が変わらない(同じ HTML が返っている=キャッシュ)

これで chi + Go template + Redis で HTML を cache-aside する 最小構成が完成です 🎉


Step 6.(発展)JSON データをキャッシュするパターン #

先ほどは「HTMLそのもの」をキャッシュしましたが、
実務では 「データ(JSON)だけキャッシュ」 することも多いです。

その場合は:

  • Redis には Articlejson.Marshal した文字列を保存
  • ハンドラでは
    • まず Redis から JSON を取得 → json.Unmarshal して Article に復元
    • なければ DB から取得 → json.Marshal して Redis に保存
    • テンプレートレンダリング自体は毎回実行(常に最新のテンプレートを反映できる)

といった流れになります。

6-1. JSON API の例(イメージ) #

import "encoding/json"

const articleJSONTTL = 10 * time.Minute

func handleShowArticleJSON(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	idStr := chi.URLParam(r, "id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}

	cacheKey := "article:json:" + idStr

	// 1. Redis から JSON を取得
	if s, ok, err := cacheGet(ctx, cacheKey); err == nil && ok {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_, _ = w.Write([]byte(s))
		return
	}

	// 2. キャッシュミス → DB
	article, err := fetchArticleFromDB(id)
	if err != nil {
		http.Error(w, "failed to fetch article", http.StatusInternalServerError)
		return
	}

	// 3. JSON 化して Redis へ
	b, err := json.Marshal(article)
	if err == nil {
		_ = cacheSet(ctx, cacheKey, string(b), articleJSONTTL)
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_, _ = w.Write(b)
}

main() 内で:

r.Get("/articles/{id}.json", handleShowArticleJSON)

のようにルーティングすれば、
/articles/1.json で JSON API 版を試せます。


Step 7. chi らしく構造化するなら… #

ここまではチュートリアル優先で main.go にすべて詰め込みましたが、
実務では次のようにパッケージ分割していくとスッキリします。

.
├── cmd
│   └── server
│       └── main.go          // router 初期化 & DI
├── internal
│   ├── cache
│   │   └── redis.go         // RedisCache struct + Get/Set/Delete
│   ├── article
│   │   ├── handler.go       // chi ハンドラ
│   │   └── repository.go    // DB アクセスや fetchArticleFromDB
│   └── templates
│       └── templates.go     // TemplateSet struct
└── templates
    ├── layout.html.tmpl
    └── article.html.tmpl

chi との相性が良い書き方の例:

r.Route("/articles", func(r chi.Router) {
    h := article.NewHandler(redisCache, tmplSet)
    r.Get("/{id}", h.ShowHTML)
    r.Get("/{id}.json", h.ShowJSON)
})

といった形にしておくと、
機能ごと(articles, users, auth など)にハンドラを分けやすくなります。


まとめ #

ここまでで、次のステップを順に踏んできました。

  1. Step1: chi + Go template でまずは普通にページを返す
  2. Step2: 擬似的な「遅い DB 処理」を入れて、キャッシュが欲しくなる状態を作る
  3. Step3: Redis を Docker で立てて、Go から go-redis で接続する
  4. Step4: シンプルな cacheGet / cacheSet ラッパを用意する
  5. Step5: cache-aside パターンで HTML をキャッシュ(chi ハンドラ内で実装)
  6. Step6: データ(JSON)だけキャッシュするパターンのイメージを掴む
  7. Step7: 実務で使うことを意識したディレクトリ構成案を確認する

今後の発展としては、例えば以下のようなネタがあります。

  • chi のミドルウェアとして
    「GET レスポンスを丸ごとキャッシュする汎用ミドルウェア」 を作る
  • マルチテナント(tenant_id)や環境 (env) を含めたキャッシュキー設計
  • 記事更新・削除 API からの キャッシュ無効化(Delete)戦略
  • Cloud Run / Supabase / Upstash Redis など、
    実際の本番構成に近いインフラ上での運用方法