简介:这份资源是面向Java Web初学者与在校学生的新闻发布系统完整项目,采用Servlet+JSP+MySQL技术栈实现,适合用作期末大作业、课程设计或自学练手。项目难度适中,源码经本地编译验证可运行,评审得分达98分,内容经助教老师审定,能较好满足学习与作业提交需求。压缩包共1647个文件,约24.45MB,涵盖54个Java源文件、92个HTML页面、90个CSS样式、364个JavaScript脚本,以及SQL建库脚本、Maven配置与项目说明文档,前端还包含Bootstrap等静态资源,结构完整便于二次开发。目前已有145人学习下载。读者可获得一套可直接运行的新闻发布系统源码,涵盖新闻增删改查、前后台交互等核心功能,并附文档说明帮助理解项目结构与部署流程,适合对照学习Servlet与JSP的整合开发思路。
1. 从零手搓 Java+Servlet+JSP+MySQL 新闻发布系统:这套老技术栈为什么还值得做一遍
如果你正在搜「Java Web 新闻发布系统源码」,大概率是三种人之一:课程设计要交差、面试前想补一个能讲清楚 CRUD 全链路的项目、或者刚学完 Servlet 想找个东西把 JSP、JDBC、MySQL 串起来。这套 Java+Servlet+JSP+MySQL 的组合看着老,但它恰恰是理解「一个请求从浏览器到数据库再回到页面」最干净的模型——没有 Spring 的自动装配帮你藏细节,没有 MyBatis 帮你拼 SQL,每一层你都得自己写,写完一遍,后面看任何框架都是降维打击。这篇笔记按「环境搭起来 → 表设计 → 后端 Servlet 怎么写 → JSP 页面怎么渲染 → 分页和富文本这些硬骨头 → 部署踩坑」的顺序推一遍,新手能照着跑通,熟手能对照检查自己的实现边界。
2. 环境与工程骨架:把 Tomcat、JDK、MySQL 三件套先跑通
2.1 版本选型:为什么我建议 JDK 8 + Tomcat 8.5 + MySQL 5.7
这套组合是 Java Web 教学项目里兼容性最稳的搭配。JDK 8 对 Servlet 3.1 支持完整,Tomcat 8.5 内置的 Jasper 编译器对 JSP 语法容错高,MySQL 5.7 的utf8mb4字符集配置成熟,不会出现 8.0 那种 caching_sha2_password 认证插件导致 JDBC 连接直接报Unable to load authentication plugin的问题。如果你非要用 MySQL 8.0,记住在连接串里加?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true,否则大概率卡在 SSL 握手或时区报错上。
| 组件 | 推荐版本 | 关键原因 |
|---|---|---|
| JDK | 1.8.0_202 | Servlet 3.1 兼容,Lambda 可用 |
| Tomcat | 8.5.x | JSP 编译稳定,部署目录结构清晰 |
| MySQL | 5.7.x | 认证插件简单,字符集配置不易翻车 |
| JDBC 驱动 | mysql-connector-java 5.1.49 | 与 5.7 匹配,无需额外 SSL 参数 |
| IDE | IDEA 或 Eclipse | IDEA 对 Web 模块支持更好 |
2.2 工程目录结构:别把 JSP 和 Java 混在一起
标准 Maven Web 工程结构如下,WEB-INF下的内容浏览器无法直接访问,这是放 JSP 和web.xml的安全位置:
news-system/ ├── src/main/java/ │ └── com/news/ │ ├── dao/ # 数据库操作层 │ ├── entity/ # 实体类 │ ├── servlet/ # 控制层 │ └── util/ # DBUtil 工具类 ├── src/main/webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # 部署描述符 │ │ └── views/ # 受保护的 JSP │ ├── static/ # css/js/图片 │ └── index.jsp # 首页入口 └── pom.xmlpom.xml里只需要三个核心依赖:javax.servlet-api(scope 设 provided,因为 Tomcat 自带)、mysql-connector-java、jstl。JSTL 一定要加,否则 JSP 里用<c:forEach>会报The absolute uri cannot be resolved。
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>2.3 数据库连接工具类:一个 DBUtil 解决所有 JDBC 样板代码
每次Class.forName+DriverManager.getConnection写一遍是新手最容易犯的重复劳动。抽一个DBUtil,把驱动加载放在静态块里只执行一次,连接参数从db.properties读,方便部署时改配置不用重新编译。
public class DBUtil { private static String url; private static String username; private static String password; static { try { Properties props = new Properties(); // 从类路径加载配置文件 props.load(DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")); Class.forName(props.getProperty("jdbc.driver")); url = props.getProperty("jdbc.url"); username = props.getProperty("jdbc.username"); password = props.getProperty("jdbc.password"); } catch (Exception e) { throw new RuntimeException("数据库驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } // 统一关闭资源,顺序不能反:ResultSet -> Statement -> Connection public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (stmt != null) stmt.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }db.properties放在src/main/resources下:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/news_db?useUnicode=true&characterEncoding=utf8mb4 jdbc.username=root jdbc.password=你的密码注意characterEncoding写utf8mb4而不是utf8,否则新闻正文里的 emoji 或生僻字会变成问号。关闭顺序必须是 ResultSet → Statement → Connection,反过来关会导致 Statement 已关闭时 ResultSet 报错。
3. 新闻表设计与 DAO 层:字段类型选错后面全是坑
3.1 建表语句:content 用 TEXT 还是 LONGTEXT
新闻正文长度差异极大,短消息几十字,长报道上万字。VARCHAR(255)肯定不够,TEXT上限 64KB 对大多数新闻够用,但如果要存带 HTML 标签的富文本,标签本身占字符,建议直接上LONGTEXT(4GB 上限),省得后期改表。
CREATE TABLE news ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, content LONGTEXT NOT NULL, author VARCHAR(50) DEFAULT '匿名', category_id INT DEFAULT 1, view_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_category (category_id), INDEX idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;create_time和update_time用数据库默认值自动维护,比在 Java 代码里手动new Date()塞进去可靠——代码里塞时间容易因为时区或格式转换出问题。view_count默认 0,后面做阅读量统计直接UPDATE news SET view_count = view_count + 1 WHERE id = ?。
3.2 NewsDao:PreparedStatement 防注入不是可选项
拼 SQL 字符串是新手最危险的习惯。用户输入' OR '1'='1就能绕过登录或删库。所有带参数的查询必须用PreparedStatement的?占位符。
public class NewsDao { // 分页查询:limit 的两个参数不能用 ? 在某些驱动下会报错,直接拼数字 public List<News> findByPage(int pageNum, int pageSize) { List<News> list = new ArrayList<>(); String sql = "SELECT id, title, author, create_time, view_count " + "FROM news ORDER BY create_time DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, (pageNum - 1) * pageSize); ps.setInt(2, pageSize); ResultSet rs = ps.executeQuery(); while (rs.next()) { News n = new News(); n.setId(rs.getInt("id")); n.setTitle(rs.getString("title")); n.setAuthor(rs.getString("author")); n.setCreateTime(rs.getTimestamp("create_time")); n.setViewCount(rs.getInt("view_count")); list.add(n); } } catch (SQLException e) { e.printStackTrace(); } return list; } // 统计总数,用于计算总页数 public int countAll() { String sql = "SELECT COUNT(*) FROM news"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { if (rs.next()) return rs.getInt(1); } catch (SQLException e) { e.printStackTrace(); } return 0; } }列表查询只SELECT需要的字段,不要SELECT *把LONGTEXT的正文也拉出来——列表页根本用不到正文,拉出来白白消耗内存和网络带宽。这是很多人不注意的性能细节,新闻量上千条后列表接口会明显变慢。
3.3 实体类与时间格式:java.util.Date 和 java.sql.Timestamp 的转换
实体类里时间字段用java.util.Date,从ResultSet取的时候用rs.getTimestamp()返回java.sql.Timestamp(它是java.util.Date的子类),直接赋值即可。反过来往数据库写的时候,用new java.sql.Timestamp(date.getTime())包装。JSP 页面展示时用 JSTL 的<fmt:formatDate>格式化,不要在 Java 里手动SimpleDateFormat,线程不安全。
4. Servlet 控制层:一个请求从 URL 到 JSP 的完整链路
4.1 用注解还是 web.xml 配置 Servlet 映射
Servlet 3.0 以后支持@WebServlet注解,比web.xml里写<servlet>和<servlet-mapping>简洁得多。我一般用注解,但要注意:注解的urlPatterns不要和 Tomcat 默认的/*冲突,否则静态资源也会被拦截。
@WebServlet("/news") public class NewsServlet extends HttpServlet { private NewsDao newsDao = new NewsDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null) action = "list"; switch (action) { case "list": listNews(req, resp); break; case "detail": showDetail(req, resp); break; default: listNews(req, resp); } } private void listNews(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 页码参数容错:非数字或小于1都归为第1页 int pageNum = 1; String pageStr = req.getParameter("page"); if (pageStr != null) { try { pageNum = Integer.parseInt(pageStr); if (pageNum < 1) pageNum = 1; } catch (NumberFormatException e) { pageNum = 1; } } int pageSize = 10; List<News> newsList = newsDao.findByPage(pageNum, pageSize); int total = newsDao.countAll(); int totalPages = (total + pageSize - 1) / pageSize; req.setAttribute("newsList", newsList); req.setAttribute("currentPage", pageNum); req.setAttribute("totalPages", totalPages); // 转发到 WEB-INF 下的 JSP,浏览器地址栏不变 req.getRequestDispatcher("/WEB-INF/views/news_list.jsp") .forward(req, resp); } }doGet里用action参数做路由分发是这套技术栈最常见的模式。forward和redirect的区别要记牢:forward是服务器内部跳转,request域里的数据能带到目标页面;redirect是让浏览器重新发请求,request数据全丢。列表页需要传newsList,所以必须用forward。
4.2 中文乱码:request 和 response 各管一半
POST 请求中文乱码,在doPost第一行加req.setCharacterEncoding("UTF-8")。GET 请求的参数在 URL 里,Tomcat 8 以后默认 URIEncoding 就是 UTF-8,一般不用改。响应端乱码,加resp.setContentType("text/html;charset=UTF-8")。两个都加,别只加一个然后纳闷为什么还是乱码。
@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); // 管请求体 resp.setContentType("text/html;charset=UTF-8"); // 管响应体 // ... 处理发布新闻等写操作 }4.3 发布新闻:表单提交到插入数据库的完整流程
发布新闻涉及表单页(GET 显示)和提交处理(POST 入库)两个动作。表单页放在WEB-INF/views/news_add.jsp,提交到/news?action=add。
private void addNews(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String title = req.getParameter("title"); String content = req.getParameter("content"); String author = req.getParameter("author"); // 服务端校验不能省,前端 required 可以被绕过 if (title == null || title.trim().isEmpty()) { req.setAttribute("error", "标题不能为空"); req.getRequestDispatcher("/WEB-INF/views/news_add.jsp") .forward(req, resp); return; } News news = new News(); news.setTitle(title.trim()); news.setContent(content); news.setAuthor(author == null || author.trim().isEmpty() ? "匿名" : author.trim()); boolean success = newsDao.insert(news); if (success) { // 发布成功重定向到列表页,防止刷新重复提交 resp.sendRedirect(req.getContextPath() + "/news?action=list"); } else { req.setAttribute("error", "发布失败,请重试"); req.getRequestDispatcher("/WEB-INF/views/news_add.jsp") .forward(req, resp); } }插入成功后用sendRedirect而不是forward,这是 PRG(Post-Redirect-Get)模式,防止用户按 F5 重复提交表单。req.getContextPath()拿到项目部署路径,避免硬编码/news-system导致换部署名就 404。
5. JSP 渲染与分页:把后端数据变成能看的页面
5.1 JSTL 循环渲染列表:c:forEach 和 fmt:formatDate 配合
JSP 里禁止写 Java 脚本片段(<% %>),用 JSTL 和 EL 表达式。列表页核心代码:
<%@ 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> <table> <tr><th>标题</th><th>作者</th><th>发布时间</th><th>阅读量</th></tr> <c:forEach items="${newsList}" var="news"> <tr> <td> <a href="${pageContext.request.contextPath}/news?action=detail&id=${news.id}"> <c:out value="${news.title}"/> </a> </td> <td><c:out value="${news.author}"/></td> <td><fmt:formatDate value="${news.createTime}" pattern="yyyy-MM-dd HH:mm"/></td> <td>${news.viewCount}</td> </tr> </c:forEach> </table> </body> </html>标题用<c:out>输出而不是直接${news.title},因为<c:out>会自动转义 HTML 标签。如果新闻标题里有人写了<script>alert(1)</script>,直接 EL 输出会执行脚本(XSS 攻击),<c:out>会把它转成<script>安全显示。
5.2 分页组件:上一页/下一页和页码跳转的边界处理
分页逻辑最容易在边界翻车:第 1 页点「上一页」、最后一页点「下一页」、直接输入超出范围的页码。后端已经做了pageNum < 1归 1 的容错,前端按钮也要做禁用态。
<div class="pagination"> <c:if test="${currentPage > 1}"> <a href="${pageContext.request.contextPath}/news?action=list&page=${currentPage - 1}">上一页</a> </c:if> <c:if test="${currentPage <= 1}"> <span class="disabled">上一页</span> </c:if> <span>第 ${currentPage} / ${totalPages} 页</span> <c:if test="${currentPage < totalPages}"> <a href="${pageContext.request.contextPath}/news?action=list&page=${currentPage + 1}">下一页</a> </c:if> <c:if test="${currentPage >= totalPages}"> <span class="disabled">下一页</span> </c:if> </div>totalPages在后端用(total + pageSize - 1) / pageSize计算,这是向上取整的整数写法。比如总数 21 条、每页 10 条,(21+9)/10 = 3页,第 3 页只有 1 条。如果写成total / pageSize会得到 2 页,最后 1 条永远显示不出来。
5.3 详情页阅读量自增:先更新再查询的顺序问题
详情页每次访问要让view_count加 1,然后显示最新值。顺序必须是先UPDATE再SELECT,反过来查到的还是旧值。
private void showDetail(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int id = Integer.parseInt(req.getParameter("id")); newsDao.increaseViewCount(id); // 先自增 News news = newsDao.findById(id); // 再查询,拿到的是自增后的值 if (news == null) { resp.sendError(404, "新闻不存在"); return; } req.setAttribute("news", news); req.getRequestDispatcher("/WEB-INF/views/news_detail.jsp") .forward(req, resp); }increaseViewCount的 SQL 是UPDATE news SET view_count = view_count + 1 WHERE id = ?,用数据库层面的自增而不是「先查出来加1再写回去」,后者在并发下会丢更新。
6. 避坑与排查:这套技术栈最容易翻车的 5 个地方
6.1 现象:启动 Tomcat 报 ClassNotFoundException: com.mysql.jdbc.Driver
原因:JDBC 驱动 jar 没放进WEB-INF/lib目录。Maven 项目里依赖 scope 如果是provided或test,打包时不会进 lib。解决:确认mysql-connector-java的 scope 是默认的compile,或者在 IDEA 的 Project Structure → Artifacts 里手动把驱动加到WEB-INF/lib输出目录。
6.2 现象:JSP 页面 EL 表达式原样输出${news.title}不解析
原因:web.xml的web-app标签 version 太低(2.3 或更低),EL 表达式默认不启用。解决:把web.xml声明改成 Servlet 3.1 对应的版本:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1">6.3 现象:MySQL 插入中文变成???问号
原因:三个环节的字符集没统一——数据库表、JDBC 连接串、Tomcat 的 URI 编码。解决:建表用DEFAULT CHARSET=utf8mb4,连接串加useUnicode=true&characterEncoding=utf8mb4,Tomcat 的server.xml里 Connector 加URIEncoding="UTF-8"。三处都改,缺一处就出问题。
6.4 现象:分页查询 LIMIT 后面用?占位符报 SQL 语法错误
原因:MySQL 5.1 版本的 JDBC 驱动对LIMIT ?, ?的占位符支持有 bug,某些情况下会把参数当字符串拼进去。解决:要么升级驱动到 5.1.49 以上,要么在 Java 里做整数校验后直接拼数字(因为已经parseInt过了,不存在注入风险)。我一般直接拼,省事。
6.5 现象:修改新闻后列表页还是旧数据
原因:浏览器缓存了列表页,或者 Tomcat 的 JSP 编译缓存没刷新。解决:开发阶段在浏览器禁用缓存(F12 → Network → Disable cache),Tomcat 的conf/context.xml里加<Context reloadable="true">让 JSP 改动自动重编译。生产环境不要开 reloadable,性能损耗大。
7. 富文本与图片上传:新闻系统绕不开的两个进阶点
新闻发布系统做到后面,纯文本域肯定不够用,编辑要加粗、插图、排版。引入富文本编辑器(常见做法是 wangEditor 或 UEditor)后,content字段存的是 HTML 字符串,展示时不能用<c:out>转义,得用${news.content}直接输出——但这带来 XSS 风险,所以必须在服务端对 HTML 做白名单过滤,只允许<p><strong><img>等安全标签,<script><iframe>一律剔除。我一般用 Jsoup 的Safelist做清洗:
// 富文本入库前清洗,只保留安全标签 String safeHtml = Jsoup.clean(rawHtml, Safelist.basicWithImages() .addTags("p", "h1", "h2", "h3", "span") .addAttributes("span", "style"));图片上传是另一个高频需求。Servlet 3.0 以后可以用@MultipartConfig注解 +req.getPart()接收文件,不用引 commons-fileupload。关键参数:maxFileSize限制单文件大小(建议 5MB),maxRequestSize限制整个请求大小,location指定临时目录。上传后的文件存到webapp/upload/下,数据库只存相对路径。注意上传目录不能放在WEB-INF里,否则浏览器访问不到图片。
验证这套系统是否真的跑通,我的习惯是走一遍完整链路:启动 Tomcat → 访问列表页确认分页正常 → 发布一条带中文和图片的新闻 → 详情页确认阅读量自增 → 修改标题后列表页刷新看到新标题 → 删除后列表少一条。五步都过,说明 DAO、Servlet、JSP、数据库四层没有断点。这套老技术栈的价值不在于它多先进,而在于每一层你都能看到、能改、能调试,把这条链路走通一遍,后面学 Spring Boot 时你会知道那些注解背后到底帮你做了什么。希望帮到你。
本文还有配套的精品资源,点击获取