06 · 框架入门:Spring(Spring Basics)

📅 预计 45 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页 quiz shengxia.dev/java?module=06


6.1 为什么需要框架

点菜不用自己下厨

去餐厅吃饭,你不会亲自进后厨切菜、配菜、炒菜、摆盘——你只要在菜单上点一道「鱼香肉丝」,后厨的各个岗位就会自动分工:有人洗菜、有人切配、有人掌勺、有人装盘,最后服务生端到你面前。你关心的只是「好不好吃」,至于后厨具体怎么运作、刀工怎么练、火候怎么控,完全不用你操心。

框架就是餐厅的后厨。一个 Web 项目里,HTTP 请求的解析、URL 到方法的映射、参数的绑定、对象的创建与销毁、数据库事务的开启与提交、异常的统一处理……这些流程几乎每个接口都要走一遍。如果每个项目都重复手写,代码量巨大、极易出错,而且同样的 bug 会反复出现。框架把这些「公共流程」全部封装好,你只需要写「这一道菜特有」的部分——也就是业务逻辑。

回想我们在 JV-03 学 Servlet 时,哪怕只是返回一句「Hello」,也要亲手写一堆重复代码:

@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String name = req.getParameter("name");         // 手动解析 URL 参数
        if (name == null) name = "世界";                // 参数判空
        resp.setContentType("text/html; charset=UTF-8"); // 手动设编码防乱码
        resp.getWriter().println("<h1>Hello, " + name + "</h1>"); // 手动拼 HTML
    }
}

这段代码里真正属于业务逻辑的,只有「拿 name 拼一句问候」这一行;其余全是重复样板(boilerplate):解析参数、判空、设编码、拼响应。一个项目里如果有几十个这样的接口,这些样板就要复制粘贴几十遍,改一处要同步改十处,还会因为漏改产生各种奇怪的 bug。框架要解决的核心问题,可以归纳成下面这张表:

痛点 手写 Servlet 的做法 框架的做法
解析参数 每个方法手动 getParameter 注解自动绑定到方法参数
对象创建 每个类手动 new,依赖写死 容器统一创建并注入
事务管理 手动开启 / 提交 / 回滚 声明式事务,注解搞定
响应封装 手动拼 HTML / JSON 字符串 自动序列化成 JSON
异常处理 每个方法 try-catch 全局异常处理器统一兜底

💡 记忆口诀:框架 = 中央厨房。点菜(写业务)是你的事,后厨(解析、装配、事务)是它的事。


6.2 IoC 控制反转与 DI 依赖注入 ⭐

打电话给物业,而不是自己联系水电工

想象家里水管坏了。传统做法是:自己翻通讯录,找到上次那个水电工的电话,预约时间、谈好价格、全程在家盯着他干活,干完还得自己检查质量。整个过程你需要「主动寻找依赖、管理依赖」,非常折腾。而有了物业(容器)之后,你只需要给物业前台打个电话说「我家水管坏了」,物业自己会安排工人上门——什么时间、哪位师傅、用什么零件,都不用你操心。

IoC(Inversion of Control,控制反转)的核心就是:对象的创建和依赖的管理,从程序员手里转移到容器手里。「反转」是相对于传统写法而言的——以前是程序主动 new 出它需要的对象(主动控制),现在程序只是「声明需要什么」,由容器在合适的时机把对象创建好、注入进来(被动接收)。这个思想还有一个著名的说法叫「好莱坞原则」:Don't call us, we'll call you(别来找我们,我们会来找你)——对象不是自己去找依赖,而是等着容器把依赖送上门。

依赖注入 DI(Dependency Injection)正是 IoC 的一种实现方式:某个对象 A 需要用到对象 B 时,A 自己不负责创建 B,而是由容器把 B 的实例「注入」给 A。注入的途径有三种:构造器注入、字段(属性)注入、setter 注入,后面会逐一说明。

对比下面两段代码,体会「反转」到底发生在哪:

// ❌ 传统写法:自己 new,依赖被写死
class UserService {
    private UserDao dao = new UserDao();   // 自己创建依赖
    // 想换成 OtherUserDao?必须改这行代码,还要重新编译
}

// ✅ 依赖注入:只声明「我需要一个 UserDao」
@Service
class UserService {
    private final UserDao dao;             // 只声明,不 new
    @Autowired                             // 请容器注入
    UserService(UserDao dao) { this.dao = dao; }   // 构造器注入
}

传统写法的问题在于「写死」:UserService 和具体的 UserDao 实现绑死了,将来想换实现(比如换缓存版、换数据库),必须改 UserService 的源码。而依赖注入之后,UserService 只依赖 UserDao 这个「类型」,具体给它哪个实现由容器决定——这就实现了解耦,也叫面向接口编程。测试的时候也方便:注入一个假的 UserDao,就能单独测 UserService 的逻辑,不需要连真的数据库。

几个关键概念:

  • Bean:被容器创建并管理的对象。你自己 new UserDao() 出来的对象不叫 Bean,只有登记进容器、由容器创建和维护生命周期的才叫 Bean。
  • 容器(container):所有 Bean 的「户口本 + 保姆」。它负责创建 Bean、管理 Bean 的创建与销毁时机、按需把 Bean 注入到需要它的地方。Spring 的容器实体叫 ApplicationContext(应用上下文),应用启动时它会扫描并初始化所有 Bean。
  • 注册注解:告诉容器「这个类归你管」。Spring 提供了四个语义不同的注册注解:
@Component   // 通用组件(工具类、配置类等)
@Service     // 业务层(Service),标注业务逻辑类
@Repository  // 数据层(DAO),标注数据库访问类
@Controller  // 表现层(控制器),标注接收请求的类

四个注解在「注册 Bean」这件事上功能完全等价,区别在于语义:项目大了以后,扫一眼类上的注解就知道它属于哪一层,便于分层维护。这也是「约定优于配置」(convention over configuration)思想的体现——用注解表达意图,少写配置。

三种注入方式对比:

注入方式 写法示例 优点 缺点 适用场景
构造器注入 @Autowired UserService(UserDao dao) 依赖不可变、一眼可见、便于测试 参数多了写起来长 必填依赖,最推荐
字段注入 @Autowired private UserDao dao; 代码最短 依赖被隐藏、无法用 final 小项目图省事
setter 注入 @Autowired void setDao(UserDao dao) 依赖可选、运行时可改 依赖可变,状态不安全 可选依赖

还有一个隐藏细节:Bean 默认是单例的(singleton)。也就是说,容器里同一个类型的 Bean 通常只有一个实例,所有注入它的地方拿到的是同一个对象。这样既省内存,也保证状态一致;但也意味着 Bean 里别放「每次请求都不同」的可变状态,否则并发请求下会互相干扰——这也是为什么 Service 层一般写成无状态(不保存用户数据)的类。

⚠️ 常见错误

  1. 忘记注册注解UserDao 类上没写 @Repository,容器里根本没有这个 Bean,@Autowired 注入时抛 NoSuchBeanDefinitionException。原因:注册注解是 Bean 的「户口」,没登记就没有。避免:检查每个被注入的类是否都标了 @Component/@Service/@Repository/@Controller 之一。
  2. 字段注入却得到 null:明明写了 @Autowired private UserDao dao;,运行时却是 null。最常见的原因是手动 new UserService() 了——手动 new 的对象不属于容器,容器不会给它注入任何依赖。避免:需要 UserService 时从容器里取(注入),而不是自己 new。
  3. 接口有多个实现导致注入失败:容器里有 MysqlUserDaoCacheUserDao 两个类都实现了 UserDao@Autowired 不知道该注入哪一个,抛 NoUniqueBeanDefinitionException。原因:一个接口多个候选,没有明确指定。避免:用 @Qualifier("mysqlUserDao") 指定 Bean 名字。
  4. 构造器参数顺序写错:多参数构造器注入时,参数顺序和类型对不上,可能把错误的 Bean 注入进来,甚至启动时报 UnsatisfiedDependencyException。避免:保持参数顺序与构造器声明一致,必要时用 @Qualifier 精确指定。

6.3 Spring MVC:一次请求怎么被处理

政务大厅:总台 + 排班表 + 办事员

去政务大厅办事,你进门先找总服务台(DispatcherServlet,前端控制器)。总台的工作人员会查一张排班表(HandlerMapping,处理器映射),看看今天哪个窗口、哪位办事员(Controller 里的方法)负责你这种业务,然后告诉你去几号窗口。你办完事,结果还要交回总台,由总台统一把凭证(响应)交给你。整个过程你只面对总台一个入口,不用自己挨个窗口打听。

这个模式叫前端控制器模式(Front Controller Pattern):所有请求先进入唯一的入口(DispatcherServlet),由它负责分发。文字流程图如下:

浏览器发送请求:GET /user/7
        │
        ▼
DispatcherServlet(前端控制器 / 总台)
        │  ① 问 HandlerMapping:这个路径由哪个方法处理?
        ▼
HandlerMapping(处理器映射 / 排班表)
        │  返回:UserController.getUser()
        ▼
HandlerAdapter 调用 getUser() 方法
        │  ② 自动解析路径参数 id=7,注入到方法参数
        ▼
getUser(7) 执行:调用 UserService → UserDao,拿到结果
        │  ③ 返回结果(字符串 / 对象)
        ▼
DispatcherServlet 组装响应:如果是对象则序列化成 JSON
        ▼
返回 HTTP 响应给浏览器

Spring MVC 底层其实还是 Servlet——DispatcherServlet 本身就是一个 HttpServlet,它把我们之前手写的「解析参数、调用业务、拼响应」流程接管了。所以可以这样理解:Spring MVC = 升级版的 Servlet,把重复的请求处理流程框架化。以前我们要写 doGet、手动解析、手动拼 HTML,现在这些都被 DispatcherServlet 和它背后的组件自动完成。

程序员在这个流程里只负责写「办事员」那一格:一个用注解标注的 Controller 方法,声明它处理哪个路径、接收什么参数、返回什么。至于总台怎么分发、参数怎么解析、结果怎么序列化,全由框架自动完成。请求分发的控制权也在框架手里,这又是「控制反转」思想在请求层面的体现——所以 IoC 不只是「对象反转」,还延伸到「流程反转」。

另外,Spring MVC 的请求链路里还有两个常被提到的配角:过滤器(Filter)拦截器(Interceptor)。Filter 是 Servlet 层面的,在请求进入 DispatcherServlet 之前执行,适合做编码设置、登录校验、日志记录;Interceptor 是 Spring MVC 层面的,在进入 Controller 之前和之后执行,适合做权限校验、统计耗时。两者执行时机不同,记不清时记住口诀:Filter 在外围守大门,Interceptor 在门口查身份

⚠️ 常见错误

  1. 请求 404 找不到处理器:访问 /user/7 返回 404。原因:Controller 类没加 @Controller/@RestController 注解,或类没被扫描到(包位置不对),或路径注解写错。避免:检查注解、包扫描范围、路径拼写。
  2. 请求 405 Method Not Allowed:方法标了 @GetMapping("/user"),却用 POST 请求访问。原因:HTTP 方法不匹配。避免:确认前端请求方式与注解一致。
  3. 参数绑定失败报 400@RequestParam int page 但请求没带 page 参数。原因:@RequestParam 默认参数必填,缺了就直接报错。避免:加 required = false 并给默认值,或改用可空类型。

6.4 常用注解与完整示例

门牌号、点菜单、外卖单

把 URL 地址想成门牌号@RequestMapping 系列注解就是给方法「贴门牌」,告诉框架这个地址归谁管。每个注解还有更细的分工:

  • @RequestMapping:通用映射,可用 method 属性限定请求方式,或直接用简化写法。
  • @GetMapping / @PostMapping:分别是 @RequestMapping(method = GET/POST) 的简化版,语义更清晰、更常用。
  • @RequestParam:从问号后面的查询串取参数,对应 /list?page=2 里的 page
  • @PathVariable:从URL 路径里抠变量,对应 /user/7 里的 7
  • @RequestBody:收快递箱——读取请求体里的 JSON,自动反序列化成 Java 对象。
  • @ResponseBody:把返回值打包寄回——序列化成 JSON 写进响应体。

一个完整的「Controller → Service → Repository」三层示例,把注入和注解串起来:

// ---------- 数据层 Repository:访问数据库 ----------
@Repository
class UserDao {
    public String findNameById(int id) {
        return "用户" + id;   // 简化:真实场景是查数据库表
    }
    public List<String> findAllNames() {
        return List.of("张三", "李四", "王五");
    }
}

// ---------- 业务层 Service:处理业务逻辑 ----------
@Service
class UserService {
    private final UserDao dao;             // 注入数据层
    @Autowired
    UserService(UserDao dao) { this.dao = dao; }
    public String getUserName(int id) {
        return dao.findNameById(id);       // 业务:查名字
    }
    public List<String> getAllNames() {
        return dao.findAllNames();
    }
}

// ---------- 表现层 Controller:接收请求 ----------
@RestController
@RequestMapping("/api/users")              // 类级前缀
public class ApiController {
    private final UserService svc;         // 注入业务层
    @Autowired
    ApiController(UserService svc) { this.svc = svc; }

    @GetMapping("/list")                   // GET /api/users/list?page=2
    public List<String> list(@RequestParam int page) {
        return svc.getAllNames();
    }

    @PostMapping("/login")                 // POST,请求体 JSON 自动转对象
    public String login(@RequestBody User user) {
        return "登录成功:" + svc.getUserName(user.getId());
    }

    @GetMapping("/info/{id}")              // 路径参数
    public String info(@PathVariable int id) {
        return svc.getUserName(id);
    }
}

请求链路:浏览器发 GET /api/users/info/7 → DispatcherServlet 按路径找到 info 方法 → 自动把路径里的 7 解析成 id → 方法调用 svc.getUserName(7) → Service 调 Dao → Dao 返回「用户7」→ 框架把返回的字符串写进响应体 → 浏览器收到。整个链路没有一行手动 new,三层依赖全部由容器注入——这就是 6.2 学的依赖注入在真实项目里的样子。

这里「返回对象自动变 JSON」的功劳,要记在 Jackson(或 Gson)这类序列化库上。Spring Boot 自动配置里已经内置了 Jackson,所以 Controller 返回对象时,框架会调用它把对象转成 JSON 字符串写进响应体,前端拿到手就能直接用——你不需要手动拼接 JSON 字符串。

@Controller@RestController 的区别是高频考点:

注解 返回字符串时 返回对象时 典型场景
@Controller 视图名,去找模板渲染 需要加 @ResponseBody 传统 JSP / 模板页面
@RestController 数据,直接写进响应体 自动序列化成 JSON 前后端分离接口

💡 记忆口诀@PathVariable 是「路标」(路径里的变量);@RequestParam 是「问号」(? 后参数);@RequestBody 是「箱子」(请求体 JSON);@RestController 是「合体金刚」(@Controller + @ResponseBody)。

⚠️ 常见错误

  1. @RequestParam 与 @PathVariable 用反:URL 是 /user/7,却写 @RequestParam int id,框架按查询串去找参数,找不到,报 400 或拿到默认值。原因:两者取参位置不同。避免:看变量在路径里还是在问号后。
  2. POST 忘加 @RequestBody:前端用 JSON 提交,方法参数没标 @RequestBody,参数一直为 null。原因:JSON 在请求体里,不标注解 Spring 不会去反序列化。避免:接收请求体 JSON 时一定加 @RequestBody
  3. @Controller 返回字符串却被当 JSON:返回的字符串被当成视图名去查模板,报 404 或模板解析错误。原因:@Controller 的默认语义就是「返回视图名」。避免:返回数据用 @RestController,或在方法上加 @ResponseBody。
  4. 请求方式写错:前端用 POST,方法却标 @GetMapping,返回 405 Method Not Allowed。原因:HTTP 方法不匹配。避免:确认前端请求方式与方法注解一致。

6.5 Spring Boot:一键启动

智能家电 vs 自己组装台式机

自己从零搭一个 Spring 项目,就像自己攒一台台式机:选主板、装 CPU、插内存、配电源、装系统、装驱动,任何一个环节出错都开不了机。传统 Spring 项目光是配置 XML 或 Java 配置类就要写一大堆,新手很容易被劝退。

Spring Boot 就是那个智能家电:出厂预装好了,插上电源就能用。它主要做了三件事,把 Spring 包装得开箱即用:

  1. 内嵌服务器(embedded server):把 Tomcat 直接打进应用里,不用再单独装 Tomcat、部署 war 包。运行 main 方法,应用自己启动并监听 8080 端口。
  2. 自动配置(auto-configuration):根据依赖和类路径自动装配默认配置——数据源、Jackson JSON 转换器、DispatcherServlet、错误处理等,全都开箱即用,省去手工写配置。
  3. 起步依赖(starter):一个 spring-boot-starter-web 就把 Web 开发需要的库全部拉齐,版本也帮你配好,不用自己一个个挑、一个个对版本。

运行也极其简单:命令行执行 mvn spring-boot:run,或者在 IDE 里直接运行 main 方法;打包后用 java -jar 就能启动——因为打进 jar 的不仅有代码,还有内嵌的 Tomcat 和全部依赖,所以这种包叫可执行 jar(fat jar),换个有 Java 环境的机器就能直接跑。

极简的启动类:

@SpringBootApplication   // 组合注解:自动配置 + 组件扫描 + 启动
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
        // 启动后:内嵌 Tomcat 监听 8080,所有 @RestController 立即对外服务
    }
}

@SpringBootApplication 是一个组合注解,等于 @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan 三合一。其中组件扫描(@ComponentScan)会扫描启动类所在包及其子包下所有标了注册注解的类,所以启动类必须放在包的最外层——这也是 6.2 里那些 Bean 能被找到的原因。

常用的配置写在 src/main/resources/application.properties 里:

server.port=8081                  # 改端口
spring.datasource.url=jdbc:mysql://localhost:3306/site   # 配置数据库地址
logging.level.root=info           # 日志级别

💡 记忆口诀:Spring 是框架,Spring Boot 是「开箱即用的 Spring」——内嵌 Tomcat + 自动配置 + 起步依赖,一个 main 方法启动。

⚠️ 常见错误

  1. 启动类放错包@SpringBootApplication 没放在最外层包,组件扫描扫不到 Controller,接口全部 404。原因:组件扫描默认从启动类所在包开始。避免:把启动类放在所有业务包的最外层。
  2. 端口被占用:启动报 Port 8080 was already in use。原因:8080 被其他程序占用。避免:改 application.propertiesserver.port
  3. 依赖缺失报 ClassNotFoundException:报 DispatcherServlet 等类找不到。原因:Maven 依赖没刷新或版本冲突。避免:检查 pom.xml、刷新 Maven 重新导入。
  4. 忘了 @SpringBootApplication:只写了 main 方法就 run,启动后没有任何接口能用。原因:自动配置和组件扫描全靠这个注解。避免:启动类一定要标 @SpringBootApplication。

📌 双语术语表(本讲)

中文 English 记忆点
框架 framework 封装通用流程,专注业务
样板代码 boilerplate 重复、无业务价值的代码
控制反转 IoC (Inversion of Control) 创建权交给容器
依赖注入 DI (Dependency Injection) 容器把依赖送进门
Bean Bean 被容器管理的对象
容器 container Bean 的户口本 + 保姆
应用上下文 ApplicationContext Spring 容器的实体
组件扫描 component scan 自动发现注册类
自动装配 autowiring @Autowired 自动注入
构造器注入 constructor injection 推荐,依赖不可变
前端控制器 front controller 所有请求先过它
服务层 service layer 业务逻辑层
数据访问层 DAO / repository 数据库访问层
起步依赖 starter 一键拉齐依赖
自动配置 auto-configuration 免手工装配

⭐ 本讲考点清单

  1. 框架的意义:解决手写 Servlet 的重复样板代码
  2. IoC:对象创建权从程序员交给容器
  3. DI 三种注入方式(构造器 / 字段 / setter)
  4. 注册 Bean 的注解:@Component / @Service / @Repository / @Controller
  5. @Autowired 用法与常见报错原因
  6. Spring MVC 流程:DispatcherServlet → HandlerMapping → Controller
  7. @Controller 与 @RestController 的区别
  8. @GetMapping / @PostMapping 与请求方式
  9. @RequestParam 与 @PathVariable 的区别
  10. @RequestBody 收 JSON、@ResponseBody 返回数据
  11. Spring Boot 三大特性:内嵌 Tomcat、自动配置、起步依赖
  12. @SpringBootApplication 与 main 方法启动