资讯动态

Java Servlet JSP MySQL实现新闻发布系统:从MVC架构到分页实战

发布时间:2026/10/18 19:33:11 来源:尧图企业网站定制
简介这套基于 JavaServletJSPMySQL 的 Web 新闻发布系统源码面向 Java Web 初学者及课程设计、毕业设计人群覆盖新闻发布、管理、展示和用户登录等典型业务闭环。资源共 269 个文件压缩包仅 6.13MB包含 112 个 class 编译文件、56 个 java 源文件、50 个 jsp 页面、10 个 jar 依赖包以及 sql 数据库脚本、css 样式、png/gif 图片等目录结构清晰控制层、数据访问层与页面按职责分离可直接导入 IDE 运行。已有 3433 人学习下载适合借此理解 Servlet 控制层、JSP 视图层、JDBC 数据库访问及 MVC 分层思想。借助完整源码与 SQL 脚本可快速搭建一套新闻发布系统并在此基础上扩展权限管理、防 SQL 注入、分页查询等实战功能对提升 Java Web 项目开发与排错能力有明显帮助。1. JavaServletJspMysql实现Web新闻发布系统先别急着上 Spring Boot这个组合依然值得写如果你打开这个标题的时候正在找课程设计选题或者要在一个资源紧张的服务器上快速部署一个资讯站JavaServletJspMysql实现Web新闻发布系统 这个组合并没有过时。它要解决的问题很明确用 Servlet 接收请求、用 JSP 渲染页面、用 MySQL 做持久化最终完成用户登录、新闻发布、列表分页、详情展示这一整套闭环。我见过很多开发者一上来就引入全家桶框架结果被自动配置折腾到半夜。相比之下这套技术栈的请求链路非常短浏览器发起请求Servlet 处理JSP 回显MySQL 存储数据。每一步都能在代码里被看到排错也不需要面对一层又一层的黑匣子。正因为链路直白它特别适合想让新手真正理解 Web 开发本质的人。如果你属于这类人或者你想用最小成本维护一个内部新闻系统这个方向值得认真做完。读完这篇笔记你会拿到从目录结构、数据表设计、登录鉴权到分页查询的完整落地思路以及我实际踩过的坑。至于要不要在后期替换成更重的框架那是你理解了这套链路之后再做决定的事。2. 先把项目骨架立住ServletJSPMySQL 的技术选型与工程目录2.1 传统 MVC 不旧Servlet、JSP 各自负责什么很多新手把 Servlet 和 JSP 当成两种并列的页面技术这是第一个误区。Servlet 本质上是运行在 Tomcat 里的 Java 类它负责接收 HTTP 请求、调用业务逻辑、再把结果塞进 request 作用域JSP 则是 Servlet 的模板化写法页面里可以写 Java 脚本但更规范的做法是用 EL 表达式和 JSTL 标签读取 Servlet 准备好的数据。在这个新闻系统里我一般会把请求路径拆成/login、/news、/admin/news这样几个入口。登录、发布、删除这类操作走 Servlet展示层交给 JSP。这样分工后你在 JSP 里几乎不用写业务代码页面要做的只是把列表、分页条、表单回显出来。这也是 MVC 的核心意图控制器只做路由和装配视图只做渲染模型只做数据访问。2.2 标准 Maven Web 工程目录一步建好我习惯用 Maven 建这个项目而不是直接用 IDE 的 Web Application 模板因为依赖管理在后面会省掉很多麻烦。最小目录结构如下注意src/main/java和src/main/webapp的职责不要混。src/main/java └── com/example/news ├── controller # Servlet ├── service # 业务层简单系统可以省略但保留更清晰 ├── dao # 数据访问对象 ├── entity # User、News、Category ├── util # DBUtil、PageResult └── filter # 编码过滤器、登录过滤器 src/main/resources └── db.properties # 数据库连接配置 src/main/webapp ├── WEB-INF │ ├── web.xml # Servlet 映射、Session 超时 │ └── jsp # 页面文件避免直接访问 WEB-INF ├── static # CSS/JS/图片 ├── login.jsp └── index.jspcontroller 只放 HttpServlet 子类dao 只写 SQL 相关代码entity 里是纯属性对象。这个结构不是模仿大厂而是为了让一个三四千行的项目仍然能被一眼看懂。2.3 最小 pom.xml依赖就这三样不要一上来就引入 Spring、MyBatis先让核心链路跑通。一个能启动的 Maven 工程只需要 Servlet API、JSP API、MySQL 驱动再加一个 JSTL 就够了。以下是我在国内常见配置里会用到的最小依赖清单版本号按你的本机环境调整即可。project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdnews-web/artifactId version1.0-SNAPSHOT/version packagingwar/packaging properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target /properties dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version scoperuntime/scope /dependency /dependencies build finalNamenews-web/finalName /build /projectServlet 和 JSP 依赖的 scope 必须写成provided因为 Tomcat 自己带了这些 jar打包时如果打进去容易出现类冲突。mysql-connector-java理论上只在运行时需要所以写runtime也可以省掉编译期依赖。finalName直接决定打出来的 war 包名后面往 Tomcat 部署时越简单越好。3. 数据层落地MySQL 表结构、连接池与防注入的 DAO 封装3.1 三张核心表用户表、新闻表、分类表新闻发布系统最怕一开始就把字段堆在一张表里。我建议先建三张表用户表t_user、新闻表t_news、分类表t_category。分类和新闻是一对多关系新闻和作者是一对多关系。下面这份 SQL 可以直接执行字符集统一用utf8mb4否则新闻内容里存不了 emoji。CREATE DATABASE news_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE news_db; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50), role TINYINT DEFAULT 1 COMMENT 1普通用户 2管理员 ) ENGINEInnoDB; CREATE TABLE t_category ( id INT PRIMARY KEY AUTO_INCREMENT, category_name VARCHAR(50) NOT NULL ) ENGINEInnoDB; CREATE TABLE t_news ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, summary VARCHAR(500), content MEDIUMTEXT, cover_url VARCHAR(255), category_id INT NOT NULL, author_id INT NOT NULL, status TINYINT DEFAULT 1 COMMENT 1草稿 2上线, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_news_category FOREIGN KEY (category_id) REFERENCES t_category(id), CONSTRAINT fk_news_author FOREIGN KEY (author_id) REFERENCES t_user(id) ) ENGINEInnoDB;字段类型的选择比想象中重要。status用TINYINT而不是CHAR(1)是为了后续扩展状态时不用改表结构content用MEDIUMTEXT是因为新闻站经常有几千字的长文TEXT的 64KB 上限偶尔会不够。create_time让 MySQL 自己维护插入时不需要手动 setTime。3.2 数据库连接URL 参数与连接池选择新手最常见的做法是每次访问都在 Servlet 里Class.forName加DriverManager.getConnection一次两次没问题用户量一多加锁和释放连接的开销会把数据库拖死。正确姿势是使用连接池。我一般会选择 HikariCP它的配置少、稳定Tomcat 环境下不需要额外插件。先准备src/main/resources/db.propertiesjdbcUrljdbc:mysql://localhost:3306/news_db?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrue usernameroot passwordyourpassword driverClassNamecom.mysql.cj.jdbc.Driver maximumPoolSize10 connectionTimeout3000URL 里有三个参数最容易被忽略。characterEncodingUTF-8控制应用和数据库之间的字符传输serverTimezoneAsia/Shanghai是因为高版本 MySQL 驱动默认要求时区不配置会直接报错useSSLfalse用于本机和内网开发环境避免证书校验的额外开销。rewriteBatchedStatementstrue是批量插入时才生效的优化项现在写上没坏处。3.3 写一个不被 SQL 注入击穿的 BaseDAO数据访问层不要直接裸用Statement而是统一走PreparedStatement。它不只是 SQL 注入的防线也能让 MySQL 对同结构 SQL 做预编译频繁查询时整体更稳。下面是我常用的数据库工具入口。package com.example.news.util; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; public final class DBUtil { private static HikariDataSource dataSource; static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { Properties props new Properties(); props.load(in); HikariConfig config new HikariConfig(); config.setJdbcUrl(props.getProperty(jdbcUrl)); config.setUsername(props.getProperty(username)); config.setPassword(props.getProperty(password)); config.setDriverClassName(props.getProperty(driverClassName)); config.setMaximumPoolSize(Integer.parseInt(props.getProperty(maximumPoolSize))); config.setConnectionTimeout(Long.parseLong(props.getProperty(connectionTimeout))); dataSource new HikariDataSource(config); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt ! null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } }close方法里conn.close()并不是真的关闭数据库连接而是归还给连接池。所以不要省略这一步否则连接池会被占满。DAO 层约定先开连接、再执行、最后在 finally 里释放这个顺序能挡住大多数资源泄漏问题。3.4 分页查询中的占位符不是装饰写新闻分页时最容易翻车的代码是直接用String sql select * from t_news limit pageNo , pageSize。一旦pageNo来自 URL 参数这就是一个可以直接被注入的洞。用PreparedStatement改写后LIMIT后面的问号也照样是安全的因为 MySQL 驱动会把参数转成整数后拼进协议。public ListNews findPage(int pageNo, int pageSize, Integer categoryId) { String sql SELECT * FROM t_news WHERE status 2 ; if (categoryId ! null) { sql AND category_id ? ; } sql ORDER BY create_time DESC LIMIT ? OFFSET ?; ListNews list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { int index 1; if (categoryId ! null) { ps.setInt(index, categoryId); } ps.setInt(index, pageSize); ps.setInt(index, (pageNo - 1) * pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { News news new News(); news.setId(rs.getInt(id)); news.setTitle(rs.getString(title)); news.setSummary(rs.getString(summary)); news.setCreateTime(rs.getTimestamp(create_time)); list.add(news); } } } catch (SQLException e) { throw new RuntimeException(分页查询失败, e); } return list; }这段代码里有三个细节值得注意。第一status 2意味着前端列表只显示已上线的新闻草稿不会泄漏。第二LIMIT ? OFFSET ?的第二个?绑定的是(pageNo - 1) * pageSize如果页面从 1 开始这个公式就是标准页偏移量。第三try-with-resources会自动关闭Connection和PreparedStatement比手动写 finally 更稳。4. 核心功能实现登录鉴权、新闻列表与发布流程4.1 登录会话Session 与 Cookie 的职责边界登录的最终目的不是让用户进入某个页面而是让后续请求能识别“你是谁”。我习惯只往 Session 里放一个不可变用户对象不把用户名和密码写到 Cookie。Session 的会话 ID 由容器自动维护客户端用 Cookie 保存这个 ID服务端数据不会全部暴露给浏览器。下面是最小可用的LoginServlet。WebServlet(/login) public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password request.getParameter(password); User user userDao.findByUsernameAndPassword(username, password); if (user ! null) { HttpSession session request.getSession(); session.setAttribute(currentUser, user); session.setMaxInactiveInterval(30 * 60); response.sendRedirect(request.getContextPath() /news); } else { request.setAttribute(error, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } }request.setCharacterEncoding必须在读取任何参数之前调用否则中文用户名会乱码。session.setMaxInactiveInterval设置 30 分钟无操作过期这个时间要按场景调整不是越长越好。登录成功的跳转建议用sendRedirect避免用户刷新页面时出现重复提交。4.2 新闻列表与分页页面参数怎么安全落到 SQL分页请求的入口一般是/news?pageNo2categoryId1。Servlet 拿到参数后不能直接信任要做合法性校验pageNo小于 1 就按 1 处理大于总页数时按最后一页处理。这个“兜底”逻辑看起来多余但没有它用户在地址栏乱改数字时页面会白屏。WebServlet(/news) public class NewsListServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int pageNo parseInt(request.getParameter(pageNo), 1); int pageSize parseInt(request.getParameter(pageSize), 10); Integer categoryId parseIntOrNull(request.getParameter(categoryId)); if (pageNo 1) { pageNo 1; } int totalCount newsDao.count(categoryId); int totalPages (totalCount pageSize - 1) / pageSize; if (pageNo totalPages) { pageNo Math.max(1, totalPages); } PageResultNews page new PageResult(pageNo, pageSize, totalCount, totalPages, newsDao.findPage(pageNo, pageSize, categoryId)); request.setAttribute(page, page); request.setAttribute(categoryList, categoryDao.findAll()); request.getRequestDispatcher(/WEB-INF/jsp/news_list.jsp).forward(request, response); } private int parseInt(String value, int defaultValue) { if (value null || value.trim().isEmpty()) { return defaultValue; } try { return Integer.parseInt(value); } catch (NumberFormatException e) { return defaultValue; } } private Integer parseIntOrNull(String value) { if (value null || value.trim().isEmpty()) { return null; } try { return Integer.valueOf(value); } catch (NumberFormatException e) { return null; } } }parseInt和parseIntOrNull是我处理 URL 参数时的习惯做法凡是用户能改的参数都要过一层转换和默认值。totalPages的计算用(totalCount pageSize - 1) / pageSize比单独写 if 判断更简洁也能避免pageSize0时除零异常。JSP 页面只负责展示分页条通过 JSTL 标签生成上一页、下一页链接。nav c:if test${page.pageNo 1} a href${pageContext.request.contextPath}/news?pageNo${page.pageNo - 1}上一页/a /c:if span第 ${page.pageNo} / ${page.totalPages} 页/span c:if test${page.pageNo page.totalPages} a href${pageContext.request.contextPath}/news?pageNo${page.pageNo 1}下一页/a /c:if /nav分页链接里的categoryId也要保留否则用户选了分类后翻页分类条件会丢失。更完整的方案是把pageNo、categoryId都塞进同一个 key-value 集合里再拼查询串。4.3 新闻详情与发布从表单到数据库的完整链路发布新闻是写入操作必须走 POST。管理员在后台表单里填标题、选分类、填正文表单提交到/admin/news/saveServlet 做基础校验后调用 DAO 插入数据最后重定向到新闻列表避免重复提交。WebServlet(/admin/news/save) public class NewsSaveServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String title request.getParameter(title); String summary request.getParameter(summary); String content request.getParameter(content); String categoryId request.getParameter(categoryId); if (title null || title.trim().isEmpty()) { request.setAttribute(error, 标题不能为空); request.getRequestDispatcher(/WEB-INF/jsp/news_edit.jsp) .forward(request, response); return; } News news new News(); news.setTitle(title.trim()); news.setSummary(summary null ? : summary.trim()); news.setContent(content); news.setCategoryId(Integer.parseInt(categoryId)); news.setAuthorId(((User) request.getSession().getAttribute(currentUser)).getId()); news.setStatus(2); newsDao.insert(news); response.sendRedirect(request.getContextPath() /admin/news); } }这里有一个容易漏掉的鉴权逻辑任何/admin/开头的 Servlet 都必须在执行前判断 Session 里的用户是否存在并且角色是否为管理员。我通常会在过滤器里做这个判断而不是在每个 Servlet 里复制一遍。还应该加一个基础的长度校验标题超过 200 字时直接拒绝而不是让数据库报错后页面 500。5. 避坑指南新手最容易翻车的 5 个问题5.1 现象JSP 文件放在 WEB-INF 外页面还在被直接访问很多人项目跑起来后发现edit.jsp能被浏览器直接输入地址打开而且功能没经过 Servlet登录状态也没校验。原因是把 JSP 放到了webapp根目录而不是WEB-INF/jsp下面。原因WEB-INF目录是容器的私有区域浏览器无法直接访问其中的文件所有访问必须通过 Servlet 转发进去。放到webapp外的文件等于公开页面。解决把所有编辑、后台管理页面移动到WEB-INF/jsp下Servlet 里用request.getRequestDispatcher(/WEB-INF/jsp/news_edit.jsp).forward(...)跳转。前端展示页news_list.jsp也可以按这个规范放进去。5.2 现象数据库里看到的中文全是问号发布新闻后在 MySQL 客户端里查t_news表title和content字段变成???。更诡异的是MySQL 命令行显示正常但 Java 代码读出来乱码。原因链路里某一环的字符集不一致。常见是db.properties的 JDBC URL 没有characterEncodingUTF-8或者建表时表的DEFAULT CHARSET是latin1也有可能 JSP 页面的pageEncoding和响应Content-Type没写UTF-8。解决把建表语句和连接 URL 都统一成utf8mb4页面顶部加% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 %。改完这一套再去查数据通常就正常了。5.3 现象点击发布后页面转圈很久最后显示 504 或连接超时发布一条几千字的新闻Tomcat 日志里出现 Connection 超时页面等了很久没返回。单独看 SQL 也没有死锁。原因连接池配置过小同时执行的查询和写入把连接占满了。更常见的场景是某个finally块里没有释放连接或者是 DAO 里ResultSet没关闭导致连接一直被占用。解决先看连接池的最大连接数和当前活跃数。如果是 HikariCP可以在日志里打开DEBUG观察。重点是检查所有 DAO 的finally/try-with-resources是否真的关闭了Connection。我遇到过最隐蔽的一次是某个查询里rs.close()抛异常后conn.close()反而没执行所以后来习惯用 try-with-resources 让容器自动处理。5.4 现象清空浏览器 Cookie 后Session 里的登录状态还在用户退出登录后把 Cookie 里的JSESSIONID删掉重新访问后台页面发现仍然显示已登录。原因在于 Session 本身还没有过期服务端内存里依然存着currentUser这个属性。解决退出登录时不仅要response.sendRedirect到登录页还要调用session.invalidate()。如果 Session 里存了敏感信息退出时再把它移除双保险。HttpSession session request.getSession(false); if (session ! null) { session.removeAttribute(currentUser); session.invalidate(); } response.sendRedirect(request.getContextPath() /login);5.5 现象新闻数据超过几万条后分页越来越慢刚开始几千条数据时分页很流畅。数据涨到几十万条后翻到第 100 页明显变慢MySQL CPU 也上去了。原因LIMIT 1000, 20的含义是先查出前 1020 条再扔掉前 1000 条偏移量越大扫描越多。t_news表如果没有合适的索引排序和偏移就有额外的开销。解决给排序字段和筛选字段加联合索引比如按状态、分类、创建时间排序的查询可以建(status, category_id, create_time)索引。如果数据量大常见的做法是先WHERE id 上一页最大 id LOWER LIMIT而不是一直用大偏移量。这个优化在新闻系统里很有效只是代码要多一行判断。6. 上线前再补三件事统一编码与安全过滤、图片上传、部署验证6.1 用过滤器统一处理编码而不是在 Servlet 里重复写我后来习惯把所有 Servlet 里的request.setCharacterEncoding删掉改用一个EncodingFilter。这样不会漏也不会重复。WebFilter(/*) public class EncodingFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); response.setContentType(text/html; charsetUTF-8); chain.doFilter(request, response); } }注意过滤器只处理编码不能替代登录校验。登录状态判断应该单独再用一个/admin/*的过滤器否则业务代码里到处都是 if。6.2 图片上传用本地存储还是 Base64小型新闻系统通常会配一个封面图。用 Base64 存进 MySQL 的优点是不需要额外目录但缺点也明显图片会拖慢数据库列表页查询也变重。我建议本地磁盘加版本号文件名数据库只存相对路径。# nginx 或 Tomcat 配置一个静态资源映射 /web/uploads/ -- /data/news-uploads/上传时用 UUID 生成文件名不要把用户上传的原文件名直接拼到路径里避免出现非法字符和重名覆盖。记一笔账系统越简单越值得把上传目录放在应用外部这样重新部署 war 包时不会丢图片。6.3 部署到 Tomcat 后的验证清单发布到 Tomcat 之后不要只看首页不报错就结束了。我一般会走一遍这四条路径。检查项预期结果直接访问/admin/news未登录时跳转登录页登录后发布一篇含 emoji 的新闻入库和回显不乱码访问?pageNo-1和?pageNo99999页面不会报错页码被兜底处理发布后立即刷新详情页不会重复提交数据只插入一次这四条里第一条最容易漏。很多人开发时直接登录后测后台没试过未登录场景上线后别人点一下/admin/news整个后台直接暴露。我现在的习惯是任何 Web 系统写完先跑一遍“恶意参数攻击”路径再跑一遍正常路径。ServletJSP 这套技术栈没有框架替你挡住这些但正因为没有遮挡你踩过的每个坑都会变成真正的肌肉记忆。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑