
又到了Java课程设计扎堆的季节“小说阅读器系统”这个选题年年都有人选但真正能做出亮点的人并不多。这题目听起来简单无非是把一本小说的章节拆开、渲染、翻页但动起手来你就会发现——章节长文本怎么读才不卡、用户的阅读进度怎么记住、分页怎么写才不翻车、中文乱码从哪来全是坑。这篇博文我把整个系统的设计过程、建表SQL、核心代码、排查思路完整梳理一遍适合正在做Java课程设计或想拿这个题目练手的同学直接参考。先说清楚这套东西的定位它是一个偏Web方向的Java入门级综合项目贯穿了前端页面、Servlet控制层、DAO数据层、MySQL持久化、Tomcat部署这些必经环节。做一个功能闭环、能演示、能答辩的版本工作量大概在一个月左右前提是你按“需求先行、表结构先行”的顺序来而不是一上来就写JSP。下面我从头拆解整个系统的实现过程。1. 项目概述与需求解析1.1 为什么这个选题适合练手也适合答辩小说阅读器系统看起来是个小项目但它覆盖的知识点密度相当高前端要展示分页和滚动后端要处理用户登录、权限拦截、数据查询数据库要设计多张表并维护关联关系。用Java来做这样一个系统恰好能在一套代码里同时锻炼Servlet生命周期、JSP标签使用、JDBC操作、事务处理、SQL优化这几项基本功比单纯做“增删改查”的管理系统有区分度。另一个容易被忽视的点是小说阅读器有“真实场景感”。评委或者老师关心的是你有没有把“阅读体验”这件事想清楚章节内容是一次性加载还是按页加载阅读进度放在数据库还是session里小说列表分页时最后一页不够一页怎么办这些细节才是答辩时能讲出内容的地方。我在下面每一节里都会把这类细节的取舍逻辑写出来。1.2 功能边界第一版只做这些就够了课程设计最容易犯的错误是贪大求全评论、打赏、购买章节、多人互动全塞进去最后代码写了一堆但每个模块都是残缺的。我建议第一版把范围锁定在四个闭环功能上。用户模块注册、登录、退出登录后才能访问书架和阅读器。小说模块按分类浏览、关键词搜索、小说详情、章节目录。阅读模块章节内容展示、上一章/下一章、按页翻读。个人模块加入书架、移除书架、续读上次阅读章节。这四个模块内部逻辑完整对外又能串联成一条使用路径用户登录后搜索一本书加入书架点进阅读器翻几页退出后再次进入能直接回到上次阅读的章节。这条路径能跑通系统就立住了。至于评论、排行、后台管理适合作为“后续扩展计划”写在报告里而不是写进第一版代码。2. 技术栈选型与开发环境准备2.1 技术栈JSPServlet还是Spring Boot这个选择直接决定你后面所有代码的写法。如果你的课程大纲明确要求掌握JSP/Servlet那不用犹豫直接选“JSP Servlet JDBC MySQL Tomcat”。这套组合的优点是请求处理链路直观浏览器发请求到ServletServlet调DAODAO操作数据库结果转发给JSP渲染答辩时你能把每一步讲清楚老师也容易验证代码是不是你自己写的。如果学校只要求“Java Web方向”没有指定技术栈我更推荐Spring Boot MyBatis Thymeleaf。理由很简单Spring Boot把Tomcat嵌入进来了省掉部署这一步而且日后把项目改成前后端分离也容易。但要注意用Spring Boot的话你得额外解释清楚“原来Servlet那套东西被Spring MVC的DispatcherServlet替代了”这一点否则老师会觉得你只是在套框架。我做这个项目时用的是JSPServlet的经典组合原因是当时需要展示手动处理请求分发的完整过程。如果你时间充裕可以两条路都走一遍——先用Servlet把原理打通再迁移到Spring Boot这比直接上手框架理解深刻得多。2.2 开发环境与工程结构开发环境按这个组合来兼容度最高JDK 8或11、IntelliJ IDEA、Maven 3.6、MySQL 5.7或8.0、Tomcat 8.5或9。如果你的机器上是新版JDK比如17用Maven构建时会出现“源发行版17需要目标发行版17”的警告或报错这是因为maven-compiler-plugin默认的编译级别和你本机JDK不匹配。解决方式是在pom.xml里显式指定properties maven.compiler.source11/maven.compiler.source maven.compiler.target11/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties注意这里写11还是17要和你实际安装的JDK一致。你装了JDK17却把编译级别设成11运行时会遇到class版本冲突反过来装的是JDK8却设成17编译直接报错。这是课程设计中排在中文乱码之后的第二高频问题。工程结构我习惯按三层分包com.xxx.entity放实体类com.xxx.dao放数据访问层com.xxx.web放Servlet控制器com.xxx.util放数据库连接、MD5加密等工具类。JSP页面放在webapp根目录下按user、novel、reader分文件夹存放。这样分层的好处是答辩时老师问你“某个功能是怎么实现的”你能按“页面→Servlet→DAO→数据库”这条线讲清楚代码结构本身就是最好的讲解稿。2.3 数据库连接池不要用DriverManager裸连很多课程设计代码里还是每次请求都Class.forName再DriverManager.getConnection这样做在并发量只有几个人的演示环境里看不出问题但只要同时打开两个页面就会频繁创建和销毁连接响应变慢。更稳妥的做法是引入Druid连接池。在pom.xml里加依赖后写一个DbUtil工具类核心逻辑很简单public class DbUtil { private static DruidDataSource dataSource; static { dataSource new DruidDataSource(); dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/novel_reader?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai); dataSource.setUsername(root); dataSource.setPassword(你的密码); dataSource.setInitialSize(5); dataSource.setMaxActive(20); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }连接串里的characterEncodingutf8是数据库存取中文不出乱码的关键后面第5节还会展开说。serverTimezoneAsia/Shanghai是为了兼容MySQL 8.x的时间类型报错加了它就能避免Server returns invalid timezone这类问题。3. 数据库设计四张表撑起整个系统3.1 核心表结构与建表SQL小说阅读器系统不需要花哨的表五张表足够用户表、小说表、章节表、收藏表、阅读历史表。这五张表把系统里所有业务数据都覆盖了。先看用户表要强调密码字段的长度。如果你用MD5加盐哈希密码字段用VARCHAR(64)如果后续想换BCrypt哈希串长度是60VARCHAR(64)也够。推荐表结构如下CREATE DATABASE novel_reader DEFAULT CHARACTER SET utf8mb4; USE novel_reader; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_novel ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, author VARCHAR(50), category VARCHAR(20), intro VARCHAR(500), word_count INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT 1连载 0完结, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_chapter ( id INT PRIMARY KEY AUTO_INCREMENT, novel_id INT NOT NULL, chapter_no INT NOT NULL, title VARCHAR(100), content MEDIUMTEXT, word_count INT DEFAULT 0, KEY idx_novel (novel_id) ); CREATE TABLE t_favorite ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, novel_id INT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_novel (user_id, novel_id) ); CREATE TABLE t_reading_history ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, novel_id INT NOT NULL, chapter_id INT NOT NULL, read_offset INT DEFAULT 0, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_novel (user_id, novel_id) );这里有两个设计细节值得解释。第一个是章节表content用MEDIUMTEXT。TEXT类型最多存65535字节utf8mb4下一个汉字占3字节算下来大概能存两万多汉字网络小说一章通常2000到5000字其实TEXT也够。但章节内容有时候带标点符号、换行、特殊字符而且从稳妥角度讲一旦数据量上去TEXT容易触顶MEDIUMTEXT最大16MB完全没有风险。为了少给自己埋雷直接用MEDIUMTEXT。第二个细节是收藏表加了UNIQUE KEY uk_user_novel。这个唯一索引一方面保证同一用户不能重复收藏同一本书另一方面为后面要做的“收藏/取消收藏”提供了便利——不需要先查再决定插入还是删除直接根据受影响行数判断结果。3.2 阅读进度到底存哪里阅读进度是小说阅读器里最容易设计错的点。有同学会把“最后阅读章节”放在小说表里加一个last_read_chapter字段这样做的后果是所有人都共享这本书的进度张三读到第三章李四登录进来也看到第三章。阅读进度必须绑定用户维度所以要单独建t_reading_history表并且以(user_id, novel_id)为唯一键。read_offset这个字段是记录阅读位置的。它存的是当前阅读页码的第一个字符在整个章节内容中的偏移量单位是字符数。这样做的好处是恢复阅读时用offset / pageSize 1就能算出页码而且不依赖前端状态。这个设计逻辑我在第4.4节里会结合代码详细展开。3.3 外键到底建不建课程设计里学生喜欢给表加FOREIGN KEY约束但实际开发中很多团队会刻意不用物理外键原因有两个一是物理外键会让插入、更新多一次约束检查影响性能二是分库分表场景下外键无法跨库生效。课程设计这种数据量加不加外键对性能没有可感知的区别我更推荐不加物理外键但在Java代码层面通过事务保证一致性。具体做法是删除章节时同时删除该章节关联的阅读历史记录这两步操作放在同一个事务里要么都成功要么都回滚。用逻辑关联代替物理外键能让你在答辩时说出“这里考虑了性能和扩展性”比单纯写一句FOREIGN KEY更有含金量。4. 核心功能实现与实操细节4.1 注册登录密码加盐与服务端Session用户模块虽然基础但很多人的写法有问题。用户名直接存明文、密码也直接存明文登录判断就是把数据库里的值拿来比对这在学校演示环境里能跑通但如果答辩老师问一句“密码安全怎么考虑”基本就答不上来了。正确的做法是密码加盐后哈希存储。注册时生成一段随机盐把盐和密码拼接后做MD5或SHA-256把“盐哈希结果”一起存进数据库的password字段。登录时取出该用户的盐把输入密码拼上盐再算一次哈希比对结果。代码大致这样// 注册 String salt UUID.randomUUID().toString().substring(0, 8); String hashedPassword DigestUtils.md5Hex(salt password); user.setSalt(salt); user.setPassword(hashedPassword); // 登录 User user userDao.findByUsername(username); String inputHash DigestUtils.md5Hex(user.getSalt() inputPassword); if (user.getPassword().equals(inputHash)) { request.getSession().setAttribute(loginUser, user); }注意这里用的是commons-codec的DigestUtils比手写MessageDigest省事。还要说明的是不要以为MD5加盐就绝对安全了生产环境更推荐BCrypt这类自带盐且计算代价高的算法课程设计用到MD5加盐已经能体现安全意识你在报告里可以注明“可替换为BCrypt”。登录信息的记录用Session就够了但每个Servlet里都写一遍“从Session取值判断为空就跳转”会非常啰嗦。更好的做法是写一个LoginFilter在web.xml里把/reader/*、/favorite/*这些需要登录的路径拦截住public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; if (request.getSession().getAttribute(loginUser) null) { ((HttpServletResponse) res).sendRedirect(request.getContextPath() /user/login.jsp); return; } chain.doFilter(req, res); } }这个Filter一定要放在/user/login.jsp和注册接口之外的路径上否则会出现“登录页面永远打不开”的问题。4.2 小说列表与搜索分页查询的正确姿势小说列表是整个系统的浏览入口直接SELECT * FROM t_novel然后一次性在页面上全部显示数据量小的时候没问题数据量稍微上100本就很难看。分页是必须做的。分页的核心是两个参数pageSize和currentPage。pageSize通常固定为12或者20currentPage从请求参数里取。DAO层用一个通用的查询方法public ListNovel findByCategory(String category, int currentPage, int pageSize) { String sql SELECT id, title, author, category, intro, word_count, status FROM t_novel WHERE category ? ORDER BY create_time DESC LIMIT ?, ?; try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, category); ps.setInt(2, (currentPage - 1) * pageSize); ps.setInt(3, pageSize); ... } }LIMIT ?, ?的第一个问号是偏移量公式是(currentPage - 1) * pageSize这个计算很容易错尤其当currentPage是String类型时要先做Integer.parseInt还要处理用户手动输入负数或0的情况。我一般会在Servlet层加一个保护函数如果currentPage 1强制赋值为1。总页数计算是另一个容易出错的地方。常见错误写法是totalCount / pageSize这会导致最后一页被丢掉。正确公式是(totalCount pageSize - 1) / pageSize。举例41条数据每页20条41 / 20 2但实际上是3页用(41 19) / 20 3才对。搜索功能就是在SQL里加AND title LIKE ?注意用PreparedStatement传参禁止把用户输入直接拼进SQL字符串。使用ps.setString(1, % keyword %)这样既能匹配部分关键词又能防止SQL注入。问到“怎么防SQL注入”时你要能答出“预编译和参数绑定”——预编译让SQL结构固定参数只作为值参与不会改变语义。4.3 阅读器整章加载还是分段加载阅读器页面是小说系统核心中的核心。最简单的实现是点击章节后把整章内容从数据库取出来塞到页面上这也是大部分课程设计的做法。问题在于上千章的小说章节内容不短每次翻章都把一两万字的内容从数据库取出来再通过网络传输到浏览器在网络环境不太好的答辩教室会有明显的白屏等待。我的建议是采用“章节内按页加载”的方案。约定每页显示约800个字符浏览器端维护一个currentPage参数每次请求只取该页对应的一段文字。DAO层在Java里操作就行public String getPageContent(int chapterId, int pageNo, int pageSize) { String content getChapterContent(chapterId); // 从t_chapter取content字段 int start (pageNo - 1) * pageSize; if (start content.length()) { return ; } int end Math.min(start pageSize, content.length()); return content.substring(start, end); // 按字符数切分 }这里有一个足够坑的细节content.substring是按字符切分的不是按字节。Java字符串的length()和substring都基于Unicode字符无论中文、英文还是标点都按一个字符算所以不会出现半个汉字乱码。但如果你误用content.getBytes(UTF-8)再按字节截取百花齐放的乱码就来了。这个点我在调试阶段确实踩过一定要提醒。如果你想让数据库少传输点数据可以把切分逻辑下推到SQL里用SUBSTRING(content, ?, ?)来实现要注意MySQL的SUBSTRING是从1开始计数的和Java的0起点必须仔细换算。两种方案都能用我更喜欢Java端切分因为对数据库压力小逻辑也更容易单测。翻页按钮就是修改currentPage参数重新请求当前章节。上一章、下一章通过章节表里chapter_no字段的加减来实现前后章节查询这样写上一章是WHERE novel_id ? AND chapter_no ? ORDER BY chapter_no DESC LIMIT 1下一章则是AND chapter_no ? ORDER BY chapter_no ASC LIMIT 1。用LIMIT 1而不是取出全本书的章节号再在Java中判断看似多了一次查询但每次只取一条记录速度反而更快还避免了一次性加载整本书章节列表的内存开销。4.4 记住阅读进度upsert更新技巧阅读进度恢复的完整链路是用户阅读时前端点击“上一章”“下一页”后把chapterId和pageNo传回后端后端通过pageNo反推出read_offset最后把数据写入阅读历史表。用户再次登录进入某本书时查询t_reading_history表拿到chapter_id和read_offset就能定位到上次读的章节和页码。这里最值得讲的技巧是“不存在则插入存在则更新”也就是INSERT ... ON DUPLICATE KEY UPDATE。因为t_reading_history表上建了(user_id, novel_id)唯一索引所以可以放心使用INSERT INTO t_reading_history (user_id, novel_id, chapter_id, read_offset) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE chapter_id VALUES(chapter_id), read_offset VALUES(read_offset), update_time NOW();这条路写法在代码层面省掉了一次“先查再决定插入还是更新”的来回也就是所谓的原子化更新并发场景下也不会出现两条重复记录。要注意MySQL 8.0.20以后VALUES()语法标记为废弃但不影响使用课程设计阶段可以继续用如果想消除告警可以改用别名写法INSERT INTO ... AS new ON DUPLICATE KEY UPDATE chapter_id new.chapter_id。你可能还遇到过一个问题用户在阅读器里只读了一段就关闭页面进度根本没来得及记录。浏览量大的网站一般用前端visibilitychange事件监听页面进入后台时自动发送保存请求或者定时每5秒保存一次。课程设计能做完“进入书本时判断是否有历史进度、翻页时保存新进度”这个闭环就已经超过及格线不少了。5. 调试部署中的高频问题排查5.1 中文乱码的三层排查小说阅读器里的内容全是中文乱码问题的出现概率几乎是100%。乱码可能出现在三个层面排查时按顺序来页面显示层、请求参数传输层、数据库存取层。页面显示乱码检查JSP页面最顶部是否写了% page contentTypetext/html;charsetUTF-8 %没有这句话页面默认按ISO-8859-1渲染中文必乱。请求参数乱码POST表单提交需要在Servlet里最早处调用request.setCharacterEncoding(UTF-8)必须放在读取任何参数之前GET请求参数在Tomcat 8.0以上默认按UTF-8解码老版Tomcat则需要在server.xml里给Connector手动配置URIEncodingUTF-8。数据库存取乱码优先级最高检查JDBC连接串是否带着characterEncodingutf8同时确认建库时使用了DEFAULT CHARACTER SET utf8mb4。很多同学代码和页面设置全对唯独漏了建库时的字符集数据库默认用了latin1存进去的中文直接就变成问号而且改库字符集之前已经存进去的脏数据还得看情况清洗所以建库时就要一步到位。5.2 分页边界问题与章节顺序错误分页最容易出的两个边界问题是首页越界和尾页越界。用户手动在URL上输入?currentPage0或者-1LIMIT的偏移量变成负数MySQL直接报错输入?currentPage999页面空白但没报错体验极其糟糕。稳妥的处理是在Servlet里做一个normalizePage函数小于1返回1超过总页数返回总页数总页数又依赖总记录数所以每次分页查询要先查一次COUNT(*)然后算总页数。这两次查询在数据量小的时候性能损失可以忽略。章节顺序错乱的问题往往是因为用id而不是chapter_no来排序导致。章节删除后有id空洞如果SQL里写ORDER BY id这种场景下排序其实也没问题但为了语义清晰我会用chapter_no这个业务序号去排序和定位前后章。插入章节时要按书名查一下当前最大chapter_no再加1不给重复编号留机会。5.3 数据库连接泄漏与Tomcat部署路径连接泄漏的问题很隐蔽但实际概率极高。如果你在DAO里写了conn DbUtil.getConnection()却忘了在finally里关闭Tomcat跑上半天就会报连接超时因为连接池的连接被占满。尤其用了Druid连接池后只要conn没关连接就不会自动归还。我的习惯是每个DAO方法都写成try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql))这样Java会自动关闭省事且不容易漏。部署上如果直接把项目打war包放到Tomcat的webapps目录下启动后访问路径要带上下文名比如项目叫novel-reader地址是http://localhost:8080/novel-reader/。很多同学把war包放进去后访问http://localhost:8080/发现404其实是没带上下文路径。IDEA调试时在Run Configuration里把Deployment的应用上下文设置为/novel-reader和线上保持一致能省掉改来改去的麻烦。6. 从课程设计到工程化几点实在的体会6.1 写在答辩报告里的技术亮点同样是做一个系统有的作品能拿高分有的只能拿“完成基本功能”差距往往在于有没有把关键决策讲清楚。我会建议你把下面这几条作为“技术亮点”写进报告密码加盐哈希存储与登录拦截器、分页查询的偏移量计算与防越界处理、阅读历史表的唯一索引与INSERT ... ON DUPLICATE KEY UPDATE原子更新、章节内容按字符偏移分段加载、JDBC连接池替代裸连、PreparedStatement预编译防SQL注入。这些点不需要多高深但每一条都对应一个实际场景答辩时老师追问细节你都有东西可答。6.2 这个系统还能往哪些方向扩展项目交付之后如果你还想继续完善有几个方向比较自然。一是做一个简单的后台管理系统让作者可以上传小说章节阅读器就从一个纯展示系统变成内容管理系统技术难度主要在文件上传和富文本编辑但做出来特别加分。二是给阅读器增加主题设置和字号调整把用户的偏好存到t_user表里体验会好很多。三是把整章分段加载改成服务端分页接口顺手把接口返回值从JSP页面改成JSON格式这就是往后端分离过渡的第一步。6.3 最后分享一个调试小技巧开发阅读器过程中最烦的事情是每次改完JSP都要重启Tomcat才能看到效果。我后来给IDEA配置了热部署在On frame deactivation选项里选择Update classes and resources改完JSP切回浏览器就能直接刷新看到变化调试效率提升了一大截。这个配置在不同版本的IDEA里位置稍有差异但都在Run/Debug Configurations的Server标签页下。另外一个有助于排查前端翻页问题的小工具是浏览器的Network面板翻页请求发生了什么、返回了什么一目了然比在Servlet里打System.out.println断点来得快。我自己做这个项目最大的感受是它像一座桥把你从“会写Java语法”带到“能独立完成一个Web应用”的位置。从头到尾走一遍需求分析、数据库建模、编码、调试、部署你会突然发现自己对Servlet和HTTP的理解上了一个台阶。如果你正在选课程设计题目或者已经选了小说阅读器系统照这条路线慢慢推进就好别急着写代码先花两个晚上把表和页面流程想清楚后面会顺手得多。