組通信之jgroups篇----Building Blocks

Building blocks are layered on top of channels. Most of them do not even need a channel, all they need is a class that implements interface Transport (channels do). This enables them to work on any type of group transport that obeys this interface. Building blocks can be used instead of channels whenever a higher-level interface is required. Whereas channels are simple socket-like constructs, building blocks may offer a far more sophisticated interface. In some cases, building blocks offer access to the underlying channel, so that -- if the building block at hand does not offer a certain functionality -- the channel can be accessed directly. Building blocks are located in the org.jgroups.blocks package. Only the ones that are relevant for application programmers are discussed below.

 

 

Building blocks位於channel上層.他們中的大部分甚至不需要channel.他們都需要的是一個Transport接口的實現(channel也是Transport接口的實現).所以他們可以位於任何遵循此接口的組通信.channel是一個簡單的類似套接字的結構,而Building blocks則提供了更多複雜的接口.有些Building blocks可以直接使用底層的channel.Building blocks在包org.jgroups.blocks中.下面討論的都將是應用程序開發者將會使用到的.


 

4.1. PullPushAdapter
Note that this building block has been deprecated and should not be used anymore !

This class is a converter (or adapter, as used in [Gamma:1995] between the pull-style of actively receiving messages from the channel and the push-style where clients register a callback which is invoked whenever a message has been received. Clients of a channel do not have to allocate a separate thread for message reception.

A PullPushAdapter is always created on top of a class that implements interface Transport (e.g. a channel). Clients interested in being called when a message is received can register with the PullPushAdapter using method setListener(). They have to implement interface MessageListener, whose receive() method will be called when a message arrives. When a client is interested in getting view, suspicion messages and blocks, then it must additionally register as a MembershipListener using method setMembershipListener(). Whenever a view, suspicion or block is received, the corresponding method will be called.

Upon creation, an instance of PullPushAdapter creates a thread which constantly calls the receive() method of the underlying Transport instance, blocking until a message is available. When a message is received, if there is a registered message listener, its receive() method will be called.

As this class does not implement interface Transport, but merely uses it for receiving messages, an underlying object has to be used to send messages (e.g. the channel on top of which an object of this class resides). This is shown in Figure 4.1, “Class PullPushAdapter”.

As is shown, the thread constantly pulls messages from the channel and forwards them to the registered listeners. An application thus does not have to actively pull for messages, but the PullPushAdapter does this for it. Note however, that the application has to directly access the channel if it wants to send a message.

 

注意:此building block已經被移除並將不再使用.

該類是一個轉換器或適配器,用於將從channel拿消息的pull模式轉換爲通過註冊回調函數,當有消息接收到時被調用的push模式.這樣,客戶端就不需要專門線程去接收消息了

PullPushAdapter創建一般都需要Transport接口的實現類(如channel).客戶端可以通過PullPushAdapter的setListener函數去註冊.他們需要實現MessageListener接口,這樣,當有消息到達時,其實現類的receive函數將被調用.當然,如果客戶端對視圖,懷疑消息,阻塞消息有興趣,同樣可以通過setMembershipListener函數註冊MembershipListener實現類,當有相關消息接收到時,相應的方法就會被調用.

具體實現是,PullPushAdapter實例將創建一個線程專門調用底層Transport實例的receive函數,阻塞的接收消息.當有消息到達且註冊有消息監聽器,則註冊的receive函數將被調用.

因爲此類沒有實現Transport接口,而僅僅是用於接收消息,所以,得用其底層對象去發送消息(如channel)(個人認爲:該類已經實現了發送函數).

此類有專門的線程用於從channel中pull出消息並將傳給已註冊的監聽器.所以應用程序纔不用自己去pull消息.(後面這句應用程序需通過channel發送消息是正確的,但其意思是必須直接使用channel發送是不對的,如上所說,該類自己其實也有發送函數).

 

4.1.1. Example
This section shows sample code for using a PullPushAdapter. The example has been shortened for readability (error handling has been removed).

    public class PullPushTest implements MessageListener {
        Channel          channel;
        PullPushAdapter  adapter;
        byte[]           data="Hello world".getBytes();
        String           props; // fetch properties

        public void receive(Message msg) {
            System.out.println("Received msg: " + msg);
        }

        public void start() throws Exception {
            channel=new JChannel(props);
            channel.connect("PullPushTest");
            adapter=new PullPushAdapter(channel);
            adapter.setListener(this);

            for(int i=0; i < 10; i++) {
                System.out.println("Sending msg #" + i);
                channel.send(new Message(null, null, data));
                Thread.currentThread().sleep(1000);
            }
            adapter.stop();
            channel.close();
        }
        public static void main(String args[]) {
            try {
                new PullPushTest().start();
            }
            catch(Exception e) { /* error */ }
        }
    } 
First a channel is created and connected to. Then an instance of PullPushAdapter is created with the channel as argument. The constructor of PullPushAdapter starts its own thread which continually reads on the channel. Then the MessageListener is set, which causes all messages received on the channel to be sent to receive(). Then a number of messages are sent via the channel to the entire group. As group messages are also received by the sender, the receive() method will be called every time a message is received. Finally the PullPushAdapter is stopped and the channel closed. Note that explicitly stopping the PullPushAdapter is not actually necessary, a closing the channel would cause the PullPushAdapter to terminate anyway.Note that, compared to the pull-style example, push-style message reception is considerably easier (no separate thread management) and requires less code to program.

Note
The PullPushAdapter has been deprecated, and will be removed in 3.0. Use a Receiver implementation instead. The advantage of the Receiver-based (push) model is that we save 1 thread.

首先,channel被創建並連接.然後, PullPushAdapter對象被創建,以channel爲參數. PullPushAdapter啓動自己的線程去接收消息.然後設置MessageListener,它可以將接收到的消息轉到監聽器的receive函數.然後,一系列消息將經過channel發送到整個組.由於組內消息也會被髮送端自己接收,所以,自己的receive接口也會被調用.最後, PullPushAdapter被停止並且關閉channel.

注意: PullPushAdapter已被移除,3.0版本將不再使用.而是使用Receiver類的實現代替.其好處是可以繼續節省一個線程.(具體可以參考上一章

jgroups之JChannel的3.6.10. Using a Receiver to receive messages).

 

 

4.2. MessageDispatcher

Channels are simple patterns to asynchronously send a receive messages. However, a significant number of communication patterns in group communication require synchronous communication. For example, a sender would like to send a message to the group and wait for all responses. Or another application would like to send a message to the group and wait only until the majority of the receivers have sent a response, or until a timeout occurred.

MessageDispatcher offers a combination of the above pattern with other patterns. It provides synchronous (as well as asynchronous) message sending with request-response correlation, e.g. matching responses with the original request. It also offers push-style message reception (by internally using the PullPushAdapter).

An instance of MessageDispatcher is created with a channel as argument. It can now be used in both client and server role: a client sends requests and receives responses and a server receives requests and send responses. MessageDispatcher allows a application to be both at the same time. To be able to serve requests, the RequestHandler.handle() method has to be implemented:
        Object handle(Message msg);

The handle() method is called any time a request is received. It must return a return value (must be serializable, but can be null) or throw an exception. The return value will be returned to the sender (as a null response, see below). The exception will also be propagated to the requester.

The two methods to send requests are:
        public RspList castMessage(Vector dests, Message msg, int mode, long timeout);
        public Object sendMessage(Message msg, int mode, long timeout) throws TimeoutException;

The castMessage() method sends a message to all members defined in dests. If dests is null the message will be sent to all members of the current group. Note that a possible destination set in the message will be overridden. If a message is sent synchronously then the timeout argument defines the maximum amount of time in milliseconds to wait for the responses.

The mode parameter defines whether the message will be sent synchronously or asynchronously. The following values are valid (from org.jgroups.blocks.GroupRequest):

GET_FIRST

Returns the first response received.

GET_ALL

Waits for all responses (minus the ones from suspected members)

GET_MAJORITY

Waits for a majority of all responses (relative to the group size)

GET_ABS_MAJORITY

Waits for the majority (absolute, computed once)

GET_N

Wait for n responses (may block if n > group size)

GET_NONE

Wait for no responses, return immediately (non-blocking). This make the call asynchronous.

The sendMessage() method allows an application programmer to send a unicast message to a receiver and optionally receive the response. The destination of the message has to be non-null (valid address of a receiver). The mode argument is ignored (it is by default set to GroupRequest.GET_FIRST) unless it is set to GET_NONE in which case the request becomes asynchronous, ie. we will not wait for the response.

One advantage of using this building block is that failed members are removed from the set of expected responses. For example, when sending a message to 10 members and waiting for all responses, and 2 members crash before being able to send a response, the call will return with 8 valid responses and 2 marked as failed. The return value of castMessage() is a RspList which contains all responses (not all methods shown):

    public class RspList {

        public boolean isReceived(Address sender);

        public int     numSuspectedMembers();

        public Vector  getResults();

        public Vector  getSuspectedMembers();

        public boolean isSuspected(Address sender);

        public Object  get(Address sender);

        public int     size();

        public Object  elementAt(int i) throws ArrayIndexOutOfBoundsException;

    }  

Method isReceived() checks whether a response from sender has already been received. Note that this is only true as long as no response has yet been received, and the member has not been marked as failed. numSuspectedMembers() returns the number of members that failed (e.g. crashed) during the wait for responses. getResults() returns a list of return values. get() returns the return value for a specific member.

 

channel是簡單的異步發送接收模式,但是,很大部分的組通信需要同步通信.比如,發送端可能需要發送一個消息到組並等待所有迴應,或者發送端應用程序發送消息到組並等待主要接收端返回或直到超時產生.

MessageDispatcher提供了上面模式的實現,它能實現同步消息發送接收(也能實現異步模式),並能正確對應請求-迴應關係.它同樣提供了push模式的消息接收方法(通過使用PullPushAdapter).

MessageDispatcher實例的創建以channel爲參數.它同時可以用於客戶端和服務器端.客戶端發送請求並接收響應,服務器端接收請求併發送響應.MessageDispatcher允許應用程序同時既是發送端也是服務端.我們必須實現RequestHandler.handle方法以處理請求.

handle方法在有請求被接收時調用,它需要返回一個值(需序列化,可以爲空)或者拋出異常.該返回值將會返回給發送端,出錯同樣會被髮送給請求者.

有兩種方法發送請求:castMessage和sendMessage.

castMessage函數發送消息到定義的發送端的所有成員.如果發送端爲空,則消息將發送到當前組所有成員.如果消息同步發送則超時參數指等待響應的毫秒數.模式參數不區分消息是同步還是異步發送,其值如下:

 

GET_FIRST  只要第一個成員響應即返回

GET_ALL  等待所有成員響應

GET_MAJORITY  等待主要成員響應(跟組大小有關).

GET_ABS_MAJORITY  等待主要成員響應(絕對成員,計算結果)

GET_N 等待N個成員響應(如果N>組大小數,則會阻塞).

GET_NONE 不等待任何成員,立即返回,異步調用.

sendMessage方法允許單播一個消息到接收端,並可選擇是否等待迴應.消息的發送端不能爲空,模式參數忽略除非是GET_NONE,否則它表示不等待任何消息,爲異步方式.

 

此building block有一個好處是失敗成員將會從期望的響應者中去除.比如,發送消息給10個成員,有2個成員異常不能發送響應,則返回8個有效的響應,那2個標誌爲失敗. castMessage的返回值爲RspList,它包含所有響應成員.

isReceived方法用於檢查發送端發出的響應消息是否被接收到,如果沒有響應消息被接收到且沒有成員標記爲失敗,則返回值爲真. numSuspectedMembers返回失敗的成員個數. getResults返回一個值的列表,get函數返回某個特定成員的返回值.

 

 

 

4.2.1. Example

This section describes an example of how to use a MessageDispatcher.

    public class MessageDispatcherTest implements RequestHandler {

        Channel            channel;

        MessageDispatcher  disp;

        RspList            rsp_list;

        String             props; // to be set by application programmer

        public void start() throws Exception {

            channel=new JChannel(props);

            disp=new MessageDispatcher(channel, null, null, this);

            channel.connect("MessageDispatcherTestGroup");

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

                Util.sleep(100);

                System.out.println("Casting message #" + i);

                rsp_list=disp.castMessage(null,

                    new Message(null, null, new String("Number #" + i)),

                    GroupRequest.GET_ALL, 0);

                System.out.println("Responses:/n" +rsp_list);

            }

            channel.close();

            disp.stop();

        }

        public Object handle(Message msg) {

            System.out.println("handle(): " + msg);

            return new String("Success !");

        }

 

        public static void main(String[] args) {

            try {

                new MessageDispatcherTest().start();

            }

            catch(Exception e) {

                System.err.println(e);

            }

        }

    }    

The example starts with the creation of a channel. Next, an instance of MessageDispatcher is created on top of the channel. Then the channel is connected. The MessageDispatcher will from now on send requests, receive matching responses (client role) and receive requests and send responses (server role).

We then send 10 messages to the group and wait for all responses. The timeout argument is 0, which causes the call to block until all responses have been received.

The handle() method simply prints out a message and returns a string.

Finally both the MessageDispatcher and channel are closed.

 

下面的例子說明了如何使用MessageDispatcher.

首先,創建channel.然後, MessageDispatcher實例基於channel被創建.再連接channel. MessageDispatcher現在將可用來發送請求,接收相匹配的迴應(客戶端)或者接收請求發送響應(服務器角色).

然後我們發送10個消息到組並等待所有迴應.超時爲0表示阻塞知道所有響應都被接收.

handle函數僅僅打印出接收到的消息.

然後,關閉MessageDispatcher和channel.

 

 

4.3. RpcDispatcher

This class is derived from MessageDispatcher. It allows a programmer to invoke remote methods in all (or single) group members and optionally wait for the return value(s). An application will typically create a channel and layer the RpcDispatcher building block on top of it, which allows it to dispatch remote methods (client role) and at the same time be called by other members (server role).

Compared to MessageDispatcher, no handle() method needs to be implemented. Instead the methods to be called can be placed directly in the class using regular method definitions (see example below). The invoke remote method calls (unicast and multicast) the following methods are used (not all methods shown):

    public RspList callRemoteMethods(Vector dests, String method_name, int mode, long timeout);

    public RspList callRemoteMethods(Vector dests, String method_name, Object arg1, int mode, long timeout);

    public Object callRemoteMethod(Address dest, String method_name,int mode, long timeout);

    public Object callRemoteMethod(Address dest, String method_name, Object arg1, int mode, long timeout);

The family of callRemoteMethods() is invoked with a list of receiver addresses. If null, the method will be invoked in all group members (including the sender). Each call takes the name of the method to be invoked and the mode and timeout parameters, which are the same as for MessageDispatcher. Additionally, each method takes zero or more parameters: there are callRemoteMethods() methods with up to 3 arguments. As shown in the example above, the first 2 methods take zero and one parameters respectively.

The family of callRemoteMethod() methods takes almost the same parameters, except that there is only one destination address instead of a list. If the dest argument is null, the call will fail.

If a sender needs to use more than 3 arguments, it can use the generic versions of callRemoteMethod() and callRemoteMethods() which use a MethodCall instance rather than explicit arguments.

Java's Reflection API is used to find the correct method in the receiver according to the method name and number and types of supplied arguments. There is a runtime exception if a method cannot be resolved.

(* Update: these methods are deprecated; must use MethodCall argument now *)

 

此類繼承自MessageDispatcher.它允許所有成員(或一個)調用遠程方法,並可選擇是否等待返回值.應用程序一般需創建channel然後基於此實現RpcDispatcher building block.它允許發送遠程消息(客戶端角色)或被其他成員調用某函數(服務器角色).

與MessageDispatcher不同,這裏沒有handle方法需要實現.要被調用的方法可以直接在此類的一些函數中定義,有如下函數可以實現遠程函數調用.

callRemoteMethods方法會被列表中的接收地址響應,如果該列表爲空,被調用方法將被所有成員響應(包含發送端).每個方法包含方法名,模式和超時參數,這個跟MessageDispatcher相同.

callRemoteMethod方法和callRemoteMethods方法參數類似(此處介紹函數參數缺少了Class[] types),只不過此函數的目的地是一個地址而不是一個列表,且不能爲空.

如果參數太多,可以使用MethodCall參數.

JAVA映射API用於找到接收端的正確方法,通過參數名和參數類型.如果沒有相應函數則有運行時異常產生.

(現在這些方法也被移除,要使用MethodCall參數)

 

4.3.1. Example

The code below shows an example:

    public class RpcDispatcherTest {

        Channel            channel;

        RpcDispatcher      disp;

        RspList            rsp_list;

        String             props; // set by application

        public int print(int number) throws Exception {

            return number * 2;

        }

        public void start() throws Exception {

            channel=new JChannel(props);

            disp=new RpcDispatcher(channel, null, null, this);

            channel.connect("RpcDispatcherTestGroup");

 

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

                Util.sleep(100);

                rsp_list=disp.callRemoteMethods(null, "print",

                     new Integer(i), GroupRequest.GET_ALL, 0);

                System.out.println("Responses: " +rsp_list);

            }

            channel.close();

            disp.stop();

         }

        public static void main(String[] args) {

            try {

                new RpcDispatcherTest().start();

            }

            catch(Exception e) {

                System.err.println(e);

            }

        }

    }

Class RpcDispatcher defines method print() which will be called subsequently. The entry point start() method creates a channel and an RpcDispatcher which is layered on top. Method callRemoteMethods() then invokes the remote print() method in all group members (also in the caller). When all responses have been received, the call returns and the responses are printed.

As can be seen, the RpcDispatcher building block reduces the amount of code that needs to be written to implement RPC-based group communication applications by providing a higher abstraction level between the application and the primitive channels.

 

RpcDispatcher類定義了print函數,此函數將被調用.入口函數start創建了channel和RpcDispatcher對象,RpcDispatcher位於channel之上.callRemoteMethods方法將調用遠程的print函數,當所有響應消息均被接收到時,此方法返回.

可以看到,RpcDispatcher building block提供了更高的抽象層,減少了代碼實現基於組通信的RPC調用.

 

4.4. ReplicatedHashMap

This class was written as a demo of how state can be shared between nodes of a cluster. It has never been heavily tested and is therefore not meant to be used in production, and unsupported.

A ReplicatedHashMap uses a concurrent hashmap internally and allows to create several instances of hashmaps in different processes. All of these instances have exactly the same state at all times. When creating such an instance, a group name determines which group of replicated hashmaps will be joined. The new instance will then query the state from existing members and update itself before starting to service requests. If there are no existing members, it will simply start with an empty state.

Modifications such as put(), clear() or remove() will be propagated in orderly fashion to all replicas. Read-only requests such as get() will only be sent to the local copy.

Since both keys and values of a hashtable will be sent across the network, both of them have to be serializable. This allows for example to register remote RMI objects with any local instance of a hashtable, which can subsequently be looked up by another process which can then invoke remote methods (remote RMI objects are serializable). Thus, a distributed naming and registration service can be built in just a couple of lines.

A ReplicatedHashMap allows to register for notifications, e.g. when a new item is set, or an existing one removed. All registered listeners will notified when such an event occurs. Notification is always local; for example in the case of removing an element, first the element is removed in all replicas, which then notify their listener(s) of the removal (after the fact).

ReplicatedHashMap allow members in a group to share common state across process and machine boundaries.

 

此類用於在組內節點共享狀態信息.該類沒有經過嚴格測試,不推薦在產品上使用,缺乏支持.

ReplicatedHashMap內部使用了併發hashmap,允許在不同進程創建多個hashmap實例.這些實例一直保持相同的狀態信息.但創建某個實例時,組名決定它加入共享哪個hashmap,該實例在啓動服務前將從已存在成員獲取狀態信息.如果此時還沒有成員,則它以空狀態啓動.

put,clear,remove等消息方法將被順序的傳遞到所有成員中,get等只讀方法將拷貝給本地使用.

由於hashtable的鍵值需要網絡傳輸,則他們需被序列化.它允許以本地實例的hashtable去註冊遠程的RMI對象,隨後被另一進程得到並調用遠程方法(具體意思可以理解爲註冊遠程對象,其和本地對象結構是一致的,然後其他成員會被響應而調用具體方法).於是,一個分佈式的命名和註冊服務只需幾行即可實現了.

ReplicatedHashMap還可以註冊事件通知事件,當有成員加入或退出時,所有註冊的監聽器都將獲得通知,通知一般是本地事件.比如,當有成員退組時,首先,該成員會在所有成員中被移除出組,然後才通知本地的監聽器該移除動作已發生.

ReplicatedHashMap的作用就是允許組內成員共享通用數據.

(此類集合了以前的ReplicatedHashtable異步共享數據和DistributedHashtable同步共享數據的功能).

 

4.5. NotificationBus
This class provides notification sending and handling capability. Also, it allows an application programmer to maintain a local cache which is replicated by all instances. NotificationBus also sits on top of a channel, however it creates its channel itself, so the application programmers do not have to provide their own channel. Notification consumers can subscribe to receive notifications by calling setConsumer() and implementing interface NotificationBus.Consumer:

    public interface Consumer {
        void          handleNotification(Serializable n);
        Serializable  getCache();
        void          memberJoined(Address mbr);
        void          memberLeft(Address mbr);
    }
Method handleNotification() is called whenever a notification is received from the channel. A notification is any object that is serializable. Method getCache() is called when someone wants to retrieve our state; the state can be returned as a serializable object. The memberJoined() and memberLeft() callbacks are invoked whenever a member joins or leaves (or crashes).

The most important methods of NotificationBus are:

    public class NotificationBus {
         public void setConsumer(Consumer c);
         public void start() throws Exception;
         public void stop();
         public void sendNotification(Serializable n);
         public Serializable getCacheFromCoordinator(long timeout, int max_tries);
         public Serializable getCacheFromMember(Address mbr, long timeout, int max_tries);
    } 
Method setConsumer() allows a consumer to register itself for notifications.

The start() and stop() methods start and stop the NotificationBus.

Method sendNotification() sends the serializable object given as argument to all members of the group, invoking their handleNotification() methods on reception.

Methods getCacheFromCoordinator() and getCacheFromMember() provide functionality to fetch the group state from the coordinator (first member in membership list) or any other member (if its address is known). They take as arguments a timeout and a maximum number of unsuccessful attempts until they return null. Typically one of these methods would be called just after creating a new NotificationBus to acquire the group state. Note that if these methods are used, then the consumers must implement Consumer.getCache(), otherwise the two methods above would always return null.

 

此類提供了一個發送和接收通知的功能.它也能允許一個應用程序編程者維護本地cache數據到組內所有成員.NotificationBus同樣基於channel之上,但它自己內部創建channel,不需要我們自己提供channel作參數,通過setConsumer函數設置Notification consumer,我們需要實現其接口,這樣,我們就可以在有通知事件到來時被響應了.

當從channel接收到數據時,handleNotification函數被調用,通知對象是被序列化的.當有成員需要獲取狀態時,getCache函數被調用.當有成員加入或離開時,memberJoined和memberLeft方法會被調用.

NotificationBus的主要方法如下:

setConsumer方法允許註冊consumer接收通知事件.

start和stop方法啓動和停止NotificationBus.

sendNotification方法以一個序列化對象爲參數發送給所有成員,接收端handleNotification方法會被響應.

getCacheFromCoordinator和getCacheFromMember方法提供了從主(第一個成員)或其他成員獲取組狀態功能.它提供了超時和嘗試次數參數.它主要用於在創建NotificationBus後獲取組狀態,如果使用該方法,我們必須實現Consumer.getCache接口.

 

總結:
PullPushAdapter主要提供了一個專門的接收線程,從而將pull模式轉換爲push模式.但其接收過程本質上還是阻塞式的,消息到達時先放入隊列,然後接收線程從隊列拿數據,再通知已註冊的MessageListener.

Receiver本文未細說,但這是取代PullPushAdapter的接口.它通過setReceiver函數在Channel層註冊自己,這樣,我們就不需要維護隊列,有消息到來時,Receiver接口的實現類相關函數會被直接調用,所以,它同時也節省了PullPushAdapter的接收線程.

MessageDispatcher是用於實現同步消息發送,它需要等待此消息是否被各成員接收.它通過setUpHandler函數在Channel層註冊自己.它能區分別的成員發送的消息和它需要的迴應消息等.
RpcDispatcher主要用於遠程RPC方法調用,繼承自MessageDispatcher類。同樣是需要等待結果。同樣在Channel層註冊自己。
ReplicatedHashMap主要維護了一個組的共享的通用數據.
NotificationBus則提供了更抽象的一層,它已經不需要用戶關心channel等底層.它能維護組的一個共享cache(可以是組狀態,組共享數據),同時,它也能完成組的消息和事件通知機制.

 

發佈了38 篇原創文章 · 獲贊 10 · 訪問量 17萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章