kafka

1、kafka是什麼

  • Apache Kafka是一個開源消息系統,由Scala寫成

  • Kafka最初是由LinkedIn開發,並於2011年初開源

  • Kafka是一個分佈式消息隊列:生產者消費者的功能。它提供了類似於JMS的特性,但是在設計實現上完全不同,此外它並不是JMS規範的實現

  • Kafka對消息保存時根據Topic進行分類,發送消息者稱爲producer,消息接受者稱爲Consumer,此外Kafka集羣有多個Kafka實例組成,每個實例稱爲broker

  • 無論是Kafka集羣還是producer和consumer都依賴於zookeeper集羣保存一些meta信息,來保證系統的可用性


JMS:jms是Java提供的一套技術規範。

    可以用來異構系統集成通信,緩解系統瓶頸,提高系統的伸縮性增強系統用戶體驗,使得系統模塊化和組件化變得可行並更加靈活。


類JMS消息隊列,結合JMS中的兩種模式,可以有多個消費者主動拉取數據,在JMS中只有點對點模式纔有消費者主動拉取數據。

kafka和JMS的區別.png


kafka是一個生產-消費模型。

    01.Producer:生產者

        只負責數據生產,生產者的代碼可以集成到任務系統中。 數據的分發策略由producer決定,默認是defaultPartition  Utils.abs(key.hashCode) % numPartitions

    02.Broker

        當前服務器上的Kafka進程,俗稱拉皮條。只管數據存儲,不管是誰生產,不管是誰消費。在集羣中每個broker都有一個唯一brokerid,不得重複。

    03.Topic:

        目標發送的目的地,這是一個邏輯上的概念,落到磁盤上是一個partition的目錄。partition的目錄中有多個segment組合(index,log)

        一個Topic對應多個partition[0,1,2,3],一個partition對應多個segment組合。一個segment有默認的大小是1G。

        每個partition可以設置多個副本(replication-factor 1),會從所有的副本中選取一個leader出來。所有讀寫操作都是通過leader來進行的

        特別強調,和mysql中主從有區別,mysql做主從是爲了讀寫分離,在kafka中讀寫操作都是leader。

    04.ConsumerGroup:

        數據消費者組,ConsumerGroup可以有多個,每個ConsumerGroup消費的數據都是一樣的。

        可以把多個consumer線程劃分爲一個組,組裏面所有成員共同消費一個topic的數據,組員之間不能重複消費。

        (在下面代碼配置文件中,可以設置groupID和讀取的位置)

    05.zookeeper

        依賴集羣保存meta信息(每次讀取到哪的信息)。


    kafka集羣.png

2、kafka生產數據時的分組策略

    默認是defaultPartition  Utils.abs(key.hashCode) % numPartitions

    上文中的key是producer在發送數據時傳入的,produer.send(KeyedMessage(topic,myPartitionKey,messageContent))


3、kafka如何保證數據的完全生產

    ack機制:broker表示發來的數據已確認接收無誤,表示數據已經保存到磁盤。

    0:不等待broker返回確認消息

    1:等待topic中某個partition leader保存成功的狀態反饋

    -1:等待topic中某個partition 所有副本都保存成功的狀態反饋

4、broker如何保存數據

    在理論環境下,broker按照順序讀寫的機制,可以每秒保存600M的數據。主要通過pagecache機制,儘可能的利用當前物理機器上的空閒內存來做緩存。

    當前topic所屬的broker,必定有一個該topic的partition,partition是一個磁盤目錄。partition的目錄中有多個segment組合(index,log)


5、partition如何分佈在不同的broker上

    int i = 0

    list{kafka01,kafka02,kafka03}

    

    for(int i=0;i<5;i++){

        brIndex = i%broker;

        hostName = list.get(brIndex)

    }

6、consumerGroup的組員和partition之間如何做負載均衡

    最好是一一對應,一個partition對應一個consumer。

    如果consumer的數量過多,必然有空閒的consumer。

    

    算法:

        假如topic1,具有如下partitions: P0,P1,P2,P3

        加入group中,有如下consumer: C1,C2

        首先根據partition索引號對partitions排序: P0,P1,P2,P3

        根據consumer.id排序: C0,C1

        計算倍數: M = [P0,P1,P2,P3].size / [C0,C1].size,本例值M=2(向上取整)

        然後依次分配partitions: C0 = [P0,P1],C1=[P2,P3],即Ci = [P(i * M),P((i + 1) * M -1)]


7、如何保證kafka消費者消費數據是全局有序的

    僞命題

    如果要全局有序的,必須保證生產有序,存儲有序,消費有序。

    由於生產可以做集羣,存儲可以分片,消費可以設置爲一個consumerGroup,要保證全局有序,就需要保證每個環節都有序。

    只有一個可能,就是一個生產者,一個partition,一個消費者。這種場景和大數據應用場景相悖。

8.kafka生產數據

import kafka.javaapi.producer.Producer;
import kafka.producer.KeyedMessage;
import kafka.producer.ProducerConfig;

import java.util.Properties;
import java.util.UUID;

/**
 * 這是一個簡單的Kafka producer代碼
 * 包含兩個功能:
 * 1、數據發送
 * 2、數據按照自定義的partition策略進行發送
 * KafkaSpout的類
 */
public class KafkaProducerSimple {
    public static void main(String[] args) {

		//1、指定當前kafka producer生產的數據的目的地
		//創建topic可以輸入以下命令,在kafka集羣的任一節點進行創建。
		//bin/kafka-topics.sh --create --zookeeper zk01:2181 --replication-factor 1 --partitions 1 --topic test
        String TOPIC = "orderMq";

        //2、讀取配置文件
        Properties props = new Properties();

        //key.serializer.class默認爲serializer.class
        props.put("serializer.class", "kafka.serializer.StringEncoder");

		//kafka broker對應的主機,格式爲host1:port1,host2:port2
        props.put("metadata.broker.list", "kafka01:9092,kafka02:9092,kafka03:9092");
        
//      request.required.acks,設置發送數據是否需要服務端的反饋,有三個值0,1,-1
//		0,意味着producer永遠不會等待一個來自broker的ack,這就是0.7版本的行爲。這個選項提供了最低的延遲,但是持久化的保證是最弱的,當server掛掉的時候會丟失一些數據。
//		1,意味着在leader replica已經接收到數據後,producer會得到一個ack。這個選項提供了更好的持久性,因爲在server確認請求成功處理後,client纔會返回。如果剛寫到leader上,還沒來得及複製leader就掛了,那麼消息纔可能會丟失。
//		-1,意味着在所有的ISR都接收到數據後,producer纔得到一個ack。這個選項提供了最好的持久性,只要還有一個replica存活,那麼數據就不會丟失

        props.put("request.required.acks", "1");

//		可選配置,如果不配置,則使用默認的partitioner partitioner.class
//		默認值:kafka.producer.DefaultPartitioner
//		用來把消息分到各個partition中,默認行爲是對key進行hash。
        props.put("partitioner.class", "cn.my.storm.kafka.MyLogPartitioner");
//      props.put("partitioner.class", "kafka.producer.DefaultPartitioner");

        //3、通過配置文件,創建生產者
        Producer<String, String> producer = new Producer<String, String>(new ProducerConfig(props));

        //4、通過for循環生產數據
        for (int messageNo = 1; messageNo < 100000; messageNo++) {
//            String messageStr = new String(messageNo + "注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey," +
//                    "注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發" +
//                    "注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發" +
//                    "注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發" +
//                    "注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發" +
//                    "注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發" +
//                    "注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發" +
//                    "注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發" +
//                    "用來配合自定義的MyLogPartitioner進行數據分發");

//            5、調用producer的send方法發送數據
//            注意:這裏需要指定 partitionKey,用來配合自定義的MyLogPartitioner進行數據分發
            producer.send(new KeyedMessage<String, String>(TOPIC, messageNo + "", "appid" + UUID.randomUUID() + "itcast"));
        }
    }
}
import kafka.producer.Partitioner;
import kafka.utils.VerifiableProperties;
import org.apache.log4j.Logger;

public class MyLogPartitioner implements Partitioner {
    private static Logger logger = Logger.getLogger(MyLogPartitioner.class);

    public MyLogPartitioner(VerifiableProperties props) {
    }

    public int partition(Object obj, int numPartitions) {
        return Integer.parseInt(obj.toString())%numPartitions;
//        return 1;
    }

}

9.kafka消費數據(低階)

import kafka.consumer.Consumer;
import kafka.consumer.ConsumerConfig;
import kafka.consumer.ConsumerIterator;
import kafka.consumer.KafkaStream;
import kafka.javaapi.consumer.ConsumerConnector;
import kafka.message.MessageAndMetadata;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class KafkaConsumerSimple implements Runnable {
    public String title;
    public KafkaStream<byte[], byte[]> stream;
    public KafkaConsumerSimple(String title, KafkaStream<byte[], byte[]> stream) {
        this.title = title;
        this.stream = stream;
    }
    @Override
    public void run() {
        System.out.println("開始運行 " + title);
        ConsumerIterator<byte[], byte[]> it = stream.iterator();
        /**
         * 不停地從stream讀取新到來的消息,在等待新的消息時,hasNext()會阻塞
         * 如果調用 `ConsumerConnector#shutdown`,那麼`hasNext`會返回false
         * */
        while (it.hasNext()) {
            MessageAndMetadata<byte[], byte[]> data = it.next();
            String topic = data.topic();
            int partition = data.partition();
            long offset = data.offset();
            String msg = new String(data.message());
            System.out.println(String.format(
                    "Consumer: [%s],  Topic: [%s],  PartitionId: [%d], Offset: [%d], msg: [%s]",
                    title, topic, partition, offset, msg));
        }
        System.out.println(String.format("Consumer: [%s] exiting ...", title));
    }

    public static void main(String[] args) throws Exception{
        Properties props = new Properties();
        props.put("group.id", "dashujujiagoushi");
        props.put("zookeeper.connect", "zk01:2181,zk02:2181,zk03:2181");
        props.put("auto.offset.reset", "largest");
        props.put("auto.commit.interval.ms", "1000");
        props.put("partition.assignment.strategy", "roundrobin");
        ConsumerConfig config = new ConsumerConfig(props);
        String topic1 = "orderMq";
        String topic2 = "paymentMq";
        
        //只要ConsumerConnector還在的話,consumer會一直等待新消息,不會自己退出
        ConsumerConnector consumerConn = Consumer.createJavaConsumerConnector(config);
        
        //定義一個map
        Map<String, Integer> topicCountMap = new HashMap<>();
        topicCountMap.put(topic1, 3);
        
        //Map<String, List<KafkaStream<byte[], byte[]>> 中String是topic, List<KafkaStream<byte[], byte[]>是對應的流
        Map<String, List<KafkaStream<byte[], byte[]>>> topicStreamsMap = consumerConn.createMessageStreams(topicCountMap);
        
        //取出 `kafkaTest` 對應的 streams
        List<KafkaStream<byte[], byte[]>> streams = topicStreamsMap.get(topic1);
        
        //創建一個容量爲4的線程池
        ExecutorService executor = Executors.newFixedThreadPool(3);
        //創建20個consumer threads
        for (int i = 0; i < streams.size(); i++)
            executor.execute(new KafkaConsumerSimple("消費者" + (i + 1), streams.get(i)));
    }
}


10.kafka和zookeeper使用JavaAPI能夠拉取到數據(高階消費)

properties配置文件

###zookeeper\u548ckafka\u914d\u7f6e\u5730\u5740
zk.connect=xxxxx
#zk.connect=xxxxx
###kafka\u6d88\u8d39\u7684group\u5fc5\u987b\u8c03\u6574\u4e3a\u72ec\u5360
adinfo.log.group.name=qinbin_ad_interfaceLog_20171218
###kafka\u7684topic.\u9700\u8981\u548cadstat\u6a21\u5757\u7684kafka topic\u4e00\u81f4
adinfo.log.topic.name=ad_interfaceLog
adinfo.log.queue.max=10000
adinfo.log.list.size=1
###\u4e2d\u95f4\u7ed3\u679c\u4fdd\u5b58\u65e5\u5fd7
adinfo.log.pathFile=E:/opt/realtime/avro/file/

 
###\u9ed8\u8ba4\u4e0d\u8981\u52a8
adinfo.statistics.time=120000
adinfo.statistics.commitSize=3000

kafka配置文件(注意groupID)

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:int="http://www.springframework.org/schema/integration"
	xmlns:int-kafka="http://www.springframework.org/schema/integration/kafka"
	xmlns:task="http://www.springframework.org/schema/task"
	xsi:schemaLocation="http://www.springframework.org/schema/integration/kafka http://www.springframework.org/schema/integration/kafka/spring-integration-kafka.xsd
		http://www.springframework.org/schema/integration http://www.springframework.org/schema/integration/spring-integration.xsd
		http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
		http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task.xsd">

    <int:channel id="inputFromAdinfo">
        <int:queue/>
    </int:channel>
    
	<int-kafka:inbound-channel-adapter
		id="kafkaInboundChannelAdinfo" kafka-consumer-context-ref="consumerContextAdinfo"
		auto-startup="true" channel="inputFromAdinfo" >
		<int:poller fixed-delay="10" time-unit="MILLISECONDS"  max-messages-per-poll="5" />
	</int-kafka:inbound-channel-adapter>

	<bean id="consumerPropertiesAdinfo"
		class="org.springframework.beans.factory.config.PropertiesFactoryBean">
		<property name="properties">
			<props>
				<prop key="auto.offset.reset">smallest</prop>
				<prop key="socket.receive.buffer.bytes">314572</prop> <!-- 5M -->
				<prop key="fetch.min.bytes">26214</prop><!-- 256k -->
				<prop key="fetch.message.max.bytes">104857</prop><!-- 3M -->
				<prop key="fetch.wait.max.ms">5000</prop>
				<prop key="auto.commit.interval.ms">2000</prop>
				<prop key="rebalance.backoff.ms">5000</prop>
				<prop key="rebalance.max.retries">5</prop>
			</props>
		</property>
	</bean>

	<int-kafka:consumer-context id="consumerContextAdinfo"
		consumer-timeout="4000" zookeeper-connect="zookeeperConnectAdinfo" consumer-properties="consumerPropertiesAdinfo">
		<int-kafka:consumer-configurations>
		<!-- 需要注意如果兩個線程同時互不相干去消費通一個topic,則需要注意group-id不能重複 -->
			<int-kafka:consumer-configuration group-id="${adinfo.log.group.name}" max-messages="500">
				<int-kafka:topic id="${adinfo.log.topic.name}" streams="1" />
			</int-kafka:consumer-configuration>
		</int-kafka:consumer-configurations>
	</int-kafka:consumer-context>

	<int-kafka:zookeeper-connect id="zookeeperConnectAdinfo"
	      zk-connect="${zk.connect}" zk-connection-timeout="6000"
		  zk-session-timeout="6000" zk-sync-time="2000"/>
</beans>

然後在spring配置文件中import kafka的配置文件


Java接收:

import java.io.UnsupportedEncodingException;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import javax.annotation.Resource;

import org.apache.avro.io.DatumReader;
import org.apache.avro.specific.SpecificDatumReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.integration.channel.QueueChannel;
import org.springframework.messaging.Message;

import com.ElasticSearchServiceImpl;
import com.IElasticSearchService;
import com.AdInfoRealTimeThread;
import com.ConfigUtil;
import com.AdInfo;

public class AdInfoConsumer {
	// DatumReader<AdInfo> adInfoDatumReader = new
	// SpecificDatumReader<AdInfo>(AdInfoOld.getClassSchema(),AdInfo.getClassSchema());
	DatumReader<AdInfo> adInfoDatumReader = new SpecificDatumReader<AdInfo>(AdInfo.class);
	private Logger logger = LoggerFactory.getLogger(AdInfoConsumer.class);
	@Resource(type = ElasticSearchServiceImpl.class)
	private IElasticSearchService elasticSearchServiceImpl;
	@Resource(type = ConfigUtil.class)
	private ConfigUtil configUtil;

	private QueueChannel queueChannel;
	public QueueChannel getQueueChannel() {
		return queueChannel;
	}
	public void setQueueChannel(QueueChannel queueChannel) {
		this.queueChannel = queueChannel;
	}

	private AdInfoRealTimeThread adInfoRealTimeThread;
	public AdInfoRealTimeThread getAdInfoRealTimeThread() {
		return adInfoRealTimeThread;
	}
	public void setAdInfoRealTimeThread(AdInfoRealTimeThread adInfoRealTimeThread) {
		this.adInfoRealTimeThread = adInfoRealTimeThread;
	}

	public void consumerLog() throws UnsupportedEncodingException {

		@SuppressWarnings("rawtypes")
		Message msg;
		while ((msg = queueChannel.receive()) != null) {
			// msg = queueChannel.receive();
			try {
				Map<String, Object> map = (Map<String, Object>) msg.getPayload();
				Set<Entry<String, Object>> set = map.entrySet();
				for (Map.Entry<String, Object> entry : set) {
					String topic = entry.getKey();
					ConcurrentHashMap<Integer, List<byte[]>> messages = (ConcurrentHashMap<Integer, List<byte[]>>) entry
							.getValue();
					Collection<List<byte[]>> values = messages.values();
					for (Iterator<List<byte[]>> iterator = values.iterator(); iterator.hasNext();) {
						List<byte[]> list = iterator.next();
						for (byte[] object : list) {
							String message = new String(object, "UTF-8");
							StringBuilder megJson = new StringBuilder(message);
							megJson.delete(0, megJson.indexOf("=") + 1);
							// logger.info("json:"+megJson.toString());
							// adinfoToSaveES.saveAdLogToEs(megJson.toString());
							elasticSearchServiceImpl.executeSearch(configUtil.clusterName,megJson.toString());
							//System.out.println(megJson.toString());

						}
					}
				}
			} catch (Exception ex) {
				logger.error("===AdInfoConsumer consumer is exception", ex);
			}
		}

		logger.error("====AdInfoConsumer receive is interrupted====");
	}

	/*
	 * public void consumerLog() throws UnsupportedEncodingException {
	 * 
	 * @SuppressWarnings("rawtypes") Message msg; while ((msg =
	 * queueChannel.receive()) != null) {
	 * 
	 * try {
	 * 
	 * Map<String, Object> map = (Map<String, Object>) msg.getPayload();
	 * Set<Entry<String, Object>> set = map.entrySet(); for (Map.Entry<String,
	 * Object> entry : set) { // String topic = entry.getKey();
	 * ConcurrentHashMap<Integer, List<byte[]>> messages =
	 * (ConcurrentHashMap<Integer, List<byte[]>>) entry .getValue();
	 * Collection<List<byte[]>> values = messages.values(); for
	 * (Iterator<List<byte[]>> iterator = values.iterator(); iterator.hasNext();) {
	 * List<byte[]> list = iterator.next(); for (byte[] object : list) {
	 * 
	 * try { Decoder decoder = DecoderFactory.get().binaryDecoder(object, null);
	 * 
	 * AdInfo adInfo = adInfoDatumReader.read(null, decoder);
	 * 
	 * String json=adInfo.toString(); System.out.println("*************"+json);
	 * //logger.info("json:"+json); //adInfoRealTimeThread.statistics(json);
	 * 
	 * } catch (Exception e) {
	 * logger.error("===AdInfoConsumer consumer one is exception", e); }
	 * 
	 * 
	 * } } } } catch (Exception ex) {
	 * logger.error("===AdInfoConsumer consumer is exception", ex); } }
	 * 
	 * logger.error("====AdInfoConsumer receive is interrupted===="); }
	 */
}


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章