Windows socket IO完成端口开发驾照理论考试系统实例
这一节我们讲解如何利用套接字完成端口开发驾照理论考试系统。
该系统由服务器和客户端两部分组成。
服务器负责对题库和学生信息的管理,主要包括以下功能:
1:试卷管理:从题库读取试卷和向客户端发送试卷。
2:客户端管理(CClientManager类)。从数据库读取学生信息,验证学生信息。
3:监视考生考试状态。
4:评分。保存学生考试状态和成绩。
客户端负责生成试卷,主要包括以下功能:
1:登录服务器。
2:生成试卷。
3:考试计时。考试时间结束考生停止答题。
主要步骤如下:
客户端成功连接服务器后,向服务器发送学号。服务器在收到学号后,会验证该学号是否存在于数据库中。如果存在且未登录过,则向客户端发送该学生姓名和试卷。否则,项客户端发送查无此人信息。
客户端在收到试卷后,向考生显示准备完成,是否开始答题。考生点击开始答题后,客户端向服务器发送开始答卷消息。考生开始答卷,客户端进行计时。考试结束后,客户端向服务器发送答题结果。服务器在收到答题结果后,对该考生的试卷进行评分,并将结果保存在数据库。
数据包设计:
为了保证客户端与服务器之间数据的正确发送与接收,在发送数据时,先发送包头后发送包体。包头指明包体的类型和长度。包头定义如下:
typedef struct _header { u_short type;//包类型。 u_short len;//包体长度。 }HEADER;
客户端发送的数据包类型包括考生状态和心跳包两种。在考试期间为了监控考生状态,客户端定期向服务器发送心跳包,服务器根据此心跳包判断客户端状态。如果由于意外导致连接断开,服务器在一段时间内没有收到心跳包则断定客户端断线。
客户端在发送包头后,发送考生状态,它包括以下几种:
登录:此时考生状态为考生学号。LOGIN
答卷:此时考生状态没有数据。DOING
交卷:此时考生状态为答题结果。DONE
断线:在服务器一段时间没有收到心跳包后,设置客户端为断线状态。DISCONN
服务器发送的数据包类型包括:
考生姓名和试卷。
服务器在验证考生学号后,向客户端发送该考生的姓名和试卷。
工作流程:
服务器启动后,调用InitSocket初始化监听套接字。读取数据库,将试卷读入内存,为发送做准备。调用启动服务函数,在启动服务函数内创建接受客户端请求线程和服务线程。创建监听线程使用WSAEventSelect模型管理请求套接字。服务线程可以有多个,循环调用GetQueuedCompletionStatus函数,检查是否有异步IO已完成。
当有套接字请求时,接受线程接受请求,并创建CClient对象,加入CClientManager管理的客户端链表。并调用CClient的AsyRecvHeader执行接收包头异步IO操作。
当有异步IO完成时,GetQueuedCompletionStatus函数返回,根据IO操作类型IOType决定执行何种操作。
如果是接收包头异步IO完成,根据包头指定的类型判断是心跳包,或是状态包。
如是状态包,则调用接受包体异步IO函数。在接收包体函数完成后,前两字节指定的状态,执行操作。如果状态是LOGIN,则是登录状态。包体长度为hdr.Len。前2字节为当前状态,两字节后为学号,长度为hdr.Len-2。接收到学号后,从数据库查询如果存在此学号,且此学号未登录则发送姓名。否则发送登录失败。GetQueuedCompletionStatus返回时,收到发送姓名异步IO完成后,发送试卷。客户端收到试卷后对试卷进行解析,生成试卷。
客户端向服务器发送考试开始包,考试开始,服务器更新考生状态为正在考试。客户端设置一个计时器,每10s向服务器发送一个心跳包,表明当前客户端处于在线状态,并未离线。服务器也定义一个计时器,它每隔60s触发一次,在响应函数内遍历所有连接的客户端,检查它发送心跳包的时间与当前时间差,如果大于60s则说明客户端已掉线。将此客户端从链表中删除。并更新对应考生状态为掉线。
考生做完所有试题后,点击交卷,客户端向服务器发送DONE包,包体部分为答案。服务器更新考生状态,并计算分数更新到列表控件和服务器。然后删除客户端,整个过程结束。
//客户端向服务器发送的包类型。
#define PULSE 107//心跳包。
#define REQUEST 108//请求包。
//服务器向客户端发送的包类型:
#define STUNAME 109//学生姓名。
#define PAPER 110//试卷包。
#define LOGINFAILED 111//登录失败。
考试系统的试题类型为选择题。它包括试题和答案两部分。题号与问题之间使用:分割。每道题有四个答案,每个答案之间用|分割。试题之间用<>分割。如
<1:问题|A:答案|B:答案|C:答案|D:答案><:问题2|A:答案|B:答案|C:答案|D:答案><:问题3|A:答案|B:答案|C:答案|D:答案><:问题4|A:答案|B:答案|C:答案|D:答案>
数据库设计
Access数据中存储了考生信息和试卷信息。此处定义了两张表,分别为StuInfo表和Paper表。。使用ODBC与数据库连接。由于本文更偏重与介绍IOCP的机制,因此对于数据库如何使用此处不再介绍。
StuInfo表包括Stu_ID ,Stu_NO,Stu_Name,Stu_State,Stu_Grade字段。Stu_ID为主键。
Paper表包括PAP_ID,PAP_QUESTION,PAP_ANSWERA,PAP_ANSWERB,PAP_ANSWERC,PAP_ANSWERD,PAP_ANSWER。
服务器设计:
服务器负责发送试题和对考生考试信息进行管理。它包括多线程设计和界面设计。
多线程设计:
主线程启动后,创建一个接受客户端连接请求的线程和多个服务线程。服务线程使用套接字的IO完成端口模型对服务器的IO操作进行管理。监听线程使用套接字的WSAEventSelect模型实现对接受客户端请求进行管理。
当服务器退出时,主线程通知接受客户端线程和服务线程退出,然后主线程退出。
在接受客户端连接请求线程中,接受客户端的连接请求后,立即发出一个接受客户端数据包头的异步请求。在服务线程取出IO操作结果后,再发起另一个异步IO操作。
一:主线程:负责初始化界面、读取数据库、更新界面、更新数据库、创建完成端口和通知接受请求线程和服务线程退出。
二:接受客户端请求线程,利用WSAEventSelect模型实现对客户端请求的管理。主要任务为:
1:接受客户端连接请求。
2:将完成端口与套接字关联起来。
3:发起异步接收客户端数据操作。
三:服务线程,利用IO完成端口实现对IO操作管理。主要任务为:
1:管理客户端。
2:发送考生姓名和试卷。
3:接收客户端数据。
4:考试结束后,对考生答卷进行评分。
服务器程序是基于单文档的MFC应用程序。主要包括一下几个类:
1:CCServerView类:视图类,实现服务器的主要功能。
2:CClient类:实现与客户端通信功能。
3:CClientManager类,实现对连接的客户端进行管理功能。
4:CServerAddrDlg类:实现IP地址输入窗口。
CCServerView类:
OnInitialUpdate函数实现服务器初始化功能:初始化列表试图控件、更新列表试图控件和读取试卷。
OnStartService实现启动服务功能。它创建监听套接字、创建完成端口、创建监听事件对象和接受客户端请求线程和服务线程。
在服务器保存一定数量的习题。在OnInitialUpdate函数中会将这些试题读入到m_cPaper数组中。
CCServerView类的用户自定义部分为:
public: static DWORD WINAPI AcceptClientThread(PVOID ppram); static DWORD WINAPI ServiceThread(PVOID ppram); bool InitSocket();//初始化套接字。 bool StartService();//开始服务。 bool StopService();//停止服务。 CString AuthenticateStuNo(char*);//验证学号。 bool ReadPaper();//从数据库读取试卷信息。 bool ReadStuInfo();//从数据库读取学生信息。 void ContructPaperBuffer();//构造 void UpdateClientState(CClient *pClient,USHORT state); void Destroy(); bool SaveGradeIntoDB(CClient*pClient);//保存成绩到数据库。 bool IsAlreadyLogin(CString StuName);//判断考生是否已登录。 public: HANDLE m_hIOCP;//完成端口句柄。 HANDLE m_hEvent;//监听套接字对应事件对象。 HANDLE m_h[SERVICE_NUMBER+1];//线程句柄。 SOCKET m_sListenSocket;//监听套接字。 bool m_IsRunning;//判断服务器是否运行。 CDatabase m_StuInfoDB;//考生信息数据库类。 CDatabase m_PaperDB;//试卷数据库类。 CListCtrl m_listCrtl;//列表控件。 PaperItem *m_pPaperArray;//试题数组。PaperItem定义马上介绍。 UINT m_numOfPaperItem;//试卷试题数量。 CString m_PaperBuff;//试卷缓冲区。 afx_msg void OnTimer(UINT_PTR nIDEvent);//计时器函数,用于检查心跳包 afx_msg void OnStartService();//服务器开始运行。
实现为:
// CIOCPDriverLisenceExamServerView 消息处理程序 void CIOCPDriverLisenceExamServerView::OnInitialUpdate() { CView::OnInitialUpdate(); // TODO: 在此添加专用代码和/或调用基类 m_hIOCP=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,0,0); CString sDriver = TEXT("MICROSOFT ACCESS DRIVER (*.mdb)"); CString sDsnStuInfo; CString sFileStuInfo = TEXT("E://DriverLiscenceExam.mdb");//Change path here sDsnStuInfo.Format(TEXT("ODBC;DRIVER={%s};DSN='';DBQ=%s"),sDriver,sFileStuInfo); bool ret=m_StuInfoDB.Open(NULL,false,false,sDsnStuInfo); if(!ret) { MessageBox(TEXT("连接数据库失败!")); } InitSocket(); ReadPaper(); ReadStuInfo(); ContructPaperBuffer(); } bool CIOCPDriverLisenceExamServerView::InitSocket() { WSAData wsadata; WSAStartup(MAKEWORD(2,2),&wsadata); m_sListenSocket=socket(AF_INET,SOCK_STREAM,0); if(m_sListenSocket==INVALID_SOCKET) { closesocket(m_sListenSocket); WSACleanup(); return false; } m_hEvent=WSACreateEvent(); WSAEventSelect(m_sListenSocket,m_hEvent,FD_ACCEPT);//为监听套接字设置FD_ACCEPT事件。 SOCKADDR_IN addr; addr.sin_family=AF_INET; addr.sin_addr.S_un.S_addr=inet_addr("192.168.1.100"); addr.sin_port=htons(4000); int ret=bind(m_sListenSocket,(SOCKADDR*)&addr,sizeof(addr)); if(ret==SOCKET_ERROR) { return false; } ret=listen(m_sListenSocket,1000); if(ret==SOCKET_ERROR) { return false; } } DWORD WINAPI CIOCPDriverLisenceExamServerView::AcceptClientThread( PVOID ppram ) { CIOCPDriverLisenceExamServerView*pServer=(CIOCPDriverLisenceExamServerView*)ppram; SOCKADDR_IN addr; int len=sizeof(addr); while(pServer->m_IsRunning) { int ret=WSAWaitForMultipleEvents(1,&pServer->m_hEvent,false,WSA_INFINITE,false); if(ret==WSA_WAIT_TIMEOUT) { continue; } else { WSANETWORKEVENTS events; int r=WSAEnumNetworkEvents(pServer->m_sListenSocket,pServer->m_hEvent,&events);//重置事件对象。 if(r==SOCKET_ERROR) { break; } if(events.lNetworkEvents&FD_ACCEPT) { if(events.iErrorCode[FD_ACCEPT_BIT]==0)//发生FD_ACCEPT网络事件。接受客户端请求。 { SOCKET sAccept=WSAAccept(pServer->m_sListenSocket,(SOCKADDR*)&addr,&len,0,NULL); CClient*pClient=new CClient(sAccept,pServer); if(CreateIoCompletionPort((HANDLE)sAccept,pServer->m_hIOCP,(ULONG_PTR)pClient,0)==NULL) return -1; g_clientManager.addClient(pClient); //调用接收数据异步IO。 if(!pClient->AsyRecvHeader()) g_clientManager.deleteClient(pClient);//接收数据失败后,将此客户端从链表删除。 } } } } return 0; } bool CIOCPDriverLisenceExamServerView::StartService() { m_IsRunning=true; m_h[0]=CreateThread(NULL,0,AcceptClientThread,this,0,NULL); for(int i=1;i<SERVICE_NUMBER;i++) { m_h[i]=CreateThread(NULL,0,ServiceThread,this,0,NULL); } //设置计时器。 客户端会每隔5秒,向服务器发送心跳包。计时器每个1分钟检查每个客户端。 //看当前时间与客户端发送的最近一次心跳包是否大于1分钟。如大于说明客户端已断开。 SetTimer(1,10000,NULL); return true; } bool CIOCPDriverLisenceExamServerView::StopService() { m_IsRunning=false; return true; } DWORD WINAPI CIOCPDriverLisenceExamServerView::ServiceThread( PVOID ppram ) { CIOCPDriverLisenceExamServerView*pServer=(CIOCPDriverLisenceExamServerView*)ppram; // IO_OPERATION_DATA *pio_operation_data; LPOVERLAPPED lpoverlapped; CClient *pClient; DWORD transferred; while(pServer->m_IsRunning) { bool ret=GetQueuedCompletionStatus(pServer->m_hIOCP,&transferred,(LPDWORD)&pClient,&lpoverlapped,WSA_INFINITE); if(ret&&lpoverlapped&&pClient)//成功的异步IO完成。根据从lpoverlapped中得到的类型,进行操作。 { IO_OPERATION_DATA*pIO=(IO_OPERATION_DATA*)lpoverlapped; switch(pIO->IOType) { case IOReadHead: { pClient->AsyRecvHeaderCompleted(); } break; case IOReadBody: { pClient->AsyRecvBodyCompleted(); } break; case IOWritePaper: { //试卷发送完毕。不执行动作。 pServer->UpdateClientState(pClient,CClient::LOGIN); } break; case IOWriteName: { pClient->AsySendPaper(); } break; case IOWriteUnLogin: { g_clientManager.deleteClient(pClient); } break; default: break; } } } return 0; } CString CIOCPDriverLisenceExamServerView::AuthenticateStuNo( char*pStuNo) { //TCHAR wStuNo[128]; //ZeroMemory(wStuNo,128); //MultiByteToWideChar(CP_ACP,MB_COMPOSITE,pStuNo,strlen(pStuNo),wStuNo,128);//TCP是基于字节流的,而此程序是使用Unicode编码。因此需要转换。 CStuInfo StuInfo(&m_StuInfoDB); if(StuInfo.IsOpen()) { StuInfo.Close(); } StuInfo.m_strFilter.Format(TEXT("StuNo='%s'"),pStuNo); StuInfo.Open(); if(StuInfo.IsEOF())//数据库中未搜索到该用户。该账号不存在。 { //MessageBox(TEXT("未找到该用户。")); return CString(); } else { //MessageBox(TEXT("找到该用户。")); return CString(StuInfo.m_StuName); } } bool CIOCPDriverLisenceExamServerView::ReadPaper() { CPaper paperRecordSet(&m_PaperDB); if(paperRecordSet.IsOpen()) { paperRecordSet.Close(); } paperRecordSet.Open(); //long numInDB=paperRecordSet.GetRecordCount(); //paperRecordSet.GetRe m_numOfPaperItem=0; while(!paperRecordSet.IsEOF()) { m_numOfPaperItem++; paperRecordSet.MoveNext(); } paperRecordSet.MoveFirst(); m_pPaperArray=new PaperItem[m_numOfPaperItem]; if(!m_pPaperArray) return false; for(int j=0;j<m_numOfPaperItem;j++) { m_pPaperArray[j].PAP_ID=paperRecordSet.m_PAP_ID; m_pPaperArray[j].PAP_QUESTION=paperRecordSet.m_PAP_QUESTION; m_pPaperArray[j].PAP_ANS_A=paperRecordSet.m_PAP_ANS_A; m_pPaperArray[j].PAP_ANS_B=paperRecordSet.m_PAP_ANS_B; m_pPaperArray[j].PAP_ANS_C=paperRecordSet.m_PAP_ANS_C; m_pPaperArray[j].PAP_ANS_D=paperRecordSet.m_PAP_ANS_D; m_pPaperArray[j].PAP_ANSWER=paperRecordSet.m_PAP_ANSWER; paperRecordSet.MoveNext(); } return true; } int CIOCPDriverLisenceExamServerView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) == -1) return -1; // TODO: 在此添加您专用的创建代码 CRect rect; GetClientRect(&rect); m_listCrtl.Create(WS_CHILD|WS_VISIBLE|WS_BORDER|LVS_REPORT,rect,this,1); //m_listCrtl.SetBkColor(RGB(255,255,255)); //m_listCrtl.SetTextColor(RGB(0,0,0)); //m_listCrtl.SetTextBkColor(RGB(117,151,240)); m_listCrtl.SetExtendedStyle(LVS_EX_FULLROWSELECT|LVS_EX_GRIDLINES|LVS_EX_HEADERDRAGDROP); int cxScreen = ::GetSystemMetrics(SM_CXSCREEN); //获得屏幕宽 int cyScreen = ::GetSystemMetrics(SM_CYSCREEN); //获得屏幕高 rect.top=0; rect.bottom = cyScreen; rect.left = 0; rect.right =cxScreen ; //MoveWindow(rect); m_listCrtl.MoveWindow(rect); //}}控件跟随窗口大小变化 m_listCrtl.InsertColumn(0,_T("考 生 ID"),LVCFMT_LEFT,100); m_listCrtl.InsertColumn(1,_T("考 号"),LVCFMT_LEFT,100); m_listCrtl.InsertColumn(2,_T("姓 名"),LVCFMT_LEFT,150); m_listCrtl.InsertColumn(3,_T("考 试 状 态"),LVCFMT_LEFT,100); m_listCrtl.InsertColumn(4,_T("考 试 成 绩"),LVCFMT_LEFT,100); return 0; } bool CIOCPDriverLisenceExamServerView::ReadStuInfo() { CStuInfo StuInfo(&m_StuInfoDB); if(StuInfo.IsOpen()) { StuInfo.Close(); } StuInfo.Open(); int i=0; while(!StuInfo.IsEOF()) { m_listCrtl.InsertItem(i,StuInfo.m_StuID); m_listCrtl.SetItemText(i,1,StuInfo.m_StuNo); m_listCrtl.SetItemText(i,2,StuInfo.m_StuName); m_listCrtl.SetItemText(i,3,StuInfo.m_StuState); i++; StuInfo.MoveNext(); } return true; } //构造试题,准备发送。 void CIOCPDriverLisenceExamServerView::ContructPaperBuffer() { for(int i=0;i<m_numOfPaperItem;i++) { CString temp; temp.Format("<%d:%s|%s|%s|%s|%s>",m_pPaperArray[i].PAP_ID,m_pPaperArray[i].PAP_QUESTION ,m_pPaperArray[i].PAP_ANS_A,m_pPaperArray[i].PAP_ANS_B,m_pPaperArray[i].PAP_ANS_C,m_pPaperArray[i].PAP_ANS_D); m_PaperBuff+=temp; } } void CIOCPDriverLisenceExamServerView::OnTimer(UINT_PTR nIDEvent) { // TODO: 在此添加消息处理程序代码和/或调用默认值 EnterCriticalSection(&g_clientManager.m_cs); for(std::list<CClient*>::iterator iter=g_clientManager.m_ClientList.begin();iter!=g_clientManager.m_ClientList.end();) { CClient*pClient=(*iter); if(pClient->m_state==CClient::DOING) { CTime CurTime=CTime::GetCurrentTime(); CTimeSpan spanTime(CurTime.GetDay()-pClient->m_time.GetDay(), CurTime.GetHour()-pClient->m_time.GetHour(), CurTime.GetMinute()-pClient->m_time.GetMinute(),CurTime.GetSecond()-pClient->m_time.GetSecond()); if(spanTime.GetMinutes()>1) { //设置考生为断线状态。 pClient->m_state=CClient::DISCONN; //更新显示状态。 UpdateClientState(pClient,CClient::DISCONN); //删除该客户端。 g_clientManager.deleteClient(pClient); iter=g_clientManager.m_ClientList.begin(); } else iter++; } } LeaveCriticalSection(&g_clientManager.m_cs); CView::OnTimer(nIDEvent); } void CIOCPDriverLisenceExamServerView::UpdateClientState( CClient *pClient,USHORT state ) { int i=0; for(i=0;i<m_listCrtl.GetItemCount();i++) { if(pClient->m_StuName==m_listCrtl.GetItemText(i,2)) { break; } } CString s; switch(state) { case CClient::LOGIN: s="已登录"; break; case CClient::DOING: s="正在答题"; break; case CClient::DONE: { s="已交卷"; SaveGradeIntoDB(pClient); } break; case CClient::DISCONN: s="掉线"; break; default: s="未知状态"; break; } m_listCrtl.SetItemText(i,3,s); } void CIOCPDriverLisenceExamServerView::Destroy() { KillTimer(1); } void CIOCPDriverLisenceExamServerView::OnStartService() { // TODO: 在此添加命令处理程序代码 StartService(); } bool CIOCPDriverLisenceExamServerView::SaveGradeIntoDB(CClient*pClient) { //在列表空间更新分数。 int i=0; for(i=0;i<m_listCrtl.GetItemCount();i++) { if(pClient->m_StuName==m_listCrtl.GetItemText(i,2)) { //m_listCrtl.SetItemText(i,4); break; } } CString s; s.Format("%d",pClient->m_grade); m_listCrtl.SetItemText(i,4,s); //在数据库更新分数。 CStuInfo StuInfo(&m_StuInfoDB); if(StuInfo.IsOpen()) { StuInfo.Close(); } StuInfo.m_strFilter.Format(TEXT("StuNo='%s'"),pClient->m_StuNo); StuInfo.Open(); if(!StuInfo.IsEOF())//数据库中未搜索到该用户。该账号不存在。 { StuInfo.Edit(); StuInfo.m_StuGrade=pClient->m_grade; StuInfo.m_StuState="已考试"; StuInfo.Update(); StuInfo.Close(); g_clientManager.deleteClient(pClient); return true; } return false; } bool CIOCPDriverLisenceExamServerView::IsAlreadyLogin( CString StuName ) { std::list<CClient*>::iterator iter; for(iter=g_clientManager.m_ClientList.begin();iter!=g_clientManager.m_ClientList.end();iter++) { if((*iter)->m_StuName==StuName) break; } if(iter==g_clientManager.m_ClientList.end()) return false; else return true; }
CClient类。
CClient类来实现服务器与客户端的通信。类的构造函数为套接字和CCServerView*指针。有了CCServerView指针,CClient对象就可以调用相关函数修改考生信息。在析构函数中关闭套接字。
在该类中定义了一些与客户端通信的函数。类中有两个WSAOVERLAPPED扩展结构的变量,分别对应接收和发送数据异步操作。m_time表示接收到客户端心跳包的事件。
m_state表示客户端的当前状态。它是state枚举类型。定义如下:
enum state { LOGIN,//已登录状态。 DOING,//正在答题状态。 DONE,//已交卷状态。 DISCONN//故障掉线。 };
CClient有一个函数用以接收包头,接收包头后,根据包头的类型,包体长度,在调用不同的函数接收包体。这在使用Windows socket开发中经常使用。这样的结构可以使程序结构变得更清晰。
由于接收和发送数据异步IO完成的时间不确定,每个异步IO函数,还分别对应一个完成函数。AsyRecvHeader和AsyRecvBody仅仅用于发出异步IO请求,而对应的完成函数用以处理在接收数据完成后的工作。这种编程方式要特别注意。
每一个异步IO请求对应着一个OVERLAPPED结构和一个完成键。我们常常会重新定义OVERLAPPED结构,并传给完成键对我们有用的信息,这样在GetQueuedCompletionStatus返回时就会得到更多的信息。
重新定义后的OVERLAPPED结构信息为:
typedef struct _IO_OPERATION_DATA { OVERLAPPED overlapped; char buffer[BUFFER_SiZE]; byte IOType;//IO类型。用以得知哪种异步IO完成。收or发。 }IO_OPERATION_DATA;
在每个异步IO完成时,都可以根据IO类型得到是什么IO完成。一定要区分IO类型和包类型。这很重要。
IO类型有以下几种:
#define IOReadHead 100//接收包头完成,异步IO完成。 #define IOReadBody 101//接收包体完成,异步IO完成。 #define IOWriteUnLogin 103//登录失败。 #define IOWritePaper 104//发送试卷完成,异步IO完成。 #define IOExit 105//退出。 #define IOWriteName 106//发送姓名异步IO完成。
定义包头。服务器和客户端互相通信离不开包头。包头可以指定,数据包的类型和包体的长度。
定义如下:
typedef struct HEADER { short PacketType;//包类型。 short Len;//包体长度。 }HDR,*PHDR;
CClient类。几乎所有前面介绍过的socket程序都有一个CClient类,它用于执行与客户端通信的功能。也是在这个IOCP开发的驾照考试系统最重要的一个类。
声明如下:
#pragma once #include"WinSock2.h" #include"ctime" class CIOCPDriverLisenceExamServerView; #define BUFFER_SIZE 10240 #define IOReadHead 100 #define IOReadBody 101 #define IOWriteUnLogin 103 #define IOWritePaper 104 #define IOExit 105 #define IOWriteName 106 //客户端向服务器发送的包类型。 #define PULSE 107//心跳包。 #define REQUEST 108//请求包。 //服务器向客户端发送的包类型: #define STUNAME 109//学生姓名。 #define PAPER 110//试卷包。 #define LOGINFAILED 111//登录失败。 typedef struct HEADER { short PacketType; short Len; }HDR,*PHDR; typedef struct _IO_OPERATION_DATA { WSAOVERLAPPED overlapped; char buffer[BUFFER_SIZE]; HDR hdr; byte IOType; }IO_OPERATION_DATA; class CClient { public: enum state//考生状态。 { LOGIN,//已登录。 DOING,//正在答题。 DONE,//已交卷。 DISCONN,//因故障断开。 UNKNOW//原因不明。 }; CClient(SOCKET s,CIOCPDriverLisenceExamServerView*pDlg); ~CClient(void); public: bool AsyRecvHeader();//接收包头。 bool AsyRecvBody(int len); void AsyRecvBodyCompleted(); bool AsySendName(); bool AsySendPaper(); bool AsySendFailedLoginMsg(); void AsyRecvHeaderCompleted(); void CalculateGrade(); public: public: SOCKET m_s; IO_OPERATION_DATA m_IoRecv;//recv IO_OPERATION_DATA m_IoSend; USHORT m_state;//考生状态。 CTime m_time;//最近一次心跳包时间。 CIOCPDriverLisenceExamServerView*m_pServerView;//主窗口指针。 public: CString m_StuNo; //CString m_StuID; CString m_StuName; long m_grade; CString m_Result;//答题结果。 };
CClient类实现为:
#include "StdAfx.h" #include "Client.h" #include"IOCPDriverLisenceExamServerView.h" CClient::CClient( SOCKET s,CIOCPDriverLisenceExamServerView*pDlg ) { m_s=s; m_pServerView=pDlg; m_time=CTime::GetCurrentTime(); } CClient::~CClient(void) { closesocket(m_s); } //读取包头异步函数。 bool CClient::AsyRecvHeader() { WSABUF wsabuf; ZeroMemory(&m_IoRecv,sizeof(IO_OPERATION_DATA)); m_IoRecv.IOType=IOReadHead; wsabuf.buf=(char*)&m_IoRecv.hdr; wsabuf.len=sizeof(HDR); DWORD flag=0; int ret=WSARecv(m_s,&wsabuf,1,NULL,&flag,&m_IoRecv.overlapped,NULL); if(ret==SOCKET_ERROR) { int err=WSAGetLastError(); if(err!=WSA_IO_PENDING) { return false; } } return true; } //包头接收完毕。 void CClient::AsyRecvHeaderCompleted() { if(m_IoRecv.hdr.PacketType==PULSE)//如果为心跳包。//客户端发送给服务器的包头的PacketType有两种。一种是心跳包,另外是REQUEST包。 //在数据的前两个字节会指定当前请求,有LOGIN,DOING ,DONE等。 { m_time=CTime::GetCurrentTime(); AsyRecvHeader(); } else //if(state==m_IoRecv.hdr.PacketType)//状态包。接着接收包体。 { AsyRecvBody(m_IoRecv.hdr.Len); } } //接收包体。 bool CClient::AsyRecvBody(int len) { WSABUF wsabuf; //ZeroMemory(&m_IoRecv.buffer,BUFFER_SIZE); ZeroMemory(&m_IoRecv,sizeof(IO_OPERATION_DATA)); m_IoRecv.IOType=IOReadBody; wsabuf.buf=m_IoRecv.buffer; wsabuf.len=len;//接收包体。 DWORD flag=0; int ret=WSARecv(m_s,&wsabuf,1,NULL,&flag,&m_IoRecv.overlapped,NULL); if(ret==SOCKET_ERROR) { int err=WSAGetLastError(); if(err!=WSA_IO_PENDING) { return false; } } return true; } extern CClientManager g_clientManager;//全局的管理客户端类。 void CClient::AsyRecvBodyCompleted() { //根据包体的内容,确定是什么类型的请求包。是请求学生姓名还是请求发送试卷。 //前2个字节用于确定类型。 u_short type; memcpy(&type,m_IoRecv.buffer,2); switch(type) { case LOGIN://登录。 { //获取学号。 char StuNo[128]; strcpy(StuNo,(m_IoRecv.buffer+2)); CString name=m_pServerView->AuthenticateStuNo(StuNo); if(!name.IsEmpty()&&!m_pServerView->IsAlreadyLogin(name))//验证成功。 { m_StuNo=StuNo; m_StuName=name; AsySendName();//在发送姓名异步IO完成收到通知后,再发送试卷。 AsyRecvHeader(); } else//验证失败。 { AsySendFailedLoginMsg(); } } break; case DOING://答题状态。 { m_state=type; AsyRecvHeader(); } break; case DONE://交卷状态。 { m_state=type; //从包体中得到考生答案。 m_Result=m_IoRecv.buffer+2; CalculateGrade(); } break; default: break; } m_pServerView->UpdateClientState(this,type); } //发送考生姓名。 bool CClient::AsySendName() { ZeroMemory(&m_IoSend,sizeof(IO_OPERATION_DATA)); WSABUF wsabuf[2]; //char name[128]; //memset(name,0,128); // WideCharToMultiByte(CP_ACP,WC_COMPOSITECHECK,m_StuName.GetString(),m_StuName.GetLength(),name,128,NULL,NULL); m_IoSend.IOType=IOWriteName; m_IoSend.hdr.Len=m_StuName.GetLength(); m_IoSend.hdr.PacketType=STUNAME; //发送包头。 wsabuf[0].buf=(char*)&m_IoSend.hdr; wsabuf[0].len=sizeof(HDR); //送包体。 wsabuf[1].buf=m_StuName.GetBuffer(); wsabuf[1].len=m_StuName.GetLength(); DWORD flag=0; int ret=WSASend(m_s,wsabuf,2,NULL,flag,&m_IoSend.overlapped,NULL); if(ret==SOCKET_ERROR) { int err=WSAGetLastError(); if(err!=WSA_IO_PENDING) { return false; } } return true; } //发送登录失败。 bool CClient::AsySendFailedLoginMsg() { ZeroMemory(&m_IoSend,sizeof(IO_OPERATION_DATA)); WSABUF wsabuf[2]; m_IoSend.IOType=IOWriteUnLogin; m_IoSend.hdr.Len=0; m_IoSend.hdr.PacketType=LOGINFAILED; //发送包头。 wsabuf[0].buf=(char*)&m_IoSend.hdr; wsabuf[0].len=sizeof(HDR); //发送包体 //wsabuf[1].buf="" int ret=WSASend(m_s,wsabuf,1,NULL,0,&m_IoSend.overlapped,NULL); if(ret==SOCKET_ERROR) { int err=WSAGetLastError(); if(err!=WSA_IO_PENDING) { return false; } } return true; } void CClient::CalculateGrade() { m_grade=0; for(int i=0;i<m_pServerView->m_numOfPaperItem;i++) { if(m_Result.GetAt(i)-'A'+1==m_pServerView->m_pPaperArray[i].PAP_ANSWER) { m_grade++; } } } //发送试卷。 bool CClient::AsySendPaper() { ZeroMemory(&m_IoSend,sizeof(m_IoSend)); WSABUF wsabuf[2]; //发送包头: wsabuf[0].buf=(char*)&m_IoSend.hdr; wsabuf[0].len=sizeof(HDR); //发送包体。 wsabuf[1].buf=m_pServerView->m_PaperBuff.GetBuffer(); wsabuf[1].len=m_pServerView->m_PaperBuff.GetLength(); m_IoSend.IOType=IOWritePaper; m_IoSend.hdr.PacketType=PAPER; m_IoSend.hdr.Len=wsabuf[1].len; int ret=WSASend(m_s,wsabuf,2,NULL,0,&m_IoSend.overlapped,NULL); if(ret==SOCKET_ERROR) { int err=WSAGetLastError(); if(err!=WSA_IO_PENDING) { return false; } } return true; }
PaperItem为保存试题的结构体,其定义为:
typedef struct _PaperItem { LONG PAP_ID; //CString PAP_NO; CString PAP_QUESTION; CString PAP_ANS_A; CString PAP_ANS_B; CString PAP_ANS_C; CString PAP_ANS_D; BYTE PAP_ANSWER; }PaperItem;
CClientManager类:用以管理接受连接的客户端。内部是使用list实现,主要具有添加、删除、删除所有元素等成员函数。list的元素类型为CClient*类型,这样以后就可以对所有连接的客户端进行管理。注意,对list进行操作时,应该使用关键段或其他同步措施进行同步。
声明如下:
#pragma once #include"Client.h" //class CClient; #include<list> class CClientManager { public: CClientManager(void); ~CClientManager(void); public: std::list<CClient*>m_ClientList; CRITICAL_SECTION m_cs;//用于线程互斥的关键段。防止同时对list进行修改。 public: bool addClient(CClient*pClient); bool deleteClient(CClient*pClient); bool delteAllClient(); };
实现为:
#pragma once #include "StdAfx.h" #include "ClientManager.h" CClientManager::CClientManager(void) { InitializeCriticalSection(&m_cs); } CClientManager::~CClientManager(void) { for(std::list<CClient*>::iterator iter=m_ClientList.begin();iter!=m_ClientList.end();iter++) { delete (*iter); } m_ClientList.clear(); } bool CClientManager::addClient( CClient*pClient ) { EnterCriticalSection(&m_cs); m_ClientList.push_back(pClient); LeaveCriticalSection(&m_cs); return true; } bool CClientManager::deleteClient( CClient*pClient ) { //std::list<<CClient*>::iterator iter=m_ClientList.find(pClient); EnterCriticalSection(&m_cs); delete pClient; m_ClientList.remove(pClient); LeaveCriticalSection(&m_cs); return true; } bool CClientManager::delteAllClient() { EnterCriticalSection(&m_cs); for(std::list<CClient*>::iterator iter=m_ClientList.begin();iter!=m_ClientList.end();iter++) { delete (*iter); } m_ClientList.clear(); LeaveCriticalSection(&m_cs); return true; }
OnTimer响应函数实现周期性的扫描所有已连接客户端,检查心跳包发送时间。
void CIOCPDriverLisenceExamServerView::OnTimer(UINT_PTR nIDEvent) { // TODO: 在此添加消息处理程序代码和/或调用默认值 EnterCriticalSection(&g_clientManager.m_cs); for(std::list<CClient*>::iterator iter=g_clientManager.m_ClientList.begin();iter!=g_clientManager.m_ClientList.end();) { CClient*pClient=(*iter); if(pClient->m_state==CClient::DOING) { CTime CurTime=CTime::GetCurrentTime(); CTimeSpan spanTime(CurTime.GetDay()-pClient->m_time.GetDay(), CurTime.GetHour()-pClient->m_time.GetHour(), CurTime.GetMinute()-pClient->m_time.GetMinute(),CurTime.GetSecond()-pClient->m_time.GetSecond()); if(spanTime.GetMinutes()>1) { //设置考生为断线状态。 pClient->m_state=CClient::DISCONN; //更新显示状态。 UpdateClientState(pClient,CClient::DISCONN); //删除该客户端。 g_clientManager.deleteClient(pClient); iter=g_clientManager.m_ClientList.begin(); } else iter++; } } LeaveCriticalSection(&g_clientManager.m_cs); CView::OnTimer(nIDEvent); }
服务器运行截图:
客户端设计:
由于客户端比较简单,因此采用最简单的阻塞模式实现。
CClientSocket类:用以实现与服务器的通信工作。
定义如下:
#pragma once #define BUFFER_SIZE 10240 //服务器向客户端发送的包类型: #define STUNAME 109//学生姓名。 #define PAPER 110//试卷包。 #define LOGINFAILED 111//登录失败。 enum state//考生状态。 { LOGIN,//已登录。 DOING,//正在答题。 DONE,//已交卷。 DISCONN,//因故障断开。 UNKNOW//原因不明。 }; class CDriverLisenceClientDlg; class CClientSocket { public: CClientSocket(CDriverLisenceClientDlg*pDlg,DWORD IP ,short port); ~CClientSocket(void); public: bool ConnectToServer();//连接到服务器。 bool LoginServer();//登录。 bool RecvName();//接收姓名。 bool RecvPaper();//接收试卷。 bool SendStart();//通知服务器考试开始。 bool SendResult();//发送考试结果。 bool SendPulse();//发送心跳包。 public: SOCKET m_s;//客户端套接字。 char *m_pRecvBuffer;//接收缓冲区。 char *m_pSendBuffer;//发送缓冲区。 DWORD m_ServerIP;//服务器IP。 SHORT m_ServerPort;//服务器端口。 CDriverLisenceClientDlg*m_pMainDlg;//主窗口指针。 };
实现为:
#include "StdAfx.h" #include "ClientSocket.h" #include"DriverLisenceClientDlg.h" CClientSocket::CClientSocket(CDriverLisenceClientDlg*pDlg, DWORD IP ,short port ) { // TODO: 在此添加控件通知处理程序代码 m_pMainDlg=pDlg; m_pSendBuffer=new char[BUFFER_SIZE]; m_pRecvBuffer=new char[BUFFER_SIZE]; m_ServerIP=IP; m_ServerPort=port; WSAData wsadata; WSAStartup(MAKEWORD(2,2),&wsadata); m_s=socket(AF_INET,SOCK_STREAM,0); if(m_s==INVALID_SOCKET) { closesocket(m_s); WSACleanup(); return ; } } CClientSocket::~CClientSocket(void) { closesocket(m_s); } //连接服务器。 bool CClientSocket::ConnectToServer() { SOCKADDR_IN addr; addr.sin_family=AF_INET; addr.sin_addr.S_un.S_addr=inet_addr("192.168.1.100"); addr.sin_port=htons(4000); int len=sizeof(addr); int ret=connect(m_s,(SOCKADDR*)&addr,len); if(ret==SOCKET_ERROR) { return false; } const char chOpt=1; ret=setsockopt(m_s,IPPROTO_TCP,TCP_NODELAY,&chOpt,sizeof(char)); if(ret==SOCKET_ERROR) { return false; } return true; } //登录到服务器。 bool CClientSocket::LoginServer() { memset(m_pSendBuffer,0,BUFFER_SIZE); HDR hdr; hdr.PacketType=REQUEST; hdr.Len=m_pMainDlg->m_StuNo.GetLength()+2; USHORT State=LOGIN; int ret=send(m_s,(char*)&hdr,sizeof(hdr),0);//发送包头。 if(ret==SOCKET_ERROR) { return false; } strncpy(m_pSendBuffer,(char*)&State,sizeof(USHORT)); strncpy(m_pSendBuffer+2,m_pMainDlg->m_StuNo.GetBuffer(),m_pMainDlg->m_StuNo.GetLength());// ret=send(m_s,m_pSendBuffer,hdr.Len,0);//发送登陆信息包体。 if(ret==SOCKET_ERROR) { return false; } return true; } //接收姓名。 bool CClientSocket::RecvName() { HDR hdr; int ret=recv(m_s,(char*)&hdr,sizeof(HDR),0);//接收包头。 if(ret==SOCKET_ERROR) { return false; } memset(m_pRecvBuffer,0,BUFFER_SIZE); if(hdr.PacketType==STUNAME) { ret=recv(m_s,m_pRecvBuffer,hdr.Len,0);//接收姓名。 if(ret==SOCKET_ERROR) { return false; } m_pMainDlg->m_StuName=m_pRecvBuffer; } else if(hdr.PacketType==LOGINFAILED) { MessageBox(NULL,"考生号输入有误或该帐号已经登陆!","登录失败",MB_OK); return false; } return true; } //接收试卷。 bool CClientSocket::RecvPaper() { HDR hdr; int ret=recv(m_s,(char*)&hdr,sizeof(HDR),0); if(ret==SOCKET_ERROR) { return false; } if(hdr.PacketType==PAPER) { memset(m_pRecvBuffer,0,BUFFER_SIZE); ret=recv(m_s,m_pRecvBuffer,hdr.Len,0); if(ret==SOCKET_ERROR) { return false; } m_pMainDlg->m_PaperBuffer=m_pRecvBuffer; } return true; } //发送开始答题消息。 bool CClientSocket::SendStart() { HDR hdr; hdr.PacketType=REQUEST; hdr.Len=sizeof(USHORT); int ret=send(m_s,(char*)&hdr,sizeof(hdr),0);//发送包头。 if(ret==SOCKET_ERROR) { return false; } memset(m_pSendBuffer,0,BUFFER_SIZE); USHORT State=DOING; strncpy(m_pSendBuffer,(char*)&State,sizeof(USHORT));//构造两字节的状态。为准备答题。 ret=send(m_s,m_pSendBuffer,sizeof(USHORT),0);//DOING状态。 if(ret==SOCKET_ERROR) { return false; } m_pMainDlg->SetTimer(1,10,NULL); return true; } //发送答题结果。 bool CClientSocket::SendResult() { HDR hdr; hdr.PacketType=REQUEST; hdr.Len=sizeof(USHORT)+m_pMainDlg->m_result.GetLength(); int ret=send(m_s,(char*)&hdr,sizeof(hdr),0);//发送包头。 if(ret==SOCKET_ERROR) { return false; } memset(m_pSendBuffer,0,BUFFER_SIZE); USHORT State=DONE; //strncpy(m_pSendBuffer,(char*)&State,sizeof(USHORT));//交卷状态。 // strncpy(m_pSendBuffer+sizeof(USHORT),m_pMainDlg->m_result.GetBuffer(),m_pMainDlg->m_result.GetLength()); ret=send(m_s,(char*)&State,sizeof(USHORT),0); ret=send(m_s,m_pMainDlg->m_result.GetBuffer(),m_pMainDlg->m_result.GetLength(),0);//状态后为答题结果。。 if(ret==SOCKET_ERROR) { return false; } return true; } //发送心跳包。 bool CClientSocket::SendPulse() { HDR hdr; hdr.PacketType=PULSE; hdr.Len=0; int ret=send(m_s,(char*)&hdr,sizeof(hdr),0);//发送包头。 if(ret==SOCKET_ERROR) { return false; } return true; }
DlgView类执行初始化答题窗口、生成试卷、发送心跳包
声明为:
public: afx_msg void OnBnClickedBtnOk();//登录消息响应函数。 bool ParsePaperBuffer();//扫描试卷缓冲区,取出试题。 bool StartTheExam();//开始考试。 static DWORD WINAPI QuesationDlgThread(PVOID pparam); public: short m_port;//服务器端口。 DWORD m_ServerIP;//服务器IP。 CString m_StuNo;//考生号。 CString m_StuName;//从服务器接收的考生姓名。 std::list<CPaperItem*>m_PaperItemList;//试题链表。每个节点存储一道试题。 CTimeSpan *m_pTimeRemained;//剩余时间。考试时间为60min. CString m_PaperBuffer;//从服务器接收的试卷缓冲区。 CString m_result;//存储答题结果。 CClientSocket *m_pClientSocket;//CCClientSocket指针。 HANDLE m_hEvent;//事件对象,用于在上一题下一题时触发。 CQuestionDlg*m_pQuestionDlg;//答题窗口。 std::list<CPaperItem*>::iterator m_IterCurQuestion; bool m_IsThreadExit;//决定试题循环线程是否退出。 afx_msg void OnTimer(UINT_PTR nIDEvent);//考试时间计时。 };
类实现为:
//登录按钮消息响应函数。
void CDriverLisenceClientDlg::OnBnClickedBtnOk() { UpdateData(); m_pClientSocket=new CClientSocket(this,m_ServerIP,m_port); bool ret=m_pClientSocket->ConnectToServer(); //ASSERT(ret); if(!ret) { MessageBox("服务器未开启,登录失败!","服务器未开启"); return ; } ret=m_pClientSocket->LoginServer(); //ASSERT(ret); if(!ret) { //MessageBox("帐号有误或重复登录,登录失败!","登录失败"); return ; } ret=m_pClientSocket->RecvName(); //ASSERT(ret); if(!ret) { MessageBox("数据接收出现错误,请检查网络连接!!","数据接收出现错误"); return ; } ret=m_pClientSocket->SendStart(); if(!ret) { MessageBox("数据发送出现错误,请检查网络连接!!","数据接收出现错误"); return ; } ret=m_pClientSocket->RecvPaper(); //ASSERT(ret); if(!ret) { MessageBox("数据接收出现错误,请检查网络连接!!","数据接收出现错误"); return ; } ParsePaperBuffer(); StartTheExam(); SetTimer(2,1000,NULL); } //扫描试题缓冲区,将试题存入试题链表内。 bool CDriverLisenceClientDlg::ParsePaperBuffer() { CPaperItem *pItem; char c; int i=0; int count=0; while(i!=m_PaperBuffer.GetLength()) { c=m_PaperBuffer[i]; if(c=='<') { count=0; pItem=new CPaperItem; } else if(c=='>') { m_PaperItemList.push_back(pItem); } else if(c=='|') { count++; } else { if(count==0) { pItem->m_Question+=c; } else if(count==1) { pItem->m_A+=c; } else if(count==2) { pItem->m_B+=c; } else if(count==3) { pItem->m_C+=c; } else { pItem->m_D+=c; } } i++; } return true; } //开始考试。 bool CDriverLisenceClientDlg::StartTheExam() { ShowWindow(SW_HIDE); DWORD num=m_PaperItemList.size(); m_pQuestionDlg=new CQuestionDlg; bool ret=m_pQuestionDlg->Create(IDD_DLG_PAPER,this); ret=m_pQuestionDlg->ShowWindow(SW_SHOW); HANDLE hThread=CreateThread(NULL,0,QuesationDlgThread,this,0,NULL); CloseHandle(hThread); CString s; s.Format(" 驾驶员理论考试! 试题数:%d",num); //CString s; m_pQuestionDlg->SetDlgItemTextA(IDC_STATIC_NOTIFICATION,s); s.Format("姓名:%s",m_StuName); m_pQuestionDlg->SetDlgItemTextA(IDC_STATIC_STUNAME,s); s.Format("考号:%s",m_StuNo); m_pQuestionDlg->SetDlgItemTextA(IDC_STATIC_STUNO,s); //dlg.DoModal(); return true; } //在此线程循环内不断遍历试题,显示到对话框。 DWORD WINAPI CDriverLisenceClientDlg::QuesationDlgThread( PVOID pparam ) { CDriverLisenceClientDlg*pDlg=(CDriverLisenceClientDlg*)pparam; ((CButton*)(pDlg->m_pQuestionDlg->GetDlgItem(IDC_BTN_HANDIN)))->EnableWindow(false); pDlg->m_IsThreadExit=false; //for(std::list<CPaperItem*>::iterator iter=pDlg->m_PaperItemList.begin();iter!=pDlg->m_PaperItemList.end();iter=pDlg->m_IterCurQuestion)//iter++) for(std::list<CPaperItem*>::iterator iter=pDlg->m_PaperItemList.begin();!pDlg->m_IsThreadExit;iter=pDlg->m_IterCurQuestion)//iter++) { if(pDlg->m_PaperItemList.end()!=iter) { CPaperItem *pPaperItem=(*iter); pDlg->m_pQuestionDlg->SetDlgItemTextA(IDC_STATIC_QUESTION,pPaperItem->m_Question); pDlg->m_pQuestionDlg->SetDlgItemText(IDC_RADIO_ANS_A,pPaperItem->m_A); pDlg->m_pQuestionDlg->SetDlgItemText(IDC_RADIO_ANS_B,pPaperItem->m_B); pDlg->m_pQuestionDlg->SetDlgItemText(IDC_RADIO_ANS_C,pPaperItem->m_C); pDlg->m_pQuestionDlg->SetDlgItemText(IDC_RADIO_ANS_D,pPaperItem->m_D); pDlg->m_IterCurQuestion=iter; } WaitForSingleObject(pDlg->m_hEvent,INFINITE);//等待上一题或下一题触发事件消息。 } return 0; } //发送心跳包。 void CDriverLisenceClientDlg::OnTimer(UINT_PTR nIDEvent) { // TODO: 在此添加消息处理程序代码和/或调用默认值 if(nIDEvent==1) m_pClientSocket->SendPulse(); else if(nIDEvent==2) { CTimeSpan temp(0,0,0,1); (*m_pTimeRemained)-=temp; CString t; t.Format("剩余时间:%d分%d秒",m_pTimeRemained->GetMinutes(),m_pTimeRemained->GetSeconds()); m_pQuestionDlg->SetDlgItemTextA(IDC_STATIC_TIMEREMAINED,t); } CDialogEx::OnTimer(nIDEvent); }
客户端登录界面:
答题窗口: