这些年带过不少刚入门Java的同学,很多人学Spring的时候都有一种特别拧巴的感觉:教材上IoC、AOP、Bean生命周期这些概念背得滚瓜烂熟,一打开IDE却不知道怎么下手写一个能跑的东西。哪怕做了一个加法计算器,也只是照着教程敲了一遍,并不知道Controller里那个方法为什么能被浏览器调用,换个参数名怎么就报400了。这篇文章我想用两个最经典的小功能——加法计算器和用户登录——把Spring Web开发里最核心的那条链路讲透。加法计算器帮你建立"浏览器发请求到后端返回结果"的完整认知,用户登录则在此基础上加上状态管理、参数校验、拦截器这些真实项目躲不开的东西。适合刚学完Spring基础语法、想做点实际功能验证理解的同学,也适合正在做课程设计或者第一次接外包项目、需要快速把Spring MVC模块跑起来的朋友参考。
1. 用Spring做加法计算器和用户登录,到底在学什么
1.1 两个例子的共同点与递进关系
先说说我为什么把这两个功能放在一起讲。加法计算器本身没什么业务价值,任何一个会写Java的人用原生Servlet或者干脆用Scanner在控制台都能算加法。但放在Spring里做加法,你接触到的是一条完整的技术链路:一个HTTP请求从浏览器发出来,怎么被Spring的DispatcherServlet接住,怎么找到对应的Controller方法,方法里的参数怎么被赋上值,返回值怎么又变成浏览器能看懂的东西。这条链路如果你只靠看概念是建立不起来的,只有一个一个接口去试、去断点、去改参数,才能真正在脑子里形成画面。
用户登录则是加法计算器的自然延伸。它带来两个新问题:第一,请求不再是无状态的,你需要记住"这个用户已经登录过了"这件事;第二,不是所有页面都该被直接访问,没登录的人访问用户中心应该被拦截。这两个问题分别对应着HttpSession和HandlerInterceptor,是Web开发里特别高频的两个知识点。再加上参数校验、密码处理、异常提示,一个登录模块本身就能撑起一篇完整的实战文章。
我个人的看法是:加法计算器解决的是"从0到1"的理解问题,用户登录解决的是"从1到2"的进阶问题。这两个功能做完,你在Spring MVC里最常用的那一套东西就已经全部过了一遍。
1.2 环境与工程结构的选择
先说一个很多人会纠结的问题:用传统Spring(XML配置或者注解配置)还是Spring Boot?
如果你是在学校课程里学Spring,可能老师要求的还是传统的spring-webmvc + Tomcat部署war包那套,因为要理解Servlet容器和配置文件的细节。但如果你是自己做项目,我强烈建议直接上Spring Boot。这倒不是说不理解底层了,而是Spring Boot把很多无关紧要的样板配置收掉了,你能把精力放在业务本身。而且Spring Boot的自动配置在底层依然是Spring MVC那一套,你学会的东西换到传统项目里依然成立。
我的建议是:用Spring Boot 2.x(3.x也可以,但部分老教程的javax.servlet包名要换成jakarta.servlet,新手容易搞混),加上spring-boot-starter-web一个依赖就够,模板引擎用Thymeleaf或者干脆前后端不分离的时候用JSP(Spring Boot下JSP支持比较麻烦,新手我建议用Thymeleaf或者纯JSON接口加静态页面)。
下面是这个项目最基本的pom依赖,只保留最核心的部分:
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
加一个依赖,写一个启动类,再写一个Controller,你的Spring应用就能跑起来了。这跟十几年前需要手动配置web.xml、spring-mvc.xml、配置视图解析器、配置包扫描的年代相比,效率高了一个量级。
工程结构上,我按照下面这样组织,比较贴近实际项目的分层习惯:
code复制src/main/java/com/example/demo
├── DemoApplication.java
├── controller
│ ├── CalcController.java
│ └── LoginController.java
├── service
│ ├── UserService.java
│ └── impl/UserServiceImpl.java
├── model
│ └── User.java
└── interceptor
└── LoginInterceptor.java
这个结构你一看就明白:Controller负责接请求,Service负责业务逻辑,model里放数据对象。加法计算器虽然简单到可以不写Service,但登录功能把Service层加上之后,你才能体会到Spring的依赖注入在分层架构里有多舒服。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 加法计算器背后:Spring MVC 一次请求的完整旅程
2.1 先写一个能跑的加法接口
加法计算器最简单的实现,就是写一个接口,接收两个数,返回它们的和。先用最直白的方式写出来,再一点点拆解里面发生了什么。
java复制package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CalcController {
@GetMapping("/calc/add")
public int add(@RequestParam("a") int a,
@RequestParam("b") int b) {
return a + b;
}
}
启动项目,浏览器访问http://localhost:8080/calc/add?a=10&b=25,页面上直接显示35。这是一个最典型的GET接口,两个参数通过URL的Query String传进来,方法直接把和返回给浏览器。
就这么一个小东西,包含了很多值得展开的点。如果你直接在浏览器里访问这个地址,你会发现返回的纯数字35,没有任何HTML标签。这是因为@RestController注解意味着这个类里所有方法的返回值都会直接写入HTTP响应体(Response Body),而不是走视图解析器去找一个同名页面。这是Spring 4.0之后的做法,在更早的时期,你需要用@Controller加@ResponseBody两个注解组合才能实现同样的效果。我把这两个写法都放在这里,让你在翻老项目的时候不会懵:
java复制// 老写法:@Controller + @ResponseBody
@Controller
public class CalcController {
@ResponseBody
@GetMapping("/calc/add")
public int add(@RequestParam("a") int a,
@RequestParam("b") int b) {
return a + b;
}
}
很多初学者看到@RestController和@Controller的区别会记不住,你只要记住一个判断标准:方法返回的东西,到底是给人看的页面,还是给程序用的数据。返回页面用@Controller,返回JSON或者一个字面量比如这里的数字35,用@RestController。
2.2 @RequestParam 的参数绑定机制:为什么这样就能接住数据
再看@RequestParam("a") int a这一行。它的作用是从请求里取出名为a的参数,转成int类型,赋给方法参数a。这个过程在Spring内部叫做参数绑定,由HandlerAdapter完成。
稍微想一下:URL里传进来的参数本质上是字符串。浏览器发请求时,Query String是a=10&b=25,这里的10和25是字符串形式的数字。Spring拿到它们之后,会尝试把它们转换到方法签名里声明的类型。你声明的是int,它就调用字符串到整数的转换器;你声明的是Integer、Long、Double,同样有对应的转换器。如果转换失败,比如你传了a=abc,那Spring会直接返回400错误,提示参数类型不匹配。
这里有一个实际项目中很常见的困惑:为什么不写@RequestParam也能接住参数?原因是如果方法参数名和请求参数名恰好一致,Spring Boot可以做到"参数名自动匹配"。依靠的是编译时保留参数名(-parameters编译参数),或者通过字节码调试信息推断。但这里有个坑:如果你改了参数名,或者IDE没开保留参数名参数,就会拿到null或者直接报错。所以我个人的习惯是:在Spring MVC的接口方法里,能写@RequestParam("参数名")就老老实实写上,显式声明永远比隐式推断可靠。
用例子来说明更清楚。下面这个接口:
java复制@GetMapping("/calc/add2")
public int add2(@RequestParam("a") int a, @RequestParam("b") int b) {
return a + b;
}
你访问/calc/add2?a=10&b=25,两个参数齐了,返回35。但如果只访问/calc/add2?a=10,会得到400错误——因为@RequestParam默认是必填的。想设成可选,可以加required = false。这也是加法计算器里值得做的一个小实验:故意少传一个参数,看Spring怎么报错,这对理解参数绑定的行为很有帮助。
java复制@GetMapping("/calc/add3")
public int add3(@RequestParam(value = "a", required = false, defaultValue = "0") int a,
@RequestParam(value = "b", required = false, defaultValue = "0") int b) {
return a + b;
}
这样访问/calc/add3?b=5,a会被赋成默认值0,结果返回5。实际业务里,required配合defaultValue是处理可选参数最常用的方式之一。
2.3 视图层的两种选择:返回 JSON 还是跳转页面
加法计算器的接口写好了,接下来要解决的是"用户怎么看到结果"。这里有两种路线,因人而异。
第一种是纯接口路线:前端页面用Ajax调用接口,拿到JSON数据,自己在浏览器里渲染结果。这种方式前后端分离,接口可以被任意客户端复用,也是现在企业里最主流的做法。对应到Spring这边,你只需要用@RestController返回数据,Spring会帮你把返回值序列化成JSON(默认使用Jackson)。上面例子里的int返回值序列化之后就是35,如果是对象,就会变成{"key": "value"}这种结构。
第二种是服务端渲染路线:后端根据计算结果,指定一个视图页面,把数据塞进去,让Thymeleaf这类模板引擎渲染出完整HTML再返回给浏览器。这样用户刷新页面看到的就是完整的页面,不需要前端额外发ajax请求。
咱们拿Thymeleaf举个例子。先写一个Controller,返回视图名和数据:
java复制package com.example.demo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class CalcPageController {
@GetMapping("/calc/page")
public String calcPage(@RequestParam("a") int a,
@RequestParam("b") int b,
Model model) {
int result = a + b;
model.addAttribute("result", result);
return "calcResult";
}
}
然后在src/main/resources/templates下建一个calcResult.html:
html复制<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>计算结果</title>
</head>
<body>
<h1 th:text="'计算结果:' + ${result}">默认显示</h1>
</body>
</html>
访问/calc/page?a=100&b=200,浏览器里会渲染出一个完整的HTML页面,显示"计算结果:300"。这个过程中的关键技术点是:Controller方法返回的字符串calcResult不是直接写给浏览器的内容,而是一个视图名(View Name),Spring内置的ThymeleafViewResolver会去templates目录下找名为calcResult.html的模板,把Model里的数据填充进去,最后把完整HTML返回给浏览器。
这两种路线的选择取决于项目形态。如果做的是前后端分离的App接口,走@RestController;如果是传统的服务端渲染网站,走@Controller加模板引擎。加法计算器这个例子,最好两种都亲手做一遍,这样你对Spring MVC视图层的工作机制才算真正有了体感。
2.4 加法计算器可以怎么扩展:给初学者的一张自测清单
加法计算器虽然简单,但它可以很自然地扩展出很多难度递增的小练习。我在教学的时候经常用这样一张自测清单来检验一个人是不是真的理解了Spring MVC的基础,每一条都很简单,但都涉及一个独立的知识点:
| 扩展要求 | 涉及的知识点 |
|---|---|
| 加一个减法接口,参数复用 | @GetMapping路径设计 |
| 改为POST方式提交,用表单传参 | @PostMapping + form表单 |
| 支持三个数相加 | 参数绑定多个参数 |
| 参数传负数或小数时友好提示 | 参数校验与异常处理 |
| 用JSON格式提交两个数 | @RequestBody + Jackson |
| 计算结果出错时返回统一错误结构 | @RestControllerAdvice全局异常处理 |
| 用前后端分离方式,页面通过Ajax调接口 | 静态页面与接口联调 |
| 在计算前后打印日志 | AOP切面日志记录 |
你注意,这个清单最后一条我写了AOP。你看热搜里有人搜"spring aop实现日志记录",这其实是加法计算器或用户登录这类项目落地时很自然的延伸需求——你需要在每个接口被调用时记录调用了哪个方法、耗时多少、参数是什么。AOP可以做到切入Controller的所有方法,在方法执行前后统一打日志,完全不侵入业务代码。等你把基础功能写完,完全可以用AOP给自己这个项目加上一个日志切面,把日志输出到控制台,你会深切体会到Spring的AOP在"横切关注点"这个场景下有多优雅。
这一步做完,加法计算器的学习价值就全部榨干了。接下来进入用户登录。
3. 用户登录:从表单到会话保持的实现关键
3.1 登录功能的需求边界:别一上来就写注册
很多人一上来就急着做注册、登录、找回密码,恨不得一次把整套账号体系做完。我的建议是把这个目标拆小。第一版只需要做三件事:有一个登录页面、能够校验用户名和密码、登录成功后把用户标识存到Session里并跳转到欢迎页。注册功能可以先用一个写死的用户代替,比如内存里放一个用户:admin / 123456。为什么这样做?因为你当前阶段的学习重心在Spring MVC的请求处理和状态管理上,不在用户管理的业务完整性上。先把链路跑通,再加注册功能,心态会轻松很多。
用户数据的存储也有讲究。既然练习核心是Spring,数据库可以先用最简单的内存Map。等你想引入MyBatis或者Spring Data JPA的时候,再把UserService的实现从Map换到数据库,这对Service层的设计是有要求的——接口和实现分离,Controller面向接口编程,这样存储层随便换,上层一点不用动。所以前面我工程结构里特意写了UserService接口和UserServiceImpl实现类,就是为了这一步扩展做准备的。
3.2 设计用户对象与登录校验的正确姿势
用户对象很简单,几个字段就够了:
java复制package com.example.demo.model;
public class User {
private Integer id;
private String username;
private String password;
public User() {
}
public User(Integer id, String username, String password) {
this.id = id;
this.username = username;
this.password = password;
}
// getter 和 setter 省略,实际代码里要生成完整的
}
这里我要插一句关于JavaBean的细节:Spring在参数绑定、Jackson序列化、MyBatis映射这些场景中,经常要求对象有空构造方法和getter/setter。你如果偷懒只写一个带参构造器而不写空构造器,会在很多莫名其妙的地方踩坑。这是很多新手第一次遇到"SPRING报错但不知道原因"的经典来源,所以JavaBean的四个标准——空构造器、getter/setter、可序列化、规范命名——建议养成肌肉记忆。
登录校验的Service接口我先定义成下面这样:
java复制package com.example.demo.service;
import com.example.demo.model.User;
public interface UserService {
User login(String username, String password);
}
登录成功返回User对象,失败返回null。继续看实现类:
java复制package com.example.demo.service.impl;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class UserServiceImpl implements UserService {
private static final Map<String, User> USER_DB = new HashMap<>();
static {
USER_DB.put("admin", new User(1, "admin", "123456"));
// 注意:这仅是演示,真实项目中密码绝不能以明文存储,后面单独讲
USER_DB.put("zhangsan", new User(2, "zhangsan", "888888"));
}
@Override
public User login(String username, String password) {
// 1. 先根据用户名找到用户
User user = USER_DB.get(username);
// 2. 用户不存在,直接返回null
if (user == null) {
return null;
}
// 3. 密码不匹配,返回null
if (!user.getPassword().equals(password)) {
return null;
}
// 4. 校验通过,返回用户对象
return user;
}
}
这里有个设计上的小细节值得讲。很多新手会把校验逻辑写成"if (!password.equals(user.getPassword()))",也就是说把密码比对放在前面。我写的时候反过来了:先判断用户是否存在,再比密码。这不是多此一举,而是一个典型的防御式编程习惯——避免在user为null的时候空指针异常。虽然在一张简单的Map里,USER_DB.get(username)为null的情况很容易被注意到,但放到真实项目里,从数据库查出"查无此人"是一条很常见的路径,你必须在写代码时就把这条路径当作默认会发生的分支来处理。空指针是Java线上事故第一大来源,而"先判空再取属性"这条规则能防住一大半空指针。
3.3 登录接口和请求方式的选择:POST才是登录该用的姿势
登录接口用GET还是POST,这个问题值得单独说一下。技术上两种都能通,但实际项目里几乎没有用GET做登录的。原因有两个:一是GET请求的参数会出现在URL里,用户名密码全部暴露在地址栏,浏览器历史记录、服务器日志里都会留下痕迹,这是安全大忌;二是GET请求被设计成语义上是"查"的操作,POST被设计成"提交"的操作,登录是改变系统状态的动作,遵循HTTP语义也该用POST。
所以登录Controller这么写:
java复制package com.example.demo.controller;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
public class LoginController {
@Autowired
private UserService userService;
@PostMapping("/login")
public String login(@RequestParam("username") String username,
@RequestParam("password") String password,
HttpSession session,
Model model) {
User user = userService.login(username, password);
if (user == null) {
model.addAttribute("errorMsg", "用户名或密码错误");
return "login";
}
session.setAttribute("loginUser", user);
return "redirect:/welcome";
}
}
你先看方法签名:除了两个参数之外,还多了一个HttpSession session。Spring MVC允许你在方法参数里直接写HttpSession,它会在请求处理过程中自动把当前请求关联的Session对象传进来,你就可以直接操作了。这是我非常喜欢Spring MVC的一点——它把Servlet容器里的对象很自然地传递给业务方法,不需要你手动从某个Context里去取。同理,写HttpServletRequest、HttpServletResponse、HttpServletResponse也都可以。
再看登录成功后的处理:return "redirect:/welcome";。这个redirect:前缀是Spring MVC里的一个约定,表示返回的是一个重定向指令,让浏览器重新发起一次对/welcome路径的GET请求,而不是转发到某个视图。这里用重定向而不是直接return "welcome"转发,涉及一个Post-Redirect-Get(PRG)模式。原因很实际:如果登录成功之后直接返回welcome视图,那么当用户在欢迎页按F5刷新时,浏览器会重新提交刚才的POST请求——也就是再次执行登录逻辑。虽然第二次提交可能因为session已经有数据而不会出错,但"刷新页面导致表单重复提交"这个问题从根源上解决的方式就是重定向。重定向之后地址栏变成/welcome,用户的刷新动作就变成了单纯的GET请求,不会把POST重新提交一遍。这个细节在电商下单、支付回调这些场景里尤其重要——重试带来的重复操作往往意味着重复扣款。
登录失败的处理也很直白:把错误信息往Model里塞,然后直接返回视图名login,让用户留在登录页并看到错误提示。这里没有用重定向,因为没有"重复提交"的隐患,而且如果用了重定向,Model里的errorMsg是带不过去的(重定向是两次独立的请求,Model数据不会保留)。
登录页模板login.html大致这样:
html复制<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<form th:action="@{/login}" method="post">
<div>
<label>用户名:</label>
<input type="text" name="username"/>
</div>
<div>
<label>密码:</label>
<input type="password" name="password"/>
</div>
<div th:if="${errorMsg}" style="color: red;" th:text="${errorMsg}"></div>
<button type="submit">登录</button>
</form>
</body>
</html>
重点看th:action="@{/login}"和method="post",这保证了表单提交到后端时走的就是POST请求。th:if="${errorMsg}"的意思是:只有在Model里存在errorMsg的时候才渲染这一行错误提示。
3.4 登录后怎么记住状态:Session的存储原理与使用细节
前面登录方法里有一行关键代码:session.setAttribute("loginUser", user);。这行代码干的事就是把登录成功的用户对象存到会话里。这背后的机制值得展开讲一下。
HTTP是一个无状态协议,客户端发一个请求到服务端,服务端返回一个响应,这两个动作之间的联系就到此为止。下一个请求来了,服务端根本不知道这个请求和刚才那个请求是不是同一个用户发的。为了让服务器记住"你是谁",Servlet容器引入了Session机制,核心思路是:
- 客户端第一次访问服务器时,服务器创建一个Session对象,分配一个唯一的Session ID。
- 服务器通过响应头
Set-Cookie把Session ID发给浏览器。 - 浏览器在后续请求中自动携带名为
JSESSIONID的Cookie。 - 服务器根据这个Cookie里的Session ID,找到对应的Session对象,从中取出之前存进去的数据。
所以session.setAttribute("loginUser", user)的本质是:往一个"跟当前浏览器绑定的数据盒子里"放了一个对象。这个数据盒子的生命周期由服务器管理,默认超时时间一般是30分钟(可以在配置文件里设置server.servlet.session.timeout),超时后Session销毁,用户就需要重新登录。
要验证你是否理解了Session的原理,可以做两个小实验。第一个实验:登录成功后,打开浏览器的开发者工具(F12),在Application或者存储一栏里找Cookie,你会看到一个名为JSESSIONID的值。第二个实验:登录成功后把浏览器的这个Cookie删掉,再刷新欢迎页,你会发现访问被拦截了——因为服务器从请求里找不到对应的Session ID,自然也就不知道你是谁了。这两个实验很直观,建议亲手做一遍。
现在看欢迎页的Controller。它要做的事是:从Session里取出loginUser,如果取到了就把用户信息放到Model里渲染页面,否则跳回登录页:
java复制package com.example.demo.controller;
import com.example.demo.model.User;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class WelcomeController {
@GetMapping("/welcome")
public String welcome(HttpSession session, Model model) {
User loginUser = (User) session.getAttribute("loginUser");
if (loginUser == null) {
// 没登录过,回登录页
return "redirect:/login";
}
model.addAttribute("username", loginUser.getUsername());
return "welcome";
}
}
welcome.html里就可以用th:text="${username}"把用户名显示出来。这里有个代码细节需要提醒:session.getAttribute("loginUser")返回的是Object,强转成User时需要保证setAttribute的传值和getAttribute的取值类型一致。这是Session使用中最容易出岔子的地方,一旦类型不一致,强转时会抛ClassCastException。所以实际项目中建议定义一个常量字符串来表示Session的key,比如public static final String SESSION_LOGIN_USER = "loginUser";,Controller和拦截器都引用这个常量,避免手写字符串拼写不一致的问题。
3.5 用拦截器统一做登录检查:HandlerInterceptor 的配置细节
到这一步,登录状态已经能保存了,但有个明显的问题:如果每个需要登录才能访问的页面,都要在Controller方法里自己写一遍"从Session取用户、判空、跳转",代码会非常冗余。比如将来你加了十个需要登录的页面,就得多写十份类似的判断条件。这时候就该用拦截器了。
Spring MVC的拦截器(HandlerInterceptor)就是一个专门干这件事的东西:在请求到达Controller之前先执行一段逻辑,如果判断用户未登录,就直接重定向到登录页,根本不让请求进入后面的Controller方法。这样登录检查的代码只需要写一遍,统一生效。
先写一个LoginInterceptor:
java复制package com.example.demo.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.servlet.HandlerInterceptor;
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("loginUser") != null) {
// 已登录,放行
return true;
}
// 未登录,重定向到登录页
response.sendRedirect("/login");
return false;
}
}
有几个点需要仔细说。第一,request.getSession(false)里的false是关键。getSession()如果传true(或者不传),当Session不存在的时候会自动创建一个新的Session对象。在拦截器里,如果没有Session却自动创建了一个,就会导致未登录用户每次被拦截时都产生一个无用的Session,白白浪费服务器内存,而且后续还会带着一个新的空Session去登录页,行为变得很怪。传false则明确表示:如果当前请求没有Session,就直接返回null,我下面判断session != null就是处理这种情况。这个小参数背后是内存开销和语义准确性的问题,建议养成习惯。
第二,拦截器preHandle方法的返回值决定了请求是否继续:返回true继续往下走,返回false就中断请求。这里我们未登录时用response.sendRedirect("/login")把浏览器引导到登录页,然后返回false,请求到此打住。
拦截器写完之后还不能直接生效,需要注册到Spring MVC的拦截器链里。在Spring Boot中,通常用配置类实现WebMvcConfigurer接口,重写addInterceptors方法:
java复制package com.example.demo.config;
import com.example.demo.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns(
"/login",
"/css/**",
"/js/**",
"/img/**"
);
}
}
这段配置里面有两个参数,作用完全不同,这里特意展开说说。addPathPatterns("/**")说明拦截所有路径,excludePathPatterns则把不需要拦截的路径排除掉。登录页本身当然不能拦截,不然所有人都进不了登录页;静态资源CSS、JS、图片也要放行,否则页面样式和脚本都会加载不出来。这是个很容易栽的坑:很多新手配了拦截器之后发现页面变得"白花花一片"没有样式,排查到最后往往就是静态资源被拦截了。放行静态资源之后问题就消失了。
路径匹配这里还要再补充一点细节。/**和/*的区别经常考到:/*只匹配当前层级,比如/login、/calc;/**匹配所有层级,包括/user/center、/api/order/list这类多级路径。如果你只需要拦截/user/**,就用/user/**就可以了,不用把所有功能都拦一层。具体业务具体分析,拦截范围绝对不是越广越好,因为它会带来性能开销,还会增加静态资源和白名单的维护成本。
3.6 密码存储的底线问题:明文密码是绝对禁区
登录模块做完,我必须单独花一段说密码安全问题。上面UserServiceImpl里的用户数据是演示用的,密码直接明文存了"123456",这在本地测试时无所谓,但如果你把这个思路带到实际项目里,那就是灾难级别的隐患。
真实项目的底线做法是:数据库中不存明文密码,只存密码的哈希值。用户注册时,把用户输入的密码用BCrypt算法(一种加盐的密码哈希算法)生成一个不可逆的哈希串保存;用户登录时,把输入的密码用同样的算法和存储的哈希做比对,而不是直接比对原始密码字符串。
Spring Security框架本身提供了BCryptPasswordEncoder,可以直接使用。虽然我们的登录模块没有引入Spring Security,但引入这个专门的加密工具类并不复杂。大致思路是:
java复制// 注册时对密码哈希
String hash = new BCryptPasswordEncoder().encode(rawPassword);
// 登录时校验
boolean matches = new BCryptPasswordEncoder().matches(rawPassword, hashFromDB);
之所以不能直接存明文密码,是因为一旦数据库泄露,攻击者拿到的就是用户原始密码。而很多用户在不同网站使用相同的密码,一套密码泄露会导致撞库风险波及大量其他平台。这个事不是"我不会被黑"的概率问题,而是只要明文存储,事故发生的概率就永远是100%。你在任何项目里都应该习惯性地把密码处理放在设计的第一序列,不要在功能开发完之后才想起来补。
4. 两个模块怎么整合:依赖注入、分层与前端配合
4.1 Controller-Service-DAO 三层结构的拆分逻辑
加法计算器因为逻辑简单,直接把加法写在Controller里也说得过去。但登录模块开始涉及多个步骤、多个数据源选择,你会发现如果不分层,代码会迅速失控。大量的业务判断逻辑都塞在Controller里,Controller会变得非常臃肿,也难以测试和复用。
行业里最常见的分层是三层架构:Controller层(接口层)、Service层(业务层)、DAO层(数据访问层)。每一层的职责是明确的:
- Controller层:接收HTTP请求和参数,调用Service,把结果转成合适的响应。它不该写具体业务规则,只做"翻译"和"路由"。
- Service层:承载核心业务逻辑,比如用户校验、订单计算、流程编排。这一层不关心HTTP细节,不依赖
HttpServletRequest、HttpSession这些Servlet API。这样设计的一个好处是,Service可以被多个Controller复用,也可以在单元测试时直接调用,不需要启动Web容器。 - DAO层:负责跟数据库打交道,把数据读出来映射成对象,或者把对象写回数据库。在本项目的简化版本里,
UserServiceImpl内部的USER_DB就扮演了DAO的角色。
用登录的例子来说:Controller只负责"拿到username和password,调用userService.login,根据返回值决定页面跳转"。至于"用户是否存在、密码是否匹配"这些规则,全在Service里。将来你把内存Map换成MySQL,只需要新增一个UserDao接口和实现,改掉UserServiceImpl里的存储代码,Controller一行都不用动。这就是分层最重要的价值:变化被隔离在了某一层内部,不会蔓延到全局。
4.2 依赖注入在登录模块里的实际体现
刚写的LoginController里有一行@Autowired private UserService userService;,这就是Spring依赖注入(DI)最典型的用法。它的核心意思是:这个Controller需要一个UserService的实现,但Controller自己不负责创建,而是由Spring容器在启动时扫描到一个@Service注解的类(也就是UserServiceImpl),自动创建一个实例,然后注入到这个字段里。
这个过程带来的好处,新手阶段可能感受不深,等你项目变大之后才会真正体会到。第一,解耦。Controller只认识UserService接口,不关心你是内存实现还是MyBatis实现,底层随便换,上层不需要改。第二,对象的生命周期统一管理。所有Bean(被Spring管理的对象)都由容器创建、持有和销毁,你不用到处new对象,避免了内存浪费和对象状态不一致的问题。第三,方便测试和替换。想测某个Controller的业务逻辑,可以注入一个假的UserService实现,完全不需要启动真实数据库,这就是单元测试里的Mock思路。
Spring的依赖注入有三种主要方式:字段注入、构造器注入、Setter注入。现在业界的主流建议是构造器注入,因为字段注入容易被滥用,而且测试时如果不用Spring容器无法方便地替换依赖。写出来是这样的:
java复制@Controller
public class LoginController {
private final UserService userService;
public LoginController(UserService userService) {
this.userService = userService;
}
// 业务方法省略
}
在Spring 4.3及以上版本,如果类只有一个构造器,可以省略@Autowired注解,Spring会自动调用这个构造器注入参数。这样的好处是依赖在对象创建时就固定了,不会出现字段注入那种"看似能用但没法在测试里轻松替换"的尴尬。对于新手,我建议直接养成构造器注入的习惯,从登录Controller开始就这么写。
4.3 页面与接口的配合:表单提交、重定向与数据传递的完整流程
把前面的代码串起来看一遍完整的用户登录交互流程,你会发现请求的走向非常清晰:
- 浏览器访问
/login,LoginController里提供GET方法展示登录页(这一步代码在前面没展开,实际需要补一个):
java复制@GetMapping("/login")
public String loginPage() {
return "login";
}
- 用户在表单里输入用户名和密码,点击提交,浏览器向
/login发起POST请求,参数是username和password。 - 后端LoginController的POST方法接收参数,调用UserService校验。
- 校验失败:Model里放errorMsg,返回
login视图,用户看到错误提示。 - 校验成功:Session里存
loginUser,返回redirect:/welcome。 - 浏览器跟着重定向指令,向
/welcome发起GET请求。此时Session携带JSESSIONID Cookie,WelcomeController从Session取出loginUser,把用户名放进Model,返回welcome视图。 - 登录拦截器在这个流程里起的作用是:如果第6步没有Session或Session里没有
loginUser,请求到不了WelcomeController,而是直接被打回登录页。
把这条线在纸上画一遍,你就能把Spring MVC的请求全流程、Session机制、重定向和转发、拦截器这些知识点全部串成一个整体。我建议你画完之后,再用断点调试把这几个类的关键行都断一遍,看看执行顺序和变量值,比看十篇文章都管用。
5. 实操中一定会踩的坑(附完整排查思路)
5.1 中文乱码问题的根因与处理
加法计算器可能还遇不到中文乱码,但登录模块一加入错误提示,你就会发现页面上出现一堆问号或者乱码。这个问题在Spring Boot里通常分两种情况。
第一种是响应乱码:页面或者接口返回的中文变成了????。Spring Boot默认的字符集是UTF-8,大多数情况下不会出问题。但如果你用了较老的Spring版本,或者手动设置了别的编码,就需要显式配置一下。在Spring Boot里可以通过配置项统一设置:
properties复制server.servlet.encoding.enabled=true
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.force=true
第二种是请求乱码:表单提交的中文用户名到后端之后变成乱码。对于POST请求,Spring Boot已经默认配置了CharacterEncodingFilter,把请求体按UTF-8解码。但如果遇到旧项目或者传统Spring,需要在web.xml里显式配置这个Filter,并且确保它的顺序在DispatcherServlet之前。对于GET请求的乱码,问题通常出在Tomcat的URI解码上,可以在配置里加:
properties复制server.tomcat.uri-encoding=UTF-8
排查乱码的时候,我建议用这样一个顺序:先看浏览器请求头里Content-Type带没带charset=UTF-8,再看后端接收参数时的编码,最后看模板文件本身是什么编码保存的(Thymeleaf或JSP文件编码不对,纯后端配置再对也没用)。三个环节任何一个不对,输出都会有乱码,但很多时候问题恰恰出在你一开始最不会怀疑的模板文件编码上。
5.2 参数类型转换失败返回400:从控制台日志到根因
加法计算器里访问/calc/add?a=abc&b=25,你会看到HTTP 400 Bad Request。新手碰到这个错误的第一反应往往是"我代码写错了吧",但其实代码没错,是参数类型转换失败了。Spring在试图把字符串abc转换为int时抛出了NumberFormatException,Spring MVC捕获之后认为请求参数不可用,返回400。
排查这个问题的思路是:先看清控制台日志里有没有Resolved [org.springframework.web.method.annotation.MethodArgumentTypeMismatchException类似的字样,如果有,那问题就锁定在参数绑定阶段。这时候你要做两件事:第一,检查URL里参数名的拼写是不是和后端@RequestParam("a")里的名字一致;第二,检查参数值格式是不是真的能转换为目标类型。多参一个空格都不行——比如a= 10(前面有空格),转int也会报错,这在从日志、配置文件、URL复制参数时特别容易发生。
如果不想让用户看到丑陋的400页面,可以做一个全局异常处理,捕获MethodArgumentTypeMismatchException,返回友好的提示。等后面学了@RestControllerAdvice,这个功能你一定会用上。
5.3 登录后刷新就失效的Session问题:一个常见的伪命题
有一种很经典的"登录后刷新页面就跳到登录页"的现象,很多人会怀疑是Session配置的问题。我见到的绝大多数情况,根本不是Session超时,而是开发环境里一种特殊的行为:后端代码修改后,IDE里的热部署或者DevTools触发了ClassLoader的重新加载,而Session对象是绑定在旧的类加载器上的,新类加载器加载的代码去取Session里的loginUser,就可能出现类型转换异常或取值失败,最终表现就是登录状态像是丢了。
遇到这种问题,我的排查顺序是这样的。第一步,先在登录成功后的Controller和/welcome的Controller里都把session.getId()打印出来,看两次请求的Session ID是否一致。如果一致,说明Session没丢,问题出在存储对象上;如果不一致,说明浏览器和服务端的Session关联断了,去看Cookie有没有被浏览器屏蔽或者过期。第二步,确认Session ID一致后,再看session.getAttribute("loginUser")取出来的东西是什么类型、是不是null。很多时候你会发现在热部署场景下,取出来的对象的ClassLoader和当前代码不一致,导致类型判断失败。第三步,确认代码本身没有把loginUser这个key写错,比如不小心写成了loginuser或者login_user。
这种"伪Session丢失"的问题在真实开发中非常常见。如果你确认了自己的代码逻辑没问题,那么先重启一次应用再试,往往就恢复正常了。这不算什么高深的技术,但排查思路比结论更重要,因为大多数时候出问题的点不在Session本身,而在Session里装了什么、请求带的Session ID是不是同一个。
5.4 拦截器放行规则配置错了:静态资源全挂的排错经验
拦截器放行规则配错导致的"样式全丢"问题,是我见过次数最多的一类问题。典型的场景是:你加了登录拦截器,然后页面突然变难看了,CSS和JS全部加载不出来。打开F12的Network面板,会发现一堆.css和.js请求返回302,被重定向到登录页了,这就是静态资源被拦截器拦住了。
我之前也踩过一次。当时把addPathPatterns("/**")配好了,但excludePathPatterns里只写了/login,没有加静态资源路径,结果登录页加载出来只有光秃秃的HTML,表单输入框乱七八糟。排查的时候,我先是怀疑Thymeleaf模板写错了,排查了半天才发现是静态资源根本没加载进来。后来把/css/**、/js/**、/img/**都加到排除列表里,问题立刻解决。
还有个更隐蔽的坑:如果项目里用了/static/**作为静态资源前缀(Spring Boot默认),而页面前端又可能直接用/static/css/style.css这种路径去引用,那你排除的时候要用/static/**而不是/css/**。总之,先看清楚页面上引用资源的实际URL前缀,再决定排除规则,不要想当然。
6. 从加法计算器到用户登录:我的一些个人体会
两个功能写完,你实际上已经完整走了一遍Spring Web开发的常用路径。回头再看Spring的几个核心概念,你会发现它们不再那么抽象了。IoC就是你把UserService交给Spring管理,需要的时候让Spring给你注入;DI就是Controller的构造器参数里传入UserService实例;MVC就是DispatcherServlet、Controller、ViewResolver那一整套请求处理流程;AOP虽然没直接写,但加日志的时候你就体会到了。
我在带新人时经常强调一个观点:学习框架最好的方式不是一上来就追着看源码,而是先做出几个能跑的功能,在做的过程中发现问题、查文档、看源码,这样知识点才记得牢。加法计算器让你建立了请求处理的完整画面,用户登录让你把状态管理、拦截、校验这些真实项目的需求摸了一遍。把这两个例子做透,比草草看二十个教程都强。
下一步我建议你可以自己动手扩展一下:给加法计算器加上一个全局异常处理器,返回统一结构的错误信息;给用户登录加一个注册页面,把数据存到MySQL,用MyBatis或Spring Data JPA操作;再给自己的接口加一个AOP日志切面。这三个扩展完成之后,你已经可以独立写出一个结构完整、有分层、有日志、有异常处理的小型Web项目了。那时候你再回头来看Spring的官方文档,理解速度会快非常非常多。
