Internet 即时通信系统的设计与实现
杨健 (ytjcopy@china.com)
中南工业大学
http://dev.gameres.com/Program/Control/InternetInstantMsg.mht
2001 年 10 月
网络通信是当今信息社会网络化一个必不可少的环节。各种为Internet量身定做的网络通信信息技术层出不穷。如早期的CGI,ISAPI等,现在非常流行的JSP,Servlet,ASP等,特别是Sun MicroSystem公司的J2EE和Microsoft的。NET方案,为企业快速高效的实现Internet应用提供了强大支持。而对于一些基于Internet的即时通信,一般是采用C/S模式。即客户端安装并执行客户程序,通过某种通信协议与服务器端的服务器程序或者是直接与另外的客户程序进行通信。本文介绍的是怎样采用Java技术,用B/S模式来实现基于Internet的即时通信,对学习Java Socket编程很有帮助。
一. 为什么选择Java 和 B/S
Sun MicroSystem 的Java技术以其明显的优势得到了广泛应用。如美国华尔街的高盛,美国Amazon.com以及美国通用等的电子商务网站都是采用Java技术。Java语言具有面向对象,面向网络,可移植,与平台无关,多线程,安全等特点。基于网络带宽限制和网络安全等原因,本即时通信系统的客户端用Java小程序(applet)来实现。即通过支持Java的浏览器(如Microsoft Internet Explorer 和 Netscape Navigator等)下载并执行Java Applet 来完成客户端操作。Java Applet 具有体积小,安全等特点。通常,基于C/S模式的通信程序都要求用户先下载一个客户端程序安装在本地机上,而且这个客户程序相对比较大(所谓“胖客户”)。而且,对于一些不可信站点的程序还要考虑到安全因素,因为大多数后门工具就是利用这个缺陷侵入用户计算机的。而使用Java Applet,用户就不必为这些而烦恼。首先,Applet通常很小,用户不必先安装就可立即执行。其次,由于Applet的安全性,用户可以放心使用来自Internet的哪怕是不可信站点的Applet程序。这样,客户端只要拥有支持Java的浏览器就可实现。根据现在的情况来看是不难办到的。而在服务器端我们采用Java Application。这样可以充分发挥Java技术的多线程及平台无关等优点,并且在后方可以借助JDBC与DBMS进行通信,存储和更新数据,以及采用J2EE等技术进行扩展。本文重点放在即时通信,因此服务器端与DBMS的连接技术将不作介绍。
二.系统设计与实现
开发平台及工具:Windows2000,Jbuilder4(J2SDK1.3),Together4.2。
客户端程序运行环境:拥有支持Java的浏览器的任何平台。
服务器程序运行环境:拥有JVM的任何平台。
1. 需求分析
图2。1。1为系统的Use Case图。
图2。1。1
用例(Use Case): 远程会话
系统范围(Scope):服务器端系统
级别(Level):顶层(Summary)
外界系统描述(Context of Use):为了与该系统交互,外界系统为远程客户,而每一客户对应于一个客户端程序,客户通过客户端程序与系统交互
主要执行者(Primary Actor):客户
典型成功场景(Main Success Scenario):
1. 客户通过客户端程序发出"申请新账号"的请求,并提供登录账号和登录密码等申请信息;
2. 服务器端程序对该客户提交的信息进行验证,并发送"账号申请成功"信息;
3. 客户接收"申请账号成功"信息后,继续发送请求;
4. 服务器端程序接收该请求并进行相应处理,然后将执行结果返回给客户;
5. 重复执行步骤3和步骤4,直到客户发送"会话结束"信息。这时服务器程序完成结束前的处理工作后,断开与客户的连接;
扩展(Extensions):
- 1a.:系统发现该账号已经存在
1a.1.:系统返回"该账号已存在"信息,客户可以选择另选账号或者退出
- 1b:客户提交"登录"信息:
- 1b.1.:系统对客户身份进行验证:
- 1b.1a.:验证通过,返回"登录成功"信息
- 1b.1b:验证不能通过,返回"登录失败"信息,客户可以再尝试登录或者退出
- 1b.1.:系统对客户身份进行验证:
- 说明:典型成功场景的第1步可以用1a代替,接下来是1a.1;或者用1b代替,后接1b.1,再接1b.1a或者1b.1b。
2. 概要设计(图2。2。1)
该系统分为两大部份:客户端程序和服务器端程序。客户端程序采用Java 小程序,通过socket与服务器端程序通信;服务器端程序采用Java Application,同样采用socket与客户端程序进行交互。考虑到即时通信的准确性要求,通信协议采用TCP。
3. 详细设计
- 服务器端程序设计:
服务器端完成的功能是:对服务器的某一可用端口进行监听,以获得客户端请求,从而对客户端请求进行处理。因为是多客户同时请求,所以要采用多线程,为每一个在线用户分配一个线程,实时处理每个客户端的请求。因此,
对服务器端程序抽象如下:(图2。3。1)
- a.公共数据处理(Common Data Processing)
处理公共数据。如在线人数统计,客户的公共数据(如通知等),客户数据资料的存储与读取等(与数据库交互);
- b. 端口监听器(Port Listener)
监听服务器某一端口,为每一在线客户建立一个会话线程;
- 客户请求处理(Client Request Processing)
处理客户的请求。根据客户的请求执行相应的操作。
- 服务器管理器
服务器端的管理工具,如对数据进行统计,紧急情况的处理等。
服务器端类的设计(图2。3。2和图2。3。3):
公共数据处理类CmDataProcessor(Common Data Processor):该类包含客户所共有的数据,以及如何对这些数据进行处理。
端口监听类PortListener(Port Listener):该类实现了java. lang. Runnable接口,从服务器程序初始化完成后一直运行。由于目前JDK只支持同步通信,在没有客户请求时,该线程处于等待状态;一旦有客户请求到来,便继续执行。这时服务器程序可以通过java. net. ServerSocket. accept()方法获得客户端请求的java. net. Socket对象。然后用这个Socket对象为参数构造一个新的线程:ClientSession的实例(类ClientSession以下将作介绍)。然后在ClientSession实例中用该Socket对象构造一个输出流java. io. PrintStream和一个输入流java. io. BufferedReader,以后,每个客户就可以通过这一对输入输出流与服务器交互了。应该注意的是,ServerSocket 对象并不是在该对象内创建的,而是在服务器程序初始化时创建的。因为socket是进程间的通信,在线程中创建将会失败。客户端程序也是如此。
客户会话类ClientSession(Client Session):该类继承自java. lang. Thread类,由PortListener创建。一般的,每一个在线客户都对应一个ClientSession的实例。该类用parseRequest()方法解析客户发来的请求,进行相应处理。该线程在客户会话期间一直运行,通过I/O流读取和发送数据(I/O流即从PortListener监听线程获得的java. net. Socket对象而创建的java. io. PrintStream和java. io. BufferedReader实例),直到客户退出才撤销。该类和类ClientSession一样都实现了java. lang. Runnable接口,故都有一个run()方法。该方法的结束标志着该线程将结束。
服务器管理类 ServerManager(Server Manager):管理服务器。拥有管理权限的客户(管理员)可以远程操作服务器程序,包括运行、停止服务器,广播通知,给指定客户发送消息等特权操作。
图2。3。2
图2。3。3
- 客户端程序设计(图2。3。4)
客户端完成的功能是:建立与服务器的连接;向服务器发送功能请求,接收来自服务器的信息,完成与主机或其他客户交互;断开与服务器的连接。客户端程序相对服务器端程序来说属于LightWeight(轻量级)。这是由本系统的自身特点决定的。所以,对客户端程序抽象如下:
1. 客户请求发送器:负责功能请求的发送。如登录请求等。
2. 服务器信息接收器:负责接收来自服务器端的信息。如请求处理结果等。
客户端类的设计:
请求发送器(RequestSender):该类发送客户端的功能请求。客户通过客户端用户界面提交要执行的操作,然后由该类将客户提交的信息封装成服务器端程序可以理解的功能请求发送出去。
信息接收器(Receiver):该类接收来服务器端的信息。这些信息可以是客户请求的处理结果,也可以是服务器端的广播通知。为保证实时性,该类实现了java. lang. Runnable接口。在客户会话期间,该类将一直运行,实时的将来自服务器端的信息反馈给客户。该类接收信息后,应该对该信息做相应处理。如通知客户已登录成功等。这些操作都将在run()方法中实现。
图2。3。4
4. 实现
以上的系统设计是一个即时通信系统的总体框架,根据实际情况,可以添加或者修改。下文就以“远程会议系统“为例来实例化这样一个通信系统。
我们知道,远程会议系统有几个方面的特点:实时交互;准确传输信息;多客户等。所以,完全可以用该系统框架来实现(这里只给出核心代码)。
首先我们来实现服务器端程序。为了便于对服务器程序的管理,服务器端程序采用了GUI界面。在该程序初始化时应该实现对可用端口的监听(程序清单1。1)。
try { socket = new ServerSocket(NetUtil.SLISTENPORT); } catch (Exception se) { statusBar.setText(se.getMessage()); } |
程序清单1。1。1
其中,NetUtil.SLISTENPORT是服务器的一个可用端口,可以根据实际情况确定。NetUtil是一个接口,其中包含了该系统用到的各类常数。NetUtil.SLISTENPORT就是NetUtil中的一个整型常数。如果端口监听抛出异常,GUI中的statusBar将给出提示。监听成功后可以启动监听线程了(程序清单1。1。2)。
listenThread=new Thread (new ListenThread ()); listenThread.start();
|
程序清单1。1。2
以上程序中listenThread是一个Thread实例,用来操作监听线程。监听线程实现如下(程序清单1。2。1):
public class PortListener implements Runnable{ ServerSocket socket; //服务器监听端口 FrmServer frm; //FrmServer为GUI窗口类 ClientSession cs; //客户端会话线程 PortListener (FrmServer frm,ServerSocket socket) { this.frm=frm; this.socket=socket; } public void run () { int count = 0; if (socket == null) { frm.statusBar.setText("socket failed!"); return; } while (true) { try { Socket clientSocket; clientSocket = socket.accept(); //取得客户请求Socket //用取得的Socket构造输入输出流 PrintStream os = new PrintStream(new BufferedOutputStream(clientSocket.getOutputStream(), 1024), false); BufferedReader is = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); //创建客户会话线程 cs = new ClientSession(this.frm); cs.is = is; cs.os = os; cs.clientSock = clientSocket; cs.start(); } catch (Exception e_socket) { frm.statusBar.setText(e_socket.getMessage()); } } } } |
程序清单1。2。1
监听线程一直在后台运行。当有客户请求到来时,监听线程创建与该客户进行会话的ClientSession实例。这时,监听线程会等待另外客户请求的到来,然后又创建会话线程,如此循环下去。客户会话则通过会话线程进行。客户会话线程主要的工作就是怎样处理客户请求。ClientSession. parseRequest ()就是处理客户请求的。这个方法的内容应该根据实际应用的需要来确定。这里只实现了一些很简单的功能。如会议大厅发言,私下交谈等。ClientSession的实现代码见程序清单1。3。1。
public class ClientSession extends Thread { FrmServer frm; //GUI窗口类 BufferedReader is = null; //输入流 PrintStream os = null; //输出流 Socket clientSock = null; //客户请求Socket int curContent = 0; public ClientSession (FrmServer frm) { this.frm=frm; } public void run () { parseRequest(); //处理客户请求 } public void destroy () { try { clientSock.close(); is.close(); os.close(); } catch (Exception cs_e) {} } //客户请求处理方法,只实现了大厅发言及私下交谈 private void parseRequest () { String strTalking; String strUserID; String strMain = ""; String strPerson = NetUtil.PERSONINFO + "/n"; boolean flagEndProc = false; while (true) { try { while ((strTalking = new String(is.readLine())) != null) { if(strTalking.equals(NetUtil.CHATSTART)){ //客户会话开始 strUserID = new String(is.readLine()); // 将所有谈话内容发送给刚登录的客户 for (int i = 0; i < frm.dataProcessor.vecMainTalk.size(); i++) { strMain += (String)frm.dataProcessor.vecMainTalk.get(i); strMain += "/n"; } curContent = frm.dataProcessor.vecMainTalk.size(); os.println(strMain); os.flush(); for (int j = 0; j < frm.dataProcessor.vecP2PInfo.size(); j++) { strPerson += (String)frm.dataProcessor.vecP2PInfo.get(j); strPerson += "/n"; } os.println(strPerson); os.flush(); //将所有在线用户名单发给新加入的客户 os.println(NetUtil.DIVIDEDLINE); os.flush(); while (true) { this.sleep(1000); String strContent = ""; //如果有人发言,则把发言发给在线客户 if (frm.dataProcessor.vecMainTalk.size() > curContent) { for (int ci = curContent; ci <frm.dataProcessor.vecMainTalk.size(); ci++) { strContent +=(String)frm.dataProcessor.vecMainTalk.get(ci); strContent += "/n"; } curContent = frm.dataProcessor.vecMainTalk.size(); os.println(strContent); os.flush(); } //如果有人私下交谈,则把交谈的内容发给交谈的另一方 if (strUserID != null) { int nvi = 0; for (nvi = 0; nvi < frm.dataProcessor.vecSelfInfo.size()&& !((String)((Vector)frm.dataProcessor.vecSelfInfo.get (nvi)).get(0)).equals(strUserID); nvi++); if (nvi < frm.dataProcessor.vecSelfInfo.size()) { Vector vecTalk =(Vector)frm.dataProcessor.vecSelfInfo.get(nvi); if ((String)vecTalk.get(1)).equals(NetUtil.CALLED)){ String strCallRes = NetUtil.ISCALLED+ "/n"; String strCallTemp = (String)vecTalk.get(2); strCallRes += strCallTemp; os.println(strCallRes); os.flush(); } else if(((String)vecTalk.get(1)).equals(NetUtil.CALLING)){ if(((String)vecTalk.get(3)).equals(NetUtil.RESPONSING)){ //一客户呼叫另一客户并有了回应 String strResponsing =NetUtil.RESPONSE + "/n"; String strResponsingT =(String)vecTalk.get(2); strResponsing += strResponsingT; os.println(strResponsing); os.flush(); //设置客户“正在私下交谈”状态 vecTalk.setElementAt(NetUtil.CHATTING, 1); String strOther = (String)vecTalk.get(2); int setvi = 0; for (setvi = 0; setvi <frm.dataProcessor.vecSelfInfo.size() && !((String)((Vector)frm.dataProcessor.vecSelfInfo.get(setvi)).get(0)).equals(strOther); setvi++); Vector vecOther = (Vector)frm.dataProcessor.vecSelfInfo.get(setvi); vecOther.setElementAt(NetUtil.CHATTING,1); }} else if (((String)vecTalk.get(1)).equals(NetUtil.CHATTING)) { String strCurContent = (String)vecTalk.get(4); if (!strCurContent.equals(NetUtil.NONCONTENT)) { String strToWho = vecTalk.get(2)+ "/n"; String strPerRes = NetUtil.PERSONALRECEIVE+ "/n"; strPerRes += strToWho; strPerRes += strCurContent; os.println(strPerRes); os.flush(); vecTalk.setElementAt(NetUtil.NONCONTENT,4); }}}}}} // 处理客户发来与另一客户私下交谈的请求 else if (strTalking.equals(NetUtil.PERSONALTALK)) { strTalking = new String(is.readLine()); int vi = 0; for (vi = 0; vi < frm.dataProcessor.vecSelfInfo.size() && !((String)((Vector)frm.dataProcessor.vecSelfInfo.get(vi)).get(0)).equals(strTalking); vi++); if (vi == frm.dataProcessor.vecSelfInfo.size()) { os.println(NetUtil.NOTEXIST); os.flush(); } else { Vector vec = (Vector)frm.dataProcessor.vecSelfInfo.get(vi); String strCall = new String(is.readLine()); vec.setElementAt(NetUtil.CALLED, 1); vec.setElementAt(strCall, 2); int vi_c = 0; for (vi_c = 0; vi_c < frm.dataProcessor.vecSelfInfo.size() && !((String)((Vector)frm.dataProcessor.vecSelfInfo.get(vi_c)).get(0)).equals(strCall); vi_c++); if (vi_c == frm.dataProcessor.vecSelfInfo.size()) { os.println(NetUtil.NOTEXIST); os.flush(); } Vector vec_c = (Vector)frm.dataProcessor.vecSelfInfo.get(vi_c); vec_c.setElementAt(NetUtil.CALLING, 1); vec_c.setElementAt(strTalking, 2); os.println(NetUtil.RESPONSE); os.flush(); } flagEndProc = true; } // 存储新客户的信息 else if (strTalking.equals(NetUtil.PERSONNAME)) { String strName = ""; frm.isStart = true; strName = new String(is.readLine()); if (strName != "/n") { frm.dataProcessor.vecP2PInfo.addElement(strName); Vector vec = new Vector(); vec.addElement(strName); vec.addElement(NetUtil.IDLE); vec.addElement(NetUtil.NONE); vec.addElement(NetUtil.NORESPONSE); vec.addElement(NetUtil.NONCONTENT); frm.dataProcessor.vecSelfInfo.addElement(vec); } flagEndProc = true; } //私下交谈时,处理被叫方发送的应答请求 else if (strTalking.equals(NetUtil.SETRESPONSE)) { String strResName = new String(is.readLine()); int res = 0; for (res = 0; res < frm.dataProcessor.vecSelfInfo.size() &&!((String)((Vector)frm.dataProcessor.vecSelfInfo.get(res)).get(0)).equals(strResName); res++); Vector vecRes = (Vector)frm.dataProcessor.vecSelfInfo.get(res); vecRes.setElementAt(NetUtil.RESPONSING, 3); } //私下交谈时,处理主叫方发送的“开始交谈“请求 else if (strTalking.equals(NetUtil.PERSONALTALKSTART)) { String strPerCallName = new String(is.readLine()); String strPerTalkContent = new String(is.readLine()); int pres = 0; for (pres = 0; pres < frm.dataProcessor.vecSelfInfo.size() &&!((String)((Vector)frm.dataProcessor.vecSelfInfo.get(pres)).get(0)).equals(strPerCallName); pres++); Vector vecPer = (Vector)frm.dataProcessor.vecSelfInfo.get(pres); vecPer.setElementAt(strPerTalkContent, 4); } else { if (!strTalking.equals("/n") && !strTalking.equals("")) { frm.dataProcessor.vecMainTalk.addElement(strTalking); strTalking += "/n"; } flagEndProc = true; }} } catch (Exception io_e) { frm.statusBar.setText(io_e.getMessage()); } if (flagEndProc) break; }}} |
程序清单1。3。1
以上程序实现了对客户请求的处理。客户登录后,就可以发言了(简化了客户身份验证)。客户可以在大厅发言(每位在线客户都能接收到该发言),也可以选择某一位客户进行私下交谈(只有被选择的客户能收到该信息)。限于篇幅的原因,只实现了这几个简单功能。其它功能可以参考着实现。
客户信息,客户谈话内容等公共数据存放在CmDataProcessor的实例中。该实例是GUI窗口类的一个成员变量,在初始化时创建。CmDataProcessor中简化了对公共数据的处理,没有与后方的DBMS交互。因为本文重点是即时通信,所以没有实现与数据库交互。CmDataProcessor类中有三个主要成员变量.vecMainTalk用来存放客户的大厅谈话内容,vecSelfInfo存放每个在线客户的状态信息,vecP2Pinfo存放在线客户列表。需要说明的是,客户会话(ClientSession)这个类的parseRequest ()方法的实现细节不是本文的重点,因为其实现是根据应用的不同而不同的,尽管程序清单中给出了比较详细的注释。以下是类CmDataProcessor的实现代码(程序清单1。4。1):
public class CmDataProcessor { Vector vecMainTalk = new Vector(); Vector vecSelfInfo = new Vector(); Vector vecP2PInfo = new Vector(); public CmDataProcessor() { vecMainTalk.addElement(NetUtil.WELCOME); //登录时的欢迎界面 } } |
程序清单1。4。1
接口NetUtil和GUI用到的类的实现这里从略。到此,服务器端程序已实现。下面是客户端程序的实现。
客户端程序的实现:
客户端程序首先实现的是类Reciever。代码如下(程序清单2。1。1):
class Receiver extends Thread { PrintStream os; BufferedReader is; Socket clientSocket = null; public Receiver (Socket socket) { clientSocket = socket; //Socket实例是在applet初始化时创建的,而不能在线程内创建 } public void run () { if (clientSocket == null) { return; } try { os = new PrintStream(clientSocket.getOutputStream()); is = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); } catch (Exception se) { String err = se.getMessage() + "/n"; } String strTalking; String strStart = ""; strStart = NetUtil.CHATSTART + "/n"; strStart += strUserID; os.println(strStart); os.flush(); while (true) { try { while ((strTalking = new String(is.readLine())) != null) { if (strTalking.equals(NetUtil.PERSONINFO)) { //显示在线客户信息 while ((strTalking = new String(is.readLine()))!= null) { if (strTalking.equals(NetUtil.DIVIDEDLINE)) { break; } if (!strTalking.equals("/n") && !strTalking.equals("")) { addUser(strTalking); }}} else if (strTalking.equals(NetUtil.ISCALLED)) { String strCallName = (String)is.readLine(); strCallingName = strCallName; textNotify.setEnabled(true); textNotify.setText(strCallName + " is calling you!"); } else if (strTalking.equals(NetUtil.RESPONSE)) { String strResponseName = (String)is.readLine(); textNotify.setText("You are chatting with " + strResponseName); strCallingName = strResponseName; isTalkWith = true; choiceUser.addItem(strResponseName); } else if (strTalking.equals(NetUtil.PERSONALRECEIVE)) { String strWhoIs = (String)is.readLine(); String strContent = (String)is.readLine(); textTalkContent.append(strWhoIs + " speak to you: "+ strContent + "/n"); } else { strTalking += "/n"; textTalkContent.append(strTalking); }} } catch (Exception io_e) { System.out.println(io_e.getMessage()); }}}} |
程序清单2。1。1
该类用来实现与服务器端数据同步。当有客户发言或者有客户和另一客户私下交谈时,该类将立即更新这些数据,在客户端显示出来。所以,该类在客户会话期间一直在后台运行。负责发送客户请求的类是RequestSender。RequestSender用成员方法chatAll ()和chatOne ()分别实现大厅发言和私下交谈,其实现可以参照以上程序。客户端applet的实现代码这里从略。至此,客户端程序也已完成。以下是客户端程序运行时的快照(图2。4。1和图2。4。2)。
图2。4。1
图2。4。2
5.系统扩展及补充说明
以上实例由于篇幅原因只实现很少一部分功能,但能体现该即时通信系统的总体设计思想,而且很容易实现功能扩展。例如,可以实现公共数据处理类(CmDataProcessor)与DBMS的交互,实现数据的持久化(persistence);可以对applet实现数字签名来加大它对客户机的访问权限,从而实现文件的传输,实现多媒体交互;可以实现applet与servlet的对话,将比较复杂的业务逻辑交给应用服务器中的EJB来处理等。由于本人水平有限,难免有错误之处,欢迎批评指正。
关于作者 |