• 基于 Socket 网络编程


    前言

    我们再进行网络编程时,主要是编写“应用层”代码,如果真正要发送这个数据,需要上层协议调用下层协议,也就是应用层调用传输层,传输层给应用层提供一组 API,统称为 Socket API

    一、基于Socket的网络通信传输(传输层)

    Socket 套接字,是由系统提供用于网络通信的技术,是基于 TCP/IP 协议的网络通信的基本操作单元。基于Socket 套接字的网络程序开发就是网络编程。

    在这一块,我们对于传输层协议,主要学习两种 Socket 套接字:

    数据报套接字:使用传输层UDP协议。UDP,即 User Datagram Protocol(用户数据报协议),传输层协议。
    以下为UDP的特点(细节后续介绍):

    1. 无连接:使用 udp 通信的双方,不需要刻意保存对端的相关信息。
    2. 不可靠传输:不关注结果
    3. 面向数据报:以一个 udp 数据报为基本单位
    4. 全双工:双向通信(有接收缓冲区,无发送缓冲区)
    5. 大小受限:一次最多传输 64k

    流套接字:使用传输层TCP协议。TCP,即 Transmission Control Protocol(传输控制协议),传输层协议。
    以下为TCP的特点(细节后续介绍):

    1. 有连接:使用 TCP 通信双方,则需要刻意各自记录了对方的信息
    2. 可靠传输:发送后尽可能的传输过去,失败了也知道
    3. 面向字节流:以字节流为传输的基本单位,读写方式非常灵活
    4. 全双工:双向通信(有接收缓冲区,也有发送缓冲区)
    5. 大小不限

    二、UDP 的数据报套接字编程

    DatagramSocket 是UDP Socket,用于发送和接收UDP数据报。

    这里的 Socket 我们可以类比于 File 对象理解,我们知道如果我们是不能直接操作硬盘的,如果想要操作硬盘就需要借助 File。对象间接操作。Socket 也是类似,它对应于网卡这个硬件设备,如果我们想要操作网卡,就需要一个 Socket 对象间接操作网卡。向 socket 对象中1写数据,就相当于通过网卡发送消息。从 socket 对象中读数据,就相当于通过网卡接收消息。

    1、UDP 套接字编程 API

    (1)DatagramSocket
    DatagramSocket 构造方法

    方法签名方法说明
    DatagramSocket()创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端)
    DatagramSocket(int port)创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端)

    说明:对于服务器,一般要手动指定一个固定的端口,客户端则不要求。类似于我去食堂吃饭,给我提供食物的窗口就是一个服务器,而我是接收食物的消费者,可看做是客户端,窗口需要有一个固定的窗口号,便于我找到它,而我在享受食物时,没有固定的座位,哪里有空位就做哪里。

    DatagramSocket 方法

    方法签名方法说明
    void receive(DatagramPacket p)从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待)
    void send(DatagramPacket p)从此套接字发送数据报包(不会阻塞等待,直接发送)
    void close()关闭此数据报套接字

    (2)DatagramPacket
    DatagramPacket 是 UDP Socket 发送和接收的数据报

    DatagramPacket构造方法

    方法签名方法说明
    DatagramPacket(byte[] buf, int length)构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度(第二个参数length)
    DatagramPacket(byte[] buf, int offset, int length)以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收从offset到指定长度length
    DatagramPacket(byte[] buf, int length, InetAddress address, int port)用来发送数据报,发送的数据为字节数组(第一个参数buf)中,length 为数据长度,address为目标主机的地址,port为目标主机的端口号。
    DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port)用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从offset到 length 为数据长度,address为目标主机的地址,port为目标主机的端口号。
    DatagramPacket(byte[] buf, int length, SocketAddress address)用来发送数据报,发送的数据为字节数组(第一个参数buf)中,length 为数据长度。
    DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从offset到指定长度length。address指定目的主机的IP和端口号

    DatagramPacket方法

    方法签名方法说明
    InetAddress getAddress()从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址
    int getPort()从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号
    byte[] getData()获取数据报中的数据

    2、使用 UDP Socket 实现简单通信

    下面我们在 Java 中使用 UDP 协议实现的一个简单的客户端和服务端的通信。

    下面的 服务器-客户端 代码看起来挺复杂,其实和数据库中的 JDBC 差不多,都是固定的套路,尽管之后在写更复杂的 服务器-客户端 程序,也都是在这个基础上拓展。例如下面的 UDP Socket 通过传输层实现网络通信,代码无非就下面几个步骤:

    1.对于服务器:
    (1)读取请求并解析
    (2)根据请求计算响应
    (3)把响应结果发送到客户端
    2.对于客户端:
    (1)构造并发送请求
    (2)接收服务器返回的响应并解析响应

    服务器程序:

    import java.io.IOException;
    import java.net.DatagramPacket;
    import java.net.DatagramSocket;
    import java.net.SocketException;
    
    // Echo-回显服务器。客户端发了个请求,服务器返回一个一模一样的响应。
    public class UdpEchoSever {
        // 需要先定义一个 socket 对象,使用网络通信,必须要使用 socekt 对象
        private DatagramSocket socket = null;
        // 绑定一个端口号,不一定能成功,比如某个端口号已经被别的进程占用了,此时这里的绑定操作就会出错。
        // 需要注意的是:同一个主机上,一个端口,同一时刻,只能被一个进程绑定。
        public UdpEchoSever(int port) throws SocketException {
            socket = new DatagramSocket(port);
        }
    
        // 启动服务器主逻辑
        public void start() throws IOException {
            System.out.println("服务器启动!");
            while (true) {
                // 每次循环,做三件事
                // 1. 读取请求并解析
                //    构造一个空的DatagramPacket对象,用来接收客户端请求
                DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
                //    从网卡上接收请求 此处的 requestPacket 为输出型参数
                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);
                // 方面查看,打印一下日志
                // ip 和 端口号 + 请求内容 + 响应内容
                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 {
            UdpEchoSever udpEchoSever = new UdpEchoSever(9090);
            udpEchoSever.start();
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    客户端程序:

    import java.io.IOException;
    import java.net.*;
    import java.util.Scanner;
    
    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 {
        	// 127.0.0.1 是一个特殊的IP地址,表示本机的回环地址。
            UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);
            udpEchoClient.start();
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    对于UDP Echo Sever 来说,socket 对象的生命周期伴随整个程序的不需要 close。这个 socket 对象是出了循环就不用了,但是循环结束就意味着 start 结束,意味着 main 方法结束,意味着进程结束,进程结束所有文件资源就自动释放了。

    三、TCP 流套接字编程

    1、TCP 流套接字编程 API

    (1)SeverSocket
    ServerSocket 是创建TCP服务端Socket的API。

    ServerSocket构造方法

    方法签名方法说明
    ServerSocket(int port)创建一个服务端流套接字Socket,并绑定到指定端口

    ServerSocket 方法

    方法签名方法说明
    Socket.accept()开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待
    void close()关闭此套接字

    (2)Socket
    Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。

    不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。

    Socket 构造方法

    方法签名方法说明
    Socket(String host, intport)创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接

    Socket 方法

    方法签名方法说明
    int getPort()返回此套接字连接到的远程端口号
    InetAddress getInetAddress()返回套接字所连接的地址
    InputStream getInputStream()返回此套接字的输入流
    OutputStream getOutputStream()返回此套接字的输出流
    void close()关闭此套接字

    2、使用 TCP Socket 实现简单通信

    下面我们同样写一个简单的基于 TCP 协议实现的回显服务器和客户端,用户可以通过客户端向服务器发送请求并接收响应。

    服务器程序:

    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 TcpEchoSever {
        // 这里有个比喻:
        // severSocket 看做是外场拉客的小哥
        // clientSocket 看做内场服务的小姐姐
        // severSocket 只有一个,clientSocket 会给每个客户端都分配一个
    
        private ServerSocket serverSocket = null;
    
        public TcpEchoSever(int port) throws IOException {
            serverSocket = new ServerSocket(port);
        }
    
        public void start() throws IOException {
            System.out.println("服务器启动!");
            ExecutorService pool = Executors.newCachedThreadPool();
            while (true) {
                Socket clientSocket = serverSocket.accept();
                // 如果直接调用,该方法会影响这个循环的二次执行,导致 accept 不及时
                // 创建新线程,用新线程调用 processConnection
                // 每次来一个新的客户端都创建一个新线程
    
                // 1.方案一:每次创建线程(每次创建销毁,开销较大)
    //            Thread t = new Thread(()->{
    //                processConnection(clientSocket);
    //            });
    //            t.start();
    
                // 2.方案二:使用线程池
                pool.submit(()->{
                    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 () 这种写法,( ) 中允许写多个流对象,使用 ; 分割
            try (InputStream inputStream = clientSocket.getInputStream();
                 OutputStream outputStream = clientSocket.getOutputStream()) {
                // 为了简单,把字节流包装成了更方便的字符流
                Scanner scanner = new Scanner(inputStream);
                PrintWriter printWriter = new PrintWriter(outputStream);
    
                // 一次可能发来多个请求,这里规定以 \n 为分隔符
                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();
                    // 最后打印一下日志
                    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();
            }
        }
        
        // 处理请求
        public String process(String request) {
            return request;
        }
    	// 主方法
        public static void main(String[] args) throws IOException {
            TcpEchoSever tcpEchoSever = new TcpEchoSever(9090);
            tcpEchoSever.start();
        }
    
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103

    客户端程序:

    import java.io.IOException;
    import java.io.InputStream;
    import java.io.OutputStream;
    import java.io.PrintWriter;
    import java.net.Socket;
    import java.util.Scanner;
    
    // idea 中默认一个程序只能启动一个,启动多个客户端可配置一下 IDEA。
    
    public class TcpEchoClient {
        private Socket socket = null;
    
        // ***只有这里会建立连接,和 Udp 不同***
        public TcpEchoClient(String severIp, int port) throws IOException {
            // 这个操作就相当于让客户端和服务器建立 TCP 连接
            // 这里的链接连上了,accept 就会返回
            socket = new Socket(severIp,port);
        }
    
        public void start() {
            Scanner scanner = new Scanner(System.in);
            try (InputStream inputStream = socket.getInputStream();
                 OutputStream outputStream = socket.getOutputStream()) {
                // 将字节流包装成字符流
                Scanner scannerFromSocket = new Scanner(inputStream);
                PrintWriter printWriter = new PrintWriter(outputStream);
                while (true) {
                    // 1.从键盘上读取用户输入的内容
                    System.out.print("->");
                    String request = scanner.next();
                    // 2.把读取的内容构成请求,发给服务器
                    //   注意:这里的发送,是带换行的!
                    printWriter.println(request);
                    // 写网卡为全缓冲,这里使用flush刷新
                    printWriter.flush();
                    // 3.从服务器读取响应内容
                    String response = scannerFromSocket.next();
                    // 4. 把响应的结果显示到控制台上
                    System.out.printf("req: %s ; resq: %s\n",request,response);
                }
    
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    	// 主方法
        public static void main(String[] args) throws IOException {
            TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
            tcpEchoClient.start();
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52

    3、使用 Tcp 协议进行网络传输的“五大要点”

    (1)自定义简单的应用层协议
    对于客户端及服务端应用程序来说,请求和响应,需要约定一致的数据格式,上述为了简单,做了如下简单约定:

    1. 每个请求是个字符串
    2. 请求和请求之间,使用\n(换行符)分割

    由于是回显服务器,响应和请求是一模一样的,因此也遵循上述规则。

    (2)写网卡是全缓冲(写文件也是全缓冲)

    为了提高IO效率,引入了缓冲区,使用缓冲区可以减少IO次数,提高整体的效率。上述 printWriter.println(“内容”) 过后,内容就被写入到了缓冲区,如果不刷新缓冲区,就要等到缓冲区满,自动刷新到网卡中,所以执行上述程序可能就会出现只请求不响应的情况,为了解决这个问题,我们可以在每次写网卡后,手动进行刷新:printWriter.flush()

    (3)长连接 与 短连接

    长连接和短连接是指在网络编程中不同的连接方式。

    短连接指客户端与服务器建立连接后,在完成一次请求-响应操作之后就会断开连接。每次请求都需要重新建立连接,这种方式可以保证连接使用的资源较少,但也对服务器的压力较大。常用于小数据量的频繁通信场景,例如HTTP协议。

    而长连接则是指客户端与服务器建立连接后,在一段时间内可以保持连接状态,多次请求-响应操作共用这一个连接。这种方式相对于短连接可以减少连接建立、关闭的次数,提高了通信效率,但是缺点是需要维护连接的状态,如果长时间没有交互,则需要进行心跳检测等机制来维持连接状态。常用于对实时性要求较高的通信场景,例如即时通讯、游戏等。

    在上述TCP协议中使用到长连接。

    (4)使用多线程

    上述例子的服务器中使用到了多线程,如果不使用多线程,代码可能产生 BUG。因为上述 start 的 while 循环,是用来循环的接收连接,而下面的 processConnection 内部也有一个循环用来循环的处理连接。假设现在来了一个连接,start 方法接收连接后其中的 processConnection 就开始循环的处理这个连接,直到这个连接关闭,但是如果这个期间又有别的客户端进行新的连接,由于当前start中的第一次循环还没结束,就会导致一直阻塞,使其他连接处理不及时。为了解决上述问题,一个很好的办法就是使用多线程,为每个连接都分配一个线程独立处理。

    (5)频繁创建,生命周期又短资源的需要 close 及时释放

    • 像上述使用 UDP 协议进行网络通信这种,生命周期伴随整个程序的不需要 close。
    • 在这里,使用 TCP 进行网络通信时,服务器那里的每个 Socket 对象只是给一个连接提供服务的,可能会有很多个连接。在这种情况下,服务器会为每个连接都创建一个新的 Socket 对象,作为后续通信的基础。当这个连接不再需要服务时,需要将相应的 Socket 对象关闭,以便及时释放资源。
  • 相关阅读:
    Python装饰器与面向切面编程
    Linux 服务器下 pypy 下载数据集
    DC/DC开关电源学习笔记(六)开关电源电路集成及封装工艺
    地塞米松-PLGA纳米粒|共载吉西他滨和辛伐他汀的PLGA聚合物纳米粒|艾地苯醌-PLGA纳米粒(齐岳)
    Houdini 地形知识点
    如何利用ChatGPT提升学术论文写作效率
    Hibiki Run 市场火爆,或引发新一轮“Listen to Earn”热潮
    浅谈安科瑞ADL系列导轨式多功能仪表在迪拜楼宇EMS中的应用
    (栈)剑指 Offer 09. 用两个栈实现队列(java)
    加密大崩盘,Web3游戏到底还有没有未来?5篇论文深度探讨
  • 原文地址:https://blog.csdn.net/LEE180501/article/details/133145429