32.Netty源码之服务端如何处理客户端新建连接

news2024/9/27 7:26:44

highlight: arduino-light

服务端如何处理客户端新建连接

Netty 服务端完全启动后,就可以对外工作了。接下来 Netty 服务端是如何处理客户端新建连接的呢? 主要分为四步:

md Boss NioEventLoop 线程轮询客户端新连接 OP_ACCEPT 事件; ​ 构造 初始化Netty 客户端 NioSocketChannel; ​ 注册 Netty 客户端 NioSocketChannel 到 Worker 工作线程中; ​ 从 Worker group 选择一个 eventLoop 工作线程;注册到选择的eventLoop的Selector ​ 注册 OP_READ 事件到 NioSocketChannel 的事件集合。 ​

下面我们对每个步骤逐一进行简单的介绍。

接收新连接

bossGroup的EventLoop是一个线程是一个线程是一个线程。所以等服务器端启动起来以后就会执行线程的run方法逻辑。

java protected void run() {    for (;;) {        try {            try {            switch (selectStrategy.calculateStrategy                   (selectNowSupplier, hasTasks())) {                case SelectStrategy.CONTINUE:                    continue;                case SelectStrategy.BUSY_WAIT:                case SelectStrategy.SELECT:                    select(wakenUp.getAndSet(false)); // 轮询 I/O 事件                    if (wakenUp.get()) {                        selector.wakeup();                   }                default:               }           } catch (IOException e) {                rebuildSelector0();                handleLoopException(e);                continue;           }            cancelledKeys = 0;            needsToSelectAgain = false;            final int ioRatio = this.ioRatio;            if (ioRatio == 100) {                try {                    // 处理 I/O 事件                    processSelectedKeys();               } finally {                    runAllTasks(); // 处理所有任务               }           } else {                final long ioStartTime = System.nanoTime();                try {                    processSelectedKeys(); // 处理 I/O 事件               } finally {                    final long ioTime = System.nanoTime() - ioStartTime;                    // 处理完 I/O 事件,再处理异步任务队列                    runAllTasks(ioTime * (100 - ioRatio) / ioRatio);               }           }       } catch (Throwable t) {            handleLoopException(t);       }        try {            if (isShuttingDown()) {                closeAll();                if (confirmShutdown()) {                    return;               }           }       } catch (Throwable t) {            handleLoopException(t);       }   } } ​

NioEventLoop#processSelectedKeys

java // processSelectedKeys private void processSelectedKeys() { if (selectedKeys != null) { //不用JDK的selector.selectedKeys(), 性能更好(1%-2%),垃圾回收更少 processSelectedKeysOptimized(); } else { processSelectedKeysPlain(selector.selectedKeys()); } }

服务器端监听 OP_ACCEPT 事件读取消息

NioEventLoop#processSelectedKeysOptimized

Netty 中 Boss NioEventLoop 专门负责接收新的连接,关于 NioEventLoop 的核心源码我们下节课会着重介绍,在这里我们只先了解基本的处理流程。当客户端有新连接接入服务端时,Boss NioEventLoop 会监听到 OP_ACCEPT 事件,源码如下所示:

```java private void processSelectedKeysOptimized() { for (int i = 0; i < selectedKeys.size; ++i) { final SelectionKey k = selectedKeys.keys[i]; // null out entry in the array to allow to have it GC'ed once the Channel close // See https://github.com/netty/netty/issues/2363 selectedKeys.keys[i] = null;

//呼应于channel的register中的this: 
        //selectionKey = javaChannel().register(eventLoop()
        //                            .unwrappedSelector(), 0, this);
        final Object a = k.attachment();
         //因为客户端和服务器端的都继承自AbstractNioChannel
        if (a instanceof AbstractNioChannel) {
           //会进入判断
            processSelectedKey(k, (AbstractNioChannel) a);
        } else {
            @SuppressWarnings("unchecked")
            NioTask<SelectableChannel> task = (NioTask<SelectableChannel>) a;
            processSelectedKey(k, task);
        }

        if (needsToSelectAgain) {
            // null out entries in the array to allow to have it GC'ed once the Channel close
            // See https://github.com/netty/netty/issues/2363
            selectedKeys.reset(i + 1);

            selectAgain();
            i = -1;
        }
    }
}

```

NioEventLoop#processSelectedKey

```java private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) { final AbstractNioChannel.NioUnsafe unsafe = ch.unsafe(); if (!k.isValid()) { final EventLoop eventLoop; try { eventLoop = ch.eventLoop(); } catch (Throwable ignored) { return; }

if (eventLoop != this || eventLoop == null) {
            return;
        }
        // close the channel if the key is not valid anymore
        unsafe.close(unsafe.voidPromise());
        return;
    }

    try {
        int readyOps = k.readyOps();

        if ((readyOps & SelectionKey.OP_CONNECT) != 0) {
            int ops = k.interestOps();
            ops &= ~SelectionKey.OP_CONNECT;
            k.interestOps(ops);
            unsafe.finishConnect();
        }


        if ((readyOps & SelectionKey.OP_WRITE) != 0) {
            ch.unsafe().forceFlush();
        }

        //处理读请求(断开连接)或接入连接
        if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT))
                            != 0 || readyOps == 0) {
            //开始处理请求 服务器端处理的是OP_ACCEPT 接收新连接 
            unsafe.read();
        }
    } catch (CancelledKeyException ignored) {
        unsafe.close(unsafe.voidPromise());
    }
}

```

NioMessageUnsafe#read

NioServerSocketChannel 所持有的 unsafe 是 NioMessageUnsafe 类型。

我们看下 NioMessageUnsafe.read() 方法中做了什么事。

```java public void read() { assert eventLoop().inEventLoop(); final ChannelConfig config = config(); final ChannelPipeline pipeline = pipeline(); final RecvByteBufAllocator.Handle allocHandle = unsafe().recvBufAllocHandle(); allocHandle.reset(config); boolean closed = false; Throwable exception = null; try { try {

do {
              //readBuf一开始是一个空List
             //while 循环不断读取 Buffer 中的数据
             //创建底层SocketChannel并封装为NioSocketChannel放到readBuf返回1
            int localRead = doReadMessages(readBuf); 
            //执行完上面的方法 readBuf放的是新创建的NioSocketChannel
            if (localRead == 0) {
                break;
            }
            if (localRead < 0) {
                closed = true;
                break;
            }
            allocHandle.incMessagesRead(localRead);
            //是否需要继续读 因为是建立连接 所以总共读取的字节数是0 不会继续进入循环
            //这里一次最多处理16个连接
        } while (allocHandle.continueReading());
    } catch (Throwable t) {
        exception = t;
    }
    int size = readBuf.size();
    //readBuf放的是新创建的NioSocketChannel
    for (int i = 0; i < size; i ++) {
        readPending = false;
        // 对于服务器端NioServerSocketChannel来说 
        // handler有
        // 1.head 
        // 2.ClientLoggingHandler 
        // 3.ServerBootstrapAcceptor
        // 4.tail
        // 接下来就是调用服务器端的每个handler的channelRead方法 传播读取事件
        // 比如ClientLoggingHandler的channelRead用于打印接收到的消息到日志
        // 比如 serverBootStrapAcceptor的channelRead 
        //用于向客户端的SocketChannel的pipeline添加handler
        //就是把服务器端方法中的childHandler都添加到客户端的NioSocketChannel的pipeline
        //具体看serverBootStrapAcceptor的channelRead 方法
        pipeline.fireChannelRead(readBuf.get(i)); 
    }
    readBuf.clear();
    allocHandle.readComplete();
    // 传播读取完毕事件
    pipeline.fireChannelReadComplete(); 
    // 省略其他代码
} finally {
    if (!readPending && !config.isAutoRead()) {
        removeReadOp();
    }
}

} ```

可以看出 read() 方法的核心逻辑就是通过 while 循环不断读取数据,然后放入 List 中,这里的数据其实就是新连接。每次最多处理16个。

需要重点跟进一下 NioServerSocketChannel 的 doReadMessages() 方法。

接前面NioMessageUnsafe#read

继续接着NioMessageUnsafe#read看

1.NioServerSocketChannel #doReadMessages

接收&&创建&初始化客户端连接

```java protected int doReadMessages(List buf) throws Exception { //Netty 先通过 JDK 底层的 accept() 获取 JDK 原生的 SocketChannel //想想这里 在NIO编程的时候 是做了判断 如果是OPACCEPT事件 //执行 SocketChannel sChannel = ssChannel.accept(); //这里的accept方法返回的就是原生的SocketChannel SocketChannel ch = SocketUtils.accept(javaChannel()); try { if (ch != null) { //根据原生的 SocketChannel构造 Netty 客户端 NioSocketChannel //NioSocketChannel 的创建同样会完成几件事: //创建核心成员变量 id、unsafe、pipeline; //注册 SelectionKey.OPREAD 事件; //设置 Channel 的为非阻塞模式; //新建客户端 Channel 的配置。 //this是NioServerSocketChannel //最后把NioSocketChannel添加到buf返回1 //super(parent, ch, SelectionKey.OP_READ); //这里不是注册读事件只是赋值 buf.add(new NioSocketChannel(this, ch)); return 1; } } catch (Throwable t) { logger.warn("Failed to create a new channel from an accepted socket.", t); try { ch.close(); } catch (Throwable t2) { logger.warn("Failed to close a socket.", t2); } } return 0; }

protected AbstractNioChannel(Channel parent, SelectableChannel ch, int readInterestOp) { super(parent); this.ch = ch; //设置读事件到NioSocketChannel this.readInterestOp = readInterestOp; try { //非阻塞模式 ch.configureBlocking(false); } catch (IOException e) { try { ch.close(); } catch (IOException e2) { logger.warn( "Failed to close a partially initialized socket.", e2); }

throw new ChannelException("Failed to enter non-blocking mode.", e);
    }
}

public static SocketChannel accept(final ServerSocketChannel serverSocketChannel) throws IOException { try { return AccessController.doPrivileged(new PrivilegedExceptionAction () { @Override public SocketChannel run() throws IOException { // 非阻塞模式下,没有连接请求时,返回null return serverSocketChannel.accept(); } }); } catch (PrivilegedActionException e) { throw (IOException) e.getCause(); } } ```

这时就开始执行第二个步骤:构造 Netty 客户端 NioSocketChannel。Netty 先通过 JDK 底层的 accept() 获取 JDK 原生的 SocketChannel,然后将它封装成 Netty 自己的 NioSocketChannel。

新建 Netty 的客户端 Channel 的实现原理与上文中我们讲到的创建服务端 Channel 的过程是类似的,只是服务端 Channel 的类型是 NioServerSocketChannel,而客户端 Channel 的类型是 NioSocketChannel。

NioSocketChannel 的创建同样会完成几件事:创建核心成员变量 id、unsafe、pipeline;

注册 SelectionKey.OP_READ 事件;设置 Channel 的为非阻塞模式;新建客户端 Channel 的配置。

成功构造客户端 NioSocketChannel 后,接下来会通过 pipeline.fireChannelRead() 触发 channelRead 事件传播。对于服务端来说,此时 Pipeline 的内部结构如下图所示。

图片6.png

2.pipeline.fireChannelRead

上文中我们提到了一种特殊的处理器 ServerBootstrapAcceptor,在下面它就发挥了重要的作用。channelRead 事件会传播到 ServerBootstrapAcceptor.channelRead() 方法,channelRead() 会将客户端 Channel 分配到工作线程组中去执行。具体实现如下:

触发服务器端hanlder#channelRead

ServerBootstrapAcceptor#channelRead

java //ServerBootstrapAcceptor负责接收客户端连接 创建连接后,对连接的初始化工作。 // ServerBootstrapAcceptor.channelRead() 方法 public void channelRead(ChannelHandlerContext ctx, Object msg) { final Channel child = (Channel) msg; //childHandler是我们自定义的EchoServer的代理类 child.pipeline().addLast(childHandler); setChannelOptions(child, childOptions, logger); setAttributes(child, childAttrs); try { // 注册客户端 Channel到工作线程组 //1.MultithreadEventLoopGroup#register childGroup.register(child).addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { if (!future.isSuccess()) { forceClose(child, future.cause()); } } }); } catch (Throwable t) { forceClose(child, t); } }

ServerBootstrapAcceptor 开始就把 msg 强制转换为 Channel。难道不会有其他类型的数据吗?

因为 ServerBootstrapAcceptor 是服务端 Channel 中一个特殊的处理器,而服务端 Channel 的 channelRead 事件只会在新连接接入时触发,所以这里拿到的数据都是客户端新连接。

register():注册客户端 Channel

java //MultithreadEventLoopGroup#register //从workGroup中选择一个EventLoop注册到channel @Override public ChannelFuture register(Channel channel) { return next().register(channel); }

```java //io.netty.channel.nio.AbstractChannel#register0 private void register0(ChannelPromise promise) { try { if (!promise.setUncancellable() || !ensureOpen(promise)) { return; } boolean firstRegistration = neverRegistered; //绑定选择器 doRegister(); neverRegistered = false; registered = true; //给客户端添加处理器 pipeline.invokeHandlerAddedIfNeeded(); safeSetSuccess(promise); pipeline.fireChannelRegistered();

//NioServerSocketChannel的注册不会走进下面if(isActive())
            //NioSocketChannel可以走进去if(isActive())。因为accept后就active了。
            if (isActive()) {
                if (firstRegistration) {
                    //第一次注册需要触发pipeline上的hanlder的read事件
                    //实际上就是注册OP_ACCEPT/OP_READ事件:创建连接或者读事件
                    //首先会进入DefaultChannelPipeLine的read方法
                    pipeline.fireChannelActive();
                } else if (config().isAutoRead()) {
                    //第二次注册的时候
                    beginRead();
                }
            }
        } catch (Throwable t) {
            // Close the channel directly to avoid FD leak.
            closeForcibly();
            closeFuture.setClosed();
            safeSetFailure(promise, t);
        }
    }

```

客户端SocketChannel绑定selector
AbstractNioChannel#doRegister

java //io.netty.channel.nio.AbstractNioChannel#doRegister @Override protected void doRegister() throws Exception { boolean selected = false; for (;;) { try { logger.info("initial register: " + 0); //这里的事件类型仍然是0 //attachement是NioSocketChannel selectionKey = javaChannel().register (eventLoop().unwrappedSelector(), 0, this); return; } catch (CancelledKeyException e) { if (!selected) { // Force the Selector to select now as the "canceled" //SelectionKey may still be // cached and not removed because no //Select.select(..) operation was called yet. eventLoop().selectNow(); selected = true; } else { // We forced a select operation on the selector before but the SelectionKey is still cached // for whatever reason. JDK bug ? throw e; } } } }

DefaultChannelPipeline.HeadContext#read

java //io.netty.channel.DefaultChannelPipeline.HeadContext#read @Override public void read(ChannelHandlerContext ctx) { //实际上就是注册OP_ACCEPT/OP_READ事件:创建连接或者读事件 unsafe.beginRead(); }

```java @Override public final void beginRead() { assertEventLoop();

if (!isActive()) {
            return;
        }
        try {
            doBeginRead();
        } catch (final Exception e) {
            invokeLater(new Runnable() {
                @Override
                public void run() {
                    pipeline.fireExceptionCaught(e);
                }
            });
            close(voidPromise());
        }
    }

```

```java @Override protected void doBeginRead() throws Exception { // Channel.read() or ChannelHandlerContext.read() was called final SelectionKey selectionKey = this.selectionKey; if (!selectionKey.isValid()) { return; }

readPending = true;

    final int interestOps = selectionKey.interestOps();
    //super(parent, ch, SelectionKey.OP_READ);
    //假设之前没有监听readInterestOp,则监听readInterestOp
    if ((interestOps & readInterestOp) == 0) {
        //NioServerSocketChannel: readInterestOp = OP_ACCEPT = 1 << 4 = 16
        logger.info("interest ops: " + readInterestOp);
        selectionKey.interestOps(interestOps | readInterestOp);
    }
}

```

ServerBootstrapAcceptor 通过 childGroup.register() 方法会完成第三和第四两个步骤.

1.将 NioSocketChannel 注册到 Worker 工作线程中

2.并注册 OP_READ 事件到 NioSocketChannel 的事件集合。

在注册过程中比较有意思的一点是,它会调用 pipeline.fireChannelRegistered() 方法传播 channelRegistered 事件,然后再调用 pipeline.fireChannelActive() 方法传播 channelActive 事件。

兜了一圈,这又会回到之前我们介绍的 readIfIsAutoRead() 方法,此时它会将 SelectionKey.OP_READ 事件注册到 Channel 的事件集合。

添加自定义handler到客户端SocketChannel
pipeline.invokeHandlerAddedIfNeeded

总结

java •接受连接本质: ​ selector.select()/selectNow()/select(timeoutMillis) 发现 OP_ACCEPT 事件,处理: ​ •SocketChannel socketChannel = serverSocketChannel.accept() ​ •selectionKey = javaChannel().register(eventLoop().unwrappedSelector(), 0, this); ​ •selectionKey.interestOps(OP_READ); ​

关于服务端如何处理客户端新建连接的具体源码,我在此就不继续展开了。这里留一个小任务,建议你亲自动手分析下 childGroup.register() 的相关源码,从而加深对服务端启动以及新连接处理流程的理解。有了服务端启动源码分析的基础,再去理解客户端新建连接的过程会相对容易很多。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/889664.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

CS:GO升级 Linux不再是“法外之地”

在前天的VAC大规模封禁中&#xff0c;有不少Linux平台的作弊玩家也迎来了“迟到”的VAC封禁。   一直以来&#xff0c;Linux就是VAC封禁的法外之地。虽然大部分玩家都使用Windows平台进行游戏。但实际上&#xff0c;使用Linux畅玩CS:GO的玩家也不在少数。 以前V社主要打击W…

【React学习】—组件三大核心属性: state(七)

【React学习】—组件三大核心属性: state&#xff08;七&#xff09; 2.2.2. 理解 state是组件对象最重要的属性, 值是对象(可以包含多个key-value的组合)组件被称为"状态机", 通过更新组件的state来更新对应的页面显示(重新渲染组件) 2.2.3. 强烈注意 组件中rend…

版本控制工具Git集成IDEA的学习笔记(第一篇Gitee)

目录 一、Gitee的使用 1、注册网站会员 2、用户中心 3、创建远程仓库 4、配置SSH免密登录 二、集成IDEA&#xff0c;Git项目搭建 1、本地仓库搭建 1&#xff09;创建一个新项目 2&#xff09;打开终端&#xff0c;在当前目录新建一个Git代码库 3&#xff09;忽略文件 …

《HeadFirst设计模式(第二版)》第八章代码——模板方法模式

代码文件目录&#xff1a; CaffeineBeverage package Chapter8_TemplateMethodPattern;/*** Author 竹心* Date 2023/8/17**/public abstract class CaffeineBeverage {final void prepareRecipe(){boilWater();brew();pourInCup();//这里使用钩子customerWantsCondiments()来…

JavaScript 快速入门手册

本篇文章学习&#xff1a; 菜鸟教程、尚硅谷。 JavaScript 快速入门手册 &#x1f4af; 前言&#xff1a; 本人目前算是一个Java程序员&#xff0c;但是目前环境… ε(ο&#xff40;*))) 一言难尽啊&#xff0c;blog也好久好久没有更新了&#xff0c;一部分工作原因吧(外包真…

【word密码】word怎么限制格式,但可以修改文字?

想要限制word文件中文字的格式&#xff0c;但是又希望别人能够删除、输入文字&#xff0c;想要实现这种设置我们可以对word文件设置限制编辑。 点击word文件工具栏中的审阅 – 限制编辑&#xff0c;勾选上【限制对选定的样式设置格式】 然后在弹出的提示框中&#xff0c;输入我…

【Rust】Rust学习 第十四章智能指针

指针 &#xff08;pointer&#xff09;是一个包含内存地址的变量的通用概念。这个地址引用&#xff0c;或 “指向”&#xff08;points at&#xff09;一些其他数据。Rust 中最常见的指针是第四章介绍的 引用&#xff08;reference&#xff09;。引用以 & 符号为标志并借用…

C#__事件event的简单使用:工具人下楼问题

// 工具人类 namespace DownStair {delegate void DownStairDelegate(); // 定义了一个下楼委托class ToolMan{public string Name { get; set; } // 声明工具人的名字属性// public DownStairDelegate downStairDelegate null; // 初始化委托downStair为空委托// 解决方案pu…

分布式可视化 DAG 任务调度系统 Taier 的整体流程分析

Taier 作为袋鼠云的开源项目之一&#xff0c;是一个分布式可视化的 DAG 任务调度系统。旨在降低 ETL 开发成本&#xff0c;提高大数据平台稳定性&#xff0c;让大数据开发人员可以在 Taier 直接进行业务逻辑的开发&#xff0c;而不用关心任务错综复杂的依赖关系与底层的大数据平…

Unknown tree updater grow_gpu_histb报错

报错显示&#xff1a;由于xgboost的问题而报错 报错显示&#xff1a;Unknown tree updater grow_gpu_histb 原因是 XGBoost 在尝试使用 GPU 加速时无法识别指定的树更新器。也就是当前xgboost版本中没有grow_gpu_histb组件&#xff0c;所以需要安装正确的版本。 经搜索&#…

Git如何上传文件到github

Git下载网址&#xff1a; https://git-scm.com/downloads 1. 新建一个空文件夹&#xff0c;用来上传文件&#xff0c;第一次需创建&#xff0c;以后无需创建 2. 点进去空文件夹&#xff0c;鼠标右键&#xff0c;使用Git Bash Here 打开 3. 克隆远程仓库&#xff1a;git cl…

【业务功能篇67】异构数据源表结构迁移

业务涉及到需要将数据库迁移&#xff0c;并且还换了不同厂商的&#xff0c;比如Oracle 迁移到 Mysql, 方式一&#xff1a;Navicat工具 最简单的做法&#xff0c;由于是不同数据库类型的,sql语法可能会有点差别&#xff0c;直接用Navicat客户端&#xff0c;把两个数据库连接&a…

mac上如何压缩视频大小?

mac上如何压缩视频大小&#xff1f;由于视频文件体积庞大&#xff0c;常常会占据我们设备的大量存储空间。通常情况下&#xff0c;我们选择删除视频以释放内存&#xff0c;但这将永久丢失它们。然而&#xff0c;有一种更好的方法可以在不删除视频的情况下减小内存占用&#xff…

微信小程序 蓝牙设备连接,控制开关灯

1.前言 微信小程序中连接蓝牙设备&#xff0c;信息写入流程 1、检测当前使用设备&#xff08;如自己的手机&#xff09;是否支持蓝牙/蓝牙开启状态 wx:openBluetoothAdapter({}) 2、如蓝牙已开启状态&#xff0c;检查蓝牙适配器的状态 wx.getBluetoothAdapterState({}) 3、添加…

PHP 公交公司充电桩管理系统mysql数据库web结构apache计算机软件工程网页wamp

一、源码特点 PHP 公交公司充电桩管理系统是一套完善的web设计系统&#xff0c;对理解php编程开发语言有帮助&#xff0c;系统具有完整的源代码和数据库&#xff0c;系统主要采用B/S模式开发。 源码下载 https://download.csdn.net/download/qq_41221322/88220946 论文下…

如何把PDF转换成PPT?试试这个转换方法

在现代办公和学习中&#xff0c;我们经常会遇到需要将PDF文件转换成PPT文件的需求。PDF作为一种通用的文件格式&#xff0c;被广泛应用于文档的共享和传递。通过将PDF转换成PPT&#xff0c;我们可以更好地编辑、演示和展示内容&#xff0c;适应不同设备和平台&#xff0c;提升交…

PyQt6学习第一篇

PyQt6中文文档 PyQt6 Digia 公司的 Qt 程序的 Python 中间件。Qt库是最强大的GUI库之一。PyQt6 是基于 Python 的一系列模块。它是一个多平台的工具包&#xff0c;可以在包括Unix、Windows和Mac OS在内的大部分主要操作系统上运行。PyQt6 有两个许可证&#xff0c;开发人员可以…

OJ练习第149题—— 二叉树中的最大路径和

二叉树中的最大路径和 力扣链接&#xff1a;124. 二叉树中的最大路径和 题目描述 二叉树中的 路径 被定义为一条节点序列&#xff0c;序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点&#xff0c;且不一定经过根…

三分钟上手!一文看懂 Git 的底层工作原理

这是一篇能让你迅速了解 Git 工作原理的文章&#xff0c;实战案例解析&#xff0c;相信我&#xff0c;3 分钟&#xff0c;绝对能够有收获&#xff01; Git 目录结构 Git 的本质是一个文件系统&#xff08;很重要&#xff0c;记住这句话&#xff0c;理解这句话&#xff09;&…

极简版VS自定义:我选择极简版

极简版VS自定义&#xff1a;我选择极简版 &#x1f607;博主简介&#xff1a;我是一名正在攻读研究生学位的人工智能专业学生&#xff0c;我可以为计算机、人工智能相关本科生和研究生提供排忧解惑的服务。如果您有任何问题或困惑&#xff0c;欢迎随时来交流哦&#xff01;&…