Go语言实战

978-7-115-44535-3
作者: 【美】威廉•肯尼迪(William Kennedy) 布赖恩•克特森(Brian Ketelsen) 埃里克•圣马丁(Erik St. Martin)
译者: 李兆海
编辑: 杨海玲

图书目录:

详情

Go语言结合了底层系统语言的能力以及现代语言的高级特性,旨在降低构建简单、可靠、高效软件的门槛。本书向读者提供一个专注且全面,符合语言习惯的视角。本书同时关注语言的规范和实现,涉及的内容包括语法、类型系统、并发、管道、测试以及其他一些主题。

图书摘要

版权信息

书名:Go语言实战

ISBN:978-7-115-44535-3

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] William Kennedy Brian Ketelsen Erik St. Martin

  译    李兆海

  审  校 谢孟军

  责任编辑 杨海玲

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Original English language edition, entitled Go in Action by William Kennedy, Brian Ketelsen, and Erik St. Martin published by Manning Publications Co., 209 Bruce Park Avenue, Greenwich, CT 06830. Copyright ©2016 by Manning Publications Co.

Simplified Chinese-language edition copyright ©2017 by Posts & Telecom Press. All rights reserved.

本书中文简体字版由Manning Publications Co.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

版权所有,侵权必究。


Go 语言结合了底层系统语言的能力以及现代语言的高级特性,旨在降低构建简单、可靠、高效软件的门槛。本书向读者提供一个专注、全面且符合语言习惯的视角。本书同时关注语言的规范和实现,涉及的内容包括语法、类型系统、并发、通道、测试,以及其他一些主题。

本书是写给有其他编程语言基础且有一定开发经验的、想学Go语言的中级开发者的。对于刚开始要学习Go语言和想要深入了解Go语言内部实现的人来说,本书都是最佳的选择。


Go语言是由谷歌公司在2007年开始开发的一门语言,目的是能在多核心时代高效编写网络应用程序。Go语言的创始人Robert Griesemer、Rob Pike和Ken Thompson都是在计算机发展过程中作出过重要贡献的人。自从2009年11月正式公开发布后,Go语言迅速席卷了整个互联网后端开发领域,其社区里不断涌现出类似vitess、Docker、etcd、Consul等重量级的开源项目。

在Go语言发布后,我就被其简洁、强大的特性所吸引,并于2010年开始在技术聚会上宣传Go语言,当时所讲的题目是《Go语言:互联网时代的C》。现在看来,Go语言确实很好地解决了互联网时代开发的痛点,而且入门门槛不高,是一种上手容易、威力强大的工具。试想一下,不需要学习复杂的异步逻辑,使用习惯的顺序方法,就能实现高性能的网络服务,并充分利用系统的多个核心,这是多么美好的一件事情。

本书是国外Go社区多年经验积累的成果。本书默认读者已经具有一定的编程基础,希望更好地使用Go语言。全书以示例为基础,详细介绍了Go语言中的一些比较深入的话题。对于有经验的程序员来说,很容易通过学习书中的例子来解决自己实际工作中遇到的问题。辅以文字介绍,读者会对相关问题有更系统的了解和认识。翻译过程中我尽量保持了原书的叙述方法,并加强了叙述逻辑,希望读者会觉得清晰易读。

在翻译本书的过程中,感谢人民邮电出版社编辑杨海玲老师的指导和进度安排,让本书能按时与读者见面。感谢谢孟军对译稿的审校,你的润色使译文读起来流畅了很多。尤其要感谢我老婆对我的支持,感谢你能理解我出于热爱才会“匍匐”在计算机前码字。

最后,感谢读者购买此书。希望读者在探索Go语言的道路上,能够享受到和我一样的乐趣。


李兆海,多年专注于后端分布式网络服务开发,曾使用过多个流行后端技术和相关架构实践,是Go语言和Docker的早期使用者和推广者,《第一本Docker书》的译者。作为项目技术负责人,成功开发了百万用户级直播系统。


在计算机科学领域,提到不同寻常的人,总会有一些名字会闪现在你的脑海中。Rob Pike、Robert Griesmier和Ken Thompson就是其中几个。他们3个人负责构建过UNIX、Plan 9、B、Java的JVM HotSpot、V8、Strongtalk、Sawzall、Ed、Acme和UTF8,此外还有很多其他的创造。在2007年,这3个人凑在一起,尝试一个伟大的想法:综合他们多年的经验,借鉴已有的语言,来创建一门与众不同的、全新的系统语言。他们随后以开源的形式发布了自己的实验成果,并将这种语言命名为“Go”。如果按照现在的路线发展下去,这门语言将是这3个人最有影响的一项创造。

当人们聚在一起,纯粹是为了让世界变得更好的时候,往往也是他们处于最佳状态的时候。在2013年,为了围绕Go语言构建一个更好的社区,Brian和Erik联合成立了Gopher Academy,没过多久,Bill和其他一些有类似想法的人也加入进来。他们首先注意到,社区需要有一个地方可以在线聚集和分享素材,所以他们在slack创立了Go讨论版和Gopher Academy博客。随着时间的推移,社区越来越大,他们创建了世界上第一个全球Go语言大会—GopherCon。随着与社区更深入地交流,他们意识到还需要为广大想学习这门新语言的人提供一些资源,所以他们开始着手写一本书,就是现在你手里拿的这本书。

为Go社区贡献了大量的时间和精力的3位作者,出于对Go语言社区的热爱写就了这本书。我曾在Bill、Brian和Erik身边,见证了他们在不同的环境和角色(作为Gopher Academy博客的编辑,作为大会组织者,甚至是在他们的日常工作中,作为父亲和丈夫)下,都会认真负责地撰写和修订本书。对他们来说,这不仅仅是一本书,也是对他们心爱的语言的献礼。他们并不满足于写就一本“好”书。他们编写、审校,再写、再修改,再三推敲每页文字、每个例子、每一章,直到认为本书的内容配得上他们珍视的这门语言。

离开一门使用舒服、掌握熟练的语言,去学习一门不仅对自己来说,对整个世界来说都是全新的语言,是需要勇气的。这是一条人迹罕至,沿途充满bug,只有少数先行者熟悉的路。这里充满了意外的错误,文档不明确或者缺失,而且缺少可以拿来即用的代码库。这是拓荒者、先锋才会选择的道路。如果你正在读这本书,那么你可能正在踏上这段旅途。

本书自始至终是为你—本书的读者精心制作的一本探索、学习和使用Go语言的简洁而全面的指导手册。在全世界,你也不会找到比Bill、Brian和Erik更好的导师了。我非常高兴你能开始探索Go语言的优点,期望能在线上和线下大会上遇到你。

Steve Francia

Go语言开发者,Hugo、Cobra、Viper和SPF13-VIM的创建人

一个高性能强类型的Smalltalk实现。——译者注


那是2013年10月,我刚刚花几个月的时间写完GoingGo.net博客,就接到了Brian Ketelsen和Erik St. Martin的电话。他们正在写这本书,问我是否有兴趣参与进来。我立刻抓住机会,参与到写作中。当时,作为一个Go语言的新手,这是我进一步了解这门语言的好机会。毕竟,与Brian和Erik一起工作、一起分享获得的知识,比我从构建博客中学到的要多得多。

完成前4章后,我们在Manning早期访问项目(MEAP)中发布了这本书。很快,我们收到了来自语言团队成员的邮件。这位成员对很多细节提供了评审意见,还附加了大量有用的知识、意见、鼓励和支持。根据这些评审意见,我们决定从头开始重写第2章,并对第4章进行了全面修订。据我们所知,对整章进行重写的情况并不少见。通过这段重写的经历,我们学会要依靠社区的帮助来完成写作,因为我们希望能立刻得到社区的支持。

自那以后,这本书就成了社区努力的成果。我们投入了大量的时间研究每一章,开发样例代码,并和社区一起评审、讨论并编辑书中的材料和代码。我们尽了最大的努力来保证本书在技术上没有错误,让代码符合通用习惯,并且使用社区认为应该有的方式来教Go语言。同时,我们也融入了自己的思考、自己的实践和自己的指导方式。

我们希望本书能帮你学习Go语言,不仅是当下,就是多年以后,你也能从本书中找到有用的东西。Brian、Erik和我总会在线上帮助那些希望得到我们帮助的人。如果你购买了本书,谢谢你,来和我们打个招呼吧。

William Kennedy


我们花了18个月的时间来写本书。但是,离开下面这些人的支持,我们不可能完成这本书:我们的家人、朋友、同学、同事以及导师,整个Go社区,还有我们的出版商Manning。

当你开始撰写类似的书时,你需要一位编辑。编辑不仅要分享喜悦与成就,而且要不惜一切代价,帮你渡过难关。Jennifer Stout,你才华横溢,善于指导,是很棒的朋友。感谢你这段时间的付出,尤其是在我们最需要你的时候。感谢你让这本书变成现实。还要感谢为本书的开发和出版作出贡献的Manning的其他人。

每个人都不可能知晓一切,所以需要社区里的人付出时间和学识。感谢Go社区以及所有参与本书不同阶段书稿评审并提供反馈的人。特别感谢Adam McKay、Alex Basile、Alex Jacinto、Alex Vidal、Anjan Bacchu、Benoît Benedetti、Bill Katz、Brian Hetro、Colin Kennedy、Doug Sparling、Jeffrey Lim、Jesse Evans、Kevin Jackson、Mark Fisher、Matt Zulak、Paulo Pires、Peter Krey、Philipp K. Janert、Sam Zaydel以及Thomas O’Rourke。还要感谢Jimmy Frasché,他在出版前对本书书稿做了快速、准确的技术审校。

这里还需要特别感谢一些人。

Kim Shrier,从最开始就在提供评审意见,并花时间来指导我们。我们从你那里学到了很多,非常感谢。因为你,本书在技术上达到了更好的境界。

Bill Hathaway在写书的最后一年,深入参与,并校正了每一章。你的想法和意见非常宝贵。我们必须给予Bill“第9章合著者”的头衔。没有Bill的参与、天赋以及努力,就没有这一章的存在。

我们还要特别感谢Cory Jacobson、Jeffery Lim、Chetan Conikee和Nan Xiao为本书持续提供了评审意见和指导,感谢Gabriel Aszalos、Fatih Arslan、Kevin Gillette和Jason Waldrip帮助评审样例代码,还要特别感谢Steve Francia帮我们作序,认可我们的工作。

最后,我们真诚地感谢我们的家人和朋友。为本书付出的时间和代价,总会影响到你所爱的人。

我首先要感谢Lisa,我美丽的妻子,以及我的5个孩子:Brianna、Melissa、Amanda、Jarrod和Thomas。Lisa,我知道你和孩子们有太多的日夜和周末,缺少丈夫和父亲的陪伴。感谢你让我这段时间全力投入本书的工作:我爱你们,爱你们每一个人。

我也要感谢我生意上的伙伴Ed Gonzalez、创意经理Erick Zelaya,以及整个Ardan工作室的团队。Ed,感谢你从一开始就支持我。没有你,我就无法完成本书。你不仅是生意伙伴,还是朋友和兄长:谢谢你。Erick,感谢你为我、为公司做的一切。我不确定没有你,我们还能不能做到这一切。

首先要感谢我的家人在我写书的这4年间付出的耐心。Christine、Nathan、Lauren和Evelyn,感谢你们在游泳时放过在旁边椅子上写作的我,感谢你们相信这本书一定会出版。

我要感谢我的未婚妻Abby以及我的3个孩子Halie、Wyatt和Allie。感谢你们对我花大量时间写书和组织会议如此耐心和理解。我非常爱你们,有你们我非常幸运。

还要感谢Bill Kennedy为本书付出的巨大努力,以及当我们需要他的帮助的时候,他总是立刻想办法组织GopherCon来满足我们的要求。还要感谢整个社区出力评审并给出一些鼓励的话。


Go是一门开源的编程语言,目的在于降低构建简单、可靠、高效软件的门槛。尽管这门语言借鉴了很多其他语言的思想,但是凭借自身统一和自然的表达,Go程序在本质上完全不同于用其他语言编写的程序。Go平衡了底层系统语言的能力,以及在现代语言中所见到的高级特性。你可以依靠Go语言来构建一个非常快捷、高性能且有足够控制力的编程环境。使用Go语言,可以写得更少,做得更多。

本书是写给已经有一定其他语言编程经验,并且想学习Go语言的中级开发者的。我们写这本书的目的是,为读者提供一个专注、全面且符合语言习惯的视角。我们同时关注语言的规范和实现,涉及的内容包括语法、类型系统,并发、通道、测试以及其他一些主题。我们相信,对于刚开始学Go语言的人,以及想要深入了解这门语言内部实现的人来说,本书都是极佳的选择。

本书由9章组成,每章内容简要描述如下。

本书中的所有代码都使用等宽字体表示,以便和周围的文字区分开。在很多代码清单中,代码被注释是为了说明关键概念,并且有时在正文中会用数字编号来给出对应代码的其他信息。

本书的源代码既可以在Manning网站(www.manning.com/books/go-in-action)上下载,也可以在GitHub(https://github.com/goinaction/code)上找到这些源代码。

购买本书后,可以在线访问由Manning出版社提供的私有论坛。在这个论坛上可以对本书做评论,咨询技术问题,并得到作者或其他读者的帮助。通过浏览器访问www.manning.com/books/go-in-action可以访问并订阅这个论坛。这个网页还提供了注册后如何访问论坛,论坛提供什么样的帮助,以及论坛的行为准则等信息。

Manning向读者承诺提供一个读者之间以及读者和作者之间交流的场所。Manning并不承诺作者一定会参与,作者参与论坛的行为完全出于作者自愿(没有报酬)。我们建议你向作者提一些有挑战性的问题,否则可能提不起作者的兴趣。

只要本书未绝版,作者在线论坛以及早期讨论的存档就可以在出版商的网站上获取到。

William Kennedy(@goinggodotnet)是Ardan工作室的管理合伙人。这家工作室位于佛罗里达州迈阿密,是一家专注移动、Web和系统开发的公司。他也是博客GoingGo.net的作者,迈阿密Go聚会的组织者。从在培训公司Ardan Labs开始,他就专注于Go语言教学。无论是在当地,还是在线上,经常可以在大会或者工作坊中看到他的身影。他总是找时间来帮助那些想获取Go语言知识、撰写博客和编码技能提升到更高水平的公司或个人。

Brian Ketelsen(@bketelsen)是XOR Data Exchange的CIO和联合创始人。Brian也是每年Go语言大会(GohperCon)的合办者,同时也是Gopher Academy的创立者。作为专注于社区的组织,Gopher Academy一直在促进Go语言的发展和对Go语言开发者的培训。Brian从2010年就开始使用Go语言。

Erik St. Martin(@erikstmartin)是XOR Data Exchange的软件开发总监。他所在的公司专注于大数据分析,最早在得克萨斯州奥斯汀,后来搬到了佛罗里达州坦帕湾。Erik长时间为开源软件及其社区做贡献。他是每年GopherCon的组织者,也是坦帕湾Go聚会的组织者。他非常热爱Go语言及Go语言社区,积极寻求促进社区成长的新方法。

本书源代码也可以从www.epubit.com.cn本书网页免费下载。


本书封面插图的标题为“来自东印度的人”。这幅图选自伦敦的Thomas Jefferys的《A Collection of the Dresses of Different Nations, Ancient and Modern》(4卷),出版于1757年到1772年之间。书籍首页说明了这幅画的制作工艺是铜版雕刻,手工上色,外层用阿拉伯胶做保护。Thomas Jefferys(1719—1771)被称作“地理界的乔治三世国王”。作为制图者,他在当时英国地图商中处于领先地位。他为政府和其他官员雕刻和印刷地图,同时也制作大量的商业地图和地图册,尤其是北美地图。他作为地图制作者的经历,点燃了他收集各地风俗服饰的兴趣,最终成就了这部衣着集。

对遥远大陆的着迷以及对旅行的乐趣,是18世纪晚期才兴起的现象。这类收集品也风行一时,向实地旅行家和空想旅行家们介绍各地的风俗。Jefferys的画集如此多样,生动地向我们描述了200年前世界上不同民族的独立特征。从那之后,衣着的特征发生了改变,那个时代不同地区和国家的多样性,也逐渐消失。现在,很难再通过本地居民的服饰来区分他们所在的大陆。也许,从乐观的角度来看,我们用文化的多样性换取了更加多样化的个人生活——当然也是更加多样化和快节奏的科技生活。

在很难将一本计算机书与另一本区分开的时代,Manning创造性地将两个世纪以前不同地区的多样性,附着在计算机行业的图书封面上,借以来赞美计算机行业的创造力和进取精神也为Jefferys的画带来了新的生命。


本章主要内容

计算机一直在演化,但是编程语言并没有以同样的速度演化。现在的手机,内置的CPU核数可能都多于我们使用的第一台电脑。高性能服务器拥有64核、128核,甚至更多核。但是我们依旧在使用为单核设计的技术在编程。

编程的技术同样在演化。大部分程序不再由单个开发者来完成,而是由处于不同时区、不同时间段工作的一组人来完成。大项目被分解为小项目,指派给不同的程序员,程序员开发完成后,再以可以在各个应用程序中交叉使用的库或者包的形式,提交给整个团队。

如今的程序员和公司比以往更加信任开源软件的力量。Go语言是一种让代码分享更容易的编程语言。Go 语言自带一些工具,让使用别人写的包更容易,并且 Go 语言也让分享自己写的包更容易。

在本章中读者会看到Go语言区别于其他编程语言的地方。Go语言对传统的面向对象开发进行了重新思考,并且提供了更高效的复用代码的手段。Go语言还让用户能更高效地利用昂贵服务器上的所有核心,而且它编译大型项目的速度也很快。

在阅读本章时,读者会对影响Go语言形态的很多决定有一些认识,从它的并发模型到快如闪电的编译器。我们在前言中提到过,这里再强调一次:这本书是写给已经有一定其他编程语言经验、想学习Go语言的中级开发者的。本书会提供一个专注、全面且符合习惯的视角。我们同时专注语言的规范和实现,涉及的内容包括语法、Go语言的类型系统、并发、通道、测试以及其他一些非常广泛的主题。我们相信,对刚开始要学习Go语言和想要深入了解语言内部实现的人来说,本书都是最佳选择。

本书示例中的源代码可以在https://github.com/goinaction/code下载。

我们希望读者能认识到,Go语言附带的工具可以让开发人员的生活变得更简单。最后,读者会意识到为什么那么多开发人员用Go语言来构建自己的新项目。

Go语言开发团队花了很长时间来解决当今软件开发人员面对的问题。开发人员在为项目选择语言时,不得不在快速开发和性能之间做出选择。C和C++这类语言提供了很快的执行速度,而Ruby和Python这类语言则擅长快速开发。Go语言在这两者间架起了桥梁,不仅提供了高性能的语言,同时也让开发更快速。

在探索Go语言的过程中,读者会看到精心设计的特性以及简洁的语法。作为一门语言,Go不仅定义了能做什么,还定义了不能做什么。Go语言的语法简洁到只有几个关键字,便于记忆。Go语言的编译器速度非常快,有时甚至会让人感觉不到在编译。所以,Go开发者能显著减少等待项目构建的时间。因为Go语言内置并发机制,所以不用被迫使用特定的线程库,就能让软件扩展,使用更多的资源。Go语言的类型系统简单且高效,不需要为面向对象开发付出额外的心智,让开发者能专注于代码复用。Go语言还自带垃圾回收器,不需要用户自己管理内存。让我们快速浏览一下这些关键特性。

编译一个大型的C或者C++项目所花费的时间甚至比去喝杯咖啡的时间还长。图1-1是XKCD中的一幅漫画,描述了在办公室里开小差的经典借口。

图1-1 努力工作?(来自XKCD)

Go语言使用了更加智能的编译器,并简化了解决依赖的算法,最终提供了更快的编译速度。编译Go程序时,编译器只会关注那些直接被引用的库,而不是像Java、C和C++那样,要遍历依赖链中所有依赖的库。因此,很多Go程序可以在1秒内编译完。在现代硬件上,编译整个Go语言的源码树只需要20秒。

因为没有从编译代码到执行代码的中间过程,用动态语言编写应用程序可以快速看到输出。代价是,动态语言不提供静态语言提供的类型安全特性,不得不经常用大量的测试套件来避免在运行的时候出现类型错误这类bug。

想象一下,使用类似JavaScript这种动态语言开发一个大型应用程序,有一个函数期望接收一个叫作ID的字段。这个参数应该是整数,是字符串,还是一个UUID?要想知道答案,只能去看源代码。可以尝试使用一个数字或者字符串来执行这个函数,看看会发生什么。在Go语言里,完全不用为这件事情操心,因为编译器就能帮用户捕获这种类型错误。

作为程序员,要开发出能充分利用硬件资源的应用程序是一件很难的事情。现代计算机都拥有多个核,但是大部分编程语言都没有有效的工具让程序可以轻易利用这些资源。这些语言需要写大量的线程同步代码来利用多个核,很容易导致错误。

Go语言对并发的支持是这门语言最重要的特性之一。goroutine很像线程,但是它占用的内存远少于线程,使用它需要的代码更少。通道(channel)是一种内置的数据结构,可以让用户在不同的goroutine之间同步发送具有类型的消息。这让编程模型更倾向于在goroutine之间发送消息,而不是让多个goroutine争夺同一个数据的使用权。让我们看看这些特性的细节。

1.goroutine

goroutine是可以与其他goroutine并行执行的函数,同时也会与主程序(程序的入口)并行执行。在其他编程语言中,你需要用线程来完成同样的事情,而在Go语言中会使用同一个线程来执行多个goroutine。例如,用户在写一个Web服务器,希望同时处理不同的Web请求,如果使用C或者Java,不得不写大量的额外代码来使用线程。在Go语言中,net/http库直接使用了内置的goroutine。每个接收到的请求都自动在其自己的goroutine里处理。goroutine使用的内存比线程更少,Go语言运行时会自动在配置的一组逻辑处理器上调度执行goroutine。每个逻辑处理器绑定到一个操作系统线程上(见图1-2)。这让用户的应用程序执行效率更高,而开发工作量显著减少。

图1-2 在单一系统线程上执行多个goroutine

如果想在执行一段代码的同时,并行去做另外一些事情,goroutine是很好的选择。下面是一个简单的例子:

func log(msg string) {
    ...这里是一些记录日志的代码
}

// 代码里有些地方检测到了错误
go log("发生了可怕的事情")

关键字go是唯一需要去编写的代码,调度log函数作为独立的goroutine去运行,以便与其他goroutine并行执行。这意味着应用程序的其余部分会与记录日志并行执行,通常这种并行能让最终用户觉得性能更好。就像之前说的,goroutine占用的资源更少,所以常常能启动成千上万个goroutine。我们会在第6章更加深入地探讨goroutine和并发。

2.通道

通道是一种数据结构,可以让goroutine之间进行安全的数据通信。通道可以帮用户避免其他语言里常见的共享内存访问的问题。

并发的最难的部分就是要确保其他并发运行的进程、线程或goroutine不会意外修改用户的数据。当不同的线程在没有同步保护的情况下修改同一个数据时,总会发生灾难。在其他语言中,如果使用全局变量或者共享内存,必须使用复杂的锁规则来防止对同一个变量的不同步修改。

为了解决这个问题,通道提供了一种新模式,从而保证并发修改时的数据安全。通道这一模式保证同一时刻只会有一个goroutine修改数据。通道用于在几个运行的goroutine之间发送数据。在图1-3中可以看到数据是如何流动的示例。想象一个应用程序,有多个进程需要顺序读取或者修改某个数据,使用goroutine和通道,可以为这个过程建立安全的模型。

图1-3 使用通道在goroutine之间安全地发送数据

图1-3中有3个goroutine,还有2个不带缓存的通道。第一个goroutine通过通道把数据传给已经在等待的第二个goroutine。在两个goroutine间传输数据是同步的,一旦传输完成,两个goroutine都会知道数据已经完成传输。当第二个goroutine利用这个数据完成其任务后,将这个数据传给第三个正在等待的goroutine。这次传输依旧是同步的,两个goroutine都会确认数据传输完成。这种在goroutine之间安全传输数据的方法不需要任何锁或者同步机制。

需要强调的是,通道并不提供跨goroutine的数据访问保护机制。如果通过通道传输数据的一份副本,那么每个goroutine都持有一份副本,各自对自己的副本做修改是安全的。当传输的是指向数据的指针时,如果读和写是由不同的goroutine完成的,每个goroutine依旧需要额外的同步动作。

Go语言提供了灵活的、无继承的类型系统,无需降低运行性能就能最大程度上复用代码。这个类型系统依然支持面向对象开发,但避免了传统面向对象的问题。如果你曾经在复杂的Java和C++程序上花数周时间考虑如何抽象类和接口,你就能意识到Go语言的类型系统有多么简单。Go 开发者使用组合(composition)设计模式,只需简单地将一个类型嵌入到另一个类型,就能复用所有的功能。其他语言也能使用组合,但是不得不和继承绑在一起使用,结果使整个用法非常复杂,很难使用。在Go语言中,一个类型由其他更微小的类型组合而成,避免了传统的基于继承的模型。

另外,Go语言还具有独特的接口实现机制,允许用户对行为进行建模,而不是对类型进行建模。在Go语言中,不需要声明某个类型实现了某个接口,编译器会判断一个类型的实例是否符合正在使用的接口。Go标准库里的很多接口都非常简单,只开放几个函数。从实践上讲,尤其对那些使用类似Java的面向对象语言的人来说,需要一些时间才能习惯这个特性。

1.类型简单

Go语言不仅有类似intstring这样的内置类型,还支持用户定义的类型。在Go语言中,用户定义的类型通常包含一组带类型的字段,用于存储数据。Go语言的用户定义的类型看起来和C语言的结构很像,用起来也很相似。不过Go语言的类型可以声明操作该类型数据的方法。传统语言使用继承来扩展结构——Client继承自User,User继承自Entity,Go语言与此不同,Go开发者构建更小的类型——Customer和Admin,然后把这些小类型组合成更大的类型。图1-4展示了继承和组合之间的不同。

图1-4 继承和组合的对比

2.Go接口对一组行为建模

接口用于描述类型的行为。如果一个类型的实例实现了一个接口,意味着这个实例可以执行一组特定的行为。你甚至不需要去声明这个实例实现某个接口,只需要实现这组行为就好。其他的语言把这个特性叫作鸭子类型——如果它叫起来像鸭子,那它就可能是只鸭子。Go语言的接口也是这么做的。在Go语言中,如果一个类型实现了一个接口的所有方法,那么这个类型的实例就可以存储在这个接口类型的实例中,不需要额外声明。

在类似Java这种严格的面向对象语言中,所有的设计都围绕接口展开。在编码前,用户经常不得不思考一个庞大的继承链。下面是一个Java接口的例子:

interface User {
    public void login();
    public void logout();
}

在Java中要实现这个接口,要求用户的类必须满足User接口里的所有约束,并且显式声明这个类实现了这个接口。而Go语言的接口一般只会描述一个单一的动作。在Go语言中,最常使用的接口之一是io.Reader。这个接口提供了一个简单的方法,用来声明一个类型有数据可以读取。标准库内的其他函数都能理解这个接口。这个接口的定义如下:

type Reader interface {
    Read(p []byte) (n int, err error)
}

为了实现io.Reader这个接口,你只需要实现一个Read方法,这个方法接受一个byte切片,返回一个整数和可能出现的错误。

这和传统的面向对象编程语言的接口系统有本质的区别。Go语言的接口更小,只倾向于定义一个单一的动作。实际使用中,这更有利于使用组合来复用代码。用户几乎可以给所有包含数据的类型实现io.Reader接口,然后把这个类型的实例传给任意一个知道如何读取io.Reader的Go函数。

Go语言的整个网络库都使用了io.Reader接口,这样可以将程序的功能和不同网络的实现分离。这样的接口用起来有趣、优雅且自由。文件、缓冲区、套接字以及其他的数据源都实现了io.Reader接口。使用同一个接口,可以高效地操作数据,而不用考虑到底数据来自哪里。

不当的内存管理会导致程序崩溃或者内存泄漏,甚至让整个操作系统崩溃。Go语言拥有现代化的垃圾回收机制,能帮你解决这个难题。在其他系统语言(如C或者C++)中,使用内存前要先分配这段内存,而且使用完毕后要将其释放掉。哪怕只做错了一件事,都可能导致程序崩溃或者内存泄漏。可惜,追踪内存是否还被使用本身就是十分艰难的事情,而要想支持多线程和高并发,更是让这件事难上加难。虽然Go语言的垃圾回收会有一些额外的开销,但是编程时,能显著降低开发难度。Go语言把无趣的内存管理交给专业的编译器去做,而让程序员专注于更有趣的事情。

感受一门语言最简单的方法就是实践。让我们看看用Go语言如何编写经典的Hello World!应用程序:

package main   ●――――Go程序都组织成包。

import "fmt"   ●――――import语句用于导入外部代码。标准库中的fmt包用于格式化并输出数据。

func main() {  ●――――像C语言一样,main函数是程序执行的入口。
    fmt.Println("Hello world!")

}

运行这个示例程序后会在屏幕上输出我们熟悉的一句话。但是怎么运行呢?无须在机器上安装Go语言,在浏览器中就可以使用几乎所有Go语言的功能。

Go Playground允许在浏览器里编辑并运行Go语言代码。在浏览器中打开http://play.golang.org。浏览器里展示的代码是可编辑的(见图1-5)。点击Run,看看会发生什么。

图1-5 Go Playground

可以把输出的问候文字改成别的语言。试着改动fmt.Println()里面的文字,然后再次点击Run。

分享Go代码 Go开发者使用Playground分享他们的想法,测试理论,或者调试代码。你也可以这么做。每次使用Playground创建一个新程序之后,可以点击Share得到一个用于分享的网址。任何人都能打开这个链接。试试http://play.golang.org/p/EWIXicJdmz

要给想要学习写东西或者寻求帮助的同事或者朋友演示某个想法时,Go Playground是非常好的方式。在Go语言的IRC频道、Slack群组、邮件列表和Go开发者发送的无数邮件里,用户都能看到创建、修改和分享Go Playground上的程序。


本章主要内容

为了能更高效地使用语言进行编码,Go语言有自己的哲学和编程习惯。Go语言的设计者们从编程效率出发设计了这门语言,但又不会丢掉访问底层程序结构的能力。设计者们通过一组最少的关键字、内置的方法和语法,最终平衡了这两方面。Go语言也提供了完善的标准库。标准库提供了构建实际的基于Web和基于网络的程序所需的所有核心库。

让我们通过一个完整的Go语言程序,来看看Go语言是如何实现这些功能的。这个程序实现的功能很常见,能在很多现在开发的Go程序里发现类似的功能。这个程序从不同的数据源拉取数据,将数据内容与一组搜索项做对比,然后将匹配的内容显示在终端窗口。这个程序会读取文本文件,进行网络调用,解码XML和JSON成为结构化类型数据,并且利用Go语言的并发机制保证这些操作的速度。

读者可以下载本章的代码,用自己喜欢的编辑器阅读。代码存放在这个代码库:

https://github.com/goinaction/code/tree/master/chapter2/sample

没必要第一次就读懂本章的所有内容,可以多读两遍。在学习时,虽然很多现代语言的概念可以对应到Go语言中,Go语言还是有一些独特的特性和风格。如果放下已经熟悉的编程语言,用一种全新的眼光来审视Go语言,你会更容易理解并接受Go语言的特性,发现Go语言的优雅。

在深入代码之前,让我们看一下程序的架构(如图 2-1 所示),看看如何在所有不同的数据源中搜索数据。

图2-1 程序架构流程图

这个程序分成多个不同步骤,在多个不同的goroutine里运行。我们会根据流程展示代码,从主goroutine开始,一直到执行搜索的goroutine和跟踪结果的goroutine,最后回到主goroutine。首先来看一下整个项目的结构,如代码清单2-1所示。

代码清单2-1 应用程序的项目结构

cd $GOPATH/src/github.com/goinaction/code/chapter2

- sample
    - data
        data.json   -- 包含一组数据源
    - matchers
        rss.go      -- 搜索rss源的匹配器
    - search
        default.go  -- 搜索数据用的默认匹配器
        feed.go     -- 用于读取json数据文件
        match.go    -- 用于支持不同匹配器的接口
        search.go   -- 执行搜索的主控制逻辑
    main.go         -- 程序的入口

这个应用的代码使用了4个文件夹,按字母顺序列出。文件夹data中有一个JSON文档,其内容是程序要拉取和处理的数据源。文件夹matchers中包含程序里用于支持搜索不同数据源的代码。目前程序只完成了支持处理RSS类型的数据源的匹配器。文件夹search中包含使用不同匹配器进行搜索的业务逻辑。最后,父级文件夹sample中有个main.go文件,这是整个程序的入口。

现在了解了如何组织程序的代码,可以继续探索并了解程序是如何工作的。让我们从程序的入口开始。

程序的主入口可以在main.go文件里找到,如代码清单2-2所示。虽然这个文件只有21行代码,依然有几点需要注意。

代码清单2-2 main.go

01 package main
02
03 import (
04     "log"
05     "os"
06
07     _ "github.com/goinaction/code/chapter2/sample/matchers"
08      "github.com/goinaction/code/chapter2/sample/search"
09 )
10
11 // init在main之前调用
12 func init() {
13     // 将日志输出到标准输出
14     log.SetOutput(os.Stdout)
15 }
16
17 // main 是整个程序的入口
18 func main() {
19     // 使用特定的项做搜索
20     search.Run("president")
21 }

每个可执行的Go程序都有两个明显的特征。一个特征是第18行声明的名为main的函数。构建程序在构建可执行文件时,需要找到这个已经声明的main函数,把它作为程序的入口。第二个特征是程序的第01行的包名main,如代码清单2-3所示。

代码清单2-3 main.go:第01行

01 package main

可以看到,main函数保存在名为main的包里。如果main函数不在main包里,构建工具就不会生成可执行的文件。

Go语言的每个代码文件都属于一个包,main.go也不例外。包这个特性对于Go语言来说很重要,我们会在第3章中接触到更多细节。现在,只要简单了解以下内容:一个包定义一组编译过的代码,包的名字类似命名空间,可以用来间接访问包内声明的标识符。这个特性可以把不同包中定义的同名标识符区别开。

现在,把注意力转到main.go的第03行到第09行,如代码清单2-4所示,这里声明了所有的导入项。

代码清单2-4 main.go:第03行到第09行

03 import (
04     "log"
05     "os"
06
07     _ "github.com/goinaction/code/chapter2/sample/matchers"
08      "github.com/goinaction/code/chapter2/sample/search"
09 )

顾名思义,关键字import就是导入一段代码,让用户可以访问其中的标识符,如类型、函数、常量和接口。在这个例子中,由于第08行的导入,main.go里的代码就可以引用search包里的Run函数。程序的第04行和第05行导入标准库里的logos包。

所有处于同一个文件夹里的代码文件,必须使用同一个包名。按照惯例,包和文件夹同名。就像之前说的,一个包定义一组编译后的代码,每段代码都描述包的一部分。如果回头去看看代码清单2-1,可以看看第08行的导入是如何指定那个项目里名叫search的文件夹的。

读者可能注意到第07行导入matchers包的时候,导入的路径前面有一个下划线,如代码清单2-5所示。

代码清单2-5 main.go:第07行

07     _ "github.com/goinaction/code/chapter2/sample/matchers"

这个技术是为了让Go语言对包做初始化操作,但是并不使用包里的标识符。为了让程序的可读性更强,Go 编译器不允许声明导入某个包却不使用。下划线让编译器接受这类导入,并且调用对应包内的所有代码文件里定义的init函数。对这个程序来说,这样做的目的是调用matchers包中的rss.go代码文件里的init函数,注册RSS匹配器,以便后用。我们后面会展示具体的工作方式。

代码文件main.go里也有一个init函数,在第12行到第15行中声明,如代码清单2-6所示。

代码清单2-6 main.go:第11行到第15行

11 // init在main之前调用
12 func init() {
13     // 将日志输出到标准输出
14     log.SetOutput(os.Stdout)
15 }

程序中每个代码文件里的init函数都会在main函数执行前调用。这个init函数将标准库里日志类的输出,从默认的标准错误(stderr),设置为标准输出(stdout)设备。在第7章,我们会进一步讨论log包和标准库里其他重要的包。

最后,让我们看看main函数第20行那条语句的作用,如代码清单2-7所示。

代码清单2-7 main.go:第19行到第20行

19     // 使用特定的项做搜索
20     search.Run("president")

可以看到,这一行调用了search包里的Run函数。这个函数包含程序的核心业务逻辑,需要传入一个字符串作为搜索项。一旦Run函数退出,程序就会终止。

现在,让我们看看search包里的代码。

这个程序使用的框架和业务逻辑都在search包里。这个包由4个不同的代码文件组成,每个文件对应一个独立的职责。我们会逐步分析这个程序的逻辑,到时再说明各个代码文件的作用。

由于整个程序都围绕匹配器来运作,我们先简单介绍一下什么是匹配器。这个程序里的匹配器,是指包含特定信息、用于处理某类数据源的实例。在这个示例程序中有两个匹配器。框架本身实现了一个无法获取任何信息的默认匹配器,而在matchers包里实现了RSS匹配器。RSS匹配器知道如何获取、读入并查找RSS数据源。随后我们会扩展这个程序,加入能读取JSON文档或CSV文件的匹配器。我们后面会再讨论如何实现匹配器。

代码清单2-8中展示的是search.go代码文件的前9行代码。之前提到的Run函数就在这个文件里。

代码清单2-8 search/search.go:第01行到第09行

01 package search
02
03 import (
04     "log"
05     "sync"
06 )
07
08 // 注册用于搜索的匹配器的映射
09 var matchers = make(map[string]Matcher)

可以看到,每个代码文件都以package关键字开头,随后跟着包的名字。文件夹search下的每个代码文件都使用search作为包名。第03行到第06行代码导入标准库的logsync包。

与第三方包不同,从标准库中导入代码时,只需要给出要导入的包名。编译器查找包的时候,总是会到GOROOTGOPATH环境变量(如代码清单2-9所示)引用的位置去查找。

代码清单2-9 GOROOTGOPATH环境变量

GOROOT="/Users/me/go"
GOPATH="/Users/me/spaces/go/projects"

log包提供打印日志信息到标准输出(stdout)、标准错误(stderr)或者自定义设备的功能。sync包提供同步goroutine的功能。这个示例程序需要用到同步功能。第09行是全书第一次声明一个变量,如代码清单2-10所示。

代码清单2-10 search/search.go:第08行到第09行

08 // 注册用于搜索的匹配器的映射
09 var matchers = make(map[string]Matcher)

这个变量没有定义在任何函数作用域内,所以会被当成包级变量。这个变量使用关键字var声明,而且声明为Matcher类型的映射(map),这个映射以string类型值作为键,Matcher类型值作为映射后的值。Matcher类型在代码文件matcher.go中声明,后面再讲这个类型的用途。这个变量声明还有一个地方要强调一下:变量名matchers是以小写字母开头的。

在Go语言里,标识符要么从包里公开,要么不从包里公开。当代码导入了一个包时,程序可以直接访问这个包中任意一个公开的标识符。这些标识符以大写字母开头。以小写字母开头的标识符是不公开的,不能被其他包中的代码直接访问。但是,其他包可以间接访问不公开的标识符。例如,一个函数可以返回一个未公开类型的值,那么这个函数的任何调用者,哪怕调用者不是在这个包里声明的,都可以访问这个值。

这行变量声明还使用赋值运算符和特殊的内置函数make初始化了变量,如代码清单2-11所示。

代码清单2-11 构建一个映射

make(map[string]Matcher)

map是Go语言里的一个引用类型,需要使用make来构造。如果不先构造map并将构造后的值赋值给变量,会在试图使用这个map变量时收到出错信息。这是因为map变量默认的零值是nil。在第4章我们会进一步了解关于映射的细节。

在Go语言中,所有变量都被初始化为其零值。对于数值类型,零值是0;对于字符串类型,零值是空字符串;对于布尔类型,零值是false;对于指针,零值是nil。对于引用类型来说,所引用的底层数据结构会被初始化为对应的零值。但是被声明为其零值的引用类型的变量,会返回nil作为其值。

现在,让我们看看之前在main函数中调用的Run函数的内容,如代码清单2-12所示。

代码清单2-12 search/search.go:第11行到第57行

11 // Run执行搜索逻辑
12 func Run(searchTerm string) {
13     // 获取需要搜索的数据源列表
14     feeds, err := RetrieveFeeds()
15     if err != nil {
16         log.Fatal(err)
17     }
18
19     // 创建一个无缓冲的通道,接收匹配后的结果
20     results := make(chan *Result)
21
22     // 构造一个waitGroup,以便处理所有的数据源
23     var waitGroup sync.WaitGroup
24
25     // 设置需要等待处理
26     // 每个数据源的goroutine的数量
27     waitGroup.Add(len(feeds))
28
29     // 为每个数据源启动一个goroutine来查找结果
30     for _, feed := range feeds {
31         // 获取一个匹配器用于查找
32         matcher, exists := matchers[feed.Type]
33         if !exists {
34             matcher = matchers["default"]
35         }
36
37         // 启动一个goroutine来执行搜索
38         go func(matcher Matcher, feed *Feed) {
39             Match(matcher, feed, searchTerm, results)
40             waitGroup.Done()
41         }(matcher, feed)
42     }
43
44     // 启动一个goroutine来监控是否所有的工作都做完了
45     go func() {
46         // 等候所有任务完成
47         waitGroup.Wait()
48
49         // 用关闭通道的方式,通知Display函数
50         // 可以退出程序了
51         close(results)
52     }()
53
54     // 启动函数,显示返回的结果,并且
55     // 在最后一个结果显示完后返回
56     Display(results)
57 }

Run函数包括了这个程序最主要的控制逻辑。这段代码很好地展示了如何组织Go程序的代码,以便正确地并发启动和同步goroutine。先来一步一步考察整个逻辑,再考察每步实现代码的细节。

先来看看Run函数是怎么定义的,如代码清单2-13所示。

代码清单2-13 search/search.go:第11行到第12行

11 // Run 执行搜索逻辑
12 func Run(searchTerm string) {

Go语言使用关键字func声明函数,关键字后面紧跟着函数名、参数以及返回值。对于Run这个函数来说,只有一个参数,是string类型的,名叫searchTerm。这个参数是Run函数要搜索的搜索项,如果回头看看main函数(如代码清单2-14所示),可以看到如何传递这个搜索项。

代码清单2-14 main.go:第17行到第21行

17 // main 是整个程序的入口
18 func main() {
19     // 使用特定的项做搜索
20     search.Run("president")
21 }

Run函数做的第一件事情就是获取数据源feeds列表。这些数据源从互联网上抓取数据,之后对数据使用特定的搜索项进行匹配,如代码清单2-15所示。

代码清单2-15 search/search.go:第13行到第17行

13     // 获取需要搜索的数据源列表
14     feeds, err := RetrieveFeeds()
15     if err != nil {
16         log.Fatal(err)
17     }

这里有几个值得注意的重要概念。第14行调用了search包的RetrieveFeeds函数。这个函数返回两个值。第一个返回值是一组Feed类型的切片。切片是一种实现了一个动态数组的引用类型。在Go语言里可以用切片来操作一组数据。第4章会进一步深入了解有关切片的细节。

第二个返回值是一个错误值。在第15行,检查返回的值是不是真的是一个错误。如果真的发生错误了,就会调用log包里的Fatal函数。Fatal函数接受这个错误的值,并将这个错误在终端窗口里输出,随后终止程序。

不仅仅是Go语言,很多语言都允许一个函数返回多个值。一般会像RetrieveFeeds函数这样声明一个函数返回一个值和一个错误值。如果发生了错误,永远不要使用该函数返回的另一个值。这时必须忽略另一个值,否则程序会产生更多的错误,甚至崩溃。

让我们仔细看看从函数返回的值是如何赋值给变量的,如代码清单2-16所示。

代码清单2-16 search/search.go:第13行到第14行

13     // 获取需要搜索的数据源列表
14     feeds, err := RetrieveFeeds()

这里可以看到简化变量声明运算符(:=)。这个运算符用于声明一个变量,同时给这个变量赋予初始值。编译器使用函数返回值的类型来确定每个变量的类型。简化变量声明运算符只是一种简化记法,让代码可读性更高。这个运算符声明的变量和其他使用关键字var声明的变量没有任何区别。

现在我们得到了数据源列表,进入到后面的代码,如代码清单2-17所示。

代码清单2-17 search/search.go:第19行到第20行

19     // 创建一个无缓冲的通道,接收匹配后的结果
20     results := make(chan *Result)

在第20行,我们使用内置的make函数创建了一个无缓冲的通道。我们使用简化变量声明运算符,在调用make的同时声明并初始化该通道变量。根据经验,如果需要声明初始值为零值的变量,应该使用var关键字声明变量;如果提供确切的非零值初始化变量或者使用函数返回值创建变量,应该使用简化变量声明运算符。

在Go语言中,通道(channel)和映射(map)与切片(slice)一样,也是引用类型,不过通道本身实现的是一组带类型的值,这组值用于在goroutine之间传递数据。通道内置同步机制,从而保证通信安全。在第6章中,我们会介绍更多关于通道和goroutine的细节。

之后两行是为了防止程序在全部搜索执行完之前终止,如代码清单2-18所示。

代码清单2-18 search/search.go:第22行到第27行

22     // 构造一个wait group,以便处理所有的数据源
23     var waitGroup sync.WaitGroup
24
25     // 设置需要等待处理
26     // 每个数据源的goroutine的数量
27     waitGroup.Add(len(feeds))

在Go语言中,如果main函数返回,整个程序也就终止了。Go程序终止时,还会关闭所有之前启动且还在运行的goroutine。写并发程序的时候,最佳做法是,在main函数返回前,清理并终止所有之前启动的goroutine。编写启动和终止时的状态都很清晰的程序,有助减少bug,防止资源异常。

这个程序使用sync包的WaitGroup跟踪所有启动的goroutine。非常推荐使用WaitGroup来跟踪goroutine的工作是否完成。WaitGroup是一个计数信号量,我们可以利用它来统计所有的goroutine是不是都完成了工作。

在第23行我们声明了一个sync包里的WaitGroup类型的变量。之后在第27行,我们将WaitGroup变量的值设置为将要启动的goroutine的数量。马上就能看到,我们为每个数据源都启动了一个goroutine来处理数据。每个goroutine完成其工作后,就会递减WaitGroup变量的计数值,当这个值递减到0时,我们就知道所有的工作都做完了。

现在让我们来看看为每个数据源启动goroutine的代码,如代码清单2-19所示。

代码清单2-19 search/search.go:第29行到第42行

29     // 为每个数据源启动一个goroutine来查找结果
30     for _, feed := range feeds {
31         // 获取一个匹配器用于查找
32         matcher, exists := matchers[feed.Type]
33         if !exists {
34             matcher = matchers["default"]
35         }
36
37         // 启动一个 goroutine来执行搜索
38         go func(matcher Matcher, feed *Feed) {
39             Match(matcher, feed, searchTerm, results)
40             waitGroup.Done()
41         }(matcher, feed)
42     }

第30行到第42行迭代之前获得的feeds,为每个feed启动一个goroutine。我们使用关键字for rangefeeds切片做迭代。关键字range可以用于迭代数组、字符串、切片、映射和通道。使用for range迭代切片时,每次迭代会返回两个值。第一个值是迭代的元素在切片里的索引位置,第二个值是元素值的一个副本。

如果仔细看一下第30行的for range语句,会发现再次使用了下划线标识符,如代码清单2-20所示。

代码清单2-20 search/search.go:第29行到第30行

29     // 为每个数据源启动一个 goroutine来查找结果
30     for _, feed := range feeds {

这是第二次看到使用了下划线标识符。第一次是在main.go里导入matchers包的时候。这次,下划线标识符的作用是占位符,占据了保存range调用返回的索引值的变量的位置。如果要调用的函数返回多个值,而又不需要其中的某个值,就可以使用下划线标识符将其忽略。在我们的例子里,我们不需要使用返回的索引值,所以就使用下划线标识符把它忽略掉。

在循环中,我们首先通过map查找到一个可用于处理特定数据源类型的数据的Matcher值,如代码清单2-21所示。

代码清单2-21 search/search.go:第31行到第35行

31         // 获取一个匹配器用于查找
32         matcher, exists := matchers[feed.Type]
33         if !exists {
34             matcher = matchers["default"]
35         }

我们还没有说过map里面的值是如何获得的。一会儿就会在程序初始化的时候看到如何设置map里的值。在第32行,我们检查map是否含有符合数据源类型的值。查找map里的键时,有两个选择:要么赋值给一个变量,要么为了精确查找,赋值给两个变量。赋值给两个变量时第一个值和赋值给一个变量时的值一样,是map查找的结果值。如果指定了第二个值,就会返回一个布尔标志,来表示查找的键是否存在于map里。如果这个键不存在,map会返回其值类型的零值作为返回值,如果这个键存在,map会返回键所对应值的副本。

在第33行,我们检查这个键是否存在于map里。如果不存在,使用默认匹配器。这样程序在不知道对应数据源的具体类型时,也可以执行,而不会中断。之后,启动一个goroutine来执行搜索,如代码清单2-22所示。

代码清单2-22 search/search.go:第37行到第41行

37         // 启动一个 goroutine来执行搜索
38         go func(matcher Matcher, feed *Feed) {
39             Match(matcher, feed, searchTerm, results)
40             waitGroup.Done()
41         }(matcher, feed)

我们会在第6章进一步学习goroutine,现在只要知道,一个goroutine是一个独立于其他函数运行的函数。使用关键字go启动一个goroutine,并对这个goroutine做并发调度。在第38行,我们使用关键字go启动了一个匿名函数作为goroutine。匿名函数是指没有明确声明名字的函数。在for range循环里,我们为每个数据源,以goroutine的方式启动了一个匿名函数。这样可以并发地独立处理每个数据源的数据。

匿名函数也可以接受声明时指定的参数。在第38行,我们指定匿名函数要接受两个参数,一个类型为Matcher,另一个是指向一个Feed类型值的指针。这意味着变量feed是一个指针变量。指针变量可以方便地在函数之间共享数据。使用指针变量可以让函数访问并修改一个变量的状态,而这个变量可以在其他函数甚至是其他goroutine的作用域里声明。

在第41行,matcherfeed两个变量的值被传入匿名函数。在Go语言中,所有的变量都以值的方式传递。因为指针变量的值是所指向的内存地址,在函数间传递指针变量,是在传递这个地址值,所以依旧被看作以值的方式在传递。

在第39行到第40行,可以看到每个goroutine是如何工作的,如代码清单2-23所示。

代码清单2-23 search/search.go:第39行到第40行

39         Match(matcher, feed, searchTerm, results)
40         waitGroup.Done()

goroutine做的第一件事是调用一个叫Match的函数,这个函数可以在match.go文件里找到。Match函数的参数是一个Matcher类型的值、一个指向Feed类型值的指针、搜索项以及输出结果的通道。我们一会儿再看这个函数的内部细节,现在只要知道,Match函数会搜索数据源的数据,并将匹配结果输出到results通道。

一旦Match函数调用完毕,就会执行第40行的代码,递减WaitGroup的计数。一旦每个goroutine都执行调用Match函数和Done方法,程序就知道每个数据源都处理完成。调用Done方法这一行还有一个值得注意的细节:WaitGroup的值没有作为参数传入匿名函数,但是匿名函数依旧访问到了这个值。

Go语言支持闭包,这里就应用了闭包。实际上,在匿名函数内访问searchTermresults变量,也是通过闭包的形式访问的。因为有了闭包,函数可以直接访问到那些没有作为参数传入的变量。匿名函数并没有拿到这些变量的副本,而是直接访问外层函数作用域中声明的这些变量本身。因为matcherfeed变量每次调用时值不相同,所以并没有使用闭包的方式访问这两个变量,如代码清单2-24所示。

代码清单2-24 search/search.go:第29行到第32行

29     // 为每个数据源启动一个goroutine来查找结果
30     for _, feed := range feeds {
31         // 获取一个匹配器用于查找
32         matcher, exists := matchers[feed.Type]

可以看到,在第30行到第32行,变量feedmatcher的值会随着循环的迭代而改变。如果我们使用闭包访问这些变量,随着外层函数里变量值的改变,内层的匿名函数也会感知到这些改变。所有的goroutine都会因为闭包共享同样的变量。除非我们以函数参数的形式传值给函数,否则绝大部分goroutine最终都会使用同一个matcher来处理同一个feed——这个值很有可能是feeds切片的最后一个值。

随着每个goroutine搜索工作的运行,将结果发送到results通道,并递减waitGroup的计数,我们需要一种方法来显示所有的结果,并让main函数持续工作,直到完成所有的操作,如代码清单2-25所示。

代码清单2-25 search/search.go:第44行到第57行

44     // 启动一个goroutine来监控是否所有的工作都做完了
45     go func() {
46         // 等候所有任务完成
47         waitGroup.Wait()
48
49         // 用关闭通道的方式,通知Display函数
50         // 可以退出程序了
51         close(results)
52     }()
53
54     // 启动函数,显示返回的结果, 
55     // 并且在最后一个结果显示完后返回
56     Display(results)
57 }

第45行到第56行的代码解释起来比较麻烦,等我们看完search包里的其他代码后再来解释。我们现在只解释表面的语法,随后再来解释底层的机制。在第45行到第52行,我们以goroutine的方式启动了另一个匿名函数。这个匿名函数没有输入参数,使用闭包访问了WaitGroupresults变量。这个goroutine里面调用了WaitGroupWait方法。这个方法会导致goroutine阻塞,直到WaitGroup内部的计数到达0。之后,goroutine调用了内置的close函数,关闭了通道,最终导致程序终止。

Run函数的最后一段代码是第56行。这行调用了match.go文件里的Display函数。一旦这个函数返回,程序就会终止。而之前的代码保证了所有results通道里的数据被处理之前,Display函数不会返回。

现在已经看过了Run函数,让我们继续看看search.go文件的第14行中的RetrieveFeeds函数调用背后的代码。这个函数读取data.json文件并返回数据源的切片。这些数据源会输出内容,随后使用各自的匹配器进行搜索。代码清单2-26给出的是feed.go文件的前8行代码。

代码清单2-26 feed.go:第01行到第08行

01 package search
02
03 import (
04     "encoding/json"
05     "os"
06 )
07
08 const dataFile = "data/data.json"

这个代码文件在search文件夹里,所以第01行声明了包的名字为search。第03行到第06行导入了标准库中的两个包。json包提供编解码JSON的功能,os包提供访问操作系统的功能,如读文件。

读者可能注意到了,导入json包的时候需要指定encoding路径。不考虑这个路径的话,我们导入包的名字叫作json。不管标准库的路径是什么样的,并不会改变包名。我们在访问json包内的函数时,依旧是指定json这个名字。

在第08行,我们声明了一个叫作dataFile的常量,使用内容是磁盘上根据相对路径指定的数据文件名的字符串做初始化。因为Go编译器可以根据赋值运算符右边的值来推导类型,声明常量的时候不需要指定类型。此外,这个常量的名称使用小写字母开头,表示它只能在search包内的代码里直接访问,而不暴露到包外面。

接着我们来看看data.json数据文件的部分内容,如代码清单2-27所示。

代码清单2-27 data.json

[
    {
        "site" : "npr",
        "link" : "http://www.npr.org/rss/rss.php?id=1001",
        "type" : "rss"
    },
    {
        "site" : "cnn",
        "link" : "http://rss.cnn.com/rss/cnn_world.rss",
        "type" : "rss"
    },
    {
        "site" : "foxnews",
        "link" : "http://feeds.foxnews.com/foxnews/world?format=xml",
        "type" : "rss"
    },
    {
        "site" : "nbcnews",
        "link" : "http://feeds.nbcnews.com/feeds/topstories",
        "type" : "rss"
    }
]

为了保证数据的有效性,代码清单2-27只选用了4个数据源,实际数据文件包含的数据要比这4个多。数据文件包括一个JSON文档数组。数组的每一项都是一个JSON文档,包含获取数据的网站名、数据的链接以及我们期望获得的数据类型。

这些数据文档需要解码到一个结构组成的切片里,以便我们能在程序里使用这些数据。来看看用于解码数据文档的结构类型,如代码清单2-28所示。

代码清单2-28 feed.go:第10行到第15行

10 // Feed 包含我们需要处理的数据源的信息
11 type Feed struct {
12     Name string `json:"site"`
13     URI  string `json:"link"`
14     Type string `json:"type"`
15 }

在第11行到第15行,我们声明了一个名叫Feed的结构类型。这个类型会对外暴露。这个类型里面声明了3个字段,每个字段的类型都是字符串,对应于数据文件中各个文档的不同字段。每个字段的声明最后 ` 引号里的部分被称作标记(tag)。这个标记里描述了JSON解码的元数据,用于创建Feed类型值的切片。每个标记将结构类型里字段对应到JSON文档里指定名字的字段。

现在可以看看search.go代码文件的第14行中调用的RetrieveFeeds函数了。这个函数读取数据文件,并将每个JSON文档解码,存入一个Feed类型值的切片里,如代码清单2-29所示。

代码清单2-29 feed.go:第17行到第36行

17 // RetrieveFeeds读取并反序列化源数据文件
18 func RetrieveFeeds() ([]*Feed, error) {
19     // 打开文件
20     file, err := os.Open(dataFile)
21     if err != nil {
22        return nil, err
23     }
24
25     // 当函数返回时
26     // 关闭文件
27     defer file.Close()
28
29     // 将文件解码到一个切片里
30     // 这个切片的每一项是一个指向一个Feed类型值的指针
31     var feeds []*Feed
32     err = json.NewDecoder(file).Decode(&feeds)
33
34     // 这个函数不需要检查错误,调用者会做这件事
35     return feeds, err
36 }

让我们从第18行的函数声明开始。这个函数没有参数,会返回两个值。第一个返回值是一个切片,其中每一项指向一个Feed类型的值。第二个返回值是一个error类型的值,用来表示函数是否调用成功。在这个代码示例里,会经常看到返回error类型值来表示函数是否调用成功。这种用法在标准库里也很常见。

现在让我们看看第20行到第23行。在这几行里,我们使用os包打开了数据文件。我们使用相对路径调用Open方法,并得到两个返回值。第一个返回值是一个指针,指向File类型的值,第二个返回值是error类型的值,检查Open调用是否成功。紧接着第21行就检查了返回的error类型错误值,如果打开文件真的有问题,就把这个错误值返回给调用者。

如果成功打开了文件,会进入到第27行。这里使用了关键字defer,如代码清单2-30所示。

代码清单2-30 feed.go:第25行到第27行

25     // 当函数返回时
26     // 关闭文件
27     defer file.Close()

关键字defer会安排随后的函数调用在函数返回时才执行。在使用完文件后,需要主动关闭文件。使用关键字defer来安排调用Close方法,可以保证这个函数一定会被调用。哪怕函数意外崩溃终止,也能保证关键字defer安排调用的函数会被执行。关键字defer可以缩短打开文件和关闭文件之间间隔的代码行数,有助提高代码可读性,减少错误。

现在可以看看这个函数的最后几行,如代码清单2-31所示。先来看一下第31行到第35行的代码。

代码清单2-31 feed.go:第29行到第36行

29     // 将文件解码到一个切片里
30     // 这个切片的每一项是一个指向一个Feed类型值的指针
31     var feeds []*Feed
32     err = json.NewDecoder(file).Decode(&feeds)
33
34     // 这个函数不需要检查错误,调用者会做这件事
35     return feeds, err
36 }

在第31行我们声明了一个名字叫feeds,值为nil的切片,这个切片包含一组指向Feed类型值的指针。之后在第32行我们调用json包的NewDecoder函数,然后在其返回值上调用Decode方法。我们使用之前调用Open返回的文件句柄调用NewDecoder函数,并得到一个指向Decoder类型的值的指针。之后再调用这个指针的Decode方法,传入切片的地址。之后Decode方法会解码数据文件,并将解码后的值以Feed类型值的形式存入切片里。

根据Decode方法的声明,该方法可以接受任何类型的值,如代码清单2-32所示。

代码清单2-32 使用空interface

func (dec *Decoder) Decode(v interface{}) error

Decode方法接受一个类型为interface{}的值作为参数。这个类型在Go语言里很特殊,一般会配合reflect包里提供的反射功能一起使用。

最后,第35行给函数的调用者返回了切片和错误值。在这个例子里,不需要对Decode调用之后的错误做检查。函数执行结束,这个函数的调用者可以检查这个错误值,并决定后续如何处理。

现在让我们看看搜索的代码是如何支持不同类型的数据源的。让我们去看看匹配器的代码。

match.go代码文件包含创建不同类型匹配器的代码,这些匹配器用于在Run函数里对数据进行搜索。让我们回头看看Run函数里使用不同匹配器执行搜索的代码,如代码清单2-33所示。

代码清单2-33 search/search.go:第29行到第42行

29     // 为每个数据源启动一个goroutine来查找结果
30     for _, feed := range feeds {
31        // 获取一个匹配器用于查找
32        matcher, exists := matchers[feed.Type]
33        if !exists {
34            matcher = matchers["default"]
35        }
36
37        // 启动一个goroutine执行查找
38        go func(matcher Matcher, feed *Feed) {
39            Match(matcher, feed, searchTerm, results)
40            waitGroup.Done()
41        }(matcher, feed)
42     }

代码的第32行,根据数据源类型查找一个匹配器值。这个匹配器值随后会用于在特定的数据源里处理搜索。之后在第38行到第41行启动了一个goroutine,让匹配器对数据源的数据进行搜索。让这段代码起作用的关键是这个架构使用一个接口类型来匹配并执行具有特定实现的匹配器。这样,就能使用这段代码,以一致且通用的方法,来处理不同类型的匹配器值。让我们看一下match.go里的代码,看看如何才能实现这一功能。

代码清单2-34给出的是match.go的前17行代码。

代码清单2-34 search/match.go:第01行到第17行

01 package search
02
03 import (
04     "log"
05 )
06
07 // Result保存搜索的结果
08 type Result struct {
09     Field   string
10     Content string
11 }
12
13 // Matcher定义了要实现的
14 // 新搜索类型的行为
15 type Matcher interface {
16     Search(feed *Feed, searchTerm string) ([]*Result, error)
17 }

让我们看一下第15行到第17行,这里声明了一个名为Matcher的接口类型。之前,我们只见过声明结构类型,而现在看到如何声明一个interface(接口)类型。我们会在第5章介绍接口的更多细节,现在只需要知道,interface关键字声明了一个接口,这个接口声明了结构类型或者具名类型需要实现的行为。一个接口的行为最终由在这个接口类型中声明的方法决定。

对于Matcher这个接口来说,只声明了一个Search方法,这个方法输入一个指向Feed类型值的指针和一个string类型的搜索项。这个方法返回两个值:一个指向Result类型值的指针的切片,另一个是错误值。Result类型的声明在第08行到第11行。

命名接口的时候,也需要遵守Go语言的命名惯例。如果接口类型只包含一个方法,那么这个类型的名字以er结尾。我们的例子里就是这么做的,所以这个接口的名字叫作Matcher。如果接口类型内部声明了多个方法,其名字需要与其行为关联。

如果要让一个用户定义的类型实现一个接口,这个用户定义的类型要实现接口类型里声明的所有方法。让我们切换到default.go代码文件,看看默认匹配器是如何实现Matcher接口的,如代码清单2-35所示。

代码清单2-35 search/default.go:第01行到第15行

01 package search
02
03 // defaultMatcher实现了默认匹配器
04 type defaultMatcher struct{}
05
06 // init函数将默认匹配器注册到程序里
07 func init() {
08     var matcher defaultMatcher
09     Register("default", matcher)
10 }
11
12 // Search 实现了默认匹配器的行为
13 func (m defaultMatcher) Search(feed *Feed, searchTerm string) ([]*Result, error) {
14     return nil, nil
15 }

在第04行,我们使用一个空结构声明了一个名叫defaultMatcher的结构类型。空结构在创建实例时,不会分配任何内存。这种结构很适合创建没有任何状态的类型。对于默认匹配器来说,不需要维护任何状态,所以我们只要实现对应的接口就行。

在第13行到第15行,可以看到defaultMatcher类型实现Matcher接口的代码。实现接口的方法Search只返回两个nil值。其他的实现,如RSS匹配器的实现,会在这个方法里使用特定的业务逻辑规则来处理搜索。

Search方法的声明也声明了defaultMatcher类型的值的接收者,如代码清单2-36所示。

代码清单2-36 search/default.go:第13行

13 func (m defaultMatcher) Search

如果声明函数的时候带有接收者,则意味着声明了一个方法。这个方法会和指定的接收者的类型绑在一起。在我们的例子里,Search方法与defaultMatcher类型的值绑在一起。这意味着我们可以使用defaultMatcher类型的值或者指向这个类型值的指针来调用Search方法。无论我们是使用接收者类型的值来调用这个方,还是使用接收者类型值的指针来调用这个方法,编译器都会正确地引用或者解引用对应的值,作为接收者传递给Search方法,如代码清单2-37所示。

代码清单2-37 调用方法的例子

// 方法声明为使用defaultMatcher类型的值作为接收者
func (m defaultMatcher) Search(feed *Feed, searchTerm string)

// 声明一个指向defaultMatcher类型值的指针
dm := new(defaultMatcher)

// 编译器会解开dm指针的引用,使用对应的值调用方法
dm.Search(feed, "test")

// 方法声明为使用指向defaultMatcher类型值的指针作为接收者
func (m *defaultMatcher) Search(feed *Feed, searchTerm string)

// 声明一个defaultMatcher类型的值
var dm defaultMatcher

// 编译器会自动生成指针引用dm值,使用指针调用方法
dm.Search(feed, "test")

因为大部分方法在被调用后都需要维护接收者的值的状态,所以,一个最佳实践是,将方法的接收者声明为指针。对于defaultMatcher类型来说,使用值作为接收者是因为创建一个defaultMatcher类型的值不需要分配内存。由于defaultMatcher不需要维护状态,所以不需要指针形式的接收者。

与直接通过值或者指针调用方法不同,如果通过接口类型的值调用方法,规则有很大不同,如代码清单2-38所示。使用指针作为接收者声明的方法,只能在接口类型的值是一个指针的时候被调用。使用值作为接收者声明的方法,在接口类型的值为值或者指针时,都可以被调用。

代码清单2-38 接口方法调用所受限制的例子

// 方法声明为使用指向defaultMatcher类型值的指针作为接收者
func (m *defaultMatcher) Search(feed *Feed, searchTerm string)

// 通过interface类型的值来调用方法
var dm defaultMatcher
var matcher Matcher = dm     // 将值赋值给接口类型
matcher.Search(feed, "test") // 使用值来调用接口方法

> go build
cannot use dm (type defaultMatcher) as type Matcher in assignment

// 方法声明为使用defaultMatcher类型的值作为接收者
func (m defaultMatcher) Search(feed *Feed, searchTerm string)

// 通过interface类型的值来调用方法
var dm defaultMatcher
var matcher Matcher = &dm    // 将指针赋值给接口类型
matcher.Search(feed, "test") // 使用指针来调用接口方法

> go build
Build Successful

除了Search方法,defaultMatcher类型不需要为实现接口做更多的事情了。从这段代码之后,不论是defaultMatcher类型的值还是指针,都满足Matcher接口,都可以作为Matcher类型的值使用。这是代码可以工作的关键。defaultMatcher类型的值和指针现在还可以作为Matcher的值,赋值或者传递给接受Matcher类型值的函数。

让我们看看match.go代码文件里实现Match函数的代码,如代码清单2-39所示。这个函数在search.go代码文件的第39行中由Run函数调用。

代码清单2-39 search/match.go:第19行到第33行

19 // Match函数,为每个数据源单独启动goroutine来执行这个函数
20 // 并发地执行搜索
21 func Match(matcher Matcher, feed *Feed, searchTerm string, results chan<- *Result) {
22     // 对特定的匹配器执行搜索
23     searchResults, err := matcher.Search(feed, searchTerm)
24     if err != nil {
25         log.Println(err)
26         return
27     }
28
29     // 将结果写入通道
30     for _, result := range searchResults {
31         results <- result
32     }
33 }

这个函数使用实现了Matcher接口的值或者指针,进行真正的搜索。这个函数接受Matcher类型的值作为第一个参数。只有实现了Matcher接口的值或者指针能被接受。因为defaultMatcher类型使用值作为接收者,实现了这个接口,所以defaultMatcher类型的值或者指针可以传入这个函数。

在第23行,调用了传入函数的Matcher类型值的Search方法。这里执行了Matcher变量中特定的Search方法。Search方法返回后,在第24行检测返回的错误值是否真的是一个错误。如果是一个错误,函数通过log输出错误信息并返回。如果搜索并没有返回错误,而是返回了搜索结果,则把结果写入通道,以便正在监听通道的main函数就能收到这些结果。

match.go中的最后一部分代码就是main函数在第56行调用的Display函数,如代码清单2-40所示。这个函数会阻止程序终止,直到接收并输出了搜索goroutine返回的所有结果。

代码清单2-40 search/match.go:第35行到第43行

35 // Display从每个单独的goroutine接收到结果后
36 // 在终端窗口输出
37 func Display(results chan *Result) {
38     // 通道会一直被阻塞,直到有结果写入
39     // 一旦通道被关闭,for循环就会终止
40     for result := range results {
41         fmt.Printf("%s:\n%s\n\n", result.Field, result.Content)
42     }
43 }

当通道被关闭时,通道和关键字range的行为,使这个函数在处理完所有结果后才会返回。让我们再来简单看一下Run函数的代码,特别是关闭results通道并调用Display函数那段,如代码清单2-41所示。

代码清单2-41 search/search.go:第44行到第57行

44     // 启动一个goroutine来监控是否所有的工作都做完了
45     go func() {
46         // 等候所有任务完成
47         waitGroup.Wait()
48
49         // 用关闭通道的方式,通知Display函数
50         // 可以退出程序了
51         close(results)
52     }()
53
54     // 启动函数,显示返回的结果, 
55     // 并且在最后一个结果显示完后返回
56     Display(results)
57 }

第45行到第52行定义的goroutine会等待waitGroup,直到搜索goroutine调用了Done方法。一旦最后一个搜索goroutine调用了DoneWait方法会返回,之后第51行的代码会关闭results通道。一旦通道关闭,goroutine就会终止,不再工作。

在match.go代码文件的第30行到第32行,搜索结果会被写入通道,如代码清单2-42所示。

代码清单2-42 search/match.go:第29行到第32行

29     // 将结果写入通道
30     for _, result := range searchResults {
31         results <- result
32     }

如果回头看一看match.go代码文件的第40行到第42行的for range循环,如代码清单2-43所示,我们就能把写入结果、关闭通道和处理结果这些流程串在一起。

代码清单2-43 search/match.go:第38行到第42行

38     // 通道会一直被阻塞,直到有结果写入
39     // 一旦通道被关闭,for循环就会终止
40     for result := range results {
41         fmt.Printf("%s:\n%s\n\n", result.Field, result.Content)
42     }

match.go代码文件的第40行的for range循环会一直阻塞,直到有结果写入通道。在某个搜索goroutine向通道写入结果后(如在match.go代码文件的第31行所见),for range循环被唤醒,读出这些结果。之后,结果会立刻写到日志中。看上去这个for range循环会无限循环下去,但其实不然。一旦search.go代码文件第51行关闭了通道,for range循环就会终止,Display函数也会返回。

在我们去看RSS匹配器的实现之前,再看一下程序开始执行时,如何初始化不同的匹配器。为此,我们需要先回头看看default.go代码文件的第07行到第10行,如代码清单2-44所示。

代码清单2-44 search/default.go:第06行到第10行

06 // init函数将默认匹配器注册到程序里
07 func init() {
08     var matcher defaultMatcher
09     Register("default", matcher)
10 }

在代码文件default.go里有一个特殊的函数,名叫init。在main.go代码文件里也能看到同名的函数。我们之前说过,程序里所有的init方法都会在main函数启动前被调用。让我们再看看main.go代码文件导入了哪些代码,如代码清单2-45所示。

代码清单2-45 main.go:第07行到第08行

07     _ "github.com/goinaction/code/chapter2/sample/matchers"
08      "github.com/goinaction/code/chapter2/sample/search"

第8行导入search包,这让编译器可以找到default.go代码文件里的init函数。一旦编译器发现init函数,它就会给这个函数优先执行的权限,保证其在main函数之前被调用。

代码文件default.go里的init函数执行一个特殊的任务。这个函数会创建一个defaultMatcher类型的值,并将这个值传递给search.go代码文件里的Register函数,如代码清单2-46所示。

代码清单2-46 search/search.go:第59行到第67行

59 // Register调用时,会注册一个匹配器,提供给后面的程序使用
60 func Register(feedType string, matcher Matcher) {
61     if _, exists := matchers[feedType]; exists {
62         log.Fatalln(feedType, "Matcher already registered")
63     }
64
65     log.Println("Register", feedType, "matcher")
66     matchers[feedType] = matcher
67 }

这个函数的职责是,将一个Matcher值加入到保存注册匹配器的映射中。所有这种注册都应该在main函数被调用前完成。使用init函数可以非常完美地完成这种初始化时注册的任务。

最后要看的一部分代码是RSS匹配器的实现代码。我们之前看到的代码搭建了一个框架,以便能够实现不同的匹配器来搜索内容。RSS匹配器的结构与默认匹配器的结构很类似。每个匹配器为了匹配接口,Search方法的实现都不同,因此匹配器之间无法互相替换。

代码清单2-47中的RSS文档是一个例子。当我们访问数据源列表里RSS数据源的链接时,期望获得的数据就和这个例子类似。

代码清单2-47 期望的RSS数据源文档

<rss xmlns:npr="http://www.npr.org/rss/" xmlns:nprml="http://api"
    <channel>
        <title>News</title>
        <link>...</link>
        <description>...</description>

        <language>en</language>
        <copyright>Copyright 2014 NPR - For Personal Use
        <image>...</image>
        <item>
            <title>
                Putin Says He'll Respect Ukraine Vote But U.S.
            </title>
            <description>
                The White House and State Department have called on the
            </description>

如果用浏览器打开代码清单2-47中的任意一个链接,就能看到期望的RSS文档的完整内容。RSS匹配器的实现会下载这些RSS文档,使用搜索项来搜索标题和描述域,并将结果发送给results通道。让我们先看看rss.go代码文件的前12行代码,如代码清单2-48所示。

代码清单2-48 matchers/rss.go:第01行到第12行

01 package matchers
02
03 import (
04     "encoding/xml"
05     "errors"
06     "fmt"
07     "log"
08     "net/http"
09     "regexp"
10
11     "github.com/goinaction/code/chapter2/sample/search"
12 )

和其他代码文件一样,第1行定义了包名。这个代码文件处于名叫matchers的文件夹中,所以包名也叫matchers。之后,我们从标准库中导入了6个库,还导入了search包。再一次,我们看到有些标准库的包是从标准库所在的子文件夹导入的,如xmlhttp。就像json包一样,路径里最后一个文件夹的名字代表包的名字。

为了让程序可以使用文档里的数据,解码RSS文档的时候需要用到4个结构类型,如代码清单2-49所示。

代码清单2-49 matchers/rss.go:第14行到第58行

14 type (
15     // item根据item字段的标签,将定义的字段
16     // 与rss文档的字段关联起来
17     item struct {
18         XMLName     xml.Name `xml:"item"`
19         PubDate     string   `xml:"pubDate"`
20         Title       string   `xml:"title"`
21         Description string   `xml:"description"`
22         Link        string   `xml:"link"`
23         GUID        string   `xml:"guid"`
24         GeoRssPoint string   `xml:"georss:point"`
25     }
26
27     // image根据image字段的标签,将定义的字段
28     // 与rss文档的字段关联起来
29     image struct {
30         XMLName xml.Name `xml:"image"`
31         URL     string   `xml:"url"`
32         Title   string   `xml:"title"`
33         Link    string   `xml:"link"`
34     }
35
36     // channel根据channel字段的标签,将定义的字段
37     // 与rss文档的字段关联起来
38     channel struct {
39         XMLName        xml.Name `xml:"channel"`
40         Title          string   `xml:"title"`
41         Description    string   `xml:"description"`
42         Link           string   `xml:"link"`
43         PubDate        string   `xml:"pubDate"`
44         LastBuildDate   string   `xml:"lastBuildDate"`
45         TTL            string   `xml:"ttl"`
46         Language       string   `xml:"language"`
47         ManagingEditor string   `xml:"managingEditor"`
48         WebMaster      string   `xml:"webMaster"`
49         Image          image    `xml:"image"`
50         Item           []item   `xml:"item"`
51     }
52
53     // rssDocument定义了与rss文档关联的字段
54     rssDocument struct {
55         XMLName xml.Name `xml:"rss"`
56         Channel channel  `xml:"channel"`
57     }
58 )

如果把这些结构与任意一个数据源的RSS文档对比,就能发现它们的对应关系。解码XML的方法与我们在feed.go代码文件里解码JSON文档一样。接下来我们可以看看rssMatcher类型的声明,如代码清单2-50所示。

代码清单2-50 matchers/rss.go:第60行到第61行

60 // rssMatcher 实现了Matcher接口
61 type rssMatcher struct{}

再说明一次,这个声明与defaultMatcher类型的声明很像。因为不需要维护任何状态,所以我们使用了一个空结构来实现Matcher接口。接下来看看匹配器init函数的实现,如代码清单2-51所示。

代码清单2-51 matchers/rss.go:第63行到第67行

63 // init 将匹配器注册到程序里
64 func init() {
65     var matcher rssMatcher
66     search.Register("rss", matcher)
67 }

就像在默认匹配器里看到的一样,init函数将rssMatcher类型的值注册到程序里,以备后用。让我们再看一次main.go代码文件里的导入部分,如代码清单2-52所示。

代码清单2-52 main.go:第07行到第08行

07     _ "github.com/goinaction/code/chapter2/sample/matchers"
08      "github.com/goinaction/code/chapter2/sample/search"

main.go代码文件里的代码并没有直接使用任何matchers包里的标识符。不过,我们依旧需要编译器安排调用rss.go代码文件里的init函数。在第07行,我们使用下划线标识符作为别名导入matchers包,完成了这个调用。这种方法可以让编译器在导入未被引用的包时不报错,而且依旧会定位到包内的init函数。我们已经看过了所有的导入、类型和初始化函数,现在来看看最后两个用于实现Matcher接口的方法,如代码清单2-53所示。

代码清单2-53 matchers/rss.go:第114行到第140行

114 // retrieve发送HTTP Get请求获取rss数据源并解码
115 func (m rssMatcher) retrieve(feed *search.Feed) (*rssDocument, error) {
116     if feed.URI == "" {
117         return nil, errors.New("No rss feed URI provided")
118     }
119
120     // 从网络获得rss数据源文档
121     resp, err := http.Get(feed.URI)
122     if err != nil {
123         return nil, err
124     }
125
126     // 一旦从函数返回,关闭返回的响应链接
127     defer resp.Body.Close()
128
129     // 检查状态码是不是200,这样就能知道
130     // 是不是收到了正确的响应
131     if resp.StatusCode != 200 {
132         return nil, fmt.Errorf("HTTP Response Error %d\n", resp.StatusCode)
133     }
134
135     // 将rss数据源文档解码到我们定义的结构类型里
136     // 不需要检查错误,调用者会做这件事
137     var document rssDocument
138     err = xml.NewDecoder(resp.Body).Decode(&document)
139     return &document, err
140 }

方法retrieve并没有对外暴露,其执行的逻辑是从RSS数据源的链接拉取RSS文档。在第121行,可以看到调用了http包的Get方法。我们会在第8章进一步介绍这个包,现在只需要知道,使用http包,Go语言可以很容易地进行网络请求。当Get方法返回后,我们可以得到一个指向Response类型值的指针。之后会监测网络请求是否出错,并在第127行安排函数返回时调用Close方法。

在第131行,我们检测了Response值的StatusCode字段,确保收到的响应是200。任何不是200的请求都需要作为错误处理。如果响应值不是200,我们使用fmt包里的Errorf函数返回一个自定义的错误。最后3行代码很像之前解码JSON数据文件的代码。只是这次使用xml包并调用了同样叫作NewDecoder的函数。这个函数会返回一个指向Decoder值的指针。之后调用这个指针的Decode方法,传入rssDocument类型的局部变量document的地址。最后返回这个局部变量的地址和Decode方法调用返回的错误值。

最后我们来看看实现了Matcher接口的方法,如代码清单2-54所示。

代码清单2-54 matchers/rss.go: 第69行到第112行

 69 // Search在文档中查找特定的搜索项
 70 func (m rssMatcher) Search(feed *search.Feed, searchTerm string) 
                                                   ([]*search.Result, error) {
 71     var results []*search.Result
 72
 73     log.Printf("Search Feed Type[%s] Site[%s] For Uri[%s]\n", 
                                               feed.Type, feed.Name, feed.URI)
 74
 75     // 获取要搜索的数据
 76     document, err := m.retrieve(feed)
 77     if err != nil {
 78         return nil, err
 79     }
 80
 81     for _, channelItem := range document.Channel.Item {
 82         // 检查标题部分是否包含搜索项
 83         matched, err := regexp.MatchString(searchTerm, channelItem.Title)
 84         if err != nil {
 85             return nil, err
 86         }
 87
 88         // 如果找到匹配的项,将其作为结果保存
 89         if matched {
 90             results = append(results, &search.Result{
 91                 Field:   "Title",
 92                 Content: channelItem.Title,
 93             })
 94         }
 95
 96         // 检查描述部分是否包含搜索项
 97         matched, err = regexp.MatchString(searchTerm, channelItem.Description)
 98         if err != nil {
 99             return nil, err
100         }
101
102         // 如果找到匹配的项,将其作为结果保存
103         if matched {
104             results = append(results, &search.Result{
105                 Field:   "Description",
106                 Content: channelItem.Description,
107             })
108         }
109     }
110
111     return results, nil
112 }

我们从第71行results变量的声明开始分析,如代码清单2-55所示。这个变量用于保存并返回找到的结果。

代码清单2-55 matchers/rss.go:第71行

71     var results []*search.Result

我们使用关键字var声明了一个值为nil的切片,切片每一项都是指向Result类型值的指针。Result类型的声明在之前match.go代码文件的第08行中可以找到。之后在第76行,我们使用刚刚看过的retrieve方法进行网络调用,如代码清单2-56所示。

代码清单2-56 matchers/rss.go:第75行到第79行

75     // 获取要搜索的数据
76     document, err := m.retrieve(feed)
77     if err != nil {
78         return nil, err
79     }

调用retrieve方法返回了一个指向rssDocument类型值的指针以及一个错误值。之后,像已经多次看过的代码一样,检查错误值,如果真的是一个错误,直接返回。如果没有错误发生,之后会依次检查得到的RSS文档的每一项的标题和描述,如果与搜索项匹配,就将其作为结果保存,如代码清单2-57所示。

代码清单2-57 matchers/rss.go:第81行到第86行

81     for _, channelItem := range document.Channel.Item {
82         // 检查标题部分是否包含搜索项
83         matched, err := regexp.MatchString(searchTerm, channelItem.Title)
84         if err != nil {
85             return nil, err
86         }

既然document.Channel.Item是一个item类型值的切片,我们在第81行对其使用for range循环,依次访问其内部的每一项。在第83行,我们使用regexp包里的MatchString函数,对channelItem值里的Title字段进行搜索,查找是否有匹配的搜索项。之后在第84行检查错误。如果没有错误,就会在第89行到第94行检查匹配的结果,如代码清单2-58所示。

代码清单2-58 matchers/rss.go:第88行到第94行

88         // 如果找到匹配的项,将其作为结果保存
89         if matched {
90             results = append(results, &search.Result{
91                 Field:   "Title",
92                 Content: channelItem.Title,
93             })
94         }

如果调用MatchString方法返回的matched的值为真,我们使用内置的append函数,将搜索结果加入到results切片里。append这个内置函数会根据切片需要,决定是否要增加切片的长度和容量。我们会在第4章了解关于内置函数append的更多知识。这个函数的第一个参数是希望追加到的切片,第二个参数是要追加的值。在这个例子里,追加到切片的值是一个指向Result类型值的指针。这个值直接使用字面声明的方式,初始化为Result类型的值。之后使用取地址运算符(&),获得这个新值的地址。最终将这个指针存入了切片。

在检查标题是否匹配后,第97行到第108行使用同样的逻辑检查Description字段。最后,在第111行,Search方法返回了results作为函数调用的结果。

这个说法并不严格成立,Go标准库中的io.Reader.Read方法就允许同时返回数据和错误。但是,如果是自己实现的函数,要尽量遵守这个原则,保持含义足够明确。——译者注


相关图书

JUnit实战(第3版)
JUnit实战(第3版)
Kafka实战
Kafka实战
Rust实战
Rust实战
PyQt编程快速上手
PyQt编程快速上手
Elasticsearch数据搜索与分析实战
Elasticsearch数据搜索与分析实战
玩转Scratch少儿趣味编程
玩转Scratch少儿趣味编程

相关文章

相关课程