自学内容网 自学内容网

Spring Cloud Netflix 系列:Eureka 经典实战案例和底层原理解析

前言

  • 在微服务架构中,服务注册与发现 是基础组件。Spring Cloud Netflix 提供了强大的服务治理工具——Eureka,它通过注册中心的概念,帮助我们实现服务注册、发现与健康监控。本文将详细讲解 Eureka 的基本原理、服务注册与高可用流程、以及常见配置和用法。

Eureka 简介

  • EurekaSpring Netflix OSS 中服务发现与注册的核心组件,在 Spring Cloud 2022.0.x 版本开始,Spring Netflix OSS 组件(例如 Hystrix、Zuul)被正式移除。Spring 团队逐渐停止了对这些组件的支持,转而支持基于 Spring Cloud 的其他解决方案,比如 Spring Cloud Gateway、Resilience4j 等。但 Eureka 仍然支持,说明在设计上 Eureka 作为服务注册与发现仍占有一席之地。
  • Spring Cloud Release Notes 参考

架构设计

  • Eureka 是 Netflix 开源的服务发现组件,遵循 AP 模式设计,采用 Client/Server 模式。它分为 Eureka Server 和 Eureka Client 两部分:
Eureka Server:作为注册中心,维护所有服务的注册信息;
Eureka Client:服务提供者或消费者,通过 Eureka Server 注册或获取服务信息。

工作流程

  • 服务注册:服务启动后,会将自身信息(如 IP、端口、服务名称等)注册到 Eureka Server 上。
  • 服务续约:Eureka Client 会定期向 Eureka Server 发送心跳,续约租约。
  • 服务发现:服务消费者从 Eureka Server 获取注册表,并根据服务名称访问目标服务。
  • 服务剔除:当某服务不再发送心跳时,Eureka Server 在租约过期后将其移出注册列表。

项目 demo 构建

Eureka Server 的搭建

  • 创建一个 Spring Boot 项目,并引入 Spring Cloud Netflix Eureka Server 依赖

  • pom.xml 文件

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>eureka-server</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.0</version>
    </parent>

    <properties>
        <spring-cloud.version>2021.0.1</spring-cloud.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>
    </dependencies>
</project>
  • 配置文件 application.yml
server:
  port: 8761

spring:
  application:
    name: eureka-server

eureka:
  instance:
    hostname: localhost  # Eureka Server 的主机名,其他服务会通过这个地址注册
    instance-id: ${spring.application.name}:${server.port}  # 实例 ID,唯一标识一个服务实例
  client:
    register-with-eureka: true  # 是否将 Eureka Server 本身作为客户端注册到注册中心
    fetch-registry: false  # Eureka Server 不拉取服务注册表(只提供服务注册功能)
    service-url:
      defaultZone: http://localhost:8761/eureka/  # Eureka Server 地址,客户端注册所用
  server:
    enable-self-preservation: false  # 启用自我保护模式,防止服务实例因心跳丢失被剔除
    eviction-interval-timer-in-ms: 60000  # 清理失效服务的时间间隔,单位为毫秒
  • 启动类
package org.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}

Eureka Client 的配置

  • pom.xml 文件
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>service-demo1</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.0</version>
    </parent>

    <properties>
        <spring-cloud.version>2021.0.1</spring-cloud.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

  • 配置文件 application.yml
server:
  port: 8081

spring:
  application:
    name: service-demo1

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/  # Eureka Server 地址,客户端注册所用
    fetch-registry: true  # 是否从 Eureka Server 拉取服务注册表,服务提供者通常设置为 true
    register-with-eureka: true  # 是否将自己注册到 Eureka Server,服务提供者需要设置为 true
    registry-fetch-interval-seconds: 30  # 注册表拉取间隔
  instance:
    hostname: localhost
    instance-id: ${spring.application.name}:${server.port}  # 实例 ID,唯一标识一个服务实例
    prefer-ip-address: true  # 使用 IP 地址注册服务,通常设置为 true
    lease-renewal-interval-in-seconds: 30  # 心跳间隔
    lease-expiration-duration-in-seconds: 90  # 过期时间
  • 启动类
package org.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}

补充说明

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

运行效果

深入使用

Eureka 注册中心添加认证

  • 添加 Spring Security 模块
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
  • 修改配置文件 application.yml
spring:
  application:
    name: eureka-security-server
  security: # 配置 Spring Security 登录用户名和密码
    user:
      name: admin
      password: 123456
  • 添加 Java 配置 WebSecurityConfig
默认情况下添加SpringSecurity依赖的应用每个请求都需要添加CSRF token才能访问。
Eureka客户端注册时并不会添加,所以需要配置/eureka/**路径不需要CSRF token。

package org.example.config;

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().ignoringAntMatchers("/eureka/**");
        super.configure(http);
    }
}
  • 运行效果

搭建 Eureka 集群实现高可用

  • 服务的注册和发现都是通过注册中心实现,一旦注册中心宕机所有服务的注册和发现都会不可用,因此我们可以 搭建 Eureka 集群实现高可用,集群一方面可以实现高可用,另一方面也可以分担服务注册和发现的压力。

双节点集群搭建

  • 下面我们搭建一个双节点的注册中心集群:
  • 复制一份启动配置

  • 修改配置文件 application.yml 实现相互注册
# 配置 host 文件
127.0.0.1 localhost1
127.0.0.1 localhost2

# eureka-server1

server:
  port: 8761

eureka:
  instance:
    hostname: localhost1  # Eureka Server 的主机名,其他服务会通过这个地址注册
    prefer-ip-address: false  # 使用 IP 地址注册服务,通常设置为 true
    instance-id: ${spring.application.name}:${server.port}  # 实例 ID,唯一标识一个服务实例
  client:
    register-with-eureka: true  # 将 Eureka Server 本身作为客户端注册到注册中心
    fetch-registry: false  # Eureka Server 不拉取服务注册表(只提供服务注册功能)
    service-url:
      defaultZone: http://admin:123456@localhost2:8762/eureka/
    
# eureka-server2

server:
  port: 8762

eureka:
  instance:
    hostname: localhost2  # Eureka Server 的主机名,其他服务会通过这个地址注册
    prefer-ip-address: false  # 使用 IP 地址注册服务,通常设置为 true
    instance-id: ${spring.application.name}:${server.port}  # 实例 ID,唯一标识一个服务实例
  client:
    register-with-eureka: true  # 将 Eureka Server 本身作为客户端注册到注册中心
    fetch-registry: false  # Eureka Server 不拉取服务注册表(只提供服务注册功能)
    service-url:
      defaultZone: http://admin:123456@localhost1:8761/eureka/

# 上面两个注册中心实现相互注册,并修改 eureka-client 配置
eureka:
  client:
    service-url:
      defaultZone: http://admin:123456@localhost:8761/eureka/,http://admin:123456@localhost:8762/eureka/  

运行效果

补充说明

为什么要配置 不同host

  • eureka 底层使用 isThisMyUrl 方法去重,如果获取到相同的 host 会被当做一个主机被去重,无法实现集群同步。
    /**
     * Checks if the given service url contains the current host which is trying
     * to replicate. Only after the EIP binding is done the host has a chance to
     * identify itself in the list of replica nodes and needs to take itself out
     * of replication traffic.
     *
     * @param url the service url of the replica node that the check is made.
     * @return true, if the url represents the current node which is trying to
     *         replicate, false otherwise.
     */
    public boolean isThisMyUrl(String url) {
        final String myUrlConfigured = serverConfig.getMyUrl();
        if (myUrlConfigured != null) {
            return myUrlConfigured.equals(url);
        }
        return isInstanceURL(url, applicationInfoManager.getInfo());
    }

原理解析

服务注册、心跳续期详细流程

  • Eureka Client 在启动完成后实例状态为变更 UP 状态,并尝试进行客户端注册,注册成功后定时进行心跳请求保持客户端状态;若第一次注册失败,后续定时心跳续期请求会返回 204 ,并重新尝试注册。
  • Eureka Client在发送注册、心跳等请求时,会向 Eureka Server 集群节点 serviceUrlList 顺序逐个去尝试,如果有一个请求成功了,就不再去向其他节点请求,最多只重试3次,超过3次直接抛出异常。
    # com.netflix.discovery.shared.transport.decorator.RetryableEurekaHttpClient#execute
    @Override
    protected <R> EurekaHttpResponse<R> execute(RequestExecutor<R> requestExecutor) {
        List<EurekaEndpoint> candidateHosts = null;
        int endpointIdx = 0;
        // 顺序尝试前 numberOfRetries 可以注册中心实例
        for (int retry = 0; retry < numberOfRetries; retry++) {
            EurekaHttpClient currentHttpClient = delegate.get();
            EurekaEndpoint currentEndpoint = null;
            if (currentHttpClient == null) {
                if (candidateHosts == null) {
                    candidateHosts = getHostCandidates();
                    if (candidateHosts.isEmpty()) {
                        throw new TransportException("There is no known eureka server; cluster server list is empty");
                    }
                }
                if (endpointIdx >= candidateHosts.size()) {
                    throw new TransportException("Cannot execute request on any known server");
                }

                currentEndpoint = candidateHosts.get(endpointIdx++);
                currentHttpClient = clientFactory.newClient(currentEndpoint);
            }

            try {
                EurekaHttpResponse<R> response = requestExecutor.execute(currentHttpClient);
                if (serverStatusEvaluator.accept(response.getStatusCode(), requestExecutor.getRequestType())) {
                    delegate.set(currentHttpClient);
                    if (retry > 0) {
                        logger.info("Request execution succeeded on retry #{}", retry);
                    }
                    return response;
                }
                logger.warn("Request execution failure with status code {}; retrying on another server if available", response.getStatusCode());
            } catch (Exception e) {
                logger.warn("Request execution failed with message: {}", e.getMessage());  // just log message as the underlying client should log the stacktrace
            }

            // Connection error or 5xx from the server that must be retried on another server
            delegate.compareAndSet(currentHttpClient, null);
            if (currentEndpoint != null) {
                quarantineSet.add(currentEndpoint);
            }
        }
        throw new TransportException("Retry limit reached; giving up on completing the request");
    }
    
    # org.springframework.cloud.netflix.eureka.http.RestTemplateEurekaHttpClient#register
@Override
public EurekaHttpResponse<Void> register(InstanceInfo info) {
String urlPath = serviceUrl + "apps/" + info.getAppName();

HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.ACCEPT_ENCODING, "gzip");
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);

ResponseEntity<Void> response = restTemplate.exchange(urlPath, HttpMethod.POST, new HttpEntity<>(info, headers),
Void.class);

return anEurekaHttpResponse(response.getStatusCodeValue()).headers(headersOf(response)).build();
}
  • 从上面的设计中我们可以看出,为了避免注册中心实例单点压力过大,我们在配置 service-url 应该尽可能把所有地址都配置上,且顺序应该保持随机。
Eureka Client 失效驱逐
  • Eureka Service 会定时遍历遍历注册表中的实例,找出超过租约期的实例并将其从注册表中移除。若已启用自我保护模式,则停止驱逐,直到恢复心跳,自我保护模式关闭。
    # com.netflix.eureka.registry.AbstractInstanceRegistry#evict(long)
    public void evict(long additionalLeaseMs) {
        logger.debug("Running the evict task");

        // 判断自我保护模式是否启动:防止由于网络分区或临时的网络中断等非正常情况导致 Eureka 大规模地将实例误认为已失效并驱逐,避免影响系统的整体可用性。
        if (!isLeaseExpirationEnabled()) {
            logger.debug("DS: lease expiration is currently disabled.");
            return;
        }

        // We collect first all expired items, to evict them in random order. For large eviction sets,
        // if we do not that, we might wipe out whole apps before self preservation kicks in. By randomizing it,
        // the impact should be evenly distributed across all applications.
        List<Lease<InstanceInfo>> expiredLeases = new ArrayList<>();
        for (Entry<String, Map<String, Lease<InstanceInfo>>> groupEntry : registry.entrySet()) {
            Map<String, Lease<InstanceInfo>> leaseMap = groupEntry.getValue();
            if (leaseMap != null) {
                for (Entry<String, Lease<InstanceInfo>> leaseEntry : leaseMap.entrySet()) {
                    Lease<InstanceInfo> lease = leaseEntry.getValue();
                    if (lease.isExpired(additionalLeaseMs) && lease.getHolder() != null) {
                        expiredLeases.add(lease);
                    }
                }
            }
        }

        // To compensate for GC pauses or drifting local time, we need to use current registry size as a base for
        // triggering self-preservation. Without that we would wipe out full registry.
        // 即使关闭自我保护模式,若不将 renewalPercentThreshold 设置为 0 ,实例也会分批过期,避免网络原因造成服务难以恢复
        int registrySize = (int) getLocalRegistrySize();
        int registrySizeThreshold = (int) (registrySize * serverConfig.getRenewalPercentThreshold());
        int evictionLimit = registrySize - registrySizeThreshold;

        int toEvict = Math.min(expiredLeases.size(), evictionLimit);
        if (toEvict > 0) {
            logger.info("Evicting {} items (expired={}, evictionLimit={})", toEvict, expiredLeases.size(), evictionLimit);

            // 随机驱逐的方式将过期实例的驱逐影响分布在不同应用之间,避免某一应用实例被全部驱逐。
            Random random = new Random(System.currentTimeMillis());
            for (int i = 0; i < toEvict; i++) {
                // Pick a random item (Knuth shuffle algorithm)
                int next = i + random.nextInt(expiredLeases.size() - i);
                Collections.swap(expiredLeases, i, next);
                Lease<InstanceInfo> lease = expiredLeases.get(i);

                String appName = lease.getHolder().getAppName();
                String id = lease.getHolder().getId();
                EXPIRED.increment();
                logger.warn("DS: Registry: expired lease for {}/{}", appName, id);
                internalCancel(appName, id, false);
            }
        }
    }
  • 除了自我保护模式以外,失效驱逐过程中还以为了分批和随机驱逐的方式来提供系统的可用性,分批驱逐举例:
假设 20 个租约,其中有 10 个租约过期。
 
第一轮执行开始
int registrySize = 20;
int registrySizeThreshold = (int) (20 * 0.85) = 17;
int evictionLimit = 20 - 17 = 3;
int toEvict = Math.min(10, 3) = 3;
第一轮执行结束,剩余 17 个租约,其中有 7 个租约过期。
 
第二轮执行开始
int registrySize = 17;
int registrySizeThreshold = (int) (17 * 0.85) = 14;
int evictionLimit = 17 - 14 = 3;
int toEvict = Math.min(7, 3) = 3;
第二轮执行结束,剩余 14 个租约,其中有 4 个租约过期。

...以此类推,或者将 renewalPercentThreshold 设置为0 ,但不建议

集群模式下如何注册 & 集群同步

  • Eureka 属于 AP 设计,注册中心是完全平等和独立,且状态并不完全一致,当 Eureka Client 请求注册、续期、下线到某一个注册中心实例时,该实例会将这些信息同步到集群中其它注册中心,以注册的代码为例:
    # com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl#register
    /**
     * Registers the information about the {@link InstanceInfo} and replicates
     * this information to all peer eureka nodes. If this is replication event
     * from other replica nodes then it is not replicated.
     *
     * @param info
     *            the {@link InstanceInfo} to be registered and replicated.
     * @param isReplication
     *            true if this is a replication event from other replica nodes,
     *            false otherwise.
     */
    @Override
    public void register(final InstanceInfo info, final boolean isReplication) {
        int leaseDuration = Lease.DEFAULT_DURATION_IN_SECS;
        if (info.getLeaseInfo() != null && info.getLeaseInfo().getDurationInSecs() > 0) {
            leaseDuration = info.getLeaseInfo().getDurationInSecs();
        }
        super.register(info, leaseDuration, isReplication);
        // 同步到集群其它服务器
        replicateToPeers(Action.Register, info.getAppName(), info.getId(), info, null, isReplication);
    }
    
    # com.netflix.eureka.registry.PeerAwareInstanceRegistryImpl#replicateToPeers
        /**
     * Replicates all eureka actions to peer eureka nodes except for replication
     * traffic to this node.
     *
     */
    private void replicateToPeers(Action action, String appName, String id,
                                  InstanceInfo info /* optional */,
                                  InstanceStatus newStatus /* optional */, boolean isReplication) {
        Stopwatch tracer = action.getTimer().start();
        try {
            if (isReplication) {
                numberOfReplicationsLastMin.increment();
            }
            // If it is a replication already, do not replicate again as this will create a poison replication
            if (peerEurekaNodes == Collections.EMPTY_LIST || isReplication) {
                return;
            }

            for (final PeerEurekaNode node : peerEurekaNodes.getPeerEurekaNodes()) {
                // If the url represents this host, do not replicate to yourself.
                if (peerEurekaNodes.isThisMyUrl(node.getServiceUrl())) {
                    continue;
                }
                replicateInstanceActionsToPeers(action, appName, id, info, newStatus, node);
            }
        } finally {
            tracer.stop();
        }
    }

自我保护模式

作用

  • Eureka 通过定期接收注册实例发送的心跳信号(续约请求)来判断服务是否存活。当一个实例未能按时发送心跳信号时,Eureka 会将其标记为“不可用”,并从注册列表中移除。但是,当遇到网络分区、延迟或临时故障时,可能导致一些正常运行的实例无法发送心跳信号,导致 Eureka 错误地将这些实例下线,进而造成服务的大量下线,影响系统的稳定性。
  • 为了防止这种情况,Eureka 的自我保护机制的目的是:
避免大量下线:当心跳数量突然下降时,停止过快地移除实例。
提高系统可用性:在网络抖动或短暂的连接问题下,保证系统中的服务实例尽可能保持在线。
  • 缺点:当服务实例确实不可用时,可能导致 Eureka 注册表无法及时更新,影响服务发现的准确性。

工作原理

  • 计算预期心跳续约率:Eureka 根据注册表中当前服务实例的数量计算出预期的心跳续约总数。
    protected void updateRenewsPerMinThreshold() {
        this.numberOfRenewsPerMinThreshold = (int) (this.expectedNumberOfClientsSendingRenews
                * (60.0 / serverConfig.getExpectedClientRenewalIntervalSeconds())
                * serverConfig.getRenewalPercentThreshold());
    }


# 以上面的实例为例
int(3 (实例数) *60.0 / 30(续期时间)* 0.85) = 5
  • 实际续约率低于预期阈值:如果实际收到的心跳续约数低于预期数的 85%(默认阈值),Eureka 会自动启动自我保护模式,认为可能发生了网络问题或节点不可达的情况。
  • 暂停实例下线:在自我保护模式下,Eureka 暂停对未续约实例的剔除操作,直到心跳率恢复到正常水平。

参考文档

个人简介

👋 你好,我是 Lorin 洛林,一位 Java 后端技术开发者!座右铭:Technology has the power to make the world a better place.

🚀 我对技术的热情是我不断学习和分享的动力。我的博客是一个关于Java生态系统、后端开发和最新技术趋势的地方。

🧠 作为一个 Java 后端技术爱好者,我不仅热衷于探索语言的新特性和技术的深度,还热衷于分享我的见解和最佳实践。我相信知识的分享和社区合作可以帮助我们共同成长。

💡 在我的博客上,你将找到关于Java核心概念、JVM 底层技术、常用框架如Spring和Mybatis 、MySQL等数据库管理、RabbitMQ、Rocketmq等消息中间件、性能优化等内容的深入文章。我也将分享一些编程技巧和解决问题的方法,以帮助你更好地掌握Java编程。

🌐 我鼓励互动和建立社区,因此请留下你的问题、建议或主题请求,让我知道你感兴趣的内容。此外,我将分享最新的互联网和技术资讯,以确保你与技术世界的最新发展保持联系。我期待与你一起在技术之路上前进,一起探讨技术世界的无限可能性。

📖 保持关注我的博客,让我们共同追求技术卓越。

个人简介

👋 你好,我是 Lorin 洛林,一位 Java 后端技术开发者!座右铭:Technology has the power to make the world a better place.

🚀 我对技术的热情是我不断学习和分享的动力。我的博客是一个关于Java生态系统、后端开发和最新技术趋势的地方。

🧠 作为一个 Java 后端技术爱好者,我不仅热衷于探索语言的新特性和技术的深度,还热衷于分享我的见解和最佳实践。我相信知识的分享和社区合作可以帮助我们共同成长。

💡 在我的博客上,你将找到关于Java核心概念、JVM 底层技术、常用框架如Spring和Mybatis 、MySQL等数据库管理、RabbitMQ、Rocketmq等消息中间件、性能优化等内容的深入文章。我也将分享一些编程技巧和解决问题的方法,以帮助你更好地掌握Java编程。

🌐 我鼓励互动和建立社区,因此请留下你的问题、建议或主题请求,让我知道你感兴趣的内容。此外,我将分享最新的互联网和技术资讯,以确保你与技术世界的最新发展保持联系。我期待与你一起在技术之路上前进,一起探讨技术世界的无限可能性。

📖 保持关注我的博客,让我们共同追求技术卓越。


原文地址:https://blog.csdn.net/qq_35578171/article/details/143984607

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!