赞
踩
目录
一种观点:“实际工作中,写好程序后对程序功能的调试就是一种单元测试”,“写好程序,编译完,跑一跑,看看写得对不对,这就是最简单的UT啊!”。是的这些是简单的UT,但是这些并不能保证你的程序完整性和正确性,只能保证你在进行了“简单的UT”后的流程正确而已。试想,如果在做系统测试时,测试的同事也随心所欲的测测,“简单的ST”一下,说这个产品没问题,谁相信?我想我们自己都不会相信测试同事给出的结果,因此测试部的同事为了使大家相信我们产品的质量,或者他们对产品测试的结果,通常会先设计大量的测试用例,然后通过执行这些测试用例,最后得到一个测试报告。而只有报告和之前设计的测试用例同事过审,才能使领导相信,这个产品没有太大的问题,可以卖给客户了。
同样的,要让别人相信你写的程序(函数)没问题,最好的证明方式就是通过全面的测试数据和测试用例对你写的程序进行单元测试,然后得到一个测试报告,并且所有的这些都过审才能成为一个接受的单元测试结果,才能说服别人你的代码(函数)没有问题。
1)领导权衡的问题
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
通常来说,程序员每修改一次程序就会进行最少一次单元测试,在编写程序的过程中前后很可能要进行多次单元测试,以证实程序达到软件规格书要求的工作目标,没有程序错误;虽然单元测试不是必须的,但也不坏,这牵涉到项目管理的政策决定。
每个理想的测试案例独立于其它案例;为测试时隔离模块,经常使用stubs、mock[1]或fake等测试马甲程序。单元测试通常由软件开发人员编写,用于确保他们所写的代码匹配软件需求和遵循开发目标。它的实施方式可以是非常手动的(透过纸笔),或者是做成构建自动化的一部分。
软件工程教材对单元测试的描述,编译也算单元测试的一部分。
我们编写代码时,一定会反复调试保证它能够编译通过。如果是编译没有通过的代码,没有任何人会愿意交付给自己的老板。但代码通过编译,只是说明了它的语法正确;我们却无法保证它的语义也一定正确,没有任何人可以轻易承诺这段代码的行为一定是正确的。
幸运的是,单元测试会为我们的承诺做保证。编写单元测试就是用来验证这段代码的行为是否与我们期望的一致。有了单元测试,我们可以自信的交付自己的代码,而没有任何的后顾之忧。
白盒测试内容:
所以总结下来,单元测试的内容如下:
单元测试一般都以白盒测试的方法为主,辅以黑盒测试的方法。
单元测试用例的设计就是要结合测试内容、测试方法。
逻辑覆盖测试方法通常采用流程图来设计测试用例,它考察的重点是图中的判定框,因为这些判定通常是与选择结构有关或者循环结构有关,是决定程序结构的关键成分。
构造单元测试模型的主要工作有:
测试模型除了能使被测对象运转起来之外,还应该考虑对测试过程的支持,例如对测试结果的保留,对测试覆盖率的记录等。
驱动模块和桩模块都是额外的开销,两种都属于必须开发,但又不能和最终软件一起提交的软件。驱动模块和桩模块为程序单元的执行构成了一个完整的环境,如图所示。
单元测试带来的好处:
单元测试的目标是隔离程序单元并验证其正确性。自动执行使目标达成更有效,也可获得本文上述单元测试收益。相反,不细心规划或者精心的单元测试可能被视为包括多个软件组件的集成测试案例,于是将因未完全达到创建单元测试的预定目标,测试可能失去较多收益。
在自动化测试时,为了实现隔离的效果,测试将脱离待测程序单元(或代码主体)本身固有的运行环境之外,即脱离产品环境或其本身被创建和调用的上下文环境,而在测试框架中运行。以隔离方式运行有利于充分显露待测试代码与其它程序单元或者产品数据空间的依赖关系。这些依赖关系在单元测试中可以被消除。
借助于自动化测试框架,开发人员可以抓住关键进行编码并通过测试去验证程序单元的正确性。在测试案例执行期间,框架通过日志记录了所有失败的测试准则。很多测试框架可以自动标记和提交失败的测试案例总结报告。根据失败的程度不同,框架可以中止后续测试。
总体说来,单元测试会激发程序员创造解耦的和内聚的代码体。单元测试实践有利于促进健康的软件开发习惯。设计模式、单元测试和重构经常一起出现在工作中,借助于它们,开发人员可以生产出最为完美的解决方案。
总结:这些c单元测试框架统一得出的结论是,C的单元测试其实就是对于assert方法,free来检测内存泄漏的一个包装。
基本来自cmockery官方指导书的翻译。
Cmockery就是之前基本按照“单元测试模型构建”过程构建的一个单元测试模型框架。Cmockery与Cmockery库,标准C库,待测模块链接在一起,最终被编译成一个可以独立运行的程序。在测试过程中,待测模块的任何外部信息都应被模拟,即用测试用例中定义的函数返回值来替换。即使会出现待测代码在实际运行环境和测试环境运行时有差异,仍可视为有效。因为它的目的在于代码模块在功能面上的逻辑测试,不必要求所有的行为都和目标环境一致。
如果不做一些修改,无法将一个模块编译成可执行程序。因此 UNIT_TESTING 预定义应当定义在执行Cmockery单元测试的应用程序中, 编译待测代码时,可用条件编译决定是否参与单元测试。
Cmockery单元测试用例就是函数,签名为void function(void **state) . Cmockery 测试程序将(多个)测试用例的函数指针初始化到一个表中,使用unit_test*() 宏. 这个表会传给 run_tests() 宏来执行测试用例。 run_tests()将适当的异常/信号句柄,以及其他数据结构的指针装入到测试函数。当单元测试结束时, run_tests() 会显示出各种定义的测试是否成功。
这个就是传说中的测试驱动。
run_test.c
#include <stdarg.h> #include <stddef.h> #include <setjmp.h> #include <cmockery.h> // A test case that does nothing and succeeds. void null_test_success(void **state) { } int main(int argc, char* argv[]) { const UnitTest tests[] = { unit_test(null_test_success), }; return run_tests(tests); } |
Cmockery提供了一系列的断言宏,在测试程序的使用方法和C标准中的用法一致。 当断言错误发生时,Cmockery的断言宏会将这个这个错误输出到标准错误流,并把这个测试标记为失败。 由于标准C库中assert()的是限制,Cmockery的assert_true() 和 assert_false() 宏只能显示导致断言失败的表达式。Cmockery中和具体类型相关的断言宏, assert_{类型}_equal() and assert_{类型}_not_equal(), 显示那些导致断言失败的数据, 这样可以增加数据的可视化,辅助调试那些出错的测试用例。
例子 |
一个单元测试最好能将待测函数或模块从外部依赖中隔离。 这就会用到模拟函数,它通过动态或静态方式链接到待测模块中去。 当被测代码直接引用外部函数是,模拟函数必须静态链接。 动态链接是一个简单的过程,将一个函数指针放到一个表中,给待测模块中一个测试用例定义的模拟函数引用。
为了简化模拟函数的实现,Cmockery 提供了给模拟函数的每个测试用例存放返回值的功能,使用的是will_return() 函数。然后,这些值将通过每个模拟函数调用mock() 返回。 传给will_return() 的值,将分别添加到每个函数所特有的队列中去。连续调用 mock() ,将从函数的队列中移除一个返回值。 这使一个模拟函数通过多次调用mock() ,来返回(多个)输出参数和(一个)返回值成为可能。 此外,一个模拟函数多次调用(多个)返回值的做法也是可以的。
例子 |
除了存储模拟函数的返回值之外,Cmockery还提供了对模拟函数参数期望值的存储功能,使用的是 expect_*()函数,一个模拟函数的参数可以通过check_expected()宏来做有效的验证.
连续调用expect_*()宏,是用队列中的一个参数值来检测给定的参数。 check_expected()检测一个的函数参数,它与expect_*()相对应,即将出队的值。 如果参数检验失败,这个测试将标记为失败。 此外,如果调用check_expected()时,队列中没有参数值出队,测试也会失败。
例子 |
|--bsu_prj |--H1 |--F1 |--sound |--*.wav |--NFV |--*.sh |--Linux |--Makefile |--buss.mak |--utest.mak |--*.sh |--*.sql |--version.h |--utest |--Makefile |--ut_main.c |--ut_common.h |--utest_*.c |--utest_*.h |
代码中主要的修改是:
根据我们自己程序的实际情况,我们可能会违背一些之前文章中说的单元测试原则,隔离。因为我在编译cmockery框架时已经将vpbx整个连接进去了。通常情况下,我们可以构造一些环境,直接让被测程序调用到被测程序的内层程序(可能包括好几层),如果我们构造的单元测试环境够好的话,某些被测单元是不需要写桩函数的;我们也可以直接将被测单元内的函数使用桩函数替代,因为我们不是在测试被测单元内的函数,只需要他返回该返回的结果而已,毕竟我们的目的是在测试被测单元的流程、功能是否正确。当然,如果我们按照UT的基本思想来处理的话,当然第二种方式更合理,更符合UT的思想;实际使用中,那就不一定了。
比较:
使用桩函数,一,需要写桩函数;二,需要维护桩函数;三,需要在执行单元测试的时候用桩函数替换掉原函数(其实不好搞);
使用原函数,一,你得把原函数内所有的调用,这些调用的调用,调用的调用的调用…都得看一遍;二,看一遍的目的是为了构建测试环境,你要确保你调用的这个函数中的每个调用的测试环境你都构建了,否则你这个单元测试是走不下去的;三,其实从某种情况来说,这样的测试不能完全较单元测试了。
UT对应的应该是编码阶段,如果一个新的项目,通常来讲,都是各写各的代码,各写各的单元测试代码,各进行各自的单元测试。理论上不会像我们现在vpbx可以直接全部编译完成,然后可以调用被测单元内的原函数(这些函数可能由别人开发、测试),所以理论上使用桩函数是最好的。但是根据我们的实际情况,我们有条件去调用被测单元内的原函数,那就可以去构造测试环境然后调用。
以上分析可得到一个结论:针对同一个函数,如果有的单元测试用例要用桩函数,有的要用原函数,那么对于现在我的编译方法编出来的测试框架在执行不同的单元测试用例的时候就会有问题。所以要针对每个模块进行编译?会导致代码的不统一性。。。
目前我已经做了cmockery能在我们的工程中进行单元测试的几个实例供大家参考。
类似黑盒测试,只需验证结果是否正常
int add(int a,int b) { return a+b; } void test_add(void **state) { assert_int_equal(add(3,3),6); assert_int_equal(add(-3,3),0); } void main() { const UnitTest tests[]= { unit_test(test_add), }; return run_tests(tests,"test_add") } |
入参检查,最简单的测试环境构建
UINT32 find_opcode_by_resource_name(char* pname,DMU_CFG_DATE_LIST* plist ) { //内容就是在plist 中找到 pname ,然后返回OPCODE //DMU_CFG_DATE_LIST 结构包含 name 和 opcode //找到return opcode;没找到return NULL; } //环境构造 DMU_CFG_DATE_LIST testlist[]={ {"test1",0x1111}, {"test2",0x1112}, {"test3",0x1113}, }; //入参检查单元测试 void test_find_opcode_by_resource_name_pname_null(void **state) { expect_assert_failues(find_opcode_by_resource_name(NULL,testlist)); } void test_find_opcode_by_resource_name_plist_null(void **state) { assert_int_equal(find_opcode_by_resource_name("aaa",NULL),0); } //pname 异常测试 void test_find_opcode_by_resource_name_pname_unusual(void **state) { pname="";//空字符串测试 pname1="abcdefghigk....";//超长字符串测试 assert_int_equal(find_opcode_by_resource_name(pname,testlist),0); expect_assert_failues(find_opcode_by_resource_name(pname1,testlist)); //assert_int_equal(find_opcode_by_resource_name(pname1,NULL),0); } void test_find_opcode_by_resource_name_list_has_no_pname(void **state) { assert_int_equal(find_opcode_by_resource_name("aaa",testlist),0); } void test_find_opcode_by_resource_name_list_nomal_find(void **state) { assert_int_equal(find_opcode_by_resource_name("test1",testlist),0x1111); assert_int_equal(find_opcode_by_resource_name("test2",testlist),0x1112); assert_int_equal(find_opcode_by_resource_name("test3",testlist),0x1113); } |
有返回值和出参的测试
/* /regest --->/regest ---->opcode /bsu/1/cfg --->/bsu/cfg /bsu/1/ten/11 --->/bsu/ten /bsu/2/ten/11/puis --->/bsu/ten/puis /bsu/2/ten/11/pui/1001/cfb --->/bsu/ten/pui/cfb */ //函数功能,将url 变成 可认识的字符串 UINT32 get_info_from_url(char *purl,DMU_URL_INFO* pinfo) { //出入参数有效性判断 //分析url,看url有几个/ ptemp=purl; while('\0'!=*ptemp) { if('/'==*ptemp) { num++; } ptemp++; } switch(num) { case 0: return 1; case 1: ... //把结果赋给pinfo带出 return 0; case 2: ... //把结果赋给pinfo带出 return 0; ... default: return 1; } return 0; } //utest 参数的有效性验证 //purl 异常验证,如空字符串,特别长的字符串 //正常purl /* DMU_URL_INFO { char aucShortUrl[DMU_STR_LEN]; char aucRes1[DMU_STR_LEN]; int ulUnitId; char aucRes2[DMU_STR_LEN]; int ulTenantId; char aucRes3[DMU_STR_LEN]; char aucRes4[DMU_STR_LEN]; } */ DMU_URL_INFO sttestlist={}; void utest_get_info_from_url_nomal(**state) { //首先判断函数返回值 assert_int_equal(get_info_from_url_nomal("/regest",&sttestlist),0); //如果在预期内再开始判断出参 assert_string_equal(sttestlist.aucShortUrl,"regest"); //首先判断函数返回值 assert_int_equal(get_info_from_url_nomal("/bsu/1/cfg",&sttestlist),0); //如果在预期内再开始判断出参 assert_string_equal(sttestlist.aucShortUrl,"/bsu/cfg"); assert_int_equal(sttestlist.ulUnitId,1); ... ... } void utest_get_info_from_url_unnomal(**state) { //首先判断函数返回值 assert_int_equal(get_info_from_url_nomal("/regest/ ",&sttestlist),0); //如果在预期内再开始判断出参 assert_string_equal(sttestlist.aucShortUrl,"regest"); assert_int_equal(get_info_from_url_nomal("regest/ //",&sttestlist),0); //如果在预期内再开始判断出参 assert_string_equal(sttestlist.aucShortUrl,"regest"); //首先判断函数返回值 assert_int_equal(get_info_from_url_nomal("/bsu/1//cfg",&sttestlist),0); //如果在预期内再开始判断出参 assert_string_equal(sttestlist.aucShortUrl,"/bsu/cfg"); assert_int_equal(sttestlist.ulUnitId,1); ... ... } |
这个例子想说明几个事情:
测试环境构造的两种方法
UINT32 cscf_regas_msg_set_string_content(CSCF_REGAS_STRING *pstInString, imp_sip_cs_IMP_STRING_STRUCT *pstOutString, IMP_SPM_MSG_CONTEXT *pstMsgContext, UINT32 ulbufflen) { //入参检查 //使用pstMsgContext给pout申请空间 //copy pin到pout return 0; } UINT32 cscf_regas_msg_set_part(CSCF_REGAS_SET_MSG_PART_T *pstIn, imp_sip_cs_PARTY_HEAD_T *pstOut, IMP_SPM_MSG_CONTEXT *pstMsgContext, UINT8 ucAddType, UINT8 ucPortTag, UINT8 ucUsernameTag, UINT8 ucOtherParaTag ) { //入参检查 //根据 type ,tag判断哪个字段需要申请编码空间 //pOut->pstSipUrl->psthost申请编码空间 cscf_regas_msg_set_string_content(in.ststring,out.sthost,pstMsgContext,len); //如果username tag =1 给pOut->pstSipUrl->pstUsername申请编码空间 cscf_regas_msg_set_string_content(in.ststring,out.stusername,pstMsgContext,len); ... ... } UINT32 cscf_regas_msg_set_common_header(CSCF_REGAS_INSTANCE_T *pstIn, imp_sip_cs_COMMON_HEADER_T *pstOut, IMP_SPM_MSG_CONTEXT *pstMsgContext, CSCF_REGAS_COMMON_TAG *pstTag; ) { //入参检查 //根据 pstTag判断哪个字段需要申请编码空间 //如果from tag =1 pOut->pstFromHeader申请编码空间 cscf_regas_msg_set_part(in.ststring,out.sthost,pstMsgContext,len); //如果to tag =1 给pOut->pstToHeader申请编码空间 cscf_regas_msg_set_part(in.ststring,out.stusername,pstMsgContext,len); ... ... } //入参为内部数据结构,全部都是数据结构,出参的结构为:每个tag后面跟一个指针,所以要给指针分配了 //空间才能使用 //其中pstMsgContext 是一个全局的结构数组空间的一个元素。一个4068空间的地址 void test_cscf_regas_msg_set_string_content_nomal(**state) { //环境构造 IMP_SPM_MSG_CONTEXT *pstMsgContext=NULL; imp_spm_msg_context_init(); pstMsgContext=imp_spm_msg_context_get(); //测试数据 CSCF_REGAS_STRING string ={18,"test.xxxx.com"}; imp_sip_cs_IMP_STRING_STRUCT *ststring=NULL; ststring=(imp_sip_cs_IMP_STRING_STRUCT*)imp_spm_msg_context_malloc(pstMsgContext,sizeof());
//断言 assert_int_equal(cscf_regas_msg_set_string_content(&string,ststring,pstMsgContext),0); assert_string_equal("test.xxxx.com",ststring->paucString); }
void test_cscf_regas_msg_set_string_content_nomal_other(**state) { //环境构造 IMP_SPM_MSG_CONTEXT stMsgContext={}; IMP_SPM_MSG_CONTEXT *pstMsgContext=&stMsgContext; pstMsgContext.pucMsg=pstMsgContext.aucMsgBuf
//测试数据 CSCF_REGAS_STRING string ={18,"test.xxxx.com"}; imp_sip_cs_IMP_STRING_STRUCT *ststring=NULL; ststring=(imp_sip_cs_IMP_STRING_STRUCT*)imp_spm_msg_context_malloc(pstMsgContext,sizeof()); //这个malloc会用到全局变量astSipContext,可能会导致问题 //断言 assert_int_equal(cscf_regas_msg_set_string_content(&string,ststring,pstMsgContext),0); assert_string_equal("test.xxxx.com",ststring->paucString); } |
这个例子想说明以下几个事情:
sql数据库测试环境构造
UINT32 rdms_regas_get_sipconfig(CSCF_REGAS_CONFIG_SIPCONFIG_TBL_T *pSipconfiginfo) { //入参检查 //数据库查询 ulret=vpbx_db_get_record_ex(g_db_handle,"SipConfig","",1,&num,pSipconfiginfo); //根据结果返回 return ulret; } //自建sqlit3环境 extern sqlite3* g_pMemDbConn; sqlite3* my_db_handle=NULL; UINT32 env_vpbx_db_init() { UINT32 ret=0; ret=sqlite3_open(":memory",&my_db_handle); ... } UINT32 env_vpbx_db_load_cfg(char * path) { //加载数据 }
void utest_rdms_regas_get_sipconfig_nmal(**state) { int ret; CSCF_REGAS_CONFIG_SIPCONFIG_TBL_T test_sipconfig={0};
bussunitsdb_function_init(); ret=vpbx_db_init(3);//3表示bsu if(ret!=0) { printf("error"); } vpbx_db_load_cfg("./utest/xxx.sql");
//ret=env_vpbx_db_init(); //ret=env_vpbx_db_load_cfg("./utest/xxx.sql");
assert_int_equal(rdms_regas_get_sipconfig(&test_sipconfig),0); //断言的依据是./utest/xxx.sql插入到sqlit的数据 assert_int_equal(test_sipconfig.port,6090); assert_int_equal(test_sipconfig.expires,3600); } |
该例子我想说明以下几件事情:
UT的最基本原则,隔离。
#define VPBX_CONF_FILE "./utest/xxx.sql" UINT32 oam_agent_load_cfg_file() { #ifndef UT_TEST ret=vpbx_db_load_cfg(VPBX_CONF_FILE); #else ret=mock_vpbx_db_load_cfg(VPBX_CONF_FILE); #endif if(ret!=0) { return 1; } //判断ret scms_sysctl_broadcast(MSG,NULL,0); return 0; } //mock unsigned int vpbx_db_load_cfg(char filename[]) { check_expected(filename); return (unsigned int)mock(); } //mock 1 unsigned int mock_vpbx_db_load_cfg(char filename[]) { check_expected(filename); return (unsigned int)mock(); } void utest_oam_agent_load_cfg_file_load_ok(void **state) { expect_string(vpbx_db_load_cfg,filename,VPBX_CONF_FILE); will_return(vpbx_db_load_cfg,0); //expect_string(mock_vpbx_db_load_cfg,filename,VPBX_CONF_FILE); //will_return(mock_vpbx_db_load_cfg,0); assert_int_equal(oam_agent_load_cfg_file(),0); } void utest_oam_agent_load_cfg_file_load_fail(void **state) { expect_string(vpbx_db_load_cfg,filename,VPBX_CONF_FILE); will_return(vpbx_db_load_cfg,1); //expect_string(mock_vpbx_db_load_cfg,filename,VPBX_CONF_FILE); //will_return(mock_vpbx_db_load_cfg,0); assert_int_equal(oam_agent_load_cfg_file(),1); } |
该例子我想说明以下几件事情:
那么怎么去将被测函数换成桩函数呢?
基本过程如下:
funA 调用 funB,再定义个mock_funB作为funB的模拟函数 | funA UT执行 真 statement1; | 实 statement2; | 执 funB -----换掉----- |-->mock_funB 行 statement3; | ... | V |
怎么换函数呢?
UT构造的模拟函数,带出参。
UINT32 cscf_regas_get_sbccfg(CSCF_REGAS_IMSENTRANCE_TBL_T *vpstImsinfo) { //入参检查 CSCF_REGAS_IMSENTRANCE_TBL_T stImsinfo={0}; #ifndef UT_TEST ulret=vpbx_db_get_record_ex(g_pMemDbconn,TB_NAME_sImsEntrance,"",1,&num,void*(stImsinfo)); #else ulret=mock_vpbx_db_get_record_ex(g_pMemDbconn,TB_NAME_sImsEntrance,"",1,&num,void*(stImsinfo)); #endif if(ulret==0||num==0) { //打印 return 1; } voss_mem_memcpy(vpstImsinfo,&stImsinfo,sizeof(CSCF_REGAS_IMSENTRANCE_TBL_T)); return 0; } /* CSCF_REGAS_IMSENTRANCE_TBL_T 结构体大致如下 { aucSbcinfo[32];ucDiscovrMode;aucPrimarySBC[33];aucSecondarySBC[33];....} */ int mock_vpbx_db_get_record_ex(sqlite *db,char tbl_name[],char strWhere[],int Max_count,int *num,void *poutBuf) { CSCF_REGAS_IMSENTRANCE_TBL_T test_buf={"xxxx.com",1,"sbc","sbc1",....}; memcpy(poutBuf,&test_buf,sizeof(CSCF_REGAS_IMSENTRANCE_TBL_T)); *num=1; check_expected(tbl_name); return (int)mock(); } void utest_cscf_regas_get_sbccfg_nomal_mock(**state) { CSCF_REGAS_IMSENTRANCE_TBL_T stImsinfo={0}; expect_string(mock_vpbx_db_get_record_ex,tbl_name,TB_NAME_sImsEntrance); will_return(mock_vpbx_db_get_record_ex,0); assert_int_equal(cscf_regas_get_sbccfg(&stImsinfo),0); assert_string_equal(stImsinfo.aucSbcinfo,"xxxx.com"); assert_int_equal(stImsinfo.ucDiscovrMode,1); assert_string_equal(stImsinfo.aucSecondarySBC,"sbc1"); } |
该例子的说明:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。