做了几年Java后端,接触过不少从JavaEE课程或传统Servlet/JSP项目过渡到Spring Boot的开发者,也包括一些刚学完JavaEE、想直接上手Spring Boot的在校学生。大家普遍有个共同感受:Spring Boot的上手门槛看起来很低——一个main方法启动,依赖一加,接口就能跑,但真正投入到实际项目里,会发现各种问题接踵而至:版本依赖冲突、数据库连接被莫名断开、MyBatis查询结果和加密字段对不上、WebSocket推送不通、Ajax请求后端明明拿到了数据前端却显示为空。这篇内容就把这些真实场景里的问题串起来讲一遍,从开发环境搭建到数据层改造、从WebSocket集成到前后端联调,覆盖一条相对完整的Spring Boot进阶路径,适合已经掌握JavaSE基础、了解Servlet/JSP、正准备系统学习Spring Boot的人参考。
1. 从JavaEE到Spring Boot:环境与工程形态的第一课
1.1 先别急着写代码:搞清Boot替你做了什么
很多刚学完JavaEE的人第一次接触Spring Boot,会觉得它"不讲武德":用传统JavaEE开发,我们要手动配置web.xml、配置DispatcherServlet、配置数据源、配置事务管理器,然后打包成war扔进Tomcat的webapps目录里才能运行。到了Spring Boot这里,十几行代码就能起一个Web应用,而且连Tomcat都内置了。
这种"魔法"感恰恰是初学者的第一个坎。如果你不理解Spring Boot在背后做了什么,遇到问题时会完全无从下手。Spring Boot的核心逻辑,说透了就两条:自动配置和约定优于配置。自动配置是@EnableAutoConfiguration注解触发的,它根据classpath里已有的依赖,自动帮你装配相应的Bean。比如你引入了spring-boot-starter-web,它就自动帮你配置好DispatcherServlet、CharacterEncodingFilter、内嵌的Tomcat;引入了spring-boot-starter-jdbc或MyBatis相关依赖,它就自动帮你配置数据源和SqlSessionFactory。
但这里有个非常容易忽视的细节:Spring Boot的自动配置是有条件的,它通过@ConditionalOnClass、@ConditionalOnMissingBean这类条件注解来决定要不要装配某个Bean。这就意味着,如果你的依赖里恰好缺少某个类、或者你自己定义了一个同名Bean,自动配置会静默跳过。很多"为什么我的配置没生效"的排查,最后都会追到这个机制上。
想真正理解一个Spring Boot应用,建议新人在启动过程多看两样东西:一是启动日志里Auto-configuration相关的行,二是spring-configuration-metadata.json里暴露的配置项。比如在启动日志里加一行debug=true,Spring Boot会把每一个自动配置的匹配情况全部列出来,你就能看到哪些条件命中了、哪些条件没命中、为什么没命中。这个排查习惯,比背任何配置清单都有用。
1.2 在VSCode里搭一套Spring Boot开发环境
既然是"快速上手",开发环境当然越省事越好。这几年越来越多的开发者放弃笨重的IDE,改用VSCode做Java开发。VSCode配置JavaEE语言环境,其实网上搜得到的方案有点老,我这边给一个现在还能直接用的版本。
第一步,安装VSCode的扩展。最基础的是Extension Pack for Java,这里面已经包含了语言服务器、调试器、Maven支持、Test Runner等组件,基本够用。做Spring Boot开发再补两个:Spring Boot Extension Pack和Spring Initializr Java Support。前者提供配置文件自动补全、运行Dashboard,后者可以直接在VSCode里通过向导创建Spring Boot项目。
第二步,确认JDK和Maven。Spring Boot 2.x系列建议使用JDK 8或JDK 11,Spring Boot 2.6以上版本跑在JDK 17上也没有问题,但如果你用的是更老的项目,不要直接跳到JDK 17,后面会解释为什么。Maven则建议3.6.3以上。VSCode会自动去找系统里的JAVA_HOME和mvn命令,如果你本机装了多个JDK,可以在VSCode的settings.json里显式指定:
{ "java.configuration.runtimes": [ { "name": "JavaSE-11", "path": "C:/Program Files/Java/jdk-11.0.17", "default": true } ], "maven.executable.path": "D:/apache-maven-3.8.6/bin/mvn.cmd" }第三步,用Spring Initializr创建项目。按快捷键Ctrl+Shift+P,输入Spring Initializr: Create a Maven Project,选择Spring Boot版本,再选依赖。这里有一个建议:刚开始不要选太多依赖,能跑通再说。第一堂课只需要Spring Web这一个Starter就够了。
创建完之后,VSCode会自动识别项目结构。启动项目有两种方式:进了src/main/java下的主类,直接点Run;或者在Spring Boot Dashboard面板里,能找到所有本地运行中的实例和可启动项目,更直观。
1.3 第一个可运行的工程:结构、启动与验证
一个由Spring Initializr生成的标准Spring Boot项目,目录结构如下:
src/main/java/com/example/demo ├── DemoApplication.java src/main/resources ├── application.properties ├── static/ └── templates/ src/test/java pom.xmlDemoApplication.java就是启动类,它上面用@SpringBootApplication标注。这个注解是三个注解的组合:@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan。其中@ComponentScan默认扫描启动类所在包及子包。新手最容易踩的坑就在这里:你把Controller、Service放到了启动类所在包之外,结果死活扫描不到,接口404查了半天。解决办法很简单——Controller的包路径必须在启动类的子包之下,或者手动用@ComponentScan指定扫描范围。
写一个最简单的接口验证环境是否正常:
@RestController public class HelloController { @GetMapping("/hello") public String hello() { return "Hello Spring Boot!"; } }启动应用后访问http://localhost:8080/hello,能看到返回字符串,说明整个链路已经通了。
这时候我建议你做一件额外的事:看一眼pom.xml里spring-boot-starter-parent的版本号,再在application.properties里加一行debug=true,重新启动观察日志。你会在启动日志里看到类似Positive matches的一段内容,列出Spring Boot根据spring-boot-starter-web自动装配的Servlet容器、DispatcherServlet等组件。搞清楚这一批自动装配到底做了什么,后面排查问题会快非常多。
2. 版本选型与默认策略:Boot 2.1到2.6的差异和坑
2.1 版本差异的直接影响和升级思路
Spring Boot的版本演进非常快,但很多JavaEE开发者往往是"项目用到哪个版本就学哪个版本",因为网上教程质量参差不齐,版本乱象很常见。热搜里同时出现了spring boot 2.1、spring boot 2.6以及2.1集成websocket、2.6集成websocket,说明不少人在版本选择上很纠结。
我先给一个选型结论:新项目直接用当前最新稳定版,遗留项目能不升级就不升级。Spring Boot 2.1是2018年的版本,2.6是2021年底的版本,中间隔了几个大的依赖基线变化。如果只是学习,用2.6以上版本完全没问题;如果有老项目,盲目升版本往往会引发连锁的依赖兼容问题。
举几个实际差异。Spring Boot 2.1默认用的Tomcat是9.0.x,Spring Boot 2.6默认Tomcat是9.0.5x,看起来只是小版本差异,但如果你的项目里用了某些基于Tomcat内部API的第三方组件,升级后可能直接编译失败。再比如,Spring Boot 2.4开始,配置文件处理方式变了:spring.profiles被标记为过时,改用spring.config.activate.on-profile;spring.config.location的优先级也调整过。2.6里又把spring.main.allow-circular-references默认值改成了false,结果一批循环依赖的项目升级后直接启动报错。
所以我的经验是:升级Spring Boot版本,一定要参照官方发布的Spring Boot 2.x Release Notes逐条核对变化,而不是改完版本号就完事。上面提到的循环依赖问题就是个典型例子,Spring Boot 2.6之前循环依赖大概率能正常启动,2.6之后直接抛出BeanCurrentlyInCreationException。这种问题排查起来特别耗时,因为报错信息和真实原因之间隔着好几层代理。
2.2 Springfox 3.0.0在Boot 2.6下启动报错的处理
现实中遇到最多的版本冲突,是Springfox 3.0.0搭配Spring Boot 2.6。热搜词里出现了"springfox 3.0.0 与 spring boot 2.6+",一定是有人被这个问题折磨过。
先解释一下原因。Spring Boot 2.6开始,Spring MVC默认使用的路径匹配策略从AntPathMatcher换成了PathPatternParser——更准确地说,是spring.mvc.pathmatch.matching-strategy的默认值从ant_path_matcher改成了path_pattern_parser。而Springfox 3.0.0内部的DocumentationPluginsBootstrapper还是在按照AntPathMatcher的方式去处理路径匹配,两者一碰,启动时就抛出NullPointerException或者Failed to start bean 'documentationPluginsBootstrapper'。
解决方案有两种。第一种是兼容性兜底,在application.properties里加一行:
spring.mvc.pathmatch.matching-strategy=ant_path_matcher这样Spring MVC回到了AntPathMatcher,Springfox就能正常启动。很多博客只给你这一步,但我要提醒:这个方案只是缓兵之计。PathPatternParser相对于AntPathMatcher有性能优势,而且Spring官方在后续版本中会彻底移除AntPathMatcher路径匹配策略,所以新项目不建议再依赖这个配置。
更推荐的是第二种方案:直接换掉Springfox,改用springdoc-openapi。springdoc-openapi-ui这个依赖包,在Spring Boot 2.6下只需要引入即可,访问/swagger-ui.html或/v3/api-docs都能正常出文档。如果你正在写一个新的Spring Boot接口项目,直接用springdoc才是最省心的路径。
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-ui</artifactId> <version>1.6.9</version> </dependency>2.3 再说"SQL执行10秒自动关闭":超时参数的真相
另一个很有代表性的热搜问题是:"jvm或者spring boot会设置一个sql执行10秒自动关闭吗"。这个问题的背后,说明很多人在数据库连接池、事务和SQL执行三个概念之间产生了混淆。
先给结论:Spring Boot不会默认设置"SQL执行10秒自动关闭"。你遇到的现象,大概率来自三个不同层面的超时控制。
第一个层面是数据库连接的空闲超时。MySQL服务端有一个wait_timeout参数,默认值是8小时,意思是连接空闲超过8小时,服务端会主动断开这条连接。如果你用连接池(Spring Boot默认使用HikariCP),连接池里维护的物理连接长时间没人用,就会被数据库服务端"回收"。连接池本身并不知道这条连接已经断了,把这条死连接交给应用去执行SQL,就会报Connection is not available, request timed out或Communications link failure。
第二个层面是连接池的超时设置。HikariCP有几个关键参数值得关注:
| 参数 | 默认值 | 含义 |
|---|---|---|
connectionTimeout | 30000ms | 从连接池获取连接的等待超时 |
idleTimeout | 600000ms(10分钟) | 连接在池中空闲多久会被回收 |
maxLifetime | 1800000ms(30分钟) | 连接的最大生命周期 |
validationTimeout | 5000ms | 连接有效性检查超时 |
假设你有一个长时间不执行的定时任务,到点突然要查数据库,如果连接池里的空闲连接已被MySQL关闭,HikariCP会在检测到连接不可用后重建连接,但这个过程会占用第一次请求的耗时。这很可能就是"SQL执行10秒自动关闭"这个说法的来源。
第三个层面才真正涉及SQL执行超时。Spring的@Transactional注解有timeout属性,单位是秒,比如@Transactional(timeout = 10)表示该事务必须在10秒内完成,超时则回滚。注意这个默认是关闭的,只有显式设置才生效。JDBC层面也有Statement#setQueryTimeout(),但Spring Boot默认也不会给普通SQL加这个限制。
所以,你在写代码时遇到"SQL自动关闭"问题,不要急着去设置什么10秒超时,先看清日志里报的到底是Connection is not available、Communications link failure还是Transaction timed out。三个问题的解决方向完全不一样:连接池问题调maxLifetime和idleTimeout,Tomcat/MyBatis连接被回收问题检查minEvictableIdleTimeMillis,事务超时问题检查@Transactional配置和慢SQL本身。
3. 数据层进阶:MyBatis字段级加密的写入与查询改造
3.1 为什么建议用字段级加密而不只是脱敏
很多JavaEE课程里提到数据安全,讲的都是"脱敏"——比如手机号中间四位打星号、身份证号只显示前六后四。但脱敏只是展示层的处理手段,数据库里存的还是明文。真正的安全需求是:数据库泄露了,明文不能被直接看到。这时候就要做字段级加密。
"spring boot + mybatis实现数据库字段级加密了怎么做查询"这个热搜问题,问得很实际。字段级加密的难点不在于写入,而在于查询——密文没办法直接参与WHERE比较,模糊搜索、范围查询全部要重新设计。如果你做的项目里涉及用户手机号、身份证、银行卡、家庭住址这类敏感信息,又需要按这些字段查询,那下面的方案就很值得参考。
字段级加密的常见做法有三种:
- 应用层加解密:在Service层,写入前加密,读取后解密。优点是实现简单直观;缺点是容易遗漏,每张表都要写一遍,且如果项目里后来接入报表、数据同步,很容易漏掉加密逻辑。
- MyBatis TypeHandler:把加解密逻辑封装在TypeHandler里,对应用层透明。写入时自动加密,查询返回时自动解密,代码侵入性最小。
- 数据库函数加密:比如MySQL的
AES_ENCRYPT、AES_DECRYPT,SQL里包一层。优点是不改Java代码;缺点是密钥会暴露在SQL日志里,灵活性差。
我用得最多的是MyBatis TypeHandler方案,下面详细拆解。
3.2 TypeHandler实现写库自动加密
TypeHandler是MyBatis里"Java类型与JDBC类型互转"的处理器。默认的TypeHandler负责String、Integer这些类型的映射,而我们自定义一个加密TypeHandler,就是在setParameter时先加密再写入,在getResult时先解密再返回。
直接看代码。假设我们用的加密算法是AES,密钥从环境变量读取(千万不要硬编码在代码里):
public class AesEncryptTypeHandler extends BaseTypeHandler<String> { private static final String AES_KEY = System.getenv("APP_DATA_KEY"); @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, AesUtil.encrypt(parameter, AES_KEY)); } @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { return AesUtil.decrypt(rs.getString(columnName), AES_KEY); } @Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return AesUtil.decrypt(rs.getString(columnIndex), AES_KEY); } @Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return AesUtil.decrypt(cs.getString(columnIndex), AES_KEY); } }AesUtil的加解密逻辑,可以用JDK自带的Cipher,也可以用Hutool的SecureUtil.aes(key)简化开发。注意AES密钥长度必须是16、24或32字节,对应AES-128、AES-192、AES-256,别用了个位数长度的字符串结果运行时报InvalidKeyException。
然后在实体类字段上标注这个TypeHandler:
public class User { private Long id; @TableField(typeHandler = AesEncryptTypeHandler.class) private String phone; // getter/setter 略 }如果用的是MyBatis-Plus,这里@TableField(typeHandler = ...)就生效了;如果用的是原生MyBatis,需要在Mapper XML里显式声明:
<resultMap id="UserResultMap" type="com.example.entity.User"> <id column="id" property="id"/> <result column="phone" property="phone" typeHandler="com.example.handler.AesEncryptTypeHandler"/> </resultMap>这样执行INSERT、UPDATE、SELECT时,MyBatis会自动用这个TypeHandler处理字段。一个字段配置好,整个项目里对它的读写都是加密的,省去了在Service层重复调加密工具类的麻烦。
3.3 查询改造:等值查询、模糊查询和加密参数传递
字段加了TypeHandler后,最容易被问到的就是:查询怎么办?比如用户登录时,前端传来的手机号是明文,但数据库里存的是密文,直接用WHERE phone = #{phone}匹配不到数据。
这个问题的本质是:加密参数的传递路径和结果映射路径是不同的。MyBatis的TypeHandler确实同时负责参数映射和结果映射,但在Mapper接口方法里,如果参数是一个普通字符串,MyBatis不知道要给它套哪个TypeHandler。这里有两种解决方案。
方案一:在Mapper方法的参数上显式指定TypeHandler。
@Select("SELECT * FROM user WHERE phone = #{phone, typeHandler=com.example.handler.AesEncryptTypeHandler}") User findByPhone(@Param("phone") String phone);这样传入的明文手机号会在执行SQL前被加密。这种方案适合等值查询,写起来直观。
方案二:在Service层手动加密后再传参。
public User findByPhone(String phone) { String encryptedPhone = AesUtil.encrypt(phone, AES_KEY); return userMapper.findByEncryptedPhone(encryptedPhone); }Mapper层完全感知不到加密的存在,就是一个普通查询。这个方法的好处是灵活,如果你要按手机号做分页、做关联查询,可以统一在Service入口处理好,避免每个Mapper方法都写一遍typeHandler。
真正麻烦的是模糊查询。LIKE '%关键词%'这种需求,直接加密后再拼接LIKE基本是不可行的——AES加密是块加密,密文和明文长度不对应,也不具备明文前缀匹配的特征。我自己的实操经验是,针对短信验证码、手机号这类需要精确匹配的字段,只做等值查询是最安全的;如果业务上确实需要模糊搜索手机号、姓名,那就需要一个专门的设计:
- 增加一个
phone_ciphertext字段,专门存确定性加密后的密文,用于等值匹配。 - 同时保留一个
phone_mask字段,存脱敏后的明文(如138****1234),用于列表展示和模糊搜索。
这里要注意,模糊搜索会暴露部分明文信息,隐私合规上要谨慎。但业务需求摆在那里的时候,总比在数据库函数里做解密再LIKE要稳妥得多。
4. 实时通信与预约类系统落地:WebSocket实战
4.1 两种WebSocket接入路线选型
JavaEE传统做法里写WebSocket,要么用JSR 356的@ServerEndpoint注解,要么基于Tomcat的WebSocketServlet自己封装。到了Spring Boot里,路数依然有两条:注解方式和Spring API方式。
注解方式就是@ServerEndpoint("/ws"),配合一个ServerEndpointExporter的Bean。Spring Boot会把带@ServerEndpoint注解的类注册成WebSocket端点。代码量最小,适合快速实现一对一实时推送。
@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }@Component @ServerEndpoint("/ws/order/{orderId}") public class OrderWebSocketEndpoint { private static final Map<String, Session> SESSIONS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("orderId") String orderId) { SESSIONS.put(orderId, session); System.out.println("订单 " + orderId + " WebSocket 连接建立"); } @OnClose public void onClose(@PathParam("orderId") String orderId) { SESSIONS.remove(orderId); } public static void pushToOrder(String orderId, String message) throws IOException { Session session = SESSIONS.get(orderId); if (session != null && session.isOpen()) { session.getBasicRemote().sendText(message); } } }Spring API方式则是实现WebSocketHandler接口,再通过WebSocketConfigurer注册。这种方式能更细粒度地控制握手、拦截、会话管理,适合复杂场景,但代码量明显多。
两条路线怎么选?我的建议是:项目里只是简单推送,比如订单状态通知,用注解方式就够了;如果你要对接Spring Security做鉴权、拦截未登录用户,或者要处理多个协议的会话互通,走Spring API方式。注解方式里WebSocket握手不在Spring MVC的拦截器链路里,做鉴权要额外实现HandshakeInterceptor,很多初学者在这上面卡很久。
4.2 基于Spring Boot的预约服务系统怎么拆模块
"基于spring boot的上门烹饪预约服务系统的设计与实现"这类标题,在毕业设计和接单项目里几乎天天见。这类系统的技术架构实际上是高度模式化的,拆开来看就是:用户端、服务提供端、管理后台三个端,加一个预约订单的主流程。
先说技术选型。后端用Spring Boot + MyBatis + MySQL,缓存用Redis(存验证码、热门服务列表),文件存储可以用OSS或MinIO(存菜品图片),前端如果要兼顾移动端,直接上微信小程序或H5。这套组合能覆盖绝大多数预约类系统的需求,而且每一层都有成熟的轮子,不需要自己造。
核心业务表设计,我列几张最关键的:
user:用户表,手机号、昵称、头像、状态。provider:服务提供者表(比如上门烹饪的厨师),姓名、简介、星级、服务区域。service_item:服务项目表,名称、价格、时长、图片。appointment:预约订单表,预约人、服务提供者、服务项目、预约时间、地址、状态。order_status_log:订单状态流转日志,下单、接单、开始服务、完成、取消。
预约系统的核心难点是时间冲突检测。同一个厨师在同一个时间段不能接两个单。我建议在service_item表里加一个服务时长字段(比如2小时),预约下单时先查一下:
SELECT COUNT(*) FROM appointment WHERE provider_id = #{providerId} AND status IN ('PENDING', 'ACCEPTED') AND start_time < #{endTime} AND end_time > #{startTime}这条SQL能覆盖重叠区间判断。再用数据库唯一索引或Redis分布式锁防并发重复预约,否则两个用户同时下同一时间段的单,查的时候都发现没有冲突,插入的时候就会出问题。
4.3 订单状态推送中的Session管理与心跳
预约系统里,用户下单后厨师接单的实时推送,是最典型的WebSocket应用场景。用户端下单,服务端收到后把消息推送给厨师端;厨师接单,再推送回用户端。
这里说几个实际项目里必须注意的细节。
第一,Session要按业务维度管理。如果是@ServerEndpoint("/ws/order/{orderId}),一个订单一个Session,逻辑清晰。如果用户需要同时感知多个订单的状态变化,建议改成/ws/user/{userId},客户端连接后,服务端维护userId -> Session`的映射,推送时按userId定位。
第二,心跳保活。WebSocket连接长时间空闲,网络中间层(负载均衡、防火墙)可能把连接断开,但两端都不感知。客户端建议每30秒发一次ping帧或业务心跳消息,服务端如果超过60秒没收到消息,可以主动关闭连接或做标记。Spring的TextWebSocketHandler里可以重写afterConnectionEstablished启动心跳任务,也可以在@OnMessage里做最后活跃时间戳的更新。
第三,服务端偶发推送失败的补偿。WebSocket是长连接,但网络抖动、客户端进程被杀都会导致连接断开。推送的时候先判断session.isOpen()再发,这是常识了;但推送失败后的补偿逻辑,很多人没做。我一般建议:WebSocket只做实时通知的加速通道,最终的数据状态以数据库为准。也就是说,推送的只是"订单状态变化了"这样的信号,前端收到后重新GET一次订单接口拿到最新详情。这样即使WebSocket推送丢了,前端做一次兜底轮询也能把数据拉回来。
5. 前后端联调实录:Ajax参数接收为何老是出错
5.1 最典型的"后台拿不到值"场景
热搜里有一条很具体的问题:"spring boot无法通过ajax的参数,后台已取得数据"——这个描述有点歧义,我理解成两种可能:一种是前端Ajax发的参数后端接收不到,另一种是后端已经取到数据了,但Ajax请求拿不到返回。
先看第一种,这也是最常见的问题:@RequestBody和@RequestParam用混了。
前端用jQuery或axios发POST请求,如果设置了contentType: 'application/json',那么请求体是一段JSON字符串:
$.ajax({ url: '/api/order/reserve', type: 'POST', contentType: 'application/json;charset=UTF-8', data: JSON.stringify({ startTime: '2024-01-01 10:00', serviceType: 1, address: '某某小区3栋2单元' }), success: function(res) { console.log(res); } });这时候后端Controller如果用的是@RequestParam接收,那一定接不到——@RequestParam专门从URL查询参数或表单格式的请求体里取值,解析不了JSON。正确写法是用@RequestBody:
@PostMapping("/api/order/reserve") public Result reserve(@RequestBody ReserveRequest request) { // request.startTime, request.serviceType, request.address }反过来,如果前端发的是表单格式,比如:
$.ajax({ url: '/api/order/reserve', type: 'POST', data: 'startTime=2024-01-01 10:00&serviceType=1&address=xxx' });那后端用@RequestBody也接不到,只能@RequestParam或写一个包含这些字段的POJO对象不加任何注解去接收。
很多新人在网上抄代码,前后端没约好参数格式,前端发JSON、后端用@RequestParam,或者前端发表单、后端用@RequestBody,必然对不上。
5.2 排查过程:请求头、参数注解、序列化逐层定位
遇到"Ajax参数后端拿不到"或者"返回数据前端读不到"这类联调问题,建议按下面的顺序排查,比在代码里瞎改靠谱得多。
第一步,打开浏览器开发者工具,看Network面板里的请求详情。确认三件事:请求URL是否拼对、Method是否匹配、Content-Type是什么。如果Content-Type是application/json,后端必须用@RequestBody;如果是application/x-www-form-urlencoded或multipart/form-data,后端用@RequestParam或普通POJO接收。
第二步,看后端接收的原始参数。可以在Controller方法入口打日志,打印请求参数、请求体。如果发现请求体是JSON但参数对象是空的,多半是Jackson反序列化失败。注意,Spring Boot对JSON反序列化失败的默认行为是抛出HttpMessageNotReadableException,但如果前端字段名和实体类字段名不一致,比如前端传userName、实体类是username,那也不会报错,只是这个字段是null。这种情况最隐蔽——参数能进来,但个别字段是空的。
第三步,看返回数据的序列化。后端有数据、前端拿不到,问题往往出现在序列化这一层。比如Controller返回的是一个对象,但类上没有@RestController或@ResponseBody,Spring会把它当成视图名去解析,前端拿到的就是一段非预期HTML。或者返回的对象里有循环引用,Jackson序列化报错,导致整个响应为空:
@Entity public class Order { @ManyToOne @JsonIgnoreProperties({"orders"}) private User user; }如果两个实体互相引用,Jackson会抛JsonMappingException: Infinite recursion,接口返回500,前端自然拿不到数据。解决办法是在字段上加@JsonIgnoreProperties,或者在全局配置里用@JsonIdentityInfo。
5.3 后端返回但前端读不到的三类原因
继续把"后台已取得数据,但前端拿不到"的情况细化一下。我总结下来,不外乎下面三类原因,一个个对照即可。
第一类:响应不是JSON。Controller方法没有加@ResponseBody,类没有用@RestController,或者返回值类型不是标准对象而是String但内容不是合法JSON。前端拿到后直接当对象用,自然读不到属性。
第二类:响应体结构变了。比如后端返回的是Result包装对象,结构是{code: 200, message: "success", data: {...}},前端却直接用res.data里取字段,取了个寂寞。解决方案是统一前后端约定的响应结构,或者前端加一层判断,先取res.data再解包。
第三类:序列化字段名不匹配。Jackson默认把Java字段名序列化成属性名,比如startTime就是startTime,但如果实体类里用了Lombok的@Data且字段命名不规范,比如字段名首字母大写——这种会导致Jackson序列化为"getStartTime"之类的问题。另一个常见场景是字段值为空时被过滤掉了,比如配置了spring.jackson.default-property-inclusion=non_null,字段为null就不会出现在JSON里,前端取值就会得到undefined。这种情况接口本身是成功的,但前端容易误判。
排查的时候,最直接的办法是先用Postman或浏览器直接访问接口看原始返回JSON,如果原始返回正常,那就是前端解析问题;如果原始返回就少了字段,那问题就在后端序列化配置或实体类字段上。分清这个责任边界,能省掉后面一半的沟通成本。
最后说点实际的
Spring Boot这个东西,用起来容易,用明白难。它的自动配置在省事的同时,也把很多底层逻辑藏了起来,所以学的时候一定要多往底下钻一钻。比如验证一个配置是否生效,先看日志里的Positive matches;遇到依赖冲突,先查版本基线;遇到数据库连接被断开,先分清是连接池、服务端还是事务超时;遇到对象字段不对,先确认是不是序列化层在捣乱。把这些排查思路练成肌肉记忆,比多背几个注解要管用得多。我自己带过的项目里,凡是基础扎实、能说清"为什么"的人,后面接手复杂系统时几乎不需要人带。希望这篇笔记能帮你少走一段弯路,也欢迎在实践过程中回来交流你自己的踩坑记录。