3.5.1 RESTful 设计原则

3.5.1 RESTful 设计原则 #

REST(Representational State Transfer)是一种软件架构风格,由 Roy Fielding 在 2000 年的博士论文中提出。RESTful API 已成为现代 Web 服务设计的标准,本节将深入讲解 REST 的核心原则和最佳实践。

REST 架构约束 #

REST 架构风格定义了六个基本约束条件,这些约束共同定义了 RESTful 系统的特征。

1. 客户端-服务器架构(Client-Server) #

客户端和服务器之间必须分离,各自独立演化。这种分离提高了用户界面的可移植性,简化了服务器组件。

// 服务器端:提供 API 接口
func main() {
    r := gin.Default()

    // API 路由组
    api := r.Group("/api/v1")
    {
        api.GET("/users", getUsers)
        api.POST("/users", createUser)
        api.GET("/users/:id", getUser)
        api.PUT("/users/:id", updateUser)
        api.DELETE("/users/:id", deleteUser)
    }

    r.Run(":8080")
}

2. 无状态(Stateless) #

服务器不应该保存客户端的状态信息。每个请求都必须包含处理该请求所需的所有信息。

// 错误示例:服务器保存会话状态
var sessions = make(map[string]*UserSession)

func loginHandler(c *gin.Context) {
    // 不推荐:在服务器端保存会话
    sessionID := generateSessionID()
    sessions[sessionID] = &UserSession{
        UserID: userID,
        LoginTime: time.Now(),
    }
}

// 正确示例:使用 JWT 令牌
func loginHandler(c *gin.Context) {
    var loginReq LoginRequest
    if err := c.ShouldBindJSON(&loginReq); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // 验证用户凭据
    user, err := authenticateUser(loginReq.Username, loginReq.Password)
    if err != nil {
        c.JSON(401, gin.H{"error": "Invalid credentials"})
        return
    }

    // 生成 JWT 令牌(无状态)
    token, err := generateJWT(user.ID)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to generate token"})
        return
    }

    c.JSON(200, gin.H{
        "token": token,
        "user":  user,
    })
}

3. 可缓存(Cacheable) #

响应数据必须明确标记为可缓存或不可缓存,以提高网络效率。

func getUserHandler(c *gin.Context) {
    userID := c.Param("id")

    user, err := getUserByID(userID)
    if err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }

    // 设置缓存头
    c.Header("Cache-Control", "public, max-age=300") // 5分钟缓存
    c.Header("ETag", fmt.Sprintf(`"%d"`, user.UpdatedAt.Unix()))

    // 检查 If-None-Match 头
    if match := c.GetHeader("If-None-Match"); match != "" {
        if match == fmt.Sprintf(`"%d"`, user.UpdatedAt.Unix()) {
            c.Status(304) // Not Modified
            return
        }
    }

    c.JSON(200, user)
}

4. 统一接口(Uniform Interface) #

REST 的核心特征是统一接口,包含四个约束:

资源标识(Resource Identification) #

每个资源都有唯一的标识符(URI)。

// 良好的资源标识设计
// GET /api/v1/users          - 获取用户列表
// GET /api/v1/users/123      - 获取特定用户
// GET /api/v1/users/123/posts - 获取用户的文章
// GET /api/v1/posts/456      - 获取特定文章
// GET /api/v1/posts/456/comments - 获取文章评论

type APIRoutes struct {
    router *gin.Engine
}

func (api *APIRoutes) setupRoutes() {
    v1 := api.router.Group("/api/v1")

    // 用户资源
    users := v1.Group("/users")
    {
        users.GET("", api.getUsers)
        users.POST("", api.createUser)
        users.GET("/:id", api.getUser)
        users.PUT("/:id", api.updateUser)
        users.DELETE("/:id", api.deleteUser)

        // 嵌套资源
        users.GET("/:id/posts", api.getUserPosts)
        users.POST("/:id/posts", api.createUserPost)
    }

    // 文章资源
    posts := v1.Group("/posts")
    {
        posts.GET("", api.getPosts)
        posts.POST("", api.createPost)
        posts.GET("/:id", api.getPost)
        posts.PUT("/:id", api.updatePost)
        posts.DELETE("/:id", api.deletePost)

        // 评论子资源
        posts.GET("/:id/comments", api.getPostComments)
        posts.POST("/:id/comments", api.createComment)
    }
}

通过表示操作资源(Resource Manipulation Through Representations) #

客户端通过资源的表示来操作资源。

type User struct {
    ID        uint      `json:"id" gorm:"primaryKey"`
    Username  string    `json:"username" gorm:"uniqueIndex"`
    Email     string    `json:"email" gorm:"uniqueIndex"`
    FullName  string    `json:"full_name"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

// 创建用户的表示
type CreateUserRequest struct {
    Username string `json:"username" binding:"required,min=3,max=20"`
    Email    string `json:"email" binding:"required,email"`
    FullName string `json:"full_name" binding:"required"`
    Password string `json:"password" binding:"required,min=6"`
}

// 更新用户的表示
type UpdateUserRequest struct {
    Email    *string `json:"email,omitempty" binding:"omitempty,email"`
    FullName *string `json:"full_name,omitempty"`
}

func createUserHandler(c *gin.Context) {
    var req CreateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // 通过表示创建资源
    user := User{
        Username: req.Username,
        Email:    req.Email,
        FullName: req.FullName,
    }

    // 密码哈希处理
    hashedPassword, err := hashPassword(req.Password)
    if err != nil {
        c.JSON(500, gin.H{"error": "Failed to process password"})
        return
    }

    if err := db.Create(&user).Error; err != nil {
        c.JSON(500, gin.H{"error": "Failed to create user"})
        return
    }

    c.JSON(201, user)
}

自描述消息(Self-Descriptive Messages) #

每个消息都包含足够的信息来描述如何处理该消息。

func setupMiddleware(r *gin.Engine) {
    // 内容类型中间件
    r.Use(func(c *gin.Context) {
        // 设置响应内容类型
        c.Header("Content-Type", "application/json; charset=utf-8")

        // 检查请求内容类型
        if c.Request.Method == "POST" || c.Request.Method == "PUT" {
            contentType := c.GetHeader("Content-Type")
            if !strings.Contains(contentType, "application/json") {
                c.JSON(415, gin.H{
                    "error": "Unsupported Media Type",
                    "message": "Content-Type must be application/json",
                })
                c.Abort()
                return
            }
        }

        c.Next()
    })
}

// 错误响应结构
type ErrorResponse struct {
    Error   string            `json:"error"`
    Message string            `json:"message"`
    Code    int              `json:"code"`
    Details map[string]string `json:"details,omitempty"`
}

func handleValidationError(c *gin.Context, err error) {
    var details map[string]string

    if validationErrors, ok := err.(validator.ValidationErrors); ok {
        details = make(map[string]string)
        for _, fieldError := range validationErrors {
            details[fieldError.Field()] = getValidationErrorMessage(fieldError)
        }
    }

    c.JSON(400, ErrorResponse{
        Error:   "Validation Failed",
        Message: "Request validation failed",
        Code:    400,
        Details: details,
    })
}

超媒体作为应用状态引擎(HATEOAS) #

响应应该包含链接,指导客户端如何进行下一步操作。

type UserResponse struct {
    User
    Links map[string]string `json:"_links"`
}

type UsersListResponse struct {
    Users []UserResponse `json:"users"`
    Meta  MetaInfo       `json:"meta"`
    Links map[string]string `json:"_links"`
}

type MetaInfo struct {
    Total       int `json:"total"`
    Page        int `json:"page"`
    PerPage     int `json:"per_page"`
    TotalPages  int `json:"total_pages"`
}

func getUserHandler(c *gin.Context) {
    userID := c.Param("id")

    var user User
    if err := db.First(&user, userID).Error; err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }

    // 构建 HATEOAS 链接
    baseURL := fmt.Sprintf("%s://%s", getScheme(c), c.Request.Host)
    userResponse := UserResponse{
        User: user,
        Links: map[string]string{
            "self":   fmt.Sprintf("%s/api/v1/users/%d", baseURL, user.ID),
            "posts":  fmt.Sprintf("%s/api/v1/users/%d/posts", baseURL, user.ID),
            "edit":   fmt.Sprintf("%s/api/v1/users/%d", baseURL, user.ID),
            "delete": fmt.Sprintf("%s/api/v1/users/%d", baseURL, user.ID),
        },
    }

    c.JSON(200, userResponse)
}

func getUsersHandler(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10"))

    var users []User
    var total int64

    offset := (page - 1) * perPage

    db.Model(&User{}).Count(&total)
    db.Offset(offset).Limit(perPage).Find(&users)

    // 构建用户响应
    userResponses := make([]UserResponse, len(users))
    baseURL := fmt.Sprintf("%s://%s", getScheme(c), c.Request.Host)

    for i, user := range users {
        userResponses[i] = UserResponse{
            User: user,
            Links: map[string]string{
                "self":  fmt.Sprintf("%s/api/v1/users/%d", baseURL, user.ID),
                "posts": fmt.Sprintf("%s/api/v1/users/%d/posts", baseURL, user.ID),
            },
        }
    }

    totalPages := int(math.Ceil(float64(total) / float64(perPage)))

    // 构建分页链接
    links := map[string]string{
        "self": fmt.Sprintf("%s/api/v1/users?page=%d&per_page=%d", baseURL, page, perPage),
    }

    if page > 1 {
        links["prev"] = fmt.Sprintf("%s/api/v1/users?page=%d&per_page=%d", baseURL, page-1, perPage)
        links["first"] = fmt.Sprintf("%s/api/v1/users?page=1&per_page=%d", baseURL, perPage)
    }

    if page < totalPages {
        links["next"] = fmt.Sprintf("%s/api/v1/users?page=%d&per_page=%d", baseURL, page+1, perPage)
        links["last"] = fmt.Sprintf("%s/api/v1/users?page=%d&per_page=%d", baseURL, totalPages, perPage)
    }

    response := UsersListResponse{
        Users: userResponses,
        Meta: MetaInfo{
            Total:      int(total),
            Page:       page,
            PerPage:    perPage,
            TotalPages: totalPages,
        },
        Links: links,
    }

    c.JSON(200, response)
}

5. 分层系统(Layered System) #

系统架构可以由多个层次组成,每一层只能看到与其交互的紧邻层。

// 分层架构示例
type UserController struct {
    userService *UserService
}

type UserService struct {
    userRepo *UserRepository
    cache    *CacheService
}

type UserRepository struct {
    db *gorm.DB
}

// 控制器层
func (uc *UserController) GetUser(c *gin.Context) {
    userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
    if err != nil {
        c.JSON(400, gin.H{"error": "Invalid user ID"})
        return
    }

    user, err := uc.userService.GetUserByID(uint(userID))
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            c.JSON(404, gin.H{"error": "User not found"})
            return
        }
        c.JSON(500, gin.H{"error": "Internal server error"})
        return
    }

    c.JSON(200, user)
}

// 服务层
func (us *UserService) GetUserByID(id uint) (*User, error) {
    // 先尝试从缓存获取
    if user, found := us.cache.Get(fmt.Sprintf("user:%d", id)); found {
        return user.(*User), nil
    }

    // 从数据库获取
    user, err := us.userRepo.FindByID(id)
    if err != nil {
        return nil, err
    }

    // 缓存结果
    us.cache.Set(fmt.Sprintf("user:%d", id), user, 5*time.Minute)

    return user, nil
}

// 数据访问层
func (ur *UserRepository) FindByID(id uint) (*User, error) {
    var user User
    if err := ur.db.First(&user, id).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, ErrUserNotFound
        }
        return nil, err
    }
    return &user, nil
}

6. 按需代码(Code on Demand) #

这是唯一的可选约束。服务器可以通过传输可执行代码来扩展客户端功能。

HTTP 方法的正确使用 #

RESTful API 应该正确使用 HTTP 方法来表示不同的操作。

GET - 获取资源 #

// 获取资源列表
func getUsers(c *gin.Context) {
    // 支持查询参数
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
    search := c.Query("search")

    var users []User
    query := db.Model(&User{})

    if search != "" {
        query = query.Where("username LIKE ? OR email LIKE ?",
            "%"+search+"%", "%"+search+"%")
    }

    query.Offset((page - 1) * limit).Limit(limit).Find(&users)

    c.JSON(200, gin.H{
        "users": users,
        "page":  page,
        "limit": limit,
    })
}

// 获取单个资源
func getUser(c *gin.Context) {
    id := c.Param("id")

    var user User
    if err := db.First(&user, id).Error; err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }

    c.JSON(200, user)
}

POST - 创建资源 #

func createUser(c *gin.Context) {
    var user User
    if err := c.ShouldBindJSON(&user); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // 验证数据
    if err := validateUser(&user); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    if err := db.Create(&user).Error; err != nil {
        c.JSON(500, gin.H{"error": "Failed to create user"})
        return
    }

    // 返回 201 Created 状态码
    c.Header("Location", fmt.Sprintf("/api/v1/users/%d", user.ID))
    c.JSON(201, user)
}

PUT - 完整更新资源 #

func updateUser(c *gin.Context) {
    id := c.Param("id")

    var existingUser User
    if err := db.First(&existingUser, id).Error; err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }

    var updateData User
    if err := c.ShouldBindJSON(&updateData); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // PUT 应该替换整个资源
    updateData.ID = existingUser.ID
    updateData.CreatedAt = existingUser.CreatedAt

    if err := db.Save(&updateData).Error; err != nil {
        c.JSON(500, gin.H{"error": "Failed to update user"})
        return
    }

    c.JSON(200, updateData)
}

PATCH - 部分更新资源 #

func patchUser(c *gin.Context) {
    id := c.Param("id")

    var user User
    if err := db.First(&user, id).Error; err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }

    var updates map[string]interface{}
    if err := c.ShouldBindJSON(&updates); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // 只更新提供的字段
    if err := db.Model(&user).Updates(updates).Error; err != nil {
        c.JSON(500, gin.H{"error": "Failed to update user"})
        return
    }

    c.JSON(200, user)
}

DELETE - 删除资源 #

func deleteUser(c *gin.Context) {
    id := c.Param("id")

    var user User
    if err := db.First(&user, id).Error; err != nil {
        c.JSON(404, gin.H{"error": "User not found"})
        return
    }

    if err := db.Delete(&user).Error; err != nil {
        c.JSON(500, gin.H{"error": "Failed to delete user"})
        return
    }

    // 返回 204 No Content
    c.Status(204)
}

状态码的正确使用 #

RESTful API 应该使用适当的 HTTP 状态码来表示操作结果。

// 状态码常量
const (
    StatusOK                  = 200
    StatusCreated            = 201
    StatusAccepted           = 202
    StatusNoContent          = 204
    StatusBadRequest         = 400
    StatusUnauthorized       = 401
    StatusForbidden          = 403
    StatusNotFound           = 404
    StatusMethodNotAllowed   = 405
    StatusConflict           = 409
    StatusUnprocessableEntity = 422
    StatusInternalServerError = 500
)

// 统一的错误处理
func handleError(c *gin.Context, err error) {
    switch {
    case errors.Is(err, ErrUserNotFound):
        c.JSON(StatusNotFound, gin.H{
            "error": "Resource not found",
            "message": err.Error(),
        })
    case errors.Is(err, ErrValidationFailed):
        c.JSON(StatusBadRequest, gin.H{
            "error": "Validation failed",
            "message": err.Error(),
        })
    case errors.Is(err, ErrUnauthorized):
        c.JSON(StatusUnauthorized, gin.H{
            "error": "Unauthorized",
            "message": "Authentication required",
        })
    case errors.Is(err, ErrForbidden):
        c.JSON(StatusForbidden, gin.H{
            "error": "Forbidden",
            "message": "Insufficient permissions",
        })
    default:
        c.JSON(StatusInternalServerError, gin.H{
            "error": "Internal server error",
            "message": "An unexpected error occurred",
        })
    }
}

资源命名约定 #

良好的资源命名是 RESTful API 设计的重要组成部分。

// 良好的资源命名示例
func setupAPIRoutes(r *gin.Engine) {
    api := r.Group("/api/v1")

    // 使用复数名词表示资源集合
    users := api.Group("/users")
    {
        users.GET("", getUsers)           // GET /api/v1/users
        users.POST("", createUser)        // POST /api/v1/users
        users.GET("/:id", getUser)        // GET /api/v1/users/123
        users.PUT("/:id", updateUser)     // PUT /api/v1/users/123
        users.DELETE("/:id", deleteUser)  // DELETE /api/v1/users/123

        // 嵌套资源
        users.GET("/:id/posts", getUserPosts)     // GET /api/v1/users/123/posts
        users.POST("/:id/posts", createUserPost)  // POST /api/v1/users/123/posts
    }

    posts := api.Group("/posts")
    {
        posts.GET("", getPosts)
        posts.POST("", createPost)
        posts.GET("/:id", getPost)
        posts.PUT("/:id", updatePost)
        posts.DELETE("/:id", deletePost)

        // 评论作为子资源
        posts.GET("/:id/comments", getPostComments)
        posts.POST("/:id/comments", createComment)
        posts.GET("/:id/comments/:comment_id", getComment)
        posts.PUT("/:id/comments/:comment_id", updateComment)
        posts.DELETE("/:id/comments/:comment_id", deleteComment)
    }

    // 非资源端点使用动词
    api.POST("/auth/login", login)
    api.POST("/auth/logout", logout)
    api.POST("/auth/refresh", refreshToken)
    api.POST("/users/:id/activate", activateUser)
    api.POST("/posts/:id/publish", publishPost)
}

内容协商 #

RESTful API 应该支持内容协商,允许客户端指定期望的响应格式。

func contentNegotiationMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        accept := c.GetHeader("Accept")

        switch {
        case strings.Contains(accept, "application/json"):
            c.Set("responseType", "json")
        case strings.Contains(accept, "application/xml"):
            c.Set("responseType", "xml")
        case strings.Contains(accept, "text/plain"):
            c.Set("responseType", "text")
        default:
            c.Set("responseType", "json") // 默认 JSON
        }

        c.Next()
    }
}

func respondWithData(c *gin.Context, data interface{}) {
    responseType, _ := c.Get("responseType")

    switch responseType {
    case "xml":
        c.XML(200, data)
    case "text":
        c.String(200, fmt.Sprintf("%+v", data))
    default:
        c.JSON(200, data)
    }
}

通过遵循这些 RESTful 设计原则,你可以创建出清晰、一致、易于使用和维护的 API。这些原则不仅提高了 API 的质量,还增强了系统的可扩展性和互操作性。