go最佳实践:如何舒适地编码

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
日志服务 SLS,月写入数据量 50GB 1个月
简介: go最佳实践:如何舒适地编码

在这篇文章中,我想根据我过去几年写go的经验,介绍三个go最佳实践。

简要:

  • 什么是 "最佳 "做法?
  • 实践1:package布局
  • 实践2:熟悉context.Context
  • 实践3:了解TDD(表格驱动方法)
  • 去尝试吧!

什么是 "最佳 "做法?

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


为了写这篇文章,我带着一个问题看了go的实践,那就是 "它在多大程度上让我对写Go感到舒服?",当我说"语言的最佳实践是什么?"时,那是在我刚接触这门语言,还没有完全适应写这门语言的时候。


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


这就是我选择"最佳"做法的原因。现在是该上手的时候了。

实践1:package布局

当我开始学习go时,最令人惊讶的事情之一是,go没有像LaravelPHPExpressNode那样的网络框架。这意味着在编写网络应用时,如何组织你的代码和包,完全取决于你。虽然在如何组织代码方面拥有自由是一件好事,但如果没有指导原则,很容易迷失方向。


另外,这也是最难达成一致的话题之一;"最佳 "的含义很容易改变,这取决于程序处理的业务逻辑或代码库的大小/成熟度。即使是同一个代码库,当前的软件包组织在6个月后也可能不是最好的。


虽然没有单一的做法可以统治一切,但为了补救这种情况,我将介绍一些准则,希望它们能使决策过程更容易。

准则1:从平面布局开始

除非你知道代码库会很大,并且需要某种预先的包布局,否则最好从平面布局开始,简单地将所有的go文件放在根文件夹中。

这是一个来自https://github.com/patrickmn/go-cache软件包的文件结构。

❯ 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:了解TDD(表格驱动方法)

表驱动测试是一种组织测试的技术,更多地关注输入数据/模拟/存根和预期输出,而不是断言,这有时可能是重复的。


我选择这种方法的原因不仅是因为这是一种常用的做法,而且这也使我在编写测试时更有乐趣。在编写测试时有一个良好的动机,对于有一个快乐的编码生活是非常重要的,不用说编写可靠的代码。


让我们来看看一个例子。


假设我们有一个餐厅的数据类型,它有一个方法,如果它在某一特定时间开放,则返回真。

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

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
9月前
|
中间件 Go 数据库
Go开发者必读:Gin框架的实战技巧与最佳实践
在当今快速发展的互联网时代,Web开发的需求日益增长。Go语言以其简洁、高效、并发性强的特点,成为了开发者们的首选。而在Go语言的众多Web框架中,Gin无疑是其中的佼佼者。本文将深入探讨Gin框架的特性、优势以及如何利用Gin构建高性能的Web应用。
|
9月前
|
运维 监控 Go
Go语言微服务实战与最佳实践
【2月更文挑战第14天】本文将深入探讨使用Go语言进行微服务实战中的最佳实践,包括服务拆分、API设计、并发处理、错误处理、服务治理与监控等方面。通过实际案例和详细步骤,我们将分享如何在Go语言环境中构建高效、稳定、可扩展的微服务系统。
|
6月前
|
缓存 监控 Kubernetes
go-zero解读与最佳实践(上)
go-zero解读与最佳实践(上)
|
6月前
|
程序员 测试技术 Go
用 Go 编写简洁代码的最佳实践
用 Go 编写简洁代码的最佳实践
|
6月前
|
存储 缓存 Java
涨姿势啦!Go语言中正则表达式初始化的最佳实践
在Go语言中,正则表达式是处理字符串的强大工具,但其编译过程可能消耗较多性能。本文探讨了正则表达式编译的性能影响因素,包括解析、状态机构建及优化等步骤,并通过示例展示了编译的时间成本。为了优化性能,推荐使用预编译策略,如在包级别初始化正则表达式对象或通过`init`函数进行错误处理。此外,简化正则表达式和分段处理也是有效手段。根据初始化的复杂程度和错误处理需求,开发者可以选择最适合的方法,以提升程序效率与可维护性。
71 0
涨姿势啦!Go语言中正则表达式初始化的最佳实践
|
7月前
|
存储 Go API
一个go语言编码的例子
【7月更文挑战第2天】本文介绍Go语言使用Unicode字符集和UTF-8编码。Go中,`unicode/utf8`包处理编码转换,如`EncodeRune`和`DecodeRune`。`golang.org/x/text`库支持更多编码转换,如GBK到UTF-8。编码规则覆盖7位至21位的不同长度码点。
203 1
一个go语言编码的例子
|
6月前
|
存储 缓存 安全
|
6月前
|
Kubernetes Go 数据库
go-zero 分布式事务最佳实践
go-zero 分布式事务最佳实践
|
9月前
|
JSON JavaScript 前端开发
Golang深入浅出之-Go语言JSON处理:编码与解码实战
【4月更文挑战第26天】本文探讨了Go语言中处理JSON的常见问题及解决策略。通过`json.Marshal`和`json.Unmarshal`进行编码和解码,同时指出结构体标签、时间处理、omitempty使用及数组/切片区别等易错点。建议正确使用结构体标签,自定义处理`time.Time`,明智选择omitempty,并理解数组与切片差异。文中提供基础示例及时间类型处理的实战代码,帮助读者掌握JSON操作。
227 1
Golang深入浅出之-Go语言JSON处理:编码与解码实战
|
9月前
|
安全 Go 调度

热门文章

最新文章