Pattern-Oriented Software Architecture v1巨詳細讀書筆記 8
上個筆記中,已經描述了Forwarder-Receiver模式的例子,及所需要解決的問題,所處的上下文環境,還有它的典型場景,那麼在實際設計過程中,是如何來實現Forwarder-Receiver模式,而應用這種實現方式後我們前面所舉的例子最終會變成什麼樣子?這些都能夠在這次的筆記中找到相關的解答。
本筆記是《Pattern-Oriented Software Architecture vol.1 A system of patterns》原書[page 313-321]的山寨翻譯:),包括了Forwarder-Receiver模式的後半部分,主要是[實現]小節,以及[例子解決方案]和[變體]。
-----------------------------------------------------
[page 313]
[實現]
通過迭代以下步驟可以實現Forwarder-Receiver設計模式:
1 描述名字-地址映射。既然peer通過名字引用其他的peer,則需要引入適當的命名空間,命名空間定義在給定的上下文中名字必須遵循的規則和限制。例如:可以指定所有名字必須由15個字符組成,並且必須是由大寫字符打頭,像“PeerVideoServer”就是一個符合這種規則的合法名字;又例如:也許需要用UNIX格式的路徑名稱來表示結構化的名字,如,“/Server/VideoServer/AVIServer”。
一個名字不一定只指向單個地址,也許指向的是一組地址。當peer給遠端發送一條帶着組名稱的消息時,消息將被髮送給組內的每個成員。甚至你也可以引入層次結構,這樣就能允許一組成爲另一組的成員。
2 描述Peer和forwarder之間的消息協議,此協議定義了forwarder從它的peer接收到的信息數據結構細節。同樣也需要定義Receiver和peer之間的消息協議。
我們的例子DwarfWare精簡了消息協議,它既沒有包括錯誤處理,也沒有包括如數據分包之類的通信細節。在調用forwarder時,Peer傳遞的是類Message的對象。在peer接收消息時,它的receiver也是返回了一個Message對象給它。在此例子中消息只包含了unicode字符串格式的sender和消息數據,沒有包含接收端的名字,因爲sender將名字作爲了一個額外的參數傳遞給forwarder,這就能允許將同一條消息發送給多個不同的接收端。
class Message {
public String sender;
public String data;
public Message(String thesender, String rawData) {
sender = thesender;
data = rawData;
}
}
[page 314]
我們也需要forwarder和遠程peer的receiver之間的協議,從forwarder發送給遠程receiver的消息也包含了sender的名字。
每條消息都是用一串byte傳輸的,其中前4個byte指定消息的總長度,後續字節包含了消息的sender和消息數據本身。
通常也需要應付系統超時,如:爲了避免整個系統在receiver接收響應消息失敗時阻塞,peer爲forwarder和receiver指定超時時間;超時時間也可以由用戶在運行時指定;或者也可以在實現forwarder和receiver時就在內部指定超時時間。
當然還需要考慮到當通信失敗時,forwarder和receiver該怎麼做。根據應用程序的需求和底層IPC機制的不同,他們可以多次發送或接收消息,也可以在第一次嘗試通信失敗時就立即報告異常。
3 選擇一種通信機制。這主要是由你所使用的操作系統中可用的通信機制所決定的,在指定IPC設施時以下方面需要考慮到:
- 如果效率比較重要,首選如TCP/IP[Ste90]這樣的底層機制,這樣的機制是非常高效的,並且採用這樣的機制構建的通信協議也將非常靈活。
- 採用像TCP/IP這樣的底層機制,實現時要付出很大努力,且依賴於你所使用的平臺,限制了可移植性。如果你的系統必須在平臺間移植,最好是採用像socket這樣的IPC機制,socket在大多數平臺都可用並且對於大多數應用程序來說都足夠高效。
在DwarfWare中我們決定採用socket作爲底層通信協議。
[page 315]
4 實現forwarder。在forwarder中封裝所有跨進程邊界的消息發送功能,封裝特定IPC機制的細節,通過公共接口對外提供功能。
定義一個名字到物理地址的映射倉庫,forwarder在和遠程peer建立通信連接前訪問此倉庫獲得接收端的物理地址。此倉庫可以是預先確定的靜態表,也可以是運行時可以更改的動態表。動態表允許系統從表中動態地添加、移動或刪除peer項。確定每個forwarder是否需要擁有自己的私有映射倉庫,又或者所有的forwarder採用位於他們同一個進程中的公共映射倉庫。前一種情況允許你將同一個名字映射到不同的物理位置。例如:一個Peer能將名字‘Printer’關聯到多個不同peer的物理地址。你所使用的IPC機制決定了物理地址的結構,例如:如果用socket實現通信,則receiver的物理地址由Iternet地址和socket端口組成。可以使用hash表實現此倉庫。
在我們的例子中,forwarder使用Registry作爲倉庫類來映射名稱-地址,此倉庫採用了標準java類庫的hash表來管理所有地址映射。遠程peer的物理地址是指目的機器名和socket端口號的組合,類Entry因而包含兩個數據成員:destinationID(目的機器名)和portNr(遠程peer的socket端口號)。倉庫類的實現中會將字符串映射到一個Entry類的實例:
class Entry {
private String destinationId; // target machine
private int portNr; // socket port
public EntryCString theDest, int theport) {
destinationId = theDest;
portNr = theport;
{
public String dest() {
return destinationId;
}
[page 316]
public int port() {
return portNr;
}
}
class Registry (
private Hashtable hTable = new Hashtable();
public void put(String theKey, Entry theEntry) {
hTable.put (theKey, theEntry) ;
}
public Entry get(String aKey) {
return (Entry) hTable.get (theKey) ;
}
}
下面引入Forwarder類,它的構造函數有個名爲theName的字符串參數,表示peer的邏輯名稱。當peer調用sendMsg時將發生以下事情:
- sendMsg調用mashal將消息theMsg變成一串byte數據。
- 調用deliver,此方法在本地倉庫中查找theDest的遠程peer的物理位置。
爲了完成這些動作,全局類fr中的fr.reg存有一個映射倉庫實例;deliver將打開socket端口,連接到遠程peer,傳送消息,並關閉socket。
class Forwarder {
private Socket s;
private Outputstream oStr;
private String myName;
public Forwarder(String theName) { myName = theName;}
private byte [] marshal (Message theMsg) { / * . . . */ }
private void deliver(String theDest, byte[] data) {
try (Entry entry = fr.reg.get(theDest);
s = new Socket(entry.dest() ,entry.port());
oStr = s.getOutputStream() ;
oStr.write (data) ;
oStr.flush();
oStr.close();
s.close();
}
catch(I0Exception e) { /* . . . * / }
}
public void sendMsg(String theDest, Message theMsg) {
deliver(theDest, marshal(theMsg)):
}
}
[page 317]
將forwarder的職責(如:編碼,消息發送,映射倉庫)分離開是很有用的,所有功能都可分解到具體的IPC機制。可以採用Whole-part設計模式將forwarder的職責封裝在其分離的part組件中。
5 實現receiver。將所有接收IPC消息的功能都封裝到receiver中,包含接收和解碼IPC消息的功能,(??Provide the receiver with a general interface that abstracts from details of a particular IPC mechanism.)給receiver提供從特定IPC機制細節中抽象出的通用接口。可以像第4步一樣,採用whole-part設計模式將這些receiver的職責封裝到分離的part組件中。
設計receiver時特別需要考慮2個方面的問題。
1 既然所有的peer都是以異步方式運行的,那麼就需要決定receiver是否應該阻塞,直到有消息到達:
- 如果這樣,receiver會一直等待,直到有消息輸入時纔將控制權交還給peer,換句話說,peer在成功接收到消息之前不能繼續執行。當peer後續操作依賴輸入的消息才能完成的情況下,此情況是比較合適的。
- 如果不這樣,就需實現非阻塞方式的receiver,允許peer指定超時時間(參見第2步)。如果在指定的時間範圍內沒有消息到達,receiver將返回一個異常給它的peer。
如果底層IPC機制不支持非阻塞I/O,那需要在peer中使用單獨的線程來處理通信。
2 另一個需要考慮的問題是,在receiver中使用多個通信通道。這種receiver能對多個通信通道進行多路分解(demultiplexing)——它會等待其中一個通道有數據到達,並在數據到達後將其返回給它的peer,如果同時有多個消息到達,則receiver可用一個內部消息隊列緩存這些消息。多路分解可能依賴於底層IPC機制,例如:UNIX系統中的select允許進程在一組文件或socket上等待事件輸入。如果IPC機制不支持多路分解,那需要你在receiver中用多線程來完成多路分解,其中每個線程負責一個通信通道。關於事件多路分解的細節可以參見Reactor模式[Sch94]。
[page 318]
在我們的例子中提供了類Receiver。在peer實例一個receiver時,它會在其構造函數中傳入自己的名稱作爲參數,receiver用這個名字來確定接收消息的socket端口號。當peer要接收消息時,它會調用Receiver對象的receiveMsg()方法,receiveMsg隨後又會調用receive()方法,receive()做了2件事情:
- 從全局的映射倉庫中獲取了socket端口後,它打開服務器的socket,並等待遠程peer連接。
- 一旦連接建立起來,到達的消息和它的大小都從通信通道中讀取出來,receive()將讀取到的數據返回給receiveMsg。
最後,receiveMsg()執行unmarshal將byte數據串轉換成Message對象並將此對象返回給peer。
class Receiver {
private Serversocket snrS;
private Socket s;
private Inputstream iStr;
private String myName;
public Receiver(String theName) { myName = theName;}
private Message unmarshal (byte [] anArray) { /* . ., */ }
private byte[] receive() {
int val;
byte buffer [] = null;
try {
Entry entry = fr.reg.get(myName);
srvS = new ServerSocket(entry.port0, 1000);
s = srvS.accept();iStr = s.getInputStream();
val = iStr. read ( ) ; buffer = new byte [val] ;
iStr.read(buffer1 ;
iStr.close0; s.close(); srvS.close();
}
catch(I0Exception e) { /* . . . */ }
return buffer;
}
public Message receiveMsg () {
return unmarshal(receive()) ;
}
}
6 實現peer。將peer分爲client和server兩個集合,兩者之間可以有交集。扮演client角色的peer會向遠程peer發送消息並等待響應,接收到響應後,它繼續執行自己的任務。扮演server角色的peer一直會等待有消息輸入,當消息到達時,它會執行此消息所請求的服務,並將響應發送給請求者。server有可能是別的server的client,甚至server和client會在運行時動態地變換他們之間的角色。
兩個peer之間的通信不一定是雙向的,有時一個peer只是發送一條消息到另一peer中,並不需要響應(單向通信)。peer發送了消息後就繼續執行它的任務,消息的接收端通過他的receiver收到消息,但是不會發送響應給發送端。可以將單向通信應用於發送端和接收端的異步通信。
這裏舉一個扮演server角色的peer的例子:
class Server extends Thread {
Receiver r;
Forwarder f;
public void run() {
Message result = null ;
r = new Receiver ( "Server") ;
result = r.receiveMsg ( ) ;
f = new Forwarder ( "Server") ;
Message msg = new Message ("Server","I am alive" ) ;
f.sendMsg(result.sender, msg);
}
}
7 實現一個啓動配置。系統啓動時,forwarder和receiver必須用有效的名字-地址映射來初始化。單獨引入此步驟,是爲了創建映射倉庫並錄入所有的名字/地址關係。採用配置的方式可以從外部文件中將對應關係讀取進倉庫中,避免在改變映射時更改源代碼。
如果系統允許不同的peer有不同的名字-地址映射,那需要讓啓動配置能根據此需求初始化不同的映射倉庫。
[page 320]
如果配置能動態改變,則需要實現一些附加功能在運行時能修改映射倉庫。
在DwarfWare例子中,實現了Configuration類,允許用戶在全局映射倉庫中註冊server和client:
class Configuration {
public Configuration ( ) {
Entry entry = new Entry ("127.0.0.1" ,1111) ;
fr.reg.put ("Client", entry);
entry = new Entry("127.0.0.1",2222);
fr.reg.put ("Server",entry );
}
}
[例子解決方案]
在我們的網絡管理基礎架構中,公共協議決定了請求、消息和響應的格式。如果agent要從遠程agent獲取如當前資源內容這樣的信息,它會發送一條消息給接收端,接收端從它的receiver收到此消息後會把請求的信息數據打進響應包,並把響應包發回給消息的發送源。當agent傳送的是一條command消息,接收端收到消息後就會解析它,並執行適當的命令,然後會告訴發動端是否能成功執行此command。所有相關信息都會用圖形界面顯示在網絡管理控制檯上,爲了增加可用性,網絡中的每臺機器都能運行網絡管理控制檯。
[變體]
沒有名字-地址映射的Forwarder-Receiver的映射。……
[page 321]
……
本筆記是《Pattern-Oriented Software Architecture vol.1 A system of patterns》原書[page 313-321]的山寨翻譯:),包括了Forwarder-Receiver模式的後半部分,主要是[實現]小節,以及[例子解決方案]和[變體]。
-----------------------------------------------------
[page 313]
[實現]
通過迭代以下步驟可以實現Forwarder-Receiver設計模式:
1 描述名字-地址映射。既然peer通過名字引用其他的peer,則需要引入適當的命名空間,命名空間定義在給定的上下文中名字必須遵循的規則和限制。例如:可以指定所有名字必須由15個字符組成,並且必須是由大寫字符打頭,像“PeerVideoServer”就是一個符合這種規則的合法名字;又例如:也許需要用UNIX格式的路徑名稱來表示結構化的名字,如,“/Server/VideoServer/AVIServer”。
一個名字不一定只指向單個地址,也許指向的是一組地址。當peer給遠端發送一條帶着組名稱的消息時,消息將被髮送給組內的每個成員。甚至你也可以引入層次結構,這樣就能允許一組成爲另一組的成員。
2 描述Peer和forwarder之間的消息協議,此協議定義了forwarder從它的peer接收到的信息數據結構細節。同樣也需要定義Receiver和peer之間的消息協議。
我們的例子DwarfWare精簡了消息協議,它既沒有包括錯誤處理,也沒有包括如數據分包之類的通信細節。在調用forwarder時,Peer傳遞的是類Message的對象。在peer接收消息時,它的receiver也是返回了一個Message對象給它。在此例子中消息只包含了unicode字符串格式的sender和消息數據,沒有包含接收端的名字,因爲sender將名字作爲了一個額外的參數傳遞給forwarder,這就能允許將同一條消息發送給多個不同的接收端。
class Message {
public String sender;
public String data;
public Message(String thesender, String rawData) {
sender = thesender;
data = rawData;
}
}
[page 314]
我們也需要forwarder和遠程peer的receiver之間的協議,從forwarder發送給遠程receiver的消息也包含了sender的名字。
每條消息都是用一串byte傳輸的,其中前4個byte指定消息的總長度,後續字節包含了消息的sender和消息數據本身。
通常也需要應付系統超時,如:爲了避免整個系統在receiver接收響應消息失敗時阻塞,peer爲forwarder和receiver指定超時時間;超時時間也可以由用戶在運行時指定;或者也可以在實現forwarder和receiver時就在內部指定超時時間。
當然還需要考慮到當通信失敗時,forwarder和receiver該怎麼做。根據應用程序的需求和底層IPC機制的不同,他們可以多次發送或接收消息,也可以在第一次嘗試通信失敗時就立即報告異常。
3 選擇一種通信機制。這主要是由你所使用的操作系統中可用的通信機制所決定的,在指定IPC設施時以下方面需要考慮到:
- 如果效率比較重要,首選如TCP/IP[Ste90]這樣的底層機制,這樣的機制是非常高效的,並且採用這樣的機制構建的通信協議也將非常靈活。
- 採用像TCP/IP這樣的底層機制,實現時要付出很大努力,且依賴於你所使用的平臺,限制了可移植性。如果你的系統必須在平臺間移植,最好是採用像socket這樣的IPC機制,socket在大多數平臺都可用並且對於大多數應用程序來說都足夠高效。
在DwarfWare中我們決定採用socket作爲底層通信協議。
[page 315]
4 實現forwarder。在forwarder中封裝所有跨進程邊界的消息發送功能,封裝特定IPC機制的細節,通過公共接口對外提供功能。
定義一個名字到物理地址的映射倉庫,forwarder在和遠程peer建立通信連接前訪問此倉庫獲得接收端的物理地址。此倉庫可以是預先確定的靜態表,也可以是運行時可以更改的動態表。動態表允許系統從表中動態地添加、移動或刪除peer項。確定每個forwarder是否需要擁有自己的私有映射倉庫,又或者所有的forwarder採用位於他們同一個進程中的公共映射倉庫。前一種情況允許你將同一個名字映射到不同的物理位置。例如:一個Peer能將名字‘Printer’關聯到多個不同peer的物理地址。你所使用的IPC機制決定了物理地址的結構,例如:如果用socket實現通信,則receiver的物理地址由Iternet地址和socket端口組成。可以使用hash表實現此倉庫。
在我們的例子中,forwarder使用Registry作爲倉庫類來映射名稱-地址,此倉庫採用了標準java類庫的hash表來管理所有地址映射。遠程peer的物理地址是指目的機器名和socket端口號的組合,類Entry因而包含兩個數據成員:destinationID(目的機器名)和portNr(遠程peer的socket端口號)。倉庫類的實現中會將字符串映射到一個Entry類的實例:
class Entry {
private String destinationId; // target machine
private int portNr; // socket port
public EntryCString theDest, int theport) {
destinationId = theDest;
portNr = theport;
{
public String dest() {
return destinationId;
}
[page 316]
public int port() {
return portNr;
}
}
class Registry (
private Hashtable hTable = new Hashtable();
public void put(String theKey, Entry theEntry) {
hTable.put (theKey, theEntry) ;
}
public Entry get(String aKey) {
return (Entry) hTable.get (theKey) ;
}
}
下面引入Forwarder類,它的構造函數有個名爲theName的字符串參數,表示peer的邏輯名稱。當peer調用sendMsg時將發生以下事情:
- sendMsg調用mashal將消息theMsg變成一串byte數據。
- 調用deliver,此方法在本地倉庫中查找theDest的遠程peer的物理位置。
爲了完成這些動作,全局類fr中的fr.reg存有一個映射倉庫實例;deliver將打開socket端口,連接到遠程peer,傳送消息,並關閉socket。
class Forwarder {
private Socket s;
private Outputstream oStr;
private String myName;
public Forwarder(String theName) { myName = theName;}
private byte [] marshal (Message theMsg) { / * . . . */ }
private void deliver(String theDest, byte[] data) {
try (Entry entry = fr.reg.get(theDest);
s = new Socket(entry.dest() ,entry.port());
oStr = s.getOutputStream() ;
oStr.write (data) ;
oStr.flush();
oStr.close();
s.close();
}
catch(I0Exception e) { /* . . . * / }
}
public void sendMsg(String theDest, Message theMsg) {
deliver(theDest, marshal(theMsg)):
}
}
[page 317]
將forwarder的職責(如:編碼,消息發送,映射倉庫)分離開是很有用的,所有功能都可分解到具體的IPC機制。可以採用Whole-part設計模式將forwarder的職責封裝在其分離的part組件中。
5 實現receiver。將所有接收IPC消息的功能都封裝到receiver中,包含接收和解碼IPC消息的功能,(??Provide the receiver with a general interface that abstracts from details of a particular IPC mechanism.)給receiver提供從特定IPC機制細節中抽象出的通用接口。可以像第4步一樣,採用whole-part設計模式將這些receiver的職責封裝到分離的part組件中。
設計receiver時特別需要考慮2個方面的問題。
1 既然所有的peer都是以異步方式運行的,那麼就需要決定receiver是否應該阻塞,直到有消息到達:
- 如果這樣,receiver會一直等待,直到有消息輸入時纔將控制權交還給peer,換句話說,peer在成功接收到消息之前不能繼續執行。當peer後續操作依賴輸入的消息才能完成的情況下,此情況是比較合適的。
- 如果不這樣,就需實現非阻塞方式的receiver,允許peer指定超時時間(參見第2步)。如果在指定的時間範圍內沒有消息到達,receiver將返回一個異常給它的peer。
如果底層IPC機制不支持非阻塞I/O,那需要在peer中使用單獨的線程來處理通信。
2 另一個需要考慮的問題是,在receiver中使用多個通信通道。這種receiver能對多個通信通道進行多路分解(demultiplexing)——它會等待其中一個通道有數據到達,並在數據到達後將其返回給它的peer,如果同時有多個消息到達,則receiver可用一個內部消息隊列緩存這些消息。多路分解可能依賴於底層IPC機制,例如:UNIX系統中的select允許進程在一組文件或socket上等待事件輸入。如果IPC機制不支持多路分解,那需要你在receiver中用多線程來完成多路分解,其中每個線程負責一個通信通道。關於事件多路分解的細節可以參見Reactor模式[Sch94]。
[page 318]
在我們的例子中提供了類Receiver。在peer實例一個receiver時,它會在其構造函數中傳入自己的名稱作爲參數,receiver用這個名字來確定接收消息的socket端口號。當peer要接收消息時,它會調用Receiver對象的receiveMsg()方法,receiveMsg隨後又會調用receive()方法,receive()做了2件事情:
- 從全局的映射倉庫中獲取了socket端口後,它打開服務器的socket,並等待遠程peer連接。
- 一旦連接建立起來,到達的消息和它的大小都從通信通道中讀取出來,receive()將讀取到的數據返回給receiveMsg。
最後,receiveMsg()執行unmarshal將byte數據串轉換成Message對象並將此對象返回給peer。
class Receiver {
private Serversocket snrS;
private Socket s;
private Inputstream iStr;
private String myName;
public Receiver(String theName) { myName = theName;}
private Message unmarshal (byte [] anArray) { /* . ., */ }
private byte[] receive() {
int val;
byte buffer [] = null;
try {
Entry entry = fr.reg.get(myName);
srvS = new ServerSocket(entry.port0, 1000);
s = srvS.accept();iStr = s.getInputStream();
val = iStr. read ( ) ; buffer = new byte [val] ;
iStr.read(buffer1 ;
iStr.close0; s.close(); srvS.close();
}
catch(I0Exception e) { /* . . . */ }
return buffer;
}
public Message receiveMsg () {
return unmarshal(receive()) ;
}
}
6 實現peer。將peer分爲client和server兩個集合,兩者之間可以有交集。扮演client角色的peer會向遠程peer發送消息並等待響應,接收到響應後,它繼續執行自己的任務。扮演server角色的peer一直會等待有消息輸入,當消息到達時,它會執行此消息所請求的服務,並將響應發送給請求者。server有可能是別的server的client,甚至server和client會在運行時動態地變換他們之間的角色。
兩個peer之間的通信不一定是雙向的,有時一個peer只是發送一條消息到另一peer中,並不需要響應(單向通信)。peer發送了消息後就繼續執行它的任務,消息的接收端通過他的receiver收到消息,但是不會發送響應給發送端。可以將單向通信應用於發送端和接收端的異步通信。
這裏舉一個扮演server角色的peer的例子:
class Server extends Thread {
Receiver r;
Forwarder f;
public void run() {
Message result = null ;
r = new Receiver ( "Server") ;
result = r.receiveMsg ( ) ;
f = new Forwarder ( "Server") ;
Message msg = new Message ("Server","I am alive" ) ;
f.sendMsg(result.sender, msg);
}
}
7 實現一個啓動配置。系統啓動時,forwarder和receiver必須用有效的名字-地址映射來初始化。單獨引入此步驟,是爲了創建映射倉庫並錄入所有的名字/地址關係。採用配置的方式可以從外部文件中將對應關係讀取進倉庫中,避免在改變映射時更改源代碼。
如果系統允許不同的peer有不同的名字-地址映射,那需要讓啓動配置能根據此需求初始化不同的映射倉庫。
[page 320]
如果配置能動態改變,則需要實現一些附加功能在運行時能修改映射倉庫。
在DwarfWare例子中,實現了Configuration類,允許用戶在全局映射倉庫中註冊server和client:
class Configuration {
public Configuration ( ) {
Entry entry = new Entry ("127.0.0.1" ,1111) ;
fr.reg.put ("Client", entry);
entry = new Entry("127.0.0.1",2222);
fr.reg.put ("Server",entry );
}
}
[例子解決方案]
在我們的網絡管理基礎架構中,公共協議決定了請求、消息和響應的格式。如果agent要從遠程agent獲取如當前資源內容這樣的信息,它會發送一條消息給接收端,接收端從它的receiver收到此消息後會把請求的信息數據打進響應包,並把響應包發回給消息的發送源。當agent傳送的是一條command消息,接收端收到消息後就會解析它,並執行適當的命令,然後會告訴發動端是否能成功執行此command。所有相關信息都會用圖形界面顯示在網絡管理控制檯上,爲了增加可用性,網絡中的每臺機器都能運行網絡管理控制檯。
[變體]
沒有名字-地址映射的Forwarder-Receiver的映射。……
[page 321]
……
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.