raphael-wild-JnPUVGDS2nk-unsplash.jpg
在这篇文章中,我想依据我过去几年写go的经历,介绍三个go最佳实践。

简要:

  • 什么是 “最佳 “做法?
  • 实践1:package布局
  • 实践2:了解context.Context
  • 实践3:了解Table Driven Test(表格驱动办法)
  • 去测验吧!

什么是 “最佳 “做法?

有很多做法:你能够自己想出来,在互联网上找到,或者从其他言语中拿来,但因为其主观性,并不总是容易说哪一个比另一个好。”最佳”的意义因人而异,也取决于其布景,例如网络应用的最佳实践或许与中间件的最佳实践不一样。

为了写这篇文章,我带着一个问题看了go的实践,那便是 “它在多大程度上让我对写Go感到舒畅?”,当我说”言语的最佳实践是什么?”时,那是在我刚触摸这门言语,还没有彻底习惯写这门言语的时分。

当然,还有更多的做法,我在这儿不做介绍,但假如你在写go时知道这些做法,就会十分有用,但这三个做法对我在go中的信心影响最大。

这便是我挑选”最佳”做法的原因。现在是该上手的时分了。

实践1:package布局

当我开始学习go时,最令人惊奇的作业之一是,go没有像LaravelPHPExpressNode那样的网络结构。这意味着在编写网络应用时,怎么安排你的代码和包,彻底取决于你。尽管在怎么安排代码方面具有自由是一件好事,但假如没有辅导原则,很容易迷失方向。

另外,这也是最难达成共同的话题之一;”最佳 “的意义很容易改变,这取决于程序处理的业务逻辑或代码库的大小/成熟度。即便是同一个代码库,当时的软件包安排在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文件夹下。

在根部

在根目录下创立一个带有软件包称号的文件夹,并将一切相关文件移到该文件夹下。这样做的优点是:

  • 没有深层次/嵌套的目录
  • 导入途径不杂乱

缺点是根文件夹会变得有点乱,特别是当有其他文件夹如scriptsbindocs时。

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)
        ...
}

假如顾客(大概是主函数)只依靠于接口,它能够很容易地被替代的完成所取代,如postgresinmemory

附加提示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
        }
        ...
}

尽管如此,因为contextgo程序中随处可见,因此了解何时需求它,以及怎么运用它是十分重要的。

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,不要惧怕犯错。

最终

假如文章能够给你带来一丝收获,请举起你的手指,给我来个一键三连吧!