资讯详情

资讯详情

Servlet+JSP手写登录注册:从环境搭建到Session会话管理

1. 为什么还要写ServletJSP的登录注册先弄清楚这个项目解决什么问题登录注册系统几乎是每个JavaWeb学习者绕不开的第一个完整项目。哪怕现在Spring Boot大行其道我还是建议你耐着性子把它用原生Servlet和JSP写一遍。原因很简单这个项目表面上只是一个输入用户名密码、点个按钮、跳个页面的小功能但它的内核其实是Web开发最基础的一套思维链——HTTP请求怎么进来、服务端怎么处理、数据怎么持久化、会话怎么保持、页面怎么回显。这套东西搞不懂后面用再牛的框架都是空中楼阁。我在带新人的时候经常发现一个现象用Spring Boot写登录注册很多人五分钟就搭出来了但问一句登录状态是存在哪里的Session和Cookie是什么关系就卡住了。而用ServletJSP手写一遍这些问题会被迫面对因为你没有过滤器、拦截器、Spring Security帮你兜底每一步都得自己来。这个项目适合谁两类人一类是做JavaWeb课程设计、毕设选题的学生需要一个能跑通、能答辩、代码讲得清楚的完整案例另一类是准备Java面试的求职者登录注册背后涉及的Servlet生命周期、Session机制、JDBC操作、MVC思想都是面试八股文里出现频率极高的考点。你亲手写过一遍面试官问你的时候你回答出来的东西是有实感的而不是背的。先说清楚技术分工Servlet负责接收请求、处理业务逻辑、控制页面跳转JSP负责展示动态数据JDBC负责和数据库打交道。这三样配合起来就构成了最经典的JSP Model 2结构——也就是MVC思想在JavaWeb里的落地。后面你学Spring MVC会发现它的核心思路和这一套是一脉相承的只是把Servlet和JSP之间的耦合做得更优雅了。另外提一句网上经常有人问Nginx支持JSP吗答案是JSP必须交给Servlet容器比如Tomcat编译执行Nginx本身不认JSP它只能转发请求。这个知识点在我下面的环境搭建部分会再碰一次。2. 开发环境搭建与项目骨架JDK、Tomcat、IDE一个都不能凑合2.1 JDK版本和Tomcat容器怎么配登录注册这种项目JDK 8完全够用你装JDK 8或JDK 11都行。Tomcat建议用Tomcat 9或Tomcat 10但有一个大坑Tomcat 10用的是Jakarta EE规范包名从javax.servlet改成了jakarta.servlet。如果你用的是Tomcat 10以上的版本还照着网上老教程写import javax.servlet.*编译直接报错。所以我个人建议初学者先用Tomcat 9等把javax.servlet这套搞熟了再迁移到Jakarta规范也不迟。安装Tomcat之后记得配两个环境变量CATALINA_HOME指向Tomcat解压目录JAVA_HOME指向JDK目录。然后进到bin目录启动startup.batWindows或startup.shLinux/macOS浏览器访问http://localhost:8080能看到那只猫的首页就说明容器起来了。2.2 IDE选型IDEA Community版足够VS Code也能跑说到IDE很多人在问用VS Code写Servlet代码需要怎么配置环境。这个我实测过VS Code确实能写Servlet但配置过程比较折腾你需要装Java Extension Pack、Tomcat插件还要手动管理classpath和部署目录。我的建议是如果你手头机器配置允许直接用IDEA Community版它对JavaWeb的开发支持是开箱即用的省下来的时间足够你多写两个功能。用IDEA新建项目的时候选择Java Enterprise或者普通Java项目然后手动加Web支持都行。核心步骤是新建一个Java项目右键项目根目录选择Add Framework Support勾选Web Application。在src/main/webapp/WEB-INF/web.xml里配置核心Servlet映射后面细讲。在Run/Debug Configurations里添加一个Tomcat Server指向你本地的Tomcat目录。设置Deployment把当前项目的war exploded部署到Tomcat。这一步做完你点的运行按钮实际上是帮你完成了编译→打包→复制到Tomcat的webapps目录→启动Tomcat一整套流程。2.3 项目目录结构约定大于配置一个标准的ServletJSP项目的目录长这样login-register-demo/ ├── src/ │ └── main/ │ ├── java/ │ │ ├── com/example/ │ │ │ ├── dao/ // 数据访问层 │ │ │ ├── model/ // 实体类 │ │ │ ├── servlet/ // Servlet控制器 │ │ │ └── util/ // JDBC连接工具类 │ └── webapp/ │ ├── WEB-INF/ │ │ └── web.xml // Web应用核心配置文件 │ ├── css/ // 静态资源 │ ├── js/ │ ├── login.jsp │ ├── register.jsp │ └── index.jsp注意JSP文件的放置位置有个讲究能直接放webapp根目录也可以放WEB-INF目录下。区别在于——放在webapp根目录的JSP用户可以直接通过URL访问放在WEB-INF下的JSP必须通过Servlet转发才能访问用户没办法直接在浏览器地址栏敲进去。登录注册这类页面通常放webapp根目录因为它本身就是要让用户访问的而用户登录成功后看到的欢迎页面、个人信息页我建议放WEB-INF下避免未登录用户猜URL直接进去。2.4 web.xml从一个空配置说起Servlet 3.0之前所有Servlet和URL映射都要在web.xml里声明。Servlet 3.0之后虽然支持注解WebServlet但web.xml还是要存在的它负责配置欢迎页、过滤器顺序、监听器等。贴一份最精简的?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-namelogin-register-demo/display-name welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-app如果你用注解方式写Servletweb.xml到这里就够了。但还是那句话我建议你至少在注册和登录两个Servlet上改用web.xml映射一遍因为面试官要是问Servlet的URL映射有哪些配置方式你不亲自动手写过XML配置表述起来会差很多。3. 数据库设计与JDBC连接先想明白要存什么加密什么3.1 用户表就四五个字段但每个字段都有讲究登录注册系统的数据表通常只需要一张t_user表。但字段设计有一些容易被忽略的细节我带过的很多学生在这里踩坑。CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码加密后存储, email VARCHAR(100) DEFAULT NULL COMMENT 邮箱, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段设计的几个关键点username必须加UNIQUE约束。否则你在代码里做了用户名重复校验遇到并发请求还是会插进去两条一样的记录数据库层面的唯一约束是最后一道防线。password长度不要只定32位。就算你只做MD5结果是32位十六进制字符串万一之后你想升级成加盐SHA-25664位或者BCrypt60位字段长度不够就得改表。直接给100位不亏。create_time是个很容易被忽略的字段。注册时间不仅能在个人信息页展示更重要的是它能帮你排查数据问题——比如测试的时候发现有人短时间内注册了一大堆账号一看时间就明白了。字符集一定要用utf8mb4别用utf8。utf8在MySQL里最多存3个字节的字符碰到emoji或者其他生僻字会报错utf8mb4是4个字节完全兼容。3.2 JDBC连接工具类别写一堆重复代码JDBC的原始写法很啰嗦加载驱动、建立连接、预编译、执行、关闭。每写一个查询都要重复一遍代码丑到没眼看。所以我习惯封装一个DbUtil工具类核心代码就做两件事获取连接和关闭资源。public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/login_demo?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4; private static final String USERNAME root; private static final String PASSWORD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动加载失败); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } public static void close(ResultSet rs, PreparedStatement ps, Connection conn) { try { if (rs ! null) rs.close(); if (ps ! null) ps.close(); if (conn ! null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }注意几个细节JDBC 4.0以后Class.forName(com.mysql.cj.jdbc.Driver)其实可以省略因为SPI机制会自动加载驱动。但我还是写上为什么因为显式加载能让代码意图更清晰而且某些老版本MySQL驱动确实需要。另外万一以后你换了数据库比如Oracle改这一行就知道去哪里改。连接串里的useSSLfalse本地开发没必要启用SSL省去证书配置的麻烦serverTimezoneAsia/Shanghai解决MySQL 8.x以后时区报错的老大难问题。CharacterEncodingutf8mb4保证中文和特殊字符不会乱码。3.3 用UserDao封装增删改查真正的高内聚低耦合实体类User就是四个字段加上getter/setter。UserDao类则负责把SQL和Java对象之间的转换工作对外暴露findByUsername和insert两个方法就够了public class UserDao { public User findByUsername(String username) { String sql SELECT id, username, password, email, create_time FROM t_user WHERE username ?; try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setEmail(rs.getString(email)); return user; } } } catch (SQLException e) { e.printStackTrace(); } return null; } public int insert(User user) { String sql INSERT INTO t_user (username, password, email) VALUES (?, ?, ?); try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } }这里全用PreparedStatement而不是Statement这个习惯从第一天就要养成。它有两个核心作用编译一次可以多次执行、参数自动转义杜绝SQL注入。你想想登录场景如果用户输入的用户名是 or 11用Statement拼接SQL的话你的登录判断就直接被绕过了。这是安全红线不是可选项。Java 7以后支持try-with-resourcesConnection、PreparedStatement、ResultSet都实现了AutoCloseable可以自动关闭不需要手动写在finally块里。代码干净很多也少写了不少样板。4. 注册功能实现从JSP表单到Servlet再到数据库的完整链路4.1 注册页面表单的name属性决定了Servlet收到什么先写注册页register.jsp。JSP的本质就是HTML里面嵌入Java代码但注册页面本身没什么动态内容要展示所以这个页面可以纯粹是HTML只有表单提交地址指向Servlet。% page contentTypetext/html;charsetUTF-8 languagejava % !DOCTYPE html html head meta charsetUTF-8 title用户注册/title /head body h2用户注册/h2 %-- 显示从Servlet传过来的错误信息 --% % String error (String) request.getAttribute(error); if (error ! null) { out.println(p stylecolor:red error /p); } % form action${pageContext.request.contextPath}/register methodpost label用户名/label input typetext nameusername required maxlength20 /br/ label密码/label input typepassword namepassword required maxlength20 /br/ label确认密码/label input typepassword nameconfirmPassword required maxlength20 /br/ label邮箱/label input typeemail nameemail /br/ button typesubmit注册/button /form p已有账号a href${pageContext.request.contextPath}/login直接登录/a/p /body /html几个表单设计的要点表单method用post而不是get。注册提交的是敏感数据GET会把参数拼在URL里面浏览器历史记录、服务器日志全都能看到这是绝对不能接受的。name属性至关重要Servlet里面request.getParameter(username)取的字符串必须和这个name值对应。这是一天最容易写错的地方——JSP里写name userName、Servlet里写getParameter(username)Debug半天都找不到原因。${pageContext.request.contextPath}这个EL表达式很关键它动态获取项目的上下文路径。你本地开发时项目部署的名字可能叫login-demo_war_exploded部署到服务器后名字变了如果把路径写死部署之后链接全断。用这个表达式能避免一整套绝对路径的坑。4.2 RegisterServlet的编码与校验流程Servlet的核心逻辑就做五件事设置编码、接收参数、校验参数、查重、插入数据库最后决定跳转。WebServlet(/register) public class RegisterServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 设置请求编码防止中文乱码 request.setCharacterEncoding(UTF-8); // 2. 接收表单参数 String username request.getParameter(username); String password request.getParameter(password); String confirmPassword request.getParameter(confirmPassword); String email request.getParameter(email); // 3. 服务端参数校验双重校验的第一步 if (username null || username.trim().isEmpty()) { request.setAttribute(error, 用户名不能为空); request.getRequestDispatcher(/register.jsp).forward(request, response); return; } if (!password.equals(confirmPassword)) { request.setAttribute(error, 两次输入的密码不一致); request.getRequestDispatcher(/register.jsp).forward(request, response); return; } // 4. 检查用户名是否已被注册 User existingUser userDao.findByUsername(username); if (existingUser ! null) { request.setAttribute(error, 该用户名已被注册); request.getRequestDispatcher(/register.jsp).forward(request, response); return; } // 5. 密码加密后入库 String encryptedPassword MD5Util.encrypt(password); User user new User(); user.setUsername(username); user.setPassword(encryptedPassword); user.setEmail(email); boolean success userDao.insert(user) 0; if (success) { response.sendRedirect(request.getContextPath() /login); } else { request.setAttribute(error, 注册失败请稍后重试); request.getRequestDispatcher(/register.jsp).forward(request, response); } } }前端表单已经写了required为什么Servlet还要重复校验因为前端的required只是浏览器层面的校验用户可以绕过页面直接构造HTTP请求发给服务器。服务端校验才是真正的安全边界。你面试的时候可以从这个角度回答前后端校验的区别一般很加分。4.3 密码为什么不直接明文存从MD5到加盐这是整个注册功能里最重要的安全实践。密码绝对不能明文存储——数据库一旦泄露所有用户的密码就直接暴露了。绝大多数用户在多个平台使用相同密码明文密码的泄露会波及其他平台。最基础的方案是MD5但单纯MD5也扛不住彩虹表攻击一种预先算好海量常见密码哈希值的表反向查出原文。所以更稳妥的做法是加盐在密码原文后面拼接一个随机字符串再对拼接结果做哈希计算。public class PasswordUtil { public static String encrypt(String password) { String salt UUID.randomUUID().toString().replaceAll(-, ).substring(0, 16); String saltedPassword salt password; String digest md5(saltedPassword); return salt $ digest; } public static boolean verify(String inputPassword, String storedPassword) { String[] parts storedPassword.split(\\$); String salt parts[0]; String storedDigest parts[1]; String inputDigest md5(salt inputPassword); return storedDigest.equals(inputDigest); } private static String md5(String input) { // 使用 MessageDigest 实现 MD5返回 32 位十六进制字符串 } }加盐的好处在于即使用户密码本身很简单加了随机盐值之后相同密码加密出来的结果完全不同彩虹表直接失效。存储的格式是盐值$哈希值验证的时候取盐值重新计算比对即可。要是想更进一步直接用BCrypt。Java里可以用jBCrypt库加密、验证一步到位运算成本还比MD5高暴力破解难度大增。但那是后面从课程作业到毕设的升级方向第一步先把不能明文存密码这个意识立起来。4.4 转发与重定向的选择一次注册失败时的正确时机细心的读者可能注意到了注册失败的时候我用的是request.getRequestDispatcher(/register.jsp).forward(...)注册成功用的是response.sendRedirect(...)。这两个选择是有讲究的。forward是服务器内部转发浏览器地址栏URL不变请求和响应在服务器内部完成接力可以在request域里通过setAttribute传递数据所以注册失败的错误信息能用request.getAttribute取到。sendRedirect是浏览器重定向服务器返回一个302状态码和一个新的URL浏览器重新发起请求。这样做的优点是地址栏会变成新的URL用户按F5刷新的时候不会重复提交表单。注册失败用forward是为了错误信息能随请求带过去注册成功用redirect是为了防止用户刷新页面时重复提交注册表单。这是Web开发中一个非常经典的设计决策理解了它你就把这两个API的运用场景真正吃透了。5. 登录功能与Session机制登录状态是怎么从一次请求保持到下一次请求的5.1 登录页和LoginServlet的认证判断登录页login.jsp和注册页类似表单提交到/login记住用户名是username和password。但登录Servlet的处理逻辑和注册完全不同核心就两步查用户、验密码。WebServlet(/login) public class LoginServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password request.getParameter(password); if (username null || username.trim().isEmpty()) { request.setAttribute(error, 用户名不能为空); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } User user userDao.findByUsername(username); if (user null || !PasswordUtil.verify(password, user.getPassword())) { request.setAttribute(error, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 登录成功将用户信息保存到 Session HttpSession session request.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); // 30分钟无操作自动失效 response.sendRedirect(request.getContextPath() /welcome); } }这里有一个安全细节值得提一下当用户名不存在和密码错误时返回的提示信息应该统一为用户名或密码错误而不是分别提示用户不存在和密码错误。为什么因为如果分开提示攻击者可以通过不断尝试逐个确认哪些用户名是注册过的方便后续定向爆破。这个细节面试官问登录接口的安全设计时你能答出来会非常加分。5.2 从Request到SessionHTTP无状态协议下的身份保持HTTP协议本身是无状态的——服务器处理完一个请求不会自动记住这次请求是谁发来的。那为什么你登录一次之后后续访问页面的请求都知道你是谁全靠Session和Cookie合作。用户第一次请求/login的时候request.getSession()会做两件事服务器内存中创建一个HttpSession对象给你一个唯一的JSessionId然后在响应头里通过Set-Cookie把JSessionId发给浏览器。浏览器收到后后续所有请求都会自动带上这个Cookie。服务器通过这个ID找到对应的Session对象就想起来了你是谁。这里就非常有意思了Session的数据存在服务器内存里Cookie只保存一个钥匙Session ID。钥匙丢了Cookie失效服务器内存里的Session就成了无人认领的数据这就是为什么基于默认Cookie的登录状态有超时机制——session.setMaxInactiveInterval(30 * 60)就是设置30分钟内没有活动Session自动失效。那么登录状态放在哪里我代码里用的是session.setAttribute(loginUser, user)。后续任何需要登录才能访问的页面只需要从Session中取这个属性取得到就是已登录取不到就是未登录。这里另外一个方案是把用户信息放Cookie里但Cookie是存在浏览器端的可以被用户篡改绝对不能把密码等敏感信息直接放进Cookie。5.3 使用Filter统一登录拦截一次只做一个功能还是每个页面都校验一遍写登录状态保持最朴素的做法是在每个需要登录的Servlet里都去检查SessionHttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(request.getContextPath() /login); return; }但这样太啰嗦了。每个Servlet里面粘贴复制三行同样的代码不仅丑还特别容易漏。更好的方式是使用Filter过滤器在请求到达Servlet之前统一做拦截校验。这也是Servlet规范里非常核心的一个组件。WebFilter(urlPatterns /*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 获取当前请求的URI排除不需要登录的页面 String uri request.getRequestURI(); String contextPath request.getContextPath(); String path uri.substring(contextPath.length()); // 公开资源登录、注册、静态资源 if (path.equals(/login) || path.equals(/register) || path.startsWith(/css/) || path.startsWith(/js/) || path.equals(/index.jsp)) { chain.doFilter(request, response); return; } // 校验Session中是否存有登录用户信息 HttpSession session request.getSession(false); if (session ! null session.getAttribute(loginUser) ! null) { chain.doFilter(request, response); } else { response.sendRedirect(request.getContextPath() /login); } } }为什么用request.getSession(false)而不是request.getSession()这是个很容易被忽略的小坑。getSession(false)表示拿不到Session就返回null不会主动创建一个而getSession()会强制创建一个新Session。在登录拦截场景下如果用户没有Session还要强制创建一个白白浪费服务器内存还可能让某些本应跳转登录页的请求拿到了一个空Session误判为已登录。Filter的优先级是沿着FilterChain按顺序执行的如果配了多个Filterweb.xml里的顺序就是执行顺序。早期版本只能用web.xml配置Filter顺序Servlet 3.0以后虽然可以用WebFilter注解但注解方式的顺序依赖类名排序不可控。如果你的过滤器不止一个建议统一用web.xml配置。5.4 退出登录Session.invalidate()的正确使用方式退出登录的逻辑很简单但是有一个动作很重要——把整个Session销毁而不只是把loginUser属性删掉。WebServlet(/logout) public class LogoutServlet extends HttpServlet { Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session request.getSession(false); if (session ! null) { // 让Session失效清除服务器端保存的全部会话数据 session.invalidate(); } response.sendRedirect(request.getContextPath() /login); } }用invalidate()的原因是Session里可能不止存了用户名还可能有其他敏感数据比如购物车、临时临时数据。只删一个属性其他数据还残留在服务器里占内存。直接销毁整个Session一了百了。如果你是在浏览器里直接访问/logout地址触发的退出用的是GET请求如果在页面上点了退出按钮注意跳转链接别写成a href/logout这种硬编码路径要用${pageContext.request.contextPath}/logout。6. 实测中最容易翻车的五个问题乱码、404、路径、依赖、并发6.1 中文乱码的三处出击登录注册项目里中文乱码几乎是所有初学者遇到的第一个黑魔法。中文乱码的本质是编码和解码字符集不一致。中文在浏览器、服务器、数据库三个环节各有一套编码方式任何一环不对接就会出现乱码。从我实测经验来看需要同时处理三个位置JSP页面顶部设置% page contentTypetext/html;charsetUTF-8 languagejava %告诉浏览器用UTF-8解码。Servlet在处理POST请求前调用request.setCharacterEncoding(UTF-8)告诉服务器按UTF-8解析请求体。数据库连接串加characterEncodingutf8mb4保证写入数据库时按UTF-8编码。三个位置有一个漏掉中文就有概率出问题。尤其request.setCharacterEncoding(UTF-8)必须在request.getParameter()之前调用否则Tomcat已经按默认的ISO-8859-1解码了之后再设置就来不及了。6.2 404和500先看Tomcat日志别盲改代码404代表路径映射不到你的Servlet排查顺序是先看访问的路径是否和WebServlet注解的值一致再看项目部署名字是否包含在URL里最后确认web.xml里没有重复映射冲突。500代表服务器内部异常要看Tomcat的logs目录下的localhost.log异常堆栈信息全在那里。最常见的是Java类编译不通过、MySQL驱动没加载、SQL写错。这里有个很高效的排查习惯遇到问题先看日志日志里有明确的类名和行号顺着堆栈找代码比自己瞎改快十倍。我见过太多学生报500后盯着JSP代码反复读但实际上问题出在DAO层一个参数类型写错了看日志一眼就能定位。6.3 上下文的路径坑为什么链接一部署到服务器就断刚才提到${pageContext.request.contextPath}这里展开讲一下。假设你在IDEA里部署的项目名是login-demo_war_exploded那么项目的访问根路径就是http://localhost:8080/login-demo_war_exploded/。如果在JSP里写了href/login浏览器会去访问http://localhost:8080/login端口后面的上下文路径丢了肯定会404。用${pageContext.request.contextPath}/login就相当于动态拼成/login-demo_war_exploded/login。之后部署上线把war包改名成app路径会自动变成/app/login代码一行都不用改。这个习惯要养成。6.4 依赖管理的手动时代lib目录放jar包ServletJSP项目如果不用Maven依赖管理全是手动活。你需要把MySQL驱动jar包、BCrypt库之类的依赖手动复制到WEB-INF/lib目录下Tomcat启动时会加载这个目录下的所有jar包。这里有个大坑如果本地明明没运行IDEA直接手动启Tomcat部署war包却报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver那十有八九是jar包没有正确打进war的WEB-INF/lib里。用IDEA的Artifacts配置检查一下Output Layout那里有没有把lib目录打进去。6.5 并发注册的用户名重复数据库唯一约束当兜底代码流程是先查重后插入但查重和插入之间有一个时间窗口。如果两个请求同时查出同一个用户名都可用然后同时执行插入就会出现两条同名的用户记录。虽然概率很小但数据库层面的UNIQUE约束会让第二条插入直接报错DAO捕获到异常后返回失败用户看到注册失败请稍后重试。这个兜底保障很重要同时它也是你面试时讲多线程安全的一个现成案例。7. 从课程设计到拿得出手的项目这个登录注册还能怎么升级7.1 改造为MVC分层Servlet只做调度不写SQL现在的代码虽然能跑但Servlet里的逻辑还是太杂。一个升级方向是引入更清晰的分层结构Servlet层接收参数和调度Service层处理业务逻辑比如查重、密码校验DAO层只做数据持久化。这样每层职责单一以后加功能、改逻辑、单测都很方便。这也是按照JSP Model 2思想实现功能的实践落地。7.2 加验证码与AJAX异步校验注册页面目前没有验证码这就给了恶意脚本批量注册的机会。可以在注册、登录页面加入图形验证码用Session保存验证码文本提交时进行比对。同时用户名查重可以改成AJAX异步校验用户输入完用户名就实时提示该用户名已被注册不用点提交才发现用户体验好很多。7.3 数据库连接池从DriverManager到druid或HikariCP当前用DriverManager.getConnection()每次请求都新建物理连接在高并发场景下性能和资源利用率都比较差。JDBC连接池的核心思路是提前创建一批连接放到池子里用的时候拿用完归还大大减少创建销毁连接的代价。换成Druid或HikariCP的配置也就几十行代码但对项目质量的提升是质的飞跃。HikariCP的性能在同类工具中常年是标杆。7.4 密码加密升级从MD5盐到BCrypt前面说了BCrypt比MD5高到哪里。MD5加盐虽然解决了彩虹表问题但MD5本身的运算速度太快暴力破解的成本还是低。BCrypt通过内部调整cost参数计算一次哈希需要几十毫秒甚至几百毫秒攻击者暴力破解的成本一下子翻了几个数量级。并且BCrypt库自带的checkpw方法做密码校验开箱即用不用自己写盐值拼接和拆分逻辑。7.5 增加日志与统一异常处理现在的代码里e.printStackTrace()在真实项目里是不可接受的。升级时可以引入Slf4j Logback在关键操作点登录成功、登录失败、注册成功、SQL异常打上日志把异常信息记录到日志文件而不是控制台。再配合一个全局异常处理Servlet或Filter让用户看到的是友好的错误提示页而不是满屏堆栈的报错页面。最后再说一个带项目的经验这种登录注册系统我的习惯是先把整个链路的运行机制在纸上画一遍——浏览器发请求、Servlet收到、调用DAO、查数据库、返回结果、渲染JSP、Cookie与Session交互把这几个箭头的方向走顺了再写代码。你把这个项目完完整整跑通过一次后面再去碰Spring Boot、Spring Security那一套你会发现它们的过滤器链、拦截器、登录认证逻辑思路全都是同一个源头。遇到奇怪的Bug别急着改代码先看日志再顺着这条链路去想问题基本都能定位。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →