引言

从错误中学习效率更高,而且从错误失败的场景下学习,往往比直接交代正确性的理论知识,没有上下文的结果去 记忆巩固知识,在错误失败场景下往往印象更深刻;可以帮助更好地避免错误并做出更明智、更有意识的决定,因为现在了解了错误背后的基本原理,有种该死的,恍然大悟的感觉,拨开雨雾见月明;

涵盖了可能导致各种软件错误的案例,包括数据竞争、泄漏、逻辑错误和其他缺陷。虽然准确的测试应该是尽早发现此类错误的一种方式,但有时可能会因为时间限制或复杂性等不同因素而错过案例。因此,使用Golang开发,确保避免常见错误至关重要。

有些坑可能曾经踩过,通过这些mistakes产生共鸣,加深印象,同时可以继续追加一些新的坑来填充,比如系统性能调优,包括IO, 网络,数据编解码压缩,分布式系统,业务系统组织架构,逐步学习实践试错过程。学习笔记开个头。

代码地址:https://github.com/teivah/100-go-mistakes ; 可以直接查看里面的示例,进行CR,如果知道坑的缘由,说明已经避坑了 :)

笔记

1.变量隐藏

需要理解golang中变量的作用域,在块中声明的变量名可以在内部块中重新声明(:=);常见于err变量名的重用的情况,需要考虑是否需要重新命名;使用 vet lint工具进行检测

go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow
go vet --vettool=$(which shadow) {go file}

2.不必要的嵌套代码(工程规范)

这个就是代码风格问题了,google code style里,或者代码质量中经常会提到的,和编程语言无关联,尽量保持在一层嵌套(happy path),经常出现的判断前置;

3.滥用初始化函数

init 函数是golang规定package初始化函数,不能直接显示调用, 而且调用顺序按照包名字母顺序依次调用执行,即使未使用(_), 也会调用包的init方法;还可以在包中声明多个init函数依次执行; 所以对于初始化依赖资源服务,需要单测的场景,不能使用包中的init函数,而是使用自定义的初始化函数,并且可以返回具体初始化实例,可用于依赖注入,工程化经常会使用; 但是仅仅是简单的初始化,静态的罗列,彼此没有关联, 可以用init初始化, 比如 net/http/pprof包直接在init中初始化了路由注册的服务接口函数句柄;

4.过度使用 getter 和 setter(工程规范)

这个在面向对象语言c++/java中经常会出现的情况,将私有成员封装成方法调用,隐藏内部一些逻辑细节; golang不会强制约束这样做,比如time.Timer结构 成员C 是公开的,相对比较自由;另外如果使用getter封装私有成员的函数名,不需要Get前缀,这个仅仅是 统一的代码风格问题,遵循业界标准Google Style就行;

5.interface 污染

设计抽象接口函数的粒度,记住Go 中的一个著名谚语 ( https://www.youtube.com/watch?v=PAAkCSZUG1c&t=318s )

The bigger the interface, the weaker the abstraction.

—Rob Pike

As Einstein said, “Everything should be made as simple as possible, but no simpler.”

接口可以进行组合使用,尽量保持单一原则吧~ golang的设计深受UNIX哲学的影响( Rob Pike 是贝尔实验室unix小组成员);

接口抽象可以和实现解耦,进行依赖反转,代码不依赖具体实现,新增或者更改方法不影响整体抽象流程结构,也方便整体mock测试,Liskov 替换原则( Robert C. Martin 的 SOLID 设计原则中的L),在使用golang实现DDD业务领域模型中经常会使用到;

接口限制,比如一个配置结构体只需要读配置内容, 则这个结构体只需要一个只读接口成员,限制结构体的接口行为,以便这个结构体可以以组合方式加入新的接口行为

对于不需要抽象的场景,则不应该使用interface来抽闲一层,直接调用对应实现函数即可,无需过度依赖接口设计,在代码中创建抽象时应该谨慎——抽象应该被发现,而不是创建。

Don’t design with interfaces, discover them.

—Rob Pike

6.生产者侧 interface

就是提供给外部使用的包中没有抽象时,不应该过度设计接口,在接口中定义过多方法,这些方法在消费接口端使用时不会用到这些过多的方法,存在过度的接口设计;golang允许在包外消费侧来定义接口,如果在生产侧定义接口,抽象应该是被发现是通用的,而且接口中方法粒度尽可能的小,方便消费侧使用方组合;

7.返回 interface

Be conservative in what you do, be liberal in what you accept from others.

—Transmission Control Protocol

其实还是接口抽象时的原则: 抽象应该被发现,而不是创建。总之,在大多数情况下,不应该返回接口,而是返回具体的实现。否则,由于包依赖性,它会使设计更加复杂,并且会限制灵活性,因为所有使用端都必须依赖相同的抽象。同样,结论与前面的部分类似:如果知道(不是预见)抽象对使用端有帮助,可以考虑返回一个接口。否则,不应该强制抽象;他们应该被使用端发现。如果使用端出于某种原因需要抽象实现,它仍然可以在使用端执行此操作。

8.any(interface{}) says nothing

在 Go 中,指定零个方法的接口类型称为空接口,interface{}. 在 Go 1.18 中,预先声明的类型any成为空接口的别名;因此,所有interface{}出现的地方都可以替换为any。在许多情况下,any可以被认为是过度概括;正如 Rob Pike 所提到的,它没有传达任何信息(https://www.youtube.com/watch?v=PAAkCSZUG1c&t=7m36s );

any(interface{})如果确实需要接受或返回任何可能的类型(例如,当涉及到marshaling or formatting时),它会很有帮助。一般来说,应该不惜一切代价避免过度概括编写的代码。如果能提高其他方面(例如代码表达能力),也许一点点重复代码偶尔会更好。

9.对何时使用泛型感到困惑

尽管泛型在特定情况下会有所帮助,但是应该谨慎选择何时使用它们以及何时不使用它们。一般来说,如果要回答何时不使用泛型,可以找到与何时不使用接口的相似之处。事实上,泛型引入了一种抽象形式,必须记住,不必要的抽象会带来复杂性。

同样,不要用不必要的抽象污染代码,专注于解决具体问题。这意味着不应该过早地使用类型参数。等到即将编写样板代码时再考虑使用泛型。而且泛型现在还存在一些性能上的问题,可以参考: Generics can make your Go code slower

10.没有意识到类型嵌入可能存在的问题

想要封装在结构中并使外部客户端不可见的东西,在这种情况下不应该将其设为嵌入字段;

如果决定使用类型嵌入,需要牢记两个主要约束:

  • 它不应该单独用作一些语法糖来简化对字段的访问(例如Foo.Baz()代替Foo.Bar.Baz())。如果这是唯一的理由,就不要嵌入内部类型,而是使用字段。
  • 它不应该提升想要从外部隐藏的数据(字段)或行为(方法):例如,如果它允许客户端访问应该对结构保持私有的锁定行为。

比如对一个开源的日志库进行二次封装,可以直接嵌入至结构体中,使用对应日志库结构体中的公有方法。

11.不使用功能选项模式(工程规范)

GO中不像python有默认参数,通过参数可变配置来new一个对象;如果GO实现,可变配置可以通过… Option方式配置,配置项通过With* 设置返回闭包函数操作, 在初始化对象时传入With** 方法对配置项进行设置,参数个数可选,未设置则使用默认配置项;这个方式经常用于程序启动时通过加载配置进行初始化操作。

12.项目组织不当(工程规范)

GO官方没有提供标准的项目组织结构,一般情况使用GitHub golang-standards 组织下的项目布局(https://github.com/golang-standards/project-layout),但是这个不是绝对的,因项目而异稍有不同;主要是项目中的层级不能嵌套太深,而且重要的一点是不能出现循环嵌套引用包; 不要过早封包,抽象一层时,尽量上层依赖下层子目录,子目录高度内聚, 业务逻辑层依赖公共层;如果不确定是否导出一个元素,应该默认不导出它;后面发现需要导出,可以调整代码。对于大型项目,有多个子模块,可以使用workspace进行管理;

Tips: 如果使用DDD clean-architecture 架构思想开发业务,可以使用 go-clean-arch 这个项目结构。

13.创建实用程序包(工程规范)

包命名,应该是包对应功能,而不是通用意义上的名称比如utils、common或base,这些可以作为具体包的归档。类似net包中的组织形式

14.忽略包名称冲突(工程规范)

变量名与包名冲突,应该避免,这个staticcheck 工具就可以直接检测出来提示; 可以使用包别名;

15.缺少代码文档(工程规范)

这个属于代码工程规范问题了,对于开放出去的包,需要提供代码文档,表明其目的和内容,方便使用者查看文档,其实这个因人而异了,最好结合源码和用例来理解,有时候代码更新,文档却未更新,在代码中经常可以看到(各种pr修改)。 最好类似rust那样,在文档中加入一些测试用例;对于后续不维护的方法,可以通过// Deprecated:这种方式使用注释弃用导出的元素, 这个不同的开发语言都有类似功能;

16.不使用 linters

应该使用Go提供的检测工具来写出统一质量高的代码,Linters 类工具和格式化程序是提高代码库质量和一致性的有效方法。花时间了解这些工具,并确保自动执行它们(例如 CI 或 Git 预提交挂钩,比如开源项目中在经常使用staticcheck 和 golangci-lint 进行分析,golangci-lint run --out-format=github-actions --path-prefix=. -E gofumpt);学会使用工具分析代码质量问题;

概括

  • 避免隐藏变量有助于防止错误,例如引用错误的变量或混淆读者。
  • 避免嵌套级别并保持快乐路径在左侧对齐,可以更轻松地构建心智代码模型。
  • 初始化变量时,请记住 init 函数具有有限的错误处理,并使状态处理和测试更加复杂。在大多数情况下,初始化应作为特定函数处理。
  • 强制使用 getter 和 setter 在 Go 中不是惯用的。务实并在效率和盲目遵循某些成语之间找到正确的平衡应该是要走的路。
  • 抽象应该被发现,而不是被创造。为避免不必要的复杂性,请在需要时创建接口,而不是在预见需要时创建接口,或者至少可以证明抽象是有效的。
  • 将接口保留在客户端可以避免不必要的抽象。
  • 为了防止在灵活性方面受到限制,函数在大多数情况下不应返回接口,而应返回具体实现。相反,函数应该尽可能接受接口。
  • 仅any在需要接受或返回任何可能的类型时使用,例如 json. Marshal. 否则,any不会提供有意义的信息,并可能通过允许调用者调用具有任何数据类型的方法而导致编译时问题。
  • 依赖泛型和类型参数可以防止编写样板代码来分解元素或行为。但是,不要过早地使用类型参数,只有当你看到对它们的具体需求时才使用。否则,它们会引入不必要的抽象和复杂性。
  • 使用类型嵌入也有助于避免样板代码;但是,请确保这样做不会导致某些字段本应隐藏的可见性问题。
  • 要以 API 友好的方式方便地处理选项,请使用功能选项模式。
  • 遵循诸如 project-layout 之类的布局可能是开始构建 Go 项目的好方法,尤其是当正在寻找现有约定来标准化新项目时。
  • 命名是应用程序设计的关键部分。创建诸如common、util和 之类的包shared不会为读者带来太多价值。将此类包重构为有意义且特定的包名称。
  • 为避免变量和包之间的命名冲突,从而导致混淆甚至错误,请为每个变量使用唯一的名称。如果这不可行,请使用导入别名来更改限定符以区分包名称和变量名称,或者考虑一个更好的名称。
  • 为了帮助客户和维护者理解的代码的用途,记录导出的元素。
  • 要提高代码质量和一致性,请使用 linters 和格式化程序。