标签 Java 下的文章

开始学Go

本文翻译自Dr.Dobb's的"Getting Going with Go"。

本文是有关Google新的系统原生语言的五周教程的第一部分,这里将先向大家展示如何建立Go语言开发环境以及构建程序,然后带领大家浏览 一些代码范例来着重了解一下这门语言的一些有趣的特性。

这个教程系列将连续刊登五周。在今天这一部分中,Go语言专家Mark Summerfield将讲解如何建立Go语言开发环境,提供两个Go语言范例并给予深度解析。这些样例程序会向大家局部地展示了Go语言的一些关键特性 以及包。接下来几周将展示其余的关键特性,并特别为C、C++和Java程序员们深入研究那些Go语言独有的特性。

正如本周主编文章中所解释的那样,Go语言拥有许多独一无二的特性,因此它也许可以被称为二十一世纪的C语言。而且考虑到Ken Thompson也是该语言的设计者之一,这两种语言的确是有共同的祖先。

开始

Go是编译型语言,而不是解释型的。Go的编译速度非常快– 甚至远远快过其他同类语言- 知名的如C和C++。

标准Go语言编译器被称为gc,与其相关的工具链包括用于编译的5g、6g和8g;用于链接的5l、6l和8l以及用于查看Go语言文档的 godoc(在Windows平台上这些程序为5g.exe、6g.exe等等诸如此类)。这些奇怪的名字遵循了Plan 9操作系统编译器的命名方式,即用数字表示处理器体系("5"代表ARM,"6"代表AMD64-包括Intel 64位处理器- "8"代表Intel 386)。幸运的是,我们无需对此产生忧虑,Go语言提供了一个更高级别的Go语言构建工具,这个工具可以为我们处理编译和链接任务。

本文中的所有代码使用的都是Go 1版本语法,并在Linux、Mac OS X以及Windows上用gc测试通过了。Go语言的开发者计划让随后所有Go 1.x版本支持Go 1向后兼容,因此这里的代码和例子将适用于所有1.x系列版本。

要下载和安装Go,请访问golang.org/doc/install.html,那里提供了下载链接与安装指令。Go 1为FreeBSD 7+、Linux 2.6+、Mac OS X (Snow Leopard和Lion)以及Windows 2000+提供源码包以及二进制形式安装包,可以支持所有Intel 32位和AMD 64位处理器体系。Go还支持ARM处理器版本的Linux,为Ubuntu Linux发布版提供预建go包。当你读到这里时,也许已经有其他Linux发布版的Go安装包了。

使用gc编译器的程序使用了一种特定的调用惯例(call convention)。这意味着使用gc编译的程序只可以与使用同样调用惯例编译的外部库进行链接 – 除非使用某适合的工具消除这些差异。使用cgo工具Go可以支持在Go程序中使用外部C代码,并且至少在Linux和BSD系统上,通过SWIG工具我们 可以将C和C++代码用于Go程序中。

除了gc,还有一种编译器称为gccgo。它是Gcc的一个Go特定前端,Gcc 4.6及以后版本才能支持。像gc一样,gccgo也许内建在一些Linux发行版中。构建和安装gccgo的指令在Go主站点上可以找到。

Go文档

Go的官方站点上维护着一份最新的Go文档。"Packages"链接提供了有关所有Go标准库包的访问方式- 以及它们的源码,这些源码在文档还很稀缺时十分有用。通过"Commands"链接你可以找到与Go一起发布的相关程序的文档(诸如编译器,构建工具 等)。通过"Specification"链接,你可以找到一份非正式,但很全面的Go语言规范。通过"Effective Go"链接,你可以找到一份介绍Go最佳实践的文档。

该站点还提供了一个沙箱特性,用于在线编写、编译以及运行Go小程序(稍有限制)。这个特性十分有用,便于初学者试验一些古怪的语法。Go站点上 的搜索框只能用于在Go文档中搜索;如果要对Go的资源进行全面搜索,请访问http://go-lang.cat-v.org/go- search。

Go的文档也可以在本地浏览,例如在Web浏览器中。如果要这样做,可运行Go的godoc工具,并通过传入命令行参数告知它以一个web服务器 的方式运行。下面是在一个Unix或Windows控制台中进行这个操作的方法,假设PATH环境变量中已经设置了godoc:

$ godoc -http=:8000

这个例子中的端口号可以是任意的- 如果它与一个已存在的服务器端口冲突,可以使用其他任一个端口号。

要想查看文档,打开一个浏览器,输入地址http://localhost:8000。一个类似golang.org首页的页面将会呈现在你的面 前。"Package"链接将指向Go标准库以及安装在GOROOT环境变量下的第三方包的文档。如果你定义了GOPATH环境变量(比如,为本 地程序和包),一个链接将会出现在"Packages"链接旁,通过这个链接你可以访问其他相关文档。(本文后续会讨论GOROOT和 GOPATH环境变量)

编辑,编译和运行

Go程序用UTF-8编码的普通的Unicode文本编写。绝大多数现代文本编辑器都可以自动处理这些代码,并且一些最流行的编辑器可以支持Go 源码的语法色彩高亮以及自动缩进。如果你的编辑器不支持Go,可以尝试在Go搜索引擎中输入你的编辑器的名字,查看一下是否有合适的插件。作为编 辑惯例,Go所有的关键字和操作符都使用ASCII字母;然而,Go的标识符可以以任意Unicode字母作为起始,后面可以跟着任意 Unicode字母或数字,因此Go开发者可以自由使用他们的母语。

为了掌握如何编辑、编译以及运行一个Go程序,我开始会用经典的"Hello World"程序作为例子- 然而我编写这个程序比寻常的稍复杂些。

如果你已经用二进制包或通过源码安装了Go,并且是以root或管理员权限安装的,你应该至少设置一个环境变量:GOROOT,该变量指示Go的 安装路径信息,你的PATH变量现在应该包含$GOROOT/bin或%GOROOT%\bin。为了检查Go安装地是否正确,可在控制台下输入 下面命令:

$ go version

如果你得到"command not found"或"'go' is not recognized…"的错误信息,那就意味着在PATH变量配置的路径下没有Go。

编译与链接

构建一个Go程序需要两步:编译和链接。(由于我们假设使用gc编译器,使用gccgo编译器的读者需要遵循golang.org/doc /gccgo_install.html中描述的编译和链接过程,同样,使用其他编译器的读者需要根据其编译器的指令进行编译和链接)。编译和链 接过程都由工具go处理,它不仅可以构建本地程序和包,还能够获取、构建以及安装第三方程序和包。

要想go能够编译本地程序和包,有三个要求。第一,Go的bin目录($GOROOT/bin或%GOROOT%\bin)必须在PATH环境变 量下。第二,必须存在一个目录,该目录下包含一个src目录,本地程序和包的源码就驻留在src目录下。例如,例子代码会解包到goeg/src /hello、goeg/src/bigdigits下等。最后,包含src的那个目录必须在GOPATH环境变量中设置。例如,要使用go工具 构建hello这个例子,我们必须这么做:

$ export GOPATH=$HOME/goeg
$ cd $GOPATH/src/hello
$ go build

两个例子中,我们都假设PATH环境变量中包含了$GOROOT/bin或%GOROOT%\bin。一旦go编译程序完毕,我们就可以运行这个 程序了。默认情况下,go会用可执行文件所在目录的名字来命名该文件(例如,在类Unix系统上是hello,而在Windows上为 hello.exe)。一旦构建完毕,我们就可以按常规方式运行它。

$ ./hello
Hello World!

注意,我们不需要编译或显式地链接其他包(即便后续我们将看到,hello.go使用了三个标准库的包)。这也是Go程序编译如此之快的一个原 因。

如果我们有多个Go程序,若他们的可执行文件能够放在同一个目录下将会非常方便,后续只需将这个目录加入到PATH环境变量中。幸运地是go支持 这样的情况:

$ export  GOPATH=$HOME/goeg
$ cd  $GOPATH/src/hello
$ go install

go install命令除了做了go build所做的事情之外,还将可执行文件放在标准位置($GOPATH/bin或%GOPATH%\bin)。这意味着将一个单一路径($GOPATH /bin或%GOPATH>%\bin)加入到PATH环境变量中,我们安装的所有Go程序就可以方便地被加入到PATH中。

除了这里给出的例子外,我们很可能想要在我们自己的目录下开发我们自己的Go程序和包。通过为GOPATH环境变量设置两个(或更多)冒号分隔的 路径(在Windows上用分号分隔)我们可以很容易解决这个问题。

虽然Go使用go工具作为标准构建工具,但你仍然可以使用make或其他现代构建工具,或使用其他Go专用的构建工具,或一些流行IDE的插件。

和谁打招呼(Hello)?

既然我们已经看到了如何构建一个Hello程序,接下来我们来看看其源代码。下面是hello程序的完整源码(在文件 hello/hello.go中):

// hello.go
package main
import (
    "fmt"
    "os"
    "strings"
)
func main() {
    who := "World!"
    if len(os.Args) > 1 { /* os.Args[0] is "hello" or "hello.exe" */
        who = strings.Join(os.Args[1:], " ")
    }
    fmt.Println("Hello", who)
}

Go用C++风格的注释符号//作为单行注释,用/* … */作为多行注释符号。依照惯例,Go中多使用单行注释,多行注释常用于在开发中注释掉代码块。

每段Go代码都存在于一个包内,并且每个Go程序必须具有一个包含main()函数的main包,其中main函数会作为程序执行的入口点,即这 个函数首先执行。事实上,Go包也可以定义在main函数之前执行的init函数。值得注意的是包名和函数名之间不会存在冲突的情况。

Go的操作是以包为单位的,而不是文件。这意味着我们可以根据需要任意地将一个包拆分到多个文件中。如果多个文件具有相同的包声明,Go语言认为 这些文件都是同一个包的组成部分,与所有内容在单一文件中无异。当然,我们也可以将应用的功能分解到许多本地包中,这样可以保持代码整洁地模块 化。

import语句从标准库导入三个包。fmt包提供格式化文本以及读取格式化文本的函数;os包提供平台无关的操作系统变量以及函 数;strings包提供操作字符串的函数。

Go的基本类型支持普通操作符(例如,+可用于数值加法以及字符串连接),Go的标准库通过提供操作基本类型的函数包补充功能,例如这里导入的 strings包。我们可以在基本类型的基础上创建我们自定义的类型并为它们定义相关方法- 自定义操作特性类型的函数。

读者也许已经注意到了Go源码中没有分号、import的包无需逗号分隔以及if条件语句不需要括号。在Go中,块(block),包括函数体以 及控制结构体(如for、if语句以及for循环),使用括号界定。缩进只是单纯用于提高代码的可读性。技术上而言,Go语句是用分号分隔的,但 这些分号由编译器插入,我们自己无需关心,除非我们要将多个语句放在同一行中时。没有分号、很少的逗号以及括号让Go程序看起来更简洁,需要的输 入也更少。

Go使用func关键字定义函数(function)和方法(method)。main包的main()函数总是具有相同的函数签名 – 没有参数、没有返回值。当main.main()结束时,程序将终止并返回0给操作系统。当然,我们可以在任意时刻返回并选择我们自己的返回值。

main()函数中的第一个语句(使用:=操作符)在Go的术语里被称为一个短变量声明。这个语句在同一时间声明并初始化一个变量。此外,我们无 需指定变量的类型,因为Go可根据初始值推导出变量的类型。因此在这个例子中,我们声明了一个string类型的变量who,感谢Go的强类型机 制,我们只需将字符串赋值给who即可。

os.Args变量是字符串的一个slice(片段)。Go使用array(数组)、slice和其他集合数据类型,但在这些例子中,知道下面这 些即可:使用内置的len()函数获取一个slice的长度以及通过[]下标操作符访问其中的元素。特别是,slice[n]返回slice的第 n个元素(从0起始),slice[:n]返回另外一个slice,这个新slice由原slice的第n个到最后一个之间的元素组成。在集合一 章,我们将看到有关这方面的一般性的Go语法。就这里的os.Args来说,这个slice总是应该至少在位置0处具有一个字符串(程序的名 字)。(所有Go的下标索引都是从0开始)

如果输入一个或更多命令行参数,if条件将被满足,我们将所有参数拼接成一个单一的字符串并赋值给who。在这里例子中,我们使用赋值操作符 (=),如果我们使用:=,我们将声明和初始化一个新的who变量,其影响范围将局限在if语句块中。strings.Join函数接受一个字符 串slice和一个分割符(可以为空,即"")作为参数,并返回一个由所有slice的字符串元素组成的,由分隔符分隔的单一字符串。这里我们用 空格分隔这些字符串。

最后,在最后一个语句中,我们输出Hello,一个空格,who变量中的字符串以及一个新行(newline)。fmt包中拥有许多不同的打印输 出变体,一些像fmt.Println()的将整齐地输出任何传入的值,其他的诸如fmt.Printf将使用占位符以提供更佳的格式控制。

另外一个例子 – 二维slices

下一个例子bigdigits程序从命令行(以一个字符串形式)读取一个数,并在控制台上使用"大号字体"输出这个数。早在二十世纪,在很多用户 共享一台高速行打印机的场合,按惯例将使用这种技术在每个用户的打印工作之前放置一个封面,该封面可以展示用于识别的细节,诸如用户名以及将被打 印的文件的名字。

我将分三段回顾这个程序的代码:首先是import部分,然后是静态数据,最后是处理过程。不过现在让我们来看看一个样例,了解一下这个程序是如 何工作的吧:

$ ./bigdigits  290175493

每位数字都由一个字符串slice表示,所有数字一起由一个元素为字符串slice的slice表示。在查看数据之前,这里我们展示一下如何声明 和初始化一个一维的字符串和数字slice:

longWeekend := []string{"Friday", "Saturday", "Sunday", "Monday"}
var lowPrimes = []int{2, 3, 5, 7, 11, 13, 17, 19}

slice用[]Type表示,如果我们要初始化它们,我们可以直接在后面跟上一个用大括号包裹、逗号分隔的对应类型的元素列表。我们本可以用同 样的变量声明语法来声明这两个变量,但在这里我们为展示两种语法的差异以及一个稍后即将说明的原因,lowPrimes slice使用了一个更长形式的声明。由于slice类型可以作为slice类型的元素类型,因此我们可以很容易地创建多维集合(slice的slice 等)。

bigdigits程序只需要导入四个包。

import (
    "fmt"
    "log"
    "os"
    "path/filepath"
)

fmt包提供了用于文本格式化和读取格式化文本的函数。log包提供了日志记录函数。os包提供了平台无关的操作系统变量以及函数,其中就包括持 有命令行参数值的[]string类型的os.Args变量。path包下面的filepath包提供跨平台操作文件名和路径的相关函数。注意对 于逻辑上存在包含关系的包(译注:如path/filepath)来说,我们在代码中使用时只指明其名字的最后部分(这里是filepath)。

对于bigdigits这个程序,我们需要一个二维的数据(一个字符串slice的slice)。下面就是创建它的方法,通过代表数字0的字符串 布局我们可以看出这个数字对应的字符串在输出时相应的行。3到8的数字对应的字符串这里省略了。

var bigDigits = [][]string{
   {"  000  ",
    " 0   0 ",
    "0     0",
    "0     0",
    "0     0",
     "0  0",
     " 000 "},
    {" 1 ", "11 ", " 1 ", " 1 ", " 1 ", " 1 ", "111"},
    {"222","2  2","  2","  2 ","2  ","2  ","22222"},
    // … 3 to 8 …
    {" 9999", "9  9", "9  9", " 9999", "  9", "  9", "  9"},

在函数或方法外面声明的变量不可以使用:=操作符,但我们可以使用长声明形式(使用关键字var)以及赋值操作符(=)来得到同样的效果,就如我 们这里为bigDigits程序中的变量所做的那样(前面对lowPrime变量的声明)。我们仍然无需指定bigDigits变量的类型,Go 可以从赋值中推断出其类型。

我们把计算工作留给了Go编译器,因此也没有必要指出slice的维数。Go的一个方便之处就是它对使用了括号的符合字面值的良好支持,这样我们 无需在一个地方声明一个数据变量,然后在另外一个地方用数据给它赋值了。

main()函数读取命令行,并使用这些数据产生输出,这个函数只有20行。

func main() {
    if len(os.Args) == 1 {
        fmt.Printf("usage: %s <whole-number>\n", filepath.Base(os.Args[0]))
        os.Exit(1)
    }
 
    stringOfDigits := os.Args[1]
    for row := range bigDigits[0] {
        line := ""
        for column := range stringOfDigits {
            digit := stringOfDigits[column] – '0'
            if 0 <= digit && digit <= 9 {
                line += bigDigits[digit][row] + " "
            } else {
                log.Fatal("invalid whole number")
            }
        }
        fmt.Println(line)
    }
}

程序一开始检查是否有任何命令行参数。如果没有,len(os.Args)的值将为1(回忆一下,os.Args[0]中存放的是程序名,因此这 个slice的长度至少是1)。如果这个if语句条件成立,我们将使用fmt.Printf输出一条适当程序用法信息,该Printf函数使用类 似C/C++中printf()或Python中%操作符的%占位符。

path/filepath包提供路径操作函数- 比如,filepath.Base()函数返回给定路径的基本名(basename,即文件名)。在输出这条信息后,程序使用os.Exit()函数结束 程序,并返回1给操作系统。在类Unix系统中,一个值为0的返回值用于表示成功,非0值标识用法错误或失败。

filepath.Base()函数的使用向我们说明了Go的一个美妙的特性:当一个包被导入时,无论它是顶层的包还是逻辑上内置于其他包中的包 (例如:path/filepath),我们总是可以只通过其名字的最后部分(即filepath)来引用它。我们还可以给包赋予本地名字以避免 名字冲突。

如果至少传入了一个命令行参数,第一个参数将被拷贝到stringOfDigits变量(string类型)中。要想将用户输入的数字转换成大数 字,我们必须迭代处理bigDigits slice的每一行,即每个数字的第一行(最上面的一行),接下来第二行,依次类推。我们假设所有bigDigits的slice都具有相同数量的行,这 样我们可以从第一个slice那里得到行数。Go的for循环对不同场景有不同的应对语法;在这个例子中,for…range循环返回 slice中每个元素的索引位置信息。

行和列的循环部分的代码可以这样来写:

for row := 0; row < len(bigDigits[0]); row++ {
    line := ""
    for column := 0; column < len(stringOfDigits); column++ {
        …

这是一个C、C++和Java程序员都熟悉的语法形式,在Go中它也是有效的。(与C、C++和Java不同在于,在Go中,++和–操作符只 能用作语句,而不能用作表达式。此外,它们只能被用作后缀操作符,而不能作为前缀操作符。这意味着求值顺序导致的相关问题在Go中不会发生- 谢天谢地,像f(i++)和a[i] = b[++i]这样的表达式在Go中是非法的。) 然而,for…range语法更加短小,也更加方便。

在每次行迭代时,代码会将行的line赋值为空字符串。接下来,我们做迭代处理从用户那里获取的stringOFDigits中的列(即,字 符)。Go的字符串使用UTF-8字符,因此本质上一个字符很可能用两个或更多字节表示。但这不是这里要讨论的话题,因为我们只关心数值0、 1、…、9,这些数值用UTF-8字符表示时只需一个字节,与用7比特ASCII字符表示所使用的字节值相同。

当我们索引字符串中的某个特定位置时,我们获取了那个位置的字节值。(在Go中byte类型是uint8类型的同义词。)因此我们获取到命令行字 符串特定列上的字节值,减去数字0的字节值后,得到它表示的数字。在UTF-8(以及7比特ASCII)中,字符'0'是码点(字符)十进制值 48,字符'1'是码点十进制值49,依次类推。这样举例,如果我们有字符'3'(码点1),我们可以通过做减法'3' – '0'(即51-48)的结果得到其整型值3。

Go使用单引号表示字符字面值,一个字符字面值是一个与Go任何整型类型都兼容的整数。Go的强类型意味着如果不进行显式转型,我们无法将一个 int32类型的数与一个int16类型的数相加,不过,Go中的数值常量和字面值自适应于其上下文,这样一来,这里的'0'被认为是一个字节。

如果这个数字(byte类型)在范围内,我们会将对应的字符串加到line变量中。(在if语句中,常量0和9被认为是byte类型,因为它们是 数值类型,但如果数值是一个不同的类型,比如说,int,它们将会被当作新类型对待。)虽然Go中的字符串是不可改变的,Go仍然支持+=附加操 作符以提供一个便于使用的语法。(它通过在后台替换掉原先的字符串。)Go同样支持+字符串连接操作符,该操作将返回一个由左右字符串操作数连接 而成的新字符串。

为了获取对应的字符串,我们根据这个数值访问bigDigits slice,然后访问其中我们需要的行(字符串)。

如果数值超出了范围(比如,由于stringOfDigits包含了一个非数值),我们调用log.Fatal()函数记录一条错误信息。如果没有显式指 定其他日志输出目标,这个函数会在os.Stderr中记录下日期、时间和错误信息。然后该函数调用os.Exit(1)结束程序。还有 一个名为log.Fatalf()的函数可以做同样的事情,但它接受%占位符。我们没有在第一个if语句中使用log.Fatal()函数,因为 我们想输出程序的使用方法信息,但不要log.Fatal()默认输出的日期和时间信息。

一旦给定行的所有数字的字符串都累加完毕,完整的一行就被输出。在这里,我们输出了7行,因为每个bigDigits slice中的数字由七个字符串表示。

最后一点是声明和定义的顺序无关紧要。因此在bigdigits/bigdigits.go文件中,我们可在main()函数前声明bigDigits变 量,也可在后面声明。在这个例子里,我们将main()函数放在前面,对于这篇文章中的例子,我们通常更倾向于自顶向下的排序。

这里的两个例子已经涵盖了大量特性,但它们所展示的资料与其他主流语言甚为相似,即便语法稍有不同。下周的文章将检视Go语言的其余特性,包含一些高级方面的特性。

行为驱动开发导引

本文翻译自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