go最佳实践:如何舒适地编码
- 什么是 "最佳 "做法?
- 实践1:package布局
- 实践2:熟悉context.Context
- 实践3:了解Table Driven Test(表格驱动方法)
- 去尝试吧!
什么是 "最佳 "做法?
有很多做法:你可以自己想出来,在互联网上找到,或者从其他语言中拿来,但由于其主观性,并不总是容易说哪一个比另一个好。”最佳”的含义因人而异,也取决于其背景,例如网络应用的最佳实践可能与中间件的最佳实践不一样。
(图片来源网络,侵删)为了写这篇文章,我带着一个问题看了go的实践,那就是 "它在多大程度上让我对写Go感到舒服?",当我说"语言的最佳实践是什么?"时,那是在我刚接触这门语言,还没有完全适应写这门语言的时候。
当然,还有更多的做法,我在这里不做介绍,但如果你在写go时知道这些做法,就会非常有用,但这三个做法对我在go中的信心影响最大。
这就是我选择"最佳"做法的原因。现在是该上手的时候了。
实践1:package布局
当我开始学习go时,最令人惊讶的事情之一是,go没有像Laravel对PHP,Express对Node那样的网络框架。这意味着在编写网络应用时,如何组织你的代码和包,完全取决于你。虽然在如何组织代码方面拥有自由是一件好事,但如果没有指导原则,很容易迷失方向。
另外,这也是最难达成一致的话题之一;"最佳 "的含义很容易改变,这取决于程序处理的业务逻辑或代码库的大小/成熟度。即使是同一个代码库,当前的软件包组织在6个月后也可能不是最好的。
虽然没有单一的做法可以统治一切,但为了补救这种情况,我将介绍一些准则,希望它们能使决策过程更容易。
准则1:从平面布局开始
除非你知道代码库会很大,并且需要某种预先的包布局,否则最好从平面布局开始,简单地将所有的go文件放在根文件夹中。
这是一个来自github.com/patrickmn/g…软件包的文件结构。
go
复制代码
❯ tree . ├── CONTRIBUTORS ├── LICENSE ├── README.md ├── cache.go ├── cache_test.go ├── sharded.go └── sharded_test.go
它只有一个领域的关注:对数据缓存,对于像这样的包,甚至不需要包的布局。扁平结构在这种情况下最适合。
但随着代码库的增长,根文件夹会变得很忙,你会开始觉得扁平结构不再是最好的了。是时候把一些文件移到它们自己的包里了。
准则2:创建子包
据我所知,主要有三种模式:直接在根部,在pkg文件夹下,以及在internal文件夹下。
在根部
在根目录下创建一个带有软件包名称的文件夹,并将所有相关文件移到该文件夹下。这样做的好处是:
- 没有深层次/嵌套的目录
- 导入路径不杂乱
缺点是根文件夹会变得有点乱,特别是当有其他文件夹如scripts、bin和docs时。
在pkg包下
创建一个名为pkg的目录,把子包放在它下面。好的方面是:
- 这个名字清楚地表明这个目录包含了子包
- 你可以保持顶层的清洁
而不好的方面是你需要在导入路径中有pkg,这并不意味着什么,因为很明显你在导入包。
然而,这种模式有一个更大的问题,也是前一种模式的问题:有可能从版本库外部访问子包。
这对私人仓库来说是可以接受的,因为如果发生这种情况,在审查过程中会被注意到,但重要的是要注意什么是公开的,特别是在开放源码的背景下,向后兼容性很重要。一旦你把它公开,你就不能轻易改变它。
有第三个选择来处理这种情况。
在internal包下
如果/internal在导入路径中,go处理包的方式有点不同。如果软件包被放在/internal文件夹下,只有共享/internal之前的路径的软件包才能访问里面的软件包。
例如,如果软件包路径是/a/b/c/internal/d/e/f,只有/a/b/c目录下的软件包可以访问/internal目录下的软件包。这意味着如果你把internal放在根目录下,只有该仓库内的包可以使用子包,而其他仓库不能访问。如果你想拥有子包,同时保持它们的API在内部,这很有用。
准则3:将main移至cmd目录下
把主包放在cmd/目录下也是一种常见的做法。
假设我们有一个用go编写的管理个人笔记的API服务器,用这种模式看起来会是这样。
go
复制代码
$ tree . ├── cmd │ └── personal-note-api │ └── main.go ... ├── Makefile ├── go.mod └── go.sum
要考虑使用这种模式的情况是:
- 你可能想在一个资源库中拥有多个二进制文件。你可以在cmd下创建任意多的文件夹,只要你想。
- 有时需要将主包移到其他地方,以避免循环依赖。
准则4:按其责任组织包装
我们已经研究了何时以及如何制作子包,但还有一个大问题:它们应该如何分组?我认为这是最棘手的部分,需要一些时间来适应,主要是因为它在很大程度上受应用程序的领域关注和功能影响。深入了解代码的作用是做出决定的必要条件。
对此,最常见的建议是按照责任来组织。
对于那些熟悉MVC框架的人来说,拥有"model"、"controller"、"service"等包可能感觉很自然。建议不要在go中使用它们。
相反,我们建议使用更多的责任/领域导向的包名,如"用户"或"事务"。
准则5:按依赖关系对子包进行分组
根据它们的依赖关系来命名包,例如"redis"、"kafka"或"pubsub",在某些情况下提供了明确的抽象性。
想象一下,你有一个这样的接口:
go
复制代码
package bestpractice type User struct {} type UserService interface { User(context.Context, string) (*User, error) }
而你在redis子包里有一个服务,它是这样实现的:
go
复制代码
package redis import ( "github.com/thirdfort/go-bestpractice" "github.com/thirdfort/go-redis" ) type UserService struct { ... } func (s *UserService) User(ctx context.Context, id string) (*bestpractice.User, error) { ... err := redis.RetrieveHash(ctx, k) ... }
如果消费者(大概是主函数)只依赖于接口,它可以很容易地被替代的实现所取代,如postgres或inmemory。
附加提示1:给包起一个简短的名字
关于命名包的几个要点。
- 短而有代表性的名称
- 使用一个词
- 使用缩略语,但不要让它变得神秘莫测
如果你想使用多个词(如billing_account)怎么办?我能想到的选项是:
- 为每个词设置一个嵌套包:billing/account
- 如果没有混淆,就简单地命名为帐户
- 使用缩略语:billacc
补充提示2:避免重复
这是关于如何命名包内的内容(结构/界面/函数)。go的建议是,在消费包的时候尽量避免重复。例如,如果我们有一个包,内容是这样的:
go
复制代码
package user func GetUser(ctx context.Context, id string) (*User, error) { ... }
这个包的消费者要这样调用这个函数:user.GetUser(ctx, u.ID)
在函数调用中出现了两次user这个词。即使我们把user这个词从函数中去掉:user.Get,仍然可以看出它返回了一个用户,因为从包的名称中可以看出。go更倾向于简单的名字。
我希望这些准则在决定包的布局时能有所帮助。
让我们来看看关于上下文的第二个实践。
实践2:熟悉context.Context
在95%的情况下,你唯一需要做的就是将调用者提供的上下文传递给需要上下文作为参数的子程序调用。
go
复制代码
func (u *User) Store(ctx context.Context) error { ... if err := u.Hash.Store(ctx, k, u); err != nil { return err } ... }
尽管如此,由于context在go程序中随处可见,因此了解何时需要它,以及如何使用它是非常重要的。
context的三种用途
首先,也是最重要的一点是,要意识到上下文可以有三种不同的用途:
- 发送取消信号
- 设置超时
- 存储/检索请求的相关值
发送取消信号
context.Context提供了一种机制,可以发送一个信号,告诉收到context的进程停止。
例如,优雅关机
当一个服务器收到关闭信号时,它需要"优雅地"停止;如果它正在处理一个请求,它需要在关闭之前为其提供服务。context包提供了context.WithCancel API,它返回一个配置了cancel的新上下文和一个取消它的函数。如果你调用cancel函数,信号会被发送到接收该上下文的进程中。
在下面的例子中,它调用context.WithCancel后,在启动服务器时将其传递给服务器。当程序收到OS信号时,会调用cancel:
go
复制代码
func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { sigchan := make(chan os.Signal, 1) signal.Notify(sigchan, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)