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/v5Step 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>© 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-alpine3-2. Go 側に Redis クライアントを追加 #
go get github.com/redis/go-redis/v9Step 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. ハンドラの流れ(文章で把握) #
- URL から
idを取得する - キャッシュキー
article:html:{id}を作る - Redis から HTML を取得する
- あればそのままレスポンス(テンプレートも DB も触らない)
- キャッシュに無ければ:
- 擬似 DB から記事を取得(500ms)
Cached: falseのままテンプレートをbytes.Bufferに描画- できた HTML を Redis に保存
- レスポンスとして返す
5-2. 実装コード #
main.go に bytes を 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. 動作確認のポイント #
go run ./...で起動/articles/1にアクセス- 初回は 500ms 程度遅い
- 画面の
FetchedAtに現在時刻が表示される
- 少し間を空けずに F5 連打
- 2回目以降は一瞬で返る(Redis キャッシュが効いている)
FetchedAtが変わらない(同じ HTML が返っている=キャッシュ)
これで chi + Go template + Redis で HTML を cache-aside する 最小構成が完成です 🎉
Step 6.(発展)JSON データをキャッシュするパターン #
先ほどは「HTMLそのもの」をキャッシュしましたが、
実務では 「データ(JSON)だけキャッシュ」 することも多いです。
その場合は:
- Redis には
Articleをjson.Marshalした文字列を保存 - ハンドラでは
- まず Redis から JSON を取得 →
json.UnmarshalしてArticleに復元 - なければ DB から取得 →
json.Marshalして Redis に保存 - テンプレートレンダリング自体は毎回実行(常に最新のテンプレートを反映できる)
- まず Redis から JSON を取得 →
といった流れになります。
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.tmplchi との相性が良い書き方の例:
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 など)にハンドラを分けやすくなります。
まとめ #
ここまでで、次のステップを順に踏んできました。
- Step1: chi + Go template でまずは普通にページを返す
- Step2: 擬似的な「遅い DB 処理」を入れて、キャッシュが欲しくなる状態を作る
- Step3: Redis を Docker で立てて、Go から
go-redisで接続する - Step4: シンプルな
cacheGet/cacheSetラッパを用意する - Step5: cache-aside パターンで HTML をキャッシュ(chi ハンドラ内で実装)
- Step6: データ(JSON)だけキャッシュするパターンのイメージを掴む
- Step7: 実務で使うことを意識したディレクトリ構成案を確認する
今後の発展としては、例えば以下のようなネタがあります。
- chi のミドルウェアとして
「GET レスポンスを丸ごとキャッシュする汎用ミドルウェア」 を作る - マルチテナント(tenant_id)や環境 (env) を含めたキャッシュキー設計
- 記事更新・削除 API からの キャッシュ無効化(Delete)戦略
- Cloud Run / Supabase / Upstash Redis など、
実際の本番構成に近いインフラ上での運用方法