🔩 ねじき教室 Go と Web の教室

仕上げと振り返り

読了目安 約6分

未ログイン時の振る舞いを仕上げて完成させ、この掲示板に Part 1〜4 の何が入っているかを振り返ります。

この章の目次

残っているのは、未ログイン時の振る舞いです。

足すものは次の 2 つです。

  • 投稿フォームをログイン中だけ表示する
  • 未ログインの POST /posts をログイン画面へ誘導する

画面をログイン状態で出し分ける

未ログインの人には投稿フォームの代わりに、ログインと登録への案内を出します。 handleIndex の末尾、ExecuteTemplate を呼んでいた部分を次に置き換えます。

Go
	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}} は、渡した値で出力を切り替えるテンプレートの条件分岐です。

HTML
<!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 を、リダイレクトに変えます。

Go
	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/posts
テキスト
HTTP/1.1 302 Found
Location: /login

これでねじ板は完成です。

ここまでの main.go の全体

完成した main.go の全体(約 200 行)を載せます。 index.html は上のもの、register.html と login.html は 3 章のままです。

Go
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 の道具だけで作れます。

  1. 投稿の削除:自分の投稿にだけ削除ボタンを出し、POST /posts/{id}/delete で消せるようにしてください。パスの中の id は r.PathValue("id") で取り出せます。URL を直接叩かれても他人の投稿を消せないように、DELETE 文の WHERE には id と user_id の両方を入れるのが要点です。
  2. ユーザーページ:GET /users/{id} で、そのユーザーの投稿だけの一覧を出してください。一覧のクエリに WHERE を足すだけで、テンプレートは index.html の作りが流用できます。
  3. 投稿数の表示:一覧の各投稿に「alice(12 投稿)」のように投稿者の総投稿数を添えてください。集計は 集計する の COUNT と GROUP BY です。投稿ごとに COUNT のクエリを 1 本ずつ発行しても動きますが、その形は Part 6 で「N+1 問題」と呼んで退治する対象です。JOIN と組み合わせて 1 本のクエリにできないか考えてみてください。

Part 6 へ

この Part では、速さを一度も測っていません。 Part 6 では、この「動くもの」を速くする側に回ります。 最初にやるのはコードを直すことではなく、どこが遅いかを測ることです(推測するな、計測せよ)。