實(shí)戰(zhàn):從流式播放到接口設(shè)計(jì)全解析)
寫這篇東西之前我先說個(gè)背景。很多人在學(xué)校、公司內(nèi)部或者接私活的時(shí)候碰到過類似的需求——做一個(gè)“基于Java的在線音樂播放系統(tǒng)”??雌饋硎莻€(gè)老掉牙的題目網(wǎng)上相關(guān)的項(xiàng)目源碼也一抓一大把但真正自己動(dòng)手做一遍或者在原有代碼上改一改就會(huì)發(fā)現(xiàn)這里面坑不少音頻文件怎么存、播放進(jìn)度怎么續(xù)傳、歌單和歌曲的關(guān)系怎么不寫成一團(tuán)亂麻、多端訪問時(shí)接口怎么設(shè)計(jì)才不打架。這篇文章不打算講那些網(wǎng)上抄來抄去的理論而是拆一個(gè)我實(shí)際帶過的項(xiàng)目基于 Java 的在線音樂播放系統(tǒng)從需求分析、表結(jié)構(gòu)設(shè)計(jì)、后端接口實(shí)現(xiàn)到前端播放器對(duì)接再到上線前容易踩的坑一條線捋下來。適合的人群就是兩類一是馬上要做相關(guān)課程設(shè)計(jì)或者畢業(yè)設(shè)計(jì)的同學(xué)二是在公司里要做內(nèi)部音樂點(diǎn)播、語音素材管理等相似系統(tǒng)的后端工程師。廢話不多說直接進(jìn)入正題。1. 動(dòng)手前先想清楚在線音樂系統(tǒng)到底在解決什么問題很多人在寫這種系統(tǒng)的時(shí)候第一步就是建 Spring Boot 項(xiàng)目然后開始寫增刪改查。這在我看來是順序錯(cuò)了。開發(fā)這種業(yè)務(wù)系統(tǒng)最先應(yīng)該花時(shí)間想清楚的是三個(gè)前提問題而不是先寫代碼。1.1 “在線播放”和“文件下載”是兩碼事我見過很多半成品系統(tǒng)所謂的在線播放其實(shí)是把音頻文件直接塞到前端audio標(biāo)簽的 src 里然后讓瀏覽器自己下載完再播。這樣實(shí)現(xiàn)極其簡(jiǎn)單但體驗(yàn)和后期問題都很大大文件加載慢、刷新就從頭開始、帶寬浪費(fèi)、無法做權(quán)限控制。真正的在線播放本質(zhì)上是一個(gè)“流式Streaming讀取”過程。客戶端向服務(wù)端發(fā)一個(gè)請(qǐng)求服務(wù)端從磁盤里讀文件按字節(jié)一點(diǎn)一點(diǎn)吐給客戶端。這時(shí)候 HTTP 協(xié)議里有一個(gè)很關(guān)鍵的東西叫Range 請(qǐng)求頭HTTP Range Header。簡(jiǎn)單說就是瀏覽器會(huì)告訴服務(wù)器“我不需要整個(gè)文件我只要從第 N 個(gè)字節(jié)到第 M 個(gè)字節(jié)的這段數(shù)據(jù)”。服務(wù)端如果能正確處理這種請(qǐng)求就能實(shí)現(xiàn)拖動(dòng)進(jìn)度條播放、快進(jìn)快退、斷點(diǎn)續(xù)傳而不是傻傻地每次從零開始傳。所以剛開始做的時(shí)候我就把系統(tǒng)的核心定位成能正確處理 Range 請(qǐng)求的音頻流接口。這一條確定下來后面整個(gè)接口設(shè)計(jì)都會(huì)不一樣。1.2 管理端和播放端要分離開這個(gè)系統(tǒng)的使用方看起來很簡(jiǎn)單用戶登錄、搜歌、聽歌。但實(shí)際落地的時(shí)候一定會(huì)有“誰把歌傳上來的”這個(gè)問題。我參與的這個(gè)項(xiàng)目里最初沒人管上傳這回事結(jié)果導(dǎo)致所有歌曲都得靠程序員往數(shù)據(jù)庫里手工插數(shù)據(jù)一次插幾十條效率低不說還很容易出現(xiàn)封面路徑寫錯(cuò)、時(shí)長(zhǎng)寫錯(cuò)這些問題。所以在設(shè)計(jì)上從一開始就要明確兩個(gè)角色管理員負(fù)責(zé)維護(hù)曲庫上傳音頻、填寫歌曲信息、管理歌手和專輯普通用戶只負(fù)責(zé)聽瀏覽、搜索、創(chuàng)建自己的歌單。這個(gè)區(qū)分不是界面上的花架子它直接決定了接口的權(quán)限分配比如上傳接口必須鑒權(quán)刪除操作必須限定角色普通用戶的接口則最多做登錄校驗(yàn)不應(yīng)該動(dòng)不動(dòng)就 403。1.3 版權(quán)和存儲(chǔ)策略不能抄網(wǎng)上的思路網(wǎng)上下載的一些源碼很多直接把歌曲文件塞到項(xiàng)目根目錄的 static 文件夾里然后數(shù)據(jù)庫存一個(gè) /static/music/xxx.mp3 了事。這在演示階段沒問題但對(duì)稍微正式一點(diǎn)的項(xiàng)目就是隱患項(xiàng)目重新打包部署時(shí)static 目錄可能被覆蓋文件說沒就沒靜態(tài)資源如果被 Nginx 直接映射出去任何人都能拼 URL 下載完全繞過了權(quán)限校驗(yàn)幾百首歌塞進(jìn) jar/war 包里包變得巨大啟動(dòng)都慢。正確的做法是把音頻文件和代碼徹底分開文件放到獨(dú)立的本地目錄或者對(duì)象存儲(chǔ)里數(shù)據(jù)庫只保存邏輯路徑通過接口動(dòng)態(tài)輸出。這一點(diǎn)我會(huì)在第 5 節(jié)專門展開講。2. 技術(shù)選型不是越新越好而是越順手越好做 Java 后端的人對(duì)技術(shù)棧通常都有一些偏好但在這個(gè)項(xiàng)目上我最終采用的是非?!皠?wù)實(shí)”的一套組合沒有刻意追新Spring Boot 2.7 MyBatis-Plus MySQL 8.0 Redis可選 Vue 3 Element Plus。2.1 后端為什么用 Spring Boot 而不是 Spring Cloud音樂播放系統(tǒng)本質(zhì)上是一個(gè)單體的中小型應(yīng)用沒必要上微服務(wù)那一套。Spring Boot 自帶內(nèi)嵌 Tomcat打包成一個(gè) jar 就能跑開發(fā)調(diào)試都簡(jiǎn)單。用 Spring Cloud 反而會(huì)把 Nacos 注冊(cè)中心、配置中心等概念引進(jìn)來徒增部署復(fù)雜度。持久層框架我用的是 MyBatis-Plus。很多人糾結(jié)到底用 MyBatis 還是 Spring Data JPA我的看法是這個(gè)系統(tǒng)涉及比較多的多表關(guān)聯(lián)查詢歌單里查歌曲、歌曲關(guān)聯(lián)歌手MyBatis 的 SQL 寫起來可控性更強(qiáng)而 MyBatis-Plus 又把單表 CRUD 給封裝掉了不用寫一堆重復(fù)的 BaseMapper 方法。查歌單的時(shí)候稍微加一點(diǎn)注解或 XML 就能完成關(guān)聯(lián)查詢效率很高。2.2 前端是 SPA 還是服務(wù)端渲染前端我試過兩條路一條是 Thymeleaf 服務(wù)端渲染另一條是前后端分離的 Vue 單頁應(yīng)用。第一條路實(shí)現(xiàn)起來很快但播放器做交互的時(shí)候很難受頁面一刷新狀態(tài)就沒了。第二條路一開始要多寫一點(diǎn)接口對(duì)接代碼但播放列表、音量、播放模式這些狀態(tài)都放在瀏覽器內(nèi)存里體驗(yàn)好得多。如果你要在一個(gè)月內(nèi)交項(xiàng)目我不建議前后端完全分離因?yàn)檫€要配 Nginx 或者處理跨域。折中一點(diǎn)的做法是后端只提供 RESTful API前端用 Vue Vite 單獨(dú)開發(fā)調(diào)試的時(shí)候用開發(fā)代理比如 Vite 的 server.proxy把 /api 轉(zhuǎn)發(fā)到后端 8080 端口部署時(shí)再統(tǒng)一放到同一個(gè)域的路徑下這樣既不難受又能講清楚架構(gòu)。2.3 項(xiàng)目包結(jié)構(gòu)設(shè)計(jì)我實(shí)際開發(fā)的包結(jié)構(gòu)大概長(zhǎng)這樣src/main/java/com/example/music ├── MusicApplication.java ├── config │ ├── WebConfig.java // 跨域、攔截器配置 │ └── MybatisPlusConfig.java // 分頁插件 ├── controller │ ├── UserController.java │ ├── SongController.java │ ├── SongListController.java │ └── StreamController.java // 音頻流播放接口 ├── service │ ├── UserService.java │ ├── SongService.java │ ├── SongListService.java │ └── OssFileService.java // 本地文件存儲(chǔ)服務(wù) ├── mapper ├── entity ├── dto └── common ├── Result.java └── BizException.java這套結(jié)構(gòu)不算復(fù)雜但把配置、控制層、業(yè)務(wù)層、持久層、通用類都分開了。項(xiàng)目到后期加了定時(shí)統(tǒng)計(jì)功能也只是在 service 下面加一個(gè) StatisticService不影響原有代碼。3. 數(shù)據(jù)庫設(shè)計(jì)表結(jié)構(gòu)決定后面少改多少需求這個(gè)系統(tǒng)的表設(shè)計(jì)是我花時(shí)間最多的地方。我見過很多爛設(shè)計(jì)比如“歌單里直接存一串歌曲 ID”查一次歌單要把字符串拆開來再查幾十遍。這里把邏輯完整理一遍。3.1 核心表與關(guān)系整個(gè)系統(tǒng)一共有 6 張核心表user 用戶表 singer 歌手表 song 歌曲表 song_list 歌單表 list_song 歌單-歌曲關(guān)聯(lián)表 upload_record 上傳記錄表可選關(guān)系是這樣的一個(gè)歌手可以有多首歌一對(duì)多一首歌可以出現(xiàn)在多個(gè)歌單里一個(gè)歌單也能包含多首歌所以歌單和歌曲是多對(duì)多必須用中間表 list_song 連接用戶可以創(chuàng)建多個(gè)歌單用戶和歌單是一對(duì)多。3.2 關(guān)鍵表結(jié)構(gòu)腳本下面給出一段我實(shí)際使用的核心 SQL雖然不能覆蓋所有字段但最能說明設(shè)計(jì)意圖的是 song 表和 list_song 表。CREATE TABLE song ( id bigint NOT NULL AUTO_INCREMENT, singer_id bigint DEFAULT NULL COMMENT 歌手ID, name varchar(128) NOT NULL COMMENT 歌曲名稱, cover_url varchar(512) DEFAULT NULL COMMENT 封面圖相對(duì)路徑, file_url varchar(512) NOT NULL COMMENT 音頻文件相對(duì)路徑, duration int DEFAULT NULL COMMENT 音頻時(shí)長(zhǎng)秒, play_count int DEFAULT 0 COMMENT 播放次數(shù), status tinyint DEFAULT 1 COMMENT 1-上架 0-下架, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_singer_id (singer_id), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE list_song ( id bigint NOT NULL AUTO_INCREMENT, song_list_id bigint NOT NULL, song_id bigint NOT NULL, sort_order int DEFAULT 0, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_list_song (song_list_id, song_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;這里有兩個(gè)容易被忽略的設(shè)計(jì)點(diǎn)。第一song 表里單獨(dú)存了singer_id為什么不直接在 song 表里冗余一個(gè)歌手名字段因?yàn)楦枋中畔⒁院髸?huì)擴(kuò)展地區(qū)、簡(jiǎn)介、頭像如果冗余字段以后同步更新會(huì)非常麻煩。歌手表穩(wěn)定下來之后查詢時(shí) JOIN 一下成本很低。第二list_song 表沒有用復(fù)合主鍵而是單獨(dú)弄了一個(gè)自增 id同時(shí)加了聯(lián)合唯一索引uk_list_song。這樣做的目的是避免以后出現(xiàn)“同一首歌在同一個(gè)歌單里被重復(fù)添加”的情況程序里做判斷容易漏數(shù)據(jù)庫層面直接擋住更穩(wěn)。3.3 duration 字段的重要性很多初學(xué)者做歌曲管理的時(shí)候根本不存音頻時(shí)長(zhǎng)。這在前端展示時(shí)問題不大但做播放列表進(jìn)度條和“最近播放”功能時(shí)會(huì)發(fā)現(xiàn)沒有時(shí)長(zhǎng)就很難展示。獲取時(shí)長(zhǎng)并不是 Java 代碼直接能讀出來的我當(dāng)時(shí)的方案是上傳完音頻之后調(diào)用 FFmpeg 的命令行工具去探測(cè)文件時(shí)長(zhǎng)。命令大概是ffprobe -v error -show_entries formatduration -of defaultnoprint_wrappers1:nokey1 /path/to/audio.mp3Java 這邊用 ProcessBuilder 去執(zhí)行這個(gè)命令把輸出解析成 int 秒數(shù)存到 duration 字段里。你也可以用現(xiàn)成的庫比如 jaudiotagger但碰到某些特殊編碼的音頻FFmpeg 明顯更穩(wěn)。這條經(jīng)驗(yàn)在后來處理一大批用戶上傳的錄音文件時(shí)幫了大忙。3.4 搜索查詢的索引設(shè)計(jì)用戶搜歌都是按歌名模糊搜SQL 基本長(zhǎng)這樣SELECT * FROM song WHERE name LIKE CONCAT(%, #{keyword}, %) LIMIT 20;這種寫法在數(shù)據(jù)量小的時(shí)候沒問題但數(shù)據(jù)庫里如果有了幾萬首歌全表掃描就會(huì)變慢。我們當(dāng)時(shí)的處理是把 name 字段加了索引并且配合 MySQL 的覆蓋索引優(yōu)化查詢?cè)偻髷?shù)據(jù)量大了更專業(yè)的做法是接入 Elasticsearch但對(duì)大多數(shù)場(chǎng)景來說加索引已經(jīng)足夠。不要一上來就整個(gè) ES那是過度設(shè)計(jì)。4. 后端核心接口登錄、播放、歌單管理的實(shí)現(xiàn)邏輯這部分是系統(tǒng)的主干。我會(huì)挑幾個(gè)最關(guān)鍵的接口講重點(diǎn)不是說把所有代碼貼出來而是講接口的設(shè)計(jì)邏輯和邊界處理。4.1 登錄用 JWT 還是 Session考慮到這個(gè)系統(tǒng)可能有 H5 端和管理端兩個(gè)前端同時(shí)訪問用的技術(shù)棧還不一樣我選了 JWT 而不是傳統(tǒng) Session。原因很簡(jiǎn)單Session 依賴瀏覽器 Cookie在 App 端、小程序端對(duì)接時(shí)要處理 Cookie 同步很麻煩JWT 就是一個(gè)字符串前端登錄后存到 localStorage 里每次請(qǐng)求放在請(qǐng)求頭 Authorization 里就行。JWT 的問題也同樣明顯服務(wù)端不好主動(dòng)把某個(gè) token 作廢。所以我做了一套小方案用戶登錄信息會(huì)緩存到 Rediskey 是用戶 IDvalue 是當(dāng)前 token。修改密碼或者被封禁時(shí)直接刪掉 Redis 中對(duì)應(yīng) key。這個(gè)設(shè)計(jì)簡(jiǎn)便宜實(shí)現(xiàn)也把 JWT 最大的痛點(diǎn)解決掉了。登錄請(qǐng)求的核心邏輯大致是public ResultString login(LoginDTO dto) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getUsername, dto.getUsername()); User user userMapper.selectOne(wrapper); if (user null || !PasswordUtil.matches(dto.getPassword(), user.getPassword())) { throw new BizException(用戶名或密碼錯(cuò)誤); } String token JwtUtil.createToken(user.getId(), user.getRole()); redisTemplate.opsForValue().set(login:token: user.getId(), token, Duration.ofDays(7)); return Result.success(token); }密碼不是明文存儲(chǔ)的而是用 BCrypt 加密。審核代碼的時(shí)候要認(rèn)真檢查有沒有人為了省事把密碼明文落庫這是紅線。4.2 流播放接口的實(shí)現(xiàn)這是整個(gè)系統(tǒng)最核心的接口我建議單獨(dú)用一個(gè) Controller 來管。實(shí)現(xiàn)流程是這樣的前端把歌曲 id 傳給后端或者直接傳一個(gè)帶簽名 token 的 URL后端根據(jù) id 查庫拿到 fileUrl后端把本地文件包裝成org.springframework.core.io.Resource通過ResponseEntity返回關(guān)鍵是處理 Range 請(qǐng)求頭。如果你只返回文件不處理 Range瀏覽器確實(shí)也能播但進(jìn)度條拖動(dòng)會(huì)非常遲鈍。處理 Range 的代碼我直接給出要點(diǎn)public ResponseEntityResource stream(PathVariable Long id, RequestHeader(value Range, required false) String range) { Song song songService.getById(id); File file ossFileService.getFile(song.getFileUrl()); long fileLength file.length(); long start 0; long end fileLength - 1; if (range ! null range.startsWith(bytes)) { String[] parts range.replace(bytes, ).split(-); start Long.parseLong(parts[0]); if (parts.length 1 !parts[1].isEmpty()) { end Long.parseLong(parts[1]); } } long contentLength end - start 1; InputStreamResource resource new InputStreamResource(new FileInputStream(file), start, contentLength); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_TYPE, audio/mpeg) .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength)) .header(HttpHeaders.CONTENT_RANGE, bytes start - end / fileLength) .body(resource); }這段邏輯其實(shí)并不復(fù)雜關(guān)鍵是你知道有這個(gè)機(jī)制、去實(shí)現(xiàn)它。不處理 Range音頻播放“在線感”會(huì)差一大截。4.3 歌單和歌曲的關(guān)聯(lián)操作歌單接口最容易出問題的是添加歌曲時(shí)的雙重校驗(yàn)。場(chǎng)景是這樣的用戶把一首歌添加到歌單 A如果歌單里已經(jīng)有了這首歌應(yīng)該提示“已存在”而不是再插一條重復(fù)數(shù)據(jù)。程序上當(dāng)然可以先查一次 list_song 表再?zèng)Q定插不插但如果兩個(gè)請(qǐng)求并發(fā)過來還是會(huì)插進(jìn)去兩條。所以我除了加數(shù)據(jù)庫唯一索引之外插入時(shí)還用了一條 SQL 直接擋INSERT INTO list_song(song_list_id, song_id, sort_order) SELECT #{songListId}, #{songId}, #{sortOrder} FROM dual WHERE NOT EXISTS ( SELECT 1 FROM list_song WHERE song_list_id #{songListId} AND song_id #{songId} );這條語句成功插入返回 1重復(fù)插入返回 0代碼里判斷一下返回結(jié)果就知道有沒有插成功。這種做法比“先查再插”要穩(wěn)得多也省一次數(shù)據(jù)庫往返。4.4 播放次數(shù)的統(tǒng)計(jì)播放次數(shù)不能每一次用戶點(diǎn)擊都去 UPDATE 一次會(huì)有并發(fā)寫問題。我當(dāng)時(shí)的做法是前端每次點(diǎn)擊播放時(shí)調(diào)一個(gè)異步接口后端只把“歌曲ID 時(shí)間戳”扔進(jìn)一個(gè)內(nèi)存隊(duì)列再寫一個(gè)定時(shí)任務(wù)每隔 30 秒把隊(duì)列里的數(shù)據(jù)聚合一次批量 UPDATE song 表的 play_count 字段。這樣數(shù)據(jù)庫的壓力很小代碼也只多了一個(gè)類而已。如果想要更精確這個(gè)數(shù)據(jù)還可以落到 Redis 里定期持久化但對(duì)校內(nèi)部系統(tǒng)來說批處理足夠。5. 音頻文件的存儲(chǔ)與安全設(shè)計(jì)這節(jié)單獨(dú)拿出來講是因?yàn)槲矣X得很多網(wǎng)上的項(xiàng)目在文件這一塊處理得太隨意了。5.1 存儲(chǔ)目錄規(guī)劃我在服務(wù)器上規(guī)劃了這樣的目錄/data/music-platform/ ├── uploads/ │ ├── covers/ # 封面圖片 │ └── audio/ # 音頻文件 └── backup/數(shù)據(jù)庫里的 file_url 字段只存相對(duì)路徑比如/uploads/audio/20240715_163000_4821.mp3。為什么不存絕對(duì)路徑因?yàn)椴渴瓠h(huán)境的 IP、域名、端口都可能變服務(wù)端只拿相對(duì)路徑去拼接移動(dòng)環(huán)境時(shí)只改一個(gè)配置項(xiàng)就夠了。文件名生成用“日期_時(shí)間戳_隨機(jī)數(shù)”的方式。為什么不直接用原文件名用戶上傳的文件名很容易出現(xiàn)中文、空格、特殊字符直接落盤后 URL 編碼各種問題都會(huì)冒出來改成系統(tǒng)生成的名字一勞永逸。原始名稱可以存到數(shù)據(jù)庫的 remark 字段里。5.2 不能直接暴露靜態(tài)目錄靜態(tài)資源控制器是這么配的Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/static/**) .addResourceLocations(file:/data/music-platform/uploads/); }這樣配置以后前端用img src/static/covers/xxx.jpg就能直接展示封面圖開發(fā)時(shí)很方便。但是注意音頻文件千萬不要這么暴露。原因我在開頭就說了一旦暴露任何人都可以繞過鑒權(quán)直接下載整個(gè)音頻系統(tǒng)的登錄機(jī)制等于形同虛設(shè)。音頻必須走第 4.2 節(jié)的 stream 接口并且接口里要判斷用戶是否登錄。更細(xì)一步可以生成一個(gè)帶過期時(shí)間的簽名 URL比如/api/stream/123?tokenxxxexpire1720000000過期就失效防住那種把播放鏈接抓出去外傳的場(chǎng)景。對(duì)于學(xué)生項(xiàng)目或者內(nèi)部系統(tǒng)做到判斷登錄這一層已足夠簽名 URL 屬于加分項(xiàng)。5.3 上傳文件的大小限制Spring Boot 默認(rèn)的上傳文件大小是 1MB一個(gè)音頻動(dòng)輒幾十 MB不配置就直接被拒了。這個(gè)坑我見過好幾個(gè)人踩。配置在 application.yml 里spring: servlet: multipart: max-file-size: 50MB max-request-size: 60MB另外單次上傳一個(gè)大文件如果體積超過 100MB還需要考慮分片上傳這已經(jīng)超出本文范圍。一般處理 50MB 以下的音頻直接 MultipartFile 上傳完全夠用。6. 前端播放器的對(duì)接細(xì)節(jié)前端我用的是 Vue 3。播放器本身是 HTML5 的audio標(biāo)簽但要做成一個(gè)能正常工作的在線播放器需要處理很多細(xì)節(jié)。6.1 播放列表是什么數(shù)據(jù)結(jié)構(gòu)播放器內(nèi)部維護(hù)一個(gè)播放隊(duì)列這個(gè)隊(duì)列其實(shí)就是一個(gè)歌曲數(shù)組。用戶的歌單可能包含幾十首歌切歌的時(shí)候不能每次都去找歌單接口所以前端是先把整個(gè)歌單的歌曲拉下來存到播放器 store 里再根據(jù)當(dāng)前歌曲 id 計(jì)算下一首。具體邏輯是這樣的store 里的 playList 存放所有歌曲對(duì)象playIndex 記錄當(dāng)前下標(biāo)點(diǎn)擊列表中的歌曲時(shí)重新設(shè)置 playList 和 playIndex播放結(jié)束后根據(jù)播放模式列表循環(huán)、單曲循環(huán)、隨機(jī)播放計(jì)算下一個(gè) index。function playSong(song, index) { store.playList [...currentSongList]; store.playIndex index; audioElement.src getStreamUrl(song.id); audioElement.play(); }6.2 獲取歌詞和封面歌詞和封面這種信息如果每次都從接口拉會(huì)拖慢頁面。我的建議是歌單列表接口里就冗余返回歌曲的基礎(chǔ)信息封面 URL、歌手名、時(shí)長(zhǎng)點(diǎn)擊播放時(shí)再去拉音頻流地址。音頻流地址確實(shí)每次都要重新拼因?yàn)閹Я藭r(shí)間戳參數(shù)可以防止瀏覽器和 Nginx 緩存住舊的鏈接。一個(gè)很有意思的細(xì)節(jié)是音頻文件的流播放接口不能用普通的 axios 去請(qǐng)求然后拿 blob 播放那樣內(nèi)存會(huì)爆。應(yīng)該直接賦給audio.src讓瀏覽器原生去處理流式加載。很多第一次做播放器的人會(huì)在這一步卡半天實(shí)際上就是沒搞明白audio.src和 axios 的區(qū)別。6.3 播放進(jìn)度條需要 source 支持 range如果后端的流接口沒有實(shí)現(xiàn) Range 處理前端拖動(dòng)進(jìn)度條后會(huì)聽到聲音但是進(jìn)度條會(huì)拉回原位置點(diǎn)擊暫停再播放又從頭開始體驗(yàn)非常差。這不是前端代碼的問題而是后端流接口不支持隨機(jī)讀。我調(diào)試的時(shí)候踩過這個(gè)坑后來補(bǔ)上 Range 處理后一切正常。所以前后端聯(lián)調(diào)時(shí)要在瀏覽器 Network 面板里檢查 Response Headers 里有沒有Content-Range有就說明后端處理對(duì)了。7. 部署與性能調(diào)優(yōu)從能跑到能扛一個(gè)系統(tǒng)從能在本地跑到部署在服務(wù)器上讓別人用中間還隔著不少事。7.1 用 Nginx 做動(dòng)靜分離雖然系統(tǒng)是一個(gè) Spring Boot 的 jar 包但在生產(chǎn)環(huán)境我建議前面再掛一個(gè) Nginx。Nginx 負(fù)責(zé)三件事監(jiān)聽 80/443 端口把 /api 下的請(qǐng)求反向代理到內(nèi)網(wǎng)的 8080直接服務(wù) /static 下的封面圖片減輕 Tomcat 的壓力配置 Gzip 壓縮讓 JSON 響應(yīng)更小。Nginx 里音頻的流播放請(qǐng)求不建議開太大的 proxy_buffer否則邊下邊播時(shí)候內(nèi)存占用會(huì)比較詭異。實(shí)測(cè)下來保持默認(rèn)值即可。7.2 大并發(fā)下的熱點(diǎn)緩存“熱歌排行榜”對(duì)應(yīng)的 SQL 很簡(jiǎn)單SELECT * FROM song ORDER BY play_count DESC LIMIT 10;真到幾十萬播放量的時(shí)候每次首頁打開都去執(zhí)行這條 SQL數(shù)據(jù)庫壓力會(huì)很大。我把它做了 Redis 緩存key 是 hot_songs緩存時(shí)間 5 分鐘。播放次數(shù)異步更新后緩存最多落后 5 分鐘對(duì)排行榜這種非實(shí)時(shí)數(shù)據(jù)來說完全能接受。實(shí)現(xiàn)上用Cacheable或者手動(dòng) redisTemplate 都行關(guān)鍵是想清楚這個(gè)數(shù)據(jù)允許“最終一致”不需要強(qiáng)一致。7.3 音頻資源防泄露部署完成后我還做了一件事給音頻流接口加了一層 referer 校驗(yàn)。不是嚴(yán)格的安全手段但對(duì)于內(nèi)部系統(tǒng)足夠當(dāng)請(qǐng)求的 Referer 域不是自己系統(tǒng)的域名時(shí)直接返回 403。這個(gè)在 Nginx 里一個(gè)配置就能實(shí)現(xiàn)location /api/stream/ { valid_referers example.com; if ($invalid_referer) { return 403; } proxy_pass http://127.0.0.1:8080; }加上這個(gè)以后別人把播放鏈接貼在其它網(wǎng)站上基本是打開就 403。這招成本極低效果卻很好。7.4 常見運(yùn)行期報(bào)錯(cuò)我在部署階段遇到過幾個(gè)高頻報(bào)錯(cuò)直接列出來上傳文件時(shí) 413通常不是 Java 的問題而是 Nginx 的client_max_body_size默認(rèn)只有 1M需要在 http 或 server 塊里加client_max_body_size 60m;??缬驁?bào)錯(cuò)開發(fā)環(huán)境前端在 5173 端口后端在 8080必須配置 CORS。生產(chǎn)環(huán)境用 Nginx 同域代理后跨域問題自然消失。中文文件名導(dǎo)致 404如果把前端傳入的中文路徑直接拼 URL容易有問題最好統(tǒng)一格式化為 URL 編碼或者干脆按前面說的避免存中文文件名。數(shù)據(jù)庫時(shí)區(qū)報(bào)錯(cuò)連接 MySQL 時(shí)如果 serverTimezone 沒配啟動(dòng)時(shí)會(huì)報(bào)時(shí)間相關(guān)的異常直接serverTimezoneAsia/Shanghai即可。8. 最后再分享幾個(gè)小經(jīng)驗(yàn)這節(jié)算是我的一點(diǎn)點(diǎn)總結(jié)吧想到哪寫到哪。第一樹形結(jié)構(gòu)不要硬造。歌單和歌曲本來就是個(gè)標(biāo)準(zhǔn)的多對(duì)多模型中間表是繞不開的。早期我圖省事用逗號(hào)分隔歌曲 id后來加排序功能時(shí)被自己坑慘了只好重構(gòu)。數(shù)據(jù)庫的規(guī)范性是在為未來的擴(kuò)展能力買單不要圖當(dāng)前幾行代碼簡(jiǎn)單。第二文件路徑能相對(duì)就不要絕對(duì)。凡是所有用戶數(shù)據(jù)相關(guān)的資源路徑都存相對(duì)路徑部署時(shí)統(tǒng)一拼接 base path換服務(wù)器不至于把數(shù)據(jù)庫里的幾百條數(shù)據(jù)改一遍。第三如果你只有一周時(shí)間最優(yōu)先要做的是把“播放”這一條鏈路跑通而不是在歌單管理上糾結(jié)太多。所謂“跑通”就是從數(shù)據(jù)庫查到歌曲、流式接口輸出 Range 響應(yīng)、前端點(diǎn)一下能正常播放、能拖進(jìn)度條。這一條鏈路通了這個(gè)項(xiàng)目的核心價(jià)值已經(jīng)交付了大半剩下的是錦上添花。第四遇到問題先看瀏覽器 Network 面板再看后端日志。很多音頻播放不了的問題其實(shí)就是 Response Header 里少了某個(gè)字段或者 MP3 文件本身是壞的。先前后端各自定位能少走很多彎路。這些經(jīng)驗(yàn)是我在實(shí)際項(xiàng)目里一個(gè)個(gè)踩出來的希望能讓準(zhǔn)備做類似系統(tǒng)的人少折騰幾個(gè)晚上。如果你也在做 Java 音樂系統(tǒng)歡迎按著這篇文章的思路先把數(shù)據(jù)庫和流接口搭起來再繼續(xù)往細(xì)節(jié)深挖。