java中TCP实现回显服务器及客户端

软件发布|下载排行|最新软件

当前位置:首页IT学院IT技术

java中TCP实现回显服务器及客户端

小小太空人w   2023-03-20 我要评论

前言:

上篇文章介绍了TCP的特点。由于TCP的特点是有连接,面向字节流,可靠传输等,我们就可以想象到TCP的代码和UDP会有一定的差异。TCP和UDP具体使用哪种协议需要根据实际业务需求来选择。

Socket API

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

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

Socket构造方法

注意:

创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接。当服务端accept()阻塞时,客户端一旦实例出Socket对象,就会建立连接。

Socket方法 

注意:

获得套接字输入流。如果建立连接,服务端调用这个方法,就是读取客户端请求。

注意:

获得套接字输出流。如果建立连接,服务端调用这个方法,就是往客户端返回响应。

注意:

连接后获得对方的IP地址。

SeverSocket API

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

ServerSocket构造方法

创建服务端套接字,并绑定端口。这个对象就是用来与客户端建立连接的。

ServerSocket方法

注意:

开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,用来收发数据,否则阻塞等待。

注意:

由于在操做系统中Socket被当作文件处理,那么就需要释放PCB中文件描述符表中的资源,同时断开连接。

TCP中的长短连接

短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。

注意:

    1)建立关闭连接耗时:很明显短连接需要不断的建立和断开连接,而长连接只需要一次。长连接耗时要比短连接短。

    2)主动发送请求不同:短连接一般是客户端主动向服务端发送请求。长连接客户端可以向服务端主动发送,服务端也可以主动向客户端发送。

    3)两者使用场景不同:短连接一般适用于客户端请求频率不高的场景(浏览网页)。长连接一般适用于客户端与服务端通信频繁的场景。(聊天室)

TCP实现回显服务器

首先服务器是被动的一方,我们必须指定端口。然后通过ServerSocket对象中accept()方法建立连接,当返回Socket对象时,处理连接并且将响应写回客户端。

由于不知道客户端什么时候建立连接,那么服务器就需要一直等待(随时待命)。这里使用了死循环的方式,但是不会一直循环,accept()方法当没有连接时就会阻塞等待。

这里是本机到本机的数据发送,即使用环回ip即可。

 private ServerSocket serverSocket = null;
 public TcpEchoSever(int port) throws IOException {
     serverSocket = new ServerSocket(port);
 }

注意:

创建ServerSocket对象,并且指定端口号。

Socket clintSocket = serverSocket.accept();

注意:

accept()方法会阻塞等待。客户端Socket对象一旦实例化,就会与服务端建立连接。

processConnection(clintSocket);

注意:

 这里通过一个方法来处理连接。这样写会有很大的好处。

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

注意:

    我们首先需要获得读和写的流对象。服务器需要接收请求(读),返回响应(写)。这里使用的是带有资源的try(),这样就会自动关闭流对象。

Scanner scanner = new Scanner(inputStream);
String request = scanner.next();

注意:

这里通过Scanner去从流对象中读取数据。注意这里的next()方法,当读到一个换行符/空格/其他空白符结束,但最终结果不包含上述空白符。

因为我们不清楚客户端连接后发送多少次请求,因此我们采用死循环的方式读和向客户端响应数据。这里不会一直循环因为scanner当读不到数据就会阻塞。

String response = process(request);
public String process(String request) {
    return request;
}

注意:

这里通过一个函数来处理请求并且返回处理后结果。由于是回显服务器直接返回即可。

  PrintWriter printWriter = new PrintWriter(outputStream);
  printWriter.println(response);
  printWriter.flush();

注意:

我们为了方便直接写字符串,将outputStream转换成PrintWriter。然后将响应写入到网卡,并且换行。因为客户端和服务端读数据都是需要空白符结束的,所以这里必须有一个空白符。

由于数据首先会写入缓冲区,我们将缓冲区刷新一下保证数据正常写入到文件中(网卡)

finally {
      clintSocket.close();
}

注意:

和一个客户端建立连接后,返回Socket对象(使用文件描述表),如果并发量大(会创建很多对象,文件描述符表就有可能满),就可能导致无法创建连接。因此需要保证资源得到释放,包裹在finally里。

特别注意:

上述代码只能处理一个客户端。当代码执行到processConnection函数里,首先是一个死循环,然后还有scanner的阻塞,当处理一个连接代码就会一直在这个函数里。没有办法执行到accept()和客户端连接。想要处理下一个客户端的连接,就必须断开这个客户端,显然这是不合理的。

解决方案:

使用多线程。当有客户端连接后,创建一个线程去处理这个连接,主线程代码继续执行,就会到accept()方法。要是有多个客户端都可以建立连接,并且有独立的线程去处理这些连接,这些线程是并发的关系。

但是存在一个问题,如果并发量足够大(客户端数量非常多),就会创建大量的线程,也会存在大量线程的销毁,这些就会消耗大量的系统资源。因此使用线程池,使用动态变化的线程数量,根据并发量来调整线程数量。而且直接使用线程池中的线程代码上就可以实现,这样就会减少系统资源的消耗。

代码实现(有详细解释)

public class TcpEchoSever {
    //Tcp协议服务器,使用ServerSocket类,来建立连接
    private ServerSocket serverSocket = null;
    public TcpEchoSever(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }
    public void start() throws IOException {
        System.out.println("启动服务器");
        //使用线程池,防止客户端数量过多,创建销毁大量线程开销太大
        //动态变化的线程池
        ExecutorService threadPool = Executors.newCachedThreadPool();
        while (true) {
            //这里会阻塞,直到和客户端建立连接,返回Socket对象,来和客户端通信
            //客户端构造Socket对象时,会指定IP和端口,就会建立连接(客户端主动连接)
            Socket clintSocket = serverSocket.accept();
            threadPool.submit(() -> {
                try {
                    processConnection(clintSocket);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
            //要连接多个客户端,需要多线程去处理连接
            //这样才能让主线程继续执行到accept阻塞,然后和其他客户端建立连接(每个线程是独立的执行流,彼此之间是并发的关系)
            //如果客户端数量非常大,这里就会创建很多线程,数量过多对于系统来说也是很大的开销(使用线程池)
//            Thread t = new Thread(() -> {
//                try {
//                    processConnection(clintSocket);
//                } catch (IOException e) {
//                    e.printStackTrace();
//                }
//            });
//            t.start();
        }
    }
 
    private void processConnection(Socket clintSocket) throws IOException {
        System.out.printf("【%s : %d】客户端上线\n", clintSocket.getInetAddress(), clintSocket.getPort());
        //读客户端请求
        //处理请求
        //将结果写回客户端(响应)
        try(InputStream inputStream = clintSocket.getInputStream();
            OutputStream outputStream = clintSocket.getOutputStream()) {
 
            //流式数据,循环读取
            while (true) {
                Scanner scanner = new Scanner(inputStream);
                //读取完毕,客户端下线
                if(!scanner.hasNext()) {
                    System.out.printf("【%s : %d】客户端下线\n", clintSocket.getInetAddress(), clintSocket.getPort());
                    break;
                }
                //读取请求
                // 注意!! 此处使用 next 是一直读取到换行符/空格/其他空白符结束, 但是最终返回结果里不包含上述 空白符 .
                String request = scanner.next();
                //处理请求
                String response = process(request);
 
                //写回客户端处理请求结果(响应)
                //为了直接写字符串,这里将字节流转换为字符流
                //也可以将字符串转为字节数组
                PrintWriter printWriter = new PrintWriter(outputStream);
                //写入且换行
                printWriter.println(response);
                //写入首先是写入了缓冲区,这里为了保险就刷新一下缓冲区
                printWriter.flush();
                System.out.printf("【%s : %d】请求:%s  响应:%s\n", clintSocket.getInetAddress(), clintSocket.getPort(),
                        request, response);
            }
 
        }catch (IOException e) {
            e.printStackTrace();
        }finally {
            //和一个客户端建立连接后,返回Socket对象(使用文件描述表),如果并发量大(会创建很多对象,文件描述符表就有可能满),就可能导致无法创建连接
            //因此需要保证资源得到释放,包裹在finally里
            clintSocket.close();
        }
    }
    public String process(String request) {
        return request;
    }
 
    public static void main(String[] args) throws IOException {
        TcpEchoSever tcpEchoSever = new TcpEchoSever(8280);
        tcpEchoSever.start();
    }
}

TCP实现回显客户端

客户端不需要指定端口号。客户端程序在用户主机上,我们如果指定就有可能和其他程序冲突,因此让操作系统随机分配一个空闲的端口号。客户端需要明确服务端的ip和端口号,这样才能明确哪个主机和哪个进程。

那么服务端为什么可以指定端口号呢?难道就不怕和其他进程端口号冲突吗?(这里详解请看上篇文章的解释)

首先需要明确客户端的工作流程:接收用户输入数据 --> 发送请求 --> 接收响应

public TcpEchoClint(String severIp, int severPort) throws IOException {
    socket = new Socket(severIp, severPort);
}

注意:

创建Socket对象,并且指定服务端的ip和端口。当这个对象实例创建完成时,同时也就和服务端建立了连接,通过这个Socket对象就可以发送和接收数据。

这里不需要将字符串ip进行转换,可以自动转换。

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

注意:

和服务端一样首先获得输入和输出流。用包含资源的try可以自动关闭,释放文件描述符表中的资源。

PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
printWriter.flush();

注意:

让用户从控制台输入数据,这里做了一个判断,如果输入“exit”就退出客户端(break直接跳出循环)。

Scanner scanner1 = new Scanner(inputStream);
String response = scanner1.next();
System.out.println(response);

注意:

为了直接发送字符串,这里将outputStream转换成PrintWriter。这里在发送时需要换行(空白符),因为服务端读取的next()方法需要空白符。

数据首先写入缓冲区,为了保证数据写入到文件(网卡),这里手动刷新一下缓冲区。

Scanner scanner1 = new Scanner(inputStream);
String response = scanner1.next();
System.out.println(response);

注意:

接收响应,通过输入流来读取响应。将接收的响应打印出来。这里的next()方法和上面一致。

代码实现(有详细注释)

public class TcpEchoClint {
    Socket socket = null;
    public TcpEchoClint(String severIp, int severPort) throws IOException {
        //Socket构造方法,可以识别点分十进制,不需要转换,比DatageamPacket方便
        //实例这个对象的同时,就会进行连接
        socket = new Socket(severIp, severPort);
    }
    public void start() {
        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()) {
            Scanner scanner = new Scanner(System.in);
            while (true) {
                //从控制台读取请求
                //空白字符结束,但不会读空白字符
                System.out.println("请输入请求:");
                String request = scanner.next();
                if(request.equals("exit")) {
                    System.out.println("bye bye");
                    break;
                }
                //发送请求
                PrintWriter printWriter = new PrintWriter(outputStream);
                //需要发送空白符,因为scanner需要空白符
                printWriter.println(request);
                printWriter.flush();
                //接收响应
                Scanner scanner1 = new Scanner(inputStream);
                String response = scanner1.next();
                System.out.println(response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) throws IOException {
        TcpEchoClint tcpEchoClint = new TcpEchoClint("127.0.0.1", 8280);
        tcpEchoClint.start();
    }
}

小结:

在写服务端代码时,需要考虑高并发的情况。我们需要尽可能节省系统资源的利用。

Copyright 2022 版权所有 软件发布 访问手机版

声明:所有软件和文章来自软件开发商或者作者 如有异议 请与本站联系 联系我们