标签 博客 下的文章

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的推广还是任重道远的。

行为驱动开发导引

本文翻译自Dan North的文章"Introducing BDD"。

我遇到了一个问题。当我在不同环境的多个项目中使用和教授类似测试驱动开发(test-driven development, TDD)这样的敏捷实践时,我总是能遇到来自程序员们相同的困惑和误解。他们想知道从哪里开始、测什么不测什么、一次测试多少、谁来调用他们的测试以及如何理解为什么一个测试失败了。

越是深入TDD,我越能感觉到我对TDD认知过程是时断时续、逐步掌握的,还远未进入到死胡同。我记得多数时间我想到的都是"这只是别人告诉我这样做的",而不是"哇,我明白为何要这样做了"。我断定一定可以通过某种方法将TDD直截了当地呈现给那些优秀的程序员们,并且可以避免所有陷阱。

我给出的答案是行为驱动开发(Behaviour-drive Development, BDD)。它从已有的敏捷实践演化而成,其设计目的是让敏捷实践对于采用敏捷软件交付的新团队来说变得更加容易理解和高效。随着时间推移,BDD已经发展为一种包含敏捷分析以及自动验收测试的敏捷实践。

测试方法名应该成句

我第一次发出"Aha!"是当看到我的同事Chris Stevenson开发的一款看似简单的名为agiledox的工具程序时。这个程序用于处理JUnit的测试类,并以普通句子的形式打印出方法名。其中的一个测试用例看起来像这样:

public class CustomerLookupTest extends TestCase {
    testFindsCustomerById() {
        …
    }
    testFailsForDuplicateCustomers() {
        …
    }
    …
}

结果是这样的:

CustomerLookup
- finds customer by id
- fails for duplicate customers
- …

"test"这个词被从类名和方法名中剥离出来,采用驼峰式命名方式(camel-case)的方法名被转换为普通的文本。这就是这个工具所做的一切,但是它产生的效果却是惊人的。

开发人员发现这至少可以为他们产生一些文档,所以他们开始编写使用真实句子作为名字的测试方法。更重要的是,当他们使用业务领域的语言作为方法名后,生成的文档对于商业用户、分析师以及测试人员变得同样有意义了。

一个让你专注于测试方法的简单句子模板

接下来我无意中发现了以单词"should"作为开头的测试方法命名手法。这个句子模板-"这个类应该(should)做某事

"-意思是你只能为当前类定义测试,这会让你保持专注。如果你发现自己编写了一个名字不符合该模板的测试,这表明这个行为很可能属于其他地方。

例如,我正在编写一个用于校验屏幕输入的类。大多字段都是常规的客户信息-名,姓氏等等,不过其中有一个字段用于输入出生日期,还有一个字段用来输入年龄信息。我开始编写一个ClientDetailsValidatorTest类,其中包含诸如testShouldFailForMissingSurname和testShouldFailForMissingTitle的测试方法.

接下来,我开始着手计算年龄,我的思维进入了一个充斥着繁琐业务规则的世界:如果客户同时提供的年龄和出生日期信息两者无法匹配该怎么处理?如果提供的出生日期是今天呢,又该如何处理?如果我只得到了出生日期,我应该如何计算年龄呢?为了描述这个行为,我正在编写的一些测试方法名字日益复杂,所以我考虑将其交给其他类去处理。这促使我引入了一个名为AgeCalculator的新类以及对应的AgeCalculatorTest。所有有关年龄计算的行为都放到calculator这个类中,这样validator类只需要一个有关年龄计算的测试例,并保证其与calculator类可以正确地交互。

如果一个类做了不止一件事情,这对我而言通常是一个提示:我应该引入其他类来分担一些工作了。我会将该新服务定义成一个可以描述它自身职责
的接口,并且将该服务通过类的构造函数传入:

public class ClientDetailsValidator {
 
    private final AgeCalculator ageCalc;
 
    public ClientDetailsValidator(AgeCalculator ageCalc) {
        this.ageCalc = ageCalc;
    }
}

这种将众多对象连接在一起的手法,即通常所说的依赖注入(dependency injection),在与mock机制一同使用时特别有用。

当测试失败时,一个表达良好的测试名字十分有用

不久,我就发现如果我修改代码后导致测试失败,我可以查看测试方法的名字并识别出这段代码预期的行为。通常发生的情况有下面三种:

* 我引入一个bug。都怪我。解决方法:修正这个bug。
* 预期行为仍然有意义,但已移至别处了。解决方法:将这个测试例移走,也许还要进行一些修改。
* 这个行为已经不再正确 – 系统的前提发生了改变。解决方法:删除这个测试。

在敏捷项目中随着你的理解的深入,最后一种情况很可能发生。不幸的是,TDD新手对删除测试例有着与生俱来的恐惧,就好像这样做会降低他们的代码质量似的。

在一个更微妙的方面上,与那些更加正式的单词will或shall相比,单词should的含义更加显而易见。Should隐式地允许你挑战测试例的前提:"它应该吗?果真是这样吗"。这样我们可以更加容易判断出测试失败到底是由于你引入的一个bug还是只是因为你之前对系统行为的假设已经不再正确。

与"测试(test)"相比,"行为(Behaviour)"是个更加有用的词

现在,我有一个工具 – agiledox – 用来删除单词"test",并且我拥有一个编写测试方法名的模板。我突然意识到人们关于TDD的误解几乎都归结到单词"test"上。

这并不是说测试不是TDD所固有的 -测试方法的结果集是一个保证你的代码可以正确工作的有效途径。但是,如果方法没有全面地描述你的系统的行为,那么它们会使你产生一种虚假的安全感。

在进行TDD时,我开始使用单词"behaviour"代替"test"。我发现这样做不仅合适,而且之前在TDD辅导时遇到的各类问题也都迎任而解。现在我已经有了这些问题的答案。什么来调用你的测试,这个问题变得很容易回答 – 我们在一个句子中调用你的测试,这个句子描述了你感兴趣的行为。测试多少用例才算充分 – 这取决于你在一个句子中可以描述多少行为。当测试失败时,我们可以简单地按照上面描述的过程解决- 要么你引入了一个bug,要么这个行为被移走了,或者是这个测试不再有意义了。

我发现这种从考虑测试到考虑行为的思维转变影响是如此巨大,以致于我开始把TDD称作为BDD,或行为驱动开发了。

与测试相比,JBehave更强调行为

在2003年末,我觉得是时候采取实际行动了。我开始编写一个名为JBehave的JUnit的替代品,它删除了代码中所有涉及测试的词汇,并替换为与验证行为相关的词汇。我这样做的目的就是为了看看如果我严格坚持我的行为驱动方法,这样一个框架将会如何演变。同时,我认为这也是一个有价值的教学工具,可以用来介绍TDD和BDD,同时可以避免大家分心于那些Test相关的词汇。

为了定义一个假想CustomerLookup类的行为,我编写了一个行为类,例如,CustomerLookupBehaviour。这个类包含以单词"should"开始的方法。行为运行器(behaviour runner)将实例化这个行为类,并依次调用每个行为方法,就像JUnit处理测试例的方式一样。它还会报告执行进度并在结束时输出一份总结。

我的第一个里程碑是使得JBehave做到自我验证。我只不过增加了一些行为,使得JBehave可以验证自己。我能够将所有JUnit测试例移植为JBehave行为并且可以像JUnit那样立即获取验证结果的反馈。

确定最重要的行为

接下来,我发现了商业价值的概念。当然了,我一直知道我编写软件的原因,但是我从未真正考虑过我现在所编写代码的价值。我的另外一个同事,业务分析师Chris Matts,促使我开始考虑在行为驱动开发背景下的商业价值。

假定我在头脑中已经有了使JBehave自托管的目标,我发现一个真正有用的保持专注的方法就是问:系统尚未
实现的最重要的特性是什么

这个问题需要你能识别出你尚未实现的特性的价值,并按优先级顺序对它们进行排序。它也可以帮助你制定这个行为方法的名字:系统尚未实现X(X是一个有意义的行为),X是重要的,这意味着系统应该实现X;所以你的下一个行为方法很简单:

public void shouldDoX() {
    // …
}

现在我有了另外一个TDD问题即"从何开始"的答案了。

需求也是行为

此时此刻,我拥有了一个框架,它可以帮助我理解,并且更重要的是解释TDD是如何工作的,并且还可以帮助我解释一种避免我遇到的所有陷阱的方法。

临近2004年年底,当我向Matts描述我新发现的、基于行为的词汇时,他说"但是这很像分析"。当我们讨论到这些时,我们停顿了很长时间,然后我们决定将这种行为驱动的思维方式应用于定义需求。如果我们可以为分析师、测试人员、开发人员以及业务开发出一致的词汇,那么我们就可以很好的消除技术人员和业务人员沟通过程中产生的一些岐义和错误传达。

BDD为分析提供了一种"通用语言(ubiquitous language)"

就在此期间,Eric Evans出版了他的畅销书《领域驱动设计》。在书中,他使用一种基于业务领域的通用语言描述了系统建模的概念,这使得商业词汇渗透到了代码库中。

Chris和我意识到我们正试图为分析过程本身
定义一种通用语言!我们拥有一个很好的起点。公司内部已经有了一个常用的故事模板,看起来类似这样:

作为(As a)
[X]
我要(I want)
[Y]
结果是(so that)
[Z]

这里Y是某个特性,Z是这个特性的价值或带来的益处,X是这个特性的受益人或角色。它的优点在于当你第一次定义需求故事时,它将迫使你识别交付这个故事的价值。当一个故事没有真正的商业价值,它常常可以归结为类似:"…我想要[某个特性],所以[我就去做,好吗?]"。这样可以更加容易地消减一些难懂的需求。

从这点触发,Matts和我开始着手了解每个敏捷测试人员已经知道了些什么:一个故事的行为仅仅是其验收标准 – 如果系统满足所有验收标准,它的行为就是正确的;相反,它的行为就是不正确的。所以我们创建了一个模板来捕捉一个故事的验收标准。

这个模板应该足够宽松,这样分析师们不会感觉到矫揉造作或受到约束。不过它也应该足够结构化,这样我们可以将故事分解成组成片断并自动生成它们。我们从场景(scenarios)的角度来描述验收标准,采用如下形式:

假定(Given)
一些初始上下文,
当(When)
一个事件发生,
那么(then)
要保证一些结果。

为了说明这一点,我们使用ATM机这个经典的例子。其中的一个故事卡可能看起来像这样:

+标题: 客户取现金+
作为(As)一个客户
我想(I want)从一台ATM机中取现金
结果(so that)是我不需要在银行中排队等候

那么我们怎么知道何时我们已经交付了这个故事呢?这里有几种场景要考虑:账户可能有盈余,账户可能被透支,但在透支额度以内,账户可能被透支且超出透支额度。当然,还有其他一些场景,注入如果账户有盈余,但是这次取款将使得账户透支,或如果自动取款机现金量不足。

使用given-when-then模板,头两个场景可能看起来是这样的:

+场景 1: 账户有盈余 +
假定账户有盈余
并且(And)卡片是有效的
并且取款机有现金
当(When)客户请求现金时
那么(Then)要保证这个账户被记入了贷方
并且(And)保证现金被取出
并且保证卡片被返还

注意,and用于以自然的方式连接多个givens(假定)或多个outcomes(结果)。

+场景 2: 账户透支超出额度限制+
假定账户被透支
并且卡片是有效的
当客户请求现金
那么要保证显示一条拒绝消息
并且保证现金没有被取出
并且保证卡片被返回

两个场景都是基于同样的events(事件),甚至有一些共同的givens和outcomes。我们要通过重用givens,events和outcomes充分利用这一点。

验收标准应该是可执行的

场景的片断-givens,events和outcomes-的粒度足够细,可以直接用代码表示。JBehave定义了一个对象模型,该模型允许我们直接将场景片断映射为Java类。

你编写一个类用于代表每个given:

public class AccountIsInCredit implements Given {
    public void setup(World world) {
        …
    }
}
public class CardIsValid implements Given {
    public void setup(World world) {
        …
    }
}

并且另外一个用于代表event:

public class CustomerRequestsCash implements Event {
    public void occurIn(World world) {
        …
    }
}

outcomes也是这样。然后JBehave将所有这些联系起来并且执行它们。它创造了一个"世界",只是用于存储你的对象,它将这个世界依次传递给每个givens,这样这些givens就可以用已知状态生存于这个世界中了。JBehave接下来告诉events出现在这个世界,它们实现了场景的实际行为。最后,它将控制权传递给我们为这个故事定义的任一一个outcome。

用一个类来表示每个片断使得我们可以在其他场景或故事中重用这些片断。起初,我们通过使用mock机制设置账户有盈余或者卡片有效来实现片断。这些形成了实现行为的起始点。当你实现应用时,你修改givens和outcome,使用你实现的实际类,这样直到场景完成为止,他们已经成为正确的端到端的功能测试。

BDD的现在和未来

经过一次简短的停顿后,JBahave回归到积极的开发。其核心已经相当完整和健壮了。下一步是将其与流行的Java IDE如IntelliJ IDEA和Eclipse集成在一起。

Dave Astels一直在积极推动BDD。他的博客以及各类发表的文章引发了一系列活动,最引人注目的是rspec项目,它是一个用Ruby语言实现的BDD框架。我已经开始开发rbehave,它将是一个用Ruby实现的JBehave。

我的许多同事都一直在现实世界中的各种项目中使用了BDD技术,并且发现这个技术非常成功。JBehave的故事runner – 校验验收标准的部分 – 正在积极的开发中。

我们的目标是拥有一个往返的编辑器,这样业务分析师和测试人员可以在一个普通的文本编辑器中捕获故事,同时这个编辑器还可以为行为类生成桩代码,所有这些都使用业务领域的语言描述。BDD的演化是与大家的帮助分不开的,我在这里十分感谢他们。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! 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