我们将分步实现一个包含客户端和服务器端的完整示例。

核心设计思路
为了通过 Socket 传输文件,我们不能直接把文件二进制流一股脑地发过去,因为接收方无法知道文件从哪里开始,到哪里结束,我们需要一个简单的协议来规范通信过程。
一个简单且有效的协议如下:
-
客户端 -> 服务器 (握手阶段):
- 客户端首先发送一个固定的字符串,
"FILE_UPLOAD_START",告诉服务器“我要开始上传文件了”。 - 紧接着,客户端发送文件名(
"test.txt")和文件大小(1024字节)。 - 服务器收到这些信息后,解析出文件名和大小,并准备好一个输出流来接收文件内容。
- 客户端首先发送一个固定的字符串,
-
客户端 -> 服务器 (文件传输阶段):
(图片来源网络,侵删)客户端开始读取本地文件,并将文件内容通过 Socket 的输出流,以字节块的形式发送给服务器。
-
服务器 -> 客户端 (确认阶段):
- 服务器不断从输入流中读取数据,并写入到本地文件,直到读取到的字节数等于之前接收到的文件大小。
- 文件接收完毕后,服务器可以发送一个确认消息,
"UPLOAD_SUCCESS",告知客户端文件已成功接收。
-
关闭连接:
双方关闭 Socket 连接。
(图片来源网络,侵删)
步骤 1:服务器端代码
服务器负责监听客户端的连接,接收文件信息,然后将接收到的数据保存到本地。
FileUploadServer.java
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
public class FileUploadServer {
private static final int SERVER_PORT = 8888;
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(SERVER_PORT)) {
System.out.println("服务器已启动,等待客户端连接...");
// 循环监听,以便处理多个客户端连接
while (true) {
try (Socket socket = serverSocket.accept();
DataInputStream dis = new DataInputStream(socket.getInputStream());
FileOutputStream fos = new FileOutputStream("received_" + System.currentTimeMillis() + ".dat")) {
System.out.println("客户端 " + socket.getInetAddress() + " 已连接。");
// 1. 读取客户端发送的协议标识
String protocol = dis.readUTF();
if (!"FILE_UPLOAD_START".equals(protocol)) {
System.out.println("收到非法的协议标识: " + protocol);
continue; // 忽略本次连接
}
// 2. 读取文件名和文件大小
String fileName = dis.readUTF();
long fileSize = dis.readLong();
System.out.println("开始接收文件: " + fileName + ", 大小: " + fileSize + " 字节");
// 3. 循环读取文件内容并写入本地文件
byte[] buffer = new byte[4096];
long remainingBytes = fileSize;
int bytesRead;
while (remainingBytes > 0 && (bytesRead = dis.read(buffer, 0, (int) Math.min(buffer.length, remainingBytes))) != -1) {
fos.write(buffer, 0, bytesRead);
remainingBytes -= bytesRead;
}
System.out.println("文件接收完成!");
} catch (IOException e) {
System.err.println("处理客户端请求时发生错误: " + e.getMessage());
}
}
} catch (IOException e) {
System.err.println("服务器启动失败: " + e.getMessage());
}
}
}
代码解释:
ServerSocket serverSocket = new ServerSocket(8888): 在端口 8888 上创建一个服务器套接字,开始监听客户端连接。serverSocket.accept(): 这是一个阻塞方法,会一直等待直到有客户端连接,一旦有客户端连接,它会返回一个Socket对象,代表与该客户端的连接通道。DataInputStream dis: 使用DataInputStream可以方便地读取基本数据类型,如UTF字符串和long整数。FileOutputStream fos: 用于将接收到的字节流写入到本地文件,文件名我们动态生成,以避免覆盖。- 协议解析:
dis.readUTF(): 读取客户端发来的字符串,判断是否为"FILE_UPLOAD_START"。dis.readUTF(): 读取文件名。dis.readLong(): 读取文件大小。
- 文件接收循环:
- 我们创建一个 4KB 的
buffer。 while (remainingBytes > 0)循环确保我们只读取文件规定大小的数据。dis.read(buffer, ...)从输入流中读取数据到buffer中。fos.write(buffer, ...)将buffer中的数据写入本地文件。remainingBytes不断减少,直到为 0,表示文件接收完毕。
- 我们创建一个 4KB 的
步骤 2:客户端代码
客户端负责连接服务器,读取本地文件,然后按照我们定义的协议将文件信息发送给服务器,最后发送文件内容。
FileUploadClient.java
import java.io.*;
import java.net.Socket;
public class FileUploadClient {
private static final String SERVER_HOST = "127.0.0.1"; // 服务器地址
private static final int SERVER_PORT = 8888; // 服务器端口
public static void main(String[] args) {
// 要上传的文件路径
String filePath = "C:\\path\\to\\your\\file.txt"; // 请替换为你的文件路径
File file = new File(filePath);
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT);
DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
FileInputStream fis = new FileInputStream(file)) {
System.out.println("已连接到服务器 " + SERVER_HOST + ":" + SERVER_PORT);
// 1. 发送协议标识
dos.writeUTF("FILE_UPLOAD_START");
// 2. 发送文件名和文件大小
dos.writeUTF(file.getName());
dos.writeLong(file.length());
System.out.println("开始上传文件: " + file.getName() + ", 大小: " + file.length() + " 字节");
// 3. 循环读取本地文件并发送给服务器
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
dos.write(buffer, 0, bytesRead);
}
System.out.println("文件上传完成!");
} catch (FileNotFoundException e) {
System.err.println("错误: 文件未找到 - " + e.getMessage());
} catch (IOException e) {
System.err.println("上传文件时发生错误: " + e.getMessage());
}
}
}
代码解释:
new Socket(SERVER_HOST, SERVER_PORT): 创建一个 Socket 客户端,尝试连接到指定地址和端口的服务器。DataOutputStream dos: 使用DataOutputStream方便地写入基本数据类型。FileInputStream fis: 用于读取本地文件的内容。- 协议发送:
dos.writeUTF("FILE_UPLOAD_START"): 发送协议标识。dos.writeUTF(file.getName()): 发送文件名。dos.writeLong(file.length()): 发送文件大小。
- 文件发送循环:
fis.read(buffer)从本地文件中读取数据到buffer。dos.write(buffer, 0, bytesRead)将buffer中的数据通过 Socket 发送给服务器。- 循环直到文件读取完毕(
fis.read()返回-1)。
如何运行
- 准备文件:在你的电脑上创建一个文本文件,
C:\temp\my_test_file.txt,并在里面写一些内容,然后修改客户端代码中的filePath为这个路径。 - 编译代码:将两个
.java文件放在同一个目录下,编译它们。javac FileUploadServer.java FileUploadClient.java
- 运行服务器:首先运行服务器端程序。
java FileUploadServer
你会看到控制台输出:
服务器已启动,等待客户端连接... - 运行客户端:在另一个新的终端窗口中,运行客户端程序。
java FileUploadClient
- 观察结果:
- 客户端终端:会显示连接成功和上传完成的日志。
- 服务器终端:会显示客户端已连接、开始接收文件和接收完成的日志。
- 服务器所在目录:你会发现一个新文件,
received_167....dat`,打开它,你会发现内容和你客户端的文件完全一样。
进阶与优化
这个基础示例已经可以实现文件上传,但在实际生产环境中,还需要考虑很多优化点:
-
关闭资源的顺序:在
try-with-resources语句中,JVM 会自动关闭资源,关闭顺序与声明顺序相反,这确保了输出流在关闭前会先刷新缓冲区,输入流在关闭前会先读取完所有数据,是很好的实践。 -
更健壮的协议:
- 可以增加一个文件校验步骤,比如在文件传输前后都发送文件的 MD5 或 SHA-256 值,服务器接收完毕后进行校验,以确保文件在传输过程中没有损坏。
- 可以增加错误处理机制,如果服务器无法创建文件(如磁盘已满),可以发送一个
"UPLOAD_FAILED"消息给客户端。
-
多线程处理:上面的服务器是单线程的,一次只能处理一个客户端,为了支持高并发,你需要为每个客户端连接创建一个新的线程来处理,可以使用线程池来优化性能,避免频繁创建和销毁线程。
多线程服务器示例:
// 在 FileUploadServer 的 main 方法中,将 accept 循环部分修改为: while (true) { Socket clientSocket = serverSocket.accept(); // 为每个客户端连接创建一个新线程 new Thread(() -> { try (DataInputStream dis = new DataInputStream(clientSocket.getInputStream()); FileOutputStream fos = new FileOutputStream("received_" + System.currentTimeMillis() + ".dat")) { // ... (中间处理逻辑与之前相同) } catch (IOException e) { e.printStackTrace(); } }).start(); } -
大文件传输:对于非常大的文件(如 GB 级别),一次性读取整个文件到内存是不现实的,我们上面的代码已经通过
byte[] buffer实现了分块读写,这是处理大文件的标准做法,内存占用很低。 -
NIO (New I/O):对于更高性能的服务器,可以使用 Java NIO (基于 Channel 和 Buffer) 来替代传统的 BIO (基于 Stream),NIO 使用非阻塞 I/O 和多路复用技术(如 Selector),可以用一个线程处理成百上千的连接,性能远超传统的多线程模型,但这会增加编程的复杂性。
希望这个详细的解释和完整的代码示例能帮助你理解 Java Socket 文件上传的原理和实现!
