用SpringBoot构建RESTfulAPI的最佳实践

用SpringBoot构建RESTfulAPI的最佳实践

别在把 RESTful API 写成 RPC 了,这六条铁律能救你的项目

你有没有见过这样的接口:/api/getUserInfo?id=123或者/api/deleteOrder?orderId=456?如果见过,那你大概率遇到了一个披着 REST 外衣的 RPC 风格系统。很多团队在用 SpringBoot 构建 API 时,仅仅把 HTTP 当成了传输通道,完全忽略了 REST 架构风格带来的约束与红利。

RESTful API 的核心不是 URL 长得漂亮,而是利用 HTTP 协议本身的语义来定义资源操作。GET 就是查询,POST 就是创建,PUT 就是全量替换,PATCH 就是部分更新,DELETE 就是移除。如果你用 GET 去删除数据,用 POST 去查询列表,你其实在用 REST 的壳写 RPC 的魂。

正确做法是:资源用名词复数,操作交给 HTTP 方法。比如GET /orders获取订单列表,POST /orders创建订单,DELETE /orders/{id}删除指定订单。这里有一个容易被忽视的点——不要在 URL 里出现动词。像/getOrders/deleteOrder这样的命名,本质上是在告诉调用方“我在执行一个动作”,而不是“我在操作一个资源”。

还有一个更隐蔽的错误:用GET /orders?action=export来做导出功能。请记住,每个 URL 应该只表达一个资源或资源集合,行为语义应该完全由 HTTP 方法承载。一旦你在查询参数里塞入action,你就等于向整个团队宣布:这个 API 不做 REST,只做远程调用。

统一响应结构——让你的客户端不再猜谜

一个标准的 RESTful API 响应应该包含三层信息:状态码、业务码、实际数据。很多新手只在成功时返回 JSON 数据,失败时直接返回一段错误文本,或者只给一个 HTTP 状态码。这会导致客户端需要写大量条件判断来解析响应。

我推荐一个经过检验的通用结构:

{ "code": 20000, "message": "success", "data": { "id": 1, "name": "张三" } }

状态码与业务码要分离。HTTP 状态码(200、400、500)用来表达传输层的结果,业务码(20000、40001、50001)用来表达业务逻辑的结果。这样设计的最大好处是:网关层可以根据 HTTP 状态码做熔断和限流,而业务层可以根据业务码做更细粒度的错误处理。

SpringBoot 中很容易实现统一响应。定义一个ApiResponse<T>泛型类,加上静态工厂方法success()error()fail()。然后在 Controller 层强制使用这个响应类作为返回值。没有一个客户端喜欢收到格式混乱的响应体,统一响应结构是你对调用方最基本的尊重。

还有一个进阶技巧:在全局异常处理器@RestControllerAdvice中,把所有异常都捕获并转换成统一响应格式。这样无论代码中抛出什么异常,客户端收到的永远是结构一致的 JSON。

异常处理——别再让 500 页面裸奔

500 页面直接返回给前端,这可能是 SpringBoot 开发中最常见的技术债。一个生产级的 API 系统,绝不能在异常时暴露堆栈信息给调用方。堆栈里可能包含数据库连接串、内网 IP、第三方密钥,这些信息落到恶意用户手里就是灾难。

正确的做法分三层防御:

第一层,在 Controller 层使用@Validated和参数校验注解,把非法输入拦截在业务逻辑之前。比如@NotBlank@Size@Pattern这些注解配合全局异常处理,能让 95% 的错误请求在进入业务代码前就被优雅拒绝。

第二层,定义自定义业务异常,比如BizException。所有业务逻辑中的校验失败都抛出这个异常,而不是直接返回null或者false用异常来控制业务流,远比用返回值判断要优雅。这样你在全局处理器中只要捕获BizException,就能自动组装成带业务码的错误响应。

第三层,全局兜底。@ExceptionHandler(Exception.class)捕获所有未预料的异常,记录完整的错误日志(包括入参、堆栈),但只返回给客户端一个模糊的“服务器内部错误”信息。开发环境可以通过 profile 控制是否暴露详细错误,生产环境必须关闭。

需要特别强调的是:404 状态码应该留给真正的资源缺失,而不是用来表示“接口不存在”。如果你把接口路径写错了,返回 404 是合理的。但如果用户请求了一个不存在的订单 ID,你应该返回 404 状态码 + 业务码 40004,而不是返回 200 状态码但 data 为 null。

参数校验——在入口处就筛掉脏数据

很多团队在 Service 层写一堆 if-else 来做参数校验,这不仅代码冗余,还容易遗漏边界情况。SpringBoot 提供了强大的javax.validation校验框架,完全可以在 Controller 层就把非法数据拦截下来。

核心思路是:让校验发生在最外层。Controller 方法的参数上使用@Valid@Validated注解,DTO 类中使用@NotNull@Size@Pattern等注解定义校验规则。一旦校验失败,全局异常处理器捕获MethodArgumentNotValidException,自动返回友好的错误信息。

这里有一个踩坑经验:不要在校验注解中硬编码错误提示信息,比如@NotBlank(message = "用户名不能为空")。更好的做法是定义一个常量类或者使用国际化资源文件来管理错误消息。错误消息应该与业务代码分离,这样产品经理想改提示文案时,不需要开发者重新部署。

对于复杂的业务校验(比如订单金额不能超过账户余额),单纯靠注解无法实现。这时候建议使用 Spring 的Validator接口或者自定义校验注解。把业务校验逻辑封装在独立的验证器中,Service 层只处理正常的业务流程。一个 Service 方法里如果同时包含参数校验、权限判断和业务逻辑,那它一定违反了单一职责原则

还有一个容易被忽略的点:集合类型参数的校验。List<@Valid OrderDTO>这样的写法可以对列表中的每个元素进行校验。很多接口接收批量操作参数时,只校验了列表非空,却忽略了列表里每个元素的合法性。

分页与排序——别让数据库承受生命之重

无分页的列表接口是性能杀手。当数据量达到十万级时,一次全量查询就能把数据库连接池打满,导致整个系统雪崩。所有列表接口必须强制分页,这是 API 设计的安全红线。

SpringBoot 推荐使用Pageable对象来接收分页参数。在 Controller 方法中声明Pageable pageable参数,Spring 会自动从请求参数中提取pagesizesort等字段。默认值应该设置合理:page 从 0 开始,size 默认 20,最大不超过 100。如果你让客户端传入size=10000,那跟没分页有什么区别?

排序参数同样需要严格控制。sort=createDate,desc这样的格式虽然方便,但如果不对排序字段做白名单校验,攻击者可以通过排序注入拖垮数据库。非索引字段的排序会导致全表扫描。正确的做法是:定义一个可排序字段的枚举,只允许客户端使用枚举中定义的字段进行排序。

分页响应的结构同样需要统一。Page<T>对象返回给前端时,应该转换成包含totalElementstotalPagescontenthasNext等字段的自定义 DTO。直接暴露 Spring Data 的Page对象给前端,会泄露内部实现细节。接口返回的是契约,不是内部数据结构

还需要注意一个场景:当 size 很大(比如 200)且数据量超过百万时,传统 limit-offset 分页会有深度分页问题。这种情况下,建议使用游标分页(WHERE id > :lastId LIMIT :size),或者使用 ES 等搜索引擎来支撑海量数据的列表查询。

版本管理——给你的 API 留条后路

接口一旦上线,你就失去了随意修改它的权利。移动端用户可能因为各种原因不更新 App,第三方开发者可能写死了接口参数。没有一个强制升级的策略能覆盖所有场景,所以版本管理不是可选项,而是必需品。

有三种主流的版本管理策略:

第一种,URL 路径版本。/api/v1/orders/api/v2/orders共存。这是最直观、最容易被客户端理解的方式。缺点是版本控制粒度较粗,一旦升级整个 v1 都废弃。

第二种,请求头版本。客户端在Accept头中指定版本,比如Accept: application/vnd.company.v1+json。这种方式更符合 REST 规范,但需要客户端和服务端有较深的协作基础。对不成熟的团队来说,不可见的东西往往难以管理

第三种,参数版本。/api/orders?version=1。这种方式实现简单,但在争议中最多——因为版本信息不属于资源数据,不应该在查询参数中体现。

我个人推荐的做法是:在 App 端使用 URL 路径版本,在 Web 端使用请求头版本。App 端通常有多个版本同时在线,URL 路径版本让降级回退更容易;Web 端可以强制用户刷新页面,请求头版本让代码更干净。

无论选择哪种策略,核心原则是:向后兼容是 API 的第一道德。如果必须要破坏兼容性,就启动新版本,旧版本至少要保留三个月的过渡期。同时,在响应头中添加Deprecation: trueSunset: ...来告知客户端该接口已废弃。

文档即代码——让文档与实现同频

手工维护 API 文档是一场必输的战役。当代码迭代速度远快于文档更新速度时,文档就会变成一张废纸。最佳的文档策略是:从代码中自动生成

SpringDoc 是目前最推荐的工具,它基于 OpenAPI 3.0 标准,能够从 Spring 注解中自动提取接口信息。你只需要在 Controller 方法上加上@Operation@ApiResponse注解,就能生成结构化的 API 文档。

关键点在于:写文档注解不应该成为负担。很多开发者觉得写注解浪费时间,那是因为他们没有尝到文档自动化的甜头。一旦你在一个团队中推行了标准化注解,新成员接入接口只需要看 Swagger 页面,前端可以一键生成请求代码,测试人员可以直接在页面中调试接口。

一个值得推荐的做法是:把文档注解的编写纳入 DoD 中。每个接口在提测之前,必须保证@Operation中描述了接口用途,@Schema中说明了每个字段的含义。没有文档的接口,不应该被认为已经开发完成

还有一个进阶技巧:利用 SpringDoc 的组(Group)功能,将不同模块的接口分组展示。比如前台接口、后台接口、开放接口各自生成独立的文档页面。每个组可以配置不同的认证方式,后台接口组可以设定必须携带 Token 才能访问文档,前端接口组则可以直接公开。

安全防护——每一层都要有守卫

安全不是 API 的一个附加模块,而应该贯穿整个请求生命周期。从网关到 Controller 再到 Service,每一层都应该有自己的安全防御策略。

首先是认证与授权。使用 JWT 而不是 Session 来维护用户状态。在无状态架构中,Session 让服务器有状态,破坏了水平扩展的能力。JWT 的核心优势是:服务器不需要存储会话信息,每次请求都携带 Token,服务端只需要验证签名即可。

推荐在 SpringSecurity 中配置 JWT 过滤器链。所有敏感接口要求Bearer Token,但登录接口、注册接口、健康检查接口应该放行。这里有一个常见错误:把 Token 放在 URL 参数中传递。Token 永远应该放在 Authorization 请求头中,放在 URL 中会被日志记录,会被浏览器历史缓存,属于严重的安全漏洞。

其次是防刷与限流。SpringBoot 中可以用过滤器配合 Redis 实现基于 IP 或用户 ID 的限流。一个核心接口(比如短信验证码)每用户每分钟只能请求一次,一个开放接口(比如商品列表)每 IP 每秒只能请求 10 次。限流不是保护服务器,而是保护所有用户的公平使用权

最后是输入安全。除了参数校验,还需要防范 SQL 注入和 XSS 攻击。使用 JPA 或 MyBatis 时,不要拼接 SQL 语句,永远使用参数化查询。对于接收的字符串参数,转义<script>标签。虽然业务系统中 XSS 的风险低于 Web 页面,但 API 返回的 JSON 如果包含恶意脚本,依然可能被某些前端的 v-html 渲染导致攻击生效。

单测守卫——对每一次重构说“放心改”

没有单元测试的 API 项目,重构起来就像拆弹。你可能只是改了一个方法名,结果三个接口同时报错。单元测试不是可选项,而是保证 API 质量的最后一道防线

针对 RESTful API 的测试,SpringBoot 提供了MockMvcWebTestClient两大利器。MockMvc 用于同步测试,WebTestClient 用于 WebFlux 测试。测试应该覆盖三个层次:参数校验、业务逻辑、异常路径。

一个值得投资的习惯是:先写测试再写代码。当你开始设计接口时,先写一个调用该接口的 MockMvc 测试,定义好输入输出和期望的状态码。这个测试会失败,然后你再去实现 Controller、Service、Repository,直到测试通过。这个流程让你始终站在调用方的角度思考接口设计,而不是站在实现方的角度。

测试覆盖率不应该追求 100%,而是追求核心业务的 100%。登录、支付、下单、退款 —— 这些核心链条上的每一个分支,包括正常流程和异常流程,都应该被测试覆盖。而一些简单的 CRUD 接口,可以只测试主要场景。

还有一个容易忽视的点:测试不仅仅是验证正确性,也是在记录契约。当你修改一个接口行为时,运行测试就会发现哪些地方被影响了。如果你没有测试,你就必须手动排查所有调用方,这在微服务架构中几乎不可能做到。