Spring 全家桶零基础图文教程

从「这是什么」到「亲手跑通一个完整项目」—— Spring · Spring MVC · MyBatis · Spring Boot 一次学透

📖 10 个章节 🖼️ 11 幅原创图解 💻 50+ 可运行代码 🚀 1 个完整实战 ⏱️ 预计 8~10 小时学完

第 0 章课程导学:认识四件套

先搞清楚四个名词的关系,再搭好环境。磨刀不误砍柴工。

0.1 这门课适合谁?

你只需要具备两个前提:

  • ✅ Java 基础:最好见过类、方法、对象这几个词(不需要熟练——忘得大半没关系,第 1 章就是专门为你准备的快速回顾);
  • ✅ SQL 基础:见过 SELECT / INSERT / UPDATE / DELETE 这几个词。

不需要会的:反射、设计模式、任何框架——用到时我会解释;注解会在第 1 章提前混个眼熟。

学完你将能够:独立搭建并开发一个提供 REST 接口、读写 MySQL 数据库的完整 Java 后端项目——这正是企业里「Spring 技术栈开发岗」的基本功。

0.2 一张图看懂四个技术的关系

初学者最懵的就是:Spring、Spring MVC、MyBatis、Spring Boot 到底什么关系?记住一句话:

Spring 是地基(管对象);Spring MVC 是前厅(管请求);MyBatis 是仓库通道(管数据库);Spring Boot 是总装车间(把所有东西自动装好,让你拎包入住)。

一个 Spring 技术栈项目的分层结构 浏览器 / 前端页面 Spring Boot 自动配置 · starter 依赖 · 内嵌 Tomcat Spring MVC(Controller 层) 接收 HTTP 请求 · 返回 JSON Service 层 写业务逻辑的地方 MyBatis(Mapper 层) 执行 SQL · 对象与记录互相转换 MySQL 数据库 数据真正存放的地方 Spring 容器统一管理这里的所有对象(Bean)

图 0-1 · 四件套在项目中的位置:Spring 横贯中三层,Boot 包裹全局,MVC 与 MyBatis 各守一头

也可以用「餐厅」来打比方:

技术餐厅里的角色干的活
Spring店长统一招聘、管理员工(对象),谁缺人手就找店长要
Spring MVC前台服务员接待客人(浏览器请求),把点单递给后厨
MyBatis采购员往返仓库(数据库)取食材、存食材
Spring Boot加盟总部装修、设备、流程提前配好,老板直接开业

0.3 术语速查表(先混个眼熟)

名词人话解释
框架(Framework)别人写好的「半成品程序」,把重复劳动都做完,你只填业务逻辑。用了框架 = 站在巨人肩膀上
注解(@Xxx)贴在类/方法上的「标签」,框架看到标签就知道该干什么。如 @Service 表示「这是个业务类,请管理它」
依赖(Dependency)你项目用到的别人写的库(jar 包)。比如连 MySQL 需要 MySQL 驱动
Maven / pom.xmlJava 的「依赖管家」:在 pom.xml 写一行坐标,自动下载并管理所有 jar 包及版本
Bean被 Spring 容器创建并保管的对象。你可以理解为「有编制的员工」
IoC 容器Spring 内部存放、管理 Bean 的「大仓库」
ORM对象关系映射:Java 对象 ↔ 数据库表记录的自动转换技术。MyBatis 属于半自动 ORM
RESTful一种接口设计风格:URL 用名词、HTTP 动词表操作(GET 查、POST 增、PUT 改、DELETE 删)
JSON前后端之间传数据的主流文本格式,形如 {"name":"tom","age":18}
YAML / yml用缩进表示层级的配置文件格式,Spring Boot 的标配(application.yml)

0.4 环境搭建(一次性工作,约 30 分钟)

工具版本获取方式
JDK17(LTS)或更高Adoptium 官网下载 Temurin 17,安装时勾选「添加到 PATH」
IntelliJ IDEACommunity 免费版即可jetbrains.com 下载,开发 Java 的主流编辑器
MySQL8.x官网或 phpstudy 等集成包;安装时牢记 root 密码
MavenIDEA 自带不用单独安装(国内必须换阿里云镜像,否则下载依赖极慢,配置见下方「常见报错表」)
Postman / curl任意测试接口用;浏览器只能发 GET 请求,学 POST/PUT/DELETE 需要它
⚠️ 版本红线Spring Boot 3.x / 4.x 一律要求 JDK 17+。网上老教程常见的 JDK 8 + Spring Boot 2.x 组合语法 95% 相同,但请勿混用版本——报错了先查版本匹配。
版本时效提醒(2026-09):Spring Boot 3.5 及更早版本的开源维护已于 2026 年 6 月全部结束,不再有免费安全补丁,新项目直接用 4.x。本教程示例基于 4.1.x,你用 Spring Initializr 生成的一般会更新,以生成结果为准。

装好后打开终端(Windows 用 PowerShell / CMD)验证:

java -version    # 期望输出: openjdk version "17.x.x" 或更高
mysql --version  # 期望输出: mysql Ver 8.4.xx(8.4 是当前 LTS;8.0 亦可)

两条命令都能出版本号,环境就绪。以后每次写完代码,都是「IDEA 里点运行」+「Postman 里发请求」的循环,不再碰命令行也没关系。

✍️ 自测:Spring Boot 和 Spring MVC 是什么关系?

答:Spring MVC 是 Spring 官方家族里的 Web 模块(负责 HTTP 请求处理),Spring Boot 是搭建在 Spring 之上的快速开发脚手架。用 Spring Boot 时默认就集成了 Spring MVC——所以第 4 章学的注解,在第 7 章的 Boot 项目里直接就能用。

第 1 章Java 基础快速回顾(衔接章)

Java 忘了大半?没关系——本章只回顾本课程会用到的部分,半小时捡回来。每个知识点都标注了「它会在后续哪一章再次出现」,学起来有目标。Java 还热乎的读者可直接跳去第 2 章。

1.1 类与 main 方法:一切代码的起点

Java 的所有代码都住在「类」里,程序从 main 方法开始执行:

public class Hello {
    public static void main(String[] args) {     // 程序入口(固定写法,先背下来)
        System.out.println("Hello, Java!");       // 向控制台打印一行
    }
}

三件事记住:① 每个源文件叫 类名.java;② 语句以分号 ; 结尾;③ 代码块用大括号 { } 包住。在 IDEA 新建 Java 项目、粘贴、点绿色三角就能运行。

📌 伏笔第 6 章的 Spring Boot 项目也有一个一模一样的 main 方法——只不过那里面写的是「启动整个 Web 网站」。

1.2 变量与常用类型

int age = 18;                    // 整数
long id = 100000000L;            // 长整数(数据库主键常用,结尾加 L)
double price = 9.9;              // 小数
boolean vip = true;              // 真 / 假
String name = "tom";             // 字符串(注意大写 S,它是个对象)

var message = "你好";            // var:让编译器自己推断类型(JDK 10+)

字符串的常用操作:拼接用 +;取长度 name.length();判空 name.isBlank()。比较字符串内容必须用 name.equals("tom"),不要用 ==(== 比较的是「是否同一个对象」而不是「内容是否相同」,这是新手经典错误)。

1.3 方法:把逻辑装进可复用的盒子

public class MathUtil {
    // 访问修饰符  返回值类型  方法名(参数类型 参数名)
    public static int add(int a, int b) {
        return a + b;              // return:交回结果,方法结束
    }

    public static void greet(String name) {   // void:不返回任何东西
        System.out.println("你好, " + name);
    }
}

int sum = MathUtil.add(1, 2);      // 调用:类名.方法名(参数)

第 7 章实战里 Service 层的每个方法,都是这个套路,只是「盒子」里装的是业务逻辑。

1.4 类与对象:图纸与房子

类(class)是图纸,对象(object)是按图纸造出来的房子。类定义「有哪些字段(属性)和哪些行为(方法)」,new 一次就造出一个对象:

类:User(图纸) 字段:id · username 方法:getId() · getUsername() 只定义"长什么样",不存具体数据 对象 u1(new User(1,"tom")) id=1, username="tom" 对象 u2(new User(2,"jerry")) id=2, username="jerry" new 一次造一个 数据各存各的

图 1-1 · 类是图纸,对象是房子:一张图纸可以造无数房子,每栋房子的数据各自独立

public class User {
    private Long id;                  // 字段(private:只有本类能直接访问)
    private String username;

    // 构造方法:方法名 = 类名,new 的时候执行
    public User(Long id, String username) {
        this.id = id;                 // this.id 是字段,id 是参数
        this.username = username;
    }

    // getter/setter:外界读取/修改私有字段的入口
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
}

User u = new User(1L, "tom");    // 创建对象
String name = u.getUsername();   // 调用对象的方法
📌 伏笔第 7 章的实体类 User 就是「字段 + 构造器 + getter/setter」三件套,和数据库表一一对应。

1.5 包(package):代码的文件夹

package com.example.demo.entity;    // 必须是文件第一行:声明"我住在哪个包"

import java.util.List;              // 用别的包里的类,需要 import 引入

包和文件夹一一对应:com.example.demo.entity.User 对应 com/example/demo/entity/User.java。行业惯例用「域名倒写」开头(如 com.example),保证全球唯一。

⚠️ 重要伏笔Spring Boot 只扫描「启动类所在包及其子包」。启动类住在 com.example.demo,你的代码就必须住在它的子包里(...demo.controller 等)——第 3 章和第 6 章会反复撞见这条规则。

1.6 接口与实现类:只认约定,不认品牌

接口(interface)只声明「能做什么」,实现类(implements)回答「具体怎么做」。这是 Spring 全部魔法的地基:

接口:Notifier void send(String message) ← 只说"能做什么" 实现类:PhoneNotifier 发短信的实现 实现类:EmailNotifier 发邮件的实现 implements 实现 implements 实现

图 1-2 · 接口是「插座面板」(约定),实现类是「不同品牌的插头」(做法)——用插座的人不关心插头是谁造的

public interface Notifier {
    void send(String message);          // 接口方法没有方法体,分号结尾
}

public class PhoneNotifier implements Notifier {
    @Override                           // 标记"我在实现/重写",写错方法名编译器会报错
    public void send(String message) {
        System.out.println("发送短信: " + message);
    }
}

Notifier n = new PhoneNotifier();       // 关键!用接口类型接收实现对象
n.send("订单已创建");                    // 调的是 PhoneNotifier 的实现
📌 伏笔第 3 章的依赖注入——「你声明要一个 Notifier,Spring 自动塞给你一个实现」——就是这 4 行代码的工程化放大。

1.7 继承与多态(了解即可)

public class Animal {
    public void eat() { System.out.println("吃东西"); }
}

public class Dog extends Animal {           // Dog 是一种 Animal(is-a 关系)
    @Override
    public void eat() { System.out.println("狗啃骨头"); }   // 重写父类方法
}

Animal a = new Dog();
a.eat();    // 打印"狗啃骨头"——看实际类型,不看变量类型,这就是多态

Spring 里「声明接口、运行时换实现」本质上就是多态在框架层面的应用。

1.8 集合:List 与 Map

List<String> names = new ArrayList<>();   // 列表:按顺序存放
names.add("tom");                          // 添加
names.add("jerry");
names.get(0);                              // 按下标取(从 0 开始)
names.size();                              // 元素个数

for (String name : names) {                // 增强 for 遍历
    System.out.println(name);
}

Map<Long, String> userNames = new HashMap<>();   // 键值对:按"键"存取
userNames.put(1L, "tom");
userNames.get(1L);                         // "tom"
📌 伏笔第 5 章查数据库一次查一批,返回的就是 List<User>。

1.9 泛型:尖括号里的类型约束

<User> 的意思是「这个列表只装 User」,装错类型编译期直接报错,把错误拦在运行之前:

List<User> users = new ArrayList<>();
users.add("tom");        // ❌ 编译报错:只能装 User

// 泛型参数 T 是"占位符",用的时候才确定:
Result<User> r1 = Result.ok(user);            // data 是单个用户
Result<List<User>> r2 = Result.ok(users);     // data 是用户列表
📌 伏笔第 7 章统一返回格式 Result<T> 的尖括号就是泛型——同一个 Result 类,既能包 User 也能包 List。

1.10 注解:贴给框架看的标签

你已经见过两次 @Override 了。注解(@Xxx)本身不执行任何逻辑,它是贴在类/方法/字段上的标签——框架在运行期读取标签,然后替你干活:

@Override                    // 内置注解:帮编译器检查"确实重写了父类方法"
public String toString() { return "User"; }

@Service                     // Spring 的注解(第 3 章起):贴上它,Spring 就接管这个类
public class UserService { }
💡 心法以后看到任何 @ 开头的东西,就问一句:「这个标签是谁在读?读完它会替我做什么?」——这是理解所有框架注解的万能钥匙。

1.11 异常:出错的正确姿势

try {
    int n = Integer.parseInt("abc");       // 这行会出错
} catch (NumberFormatException e) {        // 捕获并处理
    System.out.println("不是合法数字: " + e.getMessage());
} finally {
    System.out.println("无论成败都执行");     // 常用于关闭资源
}

public User getById(Long id) {
    if (id == null) {
        throw new RuntimeException("id 不能为空");   // 主动抛出异常
    }
    // ...
}

两类异常记住一句话就够:RuntimeException(运行时异常)可以不强制处理,一路往上抛——第 4 章的全局异常处理就是「在最高处统一接住它们,转成友好提示」。

1.12 Lambda 与 Stream(能看懂即可)

// Lambda:一句话的匿名方法,常作为参数传递
names.forEach(name -> System.out.println(name));

// Stream:链式数据处理(过滤 + 收集)
List<User> adults = users.stream()
        .filter(u -> u.getAge() >= 18)     // 只留成年用户
        .collect(Collectors.toList());

现代 Java 代码和面试都常见这套写法,看懂即可,本教程代码刻意没用它。

1.13 Java 知识 → 后续章节 对照表

Java 知识在哪里再次出现
类与对象第 7 章实体类 User(与数据库表一一对应)
方法第 7 章 Service 层(一个方法一段业务)
包与 import第 3、6 章 包扫描规则(启动类必须在上层)
接口与多态第 3 章 依赖注入(声明接口,容器塞实现)
List 与泛型第 5 章查询返回 List<User>;第 7 章 Result<T>
注解第 3 章起无处不在(@Service、@Mapper、@GetMapping…)
异常第 4 章全局异常处理(统一接住 RuntimeException)
Lambda / Stream阅读他人代码、面试必备
⚠️ 本章是「够用就好」的回顾如果读起来仍然很吃力(比如不清楚什么是变量),建议先去完整过一遍 Java 入门(B 站搜「Java 基础」任选口碑好的,学完类/对象/接口/集合即可,约 1~2 周),再回来继续——磨刀不误砍柴工。
✍️ 自测:接口和实现类是什么关系?为什么「用接口类型接收实现对象」很有用?

答:接口声明能力(做什么),实现类提供具体做法(怎么做)。用接口类型接收实现对象后,代码依赖的是「约定」而非「具体品牌」——以后换实现(短信换邮件),调用代码一行不用改。这正是第 3 章 Spring 依赖注入的解耦原理。

✍️ 自测:为什么字符串比较必须用 equals,不能用 ==?

答:== 比较的是两个变量是否指向同一个对象(内存地址),equals 比较的是内容。两个内容相同的字符串可能是不同对象,用 == 会误判为「不相等」。

✍️ 自测:package 声明和磁盘上的文件夹是什么关系?

答:一一对应。package com.example.demo.entity 意味着这个文件必须放在 com/example/demo/entity/ 目录下。所以「Spring 只扫描启动类所在包及子包」这条规则,落到磁盘上就是「必须放在启动类所在文件夹及其子文件夹里」。

第 2 章为什么需要框架?

理解框架最好的方式:先看看没有框架时,Java 程序员是怎么「受苦」的。

2.1 痛苦一:对象靠手 new,改一处动全身

写一个电商下单功能,最直觉的写法是「要用谁就 new 谁」:

public class OrderService {
    private UserService userService = new UserService();   // 自己 new

    public void createOrder() {
        userService.checkUser();
    }
}

public class UserService {
    private UserDao userDao = new UserDao();               // 又自己 new
}

public class UserDao {
    private DbUtil dbUtil = new DbUtil();                  // 还是自己 new
}

小项目没问题,项目一大就会撞上三堵墙:

  1. 牵一发动全身——UserDao 的构造方法加个参数,全项目所有 new UserDao() 的地方都得改;
  2. 资源浪费——每个对象都是新的,无法复用。数据库连接池这种「全局只要一份」的东西,自己写单例模式非常费劲;
  3. 没法测试——想测 OrderService,它内部写死了 new UserService(),想换成假对象(Mock)都插不进手。

根源在于:每个类都亲手创建自己的依赖,类和类焊死在一起,这叫「强耦合」。

2.2 痛苦二:手写 Servlet,接个表单能写到怀疑人生

Java 最原始的 Web 开发方式叫 Servlet。接收一个注册表单:

public class UserServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // 1. 手动一个个取参数
        String username = req.getParameter("username");
        String ageStr   = req.getParameter("age");
        // 2. 手动转类型
        int age = Integer.parseInt(ageStr);
        // 3. 手动组装对象
        User user = new User(username, age);
        // 4. 手动把对象变 JSON 写回响应(还要引库、拼字符串)
        resp.setContentType("application/json;charset=utf-8");
        resp.getWriter().write("{\"username\":\"" + user.getUsername() + "\"}");
    }
}

这还没完——还要去 web.xml 里注册这个 Servlet、配置 URL 映射。表单一大(注册 20 个字段很常见),上面的「取参数 → 转类型 → 组对象」循环要复制粘贴 20 遍。而你真正想写的业务逻辑,可能就一行。

2.3 痛苦三:手写 JDBC,查一张表 10 行起步

Java 原生操作数据库的 API 叫 JDBC:

public User findById(Long id) throws SQLException {
    Connection conn = null; PreparedStatement ps = null; ResultSet rs = null;
    try {
        // 1. 获取连接  2. 写 SQL  3. 填参数
        conn = DriverManager.getConnection(url, user, password);
        ps = conn.prepareStatement("SELECT id, username FROM users WHERE id = ?");
        ps.setLong(1, id);
        rs = ps.executeQuery();
        // 4. 手动把每列数据取出来塞进对象
        User u = null;
        if (rs.next()) {
            u = new User();
            u.setId(rs.getLong("id"));
            u.setUsername(rs.getString("username"));
        }
        return u;
    } finally {
        // 5. 手动关闭资源(顺序还不能错,忘了关就泄漏连接)
        rs.close(); ps.close(); conn.close();
    }
}

真正有信息量的只有那一句 SQL,其余全是模板代码。表有 20 个字段,就得写 20 行 rs.getXxx + setXxx。SQL 写错只能等运行时爆错。

2.4 四个框架 = 四副解药

痛点解药一句话原理
对象乱 new、强耦合Spring对象交给容器统一创建、保管、复用,谁需要谁伸手
Servlet 取参/转类型/拼 JSON 啰嗦Spring MVC方法参数直接接收请求数据,返回对象自动变 JSON
JDBC 样板代码堆成山MyBatis你只写 SQL,连接/参数/结果映射/关资源全自动
三者整合要写几十个 XML 配置Spring Boot自动配置 + 内嵌服务器,一个 main 方法启动全项目
📌 学习建议这一章的「痛苦」不需要你会写,只需要记住疼在哪。后面每学一个框架,回头对照一次——「原来它是来解这个疼的」,理解会深刻得多。
✍️ 自测:Spring 解决的是下面哪个问题?A. 界面好看 B. 对象管理 C. SQL 优化 D. 网速快

答:B。Spring 的核心是 IoC 容器——统一创建和管理对象。SQL 由 MyBatis 负责,Web 请求由 Spring MVC 负责。

第 3 章Spring:对象管家(IoC 与 DI)

Spring 的全部核心就两个词:IoC(控制反转)与 DI(依赖注入)。唬人的名词,朴素的道理。

3.1 IoC:把「new 对象」的权力交出去

IoC(Inversion of Control,控制反转):对象的创建权从「你手里」反转到「Spring 手里」。

  • 以前:UserService userService = new UserService(); —— 你自己造人;
  • 现在:Spring 启动时把对象创建好,放进一个「大仓库」(IoC 容器),你要用就伸手要。

容器里这些被管理的对象有个专有名词:Bean。

传统方式:对象自己 new OrderService new UserService new UserDao 类和类焊死:改一处,处处改 IoC Spring 方式:容器统一管理 Spring IoC 容器(大仓库) OrderService ✔ UserService ✔ UserDao ✔ 由容器创建、装配、复用(默认单例) 谁需要什么,容器「注入」给谁

图 3-1 · 控制反转前后对比:创建权从各个类手里「反转」到容器手里

3.2 把类登记进容器:四个「身份注解」

怎么告诉 Spring「这个类请帮我管理」?在类上贴注解即可:

@Component            // 通用组件:凡是想被管理的类都可以贴它
public class PhoneNotifier { /* ... */ }

@Service              // 业务层专用(语义更清晰,效果同 Component)
public class OrderService { /* ... */ }

@Repository           // 数据层专用
public class UserDao { /* ... */ }

@Controller           // Web 层专用(下一章的主角)
public class UserController { /* ... */ }
注解用在哪个层说明
@Component任意通用「零件」,不属于下面三层的工具类用它
@Service业务层本质就是 @Component 的别名,团队一看注解就知道类的作用
@Repository数据层额外会把数据库异常转换为统一异常
@ControllerWeb 层配合 @ResponseBody 使用(第 3 章)
⚠️ 新手第一大坑:包扫描范围Spring 只会扫描「启动类所在包及其子包」下的注解。如果启动类在 com.example.demo,而你的类写在 com.myapp 下,Spring 看都看不见。所以启动类永远放在所有代码的最上层包。

3.3 DI:需要什么,Spring 自动塞给你

DI(Dependency Injection,依赖注入)是 IoC 的具体实现方式:类只「声明」自己需要什么,对象由容器塞(注入)进来。

@Service
public class OrderService {

    private final PhoneNotifier notifier;   // final:注入后不许换

    // 构造器注入(推荐写法!):Spring 自动把容器里的 PhoneNotifier 传进来
    public OrderService(PhoneNotifier notifier) {
        this.notifier = notifier;
    }

    public void createOrder() {
        notifier.send("订单已创建");
    }
}

注意你没有写任何 new——OrderService 被 Spring 创建时,Spring 发现构造器需要一个 PhoneNotifier,就先去容器找(没有就先创建一个),然后传进来。整条链路全自动。

DI 一共有三种写法:

方式写法评价
构造器注入构造方法参数✅ 推荐:字段可 final、依赖一目了然、方便单元测试
Setter 注入setXxx 方法 + @Autowired用于「可选依赖」,少见
字段注入@Autowired private PhoneNotifier notifier;写法最短但缺陷多:不能 final、藏起依赖、难测试。新项目别用

3.4 解耦的威力:面向接口编程

依赖注入真正的威力,要配合「接口」才能发挥:

public interface Notifier {                    // 1. 先定义"通知"这个能力
    void send(String message);
}

@Component
public class PhoneNotifier implements Notifier {   // 2. 手机短信实现
    public void send(String message) { /* 调短信接口 */ }
}

@Service
public class OrderService {
    private final Notifier notifier;           // 3. 依赖的是接口,不是具体类!
    public OrderService(Notifier notifier) {
        this.notifier = notifier;
    }
}

哪天产品要求改成「邮件通知」,你只需新写一个 EmailNotifier implements Notifier 并让它成为首选 Bean(加 @Primary 注解),OrderService 一行代码都不用动——因为它依赖的是「通知」这个抽象能力,而不是某个具体实现。这就是传说中的解耦:组件像乐高积木,可插拔。

💡 记忆口诀「你声明,我注入」——你只声明需要什么(构造器参数),Spring 负责创建和传递。接口是「声明」的最佳载体。

3.5 再进阶一步(了解即可)

  • 配置类与 @Bean:第三方库的类你改不了源码、贴不了注解,可以在配置类里用方法造 Bean:@Bean public RestTemplate restTemplate(){ return new RestTemplate(); };
  • 作用域:默认所有 Bean 是单例(全容器一份,大家共用)。加 @Scope("prototype") 可改成每次要新的。日常开发用默认即可;
  • 生命周期:Bean 从「实例化 → 注入依赖 → 初始化 → 使用 → 销毁」全程由容器照管,可用 @PostConstruct 在初始化后插一段自己的逻辑(比如启动时加载缓存)。导入注意:Spring Boot 3 起这个注解属于 Jakarta EE,必须 import jakarta.annotation.PostConstruct;;网上老代码里的 javax.annotation.* 已整体改名为 jakarta.annotation.*,照抄会直接编译失败——这就是「新旧教程混用」最常见的坑。
✍️ 自测:@Service 和 @Component 有什么区别?

答:功能上没有区别(@Service 本质就是 @Component 的语义化别名),区别在「语义」:@Service 告诉团队成员「这是业务逻辑类」,让代码结构一眼可读。同理还有 @Repository(数据层)、@Controller(Web 层)。

✍️ 自测:为什么构造器注入优于字段注入?

答:三个理由:① 字段可以声明成 final,保证依赖不被篡改;② 依赖关系在构造器签名里一目了然(一眼看出这个类需要什么);③ 单元测试时可以直接 new OrderService(假Notifier),不需要启动 Spring 容器。

第 4 章Spring MVC:Web 前台

用户在浏览器敲下一个网址,请求是怎么「精准送达」你写的某段代码的?本章揭晓。

4.1 一次 HTTP 请求的完整旅程

Spring MVC 的核心是一个叫 DispatcherServlet(前端控制器)的组件——所有请求先到它这里报到,它像前台总机一样查表分发。下面的图值得反复看,它就是整个 Java Web 的骨架:

浏览器 Tomcat(Boot 内嵌) DispatcherServlet 总调度 UserController UserService UserMapper(MyBatis)→ MySQL ① 发起 GET /users/1 ② HTTP 请求进入服务器 ③ 查路由表:该交给谁? ④ 执行你写的方法 ⑤ 处理业务逻辑 ⑥ 生成并执行 SQL ⑦ 查询结果沿原路返回 → 拼成 JSON → 浏览器

图 4-1 · 一个请求的往返全程:去时逐层下钻,回时原路逆袭

📌 你要写 vs 框架替你写整条链路里,你只需要写 Controller、Service、Mapper 三层自己的代码。Tomcat、DispatcherServlet、JSON 转换、SQL 执行——全部由框架自动完成。

4.2 写出第一个接口

@RestController                    // 声明:这是接口类,返回值自动转 JSON
public class HelloController {

    @GetMapping("/hello")          // 处理 GET 请求,路径 /hello
    public String hello() {
        return "Hello, Spring MVC!";
    }
}

项目运行后,浏览器访问 http://localhost:8080/hello,页面显示那句话。对比第 2 章的 Servlet 写法:十几行配置和样板代码,浓缩成了两个注解。

  • @RestController = @Controller(我是 Web 层)+ @ResponseBody(我的返回值不是网页,直接写进响应体,转成 JSON);
  • @GetMapping("/hello") = 把「GET 请求 + /hello 路径」绑定到下面这个方法。DispatcherServlet 就是靠这些登记信息建「路由表」的。

4.3 RESTful:一套通用的接口设计规范

企业开发普遍遵循 RESTful 风格——URL 只用名词表示资源,用 HTTP 动词表示操作。以「用户」资源为例:

操作请求方式URL说明
查列表GET/users分页参数放问号后:/users?page=1&size=10
查单个GET/users/11 是路径参数
新增POST/users数据放请求体(JSON)
整体修改PUT/users/1数据放请求体
删除DELETE/users/1一般只带 id

对应的 Controller 长这样(这是你会写一辈子的模板):

@RestController
@RequestMapping("/users")              // 类级前缀:下面所有接口都以 /users 开头
public class UserController {

    private final UserService userService;
    public UserController(UserService userService) {   // 构造器注入(第 2 章)
        this.userService = userService;
    }

    @GetMapping                        // GET /users
    public List<User> list() { return userService.list(); }

    @GetMapping("/{id}")               // GET /users/1
    public User getById(@PathVariable Long id) { return userService.getById(id); }

    @PostMapping                       // POST /users
    public User create(@RequestBody User user) { return userService.create(user); }

    @PutMapping("/{id}")               // PUT /users/1
    public User update(@PathVariable Long id, @RequestBody User user) {
        return userService.update(id, user);
    }

    @DeleteMapping("/{id}")            // DELETE /users/1
    public void delete(@PathVariable Long id) { userService.delete(id); }
}

4.4 取参数三件套(重点!)

HTTP 请求里的数据藏在三个地方,各有对应的注解:

数据位置例子注解
URL 路径里/users/1@PathVariable
问号后查询串/users/search?keyword=tom&page=2@RequestParam
请求体(JSON)POST/PUT 的 body@RequestBody
请求头Authorization: xxx@RequestHeader(登录令牌常用)
@GetMapping("/search")
public List<User> search(
        @RequestParam String keyword,                          // 必填
        @RequestParam(defaultValue = "1") Integer page) {      // 可选,给默认值
    return userService.search(keyword, page);
}

@PostMapping("/register")
public Result register(@RequestBody RegisterForm form) {
    // 前端发的 {"username":"tom","age":18} 自动变成 form 对象
    return userService.register(form);
}
💡 魔法在哪?Servlet 时代要 req.getParameter + 手动转类型 + 手动 new 对象;现在 Spring MVC 自动完成「类型转换 + 对象组装」。字段类型不匹配或 JSON 格式错误时,框架还会自动返回 400 响应。

4.5 返回 JSON 与统一返回格式

方法返回对象时,Spring MVC 自动用 Jackson 库把它序列化成 JSON。企业项目通常再包一层「统一返回格式」,让前端永远拿到同样的结构:

public class Result<T> {          // T 表示"泛型":data 里装什么类型,用的时候再定
    private int code;             // 200=成功,400=客户端错,500=服务器错
    private String message;       // 提示信息
    private T data;               // 真正的数据

    public static <T> Result<T> ok(T data) {
        Result<T> r = new Result<T>();
        r.code = 200; r.message = "success"; r.data = data;
        return r;
    }
    public static <T> Result<T> error(int code, String message) {
        Result<T> r = new Result<T>();
        r.code = code; r.message = message;
        return r;
    }
    // getter/setter 省略(IDEA 右键 Generate 可自动生成)
}

返回给前端的 JSON 长这样:

{ "code": 200, "message": "success", "data": { "id": 1, "username": "tom" } }

4.6 全局异常处理:try-catch 只写一次

业务代码里到处 try-catch 很难看。Spring MVC 提供 @RestControllerAdvice:全项目的异常都汇到这里统一处理:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)      // 捕获所有运行时异常
    public Result<Void> handleBiz(RuntimeException e) {
        return Result.error(400, e.getMessage());  // 业务错误 → 400 + 原因
    }

    @ExceptionHandler(Exception.class)             // 兜底:其他所有异常
    public Result<Void> handleOther(Exception e) {
        return Result.error(500, "服务器开小差了,请稍后再试");
    }
}

之后 Service 里只管 throw new RuntimeException("用户名已存在"),接口会自动返回 {"code":400,"message":"用户名已存在"}。Controller、Service 层再也不用写 try-catch。

4.7 拦截器:请求的「安检门」(了解)

想在请求到达 Controller 之前统一做检查(比如登录校验)?用拦截器:

@Component
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
        String token = req.getHeader("Authorization");
        if (token == null || token.isBlank()) {
            resp.setStatus(401);       // 未登录
            return false;              // false = 拦下,请求到此为止
        }
        return true;                   // true = 放行
    }
}

注册后,所有被拦截路径的请求都要先过这道安检门。学完第 9 章的 JWT 后,你会把它升级成真正的登录系统。

4.8 三层架构:项目的标准结构

Controller 控制层 接待请求 · 校验参数 · 调用 Service · 返回结果(不写业务!) Service 业务层 业务规则都在这:如"下单 = 扣库存 + 生成订单 + 发通知" Mapper / DAO 持久层 只管数据库读写:一条方法对应一条 SQL 只能调用下一层 ↘ 不能跨层跳 ↘

图 4-2 · 三层架构:职责分离,只能自上而下逐层调用

为什么分三层?换前端不用动业务、改业务不用动数据库、每层可单独测试。第 7 章实战将严格按这个结构写。

✍️ 自测:GET /users/1 里的「1」、?page=2 里的「2」、POST 请求体里的 JSON,分别用哪个注解接收?

答:路径里的 1 → @PathVariable Long id;查询串里的 2 → @RequestParam Integer page;请求体 JSON → @RequestBody User user。

✍️ 自测:@RestController 和 @Controller 的区别?

答:@Controller 是传统写法,方法返回值通常被解析成页面路径;@RestController = @Controller + @ResponseBody,每个方法的返回值都直接序列化成 JSON 写进响应体。前后端分离项目一律用 @RestController。

第 5 章MyBatis:数据库工具

会写 SQL 就会用 MyBatis——它把 JDBC 的脏活累活全包了。

5.1 MyBatis 帮你干了什么

回顾第 2 章的 JDBC 代码:获取连接、预编译、填参数、取结果、关资源,五步里四步是模板。MyBatis 的分工是:

你调用接口方法 userMapper.selectById(1) MyBatis 拼 SQL 找到 SQL 模板,把 1 安全替换 #{id} MySQL 执行 返回 ResultSet 自动映射成对象 列名 → 字段名 逐列填入 User 你写的(1 行) MyBatis 自动完成(连接、参数、映射、关资源全包)

图 5-1 · MyBatis 执行流程:你只负责「调用方法」,其余全自动

MyBatis 被称为「半自动 ORM 框架」:SQL 由你亲手写(可控、可优化、DBA 能审),执行和「记录 ↔ 对象」的映射由它包办。相比之下 JPA/Hibernate 是「全自动」(连 SQL 都帮你生成)。国内企业 MyBatis 是绝对主流。

5.2 注解版:一个接口搞定一张表的 CRUD

@Mapper                                   // 告诉 MyBatis:为这个接口生成实现类
public interface UserMapper {

    @Select("SELECT id, username, age, email, create_time FROM users WHERE id = #{id}")
    User selectById(Long id);             // 结果自动装进 User 对象!

    @Select("SELECT id, username, age, email, create_time FROM users ORDER BY id DESC")
    List<User> selectAll();

    @Insert("INSERT INTO users(username, age, email) VALUES(#{username}, #{age}, #{email})")
    @Options(useGeneratedKeys = true, keyProperty = "id")   // 自增 id 回填到 user.id
    int insert(User user);                // #{username} 自动从 user 对象取同名字段

    @Update("UPDATE users SET username = #{username}, age = #{age} WHERE id = #{id}")
    int update(User user);

    @Delete("DELETE FROM users WHERE id = #{id}")
    int deleteById(Long id);
}

注意:你没有写实现类!调用 userMapper.selectById(1L) 时,MyBatis 在运行期动态生成实现:拼 SQL → 执行 → 按列名把值填进 User 对象 → 返回。条件就一个:实体类字段名和数据库列名能对上(对不上的见 5.5 节)。

5.3 #{} 与 ${}:必考的安全题

这两个占位符长得像,安全性天差地别:

#{} —— 预编译,安全 ✅ 用户输入: tom' OR '1'='1 实际 SQL: WHERE username = ? 参数单独传输:"tom' OR '1'='1" → 它只是一段普通文字,被当名字去查 结果:查无此人,攻击失败 对应 JDBC 的 PreparedStatement ${} —— 字符串拼接,危险 ❌ 用户输入: tom' OR '1'='1 拼接后: WHERE username = 'tom' OR '1'='1' → OR '1'='1' 恒成立,条件被绕过! 更狠的: '; DROP TABLE users; -- 结果:数据全泄 / 表被删 这就是「SQL 注入攻击」

图 5-2 · 同样的恶意输入,#{} 挡住攻击,${} 引狼入室

⚠️ 铁律99% 的场景用 #{}。只有 ORDER BY 列名等「不能加引号的位置」才用 ${},且必须用白名单校验(先判断输入是否在允许的列名集合里)。

5.4 动态 SQL:传了就查,不传就跳过

真实需求经常是「多个筛选条件,填哪个查哪个」。这种 SQL 没法用注解优雅表达,改用 XML 写法。先在 application.yml 声明 XML 存放位置:

mybatis:
  mapper-locations: classpath:mapper/*.xml    # 扫描 resources/mapper 下的所有 xml
  configuration:
    map-underscore-to-camel-case: true        # 下划线列名 ↔ 驼峰字段名自动互转

然后在 resources/mapper/UserMapper.xml 写动态 SQL(接口里放对应方法声明,方法名 = 标签 id):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.UserMapper">   <!-- 绑定到哪个接口 -->

    <select id="search" resultType="com.example.demo.entity.User">
        SELECT id, username, age, email FROM users
        <where>                                              <!-- 智能处理开头的 AND -->
            <if test="keyword != null and keyword != ''">     <!-- 传了才拼这句 -->
                AND username LIKE CONCAT('%', #{keyword}, '%')
            </if>
            <if test="minAge != null">
                AND age &gt;= #{minAge}
            </if>
        </where>
        ORDER BY id DESC
    </select>

</mapper>

常用动态标签一览:

标签用途例子
<if>条件成立才拼接上面的 keyword 判断
<where>自动补 WHERE、删掉开头多余的 AND/OR配合多个 if
<set>智能 UPDATE:自动补 SET、去掉末尾多余逗号「只更新传了的字段」
<foreach>遍历集合WHERE id IN (1,2,3)、批量插入
<choose>/<when>/<otherwise>多选一(相当于 switch)不同角色走不同查询
💡 选择建议简单 CRUD 用注解(一眼看到 SQL);带 <if> 等动态逻辑的用 XML。两者可在同一个 Mapper 里混用。

5.5 列名与字段名对不上怎么办

数据库习惯用下划线命名(create_time),Java 习惯驼峰(createTime)。两种解法:

  • 推荐:开配置 map-underscore-to-camel-case: true(见 5.4),全局自动互转,一劳永逸;
  • 手动:复杂映射(如多表联查、字段名完全不同)用 <resultMap> 显式声明「哪列装进哪个字段」。
<resultMap id="userMap" type="com.example.demo.entity.User">
    <id property="id" column="id"/>
    <result property="createTime" column="create_time"/>
    <result property="username" column="username"/>
</resultMap>

<select id="selectById" resultMap="userMap">
    SELECT * FROM users WHERE id = #{id}
</select>

5.6 分页:PageHelper 两行搞定

列表页都要分页。引入 PageHelper 插件后,任何查询前加一行代码即自动分页:

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>2.1.0</version>   <!-- 2.x 适配 Spring Boot 3 -->
</dependency>
PageHelper.startPage(pageNum, pageSize);          // 紧跟的第一条查询自动被分页
List<User> users = userMapper.selectAll();
PageInfo<User> info = new PageInfo<>(users);       // 拿到总数、总页数、当前页…
✍️ 自测:下面哪条有 SQL 注入风险?
A. WHERE name = #{name} B. ORDER BY ${column}(column 来自用户输入且未校验)

答:B。${} 是纯字符串拼接,用户传入恶意内容会直接改变 SQL 语义。A 是预编译参数,安全。若必须动态排序列,先校验 column 是否在白名单(如 ["id","age","create_time"])内。

第 6 章Spring Boot:总装车间

前三章的三大件各自好用,但拼装起来曾让无数人掉光头发。Spring Boot 一句「我全包了」。

6.1 没有 Boot 的年代:配置地狱

把 Spring + Spring MVC + MyBatis 整合到一起,老一代程序员要手写几十个 XML,感受一下当年的一部分:

<!-- 这只是当年配置的冰山一角(认识即可,不用学) -->
<bean id="dataSource" class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close">
    <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
    <property name="url" value="jdbc:mysql://localhost:3306/demo"/>
    <property name="username" value="root"/>
    <property name="password" value="123456"/>
</bean>
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
    <property name="basePackage" value="com.example.demo.mapper"/>
</bean>
<!-- 还有 DispatcherServlet 注册、组件扫描、事务管理器、视图解析器…… -->

更要命的是依赖版本:Spring 5.2 要配 MyBatis 3.5.x,log4j 又要排除冲突……新手一半时间耗在「让项目能启动」上。

6.2 Boot 三板斧

① Starter 起步依赖:一个坐标拉一串

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>   <!-- 这一行 = -->
</dependency>
<!-- spring-mvc + jackson(JSON) + 内嵌tomcat + 日志 + ...,版本全部兼容 -->

官方把「做某类事需要的依赖组合」打包成 starter:做 Web 用 starter-web,连数据库用 starter-jdbc,测试用 starter-test。从此告别版本冲突地狱。

② 自动配置:看到什么就配什么

① pom 里的 starter 声明你想做什么 ② classpath 上有什么 检测到 mysql 驱动等 jar ③ application.yml 你的账号密码等个性化配置 Spring Boot 自动配置引擎 @EnableAutoConfiguration 内嵌 Tomcat 起来了 数据源连接池就绪 DispatcherServlet 就绪 MyBatis 映射就绪

图 6-1 · 自动配置:Boot 侦察你的项目,按需把 Bean 全部配好——这就是「约定大于配置」

③ 内嵌服务器:main 方法一跑,网站就通了

以前要单独装 Tomcat、把项目打成 war 扔进去。Boot 把 Tomcat 做成项目的一个依赖,启动类运行即内嵌启动。这也是「为什么你能用 main 方法启动一个网站」的答案。

6.3 启动类:整个项目只有 7 行

@SpringBootApplication   // 三合一:自动配置 + 组件扫描 + Bean 注册
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
⚠️ 再次强调(新手第一坑)@SpringBootApplication 只扫描启动类所在包及子包。启动类必须放在最上层,例如:
com.example.demo ← 启动类在这(最上层) ├── DemoApplication.java ├── controller/ ← 子包,能被扫到 ✓ ├── service/ ├── mapper/ └── entity/

6.4 application.yml:一个文件管所有配置

YAML 用「缩进」表达层级(必须空格,禁止 Tab),比 XML 清爽十倍:

server:
  port: 8080                        # 冒号后必须有一个空格!

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

mybatis:
  mapper-locations: classpath:mapper/*.xml
  configuration:
    map-underscore-to-camel-case: true

常用配置速查:

想干什么配置项
改端口server.port: 9090
连数据库spring.datasource.url / username / password
MyBatis XML 位置mybatis.mapper-locations
驼峰映射mybatis.configuration.map-underscore-to-camel-case
打印 SQL 日志(调试神器)logging.level.com.example.demo.mapper: debug
JSON 日期格式spring.jackson.date-format: yyyy-MM-dd HH:mm:ss

6.5 多环境配置(开发/生产一套代码)

开发用本地库、上线用服务器库?拆成多个文件,激活哪份用哪份:

# application.yml(公共)
spring:
  profiles:
    active: dev          # 当前激活 dev 环境;上线时改成 prod
# application-dev.yml(开发环境)
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/demo?serverTimezone=Asia/Shanghai
    username: root
    password: "123456"
# application-prod.yml(生产环境)
spring:
  datasource:
    url: jdbc:mysql://prod-server:3306/demo?serverTimezone=Asia/Shanghai
    username: app
    password: "生产环境的复杂密码"

6.6 打包部署:一行命令变出一个可执行 jar

mvn package -DskipTests          # 打包(跳过测试)
java -jar target/demo-0.0.1-SNAPSHOT.jar   # 服务器上有 JDK17 即可运行

把这一个 jar 传到云服务器,java -jar 一跑,网站就上线了——这就是 Spring Boot 项目「一个 jar 走天下」的部署方式。还可以加 spring-boot-starter-actuator 依赖获得健康检查等运维接口(/actuator/health)。

✍️ 自测:为什么 Spring Boot 项目没有 web.xml、没有 Tomcat 安装目录也能跑?

答:① starter-web 已把 tomcat 作为依赖打进项目(内嵌容器);② DispatcherServlet 等组件由自动配置机制按约定注册。所以 main 方法一跑,内嵌 Tomcat 随之启动,无需任何外部服务器与 XML。

第 7 章实战:用户管理系统(从零到跑通)

光学不练假把式。本章把前 5 章全部串起来,20 分钟跑通一个完整可用的后端项目。

7.1 需求与接口设计

做一个「用户管理」模块:对 users 表的增删改查,统一返回格式,统一异常处理。接口清单:

功能请求路径成功返回
用户列表GET/usersResult<List<User>>
查单个用户GET/users/{id}Result<User>
新增用户POST/usersResult<User>(含回填的自增 id)
修改用户PUT/users/{id}Result<User>
删除用户DELETE/users/{id}Result<Void>

7.2 第一步:建库建表

CREATE DATABASE demo DEFAULT CHARACTER SET utf8mb4;
USE demo;

CREATE TABLE users (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    username    VARCHAR(50)  NOT NULL,
    age         INT          NULL,
    email       VARCHAR(100) NULL,
    create_time DATETIME     DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users(username, age, email) VALUES
('tom',  18, 'tom@example.com'),
('jerry',22, 'jerry@example.com');

7.3 第二步:创建项目

IDEA → New Project → 选 Spring Initializr(国内慢就用 https://start.aliyun.com):

  1. Language: Java · Type: Maven · JDK: 17 · Group: com.example · Artifact: demo
  2. Dependencies 勾选:Spring Web、MySQL Driver、MyBatis Framework(都在列表里,MyBatis 在 SQL 分类下)

生成的 pom.xml 关键部分(确认这几段在):

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.0</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>4.0.1</version>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

7.4 第三步:项目结构(最终目标)

demo ├── src/main/java/com/example/demo │ ├── DemoApplication.java ← 启动类(Initializr 自动生成) │ ├── controller │ │ └── UserController.java ← ④ 接口层 │ ├── service │ │ └── UserService.java ← ③ 业务层 │ ├── mapper │ │ └── UserMapper.java ← ② 持久层 │ ├── entity │ │ └── User.java ← ① 实体类 │ └── common │ ├── Result.java ← 统一返回 │ └── GlobalExceptionHandler.java ← 全局异常 └── src/main/resources ├── application.yml ← ⑤ 配置文件 └── (mapper/*.xml 本次用注解版,暂不需要)

下面按 ①→⑤ 顺序,把 7 个文件全部写完。

7.5 第四步:写代码(7 个文件)

① entity/User.java —— 实体类,和表一一对应

package com.example.demo.entity;

import java.time.LocalDateTime;

public class User {
    private Long id;
    private String username;
    private Integer age;
    private String email;
    private LocalDateTime createTime;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public Integer getAge() { return age; }
    public void setAge(Integer age) { this.age = age; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public LocalDateTime getCreateTime() { return createTime; }
    public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
}
💡 偷懒神器 Lombok手写 getter/setter 很啰嗦。实战中大家用 Lombok:加依赖后实体类只需一个 @Data 注解,编译期自动生成全部 getter/setter/toString。新手建议先手写一两次理解它是什么,再引入 Lombok。

② mapper/UserMapper.java —— 持久层(第 5 章知识)

package com.example.demo.mapper;

import com.example.demo.entity.User;
import org.apache.ibatis.annotations.*;
import java.util.List;

@Mapper
public interface UserMapper {

    @Select("SELECT id, username, age, email, create_time FROM users WHERE id = #{id}")
    User selectById(Long id);

    @Select("SELECT id, username, age, email, create_time FROM users ORDER BY id DESC")
    List<User> selectAll();

    @Insert("INSERT INTO users(username, age, email) VALUES(#{username}, #{age}, #{email})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insert(User user);

    @Update("UPDATE users SET username = #{username}, age = #{age}, email = #{email} WHERE id = #{id}")
    int update(User user);

    @Delete("DELETE FROM users WHERE id = #{id}")
    int deleteById(Long id);
}

③ service/UserService.java —— 业务层(第 2、3 章知识)

package com.example.demo.service;

import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class UserService {

    private final UserMapper userMapper;

    public UserService(UserMapper userMapper) {   // 构造器注入
        this.userMapper = userMapper;
    }

    public List<User> list() {
        return userMapper.selectAll();
    }

    public User getById(Long id) {
        User user = userMapper.selectById(id);
        if (user == null) {
            throw new RuntimeException("用户不存在: id=" + id);  // 交给全局异常处理
        }
        return user;
    }

    public User create(User user) {
        if (user.getUsername() == null || user.getUsername().isBlank()) {
            throw new RuntimeException("用户名不能为空");
        }
        userMapper.insert(user);      // 自增 id 已回填到 user.id
        return user;
    }

    public User update(Long id, User user) {
        getById(id);                  // 先确认存在,不存在会抛异常
        user.setId(id);
        userMapper.update(user);
        return userMapper.selectById(id);
    }

    public void delete(Long id) {
        getById(id);
        userMapper.deleteById(id);
    }
}

④ controller/UserController.java —— 接口层(第 4 章知识)

package com.example.demo.controller;

import com.example.demo.common.Result;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public Result<List<User>> list() {
        return Result.ok(userService.list());
    }

    @GetMapping("/{id}")
    public Result<User> getById(@PathVariable Long id) {
        return Result.ok(userService.getById(id));
    }

    @PostMapping
    public Result<User> create(@RequestBody User user) {
        return Result.ok(userService.create(user));
    }

    @PutMapping("/{id}")
    public Result<User> update(@PathVariable Long id, @RequestBody User user) {
        return Result.ok(userService.update(id, user));
    }

    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable Long id) {
        userService.delete(id);
        return Result.ok(null);
    }
}

⑤⑥ common/Result.java 与 GlobalExceptionHandler.java(第 4 章知识)

package com.example.demo.common;

public class Result<T> {
    private int code;
    private String message;
    private T data;

    public static <T> Result<T> ok(T data) {
        Result<T> r = new Result<T>();
        r.code = 200; r.message = "success"; r.data = data;
        return r;
    }
    public static <T> Result<T> error(int code, String message) {
        Result<T> r = new Result<T>();
        r.code = code; r.message = message;
        return r;
    }
    public int getCode() { return code; }
    public String getMessage() { return message; }
    public T getData() { return data; }
    public void setCode(int code) { this.code = code; }
    public void setMessage(String message) { this.message = message; }
    public void setData(T data) { this.data = data; }
}
package com.example.demo.common;

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public Result<Void> handleBiz(RuntimeException e) {
        return Result.error(400, e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleOther(Exception e) {
        return Result.error(500, "服务器开小差了,请稍后再试");
    }
}

⑦ application.yml —— 配置文件(第 6 章知识)

server:
  port: 8080

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: "改成你的密码"
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis:
  configuration:
    map-underscore-to-camel-case: true

logging:
  level:
    com.example.demo.mapper: debug        # 控制台打印 SQL,调试神器

7.6 第五步:运行与测试

运行 DemoApplication,控制台出现这两行即成功:

Tomcat started on port 8080 (http) with context path ''
Started DemoApplication in 2.3 seconds (process running for 2.9)

用 curl(或 Postman)逐个测试:

# 1. 查列表
curl http://localhost:8080/users

# 2. 查单个
curl http://localhost:8080/users/1

# 3. 新增(注意 Content-Type 请求头!)
curl -X POST http://localhost:8080/users -H "Content-Type: application/json" -d "{\"username\":\"spike\",\"age\":30,\"email\":\"spike@example.com\"}"

# 4. 修改
curl -X PUT http://localhost:8080/users/1 -H "Content-Type: application/json" -d "{\"username\":\"tomcat\",\"age\":19,\"email\":\"tom@example.com\"}"

# 5. 删除
curl -X DELETE http://localhost:8080/users/3

# 6. 查不存在的 id —— 体验全局异常处理
curl http://localhost:8080/users/999

期望响应(第 1、6 个):

// GET /users
{ "code": 200, "message": "success", "data": [
  { "id": 2, "username": "jerry", "age": 22, "email": "jerry@example.com", "createTime": "2026-09-01T20:15:30" },
  { "id": 1, "username": "tomcat", "age": 19, "email": "tom@example.com", "createTime": "2026-09-01T20:15:30" }
] }

// GET /users/999 → 全局异常处理接管
{ "code": 400, "message": "用户不存在: id=999", "data": null }
💡 恭喜!此时你已经跑通了「浏览器请求 → Spring MVC → Service → MyBatis → MySQL → JSON 返回」的完整闭环。这个项目就是 Java 后端的地基模板——企业里 80% 的接口开发,都是往这个骨架里填不同的表和业务。

7.7 课后扩展任务(检验学会没有)

  1. 给 users 表加一个 nickname 字段,同步修改实体类和所有 SQL(练:全链路加字段);
  2. 加一个接口:按用户名模糊搜索(练:@RequestParam + LIKE);
  3. 用 PageHelper 给列表加分页参数(练:5.6 节);
  4. 把「查列表」改成 XML 动态 SQL:关键词、最小年龄都可传可不传(练:5.4 节);
  5. 新增用户时校验 age 必须在 0~150 之间,不满足抛异常(练:业务校验 + 全局异常)。

第 8 章新手常见坑速查表

遇到报错先来这张表对号入座,能省下大把排错时间。都是真实高频问题。

现象 / 报错关键词原因解决
Field userMapper ... required a beanMapper 接口没被扫描注册接口加 @Mapper;或启动类加 @MapperScan("com.example.demo.mapper");检查启动类是否在最上层包
Port 8080 was already in use端口被占(上次进程没关)改 server.port;或杀掉旧进程(任务管理器找 java.exe)
Access denied for user 'root'@'localhost'数据库账号或密码错误核对 yml 里的 username/password;密码含特殊字符要用引号包起来
Unknown database 'demo'数据库没建先执行第 7.2 节的建库 SQL
连接报 serverTimezone / 时区错误MySQL 8 需要显式时区URL 追加 ?serverTimezone=Asia/Shanghai
接口访问 404路径写错 / Controller 没被扫描核对 URL 与注解路径(含类级前缀);确认类上有 @RestController 且位于扫描范围内
POST 返回 415 Unsupported Media Type没声明请求体格式请求加请求头 Content-Type: application/json
POST 返回 400,JSON 没进对象JSON 字段名与实体类对不上核对字段名大小写;检查是否忘了 @RequestBody
查询结果某字段是 null列名下划线 vs 属性驼峰没对上开 map-underscore-to-camel-case: true(见 5.5)
There is no getter for property ...#{xxx} 里写了对象没有的属性核对占位符名称与实体类字段名
XML 动态 SQL 不生效yml 没配 mapper-locations 或 id 与方法名不一致确认扫描路径正确;<select id=""> 必须等于接口方法名
改了代码不生效IDEA 没重新编译 / 老进程还在跑停掉旧进程再运行;必要时 Build → Rebuild Project
中文乱码连接或表编码不是 utf8mb4URL 加 characterEncoding=utf8;建表用 utf8mb4
Maven 依赖下载极慢/失败默认镜像在国外按下方阿里云镜像配置改 settings.xml

阿里云 Maven 镜像(国内必备):打开 ~/.m2/settings.xml(没有就新建),加入下面这段;老教程里的 maven.aliyun.com/nexus/content/groups/public 地址已失效,别再抄:

<mirrors>
  <mirror>
    <id>aliyun</id>
    <mirrorOf>*</mirrorOf>
    <url>https://maven.aliyun.com/repository/public</url>
  </mirror>
</mirrors>
📌 排错通用套路① 先读报错第一行的 Caused by(根因往往在最后一个 Caused by);② 看不出就搜报错原文(附上框架+版本号);③ 还不行就开启 SQL debug 日志,看 SQL 到底执行了什么、参数是什么。

第 9 章进阶路线:接下来学什么

你已经掌握了最核心的主干。下面是真实企业开发者的成长路径。

阶段一 · 打基础 本教程第 0~3 章 Java 回顾 · IoC/DI 理解 Bean 与解耦 ✅ 你现在在这 阶段二 · 三大件 本教程第 4~6 章 Spring MVC 接口开发 MyBatis 读写数据库 Boot 配置与打包 阶段三 · 实战与进阶 本教程第 7 章跑通实战 参数校验 · 事务 · JWT 登录 Redis 缓存 · 前后端联调 部署上线 每学完一章 → 把第 7 章的项目扩展一点 → 用实战巩固知识

图 9-1 · 三阶段成长路线

9.1 按顺序补齐的技能清单

  1. 参数校验:@Validated + @NotNull/@Min/@Size,替代手写 if 校验,代码更专业;
  2. 事务:@Transactional——理解「下单扣库存」为什么必须在一个事务里(要么全成功,要么全回滚);
  3. 登录认证:JWT + 拦截器,实现注册/登录/令牌校验,理解每个网站为什么要登录;
  4. Redis 缓存:把热点数据放缓存,理解「为什么 12306 首页快」;
  5. MyBatis-Plus:MyBatis 的增强版(国内很流行),单表 CRUD 连 SQL 都不用写,先懂 MyBatis 再用它事半功倍;
  6. 前端联调:学一点 Vue 或 React,用你写的接口做一个真实页面;
  7. 部署上线:云服务器 + java -jar + Nginx 反向代理,把项目发布到公网。

9.2 官方资料(遇到问题的第一手答案)

资源地址
Spring 官方文档https://spring.io/projects/spring-boot
MyBatis 中文文档https://mybatis.org/mybatis-3/zh/index.html
MyBatis-Spring-Boothttps://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/
项目脚手架https://start.spring.io(国内:https://start.aliyun.com)

附录速查卡合集

A. 注解速查(按层分类)

层注解作用
Spring@Component / @Service / @Repository把类登记为 Bean
@Autowired / 构造器注入注入依赖
@Bean / @Configuration配置类中手工造 Bean
@Primary / @Qualifier多个同类 Bean 时指定用哪个
Spring MVC@RestController接口类,返回值转 JSON
@GetMapping / @PostMapping / @PutMapping / @DeleteMapping路径映射
@PathVariable / @RequestParam / @RequestBody取路径/查询/请求体参数
@RestControllerAdvice + @ExceptionHandler全局异常处理
HandlerInterceptor拦截器(登录校验等)
MyBatis@Mapper / @MapperScan声明 Mapper 接口 / 批量扫描
@Select / @Insert / @Update / @Delete注解版 SQL
<if> <where> <set> <foreach>XML 动态 SQL 标签
Boot@SpringBootApplication启动类三合一注解
@Transactional事务(配合 starter-data 或 spring-tx)
@Value / @ConfigurationProperties读取 yml 配置

B. HTTP 状态码速查

状态码含义常见触发场景
200成功一切正常
400客户端参数错误JSON 格式错、校验失败、业务异常(本教程统一用它)
401未认证没登录 / token 失效
403无权限登录了但权限不够
404资源不存在URL 写错 / 方法没有对应映射
405方法不允许用 GET 去访问了 POST 接口
415不支持的媒体类型POST 忘了 Content-Type: application/json
500服务器内部错误代码抛了未处理异常 / 数据库挂了

C. 本教程知识主线(30 秒复盘)

Spring      → IoC 容器管对象;@Service 登记 Bean;构造器注入拿 Bean;接口解耦
Spring MVC  → @RestController 接请求;@XxxMapping 定路径;
              @PathVariable / @RequestParam / @RequestBody 取参数;返回值自动转 JSON
MyBatis     → @Mapper 接口即数据层;#{} 防注入;动态 SQL 用 XML 标签;驼峰映射要开启
Spring Boot → starter 拉依赖;自动配置免 XML;内嵌 Tomcat;yml 统一配置
实战套路    → 建表 → 实体类 → Mapper → Service → Controller → yml → 运行测试
            → 建表 → 实体 → 持久层 → 业务层 → 接口层 → 配置 → 测试

📖 Spring 全家桶零基础图文教程 · 完
有报错不可怕——它只是系统在用另一种方式教你。祝学习顺利!🚀