ma6174 / blog

博客

Home Page:https://ma6174.github.io/blog/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

【翻译】Go语言, REST APIs 和 指针

ma6174 opened this issue · comments

原文链接:https://willnorris.com/2014/05/go-rest-apis-and-pointers


go-github项目中,有一个有趣的设计就是几乎所有和Github交互的API结构体成员,几乎全部使用指针。经过大量试错,我决定将我的遇到的问题和解决方案分享出来,并且我认为其他用Go语言写API客户端的人也需要思考。最原始的问题来源在google/go-github#19,整个讨论过程可能也是比较有趣的;这篇文章会以一种更加容易理解的方式展示这个问题。接下来会讲以下内容的相互影响:Go语言的零值、JSON和XML标签中的omitempty参数,和HTTP请求中PATCH的语义。

从最简单的开始

Go语言对大部分数据编码优雅而简单。你只需要定义一个结构体,对结构体里面的每一个成员添加一个标签来说明这个成员如何被编码成特定的格式就可以了。例如,对于Github仓库,可以使用一个简单的结构体来表示:

type Repository struct {
    Name        string `json:"name"`
    Description string `json:"description"`
    Private     bool   `json:"private"`
}

结构体中每一个成员都明确指定当被编码成JSON之后key的名字,接下来我们构建一个新的仓库并且编码成JSON格式:

r := new(Repository)
b, _ := json.Marshal(r)
println(string(b))

输出 >>> {"name":"","description":"","private":false}

当我们创建了一个新的仓库之后,每一个成员都被赋值为其对应的零值:字符串类型是一个空字符串"",布尔类型是false。Go语言不存在一个变量定义了但是没初始化这一说。当前按照定义,如果最初一个变量没有被赋值,那么就会被初始化为其对应的零值。牢记,这个当前非常重要。

理解 PATCH

正如PATCH名字一样,基于REST的API会传递当前资源的状态信息。这在HTTP协议中很常见,也很直接:要获取当前资源的信息,发一个GET请求到资源的URI就可以了。要更新一个资源,发送一个PUT请求到其资源的URI,需要附带上资源的新描述信息。PUT被定义为将URI对应的资源完整替换,也就是说你必须提供一个资源的完整信息。但是如果你只想更新资源的部分信息呢?这个需要通过PATCH来完成,定义在:RFC 5789

PATCH请求中的body内容如何应用到对应的资源要根据请求的media type来确定。Github(和其他很多JSON API)处理PATCH请求的方式是:对于要更新的资源,你以json形式提供一个新的值,JSON中不存在的字段将不会被更新。举个例子,要更新一个仓库的描述信息,HTTP请求应该看起来是这个样子:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{"description": "new description"}

要删除描述信息,直接将其设置为空字符串就好了:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{"description": ""}

如果你发了一个PATCH请求,包含了资源的每一个字段会怎么样?这和你使用相同Body的发了一个PUT请求是等价的。事实上,正因如此,所有的资源更新操作在Github上都由PATCH来完成。他们甚至不支持(至少文档上没说)使用PUT来完成这种请求。

忽略空值

go-github 库有一个名为Edit的方法用于更新仓库信息。因为Repository结构体包含了需要更新的字段,更新描述信息的Go代码应该是这样的:

r := &github.Repository{Description:"new description"}
client.Repositories.Edit("google", "go-github", r)

这段代码发出的HTTP请求是什么样的?如果你记得之前关于JSON编码的讨论,那么请求应该是这个样子:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{"name": "", "description": "new description", "private": false}

但这并不和我们所指定的字段完全一样。Repository结构体即使没有设置nameprivate这两个字段,Body里面包含了。但是需要明确的是,这些字段被设置为它们的零值,因此和我们指定的是一致的。name字段倒是问题不大,因为它是不变的,github会忽略它。但是是private问题就比较大了,如果原先是一个私有仓库,类似看起来无害的操作可能导致仓库被公开!

为了避免这个问题,我们可以更新我们的Repository类型,让JSON编码的时候自动忽略空值:

type Repository struct {
    Name        string `json:"name,omitempty"`
    Description string `json:"description,omitempty"`
    Private     bool   `json:"private,omitempty"`
}

现在name的空字符串和privatefalse值被忽略了,实现了我们所预期的HTTP请求:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{"description": "new description"}

到目前为止看起来是没问题的。

故意设置空值

现在我们回到上一个例子看一下代码是什么样子,现在我们删除仓库的描述信息,通过设置一个空字符串实现:

r := &github.Repository{Description:""}
client.Repositories.Edit("google", "go-github", r)

之前我们的结构体加上了omitempty参数,对这个请求会有影响吗?不幸的是,请求不是我们所预期的:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{}

因为我们设置Repository结构体里面所有值为他们的零值,编码之后就成了一个空的JSON对象,这个请求不会产生任何影响,无法实现预期的删除仓库描述信息目的。

我们需要有一种方式来区分某个字段是因为初始化而被赋值为零值(JOSN序列化需要忽略)还是开发者有意将其设置为零值(JOSN序列化需要包含这些字段)。这时候指针就派上用场了、

指针

指针的零值是nil,不管指针指向什么类型。因此通过在我们的结构体成员中使用指针,我们可以明确区分因为没有设置而导致的空值:nil,和有意设置的空值,比如"",false0golang/protobuf就是这样做的,就是因为这个原因,我们的Repository结构体最终定义是这个样子的:

type Repository struct {
    Name        *string `json:"name,omitempty"`
    Description *string `json:"description,omitempty"`
    Private     *bool   `json:"private,omitempty"`
}

这确实会带来一些损耗,不管是内存分配还是开发者体验,因为如果一个字段是字符串或布尔值,是必须创建一个指向它的指针才能赋值,这个确实比较烦人。最终可能会写出类似这样的过于冗余代码:

d := "new description"
r := &github.Repository{Description:&d}
client.Repositories.Edit("google", "go-github", r)

为了让这个操作更加简单,go-github提供了一个获取某种类型对应的指针的函数(拷贝自protobuf包):

r := &github.Repository{Description: github.String("new description")}
client.Repositories.Edit("google", "go-github", r)

使用指针也意味着使用这个库的客户端必须进行适当nil检查去避免程序painc。protobuf库提供了访问这些成员的代码,这让开发更简单一些。但是go-github并没有提供。

其他库

你Go实现的客户端API会因此受到影响吗?答案是看情况。如果API不提供像PATCH这样的部分更新功能,那么你不需要设置omitempty这个参数,也不用担心指针问题,你原来是方式没有问题。如果你在你的JSON或者XML请求里面永远不会设置零值,比如空字符串、false0(或许不太可能),那么你只需要设置omitempty参数就好了,其他照常。但是对于大部分现代API接口,这些都不应该出现的。你可以测试一下你当前的库是否会阻止你执行特定的操作。

(我想再说明一点是在google/go-github#19讨论了一些可替代的方法并没有实际尝试,比如使用字段掩码或者直接使用protobuf包。研究学习一下可能是值得的。指针仅仅在这个库里面是有效的;其他方法只要能解决问题都是没问题的)。

深入阅读

好文,谢谢分享

commented

博主好久没更新博客了哦

issue 都被用成这样了, 666