《从面向对象到面向COM.ppt》由会员分享,可在线阅读,更多相关《从面向对象到面向COM.ppt(77页珍藏版)》请在三一办公上搜索。
1、从面向对象到面向COM,主讲 叶长青华东师大教育信息技术学系2006-3-1 丽娃河畔,本课的内容及目标,内容:面向对象的一般概念 从面向对象到面向COM COM组件技术目标:了解程序设计技术的发展动向 提升程序设计能力 开阔专业视野,课程参考书目,COM原理与应用COM本质论 COM技术内幕 Advanced CORBA Programming with C+,面向对象的基本概念,为节省每一个字节而努力的阶段例:用PASCAL语句写成的程序段 AI:=AI+AT;AT:=AI AT;AI:=AI AT;目的是什么?我们现在习惯的写法是什么?不关心“空间的浪费”,更关心程序的清晰框架结构阶段例
2、:用FORTRAN语言编写的程序段 DO 5 I=1,N DO 5 J=1,N5 V(I,J)=(I/J)*(J/I),程序设计的发展历程,IF(X.LT.Y)GOTO 30IF(Y.LT.Z)GOTO 50SMALL=ZGOTO 7030 IF(X.LT.Z)GOTO 60SMALL=ZGOTO 7050 SMALL=YGOTO 7060 SMALL=X70 CONTINUE,SMALL=XIF(Y.LT.SMALL)SMALL=YIF(Z.LT.SMALL)SMALL=Z,X,Y,Z,谁,最,小,注重“重用性”的问题。面向对象 注重“标准化元件”的阶段。面向组件?软件企业分工细化阶段,类是
3、具有相同属性特征事物的集合。计算机专业语境下,类是封装了状态(变量)和操作(对变量处理的过程和函数)的抽象数据类型。(对应于标准数据类型)对象是实例,它反映了具体的事物。鸟,是类的概念。麻雀是鸟类的一个实例。一只麻雀?,对象由类来定义 一个对象可以与多个具有继承关系的类相联系,即:A是一个类,B、C、D是在A的基础上扩展后的新类,E,F,G则是在C类的基础上发展而来,而对象x 是F类的一个实例。,有关术语,封装、继承与多态,封装、继承与多态,面向对象概念的三个关键词,封装是实现面向对象程序设计的第一步,封装就是将数据或函数等集合在一个个的单元中(我们称之为类)。C+对类和对象的描述:Class
4、 studentPublic:成员函数1;成员函数2;Private:成员变量1;成员变量2;Int x,y;Student z;,类名,例一:C+中类的定义与实现说明:包含成员变量,成员函数,类的定义,对象的定义。例二、例三:成员函数在类内、类外实现的情形例四:成员函数是私有函数时的存取特性与意义,封装的意义在于保护或者防止代码(数据)被我们无意中破坏。在面向对象程序设计中数据被看作是一个中心的元素并且和使用它的函数结合的很密切,从而保护它不被其它的函数意外的修改。,从程序语言角度来看,在一个对象中代码和(或)数据可以是这个对象私有的,不能被对象外的部分直接访问。因而对象提供了一种高级保护以
5、防止程序被无关部分错误修改或错误地使用了对象的私有部分。当从对象外部试图直接对受保护的内部数据进行修改时,将被程序拒绝,只有通过对象所提供的对外服务函数才能够对其内部数据进行必要的加工,从而保证了数据加工的合法性。从这一意义上讲,把这种代码和数据的联系称为“封装”。换句话说,封装是将对象封闭保护起来,是将内部细节隐蔽起来的能力。,实现的细节是“可变的部分”。如果“块”是单个类,那么可变的部分通常用 private:或 protected:关键字来封装。“稳定的部分”是接口。好的接口提供了一个以用户的词汇简化了的视图,并且被从外到里的设计。(此处的“用户”是指其它开发者,而不是购买完整应用的最终
6、用户)。设计一个清晰的接口并且将实现和接口分离,只不过是允许用户使用接口并强迫用户使用接口。,如何才能防止其它程序员查看我的类的私有部分而破坏封装?,讨论,只要其它程序员写的代码不依赖于他们的所见,那么即使它们看了你的类的 private:和/或 proteced:部分,也不会破坏封装。换句话说,封装不会阻止人认识类的内部。封装只是防止他们写出依赖类内部实现的代码。倘若他们写的代码依赖于接口而不是实现,就不会增加维护成本。,不必这么做封装是对于代码而言的,而不是对人。,封装是一种安全装置吗?,封装要防止的是错误,而不是,间谍,封装!=安全。,钱掉了!,成员函数、成员变量中的另类,例五:构造函数
7、例六:析构函数例七:何时使用构造函数例八:友元函数例九:静态变量2005-9-30,继承的概念及重要性 inheritance:是软件重用的一种形式,将相关的类组织起来,并分亨其间的共通数据和操作行为。,最具吸引力的特点:新类可以从现有的类库中继承。提倡建立与现有的类有许多共性的新类,添加基类的所没有的特点以及取代和改进从基类继承来的特点来实现软件的重用 单重继承形成树状层次结构,由基类和派生类构成了一种层次关系,继承的层次在系统的限制范围内是任意的。,2.基类 父类定义了所有子类共通的对外接口和私有实现内容,父类被称为基类 成员函数:基类的私有成员只能被基类的成员函数和友元访问,基类的受保护
8、成员只能被基类及派生类的成员函数和友元访问,3.派生类 新类继承预定义基类的数据成员和成员函数,而不必重新编写数据成员和成员函数,这种新类叫派生(derived)类,派生类永远不能直接访问基类的私有成员重定义函数:派生类中无需继承的功能及要扩充的基类功能可以重定义成员函数,但在派生类再调用基类的同名函数时要用到作用域运算符Employee:print(),派生类的构造函数和析构函数:由于派生类继承了基类的成员,所以在建立派生类的实例对象时,必须调用基类的构造函数来初始化派生类对象中的基类成员。可隐式的调用基类构造函数,也可在派生类的构造函数中通过给基类提供初始化值(成员初始化值列表)明确的调用
9、构造函数。构造函数调用顺序:先执行基类的构造函数-派生类构造函数析构函数调用顺序正好相反。派生类不继承基类的构造函数、析构函数和赋值运算符,但派生类的构造函数和赋值运算符能调用基类的构造函数和赋值运算符。,类指针:指向基类的指针,指向派生类的指针。两者关系 可以直接用基类指针引用基类对象 可以直接用派生类指针引用派生类对象 可以用基类指针引用一个派生类对象,但只能引用基类成员。用派生类指针引用基类对象,绝对不行。必须先强制转换为基类指针,例一:基类和派生类的构造函数说明:在继承关系中构造函数执行顺序。例二:指向类的指针说明:int*p;/指向整型的指针P。例三:对不同的类使用相同的指针说明:程
10、序从头到尾始终只用一个“万能”指针指引一切。例四:使用指针时基类和派生类名字的冲突说明:如果基类和派生类中有同名函数,会怎样呢?,多态,多态性是面向对象的核心,它的最主要的思想就是可以采用多种形式的能力,通过一个用户名字或者用户接口完成不同的实现。通常多态性被简单的描述为“一个接口,多个实现”。在C+里面具体的表现为通过基类指针访问派生类的函数和方法。,动态联编:联编就是将模块或者函数合并在一起生成可 执行代码的处理过程,同时对每个模块或者函数调用分配内存地址,并且对外部访问也分配正确的内存地址。按照联编所进行的阶段不同,可分为两种不同的联编方法:静态联编和动态联编。在编译阶段就将函数实现和函
11、数调用关联起来称之为静态联编,静态联编在编译阶段就必须了解所有的函数或模块执行所需要检测的信息,它对函数的选择是基于指向对象的指针(或者引用)的类型。反之在程序执行的时候才进行这种关联称之为动态联编,动态联编对成员函数的选择不是基于指针或者引用,而是基于对象类型,不同的对象类型将做出不同的编译结果。,换一种说法:如果使用基类指针访问派生类中的同名函数,希望执行的是派生类中的成员函数,怎样实现?,下面我们看一个静态联编的例子:#include class shape public:void draw()coutfun();程序的输出结果?,程序的输出结果我们希望是I am circle,造成这个
12、结果的原因是静态联编。解释:静态联编需要在编译时候就确定函数的实现,但事实上编译器在仅仅知道shape的地址时候无法获取正确的调用函数,它所知道的仅是shape:draw(),最终结果只能是draw操作束缚到shape类上。产生I am shape的结果就不足为奇了。,事实上却输出了“I am shape”,为了能够引起动态联编,我们只需要将需要动态联编的函数声明为虚函数即可。动态联编只对虚函数起作用。我们在通过基类而且只有通过基类访问派生类的时候,只要这个基类中直接的或者间接(从上层继承)的包含虚函数,动态联编将自动唤醒。下面我们将上面的程序稍微改一下。#include class shap
13、e public:virtual void draw()coutdraw();程序执行得到了正确的结果I am circle。,关键,动态联编过程:编译器在执行过程中遇到virtual关键字的时候,将自动安装动态联编需要的机制,首先为这些包含virtual函数的类(注意不是类的实例)-即使是祖先类包含虚函数而本身没有-建立一张虚拟函数表VTABLE。在这些虚拟函数表中,编译器将依次按照函数声明次序放置类的特定虚函数的地址。同时在每个带有虚函数的类中放置一个称之为vpointer的指针,简称vptr,这个指针指向这个类的VTABLE。关于虚拟函数表,有几点必须声明清楚:1.C+编译时候编译器会在
14、含有虚函数的类中加上一个指向虚拟函数表的指针vptr。2.从一个类别诞生的每一个对象,将获取该类别中的vptr指针,这个指针同样指向类的VTABLE。,因此类、对象、VTABLE的层次结构可以用下图表示。其中X类和Y类的对象的指针 都指向了X,Y的虚拟函数表,同时X,Y类自身也包含了指向虚拟函数的指针。,#include class shape public:virtual void draw()coutshape:draw()endl;virtual void area()coutshape:area()endl;void fun()draw();area();class circle:pu
15、blic shapepublic:void draw()coutcircle:draw()endl;virtual void adjust()coutcircle:adjust()endl;main()shape oneshape;oneshape.fun();circle circleshape;shape 10.9,基类执行自己的成员函数,基类指针指向派生类对象,纯虚函数和抽象类,1、基类中的纯虚函数声明如下:Virtual type function_name(参数)=0;这样定义意味着,谁继承了该函数所在的类,谁就要负责实现该函数。2、如果一个类的定义中只包含纯虚函数,那么这个类称为抽
16、象类。,例一:静态联编说明:静态联编时,函数的选择由指针决定例二:动态联编说明:例三:动态联编说明:程序对函数的选择例四:使用虚拟函数说明:有什么特别之处吗?例五:多态,从面向对象过度到面向COM,COM是什么?,COM不是什么?,使用组件的优点:应用程序的定制组件库分布式组件,对组件的需求:1、动态链接2、消息封装,COM是一种跨应用和语言共享二进制代码的方法。COM明确指出了二进制模块(DLLs和EXEs)必须被编译成与指定的结构匹配。这个标准也确切地规定了在内存中如何组织COM对象。COM定义的二进制标准还必须独立于任何编程语言(如C+中的命名修饰)。一旦满足了这些条件,就可以轻松地从任
17、何编程语言中存取这些模块。由编译器负责所产生的二进制代码与标准兼容。这样使后来的人就能更容易地使用这些二进制代码。,使用和处理COM对象每一种语言都有其自己处理对象的方式。例如,C+是在栈中创建对象,或者用new动态分配。因为COM必须独立于语言,所以COM库为自己提供对象管理例程。下面是对COM对象管理和C+对象管理所做的一个比较:创建一个新对象C+中,用new操作符,或者在栈中创建对象。COM中,调用COM库中的API。删除对象C+中,用delete操作符,或将栈对象踢出。COM中,所有的对象保持它们自己的引用计数。调用者必须通知对象什么时候用完这个对象。当引用计数为零时,COM对象将自己
18、从内存中释放。由此可见,对象处理的两个阶段:创建和销毁,缺一不可。当创建COM对象时要通知COM库使用哪一个接口。如果这个对象创建成功,COM库返回所请求接口的指针。然后通过这个指针调用方法,就像使用常规C+对象指针一样。,当你调用CoCreateInstance()时,它负责在注册表中查找COM服务器的位置,将服务器加载到内存,并创建你所请求的coclass实例。以下是一个调用的例子,创建一个CLSID_ShellLink对象的实例并请求指向这个对象IShellLink接口指针。HRESULT hr;IShellLink*pISL;hr=CoCreateInstance(CLSID_Shel
19、lLink,/coclass 的CLSID NULL,/不是用聚合 CLSCTX_INPROC_SERVER,/服务器类型 IID_IShellLink,/接口的IID(void*)/指向接口的指针 if(SUCCEEDED(hr)/用pISL调用方法 else/不能创建COM对象,hr 为出错代码,创建COM对象为了创建COM对象并从这个对象获得接口,必须调用COM库的API函数,CoCreateInstance()。其原型如下:HRESULT CoCreateInstance(REFCLSID rclsid,LPUNKNOWN pUnkOuter,DWORD dwClsContext,RE
20、FIID riid,LPVOID*ppv);参数解释:rclsid:coclass的CLSID,例如,可以传递CLSID_ShellLink创建一个COM对象来建立快捷方式。pUnkOuter:这个参数只用于COM对象的聚合,利用它向现有的coclass添加新方法。参数值为null表示不使用聚合。dwClsContext:表示所使用COM服务器的种类。最简单的COM服务器,为一个进程内(in-process)DLL,所以传递的参数值为CLSCTX_INPROC_SERVER。注意这里不要随意使用CLSCTX_ALL(在ATL中,它是个缺省值),因为在没有安装DCOM的Windows95系统上会
21、导致失败。riid:请求接口的IID。例如,可以传递IID_IShellLink获得IShellLink接口指针。ppv:接口指针的地址。COM库通过这个参数返回请求的接口。,当你调用CoCreateInstance()时,它负责在注册表中查找COM服务器的位置,将服务器加载到内存,并创建你所请求的coclass实例。以下是一个调用的例子,创建一个CLSID_ShellLink对象的实例并请求指向这个对象IShellLink接口指针。HRESULT hr;IShellLink*pISL;hr=CoCreateInstance(CLSID_ShellLink,/coclass 的CLSID NU
22、LL,/不是用聚合 CLSCTX_INPROC_SERVER,/服务器类型 IID_IShellLink,/接口的IID(void*)/指向接口的指针 if(SUCCEEDED(hr)/用pISL调用方法 else/不能创建COM对象,hr 为出错代码,删除COM对象 前面说过,你不用释放COM对象,只要告诉它们你已经用完对象。IUnknown是每一个COM对象必须实现的接口,它有一个方法,Release()。调用这个方法通知COM对象你不再需要对象。一旦调用了这个方法之后,就不能再次使用这个接口,因为这个COM对象可能从此就从内存中消失了。如果你的应用程序使用许多不同的COM对象,因此在用完
23、某个接口后调用Release()就显得非常重要。如果你不释放接口,这个COM对象(包含代码的DLLs)将保留在内存中,这会增加不必要的开销。如果你的应用程序要长时间运行,就应该在应用程序处于空闲期间调用CoFreeUnusedLibraries()API。这个API将卸载任何没有明显引用的COM服务器,因此这也降低了应用程序使用的内存开销。继续用上面的例子来说明如何使用Release():/像上面一样创建COM 对象,然后,if(SUCCEEDED(hr)/用pISL调用方法/通知COM 对象不再使用它 pISL-Release();,基本接口IUnknown每一个COM接口都派生于IUnkn
24、own。这个名字有点误导人,其中没有未知(Unknown)接口的意思。它的原意是如果有一个指向某COM对象的IUnknown指针,就不用知道潜在的对象是什么,因为每个COM对象都实现IUnknown。IUnknown 有三个方法:AddRef()通知COM对象增加它的引用计数。如果你进行了一次接口指针的拷贝,就必须调用一次这个方法,并且原始的值和拷贝的值两者都要用到。Release()通知COM对象减少它的引用计数。QueryInterface()从COM对象请求一个接口指针。当coclass实现一个以上的接口时,就要用到这个方法。当你用CoCreateInstance()创建对象的时候,你得
25、到一个返回的接口指针。如果这个COM对象实现一个以上的接口(不包括IUnknown),你就必须用QueryInterface()方法来获得任何你需要的附加的接口指针,从C+到COM客户重用C+对象,C+客户重用C+对象功能介绍:用字符串数组模拟数据库管理系统的工作原理。实现对“数据库”的建立、删除 读、写 表或记录的定位实现方法:定义DB类,将定义类的.h文件单独放在一个文件夹中,假装自己是接口。文件夹起名为interface。实现DB类,将实现类的.CPP文件单独放在一个文件夹中,文件夹起名为object。建一个VC+工程,将上面两个文件加入工程,增加菜单映射函数,实现菜单功能。,生成C+对
26、象CDB(Dbsev.h)class CDB public:HRESULT Read(short nTable,short nRow,LPTSTR lpszData);HRESULT Write(short nTable,short nRow,LPCTSTR lpszData);HRESULT Create(short#endif,CDB类的实现文件DBsrv.cpp:#include stdafx.h#include.InterfaceDBsrv.h#define new DEBUG_NEW/Database objectHRESULT CDB:Read(short nTable,short
27、 nRow,LPTSTR lpszData)CStringArray*pTable;pTable=(CStringArray*)m_arrTablesnTable;lstrcpy(lpszData,(*pTable)nRow);return NO_ERROR;HRESULT CDB:Write(short nTable,short nRow,LPCTSTR lpszData)CStringArray*pTable;pTable=(CStringArray*)m_arrTablesnTable;pTable-SetAtGrow(nRow,lpszData);return NO_ERROR;,HR
28、ESULT CDB:Create(short,客户程序1、创建客户程序,起名为DB。2、添加菜单项:建表:添加一个名称为“Testing”的表到文档的数据库对象中。写表:写一个字符串到新表的第一行。读表:读出新表第一行的内容并放在CDBDoc:m_csData中,然后 由CDBView将它显示在窗口客户区。3、实现菜单函数4、显示读表内容 pDC-TextOut(10,10,pDoc-m_csData);5、添加对象代码,程序运行效果演示,将C+对象打包成DLLDB_cppdll,要将对象的实现封装成DLL,必须考虑如下事情:成员函数的引出;Unicode/ASCII兼容性。,引出函数的一个简
29、单方法是用_declspec(dllexport)例如:_declspec(dllexport)int MyFunction(int n);_declspec(dllexport)可用于任何函数,包括类的成员函数,它可以告诉编译器将入口放进引出函数表中。要引出CDC类中的所有成员函数,只需在每个成员函数之前加上_declspec(dllexport)。,Unicode/ASCII兼容 问题的由来:所有与DB工程相关的例程都可以创建成使用Unicode和使用ASCII。但是有一些函数的参数是字符串类型,以Unicode或ASCII形式编译,得到的二进制文件将有所不同。所以应将所有函数参数标准化为
30、Unicode,因为Unicode是ASCII的超集。,步骤一:修改接口文件,#define DEF_EXPORT _declspec(dllexport)class CDB/Interfaces public:DEF_EXPORT Read(short nTable,short nRow,LPWSTR lpszData);DEF_EXPORT Write(short nTable,short nRow,LPCWSTR lpszData);/Implementation private:CPtrArray m_arrTables;/pointers to CStringArray(the da
31、tabase)CStringArray m_arrNames;/Array of table namespublic:CDB();,为CDB类的每个成员函数添加_declspec(dllexport)声明。添加成员函数Release(),该函数在对象不再被使用删除自己;声明类厂CDBSrvFactory;声明返回类厂对象的引出函数DllGetClassFactoryObject。,步骤二:修改对象程序1、创建DLL工程框架 Win32 Dynamic-Link Library。起名为DB。2、添加对象文件DBSrv.cpp和stdafx.h到工程。3、修改对象实现文件。在CDB对象的实现文件D
32、BSrv.cpp中添加 CDB:Release()的实现代码。ULONG CDB:Release()delete this;return 0;4、实现CDBSrvFactory。新建DBSrvFactory.cpp及对象的引出函数DllGetClassFactoryObject。5、将参数标准化为Unicode.6、创建程序。生成引入库文件(.LIB)和动态链接库文件(.DLL)。,步骤三:修改客户程序1、修改对象删除方式。将CDBDoc:CDBDoc()中的 delete m_pDB 改成m_pDB-Release()2、通过类厂创建对象CDB。修改CDBDoc:OnNewDocment()
33、3、将参数标准化为Unicode。4、连接DLL。5、创建客户程序。将DB.DLL拷贝到客户程序所在目录下,编译。,运行程序,观看效果,过渡,问题:私有成员变量被暴露,解决方案:抽象基类 即,将“接口”头文件中定义的成员函数定义成虚函数,将成员变量删除。将CDB改成 IDB,CDBSrvFactory改成IDBSrvFactory。,第二步:修改对象程序 增加头文件DBSRVIMP.H 其中包含dbsrv.h 其他部分也做相应修改,第三步:修改对象实现文件 将包含的头文件dbsrv.h改成dbsrvimp.h#include stdafx.h#include DBsrvImp.h“HRESUL
34、T CDBSrvFactory:CreateDB(IDB*ppvDBObject)*ppvDBObject=(IDB*)new CDB;return NO_ERROR;ULONG CDBSrvFactory:Release()delete this;return 0;HRESULT DEF_EXPORT DllGetClassFactoryObject(IDBSrvFactory*ppObject)*ppObject=(IDBSrvFactory*)new CDBSrvFactory;return NO_ERROR;,第四步:修改客户程序 将CDBDoc的数据成员类型由CDB*改成IDB*pu
35、blic:IDB*m_pDB;在CDBDoc:OnNewDocument函数中,将CDBSrvFactory*改成 IDBSrvFactory*第五步:新生成DLL,拷贝到客户程序下,运行效果和原来一样!,改由COM库装载C+对象,前面的示例中,DLL声明了一个入口点DllGetClassFactoryObject,客户程序调用此函数可以获得类厂对象,再由类厂创建真正的对象DB。,这样做隐含的问题:如何在一个DLL中实现多个对象(类)。解决办法:为每一个准备引出的类提供一个入口点;给一个标准入口点传递一个额外参数,表明所需要的类。事实上,COM正是采用的第二种做法!它使用用统一的类厂获取函数:
36、STDAPI DllGetClassObject(REFCLSID rclsid,REFIID riid,void*ppObject)REFCLSID rclsid 是个128位的二进制数字标识,做为类标识。REFIID riid 也是个128位的二进制数字标识,做为类的接口标识。void*ppObject 是返回的对应类的类厂指针。现在的例子实际上只有一个接口,如果一个类中嵌有多个类,那么内部的每一个类就是一个接口,这就是为什么除了类标识还要有接口标识的原因。,第一步:修改接口文件 在dbsrv.h中增加类ID和接口ID的说明 删除DllGetClassFactoryObject,为使用统一
37、的DllGetClassObject做准备 第二步:修改对象程序 在dbsrvfact.cpp中定义GUID。在dbsrvfact.cpp用DllGetClassObject替换DllGetClassFactoryObject注意:DllGetClassObject在系统提供的objbase.h中已经声明所以不用在申明。第三步:手工注册 运行Regedit.exe,打开HKEY_CLASSES_ROOTCLSID 增加一个子键,名称为30DF3430-0266-11cf-BAA6-00AA003E0EED第四步:修改客户程序 调用COM库函数创建对象 初始化COM库 增加ID定义,将C+对象变
38、成COM对象,最后,妈呀,总算到头了,要将C+对象变成一个真正的COM对象只要实现如下操作:实现接口的引用计数。对象容许实现多个接口。类厂对象使用标准的IClassFactory。使用_stdcall调用约定(COM对象在Win32下采用的标准调用约定)。实现DLL动态卸载。实现对象自注册。比较枯燥的几个重要概念:1、引用计数 ULONG AddRef();ULONG Release();2、多接口 如果每次都通过IID和CoGetClassObject调用接口,系统开销比较大为了方便在一个COM里多个接口之间转换,对象可以提供一个接口查询函数:HRESULT QueryInterface(R
39、IID riid,void*ppObj);3、IUnKnown接口 实现以上三个函数,定义在一个类里。其他接口继承这个类。4、标准类厂接口 IClassFactory,改造CreateDB(IDB*ppObj)为:CreateInstance,5、自动注册调用DllRegisterServer和DllUnRegisterServer具体操作如下:第一步:修改接口文件修改IDB删除类厂说明和IID_IDBSrvFactory。第二步:修改对象程序修改原来类厂有关说明增加QueryInterface、AddRef和Release三个成员函数实现QueryInterface、AddRef和Release实现CreateInstance等。编译后,注册DB.DLL(使用regsvr32)第三步:修改客户程序定义IID修改对象创建过程,第一次使用自己的COM组件,使用ATL开发COM组件,创建并测试组件,