简介:一份基于 Java + Servlet + JSP + MySQL 的 Web 新闻发布系统源码与配套文档,面向正在完成期末大作业、课程设计或需要入门 Java Web 开发的在校学生。系统涵盖新闻分类、内容发布、后台管理、数据持久化等典型模块,源码经本地编译可正常运行,评审得分 98,难度适中,所有内容均通过助教老师审定,可作为学习参考或直接改造的工程基底。资源包共 1647 个文件,大小约 24.45 MB,其中 java 文件为业务逻辑与 Servlet 控制层,jsp 负责动态页面展示,xml 用于配置映射,sql 为数据库初始化脚本,另有大量 css、js、png、gif 等前端样式、交互素材与界面元素,整体目录结构清晰,便于按层查阅。目前已有 144 人学习下载,适合需要快速上手新闻类 Web 项目的开发者。通过这份资料,可获取一套完整的项目源代码、数据库文件及相关文档说明,既能对照理解 Java Web 分层开发流程,也能在此基础上扩展功能,满足课程答辩或项目提交的实际需求。
1. 从新闻发布系统反推Java Web传统技术栈的职责边界
当一套新闻发布系统的源码摆在你面前,技术栈是 Java + Servlet + JSP + MySQL,第一反应往往是这套组合还能不能改、部署起来麻不麻烦。其实在 Spring Boot 铺开之前,这是绝大多数 Java Web 网页项目的默认答案,搜索里高频出现的 mysql 安装配置、eclipse 创建基于 maven 的 servlet 项目、jsp 编译 class 文件保存在哪里,说明很多人拿到这类源码之后,第一步卡在环境与工程结构,而不是业务本身。新闻系统最典型的链路是:Servlet 接收列表请求,调 DAO 查 MySQL,把结果 setAttribute 到 request 里,再 forward 给 JSP 渲染;后台发布则依赖 Session 登录态和发布表单的校验。这套链路一旦跑通,Servlet 生命周期、过滤器,以及 Spring MVC 里 DispatcherServlet 和 WebApplicationContext 的分工就都说得通了。下面按“建表与连接池 → Servlet 与 JSP 请求流转 → 发布管理与会话控制 → Tomcat 部署和编译验证”展开。
2. 新闻系统MySQL表结构设计,JDBC连接池选什么
先把数据库层定住。拿到这类源码,第一件事不要急着打开 Java 代码,而是先执行建表脚本。表结构如果先天缺字段,后面加列、加索引都要改动 DAO 和页面,返工成本是这几个环节里最高的。MySQL 的安装配置教程网上已经很多,这里只说和新闻系统强相关的三个点:字符集、索引、连接池。
执行脚本用 MySQL Workbench 或者命令行都行,重点是用 utf8mb4 字符集建库,并且向下兼容旧的建表语句,避免出现表情符号写入失败。
2.1 三张核心表:News、Category、User 中的字段怎么定
按资讯类 Web 项目的习惯,起步用三张表就够了:用户表、分类表、新闻表。分类和新闻是一对多,用户和新闻是一对多,业务规模不大时不需要引入标签表。表引擎用 InnoDB,字符集统一 utf8mb4,因为新闻正文里出现中文引号和 emoji 很常见,只用 utf8 会在部分字符上出现占位符错乱。
CREATE DATABASE news_web DEFAULT CHARACTER SET utf8mb4; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '分类名称', sort INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='新闻分类表'; CREATE TABLE news ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL COMMENT '所属分类', title VARCHAR(200) NOT NULL COMMENT '新闻标题', summary VARCHAR(500) DEFAULT '' COMMENT '摘要,列表页展示用', content TEXT COMMENT '新闻正文', image_url VARCHAR(500) DEFAULT '' COMMENT '封面图路径', author VARCHAR(50) DEFAULT '管理员' COMMENT '作者', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT '0草稿 1发布', view_count INT NOT NULL DEFAULT 0 COMMENT '浏览量', KEY idx_category_create (category_id, create_time), KEY idx_status (status), CONSTRAINT fk_news_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='新闻表'; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT '加密后的密码', nickname VARCHAR(50) DEFAULT '' COMMENT '显示名称', role TINYINT NOT NULL DEFAULT 0 COMMENT '0编辑 1管理员' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台用户表';字段设计上有几个容易忽略的点,直接用表说明:
| 表 | 字段 | 设计要点 |
|---|---|---|
| news | category_id 与 create_time | 建联合索引,列表页最常见的查询是“某分类按时间倒序”,联合索引让排序不再临时 filesort |
| news | status | 单独索引,后台按草稿/发布状态过滤时避免全表扫描 |
| news | content | 用 TEXT,单篇几万字足够;不要提早上 LONGTEXT,逻辑备份和跨机迁移差别明显 |
| news | image_url | 只存相对路径,如 /upload/xxx.jpg,不存带端口和域名的完整地址 |
| user | password | 存加密后的密文,用 MD5 加盐或者 BCrypt,不存明文 |
| category | sort | 排序放数据库字段,不要在 SQL 里把 name 当排序依据 |
补充一点与存储过程相关的经验:分页查询用 COUNT 加 LIMIT 两步完成即可,没必要把分页写进 MySQL 存储过程;存储过程一旦和 Servlet 事务混在一起,排查问题时日志里能给你的线索反而变少。连接层的问题比业务 SQL 更早暴露,接着看连接池。
2.2 连接池配置,DriverManager 和 Druid 怎么选
新闻发布系统并发量不大,直接用 DriverManager 获取连接也能跑通,但每次请求都建立物理连接,数据库连接成本高,机器内存一紧张,很快就会出现连接超时。常见做法是引入连接池,Druid 和 HikariCP 都可以,关键是初始化只做一次,而不是在每次请求里重复读配置。
如果你打算用 Eclipse 创建基于 Maven 的 Servlet 项目,这个 DbUtil 可以直接放到 src/main/java 下,db.properties 放在 src/main/resources 里,Maven 打包时会自动带上。
在 src/main/resources 下放一个 db.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/news_web?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=your_password再用一个 DbUtil 统一管理数据源:
public class DbUtil { private static DruidDataSource dataSource; static { Properties props = new Properties(); try (InputStream in = DbUtil.class.getClassLoader().getResourceAsStream("db.properties")) { props.load(in); dataSource = new DruidDataSource(); dataSource.setDriverClassName(props.getProperty("jdbc.driver")); dataSource.setUrl(props.getProperty("jdbc.url")); dataSource.setUsername(props.getProperty("jdbc.username")); dataSource.setPassword(props.getProperty("jdbc.password")); dataSource.setInitialSize(5); dataSource.setMaxActive(20); dataSource.setMinIdle(5); } catch (IOException e) { throw new ExceptionInInitializerError("数据库配置加载失败"); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }参数含义再明确一下。initialSize 是启动时预建的连接数,设 5 适合新闻站的低并发起步;maxActive 是最大活跃连接数,20 对这个场景足够,调高到几百并不会让数据库变快,反而会让同一台 MySQL 服务器在突发流量下被压垮;minIdle 保持 5 个空闲连接,避免瞬间并发上来时全部重新建连。数据库本身的连接上限也要留意,连接池 maxActive 和 MySQL 的 max_connections 要留出 buffer。
有一个常见误用:配了 Druid 又写Class.forName("com.mysql.cj.jdbc.Driver")。驱动注册两遍不会报错,但代码一看就是从多个错误答案里拼凑出来的。另一个问题是驱动类名,老项目里用com.mysql.jdbc.Driver,新版驱动更名成com.mysql.cj.jdbc.Driver,连接 URL 还必须带上 serverTimezone,否则 JDBC 驱动在解析时间字段时可能抛异常。
2.3 DAO 层封装,把查询结果转成对象的关键写法
DbUtil 只负责连接来源,Servlet 里不能直接写 ResultSet 循环。按单表建一个 DAO 类,NewsDao 里放 findPage、findById、insert、updateStatus。分页实现用两步查询:先 count 拿总数,再按 limit 拿当前页数据。
public PageResult<News> findPage(Integer categoryId, int page, int pageSize) { PageResult<News> result = new PageResult<>(); String where = ""; List<Object> params = new ArrayList<>(); if (categoryId != null) { where = " WHERE category_id = ?"; params.add(categoryId); } String countSql = "SELECT COUNT(*) FROM news" + where; String pageSql = "SELECT id, category_id, title, summary, create_time, view_count, status " + "FROM news" + where + " ORDER BY create_time DESC LIMIT ?, ?"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps1 = conn.prepareStatement(countSql); PreparedStatement ps2 = conn.prepareStatement(pageSql)) { for (int i = 0; i < params.size(); i++) { ps1.setObject(i + 1, params.get(i)); ps2.setObject(i + 1, params.get(i)); } ps2.setObject(params.size() + 1, (page - 1) * pageSize); ps2.setObject(params.size() + 2, pageSize); try (ResultSet rs1 = ps1.executeQuery()) { if (rs1.next()) { result.setTotal(rs1.getInt(1)); } } try (ResultSet rs2 = ps2.executeQuery()) { List<News> list = new ArrayList<>(); while (rs2.next()) { News n = new News(); n.setId(rs2.getInt("id")); n.setCategoryId(rs2.getInt("category_id")); n.setTitle(rs2.getString("title")); n.setSummary(rs2.getString("summary")); n.setCreateTime(rs2.getTimestamp("create_time")); n.setViewCount(rs2.getInt("view_count")); n.setStatus(rs2.getInt("status")); list.add(n); } result.setList(list); } return result; } catch (SQLException e) { throw new RuntimeException("分页查询失败", e); } }这里故意不用 select *。列表页不需要 content 大字段,查出来只会增加网络传输和对象内存;详情页再通过 findById 单独取 content。PreparedStatement 先占位后传参,categoryId 不会拼进 SQL 字符串,这属于防注入的基础写法。分页的 LIMIT 偏移量是(page - 1) * pageSize,第一页从第 0 条开始,要跟 SQL 的 LIMIT 语法对应,否则页码整体错位。
3. Servlet+JSP的新闻列表与详情页,请求流转怎么串
列表页和详情页是新闻系统里最常用的两个动作。列表页要处理分页和分类筛选,详情页要处理无效参数和浏览量,这两条链路走通,后台管理的 Servlet 就是同样的模式加一点状态判断。
3.1 用 @WebServlet 写 ListNewsServlet,分页参数校验不做懒处理
@WebServlet 注解方式省掉了 web.xml 里的 servlet 映射,Tomcat 7 以上都支持。同一个 web 工程里注解和 web.xml 不冲突,但同一个 Servlet 路径不要同时配置两份,否则部分容器会重复初始化。
@WebServlet("/news/list") public class ListNewsServlet extends HttpServlet { private NewsDao newsDao = new NewsDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); int page = parsePositiveInt(req.getParameter("page"), 1); int pageSize = parsePositiveInt(req.getParameter("pageSize"), 10); Integer categoryId = parsePositiveIntOrNull(req.getParameter("categoryId")); PageResult<News> result = newsDao.findPage(categoryId, page, pageSize); req.setAttribute("result", result); req.setAttribute("categoryId", categoryId); req.getRequestDispatcher("/WEB-INF/jsp/news/list.jsp").forward(req, resp); } private int parsePositiveInt(String value, int defaultValue) { if (value == null || !value.matches("\\d+")) { return defaultValue; } return Integer.parseInt(value); } private Integer parsePositiveIntOrNull(String value) { if (value == null || !value.matches("\\d+")) { return null; } return Integer.valueOf(value); } }这里有两个小细节容易被忽略。第一个是字符编码,req.setCharacterEncoding("UTF-8")只对 POST 请求体里的参数有效,GET 请求参数的编码取决于 Tomcat 连接器的 URIEncoding 配置;在 url 里带中文分类名或标题时,要么配置 Tomcat 的URIEncoding="UTF-8",要么让前端的链接只传 id。第二个是请求分页参数,page=-1或page=abc这类脏值必须兜底,直接Integer.parseInt会抛出 NumberFormatException,页面显示 500。
页面跳转用的是 forward 而不是 sendRedirect。sendRedirect 是让浏览器重新发起一次请求,地址栏会变成新地址,并且原 request 里的属性全部丢失;forward 是 Servlet 容器内部转发,浏览器地址不变,JSP 可以直接通过${result}取到 Servlet 设好的数据。
3.2 JSP 页面只用 EL 和 JSTL,源码里不写 Java 脚本
列表页 JSP 中,常见的错误写法是<% for (...) { %>块结合 JDBC 代码。换成 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> <c:forEach items="${result.list}" var="item"> <div class="news-item"> <h3><a href="${pageContext.request.contextPath}/news/detail?id=${item.id}">${item.title}</a></h3> <p>${item.summary}</p> <span><fmt:formatDate value="${item.createTime}" pattern="yyyy-MM-dd HH:mm"/></span> <span>浏览量:${item.viewCount}</span> </div> </c:forEach> <div class="pagination"> <c:if test="${result.currentPage > 1}"> <a href="${pageContext.request.contextPath}/news/list?page=${result.currentPage - 1}&categoryId=${categoryId}">上一页</a> </c:if> <span>第 ${result.currentPage} / ${result.totalPages} 页</span> <c:if test="${result.currentPage < result.totalPages}"> <a href="${pageContext.request.contextPath}/news/list?page=${result.currentPage + 1}&categoryId=${categoryId}">下一页</a> </c:if> </div> </body> </html>这段代码要注意三点。第一,${pageContext.request.contextPath}是动态获取工程上下文路径,工程名改了就自动变,不要在链接里写死 /news_web/。第二,fmt:formatDate 处理 java.util.Date,如果 createTime 在实体类里用了 LocalDateTime,需要先转换,或者直接用<c:out>输出字符串;大部分老源码直接用 Timestamp,JSTL 可以识别。第三,分页链接要带上 categoryId,否则翻到第二页时分类条件丢失,看似“分页失效”,实际是请求参数没有完整透传。
JSTL 标签库需要在 web 工程里引入 jstl 相关 jar 包,Servlet 容器本身不包含 JSTL 实现。很多人在 Eclipse 里新建 Servlet 项目时能编过 JSP,一运行报错提示找不到标签库,就是这个依赖漏了。用 Maven 管理依赖时,把 jstl 依赖加到 pom.xml,构建后会自动打进 war。
转发关系整理成一张表,方便对照工程目录找文件:
| 页面动作 | Servlet 映射 | forward 目标 |
|---|---|---|
| 新闻列表 | /news/list | /WEB-INF/jsp/news/list.jsp |
| 新闻详情 | /news/detail | /WEB-INF/jsp/news/detail.jsp |
| 后台登录入口 | /admin/login.jsp | 直接访问,不经 Servlet |
3.3 详情页与原生 Servlet 的映射关系,和 DispatcherServlet 对照着看
详情页的 Servlet 逻辑比列表页更简单,但它承担着参数校验和浏览量自增两件事。
@WebServlet("/news/detail") public class NewsDetailServlet extends HttpServlet { private NewsDao newsDao = new NewsDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String idParam = req.getParameter("id"); if (idParam == null || !idParam.matches("\\d+")) { resp.sendError(400); return; } News news = newsDao.findById(Integer.parseInt(idParam)); if (news == null || news.getStatus() != 1) { resp.sendError(404); return; } newsDao.increaseViewCount(news.getId()); req.setAttribute("news", news); req.getRequestDispatcher("/WEB-INF/jsp/news/detail.jsp").forward(req, resp); } }id 参数先匹配\\d+再转换,能挡掉负数和非数字字符串。news 不存在时返回 404,避免被搜索引擎收录后出现一堆无效详情页。浏览量自增放在查询之后,SQL 可以写成UPDATE news SET view_count = view_count + 1 WHERE id = ?,不要在 Java 里先查出来再加,因为那两步中间如果有并发,计数会互相覆盖。
原生 Servlet 的/news/detail映射到一个 HttpServlet 类上,Spring MVC 则用一个 DispatcherServlet 作为前端控制器,DispatcherServlet 内部通过 HandlerMapping 把 URL 映射到 Controller 方法。DispatcherServlet 的 WebApplicationContext 负责装配 Controller 等 Web 层 bean,rootWebApplicationContext 负责数据源和业务层 bean,两层之间有父子容器关系。理解这个语境之后再去看 Servlet WebApplicationContext 和 RootWebApplicationContext 的差异,就不会停留在背面试八股文的程度。
4. 新闻发布系统里的登录控制、发布表单与文件上传
新闻系统的后台通常包含登录、新闻发布、图片上传三个模块。这三个模块不需要复杂的权限框架,用 Filter 加 Session 就能做到清晰可控,重点是别把所有校验都压在 JSP 页面里。
4.1 用 Filter 统一拦截后台 Session 没有的请求
最常见的裸奔做法是每个后台 Servlet 开头都从 Session 里查登录用户,代码一多就有人漏写。推荐用一个 Filter 统一拦截 /manage/* 下的所有路径:
@WebFilter("/manage/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); Object loginUserId = session == null ? null : session.getAttribute("loginUserId"); if (loginUserId == null) { response.sendRedirect(request.getContextPath() + "/admin/login.jsp"); return; } chain.doFilter(req, resp); } }关键点是getSession(false)。如果传 true,容器会为每个未登录请求都新造一个 Session,等于把会话 ID 无意义地发给所有访问者,Cookie 里存了一堆垃圾。这里显式传 false,session 不存在就直接跳登录页。
登录页本身要放在 /admin/ 下而不是 /manage/ 下,避免进后台页面时先被 Filter 拦回登录页形成死循环。登录成功后不要只把用户名放 Session,把 userId 和角色 role 一起放进去,后面判断“能否删除新闻”时需要角色;只存用户名,就成了能进不能管的“假登录”。
使用 @WebFilter 时,过滤器顺序不固定;如果项目里还有编码过滤器,最好在 web.xml 里把 encodingFilter 放在 LoginFilter 前面,否则过滤器链先执行登录判断再纠正编码,POST 参数的中文可能还没生效就被带进业务逻辑了。
4.2 发布新闻的保存 Servlet:参数校验、转义与重复提交
发布新闻是后台最重要的写操作。NewsSaveServlet 建议单独建在 /manage/news/save 路径,表单 post 到这儿。这里重点关注四个边界:未登录直接 POST 的请求、空标题和空正文、正文里的 HTML 脚本、重复点击提交按钮。
@WebServlet("/manage/news/save") public class NewsSaveServlet extends HttpServlet { private NewsDao newsDao = new NewsDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); HttpSession session = req.getSession(false); if (session == null || session.getAttribute("loginUserId") == null) { resp.sendError(403); return; } String title = req.getParameter("title"); String content = req.getParameter("content"); if (title == null || title.trim().length() < 2 || title.trim().length() > 200) { req.setAttribute("error", "标题长度需要在 2 到 200 字之间"); req.getRequestDispatcher("/manage/news/edit.jsp").forward(req, resp); return; } if (content == null || content.trim().isEmpty()) { req.setAttribute("error", "正文不能为空"); req.getRequestDispatcher("/manage/news/edit.jsp").forward(req, resp); return; } News news = new News(); news.setTitle(escapeHtml(title.trim())); news.setSummary(escapeHtml(req.getParameter("summary"))); news.setContent(filterUnsafeHtml(content)); news.setAuthor((String) session.getAttribute("loginNickname")); // categoryId、status 同理,需要强校验后 set newsDao.insert(news); resp.sendRedirect(req.getContextPath() + "/manage/news/list"); } }这段代码把转义和过滤放在一个明显的位置。title 是纯文本,用 escapeHtml 把<script>变成<script>,页面再怎么渲染也出不了脚本。content 如果允许后台人员发简单 HTML,要用白名单方式过滤,只保留 p、br、strong 这类安全标签;即便富文本编辑器在浏览器端做了过滤,服务端也不能裸信任。表单提交后的跳转用了 sendRedirect,避免刷新时浏览器再次 POST。重复点击的问题,常见做法是提交后前端禁用按钮,后端也可以再配合一个 token 校验,但对后台发布系统来说,先做好参数校验和登录过滤更重要。
4.3 图片上传用 @MultipartConfig 和 Part,路径别和业务字段混在一起
新闻列表页的封面图,一般只存图片 URL,不把图片二进制放进 MySQL。上传接口用 Servlet 3.0 的 Part 接口实现,不需要额外引 commons-fileupload。在 Servlet 类上增加注解:
@WebServlet("/manage/news/uploadImage") @MultipartConfig( maxFileSize = 3 * 1024 * 1024, maxRequestSize = 10 * 1024 * 1024, fileSizeThreshold = 1024 * 1024 ) public class UploadImageServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part part = req.getPart("file"); if (part == null || part.getSize() == 0) { resp.getWriter().write("{\"code\":400}"); return; } String submitted = part.getSubmittedFileName(); int dotIndex = submitted.lastIndexOf('.'); String ext = dotIndex >= 0 ? submitted.substring(dotIndex + 1).toLowerCase() : ""; if (!"jpg".equals(ext) && !"jpeg".equals(ext) && !"png".equals(ext) && !"gif".equals(ext)) { resp.getWriter().write("{\"code\":400}"); return; } String savedName = UUID.randomUUID().toString().replace("-", "") + "." + ext; String uploadDir = getServletContext().getRealPath("/upload"); File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } long size = part.write(uploadDir + File.separator + savedName); resp.getWriter().write("{\"code\":200,\"url\":\"/upload/" + savedName + "\"}"); } }配置项的含义列一下,方便对照着调:
| 参数 | 示例值 | 作用 |
|---|---|---|
| maxFileSize | 3 * 1024 * 1024 | 单张图片上限 3MB,大于这个尺寸直接拒绝 |
| maxRequestSize | 10 * 1024 * 1024 | 一次请求体总大小上限,防止批量传图撑爆堆内存 |
| fileSizeThreshold | 1024 * 1024 | 超过 1MB 的 Part 先落磁盘临时文件,而不是一直留在内存 |
上传文件的文件名必须重命名。part.getSubmittedFileName()是浏览器传过来的原始名,可能带中文、空格甚至路径信息,直接拼到目录里会引发路径穿越风险;用 UUID 生成随机名,原始扩展名仅作为白名单判断依据。图片 URL 中保存/upload/xxx.jpg这种相对路径,不存http://localhost:8080/xxx,原因在第 2 章已经说过:换域名和端口时存量数据不用改。
5. Tomcat部署后怎么验证JSP编译的class文件位置
部署一个传统 Web 工程,网上教你“把 war 拷贝到 webapps”的文章一搜一大把,但真正上手时常见的问题往往不是启动失败,而是页面行为跟代码不一致。这时候直接去看 Tomcat 把 JSP 编译出来的 Java 源码,比反复刷新页面更快定位。
5.1 用 work 目录确认 JSP 是否真的编译成功
先用 Maven 打包或者直接用 Eclipse 的 Run on Server 把这个 web 项目部署到 Tomcat。启动后第一次访问首页新闻列表,Tomcat 才会把对应的 list.jsp 翻译成 Java 源码并编译成 class 文件。文件位置在 Tomcat 安装目录的 work 子目录里,与工程名和上下文路径对应:
ls -l $CATALINA_HOME/work/Catalina/localhost/news_web/org/apache/jsp/预期能看到类似下面的输出:
-rw-r--r-- 1 dev dev 42170 mon dd hh:mm list_jsp.java -rw-r--r-- 1 dev dev 28241 mon dd hh:mm list_jsp.classlist_jsp.java 是 JSP 翻译后的 Servlet 源码,里面能看到一个_jspService方法,request、response、session、application 这些对象会被容器预置成方法内局部变量。如果页面里引用的 JSTL 标签或者 EL 表达式运行时报错,在生成的 Java 文件里搜具体的异常关键字,比对着 JSP 猜要快得多。修改 JSP 后,Tomcat 会自动重新编译并覆盖旧的 .java 和 .class 文件,不需要手动删除 work 目录;如果你发现改完页面没生效,再检查是不是浏览器缓存了旧资源,换个无痕窗口访问一次即可。
第一次用 Eclipse 打开老项目时,JSP 编辑器会提示页面内嵌 JavaScript 和标签库无法解析,还会弹出离开页面提示,这些静态检查信息不影响 Tomcat 编译,很多人却因此误判成工程有问题。直接把 work 目录下对应生成的 .java 文件打开,看到内容里已经有你新加的 HTML 片段,就说明部署的确实是当前最新版本,提示可以忽略。
5.2 文档说明按数据库脚本、接口清单、部署步骤三块整理
这类源码包里通常会带文档说明,但很多文档写成了流水账。常见做法是控制住三个部分即可:环境要求与部署步骤、数据库初始化脚本说明、后台接口与目录结构清单。
环境要求部分写清楚 JDK 版本、Tomcat 版本和 MySQL 版本,这三个版本不匹配是最常见的启动异常。数据库部分把建表脚本的导入命令写出来,比如mysql -u root -p < news_web.sql,并注释默认后台账号密码。接口清单部分,把 /news/list、/news/detail、/manage/news/save 这些路径的请求方式、参数和返回页面列成表格。目录结构说明标注 java 源码和 JSP 所在位置,后来接手的同事不需要从 web.xml 一个个去翻。
最后给一个可复现的验证顺序:先执行数据库脚本,再用mvn package打 war,拷贝到 Tomcat webapps 后启动,访问/news/list看到数据,点进详情页看到浏览量 +1,再登录后台发布一条测试新闻并上传一张封面图,最后检查 work 目录里对应的 JSP class 文件时间戳。复制生成的 list_jsp.java 到工程外的临时目录,用 diff 命令和上一版部署的编译产物比对,行号变化的范围能精确指出 JSP 页面上哪一段模板代码被更改过,这个技巧在排查线上页面和本地不一致时比一遍遍重启 Tomcat 更省事。
本文还有配套的精品资源,点击获取