news 2026/10/4 6:07:22

基于JavaWeb的音乐网站开发实战:Servlet、JSP与MySQL完整案例

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于JavaWeb的音乐网站开发实战:Servlet、JSP与MySQL完整案例

简介:一套基于Java Web的小型音乐网站源码包,面向Java Web初学者、做课程设计或毕业设计的学生,也适合需要快速搭建音乐播放类项目进行二次开发的程序员。项目采用Eclipse、Tomcat、MySQL与JDK作为开发环境,war包可直接放入Tomcat运行测试,源码内含完整代码与全部jar包,SQL脚本也能帮助初始化数据库。包内共881个文件,压缩后约189.75MB,素材相当丰富:80个JSP页面和60个Java源文件构成主要业务逻辑,44个jar包解决依赖问题,大量png、jpg、gif图片用于界面展示,23个MP3文件可直接用于在线播放测试,整体结构完整,目录清晰,便于定位与修改。项目已实现音乐在线播放、下载、分类和排行榜等功能,涵盖音乐分类、排行展示、在线播放与下载等常见场景,适合理解JavaWeb分层架构、数据库操作以及文件流处理。目前已有915人学习下载,适合作为项目模板或课程设计蓝本,也方便在此基础上扩展新功能。

1. 从零跑通一个 JAVAWEB 音乐网站:它解决什么问题,适合谁

如果你正卡在课程设计或毕业设计上,“基于 JAVAWEB 的小型音乐网站”这个题目应该不陌生。它不像商城那样卷商品和订单,也不像博客那样只有普通增删改查,真正的难点在于文件上传、播放器接入、Session 状态管理这几块,恰好能把 Servlet、JSP、AJAX、MySQL 这些 JAVAWEB 核心知识点串成一条线。这个方案适合两类人:一是想拿完整案例当简历项目的新手,二是带学生做课设的指导老师。我按自己的开发顺序讲:选型、建表、写登录、做列表、加播放,最后给你一份避坑清单。不只讲“能跑通”,还给到可以交给别人部署验收的程度。

2. 技术选型与目录设计:基于 JAVAWEB 的音乐网站该选哪些组件

2.1 JSP + Servlet + MySQL:为什么这是 JavaWeb 项目最经典、最容易讲清楚的组合

做基于 JAVAWEB 的小型音乐网站,选择传统 Servlet + JSP + MySQL 不是老顽固,而是这个题目的标准答案。Spring Boot 当然也能做,但启动类一亮相,依赖注入、内嵌 Tomcat、自动配置这些遮住了太多中间环节;面试时被问“请求从浏览器到数据库到底经过哪些层”,反而是 Servlet 时代的东西更容易讲明白。小型音乐网站核心数据量就几千首歌、几百个用户,单台 Tomcat 完全扛得住,没有必要为一个 B/S 练习项目引入重量级框架。JSP 负责页面响应,Servlet 负责接收请求和调度,MySQL 存歌曲和用户信息,配合 Bootstrap 和 jQuery 做前端交互,够用且边界清晰。

小型音乐网站不需要把业务设计得特别复杂,五张表足够支撑前端大部分功能:用户表、歌曲表、歌单表、歌曲-歌单关联表、评论表。这种规模适合用整表 SQL 建库,不搞 Flyway。我一般把建表脚本放在项目的 sql 目录下,命名 init.sql,方便别人用 Navicat 一键执行。需要提醒的是,MySQL 建库时要指定 utf8mb4,否则中文评论和中文歌名很容易乱码,尤其是 emoji 表情符号,utf8 无法存。

2.2 在 IDEA 里搭建工程:Maven 依赖、目录结构和 Tomcat 配置

IDEA 里创建项目时,如果使用 Maven Archetype 选 maven-archetype-webapp,会自动生成 webapp 目录。这里要注意:生成的目录里没有 java 目录,需要手动在 src/main 下建 java 目录并右键 Mark Directory as Sources Root;resources 目录也要标记为 Resources Root。很多 IDEA 运行 javaweb 项目配置教程都跳过了这步,导致后面写 Servlet 时包名建不了。目录结构我习惯长这样。

music-web/ ├── pom.xml └── src/main/ ├── java/ │ └── com/example/music/ │ ├── controller/ # Servlet 控制器 │ ├── dao/ # JDBC 数据访问 │ ├── model/ # 实体类 │ ├── filter/ # 编码/login 过滤器 │ └── util/ # DB工具、加密工具 ├── resources/ │ ├── db.properties │ └── log4j.properties └── webapp/ ├── static/ │ ├── css/ │ ├── js/ │ └── music/ # 上传的 mp3 文件 ├── WEB-INF/ │ ├── web.xml │ └── views/ # JSP 页面 └── index.jsp

为什么要把 JSP 放在 WEB-INF/views 下而不是直接放 webapp 根目录?放在 WEB-INF 里浏览器不能直接通过 URL 访问,所有页面跳转都必须经过 Servlet 再 forward 进去。这样能保证登录校验 Filter 先执行,别人不能绕过登录直接拼 URL 看页面。这个习惯在做登录类网站时很有价值。

只用 Servlet 和 JSP,如果不引入 Maven,手写 lib 太痛苦;现在大部分人的工作环境都用 IDEA,Maven 是标配。创建一个 maven webapp 项目,pom.xml 里只需要四件事:servlet-api、jsp-api、jstl、mysql-connector-java。注意 servlet-api 和 jsp-api 由 Tomcat 提供,打包时要用 provided 作用域,不能打进 war,否则和容器自带的类冲突;mysql 驱动要选和你本机版本匹配的 jar,5.x 和 8.x 的驱动类名不一样,连接 URL 也不一样。完整的 pom 如下。

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>music-web</artifactId> <packaging>war</packaging> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</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> </project>

这段代码里,servlet-api 用 provided 是比较多人忽略的细节。如果用了默认 compile 作用域,Tomcat 启动时可能出现类冲突,最明显的表现是启动期间 NoSuchMethodError。jstl 用 1.2 就够了,注意 JSP 页面里要写 taglib 指令,否则<c:forEach>无法解析。mysql-connector-java 8.x 会要求加载 com.mysql.cj.jdbc.Driver,而不像 5.x 用 com.mysql.jdbc.Driver。

2.3 数据库表设计:用户、歌曲、歌单和评论的最小模型

小型音乐网站不需要把表拆得太散,五张表足够支撑前端大部分功能:用户表、歌曲表、歌单表、歌曲-歌单关联表、评论表。这种规模适合用整表 SQL 建库,不搞 Flyway。我一般把建表脚本放在项目的 sql 目录下,命名 init.sql,方便别人用 Navicat 一键执行。需要提醒的是,MySQL 建库时要指定 utf8mb4,否则中文评论和中文歌名很容易乱码,尤其是 emoji 表情符号,utf8 无法存。

CREATE DATABASE IF NOT EXISTS music_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE music_db; CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(64) NOT NULL COMMENT 'SHA-256后密文', `salt` varchar(32) NOT NULL COMMENT '加密盐', `avatar` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `song` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `singer` varchar(100) NOT NULL, `album` varchar(100) DEFAULT NULL, `type` varchar(30) DEFAULT 'pop' COMMENT 'pop/rock/classical...', `duration` int(11) DEFAULT NULL COMMENT '时长, 单位秒', `file_path` varchar(255) NOT NULL COMMENT 'mp3相对路径', `play_count` int(11) NOT NULL DEFAULT 0, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `playlist` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `name` varchar(100) NOT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `playlist_song` ( `playlist_id` int(11) NOT NULL, `song_id` int(11) NOT NULL, `add_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`playlist_id`,`song_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `comment` ( `id` int(11) NOT NULL AUTO_INCREMENT, `song_id` int(11) NOT NULL, `user_id` int(11) NOT NULL, `content` varchar(500) NOT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_song` (`song_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这几张表的关键在于 password 和 salt 分开存,绝对不要出现明文密码;play_count 用 int 而不是 varchar,方便排序;comment.content 用 varchar(500),虽然页面上可以多行输入,但数据库层限制长度能防止一条评论把整页撑破。还有一个小细节:song.file_path 存的是相对路径,比如 /static/music/song_20240101_001.mp3,不要存绝对路径。绝对路径一旦项目迁移到服务器,路径全乱;相对路径配合 getServletContext().getRealPath() 去解析,到了哪个环境都是对的。这一点在 javaweb 项目完整案例 mysql 里经常被当成亮点写,实际是个最基本的选型。

为什么歌单关联表要用主键 (playlist_id, song_id)?这样同一首歌不能重复添加进同一歌单,省了应用层再去判断重复;后续要扩展收藏数、播放量也不需要动这张表的结构。字段和数据不要完全照抄别人笔记里的建表语句,网上流传的 JavaWeb 讲义里,有的案例会把字段写成 user_name,而代码里用的是 username,结果跑起来一直报 Column not found。你自己定义字段时,保证数据库列名和实体类 getter 对应即可。

3. 从登录到播放:核心功能的实现顺序与关键代码

3.1 用 Servlet 写登录注册:JDBC、Session 与密码加密

先讲注册。常见做法是做一个 UserDao,里面封装数据库操作,Servlet 里只做参数校验和流程控制。密码处理上,我不推荐用 MD5,虽然旧教程到处都是 MD5,但在简历里写 MD5 容易被追问“为什么不用加盐”;直接用 SHA-256 加盐,代码不多,安全上比裸 MD5 好一截。写一个 PasswordUtil,注册时生成随机盐,登录时拿盐再算一次摘要比对。

package com.example.music.util; import java.security.MessageDigest; import java.security.SecureRandom; public class PasswordUtil { private static final char[] HEX = "0123456789abcdef".toCharArray(); // 生成 16 字节随机盐,转成 hex 字符串 public static String generateSalt() { byte[] bytes = new byte[16]; new SecureRandom().nextBytes(bytes); return toHex(bytes); } // SHA-256(salt + rawPassword) 两次摘要 public static String hash(String rawPassword, String salt) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); String input = salt + rawPassword; byte[] digest = md.digest(input.getBytes("UTF-8")); digest = md.digest(digest); // 再来一次,增加彩虹表成本 return toHex(digest); } catch (Exception e) { throw new RuntimeException("Password hash failed", e); } } private static String toHex(byte[] bytes) { StringBuilder sb = new StringBuilder(bytes.length * 2); for (byte b : bytes) { sb.append(HEX[(b >> 4) & 0x0F]).append(HEX[b & 0x0F]); } return sb.toString(); } }

这段代码把盐写到 16 字节 128 位,安全强度对课设和简历项目足够。hash() 里做两次 SHA-256,是为了防止有人拿简单的彩虹表直接撞;如果你愿意,可以用 BCrypt 依赖替代,但那样要去了解 Spring Security 全家桶,对基于 JAVAWEB 的小型项目来说,这个自研工具类更直接。

然后是 LoginServlet。登录接口我已经习惯用 JSON 返回,前端 jQuery 发起 ajax 请求,而不是表单刷新页面。因为音乐网站首页可能有播放器正在放歌,整页刷新会把正在播的音频断掉;局部登录能保留播放状态。Servlet 代码如下:

package com.example.music.controller; import com.example.music.dao.UserDao; import com.example.music.model.User; import com.example.music.util.PasswordUtil; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("application/json;charset=utf-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); UserDao dao = new UserDao(); User user = dao.findByUsername(username); if (user == null || !user.getPassword().equals(PasswordUtil.hash(password, user.getSalt()))) { resp.getWriter().write("{\"success\":false,\"msg\":\"用户名或密码错误\"}"); return; } HttpSession session = req.getSession(); session.setAttribute("userId", user.getId()); session.setAttribute("nickname", user.getUsername()); resp.getWriter().write("{\"success\":true,\"msg\":\"登录成功\"}"); } }

注意这里我不建议把整个 User 对象塞进 Session,只放 userId 和 nickname 就够。因为你可能之后在页面展示头像、邮箱等字段,如果塞了整个对象,用户修改资料后旧 Session 里的数据还是旧的,容易出现“改了头像页面还是老样子”的怪问题。每次请求按 userId 从数据库查最新数据才是通用做法。硬要说效率,几千用户的系统里这样做没有任何压力。

3.2 歌曲列表与搜索:JDBC 查询、分页和模糊匹配

歌曲列表是音乐网站的门面,不能一页全倒出来。常见做法是封装 PageBean,Dao 层用 LIMIT 做分页。参数有 pageNum、pageSize、keyword。下面这段是 SongDao 的分页查询代码。

package com.example.music.dao; import com.example.music.model.Song; import com.example.music.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class SongDao { public List<Song> search(String keyword, int pageNum, int pageSize) { List<Song> list = new ArrayList<>(); String baseSql = "SELECT id,name,singer,album,type,duration,play_count FROM song "; if (keyword != null && !keyword.trim().isEmpty()) { baseSql += "WHERE name LIKE ? OR singer LIKE ? "; } baseSql += "ORDER BY play_count DESC, id DESC LIMIT ? OFFSET ?"; int offset = (pageNum - 1) * pageSize; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(baseSql)) { int idx = 1; if (keyword != null && !keyword.trim().isEmpty()) { String like = "%" + keyword.trim() + "%"; ps.setString(idx++, like); ps.setString(idx++, like); } ps.setInt(idx++, pageSize); ps.setInt(idx, offset); ResultSet rs = ps.executeQuery(); while (rs.next()) { Song s = new Song(); s.setId(rs.getInt("id")); s.setName(rs.getString("name")); s.setSinger(rs.getString("singer")); s.setDuration(rs.getInt("duration")); s.setPlayCount(rs.getInt("play_count")); list.add(s); } } catch (SQLException e) { throw new RuntimeException("查询歌曲失败", e); } return list; } }

分页为什么用 OFFSET 而不是 LIMIT (offset, pageSize)?两种写法都能用,但 OFFSET 在 MySQL 8 里更清晰,且 PreparedStatement 占位符更直观。这里最容易被忽略的是 LIKE 里的 % 必须拼在占位符参数里,而不是拼进 SQL 模板。如果写成 WHERE name LIKE '%?%',PreparedStatement 会把问号当普通字符处理,查出来永远是空。另外一个常见坑是 ORDER BY play_count DESC 会让播放量为 0 的新歌沉底,演示时如果刚插入歌曲看不到,会以为功能坏了。我一般会先给测试数据补一些 play_count 随机值,比如 UPDATE song SET play_count = FLOOR(RAND()*1000),让列表看起来有真实感。

对应 Servlet 端只需要读取 pageNum、pageSize、keyword 参数,在请求里做默认值。pageNum 小于 1 的时候要强制赋 1,pageSize 超过 50 要截断,否则用户手动改 URL 参数可以把整张表一次拉出来。这些边界很影响简历里的代码质量印象。

3.3 在线播放与歌单管理:前端 audio 标签和后端接口怎么配合

列表页面用<audio>标签在线播放是前端的事,但“播了一次要计数”必须走后端。常见做法是播放器 onplay 事件发生时,调一个 /song/play?id=xxx 的接口把 play_count+1。这里要控制频率,否则用户拖动进度条会触发多次 onplay,计数会虚高。我用一个很简单的节流:记录 lastPlay 时间,5 秒内重复调用直接忽略。

let lastReportTime = 0; function reportPlay(songId) { const now = Date.now(); if (now - lastReportTime < 5000) return; lastReportTime = now; $.ajax({ url: '/song/play', method: 'POST', data: { id: songId }, success: function (res) { if (!res.success) console.error('上报失败'); } }); } // audio 的 onplay 绑定示例 audio.addEventListener('play', function () { reportPlay(currentSongId); });

这个接口对应的 Servlet 不需要返回页面,只返回 JSON,所以体积很小:先 UPDATE song SET play_count = play_count + 1 WHERE id=?,再查一次最新播放量返回给页面,用于更新列表中的播放次数。还可以顺手把这个 User-Song 的播放记录写到一张 user_song 表用于之后的推荐,这里先不展开。

歌单管理更简单:把歌加入歌单的 URL 是 /playlist/addSong,传 playlistId 和 songId。因为主键是两列,Dao 里 insert 时用 INSERT IGNORE 就可以避免重复主键报错:

String sql = "INSERT IGNORE INTO playlist_song (playlist_id, song_id) VALUES (?, ?)";

前端就一个按钮,点击后直接调这个接口,弹出“已加入歌单”。不需要事务,因为失败了 INSERT IGNORE 也不报错。真正需要事务的是“创建歌单并在同一个操作里把歌曲加进去”,那种场景我会在 Service 层用 Connection 的 setAutoCommit(false) 包一层。

后端还有个绕不开的接口:音频文件的输出。如果 mp3 文件就放在 webapp/static/music 目录下,浏览器可以直接访问 URL,不需要 Servlet 转发;但如果想控制权限,比如只有登录用户能听,需要写一个 SongStreamServlet 把文件以流的方式输出。常见实现是拿 song.file_path 拼上项目路径,用 FileInputStream 写入 response.getOutputStream()。要注意设置 Content-Type 为 audio/mpeg,并支持 Range 头,否则播放器拖动进度条时会重新从头开始。Range 头的处理有点繁琐,如果只是演示,可以不写;写的话要解析 req.getHeader("Range") 里的 start 位置,再跳流。

4. JavaWeb 项目避坑指南:部署、乱码、路径和并发问题排查

4.1 IDEA 运行 JavaWeb 项目的三个配置误区

现象一:点了 Run 之后 Tomcat 启动了,浏览器却 404,项目页面打不开。原因多半是 Run Configuration 里的 Deployment 没有添加 Artifact,或者 Deploy at server startup 没有勾选。IDEA 运行 JavaWeb 项目配置和普通 Java 程序不一样,它先把 war exploded 丢到 Tomcat 的部署目录,再启动容器。点击右上角 Edit Configurations,在 Deployment 里加 music-web:war exploded,并把 Application context 改为 /music。这样访问路径才是 http://localhost:8080/music/。

现象二:修改 JSP 或 Java 代码后刷新页面还是旧内容。原因是 Tomcat 默认不会自动编译改动后的 Java 类,JSP 改了也可能不生效。解决:在配置里勾选 On frame deactivation: Update classes and resources;如果改了 Java 文件,直接按 Ctrl+F10 重新编译;还不行就 restart。这个坑会浪费大量时间,尤其是你刚把 IDEA 运行 JavaWeb 项目配置照着视频敲完,以为项目坏了。

现象三:控制台打印一堆问号和乱码。原因有三层:Tomcat 的 logging.properties 编码、IDEA 的 Console encoding、项目页面编码。我在 4.2 详细讲,这里先说最快的检查:Help -> Edit Custom VM Options 里加 -Dfile.encoding=UTF-8,然后重启 IDEA。这个操作能解决大部分 Windows 上的乱码。注意这三个现象经常连着发生,建议按照上面的顺序排查。

4.2 数据库中文乱码的排查顺序

现象:页面显示中文正常,插入数据库后变成问号;或者页面本身全是乱码。原因往往是字符集在“浏览器 → JSP → Servlet → JDBC → MySQL”的某一环没统一。排查顺序我习惯从后往前:先看数据库表字符集,执行 SHOW CREATE TABLE song,确认 CHARSET 是 utf8mb4,不是 latin1;再看连接地址 jdbc:mysql://localhost:3306/music_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,useUnicode 和 characterEncoding 缺一不可,MySQL 8 还要带 serverTimezone。然后是 JSP 页面第一行要写 <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,并且在 web.xml 里配置 CharacterEncodingFilter。

为什么不用 request.setCharacterEncoding 逐段写?因为一个 Servlet 只能管自己,Filter 能管全局。下面是最小编码过滤器。

package com.example.music.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; @WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } }

这个过滤器必须注册在所有的 Servlet 之前,WebFilter 默认顺序由类名决定,如果有多个过滤器,建议在 web.xml 里显式声明并配置顺序。注意 response.setContentType 放在这里会让所有接口都变成 text/html,如果后面有 JSON 接口,Servlet 自己再覆盖一次 Content-Type 就行,问题不大。

如果在 Filter 里设置了编码,数据库端还是乱码,下一步直接看 MySQL 服务端变量 character_set_server 是不是 latin1,是的话需要改 my.ini 并重启 MySQL。最后还有一种可能,你连接 URL 里只写了 characterEncoding=utf8,没允许 utf8mb4,那么 emoji 和特殊符号依然会丢。网上流传很广的 JavaWeb 讲义里的数据表和字段也各不相同,如果按别人笔记里的数据导入后查询乱码,不要急着改代码,先检查导入时的字符集是不是 utf8mb4,很多时候是 SQL 文件编码的问题。

4.3 路径永远不对:URL 和文件上传的坑

现象:页面加载了但 CSS、JS、图片全部没有样式,控制台显示 Failed to load resource: 404;或者表单提交后跳转到一个不存在的页面。原因:在 JSP 里用了相对路径 src="css/style.css",当前浏览器 URL 是 /music/list,它会把 CSS 解析成 /music/css/style.css,实际资源在 /music/static/css/style.css,自然找不到。

解决方法是所有静态资源和链接前缀都写 ${pageContext.request.contextPath},也就是项目的绝对上下文路径。比如 href="${ctx}/static/css/style.css" rel="stylesheet"/> 。我一般会在 JSP 顶部用 <c:set var="ctx" value="${pageContext.request.contextPath}" />,后面写 ${ctx}。对前端 ajax 也一样,url 写成 ctx + "/song/play",不要写 /song/play,因为将来如果部署到 Tomcat 的 webapps 下,项目名可能不是 /music,写死就废了。

文件上传的路径坑更隐蔽。上传 mp3 到 webapp/static/music 下,开发环境没问题,因为 IDEA 会把你上传的文件写进 target 的部署目录;但每次 Redeploy 的时候,Tomcat 会把项目目录删掉重建,你上传的文件全没了。所以生产环境不应该把上传文件放 webapp 里,应该放到 Tomcat 之外的独立目录,比如 /data/music_files,然后通过 Tomcat 的虚拟路径映射或者写一个文件流 Servlet 对外提供访问。我这边的做法是:本地开发放到 target 里图省事,服务器上用 Nginx 映射 /static/music 到 /data/music_files。这算是一条血泪经验。

4.4 本地好好的一上服务器就翻车:Session 和并发连接排查

现象:本地用 IDEA 跑一切正常,部署到云服务器后,用户登录没几分钟就掉线,或者某个页面转圈很久才打开。原因分两块:Session 超时和数据库连接。Tomcat 默认 session 超时是 30 分钟,很多人没改;如果用户停留在播放页面超过 30 分钟然后点收藏,请求里的 JSESSIONID 已经失效,服务器开了新会话,页面跳回登录页。解决:在 web.xml 里设置 120 ,并且前端要捕获 401 状态跳转登录。但更关键的是,Session 只存在单个 Tomcat 内存里,如果服务器上面挂了多实例,Session 会随机丢失。小型网站不必做负载均衡,真要做就别依赖 Servlet Session,改成 token 方案。

另外数据库连接这块,很多人写 DBUtil 每次 getConnection 都用 DriverManager.getConnection,又在 finally 里 close,看起来没毛病,但并发一上来 MySQL 连接数会瞬间耗尽。常见做法是配置一个简单的连接池。没有 Spring 的情况下可以用 DBCP 或 HikariCP,HikariCP 需要引入依赖并且初始化 DataSource,代码量不大。我在项目里最常见到的问题是“忘记关闭 PreparedStatement”或“ResultSet 在返回前被关闭”,导致偶发 No operations allowed after connection closed。解决是确保 ResultSet 先关了再 close 上层;用 try-with-resources 能省掉这些。

5. 把项目从“能跑”做成“完整案例”:过滤链、推荐和管理后台

5.1 用 Filter 统一处理编码和登录校验

上一章给的 EncodingFilter 已经解决了全局编码。但完整案例还需要一个 LoginFilter:除了 /login、/register、/static、/index 这些允许匿名访问的路径,其余请求都要检查 Session 里有没有 userId。没有就返回登录页或者 JSON 提示。Servlet 规范里 Filter 匹配 /* 会把静态资源也拦下来,要在 doFilter 里排除关键字。

package com.example.music.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI(); if (uri.endsWith("/login") || uri.endsWith("/register") || uri.contains("/static/") || uri.endsWith("index.jsp")) { chain.doFilter(request, response); return; } HttpSession session = req.getSession(false); if (session == null || session.getAttribute("userId") == null) { if ("XMLHttpRequest".equals(req.getHeader("X-Requested-With"))) { resp.setContentType("application/json;charset=utf-8"); resp.getWriter().write("{\"success\":false,\"code\":401,\"msg\":\"未登录\"}"); } else { resp.sendRedirect(req.getContextPath() + "/index.jsp"); } return; } chain.doFilter(request, response); } }

这个 Filter 最关键的一个写法是 req.getSession(false)。如果用 req.getSession(),它会给每个未登录请求都创建一个空 Session,浪费内存的同时也掩盖了 401 判断。排除条件里 uri.contains("/static/") 放行,是因为图片、CSS、JS 不应该被登录拦截;如果你把歌单页面放在 /playlist 下,那它就必须登录。

两个 Filter 都要在 web.xml 里注册时,注意顺序:EncodingFilter 在前,LoginFilter 在后。顺序反了,LoginFilter 返回的 JSON 没有设置编码,中文会变成乱码。在 @WebFilter 注解里无法指定顺序,所以我建议统一在 web.xml 中配置,这样更直观。

5.2 做一个小推荐模块:基于播放次数和标签的“猜你喜欢”

很多简历项目都会写“推荐算法”,但真正能讲清楚的人不多。小型音乐网站最合适的推荐不是协同过滤,而是规则加权:找同类型里播放次数高、但你还没听过的歌。这是最简单、也最能在面试时讲明白的版本。SQL 大概是:

SELECT id, name, singer, type, play_count FROM song WHERE type = (SELECT type FROM song WHERE id = #{songId}) AND id != #{songId} ORDER BY play_count DESC LIMIT 6;

细看你就会发现,这个 SQL 不需要算向量,也不涉及矩阵,只是利用了“相同标签的用户可能喜欢相似音乐”这一假设。如果同类型的歌不足 6 首,就补全站热门歌曲,用两个查询组装。这个推荐模块的定位可以叫“给用户推荐同风格热门歌曲”,而不是写一个完整的协同过滤,因为对几千首歌的库,协同过滤要维护用户-歌曲矩阵,内存占用不大但代码量会吓跑新手。

真正让推荐效果“看起来好”的,是日志里要记录每首歌的播放次数和用户点击行为。我一般会在 user_song 表里记录 user_id、song_id、play_time,每播放一次插一行,当作埋点数据。推荐模块就基于这首歌的 type 和整个平台最受欢迎的歌曲做加权;如果要更细,还可以加入“用户近期播放最多的歌手”作为偏好。这不算复杂的算法,但足以撑起面试里关于“简单推荐是怎么做出来的”。

5.3 加入管理员上传歌曲:后端接收文件与重命名

后台管理系统可以单独做一个 admin 前缀的页面,普通用户无权访问。管理员上传歌曲用 FormData 上传到 /admin/song/upload,后端拿到文件后先做校验,文件扩展名必须 .mp3,大小不超过 8MB,避免用户塞一个视频或者其他东西进来。文件名的处理不能直接使用用户上传的原文件名,原因有两点:中文文件名在不同浏览器上 URL 编码可能不一致,可能引起播放乱码;如果重名会覆盖已有资源。常见做法是用时间戳拼接随机数:music_20240101_1200_1234.mp3。

// UploadServlet 核心片段 String saveDir = req.getServletContext().getRealPath("/static/music"); File dir = new File(saveDir); if (!dir.exists()) { dir.mkdirs(); } Part filePart = req.getPart("file"); String submittedFileName = filePart.getSubmittedFileName(); String ext = submittedFileName.substring(submittedFileName.lastIndexOf('.')); if (!".mp3".equalsIgnoreCase(ext)) { resp.getWriter().write("{\"success\":false,\"msg\":\"仅支持mp3\"}"); return; } String fileName = "music_" + System.currentTimeMillis() + "_" + (int)(Math.random()*1000) + ext; filePart.write(saveDir + File.separator + fileName);

上面的代码有两点值得说明:filePart.getSubmittedFileName() 在 Servlet 3.1 接口里有,如果你用的 Tomcat 8.5 以下,可能没有这个方法,要么升级 Tomcat,要么从 Content-Disposition 头里自己解析;filePart.write 是 Part 接口的方法,直接写文件,不需要手动关闭 InputStream。校验大小不能等 write 完才发现太大,可以先用 filePart.getSize() 判断,超过 8MB 直接返回错误,这样避免写一半磁盘爆了。

管理员上传后还要向 song 表插入一条记录,主要字段就是上面建表时设计的 name、singer、album、type、duration、file_path。duration 在服务端拿不到,可以前端读取音频元数据后随表单一起提交,或者在播放页面加载时懒更新。我通常会在页面上放一个隐藏的<audio>自动读取 duration,赋值给输入框,用户不需要手动填。

6. 验证与进阶:从能跑通到拿得出手

6.1 一个验收清单

交付给别人之前,我会按这个清单过一遍。功能层面:注册后能登录,登录后刷新不丢;歌曲列表分页能翻页,搜索关键词能命中歌名和歌手;点击播放能出声,切歌不刷新页面;创建歌单、添加歌曲、查看歌单歌曲;管理员登录后能上传 mp3 并立即在列表看到。工程层面:页面编码统一 utf-8,控制台和数据库无乱码;所有 URL 带上下文路径,不写死 /music;上传文件不因重新部署丢失;Session 超时设置明确;sql/init.sql 能在干净的 MySQL 上直接执行。

6.2 可以继续做的三个方向

如果你想把这个课程设计升级成简历里的亮点,第一个方向是给 Session 换成 token 登录,前端把 token 存 localStorage,请求头带 Authorization。这样 Session 的负载均衡问题就消失了,也为以后接 App 接口打了基础。第二个方向是把 JDBC DAO 层逐步替换成 MyBatis,只换数据访问,Controller 和页面不动,风险小且能体现出你有迁移框架的能力。第三个方向是给音乐列表加 Redis 缓存,歌曲信息变化不频繁,缓存热榜数据能让首页打开速度提高一大截,面试也能聊缓存穿透和一致性。

6.3 我习惯在收尾前做的三件事

每次我以为项目做完了,都会先做三件收尾:用浏览器无痕模式重走一遍主流程,避免 Session 残留造成“自己跑得通、别人跑不通”的假象;把数据库脚本用一个新的空库执行一遍,确保没有漏了字段;最后写一个 README,把 JDK、Tomcat、MySQL 版本和启动步骤写明。这第三个行为帮我解决过无数次“三个月后自己都看不懂项目”的尴尬。做 JAVAWEB 项目这件事,最大的教训不是技术不会,而是本地环境和部署环境差异。希望这份踩坑清单能帮到你,少几次为路径和编码熬夜。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 6:07:22

Simulink Scope图例设置全攻略:从信号命名到脚本批量控制

干过一段时间 Simulink 仿真的人&#xff0c;几乎都遇到过这个场景&#xff1a;Scope 里一次性拉进来五六条信号&#xff0c;波形叠在一起&#xff0c;颜色花花绿绿&#xff0c;但盯着屏幕看了半天&#xff0c;就是不知道哪条线是哪个变量。尤其在电机控制、整车 VCU 策略验证这…

作者头像 李华
网站建设 2026/10/4 6:07:04

制造业数字化:ERP+MES+IoT+AI一体化落地与避坑指南

做了快十年的制造业数字化项目&#xff0c;我越来越确认一件事&#xff1a;ERP、MES、IoT、AI这几样东西&#xff0c;单拎出来谁都能讲出一套故事&#xff0c;但真正让工厂老板点头、让车间主任愿意天天打开系统看的&#xff0c;是它们能不能“串”起来。这周我正好把一个ERPME…

作者头像 李华
网站建设 2026/10/4 6:06:42

OpenShell:Windows图形界面的可编程化改造方案

1. OpenShell 是什么&#xff1f;它不是 Shell&#xff0c;而是 Windows 终端体验的“操作系统级缝合术”OpenShell 这个名字很容易让人误以为是某种新型 Linux Shell&#xff08;比如 bash、zsh 的替代品&#xff09;&#xff0c;或者和 macOS 的 Terminal.app、iTerm2 一样属…

作者头像 李华
网站建设 2026/10/4 6:05:07

html2canvas返回data:,的四大原因与生产级解决方案

1. 这个“data:,”不是bug&#xff0c;是html2canvas在告诉你&#xff1a;它根本没画出任何东西你刚调用html2canvas(element).then(canvas > canvas.toDataURL())&#xff0c;控制台打印出来却是"data:,"——一个空得干干净净、连MIME类型都懒得写的字符串。你反…

作者头像 李华
网站建设 2026/10/4 6:04:24

SAP S4 HANA COPA获利能力分析实战:从方案设计到月结排查

做SAP FICO这么多年&#xff0c;我最怕被问到的不是总账怎么配&#xff0c;而是COPA怎么搭。总账、应收、资产这些模块都有相对固定的套路&#xff0c;照着最佳实践配置基本不会跑偏&#xff1b;但COPA获利能力分析不一样&#xff0c;它要跟销售、生产、成本、物料账、固定资产…

作者头像 李华
网站建设 2026/10/4 6:03:54

Claude Code省钱实战:模型分级与上下文管理优化指南

1. 账单从400到80&#xff0c;我到底做对了什么先说结论&#xff1a;不是换了个便宜的模型就完事了&#xff0c;也不是靠什么野路子白嫖。核心就三件事——把模型分级用对、把上下文管住、把重复劳动缓存掉。这三件事听起来像废话&#xff0c;但真正落地到 Claude Code 的日常使…

作者头像 李华