POST 创建资源如何返回 201 与 Location?

解读

国内一线、二线互联网公司面试时,这道题表面考“状态码”,实则验证候选人是否真正理解 RESTful 设计、HTTP 语义以及 PHP 对原生 Header 的操控能力。
很多候选人只答“header() 函数”,却忽略以下扣分点:

  1. 201 必须在响应体为空或仅含 JSON 描述时返回,不能混用 200;
  2. Location 必须是绝对 URI(RFC 2616 推荐),且需先校验资源是否落库成功;
  3. 框架场景下,须说明如何关闭默认 200 并防止重复渲染;
  4. 高并发场景下,需保证事务提交后再发送 Header,否则会出现“已发送 output”警告。
    面试官常追问:
    “如果提前 echo 了日志,header() 失效怎么办?”
    “Laravel 里如何一句话返回 201 并带 Location?”
    “ swoole 或 RoadRunner 常驻进程下为何要用响应对象而非 header()?”
    答到这些细节,才能体现“工程级”经验。

知识点

  1. HTTP/1.1 201 Created 语义:请求已被满足,新资源已生成,Location 指向其 URI。
  2. PHP 输出控制:header() 前不能有任何字符输出;output_buffering / ob_start 可兜底。
  3. RFC 7231 对 Location 格式要求:绝对 URI,建议同时返回 Content-Location 区分版本。
  4. RESTful 资源标识策略:自增主键用 /orders/123,UUID 用 /orders/a1b2c3…,面试需给出理由。
  5. 框架封装:
    • Laravel: response()->json(data,201)>header(Location,data, 201)->header('Location', url);
    • Symfony: new JsonResponse(data,201,[Location=>data, 201, ['Location' => url]);
    • ThinkPHP8: return json(data,201)>header([Location=>data, 201)->header(['Location' => url]);
  6. 事务安全:PDO 事务提交成功后再发送 Header,防止“回滚但已 201”的脏数据。
  7. Swoole/RoadRunner 常驻进程:禁止 header(),须用 response>setStatus(201)>setHeader(Location,response->setStatus(201)->setHeader('Location', url)。
  8. 单元测试:PHPUnit 断言 response->getStatusCode() === 201 && response->hasHeader('Location)。

答案

原生 PHP 写法(最简可运行,含防御性 ob):

<?php
declare(strict_types=1);

try {
    $pdo  = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
    $pdo->beginTransaction();

    $stmt = $pdo->prepare("INSERT INTO posts (title, body) VALUES (?, ?)");
    $stmt->execute([$_POST['title'], $_POST['body']]);
    $id   = (int) $pdo->lastInsertId();

    $pdo->commit();          // 必须提交后再输出头部

    $location = 'https://api.example.com/posts/' . $id;   // 绝对 URI

    ob_start();              // 防止之前意外输出导致 header 失效
    header('HTTP/1.1 201 Created');   // 有些 CGI 环境需用 "HTTP/1.1"
    header('Location: ' . $location);
    ob_end_clean();

    // 可选:返回极简 JSON 描述
    echo json_encode(['id' => $id, 'uri' => $location], JSON_UNESCAPED_SLASHES);
    exit;
} catch (Throwable $e) {
    $pdo->rollBack();
    http_response_code(500);
    echo json_encode(['error' => 'Internal Server Error']);
}

Laravel 一句话写法(符合国内主流框架习惯):

public function store(PostRequest $request)
{
    $post = Post::create($request->validated());
    return response()
            ->json(['data' => new PostResource($post)], 201)
            ->header('Location', route('posts.show', ['post' => $post->id]));
}

关键点:

  • 201 状态码由 response()->json 第二个参数控制;
  • route() 生成绝对 URI,满足 RFC;
  • 无手动 echo,框架自动处理输出缓冲。

拓展思考

  1. 如果资源创建是异步任务(队列、工单系统),应返回 202 Accepted,并在响应体里给出“任务状态查询地址”,此时不能再给 201。
  2. 批量导入场景:一次 POST 创建多条记录,可返回 207 Multi-Status,每个子项携带自己的 Location;国内电商 ERP 接口常用此技巧。
  3. API 网关层(Nginx+Lua、Kong)可能强制重写状态码,需在 Location 中加入自定义头(X-Resource-Id)供前端兜底。
  4. 高并发下主从延迟:刚插入主库,立即 302 到从库读详情可能 404,可在 Location 后加“grace=1s”参数,或读主库一次再返回。
  5. GraphQL mutation 没有“状态码”概念,但可在响应扩展字段里返回“location”,面试可借此展示对 REST vs GraphQL 差异的理解。