Codebase Refactoring (with help from Go)
代码库重构(借助于Go)
1.摘要
Go应该添加为类型创建替代等效名称的能力,以便在代码库重构期间渐进代码修复。本文解释了对于这种能力的需求,以及没有它对于今天的大型Go代码库的影响。本文还探讨了一些潜在的解决方案,包括在开发(但没有包含在)Go 1.8中提出的别名功能。然而,本文不是任何具体解决方案的提案。相反,它旨在作为Go社区讨论Go 1.9中应该包含哪些解决方案的开始。
本文是2016年11月18日在纽约GothamGo发表的一个演讲的扩展版本。
- 介绍
Go的目标是轻松构建可扩展的软件。我们关心的规模有两种。一种是使用Go构建的系统的大小,这意味着使用大量计算机、处理大量数据等方面的容易程度。这是Go的重点,但不是这篇文章的。相反,本文重点介绍另一种规模,即Go程序的大小,这意味着在大规模代码库中工作,大量工程师独立进行大量更改的容易程度。
一个如此大规模的代码库是Google的单一存储库,几乎所有的工程师每天都在其中工作。截止2015年1月,该存储库有900万个源文件,20亿行代码,每天有40000次提交。当然,存储库中更多的不只是Go代码。
另一个如此大规模的代码库是人们在Github和其他代码托管站点上提供的所有开源Go代码的集合。你可能会认为这是go get的代码库。与Google的代码库相反,go get的代码库是完全分散的,所以更难获取确切的数字。在2016年11月,godoc.org收录了14万个软件包,超过16万个Github repos是用Go写的。
在Go刚开始时,我们就预想到支持如此大规模的软件开发。我们非常重视高效的导入(imports)。为了避免代码膨胀,禁止导入不使用的代码。我们确保包之间没有不必要的依赖关系,以此来简化程序,易于测试和重构。有关这些考虑的详细信息可以参阅Rob Pike 2012年的文章“Go at Google:Language Design in the Service of Software Engineering”.
在过去的几年中,我们已经意识到有更多的可以并且应该做的,以便更容易地重构整个代码库,特别是在广泛的软件包结构级别,最终的目标是帮助Go扩展到更大规模的程序开发。
- 代码重构
大多数程序从一个包开始。当添加代码时,有时你可能会认识到一段连贯的代码可以独立出来,所以你把这些代码移到一个单独的包中。代码重构是重新思考、修改关于如何将代码分组划分到不同的包中以及这些包之间依赖关系的决策过程。有几个原因可能会使将代码库的组织方式更改为多个程序包。
第一个原因是为用户将包拆分成更易于管理的部分。例如,regexp包的大多数用户不需要访问正则表达式解析器,尽管高级用户可能会使用,所以解析器以单独的regexp/syntax包导出。
第二个原因是改善命名。例如,Go早期版本有一个io.ByteBuffer,但是后来我们认定bytes.Buffer是一个更好的名字,bytes包是更好的容纳该代码的包。
第三个原因是减轻依赖。例如,我们将os.EOF移动到io.EOF,因此没有使用操作系统的代码可以避免导入相当重的os包。
第四个原因是更改依赖关系图,以便一个包可以导入另一个包。例如,作为Go 1准备工作的一部分,我们研究了软件包之间的显式依赖关系以及它们如何限制了API。然后我们更改了依赖关系图,使API更好。
在Go 1之前,os.FileInfo结构体包含以下字段:
type FileInfo struct {
Dev uint64 // device number
Ino uint64 // inode number
...
Atime_ns int64 // access time; ns since epoch
Mtime_ns int64 // modified time; ns since epoch
Ctime_ns int64 // change time; ns since epoch
Name string // name of file
}
注意时间 Atime_ns,Mtime_ns,Ctime_ns的类型是int64,有一个_ns后缀,并且被注释为“从纪元开始的纳秒”。这些字段使用time.Time显然会更好,但错误的代码库包结构设计阻止了这样做。为了能够在这里使用time.Time,我们重构了代码库。
下图显示了Go 1之前的标准库中的8个包,P到Q的箭头表示P导入Q。
几乎每个包都必须考虑error,所以几乎每个包,包括time包,为了使用os.Error而导入os包。为了避免循环,导入os包的任何东西都不能被os包使用。因此,操作系统API无法使用time.Time.
这种问题使我们相信os.Error及其构造函数os.NewError是如此的基础,所以它们应该被移出os包。最后,我们将os.Error移动到语言中变为error,并将os.NewError移动到errors包变为errors.New。在这个和其他重构之后,Go 1中的导入图如下图所示。
io包和time包有足够少的依赖,可以被os包使用,Go 1中os.FileInfo的定义使用了time.Time。
(作为一个附注,我们的第一个想法是将os.Error和os.NewError移动到一个名为error(单数)的新包,变为error.Value和error.New。来自Go社区的Roger Peppe和其他人的反馈帮助我们看到在语言中预先定义error类型可以允许在低级上下文中使用,例如运行时panics规范。因此类型被命名为error,包变为errors(复数),构造函数errors.New。Andrew Gerrand的2015年演讲”How Go was Made”有更多的细节)
- 逐步代码修复
代码库重构的优点可以贯穿整个代码库。不幸的是,代价也是如此:由于重构,通常需要大量修改。随着代码库的增长,一次进行所有修改变得不可行。修改工作必须逐步完成,编程语言必须能够实现。
举个简单的例子,当我们在2009年将io.ByteBuffer移动到bytes.Buffer时,初始提交移动了两个文件,调整了三个makefile,并修改了43个其他的Go源码文件。修改工作超过了真实API变更的二十倍,整个代码库只有250个文件。随着代码库的增长,修复倍数也随之增加。大型Go代码库(如Docker,Juju和Kubernetes)的类似更改可以具有10X到100X的修复乘数。在Google内部,我们已经看到修复乘数超过1000X。
传统的观点认为,当进行代码库级的API变更时,API的变更和相关的代码修复应该在一个大提交中一起提交。
支持这种方法的论据,我们称之为“原子代码修复”,它在概念上是简单的:通过在一次提交总更新API和代码修复,代码库一步从老的API转换到新的API,不会破坏代码库。原子步骤避免了老API和新API共存必须的转换规划。然而,在大型代码库中,概念的简单性被实际的复杂性迅速超过:一个大的提交可能非常大。大提交比较难准备,很难Review,并且从根本上与其他工作产生竞争。很容易会发生:开始一个转换,准备一个大的提交,最后提交它,然后才发现另一个开发人员在你工作时添加了老API的使用。没有合并冲突,所以你错过那个老API的使用,尽管你的努力,一个大的提交破坏了代码库。随着代码库越来越大,原子代码修复越来越困难,更有可能无意中破坏代码库。
根据我们的经验,一个更好的扩展方法是规划一个过渡期,在此期间逐步修复代码,尽可能多的提交。
通常这意味着整个过程分三个阶段进行。首先,引入新的API。新老API必须是可互换的,这意味着在不改变程序总体行为的情况下可以将老API的调用转换成新API,新老API能够在一个程序中共存。第二,尽可能多地提交,将老API的调用转换成新API。第三,删除老API。
渐进代码修复通常比原子代码修复要做更多的工作,但工作本身是容易的,你不必一次性、正确无误地完成所有的操作。此外,单次提交要小很多,使其更容易Review和提交,如果需要可以回滚。更重要地是,渐进代码修复在一个大提交不可能完成的情况下也可以工作,例如需要修复的代码分散在多个存储库中。
bytes.Buffer的更改看起来像是一个原子代码修复,但实际上不是。虽然提交更新了43个源文件,但提交消息显示:“现在保留io.ByteBuffer stub,为协议编译器”。stub在一个新的名为io/xxx.go的文件中:
//这个文件定义了io.ByteBuffer 类型
//所以协议编译器的输出
//仍然有效,一旦协议编译器
//得到修复,这就不需要了。
package io
import "bytes"
type ByteBuffer struct {
bytes.Buffer;
}
当时,就像今天一样,Go在一个独立的源码库中开发的,与Google其他源码库是分离的。Google主源码库中的协议编译器负责从protocol buffer定义中生成Go 源码文件,生成的代码使用io.ByteBuffer.这个stub足以使生成的代码保持工作,直到协议编译器被更新。然后在稍后的提交中删除xxx.go。
即使在原始提交中有很多修复,这种更改仍然是渐进的代码修复,而不是原子代码修复,因为老API在现有代码转换完成后的一个单独阶段被删除。
在这种具体例子中,渐进代码修复确实是成功的,但新老API不完全可以互换:如有有一个函数带有*io.ByteBuffer参数,代码通过一个*io.ByteBuffer调用该函数,这两段代码无法独立更新:将*io.ByteBuffer传递给期望有*bytes.Buffer的函数的代码将无法编译。
再次,渐进代码修复包括三个阶段:
添加新的API,可以与老API互换
将老API的使用转换成新的API
删除老的API
这些阶段适用于任何API更改的渐进代码修复。在代码库重构的特定例子中,将API从一个包移动到另一个包,在更改过程中改变其全名,使新老API可以互换意味着使新老名称可以互换,所以使用老名称的代码与使用新名称的代码有完全一致的行为。
让我们来看看Go如何做到(或不能做到)这一点的例子。
4.1 常量
我们先来看一个移动常量的简单例子。
io包定义了Seeker接口,但是调用Seek方法时开发人员喜欢使用的命名常量来自os包。Go 1.7将常量移动到io 包,给它们更多的惯用名称,例如os.SEEK_SET现在变为io.SeekStart
对于常量,当定义使用相同的类型和值时,一个名称可以与另一个名称互换:
package io
const SeekStart int = 0
package os
const SEEK_SET int = 0
由于Go 1的兼容性,我们在渐进代码更改的第二阶段被阻止。我们不能删除老的常量,但io包中的新常量可以让开发人员避免在实际上不依赖操作系统函数的代码中导入os包。
这也是在许多代码库中进行渐进代码修复的示例。Go 1.7引入新的API,现在每个使用Go代码的人都可以根据自己的需要更新代码。没有匆忙、强迫的破坏现有代码。
4.2 函数
下面看一下将函数从一个包转移到另一个包。
如上所述,在2011年,我们用预定义的类型error替换os.Error,将构造函数os.NewError移动到一个新的包中,变为errors.New。
对于函数,当定义使用相同的签名和实现时,一个名称可以与另一个名称互换。在这种情况下,我们可以将老函数定义为对新函数调用的包装器:
package errors
func New(msg string) error { ... }
package os
func NewError(msg string) os.Error {
return errors.New(msg)
}
由于Go不允许比较函数的相等性,所以没有办法分别这两个函数。新老API是可以互换的,所以我们可以进入第2阶段和第3阶段。
(我们在这里忽略了一个细节:原来的os.NewError返回一个os.Error,而不是一个error,并且具有不同签名的两个函数是可区分的。为了使这些函数不可区分,我们还需要使os.Error和error不可区分,我们将在下面的类型讨论中回到这个细节。)
4.3 变量
现在我们来看一下将一个变量从一个包移动到另一个包。
我们在讨论导出包级API,因此所讨论的变量必须是导出的全局变量。这些变量几乎总是在初始化时设置,然后只是被读取,而不是再次写入,为了避免读写goroutines之间的竞争。对于遵循该模式的导出全局变量,当两个变量具有相同的类型和值时,一个名称与另一个名称基本可互换。最简单的方法是用其中一个的值初始化另一个。
package io
var EOF = ...
package os
var EOF = io.EOF
在这个例子中,io.EOF和os.EOF有相同的值。变量值是完全可互换的。
有个小问题。尽管变量值是可互换的,但变量的地址不可呼唤。在这个例子中,&io.EOF和&os.EOF是不同的指针。但是,很少会发生从包中导出一个只读变量并期望客户端使用其地址:如果包将导出一个变量设置为地址,那么该模式将适用于客户端。
4.4类型
最后,我们来看看将类型从一个包转移到另一个包。现在在Go中比较难做到,如下面三个例子所示。
4.4.1 Go的os.Error
再次考虑从os.Error到error的错误转换。Go中没有办法使两种类型的名称可互换。Go中最接近的是给os.Error和error相同的底层定义:
package os
type Error error
即使这样定义,即使这些是接口类型,Go仍然认为这两种类型不同,所以返回一个os.Error的函数与返回一个error的函数是不一样的。考虑io.Reader接口
package io
type Reader interface {
Read(b []byte) (n int, err error)
}
如果io.Reader使用error定义,如上所示,返回os.Error的Read方法将不能满足该接口。
如果没有方法让两个名字可以互换,那就抛出了两个问题。首先,我们如何为移动或重命名的类型启用渐进代码修复?第二,我们在2011年为os.Error做了什么?
要回答第二个问题,我们可以看一下源码管理记录。原来,为了辅助转换,我们向编译器添加了一个临时的hack,以此来将用os.Error编写的代码解释为用error所写。
4.4.2 Kubernetes
移动类型的问题不限于os.Error这样的基本变化,也不限于Go代码库。有个Kubernetes项目的更改。Kubernetes有一个util包,后来开发人员决定将该包的IntOrString类型划分到它自己的包intstr。
应用该模式进行渐进代码修复,第一阶段是建立一种可以互换的两种类型的方式。我们无法做到,因为IntOrString类型在结构的字段中使用,代码不能为这个字段赋值除非正在赋的值具有正确的类型:
package util
type IntOrString intstr.IntOrString
// Not good enough for:
// IngressBackend describes ...
type IngressBackend struct {
ServiceName string `json:"serviceName"`
ServicePort intstr.IntOrString `json:"servicePort"`
}
如果这个用法是唯一问题,那么可以想象一下使用老的类型编写一个getter和setter,在渐进代码修复中使用getter和setter改变所有现有代码,然后修改该字段以使用新的类型、做渐进代码修复来更改所有的现有代码,然后最终删除getter和setter。这需要两个渐进代码修复而不是一个,并且除了这个结构域之外还有许多其他该类型的使用。
实际上,这里唯一的选择是使用原子代码修复,或者使用IntOrString打破所有代码。
4.4.3 Docker
另一个例子,这是Docker项目的更改。Docker有一个utils包,后来开发人员决定将该包的JSONError类型划分到一个单独的jsonmessage包中。
再次,我们有新老类型不可互换的问题,但它出现了不同的方式,即类型断言:
package utils
type JSONError jsonmessage.JSONError
// Not good enough for:
jsonError, ok := err.(*jsonmessage.JSONError)
if !ok {
jsonError = &jsonmessage.JSONError{
Message: err.Error(),
}
}
如果error err不是JSONError,那么该代码将其包装在一个JSONError中,但是在渐进修复中,该代码以不同的方式处理uitils.JSONError和jsonmessage.JSONError。这两种类型是不可互换的。(type switch会暴露相同的问题)
如果这行是唯一的问题,那么你可以想象为*utils.JSONError添加一个类型断言,然后进行渐进代码修复以删除utils.JSONError的其他用途,最后在删除老类型之前删除附加的类型判断。但是这行不是唯一的问题。该类型在API的其他地方使用,并且具有Kubernetes示例的所有问题。
在实践中,唯一的选择是使用原子代码修复或者使用JSONError来破坏所有的代码。
5 解决方案
我们现在已经看了一些关于如何或为何不能将常量、函数、变量和类型从一个包移动到另一个包的例子。创建可互换的新老API的模式是:
const OldAPI = NewPackage.API
func OldAPI() { NewPackage.API() }
var OldAPI = NewPackage.API
type OldAPI ... ??? modify compiler or ... ???
输入OldAPI??修改编译器或??
对于常量和函数,渐进式代码修复的设置过程是微不足道的。对于变量,普通的设置是不完整的,但是只有在实践中很少出现的方式下。
对于类型,基本上没有办法设置一个实际可行的渐进代码修复。最常见的选择是强制进行原子代码修复,否则移动的类型会破坏所有的代码,并使客户端在下次更新中修复它们的代码。在移动os.Error的例子中,我们采用了修改编译器。这些选项都是不合理的。开发人员应该能够对涉及将类型从一个包移动到另一个包进行重构,不需要原子代码修复,不需要采用中间代码和多轮修复,不会强制所有客户端包立即更新自己的代码,甚至不会考虑修改编译器。
但是怎么做?这些重构未来该怎样做?
我不知道。这篇问题的目的是为了定义问题,以便讨论可能的答案。
5.1 别名
如上所述,移动类型的根本问题在于,尽管Go提供了为常量或函数或(大多时候)变量创建备用名称的方法,但无法为类型创建备用名称。
对于Go 1.8,我们尝试为这些备用名称引入一类支持,称为别名。一个新的声明语法(别名表)将提供统一的方式为任何类型的标识符创建备用名称:
const OldAPI => NewPackage.API
func OldAPI => NewPackage.API
var OldAPI => NewPackage.API
type OldAPI => NewPackage.API
而不是四个不同的机制,我们上面考虑的os包重构将使用一个单一的机制:
package os
const SEEK_SET => io.SeekStart
func NewError => errors.New
var EOF => io.EOF
type Error => error
在Go 1.8发布冻结期间,我们在别名支持(issue 17746和17784)中发现了两个小但重要的未解决的技术细节,我们确定在Go 1.8发布之前的剩余时间内无法自信地解决它们,所以我们从Go 1.8中去掉了别名。
5.2 版本控制
一个显而易见的问题是是否依赖版本控制和依赖关系管理来进行代码修复,而不是专注于渐进代码修复的策略。
版本控制和渐进代码修复策略是互补的。版本控制系统的工作是确定程序中需要的所有软件包的兼容版本集,或者解释为什么不能构建这样的集合。渐进代码修复创建了额外的兼容组合,使得版本系统更有可能找到一种构建特定程序的方法。
再次考虑我们上面讨论的Go标准库的各种更新。假设老的API在版本控制系统中与标准库版本5.1.3相对应。在通常的原子代码修复方法中,将引入新的API并同时删除老的API,从而产生了版本6.0.0.0;在语义版本控制后,主版本号增加,以表示删除老API所引起的不兼容性。
现在假设你的大程序依赖两个包,Foo 和Bar。Foo仍然使用老的标准库API。Bar已经被更新为使用新的标准库API,并且其中有你程序需要的重要更新:您不能在标准库更改后使用旧版本的Bar。
没有兼容的库来构建你的程序:你想要最新版本的Bar,它需要标准库6.0.0,但是你也需要Foo,这与标准库6.0.0不兼容。在这种情况下,最好的版本控制系统可以清楚地报告故障。(如果你有足够的动力,你可能会采取更新自己的Foo副本。)
相比之下,为了更好地支持渐进代码修复,我们可以在版本5.2.0中添加新的可互换的API,然后在版本6.0.0中删除老的API。
中间版本5.2.0向后兼容5.1.3,由共享的主版本号5指示。但是,由于从5.2.0到6.0.0的更改仅删除了API,5.2.0也可能令人惊讶地向后兼容6.0.0.假设Bar精确地声明了它的要求---它与5.2.0和6.0.0兼容--一个版本系统可以看到Foo和Bar与5.2.0兼容,并使用该版本的标准库构建程序。
良好的支持和采用渐进代码修复减少了不兼容性,使版本控制系统有更好的机会找到一种构建程序的方法。
5.3 类型别名
为了在代码库重构期间启用渐进代码修复,必须可以为常量、函数、变量或类型创建备用名称。Go已经允许为所有常量、所有函数和几乎所有变量引入替代名称,但不包括类型。换句话说,一般的别名形式对于常量永远不是必需的,对函数也不是必需的,对变量很少需要,但对类型是必需的。
对具体声明的相对重要性表明,Go 1.8别名可能是过度概括,我们应该将重点放在仅限于类型的解决方案上。显而易见的解决方案是类型别名,不需要新的操作符。按照Pascal(或者你喜欢Rust),Go程序可以使用赋值运算符引入一个类型别名:
type OldAPI = NewPackage.API
将别名限定于类型的想法是在Go 1.8别名讨论中提出的,但是似乎值得尝试采用更一般的方法,我们所做的,但不成功。回想起来,事实上,=和=>对于常数有相同的含义,而他们对于变量具有几乎相同但略有不同的含义,这表明通用的方法与其复杂性相比是不值得的。
事实上,在Go的早期设计中考虑了添加Pascal风格类型别名的想法,但到目前为止,我们没有强大的用例。
类型别名似乎是一种可以探索的有希望的方法,但至少对于我来说,广义别名在Go1.8周期的讨论和实验之前似乎同样有前途。本文的目的不是预先判断结果,而是详细解释问题,并研究一些可能的解决方案,以便下次能够进行富有成效的讨论和评估。
6 挑战
Go致力于成为大型代码库的理想选择。
在大型代码库中,重要的是可以重构代码库结构,这意味着在程序包之间移动API和更新客户端代码。
在这样大的重构中,重要的是能够使用从老API到新API的渐进转换。
Go不支持在包之间移动类型这种特定情况下的渐进代码修复。它应该可以。
我希望我们Go社区能够在Go 1.9中解决这个问题。也许类型别名是一个很好的起点。也许不是。时间会告诉我们。
7. 致谢
感谢许多帮助我们思考设计问题的人,让我们感觉到这一点,并且引导了G01.8开发期间的别名试用。当我们为Go 1.9重新审视这个问题时,我希望Go社区能够再次帮助我们。如果你想贡献,请参阅issue 18130