現在開始升級!🎮
前五篇我們用記憶體(以及 JSON 檔)保存資料。這一篇把儲存層換成資料庫,直接上專案等級的 Postgres。你會學到:
todos
資料表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 設定。go mod init todolist
go get github.com/labstack/echo/v4
go get github.com/jackc/pgx/v5/pgxpool
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()
);
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(¤tDone); 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
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
DATABASE_URL is empty
:沒設定環境變數。先設定再執行。db insert/query/update/delete failed
:通常是 Docker 沒跑、密碼錯或 Port 沒開。先 docker ps
確認容器狀態。$env:NAME="value"
。?sslmode=disable
(本機開發常用)。postgres
資料庫。小結
你現在已經把 TodoList 的資料搬進 Postgres,正式擁有一個可擴充、可靠的後端服務:
✅ Docker 跑 DB
✅ pgxpool 連線
✅ SQL CRUD 全套
✅ curl
測到通