ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot 写得快不等于写得好:10 个让资深开发者也翻车的坑

Spring Boot 写得快不等于写得好:10 个让资深开发者也翻车的坑 Spring Boot 有多快几分钟搭个 REST API几分钟连上数据库安全、缓存、校验、 测试——别的框架还在配环境你已经 demo 给老板看了。但快是有代价的。应用能跑接口能通测试能过演示很干净。然后生产流量一来——API 风格乱成一锅粥事务行为莫名其妙测试跑一次喝杯咖啡日志看了等于没看SQL 炸了配置没人敢动安全规则散落在各个角落Controller 胖得像个穿了 HTTP 外套的 Service。这篇文章讲的就是这些坑。不是那种“忘了加RestController”的新手错误。是真正的、有经验的开发者在赶进度、抄老代码、迷信 Spring 魔法时照样会踩的坑。我用一个咖啡店点单系统贯穿所有例子。来上咖啡。1. Controller 里写业务逻辑最常见也最致命一开始Controller 看起来人畜无害RestControllerRequestMapping(/coffees)publicclassCoffeeController{privatefinalCoffeeRepositorycoffeeRepo;privatefinalInventoryClientinventoryClient;PostMapping(/order)publicOrderResponseorder(RequestBodyOrderRequestreq){CoffeecoffeecoffeeRepo.findByName(req.coffeeName());if(coffeenull){thrownewCoffeeNotFoundException();}booleanavailableinventoryClient.checkStock(coffee.getId(),req.quantity());if(!available){thrownewOutOfStockException();}OrderordernewOrder();order.setCoffee(coffee);order.setQuantity(req.quantity());order.setStatus(OrderStatus.PREPARING);OrdersavedcoffeeRepo.saveOrder(order);returnnewOrderResponse(saved.getId(),saved.getStatus());}}能跑。这就是问题所在。这个 Controller 现在干了HTTP 处理、库存校验、实体创建、状态判断、持久化、响应映射。六个月后它会涨到 500 行所有人都不敢碰它——怕改坏了。Controller 只该做一件事把 HTTP 请求翻译成应用行为然后把结果翻译回 HTTP。瘦身后RestControllerRequestMapping(/coffees)publicclassCoffeeController{privatefinalOrderCoffeeUseCaseorderCoffee;PostMapping(/order)ResponseStatus(CREATED)publicOrderResponseorder(ValidRequestBodyOrderRequestreq){returnorderCoffee.execute(req);}}业务逻辑去该去的地方ServicepublicclassOrderCoffeeUseCase{privatefinalCoffeeRepositorycoffeeRepo;privatefinalInventoryClientinventoryClient;TransactionalpublicOrderResponseexecute(OrderRequestreq){CoffeecoffeecoffeeRepo.findByNameOrThrow(req.coffeeName());inventoryClient.reserveOrThrow(coffee.getId(),req.quantity());OrderorderOrder.prepare(coffee,req.quantity());returnOrderResponse.from(coffeeRepo.saveOrder(order));}}差别看着小生产环境里是天壤之别。瘦 Controller 好测、好改、好理解。如果你的 Controller 里有业务规则、有数据库操作、有外部调用、有 DTO 转换——它已经不是 Controller 了是个穿了 HTTP 衣服的胖 Service。2. 直接把 Entity 当 API 返回图省事埋大雷你已经有一个 Entity 了接口要返回数据直接 return 它多省事GetMapping(/{id})publicCustomergetCustomer(PathVariableLongid){returncustomerRepo.findById(id).orElseThrow();}看着干净其实是在裸奔。你的 JPA Entity 是持久化模型API 响应是对外契约。这俩不是一回事。EntitypublicclassCustomer{IdprivateLongid;privateStringname;privateStringphone;privateStringpasswordHash;// ← 这个能返回给前端吗privatebooleanvip;// ← 这个内部标记能暴露吗privateLocalDateTimecreatedAt;}直接返回 EntitypasswordHash和内部标记可能就泄露了。就算用 Jackson 注解藏字段你的持久化模型也被 API concerns 污染了。更坑的是懒加载——序列化时触发额外查询或者直接报LazyInitializationException。用 DTO把边界立起来publicrecordCustomerResponse(Longid,Stringname,booleanvip,LocalDateTimecreatedAt){publicstaticCustomerResponsefrom(Customerc){returnnewCustomerResponse(c.getId(),c.getName(),c.isVip(),c.getCreatedAt());}}请求体也一样别拿 Entity 当入参// 坏PostMappingpublicCustomercreate(RequestBodyCustomerc){returncustomerRepo.save(c);}// 好publicrecordCreateCustomerRequest(NotBlankStringname,Pattern(regexp^1\\d{10}$)Stringphone){}PostMappingResponseStatus(CREATED)publicCustomerResponsecreate(ValidRequestBodyCreateCustomerRequestreq){returncustomerService.create(req);}DTO 不是多余的样板代码是边界。边界就是防止生产系统变成意外的混沌。3. 把 Transactional 当护身符乱贴很多人用Transactional像挂平安符——碰数据库了贴一个。报错了再贴一个。求心安全贴上。这不是事务设计这是注解迷信。事务应该包裹一个有意义的业务操作。TransactionalpublicvoidbrewCoffee(BrewCommandcmd){OrderorderorderRepo.save(Order.create(cmd));paymentClient.charge(cmd.paymentToken());// ← 外部网络调用baristaService.assign(order);}看到问题了吗paymentClient.charge()调外部支付接口的时候数据库事务是开着的——你拿着数据库连接和锁在等网络响应。高并发下这就是定时炸弹。还有一个经典坑同类内自调用不生效。ServicepublicclassMemberService{publicvoidregister(RegisterRequestreq){saveMember(req);// ← 同类调用Transactional 不生效}TransactionalpublicvoidsaveMember(RegisterRequestreq){memberRepo.save(...);}}Spring 的事务是基于代理的同类内直接调用绕过了代理事务注解等于白写。另外记住默认只对 RuntimeException 回滚。受检异常比如 IOException默认不回滚要手动指定Transactional(rollbackForIOException.class)publicvoidimportMembers()throwsIOException{...}高级开发者问的是事务从哪开始到哪结束什么异常回滚这个方法是走代理调用的吗事务里有没有外部网络调用锁是不是持有太久了这才叫用事务不是赌运气。4. API 入口不校验垃圾进垃圾出PostMapping(/brew)publicBrewResponsebrew(RequestBodyBrewRequestreq){returnbrewService.brew(req);}amount是负数怎么办coffeeType是空字符串怎么办customerId是 null 怎么办你应该在请求到达业务逻辑之前就把垃圾挡在门外publicrecordBrewRequest(NotNullLongcustomerId,NotBlankStringcoffeeType,PositiveMax(10)Integerquantity,Size(max200)Stringnote){}PostMapping(/brew)publicBrewResponsebrew(ValidRequestBodyBrewRequestreq){returnbrewService.brew(req);}常用校验注解就那几个NotNull、NotBlank、NotEmpty、Size、Min、Max、Positive、Email、Pattern。结构性校验放 API 层业务规则放 Service 层if(!supportedCoffeeTypes.contains(req.coffeeType())){thrownewUnsupportedCoffeeException(req.coffeeType());}如果脏数据能深入到业务逻辑里说明你的 API 边界已经漏了。5. 错误返回格式五花八门前端的噩梦一个接口返回{message:咖啡卖完了}另一个返回{error:INVALID_ORDER,detail:数量必须大于0}第三个因为异常没处理直接返回了一个 HTML 错误页。前端开发者哭了。移动端开发者哭了。三个月后的你也哭了。用全局异常处理器 统一错误格式RestControllerAdvicepublicclassApiExceptionHandler{ExceptionHandler(CoffeeNotFoundException.class)ResponseStatus(NOT_FOUND)publicErrorResponsehandleNotFound(CoffeeNotFoundExceptione){returnnewErrorResponse(COFFEE_NOT_FOUND,e.getMessage());}ExceptionHandler(MethodArgumentNotValidException.class)ResponseStatus(BAD_REQUEST)publicErrorResponsehandleValidation(MethodArgumentNotValidExceptione){returnnewErrorResponse(VALIDATION_FAILED,请求参数校验失败);}}publicrecordErrorResponse(Stringcode,Stringmessage){}好的 API 不只是成功时返回得漂亮失败时也要可预测、可解析、可排查。6. 到处撒 Value配置变成捉迷藏Value(${brew.timeout})privateDurationtimeout;Value(${brew.retry})privateintretry;Value(${brew.machine-url})privateStringmachineUrl;一开始看着没事。然后类越来越多同一个配置在不同地方各读一份默认值散落在各处测试的时候你得一个个 mock。配置去哪了没人知道。相关的配置给它一个家ConfigurationProperties(prefixbrew)publicrecordBrewProperties(Durationtimeout,intretry,URImachineUrl){}启动类开启扫描SpringBootApplicationConfigurationPropertiesScanpublicclassCoffeeApplication{}注入使用ServicepublicclassBrewMachineClient{privatefinalBrewPropertiesprops;publicBrewMachineClient(BrewPropertiesprops){this.propsprops;}}现在配置是类型安全的、分组的、可测试的、一目了然的。三个以上相关属性就该建个 Properties 类了。7. 所有测试都 SpringBootTest慢到不想跑SpringBootTestclassMemberServiceTest{...}能跑。但它加载了整个应用上下文——数据库配置、安全配置、Web 配置、自动装配、你根本用不到的 Bean全加载了。一个纯 Service 的单元测试根本不需要 SpringclassMemberServiceTest{privatefinalMemberRepositoryrepomock(MemberRepository.class);privatefinalMemberServiceservicenewMemberService(repo);TestvoidshouldCreateMember(){...}}该用切片测试用切片WebMvcTest(CoffeeController.class)// 只测 ControllerclassCoffeeControllerTest{}DataJpaTest// 只测 RepositoryclassCoffeeRepositoryTest{}JsonTest// 只测 JSON 序列化classCoffeeJsonTest{}SpringBootTest留给真正需要全上下文的场景集成测试、完整链路验证、启动冒烟测试。测试快开发者就愿意常跑测试慢开发者就开始逃避——bug 就是这么活下来的。8. 字段注入把依赖藏起来ServicepublicclassInvoiceService{AutowiredprivateInvoiceRepositoryinvoiceRepo;AutowiredprivatePaymentClientpaymentClient;AutowiredprivateEmailServiceemailService;AutowiredprivateAuditServiceauditService;// 还在加……}能跑。但它隐藏了类的真实依赖让测试变麻烦允许对象处于无效状态还鼓励类无限收集依赖——反正构造函数不会变长来提醒你。用构造器注入让依赖暴露在阳光下ServicepublicclassInvoiceService{privatefinalInvoiceRepositoryinvoiceRepo;privatefinalPaymentClientpaymentClient;publicInvoiceService(InvoiceRepositoryinvoiceRepo,PaymentClientpaymentClient){this.invoiceRepoinvoiceRepo;this.paymentClientpaymentClient;}}构造函数参数太多那不是字段注入的理由那是设计臭味——这个类可能干了太多事该拆了。构造器注入让设计问题看得见。字段注入把它们藏到生产环境才爆发。9. 不懂懒加载和 N1SQL 在你看不见的地方爆炸JPA 让数据库访问像操作对象一样丝滑。丝滑的另一面是危险。ListOrderordersorderRepo.findAll();for(Orderorder:orders){System.out.println(order.getCustomer().getName());// ← 每条都查一次}查订单 1 条 SQL然后每个 Customer 各查 1 条——10 条订单 11 条 SQL10000 条订单 10001 条 SQL。这就是经典 N1。更坑的是 Controller 直接返回 EntityJSON 序列化时触发懒加载要么多查一堆 SQL要么直接报错。解决方案fetch join 或 DTO 投影。// fetch join一次查出来Query(select o from Order o join fetch o.customer where o.status :status)ListOrderfindByStatusWithCustomer(Param(status)OrderStatusstatus);// DTO 投影只查需要的字段publicrecordOrderSummary(Longid,StringcustomerName,BigDecimalamount){}Query(select new com.example.OrderSummary(o.id, c.name, o.amount) from Order o join o.customer c where o.status :status)ListOrderSummaryfindSummaries(Param(status)OrderStatusstatus);别把 JPA 当魔法。打开 SQL 日志看看你的代码到底产生了什么查询。高级开发者不靠猜 ORM 在干嘛他们去验证。10. 没有可观测性就上线出事了两眼一抹黑本地能跑测试通过部署成功。然后生产挂了没人知道为什么。一个生产级的 Spring Boot 应用应该能回答这些问题应用活着吗能接流量了吗多少请求在失败哪个接口慢哪个依赖超时了数据库连接池满了吗定时任务在跑吗加 Actuator暴露健康检查和指标。用结构化日志带上 traceId// 坏日志log.info(制作咖啡失败);// 好日志log.warn(制作咖啡失败 orderId{}, customerId{}, reason{},orderId,customerId,reason);别记敏感数据——密码、token、完整卡号、客户隐私信息一个都别进日志。Actuator 的/env、/beans端点会暴露敏感信息该保护保护该关关。可观测性不是生产挂了之后才补的是生产就绪的一部分。没有它你就是在蒙眼开车。十个坑背后的同一个病根这 10 个坑看起来各不相同Controller 太胖Entity 泄露到 API事务乱用校验缺失错误格式乱配置散落测试太慢依赖隐藏JPA 查询失控可观测性缺失但根子上是同一个问题代码能跑就以为设计没问题。能跑的代码不等于健康的代码。一个 Spring Boot 应用可以完美运行同时悄悄变得越来越难改、难测、难调、难运维。初级和高级的区别不是你认识多少注解而是你知不知道边界在哪HTTP 边界校验边界事务边界持久化边界配置边界测试边界运维边界Spring Boot 给了你强大的工具但它不会逼你好好用。那部分还是你的活。上线前的自检清单检查项是/否Controller 是不是瘦的业务逻辑是不是在 Service/UseCase 里Entity 是不是藏在 DTO 后面请求有没有加 Valid错误返回格式是不是统一的事务是不是有意为之的长事务里有没有外部调用配置是不是分组且类型安全的测试是不是用了最小必要的上下文依赖是不是走构造器注入JPA 查询是不是理解并优化过的日志、指标、健康检查是不是就绪了全打勾那你的应用不只是能跑——它是可维护的、可调试的、能扛真实流量的。去冲杯咖啡庆祝一下。☕
返回列表