网络编程-TCP套接字

发布于:2025-02-10 ⋅ 阅读:(53) ⋅ 点赞:(0)

初始TCP套接字

我们在上一节简单的介绍了一下, 套接字的相关特性, 现在回顾一下
TCP的特性

  • 有连接: TCP套接字内部天然保存了对端的一些地址信息
  • 可靠传输: 尽最大的能力保障数据可以传输到指定位置
  • 面向字节流: 使用字节流的方式进行数据的传输, 大小没有限制, 但是可能存在一定的问题, 比如有可能出现粘包问题, 所以我们在传输的时候, 通常会使用一些特定的分隔符对数据的内容进行分割, 我们下面的测试代码也可以体现这一点
  • 全双工: 可以同时进行请求和响应

TCP的Socket API

和UDP相同, TCP也属于传输层的范畴, 所以在一台计算机内部, 就属于操作系统的管辖范围, 所以我们处在应用层的Java程序员, 就需要使用JVM提供的API接口进行编程(JVM也是封装了操作系统提供的一些API)


Socket

这个API主要是提供给客户端使用的, 当然服务器端也会使用, 是在服务器的ServerSocket对象调用accpet方法的时候作为返回值出现


常用的构造方法
在这里插入图片描述

  • 构造方法1: 创建一个套接字(未绑定对端地址)
  • 构造方法2: 创建一个套接字(绑定了对端的IP和端口号)
    注意, 该构造方法的IP可以直接传入字符串类型, 不用先转化为InetAddress类型然后传入(其实源码进行了一步转化操作)

常用方法

在这里插入图片描述

这个获取InputStream和OutputStream对象的方法, 可以说是最重要的方法, 因为我们的TCP是面向字节流传输的, 这个方法相当于提供了客户端和服务器端进行访问的通道…

在这里插入图片描述

和UDP类似, TCP的Socket操作的网卡资源, 也可以抽象为一种文件资源, 也占用文件操作符表的内存资源, 所以如果我们不用的话, 要及时的调用close方法进行资源的关闭…


ServerSocket

其实根据名字也不难看出, 这个ServerSocketAPI是专门给服务器端使用的, 客户端用的是另一套API, 等会再说


常用的两个构造方法

在这里插入图片描述

  • 构造方法1: 绑定一个随机的端口号(服务器不常用)
  • 构造方法2: 绑定一个固定的端口号(服务器常用的)

常用方法
在这里插入图片描述
上文我们说了, 在服务器的ServerSocketAPI的使用过程中, 也有Socket出现的时候, 正是当ServerSocket想要和客户端的Socket对象建立通信的时候, 本质上是调用accpet方法, 返回一个Socket对象, 然后使用该对象和客户端的Socket对象, 通过打开的IO通道进行通信

在这里插入图片描述
不再多说, 因为这也是一种文件的资源, 所以当我们不用的时候, 要进行及时的关闭, 避免占用文件操作符表的资源, 但是在真实的服务器的场景中, 使用这个方法的场景是有限的, 因为一般都是 7 * 24 小时持续运行

使用TCP模拟通信

下面我们简单写一个翻译的服务器, 来模拟一下使用TCP协议进行网络通信


服务器端

创建网卡以及构造方法

// 创建一个网卡Socket对象
    ServerSocket serverSocket = null;
    
    // 构造方法(传入一个固定的端口号作为服务器固定端口号)
    public TcpServer(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }
    

start方法进行和客户端通信的主流程

// start方法, 与客户端进行通信的主流程
    public void start() throws IOException {
        // 输出日志信息, 服务器上线
        System.out.println("服务器已上线...");
        // 使用while循环不断处理客户端发来的连接
        while(true){
            Socket connection = serverSocket.accept();
            // 处理这个连接
            processConnection(connection);
        }
    }

注意accept方法, 仅仅相当于客户端和服务器端之间建立了连接, 还没有进行请求和响应的内容…(类比)其实就相当于客户端和服务器端进行打电话, 只是拨通了, 还没开始说话

关于长短连接

上面我们说了, 建立连接之后, 才可以进行请求和响应, 那么这个连接中, 是包含一次请求响应还是多次请求响应的 ? 是客户端给服务器端发请求还是服务器端给客户端发请求 ? 这个都是说不准的, 要看具体的使用场景, 所以分为长短连接

  • 短连接: 一次连接只有一次请求响应, 比较消耗资源, 通常应用在查看网页, 内容展示等场景
  • 长连接: 一次连接中有多次请求响应, 比较节约资源, 且不仅仅可能是服务器端给客户端发请求, 服务器端也可能给客户端发请求, 通常应用在游戏, 在线聊天等场景

处理每一个连接的代码逻辑
关键内容都在注释中了

// 处理连接的方法, 这才是真正的进行请求与响应
    private void processConnection(Socket connection){
        // 输出日志, 表示客户端上线
        System.out.printf("客户端上线[%s:%d]", connection.getInetAddress().toString(), connection.getPort());
        // 打开connection的IO通道和客户端的联通(使用try-with-resource机制自动释放资源)
        try(InputStream inputStream = connection.getInputStream(); OutputStream outputStream = connection.getOutputStream()) {
            // 为了便于我们使用 IO, 我们对上面的输入输出流进行套壳处理(也可以不用)
            Scanner in = new Scanner(inputStream);
            PrintWriter out = new PrintWriter(outputStream);
            // 使用while循环不断尝试读取请求和响应
            while(true){
                // 1. 读取请求
                if(!in.hasNext()){
                    // 如果没有下一条请求了, 输出日志, 直接退出
                    System.out.printf("客户端下线[%s:%d]", connection.getInetAddress().toString(), connection.getPort());
                    break;
                }
                String req = in.next();
                
                // 2. 处理请求
                String resp = process(req);
                
                // 3. 发送请求
                out.println(resp);
                
                // 4. 记录日志
                System.out.printf("[req: %s, resp: %s]", req, resp);
            }

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

对请求进行处理的主函数, 和上次UDP一致, 都是汉译英的功能

// 处理请求的主方法(翻译)
    private static Map<String, String> chineseToEnglish = new HashMap<>();

    static {
        chineseToEnglish.put("小猫", "cat");
        chineseToEnglish.put("小狗", "dog");
        chineseToEnglish.put("小鹿", "fawn");
        chineseToEnglish.put("小鸟", "bird");
    }

    private String process(String req) {
        return chineseToEnglish.getOrDefault(req, "未收录该词条");
    }


客户端

创建网卡, 构造方法

// 创建网卡
    private Socket clientSocket = null;
    
    // 构造方法, 由于是Tcp协议, 所以直接绑定对端的地址信息
    public TcpClient(String host, int port) throws IOException {
        clientSocket = new Socket(host, port);
    }

start方法, 和服务器端的一些内容是相似的

// start方法, 与服务器建立通信, 请求与响应
    public void start(){
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream(); Scanner sc = new Scanner(System.in)) {
            // 把输入输出流进行包装
            Scanner in = new Scanner(inputStream);
            PrintWriter out = new PrintWriter(outputStream);
            
            // 使用while循环不断读取用户的请求, 发送请求并接收响应
            while(sc.hasNext()){
                // 1. 读取请求
                String req = sc.next();
                
                // 2. 发送请求
                out.println(req);
                
                // 3. 接收响应
                String resp = in.next();
                
                // 4. 输出响应内容
                System.out.println(resp);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

上述测试代码的问题分析

如果我们直接运行上面的代码, 我们就会发现, 是无法直接运行的, 说明上面的代码存在一些问题, 我们现在处理一下这些问题

IO的输入缓冲区的问题

有一些 IO 工具, 在输出的时候, 并不会是真正的输出, 而是将输出的内容放到一个缓冲区的地方, 必须调用flush()方法才能够真正的进行数据的发送, 所以我们在 IO 那个章节的时候, 建议是所有输出的流, 我们都进行flush()方法进行推送, 这是一个非常好的习惯, 所以上面的测试代码, 我们把所有使用out.println()的位置后面, 都加上flush()方法进行消息的推送

改进代码如下

	// 2. 发送请求
	out.println(req);
    out.flush();

关于TCP协议中的粘包的问题

由于TCP协议传输的时候, 是通过字节流的方式进行传输的, 所以不同的消息之间, 并没有一个非常明显的界限, 所以我们一般手动进行消息边界的指定, 避免消息的"粘连问题"
上述测试代码的逻辑中, 使用

	out.println(req);

因为println天然的就带有一个换行, 所以这就成为了一个天然的分割条件
关于如果解决粘包问题, 我们之后会仔细说, 这里只是简单介绍一下…


不能进行多线程通信的问题

分析下面的代码片段
在这里插入图片描述
在这里插入图片描述
假设有一台服务器, 此时客户端A尝试与服务器建立连接, 然后服务器进行连接的处理, 这时, 服务器就要阻塞等待in.hasNext()这里, 如果另一个客户端B也尝试和服务器建立连接, 那此时就不会有任何的反应(因为代码阻塞无法进行连接), 那岂不是一台服务器只能给一台客户端提供服务 ?
显然这样是不合理的, 所以此时我们就引入了多线程来解决这个问题, 通过不同的线程把处理连接的内容和接收连接的内容分隔开, 实质上这也就是早期发明多线程的原因(为了解决服务器开发的问题)

为了不频繁的创建销毁线程导致资源开销太大, 我们又引入了线程池来解决这个问题


处理问题之后的完整代码

启动多个实例

首先要设置启动的时候运行启动多个实例这一项, 来模拟同时启动多个客户端
在这里插入图片描述
勾选Allow multiple instances


完整代码

客户端代码

package net_demo1.net_demo04;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;

/**
 * 关于使用Tcp协议的客户端程序的模拟
 */

public class TcpClient {

    // 创建网卡
    private Socket clientSocket = null;

    // 构造方法, 由于是Tcp协议, 所以直接绑定对端的地址信息
    public TcpClient(String host, int port) throws IOException {
        clientSocket = new Socket(host, port);
    }

    // start方法, 与服务器建立通信, 请求与响应
    public void start(){
        try(InputStream inputStream = clientSocket.getInputStream();
            OutputStream outputStream = clientSocket.getOutputStream(); Scanner sc = new Scanner(System.in)) {
            // 把输入输出流进行包装
            Scanner in = new Scanner(inputStream);
            PrintWriter out = new PrintWriter(outputStream);

            // 使用while循环不断读取用户的请求, 发送请求并接收响应
            while(sc.hasNext()){
                // 1. 读取请求
                String req = sc.next();

                // 2. 发送请求
                out.println(req);
                out.flush();

                // 3. 接收响应
                String resp = in.next();

                // 4. 输出响应内容
                System.out.println(resp);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    public static void main(String[] args) throws IOException {
        TcpClient tcpClient = new TcpClient("127.0.0.1", 9090);
        tcpClient.start();
    }
}

服务器端代码

package net_demo1.net_demo04;

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.HashMap;
import java.util.Map;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 使用TCP协议模拟的服务器
 */

public class TcpServer {

    // 创建一个网卡Socket对象
    ServerSocket serverSocket = null;

    // 构造方法(传入一个固定的端口号作为服务器固定端口号)
    public TcpServer(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    // start方法, 与客户端进行通信的主流程
    public void start() throws IOException {
        // 创建一个线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 输出日志信息, 服务器上线
        System.out.println("服务器已上线...");
        // 使用while循环不断处理客户端发来的连接
        while (true) {

            Socket connection = serverSocket.accept();

            executorService.execute(() -> {
                // 处理这个连接
                processConnection(connection);
            });
        }
    }


    // 处理连接的方法, 这才是真正的进行请求与响应
    private void processConnection(Socket connection) {
        // 输出日志, 表示客户端上线
        System.out.printf("客户端上线[%s:%d]\n", connection.getInetAddress().toString(), connection.getPort());
        // 打开connection的IO通道和客户端的联通(使用try-with-resource机制自动释放资源)
        try (InputStream inputStream = connection.getInputStream(); OutputStream outputStream = connection.getOutputStream()) {
            // 为了便于我们使用 IO, 我们对上面的输入输出流进行套壳处理(也可以不用)
            Scanner in = new Scanner(inputStream);
            PrintWriter out = new PrintWriter(outputStream);
            // 使用while循环不断尝试读取请求和响应
            while (true) {
                // 1. 读取请求
                if (!in.hasNext()) {
                    // 如果没有下一条请求了, 输出日志, 直接退出
                    System.out.printf("客户端下线[%s:%d]\n", connection.getInetAddress().toString(), connection.getPort());
                    break;
                }
                String req = in.next();

                // 2. 处理请求
                String resp = process(req);

                // 3. 发送请求
                out.println(resp);
                out.flush();

                // 4. 记录日志
                System.out.printf("[req: %s, resp: %s]\n", req, resp);
            }

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


    // 处理请求的主方法(翻译)
    private static Map<String, String> chineseToEnglish = new HashMap<>();

    static {
        chineseToEnglish.put("小猫", "cat");
        chineseToEnglish.put("小狗", "dog");
        chineseToEnglish.put("小鹿", "fawn");
        chineseToEnglish.put("小鸟", "bird");
    }

    private String process(String req) {
        return chineseToEnglish.getOrDefault(req, "未收录该词条");
    }

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


测试结果

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


关于IO多路复用机制的引入

为什么需要IO多路复用机制

上面我们提到了, 在长连接的情况下, 每一个线程处理一个客户端, 通过多线程的机制来处理并发, 但是我们知道, 一台服务器也无法同时开启过多的线程, 如果并发量非常大的时候, 比如存在一个亿的并发量, 显然, 我们是无法创建一个亿的线程来处理请求的…


IO多路复用原理与NIO组件

对于处理请求的不同客户端来说, 大部分情况下都是沉默的, 也就是说, 在同一个短时间内部, 并发量其实并不是很大, 所以我们把原来一个线程处理一个客户端转化为一个线程处理多个客户端, 此时就可以提高 IO 的效率


在操作系统的层次上, 已经提供好了 IO 多路复用机制的API接口, 我们Java中使用NIO这种组件对IO多路复用机制进行了封装, 我们只需要进行接口的调用即可, 由于当前只是这种机制的引入, 具体的相关使用等待接下来的更新…


网站公告

今日签到

点亮在社区的每一天
去签到