SpringCloud源码:客户端分析(二)- 客户端源码分析

news2024/10/1 11:53:58

6f3ceaf83f7d086cdaa12f3d37927247.jpeg


背景

我们继续分析EurekaClient的两个自动化配置类:

自动化配置类
功能职责
EurekaClientAutoConfiguration配置EurekaClient确保了Eureka客户端能够正确地:
- 注册到Eureka服务端
- 周期性地发送心跳信息来更新服务租约
- 下线时通知Eureka服务端
- 获取服务实例列表;

更侧重于Eureka客户端的基本配置和功能实现
EurekaDiscoveryClientConfiguration配置EurekaDiscoveryClient创建RefreshScopeRefreshedEvent事件的监听类,用于重启注册;
更多地涉及到服务的自动注册、健康检查以及事件处理等方面

CloudEurekaClient分析

原理

客户端本质就是4个动作:

  1. 获取服务列表

  2. 注册服务实例

  3. 租约续约

  4. 取消租约

源码

让我们继续关注 第一个自动装配类 EurekaClientAutoConfiguration 对CloudEurekaClient 的构造封装,即如下代码块:

@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(value = EurekaClient.class,
      search = SearchStrategy.CURRENT)
public EurekaClient eurekaClient(ApplicationInfoManager manager,
      EurekaClientConfig config) {
   return new CloudEurekaClient(manager, config, this.optionalArgs,
         this.context);
}

分析代码:

  • CloudEurekaClient对象,并交给容器管理bean

CloudEurekaClient    

public class CloudEurekaClient extends DiscoveryClient {
    public CloudEurekaClient(ApplicationInfoManager applicationInfoManager,
          EurekaClientConfig config, AbstractDiscoveryClientOptionalArgs<?> args,
          ApplicationEventPublisher publisher) {
       super(applicationInfoManager, config, args);
       this.applicationInfoManager = applicationInfoManager;
       this.publisher = publisher;
       this.eurekaTransportField = ReflectionUtils.findField(DiscoveryClient.class,
             "eurekaTransport");
       ReflectionUtils.makeAccessible(this.eurekaTransportField);
    }
}

分析代码:

  • 实际上CloudEurekaClient调用了父类DiscoveryClient的构造器

DiscoveryClient

经历了多个重载构造器的嵌套,我们进入了最终的构造器:

private final ScheduledExecutorService scheduler;
// additional executors for supervised subtasks
private final ThreadPoolExecutor heartbeatExecutor;
private final ThreadPoolExecutor cacheRefreshExecutor;


@Inject
DiscoveryClient(ApplicationInfoManager applicationInfoManager, EurekaClientConfig config, AbstractDiscoveryClientOptionalArgs args,
                Provider<BackupRegistry> backupRegistryProvider, EndpointRandomizer endpointRandomizer) {
    // .... 一些初始化工作


    logger.info("Initializing Eureka in region {}", clientConfig.getRegion());


    try {
        // default size of 2 - 1 each for heartbeat and cacheRefresh
        scheduler = Executors.newScheduledThreadPool(2,
                new ThreadFactoryBuilder()
                        .setNameFormat("DiscoveryClient-%d")
                        .setDaemon(true)
                        .build());


        heartbeatExecutor = new ThreadPoolExecutor(
                1, clientConfig.getHeartbeatExecutorThreadPoolSize(), 0, TimeUnit.SECONDS,
                new SynchronousQueue<Runnable>(),
                new ThreadFactoryBuilder()
                        .setNameFormat("DiscoveryClient-HeartbeatExecutor-%d")
                        .setDaemon(true)
                        .build()
        );  // use direct handoff


        cacheRefreshExecutor = new ThreadPoolExecutor(
                1, clientConfig.getCacheRefreshExecutorThreadPoolSize(), 0, TimeUnit.SECONDS,
                new SynchronousQueue<Runnable>(),
                new ThreadFactoryBuilder()
                        .setNameFormat("DiscoveryClient-CacheRefreshExecutor-%d")
                        .setDaemon(true)
                        .build()
        );  // use direct handoff


        eurekaTransport = new EurekaTransport();
        scheduleServerEndpointTask(eurekaTransport, args);


        AzToRegionMapper azToRegionMapper;
        if (clientConfig.shouldUseDnsForFetchingServiceUrls()) {
            azToRegionMapper = new DNSBasedAzToRegionMapper(clientConfig);
        } else {
            azToRegionMapper = new PropertyBasedAzToRegionMapper(clientConfig);
        }
        if (null != remoteRegionsToFetch.get()) {
            azToRegionMapper.setRegionsToFetch(remoteRegionsToFetch.get().split(","));
        }
        instanceRegionChecker = new InstanceRegionChecker(azToRegionMapper, clientConfig.getRegion());
    } catch (Throwable e) {
        throw new RuntimeException("Failed to initialize DiscoveryClient!", e);
    }


    // .......


    if (clientConfig.shouldRegisterWithEureka() && clientConfig.shouldEnforceRegistrationAtInit()) {
        try {
            if (!register() ) {
                throw new IllegalStateException("Registration error at startup. Invalid server response.");
            }
        } catch (Throwable th) {
            logger.error("Registration error at startup: {}", th.getMessage());
            throw new IllegalStateException(th);
        }
    }


    // finally, init the schedule tasks (e.g. cluster resolvers, heartbeat, instanceInfo replicator, fetch
    initScheduledTasks();


    // ...其他初始化工作
}

代码分析:

  • 这里初始化了3个异步线程池:scheduler、heartbeatExecutor、cacheRefreshExecutor

    • scheduler:coreSize=2的周期任务线程池,线程名命名是DiscoveryClient-%s

    • heartbeatExecutor:coreSize=1的异步线程池,线程名命名是DiscoveryClient-HeartbeatExecutor-%d

    • cacheRefreshExecutor:coreSize=1的异步线程池,线程名命名是DiscoveryClient-CacheRefreshExecutor-%d

  • 这三个线程池,是怎么配合工作的呢?不着急,慢慢往下看

initScheduledTasks()的代码如下:
private void initScheduledTasks() {
    if (clientConfig.shouldFetchRegistry()) {
        // registry cache refresh timer
        int registryFetchIntervalSeconds = clientConfig.getRegistryFetchIntervalSeconds();
        int expBackOffBound = clientConfig.getCacheRefreshExecutorExponentialBackOffBound();
        cacheRefreshTask = new TimedSupervisorTask(
                "cacheRefresh",
                scheduler,
                cacheRefreshExecutor,
                registryFetchIntervalSeconds,
                TimeUnit.SECONDS,
                expBackOffBound,
                new CacheRefreshThread()
        );
        // 【1】
        scheduler.schedule(
                cacheRefreshTask,
                registryFetchIntervalSeconds, TimeUnit.SECONDS);
    }


    if (clientConfig.shouldRegisterWithEureka()) {
        int renewalIntervalInSecs = instanceInfo.getLeaseInfo().getRenewalIntervalInSecs();
        int expBackOffBound = clientConfig.getHeartbeatExecutorExponentialBackOffBound();
        logger.info("Starting heartbeat executor: " + "renew interval is: {}", renewalIntervalInSecs);


        // Heartbeat timer
        heartbeatTask = new TimedSupervisorTask(
                "heartbeat",
                scheduler,
                heartbeatExecutor,
                renewalIntervalInSecs,
                TimeUnit.SECONDS,
                expBackOffBound,
                new HeartbeatThread()
        );


        // 【2】
        scheduler.schedule(
                heartbeatTask,
                renewalIntervalInSecs, TimeUnit.SECONDS);


    } else {
        logger.info("Not registering with Eureka server per configuration");
    }
}

分析代码:

  • 【1】检查是否需要获取注册表信息(配置项eureka.client.fetchRegistry默认=true)

    • 用注入的异步线程池cacheRefreshExecutor,按指定时间间隔registryFetchIntervalSeconds,去执行CacheRefreshThread,即缓存刷新refreshRegistry()任务

    • 缓存刷新任务cacheRefreshTask

    • 使用调度器 scheduler 安排任务

  • 【2】检查是否需要注册入Eureka(配置项eureka.client.registerWithEureka默认=true)

    • 用注入的异步线程池heartbeatExecutor,按指定时间间隔renewalIntervalInSecs,去执行HeartbeatThread,即执行续租renew()任务

    • 心跳续租任务heartbeatTask

    • 使用调度器 scheduler 安排任务

CacheRefreshThread - 缓存刷新
class CacheRefreshThread implements Runnable {
    public void run() {
        refreshRegistry();
    }
}


@VisibleForTesting
void refreshRegistry() {
    try {
        //.....


        //【1】刷新本地注册服务列表
        boolean success = fetchRegistry(remoteRegionsModified);
        //.....
    } catch (Throwable e) {
        logger.error("Cannot fetch registry from server", e);
    }
}


private boolean fetchRegistry(boolean forceFullRegistryFetch) {
    try {
        // 【2】获取本地localRegionApps的服务列表
        Applications applications = getApplications();


        // 【3】获取远程数据并更新服务列表
        getAndUpdateDelta(applications);
    }


    // registry was fetched successfully, so return true
    return true;
}


private void getAndUpdateDelta(Applications applications) throws Throwable {
    // .....


    //【4】检查缓存delta的服务注册列表
    Applications delta = null;
    if (delta == null) {
        // 【4.1】如果缓存为空,就再去拉取一次EurekaServer的数据
        getAndStoreFullRegistry();
    } else {
        if (fetchRegistryUpdateLock.tryLock()) {
            try {
                //【5】获取EurekaServer最新的服务注册表,并执行delta更新
                getAndStoreFullRegistry()
                updateDelta(delta);
            } finally {
                fetchRegistryUpdateLock.unlock();
            }
        }    
    }
} 


private void getAndStoreFullRegistry() throws Throwable {
    long currentUpdateGeneration = fetchRegistryGeneration.get();
    Applications apps = null;
    EurekaHttpResponse<Applications> httpResponse = clientConfig.getRegistryRefreshSingleVipAddress() == null
            ? eurekaTransport.queryClient.getApplications(remoteRegionsRef.get())
            : eurekaTransport.queryClient.getVip(clientConfig.getRegistryRefreshSingleVipAddress(), remoteRegionsRef.get());
    if (httpResponse.getStatusCode() == Status.OK.getStatusCode()) {
        apps = httpResponse.getEntity();
    }
}


private void updateDelta(Applications delta) {
    int deltaCount = 0;
    //【6】遍历服务注册列表的每个app
    for (Application app : delta.getRegisteredApplications()) {
        //【7】遍历每个服务的所有实例instance
        for (InstanceInfo instance : app.getInstances()) {
            //【8】获取本地cache的服务注册信息
            Applications applications = getApplications();
            String instanceRegion = instanceRegionChecker.getInstanceRegion(instance);
            if (!instanceRegionChecker.isLocalRegion(instanceRegion)) {
                Applications remoteApps = remoteRegionVsApps.get(instanceRegion);
                if (null == remoteApps) {
                    remoteApps = new Applications();
                    remoteRegionVsApps.put(instanceRegion, remoteApps);
                }
                applications = remoteApps;
            }


            ++deltaCount;
            //【9】如果实例是新增的类型
            if (ActionType.ADDED.equals(instance.getActionType())) {
                Application existingApp = applications.getRegisteredApplications(instance.getAppName());
                if (existingApp == null) {
                    //【10】执行实例添加
                    applications.addApplication(app);
                }
                applications.getRegisteredApplications(instance.getAppName()).addInstance(instance);
            } 
            //【11】如果实例是修改的类型
            else if (ActionType.MODIFIED.equals(instance.getActionType())) {
                Application existingApp = applications.getRegisteredApplications(instance.getAppName());
                if (existingApp == null) {
                    //【12】没有已有实例,执行添加操作
                    applications.addApplication(app);
                }
                //【13】存在已有实例,则注册新的实例信息
                applications.getRegisteredApplications(instance.getAppName()).addInstance(instance);
            } 
            //【14】如果实例是删除的类型
            else if (ActionType.DELETED.equals(instance.getActionType())) {
                Application existingApp = applications.getRegisteredApplications(instance.getAppName());
                if (existingApp != null) {
                    //【15】删除这个服务的实例
                    existingApp.removeInstance(instance);
                    //【16】如果这个服务的实例数量=0,则直接删除服务信息app
                    if (existingApp.getInstancesAsIsFromEureka().isEmpty()) {
                        applications.removeApplication(existingApp);
                    }
                }
            }
        }
    }
}

代码分析:见下面流程图

34a3a1ce9a8b23a83da835f99ab9195e.png

HeartbeatThread - 心跳续租
private final Counter REREGISTER_COUNTER = Monitors.newCounter(PREFIX
        + "Reregister");


private class HeartbeatThread implements Runnable {


    public void run() {
        //【1】更新操作
        if (renew()) {
            lastSuccessfulHeartbeatTimestamp = System.currentTimeMillis();
        }
    }
}


boolean renew() {
    EurekaHttpResponse<InstanceInfo> httpResponse;
    try {
        //【2】客户端发送心跳包,获取响应
        httpResponse = eurekaTransport.registrationClient.sendHeartBeat(instanceInfo.getAppName(), instanceInfo.getId(), instanceInfo, null);
        //【3】响应码=404,说明服务在EurekaServer不存在
        if (httpResponse.getStatusCode() == Status.NOT_FOUND.getStatusCode()) {
            REREGISTER_COUNTER.increment();
            long timestamp = instanceInfo.setIsDirtyWithTime();
            //【4】客户端重新发起一次register操作,给EurekaServer
            boolean success = register();
            if (success) {
                instanceInfo.unsetIsDirty(timestamp);
            }
             //【5】EurekaServer注册成功,则续约成功
            return success;
        }
        //【6】响应码=200,则在EurekaServer侧续约成功了
        return httpResponse.getStatusCode() == Status.OK.getStatusCode();
    } catch (Throwable e) {
        logger.error(PREFIX + "{} - was unable to send heartbeat!", appPathIdentifier, e);
        return false;
    }
}

代码分析:见下面流程图

345ce2121267fe7585d492bf0921565c.png

取消租约
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(value = EurekaClient.class,
      search = SearchStrategy.CURRENT)
public EurekaClient eurekaClient(ApplicationInfoManager manager,
      EurekaClientConfig config) {
   return new CloudEurekaClient(manager, config, this.optionalArgs,
         this.context);
}


@PreDestroy
@Override
public synchronized void shutdown() {
    if (isShutdown.compareAndSet(false, true)) {
        logger.info("Shutting down DiscoveryClient ...");


        if (statusChangeListener != null && applicationInfoManager != null) {
            applicationInfoManager.unregisterStatusChangeListener(statusChangeListener.getId());
        }


        cancelScheduledTasks();


        // If APPINFO was registered
        if (applicationInfoManager != null
                && clientConfig.shouldRegisterWithEureka()
                && clientConfig.shouldUnregisterOnShutdown()) {
            applicationInfoManager.setInstanceStatus(InstanceStatus.DOWN);
            unregister();
        }


        if (eurekaTransport != null) {
            eurekaTransport.shutdown();
        }


        heartbeatStalenessMonitor.shutdown();
        registryStalenessMonitor.shutdown();


        Monitors.unregisterObject(this);


        logger.info("Completed shut down of DiscoveryClient");
    }
}


private void cancelScheduledTasks() {
    if (instanceInfoReplicator != null) {
        instanceInfoReplicator.stop();
    }
    if (heartbeatExecutor != null) {
        heartbeatExecutor.shutdownNow();
    }
    if (cacheRefreshExecutor != null) {
        cacheRefreshExecutor.shutdownNow();
    }
    if (scheduler != null) {
        scheduler.shutdownNow();
    }
    if (cacheRefreshTask != null) {
        cacheRefreshTask.cancel();
    }
    if (heartbeatTask != null) {
        heartbeatTask.cancel();
    }
}

代码分析:见下面流程图

2f689c9147234a3979b9b72c3722c439.png

小结

我们回到开头的原理,知道EurekaClient客户端本质就是4个动作:

  1. 获取服务列表:在CacheRefreshThread里有实现,即CacheRefreshThread的【4.1】步骤的eurekaTransport.queryClient.getApplications

  2. 注册服务实例:在HeartbeatThread里有实现,即HeartbeatThread的【4】步骤的eurekaTransport.registrationClient.register

  3. 租约续约:在HeartbeatThread里有实现,即HeartbeatThread的【2】步骤的eurekaTransport.registrationClient.sendHeartBeat

  4. 取消租约:在定义CloudEurekaClient的@Bean(destroyMethod = "shutdown")注解有生命

但我们还想知道,CacheRefreshThread 和 HeartbeatThread的背后通信,以及在EurekaServer的原理细节。可以,我们放到下一个章节再讲。

其他文章

Kafka消息堆积问题排查

基于SpringMVC的API灰度方案

理解到位:灾备和只读数据库

SQL治理经验谈:索引覆盖

Mybatis链路分析:JDK动态代理和责任链模式的应用

大模型安装部署、测试、接入SpringCloud应用体系

Mybatis插件-租户ID的注入&拦截应用

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

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

相关文章

Qt网络编程——QUdpSocket

文章目录 Qt网络编程QUdpSocketUdp回显服务器Udp客户端 Qt网络编程 网络编程本质上是写应用层代码&#xff0c;需要传输层提供支持。 而传输层最核心的协议就是UDP和TCP&#xff0c;这两个协议有较大差别&#xff0c;所以Qt提供了两套API。 要是有Qt网络编程的API&#xff0…

9.23-9.25学习

前置知识 docker&#xff1a;Docker-CSDN博客 docker安装mysql和 redis&#xff1a;https://blog.csdn.net/weixin_73118927/article/details/142530243?fromshareblogdetail&sharetypeblogdetail&sharerId142530243&sharereferPC&sharesourceweixin_7311892…

从预测性维护到智能物流:ARM边缘计算控制器的工业实践

工业4.0时代的到来&#xff0c;边缘计算技术成为连接物理世界与数字世界的桥梁。ARM架构的边缘计算控制器凭借其低功耗、高能效和灵活性等特点&#xff0c;在工业自动化领域展现出巨大潜力。本文将通过几个实际应用案例来探讨ARM边缘计算控制器是如何提升生产线效率和安全性的&…

03DSP学习-利用syscfg配置IO

上一篇博客介绍了syscfg&#xff0c;对syscfg有了初步的了解&#xff0c;但是在真正使用上它之前&#xff0c;还不能理解他是一个神器。 (在写博客的时候&#xff0c;我是在从头到尾重新完成这个步骤&#xff0c;希望对初学者有点帮助) 找到Board Component 打开syscfg文件&…

【Tourism】Yuncheng(4)

文章目录 1、永乐宫&#xff08;AAAA&#xff09;2、寿圣寺3、城隍庙4、池神庙 1、永乐宫&#xff08;AAAA&#xff09; 永乐宫&#xff0c;原名大纯阳万寿宫&#xff0c;位于山西省运城市芮城县以北约三公里处的龙泉村东&#xff0c;是为纪念八仙之一吕洞宾而修建的一座道教宫…

VR虚拟展厅可以在手机上打开吗?

虚拟展厅是可以在手机上打开的。 随着数字技术和虚拟现实技术的不断发展&#xff0c;虚拟展厅已经实现了多平台无缝对接&#xff0c;包括手机、平板、电脑等多种电子设备。具体来说&#xff0c;虚拟展厅通常采用HTML5360全景架构&#xff0c;这种架构使得虚拟展厅不需要安装任…

Mac优化清理工具CleanMyMac X 4.15.6 for mac中文版

CleanMyMac X 4.15.6 for mac中文版下载是一款功能更加强大的系统优化清理工具&#xff0c;软件只需两个简单步骤就可以把系统里那些乱七八糟的无用文件统统清理掉&#xff0c;节省宝贵的磁盘空间。CleanMyMac X 4.15.6 for mac 软件与最新macOS系统更加兼容&#xff0c;流畅地…

2024年配置YOLOX运行环境+windows+pycharm24.0.1+GPU

1.配置时间2024/9/25 2.Anaconda-python版本3.7&#xff0c;yolox版本0.2.0 YOLOX网址: https://github.com/Megvii-BaseDetection/YOLOX 本人下载的这个版本 1.创建虚拟环境 conda create -n yolox37 python37 激活 conda activate yolox37 2.安装Pytorch cuda等&…

vue路由的基本使用

文章目录 1.路由的基本使用2.路由的默认路径3.嵌套路由4.路由懒加载5.动态路由匹配 1.路由的基本使用 安装 vue-router&#xff1a; npm install vue-router创建路由文件&#xff1a; 在 src 目录下创建 router 目录&#xff0c;并在其中创建 index.js 文件。 在 main.js 中使…

【Linux操作系统】进程优先级

目录 一、为什么要有优先级二、进程优先级1.进程优先级的基本概念2.进程优先级的表现形式3. 默认优先级4.进程优先级的修改5.为什么调整优先级是要受限制的&#xff1f; 一、为什么要有优先级 在学习进程优先级之前&#xff0c;我们要先知道为什么会出现进程优先级这个东西 从…

2015年国赛高教杯数学建模A题太阳影子定位解题全过程文档及程序

2015年国赛高教杯数学建模 A题 太阳影子定位 技术就是通过分析视频中物体的太阳影子变化&#xff0c;确定视频拍摄的地点和日期的一种方法。   1.建立影子长度变化的数学模型&#xff0c;分析影子长度关于各个参数的变化规律&#xff0c;并应用你们建立的模型画出2015年10月…

C++入门day3-面向对象编程(下)

前言&#xff1a;C入门day3-面向对象编程&#xff08;中&#xff09;-CSDN博客 初识&#xff1a;继承特性 继承的基础语法 class A{ public:int a; }; class B:public A { public:int b; }; B类通过继承A类后&#xff0c;内部会继承一个int变量 a&#xff1a;从下图我们可以…

Nginx基础详解2(首页解析过程、进程模型、处理Web请求机制、nginx.conf语法结构)

续&#xff1a;Nginx基础详解1&#xff08;单体部署与集群部署、负载均衡、正反代理、nginx安装&#xff09;-CSDN博客 目录 4.Nginx默认首页的过程解析 5.Nginx进程模型的详解 5.1启动nginx后的关于nginx的进程查看 5.2master进程与process进程 5.3Nginx进程图解 5.4wo…

【已解决】【Hadoop】【./bin的使用】bash: ./bin/hdfs: 没有那个文件或目录

在 Hadoop 环境中&#xff0c;决定何时在命令前添加 ./bin 和如何处理路径 /home/hadoop 与 /usr/local/hadoop 的问题&#xff0c;主要取决于你的当前工作目录和环境变量的设置。以下是一些指导原则&#xff1a; 何时使用 ./bin&#xff1a; 当前目录是 Hadoop 安装目录&…

【分类|回归】深度学习中的分类和回归?离散数据or连续数值?

【分类|回归】深度学习中的分类和回归&#xff1f;离散数据or连续数值&#xff1f; 【分类|回归】深度学习中的分类和回归&#xff1f;离散数据or连续数值&#xff1f; 文章目录 【分类|回归】深度学习中的分类和回归&#xff1f;离散数据or连续数值&#xff1f;前言1.分类问题…

帆软通过JavaScript注入sql,实现数据动态查询

将sql语句设置为参数 新建数据库查询 设置数据库查询的sql语句 添加控件 JavaScript实现sql注入 添加事件 编写JavaScript代码 //获取评价人id var pjrid this.options.form.getWidgetByName("id").getValue();//显示评价人id alert("评价人&#xff1a;&…

单片机串口AT指令操作SIM800、900拨打电话

文章目录 一、前言1.1 功能简介1.2 拨打电话功能的应用场景1.3 SIM900A与SIM800C模块介绍1.4 原理图 三、模块调试3.1 工具软件下载3.2 准备好模块3.3 串口调试助手的设置3.4 初始化配置3.5 拨打电话的测试流程 四、代码实现4.1 底层的命令发送接口4.2 底层数据接收接口4.3 检测…

Cisco Packet Tracer的安装加汉化

这个工具学计算机网络的同学会用到 1.下载安装 网盘链接&#xff1a;https://pan.baidu.com/s/1CmnxAD9MkCtE7pc8Tjw0IA 提取码&#xff1a;frkb 点击第一个进行安装&#xff0c;按步骤来即可。 2.汉化 &#xff08;1&#xff09;复制chinese.ptl文件 &#xff08;2&…

四元组问题

目录 问题描述 输入格式 输出格式 样例输入 样例输出 说明 评测数据规模 运行限制 原题链接 代码思路 问题描述 从小学开始&#xff0c;小明就是一个非常喜欢数学的孩子。他喜欢用数学的方式解决各种问题。在他的高中时期&#xff0c;他遇到了一个非常有趣的问题&…

【Unity服务】如何使用Unity Version Control

Unity上的线上服务有很多&#xff0c;我们接触到的第一个一般就是Version Control&#xff0c;用于对项目资源的版本管理。 本文介绍如何为项目添加Version Control&#xff0c;并如何使用&#xff0c;以及如何将项目与Version Control断开链接。 其实如果仅仅是对项目资源进…