Go程序员的坏提示

在使用Java进行了数十年编程之后,最近几年我主要从事Go的研究。 使用Go很棒,主要是因为代码非常易于遵循。 Java通过消除多重继承,手动内存管理和运算符重载,简化了C ++编程模型。 Go做同样的事情,继续朝着简单易懂的编程风格前进,完全消除了继承和函数重载。 简单代码是可读代码,而可读代码是受支持的代码。 这对公司和我的员工都非常重要。
在所有文化中,软件开发都有自己的传奇故事,这些故事被饮水机重新讲述。 我们都听说过,开发人员专注于保护自己的作品不受外界的关注,而不是专注于创建高质量的产品。 他们不需要受支持的代码,因为这意味着其他人将能够理解和修改它。 Go可以吗? 可以使Go代码如此复杂吗? 我马上说-这不是一件容易的事。 让我们看一下可能的选项。
您认为:“您
能用一种编程语言腐蚀多少代码? 是否有可能在Go上编写如此糟糕的代码,以至于其作者在公司中必不可少? »不用担心。 当我还是一个学生的时候,我有一个项目支持一个研究生编写的其他人的Lisp-e代码。 实际上,他设法使用Lisp编写了Fortran-e代码。 代码看起来像这样:
(defun add-mult-pi (in1 in2) (setq a in1) (setq b in2) (setq c (+ ab)) (setq d (* 3.1415 c) d )
有数十个此类代码文件。 他既可怕又聪明。 我花了几个月的时间试图弄清楚。 与此相比,在Go上编写错误的代码只是唾沫。
有很多方法可以使您的代码不受支持,但我们仅介绍其中几种。 做恶事,首先必须学会做善事。 因此,我们首先看一下“好的” Go程序员的写法,然后看相反的事情。
包装不好
包是一个入门的方便主题。 代码组织如何损害可读性?
在Go中,包名称用于引用导出的实体(例如`
fmt.Println`或`
http.RegisterFunc` )。 因为我们可以看到包的名称,所以“好的” Go程序员请确保该名称描述了导出的实体是什么。 我们不应该拥有util包,因为像
util.JSONMarshal这样的名称对我们不起作用-我们需要
json.Marshal 。
“好的” Go开发人员也不会为DAO或模型创建单独的程序包。 对于不熟悉该术语的人来说,DAO是“
数据访问对象 ” —与您的数据库交互的代码层。 我曾经在一家公司工作,其中有6个Java服务导入了相同的DAO库以访问它们共享的相同数据库,因为“
...好吧,您知道,微服务是相同的... ”。
如果您有一个包含所有DAO的单独软件包,则很有可能会在软件包之间获得循环依赖关系,这在Go中是禁止的。 并且,如果您有多个服务将此DAO包作为库连接,则还可能会遇到以下情况:一项服务的更改需要更新所有服务,否则将导致某些问题。 这被称为分布式整体,难以更新。
当您知道包装应该如何工作以及使包装变得更糟时,“开始为恶”就变得很简单。 不良组织您的代码,并给您的软件包起坏名。 将您的代码分成诸如
model ,
util和
dao之类的软件包。 如果您真的想开始制造混乱,请尝试创建包装来纪念您的猫或您喜欢的颜色。 当人们由于尝试使用您的代码而面临循环依赖或分布式整体时,您必须叹口气,睁大眼睛,告诉他们他们做错了...
不合适的接口
现在,我们所有的软件包都已损坏,我们可以继续进行界面了。 Go中的界面与其他语言中的界面不同。 最初没有明确声明该类型实现接口的事实似乎无关紧要,但实际上,它完全颠倒了接口的概念。
在大多数具有抽象类型的语言中,接口是在实现之前或同时定义的。 您至少必须对此进行测试。 如果不提前创建接口,则以后不能插入它而不会破坏使用该类的所有代码。 因为您必须使用指向接口的链接而不是特定类型来重写它。
因此,Java代码通常具有许多方法的巨大服务接口。 然后,实现这些接口的类将使用所需的方法,而忽略其余方法。 编写测试是可能的,但是您要添加更多的抽象级别,并且在编写测试时,您通常会使用工具来生成不需要的那些方法的实现。
在Go中,隐式接口确定您需要使用哪些方法。 代码拥有一个接口,而不是相反。 即使您使用定义了许多方法的类型,也可以指定仅包含所需方法的接口。 使用相同类型的单独字段的另一个代码将定义仅涵盖所需功能的其他接口。 通常,这些接口只有两种方法。
这使您更容易理解您的代码,因为方法声明不仅可以确定所需的数据,还可以准确地指示要使用的功能。 这就是优秀的Go开发人员遵循建议的原因之一:“
接受接口,返回结构” 。
但是,仅仅因为这是一种好的做法,并不意味着您应该这样做...
使您的界面“邪恶”的最佳方法是回到使用其他语言的界面的原则,即 预先定义接口作为被调用代码的一部分。 使用所有服务客户端使用的许多方法来定义巨大的接口。 尚不清楚真正需要什么方法。 这使代码变得复杂,并且众所周知,复杂性是“邪恶的”程序员的最好朋友。
传递堆指针
在解释这意味着什么之前,您需要先进行一些哲学思考。 如果您分神和思考,每个编写的程序都会做同样的事情。 它接收数据,对其进行处理,然后将处理后的数据发送到另一个位置。 这样,无论您是否编写工资系统,接受HTTP请求并返回网页,甚至检查操纵杆以跟踪按钮单击,程序都将处理数据。
如果我们以这种方式查看程序,那么最重要的事情就是确保我们容易理解数据是如何转换的。 因此,在程序执行过程中,尽可能长的时间保持数据不变是一种很好的做法。 因为不变的数据是易于跟踪的数据。
在Go中,我们有引用类型和值类型。 两者之间的区别在于变量是引用数据的副本还是引用数据在内存中的位置。 指针,切片,映射,通道,接口和函数是引用类型,其他所有内容都是值类型。 如果将值类型变量分配给另一个变量,它将创建该值的副本; 更改一个变量不会更改另一个变量的值。
将引用类型的一个变量分配给引用类型的另一个变量意味着它们都共享相同的存储区,因此,如果更改第一个指向的数据,则更改第二个指向的数据。 局部变量和函数参数均是如此。
func main() {
Kind Go开发人员希望使其更易于理解如何收集数据。 他们尝试将值的类型尽可能频繁地用作函数的参数。 在Go中,无法将结构或函数参数中的字段标记为final。 如果函数使用值参数,则更改参数不会更改调用函数中的变量。 被调用函数可以做的就是将值返回给调用函数。 因此,如果通过调用带有值参数的函数来填充结构,则不必担心将数据传输到结构,因为您了解结构中每个字段的来源。
type Foo struct { A int B string } func getA() int { return 20 } func getB(i int) string { return fmt.Sprintf("%d",i*2) } func main() { f := Foo{} fA = getA() fB = getB(fA)
那么,我们如何成为“邪恶”? 非常简单-翻转此模型。
无需调用返回期望值的函数,而是将指针传递给函数中的结构,并允许它们对结构进行更改。 由于每个函数都有其自己的结构,因此找出哪些字段正在更改的唯一方法是查看整个代码。 您可能还具有函数之间的隐式依赖关系-第一个函数传输第二个函数所需的数据。 但是在代码本身中,没有任何内容表明您应该首先调用1st函数。 如果以这种方式构建数据结构,则可以确保没有人会理解您的代码在做什么。
type Foo struct { A int B string } func setA(f *Foo) { fA = 20 }
恐慌堆焊
现在我们开始处理错误。 您可能会认为编写处理错误约75%的程序很不好,我不会说您错了。 Go代码通常填充了从头到脚的错误处理。 当然,处理它们不是那么简单会很方便。 错误会发生,而处理错误会使专业人士与初学者区分开。 错误的错误处理会导致程序不稳定,难以调试和维护。 有时,成为“好”程序员意味着要“努力”。
func (dus DBUserService) Load(id int) (User, error) { rows, err := dus.DB.Query("SELECT name FROM USERS WHERE ID = ?", id) if err != nil { return User{}, err } if !rows.Next() { return User{}, fmt.Errorf("no user for id %d", id) } var name string err = rows.Scan(&name) if err != nil { return User{}, err } err = rows.Close() if err != nil { return User{}, err } return User{Id: id, Name: name}, nil }
许多语言,例如C ++,Python,Ruby和Java,都使用异常来处理错误。 如果出现问题,使用这些语言的开发人员会抛出或引发异常,并期望有一些代码来处理该异常。 当然,程序期望客户端知道在给定位置抛出的可能错误,从而有可能引发异常。 因为,除了(没有双关语意的)Java检查的异常之外,方法签名中没有用语言或函数表示可能发生异常的内容。 那么开发人员如何知道要担心的异常呢? 他们有两个选择:
- 首先,他们可以读取其代码调用的所有库的所有源代码,以及调用被调用的库的所有库,等等。
- 其次,他们可以信任文档。 我可能有偏见,但是个人经验不允许我完全信任该文档。
那么,我们如何将这种罪恶带入现实呢? 当然,滥用恐慌(
panic )和恢复(recovery)! 紧急情况是针对“驱动器掉落”或“网卡爆炸”之类的情况而设计的。 但不是这样-“有人通过字符串而不是int”。
不幸的是,其他“开明的开发人员”将返回其代码中的错误。 因此,这是PanicIfErr的一个小辅助函数。 使用它可以使其他开发人员的错误变成恐慌。
func PanicIfErr(err error) { if err != nil { panic(err) } }
您可以使用PanicIfErr包装其他人的错误,压缩代码。 不再有丑陋的错误处理! 现在,任何错误都会引起恐慌。 如此高效!
func (dus DBUserService) LoadEvil(id int) User { rows, err := dus.DB.Query( "SELECT name FROM USERS WHERE ID = ?", id) PanicIfErr(err) if !rows.Next() { panic(fmt.Sprintf("no user for id %d", id)) } var name string PanicIfErr(rows.Scan(&name)) PanicIfErr(rows.Close()) return User{Id: id, Name: name} }
您可以将恢复放在靠近程序开始的位置,也可以放在自己的
中间件中 。 然后说,您不仅可以处理错误,而且可以使别人的代码更干净。 善做恶是最好的一种邪恶。
func PanicMiddleware(h http.Handler) http.Handler { return http.HandlerFunc( func(rw http.ResponseWriter, req *http.Request){ defer func() { if r := recover(); r != nil { fmt.Println(", - .") } }() h.ServeHTTP(rw, req) } ) }
设定副作用
接下来,我们将创建一个副作用。 请记住,“优秀”的Go开发人员想了解数据如何通过程序。 知道数据经过的最好方法是在应用程序中建立显式依赖关系。 即使是与同一接口相对应的实体,其行为也可能相差很大。 例如,将数据存储在内存中的代码和访问数据库以完成相同工作的代码。 但是,有一些方法可以在Go中安装依赖项而无需显式调用。
与许多其他语言一样,Go可以神奇地执行代码而无需直接调用它。 如果创建不带参数的init函数,则它将在程序包加载时自动启动。 并且,进一步混淆的是,如果在一个文件中有多个具有init名称的功能或在一个包中的多个文件,它们将全部启动。
package account type Account struct{ Id int UserId int } func init() { fmt.Println(" !") } func init() { fmt.Println(" , init()") }
初始化函数通常与空导入相关联。 Go有一种特殊的声明导入的方式,看起来像`import _“ github.com / lib / pq`。 当为导入的程序包设置空名称标识符时,init方法将在其中运行,但不会显示任何程序包标识符。 对于某些Go库(例如数据库驱动程序或图像格式),您必须通过启用空包导入来加载它们,仅调用init函数,以便包可以注册其代码。
package main import _ "github.com/lib/pq" func main() { db, err := sql.Open( "postgres", "postgres://jon@localhost/evil?sslmode=disable") }
这显然是一个“邪恶”的选择。 使用初始化时,神奇地起作用的代码完全不受开发人员控制。 最佳做法不建议使用初始化函数-这些是非显而易见的功能,它们会使代码混乱,并且很容易隐藏在库中。
换句话说,init函数对于我们的邪恶目的是理想的。 您可以使用初始化和空导入功能来配置应用程序的状态,而不是在包中显式配置或注册实体。 在此示例中,我们通过注册表使帐户对应用程序的其余部分可用,并且使用init函数将程序包本身放置在注册表中。
package account import ( "fmt" "github.com/evil-go/example/registry" ) type StubAccountService struct {} func (a StubAccountService) GetBalance(accountId int) int { return 1000000 } func init() { registry.Register("account", StubAccountService{}) }
如果要使用帐户,请在程序中放入一个空的导入。 它不必是主要代码或相关代码,而只需是“某处”。 这太神奇了!
package main import ( _ "github.com/evil-go/example/account" "github.com/evil-go/example/registry" ) type Balancer interface { GetBalance(int) int } func main() { a := registry.Get("account").(Balancer) money := a.GetBalance(12345) }
如果在库中使用init来配置依赖关系,您将立即看到其他开发人员正在迷惑如何安装这些依赖关系以及如何更改它们。 没有人会比你更聪明。
复杂的配置
我们仍然可以使用该配置做很多事情。 如果您是一个“优秀”的Go开发人员,则需要将配置与程序的其余部分隔离开。 在main()函数中,您从环境中获取变量,并将其转换为彼此明确相关的组件所需的值。 您的组件对配置文件或其属性一无所知。 对于简单的组件,可以设置公共属性,对于更复杂的组件,可以创建工厂函数,该工厂函数接收配置信息并返回正确配置的组件。
func main() { b, err := ioutil.ReadFile("account.json") if err != nil { fmt.Errorf("error reading config file: %v", err) os.Exit(1) } m := map[string]interface{}{} json.Unmarshal(b, &m) prefix := m["account.prefix"].(string) maker := account.NewMaker(prefix) } type Maker struct { prefix string } func (m Maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } func NewMaker(prefix string) Maker { return Maker{prefix: prefix} }
但是“邪恶的”开发人员知道,最好在整个程序中分散有关配置的信息。 与其在一个软件包中定义一个名称和值类型的函数,不如使用一个函数,该函数按原样使用该配置并自行进行转换。
如果这看起来太“邪恶”,请使用init函数从程序包中加载属性文件并自行设置值。 看来您使其他开发人员的生活变得更轻松,但是您和我知道...
使用init函数,您可以在代码的后面定义新的属性,并且直到它们投入生产并且一切都没有完成之前,没人会找到它们,因为某些东西不会进入运行所需的数十个属性文件之一。 如果您想要更多的“邪恶力量”,则可以建议创建一个Wiki,以跟踪所有库中的所有属性,并“忘记”定期添加新属性。 作为财产管理员,您将成为唯一可以运行该软件的人。
func (m maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } var Maker maker func init() { b, _ := ioutil.ReadFile("account.json") m := map[string]interface{}{} json.Unmarshal(b, &m) Maker.prefix = m["account.prefix"].(string) }
功能框架
最后,我们来讨论框架与库的话题。 区别非常细微。 不只是大小 您可以拥有大型库和小型框架。 当您自己调用库代码时,框架将调用您的代码。 框架要求您以某种方式编写代码,无论是根据特定规则命名方法,还是它们对应于特定接口,还是迫使您在框架中注册代码。 框架对所有代码都有其自己的要求。 也就是说,框架通常会命令您。
Go鼓励使用库,因为库是链接的。 当然,尽管每个库都希望数据以特定格式传输,但是您可以编写一些连接代码以将一个库的输出转换为另一个库的输入。
很难使框架无缝地协同工作,因为每个框架都希望对代码生命周期进行完全控制。 通常,使框架协同工作的唯一方法是框架作者聚集在一起,并明确组织相互支持。
使用“邪恶框架”获得长期权力的最佳方法是编写自己的框架,该框架仅在公司内部使用。当前和未来的邪恶
掌握了这些技巧之后,您将永远走上邪恶之路。在第二部分中,我将展示如何部署所有这些“邪恶”,以及如何正确地将“良好”代码转换为“邪恶”。