• 【C++模块实现】| 【04】配置模块


    该模块是从sylar服务器框架中学习的,以下将会对其进行总结以加深对该框架的理解;
    
    • 1

    ========》视频地址《========
    ========》参考内容《========

    一、配置模块简介

    定义、加载系统的配置项,可由用户自定义于文件中,通过YAML加载解析配置内容至系统中;
    
    • 1

    1.1 具备要素

    - 名称:字符串(唯一);
    - 类型:基本、复杂、自定义(该模块需要手动将其转换函数进行特化);
    - 值:与类型对应的值、且提供默认值;
    - 配置变更通知:一旦用户更新了配置值,那么应该通知所有使用了这项配置的代码,以便于进行一些具体的操作,比如重新打开文
    	件,重新起监听端口等;
    - 校验方法:更新配置时会调用校验方法进行校验,以保证用户不会给配置项设置一个非法的值;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    1.2 基本功能

    - 支持定义/声明配置项,也就是在提供配置名称、类型以及可选的默认值的情况下生成一个可用的配置项。由于一项配置可能在多个
    	源文件中使用,所以配置模块还应该支持跨文件声明配置项的方法;
    
    - 支持更新配置项的值,配置项刚被定义时可能有一个初始默认值,但用户可能会有新的值来覆盖掉原来的值;
    
    - 支持从预置的途径中加载配置项,一般是配置文件,也可以是命令行参数,或是网络服务器。这里不仅应该支持基本数据类型的加
    	载,也应该支持复杂数据类型的加载,比如直接从配置文件中加载一个map类型的配置项,或是直接从一个预定格式的配置文件
    	中加载一个自定义结构体;
    
    - 支持给配置项注册配置变更通知,配置模块应该提供方法让程序知道某项配置被修改了,以便于进行一些操作。比如对于网络服务
    	器而言,如果服务器端口配置变化了,那程序应该重新起监听端口。这个功能一般是通过注册回调函数来实现的,配置使用方预
    	先给配置项注册一个配置变更回调函数,配置项发生变化时,触发对应的回调函数以通知调用方。由于一项配置可能在多个地方
    	引用,所以配置变更回调函数应该是一个数组的形式;
    
    - 支持给配置项设置校验方法,配置项在定义时也可以指定一个校验方法,以保证该项配置不会被设置成一个非法的值,比如对于文
    	件路径类的配置,可以通过校验方法来确保该路径一定存在;
    
    - 支持导出当前配置;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    二、模块设计

    2.1 配置基类

    配置基类,提供配置项的名称、描述,且名称都统一转为小写;
    提供获取配置参数名称getName()、描述信息getDescription()及值的类型的接口getTypeName();
    提供将值转为string及string转为值的接口;
    
    • 1
    • 2
    • 3

    ========》基类的介绍《========

    /**
     * @brief 配置变量的基类
     */
    class ConfigVarBase {
    public:
        typedef std::shared_ptr<ConfigVarBase> ptr;
        /**
         * @brief 构造函数
         * @param[in] name 配置参数名称[0-9a-z_.]
         * @param[in] description 配置参数描述
         */
        ConfigVarBase(const std::string& name, const std::string& description = "")
                :m_name(name)
                ,m_description(description) {
            std::transform(m_name.begin(), m_name.end(), m_name.begin(), ::tolower);
        }
    
        /**
         * @brief 析构函数
         */
        virtual ~ConfigVarBase() {}
    
        /**
         * @brief 返回配置参数名称
         */
        const std::string& getName() const { return m_name;}
    
        /**
         * @brief 返回配置参数的描述
         */
        const std::string& getDescription() const { return m_description;}
    
        /**
         * @brief 转成字符串
         */
        virtual std::string toString() = 0;
    
        /**
         * @brief 从字符串初始化值
         */
        virtual bool fromString(const std::string& val) = 0;
    
        /**
         * @brief 返回配置参数值的类型名称
         */
        virtual std::string getTypeName() const = 0;
    protected:
        /// 配置参数的名称
        std::string m_name;
        /// 配置参数的描述
        std::string m_description;
    };
    
    • 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

    2.2 类型转换仿函数

    以下将使用boost中的lexical_cast函数定义于文件boost/lexical_cast.hpp;
    	- lexical_cast使用统一的接口实现字符串与目标类型之间的转换;
    	- 相比于atoi()函数类型检测更严格;
    	- 错误时抛出boost::bad_lexical_cast异常,因此在使用boost::lexical_cast时一定要捕获异常;
    
    • 1
    • 2
    • 3
    • 4

    ========》仿函数的应用以及如何规范要求《========
    ========》模板函数的使用及模板特化《========
    ========》YAML用法及简介《========

    该配置模块采用的是yml的文件格式;
    
    【string转其他类型】:
    	- 先加载到YAML中,再遍历转换;
    【其他类型转string】:
    	- 先转换压入到node,在转换成stringstream;
    以下展示了vector以及其他类型的转换方式;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    /**
     * @brief 类型转换模板类(F 源类型, T 目标类型)
     */
    template<class F, class T>
    class LexicalCast {
    public:
        /**
         * @brief 类型转换
         * @param[in] v 源类型值
         * @return 返回v转换后的目标类型
         * @exception 当类型不可转换时抛出异常
         */
        T operator()(const F& v) {
            return boost::lexical_cast<T>(v);
        }
    };
    
    /**
     * @brief 类型转换模板类片特化(YAML String 转换成 std::vector)
     */
    template<class T>
    class LexicalCast<std::string, std::vector<T> > {
    public:
        std::vector<T> operator()(const std::string& v) {
            YAML::Node node = YAML::Load(v);
            typename std::vector<T> vec;
            std::stringstream ss;
            for(size_t i = 0; i < node.size(); ++i) {
                ss.str("");
                ss << node[i];
                vec.push_back(LexicalCast<std::string, T>()(ss.str()));
            }
            return vec;
        }
    };
    
    /**
     * @brief 类型转换模板类片特化(std::vector 转换成 YAML String)
     */
    template<class T>
    class LexicalCast<std::vector<T>, std::string> {
    public:
    std::string operator()(const std::vector<T>& v) {
        YAML::Node node(YAML::NodeType::Sequence);
        for(auto& i : v) {
            node.push_back(YAML::Load(LexicalCast<T, std::string>()(i)));
        }
        std::stringstream ss;
        ss << node;
        return ss.str();
    }
    };
    
    • 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

    自定义类型的使用

    class Person {
    public:
        Person() {};
        std::string m_name;
        int m_age = 0;
        bool m_sex = 0;
    
        std::string toString() const {
            std::stringstream ss;
            ss << "[Person name=" << m_name
               << " age=" << m_age
               << " sex=" << m_sex
               << "]";
            return ss.str();
        }
    
        bool operator==(const Person& oth) const {
            return m_name == oth.m_name
                && m_age == oth.m_age
                && m_sex == oth.m_sex;
        }
    };
    
    namespace sylar {
    
    template<>
    class LexicalCast<std::string, Person> {
    public:
        Person operator()(const std::string& v) {
            YAML::Node node = YAML::Load(v);
            Person p;
            p.m_name = node["name"].as<std::string>();
            p.m_age = node["age"].as<int>();
            p.m_sex = node["sex"].as<bool>();
            return p;
        }
    };
    
    template<>
    class LexicalCast<Person, std::string> {
    public:
        std::string operator()(const Person& p) {
            YAML::Node node;
            node["name"] = p.m_name;
            node["age"] = p.m_age;
            node["sex"] = p.m_sex;
            std::stringstream ss;
            ss << node;
            return ss.str();
        }
    };
    
    }
    
    sylar::ConfigVar<Person>::ptr g_person =
        sylar::Config::Lookup("class.person", Person(), "system person");
    
    • 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

    2.3 配置类

    该类继承了ConfigVarBase,属性上增加了配置项的值以及值的变更回调函数组;
    提供toString()将参数值转换成YAML String;
    提供fromString()从YAML String 转成参数的值;
    对于变更回调函数组,提供addListener()可对其添加回调函数、delListener()删除、clearListener()清空、getListener() 获取
    用户可自定义添加变更回调函数,当值更新后做一些其他操作;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    ========》类模板简介《========
    ========》类模板、异常处理及执行期类型识别《========

    /**
     * @brief 配置参数模板子类,保存对应类型的参数值
     * @details T 参数的具体类型
     *          FromStr 从std::string转换成T类型的仿函数
     *          ToStr 从T转换成std::string的仿函数
     *          std::string 为YAML格式的字符串
     */
    template<class T, class FromStr = LexicalCast<std::string, T>
            ,class ToStr = LexicalCast<T, std::string> >
    class ConfigVar : public ConfigVarBase {
    public:
        typedef RWMutex RWMutexType;
        typedef std::shared_ptr<ConfigVar> ptr;
        typedef std::function<void (const T& old_value, const T& new_value)> on_change_cb;
    
        /**
         * @brief 通过参数名,参数值,描述构造ConfigVar
         * @param[in] name 参数名称有效字符为[0-9a-z_.]
         * @param[in] default_value 参数的默认值
         * @param[in] description 参数的描述
         */
        ConfigVar(const std::string& name
                ,const T& default_value
                ,const std::string& description = "")
                :ConfigVarBase(name, description)
                ,m_val(default_value) {
        }
    
        /**
         * @brief 将参数值转换成YAML String
         * @exception 当转换失败抛出异常
         */
        std::string toString() override {
            try {
                //return boost::lexical_cast(m_val);
                RWMutexType::ReadLock lock(m_mutex);
                return ToStr()(m_val);
            } catch (std::exception& e) {
                SYLAR_LOG_ERROR(SYLAR_LOG_ROOT()) << "ConfigVar::toString exception "
                                                  << e.what() << " convert: " << typeid(T).name() << " to string"
                                                  << " name=" << m_name;
            }
            return "";
        }
    
        /**
         * @brief 从YAML String 转成参数的值
         * @exception 当转换失败抛出异常
         */
        bool fromString(const std::string& val) override {
            try {
                setValue(FromStr()(val));
            } catch (std::exception& e) {
                SYLAR_LOG_ERROR(SYLAR_LOG_ROOT()) << "ConfigVar::fromString exception "
                                                  << e.what() << " convert: string to " << typeid(T).name()
                                                  << " name=" << m_name
                                                  << " - " << val;
            }
            return false;
        }
    
        /**
         * @brief 获取当前参数的值
         */
        const T getValue() {
            RWMutexType::ReadLock lock(m_mutex);
            return m_val;
        }
    
        /**
         * @brief 设置当前参数的值
         * @details 如果参数的值有发生变化,则通知对应的注册回调函数
         */
        void setValue(const T& v) {
            {
                RWMutexType::ReadLock lock(m_mutex);
                if(v == m_val) {
                    return;
                }
                for(auto& i : m_cbs) {
                    i.second(m_val, v);
                }
            }
            RWMutexType::WriteLock lock(m_mutex);
            m_val = v;
        }
    
        /**
         * @brief 返回参数值的类型名称(typeinfo)
         */
        std::string getTypeName() const override { return typeid(T).name();}
    
        /**
         * @brief 添加变化回调函数
         * @return 返回该回调函数对应的唯一id,用于删除回调
         */
        uint64_t addListener(on_change_cb cb) {
            static uint64_t s_fun_id = 0;
            RWMutexType::WriteLock lock(m_mutex);
            ++s_fun_id;
            m_cbs[s_fun_id] = cb;
            return s_fun_id;
        }
    
        /**
         * @brief 删除回调函数
         * @param[in] key 回调函数的唯一id
         */
        void delListener(uint64_t key) {
            RWMutexType::WriteLock lock(m_mutex);
            m_cbs.erase(key);
        }
    
        /**
         * @brief 获取回调函数
         * @param[in] key 回调函数的唯一id
         * @return 如果存在返回对应的回调函数,否则返回nullptr
         */
        on_change_cb getListener(uint64_t key) {
            RWMutexType::ReadLock lock(m_mutex);
            auto it = m_cbs.find(key);
            return it == m_cbs.end() ? nullptr : it->second;
        }
    
        /**
         * @brief 清理所有的回调函数
         */
        void clearListener() {
            RWMutexType::WriteLock lock(m_mutex);
            m_cbs.clear();
        }
    private:
        RWMutexType m_mutex;
        T m_val;
        //变更回调函数组, uint64_t key,要求唯一,一般可以用hash
        std::map<uint64_t, on_change_cb> m_cbs;
    };
    
    • 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
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137

    2.4 ConfigVar的管理类

    该类用于管理ConfigVar,提供Lookup()创建/访问Config的方法;
    提供加载配置文件的接口LoadFromYaml(),LoadFromConfDir()可加载当个文件或整个文件夹;
    
    • 1
    • 2

    ========》静态成员函数《========
    ========》类的静态成员《========
    ========》静态成员在执行期会处理哪些事《========

    /**
     * @brief ConfigVar的管理类
     * @details 提供便捷的方法创建/访问ConfigVar
     */
    class Config {
    public:
        typedef std::unordered_map<std::string, ConfigVarBase::ptr> ConfigVarMap;
        typedef RWMutex RWMutexType;
    
        /**
         * @brief 获取/创建对应参数名的配置参数
         * @param[in] name 配置参数名称
         * @param[in] default_value 参数默认值
         * @param[in] description 参数描述
         * @details 获取参数名为name的配置参数,如果存在直接返回
         *          如果不存在,创建参数配置并用default_value赋值
         * @return 返回对应的配置参数,如果参数名存在但是类型不匹配则返回nullptr
         * @exception 如果参数名包含非法字符[^0-9a-z_.] 抛出异常 std::invalid_argument
         */
        template<class T>
        static typename ConfigVar<T>::ptr Lookup(const std::string& name,
                                                 const T& default_value, const std::string& description = "") {
            RWMutexType::WriteLock lock(GetMutex());
            auto it = GetDatas().find(name);
            if(it != GetDatas().end()) {
                auto tmp = std::dynamic_pointer_cast<ConfigVar<T> >(it->second);
                if(tmp) {
                    SYLAR_LOG_INFO(SYLAR_LOG_ROOT()) << "Lookup name=" << name << " exists";
                    return tmp;
                } else {
                    SYLAR_LOG_ERROR(SYLAR_LOG_ROOT()) << "Lookup name=" << name << " exists but type not "
                                                      << typeid(T).name() << " real_type=" << it->second->getTypeName()
                                                      << " " << it->second->toString();
                    return nullptr;
                }
            }
    
            if(name.find_first_not_of("abcdefghikjlmnopqrstuvwxyz._012345678")
               != std::string::npos) {
                SYLAR_LOG_ERROR(SYLAR_LOG_ROOT()) << "Lookup name invalid " << name;
                throw std::invalid_argument(name);
            }
    
            typename ConfigVar<T>::ptr v(new ConfigVar<T>(name, default_value, description));
            GetDatas()[name] = v;
            return v;
        }
    
        /**
         * @brief 查找配置参数
         * @param[in] name 配置参数名称
         * @return 返回配置参数名为name的配置参数
         */
        template<class T>
        static typename ConfigVar<T>::ptr Lookup(const std::string& name) {
            RWMutexType::ReadLock lock(GetMutex());
            auto it = GetDatas().find(name);
            if(it == GetDatas().end()) {
                return nullptr;
            }
            return std::dynamic_pointer_cast<ConfigVar<T> >(it->second);
        }
    
        /**
         * @brief 使用YAML::Node初始化配置模块
         */
        static void LoadFromYaml(const std::string& name);
    
        /**
         * @brief 加载path文件夹里面的配置文件
         */
        static void LoadFromConfDir(const std::string& path, bool force = false);
    
        /**
         * @brief 查找配置参数,返回配置参数的基类
         * @param[in] name 配置参数名称
         */
        static ConfigVarBase::ptr LookupBase(const std::string& name);
    
        /**
         * @brief 遍历配置模块里面所有配置项
         * @param[in] cb 配置项回调函数
         */
        static void Visit(std::function<void(ConfigVarBase::ptr)> cb);
    
        /** 获取加载的配置文件名 */
        static std::string& GetFileName() {
            static std::string filename;
            return filename;
        }
    private:
    
        /**
         * @brief 返回所有的配置项
         */
        static ConfigVarMap& GetDatas() {
            static ConfigVarMap s_datas;
            return s_datas;
        }
    
        /**
         * @brief 配置项的RWMutex
         */
        static RWMutexType& GetMutex() {
            static RWMutexType s_mutex;
            return s_mutex;
        }
    };
    
    
    • 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

    三、测试

    以下为log.yml的配置文件
    
    • 1

    在这里插入图片描述

    test.yml
    
    • 1

    在这里插入图片描述

    sylar::ConfigVar<int>::ptr g_int_value_config =
        sylar::Config::Lookup("system.port", (int)8080, "system port");
    
    sylar::ConfigVar<std::vector<int> >::ptr g_int_vec_value_config =
        sylar::Config::Lookup("system.int_vec", std::vector<int>{1,2}, "system int vec");
    
    void test_config() {
        std::cout << "before" << std::endl;
        std::cout << "system.port: " << g_int_value_config->getValue() << std::endl;
        std::cout << "system.int_vec: ";
        for(auto i:g_int_vec_value_config->getValue()) {
            std::cout << i << " ";
        }
        std::cout << std::endl;
    
        sylar::Config::LoadFromYaml("/root/code/log_server/bin/conf/test.yml");
        std::cout << "after" << std::endl;
        std::cout << "system.port: " << g_int_value_config->getValue() << std::endl;
        std::cout << "system.int_vec: ";
        for(auto i:g_int_vec_value_config->getValue()) {
            std::cout << i << " ";
        }
        std::cout << std::endl;
    }
    
    
    void test_log() {
        static sylar::Logger::ptr system_log = SYLAR_LOG_NAME("system");
        SYLAR_LOG_INFO(system_log) << "hello system" << std::endl;
        std::cout << sylar::LoggerMgr::GetInstance()->toYamlString() << std::endl;
        sylar::Config::LoadFromYaml("/root/code/log_server/bin/conf/log.yml");
        std::cout << "=============" << std::endl;
        std::cout << sylar::LoggerMgr::GetInstance()->toYamlString() << std::endl;
        std::cout << "=============" << std::endl;
        SYLAR_LOG_INFO(system_log) << "hello system" << std::endl;
    
        system_log->setFormatter("%d - %m%n");
        SYLAR_LOG_INFO(system_log) << "hello system" << std::endl;
    }
    
    int main(int argc, char** argv) {
         test_config();
        std::cout << "**********************************************************" << std::endl;
        test_log();
    
        return 0;
    }
    
    
    • 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

    在这里插入图片描述

  • 相关阅读:
    搭建vue2 工程
    app毕业设计作品安卓毕业设计成品基于Uniapp+SSM实现的智能课堂管理
    Shopify独立站流量还可以从哪里来
    dompdf,这么做就可以支持中文了
    Shell 正则及其命令
    3D打印CLI文件格式的读取
    pyinstaller打包教程(pycharm)
    区块链应用(去中心化应用)是什么样的?
    autoware.ai中检测模块lidar_detector caffe
    深度学习系列1——Pytorch 图像分类(LeNet)
  • 原文地址:https://blog.csdn.net/weixin_45926547/article/details/126029195