19、MVC中的RESTful API设计:控制器如何优雅地返回JSON

RESTful API 这个词,你肯定听过无数遍了。但说实话,很多人在 MVC 里做 API 时,只是把控制器里的方法改个名字,返回个 JSON 完事。这样做不是不行,但离「设计」两个字还差得远。

我个人习惯把 RESTful API 看作是 MVC 架构的「对外接口层」。它不关心页面长什么样,只关心数据和资源。今天我们就聊聊,在 MVC 里怎么把 API 设计得既规范又实用。

19.1 RESTful 的核心思想:资源与动作

REST 说白了就是:把一切看成资源,用 HTTP 方法去操作它们。

  • GET:获取资源(查)
  • POST:创建资源(增)
  • PUT / PATCH:更新资源(改)
  • DELETE:删除资源(删)

你想想看,这不就是 MVC 里控制器最擅长的事吗?控制器接收请求,调用模型,返回响应。只不过传统 MVC 返回的是 HTML 视图,而 RESTful API 返回的是 JSON 数据。

核心原则:URL 只表示资源,不表示动作。动作由 HTTP 方法决定。

GET /api/users — 获取用户列表

POST /api/users — 创建新用户

GET /api/getUsers — 动词出现在 URL 里,不推荐

POST /api/deleteUser — 同上

19.2 控制器如何返回 JSON 数据

在 MVC 里,控制器返回 JSON 其实很简单。大多数现代框架都提供了内置支持。我以最常见的几种语言为例:

19.2.1 PHP(Laravel 为例)

// 控制器方法
public function index()
{
    $users = User::all();
    return response()->json([
        'success' => true,
        'data' => $users,
        'message' => '用户列表获取成功'
    ]);
}

// 或者更简洁的写法
public function show($id)
{
    $user = User::findOrFail($id);
    return response()->json($user);
}

19.2.2 Node.js(Express 为例)

// 控制器方法
const getUsers = async (req, res) => {
  try {
    const users = await User.find();
    res.json({
      success: true,
      data: users,
      message: '用户列表获取成功'
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '服务器内部错误'
    });
  }
};

19.2.3 Java(Spring Boot 为例)

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    public ResponseEntity<ApiResponse<List<User>>> getAllUsers() {
        List<User> users = userService.findAll();
        return ResponseEntity.ok(
            new ApiResponse<>(true, users, "获取成功")
        );
    }
}

嗯,这里要注意:不要直接返回模型对象。我在项目中遇到过好几次,直接返回模型对象导致泄露了密码字段。一定要用 DTO(数据传输对象)或者手动筛选字段。

避坑指南:我曾经在一个电商项目里,直接返回了 User 模型,结果前端拿到了密码的哈希值。虽然哈希值不能直接登录,但安全审计时被扣了分。从那以后,我所有 API 都强制使用资源转换层。

19.3 统一响应格式:让前端不再骂人

为什么需要统一响应格式?你想想看,如果每个接口返回的结构都不一样,前端同学每次都要看文档才知道 data 在哪,是不是很崩溃?

我建议所有 API 都遵循以下格式:

{
  "success": true|false,
  "data": { ... } | [ ... ] | null,
  "message": "提示信息",
  "errors": { ... } | null,   // 仅当有验证错误时
  "meta": {                   // 仅当需要分页时
    "current_page": 1,
    "per_page": 15,
    "total": 100
  }
}
字段 类型 说明
success boolean 请求是否成功
data object/array/null 实际返回的数据
message string 给前端的提示信息
errors object/null 验证错误详情
meta object/null 分页等元信息

小技巧:在控制器基类里定义一个 success()error() 方法,所有子控制器直接调用。这样改一次,全局生效。

19.4 状态码:别什么都返回 200

我见过太多人,不管成功还是失败,统统返回 200。然后在前端通过 success 字段判断。这样做其实浪费了 HTTP 状态码的语义。

正确的做法是:

  • 200:GET 成功,PUT/PATCH 成功
  • 201:POST 创建成功
  • 204:DELETE 删除成功(无返回体)
  • 400:请求参数错误
  • 401:未认证
  • 403:无权限
  • 404:资源不存在
  • 422:验证失败
  • 500:服务器内部错误
// 示例:创建资源返回 201
public function store(Request $request)
{
    $user = User::create($request->validated());
    return response()->json([
        'success' => true,
        'data' => $user,
        'message' => '创建成功'
    ], 201);  // 注意这里的状态码
}

19.5 资源嵌套与路由设计

实际项目中,资源往往不是孤立的。比如:一个用户有多篇文章,一篇文章有多条评论。这时候怎么设计路由?

// 嵌套资源
GET    /api/users/{user}/articles          // 获取某用户的文章列表
POST   /api/users/{user}/articles          // 为某用户创建文章
GET    /api/articles/{article}/comments    // 获取某文章的评论
POST   /api/articles/{article}/comments    // 为某文章添加评论

// 或者扁平化(我个人更推荐)
GET    /api/articles?user_id={user}        // 通过查询参数过滤
POST   /api/articles                       // 创建文章时传入 user_id
GET    /api/comments?article_id={article}  // 通过查询参数过滤

为什么我推荐扁平化?因为嵌套太深会让 URL 变得很长,而且维护起来麻烦。你想想看,三层嵌套的 URL 长什么样?/api/users/{user}/articles/{article}/comments/{comment},光是写出来就够累的。

我的建议:嵌套不超过两层。超过两层就用查询参数代替。

19.6 分页、排序与字段筛选

API 设计里,这三个功能几乎是标配。我习惯用查询参数来实现:

// 分页
GET /api/users?page=1&per_page=15

// 排序
GET /api/users?sort=created_at&order=desc

// 字段筛选
GET /api/users?fields=id,name,email

// 组合使用
GET /api/users?page=1&per_page=10&sort=name&order=asc&fields=id,name,email

控制器里处理起来也很简单:

public function index(Request $request)
{
    $query = User::query();

    // 排序
    if ($request->has('sort')) {
        $order = $request->get('order', 'asc');
        $query->orderBy($request->sort, $order);
    }

    // 字段筛选
    if ($request->has('fields')) {
        $fields = explode(',', $request->fields);
        $query->select($fields);
    }

    // 分页
    $perPage = $request->get('per_page', 15);
    $users = $query->paginate($perPage);

    return response()->json([
        'success' => true,
        'data' => $users->items(),
        'meta' => [
            'current_page' => $users->currentPage(),
            'per_page' => $users->perPage(),
            'total' => $users->total()
        ]
    ]);
}

19.7 错误处理:别让前端猜

错误处理是 API 设计里最容易忽视的部分。我见过太多 API 在出错时只返回一个 500 状态码,连个错误信息都没有。

好的错误处理应该做到:

  • 告诉前端哪里错了(字段名)
  • 告诉前端为什么错(错误原因)
  • 告诉前端怎么改(提示信息)
// 验证错误示例
{
  "success": false,
  "message": "验证失败",
  "errors": {
    "email": ["邮箱格式不正确", "该邮箱已被注册"],
    "password": ["密码长度不能少于8位"]
  }
}

// 业务错误示例
{
  "success": false,
  "message": "该用户已被禁用,请联系管理员",
  "code": "USER_DISABLED"
}

注意:不要在生产环境返回详细的堆栈信息。我曾经见过一个 API 直接把 SQL 错误抛给了前端,数据库表结构一览无余。这不仅是设计问题,更是安全问题。

19.8 核心知识体系

下面这张图总结了 MVC 中 RESTful API 设计的核心逻辑:

MVC 中 RESTful API 设计核心体系 客户端 浏览器 / App / 第三方 HTTP 请求 路由层 URL + HTTP 方法 控制器 接收请求 调用模型 返回 JSON 模型层 数据操作 / 业务逻辑 数据库 响应格式 success data message errors meta 状态码 200/201/400/404 422/500 分页/排序 字段筛选 核心流程:客户端 → 路由 → 控制器 → 模型 → 数据库 → 返回统一格式的 JSON

19.9 版本管理:给 API 留条后路

API 一旦上线,就很难改了。因为客户端可能已经依赖了旧的接口。所以版本管理是必须的。

我常用的两种方式:

  • URL 路径版本/api/v1/users/api/v2/users
  • 请求头版本Accept: application/vnd.myapp.v1+json

我个人更推荐 URL 路径版本,因为直观,调试方便。你想想看,用请求头版本的话,前端同学每次都要在浏览器里手动改请求头,多麻烦。

经验之谈:不要等到 API 上线了才考虑版本。在项目初期就把版本号加进去,哪怕 v1 只有一个接口。这样后续迭代时,你只需要新增 v2 路由,旧客户端不受影响。

19.10 总结

RESTful API 设计在 MVC 里其实不复杂。核心就三点:

  1. 资源路由:URL 只表示资源,HTTP 方法表示动作
  2. 统一响应:所有接口返回相同结构的 JSON
  3. 合理状态码:用 HTTP 状态码表达请求结果

做到这三点,你的 API 就已经比市面上 80% 的接口要规范了。剩下的细节,比如分页、排序、错误处理,都是在这些基础上锦上添花。

嗯,最后说一句:API 设计没有银弹。不同项目、不同团队可能有不同的约定。但只要你遵循 REST 的核心思想,保持一致性,你的 API 就不会太差。

公众号:蓝海资料掘金营,微信deep3321