今天把兩件事一次搞定:
這是把玩具升級成生活用品的關鍵一步。從此以後,你的 Todo 就有了「記憶力」。🧠✨
本篇目標
• 刪除任務 API:DELETE /todos/:id
• 啟動時 loadFromJSON() → 把 todos.json
載回來
• 每次新增 / 更新 / 刪除後 saveToJSON() → 落盤保存
• 安全寫入:先寫暫存檔再 rename,避免壞檔
用 Go 刪除一筆 Todo清單
DELETE /todos/:id
e.DELETE("/todos/:id", func(c echo.Context) error {
// 這裡等下逐步補齊
return nil
})
:id
(字串 → 整數)idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "id must be a number",
})
}
id
在清單中的位置(小工具函式)func findIndexByID(id int) int {
for i, t := range todos {
if t.ID == id {
return i
}
}
return -1
}
在路由中使用它:
idx := findIndexByID(id)
if idx == -1 {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "todo not found",
})
}
todos = append(todos[:idx], todos[idx+1:]...)
if err := saveToJSON("todos.json"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "save failed",
})
}
saveToJSON
(安全寫入:先寫暫存檔再 rename)
func saveToJSON(path string) error {
data, err := json.MarshalIndent(todos, "", " ")
if err != nil { return err }
tmp := ".todos.tmp.json"
if err := os.WriteFile(tmp, data, 0o644); err != nil { return err }
return os.Rename(tmp, path)
}
return c.NoContent(http.StatusNoContent)
合起來(仍是「小段落」版,不是一整坨)
e.DELETE("/todos/:id", func(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "id must be a number"})
}
idx := findIndexByID(id)
if idx == -1 {
return c.JSON(http.StatusNotFound, map[string]string{"error": "todo not found"})
}
todos = append(todos[:idx], todos[idx+1:]...)
if err := saveToJSON("todos.json"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "save failed"})
}
return c.NoContent(http.StatusNoContent)
})
測試一下 🧪
curl -X DELETE http://localhost:1323/todos/1 -i
# 預期:HTTP/1.1 204 No Content
完整 main.go(含:新增/查詢/更新/刪除 + JSON 持久化)
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/labstack/echo/v4"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type M = map[string]string
var (
todos []Todo
nextID = 1
dataFile = "todos.json"
)
func main() {
// 1) 啟動時載入 JSON(若不存在就忽略錯誤)
if err := loadFromJSON(dataFile); err != nil && !errors.Is(err, os.ErrNotExist) {
panic(err)
}
recomputeNextID()
e := echo.New()
// Hello
e.GET("/hello", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, TodoList!")
})
// 新增任務:POST /todos
e.POST("/todos", func(c echo.Context) error {
var newTodo Todo
if err := c.Bind(&newTodo); err != nil || strings.TrimSpace(newTodo.Title) == "" {
return c.JSON(http.StatusBadRequest, M{"error": "invalid request"})
}
newTodo.ID = nextID
newTodo.Done = false
nextID++
todos = append(todos, newTodo)
if err := saveToJSON(dataFile); err != nil {
return c.JSON(http.StatusInternalServerError, M{"error": "save failed"})
}
return c.JSON(http.StatusOK, newTodo)
})
// 列出全部:GET /todos
e.GET("/todos", func(c echo.Context) error {
return c.JSON(http.StatusOK, todos)
})
// 查單一:GET /todos/:id
e.GET("/todos/:id", func(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, M{"error": "id must be a number"})
}
idx := findIndexByID(id)
if idx == -1 {
return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
}
return c.JSON(http.StatusOK, todos[idx])
})
// 整筆更新:PUT /todos/:id(改 title / done)
e.PUT("/todos/:id", func(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, M{"error": "id must be a number"})
}
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"})
}
idx := findIndexByID(id)
if idx == -1 {
return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
}
todos[idx].Title = req.Title
if req.Done != nil {
todos[idx].Done = *req.Done
}
if err := saveToJSON(dataFile); err != nil {
return c.JSON(http.StatusInternalServerError, M{"error": "save failed"})
}
return c.JSON(http.StatusOK, todos[idx])
})
// 部分更新(只改完成狀態):PATCH /todos/:id/done
e.PATCH("/todos/:id/done", func(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, M{"error": "id must be a number"})
}
idx := findIndexByID(id)
if idx == -1 {
return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
}
var req struct {
Done *bool `json:"done"`
}
_ = c.Bind(&req)
if req.Done == nil {
// 切換模式
todos[idx].Done = !todos[idx].Done
} else {
todos[idx].Done = *req.Done
}
if err := saveToJSON(dataFile); err != nil {
return c.JSON(http.StatusInternalServerError, M{"error": "save failed"})
}
return c.JSON(http.StatusOK, todos[idx])
})
// 刪除任務:DELETE /todos/:id
e.DELETE("/todos/:id", func(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, M{"error": "id must be a number"})
}
idx := findIndexByID(id)
if idx == -1 {
return c.JSON(http.StatusNotFound, M{"error": "todo not found"})
}
// 移除 idx
todos = append(todos[:idx], todos[idx+1:]...)
if err := saveToJSON(dataFile); err != nil {
return c.JSON(http.StatusInternalServerError, M{"error": "save failed"})
}
return c.NoContent(http.StatusNoContent) // 204,純刪除不用回 body
})
e.Logger.Fatal(e.Start(":1323"))
}
/* ----------------- 工具區 ----------------- */
// 找到 id 的索引;找不到回 -1
func findIndexByID(id int) int {
for i, t := range todos {
if t.ID == id {
return i
}
}
return -1
}
// 啟動後根據載入資料重算 nextID
func recomputeNextID() {
maxID := 0
for _, t := range todos {
if t.ID > maxID {
maxID = t.ID
}
}
nextID = maxID + 1
}
// 讀檔:把 todos.json 載回 todos
func loadFromJSON(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return err
}
// 空檔案處理
if len(strings.TrimSpace(string(b))) == 0 {
todos = nil
return nil
}
return json.Unmarshal(b, &todos)
}
// 寫檔(安全寫入):先寫暫存檔,再 rename 成正式檔
func saveToJSON(path string) error {
dir := filepath.Dir(path)
tmp := filepath.Join(dir, ".todos.tmp.json")
b, err := json.MarshalIndent(todos, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
測試清單(一步步照抄就會動)
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":"倒垃圾"}'
# 打開 todos.json,應該看到兩筆資料
curl -X DELETE http://localhost:1323/todos/1 -i
# 預期:HTTP/1.1 204 No Content
curl http://localhost:1323/todos
# Ctrl+C 停止後再:
go run main.go
curl http://localhost:1323/todos
小結
你現在擁有:
🗒️ 新增:POST /todos
📋 查全部:GET /todos
🔍 查單筆:GET /todos/:id
✏️ 更新:PUT /todos/:id
、PATCH /todos/:id/done
🗑️ 刪除:DELETE /todos/:id
💾 持久化:啟動載入、變更即寫入 todos.json
恭喜,五篇小練習完成一輪可用的 API 服務!🎉