MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java实现Socket的客户端和服务端

2023-08-105.6k 阅读

一、Socket 编程基础

在深入探讨 Java 实现 Socket 的客户端和服务端之前,我们先来了解一下 Socket 编程的基本概念。Socket 是一种网络编程接口,它为应用程序提供了一种跨网络进行通信的方式。简单来说,Socket 就像是两个网络节点之间的管道,数据可以通过这个管道在两端进行传输。

Socket 编程主要基于传输层协议,常见的有 TCP(传输控制协议)和 UDP(用户数据报协议)。TCP 是一种面向连接的、可靠的协议,它保证数据的有序传输并且会重传丢失的数据。UDP 则是一种无连接的、不可靠的协议,它不保证数据的顺序和可靠性,但传输速度相对较快,适用于对实时性要求较高但对数据准确性要求相对较低的场景,如音频、视频流的传输。

在 Java 中,提供了 java.net 包来支持 Socket 编程。这个包中有两个主要的类用于实现基于 TCP 的 Socket 编程:Socket 类用于客户端,ServerSocket 类用于服务端。

二、Java 实现 Socket 服务端

1. 创建 ServerSocket 对象

在 Java 中创建一个 Socket 服务端,首先要创建一个 ServerSocket 对象。ServerSocket 类的构造函数可以接受一个端口号作为参数,这个端口号就是服务端监听客户端连接请求的端口。例如:

try {
    ServerSocket serverSocket = new ServerSocket(12345);
} catch (IOException e) {
    e.printStackTrace();
}

这里创建了一个 ServerSocket 对象并绑定到端口 12345。如果该端口已经被其他程序占用,会抛出 IOException 异常。

2. 监听客户端连接

创建好 ServerSocket 对象后,就可以通过调用它的 accept() 方法来监听客户端的连接请求。accept() 方法是一个阻塞方法,也就是说,当调用这个方法后,程序会暂停在这里,直到有客户端连接到服务端。例如:

try {
    ServerSocket serverSocket = new ServerSocket(12345);
    Socket clientSocket = serverSocket.accept();
} catch (IOException e) {
    e.printStackTrace();
}

当有客户端连接到服务端时,accept() 方法会返回一个 Socket 对象,通过这个对象就可以与客户端进行通信。

3. 与客户端进行通信

获取到与客户端通信的 Socket 对象后,就可以通过它获取输入流和输出流,从而实现与客户端的数据交互。例如,下面的代码展示了如何从客户端读取数据并向客户端发送数据:

try {
    ServerSocket serverSocket = new ServerSocket(12345);
    Socket clientSocket = serverSocket.accept();

    // 获取输入流
    BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
    // 获取输出流
    PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);

    String inputLine;
    while ((inputLine = in.readLine()) != null) {
        System.out.println("Received from client: " + inputLine);
        out.println("Message received by server: " + inputLine);
        if ("exit".equals(inputLine)) {
            break;
        }
    }

    in.close();
    out.close();
    clientSocket.close();
    serverSocket.close();
} catch (IOException e) {
    e.printStackTrace();
}

在这段代码中,首先通过 Socket 的 getInputStream() 方法获取输入流,并将其包装成 BufferedReader,以便按行读取数据。通过 getOutputStream() 方法获取输出流,并将其包装成 PrintWriter,方便向客户端发送数据。然后通过一个循环不断读取客户端发送的数据,将其打印出来,并回显一条包含客户端消息的响应。如果客户端发送的是 "exit",则退出循环,关闭相关的流和 Socket。

三、Java 实现 Socket 客户端

1. 创建 Socket 对象

在 Java 中创建一个 Socket 客户端,需要创建一个 Socket 对象,并指定要连接的服务端的 IP 地址和端口号。例如:

try {
    Socket socket = new Socket("127.0.0.1", 12345);
} catch (IOException e) {
    e.printStackTrace();
}

这里创建了一个 Socket 对象,并尝试连接到本地主机(IP 地址为 127.0.0.1)的 12345 端口。如果连接失败,会抛出 IOException 异常。

2. 与服务端进行通信

与服务端建立连接后,同样可以通过 Socket 对象获取输入流和输出流来与服务端进行数据交互。例如,下面的代码展示了如何向服务端发送数据并接收服务端的响应:

try {
    Socket socket = new Socket("127.0.0.1", 12345);

    // 获取输入流
    BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    // 获取输出流
    PrintWriter out = new PrintWriter(socket.getOutputStream(), true);

    BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
    String userInput;
    while ((userInput = stdIn.readLine()) != null) {
        out.println(userInput);
        System.out.println("Echo from server: " + in.readLine());
        if ("exit".equals(userInput)) {
            break;
        }
    }

    in.close();
    out.close();
    stdIn.close();
    socket.close();
} catch (IOException e) {
    e.printStackTrace();
}

在这段代码中,通过 Socket 的 getInputStream() 和 getOutputStream() 方法分别获取输入流和输出流,并进行相应的包装。同时,通过 BufferedReader 从标准输入(即控制台)读取用户输入的数据,将其发送到服务端,并接收服务端的响应并打印出来。如果用户输入 "exit",则退出循环,关闭相关的流和 Socket。

四、多线程处理客户端连接

在实际应用中,服务端往往需要同时处理多个客户端的连接。如果使用前面介绍的单线程方式,当一个客户端连接并进行长时间通信时,其他客户端的连接请求就会被阻塞。为了解决这个问题,可以使用多线程来处理每个客户端的连接。

1. 线程类的实现

首先,需要创建一个线程类来处理每个客户端的通信。例如:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

public class ClientHandler implements Runnable {
    private Socket clientSocket;

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

    @Override
    public void run() {
        try {
            // 获取输入流
            BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            // 获取输出流
            PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);

            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                System.out.println("Received from client: " + inputLine);
                out.println("Message received by server: " + inputLine);
                if ("exit".equals(inputLine)) {
                    break;
                }
            }

            in.close();
            out.close();
            clientSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这个 ClientHandler 类实现了 Runnable 接口,在其 run() 方法中处理与客户端的通信逻辑,与前面单线程服务端处理单个客户端的逻辑类似。

2. 服务端多线程改造

接下来,对服务端代码进行改造,使其能够为每个客户端连接创建一个新的线程来处理。例如:

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class MultiThreadedServer {
    public static void main(String[] args) {
        try {
            ServerSocket serverSocket = new ServerSocket(12345);
            while (true) {
                Socket clientSocket = serverSocket.accept();
                Thread clientThread = new Thread(new ClientHandler(clientSocket));
                clientThread.start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这段代码中,服务端通过一个无限循环不断调用 accept() 方法接受客户端连接。每当有新的客户端连接时,就创建一个新的线程,并将 ClientHandler 对象作为参数传递给线程的构造函数,然后启动该线程来处理与这个客户端的通信。这样,服务端就可以同时处理多个客户端的连接,不会因为某个客户端的长时间通信而阻塞其他客户端的请求。

五、使用 NIO 进行 Socket 编程

Java 的 NIO(New I/O)提供了一种基于通道(Channel)和缓冲区(Buffer)的非阻塞 I/O 方式,相比于传统的阻塞式 I/O,NIO 在处理高并发场景时具有更高的效率。

1. NIO 服务端实现

在 NIO 中,服务端使用 ServerSocketChannel 来监听客户端连接,使用 Selector 来管理多个通道的事件。以下是一个简单的 NIO 服务端示例:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;

public class NIOServer {
    private static final int PORT = 12345;

    public static void main(String[] args) {
        try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
             Selector selector = Selector.open()) {

            serverSocketChannel.bind(new InetSocketAddress(PORT));
            serverSocketChannel.configureBlocking(false);
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

            while (true) {
                if (selector.select() == 0) {
                    continue;
                }

                Set<SelectionKey> selectedKeys = selector.selectedKeys();
                Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

                while (keyIterator.hasNext()) {
                    SelectionKey key = keyIterator.next();

                    if (key.isAcceptable()) {
                        try (SocketChannel clientChannel = serverSocketChannel.accept()) {
                            clientChannel.configureBlocking(false);
                            clientChannel.register(selector, SelectionKey.OP_READ);
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    } else if (key.isReadable()) {
                        try (SocketChannel clientChannel = (SocketChannel) key.channel()) {
                            ByteBuffer buffer = ByteBuffer.allocate(1024);
                            int bytesRead = clientChannel.read(buffer);
                            if (bytesRead > 0) {
                                buffer.flip();
                                byte[] data = new byte[buffer.remaining()];
                                buffer.get(data);
                                String message = new String(data);
                                System.out.println("Received from client: " + message);

                                ByteBuffer responseBuffer = ByteBuffer.wrap(("Message received by server: " + message).getBytes());
                                clientChannel.write(responseBuffer);
                            }
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }

                    keyIterator.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这段代码中,首先创建了一个 ServerSocketChannel 并绑定到指定端口,然后将其设置为非阻塞模式,并注册到 Selector 上监听 OP_ACCEPT 事件。在循环中,通过 selector.select() 方法等待事件发生。当有事件发生时,获取选中的键集合,遍历这些键。如果键是可接受的(即有新的客户端连接),则接受客户端连接并将其设置为非阻塞模式,然后注册到 Selector 上监听 OP_READ 事件。如果键是可读的,则从客户端读取数据,处理并返回响应。

2. NIO 客户端实现

NIO 客户端使用 SocketChannel 来与服务端进行通信。以下是一个简单的 NIO 客户端示例:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.Scanner;

public class NIOClient {
    private static final String SERVER_IP = "127.0.0.1";
    private static final int SERVER_PORT = 12345;

    public static void main(String[] args) {
        try (SocketChannel socketChannel = SocketChannel.open()) {
            socketChannel.connect(new InetSocketAddress(SERVER_IP, SERVER_PORT));
            socketChannel.configureBlocking(false);

            Scanner scanner = new Scanner(System.in);
            while (true) {
                System.out.print("Enter message to send (type 'exit' to quit): ");
                String message = scanner.nextLine();

                ByteBuffer buffer = ByteBuffer.wrap(message.getBytes());
                socketChannel.write(buffer);

                if ("exit".equals(message)) {
                    break;
                }

                ByteBuffer responseBuffer = ByteBuffer.allocate(1024);
                int bytesRead = socketChannel.read(responseBuffer);
                if (bytesRead > 0) {
                    responseBuffer.flip();
                    byte[] data = new byte[responseBuffer.remaining()];
                    responseBuffer.get(data);
                    String response = new String(data);
                    System.out.println("Echo from server: " + response);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这段代码中,创建了一个 SocketChannel 并连接到服务端,将其设置为非阻塞模式。通过 Scanner 从控制台读取用户输入的数据,将其写入到 SocketChannel 中。然后从 SocketChannel 读取服务端的响应并打印出来。如果用户输入 "exit",则退出循环。

六、总结 Socket 编程注意事项

  1. 端口号的选择:选择端口号时要注意避免使用系统保留端口(0 - 1023),尽量选择大于 1024 的端口号,以防止端口冲突。
  2. 异常处理:在 Socket 编程中,由于涉及网络操作,很容易出现各种异常,如连接超时、网络中断等。因此,要在代码中合理地处理各种 IOException 异常,确保程序的稳定性和健壮性。
  3. 数据编码和解码:在进行数据传输时,要注意数据的编码和解码问题。例如,在读取和写入字符串时,要确保使用相同的字符编码,以避免乱码问题。
  4. 连接管理:在多客户端连接的场景下,要妥善管理连接,及时关闭不再使用的连接,以释放资源,避免资源泄漏。

通过以上内容,我们详细介绍了 Java 实现 Socket 客户端和服务端的方法,包括基于传统阻塞式 I/O 和基于 NIO 的非阻塞式 I/O 方式,并探讨了在实际编程中需要注意的一些问题。希望这些内容能够帮助你更好地理解和应用 Java 的 Socket 编程。