用TCP实现服务器与客户端的交互

目录

一、TCP的特点

二、API介绍

1.ServerSocket

2.Socket

三、实现服务器

四、实现客户端

五、测试解决bug

1.客户端发送了数据之后,并没有响应

2.clientSocket没有执行close()操作

3.尝试使用多个客户端同时连接服务器

六、优化

1.短时间有大量客户端访问并断开连接

2.有大量的客户端长时间在线访问

七、源码


引言:

这篇文章主要是用TCP构造的回显服务器,也就是客户端发什么,就返回什么。用实现这个过程方式来学会TCP套接字的使用。

一、TCP的特点

  • TCP是可靠的:这个需要去了解TCP的机制,这是一个大工程,博主后面写好了把连接附上
  • TCP是面向字节流
  • TCP是全双工
  • TCP是有连接

除了可靠性,在编程中无法体会到,其他特性我都会一 一讲解。

二、API介绍

1.ServerSocket

ServerSocket 是创建TCP服务端Socket的API

ServerSocket 构造⽅法:

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

ServerSocket ⽅法:

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

2.Socket

Socket 是客⼾端Socket ,或服务端中接收到客⼾端建⽴连接(accept⽅法)的请求后,返回的服务端Socket。
不管是客⼾端还是服务端Socket,都是*++双⽅建⽴连接以后,保存的对方信息,及⽤来与对⽅收发数据的++*。

Socket构造方法:

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

这里new出来就是和对方建立完成了。如果建立失败,就会在构造对象的时候抛出异常。

Socket方法:

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

三、实现服务器

服务器需要指定端口号:

java 复制代码
public class TcpEchoServer {
    
    private ServerSocket serverSocket = null;
    
    //               需要指定服务器的端口    处理ServerSocket抛出的异常
    public TcpEchoServer(int port) throws IOException {
        //                     指定服务器的端口
        serverSocket = new ServerSocket(port);
    }
}

注意处理抛出的异常
和客户端建立连接:

java 复制代码
public class TcpEchoServer {

    private ServerSocket serverSocket = null;

    //               需要指定服务器的端口    处理ServerSocket抛出的异常
    public TcpEchoServer(int port) throws IOException {
        //                     指定服务器的端口
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        //服务器需要不停的执行
        while (true) {

            //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket
            Socket clientSocket = serverSocket.accept();
            //处理逻辑
            processConnection(clientSocket);
        }


    }

    //针对一个连接,提供处理逻辑
    private void processConnection(Socket clientSocket) {
        
    }
}

这里的accept()就体现了TCP的有连接

当连接成功后,需要处理的逻辑:

java 复制代码
    //针对一个连接,提供处理逻辑
    private void processConnection(Socket clientSocket) {
        //打印客户端的信息                               返回IP地址                      返回端口号
        System.out.printf("[%s : %d]客户端上线\n",clientSocket.getInetAddress(), clientSocket.getPort());



        //获取到socket中持有的流对象
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()) {

            while (true) {
                //1.获取请求

                //2.处理请求

                //3.返回响应

                //4.打印日志
            }

        }catch (IOException e) {

        }
    }

全双工的意思 :通信的双方(如客户端和服务器)可以在同一时间内同时进行数据的发送和接收,即两个方向的数据流可以同时传输,互不干扰。

这里的getInputStream、getOutputStream就体现了全双工和面向字节流。

不了解这两个接口的可以去看我这篇文章:

JAVA如何操作文件?(超级详细)_java操作文件-CSDN博客

实现处理逻辑:

java 复制代码
    //针对一个连接,提供处理逻辑
    private void processConnection(Socket clientSocket) {
        //打印客户端的信息                               返回IP地址                      返回端口号
        System.out.printf("[%s : %d]客户端上线\n",clientSocket.getInetAddress(), clientSocket.getPort());

        //获取到socket中持有的流对象
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()) {

            //因为我们用字符串来做为数据传输,用Scanner就可以更方便的传输了
            Scanner scanner = new Scanner(inputStream);
            //包装输出流,主要是用println()会在数据之后加上\n
            PrintWriter printWriter = new PrintWriter(outputStream);

            while (true) {
                //1.获取请求
                if (!scanner.hasNext()) {
                    //如果scanner无法读取出数据,说明客户端断开了连接,导致服务器这边读取到"末尾"
                    break;
                }
                //2.处理请求
                //接收客户端的请求
                //如果遇到 空白字符 就会停止输入
                String request = scanner.next();
                //处理请求
                String response = process(request);
                //3.返回响应
                //此处可以按字节数组的形式,但是我们要输入的是字符串,这个就不太方便
                //outputStream.write(response.getBytes());

                //此方法在写入之后会自动加上\n
                printWriter.println(response);

                //4.打印日志
                System.out.printf("[%s : %d] 请求 = %s 响应 = %s \n",clientSocket.getInetAddress(),clientSocket.getPort(),
                                request,response);
            }

        }catch (IOException e) {
            throw new RuntimeException();
        }
    }

    private String process(String request) {
        //由于我们是回显服务器这里直接返回就可以了
        return request;
    }

注意里面使用了两个接口包装了一下输入输出流,最主要的是可以在用\n做为分割。

注意里面的:

发送字符串给客户端,最后会自动加上 \n 做为结尾

java 复制代码
println(response);

接收客户端信息,以空白符做为结尾。

空白符:包括不限于 空格、回车、制表符......

java 复制代码
scanner.next();

如果是nextLine()就比较严格,必须是\n做为结尾

这里的服务器处理逻辑就写完了,但其实这里还有三个错误,后面再单独讲解:

java 复制代码
public class TcpEchoServer {

    private ServerSocket serverSocket = null;

    //               需要指定服务器的端口    处理ServerSocket抛出的异常
    public TcpEchoServer(int port) throws IOException {
        //                     指定服务器的端口
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        //服务器需要不停的执行
        while (true) {

            //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket
            Socket clientSocket = serverSocket.accept();
            //处理逻辑
            processConnection(clientSocket);
        }


    }

    //针对一个连接,提供处理逻辑
    private void processConnection(Socket clientSocket) {
        //打印客户端的信息                               返回IP地址                      返回端口号
        System.out.printf("[%s : %d]客户端上线\n",clientSocket.getInetAddress(), clientSocket.getPort());

        //获取到socket中持有的流对象
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()) {

            //因为我们用字符串来做为数据传输,用Scanner就可以更方便的传输了
            Scanner scanner = new Scanner(inputStream);
            //包装输出流,主要是用println()会在数据之后加上\n
            PrintWriter printWriter = new PrintWriter(outputStream);

            while (true) {
                //1.获取请求
                if (!scanner.hasNext()) {
                    //如果scanner无法读取出数据,说明客户端断开了连接,导致服务器这边读取到"末尾"
                    break;
                }
                //2.处理请求
                //接收客户端的请求
                //如果遇到 空白字符 就会停止输入
                String request = scanner.next();
                //处理请求
                String response = process(request);
                //3.返回响应
                //此处可以按字节数组的形式,但是我们要输入的是字符串,这个就不太方便
                //outputStream.write(response.getBytes());

                //此方法在写入之后会自动加上\n
                printWriter.println(response);

                //4.打印日志
                System.out.printf("[%s : %d] 请求 = %s 响应 = %s \n",clientSocket.getInetAddress(),clientSocket.getPort(),
                                request,response);
            }

        }catch (IOException e) {
            throw new RuntimeException();
        }
    }

    private String process(String request) {
        //由于我们是回显服务器这里直接返回就可以了
        return request;
    }

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

四、实现客户端

指定服务器的IP和端口号:

java 复制代码
public class TcpEchoClient {
    
    private Socket socket = null;
    
    public TcpEchoClient(String serverIP, int serverPort) throws IOException {
        //这里只要建立实例,就是和服务端的accept()建立了连接
        //socket也就保存了服务器的IP和端口号等
        //需要传入服务器的 IP地址 和 端口号
        socket = new Socket(serverIP, serverPort);
    }
    
    public void start() {
        System.out.println("客户端启动!");
    }
    
}

整体逻辑:

java 复制代码
public class TcpEchoClient {

    private Socket socket = null;

    public TcpEchoClient(String serverIP, int serverPort) throws IOException {
        //需要传入服务器的 IP地址 和 端口号
        //这里只要建立实例,就是和服务端的accept()建立了连接
        socket = new Socket(serverIP, serverPort);
    }

    public void start() {
        System.out.println("客户端启动!");

        try(OutputStream outputStream = socket.getOutputStream();
            InputStream inputStream = socket.getInputStream()) {

            while (true) {
                //1.从控制台获取数据

                //2.将数据发送给服务器

                //3.接收服务器响应

                //4.打印相关结果
            }


        }catch (IOException e) {
            throw new RuntimeException();
        }
    }
}

整体逻辑实现:

java 复制代码
public class TcpEchoClient {

    private Socket socket = null;

    public TcpEchoClient(String serverIP, int serverPort) throws IOException {
        //这里只要建立实例,就是和服务端的accept()建立了连接
        //socket也就保存了服务器的IP和端口号等
        //需要传入服务器的 IP地址 和 端口号
        socket = new Socket(serverIP, serverPort);
    }

    public void start() {
        System.out.println("客户端启动!");

        try(OutputStream outputStream = socket.getOutputStream();
            InputStream inputStream = socket.getInputStream()) {

            //用来接收服务器的信息
            Scanner scanner = new Scanner(inputStream);
            //用于接收用户输入
            Scanner scannerIn = new Scanner(System.in);
            //用于输出数据给服务器
            PrintWriter printWriter = new PrintWriter(outputStream);

            while (true) {
                //1.从控制台获取数据
                System.out.print("->");
                String request = scannerIn.next();

                //2.将数据发送给服务器
                printWriter.println(request);

                //3.接收服务器响应
                //判断服务端是否还有信息
                if (!scanner.hasNext()) {
                    break;
                }
                //接收服务端信息
                String response = scanner.next();
                //4.打印相关结果

                System.out.println(response);
            }


        }catch (IOException e) {
            throw new RuntimeException();
        }
    }

    public static void main(String[] args) throws IOException {
//                                              127.0.0.1是专门用来访问自己的
        TcpEchoClient client = new TcpEchoClient("127.0.0.1",8080);
        client.start();
    }
}

这里仍然纯在一个问题,一会和服务器的问题一起将

五、测试解决bug

最后我会把所有的问题解决了,再把源附上

1.客户端发送了数据之后,并没有响应

先运行服务器,再运行客户端

可以看到目前还是成功的,那么我们来输入数据。

我们在客户端输入了消息,但是没有任何反应了!

此处的情况是,客户端并没有真正把请求发出去:

PrintWriter这样的类,以及很多IO流中的类,都是 **"自带缓冲区"**的。

此方法就带有缓冲区:

java 复制代码
printWriter.println(request);

引入缓冲区之后,进行写入数据的操作,并不会马上触发IO,而是先放到内存缓冲区中,等到缓冲区里攒了一波之后,再统一进行发送。
为什么引入缓冲区的机制?

因为IO操作其实是不小的开销,如果数据量较少,那么每一次都进行IO,就有很大一部分开销是IO操作。如果积累到一定数据量再进行IO操作,那么一次IO就传输了这么多数据。
我们可以使用flush方法,主动"刷新缓冲区":

注意:

服务器 和 客户端 都需要在printWriter.println();后面加上flush()方法。
再来测试:

此时就可以接收到了

2.clientSocket没有执行close()操作

这个问题比较隐蔽,这些ServerSocket 和 Socket 每一次都会在"文件描述符"中创建一个新的表项。

文件描述符:描述了该进程都要操作哪些文件。数组的每个元素就是一个struct file对象,每个结构体就描述了对应的文件信息,数组的小标就称为"文件描述符"。

每次打开一个文件,就想当于在数组上占用了一个位置,而这个数组又是不能扩容的,如果数组满了就会打开文件失败。除非主动调用close才会关闭文件,或者这个进程直接结束了这个数组也被带走了。

那么我们就需要处理一下clientSocket:

3.尝试使用多个客户端同时连接服务器

要对同一代码启动多个进程,需要设置一下步骤:

分别启动客户端1 和 客户端2 ,可以看到服务器上根本没有第二个客户端启动的信息:

原因:

我们可以用多线程去执行专门执行每一个客户端的请求:

java 复制代码
    public void start() throws IOException {
        //服务器需要不停的执行
        while (true) {

            //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket
            Socket clientSocket = serverSocket.accept();

            //让一个线程去对应一个客户端
            Thread thread = new Thread(() -> {
                //处理逻辑
                processConnection(clientSocket);
            });

            thread.start();
        }
    }

结果:

bug问题解决了,但还有一些场景,可能会把服务器干崩溃

六、优化

1.短时间有大量客户端访问并断开连接

一旦短时间内有大量的客户端,并且每个客户端请求都是很快的连接之后并退出的,这个时候对于服务器来说,就会有比较大的压力。这个时候,就算是进程比线程更加的轻量,但是短时间内有大量的线程创建销毁,就无法忽略它的开销了。
我们可以引入线程池,这样就解决了这个问题:

java 复制代码
    public void start() throws IOException {
        //服务器需要不停的执行
        while (true) {

            //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket
            Socket clientSocket = serverSocket.accept();
            ExecutorService service = Executors.newCachedThreadPool();

//            //让一个线程去对应一个客户端
//            Thread thread = new Thread(() -> {
//                //处理逻辑
//                try {
//                    processConnection(clientSocket);
//                } catch (IOException e) {
//                    e.printStackTrace();
//                }
//            });
//            thread.start();

            service.submit(() -> {
                try {
                    processConnection(clientSocket);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
    }

这个线程可创建的线程数是很大的:

2.有大量的客户端长时间在线访问

例如直播这样的情况,每个客户端分配一个线程,对于一个系统来说,这里搞几百个线程压力就非常大了。所以这里 线程池/线程 都不太适用了。

可以使用 IO多路复用 ,也就是一个线程分配多个客户端进行服务,因为大部分时间线程都是在等待状态,就能够让线程分配多个客户端,这样的机制我们做为java程序员不需要过多了解,这样的机制以及被大佬们,装进各种框架中了。

七、源码

服务器源码:

java 复制代码
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 TcpEchoServer {

    private ServerSocket serverSocket = null;

    //               需要指定服务器的端口    处理ServerSocket抛出的异常
    public TcpEchoServer(int port) throws IOException {
        //                     指定服务器的端口
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        //服务器需要不停的执行
        while (true) {

            //开始监听指定端口,当有客户端连接后,返回一个保存对方信息的Socket
            Socket clientSocket = serverSocket.accept();
            ExecutorService service = Executors.newCachedThreadPool();

//            //让一个线程去对应一个客户端
//            Thread thread = new Thread(() -> {
//                //处理逻辑
//                try {
//                    processConnection(clientSocket);
//                } catch (IOException e) {
//                    e.printStackTrace();
//                }
//            });
//            thread.start();

            service.submit(() -> {
                try {
                    processConnection(clientSocket);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
    }

    //针对一个连接,提供处理逻辑
    private void processConnection(Socket clientSocket) throws IOException {
        //打印客户端的信息                               返回IP地址                      返回端口号
        System.out.printf("[%s : %d]客户端上线\n",clientSocket.getInetAddress(), clientSocket.getPort());

        //获取到socket中持有的流对象
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream()) {

            //因为我们用字符串来做为数据传输,用Scanner就可以更方便的传输了
            Scanner scanner = new Scanner(inputStream);
            //包装输出流,主要是用println()会在数据之后加上\n
            PrintWriter printWriter = new PrintWriter(outputStream);

            while (true) {
                //1.获取请求
                if (!scanner.hasNext()) {
                    //如果scanner无法读取出数据,说明客户端断开了连接,导致服务器这边读取到"末尾"
                    break;
                }
                //2.处理请求
                //接收客户端的请求
                //如果遇到 空白字符 就会停止输入
                String request = scanner.next();
                //处理请求
                String response = process(request);
                //3.返回响应
                //此处可以按字节数组的形式,但是我们要输入的是字符串,这个就不太方便
                //outputStream.write(response.getBytes());

                //此方法在写入之后会自动加上\n
                printWriter.println(response);
                //刷新缓冲区
                printWriter.flush();

                //4.打印日志
                System.out.printf("[%s : %d] 请求 = %s 响应 = %s \n",clientSocket.getInetAddress(),clientSocket.getPort(),
                                request,response);
            }

        }catch (IOException e) {
            throw new RuntimeException();
        } finally {
            System.out.printf("[%s : %d]客户端下线\n",clientSocket.getInetAddress(), clientSocket.getPort());
            clientSocket.close();
        }
    }

    private String process(String request) {
        //由于我们是回显服务器这里直接返回就可以了
        return request;
    }

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

客户端源码:

java 复制代码
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 TcpEchoClient {

    private Socket socket = null;

    public TcpEchoClient(String serverIP, int serverPort) throws IOException {
        //这里只要建立实例,就是和服务端的accept()建立了连接
        //socket也就保存了服务器的IP和端口号等
        //需要传入服务器的 IP地址 和 端口号
        socket = new Socket(serverIP, serverPort);
    }

    public void start() {
        System.out.println("客户端启动!");

        try(OutputStream outputStream = socket.getOutputStream();
            InputStream inputStream = socket.getInputStream()) {

            //用来接收服务器的信息
            Scanner scanner = new Scanner(inputStream);
            //用于接收用户输入
            Scanner scannerIn = new Scanner(System.in);
            //用于输出数据给服务器
            PrintWriter printWriter = new PrintWriter(outputStream);

            while (true) {
                //1.从控制台获取数据
                System.out.print("->");
                String request = scannerIn.next();

                //2.将数据发送给服务器
                printWriter.println(request);
                //刷新缓冲区
                printWriter.flush();

                //3.接收服务器响应
                //判断服务端是否还有信息
                if (!scanner.hasNext()) {
                    break;
                }
                //接收服务端信息
                String response = scanner.next();
                //4.打印相关结果

                System.out.println(response);
            }


        }catch (IOException e) {
            throw new RuntimeException();
        }
    }

    public static void main(String[] args) throws IOException {
        TcpEchoClient client = new TcpEchoClient("127.0.0.1",8080);
        client.start();
    }
}
相关推荐
侠客行031718 小时前
Mybatis连接池实现及池化模式
java·mybatis·源码阅读
蛇皮划水怪18 小时前
深入浅出LangChain4J
java·langchain·llm
较劲男子汉20 小时前
CANN Runtime零拷贝传输技术源码实战 彻底打通Host与Device的数据传输壁垒
运维·服务器·数据库·cann
灰子学技术20 小时前
go response.Body.close()导致连接异常处理
开发语言·后端·golang
老毛肚20 小时前
MyBatis体系结构与工作原理 上篇
java·mybatis
wypywyp20 小时前
8. ubuntu 虚拟机 linux 服务器 TCP/IP 概念辨析
linux·服务器·ubuntu
风流倜傥唐伯虎21 小时前
Spring Boot Jar包生产级启停脚本
java·运维·spring boot
二十雨辰21 小时前
[python]-AI大模型
开发语言·人工智能·python
Doro再努力21 小时前
【Linux操作系统10】Makefile深度解析:从依赖推导到有效编译
android·linux·运维·服务器·编辑器·vim
senijusene21 小时前
Linux软件编程:IO编程,标准IO(1)
linux·运维·服务器