DELPHI编写服务程序总结
一、服务程序和桌面程序的区别
Windows 2000/XP/2003等支持一种叫做“系统服务程序”的进程,系统服务和桌面程序的区别是: 系统服务不用登陆系统即可运行;系统服务是运行在System Idle Process/System/smss/winlogon/services下的,而桌面程序是运行在Explorer下的;系统服务拥有更高的权限,系统服务拥有Sytem的权限,而桌面程序只有Administrator权限;在Delphi中系统服务是对桌面程序进行了再一次的封装,既系统服务继承于桌面程序。因而拥有桌面程序所拥有的特性;系统服务对桌面程序的DoHandleException做了改进,会自动把异常信息写到NT服务日志中;普通应用程序启动只有一个线程,而服务启动至少含有三个线程。(服务含有三个线程:TServiceStartThread服务启动线程;TServiceThread服务运行线程;Application主线程,负责消息循环); 摘录代码: procedure TServiceApplication.Run; begin . . . StartThread := TServiceStartThread.Create(ServiceStartTable); try while not Forms.Application.Terminated do Forms.Application.HandleMessage; Forms.Application.Terminate; if StartThread.ReturnValue <> 0 then FEventLogger.LogMessage(SysErrorMessage(StartThread.ReturnValue)); finally StartThread.Free; end; . . . end;
procedure TService.DoStart; begin try Status := csStartPending; try FServiceThread := TServiceThread.Create(Self); FServiceThread.Resume; FServiceThread.WaitFor; FreeAndNil(FServiceThread); finally Status := csStopped; end; except on E: Exception do LogMessage(Format(SServiceFailed,[SExecute, E.Message])); end; end; 在系统服务中也可以使用TTimer这些需要消息的定时器,因为系统服务在后台使用TApplication在分发消息;
二、如何编写一个系统服务
打开Delphi编辑器,选择菜单中的File|New|Other...,在New Item中选择Service Application项,Delphi便自动为你建立一个基于TServiceApplication的新工程,TserviceApplication是一个封装NT服务程序的类,它包含一个TService1对象以及服务程序的装卸、注册、取消方法。 TService属性介绍: AllowPause:是否允许暂停; AllowStop:是否允许停止; Dependencies:启动服务时所依赖的服务,如果依赖服务不存在则不能启动服务,而且启动本服务的时候会自动启动依赖服务; DisplayName:服务显示名称; ErrorSeverity:错误严重程度; Interactive:是否允许和桌面交互; LoadGroup:加载组; Name:服务名称; Password:服务密码; ServiceStartName:服务启动名称; ServiceType:服务类型; StartType:启动类型; 事件介绍: AfterInstall:安装服务之后调用的方法; AfterUninstall:服务卸载之后调用的方法; BeforeInstall:服务安装之前调用的方法; BeforeUninstall:服务卸载之前调用的方法; OnContinue:服务暂停继续调用的方法; OnExecute:执行服务开始调用的方法; OnPause:暂停服务调用的方法; OnShutDown:关闭时调用的方法; OnStart:启动服务调用的方法; OnStop:停止服务调用的方法;
三、编写一个两栖服务
采用下面的方法,可以实现一个两栖系统服务(既系统服务和桌面程序的两种模式) 工程代码: program FleetReportSvr;
uses SvcMgr, Forms, SysUtils, Windows, SvrMain in 'SvrMain.pas' {FleetReportService: TService}, AppMain in 'AppMain.pas' {FmFleetReport};
{$R *.RES}
const CSMutexName = 'Global\Services_Application_Mutex'; var OneInstanceMutex: THandle; SecMem: SECURITY_ATTRIBUTES; aSD: SECURITY_DESCRIPTOR; begin InitializeSecurityDescriptor(@aSD, SECURITY_DESCRIPTOR_REVISION); SetSecurityDescriptorDacl(@aSD, True, nil, False); SecMem.nLength := SizeOf(SECURITY_ATTRIBUTES); SecMem.lpSecurityDescriptor := @aSD; SecMem.bInheritHandle := False; OneInstanceMutex := CreateMutex(@SecMem, False, CSMutexName); if (GetLastError = ERROR_ALREADY_EXISTS)then begin DlgError('Error, Program or service already running!'); Exit; end; if FindCmdLineSwitch('svc', True) or FindCmdLineSwitch('install', True) or FindCmdLineSwitch('uninstall', True) then begin SvcMgr.Application.Initialize; SvcMgr.Application.CreateForm(TSvSvrMain, SvSvrMain); SvcMgr.Application.Run; end else begin Forms.Application.Initialize; Forms.Application.CreateForm(TFmFmMain, FmMain); Forms.Application.Run; end; end. 然后在SvrMain注册服务: unit SvrMain;
interface
uses Windows, Messages, SysUtils, Classes, Graphics, Controls, SvcMgr, Dialogs, MsgCenter;
type TSvSvrMain = class(TService) procedure ServiceStart(Sender: TService; var Started: Boolean); procedure ServiceStop(Sender: TService; var Stopped: Boolean); procedure ServiceBeforeInstall(Sender: TService); procedure ServiceAfterInstall(Sender: TService); private { Private declarations } public function GetServiceController: TServiceController; override; { Public declarations } end;
var SvSvrMain: TSvSvrMain;
implementation
const CSRegServiceURL = 'SYSTEM\CurrentControlSet\Services\'; CSRegDescription = 'Description'; CSRegImagePath = 'ImagePath'; CSServiceDescription = 'Services Sample.';
{$R *.DFM}
procedure ServiceController(CtrlCode: DWord); stdcall; begin SvSvrMain.Controller(CtrlCode); end;
function TSvSvrMain.GetServiceController: TServiceController; begin Result := ServiceController; end;
procedure TSvSvrMain.ServiceStart(Sender: TService; var Started: Boolean); begin Started := dmPublic.Start; end;
procedure TSvSvrMain.ServiceStop(Sender: TService; var Stopped: Boolean); begin Stopped := dmPublic.Stop; end;
procedure TSvSvrMain.ServiceBeforeInstall(Sender: TService); begin RegValueDelete(HKEY_LOCAL_MACHINE, CSRegServiceURL + Name, CSRegDescription); end;
procedure TSvSvrMain.ServiceAfterInstall(Sender: TService); begin RegWriteString(HKEY_LOCAL_MACHINE, CSRegServiceURL + Name, CSRegDescription, CSServiceDescription); RegWriteString(HKEY_LOCAL_MACHINE, CSRegServiceURL + Name, CSRegImagePath, ParamStr(0) + ' -svc'); end;
end. 这样,双击程序,则以普通程序方式运行,若用服务管理器来运行,则作为服务运行。 例如公共模块: dmPublic,提供Start,Stop方法。
在主窗体中,调用dmPublic.Start,dmPublic.Stop方法。 同样在Service中,调用dmPublic.Start,dmPublic.Stop方法。
一、如何限制系统服务和桌面程序只运行一个
如何限制系统服务和桌面程序只运行一个
在工程加入下列代码可以设置系统服务和桌面程序只运行一个。 program FleetReportSvr;
uses SvcMgr, Forms, SysUtils, Windows, SvrMain in 'SvrMain.pas' {FleetReportService: TService}, AppMain in 'AppMain.pas' {FmFleetReport};
{$R *.RES}
const CSMutexName = 'Global\Services_Application_Mutex'; var OneInstanceMutex: THandle; SecMem: SECURITY_ATTRIBUTES; aSD: SECURITY_DESCRIPTOR; begin InitializeSecurityDescriptor(@aSD, SECURITY_DESCRIPTOR_REVISION); SetSecurityDescriptorDacl(@aSD, True, nil, False); SecMem.nLength := SizeOf(SECURITY_ATTRIBUTES); SecMem.lpSecurityDescriptor := @aSD; SecMem.bInheritHandle := False; OneInstanceMutex := CreateMutex(@SecMem, False, CSMutexName); if (GetLastError = ERROR_ALREADY_EXISTS)then begin DlgError('Error, Program or service already running!'); Exit; end; if FindCmdLineSwitch('svc', True) or FindCmdLineSwitch('install', True) or FindCmdLineSwitch('uninstall', True) then begin SvcMgr.Application.Initialize; SvcMgr.Application.CreateForm(TSvSvrMain, SvSvrMain); SvcMgr.Application.Run; end else begin Forms.Application.Initialize; Forms.Application.CreateForm(TFmFmMain, FmMain); Forms.Application.Run; end; end.
二、在系统服务和桌面程序之间共享内存
用于创建内核对象的函数几乎都有一个指向SECURITY_ATTRIBUTES结构的指针作为其参数,在使用CreateFileMapping函数的时候,通常只是为该参数传递NULL,这样就可以创建带有默认安全性的内核对象。 默认安全性意味着对象的管理小组的任何成员和对象的创建者都拥有对该对象的全部访问权,而其他所有人均无权访问该对象。可以指定一个ECURITY_ATTRIBUTES结构,对它进行初始化,并为该参数传递该结构的地址。 它包含的与安全性有关的成员实际上只有一个,即lpSecurityDescriptor。当你想要获得对相应的一个内核对象的访问权(而不是创建一个新对象)时,必须设定要对该对象执行什么操作。如果想要访问一个现有的文件映射内核对象,以便读取它的数据,那么调用OpenfileMapping函数:通过将FILE_MAP_READ作为第一个参数传递给OpenFileMapping,指明打算在获得对该文件映象的访问权后读取该文件, 该函数在返回一个有效的句柄值之前,首先 执行一次安全检查。如果(已登录用户)被允许访问现有的文件映射内核对象,就返回一个有效的句柄。但是,如果被拒绝访问该对象,将返回NULL。
系统服务端核心代码:
constructor TPublicVars.Create(ANew: Boolean); var SecMem: SECURITY_ATTRIBUTES; aSD: SECURITY_DESCRIPTOR; begin inherited Create; { 创建一个任何用户都可以访问的内核对象访问权 } InitializeSecurityDescriptor(@aSD, SECURITY_DESCRIPTOR_REVISION); SetSecurityDescriptorDacl(@aSD, True, nil, False); SecMem.nLength := SizeOf(SECURITY_ATTRIBUTES); SecMem.lpSecurityDescriptor := @aSD; SecMem.bInheritHandle := False; FMapFile := CreateFileMapping($FFFFFFFF, @SecMem, PAGE_READWRITE, 0, CSharedMemSize, CSharedMemName); FMapFile := OpenFileMapping(File_Map_All_Access, False, CSharedMemName); if (FMapFile = 0) then begin raise Exception.Create(SysErrorMessage(GetLastError)); OutputDebugString(PChar(SysErrorMessage(GetLastError))); end else begin // 成功 FShareMem := MapViewOfFile(FMapFile, File_Map_All_Access, 0, 0, CSharedMemSize); OutputDebugString(PChar(SysErrorMessage(GetLastError) + ',Handle=' + IntToStr(Handle))); end; end;
destructor TPublicVars.Destroy; begin UnmapViewOfFile(FShareMem); CloseHandle(FMapFile); inherited; end;
桌面程序核心源代码:
constructor TPublicVars.Create(ANew: Boolean); var SecMem: SECURITY_ATTRIBUTES; aSD: SECURITY_DESCRIPTOR; begin inherited Create; { 创建一个任何用户都可以访问的内核对象访问权 } InitializeSecurityDescriptor(@aSD, SECURITY_DESCRIPTOR_REVISION); SetSecurityDescriptorDacl(@aSD, True, nil, False); SecMem.nLength := SizeOf(SECURITY_ATTRIBUTES); SecMem.lpSecurityDescriptor := @aSD; SecMem.bInheritHandle := False; FMapFile := CreateFileMapping($FFFFFFFF, @SecMem, PAGE_READWRITE, 0, CSharedMemSize, CSharedMemName); FMapFile := OpenFileMapping(File_Map_All_Access, False, CSharedMemName); if (FMapFile = 0) then begin raise Exception.Create(SysErrorMessage(GetLastError)); OutputDebugString(PChar(SysErrorMessage(GetLastError))); end else begin // 成功 FShareMem := MapViewOfFile(FMapFile, File_Map_All_Access, 0, 0, CSharedMemSize); OutputDebugString(PChar(SysErrorMessage(GetLastError) + ',Handle=' + IntToStr(Handle))); end; end;
destructor TPublicVars.Destroy; begin UnmapViewOfFile(FShareMem); CloseHandle(FMapFile); inherited; end;
详细源代码见报表服务和报表COM中的关于共享内存的源代码。需要注意创建共享内存需要放在:ServiceStart中初始化,不能放在initialization,否则还会出现权限不足的信息,因为initialization是在应用程序初始化之前执行的代码。
三、在服务中使用COM组件
在服务中调用COM组件不能像在桌面程序中直接创建,在每次创建之前先调用CoInitialize(nil),释放的时候调用CoUninitialize。例如:调用ADO组件 var Qry: TADOQuery; begin CoInitialize(nil); Qry := TADOQuery.Create(nil); try ... finally Qry.Free; CoUninitialize;
一、提高DELPHI程序的稳定性 软件质量是一个产品的生命线,也是关乎软件开发者的幸福关键所在,每天有很多程序员都在因为软件质量而通宵达旦的加班,经常遇到的情况是刚发布的程序不停的发布补丁包。软件质量就像一个噩梦一样,不停的在后面追赶着程序员,让他们疲于奔命,甚至于在程序员中流传着一句话:“生命不息,BUG不止”。 今天我们要探究的不是哪些可以重现的BUG,我们把哪些可以重现的BUG不定义为BUG,只有哪些不可重现的BUG,会让你茶饭不思、坐立不安。我曾在一家公司开发服务器软件,结果因为程序不稳定,而且都是一些不可重现的错误,导致我们需要不停的派人盯着服务器运行。不稳定就像一个恶鬼一样终日萦绕在我们心头,领导的不停催促,客户的不停投诉,让我们项目组个个疲于奔命,叫苦连天。我在查了无数个不可重现的BUG发现,主要是由于以下八种原因引起的: 1. 变量没有初始化; 2. 函数返回值没有初始化; 3. 编译优化导致的错误; 4. 函数递归; 5. 消息重入; 6. 野指针; 7. 内存泄漏; 8. 并发; 你会发现都是一些细小问题,因此程序员在日常开发中一定要养成好的习惯。 二、变量没有初始化 DELPHI默认初始化的变量是:全局变量、类成员,其它在函数体的变量都不会初始化,因此一些用于判断或者循环的变量一定要记得初始化,另外枚举类型、申请的内存都需要初始化,PCHAR一定要在末尾加#0。例如:下面的返回结果有可能会出现乱码。 function TempPath: string; begin SetLength(Result, GetTempPath(0, PChar(Result))); GetTempPath(Length(Result), PChar(Result)); Result := PChar(Result); end; 正确的写法应该 function TempPath: string; begin SetLength(Result, GetTempPath(0, PChar(Result))); ZeroMemory(PChar(Result), Length(Result)); GetTempPath(Length(Result), PChar(Result)); Result := PChar(Result); end; 这个程序就是典型的在申请内存的时候,没有对PCHAR进行初始化,因此末尾有可能是随机值,但是通过ZeroMemory就把末尾赋#0。 三、函数返回值没有初始化 在DELPHI中退出函数是使用Exit函数的,有很多函数在退出的时候,没有对函数返回值初始化,那么函数的返回值返回就是一个随机值,对程序运行造成不可重现错误。例如:下面程序的执行结果会让你大吃一惊。 procedure NotInitResult; var i: Integer; function GetString(AValue: Integer): string; begin if AValue = 0 then Result := 'True'; end; begin for i := -1 to 1 do begin ShowMessage(GetString(i)); end; end; 你看到的运行结果是:‘’、‘True’、‘True’,正确的写法应该是: procedure NotInitResult; var i: Integer; function GetString(AValue: Integer): string; begin if AValue = 0 then Result := 'True' else Result := ‘’; end; begin for i := -1 to 1 do begin ShowMessage(GetString(i)); end; end; 因此针对if或者Case语句一定要赋初始值,上面的函数的写法也可以写为: function GetString(AValue: Integer): string; begin Result := ‘’; if AValue = 0 then Result := 'True'; end; function GetString(AValue: Integer): string; begin case AValue of 0: Result := ‘True’; else Result := ‘’; end; end; 四、编译优化导致的错误 现在的编译器在编译代码的时候会优化掉一些可以不执行的代码,例如:布尔类型优化是最常见的一种,下面的例子能很好的说明这个问题。 procedure TForm1.btn1Click(Sender: TObject); var s: string; begin if GetTrue or GetValue1(s) then ShowMessage('Hello ' + s); end; procedure TForm1.btn2Click(Sender: TObject); var s: string; begin if GetTrue or GetValue2(s) then ShowMessage('Hello ' + s); end; function TForm1.GetTrue: Boolean; begin Result := True; end; function TForm1.GetValue1(var s: string): Variant; begin Result := True; s := 'World'; end; function TForm1.GetValue2(var s: string): Boolean; begin Result := True; s := 'World'; end; 你会发现单击btn1时出现的结果是:“Hello Word”,但是单击btn2的时候是:“Hello”,这个就是因为单击btn2的时候由于GetTrue返回的是真,所以第二句不执行,但是btn1由于还要进行Variant到Boolean类型的转换,因此肯定会执行。
五、函数递归 如果存在递归函数,就需要特别注意,是否会正常退出函数执行,如果一直执行下去,会把程序调用堆栈全部吃完,导致程序异常终止,如下例:只要一点btn1,程序就会无声无息死掉,而且没有LOG,这类代码在以服务方式运行需要特别注意,因为你的服务是无人值守的情况下运行的,如果出现这种情况,你的服务会直接退出,而且没有任何提示,对于查找问题无从下手。 procedure TForm1.btn1Click(Sender: TObject); procedure Recursive; begin Recursive; end; begin Recursive; end; 六、消息重入 消息重入的概念是:有一个消息执行过程还没有执行,相同的一个消息又进入相同的函数处理。消息重入很大原因是在很多软件中调用Application.ProcessMessage来更新界面,如果是一个操作需要很长的时间,可以改为线程来执行,或者不调用Application.ProcessMessage函数。例如:下面的函数就很容易导致消息重入。 procedure TForm1.btn1Click(Sender: TObject); var i: Integer; begin for i := 0 to 10000000 do begin Application.ProcessMessages; end; end; 如果必须要用Application.ProcessMessage来更新界面,你应该确保在函数执行过程中,这个消息不会第二次投递,如这个例子你可以通过把btn1的状态禁用来防止消息重入,正确的写法是: procedure TForm1.btn1Click(Sender: TObject); var i: Integer; begin btn1.Enabled := False; for i := 0 to 10000000 do begin Application.ProcessMessages; end; btn1.Enabled := True; end; 另外在发送消息的时候,也需要特别注意SendMessage和PostMessage的区别,SendMessage是发送等待消息处理完成再返回,PostMessage是投递到消息缓冲池排队,立即返回(这时消息可能没有处理),消息需要等到轮到它的时候再处理。 七、野指针 野指针在编译时候是无法检测的,只有在运行时候才会出现,出现野指针最常见的错误就是Access violation错误(简称AV错误),出现这种错误是你指向的物理内存不可用。出现野指针主要是由于以下四种引起:1、指针变量没有初始化;2、指针被Free或Dispose之后再次使用;指针操作超越了变量的范围;4、取string的地址,没有判断string是否已经分配内存。 代码在判断指针是否是空指针是通过判断指针的值是否介于0x00000000和0x0000FFFF之间,如果在这之间用if语句是可以判断,如果不介于这之间,则认为指针是有效的。因此指针在申请之后或者释放之后,指向的地址是随机值,因此用if语句是无法判断。另外在DELPHI中,你把指针置为nil,翻译成汇编代码就是异或一下,可以打开CPU窗口查看,如: Fm := nil;生成的汇编是:xor eax eax,即把指针置为0x00000000。
八、内存泄漏 内存泄漏指的是软件在运行过程中对于申请的内存空间没有释放,导致内存占用越来越大,最后程序异常崩溃,而且此时也不会留下任何痕迹,没有任何系统日志可查。内存泄漏也分为两种,一种是程序一起动,然后占用了内存,不会随着程序运行增长;一种是随着程序运行不停增长的;如果是第一种可以放过,对二种一定要仔细检查,检查工具推荐用FastMM,并且把DELPHI的项目属性Compiler->Use Debug DCUs和Linker->Map file->Detailed选中,这样FastMM就可以把申请内存的调用堆栈和MAP地址打出来,非常利于查找内存泄漏。查找内存泄漏一般可以从以下几个方面考虑: 1. 使用Dispose释放内存的时候要加上定义信息,如果不加定义信息,对于一些指针或者string释放不了,对于结构体内部有指针的应先释放内部指针; 2. 使用FreeMem或FreeMemory释放内存的时候,可以不加大小信息,这是因为DELPHI内存管理器内部知道指针大小信息; 3. Override函数一定要inherited来释放父类申请的内存; 4. 申请的内存要确保释放,可以用Try … finally … end来确保内存的释放,但是应杜绝这种代码风格try …申请内存…finally …释放内存… end; 5. 系统内核对象要确保关闭; 6. 申请的指针如果在某些情况下分配空间,要记得初始化为nil,释放的时候要判断是否为空,因为释放空指针也会导致内存泄漏; 7. 另外PostMessage也有可能导致内存泄漏,这种情况是通过PostMessage发送结构体,释放内存放在消息处理函数中,这时如果频繁的调用PostMessage,消息处理循环忙不过来,就会丢掉一些消息,造成内存泄漏,默认的Windows消息队列长度是4000,如果说消息队列有4000个,你这时再用PostMessage投递消息,就会被丢掉,造成申请的结构体无法释放,造成内存泄漏; 九、并发 如果程序涉及多线程,而且线程之间有协作关系,如果这时线程挂死了,就要查线程同步,一般这类问题比较难查,而且需要对代码执行流程非常了解,属于比较难以处理的一类问题。可以借助一些三方工具,比如“procexp.exe”就是一个非常优秀的工具,用他可以看到每个线程的状态,如果一个线程停在哪不动,你就可以通过MAP地址和调用堆栈找到问题点。如Excel的线程状态如下图:
十、一些有效的建议 针对以上的这些问题,我们在日常的开发中,应该注意哪些问题呢,下面是我给出的一些建议: 1.探索需求,需求理解越深写出代码的质量、架构就越轻巧,可读性和维护性大大提高; 2. 测试驱动开发; 3. 良好的代码风格,良好的编码习惯对于软件质量有非常大的提高; 4. 变量(指针、数组)被创建之后应及时把他们初始化; 5. 检查变量的初始值、缺省值错误,或者精度不够; 6. 类型转换,一定要善用as和is; 7. 检查变量上溢或下溢,数组越界; 8. 检查I/O错误,I/O不是总返回真的; 9. 数据结构够用就好,不要设计面面俱到、非常灵活的数据结构; 10. 差劲的代码,不要想着改改又可用了,应当重新编写,因为极有可能导致按下葫芦浮起瓢; 11. 对程序编译出现的每一个告警,都认真对待,要编写无警告的代码; 12. 对于不需要修改的参数带上const,不但可以提高效率,而且可以增强安全;
这个例子是我原来写的一个完成端口演示程序,没有经过严格的稳定性校验,只是做为如何编写的一个样本,仅供大家参考,下面是完成端口的简单介绍:
“完成端口”模型是迄今为止最为复杂的一种I/O模型,特别适合需要同时管理为数众多的套接字,采用这种模型,往往可以达到最佳的系统性能。但是只适合Windows NT和Windows 2000及以上操作系统。因其设计的复杂性,只有在你的应用程序需要同时管理数百乃至上千套接字的时候,而且希望随着系统内安装的CPU数量增多,应用程序的性能也可以线性提升,才考虑采用“完成端口”模型。 重叠I/O(Overlapped I/O)模型使应用程序达到更佳的系统性能。重叠模型的基本设计原理便是让应用程序使用一个重叠的数据结构,一次投递一个或多个Winsock I/O请求。针对哪些提交的请求,在它们完成之后,应用程序可为它们提供服务。该模型适用于除Windows CE之外的各种Windows平台。 开发完成端口最具有挑战是线程个数和管理内存,创建一个完成端口后,就需要创建一个或多个“工作者线程”,以便在I/O请求投递给完成端口对象后,为完成端口提供服务。但是到底应创建多少个线程,这实际正是完成端口最为复杂的一个方面,一般采用的是为每一个CPU分配一个线程(有的是CPU个数加1,有的是CPU*2的线程个数)。内存分配效率低是因为应用程序在分配内存的时候,系统内核需要不停的Lock/UnLock,而且在多CPU的情况下,会成为整个程序性能的瓶颈,不能随CPU的个数增加而性能提高,一种比较好的做法一个一次分配多块内存。 下面是我写一个的完成端口的演示程序,在我的电脑上测试可以达到链接5100个客服端,服务器性能还很好,由于我写的客服端占用资源比较多,最后直接重启了,具体见代码。演示程序主要的瓶颈在于发消息的这一块,在实际应用中应去掉。
代码下载地址:http://download.csdn.net/source/1737865
|
请发表评论