由于因特网的迅速流行,越来越多的应用程序具备了在网上与其它程序通信的能力。从WIN95开始微软把网络功能融进了它的操作系统,使得应用程序网络通信能力更为普及。因此,微软的TCP/IP协议也就成为网络应用程序基于的首选协议。
一般采用TCP/IP协议的应用程序只实现了单用户与服务器间点对点的连接,而本文在VC6.0的环境下,运用了了多线程以及共享数据结构技术,不仅实现了多用户与服务器间的连接,而且解决了多用户间信息互发问题----依靠服务器的转发功能。通过本文的阐述,希望能对那些需要编写多用户网络通信程序的读者以启发。
一、技术概述
1.1 基于TCP/IP的通信技术
基于TCP/IP的通信基本上都是利用SOCKET套接字进行数据通讯,程序一般分为服务器端和用户端两部分。下面简要地讲一下设计思路(VC6.0下):
第一部分 服务器端
一、创建服务器套接字(create)。
二、服务器套接字进行信息绑定(bind),并开始监听连接(listen)。
三、接受来自用户端的连接请求(accept)。
四、开始数据传输(send/receive)。
五、关闭套接字(closesocket)。
第二部分 用户端
一、创建用户套接字(create)。
二、与远程服务器进行连接(connect),如被接受则创建接收进程。
三、开始数据传输(send/receive)。
四、关闭套接字(closesocket)。
通过以上设计思路,我们可以创建一个简单的面向连接的单用户程序。下面,将介绍多线程技术,以使程序支持多用户。
1.2 多线程技术
我们可以把线程看成是一个进程(执行程序)中的一个执行点,每个进程在任何给定时刻可能有若干个线程在运行。一个进程中的所有线程共享该进程中同样的地址空间,同样的数据和代码,以及同样的资源。进程中每个线程都有自己独立的栈空间,和其它线程分离,并且不可互相访问。每个线程在本进程所占的CPU时间内,要么以时间片轮换方式,要么以优先级方式运行。如果以时间片轮换方式运行,则每个线程获得同样的时间量;如果以优先级方式运行,则优先级高的线程将得到较多的时间量,而优先级低的线程只能得到较少的时间量。方式的选择主要取决于系统时间调度器的机制以及程序的实时性要求。
现在,运用多线程技术就可以实现对多用户的支持。即在服务器端,使接收来自用户端的连接请求(accept)这步无限循环,每接收一个用户请求,产生两个线程(send和receive线程),用来管理服务器与该用户的通信任务。下面,运用共享数据结构技术,就可以实现本问所要解决的关键技术---服务器转发技术。
1.3 共享数据结构技术
同一进程中的多个线程共存于单一的线性地址空间,因此,在多线程间共享数据结构是非常容易且方便的。但必须注意的是,对数据结构的访问必须是多线程互斥的,否则数据任意更改将导致不可预料的结果。本文所阐述的服务器转发技术也就是通过共享数据结构实现线程间的互相通信。
二、实现方案
整体方案的构思图如下:
通过上图,我们可以看到整个系统分为三个相关的程序,即注册/登陆服务器、通信服务器以及用户程序。其中,注册/登陆服务器负责用户的注册、登陆以及数据库管理;通信服务器负责完成数据转发以及共享数据结构的管理;用户端则完成注册、登陆和通信功能。为什么要把服务器分为两部分呢?主要是考虑到服务器的用户容量问题,以及对通信服务器的保护,只有在通过验证后,用户在能与通信服务器连接。
由此可见,整个系统通信任务的实现还是很复杂的。用户端首先必须注册自己,等待注册成功;然后根据自己的注册信息进行服务器登陆,登陆成功后才能与通信服务器连接,进行用户间通信。
注册/登陆服务器接收到用户端的信息后,首先判断是注册信息还是登陆信息。如果是注册信息,则将该数据按预定的格式写入数据库,然后返回注册成功的消息,期间有任何异常产生,服务器都会返回注册失败消息,提示用户重新注册;如果是登陆信息,则从数据中提取用户名和ID与数据库中的内容进行比较,如果该用户存在,则返回登陆成功消息,反之,返回登陆失败消息。
通信服务器所完成的主要功能是数据转发,这是通过与图中的共享数据结构进行交互完成的。服务器接收到用户端发来的消息后,提取消息的一部分与共享数据结构存储的内容进行比较,确定所要转发的对象,最后通过多线程及其通信机制完成数据转发。 下面,我们将分三部分来讨论系统的具体实现过程。
三、具体实施
3.1 注册/登陆服务器
注册/登陆服务器程序是基于对话框的,该程序使用I/O端口56789与用户端连接。
首先,在对话框初始化的同时完成网络初始化,即执行Init_net()函数,代码(不完整)如下:
01.
BOOL
CServerDlg::Init_net()
02.
{
网络初始化///
03.
addrLen=
sizeof
(SOCKADDR_IN);
04.
status=WSAStartup(MAKEWORD(1, 1), &Data);
05.
………
06.
memset
(&serverSockAddr, 0,
sizeof
(serverSockAddr));
07.
08.
/*以下指定一个与某个SOCKET连接本地或远程地址*/
09.
10.
serverSockAddr.sin_port=htons(PORT);
11.
serverSockAddr.sin_family=AF_INET;
12.
serverSockAddr.sin_addr.s_addr=htonl(INADDR_ANY);
13.
serverSocket=socket(AF_INET, SOCK_STREAM, 0);
//初始化SOCKET
14.
………
15.
16.
status=bind(serverSocket,(LPSOCKADDR)&serverSockAddr,
sizeof
(serverSockAddr));
//将SOCKET与地址绑定
17.
………
18.
status=listen(serverSocket, 5);
//开始监听
19.
………
20.
return
true
;
21.
}
接着按下RUN键开始服务器功能,执行Reg_Load()函数,使服务器始终处于等待连接状态,但这样也使该线程始终阻塞。当有用户连接时,该函数创建一个任务用于处理与用户及数据库的事务。具体任务函数略(详见原始代码文件)。
01.
void
CServerDlg::Reg_Load()
02.
{
03.
while
(1)
04.
{
05.
CWinThread* hHandle;
06.
clientSocket=accept(serverSocket,(LPSOCKADDR)&clientSockAddr,&addrLen);
//等待连接,阻塞
07.
hHandle=AfxBeginThread(talkToClient,(
LPVOID
)clientSocket);
//有连接时,创建任务
08.
………
09.
}
10.
}
任务函数在接收到消息时,要对数据库进行操作,由于数据库较简单,采用ODBC连接ACCESS数据库(将netuser.mdb在ODBC数据管理器中安装成同名数据源)具体代码略。
3.2 通信服务器
通信服务器是本程序实现的关键,它运用共享数据结构技术及多线程技术,通过I/O端口56790与用户端连接,实现了数据转发功能。首先,程序初始化网络Init_net(),接着当用户连接到服务器时,创建接收线程和发送线程,这样就可以实现数据转发。最后,当用户断开连接时,服务器关闭与他的连接,并结束相应的线程。
下面我们来看一下本程序中的共享数据结构的具体内容与使用方法以及多线程的相关内容与实现。
● 共享数据结构
本程序的共享数据结构一共有两个,即socket_info和send_info。前者包含了所有登陆用户的一些基本资料,后者则包含了当前服务器接收到的用户端所发送的信息资料。详细内容及注释如下:
01.
struct
socket_info
02.
{
03.
SOCKET s_client;
//用户的SOCKET值
04.
u_long client_addr;
//用户网络地址
05.
CString pet;
//用户昵称
06.
CWinThread*
thread
;
//为该用户创建的发送线程对象的指针
07.
};
08.
09.
struct
send_info
10.
{
11.
CString data;
//用户端发送的数据内容(经过编辑)
12.
CWinThread*
thread
;
//需要发送数据的任务指针
13.
};
在程序中,定义两个全局变量,用来在线程间共享:
1.
send_info info_data; CLists_info;
每当有用户连接到服务器,服务器就将用户端的一些信息以socket_info结构体的形式存入s_info列表中;而当服务器接收到用户端发送过来的数据时,就将数据格式化后存入结构体info_data,通过与结构体列表比较,确定需要恢复的发送线程(所有发送线程在创建时都被挂起)。这样,服务器就准确地转了发数据。
●多线程
每当服务器上有用户连接成功,服务器都会为其创建两个线程:接收线程(RecvData)和发送线程(SendData),并且接收线程在创建后处于可执行状态,而发送线程则阻塞,等待服务器将其唤醒。这两个线程都执行一个无限循环的过程,只有当通信出现异常或用户端关闭连接时,线程才被自身所结束,并且,这两个线程一定是同时生成,同时结束的。很显然,每个连接产生两个线程,使得数据转发变的简单,但同时又使得服务器的任务加重。因此,用户端的连接数量有所限制,视服务器软、硬件能力而定。
同时,由于多线程对结构体info_data都需要操作,所以线程间必须同步。这儿,我定义了互斥量CMutex m_mutex,用它的方法Lock()和Unlock()来完成同步。
我们首先来看一下接收线程(RecvData):(不完整代码)
01.
UINT
RecvData(
void
* cs)
02.
{
03.
SOCKET clientSocket=(SOCKET)cs;
04.
while
(1)
05.
{
06.
numrcv=recv(clientSocket, buffer, MAXBUFLEN, NO_FLAGS_SET);
07.
buffer[numrcv]=
''
\0
''
;
08.
if
(
strcmp
(buffer,
"Close!"
)!=0)
//不是接收的“Close”数据
09.
{
10.
…………
11.
for
(i=0;iResumeThread();
//恢复发送相应的线程
12.
break
;
13.
}
14.
}
15.
}
16.
else
17.
{
18.
…………
19.
if
(clientSocket==s1.s_client)
20.
{
21.
m_mutex.Lock();
//互锁
22.
info_data.data=buffer;
23.
m_mutex.Unlock();
//解锁
24.
s1.
thread
->ResumeThread();
//恢复发送相应的线程
25.
s_info.RemoveAt(po1);
//删除该用户信息
26.
break
;
27.
}
28.
………
29.
goto
aa;
30.
}
31.
}
32.
aa: closesocketlink((
LPVOID
)clientSocket);
//关闭连接
33.
AfxEndThread(0,
true
);
//结束本线程
34.
return
1;
35.
}
接下来看一下发送线程(SendData):(不完整代码)
01.
UINT
SendData(
void
* cs)
02.
{
03.
SOCKET clientSocket=(SOCKET)cs;
04.
while
(1)
05.
{
06.
if
(info_data.data!=
"Close!"
)
07.
{
08.
m_mutex.Lock();
//互锁
09.
numsnd=send(clientSocket,info_data.data,
10.
info_data.data.GetLength(),NO_FLAGS_SET);
//发送数据
11.
now=info_data.
thread
;
12.
m_mutex.Unlock();
//解锁
13.
now->SuspendThread();
//自身挂起
14.
}
15.
else
16.
{
goto
bb; }
17.
18.
}
19.
bb: closesocketlink((
LPVOID
)clientSocket);
//关闭连接
20.
AfxEndThread(0,
true
);
//结束本线程
21.
return
1;
22.
}
3.3 用户端
很显然,用户端不用考虑多线程,网络连接技术也比较成熟,因此在通信方面没有什么难题。但是,用户端是面向实际用户的,所以,不论是界面还是功能都必须友好。就像大多数软件的更新一样,界面友好度的提高以及功能的完善往往是放在首位的。由此可见,单从总体设计与技术实现角度来讲,用户端的工作量是十分大的,并且设计较服务器端复杂得 多。我粗略总结了以下几条:
●与服务器通信格式兼容;
●操作简单、易用,有美观的界面及快捷键;
●准确地接收和传输数据;
●所有的数据记录与提取功能;
●多种消息接收提示方式,比如托盘图标(发送者头像)闪烁、声音提示等;
根据以上内容,我设计了三个独立的对话框分别用来完成注册、登陆、通信功能,登陆和注册对话框与服务器的56789I/O端口连接,通信对话框与服务器的56790I/O端口连接,这样就很好地实现了注册登陆与通信的隔离,既能使服务器负载降低,同时又能保证一定的通信安全性。
由于本部分不是主要内容,详细代码见程序。
四、结束语
通过以上阐述可以知道,本系统分为服务器端和用户端,服务器端又分为注册/登陆服务器和通信服务器,通过通信服务器的转发功能实现了局域网内的多用户通信功能。本文运用了多线程技术和共享数据结构技术实现了通信服务器的转发功能,使一般基于TCP/IP的网络应用程序得到了发展。本系统已经在我实验室的局域网(一台服务器,二十台客户机)运行通过。
参考文献:
[1] Eugene Olafsen ,Kenn Scribner, K.David White等著. MFC Visual C++ 6.0编程技术内幕. 北京:机械工业出版社 2000.2
[2] Charles Wright. Visual C++程序员实用大全. 北京:中国水利水电出版社 2001.10
作者信息: 袁 渊(华东船舶工业学院 机械系,江苏 镇江 212003)
转自:http://www.vckbase.com/index.php/wv/200
下面还有一篇文章:对上述文章的补充:
《基于TCP的局域网多用户通信、文件传送程序详解》
看了袁渊先生在VC知识库《在线杂志》第14期发表的文章《基于TCP/IP的局域网多用户通信》,感觉受益颇多,但也觉得里面有一些不太完善的地方,具体来说主要有:
1.两个服务器单独运行,且主线程均阻塞,用户界面死锁,不便于控制;
2.聊天服务器线程和互斥量的使用可能导致死锁;
3.不能实现文件传送(文件传送可不能由服务器转发,否则非把它累趴下不可^-^);
4.不能由用户进行网络设置,所以在不同的网络使用必须修改源程序等等;
我在此基础上重新设计编写了一个系统,具体如下:
一、构架设计
整个系统分为三个相关的程序模块,即注册登陆服务器(wbQQRegSer)、聊天通信服务器(wbQQChat)以及用户程序(wbQQClient)。其中,注册登陆服务器负责用户的注册、登陆以及数据库管理;通信服务器负责完成数据转发以及共享数据结构的管理;用户端则完成注册、登陆、通信和文件传送功能。在进行文件传送时,任一客户程序均可以既作为文件传送服务器发送文件,也可以作为客户端接收文件,实现半双工的文件传送。整个系统构成如图一:
图一 系统构架图
二、注册登录服务器设计
注册登录服务器采用面向连接的并发式方式,服务器设计成为一个对话框程序。调用WSAStartup初始化动态库,socket函数创建套接字,bind函数绑定本地IP地址和端口,listen函数使套接字进入侦听,然后由于调用accept()函数将产生阻塞,所以不宜在主线程中调用该函数,因而在初始化网络后当用户按下“运行注册登录服务器”按钮后,利用侦听套接字启动注册登录线程RegLoad(void *s)进入无限循环,在线程中调用accept函数,用来接受来自客户端的连接请求,每当一个连接请求到来时,accept()函数将产生一个新的套接字,利用这个套接字产生一个新的线程talkToClient(void *cs)与客户端进行通信并读写数据库,通信完毕后关闭该套接字和线程,原来的侦听套接字继续处于侦听状态。
两个服务器程序可以在同一台物理机器上运行,也可以在不同的机子上运行,为方便服务器的控制,在注册登录服务器调用函数
01.
CreateProcess( NULL,
02.
".\\..\\wbQQChat\\wbQQChat.exe"
,
// Command line.
03.
NULL,
// Process handle not inheritable.
04.
NULL,
// Thread handle not inheritable.
05.
FALSE,
// Set handle inheritance to FALSE.
06.
0,
// No creation flags.
07.
NULL,
// Use parent''''s environment block.
08.
NULL,
// Use parent''''s starting directory.
09.
&si,
// Pointer to STARTUPINFO structure.
10.
&pi )
11.
创建聊天通信服务器进程,想关闭时则调用TerminateProcess(m_hProcChat, 2)函数关闭此进程。
三、聊天通信服务器设计
聊天通信服务器设计为无界面的进程(创建时先建一个基于对话框的应用程序,然后把对话框类删除,把APP类里面与对话框有关的语句全删除即可创建无界面进程),采用共享数据结构,为每个客户端创建两个线程,实现接收和转发的功能。第一个线程用于发送,
1.
hHandleSend = AfxBeginThread(SendData,(
LPVOID
)clientSocket,0,0,CREATE_SUSPENDED,NULL);
第二个线程用于接收:
1.
hHandleRecv = AfxBeginThread(RecvData, (
LPVOID
)clientSocket);
四、客户端设计
客户端设计成为对话框的用户界面,主要分成四个模块,分别是注册模块、登录模块、聊天模块和文件传送模块。
在程序运行后的第一个对话框,客户可以选择登录或注册,若是注册则启动注册向导,分三步完成注册工作,第一步为基本信息登记,包括头像选择、用户名、性别、密码,其中用户名和密码将在注册成功后登录使用。第二步为详细资料,包括真实姓名、城市、E-mail地址和电话号码。第三步为网络设置,分别是注册登录服务器的IP地址和端口号,聊天通信服务器的IP地址和端口号,也就是说两个服务器程序可以分别位于不同的物理机器,以减轻服务器运行时的负荷。点击确定后,客户端将与指定的IP地址和端口号去连接注册登录服务器,成功连接后服务器执行注册操作,并返回注册结果。
客户注册成功后,即可用注册时的用户名和密码进行登录,将登录信息按注册时的网络设置发往服务器,服务器执行登录操作并返回注册结果,登录成功则连接聊天通信服务器,否则退出程序。
登录成功出现聊天对话框,可以从下拉组合框选择好友,发送信息的同时将信息写入聊天记录文件,服务器收到信息后依照接收者用户名进行转发。若客户收到信息则闪动托盘处的图标,提示用户收到信息,用户可以点击回答进行回复。
当登录成功后,用户也可以在选择好友后点击传送文件按钮来进行文件传送。当客户A向客户B发送文件时,A弹出传送文件对话框,提示给B发送文件,等待B的回应,客户B将弹出消息框告知A向B发送文件,B可以接收也可以拒收。文件收发完毕后,点击关闭按钮关闭文件传送对话框。
五、网络传输协议设计
为了让客户端和服务器能够协同工作,必须在通信过程中定义一套规则也就是协议,让双方能够相互听懂,并依照协议执行相应的功能块。
客户端注册时发送的消息为Reg: + BasicDlg.m_strUserName + BasicDlg.m_nAge + sex + BasicDlg.m_strPassWd + MiscDlg.m_strTruName + MiscDlg.m_strCity + MiscDlg.m_strEmail + res + MiscDlg.m_strTel,注册时发送消息的头部为Reg。登录时发送的消息为:Load: + m_strUserName + m_strPassWd,登录时发送消息的头部为Load。注册登录服务器收到客房端的消息后检查其头部,若是Reg则执行注册操作,注册成功则返回success!,用户名已经存在则返回exist!,其它原因注册不成功则返回Error!;若是Load则执行登录操作,登录成功返回success!,登录不成功则返回error!。客户端依照返回信息做出相应提示,并执行相应功能模块。
登录成功后,客户端将自己的用户名发送给聊天通信服务器,服务器为客户端创建一个套接字,两个线程,并填充socketInfo结构,连入链表。客户端发送消息结构为:“接收者用户名” + “:” + “发送者头像ID” + “~” + “(星期、月、日、年、时、分、秒)” +"\t" +"发送者用户名" +“->” + “接收者用户名” +"\n\r" + “发送的消息”,其头部均为接收者用户名,服务器依照用户名查找链表,截掉头部后把原信息进行转发,若客户端关闭, 则发送消息为Close!,服务器从链表中删除相应项。
客户端可能收到的消息有三种,第一种为普通消息,结构如前所述;第二种为SendFile!,表示对方想向己方传送文件;第三种为Refuse!,表示对方拒绝接收己方文件。客户端A想给客户端B传送文件,则发送消息为SendFile!,B收到SendFile!后弹出消息框,提示对方向己方传送文件,接收按“是”,执行文件接收功能;拒绝按“否”,发送Refuse!
六、附加说明
1.本软件在win2000professionSP4 + vc6MFC环境下开发和测试通过,使用前要注册ODBC数据源;数据源名称:wbQQuser;类型:ACCESS;文件名:wbQQuser.mdb,不明了之处请参看源程序,注释很清楚。
2.多线程通信使用的全局变量导致函数耦合度较大。
3.有些函数太长,导致功能不单一,内聚度降低。
4.客户端点击传送文件后,应使该按钮无效,直到文件传送完毕或文件传送线程关闭再使之有效,如不使用全局变量有什么好办法实现。
我认为学好一种技术爱好是最好的老师,交流是最好的方法,请高手赐教。
QQ:2105629
Email:lwb75@sina.com。
志存高远,脚踏实地,生命不息,奋斗不止!