iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Modern Web

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

用 Go + Echo 打造你的第一個 TodoList , 第 6 篇:把資料搬進資料庫—Postgres 串接

  • 分享至 

  • xImage
  •  

現在開始升級!🎮

前五篇我們用記憶體(以及 JSON 檔)保存資料。這一篇把儲存層換成資料庫,直接上專案等級的 Postgres。你會學到:

  • 用 Docker 啟動 Postgres
  • 在 Go 裡用 pgxpool 建連線
  • 建立 todos 資料表
  • 把新增 / 查詢 / 單筆查詢 / 更新 / 勾選 / 刪除改成 SQL 版本
  • 附上 curl 測試指令,可以一步步跟著做 🧪

  1. 前置:準備環境
    0-1. 啟動一台本機 Postgres(用 Docker)
docker run --name my-postgres   -e POSTGRES_PASSWORD=mysecretpassword   -p 5432:5432 -d postgres

0-2. 設定連線字串(環境變數)

# macOS / Linux
export DATABASE_URL="postgresql://postgres:mysecretpassword@localhost:5432/postgres?sslmode=disable"

# Windows (PowerShell)
$env:DATABASE_URL="postgresql://postgres:mysecretpassword@localhost:5432/postgres?sslmode=disable"

小提醒:開發環境加上 ?sslmode=disable 會比較省事。正式環境請用正規 SSL 設定。

  1. 新增依賴(go.mod)
go mod init todolist
go get github.com/labstack/echo/v4
go get github.com/jackc/pgx/v5/pgxpool

  1. 建表 SQL(第一次啟動會自動執行)
CREATE TABLE IF NOT EXISTS todos (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  done BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

  1. 完整 main.go(Echo + pgxpool + SQL CRUD)
    直接整檔貼上、設定好 DATABASE_URL,然後 go run main.go 就能跑。
package main

import (
	"context"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/labstack/echo/v4"
)

type Todo struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
	// 可選:CreatedAt time.Time `json:"created_at"`
}

type M = map[string]string

func main() {
	// 1) 讀取 DATABASE_URL
	dsn := os.Getenv("DATABASE_URL")
	if strings.TrimSpace(dsn) == "" {
		panic("DATABASE_URL is empty. Please set DATABASE_URL environment variable.")
	}

	// 2) 建連線池
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		panic(err)
	}
	defer pool.Close()

	// 3) 建表(如果不存在)
	if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS todos (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  done BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`); err != nil {
		panic(err)
	}

	e := echo.New()

	// 健康檢查 / 問候
	e.GET("/hello", func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello, TodoList with Postgres!")
	})

	// 新增任務:POST /todos
	e.POST("/todos", func(c echo.Context) error {
		var req struct {
			Title string `json:"title"`
		}
		if err := c.Bind(&req); err != nil || strings.TrimSpace(req.Title) == "" {
			return c.JSON(http.StatusBadRequest, M{"error": "invalid request"})
		}

		var t Todo
		row := pool.QueryRow(ctx,
			`INSERT INTO todos (title) VALUES ($1) RETURNING id, title, done`,
			req.Title,
		)
		if err := row.Scan(&t.ID, &t.Title, &t.Done); err != nil {
			return c.JSON(http.StatusInternalServerError, M{"error": "db insert failed"})
		}
		return c.JSON(http.StatusOK, t)
	})

	// 列出全部:GET /todos
	e.GET("/todos", func(c echo.Context) error {
		rows, err := pool.Query(ctx, `SELECT id, title, done FROM todos ORDER BY id`)
		if err != nil {
			return c.JSON(http.StatusInternalServerError, M{"error": "db query failed"})
		}
		defer rows.Close()

		var out []Todo
		for rows.Next() {
			var t Todo
			if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil {
				return c.JSON(http.StatusInternalServerError, M{"error": "scan failed"})
			}
			out = append(out, t)
		}
		return c.JSON(http.StatusOK, out)
	})

	// 查單一:GET /todos/:id
	e.GET("/todos/:id", func(c echo.Context) error {
		id, bad := parseID(c)
		if bad != nil {
			return bad
		}
		var t Todo
		row := pool.QueryRow(ctx, `SELECT id, title, done FROM todos WHERE id=$1`, id)
		if err := row.Scan(&t.ID, &t.Title, &t.Done); err != nil {
			return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
		}
		return c.JSON(http.StatusOK, t)
	})

	// 整筆更新:PUT /todos/:id(改 title / done)
	e.PUT("/todos/:id", func(c echo.Context) error {
		id, bad := parseID(c)
		if bad != nil {
			return bad
		}
		var req struct {
			Title string `json:"title"`
			Done  *bool  `json:"done"`
		}
		if err := c.Bind(&req); err != nil {
			return c.JSON(http.StatusBadRequest, M{"error": "invalid json"})
		}
		if strings.TrimSpace(req.Title) == "" {
			return c.JSON(http.StatusBadRequest, M{"error": "title cannot be empty"})
		}

		var currentDone bool
		if err := pool.QueryRow(ctx, `SELECT done FROM todos WHERE id=$1`, id).Scan(&currentDone); err != nil {
			return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
		}
		newDone := currentDone
		if req.Done != nil {
			newDone = *req.Done
		}

		var t Todo
		row := pool.QueryRow(ctx,
			`UPDATE todos SET title=$1, done=$2 WHERE id=$3 RETURNING id, title, done`,
			req.Title, newDone, id,
		)
		if err := row.Scan(&t.ID, &t.Title, &t.Done); err != nil {
			return c.JSON(http.StatusInternalServerError, M{"error": "db update failed"})
		}
		return c.JSON(http.StatusOK, t)
	})

	// 部分更新(完成狀態):PATCH /todos/:id/done
	e.PATCH("/todos/:id/done", func(c echo.Context) error {
		id, bad := parseID(c)
		if bad != nil {
			return bad
		}
		var req struct {
			Done *bool `json:"done"`
		}
		_ = c.Bind(&req)

		if req.Done == nil {
			var t Todo
			row := pool.QueryRow(ctx,
				`UPDATE todos SET done = NOT done WHERE id=$1 RETURNING id, title, done`,
				id,
			)
			if err := row.Scan(&t.ID, &t.Title, &t.Done); err != nil {
				return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
			}
			return c.JSON(http.StatusOK, t)
		}

		var t Todo
		row := pool.QueryRow(ctx,
			`UPDATE todos SET done=$1 WHERE id=$2 RETURNING id, title, done`,
			*req.Done, id,
		)
		if err := row.Scan(&t.ID, &t.Title, &t.Done); err != nil {
			return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
		}
		return c.JSON(http.StatusOK, t)
	})

	// 刪除:DELETE /todos/:id
	e.DELETE("/todos/:id", func(c echo.Context) error {
		id, bad := parseID(c)
		if bad != nil {
			return bad
		}
		ct, err := pool.Exec(ctx, `DELETE FROM todos WHERE id=$1`, id)
		if err != nil {
			return c.JSON(http.StatusInternalServerError, M{"error": "db delete failed"})
		}
		if ct.RowsAffected() == 0 {
			return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
		}
		return c.NoContent(http.StatusNoContent)
	})

	e.Logger.Fatal(e.Start(":1323"))
}

/* ----------------- 小工具 ----------------- */

func parseID(c echo.Context) (int, error) {
	idStr := c.Param("id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		return 0, c.JSON(http.StatusBadRequest, M{"error": "id must be a number"})
	}
	return id, nil
}

//(可選)單純示範用:若你想拿 created_at 也行
var _ = time.Now


  1. 測試清單(你照著抄就會動)
go run main.go
curl -X POST http://localhost:1323/todos   -H "Content-Type: application/json"   -d '{"title":"寫數學作業"}'

curl -X POST http://localhost:1323/todos   -H "Content-Type: application/json"   -d '{"title":"倒垃圾"}'
curl http://localhost:1323/todos
curl http://localhost:1323/todos/1
curl -X PUT http://localhost:1323/todos/1   -H "Content-Type: application/json"   -d '{"title":"寫數學作業(已交)","done":true}'
curl -X PATCH http://localhost:1323/todos/2/done
curl -X PATCH http://localhost:1323/todos/2/done   -H "Content-Type: application/json"   -d '{"done":false}'
curl -X DELETE http://localhost:1323/todos/1 -i
# 預期:HTTP/1.1 204 No Content

  1. 常見小坑(排雷小抄)
    DATABASE_URL is empty:沒設定環境變數。先設定再執行。
    db insert/query/update/delete failed:通常是 Docker 沒跑、密碼錯或 Port 沒開。先 docker ps 確認容器狀態。
    • Windows PowerShell 設環境變數要用 $env:NAME="value"
    • 連線失敗時在 DSN 加 ?sslmode=disable(本機開發常用)。
    • 初次啟動建表若報權限,檢查帳號是否有權限或改用預設 postgres 資料庫。

小結
你現在已經把 TodoList 的資料搬進 Postgres,正式擁有一個可擴充、可靠的後端服務:

✅ Docker 跑 DB
✅ pgxpool 連線
✅ SQL CRUD 全套
curl 測到通


上一篇
用 Go + Echo 打造你的第一個 TodoList , 第 5 篇:刪除任務 + JSON 檔持久化
系列文
Golang x Echo 30 天:零基礎GO , 後端入門16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言