iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
Modern Web

Golang x Echo 30 天:零基礎GO , 後端入門系列 第 18

以 Go + Echo 打造部落格|第 2 篇 Postgres + pgxpool + goose

  • 分享至 

  • xImage
  •  

第 0 步:前置條件(確認一下)

  • 你已完成第 1 篇的專案骨架並能 make run 起來
  • 已安裝 Docker 與 docker compose
  • Go 版本 1.22+

第 1 步(骨架):用 Docker 起 Postgres

目標:把資料庫「裝在箱子裡」,本機不用額外安裝。
名詞小辭典Docker 就像可攜式小電腦,把服務裝進去,隨叫隨用。

建立 docker-compose.yml

version: "3.9"

services:
  postgres:
    image: postgres:16-alpine
    container_name: blog-postgres
    environment:
      POSTGRES_DB: blog
      POSTGRES_USER: blog
      POSTGRES_PASSWORD: blogpass
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U blog -d blog"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  pgdata:

啟動:

docker compose up -d

驗證(看健康狀態,等到 healthy):

docker ps

第 2 步(填充):設定 .env,讓 App 知道怎麼連

重點DB_DSN 是「資料庫連線字串」。

更新 .env.example,然後 cp.env

cat > .env.example << 'EOF'
APP_ENV=development
PORT=1323
SITE_NAME=My Echo Blog

# Postgres 連線字串(DSN)
DB_DSN=postgres://blog:blogpass@localhost:5432/blog?sslmode=disable
DB_MAX_CONNS=10
DB_MIN_CONNS=2
DB_MAX_LIFETIME=30m
DB_MAX_IDLE_TIME=5m
EOF

cp .env.example .env

第 3 步(骨架):安裝 goose(遷移工具)

名詞小辭典goose 幫你用檔案管理資料表變更,換機器也能一鍵復原。

go install github.com/pressly/goose/v3/cmd/goose@latest
goose --version

第 4 步(填充):寫遷移檔(一次建 5 張表)

建立 migrations/20251002090000_init_schema.up.sql

CREATE TABLE IF NOT EXISTS users (
    id             BIGSERIAL PRIMARY KEY,
    email          TEXT NOT NULL UNIQUE,
    password_hash  TEXT NOT NULL,
    display_name   TEXT NOT NULL,
    role           TEXT NOT NULL DEFAULT 'author',
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS posts (
    id            BIGSERIAL PRIMARY KEY,
    author_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title         TEXT NOT NULL,
    slug          TEXT NOT NULL UNIQUE,
    summary       TEXT,
    content_md    TEXT NOT NULL,
    cover_image   TEXT,
    status        TEXT NOT NULL DEFAULT 'draft',
    published_at  TIMESTAMPTZ,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS tags (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS post_tags (
    post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    tag_id  BIGINT NOT NULL REFERENCES tags(id)  ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

CREATE TABLE IF NOT EXISTS sessions (
    id          TEXT PRIMARY KEY,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    data        JSONB NOT NULL DEFAULT '{}'::jsonb,
    expires_at  TIMESTAMPTZ NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_posts_status_published_at ON posts (status, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts (slug);
CREATE INDEX IF NOT EXISTS idx_tags_name ON tags (name);

建立 migrations/20251002090000_init_schema.down.sql

DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS post_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS users;

執行遷移:

goose -dir ./migrations postgres "$DB_DSN" up

第 5 步(骨架):用 pgxpool 連線(App ↔ DB 接起來)

新增 internal/storage/postgres/db.go

package postgres

import (
	"context"
	"fmt"
	"os"
	"strconv"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

type DB struct{ Pool *pgxpool.Pool }

func getenv(key, def string) string {
	if v := os.Getenv(key); v != "" { return v }
	return def
}

func parseDuration(s, def string) time.Duration {
	if s == "" { d,_ := time.ParseDuration(def); return d }
	d, err := time.ParseDuration(s); if err != nil { d,_ = time.ParseDuration(def) }
	return d
}

func parseInt(s string, def int) int {
	if s == "" { return def }
	n, err := strconv.Atoi(s); if err != nil { return def }
	return n
}

func Connect(ctx context.Context) (*DB, error) {
	dsn := getenv("DB_DSN", "")
	if dsn == "" { return nil, fmt.Errorf("missing DB_DSN") }

	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil { return nil, fmt.Errorf("parse dsn: %w", err) }

	cfg.MaxConns = int32(parseInt(getenv("DB_MAX_CONNS","10"),10))
	cfg.MinConns = int32(parseInt(getenv("DB_MIN_CONNS","2"),2))
	cfg.MaxConnLifetime = parseDuration(getenv("DB_MAX_LIFETIME","30m"),"30m")
	cfg.MaxConnIdleTime = parseDuration(getenv("DB_MAX_IDLE_TIME","5m"),"5m")

	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil { return nil, fmt.Errorf("new pool: %w", err) }

	ctxPing, cancel := context.WithTimeout(ctx, 5*time.Second); defer cancel()
	if err := pool.Ping(ctxPing); err != nil {
		pool.Close(); return nil, fmt.Errorf("ping db: %w", err)
	}

	return &DB{Pool: pool}, nil
}

func (d *DB) Close() { if d != nil && d.Pool != nil { d.Pool.Close() } }

第 6 步(填充):加 /db/health 路由(健康檢查)

新增 internal/http/handlers/db.go

package handlers

import (
	"context"
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
)

func DBHealthHandler(ping func(ctx context.Context) error) echo.HandlerFunc {
	return func(c echo.Context) error {
		ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
		defer cancel()
		if err := ping(ctx); err != nil {
			return c.JSON(http.StatusServiceUnavailable, map[string]any{"ok": false, "error": err.Error()})
		}
		return c.JSON(http.StatusOK, map[string]any{
			"ok":  true,
			"db":  "up",
			"now": time.Now().Format(time.RFC3339),
		})
	}
}

更新 cmd/server/main.go(掛上 DB 與路由):

package main

import (
	"context"
	"html/template"
	"io"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/joho/godotenv"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"

	"example.com/go-echo-blog/internal/http/handlers"
	pgstore "example.com/go-echo-blog/internal/storage/postgres"
)

type TemplateRenderer struct{ t *template.Template }
func (tr *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
	return tr.t.ExecuteTemplate(w, name, data)
}
func getenv(k, d string) string { if v:=os.Getenv(k); v!="" {return v}; return d }

func main() {
	_ = godotenv.Load()
	port := getenv("PORT", "1323")

	e := echo.New()
	e.Use(middleware.Recover(), middleware.Logger(), middleware.CORS())
	e.Static("/static", "web/static")
	t := template.Must(template.ParseGlob("web/templates/*.html"))
	e.Renderer = &TemplateRenderer{t: t}

	ctx := context.Background()
	db, err := pgstore.Connect(ctx)
	if err != nil { log.Fatalf("connect db: %v", err) }
	defer db.Close()

	e.GET("/", handlers.HomeHandler)
	e.GET("/health", handlers.HealthHandler)
	e.GET("/_ping", func(c echo.Context) error { return c.String(http.StatusOK, "pong") })
	e.GET("/db/health", handlers.DBHealthHandler(func(ctx context.Context) error { return db.Pool.Ping(ctx) }))

	go func() {
		log.Printf("Server on :%s 🚦", port)
		if err := e.Start(":" + port); err != nil && err != http.ErrServerClosed { log.Fatal(err) }
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := e.Shutdown(ctxShutdown); err != nil { log.Printf("server shutdown: %v", err) }
}

第 7 步(加料):Makefile 指令更好用

把下列目標加進你的 Makefile(或直接替換):

APP_NAME=go-echo-blog

.PHONY: run dev tidy fmt clean db-up db-down migrate up down status

run:
	@go run ./cmd/server

dev:
	@go run ./cmd/server

tidy:
	@go mod tidy

fmt:
	@gofmt -w .

clean:
	@rm -f $(APP_NAME)

db-up:
	@docker compose up -d

db-down:
	@docker compose down

migrate:
	@goose -dir ./migrations postgres "$$DB_DSN" up

up: migrate

down:
	@goose -dir ./migrations postgres "$$DB_DSN" down

status:
	@goose -dir ./migrations postgres "$$DB_DSN" status

✅ 最終合併版(可直接跑)

(如果你喜歡一次複製,下面是第 2 篇所有新增/更新檔案的「合併版」。)

docker-compose.ymlmigrations/*.sqlinternal/storage/postgres/db.gointernal/http/handlers/db.gocmd/server/main.goMakefile:請從上面各段落複製對應內容到你的專案。


測試區(curl / Postman)

  1. 起 DB、跑遷移、啟動 App:
make db-up
goose -dir ./migrations postgres "$DB_DSN" up
make run
  1. 檢查 DB 健康(JSON):
curl -s http://localhost:1323/db/health | jq .
# 期待:
# { "ok": true, "db": "up", "now": "2025-10-02T10:xx:xx+08:00" }
  1. 其它 baseline 還在:
curl -i http://localhost:1323/
curl -s http://localhost:1323/health | jq .
curl -s http://localhost:1323/_ping

常見坑(對應解法)🧯

  1. connect db: missing DB_DSN.env 沒設好,或路徑不在專案根目錄。
  2. connection refused:Postgres 沒起來;make db-up,再 docker ps 看健康狀態。
  3. 帳密不符docker-compose.yml.env 的使用者/密碼/DB 名要一致。
  4. 遷移跑不起來:檢查 -dir ./migrationsDB_DSN,以及檔名時間戳格式。
  5. Windows 權限問題:清空資料卷重來:docker compose down -v && docker compose up -d(會刪資料,注意)。

小結 & 下一篇

恭喜把 DB 打通了!🎉 現在我們有 Docker 化的 Postgres、pgxpool 連線、goose 管理 schema,還能用 /db/health 自我檢查。
第 3 篇預告:模板與靜態資源——加上 html/template 的 layout/partial、Tailwind CDN,讓首頁不再素顏。
加分作業:插入一個 admin 使用者與 2 篇假文章,之後列表與分頁就能用到;也可以把 Makefile 加上 seed 指令,養成好習慣 😄


上一篇
以 Go + Echo 打造部落格|第 1 篇:專案初始化與骨架(MVP)
下一篇
以 Go + Echo 打造部落格|第 3 篇:模板與靜態資源
系列文
Golang x Echo 30 天:零基礎GO , 後端入門24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言