写点什么

如何优雅的编写 GO 程序?

用户头像
八两
关注
发布于: 2020 年 08 月 12 日
如何优雅的编写GO程序?

GO的语言语法简单易用,有其他编程经验的开发者相信学习并快速上手GO语言的开发多数并不是一件困难的事儿。但是要编写出优雅、扩展性强、容易维护的代码就没那么容易了。当然我今天分享的是GO程序的编写,但是有很多编程思想是可以互通的,如果你不是GO语言工程师,相信也一样可以将其中的一些知识应用到自己的开发当中去。在了解如何优雅的编写GO程序之前呢,我们应该先了解下什么是优雅的代码。



什么是优雅的代码

可读性

如何来定义优雅呢,有必要先来分析一些不太优雅的代码,更清楚的弄明白什么样的代码才是优雅的,自然心里也就或多或少有了一些衡量的标准。如果把一个正常的程序,例如fmt.println函数。



func main() {
fmt.Println("hello tal")
}




写成像下面这样没有缩进、没有空格、没有注释的形式,阅读和理解起来就会非常困难了,如果是你接手这样的代码,会不会感觉到很崩溃呢?



func main(){fmt.Println("hello tal")}




可理解性

再举个例子,如果代码组织不合理,一个项目当中你会看到四处横飞的init函数,又互相会调用其他的package。



//file a.go
package a
func init() {
//...
}

//file b.go
package b
func init() {
//...
}




可见包之间的逻辑比较难梳理、功能之间没有做到有效的分离,不仅不容易理解,维护、修改代码都会有非常高的成本,而且面临着较大的风险。



可维护性

func GetInfo() {
initMysql()
initRedis()
//业务代码....
//....
}




如果其他技术老师让你给一个函数添加一个简单的功能,不过发现这个函数依赖Mysql、Redis这样的数据库或者Zookeeper、ETCD服务发现的时候,那么想要测试调试,但这个时候又不能直接连接生产的环境,那这时也会增加项目的维护难度,操作上了就更困难。



可扩展性

还有的人呢,喜欢直接将所有的代码和功能都写到同一个package,甚至于同一个函数当中去,来一个简单的例子。



package a

func ReadData() {
//读取缓存
}

func WriteData() {
//写入缓存
}




读取和写入都放在了同一个package当中,如果我们的业务访问量突然的翻倍了,需要扩容读取数据的时候,我们要对代码进行扩容就会显得尤为的困难。

很明显在前文看到的代码都是非常糟糕的,通过综合分析可以得出:有的不易维护、有的不具有可读性也不容易理解,怎么去掉这个不字呢?明白什么是不优雅的代码特征,我想我们就明白了应该如何来定义优雅代码。优雅代码必须首先具备这几个特性:至少会包含易读、可理解、易维护、可扩展性强等几个方面。接下来我们一起从标准、工具、项目、结构、代码的结构、代码的风格以及接口抽象等几个方面入手,看看围绕这几点我们应该怎样来做才能够写出优雅的代码。



如何写出优雅的程序

代码格式化

GO官方提供了许多的工具来保证代码的可读性和易于理解。例如gofmt它可以格式项目中的代码,他使用了制表符来进行缩进,使用了空格来进行对齐。



代码规范

golint工具,可以识别出代码中的样式命名规范错误。golint它与代码的正确性无关,但是可以与GO社区推荐的样式命名规范保持一致。



package foo

import "fmt"

const Url = "www.tal.com"

func Print() {
fmt.Println(Url)
}





golint foo.go
foo.go:5:7: const Url should be URL
foo.go:5:7: exported const Url should have comment or be unexported
foo.go:7:1: exported function Print should have comment or be unexported




golint可以扫描出项目中的所有的代码,命令执行完成后,我们可以看到像这段Url一个首字母大写的形式,被golint检测到并告知我们社区规范应该是URL大写的格式,这样非常细节的问题都会被指出来,目前轻课依赖golint进行静态代码检查,帮助轻课技术团队提升规范。可以访问https://github.com/golang/lint获取golint。



工具链

GO语言它具有非常强大的工具链,包括:



功能命令测试go test依赖管理go mod、go get文档go doc语法检查go vet编译、性能分析go tool



熟练地掌握这些标准工具,可以帮助我们写出易读易维护的代码,代码的格式化可以通过工具来轻松的搞定。



项目结构

项目的结构呢,需要我们开发自己去实现。对于整个项目,GO社区建议使用标准的项目布局,这样在接触到一个项目的时候,一眼就能够看出每个模块的功能。



LICENSE.md
Makefile
README.md
api
assets
build
cmd
configs
deployments
docs
examples
githooks
go.mod
init
internal
pkg
scripts
test
third_party
tools
vendor
web
website




其中cmd目录中存储的每一个子目录,它代表的是程序中的可执行文件,比如说main、cron等。

internal目录是一个比较特殊的目录,存储我们的私有代码和一些不可以被外部库导出的一些代码,私有代码推荐放到把internal这样的一个目录中去。

pkg目录也非常重要,它存储的是默认的可以被外部程序导出的这样的一些代码,那其他项目呢,就可以很简单通过import引入pkg目录中的公共的代码。

那么。在Go语言项目当中最不该出现的目录结构就是src,因为当我们的项目是放置在GOPATH目录下的时候,这时候项目实际上存储在GOPATH内部的src目录下,如果我们项目中也使用到了src目录,那么项目的路径会出现两个src,是不是觉得有一些困扰呢?

更多的建议大家可以访问一下https://github.com/golang-standards/project-layout了解更多的关于目录结构的规范。



代码结构

除了项目文件组织结构我们还要关心项目代码的结构,GO语言不支持继承,但是支持组合,简单的理解呢是指代码组装在一起。





例如一辆汽车,它是由发动机、轮胎、地盘等组合在一起的。

在GO程序当中呢,我们倾向于根据功能来拆分为不同的模块存储在不同的package当中去。那么需要时呢,再对package做适当的组合,这样做的好处是什么呢?我们知道只要子模块它是稳健的,他们组合起来的模块,那一定是稳健的,这样我们不仅能够单独的很方便地对单个子模块进行单元测试,而且还比较方便的进行代码的重构。

在微服务流行的当下,我们可以对某些热点的模块专门的进行必要的扩容。比如说一个简单的数据存储服务,那我们可以将其分成写数据与读数据这两个模块,一旦用户数量增加以后把业务陷入到瓶颈,我们可以快速地对读取模块进行扩容。例如,对于承载用户访问的服务,那么在业务量快速扩张的时候,我们就可以对他进行轻松的扩容处理,这也是模块分离的一个好处。



代码风格

写出优雅代码,代码风格同样是值得关注的。GO语言中的channel与goroutine,原生的就支持高并发的特性。因此它与传统的一些程序,例如Java、C++、PHP等,在处理高并发的时候有显著的不同,在GO语言有一句经典名言,通过通信来共享内存,而不是共享内存来进行通信。怎么理解这句话呢?说说我自己的理解,其实就是由于在GO语言程序设计当中,我们很少通过加锁的方式来避免并发冲突。而会选择使用协程与通道,传统的线程模型要求我们使用共享内存在线程之间进行通信,通常共享内存的结构是受锁保护的,线程争夺这些锁来访问数据,举个例子。



type Resource struct {
Url string
polling bool
lastPolled int64
}

type Resources struct {
data []*Resource
lock *sync.Mutex
}





我们轮询URL一个列表,那么在传统的线程模型当中呢,数据可以构造成data数组以及一把锁,其他多个线程同时运行一个Poller函数。



func Poller(res *Resources) {
for {
res.lock.Lock()
var r *Resource
for _, v := range res.data {
//...
}
res.lock.Unlock()
// get请求拉取url信息 省略
res.lock.Lock()
r.polling = false
r.lastPolled = time.Now().UnixNano()
res.lock.Unlock()
}
}




他们用作爬取列表的功能一般会加锁,遍历整个列表对某一个URL的资源修改时,又需要加锁防止并发错误,这个时候为了实现这个功能,需要花费大量的代码,而且这个程序还没有实现资源池的耗尽问题以及出错时锁的释放问题等等。那么完整的Poller函数它可能需要的代码量会更多,但我们来看一下使用Go语言实现的相同功能的模型。



func Poller(in, out chan *Resource) {
for r := range in {
//poll url
//...
//send processed Resource to out
out <- r
}
}




在这个示例当中呢,相同的Poller函数,通过一个in通道监听我们要轮询的资源,并在完成之后将其发送到out通道当中去,我们甚至于不用加任何的锁就可以优雅地完成协程之间的调度任务。



接口抽象

最后和大家学习一下书写幽雅代码的大杀器,通过接口抽象的方式来组合代码。在GO程序当中,如果能够适当的使用接口,将会带来百倍的效率的提升。



package a
res, err := filecache.Set(ctx, key, "tal")




例如,在我们的项目当中呢,可能之前是file文件实现的缓存,比如说package a。那么在package a当中要设置一个缓存的时候会使用Set函数。但是后来由于业务快速发展,文件缓存系统无法满足业务,需要使用分布式数据库Redis,



package b
res, err := redis.Set(key, "tal", 0)




那么它叫做package b,Redis的设置缓存函数也是Set,但是形参的数量、类型都发生了很大的变化,那这个时候如果我们想要把我们现在的代码替换为新的Redis的话,是不是每个使用了file文件缓存的地方都需要进行替换呢?这为我们业务发展带来了非常大的问题。

但是如果我们能够抽象出一个中间层,好的,那我们抽象出一个CacheService接口。



type CacheService interface {
Set(key string, val interface{}, expire time.Duration)
}




同时呢,在代码中我们用Redis分布式缓存数据库实现CacheService接口。那么在我们会新建一个RedisCache结构体,并实现接口中的Set方法。



type RedisCache struct {
}

func (p *RedisCache) Set(key string, val interface{}, expire time.Duration) {
p.redis.Set(key, val, expire)
}




那么在实际的业务代码调用过程当中,我们初始化的时候会注入接口的具体实现。在这里是RedisCache这样的一个结构体。接着呢我们会使用接口的方式去调用函数。



var cache CacheService
cache = &RedisCache{}
cache.Set("tal", 100, time.Hour)




这时如果我们要改变或者更新缓存实现方式的话,我们只用非常轻松的改变接口具体的实现方式,一行代码我们就可以解决问题。而业务代码他由于使用了接口的调用方式,它就不需要再做任何的改变,那这是不是百倍的一个效率提升呢?那么同时接口的使用还可以做解耦的操作,避免GO语言当中的循环依赖以及依赖注入的困境。

比如我们的UserGet函数,它依赖了CacheService的功能。



func UserSet(id int, user User) {
key := fmt.Sprintf("tal:uid:%d", id)
CacheService.Set(key, User, time.Second)
}




但是呢,如果我们通过接口的方式注入数据库的实例,在进行测试的时候,我们就可以直接mock数据,将不可控的数据库的实例控制在可控的范围之内,而不需要呢实际的去连接真正的数据库。



总结

OK,我们来总结一下,在GO语言当中呢,要写出优雅代码,它并不是一件容易的事情,我们具体从标准、工具、项目、组织、代码、风格、接口、抽象等方面了解了要写出优雅够程序需要掌握的知识。最后呢,要实现代码的可读可理解性,我认为可以通过使用官方的标准工具和项目结构以及代码组织来实现。而一个项目,要想使它变得容易维护我们需要积极地拥抱GO语言的生态与特性,通过合理的代码风格,以及恰当的接口抽象来实现。最后呢,熟练地在GO项目当中,通过模块的拆分与组合的方式来整合代码,有助于写出高扩展性的程序。



用户头像

八两

关注

还未添加个人签名 2018.08.06 加入

还未添加个人简介

评论

发布
暂无评论
如何优雅的编写GO程序?