简介:这是一份面向Java Web初学者与课程设计者的JSP动态网站实战源码,基于JSP+MySQL实现完整的个人日记管理功能,涵盖用户登录、日记增删改查、分类管理及个人中心等核心模块,适用于高校Web开发实训、毕业设计参考或Servlet/JSP技术入门实践。压缩包共90个文件,包含18个Java后端逻辑类(如Servlet控制器与DAO层)、8个JSP页面(登录页、日记列表、编辑表单等视图)、27个HTML静态模板与21张PNG界面素材,辅以6份Markdown说明文档(含中英文README),整体仅256KB,轻量易读。目前已有223人学习下载,资源结构清晰,采用近似MVC分层组织,含JDBC数据库连接、Session会话管理、表单验证与基础SQL操作等关键实现细节,可直接部署运行,是理解Java Web基础架构与前后端交互流程的典型教学案例。
1. JSP个人日记本:为什么一个“过时”的技术栈,反而成了理解Web底层最干净的教科书?
你可能第一眼看到“JSP + MySQL”就下意识划走——这不就是十年前高校实验课的标配?连IDEA都默认不带JSP支持了,Tomcat 10+干脆把JSP API移出核心包。但恰恰是这份看似落伍的jsp_diary源码,藏着现代Web框架里早已被封装到黑匣子深处的原始脉搏:HTTP请求如何一帧一帧流经容器、Java代码怎样在HTML里“呼吸”、session到底存在哪、SQL查询怎么从字符串变成数据库里的真实行记录。它没用Spring Boot自动装配,没用MyBatis动态代理,所有链路都赤裸裸摊开在.jsp、.java和.sql文件里。某高校实验室曾用它做Web原理课实验,学生第一次亲手改完login.jsp里那行request.getParameter("pwd"),再在UserServlet.java里打断点看到密码明文进来的那一刻,才真正信了“表单提交不是魔法”。这不是怀旧,而是把Web开发从“调API”拉回“造轮子”的临界点——当你能徒手复现一次登录态校验全过程,Vue的v-model、React的useState、甚至Next.js的Server Actions,就再也不是玄学。
2. JSP执行机制:从.jsp文件到_login_jsp.class,Tomcat到底做了什么?
JSP从来不是“直接运行”的脚本,而是一套编译时生成Servlet的约定。这份日记本源码里所有.jsp文件(如login.jsp、diary_list.jsp)在首次访问时,都会被Tomcat的Jasper引擎翻译成标准Java Servlet类,再编译成字节码。这个过程暴露了Web容器最原始的契约:HTTP请求必须落地为Java对象,响应必须包装成HTTP报文。
2.1 JSP编译流程:三步拆解login.jsp的“变形记”
以login.jsp为例,其核心逻辑是接收表单参数、调用DAO验证用户、跳转到主页或返回错误。Tomcat处理它的真实路径如下:
- 解析阶段:Jasper读取
login.jsp,识别<% %>中的Java代码块、<%= %>中的表达式、<%@ page %>指令,将静态HTML内容转为out.write()调用,动态Java代码原样保留; - 生成Servlet源码:输出类似
org.apache.jsp.login_jsp.java的Java文件,结构为:public final class login_jsp extends org.apache.jasper.runtime.HttpJspBase { public void _jspService(HttpServletRequest request, HttpServletResponse response) { // 1. 静态HTML转为out.write() out.write("<html><body>"); // 2. 表达式转为out.print() out.print( request.getParameter("msg") ); // 3. 脚本片段原样嵌入 String username = request.getParameter("username"); if (username != null && !username.trim().isEmpty()) { // ... DAO调用逻辑 } } } - 编译与加载:
javac编译该Java文件为login_jsp.class,由JasperLoader加载进JVM,后续请求直接调用_jspService()方法。
提示:你可以在Tomcat的
work/Catalina/localhost/jsp_diary/org/apache/jsp/目录下找到这些生成的.java和.class文件——这是理解JSP本质的唯一实证入口。删掉它,下次访问login.jsp会触发重新编译。
2.2page指令背后的容器契约:isThreadSafe与session的真相
login.jsp顶部常有这行:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" session="true" isThreadSafe="false" %>session="true"并非“开启session”,而是声明当前JSP需要访问HttpSession对象。Tomcat据此在_jspService()开头插入HttpSession session = request.getSession(true);,若设为false,则session变量不可用(编译报错);isThreadSafe="false"是关键陷阱:它告诉容器此JSP生成的Servlet不是线程安全的,容器会为每个请求创建独立实例(实际是通过同步_jspService方法实现)。而设为true(默认值)时,容器复用同一实例,但要求开发者自行保证成员变量线程安全——这正是早期JSP项目高并发下数据错乱的根源。
2.3 EL表达式与JSTL:为什么${user.name}比<%= user.getName() %>更安全?
日记本源码中diary_list.jsp大量使用${diary.title}而非<%= diary.getTitle() %>。这不是语法糖,而是两套完全不同的执行模型:
<%= %>:在_jspService()中直接调用Java方法,异常会中断整个页面渲染(如diary为null时抛NullPointerException);${}:由EL Resolver按作用域(page→request→session→application)逐层查找,自动处理null值(输出空字符串),且支持.操作符链式调用(${user.profile.avatar})。
JSTL标签库(如<c:if test="${not empty msg}">)进一步将控制逻辑从Java代码中剥离,其本质是TagHandler接口实现类,在_jspService()中被编译为new IfTag().doStartTag()调用——这正是MVC中View层“去逻辑化”的雏形。
3. JDBC直连MySQL:没有ORM遮蔽的数据库交互全链路
这份源码未使用Hibernate或MyBatis,所有数据库操作都基于原生JDBC。这意味着每一步——从驱动加载、连接获取、SQL拼接、参数绑定到结果集遍历——都必须手动编码。这种“笨办法”恰恰是理解数据持久层本质的必经之路。
3.1DBUtil.java:一个50行工具类,道尽连接池之前的生存法则
src/utils/DBUtil.java是整个项目的数据库心脏,其核心逻辑只有三段:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/diary_db?useSSL=false&serverTimezone=UTC"; private static final String USER = "root"; private static final String PASSWORD = "123456"; public static Connection getConnection() throws SQLException { // 1. 显式加载驱动(JDBC 4.0后可省略,但源码保留) try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL Driver not found", e); } // 2. 每次请求新建Connection(无连接池!) return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 3. 严格按rs→ps→conn顺序关闭(ResultSet依赖Statement,Statement依赖Connection) if (rs != null) try { rs.close(); } catch (SQLException e) {} if (ps != null) try { ps.close(); } catch (SQLException e) {} if (conn != null) try { conn.close(); } catch (SQLException e) {} } }- URL参数深意:
useSSL=false关闭SSL(开发环境简化),serverTimezone=UTC强制时区对齐,避免java.sql.SQLException: The server time zone value 'XXX' is unrecognized这类经典翻车; - 无连接池的代价:每次
getConnection()都建立TCP新连接,MySQL默认最大连接数151,高并发下直接Too many connections——这正是后来Druid/HikariCP诞生的原始驱动力。
3.2UserDAO.java:PreparedStatement防注入的底层实现
用户登录验证逻辑在src/dao/UserDAO.java中:
public User findUserByUsernameAndPassword(String username, String password) { String sql = "SELECT id, username, email FROM users WHERE username = ? AND password = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); // 参数1绑定username ps.setString(2, password); // 参数2绑定password try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return new User(rs.getInt("id"), rs.getString("username"), rs.getString("email")); } } } catch (SQLException e) { e.printStackTrace(); } return null; }?占位符的本质:PreparedStatement将SQL语句发送给MySQL服务端预编译,setString()只传输参数值,服务端绝不会将参数值拼进SQL字符串。即使username传入' OR '1'='1,最终执行的仍是WHERE username = ?,问号处的值被当作纯数据处理;- 对比
Statement的灾难:若用Statement stmt = conn.createStatement(); stmt.executeQuery("SELECT ... WHERE username = '" + username + "'"),恶意输入会直接拼成WHERE username = '' OR '1'='1',绕过所有校验。
3.3 事务边界:DiaryDAO.java中save()方法的手动控制
日记保存需同时写入diary主表和diary_tag关联表,源码用Connection.setAutoCommit(false)显式管理事务:
public boolean save(Diary diary, List<String> tags) { String sql1 = "INSERT INTO diary(title, content, create_time) VALUES(?, ?, ?)"; String sql2 = "INSERT INTO diary_tag(diary_id, tag_name) VALUES(?, ?)"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); // 关闭自动提交 try (PreparedStatement ps1 = conn.prepareStatement(sql1, Statement.RETURN_GENERATED_KEYS)) { ps1.setString(1, diary.getTitle()); ps1.setString(2, diary.getContent()); ps1.setTimestamp(3, new Timestamp(diary.getCreateTime().getTime())); int affected = ps1.executeUpdate(); if (affected == 0) throw new SQLException("Insert diary failed"); // 获取自增主键 try (ResultSet rs = ps1.getGeneratedKeys()) { if (rs.next()) { int diaryId = rs.getInt(1); // 批量插入标签 try (PreparedStatement ps2 = conn.prepareStatement(sql2)) { for (String tag : tags) { ps2.setInt(1, diaryId); ps2.setString(2, tag); ps2.addBatch(); } ps2.executeBatch(); } } } conn.commit(); // 全部成功才提交 return true; } catch (Exception e) { conn.rollback(); // 任一环节失败则回滚 throw e; } } catch (SQLException e) { e.printStackTrace(); return false; } }RETURN_GENERATED_KEYS的妙用:ps1.executeUpdate()后,通过ps1.getGeneratedKeys()获取刚插入行的自增ID,这是跨表关联的基石;addBatch()的性能逻辑:将多条INSERT合并为单次网络请求,避免N次往返——但注意,MySQL默认rewriteBatchedStatements=false,需在JDBC URL中显式添加&rewriteBatchedStatements=true才能真正生效。
4. Session会话管理:用户状态如何在无状态HTTP上“粘住”?
HTTP协议天生无状态,而日记本需要记住“谁正在写日记”。源码用HttpSession实现,但它的运作远非session.setAttribute("user", user)这么简单——背后是Cookie、内存存储、超时策略的精密配合。
4.1 Session ID的传递:从JSESSIONIDCookie到URL重写
用户登录成功后,LoginServlet.java中:
HttpSession session = request.getSession(); // 创建或获取session session.setAttribute("user", user); // 存储用户对象 response.sendRedirect("main.jsp"); // 重定向到主页此时Tomcat做了三件事:
- 生成唯一
sessionId(如2F7C9E3A1B5D8F0C4E6A9B2D7F0C4E6A); - 将
sessionId写入响应头Set-Cookie: JSESSIONID=2F7C9E3A...; Path=/; HttpOnly; - 在服务器内存中创建
StandardSession对象,以sessionId为key存储user等属性。
注意:若客户端禁用Cookie,Tomcat会自动启用URL重写——在
response.sendRedirect("main.jsp")生成的Location头中,URL变为main.jsp;jsessionid=2F7C9E3A...。你可在浏览器开发者工具Network面板中观察Set-Cookie头是否存在。
4.2 Session存储位置:内存、文件、数据库的三种实现
源码默认使用Tomcat内置的StandardManager,将Session对象存于JVM堆内存。但生产环境必须考虑:
- 集群部署问题:若用户请求被负载均衡分发到不同Tomcat节点,内存Session无法共享;
- 解决方案对比:
方案 实现方式 优点 缺点 PersistentManagerSession序列化到本地磁盘文件 进程重启不丢失 磁盘IO瓶颈,不支持集群 RedisStore(需扩展)Session存入Redis 高性能、天然支持集群 需额外部署Redis,增加运维复杂度 JDBCStoreSession存入MySQL表 利用现有数据库 数据库成为单点瓶颈
日记本源码未涉及这些,但web.xml中<session-config>配置已埋下伏笔:
<session-config> <session-timeout>30</session-timeout> <!-- 单位:分钟 --> <cookie-http-only>true</cookie-http-only> <!-- 防XSS窃取 --> <tracking-mode>COOKIE</tracking-mode> <!-- 可选URL、COOKIE、SSL --> </session-config>4.3 Session失效的四种触发条件
Session并非永生,以下任一情况即失效:
- 超时:
<session-timeout>30</session-timeout>配置后,30分钟内无请求则销毁; - 主动失效:调用
session.invalidate()(如LogoutServlet.java中); - 容器重启:内存Session全部丢失(除非配置了持久化);
- JVM崩溃:进程退出,内存清空。
提示:
session.getAttribute("user")返回null时,不能简单认为“用户未登录”,必须结合request.getRequestedSessionId()是否为空、request.isRequestedSessionIdValid()是否为true综合判断——这是很多新手排查登录态丢失的盲区。
5. 常见问题排查:十个血泪经验总结出的JSP+MySQL踩坑清单
这份源码虽小,但覆盖了Java Web开发的典型故障场景。以下是某开发者在部署调试过程中记录的真实问题,按“现象→原因→解决”结构整理,每一条都对应源码中的具体文件和配置。
5.1 现象:访问login.jsp报错HTTP Status 500 – java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver
原因:MySQL 8.0+驱动类名从com.mysql.jdbc.Driver改为com.mysql.cj.jdbc.Driver,而DBUtil.java中Class.forName()硬编码了旧类名;或mysql-connector-java.jar未放入WEB-INF/lib/目录。
解决:
- 修改
DBUtil.java第12行:Class.forName("com.mysql.cj.jdbc.Driver");; - 下载
mysql-connector-java-8.0.33.jar,复制到WEB-INF/lib/; - 重启Tomcat(热部署不生效,因驱动需在容器启动时加载)。
5.2 现象:登录成功后跳转main.jsp,页面显示java.lang.NullPointerException在<%= session.getAttribute("user").getUsername() %>
原因:LoginServlet.java中session.setAttribute("user", user)执行前,user对象为null(如数据库查无此人),导致存入null值;后续main.jsp直接调用getUsername()抛NPE。
解决:
- 在
LoginServlet.java中增加判空:if (user != null) { session.setAttribute("user", user); response.sendRedirect("main.jsp"); } else { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } main.jsp中用EL表达式${empty user}替代直接调用。
5.3 现象:中文日记内容存入MySQL后显示为???乱码
原因:三处编码未统一:① JSP页面<%@ page pageEncoding="UTF-8" %>;② MySQL连接URL缺少characterEncoding=utf8;③ MySQL数据库/表字符集非utf8mb4。
解决:
DBUtil.java中URL改为:"jdbc:mysql://localhost:3306/diary_db?useSSL=false&serverTimezone=UTC&characterEncoding=utf8";- MySQL执行:
ALTER DATABASE diary_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ALTER TABLE diary CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - Tomcat的
conf/server.xml中Connector标签添加URIEncoding="UTF-8"。
5.4 现象:diary_list.jsp中<c:forEach items="${diaryList}" var="diary">不渲染任何日记
原因:JSTL标签库缺失。WEB-INF/lib/中无jstl.jar和standard.jar(JSP 2.0+需jakarta.servlet.jsp.jstl-api和jakarta.servlet.jsp.jstl)。
解决:
- 下载
jakarta.servlet.jsp.jstl-api-2.0.0.jar和jakarta.servlet.jsp.jstl-2.0.0.jar; - 复制到
WEB-INF/lib/; diary_list.jsp顶部添加:<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>(Tomcat 10+需改为http://xmlns.jcp.org/jsp/jstl/core)。
5.5 现象:修改web.xml中<welcome-file-list>为<welcome-file>index.jsp</welcome-file>,但访问http://localhost:8080/jsp_diary/仍显示404
原因:index.jsp不存在,或web.xml未被Tomcat识别(文件名大小写错误、XML格式非法、未放在WEB-INF/目录下)。
解决:
- 确认
index.jsp位于Web应用根目录(与login.jsp同级); - 用XML校验工具检查
web.xml是否符合DTD/XSD规范(如<web-app>根标签的xmlns属性); - 查看Tomcat启动日志,搜索
Deploying web application directory确认应用是否成功部署。
6. 设计取舍反思:当MVC被拆成三个物理文件夹,我们失去了什么,又得到了什么?
这份日记本源码的目录结构极简:WEB-INF/下放web.xml和lib/,Java类按功能散落在src/dao/、src/servlet/、src/utils/,JSP页面平铺在Web根目录。它没有Spring MVC的@Controller注解,没有MyBatis的@Select,甚至没有Maven的pom.xml——所有依赖、配置、路由都靠手工维护。这种“返祖式”设计,表面是技术落后,实则是刻意为之的认知锚点。
6.1 物理分层即逻辑分层:src/目录下的MVC三原色
打开src/目录,你能清晰看到MVC的物理映射:
- Model(数据模型):
src/entity/User.java、src/entity/Diary.java——纯粹的POJO,无任何框架注解,字段与数据库表一一对应; - View(视图):
login.jsp、diary_list.jsp——仅含HTML和EL/JSTL,零Java脚本(<% %>被严格禁止); - Controller(控制器):
src/servlet/LoginServlet.java、src/servlet/DiaryServlet.java——接收请求、调用DAO、设置请求属性、转发跳转。
这种分离不是靠框架约定,而是靠开发者自觉遵守的目录约束。某导师曾让学生故意把DAO代码写进JSP,结果diary_list.jsp体积暴涨到800行,修改一个SQL要同时改JSP和Servlet——三天后全班自发重构为标准三层。物理隔离倒逼逻辑清晰,这比任何注解都管用。
6.2web.xml:一份被遗忘的中央路由注册表
现代框架用@WebServlet("/login")或@RequestMapping("/login")将路由分散到每个类上,而本源码的web.xml是唯一的路由中心:
<servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>src.servlet.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping>- 优势:全局路由一目了然,安全策略(如
<security-constraint>)可集中配置; - 劣势:新增一个Servlet需手动改三处(
<servlet>、<servlet-mapping>、<welcome-file>),易遗漏; - 现实启示:Spring Boot的
@SpringBootApplication本质是自动生成web.xml的替代品,但当application.properties里server.servlet.context-path=/api和spring.mvc.servlet.path=/v1冲突时,你依然得回到web.xml思维去理解容器行为。
6.3 为什么不用Filter做统一编码?因为request.setCharacterEncoding()必须在getParameter()之前
src/filter/EncodingFilter.java实现了UTF-8编码过滤器,但它的doFilter()方法必须这样写:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; req.setCharacterEncoding("UTF-8"); // 必须在getParameter()前调用! resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); }- 关键细节:
request.setCharacterEncoding()只对POST请求的请求体(Body)生效,对GET请求的URL参数无效(需在server.xml中配置URIEncoding); - 血泪教训:某开发者把
req.setCharacterEncoding("UTF-8")放在chain.doFilter()之后,结果所有中文参数仍是ISO-8859-1乱码——因为getParameter()在Filter链中已被前置Filter或Servlet调用过,编码已固化。
从那以后我每次写Filter,都强制走一遍getParameterNames()打印所有参数名,确认编码正确后再继续。这份源码里EncodingFilter的存在,不是为了炫技,而是把“请求生命周期中编码设置的窗口期”这个抽象概念,钉死在doFilter()方法的第3行。希望帮到你。
本文还有配套的精品资源,点击获取