杰瑞科技汇

Java 序列化如何通过 Socket 传输数据?

  1. 序列化:在客户端,将 Java 对象转换成一个字节流。
  2. 传输:通过 Socket 将这个字节流发送到服务器。
  3. 反序列化:在服务器端,接收字节流并将其重新还原成原来的 Java 对象。

核心概念

a. Java 序列化

为了让一个 Java 对象可以被序列化,它的类必须实现 java.io.Serializable 接口,这个接口是一个标记接口,它本身没有任何方法,只是告诉 JVM 这个类的对象可以被序列化。

Java 序列化如何通过 Socket 传输数据?-图1
(图片来源网络,侵删)

重要注意事项:

  • serialVersionUID:强烈建议为每个可序列化的类显式声明一个 serialVersionUID,这是一个唯一的标识符,用于在序列化和反序列化时验证发送方和接收方的对象类是否一致,如果不一致,会抛出 InvalidClassException,如果不显式声明,JVM 会根据类的结构自动生成一个,但任何对类的微小修改(如添加一个字段)都会导致这个 ID 改变,从而破坏兼容性。
  • 可序列化的字段:只有非 static 和非 transient 的字段会被序列化。static 字段属于类,不属于对象实例;transient 字段被标记为“瞬态”,不参与序列化。

b. Socket 通信

Socket 通信是基于 TCP/IP 的,它提供了一个双向的通信通道,我们需要两个 Socket:

  • 服务器端:使用 ServerSocket 在指定端口监听客户端的连接请求,一旦有客户端连接,就会返回一个 Socket 对象,用于与该客户端进行后续通信。
  • 客户端:使用服务器的 IP 地址和端口号创建一个 Socket 对象,尝试连接到服务器。

实现步骤

我们将创建一个完整的例子,包括:

  • 一个可序列化的 User 类。
  • 一个服务器端程序,接收并反序列化 User 对象。
  • 一个客户端程序,创建 User 对象并序列化发送。

第 1 步:创建可序列化的对象类 (User.java)

import java.io.Serializable;
// 1. 实现 Serializable 接口
public class User implements Serializable {
    // 2. 声明 serialVersionUID 以确保版本兼容性
    private static final long serialVersionUID = 1L;
    private String username;
    private transient String password; // transient 字段不会被序列化
    private int age;
    public User(String username, String password, int age) {
        this.username = username;
        this.password = password;
        this.age = age;
    }
    @Override
    public String toString() {
        return "User{" +
                "username='" + username + '\'' +
                // password 字段在反序列化后会是 null,因为它没有被传输
                ", password='" + password + '\'' +
                ", age=" + age +
                '}';
    }
}

第 2 步:创建服务器端程序 (Server.java)

服务器端的工作流程是:

Java 序列化如何通过 Socket 传输数据?-图2
(图片来源网络,侵删)
  1. 创建 ServerSocket 并绑定端口。
  2. 调用 accept() 方法,阻塞等待客户端连接。
  3. 客户端连接后,获取输入流。
  4. 使用 ObjectInputStream 包装输入流,用于读取对象。
  5. 读取对象(反序列化)。
  6. 处理对象并打印。
  7. 关闭连接。
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
    public static void main(String[] args) {
        int port = 12345;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器已启动,等待客户端连接...");
            // 阻塞,等待客户端连接
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress());
            // 获取输入流
            InputStream inputStream = clientSocket.getInputStream();
            // 使用 ObjectInputStream 来读取对象
            ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
            // 读取对象(反序列化)
            User receivedUser = (User) objectInputStream.readObject();
            // 处理接收到的对象
            System.out.println("服务器接收到对象: " + receivedUser);
            System.out.println("注意,password 字段为 null,因为它被标记为 transient: " + receivedUser.getPassword());
            // 关闭资源
            objectInputStream.close();
            clientSocket.close();
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

第 3 步:创建客户端程序 (Client.java)

客户端的工作流程是:

  1. 创建 Socket 连接到服务器的 IP 和端口。
  2. 获取输出流。
  3. 使用 ObjectOutputStream 包装输出流,用于写入对象。
  4. 创建要发送的对象。
  5. 将对象写入输出流(序列化)。
  6. 刷新并关闭流。
import java.io.*;
import java.net.Socket;
public class Client {
    public static void main(String[] args) {
        String host = "127.0.0.1"; // 本地主机地址
        int port = 12345;
        try (Socket socket = new Socket(host, port)) {
            System.out.println("已连接到服务器: " + socket.getInetAddress());
            // 获取输出流
            OutputStream outputStream = socket.getOutputStream();
            // 使用 ObjectOutputStream 来写入对象
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
            // 创建要发送的对象
            User userToSend = new User("zhangsan", "123456", 30);
            System.out.println("客户端准备发送对象: " + userToSend);
            // 写入对象(序列化)
            objectOutputStream.writeObject(userToSend);
            // 刷新流,确保数据被立即发送
            objectOutputStream.flush();
            System.out.println("对象已发送。");
            // 关闭资源
            objectOutputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

如何运行

  1. 编译:将 User.java, Server.java, Client.java 放在同一个目录下,然后编译它们。

    javac User.java Server.java Client.java
  2. 运行

    • 首先,在终端运行服务器程序:

      Java 序列化如何通过 Socket 传输数据?-图3
      (图片来源网络,侵删)
      java Server

      你会看到输出:服务器已启动,等待客户端连接...

    • 然后,在另一个终端运行客户端程序:

      java Client

      你会看到客户端的输出:已连接到服务器...客户端准备发送对象...

    • 最后,回到服务器终端,你会看到服务器的输出:

      客户端已连接: /127.0.0.1
      服务器接收到对象: User{username='zhangsan', password='null', age=30}
      注意,password 字段为 null,因为它被标记为 transient: null

重要注意事项和最佳实践

  1. 关闭资源Socket, InputStream, OutputStream, ObjectInputStream, ObjectOutputStream 等都是需要关闭的资源,强烈建议使用 try-with-resources 语句(如上面的例子所示),这样可以自动关闭资源,避免资源泄漏。

  2. serialVersionUID 的作用:如果你修改了 User 类(增加一个 String email 字段),但没有更新 serialVersionUID,然后尝试用新版本的客户端连接旧版本的服务器(或反之),反序列化时会抛出 InvalidClassException,显式声明 serialVersionUID 可以让你控制类的版本兼容性。

  3. 安全性问题

    • 远程代码执行:标准的 Java 序列化机制存在严重的安全漏洞,攻击者可以构造恶意的字节流,导致服务器在反序列化时执行任意代码。在生产环境中,直接使用 ObjectInputStreamObjectOutputStream 是非常危险的。
    • 替代方案:为了安全,强烈推荐使用更现代、更安全的序列化格式,JSONProtocol Buffers (protobuf),它们不仅更安全,而且跨语言性更好,数据格式也更紧凑。
  4. 发送多个对象:如果你想通过一个 Socket 连续发送多个对象,需要注意:

    • ObjectOutputStream 在第一次写入对象时,会写入一个头信息(stream header),后续写入只需要追加对象数据。
    • 客户端必须确保所有对象都写入完毕后,再关闭 ObjectOutputStream,如果在写入过程中关闭,会导致服务器端的 ObjectInputStream 抛出 StreamCorruptedException
    • 服务器端可以使用一个循环来持续读取 ObjectInputStream,直到遇到流结束的标记(客户端关闭连接)或特定的协议消息。

使用 JSON 作为更安全的替代方案

下面是一个使用流行的 Jackson 库进行 JSON 序列化的例子。

Maven 依赖 (pom.xml)

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.2</version> <!-- 使用最新版本 -->
</dependency>

修改后的客户端 (ClientWithJson.java)

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.OutputStream;
import java.net.Socket;
public class ClientWithJson {
    public static void main(String[] args) {
        String host = "127.0.0.1";
        int port = 12345;
        ObjectMapper mapper = new ObjectMapper(); // Jackson 核心类
        try (Socket socket = new Socket(host, port);
             OutputStream outputStream = socket.getOutputStream()) {
            User userToSend = new User("lisi", "654321", 25);
            // 将对象序列化为 JSON 字节数组
            byte[] jsonBytes = mapper.writeValueAsBytes(userToSend);
            System.out.println("客户端发送 JSON: " + new String(jsonBytes));
            // 发送 JSON 字节数组
            outputStream.write(jsonBytes);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

修改后的服务器 (ServerWithJson.java)

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerWithJson {
    public static void main(String[] args) {
        int port = 12345;
        ObjectMapper mapper = new ObjectMapper();
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("JSON 服务器已启动,等待客户端连接...");
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress());
            try (InputStream inputStream = clientSocket.getInputStream()) {
                // 读取所有字节
                byte[] jsonBytes = inputStream.readAllBytes();
                // 将 JSON 字节数组反序列化为 User 对象
                User receivedUser = mapper.readValue(jsonBytes, User.class);
                System.out.println("服务器接收到对象: " + receivedUser);
            }
            clientSocket.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这个 JSON 版本的例子更加安全和现代,是当前业界更推荐的做法。

分享:
扫描分享到社交APP
上一篇
下一篇