[Golang] 在 Golang 的设计中需要注意的事项
我在设计Golang时个人的注意事项记在下面。
目标是较大规模的Web应用程序。如果是小型工具可能会有所差异。
欢迎提供其他类似于“我注意到这一点!”的信息!
说到Golang的设计原则,我觉得Clean Architecture独树一帜,但却没有明确的指导方针。个人觉得如果有更多类似的指导原则就好了。
構造方面的东西 de
1-1. 清洁架构
当然,这是Golang的标准配置指南。它涵盖了包结构、接口使用方法、包之间的依赖关系等必要内容。
1-2. 依赖于接口
在Golang模块之间的引用中,务必使用接口。这不仅是Clean Architecture的必要条件,而且还会导致在单元测试中无法替换模拟对象!
1-3. DI
让我们确保在模块的构造函数中传递模块间的引用。
然后,在main.go(或者说逻辑的外部)中注入依赖性。
1-4. 函数、闭包、模块是三种可能的选项。
先考虑函数,然后考虑闭包。
与使用接口的模块依赖注入相比,使用函数类型的依赖注入更简单。
如果需要在某个方法之外持续保持状态,并且有一组方法集合的话,请使用模块(结构体)。
1-5. 避免包裹之间的双向依赖关系
為了避免循環引用問題,除了採用乾淨的架構,我們需要事先定義依賴方向,確保包之間的依賴關係是單向的。
1-6. 环境依赖
我认为在本地环境、开发环境、阶段环境和生产环境等各种环境中,我们可能需要根据环境进行不同的操作。基本上,我们会根据环境变量来改变行为,但如果不想这样,我们可以使用构建标签来切换代码。
2. 模块设计
2-1. 界面
在接口中定义的值(例如枚举定义和函数选项等),务必写在接口的一侧。如果写在实现一侧会导致循环导入问题!
请不要忘记,接口是“使用方的东西”,而不是实现方的东西!
2-2. 默认值 (default value)
在Golang中,不存在null这样的概念,而是将””(空字符串)和0视为null。因此,经常会遇到处理默认值的困扰。
我们可以使用Functional Option(函数选项)来解决这个问题。
2-3. 继承与面向对象
虽然有人说Golang不是面向对象的语言,但通过嵌入类型,可以实现继承。
只是不继承类型,功能将会被继承。
当进行此操作时,会产生与通常继承中反模式所提到的相同风险。请务必谨慎处理嵌入式的处理。
如果不了解面向对象,就不应该使用嵌入式类型。
2-4. 将其成为一个函数
我认为,Golang是一种非常便于处理函数作为值的语言。请积极地声明函数接口类型!
type OptionalFunction func(*Option)
func hoge(a int, options ...OptionalFunction) err {
}
2-5. 枚举
在Golang中,没有在语言级别上提供安全的枚举选项。
虽然不能创建enum类,但我认为可以创建类型别名并使用const进行定义。
type ErrorLevel int
const (
Critical ErrorLevel = 0
Error ErrorLevel = 1
Warning ErrorLevel = 2
Info ErrorLevel = 3
)
// 個人的にはあまりiotaが好きじゃない。べた書きしたい。
由於創建未定義的const值很容易,所以請務必撰寫例外處理。
switch (level) {
case Critical:
//pass
case Error:
//pass
default:
return nil, errors.New("illegal error level")
}
2-6. Type safety (类型安全性)
Golang是一种类型安全的语言。我们应该设计并利用类型安全性。具体来说,除非非常需要,否则尽量避免下行转型。
2-7. 接口{}类型
活用interface{}类型也很重要,虽然这似乎说的是与类型安全相反的事情。在可以忘记类型的时候,要积极地去忘记它。
遗忘的时候是「即使忘记也无需回想起来的时候」。
2-8. 上下文
在设计Golang时,处理上下文是非常重要的。
-
- contextに付与するvalueは、contextと生存期間が一致するモノでなければなりません。
- contextは、時間的な範囲を表します。structの属性にすべきではありません。必ずメソッドの第一引数で受け渡しましょう。
3. 编码
3-1. 变量
简化变量
不必要的变量不要声明。尽量减少使用变量。
将其设为const
在Golang中并没有像JavaScript那样的const/let,但我们应尽可能地避免使用类似let的变量,而是将变量作为const处理。
不要将仅用于测试的代码混入生产代码中。
请不要在代码中使用if语句来切换无关的production代码。
请确保能进行DI,并通过环境变量或者构建标签进行切换。
用明确的方式写下来
if len(str(arg)) > 0:
这是一个Python的例子,你一眼就能看出它是在做什么吗?
编程是一种表达意图的文字形式。
我们应该编写直接和明确地表达出我们想做的事情的代码。
如果代码不明确,读者将需要花费额外时间来理解想要实现的目标,并且可能会引入错误。
3-4. 不要进行半途而废的抽象化处理
func doSomething(arg string) error {
if arg == "a" {
// doSomething_a
}
if arg == "b" {
// doSomething_b
}
if arg == "c" {
// doSomething_c
}
if arg == "a" {
// doSomething_a_2nd
}
if arg == "b" {
// doSomething_b_2nd
}
if arg == "c" {
// doSomething_c_2nd
}
}
我偶尔会看到这样的函数。为什么呢?
func doSomething_a() error {
// doSomething_a
// doSomething_a_2nd
}
func doSomething_b() error {
// doSomething_b
// doSomething_b_2nd
}
func doSomething_c() error {
// doSomething_c
// doSomething_c_2nd
}
不这样吗?
我理解doSomething这个函数想要抽象化a/b/c。然而,函数内部的处理完全没有抽象化。
在这种情况下,我们应该将函数切割为只涉及可以不使用if语句编写的部分。
我认为代码中存在很多if语句是“不完全的抽象化”病的症状。
3-5. 收藏
没有set
Golang中仅存在array、slice和map,最令人困扰的是set。
如果放弃set,必然会遭遇嵌套循环的困境。
当需要使用集合(set)时,我们可以使用map[?]struct{}。但是,如果列表长度较短,即使使用双重循环也不会太慢。我认为也有选择不使用集合的选项。
不进行无用的扩展
在使用数组或映射时,请不要忘记考虑到”容量”的问题。如果忽视这点,会频繁发生不必要的扩展。如果事先已经知道所需容量,就应当事先分配好相应的容量。
items := make([]int, 0, 10)
通过这个方式,你可以创建一个已经分配了容量为10的切片。对于映射(map)也是同样适用的。
3-6. 让我们使用defer
在Golang中,可以使用defer来表达类似于Python中的with的上下文。
defer s.Close()
- deferが実行されるのは「関数の終わり」だと言う事を忘れずに。
3到7之间的json
学习实现MarshalJSON和UnmarshalJSON方法是Golang设计中的必要事项。
然而,我认为过于纠结于MarshalJSON/UnmarshalJSON也是个问题。要避免过度。
json的输入
处理嵌套深度的Golang的JSON非常困难,如果按照普通方式编写代码,会充满很多向下转换的代码。
「我們會說手腕的力量。」
我认为这个非常难读。
在Golang内部,我们应该注重对数据的类型安全处理,并且将必要的信息存储在结构体中。
另外,如果只需要深层嵌套的JSON的特定部分,也可以使用类似JsonPath的技术来实现。
将JSON数据输出
将struct转换为json并不困难,但并不要求输出的json中包含所有的struct定义。
如果不需要严格的类型定义,可以考虑使用模板转换来生成json文本。
克隆或复制
如果需要在相同的结构体之间进行复制或在不同的结构体之间进行拷贝,考虑通过JSON进行拷贝。
逐个传输属性的代码容易导致错误。
将来可能添加属性时,由于可能发生转移漏洞,因此尽量避免属性转移。比如这样。
func Copy(src interface{}, dest interface{}, allowUnknownFields bool) error {
data, err := json.Marshal(src)
if err != nil {
return errors.WithStack(err)
}
dec := json.NewDecoder(bytes.NewReader(data))
if !allowUnknownFields {
dec.DisallowUnknownFields()
}
if err = dec.Decode(&dest); err != nil {
return errors.WithStack(err)
}
return nil
}
当allowUnknownFields参数为false时,如果要复制的属性不足,将会产生错误。这样可以防止遗漏复制。
嗯,如果你真的很在意性能的话,也许可以为模型准备一个复制构造函数的方法。
MarshalJSON/UnmarshalJSON – 编码/解码JSON
例如,您可以自由地更改如 time.Time 这样的现有类型的 JSON 表示方式。
type MyTime time.Time
func (t *MyTime) MarshalJSON() ([]byte, error) {
}
func (t *MyTime) UnmarshalJSON([]byte) error {
}
通过实现这一接口,可以将时间.Time作为值并自定义其JSON表示。
3-8. 异步
学习goroutine和channel在golang设计中是必不可少的。尤其是为了编写流式IO处理,需要使用goroutine和io.Pipe进行处理。
不使用锁,而是使用通道
在写异步处理时,其他语言通常需要使用锁,但在golang中,我们可以使用通道而不是锁。
具体来说,我们可以考虑使用通道进行线程间通信,而不是使用锁来读写共享内存。
3-9. 评论
在Golang中,似乎没有像pydoc或javadoc那样写详细注释的文化。
基本上,我们应该努力写一些即使没有注释也能理解的代码。
当然,如有需要,尽快写下您的评论!尤其是对于他人可能使用的共享库代码,务必撰写详细的README注释和可理解的测试方式。
3-10. 考试
我们应该为每个模块和函数编写单元测试。
让我们为每个项目确定使用gomock、mockery等mock库。
测试不仅仅是用来确认代码是否正常运行,也是用来确认使用代码的方法的。
换句话说,测试也是评论的一部分。
使用消息进行测试和使用状态进行测试
在测试中,有两种主要方法。一种是基于消息的测试,另一种是基于状态的测试。
基于消息的测试使用假的请求和响应内容来进行测试。
基于状态的测试则是通过准备真实的存储设施(如RDB),并确认“最终的状态是怎样的”来进行测试。
我认为,在单元测试中应尽可能使用消息来编写测试。但是,有些情况确实需要编写使用状态的测试。
在查询复杂的情况下,如RDB等,使用消息进行测试很难证明其正确性。
使用状态进行测试时,应将其限制在最小范围内进行。而在其他地方,我认为使用消息进行测试是更好的选择。
猴子修补
如果无法进行模拟DI,就需要使用monkey patch。不过,由于monkey patch的操作往往存在问题,因此最好尽量限制使用。
4. (增加)
4-1. 除外
Golang的异常是值。它是一种特别的语言,没有像try-catch这样处理异常的特殊机制。
尽早返回
如果发生异常,似乎在Go语言中”立即返回”是一种惯例。
只有在想要终止进程时才使用”panic”。
虽然通过 panic recover 可以实现类似于 try catch 的功能,但我认为这种用法不被推荐!为了优雅地关闭和记录 panic 的原因,请不要忘记在 main.go 中使用 recover!
使用pkg/errors包而不是errors包。
pkg/errors会创建具有堆栈跟踪信息的异常对象。
普通的errors包的信息量通常不足。
务必使用pkg/errors。
没有原因句
在其他语言中,通常都会有为了异常的重新抛出而提供的cause机制,但在Golang中并没有。我认为只记录下cause以便重新抛出异常可能足够了,但考虑创建自定义异常可能更好。
不放弃例外
只要咬紧牙关,不放过任何例外,就应该陷入恐慌。
首先,到目前为止(正在编辑中)。
虽然Golang特有的东西与其他的混在一起,但是应该剔除它们吗?