1. 为什么JSP+JavaBean这种"老古董"还值得完整做一次
先说一个可能反直觉的结论:如果你正在找题做,而且目标是"把Java Web到底是怎么跑起来的"彻底弄清楚,那基于JSP+JavaBean的中小型企业门户网站,比一上来就套Spring Boot全家桶更有价值。这话放现在已经很少有人说了,但确实是真话。
刚看到"JSP+JavaBean"这个组合,不少人第一反应是"这都什么年代了还在用JSP"。但实际上国内的课程设计、实验项目、入门练习里,这类题目一直没消失,因为它的边界非常清晰:不需要搞懂IOC容器、AOP代理、自动配置这些抽象机制,只需要把"浏览器发请求→Web容器调Java代码→查数据库→拼HTML返回"这条链路亲手搭一遍。把这条链路吃透了,后面再去学Spring MVC、Spring Boot,你会发现那些框架做的事本质上是同一件事,只是帮你省掉了大量样板代码。
那"中小型企业门户网站"这个业务载体选得也很好。它的核心需求是企业形象展示,不是复杂电商也不是重交互系统。企业简介、新闻动态、产品展示、人才招聘、联系方式,再加上一个后台信息维护入口,这些模块的复杂度非常适合用JSP+JavaBean来承载。你说难点在哪?不在某个高深算法,而在"一个常规CRUD系统如何组织得干净、不写成一锅粥"。这个分寸感,恰恰是很多新手项目翻车的地方。
还有一个现实维度:这类题目经常出现在毕业设计和课程设计的备选库里。你要是拿它当练手,做出来的东西既能在答辩时讲清楚,又能给后续的框架学习打底。我后面写的所有内容,也都按"一个基础扎实、边界克制、能跑能演示"的标准来展开,完全贴合这类项目的实际定位。
1.1 企业门户网站到底需要哪些能力
在开始写代码之前,先把业务面想清楚。一个典型的中小型企业门户网站,按使用者分有两个视角:
- 访客视角:打开网站能看到企业介绍、最新公告、产品展示、招聘信息,能通过留言板或联系方式找企业。这些内容以"看得见、查得到"为主,不需要注册登录。
- 管理员视角:要维护上面那些内容,登录后台后能发布新闻、上下架产品、管理招聘职位、回复留言,顺便改一改网站的标题和联系方式。
这两类需求合并后,系统的功能边界就出来了。前台是一个数据展示门户,后台是一个内容管理后台(CMS)。这个定位非常重要——如果题目本身只是门户网站,千万不要自己拍脑袋加购物车、订单、用户积分之类的电商功能。每次我遇到有人把这俩混在一起,项目都会失控,代码量翻倍不说,答辩时还会被问得很难受。
1.2 JSP、JavaBean、Servlet、JDBC各自的活
很多资料喜欢把这三个名词堆在一起讲,结果新手越看越糊涂。这里我用大白话把分工理一遍:
- JSP(Java Server Pages):负责"把Java数据变成HTML页面"。它本质是一个运行在服务器上的模板,里面可以写HTML,也可以通过标签或小脚本把Java对象的属性输出到页面上。
- JavaBean(Java类):负责"装数据"和"处理业务规则"。一个JavaBean通常有两类,一类是实体Bean,对应数据库表结构,比如新闻对象、产品对象;另一类是业务Bean(这里我们通常叫DAO),负责查询数据、保存数据、删除数据。
- Servlet(控制器):严格来说,一个规范的Java Web项目里JSP不该直接处理请求,而应该由Servlet接收请求、调用JavaBean、再跳转到JSP渲染。不过你会发现很多入门版门户网站为了省事,直接在JSP页面里写处理逻辑,标题里的"jsp+java bean"组合,在不少参考实现里就是指"JSP页面+JavaBean类",控制器角色被压缩了。我不推荐完全去掉Servlet,但可以理解这种简化,后面说代码结构时我会给出更稳的写法。
- JDBC(Java DataBase Connectivity):Java连接数据库的标准接口,通过它发送SQL语句到MySQL并取回结果。
把这四个角色装进脑子里,整个项目的技术框架就已经成型:MySQL存数据,JavaBean承载数据与业务,JDBC负责两者之间的通道,JSP把数据铺到页面上,Servlet作为可选的前端控制器。
2. 动工前的模块拆解:前台与后台分开设计
很多参考代码把前后台混在同一个目录里,页面和逻辑纠缠在一起,后期改一个功能要牵全身。我这个项目里坚持前台和后台目录分离,物理上就隔开。目录结构大概是这样的:
webapp/ ├── index.jsp // 门户首页,入口 ├── about.jsp // 企业简介 ├── news_list.jsp // 新闻列表 ├── news_detail.jsp // 新闻详情 ├── product_list.jsp // 产品列表 ├── product_detail.jsp // 产品详情 ├── job_list.jsp // 招聘列表 ├── contact.jsp // 联系我们 ├── message_add.jsp // 在线留言提交 ├── css/ images/ // 静态资源 ├── common/ │ ├── header.jsp // 公共头部 │ └── footer.jsp // 公共底部 └── admin/ ├── login.jsp // 后台登录 ├── index.jsp // 后台首页(数据概览) ├── news_manage.jsp // 新闻管理 ├── news_add.jsp // 发布新闻 ├── product_manage.jsp // 产品管理 ├── job_manage.jsp // 职位管理 └── message_manage.jsp // 留言管理这样设计之后,访客访问的页面都集中在根路径,管理后台统一挂在admin/下面,通过登录状态做一道拦截。代码维护时,改前台样式不会碰到后台逻辑,加一个功能模块也只需要在对应位置补页面和Bean。
2.1 每个模块需要哪些字段和交互
以新闻模块为例,它是管理后台最典型的功能。管理员登录后进入新闻管理页,看到的是一个表格,列出了已有新闻的标题、发布时间、操作按钮(编辑、删除)。点击"发布新闻"进入编辑页,填标题和正文,提交后写入数据库,然后刷新列表。前台新闻列表页按发布时间倒序展示标题,点击任意标题进入详情页,展示正文全文。
产品模块稍微特殊一点,因为产品一般带有图片。需要注意字段里要留一个图片路径,而不是把图片本身塞进数据库。上传的图片保存到服务器某个目录(比如upload/下),数据库中只存文件名或访问路径。招聘模块则偏文本化:职位名称、招聘人数、学历要求、薪资范围、岗位职责、发布时间。前台展示时可以做成列表加详情,也可以直接一个页面全部列出,根据内容量决定。
留言模块是唯一一个"从访客到管理员"的交互点。前台提供表单,访客填写姓名、联系方式、留言内容,提交后插入数据库。管理员在后台看到留言列表,可以对它做"标记已回复"或删除处理。这个模块虽然小,但它是整个门户里最能体现"前后台数据流动"的功能,建议不要省。
2.2 管理员登录权限怎么设计才不显得脆弱
后台管理页面不能让所有人直接访问,所以登录校验是必须的。最简单的做法是:登录成功后把管理员用户名放进Session,后台页面的公共头部检查Session里有没有这个值,没有就跳回登录页。
这里的坑在于很多参考代码只在菜单上"隐藏"了后台入口,或者只在admin/index.jsp一个页面做了判断。实际要保护的是后台所有页面,否则直接输入admin/news_manage.jsp地址就能绕过登录。我建议用一个简单的公共包含页面admin/check_login.jsp,专门做Session判断,然后在每个后台管理页面的第一行<%@ include file="check_login.jsp" %>,一劳永逸。
管理员表不用设计得很复杂,字段就三个:id、username、password。密码不要明文存,至少用MD5做一次摘要,登录时把输入密码也做同样的摘要再和库里比对。虽然这个项目不是高安全场景,但这个习惯值得从入门就培养。
3. 请求流转与代码结构:一次新闻列表是怎么被渲染出来的
这里我把最关键的执行链路用文字完整还原一遍,你看完就知道JSP+JavaBean根本没有神秘感。
假设访客在浏览器里输入http://localhost:8080/portal/news_list.jsp。服务器收到请求后,Tomcat找到名为portal的Web应用下的news_list.jsp文件,执行其中的JSP代码。JSP里有这样一段:
<jsp:useBean id="newsDao" class="com.portal.dao.NewsDAO" scope="page" /> <% List<News> list = newsDao.getAllNews(); request.setAttribute("newsList", list); %>这段代码的意思是:创建一个新闻数据操作对象newsDao,调用它的getAllNews()方法拿到新闻列表,放进request作用域。紧接着页面下方用JSTL循环输出:
<c:forEach var="news" items="${newsList}"> <li><a href="news_detail.jsp?id=${news.id}">${news.title}</a></li> </c:forEach>浏览器最终收到的是纯HTML,JSP和JavaBean在这些HTML生成之后就已经退场了。
3.1 JavaBean分成两层:实体Bean与数据操作Bean
不少入门代码把JavaBean当做一个类从头写到尾,数据库操作逻辑和属性封装全塞在一起,导致代码复用性很差。我习惯把JavaBean拆成两类:
第一类是实体Bean(Model),例如News类,里面是数据库中news表的字段和对应的getter/setter:
package com.portal.entity; public class News { private int id; private String title; private String content; private String publishTime; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public String getPublishTime() { return publishTime; } public void setPublishTime(String publishTime) { this.publishTime = publishTime; } }第二类是数据操作Bean(DAO),例如NewsDAO,里面是访问数据库的具体方法:
package com.portal.dao; import com.portal.entity.News; import com.portal.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class NewsDAO { public List<News> getAllNews() { List<News> list = new ArrayList<>(); String sql = "SELECT id, title, content, publish_time FROM news ORDER BY publish_time DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { News news = new News(); news.setId(rs.getInt("id")); news.setTitle(rs.getString("title")); news.setContent(rs.getString("content")); news.setPublishTime(rs.getString("publish_time")); list.add(news); } } catch (SQLException e) { e.printStackTrace(); } return list; } public News getNewsById(int id) { // 单条查询,略 } public boolean addNews(News news) { // 插入逻辑 } public boolean updateNews(News news) { // 更新逻辑 } public boolean deleteNews(int id) { // 删除逻辑 } }3.2 数据库连接不要写死在每个DAO里
上面代码里有一个DBUtil.getConnection(),这是一个工具类,统一负责创建数据库连接。这是整个项目里容易被新手忽略但极度重要的一处设计。如果你在每个DAO方法里都DriverManager.getConnection("jdbc:mysql://..."),一旦数据库地址或账号变了,你要改十几个地方,漏一处就是灾难。把所有连接参数收口到DBUtil里,只改一个类即可:
package com.portal.util; import java.sql.Connection; import java.sql.DriverManager; 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/portal_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; 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); } }这里提前说一下两个高频坑:MySQL 8.x版本驱动类名是com.mysql.cj.jdbc.Driver,不是老版本的com.mysql.jdbc.Driver;URL连接参数里如果不带serverTimezone=Asia/Shanghai,很多环境会报时区错误。
3.3 后台发布新闻的完整数据流
后台表单页news_add.jsp里有一个HTML表单,提交后到达处理页news_add_do.jsp。处理页里的逻辑是:设置请求编码,读取表单字段,组装News对象,调用NewsDAO.addNews(),然后跳转回新闻管理列表。看这段代码的先后顺序就明白了:
<%@ page contentType="text/html; charset=utf-8" pageEncoding="utf-8" %> <% request.setCharacterEncoding("utf-8"); String title = request.getParameter("title"); String content = request.getParameter("content"); News news = new News(); news.setTitle(title); news.setContent(content); NewsDAO dao = new NewsDAO(); boolean ok = dao.addNews(news); if (ok) { response.sendRedirect("news_manage.jsp"); } else { out.println("发布失败,请重试"); } %>注意这里有一个细节:跳转时用的是response.sendRedirect,而不是forward。区别在于,sendRedirect让浏览器发出一个新的请求,地址栏会变成news_manage.jsp,刷新页面不会重复提交表单;而forward是服务器内部转发,地址栏不变,刷新时会重新提交,导致重复插入数据。这个问题在演示答辩时特别容易翻车,我之前就见过有人连续点了两次刷新,新闻列表里冒出好几条一模一样的记录。
4. 数据库建模:门户网站的关键表怎么设计才不冗余
数据库设计说难不难,说简单也不简单。我在建模初期吃过亏——一开始图省事只建了一张超大的content表,想用"类型字段"区分新闻、产品、招聘,结果写SQL的时候到处是WHERE type='news',逻辑非常别扭。后来老老实实拆表,每个模块一张表,清爽多了。
这里给出的是一套适合JSP+JavaBean入门项目的表结构,总共5张核心表加1张管理员表。用MySQL建库时,字符集一定要指定utf8mb4,否则中文可能出现乱码或四字节表情存不进去:
CREATE DATABASE portal_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE portal_db; CREATE TABLE admin ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ); CREATE TABLE news ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, content TEXT, publish_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE product ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(50), image_path VARCHAR(200), intro VARCHAR(500), content TEXT, publish_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE job ( id INT AUTO_INCREMENT PRIMARY KEY, position VARCHAR(100) NOT NULL, requirement VARCHAR(500), salary VARCHAR(50), place VARCHAR(100), publish_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE message ( id INT AUTO_INCREMENT PRIMARY KEY, visitor_name VARCHAR(50), visitor_phone VARCHAR(20), visitor_email VARCHAR(100), content VARCHAR(1000), is_reply TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );4.1 为什么产品表要有category和intro
产品模块和企业简介模块不同,它天然带着分类属性。比如一家制造业企业可能有"智能设备""配件耗材"两条产品线。如果没有category字段,前台做分类筛选几乎无从下手。intro字段的作用是列表页的摘要,长度控制在500字以内,避免列表页因为加载正文全文而变得很长。这种"列表页只显示摘要,详情页加载全文"的字段设计思路,在内容型网站里是通用做法。
4.2 招聘表要不要拆成两张表
有人问"岗位职责和任职要求那么多条,是不是应该拆成子表"。我的建议是:如果是像本系统这样的轻量门户,不需要。把要求做成一个requirement文本字段,写的时候用换行分隔或分号分隔即可。拆成两张表意味着要引入主键外键关联、多表查询,复杂度上去了,实际收益却很小。做项目最忌讳的不是"功能少",而是"复杂度超过了题目的需要"。"中小型企业门户网站"这个题目决定了它不需要把招聘做成一站式招聘平台。
4.3 新闻表为什么不需要外键关联admin表
新闻表里没有记录发布人ID,只存了标题、正文、发布时间。这是因为本项目的管理员数量极少(通常就一两个),发新闻时不需要精确到"哪个管理员发的"。如果需要,可以在news表里加一个author字符串字段,发布时把管理员用户名塞进去。真的没必要为此建外键关联admin表,否则还要考虑管理员的增删对新闻历史数据的影响。记住一句话:外键和关联不是越多越好,而是够用就好。
5. 前台页面渲染的几种手段:useBean、JSTL和小脚本
JSP页面里写Java代码有三种主流方式,很多项目同时混用了这三种方式,这本身没问题,但要弄清楚各自的使用场景,否则页面会变得很难读。
5.1 jsp:useBean与jsp:getProperty适合简单数据
jsp:useBean是题目里"JavaBean"比较直观的体现。它的作用是在JSP页面里实例化一个JavaBean对象,并存放到指定作用域:
<jsp:useBean id="now" class="java.util.Date" scope="page" /> 当前时间:<jsp:getProperty name="now" property="time" />对于门户网站而言,这种标签适合在页面上直接输出实体对象的属性。它看起来优雅,但功能有限——你不能在标签里写循环、写条件。所以它只适合"零散输出一个对象属性"的场景,比如页面头部显示当前管理员用户名。
5.2 JSTL+EL是列表页的推荐方式
产品列表、新闻列表这类"多条数据循环输出"的场景,我强烈建议使用JSTL核心标签加EL表达式。使用前要在JSP页面顶部引入标签库:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>然后在页面上用forEach循环:
<c:forEach var="p" items="${productList}"> <li> <a href="product_detail.jsp?id=${p.id}">${p.name}</a> <p>${p.intro}</p> </li> </c:forEach>EL表达式${p.name}会自动调用Product类的getName()方法,这就是Bean的命名规则为什么必须遵循getter/setter的原因。可以说,Bean约定和JSP的EL表达式是天然配套的。
5.3 小脚本<% %>越少越好
JSP里直接写<% ... %>是最直白的方式,新手最喜欢用,但它有个问题:调试困难,一个英文状态下的单引号写错,整个页面白屏,错误信息还不好定位。我的建议是:页面里只保留"取得数据、转交给EL"这类短逻辑,复杂业务一律下沉到JavaBean方法里。
比如新闻列表页,有一句通用做法是在页面顶部嵌套useBean再加一小段脚本获取数据:
<jsp:useBean id="newsDAO" class="com.portal.dao.NewsDAO" scope="page" /> <% request.setAttribute("newsList", newsDAO.getAllNews()); %>这段脚本已经足够克制,剩下的HTML展示全交给JSTL,维护起来完全在可控范围。以后学了Spring MVC,你再看这个写法,就是"Controller里拿数据、放到Model、JSP展示"的雏形——知识迁移就是这么发生的。
6. 图片上传、分页与搜索:三个最容易被要求加的扩展点
门户网站这个题目,做过基础版本后,评委或导师常问的问题通常是:"你这个产品图片是怎么传上去的?""新闻多了一页放不下怎么办?""用户能不能搜索?"所以这三个扩展点最好提前有准备。
6.1 图片上传涉及的两个层面的问题
产品图片上传,表面上是文件操作,实际上涉及两个层面:一是文件如何从浏览器传到服务器的指定目录,二是文件路径如何在数据库和页面之间流转。
实现上,不要用普通的表单application/x-www-form-urlencoded编码,而要使用multipart/form-data编码,并在后台用commons-fileupload组件解析请求。我的建议是,直接在后台产品添加页里写一段统一的上传处理逻辑,保存到upload/目录,文件名用时间戳加随机数重命名,避免中文名乱码和同名覆盖:
String savePath = application.getRealPath("/upload"); String newFileName = System.currentTimeMillis() + "_" + fileName; FileItem item = upload.iterator().next(); item.write(new File(savePath, newFileName)); product.setImagePath("upload/" + newFileName);数据库里存的是相对路径upload/xxx.jpg,而不是绝对路径。这样做的原因是,当项目部署到不同环境(本机Tomcat、服务器Tomcat的webapps),绝对路径都会变化,只有相对路径是稳定的。页面展示图片时,直接<img src="${p.imagePath}" />就完事。
6.2 分页查询的核心是limit参数计算
新闻数量超过20条时,列表页就要分页。分页的逻辑很简单,但新手经常搞错的是页码参数的计算。MySQL的分页用LIMIT offset, size,其中offset是跳过的记录数,size是每页条数。
假设每页显示= 10条,第page页的offset = (page - 1) * pageSize。比如第1页offset=0,第2页offset=10。DAO里分页查询的核心SQL:
SELECT id, title, content, publish_time FROM news ORDER BY publish_time DESC LIMIT ?, ?;同时你还需要一个总数查询SELECT COUNT(*) FROM news,用于计算总页数totalPage = (totalCount + pageSize - 1) / pageSize。这个公式相当于向上取整,比用Math.ceil直接写还要直观。
6.3 搜索功能的SQL写法与防注入
门户网站的搜索,最实用的就是新闻标题模糊搜索和产品名称模糊搜索。实现方式都是同一个套路:把关键词拼进LIKE查询。但这里有一个安全红线——绝对不能直接做字符串拼接,而要用PreparedStatement占位符:
String sql = "SELECT * FROM product WHERE name LIKE ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, "%" + keyword + "%"); ResultSet rs = ps.executeQuery();如果你写成"SELECT * FROM product WHERE name LIKE '%" + keyword + "%'",一旦关键词里包含单引号或SQL关键字,轻则查询报错,重则被构造出恶意SQL。这个问题在答辩时经常被问,能脱口说出"用PreparedStatement防止SQL注入",比你说三千字功能描述都加分。
7. 部署上线与运行环境配置:从本机到演示环境的完整步骤
一个JSP+JavaBean的Web项目,最终交付时通常是这样一个形态:MySQL数据库脚本、项目源码压缩包、部署说明文档。让别人能在自己电脑上跑起来,是这类项目的基本交付标准。
7.1 本机跑通的三个必要条件
首先要确认三个环境变量到位:JDK版本(建议8或11)、Tomcat(建议8.5或9,与JDK版本匹配)、MySQL(5.7或8.0都可以)。然后按下面步骤走:
- 在MySQL里执行前面给出的建库建表SQL,并插入一条管理员初始账号。
- 确认
DBUtil.java里的账号密码、URL和你本机一致。 - 使用Eclipse或IDEA配置Tomcat服务器,部署项目,启动。
如果不用IDE,也可以直接打WAR包放到Tomcat的webapps目录,启动Tomcat后它会自动解压部署。访问入口是http://localhost:8080/portal/index.jsp(其中portal是应用上下文路径,取决于你的WAR包名称或IDE里的Application context)。
7.2 从零开始最容易卡的五个运行时错误
我总结一下新手在这个项目上最容易卡住的地方,每个都是我实际遇到或帮别人排查过的:
| 现象 | 根因 | 解决办法 |
|---|---|---|
| 启动时ClassNotFound异常 | JDBC驱动包mysql-connector-java.jar没放到WEB-INF/lib | 把jar放进lib目录,确认项目编译时也引用了 |
| 页面中文全是问号 | 数据库连接URL缺少characterEncoding=utf8,或JSP页面没写UTF-8 | URL加上编码参数,JSP头部设置pageEncoding="utf-8" |
| 登录成功却跳回登录页 | Session作用域写错,比如存进了request | 登录成功后用session.setAttribute("adminName", username) |
| 404找不到页面 | 路径写错,或应用上下文不含/portal | 检查页面间跳转的路径是否带上下文,或直接用相对路径 |
| 时间差8小时 | 数据库时区设置问题 | URL加serverTimezone=Asia/Shanghai,或建表时指定datetime默认值 |
7.3 部署前顺手做掉的几处"面子工程"
演示项目最容易在最后一刻漏掉小细节:首页没有给空数据做提示、管理员后台删除操作没有二次确认、页面标题统一写成了默认的Untitled。我的经验是,在最终交付前留出半天专门"挑刺":
- 给所有空列表加一句"暂无数据"提示,否则满屏空白很尴尬。
- 删除操作加一个
onclick="return confirm('确定删除吗')",既保护数据也显得专业。 - 页面
<title>统一改成企业门户相关名称,浏览器标签页看起来整洁。 - 后台登录页加一个跳回首页的链接,演示操作时万一在后台迷失了,能一键出去。
这些小改动工作量不大,但对整体观感的提升非常明显。
8. 踩坑记录:我在调试这类项目时最典型的三个问题
最后这一段我不按章节套路来,就讲几个真实调试过程中印象最深的坑,给后面做类似项目的人一个提前预警。
8.1 新闻管理列表刷新后重复插入的真相
第一次发现这个bug是在演示前两小时。我在后台点"发布新闻",再点浏览器刷新,结果新闻列表里出现了两条一模一样的记录。排查时我先以为是sendRedirect写错了,后来发现地址栏确实已经跳到了news_manage.jsp,但刷新时浏览器重新提交了上一次表单请求。真正的问题出在发布成功用的不是sendRedirect,而是forward——地址栏停留在news_add_do.jsp,刷新自然重新执行了插入。
修复方案很简单:发布成功后一律用response.sendRedirect("news_manage.jsp")。这个坑的教训是:表单提交后的页面跳转,优先用重定向而不是请求转发。
8.2 本地正常、换台电脑就白屏的静态资源路径问题
项目在自己的电脑上一切正常,拷给别人就发现CSS样式全丢了,页面排版完全错乱。原因在于我在JSP页面里写了href="css/style.css",然后浏览器访问的URL是http://localhost:8080/portal/news_list.jsp,浏览器在相对路径下会去请求http://localhost:8080/css/style.css,而项目部署目录是/portal,所以CSS请求404了。
这段经历让我记住了JSP里引用静态资源时,要用带上下文的绝对路径。直接用JSP内置的pageContext.request.contextPath拼路径:
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/style.css" />这样不管项目部署在/portal、/company还是根路径下,静态资源都能正确加载。
8.3 JDBC连接一直超时的版本匹配问题
还有一次是在MySQL 8.0环境里怎么都连接不上数据库,控制台报错里带有Public Key Retrieval is not allowed。谷歌搜索后才知道MySQL 8.0默认的认证插件是caching_sha2_password,JDBC驱动版本如果太老或者URL缺少allowPublicKeyRetrieval=true参数,连接就会被拒绝。最终的连接URL是这样解决的:
jdbc:mysql://localhost:3306/portal_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8这提醒我一件事情:做这类教学型项目,技术栈虽然老,但依赖的官方驱动、数据库版本都在更新,千万别以为"老项目不用考虑版本问题"。
最后再说一句实在话:JSP+JavaBean这套组合,在真实的生产环境里确实已经不是主流选择,但它作为理解Java Web底层运转方式的训练项目,依然有不可替代的价值。把门户网站这个题目完整做下来,你收获的不仅是一个能演示的系统,更是一套"前端页面、后台逻辑、数据库操作"如何协作的思维框架。之后再去接触Spring MVC、MyBatis乃至Spring Boot,你会发现所有的概念都能在新框架里找到对应位置,学习曲线会平缓很多。