iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Modern Web

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

以 Go + Echo 打造部落格|第 5 篇:文章列表與分頁

  • 分享至 

  • xImage
  •  

以 Go + Echo 打造部落格|第 5 篇(完整 Markdown)

今天讓首頁真的「像部落格」!我們把資料庫裡 已發佈(published) 的文章抓出來,做成列表,並加上 分頁(上一頁/下一頁)。完成後,使用者打開 / 就能看到最新文章;草稿(draft)只在後台看得到,不會外流 👍
小辭典:
分頁(pagination):把很多資料切成一頁一頁,避免一次塞爆畫面與網路。


步驟清單(骨架 → 填充)

  1. 增加前台 Handler:只撈 published 的文章,組好分頁資料。
  2. 更新首頁模板:顯示卡片列表、分頁按鈕。
  3. 路由切到新首頁 Handler(保留健康檢查等原路由)。
  4. (選配)提供 /api/posts 公開 API(只回已發佈)。
  5. MVP 預設每頁 5 筆,?page=1..N 切換。

完整程式(可直接跑)

下方是「新增或更新」的檔案。放好後 make run 就能測。

1) 前台列表 Handler(只顯示 published)

internal/http/handlers/front_posts.go(新增)

package handlers 
 
import ( 
	"net/http" 
	"strconv" 
	"time" 
 
	"github.com/labstack/echo/v4" 
 
	"example.com/go-echo-blog/internal/storage/postgres" 
) 
 
type FrontPostsHandler struct { 
	Repo     *postgres.PostRepo 
	SiteName string 
	PerPage  int 
} 
 
func NewFrontPostsHandler(r *postgres.PostRepo, site string, perPage int) *FrontPostsHandler { 
	if perPage <= 0 { perPage = 5 } 
	return &FrontPostsHandler{Repo: r, SiteName: site, PerPage: perPage} 
} 
 
type postCard struct { 
	ID        int64 
	Title     string 
	Slug      string 
	Summary   string 
	Date      string // yyyy-mm-dd 
	URL       string // 先預留 /posts/:slug,下一篇會實作內頁 
	Status    string 
} 
 
func (h *FrontPostsHandler) Home(c echo.Context) error { 
	page := 1 
	if v := c.QueryParam("page"); v != "" { 
		if n, err := strconv.Atoi(v); err == nil && n > 0 { page = n } 
	} 
	items, total, err := h.Repo.List(c.Request().Context(), postgres.ListPostsOpts{ 
		Page: page, PerPage: h.PerPage, Status: "published", 
	}) 
	if err != nil { return c.String(http.StatusInternalServerError, err.Error()) } 
 
	// 轉為模板可用的卡片資料 
	cards := make([]postCard, 0, len(items)) 
	for _, p := range items { 
		d := p.PublishedAt 
		if d == nil { 
			// 理論上 published 一定有時間,但保險起見 fallback 
			t := p.UpdatedAt 
			d = &t 
		} 
		tt := d.In(time.FixedZone("Asia/Taipei", 8*60*60)) 
		sum := "" 
		if p.Summary != nil { sum = *p.Summary } 
		cards = append(cards, postCard{ 
			ID: p.ID, Title: p.Title, Slug: p.Slug, Summary: sum, 
			Date: tt.Format("2006-01-02"), 
			URL:  "/posts/" + p.Slug, // 下一篇會實作 /posts/:slug 
			Status: p.Status, 
		}) 
	} 
 
	// 分頁資訊 
	totalPages := (total + h.PerPage - 1) / h.PerPage 
	if totalPages == 0 { totalPages = 1 } 
	hasPrev := page > 1 
	hasNext := page < totalPages 
	prevPage := page - 1 
	nextPage := page + 1 
 
	now := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60)) 
	data := map[string]any{ 
		"Title":      "首頁", 
		"SiteName":   h.SiteName, 
		"Year":       now.Year(), 
		"Now":        now.Format("2006-01-02 15:04:05"), 
		"Posts":      cards, 
		"Page":       page, 
		"Total":      total, 
		"PerPage":    h.PerPage, 
		"TotalPages": totalPages, 
		"HasPrev":    hasPrev, 
		"HasNext":    hasNext, 
		"PrevPage":   prevPage, 
		"NextPage":   nextPage, 
	} 
	return c.Render(http.StatusOK, "pages/index.html", data) 
} 

2) 公開 API:/api/posts(只回已發佈)

internal/http/handlers/api_public.go(新增)

package handlers 
 
import ( 
	"net/http" 
	"strconv" 
 
	"github.com/labstack/echo/v4" 
 
	"example.com/go-echo-blog/internal/storage/postgres" 
) 
 
type APIPublicHandler struct { 
	Repo    *postgres.PostRepo 
	PerPage int 
} 
 
func NewAPIPublicHandler(r *postgres.PostRepo, perPage int) *APIPublicHandler { 
	if perPage <= 0 { perPage = 5 } 
	return &APIPublicHandler{Repo: r, PerPage: perPage} 
} 
 
func (h *APIPublicHandler) List(c echo.Context) error { 
	page := 1 
	if v := c.QueryParam("page"); v != "" { 
		if n, err := strconv.Atoi(v); err == nil && n > 0 { page = n } 
	} 
	items, total, err := h.Repo.List(c.Request().Context(), postgres.ListPostsOpts{ 
		Page: page, PerPage: h.PerPage, Status: "published", 
	}) 
	if err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()}) } 
	return c.JSON(http.StatusOK, echo.Map{ 
		"items": items, "total": total, "page": page, "per_page": h.PerPage, 
	}) 
} 

3) 首頁模板:列表 + 分頁

把第 3 篇做的 pages/index.html 改成顯示「文章卡片」與「分頁按鈕」。

web/templates/pages/index.html(更新)

{{ define "pages/index" -}} 
{{ template "layouts/base" . }} 
 
{{ define "title" -}}首頁{{ end }} 
 
{{ define "content" -}} 
  <section class="grid gap-6"> 
    <h1 class="text-3xl font-bold">📰 最新文章</h1> 
    <p class="text-slate-600">只會顯示「已發佈」文章;草稿只在後台看得到。</p> 
 
    <div class="grid gap-4"> 
      {{ range .Posts }} 
      <article class="rounded-lg border bg-white p-4 hover:shadow"> 
        <div class="text-sm text-slate-500">{{ .Date }}</div> 
        <h2 class="text-xl font-semibold"> 
          <a class="hover:underline" href="{{ .URL }}">{{ .Title }}</a> 
        </h2> 
        {{ if .Summary }} 
          <p class="text-slate-600 mt-1">{{ .Summary }}</p> 
        {{ else }} 
          <p class="text-slate-400 mt-1">(這篇沒有摘要)</p> 
        {{ end }} 
      </article> 
      {{ else }} 
        <div class="rounded-lg border bg-white p-4 text-slate-500"> 
          還沒有發佈的文章,先去後台新增一篇吧!(第 4 篇教過囉) 
        </div> 
      {{ end }} 
    </div> 
 
    <nav class="flex items-center justify-between pt-2"> 
      <div class="text-sm text-slate-500"> 
        第 {{ .Page }} / {{ .TotalPages }} 頁(共 {{ .Total }} 篇) 
      </div> 
      <div class="flex gap-2"> 
        {{ if .HasPrev }} 
          <a class="px-3 py-1 rounded border bg-white hover:bg-slate-50" href="/?page={{ .PrevPage }}">上一頁</a> 
        {{ else }} 
          <span class="px-3 py-1 rounded border bg-slate-100 text-slate-400">上一頁</span> 
        {{ end }} 
        {{ if .HasNext }} 
          <a class="px-3 py-1 rounded border bg-white hover:bg-slate-50" href="/?page={{ .NextPage }}">下一頁</a> 
        {{ else }} 
          <span class="px-3 py-1 rounded border bg-slate-100 text-slate-400">下一頁</span> 
        {{ end }} 
      </div> 
    </nav> 
 
    <div class="rounded-lg border bg-white p-4"> 
      <div class="text-sm text-slate-500">台北時間</div> 
      <div class="text-xl font-mono">{{ .Now }}</div> 
    </div> 
  </section> 
{{- end }} 
{{- end }} 

備註:我們把標題連結指到 /posts/:slug下一篇會實作這條路由與內頁;現在點下去會 404,屬於預留洞口 😎(工作中的假說:先把資料流與 UI 固定,細節路由下一篇補齊)。


4) 路由:把首頁指向新的前台列表

cmd/server/main.go(只示範變動部分)

// ... 省略前段相同程式 
 
	// DB 
	ctx := context.Background() 
	db, err := pgstore.Connect(ctx) 
	if err != nil { log.Fatalf("connect db: %v", err) } 
	defer db.Close() 
 
	// Repos + Handlers 
	postRepo := pgstore.NewPostRepo(db.Pool) 
 
	// 前台首頁(每頁 5 筆) 
	front := handlers.NewFrontPostsHandler(postRepo, site, 5) 
 
	// 原本有的 admin、api(第 4 篇)依然保留 
	admin := handlers.NewAdminPostsHandler(postRepo, site) 
	apiAdmin := handlers.NewAPIPostsHandler(postRepo) 
	apiPublic := handlers.NewAPIPublicHandler(postRepo, 5) 
 
	// 基本路由 
	e.GET("/", front.Home) // ← 取代原本 HomeHandlerWithMeta 
	e.GET("/health", handlers.HealthHandler) 
	e.GET("/_ping", func(c echo.Context) error { return c.String(http.StatusOK, "pong") }) 
 
	// 公開 API 
	e.GET("/api/posts", apiPublic.List) 
 
// ... 其餘(adminGroup / apiAdmin 等)維持第 4 篇設定 

如果你還想保留第 3 篇那個「Hello Blog」版本,只要換成別一路徑(例如 /welcome)即可。


5) 目錄樹(★ 為本篇新增或更新)

go-echo-blog/ 
├─ cmd/ 
│  └─ server/ 
│     └─ main.go                      ★ 更新(首頁路由 → FrontPostsHandler、加 /api/posts) 
├─ internal/ 
│  ├─ http/ 
│  │  └─ handlers/ 
│  │     ├─ front_posts.go            ★ 新增(前台列表 + 分頁) 
│  │     ├─ api_public.go             ★ 新增(公開 API:/api/posts) 
│  │     ├─ admin_posts.go 
│  │     ├─ api_posts.go 
│  │     └─ home.go                   (可留著或移除不再使用的 HomeHandlerWithMeta) 
│  └─ storage/ 
│     └─ postgres/ 
│        └─ posts.go 
├─ web/ 
│  └─ templates/ 
│     └─ pages/ 
│        └─ index.html                ★ 更新(列表 + 分頁) 
└─ 其他檔案(migrations、Makefile、.env...) 

資料庫變更

本篇 不用加新的表

政策:首頁只秀「已發佈」;草稿只在後台管理。排序採用 published_at DESC(我們在 Repo 的 SQL 已優先 COALESCE(published_at, created_at) 了)。


測試區(curl / Browser)

A) 前台首頁(HTML)

# 第 1 頁 
curl -s http://localhost:1323/ | head -n 40 
 
# 第 2 頁 
curl -s "http://localhost:1323/?page=2" | head -n 40 

B) 公開 API(只回 published)

# 列表(第 1 頁、每頁 5 筆) 
curl -s "http://localhost:1323/api/posts?page=1" | jq . 
 
# 切頁 
curl -s "http://localhost:1323/api/posts?page=2" | jq . 

C) 快速建立幾篇「已發佈」文章(用上一篇的管理 API)

for i in 1 2 3 4 5 6 7; do 
  curl -sX POST http://localhost:1323/api/admin/posts \ 
    -H "Content-Type: application/json" \ 
    -d '{ 
      "author_id": 1, 
      "title": "Demo 文 '"$i"'", 
      "slug": "demo-'"$i"'", 
      "summary": "這是第 '"$i"' 篇示範文章", 
      "content_md": "內容內容內容 '"$i"'", 
      "status": "published" 
    }' > /dev/null 
done 

接著重新整理首頁 /,應該就會看到分頁囉 😄


常見坑(排雷)🧯

  1. 頁碼超出範圍:現在沒做特別處理,頁碼過大會顯示空列表;屬於 MVP 行為,可接受。
  2. 沒有 published_at:理論上發佈文章都會寫入;若遇到 NULL,本篇用 updated_at 當備援顯示。
  3. 點標題 404:因為 /posts/:slug 會在下一篇實作;目前是預留。
  4. 速度與效能:資料量小先不做快取;將來可加 OFFSET/LIMIT 的索引或 cursor-based 分頁。
  5. 樣式不生效:公司網路擋 CDN;參考第 3 篇註解,改用本地 build 的 Tailwind。

小結 & 下一篇預告

恭喜!你的首頁現在能列出已發佈文章並且支援分頁 🎯
下一篇(第 6 篇):文章內頁與 slug 路由 /posts/:slug,草稿保護與 404 頁面也會一起上。
加分挑戰

  • 在公開 API /api/posts 加上 q=關鍵字 查詢(title/summary LIKE)。
  • 分頁 UI 增加「直接跳頁」輸入框,或顯示第一頁/最後一頁按鈕。
  • 在卡片加上封面縮圖(先用固定圖片欄位,之後第 9 篇會做上傳)。

上一篇
以 Go + Echo 打造部落格|第 4 篇:文章 CRUD
下一篇
以 Go + Echo 打造部落格|第 6 篇:文章內頁開張啦!
系列文
Golang x Echo 30 天:零基礎GO , 後端入門24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言