1. 项目概述:为什么一个“用户登录注册”功能值得用MVC三层架构重做一遍?
你有没有遇到过这样的情况:写完一个JavaWeb登录页面,把数据库连接、SQL查询、页面跳转全塞在同一个Servlet里?改个密码校验逻辑,得翻三四个文件;加个邮箱验证,发现JSP里已经混着Java代码和HTML;等产品说“注册页要加个图形验证码”,你盯着那堆嵌套的<% %>标签,手抖得不敢敲回车——这根本不是开发,是考古现场。
这个标题里的“JavaWeb之利用MVC三层架构和Servlet+Jsp+JDBC实现用户登陆注册功能”,表面看是个教学级小项目,但背后藏着JavaWeb工程化落地的第一道分水岭。它不是教你怎么写request.getParameter("username"),而是逼你回答三个现实问题:数据从哪来、逻辑在哪跑、页面怎么画。MVC不是银弹,但它强制你把这三件事拆开——Model管数据库映射和业务规则(比如“密码不能少于6位”),View只负责渲染(JSP里连if都该用JSTL而不是<% if %>),Controller(Servlet)则像交通指挥员,只做路由和参数搬运,绝不碰SQL也不写HTML。
我带过的几个刚毕业的开发者,在某高校实验室做模拟项目X时,第一版登录功能用了23行内嵌SQL的JSP,上线三天就因SQL注入被导师叫停;第二版改成纯Servlet处理,但所有校验逻辑散落在doPost方法里,加个手机号格式校验就得改8处;直到第三版严格按MVC分层,他们才第一次体会到“改需求不等于改代码量”——产品经理说“注册成功后发站内信”,我们只在Service层加了个notifyUser()调用,Controller和View一行没动。这种可维护性,才是企业级开发的硬通货。
关键词“Servlet+Jsp+JDBC”不是怀旧复古,而是精准锚定JavaWeb最轻量、最可控的技术栈组合。它不依赖Spring Boot的自动配置,让你看清每个HTTP请求如何穿过Tomcat容器、被哪个Servlet拦截、怎样通过JDBC驱动连接MySQL、最终把User对象塞进request域再转发给JSP。这种“裸机感”对理解Web本质至关重要——就像学开车先摸手动挡,不是因为手动挡高级,而是它能让你感知离合、油门、档位之间的物理反馈。而“用户登陆注册”这个场景,恰好覆盖了Web开发90%的核心能力:表单提交、会话管理、密码加密、数据库增删查改、前后端数据流转、错误提示机制。它小到能在一个下午完成,又大到能照见你整个知识结构的漏洞。
所以别把它当练手Demo。当你在IDEA里新建一个Maven项目,敲下第一个public class UserServlet extends HttpServlet时,你真正启动的是一套工业级协作规范:前端工程师改JSP不用懂DAO层SQL,测试工程师写JUnit只需Mock Service接口,运维部署时知道Controller层就是流量入口。这个标题的价值,从来不在功能本身,而在于它用最朴素的技术组件,搭建起软件工程的第一座脚手架。
2. 架构设计与分层逻辑:为什么MVC不是“把代码切成三块”那么简单?
很多人以为MVC就是把代码按文件夹分成model、view、controller,然后各写各的。我在某公司带新人做某跨平台系统时,见过最典型的反模式:一个UserDaoImpl.java里塞了17个SQL语句,其中5个是重复的SELECT * FROM user WHERE id=?;Service层方法名叫loginCheck(),但里面直接new了一个JDBC连接;JSP页面里用<%= new SimpleDateFormat("yyyy-MM-dd").format(new Date()) %>动态生成时间——这根本不是分层,是把意大利面煮成一锅粥再盛进三个碗。
真正的MVC分层,核心是职责隔离和依赖倒置。我们来看这个登录注册功能里每一层到底该干什么、不该干什么:
2.1 Model层:数据契约与业务规则的守门人
Model层绝不是简单的JavaBean。以User类为例,它必须同时承担三重身份:
- 数据传输载体(DTO):字段与数据库user表严格对应,包括id、username、password、email、create_time等;
- 业务规则容器(Domain Object):提供
isValidPassword()校验方法,内部调用BCrypt加密库比对密文,而不是让Controller去拼接SQL; - 持久化契约(PO):通过JDBCUtils工具类统一管理Connection,避免每个DAO都自己写
Class.forName("com.mysql.cj.jdbc.Driver")。
关键设计点在于:Model层不持有任何数据库连接,也不依赖Servlet API。我见过太多初学者在User类里写request.getSession().setAttribute("user", this),这直接污染了领域模型。正确的做法是——User只管“我是谁、我有什么属性、我能做什么校验”,至于“谁把我存进session”,那是Controller的事。
提示:User类的password字段必须声明为private String password,并提供
setPassword(String rawPassword)方法,在内部调用BCrypt.hashpw(rawPassword, BCrypt.gensalt())。这样即使前端传入明文密码,Model层也自动完成加密,上层无需关心加密细节。
2.2 View层:纯粹的模板引擎,拒绝任何逻辑入侵
JSP在这里的角色,是HTML的增强版模板,不是编程语言。它的边界非常清晰:只做三件事——展示数据、组织结构、响应用户交互事件。所有业务判断必须剥离:
- ❌ 错误示范:
<% if(user.getRole().equals("admin")) { %><a href="/admin">后台</a><% } %> - ✅ 正确做法:Controller将
isAdmin布尔值放入request域,JSP用JSTL<c:if test="${isAdmin}">控制显示。
更关键的是资源加载策略。登录页的CSS和JS不能写死在JSP里,而应通过<link rel="stylesheet" href="${pageContext.request.contextPath}/css/login.css">动态解析路径。我踩过的坑是:本地调试时路径正常,但部署到Tomcat子目录(如/myapp/)时所有静态资源404——因为没加contextPath前缀,浏览器直接请求/css/login.css而非/myapp/css/login.css。
注意:JSP中禁止出现任何Java代码块(
<% %>),必须用JSTL标签库替代。比如循环用户列表用<c:forEach items="${users}" var="u">,空值判断用<c:if test="${not empty errorMsg}">。这不仅是规范,更是安全防线——JSTL会自动转义EL表达式输出,防止XSS攻击。
2.3 Controller层:无状态的请求调度中心
Servlet作为Controller,本质是HTTP协议的翻译官。它的唯一使命是:接收原始请求、转换为领域对象、调用Service、决定响应方式。这里有两个致命误区必须避开:
- 误区一:在Servlet里写SQL。曾有个开发者在LoginServlet的doPost方法里直接写
String sql = "SELECT * FROM user WHERE username=? AND password=?";——这彻底废掉了DAO层,导致密码加密逻辑散落各处; - 误区二:在Servlet里操作session细节。比如
HttpSession session = request.getSession(); session.setAttribute("user", user);看似合理,但违反了“Controller不管理状态”的原则。正确做法是Service返回User对象,Controller再决定是否存入session。
实际编码中,LoginServlet的doPost方法应该像这样精简:
protected void doPost(HttpServletRequest request, HttpServletResponse response) { // 1. 参数封装(非空校验在此层) String username = request.getParameter("username"); String password = request.getParameter("password"); if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) { request.setAttribute("errorMsg", "用户名或密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 2. 调用Service(只传必要参数,不传request/response) User user = userService.login(username, password); // 3. 响应决策(成功跳转主页,失败返回登录页) if (user != null) { request.getSession().setAttribute("currentUser", user); response.sendRedirect(request.getContextPath() + "/home.jsp"); } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } }看到没?没有SQL,没有JDBC连接,甚至没有try-catch——异常由Service层抛出,Controller只处理业务结果。这种设计让单元测试变得极其简单:Mock掉userService,就能100%覆盖所有分支逻辑。
3. 核心模块实现详解:从数据库建表到JSP渲染的完整链路
现在我们把MVC骨架填充血肉。这个过程不是机械复制代码,而是理解每个环节的“为什么”。比如为什么用户表要加UNIQUE KEY username?为什么密码字段长度设为60?为什么注册成功后要用response.sendRedirect()而不是forward()?这些细节背后全是生产环境踩过的坑。
3.1 数据库设计:不只是建表,更是安全边界的划定
MySQL建表语句看着简单,但每个字段类型和约束都暗藏玄机:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名,唯一', `password` varchar(60) NOT NULL COMMENT 'BCrypt加密后的密码,60字符足够', `email` varchar(100) DEFAULT NULL COMMENT '邮箱,允许为空', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-启用,0-禁用', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) COMMENT '强制用户名唯一' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户基础信息表';重点解析三个设计决策:
- password字段长度为60:这不是拍脑袋定的。BCrypt加密后字符串格式为
$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,其中$2a$是算法标识,10是强度因子,后面60位是Base64编码的哈希值。如果设成50,加密结果会被截断,导致永远无法登录; - email字段允许NULL:注册时邮箱非必填项,但若设为NOT NULL,用户不填邮箱就会插入失败。更合理的做法是设DEFAULT NULL,后续通过业务逻辑校验是否需要邮箱验证;
- status字段用tinyint而非enum:虽然MySQL支持ENUM,但Java中处理枚举类型容易引发序列化问题。用整型状态码更稳定,且便于未来扩展(比如增加-1表示“待审核”)。
实操心得:建表后立刻执行
INSERT INTO user(username,password) VALUES('test', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy')插入一条测试数据。这个密码明文是"123456",用BCrypt在线工具可验证。这样启动项目就能直接登录,避免卡在“数据库空”导致前端报500错误。
3.2 JDBC工具类:如何让数据库连接既安全又高效?
JDBCUtils不是简单封装DriverManager.getConnection()。它要解决三个核心问题:连接复用、资源释放、线程安全。我们采用DBCP连接池(Apache Commons DBCP2),而非每次请求都新建连接:
public class JDBCUtils { private static BasicDataSource dataSource; static { dataSource = new BasicDataSource(); dataSource.setUrl("jdbc:mysql://localhost:3306/myweb?useSSL=false&serverTimezone=UTC"); dataSource.setUsername("root"); dataSource.setPassword("123456"); dataSource.setInitialSize(5); // 初始连接数 dataSource.setMaxTotal(20); // 最大连接数 dataSource.setMaxWaitMillis(3000); // 获取连接超时3秒 } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { if (rs != null) try { rs.close(); } catch (SQLException e) { /* 忽略 */ } if (ps != null) try { ps.close(); } catch (SQLException e) { /* 忽略 */ } if (conn != null) try { conn.close(); } catch (SQLException e) { /* 忽略 */ } } }为什么不用DriverManager?因为每次getConnection()都会建立TCP连接,耗时约50-200ms。而连接池预先创建好5个连接,请求时直接分配,耗时降到1ms以内。更重要的是,close()方法在这里不是关闭物理连接,而是归还到连接池——这才是高性能的关键。
注意事项:
dataSource.setInitialSize(5)不能设为0。实测发现,首次请求时连接池为空,会触发同步初始化,导致首屏加载慢2秒以上。设为5能保证服务启动后立即有连接可用。
3.3 DAO层实现:用PreparedStatement堵死SQL注入的门
UserDao接口定义了数据访问契约,其实现类UserDaoImpl必须遵循“参数化查询”铁律:
public class UserDaoImpl implements UserDao { @Override public User findByUsername(String username) { String sql = "SELECT id, username, password, email, create_time, status FROM user WHERE username = ?"; try (Connection conn = JDBCUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); // 用setString()而非字符串拼接 try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setEmail(rs.getString("email")); user.setCreateTime(rs.getTimestamp("create_time")); user.setStatus(rs.getByte("status")); return user; } } } catch (SQLException e) { throw new RuntimeException("根据用户名查询用户失败", e); } return null; } }关键点在于ps.setString(1, username)——它把用户输入的username作为参数值传递,数据库驱动会自动处理引号转义。对比字符串拼接:"SELECT * FROM user WHERE username = '" + username + "'",当username是admin' --时,SQL变成SELECT * FROM user WHERE username = 'admin' --',注释符--让后面密码校验失效,直接登录成功。
实操技巧:在DAO方法中捕获SQLException后,不要直接抛出给上层。要包装成自定义异常(如DataAccessException),并记录详细日志。我在线上环境见过因MySQL连接超时导致的SQLException,如果Controller直接打印堆栈,用户看到的是500错误页,而日志里只有
Communications link failure——根本看不出是数据库问题还是网络问题。
3.4 Service层:业务逻辑的熔断器与编排中心
UserServiceImpl是真正的业务中枢。它不关心数据怎么存,只关心“登录”这件事该怎么定义:
public class UserServiceImpl implements UserService { private UserDao userDao = new UserDaoImpl(); @Override public User login(String username, String password) { // 1. 查询用户(可能为空) User user = userDao.findByUsername(username); if (user == null) { return null; // 用户不存在 } // 2. 状态校验(禁用账户不能登录) if (user.getStatus() == 0) { return null; // 账户已禁用 } // 3. 密码比对(使用BCrypt.matches) if (BCrypt.checkpw(password, user.getPassword())) { // 登录成功,清除敏感字段 user.setPassword(null); // 不把密码传给前端 return user; } return null; } }这里体现了Service层的两大价值:
- 业务规则聚合:登录成功需同时满足“用户存在”、“状态启用”、“密码正确”三个条件,这些规则分散在DAO和Model中,Service负责编排;
- 安全过滤:
user.setPassword(null)是关键防护。如果Controller把完整User对象存入session,JSP里用${user.password}可能意外输出密文,造成信息泄露。
常见问题:为什么Service层要new UserDaoImpl而不是注入?因为本项目未引入Spring,采用最简依赖管理。但要注意——如果DAO需要事务控制(如注册时要同时插入user和profile表),就必须用ThreadLocal绑定Connection,否则每个DAO获取的Connection不同,无法回滚。本项目登录注册功能无需事务,故简化处理。
3.5 JSP页面:用JSTL和EL构建防错前端
login.jsp不是写HTML,而是设计用户旅程。它必须处理至少五种状态:
- 正常加载(空表单)
- 提交后校验失败(用户名为空)
- 登录失败(用户名密码错误)
- 注册成功跳转(URL参数携带success=1)
- CSRF防护(隐藏token字段)
完整login.jsp结构如下:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>用户登录</title> <link rel="stylesheet" href="${pageContext.request.contextPath}/css/login.css"> </head> <body> <div class="login-container"> <h2>用户登录</h2> <!-- 错误提示 --> <c:if test="${not empty errorMsg}"> <div class="error-msg">${errorMsg}</div> </c:if> <!-- 成功提示(注册后跳转) --> <c:if test="${param.success == '1'}"> <div class="success-msg">注册成功!请登录</div> </c:if> <form action="${pageContext.request.contextPath}/login" method="post"> <div class="input-group"> <label for="username">用户名:</label> <input type="text" id="username" name="username" value="${param.username}" required> </div> <div class="input-group"> <label for="password">密码:</label> <input type="password" id="password" name="password" required> </div> <div class="button-group"> <button type="submit">登录</button> <a href="${pageContext.request.contextPath}/register.jsp">立即注册</a> </div> </form> </div> </body> </html>关键细节:
${param.username}实现表单回显:用户输错密码后刷新页面,用户名还在输入框里,不用重新输入;required属性是HTML5原生校验,减轻后端压力;- 所有路径都用
${pageContext.request.contextPath}动态解析,确保部署到任意上下文路径都有效。
实操心得:JSP中
${errorMsg}的值来自Controller的request.setAttribute("errorMsg", "xxx")。如果忘记这步,EL表达式会输出空字符串而非报错,导致调试困难。建议在开发阶段开启JSP调试模式:在web.xml中添加<servlet>配置,设置<init-param>为development=true,这样EL错误会直接显示在页面上。
4. 关键技术点深度解析:Servlet生命周期、会话管理与密码安全实践
前三章讲“怎么做”,这一章必须深挖“为什么这么做”。很多开发者能跑通登录功能,但一问request.getSession(true)和request.getSession(false)区别就卡壳;一说“密码要加密”,就直接用MD5——这些知识盲区,正是线上事故的温床。
4.1 Servlet生命周期:从加载到销毁的三次握手
Servlet不是每次请求都新建实例,而是由Servlet容器(Tomcat)管理其生命周期。理解这个过程,才能写出线程安全的代码:
- 加载与实例化:Web应用启动时,Tomcat读取web.xml或扫描@WebServlet注解,加载LoginServlet类并创建唯一实例(注意:是单例!);
- 初始化(init):调用
init(ServletConfig config)方法,此时可加载配置文件、初始化连接池。这是线程安全的,只执行一次; - 服务(service):每次HTTP请求到达,容器调用
service(HttpServletRequest, HttpServletResponse),再分发到doGet或doPost。这是多线程并发的,doPost方法内不能有共享变量; - 销毁(destroy):应用卸载时调用,释放资源(如关闭连接池)。
这就解释了为什么LoginServlet里不能定义private String username;——多个用户同时登录时,这个字段会被相互覆盖。我见过最惨的案例:一个开发者在Servlet里用static变量缓存用户列表,结果A用户登录后看到B用户的订单数据。
验证方法:在LoginServlet的init()方法里加
System.out.println("Servlet初始化:" + this),在doPost里加System.out.println("请求处理:" + this)。启动项目后多次刷新登录页,观察控制台输出——你会发现init只打印一次,而doPost每次请求都打印,且this的hashCode始终相同。
4.2 HttpSession机制:Cookie、URL重写与会话超时的三角关系
用户登录后,服务器如何记住“你是谁”?靠HttpSession。但Session ID的传递方式有三种,必须根据场景选择:
- Cookie(默认):服务器返回
Set-Cookie: JSESSIONID=ABC123,浏览器后续请求自动带上。问题:用户禁用Cookie时失效; - URL重写:在链接后追加
;jsessionid=ABC123,如<a href="home.jsp;jsessionid=${pageContext.session.id}">。问题:所有URL都要手动重写,且不安全(Session ID暴露在URL中); - 表单隐藏字段:
<input type="hidden" name="jsessionid" value="${pageContext.session.id}">。问题:仅对POST有效,GET请求仍需URL重写。
生产环境最佳实践是优先Cookie,降级URL重写。Tomcat默认开启URL重写,但需在web.xml中配置:
<session-config> <cookie-config> <http-only>true</http-only> <!-- 防止JS读取Session ID --> <secure>true</secure> <!-- 仅HTTPS传输 --> </cookie-config> <tracking-mode>COOKIE</tracking-mode> </session-config>安全警告:
<http-only>true</http-only>必须开启!否则XSS攻击者可通过document.cookie窃取JSESSIONID,冒充用户登录。某次渗透测试中,我们发现某系统未开启此选项,用<script>alert(document.cookie)</script>就拿到了管理员Session。
4.3 密码安全:为什么MD5、SHA-1都是定时炸弹?
“密码加密”是登录功能最易被忽视的安全雷区。我整理了常见错误及正确方案:
| 加密方式 | 是否安全 | 原因 | 替代方案 |
|---|---|---|---|
| 明文存储 | ❌ 绝对禁止 | 数据库泄露即密码泄露 | 必须加密 |
| MD5/SHA-1 | ❌ 已淘汰 | 彩虹表可秒破,GPU每秒计算数十亿次 | 改用BCrypt |
| AES对称加密 | ❌ 错误方向 | 加密可逆,密钥泄露即全盘崩溃 | 密码不可逆,只可验证 |
| BCrypt | ✅ 推荐 | 自带盐值、可调强度、抗暴力破解 | 使用BCrypt.hashpw() |
BCrypt的强度因子(strength)是关键参数。BCrypt.gensalt(10)中10表示2^10次哈希迭代,耗时约200ms。设为4则太快(20ms),易被暴力破解;设为14则太慢(3秒),影响用户体验。实测10是最佳平衡点。
注册时密码处理流程:
// UserServiceImpl.register() String salt = BCrypt.gensalt(10); String hashedPassword = BCrypt.hashpw(rawPassword, salt); user.setPassword(hashedPassword); // 存入数据库的是$2a$10$...格式字符串登录时验证流程:
// UserServiceImpl.login() if (BCrypt.checkpw(inputPassword, storedHashedPassword)) { // 密码正确 }实操技巧:BCrypt.checkpw()方法会自动解析storedHashedPassword中的盐值,无需单独存储盐。这是BCrypt的设计精妙之处——盐值、哈希值、算法标识全部编码在同一字符串中,极大简化了开发。
5. 常见问题排查与避坑指南:从500错误到中文乱码的实战解决方案
再完美的设计,上线也会遇到各种诡异问题。我把过去三年在多个项目中积累的登录注册功能典型故障,按发生频率排序,给出可直接复用的排查路径。
5.1 HTTP 500错误:定位Java异常的黄金三步法
500错误是后端抛出未捕获异常的信号。不要盲目重启服务器,按顺序检查:
第一步:看浏览器Network面板的Response
- 如果返回纯白页,说明异常发生在JSP渲染阶段(如EL表达式错误);
- 如果返回Tomcat默认错误页,说明异常在Servlet或Service层。
第二步:查Tomcat日志(logs/catalina.out)
- 搜索
Caused by:关键字,定位根异常。常见类型:ClassNotFoundException: com.mysql.cj.jdbc.Driver→ MySQL驱动jar包未放入WEB-INF/lib;SQLException: Access denied for user→ 数据库用户名密码错误;NullPointerException→ 某个对象为null,比如userDao未初始化。
第三步:在关键位置加日志在LoginServlet的doPost开头加:
System.out.println("=== LoginServlet接收到请求 ==="); System.out.println("username: " + username); System.out.println("password length: " + (password != null ? password.length() : "null"));如果日志没打印,说明请求根本没到达Servlet——检查web.xml的<servlet-mapping>路径是否匹配。
独家技巧:在web.xml中配置全局错误页,把500错误重定向到友好提示页:
<error-page> <error-code>500</error-code> <location>/error/500.jsp</location> </error-page>这样用户看到的不是Tomcat黄页,而是“系统繁忙,请稍后再试”。
5.2 中文乱码:GET与POST的双重陷阱
中文乱码是JavaWeb新手最高频问题,根源在于字符集不一致。解决方案分两层:
POST请求乱码(表单提交):
- 在LoginServlet的doPost方法第一行加:
request.setCharacterEncoding("UTF-8"); // 必须在getParameter()之前 - 同时确保JSP页面声明
<%@ page contentType="text/html;charset=UTF-8" %>。
GET请求乱码(URL参数,如?username=张三):
- Tomcat默认用ISO-8859-1解码URL,需在conf/server.xml中修改Connector:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" /> <!-- 关键配置 -->
验证方法:在doPost中打印
new String(username.getBytes("ISO-8859-1"), "UTF-8"),如果能正确显示中文,说明是GET乱码;如果仍是乱码,检查JSP页面编码声明。
5.3 JSP无法解析EL表达式:JSTL版本与TLD文件的隐秘战争
现象:${user.username}直接显示在页面上,而不是输出值。原因不是EL被禁用,而是JSTL标签库未正确加载。
解决方案:
- 确保WEB-INF/lib中有
jstl-1.2.jar(不是jstl-api.jar); - JSP顶部必须声明taglib:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - 检查web.xml的Servlet版本是否≥2.4(JSP2.0才支持EL):
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0">
实操心得:如果用Maven,直接在pom.xml中添加:
<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>Maven会自动下载jstl-1.2.jar和标准TLD文件,比手动拷贝更可靠。
5.4 数据库连接失败:从驱动到防火墙的全链路诊断
当JDBCUtils.getConnection()抛出异常,按此顺序排查:
| 检查项 | 命令/操作 | 预期结果 | 故障表现 |
|---|---|---|---|
| MySQL服务是否运行 | systemctl status mysqld(Linux)或任务管理器(Windows) | active (running) | Connection refused |
| 数据库是否存在 | mysql -u root -p -e "SHOW DATABASES;" | 列出myweb数据库 | Unknown database 'myweb' |
| 用户权限是否正确 | mysql -u root -p -e "SELECT host,user FROM mysql.user;" | root用户host为%或localhost | Access denied for user |
| 端口是否被占用 | netstat -an | findstr :3306 | LISTENING | Connection timed out |
| 防火墙是否放行 | sudo ufw status(Ubuntu) | 3306端口状态为ALLOW | 本地能连,远程连不上 |
终极方案:在LoginServlet中写一个测试方法:
public void testDBConnection() { try (Connection conn = JDBCUtils.getConnection()) { System.out.println("数据库连接成功!当前时间:" + conn.createStatement().executeQuery("SELECT NOW()").next()); } catch (Exception e) { e.printStackTrace(); } }在init()方法中调用,服务启动时就能看到连接结果。
6. 项目扩展与工程化演进:从单体Servlet到可维护系统的升级路径
完成基础登录注册只是起点。我在某实验室指导模拟项目X时,团队用两周时间把这个MVC骨架扩展为具备生产可用性的系统。以下是经过验证的演进路线,每一步都解决一个真实痛点。
6.1 引入Filter实现统一权限控制
当系统增加“管理员后台”功能后,每个Servlet都要写if (user == null || !user.isAdmin()) { response.sendError(403); return; }。这时Filter就是救星:
@WebFilter("/admin/*") public class AdminFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; User user = (User) request.getSession().getAttribute("currentUser"); if (user == null || user.getRole() != Role.ADMIN) { response.sendRedirect(request.getContextPath() + "/login.jsp?error=access_denied"); return; } chain.doFilter(req, resp); } }@WebFilter("/admin/*")表示拦截所有/admin/开头的请求。这样Controller层彻底解放,专注业务逻辑。
注意事项:Filter链顺序很重要。如果同时有编码Filter和权限Filter,必须让编码Filter先执行,否则
request.getParameter()会乱码。
6.2 用Properties文件管理配置
把数据库URL、用户名、密码硬编码在JDBCUtils里是灾难。创建src/main/resources/db.properties:
jdbc.url=jdbc:mysql://localhost:3306/myweb?useSSL=false&serverTimezone=UTC jdbc.username=root jdbc.password=123456在JDBCUtils中用ResourceBundle加载:
static { ResourceBundle bundle = ResourceBundle.getBundle("db"); dataSource.setUrl(bundle.getString("jdbc.url")); dataSource.setUsername(bundle.getString("jdbc.username")); dataSource.setPassword(bundle.getString("jdbc.password")); }这样更换数据库只需改properties文件,无需重新编译Java代码。
6.3 添加验证码功能:从简单数字到图形验证码
基础版用JSP生成随机数即可:
<% String code = String.valueOf((int)(Math.random() * 9000 + 1000)); session.setAttribute("verifyCode", code); %> <input type="text" name="verifyCode" placeholder="验证码"> <span style="color:red"><%=code%></span>但生产环境必须用图形验证码。推荐使用Kaptcha库,它能生成带干扰线、旋转字符的图片:
<dependency> <groupId>com.github.penggle</groupId> <artifactId>kaptcha</artifactId> <version>2.3.2</version> </dependency>配置web.xml:
<servlet> <servlet-name>Kaptcha</servlet-name> <servlet-class>com.google.code.kaptcha.servlet.KaptchaServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>Kaptcha</servlet-name> <url-pattern>/kaptcha.jpg</url-pattern> </servlet-mapping>JSP中引用:<img src="${pageContext.request.contextPath}/kaptcha.jpg" onclick="this.src='${pageContext.request.contextPath}/kaptcha.jpg?'+Math.random()">
实操心得:验证码校验必须在Service层完成,Controller只负责传递参数。这样测试时可以Mock验证码服务,避免每次测试都要识别图片。