网络编程(二)TCP回显服务器与客户端通信详解

前言

hello hello💕,这里是洋不写bug~😄,欢迎大家点赞👍👍,关注😍😍,收藏🌹🌹

这篇博客会解析基于TCP协议来进行网络编程,进行客户端和服务器之间的交互,TCP传输的是字节流,TCP通信时,客户端和服务器之间要建立一条连接通道,TCP的代码涉及到文件资源释放、以及多客户端访问时线程的分配,相对UDP来说会稍微复杂一些

初学的铁汁对于服务器和客户端交互的代码可能会感觉比较抽象,建议多写几次代码,结合api解析和数据传输流程细品下🐵

🎆个人主页:洋不写bug的博客
🎆所属专栏:JavaEE学习
🎆铁汁们对于JavaEE的各种常用核心语法,都可以在上面的前端专栏学习,专栏正在持续更新中🏀,有问题可以写在评论区或者私信我哦~

1,TCP回显服务器/客户端

TCP编程中,有两个核心的类:

  1. ServerSocket类,创建一个这样的对象,就相当于打开了一个socket文件,这个socket对象是专门给服务器使用的,这个类不负责发送接收,主要负责建立连接
  2. Socket类,创建一个这样的对象,就相当于打开了一个socket文件,这个类负责发送接收数据,服务器和客户端都会使用

tcp是字节流,读写的时候,是以字节为基本单位的,tcp读写的代码,跟前面文件IO中字节流读写文件的代码是一致的,用inputStream/outputStream

服务器和客户端进行通信流程跟UDP是一样(如下图),只是api有区别

1,服务器

服务器代码逻辑如下:

  1. ServeSocket类是专门给服务器建立连接用的,构造方法中传入端口号,创建ServeSocket对象
  2. 在start中写个while(true)循环,因为服务器要多次处理来自客户端的请求,调用serveSocket的accept方法,等待客户端的连接,没有客户端连接时,accept方法会处于阻塞状态;
    如果有客户端访问服务器了,accept方法就返回Socket对象,代表服务器与该客户端之间的一条 TCP 通道 ,后续操作数据都是通过返回的这个socket,serveSocket就不参与了(例如一些售楼部会雇用一些大妈,专门在大街上拉人去售楼部看房,大妈把客户拉到售楼部,交给售楼部专业的置业顾问,给客户详细的讲解房子,接着大妈再去大街上拉人,serveSocket就相当于大妈,只负责把顾客拉到售楼部去,也就是建立连接;socket就类似于置业顾问,连接后的操作,比如说详细介绍房子、讲价、签合同,是置业顾问负责的)
java 复制代码
package network.tcp;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class EchoServer {

    private ServerSocket serverSocket;

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

    public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true){
            Socket socket = serverSocket.accept();
            processConnection(socket);
        }
    }

    private void processConnection(Socket socket){
    }


}

processConnection方法中写服务器和客户端建立连接后进行通讯的详细过程:

  1. TCP协议服务器和客户端之间的通信是通过字节流的,字节输出流是向文件中写数据的,字节输入流是从文件中读取数据的;那流程就是客户端通过输出流往socket文件中写入数据,服务器通过输入流读取数据,进行响应后,再通过输出流把数据写到socket文件中,也就是返回给客户端
  2. 结合文件IO的知识,服务器就可以用字节数组来读取客户端发送的数据,把数据转化为字符串,进行处理,处理后要把数据再转化为字节数组类型,用输出流把数据返回给客户端
  3. 用这种方法来写代码,就会涉及到很多的字节数组和字符串的相互转化,有个更简单的方法,就可以创建个Scanner对象和PrintWriter对象,分别负责输入和输出
    平常用new Scanner(System.in),就是从控制台读取用户的输入,现在写成new Scanner(inputStream),就是让Scanner从inputStream中读取数据,后面直接用scanner.next()来获取数据即可,如果客户端传输的是int类型的数据,就用scanner.nextInt()来读取数据,特别方便
    PrintWriter就是将字符串编码为字节,写入 Socket 的字节输出流 OutputStream中,写入时用的是println方法,用System.out调用println方法就是往控制台上输出,PrintWriter调用就是往OutputStream上输出
  4. PrintWriter将字符串编码成字节后, 是先放到 PrintWriter 的内存缓冲区,不会立刻交给 OutputStream,在发送完成后还需要用flush刷新下,也可以创建PrintWriter对象时,传入一个true,将PrintWriter设置为自动刷新
  5. 写个if语句,scanner.hasNext()判断下客户端是否已经关闭,如果客户端已经关闭了,就打印客户端下线信息,退出processConnection方法,进入start方法中,等待下一次客户端的连接
    可能有的铁汁会这样想:如果客户端连接上了,但是客户端还没有输入,此时scanner.hasNext()的返回值不就是false吗,那就直接显示客户端下线,退出processConnection方法吗?
    如果客户端连接上不输入,scanner.hasNext()就会阻塞,等待输入数据;有收到数据才返回 true;只有客户端正常关闭流才返回 false
java 复制代码
package network.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;

public class EchoServer {

    private ServerSocket serverSocket;

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

    public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true){
            Socket socket = serverSocket.accept();
            processConnection(socket);
        }
    }

    private void processConnection(Socket socket){
        System.out.printf("[%s:%d] 客户端上线!\n",socket.getInetAddress().toString(),socket.getPort());
        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()) {
            Scanner scanner = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream,true);
            while (true){
                //1.读取请求并解析
                if (!scanner.hasNext()){
                    System.out.printf("[%s:%d] 客户端下线!",socket.getInetAddress().toString(),socket.getPort());
                    break;
                }
                String request = scanner.next();
                //2.根据请求计算响应
                String response = process(request);
                //3.把响应写回客户端
                writer.println(response);

                System.out.printf("[%s:%d]  req:%s; resp: %s\n",socket.getInetAddress().toString(),socket.getPort(),request,response);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }

    }

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

2,客户端

UDP协议是无连接的,socket是不能保存服务器IP和服务器端口号的信息的,因此上篇博客在写UDP客户端时,就创建了两个成员变量,serveIP和servePort,在构造方法中给这两个成员变量赋值,构建数据包时再传入这两个成员变量
TCP协议是有连接的,socket对象是可以保存服务器的端口号和IP的,就不需要再去创建成员变量保存IP和端口号,直接在构造方法中传入端口号和IP,创建socket对象即可
Scanner和PrintWriter的用法前面服务器代码中已经解析过了,这里就不再赘述了,客户端读取用户输入,通过PrintWriter把请求发送给服务器,再用Scanner读取服务器的返回,代码如下:

java 复制代码
package network.tcp;

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 EchoClient {

    private Socket socket;

    public EchoClient(String serverIP,int serverPort) throws IOException {
        socket = new Socket(serverIP,serverPort);
    }

    public void start(){
        System.out.println("客户端启动!");
        Scanner scanner = new Scanner(System.in);
        try (InputStream inputStream  = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()){
            Scanner scannerNet = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);
            while (true){
                //1.从控制台读取用户输入
                System.out.print(">");
                String request = scanner.next();
                //2.构造请求发送给服务器
                writer.println(request);

                if(!scannerNet.hasNext()){
                    System.out.println("服务器断开了连接");
                    break;
                }
                //3.读取服务器响应
                String response = scannerNet.next();
                //4.把响应显示到控制台上
                System.out.println(response);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

3,测试

在服务器类中写个main方法,启动服务器,端口号传为9090

java 复制代码
    public static void main(String[] args) throws IOException {
        EchoServer echoServer = new EchoServer(9090);
        echoServer.start();
    }

上篇博客用UDP协议写了个回显服务器,main方法启动时传入的端口号也是9090

同时启动这两个服务器,虽然端口号相同,但是并不会报出异常,因为TCP:9090 和 UDP:9090 是两个完全独立的端口资源,操作系统分开管理,并不会有冲突,因为是不同的协议,UDP客户端找UDP服务器,TCP客户端找TCP服务器

启动这两个服务器,不要终止程序,可以在命令行中查一下,就会发现TCP和UDP是可以同时占用9090端口号的

在客户端中写个main方法,启动下客户端,就可以正常接收和读取数据

java 复制代码
   public static void main(String[] args) throws IOException {
        EchoClient echoClient = new EchoClient("127.0.0.1",9090);
        echoClient.start();
    }

如果把客户端程序终止,那服务器的scanner.hasNext()方法就会返回false,服务器退出processConnection方法,并且显示客户端已经下线

如果客户端和服务器都启动了,服务器程序终止,那客户端的scannerNet.hasNext()就会返回flase,显示服务器断开了连接

这里用一个客户端连接服务器来测试,各项功能似乎都没有问题,但是这个代码中是存在两个比较严重的问题,下面会进行解析😅

2,文件资源泄露问题

在服务器的start方法中,如果没有客户端连接,这个accept方法是处于阻塞状态的,每次有客户端连接,循环一次,就会构造一个socket对象,会占用一个文件描述符
服务器被客户端访问很多次,进程的文件描述符耗尽之后,accept()、new Socket()、getInputStream()方法都会抛出异常,服务器就废掉了

java 复制代码
    public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true){
            Socket socket = serverSocket.accept();
            processConnection(socket);
        }
    }

解决办法也很简单,在processConnection方法的末尾,加个finally语句,里面关闭下socket对象,这样每次processConnection执行完都会关闭这次连接创建的socket对象

java 复制代码
finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

UDP服务器并不需要关闭socket资源,因为代码逻辑不同(UDP代码如下所示),UDP服务器整个过程中只创建了一个socket对象,并不是每次有客户端访问都创建一个新的socket对象

java 复制代码
package network.udp;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;

public class EchoServer{
    private DatagramSocket socket;

    public EchoServer(int port) throws SocketException {
        socket = new DatagramSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true){
            //1.读取请求并解析
            DatagramPacket reqPacket = new DatagramPacket(new byte[4096],4096);
            socket.receive(reqPacket);
            String request = new String(reqPacket.getData(),0,reqPacket.getLength());
            //2.根据请求计算响应
            String response = process(request);
            //3.把响应写回到客户端
            DatagramPacket respPacket = new DatagramPacket(response.getBytes(),response.getBytes().length,reqPacket.getSocketAddress());
            socket.send(respPacket);
            System.out.printf("[%s:%d] req:%s, resp:%s\n",reqPacket.getAddress(),reqPacket.getPort(),request,response);
        }
    }

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

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

可能有的铁汁会想:TCP服务器中的scanner和writer用不用手动关闭呢?

并不需要,因为是用try‑with‑resources的方式来写代码的,pocessConnection方法执行完成后,输入流和输出流会自动关闭,Scanner、PrintWriter 对象只是包装对象,底层流已经没了,包装对象本身就失去作用,就会被Java的垃圾回收机制回收

3,多客户端访问问题

服务器基本上都是要同时服务多个客户端的,前面测试TCP服务器时,只测试了一个客户端访问服务器,下面测试下两个客户端同时访问服务器,看能不能正常的访问
可以在IDEA上设置一下,这样运行同一个客户端代码两次,就能够启动两个客户端,点击客户端右上角运行图标旁边的这个下拉框,点击Edit Confjigurations(编辑运行配置)

接着在跳出的这个页面中点击Modify options(修改选项)

把最上面的Allow multiple instance(允许多个实例)打上对号,再点击OK保存即可

实例是一个比较广义的概念,可以把创建的对象称为实例,也可以把进程称为实例,此时就表示可以给当前的客户端程序去启动多个进程(多次运行代码可以产生多个客户端)

设置完成后,先把服务器启动,在执行两次客户端代码,就能启动两个客户端,如下图

但是服务器只有一个客户端的上线记录,而且只有第一个客户端是可以正常跟服务器交互的,第二个客户端输入后没有反应

如果把第一个客户端关掉,这时候服务器就会显示第二个客户端的上线记录,第二个客户端输入后又可以正常返回了

通过测试,就会发现当前的服务器处理客户端是串行处理的,只有等第一个客户端程序终止,才会连接第二个客户端,这显然是不符合服务器要求的,服务器需要能同时处理多个客户端

出现这种情况就是代码的问题,在服务器的start方法中写了一个while(true)循环,在processConnection方法中又写了个while(true)循环

当一个客户端建立连接后,进入processConnection方法,只要这个客户端不终止,processConnection中的while(true)方法就不会退出

也就是在第一个whiile(true)方法中,是一直卡到processConnection(socket);并不会进入下次循环,也不会尝试用accept方法获取新的客户端的连接,因此新的客户段尝试连接服务器就没有反应

java 复制代码
while (true){
            Socket socket = serverSocket.accept();
            processConnection(socket);
        }
java 复制代码
 while (true){
                //1.读取请求并解析
                if (!scanner.hasNext()){
                    System.out.printf("[%s:%d] 客户端下线!",socket.getInetAddress().toString(),socket.getPort());
                    break;
                }
                String request = scanner.next();
                //2.根据请求计算响应
                String response = process(request);
                //3.把响应写回客户端
                writer.println(response);

                System.out.printf("[%s:%d]  req:%s; resp: %s\n",socket.getInetAddress().toString(),socket.getPort(),request,response);
            }

processConnection(socket)是写到while(true)方法中的,那如何做到第二个while(true)循环不会影响到第一个while(true)循环呢?

解决办法就是线程,创建一个新的线程t去执行processConnection(socket),这样main线程执行第一个while循环,t线程执行第二个while循环,t线程并不会干扰main线程

java 复制代码
  public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true){
            Socket socket = serverSocket.accept();
            Thread t = new Thread(() ->{
                processConnection(socket);
            });
            t.start();
        }
    }

这样有客户端访问服务器时,服务器就会创建个线程,专门负责处理这个客户端的响应和请求

代码还可以再优化下,如果访问服务器的客户端非常多的话,就会导致线程被频繁的创建和销毁,开销比较大,就可以用线程池来进行优化

就创建个没有线程数量限制的线程池,调用submit方法,每次把任务直接交给线程池即可

java 复制代码
    public void start() throws IOException {
        System.out.println("服务器启动!");
        ExecutorService threadPool = Executors.newCachedThreadPool();
        while (true){
            Socket socket = serverSocket.accept();
            threadPool.submit(() -> {
                processConnection(socket);
            });
        }
    }

还存在一个问题,如果有非常多的客户端访问服务器,这些客户端连接服务器后,并不会即可销毁,像网页类服务器,客户端用一会就关闭了,不会连接很长时间

但游戏类服务器,客户端连接后普遍要保持连接的时间就比较长,就会在短时间内出现大量的线程,但是操作系统的线程数目是有限的,解决这个问题,就用到了IO复用技术

IO复用技术呢可以类比宿舍带饭来理解
例如博主今天要去小吃街,给室友带饭,博主想吃扬州炒饭、室友A想吃炸串、室友B想吃热干面,那博主带饭就有三种方案:

  1. 先去买扬州炒饭,在摊子上等老板炒完;拿上扬州炒饭后,再去买炸串,也是在摊子上等老板炸完;拿上炸串后,再去买热干面,等老板拌好,三种饭就齐了,这样等待的时间就是三种饭等待的时间之和
    这种就类似于一次服务器只能处理一个客户端,处理完成这个客户端后才能处理下个客户端,也就是串行处理的
  2. 我们三个人都下楼买,各等各的,这样效率虽然高,但是开销就比较大
    就类似于最后一版代码,每次都用线程专门负责处理一个客户端的响应
  3. 博主直接报完饭后不在摊子上等,立马去下个摊子报饭,接着博主站在这三个摊子的中间,让老板做好了喊一下博主即可,这样等待的时间就是做的最慢的那个的时间
    这种方法效率比较高,开销也很小,就是IO复用技术,本质上就是用一个线程等待多个socket,哪个socket有数据了,线程处理就好了,就能在线程有限的前提下,处理更多的客户端了

这个IO复用技术在C++中会重点学习,在Java面试中是很少涉及的,Java标准库中对于多路复用的API也是比较复杂的,在实际的工作中,通常会用框架来封装这些底层操作,因此这里铁汁们只是了解下即可

结语💕💕

  • TCP服务器的代码和需要考虑的东西是要比UDP更复杂的,因为TCP 中每一个客户端连接都会对应一套独立的 Socket、输入流与输出流,多个客户端同时接入时,还得借助线程池实现并发处理
  • 包括writer和scanner进行写入和读取数据时,初学的铁汁可能会感觉比较抽象,就需要多写几次代码,细品一下
  • 下篇博客开始解析网络原理部分的内容
相关推荐
K成长日志1 小时前
BLE Host层L2CAP--数据包格式
网络·物联网·无线通信·蓝牙·iot·ble
lisanmengmeng1 小时前
NRPE 添加命令(四)
linux·运维·服务器
liangshanbo12151 小时前
主系统登录后,子系统怎么实现自动登录?
java·网络·数据库
FL16238631291 小时前
Siemens‑Automation‑License‑Manager & SIMATIC‑Automation‑Tool 下载安装教程
运维·服务器
此生决int2 小时前
Linux系列(04)——基础开发工具(上)软件包管理器、vim、gcc/g++
linux·服务器·vim
Huangjin007_2 小时前
【Linux 系统篇(二十六)】文件(三): 深入理解 “一切皆文件”、缓冲区
linux·运维·服务器
2401_868534782 小时前
2024年11月软考网规
运维·服务器
谢亮_vipxieliang2 小时前
Go 并发安全性保障
服务器·网络·golang
杨云龙UP2 小时前
Oracle 19c RAC到RAC Active Data Guard标准搭建与巡检指南
运维·服务器·数据库·oracle·adg·data guard·rac到rac