这天的内容我在实际做项目时回头再看觉得是整个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 为什么选JDBCMySQL而不是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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表的字段都是有讲究的。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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8这里三个参数很有讲究。useSSLfalse是因为本地开发环境没有配置SSL证书不关掉会有一堆告警日志serverTimezone不设置的话高版本驱动会报时区异常characterEncodingutf8配合MySQL服务端配置文件里的character-set-serverutf8mb4才能保证你存进去的中文不乱码。新手笔记里只写了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数、慢查询记录等信息。这对调试阶段帮助巨大——项目跑起来之后你可以直观看到“到底有没有连接泄漏”活跃数持续只增不减就是泄漏了。配置文件的写法也值得记录driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/javaweb_day10?useSSLfalseserverTimezoneAsia/Shanghai usernameroot password你的密码 initialSize5 maxActive10 maxWait3000initialSize5是启动时就给你备好5条连接maxActive10是连接数的上限maxWait3000是排队借连接最多等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 ListUser findAll() throws SQLException { ListUser 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因为你要把查询结果ListUser通过request.setAttribute传给JSPsendRedirect是重定向相当于浏览器重新发一次请求你上一次设置的request属性就丢了。删除和新增操作成功后反而应该用sendRedirect——避免刷新页面时重复提交表单。这个“POST后重定向”模式是Web开发的老生常谈也是防止表单重复提交的最简单手段。JSP页面里用JSTL的c:forEach遍历列表是最舒服的写法c:forEach items${userList} varuser tr td${user.id}/td td${user.username}/td td${user.email}/td tdfmt:formatDate value${user.createTime} patternyyyy-MM-dd HH:mm:ss//td td a hrefuserEdit?id${user.id}编辑/a a hrefuserDelete?id${user.id} onclickreturn 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 rootlocalhost连接池配置里的用户名或密码不对核对druid.properties中的账密注意MySQL 8.x默认认证插件是caching_sha2_password高版本驱动才兼容The server time zone value Öйú is unrecognizedJDBC URL缺少serverTimezone参数URL加上serverTimezoneAsia/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和useUnicodetrue这种老参数建议直接切换成新写法不然你照着抄都会踩版本坑。这是JavaWeb时间跨度上最典型的知识折旧问题。第二个IDEA里修改了druid.properties文件必须重新启动Tomcat。这个配置是在static代码块里加载的类只初始化一次。你改了配置还指望它自动生效属于想当然。同理改了web.xml也必须重启——它是由Tomcat启动时读取的热加载不覆盖这个文件。第三个JSP里中文乱码不一定是页面编码问题可能是数据库连接没指定编码。你的JSP写了% page contentTypetext/html;charsetUTF-8 %表单提交也是UTF-8但存到MySQL里就变成问号。这时候先去查JDBC URL里有没有characterEncodingutf8再去查MySQL服务器端配置。层级排查的顺序应该是“页面→请求→JDBC→数据库”而不是一上来就怀疑哪儿哪儿都错了。4.3 关于“完整案例”的一些扩展建议如果你不想只做一个简单的用户管理想让这个案例“更像真实项目”我建议做三个扩展每一个都不超出一晚上的时间加一个登录页和Session校验用Filter做登录拦截试一下过滤器的姿势把密码从明文改成MD5或SHA-256存储顺便了解一下不可逆加密的含义给用户表加一个status字段动手做一次“软删除”体会delete操作和update操作对数据意义的差别。这三个扩展做完你的CRUD案例基本就具备了一个小项目的骨架后面学框架时再往上面套会顺滑得多。你可能会问Day11学什么——这个节点一般会进入Servlet的高级特性比如过滤器、监听器或者是文件上传下载。不管课程推进到哪我的建议都一致每天的新内容尽量回过头来改造一遍Day10这个案例把它加过滤、加权限、加上传头像。你每学会一个新技能就回头升级老项目这种“滚雪球”式练习比跟着课程老老实实敲一遍示例代码要管用十倍。我自己后来面试时聊项目说的就是这样一个不断迭代出来的管理系统每个功能点都能说得清为什么这么做比背十个项目名都踏实。