第 12 章:综合实战 —— 构建 RESTful API 服务¶
综合实战 —— 综合运用前 11 章的知识,从零构建一个完整的 RESTful API 服务:Todo List 应用
12.1 项目概述¶
本章将通过构建一个功能完整的 Todo List API 服务,将前 11 章所学的 Go 语言知识融会贯通。本项目涵盖以下核心技术点:
- 项目结构设计(go mod init、包划分)
- 数据模型定义(struct、JSON tags)
- 内存存储层(map + sync.RWMutex 并发安全)
- HTTP Handler 实现(net/http 路由分发)
- CRUD 操作(POST/GET/PUT/DELETE)
- JSON 请求解析与响应编码
- Middleware 模式(日志记录、请求 ID)
- 单元测试(net/http/httptest)
- 编译与交叉编译
12.1.1 项目目录结构¶
todo-api/
├── main.go # 程序入口,路由注册,服务启动
├── go.mod # Go module 定义
├── model/
│ └── todo.go # 数据模型定义
├── store/
│ └── memory.go # 内存存储实现
├── handler/
│ ├── todo.go # HTTP Handler 实现
│ └── todo_test.go # Handler 测试
└── Dockerfile # 容器化部署配置(可选)
12.2 项目初始化¶
12.2.1 创建项目目录和 Module¶
# 创建项目目录结构
mkdir -p todo-api/model
mkdir -p todo-api/store
mkdir -p todo-api/handler
# 初始化 Go module
cd todo-api
go mod init todo-api
12.3 定义数据模型¶
model/todo.go —— 定义 Todo 项的数据结构:
// model/todo.go
// 包 model 定义了 Todo 应用的数据模型。
// 所有的数据结构定义集中在此包中,便于维护和复用。
package model
// Status 表示 Todo 项的状态类型。
type Status string
const (
StatusPending Status = "pending" // 待办
StatusInProgress Status = "in_progress" // 进行中
StatusCompleted Status = "completed" // 已完成
)
// Todo 表示一个待办事项。
// JSON tags 用于控制序列化和反序列化时的字段名。
type Todo struct {
ID int `json:"id"` // 唯一标识符
Title string `json:"title"` // 标题
Description string `json:"description,omitempty"` // 描述(可选,为空时省略)
Status Status `json:"status"` // 状态
CreatedAt string `json:"created_at"` // 创建时间(ISO 8601 格式)
UpdatedAt string `json:"updated_at"` // 更新时间(ISO 8601 格式)
}
// CreateTodoRequest 创建 Todo 的请求体。
type CreateTodoRequest struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
}
// UpdateTodoRequest 更新 Todo 的请求体。
// 使用指针类型表示可选字段:nil 表示不更新。
type UpdateTodoRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Status *Status `json:"status,omitempty"`
}
12.4 实现内存存储层¶
store/memory.go —— 使用 map 和 sync.RWMutex 实现线程安全的内存存储:
// store/memory.go
// 包 store 提供了 Todo 数据的存储接口和内存实现。
package store
import (
"fmt"
"sort"
"sync"
"time"
"todo-api/model"
)
// Store 定义了 Todo 存储的接口。
// 通过接口抽象,后续可以替换为数据库实现而不影响 handler 层。
type Store interface {
GetAll() ([]model.Todo, error)
GetByID(id int) (*model.Todo, error)
Create(req model.CreateTodoRequest) (*model.Todo, error)
Update(id int, req model.UpdateTodoRequest) (*model.Todo, error)
Delete(id int) error
}
// MemoryStore 基于内存的 Todo 存储实现。
// 使用 sync.RWMutex 保证并发安全:读操作使用读锁,写操作使用写锁。
type MemoryStore struct {
mu sync.RWMutex
data map[int]model.Todo
nextID int
}
// NewMemoryStore 创建并初始化一个新的 MemoryStore 实例。
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
data: make(map[int]model.Todo),
nextID: 1,
}
}
// GetAll 返回所有 Todo 项,按创建时间升序排列。
func (s *MemoryStore) GetAll() ([]model.Todo, error) {
s.mu.RLock() // 获取读锁:允许多个 goroutine 同时读取
defer s.mu.RUnlock() // 函数返回时释放读锁
// 将 map 中的值收集到切片中
todos := make([]model.Todo, 0, len(s.data))
for _, todo := range s.data {
todos = append(todos, todo)
}
// 按 ID 排序:越早创建的 ID 越小
sort.Slice(todos, func(i, j int) bool {
return todos[i].ID < todos[j].ID
})
return todos, nil
}
// GetByID 根据 ID 获取单个 Todo 项。
// 当 ID 不存在时返回 error。
func (s *MemoryStore) GetByID(id int) (*model.Todo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
todo, ok := s.data[id]
if !ok {
return nil, fmt.Errorf("todo 项不存在:id=%d", id)
}
// 返回副本,避免外部修改内部状态
return &todo, nil
}
// Create 创建一个新的 Todo 项。
func (s *MemoryStore) Create(req model.CreateTodoRequest) (*model.Todo, error) {
s.mu.Lock() // 获取写锁:独占访问
defer s.mu.Unlock() // 函数返回时释放写锁
now := time.Now().Format(time.RFC3339)
todo := model.Todo{
ID: s.nextID,
Title: req.Title,
Description: req.Description,
Status: model.StatusPending, // 新建 Todo 默认状态为 pending
CreatedAt: now,
UpdatedAt: now,
}
s.data[s.nextID] = todo
s.nextID++
return &todo, nil
}
// Update 更新指定 ID 的 Todo 项。
// 只更新请求体中非 nil 的字段(部分更新 / PATCH 语义)。
func (s *MemoryStore) Update(id int, req model.UpdateTodoRequest) (*model.Todo, error) {
s.mu.Lock()
defer s.mu.Unlock()
todo, ok := s.data[id]
if !ok {
return nil, fmt.Errorf("todo 项不存在:id=%d", id)
}
// 部分更新:仅修改提供了值的字段
if req.Title != nil {
todo.Title = *req.Title
}
if req.Description != nil {
todo.Description = *req.Description
}
if req.Status != nil {
todo.Status = *req.Status
}
todo.UpdatedAt = time.Now().Format(time.RFC3339)
s.data[id] = todo
return &todo, nil
}
// Delete 删除指定 ID 的 Todo 项。
func (s *MemoryStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[id]; !ok {
return fmt.Errorf("todo 项不存在:id=%d", id)
}
delete(s.data, id)
return nil
}
12.5 实现 HTTP Handler¶
handler/todo.go —— 实现 Todo 相关的 HTTP 请求处理:
// handler/todo.go
// 包 handler 实现了 HTTP 请求处理逻辑。
// 每个函数对应一个 API 端点。
package handler
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"todo-api/model"
"todo-api/store"
)
// TodoHandler 封装 Todo 相关的 HTTP 处理逻辑。
type TodoHandler struct {
store store.Store // 通过接口依赖存储层,便于测试替换
}
// NewTodoHandler 创建新的 TodoHandler 实例。
func NewTodoHandler(s store.Store) *TodoHandler {
return &TodoHandler{store: s}
}
// HandleTodos 处理 /api/todos 路径的请求。
// GET :获取所有 Todo 列表
// POST :创建新的 Todo
func (h *TodoHandler) HandleTodos(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.listTodos(w, r)
case http.MethodPost:
h.createTodo(w, r)
default:
// 方法不允许:返回 405
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "方法不允许,仅支持 GET 和 POST",
})
}
}
// HandleTodo 处理 /api/todos/{id} 路径的请求。
// GET :获取指定 ID 的 Todo
// PUT :更新指定 ID 的 Todo
// DELETE :删除指定 ID 的 Todo
func (h *TodoHandler) HandleTodo(w http.ResponseWriter, r *http.Request) {
// 从 URL 路径中提取 ID
id, err := extractID(r.URL.Path)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
return
}
switch r.Method {
case http.MethodGet:
h.getTodo(w, r, id)
case http.MethodPut:
h.updateTodo(w, r, id)
case http.MethodDelete:
h.deleteTodo(w, r, id)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "方法不允许,仅支持 GET、PUT 和 DELETE",
})
}
}
// listTodos 处理 GET /api/todos:返回所有 Todo 列表。
func (h *TodoHandler) listTodos(w http.ResponseWriter, r *http.Request) {
todos, err := h.store.GetAll()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "获取 Todo 列表失败",
})
return
}
writeJSON(w, http.StatusOK, todos)
}
// createTodo 处理 POST /api/todos:创建新的 Todo。
func (h *TodoHandler) createTodo(w http.ResponseWriter, r *http.Request) {
var req model.CreateTodoRequest
// 解析请求体中的 JSON
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "请求体格式错误:" + err.Error(),
})
return
}
// 验证必填字段
if strings.TrimSpace(req.Title) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "标题不能为空",
})
return
}
todo, err := h.store.Create(req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "创建 Todo 失败:" + err.Error(),
})
return
}
// 201 Created:资源创建成功
writeJSON(w, http.StatusCreated, todo)
}
// getTodo 处理 GET /api/todos/{id}:获取指定 Todo。
func (h *TodoHandler) getTodo(w http.ResponseWriter, r *http.Request, id int) {
todo, err := h.store.GetByID(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, todo)
}
// updateTodo 处理 PUT /api/todos/{id}:更新指定 Todo。
func (h *TodoHandler) updateTodo(w http.ResponseWriter, r *http.Request, id int) {
var req model.UpdateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "请求体格式错误:" + err.Error(),
})
return
}
todo, err := h.store.Update(id, req)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, todo)
}
// deleteTodo 处理 DELETE /api/todos/{id}:删除指定 Todo。
func (h *TodoHandler) deleteTodo(w http.ResponseWriter, r *http.Request, id int) {
err := h.store.Delete(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]string{
"message": "删除成功",
})
}
// ---- 辅助函数 ----
// writeJSON 将 data 编码为 JSON 并写入 HTTP 响应。
func writeJSON(w http.ResponseWriter, statusCode int, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
// extractID 从 URL 路径中提取末尾的数字 ID。
// 示例:/api/todos/42 → 42
func extractID(path string) (int, error) {
parts := strings.Split(strings.TrimRight(path, "/"), "/")
if len(parts) < 1 {
return 0, nil
}
idStr := parts[len(parts)-1]
id, err := strconv.Atoi(idStr)
if err != nil {
return 0, fmt.Errorf("无效的 ID:%s", idStr)
}
return id, nil
}
12.6 程序入口和中间件¶
main.go —— 程序入口,包含路由注册、中间件实现和服务启动:
// main.go
// 包 main 是 Todo API 服务的程序入口。
package main
import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"todo-api/handler"
"todo-api/store"
)
// requestIDKey 用于在 context 中存储请求 ID 的键类型。
// 使用自定义类型避免 context key 冲突。
type requestIDKey struct{}
// loggingMiddleware 日志记录中间件。
// 记录每个 HTTP 请求的方法、路径、状态码和耗时。
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 使用 ResponseWriter 包装器以获取状态码
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
// 执行下一个 handler
next.ServeHTTP(wrapped, r)
// 请求完成后记录日志
duration := time.Since(start)
slog.Info("HTTP 请求",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration", duration.String(),
"remote_addr", r.RemoteAddr,
)
})
}
// requestIDMiddleware 请求 ID 中间件。
// 为每个请求生成唯一的标识符,存储在 context 中。
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 从请求头获取请求 ID,如果没有则生成一个
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = fmt.Sprintf("req-%d", time.Now().UnixNano())
}
// 将请求 ID 存储在 request context 中
ctx := context.WithValue(r.Context(), requestIDKey{}, requestID)
r = r.WithContext(ctx)
// 设置响应头
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r)
})
}
// recoveryMiddleware panic 恢复中间件。
// 捕获 handler 中的 panic,返回 500 错误而非让程序崩溃。
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("handler panic 恢复", "error", rec)
http.Error(w, `{"error":"服务器内部错误"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// responseWriter 包装 http.ResponseWriter,用于捕获状态码。
type responseWriter struct {
http.ResponseWriter
statusCode int
}
// WriteHeader 捕获状态码并调用原始的 WriteHeader。
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func main() {
// ---- 初始化依赖 ----
todoStore := store.NewMemoryStore()
todoHandler := handler.NewTodoHandler(todoStore)
// ---- 注册路由 ----
mux := http.NewServeMux()
mux.HandleFunc("/api/todos", todoHandler.HandleTodos)
mux.HandleFunc("/api/todos/", todoHandler.HandleTodo)
// ---- 应用中间件 ----
// 中间件按从外到内的顺序执行:
// 请求进入:recovery → requestID → logging → handler
// 响应返回:handler → logging → requestID → recovery
var app http.Handler = mux
app = loggingMiddleware(app)
app = requestIDMiddleware(app)
app = recoveryMiddleware(app)
// ---- 配置和启动 HTTP 服务 ----
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &http.Server{
Addr: ":" + port,
Handler: app,
ReadTimeout: 10 * time.Second, // 读取请求超时
WriteTimeout: 15 * time.Second, // 写入响应超时
IdleTimeout: 60 * time.Second, // 保持连接超时
}
// 在独立的 goroutine 中启动服务
go func() {
slog.Info("Todo API 服务启动", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败:%v", err)
}
}()
// ---- 优雅关闭 ----
// 监听操作系统信号(Ctrl+C 或 SIGTERM)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("正在关闭服务...")
// 设置 30 秒的超时等待未完成的请求处理完成
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("服务关闭失败:%v", err)
}
slog.Info("服务已安全关闭")
}
12.7 编写 Handler 测试¶
handler/todo_test.go —— 使用 net/http/httptest 编写测试:
// handler/todo_test.go
// 包 handler_test 包含 TodoHandler 的测试用例。
// 使用外部测试包实现黑盒测试。
package handler_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"todo-api/handler"
"todo-api/model"
"todo-api/store"
)
// setupTest 创建测试环境,返回 TodoHandler 和测试用的 HTTP Server。
func setupTest() *handler.TodoHandler {
s := store.NewMemoryStore()
return handler.NewTodoHandler(s)
}
// executeRequest 发送 HTTP 请求到 handler 并返回响应。
func executeRequest(h *handler.TodoHandler, method, path string, body any) *httptest.ResponseRecorder {
var reqBody []byte
if body != nil {
reqBody, _ = json.Marshal(body)
}
req := httptest.NewRequest(method, path, bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
// 根据路径分发到对应的 handler
if path == "/api/todos" {
h.HandleTodos(rr, req)
} else {
h.HandleTodo(rr, req)
}
return rr
}
// TestCreateTodo 测试创建 Todo 的功能。
func TestCreateTodo(t *testing.T) {
h := setupTest()
// 测试创建成功
t.Run("成功创建 Todo", func(t *testing.T) {
body := model.CreateTodoRequest{
Title: "学习 Go 并发编程",
Description: "完成第 10 章的练习",
}
rr := executeRequest(h, http.MethodPost, "/api/todos", body)
if rr.Code != http.StatusCreated {
t.Errorf("期望状态码 %d,得到 %d", http.StatusCreated, rr.Code)
}
var todo model.Todo
if err := json.Unmarshal(rr.Body.Bytes(), &todo); err != nil {
t.Fatalf("JSON 解析失败:%v", err)
}
if todo.Title != body.Title {
t.Errorf("期望标题 %q,得到 %q", body.Title, todo.Title)
}
if todo.Status != model.StatusPending {
t.Errorf("新创建的 Todo 状态应为 pending,得到 %q", todo.Status)
}
if todo.ID <= 0 {
t.Errorf("ID 应大于 0,得到 %d", todo.ID)
}
})
// 测试标题为空时创建失败
t.Run("标题为空时返回 400", func(t *testing.T) {
body := model.CreateTodoRequest{
Title: "",
}
rr := executeRequest(h, http.MethodPost, "/api/todos", body)
if rr.Code != http.StatusBadRequest {
t.Errorf("期望状态码 %d,得到 %d", http.StatusBadRequest, rr.Code)
}
var resp map[string]string
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp["error"] == "" {
t.Error("期望返回错误消息")
}
})
}
// TestGetTodo 测试获取 Todo 的功能。
func TestGetTodo(t *testing.T) {
h := setupTest()
// 先创建一个 Todo
createBody := model.CreateTodoRequest{Title: "测试获取"}
createResp := executeRequest(h, http.MethodPost, "/api/todos", createBody)
var created model.Todo
json.Unmarshal(createResp.Body.Bytes(), &created)
// 测试获取已存在的 Todo
t.Run("获取已存在的 Todo", func(t *testing.T) {
rr := executeRequest(h, http.MethodGet, "/api/todos/"+itoa(created.ID), nil)
if rr.Code != http.StatusOK {
t.Errorf("期望状态码 %d,得到 %d", http.StatusOK, rr.Code)
}
var todo model.Todo
json.Unmarshal(rr.Body.Bytes(), &todo)
if todo.ID != created.ID {
t.Errorf("期望 ID %d,得到 %d", created.ID, todo.ID)
}
})
// 测试获取不存在的 Todo
t.Run("获取不存在的 Todo 返回 404", func(t *testing.T) {
rr := executeRequest(h, http.MethodGet, "/api/todos/99999", nil)
if rr.Code != http.StatusNotFound {
t.Errorf("期望状态码 %d,得到 %d", http.StatusNotFound, rr.Code)
}
})
}
// TestUpdateTodo 测试更新 Todo 的功能。
func TestUpdateTodo(t *testing.T) {
h := setupTest()
// 创建测试数据
createBody := model.CreateTodoRequest{Title: "需要更新"}
createResp := executeRequest(h, http.MethodPost, "/api/todos", createBody)
var created model.Todo
json.Unmarshal(createResp.Body.Bytes(), &created)
// 测试部分更新
t.Run("部分更新 Todo", func(t *testing.T) {
newTitle := "已更新标题"
updateBody := model.UpdateTodoRequest{
Title: &newTitle,
}
status := model.StatusCompleted
updateBody.Status = &status
rr := executeRequest(h, http.MethodPut, "/api/todos/"+itoa(created.ID), updateBody)
if rr.Code != http.StatusOK {
t.Errorf("期望状态码 %d,得到 %d", http.StatusOK, rr.Code)
}
var updated model.Todo
json.Unmarshal(rr.Body.Bytes(), &updated)
if updated.Title != newTitle {
t.Errorf("期望标题 %q,得到 %q", newTitle, updated.Title)
}
if updated.Status != model.StatusCompleted {
t.Errorf("期望状态 %q,得到 %q", model.StatusCompleted, updated.Status)
}
})
}
// TestDeleteTodo 测试删除 Todo 的功能。
func TestDeleteTodo(t *testing.T) {
h := setupTest()
// 创建测试数据
createBody := model.CreateTodoRequest{Title: "需要删除"}
createResp := executeRequest(h, http.MethodPost, "/api/todos", createBody)
var created model.Todo
json.Unmarshal(createResp.Body.Bytes(), &created)
// 测试删除
t.Run("删除存在的 Todo", func(t *testing.T) {
rr := executeRequest(h, http.MethodDelete, "/api/todos/"+itoa(created.ID), nil)
if rr.Code != http.StatusOK {
t.Errorf("期望状态码 %d,得到 %d", http.StatusOK, rr.Code)
}
})
// 验证删除后无法获取
t.Run("删除后返回 404", func(t *testing.T) {
rr := executeRequest(h, http.MethodGet, "/api/todos/"+itoa(created.ID), nil)
if rr.Code != http.StatusNotFound {
t.Errorf("期望状态码 %d,得到 %d", http.StatusNotFound, rr.Code)
}
})
}
// TestListTodos 测试获取 Todo 列表的功能。
func TestListTodos(t *testing.T) {
h := setupTest()
// 创建多个测试数据
titles := []string{"任务 A", "任务 B", "任务 C"}
for _, title := range titles {
body := model.CreateTodoRequest{Title: title}
executeRequest(h, http.MethodPost, "/api/todos", body)
}
// 获取列表
rr := executeRequest(h, http.MethodGet, "/api/todos", nil)
if rr.Code != http.StatusOK {
t.Errorf("期望状态码 %d,得到 %d", http.StatusOK, rr.Code)
}
var todos []model.Todo
json.Unmarshal(rr.Body.Bytes(), &todos)
if len(todos) != len(titles) {
t.Errorf("期望 %d 个 Todo,得到 %d", len(titles), len(todos))
}
}
// itoa 是一个简单的整数转字符串辅助函数。
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
12.8 编译与部署¶
12.8.1 常规编译¶
# 编译为当前平台的二进制文件
go build -o todo-api.exe .
# 运行
./todo-api.exe
# 测试 API 端点
curl http://localhost:8080/api/todos
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"学习 Go","description":"完成第 12 章"}'
12.8.2 交叉编译¶
Go 支持为不同平台编译二进制文件,通过环境变量 GOOS 和 GOARCH 控制:
# 为 Linux (amd64) 编译
GOOS=linux GOARCH=amd64 go build -o todo-api-linux .
# 为 macOS (arm64/M 系列芯片) 编译
GOOS=darwin GOARCH=arm64 go build -o todo-api-macos .
# 为 Windows (amd64) 编译
GOOS=windows GOARCH=amd64 go build -o todo-api.exe .
常见的 GOOS/GOARCH 组合:
| 目标平台 | GOOS | GOARCH |
|---|---|---|
| Linux (x86_64) | linux | amd64 |
| Linux (ARM64) | linux | arm64 |
| macOS (Intel) | darwin | amd64 |
| macOS (Apple Silicon) | darwin | arm64 |
| Windows (x86_64) | windows | amd64 |
12.8.3 Docker 部署(可选)¶
Dockerfile:
# 构建阶段:使用 Go 官方镜像编译二进制文件
FROM golang:1.22-alpine AS builder
WORKDIR /app
# 先复制依赖文件,利用 Docker 层缓存
COPY go.mod go.sum ./
RUN go mod download
# 复制源码并编译
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/todo-api .
# 运行阶段:使用极小的 scratch 镜像
FROM scratch
WORKDIR /app
# 从构建阶段复制编译好的二进制文件
COPY --from=builder /app/todo-api .
# 暴露 API 端口
EXPOSE 8080
# 启动服务
CMD ["./todo-api"]
构建和运行 Docker 镜像:
# 构建 Docker 镜像
docker build -t todo-api .
# 运行容器
docker run -d -p 8080:8080 --name todo-api todo-api
12.9 测试和验证¶
12.9.1 运行单元测试¶
# 运行所有测试
go test -v ./...
# 运行测试并检查竞态条件
go test -race -v ./...
# 生成测试覆盖率报告
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
12.9.2 手动验证 API¶
启动服务后,可以通过 curl 或浏览器验证各端点:
# 1. 创建两个 Todo 项
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"学习 Go 语法","description":"完成前 6 章"}'
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"练习并发编程"}'
# 2. 获取所有 Todo 列表
curl http://localhost:8080/api/todos
# 3. 获取单个 Todo(假设 ID 为 1)
curl http://localhost:8080/api/todos/1
# 4. 更新 Todo(标记为完成)
curl -X PUT http://localhost:8080/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"status":"completed"}'
# 5. 删除 Todo
curl -X DELETE http://localhost:8080/api/todos/2
12.9.3 API 端点总览¶
| 方法 | 路径 | 功能 | 状态码 |
|---|---|---|---|
| GET | /api/todos |
获取所有 Todo | 200 |
| POST | /api/todos |
创建 Todo | 201 |
| GET | /api/todos/{id} |
获取单个 Todo | 200 / 404 |
| PUT | /api/todos/{id} |
更新 Todo | 200 / 404 |
| DELETE | /api/todos/{id} |
删除 Todo | 200 / 404 |
12.10 项目扩展方向¶
本章实现的 Todo API 是一个最小可行产品。以下是可以进一步探索的扩展方向:
- 持久化存储:替换 MemoryStore 为 SQLite 或 PostgreSQL 实现
- 数据库 ORM:使用 GORM 或 sqlx 简化数据库操作
- 配置管理:使用 Viper 库管理配置文件
- 路由框架:使用 Gin 或 Chi 替代标准库 mux
- 认证授权:添加 JWT Token 认证
- API 文档:集成 Swagger/OpenAPI 文档生成
- 日志聚合:将结构化日志输出到集中式日志系统
- 健康检查:添加
/health和/ready探活端点
12.11 本章小结¶
- 项目结构设计遵循 Go 标准布局,按职责划分包(model / store / handler)
- 数据模型使用 struct tag 控制 JSON 序列化行为
- 内存存储层通过接口抽象和
sync.RWMutex实现并发安全 - HTTP Handler 使用标准库
net/http实现 RESTful API - Middleware 通过装饰器模式实现横切关注点的分离
net/http/httptest支持编写高质量的 handler 级别黑盒测试- 交叉编译通过
GOOS/GOARCH环境变量实现 - Docker 多阶段构建可以有效减小最终镜像体积
实践任务¶
- 基于本章代码,为 Todo 模型添加
Priority(优先级:low / medium / high)和DueDate(截止日期)字段,并更新 CRUD 的实现 - 实现一个
PaginationMiddleware,支持?page=1&limit=10参数对 Todo 列表进行分页 - 添加一个搜索端点
GET /api/todos?q=keyword,支持按标题和描述的关键字搜索 - 将存储层替换为文件存储(JSON 文件持久化),实现
FileStore并实现store.Store接口
👉 返回教程首页 →