hnu202326010206 4 months ago
parent f8e940a203
commit 555e70e198

@ -0,0 +1,270 @@
# 文件大小限制说明
## 为什么有文件大小限制?
WebSocket 协议虽然支持传输大文件,但在实际应用中会遇到以下问题:
1. **浏览器内存限制** - 大文件的 base64 编码会占用大量内存
2. **网络稳定性** - 大文件传输时间长,容易因网络波动而失败
3. **用户体验** - 传输大文件会阻塞其他消息,影响聊天体验
4. **服务器负载** - 多个用户同时传输大文件会导致服务器压力过大
## 文件大小限制
### Web 客户端
| 文件类型 | 推荐大小 | 最大限制 | 说明 |
|---------|---------|---------|------|
| 图片 | < 500KB | 2MB | |
| 视频 | < 1MB | 2MB | |
| 音频 | < 1MB | 2MB | 使MP3 |
| 语音消息 | < 500KB | 2MB | < 30 |
| 其他文件 | < 1MB | 2MB | |
**重要提示:**
- Base64 编码会使文件大小增加约 33%
- 实际传输大小 = 原文件大小 × 1.33
- 例如1.5MB 的文件编码后约 2MB
### TCP 客户端(命令行)
| 文件类型 | 推荐大小 | 最大限制 | 说明 |
|---------|---------|---------|------|
| 任意文件 | < 50MB | 100MB | JVM |
## 如何压缩文件
### 压缩图片
#### 在线工具
- [TinyPNG](https://tinypng.com/) - 无损压缩 PNG/JPG
- [Squoosh](https://squoosh.app/) - Google 开发的图片压缩工具
- [Compressor.io](https://compressor.io/) - 支持多种格式
#### 命令行工具
```bash
# 使用 ImageMagick 压缩图片
magick convert input.jpg -quality 85 -resize 1920x1080 output.jpg
# 使用 pngquant 压缩 PNG
pngquant --quality=65-80 input.png --output output.png
```
### 压缩视频
#### 在线工具
- [CloudConvert](https://cloudconvert.com/) - 在线视频转换和压缩
- [Online-Convert](https://www.online-convert.com/) - 支持多种格式
#### 命令行工具
```bash
# 使用 ffmpeg 压缩视频
ffmpeg -i input.mp4 -vcodec h264 -acodec aac -b:v 1000k output.mp4
# 压缩并调整分辨率
ffmpeg -i input.mp4 -vf scale=1280:720 -b:v 800k output.mp4
# 压缩为 WebM 格式(更小)
ffmpeg -i input.mp4 -c:v libvpx-vp9 -b:v 500k output.webm
```
### 压缩音频
#### 在线工具
- [Online Audio Converter](https://online-audio-converter.com/)
- [Convertio](https://convertio.co/audio-converter/)
#### 命令行工具
```bash
# 使用 ffmpeg 压缩音频
ffmpeg -i input.wav -c:a libmp3lame -b:a 128k output.mp3
# 转换为 OGG 格式(更小)
ffmpeg -i input.mp3 -c:a libvorbis -q:a 4 output.ogg
```
## 发送大文件的替代方案
### 方案 1使用 TCP 客户端
TCP 命令行客户端支持更大的文件:
```bash
# 启动 TCP 客户端
run_client.bat # Windows
./run_client.sh # Linux/Mac
# 发送文件
/file @用户名 大文件.mp4
```
### 方案 2分段发送
将大文件分割成多个小文件:
```bash
# Linux/Mac
split -b 1M large_file.zip part_
# Windows (PowerShell)
$file = [System.IO.File]::ReadAllBytes("large_file.zip")
$chunkSize = 1MB
for ($i = 0; $i -lt $file.Length; $i += $chunkSize) {
$chunk = $file[$i..([Math]::Min($i + $chunkSize - 1, $file.Length - 1))]
[System.IO.File]::WriteAllBytes("part_$($i/$chunkSize).zip", $chunk)
}
```
### 方案 3使用云存储
对于非常大的文件,建议使用云存储服务:
1. 上传文件到云存储(如百度网盘、阿里云盘)
2. 获取分享链接
3. 在聊天中发送链接
## 错误提示说明
### "文件大小不能超过2MB"
**原因:** 选择的文件超过了 Web 客户端的大小限制
**解决方法:**
1. 压缩文件后再发送
2. 使用 TCP 客户端发送
3. 使用云存储分享链接
### "文件编码后过大,可能导致传输失败"
**原因:** 文件 base64 编码后超过 100KB
**解决方法:**
1. 进一步压缩文件
2. 选择更小的文件
3. 使用 TCP 客户端
### "连接异常断开,可能是发送的文件过大"
**原因:** 文件传输过程中连接断开
**解决方法:**
1. 检查网络连接
2. 减小文件大小
3. 重新连接后再试
## 性能优化建议
### 图片优化
1. **选择合适的格式**
- 照片JPG有损压缩文件小
- 图标/图表PNG无损压缩支持透明
- 动画GIF 或 WebP
2. **调整分辨率**
- 聊天分享1920x1080 足够
- 缩略图800x600 即可
3. **降低质量**
- JPG 质量 80-85 通常足够
- 肉眼难以察觉差异
### 视频优化
1. **选择合适的编码**
- H.264:兼容性好
- VP9/AV1压缩率高
2. **调整参数**
- 分辨率720p 或 480p
- 码率500-1000 kbps
- 帧率24-30 fps
3. **剪辑视频**
- 只发送关键片段
- 控制时长在 30 秒内
### 音频优化
1. **选择合适的格式**
- MP3兼容性好
- OGG压缩率高
- AAC质量好
2. **调整参数**
- 码率128 kbps 足够
- 采样率44.1 kHz
- 声道:单声道(语音)或立体声(音乐)
## 技术细节
### Base64 编码开销
```
原始文件大小 → Base64 编码后大小
100 KB → 133 KB (+33%)
500 KB → 666 KB (+33%)
1 MB → 1.33 MB (+33%)
2 MB → 2.66 MB (+33%)
```
### WebSocket 帧大小
- 最大帧大小:理论上无限制
- 实际限制:浏览器和服务器实现不同
- 推荐大小:< 1MB
- 我们的实现自动分片64KB 每片)
### 内存占用
发送 2MB 文件时的内存占用:
1. 原始文件2MB
2. Base64 编码2.66MB
3. JSON 字符串:约 2.7MB
4. WebSocket 缓冲:约 2.7MB
5. **总计:约 10MB**
接收端也需要类似的内存。
## 常见问题
**Q: 为什么 Web 客户端限制这么小?**
A: 因为浏览器环境的限制:
- 内存限制
- 单线程 JavaScript
- 网络稳定性要求高
- 用户体验优先
**Q: 能否增加文件大小限制?**
A: 可以修改代码中的限制,但不推荐:
```javascript
// web/app.js
const maxSize = 5 * 1024 * 1024; // 改为 5MB
```
但这会增加传输失败的风险。
**Q: TCP 客户端为什么支持更大文件?**
A: 因为:
- 直接使用 Socket没有浏览器限制
- 可以使用更多内存
- 传输更稳定
- 可以显示进度
**Q: 如何查看文件大小?**
A:
- Windows右键文件 → 属性
- Mac右键文件 → 显示简介
- Linux`ls -lh 文件名`
## 总结
- **Web 客户端**:适合小文件(< 2MB),方便快捷
- **TCP 客户端**:适合大文件(< 100MB),功能强大
- **云存储**:适合超大文件(> 100MB最可靠
选择合适的方式,享受流畅的聊天体验!

@ -387,13 +387,15 @@ sudo ufw allow 8888/tcp
### 支持的文件格式
| 类型 | 格式 | 说明 |
|-----|------|------|
| 图片 | JPG, PNG, GIF, BMP, WebP | 自动预览 |
| 视频 | MP4, WebM, AVI, MOV | 内置播放器 |
| 音频 | MP3, WAV, OGG, M4A | 内置播放器 |
| 语音 | WebM, OGG | 录制格式 |
| 其他 | 任意格式 | 下载保存 |
| 类型 | 格式 | 大小限制 | 说明 |
|-----|------|---------|------|
| 图片 | JPG, PNG, GIF, BMP, WebP | 2MB | 自动预览,建议压缩 |
| 视频 | MP4, WebM, AVI, MOV | 2MB | 内置播放器,建议使用短视频 |
| 音频 | MP3, WAV, OGG, M4A | 2MB | 内置播放器 |
| 语音 | WebM, OGG | 2MB | 录制格式 |
| 其他 | 任意格式 | 2MB | 下载保存 |
**注意:** Web 客户端文件大小限制为 2MB超过此大小请使用 TCP 命令行客户端(支持更大文件)。
### 麦克风权限设置

@ -3,7 +3,6 @@ package server;
import com.google.gson.Gson;
import java.io.*;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@ -31,6 +30,8 @@ public class WebSocketClient implements Runnable {
socket.setSendBufferSize(1024 * 1024); // 1MB
socket.setReceiveBufferSize(1024 * 1024); // 1MB
socket.setTcpNoDelay(true); // 禁用Nagle算法提高实时性
// 设置超时时间
socket.setSoTimeout(30000); // 30秒超时
} catch (Exception e) {
System.err.println("设置Socket参数失败: " + e.getMessage());
}
@ -103,7 +104,10 @@ public class WebSocketClient implements Runnable {
boolean fin = (b & 0x80) != 0;
int opcode = b & 0x0F;
System.out.println("读取帧: FIN=" + fin + ", opcode=" + opcode);
// 调试信息
if (opcode != 0) { // 非继续帧才打印
System.out.println("读取帧: FIN=" + fin + ", opcode=" + opcode);
}
// 处理控制帧
if (opcode == 8) { // Close frame
@ -289,6 +293,11 @@ public class WebSocketClient implements Runnable {
// 转发消息
server.forwardMediaMessage(message);
System.out.println("消息已转发");
} else if ("PING".equals(type)) {
// 心跳包响应
Map<String, Object> response = new HashMap<>();
response.put("type", "PONG");
sendMessage(gson.toJson(response));
}
} catch (com.google.gson.JsonSyntaxException e) {
System.err.println("JSON 解析失败: " + e.getMessage());
@ -316,9 +325,10 @@ public class WebSocketClient implements Runnable {
sendFrame(payload, 0, payloadLength, true);
} else {
// 大消息,分片发送
System.out.println("消息过大,分片发送");
System.out.println("消息过大,分片发送,总大小: " + payloadLength + " 字节");
int offset = 0;
int fragmentCount = 0;
long startTime = System.currentTimeMillis();
while (offset < payloadLength) {
int length = Math.min(maxFrameSize, payloadLength - offset);
@ -328,12 +338,26 @@ public class WebSocketClient implements Runnable {
offset += length;
fragmentCount++;
if (fragmentCount % 10 == 0) {
System.out.println("已发送: " + offset + "/" + payloadLength + " 字节");
// 每发送5个片段暂停一下避免接收端处理不过来
if (fragmentCount % 5 == 0 && offset < payloadLength) {
try {
Thread.sleep(10); // 10毫秒延迟
} catch (InterruptedException e) {
// 恢复中断状态
Thread.currentThread().interrupt();
}
}
// 每发送20个片段打印进度
if (fragmentCount % 20 == 0 || offset >= payloadLength) {
System.out.println("已发送: " + offset + "/" + payloadLength + " 字节 (" +
String.format("%.1f", (offset * 100.0 / payloadLength)) + "%)");
}
}
System.out.println("分片发送完成,共 " + fragmentCount + " 个片段");
long endTime = System.currentTimeMillis();
System.out.println("分片发送完成,共 " + fragmentCount + " 个片段,耗时 " +
(endTime - startTime) + " 毫秒");
}
} catch (IOException e) {
@ -368,14 +392,9 @@ public class WebSocketClient implements Runnable {
frameHeader.write(length & 0xFF);
} else {
frameHeader.write(127);
frameHeader.write((int)((length >> 56) & 0xFF));
frameHeader.write((int)((length >> 48) & 0xFF));
frameHeader.write((int)((length >> 40) & 0xFF));
frameHeader.write((int)((length >> 32) & 0xFF));
frameHeader.write((int)((length >> 24) & 0xFF));
frameHeader.write((int)((length >> 16) & 0xFF));
frameHeader.write((int)((length >> 8) & 0xFF));
frameHeader.write((int)(length & 0xFF));
for (int i = 7; i >= 0; i--) {
frameHeader.write((int)((length >> (i * 8)) & 0xFF));
}
}
// 发送帧头
@ -401,8 +420,13 @@ public class WebSocketClient implements Runnable {
if (input != null) input.close();
if (output != null) output.close();
if (socket != null && !socket.isClosed()) socket.close();
System.out.println("客户端 " + username + " 资源已清理");
} catch (IOException e) {
System.err.println("清理资源失败: " + e.getMessage());
}
}
}
public String getUsername() {
return username;
}
}

@ -1,14 +1,11 @@
package server;
import common.Message;
import common.MessageType;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
import com.google.gson.Gson;
/**
@ -32,6 +29,7 @@ public class WebSocketServer {
public void start() {
try {
serverSocket = new ServerSocket(PORT);
serverSocket.setReuseAddress(true);
running = true;
System.out.println("WebSocket服务器启动成功监听端口: " + PORT);
System.out.println("Web客户端访问地址: http://localhost:" + PORT);
@ -40,6 +38,9 @@ public class WebSocketServer {
// 检查web文件是否存在
checkWebFiles();
// 启动心跳检测线程
startHeartbeatCheck();
while (running) {
try {
Socket clientSocket = serverSocket.accept();
@ -56,6 +57,42 @@ public class WebSocketServer {
}
}
private void startHeartbeatCheck() {
Thread heartbeatThread = new Thread(() -> {
while (running) {
try {
Thread.sleep(30000); // 每30秒检查一次
Map<String, Object> pingMessage = new HashMap<>();
pingMessage.put("type", "PING");
pingMessage.put("timestamp", System.currentTimeMillis());
String pingJson = gson.toJson(pingMessage);
synchronized (this) {
List<String> deadClients = new ArrayList<>();
for (Map.Entry<String, WebSocketClient> entry : clients.entrySet()) {
try {
entry.getValue().sendMessage(pingJson);
} catch (Exception e) {
System.err.println("发送心跳包失败: " + entry.getKey() + ", " + e.getMessage());
deadClients.add(entry.getKey());
}
}
// 清理死亡的客户端
for (String username : deadClients) {
removeClient(username);
}
}
} catch (InterruptedException e) {
break;
}
}
});
heartbeatThread.setDaemon(true);
heartbeatThread.start();
}
private void handleClient(Socket socket) {
try {
BufferedReader reader = new BufferedReader(
@ -246,9 +283,11 @@ public class WebSocketServer {
}
public synchronized void removeClient(String username) {
clients.remove(username);
System.out.println("用户 " + username + " 已离线,当前在线: " + clients.size());
broadcastUserList();
WebSocketClient client = clients.remove(username);
if (client != null) {
System.out.println("用户 " + username + " 已离线,当前在线: " + clients.size());
broadcastUserList();
}
}
public void sendPrivateMessage(String sender, String receiver, String content) {
@ -291,9 +330,44 @@ public class WebSocketServer {
if (receiverClient != null) {
System.out.println("找到接收者客户端,准备发送");
String jsonMessage = gson.toJson(messageData);
System.out.println("消息大小: " + jsonMessage.length() + " 字符");
receiverClient.sendMessage(jsonMessage);
System.out.println("消息已发送到接收者");
int messageSize = jsonMessage.length();
System.out.println("消息大小: " + messageSize + " 字符 (" + formatSize(messageSize) + ")");
// 提高消息大小限制到5MB
if (messageSize > 5 * 1024 * 1024) { // 5MB
System.out.println("警告:消息过大,可能导致传输问题");
// 发送错误消息给发送者
if (senderClient != null) {
Map<String, Object> error = new HashMap<>();
error.put("type", "ERROR");
error.put("content", "文件过大(" + formatSize(messageSize) + "),建议压缩后再发送");
senderClient.sendMessage(gson.toJson(error));
}
return;
}
try {
receiverClient.sendMessage(jsonMessage);
System.out.println("消息已发送到接收者");
// 发送成功确认给发送者
if (senderClient != null) {
Map<String, Object> ack = new HashMap<>();
ack.put("type", "SEND_SUCCESS");
ack.put("receiver", receiver);
ack.put("timestamp", System.currentTimeMillis());
senderClient.sendMessage(gson.toJson(ack));
}
} catch (Exception e) {
System.err.println("发送消息失败: " + e.getMessage());
if (senderClient != null) {
Map<String, Object> error = new HashMap<>();
error.put("type", "ERROR");
error.put("content", "发送失败: " + e.getMessage());
senderClient.sendMessage(gson.toJson(error));
}
}
} else {
System.out.println("接收者不在线: " + receiver);
if (senderClient != null) {
@ -305,6 +379,12 @@ public class WebSocketServer {
}
}
private String formatSize(int bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
return String.format("%.1f MB", bytes / (1024.0 * 1024));
}
private void broadcastUserList() {
Map<String, Object> message = new HashMap<>();
message.put("type", "USER_LIST");
@ -343,12 +423,34 @@ public class WebSocketServer {
System.out.println();
}
public void stop() {
running = false;
try {
if (serverSocket != null && !serverSocket.isClosed()) {
serverSocket.close();
}
} catch (IOException e) {
System.err.println("关闭服务器失败: " + e.getMessage());
}
threadPool.shutdown();
try {
if (!threadPool.awaitTermination(5, TimeUnit.SECONDS)) {
threadPool.shutdownNow();
}
} catch (InterruptedException e) {
threadPool.shutdownNow();
}
System.out.println("服务器已停止");
}
public static void main(String[] args) {
WebSocketServer server = new WebSocketServer();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("\n正在关闭服务器...");
server.stop();
}));
server.start();
}
}
}

@ -644,10 +644,14 @@ function handleFileSelect(event) {
return;
}
// 检查文件大小(限制5MB以避免WebSocket断联
const maxSize = 5 * 1024 * 1024;
// 检查文件大小(限制2MB以避免WebSocket断联
const maxSize = 2 * 1024 * 1024;
if (file.size > maxSize) {
alert('文件大小不能超过5MB\n当前文件大小: ' + formatFileSize(file.size));
alert('文件大小不能超过2MB\n' +
'当前文件大小: ' + formatFileSize(file.size) + '\n\n' +
'建议:\n' +
'1. 压缩图片/视频后再发送\n' +
'2. 使用 TCP 客户端发送大文件');
return;
}
@ -671,6 +675,15 @@ function handleFileSelect(event) {
}
console.log('文件类型:', messageType, 'MIME:', file.type);
console.log('Base64 数据大小:', base64Data.length, '字符');
// 检查编码后的大小
const encodedSize = base64Data.length;
if (encodedSize > 100000) { // 100KB
alert('文件编码后过大,可能导致传输失败\n' +
'建议压缩文件或使用 TCP 客户端');
return;
}
// 发送文件消息
const message = {
@ -683,7 +696,7 @@ function handleFileSelect(event) {
fileData: base64Data
};
console.log('发送文件消息,数据大小:', base64Data.length, '字符');
console.log('发送文件消息');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));

Loading…
Cancel
Save