【JavaEE】(6) 网络编程套接字

发布于:2025-07-03 ⋅ 阅读:(18) ⋅ 点赞:(0)

一、什么是网络编程套接字 

        这一章节主要讲网络编程实现跨主机通信,我们在应用层写程序需要关心的是传输层的 APIsocket api,网络编程套接字),因为传输层到物理层已经由操作系统实现。

        网络通信数据报(UDP)/包(IP)/帧(数据链路层协议)/段(TCP),我们不深究,只有做学术研究时才这么严谨。

        传输层涉及到 TCP 协议UDP 协议,提供了两组 socket api

        TCP 与 UDP 的区别

  • TCP:有连接,可靠传输,面向字节流,全双工。
  • UDP:无连接,不可靠传输,面向数据报,全双工。

        各种词的解释

  • 有连接:通信双方会保存对方的信息。无连接:通信双方不保存对方的信息。(若要保存,需要应用层自己写代码)
  • 可靠传输:尽可能保证数据报被对端收到。不可靠传输:数据报发出去后就不管了。
  • 面向字节流:读写数据的基本单位是一个字节。面向数据报:读写数据的基本单位是一个数据报(由几个字节构成的结构化数据)。
  • 全双工:一个信道,双向通信。半双工:一个信道,单向通信。
  • 安全:数据不容易被黑客截获/破解。

二、UDP socket api

1、DatagramSocket

        创建一个 DatagramSocket 对象就是打开了一个 socket 文件,socket 文件就是网卡。通过网卡发送数据就是通过该对象写入数据;通过网卡接收数据就是通过该对象读取数据。

        构造方法

  • DatagramSocket():创建 UDP 数据报的套接字,随机一个端口绑定到本机。
  • DatagramSocket(int port):创建 UDP 数据报的套接字,指定一个端口绑定到本机。

        其它方法

  • void receive(DatagramPacket p):从该套接字接收数据报,没有接收到就阻塞
  • void send(DatagramPacket p):从该套接字发送数据报。
  • void close():关闭该套接字。

2、DatagramPacket

        该对象就是一个 UDP 数据报,是 UDP 协议的基本传输单位。

        构造方法

  • DatagramPacket(byte[] buf, int length):接收到的数据存放在字节数组 buf 中,接收指定出长度。
  • DatagramPacket(byte[] buf, int offset, int length, SocketAddress address):从 offset 开始存放数据;address 是目的主机的 IP 和端口号。

        发送数据时,需要指定目标 IP 和端口号 SocketAddress,可以用 InetSocketAddress 来创建:

  • InetSocketAddress(InetAddress addr, int port)。

        其他方法

  • InetAddress getAddress():从接收数据报中,获取发送端的 IP 地址;从发送数据报中,获取接收端的 IP 地址。
  • int get Port():获取端口号。
  • byte[] getData():获取数据报中的数据。

3、实现一个简单的网络通信程序

        基本流程

客户端

  • 从控制台读取用户输入内容。
  • 将内容通过网络发送给服务器。

服务器

  • 从客户端读取到请求内容。
  • 根据请求内容计算响应。
  • 把响应返回给客户端。

客户端

  • 从服务器读取到响应。
  • 把响应结果显示到控制台上。

        业务逻辑不是我们当前关注的重点,这会在后续重点学习,因此只实现简单的回显功能,将接收的原字符串再发送回去。

服务器

  • 创建 socket 时需要绑定一个端口号,来区分同一个设备上的不同的程序。一个端口号一个时刻,只能被一个进程(socket)绑定,所以我们要避开已经被使用的端口。可以用 netstat -ano 来查看现有程序所绑定的端口,可以用 netstat -ano | findstr "端口号" 来查看某端口是否被使用。端口号在网络协议中,使用 2 个字节的无符号整数来表示(0~65535),其中 0~1024 是知名端口号,供一些知名协议的服务器使用,我们写程序时应该避免使用。虽然我们是用 int 来存储端口号,但是 Java 会对端口号的范围进行检查,不符合规定则会报错。
  • 启动服务器,目的是不断处理各种客户端的各种请求,需要使用循环实现。
  • 循环当中,socket 会不断接收请求,但没有请求时,就会阻塞
  • 计算好响应后,需要把字符串构造为数据报 DatagramPacket 的形式再返回,用 String 的字节数组来构造,注意要传入字节数组的长度,而不是字符串的长度。
  • 返回的时候,我们需要知道目的 IP 和端口号传给 DatagramPacket,才知道发送到哪。但是 UDP 是无连接的,所以 socket 不含对方的信息。但是从客户端发来的请求 DatagramPacket 中包含它的 IP 和端口,虽然被操作系解析掉了,但是还是可以通过  getSocketAddress() 方法来获取。

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;

public class EchoServer {
    private DatagramSocket socket = null;

    public EchoServer(int port) throws SocketException {
        socket = new DatagramSocket(port);
    }

    public void start() throws IOException {
        while(true) {
            // 1. 从客户端接收请求
            // 1). 创建 DatagramPacket 对象,用于存放接收的请求数据
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
            // 2). 调用 receive 方法,读取网卡中的请求数据
            socket.receive(requestPacket);
            // 3). 将 DatagramPacket 解析为字符串
            String request = new String(requestPacket.getData(), 0, requestPacket.getLength());

            // 2. 根据请求计算响应
            String response = process(request);

            // 3. 将响应返回给客户端
            // 1). 将字符串响应转换为 DatagramPacket 对象
            // 从请求中获取客户端的 IP 和端口号,作为响应的目标地址
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress());
            socket.send(responsePacket);

            // 4.打印日志
            System.out.printf("[%s:%d] req: %s; resp: %s\n",
            requestPacket.getAddress(), requestPacket.getPort(), request, response);
        }
    }

    private String process(String request) {
        return request;
    }

    public static void main(String[] args) throws IOException {
        EchoServer server = new EchoServer(9090);
        server.start();
    }
}

        从上面的代码中,可以看出 UDP 的其中三个特性:

  • 无连接:socket 没有建立连接,直接可以使用 receive 和 send。
  • 面向数据报:传输的基本单位是 DatagramPocket。
  • 全双工:一个 socket 既可以接收也可以发送。

客户端

  • 客户端的 socket 不需要指定端口号,因为那是客户的电脑程序员也管不着,并且防止客户端的端口号冲突,所以不设置端口号,客户的设备上的操作系统会随机分配一个端口号。
  • 客户端作为主动发送请求的一方,需要给要发送的 DatagramPacket 设置服务器的 IP 和端口号
  • 127.0.0.1 本地回环 IP,当客户端和服务器在同一个主机上时,客户端可以用回环 IP 向本机的服务器发送请求。
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.util.Scanner;

public class EchoCilent {
    private DatagramSocket socket = null;
    private String serverIp;
    private int serverPort;

    public EchoCilent(String serverIp, int serverPort) throws SocketException {
        this.serverIp = serverIp;
        this.serverPort = serverPort;
        socket = new DatagramSocket();
    }

    public void start() throws IOException {
        System.out.println("客户端启动!");
        // 1. 从控制台接收用户输入
        Scanner scanner = new Scanner(System.in);
        System.out.printf("> ");
        String request = scanner.nextLine();
        // 2. 将字符串转换为 DatagramPacket 对象
        DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
            InetAddress.getByName(serverIp), serverPort);
        // 3. 发送请求到服务器
        socket.send(requestPacket);
        // 4. 从服务器接收响应
        DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
        socket.receive(responsePacket);
        // 5. 将响应转换为字符串
        String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
        // 6. 打印响应结果
        System.out.println(response);

        scanner.close();
    }

    public static void main(String[] args) throws IOException {
        EchoCilent client = new EchoCilent("127.0.0.1", 9090);
        client.start();
    }
}

       运行结果:

        如果想所有主机上的客户端都能向服务器发送请求,那么就得把打包好的服务器程序 jar 包放到云服务器上运行并设置防火墙,让服务器上指定端口的服务器程序能被外界访问。

        我们还可以写其他的服务器程序,将 process 的逻辑改为将单词翻译成英文的功能,但是 start 还是一样的,所以可以继承 EchoServer 重写 process 方法

import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;

public class DictServer extends EchoServer{
    private Map<String, String> dict = new HashMap<>();
    
    public DictServer(int port) throws SocketException {
        super(port);
        dict.put("小猫", "cat");
        dict.put("小狗", "dog");
        dict.put("小鸟", "bird");
        dict.put("小猪", "pig");
    }

    @Override
    public String process(String request) {
        return dict.getOrDefault(request, "未找到该单词的英文");
    }

    public static void main(String[] args) throws IOException {
        DictServer server = new DictServer(9090);
        server.start();
    }
}

三、TCP socket api

1、ServerSocket

        供服务器使用,不负责发送和接收数据,主要负责建立连接。TCP 不能直接读写数据,需要先建立连接。而建立连接的过程由操作系统内核完成,我们只需要调用方法把建立好的连接拿来使用。

构造方法

  • ServerSocket(int port):创建服务端流套接字,绑定指定端口。

其它方法

  • Socket accept():监听绑定的指定端口,有客户端连接后,返回一个服务端 Socket,使用该 Socket 与客户端建立连接;没有则阻塞等待
  • void close():关闭该套接字。

2、Socket

        供服务器和客户端使用,负责发送和接收数据TCP 以字节流为网络传输的基本单位,跟文件 IO 一致,所以 TCP 网络传输的读写也是通过 InputStream 和 OutputStream 展开。

构造方法

  • Socket(String host, int port):创建客户端流的套接字,与指定 IP 和端口的进行建立连接

其他方法

  • InetAddress getInetAddress():返回 Socket 连接的 IP。
  • InputStream getInputStream():返回 Socket 的输入流。
  • OutputStream getOutputStream():返回 Socket 的输出流。

3、实现一个回显功能的网络通信程序

服务器

  • ServerSocket 获取服务器套接字;serverSocket.accept() 获取与每个客户端建立了连接的套接字。

  • 外循环,不断处理不同的客户端连接。

  • socket.getInputStream()、socket.getOutputStream() 获取每个连接的字节输入流、输出流。

  • 为了省略解析请求数据为字符串、包装响应数据为字节格式的繁琐流程,使用 Scanner (System.in 也是属于 InputStream)和 PrintWriter 包装字节输入、输出流,直接以字符串的格式读取和写入。

  • 内循环,不断处理同一个客户端的多个请求响应操作。

  • 不同协议使用同一端口不会冲突

  • 因为读写内存比外存快得多,所以每次 println 只是将数据写入了缓冲区缓冲区满了才会自动写入外存上的网卡文件。所以我们需要 flush()手动刷新缓冲区,将剩余数据写入网卡。避免因响应数据全部写入了缓冲区,但缓冲区未满,而没有写入网卡,造成的没有响应发送给客户端的错误

  • 文件资源泄露问题。外循环内,会频繁处理与多个客户端的连接 Socket,如果处理完后不关闭,就会占满文件描述符导致后续连接失败,所以需要 close。而 UDP 中的服务器、客户端的 DatagramSocket ,以及 TCP 服务器的 ServerSocket、客户端的 Socket 因为是全局的,只有在程序结束前需要关闭,但是程序结束后会自动销毁文件描述符,相当于自动关闭了。而Scanner 和 PrintWriter 是为了包装 InputStream 和 OutputStream,实际打开的是与每个客户端连接的 Socket(try-with-resource),所以 Scanner、PrintWriter 不持有文件描述符。

  • 无法并发执行多个客户端的问题。 外循环处理不同的客户端连接,当第一个客户端连接的请求响应未结束,程序就会一直处于内循环中,导致第二个客户端的连接无法处理。所以我们要用到多线程,每处理一个客户端连接就开启一个线程。但是这样会导致线程频繁创建和销毁,所以我们又要用到线程池(注意,不要用 newFixedThreadPool,会限制客户端的并发数目。用 newCachedThreadPool,最大线程数目是 Integer.MAX_VALUE)。

  • 但是如果同一时刻有多个客户端连接,并且会持续存在一段时间,那么就会有大量线程,但操作系统能处理的线程数目并不是无限的。在线程数目受限的情况下,使用 IO 多路复用,让一个线程等待多个 socket,哪个 socket  有数据就去处理。这个的实现在 Java 标准库 NIO 中,很难用,还有第三方库 Netty 对 NIO 进行了封装和简化。但实际应用中很少会使用,因为像 Spring 这种框架已经在底层的 http 服务器的底层使用了 NIO。

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class EchoServer {
    private ServerSocket serverSocket;

    public EchoServer(int port) throws IOException {
        // 服务器流套接字,绑定端口
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException{
        System.out.println("server start!");
        ExecutorService executorService = Executors.newCachedThreadPool();
        while(true) {
            // 获取与客户端的连接套接字
            Socket socket = serverSocket.accept();
            // 处理连接,多线程并发执行
            // Thread thread = new Thread(() -> {
            //     processConnection(socket);
            // });
            // thread.start();

            // 线程池,避免频繁的线程销毁和创建
            executorService.submit(() -> {
                processConnection(socket);
            });
        }
    }
    
    private void processConnection(Socket socket) {
        System.out.printf("[%s:%d] cilent online!\n", socket.getInetAddress(), socket.getPort());
        // 打开网卡的字节输入、输出流
        try (InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()) {
            // 用 Scanner、PrintWriter 包装,避免繁琐的 字节数据与字符串的转换
            Scanner scanner = new Scanner(inputStream);
            PrintWriter printWriter = new PrintWriter(outputStream);
            // 内循环,处理同一个客户端连接的多个请求响应
            while(true) {
                // 读取客户端发送的请求,没有则退出
                if (!scanner.hasNext()) {
                    System.out.printf("[%s:%d] cilent offline!\n", socket.getInetAddress(), socket.getPort());
                    break;
                }
                String request = scanner.nextLine();
                // 计算响应
                String response = process(request);
                // 把响应写回客户端
                printWriter.println(response);
                // 刷新缓冲区,避免缓冲区未满的情况导致数据未写入网卡文件
                printWriter.flush();
                // 打印日志
                System.out.printf("[%s:%d] req: %s; resp: %s\n", socket.getInetAddress(), socket.getPort(), request, response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private String process(String request) {
        return request;
    }

    public static void main(String[] args) throws IOException {
        EchoServer echoServer = new EchoServer(9090);
        echoServer.start();
    }
}

客户端

  • 建立连接需要指定目标进程的 IP 和端口号。
  • 配置允许多个客户端进程并发执行:

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;

public class EchoCilent {
    private Socket socket;

    public EchoCilent(String serverIp, int serverPort) throws IOException {
        socket = new Socket(serverIp, serverPort);
    }

    public void start() {
        System.out.println("cilent start!");
        try (InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream();
            Scanner scanner = new Scanner(System.in)) {
            
            Scanner input = new Scanner(inputStream); // 用于读取服务器响应
            PrintWriter output = new PrintWriter(outputStream); // 用于发送请求
            
            // 进行多次发起请求,接收响应
            while(true) {
                // 接受用户输入,作为请求
                System.out.printf("> ");
                String request = scanner.nextLine();
                // 发送请求
                output.println(request);
                output.flush(); // 确保请求被立即发送
                if(!input.hasNextLine()) {
                    System.out.println("server disconnect!");
                    break;
                }
                // 读取服务器响应
                String response = input.nextLine();
                // 打印响应
                System.out.println(response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws IOException {
        EchoCilent echoCilent = new EchoCilent("127.0.0.1", 9090);
        echoCilent.start();
    }
}

运行结果: