Pulsar 安裝及消息特性基礎

  Pulsar 是一個用於服務器到服務器的消息系統,具有多租戶、高性能等優勢。 Pulsar 最初由 Yahoo 開發,目前由 Apache 軟件基金會管理。

Pulsar 的關鍵特性如下:

  • Pulsar 的單個實例原生支持多個集羣,可跨機房在集羣間無縫地完成消息複製。
  • 極低的發佈延遲和端到端延遲。
  • 可無縫擴展到超過一百萬個 topic。
  • 簡單的客戶端 API,支持 Java、Go、Python 和 C++。
  • 支持多種 topic 訂閱模式(獨佔訂閱、共享訂閱、故障轉移訂閱)。
  • 通過 Apache BookKeeper 提供的持久化消息存儲機制保證消息傳遞 。
  •  由輕量級的 serverless 計算框架 Pulsar Functions 實現流原生的數據處理。
  • 基於 Pulsar Functions 的 serverless connector 框架 Pulsar IO 使得數據更易移入、移出 Apache Pulsar。
  • 分層式存儲可在數據陳舊時,將數據從熱存儲卸載到冷/長期存儲(如S3、GCS)中。

   By default, Pulsar allocates 2G JVM heap memory to start. It can be changed in conf/pulsar_env.sh file under PULSAR_MEM. This is extra options passed into JVM.

# PULSAR_MEM=${PULSAR_MEM:-"-Xms2g -Xmx2g -XX:MaxDirectMemorySize=4g"}
PULSAR_MEM=${PULSAR_MEM:-"-Xms512m -Xmx512m -XX:MaxDirectMemorySize=1g"}

  單機啓動:bin/pulsar standalone 。pulsar-daemon start/stop standalone: 後臺運行的standalone服務模式

  在 logs 目錄下會出現一個 pulsar-standalone-m.log 日誌,查看一下 看到如下信息說明服務啓動成功。

 

  控制檯啓動發佈訂閱,bin/pulsar-client consume my-topic -s "first-subscription"

  如果消息成功發送到 topic,則會在 pulsar-client 日誌中出現一個確認,如下所示:

   bin/pulsar-client produce my-topic --messages "hello-pulsar"

  如果消息成功發送到 topic,則會在 pulsar-client 日誌中出現一個確認,如下所示:

   消息是 Pulsar 的基礎“單元”。消息的缺省大小爲5mb。您可以通過以下配置來配置消息的最大大小。

//在 broker.conf 文件中
maxMessageSize=5242880
//在 bookkeeper.conf 配置文件中
nettyMaxFrameSizeBytes=5253120

Java API:

  Producer 可以以同步(sync) 或 異步(async) 的方式發佈消息到 broker。Comsumer 可以通過同步(sync) 或者異步(async)的方式接受消息。下面演示簡單的生產消費demo:

  消費者:

public class PulsarSimpleConsumer extends Thread {

    private Consumer consumer;

    public PulsarSimpleConsumer() {
        try {
            PulsarClient client = PulsarClient.builder()
                    .serviceUrl("pulsar://192.168.1.101:6650")
                    .build();
            consumer = client.newConsumer()
                    .topic("my-topic")
                    .subscriptionName("my-subscription")
                    .subscriptionType(SubscriptionType.Shared)
                    .subscribe();

//            consumer = client.newConsumer()
//                    .topic("my-topic")
//                    .messageListener(new MessageListener<byte[]>() {
//                        @Override
//                        public void received(Consumer<byte[]> consumer, Message<byte[]> message) {
//                            System.out.printf("Message received MessageListener: %s", new String(message.getData()));
//                            try {
//                                consumer.acknowledge(message);
//                            } catch (PulsarClientException e) {
//                                e.printStackTrace();
//                            }
//                        }
//                    })
//                    .subscriptionName("my-subscription")
//                    .subscribe();
        } catch (PulsarClientException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        PulsarSimpleConsumer pulsarConsumer = new PulsarSimpleConsumer();
        pulsarConsumer.start();

    }

    @Override
    public void run() {
        while (true) {

            Message msg = null;
            try {
                // Wait for a message
                msg = consumer.receive();

                System.out.printf("Message received: %s", new String(msg.getData()));

                // Acknowledge the message so that it can be deleted by the message broker
                consumer.acknowledge(msg);
            } catch (Exception e) {
                // Message failed to process, redeliver later
                consumer.negativeAcknowledge(msg);
            }
        }
    }
}

   生產者:

public class PulsarSimpleProducer {

    public static void main(String[] args) {
        Producer<byte[]> producer = null;
        try {
            PulsarClient client = PulsarClient.builder()
                    .serviceUrl("pulsar://192.168.1.101:6650")
                    .build();

            producer = client.newProducer()
                    .topic("my-topic")
                    .blockIfQueueFull(true) // 發送模式
                    .create();
            // 然後你就可以發送消息到指定的broker 和topic上:
            producer.send("My message".getBytes());
            //異步發送
//            producer.sendAsync("my-async-message".getBytes()).thenAccept(msgId -> {
//                System.out.printf("Message with ID %s successfully sent", msgId);
//            });
            client.close();
        } catch (PulsarClientException e) {
            e.printStackTrace();
        } finally {
            producer.closeAsync().thenRun(() -> {
                System.out.println("Producer closed");
            });

        }
    }
}

Access mode:

  訪問模式分爲 Shared(默認),Exclusive。當設置爲 Exclusive ,是不允許同時有多個生產者向該topic 發送消息,否則將拋出以下異常:

 

消息壓縮:

   您可以壓縮在傳輸期間由生產者發佈的消息。目前支持以下幾種壓縮方式:

 批量處理:

  啓用批處理後,生產者將在單個請求中累積併發送一批消息。 批處理大小由最大消息數和最大發布延遲定義。 因此,待辦事項的大小表示批處理的總數而不是消息的總數。在Pulsar中,批次被跟蹤存儲存儲不是以單條信息爲單位,而是以batch爲單位。消費者會將batch的數據拆分成一條一條的數據。然而,調度信息無論是否配置了batch總是以一條一條進行發送的。通常情況下,只有所有消息都被consumer 發送ack了,這個batch纔會被ack。這就意味着,非預期的失敗、超時或者否定ack 都會導致整個批次信息的重傳。

  爲了避免將已確認的消息批量分發給使用者,自Pulsar 2.6.0起,Pulsar引入了批處理索引確認。 啓用批處理索引確認後,使用者將篩選出已確認的批處理索引,並將批處理索引確認請求發送給Broker。 Broker維護批次索引確認狀態,並跟蹤每個批次索引的確認狀態,以避免將已確認的消息發送給使用者。 確認批處理消息的所有索引後,該批處理消息將被刪除。默認情況下批處理索引確認功能是關閉的(batchIndexAcknowledgeEnable=false)。可以在broker上通過設置這個配置打開這個功能。啓用批處理索引確認會導致更多的內存開銷

producer = client.newProducer()
                    .topic("my-topic")
                    .blockIfQueueFull(true) // 發送模式
                    //是否開啓批量處理消息,默認true,需要注意的是enableBatching只在異步發送sendAsync生效,
                    // 同步發送send失效。因此建議生產環境若想使用批處理,則需使用異步發送,或者多線程同步發送
                    .enableBatching(true)
                    //設置將對發送的消息進行批處理的時間段,10ms;
                    // 可以理解爲若該時間段內批處理成功,則一個batch中的消息數量不會被該參數所影響。
                    .batchingMaxPublishDelay(10, TimeUnit.MILLISECONDS)
                    .batchingMaxMessages(1000)//批處理中允許的最大消息數。默認1000
                    .create();

分塊:

  當你想要啓用分塊(chunking) 時,請閱讀以下說明。enableChunking(true)

  • Batching and chunking cannot be enabled simultaneously. 如果想要啓用分塊(chunking) ,您必須提前禁用批量處理。
  • Chunking is only supported for persisted topics.
  • Chunking is only supported for the exclusive and failover subscription modes.

  當啓用分塊(chunking) 時(chunkingEnabled=true) ,如果消息大小大於允許的最大發布有效載荷大小,則 producer 將原始消息分割成分塊的消息,並將它們與塊狀的元數據一起單獨和按順序發佈到 broker。 在 broker 中,分塊的消息將和普通的消息以相同的方式存儲在 Managed Ledger 上。 唯一的區別是,consumer 需要緩衝分塊消息,並在收集完所有分塊消息後將其合併成真正的消息。 Managed Ledger 上的分塊消息可以和普通消息交織在一起。 如果 producer 未能發佈消息的所有分塊,則當 consumer 未能在過期時間(expire time) 內接收所有分塊時,consumer 可以過期未完成的分塊。 默認情況下,過期時間設置爲1小時。

  Consumer 會緩存收到的塊狀消息,直到收到消息的所有分塊爲止。 然後 consumer 將分塊的消息拼接在一起,並將它們放入接收器隊列中。 客戶端從接收器隊列中消費消息。 一旦 consumer 使用整個大消息並確認,consumer 就會在內部發送與該大消息關聯的所有分塊消息的確認。 You can set the maxPendingChunkedMessage parameter on the consumer. 當達到閾值時,consumer 通過靜默確認未分塊的消息或通過將其標記爲未確認,要求 broker 稍後重新發送這些消息。

  broker 不需要任何更改來支持非共享訂閱的分塊。broker 只使用chunkedMessageRate來記錄主題上的分塊消息速率。

處理一個 producer 和一個訂閱 consumer 的分塊消息

  如下圖所示,當生產者向主題發送一批大的分塊消息和普通的非分塊消息時。 假設生產者發送的消息爲 M1,M1 有三個分塊 M1-C1,M1-C2 和 M1-C3。 這個 broker 在其管理的ledger裏面保存所有的三個塊消息,然後以相同的順序分發給消費者(獨佔/災備模式)。 消費者將在內存緩存所有的塊消息,直到收到所有的消息塊。將這些消息合併成爲原始的消息M1,發送給處理進程。

 

多個生產者和一個生產者處理塊消息。

  當多個生產者發佈塊消息到單個主題,這個 Broker 在同一個 Ledger 裏面保存來自不同生產者的所有塊消息。 如下所示,生產者1發佈的消息 M1,M1 由 M1-C1, M1-C2 和 M1-C3 三個塊組成。 生產者2發佈的消息 M2,M2 由 M2-C1, M2-C2 和 M2-C3 三個塊組成。 這些特定消息的所有分塊是順序排列的,但是其在 ledger 裏面可能不是連續的。 這種方式會給消費者帶來一定的內存負擔。因爲消費者會爲每個大消息在內存開闢一塊緩衝區,以便將所有的塊消息合併爲原始的大消息。

 

消息確認:

  當消費者成功的消費了一條消息,這個消費者會發送一個確認信息給broker。 這個消息時是永久保存的,只有在收到訂閱者消費成功的消息確認後纔會被刪除。 如果希望消息被 Consumer 確認後仍然保留下來,可配置 消息保留策略實現。

  當某一批消息的所有索引都被確認時,該批消息將被刪除。

  消息有兩種確認模式:單條確認或者累計確認。 累積確認時,消費者只需要確認最後一條他收到的消息。 所有之前(包含此條)的消息,都不會被再次重發給那個消費者。

  消息可以通過以下兩種方式被確認:

  1. 消息是單獨確認的。對於單獨的確認,使用者需要確認每條消息並向代理髮送確認請求。
  2. 累積確認模式:累積確認時,消費者只需要確認最後一條他收到的消息。 所有之前(包含此條)的消息,都不會被再次發送給那個消費者。

   在共享訂閱模式,消息都是單條確認模式。

消息取消確認

  當消費者在某個時間沒有成功的消費某條消息,消費者想重新消費到這條消息,這個消費者可以發送一條取消確認消息到 broker,broker 會將這條消息重新發給消費者。

  消息取消確認也有單條取消模式和累積取消模式 ,這依賴於消費者使用的訂閱模式。

  在獨佔消費模式和災備訂閱模式中,消費者僅僅只能對收到的最後一條消息進行取消確認。

消息確認超時

  如果消息沒有被成功消費,你想去讓 broker 自動重新交付這個消息, 你可以採用未確認消息自動重新交付機制。 客戶端會跟蹤 超時 時間範圍內所有未確認的消息。 並且在指定超時時間後會發送一個 重發未確認的消息 請求到 broker。

  注意:如果啓用了批處理,則同一批處理中的其他消息和未確認消息將被重新交付給使用者。

死信主題:

Consumer<byte[]> consumer = pulsarClient.newConsumer(Schema.BYTES)
              .topic(topic)
              .subscriptionName("my-subscription")
              .subscriptionType(SubscriptionType.Shared)
              .deadLetterPolicy(DeadLetterPolicy.builder()
                    .maxRedeliverCount(maxRedeliveryCount)
                    .deadLetterTopic("your-topic-name")
                    .build())
              .subscribe();

Retry letter topic

  很多在線的業務系統,由於業務邏輯處理出現異常,消息一般需要被重新消費。 若需要允許延時重新消費失敗的消息,你可以配置生產者同時發送消息到業務主題和重試主題,並允許消費者自動重試消費。 配置了允許消費者自動重試。如果消息沒有被消費成功,它將被保存到重試主題當中。並在指定延時時間後,自動重新消費重試主題裏面的消費失敗消息。

  缺省情況下,禁用自動重試功能。您可以將enableRetry設置爲true,以在使用者上啓用自動重試。

Consumer<byte[]> consumer = pulsarClient.newConsumer(Schema.BYTES)
                .topic(topic)
                .subscriptionName("my-subscription")
                .subscriptionType(SubscriptionType.Shared)
                .enableRetry(true)
                .receiverQueueSize(100)
                .deadLetterPolicy(DeadLetterPolicy.builder()
                        .maxRedeliverCount(maxRedeliveryCount)
                        .retryLetterTopic("persistent://my-property/my-ns/my-subscription-custom-Retry")
                        .build())
                .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest)
                .subscribe();

  默認的情況下Topic的名稱爲符合良好結構的URL:

{persistent|non-persistent}://tenant/namespace/topic

命名空間:

  命名空間是租戶內部邏輯上的命名術語。 可以通過admin API在租戶下創建多個命名空間。 例如,包含多個應用程序的租戶可以爲每個應用程序創建單獨的命名空間。 Namespace使得程序可以以層級的方式創建和管理topic Topicmy-tenant/app1 ,它的namespace是app1這個應用,對應的租戶是 my-tenant。 你可以在namespace下創建任意數量的topic

訂閱:

  訂閱是命名好的配置規則,指導消息如何投遞給消費者。 Pulsar 中有四種訂閱模式: 獨佔共享災備key共享 下圖展示了這三種模式:

  1. Exclusive:Exclusive模式爲默認訂閱模式。
  2. Failover(災備):在故障轉移模式中,多個使用者可以附加到相同的訂閱. 主消費者會消費非分區主題或者分區主題中的每個分區的消息。 當主使用者斷開連接時,所有(非確認的和後續的)消息都被傳遞給下一個使用者。對於分區主題來說,Broker 將按照消費者的優先級和消費者名稱的詞彙表順序對消費者進行排序。 然後試圖將主題均勻的分配給優先級最高的消費者。對於非分區主題來說,broker 會根據消費者訂閱非分區主題的順序選擇消費者。在下面的圖中,Consumer-B-0是主消費者,而如果Consumer-B-0斷開連接,Consumer-B-1將是下一個接收消息的消費者。
  3. Shared共享):In shared or round robin mode, multiple consumers can attach to the same subscription. 消息通過round robin輪詢機制分發給不同的消費者,並且每個消息僅會被分發給一個消費者。 當消費者斷開連接,所有被髮送給他,但沒有被確認的消息將被重新安排,分發給其它存活的消費者。
  4. Key_Shared:在Key_Shared模式下,多個消費者可以附加到同一個訂閱。消息以跨使用者的分發方式傳遞,具有相同鍵或相同排序鍵的消息只被傳遞給一個使用者。無論消息被重新交付多少次,它都被交付給相同的使用者。當使用者連接或斷開時會導致服務使用者更改消息的某些鍵。可以在 broker.config 中禁用 Key_Shared 模式。

多主題訂閱:

  當consumer訂閱pulsar的主題時,它默認指定訂閱了一個主題,例如:persistent://public/default/my-topic。 從Pulsar的1.23.0-incubating的版本開始,Pulsar消費者可以同時訂閱多個topic。

  當使用正則匹配訂閱多個主題的時候,所有的主題必須是在同一個命名空間裏面的。

PulsarClient pulsarClient = // Instantiate Pulsar client object

// Subscribe to all topics in a namespace
Pattern allTopicsInNamespace = Pattern.compile("persistent://public/default/.*");
Consumer<byte[]> allTopicsConsumer = pulsarClient.newConsumer()
                .topicsPattern(allTopicsInNamespace)
                .subscriptionName("subscription-1")
                .subscribe();

// Subscribe to a subsets of topics in a namespace, based on regex
Pattern someTopicsInNamespace = Pattern.compile("persistent://public/default/foo.*");
Consumer<byte[]> someTopicsConsumer = pulsarClient.newConsumer()
                .topicsPattern(someTopicsInNamespace)
                .subscriptionName("subscription-1")
                .subscribe();

分區 topic:

  普通的主題僅僅被保存在單個 broker中,這限制了主題的最大吞吐量。分區主題是由多個 broker 處理的特殊類型的主題,因此允許更高的吞吐量。分區主題實際是通過在底層擁有 N 個內部主題來實現的,這個 N 的數量就是等於分區的數量。 當向分區的topic發送消息,每條消息被路由到其中一個broker。 Pulsar自動處理跨broker的分區分佈。下圖對此做了闡明:

  The Topic1 topic has five partitions (P0 through P4) split across three brokers. 因爲分區多於broker數量,其中有兩個broker要處理兩個分區。第三個broker則只處理一個。(再次強調,分區的分佈是Pulsar自動處理的)。這個topic的消息被廣播給兩個consumer。 路由模式確定每條消息該發往哪個分區,而訂閱模式確定消息傳遞給哪個消費者。大多數境況下,路由和訂閱模式可以分開制定。 通常來講,吞吐能力的要求,決定了 分區/路由 的方式。訂閱模式則應該由應用的語義來做決定。分區topic和普通topic,對於訂閱模式如何工作,沒有任何不同。分區只是決定了從生產者生產消息到消費者處理及確認消息過程中發生的事情。

路由模式:

  當發佈到分區主題時,必須指定路由模式。路由模式決定每條消息應該發佈到哪個分區——即哪個內部主題

  有三種 MessageRoutingMode 可用:

  1. RoundRobinPartition:如果消息沒有指定 key,爲了達到最大吞吐量,生產者會以 round-robin 方式將消息發佈到所有分區。 請注意round-robin並不是作用於每條單獨的消息,而是作用於延遲處理的批次邊界,以確保批處理有效。 如果消息指定了key,分區生產者會根據key的hash值將該消息分配到對應的分區。 這是默認的模式。
  2. SinglePartition:如果消息沒有指定 key,生產者將會隨機選擇一個分區,併發布所有消息到這個分區。 如果消息指定了key,分區生產者會根據key的hash值將該消息分配到對應的分區。
  3. CustomPartition:使用自定義消息路由器實現來決定特定消息的分區。 用戶可以創建自定義路由模式:使用 Java client 並實現MessageRouter 接口。

順序保證:

  消息的順序與MessageRoutingMode和Message Key相關。通常,用戶希望每個鍵分區保證的順序。

  當使用 SinglePartition或者RoundRobinPartition模式時,如果消息有key,消息將會被路由到匹配的分區,這是基於ProducerBuilder 中HashingScheme 指定的散列shema。

  1. 按鍵分區:所有具有相同 key 的消息將按順序排列並放置在相同的分區(Partition)中。使用 SinglePartition 或 RoundRobinPartition 模式,每條消息都需要有key。
  2. 生產者排序:來自同一生產者的所有消息都是有序的路由策略爲SinglePartition, 且每條消息都沒有key。

散列scheme:

       HashingScheme 是代表一組標準散列函數的枚舉。爲一個指定消息選擇分區時使用。

  有兩種可用的散列函數: JavaStringHash 和Murmur3_32Hash. The default hashing function for producer is JavaStringHash. 請注意,當producer可能來自於不同語言客戶端時,JavaStringHash是不起作用的。建議使用Murmur3_32Hash

非持久topic:

  默認情況下,Pulsar將所有未確認的消息持久地存儲在多個BookKeeper bookies (存儲節點)上. 因此,持久性主題上的消息數據可以在 broker 重啓和訂閱者故障轉移之後繼續存在。不過,Pulsar也支持非持久主題,即消息永遠不會持久化到磁盤上,而只存在於內存中. Pulsar也提供了非持久topic。非持久topic的消息不會被保存在硬盤上,只存活於內存中。當使用非持久topic分發時,殺掉Pulsar的broker或者關閉訂閱者,此topic( non-persistent))上所有的瞬時消息都會丟失,意味着客戶端可能會遇到消息缺失。非持久性主題具有這種形式的名稱(注意名稱中的 non-persistent):

  非持久topic,消息數據僅存活在內存。 如果broker掛掉或者因其他情況不能從內存取到,你的消息數據就可能丟失。

  默認非持久topic在broker上是開啓的。 你可以通過broker的配置關閉。

消息保留和過期:

  Pulsar broker默認如下:

  1. 立即刪除所有已被消費者確認的消息
  2. 以消息backlog的形式,持久保存所有的未被確認消息

  Pulsar有兩個特性,讓你可以覆蓋上面的默認行爲。

  1. 消息保留使您能夠存儲已被消費者確認的消息
  2. 消息過期使您能夠爲尚未得到確認的消息設置生存時間(TTL)

  所有消息保留和過期都在名稱空間級別進行管理。

  圖中上面的是消息存留,存留規則會被用於某namespace下所有的topic,指明哪些消息會被持久存儲,即使已經被確認過。 沒有被留存規則覆蓋的消息將會被刪除。 如果沒有保留策略,所有已確認的消息將被刪除。

  圖中下面的是消息過期,有些消息即使還沒有被確認,也被刪除掉了。因爲根據設置在namespace上的TTL,他們已經過期了。(例如,TTL爲5分鐘,過了十分鐘消息還沒被確認)

消息去重:

  參考手冊:https://pulsar.apache.org/docs/zh-CN/cookbooks-deduplication/

  消息去重保證了一條消息只能在 Pulsar 服務端被持久化一次。 消息去重是一個 Pulsar 可選的特性,它能夠阻止不必要的消息重複,它保證了即使消息被消費了多次,也只會被保存一次。

  下圖展示了開啓和關閉消息去重的場景:

生產者冪等:

  The other available approach to message deduplication is to ensure that each message is only produced once. This approach is typically called producer idempotency. 這種方式的缺點是,把消息去重的工作推給了應用去做。 在 Pulsar 中,消息去重是在 broker上處理的,用戶不需要去修改客戶端的代碼。 相反,你只需要通過修改配置就可以實現。

  去重和實際一次語義:消息去重,使 Pulsar 成爲了流處理引擎(SPE)或者其他尋求 "僅僅一次" 語義的連接系統所需的理想消息系統。 如果消息系統沒有提供自動去重能力,那麼 SPE (流處理引擎) 或者其他連接系統就必須自己實現去重語義,這意味着需要應用去承擔這部分的去重工作。 使用Pulsar,嚴格的順序保證不會帶來任何應用層面的代價。

消息延遲傳遞:

  延時消息功能允許你能夠過一段時間才能消費到這條消息,而不是消息發佈後,就馬上可以消費到。 在這種機制中,消息存儲在BookKeeper中,DelayedDeliveryTracker在發佈到broker之後維護內存中的時間索引(time -> messageId),一旦經過了特定的延遲時間,它就被傳遞給消費者。

  延遲消息傳遞僅在共享訂閱模式下有效。在Exclusive和Failover訂閱模式中,將立即分發延遲的消息。

  如下圖所示,說明了延時消息的實現機制:

  

  Broker 保存消息是不經過任何檢查的。 當消費者消費一條消息時,如果這條消息是延時消息,那麼這條消息會被加入到DelayedDeliveryTracker當中。 訂閱檢查機制會從DelayedDeliveryTracker獲取到超時的消息,並交付給消費者。

  默認情況下啓用延遲消息傳遞。你可以在代理配置文件中修改它,如下所示:

# Whether to enable the delayed delivery for messages.
# If disabled, messages are immediately delivered and there is no tracking overhead.
delayedDeliveryEnabled=true

# Control the ticking time for the retry of delayed message delivery,
# affecting the accuracy of the delivery time compared to the scheduled time.
# Default is 1 second.
delayedDeliveryTickTimeMillis=1000

  下面是 Java 當中生產延時消息一個例子:

producer.newMessage().deliverAfter(3L, TimeUnit.Minute).value("Hello Pulsar!").send();
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章