• 学习了解nacos原理以及源码解析



    干就完了

    Nacos介绍

    英文全称Dynamic Naming and Configuration Service,Na为naming/nameServer即注册中心,co为configuration即注册中心,service是指该注册/配置中心都是以服务为核心(相当于Spring Cloud的Eureka+Config)。

    • 服务发现(服务注册中心)

    • 配置管理(服务配置中心

    nacos配置管理

    配置管理:动态管理发布配置,无需重启服务,更好保证服务的可用。(其实这里的无需重启服务是针对某些情况下的,只是为了说明nacos配置发生变更后,服务端和客户端会进行通信,然后获取到变更的信息;但是有些配置信息是在应用服务初始化启动时加载进来的,在服务运行时不再获取配置信息的,如果配置信息发生变更,则需要重启服务。比如:数据库连接池大小初始化时加载。)
    nacos配置页面

    Nacos使用手册

    配置管理

    1.发布配置——打开nacos控制台,并点击菜单配置管理->配置列表,新增配置。
    (yml与properties一样,只是yml更简洁)
    2.添加依赖——nacos-config
    3.创建配置文件

    空间切换

    在实际开发中,通常有多套不同的环境(默认只有public),那么这个时候可以根据指定的环境来创建不同的namespce,例如,开发、测试和生产(还有预生产)三个不同的环境,那么使用一套 nacos 集群可以分别建以下三个不同的 namespace。以此来实现多环境的隔离。
    空间域名
    在项目模块中,修改bootstrap.properties添加如下配置:

    #指定命名空间
    spring.cloud.nacos.config.namespace=命名空间ID
    

    补充:

    (1)配置文件加载顺序

    • bootstrap.yml(bootstrap.properties)先加载——应用程序上下文的引导
    • application.yml(application.properties)后加载——由父Spring ApplicationContext加载

    (2)配置多个开发环境
    修改项目bootstrap.properties配置文件,添加一行配置

    spring.profiles.active=xxx
    

    Nacos原理

    服务注册

    服务注册:Nacos注册中心分为server与client,server采用Java编写,为client提供注册发现服务与配置服务。而client可以用多语言实现,client与微服务嵌套在一起,Nacos提供sdk和openApi。(理解zk)
    nacos服务注册
    补充:服务注册的策略的是每5秒向nacos server发送一次心跳,心跳带上了服务名,服务ip,服务端口等信息。同时 nacos server也会向client 主动发起健康检查,支持tcp/http检查。如果15秒内无心跳且健康检查失败则认为实例不健康,如果30秒内健康检查失败则剔除实例。就这啊

    服务发现

    nacos支持两种服务发现方式:

    • 一种是直接去Nacos服务端拉取某个服务的实例列表,就像Eureka那样定时去拉取注册表信息;
    • 另一种是服务订阅的方式,就是订阅某个服务,然后这个服务下面的实例列表一旦发生变化,Nacos服务端就会使用UDP的方式通知客户端,并将实例列表带过去。
    • SpringBoot的启动类,需要添加 @EnableDiscoveryClient 注解,将扫描到的接口注册到Nacos服务中心

    Istio

    sitio作用
    Istio 就是我们上述提到的 service mesh 的一种实现。
    istio

    cap

    Consistency 一致性,在分布式系统中的所有数据备份,在同一时刻是否同样的值;
    Availability 可用性,只要收到用户的请求,服务器就必须给出回应;
    Partition tolerance 分区容错性。(zk:CP,Eureka:AP,nacos:CP+AP)
    Raft协议:通俗的就是“民主投票任期责任制”C。
    Distro协议:通俗的就是“服务联产承包责任制”A。
    五名字
    cap

    CAP——CP(Raft)

    一般来说,如果需要在服务级别编辑或者存储配置信息,那么CP是必须的;如果对数据的一致性要求很高,那么就需要CP模式。 (zk:CP)
    服务模式

    CAP——AP(Distro)

    一般来说,如果不需要存储服务级别的信息且服务实例是通过nacos-client注册,并能够保持心跳上报,那么就可以选择AP模式。( Eureka:AP)
    Distro协议服务端节点发现使用寻址机制来实现服务端节点的管理。之所以使用临时服务的模式,是因为临时数据通常和服务器保持一个session会话, 该会话只要存在,数据就不会丢失

    • 客户端与服务端有两个重要的交互,服务注册与心跳发送;
    • 心跳包需要带上注册服务的全部信息,在客户端看来,服务端节点对等,所以请求的节点是随机的;
    • 服务端节点都存储所有数据,但每个节点只负责其中一部分服务,在接收到客户端的“写”(注册、心跳、下线等)请求后,服务端节点判断请求的服务是否为自己负责,如果是,则处理,否则交由负责的节点处理
    • 服务端在接收到客户端的服务心跳后,如果该服务不存在,则将该心跳请求当做注册请求来处理;
    • 节点在收到读请求后直接从本机获取后返回,无论数据是否为最新(基本没啥一致性可言)。

    Nacos源码分析

    源码架构

    源码架构

    结构说明:

    ①cmdb顾名思义,配置管理数据库。用于管理nacos的各种配置资源(提前配置好的,不需要我们使用者的初始化创建)。它是支撑自动化交付平台(DevOps的持续交付)的核心基础模块;
    ②nacos-example提供了使用nacos的示例代码,从这里也能看出来,我们使用nacos时真正关心的只有服务器配置、配置中心管理以及注册中心管理(示例代码静态配置写死);
    nacos配置示例
    ③Istio基于Service Mesh的理念,承担着服务发现、服务通信、负载均衡、限流熔断、监控等等功能。Nacos直接采用Istio,可以让nacos不用关心这些底层逻辑,专注于nacos本身的业务的开发和Istio的服务功能访问即可。

    服务注册源码分析

    直接拉取客户端

    在客户端调用服务订阅接口时,会将客户端的UPD信息(IP和端口)上送到注册中心,注册中心以PushClient对象来进行封装和存储。当注册中心有实例变化时,会发布一个ServiceChangeEvent事件,注册中心监听到这个事件之后,会遍历存储的PushClient,基于UDP协议对客户端进行通知。客户端接收到UDP通知,即可更新本地缓存的实例列表。
    程序通过创建一个NamingService ,接着注册了一个服务实例,最后是调用了getAllInstances方法获取某个服务的实例列表。

    	@Override
        public List<Instance> getAllInstances(String serviceName, String groupName, List<String> clusters,
                boolean subscribe) throws NacosException {
            ServiceInfo serviceInfo;
            String clusterString = StringUtils.join(clusters, ",");
            //是否订阅,默认是订阅的,也就是subscribe =true
            if (subscribe) {
                serviceInfo = serviceInfoHolder.getServiceInfo(serviceName, groupName, clusterString);
                if (null == serviceInfo) {
                    serviceInfo = clientProxy.subscribe(serviceName, groupName, clusterString);
                }
            } else {
                //不进行订阅
                serviceInfo = clientProxy.queryInstancesOfService(serviceName, groupName, clusterString, 0, false);
            }
            List<Instance> list;
            if (serviceInfo == null || CollectionUtils.isEmpty(list = serviceInfo.getHosts())) {
                return new ArrayList<Instance>();
            }
            return list;
        }
    

    :UDP端口是0 ,因为这里是不订阅的,另外,这个UDP是给订阅的接收通知用的。

    • udpPort:UDP端口,如果为0表示不订阅;
    • clientIP:客户端IP地址,此处获取本地IP地址;
    • healthyOnly:单个健康实例

    最后调用reqApi 选择server 发送请求了,请求url是 /nacos/…/instance/list ,请求方法是get。

    	@Override
        public ServiceInfo queryInstancesOfService(String serviceName, String groupName, String clusters, int udpPort,
                boolean healthyOnly) throws NacosException {
            final Map<String, String> params = new HashMap<String, String>(16);
            params.put(CommonParams.NAMESPACE_ID, namespaceId);
            params.put(CommonParams.SERVICE_NAME, NamingUtils.getGroupedName(serviceName, groupName));
            params.put(CLUSTERS_PARAM, clusters);
            params.put(UDP_PORT_PARAM, String.valueOf(udpPort));//UDP=0,即为不订阅
            params.put(CLIENT_IP_PARAM, NetUtils.localIP());
            params.put(HEALTHY_ONLY_PARAM, String.valueOf(healthyOnly));
            //生成url
            String result = reqApi(UtilAndComs.nacosUrlBase + "/instance/list", params, HttpMethod.GET);
            if (StringUtils.isNotEmpty(result)) {
                return JacksonUtils.toObj(result, ServiceInfo.class);
            }
            return new ServiceInfo(NamingUtils.getGroupedName(serviceName, groupName), clusters);
        }
    

    直接拉取服务端

    InstanceController-list方法
    在这里插入图片描述
    获取对应的service对象,并判断UDP以及客户端等信息是否适合推送。紧接着根据cluster获取这个服务下面对应的实例集合,并进行筛选操作。

    		Service service = serviceManager.getService(namespaceId, serviceName);//获取服务
            long cacheMillis = switchDomain.getDefaultCacheMillis();//默认cache时间
            // now try to enable the push
            try {//判断这个客户端是否可用,(客户端语言、配置、版本等信息)
                if (subscriber.getPort() > 0 && pushService.canEnablePush(subscriber.getAgent())) {
                    subscriberServiceV1.addClient(namespaceId, serviceName, cluster, subscriber.getAgent(),
                            new InetSocketAddress(clientIP, subscriber.getPort()), pushDataSource, StringUtils.EMPTY,
                            StringUtils.EMPTY);//添加客户端
                    cacheMillis = switchDomain.getPushCacheMillis(serviceName);
                }
            } catch (Exception e) {
                Loggers.SRV_LOG.error("[NACOS-API] failed to added push client {}, {}:{}", clientInfo, clientIP,
                        subscriber.getPort(), e);
                cacheMillis = switchDomain.getDefaultCacheMillis();
            }//异常信息
            if (service == null) {
                if (Loggers.SRV_LOG.isDebugEnabled()) {
                    Loggers.SRV_LOG.debug("no instance to serve for service: {}", serviceName);
                }
                result.setCacheMillis(cacheMillis);
                return result;
            }//判断是否有服务
            checkIfDisabled(service);//检查服务是否可用
            List<com.alibaba.nacos.naming.core.Instance> srvedIps = service
                    .srvIPs(Arrays.asList(StringUtils.split(cluster, StringUtils.COMMA)));//从服务中找到实例
            // filter ips using selector:使用过滤器过滤
            if (service.getSelector() != null && StringUtils.isNotBlank(clientIP)) {
                srvedIps = selectorManager.select(service.getSelector(), clientIP, srvedIps);
            }
    

    用来遍历区分健康(true)的实例与不健康(false)的实例,接着就是判断是否检查,默认是false的,获取服务保护的阈值,默认是0 ; 如果健康的服务实例数量占比小于这个阈值的话,就会将不健康的实例也放到健康的里面,这就是nacos的服务保护机制。问题:可以联想下Eureka的服务保护机制?

            //遍历所有实例,区分开健康实例 不健康实例
            for (com.alibaba.nacos.naming.core.Instance ip : srvedIps) {
                if (!ip.isEnabled()) {
                    continue;
                }// remove disabled instance:
                ipMap.get(ip.isHealthy()).add(ip);
                total += 1;
            }
            //保护边界阈值
            double threshold = service.getProtectThreshold();
            List<Instance> hosts;
            if ((float) ipMap.get(Boolean.TRUE).size() / total <= threshold) {
                //如果存活的不健康实例阈值小于既定阈值的话,则进行保护加到健康实例中;
                Loggers.SRV_LOG.warn("protect threshold reached, return all ips, service: {}", result.getName());
                result.setReachProtectionThreshold(true);
                hosts = Stream.of(Boolean.TRUE, Boolean.FALSE).map(ipMap::get).flatMap(Collection::stream)
                        .map(InstanceUtil::deepCopy)
                        // set all to `healthy` state to protect
                        .peek(instance -> instance.setHealthy(true)).collect(Collectors.toCollection(LinkedList::new));
            } else {
                result.setReachProtectionThreshold(false);
                hosts = new LinkedList<>(ipMap.get(Boolean.TRUE));
                if (!healthOnly) {
                    hosts.addAll(ipMap.get(Boolean.FALSE));
                }
            }
    

    订阅通知客户端

    ServiceInfoHolder:先是根据clusters与serviceName生成一个订阅key,接着就是调用getServiceInfo0 方法获取本地的一个缓存,然后去serviceInfoMap 这个map中获取,它你可以理解成一个本地的缓存。紧接着就是调用serverProxy 的queryInstancesOfService。

            //群组服务名称
            String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
            String key = ServiceInfo.getKey(groupedServiceName, clusters);//根据name 和名称生成key
            if (failoverReactor.isFailoverSwitch()) {
                return failoverReactor.getService(key);
            }
            return serviceInfoMap.get(key);//返回key
    

    ServiceInfoUpdateService-scheduleUpdateIfAbsent:如果有了的话直接返回,很显然第一次请求肯定是没有的,然后通过调用了addTask方法添加一个task,然后返回一个future,并缓存到map中去。

        public void scheduleUpdateIfAbsent(String serviceName, String groupName, String clusters) {
            String serviceKey = ServiceInfo.getKey(NamingUtils.getGroupedName(serviceName, groupName), clusters);
            if (futureMap.get(serviceKey) != null) {//查看是否已经存在
                return;
            }
            synchronized (futureMap) {//同步 根据futureMap加锁进行双重校验
                if (futureMap.get(serviceKey) != null) {
                    return;
                }
                ScheduledFuture<?> future = addTask(new UpdateTask(serviceName, groupName, clusters));//添加任务
                futureMap.put(serviceKey, future);//将任务添加到map中
            }
        }
    

    不难看出,就是将task扔到一个任务调度线程池,然并且延迟1s调度。
    在这里插入图片描述
    这里先是更新一下任务里面维护的这个lastRefTime时间值,接着就是判断如果唤醒监听列表中没有订阅这个服务并且 futureMap(任务集合)里面没有这个的话,就说明被任务被停了, 接着就是计算下延迟时间,然后放到调度线程池中执行,普通情况延迟10s,失败的话就多延迟会,但是不会超过60s。

                try {//先检查是否已订阅这个服务 如果map中也没有的话 终止服务
                    if (!changeNotifier.isSubscribed(groupName, serviceName, clusters) && !futureMap.containsKey(serviceKey)) {
                        NAMING_LOGGER
                                .info("update task is stopped, service:{}, clusters:{}", groupedServiceName, clusters);
                        return;
                    }
                    
                    ServiceInfo serviceObj = serviceInfoHolder.getServiceInfoMap().get(serviceKey);
                    if (serviceObj == null) {//null的话立即更新服务
                        serviceObj = namingClientProxy.queryInstancesOfService(serviceName, groupName, clusters, 0, false);
                        serviceInfoHolder.processServiceInfo(serviceObj);
                        lastRefTime = serviceObj.getLastRefTime();
                        return;
                    }
                    if (serviceObj.getLastRefTime() <= lastRefTime) {
                        serviceObj = namingClientProxy.queryInstancesOfService(serviceName, groupName, clusters, 0, false);
                        serviceInfoHolder.processServiceInfo(serviceObj);
                    }
                    lastRefTime = serviceObj.getLastRefTime();
                    if (CollectionUtils.isEmpty(serviceObj.getHosts())) {
                        incFailCount();
                        return;
                    }//如果hosts为空的话,增加失败次数
                    // 延迟时间值由服务端决定 为10s
                    delayTime = serviceObj.getCacheMillis() * DEFAULT_UPDATE_CACHE_TIME_MULTIPLE;
                    resetFailCount();
                } catch (Throwable e) {
                    incFailCount();
                    NAMING_LOGGER.warn("[NA] failed to update serviceName: {}", groupedServiceName, e);
                } finally {
                    executor.schedule(this, Math.min(delayTime << failCount, DEFAULT_DELAY * 60), TimeUnit.MILLISECONDS);
                }
    

    订阅通知服务端

    调用了pushService 组件的addClient 方法,这个pushService 组件主要就是用来进行推送的, 比如我们订阅了某个服务,然后这个服务下面的实例信息发生了变化,pushService组件就会通知所有的订阅客户端,将新的数据给客户端推过去。通过PushClient生成一个serviceKey ,然后去clientMap中获取,最后就是将PushClient 转成字符串当作key,去clients这个map中获取。

        public void addClient(PushClient client) {
            // client is stored by key 'serviceName' because notify event is driven by serviceName change
            String serviceKey = UtilsAndCommons.assembleFullServiceName(client.getNamespaceId(), client.getServiceName());
            ConcurrentMap<String, PushClient> clients = clientMap.get(serviceKey);
            if (clients == null) {
                clientMap.putIfAbsent(serviceKey, new ConcurrentHashMap<>(1024));
                clients = clientMap.get(serviceKey);
            }
            
            PushClient oldClient = clients.get(client.toString());
            if (oldClient != null) {
                oldClient.refresh();
            } else {
                PushClient res = clients.putIfAbsent(client.toString(), client);
                if (res != null) {
                    Loggers.PUSH.warn("client: {} already associated with key {}", res.getAddrStr(), res);
                }
                Loggers.PUSH.debug("client: {} added for serviceName: {}", client.getAddrStr(), client.getServiceName());
            }
        }
    

    配置管理源码分析

    配置初始化

    ConfigLongPoll_CITCase的init方法进行初始化创建configService实例,并加载properties配置信息。

            // use local config first(优先使用本地配置)
            String content = LocalConfigInfoProcessor.getFailover(worker.getAgentName(), dataId, group, tenant);
            if (content != null) {
                LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, config={}",
                        worker.getAgentName(), dataId, group, tenant, ContentUtils.truncateContent(content));
                cr.setContent(content);
                String encryptedDataKey = LocalEncryptedDataKeyProcessor
                        .getEncryptDataKeyFailover(agent.getName(), dataId, group, tenant);
                cr.setEncryptedDataKey(encryptedDataKey);
                configFilterChainManager.doFilter(null, cr);
                content = cr.getContent();
                return content;
            }
            //获取远程配置
            ConfigResponse response = worker.getServerConfig(dataId, group, tenant, timeoutMs, false);
            cr.setContent(response.getContent());
            cr.setEncryptedDataKey(response.getEncryptedDataKey());
            configFilterChainManager.doFilter(null, cr);
            content = cr.getContent();
    

    通过dataId, group, fileExtension加载配置文件信息,并通过RPC方式远程加载配置参数。

    配置同步源码分析

    配置同步

    客户端请求

    初始请求配置完成后,会通过 WorkClient 进行长轮询查询配置。进行初始化使用了两个线程池:

    • 主要是用来初始化做长轮询的;
    • Delay:用来做检查的,会每间隔5秒钟执行一次登录检查方法。
       ScheduledExecutorService executorService = Executors
               .newScheduledThreadPool(ThreadUtils.getSuitableThreadCount(1), r -> {
                   Thread t = new Thread(r);
                   t.setName("com.alibaba.nacos.client.Worker");
                   t.setDaemon(true);
                   return t;
               });
       agent.setExecutor(executorService);
       agent.start();
    
       if (securityProxy.isEnabled()) {
           securityProxy.login(serverListManager.getServerUrls());
           
           this.executor.scheduleWithFixedDelay(new Runnable() {
               @Override
               public void run() {
                   securityProxy.login(serverListManager.getServerUrls());
               }
           }, 0, this.securityInfoRefreshIntervalMills, TimeUnit.MILLISECONDS);
           
       }
    

    在这个方法里面主要是分配任务,给每个task分配一个taskId,后面会去检查本地配置和远程配置,最终调用的是executeConfigListen方法。

    • 检查本地配置信息;
    • 通过 dataId 去检查服务端是否有变动的配置信息;
    • 添加监听;
    • 通过 dataId , group 来获取 cache 本地缓存的配置信息;
    • 再将 Listener 也传给 cache 统一管理
    	 //check local listeners consistent.
    	 if (cache.isSyncWithServer()) {
    	     cache.checkListenerMd5();
    	     if (!needAllSync) {
    	         continue;
    	     }
    	 }
         //get listen  config
         if (!cache.isUseLocalConfigInfo()) {
             List<CacheData> cacheDatas = listenCachesMap.get(String.valueOf(cache.getTaskId()));
             if (cacheDatas == null) {
                 cacheDatas = new LinkedList<CacheData>();
                 listenCachesMap.put(String.valueOf(cache.getTaskId()), cacheDatas);
             }
             cacheDatas.add(cache);
             
         }
         @Override
         public void startInternal() throws NacosException {
             executor.schedule(new Runnable() {
                 @Override
                 public void run() {
                     while (!executor.isShutdown() && !executor.isTerminated()) {
                         try {
                             listenExecutebell.poll(5L, TimeUnit.SECONDS);
                             if (executor.isShutdown() || executor.isTerminated()) {
                                 continue;
                             }
                             executeConfigListen();
                         } catch (Exception e) {
                             LOGGER.error("[ rpc listen execute ] [rpc listen] exception", e);
                         }
                     }
                 }
             }, 0L, TimeUnit.MILLISECONDS);
             
         }
    

    服务端响应

    当服务端收到请求后,会持住当前请求,如果有变化就返回,如果没有变化就等待超时之前返回无变化。

        public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map,
                int probeRequestSize) {
            
            String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER);
            String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER);
            String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER);
            String tag = req.getHeader("Vipserver-Tag");
            int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);
            
            // Add delay time for LoadBalance, and one response is returned 500 ms in advance to avoid client timeout.
            long timeout = Math.max(10000, Long.parseLong(str) - delayTime);
            if (isFixedPolling()) {
                timeout = Math.max(10000, getFixedPollingInterval());
                // Do nothing but set fix polling timeout.
            } else {
                long start = System.currentTimeMillis();
                List<String> changedGroups = MD5Util.compareMd5(req, rsp, clientMd5Map);
                if (changedGroups.size() > 0) {
                    generateResponse(req, rsp, changedGroups);
                    LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "instant",
                            RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
                            changedGroups.size());
                    return;
                } else if (noHangUpFlag != null && noHangUpFlag.equalsIgnoreCase(TRUE_STR)) {
                    LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "nohangup",
                            RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
                            changedGroups.size());
                    return;
                }
            }
            String ip = RequestUtil.getRemoteIp(req);
            
            // Must be called by http thread, or send response.
            final AsyncContext asyncContext = req.startAsync();
            
            // AsyncContext.setTimeout() is incorrect, Control by oneself
            asyncContext.setTimeout(0L);
            
            ConfigExecutor.executeLongPolling(
                    new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
        }
    

    Nacos常见问题解析

    大量的无效日志打印,这些日志的打印会迅速占用完用户的磁盘空间,同时也让有效日志难以查找。
    1、access 日志大量打印,access日志不能自动清理和滚动并且不能控制日期以及文件大小限制。这个日志是 Spring Boot 提供的 Tomcat 访问日志打印。
    ——server.tomcat.accesslog.enabled=false(生产环境磁盘允许的话,不建议删除
    2、服务端业务日志大量打印
    #调整naming模块的naming-raft.log的级别为error:

    curl -X PUT '$nacos_server:8848/nacos/v1/ns/operator/log?logName=naming-raft&logLevel=error'
    

    #调整config模块的config-dump.log的级别为warn:

    curl -X PUT '$nacos_server:8848/nacos/v1/cs/ops/log?logName=config-dump&logLevel=warn‘
    

    3、客户端日志大量打印(心跳日志、轮询日志)
    (如果允许的话,可以进行二次开发,对于轮询以及心跳不设置日志信息输出,或者采用超时+时间片的方式进行控制输出)

  • 相关阅读:
    NR PUSCH(五) DMRS
    Unity Shader—05 Unity中的纹理采样
    postman下载文件的名字 中文部分表示成%
    Flink 1.13 源码解析——Flink 作业提交流程
    怎么给图片名称快速重命名?来跟我学着两个实用方法
    【TypeScript】深入学习TypeScript命名空间
    二十一、动态内存管理
    机器学习——强化学习状态值函数V和动作值函数Q的个人思考
    如何使用 Media.io 生成不同年龄的照片
    哪个运动耳机比较好?适合运动佩戴的运动耳机推荐
  • 原文地址:https://blog.csdn.net/qq_40921561/article/details/117092067