Go Web编程

978-7-115-32247-0
作者: 【新加坡】郑兆雄(Sau Sheong Chang)
译者: 黄健宏
编辑: 杨海玲

图书目录:

详情

本书全面介绍使用Go语言开发Web应用所需的全部基本概念,并详细讲解如何运用现代设计原则使用Go语言构建Web应用。本书围绕一个网络论坛的例子,讲解如何使用Go语言的核心组件构建一个Go Web应用,然后在这一应用的基础上,构建出相应的Web服务。

图书摘要

版权信息

书名:Go Web编程

ISBN:978-7-115-32247-0

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

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

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

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

• 著    [新加坡] 郑兆雄(Sau Sheong Chang)

  译    黄健宏

  责任编辑 杨海玲

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

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

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

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

  反盗版热线:(010)81055315


Original English language edition, entitled Go Web Programming by Sau Sheong Chang 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语言开发Web应用所需的全部基本概念,并详细讲解如何运用现代设计原则使用Go语言构建Web应用。本书通过大量的实例介绍核心概念(如处理请求和发送响应、模板引擎和数据持久化),并深入讨论更多高级主题(如并发、Web应用程序测试以及部署到标准系统服务器和PaaS提供商)。

本书以一个网络论坛为例,讲解如何使用请求处理器、多路复用器、模板引擎、存储系统等核心组件构建一个Go Web应用,然后在这一应用的基础上,构建出相应的Web服务。值得一提的是,本书在介绍Go Web开发方法时,基本上只用到Go语言自带的标准库,而不会用到任何特定的Web框架,读者学到的知识将不会局限于特定的框架,即使将来需要用到现成的框架或者自行构建框架,仍然会从本书中获益。本书除了讲解具体的Web开发方法,还介绍如何对Go Web应用进行测试,如何使用Go的并发特性提高Web应用的性能,以及如何在Heroku、Google App Engine、Digital Ocean等云平台上部署Go Web应用;此外,书中还传授一些Go Web开发方面的经验和提示。这些重要的实践知识将帮助读者快速成为真正具有生产力的Go Web开发者。

阅读本书需要读者具备基本的Go语言编程技能并掌握Go语言的语法。本书适合所有想用Go语言进行Web开发的读者阅读,无论是Web开发的初学者还是入行已久的开发者都会在阅读本书的过程中有所收获。


随着近年来Web开发的盛行,很多相关书籍也随之如雨后春笋般出现,然而在这些书籍当中,绝大多数书籍都只关注表面的实现代码,而对代码背后的技术原理却少有提及。读者在看这类书籍时,虽然可以学到某个框架或者某个库的API,并根据书中给出的代码搭建出一个个演示程序(demo),但是对隐藏在这些代码之下的原理却一无所知。这种停留在表面的理解一旦离开了书本的指导,就会让人感到寸步难行,不知所措。

本书的独特之处在于,它抛开了现有的所有Go Web框架,仅仅通过Go语言内置的标准库来展示如何去构建一个Web应用或Web服务。这样做的好处是,无论将来读者是使用这些标准库来构建Web应用,还是使用现成的框架去构建Web应用,又或者使用自己建造的框架去构建Web应用,本书介绍的知识都是非常有用的:如果使用的是现成的框架,那么这些框架的内部实现通常就是由本书介绍的Go标准库构建的;如果选择自建框架,那么将有很大概率会用到本书介绍的Go标准库。因此,不论在何种情况下,本书对于构建Go Web应用都是非常有帮助的。

本书的另一个优点是,它在介绍Web应用开发技术的同时,也介绍了隐藏在这些技术背后的基础知识。比如,在介绍Web处理器(handler)的创建方法之前,本书就先深入浅出地介绍了HTTP协议,然后才说明具体的请求处理方法以及响应返回方法;又比如说,在介绍会话(session)技术时,本书就先说明了HTTP协议的无状态性质,然后才说明如何使用会话去解决这一问题;类似的例子在书里面还有很多,不一而足。对刚开始接触Web开发的读者来说,本书这种“知其然,也知其所以然”的教授方式能够让读者打好Web开发的基础,从而达到事半功倍的效果;此外,对那些已经有一定Web开发经验的读者来说,本书将在介绍Go Web开发方法的同时,帮助读者回顾和巩固Web开发的相关基础知识,并借此成为更好的Web开发者。

综上所述,我认为这本书对所有关心Web开发的人来说,都是非常值得一读的——无论读者使用的是Go语言还是其他语言、X框架还是Y框架,无论读者是Web开发的初学者还是入行已久的开发者,应该都会在阅读本书的过程中有所收获。

这本《Go Web编程》是我的第二部译作,在翻译第一部译作《Redis实战》的时候,因为受经验、知识以及时间等条件限制,我只能把时间尽量花在保证译文的准确性上,但是对于译文本身的可读性却未能有太多的关注。这次在翻译这本《Go Web编程》的过程中,我给自己订立了更高的目标,那就是,在保证译文正确性的前提下,通过合理的用词遣句,让译文更符合中文表达方式,并且更具表现力。

以本书的前言原文为例,其中就有一句“My own journey in developing applications for the web started around the same time, in the mid-1990s”,这句话的原意是说作者的Web开发生涯跟万维网的发展轨迹正好重合,因此把它单纯地译为“本人的Web应用开发生涯也是从20世纪90年代中期开始……”是完全没有问题的,但是通过在句子前面添加“无独有偶”一词来与“around the same time”的翻译“也是”相互呼应,就会一下子给译文带来画龙点睛的效果:“无独有偶,本人的Web应用开发生涯也是从20世纪90年代中期开始……”

继续以前言为例,在这篇文章的原文当中,出现了不少常见的英文短语和词汇,这些短语和词汇通常都有一个正确、常见并且平庸的翻译,但是本书却抛弃了这些翻译,转而选择了更准确也更有表现力的译法。比如说,“Writing web applications has changed dramatically over the years”中的“changed dramatically”没有直译为“发生了戏剧性的变化”,而是翻译为“发生了翻天覆地的变化”;“Almost as soon as the first web applications were written, web application frameworks appeared”中的“were written”和“appeared”没有直译为“被编写出来之后”以及“出现”,而是分别翻译为“闪亮登场”和“应运而生”,前者突出了Web应用的出现对于互联网的巨大改变,而后者则突出了Web应用和Web框架之间相辅相成的关系。类似的例子还有很多很多,并且它们不仅出现在了前言里,还出现在了本书的正文当中。

当然,提高译文的可读性并不是一件一蹴而就的事。为了让译文更有“中文味”,本书的大多数译文都已三易其稿,有时候仅仅为了挑选出一个更恰当的词语或成语,就不得不对着词典推敲半天。这本书的翻译从2016年8月开始,到2017年8月交稿,整整跨越了一年时间,其中翻译原文和润色译文两项工作花费的时间可谓各占一半。如果读者能够从译文的字里行间感受到这种润物细无声的优化,那将是对本人翻译工作最好的肯定。

另外,因为这是一本使用Go语言标准库进行Web开发的书,所以对Go Web开发相关标准库的理解程度将是能否准确地翻译本书技术内容的关键。为了进一步熟悉本书用到的标准库,本人通读了书中用到的各个标准库的文档,阅读了其中部分标准库的源码,并且因为有时候“好记性比不上烂笔头”,所以本人还翻译了其中一部分标准库文档,力求在尽可能掌握标准库细节的情况下,再进行翻译,尽量做到知其然也知其所以然,而不是单纯地根据纸面上的文字和代码进行翻译。

最后,在翻译本书的过程中,本人也发现了原著中大大小小数十个bug,并在译文中一一进行了修正。综上所述,读者看到的这个译本从某个角度来说将比原著更准确也更易读。这也是我一直以来在实践翻译工作时的信念——译作不应该是原著的“劣化版”,而是应该以“青出于蓝而胜于蓝”的方式超越原著。当然,要做到这一点并不是一件容易的事,但每一个合格的译者都应该以此为目标,不断奋斗。

感谢人民邮电出版社以及杨海玲编辑对我的信任,将这样一本有趣而且重要的书交给我翻译,我衷心希望这本译作能够取得好成绩,从而不辜负他们对我的信任与期待。

感谢网络上一直关心这个译本出版进度的读者朋友们,他们的支持和鼓励让我不敢有所怠慢,争取竭尽自己所能,贡献出一个能够为大家所喜爱的译本。

最后也是最重要的,我要感谢本书翻译过程中一如既往地全力支持我的家人和朋友,多亏了他们的帮助,本书的翻译工作才得以顺利完成。

黄健宏

2017年秋


黄健宏(huangz),一位1990年出生的计算机技术图书作译者,《Redis设计与实现》一书的作者,《Redis实战》一书的译者。

除了已出版的两本作品,他还创作和翻译了《Go标准库中文文档》《Redis命令参考》《SICP解题集》等一系列开源文档。

要了解关于黄健宏的更多信息,请访问他的个人主页。


自互联网从20世纪90年代中期诞生以来,Web应用就以这样或那样的方式存在了。虽然Web应用在最初只能传输静态网页,但它很快就升级和演变成了一个令人眼花缭乱、能够传输各种数据以及实现各种功能的动态系统。无独有偶,本人也是从20世纪90年代中期开始接触Web应用开发的,在迄今为止的职业生涯当中,我把大部分时间都花费在了大规模Web应用的设计、开发以及团队管理上面,并且在这期间还使用过多种不同的编程语言和框架,其中包括Java、Ruby、Node.js、PHP、Perl、Elixir甚至是Smalltalk。

几年前,我因为一次偶然的机会接触到了Go语言,并迅速被它的简单和清爽直率所吸引,而当我意识到只使用Go的标准库就可以快速地构建完整、高效并且可扩展的Web应用和服务时,我对Go的喜爱又更进了一步。使用Go语言编写的代码不仅易懂、直截了当,而且还能够快速、简单地编译成一个独立的可部署二进制文件。更关键的是,我不必投入大量服务器就可以让自己的Web应用变得可扩展且具备生产能力。很自然地,所有的这些优点都使Go成为了我在Web应用开发方面最新的心头好语言。

从当初传输静态内容到现在通过HTTP传输动态数据,从当初使用服务器传输HTML内容,到现在使用客户端单页应用去处理通过HTTP传输的JSON数据,Web应用的开发方式已经发生了翻天覆地的变化。几乎就在Web应用闪亮登场的同时,Web框架也应运而生,并使程序员可以更为容易地去开发Web应用。这二十多年以来,绝大多数编程语言都会有至少一个Web应用框架,其中很多语言甚至会有一大堆框架可用,而当今出现的绝大多数应用都是Web应用。

尽管Web应用框架的风靡使开发Web应用变得更加容易了,但这些框架在带来方便的同时也隐藏了大量的细节——Web应用开发者对于万维网的运作方式知之甚少甚至一窍不通,这样的情况正在变得越来越常见。幸运的是,通过Go语言,我发现了一种正确地教授Web应用开发基础知识的绝佳工具,它能够让Web应用开发重新回到简单直接的状态:程序需要考虑的就是如何处理HTTP协议,以及如何通过HTTP协议传输内容和数据,并且满足这两个要求只需要用到Go语言本身提供的工具——不需要用到外部库,也不需要用到外部的依赖。

在拿定主意之后,我就向Manning出版社提交了一个撰写Go语言编程书籍的构思,这个构思关注的是如何在只使用标准库的情况下,向读者传授从零开始构建Web应用的方法,而Manning出版社也很快就同意了我的构思并开启了这个项目。尽管本书的撰写工作持续了一段时间才得以完成,但是在写作的过程中,抢先预览版带来的反馈总是不断地鼓舞着我。最后,我希望读者能够像我享受创作本书的过程一样,享受阅读本书的过程,并且在这个过程中能够有所收获。


本书最初的想法是在只使用标准库的情况下教授基本的Go Web编程知识。说实在的,刚开始的时候我并不确定这个想法是否能够行得通,但那些花费自己血汗钱来购买本书抢先预览版的读者给了我鼓励和动力来实现这个想法,因此在这里我要向我的读者们致以诚挚的感谢!

写书是一项团队协作活动,尽管本书的封面上只记载了我一个人的名字,但实际上大量幕后人员也为这本书付出了自己的心血,他们分别是:

另外,我还要感谢Go的创造者Robert Griesemer、Rob Pike和Ken Thompson,以及net/httphtml/template等Web标准库的开发者,特别是Brad Fitzpatrick,没有他们的辛勤付出,这本书就不可能出现。

最后,也是最必不可少的,我要感谢我的家人,包括我亲爱的妻子Wooi Ying,以及在身高方面后来居上的我的儿子Kai Wen。我希望自己能够通过创作这本书给他带来启发,我也希望他会自豪地阅读这本书,并从中有所收获。


本书将完整地介绍使用Go语言开发Web应用所需的全部基本概念,并且在这个过程中只使用Go语言自带的标准库。尽管本书的部分章节会对其他库以及其他主题进行讨论,比如如何测试Web应用以及如何部署Web应用,但本书的主要目的还是教读者如何在只使用Go标准库的情况下进行Web开发。

本书要求读者具备基本的Go编程技能并掌握Go语言的语法。如果读者不具备这些知识,可以阅读由William Kennedy、Brian Ketelsen和Erik St. Martin创作的Go in Action[1]一书,该书也是由Manning出版社出版的。由Addison-Wesley 出版社出版、Alan Donovan和Brian Kernighan 创作的The Go Programming Language[2]也是一本值得一读的好书。除了以上提到的两本书之外,网上也有非常多免费的Go教程可供浏览,比如,Go官方网站的《Go入门教程》(A Tour of Go)就是一个很棒的例子。

本书由10章和一个附录组成。

第1章会介绍使用Go开发Web应用的方法,并阐述这种做法的优点所在。除此之外,本章还会对HTTP协议等构成Web应用的关键概念做深入浅出的介绍。

第2章会以一步一个脚印的方式,带领读者去构建一个简单的网上论坛,以此来向读者展示如何使用Go构建一个典型的Web应用。

第3章会更加详细地展示使用net/http包接收HTTP请求的方法。读者将学会如何编写Go Web服务器监听HTTP请求,以及如何使用处理器和处理器函数处理这些请求。

第4章会继续介绍处理HTTP请求的相关细节,重点讲述Go是如何处理请求并返回响应的。除此之外,读者还将学会如何从HTML表单中获取数据以及如何使用cookie。

第5章将会介绍由text/template库和html/template库组成的Go模板引擎。读者将会看到Go提供的各种模板机制,并学会如何使用Go的布局(layout)。

第6章将会对Go的存储策略进行讨论。读者将学会如何通过结构将数据存储到内存里面,如何通过CSV格式以及gob二进制格式将数据存储到文件系统里面,以及如何通过SQL和SQL映射器去访问关系数据库。

第7章将展示使用Go语言构建Web服务的方法。读者不仅会学到如何使用Go语言构建一个简单的Web服务,还会学到如何使用Go语言创建并分析XML数据和JSON数据。

第8章将向读者传授在不同层级中测试Go Web应用的不同方法,其中包括单元测试、基准测试以及HTTP测试;除此之外,这一章还会简单介绍几个第三方测试库。

第9章会介绍在Web应用中使用Go语言的并发特性的方法。读者将会了解到Go语言的各个并发特性,并学会如何使用这些特性提高一个图像生成Web应用的性能。

第10章是本书的最后一章,它将展示Go Web应用的部署方法。读者将会学到如何把应用部署到独立的服务器上,如何把应用部署到Heroku、Google App Engine之类的云平台上,以及如何把应用部署到Docker容器里面。

最后,本书的附录会展示在不同平台上安装和设置Go环境的方法。

本书通过代码清单以及正文内嵌的方式展示了大量源代码。为了跟一般的正文区别开来,书中的源代码都会使用等宽字体。为了凸显某些代码在不同章节之间的区别,又或者为了强调正文中讨论的某些代码,本书有时候也会以加粗的方式显示代码。

curl -i 127.0.0.1:8080/write
HTTP/1.1 200 OK
Date: Tue, 13 Jan 2015 16:16:13 GMT 
Content-Length: 95
Content-Type: text/html; charset=utf-8

<html>
<head><title>Go Web Programming</title></head>
<body><h1>Hello World</h1></body>
</html>

郑兆雄(Sau Sheong Chang),现任新加坡能源有限公司数字技术总裁,在此之前他曾经担任过PayPal的消费者工程经理。郑兆雄是Ruby社区和Go社区一位活跃的贡献者,除了创作书籍之外,他还为开源项目提交代码,并在各种技术研讨会和技术会议上发言。

[1] Go in Action的中文版已由人民邮电出版社出版,中文版书名为《Go语言实战》。——译者注

[2] The Go Programming Language的中文版已由机械工业出版社出版,中文版书名为《Go程序设计语言》。——译者注

本书的封面插图系Paolo Mercuri(1804—1884)所作,标题为“穿着中世纪服装的男人”,该插图来源于Camille Bonnard搜集并编辑的Costumes Historiques(服装史)多卷本,该书于19世纪50或60年代在巴黎出版,它搜集了大量12世纪、13世纪、14世纪和15世纪的历史服装。随着异国风情和历史文明在19世纪风靡,人们开始着迷于这类服装收藏本,并借此去探索自己所在的世界以及已经远去的旧世界。

在这一历史画册中,Mercuri丰富多彩的画作让我们生动地回想起了数百年前,世界各地不同城市和地区之间的文化差异。无论是在街道还是乡间,仅仅通过人们的着装就可以八九不离十地辨识他们的社会地位、从事的行业和职业。在经历了数个世纪的变迁以后,人们的着装方式已经发生了很大的变化,当初丰富多彩的地区多样性也已逐渐消失。时至今日,仅仅通过着装已经很难区分不同大洲的居民了,更别说想要知道他们所在的国家和城市、知悉他们的社会地位和职业了。乐观地讲,也许我们已经放弃了追求文化上的多样性,转为拥抱更丰富多彩也更快节奏的技术生活了。

在计算机书籍正在变得越来越相似、越来越同质化的今天,Manning出版社希望通过Mercuri的作品,将数个世纪以前丰富多彩的地区生活融入图书封面,以此来赞美计算机行业不断创新和敢为人先的精神。


Web应用是当今使用最为广泛的一类软件应用,连接至互联网的人们基本上每天都在使用Web应用。因为很多看上去像是原生应用的移动应用都在内部包含了使用Web技术构建的组件,所以使用移动设备的人们实际上也是在使用Web应用。

因为编写Web应用必须对HTTP有所了解,所以接下来的两章将对HTTP进行介绍。除此之外,我们还会了解到使用Go语言编写Web应用的优点,并且实际使用Go语言来构建一个简单的网上论坛,然后鸟瞰Web应用的各个组成部分。


本章主要内容

Web应用在我们的生活中无处不在。看看我们日常使用的各个应用程序,它们要么是Web应用,要么是移动App这类Web应用的变种。无论哪一种编程语言,只要它能够开发出与人类交互的软件,它就必然会支持Web应用开发。对一门崭新的编程语言来说,它的开发者首先要做的一件事,就是构建与互联网(internet)和万维网(World Wide Web)交互的库(library)和框架,而那些更为成熟的编程语言还会有各种五花八门的Web开发工具。

Go是一门刚开始崭露头角的语言,它是为了让人们能够简单且高效地编写后端系统(back end system)而创建的。这门语言拥有众多先进的特性,并且密切关注程序员的生产力以及各种与速度相关的事项。和其他语言一样,Go语言也提供了对Web编程的支持。自从问世以来,Go语言在编写Web应用以及“×即服务系统”(*-as-a-service system)方面就受到了热烈追捧。

本章接下来将列举一些使用Go编写Web应用的优点,并介绍一些关于Web应用的基本知识。

“为什么要使用Go语言编写Web应用呢?”作为本书的读者,我想你肯定很想知道这个问题的答案。本书是一本教人们如何使用Go语言进行Web编程的图书,而作为本书的作者,我的任务就是向你解释为什么人们会使用Go语言进行Web编程。本书将在接下来的内容中陆续介绍Go语言在Web开发方面的优点,我衷心地希望你也能够对这些优点有感同身受的想法。

Go是一门相对比较年轻的编程语言,它拥有繁荣并且仍在不断成长的社区,并且它也非常适合用来编写那些需要快速运行的服务器端程序。因为Go语言提供了很多过程式编程语言的特性,所以拥有过程式编程语言使用经验的程序员对Go应该都不会感到陌生,但与此同时,Go语言也提供了函数式编程方面的特性。除了内置对并发编程的支持之外,Go语言还拥有现代化的包管理系统、垃圾收集特性以及一系列包罗万象、威力强大的标准库。

虽然Go自带的标准库已经非常丰富和宏大了,但Go仍然拥有许多质量上乘的开源库,它们可以对标准库不足的地方进行补充。本书在大部分情况下都会尽可能地使用标准库,但是偶尔也会使用一些第三方开源库,以此来展示开源社区提供的一些另辟蹊径并且富有创意的方法。

使用Go语言进行Web开发正变得日益流行,很多公司都已经开始使用Go了,其中包括Dropbox、SendGrid这样的基础设施公司,Square和Hailo这样的技术驱动的公司,甚至是BBC、纽约时报这样的传统公司。

在开发大规模Web应用方面,Go语言提供了一种不同于现有语言和平台但又切实可行的方案。大规模可扩展的Web应用通常需要具备以下特质:

接下来的几小节将分别对这些特质进行讨论。

大规模的Web应用应该是可扩展的(scalable),这意味着应用的管理者应该能够简单、快速地提升应用的性能以便处理更多请求。如果一个应用是可扩展的,那么它就是线性的,这意味着应用的管理者可以通过添加更多硬件来获得更强的请求处理能力。

有两种方式可以对性能进行扩展:

因为Go语言拥有非常优异的并发编程支持,所以它在垂直扩展方面拥有不俗的表现:一个Go Web应用只需要使用一个操作系统线程(OS thread),就可以通过调度来高效地运行数十万个goroutine。

跟其他Web应用一样,Go也可以通过在多个Go Web应用之上架设代理来进行高效的水平扩展。因为Go Web应用都会被编译为不包含任何动态依赖关系的静态二进制文件,所以我们可以把这些文件分发到没有安装Go语言的系统里,从而以一种简单且一致的方式部署Go Web应用。

大规模Web应用应该由可替换的组件构成,这种做法能够使开发者更容易添加、移除或者修改特性,从而更好地满足程序不断变化的需求。除此之外,这种做法的另一个好处是使开发者可以通过复用模块化的组件来降低软件开发所需的费用。

尽管Go是一门静态类型语言,但用户可以通过它的接口机制对行为进行描述,以此来实现动态类型匹配(dynamic typing)。Go语言的函数可以接受接口作为参数,这意味着用户只要实现了接口所需的方法,就可以在继续使用现有代码的同时向系统中引入新的代码。与此同时,因为Go语言的所有类型都实现了空接口,所以用户只需要创建出一个接受空接口作为参数的函数,就可以把任何类型的值用作该函数的实际参数。此外,Go语言还实现了一些在函数式编程中非常常见的特性,其中包括函数类型、使用函数作为值以及闭包,这些特性允许用户使用已有的函数来构建新的函数,从而帮助用户构建出更为模块化的代码。

Go语言也经常会被用于创建微服务(microservice)。在微服务架构中,大型应用通常由多个规模较小的独立服务组合而成,这些独立服务通常可以相互替换,并根据它们各自的功能进行组织。比如,日志记录服务会被归类为系统级服务,而开具账单、风险分析这样的服务则会被归类为应用级服务。创建多个规模较小的Go服务并将它们组合为单个Web应用,这种做法使得我们可以在有需要的时候对应用中的服务进行替换,而整个Web应用也会因此变得更加模块化。

和其他庞大而复杂的应用一样,拥有一个易于维护的代码库(codebase)对大规模的Web应用来说也是非常重要的。这是因为大规模的应用通常都会不断地成长和演化,所以开发者需要经常性地回顾并修改代码,而修改难懂、笨拙的代码需要花费大量的时间,并且隐含着可能会造成某些功能无法正常运作的风险。因此,确保源代码能够以适当的方式组织起来并且具有良好的可维护性对开发者来说就显得至关重要了。

Go语言的设计鼓励良好的软件工程实践,它拥有简洁且极具可读性的语法以及灵活且清晰的包管理系统。除此之外,Go语言还有一整套优秀的工具,它们不仅可以增强程序员的开发体验,还能够帮助他们写出更具可读性的代码,比如以标准化方式对Go代码进行格式化的源代码格式化程序gofmt就是其中一个例子。

因为Go语言希望文档可以和代码一同演进,所以它的文档工具godoc会对Go源代码及其注释进行语法分析,然后以HTML、纯文本或者其他多种格式创建出相应的文档。godoc的使用方法非常简单,开发者只需要把文档写到源代码里面,godoc就会把这些文档以及与之相关联的代码提取出来,生成相应的文档文件。

除此之外,Go还内置了对测试的支持:gotest工具会自动寻找与源代码处于同一个包(package)之内的测试代码,并运行其中的功能测试和性能测试。Go语言也提供了Web应用测试工具,这些工具可以模拟出一个Web服务器,并对该服务器生成的响应(response)进行记录。

高性能不仅意味着能够在短时间内处理大量请求,还意味着服务器能够快速地对客户端进行响应,并让终端用户(end user)能够快速地执行操作。

Go语言的一个设计目标就是提供接近于C语言的性能,尽管这个目标目前尚未达成,但Go语言现在的性能已经非常具有竞争力:Go程序会被编译为本地码(native code),这一般意味着Go程序可以运行得比解释型语言的程序要快,并且就像前面说过的那样,Go语言的goroutine对并发编程提供了非常好的支持,这使得Go应用可以同时处理多个请求。

希望以上介绍能够引起你对使用Go语言及其平台进行Web开发的兴趣。但是在学习如何使用Go进行Web开发之前,我们需要先来了解一下什么是Web应用,以及它们的工作原理是什么,这会给我们学习之后几章的内容带来非常大的帮助。

如果你在一个技术会议上向在场的程序员们提出“什么是Web应用”这一问题,那么通常会得到五花八门的回答,有些人甚至可能还会因为你问了个如此基础的问题而感到惊讶和不解。通过不同的人对这个问题的不同回答,我们可以了解到人们对Web应用并没有一个十分明确的定义。比如说,Web服务算不算Web应用?因为Web服务通常会被其他软件调用,而Web应用则是为人类提供服务,所以很多人都认为Web服务与Web应用是两种不同的事物。但如果一个程序能够像RSS feed那样,产生出来的数据既可以被其他软件使用,又可以被人类理解,那么这个程序到底是一个Web服务还是一个Web应用呢?

同样地,如果一个应用只会返回HTML页面,但却并不对页面进行任何处理,那么它是一个Web应用吗?运行在Web浏览器之上的Adobe Flash程序是一个Web应用吗?对于一个纯HTML5编写的应用,如果它运行在一个长期驻留于计算机的浏览器中,那么它算是一个Web应用吗?如果一个应用在向服务器发送请求时没有使用HTTP协议,那么它算是一个Web应用吗?大多数程序员都能够从高层次的角度去理解Web应用是什么,但是一旦我们深入一些,尝试去探究Web应用的实现层次,事情就会变得含糊不清起来。

从纯粹且狭隘的角度来看,Web应用应该是这样的计算机程序:它会对客户端发送的HTTP请求做出响应,并通过HTTP响应将HTML回传至客户端。但这样一来,Web应用不就跟Web服务器一样了吗?的确如此,如果按照上面给出的定义来看,Web服务器和Web应用将没有区别:一个Web服务器就是一个Web应用(如图1-1所示)。

图1-1 Web应用最基本的请求与响应结构

将Web服务器看作是Web应用的一个问题在于,像httpd和Apache这样的Web服务器都会监视特定的目录,并在接收到请求时返回位于该目录中的文件(比如Apache就会对docroot目录进行监视)。与此相反,Web应用并不会简单地返回文件:它会对请求进行处理,并执行应用程序中预先设定好的操作(如图1-2所示)。

图1-2 Web应用的工作原理

从以上观点来看,我们也许可以把Web服务器看作是一种特殊的Web应用,这种应用只会返回被请求的文件。普遍来讲,很多用户都会把使用浏览器作为客户端的应用看作是Web应用。这其中包括Adobe Flash应用、单页Web应用,甚至是那些不使用HTTP协议进行通信但却驻留在桌面或系统上的应用。

为了在书中讨论Web编程的相关技术,我们必须给这些技术一个明确的定义。首先,让我们来给出应用的定义。

应用(application)是一个与用户进行互动并帮助用户执行指定活动的软件程序。比如记账系统、人力资源系统、桌面出版软件等。而Web应用则是部署在Web之上,并通过Web来使用的应用。

换句话说,一个程序只需要满足以下两个条件,我们就可以把它看作是一个Web应用:

在这个定义的基础上,如果一个程序不是向用户渲染并展示HTML,而是向其他程序返回某种非HTML格式的数据,那么这个程序就是一个为其他程序提供服务的Web服务。本书将在第7章对Web服务进行更详细的说明。

与大部分程序员对Web应用的定义相比,上面给出的定义可能显得稍微狭隘了一些,但因为这个定义消除了所有的模糊与不清晰,并使Web应用变得更加易于理解,所以它对于本书讨论的问题是非常有帮助的。随着读者对本书阅读的不断深入,这一定义将变得更为清晰,但是在此之前,让我们先来回顾一下HTTP协议的发展历程。

HTTP是万维网的应用层通信协议,Web页面中的所有数据都是通过这个看似简单的文本协议进行传输的。HTTP非常朴素,但却异常地强大——这个协议自20世纪90年代定义以来,至今只进行了3次迭代修改,其中HTTP 1.1是目前使用最为广泛的一个版本,而最新的一个版本则是HTTP 2.0,又称HTTP/2。

HTTP的最初版本HTTP 0.9是由Tim Berners-Lee为了让万维网能够得以被采纳而创建的:它允许客户端与服务器进行连接,并向服务器发送以空行(CRLF)结尾的ASCII字符串请求,而服务器则会返回不带任何元数据的HTML作为响应。

HTTP 0.9之后的每个新版本实现都包含了大量的新特性,1996年发布的HTTP 1.0就是由大量特性合并而成的,之后的HTTP 1.1版本于1999年发布,而HTTP 2.0版本则于2015年发布。因为目前使用最为广泛的还是HTTP 1.1版本,所以本书主要还是对HTTP 1.1进行讨论,但也会在适当的地方介绍一些HTTP 2.0的相关信息。

首先,让我们通过一个简单的定义来说明什么是HTTP。

HTTP是一种无状态、由文本构成的请求-响应(request-response)协议,这种协议使用的是客户端-服务器(client-server)计算模型。

请求-响应是两台计算机进行通信的基本方式,其中一台计算机会向另一台计算机发送请求,而接收到请求的计算机则会对请求进行响应。在客户端-服务器计算模型中,发送请求的一方(客户端)负责向返回响应的一方(服务器)发起会话,而服务器则负责为客户端提供服务。在HTTP协议中,客户端也被称作用户代理(user-agent),而服务器则通常会被称为Web服务器。在大多数情况下,HTTP客户端都是一个Web浏览器。

HTTP是一种无状态协议,它唯一知道的就是客户端会向服务器发送请求,而服务器则会向客户端返回响应,并且后续发生的请求对之前发生过的请求一无所知。相对的,像FTP、Telnet这类面向连接的协议则会在客户端和服务器之间创建一个持续存在的通信通道(其中Telnet在进行通信时使用的也是请求-响应方式以及客户端-服务器计算模型)。顺带提一下,HTTP 1.1也可以通过持久化连接来提升性能。

跟很多互联网协议一样,HTTP也是以纯文本方式而不是二进制方式发送和接收协议数据的。这样做是为了让开发者可以在无需使用专门的协议分析工具的情况下,弄清楚通信中正在发生的事情,从而更容易进行故障排查。

因为HTTP最初在设计时只用于传送HTML,所以HTTP 0.9只提供了GET这一个方法(method),但新版本对HTTP的扩展使它逐渐变成了一种通用的协议,用户也得以将其应用于Web应用等分布式系统中,本章接下来就会对Web应用进行介绍。

在万维网出现不久之后,人们开始意识到一点:尽管使用Web服务器处理静态HTML文件这个主意非常棒,但如果HTML里面能够包含动态生成的内容,那么事情将会变得更加有趣。其中,通用网关接口(Common Gateway Interface,CGI)就是在早期尝试动态生成HTML内容的技术之一。

1993年,美国国家超级计算应用中心(National Center for Supercomputing Applications, NCSA)编写了一个在Web服务器上调用可执行命令行程序的规范(specification),他们把这个规范命名为CGI,并将它包含在了NCSA开发的广受欢迎的HTTPd服务器里面。不过NCSA制定的这个规范最终并没有成为正式的互联网标准,只有CGI这个名字被后来的规范沿用了下来。

CGI是一个简单的接口,它允许Web服务器与一个独立运行于Web服务器进程之外的进程进行对接。通过CGI与服务器进行对接的程序通常被称为CGI程序,这种程序可以使用任何编程语言编写——这也是我们把这种接口称之为“通用”接口的原因,不过早期的CGI程序大多数都是使用Perl语言编写的。向CGI程序传递输入参数是通过设置环境变量来完成的,CGI程序在运行之后将向标准输出(stand output)返回结果,而服务器则会将这些结果传送至客户端。

与CGI同期出现的还有服务器端包含(server-side includes,SSI)技术,这种技术允许开发者在HTML文件里面包含一些指令(directive):当客户端请求一个HTML文件的时候,服务器在返回这个文件之前,会先执行文件中包含的指令,并将文件中出现指令的位置替换成这些指令的执行结果。SSI最常见的用法是在HTML文件中包含其他被频繁使用的文件,又或者将整个网站都会出现的页面首部(header)以及尾部(footer)的代码段嵌入HTML文件中。

作为例子,以下代码演示了如何通过SSI指令将navbar.shtml文件中的内容包含到HTML文件中:

<html>
 <head><title>Example SSI</title></head>
 <body>
  <!--#include file="navbar.shtml" -->
 </body>
</html>

SSI技术的最终演化结果就是在HTML里面包含更为复杂的代码,并使用更为强大的解释器(interpreter)。这一模式衍生出了PHP、ASP、JSP和ColdFusion等一系列非常成功的引擎,开发者通过使用这些引擎能够开发出各式各样复杂的Web应用。除此之外,这一模式也是Mustache、ERB、Velocity等一系列Web模板引擎的基础。

如前所述,Web应用是为了通过HTTP向用户发送定制的动态内容而诞生的,为了弄明白Web应用的运作原理,我们必须知道HTTP的工作过程,并理解HTTP请求和响应的运作机制。

HTTP是一种请求-响应协议,协议涉及的所有事情都以一个请求开始。HTTP请求跟其他所有HTTP报文(message)一样,都由一系列文本行组成,这些文本行会按照以下顺序进行排列:

(1)请求行(request-line);

(2)零个或任意多个请求首部(header);

(3)一个空行;

(4)可选的报文主体(body)。

一个典型的HTTP请求看上去是这个样子的:

GET /Protocols/rfc2616/rfc2616.html HTTP/1.1
Host: www.w3.org
User-Agent: Mozilla/5.0
(empty line)

这个请求中的第一个文本行就是请求行

GET /Protocols/rfc2616/rfc2616.html HTTP/1.1

请求行中的第一个单词为请求方法(request method),之后跟着的是统一资源标识符(Uniform Resource Identifier,URI)以及所用的HTTP版本。位于请求行之后的两个文本行为请求的首部。注意,这个报文的最后一行为空行,即使报文的主体部分为空,这个空行也必须存在,至于报文是否包含主体则需要根据请求使用的方法而定。

请求方法是请求行中的第一个单词,它指明了客户端想要对资源执行的操作。HTTP 0.9只有GET一个方法,HTTP 1.0添加了POST方法和HEAD方法,而HTTP 1.1则添加了PUTDELETEOPTIONSTRACECONNECT这5个方法,并允许开发者自行添加更多方法——很多人立即就把这个功能付诸实践了。

关于请求方法的一个有趣之处在于,HTTP 1.1要求必须实现的只有GET方法和HEAD方法,而其他方法的实现则是可选的,甚至连POST方法也是可选的。

各个HTTP方法的作用说明如下。

如果一个HTTP方法只要求服务器提供信息而不会对服务器的状态做任何修改,那么这个方法就是安全的(safe)。GETHEADOPTIONSTRACE都不会对服务器的状态进行修改,所以它们都是安全的方法。与此相反,POSTPUTDELETE都能够对服务器的状态进行修改(比如说,在处理POST请求时,服务器存储的数据就可能会发生变化),因此这些方法都不是安全的方法。

如果一个HTTP方法在使用相同的数据进行第二次调用的时候,不会对服务器的状态造成任何改变,那么这个方法就是幂等的(idempotent)。根据安全的方法的定义,因为所有安全的方法都不会修改服务器状态,所以它们天生就是幂等的。

PUTDELETE虽然不安全,但却是幂等的,这是因为它们在进行第二次调用时都不会改变服务器的状态:因为服务器在执行第一个PUT请求之后,URI指定的资源已经被更新或者创建出来了,所以针对同一个资源的第二次PUT请求只会执行服务器已经执行过的动作;与此类似,虽然服务器对于同一个资源的第二次DELETE请求可能会返回一个错误,但这个请求并不会改变服务器的状态。

相反,因为重复的POST请求是否会改变服务器状态是由服务器自身决定的,所以POST方法既不安全也非幂等。幂等性是一个非常重要的概念,本书第7章在介绍Web服务时将再次提及这个概念。

GET方法是最基本的HTTP方法,它负责从服务器上获取内容,所有浏览器都支持这个方法。POST方法从HTML 2.0 开始可以通过添加HTML表单来实现:HTML的form标签有一个名为method的属性,用户可以通过将这个属性的值设置为get或者post来指定要使用哪 种方法。

HTML不支持除GETPOST之外的其他HTTP方法:在HTML5规范的早期草案中,HTML表单的method属性曾经添加过对PUT方法和DELETE方法的支持,但这些支持在之后又被删除了。

话虽如此,但流行的浏览器通常都不会只支持HTML一种数据格式——用户可以使用XMLHttpRequest(XHR)来获得对PUT方法和DELTE方法的支持。XHR是一系列浏览器API,这些API通常由JavaScript包裹(实际上XHR就是一个名为XMLHttpRequest的浏览器对象)。XHR允许程序员向服务器发送HTTP请求,并且跟“XMLHttpRequest”这个名字所暗示的不一样,这项技术并不仅仅局限于XML格式——包括JSON以及纯文本在内的任何格式的请求和响应都可以通过XHR发送。

HTTP请求方法定义了发送请求的客户端想要执行的动作,而HTTP请求的首部则记录了与请求本身以及客户端有关的信息。请求的首部由任意多个用冒号分隔的纯文本键值对组成,最后以回车(CR)和换行(LF)结尾。

作为HTTP 1.1 RFC的一部分,RFC 7231对主要的一些HTTP请求字段(request field)进行了标准化。过去,非标准的HTTP请求通常以X-作为前缀,但标准并没有沿用这一惯例。

大多数HTTP请求首部都是可选的,宿主(Host)首部字段是HTTP 1.1唯一强制要求的首部。根据请求使用的方法不同,如果请求的报文中包含有可选的主体,那么请求的首部还需要带有内容长度(Content-Length)字段或者传输编码(Transfer-Encoding)字段。表1-1展示了一些常见的请求首部。

表1-1 常见的HTTP请求首部

首部字段

作用描述

Accept

客户端在HTTP响应中能够接收的内容类型。比如说,客户端可以通过Accept: text/html这个首部,告知服务器自己希望在响应的主体中收到HTML类型的内容

Accept-Charset

客户端要求服务器使用的字符集编码。比如说,客户端可以通过Accept-Charset: utf-8这个首部,告知服务器自己希望响应的主体使用UTF-8字符集

Authorization

这个首部用于向服务器发送基本的身份验证证书

Cookie

客户端应该在这个首部中把服务器之前设置的所有cookie回传给服务器。比如说,如果服务器之前在浏览器上设置了3个cookie,那么Cookie首部字段将在一个字符串里面包含这3个cookie,并使用分号对这些cookie进行分隔。以下是一个Cookie首部示例:Cookie: my_first_cookie=hello; my_second_cookie=world

Content-Length

请求主体的字节长度

Content-Type

当请求包含主体的时候,这个首部用于记录主体内容的类型。在发送POSTPUT请求时,内容的类型默认为x-www-form-urlencoded,但是在上传文件时,内容的类型应该设置为multipart/form-data(上传文件这一操作可以通过将input标签的类型设置为file来实现)

Host

服务器的名字以及端口号。如果这个首部没有记录服务器的端口号,就表示服务器使用的是80端口

Referrer

发起请求的页面所在的地址

User-Agent

对发起请求的客户端进行描述

HTTP响应报文是对HTTP请求报文的回复。跟HTTP请求一样,HTTP响应也是由一系列文本行组成的,其中包括:

也许你已经发现了,HTTP响应的组织方式跟HTTP请求的组织方式是完全相同的。以下是一个典型的HTTP响应的样子(为了节省篇幅,我们省略了报文主体中的部分内容):

200 OK
Date: Sat, 22 Nov 2014 12:58:58 GMT
Server: Apache/2
 Last-Modified: Thu, 28 Aug 2014 21:01:33 GMT
Content-Length: 33115
Content-Type: text/html; charset=iso-8859-1

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/ 
   TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns='http://www.w3.org/1999/
   xhtml'> <head><title>Hypertext Transfer Protocol -- HTTP/1.1</title></ 
   head><body>...</body></html>

HTTP响应的第一行为状态行,这个文本行包含了状态码(status code)和相应的原因短语(reason phrase),原因短语对状态码进行了简单的描述。除此之外,这个例子中的HTTP响应还包含了一个HTML格式的报文主体。

正如之前所说,HTTP响应中的状态码表明了响应的类型。HTTP响应状态码共有5种类型,它们分别以不同的数字作为前缀,如表1-2所示。

表1-2 HTTP响应状态码

状态码类型

作用描述

1XX

情报状态码。服务器通过这些状态码来告知客户端,自己已经接收到了客户端发送的请求,并且已经对请求进行了处理

2XX

成功状态码。这些状态码说明服务器已经接收到了客户端发送的请求,并且已经成功地对请求进行了处理。这类状态码的标准响应为“200 OK”

3XX

重定向状态码。这些状态码表示服务器已经接收到了客户端发送的请求,并且已经成功处理了请求,但为了完成请求指定的动作,客户端还需要再做一些其他工作。这类状态码大多用于实现URL重定向

4XX

客户端错误状态码。这类状态码说明客户端发送的请求出现了某些问题。在这一类型的状态码中,最常见的就是“404 Not Found”了,这个状态码表示服务器无法从请求指定的URL中找到客户端想要的资源

5XX

服务器错误状态码。当服务器因为某些原因而无法正确地处理请求时,服务器就会使用这类状态码来通知客户端。在这一类状态码中,最常见的就是“500 Internal Server Error”状态码了

响应首部跟请求首部一样,都是由冒号分隔的纯文本键值对组成,并且同样以回车(CR)和换行(LF)结尾。正如请求首部能够告诉服务器更多与请求相关或者与客户端诉求相关的信息一样,响应首部也能够向客户端传达更多与响应相关或者与服务器(对客户端的)诉求相关的信息。表1-3展示了一些常见的响应首部。

表1-3 常见的响应首部

首部字段

作用描述

Allow

告知客户端,服务器支持哪些请求方法

Content-Length

响应主体的字节长度

Content-Type

如果响应包含可选的主体,那么这个首部记录的就是主体内容的类型

Date

以格林尼治标准时间(GMT)格式记录的当前时间

Location

这个首部仅在重定向时使用,它会告知客户端接下来应该向哪个URL发送请求

Server

返回响应的服务器的域名

Set-Cookie

在客户端里面设置一个cookie。一个响应里面可以包含多个Set-Cookie首部

WWW-Authenticate

服务器通过这个首部来告知客户端,在Authorization请求首部中应该提供哪种类型的身份验证信息。服务器常常会把这个首部与“401 Unauthorized”状态行一同发送。除此之外,这个首部还会向服务器许可的认证授权模式(schema)提供验证信息(challenge information)(比如RFC 2617描述的基本和摘要访问认证模式)

Tim Berners-Lee在创建万维网的同时,也引入了使用位置字符串表示互联网资源的概念。他在1994年发表的RFC 1630中对统一资源标识符(Uniform Resource Identifier,URI)进行了定义。在这篇RFC中,他描述了一种使用字符串表示资源名字的方法,以及一种使用字符串表示资源所在位置的方法,其中前一种方法被称为统一资源名称(Uniform Resource Name,URN),而后一种方法则被称为统一资源定位符(Uniform Resource Location,URL)。URI是一个涵盖性术语,它包含了URN和URL,并且这两者也拥有相似的语法和格式。因为本书只会对URL进行讨论,所以本书中提及的URI指代的都是URL。

URI的一般格式为:

<方案名称>:<分层部分>[ ? <查询参数> ] [ # <片段> ]

URI中的方案名称(scheme name)记录了URI正在使用的方案,它定义了URI其余部分的结构。因为URI是一种非常常用的资源标识方式,所以它拥有大量的方案可供使用,不过本书在大多数情况下只会使用HTTP方案。

URI的分层部分(hierarchical part)包含了资源的识别信息,这些信息会以分层的方式进行组织。如果分层部分以双斜线(//)开头,那么说明它包含了可选的用户信息,这些信息将以@符号结尾,后跟分层路径。不带用户信息的分层部分就是一个单纯的路径,每个路径都由一连串的分段(segment)组成,各个分段之间使用单斜线(/)分隔。

在URI的各个部分当中,只有“方案名称”和“分层部分”是必需的。以问号(?)为前缀的查询参数(query)是可选的,这些参数用于包含无法使用分层方式表示的其他信息。多个查询参数会被组织成一连串的键值对,各个键值对之间使用&符号分隔。

URI的另一个可选部分为片段(fragment),片段使用井号(#)作为前缀,它可以对URI定义的资源中的次级资源(secondary resource)进行标识。当URI包含查询参数时,URI的片段将被放到查询参数之后。因为URI的片段是由客户端负责处理的,所以Web浏览器在将URI发送给服务器之前,一般都会先把URI中的片段移除掉。如果程序员想要取得URI片段,那么可以通过JavaScript或者某个HTTP客户端库,将URI片段包含在一个GET请求里面。

让我们来看一个使用HTTP方案的URI示例:http://sausheong:password@www.example.com/docs/file?name=sausheong&location=singapore#summary

这个URI使用的是http方案,跟在方案名之后的是一个冒号。位于@符号之前的分段sausheong:password记录的是用户名和密码,而跟在用户信息之后的www.example.com/docs/file就是分层部分的其余部分。位于分层部分最高层的是服务器的域名www.example.com,之后跟着的两个层分别为doc和file,每个分层之间都使用单斜线分隔。跟在分层部分之后的是以问号(?)为前缀的查询参数,这个部分包含了name=sausheong和location=singapore这两个键值对,键值对之间使用一个&符号连接。最后,这个URI的末尾还带有一个以井号(#)为前缀的片段。

因为每个URL都是一个单独的字符串,所以URL里面是不能够包含空格的。此外,因为问号(?)和井号(#)等符号在URL中具有特殊的含义,所以这些符号是不能够用于其他用途的。为了避开这些限制,我们需要使用URL编码来对这些特殊符号进行转换(URL编码又称百分号编码)。

RFC 3986定义了URL中的保留字符以及非保留字符,所有保留字符都需要进行URL编码:URL编码会把保留字符转换成该字符在ASCII编码中对应的字节值(byte value),接着把这个字节值表示为一个两位长的十六进制数字,最后再在这个十六进制数字的前面加上一个百分号(%)。

比如说,空格在ASCII编码中的字节值为32,也就是十六进制中的20。因此,经过URL编码处理的空格就成了%20,URL中的所有空格都会被替换成这个值。比如在接下来展示的这个URL里面,用户名sau和sheong之间的空格就被替换成了%20http://www.example.com/docs/file? name=sau%20sheong&location=singapore

HTTP/2是HTTP协议的最新版本,这一版本对性能非常关注。HTTP/2协议由SPDY/2协议改进而来,后者最初是Google公司为了传输Web内容而开发的一种开放的网络协议。

与使用纯文本方式表示的HTTP 1.x不同,HTTP/2是一种二进制协议:二进制表示不仅能够让HTTP/2的语法分析变得更为高效,还能够让协议变得更为紧凑和健壮;但与此同时,对那些习惯了使用HTTP 1.x的开发者来说,他们将无法再通过telnet等应用程序直接发送HTTP/2报文来进行调试。

跟HTTP 1.x在一个网络连接里面每次只能发送单个请求的做法不同,HTTP/2是完全多路复用的(fully multiplexed),这意味着多个请求和响应可以在同一时间内使用同一个连接。除此之外,HTTP/2还会对首部进行压缩以减少需要传送的数据量,并允许服务器将响应推送(push)至客户端,这些措施都能够有效地提升性能。

因为HTTP的应用范围是如此的广泛,对语法的任何贸然修改都有可能会对已有的Web造成破坏,所以尽管HTTP/2对协议的通信性能进行了优化,但它并没有对HTTP协议本身的语法进行修改:在HTTP/2中,HTTP方法和状态码等功能的语法还是跟HTTP 1.1时一样。

在Go 1.6版本中,用户在使用HTTPS时将自动使用HTTP/2,而Go 1.6之前的版本则在golang.org/x/net/http2包里面实现了HTTP/2协议。本书的第3章将会介绍如何使用HTTP/2。

通过前面的介绍,我们知道了Web应用就是一个执行以下任务的程序:

(1)通过HTTP协议,以HTTP请求报文的形式获取客户端输入;

(2)对HTTP请求报文进行处理,并执行必要的操作;

(3)生成HTML,并以HTTP响应报文的形式将其返回给客户端。

为了完成这些任务,Web应用被分成了处理器(handler)和模板引擎(template engine)这两个部分。

Web应用中的处理器除了要接收和处理客户端发来的请求,还需要调用模板引擎,然后由模板引擎生成HTML并把数据填充至将要回传给客户端的响应报文当中。

用MVC模式来讲,处理器既是控制器(controller),也是模型(model)。在理想的MVC模式实现中,控制器应该是“苗条的”,它应该只包含路由(routing)代码以及HTTP报文的解包和打包逻辑;而模型则应该是“丰满的”,它应该包含应用的逻辑以及数据。

“模型-视图-控制器”模式

 

模型-视图-控制器(Model-View-Controller,MVC)模式是编写Web应用时常用的模式,这个模式是如此的流行,以至于人们有时候会错误地把这一模式当成了Web应用开发本身。

实际上,MVC模式最初是在20世纪70年代末的施乐帕罗奥多研究中心(Xerox PARC)被引入到Smalltalk语言里面的,这一模式将程序分成了模型、视图和控制器3个部分,其中模型用于表示底层的数据,而视图则以可视化的方式向用户展示模型,至于控制器则会根据用户的输入对模型进行修改。每当模型发生变化时,视图都会自动进行更新,从而展现出模型的最新状态。

尽管MVC模式起源于桌面开发,但它在编写Web应用方面也流行了起来——包括Ruby on Rails、CodeIgniter、Play和Spring MVC在内的很多Web应用框架都把MVC用作它们的基本模式。在这些框架里面,模型一般都会通过结构(struct)或对象(object)映射(map)到数据库,而视图则会被渲染为HTML,至于控制器则负责对请求进行路由,并管理对模型的访问。

使用MVC框架进行Web应用开发的新手程序员常常会误以为MVC模式是开发Web应用的唯一方法,但Web应用本质上只是一个通过HTTP协议与用户互动的程序,只要能够实现这种互动,程序本身可以使用任何一种模式开发,甚至不使用模式也是可以的。

为了防止模型变得过于臃肿,并且出于代码复用的需要,开发者有时候会使用服务对象(service object)或者函数(function)对模型进行操作。尽管服务对象严格来说并不是MVC模式的一部分,但是通过把相同的逻辑放置到服务对象里面,并将同一个服务对象应用到不同的模型之上,可以有效地避免在多个模型里面复制相同代码的窘境。

正如之前所说,Web应用并不是一定要用MVC模式进行开发——通过将控制器和模型进行合并,然后由处理器直接执行所有操作并向客户端返回响应的做法不仅是可行的,而且也是十分合理的。

通过HTTP响应报文回传给客户端的HTML是由模板(template)转换而成的,模板里面可能会包含HTML,但也可能不会,而模板引擎(template engine)则通过模板和数据来生成最终的HTML。正如之前所说,模板引擎是经由早期的SSI技术演变而来的。

模板可以分为静态模板和动态模板两种,这两种模板都有各自的设计哲学。

到目前为止,本章已经介绍了很多Web应用背后的基础知识以及原理。初看上去,这些内容可能会显得过于琐碎了,但随着读者对本书内容的不断深入,理解这些基础知识的重要性就会慢慢地显现出来。在了解了Web应用开发所需的基本知识之后,现在是时候进入下一个阶段——开始实际地进行Go编程了。在接下来的一节,我们将开始学习如何使用Go开发Web应用。

在这一节,我们将开始学习如何实际地使用Go语言构建Web应用。如果你还没有安装Go,那么请先阅读本书的附录,根据附录中的指示安装Go并设置相关的环境变量。本节在构建Web应用时将会用到Go的net/http包,因为本书将会在接下来的几章中对这个包进行详细的介绍,所以即使目前对这个包知之甚少,也不必过于担心。目前来说,你只需要在计算机上键入本节展示的代码,编译它,然后观察这些代码是如何运行的就可以了。习惯了使用大小写无关编程语言的读者请注意,因为Go语言是区分大小写的,所以在键入本书展示的代码时请务必注意代码的大小写。

请在你的工作空间的src目录中创建一个first_webapp子目录,并在这个子目录里面创建一个server.go文件,然后将代码清单1-1中展示的源代码键入到文件里面。

代码清单1-1 使用Go构建的Hello World Web应用

package main

import (
  "fmt"
  "net/http"
)

func handler(writer http.ResponseWriter, request *http.Request) {
  fmt.Fprintf(writer, "Hello World, %s!", request.URL.Path[1:])
}

func main() {
  http.HandleFunc("/", handler)
  http.ListenAndServe(":8080", nil)
}

在一切就绪之后,请打开你的终端,执行以下命令:

$ go install first_webapp

你可以在任意目录中执行这个命令。在正确地设置了GOPATH环境变量的情况下,这个命令将在你的$GOPATH/bin目录中创建一个名为first_webapp的二进制可执行文件,接着就可以在终端里面运行这个文件了。如果你按照附录的指示,将$GOPATH/bin目录也添加到了PATH环境变量当中,那么你也可以在任意目录中执行first_webapp文件。被执行的first_webapp文件将在系统的8080端口上启动你的Web应用。一切就这么简单!

现在,打开网页浏览器,访问http://localhost:8080/。如果一切正常,那么你将会看到图1-3所示的内容。

图1-3 我们创建的首个Web应用

让我们来仔细地分析一下这个Web应用的代码。第一行代码声明了这个程序所属的包,跟在package关键字之后的main就是包的名字。Go语言要求可执行程序必须位于main包当中,Web应用也不例外。如果你曾经使用过Ruby、Python或者Java等其他编程语言来开发Web应用,那么你可能已经发现了Go和这些语言之间的区别:其他语言通常需要将Web应用部署到应用服务器上面,并由应用服务器为Web应用提供运行环境,但是对Go来说,Web应用的运行环境是由net/http包直接提供的,这个包和应用的源代码会一起被编译成一个可以快速部署的独立Web应用。

位于package语句之后的import语句用于导入所需的包:

import (
   "fmt"
   "net/http" 
)

被导入的包分别为fmt包和http包,前者使得程序可以使用Fprintf等函数对I/O进行格式化,而后者则使得程序可以与HTTP进行交互。顺带一提,Go的import语句不仅可以导入标准库里面的包,还可以从第三方库里面导入包。

出现在导入语句之后的是一个函数定义:

func handler(writer http.ResponseWriter, request *http.Request) {
  fmt.Fprintf(writer, "Hello World, %s!", request.URL.Path[1:])
}

这3行代码定义了一个名为handler的函数。处理器handler)这个名字通常用来表示在指定事件被触发之后,负责对事件进行处理的回调函数,这也正是我们如此命名这个函数的原因(不过从技术上来说,至少在Go语言里面,这个函数并不是一个处理器,而是一个处理器函数,处理器和处理器函数之间的区别将在第3章中介绍)。

这个处理器函数接受两个参数作为输入,第一个参数为ResponseWriter接口,第二个参数则为指向Request结构的指针。handler函数会从Request结构中提取相关的信息,然后创建一个HTTP响应,最后再通过ResponseWriter接口将响应返回给客户端。至于handler函数内部的Fprintf函数在被调用时则会使用一个ResponseWriter接口、一个带有单个格式化指示符%s)的格式化字符串以及从Request结构里面提取到的路径信息作为参数。因为我们之前访问的地址为http://localhost:8080/,所以应用并没有打印出任何路径信息,但如果我们访问地址http://localhost:8080/sausheong/was/here,那么浏览器应该会展示出图1-4所示的信息。

Go语言规定,每个需要被编译为二进制可执行文件的程序都必须包含一个main函数,用作程序执行时的起点:

func main() {
   http.HandleFunc("/", handler)
   http.ListenAndServe(":8080", nil)
}

这个main函数的作用非常直观,它首先把之前定义的handler函数设置成根(root)URL(/)被访问时的处理器,然后启动服务器并让它监听系统的8080端口(按下Ctrl+C可以停止这个服务器)。至此,这个使用Go语言编写的Hello World Web应用就算顺利完成了。

图1-4 带有路径信息的Hello World示例

本章以介绍Web应用的基础知识开始,并最终走马观花地编写了一个简单却没什么用处的Go Web应用作为结束。在接下来的一章中,我们将会看到更多代码,并学习如何使用Go语言以及它的标准库去编写更真实的Web应用(不过这些应用距离真正生产级别的应用还有一定距离)。尽管第2章出现的大量代码可能会让读者有一种囫囵吞枣的感觉,但我们将会从中学习到一个典型的Go Web应用是如何组织的。


本章主要内容

上一章在末尾展示了一个非常简单的Go Web应用,但是因为该应用只是一个Hello World程序,所以它实际上并没有什么用处。在本章中,我们将会构建一个简单的网上论坛Web应用,这个应用同样非常基础,但是却有用得多:它允许用户登录到论坛里面,然后在论坛上发布新帖子,又或者回复其他用户发表的帖子。

虽然本章介绍的内容无法让你一下子就学会如何编写一个非常成熟的Web应用,但这些内容将教会你如何组织和开发一个Web应用。在阅读完这一章之后,你将进一步地了解到使用Go进行Web应用开发的相关方法。

如果你觉得本章介绍的内容难度较大,又或者你觉得本章展示的大量代码看起来让人觉得胆战心惊,那也不必过于担心:本章之后的几章将对本章介绍的内容做进一步的解释,在阅读完本章并继续阅读后续章节时,你将会对本章介绍的内容有更加深入的了解。

网上论坛无处不在,它们是互联网上最受欢迎的应用之一,与旧式的电子公告栏(BBS)、新闻组(Usenet)和电子邮件一脉相承。雅虎公司和Google公司的群组(Groups)都非常流行,雅虎报告称,他们总共拥有1000万个群组以及1.15亿个群组成员,其中每个群组都拥有一个自己的论坛;而全球最具人气的网上论坛之一——Gaia在线——则拥有2300万注册用户以及接近230亿张帖子,并且这些帖子的数量还在以每天上百万张的速度持续增长。尽管现在出现了诸如Facebook这样的社交网站,但论坛仍然是人们在网上进行交流时最为常用的手段之一。作为例子,图2-1展示了GoogleGroups的样子。

图2-1 一个网上论坛示例:GoogleGroups里面的Go编程语言论坛

从本质上来说,网上论坛就相当于一个任何人都可以通过发帖来进行对话的公告板,公告板上面可以包含已注册用户以及未注册的匿名用户。论坛上的对话称为帖子(thread),一个帖子通常包含了作者想要讨论的一个主题,而其他用户则可以通过回复这个帖子来参与对话。比较复杂的论坛一般都会按层级进行划分,在这些论坛里面,可能会有多个讨论特定类型主题的子论坛存在。大多数论坛都会由一个或多个拥有特殊权限的用户进行管理,这些拥有特殊权限的用户被称为版主(moderator)。

在本章中,我们将会开发一个名为ChitChat的简易网上论坛。为了让这个例子保持简单,我们只会为ChitChat实现网上论坛的关键特性:在这个论坛里面,用户可以注册账号,并在登录之后发表新帖子又或者回复已有的帖子;未注册用户可以查看帖子,但是无法发表帖子或是回复帖子。现在,让我们首先来思考一下如何设计ChitChat这个应用。

关于本章展示的代码

 

跟本书的其他章节不一样,因为篇幅的关系,本章并不会展示ChitChat论坛的所有实现代码,但你可以在作者的GitHub 页面找到这些代码。如果你打算在阅读本章的同时实际了解一下这个应用,那么这些完整的代码应该会对你有所帮助。

正如第1章所说,Web应用的一般工作流程是客户端向服务器发送请求,然后服务器对客户端进行响应(如图2-2所示),ChitChat应用的设计也遵循这一流程。

图2-2 Web应用的一般工作流程,客户端向服务器发送请求,然后等待接收响应

ChitChat的应用逻辑会被编码到服务器里面。服务器会向客户端提供HTML页面,并通过页面的超链接向客户端表明请求的格式以及被请求的数据,而客户端则会在发送请求时向服务器提供相应的数据,如图2-3所示。

图2-3 HTTP请求的URL格式

请求的格式通常是由应用自行决定的,比如,ChitChat的请求使用的是以下格式:http://<服务器名><处理器名>?<参数>

服务器名(server name)是ChitChat服务器的名字,而处理器名(handler name)则是被调用的处理器的名字。处理器的名字是按层级进行划分的:位于名字最开头是被调用模块的名字,而之后跟着的则是被调用子模块的名字,以此类推,位于处理器名字最末尾的则是子模块中负责处理请求的处理器。比如,对/thread/read这个处理器名字来说,thread是被调用的模块,而read则是这个模块中负责读取帖子内容的处理器。

该应用的参数(parameter)会以URL查询的形式传递给处理器,而处理器则会根据这些参数对请求进行处理。比如说,假设客户端要向处理器传递帖子的唯一ID,那么它可以将URL的参数部分设置成id=123,其中123就是帖子的唯一ID。

如果chitchat就是ChitChat服务器的名字,那么根据上面介绍的URL格式规则,客户端发送给ChitChat服务器的URL可能会是这样的:http://chitchat/thread/read?id=123。

当请求到达服务器时,多路复用器(multiplexer)会对请求进行检查,并将请求重定向至正确的处理器进行处理。处理器在接收到多路复用器转发的请求之后,会从请求中取出相应的信息,并根据这些信息对请求进行处理。在请求处理完毕之后,处理器会将所得的数据传递给模板引擎,而模板引擎则会根据这些数据生成将要返回给客户端的HTML,整个过程如图2-4所示。

图2-4 服务器在典型Web应用中的工作流程

绝大多数应用都需要以某种方式与数据打交道。对ChitChat来说,它的数据将被存储到关系式数据库PostgreSQL里面,并通过SQL与之交互。

ChitChat的数据模型非常简单,只包含4种数据结构,它们分别是:

以上这4种数据结构都会被映射到关系数据库里面,图2-5展示了这4种数据结构是如何与数据库交互的。

ChitChat论坛允许用户在登录之后发布新帖子或者回复已有的帖子,未登录的用户可以阅读帖子,但是不能发布新帖子或者回复帖子。为了对应用进行简化,ChitChat论坛没有设置版主这一职位,因此用户在发布新帖子或者添加新回复的时候不需要经过审核。

图2-5 Web应用访问数据存储系统的流程

在了解了ChitChat的设计方案之后,现在可以开始考虑具体的实现代码了。在开始学习ChitChat的实现代码之前,请注意,如果你在阅读本章展示的代码时遇到困难,又或者你是刚开始学习Go语言,那么为了更好地理解本章介绍的内容,你可以考虑先花些时间阅读一本Go语言的编程入门书,比如,由William Kennedy、Brian Ketelsen和Erik St. Martin撰写的《Go语言实战》就是一个很不错的选择。

除此之外,在阅读本章时也请尽量保持耐心:本章只是从宏观的角度展示Go Web应用的样子,并没有对Web应用的细节作过多的解释,而是将这些细节留到之后的章节再进一步说明。在有需要的情况下,本章也会在介绍某种技术的同时,说明在哪一章可以找到这一技术的更多相关信息。

请求的接收和处理是所有Web应用的核心。正如之前所说,Web应用的工作流程如下。

(1)客户端将请求发送到服务器的一个URL上。

(2)服务器的多路复用器将接收到的请求重定向到正确的处理器,然后由该处理器对请求进行处理。

(3)处理器处理请求并执行必要的动作。

(4)处理器调用模板引擎,生成相应的HTML并将其返回给客户端。

让我们先从最基本的根URL(/)来考虑Web应用是如何处理请求的:当我们在浏览器上输入地址http://localhost的时候,浏览器访问的就是应用的根URL。在接下来的几个小节里面,我们将会看到ChitChat是如何处理发送至根URL的请求的,以及它又是如何通过动态地生成HTML来对请求进行响应的。

因为编译后的二进制Go应用总是以main函数作为执行的起点,所以我们在对Go应用进行介绍的时候也总是从包含main函数的主源码文件(main source code file)开始。ChitChat应用的主源码文件为main.go,代码清单2-1展示了它的一个简化版本。

代码清单2-1 main.go文件中的main函数,函数中的代码经过了简化

package main

import (
 "net/http"
)

func main() {

 mux := http.NewServeMux()
 files := http.FileServer(http.Dir("/public"))
 mux.Handle("/static/", http.StripPrefix("/static/", files))

  mux.HandleFunc("/", index)

 server := &http.Server{
  Addr: "0.0.0.0:8080",
  Handler: mux,
 }
 server.ListenAndServe()
}

main.go中首先创建了一个多路复用器,然后通过一些代码将接收到的请求重定向到处理器。net/http标准库提供了一个默认的多路复用器,这个多路复用器可以通过调用NewServeMux函数来创建:

mux := http.NewServeMux()

为了将发送至根URL的请求重定向到处理器,程序使用了HandleFunc函数:

mux.HandleFunc("/", index)

HandleFunc函数接受一个URL和一个处理器的名字作为参数,并将针对给定URL的请求转发至指定的处理器进行处理,因此对上述调用来说,当有针对根URL的请求到达时,该请求就会被重定向到名为index的处理器函数。此外,因为所有处理器都接受一个ResponseWriter和一个指向Request结构的指针作为参数,并且所有请求参数都可以通过访问Request结构得到,所以程序并不需要向处理器显式地传入任何请求参数。

需要注意的是,前面的介绍模糊了处理器以及处理器函数之间的区别:我们刚开始谈论的是处理器,而现在谈论的却是处理器函数。这是有意而为之的——尽管处理器和处理器函数提供的最终结果是一样的,但它们实际上并不相同。本书的第3章将对处理器和处理器函数之间的区别做进一步的说明,但是现在让我们暂时先忘掉这件事,继续研究ChitChat应用的代码实现。

除负责将请求重定向到相应的处理器之外,多路复用器还需要为静态文件提供服务。为了做到这一点,程序使用FileServer函数创建了一个能够为指定目录中的静态文件服务的处理器,并将这个处理器传递给了多路复用器的Handle函数。除此之外,程序还使用StripPrefix函数去移除请求URL中的指定前缀:

files := http.FileServer(http.Dir("/public"))
mux.Handle("/static/", http.StripPrefix("/static/", files))

当服务器接收到一个以/static/开头的URL请求时,以上两行代码会移除URL中的/static/字符串,然后在public目录中查找被请求的文件。比如说,当服务器接收到一个针对文件http://localhost/static/css/bootstrap.min.css的请求时,它将会在public目录中查找以下文件:

<application root>/css/bootstrap.min.css

当服务器成功地找到这个文件之后,会把它返回给客户端。

正如之前的小节所说,ChitChat应用会通过HandleFunc函数把请求重定向到处理器函数。正如代码清单2-2所示,处理器函数实际上就是一个接受ResponseWriterRequest指针作为参数的Go函数。

代码清单2-2 main.go文件中的index处理器函数

func index(w http.ResponseWriter, r *http.Request) {
 files := []string{"templates/layout.html",
          "templates/navbar.html",
          "templates/index.html",}
 templates := template.Must(template.ParseFiles(files...))
 if threads, err := data.Threads(); err == nil {
  templates.ExecuteTemplate(w, "layout", threads)
 }
}

index函数负责生成HTML并将其写入ResponseWriter中。因为这个处理器函数会用到html/template标准库中的Template结构,所以包含这个函数的文件需要在文件的开头导入html/template库。之后的小节将对生成HTML的方法做进一步的介绍。

除了前面提到过的负责处理根URL请求的index处理器函数,main.go文件实际上还包含很多其他的处理器函数,如代码清单2-3所示。

代码清单2-3 ChitChat应用的main.go源文件

package main

import (
 "net/http"
)

func main() {

 mux := http.NewServeMux()
 files := http.FileServer(http.Dir(config.Static))
 mux.Handle("/static/", http.StripPrefix("/static/", files))

 mux.HandleFunc("/", index)
 mux.HandleFunc("/err", err)

 mux.HandleFunc("/login", login)
 mux.HandleFunc("/logout", logout)
 mux.HandleFunc("/signup", signup)
 mux.HandleFunc("/signup_account", signupAccount)
 mux.HandleFunc("/authenticate", authenticate)

 mux.HandleFunc("/thread/new", newThread)
 mux.HandleFunc("/thread/create", createThread)
 mux.HandleFunc("/thread/post", postThread)
 mux.HandleFunc("/thread/read", readThread)

 server := &http.Server{
  Addr:      "0.0.0.0:8080",
  Handler:    mux,
 }
 server.ListenAndServe()
}

main函数中使用的这些处理器函数并没有在main.go文件中定义,它们的定义在其他文件里面,具体请参考ChitChat项目的完整源码。

为了在一个文件里面引用另一个文件中定义的函数,诸如PHP、Ruby和Python这样的语言要求用户编写代码去包含(include)被引用函数所在的文件,而另一些语言则要求用户在编译程序时使用特殊的链接(link)命令。

但是对Go语言来说,用户只需要把位于相同目录下的所有文件都设置成同一个包,那么这些文件就会与包中的其他文件分享彼此的定义。又或者,用户也可以把文件放到其他独立的包里面,然后通过导入(import)这些包来使用它们。比如,ChitChat论坛就把连接数据库的代码放到了独立的包里面,我们很快就会看到这一点。

跟其他很多Web应用一样,ChitChat既拥有任何人都可以访问的公开页面,也拥有用户在登录账号之后才能看见的私人页面。

当一个用户成功登录以后,服务器必须在后续的请求中标示出这是一个已登录的用户。为了做到这一点,服务器会在响应的首部中写入一个cookie,而客户端在接收这个cookie之后则会把它存储到浏览器里面。代码清单2-4展示了authenticate处理器函数的实现代码,这个函数定义在route_auth.go文件中,它的作用就是对用户的身份进行验证,并在验证成功之后向客户端返回一个cookie。

代码清单2-4 route_auth.go文件中的authenticate处理器函数

func authenticate(w http.ResponseWriter, r *http.Request) {
 r.ParseForm()
 user, _ := data.UserByEmail(r.PostFormValue("email"))
 if user.Password == data.Encrypt(r.PostFormValue("password")) {
  session := user.CreateSession()
  cookie := http.Cookie{
   Name: "_cookie",
   Value: session.Uuid,
   HttpOnly: true,
  }
  http.SetCookie(w, &cookie)
  http.Redirect(w, r, "/", 302)
 } else {
  http.Redirect(w, r, "/login", 302)
 }
}

注意,代码清单2-4中的authenticate函数使用了两个我们尚未介绍过的函数,一个是data.Encrypt,而另一个则是data.UserbyEmail。因为本节关注的是ChitChat论坛的访问控制机制而不是数据处理方法,所以本节将不会对这两个函数的实现细节进行解释,但这两个函数的名字已经很好地说明了它们各自的作用:data.UserByEmail函数通过给定的电子邮件地址获取与之对应的User结构,而data.Encrypt函数则用于加密给定的字符串。本章稍后将会对data包作更详细的介绍,但是在此之前,让我们回到对访问控制机制的讨论上来。

在验证用户身份的时候,程序必须先确保用户是真实存在的,并且提交给处理器的密码在加密之后跟存储在数据库里面的已加密用户密码完全一致。在核实了用户的身份之后,程序会使用User结构的CreateSession方法创建一个Session结构,该结构的定义如下:

type Session struct {
 Id    int
 Uuid   string
 Email   string
 UserId  int
 CreatedAt time.Time
}

Session结构中的Email字段用于存储用户的电子邮件地址,而UserId字段则用于记录用户表中存储用户信息的行的ID。Uuid字段存储的是一个随机生成的唯一ID,这个ID是实现会话机制的核心,服务器会通过cookie把这个ID存储到浏览器里面,并把Session结构中记录的各项信息存储到数据库中。

在创建了Session结构之后,程序又创建了Cookie结构:

cookie := http.Cookie{
 Name:   "_cookie",
 Value:   session.Uuid,
 HttpOnly: true,
}

cookie的名字是随意设置的,而cookie的值则是将要被存储到浏览器里面的唯一ID。因为程序没有给cookie设置过期时间,所以这个cookie就成了一个会话cookie,它将在浏览器关闭时自动被移除。此外,程序将HttpOnly字段的值设置成了true,这意味着这个cookie只能通过HTTP或者HTTPS访问,但是却无法通过JavaScript等非HTTP API进行访问。

在设置好cookie之后,程序使用以下这行代码,将它添加到了响应的首部里面:

http.SetCookie(writer, &cookie)

在将cookie存储到浏览器里面之后,程序接下来要做的就是在处理器函数里面检查当前访问的用户是否已经登录。为此,我们需要创建一个名为session的工具(utility)函数,并在各个处理器函数里面复用它。代码清单2-5展示了session函数的实现代码,跟其他工具函数一样,这个函数也是在util.go文件里面定义的。再提醒一下,虽然程序把工具函数的定义都放在了util.go文件里面,但是因为util.go文件也隶属于main包,所以这个文件里面定义的所有工具函数都可以直接在整个main包里面调用,而不必像data.Encrypt函数那样需要先引入包然后再调用。

代码清单2-5 util.go文件中的session工具函数

func session(w http.ResponseWriter, r *http.Request)(sess data.Session, err
 error){
 cookie, err := r.Cookie("_cookie")
 if err == nil {
  sess = data.Session{Uuid: cookie.Value}
  if ok, _ := sess.Check(); !ok {
   err = errors.New("Invalid session")
  }
 }
 return
}

为了从请求中取出cookie,session函数使用了以下代码:

cookie, err := r.Cookie("_cookie")

如果cookie不存在,那么很明显用户并未登录;相反,如果cookie存在,那么session函数将继续进行第二项检查——访问数据库并核实会话的唯一ID是否存在。第二项检查是通过data.Session函数完成的,这个函数会从cookie中取出会话并调用后者的Check方法:

sess = data.Session{Uuid: cookie.Value}
if ok, _ := sess.Check(); !ok {
 err = errors.New("Invalid session")
}

在拥有了检查和识别已登录用户和未登录用户的能力之后,让我们来回顾一下之前展示的index处理器函数,代码清单2-6中被加粗的代码行展示了这个处理器函数是如何使用session函数的。

代码清单2-6 index处理器函数

func index(w http.ResponseWriter, r *http.Request) {
 threads, err := data.Threads(); if err == nil {
  , err := session(w, r)
  public_tmpl_files := []string{"templates/layout.html",
                 "templates/public.navbar.html",
                 "templates/index.html"}
  private_tmpl_files := []string{"templates/layout.html",
                  "templates/private.navbar.html",
                  "templates/index.html"}
  var templates *template.Template
  if err != nil {
   templates = template.Must(template.ParseFiles(public_tmpl_files...))
  } else {
   templates = template.Must(template.ParseFiles(private_tmpl_files...))
  }
  templates.ExecuteTemplate(w, "layout", threads)
 }
}

通过调用session函数可以取得一个存储了用户信息的Session结构,不过因为index函数目前并不需要这些信息,所以它使用空白标识符(blank identifier)(_)忽略了这一结构。index函数真正感兴趣的是err变量,程序会根据这个变量的值来判断用户是否已经登录,然后以此来选择是使用public导航条还是使用private导航条。

好的,关于ChitChat应用处理请求的方法就介绍到这里了。本章接下来会继续讨论如何为客户端生成HTML,并完整地叙述之前没有说完的部分。

index处理器函数里面的大部分代码都是用来为客户端生成HTML的。首先,函数把每个需要用到的模板文件都放到了Go切片里面(这里展示的是私有页面的模板文件,公开页面的模板文件也是以同样方式进行组织的):

private_tmpl_files := []string{"templates/layout.html",
                "templates/private.navbar.html",
                "templates/index.html"}

跟Mustache和CTemplate等其他模板引擎一样,切片指定的这3个HTML文件都包含了特定的嵌入命令,这些命令被称为动作(action),动作在HTML文件里面会被{{符号和}}符号包围。

接着,程序会调用ParseFiles函数对这些模板文件进行语法分析,并创建出相应的模板。为了捕捉语法分析过程中可能会产生的错误,程序使用了Must函数去包围ParseFiles函数的执行结果,这样当ParseFiles返回错误的时候,Must函数就会向用户返回相应的错误报告:

templates := template.Must(template.ParseFiles(private_tmpl_files...))

好的,关于模板文件的介绍已经足够多了,现在是时候来看看它们的庐山真面目了。

ChitChat论坛的每个模板文件都定义了一个模板,这种做法并不是强制的,用户也可以在一个模板文件里面定义多个模板,但模板文件和模板一一对应的做法可以给开发带来方便,我们在之后就会看到这一点。代码清单2-7展示了layout.html模板文件的源代码,源代码中使用了define动作,这个动作通过文件开头的{{ define "layout" }}和文件末尾的{{ end }},把被包围的文本块定义成了layout模板的一部分。

代码清单2-7 layout.html模板文件

{{ define "layout" }}

<!DOCTYPE html>
<html lang="en">
 <head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=9">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ChitChat</title>
  <link href="/static/css/bootstrap.min.css" rel="stylesheet">
  <link href="/static/css/font-awesome.min.css" rel="stylesheet">
 </head>
 <body>
  {{ template "navbar" . }}

  <div class="container">

   {{ template "content" . }}

  </div> <!-- /container -->

  <script src="/static/js/jquery-2.1.1.min.js"></script>
  <script src="/static/js/bootstrap.min.js"></script>
 </body>
</html>

{{ end }}

除了define动作之外,layout.html模板文件里面还包含了两个用于引用其他模板文件的template动作。跟在被引用模板名字之后的点(.)代表了传递给被引用模板的数据,比如{{ template "navbar" . }}语句除了会在语句出现的位置引入navbar模板之外,还会将传递给layout模板的数据传递给navbar模板。

代码清单2-8展示了public.navbar.html模板文件中的navbar模板,除了定义模板自身的define动作之外,这个模板没有包含其他动作(严格来说,模板也可以不包含任何动作)。

代码清单2-8 public.navbar.html模板文件

{{ define "navbar" }}

<div class="navbar navbar-default navbar-static-top" role="navigation">
 <div class="container">
  <div class="navbar-header">
   <button type="button" class="navbar-toggle collapsed"
   ➥ data-toggle="collapse" data-target=".navbar-collapse">
    <span class="sr-only">Toggle navigation</span>
    <span class="icon-bar"></span>
    <span class="icon-bar"></span>
    <span class="icon-bar"></span>
   </button>
   <a class="navbar-brand" href="/">
    <i class="fa fa-comments-o"></i>
    ChitChat
   </a>
  </div>
  <div class="navbar-collapse collapse">
   <ul class="nav navbar-nav">
    <li><a href="/">Home</a></li>
   </ul>
   <ul class="nav navbar-nav navbar-right">
    <li><a href="/login">Login</a></li>
   </ul>
  </div>
 </div>
</div>

{{ end }}

最后,让我们来看看定义在index.html模板文件中的content模板,代码清单2-9展示了这个模板的源代码。注意,尽管之前展示的两个模板都与模板文件拥有相同的名字,但实际上模板和模板文件分别拥有不同的名字也是可行的。

代码清单2-9 index.html模板文件

{{ define "content" }}

<p class="lead">
 <a href="/thread/new">Start a thread</a> or join one below!
</p>

{{ range . }}
 <div class="panel panel-default">
  <div class="panel-heading">
   <span class="lead"> <i class="fa fa-comment-o"></i> {{ .Topic }}</span>
  </div>
  <div class="panel-body">
   Started by {{ .User.Name }} - {{ .CreatedAtDate }} - {{ .NumReplies }}
 posts.
   <div class="pull-right">
    <a href="/thread/read?id={{.Uuid }}">Read more</a>
   </div>
  </div>
 </div>
{{ end }}

{{ end }}

index.html文件里面的代码非常有趣,特别值得一提的是文件里面包含了几个以点号(.)开头的动作,比如{{ .User.Name }}{{ .CreatedAtDate }},这些动作的作用和之前展示过的index处理器函数有关:

threads, err := data.Threads(); if err == nil {
 templates.ExecuteTemplate(writer, "layout", threads)
}

在以下这行代码中:

templates.ExecuteTemplate(writer, "layout", threads)

程序通过调用ExecuteTemplate函数,执行(execute)已经经过语法分析的layout模板。执行模板意味着把模板文件中的内容和来自其他渠道的数据进行合并,然后生成最终的HTML内容,具体过程如图2-6所示。

图2-6 模板引擎通过合并数据和模板来生成HTML

程序之所以对layout模板而不是navbar模板或者content模板进行处理,是因为layout模板已经引用了其他两个模板,所以执行layout模板就会导致其他两个模板也被执行,由此产生出预期的HTML。但是,如果程序只执行navbar模板或者content模板,那么程序最终只会产生出预期的HTML的一部分。

现在,你应该已经明白了,点号(.)代表的就是传入到模板里面的数据(实际上还不仅如此,接下来的小节会对这方面做进一步的说明)。图2-7展示了程序根据模板生成的ChitChat论坛的样子。

图2-7 ChitChat Web应用示例的主页

因为生成HTML的代码会被重复执行很多次,所以我们决定对这些代码进行一些整理,并将它们移到代码清单2-10所示的generateHTML函数里面。

代码清单2-10 generateHTML函数

func generateHTML(w http.ResponseWriter, data interface{}, fn ...string) {
 var files []string
 for _, file := range fn {
  files = append(files, fmt.Sprintf("templates/%s.html", file))
 }
 templates := template.Must(template.ParseFiles(files...))
 templates.ExecuteTemplate(writer, "layout", data)
}

generateHTML函数接受一个ResponseWriter、一些数据以及一系列模板文件作为参数,然后对给定的模板文件进行语法分析。data参数的类型为空接口类型(empty interface type),这意味着该参数可以接受任何类型的值作为输入。刚开始接触Go语言的人可能会觉得奇怪——Go不是静态编程语言吗,它为什么能够使用没有类型限制的参数?

但实际上,Go程序可以通过接口(interface)机制,巧妙地绕过静态编程语言的限制,并借此获得接受多种不同类型输入的能力。Go语言中的接口由一系列方法构成,并且每个接口就是一种类型。一个空接口就是一个空集合,这意味着任何类型都可以成为一个空接口,也就是说任何类型的值都可以传递给函数作为参数。

generateHTML函数的最后一个参数以3个点(...)开头,它表示generateHTML函数是一个可变参数函数(variadic function),这意味着这个函数可以在最后的可变参数中接受零个或任意多个值作为参数。generateHTML函数对可变参数的支持使我们可以同时将任意多个模板文件传递给该函数。在Go语言里面,可变参数必须是可变参数函数的最后一个参数。

在实现了generateHTML函数之后,让我们回过头来,继续对index处理器函数进行整理。代码清单2-11展示了经过整理之后的index处理器函数,现在它看上去更整洁了。

代码清单2-11 index处理器函数的最终版本

func index(writer http.ResponseWriter, request *http.Request) {
 threads, err := data.Threads(); if err == nil {
  _, err := session(writer, request)
  if err != nil {
   generateHTML(writer, threads, "layout", "public.navbar", "index")
  } else {
   generateHTML(writer, threads, "layout", "private.navbar", "index")
  }
 }
}

在这一节中,我们学习了很多关于模板的基础知识,之后的第5章将对模板做更详细的介绍。但是在此之前,让我们先来了解一下ChitChat应用使用的数据源(data source),并借此了解一下ChitChat应用的数据是如何与模板一同生成最终的HTML的。

在本章以及后续几章中,每当遇到需要访问关系数据库的场景,我们都会使用PostgreSQL。在开始使用PostgreSQL之前,我们首先需要学习的是如何安装并运行PostgreSQL,以及如何创建本章所需的数据库。

PostgreSQL 官方网站为各种不同版本的Linux和FreeBSD都提供了预编译的二进制安装包,用户只需要下载其中一个安装包,然后根据指示进行安装就可以了。比如说,通过执行以下命令,我们可以在Ubuntu发行版上安装Postgres:

sudo apt-get install postgresql postgresql-contrib

这条命令除了会安装postgres包之外,还会安装附加的工具包,并在安装完毕之后启动PostgreSQL数据库系统。

在默认情况下,Postgres会创建一个名为postgres的用户,并将其用于连接服务器。为了操作方便,你也可以使用自己的名字创建一个Postgres账号。要做到这一点,首先需要登入Postgres账号:

sudo su postgres

接着使用createuser命令创建一个PostgreSQL账号:

createuser –interactive

最后,还需要使用createdb命令创建以你的账号名字命名的数据库:

createdb <YOUR ACCOUNT NAME>

要在Mac OS X上安装PostgreSQL,最简单的方法是使用的Postgres应用:你只需要把网站上提供的zip压缩包下载下来,解压它,然后把Postgres.app文件拖曳到自己的Applications文件夹里面就可以了。启动Postgres.app的方法跟启动其他Mac OS X应用的方法完全一样。Postgres.app在初次启动的时候会初始化一个新的数据库集群,并为自己创建一个数据库。因为命令行工具psql也包含在了Postgres.app里面,所以在设置好正确的路径之后,你就可以使用psql访问数据库了。设置路径的工作可以通过在你的~/.profile文件或者~/.bashrc文件中添加以下代码行来完成[1]

export PATH=$PATH:/Applications/Postgres.app/Contents/Versions/9.4/bin

因为Windows系统上的很多PostgreSQL图形安装程序都会把一切安装步骤布置妥当,用户只需要进行相应的设置就可以了,所以在Windows系统上安装PostgreSQL也是非常简单和直观的。其中一个流行的安装程序是由Enterprise DB提供的。

除了PostgreSQL数据库本身之外,安装包还会附带诸如pgAdmin等工具,以便用户通过这些工具进行后续的配置。

本章前面在展示ChitChat应用的设计方案时,曾经提到过ChitChat应用包含了4种数据结构。虽然把这4种数据结构放到主源码文件里面也是可以的,但更好的办法是把所有与数据相关的代码都放到另一个包里面——ChitChat应用的data包也因此应运而生。

为了创建data包,我们首先需要创建一个名为data的子目录,并创建一个用于保存所有帖子相关代码的thread.go文件(在之后的小节里面,我们还会创建一个用于保存所有用户相关代码的user.go文件)。在此之后,每当程序需要用到data包的时候(比如处理器需要访问数据库的时候),程序都需要通过import语句导入这个包:

import (
 "github.com/sausheong/gwp/Chapter_2_Go_ChitChat/chitchat/data"
)

代码清单2-12展示了定义在thread.go文件里面的Thread结构,这个结构存储了与帖子有关的各种信息。

代码清单2-12 定义在thread.go文件里面的Thread结构

package data

import(
 "time"
)
type Thread struct {
 Id    int
 Uuid   string
 Topic   string
 UserId  int
 CreatedAt time.Time
}

正如代码清单2-12中加粗显示的代码行所示,文件的包名现在是data而不再是main了,这个包就是前面小节中我们曾经见到过的data包。data包除了包含与数据库交互的结构和代码,还包含了一些与数据处理密切相关的函数。隶属于其他包的程序在引用data包中定义的函数、结构或者其他东西时,必须在被引用元素的名字前面显式地加上data这个包名。比如说,引用Thread结构就需要使用data.Thread这个名字,而不能仅仅使用Thread这个名字。

Thread结构应该与创建关系数据库表threads时使用的数据定义语言(Data Definition Language,DDL)保持一致。因为threads表目前尚未存在,所以我们必须创建这个表以及容纳该表的数据库。创建chitchat数据库的工作可以通过执行以下命令来完成:

createdb chitchat

在创建数据库之后,我们就可以通过代码清单2-13展示的setup.sql文件为ChitChat论坛创建相应的数据库表了。

代码清单2-13 用于在PostgreSQL里面创建数据库表的setup.sql文件

create table users (
 id     serial primary key,
 uuid    varchar(64) not null unique,
 name    varchar(255),
 email   varchar(255) not null unique,
 password  varchar(255) not null,
 created_at timestamp not null
);

create table sessions (
 id     serial primary key,
 uuid    varchar(64) not null unique,
 email   varchar(255),
 user_id  integer references users(id),
 created_at timestamp not null
);

create table threads (
 id     serial primary key,
 uuid    varchar(64) not null unique,
 topic   text,
 user_id  integer references users(id),
 created_at timestamp not null
);

create table posts (
 id     serial primary key,
 uuid    varchar(64) not null unique,
 body    text,
 user_id  integer references users(id),
 thread_id integer references threads(id),
 created_at timestamp not null
);

运行这个脚本需要用到psql工具,正如上一节所说,这个工具通常会随着PostgreSQL一同安装,所以你只需要在终端里面执行以下命令就可以了:

psql –f setup.sql –d chitchat

如果一切正常,那么以上命令将在chitchat数据库中创建出相应的表。在拥有了表之后,程序就必须考虑如何与数据库进行连接以及如何对表进行操作了。为此,程序创建了一个名为Db的全局变量,这个全局变量是一个指针,指向的是代表数据库连接池的sql.DB,而后续的代码则会使用这个Db变量来执行数据库查询操作。代码清单2-14展示了Db变量在data.go文件中的定义,此外还展示了一个用于在Web应用启动时对Db变量进行初始化的init函数。

代码清单2-14 data.go文件中的Db全局变量以及init函数

Var Db *sql.DB

func init() {
 var err error
 Db, err = sql.Open("postgres", "dbname=chitchat sslmode=disable")
 if err != nil {
  log.Fatal(err)
 }
 return
}

现在程序已经拥有了结构、表以及一个指向数据库连接池的指针,接下来要考虑的是如何连接(connect)Thread结构和threads表。幸运的是,要做到这一点并不困难:跟ChitChat应用的其他部分一样,我们只需要创建能够在结构和数据库之间互动的函数就可以了。例如,为了从数据库里面取出所有帖子并将其返回给index处理器函数,我们可以使用thread.go文件中定义的Threads函数,代码清单2-15给出了这个函数的定义。

代码清单2-15 threads.go文件中定义的Threads函数

func Threads() (threads []Thread, err error){
 rows, err := Db.Query("SELECT id, uuid, topic, user_id, created_at FROM
 threads ORDER BY created_at DESC")
 if err != nil {
  return
 }
 for rows.Next() {
  th := Thread{}
  if err = rows.Scan(&th.Id, &th.Uuid, &th.Topic, &th.UserId,
  ➥&th.CreatedAt); err != nil {
   return
  }
  threads = append(threads, th)
 }
 rows.Close()
 return
}

简单来讲,Threads函数执行了以下工作:

(1)通过数据库连接池与数据库进行连接;

(2)向数据库发送一个SQL查询,这个查询将返回一个或多个行作为结果;

(3)遍历行,为每个行分别创建一个Thread结构,首先使用这个结构去存储行中记录的帖子数据,然后将存储了帖子数据的Thread结构追加到传入的threads切片里面;

(4)重复执行步骤3,直到查询返回的所有行都被遍历完毕为止。

本书的第6章将对数据库操作的细节做进一步的介绍。

在了解了如何将数据库表存储的帖子数据提取到Thread结构里面之后,我们接下来要考虑的就是如何在模板里面展示Thread结构存储的数据了。在代码清单2-9中展示的index.html模板文件,有这样一段代码:

{{ range . }}
 <div class="panel panel-default">
  <div class="panel-heading">
   <span class="lead"> <i class="fa fa-comment-o"></i> {{ .Topic }}</span>
  </div>
  <div class="panel-body">
   Started by {{ .User.Name }} - {{ .CreatedAtDate }} - {{ .NumReplies }}
 posts.
   <div class="pull-right">
    <a href="/thread/read?id={{.Uuid }}">Read more</a>
   </div>
  </div>
 </div>
{{ end }}

正如之前所说,模板动作中的点号(.)代表传入模板的数据,它们会和模板一起生成最终的结果,而{{ range . }}中的.号代表的是程序在稍早之前通过Threads函数取得的threads变量,也就是一个由Thread结构组成的切片。

range动作假设传入的数据要么是一个由结构组成的切片,要么是一个由结构组成的数组,这个动作会遍历传入的每个结构,而用户则可以通过字段名访问结构里面的字段,比如,动作{{ .Topic }}访问的是Thread结构的Topic字段。注意,在访问字段时必须在字段名的前面加上点号,并且字段名的首字母必须大写。

用户除可以在字段名的前面加上点号来访问结构中的字段以外,还可以通过相同的方法调用一种名为方法(method)的特殊函数。比如,在上面展示的代码中,{{ .User.Name }}{{ .CreatedAtDate }}{{ .NumReplies }}这些动作的作用就是调用结构中的同名方法,而不是访问结构中的字段。

方法是隶属于特定类型的函数,指针、接口以及包括结构在内的所有具名类型都可以拥有自己的方法。比如说,通过将函数与指向Thread结构的指针进行绑定,可以创建出一个针对Thread结构的方法,而传入方法里面的Thread结构则称为接收者(receiver):方法可以访问接收者,也可以修改接收者。

作为例子,代码清单2-16展示了NumReplies方法的实现代码。

代码清单2-16 thread.go文件中的NumReplies方法

func (thread *Thread) NumReplies() (count int) {
 rows, err := Db.Query("SELECT count(*) FROM posts where thread_id = $1",
 thread.Id)
 if err != nil {
  return
 }
 for rows.Next() {
  if err = rows.Scan(&count); err != nil {
   return
  }
 }
 rows.Close()
 return
}

NumReplies方法首先打开一个指向数据库的连接,接着通过执行一条SQL查询来取得帖子的数量,并使用传入方法里面的count参数来记录这个值。最后,NumReplies方法返回帖子的数量作为方法的执行结果,而模板引擎则使用这个值去代替模板文件中出现的{{ .NumReplies }}动作。

通过为UserSessionThreadPost这4种数据结构创建相应的函数和方法,ChitChat最终在处理器函数和数据库之间构建起了一个数据层,以此来避免处理器函数直接对数据库进行访问,图2-8展示了这个数据层和数据库以及处理器函数之间的关系。虽然有很多库都可以达到同样的效果,但亲自构建数据层能够帮助我们学习如何对数据库进行基本的访问,并借此了解到实现这种访问并不困难,只需要用到一些简单直接的代码,这一点是非常有益的。

图2-8 通过结构模型连接数据库和处理器

在本章的最后,让我们来看一下ChitChat应用是如何启动服务器并将多路复用器与服务器进行绑定的。执行这一工作的代码是在main.go文件里面定义的:

server := &http.Server{
 Addr:   "0.0.0.0:8080",
 Handler: mux,
}
server.ListenAndServe()

这段代码非常简单,它所做的就是创建一个Server结构,然后在这个结构上调用ListenAndServe方法,这样服务器就能够启动了。

现在,我们可以通过执行以下命令来编译并运行ChitChat应用:

go build

这个命令会在当前目录以及$GOPATH/bin目录中创建一个名为chitchat的二进制可执行文件,它就是ChitChat应用的服务器。接着,我们可以通过执行以下命令来启动这个服务器:

./chitchat

如果你已经按照之前所说的方法,在数据库里面创建了ChitChat应用所需的数据库表,那么现在你只需要访问http://localhost:8080/并注册一个新账号,然后就可以使用自己的账号在论坛上发布新帖子了。

在本章的各节中,我们对一个Go Web应用的不同组成部分进行了初步的了解和观察。图2-9对整个应用的工作流程进行了介绍,其中包括:

(1)客户端向服务器发送请求;

(2)多路复用器接收到请求,并将其重定向到正确的处理器;

(3)处理器对请求进行处理;

(4)在需要访问数据库的情况下,处理器会使用一个或多个数据结构,这些数据结构都是根据数据库中的数据建模而来的;

(5)当处理器调用与数据结构有关的函数或者方法时,这些数据结构背后的模型会与数据库进行连接,并执行相应的操作;

(6)当请求处理完毕时,处理器会调用模板引擎,有时候还会向模板引擎传递一些通过模型获取到的数据;

(7)模板引擎会对模板文件进行语法分析并创建相应的模板,而这些模板又会与处理器传递的数据一起合并生成最终的HTML;

(8)生成的HTML会作为响应的一部分回传至客户端。

图2-9 Web应用工作流程概览

主要的步骤大概就是这些。在接下来的几章中,我们会更加深入地学习这一工作流程,并进一步了解该流程涉及的各个组件。

[1] 在安装Postgres.app时,你可能需要根据Postgres.app的版本对路径的版本部分做相应的修改,比如,将其中的9.4修改为9.5或者9.6,诸如此类。——译者注


相关图书

Rust游戏开发实战
Rust游戏开发实战
仓颉编程快速上手
仓颉编程快速上手
深入浅出Go语言编程从原理解析到实战进阶
深入浅出Go语言编程从原理解析到实战进阶
JUnit实战(第3版)
JUnit实战(第3版)
Go语言编程指南
Go语言编程指南
Scala速学版(第3版)
Scala速学版(第3版)

相关文章

相关课程