我终于熬到了“本科生勇闯Java后端”的第三天。前两天还在跟JDK环境变量和IDEA的默认配置较劲,今天总算是摸到了点后端开发的门道,也踩了几个让新手血压飙升的坑。这篇文章不打算写成教程复读机,只把我第三天实际动手时遇到的问题、绕过的弯、以及后来想明白的原理记下来,希望能给同样在自学Java后端、尤其是从“会写几道算法题”过渡到“能做工程”阶段的同学一点参考。
我给自己定的Day 3目标是:不满足于在IDE里跑通“Hello World”,而是要把Java后端开发的一套基本链路跑起来——从Spring Boot项目初始化,到写一个能处理JSON请求的接口,再到连接数据库做最简单的增删改查,最后搞清楚前后端分离时,后端到底在“分离”什么。这套流程对于有经验的人来说可能就是半小时的事,但我作为本科生从零摸索,足足折腾了一整天。这篇文章就是这一天的心得记录。
1. 从“会Java语法”到“能做后端”之间,隔着一整个工程化思维
1.1 算法题里的Java,和后端项目里的Java,几乎是两门语言
如果你跟我一样,平时写Java最多的地方是力扣或者学校OJ,那你刚接触后端项目时一定会产生一种强烈的撕裂感。算法题里我们关注的是数据结构、循环、递归、时间复杂度;但到了后端项目里,代码变成了分布在几十个文件里的类,类与类之间通过注解、接口、依赖注入互相协作,你甚至找不到一个从上往下执行的“主流程”。
我Day 1、Day 2在配置环境时还觉得Java挺简单的,直到Day 3打开Spring Initializr生成项目,看着解压出来的目录结构,人有点懵。
code复制src/main/java/com/example/demo
├── DemoApplication.java
├── controller
│ └── UserController.java
├── service
│ └── UserService.java
├── mapper
│ └── UserMapper.java
└── entity
└── User.java
这跟刷题时“一个类搞定一切”的习惯完全不同。刚开始我很不理解为什么要拆这么碎,这不自找麻烦吗?后来自己动手写了一个带用户查询功能的接口,才体会到分层的好处:controller只负责接收HTTP请求和返回响应,service只负责业务逻辑,mapper只负责和数据库打交道。每一层各司其职,出了问题知道去哪找,多人协作时也不容易改到别人的代码。
1.2 注解到底帮我做了什么,别再当黑魔法了
新手读Spring Boot代码,最大的障碍就是满屏的注解——@RestController、@RequestMapping、@Autowired、@Service。我一开始的心态是“背下来就行”,但很快发现这根本背不完,因为每个注解背后都藏着框架帮你做的一大堆事情。
举个例子,@RestController 这个注解其实是 @Controller 加 @ResponseBody 的组合体。它的作用是两件事:第一,把这个类标记成一个Spring MVC的控制器;第二,让它里面每个方法的返回值直接序列化成JSON写进HTTP响应体,而不是去解析成视图名称。换句话说,你在方法里 return "hello",前端拿到的不是叫“hello”的页面,而是字符串 hello。
理解注解的作用,比死记硬背重要得多。我建议每个新手拿到一个陌生注解时,都去查一下它组合了哪些子注解、对应的Bean生命周期是什么。搞懂注解背后的机制后,Spring Boot不再是一堆黑魔法,而是一个你逐渐能预测行为的工具。
1.3 Maven是后端项目的地基,绕不开就早点啃
Day 3我花了不少时间在Maven上。说实话这不是我计划内的内容,但任何一个Spring Boot项目都离不开Maven(或者Gradle)。当你在IDE里打开 pom.xml,点击刷新,成百上千的jar包被下载下来时,如果不懂Maven的基本坐标体系和依赖传递规则,一报错就会手足无措。
我实际遇到的问题是这样的:我在 pom.xml 里加了MyBatis依赖,然后启动项目时报错,说找不到 SqlSessionFactory。排查了大半天,最后发现是版本冲突——Spring Boot 2.7.x内置的MyBatis Starter版本和我手动指定的MyBatis版本不兼容。
| 我开始的错误理解 | 实际的工作原理 |
|---|---|
| 依赖越多越好,版本随便挑 | Maven有依赖仲裁机制,版本可能被覆盖 |
| 只要在pom.xml写上坐标就能用 | 还要考虑作用域(scope)和可选依赖 |
| 报错就删依赖重来 | 要看依赖树(mvn dependency:tree)定位冲突 |
后来我养成了一个习惯:任何依赖都优先用Spring Boot官方BOM(Bill of Materials)里维护的版本,除非官方确实没有,再考虑手动指定并做好版本兼容测试。这个习惯让我后来的项目少踩了很多坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一次写“能通”的REST接口:核心不在代码,而在HTTP
2.1 一个最简单的查询接口,背后藏了哪些知识点
Day 3下午,我成功写出了人生中第一个真正意义上的后端接口。需求很简单:前端传一个用户ID,后端返回对应用户的名字和邮箱。
java复制@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public Result<User> getUser(@PathVariable("id") Long id) {
User user = userService.getUserById(id);
return Result.success(user);
}
}
这段代码现在看起来稀松平常,但对当时的我来说,每一个点都是新的:
@GetMapping("/{id}")里的花括号路径变量,跟查询参数?id=1是两回事。@PathVariable负责把URL路径里的{id}绑定到方法参数上。Result<T>是我自己定义的一个统一返回体,用来封装 code、message、data 三段信息。
写这个接口时,我最大的困惑是:为什么要包一层 Result?前端直接拿裸数据不香吗?后来看了一些前后端联调的文章才明白,统一返回体是为了让前端能根据固定格式做全局处理,比如判断登录是否过期、接口是否报错,这些如果靠解析裸数据的不同结构来判断,前端代码会写得很痛苦。
2.2 参数校验不是在controller里写if,而是在实体类里加注解
初学阶段最喜欢犯的错,是在controller方法里写一大堆if判断:
java复制if (user == null) {
return Result.error("用户不存在");
}
if (user.getName().isEmpty()) {
return Result.error("用户名为空");
}
这样写多了,controller会变得极其臃肿,而且每个接口的参数校验逻辑都无法复用。后来我了解到Java Bean Validation规范(JSR 380)和Hibernate Validator实现,习惯才改过来。
正确做法是在实体类字段上加校验注解:
java复制public class User {
@NotNull(message = "用户名不能为空")
@Size(min = 2, max = 20, message = "用户名长度必须在2-20之间")
private String name;
@Email(message = "邮箱格式不正确")
private String email;
}
然后在controller方法的参数前加 @Validated 或 @Valid:
java复制@PostMapping
public Result<Void> createUser(@RequestBody @Validated User user) {
userService.createUser(user);
return Result.success();
}
这一改,校验逻辑从“散落在各个controller里的if”变成了“实体类上的声明式约束”,代码量减少了,出错率也降低了。我后来看公司里的代码,基本都遵循这个模式。
注意:实体类直接承载校验注解有个小问题——同一个实体在新增和更新场景下,校验规则可能不一样。比如新增时ID必须为空,更新时ID必须非空。这种情况下可以定义分组接口,把校验规则分到CreateGroup和UpdateGroup。Day 3我没走到这一步,但你先知道有这回事,免得后面返工。
2.3 跨域问题第一次找上我:前后端分离的第一个拦路虎
写完了接口,我顺手用前端的Vue项目去调,结果浏览器控制台直接报错:
code复制Access to XMLHttpRequest at 'http://localhost:8080/api/users/1' from origin 'http://localhost:5173' has been blocked by CORS policy
这就是传说中的跨域问题。我当时第一反应是后端配置写错了,但查了半天发现代码没问题,纯粹是浏览器的同源策略在拦截。只要前端的端口(5173)和后端的端口(8080)不一样,前端通过AJAX请求后端就会被浏览器视为跨域请求,如果后端不返回对应的CORS头,浏览器就会把这个响应挡住。
我尝试了两种解决方案。第一种是在后端加一个配置类:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
第二种是在 application.yml 里配置。实测下来,配置类方案更灵活,可以针对不同路径设置不同的跨域规则。但这里要提醒一下:不要为了图省事直接 allowedOriginPatterns("*") 再配上 allowCredentials(true),这种组合在携带Cookie时会出问题,而且存在安全隐患。正确做法是明确列出允许的前端地址。
3. 连数据库这一步,我差点被JDBC的原生写法劝退
3.1 为什么有了JDBC,大家还要用MyBatis和JPA
Day 3原先计划里根本没有数据库这一项,但我总觉得一个后端接口只返回写死的数据,太像个玩具了。于是下午我决定挑战一下自己:连上MySQL,写一个真正从数据库查数据的接口。
最原始的方式是用JDBC。你可能在学校里学过这个,步骤是:加载驱动、建立连接、创建Statement、执行SQL、解析ResultSet、关闭连接。纯粹的JDBC代码写一遍还挺有成就感,但写多了就会发现大量重复样板代码,而且资源关闭稍不注意就会泄漏。
我当时写的一段JDBC查询:
java复制Class.forName("com.mysql.cj.jdbc.Driver");
Connection conn = DriverManager.getConnection(url, username, password);
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = ?");
ps.setLong(1, userId);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
User user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
// ...手动映射
}
这代码能跑通,但是非常“啰嗦”。也正因如此,ORM框架和半ORM框架才如此流行。MyBatis帮你把SQL执行结果自动映射成Java对象,JPA更进一步,连SQL都不用到处写。对于我这种从零开始的后端新手,我选择先入手MyBatis——因为它把SQL的控制权还给开发者,学习曲线相对平滑,出了问题你知道它在背后执行了什么SQL,排查起来更踏实。
3.2 MyBatis-Plus,一个让新手感动到哭的增强工具
纯粹的MyBatis用起来还是要写不少XML映射文件,后来我发现有个叫MyBatis-Plus的增强工具,它的 BaseMapper 直接帮你把单表CRUD最常用的方法都实现了。
java复制public interface UserMapper extends BaseMapper<User> {
// 无需任何代码,就拥有了insert/delete/update/selectById等方法
}
对应的Service层:
java复制@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User getUserById(Long id) {
return userMapper.selectById(id);
}
public List<User> listUsers(String keyword) {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.hasText(keyword), User::getName, keyword);
return userMapper.selectList(wrapper);
}
}
注意看 listUsers 方法里的 LambdaQueryWrapper,它是MyBatis-Plus用来构造查询条件的核心类,最大的好处是能链式调用,还能用 Lambda 表达式引用实体字段,避免字符串硬编码。这在以前用MyBatis时要写一堆XML条件标签,现在三行代码搞定。
当然,MyBatis-Plus也是有代价的——它帮你省了简单CRUD的SQL,但如果你想做复杂的多表联查、动态SQL,还是得回到XML里手写SQL。MyBatis-Plus不会魔法般地帮你解决所有问题,它只解决简单问题,复杂问题它会把控制权还给你。
3.3 连接池和事务:想在生产环境活下来,这两个概念必须懂
代码能跑通数据库查询之后,我注意到一个细节:每次查询都新建连接,用完就关闭,这在本地测试没问题,但如果有100个人同时访问,数据库连接就会不断创建销毁,性能极差。于是我开始了解连接池的概念。
连接池的原理像一个水管中转站:先初始化一批数据库连接放在池子里,谁需要谁借,用完归还,不够用就扩容。Spring Boot 2.x默认使用的连接池是HikariCP,号称速度极快。配置起来也很简单:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
minimum-idle: 5
maximum-pool-size: 20
connection-timeout: 30000
事务这个概念我入学时老师讲过,但直到自己写后端才真正用上。比如转账操作,从A账户扣钱和给B账户加钱必须同时成功或同时失败,中间任何一步出错都要回滚。在Spring里加 @Transactional 注解就能实现声明式事务管理:
java复制@Transactional(rollbackFor = Exception.class)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountMapper.deductBalance(fromId, amount);
accountMapper.addBalance(toId, amount);
}
这里有个小细节:rollbackFor = Exception.class 一定要写,否则Spring默认只对RuntimeException回滚,遇到检查异常(比如IOException)时事务不会回滚,数据就可能出现不一致。
4. 前后端分离,到底在“分离”什么——给非科班同学的安全感
4.1 分离的不是技术,而是职责边界
我经常在论坛上看到有人问:“前后端分离之后,后端到底还要不要会前端?”我的理解是:后端不需要精通Vue或React,但必须知道前端是怎么请求你的接口的、你的接口返回什么格式前端用起来最顺手、以及一场完整的数据交互流程中包含哪些角色。
前后端分离的本质是职责边界的前移。前端负责数据展示和用户交互,后端负责数据处理和业务规则校验。两者通过HTTP接口通信,用JSON作为数据交换格式。前端不用关心后端用什么语言写的——Java、Go、Python都行;后端也不用关心前端用的是Vue还是React。
但这种分离也带来一个现实问题:接口规范变得极其重要。没有统一的接口约定,前端和后端就会互相“甩锅”。我Day 3实践时就深有体会,前端同学问我“你这接口error的时候返回什么结构?”,我被问住了,因为我确实没定义好统一错误码。后来我老老实实设计了一版:
json复制{
"code": 200,
"message": "操作成功",
"data": { "id": 1, "name": "张三", "email": "zhangsan@example.com" }
}
错误时:
json复制{
"code": 40001,
"message": "用户不存在",
"data": null
}
统一返回结构看着是不起眼的小事,但它决定了前后端联调的效率。如果每个接口返回结构都不一样,前端每接一个接口就要写一套解析逻辑,维护成本会呈指数级上升。
4.2 Spring Boot项目全流程:从启动类到一次完整请求
为了彻底搞懂一次请求在后端项目里到底经历了什么,我专门画了个“脑内流程图”(虽然博客里不能画,但我用文字给你捋一遍):
- 前端发送HTTP请求,比如
GET /api/users/1。 - Spring Boot内置的Tomcat(或Undertow、Jetty)接收到请求。
- DispatcherServlet作为前端控制器介入,根据URL匹配到对应的
@RequestMapping。 - HandlerMapping找到
UserController里的getUser方法。 - 参数解析器把URL中的
1解析成Long id。 - 方法执行前,Spring AOP可能会介入(比如事务、日志、权限校验)。
UserService被调用,内部调用UserMapper.selectById。- MyBatis通过动态代理执行SQL,从MySQL查出数据并映射成
User对象。 - 返回值经过
HttpMessageConverter,把Result<User>序列化成JSON。 - DispatcherServlet把JSON写回HTTP响应体,前端拿到结果。
整个过程看似复杂,但Spring Boot通过自动配置帮你把大多数组件都拼装好了。你只需要关注自己写的controller、service、mapper三层逻辑即可。这种“约定优于配置”的设计,确实是Java后端开发效率提升的一大法宝。
4.3 让新手焦虑的“八股文”,本质是把你用过的工具讲清楚
最后聊聊“Java八股文”这个话题。经常有人说Java后端面试就是背八股文,什么HashMap原理、JVM内存模型、并发编程、Spring Bean生命周期,听着就很劝退。我Day 3时也焦虑过这个问题,但焦虑的原因不是“记不住”,而是“记住了却不理解”。
后来我想明白一个道理:八股文不是知识的终点,而是梳理知识结构的线索。比如HashMap,你在项目里用过无数次,但问“为什么默认负载因子是0.75”时,你答不上来。这其实不是面试官故意刁难你,而是负载因子涉及哈希表的空间利用率和时间复杂度的权衡,如果连这个都搞不清楚,很难让人相信你在数据量大的场景里能做出合理的技术选型。
所以我给自己的Day 3定了一个附加任务:每学一个新的Spring Boot机制,就去底层找一找它和“八股文”知识点的关联。比如学了 @Autowired 就去搞懂依赖注入和控制反转;学了 @Transactional 就去搞懂Spring AOP代理。这样一来,八股文不再是需要死记硬背的题库,而是你实际用到的工具背后的原理说明书。
5. Day 3踩坑实录:这一天我遇到的报错和最终解法
5.1 “Java: 警告: 源发行版 17 需要目标发行版 17” 到底谁错了
这是我Day 3最开始遇到的一个诡异报错,明明代码什么都没写,一启动项目就提示源发行版需要目标发行版17。我一开始以为是JDK版本问题,后来发现自己机器上装了JDK 8和JDK 17两个版本,IDE默认编译用的JDK 8,而Spring Boot 3.x要求至少JDK 17。
问题出在三个地方可能不一致:File -> Project Structure 里的Project SDK、Settings -> Build Tools -> Maven -> Importing 里的JDK版本、pom.xml 里 <java.version> 属性的值。新手只改其中一个地方是不够的,必须确保这三处是同一个JDK版本。
我的解决方案是统一改成JDK 17,并确认 pom.xml 里设置了 <java.version>17</java.version> 后重新导入Maven项目。这个坑看着不起眼,但几乎每个刚开始用Spring Boot 3的人都踩过。
5.2 “You aren't using a compiler supported by lombok” 其实是版本错位
这个报错也是经典。Lombok是个能通过注解自动生成getter/setter的库,但它在不同JDK版本下的兼容性不一样。我用的Lombok版本太老,不支持JDK 17的编译选项,于是IDEA在编译阶段抛了这个警告。
我的解决方法是升级Lombok依赖到最新稳定版,并且确保IDEA的“Annotation Processing”选项是开启状态。路径是 Settings -> Build, Execution, Deployment -> Compiler -> Annotation Processors,把 Enable annotation processing 勾上就可以。
经验之谈:Lombok这种东西属于“开发期工具”,只在编译期生效,运行时并不需要它。但正因为这样,它的版本必须和JDK、IDE高度匹配。我现在的做法是直接通过Spring Initializr生成项目时自带的Lombok版本,不自己乱指定。
5.3 “Insufficient memory” 不是代码问题,是你给IDE的饭不够
Day 3下午我同时开着IDEA、Vue项目、MySQL Workbench和Chrome一大堆标签页,结果IDEA突然弹出 java: OutOfMemoryError: insufficient memory,整个编译直接挂掉。
这个报错往往不是代码问题,而是JVM堆内存不够用。IDEA本身是个跑在JVM上的应用,默认堆内存可能只有1280MB,项目一多、索引一建,内存就见底了。解决方法是调整IDEA的启动参数,在菜单里找到 Help -> Change Memory Settings,把堆内存调大,比如设为4096MB。如果是命令行编译项目时碰到这个报错,就在Maven配置里加 MAVEN_OPTS=-Xmx1024m。
这个经历让我意识到,搞后端开发不仅是写代码,环境资源的管理也是基本功。电脑内存不够、IDE卡顿、数据库连接数配太少,这些看起来跟“Java编程”无关的问题,恰恰是最降低开发效率的。
5.4 环境变量、端口占用、数据库时区,三个“非技术”但常见的坑
剩下几个小坑我简单罗列一下,给同是零基础起步的同学提个醒:
- 端口被占用:启动Spring Boot时提示
Port 8080 was already in use,用netstat -ano | findstr 8080(Windows)或lsof -i:8080(Mac/Linux)找到占用进程,关掉或者换个端口。 - MySQL时区报错:连接串上一定要加
serverTimezone=Asia/Shanghai,否则MySQL驱动会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized这个让人摸不着头脑的错。 - 网络代理冲突:如果你本机开了代理工具,Maven下载依赖时可能因为代理配置问题导致一半包下载失败。建议在Maven的settings.xml里明确配置镜像源,避免依赖下载到一半就卡死的尴尬。
6. 想提前跟下一批“勇闯Java后端”的新手说的话
Day 3这一天过得其实挺狼狈的,早上信心满满,中午被跨域问题卡到怀疑人生,下午又被数据库连接池和事务折磨到崩溃,晚上回顾的时候才慢慢理清思路。
但恰恰是这些折磨,让我意识到了学校里练的“写代码”和工程里需要的“做系统”之间的差距。写代码只需要语法正确、算法可行;做系统则需要考虑分层、性能、异常处理、资源管理、协作规范。后者才是后端开发的日常。
如果让我给同样在自学路上的本科生一个建议,我会说:尽早从“跟着教程敲代码”过渡到“带着问题做项目”。我Day 3之所以能主动去研究Maven依赖机制、CORS跨域原理、HikariCP连接池,就是因为我给自己设置了一个小项目目标——做一个简单的用户管理系统,哪怕只有增删改查四个接口。当你有具体目标时,那些散落的知识点会自动串成线。
下一步我打算继续完善这个项目,把登录鉴权(比如JWT)、统一异常处理、日志切面这些企业级功能一个个加进去,然后尝试部署到云服务器上。我相信等我把这套链路走通之后,就不只是个会背HashMap原理的面试选手了,而是一个真的能交付功能的后端萌新。
如果你的进度和我差不多,或者比我快一点、慢一点,都别慌。Java后端这条路很长,Day 3只是一个开始,重要的是每天都能比前一天多理解一点“为什么”。共勉。
