简介:这是一套基于JSP与MySQL实现的个人记事备忘系统源码,适合Java Web初学者、课程设计者及需要快速搭建笔记类应用的开发者。系统围绕在线记事场景,实现了笔记的创建、编辑、存储与检索,并清晰展示了JSP动态页面、Servlet请求处理、JDBC数据库交互及基础安全防护等关键环节。压缩包共374个文件,体积约27.51MB,主要包含Java类(24个)、JSP页面(9个)、HTML静态页(26个)、CSS样式(28个)、JavaScript脚本(86个)以及PNG界面预览图(97个),目录结构完整,便于按模块阅读与二次开发。资源中还附带数据库配置说明与部署相关文件,可帮助理解前后端数据流转和整体架构。目前已有109人学习使用,适合用来自学参考、毕业设计或实训项目改进,能有效缩短从理论到实践的上手周期。
1. 为什么还要折腾一个JSP+MySQL的记事本:先看清这个选题的落地价值
一个人记事备忘,多数人第一反应是装个App或记在手机备忘录里,但作为JavaWeb学习者,自己用JSP+MySQL写一套个人记事备忘系统是另一回事。它把用户登录、记事增删改查、分类管理这些核心功能完整串起来,是一份可以直接跑的源码打包项目,很适合做课程设计、毕设题目,或者想快速看懂传统JavaWeb项目结构的入门实践。它能解决的问题很具体:本机搭一套能记录日程和备忘的小系统,有看得懂的代码、能用的数据库脚本、可复现的部署方案。这篇文章从表设计讲到WAR部署,把关键参数和路上容易踩的坑一并说清,照着复现即可。
2. 拆解系统结构:三张表怎么建、请求怎么走
2.1 三张核心表怎么设计:用户、分类、记事
个人记事备忘系统的数据落库,常见做法是拆三张表:tb_user存账号,tb_category做记事分类,tb_note记正文和状态。表之间用归属字段关联,但物理外键不滥用,保持由程序层控制一致性。
-- 用户表 CREATE TABLE tb_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '密码,MD5或SHA-256后存储', nickname VARCHAR(50) DEFAULT '' COMMENT '展示昵称', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 分类表 CREATE TABLE tb_category ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '分类ID', user_id INT NOT NULL COMMENT '所属用户ID', category_name VARCHAR(50) NOT NULL COMMENT '分类名称', sort_order INT DEFAULT 0 COMMENT '排序号,数字小的排前面', UNIQUE KEY uk_user_category (user_id, category_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记事分类表'; -- 记事表 CREATE TABLE tb_note ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '记事ID', user_id INT NOT NULL COMMENT '所属用户ID', category_id INT DEFAULT NULL COMMENT '分类ID,NULL表示未分类', title VARCHAR(200) NOT NULL COMMENT '标题', content TEXT COMMENT '正文内容', is_completed TINYINT(1) DEFAULT 0 COMMENT '完成状态:0未完成 1已完成', remind_time DATETIME DEFAULT NULL COMMENT '提醒时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', KEY idx_user_create (user_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记事表';这里有一处容易忽略:user_id在tb_note和tb_category里都必须出现,且所有查询都要带上“当前登录用户ID”这个条件,才能保证多账号数据不串。category_id指向tb_category的主键,程序层需要额外校验“这个分类属于当前用户”,否则用户A的记事可能引用到用户B的分类。外键约束不写在DDL里,主要原因是删除分类时通常先把该分类下的记事批量置为未分类,再删分类本身,物理外键反而碍事。
字段类型方面,密码字段不设VARCHAR(32)而是VARCHAR(64),因为MD5是32位十六进制,后面升级到SHA-256是64位,留出余量。sort_order用INT而不是TINYINT,要求不高但排序时不担心溢出。remind_time允许NULL,表示该记事不设提醒。这样一套三表结构,后面扩展priority、tag字段都很方便,不会牵一发动全身。
2.2 新增记事的一次完整请求:表单如何流转到Servlet、DAO和MySQL
一次“新增记事”的操作,在传统JavaWeb项目里至少经过四层。先看浏览器端的表单,再沿请求链路往下推。
<form action="${pageContext.request.contextPath}/note/add" method="post"> <input type="text" name="title" placeholder="记事标题" required> <textarea name="content" rows="5" placeholder="正文内容"></textarea> <select name="categoryId"> <option value="">未分类</option> <!-- 循环渲染当前用户的分类列表 --> </select> <input type="datetime-local" name="remindTime"> <button type="submit">保存</button> </form>表单提交到项目上下文根下的/note/add,这个URL由NoteServlet上的@WebServlet("/note/*")映射。Tomcat收到POST请求后,把请求交给对应的Servlet。Servlet第一步设置请求字符编码为UTF-8,然后通过request.getParameter("title")等接参数,组装成Note实体对象,调用NoteDao.insert(note)。DAO层使用PreparedStatement执行INSERT,执行完毕后Servlet用重定向跳到/note/list,避免刷新页面时重复提交。
这条链路里,JSP负责展示和收集参数,Servlet负责流程控制和参数合法性校验,DAO负责SQL操作,MySQL负责数据持久化。新手容易把SQL直接写在JSP里,虽然能跑,但维护时极其痛苦,改一个字段名要在十几个页面里找,这就是典型的“能跑”和“能用”之间的差距。
2.3 代码分包与命名:照着这套结构不会迷路
项目结构建议按职责分包。com.note.entity放User、Note、Category实体类;com.note.dao放数据访问层;com.note.servlet放控制器;com.note.util放DBUtil等工具类;JSP放在WebRoot根目录或WEB-INF外的views目录下。
WEB-INF下的页面有一个特点:不能通过URL直接访问,必须由Servlet转发或者重定向访问。登录成功后的note_list.jsp如果放在WEB-INF/views/目录,用户直接访问http://localhost:8080/note/WEB-INF/views/note_list.jsp是404,必须请求/note/list让Servlet转发过去。这个设计在过滤登录态时很省心,因为外部根本拿不到页面路径。我第一次搭项目时把所有JSP都放在WebRoot根目录,结果登录过滤器漏掉一个页面,用户不登录也能看到别人的记事列表,后来改成WEB-INF/views布局才彻底堵上。
3. 核心功能实现:登录、记事与分类的完整代码
3.1 数据库连接工具类与连接串参数说明
数据库连接是每次请求都要用的资源,最基础的做法是写一个静态工具类,统一管理驱动加载、连接创建和资源释放。这里选择的方案是DriverManager直接建连,适合学习和单用户低并发场景;生产环境请换成连接池,第6章会说怎么改。
package com.note.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class DBUtil { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/note_db" + "?useUnicode=true&characterEncoding=utf8mb4" + "&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { // 实际项目中应换成日志输出 e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps != null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }连接串里有几个参数是关键,少一个都可能出诡异问题。useUnicode=true与characterEncoding=utf8mb4组合保证中文按UTF-8编码传输,不设置时MySQL默认可能走latin1导致中文乱码;useSSL=false是因为本机开发不需要走SSL握手,能省掉一堆证书警告;serverTimezone=Asia/Shanghai解决MySQL 8.x驱动要求时区的报错;allowPublicKeyRetrieval=true解决MySQL 8.x下使用caching_sha2_password认证时连接被拒的坑。
USER和PASSWORD写死在代码里是图省事,正式项目建议放到配置文件中,比如db.properties,再写一个静态块读取。工具类只提供getConnection()和close()两个方法,所有DAO的数据库操作都走这里,后续切换数据库只要改URL,不用改业务代码。
3.2 登录认证与Session处理:LoginServlet和UserDao怎么写
登录是系统的门面。页面提交用户名和密码,Servlet调DAO查询,成功则把User对象放进Session,失败则回填错误提示。这里展示Servlet和DAO两个关键类。
package com.note.servlet; import com.note.dao.UserDao; import com.note.entity.User; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @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"); String username = request.getParameter("username"); String password = request.getParameter("password"); if (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) { request.setAttribute("errorMsg", "用户名和密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } User user = userDao.findByUsernameAndPassword(username, password); if (user != null) { // 登录成功后把用户对象放进Session,后续页面通过sessionScope取 request.getSession().setAttribute("loginUser", user); response.sendRedirect(request.getContextPath() + "/note/list"); } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }这段Servlet里有两个细节值得注意。一是先做空值校验再查库,避免空字符串直接拼SQL;二是登录成功用sendRedirect,失败用forward。重定向会改变浏览器地址栏URL,用户刷新列表页时不会再次提交登录表单;转发保留原来的请求参数,适合把错误信息带回登录页。request.getSession()不带参数时,如果Session不存在会创建一个,这正是登录时需要的行为,但要注意在未登录状态下访问其他页面时不要随意调用getSession(),否则会在后端频繁创建无用的Session对象。
package com.note.dao; import com.note.entity.User; import com.note.util.DBUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class UserDao { public User findByUsernameAndPassword(String username, String password) { // 这里假设密码在Service层已经做过MD5加密,DAO只负责比对 String sql = "SELECT id, username, nickname, create_time FROM tb_user " + "WHERE username = ? AND password = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setNickname(rs.getString("nickname")); user.setCreateTime(rs.getTimestamp("create_time")); return user; } } } catch (SQLException e) { // 实际项目中应记录日志,而不是只printStackTrace e.printStackTrace(); } return null; } }这里必须使用PreparedStatement而不是Statement拼SQL。用户如果输入admin' or '1'='1这种字符串,直接拼接会改变SQL语义,甚至把整张表的数据查出来;参数化查询把这个输入变成字符串字面量,不存在注入的可能。try-with-resources写法在Java 7之后可以直接自动关闭连接和语句,不再需要手动在finally里关闭,代码结构也更清爽。密码字段建议在注册时用MD5加密存储,登录时对输入做同样加密再比对,不要明文存密码。
3.3 记事增删改查:NoteServlet处理参数、NoteDao执行SQL
记事模块是系统的核心,需要支持列表、详情、新增、修改、删除五个动作。用一个@WebServlet("/note/*")接收路径匹配,再按request.getRequestURI()分词路由。
package com.note.servlet; import com.note.dao.NoteDao; import com.note.entity.Note; import com.note.entity.User; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.util.List; @WebServlet("/note/*") public class NoteServlet extends HttpServlet { private NoteDao noteDao = new NoteDao(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String path = request.getRequestURI().substring(request.getContextPath().length()); if ("/note/list".equals(path)) { list(request, response); } else if ("/note/delete".equals(path)) { delete(request, response); } else if ("/note/edit".equals(path)) { showEdit(request, response); } } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String path = request.getRequestURI().substring(request.getContextPath().length()); if ("/note/add".equals(path)) { add(request, response); } else if ("/note/update".equals(path)) { update(request, response); } } private void list(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); // 防御:Session里没有用户就回登录页 if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } List<Note> noteList = noteDao.findByUserId(user.getId()); request.setAttribute("noteList", noteList); request.getRequestDispatcher("/WEB-INF/views/note_list.jsp").forward(request, response); } private void add(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { User user = (User) request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } request.setCharacterEncoding("UTF-8"); String title = request.getParameter("title"); String content = request.getParameter("content"); String categoryIdStr = request.getParameter("categoryId"); String remindTimeStr = request.getParameter("remindTime"); Note note = new Note(); note.setUserId(user.getId()); note.setTitle(title); note.setContent(content); if (categoryIdStr != null && !categoryIdStr.isEmpty()) { note.setCategoryId(Integer.parseInt(categoryIdStr)); } if (remindTimeStr != null && !remindTimeStr.isEmpty()) { note.setRemindTime(java.sql.Timestamp.valueOf(remindTimeStr.replace("T", " ") + ":00")); } noteDao.insert(note); response.sendRedirect(request.getContextPath() + "/note/list"); } }路由写法上,子路径判断直接用request.getRequestURI()去掉上下文前缀,比用request.getServletPath()更直观,不容易被filter拦截路径干扰。list()方法里每次先取Session里的loginUser,为空就重定向回登录页,这就是最简单的登录拦截。add()方法里的日期处理是个容易出错的地方:<input type="datetime-local">提交的格式是2025-01-15T14:30,但MySQL的DATETIME类型接受的是2025-01-15 14:30:00,所以要先替换掉T,再追加:00补秒,否则Timestamp.valueOf()会抛IllegalArgumentException。
public void update(Note note) { String sql = "UPDATE tb_note SET title = ?, content = ?, category_id = ?, " + "is_completed = ?, remind_time = ? WHERE id = ? AND user_id = ?"; // 执行PreparedStatement,字段按顺序绑定 }UPDATE语句的WHERE条件里必须同时带id和user_id,这样即使请求里传来的id是别人的记事,也会因为user_id不匹配影响0行,等于天然防止越权修改。这是多用户系统里最廉价又最有效的防线。
JSP列表页展示时用JSTL标签循环,注意c:forEach的items来自Servlet request作用域,pageContext.request.contextPath拼出上下文根,防止部署路径带项目名后链接全部失效。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <head> <title>我的记事列表</title> </head> <body> <h2>${sessionScope.loginUser.nickname},这是你的记事列表</h2> <table border="1" cellpadding="6" cellspacing="0"> <tr> <th>标题</th> <th>分类</th> <th>更新时间</th> <th>状态</th> <th>操作</th> </tr> <c:forEach var="note" items="${noteList}"> <tr> <td><a href="${pageContext.request.contextPath}/note/edit?id=${note.id}">${note.title}</a></td> <td>${note.categoryName}</td> <td><fmt:formatDate value="${note.updateTime}" pattern="yyyy-MM-dd HH:mm"/></td> <td> <c:choose> <c:when test="${note.completed}">已完成</c:when> <c:otherwise>未完成</c:otherwise> </c:choose> </td> <td> <a href="${pageContext.request.contextPath}/note/edit?id=${note.id}">编辑</a> <a href="${pageContext.request.contextPath}/note/delete?id=${note.id}" onclick="return confirm('确定删除这条记事吗?');">删除</a> </td> </tr> </c:forEach> </table> </body> </html>这里${note.categoryName}对应实体类里一个额外字段,通常由SQL联表查询带出来:SELECT n.*, c.category_name FROM tb_note n LEFT JOIN tb_category c ON n.category_id = c.id WHERE n.user_id = ?。实体类里加一个categoryName属性,不映射数据库列,只用于页面展示。这个做法比在JSP里再查一次分类名称要高效得多,也是一线项目里常见的查询套路。
4. 部署与源码打包:从IDEA到Tomcat的WAR生成路径
4.1 用IDEA新建传统JSP项目并关联Tomcat
拿到“源码打包”后,第一步是把项目导进IDEA。如果是从压缩包解压的文件夹,用File -> Open选择项目根目录,IDEA识别出pom.xml或普通Web结构后会提示导入。如果是空项目手动建,常见做法是File -> New -> Project -> Java Enterprise,勾选Web Application,然后配置Tomcat。
手动配置Tomcat的步骤在IDEA里是这样的:Run -> Edit Configurations -> + -> Tomcat Server -> Local,Application server选本机的Tomcat安装目录,Deployment选项卡点+ -> Artifact -> 你的项目:war exploded,Application context填/note。这里有个细节:war exploded是展开目录模式,改JSP直接刷新就能生效;war模式是压缩包,改代码要重新build。开发阶段用exploded,发布阶段再打war。
Tomcat本身解压后无需安装,设置环境变量CATALINA_HOME指向Tomcat根目录,然后bin/startup.sh(Linux/Mac)或bin/startup.bat(Windows)启动。默认端口8080,如果被占用,改conf/server.xml里的Connector port为8081或8090。本机验证浏览器访问http://localhost:8080/能看到Tomcat首页,说明环境就绪。
4.2 传统JSP项目打包WAR:Artifacts与手工jar命令两种路径
传统JSP项目打包WAR,IDEA标准做法是通过Artifacts。点击File -> Project Structure -> Artifacts,点+ -> Web Application -> Archive,Name填note.war,Output directory选择你要输出war的目录。然后执行Build -> Build Artifacts -> Build,war就会生成在指定目录。
如果不用IDEA,也可以手工用jar命令打包。前提是项目编译后的classes目录、WEB-INF/web.xml、所有JSP和静态资源都在一个目录下,然后在该目录下执行:
cd /path/to/project/build jar -cvf note.war .jar -cvf命令中c表示创建新归档,v表示输出中打印打包文件列表,f note.war指定归档文件名。最后的.表示把当前目录下所有内容打包进去,所以执行前一定要确认当前目录结构是Web应用标准结构,WEB-INF/classes下是编译好的class文件,WEB-INF/lib下有MySQL驱动jar包,根目录下有index.jsp等页面。
新手最容易忘的是把MySQL驱动jar放进WEB-INF/lib。没放驱动时,Tomcat启动项目不会报错,但运行时访问任何查库接口都会抛ClassNotFoundException: com.mysql.cj.jdbc.Driver,而且这个异常往往藏在控制台的一堆日志里。检查war包内容可以用jar -tf note.war | grep mysql看驱动是否在列。
4.3 部署到远程服务器:Tomcat的webapps目录与nginx的角色
拿到note.war后,本机部署最简单的方法是复制到Tomcat的webapps目录,然后重启Tomcat。Tomcat启动时会自动解压war包成同名目录。如果是远程Linux服务器,用scp或ftp工具把war传上去,放入/usr/local/tomcat/webapps/,再执行bin/shutdown.sh和bin/startup.sh完成重启。
部署后访问路径要注意:如果war包名是note.war,访问入口是http://服务器IP:8080/note/,不是http://服务器IP:8080/。想省掉应用上下文名,把war改名为ROOT.war,Tomcat会把ROOT当作根应用,直接访问http://IP:8080/。这个改名操作很简单,但很多第一次部署的人在这里卡住,明明Tomcat启动成功却打不开页面。
类似“nginx支持jsp吗”这类问题,答案是否定的。Nginx不解析JSP,只处理静态文件或做反向代理。如果服务器上装了nginx,正确配置是让nginx监听80端口,location / { proxy_pass http://127.0.0.1:8080; },把请求转发给后端的Tomcat处理,JSP由Tomcat解析后把生成HTML返回给nginx再回给浏览器。数据库MySQL在同一台服务器或另一台机上都行,连接串里的localhost改成对应IP即可,但要确认MySQL的bind-address配置允许远程连接,否则只能本机连。
5. 避坑指南:JSP+MySQL开发中5个容易反复翻车的点
5.1 中文乱码:页面、过滤器、连接串三处必须对齐
现象:登录名或记事标题里带中文,存入MySQL后变成???,或者页面显示乱码。
原因:乱码是一个链路上的问题,任何一环编码不一致都会翻车。JSP页面本身声明contentType="text/html;charset=UTF-8",但表单POST提交时Servlet没调request.setCharacterEncoding("UTF-8"),Tomcat默认按ISO-8859-1解码,中文就成了乱码;另外MySQL表如果建库时没指定utf8mb4,也会丢掉汉字。
解决:三处必须同时对齐。一是JSP页面声明和meta标签都设UTF-8;二是在Servlet的doGet和doPost开头调用request.setCharacterEncoding("UTF-8"),或者直接写一个CharacterEncodingFilter过滤器,统一设置请求和响应编码;三是JDBC连接串加characterEncoding=utf8mb4,建表时DEFAULT CHARSET=utf8mb4。排查顺序先看数据进库前是否乱,再看库里存的字节对不对,最后看页面渲染编码是否是UTF-8。
5.2 JDBC连接不上MySQL:从ERROR 2002排查到驱动版本
现象:启动项目后访问登录页,点登录按钮报错,日志里出现SQLException: Cannot create PoolableConnectionFactory或Communications link failure。用系统命令连MySQL时可能报ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'。
原因:ERROR 2002通常是MySQL服务没启动,或者socket路径不对。JDBC连不上的原因更多:端口不是默认3306、MySQL用户只授权了localhost、驱动版本和MySQL版本不匹配、防火墙拦了端口。
解决:先本机用mysql -uroot -p试连,能连上说明服务正常;再看Java代码里的URL,端口、数据库名、字符集参数逐个核对。MySQL 5.7用com.mysql.jdbc.Driver,MySQL 8.0+必须用com.mysql.cj.jdbc.Driver,驱动包也要换成8.x版本。驱动jar不匹配时通常报ClassNotFoundException或Unable to load authentication plugin 'caching_sha2_password',后者在连接串加allowPublicKeyRetrieval=true即可解决。
5.3 Tomcat版本与javax/jakarta命名空间导致的翻车
现象:源码里import的是javax.servlet.http.HttpServlet,代码编译正常,但部署到Tomcat 10后抛java.lang.NoClassDefFoundError: javax/servlet/http/HttpServlet或者ClassNotFoundException。
原因:Tomcat 10开始把Servlet API的包名从javax.*改成jakarta.*,这是Oracle和Eclipse Foundation商标问题带来的历史包袱。传统JSP项目大多基于javax编写,直接扔到Tomcat 10上,容器加载Servlet时根本找不到对应的类。
解决:两个方案。一是把Tomcat降到9.x版本,这是最省事的方案,教科书和毕设项目里绝大多数代码都按javax写;二是把源码里的javax.servlet全部替换成jakarta.servlet,同时替换依赖jar包。第二个方案对源码打包项目来说改动量不小,不建议新手折腾。部署前先确认目标Tomcat大版本,再决定代码是否需要适配。
5.4 Session失效:为什么登录一会儿就掉线
现象:登录成功后操作几分钟,刷新页面就跳回登录页。
原因:Session默认存活时间是Tomcat的web.xml里配置的30分钟,但这个时间是“空闲超时”,不是“绝对超时”。如果服务器系统时间和实际时间不一致,或者把项目的web.xml里Session超时时间设成了1分钟,都会出现频繁掉线。另一种情况是浏览器禁用了Cookie,SessionId没法保存在JSESSIONID里,每次请求都是新Session。
解决:先检查项目WEB-INF/web.xml里有没有session-config段,session-timeout的值以分钟为单位,建议设30或60。再检查Tomcat的conf/context.xml,看是否有破坏Session的配置。浏览器端按F12打开开发者工具,查看Network请求里的Cookie是否都带上了JSESSIONID。开发中用localhost访问没问题,但如果是用IP访问,注意Cookie的domain是否需要配置,否则第一次请求种下的Cookie在后续请求中可能不带。
5.5 重复执行SQL脚本导致的重复数据与主键撞车
现象:tb_user或tb_note里出现两条完全相同的数据,或者插入时主键自增值跳得离谱,比如用户表只有3条数据,下一个id却是500多。
原因:开发时反复执行note.sql脚本,脚本里的CREATE TABLE没有DROP TABLE IF EXISTS,导致旧数据残留;或者脚本本身带INSERT语句,每次执行都追加一遍数据。自增主键一旦删除过记录,MySQL会把自增值继续往后推,不会回收已删除的id。
解决:给脚本加幂等处理,在每张表前写DROP TABLE IF EXISTS tb_user;再CREATE TABLE,这样重复执行不会产生脏数据。不想全删的话,用TRUNCATE TABLE tb_user;清空表并重置自增。在生产环境千万不要手动执行TRUNCATE,只把幂等脚本用在开发环境初始化。这类问题本质上是“部署流程黑匣子”没管好,建议一个人维护脚本,只用一份初始化SQL,别随手造第二份。
6. 验证与进阶:把记事本从能跑到能用
6.1 从DriverManager切换到连接池,告别频繁建连
用DriverManager每查一次数据就创建一次物理连接,在并发请求上来时MySQL会很快达到max_connections上限。Druid连接池是常见的替换方案,只改DBUtil里获取连接的部分,DAO层代码不用动:
private static DruidDataSource dataSource = new DruidDataSource(); static { dataSource.setUrl(URL); dataSource.setUsername(USER); dataSource.setPassword(PASSWORD); dataSource.setInitialSize(5); dataSource.setMaxActive(20); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); }核心参数就两个:initialSize是启动时预创建的连接数,maxActive是最大活跃连接数。maxActive设太小,高并发下线程会等待连接池释放,接口响应变慢;设太大会打满MySQL连接,反而拖垮数据库。个人项目给10到20足够。
6.2 参数化查询:挡住SQL注入和越权
前面代码里已经用PreparedStatement替代了Statement,这是底线做法。顺手再做一层:DAO方法里所有涉及用户ID的SQL,一律用WHERE user_id = ?带上参数,不要相信前端传来的任何隐藏字段。JSP表单里的categoryId可能被恶意改成别人的分类ID,接收到后要校验它确实属于当前用户,否则数据归属就被绕过了。这两个习惯一起养成,多用户系统的数据安全就稳了一半。
6.3 多用户隔离验证与方法收尾
部署完成后,建议用两个账号做验证。注册用户A和用户B,分别登录,A新建三条记事并加分类,B登录后看到列表为空,直接访问A的记事编辑页URL,应该被“查无此数据”挡住或被重定向。这一步真正验证了所有查询都带user_id条件,是比功能验收更重要的数据隔离验收。
再查一下更新时间和提醒时间的格式。tb_note里的update_time依赖ON UPDATE CURRENT_TIMESTAMP,MySQL 8.0的默认配置就能生效;如果是5.7,要注意时间字段类型和自动更新是否被建表语句完整保留。这轮验证做完,系统才算真的从“能跑”变成“能用”。
这套JSP+MySQL的记事本,结构简单但五脏俱全。我现在遇到简单需求时,仍然会选这种传统方式快速出活,它的好处是部署链路透明、出问题可排查。在做登录拦截和SQL参数化时多花点功夫,后面维护能省很多事。希望帮到你。
本文还有配套的精品资源,点击获取