自己写分布式配置中心(上篇)- 单机模式

作者:SnoWalker

来源:http://wuwenliang.net/2018/12/05/%E8%87%AA%E5%B7%B1%E5%86%99%E5%88%86%E5%B8%83%E5%BC%8F%E9%85%8D%E7%BD%AE%E4%B8%AD%E5%BF%83-%E5%8D%95%E6%9C%BA%E6%A8%A1%E5%BC%8F/

本篇我们讲讲如何实现一个分布式配置中心服务。

由于该项目实现起来较为复杂,因此分为上下两篇讲解,上篇为单机模式,下篇为分布式模式。话不多说,我们进入正文。

说说配置中心

在编码之前,我们简单介绍一下什么是分布式配置中心,以及它是为了解决什么问题而出现的。

传统项目中,我们使用配置文件来进行可变参数的动态配置,通过改动配置文件并重启应用的方式达到“不改代码而变更程序行为”的目的。

项目规模小,服务数量少的时候,这种方式还是可堪一用的,当项目规模变大,服务数量上升的时候,停机修改配置就成为一件难以忍受的事情。试想,我们为了修改配置文件,首先要关停部分机器,修改完成还需要手动重启,如果修改有误还需要返工。这对运维而言,简直就是折磨。

因此,配置中心,或者说配置服务就应运而生了。最直接的变化就是,修改配置无需重启,配置能够动态生效,减少了运维成本和压力。

不仅如此,引入较为成熟的配置中心,能够在全局的视角,对不同业务线的不同模块的配置进行统一管理,将分散在各处的配置集中管理,从根源上规避了“配置地狱”的情况的出现。

配置中心不仅减少了开发运维的压力,对运营而言也方便了需求的快速实现及业务目标的灵活变更,它是分布式/微服务架构中的必备的基础设施。

业界较为流行的配置中心实现如下,读者可以根据自己的需要,结合官方文档及业界的最佳实践,灵活的选择适合自己的配置中心实现。

项目    公司  模式
qconf    奇虎360   拉模式
apollo    携程  推模式
dimond    阿里巴巴    拉模式
ACM    阿里巴巴    推/拉模式
Disconf    百度  推/拉模式

定义配置实体

按照惯例,在实现一个组件之前,需要定义业务模型,我们按照

主项目->子项目->key/value

的层级模式定义配置实体如下。

public class SysConfig implements Serializable {

    private static final long serialVersionUID = 7173154101565448135L;

    private Integer configId;                   // 配置id
    private String configKey;                   // 配置key
    private String configValue;                 // 配置值
    private String configDesc;                  // 配置描述
    private Integer projectId;                  // 主工程id
    private String projectName;                 // 主工程名
    private Integer moduleId;                   // 子模块id
    private String moduleName;                  // 子模块名
    private Integer configSwitch;               // 配置开关,默认为1关闭  0-开启,1-关闭
    private String optUser;                     // 操作人员,默认administrator
    private String insertTime;                  // 插入时间
    private String updateTime;                  // 修改时间
    private String md5Value;                    // md5值,用于版本控制
    private String configSwitchDesc;            // 配置标记描述
    ...省略getter setter...

这些配置在配置服务后台都是可配置的。

注意,由于是demo项目,因此本文的目的主要是从原理的角度去阐述一个配置中心是如何实现的,提供一种思路,因此未实现如:命名空间、版本隔离等高级功能。

项目类结构

1、概述

项目包结构如下

sheild-conf-client-core -- 配置服务核心包,实现配置服务的全部功能
shield-config-manage-ui -- 配置扩展包,提供一个简洁的配置管理页面,添加用户授权机制
shield-demo-project-single -- demo工程

简单看下管理的ui界面,基于bootstrap实现的,没有什么技术含量,由于我本身不是专攻前端的,所以就以实现功能为主要目标了,如果觉得长的太丑,欢迎吐槽(反正我也不改,逃。。。)

碍于篇幅限制,我们主要讲解sheild-conf-client-core配置核心的实现。

首先看一下类图

是不是很熟悉的感觉,“Subject”、“Observer”。。。这些类名很直接的表明了这个项目主要使用了观察者模式,相信有心的小伙伴已经猜到了,观察者模式用在这里就是为了实现配置的动态拉取变更的,核心就是被观察者感知到到变更,通知对应的观察者对象去更新配置。

很简单对吧,不急,我们继续往下走。

2、配置持有者ConfigHolder及配置获取者Config

首先引入的类是ConfigHolder,它的作用是对配置进行集中的管理,并在项目的初始化阶段预加载所有配置。

@Service
class ConfigHolder {

    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigHolder.class);

    private static Map<String, SysConfig> config;

    static {
        config = new ConcurrentHashMap<>();
    }

    @Autowired
    ConfigRepository configRepository;

    @PostConstruct
    private void init() {
        // 初始化加载一次全量配置
        config = configRepository.getConfigMap();
        LOGGER.info("sheild-conf初始化全量配置完成");
    }

    protected static SysConfig get(String key) {
        return config.get(key);
    }

    public static void set(String key, SysConfig sysConfig) {
        config.put(key, sysConfig);
    }

    protected static void remove(String key) {
        config.remove(key);
    }
}

由于是全局的配置持有者,我们使用具备分段锁机制的ConcurrentHashMap作为配置的容器,并提供了对外的读取、移除、设置配置的方法。

接下来再定义一个客户端可以直接操作的配置获取者类,用于将读取配置功能暴露给业务调用者。

public class Config {

    private volatile static Config config;

    private Config() {}

    public static Config getInstance() {
        if (config == null) {
            synchronized (Config.class) {
                if (config == null) {
                    config = new Config();
                }
            }
        }
        return config;
    }

    /**
    * 客户端获取配置项
    * @param key
    * @return
    */
    public static String get(String key) {
        return getInstance().get(key, "");
    }

    /**
    * 客户端获取配置项,如果不存在则使用默认值
    * @param key
    * @return
    */
    public static String get(String key, String defaultValue) {
        return ConfigHolder.get(key).getConfigValue() == null ? 
            defaultValue : ConfigHolder.get(key).getConfigValue();
    }
}

这里的方法 get(String key, String defaultValue) 为调用方提供了指定默认值的功能,如果读取到的配置不存在,则使用默认值,从而避免业务异常。

3、配置服务后台更新线程池加载器ConfigCommandLineRunner

这里开始,我们就开始引入配置动态加载“魔法”的核心–观察者模式,这里先介绍一下配置服务后台更新的启动器。它的作用是在项目启动完成的同时,加载好配置的被观察者角色及其依赖的线程池。

@Component
public class ConfigCommandLineRunner implements CommandLineRunner {

    static ScheduledExecutorService configExec;

    @Override
    public void run(String... strings) throws Exception {
        // 新建线程池
        configExec = Executors.newScheduledThreadPool(10);
        // 定义配置更新被观察者
        ConfigSubject configSubject = new ConfigSubject(configExec, 30,
         0, 10, TimeUnit.SECONDS);
        configSubject.runExec();
    }
}

此处我们的目的是在项目加载完成前,完成对配置更新的被观察者实体的构建,这样在项目启动完成后,就可以同步开始对配置项的持续扫描操作,实现对配置变更的及时处理。

这里解释一下为何实现CommandLineRunner接口:CommandLineRunner是springboot为开发者提供的在项目构建时,进行预先加载数据的接口,当我们的项目启动完成,通过CommandLineRunner预加载的资源也就同步准备完毕,我们的业务类就可以对该资源进行业务操作。是一个很方便易用的接口。

4、被观察者接口Observable

interface Observable {
    // 增加一个观察者
    void addObserver(Observer observer);
    // 删除一个观察者
    void deleteObserver(Observer observer);
    // 通知观察者
    void notifyObservers(Observer observer, List<Object> context);
}

这里定义了一个被观察者接口,我们主要用到了notifyObservers,当实际的配置更新被观察者实体检测到配置变更,会调用该方法通知对应的观察者实体进行配置更新后的业务操作。

5、观察者接口Observer

interface Observer {

    // 接收到通知发起更新操作
    void update(List<Object> context);
}

此处定义的观察者接口Observer定义了具体的观察者实现类在接收到被观察者传递的更新配置通知后做更新操作的接口update(List context);

不同的实现类在收到通知后会采用不同的配置项更新策略,本项目中我们主要实现了两种配置更新策略:

ConfigDirectUpdateObserver--配置的key不存在,对配置的value进行直接设置。
ConfigMD5UpdateObserver--   配置项发生变更,计算出的MD5与原先的不相等,
                            更新key对应的配置项value

6、配置新增观察者实现–ConfigDirectUpdateObserver–直接修改更新配置

public class ConfigDirectUpdateObserver implements Observer {

    private static final Logger LOGGER = LoggerFactory
            .getLogger(ConfigDirectUpdateObserver.class);

    @Override
    public void update(List<Object> context) {
        ConfigHolder.set((String)context.get(0), 
                                (SysConfig)context.get(1));
        LOGGER.debug("通知配置观察者ConfigDirectUpdateObserver更新key={}的配置完成", 
                                (String)context.get(0));
    }
}

很简单对吧,这个类实现了接口Observer,作用为:当接收到被观察者通知的时候,直接将配置上下文通过ConfigHolder.set(key,value)方法更新到内存中。

该类的调用场景为:当内存中的配置容器中不存在该项配置(即后台新增配置)。更新配置到配置容器中。

7、观察者修改观察者实现–ConfigMD5UpdateObserver

public class ConfigMD5UpdateObserver implements Observer  {

    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigDirectUpdateObserver.class);

    @Override
    public void update(List<Object> context) {
        ConfigHolder.getConfig().remove(context.get(0));
        ConfigHolder.getConfig().put((String)context.get(0), 
            (SysConfig)context.get(1));
        LOGGER.debug("通知配置观察者ConfigMD5UpdateObserver根据MD5更新key={}的配置完成", 
            context.get(0));
    }
}

本类同样实现了接口Observer,它的作用是检测一旦被观察者检测到某项配置的内存内MD5值与数据库中最新的配置的MD5值不同,就会通知ConfigMD5UpdateObserver观察者进行更新操作。

而更新操作也比较粗暴,就是将key对应的配置项删除后重新添加最新的配置。这样对外的效果就是key未改变,而配置内容已经更新。

8、接口IConfigPullHandler,定义对配置项的更新策略

interface IConfigPullHandler {
    void runExec();
}

扩展接口,在核心工程中用于被观察者调度线程池进行定时任务。在ConfigCommandLineRunner中用到,在项目加载完成时启动被观察者进行配置项的定时扫描作业。

9、重要:配置被观察者ConfigSubject–自动更新魔法的主角

这里,我们隆重推出配置的核心类–ConfigSubject,它是配置服务实现配置自动更新的主角,核心思路就是:ConfigSubject会在通过线程池定时调度,扫描当前应用对应的所有配置项,对其中新增的及修改的配置发送通知给我们之前定义的ConfigDirectUpdateObserver及ConfigMD5UpdateObserver这两个配置更新观察者角色,从而实现配置的动态更新。

国际惯例我们还是先show一下code。

@Component
public class ConfigSubject implements Observable, IConfigPullHandler {

    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigSubject.class);

    // 待注册的观察者列表
    private List<Observer> observerList = new CopyOnWriteArrayList<>();

    private ScheduledExecutorService configExec;           // 配置定时调度线程池
    private long initialDelay;               // 定时任务初始化延迟时间长度
    private long delay;                      // 一次执行终止和下一次执行开始之间的延迟
    private TimeUnit unit;                   // 使用毫秒
    private int poolSize;

这里我们定义了观察者列表,用于在业务中实际发起通知。同时定义了一系列的参数,用于对线程池进行调度。

public ConfigSubject() {}

public ConfigSubject(ScheduledExecutorService configExec, 
                     int poolSize, 
                     long initialDelay, 
                     long delay, 
                     TimeUnit unit) {
    this.configExec = configExec;
    this.poolSize = poolSize;
    this.delay = delay;
    this.initialDelay = initialDelay;
    this.unit = unit;
}

这里不多解释,提供一个带参构造,对外部暴露配置参数,这个构造方法我主要用在了配置管理模块中,通过网页前端传递参数,从而实现对扫描频率,线程池等的动态配置。

/**
 * 执行更新操作,发现变更后通知观察者
 */
@Override
public void runExec() {
    // 初始化后开始执行更新操作
    configExec.scheduleAtFixedRate(
            new Runnable() {
                @Override
                public void run() {
                    doWork();
                }
            }, initialDelay, delay, unit);
}

该方法是实现了IConfigPullHandler接口的runExec(),让调用者能够自主选择何时开始对配置项进行定时拉取操作。

/**
 * <p>一共有三种情况</p>
 * <p>1. 本地有远程没有,基于本地迭代
 *      需要同步远程,本地优先</p>
 * <p>2. 远程有本地没有,基于远程迭代
 *      直接同步本地</p>
 * <p>3. 本地和远程都有,基于远程迭代
 *      比较MD5进行更新操作</p>
 * <p>当然,存在一种情况,某个配置项不再需要使用,即修改了代码逻辑
 * 删除了该配置<br/>
 * 此时,如果加载应用,根据逻辑还是会获取到不需要的配置项,这时只需要
 * 重启应用,<br/>并在远程删除配置项。如果是集群,可以增加同步配置的时间,保证一致性
 * <br/>当然,我们允许应用中存在一定的配置冗余<p/>
 */
public void doWork() {
    LOGGER.debug("开始定时获取全量配置定时任务");
    ConfigRepository configRepository = (ConfigRepository)SpringConfigTool.getBean("configRepository");
    Map<String, SysConfig> remoteConfigMap = configRepository.getConfigMap();
    LOGGER.debug("获取到应用的全量配置数量为:" + remoteConfigMap.size() + ",本地缓存中的配置数量为:"
            + ConfigHolder.getConfig().size());
    StringBuffer stringBuffer = new StringBuffer();
    // 根据数据库中迭代本地
    for (String remoteKey : remoteConfigMap.keySet()) {
        SysConfig localConfig = ConfigHolder.getConfig().get(remoteKey);
        if (ConfigHolder.getConfig().get(remoteKey) == null) {
            // 本地不存在配置项,新增配置
            notifyIfLocalConfigNotExist(remoteConfigMap, stringBuffer, remoteKey);
        } else if (!getMd5Value(localConfig.getProjectName(),
                localConfig.getConfigKey(),
                localConfig.getConfigValue(),
                remoteConfigMap.get(remoteKey)).equals(remoteConfigMap.get(remoteKey).getMd5Value())) {
            // 相同key对应的配置项MD5比对不相同,更新配置项
            notifyObserverIfConfigInvalid(remoteConfigMap, stringBuffer, remoteKey);
        }
    }
    LOGGER.debug("应用配置文件更新完毕, 发生变更的配置项列表为:{}", stringBuffer.toString());
}

这段代码是核心逻辑,我在注释中写的已经很清楚了,主要就是定时拉取数据库中的远程配置列表并迭代,与本地的配置列表进行对比,根据比较的结果做进一步的操作。这里再简单讲解一下,这里主要枚举了所有可能出现的情况:

  1. 本地有配置而远程(此处特指数据库)没有,则基于本地的配置列表进行迭代,并同步到远程。
  2. 远程有(数据库中新增)而本地没有,基于远程迭代,直接同步本地。这种情况就要通知ConfigDirectUpdateObserver观察者对新增的配置项进行新增操作,更新到内存中的配置容器中。
  3. 本地和远程都有,但是计算出来的配置的MD5值不同,则基于远程迭代并比较MD5,对比较结果不同的配置,根据配置的key更新本地的配置项。这种情况就是调用ConfigMD5UpdateObserver进行配置的更新。
  4. 暂时还未实现配置删除后本地同步删除的功能,这个思路也很简单,新增一个观察者,当被观察者检测到拉取的配置列表比本地的少,则通知新增的观察者将本地多出来的配置项进行删除操作。
/**
* 本地不存在配置项,通知ConfigDirectUpdateObserver新增配置
* @param remoteConfigMap
* @param stringBuffer
* @param remoteKey
*/
private void notifyIfLocalConfigNotExist(Map<String, SysConfig> remoteConfigMap, StringBuffer stringBuffer, String remoteKey) {
    LOGGER.debug("key={}为新增配置,直接更新本地配置", remoteKey);
    List<Object> configList = new CopyOnWriteArrayList<>();
    configList.add(remoteKey);
    configList.add(remoteConfigMap.get(remoteKey));
    // 定义配置更新观察者--根据key修改对应的value
    Observer configObserver = new ConfigDirectUpdateObserver();
    this.notifyObservers(configObserver, configList);
    stringBuffer.append(",key=" + remoteKey + ",");
}

本段代码就是检测本地不存在配置值,通知ConfigDirectUpdateObserver新增配置

/**
 * 相同key对应的配置项MD5比对不相同,通知ConfigMD5UpdateObserver更新配置项
 * @param remoteConfigMap
 * @param stringBuffer
 * @param remoteKey
 */
private void notifyObserverIfConfigInvalid(Map<String, SysConfig> remoteConfigMap, StringBuffer stringBuffer, String remoteKey) {
    LOGGER.debug("key={}配置项为更新的配置项,直接更新本地配置", remoteKey);
    List<Object> configList = new CopyOnWriteArrayList<>();
    configList.add(remoteKey);
    configList.add(remoteConfigMap.get(remoteKey));
    Observer configObserver = new ConfigMD5UpdateObserver();
    this.notifyObservers(configObserver, configList);
    stringBuffer.append(",key=" + remoteKey + ",");
}

这代代码逻辑为比较出同一个key对应的配置项的MD5比对不相同,则通知ConfigMD5UpdateObserver更新配置项。

/**
 * 计算本地配置MD5 哈希值
 * @param projectName
 * @param configKey
 * @param configValue
 * @return
 */
private static String getMd5Value(String projectName, String configKey, String configValue, SysConfig sysConfig) {
    String md5Value = DigestUtils.md5Hex(projectName + configKey + configValue);
    LOGGER.debug("configKey={}对应的本地MD5为--{}, 远程MD5为--{}", configKey, md5Value, sysConfig.getMd5Value());
    return md5Value;
}

getMd5Value方法为计算配置项的MD5策略,策略为对projectName+configKey+configValue拼接的结果做MD5,这样保证一个工程中的某个配置项的MD5一定是唯一的。

@Override
public void addObserver(Observer observer) {
    this.observerList.add(observer);
}

@Override
public void deleteObserver(Observer observer) {
    this.observerList.remove(observer);
}

@Override
public void notifyObservers(Observer observer, List<Object> context) {
    observer.update(context);
}
}

10、其他辅助类

能读到这里的一定都是真爱,那么我们一鼓作气,把剩下的类也过一次吧。

首先是SpringConfigTool,这个类多次出现在我的博客中,眼尖的读者应该能发现。作用为为非Spring容器加载的类提供获取Spring容器中bean的能力。实现跨容器的交互。

@Component
public class SpringConfigTool implements ApplicationContextAware {
    @Autowired
    private static ApplicationContext context;
    private static volatile SpringConfigTool stools = null;
    public synchronized static SpringConfigTool init() {
        if (stools == null) {
            synchronized (SpringConfigTool.class) {
                if (stools == null) {
                    stools = new SpringConfigTool();
                    stools.setApplicationContext(context);
                }
            }
        }
        return stools;
    }
    public void setApplicationContext(ApplicationContext applicationContext)
            throws BeansException {
        context = applicationContext;
    }
    public static Object getBean(String beanName) {
        return context.getBean(beanName);
    }
}

然后是ConfigRepository,这个类通过JDBCTemplate从数据库中加载某个项目对应的所有生效的配置。

@Repository
class ConfigRepository {

    private static final Logger LOGGER = LoggerFactory.getLogger
    (ConfigRepository.class);

    @Autowired
    JdbcTemplate jdbcTemplate;

    protected Map<String, SysConfig> getConfigMap() {
        String sql = SQL.SQL_GET_ALL_CONFIGS;
        LOGGER.debug("开始获取全量配置信息");
        final List<SysConfig> sysConfigs = new CopyOnWriteArrayList<>();
        jdbcTemplate.query(sql, new RowCallbackHandler() {
            @Override
            public void processRow(ResultSet rs) throws SQLException {
                SysConfig sysConfig = new SysConfig();
                sysConfig.setConfigId(rs.getInt("configId"))
                        .setConfigKey(rs.getString("configKey"))
                        .setConfigValue(rs.getString("configValue"))
                        .setConfigDesc(rs.getString("configDesc"))
                        .setOptUser(rs.getString("optUser"))
                        .setInsertTime(rs.getString("insertTime"))
                        .setUpdateTime(rs.getString("updateTime"));
                sysConfigs.add(sysConfig);
            }
        });
        // 迭代设置Map,生成MD5
        Map<String, SysConfig> sysConfigMap = new ConcurrentHashMap<>();
        for (SysConfig sysConfig : sysConfigs) {
            String md5Source = sysConfig.getProjectName() + sysConfig.getConfigKey()
                    + sysConfig.getConfigValue();
            sysConfig.setMd5Value(DigestUtils.md5Hex(md5Source));
            sysConfigMap.put(sysConfig.getConfigKey(), sysConfig);
        }
        LOGGER.debug("获取全量配置信息--{}", sysConfigs.toString());
        return sysConfigMap;
    }
}

思路也很简单,查找到某个项目的所有生效的配置项,并迭代取出每个配置的ProjectName,ConfigKey,ConfigValue计算MD5重新设置回Map并返回给调用者,效率不太好,其实更好的实现应该是查询出来的时候就带了MD5,这个问题有很多解决方法,留给聪明的你解决吧。

最后是查询SQL,这里为了方便,定义了一个静态类SQL.java

class SQL {
    /**
    * 查询所有可用的配置
    */
    static String SQL_GET_ALL_CONFIGS = "SELECT\n" +
            "  t.CONFIG_ID configId,\n" +
            "  t.CONFIG_KEY configKey,\n" +
            "  t.CONFIG_VALUE configValue,\n" +
            "  t.CONFIG_DESC configDesc,\n" +
            "  t.CONFIG_SWITCH configSwitch,\n" +
            "  t.OPT_USER optUser,\n" +
            "  t.PROJECT_NAME projectName,\n" +
            "  DATE_FORMAT(t.INSERT_TIME,'%Y-%m-%d %H:%i:%s') insertTime,\n" +
            "  DATE_FORMAT(t.UPDATE_TIME,'%Y-%m-%d %H:%i:%s') updateTime\n" +
            " FROM\n" +
            "  sys_config t\n" +
            "  where t.CONFIG_SWITCH=0";
}

简单的说就是获取所有状态为可用(config_switch=0)的所有配置项。你可能已经想到了,是的,我们可以在配置页面去设置配置的启用和禁用状态,如下图。

使用效果

辛苦了大半天,我们终于讲完了项目的代码层的实现。聪明的你也一定能看出来,我们就是利用了经典的设计模式–观察者模式,实现了配置的动态扫描和更新的解耦,从而能够灵活的选择不同的策略对配置进行更新/新增等操作。这或许就是设计模式的魅力所在吧。

如下是使用该核心进行配置调用的步骤。

1、建立数据库并导入deploy/sql中的SysConfig.sql脚本建立配置表

2、构建项目sheild-conf-client-single,执行下方命令

mvn clean install -DskipTests

3、参考shield-demo-project-single进行开发。在pom中添加下方依赖

<dependency>
        <artifactId>shield-config-client-core</artifactId>
        <groupId>com.hispeed.development</groupId>
        <version>1.1.0</version>
</dependency>

4、对于springboot项目在启动类添加注解 @ComponentScan(basePackages = {“com.sheild”}),如下

@SpringBootApplication
@ComponentScan(basePackages = {"com.sheild"})
public class App {
        public static void main(String[] args) {
                SpringApplication.run(App.class, args);
        }
        ......

5、java 在需要调用配置的地方,使用 Config.get(“key”) 方法获取需要的配置,参数即为配置的key

项目已经传到github,项目地址为https://github.com/TaXueWWL/sheild-conf-single 感兴趣的小伙伴可以down下来玩玩,有意见欢迎给我提PR,我会保持持续性的更新。也请小伙伴们动动手指给我点个star,你的支持是我前进的不懈动力~

小结

到这里,我们就完成了一个单机模式的配置中心的原理讲解及代码实现,这里我想到一句话:“计算机领域的一切问题都能通过新增一个中间层解决”。

我们这里提到的配置中心其实就是介于变化和不变之间的中间层,有了这个中间层的存在,我们的代码就更加具有扩展性和容错性。

原文发布于微信公众号 - 程序猿DD(didispace)

原文发表时间:2018-12-14

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区