• 【JavaEE网络】TCP套接字编程详解:从概念到实现



    TCP流套接字编程

    TCP用的协议比UDP更多,可靠性

    提供的api主要有两个类ServerSocket(给服务器使用的socket),Socket(既会给服务器使用也会给客户端使用)

    字节流:一个字节一个字节进行传输的
    一个tcp数据报,就是一个字节数组byte[]

    ServerSocket API

    ServerSocket 是创建TCP服务端Socket的API。

    ServerSocket 构造方法:

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

    ServerSocket 方法:

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

    Socket API

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

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

    Socket 构造方法:

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

    Socket 方法:

    方法签名方法说明
    InetAddress getInetAddress()返回套接字所连接的地址
    InputStream getInputStream()返回此套接字的输入流
    OutputStream getOutputStream()返回此套接字的输出流

    TCP回显客户端服务器

    TCP版本的回显服务器,进入循环之后要做的事情不是读取客户端的请求,而是先处理客户端的“连接”

    服务器代码流程:

    1. 读取请求并分析
    2. 根据请求计算响应
    3. 把响应写回客户端
    //处理连接的过程=>此时可能客户端还没来,accept就阻塞等待了
    Socket clientSocket = serverSocket.accept();
    //把内核中的连接获取到应用程序中了=>这个过程类似于“生产者消费者模型”
    
    • 1
    • 2
    • 3

    accept 是把内核中已经建立好的连接,给拿到应用程序中。

    但是这里的返回值并非是一个"Connection"这样的对象,而只是一个 Socket 对象,这个 Socket 对象就像一个耳麦一样,就可以说话,也能听到对方的声音

    通过 Socket 对象和对方进行网络通信

    一次0,主要是经历两个部分

    1. 等(阻塞)
    2. 拷贝数据

    此处是先握手吗?

    不是!握手是系统内核负责的.写代码过程感知不到握手的过程

    此处主要是处理连接,也就是握手之后得到的东西

    一个服务器,要对应很多客户端,服务器内核里有很多客户端的连接。虽然内核中连接很多,但是在应用程序中,还是得一个一个的处理的。

    内核中的“连接”就像一个一个“待办事项“。这些待办事项在一个 队列 的数据结构中。应用程序就需要一个一个完成这些任务

    而要完成任务,就需要先取任务

    TCP中涉及到两种socket

    1. serverSocket
    2. clientSocket
    Socket clientSocket = serverSocket.accept();
    //TCP通信能实现两台计算机之间的数据交互,通信的两端要严格区分为客户端(Client)与服务端(Server)
    
    • 1
    • 2

    通过processConnection这个方法(自己实现)来处理一个连接的逻辑

    try (InputStream inputStream = clientSocket.getInputStream();//相当于耳麦(clientSocket)的耳机(inputStream)
         OutputStream outputStream = clientSocket.getOutputStream()) {//相当于耳麦(clientSocket)的麦克风(outputStream)
    
    • 1
    • 2
    public class TcpEchoServer {
        private ServerSocket serverSocket = null;
        //此处不应该创建一个固定线程数目的线程池,不然就有上限了
        private ExecutorService service= Executors.newCachedThreadPool();
    
        //这个操作就会绑定端口号
        public TcpEchoServer(int port) throws IOException {
            serverSocket = new ServerSocket(port);
        }
    
        //启动服务器
        public void start() throws IOException {
            System.out.println("服务器启动");
            while (true) {
                //处理连接的过程=>此时可能客户端还没来,accept就阻塞等待了
                Socket clientSocket = serverSocket.accept();
                //把内核中的连接获取到应用程序中了
    
                processConnection(clientSocket);
    
            }
        }
    
        //通过这个方法来处理一个连接的逻辑
        private void processConnection(Socket clientSocket) {
            System.out.printf("[%s:%d] 客户端上线\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
            //接下来就可以 读取请求,根据请求计算响应,返回响应 三步走了
            // Socket 对象内部包含了两个字节流对象,可以把这两字节流对象,完成后续的读写工作
            try (InputStream inputStream = clientSocket.getInputStream();
                 OutputStream outputStream = clientSocket.getOutputStream()) {
                //一次连接中,可能会涉及到多次请求/响应
                while (true) {
                    //1、读取请求并解析,为了读取方便,直接使用Scanner
                    Scanner sc = new Scanner(inputStream);
                    if (!sc.hasNext()) {
                        //读取完毕,客户端下线了
                        System.out.printf("[%s:%d] 客户端下线\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
                        break;
                    }
                    //这个代码暗含一个约定,客户端发过来的请求得是文本数据,同时还得带有空白符作为分割,(比如换行这种)
                    String request = sc.next();
                    //2、根据请求计算响应
                    String response = process(request);
                    //3、把响应写回客户端,把OutputStream使用PrinterWriter包裹一下,方便进行发数据
                    PrintWriter writer = new PrintWriter(outputStream);
                    //  使用PrintWriter的println方法把响应返回给客户端
                    //  此处用println而不是print就是为了在结尾加上\n,方便客户端读取响应,使用Scanner.next读取
                    writer.println(response);
                    //  还需要加入一个"刷新缓冲区"操作
    
                    //网络程序讲究的就是客户端和服务器能“配合”
    
                    writer.flush();//“上完厕所冲一下”。
                    //这里加上 flush 更稳妥,不加也不一定就出错!!缓冲区内置了一定的刷新策略
                    //比如缓冲区满了,就会触发刷新; 再比如,程序退出,也会触发刷新......推荐大家把 flush 刷新给加上
    
                    /*
                    IO操作是比较有开销的,相比于访问内存。进入IO操作次数越多,程序的速度越慢。
                    方法:使用一块内存作为缓冲区,写数据的时候,先写到缓冲区,攒一波数据,统一进入IO。
                    PrintWriter内置了缓冲区,手动刷新,确保这里的数据是真的通过网卡发出去了,而不是残留在内存缓冲区中
                     */
    
                    //日志,打印当前的请求详情
                    System.out.printf("[%s:%d] req: %s, resp: %s\n", clientSocket.getInetAddress().toString(), clientSocket.getPort(),
                            request, response);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        public String process(String request) {
            return request;
        }
    
        public static void main(String[] args) throws IOException {
            TcpEchoServer server = new TcpEchoServer(9090);
            server.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

    以上代码有两个问题:

    但没有线程安全问题,因为没有多线程

    1、关闭

    在这个程序中,设计到两类Socket:

    1. ServerSocket(只有一个,生命周期跟随程序,不关闭也没事)
    while (true) {
        //处理连接的过程=>此时可能客户端还没来,accept就阻塞等待了
        Socket clientSocket = serverSocket.accept();
        //把内核中的连接获取到应用程序中了
    
        processConnection(clientSocket);
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    1. 而Socket在1w个客户端就有1w个Socket,此处的Socket是被反复创建的。因此要确保在连接断开后,socket能被关闭
    catch (IOException e) {
        e.printStackTrace();
    } finally {
        //在finally中加入close,确保当前socket被及时关闭
        try {
            clientSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    //在trycatch后关闭socket
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    写到这以上代码中对于Scanner和PrintWriter没有close是否会有文件资源泄露呢?不会

    因为流对象持有的资源有两个部分:

    1. 内存(对象销毁,内存就回收了)while循环一圈内存自然销毁
    2. 文件描述符(scanner和printWriter没有持有文件描述符;持有的是inputStream和outputStream的引用,这两个进行关闭了,或者更准确的说是socket对象持有的,socket对象关闭了就ok)

    不是每个流对象都持有文件描述符,持有文件描述符是要调用操作系统提供的open方法(系统调用,是要在内核完成的,相当重量/严肃的事情)

    2、第二个问题是在写完客户端后再说

    客户端代码流程:

    1. 从控制台读取用户的输入
    2. 把输入的内容构造请求并发送给服务器
    3. 从服务器读取响应
    4. 把响应显示到控制台上
    public class TcpEchoClient {
        private Socket socket=null;
    
        //要和服务器通信,就需要先知道,服务器所在的位置
        public TcpEchoClient(String serverIp,int serverPort) throws IOException {
            //完成这个new操作就完成了tcp连接的建议
            socket=new Socket(serverIp,serverPort);
        }
    
        public void start(){
            System.out.println("客户端启动");
            Scanner scConsole=new Scanner(System.in);
            try(InputStream inputStream=socket.getInputStream();
                OutputStream outputStream=socket.getOutputStream()){
                while(true){
                    //1、从控制台输入一个字符串
                    System.out.print("-> ");
                    String request = scConsole.next();
                    //2、把请求发送给服务器
                    PrintWriter printWriter=new PrintWriter(outputStream);
                    //使用println带上换行,后续服务器读取请求,就可以使用Scanner.next来读取了
                    printWriter.println(request);
                    //别忘记flush,确保数据真的发出去了
                    printWriter.flush();
                    //3、从服务器读取响应
                    Scanner scNetwork=new Scanner(inputStream);
                    String response = scNetwork.next();
                    //4、把响应打印出来
                    System.out.println(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();
        }
    }
    
    • 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
    //以目前以上的代码执行结果
    //服务器
    服务器启动
    [/127.0.0.1:63945] 客户端上线
    [/127.0.0.1:63945] req: 你好, resp: 你好
    [/127.0.0.1:63945] req: hello, resp: hello
    [/127.0.0.1:63945] 客户端下线
    //客户端
    客户端启动
    -> 你好
    你好
    -> hello
    hello
    -> //这里客户端退出了
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    回到上面遗留的第二个问题

    现象:当第一个客户端连接好了之后,第二个客户端不能正确被处理,服务器看不到客户端上线,同时客户端发来的请求也无法被处理,当第一个客户端退出之后,之前第二个客户端发的请求,就能正确响应了。

    问题出在:在服务器这边,当一个客户端来了,accept就可以正确返回,进入processConnection,然后进入循环处理该客户端的请求,一直等到这个客户端结束,才能回到start方法中

    问题关键在于,处理一个客户端的请求过程中,无法第二次调用accept(即使第二个客户端来了也无法处理)

    我们期望能够同时让多个客户端进入调用accept,因此用到多线程

    主线程负责找到客户端,在有客户端到来时,创建新的线程,让新的线程负责处理客户端的各种请求

    以下是改进方式:

    //启动服务器
    public void start() throws IOException {
        System.out.println("服务器启动");
        while (true) {
            //处理连接的过程=>此时可能客户端还没来,accept就阻塞等待了
            Socket clientSocket = serverSocket.accept();
            //把内核中的连接获取到应用程序中了
    
            //单个线程不太方便同时完成多个任务,因此要多线程,主线程主要负责寻找客户端,每有一个客户端就创建一个新的线程
        /*
            Thread t=new Thread(()->{
                processConnection(clientSocket);
            });
            t.start();
        */
            //使用线程池
            service.submit(new Runnable() {
                @Override
                public void run() {
                    processConnection(clientSocket);
                }
            });
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    TCP程序的时候,涉及到两种写法:

    1. 一个连接中只传输一次请求和响应(短连接)
    2. 一个连接中可以传输多次请求和响应(长连接)

    而由于我们只是通过普通的方式创建线程,有一个连接就创建一个线程,如果有多个客户端,频繁连接/断开,服务器就频繁创建/释放线程了,因此我们直接采用线程池的方式

    注意:我们上述的不能写成这种方式

    try(Socket clientSocket = serverSocket.accept()){
        service.submit(new Runnable() {
            @Override
            public void run() {
                processConnection(clientSocket);
            }
        });
    };
    /*
    processConnection 和主线程就是不同线程了
    执行 processConnection 过程中,主线程 try 就执行完毕了。
    这就会导致 clientSocket 还没用完呢,就关闭了
    因此,还是要把clientSocket交给processConnection里来关闭
    */
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    虽然使用了线程池,避免了频繁创建销毁线程,但如果仍然很多客户端,创建大量线程仍有很大开销,可以称为高并发

    方法:解决高并发引入了很多技术手段,IO多路复用/IO多路转接(但不是说用了一下子就解决了)

    解决高并发(四个字):

    1. 开源:引入更多的硬件资源(本质上是减少线程的数量)
    2. 节流:提高单位硬件资源能够处理的请求数

    (同样的请求数,消耗的硬件资源更少)

    完整代码

    服务器

    public class TcpEchoServer {
        private ServerSocket serverSocket = null;
        //此处不应该创建一个固定线程数目的线程池,不然就有上限了
        private ExecutorService service= Executors.newCachedThreadPool();
    
        //这个操作就会绑定端口号
        public TcpEchoServer(int port) throws IOException {
            serverSocket = new ServerSocket(port);
        }
    
        //启动服务器
        public void start() throws IOException {
            System.out.println("服务器启动");
            while (true) {
                //处理连接的过程=>此时可能客户端还没来,accept就阻塞等待了
                Socket clientSocket = serverSocket.accept();
                //把内核中的连接获取到应用程序中了
    
                //单个线程不太方便同时完成多个任务,因此要多线程,主线程主要负责寻找客户端,每有一个客户端就创建一个新的线程
            /*
                Thread t=new Thread(()->{
                    processConnection(clientSocket);
                });
                t.start();
            */
                //使用线程池
                service.submit(new Runnable() {
                    @Override
                    public void run() {
                        processConnection(clientSocket);
                    }
                });
            }
        }
    
        //通过这个方法来处理一个连接的逻辑
        //服务器一启动,就会执行accept,并阻塞等待,当客户端连接上之后,就会立即执行这个方法
        private void processConnection(Socket clientSocket) {
            System.out.printf("[%s:%d] 客户端上线\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
            //接下来就可以 读取请求,根据请求计算响应,返回响应 三步走了
            // Socket 对象内部包含了两个字节流对象,可以把这两字节流对象,完成后续的读写工作
            try (InputStream inputStream = clientSocket.getInputStream();
                 OutputStream outputStream = clientSocket.getOutputStream()) {
                //一次连接中,可能会涉及到多次请求/响应
                while (true) {
                    //1、读取请求并解析,为了读取方便,直接使用Scanner
                    Scanner sc = new Scanner(inputStream);
                    //hasNext这里在客户端没有发请求的时候也会阻塞等待,等到客户端真正发数据或者客户端退出,hasNext就返回了
                    if (!sc.hasNext()) {
                        //读取完毕,客户端下线了
                        System.out.printf("[%s:%d] 客户端下线\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
                        break;
                    }
                    //这个代码暗含一个约定,客户端发过来的请求得是文本数据,同时还得带有空白符作为分割,(比如换行这种)
                    String request = sc.next();
                    //2、根据请求计算响应
                    String response = process(request);
                    //3、把响应写回客户端,把OutputStream使用PrinterWriter包裹一下,方便进行发数据
                    PrintWriter writer = new PrintWriter(outputStream);
                    //  使用PrintWriter的println方法把响应返回给客户端
                    //  此处用println而不是print就是为了在结尾加上\n,方便客户端读取响应,使用Scanner.next读取
                    writer.println(response);
                    //  还需要加入一个"刷新缓冲区"操作
    
                    //网络程序讲究的就是客户端和服务器能“配合”
    
                    writer.flush();//“上完厕所冲一下”。
                    //这里加上 flush 更稳妥,不加也不一定就出错!!缓冲区内置了一定的刷新策略
                    //比如缓冲区满了,就会触发刷新; 再比如,程序退出,也会触发刷新......推荐大家把 flush 刷新给加上
    
                    /*
                    IO操作是比较有开销的,相比于访问内存。进入IO操作次数越多,程序的速度越慢。
                    方法:使用一块内存作为缓冲区,写数据的时候,先写到缓冲区,攒一波数据,统一进入IO。
                    PrintWriter内置了缓冲区,手动刷新,确保这里的数据是真的通过网卡发出去了,而不是残留在内存缓冲区中
                     */
    
                    //日志,打印当前的请求详情
                    System.out.printf("[%s:%d] req: %s, req: %s\n", clientSocket.getInetAddress().toString(), clientSocket.getPort(),
                            request, response);
                }
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                //在finally中加入close,确保当前socket被及时关闭
                try {
                    clientSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    
        public String process(String request) {
            return request;
        }
    
        public static void main(String[] args) throws IOException {
            TcpEchoServer server = new TcpEchoServer(9090);
            server.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

    客户端

    public class TcpEchoClient {
        private Socket socket=null;
    
        //要和服务器通信,就需要先知道,服务器所在的位置
        public TcpEchoClient(String serverIp,int serverPort) throws IOException {
            //完成这个new操作就完成了tcp连接的建议
            socket=new Socket(serverIp,serverPort);
        }
    
        public void start(){
            System.out.println("客户端启动");
            Scanner scConsole=new Scanner(System.in);
            try(InputStream inputStream=socket.getInputStream();
                OutputStream outputStream=socket.getOutputStream()){
                while(true){
                    //1、从控制台输入一个字符串
                    System.out.print("-> ");
                    String request = scConsole.next();
                    //2、把请求发送给服务器
                    PrintWriter printWriter=new PrintWriter(outputStream);
                    //使用println带上换行,后续服务器读取请求,就可以使用Scanner.next来读取了
                    printWriter.println(request);
                    //别忘记flush,确保数据真的发出去了
                    printWriter.flush();
                    //3、从服务器读取响应
                    Scanner scNetwork=new Scanner(inputStream);
                    String response = scNetwork.next();
                    //4、把响应打印出来
                    System.out.println(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();
        }
    }
    
    • 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
  • 相关阅读:
    Springboot使用maven打包指定mainClass
    香港服务器租用优化回国大带宽快吗?
    mysql面试题16:说说分库与分表的设计?常用的分库分表中间件有哪些?分库分表可能遇到的问题有哪些?
    MongoDB在Linux下的安装及其环境部署配置
    Python算法——树的路径和算法
    CodeTON Round 3 (C.差分维护,D.容斥原理)
    HTML5期末考核大作业 基于HTML+CSS+JavaScript沪上美食(9页)
    神经滤镜为什么不能用,ps神经网络滤镜安装包
    Matlab绘制面积堆叠图/面积图
    【web前端】web前端设计入门到实战第一弹——html基础精华
  • 原文地址:https://blog.csdn.net/Hsusan/article/details/138173175