log4cpp源码阅读:Layout组件解析

1059 篇文章 285 订阅

功能

Layout在log4cpp中是作为Appender的一个可选的策略接口而存在的。它的主要功能是将一个LoggingEvent的对象类型格式化为一个可以输出的字符串类型。

   class Layout {
        public:
       // 作为基类的析构函数必须是virtual:如果你外面使用基类的指针来接收具体的子类地址,那么当你使用delete删除掉子基类的指针的时候,可以保证子类的虚方法也能够被调用
        virtual ~Layout() { };

        // 纯虚方法
        // 功能就是将event格式化为string类型
        virtual std::string format(const LoggingEvent& event) = 0;
    };      

从上面可以看到,它只是定义了一个format纯虚方法,功能就是将event格式化为一个string。

我们可以简单回忆一下LoggingEvent中的成员:

  • categoryName : category的名称
  • priority : 日志操作的等级
  • ndc : 是使用NDC::get()获取的栈顶的fullMessage
  • message : 要进行日志操作的消息
  • threadName : 发出日志请求所在的线程名称,内部是使用threading::getThreadID来获取的
  • timeStamp : 发出日志请求时候的时间

我们来看一下Layout的具体几个实现类

在这里插入图片描述

SimpleLayout类

class SimpleLayout : public Layout {
	public:
        SimpleLayout() {} ;
        virtual ~SimpleLayout() {};

        virtual std::string format(const LoggingEvent& event);
};  




std::string SimpleLayout::format(const LoggingEvent& event) {
        std::ostringstream message;
		
     	 //  利用Priority来讲Priority::Value转换为可读的字符串
        const std::string& priorityName = Priority::getPriorityName(event.priority);
        //首先流每次输出宽度,并且是靠左,也就是说右侧可能有空格  Priority::MESSAGE_SIZE=8
		message.width(Priority::MESSAGE_SIZE);message.setf(std::ios::left);
        //输出格式:首先是操作级别名称 : 具体的日志消息
		message << priorityName << ": " << event.message << std::endl;
        return message.str();
    }

SimpleLayout的输出格式是: priorityName[当priorityName长度不够Priority::MESSAGE_SIZE的时候,这儿会有留空]: message 换行

BasicLayout类

class BasicLayout : public Layout {
   public:
        BasicLayout();
        virtual ~BasicLayout();
        virtual std::string format(const LoggingEvent& event);
};     


std::string BasicLayout::format(const LoggingEvent& event) {
        std::ostringstream message;

        const std::string& priorityName = Priority::getPriorityName(event.priority);
        message << event.timeStamp.getSeconds() << " " << priorityName << " " 
                << event.categoryName << " " << event.ndc << ": " 
                << event.message << std::endl;

        return message.str();
    }

BaseicLayout类的输出格式是: 日志发出时的时刻(秒数描述表示) 日志等级名称 category名称 ndc内容 : 日志内容 换行

PatternLayout类

PatternLayout可以使用指定格式来格式化LoggingEvent对象为字符串。类似printf, vsprintf,比如"%d"输出日志的发出具体时间,"%p"表示输出日志的等级名称, m表示输出日志的消息。。。。

我们可以通过setConversionPattern方法来设置指定的格式化字符串,我们来看一下这个方法

PatternLayout::setConversionPattern

class PatternLayout : public Layout {
	private:
		 std::string _conversionPattern; // 用来设置传过来的格式化字符串
	public:
		class PatternComponent {  //一个格式化组件
            public:
           		inline virtual ~PatternComponent() {};
           	 // 将event中的某些信息输出到out中
            	virtual void append(std::ostringstream& out, const LoggingEvent& event) = 0;
        };
        virtual void setConversionPattern(const std::string& conversionPattern);
}


void PatternLayout::clearConversionPattern() {
        for(ComponentVector::const_iterator i = _components.begin();
            i != _components.end(); ++i) {
            delete (*i);
        }
        _components.clear();
        _conversionPattern = "";
}

// 设置格式化字符串
void PatternLayout::setConversionPattern(const std::string& conversionPattern) {
	std::istringstream conversionStream(conversionPattern);  //一个字符串输入流
	std::string literal;     // 用来存储某些纯字符串值
 
 	char ch;
    PatternLayout::PatternComponent* component = NULL;
   	int minWidth = 0;
    size_t maxWidth = 0;
    clearConversionPattern();
	
	// 一个一个遍历字符,结果保存在ch中
    while (conversionStream.get(ch)) {
           // 当前流字符是'%',则说明下一个字符应该是应该是具体的格式化字符串
           // 那么内部肯定要解析具体的格式化字符串
           if (ch == '%') {
           		// 这儿主要是处理,形如%10.20m的情况
                // readPrefix;
                {
                    char ch2;
                    conversionStream.get(ch2); 
                     // 如果是数字,则读取下一个数字
                    if ((ch2 == '-') || ((ch2 >= '0') && (ch2 <= '9'))) {
                        conversionStream.putback(ch2); //ch2只是用来探测,当发现符合,把它放回去
                        conversionStream >> minWidth;
                        conversionStream.get(ch2);   //ch2读取数字下一个字符
                    } 
                    // 如果是小数点,那么他会退到这儿的具体格式是形如%10.20的格式
                    if (ch2 == '.') {
                        conversionStream >> maxWidth;  // 读取小数点后面的字符
                    } else {
                        conversionStream.putback(ch2); // 如果不是的话,还把ch2放回去,不影响下面的解析操作
                    }                        
                }
                // 这儿的字符肯定是具体的格式化字符,这儿也对错误进行了处理
           // 就是如果conversionPattern的最后一个字符是%的时候,到这儿肯定是读取失败的,然后就会抛出ConfigureFailure异常
               if (!conversionStream.get(ch)) {
                    std::ostringstream msg;
                    msg << "unterminated conversion specifier in '" << conversionPattern << "' at index " << conversionStream.tellg();
                    throw ConfigureFailure(msg.str());
                }
                 // 下面处理的是形如%m{%Y-%m-%d %H:%M:%S}的情况
                std::string specPostfix = "";
                // read postfix
                {
                    char ch2;
                    if (conversionStream.get(ch2)) { //ch2是格式化字符后面的字符,比如%m{..},那么这儿ch2就是{			
                    	 // 如果格式化字符后面有大括号,那么大括号里面的内容会保存到specPostfix
                        if (ch2 == '{') {
                            while(conversionStream.get(ch2) && (ch2 != '}'))
                                specPostfix += ch2;
                        } else {
                            conversionStream.putback(ch2); // 否则取消之前的操作,不影响下次正常读取
                        }
                    }
                }
                // 到目前为止ch肯定是一个特别的格式化字符了,我们可以根据下面的操作,明白各种格式化字符的具体含义
                switch (ch) {
                case '%':  // 如果出现两次%,他会PatternLayout类,会把它当做一个%
                    literal += ch;
                    break;
                case 'm':   // 如果出现%m, 那么表示要输出的是消息,至于XXXComponent会在后面进行介绍
                           // 现在只要知道MessageComponent是PatternComponent的基类就可以了
                    component = new MessageComponent();
                    break;
                case 'n':     //  如果出现%n,那么表示要输出的内容是一个换行符
                    {
                        std::ostringstream endline;
                        endline << std::endl;
                        literal += endline.str();
                    }
                    break;
                case 'c':  // 如果出现%c,则说明要输出的内容是category的名称
                    component = new CategoryNameComponent(specPostfix);
                    break;
                    // 如果出现%d,则说明要输出的内容是日志产生的时间,这儿我们需要注意的是,他居然把
		           // specPostfix,就是大括号里面的内容传给下面类的构造函数,原因,这儿可以提前说一个
		           // 比如%d 他只会输出默认的日志格式,但是如果你传入%d{%H:%M:%S,%l},他会只输出时间
		           // 不会输出日期,具体可以查看TimeStampComponent的实现,我们下面会有介绍的,总之
		           // {}中的内容可以是strftime支持的格式,另外在加上%l表示输出毫秒
               case 'd':
                    component = new TimeStampComponent(specPostfix);
                    break;
                case 'p':  // 如果出现%p,则说明要输出的内容是日志等级的字符串形式
                    component = new PriorityComponent();
                    break;
                case 'r':  // 如果输出的是%r,则说明要输出的内容是 日志产生时刻到进程启动时刻的时间间隔
                    component = new MillisSinceEpochComponent();
                    break;
                case 'R':   // 如果输出的是%R,则说明要输出的内容是 日志产生的时候的时间(但是是秒数)
                    component = new SecondsSinceEpochComponent();
                    break;
                case 't':	 // 如果输出的是%t, 则说明要输出的内容是线程名称
                    component = new ThreadNameComponent();
                    break;
                case 'u':  // 如果输出的是%u, 则说明要输出的内容是处理器运行到这儿(或者说执行到这儿)的时间
                    component = new ProcessorTimeComponent();
                    break;
                case 'x':   // 如果输出的是%x, 则说明要输出的内容是category.ndc的内容
                    component = new NDCComponent();
                    break;
                default:  //这儿是抛出异常
                    std::ostringstream msg;
                    msg << "unknown conversion specifier '" << ch << "' in '" << conversionPattern << "' at index " << conversionStream.tellg();
                    throw ConfigureFailure(msg.str());                    
                }
                // 表示当前解析的字符单元是格式化字符串
                if (component) {
                    if (!literal.empty()) {
                        _components.push_back(new StringLiteralComponent(literal));
                        literal = "";
                    }
                    // 如果处理到形如%10.20m的时候,那面下面会被执行
            	    // 此时minWidth=10 maxWidth=20
                   if ((minWidth != 0) || (maxWidth != 0)) {
                        component = new FormatModifierComponent(component, std::abs(minWidth), maxWidth, minWidth < 0);   // 这个类的内部使用的是装饰着模式
                        minWidth = maxWidth = 0;
                    }
                    _components.push_back(component); // 会将构造好的PatternComponent对象放到_components中
                    component = NULL;
                }
            } else {
                literal += ch;  //如果ch不是格式化字符,则将这个字符添加到literal中,正如前面介绍的,他是用来存储普通字符串
            }
        }
	   // 当上面的循环执行完毕,那么所有的conversionPattern中的内容都会被解析完毕,我们可以保证的
	   // 是前面所有格式化字符串的内容都会被构建成PatternComponent对象的形式,并且会被保存到_components中
	   // 但是输出普通文本的时机是当解析到格式化字符串的时候,才会将前次产生的字符串literal进行输出,但是如果
	   // conversionPattern的最后一部分内容不是普通文本的时候,这个时候,字符串内容仍然保存在literal中
	   // 所以当literal不为空的时候,我们还需要将普通文本也进行输出
        if (!literal.empty()) {
            _components.push_back(new StringLiteralComponent(literal));
        }

        _conversionPattern = conversionPattern;  //最后将设置过来的字符串保存到_conversionPattern属性中
}

从上面可以看出,设置格式化字符串的时机是在setConversionPattern方法中,解析格式化字符串的时机也是在setConversionPattern方法中,内部会将解析到的内容以PatternComponent具体子类的对象来表示,并且最终完整结果保存到_components容器中

PattrenLayout支持的格式化字符串表

有如下映射关系:
在这里插入图片描述

PatternLayout::format

std::string PatternLayout::format(const LoggingEvent& event) {
        std::ostringstream message;  // 创建一个输出字符串流来保存最终结果

      // 挨个调用PatternComponent的append方法来讲内容输出到message中
        for(ComponentVector::const_iterator i = _components.begin();
            i != _components.end(); ++i) {
            (*i)->append(message, event);
        }

        return message.str();  // 返回message的内容
    }

下面我们来详细探索一下PatternComponent相关组件的功能。

PatternComponent

class PatternLayout {
private:
   ...
public:
   // 一个表达式组件
    class PatternComponent {
        public:
        inline virtual ~PatternComponent() {};

       // 将event中的某些信息输出到out中
       // out是输出参数
        virtual void append(std::ostringstream& out, const LoggingEvent& event) = 0;
    };
   virtual void setConversionPattern(const std::string &conversionPattern)throw(ConfigureFailure);
};

其实PatternComponent就是一个接口,它的功能就是允许将event中的某些信息输出到out中

下面来看看它的具体子类实现

StringLiteralComponent

// 原样输出通过构造器传进来的字符串
struct StringLiteralComponent : public PatternLayout::PatternComponent {
		// 构造器,设置_literal的值
        StringLiteralComponent(const std::string& literal) :
            _literal(literal) {
        }

       // 实现基类PatternComponent 的方法
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            out << _literal;  // 将从构造器中传进来的内容输出到out中
        }

        private:
        std::string _literal; // 保存要进行输出的字面内容
    };

以及:

struct MessageComponent : public PatternLayout::PatternComponent {
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            out << event.message;  // 原样输出event中的message属性
        }
    };

    struct NDCComponent : public PatternLayout::PatternComponent {
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            out << event.ndc; // 原样输出event中的ndc属性
        }
    };

    struct PriorityComponent : public PatternLayout::PatternComponent {
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            out << Priority::getPriorityName(event.priority);  // 输出event的对应的权限等级
        }
    };

    struct ThreadNameComponent : public PatternLayout::PatternComponent {
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            out << event.threadName;  // 原样输出event中的threadName
        }
    };

    struct ProcessorTimeComponent : public PatternLayout::PatternComponent {
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            out << std::clock();  // 输出当前时间(其实是处理器运行到这儿的时间)
        }
    };



struct SecondsSinceEpochComponent : public PatternLayout::PatternComponent {
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            out << event.timeStamp.getSeconds(); // 输出的是当前日志产生的时间(单位是秒)
        }
    };

    struct MillisSinceEpochComponent : public PatternLayout::PatternComponent {
    // 输出的内容日志记录产生时间到进程启动时间的间隔(单位是毫秒)
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            int64_t t = event.timeStamp.getSeconds() -
                TimeStamp::getStartTime().getSeconds();  //获取的是日志产生时间到进程启动时间的间隔
            t *= 1000;   //将秒转换为毫秒
            t += event.timeStamp.getMilliSeconds() -
                TimeStamp::getStartTime().getMilliSeconds();
            
            out << t;
        }
    };

下面会介绍几个复杂点的PatternComponent

CategoryNameComponent

struct CategoryNameComponent : public PatternLayout::PatternComponent {
		//specifier是一个可以转换为int类型的字符串或者空字符串
        CategoryNameComponent(std::string specifier) {
            if (specifier == "") {
                _precision = -1;
            } else {
                std::istringstream s(specifier); // 利用字符串流将性如"123","222"字符串转换为int类型的数字,相当于atoi喊函数
                s >> _precision;
            }
        }

        // 实现PatternComponent具体的追加方法
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            if (_precision == -1) {
                out << event.categoryName;    // 当_precision == -1则直接将category名称进行原样输出
            } else {
                std::string::size_type begin = std::string::npos;
                // 这儿的逻辑就是倒数第_precision个.后面的字符
	           // 比如如果categoryName = "pparent.parent.son"
	           // 那么%c{1}返回的是son
                for(int i = 0; i < _precision; i++) {
                    begin = event.categoryName.rfind('.', begin - 2);
                    if (begin == std::string::npos) {
                        begin = 0;
                        break;
                    }
                    begin++;  // 这儿加1是为了下面那句代码考虑的,保证退出循环时候,begin位于.的后一个字符
                }
                if (begin == std::string::npos) {
                    begin = 0;
                }
                out << event.categoryName.substr(begin);//截取地倒数_precision个.后面的字符
            }
        }

       private:
        int _precision;
    };

从上面可以分析出,CategoryNameComponent除了可以输出category的名称,还可以指定一个presion,来指定到底输出几个名称。比如categoryName为"parent.parent.son",那么如果格式化字符串是"%c{1}“则输出内容为"son”, 如果格式化字符串是"%c{2}“则输出内容是"parent.son”…

TimeStampComponent




struct TimeStampComponent : public PatternLayout::PatternComponent {
        static const char* const FORMAT_ISO8601;  // 几个字符串常量,每个常量都是具体的格式化字符串
        static const char* const FORMAT_ABSOLUTE;
        static const char* const FORMAT_DATE;
	
	 	//从下面的源码中,我们可以发现,timeFormat除了是形如:%H:%M:%S的格式
	    //还可以是一些格式的名称,比如ISO8601,则他的内部使用格式就是:"%Y-%m-%d %H:%M:%S,%l",
	    //当空的时候,也是使用这个格式,所以当我们指定%d或者%d{} 他能够输出完整的时间
	    //比如ABSOLUTE,则他的格式就是:"%H:%M:%S,%l",只包含时间,没有日期信息
	    //比如DATE,则他内部使用的格式是:"%d %b %Y %H:%M:%S,%l"
        TimeStampComponent(std::string timeFormat) {
      	  // 处理timeFormat使用某种固定格式的名称
            if ((timeFormat == "") || (timeFormat == "ISO8601")) {
                timeFormat = FORMAT_ISO8601;
            } else if (timeFormat == "ABSOLUTE") {
                timeFormat = FORMAT_ABSOLUTE;
            } else if (timeFormat == "DATE") {
                timeFormat = FORMAT_DATE;
            }
            // 根据timeFormat中是否含有%l来确定是否要显示毫秒部分
            std::string::size_type pos = timeFormat.find("%l");
            if (pos == std::string::npos) {
                _printMillis = false;
                _timeFormat1 = timeFormat; 
            } else {                      // 有的话  
                _printMillis = true;     //假如说,timeFormat为%H:%M:%S,%lms,则
                _timeFormat1 = timeFormat.substr(0, pos); //截取非毫秒部分, 为%H:%M:%S,
                _timeFormat2 = timeFormat.substr(pos + 2);//截取毫秒部分      为ms
            }
        }

		 // 实现PatternComponent中的append方法
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            struct std::tm currentTime;
            std::time_t t = event.timeStamp.getSeconds();//获取的是utc时间(就是距离1970-1-1 00:00:00的时间间隔),单位是秒数
            localtime(&t, &currentTime);  //将utc时间转换为本地时间
            char formatted[100];
            std::string timeFormat;
            if (_printMillis) { //如果显示毫秒部分,因为下面strftime不支持格式化毫秒部分,所以首先把格式化毫秒部分的内容给处理出来
                std::ostringstream formatStream;
                formatStream << _timeFormat1 
                             << std::setw(3) << std::setfill('0')
                             << event.timeStamp.getMilliSeconds()
                             << _timeFormat2;
                timeFormat = formatStream.str();
            } else {
                timeFormat = _timeFormat1;
            }
            std::strftime(formatted, sizeof(formatted), timeFormat.c_str(), &currentTime);
            out << formatted;
        }

       private:
   		 std::string _timeFormat1;  // 非毫秒部分的格式
     	std::string _timeFormat2;  // 毫秒部分的格式
    	 bool _printMillis;         // 用来标志是否输出毫秒部分的内容

};



const char* const TimeStampComponent::FORMAT_ISO8601 = "%Y-%m-%d %H:%M:%S,%l";
const char* const TimeStampComponent::FORMAT_ABSOLUTE = "%H:%M:%S,%l";
const char* const TimeStampComponent::FORMAT_DATE = "%d %b %Y %H:%M:%S,%l";

这个方法内部实现使用的是strftime,所以他内部的设置的日期具体字符串是和strftime中的字符串是完全一致的。并且一般平时,我们只需要 使用d就能够输出我们需要的格式了,因为他默认输出格式是%Y-%m-%d %H:%M:%S,%l,包含日期 时间,和毫秒。有一点需要说明,向输出毫秒使用%l,可能 这个格式strftime可能不支持。

FormatModifierComponent

 struct FormatModifierComponent : public PatternLayout::PatternComponent {
		 // component 是要进行包装的组件对象
		// minWidth 用来设置的最小宽度
		// maxWidth 用来进行设置的最大宽度
		// alignLeft 是到输出的内容长度小于指定的minWidth用来指定文字的对齐方法,如果是左边则右不空,否则左不空
        FormatModifierComponent(PatternLayout::PatternComponent* component,
                                size_t minWidth, size_t maxWidth, bool alignLeft) :
            _component(component) , 
            _minWidth(minWidth),
            _maxWidth(maxWidth),
            _alignLeft(alignLeft) {
        }

        virtual ~FormatModifierComponent() {
            delete _component;
        }

		// 实现基类的append方法
        virtual void append(std::ostringstream& out, const LoggingEvent& event) {
            std::ostringstream s;
            _component->append(s, event);  // 装饰着模式
            std::string msg = s.str();
            // 如果处理最大宽度,删除掉最大长度的后面的内容
            if (_maxWidth > 0 && _maxWidth < msg.length()) {
                msg.erase(_maxWidth);
            }
            
            // 需要填充字符,当这个值大于0则需要填充
            size_t fillCount = _minWidth - msg.length();
            if (_minWidth > msg.length()) {
                if (_alignLeft) {
                    out << msg << std::string(fillCount, ' ');
                } else {
                    out << std::string(fillCount, ' ') << msg;
                }
            } else {
                out << msg;   //要输出内容的长度大于等于最小宽度,则原样输出
            }
        }

        private:
	        PatternLayout::PatternComponent* _component;
	        size_t _minWidth;
	        size_t _maxWidth;
	        bool _alignLeft;
    };

    const char* PatternLayout::DEFAULT_CONVERSION_PATTERN = "%m%n";
    const char* PatternLayout::SIMPLE_CONVERSION_PATTERN = "%p - %m%n";
    const char* PatternLayout::BASIC_CONVERSION_PATTERN = "%R %p %c %x: %m%n";
    const char* PatternLayout::TTCC_CONVERSION_PATTERN = "%r [%t] %p %c %x - %m%n";

这个类是一个典型的装饰者模式,可以使用这个类来控制已有的PatternLayout的输出宽度

PatternLayout总结

回过头来看一下 PatternLayout::setConversionPattern的实现:

  • 它提取conversionPattern中的每一个字符,当提取的字符是以%开头,它会尝试提取形如%10.20m这样的格式化字符串,将10保存到minWidth 将20保存到maxWidth中,然后提取格式化字符,然后会尝试提取大括号中的内容将内容保存到specPosfix中,最后会根据提取的格式化字符来 创建具体的PatternComponent子类。
  • 会将创建的子类指针保存到component中,对于非%开头的内容,他会将字符保存到literal中,循环体的最后 会根据compoent是否为空,来决定是否输出上次literal文本中的内容,使用的是_components.push_back(new StringLiteralComponent(literal)); 然后输出本次component中的内容,然后程序根据maxWidth和minWidth的状态来决定是否使用FormatModifierComponent来设置component输出的 最小宽度和最大宽度,按照上述步骤,循环执行,直到所有的格式化字符串都转换为PatternComponent对象添加_components中。
  • 在循环体外,他会根据 literal的状态来确定是否conversionPattern的最后内容是纯字符串,如果是则将最后的字符串内容也转换为StringLiteralComponent对象添加到_components中。

PatternLayout::format方法实现:

  • 首先新建一个字符串输出流来获取最终的结果,
  • 然后挨个遍历_components中的PatternComponent对象,调用每个对象的append方法,将上一步创建的字符串输出流作为append方法实际上参数来接收结果
  • 最后遍历完毕后,那么最终的结果就会保存到是输出流中,就可以得到想要的结果

总结

Layout的功能就是将一个LoggingEvent对象格式化为一个可读的字符串。

对于SimpleLayout, BasicLayout, PatternLayout三个具体具体的Layout的实现类

  • SimpleLayout 格式是: 权限 : 消息 换行
  • BasicLayout 格式是: 时刻(秒数为单位) 日志等级 category名称 ndc内容: 消息 换行
  • PatternLayout 输出格式可以由用户指定,具体的格式
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值