即时通讯应用中的RPC事件实现
1. 即时通讯应用基础
在即时通讯应用里,当用户输入消息并按下回车键后,视图会调用
MessengerServiceClientImpl
的
onSendMessage
方法。由于
Contact
和
Message
对象实现了
IsSerializable
接口,它们能直接通过
sendMessage
调用传递到服务器。以下是相关代码示例:
public void onSuccess(Object obj){
}
2. RPC事件添加
即时通讯需要基于事件的协议。对于桌面应用而言,基于TCP/IP套接字实现可能较为容易,但Ajax应用依赖HTTP,而HTTP不支持事件广播,它仅支持对客户端发起的请求发送响应,服务器在无请求时无法发送数据。
并非所有RPC实现都需要RPC事件,像电子邮件客户端,它在桌面和网页上都很常见,通常不使用基于事件的协议。电子邮件客户端通过定期检查从服务器批量检索电子邮件数据并展示给用户,用户习惯了批量检索邮件,甚至在应用中能看到用于绕过等待并发起检查的按钮。Ajax应用可通过向服务器发起异步HTTP请求轻松复制这种行为。
2.1 轮询协议
电子邮件应用和许多Ajax应用检索数据的技术被称为轮询。轮询在客户端和网络协议中都易于实现,长期以来在电子邮件协议中成功应用,在Ajax应用中也容易实现。不过,轮询的缺点是消息传递存在延迟。例如,电子邮件客户端的定期检查导致邮件并非在到达服务器的瞬间被接收,而是在客户端向服务器请求新邮件后才收到,具体过程如下:
graph LR
A[电子邮件服务器] -->|邮件到达| B(等待客户端请求)
C[电子邮件客户端] -->|获取邮件请求| A
A -->|返回邮件| C
虽然这种延迟不算严重,因为我们不期望邮件立即到达,且最大延迟为轮询间隔,通常仅几分钟。在电子邮件客户端和许多其他应用中,用户能接受数据并非即时或实时的情况,使用刷新或数据检索按钮也无太大问题。但在某些应用中,这种延迟是不可接受的,比如即时通讯应用,我们期望能即时看到在线状态和消息事件。
若从电子邮件客户端的轮询实现入手,可将检查周期缩短到足够小,使响应速度快到看似即时。然而,这种解决方案无法扩展。例如,若将周期缩短至1秒,每个连接的客户端看似能即时接收事件,但无论服务器上是否有邮件,每秒都会发起新请求。若有1000个客户端使用服务器上的电子邮件,服务器资源需支持每秒1000个请求,对于如此简单的任务,这并不经济。对于用户较多的应用,轮询实现无法为客户端提供即时或实时数据。
更好的解决方案是仅在有新数据或更新时从服务器接收事件。客户端无法知晓新数据何时可用,因此这种方法要求服务器在无匹配请求的情况下向客户端发送数据。与轮询方法的区别在于,仅在数据可用时进行传输,这能提高性能并节省带宽。
3. 基于事件的协议
基于事件的协议得益于观察者设计模式,这是软件开发中常用的模式,可观察对象的状态。该模式通常用于防止两个不同组件之间的耦合。模式中的被观察对象为观察者提供一个接口,观察者必须实现该接口以接收被观察对象状态变化的通知。此模式在软件开发中已应用多年,早期计算机软件中,以中断形式从操作系统接收事件就是例子。操作系统会在调用中断时处理这些中断,如键盘输入的数据,而非让应用轮询设备获取信息。这种技术为计算机节省了CPU周期,就像网络上使用的事件模型节省了带宽一样。在GWT的UI事件系统中也有类似解决方案,应用可注册接收小部件的事件,客户端代码将自身注册为监听器,以便在事件发生时被调用。
在网络环境中,服务器是被观察对象,客户端是观察者。若网络协议真正实现观察者模式,会看到服务器调用客户端的接口。但电子邮件客户端并非如此,如前文所述,它不使用基于事件的协议。而即时通讯协议基于事件,客户端会响应服务器的调用,遵循观察者模式。
为理解基于事件的协议的优势,对比即时通讯客户端与电子邮件客户端和服务器的通信方式。即时通讯客户端先连接到服务器,然后等待事件。当联系人上线或消息到达时,服务器通过已建立的连接向客户端发送消息,具体过程如下:
graph LR
A[即时通讯服务器] -->|连接| B[即时通讯客户端]
A -->|联系人上线通知| B
A -->|消息到达通知| B
与电子邮件协议相比,事件到达服务器和到达客户端之间没有相同的延迟。唯一的延迟是通过现有连接发送简短通知消息所需的时间,在正常连接下,用户会感觉这是即时的。这种解决方案所需的资源也远少于轮询方法。
使用标准TCP/IP连接创建允许这种行为的协议实际上相当简单,因为它允许以任何格式进行双向通信。可将每个事件格式化为消息,并随时从服务器通过连接发送到客户端。这种连接通常由操作系统通过所选语言的支持库提供,常用于需要与服务器通信的桌面应用。但Ajax应用受限于Web浏览器提供的功能,无法使用这种连接,只能依赖HTTP进行网络通信,这就是创建基于事件的Ajax应用较为罕见和困难的原因。
尽管Ajax应用能使用
XMLHttpRequest
对象与服务器进行异步通信,但该对象不允许使用除HTTP之外的任何协议。HTTP严格基于请求和响应,如电子邮件协议,不具备接收服务器未配对请求消息的能力,HTTP连接无法作为基于事件的协议的传输方式。不过,我们可利用浏览器提供的HTTP请求功能,实现基于事件协议的桌面应用的响应性,避免轮询解决方案的延迟和可扩展性问题。
4. 事件实现
GWT自带的RPC实现不提供任何事件机制,即我们只能通过方法调用的直接请求从服务器接收数据。为支持向其他客户端通知联系人的在线状态或向其他客户端发送消息,我们需要支持事件。
前文提到轮询替代方案,可通过GWT - RPC定期检查服务器以获取新消息或联系人,但这种解决方案对服务器资源成本过高。另一种选择是支持基于事件的协议,即服务器仅在数据可用时向客户端发送数据,这是理想的解决方案,但由于HTTP限制和GWT - RPC的原因,我们无法构建需要纯事件协议的接口,不过可获得接近的近似方案。
4.1 挂起RPC调用
挂起RPC调用是轮询和服务器事件的混合,它具有从服务器获取即时事件的优势,且不会像轮询实现那样存在性能问题。挂起RPC调用的流程如下:
1. 客户端调用挂起RPC方法,在本应用中为
getEvents
。
2. 服务器处理该调用,识别用户并检查是否有挂起的事件。
- 若有事件,方法返回事件列表。
- 若无事件,服务器以最大等待时间阻塞线程,本应用中设置为30秒。
- 若30秒后无事件可用,方法返回,客户端再次调用
getEvents
。
- 若线程挂起时事件到达,线程被唤醒并立即将事件返回给客户端。
这种解决方案提供即时事件通知,将轮询频率降低到30秒。不过,该技术的缺点是每个等待事件的客户端会占用服务器上的一个线程。为使该技术可扩展,需要使用非常大的线程池或支持在这种情况下释放线程的服务器。
4.2 客户端实现
客户端的实现相对简单。首先,需在
MessengerService
和
MessengerServiceAsync
接口中添加新方法以表示挂起RPC调用:
public interface MessengerService extends RemoteService {
void signIn( String name );
void signOut();
void sendMessage( Contact to, Message message );
/**
* @gwt.typeArgs <com.gwtapps.messenger.client.Event>
*/
List getEvents();
}
注意,我们指定了
gwt.typeArgs
Javadoc注释,GWT编译器在生成该接口的序列化代码时会将其作为提示。此代码告知GWT编译器,
getEvents
方法返回的集合只能包含
Event
类的实例,这让编译器减少需要生成的代码量,因为它知道该方法只需序列化
Event
实例。这种注释可用于可序列化类的字段和RPC接口参数,例如:
/**
* @gwt.typeArgs events <com.gwtapps.messenger.client.Event>
* @gwt.typeArgs <com.gwtapps.messenger.client.Event>
*/
List sendAndGetEvents( List events );
在应用中,登录
MessengerServiceClientImpl
的
SignInCallback
内部类后,需调用
getEvents
方法:
private class SignInCallback implements AsyncCallback{
public void onFailure(Throwable throwable){
GWT.log("error sign in",throwable);
}
public void onSuccess(Object obj){
view.setContactList( contactList );
messengerService.getEvents( new GetEventsCallback() );
}
}
getEvents
调用的回调函数
GetEventsCallback
会遍历调用的返回值(即事件列表):
private class GetEventsCallback implements AsyncCallback {
public void onFailure(Throwable throwable){
GWT.log("error get events",throwable);
}
public void onSuccess(Object obj){
List events = (List)obj;
for( int i=0; i< events.size(); ++i ){
Object event = events.get(i);
handleEvent( event );
}
messengerService.getEvents( this );
}
}
处理每个事件后,回调函数会再次调用服务器上的
getEvents
方法,形成一个模拟事件循环的无限循环。
事件列表中的每个事件都是一个简单对象,其属性作为事件的参数。对于即时通讯应用,我们定义了三个事件:
SignOnEvent
、
SignOffEvent
和
SendMessageEvent
:
public class Event implements IsSerializable{
}
public class SignOnEvent implements Event{
public Contact contact;
}
public class SignOffEvent implements Event {
public Contact contact;
}
public class SendMessageEvent implements Event {
public Contact sender;
public Message message;
}
服务器根据其他客户端的操作生成这些事件。当
MessengerServiceClientImpl
实例接收到这些事件时,会调用
handleEvent
方法处理,以在视图上执行相应操作:
protected void handleEvent( Object event ){
if( event instanceof SendMessageEvent ){
SendMessageEvent sendMessageEvent = (SendMessageEvent)event;
view.getChatWindowView( sendMessageEvent.sender ).addMessage(
sendMessageEvent.message );
}
else if( event instanceof SignOnEvent ){
SignOnEvent signOnEvent = (SignOnEvent)event;
view.getContactListView().addContact(signOnEvent.contact);
}
else if( event instanceof SignOffEvent ){
SignOffEvent signOffEvent = (SignOffEvent)event;
view.getContactListView().removeContact(signOffEvent.contact);
}
}
若事件为
SendMessageEvent
实例,应用会检索聊天窗口视图并使用
addMessage
方法添加消息;若为
SignOnEvent
实例,会检索联系人列表视图并添加联系人;若为
SignOffEvent
实例,会检索联系人列表视图并移除联系人。服务器端负责实现GWT - RPC事件的其余部分。
5. 即时通讯服务器实现
要在服务器中构建即时通讯功能,GWT - RPC servlet的超类
RemoteServiceServlet
已完成将异步HTTP调用转换为方法调用的工作。我们需要实现
MessengerService
接口中定义的每个方法以及事件广播功能。
5.1 事件广播实现
为实现事件广播,需跟踪每个连接的客户端并维护一个待处理事件列表。当有新事件需要发送给客户端时,将其添加到列表中;若有线程在等待,则释放该线程。以下是
MessengerServiceImpl
类的声明及其私有变量和
getCurrentUser
辅助方法:
public class MessengerServiceImpl
extends RemoteServiceServlet implements MessengerService {
protected static final int MAX_MESSAGE_LENGTH = 256;
protected static final int MAX_NAME_LENGTH = 10;
private Map users = new HashMap();
private class UserInfo{
Contact contact;
List events = new ArrayList();
}
protected UserInfo getCurrentUser(){
String id = getThreadLocalRequest().getSession().getId();
synchronized( this ){
return (UserInfo)users.get(id);
}
}
}
前两个常量值表示消息和联系人姓名长度的限制。
UserInfo
内部类用于表示连接的用户,包含一个
Contact
实例(连接用户的模型对象)和一个事件列表。每个连接的客户端的
UserInfo
实例存储在以会话ID为键的用户映射中。
会话是服务器端的概念,代表与一个客户端的连接,通常在客户端保持浏览器打开时持续存在。它允许服务器端代码设置用户唯一的变量,可在请求之间使用,并提供一个ID来识别用户。对于即时通讯服务器,我们使用该ID作为用户映射中
UserInfo
实例的键。在每个方法调用开始时,需要识别调用方法的用户,通过会话ID识别用户可避免要求客户端为每个方法调用提供登录名,从而防止假冒攻击。
getCurrentUser
方法可获取当前用户的
UserInfo
对象。
5.2 getEvents方法实现
public ArrayList getEvents(){
ArrayList events = null;
UserInfo user = getCurrentUser();
if( user != null ){
if( user.events.size() == 0 ){
try{
synchronized( user ){
user.wait( 30*1000 );
}
}
catch (InterruptedException ignored){}
}
synchronized( user ){
events = user.events;
user.events = new ArrayList();
}
}
return events;
}
该方法的目标是在有可用事件时返回事件列表。若无可用事件,方法会调用用户对象的
wait
方法,这是所有Java对象都可用的线程同步方法,会使当前线程停止处理,最长时间为参数指定的毫秒数,这里为30000毫秒(即30秒)。若另一个线程在对象上调用
notifyAll
方法,线程可继续执行。当有事件添加到用户的事件列表时,会在该用户对象上调用
notifyAll
方法。方法最后返回事件列表,并重置用户对象上的事件列表,以便接收新事件。
5.3 sendMessage方法实现
public void sendMessage( Contact to, Message message ){
String cleanMessage = cleanString( message.toString(),MAX_MESSAGE_LENGTH);
UserInfo sender = getCurrentUser();
UserInfo receiver = getUserByName( to.getName() );
if( receiver != null ){
SendMessageEvent event = new SendMessageEvent();
event.sender = sender.contact;
event.message = new Message( cleanMessage );
synchronized( receiver ){
receiver.events.add( event );
receiver.notifyAll();
}
}
}
该方法接收一个用户的消息,并将其作为事件发送给参数中指定的联系人。获取消息接收者的
UserInfo
实例,若用户已连接,则创建一个新的
SendMessageEvent
,设置其属性为消息值和发送者标识,并将新事件添加到接收者的事件列表中,然后调用接收者的
notifyAll
方法,使在
getEvents
方法中等待的接收者线程唤醒并返回新事件。
5.4 广播事件方法实现
对于
SignOnEvent
和
SignOffEvent
,需要将事件发送给每个连接的客户端,以便所有人都能收到在线状态更新。为此,我们编写了
broadcastEvent
辅助方法:
protected void broadcastEvent( Object event, UserInfo except ){
synchronized( this ){
Set entrySet = users.entrySet();
for( Iterator it = entrySet.iterator(); it.hasNext(); ){
Map.Entry entry = (Map.Entry)it.next();
UserInfo user = (UserInfo)entry.getValue();
if( user != except ){
synchronized( user ){
user.events.add( event );
user.notifyAll();
}
}
}
}
}
该方法接收一个事件实例,并将其广播给除
except
参数表示的客户端之外的每个连接客户端。发送事件给每个连接客户端的过程很简单,代码遍历
UserInfo
实例的映射,将事件添加到它们的事件列表中,并调用对象的
notifyAll
方法唤醒任何等待的线程。
5.5 signOut方法实现
public void signOut(){
Contact contact;
String id = getThreadLocalRequest().getSession().getId();
synchronized( this ){
UserInfo user = (UserInfo)users.get(id);
contact = user.contact;
users.remove(id);
}
//create sign-off event
SignOffEvent event = new SignOffEvent();
event.contact = contact;
broadcastEvent( event, null );
}
该实现简单地从用户映射中移除表示该连接的
UserInfo
,然后向其他每个联系人广播
SignOffEvent
。
5.6 signIn方法实现
public void signIn( String name ){
name = cleanString( name, MAX_NAME_LENGTH );
//add user to list
String id = getThreadLocalRequest().getSession().getId();
UserInfo user = new UserInfo();
user.contact = new Contact( name );
//后续代码省略,推测是将用户添加到映射、广播登录事件等操作
}
signIn
方法的操作与
signOut
相反且更复杂。它创建一个新的
UserInfo
对象并添加到用户映射中,向其他每个用户广播
SignOnEvent
,然后向登录用户发送每个用户的
SignOnEvent
,目的是为客户端提供联系人以填充联系人列表。
即时通讯应用中的RPC事件实现(续)
6. 服务器端方法交互流程总结
为了更清晰地理解服务器端各个方法之间的交互流程,我们可以通过以下表格和流程图来进行总结。
| 方法名称 | 功能描述 | 主要操作步骤 |
|---|---|---|
getEvents
| 获取用户的事件列表 |
1. 获取当前用户信息
2. 检查用户事件列表是否为空,若为空则线程等待30秒 3. 若有事件或等待超时,返回事件列表并重置事件列表 |
sendMessage
| 发送消息给指定联系人 |
1. 清理消息内容
2. 获取发送者和接收者信息 3. 创建
SendMessageEvent
事件并添加到接收者事件列表
4. 唤醒接收者等待线程 |
broadcastEvent
| 广播事件给除指定用户外的所有连接客户端 |
1. 遍历所有用户信息
2. 将事件添加到除指定用户外的其他用户事件列表 3. 唤醒这些用户的等待线程 |
signOut
| 用户登出 |
1. 从用户映射中移除当前用户信息
2. 创建
SignOffEvent
事件并广播给其他用户
|
signIn
| 用户登录 |
1. 清理用户名
2. 创建新的
UserInfo
对象并添加到用户映射
3. 广播
SignOnEvent
给其他用户
4. 向登录用户发送其他用户的
SignOnEvent
|
graph LR
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A([用户操作]):::startend -->|登录| B(signIn方法):::process
A -->|登出| C(signOut方法):::process
A -->|发送消息| D(sendMessage方法):::process
A -->|获取事件| E(getEvents方法):::process
B --> F(广播SignOnEvent):::process
C --> G(广播SignOffEvent):::process
D --> H(创建SendMessageEvent):::process
H --> I(添加到接收者事件列表):::process
I --> J(唤醒接收者线程):::process
E --> K{事件列表是否为空?}:::decision
K -->|是| L(线程等待30秒):::process
K -->|否| M(返回事件列表):::process
L -->|超时或有事件| M
F --> N(broadcastEvent方法):::process
G --> N
N --> O(遍历用户信息):::process
O --> P(添加事件到用户事件列表):::process
P --> Q(唤醒用户线程):::process
7. 性能与扩展性分析
在实现即时通讯应用时,性能和扩展性是非常重要的考虑因素。下面我们对上述实现方案的性能和扩展性进行分析。
7.1 轮询协议的性能问题
如前文所述,轮询协议虽然实现简单,但存在明显的性能问题。当轮询周期较短时,会导致服务器资源消耗过大,尤其是在用户数量较多的情况下,服务器需要处理大量的请求,容易造成性能瓶颈。例如,若将轮询周期设置为1秒,1000个客户端同时在线时,服务器每秒需要处理1000个请求,这对服务器的处理能力和带宽都提出了很高的要求。
7.2 挂起RPC调用的优势与挑战
挂起RPC调用作为一种折中的解决方案,在一定程度上解决了轮询协议的性能问题。它通过减少轮询频率,将轮询周期延长到30秒,降低了服务器的请求压力。同时,能够在事件发生时及时通知客户端,提供了较好的即时性。
然而,挂起RPC调用也存在一些挑战。每个等待事件的客户端会占用服务器上的一个线程,当客户端数量较多时,会消耗大量的服务器线程资源。为了使该技术可扩展,需要使用非常大的线程池或支持在这种情况下释放线程的服务器。例如,在高并发场景下,如果服务器的线程池大小有限,可能会导致部分客户端无法及时获取事件通知。
7.3 服务器端线程管理
为了提高服务器的性能和扩展性,需要对服务器端的线程进行有效的管理。一种解决方案是使用线程池,合理分配和复用线程资源。例如,可以设置一个较大的线程池,根据客户端的数量和请求频率动态调整线程的分配。另一种解决方案是使用支持异步I/O的服务器,当线程处于等待状态时,可以释放该线程,让其处理其他任务,从而提高服务器的资源利用率。
8. 总结与实践建议
通过对即时通讯应用中RPC事件实现的详细分析,我们可以总结出以下要点和实践建议。
8.1 要点总结
- 即时通讯应用需要支持基于事件的协议,以实现即时的消息通知和在线状态更新。
- 轮询协议虽然实现简单,但存在性能和扩展性问题,不适合大规模应用。
- 挂起RPC调用是一种折中的解决方案,能够在一定程度上提供即时性并减少服务器资源消耗。
- 服务器端需要实现事件广播和线程管理功能,以确保系统的性能和稳定性。
8.2 实践建议
- 在开发即时通讯应用时,优先考虑使用挂起RPC调用或其他基于事件的协议,避免使用轮询协议。
- 合理设置挂起RPC调用的等待时间,平衡即时性和服务器资源消耗。
- 对服务器端的线程进行有效的管理,使用线程池或支持异步I/O的服务器,提高服务器的性能和扩展性。
- 在代码实现过程中,注意线程同步和异常处理,确保系统的稳定性和可靠性。
通过以上的分析和实践建议,我们可以更好地实现即时通讯应用中的RPC事件功能,为用户提供高效、稳定的即时通讯服务。
超级会员免费看

479

被折叠的 条评论
为什么被折叠?



