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

完成コードと動作確認

読了目安 約3分

完成した main.go の全体と、登録からログアウトまでを通しで確かめる手順です。

30秒でつかむ 登録からログアウトまでを、一本につなぐ
登録からログアウトまでを、一本につなぐユーザー登録、ログイン、投稿、ログアウトを順に動かして完成した掲示板を確認します。1登録とログイン本人を確認2投稿DBへ保存3ログアウトセッションを破棄
  1. 登録とログイン 本人を確認
  2. 投稿 DBへ保存
  3. ログアウト セッションを破棄
この章の目次

完成した 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
	}
	if len(password) > 72 {
		http.Error(w, "パスワードは72バイト以内にしてください", 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,
		SameSite: http.SameSiteLaxMode,
	})
	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,
		SameSite: http.SameSiteLaxMode,
	})
	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.StatusSeeOther)
		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)
}

通しの動作確認

サーバーを起動して、機能をひととおり触って確かめます。

シェル
go run main.go
テキスト
2026/07/21 10:00:00 http://localhost:8080 で待ち受けます

ブラウザで次の順に確かめてください。

  1. 未ログインで http://localhost:8080 を開くと、投稿フォームの代わりにログインと登録への案内が出る
  2. /register で登録すると、ログイン画面へ移動する
  3. ログインすると、投稿フォームとログアウトボタンが現れる
  4. 投稿すると、一覧のいちばん上に自分の投稿が出る
  5. ログアウトすると、未ログインの案内表示に戻る

うまく動かないとき

起動時によく見るエラーと原因です。

  • dial tcp 127.0.0.1:3306: connect: connection refused:MySQL のコンテナが動いていません。docker start neziki-mysql で起動してから再実行してください。
  • panic: html/template: pattern matches no files: templates/*.html:main.go のあるディレクトリ以外から起動しています。templates ディレクトリが見える場所で go run main.go を実行してください。
  • listen tcp :8080: bind: address already in use:前に起動したサーバーが残っています。起動したままのターミナルを探して Ctrl+C で止めてください。