行为驱动开发(BDD)你准备好了吗?

其他 专栏收录该内容
9 篇文章 0 订阅

GitChat 作者:冰尘
原文:行为驱动开发(BDD)你准备好了吗?
关注微信公众号:「GitChat 技术杂谈」 一本正经的讲技术

【不要错过文末彩蛋】

enter image description here

这个Chat笔者将会和大家一起探讨下面的主题:

  1. 什么是行为驱动开发(BDD)?

  2. 为什么使用行为驱动开发(BDD)?

  3. 如何做行为驱动开发(BDD)?

  4. 遗留系统适合使用行为驱动开发(BDD)吗?

  5. 总结

一、什么是行为驱动开发(BDD)?

BDD,“Behavior Driven Development”的缩写,中文意思,行为驱动开发,BDD本质上是一种敏捷软件开发实践,它鼓励软件项目中的开发者、测试,用户,业务分析人员等之间相互协作。BDD最初是由Dan North在2003年命名,它包括验收测试和客户测试驱动等极限编程实践,是作为对测试驱动开发(TDD,Test drive development)的回应。在过去数年里,它得到了很大的发展。

为了加深大家对BDD概念的理解,咱们来看看《BDD in action》一书对BDD的定义和概括,

Behavior-Driven Development (BDD) is a set of software engineering practices
designed to help teams build and deliver more valuable, higher quality software faster. It draws on Agile and lean practices including, in particular, Test-Driven Development (TDD) and Domain-Driven Design (DDD). But most importantly, BDD provides a common language based on simple, structured sentences expressed in English (or in the native language of the stakeholders) that facilitate communication between project team members and business stakeholders.

翻译成中文的大概意思就是,行为驱动开发是一个软件工程的系列实践,能够帮助团队快速构建和交付更多价值和质量的软件产品。其和敏捷已经精益的开发实践,是一脉相承的,特别是测试驱动开发,已经领域驱动开发。但是最重要的是BDD提供了一种通用的,简单的,结构化的描述语言,这种语言既可以是英语也可以是其他本地的语言,通过他能够很方便让项目成员和业务干系人非常顺畅的沟通需求,及时这些干系人不懂的任何编程语言。

下面举1个简单易懂的栗子: 计算器的例子。

假设我们需要开发一个计算器,其 里面有一个加法的运算,那应该如何描述,才能让所有参与项目的人都能看懂呢?下面就是其中的一种写法。

enter image description here

上面这个例子,写的就是描述一个计算器加法的一个例子,是不是非常直观易懂,上面的文件,其实叫Feature(特性文件)。那为什么容易看懂呢? 因为其使用了Gherkin语法。那么Gherkin是什么呢? 其实,Gherkin语法就是使用 Given,when,then等关键字词来描述一个用户故事(User Story)。形成一份不论是客户,业务分析人员,测试,还是开发,都能读懂的文件格式。具体定义和用法请大家参考这个链接

需要说明的是,请大家注意左边的红色的关键字,Feature,Scenario,Given,When,And,Then; 这些关键字其实就是Gherkin语法定义的标准关键字,其主要的关键字如下,

  • Feature

  • Background

  • Scenario

  • Given

  • When

  • Then

  • And

  • But

    • *
  • Scenario Outline

  • Examples

上面的关键字中有一个examples的关键字,这个关键字非常的好,不知道大家发现没有,人们在日常交流的过程中,有的时候,为了让大家对某一件比较复杂或者难以理解的,或者容易产生歧义的事情,喜欢举个例子,Gherkin也不例外,为了让大家在使用行为驱动开发的过程,相互协作的各个团队之间,更好的理解需求,举个例子是一个非常好的方式。下面咱们可以看一个使用Gherkin中的examples的例子,

故事上下文如下:

虽然现在已经进入了无现金交易的时代,但是我们有的时候还是需要去银行取点现金,那我们就看一个取钱的例子吧。

enter image description here

上面的一个基于BDD的特性文件(Feature)中,明显的用到了Examples关键字,举了3个例子(三行),比如第一行,当前账号余额为500美元,取了50美元,我们收到了50美元,账号还剩下450美元。

这个时候,可能有读者会问,如果我们公司是国内的公司,没有英语的环境,项目经理,客户,产品经理的英语都不太好,那我用英语写的特性文件(Feature),他们都能看懂吗?,是不是还要边看边查字典啊??? 哈哈,没有关系,Gherkin语法是支持国际化的,下面是他们的一个关键字对照表。

英文关键字中文关键字
feature“功能”
background“背景”
scenario“场景”, “剧本”
scenario_outline“场景大纲”, “剧本大纲”
examples“例子”
given“假如”, “假设”, “假定”
when“当”
then“那么”
and“而且”, “并且”, “同时”
but“但是”

我们可以直接把中文写在Feature文件中,处理和解析就交给第三方的框架的吧,比如Cucumber。什么? Cucumber,是什么鬼东西,我查查字典,原来是“黄瓜”的意思。 其实,亲们, Cucumber就是实现行为驱动开发的一个开源框架而已,其中支持BDD的框架,除了Cucumber,还有Spec,Spock等等等开源项目,在后面分享到的如何实现BDD的章节中,我会为大家进一步介绍Cucumber。

二、为什么使用行为驱动开发(BDD)?

如果读者已经有过软件开发工作经验的话,应该能很快看懂传统需求挖掘,分发和使用流程,一般情况下,应该是下面的样子。

enter image description here

那么,通过这张图,我想您一定能立马发现,所有的需求流动和维护都是单方向的,而我们知道,软件的需求其实就是软件的目标,就是我们应该交付的产品,是我们应该要做的正确的事情。而对于用户的需求而言,有的时候其实是很复杂的,有的时候客户在提出某一想法的时候,其实压根自己也不知道最终需要一个什么产品,只是大概模糊的知道需要实现一个功能,而且客户的想法和最终实现这个产品的开发人员做出来的东西最终肯能会不太一样,因为开发人员可能已经开始根据最初的需求文档已经把代码实现了,QA也把测试用例写好了,但是QA根据需求文档写出的测试用例和开发人员开发出来的产品的可能根本匹配不上,好多的工作就这样白白浪费了,于是团队成员抱怨了。

另外,谁有能保证业务人员把需求文档写出来后,没有歪曲和误解商务人员告诉给他的需求和想法,开发人员能通过文档把业务分析人员写的东西全部理解透吗?有的时候业务需求文档,真的不是特别的有趣,没有例子,比较抽象,有歧义。怎么办?怎么办?怎么办?重要话说三遍,那有没有一种媒介,可以让大家及时的,基于同一个平台的交流,而且用于交流的媒介,对于需求的描述也非常的生动,会根据以后软件的行为进行分类,并提供一些生动的例子呢? 下面我们看看BDD会如何做。

enter image description here

通过对比,大家是不是发现BDD的这种方式,把客户,业务分析人员,开发人员,测试人员,文档工程师,通过特性文件(Feature File)真正的联系在一起了,其沟通是顺畅的,QA,BA,开发,测试,客户,用户可以通过这一媒介,进行高效无障碍的沟通,而不是像传统的方式,通过BA进行二次转达,从而丢失了很多重要的需求。 由此可见,其BDD的好处如下:

  • 减少浪费

  • 节省成本

  • 容易并且安全的适应变化

  • 因为少了中间的转达环节,从而能够快速交付产品

三、如何做行为驱动开发(BDD)?

enter image description here

从上图可以看出,当一个需求过来的时候,先通过项目干系人都能理解的Feature文件,描述项目的User Story, 有的里面还有详细生动的数据范例(examples),从而能够让所有的人更加容易理解其需求, 比如,

enter image description here

不得不说,通过上面的数据范例(examples)的表格是不是更加容易的理解当前case的意图了。当Feature和Example文件都完成后,借助于第三方的开源框架实现,比如Cucumber,jBehave,SpecFlow等把Feature和Example转换成代码,然后通过低层次的单元测试框架,比如JUnit,NUnit,Spock,RSpec,结合测试驱动开发(TDD),从而把业务代码的逻辑实现。

下面,笔者就以Cucumber和JUnit为例,举一个BDD的例子吧。大家对JUnit比较熟悉,但是对Cucumber可能会相对陌生一点,笔者就花一点笔墨,简单介绍了一下Cucumber。

Cucumer是一个实现了BDD的一个框架,其支持下面的语言和框架集成,
Cucumer简直要逆天了,基本上所有的主流语言都支持,而且还能和市面上一些流行框架相结合,比如自动化测试框架,Selenium; Ruby的超级牛逼的Web开发框架Ruby On Rails等等。

enter image description here

是不是感觉很强大啊!!!! 下面进入实战。咱们以Java代码为例子,
结合Cucumber,JUnit以及Maven给大家演示。

3.1 建立一个Maven项目并添加Cucumber依赖库

首先,我们建立一个Maven的项目,名字就叫BDDKata,为什么叫这个名字呢?

因为针对某一种特定技术或技能进行重复性的练习,从而将其熟练掌握,这在编程领域常被人称为“编码套路”(Code Kata)。Code Kata的概念是由David Thomas提出的,他是《程序员修炼之道:从小工到专家》的作者之一,大家如果感兴趣的话可以买这本书看看,非常经典的一本书。因为当前的例子也是针对BDD的一个简单的练习,所以我也取名就做BDDKata。

既然是基于Java Cucumber 的类库去实现BDD,那么我们首先要把Cucumber相关的jar通过Maven的依赖(Dependency)加入进来,

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.icedust.bdd</groupId>
  <artifactId>bddKata</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>bddKata</name>
  <description>bddKata</description>
  <properties>
        <cucumber.version>1.2.0</cucumber.version>
        <junit.version>4.11</junit.version>
        <picocontainer.version>2.14.2</picocontainer.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>info.cukes</groupId>
            <artifactId>cucumber-junit</artifactId>
            <version>${cucumber.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>info.cukes</groupId>
            <artifactId>cucumber-picocontainer</artifactId>
            <version>${cucumber.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.picocontainer</groupId>
            <artifactId>picocontainer</artifactId>
            <version>${picocontainer.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

3.2 安装Cucumber Eclipse插件

为了支持Feature的Gherkin语法,我们需要在Eclipse开发环境里面安装下面的插件:

https://cucumber.io/cucumber-eclipse/update-site

具体安装方法,请到百度或者google搜索。

3.3 新建一个Feature文件,编一个需求

为了简单起见,我们选择一个买服装的一个场景,下面是根据和业务人员调研后,业务分析人员得到的一个服装店收银台的一个例子,

Feature: Checkout on Shopping
  Scenario Outline: Checkout Shirt 
    Given the price of a "Shirt" is 200RMB 
    When I checkout <count> "Shirt"
    Then the total price should be 400RMB

    Examples:
    | count | total   |     
    | 1     | 200     | 
    | 2     | 400     |

  Scenario: Two Shirt scanned separately 
    Given the price of a "Shirt" is 300RMB 
    When I checkout 1 "Shirt"
    And I checkout 1 "Shirt"
    Then the total price should be 600RMB

  Scenario: A Shirt and an Shoes
    Given the price of a "Shirt" is 200RMB
    And the price of a "Shoes" is 300RMB 
    When I checkout 1 "Shirt"
    And I checkout 1 "Shoes"
    Then the total price should be 500RMB

里面总共有3个场景(Scenario),

第1个场景:

假设一件衬衣的价格是200,买了 n件,其价格应该是多少钱?

第2个场景:

买了两件衬衣,总共是多少钱?

第3个场景:

买了一件衬衣和一双鞋是多少钱?

3.4 运行Feature文件,生成Cucumber的步骤(Steps)代码

当我们选中这个Feature文件(Checkout.feature)文件的时候,我们运行Cucumber Feature的时候,如下图。

enter image description here

其部分输出如下:

4 Scenarios (4 undefined)
15 Steps (15 undefined)
0m0.000s


You can implement missing steps with the snippets below:

@Given("^the price of a \"(.*?)\" is (\\d+)RMB$")
public void the_price_of_a_is_RMB(String arg1, int arg2) throws Throwable {
    // Write code here that turns the phrase above into concrete actions
    throw new PendingException();
}

@When("^I checkout (\\d+) \"(.*?)\"$")
public void i_checkout(int arg1, String arg2) throws Throwable {
    // Write code here that turns the phrase above into concrete actions
    throw new PendingException();
}

@Then("^the total price should be (\\d+)RMB$")
public void the_total_price_should_be_RMB(int arg1) throws Throwable {
    // Write code here that turns the phrase above into concrete actions
    throw new PendingException();
}

上面的代码提示我们有4个场景,15个步骤没有定义。

那么明明上面笔者说了只有3个场景,那为什么提示的时候,是4个场景呢?

原来,第一个场景描述里面有一个Examples,里面举了2个例子,在加上后面的2个所以是4个。15个
步骤,指的是Gherkin关键字所对应的语句(Given,When,And,Then 等等),注意带有Examples的第一个场景,因为有2个example,所以要乘以2。

根据提示,我们把胶水代码从上面的控制台输出拷贝下面,并新建一个java类:CheckoutSteps ,文件名字为CheckoutSteps.java,内容如下:

package com.icedust.bddkata;
import cucumber.api.java.en.*;
import cucumber.api.PendingException;

public class CheckoutSteps {
    @Given("^the price of a \"(.*?)\" is (\\d+)RMB$")
    public void the_price_of_a_is_RMB(String arg1, int arg2) throws Throwable {
        // Write code here that turns the phrase above into concrete actions
        throw new PendingException();
    }
    @When("^I checkout (\\d+) \"(.*?)\"$")
    public void i_checkout(int arg1, String arg2) throws Throwable {
        // Write code here that turns the phrase above into concrete actions
        throw new PendingException();
    }

    @Then("^the total price should be (\\d+)RMB$")
    public void the_total_price_should_be_RMB(int arg1) throws Throwable {
        // Write code here that turns the phrase above into concrete actions
        throw new PendingException();
    }

}

3.5 在步骤代码里面加上JUnit的断言并根据断言驱动业务实现

根据BDD的开发的原则,先写BDD的步骤代码(Steps)和单元测试代码,然后再写实现代码,因为实现代码还没有写,所以先写的骤代码(Steps)和单元测试代码肯定运行失败,但是没有关系,这个时候我们就可以写业务实现代码了,然后让单元测试通过,一旦单元测试通过,我们就可以对代码进行重构,上面的步骤简称RGB(红绿蓝),具体含义大家请参考这篇文章

3.5.1 修改步骤代码并编写单元测试

在根据Feature文件生成的步骤代码中(Steps)中,写上业务实现的类的对象和以及其方法,通过Cucumber中定义的Steps(带有When,Given,then , And的关键字),获取Feature文件里面的数据,最后写上单元测试的断言,具体代码如下

enter image description here

注意: 因为业务实现代码暂时还没有实现,比如Checkout类,以及Checkout类的中add() 方法,所以Eclipse开发环境出现编译异常,不要紧,因为这是BDD&TDD的一个必经的步骤。

3.5.2 编写业务实现

下面咱们通BDD的测试用例来驱动业务代码的开发,驱动出来的业务代码如下:

package com.icedust.bddkata;
public class Checkout {
    private int runningTotal = 0;

    public void add(int count, int price) { 
        runningTotal += (count * price);
    }

    public int total() { 
        return runningTotal;
    }
}

3.5.3 重新运行测试

这个时候,我们发现,CheckoutSteps类里面的异常消失了。

那么如何自动运行单元CheckoutSteps定义的Step以及其中的单元测试呢?

这个时候,我就需要加入一个启动BDD的Step和单元测试的入口类: RunBDDTest,如下所示意。

package com.icedust.bddkata;

import cucumber.api.junit.Cucumber;
import cucumber.api.CucumberOptions;
import cucumber.api.SnippetType;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@CucumberOptions(plugin="pretty", snippets=SnippetType.CAMELCASE)
public class RunBDDTest {

}

3.5.4 运行测试类:RunBDDTest 并输出结果

选中RunBDDTest的类并运行单元测试,则在控制台会出现下面的输出。

从最后一句话我们可以得知,4个场景,15个步骤都运行成功了。

Feature: Checkout on Shopping

  Scenario Outline: Checkout Shirt            [90m# com/icedust/bddkata/checkout.feature:3[0m
    [36mGiven [0m[36mthe price of a "Shirt" is 200RMB[0m
    [36mWhen [0m[36mI checkout <count> "Shirt"[0m
    [36mThen [0m[36mthe total price should be <total>RMB[0m

    Examples: 

  Scenario Outline: Checkout Shirt         [90m# com/icedust/bddkata/checkout.feature:10[0m
    [32mGiven [0m[32mthe price of a "[0m[32m[1mShirt[0m[32m" is [0m[32m[1m200[0m[32mRMB[0m [90m# CheckoutSteps.the_price_of_a_is_RMB(String,int)[0m
    [32mWhen [0m[32mI checkout [0m[32m[1m1[0m[32m "[0m[32m[1mShirt[0m[32m"[0m              [90m# CheckoutSteps.i_checkout(int,String)[0m
    [32mThen [0m[32mthe total price should be [0m[32m[1m200[0m[32mRMB[0m  [90m# CheckoutSteps.the_total_price_should_be_RMB(int)[0m

  Scenario Outline: Checkout Shirt         [90m# com/icedust/bddkata/checkout.feature:11[0m
    [32mGiven [0m[32mthe price of a "[0m[32m[1mShirt[0m[32m" is [0m[32m[1m200[0m[32mRMB[0m [90m# CheckoutSteps.the_price_of_a_is_RMB(String,int)[0m
    [32mWhen [0m[32mI checkout [0m[32m[1m2[0m[32m "[0m[32m[1mShirt[0m[32m"[0m              [90m# CheckoutSteps.i_checkout(int,String)[0m
    [32mThen [0m[32mthe total price should be [0m[32m[1m400[0m[32mRMB[0m  [90m# CheckoutSteps.the_total_price_should_be_RMB(int)[0m

  Scenario: Two Shirt scanned separately   [90m# com/icedust/bddkata/checkout.feature:13[0m
    [32mGiven [0m[32mthe price of a "[0m[32m[1mShirt[0m[32m" is [0m[32m[1m300[0m[32mRMB[0m [90m# CheckoutSteps.the_price_of_a_is_RMB(String,int)[0m
    [32mWhen [0m[32mI checkout [0m[32m[1m1[0m[32m "[0m[32m[1mShirt[0m[32m"[0m              [90m# CheckoutSteps.i_checkout(int,String)[0m
    [32mAnd [0m[32mI checkout [0m[32m[1m1[0m[32m "[0m[32m[1mShirt[0m[32m"[0m               [90m# CheckoutSteps.i_checkout(int,String)[0m
    [32mThen [0m[32mthe total price should be [0m[32m[1m600[0m[32mRMB[0m  [90m# CheckoutSteps.the_total_price_should_be_RMB(int)[0m

  Scenario: A Shirt and an Shoes           [90m# com/icedust/bddkata/checkout.feature:19[0m
    [32mGiven [0m[32mthe price of a "[0m[32m[1mShirt[0m[32m" is [0m[32m[1m200[0m[32mRMB[0m [90m# CheckoutSteps.the_price_of_a_is_RMB(String,int)[0m
    [32mAnd [0m[32mthe price of a "[0m[32m[1mShoes[0m[32m" is [0m[32m[1m300[0m[32mRMB[0m   [90m# CheckoutSteps.the_price_of_a_is_RMB(String,int)[0m
    [32mWhen [0m[32mI checkout [0m[32m[1m1[0m[32m "[0m[32m[1mShirt[0m[32m"[0m              [90m# CheckoutSteps.i_checkout(int,String)[0m
    [32mAnd [0m[32mI checkout [0m[32m[1m1[0m[32m "[0m[32m[1mShoes[0m[32m"[0m               [90m# CheckoutSteps.i_checkout(int,String)[0m
    [32mThen [0m[32mthe total price should be [0m[32m[1m500[0m[32mRMB[0m  [90m# CheckoutSteps.the_total_price_should_be_RMB(int)[0m

4 Scenarios ([32m4 passed[0m)
15 Steps ([32m15 passed[0m)
0m0.099s

3.5.5 重构代码

运行成功后,可以重构代码,重构的代码不仅仅只包括重构业务实现代码,还包括重构BDD的测试代码。重构代码,100个人可能就有100种重构的方式,具体如何重构代码,这又是另外一个话题,读者可以看Martin Fowler 著写的《重构 改善既有代码的设计》以及Robert C. Martin写的《代码整洁之道 程序员的职业素养》,笔者就不在赘述。感兴趣的读者,可以自行重构。

上面的整个流程其实可以用下面的2张图,完美描述。

enter image description here

上面的例子,列举的是一个新系统的中使用BDD的例子,那么对于遗留系统该如何BDD呢?

四、复杂遗留系统适合使用行为驱动开发(BDD)吗?

笔者去年有一段时间,特别针对了这个问题进行了研究,因为遗留系统,一般年限很长,用到的技术多种多样。而且有的部分代码有单元测试,有的部分的代码没有单元测试。有的单元测试,一看就知道不是先写测试再写业务实现,而是先写业务实现,再写单元测试的,且单元测试都是补上去的。

针对这种情况,笔者认为,如果要把一个大型的,维护了10几年的遗留项目,彻底推翻,使用 BDD和TDD进行重写是不现实的,而且随着复杂性的提高,其成本也是很高的。除非有特别的理由,否则很难得到领导的同意。那这个时候,我们该怎么办?难道BDD在复杂的遗留系统里面就用不上了吗?

其实,非也,冰冻三尺,非一日之寒,下面是笔者的一些个人建议,仅供参考,不喜勿喷。

首先要给大家灌输BDD和TDD的好处。教会大家如何做BDD和TDD。因为在遗留系统中有的时候,会添加一些新的特性,这个时候,可以在不动别的功能特性的基础上,尝试对新添加的功能使用BDD和TDD。

根据笔者观察,因为复杂的遗留系统,很多代码很难测试,而且还有很多私有方法,静态方法,有final修饰的,很难写单元测试的。这个时候,其实瓶颈就在如何Mock这些复杂的上下文场景。推荐大家使用PowerMock。

如果有时间的话,先从重构现有的单元测试的用例开始,只要单元测试的用例好维护,好扩展了,大家才能有动力和心情,继续写更多更好的单元测试,从而培养大家先写单元测试的感觉。

如果您有更好的建议和方案,也可以在笔者的这片文章下面留言,笔者将会把其加入到本文中来。

五、总结

感谢大家看完了本文,通过本文,笔者给大家分享了什么是BDD,为什么要做BDD,如何来做BDD,最后探讨了如何做BDD已经在复杂的遗留系统上应该使用什么策略来做BDD。 其实,我们知道在软件开发或者维护过程中,基本的主流角色有,开发,测试,客户,用户,项目经理,运维人员等。 而在生产和开发一个软件的过程中,处处充满风险,这个时候,从宏观角度来说,做正确的事是最重要的;从微观角度来说,正确的做事也很重要。其中,做正确的事是最最重要的,如果大家看过玩过传递猜词游戏的话,应该知道,第一个人看到一个正确的词语,然后用动作表达出来,然第二个人猜意思,然后再用动作表达给第三个人。。。。。 往往到了最后,意思可能大相径庭。

enter image description here

在软件开发的需求分析和实现阶段又何尝不是这样呢?本来客户需要的是一辆自行车,结果却可能得到一辆摩托车。

enter image description here

这一切的一切都是沟通惹的祸,其实就是没有做正确且被期望的事情。而BDD(行为驱动开发), 就是为了解决这个问题。我们知道,BDD就是先写需求和功能点描述,这种描述客户,经理,开发,测试都能看懂,然后根据这些特性文件,列出一个个生动形象的场景,即使没有写过任何代码的人都能看懂,而且是基于文本的。开发人员把这些特性文件转换成具体的测试用例并驱动业务实现,目的说白了,就是让开发出来的系统正是客户所需要的,从而保证了做正确的事情,正如上面的图所示,客户需要的是自行车,就真正开发出来自行车,而不是开发出摩托车。

参考资料

《BDD In Action》

《The Cucumber for Java Book(Pragmatic,2015)》

【GitChat达人课】

  1. 前端恶棍 · 大漠穷秋 :《Angular 初学者快速上手教程
  2. Python 中文社区联合创始人 · Zoom.Quiet :《GitQ: GitHub 入味儿
  3. 前端颜值担当 · 余博伦:《如何从零学习 React 技术栈
  4. GA 最早期使用者 · GordonChoi:《GA 电商数据分析实践课
  5. 技术总监及合伙人 · 杨彪:《Gradle 从入门到实战
  6. 混元霹雳手 · 江湖前端:《Vue 组件通信全揭秘
  7. 知名互联网公司安卓工程师 · 张拭心:《安卓工程师跳槽面试全指南

这里写图片描述

  • 1
    点赞
  • 2
    评论
  • 3
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

评论2
请先登录 后发表评论~
©️2021 CSDN 皮肤主题: 技术工厂 设计师:CSDN官方博客 返回首页

打赏作者

软件供应链

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值