仕上げと振り返り
読了目安 約6分
未ログイン時の振る舞いを仕上げて完成させ、この掲示板に Part 1〜4 の何が入っているかを振り返ります。
この章の目次
残っているのは、未ログイン時の振る舞いです。
足すものは次の 2 つです。
- 投稿フォームをログイン中だけ表示する
- 未ログインの POST /posts をログイン画面へ誘導する
画面をログイン状態で出し分ける
未ログインの人には投稿フォームの代わりに、ログインと登録への案内を出します。 handleIndex の末尾、ExecuteTemplate を呼んでいた部分を次に置き換えます。
userName := ""
userID, loggedIn := currentUserID(r)
if loggedIn {
if err := db.QueryRow("SELECT name FROM users WHERE id = ?", userID).Scan(&userName); err != nil {
serverError(w, err)
return
}
}
data := map[string]any{
"Posts": posts,
"LoggedIn": loggedIn,
"UserName": userName,
}
if err := templates.ExecuteTemplate(w, "index.html", data); err != nil {
log.Println(err)
}templates/index.html も出し分けに対応させます。
{{if}} と {{else}} は、渡した値で出力を切り替えるテンプレートの条件分岐です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>ねじ板</title>
</head>
<body>
<h1>ねじ板</h1>
{{if .LoggedIn}}
<p>{{.UserName}} さんとしてログイン中</p>
<form action="/logout" method="post">
<button type="submit">ログアウト</button>
</form>
<form action="/posts" method="post">
<textarea name="body" rows="3" cols="40"></textarea>
<button type="submit">投稿する</button>
</form>
{{else}}
<p>投稿するには<a href="/login">ログイン</a>が必要です。はじめての人は<a href="/register">ユーザー登録</a>へ。</p>
{{end}}
<ul>
{{range .Posts}}
<li>{{.UserName}}: {{.Body}}({{.CreatedAt.Format "2006-01-02 15:04"}})</li>
{{end}}
</ul>
</body>
</html>ログアウトがリンクではなく button 付きの form なのは、リンクは GET しか送れないからです(「状態を変える操作は POST」という 1 章の設計)。
未ログインの投稿はログイン画面へ
handleCreatePost の先頭で返していた 401 を、リダイレクトに変えます。
userID, ok := currentUserID(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}- POST /posts というリクエスト自体は、画面にフォームが無くても誰でも送れる(前章の curl がそうだった)。画面で隠すのは案内にすぎず、拒む仕事はサーバーにしかできない
- 二重投稿を防ぐ 303 と違い、ここは単に「先にこちらへ」という案内なので、リダイレクトの一般形である 302 Found を使う
動作確認
シークレットウィンドウなど未ログインのブラウザで http://localhost:8080 を開いてください。 投稿フォームは無く、ログインへの案内が出ます。 そのままログインすると、フォームとログアウトボタンが現れます。
未ログインの POST も確かめます。
curl -i -d 'body=未ログインの投稿' http://localhost:8080/postsHTTP/1.1 302 Found
Location: /loginこれでねじ板は完成です。
ここまでの main.go の全体
完成した main.go の全体(約 200 行)を載せます。 index.html は上のもの、register.html と login.html は 3 章のままです。
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"html/template"
"log"
"net/http"
"sync"
"time"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
var (
db *sql.DB
templates = template.Must(template.ParseGlob("templates/*.html"))
sessions = map[string]int64{} // セッション ID → ユーザー ID
sessionsMu sync.Mutex
)
type Post struct {
ID int64
UserName string
Body string
CreatedAt time.Time
}
func main() {
var err error
db, err = sql.Open("mysql", "root:neziki@tcp(127.0.0.1:3306)/neziki_board?parseTime=true")
if err != nil {
log.Fatal(err)
}
if err := db.Ping(); err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", handleIndex)
mux.HandleFunc("GET /register", handleRegisterForm)
mux.HandleFunc("POST /register", handleRegister)
mux.HandleFunc("GET /login", handleLoginForm)
mux.HandleFunc("POST /login", handleLogin)
mux.HandleFunc("POST /logout", handleLogout)
mux.HandleFunc("POST /posts", handleCreatePost)
log.Println("http://localhost:8080 で待ち受けます")
log.Fatal(http.ListenAndServe(":8080", mux))
}
func serverError(w http.ResponseWriter, err error) {
log.Println(err)
http.Error(w, "サーバー内部でエラーが起きました", http.StatusInternalServerError)
}
func newSessionID() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func currentUserID(r *http.Request) (int64, bool) {
c, err := r.Cookie("session_id")
if err != nil {
return 0, false
}
sessionsMu.Lock()
defer sessionsMu.Unlock()
userID, ok := sessions[c.Value]
return userID, ok
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query(`
SELECT posts.id, users.name, posts.body, posts.created_at
FROM posts
JOIN users ON posts.user_id = users.id
ORDER BY posts.id DESC`)
if err != nil {
serverError(w, err)
return
}
defer rows.Close()
var posts []Post
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID, &p.UserName, &p.Body, &p.CreatedAt); err != nil {
serverError(w, err)
return
}
posts = append(posts, p)
}
if err := rows.Err(); err != nil {
serverError(w, err)
return
}
userName := ""
userID, loggedIn := currentUserID(r)
if loggedIn {
if err := db.QueryRow("SELECT name FROM users WHERE id = ?", userID).Scan(&userName); err != nil {
serverError(w, err)
return
}
}
data := map[string]any{
"Posts": posts,
"LoggedIn": loggedIn,
"UserName": userName,
}
if err := templates.ExecuteTemplate(w, "index.html", data); err != nil {
log.Println(err)
}
}
func handleRegisterForm(w http.ResponseWriter, r *http.Request) {
if err := templates.ExecuteTemplate(w, "register.html", nil); err != nil {
log.Println(err)
}
}
func handleRegister(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
password := r.FormValue("password")
if name == "" || password == "" {
http.Error(w, "ユーザー名とパスワードを入力してください", http.StatusBadRequest)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
serverError(w, err)
return
}
_, err = db.Exec("INSERT INTO users (name, password_hash) VALUES (?, ?)", name, string(hash))
if err != nil {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
http.Error(w, "そのユーザー名はすでに使われています", http.StatusConflict)
return
}
serverError(w, err)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func handleLoginForm(w http.ResponseWriter, r *http.Request) {
if err := templates.ExecuteTemplate(w, "login.html", nil); err != nil {
log.Println(err)
}
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
password := r.FormValue("password")
var userID int64
var passwordHash string
err := db.QueryRow("SELECT id, password_hash FROM users WHERE name = ?", name).Scan(&userID, &passwordHash)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "ユーザー名かパスワードが違います", http.StatusUnauthorized)
return
}
if err != nil {
serverError(w, err)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
http.Error(w, "ユーザー名かパスワードが違います", http.StatusUnauthorized)
return
}
sessionID := newSessionID()
sessionsMu.Lock()
sessions[sessionID] = userID
sessionsMu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
Path: "/",
HttpOnly: true,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("session_id"); err == nil {
sessionsMu.Lock()
delete(sessions, c.Value)
sessionsMu.Unlock()
}
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleCreatePost(w http.ResponseWriter, r *http.Request) {
userID, ok := currentUserID(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
body := r.FormValue("body")
if body == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if _, err := db.Exec("INSERT INTO posts (user_id, body) VALUES (?, ?)", userID, body); err != nil {
serverError(w, err)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}この掲示板のどこに、どの Part が入っているか
Part 1 から Part 4 の道具をどこで使ったか、対応表にします。
| ねじ板の中でやったこと | 使った知識 |
|---|---|
| メソッドと URL の組で 7 つの機能を設計した | HTTP を生で読む |
| mux.HandleFunc で URL とハンドラを対応づけた | ルーティングとハンドラ |
| r.FormValue でフォームの値を受け取った | リクエストを受け取る |
| 401 や 409 を選んで返し、303 や 302 でリダイレクトした | レスポンスを返す |
| html/template で HTML を組み立てた | HTML と JSON |
| 投稿を Post という struct で表した | struct とメソッド |
| errors.Is と errors.As でエラーの種類を見分けた | エラー処理 |
| sessions の map を sync.Mutex で守った | goroutine と並行性 |
| CREATE TABLE と INSERT でデータを保存した | テーブルを作って書き込む |
| 一覧を SELECT と JOIN の 1 本のクエリで取った | SELECT でデータを取り出す、複数のテーブルと JOIN |
| database/sql と プレースホルダで Go から SQL を発行した | Go から SQL を使う |
- トランザクションは、複数の書き込みをひとまとめにする処理が無く出番なし(3 章の重複チェックは UNIQUE 制約に任せた)
- インデックスもこの規模では出番なし(Part 6 の主役の一つとして戻ってくる)
ISUCON で渡されるアプリを読むために
ISUCON で配られるお題のアプリは、このねじ板が数千行に育ったものだと考えてください。 URL は数十個、テーブルは十数個に増えますが、ハンドラが入力を受けて SQL を発行し、テンプレートか JSON で応答する構造は同じです。
初見のコードは、まずルーティングの定義から URL とハンドラの対応表を作り、次に CREATE TABLE の一覧を読みます。 1 章で自分がやった設計の逆再生です。
改造課題
改造課題を 3 つ置いておきます。 どれもこの Part の道具だけで作れます。
- 投稿の削除:自分の投稿にだけ削除ボタンを出し、POST /posts/{id}/delete で消せるようにしてください。パスの中の id は
r.PathValue("id")で取り出せます。URL を直接叩かれても他人の投稿を消せないように、DELETE 文の WHERE には id と user_id の両方を入れるのが要点です。 - ユーザーページ:GET /users/{id} で、そのユーザーの投稿だけの一覧を出してください。一覧のクエリに WHERE を足すだけで、テンプレートは index.html の作りが流用できます。
- 投稿数の表示:一覧の各投稿に「alice(12 投稿)」のように投稿者の総投稿数を添えてください。集計は 集計する の COUNT と GROUP BY です。投稿ごとに COUNT のクエリを 1 本ずつ発行しても動きますが、その形は Part 6 で「N+1 問題」と呼んで退治する対象です。JOIN と組み合わせて 1 本のクエリにできないか考えてみてください。
Part 6 へ
この Part では、速さを一度も測っていません。 Part 6 では、この「動くもの」を速くする側に回ります。 最初にやるのはコードを直すことではなく、どこが遅いかを測ることです(推測するな、計測せよ)。