【Java实战⑲】深入Java网络编程:TCP与UDP实战攻略

发布于:2025-09-04 ⋅ 阅读:(21) ⋅ 点赞:(0)


一、网络编程基础概念

1.1 TCP/IP 协议族

TCP/IP 协议族是互联网的基础协议,它定义了计算机之间如何通过网络进行数据传输和通信。该协议族包含多个协议,其中 TCP(传输控制协议)、UDP(用户数据报协议)和 IP(网际协议)是最为核心的几个协议。

  • TCP:工作在传输层,提供可靠的面向连接的数据传输服务。在数据传输前,TCP 会通过三次握手建立起可靠的连接,这确保了通信双方都做好了传输数据的准备。在传输过程中,它使用序列号和确认机制来保证数据的顺序性和完整性,通过超时重传机制来处理丢失的数据,利用流量控制和拥塞控制机制来避免网络拥塞和接收方过载,例如我们日常使用的 HTTP 协议,在传输网页数据时就依赖 TCP 的可靠性来保证数据的准确无误传输。
  • UDP:同样位于传输层,提供无连接的数据传输服务。UDP 不保证数据的可靠传输,它没有复杂的连接建立和确认机制,因此传输速度快、开销小。对于一些对实时性要求高,能容忍少量数据丢失的应用,如在线视频会议、实时音频流传输等,UDP 是很好的选择。在在线游戏中,游戏中的实时操作指令、玩家位置信息等的传输就常常使用 UDP,以减少延迟,保证游戏的流畅性。
  • IP:网络层协议,负责将数据从源主机传输到目标主机。它为每个数据包添加源地址和目标地址,通过路由选择算法决定数据包的传输路径。无论数据是通过 TCP 还是 UDP 传输,最终都需要 IP 协议来完成在网络中的传输。比如,当我们从一台计算机向另一台计算机发送文件时,IP 协议负责将包含文件数据的数据包从源计算机路由到目标计算机。

1.2 端口与 IP 地址

  • 端口号:是一个 16 位的整数,范围从 0 到 65535。它用于标识同一台主机上的不同应用程序或服务。不同类型的端口号有着不同的用途:
    • 公认端口:范围是 0 到 1023,这些端口通常被分配给一些常见的、重要的网络服务,比如 HTTP 协议使用 80 端口,HTTPS 使用 443 端口,FTP 使用 21 端口等。这些端口号是众所周知的,客户端通过这些固定端口号与相应的服务器服务进行通信。
    • 注册端口:范围为 1024 到 49151,一般用于用户自定义的应用程序或服务。这些端口不固定分配给特定服务,当有应用程序需要网络连接时,系统可从这个范围内分配端口供其使用 ,在使用数据库管理工具连接数据库服务器时,可能会使用 1024 以上的某个注册端口。
    • 动态 / 私有端口:范围从 49152 到 65535,通常用于临时的、客户端的连接。许多应用程序在作为客户端发起连接时,会从这个范围中随机选择一个端口作为源端口。
  • IP 地址:用于唯一标识网络中的设备。目前主要使用的是 IPv4 地址,它是一个 32 位的二进制数,通常用点分十进制表示,如 192.168.1.1。IP 地址主要分为以下几类:
    • A 类地址:范围是 1.0.0.0 到 126.0.0.0,第一个字节表示网络号,后三个字节表示主机号。A 类地址适用于大型网络,例如一些大型企业或政府机构的网络。
    • B 类地址:范围是 128.0.0.0 到 191.255.0.0,前两个字节表示网络号,后两个字节表示主机号。一般用于中等规模的网络,如一些规模较大的公司网络。
    • C 类地址:范围是 192.0.0.0 到 223.255.255.0,前三个字节表示网络号,最后一个字节表示主机号。常用于小型网络,像家庭网络、小型办公室网络等。
    • D 类地址:是多播地址,范围是 224.0.0.0 到 239.255.255.255,用于将数据发送到一组特定的主机,而不是单个主机,比如在视频会议中,可通过 D 类地址将会议视频流发送给多个参会者。
    • E 类地址:是保留地址,范围是 240.0.0.0 到 255.255.255.255,主要用于实验和研究。

1.3 客户端 - 服务器模型

客户端 - 服务器模型(C/S 架构)是一种常用的网络应用架构,其工作流程如下:

  1. 客户端发起请求:客户端程序运行在用户的设备上,它根据用户的操作或需求,向服务器发送请求消息。这个请求可能是获取数据(如请求网页内容、查询数据库中的信息)、执行某个操作(如上传文件、注册用户信息)等。当我们在浏览器中输入一个网址并回车时,浏览器(客户端)就会向对应的网站服务器发送 HTTP 请求,请求获取该网页的内容。
  2. 服务器接收请求:服务器程序一直在特定的 IP 地址和端口上监听,等待客户端的请求。当服务器接收到客户端发来的请求后,它会解析请求消息,了解客户端的需求。
  3. 服务器处理请求:服务器根据请求的内容,执行相应的处理逻辑。这可能涉及到访问数据库、进行数据计算、调用其他服务等。如果是处理一个文件上传请求,服务器可能会将文件保存到指定的存储位置,并在数据库中记录相关的文件信息。
  4. 服务器返回结果:服务器完成请求处理后,将处理结果封装成响应消息,发送回客户端。对于之前的网页请求,服务器会将包含网页内容的 HTML 文件以及相关的资源(如图片、CSS 样式文件等)作为响应返回给浏览器。
  5. 客户端接收并处理结果:客户端收到服务器返回的响应后,解析响应消息,并根据结果进行相应的展示或进一步的操作。浏览器会解析接收到的 HTML 文件,将网页内容渲染展示给用户。

1.4 网络编程核心类

在 Java 网络编程中,有几个关键的类用于实现网络通信:

  • Socket:主要用于客户端与服务器之间的通信。通过 Socket,客户端可以连接到指定 IP 地址和端口的服务器,并进行数据的发送和接收。在客户端程序中创建一个 Socket 对象,并指定要连接的服务器 IP 地址和端口号,就可以与服务器建立连接,然后通过 Socket 的输入输出流来进行数据的交互。
  • ServerSocket:用于服务器端监听客户端的连接请求。服务器创建一个 ServerSocket 对象,并绑定到特定的端口上,然后调用其 accept () 方法进入监听状态,等待客户端的连接。当有客户端发起连接请求时,accept () 方法会返回一个新的 Socket 对象,服务器可以通过这个 Socket 对象与该客户端进行通信,实现多客户端处理时,服务器可以在一个循环中不断调用 accept () 方法,为每个连接的客户端创建一个新的线程或使用线程池来处理与客户端的通信。
  • DatagramSocket:用于 UDP 协议的通信。它可以发送和接收数据报(DatagramPacket)。无论是客户端还是服务器端,都可以使用 DatagramSocket 来创建 UDP 套接字,通过将数据封装成 DatagramPacket 对象,然后使用 DatagramSocket 的 send () 和 receive () 方法来发送和接收数据报。在实现 UDP 即时聊天功能时,客户端和服务器端都可以使用 DatagramSocket 来进行聊天消息的发送和接收。

二、TCP 协议编程实战

2.1 TCP 客户端开发

在 Java 中,开发 TCP 客户端主要使用Socket类。以下是创建Socket并实现数据发送与接收的步骤:

  1. 创建 Socket:使用Socket类的构造函数,指定服务器的 IP 地址和端口号,从而与服务器建立连接。例如:
Socket socket = new Socket("127.0.0.1", 8080);

这行代码创建了一个Socket对象,并尝试连接到本地地址(127.0.0.1)的 8080 端口。

  1. 获取输出流发送数据:通过Socket对象的getOutputStream()方法获取输出流,将数据发送到服务器。如果要发送字符串数据,需要先将其转换为字节数组。如下:
OutputStream outputStream = socket.getOutputStream();
String message = "Hello, Server!";
outputStream.write(message.getBytes());

这里获取了输出流outputStream,然后将字符串消息"Hello, Server!"转换为字节数组后通过输出流发送。

  1. 获取输入流接收数据:利用Socket对象的getInputStream()方法获取输入流,从服务器接收数据。由于接收到的数据是字节形式,可能需要根据具体情况进行转换。代码示例如下:
InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int length = inputStream.read(buffer);
String response = new String(buffer, 0, length);
System.out.println("Server response: " + response);

这段代码获取了输入流inputStream,创建了一个字节数组buffer用于存储接收到的数据,通过read方法读取数据到buffer中,read方法返回实际读取的字节数length,最后将接收到的字节数组转换为字符串并打印输出。

  1. 关闭资源:在数据传输完成后,要关闭Socket和相关的流,以释放资源。
inputStream.close();
outputStream.close();
socket.close();

依次关闭输入流、输出流和Socket,避免资源泄漏。

2.2 TCP 服务器开发

使用ServerSocket类来开发 TCP 服务器,实现监听端口和处理多客户端连接。

  1. 创建 ServerSocket 并监听端口:创建ServerSocket对象并绑定到指定的端口,然后调用accept()方法进入监听状态,等待客户端的连接请求。例如:
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("Server is listening on port 8080...");
while (true) {
    Socket socket = serverSocket.accept();
    System.out.println("A client has connected.");
    // 处理客户端连接
}

这段代码创建了一个ServerSocket对象并绑定到 8080 端口,在一个无限循环中调用accept()方法,当有客户端连接时,accept()方法会返回一个新的Socket对象,代表与该客户端的连接。

  1. 处理客户端连接:为了处理多客户端连接,通常为每个客户端连接创建一个新的线程。在新线程中,可以进行数据的读取和处理。如下:
class ClientHandler implements Runnable {
    private Socket socket;

    public ClientHandler(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            InputStream inputStream = socket.getInputStream();
            byte[] buffer = new byte[1024];
            int length;
            while ((length = inputStream.read(buffer)) != -1) {
                String message = new String(buffer, 0, length);
                System.out.println("Received from client: " + message);
                // 处理接收到的消息
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

上述代码定义了一个ClientHandler类,实现了Runnable接口。在run方法中,获取客户端Socket的输入流,循环读取客户端发送的数据并进行处理。当读取到输入流的末尾(read方法返回 - 1)时,结束循环。在finally块中关闭Socket,确保资源被正确释放。

在服务器的主循环中,每当有新的客户端连接时,创建一个ClientHandler线程并启动:

while (true) {
    Socket socket = serverSocket.accept();
    System.out.println("A client has connected.");
    Thread thread = new Thread(new ClientHandler(socket));
    thread.start();
}

2.3 TCP 数据传输案例

实现文件上传功能时,客户端读取文件内容并发送给服务器,服务器接收并保存文件。

  1. 客户端实现:客户端需要创建Socket连接到服务器,获取文件输入流读取文件内容,再通过Socket的输出流将文件数据发送给服务器。如下:
Socket socket = new Socket("127.0.0.1", 8080);
FileInputStream fileInputStream = new FileInputStream("example.txt");
OutputStream outputStream = socket.getOutputStream();

byte[] buffer = new byte[1024];
int length;
while ((length = fileInputStream.read(buffer)) != -1) {
    outputStream.write(buffer, 0, length);
}

fileInputStream.close();
outputStream.close();
socket.close();

上述代码中,首先创建Socket连接到服务器,然后创建FileInputStream用于读取本地文件example.txt。通过循环从文件输入流中读取数据到字节数组buffer,每次读取length个字节,再将buffer中的数据通过Socket的输出流发送给服务器。当文件读取完毕(read方法返回 - 1)时,关闭文件输入流、Socket的输出流和Socket。

  1. 服务器实现:服务器创建ServerSocket监听端口,接收客户端连接,获取Socket的输入流读取文件数据,再创建文件输出流将数据保存到本地。如下:
ServerSocket serverSocket = new ServerSocket(8080);
Socket socket = serverSocket.accept();
InputStream inputStream = socket.getInputStream();
FileOutputStream fileOutputStream = new FileOutputStream("uploaded_example.txt");

byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) != -1) {
    fileOutputStream.write(buffer, 0, length);
}

fileOutputStream.close();
inputStream.close();
socket.close();
serverSocket.close();

这段代码中,服务器创建ServerSocket并监听 8080 端口,当有客户端连接时,接受连接获取Socket。创建FileOutputStream用于将接收到的文件数据保存为uploaded_example.txt。通过循环从Socket的输入流中读取数据到字节数组buffer,每次读取length个字节,再将buffer中的数据通过文件输出流写入到本地文件。当读取到输入流末尾(read方法返回 - 1)时,关闭文件输出流、Socket的输入流、Socket和ServerSocket。

2.4 TCP 编程常见问题

  1. 粘包问题:在 TCP 传输中,由于 TCP 是基于流的协议,它并不保证一个发送的数据包对应一个接收的数据包,可能会出现多个数据包在传输过程中被合并成一个数据包(粘包),或者一个大的数据包被分成多个小的数据包进行发送(拆包)的情况。这会导致接收方无法正确解析数据。
    • 产生原因
      • TCP 缓冲区大小不确定:TCP 协议使用缓冲区来存储待发送或待接收的数据,而缓冲区的大小不确定可能导致数据的不确定性。当发送方发送数据的速度较快,而接收方处理数据的速度较慢时,发送方的缓冲区可能会积累多个数据包,然后一次性发送给接收方,从而造成粘包。
      • 操作系统的 Nagle 算法:Nagle 算法的目的是减少小分组的发送次数,提高网络传输的效率。它会将小的数据包合并成一个大的数据包后再发送,这也可能导致多个小包被合并成一个大包发送,从而引发粘包问题。
    • 解决方案
      • 定长包:每个消息的长度固定,接收方根据固定的长度来划分消息。在发送消息时,如果消息长度不足定长,可以在消息后面填充特定的字符(如空格)使其达到定长;接收方按照定长读取数据,这样就能准确地解析每个消息。但这种方法的缺点是,如果消息长度不足定长,会浪费带宽,而超过定长可能导致解析错误。
      • 包尾加特殊字符:通过在数据包中添加特殊的分隔符来划分消息,接收方根据分隔符来分割消息。比如在每个消息的末尾添加换行符\n或者其他不会出现在消息内容中的特殊字符作为结束标志,接收方在读取数据时,根据这些特殊字符来判断一个消息的结束位置。这样可以解决长度不确定的问题,但需要保证分隔符不会出现在消息内容中。
      • 包头加消息长度字段:在消息头中添加表示消息长度的字段,接收方先读取消息头的长度信息,然后根据长度信息读取消息内容。这是一种通用且有效的解决方案,例如在发送消息前,先将消息的长度以固定的字节数(如 4 个字节)写入消息头,接收方首先读取这 4 个字节,解析出消息的长度,然后根据这个长度读取后面的消息内容。
  2. 断连处理:TCP 连接可能会因为网络故障、服务器故障、客户端故障或超时等原因而断开。为了保证应用程序的稳定性和可靠性,需要处理这种断开连接的情况。
    • 心跳检测:可以使用心跳机制来检测 TCP 连接的状态。客户端和服务器定期向对方发送心跳包(例如一个简单的固定格式的消息),如果一方在一定时间内没有收到对方的心跳包,就认为连接可能已经断开。在客户端,可以启动一个定时任务(如使用 Java 的ScheduledExecutorService),每隔一段时间(如 5 秒)向服务器发送一个心跳包;在服务器端,同样可以设置一个定时器来检测客户端的心跳包。如果在规定时间内没有收到客户端的心跳包,服务器可以关闭与该客户端的连接,或者尝试重新建立连接。
    • 异常处理:在 Java 中,当 TCP 连接断开时,会抛出IOException异常。可以在代码中使用try - catch块捕获该异常,并在catch块中执行相应的处理逻辑,如重新连接服务器、通知用户连接已断开、保存未发送的数据等。在客户端读取服务器数据的代码中,使用try - catch捕获IOException,当捕获到异常时,提示用户连接已断开,并尝试重新连接。
try {
    BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    String line;
    while ((line = reader.readLine()) != null) {
        // 处理接收到的数据
        System.out.println("Received: " + line);
    }
} catch (IOException e) {
    System.out.println("Connection closed: " + e.getMessage());
    // 尝试重新连接
    // 这里可以添加重新连接的代码逻辑
}

三、UDP 协议编程实战

3.1 UDP 客户端开发

在 Java 中,使用DatagramSocket类进行 UDP 客户端开发。DatagramSocket主要用于发送和接收数据报(DatagramPacket)。以下是开发 UDP 客户端的步骤:

  1. 创建 DatagramSocket:可以创建一个DatagramSocket对象,并指定本地端口号(如果不指定,系统会自动分配一个可用端口)。
DatagramSocket socket = new DatagramSocket(9876);

上述代码创建了一个绑定到本地 9876 端口的DatagramSocket。

  1. 创建要发送的数据报:使用DatagramPacket类来封装要发送的数据、目标 IP 地址和目标端口号。假设要发送字符串 “Hello, UDP Server!” 到 IP 地址为 “127.0.0.1”,端口号为 8888 的服务器,代码如下:
InetAddress serverAddress = InetAddress.getByName("127.0.0.1");
String message = "Hello, UDP Server!";
byte[] buffer = message.getBytes();
DatagramPacket packet = new DatagramPacket(buffer, buffer.length, serverAddress, 8888);

这里通过InetAddress.getByName方法获取目标服务器的地址,将字符串消息转换为字节数组,然后创建DatagramPacket对象,包含数据、数据长度、目标地址和目标端口。

  1. 发送数据报:通过DatagramSocket的send方法发送数据报。
socket.send(packet);

执行这行代码后,数据报就会被发送到目标服务器。

  1. 关闭资源:数据发送完成后,关闭DatagramSocket以释放资源。
socket.close();

3.2 UDP 服务器开发

UDP 服务器同样使用DatagramSocket来接收数据报。

  1. 创建 DatagramSocket 并绑定端口:服务器需要创建一个DatagramSocket对象,并绑定到指定的端口,以监听来自客户端的数据报。
DatagramSocket socket = new DatagramSocket(8888);

此代码创建了一个绑定到 8888 端口的DatagramSocket,用于接收客户端发送到该端口的数据报。

  1. 接收数据报:创建一个DatagramPacket对象作为缓冲区,用于接收数据。然后通过DatagramSocket的receive方法接收数据报,该方法会阻塞当前线程,直到接收到数据报。
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
socket.receive(packet);

上述代码创建了一个大小为 1024 字节的字节数组buffer,并基于该数组创建DatagramPacket对象。receive方法会等待数据报的到来,当有数据报到达时,数据会被存储到packet中。

  1. 处理接收到的数据:从接收到的DatagramPacket中提取数据,并进行相应的处理。
int length = packet.getLength();
String receivedMessage = new String(buffer, 0, length);
System.out.println("Received from client: " + receivedMessage);

这段代码通过packet.getLength()获取实际接收到的数据长度,然后将字节数组转换为字符串,得到客户端发送的消息并打印输出。

  1. 关闭资源:在服务器不再需要接收数据时,关闭DatagramSocket。
socket.close();

3.3 UDP 数据传输案例

以实现一个简单的即时聊天功能为例,展示 UDP 数据传输。在这个案例中,客户端可以输入消息发送给服务器,服务器接收消息后广播给所有连接的客户端。

  1. 客户端实现:客户端创建DatagramSocket,在一个循环中读取用户输入的消息,将消息封装成数据报发送给服务器。
DatagramSocket socket = new DatagramSocket();
InetAddress serverAddress = InetAddress.getByName("127.0.0.1");
int serverPort = 8888;
Scanner scanner = new Scanner(System.in);
while (true) {
    System.out.print("Enter message to send: ");
    String message = scanner.nextLine();
    byte[] buffer = message.getBytes();
    DatagramPacket packet = new DatagramPacket(buffer, buffer.length, serverAddress, serverPort);
    socket.send(packet);
    if (message.equals("exit")) {
        break;
    }
}
socket.close();
scanner.close();

上述代码中,客户端创建DatagramSocket,并获取服务器的地址和端口。在循环中,使用Scanner读取用户输入的消息,将消息封装成数据报发送给服务器。如果用户输入 “exit”,则退出循环并关闭资源。

  1. 服务器实现:服务器创建DatagramSocket监听端口,接收客户端发送的消息,然后将消息广播给所有已知的客户端。
DatagramSocket socket = new DatagramSocket(8888);
ArrayList<InetAddress> clientAddresses = new ArrayList<>();
ArrayList<Integer> clientPorts = new ArrayList<>();
while (true) {
    byte[] buffer = new byte[1024];
    DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
    socket.receive(receivePacket);
    int length = receivePacket.getLength();
    String receivedMessage = new String(buffer, 0, length);
    InetAddress clientAddress = receivePacket.getAddress();
    int clientPort = receivePacket.getPort();
    if (!clientAddresses.contains(clientAddress) ||!clientPorts.contains(clientPort)) {
        clientAddresses.add(clientAddress);
        clientPorts.add(clientPort);
    }
    System.out.println("Received from client: " + receivedMessage);
    for (int i = 0; i < clientAddresses.size(); i++) {
        DatagramPacket sendPacket = new DatagramPacket(buffer, length, clientAddresses.get(i), clientPorts.get(i));
        socket.send(sendPacket);
    }
}

服务器代码中,创建DatagramSocket并监听 8888 端口。使用两个ArrayList分别存储客户端的地址和端口。在循环中,接收客户端发送的消息,将新的客户端地址和端口添加到列表中,然后将接收到的消息广播给所有已知的客户端。

3.4 TCP 与 UDP 协议的对比

  1. 可靠性
    • TCP:提供可靠的数据传输服务。通过三次握手建立连接,确保双方都准备好进行数据传输。在传输过程中,使用序列号、确认机制、超时重传机制等,保证数据的顺序性和完整性。如果数据在传输过程中丢失或损坏,TCP 会自动重传,直到数据被正确接收。在文件传输场景中,使用 TCP 协议可以确保文件完整无误地从发送方传输到接收方。
    • UDP:是不可靠的传输协议。它不保证数据报一定能到达目标主机,也不保证数据报的顺序和完整性。UDP 发送数据时,不进行连接建立和确认,数据可能会在传输过程中丢失、乱序或重复。在在线视频播放中,由于视频数据量较大且对实时性要求高,少量数据丢失可能不会对观看体验产生太大影响,所以可以使用 UDP 协议,即使有部分数据丢失,播放器也能继续播放后续数据,保证视频的流畅性。
  2. 效率
    • TCP:由于其可靠性机制,在数据传输过程中需要进行大量的控制信息交互,如建立连接的三次握手、确认应答、重传等操作,这增加了传输的开销,导致传输效率相对较低。
    • UDP:没有复杂的连接建立和确认机制,头部开销小(UDP 头部仅 8 个字节,而 TCP 头部通常为 20 个字节),数据可以直接发送,因此传输效率高,适合传输大量的实时数据。
  3. 适用场景
    • TCP:适用于对数据准确性和完整性要求高的场景,如文件传输、电子邮件发送、网页浏览(HTTP/HTTPS 协议基于 TCP)、数据库访问等。在这些场景中,数据的错误或丢失可能会导致严重的后果,例如文件传输不完整会使文件无法正常使用,电子邮件丢失重要内容会影响信息传递。
    • UDP:适用于对实时性要求高,能容忍少量数据丢失的场景,如实时视频会议、在线游戏、语音通话、DNS 查询、网络广播等。在在线游戏中,玩家的操作指令需要及时传输到服务器,使用 UDP 可以减少延迟,保证游戏的实时性和流畅性,即使偶尔有少量指令数据丢失,也不会对游戏的整体体验造成太大影响。

网站公告

今日签到

点亮在社区的每一天
去签到