上个笔记中,已经描述了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]
……