Socket套接字编程(实现TCP和UDP的通信)

 

点进来你就是我的人了
博主主页:戳一戳,欢迎大佬指点!

人生格言:当你的才华撑不起你的野心的时候,你就应该静下心来学习!

欢迎志同道合的朋友一起加油喔
目标梦想:进大厂,立志成为一个牛掰的Java程序猿,虽然现在还是一个嘿嘿
谢谢你这么帅气美丽还给我点赞!比个心


目录

一.Socket概述

​ Socket通信是有两种方式的:TCP和UDP

TCP与UDP区别

socket之send和recv原理剖析

二. TCP通信客户端Socket

三. TCP通信服务器端ServerSocket

四.基于TCP的Socket通信

五.UDP相关类DatagramPacket类和DatagramSocket类

数据包类DatagramPacket

发送数据包类DatagramSocket

InetAddress类(无构造方法)

六.基于UDP的Socket通信

七. TCP和UCP的缓冲区

1.TCP的缓冲区

2.UDP的缓冲区



一.Socket概述

​ Socket(套接字),是网络上两个程序之间实现数据交换的一端,它既可以发送请求,也可以接受请求,一个Socket由一个IP地址和一个端口号唯一确定,利用Socket能比较方便的实现两端(服务端和客户端)的网络通信。

​ 在Java中,有专门的Socket类来处理用户请求和响应,学习使用Socket类方法,就可以实现两台机器之间通信。

​ Socket通信是有两种方式的:TCP和UDP

​ TCP通信:客户端提供了java.net.Socket类,服务器端提供了java.net.ServerSocket类。

​ UDP通信:UDP通信不建立逻辑连接,使用DatagramPacket类打包数据包,使用DatagramSocket类发送数据包。

TCP与UDP区别

  • TCP面向连接;UDP是无连接的,即发送数据之前不需要建立连接。
  • TCP提供可靠的服务。也就是说,通过TCP连接传送的数据,无差错,不丢失,不重复,且按序到达;UDP尽最大努力交付,即不保证可靠交付。
  • UDP具有较好的实时性,工作效率比TCP高,适用于对高速传输和实时性有较高的通信或广播通信。
  • TCP对系统资源要求较多,UDP对系统资源要求较少。
  • TCP面向字节流;UDP面向数据报,一次发送/接收都必须是完整的一个数据报或者多个数据报,不能是半个数据报,两者都是全双工,支持双向通信

Socket通信模型如下图:

Socket套接字编程(实现TCP和UDP的通信)_第1张图片

socket之send和recv原理剖析

当创建一个TCP socket对象的时候会有一个发送缓冲区和一个接收缓冲区,这个发送和接收缓冲区指的就是内存中的一片空间。

send原理剖析

send发数据,必须得通过网卡发送数据,应用程序是无法直接通过网卡发送数据的,它需要调用操作系统接口,也就是说,应用程序把发送的数据先写入到发送缓冲区(内存中的一片空间),再由操作系统控制网卡把发送缓冲区的数据发送给服务端网卡。

recv原理剖析

应用软件是无法直接通过网卡接收数据的,它需要调用操作系统接口,由操作系统通过网卡接收数据,把接收的数据写入到接收缓冲区(内存中的一片空间),应用程序再从接收缓存区获取客户端发送的数据。

二. TCP通信客户端Socket

​ Java中专门用来实现Socket客户端的类就叫Socket,这个类实现了客户端套接字,用于向服务器发出连接请求等。

  • 构造方法

    Socket(String host, int port):创建一个流套接字并将其连接到指定IP地址的指定端口号。

    如果host为null,则相当于指定地址为回送地址。

    127.x.x.x是本机的回送地址,即主机IP堆栈内部的IP地址,主要用于网络软件测试以及本地机进程间通信,无论什么程序,一旦使用回送地址发送数据,协议软件立即返回之,不进行任何网络传输。

  • 主要方法:

    • InputStream getInputStream():返回此套接字的输入流。

      关闭生成的InputStream也将关闭相关的Socket。

    • OutputStream getOutputStream():返回此套接字的输出流。

      关闭生成的OutputStream也将关闭相关的Socket。

    • void close():关闭此套接字

三. TCP通信服务器端ServerSocket

​ Java中专门用来建立Socket服务器的类叫ServerSocket,这个类实现了服务器套接字,该对象等待通过网络的请求。

  • 构造方法:

    ServerSocket(int port):创建绑定到特定端口的服务器套接字。

  • 主要方法:

    • Socket accept():监听并接受连接,返回一个新的Socket对象,用于和客户端通信,该方法会一直阻塞直到建立连接。

    • void close():关闭此套接字。

四.基于TCP的Socket通信

  1. 步骤分析:

    • 服务端先启动,创建ServerSocket对象,等待连接。
    • 客户端启动,创建Socket对象,请求连接。
    • 服务器端接收请求,调用accept方法,并返回一个Socket对象,连接成功
    • 客户端的Socket对象通过调用getOutputStream()方法获取OutputStream对象,并使用write()方法将数据写入到发送缓冲区。随后,通过调用flush()方法确保数据已被发送出去
    • 服务器端Socket对象通过调用getInputStream()方法获取与该socket关联的InputStream实例,然后使用read()方法从接收缓冲区中读取数据。
    • 客户端释放资源,断开连接。

服务器端:

public class TcpEchoServer {
    // serverSocket 就是外场拉客的小哥
    // clientSocket 就是内场服务的小姐姐.
    // serverSocket 只有一个. clientSocket 会给每个客户端都分配一个~
    private ServerSocket serverSocket = null;

    public TcpEchoServer(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        System.out.println("服务器启动!");
        while (true) {
            Socket clientSocket = serverSocket.accept();
            //服务器的主线程(main 线程)负责运行 while 循环,用于接收客户端的连接请求。
            // 与此同时,针对每个接收到的连接请求,都会创建一个新线程处理与该客户端的数据通信。这些新线程与主线程是并发执行的。
            //由于主线程和新创建的线程并发执行,服务器可以在处理一个客户端连接的同时,继续接收其他客户端的连接请求。
            // 这使得服务器可以并发处理多个客户端连接,提高了服务器的处理能力。
            // 创建新的线程, 用新线程来调用 processConnection
//            Thread t = new Thread(() -> {
//                try {
//                    processConnection(clientSocket);
//                } catch (IOException e) {
//                    e.printStackTrace();
//                }
//            });
//            t.start();

            //使用线程池
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    try {
                        processConnection(clientSocket);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
    }

    // 通过这个方法来处理一个连接.
    // 读取请求
    // 根据请求计算响应
    // 把响应返回给客户端
    private void processConnection(Socket clientSocket) throws IOException {
        System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress().toString(),
                clientSocket.getPort());
        try (InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()) {
            // 没有这个 scanner 和 printWriter, 完全可以!! 但是代价就是得一个字节一个字节扣, 找到哪个是请求的结束标记 \n
            // 不是不能做, 而是代码比较麻烦.
            // 为了简单, 把字节流包包装成了更方便的字符流~~
            Scanner scanner = new Scanner(inputStream);
            PrintWriter printWriter = new PrintWriter(outputStream);
            while (true) {
                // 1. 读取请求
                if (!scanner.hasNext()) {
                    // 读取的流到了结尾了 (对端关闭了)
                    System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress().toString(),
                            clientSocket.getPort());
                    break;
                }
                // 直接使用 scanner 读取一段字符串.
                String request = scanner.next();
                // 2. 根据请求计算响应
                String response = process(request);
                // 3. 把响应写回给客户端. 不要忘了, 响应里也是要带上换行的.
                printWriter.println(response);
                //数据此时还在缓存区,使用 flush() 方法,可以确保数据立即发送
                printWriter.flush();
                //clientSocket.getInetAddress().toString() 返回客户端的 IP 地址,clientSocket.getPort() 返回客户端的端口号,
                // request 是客户端发送的请求,response 是服务器响应的数据。
                // 通过格式化输出的方式将这些信息打印出来,方便程序员进行调试和查看。
                System.out.printf("[%s:%d] req: %s; resp: %s\n", clientSocket.getInetAddress().toString(),
                        clientSocket.getPort(), request, response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            clientSocket.close();
        }
    }

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

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

在服务器端我们使用了try-with-resources语句,确保在代码块执行完毕后自动关闭资源,无论代码执行过程中是否发生异常。

当程序执行到try语句块结束时,如果resource实现了AutoCloseableCloseable接口,那么close()方法将被自动调用。

同时在服务器端引入了多线程的写法,保证服务器能连接多个客户端,同时与多个客户端保持通信,具体实现逻辑如下:

服务器的主线程(main 线程)负责运行 while 循环,用于接收客户端的连接请求。
与此同时,针对每个接收到的连接请求,都会创建一个新线程处理与该客户端的数据通信。这些新线程与主线程是并发执行的。由于主线程和新创建的线程并发执行,服务器可以在处理一个客户端连接的同时,继续接收其他客户端的连接请求。 这使得服务器可以并发处理多个客户端连接,提高了服务器的处理能力。

 当然我们也可以引入线程池来优化这段代码:

  1. 线程池中的线程数量是动态调整的。
  2. 当有新任务提交时,如果线程池中有空闲线程,那么会复用空闲线程来执行新任务;如果没有空闲线程,则会创建一个新线程来执行新任务。
  3. 当线程池中的线程空闲时间超过一定时间(默认为 60 秒)时,线程池会回收这个空闲线程。

好处:线程池可以复用已经创建的线程,避免了频繁地创建和销毁线程所带来的性能开销。当有新任务到来时,线程池会优先使用空闲的线程,从而提高系统资源的利用率。

 客户端:

public class TcpEchoClient {
    private Socket socket = null;

    public TcpEchoClient(String serverIp, int port) throws IOException {
        // 这个操作相当于让客户端和服务器建立 tcp 连接.
        // 这里的连接连上了, 服务器的 accept 就会返回.
        socket = new Socket(serverIp, port);
    }

    public void start() {
        Scanner scanner = new Scanner(System.in);
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()) {
            //outputStream 是一个字节输出流,通过 PrintWriter 对象的构造方法将其包装成字符输出流,以便能够方便地写入字符数据。
            PrintWriter printWriter = new PrintWriter(outputStream);
            //scannerFromSocket 对象是将输入流对象包装成一个 Scanner 对象,以便能够方便地读取输入流中的数据。
            // Scanner 对象会自动解析和分隔输入流中的数据,并将其转换为相应的数据类型或字符串。
            Scanner scannerFromSocket = new Scanner(inputStream);

            while (true) {
                // 1. 从键盘上读取用户输入的内容.
                System.out.print("-> ");
                String request = scanner.next();
                // 2. 把读取的内容构造成请求, 发送给服务器.
                //在 Java 中,可以通过 PrintWriter 对象的 println() 方法发送带有换行符的数据包。
                // 该方法会将指定的字符串添加一个换行符,并将其发送到输出流中。
                printWriter.println(request);
                //数据此时还在缓存区,使用 flush() 方法,可以确保数据立即发送
                printWriter.flush();
                // 3. 从服务器读取响应内容
                //next() 方法会读取下一个标记,而不是一行数据。标记通常是以空格、制表符或换行符为分隔符的单词或符号。
                // 因此,在读取数据时,如果数据包中只有一个标记,则可以使用 next() 方法读取该标记。
                String response = scannerFromSocket.next();
                // 4. 把响应结果显示到控制台上.
                // request 是客户端发送的请求,response 是服务器响应的数据。
                // 通过格式化输出的方式将这些信息打印出来,方便程序员进行调试和查看。
                System.out.printf("req: %s; resp: %s\n", request, response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

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

 PrintWriter printWriter = new PrintWriter(outputStream);           

outputStream 是一个字节输出流,通过 PrintWriter 对象的构造方法将其包装成字符输出流,以便能够方便地写入字符数据。

Scanner scannerFromSocket = new Scanner(inputStream);

scannerFromSocket 对象是将输入流对象包装成一个 Scanner 对象,以便能够方便地读取输入流中的数据。
Scanner 对象会自动解析和分隔输入流中的数据,并将其转换为相应的数据类型或字符串。

五.UDP相关类DatagramPacket类和DatagramSocket类

  1. 数据包类DatagramPacket

    • 作用:用来封装发送端或接收端要发送或接收的数据。
    • 构造方法
      • DatagramPacket(byte[] buf, int length):构造DatagramPacket,用来接收长度为length的数据包。
      • DatagramPacket(byte[] buf, int length, InetAddress address, int port):构造数据报包,用来将长度为length的包发送到指定主机上的指定端口号。
    • 常用方法
      • public int getLength():获得发送端实际发送的字节数或接收端世界接收的字节数
      • public int getPort():获得发送端或接收端端口号
  2. 发送数据包类DatagramSocket

    • 作用:用来发送和接收数据包对象
    • 构造方法
      • DatagramSocket():构造数据报套接字并将其绑定到本地主机上任何可用的端口。
      • DatagramSocket(int port):创建数据包套接字并将其绑定到本地主机上指定端口。
    • 常用方法
      • public void send(DatagramPacket p):从此套接字发送数据报包
      • public void receive(DatagramPacket p):从此套接字接收数据报包
      • public void close():关闭此数据报套接字
  3. InetAddress类(无构造方法)

    • 作用:代表一个IP地址
    • 静态方法
      • public static InetAddress getLocalHost():返回本地主机
      • public static InetAddress getByName():在给定主机名的情况下确定主机的 IP 地址。
    • 普通方法
      • public String getHostName(): 获取此 IP 地址的主机名。
      • public String getHostAddress():返回 IP 地址字符串(以文本表现形式)

六.基于UDP的Socket通信

  1. 步骤分析

    • 服务器端先启动,创建DatagramSocket对象,监听端口,用于接收
    • 服务器端创建DatagramPacket对象,打包用于接收的数据包
    • 服务器阻塞等待接收
    • 客户端启动,创建DatagramSocket对象,监听端口,用于接收
    • 客户端创建DatagramPacket对象,打包用于发送的数据包
    • 客户端发送数据,服务端接收
    • 服务端接收数据后,创建DatagramPacket对象,打包用于发送的数据包,发送数据
    • 客户端创建DatagramPacket对象,打包用于接收的数据包,阻塞等待接收
    • 客户端接收服务端数据,断开连接,释放资源

Socket套接字编程(实现TCP和UDP的通信)_第2张图片

 

服务器端:

public class UdpEchoServer {
    // 需要先定义一个 socket 对象.
    // 通过网络通信, 必须要使用 socket 对象.
    private DatagramSocket socket = null;

    // 绑定一个端口, 不一定能成功!!
    // 如果某个端口已经被别的进程占用了, 此时这里的绑定操作就会出错.
    // 同一个主机上, 一个端口, 同一时刻, 只能被一个进程绑定.
    public UdpEchoServer(int port) throws SocketException {
        // 构造 socket 的同时, 指定要关联/绑定的端口.
        socket = new DatagramSocket(port);
    }

    // 启动服务器的主逻辑.
    public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true) {
            // 每次循环, 要做三件事情:
            // 1. 读取请求并解析
            //    构造空饭盒
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
            //    食堂大妈给饭盒里盛饭. (饭从网卡上来的)
            socket.receive(requestPacket);
            //    为了方便处理这个请求, 把数据包转成 String
            String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
            // 2. 根据请求计算响应(此处省略这个步骤)
            String response = process(request);
            // 3. 把响应结果写回到客户端
            //    根据 response 字符串, 构造一个 DatagramPacket .
            //    和请求 packet 不同, 此处构造响应的时候, 需要指定这个包要发给谁.
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
                    // requestPacket 是从客户端这里收来的. getSocketAddress 就会得到客户端的 ip 和 端口
                    requestPacket.getSocketAddress());
            socket.send(responsePacket);
            System.out.printf("[%s:%d] req: %s, resp: %s\n", requestPacket.getAddress().toString(),
                    requestPacket.getPort(), request, response);
        }
    }

    // 这个方法希望是根据请求计算响应.
    // 由于咱们写的是个 回显 程序. 请求是啥, 响应就是啥!!
    // 如果后续写个别的服务器, 不再回显了, 而是有具体的业务了, 就可以修改 process 方法,
    // 根据需要来重新构造响应.
    // 之所以单独列成一个方法, 就是想让同学们知道, 这是一个服务器中的关键环节!!!
    public String process(String request) {
        return request;
    }

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

UdpEchoServer(UDP 服务器):

a. 首先,创建一个 DatagramSocket 对象,并绑定到指定的端口。这个端口用于服务器与客户端之间的通信。

b. 在服务器的主循环中,首先创建一个空的 DatagramPacket 对象,用于接收客户端发来的请求数据。

c. 调用 socket.receive(requestPacket) 方法接收客户端发来的数据包。

d. 将收到的数据包中的数据转换成字符串形式,并调用 process() 方法生成响应。在这个例子中,响应就是原请求。

e. 创建一个新的 DatagramPacket 对象,包含响应数据和客户端的地址信息。

f. 使用 socket.send(responsePacket) 方法将响应数据包发送回客户端。

g. 打印请求和响应信息。

 客户端:

public class UdpEchoClient {
    private DatagramSocket socket = null;
    private String serverIP;
    private int serverPort;

    // 客户端启动, 需要知道服务器在哪里!!
    public UdpEchoClient(String serverIP, int serverPort) throws SocketException {
        // 对于客户端来说, 不需要显示关联端口.
        // 不代表没有端口, 而是系统自动分配了个空闲的端口.
        socket = new DatagramSocket();
        this.serverIP = serverIP;
        this.serverPort = serverPort;
    }

    public void start() throws IOException {
        // 通过这个客户端可以多次和服务器进行交互.
        Scanner scanner = new Scanner(System.in);
        while (true) {
            // 1. 先从控制台, 读取一个字符串过来
            //    先打印一个提示符, 提示用户要输入内容
            System.out.print("-> ");
            String request = scanner.next();
            // 2. 把字符串构造成 UDP packet, 并进行发送.
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
                    InetAddress.getByName(serverIP), serverPort);
            socket.send(requestPacket);
            // 3. 客户端尝试读取服务器返回的响应
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(responsePacket);
            // 4. 把响应数据转换成 String 显示出来.
            //这行代码将接收到的字节流数据按照指定的编码格式转换成字符串,方便我们查看和处理数据。
            String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
            System.out.printf("req: %s, resp: %s\n", request, response);
        }
    }

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

UdpEchoClient(UDP 客户端):

a. 创建一个 DatagramSocket 对象,不需要显式地关联端口,系统会自动分配一个空闲的端口。

b. 在客户端的主循环中,从控制台读取用户输入的字符串作为请求。

c. 创建一个 DatagramPacket 对象,包含请求数据、服务器的 IP 地址和端口信息。

d. 调用 socket.send(requestPacket) 方法将请求数据包发送给服务器。

e. 创建一个空的 DatagramPacket 对象,用于接收服务器返回的响应数据。

f. 调用 socket.receive(responsePacket) 方法接收服务器发来的响应数据包。

g. 将收到的响应数据包中的数据转换成字符串形式,并打印请求和响应信息。

总结:这两段代码实现了一个简单的 UDP 回显服务器和客户端。客户端将用户输入的请求数据通过 UDP 协议发送给服务器,服务器接收到请求后原样返回响应,客户端接收响应并打印信息。整个过程使用无连接的 UDP 协议进行通信。

七. TCP和UCP的缓冲区

1.TCP的缓冲区

创建一个TCP的socket,同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
调用write时,数据会先写入发送缓冲区中;
如果发送的字节数太长,会被拆分成多个TCP的数据包发出;
如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适
的时机发送出去;
接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区;
然后应用程序可以调用read从接收缓冲区拿数据;
另一方面,TCP的另一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既
可以读数据,也可以写数据。这个概念叫做 全双工

2.UDP的缓冲区

UDP只有接收缓冲区,没有发送缓冲区:
UDP没有真正意义上的 发送缓冲区。发送的数据会直接交给内核,由内核将数据传给网络层协议
进行后续的传输动作;
UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一
致;如果缓冲区满了,再到达的UDP数据就会被丢弃;

你可能感兴趣的:(网络,java,服务器)