每位软件工程师应掌握的 API 概念详解

本文有一个短视频:TikTok

1. 概述

调用 API 很简单——发送请求、读取 JSON,完成。但是设计一个 API,让其他团队或者你自己 6 个月后仍然能使用,而不必阅读代码来猜测行为——这完全是另一回事。本文汇总了任何后端工程师都应该掌握的 API 概念:从 HTTP 基础、架构风格(REST、GraphQL、gRPC、webhook、WebSocket),到可持续设计、安全性、可靠性和文档化。读完本文,你将拥有一个具体的检查清单,在向他人公开你的 API 之前进行自我审查。

2. HTTP 基础:方法、状态码、请求/响应

在讨论 REST 或 GraphQL 之前,你必须正确使用基本 HTTP 语义。这是许多看起来像 REST 的 API 实际上大错特错的地方。

@RestController
@RequestMapping("/api/orders")
class OrderController {

    private final OrderService orderService;

    OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    ResponseEntity<Order> getOrder(@PathVariable Long id) {
        return orderService.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping
    ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
        Order created = orderService.create(request);
        return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
    }

    @PutMapping("/{id}")
    ResponseEntity<Order> replaceOrder(@PathVariable Long id, @RequestBody Order order) {
        return ResponseEntity.ok(orderService.replace(id, order));
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
        orderService.cancel(id);
        return ResponseEntity.noContent().build();
    }
}

几个容易出错的地方:

  • GET 必须是安全的(不改变状态)且幂等的。如果你的 GET 端点增加计数器或写重要日志,你就违反了与客户端的隐含契约(也与代理/CDN 缓存的契约)。
  • PUT 替换整个资源,PATCH 只更新改变的部分。用 PUT 更新单个字段在语义上是错误的,即使代码仍然可以运行。
  • 状态码必须反映真实的含义:创建新资源时返回 201 Created(含 Location 请求头),成功删除且无响应体时返回 204 No Content,状态冲突时返回 409 Conflict——而不是所有错误都是 400 或 500。

注意: 对所有响应返回 200 OK 加上 body 中的 "success": false 是常见的反模式。客户端、负载均衡器、APM 工具都依赖状态码来决定是否重试或告警——在 body 中隐藏错误会破坏整个链路。

3. 选择 API 风格:REST、GraphQL、gRPC、webhook、WebSocket

每种架构风格解决不同的问题。选错不是技术错误——这是误解了用例。

3.1. REST——HTTP 上 CRUD 的默认选择

适合资源清晰明确(Order、Customer、Invoice)且客户端多样(web、mobile、第三方)时使用。第 2 节的例子就是标准 REST。

3.2. GraphQL——当客户端需要灵活的字段选择

type Order {
  id: ID!
  customer: Customer!
  total: Float!
  items: [OrderItem!]!
}

type Query {
  order(id: ID!): Order
}
@Controller
class OrderGraphQlController {

    private final OrderService orderService;

    OrderGraphQlController(OrderService orderService) {
        this.orderService = orderService;
    }

    @QueryMapping
    Order order(@Argument Long id) {
        return orderService.findById(id).orElse(null);
    }
}

客户端可以自由选择需要的字段({ order(id: 1) { total } } 而不必调用 3 个 REST 端点)。代价是你失去了基于 URL 的 HTTP 缓存,必须自己处理 N+1 查询问题(使用 DataLoader)。GraphQL 适合有复杂 UI、多个客户端需要不同数据的应用——不适合简单 API 和少数客户端。

3.3. gRPC——当需要服务内部高性能通信

service PaymentService {
  rpc Charge (ChargeRequest) returns (ChargeResponse);
}

message ChargeRequest {
  string order_id = 1;
  int64 amount_cents = 2;
}

gRPC 使用 HTTP/2 + Protobuf(二进制,比 JSON 更小更快),支持双向流。非常适合微服务内部的服务间通信,但难以手动调试(无法用普通 curl 读取),不适合面向浏览器的公开 API。

3.4. webhook——当需要主动通知接收方

@PostMapping("/webhooks/payment-status")
ResponseEntity<Void> handlePaymentWebhook(@RequestBody PaymentStatusEvent event,
                                           @RequestHeader("X-Signature") String signature) {
    if (!signatureValidator.isValid(event, signature)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    paymentEventHandler.handle(event);
    return ResponseEntity.ok().build();
}

Webhook 反转调用方向:PaymentService 的服务器在事件发生时主动回调你的服务器。始终验证签名(HMAC)以防止伪造请求,始终快速返回 200 然后异步处理——发送方通常在超时或收到错误时会重试。

3.5. WebSocket——实时双向通信

用于聊天、实时追踪、持续更新的仪表板。不适合简单的请求-响应,因为维持连接的开销比 REST 大得多。

4. 可持续设计:命名、分页、版本管理、错误响应

4.1. 命名一致性

使用集合名词复数(/orders 而非 /order 或 /getOrders),用嵌套名词表示关系(/customers/{id}/orders)。避免在同一 API 中混用 camelCase 和 snake_case。

4.2. 分页

@GetMapping
Page<Order> listOrders(@RequestParam(defaultValue = "0") int page,
                        @RequestParam(defaultValue = "20") int size) {
    return orderService.findAll(PageRequest.of(page, size));
}
GET /api/orders?page=1&size=20

基于偏移量的分页(page/size)易于理解但对大表性能较差,且在分页过程中有插入/删除时容易返回重复或遗漏的记录。基于游标的分页(?after=order_1234)对大数据集更稳定,代价是实现更复杂且难以“跳转到第 10 页”。

4.3. 版本管理

@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { /* ... */ }

通过 URI 路径的版本管理易于阅读和通过网关路由。通过请求头的版本管理(Accept: application/vnd.myapp.v2+json)更清洁但难以用浏览器调试。选择哪种不如拥有明确的弃用策略重要:提前通知、保持旧版本足够长时间、返回 Deprecation 和 Sunset 请求头。

4.4. 错误响应标准化

@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setProperty("orderId", ex.getOrderId());
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 99 not found",
  "orderId": 99
}

ProblemDetail(Spring Boot 3.x 原生支持,遵循 RFC 7807)提供统一的错误格式——客户端只需解析一个 schema,不必猜测哪个字段包含错误消息。

5. 安全性:API key、OAuth2、JWT、作用域

5.1. API key——简单,适合可信服务间的低风险场景

@Component
class ApiKeyFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String apiKey = req.getHeader("X-Api-Key");
        if (apiKey == null || !apiKeyRepository.isValid(apiKey)) {
            res.setStatus(HttpStatus.UNAUTHORIZED.value());
            return;
        }
        chain.doFilter(req, res);
    }
}

API key 无法区分谁代表谁在调用——它只能认证应用,不能认证用户。不要用它来保护最终用户的个人数据。

5.2. OAuth2 + JWT——需要按作用域分配权限

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.nonladev.com
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
    return ResponseEntity.ok(orderService.create(request));
}

JWT 在令牌中携带声明(用户 id、作用域、过期时间),可用公钥自行验证,无需每次请求都询问认证服务器——这就是它扩展性好过传统 session 的原因。但 JWT 难以在过期前撤销:如果用户账户被锁定,旧 token 仍然有效直到 exp。常见解决方案:设置短的 exp(5–15 分钟)配合刷新令牌,或在网关层检查黑名单。

注意: 作用域只表示“这个 token 被允许做什么”,不能替代业务规则检查(例如:用户 A 有 orders:write 但无法修改用户 B 的订单)。始终在服务层检查资源所有权。

6. 可靠性:超时、重试、幂等性、限流、缓存

6.1. 超时和重试

@Bean
RestClient paymentClient() {
    return RestClient.builder()
            .baseUrl("https://payment.internal")
            .requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
                    .withConnectTimeout(Duration.ofSeconds(2))
                    .withReadTimeout(Duration.ofSeconds(3))
                    .buildRequestFactory())
            .build();
}
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.2.0</version>
</dependency>
@Retry(name = "paymentService", fallbackMethod = "chargeFallback")
Charge charge(ChargeRequest request) {
    return paymentClient.post().uri("/charge").body(request).retrieve().body(Charge.class);
}

不设置超时 = 线程在下游故障时无限期挂起,导致整个服务按多米诺骨牌效应崩溃。不当的重试又会造成请求风暴,让下游崩溃得更严重——始终结合重试与指数退避和断路器。

6.2. 幂等性密钥用于金融操作

@PostMapping("/charges")
ResponseEntity<Charge> charge(@RequestHeader("Idempotency-Key") String key,
                               @RequestBody ChargeRequest request) {
    return idempotencyStore.findByKey(key)
            .map(existing -> ResponseEntity.ok(existing))
            .orElseGet(() -> {
                Charge charge = paymentService.charge(request);
                idempotencyStore.save(key, charge);
                return ResponseEntity.ok(charge);
            });
}

客户端因超时或网络故障重新发送相同请求,附带相同的 Idempotency-Key 将收到之前的结果,不会重复扣款。这是任何涉及金钱的 API 最重要的概念。

@Test
void shouldReturnSameChargeForDuplicateIdempotencyKey() {
    ChargeRequest request = new ChargeRequest("order-1", 10_000L);
    Charge first = client.charge("idem-key-1", request);
    Charge second = client.charge("idem-key-1", request);

    assertThat(second).isEqualTo(first);
    assertThat(paymentGateway.chargeCallCount()).isEqualTo(1);
}

6.3. 限流和缓存

@Bean
Bucket4jConfiguration rateLimitConfig() {
    return Bucket4jConfiguration.builder()
            .capacity(100)
            .refillGreedy(100, Duration.ofMinutes(1))
            .build();
}
@Cacheable(value = "orders", key = "#id")
Order findById(Long id) {
    return orderRepository.findById(id).orElseThrow();
}

限流保护系统免受某个错误客户端(无意或恶意)的过载。缓存减轻下游压力,但必须谨慎考虑 TTL 和缓存失效——订单处理中的过期缓存可能导致状态误解,比不缓存的后果更严重。

7. 文档化、可观测性和 Contract 测试

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>
@Operation(summary = "创建新订单", responses = {
        @ApiResponse(responseCode = "201", description = "订单创建成功")
})
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) { /* ... */ }

从注解自动生成 OpenAPI spec 确保 spec 与真实代码一致——手写文档然后代码改动但忘记更新文档是常见问题。

Contract 测试(Spring Cloud Contract、Pact)确保消费方和提供方独立部署时不会破坏契约:

@Test
void providerShouldHonorOrderContract() {
    Order order = orderService.findById(1L).orElseThrow();

    assertThat(order.id()).isEqualTo(1L);
    assertThat(order.status()).isIn("PENDING", "PAID", "CANCELLED");
}

结合可观测性(结构化日志、通过 traceparent 请求头的跨服务 trace ID、按端点的延迟/错误率指标),运维团队有足够信息在生产环境调试新 API,而不是靠拼凑零散日志盲目猜测。

8. 总结

API 设计不在于选对框架——它在于一系列小决定:用哪个方法、哪个状态码、什么分页方式、谁能用什么作用域调用、重试多少次才放弃。每个单独的决定看起来简单,但累积起来它们决定了你的 API 是易用的还是成为每个集成团队的噩梦。掌握上述概念——HTTP 语义、为正确用例选择架构、分层安全、可靠性模式、与代码同步的文档——是设计一个人们真正信任和长期使用的 API 的基础。

system-designapispring-bootrestsecurity