这天的内容我在实际做项目时回头再看,觉得是整个JavaWeb阶段里性价比最高的一课。Day10的主题正好卡在“能跑”和“能扛”之间的分水岭上——Servlet和JSP你已经会用了,但是项目一旦要让用户真正用起来、数据要存得住,就必须把数据库接进来,把代码结构从“全塞一个Servlet里”理顺成“分层干活”。这篇笔记我会围绕JavaWeb连接MySQL数据库、完整案例的用户增删改查、以及IDEA里运行JavaWeb项目时绕不开的配置细节展开,把当天学到的思路、代码和踩过的坑一并记下来。无论是跟着黑马JavaWeb笔记自学、正在补课的朋友,还是学校课程上刚学到数据访问层的同学,这篇内容都能给你一条可以直接照抄的实操路径。
1. 内容整体设计与思路拆解
1.1 Day10到底该学什么:从“会写Servlet”到“会写业务”
前九天的内容,说白了都是让浏览器和服务器之间“把话传明白”。请求来了,Servlet接住,JSP渲染,响应回去。这套链路跑通了,但有个致命问题:数据放哪?你写死在HttpSession里?用户一关浏览器,数据跟着蒸发了。你写死在静态变量里?重启一次项目,一切归零。所以Day10的第一个任务很明确——把数据从“内存级”挪到“持久化级”,让项目重启之后数据还在,这就必须让JavaWeb项目连接上MySQL数据库。
第二个任务则是结构上的升级。没学数据库之前,很多练习项目是“一个Servlet干所有事”:接收参数、拼SQL、封装数据、跳转页面全丢在doGet或者doPost里,写的时候很爽,回头维护就是灾难。Day10真正要培养的是分层意识——数据访问归DAO层管,业务逻辑归Service层管,请求分发和参数接收归Servlet层管,页面展示归JSP层管。每一层只干一件事,出了问题能顺着调用链一层层定位,而不是像摊大饼一样乱成一团。
我个人的理解是,Day10更像是一个“从练习到项目”的过渡节点。前面学的是零件,今天开始学的是把零件组装成机器的方法。组装这件事本身不难,难的是建立“分层”和“解耦”的思维习惯,这直接决定了你以后写出来的东西是堆代码还是做产品。
1.2 为什么选JDBC+MySQL而不是MyBatis或JPA
很多同学学到第一天就跑来问:现在企业里不都用MyBatis吗?为什么还要学JDBC这种老古董,DriverManager、Connection、PreparedStatement写一大堆样板代码?这个问题我当年也问过,被一句话点醒:框架是包装,JDBC才是底裤。MyBatis、JPA不管封装得多好,底层最终都是通过JDBC和数据库对话。你把JDBC搞明白了,以后学框架是在理解“它帮我省了什么”,而不是在黑盒里盲目碰运气——报个错连“ClassNotFoundException”到底缺哪个jar都不知道,那时候才是最崩溃的。
另一个现实原因是学习曲线。Day10这个时间点,你连Servlet过滤器还没搞透彻,直接上MyBatis反而会把两件事混在一起:既要理解框架的配置规则、代理机制和动态SQL,又要理解原本就有的请求流转过程,出了问题你根本分不清是配置写错了,还是Servlet调用写错了。先用原生JDBC把“Java代码→SQL→数据库→结果集”这条链路走一遍,每一步都是可控的、可见的,才能建立正确的数据流转直觉。
MySQL选择和JDBC同理。虽然有的教学案例用的是Oracle或者SQL Server,但MySQL在社区资源、轻量程度和IDEA内置支持上对学习者最友好,官方驱动mysql-connector-j在Maven中央仓库里直接拉就好。学的时候用一种数据库把SQL基础打牢,以后换库也就是改连接串和少数方言语法的事。
1.3 项目案例设计:一个贯穿学习的用户管理系统
那天学的时候配套的是一个典型的用户管理案例:用户列表展示、新增用户、根据ID查详情、更新用户信息、删除用户。一个标准到不能再标准的CRUD,但正是这种“简单到极致”的案例最适合拿来拆解分层结构。如果你一上来就挑战订单+商品+库存的多表联查,很容易被业务复杂度淹没,反而忘了自己在练的是分层和数据访问。
表结构当时设计得非常收敛,就一张用户表,大概长这样:
CREATE TABLE `t_user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(20) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL, `email` VARCHAR(50), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表的字段都是有讲究的。id用自增主键,不需要外部生成主键的复杂逻辑;username加唯一约束,为后面的重名校验提供数据库层面的兜底;password定64位是为了后面演示哈希存储时够用;create_time给默认值,这样插入时少写一个字段,也自然引出了DEFAULT CURRENT_TIMESTAMP这个知识点。
案例虽然简单,但覆盖了JDBC最核心的操作类型:查询列表(返回多行)、查询单条(返回一行)、插入(拿自增主键)、更新(影响行数判断)、删除(按ID去删)。这五类操作基本上涵盖了日常开发中八成以上的数据访问需求,把它们的代码模板练熟了,后面写什么业务都是在套用骨架。
2. 核心细节解析与实操要点
2.1 JDBC的固定五步套路与原理
JDBC操作数据库,不管SQL是啥,代码骨架永远是那五步:注册驱动→获取连接→创建语句对象→执行SQL→释放资源。你别看网上代码千变万化,抽掉业务逻辑之后,本质都是这五步的排列组合。
注册驱动的操作在较新版本的MySQL驱动里可以省略,因为SPI机制会自动加载com.mysql.cj.jdbc.Driver。但笔记里我还是推荐显式写Class.forName("com.mysql.cj.jdbc.Driver"),原因很实在:第一,老项目或者某些驱动包版本下不写会直接报No suitable driver found,你排查的时候会浪费很多时间;第二,显式写出来能让你明确知道“驱动是干什么的”——它本质上就是告诉JDBC机制“我用的是哪个数据库的方言包”。
获取连接是新手最容易复制粘贴出错的一行,核心是URL的格式问题。MySQL 8.x版本的JDBC URL长这样:
jdbc:mysql://localhost:3306/javaweb_day10?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8这里三个参数很有讲究。useSSL=false是因为本地开发环境没有配置SSL证书,不关掉会有一堆告警日志;serverTimezone不设置的话,高版本驱动会报时区异常;characterEncoding=utf8配合MySQL服务端配置文件里的character-set-server=utf8mb4,才能保证你存进去的中文不乱码。新手笔记里只写了jdbc:mysql://localhost:3306/javaweb_day10,在旧版驱动上可能能跑,但在8.x版本上必报错——这个坑我在第三节的排查实录里也记录了一条。
2.2 数据库连接池:为什么不能每次new Connection
写JDBC初版代码的时候,最直观的做法是每次查询都新建一个Connection,用完就关。这样做功能上没问题,但我那天专门做了一次小测试:在循环里连续开200次连接和关闭,耗时会从十几秒一路飙到几十秒,而且数据库端的max_connections很容易被打满。原因在于Connection的建立要经过TCP握手、认证、分配资源等多步操作,每一次都是实打实的开销。
所以Day10在进阶环节引入了连接池的概念——提前创建一批连接放在池子里,用的时候借,用完归还,不够了再扩。打个比方,你自己烧水是一壶一壶现烧,连接池是保温瓶里常备热水,要喝直接倒,效率完全不是一个量级。
我当时使用的是Druid连接池,原因很直接:它在国内使用率极高,而且自带了监控页面,你可以在http://localhost:8080/druid/index.html上看到当前的活跃连接数、执行SQL数、慢查询记录等信息。这对调试阶段帮助巨大——项目跑起来之后,你可以直观看到“到底有没有连接泄漏”(活跃数持续只增不减就是泄漏了)。
配置文件的写法也值得记录:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/javaweb_day10?useSSL=false&serverTimezone=Asia/Shanghai username=root password=你的密码 initialSize=5 maxActive=10 maxWait=3000initialSize=5是启动时就给你备好5条连接;maxActive=10是连接数的上限;maxWait=3000是排队借连接最多等3秒,超时就报错,防止请求无限阻塞。这三个参数是小项目里最常见的“够用就好”配置,生产环境再根据压测结果调大,但思路是通用的。
注意:连接池的配置文件一定不要提交到公共仓库。里面写的数据库密码相当于你家的钥匙,被别人看到之后后果不堪设想。我自己习惯是在本地维护一个
druid.properties,部署到服务器时再单独用环境变量覆盖。
2.3 DAO模式:从“SQL满天飞”到“集中管理”
分层设计里,最让我觉得“开了窍”的就是DAO模式。没有DAO之前,你的Servlet代码长这样:接收参数、拼SQL、拿Statement、处理结果集、再自己封装成对象。SQL语句散落在各个Servlet里,项目一旦复杂起来,你想改个表名都得到处搜索替换。
引入DAO之后,所有SQL集中到UserDao这个类里。Servlet层只负责说“我要一个id为3的用户”,DAO负责回答“好的,我查给你”。调用方完全不用关心你查的是t_user表还是别的表,也根本不知道里面用的是PreparedStatement还是别的什么——这就是封装的价值。
我用一个简单的类图关系来表达:
JSP页面 → Servlet → Service(如果有) → DAO → JDBC → MySQL刚开始学的时候,很多人疑惑Service层是不是多余。我的建议是:Day10的案例可以先不引入Service,等你发现“多个Servlet里出现了同一段重复逻辑”的时候,再把它们提取到Service层。过早设计是负担,恰到好处的抽取才是重构。但是DAO层必须要,因为哪怕最简单的案例,它也能让你感受到“改SQL不用动Servlet”的巨大便利。
DAO的另一个职责是处理结果集的映射,也就是ResultSet到Java对象的转换。这一步新手最容易犯的错是“死记硬背”:rs.getInt("id")、rs.getString("username")……只知道要写,不知道为什么。实际上你只要记住一句话:ResultSet游标模型,它是一行一行往下走的,每走一行就通过列名或列序号取当前行的字段。理解了游标,你就明白为什么rs.next()要放在while循环里判断,也明白为什么第一次调用rs.next()之前游标是指向第一条数据之前的(这是ResultSet设计上的经典细节,很多面试题也爱考这里)。
2.4 事务边界:小白最容易忽略的进阶点
单表的CRUD操作里,每一条SQL都是自动提交的,你感觉不到事务的存在。但学习这件事有个特点:如果在简单阶段不把正确的概念刻进去,后面复杂了就会栽跟头。所以当天的案例虽然简单,我还是刻意演示了“连续更新多张表”的事务场景——比如用户改密码时要同时更新用户表和操作日志表,两步操作必须要么都成功、要么都失败。
事务代码的套路在JDBC里是这样的:先conn.setAutoCommit(false)关掉自动提交,然后把两步更新写在同一个try块里,全部成功再conn.commit();任何一步抛异常,就conn.rollback()回滚。关键点在于,事务必须和同一个连接绑定。你不能第一步用连接A,第二步用连接B,那样事务根本管不到对方。这也是为什么设计DAO时要把“获取连接”和“释放连接”分开——事务场景下,连接要跨越多个DAO方法传递,而不能在每个方法内部各拿各的。
这个点我在笔记里单独用红笔标注了:如果用了连接池,千万别在finally里调用conn.close()。你以为你在关闭连接,其实它是归还给池子,事务状态会被重置。你必须在每个DAO方法入口重新获取连接并开启事务,而不是把一个已经参与过事务的连接“续用”下去。
3. 实操过程与核心环节实现
3.1 IDEA里运行JavaWeb项目的前置配置
如果你用的是IDEA社区版,新建项目时看不到“Java Enterprise”相关的选项,这一步会卡住你整整一个小时。我当时第一次配的时候就是这个状况,后来才搞清楚:社区版做JavaWeb开发需要手动加Tomcat插件,或者干脆用Maven骨架生成maven-archetype-webapp,然后手动补目录结构。
一个标准的JavaWeb项目,目录结构长这样:
javaweb-day10/ ├── pom.xml └── src └── main ├── java │ └── com/example/dao/UserDao.java │ └── com/example/entity/User.java │ └── com/example/servlet/UserListServlet.java │ └── com/example/util/DBUtil.java ├── resources │ └── druid.properties └── webapp ├── WEB-INF/web.xml ├── jsp/userList.jsp └── index.jspIDEA里配置Tomcat运行的核心步骤,我帮你简化成了四条:
- 打开
Run/Debug Configurations,点“+”号,选Tomcat Server → Local。 - 在
Server选项卡里,Application server选你的Tomcat安装目录。 - 切到
Deployment选项卡,点“+”,选Artifact,把项目名:war exploded加进去,Application context填/day10。 - 启动时IDEA会自动打开浏览器,地址类似
http://localhost:8080/day10/userList。
这里有个隐藏小技巧,IDEA里你改JSP后不一定需要重启Tomcat。如果你把Deployment选成war exploded(而不是war),并且开启了Build → Build Project自动编译,Tomcat的reload功能会自动加载新类。实测下来,改JSP能秒级生效,改普通Java类也能在两三秒内热加载,省掉大量重启时间。
3.2 数据库连接工具类的封装思路
有了连接池,工具类代码就非常清爽了。我当时写了一个DBUtil,做了两件事:加载配置、提供获取连接和释放资源的方法。核心代码我贴在下面,这几乎是一个可以一直复用的模板:
package com.example.util; import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; public class DBUtil { private static DataSource dataSource; static { try (InputStream is = DBUtil.class.getClassLoader() .getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(is); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError("数据库连接池初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { // 从池中借出一条连接 return dataSource.getConnection(); } 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(); } } } }你可能会问:conn.close()在连接池里不是归还吗?为什么工具类里还是要写?因为你的DAO代码里不需要区分“真关闭”和“归还池子”,你只需要保证资源被释放就行了。DruidDataSource的内部机制会接管真正的回收逻辑。这种“面向接口编程”的思想,你会在JavaWeb阶段反复遇到——只管调用方需要什么,不用关心底层怎么做。
3.3 实体类与DAO的代码落地
实体类是表结构的Java映射,字段名和表字段对应。注意一点,Java里习惯用驼峰命名,数据库里习惯用下划线命名,所以create_time这个字段映射到Java属性就是createTime。做映射时要么在SQL里用别名AS createTime,要么用underscore-to-camel-case风格的自动映射框架(如果是手写JDBC,我强烈建议你在SQL里直接起别名,一行代码搞定,不依赖任何框架配置)。
DAO里查询列表的代码是整个案例最典型的一段:
public List<User> findAll() throws SQLException { List<User> list = new ArrayList<>(); String sql = "SELECT id, username, password, email, create_time AS createTime FROM t_user"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (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.setCreateTime(rs.getTimestamp("createTime")); list.add(user); } } return list; }这一段里有三个细节是常见丢分点。第一,try-with-resources语法自动关闭资源,不用手写finally,代码更短、容错性更好。第二,PreparedStatement占位符?必须用setXxx()方法预先传参,永远不要用字符串拼接SQL——拼接不仅有SQL注入风险,还会因为引号转义问题写出你根本看不明白的语法错误。第三,rs.getTimestamp取到的是带时分秒的时间类型,如果你用getDate只会拿到日期部分,显示的时候就会“丢时间”。
再补充一个案例里“查单个用户”的写法,这能帮你理解为什么DAO的查询方法可以提炼出“判断是否有结果”的小技巧:
public User findById(Integer id) throws SQLException { String sql = "SELECT id, username, password, email, create_time AS createTime FROM t_user WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); 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")); user.setCreateTime(rs.getTimestamp("createTime")); return user; } return null; } } }if (rs.next())这一句格外关键。它是判断“查没查到数据”的标准姿势——游标能移到第一条记录,说明有数据;移不动,说明没有。返回null给Servlet层,Servlet收到之后就可以做“数据不存在”的友好提示,而不是直接让页面报空指针。
3.4 Servlet层与页面联调的注意事项
Service层不在这里展开,因为案例简单我直接让Servlet调DAO,但如果你想练手分层,中间加一层Service只会让结构更稳。重点说说Servlet里的“收参、调DAO、回传、跳转”四条龙。
接收参数时,如果你用request.getParameter("id")拿到的是字符串,要转Integer就得用Integer.parseInt()。这一步新手最常踩的雷就是:用户没传id,或者id不是数字,直接调用parseInt就会抛NumberFormatException,页面会直接炸出一大段黄色异常栈。正确的姿势是先判空:
String idStr = request.getParameter("id"); if (idStr == null || idStr.trim().isEmpty()) { response.sendRedirect("userList"); return; }跳转时有两个选择:forward和sendRedirect。列表页查询完一般用forward,因为你要把查询结果List<User>通过request.setAttribute传给JSP,sendRedirect是重定向,相当于浏览器重新发一次请求,你上一次设置的request属性就丢了。删除和新增操作成功后,反而应该用sendRedirect——避免刷新页面时重复提交表单。这个“POST后重定向”模式是Web开发的老生常谈,也是防止表单重复提交的最简单手段。
JSP页面里用JSTL的c:forEach遍历列表是最舒服的写法:
<c:forEach items="${userList}" var="user"> <tr> <td>${user.id}</td> <td>${user.username}</td> <td>${user.email}</td> <td><fmt:formatDate value="${user.createTime}" pattern="yyyy-MM-dd HH:mm:ss"/></td> <td> <a href="userEdit?id=${user.id}">编辑</a> <a href="userDelete?id=${user.id}" onclick="return confirm('确定删除?')">删除</a> </td> </tr> </c:forEach>这里有个特别值得留意的点:${user.username}看起来是直接访问了属性,其实走的是User类的getUsername()方法。所以实体类的属性名和getter方法必须严格遵循JavaBean规范,否则EL表达式会取不到值,页面上显示一片空白,你还以为数据没查出来。
4. 常见问题与排查技巧实录
4.1 四个高频报错,对应问题一次看清
我把当天笔记里四条最典型的报错和解决思路整理成一张速查表,排障时照着对号入座,比瞎猜快得多。
| 报错信息 | 原因分析 | 解决方案 |
|---|---|---|
ClassNotFoundException: com.mysql.cj.jdbc.Driver | 没引入MySQL驱动jar,或引入的是5.x旧版驱动 | pom里加mysql-connector-j依赖;如果手动导jar,检查WEB-INF/lib下是否真的有包 |
Access denied for user 'root'@'localhost' | 连接池配置里的用户名或密码不对 | 核对druid.properties中的账密;注意MySQL 8.x默认认证插件是caching_sha2_password,高版本驱动才兼容 |
The server time zone value 'Öйú' is unrecognized | JDBC URL缺少serverTimezone参数 | URL加上&serverTimezone=Asia/Shanghai;这是编码乱码导致报错信息都以乱码显示 |
java.sql.SQLException: No suitable driver found | 驱动没注册成功,或URL格式写错 | 确认驱动类名写的是com.mysql.cj.jdbc.Driver(不是com.mysql.jdbc.Driver);检查URL前缀一定是jdbc:mysql:// |
第四条是我自己真正卡过二十分钟的坑。5.x驱动和8.x驱动的类名不一样,最坑的是两者在WEB-INF/lib下都有jar时,ClassLoader会先加载到旧版本的包,然后Class.forName一看类名不一样,直接说找不到。我的建议是:用Maven统一版本管理,锁死mysql-connector-j的版本,不要手动往lib下扔驱动包。Maven版本号写着8.0.33,那你用的就一定是这个版本,杜绝了这种“新旧打架”的史诗级迷惑问题。
4.2 三个容易忽略的坑,全是过来人经验
第一个,MySQL8.0以上版本,驱动类名变了,URL参数变了,但很多老教程还在用旧的写法。如果你看到com.mysql.jdbc.Driver(没有cj)和useUnicode=true这种老参数,建议直接切换成新写法,不然你照着抄都会踩版本坑。这是JavaWeb时间跨度上最典型的知识折旧问题。
第二个,IDEA里修改了druid.properties文件,必须重新启动Tomcat。这个配置是在static代码块里加载的,类只初始化一次。你改了配置还指望它自动生效,属于想当然。同理,改了web.xml也必须重启——它是由Tomcat启动时读取的,热加载不覆盖这个文件。
第三个,JSP里中文乱码不一定是页面编码问题,可能是数据库连接没指定编码。你的JSP写了<%@ page contentType="text/html;charset=UTF-8" %>,表单提交也是UTF-8,但存到MySQL里就变成问号。这时候先去查JDBC URL里有没有characterEncoding=utf8,再去查MySQL服务器端配置。层级排查的顺序应该是“页面→请求→JDBC→数据库”,而不是一上来就怀疑哪儿哪儿都错了。
4.3 关于“完整案例”的一些扩展建议
如果你不想只做一个简单的用户管理,想让这个案例“更像真实项目”,我建议做三个扩展,每一个都不超出一晚上的时间:加一个登录页和Session校验,用Filter做登录拦截试一下过滤器的姿势;把密码从明文改成MD5或SHA-256存储,顺便了解一下不可逆加密的含义;给用户表加一个status字段,动手做一次“软删除”,体会delete操作和update操作对数据意义的差别。这三个扩展做完,你的CRUD案例基本就具备了一个小项目的骨架,后面学框架时再往上面套,会顺滑得多。
你可能会问Day11学什么——这个节点一般会进入Servlet的高级特性,比如过滤器、监听器,或者是文件上传下载。不管课程推进到哪,我的建议都一致:每天的新内容尽量回过头来改造一遍Day10这个案例,把它加过滤、加权限、加上传头像。你每学会一个新技能,就回头升级老项目,这种“滚雪球”式练习,比跟着课程老老实实敲一遍示例代码要管用十倍。我自己后来面试时聊项目,说的就是这样一个不断迭代出来的管理系统,每个功能点都能说得清为什么这么做,比背十个项目名都踏实。