ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写Servlet+JSP登录注册系统,吃透Java Web核心原理

手写Servlet+JSP登录注册系统,吃透Java Web核心原理 前阵子有个学弟问我想练Java Web基本功做什么项目最合适。我说别想太多先老老实实手写一个基于Java Servlet和JSP的登录注册系统。这个项目看着不起眼但里面牵扯到HTTP请求处理、表单校验、Session会话管理、过滤器拦截、JDBC数据库操作几乎把Java Web最核心的零件全过了一遍。这篇文章就是我这几年带人、自己也反复写过这类系统后整理出来的完整复盘。不管你是刚学完Java基础准备入门Web开发还是正在做课程设计、准备Java面试又或者是想把手里的旧项目结构捋清楚这篇都值得你从头到尾看一遍。不夸张地说把这个系统吃透你再去理解Spring MVC、Spring Boot那套东西会轻松非常多。1. 项目背景与整体设计思路拆解1.1 都这个年代了为什么还要写ServletJSP很多人一上来就学Spring Boot结果连HTTP请求是怎么进到后台的、Session是怎么保持登录状态的、请求参数是怎么被解析的全都是一团浆糊。Spring Boot太方便了注解一贴、依赖一引接口就出来了但它帮你把底层的复杂性全藏起来了。真正出了问题你连排查的方向都没有。Servlet和JSP就是那层窗户纸。Servlet负责处理请求和响应JSP负责动态渲染页面两者配合你能清晰地看到一条请求从浏览器发出后经过容器、Servlet、DAO、数据库再返回页面的完整链路。这套逻辑理解透了后面学框架就是在学别人怎么把这套流程封装得更好用而不是学一个黑盒。再说现实一点很多公司的老项目、高校的课程设计、一部分外包系统用的还是JSP这套技术栈。你如果只会Spring Boot碰到这些项目会非常被动。不是说JSP有多先进而是它在存量市场里依然占着不小的份额。你愿意的话可以不用它但不能不会。1.2 功能范围、技术选型和目录结构规划这个项目我定的功能范围很简单但也足够完整用户注册填写用户名、密码、确认密码、邮箱、手机号后台校验后写入数据库用户登录校验用户名和密码成功后把用户信息写入Session登录状态拦截未登录用户不能访问系统首页和个人信息页退出登录清空Session回到登录页错误提示用户名重复、密码错误、表单为空等场景都要有明确反馈技术上我用的是经典三层结构JSP负责展示Servlet负责接收请求和转发DAO负责数据库操作。整个项目的目录结构如下user-login-demo/ ├── src │ ├── com/demo/entity/User.java │ ├── com/demo/dao/UserDao.java │ ├── com/demo/util/DBUtil.java │ ├── com/demo/util/MD5Util.java │ ├── com/demo/servlet/RegisterServlet.java │ ├── com/demo/servlet/LoginServlet.java │ ├── com/demo/servlet/LogoutServlet.java │ └── com/demo/filter/AuthFilter.java ├── web │ ├── login.jsp │ ├── register.jsp │ ├── index.jsp │ ├── success.jsp │ └── WEB-INF/web.xml └── lib └── mysql-connector-java-8.0.33.jar这套结构不是随便拍脑袋定的。entity、dao、util、servlet、filter各司其职页面单独放一个目录数据库驱动放到lib目录后面每加一个功能你都知道代码该往哪里放。新手最容易犯的错就是把所有逻辑全写在JSP里页面里又是Java代码又是HTML标签改一个需求能找半天。我见过最夸张的代码一个登录功能写了八百行JSP那真不是在写项目是在给自己埋雷。2. 开发环境准备与数据库表设计2.1 从零搭一个可运行的Web环境这个项目我用的环境组合是JDK 8 Tomcat 9 MySQL 8.0 IDEA。这套组合兼容性最好网上遇到问题也最容易查到解决方案。JDK 8虽然年纪不小但对Servlet和JSP来说完全够用而且校园机和大多数公司的老项目里JDK 8依然是主力没必要一上来就追JDK 17或者21。IDEA里创建一个普通的Java Web项目步骤大概是这样的新建Project选择Java EnterpriseWeb Application模板勾上然后配置Tomcat。这里有个很多人会忽略的点Tomcat版本和Servlet版本是绑定的。Tomcat 9对应Servlet 4.0支持WebServlet注解可以省掉web.xml里大量配置。如果你用了Tomcat 10要注意包名从javax.servlet变成了jakarta.servlet代码写法会不一样网上很多老教程会直接报错。我这篇用的是javax包。数据库驱动这块把mysql-connector-java的jar包放到项目的WEB-INF/lib目录下。一定要记得是WEB-INF下的lib不是随便建一个lib文件夹就行否则Tomcat根本不会加载这个驱动运行时会直接报ClassNotFoundException。2.2 用户表设计与JDBC连接细节数据库名字我用的是user_db表名tb_user。建表SQL我直接用MySQL 8的写法CREATE TABLE tb_user ( id INT NOT NULL AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码MD5加密后存储, email VARCHAR(100) DEFAULT NULL COMMENT 邮箱, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;用户名加了唯一索引这样即使用户没有先查重直接插入数据时数据库也会帮我们拦截重复记录。密码字段我预留了100的长度因为你后面如果从MD5升级到BCrypt存储长度会变长现在留够空间后面不用改表结构。JDBC连接我写了一个工具类用最简单的DriverManager方式public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/user_db ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 你的数据库密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt ! null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }连接串后面那一堆参数每一个都有讲究。useUnicode和characterEncodingutf8是保证中文能正确写入数据库useSSLfalse是关掉MySQL 8默认开启的SSL警告serverTimezoneAsia/Shanghai是解决时差问题否则你查出来的时间可能比北京时间差8个小时。这些参数以后你在Spring Boot里配连接串也会遇到现在理解了后面就不慌了。3. 注册功能开发实录3.1 注册页面与前端表单校验注册页面用JSP来写字体编码统一设为UTF-8。页面本身没什么花哨的关键是表单的name属性要和Servlet里取参数的key严格对应我见过太多人卡在这个地方页面写的是userNameServlet里取的是username结果永远拿不到值。% page contentTypetext/html;charsetUTF-8 languagejava % html head title用户注册/title /head body h2用户注册/h2 % String error (String) session.getAttribute(registerError); if (error ! null) { % div stylecolor:red;margin-bottom:10px;% error %/div % session.removeAttribute(registerError); } % form action${pageContext.request.contextPath}/register methodpost div label用户名/label input typetext nameusername placeholder请输入用户名 required /div div label密码/label input typepassword namepassword placeholder请输入密码 required /div div label确认密码/label input typepassword nameconfirmPassword placeholder请再次输入密码 required /div div label邮箱/label input typeemail nameemail placeholder选填 /div div label手机号/label input typetext namephone placeholder选填 /div div input typesubmit value注册 a hreflogin.jsp已有账号去登录/a /div /form /body /html表单里的required是浏览器的非空校验这是前端的第一道防线。但注意前端校验只是为了提升用户体验真正决定安全性的永远在后端。用户可以绕过前端直接把请求发到Servlet所以Servlet里的校验一条都不能少。我见过有人只在前端校验结果人家用Postman直接发请求空密码也注册成功了这种系统上线就是事故。3.2 后端注册Servlet与DAO层实现RegisterServlet是整个注册功能的核心。前端表单提交到/registerServlet先做参数校验然后调用DAO层检查用户名是否存在最后插入数据。关键代码如下WebServlet(/register) public class RegisterServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String username request.getParameter(username); String password request.getParameter(password); String confirmPassword request.getParameter(confirmPassword); String email request.getParameter(email); String phone request.getParameter(phone); HttpSession session request.getSession(); if (username null || username.trim().isEmpty() || password null || password.trim().isEmpty()) { session.setAttribute(registerError, 用户名和密码不能为空); response.sendRedirect(request.getContextPath() /register.jsp); return; } if (!password.equals(confirmPassword)) { session.setAttribute(registerError, 两次输入的密码不一致); response.sendRedirect(request.getContextPath() /register.jsp); return; } if (userDao.getUserByUsername(username) ! null) { session.setAttribute(registerError, 用户名已存在); response.sendRedirect(request.getContextPath() /register.jsp); return; } User user new User(); user.setUsername(username); user.setPassword(MD5Util.md5(password)); user.setEmail(email); user.setPhone(phone); if (userDao.addUser(user)) { session.setAttribute(registerSuccess, 注册成功请登录); response.sendRedirect(request.getContextPath() /login.jsp); } else { session.setAttribute(registerError, 注册失败请稍后重试); response.sendRedirect(request.getContextPath() /register.jsp); } } Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); } }注意两个细节。第一我用了重定向来处理注册结果也就是response.sendRedirect。这样用户注册成功后被带到登录页即使刷新页面也不会重复提交表单。如果你用了forward转发用户一刷新就重新注册一遍数据库里全是重复数据这种体验特别糟糕。第二错误信息放在Session里而不是Request里。原因很简单sendRedirect是客户端重新发起一次新的请求上次请求的Request对象已经销毁了用request.setAttribute存的东西取不到。放在Session里页面重定向后还能读到读完再removeAttribute避免下次刷新还一直显示旧错误信息。DAO层的UserDao实现如下public class UserDao { public boolean addUser(User user) { String sql INSERT INTO tb_user(username, password, email, phone) VALUES(?,?,?,?); Connection conn null; PreparedStatement ps null; try { conn DBUtil.getConnection(); ps conn.prepareStatement(sql); ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); ps.setString(4, user.getPhone()); return ps.executeUpdate() 0; } catch (SQLException e) { e.printStackTrace(); return false; } finally { DBUtil.close(conn, ps, null); } } public User getUserByUsername(String username) { String sql SELECT * FROM tb_user WHERE username ?; Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DBUtil.getConnection(); ps conn.prepareStatement(sql); ps.setString(1, username); 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)); user.setPhone(rs.getString(phone)); user.setCreateTime(rs.getTimestamp(create_time)); return user; } } catch (SQLException e) { e.printStackTrace(); } finally { DBUtil.close(conn, ps, rs); } return null; } }所有SQL都用PreparedStatement不用Statement拼字符串这是最基本的安全底线。用拼字符串的方式写SQL一旦用户输入里带了单引号轻则SQL报错重则被SQL注入直接脱库。PreparedStatement是预编译的参数和SQL语句分离开注入这条路就堵死了。3.3 密码加密存储与基础安全常识密码绝对不能明文存数据库这是这个项目里我最想强调的一点。不用等黑客攻击只要数据库文件泄露一次所有用户的账密就全完了。任何正规公司用户密码都是加密后才落库的。这个项目我用了MD5加密写了一个工具类public class MD5Util { public static String md5(String source) { if (source null || source.isEmpty()) { return null; } try { MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest(source.getBytes(UTF-8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { String hex Integer.toHexString(b 0xff); if (hex.length() 1) { sb.append(0); } sb.append(hex); } return sb.toString(); } catch (Exception e) { throw new RuntimeException(e); } } }先说清楚MD5本身并不是一种安全的密码存储方案它算得太快用彩虹表很容易逆推。但作为教学项目它能直观地让你理解加密后再比较这个思路。真正的生产项目建议用BCrypt或者PBKDF2这类专门为密码设计、自带盐值的算法。不过算法可以换思路是一样的用户传过来的密码先加密再和数据库里存的密文对比永远不去还原用户的明文密码。还有一个很多人容易忽略的点用户注册时前端已经把密码传给Servlet了密码在网络上传输的过程中是明文。正式上线必须配HTTPS否则就算数据库里存的是密文用户在登录页输入的密码还是会被抓包看到。这是另外一层的安全问题但你在做系统的时候心里得有这根弦。4. 登录功能与会话管理4.1 登录页面与登录Servlet实现登录页和注册页结构类似提交到/login。登录的逻辑链路是接收用户名和密码先去数据库根据用户名查用户查到再把加密后的密码拿过来比对全部通过就往Session里写入用户信息。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); response.setContentType(text/html;charsetUTF-8); String username request.getParameter(username); String password request.getParameter(password); HttpSession session request.getSession(); if (username null || username.trim().isEmpty() || password null || password.trim().isEmpty()) { session.setAttribute(loginError, 请输入用户名和密码); response.sendRedirect(request.getContextPath() /login.jsp); return; } User user userDao.getUserByUsername(username); if (user null) { session.setAttribute(loginError, 用户名不存在); response.sendRedirect(request.getContextPath() /login.jsp); return; } if (!MD5Util.md5(password).equals(user.getPassword())) { session.setAttribute(loginError, 密码错误); response.sendRedirect(request.getContextPath() /login.jsp); return; } session.setAttribute(user, user); response.sendRedirect(request.getContextPath() /index.jsp); } Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); } }这里有个细节值得说道说道用户名不存在和密码错误我给了不同的提示。在练习项目里这样可以让你快速定位问题但正式系统里更推荐统一提示用户名或密码错误防止别人通过提示信息猜到哪些用户名是注册过的降低被定向爆破的风险。这个取决你系统的安全等级要求知道两者区别就够了。4.2 Session会话保持与退出登录登录成功后我把整User对象放进了Sessionsession.setAttribute(user, user)。Session是Servlet容器帮我们维护的一份服务端数据每个浏览器会话对应一个独立的Session通过JSESSIONID这个Cookie来识别。只要Session没过期后续请求里都能从Session里把用户取出来这样登录状态就保持了。退出登录的逻辑正好反过来拿到Session让它失效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.invalidate(); } response.sendRedirect(request.getContextPath() /login.jsp); } }这里写的request.getSession(false)意思是有就拿来没有就不要新建。如果你直接写request.getSession()那即使没人登录也会强制创建一个新Session为一些本不该存在的会话分配了内存。logout场景用getSession(false)是最合适的。页面退出链接很简单超链接指向${pageContext.request.contextPath}/logout就行。注意logout我用的doGet方法因为用户点击链接就是一次GET请求没必要刻意改成POST毕竟它没改任何业务数据只是销毁会话。4.3 Filter过滤器实现登录拦截如果只做登录不做拦截那登录就形同虚设。用户不登录直接敲地址访问index.jsp照样能看到页面Session里的用户信息是null。真正要拦的是这种请求。AuthFilter过滤器把所有请求都拦下来先判断路径需不需要放行不需要放行的再看Session里有没有用户WebFilter(/*) public class AuthFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); String contextPath req.getContextPath(); String path uri.substring(contextPath.length()); if (path.equals(/login) || path.equals(/register) || path.equals(/login.jsp) || path.equals(/register.jsp) || path.startsWith(/static/)) { chain.doFilter(request, response); return; } HttpSession session req.getSession(false); if (session null || session.getAttribute(user) null) { resp.sendRedirect(contextPath /login.jsp); return; } chain.doFilter(request, response); } }这个过滤器我用的WebFilter(/*)匹配所有请求。放行规则是登录、注册相关的Servlet和JSP以及静态资源。其它的凡是Session里没有user一律踢回登录页。这样就算用户手动敲index.jsp的地址也只能看到登录页在等他。这里务必要注意放行路径的匹配粒度。如果把/也放了等于所有请求都不拦过滤器白写了。如果把需要登录才能访问的某个Servlet忘在放行列表里那这个页面就裸奔了。每次加新页面都要重新检查一遍Filter的放行规则这是我在实际项目里踩过坑才总结出来的经验。5. 常见问题与排查技巧实录5.1 中文乱码从页面一路乱到数据库中文乱码简直是小菜鸟的头号杀手。乱码的本质是编码和解码用了不同的字符集。在这个项目里乱码可能出现在三个环节。第一个环节是请求参数乱码。表单提交方式是POSTServlet里必须在读取任何参数之前先调用request.setCharacterEncoding(UTF-8)而且必须在getParameter之前。如果你先getParameter了再setCharacterEncoding那就晚了参数已经按默认编码解析完了。GET请求的乱码则更麻烦因为参数在URL上Tomcat默认用ISO-8859-1解码光setCharacterEncoding没用还要去改Tomcat的server.xml在Connector上加URIEncodingUTF-8。第二个环节是响应乱码。给客户端回写内容时要设置response.setContentType(text/html;charsetUTF-8)让浏览器知道用什么编码来解码。JSP页面则在文件头部写page contentType指令。第三个环节是数据库乱码。JSP和Servlet都设置好了数据写到数据库还是乱那基本都是连接串的问题。JDBC连接串里必须带上characterEncodingutf8建表时也建议用utf8mb4。这三个环节任何一个断了乱码就会顺着链路一路串下去从页面到数据库全乱。我把解决方案整理成了一个对照表乱码环节典型表现解决方法POST请求参数Servlet拿到的中文是问号或乱码第一个操作就执行request.setCharacterEncoding(UTF-8)GET请求参数URL地址栏里的中文乱码Tomcat连接器配置URIEncodingUTF-8响应页面乱码页面显示乱码response.setContentType(text/html;charsetUTF-8)数据库乱码页面正常但MySQL里存的值乱JDBC连接串加characterEncodingutf8表用utf8mb45.2 404、500、405这几个报错怎么定位写这个项目最常见的三个HTTP状态码我一个个说。404是资源找不到。打开登录页面404先看Tomcat的部署配置确认项目是否成功挂载访问路径是项目名加资源名提交表单后404先看form的action路径写没写对。这是个高频坑我格式化列举一下可能的原因路径少了/比如actionregister配在上下文为/user-login-demo的项目里实际请求会跑到/user-login-demo/register如果控制器映射是WebServlet(/register)路径不匹配就404路径多了项目名。JSP里写死action/register部署后的项目名一变路径就失效。所以表单和重定向我都建议用${pageContext.request.contextPath}动态拼接过滤器意外拦截比如Filter放行规则没写对登录请求被拦截重定向了500是服务器内部异常最常见的几种SQL写错、空指针、类找不到。看到500先别慌看Tomcat控制台或者日志里的异常栈。如果是java.sql.SQLException说明SQL语句有问题把SQL打印出来和表结构对一下如果是NullPointerException最可能的是字段值没取到检查参数名如果是ClassNotFoundException: com.mysql.cj.jdbc.Driver几乎可以确定mysql的jar包没放对位置检查是不是在WEB-INF/lib下。405是请求方式不支持。你明明写的doPost但页面用GET方式访问就会405。比如登录表单methodpost但你在浏览器地址栏直接敲了登录Servlet的URL那当然会405。解决方式要么在Servlet里把doGet也实现统一转发到doPost要么确保请求方式匹配。5.3 Session和Cookie使用中的隐藏坑Session相关的问题最难排查因为它不像404和500有明确的报错。我遇到过几个很典型的第一个是Session里取不到用户信息。明明登录成功了跳转后取session.getAttribute(user)却是null。排查思路是先确认登录成功后有没有把数据放进去再确认跳转的时候是不是同一个Session。最常见的原因有两个一个是Tomcat重启过旧的Session失效重新打开了登录前的页面另一个是浏览器开启了无痕模式或者清过CookieJSESSIONID丢了服务端自然找不到原来的Session。前者换个浏览器重试就行后者需要重新登录。第二个是Session超时设置不合理。Tomcat默认Session过期时间是30分钟用户长时间不操作Session就失效了。这时页面不会主动提示而是点击后突然跳回登录页。需要在web.xml里显式配置Session超时时间我一般设置30分钟具体要看业务场景session-config session-timeout30/session-timeout /session-config第三个是Cookie的Path问题。我在登录功能里做了一个简单的记住用户名功能用户名写入Cookie。但是设置Cookie的时候如果不指定Path默认路径是当前项目上下文超链接或转发只要没出项目上下文问题不大但一旦你的应用部署在根路径下或者有多个应用共享域名Cookie的Path不对会导致取不到。写Cookie的时候建议显式指定路径Cookie cookie new Cookie(username, username); cookie.setMaxAge(7 * 24 * 60 * 60); cookie.setPath(request.getContextPath()); response.addCookie(cookie);6. 项目扩展思路与后续升级方向整个登录注册系统的核心功能到这就全部完成了可以正常注册、登录、拦截和退出。但如果你只是写完就扔这个项目的价值只发挥了一半。我强烈建议你在这个基础上继续折腾几个方向每一个都能让你对Java Web的理解再深一层。最直接的一个扩展是手动加一个图形验证码。登录页面加一张随机生成的图片验证码用户输入之后和Session里存的答案比对。这个功能涉及Servlet动态生成图片、Session存取随机字符串完全是前面知识点的综合运用能做出来说明你对响应流的理解已经到位了。技术栈升级的方向第一步是在项目里引入Maven。不再手动下载jar包复制到lib目录而是通过pom.xml管理依赖这是走向工程化的第一道门槛。接着可以把DriverManager手动连接替换成Druid或者HikariCP连接池理解连接池为什么比每次都新建连接快。再往后可以接一个统一日志框架替代到处System.out的结果。有些读者会问什么时候可以开始学Spring Boot。我的建议是等你把这个项目里的每个环节都能不看教程讲明白尤其是从浏览器请求到Servlet再到数据库再返回的完整链路Session和Filter的原理以及为什么需要三层分离这时候就可以放心去学Spring Boot了。你会发现Spring Boot里的Controller其实是Servlet的封装Spring MVC里的Interceptor和Filter类似而Spring Security里的很多东西就是在这个登录注册系统的基础逻辑上升级出来的。写到这让我想起自己当年第一次跑通ServletJSP登录注册时那种原来整个Web系统是这么跑起来的通透感。做这个项目千万别贪快一行行代码敲一步步跟把每个为什么都搞清楚这个练习项目的价值才能真正兑现。
返回列表