1. 为什么用这两个Demo切入Spring Web开发
先聊点反直觉的观察。很多新手学Spring,一上来就啃配置、背注解、看各种“最佳实践”,结果越学越懵。我见过不少初学者,能把IoC、AOP、Bean生命周期讲得头头是道,但你让他用Spring写一个能跑通浏览器请求的小功能,反而卡半天。问题出在哪?没有把Spring放进一个真实的请求-响应链路里去看。
加法计算器和用户登录,恰恰是两个能把这条链路完整牵出来的最小业务。加法器是“无状态”的:浏览器提交两个数,服务端算个和,返回结果,谁提交都一样,服务端不需要记住任何东西。它考验的是你能否把一个HTTP请求从浏览器送进Controller方法,再把结果送回去。用户登录则引入了一个真正绕不开的概念——会话状态:你登录一次,后续请求怎么让服务端认得出你?这背后是Session、Cookie、拦截器、重定向这一串东西。可以说,加法器解决的是“请求怎么进来”,登录解决的是“请求进来后怎么知道是谁”。
把这两个Demo做完,你至少能摸到Spring Web开发最核心的骨架:
- IoC容器和依赖注入——Service层怎么被Controller用起来
- Spring MVC请求处理链路——DispatcherServlet、HandlerMapping、@Controller、ViewResolver各自干什么
- 参数绑定——从手工request.getParameter到@RequestParam再到POJO绑定,同一个问题三种写法
- 请求转发与重定向——为什么登录成功要redirect而不是return一个页面
- HttpSession——登录态的本质
- 拦截器——如何统一做鉴权,避免每个Controller写重复的登录判断
- 模板引擎渲染——用Thymeleaf把数据填进HTML页面
这套知识学完,你再去看Spring Boot那套自动化配置,会发现过去让你犯迷糊的东西一下子有了落点。拦截器配置、Servlet容器特性、请求映射规则,几乎都在这个“双Demo”项目里出现过。
适合什么人看?刚学完Java基础、想进入Web开发的在校生,想快速捋清Spring MVC主线的转行者,还有期末做课程设计、需要一个能跑通的小系统来保底的同学。我下面的写法会分两条线:先做加法计算器,把参数绑定和页面渲染讲透;再做用户登录,把会话管理和拦截器讲透。两条线最后可以合并成一个小而完整的练习项目。
提示:本文所有示例基于Spring MVC + Thymeleaf,环境用Maven构建。Spring Boot项目结构略有差异,但Controller写法、拦截器逻辑、Session API完全一致,跑通后迁移难度很低。
2. 加法计算器:从HttpServletRequest到@RequestParam的参数绑定演进
2.1 第一版:Servlet原生的读取方式
先看加法器最朴素的做法。在Spring的Controller方法里,直接使用HttpServletRequest来拿参数:
@Controller @RequestMapping("/calc") public class CalcController { @PostMapping("/add") public String add(HttpServletRequest request, Model model) { String xParam = request.getParameter("x"); String yParam = request.getParameter("y"); int x = Integer.parseInt(xParam); int y = Integer.parseInt(yParam); int sum = x + y; model.addAttribute("sum", sum); return "calc-result"; } }这段代码能跑,但问题很明显:
- 参数名靠字符串硬编码,写错一个字母只有运行时才知道
- 字符串转int要手工parseInt,空值、非法值还得自己处理
- 参数多的时候,方法体全是取值和转换的样板代码
- Controller耦合了Servlet API,测试的时候必须模拟HttpServletRequest
对于“加法器”这种两个参数的小Case,上面的问题都能忍。但如果你做过真实项目就知道,一个表单动辄十几个字段,用这种方式写,光取值就能写一百行,而且极易出错。
2.2 第二版:@RequestParam绑定,让框架替你干活
Spring提供了@RequestParam来做参数绑定,它解决了第一个大问题:把HTTP请求参数直接映射为方法参数。
@PostMapping("/add") public String add(@RequestParam("x") int x, @RequestParam("y") int y, Model model) { int sum = addService.add(x, y); model.addAttribute("sum", sum); return "calc-result"; }这里有个很关键的机制,值得弄清楚:Spring怎么把HTTP请求里的字符串"5"变成int类型?
答案在转换服务(ConversionService)。Spring容器里维护了一套类型转换器,StringToNumber就是其中之一。请求进来后,Spring MVC会根据Controller方法的参数类型自动调用相应的转换器。转换失败会抛出TypeMismatchException,最终表现为HTTP 400错误。
这个看似不起眼的设计,其实解决的是Web开发里一个非常常见的需求:前端传来的全是字符串,后端方法想用int、long、LocalDate这些真实类型。Spring通过ConversionService替你完成了从“字符串世界”到“类型世界”的翻译。
注意:@RequestParam默认是required=true。如果请求没有携带对应参数,会直接报400错误。需要可选参数时,可以写成@RequestParam(value = "x", required = false) Integer x,或者用defaultValue="0"来给个默认值。
2.3 第三版:POJO绑定,面向真实项目的写法
参数少的时候@RequestParam很舒服,但参数一多,方法签名会变得很长,比如用户提交一个注册表单,十几个字段全堆在方法参数上,阅读体验极差。
Spring支持直接绑定到一个POJO:
public class CalcForm { private Integer x; private Integer y; // getter/setter 省略 } @PostMapping("/add") public String add(CalcForm form, Model model) { int sum = addService.add(form.getX(), form.getY()); model.addAttribute("sum", sum); return "calc-result"; }Spring会按表单字段名和POJO属性名的对应关系自动完成绑定。这里的匹配规则是:请求参数名要与属性名一致,即表单里的x对应POJO的x属性。实现原理是先实例化CalcForm对象,然后反射获取每个属性的setter,再调用转换器把字符串值转成对应类型并注入。
这个写法在真实项目里是最主流的,因为它把“请求参数的集合”变成了一个“有结构的对象”,后面加字段,Controller方法不用改,只要在POJO里加属性即可。
2.4 完整的加法器项目骨架
下面给出一个能直接运行的加法器示例,项目结构如下:
src/main/java └── com/example/calc ├── CalcApplication.java // Spring Boot 启动类 ├── controller/CalcController.java └── service/AddService.java src/main/resources └── templates ├── calc-form.html └── calc-result.html src/main/resources/application.properties启动类(Spring Boot版):
@SpringBootApplication public class CalcApplication { public static void main(String[] args) { SpringApplication.run(CalcApplication.class, args); } }Service组件:
@Service public class AddService { public int add(int x, int y) { return x + y; } }表单页:
<form th:action="@{/calc/add}" method="post"> <label> 第一个数: <input type="number" name="x" required> </label> <label> 第二个数: <input type="number" name="y" required> </label> <button type="submit">计算</button> </form>结果页:
<p>计算结果:<span th:text="${sum}">0</span></p> <a th:href="@{/calc/form}">再算一次</a>Controller里再多一个方法,用来显示表单页:
@GetMapping("/calc/form") public String form() { return "calc-form"; }观察一下这个流程:浏览器GET /calc/form,Spring返回表单页;用户填数提交POST /calc/add,Spring把参数绑定进Controller方法,Service算出结果,放进Model,再交给视图解析器渲染calc-result页面。
这就是Spring MVC一次完整的请求-响应周期。加法器虽然简单,但这个“请求→HandlerMapping→Controller→Service→Model→View”的流程,是后面所有复杂功能的基石。
3. 加法器跑通后,最容易翻车的三个细节
加法器代码很短,但新手跑起来之后,翻车的点反而比想象的多。我简单列一下实测里最常见的三类问题,以及对应的排查思路。
3.1 参数类型转换失败导致400
这个很典型。@RequestParam("x") int x,前端却传了"abc"——Spring调用StringToNumber转换器时会直接抛异常,默认情况容器返回HTTP 400。
排查方法很简单:看Tomcat日志里是否出现MethodArgumentTypeMismatchException。这是个信号,告诉你类型转换阶段出了问题,而不是业务代码出了错。
解决思路有两个方向:
- 前端做好约束,比如input加type="number",服务端加required
- Controller方法入参改成Integer,允许null;业务里做判空
@PostMapping("/add") public String add(@RequestParam(value = "x", required = false) Integer x, @RequestParam(value = "y", required = false) Integer y, Model model) { if (x == null || y == null) { model.addAttribute("error", "请完整填写两个数字"); return "calc-form"; } model.addAttribute("sum", addService.add(x, y)); return "calc-result"; }这样增加了容错性,但业务逻辑也开始膨胀。后面我们会看到,更干净的做法是引入参数校验注解,比如@NotNull、@Min,这里先不展开。
3.2 表单method不匹配导致405
我见过不止一个同学在Thymeleaf模板里写了method="post",Controller却用的是@GetMapping("/calc/add"),结果提交时直接405。直觉反应往往是“代码没问题啊”,但Spring的@RequestMapping映射本身就包含了一个约束:请求方法必须匹配。
排查建议:先看浏览器开发者工具里Network的请求信息,确认客户端发的是什么Method、实际命中的URL是什么。再用Postman直接请求一次,如果同样的URL用POST成功而用GET失败,基本可以断定是方法映射不匹配。
3.3 中文乱码:CharacterEncodingFilter的作用
表单页面如果提交中文字符,不处理编码的话,Controller里拿到的就是乱码。原因很简单:HTTP请求体的字节流,默认按照容器的编码解码,Tomcat 8以上的版本HTTP请求行URI默认UTF-8,但POST请求体的编码默认却不是UTF-8。
Spring Boot项目里,可以通过配置一个CharacterEncodingFilter来统一处理:
@Bean public CharacterEncodingFilter characterEncodingFilter() { CharacterEncodingFilter filter = new CharacterEncodingFilter(); filter.setEncoding("UTF-8"); filter.setForceEncoding(true); return filter; }Spring Boot其实已经在自动配置里带了这个Filter,默认编码就是UTF-8,前提是你没有改过server.servlet.encoding配置。如果用的是传统Spring MVC项目,就需要在web.xml里手动注册对应的Filter。这个细节,期末答辩被问到过的人应该懂。
我把这三种典型问题汇总成一张表,方便对照:
| 现象 | 根源 | 排查方向 |
|---|---|---|
| 提交表单后HTTP 400 | 参数缺失或类型转换失败 | 看是否MethodArgumentTypeMismatchException |
| 提交表单后HTTP 405 | 请求方法不匹配 | 检查表单method与Controller方法注解 |
| 中文乱码 | 编码过滤器未生效 | 检查UTF-8 Filter配置 |
4. 用户登录0.5版:用Session手工维护登录态
加法器验证了“无状态请求”的处理。但Web应用真正复杂的地方在于“状态”:用户登录一次,之后访问页面时,服务端怎么知道是他?
先要认清一个常见误区:登录功能不是“校验密码正确,然后弹一个成功页面”就完了。登录的本质是认证成功后,在服务端建立一个“可信的标识”,让后续请求都能带上这个标识,服务端据此识别用户。这个标识通常放在HttpSession里,由容器自动生成一个JSESSIONID的Cookie返回给浏览器。
4.1 用户数据与密码存储
为了聚焦Spring本身,先不引入数据库,用内存Map充当用户表:
@Repository public class UserRepository { private final Map<String, String> users = new HashMap<>(); public UserRepository() { // 教学演示用,真实项目密码绝不允许明文存储 users.put("admin", MD5Util.md5("123456" + SALT)); } public String getPasswordByUsername(String username) { return users.get(username); } }提醒一下:上面这个写法只是为了让项目在没有数据库的情况下能跑通。实际开发中密码必须用BCrypt这类专门算法加盐哈希,内存Map也不适合做用户存储。课程设计阶段可以这样演示,但一定要在报告或答辩里说清楚这一点,专业印象分会好很多。
4.2 登录Controller:校验、写入Session、重定向
登录流程拆成三个动作:
- 校验用户名密码
- 通过后把用户对象写入Session
- 重定向到登录后的首页
@Controller @RequestMapping("/auth") public class LoginController { private final UserService userService; public LoginController(UserService userService) { this.userService = userService; } @GetMapping("/login") public String loginPage() { return "login"; } @PostMapping("/login") public String doLogin(@RequestParam String username, @RequestParam String password, HttpSession session, Model model) { User user = userService.checkLogin(username, password); if (user == null) { model.addAttribute("error", "用户名或密码错误"); return "login"; } session.setAttribute("loginUser", user); return "redirect:/home"; } @GetMapping("/home") public String home(HttpSession session, Model model) { Object user = session.getAttribute("loginUser"); if (user == null) { return "redirect:/auth/login"; } model.addAttribute("user", user); return "home"; } @PostMapping("/logout") public String logout(HttpSession session) { session.invalidate(); return "redirect:/auth/login"; } }这里有几个点值得展开:
为什么登录成功用redirect:/home,而不是return "home"?直接返回视图名,浏览器地址栏会停在POST /auth/login。用户按F5刷新,浏览器会提示“重新提交表单”,再次POST同一个/login请求,如果前端没有防护,就等于重复登录。用了redirect之后,浏览器会先收到302响应,然后发起GET /home请求,地址栏变成/home,刷新时只是重新GET /home,不会重复提交。
为什么退出登录也用POST?如果退出是GET请求,用户可能被一个链接自动触发,或者浏览器预加载、爬虫访问时意外登出。POST退出更符合操作语义,也降低了跨站请求伪造的基础风险。
为什么home里手工检查session?这个版本为了把核心逻辑摊开看,先做了最直观的手工检查。但问题也很明显:如果后续有更多需要登录的页面,每个方法里都要重复这段判断,代码会变得很啰嗦。这就是下一章要解决的问题。
4.3 测试登录流程
启动项目后,按以下路径验证:
- 访问 http://localhost:8080/auth/login,能看到登录页
- 输入admin / 123456,提交后跳转 /home,页面显示当前用户名
- 浏览器开发者工具里能看到JSESSIONID这个Cookie
- 直接访问 /home,会跳回 /auth/login
浏览器Cookie里那个JSESSIONID,就是会话的“钥匙”。用户第一次访问时容器创建HttpSession,返回一个随机ID;浏览器把ID存进Cookie;后续每次请求都带上这个ID,容器据此找回对应的HttpSession对象。整个机制本身不神奇,神奇的是Spring/Servlet容器把你需要手工处理的ID管理全部透明化了。
5. 用户登录升级版:HandlerInterceptor统一鉴权
手工在每个需要登录的Controller方法里检查session,有三个坏处:
- 重复代码多,10个页面就要写10份
- 容易漏掉某个方法,漏一个就相当于留了一个鉴权漏洞
- 业务方法和“登录判断逻辑”耦合在一起,阅读代码时注意力被打断
Spring提供了拦截器(HandlerInterceptor),可以在请求进入Controller方法之前、之后、渲染完成之后分别插入逻辑。我们只需要在preHandle阶段检查Session,没有登录就重定向到登录页,并且返回false阻止后续执行。
5.1 自定义拦截器
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(request.getContextPath() + "/auth/login"); return false; } }这里用request.getSession(false)而不是request.getSession(),是个值得说明的细节。getSession(true)在没有会话时会强制创建一个新的Session,继续请求放行,这就破坏了我们检查登录的意图。false表示“没有会话就返回null”,更符合鉴权场景。
5.2 注册拦截器并配置放行路径
Spring MVC项目里用WebMvcConfigurer来注册拦截器:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns( "/auth/login", "/error", "/css/**", "/js/**", "/images/**" ); } }配置好之后,Controller里的home方法就变得干净了:
@GetMapping("/home") public String home(Model model, HttpSession session) { model.addAttribute("user", session.getAttribute("loginUser")); return "home"; }登录判断交给了拦截器,Controller只专注于取数、渲染。职责一分离,代码立刻清爽很多。
5.3 拦截器跟Filter的区别
很多新手会问:Servlet里也有Filter,为什么Spring还要搞一个HandlerInterceptor?两者都能在请求前后做处理,区别主要体现在位置和颗粒度:
| 维度 | Filter | HandlerInterceptor |
|---|---|---|
| 所处位置 | Servlet容器层面,在请求进入DispatcherServlet之前 | Spring MVC内部,请求已匹配到Handler之后 |
| 可以拿到的信息 | Servlet的Request/Response | 除了Request/Response,还能拿到Handler对象 |
| 控制范围 | 对进入容器的所有请求生效 | 只对映射到Controller的请求生效 |
| 终止时机 | doFilter里可以放行或拒绝 | preHandle返回false可以中断 |
Filter适合做编码设置、跨域配置这种与业务无关的通用逻辑;HandlerInterceptor适合做与Handler相关的控制,比如登录鉴权、权限校验、请求日志。实际项目里两者经常配合使用。
给一个验证拦截器效果的思路:不登录直接访问 /home,观察浏览器的响应状态和Location,你会发现是302和 /auth/login。在拦截器生效前,这个URL能直接打开;生效后,它会无缝跳到登录页,这个过程本身就是一次很好的“拦截器到底拦在哪一层”的实验。
6. 表单重复提交与登录态的常见坑
两个Demo都跑通之后,可以主动制造并解决几个经典问题。处理这些问题时踩过的坑,往往比“成功跑通”更有价值。下面说几个我认为最值得新手动手验证的。
6.1 PRG模式:Post/Redirect/Get
前面登录成功用了redirect,其实这就是PRG模式的一部分。加法器也可以改成PRG模式,避免用户提交后刷新导致重复计算:
@PostMapping("/add") public String add(@RequestParam int x, @RequestParam int y, RedirectAttributes redirectAttributes) { int sum = addService.add(x, y); redirectAttributes.addFlashAttribute("sum", sum); return "redirect:/calc/result"; } @GetMapping("/result") public String result() { return "calc-result"; }RedirectAttributes里的addFlashAttribute是带“一次性”语义的:数据存进FlashMap,重定向后的下一次GET请求里可读,读完即失效。这正好适合“提交后跳转结果页并成功消息只显示一次”的场景。
用FlashAttribute时要留意两点:
- 页面必须在重定向之后读取,否则数据拿不到
- Flash作用域只持续到下个请求,刷新结果页两次,第二次就看不到sum了
6.2 登录按钮双击与回退按钮
用户登录时双击提交,后端可能连续创建多个Session,旧Session失效问题属于小概率,但重复提交确实会让服务端多处理几次。用PRG模式能缓解刷新的问题,但双击提交仍然会发生两次POST请求,所以很多系统会在前端做个“提交后禁用按钮”的功能。Spring层面能做的,是加上防止重复提交的拦截器,思路是记录每个Session最后一次提交的令牌,新请求来了对比,如果相同就拒绝。
新手阶段,我更建议先理解PRG,理解会话机制,再去碰防重令牌。先知道问题存在,再看解决问题的手段,比一开始就上重型方案要容易吸收。
6.3 会话过期后的体验
默认情况下Tomcat的session超时时间是30分钟。过期后再请求需要登录的页面,拦截器会发现session里没有loginUser,于是跳到登录页。这个跳转机制其实是好的。容易出问题的是页面里的异步请求,比如登录后的首页上有Ajax定时刷新,如果Session过期,Ajax请求会被拦截器重定向到登录页,浏览器收到的是302和HTML页面,而不是预期的JSON,前端代码如果没做相应处理,就会报解析错误。
为了演示这个情况,可以自己做一个定时Ajax请求,然后去application.properties里把server.servlet.session.timeout改短,比如改成1分钟,观察过期后的网络请求行为。这种“让系统变慢”的调试方法,比直接读文档理解会话管理要来得直观。
6.4 Cookie与登录态的安全边界
这一步需要守住一个原则:判断登录态只能依赖服务端Session,而不能信任前端传过来的字段。曾经有个模拟项目X里,开发者图省事,登录成功后把用户ID放进了Cookie,后续页面直接读Cookie里的userId显示用户名,结果被人手工改了Cookie就把任意页面当成了自己的。后来改成统一从Session里取当前用户,才把这个漏洞堵上。
项目里类似的签名、令牌校验都属于安全工作,我这里只提醒一点:HTTP是无状态协议,服务端认“会话标识”,不认“口头自称”。新手阶段先记住Session方案的正确姿势,再慢慢学习各种令牌机制。
7. 把两个Demo整合成一个小型练习项目
加法器和用户登录是完全独立的两块。为了更好地巩固Spring MVC的几条主线,推荐把它们整合成一个极简的“加法器控制面板”练习项目。这样登录态才能真正发挥作用——未登录不能使用计算器,登录后可看到自己的计算历史。
一个可行的目录结构:
src/main/java └── com/example/mycalc ├── MyCalcApplication.java ├── config │ └── WebConfig.java // 拦截器注册 ├── interceptor │ └── LoginInterceptor.java ├── controller │ ├── AuthController.java // 登录、退出 │ └── CalcController.java // 计算器 ├── service │ ├── UserService.java │ └── CalcHistoryService.java // 历史记录存入Session └── repository └── UserRepository.java src/main/resources └── templates ├── login.html ├── calc-form.html └── calc-result.html练习任务可以这样排:
- 让计算器页面必须登录后才能访问,计算历史存到Session,并在登录后的首页展示最近5条记录
- 把用户表从内存Map换成JDBC或者MyBatis,新增一个注册页面
- 给登录页面加上验证码,验证码明文存Session,登录时校验——这个练习能让你彻底理解Session还能传哪些数据
- 用一个拦截器之外的Filter,给所有请求加统一的响应时间日志,和HandlerInterceptor对比一下
- 把登录失败次数限制加到Session里,连续失败5次锁定一段时间
每个任务都对应着Spring Web开发里一个独立的知识点。全部做完,你对下列问题的理解会比只看文档深得多:
- Session里到底能放什么,什么不应该放
- 拦截器路径配置的粒度
- 重定向和跳转在地址栏与请求上的差异
- 类型转换失败时错误消息怎么友好返回
我个人建议的动手顺序是:先把加法器的写法从Servlet原生一步步改成@RequestParam、POJO绑定,跑通后再加登录,最后才考虑拦截器整合。这个顺序和文章结构一致,每走一步都能看到代码变干净的过程,而不是直接拿到一个“天降”的正确写法。
如果做过程中遇到莫名其妙的问题,先去看Tomcat日志里有没有异常堆栈,再看网络面板里请求的Method和URL,大部分Spring MVC入门问题都能在这两个地方找到答案。希望这篇记录能帮你把Spring Web开发的第一条主线,真真正正跑通。