02-Kafka生产者

Kafka生产者

  • Kafka客户端支持多种语言,Java客户端是Kafka社区维护的,其他语言由社区提供。

一、客户端开发

1.1 代码

public class MyKafkaProducer {

    public static final String BROKER_LIST = "192.168.13.53:9092";
    public static final String TOPIC = "testTopic-1";
    public static final String CLIENT_ID = "producer-mzp-1";

    public static Properties initConfig() {
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, BROKER_LIST);
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ProducerConfig.CLIENT_ID_CONFIG, CLIENT_ID);
        properties.put(ProducerConfig.RETRIES_CONFIG, 10);
        return properties;
    }

    public static void main(String[] args) {
        sendAndForget();
        //sendSync();
        //sendAync();

    }

    private static void sendAndForget() {
        Properties prop = initConfig();
        KafkaProducer<String, String> producer = new KafkaProducer<>(prop);
        //ProducerRecord<String, String> record = new ProducerRecord<>(TOPIC, "Hello world , Kafka !" + new Date());
        RecordHeaders headers = new RecordHeaders();
        RecordHeader recordHeader = new RecordHeader("headKey", "headValue".getBytes());
        headers.add(recordHeader);
        ProducerRecord<String, String> record =
                new ProducerRecord<>(TOPIC, 0, System.currentTimeMillis(), null, "Hello world , Kafka !", headers);
        try {
            producer.send(record);
            System.out.println("发送完毕...");
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        producer.flush();
        System.out.println("End ... ");
    }

1.2 参数配置

参数含义备注
bootstrap.serversKafka的Broker地址可以设置多个,ip1:port1,ip2:port2
key.serializerkey的序列化器,将key序列化为字节数组默认为null
value.serializervalue的序列化器,将value序列化为字节数组默认为null
client.id指定客户端Id默认类似于Producer-1的命名方式

1.3 发送消息

1.3.1 消息结构
  • Java中Kafka消息的数据结构对应的类是ProducerRecord,其中除了消息体之外还有部分信息,如下:
public class ProducerRecord<K, V> {

    //所属toipc
    private final String topic;
    //所属分区
    private final Integer partition;
    //头部信息
    private final Headers headers;
    //消息的key
    private final K key;
    //消息内容
    private final V value;
    //时间戳
    private final Long timestamp;
  • 如下是前面的示例代码的消息对象,ProducerRecord包含很多不同的构造方法,最简单的是下面的形式。
new ProducerRecord<>(TOPIC, "Hello world , Kafka !");

PS:Kafka消息的key用的不多,在指定了partion或者key的时候,消息会被发往指定的分区(同一个key被发往同一分区的前提是分区没有变化)
  另外也可以根据需求场景,在header中添加自定义的信息
1.3.2 发送方式
  • Kafka客户端支持3种不同的发送模式,分别是:发后即忘,同步和异步。

  • 发后即忘:

该方式使用producer.send(record)之后,不管响应消息了,消息可能丢失,但是性能最高。
  • 同步:
该方式使用RecordMetadata recordMetadata = producer.send(record).get();发送之后同步获取服务端的响应,默认打印的是:topic-partion@offset,
如下 topic + "-" + partition + "@" + offset

另外同步也可以使用Future的方式,形式稍有不同,本质是一样的,这种方式可以实现一些超时获取,比前面一种较为灵活:
Future<RecordMetadata> future = producer.send(record);= producer.send(record);
RecordMetadata recordMetadata = future.get();
System.out.println("发送完毕..." + recordMetadata);
  • 异步:
//同步的方式会等待服务端的响应,但是异步的方式会在成功之后调用一个callback方法,方法会收到异常对象和RecordMetadata,回调函数可以保证分区的有序性,
producer.send(record, new Callback() {
                @Override
                public void onCompletion(RecordMetadata metadata, Exception exception) {
                    if(exception!=null){
                        System.out.println("发送消息异常...");
                        exception.printStackTrace();
                    }
                    System.out.println("发送成功后回调," + metadata);
                }
            });
  • 重试
//生产消息时可能发生异常,有些异常可以恢复,比如leader选举,有些异常不能恢复,因此可以使用重试机制来恢复部分可以恢复的异常。如果重试指定次数之后还不能恢复异常,那么则会抛出异常。
public static final String RETRIES_CONFIG = "retries";
properties.put(ProducerConfig.RETRIES_CONFIG, 10);
PS: 
> 发送消息之后得到的数据结构RecordMetadata内部包含很多数据的元信息,比如消息分区,offset,时间戳等,在必要的场景可以使用这些元信息。
> Future的方式理论上也可以实现异步,但是编码和实现上不如Callback灵活。

1.4 序列化

1.4.1 序列化器
  • 序列化器的作用是将消息转换为字节数组,在网络传输和Kafka服务端都是以字节数组保存。常用的是StringSerializer,另外还有ByteBufferSerializer等其他序列化器,他们都实现了Serializer接口。
public interface Serializer<T> extends Closeable {

    void configure(Map<String, ?> configs, boolean isKey);

    byte[] serialize(String topic, T data);
 
    void close();
}
  • 如下是StringSerializer的实现
public class StringSerializer implements Serializer<String> {
    private String encoding = "UTF8";

    //configure方法在创建KafkaProducer的时候会被调用,encoding没有传进来默认是UTF8
    //configs参数就是new KafkaProducer<>(properties);传进来的配置对象
    @Override
    public void configure(Map<String, ?> configs, boolean isKey) {
        String propertyName = isKey ? "key.serializer.encoding" : "value.serializer.encoding";
        Object encodingValue = configs.get(propertyName);
        if (encodingValue == null)
            encodingValue = configs.get("serializer.encoding");
        if (encodingValue != null && encodingValue instanceof String)
            encoding = (String) encodingValue;
    }

    //将String转换为字节数组
    @Override
    public byte[] serialize(String topic, String data) {
        try {
            if (data == null)
                return null;
            else
                return data.getBytes(encoding);
        } catch (UnsupportedEncodingException e) {
            throw new SerializationException("Error when serializing string to byte[] due to unsupported encoding " + encoding);
        }
    }

    //关闭序列化器
    @Override
    public void close() {
        // nothing to do
    }
}
1.4.2 自定义序列化器
  • 自定义序列化器
public class ItemSerilializer implements Serializer<Item> {
    @Override
    public void configure(Map configs, boolean isKey) {

    }

    @Override
    public byte[] serialize(String topic, Item data) {
        if (data == null) {
            return null;
        }
        byte[] name, detail;
        try {


            if (data.getName() != null) {
                name = data.getName().getBytes();
            } else {
                name = new byte[0];
            }

            if (data.getDetail() != null) {
                detail = data.getDetail().getBytes();
            } else {
                detail = new byte[0];
            }
            //1.分配内存
            ByteBuffer byteBuffer = ByteBuffer.allocate(4 + 4 + name.length + detail.length);
            //2.设置写入字节数
            byteBuffer.putInt(name.length);
            //3.写入内容
            byteBuffer.put(name);
            byteBuffer.putInt(detail.length);
            byteBuffer.put(detail);
            return byteBuffer.array();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return new byte[0];
    }

    @Override
    public void close() {

    }
}
  • 使用:
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ItemSerilializer.class.getName());

private static void sendAndForget() {
        Properties prop = initConfig();
        KafkaProducer<String, Item> producer = new KafkaProducer<>(prop);
        ProducerRecord<String, Item> record = new ProducerRecord<>(TOPIC, new Item("mozping","hello!"));
        try {
            producer.send(record);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        producer.flush();
        System.out.println("End ... ");
    }

在这里插入图片描述

1.5 分区器

1.5.1 Partitioner
  • Kafka生产者中通过分区器决定一条消息应该发往哪一个分区,分区器接口是:Partitioner
public interface Partitioner extends Configurable, Closeable {

    /**
     * Compute the partition for the given record.
     * 根据消息计算分区,返回分区号
     * @param topic The topic name
     * @param key The key to partition on (or null if no key)
     * @param keyBytes The serialized key to partition on( or null if no key)
     * @param value The value to partition on or null
     * @param valueBytes The serialized value to partition on or null
     * @param cluster The current cluster metadata
     */
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);

    /**
     * 关闭分区器
     * This is called when partitioner is closed.
     */
    public void close();
}
1.5.2 DefaultPartitioner
  • DefaultPartitioner是Partitioner的唯一实现类。
public class DefaultPartitioner implements Partitioner {

    private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new ConcurrentHashMap<>();

    public void configure(Map<String, ?> configs) {}

    /**
     * Compute the partition for the given record.
     *
     * @param topic The topic name
     * @param key The key to partition on (or null if no key)
     * @param keyBytes serialized key to partition on (or null if no key)
     * @param value The value to partition on or null
     * @param valueBytes serialized value to partition on or null
     * @param cluster The current cluster metadata
     */
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        //1.获取Topic的全部分区信息
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        //2.分区数量
        int numPartitions = partitions.size();
        if (keyBytes == null) {
            //3.如果消息没有指定了key,就会轮询发往各个可用的分区,先获取指定topic的计数器
            int nextValue = nextValue(topic);
            //4.获取全部的可用分区
            List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
            if (availablePartitions.size() > 0) {
                //5.如果有可用分区,就计数器取余,获取一个可用分区
                int part = Utils.toPositive(nextValue) % availablePartitions.size();
                return availablePartitions.get(part).partition();
            } else {
                // no partitions are available, give a non-available partition
                //6.如果没有可用分区,返回一个不可用的分区
                return Utils.toPositive(nextValue) % numPartitions;
            }
        } else {
            //如果消息指定了key,那就通过一定的hash算法来取余计算分区,(有可能是所有分区中的任何一个)
            // hash the keyBytes to choose a partition
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }

    //获取topic消息的计数器,便于轮询,首次会初始化,之后便会递增
    private int nextValue(String topic) {
        AtomicInteger counter = topicCounterMap.get(topic);
        if (null == counter) {
            counter = new AtomicInteger(ThreadLocalRandom.current().nextInt());
            AtomicInteger currentCounter = topicCounterMap.putIfAbsent(topic, counter);
            if (currentCounter != null) {
                counter = currentCounter;
            }
        }
        return counter.getAndIncrement();
    }

    public void close() {}
}
  • 下面是分区器选择逻辑:

在这里插入图片描述

1.5.3 自定义分区器
  • 实现Partitioner接口即可实现自定义分区器,
public class MyPartitioner implements Partitioner {
    @Override
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        //实现分区选择逻辑
        return 0;
    }

    @Override
    public void close() {

    }

    @Override
    public void configure(Map<String, ?> configs) {

    }
}
  • 配置
    properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, MyPartitioner.class.getName());
    public static final String PARTITIONER_CLASS_CONFIG = "partitioner.class";

1.6 拦截器

  • 生产者拦截器或者消费者拦截器可以在消息发送或者接受之前做一些工作,比如过滤,修改消息等,或者做一些定制或者统计等。
1.6.1 ProducerInterceptor
  • ProducerInterceptor是生产者拦截器接口,没有默认的实现类,是一个可用扩展的接口。
public interface ProducerInterceptor<K, V> extends Configurable {
    
    /**
     * @param record the record from client or the record returned by the previous interceptor in the chain of interceptors.
     * @return producer record to send to topic/partition
     */
    public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record);

    /**
     *   
     */
    public void onAcknowledgement(RecordMetadata metadata, Exception exception);

    /**
     * This is called when interceptor is closed
     */
    public void close();
}

  • 消息在发送之前首先会经过拦截器,下面是方法调用栈
producer.send(record);
->KafkaProducer#send()
 ->KafkaProducer#send()
 @Override
    public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
        // intercept the record, which can be potentially modified; this method does not throw exceptions
        //1.如果有拦截器配置,则调用拦截器的onSend方法处理消息,并返回处理后的消息,再将其发生,
        //在ProducerInterceptors里面会保存全部的ProducerInterceptor拦截器实例,并一一调用
        ProducerRecord<K, V> interceptedRecord = this.interceptors == null ? record : this.interceptors.onSend(record);
        return doSend(interceptedRecord, callback);
    }
    
    private final ProducerInterceptors<K, V> interceptors;
1.6.2 ProducerInterceptors
  • ProducerInterceptors;在onSend方法里面调用所有的拦截器处理消息,注意该方法不会抛出异常
 
public class ProducerInterceptors<K, V> implements Closeable {
    private static final Logger log = LoggerFactory.getLogger(ProducerInterceptors.class);
    
    //1.这里会保存全部的拦截器
    private final List<ProducerInterceptor<K, V>> interceptors;

    public ProducerInterceptors(List<ProducerInterceptor<K, V>> interceptors) {
        this.interceptors = interceptors;
    }

    /**
     *2.调用拦截器处理消息,就是调用这个方法,方法内部会遍历调用全部的拦截器
     */
    public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record) {
        ProducerRecord<K, V> interceptRecord = record;
        for (ProducerInterceptor<K, V> interceptor : this.interceptors) {
            try {
                interceptRecord = interceptor.onSend(interceptRecord);
            } catch (Exception e) {
                // do not propagate interceptor exception, log and continue calling other interceptors
                // be careful not to throw exception from here
                if (record != null)
                    log.warn("Error executing interceptor onSend callback for topic: {}, partition: {}", record.topic(), record.partition(), e);
                else
                    log.warn("Error executing interceptor onSend callback", e);
            }
        }
        return interceptRecord;
    }

    /**
     * 消息发送成功之后收到确认时,会调用该方法
     */
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
        for (ProducerInterceptor<K, V> interceptor : this.interceptors) {
            try {
                interceptor.onAcknowledgement(metadata, exception);
            } catch (Exception e) {
                // do not propagate interceptor exceptions, just log
                log.warn("Error executing interceptor onAcknowledgement callback", e);
            }
        }
    }

    /**
     * 消息发送失败之后会调用
     */
    public void onSendError(ProducerRecord<K, V> record, TopicPartition interceptTopicPartition, Exception exception) {
        for (ProducerInterceptor<K, V> interceptor : this.interceptors) {
            try {
                if (record == null && interceptTopicPartition == null) {
                    interceptor.onAcknowledgement(null, exception);
                } else {
                    if (interceptTopicPartition == null) {
                        interceptTopicPartition = new TopicPartition(record.topic(),
                                record.partition() == null ? RecordMetadata.UNKNOWN_PARTITION : record.partition());
                    }
                    interceptor.onAcknowledgement(new RecordMetadata(interceptTopicPartition, -1, -1,
                                    RecordBatch.NO_TIMESTAMP, Long.valueOf(-1L), -1, -1), exception);
                }
            } catch (Exception e) {
                // do not propagate interceptor exceptions, just log
                log.warn("Error executing interceptor onAcknowledgement callback", e);
            }
        }
    }

    /**
     * Closes every interceptor in a container.
     * 关闭全部拦截器
     */
    @Override
    public void close() {
        for (ProducerInterceptor<K, V> interceptor : this.interceptors) {
            try {
                interceptor.close();
            } catch (Exception e) {
                log.error("Failed to close producer interceptor ", e);
            }
        }
    }
}
1.6.3 自定义拦截器
  • 定义拦截器,实现拦截器接口ProducerInterceptor
public class MyInterceptor implements ProducerInterceptor<String, String> {
    @Override
    public ProducerRecord<String, String> onSend(ProducerRecord<String, String> record) {
        String newVal = "MyInterceptor - " + record.value();
        ProducerRecord<String, String> newProducerRecord = new ProducerRecord<>(
                record.topic(),
                record.partition(),
                record.timestamp(),
                record.key()
                , newVal,
                record.headers());
        return newProducerRecord;
    }

    @Override
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
    }

    @Override
    public void close() {

    }

    @Override
    public void configure(Map<String, ?> configs) {
    }
}
  • 配置
properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, MyInterceptor.class.getName());
public static final String INTERCEPTOR_CLASSES_CONFIG = "interceptor.classes";
  • 测试

在这里插入图片描述

二、原理分析

  • 本小节对生产者端的内部原理进行一定的分析,整体上把握,让我们更好的使用Kafka。

2.1 整体架构

  • 下面一图展示了消息在发送之前经历的流程步骤

在这里插入图片描述

  • 生产者客户端由2个线程维护,主线程创建消息,Sender线程负责发送消息。
  • 消息在发送之前依次经过:拦截器 -> 序列化器 -> 分区器 ,然后消息累加器中便于批量发送。
  • 消息累加器(RecordAccumulator)内部通过双向队列保存消息,队列按照分区划分。每一个队列保存一个分区的消息,队列中的元素不是单个消息,而是ProducerBatch,代表一个批量的消息,RecordAccumulator的缓存大小通过buffer.memory控制,默认32MB。如果创建线程速度大于发送线程,那么send方法会被阻塞,阻塞时长由参数max.block.ms参数决定,超时抛出异常。
  • RecordAccumulator内部维护一个BufferPool来实现ByteBuffer的复用,默认大小16KB,由参数batch.size控制,在发送前会用该区域缓存需要发送的消息,ProducerBatch的大小可以参考该配置的大小。
  • 发送消息最后将分区和消息的对应关系转换为节点和消息的对应关系,最后将消息发往对应的节点,这是一个应用层向网络层的转换。
  • 发送线程会在InFlightRequests中缓存那些已经发送但是还未收到响应的请求,具体缓存数量通过max.in.flight.requests.per.connection配置,默认是5,缓存超过之后就不能往该节点发送请求了,通过该机制可以知道哪些节点的负载较大,有超时的可能。

2.2 元数据更新

  • 元数据
Kafka集群的元数据包括:集群的topic,topic对应的分区,分区所在的节点,每个分区的leader副本和follower副本所在的节点等信息,分区的副本哪些在ISR等。
  • 通过前面提到的InFlightRequests可以知道所有节点中负载最小的那个,即未收到响应的请求数目最少的那个节点,称其为LeastLoadedNode,选择LeastLoadedNode发送消息能够尽量避免网络拥塞。Kafka需要定时更新元数据,在更新时会往LeastLoadedNode节点发送请求,更新过程由发送线程负责,主线程会读取,线程间数据同步通过Synchronized和final来保证。

2.3 重要参数

参数作用
acks发送消息成功需要确认的副本数
max.request.size客户端能够发送的最大消息数量
retries、retry.backoff.ms消息重试次数和重试间隔
compression.type消息压缩类型
connections.max.idle.ms连接闲置后关闭时间
linger.ms发送消息之前等待时候,以便批量发送提高吞吐
receive.buffer.bytesSocket接收缓冲区大小
send.buffer.bytesSocket发送缓冲区大小
request.timeout.ms生产者等待请求的最大时长
batch.size批量发送消息的等待大小,默认16384为16k,配置0则禁止批次发送
buffer memory生产者客户端缓存消息的大小
max.block.ms生产者客户端发送阻塞时长
max.in.flight.requests.per.connection限制每个连接最多缓存的请求数
2.3.1 acks
  • acks参数指定了分区中至少需要多少副本收到消息才认为该消息发送成功。支持以下配置:
配置项配置值效果
acks0生产者将不等待消息确认,不能保障消息被服务器接收到且重试机制不会生效(因为客户端不知道故障了没有),每个消息返回的offset始终设置为-1。
acks1leader写入本地日志后立即响应,不等待follower应答,有丢失的风险,这也是默认值。
acksallleader将等待ISR中所有副本同步后应答消息,这是最强壮的可用性保障。
acks-1等价于all
acks=all 这意味着leader将等待所有副本同步后应答消息,这是最强壮的可用性保障。这不意味着一定可靠,如果ISR只有leader一个则和1等价,此时需要
配合min.insync.replicas等参数来保证可靠性
2.3.2 max.request.size
  • 生产者一次发送的数据量最大大小,这个不是消息的大小,而是一次请求的大小,比如设置为1MB,包含100个消息,那么每个消息不能大于10KB(粗略计算),通常这个配置和message.max.bytes是一致联动的,否则过大的消息服务端可能拒收。
2.3.3 retries和retry.backoff.ms
重试可以让客户端允许服务端出现一些可以自行修复的异常,比如短暂的leader选举;
重试间隔可以避免无效的重试;
重试有可能导致消息乱序,比如消息1发送失败,消息2成功,然后重试1成功,那么1和2就会乱序,即使为了严格保证消息顺序,我们依然还是要开启重试,但是
可以将max.in.flight.requests.per.connection参数设置为1,这个参数前面提到过是指每个节点未收到响应的最大消息缓存数量,置为1意味着1条消息未收到响
应,则不会发下一条消息,会对吞吐有影响;
2.3.4 compression.type
  • 压缩类型,默认为none表示不会压缩消息,压缩消息可以极大减少网络IO,有gzip、snappy和lz4的配置方式,要求低时延则不建议压缩
2.3.5 connections.max.idle.ms
  • 连接闲置后关闭时间,默认9min
2.3.6 linger.ms
  • 发送消息之前等待时候,以便批量发送提高吞吐。即发送ProducerBatch之前等待ProducerRecord的时间,要么ProducerBatch被填满要么到达linger.ms的时间,默认0,是吞吐和时延之间的平衡。
2.3.7 receive.buffer.bytes
  • Socket接收缓冲区大小,默认32KB,如果设置为-1则使用OS的值
2.3.8 send.buffer.bytes
  • Socket发送缓冲区大小,默认128KB,如果设置为-1则使用OS的值
2.3.9 request.timeout.ms
  • 生产者等待请求的最大时长,默认30S。比如之前自定义分区器的时候,我将分区器返回一个不存在的分区,发送消息要30S后才结束,设置为2S,那么2秒后就返回了。
2.3.10 batch.size
  • 要么ProducerBatch内可复用内存区域的大小
2.3.11 buffer.memory
  • 生产者客户端缓存消息的大小,即生成线程往该区域缓存,发送线程从该区域获取消息发送,如果满了,发送线程会阻塞
2.3.12 max.block.ms
  • 生产者客户端发送阻塞时长,当buffer.memory满了,发送线程会阻塞,这是最大的阻塞时长。
2.3.13 max.in.flight.requests.per.connection
  • 限制每个连接(也就是客户端与Node之间的连接)最多缓存的请求数,,默认是5,设置为1则必须响应之后才能继续发送,会影响吞吐,但是对于消息顺序要求很严格的场景,则必须设置为1

2.4 补充参数

  • 该部分参数相对没有那么常用
参数作用
enable.idempotence是否开启幂等,默认false
metadata.max.age.ms这个时间内元数据没有更新的话会被强制更新,默认5min
transactional. id设置事物id,默认null

三、参考

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值