注册中心 Eureka 源码解析 —— Eureka-Client 初始化(一)之 EurekaInstanceConfig

本文主要基于 Eureka 1.8.X 版本

  • 1. 概述
  • 2. EurekaInstanceConfig
    • 2.1 类关系图
    • 2.2 配置属性
    • 2.3 AbstractInstanceConfig
    • 2.4 PropertiesInstanceConfig
    • 2.5 MyDataCenterInstanceConfig
  • 2.6 小结
    • 3. InstanceInfo
    • 4. ApplicationInfoManager
  • 666. 彩蛋

友情提示:欢迎关注公众号【芋道源码】。😈关注后,拉你进【源码圈】微信群讨论技术和源码。


1. 概述

本文主要分享 Eureka-Client 自身初始化的过程,不包含 Eureka-Client 向 Eureka-Server 的注册过程( 🙂后面会另外文章分享 )。

Eureka-Client 自身初始化过程中,涉及到主要对象如下图:

注册中心 Eureka 源码解析 —— Eureka-Client 初始化(一)之 EurekaInstanceConfig
  1. 创建 EurekaInstanceConfig对象
  2. 使用 EurekaInstanceConfig对象 创建 InstanceInfo对象
  3. 使用 EurekaInstanceConfig对象 + InstanceInfo对象 创建 ApplicationInfoManager对象
  4. 创建 EurekaClientConfig对象
  5. 使用 ApplicationInfoManager对象 + EurekaClientConfig对象 创建 EurekaClient对象

考虑到整个初始化的过程中涉及的配置特别多,拆分成三篇文章:

  1. 【本文】(一)EurekaInstanceConfig
  2. (二)EurekaClientConfig
  3. (三)EurekaClient

下面我们来看看每个的实现。

推荐 Spring Cloud 书籍

  • 请支持正版。下载盗版,等于主动编写低级 BUG 。
  • 程序猿DD —— 《Spring Cloud微服务实战》
  • 周立 —— 《Spring Cloud与Docker微服务架构实战》
  • 两书齐买,京东包邮。

2. EurekaInstanceConfig

com.netflix.appinfo.EurekaInstanceConfig,Eureka 应用实例配置接口。在下文你会看到 EurekaClientConfig 接口,两者的区别如下:

  • EurekaInstanceConfig,重在应用实例,例如,应用名、应用的端口等等。此处应用指的是,Application Consumer 和 Application Provider。
  • EurekaClientConfig,重在 Eureka-Client,例如, 连接的 Eureka-Server 的地址、获取服务提供者列表的频率、注册自身为服务提供者的频率等等。
注册中心 Eureka 源码解析 —— Eureka-Client 初始化(一)之 EurekaInstanceConfig

2.1 类关系图

EurekaInstanceConfig 整体类关系如下图:

注册中心 Eureka 源码解析 —— Eureka-Client 初始化(一)之 EurekaInstanceConfig
  • 本文只解析红圈部分类。
  • EurekaArchaius2ClientConfig 基于 Netflix Archaius 2.x 实现,目前还在开发中,因此暂不解析。
  • CloudInstanceConfig、Ec2EurekaArchaius2InstanceConfig 基于亚马逊 AWS,大多数读者和我对 AWS 都不了解,因此暂不解析。

2.2 配置属性

点击 EurekaInstanceConfig 查看配置属性简介,已经添加中文注释,可以对照着英文注释一起理解。这里笔者摘出部分较为重要的属性:

  • #getLeaseRenewalIntervalInSeconds() :租约续约频率,单位:秒。应用不断通过按照该频率发送心跳给 Eureka-Server 以达到续约的作用。当 Eureka-Server 超过最大频率未收到续约(心跳),契约失效,进行应用移除。应用移除后,其他应用无法从 Eureka-Server 获取该应用。

  • #getLeaseExpirationDurationInSeconds() :契约过期时间,单位:秒。

  • #getDataCenterInfo() :数据中心信息。com.netflix.appinfo.DataCenterInfo,数据中心信息接口,目前较为简单,标记所属数据中心名。一般情况下,我们使用 Name.MyOwn。接口实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface DataCenterInfo {
/**
* 数据中心名枚举
*/
enum Name {
   Netflix,
   Amazon,
   MyOwn
}
 
/**
* @return 归属的数据中心名
*/
Name getName();
}
  • #getNamespace() :配置命名空间,默认使用 eureka。以 eureka-client.properties 举个例子:

    1
    2
    3
    
    eureka.name=eureka
    eureka.port=8080
    eureka.vipAddress=eureka.mydomain.net
    • 每个属性最前面的 eureka 即是配置命名空间,一般情况无需修改。
  • TODO[0004]:健康检查

  • #isInstanceEnabledOnit() :应用初始化后是否开启。在「3. InstanceInfo」详细解析。

2.3 AbstractInstanceConfig

com.netflix.appinfo.AbstractInstanceConfig,Eureka 应用实例配置抽象基类,主要实现一些相对通用的配置,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public abstract class AbstractInstanceConfig implements EurekaInstanceConfig {
 
    /**
     * 契约过期时间,单位:秒
     */
    private static final int LEASE_EXPIRATION_DURATION_SECONDS = 90;
    /**
     * 租约续约频率,单位:秒。
     */
    private static final int LEASE_RENEWAL_INTERVAL_SECONDS = 30;
    /**
     * 应用 https 端口关闭
     */
    private static final boolean SECURE_PORT_ENABLED = false;
    /**
     * 应用 http 端口开启
     */
    private static final boolean NON_SECURE_PORT_ENABLED = true;
    /**
     * 应用 http 端口
     */
    private static final int NON_SECURE_PORT = 80;
    /**
     * 应用 https 端口
     */
    private static final int SECURE_PORT = 443;
    /**
     * 应用初始化后开启
     */
    private static final boolean INSTANCE_ENABLED_ON_INIT = false;
    /**
     * 主机信息
     * key:主机 IP 地址
     * value:主机名
     */
    private static final Pair<String, String> hostInfo = getHostInfo();
    /**
     * 数据中心信息
     */
    private DataCenterInfo info = new DataCenterInfo() {
        @Override
        public Name getName() {
            return Name.MyOwn;
        }
    };
 
    private static Pair<String, String> getHostInfo() {
        Pair<String, String> pair;
        try {
            InetAddress localHost = InetAddress.getLocalHost();
            pair = new Pair<String, String>(localHost.getHostAddress(), localHost.getHostName());
        } catch (UnknownHostException e) {
            logger.error("Cannot get host info", e);
            pair = new Pair<String, String>("", "");
        }
        return pair;
    }
 
    // .... 省略 setting / getting 方法
}
  • #getHostInfo() 方法,获取本地服务器的主机名和主机 IP 地址。如果主机有多网卡或者虚拟机网卡,这块要小心,解决方式如下:
    • 手动配置本机的 hostname + etc/hosts 文件,从而映射主机名和 IP 地址。
    • 使用 Spring-Cloud-Eureka-Client 的话,参考周立 —— 《Eureka服务注册过程详解之IpAddress》解决。

2.4 PropertiesInstanceConfig

com.netflix.appinfo.PropertiesInstanceConfig,基于配置文件的 Eureka 应用实例配置抽象基类,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public abstract class PropertiesInstanceConfig extends AbstractInstanceConfig implements EurekaInstanceConfig {
 
    /**
     * 命名空间
     */
    protected final String namespace;
    /**
     * 配置文件对象
     */
    protected final DynamicPropertyFactory configInstance;
    /**
     * 应用分组
     * 从 环境变量 获取
     */
    private String appGrpNameFromEnv;
 
    public PropertiesInstanceConfig() {
        this(CommonConstants.DEFAULT_CONFIG_NAMESPACE);
    }
 
    public PropertiesInstanceConfig(String namespace) {
        this(namespace, new DataCenterInfo() {
            @Override
            public Name getName() {
                return Name.MyOwn;
            }
        });
    }
 
    public PropertiesInstanceConfig(String namespace, DataCenterInfo info) {
        super(info);
        // 设置 namespace,为 "." 结尾
        this.namespace = namespace.endsWith(".")
                ? namespace
                : namespace + ".";
        // 从 环境变量 获取 应用分组
        appGrpNameFromEnv = ConfigurationManager.getConfigInstance()
                .getString(FALLBACK_APP_GROUP_KEY, Values.UNKNOWN_APPLICATION);
        // 初始化 配置文件对象
        this.configInstance = Archaius1Utils.initConfig(CommonConstants.CONFIG_FILE_NAME);
    }
 
    @Override
    public String getAppGroupName() {
        return configInstance.getStringProperty(namespace + APP_GROUP_KEY, appGrpNameFromEnv).get().trim();
    }
}
  • configInstance 属性,配置文件对象,基于 Netflix Archaius 1.x 实现配置文件的读取。在 com.netflix.appinfo.PropertyBasedInstanceConfigConstants 可以看到配置文件的每个属性 KEY 。

  • appGrpNameFromEnv 属性,应用分组,从环境变量中获取。从 #getAppGroupName() 方法中,可以看到优先还是从配置文件读取。设置方法如下:

    1
    
    System.setProperty(FALLBACK_APP_GROUP_KEY, "app_gropu_name");
    • FALLBACK_APP_GROUP_KEY,私有静态变量,实际得使用 NETFLIX_APP_GROUP
    • com.netflix.config.ConfigurationManager 可以从环境变量获取到值。

调用 Archaius1Utils#initConfig(...) 方法,初始化读取的配置文件对象,实现代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public final class Archaius1Utils {
private static final Logger logger = LoggerFactory.getLogger(Archaius1Utils.class);
 
private static final String ARCHAIUS_DEPLOYMENT_ENVIRONMENT = "archaius.deployment.environment";
private static final String EUREKA_ENVIRONMENT = "eureka.environment";
 
public static DynamicPropertyFactory initConfig(String configName) {
   // 配置文件对象
   DynamicPropertyFactory configInstance = DynamicPropertyFactory.getInstance();
   // 配置文件名
   DynamicStringProperty EUREKA_PROPS_FILE = configInstance.getStringProperty("eureka.client.props", configName);
   // 配置文件环境
   String env = ConfigurationManager.getConfigInstance().getString(EUREKA_ENVIRONMENT, "test");
   ConfigurationManager.getConfigInstance().setProperty(ARCHAIUS_DEPLOYMENT_ENVIRONMENT, env);
   // 将配置文件加载到环境变量
   String eurekaPropsFile = EUREKA_PROPS_FILE.get();
   try {
       ConfigurationManager.loadCascadedPropertiesFromResources(eurekaPropsFile);
   } catch (IOException e) {
       logger.warn(
               "Cannot find the properties specified : {}. This may be okay if there are other environment "
                       + "specific properties or the configuration is installed with a different mechanism.",
               eurekaPropsFile);
 
   }
   return configInstance;
}
}
  • 从环境变量 eureka.client.props,获取配置文件名。如果未配置,使用参数 configName,即 CommonConstants.CONFIG_FILE_NAME ( "eureka-client" )。
  • 从环境变量 eureka.environment ( EUREKA_ENVIRONMENT ),获取配置文件环境。
  • 调用 ConfigurationManager#loadCascadedPropertiesFromResources(...) 方法,读取配置文件到环境变量,首先读取 ${eureka.client.props} 对应的配置文件;然后读取 ${eureka.client.props}-${eureka.environment} 对应的配置文件。若有相同属性,进行覆盖。

2.5 MyDataCenterInstanceConfig

com.netflix.appinfo.MyDataCenterInstanceConfig,非 AWS 数据中心的 Eureka 应用实例配置实现类,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyDataCenterInstanceConfig extends PropertiesInstanceConfig implements EurekaInstanceConfig {
 
    public MyDataCenterInstanceConfig() {
    }
 
    public MyDataCenterInstanceConfig(String namespace) {
        super(namespace);
    }
 
    public MyDataCenterInstanceConfig(String namespace, DataCenterInfo dataCenterInfo) {
        super(namespace, dataCenterInfo);
    }
 
}

2.6 小结

一般情况下,使用 MyDataCenterInstanceConfig 配置 Eureka 应用实例。

在 Spring-Cloud-Eureka 里,直接基于 EurekaInstanceConfig 接口重新实现了配置类,实际逻辑差别不大,在TODO[0007] :《Spring-Cloud-Eureka-Client》详细解析。

3. InstanceInfo

com.netflix.appinfo.InstanceInfo应用实例信息。Eureka-Client 向 Eureka-Server 注册该对象信息。注册成功后,可以被其他 Eureka-Client 发现

本文仅分享 InstanceInfo 的初始化。InstanceInfo 里和注册发现相关的属性和方法,暂时跳过。

com.netflix.appinfo.providers.EurekaConfigBasedInstanceInfoProvider,基于 EurekaInstanceConfig 创建 InstanceInfo 的工厂,实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
  @Singleton
  public class EurekaConfigBasedInstanceInfoProvider implements Provider<InstanceInfo> {
      private static final Logger LOG = LoggerFactory.getLogger(EurekaConfigBasedInstanceInfoProvider.class);
   
      private final EurekaInstanceConfig config;
   
      private InstanceInfo instanceInfo;
   
      @Inject(optional = true)
      private VipAddressResolver vipAddressResolver = null;
 
      @Inject
      public EurekaConfigBasedInstanceInfoProvider(EurekaInstanceConfig config) {
          this.config = config;
      }
 
      @Override
      public synchronized InstanceInfo get() {
          if (instanceInfo == null) {
              // Build the lease information to be passed to the server based on config
              // 创建 租约信息构建器,并设置属性
              LeaseInfo.Builder leaseInfoBuilder = LeaseInfo.Builder.newBuilder()
                      .setRenewalIntervalInSecs(config.getLeaseRenewalIntervalInSeconds())
                      .setDurationInSecs(config.getLeaseExpirationDurationInSeconds());
 
              // 创建 VIP地址解析器
              if (vipAddressResolver == null) {
                  vipAddressResolver = new Archaius1VipAddressResolver();
              }
 
              // Builder the instance information to be registered with eureka server
              // 创建 应用实例信息构建器
              InstanceInfo.Builder builder = InstanceInfo.Builder.newBuilder(vipAddressResolver);
 
              // 应用实例编号
              // set the appropriate id for the InstanceInfo, falling back to datacenter Id if applicable, else hostname
              String instanceId = config.getInstanceId();
              DataCenterInfo dataCenterInfo = config.getDataCenterInfo();
              if (instanceId == null || instanceId.isEmpty()) {
                  if (dataCenterInfo instanceof UniqueIdentifier) {
                      instanceId = ((UniqueIdentifier) dataCenterInfo).getId();
                  } else {
                      instanceId = config.getHostName(false);
                  }
              }
 
              // 获得 主机名
              String defaultAddress;
              if (config instanceof RefreshableInstanceConfig) {
                  // Refresh AWS data center info, and return up to date address
                  defaultAddress = ((RefreshableInstanceConfig) config).resolveDefaultAddress(false);
              } else {
                  defaultAddress = config.getHostName(false);
              }
              // fail safe
              if (defaultAddress == null || defaultAddress.isEmpty()) {
                  defaultAddress = config.getIpAddress();
              }
 
              // 设置 应用实例信息构建器 的 属性
              builder.setNamespace(config.getNamespace())
                      .setInstanceId(instanceId)
                      .setAppName(config.getAppname())
                      .setAppGroupName(config.getAppGroupName())
                      .setDataCenterInfo(config.getDataCenterInfo())
                      .setIPAddr(config.getIpAddress())
                      .setHostName(defaultAddress) // 主机名
                      .setPort(config.getNonSecurePort())
                      .enablePort(PortType.UNSECURE, config.isNonSecurePortEnabled())
                      .setSecurePort(config.getSecurePort())
                      .enablePort(PortType.SECURE, config.getSecurePortEnabled())
                      .setVIPAddress(config.getVirtualHostName()) // VIP 地址
                      .setSecureVIPAddress(config.getSecureVirtualHostName())
                      .setHomePageUrl(config.getHomePageUrlPath(), config.getHomePageUrl())
                      .setStatusPageUrl(config.getStatusPageUrlPath(), config.getStatusPageUrl())
                      .setASGName(config.getASGName())
                      .setHealthCheckUrls(config.getHealthCheckUrlPath(),
                              config.getHealthCheckUrl(), config.getSecureHealthCheckUrl());
 
              // 应用初始化后是否开启
              // Start off with the STARTING state to avoid traffic
              if (!config.isInstanceEnabledOnit()) {
                  InstanceStatus initialStatus = InstanceStatus.STARTING;
                  LOG.info("Setting initial instance status as: " + initialStatus);
                  builder.setStatus(initialStatus);
              } else {
                  LOG.info("Setting initial instance status as: {}. This may be too early for the instance to advertise "
                           + "itself as available. You would instead want to control this via a healthcheck handler.",
                           InstanceStatus.UP);
              }
 
              // 设置 应用实例信息构建器 的 元数据( Metadata )集合
              // Add any user-specific metadata information
              for (Map.Entry<String, String> mapEntry : config.getMetadataMap().entrySet()) {
                  String key = mapEntry.getKey();
                  String value = mapEntry.getValue();
                  builder.add(key, value);
              }
 
             // 创建 应用实例信息
             instanceInfo = builder.build();
 
             // 设置 应用实例信息 的 租约信息
             instanceInfo.setLeaseInfo(leaseInfoBuilder.build());
         }
         return instanceInfo;
     }
 
 }
  • 该类实现 javax.inject.Provider 接口,设置 InstanceInfo 的生成工厂。感兴趣的同学,可以点击《Google-Guice入门介绍》搜索 Provider 关键字。目前处于试验阶段,未完成。

  • EurekaConfigBasedInstanceInfoProvider(config) 构造方法,设置生成 InstanceInfo 的 EurekaInstanceConfig 配置。

  • 调用 #get() 方法,根据 EurekaInstanceConfig 创建 InstanceInfo。InstanceInfo 的绝大数属性和 EurekaInstanceConfig 是一致的 。实现代码如下:

    • 第 82 至 85 行 :应用不开启,应用实例处于 STARTING 状态。
    • 第 86 至 90 行 :应用开启,应用实例处于 UP 状态。
    • 使用应用初始化后不开启,可以通过调用 ApplicationInfoManager#setInstanceStatus(...) 方法改变应用实例状态,在《Eureka 源码解析 —— 应用实例注册发现 (一)之注册》「2.1 应用实例信息复制器」有详细解析。
    • 使用 #resolveDeploymentContextBasedVipAddresses() 方法,将 VIP地址 里的 ${(.*?)} 查找配置文件里的键值进行替换。例如,${eureka.env}.domain.com,查找配置文件里的键 ${eureka.env} 对应值进行替换。TODO[0005]:调试下来,发现 Archaius 已经替换,等到找到答案修改此处。
    • 第 21 至 24 行 :创建租约信息构建器( com.netflix.appinfo.LeaseInfo.Builder ),并设置 renewalIntervalInSecs / durationInSecs 属性。

    • 第 26 至 29 行 :创建 VIP地址解析器( com.netflix.appinfo.providers.VipAddressResolver )。实现代码如下:

    • 1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      
      // VipAddressResolver.java
      public interface VipAddressResolver {
      String resolveDeploymentContextBasedVipAddresses(String vipAddressMacro);
      }
       
      public class Archaius1VipAddressResolver implements VipAddressResolver {
       
      private static final Pattern VIP_ATTRIBUTES_PATTERN = Pattern.compile("\$\{(.*?)\}");
       
      @Override
      public String resolveDeploymentContextBasedVipAddresses(String vipAddressMacro) {
         if (vipAddressMacro == null) {
             return null;
         }
         String result = vipAddressMacro;
         // 替换表达式
         Matcher matcher = VIP_ATTRIBUTES_PATTERN.matcher(result);
         while (matcher.find()) {
             String key = matcher.group(1);
             String value = DynamicPropertyFactory.getInstance().getStringProperty(key, "").get();
         logger.debug("att:" + matcher.group());
         logger.debug(", att key:" + key);
         logger.debug(", att value:" + value);
         logger.debug("");
       
         result = result.replaceAll("\$\{" + key + "\}", value);
         matcher = VIP_ATTRIBUTES_PATTERN.matcher(result);
      }
       
      return result;
      }
       
      }
      • 第 32 至 33 行 :创建应用实例信息构建器( com.netflix.appinfo.InstanceInfo.Builder )。
      • 第 35 至 45 行 :获得应用实例编号( instanceId )。
      • 第 47 至 58 行 :获得主机名。
      • 第 60 至 78 行 :设置应用实例信息构建器的属性。
      • 第 80 至 90 行 :应用初始化后是否开启。
      • 第 92 至 98 行 :设置应用实例信息构建器的元数据( Metadata )集合。
      • 第 100 至 101 行 :创建应用实例信息( com.netflix.appinfo.InstanceInfo )。
      • 第 103 至 104 行 :设置应用实例信息的租约信息( com.netflix.appinfo.InstanceInfo )。

4. ApplicationInfoManager

com.netflix.appinfo.ApplicationInfoManager,应用信息管理器。实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class ApplicationInfoManager {
 
    /**
     * 单例
     */
    private static ApplicationInfoManager instance = new ApplicationInfoManager(null, null, null);
 
    /**
     * 状态变更监听器
     */
    protected final Map<String, StatusChangeListener> listeners;
    /**
     * 应用实例状态匹配
     */
    private final InstanceStatusMapper instanceStatusMapper;
    /**
     * 应用实例信息
     */
    private InstanceInfo instanceInfo;
    /**
     * 应用实例配置
     */
    private EurekaInstanceConfig config;
 
    // ... 省略其它构造方法
 
    public ApplicationInfoManager(EurekaInstanceConfig config, InstanceInfo instanceInfo, OptionalArgs optionalArgs) {
        this.config = config;
        this.instanceInfo = instanceInfo;
        this.listeners = new ConcurrentHashMap<String, StatusChangeListener>();
        if (optionalArgs != null) {
            this.instanceStatusMapper = optionalArgs.getInstanceStatusMapper();
        } else {
            this.instanceStatusMapper = NO_OP_MAPPER;
        }
 
        // Hack to allow for getInstance() to use the DI'd ApplicationInfoManager
        instance = this;
    }
 
    // ... 省略其它方法
}
  • listeners 属性,状态变更监听器集合。在《Eureka 源码解析 —— 应用实例注册发现 (一)之注册》「2.1 应用实例信息复制器」有详细解析。

  • instanceStatusMapper 属性,应用实例状态匹配。实现代码如下:

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    // ApplicationInfoManager.java
     
    public static interface InstanceStatusMapper {
    }
     
    private static final InstanceStatusMapper NO_OP_MAPPER = new InstanceStatusMapper() {
       @Override
       public InstanceStatus map(InstanceStatus prev) {
           return prev;
       }
    };
    • #map 方法,根据传入 pre 参数,转换成对应的应用实例状态。
    • 默认情况下,使用 NO_OP_MAPPER 。一般情况下,不需要关注该类。

666. 彩蛋

涉及到配置,内容初看起来会比较多,慢慢理解后,就会变得很“啰嗦”,请保持耐心。

胖友,分享一个朋友圈可好。

 

发表评论