最近带一个新手做Spring入门项目他没有选那种花里胡哨的管理系统就做了两个功能一个加法计算器一个用户登录。做完这两个功能我才发现它们刚好把Spring MVC的主链路给串起来了。加法计算器覆盖了请求接收、参数绑定、类型转换、Service分层和页面渲染用户登录则是把表单提交、校验逻辑、Session会话、重定向和拦截器这些Web开发里最常用的零件全过了一遍。这篇文章就把这两个功能从零开始拆开讲适合刚学完Spring IoC、DI这些概念正愁不知道怎么落地的人。1. 项目整体设计思路两个功能背后的学习脉络1.1 为什么选这个组合加法计算器是最小的Web交互闭环。用户在页面输入两个数字点一下按钮浏览器发请求给后端后端算出结果再渲染回页面。这个过程虽然简单但每一个环节都踩在Spring MVC的核心点上比如DispatcherServlet怎么分发请求、RequestParam怎么把URL参数变成方法入参、Model怎么把数据传到模板视图。用户登录则是把难度往上提一个台阶。它引入了“状态”这个概念。HTTP协议本身是无状态的意味着服务器和浏览器之间默认不记得谁是谁。登录功能要做的核心事情就是让服务器在多次请求之间识别同一个用户。这里要用到Session要处理Cookie里的SessionId要拦截未登录的访问还要在退出时把状态清掉。这些东西在真实项目中天天用但在教科书里往往讲得很抽象只有亲手写一遍才能真正理解。两个功能放在一起还有一个好处它们是递进关系。计算器不需要保存任何状态参数来了算完就返回登录则需要跨请求保存用户状态。先从无状态做起再玩有状态学习曲线非常平滑。1.2 技术选型与开发环境这个项目我推荐用Spring Boot 3.2.x来做理由很直接现在新项目基本都是Spring Boot开局用它能把环境配置的干扰降到最低。有人会觉得标题写的是“Spring”用Spring Boot是不是偏离了实际上Spring Boot是Spring框架的官方封装它底层依然是IoC容器、Spring MVC那一套只是把XML配置、Tomcat启动、依赖版本管理这些繁琐事情自动化了。JDK用17版本构建工具选Maven。IDE用IntelliJ IDEA社区版就足够不需要破解旗舰版一个Spring Boot项目在社区版里跑起来完全没问题。视图层我用Thymeleaf而不是JSP原因后面会详细说先记住这个选择。传统SSM工程要写web.xml、spring-mvc.xml、applicationContext.xml一大堆配置文件光是让Hello World跑起来就能劝退一批人。Spring Boot把这些都内置了你只要关注业务代码本身。这对新手来讲是件好事先通过Boot把Spring MVC的工作流程跑通之后再去读传统配置你会发现那些配置无非就是代码里那些注解的另一种表达方式。1.3 工程结构与分层约定项目不大但分层必须按正规军的习惯来。我的目录结构是这样的src/main/java/com/example/demo/ ├── DemoApplication.java ├── controller/ │ ├── CalculatorController.java │ └── LoginController.java ├── service/ │ ├── CalculatorService.java │ ├── UserService.java │ └── UserRepository.java └── model/ ├── User.java └── WebConfig.java src/main/resources/ ├── static/ └── templates/ ├── calc.html ├── login.html └── dashboard.htmlController层只负责接收请求、调用Service、返回视图名Service层写业务逻辑比如登录校验、加法运算Repository层在这里用内存Map模拟数据库。有人说项目这么小何必分这么多层我的看法是分层的目的不是让代码变多而是让每个类的职责边界变清晰。以后你加一个“修改密码”功能就知道校验逻辑放UserService数据操作放UserRepositoryController只是转发请求思维习惯一旦养成碰到复杂项目就不会乱。2. 加法计算器请求、参数与分层设计的入门实战2.1 先跑通一个最简Controller如果你写过原生Servlet加法计算器大概要这么写从request.getParameter(a)拿到字符串Integer.parseInt转成数字然后业务计算最后request.getRequestDispatcher转发到JSP。这个流程没问题但框架的价值就是把这些重复劳动去掉。用Spring MVC第一步是建ControllerController public class CalculatorController { private final CalculatorService calculatorService; public CalculatorController(CalculatorService calculatorService) { this.calculatorService calculatorService; } GetMapping(/calc) public String calc(RequestParam(defaultValue 0) int a, RequestParam(defaultValue 0) int b, Model model) { int result calculatorService.add(a, b); model.addAttribute(a, a); model.addAttribute(b, b); model.addAttribute(result, result); return calc; } }这里有几个关键点。GetMapping(/calc)告诉DispatcherServlet当浏览器请求/calc这个地址时由calc方法来处理。RequestParam(defaultValue 0)是从请求参数里取值并绑定到方法入参框架自动帮你做了字符串到int的类型转换。我特意加了defaultValue这样用户第一次访问/calc不传任何参数时不会报500错误直接显示0 0 0体验友好很多。Model的作用可以理解成一个“快递袋”这个项目里数据通过它从Controller传递到视图层。我在方法里往model里放了a、b、result三个数据模板里就能用${a}、${b}、${result}取出来。有些教程推荐返回ModelAndView也可以但实际项目中Model用得更多因为方法返回值通常就是视图名两者分开写更直观。2.2 把计算逻辑交给Service新手最容易犯的错就是把所有代码堆在Controller里。加法计算器只有一个add方法放Controller里顶多被吐槽几句不优雅但一旦你以后要处理复杂业务Controller就会膨胀成一个几百行的怪物。所以我建了一个CalculatorServiceService public class CalculatorService { public int add(int a, int b) { return a b; } }然后通过构造器注入到Controller里。Spring容器在创建CalculatorController的时候会先创建一个CalculatorService实例然后把它作为构造器参数传进来。这背后就是依赖注入DI。你不需要自己new不需要关心对象何时创建何时销毁这些全部交给Spring IoC容器管理。我在这里有个经验想分享字段注入Autowired直接打在成员变量上虽然能省几行代码但我建议新手用构造器注入。原因有两个第一构造器注入让依赖关系一目了然看方法签名就知道这个类需要什么第二测试的时候直接自己new一个Service传进去就行不需要启动Spring容器。计算逻辑放Service还有一个好处就是以后要加乘法、除法Controller根本不用动往里加方法就行。这也是单一职责原则最直观的体现。2.3 参数校验与类型转换的坑加法计算器的业务场景里参数校验很容易被忽略。用户在页面上填了“abc”Spring把String转int时会抛异常默认会跳到错误页这个体验很差。在生产项目里参数校验通常用Bean Validation注解来做比如Min、NotNull。这个项目我做了一个轻量级方案在Controller里捕获NumberFormatException并返回错误提示。GetMapping(/calc) public String calc(RequestParam(defaultValue 0) String a, RequestParam(defaultValue 0) String b, Model model) { try { int numA Integer.parseInt(a); int numB Integer.parseInt(b); model.addAttribute(result, calculatorService.add(numA, numB)); } catch (NumberFormatException e) { model.addAttribute(error, 参数必须是整数); } model.addAttribute(a, a); model.addAttribute(b, b); return calc; }注意这里我把参数声明成String自己手动解析。虽然麻烦一点但好处是对异常可控。如果你想完全依赖框架的自动转换可以让Spring的ExceptionHandler或者全局异常处理器来处理转换异常那是另一套写法。关于中文乱码Spring Boot 3里web默认UTF-8基本不会遇到。但如果哪天你接手老项目Spring Boot 2.x配了非UTF-8编码或者Tomcat版本较老请求中文乱码就很常见。解决思路无非两方面请求端设置CharacterEncodingFilter服务端保证URIEncoding。这个排查思路在后面章节还会细讲。2.4 GET和POST到底怎么选加法计算器我用的是GET请求表单提交地址是/calc?methodget。为什么要这样做因为GET适合幂等操作——只是查询和计算不修改服务器上的任何数据带上参数刷新页面完全没问题。用户登录则必须用POST因为用户名和密码属于敏感信息GET会把参数暴露在URL地址栏里还会留在浏览器历史记录里这是绝对不允许的。这个区分很重要。很多新手不管什么操作一律用GET做登录也拿POST参数拼接在URL后面调试的时候自己没感觉但一旦上线就会被人抓包截走密码。判断标准很简单这个请求会改变服务器状态吗会用POST不会用GET。对比项GETPOST参数位置URL地址栏请求体安全性会暴露在地址栏、历史记录相对安全应用场景查询、计算、页面跳转登录、提交表单、修改数据3. 用户登录把HTTP状态管理搞清楚3.1 无状态协议与Session要写用户登录必须先理解一个核心难题HTTP协议记不住人。浏览器访问服务器服务器响应完就“失忆”了下一次请求完全不知道这次是谁来的。这里用一个饭馆的类比。你第一次去那家饭馆吃饭老板不认识你你吃完就离开。第二次你再去老板依然不知道你是谁。Session机制就相当于老板给你发了一张带编号的票据你把票据存在浏览器里Cookie下次来的时候把票据递给老板老板查一下自己的账本就知道这个票据对应的是你。技术上的实现是用户在某个请求中成功登录后服务器在内存里创建了一个HttpSession对象生成一个SessionId通过Set-Cookie头发给浏览器。浏览器之后每次请求都自动带上JSESSIONID这个Cookie服务器靠它找到对应的HttpSession对象。所以你往HttpSession里放了什么数据下一次请求还能取出来。3.2 用户数据存储先别急着上MySQL做一个登录功能要不要一开始就接MySQL我的建议是先用内存模拟。原因很简单一旦引入数据库你就要处理JDBC、连接池、SQL语法、ORM映射这些问题学习的焦点就从“登录逻辑本身”扩散到一堆周边技术上了。我用一个Map来模拟用户表Repository public class UserRepository { private final MapString, User userMap new ConcurrentHashMap(); PostConstruct public void init() { userMap.put(admin, new User(admin, 123456, 管理员)); userMap.put(zhangsan, new User(zhangsan, 123456, 张三)); } public User findByUsername(String username) { return userMap.get(username); } }User实体类很简单字段是username、password、nickname。注意这里密码我用了明文这是为了先跑通登录流程。但必须说清楚明文密码在生产环境里是绝对禁止的。真实项目里密码要经过加盐哈希存储Spring Security里自带的BCryptPasswordEncoder是标准选择。等你把Session这套东西理解透了下一步值得认真学一下Spring Security。PostConstruct是Spring Bean生命周期里的初始化回调注解作用是在UserRepository实例创建完成后自动执行init方法把测试用户准备好。这样你启动应用就有两个账号可以拿来登录。3.3 登录校验Service别把密码比对写进Controller登录校验的逻辑放在哪一层有人觉得就几行代码直接放Controller里得了。我建议遵循统一约定Controller只做“接收请求、调用Service、决定跳转”业务校验全部丢给UserService。Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } public User login(String username, String password) { User user userRepository.findByUsername(username); if (user ! null user.getPassword().equals(password)) { return user; } return null; } }登录成功返回User对象失败返回null。这样Controller里只需要判断是不是null就够了。如果以后规则改了比如用户被锁定不能登录那就在login方法里加判断条件Controller完全不用动。这里有一个设计上的细节确定用户名或密码错误时尽量不要告诉用户到底是哪一个错。提示“用户名或密码错误”是通用做法可以避免攻击者通过这个接口批量探测已注册用户名。4. 登录功能完整实操Controller、Session与拦截器4.1 登录页与表单提交在templates目录下创建login.html。Thymeleaf作为模板引擎最大的优势是页面本身是HTML在浏览器里直接打开也能看到静态效果只是在Servlet环境下才解析th:开头的属性。!DOCTYPE html html langzh xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title用户登录/title /head body h1用户登录/h1 p th:if${error} th:text${error} stylecolor:red/p form th:action{/login} methodpost div用户名input typetext nameusername//div div密码input typepassword namepassword//div button typesubmit登录/button /form /body /htmlth:action{/login}的意思是在渲染时把表单的action属性设置为上下文根路径下的/login。如果不写XML命名空间和th:action直接写action/login也能用但你可能遇到项目部署在带路径前缀的情况比如context-path是/test那表单就会提交到错误地址。用Thymeleaf的URL语法可以自动拼接上下文路径省了这个心。4.2 登录Controller记住用户状态LoginController是这个项目里最重要的一个类。它要处理三种情况打开登录页、提交登录表单、退出登录。Controller 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(String username, String password, HttpSession session, RedirectAttributes redirectAttributes) { User user userService.login(username, password); if (user ! null) { session.setAttribute(loginUser, user); return redirect:/dashboard; } redirectAttributes.addFlashAttribute(error, 用户名或密码错误); return redirect:/login; } GetMapping(/dashboard) public String dashboard(HttpSession session) { User user (User) session.getAttribute(loginUser); if (user null) { return redirect:/login; } return dashboard; } GetMapping(/logout) public String logout(HttpSession session) { session.invalidate(); return redirect:/login; } }关键点在doLogin方法里。登录成功后我把整个User对象塞进sessionsession.setAttribute(loginUser, user)。为什么不只存个用户名因为后续页面往往要展示昵称、头像这类信息如果只存用户名每次都得重新查数据库存的粒度应该满足整个会话期间的基本需要。登录失败后的处理方式我用了PRG模式Post/Redirect/Get。意思是表单提交后不直接返回视图而是先重定向到/login让浏览器发一个GET请求来显示登录页面。这样做的目的是防止用户刷新页面时重复提交表单。失败信息通过RedirectAttributes的addFlashAttribute携带它是一个只被下一次请求消费一次的临时属性刷新页面之后就会消失正好符合“一次性提示”的使用场景。我在开发中发现新手经常把dashboard的逻辑也写在LoginController里这倒不是什么大问题。但别忘了dashboard页面的访问本身就应该被拦截器保护而不是在Controller里写if判断这会把安全问题分散到每个方法里去容易漏掉。这块在下一节解决。4.3 用拦截器挡住未登录访问光在登录成功以后往Session里放数据是不够的你还要保证未登录的用户不能访问受保护页面。最优雅的方案是Spring MVC的拦截器。定义一个LoginInterceptor实现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; } }preHandle在请求进入Controller之前执行。登录过返回true放行没登录重定向到登录页返回false拦截。这里我用了request.getSession(false)加了一个false参数表示如果当前请求没有关联Session就不要新建一个。很多新手用request.getSession()不带参数那会强制创建一个Session结果是没登录的人也会背上一个SessionId既浪费内存又让拦截判断失去意义。然后在配置类里注册拦截器并设置拦截范围Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /css/**, /js/**); } }addPathPatterns(/**)表示拦截所有请求excludePathPatterns则是放走一些不需要登录的路径。很多人会疑惑login页面本身也是请求POST登录操作也应该被拦截才对吧不恰恰相反。登录接口必须对所有用户开放因为拦截器没法替你判断当前用户是谁判断之前你得让人家先来登录。所以/login整个路径无论GET还是POST都要放进白名单。放行静态资源也很重要。如果你忘记/excludePathPatterns(/css/**)登录页面加载的时候浏览器请求CSS样式也会被拦截重定向到LoginController样式全丢。4.4 登出与状态清理登出看似简单但有一个经常被忽视的点。如果只写session.removeAttribute(loginUser)这个Session对象本身还活着SessionId还存在。在安全的视角里一个已经退出登录的Session应该被彻底废弃否则一旦SessionId被第三方窃取仍然可能复用来冒充用户。正确做法是调用session.invalidate()让服务器销毁整个Session对象。浏览器下次请求时即使JSESSIONID还在Cookie里服务器里也已经找不到对应的Session了就会被认为是未登录状态。我建议在登出时顺带把Cookie里的JSESSIONID清掉一致性更好GetMapping(/logout) public String logout(HttpSession session, HttpServletResponse response) { session.invalidate(); Cookie cookie new Cookie(JSESSIONID, null); cookie.setMaxAge(0); cookie.setPath(/); response.addCookie(cookie); return redirect:/login; }4.5 dashboard页面简单展示登录用户登录成功后跳转到/dashboard页面里显示当前用户的昵称。Thymeleaf取Session里的数据可以用session.loginUser.nickname但更推荐先用控制器从Session里取出来放进Model这样模板只依赖Model测试起来也方便。h1欢迎回来span th:text${nickname}昵称/span/h1 pa th:href{/logout}退出登录/a/pController里这样写GetMapping(/dashboard) public String dashboard(HttpSession session, Model model) { User user (User) session.getAttribute(loginUser); if (user null) { return redirect:/login; } model.addAttribute(nickname, user.getNickname()); return dashboard; }加了拦截器之后其实Controller里的null判断有点冗余但留着它也没有坏处相当于多一层防御。真实项目里确实常见“拦截器做第一道防线业务代码再做第二层校验”的写法。5. 实操中常见的坑与排查实录5.1 页面能打开但Controller进不去症状浏览器访问/calc返回404或者空白。排查思路分几步。第一检查GetMapping路径是否和访问路径一致包括大小写Spring MVC对路径是区分大小写的。第二检查方法所在的Controller是否被Spring扫描到如果没有ComponentScan的配置类要放在主启动类所在包及其子包下面。Spring Boot的默认扫描机制是扫描主类所在的包放在外面就不会被装入容器。第三排查视图问题如果你的Controller明明执行了但返回的视图找不到模板页面照样是404或报错。Thymeleaf会报“Error resolving template [calc]”模板文件必须放在src/main/resources/templates目录下后缀和路径都要匹配。现象可能原因排查方向404路径写错、类没被扫描检查URL和注解、调整包位置空白页模板路径不对检查templates目录与返回视图名500参数类型转换失败检查RequestParam传参与参数声明5.2 Session取出来总是null登录成功明明设置了session.setAttribute(loginUser, user)到别的页面一取却是null。这个我见过太多次了原因通常有三个。第一个原因是浏览器禁用了Cookie。SessionId靠Cookie下发Cookie被禁服务器就认不出同一个Session每次请求都是全新的。第二个原因是浏览器换了新标签页但严格隔离了站点数据不同上下文之间SessionId不共享。第三个原因最常见你设置Session和读取Session的请求不在同一个域名或路径下Cookie的Path属性限制了可见范围。比如项目部署在localhost:8080/appCookie的Path是/app你用localhost:8080访问另一个应用当然没有。排查技巧用浏览器开发者工具切到Application面板看Cookie确认请求时有没有自动带上JSESSIONID以及它的值是否和服务器下发的保持一致。5.3 登录页也被拦截器拦了症状访问/login本身没登录过结果被重定向到/login死循环。这个现象十有八九是拦截器配置里忘了excludePathPatterns(/login)。我前面强调过路径白名单必须是第一优先级在注册拦截器时就要想清楚哪些路径是公开的。除了/login本身还有注册页如果以后要加、验证码接口、静态资源凡是未登录状态必须能到达的路径都要放行。不过在写白名单时也要注意别太松比如直接excludePathPatterns(/**)那就等于没写拦截器。另一个隐蔽点是风格差异如果你在拦截器里用了request.getRequestURI().startsWith(/login)之类的字符串判断记得带上项目上下文路径。用配置化的excludePathPatterns就不会有这种问题。关于中文乱码Spring Boot 3默认UTF-8基本不会遇到。但如果你以后给老项目做迁移或者手动改了server.servlet.encoding配置就要检查三处页面meta声明、CharacterEncodingFilter强制编码、请求体的contentType。乱码的排查往往不是一处错了而是链条上某处用了不同的编码集。5.4 一个容易被忽略的小细节在Controller方法参数里声明HttpSession和声明SessionAttribute效果不一样。用SessionAttribute(loginUser)来拿对象对象不存在时Spring会抛异常你还要处理这个异常。而直接用HttpSession然后 getAttribute 判断 null语义非常清楚没有就重定向。我在项目里更倾向于后者因为Java Web对Session的操作本来就是依赖HttpSession的自有API框架注解虽然省事但新手容易被异常信息绕晕。还有一个小细节是登录表单提交的字段名。Controller里String username对应HTML里nameusername两边名字必须完全一致多一个空格、大小写不同都会导致取不到值。出了一个null先检查是不是这里。收尾一点个人体会这个加法计算器加用户登录的小项目我带着几个朋友做过也反复重写过几次。最深的体会是代码能不能跑通只是第一关而真正理解“为什么要这么拆”比“怎么敲代码”重要得多。很多人掌握了一堆注解写法却不知道DispatcherServlet不知道Session为什么存储在服务器内存、不知道拦截器和过滤器有什么区别这样换一个框架就彻底慌了。最后再分享一个小技巧。日常开发中如果一个Controller里的方法超过十个、一个Service里的逻辑超过五百行我就知道该停下来重新审视拆分方式了。加法计算器和登录只是一个起点把这个小项目改造成带MySQL存储、Spring Security权限控制甚至往后做成前后端分离并用JWT代替Session都是很自然的后续方向。每一步扩展你都会更清楚当初引入分层的意义。