标签 开源 下的文章

CBehave – 一个C语言行为驱动开发框架

Behaviour-Driven Development,即行为驱动开发在业界早已不是什么新鲜玩意了。我之前也略有了解,不过一直没有"深入钻研"。直到今年年初InfoQ的几篇有关BDD的文章才让我对BDD有了更多的认识。与TDD一样,C语言在BDD领域依旧是一个"后进分子",在多数主流语言(Java,C#,Ruby等)都已经拥有比较成熟的BDD框架(如JBehaveSpecFlowCucumber)的今天,C语言却似乎仅有一款BDD框架-CSpec可用。于是年初的时候我就把设计和实现一个用于C语言的行为驱动开发框架加入到我今年的ToDoList中了。

在确定好目标的同时,我也给这款框架命名为CBehave(模仿JBehave),并在Google Code上建立了CBehave的托管项目。但人的时间和精力总是有限的,直到8月中旬我才开始着手进行这个框架的设计和实现。设计和实现一款给程序员使用的工具,这本身就是一件让人兴奋的事情。我先是通过DanNorth的博文"Introducing BDD"(其中译版在这里)了解了BDD的"诞生历程",然后又广泛地了解了一下其他语言的BDD框架。对于CSpec这一目前唯一的C语言BDD框架,我并不想给予过多评价,不过总体来说和其他语言的BDD框架相比,CSpec有些简陋,应该说还无法很好的支持BDD中一些核心思想的表达,并且目前它还不支持Mock。这些都坚定了我重新实现一个C语言BDD框架的决心,起码我不完全是"重新发明轮子"^_^。

作为后来者,CBehave的设计参考了诸多现有的主流BDD框架,其中直接灵感来源于Cucumber,不过由于C语言静态编译语言的本质,CBehave与Cucumber在大多地方也只是形似而已。作为一篇CBehave的介绍性文章,这里列举一些CBehave的主要特点:

首先,CBehave借鉴Cucumber的设计采用Feature + Scenario结合的方式来描述功能需求(DanNorth: 需求也是行为),并且在每个Scenario内部采用BDD的经典的GIVEN-WHEN-THEN结构描述行为的验收标准(acceptance criteria)。

这里给出一个总体的行为描述模板:

FEATURE #1
    SCENARIO #1
        GIVEN
            … …
        WHEN
            … …
        THEN
            … …

    SCENARIO #2
        GIVEN
            … …
        WHEN
            … …
        THEN
            … …

    … …

FEATURE #2
… … 

FEATURE #n

原则上FEATURE之间是相互隔离的;FEATURE内部的多个Scenario之间在代码定义和执行时也是相互隔离,互不干扰的。这是一个使用该框架的基本约束。不过这就好比建议性锁,全靠使用时的自觉,否则很容易造成框架运行出错。

下面是一个真实的使用CBehave对strstr函数进行测试的例子(代码片断,完整例子参见源码cbehave/src/example/string_test.c):

FEATURE(1, "strstr")
    SCENARIO("The strstr finds the first occurrence of the substring in the source string")

        GIVEN("A source string: [Lionel Messi is a great football player]")
            char *str = "Lionel Messi is a great football player";
        GIVEN_END

        WHEN("we use strstr to find the first occurrence of [football]")
            char *p = strstr(str, "football");
        WHEN_END

        THEN("We should get the string: [football player]")
            SHOULD_STR_EQUAL(p, "football player");
        THEN_END

    SCENARIO_END

    SCENARIO("If strstr could not find the first occurrence of the substring, it will return NULL")

        GIVEN("A source string: FC Barcelona is a great football club.")
            char *str = "FC Barcelona is a great football club";
        GIVEN_END

        WHEN("we use strstr to find the first occurrence of [AC Milan]")
            char *p = strstr(buf, "AC Milan");
        WHEN_END

        THEN("We should get no string but a NULL")
            SHOULD_STR_EQUAL(p, NULL);
        THEN_END
    SCENARIO_END
FEATURE_END

int main() {
    cbehave_feature strstr_features[] = {
        {feature_idx(1)},
    };

    return cbehave_runner("Strstr Features are as belows:", strstr_features);
}

编译运行这个测试,我们会得到如下结果(节选):

Strstr Features are as belows:

Feature: strstr
 Scenario: The strstr finds the first occurrence of the substring in the source string
  Given: A source string: Lionel Messi is a great football player
  When: we use strstr to find the first occurrence of [football]
  Then: We should get the string: [football player]
 Scenario: If strstr could not find the first occurrence of the substring, it will return NULL.
  Given: A source string: FC Barcelona is a great football club.
  When: we use strstr to find the first occurrence of [AC Milan]
  Then: We should get no string but a NULL

Summary:
 total features: [1]
 failed features: [0]
 total scenarios: [2]
 failed scenarios: [0]

CBehave将strstr的行为原汁原味地输出到最终的测试结果中,与xUnit等框架相比,这确是一个进步,我们在获知测试结果的同时,还依稀中看到了这个特性的需求描述,前提是你要给出一个很好很精确的描述,但这已经不是框架可以帮助你做的了^_^。另外即使你的测试失败了,你甚至可以不通过错误提示中的源码文件名和行号信息也可以快速定位到错误的位置所在。因为错误周围是有足够的上下文信息的。

其次,CBehave支持mock。CBehave中mock的实现完全参考了我之前设计的单元测试框架LCUT,下面是一个简单的例子(片断,完整代码参加cbehave/src/example/product_database_test.c):

FEATURE(1, "Get the total count of employees")
    SCENARIO("Get the total count of employees")
        GIVEN("The db connection is ready and there are 5 employees in total");
            CBEHAVE_RETV_RETURN(connect_to_database, 0×1234);
            CBEHAVE_ARG_RETURN(table_row_count, 5);
            CBEHAVE_RETV_RETURN(table_row_count, 0);
        GIVEN_END

        WHEN("We call function: get_total_count_of_employee");
            int count = get_total_count_of_employee();
        WHEN_END

        THEN("The total count of employees we read from db should be 5")
            SHOULD_INT_EQUAL(count, 5);
        THEN_END

    SCENARIO_END
FEATURE_END

最后,CBeahve还支持多种SHOULD_XX宏,并且可根据需要灵活添加。目前已支持整型、字符串以及布尔类型的判定。

关于CBehave的实现历程这里也简单说一下。

首先需要确定CBehave的"长相",也就是CBehave采用的行为描述模板是啥样子的。这块儿确是花了我不少的时间,查看各种资料以及研究其他框架的设计,最终选择了Feature+Scenario以及用Given-When-Then来描述行为的"文档模板"。

其次,有了"文档模板"后如何将其转换为可执行的代码实体?这块也费了我不少脑细胞。思前想后,最终设计是将Feature转换成一个可运行的实体-函数。另外我在函数中使用{}来物理划分Scenario,{}可以隔离变量的可见性和作用域,已达到多个Scenarios定义和执行互不干扰的目的。

上面的标准文档结果中的一个FEATURE宏展开后的样子大致是这样的:

static void cbehave_feature_n(void *_state) {
    cbehave_state _old_state;
    cbehave_feature_entry(…, &_old_state, _state);

    {
        /* Scenario #1 */
        int _scenario_state = 0; \
        cbehave_scenario_entry(x, _state);

        … …
        cbehave_scenario_exit(&_scenario_state, _state);
    }

    {
        /* Scenario #2 */

    }

    … …
    {
        /* Scenario #n */

    }

_feature_over: \
    cbehave_feature_exit(…);
}

关于feature函数的命名,我考虑了很长时间,由于从外界输入的信息无法约束,这里我引入了Feature序号,并同时将序号作为Feature函数名的一部分。例如:
FEATURE(10, "fopen features")
展开后的feature函数名就是cbehave_feature_10,这样做对用户也有一定约束,但约束较小,只需CBehave用户保证各个Feature的序号不同即可。

在使用CBehave时,很可能出现另外一个问题:那就是测试代码在GIVEN或WHEN区段中依赖的一些资源申请或其他代码的初始化可能失败或出现异常。遇到这种情况时,用户多选择return或exit。但一旦用户这样做,CBehave就无法统计和输出测试失败情况或统计的不够准确了。为了尽量保证CBehave统计的精确性,CBehave提供了一个宏FEATURE_RETURN供用户使用。FEATURE_RETURN将控制权转移到Feature函数的末尾,也就是上面宏展开末尾的哪个_feature_over跳转标识符处,这样Feature有机会把这一错误情况记录下来。另外还可以保证其他Feature测试的继续运行。这里举个简单例子(完整代码参见cbehave/src/example/text_editor_test.c):

FEATURE(1, "Text Editor – Open Exsited File")
    SCENARIO("Open an Exsited File and write something to it")

        GIVEN("A file named foo.txt")
            FILE *fp = NULL;
            char *buf = "Hello Cbehave!";
        GIVEN_END

        WHEN("we open the file and write something to it")
            fp = fopen("foo.txt", "r+");
            if (!fp)
                FEATURE_RETURN(errno);
        WHEN_END

        THEN("We should see [Hello Cbehave] has been written into foo.txt")
            if (fp)
                fclose(fp);
        THEN_END
    SCENARIO_END
FEATURE_END

例子中如果fopen打开foo.txt失败,代码中调用了FEATURE_RETURN来应对这一情况,而不是直接调用return或exit。

最后,与LCUT不同,Cbehave会尝试运行完所有Feature的测试,而不是遇到测试错误就停止运行。

因为之前有过LCUT单元测试框架的设计和实现经验,这次CBehave框架的设计和实现就相对容易了些。CBehave的设计用了一天时间,上周末两天"百忙"中抽空完成了编码和测试,目前已提供了cbehave-0.1.0-beta版供下载体验,欢迎大家提出你的宝贵意见和建议^_^,更多关于CBehave使用方面的细节请参考CBehave用户指南(http://code.google.com/p/cbehave/wiki/CBehave_User_Guide_cn)。

BTW,我并不是一个纯粹的TDDers。我个人认为完全采用TDD或是BDD还是有一定局限的。是否采用这些方式进行开发还要视产品(或项目)的时间、质量、人员能力等诸多制约因素而定。个人推断国内的C程序员多普遍缺乏采用框架进行单元测试的意识,BDD或TDD的推广还是任重道远的。

偿还N年前的一笔技术债

记得刚来公司时曾参与过一个项目,项目中用到了部门基础库中的一个B+树接口。不过在程序调试过程中我们发现可执行程序总是dump core(在sparc solaris上),经初步分析,断定问题就出在B+树接口处,但一时又找不到问题原因。还好这个B+树的实现者就坐在我的旁边。他分析后告诉我:这个B+树接口要求用户自定义的索引结构体的size应该为4的整数倍。按照他的说法,我为结构体打了padding,以满足结构体size为4的整数倍的要求。修改后果然不再dump core了。当时项目进度紧,我也没有求甚解,这件事也就过去了。

一晃N年过去了。今天在做程序的64位移植过程中我再次遇到了这个问题。问题的表象就是程序运行时dump core,通过gdb或pstack查看core的内容,发现程序是在B+ Tree初始化时出的core。显然这又是一个内存违规访问的问题,且在Sparc上出现(x86 Linux上运行正常)十有八九与内存对齐有关。

B+ Tree出问题首先让我想到了N年前的那个解决方法。我先查看了自定义的索引结构体(usr_idx):

struct usr_idx {
    unsigned int usr;
};

不过sizeof(usr_idx)无论是32bit编译还是64bit编译,其值都是4。那按照B+树原作者的说法,这显然不足以让B+树出现问题。事实也的确如此,32bit编译的程序在Sparc Solaris下运行良好,只是目前改为了64bit编译,才dump core,那问题到底出现在哪呢?

到这里,我也只能从代码着手了,把N年前没弄清楚的原因找出来,顺便也把这个存在了N年的Bug彻底解决掉,把这笔技术债还了。pstack的输出告诉我问题出在一个名为bptree_create_node的函数中,嫌疑最大的一处代码大致是这样的:

for (i = 0; i rank; i++) {
    (elem_base(tree, tmp_bn, i))->key = key_base(tree, tmp_bn, i);
    (elem_base(tree, tmp_bn, i))->pointer = NULL;
}

直觉告诉我问题出在elem_base这个宏里,elem_base的定义如下:

#define elem_base(tree, eb, index) ((xx_bptree_elem*)((char *)&(eb)->e_base.mw_cp + ((SIZEOF_bptree_elem + (tree)->keysize))*(index)))

很显然这个定义最终是想得到一个xx_bptree_elem*类型的指针。从内存地址角度来说,我们会得到了一个内存地址,且这个地址被认为是一个xx_bptree_element元素的起始地址。那么是否所有地址作为xx_bptree_element元素的起始地址都合法呢?答案是不一定,至少在Sparc平台上不是所有地址都可以作为xx_bptree_elem的起始地址的。

那么什么样地址可以作为xx_bptree_element的起始地址呢?在Sparc上这取决于结构体的对齐系数。xx_bptree_elem结构的定义如下:

union mem_word {
    void  *mw_vp;
    void (*mw_fp)(void);
    char  *mw_cp;
    long   mw_l;
    double mw_d;
};
typedef union mem_word mem_word;
#define SIZEOF_mem_word (sizeof(mem_word))

struct xx_bptree_elem {
    void       *key;
    void       *pointer;
    mem_word   base;
};
typedef struct xx_bptree_item xx_bptree_item;
#define SIZEOF_bptree_elem        (sizeof(xx_bptree_elem)-sizeof(mem_word))

在32bit编译的情况下,系统默认对齐系数为4(参见/usr/include/sys/isa_defs.h中的宏_MAX_ALIGNMENT),则该结构体的对齐系数 = min(max(sizeof(key), sizeof(pointer), sizeof(base)), 4) = 4。这样xx_bptree_elem在32bit下的有效起始地址为可被4整除的内存地址。

而在用64bit编译时,系统默认的对齐系数为16(同参见isa_defs.h),但由于xx_bptree_elem中size最大的字段(base)的size为8,则结构体的对齐系数就等于8。即xx_bptree_elem元素的有效起始地址为可被8整除的地址。

好了,我们再回过头来看看elem_base宏在不同编译情况下能否总是返回合法的地址。

#define elem_base(tree, eb, index) ((xx_bptree_elem*)((char *)&(eb)->e_base.mw_cp + ((SIZEOF_bptree_elem + (tree)->keysize))*(index)))

这个宏中有三个元素决定返回地址,分别是"基址":&(eb)->e_base.mw_cp、偏移量SIZEOF_bptree_elem和(tree)->keysize。其中基址是另外一个结构体xx_bptree_node中一个mem_word类型字段的地址,你知道的,mem_word这种手法可以保证其起始地址严格按照其内部最大字段的对齐系数对齐的,也就是说mem_word的对齐系数与double的对齐系数一致,即无论是32bit编译还是64bit编译,其对齐系数都是8,也就是说我们可以确保这个”基址“是可以被8整除的;至于偏移量SIZEOF_bptree_elem,我们可以直接可以得出其大小:

32bit下,SIZEOF_bptree_elem = 8
64bit下,SIZEOF_bptree_elem = 16

可以看出无论是32bit还是64bit编译,SIZEOF_bptree_elem的值都是8的倍数;显然这两个值都不会影响elem_base最终返回地址的合法性。

现在剩下的就是(tree)->keysize了。keysize是由xx_bptree_init接口传进来的,它在上层实际上就是用户自定义的索引结构体的大小,显然这个大小不一定就是8的倍数。在我们的系统中,keysize = sizeof(usr_idx) =
4。这个keysize在32bit编译下是没有问题的,因为32bit编译只需要elem_base返回的地址可以被4整除即可,这也是为什么我们的程序在32bit编译下运行正常的原因。回想一下N年前的那个问题,其真正原因也就在这里:当时我定义的索引结构体的大小无法被4整除。在64bit编译下,keysize显然不能满足被8整除的要求,导致elem_base返回的地址只能被4整除。而xx_bptree_elem这个结构体的地址是严格要求必须可被8整除的。将一个只能被4整除而不能被8整除的地址强制转换为xx_bptree_elem元素地址并通过该强制类型转换后的地址访问xx_bptree_elem内部的元素显然就会导致core的出现了。

现在看来当初我的同事并未真正理解该B+ tree为何要求用户自定义结构体的大小必须为4的整数倍了,他只是通过现象得到了那条经验罢了,这笔技术债务也就从那时留了下来。

解决该问题并不难,作为基础库,我们无论如何都不应该依赖用户的自觉,我们在接口实现中增加一个转换就可以解决这一隐藏了若干年的Bug,将外面传入的keysize经align_word转换后再赋给tree->keysize,这样就可以保证elem_base始终返回合法的地址了。

突然想起了那句话:”出来混,总是要还的“,我们欠的技术债务也不例外。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats