最近一个朋友问我,怎么用 Java 快速搭一个在线音乐播放系统,用来应付课程设计。我当时以为这只是个普通的 CRUD 项目,等实际动手才发现,真正难啃的并不是用户注册和歌单管理,而是“音频流播放”这个核心环节——浏览器怎么做到边下边播、拖动进度条不重新加载、以及多端兼容不卡顿。这篇文章,我把从零到一的完整思路、数据库设计、关键代码、部署验证和踩坑记录全部整理出来,希望能帮你省掉我当初几天的摸索时间。
基于Java的在线音乐播放系统开发
1.1 这个项目到底在做什么
所谓“基于 Java 的在线音乐播放系统”,拆开来看就三件事:把音频文件安全地交给浏览器播放、让用户能搜索和收藏歌曲、以及让管理员能上传和维护歌曲信息。很多同学一开始容易把重点放在页面上,花大量时间调样式,结果忽略了最核心的音频流传输逻辑。其实播放器能不能真正跑起来,取决于后端怎么处理 Range 请求、怎么设置文件分块、以及怎么应对大文件的 IO 消耗。
我建议按“先打通端到端播放链路,再补业务功能”的顺序来做。也就是说,第一天先搞定一个接口:前端传一个歌曲 ID,后端返回一段音频流,浏览器能正常播放、能拖进度条,这时核心链路就通了。接下来再去做用户登录、歌单收藏、搜索推荐这些功能,压力会小很多。
1.2 技术选型的实际考量
选型这件事没有绝对标准,但有一个大原则:用最成熟的方案解决最痛的问题。项目落地的技术栈如下:
| 层级 | 技术选型 | 选型理由 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 起步快、生态丰富、内置 Tomcat 省去额外配置 |
| 持久层 | Spring Data JPA + MySQL 8.0 | 避免手写大量 SQL,表结构也直观 |
| 音频存储 | 本地磁盘 + Nginx 做静态转发 | 个人项目不需要上 OSS,成本最低 |
| 前端 | Vue 3 + HTML5 Audio 标签 | 原生播放器已支持流式加载,省去自己解码 |
| 鉴权 | JWT + 拦截器 | 无状态、前端好处理、适合练手 |
| 构建 | Maven | 配置简单,依赖管理方便 |
这里我特意没有选择复杂的消息队列或分布式文件系统,因为在线音乐播放系统最本质的场景是“单机环境、中小并发、练手为主”。过度设计不但增加部署成本,还会掩盖核心逻辑的学习价值。等到你需要支撑海量用户时,再横向扩展也来得及。
2. 数据库设计与模块边界划分
2.1 最少要几张表?每张表怎么设计
如果希望系统完整又不想冗余,我建议先规划以下 6 张核心表。不要一上来就搞“关注”“动态”“评论”这类社交功能,先把基础业务闭环跑通更重要。
-- 用户表 CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(128) NOT NULL COMMENT '加盐后的密码', `nickname` varchar(50) DEFAULT '' COMMENT '昵称', `avatar` varchar(255) DEFAULT '' COMMENT '头像地址', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 歌手表 CREATE TABLE `singer` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(80) NOT NULL, `introduction` text, `avatar` varchar(255) DEFAULT '', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 歌曲表 CREATE TABLE `song` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(120) NOT NULL, `singer_id` bigint NOT NULL, `album` varchar(120) DEFAULT '', `duration` int DEFAULT 0 COMMENT '时长,单位秒', `file_path` varchar(255) NOT NULL COMMENT '音频文件相对存储路径', `lyric_path` varchar(255) DEFAULT '', `play_count` bigint DEFAULT 0, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_singer` (`singer_id`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;除了这三张主表,另外还需要playlist(歌单表)、playlist_song(歌单明细表)、user_favorite(收藏表)。我想重点提醒一个细节:歌曲表的file_path字段不要保存绝对路径,只保存相对路径,比如/audio/2024/09/01.mp3。这样项目迁移、部署到不同服务器时,不需要改数据库里的数据。
2.2 模块切分:别把代码写成一坨
我见过不少人把 Controller 写成一个类,几百行下来不仅自己改得累,后面功能扩展更是灾难。建议按业务模块拆分成auth、singer、song、playlist、favorite、recommend六个包,并严格遵循一个原则:
接口不需要知道你的文件存在哪个目录,也不需要理解 JWT 怎么生成。它只需要接收参数、调用 Service、返回 DTO。
实际的目录结构参考:
src/main/java/com/example/music/ ├── MusicApplication.java ├── config/ # CORS、拦截器注册配置 ├── controller/ # 接收前端请求 ├── service/ # 业务逻辑 ├── repository/ # JPA 数据访问 ├── entity/ # 数据库映射实体 ├── dto/ # 接口出入参对象 ├── common/ # 统一返回体、异常处理 └── utils/ # JWT 工具、文件工具这样划分的好处是:当你要临时加一个“热门歌曲榜”接口,只需要动controller和service,完全不影响其他模块。而且统一的返回结构Result<T>能让前端少做很多类型判断。
3. 最难啃的骨头:音频流式播放实现
3.1 为什么不直接返回 MP3 文件链接
最直接的方案,是把音频文件路径暴露成静态资源,前端拼一个完整 URL 就能播放。这种做法在开发环境没问题,但有两个致命缺陷:
- 文件路径被拿到后,别人可以通过遍历文件名把整个曲库都下载走,完全没法控制访问权限。
- 没有经过业务校验。用户未登录也能直接播放所有歌曲,后续如果要做会员试听,束手无策。
所以正确做法是:提供一个经过后端鉴权的音频流接口。前端播放器的src指向这个接口,比如/api/song/stream/{songId},后端读取本地文件然后以流方式输出。
3.2 Range 请求是拖拽进度条的关键所在
很多初学者会发现,明明音频能播放,但浏览器进度条一拖就卡住,或者拖动后从头开始。真正原因只有一个:后端没有正确响应Range请求头。
浏览器用 HTML5<audio>播放时,默认就会发起带Range: bytes=0-的请求,表示“从字节 0 开始给我数据”。如果后端忽略这个请求头,直接把整个文件一次返回 200,那么播放器虽然能放,但无法随机定位。
正确的做法是:解析Range头,计算出起始和结束字节,使用RandomAccessFile读取片段,并且返回206 Partial Content。支持 Range 后,拖动进度条就变成“从指定字节位置继续发数据”,体验会改善非常多。
3.3 一个生产可用的分段流式接口
下面这段代码,是打磨过好几版的实现,支持断点续传、范围取字节、浏览器兼容:
@RestController @RequestMapping("/api/song") public class SongStreamController { @Autowired private SongService songService; @GetMapping(value = "/stream/{songId}", produces = "audio/mpeg") public ResponseEntity<Resource> stream(@PathVariable Long songId, @RequestHeader(value = "Range", required = false) String range) { Song song = songService.findById(songId); if (song == null) { return ResponseEntity.notFound().build(); } // 只存相对路径,真正文件放在统一的上传目录下 String fullPath = uploadDir + song.getFilePath(); File file = new File(fullPath); if (!file.exists()) { return ResponseEntity.notFound().build(); } long fileLength = file.length(); long start = 0; long end = fileLength - 1; // 解析 Range: bytes=start-end 格式 if (range != null && range.startsWith("bytes=")) { String[] rangeStr = range.substring(6).split("-"); start = Long.parseLong(rangeStr[0]); if (rangeStr.length > 1 && !rangeStr[1].isEmpty()) { end = Long.parseLong(rangeStr[1]); } } // 参数保护,防止恶意读取越界 if (start > fileLength - 1) { return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build(); } end = Math.min(end, fileLength - 1); long contentLength = end - start + 1; InputStreamResource resource; try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { raf.seek(start); byte[] buf = new byte[(int) contentLength]; raf.readFully(buf); resource = new InputStreamResource(new ByteArrayInputStream(buf)); } catch (IOException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_TYPE, "audio/mpeg") .header(HttpHeaders.ACCEPT_RANGES, "bytes") .header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + fileLength) .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength)) .body(resource); } }这里有一个值得注意的细节:produces = "audio/mpeg"和返回头里的Content-Type必须保持一致。有些浏览器如果拿不到正确的 MIME 类型,会直接拒绝播放或认定文件损坏。
注意:如果歌曲时长很长,把整个文件读进内存再输出并不是最优解。更好的方式是直接用设置响应体的方式写流,一行一行写到 OutputStream,这样内存占用始终只停留在很小的级别。练手项目用字节数组读没问题,但在生产环境一定要改成 Streaming 方式。
3.4 前端播放器只需要几行代码
前端不用做复杂的事,Vue 3里直接用<audio>标签配合接口地址:
<template> <div class="player"> <audio ref="audioRef" :src="audioUrl" controls @timeupdate="onProgress" @play="onPlay" @pause="onPause" /> </div> </template> <script setup> import { computed } from 'vue'; import { useStore } from '@/store'; import request from '@/utils/request'; const store = useStore(); const audioRef = ref(null); const audioUrl = computed(() => { if (!store.currentSong) return ''; // 携带 token,后端通过拦截器校验 return `/api/song/stream/${store.currentSong.id}?token=${store.token}`; }); function onPlay() { // 通知后端统计播放次数,可以做异步躲避节流 } </script>这里我要特别提示一下:audio标签发起的请求通常不会带上自定义 Header,所以 JWT 放在请求头里是收不到的。建议做法是把token作为查询参数或通过 Cookie 传递,我在上面代码里用的就是 query 方式,调试最简单。
4. 完整实操:从建表到接口联调
4.1 项目初始化配置
用Spring Initializr生成一个项目,依赖选择 Web、JPA、MySQL Driver、Validation、Lombok。配置文件我建议全部用application.yml,比.properties可读性高太多:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/music_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true server: servlet: encoding: charset: UTF-8 force: true # 自定义配置:音频文件的存储根目录 music: upload-dir: /data/music第一件事不是写业务代码,而是先启动一次项目,确认数据库能连通、建表正常。这一步能筛掉 80% 的环境问题。
4.2 JWT 登录与注册实现
用户模块不要用 Session。因为前端是 Vue,后端是 API,后续可能还要适配小程序,Session 天然不适合这种场景。用 JWT 只需要两段逻辑:
生成 token 的工具:
@Component public class JwtUtil { private static final SecretKey KEY = Keys.hmacShaKeyFor("你的32位以上密钥字符串".getBytes()); private static final long EXPIRE = 7 * 24 * 3600 * 1000L; public String generateToken(Long userId, String username) { return Jwts.builder() .claim("userId", userId) .claim("username", username) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(KEY) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }拦截器绑定到WebMvcConfigurer里:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { String token = request.getParameter("token"); if (token != null && jwtUtil.parseToken(token) != null) { return true; } response.setStatus(401); return false; } }登录逻辑本身只需要三步:按用户名查用户、用相同盐加密比对密码、生成 token 返回。密码存储记得用BCrypt或者至少是MD5 + 盐。明文密码是项目里的一个高概率被批评的点,前期处理好能省很多麻烦。
4.3 歌单与收藏模块的核心思路
歌单模块的本质是多对多关系:一个歌单可以有多个歌曲,一个歌曲可以属于多个歌单。用中间表playlist_song来连接非常方便。
收藏模块稍微不同,它是“用户-歌曲”的一对多关系,不用中间表,直接在user_favorite表中存user_id和song_id即可。
创建歌单的接口流程建议:
@PostMapping("/playlist/create") public Result<Long> create(@RequestBody PlaylistCreateRequest req, @RequestParam Long userId) { Playlist playlist = new Playlist(); playlist.setName(req.getName()); playlist.setUserId(userId); playlist.setDescription(req.getDescription()); playlistRepository.save(playlist); return Result.ok(playlist.getId()); }需要注意:前端传入的userId绝对不能直接从请求体里取,否则别人可以伪造。正确做法是从 token 里解析出userId。这也是安全上的一个常见扣分点。
4.4 搜索与热门歌曲推荐
搜索功能用 SQL 的LIKE就能满足基本需求,比如:
public List<Song> search(String keyword) { return songRepository.findByNameContainingOrSingerNameContaining(keyword, keyword); }推荐功能不用做复杂算法。我实际用到的方案是:根据当前用户收藏偏好进行标签匹配。比如用户收藏歌曲里“流行”语标签占多数,那就把其他流行歌曲按播放次数排序列出来。代码量不大,但展示效果比随机推荐好太多。
如果想进一步提升热门榜单的真实感,可以在播放接口内部把不常见字段移出高频路径,改成每播放一次更新 Redis 计数,定时批量写回数据库。个人项目用 Redis 有点重,直接更新play_count字段就行。
5. 部署验证与常见问题排查实录
5.1 本地跑通和部署的完整流程
我在本地调试时常用的跑法如下:
- 先用 Maven 打包:
mvn clean package -DskipTests。 - 启动前检查
application.yml里数据库地址对不对,端口有没有被占用。 - 通过
nohup java -jar music.jar &后台启动,然后看一眼日志有没有异常。 - 用另一个窗口执行 curl 测试:
curl -I -H "Range: bytes=0-1024" "http://localhost:8080/api/song/stream/1"。 - 如果返回
206 Partial Content,说明音频流接口已经通了。
部署到云服务器时,我习惯在前面加一层 Nginx 做反向代理,同时让它暂时托管上传目录。
关于目录权限有一个容易踩的坑:Java 进程用的是普通用户启动,上传目录如果创建在 root 用户下面,就会因为权限不足而无法写入。解决办法是把上传目录chown给启动项目的用户。
5.2 高频问题速查表
| 症状 | 根因 | 解决办法 |
|---|---|---|
| 音频可以播放但没有声音 | 歌曲文件编码格式非浏览器兼容(如 wma) | 统一转成 mp3 或 aac |
| 拖动进度条无反应 | 后端未处理 Range 请求 | 按上文实现 206 响应 |
| 中文歌名乱码 | 数据库连接 URL 未设置字符集 | URL 加useUnicode=true&characterEncoding=utf8mb4 |
| 跨域报错 | 前端端口 5173,后端 8080 | 配置 CORS 允许该来源 |
| 上传大文件超时 | Spring 默认单请求大小限制 | 设置spring.servlet.multipart.max-file-size |
| 图片和音频加载 404 | 路径拼接错误 | 检查 audio 标签 src 与后端实际路径是否完全一致 |
排查这类问题时,我最常用的是 F12 打开 Network 面板。如果音频请求是红色,点开看它的响应头如果是404就是路径问题,如果是500就是后端逻辑问题,如果是206就说明流式播放链路正常。定位永远比瞎猜有效。
5.3 反复踩过的三个小坑
我会反复遇到三个坑,专门写下来提醒大家。
第一个是回收 HTML5 Audio 的内存。在 Vue 里切换歌曲时,如果不把旧实例暂停并置空 src,浏览器会继续保留旧文件的加载状态,导致连续切换几十首后浏览器卡死。正确的清理方式是audioRef.value.pause(); audioRef.value.removeAttribute('src'); audioRef.value.load();
第二个是给音频接口加缓存控制。如果音频文件不常变,建议在响应头加上缓存策略。否则同一首歌来回切换时会重复请求整个文件,非常耗流量和带宽。
第三个是本地目录结构不能随意变。音乐文件上传后的目录树,要和数据库里的相对路径保持一致。我曾经因为调整过子目录名称,结果所有歌曲都变成了 404,排查了很久才发现是数据库里旧路径还指向旧位置。
6. 写在最后的几条个人体会
有一次我压测本地服务,同时开十多个播放会话,结果发现 CPU 飙得很快。排查后定位到音频流接口里用readFully把整个文件读进内存,这在并发高的时候内存直接翻倍。后来我改成流式输出并用缓冲数组分段写,内存降到了非常低的水平,问题解决。
在做这个项目的过程中,我最大的感受就是:流式传输是音乐系统真正的技术门槛,它考验的不是你会写多少注解,而是你对 HTTP 协议和文件 IO 的理解。哪怕你只会用原生 Servlet 写,只要真正吃透了 Range,后续学什么框架都会通透很多。
另一个体会是,这类系统很适合做功能纵深扩展。基础版本做完之后,你可以接着加用户关注、评论、情感分类推荐、后台审核上传,甚至做一个小程序端。技术栈不用变,只是把业务边界划宽一点。
最后再分享一个小技巧:做这种多媒体项目时,一定先把测试音频准备好。建议准备三首不同格式的音频,其中至少包含一个超过五分钟的长文件。这样调试进度条和数据传输时,远比反复用三十秒的样本文件靠谱。很多看起来奇奇怪怪的问题,都是因为测试数据太“瘦”而不暴露的。