1. 从"注册"这个动作说起:为什么逆向思维值得聊
做Web开发的人对注册功能再熟悉不过了。一个表单,几个输入框,提交按钮,后端写库,完事。但如果你把视角倒过来——不是站在"我要实现注册功能"的角度,而是站在"我要理解一个注册功能是怎么被设计出来的"角度——你会发现很多平时忽略的东西。
"JSP逆向思维之注册篇"这个标题,核心不在于教你写一个注册页面。写注册页面太简单了,一个刚学JSP的人半小时就能搞定。它真正想说的是:通过逆向拆解一个已有的注册功能,去理解JSP在整个请求链路中扮演的角色、它的设计边界在哪里、以及为什么很多看起来"能跑"的代码其实埋着坑。
这篇文章适合谁看?如果你正在学JSP,或者工作中需要维护一些基于JSP的老系统,再或者你单纯想搞清楚"一个注册请求从浏览器发出到数据库落盘,中间到底经历了什么",那这篇内容会对你有帮助。我会从一个注册功能的完整链路出发,逆向拆解每个环节的设计意图,补充实操中容易踩的坑,以及一些教科书上不会写的经验。
需要提前说明的是,这里讨论的JSP指的是经典的JavaServer Pages技术栈,配合Servlet容器(比如Tomcat)运行的传统Web应用模式。虽然现在前后端分离是主流,但大量存量系统仍然跑在这套架构上,理解它的运作逻辑对于维护和改造这些系统非常关键。
2. 逆向拆解注册功能:从结果倒推设计
2.1 先看"注册成功"这个结果包含哪些信息
大部分人学JSP的时候,老师会直接告诉你:建一个register.jsp,写表单,再建一个RegisterServlet,在doPost里拿参数、写数据库。这是正向思维——从零开始搭。
逆向思维反过来:先假设系统里已经有一个能正常工作的注册功能,然后问自己几个问题。
第一个问题:用户点击"注册"按钮之后,数据去了哪里?表面上看是提交到了一个URL,但这个URL背后是谁在处理?在JSP+Servlet的架构里,有两种可能:一种是直接提交到另一个JSP页面,另一种是提交到一个Servlet。这两种方式看起来都能跑,但设计含义完全不同。
提交到JSP页面处理,意味着业务逻辑和页面渲染混在一起。你会在JSP里看到<% request.getParameter("username") %>这样的代码,然后直接调JDBC写库。这种方式在早期JSP教程里非常常见,因为上手快。但它的问题是:JSP本质上是一个视图技术,它的职责应该是渲染输出,而不是处理业务逻辑。把注册逻辑写在JSP里,等于让前台接待员去干后厨的活——能干,但迟早出乱子。
提交到Servlet处理,是更合理的分工。Servlet负责接收请求、校验参数、调用业务层、写数据库,然后根据结果决定是重定向到成功页面还是转发回注册页并附带错误信息。JSP只负责把结果展示出来。
第二个问题:注册成功之后,服务器怎么知道"这个用户已经登录了"?答案通常是Session。但Session是什么时候创建的?是在注册成功之后手动创建,还是在用户第一次访问网站时就自动创建了?这个细节很多人没注意过。默认情况下,JSP页面会自动创建Session(除非显式设置session="false"),也就是说,用户可能还没注册,服务器就已经给他分配了一个Session对象。这本身不是问题,但如果你在注册逻辑里依赖Session来判断"是否已登录",就要小心Session的创建时机。
第三个问题:如果注册失败,用户看到的错误信息是怎么传回来的?常见做法是在Servlet里把错误信息塞进request作用域,然后forward回注册页面,页面上用<c:if>或者脚本片段来判断并显示。但这里有个容易忽略的点:forward和redirect的区别。forward是服务器内部跳转,浏览器地址栏不变,request作用域里的数据可以传递;redirect是告诉浏览器重新请求另一个地址,request作用域数据丢失,需要借助Session或者URL参数来传递信息。
2.2 为什么选择逆向拆解而不是正向搭建
正向搭建的问题是,你很容易陷入"能跑就行"的思维定式。代码跑通了,注册成功了,就觉得学会了。但一旦遇到问题——比如中文乱码、重复注册、SQL注入——就不知道从哪下手。
逆向拆解的好处是,你会被迫去思考每个环节"为什么这么设计"。比如:
- 为什么表单的
method要用POST而不是GET?因为注册涉及密码等敏感信息,GET会把参数暴露在URL里,而且URL长度有限制。 - 为什么要在前端做校验之后,后端还要再做一次?因为前端校验可以被绕过,后端校验才是最后一道防线。
- 为什么密码不能明文存数据库?因为一旦数据库泄露,用户的密码就全完了。即使你觉得"我这个系统没人会攻击",也不应该明文存密码——这是职业底线。
这些问题,正向搭建的时候你可能根本不会想。但逆向拆解的时候,每一个设计决策都会浮出水面。
3. 注册链路中的核心技术点拆解
3.1 表单提交:GET和POST的选择不是随便定的
先看一个典型的注册表单长什么样:
<form action="register" method="post"> <input type="text" name="username" /> <input type="password" name="password" /> <input type="password" name="confirmPassword" /> <input type="submit" value="注册" /> </form>这个表单里,action指向的是register,method是post。看起来很简单,但每个属性都有讲究。
action的值可以是相对路径,也可以是绝对路径。相对路径的情况下,浏览器会根据当前页面的URL来拼接。比如当前页面是/app/register.jsp,action="register"提交的地址就是/app/register。这个细节在部署到不同上下文路径的时候容易出问题。我见过不少项目在本地跑得好好的,部署到服务器上就404了,原因就是action写的是相对路径,但页面URL的层级变了。
更稳妥的做法是用<c:url>标签或者request.getContextPath()来拼接绝对路径:
<form action="${pageContext.request.contextPath}/register" method="post">这样不管页面在哪个层级,提交的地址都是正确的。
method的选择前面提过了,注册必须用POST。但这里还有一个容易被忽略的点:如果表单里包含文件上传(比如上传头像),enctype必须设置为multipart/form-data,否则文件内容传不过去。而且一旦设置了这个enctype,后端就不能用request.getParameter()直接拿普通字段了,需要用文件上传组件(比如Apache Commons FileUpload)来解析请求体。
3.2 中文乱码:一个老生常谈但总有人踩的坑
中文乱码是JSP开发中最经典的问题之一。注册功能里,用户名、昵称、地址这些字段都可能包含中文。乱码的根源在于字符编码不一致。
浏览器发送请求时,会根据页面的编码来决定参数的编码方式。如果页面是UTF-8,浏览器就会用UTF-8编码参数。但Tomcat默认的请求体解码方式是ISO-8859-1,这就导致中文变成乱码。
解决办法是在读取任何参数之前,先设置请求的编码:
request.setCharacterEncoding("UTF-8");注意,这行代码必须在调用request.getParameter()之前执行,否则无效。因为一旦调用了getParameter(),请求体就已经被解析了,再设置编码也来不及了。
对于GET请求的参数,setCharacterEncoding是不起作用的。GET请求的参数在URL里,Tomcat对URL的解码方式由server.xml里的Connector配置决定。可以在Connector标签里加上URIEncoding="UTF-8"来解决。
还有一个更省事的办法:在web.xml里配置一个字符编码过滤器,统一处理所有请求的编码:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>如果不用Spring,自己写一个Filter也很简单,核心就是request.setCharacterEncoding("UTF-8")和response.setContentType("text/html;charset=UTF-8")。
注意:
response.setCharacterEncoding和request.setCharacterEncoding是两回事。前者影响的是服务器往浏览器写数据时的编码,后者影响的是服务器读取浏览器请求数据时的编码。两个都要设置,才能保证双向都不乱码。
3.3 参数校验:前端是体验,后端是底线
注册表单的校验分前端和后端两层。前端校验用JavaScript或者HTML5的表单属性(比如required、pattern、minlength),目的是让用户在提交之前就能发现错误,提升体验。后端校验用Java代码,目的是防止恶意用户绕过前端直接提交非法数据。
前端校验的常见做法:
<input type="text" name="username" required minlength="3" maxlength="20" pattern="[a-zA-Z0-9_]+" />这段HTML5代码会在浏览器层面做基本校验:必填、长度3到20、只允许字母数字和下划线。但用户完全可以打开开发者工具删掉这些属性,或者直接用工具构造请求。所以后端必须再校验一遍。
后端校验的内容包括但不限于:
- 用户名是否为空、长度是否在允许范围内、是否包含非法字符
- 密码是否符合复杂度要求
- 两次输入的密码是否一致
- 用户名是否已经被注册
- 邮箱格式是否正确(如果注册需要邮箱)
这里有一个实操中容易忽略的点:校验的顺序。应该先做格式校验(非空、长度、格式),再做业务校验(用户名是否已存在)。因为格式校验不需要查数据库,速度快;业务校验需要查库,相对慢。如果格式都不对,就没必要去查库了。
另外,校验失败后的处理方式也值得注意。如果是在Servlet里校验,失败后应该把错误信息和一个标记(比如hasError)放进request作用域,然后forward回注册页面。注册页面根据这个标记来决定是否显示错误提示。不要用response.sendRedirect,因为重定向会丢失request作用域的数据。
3.4 密码处理:明文存储是不可接受的
密码处理是注册功能里最需要认真对待的部分。明文存储密码是绝对不可接受的,即使你觉得"这只是个练习项目"。因为习惯一旦养成,到了真实项目里就会出大问题。
正确的做法是存储密码的哈希值。但简单的MD5或SHA-1已经不够安全了,因为这些算法计算速度太快,容易被暴力破解。现在推荐的做法是用专门为密码存储设计的算法,比如bcrypt、scrypt或者Argon2。
以bcrypt为例,Java里可以用BCryptPasswordEncoder(Spring Security提供)或者jBCrypt库:
String hashedPassword = BCrypt.hashpw(plainPassword, BCrypt.gensalt());bcrypt的特点是计算速度慢,而且可以调整"工作因子"来控制慢的程度。慢对于正常登录来说影响不大(几百毫秒),但对于暴力破解来说就是灾难——尝试成本成倍增加。
还有一个细节:bcrypt每次生成的盐值都是随机的,所以同一个密码每次哈希的结果都不一样。验证的时候需要用BCrypt.checkpw方法,它会从存储的哈希值里提取盐值,然后对输入的密码做同样的哈希运算再比较。
实操心得:如果你在维护一个老系统,发现密码是MD5存储的,不要直接改成bcrypt——因为老用户的密码是MD5值,你没法反向算出明文再哈希。常见的做法是"渐进式迁移":用户下次登录时,用MD5验证通过后,把密码升级为bcrypt存储。这样老用户无感知,新用户直接用bcrypt。
3.5 防重复提交:不只是前端禁用按钮那么简单
注册功能里,用户可能会因为网络慢而多次点击提交按钮,导致同一份数据被插入多次。前端的做法是提交后禁用按钮:
document.querySelector('form').addEventListener('submit', function() { document.querySelector('input[type=submit]').disabled = true; });但这只能防住"正常用户的手抖",防不住恶意请求。后端的防重复提交通常有两种思路:
第一种是在数据库层面加唯一约束。比如用户名字段加UNIQUE索引,这样即使多次插入,也只有第一次会成功,后面的会抛异常。这是最可靠的兜底方案。
第二种是在应用层面做令牌校验。页面渲染时生成一个随机令牌,存在Session里,同时放在表单的隐藏字段里。提交时比较两个令牌是否一致,一致则处理并删除Session里的令牌,不一致则拒绝。这样同一个令牌只能用一次。
// 生成令牌 String token = UUID.randomUUID().toString(); session.setAttribute("registerToken", token); // 页面隐藏字段 // <input type="hidden" name="token" value="${registerToken}" /> // 提交时校验 String sessionToken = (String) session.getAttribute("registerToken"); String requestToken = request.getParameter("token"); if (sessionToken == null || !sessionToken.equals(requestToken)) { // 重复提交或非法请求 } session.removeAttribute("registerToken");两种方案可以结合使用,数据库唯一约束是最后一道防线,令牌校验则能在应用层提前拦截。
4. 完整实操:从零搭一个可用的注册模块
4.1 项目结构和依赖准备
先明确项目结构。一个典型的JSP+Servlet项目(非Maven的Web项目)目录结构大致如下:
webapp/ ├── WEB-INF/ │ ├── web.xml │ └── lib/ (放jar包) ├── register.jsp ├── register-success.jsp └── css/ └── style.css如果用Maven管理依赖,pom.xml里需要加上Servlet API和JSTL:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>数据库驱动根据你用的数据库来选,MySQL的话加上mysql-connector-java。连接池推荐用HikariCP或者Druid,不要每次请求都DriverManager.getConnection,那样性能很差。
4.2 注册页面的JSP写法
注册页面本身不复杂,但有几个细节值得注意。下面是一个相对完整的示例:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>用户注册</title> </head> <body> <h2>注册新账号</h2> <c:if test="${not empty errorMsg}"> <div class="error">${errorMsg}</div> </c:if> <form action="${pageContext.request.contextPath}/register" method="post"> <div> <label>用户名:</label> <input type="text" name="username" value="${param.username}" required /> </div> <div> <label>密码:</label> <input type="password" name="password" required /> </div> <div> <label>确认密码:</label> <input type="password" name="confirmPassword" required /> </div> <div> <label>邮箱:</label> <input type="email" name="email" value="${param.email}" /> </div> <button type="submit">注册</button> </form> </body> </html>几个关键点:
第一,contentType="text/html;charset=UTF-8"必须写在页面指令里,这样浏览器才知道用UTF-8来解析页面。
第二,错误信息的显示用了JSTL的<c:if>,比脚本片段<% if (...) { %>更干净。${param.username}和${param.email}的作用是回显用户上次输入的内容,避免用户重新填写。注意密码字段不要回显,这是安全考虑。
第三,表单的action用了${pageContext.request.contextPath},保证上下文路径正确。
4.3 Servlet端的处理逻辑
Servlet是注册功能的核心。下面是一个完整的doPost方法示例,包含了参数获取、校验、密码哈希、写库、跳转等环节:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); String confirmPassword = request.getParameter("confirmPassword"); String email = request.getParameter("email"); // 格式校验 if (username == null || username.trim().isEmpty()) { forwardError(request, response, "用户名不能为空", username, email); return; } if (username.length() < 3 || username.length() > 20) { forwardError(request, response, "用户名长度必须在3到20之间", username, email); return; } if (!username.matches("[a-zA-Z0-9_]+")) { forwardError(request, response, "用户名只能包含字母、数字和下划线", username, email); return; } if (password == null || password.length() < 6) { forwardError(request, response, "密码长度不能少于6位", username, email); return; } if (!password.equals(confirmPassword)) { forwardError(request, response, "两次输入的密码不一致", username, email); return; } // 业务校验:用户名是否已存在 UserDao userDao = new UserDao(); if (userDao.findByUsername(username) != null) { forwardError(request, response, "该用户名已被注册", username, email); return; } // 密码哈希 String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt()); // 写库 User user = new User(); user.setUsername(username); user.setPassword(hashedPassword); user.setEmail(email); user.setCreateTime(new Date()); try { userDao.insert(user); } catch (Exception e) { forwardError(request, response, "注册失败,请稍后重试", username, email); return; } // 注册成功,重定向到成功页面 response.sendRedirect(request.getContextPath() + "/register-success.jsp"); } private void forwardError(HttpServletRequest request, HttpServletResponse response, String errorMsg, String username, String email) throws ServletException, IOException { request.setAttribute("errorMsg", errorMsg); request.setAttribute("username", username); request.setAttribute("email", email); request.getRequestDispatcher("/register.jsp").forward(request, response); }这段代码里有几个设计决策值得解释。
为什么用forward而不是redirect来返回错误?因为forward可以保留request作用域里的数据(错误信息和回显内容),而redirect会丢失这些数据。如果非要用redirect,就得把错误信息放到Session里,用完再删,比较麻烦。
为什么注册成功用redirect而不是forward?因为注册成功后,用户看到的应该是成功页面,而不是注册页面的URL。如果用forward,浏览器地址栏还是/register,用户刷新页面会重新提交表单(虽然此时可能因为用户名已存在而失败,但体验不好)。redirect让浏览器重新请求成功页面,地址栏变成/register-success.jsp,刷新也不会重复提交。
为什么密码哈希放在业务校验之后?因为哈希计算相对耗时(bcrypt故意设计得慢),如果用户名已经被占用,就没必要做哈希计算了。先做便宜的校验,再做昂贵的操作,这是一个通用的性能优化原则。
4.4 数据库表设计
用户表的设计看起来简单,但有几个字段值得仔细考虑:
CREATE TABLE user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, email VARCHAR(100), create_time DATETIME NOT NULL, status TINYINT DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;username加了UNIQUE约束,这是防重复注册的最后一道防线。password字段长度给到100,因为bcrypt哈希后的字符串长度是60,留一些余量。charset=utf8mb4而不是utf8,因为utf8在MySQL里最多只支持3字节的字符,存不了emoji等4字节字符。status字段用于标记用户状态(正常、禁用等),虽然注册时用不到,但后续扩展会需要。
实操心得:
create_time字段建议在应用层设置,而不是用数据库的DEFAULT CURRENT_TIMESTAMP。因为应用层设置更灵活,而且不同数据库的默认时间函数不一样,应用层设置可以保持代码的可移植性。
4.5 连接池配置
前面提到不要每次请求都新建数据库连接。连接池的配置通常放在context.xml或者通过代码初始化。以HikariCP为例:
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb?useUnicode=true&characterEncoding=utf8mb4"); config.setUsername("dbuser"); config.setPassword("dbpass"); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(30000); HikariDataSource dataSource = new HikariDataSource(config);maximumPoolSize设置为10是一个保守值,适合小型应用。设置太大的话,数据库连接数会成为瓶颈。connectionTimeout设置为30秒,超过这个时间拿不到连接就抛异常,避免请求无限等待。
连接池的初始化应该放在ServletContextListener里,应用启动时执行一次,而不是每次请求都初始化:
public class AppInitializer implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { HikariDataSource ds = createDataSource(); sce.getServletContext().setAttribute("dataSource", ds); } @Override public void contextDestroyed(ServletContextEvent sce) { HikariDataSource ds = (HikariDataSource) sce.getServletContext().getAttribute("dataSource"); if (ds != null) ds.close(); } }5. 常见问题与排查技巧实录
5.1 注册请求返回404:路径问题排查
404是注册功能最常见的错误之一。可能的原因有几种:
第一种,action路径写错了。如果表单的action是/register,但Servlet的url-pattern是/registerServlet,就会404。检查web.xml或者@WebServlet注解里的路径配置。
第二种,上下文路径问题。如果应用部署在/myapp下,表单提交到/register,实际请求的是http://localhost:8080/register,而不是http://localhost:8080/myapp/register。解决办法是用${pageContext.request.contextPath}/register。
第三种,Servlet没有正确注册。如果用@WebServlet注解,确认web.xml的metadata-complete没有设置为true(设置为true会忽略注解)。如果用web.xml配置,确认<servlet>和<servlet-mapping>都写了,而且<servlet-name>一致。
5.2 中文乱码:分场景排查
中文乱码的排查要看乱码出现在哪个环节:
| 乱码场景 | 可能原因 | 解决办法 |
|---|---|---|
| 页面显示乱码 | JSP页面编码与浏览器解析编码不一致 | 页面指令加contentType="text/html;charset=UTF-8" |
| POST参数乱码 | 请求体解码编码不是UTF-8 | request.setCharacterEncoding("UTF-8") |
| GET参数乱码 | URL解码编码不是UTF-8 | Connector加URIEncoding="UTF-8" |
| 数据库存储乱码 | 数据库或表的字符集不是utf8mb4 | 建库建表时指定CHARSET=utf8mb4 |
| 数据库连接乱码 | JDBC URL没有指定编码 | URL加characterEncoding=utf8mb4 |
排查的时候从源头开始:先确认浏览器发送的编码是什么(看页面的meta标签和contentType),再确认服务器读取的编码是什么(看setCharacterEncoding),再确认数据库存储的编码是什么(看表的字符集),最后确认数据库连接使用的编码是什么(看JDBC URL)。
5.3 重复注册:从现象到根因
用户反映"我注册了一次,但数据库里出现了两条记录"。排查思路:
先看是不是用户多次点击了提交按钮。如果是,前端加禁用按钮的逻辑,后端加令牌校验。
再看是不是网络重试导致的。有些浏览器或者代理在请求超时后会重试,导致同一份数据提交两次。这种情况令牌校验也能解决。
最后看是不是代码逻辑有问题。比如在Servlet里先查用户名是否存在,不存在则插入。但如果两个请求同时到达,都查到了"不存在",然后都执行了插入,就会产生两条记录。这是典型的竞态条件。解决办法是依赖数据库的唯一约束,捕获唯一约束冲突异常,而不是先查后插。
try { userDao.insert(user); } catch (DuplicateKeyException e) { forwardError(request, response, "该用户名已被注册", username, email); return; }5.4 密码验证失败:哈希算法的坑
用户注册成功,但登录时提示密码错误。可能的原因:
第一种,注册时存的是明文,登录时比较的是哈希值。检查注册和登录的代码是否用了同样的哈希逻辑。
第二种,bcrypt的盐值问题。bcrypt每次哈希都会生成新的盐值,所以不能用hash1.equals(hash2)来比较。必须用BCrypt.checkpw(plainPassword, hashedPassword)。
第三种,数据库字段长度不够。bcrypt哈希值是60个字符,如果password字段定义的是VARCHAR(50),存进去会被截断,导致验证失败。把字段长度改成VARCHAR(100)或更长。
5.5 常见问题速查表
| 问题现象 | 排查方向 | 快速修复 |
|---|---|---|
| 404 Not Found | 检查Servlet路径、上下文路径 | 用${pageContext.request.contextPath}拼接 |
| 405 Method Not Allowed | 表单method与Servlet的doGet/doPost不匹配 | 确认表单用POST,Servlet重写doPost |
| 中文乱码 | 检查请求编码、响应编码、数据库编码 | 统一设置为UTF-8 |
| 重复插入 | 检查是否有唯一约束、是否有防重令牌 | 加唯一索引+令牌校验 |
| 密码验证失败 | 检查哈希算法、字段长度 | 用bcrypt,字段长度100+ |
| 数据库连接耗尽 | 检查是否用了连接池、连接是否关闭 | 用HikariCP,确保连接归还 |
| 页面报500 | 看服务器日志的异常堆栈 | 根据堆栈定位具体行 |
实操心得:排查问题时,养成看日志的习惯。Tomcat的日志在
logs/catalina.out或者logs/localhost.yyyy-mm-dd.log里。不要只看浏览器上的错误页面,那只是冰山一角。真正的错误信息在服务器日志里。
6. 一些教科书上不会写的经验
6.1 关于JSP的脚本片段
JSP里可以写Java代码,用<% %>包裹。但现代JSP开发中,应该尽量避免在JSP里写Java代码。原因有三:一是违反MVC分层,二是难以测试,三是容易引入XSS漏洞。
如果确实需要在页面上做逻辑判断,用JSTL和EL表达式。比如判断用户是否登录:
<c:if test="${not empty sessionScope.user}"> 欢迎,${sessionScope.user.username} </c:if>而不是:
<% if (session.getAttribute("user") != null) { %> 欢迎,<%= ((User)session.getAttribute("user")).getUsername() %> <% } %>后者不仅难看,而且如果username包含HTML特殊字符,还会导致XSS。EL表达式默认会对输出进行HTML转义(在JSP 2.0+中),更安全。
6.2 关于Session的使用
注册功能本身不一定需要Session,但注册成功后的"自动登录"通常需要。这里有一个容易忽略的点:Session Fixation攻击。如果用户在未登录时就有一个Session ID,登录后服务器没有更换Session ID,攻击者就可以利用这个已知的Session ID来劫持用户会话。
解决办法是在登录成功后(注册自动登录也算),调用request.changeSessionId()或者先invalidate()再新建Session:
request.getSession().invalidate(); HttpSession newSession = request.getSession(true); newSession.setAttribute("user", user);6.3 关于SQL注入
如果用JDBC拼接SQL字符串,就会有SQL注入风险:
// 危险写法 String sql = "INSERT INTO user (username, password) VALUES ('" + username + "', '" + password + "')";如果用户输入的用户名是admin'); DROP TABLE user; --,拼接后的SQL就变成了:
INSERT INTO user (username, password) VALUES ('admin'); DROP TABLE user; --', '...')后果不堪设想。正确的做法是用PreparedStatement:
String sql = "INSERT INTO user (username, password, email, create_time) VALUES (?, ?, ?, ?)"; PreparedStatement ps = connection.prepareStatement(sql); ps.setString(1, username); ps.setString(2, hashedPassword); ps.setString(3, email); ps.setTimestamp(4, new Timestamp(System.currentTimeMillis())); ps.executeUpdate();PreparedStatement会对参数进行转义,从根本上杜绝SQL注入。
6.4 关于错误信息的暴露
注册失败时,错误信息应该尽量模糊。比如"用户名或密码错误"而不是"用户名不存在"或"密码错误"。因为后者会帮助攻击者枚举系统里存在哪些用户名。
对于注册功能,如果用户名已存在,提示"该用户名已被注册"是合理的,因为注册场景下用户需要知道这个信息。但在登录场景下,就不应该区分"用户名不存在"和"密码错误"。
6.5 关于验证码
如果注册接口被恶意批量调用,可以考虑加验证码。验证码的实现方式有很多,简单的有算术验证码,复杂的有行为验证码。核心逻辑是:生成验证码图片,把答案存到Session里,提交时比较用户输入和Session里的答案。
// 生成验证码 String code = generateRandomCode(4); session.setAttribute("captcha", code); // 校验 String inputCode = request.getParameter("captcha"); String sessionCode = (String) session.getAttribute("captcha"); if (!code.equalsIgnoreCase(inputCode)) { // 验证码错误 } session.removeAttribute("captcha");验证码的答案存在Session里,用完就删,防止重复使用。验证码的有效期也应该限制,比如5分钟过期。
6.6 关于前后端分离的过渡
现在很多项目在从JSP向前后端分离过渡。注册功能可以改造成后端提供RESTful接口,前端用AJAX调用。后端返回JSON而不是HTML:
response.setContentType("application/json;charset=UTF-8"); Map<String, Object> result = new HashMap<>(); result.put("success", true); result.put("message", "注册成功"); response.getWriter().write(new Gson().toJson(result));前端用fetch或者axios调用:
fetch('/api/register', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username, password, email}) }).then(res => res.json()).then(data => { if (data.success) { location.href = '/login.html'; } else { alert(data.message); } });这种改造的好处是前后端职责更清晰,JSP只负责渲染静态页面或者干脆不用JSP。但改造过程中要注意兼容老接口,避免影响已有用户。
6.7 关于测试
注册功能的测试不能只测"正常流程"。至少要覆盖以下场景:
- 正常注册:所有字段合法,注册成功
- 用户名为空:提示错误
- 用户名过短或过长:提示错误
- 用户名包含非法字符:提示错误
- 密码过短:提示错误
- 两次密码不一致:提示错误
- 用户名已存在:提示错误
- 邮箱格式错误:提示错误(如果校验邮箱)
- 重复提交:只插入一条记录
- SQL注入尝试:不产生异常,不破坏数据
- XSS尝试:用户名包含
<script>,页面上不执行脚本
这些测试可以用JUnit加Mockito来做单元测试,也可以用Selenium做集成测试。手工测试的话,至少要把上面这些场景过一遍。
6.8 关于代码组织
注册功能的代码不应该全部堆在Servlet里。合理的分层是:
- Servlet层:接收请求、调用Service、控制跳转
- Service层:业务逻辑、事务控制
- DAO层:数据库操作
- Model层:数据对象
// Servlet protected void doPost(...) { RegisterRequest req = new RegisterRequest(username, password, confirmPassword, email); RegisterResult result = userService.register(req); if (result.isSuccess()) { response.sendRedirect("success.jsp"); } else { request.setAttribute("errorMsg", result.getMessage()); request.getRequestDispatcher("register.jsp").forward(request, response); } }这样分层的好处是,业务逻辑可以独立测试,Servlet只负责请求调度,代码更清晰,也更容易维护。
6.9 关于日志记录
注册功能应该记录关键操作的日志,比如注册成功、注册失败(以及失败原因)、异常情况。日志用SLF4J加Logback或者Log4j2:
private static final Logger logger = LoggerFactory.getLogger(RegisterServlet.class); logger.info("用户注册成功: username={}", username); logger.warn("用户注册失败: username={}, reason={}", username, reason); logger.error("注册异常", e);日志里不要记录密码等敏感信息。用户名可以记录,但密码绝对不能出现在日志里。
6.10 关于性能
注册功能的性能瓶颈通常在数据库写入和密码哈希上。bcrypt的哈希计算是故意设计得慢的,工作因子越高越慢。默认的工作因子是10,大约需要100毫秒。如果注册量很大,可以考虑降低工作因子到8,或者用异步方式处理哈希计算。
数据库写入方面,确保username字段有索引(唯一约束自带索引),这样查询"用户名是否存在"会很快。批量注册的场景下,可以考虑批量插入,但注册通常是单条插入,批量优化的意义不大。
连接池的大小要根据并发量来调整。一般来说,maximumPoolSize设置为CPU核数的2倍加磁盘数,是一个经验公式。但更准确的做法是通过压测来确定。