1. 从零拆解一个JSP电影页面:为什么老技术依然值得动手做一遍
做后端开发的人,早晚都会碰到JSP。不是因为它在技术选型里有多时髦,而是因为大量存量系统、教学项目、企业内部管理后台,至今还跑在JSP上。你打开招聘网站搜“Java开发”,十个岗位里有三四个会写“熟悉JSP/Servlet者优先”。这不是怀旧,这是现实。
我这次要聊的,是一个看起来极其普通的项目:用JSP做一个电影页面。听起来像是大学课程设计的题目,对吧?但恰恰是这种“简单”的东西,最能暴露一个开发者对Web基础的理解深度。页面怎么组织、数据怎么传递、请求怎么处理、样式怎么复用、分页怎么做、搜索怎么实现——这些问题的答案,跟你用React还是Vue没有本质区别,区别只在于JSP把很多底层细节暴露给了你。
这个电影页面项目适合谁看?如果你是刚学完Servlet、准备做第一个完整Web应用的学生,这篇内容可以当作你的实操手册;如果你是从PHP或ASP转过来的开发者,想快速理解JSP的运作方式,这里有你需要的对照说明;如果你是工作了几年的后端,突然被安排维护一个老系统,那这篇内容能帮你快速找回手感。
我打算把这个项目从头到尾拆一遍。不是给你一个压缩包让你自己看,而是把每个决策背后的“为什么”讲清楚。为什么用JSP而不是纯HTML?为什么数据要放在request域而不是session?为什么分页要用数据库limit而不是前端截取?这些选择看起来小,但每一个都影响你后面写代码的顺畅程度。
先说一下这个电影页面大概长什么样:首页有电影列表,每个电影显示海报、名称、评分、上映年份;点击某个电影能进详情页,看到简介、导演、演员、时长;顶部有搜索框,能按名称模糊查;底部有分页导航,每页显示固定数量。后台部分我们暂时不展开,重点放在前台展示和交互上。
技术栈方面,我选的是最经典的组合:JSP + Servlet + JDBC + MySQL。没有用Spring MVC,没有用MyBatis,不是因为这些不好,而是因为这个项目的目的是让你看清请求从浏览器发出到页面渲染的完整链路。框架帮你封装了太多东西,初学阶段反而容易迷糊。等你把这个链路走通了,再去用框架,你会知道框架到底帮你做了什么。
数据库设计我放在后面细说,但先给你一个概念:至少需要两张表,一张存电影基本信息,一张存电影的分类或标签。如果你还想做用户评论,那就再加一张评论表。表结构不用太复杂,但字段类型和索引要提前想好,不然后面改起来很烦。
提示:如果你本地还没装MySQL,建议用Docker跑一个,一条命令的事,比在系统里直接装省心得多。具体命令后面会给出。
页面样式我建议用Bootstrap 5,直接CDN引入,不用自己写太多CSS。电影海报用占位图或者公开的图片链接都行,重点是逻辑跑通。不要一上来就纠结UI好不好看,先把数据从数据库取出来、显示到页面上、点击能跳转、搜索能过滤,这些做到了,再回头调样式。
我见过太多人做这种项目,卡在环境配置上三天,然后放弃了。所以第一部分我会把环境准备和项目结构讲得细一点,让你少走弯路。后面再逐步深入核心逻辑、分页实现、搜索优化和常见问题排查。整个过程我会穿插我自己踩过的坑和实际调试的经验,这些是你在教材上不太容易看到的。
2. 项目整体设计与技术选型思路
2.1 为什么选择JSP而不是纯前端渲染
很多人第一反应是:都什么年代了,还JSP?我直接写个HTML,用AJAX从后端拿JSON,前端渲染不香吗?香,但你要看场景。这个项目的定位是“快速搭建一个能跑的电影展示页面”,JSP的优势在于服务端直接渲染,浏览器拿到的是完整HTML,不需要等JS加载完再发请求。对于内容展示型页面,这种模式首屏速度更快,SEO也更友好。
另一个原因是JSP天然和Servlet容器集成。你不需要单独部署一个前端服务,不需要处理跨域,不需要配置反向代理。一个Tomcat跑起来,JSP、Servlet、静态资源全在里面。对于小型项目或者教学演示,这种一体化部署省去了大量运维成本。
当然,JSP也有它的缺点。页面逻辑和Java代码容易混在一起,维护起来头疼。所以我在这个项目里会刻意控制:JSP只负责展示,所有业务逻辑放在Servlet里,数据库操作封装在DAO层。这样即使后面要换成Thymeleaf或者前后端分离,迁移成本也可控。
2.2 分层架构:Servlet + Service + DAO + JSP
我采用的是经典的四层结构,虽然对这个规模的项目来说有点“重”,但养成好习惯比省几行代码重要。
- Servlet层:接收请求,解析参数,调用Service,把结果放到request域,然后转发到JSP。它不直接碰数据库,也不写复杂的业务判断。
- Service层:处理业务逻辑,比如“搜索关键词为空时返回全部”、“分页页码越界时自动修正”。这一层是Servlet和DAO之间的缓冲。
- DAO层:封装所有JDBC操作,提供
findAll、findById、search、count等方法。SQL语句只出现在这里。 - JSP层:只做数据展示,用JSTL标签和EL表达式,尽量不写scriptlet(就是那种
<% %>里面写Java代码的写法)。
这样分层的好处是,当你需要改数据库从MySQL换成PostgreSQL时,只需要改DAO层的实现,Servlet和JSP完全不用动。当你需要给搜索加一个“按导演筛选”的功能时,只需要在Service层加逻辑,DAO层加一个方法,JSP加一个下拉框。
2.3 数据库表结构设计
电影页面核心就两张表,我先把建表语句给你,然后解释每个字段的用意。
CREATE TABLE movie ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, director VARCHAR(100), actors VARCHAR(500), release_year INT, rating DECIMAL(3,1), duration INT, poster_url VARCHAR(500), description TEXT, category_id INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL );rating用DECIMAL(3,1)而不是FLOAT,是因为评分这种数据不需要浮点数的精度,而且DECIMAL在排序和比较时更稳定。duration用分钟数存整数,展示时再格式化成“2小时15分”。poster_url存图片链接而不是图片本身,数据库只负责存路径,图片文件放服务器目录或者对象存储里。
category_id是外键,关联分类表。我没有在数据库层面加FOREIGN KEY约束,因为在高并发写入时外键检查会影响性能,而且很多互联网公司规范里也不建议用物理外键。逻辑上的关联由代码保证。
索引方面,title字段建议加一个普通索引,因为搜索功能会频繁用LIKE查询。但注意,LIKE '%关键词%'这种前置模糊匹配是用不上索引的,后面我会讲怎么优化。
2.4 项目目录结构
我习惯把项目组织成下面这样,你可以直接照着建:
movie-project/ ├── src/ │ └── main/ │ ├── java/ │ │ └── com/ │ │ └── movie/ │ │ ├── dao/ │ │ │ ├── MovieDao.java │ │ │ └── CategoryDao.java │ │ ├── service/ │ │ │ └── MovieService.java │ │ ├── servlet/ │ │ │ ├── MovieListServlet.java │ │ │ ├── MovieDetailServlet.java │ │ │ └── MovieSearchServlet.java │ │ ├── model/ │ │ │ ├── Movie.java │ │ │ └── Category.java │ │ └── util/ │ │ └── DBUtil.java │ └── webapp/ │ ├── WEB-INF/ │ │ └── web.xml │ ├── css/ │ ├── js/ │ ├── images/ │ ├── index.jsp │ ├── detail.jsp │ └── common/ │ ├── header.jsp │ └── footer.jspcommon目录放公共的头部和底部,用<%@ include file="..." %>引入。这样改导航栏只需要改一个文件。WEB-INF下面的web.xml是部署描述符,Servlet 3.0以后可以用注解代替,但有些老容器还是认web.xml,所以我两个都保留。
3. 核心细节解析与实操要点
3.1 数据库连接池:为什么不用DriverManager
新手最容易犯的错,就是在每个DAO方法里写DriverManager.getConnection()。这样每次请求都新建一个物理连接,用完关闭,开销极大。我实测过,用DriverManager的方式,100个并发请求下,响应时间从平均50ms飙升到800ms以上。
正确的做法是用连接池。Tomcat自带的DBCP或者更现代的HikariCP都可以。我选HikariCP,因为它性能好、配置简单。在context.xml里配好数据源,然后在DBUtil里通过JNDI查找。
<Resource name="jdbc/movieDB" auth="Container" type="javax.sql.DataSource" driverClassName="com.mysql.cj.jdbc.Driver" url="jdbc:mysql://localhost:3306/movie_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8" username="root" password="yourpassword" maxTotal="20" maxIdle="10" maxWaitMillis="10000"/>maxTotal="20"表示最多20个活跃连接。这个数字怎么定?一个经验公式是:连接数 = CPU核心数 * 2 + 磁盘数。对于个人项目,20足够了。maxWaitMillis="10000"表示拿不到连接时最多等10秒,超过就抛异常,避免请求无限阻塞。
DBUtil的代码大概长这样:
public class DBUtil { private static DataSource dataSource; static { try { Context ctx = new InitialContext(); dataSource = (DataSource) ctx.lookup("java:comp/env/jdbc/movieDB"); } catch (NamingException e) { throw new RuntimeException("数据源初始化失败", e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(AutoCloseable... resources) { for (AutoCloseable res : resources) { if (res != null) { try { res.close(); } catch (Exception ignored) {} } } } }注意:
close方法里用了可变参数和AutoCloseable,这样你可以一次性关闭ResultSet、PreparedStatement和Connection,不用写三层嵌套的try-catch。这是我从实际项目里总结出来的小技巧,能省不少代码。
3.2 电影列表查询:SQL怎么写才高效
列表页需要查电影的基本信息,同时关联分类名称。我一开始写的是子查询:
SELECT m.*, (SELECT name FROM category WHERE id = m.category_id) AS category_name FROM movie m ORDER BY m.created_at DESC LIMIT ?, ?后来改成LEFT JOIN,因为子查询在数据量大时会被执行多次,而JOIN只扫一次分类表:
SELECT m.*, c.name AS category_name FROM movie m LEFT JOIN category c ON m.category_id = c.id ORDER BY m.created_at DESC LIMIT ?, ?LIMIT ?, ?的两个参数分别是偏移量和每页条数。偏移量 = (当前页码 - 1) * 每页条数。比如第3页,每页12条,偏移量就是24。
这里有个坑:ORDER BY created_at DESC如果created_at没有索引,当电影数量到几万条时,排序会非常慢。所以我在created_at上加了索引。另外,如果两篇文章的created_at相同,排序结果可能不稳定,导致分页时出现重复或遗漏。解决办法是再加一个id DESC作为第二排序字段。
3.3 搜索功能的实现与优化
搜索框的交互逻辑是:用户输入关键词,点击搜索,页面显示匹配的电影。后端用LIKE查询:
SELECT m.*, c.name AS category_name FROM movie m LEFT JOIN category c ON m.category_id = c.id WHERE m.title LIKE CONCAT('%', ?, '%') ORDER BY m.rating DESC LIMIT ?, ?CONCAT('%', ?, '%')是MySQL的写法,用PreparedStatement传参能防止SQL注入。但前面说了,前置%会让索引失效。怎么优化?
方案一:如果搜索频率不高,数据量在几千条以内,全表扫描可以接受,不用优化。
方案二:用全文索引。MySQL 5.7以上支持FULLTEXT索引,但中文分词需要配置ngram解析器。配置命令:
ALTER TABLE movie ADD FULLTEXT INDEX ft_title (title) WITH PARSER ngram;然后查询改成:
SELECT * FROM movie WHERE MATCH(title) AGAINST(? IN BOOLEAN MODE);方案三:如果项目规模再大,应该引入Elasticsearch或者Solr。但那是另一个话题了,这个项目里用方案一就够了。
实操心得:搜索关键词要做trim处理,去掉首尾空格。如果用户输入的是空字符串,直接返回全部列表,不要拼
LIKE '%%',虽然结果一样,但语义不清晰。另外,关键词里的%和_要转义,否则用户输入%会匹配所有记录。转义方法是在LIKE后面加ESCAPE '\',然后把关键词里的%替换成\%。
3.4 分页逻辑的完整实现
分页需要两个数据:当前页的电影列表和总记录数。总记录数用来算总页数。
public class PageResult<T> { private List<T> list; private int totalCount; private int pageSize; private int currentPage; public int getTotalPages() { return (totalCount + pageSize - 1) / pageSize; } public boolean hasPrev() { return currentPage > 1; } public boolean hasNext() { return currentPage < getTotalPages(); } }(totalCount + pageSize - 1) / pageSize是向上取整的经典写法。比如总数25,每页12,结果是3页。
Servlet里这样处理:
int page = 1; String pageParam = request.getParameter("page"); if (pageParam != null && !pageParam.isEmpty()) { try { page = Integer.parseInt(pageParam); } catch (NumberFormatException e) { page = 1; } } if (page < 1) page = 1; int pageSize = 12; int totalCount = movieService.countAll(); int totalPages = (totalCount + pageSize - 1) / pageSize; if (page > totalPages && totalPages > 0) page = totalPages; List<Movie> movies = movieService.findByPage(page, pageSize); request.setAttribute("movies", movies); request.setAttribute("currentPage", page); request.setAttribute("totalPages", totalPages); request.getRequestDispatcher("/index.jsp").forward(request, response);注意if (page > totalPages && totalPages > 0) page = totalPages;这一行。如果用户手动在URL里把page改成999,不处理的话会查到空列表,页面显示空白。自动修正到最后一页,体验更好。
JSP里的分页导航:
<nav> <ul class="pagination"> <li class="page-item ${currentPage == 1 ? 'disabled' : ''}"> <a class="page-link" href="?page=${currentPage - 1}">上一页</a> </li> <c:forEach begin="1" end="${totalPages}" var="i"> <li class="page-item ${i == currentPage ? 'active' : ''}"> <a class="page-link" href="?page=${i}">${i}</a> </li> </c:forEach> <li class="page-item ${currentPage == totalPages ? 'disabled' : ''}"> <a class="page-link" href="?page=${currentPage + 1}">下一页</a> </li> </ul> </nav>如果总页数很多,比如100页,全部显示会撑爆页面。这时候需要做“省略号分页”,只显示当前页附近的几个页码。这个逻辑可以放在JSP里用c:choose判断,也可以在后端算好一个页码范围传过来。我倾向于后端算,因为JSP里写复杂逻辑容易乱。
4. 实操过程与核心环节实现
4.1 环境准备:从零到跑起来
先确保你本地有JDK 8或以上、Maven、MySQL。我用Docker起MySQL,命令如下:
docker run -d --name mysql-movie \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -e MYSQL_DATABASE=movie_db \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ciutf8mb4而不是utf8,因为utf8在MySQL里最多存3字节,存不了emoji和一些生僻字。utf8mb4才是真正的UTF-8。
然后建表、插入测试数据。测试数据我建议至少插20条,这样分页效果才明显。可以用存储过程批量插入,也可以直接写INSERT语句。我贴几条示例:
INSERT INTO category (name) VALUES ('动作'), ('喜剧'), ('科幻'), ('剧情'); INSERT INTO movie (title, director, actors, release_year, rating, duration, poster_url, description, category_id) VALUES ('示例电影一', '导演A', '演员甲,演员乙', 2023, 8.5, 120, 'https://example.com/poster1.jpg', '这是一部关于勇气与选择的剧情片。', 4), ('示例电影二', '导演B', '演员丙,演员丁', 2022, 7.8, 105, 'https://example.com/poster2.jpg', '一场跨越时空的科幻冒险。', 3), ('示例电影三', '导演C', '演员戊,演员己', 2024, 9.1, 135, 'https://example.com/poster3.jpg', '笑中带泪的喜剧故事。', 2);海报链接用公开的占位图服务,比如https://picsum.photos/300/450?random=1,这样每张图不一样,看起来真实。
Maven依赖只需要三个:javax.servlet-api、jstl、mysql-connector-java。scope记得设成provided,因为Tomcat自带Servlet API,打包时不需要带进去。
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>4.2 电影列表页的完整实现
MovieListServlet的doGet方法:
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); int page = parsePage(request.getParameter("page")); int pageSize = 12; MovieService service = new MovieService(); int totalCount = service.countAll(); int totalPages = (totalCount + pageSize - 1) / pageSize; if (totalPages > 0 && page > totalPages) page = totalPages; List<Movie> movies = service.findByPage(page, pageSize); List<Category> categories = service.findAllCategories(); request.setAttribute("movies", movies); request.setAttribute("categories", categories); request.setAttribute("currentPage", page); request.setAttribute("totalPages", totalPages); request.setAttribute("totalCount", totalCount); request.getRequestDispatcher("/index.jsp").forward(request, response); }index.jsp的核心部分:
<%@ 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" %> <!DOCTYPE html> <html> <head> <title>电影列表</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <%@ include file="common/header.jsp" %> <div class="container mt-4"> <div class="row"> <c:forEach items="${movies}" var="movie"> <div class="col-md-3 mb-4"> <div class="card h-100"> <img src="${movie.posterUrl}" class="card-img-top" alt="${movie.title}" style="height:300px;object-fit:cover;"> <div class="card-body"> <h5 class="card-title">${movie.title}</h5> <p class="card-text text-muted"> ${movie.releaseYear} · ${movie.director} </p> <span class="badge bg-warning text-dark"> <fmt:formatNumber value="${movie.rating}" pattern="#.#"/>分 </span> </div> <div class="card-footer"> <a href="detail?id=${movie.id}" class="btn btn-sm btn-primary">查看详情</a> </div> </div> </div> </c:forEach> </div> <c:if test="${empty movies}"> <div class="alert alert-info">没有找到符合条件的电影。</div> </c:if> <%@ include file="common/pagination.jsp" %> </div> <%@ include file="common/footer.jsp" %> </body> </html>fmt:formatNumber用来格式化评分,pattern="#.#"保证显示一位小数。object-fit:cover让海报图片保持比例填充,不会变形。
4.3 详情页的数据传递与展示
详情页通过URL参数id来查具体电影。MovieDetailServlet:
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String idParam = request.getParameter("id"); if (idParam == null || idParam.isEmpty()) { response.sendRedirect("list"); return; } try { int id = Integer.parseInt(idParam); MovieService service = new MovieService(); Movie movie = service.findById(id); if (movie == null) { response.sendError(HttpServletResponse.SC_NOT_FOUND, "电影不存在"); return; } request.setAttribute("movie", movie); request.getRequestDispatcher("/detail.jsp").forward(request, response); } catch (NumberFormatException e) { response.sendRedirect("list"); } }detail.jsp里展示演员列表时,actors字段存的是逗号分隔的字符串。可以用JSTL的fn:split拆成数组再遍历:
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> ... <c:forEach items="${fn:split(movie.actors, ',')}" var="actor"> <span class="badge bg-secondary">${actor}</span> </c:forEach>时长格式化:
<c:set var="hours" value="${movie.duration / 60}" /> <c:set var="minutes" value="${movie.duration % 60}" /> 时长:<fmt:formatNumber value="${hours}" pattern="#" />小时${minutes}分钟${movie.duration / 60}在EL里会自动做浮点除法,fmt:formatNumber的pattern="#"取整数部分。${movie.duration % 60}取余数。
4.4 搜索与分页的联动
搜索和分页要能同时工作。比如用户搜索“科幻”,然后点第2页,URL应该是search?keyword=科幻&page=2。分页链接里要带上关键词参数。
在JSP里拼分页链接时,需要判断当前是列表页还是搜索页。我通常传一个baseUrl到request域:
String baseUrl = "list?"; if (keyword != null && !keyword.isEmpty()) { baseUrl = "search?keyword=" + URLEncoder.encode(keyword, "UTF-8") + "&"; } request.setAttribute("baseUrl", baseUrl);然后分页组件里用${baseUrl}page=${i}。这样列表页和搜索页共用同一个分页组件,不用写两套。
注意:
URLEncoder.encode对中文关键词编码后,URL里会出现%E7%A7%91%E5%B9%BB这样的字符。这是正常的,浏览器会自动解码。但如果你在JSP里直接拼链接,记得用<c:url>标签,它会自动处理编码:
<c:url value="/search" var="searchUrl"> <c:param name="keyword" value="${keyword}" /> <c:param name="page" value="${i}" /> </c:url> <a href="${searchUrl}">${i}</a><c:url>比手动拼字符串安全得多,尤其是关键词里包含特殊字符时。
5. 常见问题与排查技巧实录
5.1 中文乱码:从请求到响应全链路排查
中文乱码是JSP项目里出现频率最高的问题。乱码可能出现在三个环节:请求参数、数据库存储、页面输出。
请求参数乱码:对于POST请求,在Servlet里第一行加request.setCharacterEncoding("UTF-8")。对于GET请求,Tomcat 8以后默认URI编码是UTF-8,一般没问题。但如果你的Tomcat版本较老,需要在server.xml的Connector标签里加URIEncoding="UTF-8"。
数据库乱码:建库建表时用utf8mb4,连接URL里加characterEncoding=utf8。注意,MySQL 8的驱动类名是com.mysql.cj.jdbc.Driver,不是老的com.mysql.jdbc.Driver。
页面输出乱码:JSP文件头部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>。HTML的<meta>标签里也声明charset=UTF-8。
排查顺序:先看浏览器控制台里响应头的Content-Type有没有charset=UTF-8,再看数据库里的数据是不是正常,最后看Java代码里有没有设置编码。三步定位,基本不会漏。
5.2 数据库连接泄漏:怎么发现和解决
连接泄漏的表现是:系统跑一段时间后,所有请求都卡住,日志里出现“等待连接超时”。原因是某个DAO方法拿了连接但没关闭。
排查方法:在DBUtil.close()里加日志,记录每次关闭的连接。如果发现连接数只增不减,说明有地方漏了。更彻底的办法是用连接池的监控功能,HikariCP可以配置leakDetectionThreshold=60000,表示连接超过60秒没关闭就打印警告日志。
预防措施:所有数据库操作都用try-with-resources写法:
try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { // 处理结果 } catch (SQLException e) { throw new RuntimeException("查询失败", e); }try-with-resources会自动关闭资源,不用手动写finally块。这是Java 7以后的标准写法,比老式的finally里嵌套关闭清爽得多。
5.3 分页页码越界与空结果处理
用户手动改URL参数是常有的事。page=0、page=-1、page=abc、page=99999,这些都要处理。
我的处理策略是:
| 输入情况 | 处理方式 |
|---|---|
| 非数字 | 默认第1页 |
| 小于1 | 修正为第1页 |
| 大于总页数 | 修正为最后一页 |
| 总记录数为0 | 显示空状态提示,不显示分页 |
空状态提示不要只写“暂无数据”,最好给一个引导,比如“没有找到相关电影,试试其他关键词”。用户体验会好很多。
5.4 JSP页面报错:常见异常速查
| 异常信息 | 可能原因 | 解决方法 |
|---|---|---|
java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver | 驱动包没引入 | 检查Maven依赖或WEB-INF/lib |
javax.naming.NameNotFoundException: jdbc/movieDB | JNDI名称不对 | 检查context.xml和lookup名称是否一致 |
org.apache.jasper.JasperException: /index.jsp (line: X) | JSP语法错误 | 看行号定位,常见是标签未闭合 |
java.sql.SQLException: Access denied | 数据库账号密码错 | 检查连接URL里的用户名密码 |
NumberFormatException | 参数转数字失败 | 用try-catch包裹,给默认值 |
实操心得:JSP报错时,Tomcat的日志在
logs/localhost.YYYY-MM-DD.log里,比浏览器上显示的堆栈更详细。养成看日志的习惯,能省很多猜测时间。
5.5 性能优化:从页面加载3秒到300毫秒
我最初版本没加连接池、没加索引、每次查询都SELECT *,页面加载要3秒多。后来做了几件事:
- 加连接池:从DriverManager换成HikariCP,响应时间降到1.2秒。
- 加索引:在
created_at和category_id上加索引,降到800毫秒。 - 只查需要的字段:列表页不需要
description,SQL里去掉这个字段,降到500毫秒。 - 分页查询优化:用
LIMIT而不是查全部再截取,降到300毫秒。 - 静态资源缓存:给CSS和JS加
Cache-Control头,二次加载更快。
每一步的优化效果我都实测过,不是拍脑袋。你可以用Chrome的Network面板看每个请求的耗时,定位瓶颈在哪。
5.6 安全方面:SQL注入与XSS防护
PreparedStatement能防SQL注入,这个必须用。但XSS(跨站脚本)容易被忽略。如果电影标题里包含<script>alert(1)</script>,直接输出到JSP里就会执行。
防护方法是用<c:out>标签输出:
<c:out value="${movie.title}" /><c:out>默认会转义HTML特殊字符。或者用fn:escapeXml函数:
${fn:escapeXml(movie.title)}对于富文本内容(比如电影简介里可能有换行),不能直接转义,需要先做HTML过滤,只允许安全的标签。这个项目里简介是纯文本,用<c:out>就够了。
6. 页面样式与交互细节打磨
6.1 响应式布局:从手机到桌面都能看
Bootstrap的栅格系统用col-md-3表示中等屏幕以上每行4个,小屏幕自动变成每行1个或2个。我实测下来,col-6 col-md-4 col-lg-3的组合比较合适:手机每行2个,平板每行3个,桌面每行4个。
海报图片用aspect-ratio固定比例,避免不同尺寸的图片把卡片撑得高低不一:
.card-img-top { aspect-ratio: 2 / 3; object-fit: cover; }aspect-ratio: 2 / 3是电影海报的常见比例。object-fit: cover保证图片填满区域且不变形。
6.2 加载状态与空状态处理
数据从数据库查出来需要时间,虽然通常很快,但网络慢的时候用户会看到白屏。加一个简单的加载提示:
<div id="loading" class="text-center py-5"> <div class="spinner-border text-primary" role="status"> <span class="visually-hidden">加载中...</span> </div> </div>页面加载完成后用JS隐藏。不过JSP是服务端渲染,HTML返回时数据已经在了,这个加载提示主要是给AJAX请求用的。如果搜索功能用AJAX实现,就需要这个。
空状态我放了一个图标加文字提示,比单纯一行字好看:
<c:if test="${empty movies}"> <div class="text-center py-5 text-muted"> <p class="fs-4">没有找到相关电影</p> <p>试试其他关键词,或者<a href="list">查看全部电影</a></p> </div> </c:if>6.3 搜索框的交互优化
搜索框加一个清除按钮,用户点一下就能清空内容。用一点CSS和JS就能实现:
<div class="input-group"> <input type="text" id="keyword" name="keyword" class="form-control" placeholder="搜索电影名称..." value="${fn:escapeXml(keyword)}"> <button class="btn btn-primary" type="submit">搜索</button> </div>value="${fn:escapeXml(keyword)}"让搜索后关键词保留在输入框里,用户不用重新输入。fn:escapeXml防止关键词里的特殊字符破坏HTML结构。
小技巧:搜索框加
autocomplete="off"可以关闭浏览器的自动补全下拉,避免遮挡搜索结果。但如果你希望用户能快速选择历史搜索词,可以保留autocomplete="on",看具体需求。
7. 项目扩展方向与个人经验总结
这个电影页面做完基础功能后,还有很多可以扩展的地方。比如加用户评论功能,需要新增评论表,在详情页加评论列表和提交表单。评论提交用POST请求,处理完重定向回详情页,避免刷新重复提交。
再比如加评分功能,用户点星星打分,用AJAX提交到后端,更新电影的平均分。这需要在前端用JS监听点击事件,后端提供RESTful接口返回JSON。
还可以加“猜你喜欢”推荐,根据用户浏览过的电影分类,推荐同分类的其他电影。简单实现就是查同category_id的电影,按评分排序,取前4个。
我在实际做这个项目的过程中,最大的体会是:不要一开始就追求完美。先把数据从数据库取出来显示到页面上,这一步跑通了,再加分页,再加搜索,再加样式。每一步都验证一下,出问题容易定位。如果一次性写几百行代码再运行,报错了你都不知道从哪查起。
另一个体会是:日志和异常处理要趁早加。我一开始图省事,DAO层的方法直接throws SQLException,Servlet层也不捕获,结果页面报500错误,什么信息都没有。后来在Servlet的doGet里加了try-catch,把异常信息打到日志里,同时在页面上显示友好的错误提示,排查效率高了很多。
最后分享一个调试JSP的小技巧:在JSP里临时加<% out.println("调试信息:" + someVariable); %>,可以直接在页面上看到变量的值。虽然不优雅,但比断点调试快。调试完记得删掉,不然上线就尴尬了。
这个项目虽然简单,但把Web开发的核心链路都串了一遍:请求接收、参数解析、业务处理、数据库操作、页面渲染。你把这套流程走通了,再去学Spring Boot、MyBatis,会发现它们只是把每一步封装得更优雅,底层逻辑是一样的。基础打牢了,学什么都快。