摘要:文将详细介绍 Golang 的语言特点以及它的优缺点和适用场景,带着上述几个疑问,为读者分析 Go 语言的各个方面,以帮助初入 IT 行业的程序员以及对 Go 感兴趣的开发者进一步了解这个热门语言。
本文分享自华为云社区《大红大紫的 Golang 真的是后端开发中的万能药吗?》,原文作者:Marvin Zhang 。
城外的人想进去,城里的人想出来。– 钱钟书《围城》
随着容器编排(Container Orchestration)、微服务(Micro Services)、云技术(Cloud Technology)等在 IT 行业不断盛行,2009 年诞生于 Google 的 Golang(Go 语言,简称 Go)越来越受到软件工程师的欢迎和追捧,成为如今炙手可热的后端编程语言。在用 Golang 开发的软件项目列表中,有 Docker(容器技术)、Kubernetes(容器编排)这样的颠覆整个 IT 行业的明星级产品,也有像 Prometheus(监控系统)、Etcd(分布式存储)、InfluxDB(时序数据库)这样的强大实用的知名项目。当然,Go 语言的应用领域也绝不局限于容器和分布式系统。如今很多大型互联网企业在大量使用 Golang 构建后端 Web 应用,例如今日头条、京东、七牛云等;长期被 Python 统治的框架爬虫领域也因为简单而易用的爬虫框架 Colly 的崛起而不断受到 Golang 的挑战。Golang 已经成为了如今大多数软件工程师最想学习的编程语言。下图是 HackerRank 在 2020 年调查程序员技能的相关结果。
那么,Go 语言真的是后端开发人员的救命良药呢?它是否能够有效提高程序员们的技术实力和开发效率,从而帮助他们在职场上更进一步呢?Go 语言真的值得我们花大量时间深入学习么?本文将详细介绍 Golang 的语言特点以及它的优缺点和适用场景,带着上述几个疑问,为读者分析 Go 语言的各个方面,以帮助初入 IT 行业的程序员以及对 Go 感兴趣的开发者进一步了解这个热门语言。
Golang 诞生于互联网巨头 Google,而这并不是一个巧合。我们都知道,Google 有一个 20% 做业余项目(Side Project)的企业文化,允许工程师们能够在轻松的环境下创造一些具有颠覆性创新的产品。而 Golang 也正是在这 20% 时间中不断孵化出来。Go 语言的创始者也是 IT 界内大名鼎鼎的行业领袖,包括 Unix 核心团队成员 Rob Pike、C 语言作者 Ken Thompson、V8 引擎核心贡献者 Robert Griesemer。Go 语言被大众所熟知还是源于容器技术 Docker 在 2014 年被开源后的爆发式发展。之后,Go 语言因为其简单的语法以及迅猛的编译速度受到大量开发者的追捧,也诞生了很多优秀的项目,例如 Kubernetes。
Go 语言相对于其他传统热门编程语言来说,有很多优点,特别是其高效编译速度和天然并发特性,让其成为快速开发分布式应用的首选语言。Go 语言是静态类型语言,也就是说 Go 语言跟 Java、C# 一样需要编译,而且有完备的类型系统,可以有效减少因类型不一致导致的代码质量问题。因此,Go 语言非常适合构建对稳定性和灵活性均有要求的大型 IT 系统,这也是很多大型互联网公司用 Golang 重构老代码的重要原因:传统的静态 OOP 语言(例如 Java、C#)稳定性高但缺乏灵活性;而动态语言(例如 PHP、Python、Ruby、Node.js)灵活性强但缺乏稳定性。因此,“熊掌和鱼兼得” 的 Golang,受到开发者们的追捧是自然而然的事情,毕竟,“天下苦 Java/PHP/Python/Ruby 们久矣“。
不过,Go 语言并不是没有缺点。用辩证法的思维方式可以推测,Golang 的一些突出特性将成为它的双刃剑。例如,Golang 语法简单的优势特点将限制它处理复杂问题的能力。尤其是 Go 语言缺乏泛型(Generics)的问题,导致它构建通用框架的复杂度大增。虽然这个突出问题在 2.0 版本很可能会有效解决,但这也反映出来明星编程语言也会有缺点。当然,Go 的缺点还不止于此,Go 语言使用者还会吐槽其啰嗦的错误处理方式(Error Handling)、缺少严格约束的鸭子类型(Duck Typing)、日期格式问题等。下面,我们将从 Golang 语言特点开始,由浅入深多维度深入分析 Golang 的优缺点以及项目适用场景。
Go 语言的语法非常简单,至少在变量声明、结构体声明、函数定义等方面显得非常简洁。
变量的声明不像 Java 或 C 那样啰嗦,在 Golang 中可以用 := 这个语法来声明新变量。例如下面这个例子,当你直接使用 := 来定义变量时,Go 会自动将赋值对象的类型声明为赋值来源的类型,这节省了大量的代码。
func main() {
valInt := 1 // 自动推断 int 类型
valStr := "hello" // 自动推断为 string 类型
valBool := false // 自动推断为 bool 类型
}
Golang 还有很多帮你节省代码的地方。你可以发现 Go 中不会强制要求用 new 这个关键词来生成某个类(Class)的新实例(Instance)。而且,对于公共和私有属性(变量和方法)的约定不再使用传统的 public 和 private 关键词,而是直接用属性变量首字母的大小写来区分。下面一些例子可以帮助读者理解这些特点。
// 定义一个 struct 类
type SomeClass struct {
PublicVariable string // 公共变量
privateVariable string // 私有变量
}
// 公共方法
func (c *SomeClass) PublicMethod() (result string) {
return "This can be called by external modules"
}
// 私有方法
func (c *SomeClass) privateMethod() (result string) {
return "This can only be called in SomeClass"
}
func main() {
// 生成实例
someInstance := SomeClass{
PublicVariable: "hello",
privateVariable: "world",
}
}
如果你用 Java 来实现上述这个例子,可能会看到冗长的 .java 类文件,例如这样。
// SomeClass.java
public SomeClass {
public String PublicVariable; // 公共变量
private String privateVariable; // 私有变量
// 构造函数
public SomeClass(String val1, String val2) {
this.PublicVariable = val1;
this.privateVariable = val2;
}
// 公共方法
public String PublicMethod() {
return "This can be called by external modules";
}
// 私有方法
public String privateMethod() {
return "This can only be called in SomeClass";
}
}
...
// Application.java
public Application {
public static void main(String[] args) {
// 生成实例
SomeClass someInstance = new SomeClass("hello", "world");
}
}
可以看到,在 Java 代码中除了容易看花眼的多层花括号以外,还充斥着大量的 public、private、static、this 等修饰用的关键词,显得异常啰嗦;而 Golang 代码中则靠简单的约定,例如首字母大小写,避免了很多重复性的修饰词。当然,Java 和 Go 在类型系统上还是有一些区别的,这也导致 Go 在处理复杂问题显得有些力不从心,这是后话,后面再讨论。总之,结论就是 Go 的语法在静态类型编程语言中非常简洁。
Go 语言之所以成为分布式应用的首选,除了它性能强大以外,其最主要的原因就是它天然的并发编程。这个并发编程特性主要来自于 Golang 中的协程(Goroutine)和通道(Channel)。下面是使用协程的一个例子。
func asyncTask() {
fmt.Printf("This is an asynchronized task")
}
func syncTask() {
fmt.Printf("This is a synchronized task")
}
func main() {
go asyncTask() // 异步执行,不阻塞
syncTask() // 同步执行,阻塞
go asyncTask() // 等待前面 syncTask 完成之后,再异步执行,不阻塞
}
可以看到,关键词 go 加函数调用可以让其作为一个异步函数执行,不会阻塞后面的代码。而如果不加 go 关键词,则会被当成是同步代码执行。如果读者熟悉 JavaScript 中的 async/await、Promise 语法,甚至是 Java、Python 中的多线程异步编程,你会发现它们跟 Go 异步编程的简单程度不是一个量级的!
异步函数,也就是协程之间的通信可以用 Go 语言特有的通道来实现。下面是关于通道的一个例子。
func longTask(signal chan int) {
// 不带参数的 for
// 相当于 while 循环
for {
// 接收 signal 通道传值
v := <- signal
// 如果接收值为 1,停止循环
if v == 1 {
break
}
time.Sleep(1 * Second)
}
}
func main() {
// 声明通道
sig := make(chan int)
// 异步调用 longTask
go longTask(sig)
// 等待 1 秒钟
time.Sleep(1 * time.Second)
// 向通道 sig 传值
sig <- 1
// 然后 longTask 会接收 sig 传值,终止循环
}
Go 语言不是严格的面向对象编程(OOP),它采用的是面向接口编程(IOP),是相对于 OOP 更先进的编程模式。作为 OOP 体系的一部分,IOP 更加强调规则和约束,以及接口类型方法的约定,从而让开发人员尽可能的关注更抽象的程序逻辑,而不是在更细节的实现方式上浪费时间。很多大型项目采用的都是 IOP 的编程模式。如果想了解更多面向接口编程,请查看 “码之道” 个人技术博客的往期文章《为什么说 TypeScript 是开发大型前端项目的必备语言》,其中有关于面向接口编程的详细讲解。
Go 语言跟 TypeScript 一样,也是采用鸭子类型的方式来校验接口继承。下面这个例子可以描述 Go 语言的鸭子类型特性。
// 定义 Animal 接口
interface Animal {
Eat() // 声明 Eat 方法
Move() // 声明 Move 方法
}
// ==== 定义 Dog Start ====
// 定义 Dog 类
type Dog struct {
}
// 实现 Eat 方法
func (d *Dog) Eat() {
fmt.Printf("Eating bones")
}
// 实现 Move 方法
func (d *Dog) Move() {
fmt.Printf("Moving with four legs")
}
// ==== 定义 Dog End ====
// ==== 定义 Human Start ====
// 定义 Human 类
type Human struct {
}
// 实现 Eat 方法
func (h *Human) Eat() {
fmt.Printf("Eating rice")
}
// 实现 Move 方法
func (h *Human) Move() {
fmt.Printf("Moving with two legs")
}
// ==== 定义 Human End ====
可以看到,虽然 Go 语言可以定义接口,但跟 Java 不同的是,Go 语言中没有显示声明接口实现(Implementation)的关键词修饰语法。在 Go 语言中,如果要继承一个接口,你只需要在结构体中实现该接口声明的所有方法。这样,对于 Go 编译器来说你定义的类就相当于继承了该接口。在这个例子中,我们规定,只要既能吃(Eat)又能活动(Move)的东西就是动物(Animal)。而狗(Dog)和人(Human)恰巧都可以吃和动,因此它们都被算作动物。这种依靠实现方法匹配度的继承方式,就是鸭子类型:如果一个动物看起来像鸭子,叫起来也像鸭子,那它一定是鸭子。这种鸭子类型相对于传统 OOP 编程语言显得更灵活。但是,后面我们会讨论到,这种编程方式会带来一些麻烦。
Go 语言的错误处理是臭名昭著的啰嗦。这里先给一个简单例子。
package main
import "fmt"
func isValid(text string) (valid bool, err error){
if text == "" {
return false, error("text cannot be empty")
}
return text == "valid text", nil
}
func validateForm(form map[string]string) (res bool, err error) {
for _, text := range form {
valid, err := isValid(text)
if err != nil {
return false, err
}
if !valid {
return false, nil
}
}
return true, nil
}
func submitForm(form map[string]string) (err error) {
if res, err := validateForm(form); err != nil || !res {
return error("submit error")
}
fmt.Printf("submitted")
return nil
}
func main() {
form := map[string]string{
"field1": "",
"field2": "invalid text",
"field2": "valid text",
}
if err := submitForm(form); err != nil {
panic(err)
}
}
虽然上面整个代码是虚构的,但可以从中看出,Go 代码中充斥着 if err := …; err != nil { … } 之类的错误判断语句。这是因为 Go 语言要求开发者自己管理错误,也就是在函数中的错误需要显式抛出来,否则 Go 程序不会做任何错误处理。因为 Go 没有传统编程语言的 try/catch 针对错误处理的语法,所以在错误管理上缺少灵活度,导致了 “err 满天飞” 的局面。
不过,辩证法则告诉我们,这种做法也是有好处的。第一,它强制要求 Go 语言开发者从代码层面来规范错误的管理方式,这驱使开发者写出更健壮的代码;第二,这种显式返回错误的方式避免了 “try/catch 一把梭”,因为这种 “一时爽” 的做法很可能导致 Bug 无法准确定位,从而产生很多不可预测的问题;第三,由于没有 try/catch 的括号或额外的代码块,Go 程序代码整体看起来更清爽,可读性较强。
Go 语言肯定还有很多其他特性,但笔者认为以上的特性是 Go 语言中比较有特色的,是区分度比较强的特性。Go 语言其他一些特性还包括但不限于如下内容。
- 编译迅速
- 跨平台
- defer 延迟执行
- select/case 通道选择
- 直接编译成可执行程序
- 非常规依赖管理(可以直接引用 Github 仓库作为依赖,例如 import "github.com/crawlab-team/go-trace")
- 非常规日期格式(格式为 “2006-01-02 15:04:05”,你没看错,据说这就是 Golang 的创始时间!)
前面介绍了 Go 的很多语言特性,想必读者已经对 Golang 有了一些基本的了解。其中的一些语言特性也暗示了它相对于其他编程语言的优缺点。Go 语言虽然现在很火,在称赞并拥抱 Golang 的同时,不得不了解它的一些缺点。
这里笔者不打算长篇大论的解析 Go 语言的优劣,而是将其中相关的一些事实列举出来,读者可以自行判断。以下是笔者总结的 Golang 语言特性的不完整优缺点对比列表。
其实,每一个特性在某种情境下都有其相应的优势和劣势,不能一概而论。就像 Go 语言采用的静态类型和面向接口编程,既不缺少类型约束,也不像严格 OOP 那样冗长繁杂,是介于动态语言和传统静态类型 OOP 语言之间的现代编程语言。这个定位在提升 Golang 开发效率的同时,也阉割了不少必要 OOP 语法特性,从而缺乏快速构建通用工程框架的能力(这里不是说 Go 无法构建通用框架,而是它没有 Java、C# 这么容易)。另外,Go 语言 “奇葩” 的错误处理规范,让 Go 开发者们又爱又恨:可以开发出更健壮的应用,但同时也牺牲了一部分代码的简洁性。要知道,Go 语言的设计理念是为了 “大道至简”,因此才会在追求高性能的同时设计得尽可能简单。
无可否认的是,Go 语言内置的并发支持是非常近年来非常创新的特性,这也是它被分布式系统广泛采用的重要原因。同时,它相对于动辄编译十几分钟的 Java 来说是非常快的。此外,Go 语言没有因为语法简单而牺牲了稳定性;相反,它从简单的约束规范了整个 Go 项目代码风格。因此,**“快”(Fast)、“简”(Concise)、“稳”(Robust)**是 Go 语言的设计目的。我们在对学习 Golang 的过程中不能无脑的接纳它的一切,而是应该根据它自身的特性判断在实际项目应用中的情况。
经过前文关于 Golang 各个维度的讨论,我们可以得出结论:Go 语言并不是后端开发的万能药。在实际开发工作中,开发者应该避免在任何情况下无脑使用 Golang 作为后端开发语言。相反,工程师在决定技术选型之前应该全面了解候选技术(语言、框架或架构)的方方面面,包括候选技术与业务需求的切合度,与开发团队的融合度,以及其学习、开发、时间成本等因素。笔者在学习了包括前后端的一些编程语言之后,发现它们各自有各自的优势,也有相应的劣势。如果一门编程语言能广为人知,那它绝对不会是一门糟糕语言。因此,笔者不会断言 “XXX 是世界上最好的语言“,而是给读者分享个人关于特定应用场景下技术选型的思路。当然,本文是针对 Go 语言的技术文,接下来笔者将分享一下个人认为 Golang 最适合的应用场景。
Golang 是非常适合在分布式应用场景下开发的。分布式应用的主要目的是尽可能多的利用计算资源和网络带宽,以求最大化系统的整体性能和效率,其中重要的需求功能就是并发(Concurrency)。而 Go 是支持高并发和异步编程方面的佼佼者。
前面已经提到,Go 语言内置了协程(Goroutine)和通道(Channel)两大并发特性,这使后端开发者进行异步编程变得非常容易。Golang 中还内置了sync 库,包含 Mutex(互斥锁)、WaitGroup(等待组)、Pool(临时对象池)等接口,帮助开发者在并发编程中能更安全的掌控 Go 程序的并发行为。Golang 还有很多分布式应用开发工具,例如分布式储存系统(Etcd、SeaweedFS)、RPC 库(gRPC、Thrift)、主流数据库 SDK(mongo-driver、gnorm、redigo)等。这些都可以帮助开发者有效的构建分布式应用。
稍微了解网络爬虫的开发者应该会听说过 Scrapy,再不济也是 Python。市面上关于 Python 网络爬虫的技术书籍数不胜数,例如崔庆才的《Python 3 网络开发实战》和韦世东的《Python 3 网络爬虫宝典》。用 Python 编写的高性能爬虫框架 Scrapy,自发布以来一直是爬虫工程师的首选。
不过,由于近期 Go 语言的迅速发展,越来越多的爬虫工程师注意到用 Golang 开发网路爬虫的巨大优势。其中,用 Go 语言编写的 Colly 爬虫框架,如今在 Github 上已经有 13k+ 标星。其简洁的 API 以及高效的采集速度,吸引了很多爬虫工程师,占据了爬虫界一哥 Scrapy 的部分份额。前面已经提到,Go 语言内置的并发特性让严重依赖网络带宽的爬虫程序更加高效,很大的提高了数据采集效率。另外,Go 语言作为静态语言,相对于动态语言 Python 来说有更好的约束下,因此健壮性和稳定性都更好。
Golang 有很多优秀的后端框架,它们大部分都非常完备的支持了现代后端系统的各种功能需求:RESTful API、路由、中间件、配置、鉴权等模块。而且用 Golang 写的后端应用性能很高,通常有非常快的响应速度。笔者曾经在开源爬虫管理平台 Crawlab 中用 Golang 重构了 Python 的后端 API,响应速度从之前的几百毫秒优化到了几十毫秒甚至是几毫秒,用实践证明 Go 语言在后端性能方面全面碾压动态语言。Go 语言中比较知名的后端框架有 Gin、Beego、Echo、Iris。
当然,这里并不是说用 Golang 写后端就完全是一个正确的选择。笔者在工作中会用到 Java 和 C#,用了各自的主流框架(SpringBoot 和 .Net Core)之后,发现这两门传统 OOP 语言虽然语法啰嗦,但它们的语法特性很丰富,特别是泛型,能够轻松应对一些逻辑复杂、重复性高的业务需求。因此,笔者认为在考虑用 Go 来编写后端 API 时候,可以提前调研一下 Java 或 C#,它们在写后端业务功能方面做得非常棒。
本篇文章从 Go 语言的主要语法特性入手,循序渐进分析了 Go 语言作为后端编程语言的优点和缺点,以及其在实际软件项目开发中的试用场景。笔者认为 Go 语言与其他语言的主要区别在于语法简洁、天然支持并发、面向接口编程、错误处理等方面,并且对各个语言特性在正反两方面进行了分析。最后,笔者根据之前的分析内容,得出了 Go 语言作为后端开发编程语言的适用场景,也就是分布式应用、网络爬虫以及后端API。
当然,Go 语言的实际应用领域还不限于此。实际上,不少知名数据库都是用 Golang 开发的,例如时序数据库 Prometheus 和 InfluxDB、以及有 NewSQL 之称的 TiDB。此外,在机器学习方面,Go 语言也有一定的优势,只是目前来说,Google 因为 Swift 跟 TensorFlow 的意向合作,似乎还没有大力推广 Go 在机器学习方面的应用,不过一些潜在的开源项目已经涌现出来,例如 GoLearn、GoML、Gorgonia 等。
在理解 Go 语言的优势和适用场景的同时,我们必须意识到 Go 语言并不是全能的。它相较于其他一些主流框架来说也有一些缺点。开发者在准备采用 Go 作为实际工作开发语言的时候,需要全面了解其语言特性,从而做出最合理的技术选型。就像打网球一样,不仅需要掌握正反手,还要会发球、高压球、截击球等技术动作,这样才能把网球打好。