前阵子有个同学做JavaWeb课程设计,卡在第三步:登录注册。不是乱码就是500,问我怎么办。我让他把项目包结构发过来,结果所有JDBC代码全写在Servlet里,数据库账号密码硬编码在Java类中,Session取值放值也说不清楚。这种状态其实很普遍,我自己写学生信息管理系统时也走过同样的弯路。这篇以这个项目为例,把Servlet、JSP、JDBC串起来的关键内容——登录会话、数据访问层、三层架构组织方式——从头到尾梳理清楚。
适合谁看呢?刚学完Servlet和JSP基础,还不知道一个正经JavaWeb项目应该长什么样的人;正在做课程设计、准备期末项目,写出来的代码被老师嫌弃的人。这篇不堆理论,直接给你一套能落地、能迁移的写法。
1. 先想清楚:这三层到底怎么分
很多人的JavaWeb项目,是把JDBC查询直接塞进Servlet。比如登录功能,Servlet里先Class.forName,再DriverManager.getConnection,然后拼接SQL字符串,最后把查询结果往request里一放。这种写法在小demo里没毛病,但一旦功能多起来,登录要加验证码、注册要检查用户名、用户要关联学生信息,代码就会变成一大坨,改一个地方牵扯三处。真正要做的是把项目分成三个层次:Web层、Service层、DAO层。
分层的好处,拿做饭类比最直观。Servlet是前厅服务员,负责收菜单、端菜;Service是后厨,决定菜怎么做、要不要少油少盐;DAO是采购员,只负责把食材买回来。前厅不需要自己去菜市场,后厨也不用端着菜上桌。各干各的,一个人出问题时,换掉对应角色就行。代码里同理:Servlet收到请求,调用Service的方法,Service处理业务规则,再让DAO去数据库里取数或写数。
1.1 Web层、Service层、DAO层各自该干什么
先明确每一层的边界。我第一次分的时候也困惑过:Servlet里到底能不能写if?日期格式化算谁的活?后来我的标准很简单:Servlet只做与请求/响应相关的事,不碰SQL,不碰业务规则。
具体来说,Web层接收参数、校验参数基本格式、调用Service、根据结果转发或重定向、把对象放进request或session。JSP也算Web层,但重点是展示,不要在JSP里用Java脚本片段写一大堆逻辑,尽量用EL表达式和JSTL标签照顾前端展示。Service层负责业务规则的落地,比如用户名不能重复、密码强度校验、盐值生成、事务的开启与提交回滚。这个“用户名不能重复”的规则,放在Service里比放在Servlet里更合理,因为如果将来不只一个入口调用注册,规则能复用。DAO层只负责和数据库打交道,传入参数、执行SQL、把ResultSet映射成Java对象、返回结果,不该出现“这个用户名存不存在”这种业务判断。
MVC是另外一套划分,描述的是Web层内部的分工:View是JSP,Controller是Servlet,Model是请求里的JavaBean。它和三层架构不冲突,可以说Web层内部再按MVC细分。很多人一提到三层就把MVC混进来,其实两个维度不一样,但可以互相嵌套。
如果项目规模小,很多人会跳过Service层,Servlet直接调DAO。这样做的问题不是不能跑,而是事务不好控制。后面讲事务的时候你就知道,Service层是唯一适合管理事务的地方,因为一次事务可能跨多个DAO方法,比如先插入用户,再插入学生信息,这两个操作必须绑定在一个数据库连接上。
1.2 数据库表设计:先定需求再写SQL
我做的学生信息管理系统,需求很朴素:登录后能查看某个学生的基本信息。既然是登录注册,第一步就是设计user表。字段不多,但有几个地方值得细看。
CREATE DATABASE student_db DEFAULT CHARACTER SET utf8mb4; USE student_db; CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL COMMENT '登录名', `password` VARCHAR(80) NOT NULL COMMENT '密码(加盐哈希结果)', `salt` VARCHAR(16) NOT NULL COMMENT '随机盐值', `nickname` VARCHAR(32) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户账号表';密码字段我特意设计成80位,不是为了好看,而是为了以后切换算法时留够余量。很多人建表时喜欢用varchar(32)存密码,如果是老式MD5十六进制字符串,32位刚好;但如果加了盐再哈希,长度就不好说了。给80位,将来换BCrypt或者SHA-256也不用改表结构。
username加了唯一约束,直接反映“用户名不能重复”的业务需求。重复判断不能只靠代码,数据库层面的唯一约束是底线,防的是并发情况下两个请求同时注册同一个用户名。
学生信息表我没有单独建,这个系统后面再补。只说一点:如果用户登录后要展示学生信息,建议建一张student_info表,用user_id字段关联到user表,思路是一张表管账号,一张表管业务数据,不要什么都往user表里塞。
2. JDBC封装:别再用DriverManager裸连了
JDBC是Java访问数据库的标准接口,但直接用起来很痛苦。想想看,现在项目里十几个DAO方法,每个方法开头都来一遍加载驱动、建立连接、执行SQL、关闭资源,期间还可能漏掉rs.close()。我早期就是因为漏了close,导致MySQL报Too many connections,那叫一个酸爽。所以这章的重点,是把JDBC的重复代码收拢起来,统一管理。
2.1 用properties管理数据库配置
最让人头疼的不是写法,而是硬编码。有人把数据库密码直接写在Servlet的静态变量里,最后项目发给别人,密码也一起发。正确做法是把配置放到src/main/resources下的jdbc.properties文件里,然后程序去读。
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/student_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username=root password=你的数据库密码 initialSize=5 maxActive=20 maxWait=30000这里要提醒一下:MySQL 8.x的驱动类是com.mysql.cj.jdbc.Driver,MySQL 5.x则是com.mysql.jdbc.Driver,不要搞混。url里的serverTimezone在MySQL 8里特别重要,不设置的话,连接时会报“The server time zone value”异常,因为新版驱动默认要求服务器和客户端时区一致。今天连前面配了characterEncoding=utf8,再加上表本身是utf8mb4,基本可以避免“写进去中文、读出来还是中文”的幺蛾子。
2.2 DBUtil工具类的实现和连接池选型
配置文件准备好之后,接下来是工具类。我建议用Druid连接池,理由很现实:连接复用、监控方便、配置简单,而且自带数据库密码加密功能,以后想加强安全也方便。Maven项目就在pom.xml加依赖。
<dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency>然后写一个DBUtil,所有取连接的操作都从这一个类走。
import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; public class DBUtil { private static DataSource dataSource; static { try { Properties props = new Properties(); InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("jdbc.properties"); props.load(in); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { e.printStackTrace(); throw new ExceptionInInitializerError("数据库连接池初始化失败"); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这里有几个细节要注意。第一,静态块的作用是类加载时只初始化一次连接池,不用每个方法都重新创建。第二,getResourceAsStream用类加载器去读classpath下的文件,所以jdbc.properties必须放在resources目录。第三,如果初始化失败,我选择抛ExceptionInInitializerError,让项目一启动就报错,而不是某个请求过来才突然爆炸。
用连接池之后,conn.close()并不是真正关闭物理连接,而是把连接归还给连接池。所以释放资源这件事更关键,要保证finally里或try-with-resources里把ResultSet、Statement、Connection都关掉。
2.3 DAO层实现:PreparedStatement不只是防注入
DAO层的核心是从数据库取数据,并把数据映射成Java对象。以用户表为例,先定义User对象,再写UserDao接口和UserDaoImpl。
public class User { private Integer id; private String username; private String password; private String salt; private String nickname; private LocalDateTime createTime; // getter、setter 略 }最基本的两个方法:insertUser和findByUsername。
public User findByUsername(String username) { String sql = "SELECT id, username, password, salt, nickname, create_time FROM 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.setSalt(rs.getString("salt")); user.setNickname(rs.getString("nickname")); user.setCreateTime(rs.getTimestamp("create_time").toLocalDateTime()); return user; } } } catch (SQLException e) { throw new RuntimeException("查询用户失败", e); } return null; }为什么用PreparedStatement而不是字符串拼接SQL?因为它能预编译,能有效防止SQL注入。举个例子,如果在登录时用字符串拼接,用户名输入一个' or '1'='1,很可能把登录条件变成恒真,你会莫名奇妙被登录。用参数化SQL则不会,占位符?会把值当成普通数据,而不是SQL语句的一部分。这个习惯一旦养成,后面写任何Java项目都不容易踩坑。
另外注意try-with-resources:Connection、PreparedStatement、ResultSet都实现了AutoCloseable,可以在try结束后自动关闭,不用手动写finally三层嵌套。唯一要注意的是事务场景,连接不能在DAO里提前释放,否则后续操作拿不到同一个连接,事务就形同虚设。这一点我在后面专门说明。
3. 登录注册从0到1:会话管理的完整闭环
登录注册看着简单,其实是JavaWeb里Session、Filter、表单回显这些知识点的大集合。我按注册、登录、拦截三个步骤串起来,你可以照着直接抄。
3.1 注册功能实现
注册页很简单,form提交后,RegisterServlet接收请求。我这里为了说明先在Servlet里写编码设置,实际项目里更推荐用Filter统一处理,后面会提到。
@WebServlet("/register") public class RegisterServlet extends HttpServlet { private UserService userService = new UserService(); protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("utf-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); String nickname = req.getParameter("nickname"); if (username == null || username.trim().isEmpty() || password == null || password.length() < 6) { req.setAttribute("msg", "用户名不能为空,密码至少6位"); req.getRequestDispatcher("/register.jsp").forward(req, resp); return; } User user = new User(); user.setUsername(username.trim()); user.setPassword(password); user.setNickname(nickname == null ? username : nickname.trim()); boolean ok = userService.register(user); if (ok) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } else { req.setAttribute("msg", "用户名已经存在"); req.getRequestDispatcher("/register.jsp").forward(req, resp); } } }注意req.setCharacterEncoding必须在读取参数之前,写错位置中文参数大概率乱码。然后是Service层。注册业务里,用户名重复检查和密码加密都放在这里,而不是Servlet。
public class UserService { private UserDao userDao = new UserDaoImpl(); public boolean register(User user) { if (userDao.findByUsername(user.getUsername()) != null) { return false; } String salt = UUID.randomUUID().toString().replace("-", "").substring(0, 8); String encrypted = PasswordUtil.encrypt(user.getPassword(), salt); user.setSalt(salt); user.setPassword(encrypted); userDao.insert(user); return true; } }密码加密不能图省事就明文入库。如果数据库一旦泄露,用户名密码就全裸奔了。我写了个PasswordUtil:
import java.nio.charset.StandardCharsets; import java.security.MessageDigest; public class PasswordUtil { private static final String DIGEST_PREFIX = "student"; public static String encrypt(String rawPassword, String salt) { try { String source = DIGEST_PREFIX + salt + rawPassword; MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(source.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { String hex = Integer.toHexString(0xff & b); if (hex.length() == 1) { sb.append('0'); } sb.append(hex); } return sb.toString().toUpperCase(); } catch (Exception e) { throw new RuntimeException("密码加密失败", e); } } }DIGEST_PREFIX相当于一个固定前缀,盐是随机生成的。即使两个用户原始密码相同,因为盐不同,最终存到数据库里的值也不一样,这可以防一部分彩虹表攻击。工业项目里更建议用BCrypt,但JavaWeb课程设计用MD5加盐已经比明文强很多,关键是你要能解释清楚加盐的原因。
注册成功之后用sendRedirect而不是forward,这是个细节。转发的话浏览器地址栏还停在register.do,用户按F5刷新,表单会重新提交一次,导致重复注册。重定向会让浏览器重新发起GET到login.jsp,正好符合POST后重定向的规范。
3.2 登录与Session会话
登录逻辑比注册稍微复杂一点。用户提交表单后,LoginServlet调用Service校验密码,成功之后把User对象放进Session。
@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserService(); protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("utf-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user == null) { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(); session.setAttribute("currentUser", user); resp.sendRedirect(req.getContextPath() + "/index.jsp"); } }Service里的login方法,先按用户名查出User,再比对加密后的密码:
public User login(String username, String rawPassword) { User user = userDao.findByUsername(username); if (user == null) { return null; } String encrypted = PasswordUtil.encrypt(rawPassword, user.getSalt()); if (!encrypted.equals(user.getPassword())) { return null; } return user; }Session机制,简单说就是服务器为每个浏览器会话分配一个唯一ID,通过Cookie传回浏览器。访问req.getSession()时,没有就创建一个,有就拿来用,服务器端用Map把ID和Session对象对应起来。把用户放进Session之后,后续每个Servlet都能通过req.getSession().getAttribute("currentUser")拿到当前用户。
登录失败我用request转发回login.jsp,登录成功用重定向。为什么失败用转发?因为需要保留request里的msg提示信息,转发是一次请求内的属性传递,重定向会丢掉。为什么成功用重定向?因为登录成功后刷新页面不应该反复提交表单,同时新页面直接用Session里的数据就够了。
退出登录的Servlet也顺手写了:
@WebServlet("/logout") public class LogoutServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.getSession().invalidate(); resp.sendRedirect(req.getContextPath() + "/login.jsp"); } }调用invalidate会销毁当前Session,把服务端会话状态清干净。如果只删除当前用户属性,其他属性可能还残留在会话里,除非你明确知道会话里没别的东西,否则invalidate更直接。
3.3 用Filter统一拦截未登录访问
Session管理只完成一半。你现在有了登录标志,但如果不拦截,用户直接输入 /index.jsp 照样能访问。一个个Servlet写判断太啰嗦,Filter就是为这个场景准备的。
@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 path = req.getRequestURI().substring(req.getContextPath().length()); if (isPublic(path)) { chain.doFilter(request, response); return; } Object currentUser = req.getSession().getAttribute("currentUser"); if (currentUser == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } private boolean isPublic(String path) { return path.equals("/login.jsp") || path.equals("/login") || path.equals("/register.jsp") || path.equals("/register") || path.startsWith("/static/") || path.equals("/favicon.ico"); } }这段代码里最关键的是放行判断。我见过不少人只在Filter里判断了登录页,忘了放行CSS、JS、图片,结果页面登录后没有任何样式,光秃秃一片。这里的isPublic把静态资源目录static和favicon.ico全部放行,再把登录、注册相关URI放行。实际项目里可能还有验证码接口、重置密码接口,都得加进白名单。
Filter还有一个容易被忽略的点:如果你在Filter里做了编码处理,就不需要每个Servlet里再写request.setCharacterEncoding。我一般把两个Filter叠起来,一个处理编码,一个处理会话,这样的分工让Servlet干净很多。
4. 常见问题排查与避坑实录
写完这些代码,你大概率还会遇到一些奇奇怪怪的报错。我把这段时间踩过的坑整理成清单,遇到问题直接对照着查,能省不少排查时间。
4.1 500错误集中区:依赖、驱动、空指针
HTTP 500在JavaWeb里是个万能错误,但大多数情况下逃不出三类。第一种是NoClassDefFoundError或ClassNotFoundException,说明Tomcat运行时找不到某些类。比如Maven写了mysql依赖,但打包成war时没把依赖带进去,运行时就会找不到Driver。解决办法是确认依赖没有被错误的scope排除掉,并且检查war包WEB-INF/lib里有没有对应的jar。
第二种是SQLException: No suitable driver found。常见原因有两个:一是驱动类配置错误,MySQL 8还用旧驱动名;二是连接池配置里少了driverClassName,Druid无法自动识别。用上面的配置基本不会遇到。
第三种是NullPointerException。比如登录时按用户名查不到用户,DAO返回null,Service里继续调user.getPassword(),空指针就来了。所以Service代码里要先判空。还有一个常见位置是JSP里输出session.getAttribute("currentUser").nickname,如果Session里没有值也会空指针。你看异常栈时,重点看Caused by,真正的错误原因往往藏在后面,不要只看第一行。
4.2 中文乱码:一条链路三个点
乱码是JavaWeb绕不开的问题。POST表单提交时,请求编码和响应编码都要正确。最基本的两行代码是:
req.setCharacterEncoding("utf-8"); resp.setContentType("text/html;charset=utf-8");第一行必须在读取表单参数之前,第二行应该在写入内容之前。更高阶的做法是写一个EncodingFilter,让所有Servlet共用。另外,mysql连接url里必须带characterEncoding=utf8,建表时用utf8mb4,jsp页面顶部设置pageEncoding。三个点都统一,基本不会乱码。
Tomcat 8以上的GET请求默认使用UTF-8解码URI,但如果你在Filter里统一设置了request编码,GET也基本没问题。别再到处怀疑浏览器编码,先按这三个点逐项检查,大概率就是你漏了其中一处。
4.3 数据库连不上:时区、权限、连接池
数据库连接失败也有几个高频报错,我后来把常见现象和原因整理成了一张表,遇到直接对号入座。
| 报错关键字 | 可能原因 | 处理方式 |
|---|---|---|
| Access denied for user | 用户名、密码或主机权限不对 | 在客户端工具里重新验证账号,确认能连再回来查配置 |
| Communications link failure | MySQL服务没启动、端口不通、防火墙拦截 | 检查MySQL服务状态,用telnet ip 3306测试端口 |
| Server returns invalid timezone | MySQL 8时区信息缺失 | 在url后面加serverTimezone=Asia/Shanghai |
| Connection is not available | 连接池的连接被借光且未归还 | 检查DAO中Connection、Statement、ResultSet是否都关闭了 |
连接池资源耗尽是个很隐蔽的问题。如果项目运行一段时间后报“获得连接超时”,很大概率是DAO里漏关了资源。连接池不是无限连接,initialSize=5,maxActive=20,一旦20个连接都被借走且不归还,新请求就只能等maxWait,超过30秒就抛异常。每次数据库操作后必须归还连接,这个习惯比工具类本身更重要。
4.4 事务:跨表操作时不能各自为战
第三章后期通常要加功能,比如注册成功后自动创建一个空的学生资料记录。这时候一个请求里会有两个DAO方法:insertUser和insertStudentInfo。如果学生资料插入失败,用户表已经写进去了,数据就对不上。
原生JDBC下,事务必须绑定到同一个Connection上。数据库的begin、commit、rollback是连接级的,不是方法级的。所以事务场景下,DAO方法不能再用try-with-resources把Connection关掉,而应该从ThreadLocal里取同一个连接。简单示例:
public class DBUtil { private static ThreadLocal<Connection> threadLocal = new ThreadLocal<>(); public static Connection getCurrentConnection() throws SQLException { Connection conn = threadLocal.get(); if (conn == null) { conn = getConnection(); threadLocal.set(conn); } return conn; } public static void setAutoCommit(boolean autoCommit) throws SQLException { getCurrentConnection().setAutoCommit(autoCommit); } public static void commit() throws SQLException { Connection conn = threadLocal.get(); if (conn != null) { conn.commit(); conn.close(); threadLocal.remove(); } } public static void rollback() throws SQLException { Connection conn = threadLocal.get(); if (conn != null) { conn.rollback(); conn.close(); threadLocal.remove(); } } }Service层里这样用:
try { DBUtil.setAutoCommit(false); userDao.insert(user); studentDao.insert(studentInfo); DBUtil.commit(); } catch (Exception e) { DBUtil.rollback(); throw e; }ThreadLocal的作用,是让同一个线程内多个方法共享同一个Connection。每个Tomcat请求都由一个线程处理,Service方法调用DAO方法都在这个线程里,所以ThreadLocal能保证它们拿到的是同一连接。记得在commit或rollback之后把ThreadLocal里的连接移除,不然连接还绑在线程上,线程复用回来时会拿到一个已关闭或状态错误的连接,后续操作全乱。
如果不想自己实现这套,可以换Spring的DataSourceTransactionManager,或者直接用MyBatis,它们内部就是帮你处理这些的。这也是为什么JavaWeb项目做到后面,一定会引入框架的原因。
最后再分享一点我的实际体会。这个项目写下来,踩得最多的不是SQL语句本身,而是环境配置和连接泄漏这类“看起来不会错”的地方。我的建议是:先按上面相对简洁的写法把登录注册跑通,再慢慢加过滤器、事务、密码加密这些细节。每加完一个点就重启一次Tomcat测一轮,不要一口气全写完再找bug,那种排错体验真的很折磨人。如果后续继续做学生信息CRUD,你会发现分好层的好处——新增一个查询只动DAO,加一个页面只动Web层,剩下的改动很少。JavaWeb_03这个阶段练的核心能力就是这个。我自己下一步应该会继续写学生管理部分的增删改查和分页,到时候再把新的踩坑记录整理出来。