第3 章 Go 模块 欢迎来到第 能认为是时候开始实现更复杂的应用程序了 3章!现在你已经了解了Go语言的基本构建块 。然而,在此之前 ,你可 , 学习如何与较大的程序员社区协同工作是很重要的。要做到这一点,必 须学习如何使用和创建包(package),从而能够大规模地部署代码。 阅读本章后,可以得到以下问题的答案。 . 什么是包 ? 为什么它们对编程语言的生态系统很重要 ? . 包管理器是如何工作的 ? 它在开发大型应用程序中起到了什么 作用 ? . Go 模块是如何构建的 ? . 如何在应用程序中使用内置和第三方的模块 ? . 如何构建自己的Go 模块 ? 62 极简Go语言———后端开发入门之道 大多数编程语言都使用包管理系统对代码进行部署,如果使用得 当,那么包管理系统能够使程序员的工作更高效且更具有协作性。例 如,Python的PIP 、Swift的包管理器、Ruby的RubyGems,当然,还有 Go的Gomodules。 此外,当一个令人称赞的包生态系统(无论是内置的还是第三方社 区支持的)具有独特的语言特性(如Go独特的并发性和快速编译特性) 时,编程就会变得简单而有趣。 编程语言实际上是由其提供的功能“包”定义的。以Python为例, Python通常通过大量经过良好优化的内置包来扩展其标准库,例如, json、urlib、pickle、os、sys、sqlite等。这些包是Python成功的关键,尽 管从技术角度来看它是有缺陷的,但你只需要安装Python语言,就可以 发送和接收HTTP请求、解析JSON和CSV文件、序列化数据到磁盘、 控制OS特性,甚至使用SQLite数据库。 另一个关键是,它不仅使用内置包提供了基本功能,而且为其他人 发布自己的包和使用他人的包提供了一种简单的方法。如果是一个不 易使用、不集中,并且不是与语言本身一起构建的系统,那么最终可能会 得到一个松散、混乱、滋生bug的生态系统,例如Java或iOS以外平台 的旧版本Swift。 3.使用内置包 1 我们探索的第一个示例是一个相对简单的应用程序,它可以从 OMDbAPI中获取数据。OMDb代表theOpenMovieDatabase,顾名 思义,它们提供了大量关于电影的可编程访问信息。 具体来说,我们会构建一个可以根据电影名称搜索电影的应用程 序。我们将以常规的方式开始构建应用程序,再加上一些其他东西(不 第3章 Go模块6 3 用担心,我们将在稍后解释它们的含义)。 package main import ( "encoding/json" "errors" "io/ioutil" "net/http" "net/url" "strings" ) 当然,第一行代码只是告诉Go我们正在为哪个包编写代码。然 后,让我们来看看导入(import)。到目前为止,你只使用过fmt库,但这 次会使用许多其他库,如表3.1所示。 表3.1 在OMDb应用程序中导入各包的含义 包含 义 encoding/json 用于编码/解码(封装/解封)JSON对象 errors 用于引发错误 io/ioutil 输入/输出程序(用于从RESTAPI读取数据流) net/http HTTP客户端和服务器的实现(我们只使用客户端) net/url 处理URL应用程序的实现(包括HTTPURL) strings 字符串应用程序的实现 这些包将提供构建块,我们可以在其上实现自己的逻辑。 备注:需要记住的是,在Go中,必须用到每个import;否则,Go将抛 出error并拒绝编译程序,而其他编程语言要么静默地继续,要么抛 出警告,这是为了保证速度与安全,因为未使用的import可能存 在bug。 6 4 极简Go语言———后端开发入门之道 导入之后,我们将创建一个名为APIKEY 的新常量,用于存储 OMDBAPI键。OMDbAPI键用于告诉API你是谁,以便服务可以实 现如评级限制、权限和演员表等功能。 //omdbapi.com API key const APIKEY = "193ef3a" 在继续之前,先简单介绍一下API:我们将实现两个函数,用其调 用众多API端点(endpoint)中的两个,这两个函数都将实现电影搜索功 能,一个将实现按标题搜索,另一个将实现按MovieID(IMDB中电影的 唯一标识符)搜索。 如果调用这两个端点中的任何一个,将得到一个JSON 响应,其中 包含一个符合特定规范(specification)的对象。我们现在要做的是在Go 语言的结构体中实现相同的规范。这样,当得到JSON 字符串作为响应 时,我们就可以告诉Go将JSON封装到结构体中。 以下是我们将构建的结构体: //The structure of the returned JSON from omdbapi.com //To keep this example short, some of the values are not //mapped into the structure type MovieInfo struct { Title string `json:"Title"` Year string `json:"Year"` Rated string `json:"Rated"` Released string `json:"Released"` Runtime string `json:"Runtime"` Genre string `json:"Genre"` Writer string `json:"Writer"` Actors string `json:"Actors"` Plot string `json:"Plot"` Language string `json:"Language"` Country string `json:"Country"` 第3章 Go模块6 5 Awards string `json:"Awards"` Poster string `json:"Poster"` ImdbRating string `json:"imdbRating"` ImdbID string `json:"imdbID"` } 我们还可以从API中获得相当多的其他数据,但是为了简洁起见, 我们在结构体中删掉了这些数据。在这个结构体中,还有一些没有介绍 过的其他语法———位于类型标注之后,用反引号括起来。例如: Actors string `json:"Actors"` `json:"Actors"`用来告诉JSON 包,当你需要将JSON 字符串封装 到这个结构体时,Actors键的值应该被置于这个变量("Actors"字符串) 中,这允许你在JSON中为键设置一个与结构体中存储的键的变量名不 同的名称。 关于这一点,我们在第2章中介绍了简化版的结构体,其中说明了 需要在结构体中提供的变量名和类型标注,但是Go还可以接收另一部 分的变量信息(尽管这是可选的),这就是所谓的“标签”。标签包含关于 变量的其他信息,以及代码的不同部分所需的变量信息。 在本例中,我们使用包含JSON键的标签支持JSON的封装处理。 现在,我们有了一个包含从API获得的信息的结构体,下面实现对 API的调用。但是在此之前,我们必须编写一个实际负责向API发送 HTTPGET请求的函数。下面是实现这个逻辑的一个简单函数: func sendGetRequest(url string) (string, error) { resp, err := http.Get(url) if err != nil { return "", err } 6 6 极简Go语言———后端开发入门之道 defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode != 200 { return string(body), errors.New(resp.Status) } return string(body), nil } 这个函数的工作方式很简单,它看起来可能很复杂,这是因为它是 你在Go中处理的第一个较大的函数。让我们从解析函数签名开始: func sendGetRequest(url string) (string, error) 这个签名很简单。该函数只接收一个字符串类型的参数url。但 是,它会返回两个独立的值:一个字符串,它将请求的响应表示为字符 串;一个error类型,error应该是nil,但如果在请求或解析响应的某个 地方发生了错误,则会包含一个值。 签名之后是函数的主体,因为这是你接触到的第一个真正的函数, 所以让我们在表3.2中分别介绍每一部分吧。 表3.2 sendGetRequest中每部分代码的功能 代 码功 能 resp, err := http.Get(url) if err != nil { return "", err } 使用http模块中的GET 函数运行实际的GET 请求,并 将该函数的url作为参数传递给它。然后,将响应存储在 resp和err中,并确保err为nil。如果不是,则提前从该 函数返回一个空响应和此error 续表 第3章 Go模块6 7 代 码功 能 defer resp.Body.Close() body, err := ioutil.ReadAll (resp.Body) if err != nil { return "", err } 告诉Go“在将这个函数返回给它的调用者之前,确保从 响应中关闭Body输入流”。然后,使用io包读取从响应 Body中获得的字节,这可能会返回一个error,因此运行 与第一部分中类似的逻辑———如果有error,则提前返回 一个空响应和我们得到的error if resp.StatusCode != 200 { return string(body), errors.New(resp. Status) } 检查我们得到的状态码,如果它不是200(意味着一切正 常),那么返回响应的body(其中可能包含有用的信息) 并创建一个新的error,其描述了响应的整个状态 return string(body), nil 最后一部分是我们希望得到的结果,这意味着管道中没 有error,并且可以将body作为字符串返回,没有error 完成此函数后,我们现在就能够发送GET 请求并处理可能出现的 一些常见错误。下面让我们实现搜索。 请记住,我们需要实现两种搜索方式:通过标题搜索和通过电影ID 搜索。让我们先从按标题或姓名搜索开始。 func SearchByName(name string) (*MovieInfo, error) { parms := url.Values{} parms.Set("apikey", APIKEY) parms.Set("t", name) siteURL := "http://www.omdbapi.com/? " + parms.Encode() body, err := sendGetRequest(siteURL) if err != nil { return nil, errors.New(err.Error() + "\nBody:" + body) } mi := &MovieInfo{} return mi, json.Unmarshal([]byte(body), mi) } 这个函数看起来更简单一些,我们依然从函数签名开始。 6 8 极简Go语言———后端开发入门之道 func SearchByName(name string) (*MovieInfo, error) 函数签名乍一看很简单:只是创建了一个接收单个参数并返回两 个值的函数。然而,仔细观察后,你会发现返回的第一个值,即从JSON 响应中解析出的MovieInfo结构体实际上是一个指针。 为什么会这样? 这是因为我们还有另一个返回值———error。当有 一个error时,这意味着没有返回一个MovieInfo结构体。那么,用什么 来代替它呢? 我们可以返回一个带有一堆占位符值的空结构体,但这并 不美观。你可能会问,为什么不直接返回nil呢? 如果函数的类型注解 只是MovieInfo,其作为一个值而不是指针,那么Go就不允许返回nil, 这是因为没有办法在内存中表示它。 当我们将其声明为指针时,Go则允许返回nil,这是因为指针允许 指向任何内容。如果我们需要返回一个error,则可以返回一个带有 error的nil值;如果没有问题,也可以返回一个带有nilerror的值。 就函数逻辑而言,其本质上遵循以下步骤: (1)构建一组URL 参数,其中包含API和我们想要搜索的电影 名称; (2)组合我们想要查询的RESTAPIURL,以及步骤(1)中的参数; (3)向站点发出请求,如果有错误,则返回nil指针和error; (4)创建一个新的MovieInfo值,获取指向该值的指针,返回该指 针,并通过该指针将响应字符串解析为值;如果数据解析产生错误,则会 返回error。 在所有步骤中,步骤(4)可能是唯一一个比较复杂的,因为它与函数 中的最后两行代码相关,让我们来看一下更多的细节。 mi := &MovieInfo{} return mi, json.Unmarshal([]byte(body), mi) 第3章 Go模块6 9 代码的第一行很简单:创建结构体,通过取地址操作符“&”获取指 针,并将该地址存储在mi变量中。第二行有些复杂,如果仔细分析一 下,你会发现需要返回的第一个值是一个很容易解析的表达式,它只是 变量中的一个值。 但是,需要返回的第二个值就不那么容易解析了。你需要调用一个 函数(在本例中是Unmarshal)获取返回值,然后将其作为此函数的返回 值。所以,这是内部发生的事情: (1)创建一个指向新的MovieInfo结构体的指针; (2)将body字符串转换为字节数组; (3)以字节的形式传递body和指向json包中的Unmarshal函数的 指针。Unmarshal函数的响应是error类型,并没有一个真正的“值”被 返回,这是因为我们给它传递了一个指针,所以它只是把我们期望它“返 回”的值放在指针指向的内存中而已; (4)该函数返回指向刚刚解析的内存的指针,以及Unmarshal函数 可能返回的error。 同样,第一次看可能有些不太直观,但一旦习惯了,它就会更容易 理解。然 后,我们对按唯一标识符搜索电影的函数做以下操作: func SearchById(id string) (*MovieInfo, error) { parms := url.Values{} parms.Set("apikey", APIKEY) parms.Set("i", id) siteURL := "http://www.omdbapi.com/? " + parms.Encode() body, err := sendGetRequest(siteURL) if err != nil { return nil, errors.New(err.Error() + "\nBody:" + body) } mi := &MovieInfo{} 7 0 极简Go语言———后端开发入门之道 return mi, json.Unmarshal([]byte(body), mi) } 除了函数名之外,这里唯一的区别在代码的第4行。参数的名称不 是t表示的标题,而是i表示的ID。 以上就是全部代码! 现在我们就可以查询OMDb并获取有关电影 的信息。让我们用main函数来测试一下。 func main() { body, _ := SearchById("tt3896198") fmt.Println(body.Title) body, _ = SearchByName("Game of") fmt.Println(body.Title) } 我们在main函数中所做的是按ID搜索,然后按Name搜索。尝试 在这里输入值,看看你会得到哪个电影。要记住的是,我们忽略了这些 函数的第二个返回值,它们都是error类型的。这意味着,如果此时得到 一个错误,则不会处理它。根据错误发生的位置,可能导致应用程序崩 溃或打印出一个无用的、无意义的值。 当然,在实际的产品代码中,我们会处理这些错误。 当运行这段代码时,你会看到以下输出: Guardians of the Galaxy Vol. 2 Game of Thrones 第一行是ID为tt3896198的结果的标题,第二行是在OMDb中搜 索“Gameof”时得到的第一个结果的标题。 下面是完整的代码清单。 第3章 Go模块7 1 代码清单3.1 使用OMDbAPI获取电影信息 package main /* Example of only using many built-in packages in Go to reach out to a rest API to retrieve movie detail. */ import ( "encoding/json" "errors" "fmt" "io/ioutil" "net/http" "net/url" "strings" ) //omdbapi.com API key const APIKEY = "193ef3a" //The structure of the returned JSON from omdbapi.com //To keep this example short, some of the values are not //mapped into the structure type MovieInfo struct { Title string `json:"Title"` Year string `json:"Year"` Rated string `json:"Rated"` Released string `json:"Released"` Runtime string `json:"Runtime"` Genre string `json:"Genre"` Writer string `json:"Writer"` Actors string `json:"Actors"` Plot string `json:"Plot"` Language string `json:"Language"` Country string `json:"Country"` 7 2 极简Go语言———后端开发入门之道 Awards string `json:"Awards"` Poster string `json:"Poster"` ImdbRating string `json:"imdbRating"` ImdbID string `json:"imdbID"` } func main() { body, _ := SearchById("tt3896198") fmt.Println(body.Title) body, _ = SearchByName("Game of") fmt.Println(body.Title) } func SearchByName(name string) (*MovieInfo, error) { parms := url.Values{} parms.Set("apikey", APIKEY) parms.Set("t", name) siteURL := "http://www.omdbapi.com/? " + parms.Encode() body, err := sendGetRequest(siteURL) if err != nil { return nil, errors.New(err.Error() + "\nBody:" + body) } mi := &MovieInfo{} return mi, json.Unmarshal([]byte(body), mi) } func SearchById(id string) (*MovieInfo, error) { parms := url.Values{} parms.Set("apikey", APIKEY) parms.Set("i", id) siteURL := "http://www.omdbapi.com/? " + parms.Encode() body, err := sendGetRequest(siteURL) if err != nil { return nil, errors.New(err.Error() + "\nBody:" + body) } mi := &MovieInfo{} return mi, json.Unmarshal([]byte(body), mi) } 第3章 Go模块7 3 func sendGetRequest(url string) (string, error) { resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode != 200 { return string(body), errors.New(resp.Status) } return string(body), nil } 以上就是一个仅使用内置包构建真实的、有用的应用程序的示例。 然而,当你使用社区编写的代码时,编程语言的美就会显现出来。为此, 我们将使用Go模块。 3.2 使用第三方包 对Go模块的支持始于Go1.11版本。通过模块,Go可以无缝地处 理第三方包,使程序员能够协作和共享代码。 Go模块可以由IDE处理,也可以通过命令行手动处理。因为我们 的目的是尽可能以与平台无关的方式学习Go,所以只会介绍使用命令 行的方法。 处理模块的主要命令是“gomod”。例如,如果你在命令行中运行这 个命令: 7 4 极简Go语言———后端开发入门之道 go mod help 就会看到一个帮助页面,从中可以了解使用Go模块做的所有事情。 Go模块有以下两种使用方式。 (1)“全局”安装Go模块。你需要下载该模块的代码并将其存储 在所有项目都可以访问的路径中。这样做的好处是,你只需要获取它 一次,然后你的所有项目就都可以访问该模块了。其主要的缺点是有 时可能会很烦琐,例如当你需要某个包的特定版本或一个独立的包环 境时。 (2)如果创建自己的Go模块,则可以在自己的模块中安装第三方 Go模块,这样可以防止其他项目和模块访问你下载的模块,它将被存储 在项目文件夹中。 让我们先编写一些使用第三方Go模块的代码,然后探究这两种选 择方式。首先,我们建议在项目中安装模块,除非有非常特殊的原因,否 则需要在全局安装它们。 我们将构建一个应用程序,它通过命令行从用户处获取一个数 字,并打印出该数字是否是素数。这个例子非常简单,所以我们可以 很容易地自己编写代码。但是,我们将使用GitHub上的一个开源模 块完成。 我们将使用的包可以在www.github.com/otiai10/primes上找到。 整个代码文件如下。 代码清单3.2 使用otiai10包检测数字是否为素数 package main import ( "fmt" "github.com/otiai10/primes" 第3章 Go模块7 5 "os" "strconv" ) func main() { args := os.Args[1:] if len(args) != 1 { fmt.Println("Usage:", os.Args[0], "") os.Exit(1) } number, err := strconv.Atoi(args[0]) if err != nil { panic(err) } f := primes.Factorize(int64(number)) fmt.Println("primes:", len(f.Powers()) == 1) } 正如你看到的,使用第三方模块和使用内置包几乎是一样的。一个 明显的区别是,当运行import时,需要指定到模块所在的GitHubrepo (存储库)的链接。 从技术上讲,Go模块不需要GitHub链接即可工作,只需要一个链 接到任意远程的Git存储库(可能托管在GitHub、Gitlab、Bitbucket 等),你甚至可以使用自己的Git服务器实例。 这使得Go模块系统能够处理版本控制。例如,它会专门记住你调 用了哪个模块编译了你的代码,从而使编码环境的一致性更好。如果采 用刚才提到的第二种方式,即创建自己的包,那么这些信息会存储在一 个名为go.sum 的文件中。 回到我们的代码,如果你此时编译它(使用gobuild命令),将遇到 以下错误: main.go:14:2: cannot find package "github.com/otiai10/primes" in any of: 7 6 极简Go语言———后端开发入门之道 /usr/local/Cellar/go/1.14.4/libexec/src/github.com/otiai10/ primes (from $GOROOT) /Users/tanmaybakshi/go/src/github.com/otiai10/primes (from $GOPATH) 这时Go会告诉你“我找不到你试图导入的模块”,它会抛出一个编 译器错误。如果你决定使用第一种方法安装软件包(我们并不推荐),那 么可以在命令行中运行以下命令: go get github.com/otiai10/primes 这会将包下载到你的主文件夹(homefolder),如果构建并运行代码,那 么它应该可以正常工作。 但是,我们建议执行以下命令: go mod init primechecker 这个命令只会做一件事情:在当前目录下,它将创建一个名为 primechecker的新模块。在本例中,名称并不重要,它只在其他人想使 用你的包时才会有用(这是他们用来引用你的模块的名称)。 现在,当运行gobuild命令时,你将看到代码编译成功,这是因为 Go能够将所需的模块下载到你自己的新模块中。 当运行gobuild命令时,你会看到如下输出: > go build . go: finding module for package github.com/otiai10/primes go: found github.com/otiai10/primes in github.com/otiai10/primes v0.0.0-20180210170552-f6d2a1ba97c4 在go.sum 文件中,你会看到以下内容: 第3章 Go模块7 7 github.com/otiai10/primes v0.0.0-20180210170552-f6d2a1ba97c4/ go.mod h1:UmSP7QeU3XmAdGu5+dnrTJqjBc+IscpVZkQzk473cjM= 3.3 构建自己的包 正如我们已经提到的,在Go中制作和构建自己的自定义包非常 简单。现 在,为了让其他人使用你的模块,你必须非常具体地命名这个模 块。在上面的示例中,你可以将模块命名为任意名称,但是,如果你想将 该模块推送到远程Git服务器供其他人使用,则必须将包命名为其他人 可以在其代码中导入的远程Git库。 例如,让我们编写一个允许用户检查一个数字是否为素数的包。可 以像下面这样初始化包: go mod init github.com/Tanmay-Teaches/golang/chapter3/example3 这样,我们的Go模块就可以被想要使用我们代码的人自动导 入了。下 面编写这个包的代码。当然,我们将在main.go文件中进行编 码。但是这次,我们将把包命名为example3,而不是packagemain,这表 示当人们在自己的代码中引用这个包时,将把它称为example3。 代码清单3.3 自定义素数检测包 package example3 func IsPrime(n int) bool { if n <= 1 { return false 7 8 极简Go语言———后端开发入门之道 } else if n <= 3 { return true } else if n %2 == 0 || n %3 == 0 { return false } i := 5 for i * i