43·网络编程进阶

REST API 设计与实现

restcrudjsonrouting

第43课 - REST API 设计与实现

学习目标

完成本课学习后,你将能够:

  1. 理解RESTful架构风格的核心原则与HTTP方法的语义。
  2. 使用Go标准库net/http构建一个完整的、支持CRUD操作的RESTful API。
  3. 正确处理JSON格式的请求体与响应体。
  4. 使用路由(Router)组织和管理API端点。
  5. 了解在并发环境下处理HTTP请求的基本模式。

核心概念

REST (Representational State Transfer) 不是一个协议,而是一种设计风格。想象一个餐厅:你(客户端)拿着菜单(API文档),告诉服务员(服务器)你想要什么菜(资源),用特定的方式(HTTP方法)。服务员给你做好的菜(资源状态)。

  • 资源 (Resource):一切皆资源,比如一个用户、一篇文章、一本书。它通过URI(统一资源标识符)来标识,例如 /api/books/123
  • HTTP 方法 (动词):定义了你对资源要执行的动作。
    • GET读取资源。无副作用,幂等。
    • POST创建新资源。通常向集合端点 /api/books 提交数据。
    • PUT替换/更新整个资源。向资源端点 /api/books/123 发送完整数据。
    • DELETE删除资源。向资源端点 /api/books/123 发起请求。
    • PATCH部分更新资源(本课示例中暂不实现)。
  • 状态码 (Status Codes):服务器的回复暗号。
    • 200 OK:通用成功。
    • 201 Created:资源创建成功(通常与POST配合使用)。
    • 204 No Content:操作成功,但没有内容返回(常用于DELETE)。
    • 400 Bad Request:客户端请求有误(如JSON格式错误)。
    • 404 Not Found:资源不存在。
    • 500 Internal Server Error:服务器内部错误。
  • JSON:前后端数据交换的“通用语言”。Go通过 encoding/json 包方便地进行序列化(Go结构体 → JSON)与反序列化(JSON → Go结构体)。

路由 (Routing) 就像餐厅的不同柜台。甜点柜台(/desserts)和主食柜台(/main-courses)处理不同的请求。在Go中,http.ServeMux 就是一个简单的路由器。

代码示例:一个简单的书籍管理API

我们将构建一个管理书籍的RESTful API,支持增删改查。

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"strconv"
	"strings"
	"sync"
)

// Book 定义了书籍的资源结构
type Book struct {
	ID     int    `json:"id"`
	Title  string `json:"title"`
	Author string `json:"author"`
}

// 模拟数据库:一个全局的map和一把并发锁
var (
	books    = make(map[int]Book)
	nextID   = 1
	booksMu  sync.RWMutex // 保护books的并发读写锁
)

func main() {
	// 初始化几条数据
	books[nextID] = Book{ID: nextID, Title: "The Go Programming Language", Author: "Alan A. A. Donovan"}
	nextID++
	books[nextID] = Book{ID: nextID, Title: "Clean Code", Author: "Robert C. Martin"}
	nextID++

	// 路由设置
	mux := http.NewServeMux()
	mux.HandleFunc("/api/books", booksHandler)       // 处理 /api/books 和 /api/books/
	mux.HandleFunc("/api/books/", bookHandler)       // 处理 /api/books/{id}

	fmt.Println("Server starting on :8080...")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

// booksHandler 处理对书籍集合的请求 (GET, POST)
func booksHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		// 获取所有书籍列表
		booksMu.RLock()
		bookList := make([]Book, 0, len(books))
		for _, b := range books {
			bookList = append(bookList, b)
		}
		booksMu.RUnlock()

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(bookList)

	case http.MethodPost:
		// 创建一本新书
		var newBook Book
		decoder := json.NewDecoder(r.Body)
		if err := decoder.Decode(&newBook); err != nil {
			http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
			return
		}
		// 注意:客户端通常不指定ID,我们从服务端分配
		booksMu.Lock()
		newBook.ID = nextID
		books[newBook.ID] = newBook
		nextID++
		booksMu.Unlock()

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated) // 201 Created
		json.NewEncoder(w).Encode(newBook)

	default:
		http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
	}
}

// bookHandler 处理对单个书籍的请求 (GET, PUT, DELETE)
func bookHandler(w http.ResponseWriter, r *http.Request) {
	// 从路径中解析书籍ID,例如 /api/books/1 -> id=1
	idStr := strings.TrimPrefix(r.URL.Path, "/api/books/")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, `{"error": "Invalid book ID"}`, http.StatusBadRequest)
		return
	}

	// 根据HTTP方法处理
	switch r.Method {
	case http.MethodGet:
		booksMu.RLock()
		book, exists := books[id]
		booksMu.RUnlock()

		if !exists {
			http.Error(w, `{"error": "Book not found"}`, http.StatusNotFound)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(book)

	case http.MethodPut:
		// 更新一本书
		booksMu.Lock()
		_, exists := books[id]
		if !exists {
			booksMu.Unlock()
			http.Error(w, `{"error": "Book not found"}`, http.StatusNotFound)
			return
		}

		var updatedBook Book
		decoder := json.NewDecoder(r.Body)
		if err := decoder.Decode(&updatedBook); err != nil {
			booksMu.Unlock()
			http.Error(w, `{"error": "Invalid JSON"}`, http.StatusBadRequest)
			return
		}
		// 确保路径ID与请求体中的ID一致(或忽略请求体中的ID)
		updatedBook.ID = id
		books[id] = updatedBook
		booksMu.Unlock()

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(updatedBook)

	case http.MethodDelete:
		// 删除一本书
		booksMu.Lock()
		_, exists := books[id]
		if !exists {
			booksMu.Unlock()
			http.Error(w, `{"error": "Book not found"}`, http.StatusNotFound)
			return
		}
		delete(books, id)
		booksMu.Unlock()

		w.WriteHeader(http.StatusNoContent) // 204 No Content

	default:
		http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
	}
}

如何测试这个API?

  1. 运行程序:go run main.go
  2. 使用 curl 命令(或Postman等工具)进行测试:
    • 获取所有书籍curl http://localhost:8080/api/books
    • 创建一本新书curl -X POST -H "Content-Type: application/json" -d '{"title": "Effective Go", "author": "Go Team"}' http://localhost:8080/api/books
    • 获取单本书curl http://localhost:8080/api/books/1
    • 更新一本书curl -X PUT -H "Content-Type: application/json" -d '{"title": "Effective Go (Updated)", "author": "Go Team"}' http://localhost:8080/api/books/1
    • 删除一本书curl -X DELETE http://localhost:8080/api/books/2

实践练习

  1. 简单扩展(易):为 /api/books 端点添加搜索功能。当请求带有查询参数 ?author=xxx 时,只返回作者名包含该关键字的书籍。

    • 要求:在 booksHandlerGET 逻辑中解析 author 参数。
    • 预期输出curl "http://localhost:8080/api/books?author=Martin" 应返回作者为“Robert C. Martin”的书籍。
  2. 嵌套路由(中):为用户添加书籍收藏功能。设计新的API端点:

    • GET /api/users/{userId}/books:获取某个用户收藏的书籍ID列表。
    • POST /api/users/{userId}/books/{bookId}:收藏一本书。
    • 提示:需要新增 User 结构体和对应的存储。思考如何组织路由和处理函数。
  3. 并发控制(难):修改当前代码,移除全局的 booksMu 互斥锁。你的任务是:仅在 nextID 自增时使用锁保护,而 books 这个map的读写完全不加锁。运行程序,并用 curl 快速并发地创建多本书(可使用 abwrk 工具),观察程序是否出现 fatal error: concurrent map read and map write 异常。分析原因,并思考为什么必须使用锁(或分片、sync.Map等)来保护共享数据结构。

常见错误

  1. 忘记设置响应头:返回JSON数据时,忘记设置 Content-Type: application/json,导致客户端(尤其是浏览器)可能无法正确解析。
  2. 路由冲突与优先级/api/books/api/books/ 在Go的ServeMux中是同一个路由。但如果定义了 /api/books/api/books/search,需要注意匹配的精确性。更复杂的路由可能需要第三方库(如 gorilla/mux)。
  3. 请求体未读取或重复读取:对于POST/PUT请求,r.Body 只能被读取一次。如果在中间件和主处理器中都需要读取,需要预先存储读取的内容。
  4. JSON标签拼写错误:结构体标签 json:"title" 中的引号或字段名写错,会导致JSON编码/解码失败或字段名不符预期。
  5. 并发数据竞争:如示例所示,对共享的全局变量(如books map)进行读写操作时,如果不加锁,在高并发下会导致程序崩溃。这是Go并发编程中最常见的问题之一。

小结

  • REST的核心是资源和对资源的操作,这些操作由HTTP方法(GET, POST, PUT, DELETE)明确定义。
  • Go标准库足以构建生产级APInet/http 提供了强大的基础,encoding/json 处理了数据序列化。
  • 状态码是API通信的重要部分,正确使用它们能让客户端更准确地理解服务器响应。
  • 路由是组织API的骨架,清晰的路由设计让API易于理解和使用。
  • 并发安全至关重要。在Web服务器这个并发环境下,必须使用锁(sync.Mutexsync.RWMutex)或其他同步机制来保护共享状态,否则程序会随机崩溃。
  • 更复杂的路由匹配(如路径参数 /api/books/{id}、方法过滤)可以考虑使用第三方库如 gorilla/muxchi,它们提供了更强大和便捷的功能。

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「TCP/UDP 网络编程」 以巩固所学知识。