什么是 "最佳 "做法?
有很多做法:你可以自己想出来,在互联网上找到,或者从其他语言中拿来,但由于其主观性,并不总是容易说哪一个比另一个好。”最佳”的含义因人而异,也取决于其背景,例如网络应用的最佳实践可能与中间件的最佳实践不一样。
为了写这篇文章,我带着一个问题看了go的实践,那就是 “它在多大程度上让我对写Go感到舒服?”,当我说"语言的最佳实践是什么?"时,那是在我刚接触这门语言,还没有完全适应写这门语言的时候。
当然,还有更多的做法,我在这里不做介绍,但如果你在写go时知道这些做法,就会非常有用,但这三个做法对我在go中的信心影响最大。
这就是我选择"最佳"做法的原因。现在是该上手的时候了。
实践1:package布局
当我开始学习go时,最令人惊讶的事情之一是,go没有像Laravel对PHP,Express对Node那样的网络框架。这意味着在编写网络应用时,如何组织你的代码和包,完全取决于你。虽然在如何组织代码方面拥有自由是一件好事,但如果没有指导原则,很容易迷失方向。
另外,这也是最难达成一致的话题之一;"最佳 "的含义很容易改变,这取决于程序处理的业务逻辑或代码库的大小/成熟度。即使是同一个代码库,当前的软件包组织在6个月后也可能不是最好的。
虽然没有单一的做法可以统治一切,但为了补救这种情况,我将介绍一些准则,希望它们能使决策过程更容易。
准则1:从平面布局开始
除非你知道代码库会很大,并且需要某种预先的包布局,否则最好从平面布局开始,简单地将所有的go文件放在根文件夹中。
这是一个来自github.com/patrickmn/g…软件包的文件结构。
❯ 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服务器,用这种模式看起来会是这样。
$ tree
.
├── cmd
│ └── personal-note-api
│ └── main.go
...
├── Makefile
├── go.mod
└── go.sum
要考虑使用这种模式的情况是:
- 你可能想在一个资源库中拥有多个二进制文件。你可以在cmd下创建任意多的文件夹,只要你想。
- 有时需要将主包移到其他地方,以避免循环依赖。
准则4:按其责任组织包装
我们已经研究了何时以及如何制作子包,但还有一个大问题:它们应该如何分组?我认为这是最棘手的部分,需要一些时间来适应,主要是因为它在很大程度上受应用程序的领域关注和功能影响。深入了解代码的作用是做出决定的必要条件。
对此,最常见的建议是按照责任来组织。
对于那些熟悉MVC框架的人来说,拥有"model"、“controller”、“service"等包可能感觉很自然。建议不要在go中使用它们。
相反,我们建议使用更多的责任/领域导向的包名,如"用户"或"事务”。
准则5:按依赖关系对子包进行分组
根据它们的依赖关系来命名包,例如"redis"、“kafka"或"pubsub”,在某些情况下提供了明确的抽象性。
想象一下,你有一个这样的接口:
package bestpractice
type User struct {}
type UserService interface {
User(context.Context, string) (*User, error)
}
而你在redis子包里有一个服务,它是这样实现的:
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的建议是,在消费包的时候尽量避免重复。例如,如果我们有一个包,内容是这样的:
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%的情况下,你唯一需要做的就是将调用者提供的上下文传递给需要上下文作为参数的子程序调用。
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:
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)
<-sigchan
cancel()
}()
svr := &ctxpkg.Server{}
svr.Run(ctx) // ← long running process
log.Println("graceful stop")
}
让我们看看"伪"服务器的实现;它实际上什么也没做,但为了演示,它有足够的功能:
type Server struct{}
func (s *Server) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
log.Println("cancel received, attempting graceful stop...")
// clean up process
return
default:
handleRequest()
}
}
}
func handleRequest() {
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
}
它首先进入一个无限的循环。在这个循环中,它检查上下文是否已经在ctx.Done()通道上使用select取消了。如果取消了,它就清理进程并返回。如果没有,它就处理一个请求。一旦请求被处理,它就回到循环中,再次检查上下文。
这里的重点是通过使用context.Context,你可以允许进程在他们准备好的时候返回。
设置超时
第二种用法是为操作设置超时。想象一下,你正在向第三方发送HTTP请求。如果由于某些原因,如网络中断,请求的时间超过预期,你可能想取消请求,以防止整个过程挂起。通过context.WithTimeout,你可以为这些情况设置超时。
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() // ← cancel should be called even if timeout didn't happen
SendRequest(ctx) // ← subroutine that can get stuck
}
在SendRequest方法中,在不同的goroutine中发送请求后,它同时在ctx.Done()通道和响应通道中等待。当超时发生时,你会从ctx.Done()通道得到一个信号,这样你就可以从该函数中退出,而不用等待响应。
func SendRequest(ctx context.Context) {
respCh := make(chan interface{}, 1)
go sendRequest(respCh)
select {
case <-ctx.Done():
log.Println("operation timed out!")
case <-respCh:
log.Println("response received")
}
}
func sendRequest(ch chan<- interface{}) {
time.Sleep(60 * time.Second)
ch <- struct{}{}
}
context包也有context.WithDeadline();不同的是,context.WithTimeout需要time.Duration,而context.WithDeadline()需要time.Time。
存储/检索请求的相关值
上下文的最后一种用法是在上下文中存储和检索与请求相关的值。例如,如果服务器收到一个请求,你可能希望在请求过程中产生的所有日志行都有请求信息,如路径和方法。在这种情况下,你可以创建一个日志记录器,设置请求相关的信息,并使用context.WithValue将其存储在上下文中。
var logCtxKey = &struct{}{}
func handleRequest(w http.ResponseWriter, r *http.Request) {
method, path := r.Method, r.URL.Path
logger := log.With().
Str("method", method).
Str("path", path).
Logger()
ctxWithLogger := context.WithValue(r.Context(), logCtxKey, logger)
...
accessDatabase(ctxWithLogger)
}
在某个地方,你可以用同样的键把记录器从上下文中取出来。例如,如果你想在数据库访问层留下一个日志,你可以这样做:
func accessDatabase(ctx context.Context) {
logger := ctx.Value(logCtxKey).(zerolog.Logger)
logger.Debug().Msg("accessing database")
}
这产生了以下包含请求方法和路径的日志行。
{"level":"debug","method":"GET","path":"/v1/todo","time":"2022-11-15T15:44:53Z","message":"accessing database"}
就像我说的,你需要使用这些上下文API的情况并不常见,但了解它的作用真的很重要,这样你就知道在哪种情况下你真的需要注意它。
让我们进入最后一个实践。
实践3:了解 Table Driven Test(表格驱动方法)
表驱动测试是一种组织测试的技术,更多地关注输入数据/模拟/存根和预期输出,而不是断言,这有时可能是重复的。
我选择这种方法的原因不仅是因为这是一种常用的做法,而且这也使我在编写测试时更有乐趣。在编写测试时有一个良好的动机,对于有一个快乐的编码生活是非常重要的,不用说编写可靠的代码。
让我们来看看一个例子。
假设我们有一个餐厅的数据类型,它有一个方法,如果它在某一特定时间开放,则返回真。
type Restaurant struct {
openAt time.Time
closeAt time.Time
}
func (r Restaurant) IsOpen(at time.Time) bool {
return (at.Equal(r.openAt) || at.After(r.openAt)) &&
(at.Equal(r.closeAt) || at.Before(r.closeAt))
}
让我们为这个方法写一些测试。
如果我们在餐厅开门的时候访问了它,我们期望它是开放的。
func TestRestaurantJustOpened(t *testing.T) {
r := Restaurant{
openAt: time.Date(2022, time.January, 17, 12, 0, 0, 0, time.UTC),
closeAt: time.Date(2022, time.January, 17, 22, 0, 0, 0, time.UTC),
}
input := r.openAt
got := r.IsOpen(input)
assert.True(t, got)
}
到目前为止还不错。让我为边界条件添加更多测试:
func TestRestaurantBeforeOpen(t *testing.T) {
r := Restaurant{
openAt: time.Date(2022, time.January, 17, 12, 0, 0, 0, time.UTC),
closeAt: time.Date(2022, time.January, 17, 22, 0, 0, 0, time.UTC),
}
input := r.openAt.Add(-1 * time.Second)
got := r.IsOpen(input)
assert.False(t, got)
}
func TestRestaurantBeforeClose(t *testing.T) {
r := Restaurant{
openAt: time.Date(2022, time.January, 17, 12, 0, 0, 0, time.UTC),
closeAt: time.Date(2022, time.January, 17, 22, 0, 0, 0, time.UTC),
}
input := r.closeAt
got := r.IsOpen(input)
assert.True(t, got)
}
你可能已经注意到,这些测试之间的差异非常小,我认为这是表驱动测试的一个典型用例。
表驱动测试的介绍
现在让我们看看,如果用表驱动的方式来写,会是什么样子:
func TestRestaurantTableDriven(t *testing.T) {
r := Restaurant{
openAt: time.Date(2022, time.January, 17, 12, 0, 0, 0, time.UTC),
closeAt: time.Date(2022, time.January, 17, 22, 0, 0, 0, time.UTC),
}
// test cases
cases := map[string]struct {
input time.Time
want bool
}{
"before open": {
input: r.openAt.Add(-1 * time.Second),
want: false,
},
"just opened": {
input: r.openAt,
want: true,
},
"before close": {
input: r.closeAt,
want: true,
},
"just closed": {
input: r.closeAt.Add(1 * time.Second),
want: false,
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
got := r.IsOpen(c.input)
assert.Equal(t, c.want, got)
})
}
}
首先,我声明了测试目标。根据情况,它可以在每个测试案例里面。
接下来,我定义了测试用例。我在这里使用了map,所以我可以使用测试名称作为map键。测试用例结构包含每个情况下的输入和预期输出。
最后,我对测试用例进行了循环,并对每个测试用例运行了子测试。断言与之前的例子相同,但这里我从测试用例结构中获取输入和预期值。
以表格驱动方式编写的测试很紧凑,重复性较低,如果你想添加更多的测试,你只需要添加一个新的测试用例,无需更多的断言。
去尝试吧!
一方面,了解社区中共享的实践很重要。go社区足够大,很容易找到它们。你可以找到博客文章、讲座、YouTube视频等等。另外,说到go,很多实践都来自go的标准库。表驱动测试就是一个很好的例子。go是一种开源语言。阅读标准包代码是个好主意。
另一方面,仅仅知道它们并不能让你感到舒服。到目前为止,学习最佳实践的最好方法是在你现在工作的真实代码库中使用它们,看看它们有多合适,这实际上是我学习go实践的方式。所以,多写go,不要害怕犯错。