Glog日志

				版权声明:本文为博主原创文章,未经博主允许不得转载。					https://blog.csdn.net/breaksoftware/article/details/51363353				</div>
							<link rel="stylesheet" href="https://csdnimg.cn/release/phoenix/template/css/ck_htmledit_views-f57960eb32.css">
							            <link rel="stylesheet" href="https://csdnimg.cn/release/phoenix/template/css/ck_htmledit_views-f57960eb32.css">
					<div class="htmledit_views" id="content_views">
            <p>&nbsp; &nbsp; &nbsp; &nbsp; GLog是Google开发的一套日志输出框架。由于其具有功能强大、方便使用等特性,它被众多开源项目使用。本文将通过分析其源码,解析Glog实现的过程。</p>

        该框架的源码在https://github.com/google/glog上可以获取到。本文将以目前最新的0.3.3版本源码为范例进行分析。(转载请指明出于breaksoftware的csdn博客)

        首先我们介绍下Glog简单的使用方法。Glog暴露了很多方法供使用者调用,一般它们都是通过宏的形式提供的。这些方法的功能包括:参数设置、判断、日志输出、自定义行为。基于重要性的考虑,我并不准备在本文中介绍参数设置和判断两个特性的函数。而将重点放在Glog的核心——日志输出和自定义行为上。而且在日志输出环节,我们将重点关注于文件形式的输出。下文中很多未加设定的场景一般都是以文件输出为例说明的。

        我们以一个简单的例子作为开始


 
 
  1. int _tmain( int argc, _TCHAR* argv[])
  2. {
  3. google::InitGoogleLogging(argv[ 0]);
  4. FLAGS_log_dir = "D:\\Dev\\glog-0.3.3\\glog-0.3.3\\Debug\\log";
  5. LOG(INFO) << "INFO";
  6. LOG(INFO) << "INFO1";
  7. LOG(WARNING) << "WARNING";
  8. LOG(WARNING) << "WARNING1";
  9. LOG(ERROR) << "ERROR";
  10. LOG(ERROR) << "ERROR1";
  11. LOG(FATAL) << "FATAL";
  12. google::ShutdownGoogleLogging();
  13. return 0;
  14. }

        第3和12行是对称的,用于初始化和关闭Glog系统。这两个过程的实现也非常简单,其比较有意义的是ShutdownGoogleLogging中实现了对过程中新建对象的清理。即调用了


 
 
  1. static LogDestination* log_destinations_[NUM_SEVERITIES];
  2. void LogDestination::DeleteLogDestinations() {
  3. for ( int severity = 0; severity < NUM_SEVERITIES; ++severity) {
  4. delete log_destinations_[severity];
  5. log_destinations_[severity] = NULL;
  6. }
  7. }

        LogDestination是是GLog非常核心的一个类。按英文翻译过来,它就是“日志目标”类。正如它名称,我们对日志的输出最终将落到该类上去执行。之后我们将一直和它打交道。

        上面main函数的04行对Glog的全局变量重新赋值,它用于标记日志文件的生成路径。这类全局变量在logging.h中暴露了很多,它们很多是以DECLARE_bool、DECLARE_int32或DECLARE_string等宏声明的。这些就是我前文所述的“参数”。我们先只要知道FLAGS_log_dir的作用就行了。

        05到11行向GLog系统中输出了4中类型的日志,即INFO、WARNING、ERROR和FATAL。GLog框架一共提供了上述四种类型的日志,这些日志将被分别输出到四个文件中。如本例我们将生成如下四个文件:

glog_test.exe.computername.username.log.ERROR.20160510-153013.9704
glog_test.exe.computername.username.log.FATAL.20160510-153013.9704
glog_test.exe.computername.username.log.INFO.20160510-153013.9704
glog_test.exe.computername.username.log.WARNING.20160510-153013.9704

        其中computername是设备的ID,username是登录用户的名称。

        这种设计是非常有意义的。在我们开发过程中,我们可以通过INFO类型的日志进行过程分析。在自测阶段,我们可能更多要关注于是否存在WARNING类型的日志。而上线后,可能会出现一些我们认为不合法或者异常的场景,则这个时候就要关注ERROR和FATAL类型的日志了。这四种日志并不是相互独立的,而是存在包含关系。按照重要性,INFO日志文件将包含INFO、WARNING、ERROR和FATAL日志,因为在开发过程中我们需要关注所有信息。WARNING日志文件将包含WARNING、ERROR和FATAL日志。ERROR日志将包含ERROR和FATAL日志。FATAL日志文件里只有FATAL类型的日志。这种层级关系将非常有助于我们在不同时期关注不同性质的问题。我们看下INFO日志文件的内容

Log file created at: 2016/05/10 15:30:13
Running on machine: COMPUTERNAME
Log line format: [IWEF]mmdd hh:mm:ss.uuuuuu threadid file:line] msg
I0510 15:30:13.564411 11392 glog_test.cpp:11] INFO
I0510 15:30:13.566411 11392 glog_test.cpp:12] INFO1
W0510 15:30:13.566411 11392 glog_test.cpp:13] WARNING
W0510 15:30:13.566411 11392 glog_test.cpp:14] WARNING1
E0510 15:30:13.567411 11392 glog_test.cpp:15] ERROR
E0510 15:30:13.567411 11392 glog_test.cpp:16] ERROR1
F0510 15:30:13.568413 11392 glog_test.cpp:17] FATAL

        眼尖的同学可能发现INFO、ERROR和WARNING日志都有两条,而FATAL日志只有一条。这个地方引出FATAL类型日志的使用场景问题。一般情况下,如果出现程序已经无法执行的场景才使用FATAL日志用于记录临死之前的事情。所以如果我们在项目中发现日志中出现一连串的FATAL日志,往往是对Glog的错误使用。
        Glog的基本使用我们讲完了,我们开始进行源码的讲解。

        我们先看下LOG宏的定义

#define LOG(severity) COMPACT_GOOGLE_LOG_ ## severity.stream()
 
 

        可以见得,LOG宏的等级参数和COMPACT_GOOGLE_LOG_联合在一块组成一个宏。比如INFO则对应于COMPACT_GOOGLE_LOG_INFO;FATAL对应于COMPACT_GOOGLE_LOG_FATAL。这些宏最终都将对应于一个google::LogMessage对象,比如


 
 
  1. #define LOG_TO_STRING_ERROR(message) google::LogMessage( \
  2. __FILE__, __LINE__, google::GLOG_ERROR, message)

        通过对不同日志类型的LogMessage构造对象对比说明,它们是通过LogMessage的第三个参数进行区分的,上例中ERROR日志的等级就是google::GLOG_ERROR。在log_severity.h文件中,定义了GLog四个等级对应的值。


 
 
  1. const int GLOG_INFO = 0, GLOG_WARNING = 1, GLOG_ERROR = 2, GLOG_FATAL = 3,
  2. NUM_SEVERITIES = 4;

        这四个值并非随便定义的。它们这样设计,将有助于完成之前所说的日志包含输出问题。在将日志输出到文件的函数LogDestination::LogToAllLogfiles中


 
 
  1. for ( int i = severity; i >= 0; --i)
  2. LogDestination::MaybeLogToLogfile(i, timestamp, message, len);
  3. }

        这段简单的for循环,让日志输出到自己以及低于自己等级的日志文件中。

        在现实使用中,我们往往会通过一个临时变量或者宏,来区分开发环境和线上环境。比如开发环境我们需要INFO级别的日志,而线上环境我们需要ERROR及其以上等级的日志。当我们测试完毕,准备上线前,我们不可能将输出INFO类型日志的语句删掉——因为可能很多、很分散且删除代码是不安全的。那么我们就需要使用一种手段让INFO和WARNING等级的日志失效。GLog是这么做的


 
 
  1. #if GOOGLE_STRIP_LOG == 0
  2. #define COMPACT_GOOGLE_LOG_INFO google::LogMessage( \
  3. __FILE__, __LINE__)
  4. #define LOG_TO_STRING_INFO(message) google::LogMessage( \
  5. __FILE__, __LINE__, google::GLOG_INFO, message)
  6. #else
  7. #define COMPACT_GOOGLE_LOG_INFO google::NullStream()
  8. #define LOG_TO_STRING_INFO(message) google::NullStream()
  9. #endif
  10. #if GOOGLE_STRIP_LOG <= 1
  11. #define COMPACT_GOOGLE_LOG_WARNING google::LogMessage( \
  12. __FILE__, __LINE__, google::GLOG_WARNING)
  13. #define LOG_TO_STRING_WARNING(message) google::LogMessage( \
  14. __FILE__, __LINE__, google::GLOG_WARNING, message)
  15. #else
  16. #define COMPACT_GOOGLE_LOG_WARNING google::NullStream()
  17. #define LOG_TO_STRING_WARNING(message) google::NullStream()
  18. #endif
  19. #if GOOGLE_STRIP_LOG <= 2
  20. #define COMPACT_GOOGLE_LOG_ERROR google::LogMessage( \
  21. __FILE__, __LINE__, google::GLOG_ERROR)
  22. #define LOG_TO_STRING_ERROR(message) google::LogMessage( \
  23. __FILE__, __LINE__, google::GLOG_ERROR, message)
  24. #else
  25. #define COMPACT_GOOGLE_LOG_ERROR google::NullStream()
  26. #define LOG_TO_STRING_ERROR(message) google::NullStream()
  27. #endif
  28. #if GOOGLE_STRIP_LOG <= 3
  29. #define COMPACT_GOOGLE_LOG_FATAL google::LogMessageFatal( \
  30. __FILE__, __LINE__)
  31. #define LOG_TO_STRING_FATAL(message) google::LogMessage( \
  32. __FILE__, __LINE__, google::GLOG_FATAL, message)
  33. #else
  34. #define COMPACT_GOOGLE_LOG_FATAL google::NullStreamFatal()
  35. #define LOG_TO_STRING_FATAL(message) google::NullStreamFatal()
  36. #endif

        我们可以通过定义GOOGLE_STRIP_LOG的值,来控制相应宏的定义。比如我们给GOOGLE_STRIP_LOG赋值2,则COMPACT_GOOGLE_LOG_WARNING会被定义为google::NullStream(),从而不会进行输出。ERROR和FATAL都将定义为LogMessage的临时对象进行之后的业务逻辑。

        以一个常用的LogMessage的构造函数来看临时对象的初始化


 
 
  1. LogMessage::LogMessage( const char* file, int line, LogSeverity severity)
  2. : allocated_( NULL) {
  3. Init(file, line, severity, &LogMessage::SendToLog);
  4. }

        该函数中传入了执行语句所在的文件名和行号,这些我们都将在输出的日志中见到过。第三个参数是日志等级,即GLOG_INFO等值。Init传入的第四个参数是该类的一个成员函数地址。它才是我们关注的重点。
        LogMessage::SendToLog函数主要执行的是下面这段代码


 
 
  1. // log this message to all log files of severity <= severity_
  2. LogDestination::LogToAllLogfiles(data_->severity_, data_->timestamp_,
  3. data_->message_text_,
  4. data_->num_chars_to_log_);
  5. LogDestination::MaybeLogToStderr(data_->severity_, data_->message_text_,
  6. data_->num_chars_to_log_);
  7. LogDestination::MaybeLogToEmail(data_->severity_, data_->message_text_,
  8. data_->num_chars_to_log_);
  9. LogDestination::LogToSinks(data_->severity_,
  10. data_->fullname_, data_->basename_,
  11. data_->line_, &data_->tm_time_,
  12. data_->message_text_ + data_->num_prefix_chars_,
  13. (data_->num_chars_to_log_
  14. - data_->num_prefix_chars_ - 1));

        这段代码分别是:尝试向文件中输出、尝试向标准错误流中输出、尝试发送邮件、尝试向用户自定义池中输出。

        尝试发送邮件的方式我们很少使用到,它实际是借用了linux系统上/bin/mail程序去发送邮件,所以对这块有兴趣的同学,可以主要关注下邮件内容的组装和发送命令的使用。

        LogToAllLogfiles函数不仅具有向文件输出的能力,还有向标准错误流中输出的能力。


 
 
  1. inline void LogDestination::LogToAllLogfiles(LogSeverity severity,
  2. time_t timestamp,
  3. const char* message,
  4. size_t len) {
  5. if ( FLAGS_logtostderr ) { // global flag: never log to file
  6. ColoredWriteToStderr(severity, message, len);
  7. } else {
  8. for ( int i = severity; i >= 0; --i)
  9. LogDestination::MaybeLogToLogfile(i, timestamp, message, len);
  10. }
  11. }

        else中的for循环就是之前我们讲解的日志包含的实现。

        MaybeLogToLogfile方法将让一个全局的LogDestination对象执行日志输出逻辑


 
 
  1. inline void LogDestination::MaybeLogToLogfile(LogSeverity severity,
  2. time_t timestamp,
  3. const char* message,
  4. size_t len) {
  5. const bool should_flush = severity > FLAGS_logbuflevel;
  6. LogDestination* destination = log_destination(severity);
  7. destination->logger_->Write(should_flush, timestamp, message, len);
  8. }

        log_destination方法将通过日志等级新建并返回或者获取一个保存在全局区域中LogDestination指针


 
 
  1. LogDestination* LogDestination::log_destinations_[NUM_SEVERITIES];
  2. inline LogDestination* LogDestination::log_destination(LogSeverity severity) {
  3. assert(severity >= 0 && severity < NUM_SEVERITIES);
  4. if (!log_destinations_[severity]) {
  5. log_destinations_[severity] = new LogDestination(severity, NULL);
  6. }
  7. return log_destinations_[severity];
  8. }

        如此可见,我们的测试代码将新建四个LogDestination对象,并将指针保存在全局数组log_destinations_中。这四个指针分别对应于INFO、WARNING、ERROR和FATAL日志的输出目标对象。所以,不管在多线程还是在单线程环境中,我们日志输出都将调用到这四个对象指针。
       LogDestination类有个用于实际输出日志的成员变量


 
 
  1. LogFileObject fileobject_;
  2. base::Logger* logger_; // Either &fileobject_, or wrapper around it

        Logger是个接口类,其暴露出来的方法都是纯虚。所以实际操作的是其继承类。LogFileObject继承于Logger接口,对于文件类型的输出,logger_指向fileobject_对象。


 
 
  1. LogDestination::LogDestination(LogSeverity severity,
  2. const char* base_filename)
  3. : fileobject_(severity, base_filename),
  4. logger_(&fileobject_) {
  5. }

        LogFileObject的Write的实现非常简单,除了在文件不存在的情况下新建了日志文件,还有就是日志个格式化输出。它的整个逻辑是在锁内部完成的,这样可以保证多线程写操作是安全的。
        我们分析完LogToAllLogfiles的实现,再探索下它是在何处被调用的。之前我们讲过LOG宏构建了一个LogMessage临时对象。这个临时对象的生命周期就是C++语句中其所在的那一行的执行周期。


 
 
  1. LOG(ERROR) << "ERROR";
  2. LOG(ERROR) << "ERROR1";

        上述两行,就构建了两个LogMessage临时对象。第一行的临时对象在第二行执行之前就消亡了,第二行的临时对象在之后一行执行之前就消亡了。而对LogToAllLogfiles的调用就是在LogMessage的析构函数中(实际是Flush中)。


 
 
  1. LogMessage::~LogMessage() {
  2. Flush();
  3. delete allocated_;
  4. }

        Flush中的核心代码是


 
 
  1. {
  2. MutexLock l(&log_mutex);
  3. ( this->*(data_->send_method_))();
  4. ++num_messages_[ static_cast< int>(data_->severity_)];
  5. }
  6. LogDestination::WaitForSinks(data_);

        注意在调用send_method_所指向的函数(保存为文件时默认的就是LogToAllLogfiles方法)之前上了锁。这个锁非常必要,可以保证之后的操作是受到保护的。否则之前在全局区域保存LogDestination对象指针的逻辑就存在多线程访问的问题。

        我们可以总结下,每条日志的输出都通过一个LogMessage临时对象的析构,传递到全局变量log_destinations_中相应等级对应的LogDestination指针所指向的对象。那么LogDestination对象又是在何时进行日志写入文件的呢?我们知道文件的Write方法并不一定马上把内容写入文件,而是存在一定的缓存中,再根据系统的判断或者用户主动调用fflush,将数据实际写入文件。LogFileObject提供了两个Flush方法


 
 
  1. void LogFileObject::Flush() {
  2. MutexLock l(&lock_);
  3. FlushUnlocked();
  4. }
  5. void LogFileObject::FlushUnlocked(){
  6. if (file_ != NULL) {
  7. fflush(file_);
  8. bytes_since_flush_ = 0;
  9. }
  10. // Figure out when we are due for another flush.
  11. const int64 next = (FLAGS_logbufsecs
  12. * static_cast<int64>( 1000000)); // in usec
  13. next_flush_time_ = CycleClock_Now() + UsecToCycles(next);
  14. }

        一个是通过锁保证多线程安全的版本,一个是不安全的版本。它们都是执行了fflush,并计算了下一次flush时间。在LogFileObject的Write方法末尾,通过该时间的判断,确定是否真的将缓存写入文件中。


 
 
  1. // See important msgs *now*. Also, flush logs at least every 10^6 chars,
  2. // or every "FLAGS_logbufsecs" seconds.
  3. if ( force_flush ||
  4. (bytes_since_flush_ >= 1000000) ||
  5. (CycleClock_Now() >= next_flush_time_) ) {
  6. FlushUnlocked();
  7. #ifdef OS_LINUX
  8. if (FLAGS_drop_log_memory) {
  9. if (file_length_ >= logging::kPageSize) {
  10. // don't evict the most recent page
  11. uint32 len = file_length_ & ~(logging::kPageSize - 1);
  12. posix_fadvise(fileno(file_), 0, len, POSIX_FADV_DONTNEED);
  13. }
  14. }
  15. #endif
  16. }

        还有一种强制写入文件的方法,就是LogDestination::FlushLogFilesUnsafe和LogDestination::FlushLogFiles方法,它们的实现是


 
 
  1. inline void LogDestination::FlushLogFilesUnsafe( int min_severity) {
  2. // assume we have the log_mutex or we simply don't care
  3. // about it
  4. for ( int i = min_severity; i < NUM_SEVERITIES; i++) {
  5. LogDestination* log = log_destination(i);
  6. if ( log != NULL) {
  7. // Flush the base fileobject_ logger directly instead of going
  8. // through any wrappers to reduce chance of deadlock.
  9. log->fileobject_.FlushUnlocked();
  10. }
  11. }
  12. }
  13. inline void LogDestination::FlushLogFiles( int min_severity) {
  14. // Prevent any subtle race conditions by wrapping a mutex lock around
  15. // all this stuff.
  16. MutexLock l(&log_mutex);
  17. for ( int i = min_severity; i < NUM_SEVERITIES; i++) {
  18. LogDestination* log = log_destination(i);
  19. if ( log != NULL) {
  20. log->logger_->Flush();
  21. }
  22. }
  23. }

        使用者可以通过这两个函数人为主动的刷新缓冲区,让内容落地。
        以上,我们讲解了GLog主要使用方法及其实现原理。实际GLog作为一个框架,也不失灵活性。

        比如我们可以通过SetLogger方法修改不同等级的日志输出方法。


 
 
  1. base::Logger* base::GetLogger(LogSeverity severity) {
  2. MutexLock l(&log_mutex);
  3. return LogDestination::log_destination(severity)->logger_;
  4. }
  5. void base::SetLogger(LogSeverity severity, base::Logger* logger) {
  6. MutexLock l(&log_mutex);
  7. LogDestination::log_destination(severity)->logger_ = logger;
  8. }

        下面代码是其使用的一个实例


 
 
  1. struct MyLogger : public base::Logger {
  2. string data;
  3. virtual void Write(bool /* should_flush */,
  4. time_t /* timestamp */,
  5. const char* message,
  6. int length) {
  7. data.append(message, length);
  8. }
  9. virtual void Flush() { }
  10. virtual uint32 LogSize() { return data.length(); }
  11. };
  12. static void TestWrapper() {
  13. fprintf( stderr, "==== Test log wrapper\n");
  14. MyLogger my_logger;
  15. base::Logger* old_logger = base::GetLogger(GLOG_INFO);
  16. base::SetLogger(GLOG_INFO, &my_logger);
  17. LOG(INFO) << "Send to wrapped logger";
  18. FlushLogFiles(GLOG_INFO);
  19. base::SetLogger(GLOG_INFO, old_logger);
  20. CHECK( strstr(my_logger.data.c_str(), "Send to wrapped logger") != NULL);
  21. }

        我们还可以使用AddLogSink和RemoveLogSink方法自定义处理日志的逻辑。AddLogSink最终会调用到


 
 
  1. inline void LogDestination::AddLogSink(LogSink *destination) {
  2. // Prevent any subtle race conditions by wrapping a mutex lock around
  3. // all this stuff.
  4. MutexLock l(&sink_mutex_);
  5. if (!sinks_) sinks_ = new vector<LogSink*>;
  6. sinks_->push_back(destination);
  7. }

        其中sinks_是全局vector指针。它是独立于之前介绍的log_destinations_数组管理的日志输出方式。


 
 
  1. // arbitrary global logging destinations.
  2. static vector<LogSink*>* sinks_;

        最终它会在SendToLog方法中的LogToSinks中被调用。


 
 
  1. inline void LogDestination::LogToSinks(LogSeverity severity,
  2. const char *full_filename,
  3. const char *base_filename,
  4. int line,
  5. const struct ::tm* tm_time,
  6. const char* message,
  7. size_t message_len) {
  8. ReaderMutexLock l(&sink_mutex_);
  9. if (sinks_) {
  10. for ( int i = sinks_->size() - 1; i >= 0; i--) {
  11. (*sinks_)[i]->send(severity, full_filename, base_filename,
  12. line, tm_time, message, message_len);
  13. }
  14. }
  15. }

        作为使用者,我们需要定义send方法,并根据日志等级的不同,处理不同的逻辑。下面是GLog测试代码中的一个例子


 
 
  1. virtual void send(LogSeverity severity, const char* /* full_filename */,
  2. const char* base_filename, int line,
  3. const struct tm* tm_time,
  4. const char* message, size_t message_len) {
  5. // Push it to Writer thread if we are the original logging thread.
  6. // Note: Something like ThreadLocalLogSink is a better choice
  7. // to do thread-specific LogSink logic for real.
  8. if (pthread_equal(tid_, pthread_self())) {
  9. writer_.Buffer(ToString(severity, base_filename, line,
  10. tm_time, message, message_len));
  11. }
  12. }

        最后介绍LOG_TO_SINK和LOG_TO_SINK_BUT_NOT_TO_LOGFILE宏,它是设置单个Sink的方式(AddLogSink是设置一个到多个sink的方式)


 
 
  1. #define LOG_TO_SINK(sink, severity) \
  2. google::LogMessage( \
  3. __FILE__, __LINE__, \
  4. google::GLOG_ ## severity, \
  5. static_cast<google::LogSink*>(sink), true).stream()
  6. #define LOG_TO_SINK_BUT_NOT_TO_LOGFILE(sink, severity) \
  7. google::LogMessage( \
  8. __FILE__, __LINE__, \
  9. google::GLOG_ ## severity, \
  10. static_cast<google::LogSink*>(sink), false).stream()

        它调用的是5个参数版本的LogMessage构造函数


 
 
  1. LogMessage::LogMessage( const char* file, int line, LogSeverity severity,
  2. LogSink* sink, bool also_send_to_log)
  3. : allocated_( NULL) {
  4. Init(file, line, severity, also_send_to_log ? &LogMessage::SendToSinkAndLog :
  5. &LogMessage::SendToSink);
  6. data_->sink_ = sink; // override Init()'s setting to NULL
  7. }

        构造函数中true或false标识是否需要调用之前分析过的SendToLog()方法,还是只是调用SendToSink方法


 
 
  1. void LogMessage::SendToSink() EXCLUSIVE_LOCKS_REQUIRED(log_mutex) {
  2. if (data_->sink_ != NULL) {
  3. RAW_DCHECK(data_->num_chars_to_log_ > 0 &&
  4. data_->message_text_[data_->num_chars_to_log_ -1] == '\n', "");
  5. data_->sink_->send(data_->severity_, data_->fullname_, data_->basename_,
  6. data_->line_, &data_->tm_time_,
  7. data_->message_text_ + data_->num_prefix_chars_,
  8. (data_->num_chars_to_log_ -
  9. data_->num_prefix_chars_ - 1));
  10. }
  11. }

        至此,我们便将Glog的实现原理分析完毕了。

        在阅读代码和实验其使用过程中,可以发现GLog是一个非常优秀的日志开源库。但是如果想让GLog灵活的应用于产品中,其实还有很多事情可以做。比如我们可以将参数放到配置文件中,这样不至于我们每次修改参数时要重新编译代码。再比如我们可以定制自己的Sink,将日志数据发送到指定机器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值