每位软件工程师应掌握的 API 概念详解
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 的基础。