JV-06 框架入门
06 · 框架入门:Spring(Spring Basics)
📅 预计 45 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页 quizshengxia.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 层一般写成无状态(不保存用户数据)的类。
⚠️ 常见错误
- 忘记注册注解:
UserDao类上没写@Repository,容器里根本没有这个 Bean,@Autowired注入时抛NoSuchBeanDefinitionException。原因:注册注解是 Bean 的「户口」,没登记就没有。避免:检查每个被注入的类是否都标了@Component/@Service/@Repository/@Controller之一。 - 字段注入却得到 null:明明写了
@Autowired private UserDao dao;,运行时却是 null。最常见的原因是手动new UserService()了——手动 new 的对象不属于容器,容器不会给它注入任何依赖。避免:需要 UserService 时从容器里取(注入),而不是自己 new。 - 接口有多个实现导致注入失败:容器里有
MysqlUserDao和CacheUserDao两个类都实现了UserDao,@Autowired不知道该注入哪一个,抛NoUniqueBeanDefinitionException。原因:一个接口多个候选,没有明确指定。避免:用@Qualifier("mysqlUserDao")指定 Bean 名字。 - 构造器参数顺序写错:多参数构造器注入时,参数顺序和类型对不上,可能把错误的 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 在门口查身份。
⚠️ 常见错误
- 请求 404 找不到处理器:访问
/user/7返回 404。原因:Controller 类没加@Controller/@RestController注解,或类没被扫描到(包位置不对),或路径注解写错。避免:检查注解、包扫描范围、路径拼写。 - 请求 405 Method Not Allowed:方法标了
@GetMapping("/user"),却用 POST 请求访问。原因:HTTP 方法不匹配。避免:确认前端请求方式与注解一致。 - 参数绑定失败报 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)。
⚠️ 常见错误
- @RequestParam 与 @PathVariable 用反:URL 是
/user/7,却写@RequestParam int id,框架按查询串去找参数,找不到,报 400 或拿到默认值。原因:两者取参位置不同。避免:看变量在路径里还是在问号后。 - POST 忘加 @RequestBody:前端用 JSON 提交,方法参数没标
@RequestBody,参数一直为 null。原因:JSON 在请求体里,不标注解 Spring 不会去反序列化。避免:接收请求体 JSON 时一定加@RequestBody。 - @Controller 返回字符串却被当 JSON:返回的字符串被当成视图名去查模板,报 404 或模板解析错误。原因:@Controller 的默认语义就是「返回视图名」。避免:返回数据用 @RestController,或在方法上加 @ResponseBody。
- 请求方式写错:前端用 POST,方法却标
@GetMapping,返回 405 Method Not Allowed。原因:HTTP 方法不匹配。避免:确认前端请求方式与方法注解一致。
6.5 Spring Boot:一键启动
智能家电 vs 自己组装台式机
自己从零搭一个 Spring 项目,就像自己攒一台台式机:选主板、装 CPU、插内存、配电源、装系统、装驱动,任何一个环节出错都开不了机。传统 Spring 项目光是配置 XML 或 Java 配置类就要写一大堆,新手很容易被劝退。
Spring Boot 就是那个智能家电:出厂预装好了,插上电源就能用。它主要做了三件事,把 Spring 包装得开箱即用:
- 内嵌服务器(embedded server):把 Tomcat 直接打进应用里,不用再单独装 Tomcat、部署 war 包。运行
main方法,应用自己启动并监听 8080 端口。 - 自动配置(auto-configuration):根据依赖和类路径自动装配默认配置——数据源、Jackson JSON 转换器、DispatcherServlet、错误处理等,全都开箱即用,省去手工写配置。
- 起步依赖(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方法启动。
⚠️ 常见错误
- 启动类放错包:
@SpringBootApplication没放在最外层包,组件扫描扫不到 Controller,接口全部 404。原因:组件扫描默认从启动类所在包开始。避免:把启动类放在所有业务包的最外层。 - 端口被占用:启动报
Port 8080 was already in use。原因:8080 被其他程序占用。避免:改application.properties的server.port。 - 依赖缺失报 ClassNotFoundException:报
DispatcherServlet等类找不到。原因:Maven 依赖没刷新或版本冲突。避免:检查 pom.xml、刷新 Maven 重新导入。 - 忘了 @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 | 免手工装配 |
⭐ 本讲考点清单
- 框架的意义:解决手写 Servlet 的重复样板代码
- IoC:对象创建权从程序员交给容器
- DI 三种注入方式(构造器 / 字段 / setter)
- 注册 Bean 的注解:@Component / @Service / @Repository / @Controller
- @Autowired 用法与常见报错原因
- Spring MVC 流程:DispatcherServlet → HandlerMapping → Controller
- @Controller 与 @RestController 的区别
- @GetMapping / @PostMapping 与请求方式
- @RequestParam 与 @PathVariable 的区别
- @RequestBody 收 JSON、@ResponseBody 返回数据
- Spring Boot 三大特性:内嵌 Tomcat、自动配置、起步依赖
- @SpringBootApplication 与 main 方法启动