Review(7)

17 flume如何抽取数据  记录pos点?用哪一个source?   tailDir目录能支持递归吗

    flume中有三种可监控文件或目录的source、分别是Exec Source、Spooling Directory Source和Taildir Source。

   Taildir Source是1.7版本的新特性,综合了Spooling Directory Source和Exec Source的优点。

   使用场景:

     Exec Source
  Exec Source可通过tail -f命令去tail住一个文件,然后实时同步日志到sink。但存在的问题是,当agent进程挂掉重启后,会有重复消费的问题。可以通过增加UUID来解决,或通过改进ExecSource来解决。
    Spooling Directory Source
  Spooling Directory Source可监听一个目录,同步目录中的新文件到sink,被同步完的文件可被立即删除或被打上标记。适合用于同步新文件,但不适合对实时追加日志的文件进行监听并同步。如果需要实时监听追加内容的文件,可对SpoolDirectorySource进行改进。
     Taildir Source
  Taildir Source可实时监控一批文件,并记录每个文件最新消费位置,agent进程重启后不会有重复消费的问题。
使用时建议用1.8.0版本的flume,1.8.0版本中解决了Taildir Source一个可能会丢数据的bug。

   ######## source相关配置 ########
 # source类型
     agent.sources.s1.type = TAILDIR
# 元数据位置
     agent.sources.s1.positionFile = /Users/wangpei/tempData/flume/taildir_position.json
# 监控的目录
     agent.sources.s1.filegroups = f1
     agent.sources.s1.filegroups.f1=/Users/wangpei/tempData/flume/data/.*log
     agent.sources.s1.fileHeader = true

记录每个文件消费位置的元数据
#配置
agent.sources.s1.positionFile = /Users/wangpei/tempData/flume/taildir_position.json
#内容
[
  {
"inode":6028358,
"pos":144,
"file":"/Users/wangpei/tempData/flume/data/test.log"
  },
  {
"inode":6028612,
"pos":20,
"file":"/Users/wangpei/tempData/flume/data/test_a.log"
  }
]

可以看到,在taildir_position.json文件中,通过json数组的方式,记录了每个文件最新的消费位置,每消费一次便去更新这个文件。

 官方版本不支持,可以定制开发

/**
 * hzy
 * 带递归
 * @return
 */
 private List<File> getMatchingFilesNoCache2() {
 List<File> result = Lists.newArrayList();
  
 List<Path> paths = recurseFolder(parentDir);
 for(Path path:paths){
 try (DirectoryStream<Path> stream = Files.newDirectoryStream(path, fileFilter)) {
 for (Path entry : stream) {
 result.add(entry.toFile());
 }
 } catch (IOException e) {
 e.printStackTrace();
 }
 }
  
 String matchedFileNames = result.stream().map(r-> r.getAbsolutePath()).collect(Collectors.joining("\n"));
 logger.debug("============================matched files=======================================");
 logger.debug(matchedFileNames);
 logger.debug("=============================matched files======================================");
 return result;
 }
  
 /**
 * hzy
 * @param root
 * @return
 */
 public List<Path> recurseFolder(File root) {
 List<Path> allParentFolders = new ArrayList<>();
 allParentFolders.add(root.toPath());
  
 if (root.exists()) {
 File[] files = root.listFiles();
 if (null == files || files.length == 0) {
 return allParentFolders;
 } else {
 for (File subFile : files) {
 if (subFile.isDirectory()) {
 allParentFolders.addAll(recurseFolder(subFile));
 }
 }
 }
 }
 return allParentFolders;
 }

18 flume源代码  有没有做过二次开发

其中flume-ng-core存放了最核心部分的代码,包含基础的Source、Channel、Sink等;flume-ng-node则是存放了程序启动的代码(入口函数)。

其他可能会用到的模块就是flume-ng-sources、flume-ng-channels、flume-ng-sinks,这3个模块存放了非必须的flume组件(flume-ng-core中未包含的),里面有些组件也是很常用的。

Flume有三大组件:Source、Channel、Sink。

Source就是数据来源,例如Web Server产生日志后,可使用ExecSource执行tail -F命令后不断监听日志文件新生成的数据,然后传给Channel。
Channel就是一个缓存队列,由于读取数据和写入数据的速度可能不匹配,假如用同步完成的方式可能效率低下,所以Source把数据写到Channel这个队列里面,Sink再用另外的线程去读取。
Sink就是最终的存储,例如可以是HDFS或LOG文件输出等,Sink负责去Channel里面读取数据,并存储。
在程序启动时,会启动所有的SourceRunner、Channel、SinkRunner。其中Channel的启动,没做什么特别的事情,就是初始化一下状态、创建一下计数器,算做一个被动的角色。比较重要的是SourceRunner和SinkRunner。

SourceRunner会调用Source的start方法。以ExecSource为例,其start方法就是启动一个线程,去不断获取标准输出流写入一个列表(eventList),同时再启动一个线程去定期批量地把列表中的数据往Channel发,如下图所示。
SinkRunner则是不断循环调用SinkProcess的process的方法,SinkProcess有几种类型,用于决定选择哪个Sink进行存储(Sink可以有多个),选择了Sink后,调用其process方法。Sink的process方法,主要做的就是去Channel中读取数据,并写入对应的存储,如下图所示。

 程序入口
启动Flume的过程可以简单分为2个步骤: 
1. 获取相关配置文件(一般来说就是flume-conf.properties)。 
2. 启动各组件。不特别说明,本文中的组件是指实现了LifecycleAware接口的类的对象,一般就是Source、Channel、Sink这3种对象。

启动Flume的Main函数在flume-ng-node模块的org.apache.flume.node.Application。该函数的功能可以简单划分为以下三个步骤: 
1. 使用commons.cli类获取命令行参数(就是启动时传入的参数) 
2. 根据启动参数确定的读取配置的方式。读取配置的方式总共有4种,分别根据配置是保存在zookeeper上还是本地properties文件、以及是否reload(自动重载配置文件)分为4种方式。 
3. 根据相应的配置启动程序,并注册关闭钩子。 
接下来以properties文件、不重载的方式为例,主要的代码如下:

PropertiesFileConfigurationProvider configurationProvider =
    new PropertiesFileConfigurationProvider(agentName, configurationFile);
//创建Application对象,包含初始化组件列表(components),初始化LifecycleSupervisor。
application = new Application();
application.handleConfigurationEvent(configurationProvider.getConfiguration());
//start方法用于检查所有组件是否是启动状态,如果不是则启动该组件。
application.start();
//监听程序关闭事件,用于当程序被kill后能够执行一些清理工作。
final Application appReference = application;
Runtime.getRuntime().addShutdownHook(new Thread("agent-shutdown-hook") {
  public void run() {
    appReference.stop();
  }
});

上面的代码,有两处比较关键:

configurationProvider.getConfiguration()会返回一个MaterializedConfiguration类型的对象,用于从文件形式的配置转为物化的配置,即包含实际的channel、sinkRunner等对象的实例,在“物化配置”一节分析。
handleConfigurationEvent用于停止所有components,并使用新的配置进行启动,在“使用新配置重启”一节分析。
--------------------- 

 物化配置
configurationProvider.getConfiguration()方法主要做了以下两件事: 
1. 读取配置文件(flume-conf.properties),保存在AgentConfiguration对象中。

public static class AgentConfiguration {
  private final String agentName;
  private String sources;
  private String sinks;
  private String channels;
  private String sinkgroups;
  private final Map<String, ComponentConfiguration> sourceConfigMap;
  private final Map<String, ComponentConfiguration> sinkConfigMap;
  private final Map<String, ComponentConfiguration> channelConfigMap;
  private final Map<String, ComponentConfiguration> sinkgroupConfigMap;
  private Map<String, Context> sourceContextMap;
  private Map<String, Context> sinkContextMap;
  private Map<String, Context> channelContextMap;
  private Map<String, Context> sinkGroupContextMap;
  private Set<String> sinkSet;
  private Set<String> sourceSet;
  private Set<String> channelSet;
  private Set<String> sinkgroupSet;
}

到这个步骤还仅仅是做好了分类的文本形式的配置项。 
2. 创建出配置中的各组件实例,并添加到MaterializedConfiguration实例中。

public interface MaterializedConfiguration {
  public void addSourceRunner(String name, SourceRunner sourceRunner);
  public void addSinkRunner(String name, SinkRunner sinkRunner);
  public void addChannel(String name, Channel channel);
  public ImmutableMap<String, SourceRunner> getSourceRunners();
  public ImmutableMap<String, SinkRunner> getSinkRunners();
  public ImmutableMap<String, Channel> getChannels();
}
--------------------- 

启动所有组件
4.2.1 使用新配置重启
有了上面的MaterializedConfiguration实例,我们就可以启动组件了。 
在handleConfigurationEvent方法中,首先会停止所有组件,然后再启动所有组件。

stopAllComponents();
startAllComponents(conf); //这里的conf就是上节的MaterializedConfiguration。
1
2
在startAllComponents方法中,会遍历组件列表(SourceRunners、SinkRunners、Channels),分别调用supervise方法。以Channel为例:

for (Entry<String, Channel> entry :
    materializedConfiguration.getChannels().entrySet()) {
  try {
    logger.info("Starting Channel " + entry.getKey());
    supervisor.supervise(entry.getValue(),
        new SupervisorPolicy.AlwaysRestartPolicy(), LifecycleState.START);
  } catch (Exception e) {
    logger.error("Error while starting {}", entry.getValue(), e);
  }
}
--------------------- 

LifecycleSupervisor
上节的supervisor是一个LifecycleSupervisor对象。前面有说到,在创建Application的时候初始化了一个LifecycleSupervisor对象,就是这里的supervisor。这个对象,我理解为各组件生命周期的管理者,用于实时监控所有组件的状态,如果不是期望的状态(desiredState),则进行状态转换。

上节的代码中调用了supervisor.supervise方法,接下来分析一下supervise这个方法:

public synchronized void supervise(LifecycleAware lifecycleAware,
    SupervisorPolicy policy, LifecycleState desiredState) {
  //省略状态检查的代码
Supervisoree process = new Supervisoree();
  process.status = new Status();
  process.policy = policy;
  process.status.desiredState = desiredState;
  process.status.error = false;
  MonitorRunnable monitorRunnable = new MonitorRunnable();
  monitorRunnable.lifecycleAware = lifecycleAware;
  monitorRunnable.supervisoree = process;
  monitorRunnable.monitorService = monitorService;
  supervisedProcesses.put(lifecycleAware, process);
  ScheduledFuture<?> future = monitorService.scheduleWithFixedDelay(
      monitorRunnable, 0, 3, TimeUnit.SECONDS);
  monitorFutures.put(lifecycleAware, future);
}

由于所有的组件都实现了LifecycleAware接口,所以这里的supervise方法传入的是LifecycleAware接口的对象。

可以看到创建了一个Supervisoree对象,顾名思义,就是被监控的的对象,该对象有以下几种状态:IDLE, START, STOP, ERROR。 
scheduleWithFixedDelay每隔3秒触发一次监控任务(monitorRunnable)
--------------------- 

MonitorRunnable
在MonitorRunnable中主要是检查组件的状态,并实现从lifecycleState到desiredState的转变。

switch (supervisoree.status.desiredState) {
  case START:
    try {
      lifecycleAware.start();
    } catch (Throwable e) {省略}
    break;
  case STOP:
    try {
      lifecycleAware.stop();
    } catch (Throwable e) {省略}
    break;
  default:
    logger.warn("I refuse to acknowledge {} as a desired state", supervisoree.status.desiredState);
}

到这里为止,可以看到监控的进程,调用了组件自己的start和stop方法来启动、停止。前面有提到有3种类型的组件,SourceRunner、Channel、SinkRunner,而Channel的start只做了初始化计数器,没什么实质内容,所以接下来从SourceRunner的启动(从Source写数据到Channel)和SinkRunner的启动(从Channel获取数据写入Sink)来展开说明。
--------------------- 

从Source写数据到Channel
5.1 Source部分
5.1.1 SourceRunner
SourceRunner就是专门用于运行Source的一个类。 
在”物化配置”一节获取配置信息后,会根据Source去获取具体的SourceRunner,调用的是SourceRunner的forSource方法。

public static SourceRunner forSource(Source source) {
  SourceRunner runner = null;
  if (source instanceof PollableSource) {
    runner = new PollableSourceRunner();
    ((PollableSourceRunner) runner).setSource((PollableSource) source);
  } else if (source instanceof EventDrivenSource) {
    runner = new EventDrivenSourceRunner();
    ((EventDrivenSourceRunner) runner).setSource((EventDrivenSource) source);
  } else {
    throw new IllegalArgumentException("No known runner type for source " + source);
  }
  return runner;
}
--------------------- 

可以看到source分为了2种类型,并有对应的sourceRunner(PollableSourceRunner、EventDrivenSourceRunner)。这2种source区别在于是否需要外部的驱动去获取数据,不需要外部驱动(采用自身的事件驱动机制)的称为EventDrivenSource,需要外部驱动的称为PollableSource。

常见的EventDrivenSource:AvroSource、ExecSource、SpoolDirectorySource。
常见的PollableSource:TaildirSource、kafkaSource、JMSSource。
以EventDrivenSourceRunner为例,由MonitorRunnable调用其start方法:

public void start() {
  Source source = getSource();
  ChannelProcessor cp = source.getChannelProcessor();
  cp.initialize();//用于初始化Interceptor
  source.start();
  lifecycleState = LifecycleState.START;
}
这里的ChannelProcessor是比较重要的一个类,后面会具体说。接下来调用了Source的start方法。可以对照一下之前的整体架构的图,start方法实现的就是这个部分:

 

5.1.2 ExecSource
以ExecSource的start方法为例:

public void start() {
  executor = Executors.newSingleThreadExecutor();
  runner = new ExecRunnable(shell, command, getChannelProcessor(), sourceCounter, restart, restartThrottle, logStderr, bufferCount, batchTimeout, charset);
  runnerFuture = executor.submit(runner);
  sourceCounter.start();
  super.start();
}

主要启动了一个线程runner,初始化了一下计数器。具体实现还是要看ExecRunable类的run方法:

public void run() {
  do {
    timedFlushService = Executors.newSingleThreadScheduledExecutor(…);
//使用配置的参数启动Shell命令
    String[] commandArgs = command.split("\\s+");
    process = new ProcessBuilder(commandArgs).start();
//设置标准输入流
    reader = new BufferedReader(new InputStreamReader(process.getInputStream()…));
    //设置错误流
StderrReader stderrReader = new StderrReader(…);
    stderrReader.start();
//启动定时任务,将eventList中数据批量写入到Channel
    future = timedFlushService.scheduleWithFixedDelay(new Runnable() {
        public void run() {
          synchronized (eventList) {
            if (!eventList.isEmpty() && timeout()) {flushEventBatch(eventList);}
          }
        }
    },batchTimeout, batchTimeout, TimeUnit.MILLISECONDS);
//按行读取标准输出流的内容,并写入eventList
    while ((line = reader.readLine()) != null) {
      synchronized (eventList) {
        sourceCounter.incrementEventReceivedCount();
        eventList.add(EventBuilder.withBody(line.getBytes(charset)))
//超出配置的大小或者超时后,将eventList写到Channel
        if (eventList.size() >= bufferCount || timeout()) {flushEventBatch(eventList);}
}
}
    synchronized (eventList) {if (!eventList.isEmpty()){flushEventBatch(eventList);}}
  } while (restart);//如果配置了自动重启,当Shell命令的进程结束时,自动重启命令。
}

在该方法中启动了2个reader,分别取读取标准输入流和错误流,将标准输入流中的内容写入eventList。

与此同时启动另外一个线程,调用flushEventBatch方法,定期将eventList中的数据写入到Channel。

private void flushEventBatch(List<Event> eventList) {
  channelProcessor.processEventBatch(eventList);//假如这里异常的话,eventList还没有清空
  sourceCounter.addToEventAcceptedCount(eventList.size());
  eventList.clear();
  lastPushToChannel = systemClock.currentTimeMillis();
}

可以看到这里调用了channelProcessor.processEventBatch()来写入Channel。

5.2 Channel部分
5.2.1 ChannelProcessor
ChannelProcessor的作用是执行所有interceptor,并将eventList中的数据,发送到各个reqChannel、optChannel。ReqChannel和optChannel是通过channelSelector来获取的。

public interface ChannelSelector extends NamedComponent, Configurable {
  public void setChannels(List<Channel> channels);
  public List<Channel> getRequiredChannels(Event event);
  public List<Channel> getOptionalChannels(Event event);
  public List<Channel> getAllChannels();//获取在当前Source中配置的全部Channel
}

如果要自定义一个ChannelSelector,只需要继承AbstractChannelSelector后,实现getRequiredChannels和getOptionalChannels即可。

ReqChannel代表一定保证存储的Channel(失败会不断重试),optChannel代表可能存储的Channel(即失败后不重试)。

ReqChannel与optChannel的区别从代码上来看,前者在出现异常时,会在执行完回滚后往上层抛,而optChannel则只执行回滚。注意到回滚操作只清空putList(5.2.4节会说明),而这一层如果没有抛出异常的话,调用方(也就是上节的flushEventBatch)会清空eventList,也就是异常之后的数据丢失了。

发送其中一条数据的代码如下:

try {
  tx.begin();
  reqChannel.put(event);
  tx.commit();
} catch (Throwable t) {
  tx.rollback();
    //省略部分代码
}

其中put调用Channel的doPut方法,commit调用Channel的doCommit方法。 
Channel主要包含4个主要方法:doPut、doTake、doCommit、doRollback。下面以MemoryChannel为例说明。
--------------------- 

5.2.2 doPut方法
在这个方法中,只包含了递增计数器和将事件添加到putList。

protected void doPut(Event event) throws InterruptedException {
  channelCounter.incrementEventPutAttemptCount();
  int eventByteSize = (int) Math.ceil(estimateEventSize(event) / byteCapacitySlotSize);
  if (!putList.offer(event)) {
    throw new ChannelException("");
  }
  putByteCounter += eventByteSize;
}
1
2
3
4
5
6
7
8
假如这个方法中出现了异常,则会抛到ChannelProcessor中执行回滚操作。

5.2.3 doCommit方法
这个方法是比较复杂的方法之一,原因在于put和take操作的commit都是通过这个方法来进行的,所以代码里面其实混合了2个功能(即put和take操作)所需的提交代码。

单纯从Source写数据到Channel这件事情,流程为eventList->putList->queue。

由于前面已经完成了把数据放到putList中,那接下来要做的事情就是将putList中数据放入queue中就可以了。这个部分先说明到这里,下一个章节结合take操作一起看这个方法。

5.2.4 doRollback方法
与doCommit方法类似,这里的回滚,也分为2种情况:由take操作引起的和由put方法引起的。

这里先说由put发起的,该transaction的流程如下: 
eventList->putList->queue

由于doPut和doCommit执行出现异常就直接跳出了,还没执行清空语句(这里可以参考“ExecSource“章节的最后一段代码的注释部分),也就是eventList还没有清空,所以可以直接清空putList,这样下次循环还会重新读取该eventList中的数据。

附注:在put操作commit的时候,如果部分数据已经放进queue的话,这个时候回滚,那是否存在数据重复问题呢?根据代码,由于在放队列这个操作之前已经做过很多判断(容量等等),这个操作只是取出放进队列的操作,而这个代码之后,也只是一些设置计数器的操作,理论上不会出现异常导致回滚了
--------------------- 

从Channel获取数据写入Sink
6.1 Sink部分
Sink部分主要分为以下3个步骤: 
1. 由SinkRunner不断调用SinkProcessor的process方法。 
2. 根据配置的SinkProcessor的不同,会使用不同的策略来选择sink。SinkProcessor有3种,默认是DefaultSinkProcessor。 
3. 调用选择的sink的process方法。

6.1.1 Sink的Process方法
以LoggerSink为例进行说明。这个方法来自Sink接口,主要用于取出数据进行处理,如果失败则回滚(takeList中内容退回quene):

public Status process() throws EventDeliveryException {
  Status result = Status.READY;
  Channel channel = getChannel();
  Transaction transaction = channel.getTransaction();
  Event event = null;
  try {
    transaction.begin();
    event = channel.take();//从channel中获取一条数据
    if (event != null) {
      if (logger.isInfoEnabled()) {
        logger.info("Event: " + EventHelper.dumpEvent(event, maxBytesToLog));
//输出event到日志
      }
    } else {
      result = Status.BACKOFF;
    }
    transaction.commit();//执行提交操作
  } catch (Exception ex) {
    transaction.rollback();//执行回滚操作
    throw new EventDeliveryException("Failed to log event: " + event, ex);
  } finally {
    transaction.close();
  }
  return result;
}

6.2 Channel部分
6.2.1 doTake方法
这个方法中主要是从queue中取出事件,放到takeList中。

protected Event doTake() throws InterruptedException {
  channelCounter.incrementEventTakeAttemptCount();
  //获取take列表容量的许可,如果没有则报异常。
  if (takeList.remainingCapacity() == 0) {
    throw new ChannelException("");
  }
//尝试获取queue数量的许可,如果没有则代表没有数据可以取,直接返回。
  if (!queueStored.tryAcquire(keepAlive, TimeUnit.SECONDS)) {
    return null;
  }
  Event event;
  synchronized (queueLock) {
    event = queue.poll();//从queue中取出一条数据
  }
  Preconditions.checkNotNull(event, "");
  takeList.put(event);//放到takeList中
  int eventByteSize = (int) Math.ceil(estimateEventSize(event) / byteCapacitySlotSize);
  takeByteCounter += eventByteSize;//设置计数器
  return event;
}

6.2.2 doCommit方法
前面说到put和take操作的提交都是通过这个方法来提交的。

这个步骤要做的事情有: 
1. putList放入queue,完成后就代表eventList->putList->queue这个步骤完成。 
2. 假如doTake过程没报错(能进到这个方法说明没报错),说明sink那边已经获取到了全部的event,这时可直接清空takeList,代表queuetakeList & sink这个步骤完成。

综上,两个事情合并在一起的话,要做的就是,把putList放入queue再清空takeList。

protected void doCommit() throws InterruptedException {
    int remainingChange = takeList.size() - putList.size();
    if (remainingChange < 0) {
        if (!bytesRemaining.tryAcquire(putByteCounter, keepAlive, TimeUnit.SECONDS)) {
            throw new ChannelException("");
        }
        if (!queueRemaining.tryAcquire(-remainingChange, keepAlive, TimeUnit.SECONDS)) {
            bytesRemaining.release(putByteCounter);
            throw new ChannelFullException("");
        }
    }
    int puts = putList.size();
    int takes = takeList.size();
    synchronized (queueLock) {
        if (puts > 0) {
            while (!putList.isEmpty()) {
                if (!queue.offer(putList.removeFirst())) {
                    throw new RuntimeException("");
                }
            }
        }
        putList.clear();
        takeList.clear();
    }
    //后面是重新设置相关计数器
}

这个方法一开始去比较takeList和putList的容量差,是为了简化申请许可的过程。正常的流程是清空takeList,释放takeList.size个许可,再申请putList.size个许可,它是两个步骤合并起来的。

6.2.3 doRollback方法
与doCommit方法类似,这里的回滚,也分为2种情况: 
- 由take操作引起的 
该transaction的流程如下:queue->takeList & sink,所以回滚操作要做的事情就是:把takeList放回queue。 
- 由put操作引起的 
该transaction的流程如下:eventList->putList->queue,由于doPut和doCommit执行出现异常就直接跳出了,还没执行清空语句,也就是eventList还没有清空,所以可以直接清空putList,这样下次循环还会重新读取该eventList中的数据。

综上,两种操作要合为一个方法的话,就把takeList放回queue,然后清理putList就可以了。代码如下:

protected void doRollback() {
    int takes = takeList.size();
    synchronized (queueLock) {
        Preconditions.checkState(queue.remainingCapacity() >= takeList.size(),"");
        while (!takeList.isEmpty()) {
            queue.addFirst(takeList.removeLast());
        }
        putList.clear();
    }
    //后面是重新设置相关计数器
}

附注:从目前的代码看,在take操作的时候,应该已经获取到了部分数据,如果这个时候异常了,把takeList返回queue的话,会导致重复数据。
 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值