1. 项目概述:一个被反复打磨却始终绕不开的起点
“Servlet实现登录注册”——这八个字,几乎刻在每个刚走出校门的Java后端开发者简历开头。它不像分布式锁那样让人头皮发麻,也不像JVM调优那样需要翻烂三本手册,但它偏偏是面试官手里那把最锋利的“试金石”:不考多深,但考得极细;不问多新,但问得极实。我带过十几届某高校的实训班,每次布置这个任务,总有学生第一反应是去GitHub搜“servlet login register”,结果抄回来的代码跑不通、改不了、加个验证码就崩。为什么?因为绝大多数人只把它当“作业”,没把它当“系统”来看。
它表面是个登录框+注册表单,背后却是一整套Web应用的最小可行骨架:HTTP协议的请求响应生命周期、Servlet容器(如Tomcat)的线程模型、会话管理(Session)与状态保持、表单数据的编码与校验、数据库连接与CRUD操作、前后端交互边界、甚至基础的安全意识(比如密码明文传输、SQL注入防护)。关键词里没有出现“安全”“高并发”“微服务”,但这些能力,全得从这里一砖一瓦垒起来。适合谁?不是只适合零基础新手,更适合作为中级开发者回炉重造的“压力测试”——当你能不依赖Spring Boot自动配置,手写一套完整、健壮、可调试的Servlet登录注册,并说清每一步背后的容器行为和线程状态时,你才算真正摸到了Java Web的脉门。
我试过用三种方式教这个项目:纯手写(从web.xml开始)、基于Maven骨架快速搭建、以及用IDEA向导生成再反向拆解。最终发现,纯手写路径虽然慢,但留下的肌肉记忆最深。比如,为什么<url-pattern>/login</url-pattern>必须写成斜杠开头?为什么request.setCharacterEncoding("UTF-8")必须放在request.getParameter()之前?为什么response.sendRedirect()之后还要return?这些问题,只有亲手在doPost方法里打断点、看堆栈、查Tomcat源码片段,才能真正理解。这不是复古情怀,而是对底层契约的尊重。接下来的内容,我会以一个真实项目迭代的节奏展开:从最简Servlet响应,到带数据库验证的登录,再到含邮箱激活的注册闭环,每一步都附带我在某公司内部培训中总结的“三秒自查清单”——即你在敲完代码后,用三秒钟就能判断是否埋了雷的检查项。
2. 整体设计思路与方案选型逻辑
2.1 为什么坚持用原生Servlet,而不是直接上Spring Boot?
这是所有初学者最先问的问题。答案很实在:Spring Boot是汽车,Servlet是发动机原理图。你坐进一辆车,可以不懂活塞运动,但如果你想改装排气、调校ECU,或者诊断异响根源,就必须懂四冲程循环。同理,Spring Boot的@Controller、@PostMapping、自动注入的HttpServletResponse,把HTTP请求的流转封装成了黑盒。而Servlet API强制你直面三个核心对象:HttpServletRequest、HttpServletResponse、HttpServlet。它们不是接口,是契约——Tomcat作为容器,只认这个契约。我曾帮某公司排查一个线上问题:用户登录后偶尔跳转到错误页面。最后定位到是response.sendRedirect()后忘了return,导致后续代码继续执行并输出了另一段HTML,浏览器收到的是混合响应。这种问题,在Spring Boot里会被@ControllerAdvice或ResponseEntity兜底,但在Servlet里,它赤裸裸地暴露在线程执行流里。所以,本项目的设计底线是:所有功能必须通过javax.servlet.*包下的标准API实现,不引入任何Spring、Struts等框架类。数据库访问层也仅使用JDBC,不碰MyBatis或Hibernate。这不是守旧,而是为了建立清晰的因果链:输入什么参数 → 容器如何分发 → Servlet如何处理 → 输出什么响应 → 浏览器如何渲染。
2.2 架构分层:为什么只分三层,且拒绝Service层抽象?
很多教程会画出Controller-Service-DAO三层图,然后在Service里写一堆空方法。这反而模糊了焦点。本项目采用极简分层:Servlet层(控制流) + DAO层(数据流) + JSP/HTML层(视图流)。理由很朴素:登录注册的核心逻辑就两件事——校验用户名密码、存取数据库记录。把校验逻辑硬塞进Service,无非是把if (user == null)挪了个地方,还多了一层方法调用开销。真正的复杂度在于边界处理:比如注册时邮箱格式校验、密码强度提示、用户名唯一性检查的时机(前端JS校验只是体验优化,后端必须二次校验)。因此,我把所有校验逻辑集中在Servlet的doPost方法内,用清晰的if-else if-else链组织,每条分支对应一个明确的HTTP状态码和跳转路径。DAO层则严格遵循单一职责:UserDAO只提供findByUsername()、save()两个方法,不暴露Connection或PreparedStatement细节。这样设计,新手能一眼看清数据流向,老手也能快速定位性能瓶颈——比如发现findByUsername()执行慢,直接去看SQL和索引,不用在Service层里扒日志。
2.3 会话管理:为什么用HttpSession,而不是Cookie或Token?
登录成功后,如何让服务器记住“你是谁”?有三个常见选项:客户端Cookie存凭证、服务端Session存状态、无状态Token(如JWT)。本项目选择HttpSession,原因有三:
第一,符合Servlet规范原意。HttpSession是Servlet API内置机制,由容器(Tomcat)自动管理生命周期,创建、销毁、超时、集群复制都有标准实现。你只需调用request.getSession(),容器就负责在后台维护一个内存Map,Key是Session ID,Value是你存进去的对象。
第二,安全性可控。Session ID默认通过Cookie传输(JSESSIONID),但你可以强制要求HTTPS,或在URL重写时禁用(response.encodeURL())。而自己用Cookie存密码哈希,等于把密钥贴在用户浏览器上,风险极高。
第三,调试直观。在IDEA里打断点,session.getAttribute("user")能直接看到用户对象,session.getMaxInactiveInterval()能读到超时秒数。相比之下,JWT需要解析签名、验证过期时间,对新手不友好。当然,HttpSession有缺点:单机部署时内存占用,集群时需配置Session共享。但本项目目标是“理解本质”,不是“生产就绪”,所以选择最贴近规范、最易观察的方案。
提示:
HttpSession默认超时时间为30分钟(Tomcat配置),但实际开发中,建议在登录成功后显式设置:session.setMaxInactiveInterval(60 * 15); // 15分钟。避免用户填完表单才发现Session已失效。
3. 核心细节解析与实操要点
3.1 Servlet生命周期与线程安全陷阱
Servlet不是每次请求都新建实例,而是单实例多线程。Tomcat启动时创建一个LoginServlet对象,后续所有/login请求都由这个对象的doPost()方法处理,但运行在不同线程里。这意味着:Servlet的成员变量(field)是线程不安全的。我见过太多人在这里踩坑:
public class LoginServlet extends HttpServlet { private String username; // ❌ 危险!多个线程会互相覆盖 private Connection conn; // ❌ 更危险!连接被关闭后其他线程还在用 protected void doPost(HttpServletRequest request, HttpServletResponse response) { username = request.getParameter("username"); // 线程A设为"admin" // 线程B同时进来,设为"user123",A还没用完,值就被改了 User user = userDao.findByUsername(username); } }正确做法是:所有请求相关数据必须放在方法局部变量或Request/Session属性里。request和response对象本身就是线程隔离的,每个请求独享一份。所以你应该这样写:
protected void doPost(HttpServletRequest request, HttpServletResponse response) { String username = request.getParameter("username"); // ✅ 局部变量,线程安全 String password = request.getParameter("password"); // 数据库操作必须在方法内获取连接,用完立即关闭 try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE username = ?")) { ps.setString(1, username); ResultSet rs = ps.executeQuery(); // ... 处理结果 } catch (SQLException e) { // 记录日志,不要吞掉异常 e.printStackTrace(); } }注意:
try-with-resources是关键。它保证Connection和PreparedStatement在方法结束前必然关闭,即使发生异常。我曾在一个项目里漏写conn.close(),导致数据库连接池耗尽,整个系统雪崩。后来定下铁律:所有JDBC资源获取,必须配对try-with-resources或显式finally关闭。
3.2 表单提交的字符编码:UTF-8的生死时速
中文用户名、密码提交后变成乱码?这是最经典的字符编码问题。根源在于:HTTP协议本身不规定字符编码,浏览器和服务器必须约定一致。流程是:浏览器用某种编码(如UTF-8)把表单数据转成字节流 → 服务器收到字节流 → 服务器用某种编码(如ISO-8859-1)解码 → 得到字符串。如果两边不一致,就乱码。
解决方案分两步:
第一步:告诉浏览器用UTF-8编码。在登录页HTML的<head>里加:
<meta charset="UTF-8">并在form标签里声明:
<form action="/login" method="post" accept-charset="UTF-8">第二步:告诉服务器用UTF-8解码。在Servlet的doPost方法开头,必须在调用getParameter()之前设置:
request.setCharacterEncoding("UTF-8");为什么必须在getParameter()之前?因为Servlet容器第一次调用getParameter()时,会缓存解码结果。如果先调getParameter("username"),容器用默认编码(通常是ISO-8859-1)解了一次,再调setCharacterEncoding()就晚了,缓存不会刷新。
实操心得:我习惯在所有Servlet的
doPost开头统一加三行:request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); response.setCharacterEncoding("UTF-8");前者解决请求解码,后两者解决响应编码(防止JSP输出中文乱码)。这三行就像开车系安全带,养成习惯,永不遗漏。
3.3 密码安全:从明文存储到BCrypt哈希
“注册时密码明文存数据库”是新手最大雷区。我审过某公司外包团队的代码,发现他们用String password = request.getParameter("password")直接拼SQL插入,密码字段还是VARCHAR(50)。这等于把用户钥匙挂在大门上。
正确姿势是:永远存储密码的哈希值,而非明文。但哈希算法有讲究。MD5、SHA-1已被证明不安全,且它们计算太快,容易被暴力破解。本项目采用BCrypt,它是专为密码设计的自适应哈希算法,特点有二:
- 加盐(Salt):每次哈希都生成随机盐值,即使两个用户密码相同,哈希结果也不同,防止彩虹表攻击。
- 可调成本因子(Cost Factor):通过
BCrypt.hashpw(password, BCrypt.gensalt(12))中的12,控制计算耗时。值越大越安全(12表示2^12次哈希迭代),但也越占CPU。实测Tomcat 8核服务器,cost=12时单次哈希约需80ms,平衡了安全与性能。
集成步骤:
- 引入Maven依赖:
<dependency> <groupId>org.mindrot</groupId> <artifactId>jbcrypt</artifactId> <version>0.4</version> </dependency>- 注册时哈希密码:
String plainPassword = request.getParameter("password"); String hashedPassword = BCrypt.hashpw(plainPassword, BCrypt.gensalt(12)); user.setPassword(hashedPassword); // 存入数据库- 登录时验证密码:
String inputPassword = request.getParameter("password"); String storedHash = userDao.findPasswordByUsername(username); if (BCrypt.checkpw(inputPassword, storedHash)) { // 密码正确 }注意:
BCrypt.gensalt()生成的盐值已嵌入哈希字符串中(形如$2a$12$abc...),所以验证时无需单独存盐。这是BCrypt的设计精妙之处。
4. 实操过程与核心环节实现
4.1 环境准备:从零搭建Tomcat+MySQL开发环境
不依赖IDEA向导,手动配置更能理解各组件职责。以下是我在某实验室给学生演示的标准流程:
Step 1:安装Tomcat 9.x(非10+,因Servlet API版本差异)
- 下载地址:https://tomcat.apache.org/download.cgi(选
tar.gz或zip) - 解压到
/opt/tomcat(Linux/Mac)或C:\tomcat(Windows) - 验证:进入
bin目录,Linux/Mac执行./startup.sh,Windows执行startup.bat,浏览器访问http://localhost:8080,看到Tomcat欢迎页即成功。
Step 2:配置MySQL数据库
- 创建数据库与用户:
CREATE DATABASE login_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'demo_user'@'localhost' IDENTIFIED BY 'StrongPass123!'; GRANT ALL PRIVILEGES ON login_demo.* TO 'demo_user'@'localhost'; FLUSH PRIVILEGES;- 创建用户表(关键字段注释):
CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一索引', email VARCHAR(100) NOT NULL UNIQUE COMMENT '邮箱,唯一索引', password VARCHAR(255) NOT NULL COMMENT 'BCrypt哈希密码,长度必须255', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', is_active TINYINT(1) DEFAULT 1 COMMENT '是否激活,0=未激活,1=已激活' ); -- 添加复合索引提升查询效率 CREATE INDEX idx_username_email ON users(username, email);Step 3:创建Web项目结构(标准Java Web目录)
login-demo/ ├── src/ │ └── main/ │ ├── java/ │ │ └── com/example/login/ │ │ ├── servlet/ │ │ │ ├── LoginServlet.java │ │ │ └── RegisterServlet.java │ │ └── dao/ │ │ └── UserDAO.java │ └── resources/ │ └── db.properties # 数据库连接配置 ├── webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # Servlet配置文件 │ │ └── lib/ # 依赖jar包(mysql-connector-java, jbcrypt) │ ├── login.jsp │ ├── register.jsp │ └── success.jsp └── pom.xml # Maven配置(可选,但推荐)关键经验:
web.xml是Servlet 3.0之前的配置核心。即使现在可用注解,我也坚持手写,因为它是理解容器行为的入口。例如,<load-on-startup>1</load-on-startup>表示Tomcat启动时就初始化该Servlet,避免首次请求延迟。
4.2 登录功能实现:从请求解析到会话建立
LoginServlet是整个流程的中枢。我们按HTTP请求生命周期拆解:
① 请求接收与参数解析
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 设置编码(生死时速) request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); // 2. 获取参数(注意:getParameter返回null时需判空) String username = request.getParameter("username"); String password = request.getParameter("password"); // 3. 基础校验(防御性编程) if (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) { request.setAttribute("error", "用户名或密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; // ⚠️ 必须return,否则继续执行 } }② 数据库查询与密码验证
// 4. 查询用户(DAO层封装) UserDAO userDao = new UserDAO(); User user = userDao.findByUsername(username); if (user == null) { request.setAttribute("error", "用户名不存在"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 5. BCrypt密码验证(注意:checkpw第一个参数是明文,第二个是哈希值) if (!BCrypt.checkpw(password, user.getPassword())) { request.setAttribute("error", "密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; }③ 会话建立与重定向
// 6. 创建会话(如果不存在则创建,存在则返回) HttpSession session = request.getSession(true); session.setMaxInactiveInterval(60 * 15); // 15分钟超时 // 7. 将用户信息存入会话(避免存敏感字段如密码) session.setAttribute("username", user.getUsername()); session.setAttribute("userId", user.getId()); // 8. 重定向到成功页(不是转发!避免F5刷新重复提交) response.sendRedirect(request.getContextPath() + "/success.jsp");重要区别:
forward()是服务器内部跳转,URL不变,且request对象传递;sendRedirect()是客户端重定向,URL变为新地址,request对象丢失。登录成功后必须用sendRedirect(),否则用户刷新success.jsp会重新触发doPost,造成逻辑混乱。
4.3 注册功能实现:从邮箱校验到激活闭环
注册比登录复杂在状态流转:填写表单 → 服务端校验 → 存库 → 发送激活邮件 → 用户点击链接激活。本项目简化为“邮箱格式校验+唯一性检查+存库”,但保留激活字段为后续扩展。
① 前端表单增强register.jsp中,邮箱输入框增加HTML5校验:
<input type="email" name="email" required placeholder="请输入邮箱" pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$">同时用JavaScript做实时提示(非必需,但提升体验):
document.getElementById('email').addEventListener('blur', function() { const email = this.value; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { alert('邮箱格式不正确'); } });② 后端注册逻辑
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); String username = request.getParameter("username"); String email = request.getParameter("email"); String password = request.getParameter("password"); // 1. 多维度校验(顺序很重要!) if (username == null || username.length() < 3 || username.length() > 20) { request.setAttribute("error", "用户名长度3-20位"); request.getRequestDispatcher("/register.jsp").forward(request, response); return; } if (email == null || !email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) { request.setAttribute("error", "邮箱格式不正确"); request.getRequestDispatcher("/register.jsp").forward(request, response); return; } // 2. 数据库唯一性检查(必须在存库前!) UserDAO userDao = new UserDAO(); if (userDao.findByUsername(username) != null) { request.setAttribute("error", "用户名已存在"); request.getRequestDispatcher("/register.jsp").forward(request, response); return; } if (userDao.findByEmail(email) != null) { request.setAttribute("error", "邮箱已被注册"); request.getRequestDispatcher("/register.jsp").forward(request, response); return; } // 3. 存库(BCrypt哈希密码) User user = new User(); user.setUsername(username); user.setEmail(email); user.setPassword(BCrypt.hashpw(password, BCrypt.gensalt(12))); user.setActive(true); // 简化版,直接激活 boolean saved = userDao.save(user); if (saved) { response.sendRedirect(request.getContextPath() + "/login.jsp?registered=true"); } else { request.setAttribute("error", "注册失败,请重试"); request.getRequestDispatcher("/register.jsp").forward(request, response); } }实操心得:唯一性检查必须用
SELECT语句查库,不能靠数据库唯一约束后捕获异常。因为后者属于“用异常控制流程”,性能差且难以区分是用户名冲突还是邮箱冲突。我见过一个项目,注册时只检查用户名,结果大量用户用同一邮箱注册,导致后续密码找回功能崩溃。
5. 常见问题与排查技巧实录
5.1 HTTP 404错误:找不到Servlet的七种可能
这是新手最高频问题。当浏览器显示“HTTP Status 404 – Not Found”,别急着重写代码,按此清单逐项排查:
| 检查项 | 具体操作 | 常见原因 |
|---|---|---|
| 1. URL路径是否匹配 | 对比浏览器地址栏URL(如http://localhost:8080/login)与web.xml中<url-pattern>(如/login) | web.xml写成<url-pattern>login</url-pattern>(缺斜杠)→ 应为/login |
| 2. Servlet类是否在正确包路径 | 检查LoginServlet.java是否在src/main/java/com/example/login/servlet/下,且package声明为package com.example.login.servlet; | 包名与物理路径不一致,编译后class文件位置错误 |
| 3. web.xml是否被Tomcat加载 | 查看Tomcat启动日志,搜索Deploying web application directory,确认你的项目名(如login-demo)出现在日志中 | webapp目录未放在Tomcat的webapps下,或项目名含非法字符 |
| 4. Servlet映射是否生效 | 在web.xml中,<servlet>和<servlet-mapping>的<servlet-name>是否完全一致(大小写敏感) | <servlet-name>LoginServlet</servlet-name>与<servlet-name>loginServlet</servlet-name>不匹配 |
| 5. Tomcat端口是否被占用 | 命令行执行netstat -ano | findstr :8080(Windows)或lsof -i :8080(Mac/Linux) | 其他程序(如另一个Tomcat)占用了8080端口 |
| 6. 编译后的class文件是否存在 | 进入target/classes/(Maven)或out/production/(IDEA),检查com/example/login/servlet/LoginServlet.class是否存在 | 没有编译,或IDEA未开启“Build project automatically” |
| 7. 项目上下文路径(Context Path)是否正确 | http://localhost:8080/login-demo/login中的login-demo是项目名,若web.xml中<context-param>未配置,则默认为项目文件夹名 | 项目文件夹名是login_demo,但URL写了login-demo |
我的快速定位法:在Tomcat
logs/catalina.out日志末尾,搜索INFO: Deploying web application archive,看是否打印了你的项目路径。如果没有,说明项目根本没被扫描到,重点查第3、6项。
5.2 登录后Session丢失:用户总被踢回登录页
现象:登录成功跳转到success.jsp,但刷新页面或点击其他链接,又回到登录页。本质是HttpSession未正确维持。
排查步骤:
- 检查JSP中是否禁用了Session:
<%@ page session="false" %>会强制不创建Session,必须删掉或改为true(默认就是true)。 - 检查浏览器Cookie设置:F12打开开发者工具 → Application → Cookies,看是否有
JSESSIONIDCookie。如果没有,说明Session未创建;如果有但每次请求都变,说明Session未复用。 - 检查URL重写是否开启:如果浏览器禁用Cookie,Tomcat会自动在URL后加
;jsessionid=xxx。确保你的重定向URL用了response.encodeRedirectURL():
String redirectUrl = request.getContextPath() + "/success.jsp"; response.sendRedirect(response.encodeRedirectURL(redirectUrl)); // ✅ 自动添加jsessionid- 检查Tomcat配置:
conf/context.xml中<Context>标签是否有sessionCookiePath="/"?如果没有,Session Cookie可能只对子路径有效。
终极验证:在
success.jsp中加入以下代码,直接打印Session ID:<% HttpSession session = request.getSession(false); out.println("Session ID: " + (session != null ? session.getId() : "null")); %>如果每次刷新都不同,说明Session未复用,重点查第2、3项。
5.3 数据库连接失败:ClassNotFoundException与SQLException
问题1:java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver
原因:MySQL JDBC驱动jar包未放入WEB-INF/lib/目录。
解决:下载mysql-connector-java-8.0.33.jar(注意版本兼容Tomcat 9),复制到webapp/WEB-INF/lib/,重启Tomcat。
验证:在Tomcat启动日志中搜索com.mysql.cj.jdbc.Driver,应看到“Driver loaded”字样。
问题2:java.sql.SQLException: Access denied for user 'demo_user'@'localhost'
原因:数据库用户名密码错误,或用户权限不足。
解决:
- 用命令行登录MySQL:
mysql -u root -p,执行SELECT User,Host FROM mysql.user;确认用户存在; - 执行
SHOW GRANTS FOR 'demo_user'@'localhost';确认有ALL PRIVILEGES ON login_demo.*; - 检查
db.properties中URL是否为jdbc:mysql://localhost:3306/login_demo?useSSL=false&serverTimezone=UTC(useSSL=false避免证书问题,serverTimezone=UTC避免时区报错)。
避坑技巧:在
UserDAO构造函数中,用Class.forName("com.mysql.cj.jdbc.Driver")显式加载驱动,并捕获ClassNotFoundException打印详细日志。这样启动时就能发现驱动缺失,而不是等到登录时才报错。
6. 安全加固与生产就绪建议
6.1 SQL注入防护:为什么PreparedStatement是唯一答案
新手常犯错误:用字符串拼接SQL。
// ❌ 危险!用户输入' OR '1'='1 会让SQL变成 SELECT * FROM users WHERE username = '' OR '1'='1' String sql = "SELECT * FROM users WHERE username = '" + username + "'";PreparedStatement通过预编译+参数绑定彻底解决此问题:
// ✅ 安全!?是占位符,数据库引擎会将username作为纯数据处理,不参与SQL语法解析 String sql = "SELECT * FROM users WHERE username = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); // 参数1对应第一个? ResultSet rs = ps.executeQuery();原理:PreparedStatement先将SQL发送给数据库进行语法分析和编译(生成执行计划),再把参数作为独立数据传入。数据库引擎绝不会把参数值当作SQL代码执行。
实操验证:在登录框输入
admin' --(注意空格),如果用拼接SQL,--会注释掉后面密码校验,导致任意密码登录。而用PreparedStatement,输入会被当作文本字符串,查询不到用户,自然失败。
6.2 XSS防护:JSP输出时的EL表达式安全
用户注册时输入<script>alert('xss')</script>作为用户名,登录后在success.jsp显示时弹窗,这就是XSS(跨站脚本攻击)。防护核心是输出编码:把特殊字符转义为HTML实体。
在JSP中,永远不要用<%= username %>直接输出用户数据。正确做法:
- 使用JSTL的
<c:out>标签(需引入<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>):
<c:out value="${username}" /> <!-- 自动转义:< 转 <,> 转 > -->- 或使用EL表达式的
fn:escapeXml()函数(需引入<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>):
${fn:escapeXml(username)}注意:
<c:out>默认escapeXml="true",但显式写出更清晰:<c:out value="${username}" escapeXml="true" />。我习惯在所有用户输入输出处加此标签,形成条件反射。
6.3 密码找回功能:基于时间戳Token的简易实现
虽然标题是“登录注册”,但密码找回是高频需求。这里提供一个轻量级方案,不依赖邮件服务,用Session模拟。
流程:
- 登录页增加“忘记密码?”链接,指向
/forgot-password.jsp; ForgotPasswordServlet接收邮箱,生成一个带时间戳的Token:
String token = username + "_" + System.currentTimeMillis(); // 简化版,实际应加密 String hashToken = BCrypt.hashpw(token, BCrypt.gensalt(10)); session.setAttribute("reset_token", hashToken); session.setAttribute("reset_email", email); response.sendRedirect("/reset-password.jsp?token=" + hashToken);ResetPasswordServlet验证Token有效性(检查Session中是否存在且未过期),然后更新密码。
此方案虽不完美(Token未存库),但比明文密码重置安全得多。生产环境应使用UUID+Redis存储Token,设置5分钟过期。
7. 性能优化与可维护性实践
7.1 数据库连接池:为什么不能每次请求都new Connection
DriverManager.getConnection()每次调用都新建TCP连接,开销巨大。Tomcat内置DataSource连接池,复用连接。
配置步骤(conf/context.xml):
<Resource name="jdbc/LoginDB" auth="Container" type="javax.sql.DataSource" maxTotal="20" maxIdle="10" minIdle="5" initialSize="5" maxWaitMillis="10000" username="demo_user" password="StrongPass123!" driverClassName="com.mysql.cj.jdbc.Driver" url="jdbc:mysql://localhost:3306/login_demo?useSSL=false&serverTimezone=UTC"/>在Servlet中获取连接:
Context initCtx = new InitialContext(); Context envCtx = (Context) initCtx.lookup("java:comp/env"); DataSource ds = (DataSource) envCtx.lookup("jdbc/LoginDB"); Connection conn = ds.getConnection(); // 从池中获取,非新建性能对比:未用连接池时,100次登录请求平均耗时1200ms;启用后降至280ms。连接池是Web应用的“呼吸系统”,不可或缺。
7.2 日志记录:SLF4J+Logback的最小化集成
不记录日志的系统等于盲人开车。本项目用最简方案:slf4j-api+logback-classic。
Maven依赖:
<dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.36</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.11</version> </dependency>在Servlet中使用:
import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class LoginServlet extends HttpServlet { private static final Logger logger = LoggerFactory.getLogger(LoginServlet.class); protected void doPost(...) { logger.info("Login attempt for username: {}", username); if (user == null) { logger.warn("Login failed: user not found - username={}", username); } } }logback.xml配置(src/main/resources/):
<configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <root level="info"> <appender-ref ref="STDOUT" /> </root> </configuration>日志黄金法则:INFO级别记录正常流程(如“用户登录成功”),WARN记录异常但可恢复(如“邮箱已存在”),ERROR记录必须人工介入(如“数据库连接超时”)。避免在循环里打INFO日志,会拖垮性能。
7.3 项目结构演进:从Servlet到MVC的平滑过渡
当项目功能增多(如增加用户资料管理、订单查询),Servlet层