使用SpringBoot开发RESTfulAPI的注意事项 别再这样写Spring Boot API了一个老程序员的血泪教训三年前我接手了一个号称“已完成80%”的订单系统微服务。第一天部署到测试环境全团队就收到了37个Bug警告。调试到凌晨三点我发现罪魁祸首竟然是某个“看起来没问题”的RESTful API——它接收参数时用了一个RequestParam MapString,String结果前端传啥它收啥连SQL注入都懒得过滤。一个连基础参数校验都不做的API就是给自己挖的活坟墓。从那天起我给自己定了个规矩Spring Boot开发的API必须按军工级标准来要求。下面这些血的教训希望能让你少走我走过的弯路。参数接收别让map成为你代码的“野狗”很多新手喜欢用RequestParam MapString, String或者RequestBody String json来接收参数因为“方便扩展”。但你要知道这种“灵活”本质上是对API契约的背叛。当你的控制层对参数来者不拒时就意味着你的业务逻辑层必须承担额外的不确定性。你可以用实体类DTO加上Valid注解来约束每个字段的类型、长度和格式比如PostMapping(/orders) public ResultOrderVO createOrder(Valid RequestBody OrderCreateDTO dto) { // 业务逻辑 }这样OrderCreateDTO中每个字段的校验规则都清晰可见。一个DTO就是一个活文档团队成员看到它就知道该传什么参数前端的TypeScript接口也能通过OpenAPI自动生成。哪个团队不需要这种确定性URL设计拥抱复数名词远离动词地狱你是否见过这样的API/getUser、/createOrder、/deleteItemById这完全是用RPC的思维写RESTful。RESTful API的核心是资源资源用复数名词表示/users、/orders、/items。CRUD操作通过HTTP动词映射GET /orders——查询订单列表POST /orders——创建订单GET /orders/{id}——获取单个订单PUT /orders/{id}——全量更新订单PATCH /orders/{id}——部分更新订单DELETE /orders/{id}——删除订单复杂操作怎么办比如“订单完成发货”。可以设计为PATCH /orders/{id}/statusbody里传{status: SHIPPED}。一个合理的状态机设计远比一堆动词API优雅。你把状态迁移逻辑封装在服务层前端只需要知道当前允许哪些状态转换即可。异常处理别再让500错误裸奔了Spring Boot默认的Whitelabel Error Page会把堆栈信息暴露给调用方这在生产环境中是灾难性的。一个没有全局异常处理的API就像没关门的银行任何人都能看到你的内部实现细节。使用RestControllerAdvice进行全局异常处理RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(MethodArgumentNotValidException.class) public ResultVoid handleValidation(MethodArgumentNotValidException ex) { String msg ex.getBindingResult().getFieldErrors().stream() .map(e - e.getField() : e.getDefaultMessage()) .collect(Collectors.joining(; )); return Result.fail(400, msg); } ExceptionHandler(BizException.class) public ResultVoid handleBiz(BizException ex) { return Result.fail(ex.getCode(), ex.getMessage()); } ExceptionHandler(Exception.class) public ResultVoid handleUnknown(Exception ex) { log.error(未知异常, ex); return Result.fail(500, 服务器内部错误); } }异常信息是给开发者看的不是给用户看的。用户只需要知道“操作失败原因XXX”而堆栈应该完整记录在日志中。另外建议定义业务异常类BizException携带code和message这样前端可以根据code做不同处理比如重新登录、刷新数据等。返回体结构统一是减少心智负担的最佳方式前端对接过多个API后最怕的就是每个接口返回格式都不一致。有的只返回data有的返回{code, message}还有的返回{status, error}。一个团队必须统一API返回体结构否则前端每次都要写不同的解析逻辑。推荐使用泛型Result类Data public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { return new Result(200, success, data); } public static T ResultT fail(int code, String message) { return new Result(code, message, null); } }有几点要注意就算没有数据data字段也应该是null而不是缺失否则前端解析JSON时会报错成功时message可以保持默认但失败时message必须明确告知原因。你的返回体越统一前端和后端的联调速度就越快。版本管理永远不要发布没有版本的API我见过最惊悚的线上事故是一个团队把GET /users的返回从[{id, name}]改成了[{userId, userName}]然后第三天有十几个B端客户报数据解析失败。没有版本管理的API就是在用生产环境做灰度测试。推荐在URL路径中嵌入版本号/api/v1/users旧版/api/v2/users新版Spring Boot里可以这样配置RestController RequestMapping(/api/v1/users) public class UserV1Controller { ... } RestController RequestMapping(/api/v2/users) public class UserV2Controller { ... }或者通过自定义ApiVersion注解和RequestMappingHandlerMapping来实现更灵活的路由。版本不应该是后知后觉的补丁而应该是每个API设计之初就考虑的维度。当旧版需要废弃时要提前通过响应头添加Deprecation: true和Sunset: Sat, 01 Jan 2025 00:00:00 GMT给客户端足够的时间迁移。日志追踪无痕调试等于自掘坟墓当线上某个用户报“订单创建失败”时你如何定位问题如果日志里只有[INFO] 创建订单失败那你只能大海捞针。每个请求都需要一个全局唯一的traceId贯穿整个调用链路。在Spring Boot项目中可以用Filter在每个请求到达时生成traceId放入MDCMapped Diagnostic ContextComponent public class TraceFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId UUID.randomUUID().toString().replace(-, ).substring(0, 16); MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }然后在logback配置里加上[%X{traceId}]日志就会变成[2025-01-15 10:23:45.123] [a1b2c3d4e5f67890] [INFO] 订单创建请求: userId10086, amount299.00traceId是分布式系统中的破案神器当你需要从几百台机器的日志中找到某个用户的完整请求链时它能救你一命。另外响应头里也建议返回X-Trace-Id这样用户反馈问题时可以直接提供这个ID。安全性别等到被攻击了才后悔很多开发者在写API时只关注功能实现对安全问题置之不理。但你想想一个没有限流的API一个没有参数校验的端点一个返回全部字段的查询接口哪一个不会成为黑客的突破口首先永远不要返回密码、token、内部ID等敏感字段。就像你订外卖不会把家门密码告诉骑手一样你的API也不应该把数据库主键暴露出去。可以用JsonIgnore或者自定义序列化器来屏蔽public class UserDTO { private Long id; // 内部ID不暴露 JsonProperty(user_id) private String userId; // 对外暴露的对外ID JsonIgnore private String passwordHash; private String nickname; }其次接口限流是API的必修课。可以用Guava的RateLimiter做本地限流或者引入Sentinel做分布式限流。对于登录、注册、支付等敏感接口建议使用令牌桶算法每秒最多允许100个请求多出来的直接返回429 Too Many Requests。最后CSRF和XSS防护不是后端项目的选修课。如果API被用作前后端分离只接受JSON请求体CSRF的风险相对较低但XSS过滤一定要做。所有用户输入的内容在返回时都应该进行HTML转义除非你明确知道自己在做富文本编辑器。文档生成API写完了文档不能忘写代码时你最讨厌什么我猜多半是“写文档”。但没有文档的API就是一个传声筒每次联调都需要开发者口头沟通。Spring Boot可以集成SpringDoc OpenAPI自动从代码生成文档dependency groupIdorg.springdoc/groupId artifactIdspringdoc-openapi-starter-webmvc-ui/artifactId version2.3.0/version /dependency然后你只需要在控制器和方法上添加注解即可Operation(summary 创建订单, description 用户提交订单信息创建新订单) PostMapping(/orders) public ResultOrderVO createOrder(Valid RequestBody OrderCreateDTO dto) { // ... }文档是API的说明书Swagger/OpenAPI文档还能直接让前端生成TypeScript类型定义让后端和前端在类型上保持一致。默认访问路径是/swagger-ui.html你可以配置生产环境不暴露该端点只在内网或测试环境使用。测试不写测试的API等于没写完你可能觉得测试是浪费时间但我要告诉你没有单元测试和集成测试的API重构时就是噩梦。当你改了某个方法签名发现三十个地方都要改时测试就是你的安全网。对于RESTful API推荐使用MockMvc进行集成测试SpringBootTest AutoConfigureMockMvc public class OrderControllerTest { Autowired private MockMvc mockMvc; Test public void shouldCreateOrderSuccessfully() throws Exception { String requestBody {userId: 10086, amount: 299.00, items: [{productId: P001, quantity: 2}]} ; mockMvc.perform(post(/api/v1/orders) .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()) .andExpect(jsonPath($.code).value(200)) .andExpect(jsonPath($.data.orderId).isNotEmpty()); } Test public void shouldFailWhenAmountIsNegative() throws Exception { String requestBody {userId: 10086, amount: -100.00, items: []} ; mockMvc.perform(post(/api/v1/orders) .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()) .andExpect(jsonPath($.code).value(400)) .andExpect(jsonPath($.message).value(金额必须为正数)); } }测试是API的质保单。当你有了一套完整的测试用例每次部署前跑一遍就能确保新增功能不会破坏已有逻辑。建议把测试覆盖到80%以上的代码行特别是校验逻辑和异常处理分支。写在最后写一个能跑的API不难写一个稳定、可维护、安全的API却需要时刻保持敬畏之心。每一个糟糕的API设计都会在未来变成你的技术债。从参数校验到异常处理从版本管理到日志追踪从安全性到文档测试这些都不是可选项而是必选项。下次当你敲下SpringBootApplication时不妨多问自己一句“这个API我敢在生产环境上跑三年不重构吗” 如果答案是否定的那就回到上面这些点一个一个去检查。只有这样你才能从一开始就写出经得起考验的代码。