第二章 服务的注册和发现Eureka

导读:本篇文章讲解 第二章 服务的注册和发现Eureka,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

        “Eureka”来源于古希腊词汇,意为“发现了”。在软件领域,Eureka是Netflix在线影⽚公司开源 的⼀个服务注册和发现组件,和其他的Netflix公司的服务组件(例如负载均衡,熔断器,⽹关等) ⼀起,被Spring Cloud社区整合为Spring Cloud Netflix模块。

2.1 Eureka简介

        和Zookeeper类似,Eureka是⼀个⽤于服务注册和发现的组件,最开始主要应⽤与亚⻢逊公司的云 计算服务平台AWS,Eureka分为Eureka Server和Eureka Client,Eureka Server为Eureka服务注册中⼼,Eureka Client为Eureka客户端。

        举个例⼦:Eureka好⽐滴滴⽹约⻋平台,没有滴滴时,⼈们出⻔叫⻋只能叫出租⻋。⼀些私家⻋想做出租却没有资格,被称为⿊⻋。⽽很多⼈想要约⻋,但是⽆奈出租⻋太少,不⽅便。私家⻋很多却不敢拦,⽽且满⼤街的⻋,谁知道哪个才是愿意载⼈的。⼀个想要,⼀个愿意给,就是缺少引⼦,缺乏管理啊。

        此时滴滴这样的⽹约⻋平台出现了,所有想载客的私家⻋全部到滴滴注册,记录你的⻋型(服务类型),身份信息(联系⽅式)。这样提供服务的私家⻋,在滴滴那⾥都能找到,⼀⽬了然。此时要叫⻋的⼈,只需要打开APP,输⼊你的⽬的地,选择⻋型(服务类型),滴滴⾃动安排⼀个符合需求的⻋到你⾯前,为你服务,完美!

        Eureka相当于微服务架构中的“滴滴”。负责微服务的注册和发现⼯作,它记录了服务和服务地址的映射关系。在分布式架构中,服务会注册到Eureka注册中⼼,当服务需要调⽤其它服务时,就从Eureka找到服务的地址,进⾏调⽤。Eureka在Spring Cloud中的作⽤是⽤来作为服务治理实现服务注册和发现。Eureka主要涉及到三⼤⻆⾊:服务提供者、服务消费者、注册中⼼。

        服务注册是指,各个微服务在启动时,将⾃⼰的⽹络地址等信息注册到Eureka,服务提供者将⾃⼰的服务信息,如服务名、IP等告知服务注册中⼼。

        服务发现是指当⼀个服务消费者需要调⽤另外⼀个服务时,服务消费者从Eureka查询服务提供者的地址,并通过该地址调⽤服务提供者的接⼝。⼀个服务既可以是服务消费者,也可以是服务发现者。

        各个微服务与注册中⼼使⽤⼀定机制(例如⼼跳)通信。如果Eureka与某微服务⻓时间⽆法通信,Eureka会将该服务实例从服务注册中⼼中剔除,如果剔除掉这个服务实例过了⼀段时间,此服务恢复⼼跳,那么服务注册中⼼将该实例重新纳⼊到服务列表中,Eureka架构图,如图2-1所示。

第二章 服务的注册和发现Eureka

        注意:Eureka2.x已经停更,解决⽅案推荐使⽤Nacos作为替换⽅案,Nacos在Spring Cloud Alibaba中讲解。

2.2 Eureka⼊⻔

        本节介绍Eureka的基本使⽤,创建Eureka Server,让后将上⾯⽀付微服务,和订单微服务注册到Eureka Server中。Eureka基本机构主要包括以下3个⻆⾊。

  • Eureka Server:服务注册中⼼,提供服务注册和发现功能。
  • Provider Service:服务提供者,案例中就是⽀付微服务。
  • Consumer Service:服务消费者,案例中就是订单微服务。

2.2.1 EurekaServer

1.选择依赖

选择下⾯依赖,如图2-2所示。

  • Spring Boot 2.4.8
  • Spring Boot DevTools
  • Lombok
  • Eureka Server

pom.xml配置⽂件代码如下。

<?xml version="1.0" encoding="UTF-8"?> 
<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 
https://maven.apache.org/xsd/maven-4.0.0.xsd"> 
​
<modelVersion>4.0.0</modelVersion> 
<parent> 
<groupId>org.springframework.boot</groupId> 
<artifactId>spring-boot-starter-parent</artifactId> 
<version>2.4.8</version> 
<relativePath/> <!-- lookup parent from repository --> 
</parent> 
​
<groupId>com.lxs.demo</groupId> 
<artifactId>04_cloud_eureka</artifactId> 
<version>0.0.1-SNAPSHOT</version> 
<name>04_cloud_eureka</name> 
<description>Demo project for Spring Boot</description> 
​
<properties> 
<java.version>1.8</java.version> 
<spring-cloud.version>2020.0.3</spring-cloud.version> 
</properties> 
​
<dependencies> 
<dependency> 
<groupId>org.springframework.cloud</groupId> 
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId> 
</dependency> 
<dependency> 
<groupId>org.springframework.boot</groupId> 
<artifactId>spring-boot-devtools</artifactId> 
<scope>runtime</scope> 
<optional>true</optional> 
</dependency> 
<dependency> 
<groupId>org.projectlombok</groupId> 
<artifactId>lombok</artifactId> 
<optional>true</optional> 
</dependency> 
<dependency> 
<groupId>org.springframework.boot</groupId> 
<artifactId>spring-boot-starter-test</artifactId> 
<scope>test</scope> 
</dependency> 
</dependencies> 
<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> 
<build> 
<plugins> 
<plugin> 
<groupId>org.springframework.boot</groupId> 
<artifactId>spring-boot-maven-plugin</artifactId> 
<configuration> 
<excludes> 
<exclude> 
<groupId>org.projectlombok</groupId> 
<artifactId>lombok</artifactId> 
</exclude> 
</excludes> 
</configuration> 
</plugin> 
</plugins> 
</build> 
</project> 

2.启动器

注:@EnableEurekaServer,声明当前应⽤为Eureka Server

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

3.配置⽂件

server: 
  port: 9004 
spring: 
  application: 
    name: eureka-server 
​
eureka: 
client: 
service-url: 
# eureka 服务地址,如果是集群的话;需要指定其它集群eureka地址 
defaultZone: http://127.0.0.1:9004/eureka 
# 不注册⾃⼰ 
register-with-eureka: false 
# 不拉取服务 
fetch-registry: false 

4.启动并测试

启动应⽤访问http://localhost:9004/

2.2.2 服务提供者

        改造⽀付服务作为服务提供者,提供⽀付服务,注册到Eureka Server。

1.添加依赖

在⽀付微服务⼯程pom.xml中添加如下依赖。

<properties> 
<java.version>1.8</java.version> 
<spring-cloud.version>2020.0.3</spring-cloud.version> 
</properties> 
<dependencies> 
<dependency> 
<groupId>org.springframework.cloud</groupId> 
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> 
</dependency> 
</dependencies> 
<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> 

2.配置⽂件

application.yml配置如下。

server: 
port: 9001 
spring: 
application: 
name: cloud-payment-service 
eureka: 
client: 
service-url: 
defaultZone: http://127.0.0.1:9004/eureka 
fetch-registry: true 
register-with-eureka: true 

注意:

  • 这⾥我们添加了spring.application.name属性来指定应⽤名称,将来会作为服务的id使⽤。

  • 不⽤指定register-with-eureka和fetch-registry,因为默认是true

3.启动器

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

4.启动并测试

2.2.3 服务消费者

        改造订单微服务,订单服务调⽤⽀付服务,订单微服务作为服务消费者,当然订单服务既可以作为消费 者,调⽤⽀付服务,也可以作为被其他服务调⽤,作为服务提供者,所以订单服务也可以注册到Eureka Server

  1. 添加依赖

在⽀付微服务⼯程pom.xml中添加如下依赖。

<properties> 
<java.version>1.8</java.version> 
<spring-cloud.version>2020.0.3</spring-cloud.version> 
</properties> 
<dependencies> 
<dependency> 
<groupId>org.springframework.cloud</groupId> 
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> 
</dependency> 
</dependencies> 
<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> 

2.配置⽂件

application.yml配置如下。

server: 
port: 9002 
spring: 
application: 
name: cloud-order-service 
eureka: 
client: 
service-url: 
defaultZone: http://127.0.0.1:9004/eureka 

注意:

  • 这⾥我们添加了spring.application.name属性来指定应⽤名称,将来会作为服务的id使⽤。

  • 不⽤指定register-with-eureka和fetch-registry,因为默认是true

3.启动器

@SpringBootApplication 
@EnableDiscoveryClient 
public class OrderApplication { 
    public static void main(String[] args) { 
    SpringApplication.run(OrderApplication.class, args); 
    }
​
    @Bean 
    public RestTemplate restTemplate() { 
    return new RestTemplate(); 
    } 
​
} 

4.OrderController

修改OrderController,代码如下。

@RestController 
@RequestMapping("/order") 
public class OrderController { 
​
    @Autowired 
    private RestTemplate restTemplate; 
​
    @Autowired 
    private DiscoveryClient discoveryClient; 
​
    @GetMapping("/payment/{id}") 
    public ResponseEntity<Payment> getPaymentById(@PathVariable("id") Integer id) { 
        String url = "http://localhost:9001/payment/" + id; 
        List<ServiceInstance> serviceInstances = discoveryClient.getInstances("cloud-payment-service"); 
        ServiceInstance serviceInstance = serviceInstances.get(0); 
        url = "http://" + serviceInstance.getHost() + ":" + serviceInstance.getPort() + "/payment/" + id; 
        Payment payment = restTemplate.getForObject(url, Payment.class); 
        return ResponseEntity.ok(payment); 
    } 
​
} 

5.启动并测试

2.3 源码解析

2.3.1 Eureka的⼀些概念

1.Register — 服务注册

        当Eureka Client向Eureka Server注册时,Eureka Client提供⾃身的元数据,⽐如IP地址、端⼝、 运⾏状况指标的URL,主⻚地址等信息。

2.Renew — 服务续约

        Eureka Client在默认情况下会每隔30秒发送⼀次⼼跳来进⾏服务续约,通过服务续约来告知Eureka Server该Eureka Client依然可⽤,正常情况下,如果Eureka Server在90秒内没有收到Eureka Client的⼼跳,Eureka Server会将Eureka Client实例从注册列表中删除,注意:官⽹建议不要更爱服务续约的间隔时间。

3.Fetch Registries — 获取服务注册列表信息

        Eureka Client从Eureka Server获取服务注册表信息,并将其缓存到本地。Eureka Client 会使⽤服务注册列表信息查找其他服务的信息,从⽽进⾏远程调⽤,改注册列表信息定时(每隔30秒)更新⼀次,每次返回的注册列表信息可能与Eureka Client的缓存信息不同,Erueka Client会重新获取整个注册表信息。Eureka Server缓存了所有的服务注册表信息,并且进⾏了压缩。Eureka Client和Eureka

        Server可以使⽤json和xml的数据格式进⾏通信,默认,Eureka Client使⽤JSON格式⽅式来获取服务器注册列表信息。

4.Cancel — 服务下线

        Eureka Client在程序关闭时可以向Eureka Server发送下线请求,发送请求后,该客户端的实例信息将从Eureka Server的服务注册列表信息中删除。改下线请求不会⾃动完成,需要在程序关闭时调⽤以下代码

        DiscoveryManager.getInstance().shutdownComponent();

5.Eviction — 服务

        在默认情况下,Eureka Client连续90秒没有想Eureka Server发送服务续约(⼼跳)时,Eureka Server会将该服务实例从服务列表中删除。即服务剔除。

2.3.2 Register 服务注册

1.Eureka Client源码

        Eureka Client向Eureka Server提交⾃⼰的服务信息,包括IP、端⼝、ServiceId等信息。如果Eureka Client没有配置ServiceId,则默认为配置⽂件中的配置的服务名,即${spring.application.name}的值。

(1)DiscoveryClient初始化⽅法initScheduledTasks⽅法

        该⽅法主要开启了获取服务注册列表的信息,如果需要向Eureka Server注册,则开启注册,同时开启了定时任务向Eureka Server服务续约,代码如下。

/**
\* Initializes all scheduled tasks. 
*/ 
​
private void initScheduledTasks() { 
    if (clientConfig.shouldFetchRegistry()) { 
    ...//省略了任务调度获取注册列表的代码。 
    }
    if (clientConfig.shouldRegisterWithEureka()) { 
        ... 
        // Heartbeat timer 
        heartbeatTask = new TimedSupervisorTask( 
            "heartbeat", 
            scheduler, 
            heartbeatExecutor, 
            renewalIntervalInSecs, 
            TimeUnit.SECONDS, 
            expBackOffBound, 
            new HeartbeatThread() 
        );
        scheduler.schedule( 
            heartbeatTask, 
            renewalIntervalInSecs, TimeUnit.SECONDS); 
        // InstanceInfo replicator 
        instanceInfoReplicator = new InstanceInfoReplicator( 
            this, 
            instanceInfo, 
            clientConfig.getInstanceInfoReplicationIntervalSeconds(),2); // burstSize 
        ... 
    } 
} 

(2) instanceInfoReplicator类

        initScheduledTasks⽅法中,定时任务调⽤instanceInfoReplicator类,instanceInfoReplicator类继承Runable接⼝,run⽅法代码如下。

public void run() { 
    try {
        discoveryClient.refreshInstanceInfo(); 
        Long dirtyTimestamp = instanceInfo.isDirtyWithTime(); 
        if (dirtyTimestamp != null) { 
            discoveryClient.register(); 
            instanceInfo.unsetIsDirty(dirtyTimestamp); 
        } 
    } catch (Throwable t) { 
        logger.warn("There was a problem with the instance info 
        replicator", t); 
    } finally { 
        Future next = scheduler.schedule(this, 
        replicationIntervalSeconds, TimeUnit.SECONDS); 
        scheduledPeriodicRef.set(next); 
    } 
} 

(3)DiscoveryClient的register⽅法

        在com.netflix.discovery包下的DiscoveryClient类中有⼀个register()⽅法,该⽅法通过Http请求向 Eureka Server注册,代码如下所示。

boolean register() throws Throwable { 
    logger.info(PREFIX + "{}: registering service...", 
    appPathIdentifier); 
    EurekaHttpResponse<Void> httpResponse; 
    try {
        httpResponse = 
        eurekaTransport.registrationClient.register(instanceInfo); 
    } catch (Exception e) { 
        logger.warn(PREFIX + "{} - registration failed {}", 
        appPathIdentifier, e.getMessage(), e); 
        throw e; 
    }
    if (logger.isInfoEnabled()) { 
        logger.info(PREFIX + "{} - registration status: {}", 
        appPathIdentifier, httpResponse.getStatusCode()); 
    }
    return httpResponse.getStatusCode() == Status.NO_CONTENT.getStatusCode(); 
} 

2.Eureka Server端源码

(1)ApplicationResource类

        ApplicationResource类的addInstance⽅法,接收Eureka Client客户端注册请求,完成注册,代 码如下。

/**
* Registers information about a particular instance for an 
* {@link com.netflix.discovery.shared.Application}. 
* @param info 
* {@link InstanceInfo} information of the instance. 
* @param isReplication 
* a header parameter containing information whether this is 
* replicated from other nodes. 
*/ 
​
@POST 
@Consumes({"application/json", "application/xml"}) 
public Response addInstance(InstanceInfo info, 
@HeaderParam(PeerEurekaNode.HEADER_REPLICATION) String isReplication) { 
    ... 
    registry.register(info, "true".equals(isReplication)); 
    return Response.status(204).build(); // 204 to be backwards compatible 
​
} 

(2) PeerAwareInstanceRegistryImpl类

        上⾯addInstance⽅法调⽤PeerAwareInstanceRegistryImpl类的register⽅法进⾏注册,代码如 下。

@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); 
​
} 

2.3.3 Renew 服务续约

        服务续约和服务注册⾮常类似,通过前⽂中的分析可以知道,服务注册在Eureka Client程序启动后开启,并且同时开启服务续约定时任务。

1.Eureka Client端

        在DiscoveryClient类下⼜renew()⽅法,完成续约,代码如下。

/**
* Renew with the eureka service by making the appropriate REST call 
*/ 
​
boolean renew() { 
    EurekaHttpResponse<InstanceInfo> httpResponse; 
    try {
        httpResponse = eurekaTransport.registrationClient.sendHeartBeat(instanceInfo.getAppName(), 
        instanceInfo.getId(), instanceInfo, null); 
        logger.debug(PREFIX + "{} - Heartbeat status: {}", 
        appPathIdentifier, httpResponse.getStatusCode()); 
        if (httpResponse.getStatusCode() == Status.NOT_FOUND.getStatusCode()) { 
        REREGISTER_COUNTER.increment(); 
        logger.info(PREFIX + "{} - Re-registering apps/{}", 
        appPathIdentifier, instanceInfo.getAppName()); 
        long timestamp = instanceInfo.setIsDirtyWithTime(); 
        boolean success = register(); 
        if (success) { 
        instanceInfo.unsetIsDirty(timestamp); 
        }
        return success; 
        }
        return httpResponse.getStatusCode() == Status.OK.getStatusCode(); 
    } catch (Throwable e) { 
        logger.error(PREFIX + "{} - was unable to send heartbeat!", 
        appPathIdentifier, e); 
        return false; 
    } 
} 

2.Eureka Server端

        在com.netflix.eureka.InstanceResource类下,接⼝⽅法renewLease(),它是⼀个RESTful API接⼝。 完成服务器断续,代码如下。

@PUT 
public Response renewLease( 
@HeaderParam(PeerEurekaNode.HEADER_REPLICATION) String isReplication,
@QueryParam("overriddenstatus") String overriddenStatus, 
@QueryParam("status") String status, 
@QueryParam("lastDirtyTimestamp") String lastDirtyTimestamp) { 
​
    ... 
    boolean isSuccess = registry.renew(app.getName(), id, 
    isFromReplicaNode); 
    ... 
​
} 

        此外,服务续约的两个参数是可以配置的,即Eureka Client发送续约⼼跳间隔时间参数,和Eureka Server多⻓时间内没有收到⼼跳将实例剔除的时间参数,默认情况下这两个参数分别是30秒和90秒

eureka: 
instance: 
# ⼼跳间隔时间 
lease-renewal-interval-in-seconds: 30 
# 没收到⼼跳多⻓时间剔除 
lease-expiration-duration-in-seconds: 90 

2.4 Eureka的⾃我保护

        当有⼀个新的Eureka Server出现时,他尝试从相邻的Peer节点获取所有服务实例注册信息。如果 从相邻的Peer节点获取信息时出现了故障,Eureka Server会尝试其他的Peer节点。如果Eureka Server 能够成功获取所有的服务实例信息。则根据配置信息设置服务续约的阈值。在任何时间,如果Eureka Server接收到的服务续约低于为该值配置的百分⽐(默认为15分钟内低于85%),则服务器开启⾃我保 护模式,即不再剔除注册列表的信息。

        这样做的好处在于,如果Eureka Server⾃身的⽹络问题⽽导致Eureka Client⽆法续约,Eureka Client的注册列表信息不再被删除,也就是Eureka Client还可以被其他服务消费。

        在默认情况下,Eureka Server的⾃我保护模式是开启的,⽣产环境下这很有效,保证了⼤多数 服务依然可⽤,但是这给我们的开发带来了麻烦, 因此开发阶段我们都会关闭⾃我保护模式。代 码如下

eureka: 
server: 
enable-self-preservation: false # 关闭⾃我保护模式(缺省为打开) 
eviction-interval-timer-in-ms: 1000 # 扫描失效服务的间隔时间(缺省为60*1000ms) 

2.5 Eureka Server集群

        Eureka Server不但需要接收服务的⼼跳,⽤来检测服务是否可⽤,⽽且每个服务会定期会去 Eureka申请服务列表的信息,当服务实例很多时,Eureka中的负载就会很⼤,所以必须实现Eureka服 务注册中⼼的⾼可⽤,⼀般的做法是将Eureka Server集群化。

1.配置⽂件

        更改Eureka Server的配置⽂件 application.yml,在改配置⽂件中,采⽤多profile格式,代码如下。

---
spring:
  config:
    activate:
      on-profile: peer1
server:
  port: 9003
eureka:
  instance:
    hostname: peer1
  client:
    service-url:
      defaultZone: http://peer2:9004/eureka,http://peer3:9005/eureka

---
spring:
  config:
    activate:
      on-profile: peer2
server:
  port: 9004
eureka:
  instance:
    hostname: peer2
  client:
    service-url:
      defaultZone: http://peer1:9003/eureka,http://peer3:9005/eureka

---
spring:
  config:
    activate:
      on-profile: peer3
server:
  port: 9005
eureka:
  instance:
    hostname: peer3
  client:
    service-url:
      defaultZone: http://peer1:9003/eureka,http://peer2:9004/eureka

注:

  • 在yaml单⼀配置⽂件中,可⽤连续三个连字号(—)区分多个⽂件。

  • Spring Boot2.4.x使⽤spring.config.activate.on-profile代替原来的spring.profiles

2.域名解析

        因为本地搭建Eureka Server集群,所以需要修改本地的host⽂件,c:\Windows\System32\drivers\etc\hosts,代码如下。

127.0.0.1 peer1 
127.0.0.1 peer2 
127.0.0.1 peer3 

3.启动并测试

        通过mvn package编译后,使⽤java -jar⽅式启动,并通过–spring.profiles.active指定配置⽂ 件,本案例中需要启动2个Eureka Server实例,他们的配置⽂件分别是peer1和peer2,命令如下

java -jar 04_cloud_eureka-0.0.1-SNAPSHOT.jar --spring.profiles.active=peer1 
java -jar 04_cloud_eureka-0.0.1-SNAPSHOT.jar --spring.profiles.active=peer2 
java -jar 04_cloud_eureka-0.0.1-SNAPSHOT.jar --spring.profiles.active=peer3 

        启动⽀付服务,⽀付微服务仅向9004的Eureka Server注册,代码如下。

eureka: 
client: 
service-url: 
defaultZone: 
http://peer1:9003/eureka,http://peer2:9004/eureka,http://peer3:9005/eureka 

        此时,⽀付服务并没有向9003注册,访问Eureka Server的节点的peer1管控台界⾯,可⻅ 9004的注册列表信息已经,同步到了9003节点。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/13440.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!