iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Modern Web

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

用 Go + Echo 打造你的第一個 TodoList , 第 5 篇:刪除任務 + JSON 檔持久化

  • 分享至 

  • xImage
  •  

今天把兩件事一次搞定:

  1. DELETE /todos/:id:刪除任務 🗑️
  2. JSON 檔持久化:把記憶體的 todos 存進 todos.json,重開不會全忘光 💾

這是把玩具升級成生活用品的關鍵一步。從此以後,你的 Todo 就有了「記憶力」。🧠✨


本篇目標

• 刪除任務 API:DELETE /todos/:id

• 啟動時 loadFromJSON() → 把 todos.json 載回來

• 每次新增 / 更新 / 刪除後 saveToJSON() → 落盤保存

• 安全寫入:先寫暫存檔再 rename,避免壞檔


用 Go 刪除一筆 Todo清單

  1. 路由骨架:先接到 DELETE /todos/:id
e.DELETE("/todos/:id", func(c echo.Context) error { 
    // 這裡等下逐步補齊 
    return nil 
}) 
  1. 讀取 URL 裡的 :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", 
    }) 
} 
  1. 找出這個 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", 
    }) 
} 
  1. 從 slice 中把它移除(經典切片手法)
todos = append(todos[:idx], todos[idx+1:]...) 
  1. 若有做 JSON 持久化,記得保存到檔案
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) 
} 
  1. 成功刪除後,回傳 204 No Content
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 服務!🎉


上一篇
用 Go + Echo 打造你的第一個 TodoList , 第 4 篇:更新任務 API — PUT 與 PATCH
下一篇
用 Go + Echo 打造你的第一個 TodoList , 第 6 篇:把資料搬進資料庫—Postgres 串接
系列文
Golang x Echo 30 天:零基礎GO , 後端入門16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言