Go-网络编程-全-
Go 网络编程(全)
一、架构
本章涵盖了分布式系统的主要架构特征。如果你对自己想要构建的东西没有一点概念,你就无法构建一个系统。如果你不知道它的工作环境,你就无法建造它。GUI 程序不同于批处理程序;游戏程序不同于商业程序;分布式程序不同于独立程序。他们都有自己的方法、共同的模式、通常会出现的问题以及常用的解决方案。
本章涵盖了分布式系统的高级架构方面。有许多方法来看待这样的系统,其中许多是处理。
协议层
分布式系统很难。这涉及到多台计算机,它们必须以某种方式连接起来。必须编写程序在系统中的每台计算机上运行,并且它们都必须合作来完成分布式任务。
处理复杂性的通常方法是把它分解成更小更简单的部分。这些部分有自己的结构,但它们也有与其他相关部分通信的定义方式。在分布式系统中,这些部分被称为协议层,它们有明确定义的功能。它们形成一个堆栈,每一层都与上一层和下一层通信。各层之间的通信由协议定义。
网络通信要求协议涵盖高层应用通信,一直到有线通信,以及协议层封装所处理的复杂性。
ISO 开放系统互连协议
尽管 OSI(开放系统互连)协议从未被正确实现,但它已经成为谈论和影响分布式系统设计的一个重要因素。通常如图 1-1 所示。
图 1-1。
The Open Systems Interconnect protocol
OSI 层
每层的功能自下而上如下:
- 物理层使用电、光或无线电技术传送比特流。
- 数据链路层将信息包放入网络帧中,以便通过物理层传输,然后再放回信息包中。
- 网络层提供交换和路由技术。
- 传输层在终端系统之间提供透明的数据传输,并负责端到端的错误恢复和流量控制。
- 会话层建立、管理和终止应用程序之间的连接。
- 表示层提供了与数据表示(如加密)差异的独立性。
- 应用层支持应用程序和最终用户流程。
TCP/IP 协议
当 OSI 模型被争论、辩论、部分实现和争论的时候,DARPA 互联网研究项目正忙于建立 TCP/IP 协议。这些都取得了巨大的成功,并导致了互联网(与资本)。这是一个简单得多的堆栈,如图 1-2 所示。
图 1-2。
The TCP/IP protocols
一些替代协议
尽管看起来很像,但 TCP/IP 协议并不是现存的唯一协议,从长远来看,它甚至可能不是最成功的。维基百科的网络协议列表(见 https://en.wikipedia.org/wiki/List_of_network_protocols_(OSI_model)
)在每一个 ISO 层都有一个巨大的数字。其中许多协议已经过时或用处不大,但由于各种领域的技术进步,如太空互联网和物联网,总会有新协议的空间。
本书的重点是 TCP/IP(包括 UDP)层,但是您应该知道还有其他层。
建立关系网
网络是连接称为主机的终端系统的通信系统。连接机制可能是铜线、以太网、光纤或无线,但这不是我们在这里关心的。局域网(LAN)将相距很近的计算机连接起来,这些计算机通常属于一个家庭、小型组织或大型组织的一部分。
广域网(WAN)将计算机连接到更大的物理区域,如城市之间。还有其他类型,如城域网,个人局域网,甚至体域网。
互联网是两个或多个不同网络的连接,通常是局域网或广域网。内部网是所有网络都属于一个组织的互联网。
互联网和内部网有很大的不同。典型地,内部网将处于单一的管理控制之下,这将强加单一的一组一致的策略。另一方面,互联网不会由一个机构控制,对不同部分的控制甚至可能不兼容。
这种差异的一个小例子是,内部网通常被运行特定操作系统的标准化版本的少数供应商限制在计算机上。另一方面,互联网通常会有不同的计算机和操作系统的大杂烩。
这本书的技术适用于互联网。它们对内部网也是有效的,但在那里你也会发现专门的、不可移植的系统。
此外,还有所有互联网的“母亲”:互联网。这只是一个非常非常大的互联网,连接我们和谷歌,我的电脑和你的电脑,等等。
方法
网关是用于连接两个或多个网络的实体的通称。中继器在物理层工作,将信息从一个子网复制到另一个子网。网桥工作在数据链路层,在网络之间复制帧。路由器在网络层运行,不仅在网络间传输信息,还决定路由。
分组封装
OSI 或 TCP/IP 堆栈中各层之间的通信是通过将数据包从一层发送到下一层,然后最终通过网络完成的。每一层都有它必须保存的关于自己层的管理信息。当数据包向下传递时,它会将报头信息添加到从上一层接收的数据包中。在接收端,当数据包向上移动时,这些报头会被删除。
例如,TFTP(普通文件传输协议)将文件从一台计算机移动到另一台计算机。它使用 IP 协议之上的 UDP 协议,可以通过以太网发送。如图 1-3 所示。
图 1-3。
The TFTP (Trivial File Transfer Protocol)
通过以太网传输的数据包当然是最底层的。
连接模型
为了让两台计算机进行通信,它们必须建立一条路径,通过这条路径,它们可以在一个会话中至少发送一条消息。对此有两种主要模式:
- 面向连接
- 无连接传输模式
面向连接
为会话建立单个连接。双向通信沿着连接流动。当会话结束时,连接断开。这类似于电话交谈。TCP 就是一个例子。
无连接传输模式
在无连接系统中,消息是相互独立发送的。打个比方,普通邮件。无连接消息可能会无序到达。IP 协议就是一个例子。UDP 是 IP 之上的无连接协议,因为它的重量轻得多,所以经常被用作 TCP 的替代协议。
面向连接的传输可以建立在无连接传输之上——TCP over IP。无连接传输可以建立在面向连接的传输之上——HTTP over TCP。
这些可以有变化。例如,会话可能强制消息到达,但可能无法保证它们按照发送的顺序到达。然而,这两个是最常见的。
通信模型
在分布式系统中,会有许多组件运行,它们必须相互通信。有两种主要的模型,消息传递和远程过程调用。
信息传递
一些非过程化语言建立在消息传递的原则上。并发语言经常使用这种机制,最著名的例子可能是 UNIX 管道。UNIX 管道是字节的管道,但这不是固有的限制:微软的 PowerShell 可以沿其管道发送对象,Parlog 等并发语言可以在并发进程之间的消息中发送任意逻辑数据结构。
消息传递是分布式系统的基本机制。建立一个连接,然后输入一些数据。在另一端,找出消息是什么,并对其作出响应,可能发送回消息。这如图 1-4 所示。
图 1-4。
The message passing communications model
事件驱动系统以类似的方式工作。在底层,node.js
运行一个事件循环,等待 I/O 事件,为这些事件分派处理程序并做出响应。在更高层次上,大多数用户界面系统使用事件循环等待用户输入,而在网络世界中,Ajax 使用XMLHttpRequest
来发送和接收请求。
远程过程得
在任何系统中,都有信息和流控制从系统的一部分到另一部分的转移。在过程语言中,这可能由过程调用组成,其中信息被放在调用堆栈上,然后控制流被转移到程序的另一部分。
即使是过程调用,也会有变化。代码可以是静态链接的,以便控制从程序的可执行代码的一部分转移到另一部分。由于库例程的使用越来越多,在动态链接库(dll)中拥有这样的代码已经变得很常见,在动态链接库中,控制转移到一段独立的代码。
dll 与调用代码运行在同一台计算机上。将控制权转移给运行在不同机器上的过程是一个简单的(概念性的)步骤。这其中的机制并不简单!然而,这种控制模式导致了远程过程调用(RPC ),这将在后面的章节中详细讨论。如图 1-5 所示。
图 1-5。
The remote procedure call communications model
有很多这样的例子:一些基于特定的编程语言,比如 Go rpc 包(在第十三章中讨论)或者覆盖多种语言的 rpc 系统,比如 SOAP 和 Google 的 grpc。
分布式计算模型
在最高层次上,我们可以考虑分布式系统组件的等价或不等价。最常见的情况是非对称的:客户端向服务器发送请求,服务器做出响应。这是一个客户机-服务器系统。
如果两个组件是等价的,都能够发起和响应消息,那么我们就有了一个对等系统。注意,这是一个逻辑分类:一个对等体可能是 16000 核的超级计算机,另一个可能是手机。但是,如果两者的行为相似,那么他们就是同龄人。
如图 1-6 所示。
图 1-6。
Client-sever versus peer-to-peer systems
客户-服务器系统
客户端-服务器系统的另一个视图如图 1-7 所示。
图 1-7。
The client-server system
需要了解系统组件的开发人员可能持有这种观点。这也是用户可能持有的观点:浏览器的用户知道它正在她的系统上运行,但是正在与别处的服务器通信。
客户端-服务器应用程序
有些应用程序可能是无缝分布的,用户并不知道它是分布式的。用户将看到他们的系统视图,如图 1-8 所示。
图 1-8。
The user’s view of the system
服务器分布
客户机-服务器系统不必简单。基本模型是单客户端、单服务器系统,如图 1-9 所示。
图 1-9。
The single client, single server system
然而,你也可以有多个客户端,单个服务器,如图 1-10 所示。
图 1-10。
The multiple clients, single server system
在这个系统中,主服务器接收请求,并不是一次处理一个请求,而是将它们传递给其他服务器来处理。当可能有并发客户端时,这是一个常见的模型。
还有单客户端,多服务器,如图 1-11 所示。
图 1-11。
The single client, multiple servers system
当一个服务器需要充当其他服务器的客户端时,例如业务逻辑服务器从数据库服务器获取信息,这种类型的系统经常出现。当然,也可能有多个客户端和多个服务器。
沟通流程
前面的图表显示了系统高层组件之间的连接视图。数据将在这些组件之间流动,并且可以通过多种方式流动,这将在下面的部分中讨论。
同步通信
在同步通信中,一方将发送消息并阻塞,等待回复。这通常是实现起来最简单的模型,仅仅依赖于阻塞 I/O。但是,可能需要一个超时机制,以防某些错误意味着永远不会发送回复。
异步通信
在异步通信中,一方发送消息,而不是等待回复,继续进行其他工作。当回复最终到来时,它被处理。这可能是在另一个线程中或通过中断当前线程。这种应用程序更难构建,但使用起来更加灵活。
流式通信
在流式通信中,一方发送连续的消息流。在线视频就是一个很好的例子。该流可能需要实时处理,可能容忍或不容忍丢失,并且可以是单向的或允许反向通信,如在控制消息中。
发布/订阅
在发布/订阅系统中,各方订阅主题,其他人发布主题。正如 Twitter 所展示的那样,这可以是小规模的,也可以是大规模的。
成分分布
分解许多应用程序的一个简单而有效的方法是将它们看作由三部分组成:
- 演示组件
- 应用逻辑
- 数据存取
表示组件负责与用户的交互,包括显示数据和收集输入。它可以是具有按钮、列表、菜单等的现代 GUI 界面。,还是比较老的命令行风格的界面,提问得到答案。它还可以包含更广泛的交互风格,例如与诸如收银机、ATM 等物理设备的交互。它还可以涵盖与非人类用户的交互,如在机器对机器系统中。细节在这个层面并不重要。
应用程序逻辑负责解释用户的响应、应用业务规则、准备查询以及管理来自第三方组件的响应。
数据访问组件负责存储和检索数据。这通常会通过数据库,但不是必须的。
Gartner 分类
基于应用程序的这种三重分解,Gartner 考虑了如何在客户机-服务器系统中分配组件。他们提出了五种模型,如图 1-12 所示。
图 1-12。
Gartner’s five models
示例:分布式数据库
-
Gartner classification : 1 (see Figure 1-13)
图 1-13。
Gartner example 1
现代手机就是很好的例子。由于内存有限,他们可能会在本地存储一小部分数据库,这样他们通常可以快速响应。然而,如果需要的数据不是本地保存的,则可以向远程数据库请求该附加数据。
谷歌地图是另一个很好的例子。所有的地图都在谷歌的服务器上。当用户请求时,“附近”的地图也被下载到浏览器的一个小数据库中。当用户稍微移动地图时,所需的额外位已经在本地存储中,以便快速响应。
示例:网络文件服务
Gartner classification 2 允许远程客户端访问共享文件系统,如图 1-14 所示。
图 1-14。
Gartner example 2
这样的系统有很多例子:NFS、微软股份、DCE 等等。
示例:Web
Gartner 分类 3 的一个例子是带有 Java 小程序或 JavaScript、CGI 脚本或类似程序(Ruby on Rails 等)的 Web。)在服务器端。这是一个分布式超文本系统,有许多附加机制,如图 1-15 所示。
图 1-15。
Gartner example 3
示例:终端仿真
Gartner 分类 4 的一个例子是终端仿真。这允许远程系统作为本地系统的普通终端,如图 1-16 所示。
图 1-16。
Gartner example 4
Telnet 是这方面最常见的例子。
示例:安全外壳
UNIX 上的安全 shell 允许您连接到远程系统,在那里运行命令,并在本地显示演示。演示文稿在远程机器上准备,并在本地显示。在 Windows 下,远程桌面的行为类似。参见图 1-17 。
图 1-17。
Gartner example 4
三层模型
当然,如果您有两层,那么您可以有三层、四层或更多层。一些三层可能性如图 1-18 所示。
图 1-18。
Three-tier models
现代网络是最右边的一个很好的例子。后端由数据库组成,通常运行存储过程来保存一些数据库逻辑。中间层是 HTTP 服务器,如运行 PHP 脚本(或 Ruby on Rails,或 JSP 页面等)的 Apache。).这将管理一些逻辑,并将 HTML 页面等数据存储在本地。前端是在一些 JavaScript 的控制下显示页面的浏览器。在 HTML 5 中,前端可能也有一个本地数据库。
胖与瘦
一种常见的成分标签是“脂肪”或“瘦”。Fat 组件占用大量内存并进行复杂的处理。另一方面,薄的组件在这两方面都没什么用。似乎没有什么“正常”的尺寸成分,只有胖或瘦!
胖瘦是一个相对的概念。浏览器经常被贴上瘦的标签,因为它们所做的只是显示网页。然而,我的 Linux 机器上的 Firefox 占用了将近半个千兆字节的内存,我一点也不认为这很小!
中间件模型
中间件是连接分布式系统组件的“粘合剂”。中间件模型如图 1-19 所示。
图 1-19。
The middleware model
中间件的组件包括以下内容:
- TCP/IP 等网络服务
- 中间件层是使用网络服务的独立于应用程序的软件
- 数据库访问
- 身份等服务的管理者
- 安全模块
中间件示例
中间件的例子包括:
- 终端模拟器、文件传输和电子邮件等基本服务
- RPC 等基本服务
- 集成服务,如 DCE(分布式计算环境)
- 分布式对象服务,如 CORBA 和 OLE/ActiveX
- 移动对象服务,如 RMI 和 Jini
- 万维网
中间件功能
中间件的功能包括:
- 在不同计算机上启动进程
- 会话管理
- 允许客户端定位服务器的目录服务
- 远程数据访问
- 允许服务器处理多个客户端的并发控制
- 安全性和完整性
- 监视
- 本地和远程进程的终止
连续加工
Gartner 模型基于将应用程序分解为表示、应用程序逻辑和数据处理等组件。图 1-20 显示了更精细的细分。
图 1-20。
Breakdown of an application into its components of presentation
故障点
分布式应用程序运行在复杂的环境中。这使得它们比单台计算机上的独立应用程序更容易失败。故障点包括:
- 客户端错误
- 应用程序的客户端可能会崩溃
- 客户端系统可能有硬件问题
- 客户端的网卡可能会出现故障
- 网络错误
- 网络连接可能会导致超时
- 可能存在网络地址冲突
- 路由器等网络元素可能会出现故障
- 传输错误可能会丢失消息
- 客户端-服务器错误
- 客户端和服务器版本可能不兼容
- 服务器错误
- 服务器的网卡可能会出现故障
- 服务器系统可能有硬件问题
- 服务器软件可能会崩溃
- 服务器的数据库可能会损坏
设计应用程序时必须考虑到这些可能的故障。如果系统的其他部分出现故障,由一个组件执行的任何操作都必须是可恢复的。需要使用诸如事务和连续错误检查之类的技术来避免错误。应该注意的是,虽然独立的应用程序可能对可能发生的错误有很多控制,但分布式系统不是这样。例如,服务器无法控制网络或客户端错误,只能准备处理它们。在许多情况下,错误的原因可能不清楚:是客户端崩溃了还是网络中断了?
接受因素
分布式系统的验收因素与独立系统的验收因素相似。它们包括以下内容:
- 可靠性
- 表演
- 响应性
- 可量测性
- 容量
- 安全
目前,用户经常容忍比独立系统更糟糕的行为。“哦,网速慢”似乎是一个可以接受的借口。事实并非如此,开发人员不应该陷入这样的思维定势,认为他们控制下的因素会产生可忽略的影响。
透明度
分布式系统的“圣杯”提供以下功能:
- 访问透明性
- 位置透明性
- 迁移透明度
- 复制透明性
- 并发透明性
- 可扩展性透明性
- 绩效透明度
- 故障透明度
访问透明性
用户不应知道(或需要知道)对系统的全部或部分的访问是本地的还是远程的。
位置透明性
服务的位置并不重要。
迁移透明度
如果系统的一部分移动到另一个位置,对用户来说应该没有什么影响。
复制透明性
如果系统的一个或多个副本正在运行,应该没有关系。
并发透明性
同时运行的系统各部分之间不应有干扰。例如,如果我正在访问数据库,那么你不应该知道。
可扩展性透明性
系统上有一百万或一百万用户都没关系。
绩效透明度
性能不应受到任何系统或网络特征的影响。
故障透明度
该系统不应失败。如果部分失败,系统应该在用户不知道失败发生的情况下恢复。
这些透明度因素中的大多数在违反中比在遵守中观察到的多。有一些显著的例子几乎符合这些标准。例如,当您连接到 Google 时,您不知道(或不关心)服务器在哪里。使用亚马逊网络服务的系统能够根据需求进行伸缩。网飞有着看似残酷的测试策略,定期故意破坏其系统的大部分,以确保整体仍能正常工作。
分布式计算的八个谬误
Sun Microsystems 是一家在分布式系统中做了大量早期工作的公司,甚至有一句口头禅“网络就是计算机”。" Sun 的一些科学家根据他们多年的经验,提出了以下通常假定的谬误:
- 网络是可靠的。
- 延迟为零。
- 带宽是无限的。
- 网络很安全。
- 拓扑不会改变。
- 有一个管理员。
- 运输成本为零。
- 网络是同构的。
谬论:网络可靠
Bailis 和 Kingsbury 的一篇题为“网络是可靠的”(见 http://queue.acm.org/detail.cfm?id=2655736
)的论文检验了这一谬误。它发现了许多实例,例如微软报告他们的数据中心每天发生 5.2 次设备故障和 40.8 次链路故障。
中国政府使用“DNS 中毒”作为审查其认为不受欢迎的网站的技术之一。中国也运行一个 DNS 根服务器。2010 年,这台服务器配置错误,毒害了许多其他国家的 DNS 服务器。这使得许多非中文网站在中国内外都无法访问(见 http://www.pcworld.com/article/192658/article.html
)。
还有许多其他可能的情况,例如使网站不可用的 DDS(分布式拒绝服务)攻击。在 Box Hill Institute,一个承包商曾经在连接我们的 DHCP 服务器和网络其余部分的光缆上开了一个反孔,于是我们就回家休息了。
网络不可靠。这意味着任何网络程序都必须准备好应对失败。这导致了 Java 的 RMI 和大多数后来的框架的设计选择,应用程序设计允许每个网络调用可能失败。
谬误:延迟为零
等待时间是发送信号和得到回复之间的延迟。在单进程系统中,延迟可能取决于函数调用返回之前在函数调用中执行的计算量,但在网络上,延迟通常是由简单地必须遍历传输并由途中的各种节点(如路由器)处理而引起的。
ping
命令是显示延迟的好方法。从墨尔本到谷歌的澳大利亚服务器需要 20 毫秒。对百度中国服务器的一次 ping 大约需要 200 毫秒 1 。
相比之下,Williams(参见 http://www.eetimes.com/document.asp?doc_id=1200916
)讨论了 Linux 调度程序的延迟,得出平均延迟为 88 微秒。网络调用的延迟要大几千倍。
谬误:带宽是无限的
每个在下载发生时去沏杯茶或咖啡的人都知道这是一个谬论。我运行自己的网络服务器,在 ADSL2 上获得 800 Kbps 的上传速度。我很不幸,家里有 HFC,灾难性的澳大利亚国家宽带网络可能会将它升级到 1000 Kbps。三年后,到 2020 年。
与此同时,我使用本地无线连接,给我 75 Mbps 上下,它仍然不够快!
谬论:网络安全
科技公司大力推动将强大的加密技术用于所有网络通信,世界各国政府也同样大力推动“仅针对特定政府”的较弱系统或后门。这似乎同样适用于民主(我的意外拼写错误可能是准确的!)以及极权政府。
当然,除此之外,还有一般的“坏人”,窃取并出售数百万张信用卡号码和密码。
谬误:拓扑不会改变
确实如此。通常,这可能会影响延迟和带宽。但是路由或 IP 地址的硬编码越多,网络应用就越容易出故障。
谬误:只有一个管理员
那又怎样?一切正常的时候没问题。出了问题,问题就开始了——该怪谁,该由谁来解决?
多年来的一个主要研究课题是网格计算,它将计算任务分配给许多大学和研究机构来解决巨大的科学问题。这必须解决许多复杂的问题,因为不仅有多个管理员,而且还有不同的访问和安全问题、不同的维护计划等等。云计算的出现解决了许多这样的问题,减少了管理员和系统的数量,因此云计算比许多网格系统更有弹性。
谬论:运输成本为零
一旦我买了我的电脑,从 CPU 到显示器的传输成本是零(嗯,小电!).但是我们每个月都要向我们的 IP 提供商付费,因为他们必须建造服务器机房、铺设电缆等等。这只是一个必须考虑的成本。
谬误:网络是同质的
网络不是同质的,终端也不是,比如你和我的电脑、iPads、Android 设备和手机。更不用说物联网将无数互联设备带入画面。供应商不断尝试产品锁定,不断限制工作环境,试图简化他们的控制系统,这在一定程度上取得了成功。但当它们失败时,依赖同质性的系统也会失败。
结论
本章试图强调,与其他类型的计算相比,分布式计算有其独特的特点。忽视这些特征只会导致最终系统的失败。不断有人试图简化架构模型,最新的是“微服务”和“无服务器”计算,但最终复杂性仍然存在。
这些必须使用任何编程语言来解决,后续章节将考虑 Go 如何管理它们。
Footnotes 1
从我在澳大利亚墨尔本的位置,我看到平时间
PING google.com.au
(216.58.203.99)56(84)字节的数据。
来自syd09s15-in-f3.1e100.net (216.58.203.99): icmp_seq=1 ttl=50 time=27.1 ms
的 64 字节
来自syd09s15-in-f3.1e100.net (216.58.203.99): icmp_seq=2 ttl=50 time=19.7 ms
的 64 字节
二、Go 语言概述
不断有编程语言被发明出来。有些是高度专业化的,有些是相当通用的,而第三组是为了填补广泛的,但在某种程度上利基领域。Go 创建于 2007 年,于 2009 年公开发布。它旨在成为一种系统编程语言,为生产网络和多处理系统扩充(或取代)C++和其他静态编译语言。
Go 加入了一组现代语言,包括 Rust、Swift、Julia 和其他几种语言。Go 的独特之处在于简单的语法、多个程序单元的快速编译、一种基于“结构化”类型的 O/O 编程形式,当然还有从 C、C++和 Java 的大型程序中吸取的经验教训。
2017 年初的语言流行度列表,如 TIOBE(参见 http://www.tiobe.com/tiobe-index/
)
)将 Go 列为目前第 14 大最受欢迎的语言。PYPL(参见 http://pypl.github.io/PYPL.html
)排在第 19 位。这与 20 多年前的 Java、Python、C、C++、JavaScript 等语言齐名。
这本书假设你是一个有经验的程序员,在某种程度上有一些或广泛的 Go 知识。这可以通过介绍性文本,如 Caleb Doxsey (O'Reilly)的《Go 入门》或 Karl Seguin 的《Go 小百科全书》,或者通过阅读更正式的文档,如位于 https://golang.org/ref/spec
的《Go 编程语言规范》。
如果你是一个有经验的程序员,你可以跳过这一章。如果没有,这一章指出了本书中用到的一些 Go 知识,但是你应该去别的地方获取必要的背景知识。在 Go 网站的 http://golang.org
上有几个教程:
- 入门指南
- Go 编程语言教程
- 有效 Go
- GoLang 教程
最好从 Go 编程语言网站安装 Go。在写这篇文章的时候,Go 1.8 刚刚发布。本书中的大多数例子将使用 Go 1.6 运行,有一些指向 Go 1.8 的指针。你实际上不需要安装 Go 来测试程序:Go 有一个“操场”,可以从主页进入,用来运行代码。还有几个 REPL(读取-评估-打印循环)环境,但这些都是第三方的。
这本书主要使用了 Go 标准库中的库和包( https://golang.org/pkg/
)。Go 团队还构建了另一组包作为“子库”,它们通常不像标准库那样支持。这些偶尔会用到。需要使用go get
命令安装它们。这些包的名字包含一个“x”,比如golang.org/x/net/ipv4
。
类型
有预定义的布尔、数字和字符串类型。数字类型包括uint32
、int32
、float32
和其他大小的数字,以及字节(uint8
和符文。符文和字符串在第七章中被广泛讨论,因为国际化的问题在分布式程序中很重要。
还有更复杂的类型,将在下面讨论。
切片和阵列
数组是单一类型的元素序列。切片是基础数组的片段。Go 中处理切片往往更方便。可以静态创建数组:
var x [128]int
或者动态地作为指针:
xp := new([128]int)
切片可以与其底层阵列一起创建:
x := make([]int, 50, 100)
或者
x := new([100]int)[0:50]
这最后两个都是类型[]int
(如reflect.TypeOf(x)
所示)。
数组或切片的元素通过它们的索引来访问:
x[1]
索引从 0 到len(x)-1
。
可以通过使用数组或切片的较低(包括)和较高(不包括)索引来获取数组或切片的切片:
a := [5]int{-1, -2, -3, -4, -5}
s := a[1:4] // s is now [-2, -3, -4]
结构
结构类似于其他语言中的结构。在第四章中,我们考虑数据的序列化,并以下列结构为例:
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
复合结构可以声明如下:
person := Person{
Name: Name{Family: "Newmarch", Personal: "Jan"},
Email: []Email{Email{Kind: "home",
Address: "jan@newmarch.name"},
Email{Kind: "work",
Address: "j.newmarch@boxhill.edu.au"}}}
结构字段的可见性由字段名称的第一个字符的大小写控制。如果它是大写的,那么它在声明它的包之外是可见的;如果是小写,就不是。在前面的示例中,所有结构的所有字段都是可见的。
两颗北极指极星
指针的行为类似于其他语言中的指针。*
操作符解引用一个指针,而&
操作符接受一个变量的地址。Go 简化了指针的使用,这样大部分时间你就不用担心了。我们在本书中最多做的是检查指针值是否是nil
,这通常意味着一个错误,或者相反,如果一个可能的错误值不是nil
,如下一节所述。
功能
使用 Go 特有的符号来定义函数。在 Go 的声明语法博客中解释了为什么没有使用大家熟悉的 C 语法(或者其他语法)。我们让教科书来解释语法的细节。
每个 Go 程序必须有一个如下声明的main
函数:
func main() { ... }
我们将经常使用如下定义的函数checkError
:
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
它接受一个参数,没有返回值。它以小写字母开头,所以它对于声明它的包来说是局部的。
返回值的函数通常会返回一个错误状态和一个实际值,如第三章中的函数:
func readFully(conn net.Conn) ([]byte, error) { ... }
它将net.Conn
作为参数,并返回一个字节数组和一个错误状态(如果没有发生错误,则返回nil
)。
在本书中,没有比这更复杂的定义被使用。
地图
映射是一种类型的无序元素组,由另一种类型的键索引。我们在本书中不太使用映射,尽管有一处是在第十章中,其中一个 HTTP 请求的字段值可以通过使用字段名作为关键字的映射来访问。
方法
Go 不像 Java 这样的语言那样有类。然而,类型可以有与之关联的方法,这些方法的行为类似于更标准的 O/O 语言的方法。
我们将大量使用为各种网络类型定义的方法。这将从下一章的第一个程序开始。例如,类型IPMask
被定义为一个字节数组:
type IPMask []byte
在这种类型上定义了许多函数,例如:
func (m IPMask) Size() (ones, bits int)
类型为IPMask
的变量可以应用方法Size()
,如下所示:
var m IPMask
...
ones, bits := m.Size()
学习如何使用网络相关类型的方法是本书的主要目的。
在本书中,我们不会过多地定义我们自己的方法。这是因为为了说明 Go 库,我们不需要很多自己的复杂类型。一个典型的应用是修饰打印一个类似前面定义的Person
类型的类型:
func (p Person) String() string {
s := p.Name.Personal + " " + p.Name.Family
for _, v := range p.Email {
s += "\n" + v.Kind + ": " + v.Address
}
return s
}
在第十章中有更广泛的使用,其中使用了许多类型和这些类型上的方法。这是因为当我们构建更现实的系统时,我们确实需要自己的类型。
多线程
Go 有一个使用go
命令启动额外线程的简单机制。在本书中,这就是我们所需要的。这里不需要复杂的任务,比如同步多个线程。
包装
Go 程序是从链接包中构建的。任何代码块使用的包都必须通过代码文件头部的import
语句导入。我们自己的程序被声明在包main
中。
除了第十章之外,这本书里几乎所有的程序都在main
包里。
大多数包都是从标准库中导入的。有些是从golang.org/x/net/ipv4
等子库导入的。
类型变换
本书中我们唯一需要担心的是字符串到字节数组的转换,反之亦然。要将字符串转换为字节数组,您需要:
var b []byte
b = []byte("string")
要将整个数组/切片转换为字符串,请使用以下命令:
var s string
s = string(b[:])
声明
一个函数或方法将由一组语句组成。这些包括赋值、if
和switch
语句、for
和while
循环,以及其他一些语句。
除了语法之外,它们在本质上与其他编程语言具有相同的含义。几乎所有的语句类型都将在后面的章节中用到。
GOPATH(高路径)
有两种组织项目工作空间的方法:将每个项目放在一个共享的工作空间中,或者为每个项目准备一个单独的工作空间。我的偏好是第二种,而显然大多数 Go 程序员的偏好是第一种。
环境变量GOPATH
支持go
工具的任何一种方式。这可以设置为一个目录列表(Linux/UNIX 中的一个:
分隔列表,Windows 中的一个;
分隔列表,以及 Plan9 中的一个列表)。如果未设置,默认为用户主目录中的目录go
。
对于GOPATH
中的每个目录,会有三个子目录——src
、pkg
和bin
。目录src
通常包含每个包名的一个目录,在这个目录下是这个包的源文件。例如,在第十章中,我们有一个完整的 web 服务器,它使用我们定义的dictionary
和flashcards
包。src/flashcards
目录包含文件FlashCards.go
。
运行 Go 程序
Go 程序必须有一个定义包main
的文件。本书中的大多数程序都是在一个文件中定义的,比如第三章中的程序IP.go
。运行它的最简单方法是从包含该文件的目录中运行:
go run IP.go <IP address>
或者,您可以构建一个可执行文件,然后运行它:
go build IP.go
./IP <IP address>
需要标准软件包以外的软件包的程序将需要设置GOPATH
。例如,第十章中的程序要求(在 Linux 下):
export GOPATH=$PWD
go run Server.go <port>
标准库
Go 有一套广泛的标准库。例如,没有 C、Java 或 C++大,但是这些语言已经存在很长时间了。Go 包记录在 https://golang.org/pkg/
中,我们将在本书中广泛使用它们,特别是net
、crypto
和encoding
包。
此外,在同一个页面上还有一个包的子存储库组。这些不太稳定,但有时会有有用的包,我们偶尔会用到。
除此之外,还有大量用户贡献的包。在本书论述原理的正文中不会用到它们,但实际上你会发现它们中的许多非常有用。最后一章讨论了一些问题。
误差值
我们在上一章中讨论过,分布式编程和本地编程的一个主要区别是在执行过程中发生错误的可能性大大增加。局部函数调用可能因为简单的编程错误而失败,例如被零除;可能会发生更细微的错误,如内存不足错误,但它们可能发生的情况通常是可以预测的。
另一方面,几乎所有利用网络的功能都可能因为应用程序无法控制的原因而失败。因此,网络程序充满了错误检查。这是乏味的,但也是必要的。就像操作系统内核代码总是要进行错误检查一样——需要对错误进行管理。
在本书中,我们通常在客户端用适当的消息退出一个有错误的程序,对于服务器,尝试通过断开有问题的连接并继续运行来恢复。
像 C 这样的语言通常通过返回“非法”值(如负整数、空指针)或发出信号来发出错误信号。像 Java 这样的语言会引发异常,这会导致混乱的代码,而且通常会很慢。标准的 Go 函数在函数调用返回的额外参数中给出了一个错误。
例如,在下一章中,我们将讨论net
包中的函数:
func ResolveIPAddr(net, addr string) (*IPAddr, error)
管理这种情况的典型代码是:
addr, err := net.ResolveIPAddr("ip", name)
if err != nil {
...
}
结论
这本书假设你了解 Go 编程语言。本章只是强调了后面章节需要的部分。
三、套接字级编程
世界上有很多种网络。这些网络从非常古老的网络(如串行链路)到由铜线和光纤构成的广域网,再到各种类型的无线网络,既用于计算机,也用于电信设备(如电话)。这些网络显然在物理链路层有所不同,但在许多情况下,它们在 OSI 堆栈的更高层也有所不同。
这些年来,IP 和 TCP/UDP 的“互联网堆栈”已经趋同。例如,蓝牙定义了物理层和协议层,但是在物理层和协议层之上是 IP 栈,因此相同的互联网编程技术可以在许多蓝牙设备上使用。类似地,开发物联网(IoT)无线技术,如 LoRaWAN 和 6LoWPAN,也包括 IP 堆栈。
IP 提供 OSI 堆栈的第 3 层网络,而 TCP 和 UDP 处理第 4 层。即使在互联网世界中,这些也不是最终的结论:SCTP(流控制传输协议)来自电信世界,挑战 TCP 和 UDP,而在星际空间中提供互联网服务需要新的、正在开发的协议,如 DTN(延迟容忍网络)。然而,IP、TCP 和 UDP 作为主要的网络技术在现在以及至少在未来相当长的一段时间内占据主导地位。Go 完全支持这种风格的编程
本章展示了如何使用 Go 进行 TCP 和 UDP 编程,以及如何为其他协议使用原始套接字。
TCP/IP 协议栈
OSI 模型是使用一个委员会过程设计的,在这个过程中,标准被建立,然后被实施。OSI 标准的一些部分是模糊的,一些部分不容易实现,一些部分还没有实现。
TCP/IP 协议是通过长期运行的 DARPA 项目设计的。这是通过 RFC(征求意见)后的实施来实现的。TCP/IP 是主要的 UNIX 网络协议。TCP/IP 代表传输控制协议/互联网协议。
TCP/IP 协议栈比 OSI 协议栈短,如图 3-1 所示。
图 3-1。
TCP/IP stack versus the OSI
TCP 是面向连接的协议,而 UDP(用户数据报协议)是无连接的协议。
IP 数据报
IP 层提供了一个无连接和不可靠的传输系统。它认为每个数据报独立于其他数据报。数据报之间的任何关联必须由较高层提供。
IP 层提供包含其自身报头的校验和。报头包括源地址和目的地址。
IP 层处理通过互联网的路由。它还负责将较大的数据报分解成较小的数据报进行传输,并在另一端重新组装。
用户数据报协议(User Datagram Protocol)
UDP 也是无连接和不可靠的。它添加到 IP 中的是数据报内容和端口号的校验和。这些用来给出一个客户机-服务器模型,稍后你会看到。
三氯苯酚
TCP 提供逻辑,在 IP 之上给出可靠的面向连接的协议。它提供了两个进程可以用来通信的虚电路。它还使用端口号来识别主机上的服务。
互联网地址
为了使用服务,您必须能够找到它。互联网对计算机等设备使用地址方案,以便对它们进行定位。这种编址方案最初是在只有少数几台相连的计算机时设计的,使用 32 位无符号整数,非常宽松地允许多达 2³² 地址。这些就是所谓的 IPv4 地址。近年来,连接的(或者至少是可直接寻址的)设备的数量有超过这个数字的危险,并且正在逐步过渡到 IPv6。这种转变是不完整的,例如在谷歌( https://www.google.com/intl/en/ipv6/statistics.html
)的图表中显示的。遗憾的是,在我看来,很少有澳大利亚 IP 提供商支持 IPv6。
IPv4 地址
该地址是一个 32 位整数,给出了 IP 地址。该地址可解析为单个设备上的网络接口卡。地址通常写成十进制的四个字节,中间用点.
隔开,如127.0.0.1
或66.102.11.104
。
任何设备的 IP 地址通常由两部分组成:设备所在网络的地址,以及设备在该网络中的地址。从前,网络地址和内部地址之间的划分很简单,是基于 IP 地址中使用的字节。
- 在 A 类网络中,第一个字节标识网络,后三个字节标识设备。只有 128 个 A 类网络,由互联网领域的早期参与者拥有,如 IBM、通用电气公司和麻省理工学院 1 。
- B 类网络使用前两个字节标识网络,后两个字节标识子网内的设备。这允许一个子网上最多有 2¹⁶ (65,536)台设备。
- C 类网络使用前三个字节标识网络,最后一个字节标识网络中的设备。这允许多达 2⁸(实际上是 254,而不是 256,因为底部和顶部地址是保留的)设备。
如果你想要,比如说,一个网络上有 400 台计算机,这个方案就不太管用。254 太小,而 65,536 (-2)太大。用二进制算术术语来说,你要 512 (-2)左右。这可以通过使用 23 位网络地址和 9 位设备地址来实现。同样,如果您想要多达 1024 (-2)个设备,您可以使用 22 位网络地址和 10 位设备地址。
给定一个设备的 IP 地址,并且知道网络地址使用了多少位 N,给出了一个相对简单的过程来提取网络地址和该网络内的设备地址。形成一个“网络掩码”,它是一个 32 位二进制数,前 N 位全为 1,其余全为 0。例如,如果网络地址使用 16 位,掩码为11111111111111110000000000000000
。用二进制有点不方便,所以一般用十进制字节。16 位网络地址的网络掩码为255.255.0.0
,24 位网络地址的网络掩码为255.255.255.0
,23 位网络地址的网络掩码为255.255.254.0
,22 位网络地址的网络掩码为255.255.252.0
。
然后查找设备的网络,按位AND
使用网络掩码查找其 IP 地址,而子网内的设备地址则按位AND
使用 IP 地址查找掩码的补码。例如,IP 地址192.168.1.3
的二进制值是11000000101010000000000100000011
(使用 IP 地址子网掩码计算器)。如果使用 16 位网络掩码,网络为1100000010101000 0000000000000000
(或192.168.0.0
),而设备地址为0000000000000000 0000000100000011
(或0.0.1.3
)。
IPv6 地址
互联网的发展远远超出了最初的预期。最初慷慨的 32 位寻址方案即将耗尽。有一些令人不快的解决方法,如 NAT(网络地址转换)寻址,但最终我们将不得不切换到更宽的地址空间。IPv6 使用 128 位地址。用偶数字节来表示这样的地址变得很麻烦,所以使用十六进制数字,分成四个数字,并用冒号:
隔开。典型的地址可能是FE80:CD00:0000:0CDE:1257:0000:211E:729C
。
这些地址不好记!DNS 将变得更加重要。减少一些地址是有技巧的,比如前导零和重复数字。比如“localhost”就是0:0:0:0:0:0:0:1
,可以简称为::1
。
每个地址分为三部分:第一部分是用于互联网路由的网络地址,是地址的前 64 位。下一部分是 16 位网络掩码。这用于将网络划分为子网。它可以给出从一个子网(全 0)到 65,535 个子网(全 1)的任何值。最后一部分是器件组件,48 位。上述地址将是网络的FE80:CD00:0000:0CDE
、子网的1257
和设备的0000:211E:729C
。
IP 地址类型
最后,我们可以开始使用一些 Go 语言网络包。包net
定义了许多类型、功能和在 Go 网络编程中的使用方法。类型IP
被定义为一个字节数组:
type IP []byte
有几个函数可以操作类型为IP
的变量,但是在实践中你可能只使用其中的一部分。例如,函数ParseIP(String)
将采用带点的 IPv4 地址或冒号的 IPv6 地址,而 IP 方法String()
将返回一个字符串。请注意,您可能无法回到开始时的状态:字符串形式的0:0:0:0:0:0:0:1
是::1
。
说明这个过程的一个程序是IP.go
:
/* IP
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s ip-addr\n", os.Args[0])
os.Exit(1)
}
name := os.Args[1]
addr := net.ParseIP(name)
if addr == nil {
fmt.Println("Invalid address")
} else {
fmt.Println("The address is ", addr.String())
}
os.Exit(0)
}
例如,这可以如下运行:
go run IP.go 127.0.0.1
以下是回应:
The address is 127.0.0.1
或者它可以运行为:
go run IP.go 0:0:0:0:0:0:0:1
有了这样的回应:
The address is ::1
IPMask 类型
IP 地址通常分为网络地址、子网和设备部分。网络地址和子网构成了设备部分的前缀。掩码是一个全二进制的 IP 地址,以匹配前缀长度,后跟全零。
为了处理屏蔽操作,可以使用以下类型:
type IPMask []byte
创建网络掩码最简单的函数是使用 CIDR 表示法,即 1 后面跟 0,最大位数为:
func CIDRMask(ones, bits int) IPMask
然后,IP 地址的方法可以使用掩码来查找该 IP 地址的网络:
func (ip IP) Mask(mask IPMask) IP
下面这个叫做Mask.go
的程序就是一个例子:
/* Mask
*/
package main
import (
"fmt"
"net"
"os"
"strconv"
)
func main() {
if len(os.Args) != 4 {
fmt.Fprintf(os.Stderr, "Usage: %s dotted-ip-addr ones bits\n", os.Args[0])
os.Exit(1)
}
dotAddr := os.Args[1]
ones, _ := strconv.Atoi(os.Args[2])
bits, _ := strconv.Atoi(os.Args[3])
addr := net.ParseIP(dotAddr)
if addr == nil {
fmt.Println("Invalid address")
os.Exit(1)
}
mask := net.CIDRMask(ones, bits)
network := addr.Mask(mask)
fmt.Println("Address is ", addr.String(),
"\nMask length is ", bits,
"\nLeading ones count is ", ones,
"\nMask is (hex) ", mask.String(),
"\nNetwork is ", network.String())
os.Exit(0)
}
这可以编译成Mask
并运行如下:
Mask <ip-address> <ones> <zeroes>
也可以直接运行,如下所示:
go run Mask.go <ip-address> <ones> <zeroes>
对于/24
网络上的 IPv4 地址103.232.159.187
,我们得到如下结果:
go run Mask.go 103.232.159.187 24 32
Address is 103.232.159.187
Mask length is 32
Leading ones count is 24
Mask is (hex) ffffff00
Network is 103.232.159.0
对于一个 IPv6 地址fda3:97c:1eb:fff0:5444:903a:33f0:3a6b
,其中网络组件是fda3:97c:1eb
,子网是fff0
,设备部分是5444:903a:33f0:3a6b
,我们得到如下:
go run Mask.go fda3:97c:1eb:fff0:5444:903a:33f0:3a6b 52 128
Address is fda3:97c:1eb:fff0:5444:903a:33f0:3a6b
Mask length is 128
Leading ones count is 52
Mask is (hex) fffffffffffff0000000000000000000
Network is fda3:97c:1eb:f000::
IPv4 网络掩码通常以 4 字节点符号表示,如255.255.255.0
表示/24
网络。有一个函数可以从这样一个 4 字节的 IPv4 地址创建一个掩码:
func IPv4Mask(a, b, c, d byte) IPMask
此外,有一种 IP 方法可以返回 IPv4 的默认掩码:
func (ip IP) DefaultMask() IPMask
注意掩码的字符串形式是一个十六进制数,例如ffffff00
代表/24
掩码。
以下名为IPv4Mask.go
的程序说明了这些:
/* IPv4Mask
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s dotted-ip-addr\n", os.Args[0])
os.Exit(1)
}
dotAddr := os.Args[1]
addr := net.ParseIP(dotAddr)
if addr == nil {
fmt.Println("Invalid address")
os.Exit(1)
}
mask := addr.DefaultMask()
network := addr.Mask(mask)
ones, bits := mask.Size()
fmt.Println("Address is ", addr.String(),
"\nDefault mask length is ", bits,
"\nLeading ones count is ", ones,
"\nMask is (hex) ", mask.String(),
"\nNetwork is ", network.String())
os.Exit(0)
}
例如,运行以下命令:
go run Mask.go 192.168.1.3
在我的家庭网络中给出以下结果:
Address is 192.168.1.3
Default mask length is 32
Leading ones count is 24
Mask is (hex) ffffff00
Network is 192.168.1.0
IPAddr 类型
net 包中的许多其他函数和方法返回一个指向IPAddr
的指针。这只是一个包含 IP(和 IPv6 地址可能需要的区域)的结构。
type IPAddr {
IP IP
Zone string
}
这种类型的主要用途是在 IP 主机名上执行 DNS 查找。对于具有多个网络接口的不明确 IPv6 地址,可能需要该区域。
func ResolveIPAddr(net, addr string) (*IPAddr, error)
其中net
是ip
、ip4
或ip6
中的一个。这表现在名为ResolveIP.go
的节目中:
/* ResolveIP
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s hostname\n", os.Args[0])
fmt.Println("Usage: ", os.Args[0], "hostname")
os.Exit(1)
}
name := os.Args[1]
addr, err := net.ResolveIPAddr("ip", name)
if err != nil {
fmt.Println("Resolution error", err.Error())
os.Exit(1)
}
fmt.Println("Resolved address is ", addr.String())
os.Exit(0)
}
运行这个:
go run ResolveIP.go www.google.com
返回以下内容:
Resolved address is 172.217.25.164
如果网络类型的第一个参数ResolveIPAddr()
被给定为ip6
而不是ip
,我得到这样的结果:
Resolved address is 2404:6800:4006:801::2004
你可能会得到不同的结果,这取决于从你的地址来看谷歌似乎住在哪里。
主机查找
ResolveIPAddr
函数将对主机名执行 DNS 查找,并返回一个 IP 地址。它如何做到这一点取决于操作系统及其配置。例如,Linux/UNIX 系统可能使用/etc/resolv.conf
或/etc/hosts
,搜索顺序设置在/etc/nsswitch.conf
中。
一些主机可能有多个 IP 地址,通常来自多个网络接口卡。他们也可能有多个主机名,充当别名。LookupHost
函数将返回一片地址。
func LookupHost(name string) (cname string, addrs []string, err error)
其中一个地址将被标记为“标准”主机名。如果您想找到规范名称,请使用:
。
func LookupCNAME(name string) (cname string, err error)
.
对于 www.google.com
,它打印 IPv4 和 IPv6 地址:
172.217.25.164
2404:6800:4006:806::2004
这显示在以下名为LookupHost.go
的程序中:
/* LookupHost
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s hostname\n", os.Args[0])
os.Exit(1)
}
name := os.Args[1]
addrs, err := net.LookupHost(name)
if err != nil {
fmt.Println("Error: ", err.Error())
os.Exit(2)
}
for _, s := range addrs {
fmt.Println(s)
}
os.Exit(0)
}
注意,这个函数返回字符串,而不是 IP 地址值。运行时:
go run LookupHost.go
它打印出类似这样的内容:
172.217.25.132
2404:6800:4006:807::2004
服务
服务在主机上运行。它们通常具有很长的生命周期,被设计用来等待请求并对请求做出响应。服务有很多种,他们向客户提供服务的方式也有很多种。互联网世界中的许多服务都基于两种通信方法——TCP 和 UDP——尽管还有其他通信协议,如 SCTP,正准备接管。许多其他类型的服务,如点对点、远程过程调用、通信代理等,都是建立在 TCP 和 UDP 之上的。
港口
服务存在于主机上。我们可以使用 IP 地址来定位主机。但是在每台计算机上可能有许多服务,需要一种简单的方法来区分它们。TCP、UDP、SCTP 和其他协议使用的方法是使用端口号。这是一个介于 1 和 65,535 之间的无符号整数,每个服务将自己与这些端口号中的一个或多个相关联。
有许多“标准”端口。Telnet 通常使用 TCP 协议的端口 23。DNS 通过 TCP 或 UDP 使用端口 53。FTP 使用端口 21 和 20,一个用于命令,另一个用于数据传输。HTTP 一般使用端口 80,但也经常使用端口 8000、8080、8088,都是用 TCP。X Window 系统通常使用 TCP 和 UDP 上的 6000-6007 端口。
在 UNIX 系统上,常用端口列在文件/etc/services
中。Go 具有在所有系统上查找端口的功能:
func LookupPort(network, service string) (port int, err error)
网络参数是一个字符串,如"tcp"
或"udp"
,而服务是一个字符串,如"telnet"
或"domain"
(用于 DNS)。
使用这个的程序是LookupPort.go
:
/* LookupPort
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintf(os.Stderr,
"Usage: %s network-type service\n",
os.Args[0])
os.Exit(1)
}
networkType := os.Args[1]
service := os.Args[2]
port, err := net.LookupPort(networkType, service)
if err != nil {
fmt.Println("Error: ", err.Error())
os.Exit(2)
}
fmt.Println("Service port ", port)
os.Exit(0)
}
例如,运行LookupPort tcp telnet
打印服务端口 23。
TCPAddr 类型
TCPAddr
类型是包含 IP、端口和区域的结构。需要该区域来区分可能不明确的 IPv6 链路本地地址和站点本地地址,因为不同的网络接口卡(NIC)可能具有相同的 IPv6 地址。
type TCPAddr struct {
IP IP
Port int
Zone string
}
创建一个TCPAddr
的函数是ResolveTCPAddr
:
func ResolveTCPAddr(net, addr string) (*TCPAddr, error)
其中net
是tcp
、tcp4
或tcp6
中的一个,addr
是由主机名或 IP 地址组成的字符串,后跟:
后的端口号,如www.google.com:80
或127.0.0.1:22
。如果地址是 IPv6 地址,其中已经有冒号,那么主机部分必须用方括号括起来,例如[::1]:23
。另一种特殊情况通常用于服务器,其中主机地址为零,因此 TCP 地址实际上只是端口名,如 HTTP 服务器的:80
所示。
TCP 套接字
当您知道如何通过网络和端口 id 访问服务时,接下来该怎么办呢?如果您是一个客户端,您需要一个 API 来允许您连接到一个服务,然后向该服务发送消息并从该服务读取回复。
如果您是服务器,您需要能够绑定到一个端口并监听它。当消息进来时,您需要能够阅读它并写回给客户端。
net.
TCPConn
是 Go 类型,允许客户端和服务器之间的全双工通信。感兴趣的两种主要方法如下:
func (c *TCPConn) Write(b []byte) (n int, err error)
func (c *TCPConn) Read(b []byte) (n int, err error)
客户端和服务器都使用TCPConn
来读取和写入消息。
注意,TCPConn
实现了io.Reader
和io.Writer
接口,因此任何使用读取器或写入器的方法都可以应用于TCPConn
。
TCP 客户端
一旦客户端为服务建立了 TCP 地址,它就“拨号”该服务。如果成功,拨号盘返回一个TCPConn
用于通信。客户端和服务器就此交换消息。通常,客户端使用TCPConn
向服务器写入请求,并从TCPConn
读取响应。这种情况一直持续到任一端(或两端)关闭连接。客户端使用以下函数建立 TCP 连接:
func DialTCP(net string, laddr, raddr *TCPAddr) (c *TCPConn, err error)
其中laddr
是本地地址,通常设置为nil
,raddr
是服务的远程地址。net
字符串是"tcp4"
、"tcp6"
或"tcp"
中的一种,这取决于您是想要 TCPv4 连接、TCPv6 连接还是不在乎。
一个简单的例子可以由客户机提供给 web (HTTP)服务器。我们将在后面的章节中更详细地讨论 HTTP 客户端和服务器,所以现在我们保持简单。
客户端可能发送的消息之一是HEAD
消息。这将向服务器查询有关该服务器和该服务器上的文档的信息。服务器返回信息,但不返回文档本身。发送查询 HTTP 服务器的请求可能如下:
"HEAD / HTTP/1.0\r\n\r\n"
这要求提供关于根文档和服务器的信息。典型的回答可能是:
HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Tue, 28 Feb 2017 10:33:01 GMT
Content-Type: text/html
Content-Length: 2152
Last-Modified: Mon, 13 Oct 2008 02:38:03 GMT
Connection: close
ETag: "48f2b48b-868"
Accept-Ranges: bytes
我们首先给程序(GetHeadInfo.go
)建立一个 TCP 地址的连接,发送请求字符串,然后读取并打印响应。编译后,可以按如下方式调用它:
GetHeadInfo www.google.com:80
程序是GetHeadInfo.go
:
/* GetHeadInfo
*/
package main
import (
"fmt"
"io/ioutil"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])
os.Exit(1)
}
service := os.Args[1]
tcpAddr, err := net.ResolveTCPAddr
("tcp4", service)
checkError(err)
conn, err := net.DialTCP("tcp", nil, tcpAddr)
checkError(err)
_, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
checkError(err)
result, err := ioutil.ReadAll(conn)
checkError(err)
fmt.Println(string(result))
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
要注意的第一点是正在进行的几乎过量的错误检查。这对于网络程序来说是正常的:失败的机会远远大于独立程序。客户端、服务器或中间的任何路由器和交换机上的硬件可能会出现故障;通信可能被防火墙阻止;网络负载可能会导致超时;当客户端与服务器通信时,服务器可能会崩溃。将执行以下检查:
- 指定的地址中可能有语法错误。
- 连接到远程服务的尝试可能会失败。例如,请求的服务可能没有运行,或者可能没有这样的主机连接到网络。
- 虽然已经建立了连接,但是如果连接突然中断或者网络超时,对服务的写入可能会失败。
- 类似地,读取可能会失败。
从服务器读取需要注释。在这种情况下,我们基本上从服务器读取一个响应。这将由连接上的文件结尾终止。但是,它可能由几个 TCP 包组成,所以我们需要一直读取,直到文件结束。io/ioutil
函数ReadAll
将处理这些问题并返回完整的响应。(感谢golang-nuts
邮件列表上的罗杰·佩佩。)
这涉及到一些语言问题。首先,大多数函数返回一个 dual 值,可能的错误作为第二个值。如果没有错误发生,那么这将是nil
。在 C 语言中,如果可能的话,通过返回特殊值,如NULL
、或-1、或零,可以获得相同的行为。在 Java 中,同样的错误检查是通过抛出和捕获异常来管理的,这会使代码看起来非常混乱。
日间服务员
我们可以建立的最简单的服务是日间服务。这是 RFC 867 定义的标准互联网服务,TCP 和 UDP 的默认端口都是 13。不幸的是,随着(合理的)对安全的偏执的增加,几乎没有任何网站再运行日间服务器了。没关系;我们可以自己造。(对于那些感兴趣的人,如果你在你的系统上安装了inetd
,你通常会得到一个日间服务器。)
服务器在一个端口上注册并监听该端口。然后它阻塞一个“接受”操作,等待客户端连接。当客户端连接时,accept 调用返回,并带有一个连接对象。日间服务非常简单,只需将当前时间写入客户端,关闭连接,然后继续等待下一个客户端。
相关电话如下:
func ListenTCP(net string, laddr *TCPAddr) (l *TCPListener, err error)
func (l *TCPListener) Accept() (c Conn, err error)
参数net
可以设置为字符串"tcp"
、"tcp4"
或"tcp6"
中的一个。如果要监听所有网络接口,IP 地址应设置为零;如果只想监听某个网络接口,则应设置为该接口的 IP 地址。如果端口设置为零,那么操作系统将为您选择一个端口。否则,你可以自己选择。请注意,在 UNIX 系统上,您不能监听低于 1024 的端口,除非您是系统管理员、root 用户,而低于 128 的端口是由 IETF 标准化的。示例程序选择端口 1200 没有任何特殊原因。TCP 地址给定为:1200
—所有接口,端口 1200。
程序是DaytimeServer.go
:
/* DaytimeServer
*/
package main
import (
"fmt"
"net"
"os"
"time"
)
func main() {
service := ":1200"
tcpAddr, err := net.ResolveTCPAddr("tcp", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
daytime := time.Now().String()
conn.Write([]byte(daytime)) // don't care about return value
conn.Close() // we're finished with this client
}
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
如果你运行这个服务器,它将只是在那里等待,不做太多。当客户端连接到它时,它将通过向它发送日间字符串来响应,然后返回等待下一个客户端。
请注意,与客户端相比,服务器中的错误处理发生了变化。服务器应该永远运行,这样,如果客户机出现任何错误,服务器就会忽略该客户机并继续运行。否则,客户端可能会试图破坏与服务器的连接并使其崩溃!
我们还没有建立客户。这很容易,只需更改以前的客户端以省略初始写入。或者,只需打开到主机 telnet 连接:
telnet localhost 1200
这将产生如下输出:
$telnet localhost 1200
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
2017-01-02 20:13:21.934698384 +1100 AEDTConnection closed by foreign host.
其中2017-01-02 20:13:21.934698384 +1100 AEDT
是服务器的输出。
多线程服务器
echo
是另一个简单的 IETF 服务。SimpleEchoServer.go
程序只是读取客户端输入的内容并将其发送回来:
/* SimpleEchoServer
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
service := ":1201"
tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
handleClient(conn)
conn.Close() // we're finished
}
}
func handleClient(conn net.Conn) {
var buf [512]byte
for {
n, err := conn.Read(buf[0:])
if err != nil {
return
}
fmt.Println(string(buf[0:]))
_, err2 := conn.Write(buf[0:n])
if err2 != nil {
return
}
}
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
虽然它可以工作,但是这个服务器有一个重要的问题:它是单线程的。当一个客户端打开了一个连接时,没有其他客户端可以连接。其他客户端被阻止,可能会超时。幸运的是,这很容易通过使客户端处理程序成为一个go
例程来解决。我们还将关闭连接移到了处理程序中,因为它现在属于那里。这个程序叫做ThreadedEchoServer.
go
:
/* ThreadedEchoServer
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
service := ":1201"
tcpAddr, err := net.ResolveTCPAddr("tcp", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
// run as a goroutine
go handleClient(conn)
}
}
func handleClient(conn net.Conn) {
// close connection on exit
defer conn.Close()
var buf [512]byte
for {
// read up to 512 bytes
n, err := conn.Read(buf[0:])
if err != nil {
return
}
fmt.Println(string(buf[0:]))
// write the n bytes read
_, err2 := conn.Write(buf[0:n])
if err2 != nil {
return
}
}
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
控制 TCP 连接
超时
如果客户端响应不够快,即没有及时向服务器写入请求,服务器可能希望客户端超时。这应该是一段很长的时间(几分钟),因为用户可能会慢慢来。相反,客户端可能希望服务器超时(在短得多的时间后)。两者都是这样做的:
func (c *IPConn) SetDeadline(t time.Time) error
这是在套接字上的任何读取或写入之前完成的。
活着
客户端可能希望保持与服务器的连接,即使它没有要发送的内容。它可以使用这个:
func (c *TCPConn) SetKeepAlive(keepalive bool) error
还有其他几种连接控制方法,记录在net
包中。
UDP 数据报
在无连接协议中,每条消息都包含有关其来源和目的地的信息。没有使用长期套接字建立的“会话”。UDP 客户端和服务器使用数据报,数据报是包含源和目的信息的单独消息。除非客户端或服务器维护状态,否则这些消息不会维护状态。不保证消息会到达,或者可能会无序到达。
对于客户端来说,最常见的情况是发送一条消息并希望收到回复。对于服务器来说,最常见的情况是接收一条消息,然后向客户端发送一个或多个回复。但是,在对等的情况下,服务器可能只是将消息转发给其他对等方。
Go 的 TCP 和 UDP 处理之间的主要区别是如何处理来自多个客户端的数据包,没有 TCP 会话的缓冲来管理事情。需要的主要调用如下:
func ResolveUDPAddr(net, addr string) (*UDPAddr, error)
func DialUDP(net string, laddr, raddr *UDPAddr) (c *UDPConn, err error)
func ListenUDP(net string, laddr *UDPAddr) (c *UDPConn, err error)
func (c *UDPConn) ReadFromUDP(b []byte) (n int, addr *UDPAddr, err error
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (n int, err error)
UDP 时间服务的客户端不需要做很多改变;将UDPDaytimeClient.go
程序中的...TCP...
调用改为...UDP...
调用即可:
/* UDPDaytimeClient
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
os.Exit(1)
}
service := os.Args[1]
udpAddr, err := net.ResolveUDPAddr("udp", service)
checkError(err)
conn, err := net.DialUDP("udp", nil, udpAddr)
checkError(err)
_, err = conn.Write([]byte("anything"))
checkError(err)
var buf [512]byte
n, err := conn.Read(buf[0:])
checkError(err)
fmt.Println(string(buf[0:n]))
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error ", err.Error())
os.Exit(1)
}
}
而服务器必须在程序UDPDaytimeServer.go
中再做一些改变:
/* UDPDaytimeServer
*/
package main
import (
"fmt"
"net"
"os"
"time"
)
func main() {
service := ":1200"
udpAddr, err := net.ResolveUDPAddr("udp", service)
checkError(err)
conn, err := net.ListenUDP("udp", udpAddr)
checkError(err)
for {
handleClient(conn)
}
}
func handleClient(conn *net.UDPConn) {
var buf [512]byte
_, addr, err := conn.ReadFromUDP(buf[0:])
if err != nil {
return
}
daytime := time.Now().String()
conn.WriteToUDP([]byte(daytime), addr)
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error ", err.Error())
os.Exit(1)
}
}
服务器按如下方式运行:
go run UDPDaytimeServer.go
同一主机上的客户端运行如下:
go run UDPDaytimeClient.go localhost:1200
输出将是这样的:
2017-03-01 21:37:03.988603994 +1100 AEDT
服务器监听多个套接字
一个服务器可能试图监听多个客户机,不只是在一个端口上,而是在许多端口上。在这种情况下,它必须在端口之间使用某种轮询机制。
在 C 中,select()
调用让内核完成这项工作。该调用需要许多文件描述符。该过程被暂停。当其中一个上的 I/O 就绪时,唤醒完成,该过程可以继续。这比忙轮询便宜。在 Go 中,您可以通过为每个端口使用不同的go
例程来完成相同的任务。当较低级别的select()
发现 I/O 已经为线程准备好时,线程将变得可运行。
Conn、PacketConn 和侦听器类型
到目前为止,我们已经区分了 TCP 的 API 和 UDP 的 API,例如使用分别返回TCPConn
和UDPConn
的DialTCP
和DialUDP
。Conn
类型是一个接口,TCPConn
和UDPConn
都实现了这个接口。在很大程度上,您可以处理这个接口,而不是这两种类型。
您可以使用一个单独的功能来代替 TCP 和 UDP 的单独拨号功能:
func Dial(net, laddr, raddr string) (c Conn, err error)
net
可以是tcp
、tcp4
(仅 IPv4)、tcp6
(仅 IPv6)、udp
、udp4
(仅 IPv4)、udp6
(仅 IPv6)、ip
、ip4
(仅 IPv4)和ip6
(仅 IPv6)中的任何一个,以及几个特定于 UNIX 的,例如用于 UNIX 套接字的unix
。它将返回一个适当的Conn
接口实现。注意,这个函数采用一个字符串而不是地址作为raddr
参数,这样使用它的程序可以避免首先计算出地址类型。
使用此功能可以对程序进行微小的更改。例如,从网页获取HEAD
信息的早期程序可以重写为IPGetHeadInfo.
go
:
/* IPGetHeadInfo
*/
package main
import (
"bytes"
"fmt"
"io"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
os.Exit(1)
}
service := os.Args[1]
conn, err := net.Dial("tcp", service)
checkError(err)
_, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
checkError(err)
result, err := readFully(conn)
checkError(err)
fmt.Println(string(result))
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
func readFully(conn net.Conn) ([]byte, error) {
defer conn.Close()
result := bytes.NewBuffer(nil)
var buf [512]byte
for {
n, err := conn.Read(buf[0:])
result.Write(buf[0:n])
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
}
return result.Bytes(), nil
}
这可以在我自己的机器上运行,如下所示:
go run IPGetHeadInfo.go localhost:80
它打印了关于在端口 80 上运行的服务器的以下信息:
HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Wed, 01 Mar 2017 10:42:39 GMT
Content-Type: text/html
Content-Length: 2152
Last-Modified: Mon, 13 Oct 2008 02:38:03 GMT
Connection: close
ETag: "48f2b48b-868"
Accept-Ranges: bytes
使用此函数可以类似地简化服务器的编写:
func Listen(net, laddr string) (l Listener, err error)
这将返回一个实现了Listener
接口的对象。这个接口有一个方法:
func (l Listener) Accept() (c Conn, err error)
这将允许构建服务器。利用这一点,前面给出的多线程Echo
服务器变成了ThreadedIPEchoServer.
go
:
/* ThreadedIPEchoServer
*/
package main
import (
"fmt"
"net"
"os"
)
func main() {
service := ":1200"
listener, err := net.Listen("tcp", service)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
go handleClient(conn)
}
}
func handleClient(conn net.Conn) {
defer conn.Close()
var buf [512]byte
for {
n, err := conn.Read(buf[0:])
if err != nil {
return
}
_, err2 := conn.Write(buf[0:n])
if err2 != nil {
return
}
}
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
如果你想写一个 UDP 服务器,有一个名为PacketConn
的接口和一个返回实现的方法:
func ListenPacket(net, laddr string) (c PacketConn, err error)
这个接口有处理包读写的主要方法ReadFrom
和WriteTo
。
Go net
包推荐使用这些接口类型,而不是具体的接口类型。但是通过使用它们,你会失去特定的方法,比如TCPConn
的SetKeepAlive
和UDPConn
的SetReadBuffer
,除非你进行类型转换。这是你的选择。
原始套接字和 IPConn 类型
本节涵盖了大多数程序员不太可能需要的高级材料。它处理原始套接字,允许程序员构建自己的 IP 协议,或者使用 TCP 或 UDP 以外的协议。
TCP 和 UDP 并不是唯一建立在 IP 层之上的协议。网站 http://www.iana.org/assignments/protocol-numbers
列出了其中的大约 140 个(这个列表通常可以在 UNIX 系统的文件/etc/protocols
中找到)。在这个列表中,TCP 和 UDP 分别只排在第 6 和第 17 位。
Go 允许您构建所谓的原始套接字,使您能够使用这些其他协议之一进行通信,甚至构建自己的协议。但是它提供了最低限度的支持:它将连接主机,并在主机之间读写数据包。在下一章,我们将着眼于在 TCP 之上设计和实现你自己的协议;本节考虑的是同一类型的问题,但是是在 IP 层。
为了简单起见,我们使用最简单的例子:如何向主机发送 IPv4 ping 消息。Ping 使用 ICMP 协议中的echo
命令。这是一个面向字节的协议,客户端向另一台主机发送字节流,主机进行回复。ICMP 数据包有效负载的格式如下:
- 第一个字节是 8,代表回应消息。
- 第二个字节是零。
- 第三和第四个字节是整个消息的校验和。
- 第五和第六个字节是一个任意的标识符。
- 第七个和第八个字节是一个任意的序列号。
- 数据包的其余部分是用户数据。
可以使用Conn.Write
方法发送数据包,该方法用这个有效载荷准备数据包。收到的回复包括 IPv4 报头,占用 20 个字节。(例如,参见维基百科关于因特网控制消息协议 ICMP 的文章。)
下面这个名为Ping.
go
的程序将准备一个 IP 连接,向一个主机发送 ping 请求,并得到回复。您可能需要 root 访问权限才能成功运行它:
/* Ping
*/
package main
import (
"bytes"
"fmt"
"io"
"net"
"os"
)
// change this to my own IP address or set to 0.0.0.0
const myIPAddress = "192.168.1.2"
const ipv4HeaderSize = 20
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host")
os.Exit(1)
}
localAddr, err := net.ResolveIPAddr("ip4", myIPAddress)
if err != nil {
fmt.Println("Resolution error", err.Error())
os.Exit(1)
}
remoteAddr, err := net.ResolveIPAddr("ip4", os.Args[1])
if err != nil {
fmt.Println("Resolution error", err.Error())
os.Exit(1)
}
conn, err := net.DialIP("ip4:icmp", localAddr, remoteAddr)
checkError(err)
var msg [512]byte
msg[0] = 8 // echo
msg[1] = 0 // code 0
msg[2] = 0 // checksum, fix later
msg[3] = 0 // checksum, fix later
msg[4] = 0 // identifier[0]
msg[5] = 13 // identifier[1] (arbitrary)
msg[6] = 0 // sequence[0]
msg[7] = 37 // sequence[1] (arbitrary)
len := 8
// now fix checksum bytes
check := checkSum(msg[0:len])
msg[2] = byte(check >> 8)
msg[3] = byte(check & 255)
// send the message
_, err = conn.Write(msg[0:len])
checkError(err)
fmt.Print("Message sent: ")
for n := 0; n < 8; n++ {
fmt.Print(" ", msg[n])
}
fmt.Println()
// receive a reply
size, err2 := conn.Read(msg[0:])
checkError(err2)
fmt.Print("Message received:")
for n := ipv4HeaderSize; n < size; n++ {
fmt.Print(" ", msg[n])
}
fmt.Println()
os.Exit(0)
}
func checkSum(msg []byte) uint16 {
sum := 0
// assume even for now
for n := 0; n < len(msg); n += 2 {
sum += int(msg[n])*256 + int(msg[n+1])
}
sum = (sum >> 16) + (sum & 0xffff)
sum += (sum >> 16)
var answer uint16 = uint16(^sum)
return answer
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
func readFully(conn net.Conn) ([]byte, error) {
defer conn.Close()
result := bytes.NewBuffer(nil)
var buf [512]byte
for {
n, err := conn.Read(buf[0:])
result.Write(buf[0:n])
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
}
return result.Bytes(), nil
}
它使用目标地址作为参数来运行。接收到的消息与发送的消息的区别仅在于第一个类型字节以及第三和第四个校验和字节,如下所示:
Message sent: 8 0 247 205 0 13 0 37
Message received: 0 0 255 205 0 13 0 37
结论
本章考虑了 IP、TCP 和 UDP 级别的编程。如果您想要实现自己的协议或者为现有协议构建客户端或服务器,这通常是必要的。
Footnotes 1
最近,麻省理工学院将他们的 A 类网络归还给了游泳池。 http://www.iana.org/assignments/ipv4-address-space/ipv4-address-space.xml
。
四、数据序列化
客户端和服务器需要通过消息交换信息。TCP 和 UDP 提供了实现这一点的传输机制。这两个过程还需要有一个合适的协议,这样消息交换才能有意义地进行。
消息作为字节序列在网络上发送,除了作为线性字节流之外,它没有任何结构。我们将在下一章讨论消息的各种可能性以及定义它们的协议。在这一章中,我们集中讨论消息的一个组成部分——被传输的数据。
程序通常会构建复杂的数据结构来保存当前的程序状态。在与远程客户端或服务进行对话时,程序将试图通过网络传输这种数据结构——也就是说,在应用程序自己的地址空间之外。
结构数据
编程语言使用结构化数据,如下所示:
- 记录/结构
- 变体记录
- 数组:固定大小或可变大小
- 字符串:固定大小或可变大小
- 表格:记录数组
- 非线性结构,例如
- 循环链表
- 二叉树
- 引用其他对象的对象
IP、TCP 或 UDP 数据包都不知道这些数据类型的含义。它们所能包含的只是一个字节序列。因此,应用程序必须将任何数据序列化为字节流才能写入数据,并在读取数据时将字节流反序列化为合适的数据结构。这两个操作称为编组和解组,分别为 1 。
例如,考虑发送以下两列可变长度字符串的可变长度表:
| 图像读取器设备(figure-reader electronic device 的缩写) | 节目编排者 | | 黎平 | 分析家 | | 一定的 | 经理 |这可以通过各种方式来实现。例如,假设已知数据将是两列表中未知数量的行。那么编组的表单可以是:
3 // 3 rows, 2 columns assumed
4 fred // 4 char string,col 1
10 programmer // 10 char string,col 2
6 liping // 6 char string, col 1
7 analyst // 7 char string, col 2
8 sureerat // 8 char string, col 1
7 manager // 7 char string, col 2
可变长度的东西也可以用一个“非法”的值来表示它们的长度,比如用\0
来表示字符串。前面的表也可以用行数来写,但是每个字符串都以\0
结束(换行符是为了可读性,不是序列化的一部分):
3
fred\0
programmer\0
liping\0
analyst\0
sureerat\0
manager\0
或者,可以知道数据是一个三行的固定表,包含两列长度分别为 8 和 10 的字符串。那么表的序列化可以是(换行符也不是序列化的一部分):
fred\0\0\0\0
programmer
liping\0\0
analyst\0\0\0
sureerat
manager\0\0\0
这些格式都可以,但是消息交换协议必须指定使用哪种格式,或者允许在运行时确定使用哪种格式。
双方协议
上一节概述了数据序列化的问题。实际上,细节可能要复杂得多。例如,考虑第一种可能性,将一个表编组到流中:
3
4 fred
10 programmer
6 liping
7 analyst
8 sureerat
7 manager
许多问题出现了。例如,表可能有多少行——也就是说,我们需要多大的整数来描述行大小?如果它小于或等于 255,那么单个字节就可以了,但是如果它大于 255,那么就需要一个短整型、整型或长整型。每个字符串的长度也会出现类似的问题。对于字符本身,它们属于哪个字符集?7 位 ASCII 码?16 位 Unicode?字符集的问题将在后面的章节中详细讨论。
这种序列化是不透明的或隐式的。如果数据是用这种格式编排的,那么在序列化的数据中就没有说明应该如何对其进行解组。为了正确地解组数据,解组方必须确切地知道数据是如何序列化的。例如,如果行数被封送为 8 位整数,但被解组为 16 位整数,那么当接收方试图将 3 和 4 解组为 16 位整数时,将会出现不正确的结果,并且接收程序稍后几乎肯定会失败。
早期一个众所周知的序列化方法是 Sun 的 RPC 使用的 XDR(外部数据表示),后来被称为 ONC(开放网络计算)。RFC 1832 定义了 XDR,了解这一规格有多精确很有意义。尽管如此,XDR 本质上是类型不安全的,因为序列化数据不包含类型信息。它在 ONC 中使用的正确性主要是由编译器为编组和解组生成代码来保证的。
Go 不包含对不透明序列化数据的编组和解组的显式支持。Go 中的 RPC 包不使用 XDR,而是使用 Gob 序列化,这将在本章后面描述。
自描述数据
自描述数据携带数据的类型信息。例如,以前的数据可能会被编码如下:
table
uint8 3
uint 2
string
uint8 4
[]byte fred
string
uint8 10
[]byte programmer
string
uint8 6
[]byte liping
string
uint8 7
[]byte analyst
string
uint8 8
[]byte sureerat
string
uint8 7
[]byte manager
当然,真正的编码通常不会像示例中那样麻烦和冗长:小整数将被用作类型标记,并且整个数据将被打包在尽可能小的字节数组中。(不过,XML 提供了一个反例。)但是,原理是封送拆收器将在序列化数据中生成这样的类型信息。解组器将知道类型生成规则,并将能够使用它们来重建正确的数据结构。
ASN.1
抽象语法符号一(ASN.1)最初是在 1984 年为电信行业设计的。ASN.1 是一个复杂的标准,它的一个子集在包asn1
中被 Go 支持。它从复杂的数据结构中构建自描述的序列化数据。它在当前网络系统中的主要用途是作为 X.509 证书的编码,在认证系统中大量使用。Go 中的支持基于读写 X.509 证书所需的内容。
两个函数允许我们编组和解组数据:
func Marshal(val interface{}) ([]byte, error)
func Unmarshal(val interface{}, b []byte) (rest []byte, err error)
第一个函数将数据值封送到一个序列化的字节数组中,第二个函数将它解组。但是,类型接口的第一个参数值得进一步研究。给定一个类型的变量,我们可以通过传递它的值来封送它。为了解组它,我们需要一个命名类型的变量来匹配序列化的数据。具体细节将在后面讨论。但是我们还需要确保变量被分配给该类型的内存,这样实际上就有现有的内存供解组写入值。
我们在ASN1.go
中用一个简单的例子来说明一个整数的编组和解组。我们可以将一个整数值传递给marshal
以返回一个字节数组,并将该数组解组为一个整数变量,如下所示:
/* ASN1
*/
package main
import (
"encoding/asn1"
"fmt"
"os"
)
func main() {
val := 13
fmt.Println("Before marshal/unmarshal: ", val)
mdata, err := asn1.Marshal(val)
checkError(err)
var n int
_, err1 := asn1.Unmarshal(mdata, &n)
checkError(err1)
fmt.Println("After marshal/unmarshal: ", n)
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
该程序运行如下:
go run ASN1.go
解组值,当然是13
。
一旦我们超越了这一点,事情就变得更难了。为了管理更复杂的数据类型,我们必须更仔细地研究 ASN.1 支持的数据结构,以及 ASN.1 在 Go 中是如何支持的。
任何序列化方法都能够处理某些数据类型,而不能处理其他一些数据类型。因此,为了确定任何序列化(如 ASN.1)的适用性,您必须查看可能支持的数据类型和您希望在应用程序中使用的数据类型。以下 ASN.1 类型摘自 http://www.obj-sys.com/asn1tutorial/node4.html
。
简单类型如下:
BOOLEAN
:双态变量值INTEGER
:模型整型变量值BIT STRING
:模拟任意长度的二进制数据OCTET STRING
:模拟长度为 8 的倍数的二进制数据NULL
:表示序列元素的有效缺失OBJECT IDENTIFIER
:命名信息对象REAL
:模拟实变量值ENUMERATED
:用至少三种状态模拟变量值CHARACTER STRING
:模拟指定字符集中的字符串值
字符串可以来自某些字符集:
NumericString
: 0,1,2,3,4,5,6,7,8,9,空格PrintableString
:大小写字母、数字、空格、撇号、左/右括号、加号、逗号、连字符、句号、实线、冒号、等号和问号TeletexString (T61String)
:CCITT ?? 中的 Teletex 字符集,空格,删除VideotexString
:CCITT 的 T.100 和 T.101 中的可视图文字符集,空格,删除VisibleString
(输入字符串):打印国际 ASCII 字符集,空格IA5String
:国际字母表 5(国际 ASCII)GraphicString 25
:所有注册的 G 组,空格GraphicString
- 除此之外,还有其他字符串类型,特别是
UTF8String
最后,还有结构化类型:
SEQUENCE
:对不同类型变量的有序集合建模SEQUENCE OF
:对同一类型变量的有序集合进行建模- 对不同类型的变量的无序集合建模
SET OF
:对同一类型变量的无序集合建模CHOICE
:指定不同类型的集合,从中选择一种类型SELECTION
:从指定的CHOICE
类型中选择一个组件类型ANY
:允许应用程序指定类型
Note
ANY
是不推荐使用的 ASN.1 结构化类型。已经换成 X.680 开放式了。
并不是所有这些都是 Go 支持的。Go 并不支持所有可能的值。Go asn1
包文档中给出的规则如下:
- ASN.1
INTEGER
可以写入到int
或int64
中。如果编码值不符合 Go 类型,Unmarshal
返回一个解析错误。 - ASN.1
BIT STRING
可以写入到BitString
中。 - ASN.1
OCTET STRING
可以写入到[]byte
中。 - ASN.1
OBJECT IDENTIFIER
可以写入到ObjectIdentifier
中。 - ASN.1
ENUMERATED
可以写入到Enumerated
中。 - ASN.1
UTCTIME
或GENERALIZEDTIME
可以被写入*time.Time
。 - ASN.1
PrintableString
或IA5String
可以写入字符串。 - 任何上述 ASN.1 值都可以写入一个
interface{}
。存储在接口中的值具有相应的 Go 类型。对于整数,该类型是int64
。 - 如果可以将
x
写入片的元素类型,则可以将 ASN.1SEQUENCE OF x
或SET OF x
写入片。 - 如果序列中的每个元素都可以写入结构中的相应元素,则 ASN.1
SEQUENCE
或SET
可以写入 Go 结构。
Go 对 ASN.1 进行了真正的限制,比如 ASN.1 允许任意大小的整数,而 Go 实现最多只允许有符号的 64 位整数。另一方面,Go 区分有符号和无符号类型,而 ASN.1 不区分。例如,如果值uint64
对于int64
来说太大,传输可能会失败。
同理,ASN.1 允许几种不同的字符集,而 Go 包声明只支持PrintableString
和IA5String
(ASCII)。ASN.1 现在有了 Unicode UTF8 字符串类型,Go 也支持这种类型,但目前还没有文档。
我们已经看到,像整数这样的值可以很容易地进行编组和解组。其他基本类型如布尔型和实数型也可以类似地处理。完全由 ASCII 字符或 UTF8 字符组成的字符串可以被编组和解组。只要字符串仅由 ASCII 或 UTF8 字符组成,此代码就有效:
s := "hello"
mdata, _ := asn1.Marshal(s)
var newstr string
asn1.Unmarshal(mdata, &newstr)
ASN.1 还包括一些不在这个列表中的“有用类型”,比如 UTC 时间。Go 支持此 UTC 时间类型。这意味着您可以以一种其他数据值不可能的方式传递时间值。ASN.1 不支持指针,但是 Go 有专门的代码来管理指向时间值的指针。函数Now()
返回*time.Time
。特殊的代码对此进行整理,并且可以将其解组到一个指向time.Time
对象的指针变量中。因此,这段代码是有效的:
t := time.Now()
mdata, err := asn1.Marshal(t)
var newtime = new(time.Time)
_, err1 := asn1.Unmarshal(newtime, mdata)
LocalTime
和new
都处理指向一个*time.Time
的指针,而 Go 处理这个特例。程序ASN1basic.go
说明了这些:
/* ASN.1 Basic
*/
package main
import (
"encoding/asn1"
"fmt"
"os"
"time"
)
func main() {
t := time.Now()
fmt.Println("Before marshalling: ", t.String())
mdata, err := asn1.Marshal(t)
checkError(err)
fmt.Println("Marshalled ok")
var newtime = new(time.Time)
_, err1 := asn1.Unmarshal(mdata, newtime)
checkError(err1)
fmt.Println("After marshal/unmarshal: ", newtime.String())
s := "hello \u00bc"
fmt.Println("Before marshalling: ", s)
mdata2, err := asn1.Marshal(s)
checkError(err)
fmt.Println("Marshalled ok")
var newstr string
_, err2 := asn1.Unmarshal(mdata2, &newstr)
checkError(err2)
fmt.Println("After marshal/unmarshal: ", newstr)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
当它如下运行时:
go run ASN1basic.go
它打印出类似这样的内容:
Before marshalling: 2017-03-02 22:31:16.878943019 +1100 AEDT
Marshalled ok
After marshal/unmarshal: 2017-03-02 22:31:16 +1100 AEDT
Before marshalling: hello ¼
Marshalled ok
After marshal/unmarshal: hello ¼
一般来说,您可能想要封送和解封送结构。除了时间这个特例,Go 会很乐意处理结构,但是不会处理指向结构的指针。像new
这样的操作会创建指针,所以您必须在编组/解组它们之前解引用它们。Go 通常会在需要时解引用指针,但在这种情况下不会,所以您必须显式地解引用它们。这两者都适用于一种类型T
:
// using variables
var t1 T
t1 = ...
mdata1, _ := asn1.Marshal(t)
var newT1 T
asn1.Unmarshal(&newT1, mdata1)
// using pointers
var t2 = new(T)
*t2 = ...
mdata2, _ := asn1.Marshal(*t2)
var newT2 = new(T)
asn1.Unmarshal(newT2, mdata2)
指针和变量的任何合适的组合都可以。这里我们没有给出完整的例子,因为应用这些规则应该足够简单。
结构中的所有字段都必须是可导出的,也就是说,字段名必须以大写字母开头。Go 使用反射包来编组/解组结构,因此它必须能够检查所有字段。无法封送此类型:
type T struct {
Field1 int
field2 int // not exportable
}
ASN.1 只处理数据类型。它不考虑结构字段的名称。因此,下面的类型 T1 可以被编组/解组到类型T2
中,因为对应的字段是相同的类型:
type T1 struct {
F1 int
F2 string
}
type T2 struct {
FF1 int
FF2 string
}
不仅每个字段的类型必须匹配,数量也必须匹配。这两种类型不起作用:
type T1 struct {
F1 int
}
type T2 struct {
F1 int
F2 string // too many fields
}
我们没有给出完整的代码示例,因为我们不会用到这些特性。
ASN.1 说明了实现序列化方法的人可以做出的许多选择。可以通过使用更多的代码对指针进行特殊处理,例如强制名称匹配。字符串的顺序和数量将取决于序列化规范的细节、它所允许的灵活性以及利用这种灵活性所需的编码工作。值得注意的是,其他序列化格式会做出不同的选择,不同语言的实现也会强制执行不同的规则。
ASN.1 日间客户端和服务器
现在(最后)让我们转向使用 ASN.1 跨网络传输数据。
我们可以使用上一章的技术编写一个 TCP 服务器,以 ASN.1 Time
的形式提供当前时间。一个服务器是ASNDaytimeServer.go
:
/* ASN1 DaytimeServer
*/
package main
import (
"encoding/asn1"
"fmt"
"net"Calibri
"os"
"time"
)
func main() {
service := ":1200"
tcpAddr, err := net.ResolveTCPAddr("tcp", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
daytime := time.Now()
// Ignore return network errors.
mdata, _ := asn1.Marshal(daytime)
conn.Write(mdata)
conn.Close() // we're finished
}
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
这可以编译成一个可执行文件,比如ASN1DaytimeServer
,并且不带参数运行。它将等待连接,然后将时间作为 ASN.1 字符串发送给客户端。
一个客户是ASNDaytimeClient.go
:
/* ASN.1 DaytimeClient
*/
package main
import (
"bytes"
"encoding/asn1"
"fmt"
"io"
"net"
"os"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
os.Exit(1)
}
service := os.Args[1]
conn, err := net.Dial("tcp", service)
checkError(err)
result, err := readFully(conn)
checkError(err)
var newtime time.Time
_, err1 := asn1.Unmarshal(result, &newtime)
checkError(err1)
fmt.Println("After marshal/unmarshal: ", newtime.String())
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
os.Exit(1)
}
}
func readFully(conn net.Conn) ([]byte, error) {
defer conn.Close()
result := bytes.NewBuffer(nil)
var buf [512]byte
for {
n, err := conn.Read(buf[0:])
result.Write(buf[0:n])
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
}
return result.Bytes(), nil
}
它连接到以类似于localhost:1200
的形式给出的服务,读取 TCP 包,并将 ASN.1 内容解码成一个字符串,然后打印出来。
注意,这两者——客户机或服务器——都与上一章中基于文本的客户机和服务器不兼容。这个客户端和服务器正在交换 ASN.1 编码的数据值,而不是文本字符串。
数据
JSON 代表 JavaScript 对象符号。它被设计成一种在 JavaScript 系统之间传递数据的轻量级方法。它使用基于文本的格式,非常通用,已经成为许多编程语言的通用序列化方法。
JSON 序列化对象、数组和基本值。基本值包括字符串、数字、布尔值和空值。数组是以逗号分隔的值列表,可以表示数组、向量、列表或各种编程语言的序列。它们由方括号[ ... ]
分隔。对象由花括号{ ... }
中的“字段:值”对列表表示。
例如,前面给出的雇员表可以写成一个雇员对象数组:
[
{"Name": "fred", "Occupation": "programmer"},
{"Name": "liping", "Occupation": "analyst"},
{"Name": "sureerat", "Occupation": "manager"}
]
对于复杂的数据类型(如日期、数字类型之间没有区别、没有递归类型等)没有特殊的支持。JSON 是一种非常简单的语言,但是仍然非常有用。它基于文本的格式使它易于使用和调试,尽管它有字符串处理的开销。
根据 Go JSON 包规范,编组使用以下类型相关的默认编码:
- 布尔值编码为 JSON 布尔值。
- 浮点和整数值编码为 JSON 数字。
- 字符串值编码为 JSON 字符串,每个无效的 UTF-8 序列由 Unicode 替换字符 U+FFFD 的编码替换。
- 数组和切片值编码为 JSON 数组,除了
[]byte
编码为 Base64 编码的字符串。 - 结构值编码为 JSON 对象。每个结构字段都成为对象的成员。默认情况下,对象的键名是转换为小写的结构字段名。如果 struct 字段有标记,则该标记将被用作名称。
- 映射值编码为 JSON 对象。映射的键类型必须是字符串;对象键直接用作贴图键。
- 指针值编码为所指向的值。(注意:这允许树,但不允许图!).nil 指针编码为空 JSON 对象。
- 接口值编码为接口中包含的值。nil 接口值编码为空 JSON 对象。
- 通道、复杂和函数值不能在 JSON 中编码。试图对这样的值进行编码会导致
Marshal
返回InvalidTypeError
。 - JSON 不能表示循环数据结构,并且
Marshal
不处理它们。将循环结构传递给Marshal
将导致无限递归。
将 JSON 序列化数据存储到文件person.json
中的程序是SaveJSON.go
:
/* SaveJSON
*/
package main
import (
"encoding/json"
"fmt"
"os"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
func main() {
person := Person{
Name: Name{Family: "Newmarch", Personal: "Jan"},
Email: []Email{Email{Kind: "home", Address: "jan@newmarch.name"},
Email{Kind: "work", Address: "j.newmarch@boxhill.edu.au"}}}
saveJSON("person.json", person)
}
func saveJSON(fileName string, key interface{}) {
outFile, err := os.Create(fileName)
checkError(err)
encoder := json.NewEncoder(outFile)
err = encoder.Encode(key)
checkError(err)
outFile.Close()
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
要将其加载回内存,请使用LoadJSON.go
:
/* LoadJSON
*/
package main
import (
"encoding/json"
"fmt"
"os"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
func (p Person) String() string {
s := p.Name.Personal + " " + p.Name.Family
for _, v := range p.Email {
s += "\n" + v.Kind + ": " + v.Address
}
return s
}
func main() {
var person Person
loadJSON("person.json", &person)
fmt.Println("Person", person.String())
}
func loadJSON(fileName string, key interface{}) {
inFile, err := os.Open(fileName)
checkError(err)
decoder := json.NewDecoder(inFile)
err = decoder.Decode(key)
checkError(err)
inFile.Close()
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
序列化形式(格式良好):
{"Name":{"Family":"Newmarch",
"Personal":"Jan"},
"Email":[{"Kind":"home","Address":"jan@newmarch.name"},
{"Kind":"work","Address":"j.newmarch@boxhill.edu.au"}
]
}
客户端和服务器
一个客户端发送一个人的数据并读回 10 次是JSONEchoClient.go
:
/* JSON EchoClient
*/
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"os"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
func (p Person) String() string {
s := p.Name.Personal + " " + p.Name.Family
for _, v := range p.Email {
s += "\n" + v.Kind + ": " + v.Address
}
return s
}
func main() {
person := Person{
Name: Name{Family: "Newmarch", Personal: "Jan"},
Email: []Email{Email{Kind: "home", Address: "jan@newmarch.name"},
Email{Kind: "work", Address: "j.newmarch@boxhill.edu.au"}}}
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host:port")
os.Exit(1)
}
service := os.Args[1]
conn, err := net.Dial("tcp", service)
checkError(err)
encoder := json.NewEncoder(conn)
decoder := json.NewDecoder(conn)
for n := 0; n < 10; n++ {
encoder.Encode(person)
var newPerson Person
decoder.Decode(&newPerson)
fmt.Println(newPerson.String())
}
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
func readFully(conn net.Conn) ([]byte, error) {
defer conn.Close()
result := bytes.NewBuffer(nil)
var buf [512]byte
for {
n, err := conn.Read(buf[0:])
result.Write(buf[0:n])
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
}
return result.Bytes(), nil
}
对应的服务器是JSONEchoServer.go
:
/* JSON EchoServer
*/
package main
import (
"encoding/json"
"fmt"
"net"
"os"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
func (p Person) String() string {
s := p.Name.Personal + " " + p.Name.Family
for _, v := range p.Email {
s += "\n" + v.Kind + ": " + v.Address
}
return s
}
func main() {
service := "0.0.0.0:1200"
tcpAddr, err := net.ResolveTCPAddr("tcp", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
encoder := json.NewEncoder(conn)
decoder := json.NewDecoder(conn)
for n := 0; n < 10; n++ {
var person Person
decoder.Decode(&person)
fmt.Println(person.String())
encoder.Encode(person)
}
conn.Close() // we're finished
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
Gob 包
Gob 是一种特定于 Go 的序列化技术。它是专门为编码 Go 数据类型而设计的,目前还没有其他语言的实质性支持。它支持除通道、函数和接口之外的所有 Go 数据类型。它支持所有类型和大小的整数、字符串和布尔值、结构、数组和切片。目前,它在环形结构方面存在一些问题,但随着时间的推移会有所改善。
Gob 将类型信息编码成它的序列化形式。这比 X.509 序列化中的类型信息要广泛得多,但比 XML 文档中包含的类型信息要有效得多。对于每一段数据,类型信息只包括一次,但包括例如结构字段的名称。
这种类型信息的包含使得 Gob 编组和解组对于编组器和解组器之间的变化或差异相当健壮。例如,此结构:
struct T {
a int
b int
}
可以被编组,然后解组到不同的结构中,其中字段的顺序已经改变:
struct T {
b int
a int
}
它还可以处理丢失的字段(值被忽略)或额外的字段(字段保持不变)。它可以处理指针类型,因此前面的结构可以被解组到这个结构中:
struct T {
*a int
**b int
}
在某种程度上,它可以处理类型强制,使int
字段可以扩展为int64
,但不能处理不兼容的类型,如int
和uint
。
要使用 Gob 编组数据值,首先需要创建一个Encoder
。它将一个Writer
作为参数,并将对这个写流进行编组。编码器有一个名为Encode
的方法,它将值封送到流中。可以对多段数据多次调用此方法。但是,每种数据类型的类型信息只写一次。
您使用一个Decoder
来解组序列化的数据流。这需要一个Reader
,每次读取都返回一个解组的数据值。
将 Gob 序列化数据存储到文件person.go
中的程序是SaveGob.go
:
/* SaveGob
*/
package main
import (
"encoding/gob"
"fmt"
"os"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
func main() {
person := Person{
Name: Name{Family: "Newmarch", Personal: "Jan"},
Email: []Email{Email{Kind: "home", Address: "jan@newmarch.name"},
Email{Kind: "work", Address: "j.newmarch@boxhill.edu.au"}}}
saveGob("person.gob", person)
}
func saveGob(fileName string, key interface{}) {
outFile, err := os.Create(fileName)
checkError(err)
encoder := gob.NewEncoder(outFile)
err = encoder.Encode(key)
checkError(err)
outFile.Close()
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
要将其加载回内存,请使用LoadGob.go
:
/* LoadGob
*/
package main
import (
"encoding/gob"
"fmt"
"os"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
func (p Person) String() string {
s := p.Name.Personal + " " + p.Name.Family
for _, v := range p.Email {
s += "\n" + v.Kind + ": " + v.Address
}
return s
}
func main() {
var person Person
loadGob("person.gob", &person)
fmt.Println("Person", person.String())
}
func loadGob(fileName string, key interface{}) {
inFile, err := os.Open(fileName)
checkError(err)
decoder := gob.NewDecoder(inFile)
err = decoder.Decode(key)
checkError(err)
inFile.Close()
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
客户端和服务器
一个客户端发送一个人的数据并读回 10 次是GobEchoClient.go
:
/* Gob EchoClient
*/
package main
import (
"bytes"
"encoding/gob"
"fmt"
"io"
"net"
"os"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
func (p Person) String() string {
s := p.Name.Personal + " " + p.Name.Family
for _, v := range p.Email {
s += "\n" + v.Kind + ": " + v.Address
}
return s
}
func main() {
person := Person{
Name: Name{Family: "Newmarch", Personal: "Jan"},
Email: []Email{Email{Kind: "home", Address: "jan@newmarch.name"},
Email{Kind: "work", Address: "j.newmarch@boxhill.edu.au"}}}
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host:port")
os.Exit(1)
}
service := os.Args[1]
conn, err := net.Dial("tcp", service)
checkError(err)
encoder := gob.NewEncoder(conn)
decoder := gob.NewDecoder(conn)
for n := 0; n < 10; n++ {
encoder.Encode(person)
var newPerson Person
decoder.Decode(&newPerson)
fmt.Println(newPerson.String())
}
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
func readFully(conn net.Conn) ([]byte, error) {
defer conn.Close()
result := bytes.NewBuffer(nil)
var buf [512]byte
for {
n, err := conn.Read(buf[0:])
result.Write(buf[0:n])
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
}
return result.Bytes(), nil
}
对应的服务器是GobEchoServer.go
:
/* Gob EchoServer
*/
package main
import (
"encoding/gob"
"fmt"
"net"
"os"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string
Address string
}
func (p Person) String() string {
s := p.Name.Personal + " " + p.Name.Family
for _, v := range p.Email {
s += "\n" + v.Kind + ": " + v.Address
}
return s
}
func main() {
service := "0.0.0.0:1200"
tcpAddr, err := net.ResolveTCPAddr("tcp", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
encoder := gob.NewEncoder(conn)
decoder := gob.NewDecoder(conn)
for n := 0; n < 10; n++ {
var person Person
decoder.Decode(&person)
fmt.Println(person.String())
encoder.Encode(person)
}
conn.Close() // we're finished
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
将二进制数据编码为字符串
从前,传输 8 位数据是有问题的。它通常通过嘈杂的串行线路传输,很容易被破坏。另一方面,7 位数据可以更可靠地传输,因为第 8 位可以用作校验位。例如,在“偶数奇偶校验”方案中,校验位将被设置为 1 或 0,以使一个字节中的 1 为偶数。这允许检测每个字节中单个位的错误。
ASCII 是 7 位字符集。已经开发了许多比简单的奇偶校验更复杂的方案,但是这些方案涉及将 8 位二进制数据转换成 7 位 ASCII 格式。本质上,8 位数据以某种方式扩展到 7 位字节。
在 HTTP 响应和请求中传输的二进制数据通常被转换成 ASCII 形式。这使得用一个简单的文本阅读器检查 HTTP 消息变得很容易,而不用担心奇怪的 8 位字节会对您的显示产生什么影响!
一种常见的格式是 Base64。Go 支持许多二进制到文本的格式,包括 Base64。
Base64 编码和解码有两个主要函数:
func NewEncoder(enc *Encoding, w io.Writer) io.WriteCloser
func NewDecoder(enc *Encoding, r io.Reader) io.Reader
一个简单的编码和解码八个二进制数字的程序是:
/**
* Base64
*/
package main
import (
"encoding/base64"
"fmt"
)
func main() {
eightBitData := []byte{1, 2, 3, 4, 5, 6, 7, 8}
enc := base64.StdEncoding.EncodeToString(eightBitData)
dec, _ := base64.StdEncoding.DecodeString(enc)
fmt.Println("Original data ", eightBitData)
fmt.Println("Encoded string ", enc)
fmt.Println("Decoded data ", dec)
}
输出如下所示:
Original data [1 2 3 4 5 6 7 8]
Encoded string AQIDBAUGBwg=
Decoded data [1 2 3 4 5 6 7 8]
协议缓冲区
到目前为止,考虑的序列化方法分为多种类型:
- ASN.1 使用数据中的二进制标签对不同类型进行编码。从这个意义上说,ASN.1 编码的数据结构是一种自描述结构。
- JSON 同样是一种自描述格式,使用 JavaScript 数据结构的规则:列表、字典等。
- Gob 同样将类型信息编码成它的编码形式。这比 JSON 格式要详细得多。
另一类序列化技术依赖于要编码的数据类型的外部规范。有几个主要的,比如 ONC RPC 使用的编码。
ONC RPC 是一种旧的编码,面向 C 语言。最近的一个来自谷歌,被称为协议缓冲区。Go 标准库中不支持这一点,但 Google Protocol Buffers 开发小组( https://developers.google.com/protocol-buffers/
)支持这一点,而且这一点在 Google 内部显然非常流行。出于这个原因,我们包括了一个关于协议缓冲区的部分,尽管在本书的其余部分我们通常处理 Go 标准库。
协议缓冲区是数据的二进制编码,旨在支持多种语言的数据类型。它们依赖于数据结构的外部规范,该规范用于编码数据(用源语言)以及将编码的数据解码回目标语言。(注:协议缓冲区于 2016 年 7 月过渡到版本 3。它与版本 2 不兼容。版本 2 将会被长期支持,但最终会被淘汰。参见 https://github.com/google/protobuf/releases/tag/v3.0.0
的协议缓冲区 3.0.0 版)。
要序列化的数据结构称为消息。每个消息中支持的数据类型包括:
- 数字(整数或浮点数)
- 布尔运算
- 弦乐(UTF 语-8)
- 原始字节
- 地图
- 其他消息,允许构建复杂的数据结构
消息的所有字段都是可选的(这是从proto2
开始的变化,在那里字段是必需的或可选的)。字段可以代表关键字 repeated 的列表或数组,也可以代表使用关键字 map 的映射。每个字段都有一个类型,后跟一个名称,再后跟一个标记索引值。完整的语言指南称为“协议缓冲区语言指南”(参见 https://developers.google.com/protocol-buffers/docs/proto
)。
消息的定义与可能的目标语言无关。协议缓冲区版本 3 的语法中的Person
类型的一个版本是personv3.proto
。请注意,该文件包含每种类型的特定标记(1,2)。
syntax = "proto3";
package person;
message Person {
message Name {
string family = 1;
string personal = 2;
}
message Email {
string kind = 1;
string address = 2;
}
Name name = 1;
repeated Email email = 2;
}
安装和编译协议缓冲区
使用名为protoc
的程序编译协议缓冲区。这不太可能安装在您的系统上。版本 3 是 2016 年 7 月才发布的,所以存储库中的副本很可能是版本 2。
从协议缓冲区 v3.0.0 页面安装最新版本。以 64 位 Linux 为例,从 GitHub 下载protoc-3.0.0-linux-x86_64.zip
并解压到合适的地方(它包括二进制bin/protoc
,应该放在你的PATH
的某个地方)。
安装通用二进制文件。您还需要“后端”来生成 Go 文件。为此,从 GitHub 获取它:
go get -u github.com/golang/protobuf/protoc-gen-go
您几乎已经准备好编译一个.proto
文件了。前面的例子personv3.proto
声明了包person
。在你的GOPATH
中,你应该有一个名为src
的目录。创建一个名为src/person
的子目录。然后编译personv3.proto
如下:
protoc --go_out=src/person personv3.proto
这应该会创建src/person/personv3.pb.go
文件。
编译后的 personv3.pb.go 文件
编译后的文件将声明许多类型和这些类型上的方法。这些类型如下:
type Person struct {
Name *Person_Name `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"`
Email []*Person_Email `protobuf:"bytes,2,rep,name=email" json:"email,omitempty"`
}
type Person_Name struct {
Family string `protobuf:"bytes,1,opt,name=family" json:"family,omitempty"`
Personal string `protobuf:"bytes,2,opt,name=personal" json:"personal,omitempty"`
}
type Person_Email struct {
Kind string `protobuf:"bytes,1,opt,name=kind" json:"kind,omitempty"`
Address string `protobuf:"bytes,2,opt,name=address" json:"address,omitempty"`
}
它们在名为person
的包中。(注意:字符串等简单类型直接编码。在协议缓冲区 v2 中,使用了指针。对于复合类型,需要一个指针,如在 v2 中。)
使用编译的代码
JSON 示例中使用的代码和这个示例中使用的代码基本上没有区别,除了必须监视所用结构的指针。一个简单的程序就是ProtocolBuffer.go
来编组和解组一个Person
:
编组前后的输出应该是一个Person
,并且应该是相同的:
/* ProtocolBuffer
*/
package main
import (
"fmt"
"github.com/golang/protobuf/proto"
"os"
"person"
)
func main() {
name := person.Person_Name{
Family: "newmarch",
Personal: "jan"}
email1 := person.Person_Email{
Kind: "home",
Address: "jan@newmarch.name"}
email2 := person.Person_Email{
Kind: "work",
Address: "j.newmarch@boxhill.edu.au"}
emails := []*person.Person_Email{&email1, &email2}
p := person.Person{
Name: &name,
Email: emails,
}
fmt.Println(p)
data, err := proto.Marshal(&p)
checkError(err)
newP := person.Person{}
err = proto.Unmarshal(data, &newP)
checkError(err)
fmt.Println(newP)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
编组前后的输出应该是一个人,通过运行以下命令应该是相同的:
go run ProtocolBuffer.go
{family:"newmarch" personal:"jan" [kind:"home" address:"jan@newmarch.name" kind:"work" address:"j.newmarch@boxhill.edu.au" ]}
{family:"newmarch" personal:"jan" [kind:"home" address:"jan@newmarch.name" kind:"work" address:"j.newmarch@boxhill.edu.au" ]}
我们还没有对编组对象做太多的工作。然而,它可以被保存到文件中或通过网络发送,并由任何支持的语言解组:C++、C#、Java、Python 以及 Go。
结论
本章讨论了序列化数据类型的一般属性,并展示了一些常见的编码。还有很多,包括 XML(包含在 Go 库中)、CBOR(JSON 的二进制形式)和 YAML(类似于 XML),以及许多特定于语言的语言,如 Java 对象序列化和 Python 的 Pickle。那些不在 Go 标准包中的可能经常在 GitHub 上找到。
Footnotes 1
我将序列化和编组视为同义词。关于这一点有各种各样的观点,有些观点比其他观点更具体。例如,参见“序列化和封送处理的区别是什么?
五、应用层协议
客户端和服务器需要通过消息交换信息。TCP 和 UDP 提供了实现这一点的传输机制。这两个过程还需要有一个合适的协议,这样消息交换才能有意义地进行。协议通过指定消息、数据类型、编码格式等,定义了分布式应用程序的两个组件之间可以进行何种类型的对话。本章着眼于这个过程中涉及的一些问题,并给出一个简单的客户机-服务器应用程序的完整例子。
协议设计
在设计协议时,有许多可能性和问题需要决定。一些问题包括:
- 是广播还是点对点?广播可以是 UDP、本地多播或更具实验性的 MBONE。点对点可以是 TCP 或 UDP。
- 是有状态还是无状态?一方维护另一方的状态合理吗?一方维护另一方的状态通常更简单,但是如果某个东西崩溃了会发生什么呢?
- 传输协议可靠还是不可靠?可靠的往往比较慢,但是这样你就不用那么担心丢失消息了。
- 需要回复吗?如果需要回复,如何处理丢失的回复?可以使用超时。
- 您想要什么数据格式?上一章讨论了几种可能性。
- 你的交流是突发性的还是源源不断的?以太网和互联网最擅长突发流量。视频流尤其是语音流需要稳定的流。如果需要,您如何管理服务质量(QoS)?
- 是否有多个流需要同步?数据是否需要与任何东西同步,比如视频和语音?
- 您是在构建一个独立的应用程序还是一个供他人使用的库?所需文件的标准可能会有所不同。
你为什么要担心?
据报道,亚马逊首席执行官杰夫·贝索斯在 2002 年发表了以下声明:
- 所有团队将从此通过服务接口公开他们的数据和功能。
- 团队必须通过这些接口相互交流。
- 不允许其他形式的进程间通信:不允许直接链接,不允许直接读取另一个团队的数据存储,不允许共享内存模型,不允许任何后门。唯一允许的通信是通过网络上的服务接口调用。
- 他们用什么技术并不重要。HTTP、Corba、Pubsub、自定义协议——都没关系。贝佐斯不在乎。
- 所有的服务接口,无一例外,都必须从头开始被设计成可外部化的。也就是说,团队必须计划和设计能够向外部世界的开发人员公开接口。没有例外。
- 不这么做的人都会被开除。
(来源:Rip Rowan 关于 Steve Yegge 在 https://plus.google.com/+RipRowan/posts/eVeouesvaVX
发帖的博客)。)贝佐斯所做的是围绕服务架构引导世界上最成功的互联网公司之一,接口必须足够清晰,所有的通信都必须通过这些接口进行,不能有混乱或错误。
版本控制
客户机-服务器系统中使用的协议会随着时间的推移而发展,随着系统的扩展而变化。这就产生了兼容性问题:第 2 版客户机将发出第 1 版服务器无法理解的请求,而第 2 版服务器将发送第 1 版客户机无法理解的回复。
理想情况下,每一方都应该能够理解来自自己版本和所有早期版本的消息。它应该能够以旧式响应格式编写对旧式查询的回复。见图 5-1 。
图 5-1。
Compatibility versus version control
如果协议变化太大,可能会失去与早期版本格式对话的能力。在这种情况下,您需要能够确保不存在早期版本的副本,这通常是不可能的。
协议设置的一部分应该包括版本信息。
网络
网络是一个系统经历多个不同版本的好例子。底层 HTTP 协议以出色的方式管理版本控制,即使它已经经历了四个版本。大多数服务器/浏览器支持最新版本,但也支持早期版本。截至 2017 年 1 月,最新版本的 HTTP/2 似乎占网络流量的 11%多一点,而 HTTP/1.1 几乎占了其余的所有份额。每个请求中都给出了版本,如以下GET
请求所示:
HTTP/2 是二进制格式,与早期版本不兼容。然而,有一种协商机制可以发送一个带有 HTTP/2 升级字段的 HTTP/1.1 请求。如果客户端接受,就可以进行升级。如果客户端不理解升级参数,连接将继续使用 HTTP/1.1。
虽然 HTTP 最初是为 HTML 设计的,但它可以承载任何内容。如果我们只看 HTML,它已经经历了大量的版本,有时很少尝试确保版本之间的兼容性:
- HTML5,它放弃了点修订之间的任何版本信令
- HTML 版本 1-4(各不相同),其中“浏览器大战”中的版本尤其成问题
- 不同浏览器识别的非标准标签
- 非 HTML 文档通常需要可能不存在的内容处理程序;你的浏览器有 Flash 的处理程序吗?
- 对文档内容的处理不一致(例如,一些样式表内容会使一些浏览器崩溃)
- 对 JavaScript 的不同支持(以及不同版本的 JavaScript)
- 不同的 Java 运行时引擎
- 许多页面不符合任何 HTML 版本(例如,有语法错误)
HTML5(实际上还有许多早期版本)是一个不做版本控制的极好例子。撰写本文时的最新修订版是修订版 5。在此版本中,引入了新功能来帮助 Web 应用程序作者,引入了基于对流行创作实践的研究的新元素。不仅添加了一些新功能,而且一些旧功能(应该不会再使用了)也被删除,不再工作。HTML5 文档没有办法表明它使用的是哪个版本。
消息格式
在上一章中,我们讨论了表示通过网络发送的数据的一些可能性。现在我们向上看一层,看可能包含这种数据的消息。
- 客户端和服务器将交换具有不同含义的消息:
- 登录请求
- 登录回复
- 获取记录请求
- 记录数据回复
- 客户端将准备一个请求,这个请求必须被服务器理解。
- 服务器将准备一个必须被客户端理解的回复。
通常,消息的第一部分是消息类型。
-
客户端到服务器:
LOGIN <name> <passwd> GET <subject> grade
-
服务器到客户端:
LOGIN succeeded GRADE <subject> <grade>
消息类型可以是字符串或整数。比如 HTTP 用 404 这样的整数表示“没有找到”(虽然这些整数都写成字符串)。从客户端到服务器的消息是不相交的,反之亦然。从客户端到服务器的LOGIN
消息与从服务器到客户端的LOGIN
消息是不同的消息,并且它们可能在协议中起补充作用。
数据格式
消息有两种主要的格式选择:字节编码或字符编码。
字节格式
在字节格式中:
- 消息的第一部分通常是一个字节,用于区分消息类型。
- 消息处理程序检查第一个字节以区分消息类型,然后执行切换以选择适合该类型的处理程序。
- 消息中的其他字节包含符合预定义格式的消息内容(如前一章所述)。
优点是紧凑,因此速度快。缺点是由数据的不透明性引起的:可能更难发现错误,更难调试,并且需要特殊目的的解码功能。有许多字节编码格式的例子,包括 DNS 和 NFS 等主要协议,以及 Skype 等最新协议。当然,如果您的协议没有公开指定,那么字节格式也会使其他人更难对其进行逆向工程!
字节格式服务器的伪代码如下:
handleClient(conn) {
while (true) {
byte b = conn.readByte()
switch (b) {
case MSG_1: ...
case MSG_2: ...
...
}
}
}
Go 对管理字节流有基本的支持。接口io.ReaderWriter
有这些方法:
Read(b []byte) (n int, err error)Write(b []byte) (n int, err error)
这些方法由 TCPConn 和 UDPConn 实现。
字符格式
在这种模式下,所有内容都尽可能以字符形式发送。例如,整数 234 将作为,比如说,三个字符2
、3
和4
发送,而不是作为一个字节 234 发送。固有的二进制数据可以进行 Base64 编码,将其转换为 7 位格式,然后作为 ASCII 字符发送,如前一章所述。
以字符格式:
- 消息是一行或多行的序列。消息第一行的开头通常是代表消息类型的单词。
- 字符串处理函数可用于解码消息类型和数据。
- 第一行和后续行的剩余部分包含数据。
- 面向行的函数和面向行的约定用于管理这一点。
伪代码如下:
handleClient() {
line = conn.readLine()
if (line.startsWith(...) {
...
} else if (line.startsWith(...) {
...
}
}
字符格式更容易设置和调试。例如,您可以使用telnet
在任何端口上连接到服务器,并向该服务器发送客户端请求。没有像telnet
这样简单的工具向客户端发送服务器响应,但是您可以使用像tcpdump
或wireshark
这样的工具来窥探 TCP 流量,并立即查看客户端向服务器发送了什么,以及从服务器接收了什么。
在 Go 中对管理字符流没有相同级别的支持。字符集和字符编码有一些重要的问题,我们将在后面的章节中探讨这些问题。
如果我们只是假设一切都是 ASCII,就像从前一样,那么字符格式就很容易处理。这一级的主要复杂性是不同操作系统中“换行符”的不同状态。UNIX 使用单个字符\n
。Windows 和其他(更正确地说)使用对\r\n
。在互联网上,这一对\r\n
最为常见。UNIX 系统只需要注意不要假设\n
。
简单的例子
这个例子处理一个目录浏览协议,它基本上是 FTP 的一个简化版本,但是甚至没有文件传输部分。我们只考虑列出一个目录名,列出一个目录的内容,并更改当前目录——当然,所有这些都在服务器端。这是一个创建客户机-服务器应用程序所有组件的完整实例。这是一个简单的程序,包括双向消息,以及消息协议的设计。
独立的应用程序
看一个简单的非客户机-服务器程序,它允许您列出目录中的文件,并更改和打印服务器上的目录名。我们省略了复制文件,因为这增加了程序的长度,却没有引入重要的概念。为简单起见,所有文件名都假定为 7 位 ASCII 码。如果我们先看一个独立的应用程序,它将如图 5-2 所示。
图 5-2。
The standalone application
伪代码如下所示:
read line from user
while not eof do
if line == dir
list directory // local function call
else
if line == cd <directory>
change directory // local function call
else
if line == pwd
print directory // local function call
else
if line == quit
quit
else
complain
read line from user
非分布式应用程序将通过本地函数调用简单地链接 UI 和文件访问代码。
客户端-服务器应用程序
在客户机-服务器的情况下,客户机位于用户端,与其他地方的服务器通信。这个程序的某些方面只属于表示端,比如从用户那里获取命令。一些是从客户端到服务器的消息;有些只是在服务器端。见图 5-3
图 5-3。
The client-server situation
客户端
对于一个简单的目录浏览器,假设所有的目录和文件都在服务器端,我们只将文件信息从服务器传输到客户机。客户端(包括表示方面)将成为:
read line from user
while not eof do
if line == dir
list directory // network call to server
else
if line == cd <directory>
change directory // network call to server
else
if line == pwd
print directory // network call to server
else
if line == quit
quit
else
complain
read line from user
其中调用list
directory
、change directory
和print directory
现在都涉及到对服务器的网络调用。细节尚未显示,将在稍后讨论。
可选演示方面
GUI 程序将允许目录内容显示为列表,以便选择文件和对它们执行诸如更改目录之类的操作。客户端将由与图形对象上发生的各种事件相关联的动作来控制。伪代码可能如下所示:
change dir button:
if there is a selected file
change directory // remote call to server
if successful
update directory label
list directory // remote call to server
update directory list
从不同 ui 调用的函数应该是相同的——改变表示不应该改变网络代码。
服务器端
服务器端独立于客户端使用的任何表示。所有客户端都是一样的:
while read command from client
if command == dir
send list directory // local call on server
else
if command == cd <directory>
change directory // local call on server
else
if command == pwd
send print directory // local call on server
else
礼仪:非正式
| 客户请求 | 服务器响应 | | --- | --- | | `dir` | 发送文件列表 | | `cd文本协议
这是一个简单的协议。我们需要发送的最复杂的数据结构是一个目录列表的字符串数组。在这种情况下,我们不需要上一章的重载序列化技术。在这种情况下,我们可以使用简单的文本格式。
但是即使我们使协议变得简单,我们仍然必须详细地指定它。我们选择以下消息格式:
- 所有消息都是 7 位 US-ASCII 格式。
- 这些消息区分大小写。
- 每条消息由一系列行组成。
- 每条消息第一行的第一个词描述了消息类型。所有其他字都是消息数据。
- 所有单词都由一个空格分隔。
- 每一行都以 CR-LF 结尾。
上面所做的一些选择在现实生活的协议中比较弱。例如:
- 消息类型可以不区分大小写。这只需要在解码前将消息类型字符串映射为小写。
- 单词之间可以留任意数量的空白。这只是增加了一点复杂性,压缩空白。
- 像
\
这样的连续字符可以用来在几行上断开长行。这开始使处理变得更加复杂。 - 仅仅一个
\n
就可以作为行终止符,\r\n
也可以。这使得识别行尾有点困难。
所有这些变化都存在于真实的协议中。累积起来,它们使得字符串处理比这种情况更复杂。
| 客户请求 | 服务器响应 | | --- | --- | | `send "DIR"` | 发送文件列表,每行一个,以空行结束 | | `send "CD我们还应该指定运输工具:
- 所有消息都通过从客户端到服务器建立的 TCP 连接发送。
服务器代码
服务器是FTPServer.go
:
/* FTP Server
*/
package main
import (
"fmt"
"net"
"os"
)
const (
DIR = "DIR"
CD = "CD"
PWD = "PWD"
)
func main() {
service := "0.0.0.0:1202"
tcpAddr, err := net.ResolveTCPAddr("tcp", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
go handleClient(conn)
}
}
func handleClient(conn net.Conn) {
defer conn.Close()
var buf [512]byte
for {
n, err := conn.Read(buf[0:])
if err != nil {
conn.Close()
return
}
s := string(buf[0:n])
// decode request
if s[0:2] == CD {
chdir(conn, s[3:])
} else if s[0:3] == DIR {
dirList(conn)
} else if s[0:3] == PWD {
pwd(conn)
}
}
}
func chdir(conn net.Conn, s string) {
if os.Chdir(s) == nil {
conn.Write([]byte("OK"))
} else {
conn.Write([]byte("ERROR"))
}
}
func pwd(conn net.Conn) {
s, err := os.Getwd()
if err != nil {
conn.Write([]byte(""))
return
}
conn.Write([]byte(s))
}
func dirList(conn net.Conn) {
// send a blank line on termination
defer conn.Write([]byte("\r\n"))
dir, err := os.Open(".")
if err != nil {
return
}
names, err := dir.Readdirnames(-1)
if err != nil {
return
}
for _, nm := range names {
conn.Write([]byte(nm + "\r\n"))
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
客户代码
命令行客户端是FTPClient.go
:
/* FTPClient
*/
package main
import (
"bufio"
"bytes"
"fmt"
"net"
"os"
"strings"
)
// strings used by the user interface
const (
uiDir = "dir"
uiCd = "cd"
uiPwd = "pwd"
uiQuit = "quit"
)
// strings used across the network
const (
DIR = "DIR"
CD = "CD"
PWD = "PWD"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host")
os.Exit(1)
}
host := os.Args[1]
conn, err := net.Dial("tcp", host+":1202")
checkError(err)
reader := bufio.NewReader(os.Stdin)
for {
line, err := reader.ReadString('\n')
// lose trailing whitespace
line = strings.TrimRight(line, " \t\r\n")
if err != nil {
break
}
// split into command + arg
strs := strings.SplitN(line, " ", 2)
// decode user request
switch strs[0] {
case uiDir:
dirRequest(conn)
case uiCd:
if len(strs) != 2 {
fmt.Println("cd <dir>")
continue
}
fmt.Println("CD \"", strs[1], "\"")
cdRequest(conn, strs[1])
case uiPwd:
pwdRequest(conn)
case uiQuit:
conn.Close()
os.Exit(0)
default:
fmt.Println("Unknown command")
}
}
}
func dirRequest(conn net.Conn) {
conn.Write([]byte(DIR + " "))
var buf [512]byte
result := bytes.NewBuffer(nil)
for {
// read till we hit a blank line
n, _ := conn.Read(buf[0:])
result.Write(buf[0:n])
length := result.Len()
contents := result.Bytes()
if string(contents[length-4:]) == "\r\n\r\n" {
fmt.Println(string(contents[0 : length-4]))
return
}
}
}
func cdRequest(conn net.Conn, dir string) {
conn.Write([]byte(CD + " " + dir))
var response [512]byte
n, _ := conn.Read(response[0:])
s := string(response[0:n])
if s != "OK" {
fmt.Println("Failed to change dir")
}
}
func pwdRequest(conn net.Conn) {
conn.Write([]byte(PWD))
var response [512]byte
n, _ := conn.Read(response[0:])
s := string(response[0:n])
fmt.Println("Current dir \"" + s + "\"")
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
文本换行软件包
textproto
包包含的功能旨在简化类似于 HTTP 和 SNMP 的文本协议的管理。
这些格式对于在多行上延续的单个逻辑行有一些鲜为人知的规则,例如:“如果延续行以空格或水平制表符开始,则 HTTP/1.1 头字段值可以折叠到多行上”(HTTP1.1 规范)。允许像这样的行的格式可以使用ReadContinuedLine()
函数读取,除了像ReadLine()
这样更简单的函数。
这些协议还通过以三位数代码开头的行(如404
)来表示状态值。这些可以使用ReadCodeLine()
读取。他们也有关键的价值线,如Content-Type: image/gif
。这样的线可以通过ReadMIMEHeader()
读入一张地图。
状态信息
应用程序通常使用状态信息来简化正在发生的事情。例如:
- 保持指向当前文件位置的文件指针。
- 保持当前鼠标位置。
- 保持当前的客户价值。
在分布式系统中,这种状态信息可以保存在客户机、服务器或两者中。
重要的一点是,一个进程保存的状态信息是关于它自己的还是关于另一个进程的。一个进程可以根据自己的需要保存尽可能多的状态信息,而不会引起任何问题。如果它需要保存另一个进程的状态信息,那么问题就出现了。该进程对另一个进程的状态的实际了解可能变得不正确。这可能是由于消息丢失(在 UDP 中)、更新失败或软件错误造成的。
读取文件就是一个例子。在单进程应用程序中,文件处理代码作为应用程序的一部分运行。它维护一个打开文件的表以及每个文件的位置。每次读取或写入完成时,该文件位置都会更新。在分布式系统中,这个简单的模型不成立。见图 5-4 。
图 5-4。
The DCE file system
在图 5-4 所示的 DCE 文件系统中,文件服务器跟踪客户端打开的文件以及客户端文件指针的位置。如果消息可能丢失(但 DCE 使用 TCP),这些可能会失去同步。如果客户机崩溃,服务器最终必须在客户机的文件表上超时并删除它们。
在 NFS,服务器不保持这种状态。客户知道。从客户端到达服务器的每个文件访问必须在客户端给出的适当点打开文件,以便执行操作。见图 5-5 。
图 5-5。
The NFS file system
如果服务器维护有关客户机的信息,那么它必须能够在客户机崩溃时恢复。如果信息没有被保存,那么在每一次交易中,客户端必须传输足够的信息以使服务器正常工作。
如果连接不可靠,必须进行额外的处理,以确保两者不会失去同步。典型的例子是银行账户交易,其中消息丢失。交易服务器可能需要成为客户机-服务器系统的一部分。
应用程序状态转换图
状态转换图跟踪应用程序的当前状态以及使其进入新状态的更改。
前面的例子基本上只有一种状态:文件传输。如果我们添加一个登录机制,这将增加一个额外的状态,称为登录,应用程序将需要在登录和文件传输之间改变状态,如图 5-6 所示。
图 5-6。
The state-transition diagram
这种状态变化也可以表示为表格:
| 初速电流状态 | 过渡 | 次状态 | | --- | --- | --- | | `login` | `login failed` | `login` | | `login succeeded` | `file transfer` | | `file transfer` | `dir` | `file transfer` | | `get` | `file transfer` | | `logout` | `login` | | `quit` | `-` |客户端状态转换图
客户端状态图必须跟在应用程序图后面。不过它有更多的细节:它先写,然后读:
| 初速电流状态 | 写 | 阅读 | 次状态 | | --- | --- | --- | --- | | `login` | `LOGIN name password` | `FAILED` | `login` | | `OK` | `file transfer` | | `file transfer` | `CD dir` | `OK` | `file transfer` | | `FAILED` | `file transfer` | | `GET filename` | `#lines + contents` | `file transfer` | | `FAILED` | `file transfer` | | `DIR` | `File names + blank line` | `file transfer` | | `blank line (Error)` | `file transfer` | | `quit` | `none` | `quit` |服务器状态转换图
服务器状态图也必须跟在应用程序图后面。它还有更多细节:它先读,然后写:
| 初速电流状态 | 阅读 | 写 | 次状态 | | --- | --- | --- | --- | | `login` | `LOGIN name password` | `FAILED` | `login` | | `OK` | `file transfer` | | `file transfer` | `CD dir` | `SUCCEEDED` | `file transfer` | | `FAILED` | `file transfer` | | `GET filename` | `#lines + contents` | `file transfer` | | `FAILED` | `file transfer` | | `DIR` | `filenames + blank line` | `file transfer` | | `blank line (failed)` | `file transfer` | | `quit` | `none` | `login` |服务器伪代码
以下是服务器伪代码:
state = login
while true
read line
switch (state)
case login:
get NAME from line
get PASSWORD from line
if NAME and PASSWORD verified
write SUCCEEDED
state = file_transfer
else
write FAILED
state = login
case file_transfer:
if line.startsWith CD
get DIR from line
if chdir DIR okay
write SUCCEEDED
state = file_transfer
else
write FAILED
state = file_transfer
...
我们没有给出这个服务器或客户端的实际代码,因为它非常简单。
结论
构建任何应用程序都需要在开始编写代码之前做出设计决策。与独立系统相比,使用分布式应用程序,您可以做出更大范围的决策。本章考虑了其中的一些方面,并演示了最终的代码可能是什么样子。我们只触及了协议设计的元素。有许多正式和非正式的模型。IETF(互联网工程任务组)在其 RFC(请求注解)中为其协议规范创建了标准格式,迟早,每个网络工程师都需要与 RFC 一起工作。
六、管理字符集和编码
从前有 EBCDIC 和 ASCII。事实上,它从来没有那么简单,只是随着时间的推移变得更加复杂。地平线上有光,但一些人估计,我们可能需要 50 年才能在这里生活在日光下!
早期的计算机是在说英语的美国、英国和澳大利亚发展起来的。因此,人们对正在使用的语言和字符集做出了假设。基本上,使用拉丁字母,加上数字,标点符号,和一些其他的。然后使用 ASCII 或 EBCDIC 将这些编码成字节。
字符处理机制基于此:文本文件和 I/O 由一系列字节组成,每个字节代表一个字符。字符串比较可以通过匹配相应的字节来完成;从大写到小写的转换可以通过映射单个字节来完成,等等。
世界上大约有 6500 种口语(其中 850 种在巴布亚新几内亚!).少数语言使用“英语”字符,但大多数不使用。像法语这样的罗马语言在不同的字符上有装饰,所以你可以用两个不同重音的元音写“j'ai arrêté”。同样,日耳曼语也有额外的字符,如“σ”。甚至英国英语也有不在标准 ASCII 码集中的字符:英镑符号“”和最近的欧元“€”。
但是这个世界并不局限于拉丁字母的变体。泰国有自己的字母表,单词看起来像这样:“ภาษาไทย".还有很多其他的字母,日本甚至有两个,平假名和片假名。
也有象形文字语言,比如中文,你可以在上面写字百度一下,你就知道".
从技术角度来看,如果全世界都使用 ASCII 就好了。然而,趋势是相反的,越来越多的用户要求软件使用他们熟悉的语言。如果你开发一个可以在不同国家运行的应用程序,那么用户会要求它使用他们自己的语言。在分布式系统中,期望不同语言和字符的用户可以使用系统的不同组件。
国际化(i18n)是您如何编写应用程序,以便它们可以处理各种语言和文化。本地化(l10n)是针对特定文化群体定制您的国际化应用程序的过程。
i18n 和 l10n 本身就是大话题。例如,它们涵盖了颜色等问题:虽然白色在西方文化中意味着“纯洁”,但它对中国人来说意味着“死亡”,对埃及人来说意味着“快乐”。在这一章中,我们只看字符处理的问题。
定义
很重要的一点是要注意你所说的是文本处理系统的哪一部分。这里有一组被证明有用的定义。
性格;角色;字母
字符是“大致对应于自然语言的字形(书面符号)的信息单元,如字母、数字或标点符号”(维基百科)。字符是“书面语言中具有语义价值的最小组成部分”(Unicode)。这包括字母,如“a”和“à”(或任何其他语言的字母),数字,如“2”,标点符号,如“,”和各种符号,如英国英镑货币符号“”。
一个字符是任何实际符号的某种抽象:字符“a”对于任何书写的“a”就像柏拉图的圆圈对于任何实际的圆圈一样。字符的概念还包括控制字符,它不对应于自然语言符号,而是对应于用于处理语言文本的其他信息。
一个角色没有任何特定的外貌,尽管我们用外貌来帮助识别角色。然而,即使是外观也可能必须在一个上下文中理解:在数学中,如果你看到符号π (pi ),它是圆周与半径之比的字符,而如果你正在阅读希腊文本,它是字母表的第 16 个字母:“ρρoσ”是希腊单词“with ”,与 3.14159 无关。
字符集/字符集
字符集是一组不同的字符,如拉丁字母。不假定特定的顺序。在英语中,虽然我们说“a”在字母表中比“z”早,但我们不会说“a”比“z”少。“电话簿”的排序将“麦克菲”放在“麦克雷”之前,这表明“字母排序”对角色来说并不重要。
剧目指定了角色的名字,通常还有角色的样片。例如,字母“a”可能看起来像“a”、“a”或“a”。但这并不强迫它们看起来像那样——它们只是样品。剧目可能会作出区分,如大写和小写,以便“A”和“A”是不同的。但它可能认为它们是相同的,只是样品外观不同。(就像一些编程语言将大写和小写视为不同一样——Go——但一些不这样——Basic。).另一方面,一个汇编可能包含具有相同样本外观的不同字符:一个希腊数学家的汇编可能有两个具有π外观的不同字符。这也称为非编码字符集。
字符二进制码
字符代码是从字符到整数的映射。字符集的映射也称为编码字符集或代码集。这种映射中每个字符的值通常称为代码点。ASCII 是一种代码集。“A”的码位是 97,而“A”的码位是 65(十进制)。
字符代码仍然是一个抽象概念。它还不是我们将在文本文件或 TCP 包中看到的。然而,它越来越接近了,因为它提供了从面向人的概念到数字概念的映射。
字符编码
为了交流或存储一个字符,你需要以某种方式对它进行编码。要传输一个字符串,需要对字符串中的所有字符进行编码。任何代码集都有许多可能的编码。
例如,7 位 ASCII 码位可以编码为 8 位字节(一个八位字节)。因此,ASCII“A”(代码点为 65)被编码为 8 位二进制八位数 01000001。然而,另一种不同的编码方式是将高位用于奇偶校验。例如,对于奇数奇偶校验,ASCII“A”将是八位字节 11000001。一些协议如 Sun 的 XDR 使用 32 位字长编码。ASCII“A”将被编码为 0000000000000000000000000001000001。
字符编码是我们在编程层面的功能。我们的程序处理编码字符。显然,我们是处理带或不带奇偶校验的 8 位字符,还是处理 32 位字符是有区别的。
编码扩展到字符串。“ABC”的字长偶校验编码可能是 10000000(高位字节中的奇偶校验位)0100000011(C)01000010(B)01000001(低位字节中的 A)。关于编码重要性的评论同样适用于字符串,只是规则可能有所不同。
传输编码
字符编码足以在单个应用程序中处理字符。然而,一旦你开始在应用程序之间发送文本,那么就有一个更进一步的问题,如何将字节、缩写或单词放到网络上。编码可以基于节省空间和带宽的技术,如压缩文本。或者可以简化为 7 位格式,以允许奇偶校验位,例如 base64。
如果我们知道字符和传输编码,那么管理字符和字符串就是编程的问题了。如果我们不知道字符或传输编码,那么如何处理任何特定的字符串就只能靠猜测了。文件没有约定来表示字符编码。
然而,在通过互联网传输的文本中有一个信令编码的惯例。很简单:文本消息的报头包含编码信息。例如,HTTP 头可以包含如下行:
Content-Type: text/html; charset=ISO-8859-4
Content-Encoding: gzip
这表明字符集是 ISO 8859-4(对应于欧洲的某些国家)的默认编码,但是后来被压缩了。第二部分—内容编码—我们称之为“传输编码”(IETF RFC 2130)。
但是你如何阅读这些信息呢?不是被编码了吗?我们不是有一个先有鸡还是先有蛋的局面吗?不,约定是这样的信息以 ASCII(准确地说是 US ASCII)给出,这样程序可以读取文件头,然后为文档的其余部分调整它的编码。
美国信息交换标准代码
ASCII 包含英文字符、数字、标点符号和一些控制字符。这个熟悉的表格给出了 ASCII 的代码点:
Oct Dec Hex Char Oct Dec Hex Char
------------------------------------------------------------
000 0 00 NUL '¥0' 100 64 40 @
001 1 01 SOH 101 65 41 A
002 2 02 STX 102 66 42 B
003 3 03 ETX 103 67 43 C
004 4 04 EOT 104 68 44 D
005 5 05 ENQ 105 69 45 E
006 6 06 ACK 106 70 46 F
007 7 07 BEL '\a' 107 71 47 G
010 8 08 BS '\b' 110 72 48 H
011 9 09 HT '\t' 111 73 49 I
012 10 0A LF '\n' 112 74 4A J
013 11 0B VT '\v' 113 75 4B K
014 12 0C FF '\f' 114 76 4C L
015 13 0D CR '\r' 115 77 4D M
016 14 0E SO 116 78 4E N
017 15 0F SI 117 79 4F O
020 16 10 DLE 120 80 50 P
021 17 11 DC1 121 81 51 Q
022 18 12 DC2 122 82 52 R
023 19 13 DC3 123 83 53 S
024 20 14 DC4 124 84 54 T
025 21 15 NAK 125 85 55 U
026 22 16 SYN 126 86 56 V
027 23 17 ETB 127 87 57 W
030 24 18 CAN 130 88 58 X
031 25 19 EM 131 89 59 Y
032 26 1A SUB 132 90 5A Z
033 27 1B ESC 133 91 5B [
034 28 1C FS 134 92 5C \
035 29 1D GS 135 93 5D ]
036 30 1E RS 136 94 5E ^
037 31 1F US 137 95 5F _
040 32 20 SPACE 140 96 60 `
041 33 21 ! 141 97 61 a
042 34 22 " 142 98 62 b
043 35 23 # 143 99 63 c
044 36 24 $ 144 100 64 d
045 37 25 % 145 101 65 e
046 38 26 & 146 102 66 f
047 39 27 ' 147 103 67 g
050 40 28 ( 150 104 68 h
051 41 29 ) 151 105 69 i
052 42 2A * 152 106 6A j
053 43 2B + 153 107 6B k
054 44 2C , 154 108 6C l
055 45 2D - 155 109 6D m
056 46 2E . 156 110 6E n
057 47 2F / 157 111 6F o
060 48 30 0 160 112 70 p
061 49 31 1 161 113 71 q
062 50 32 2 162 114 72 r
063 51 33 3 163 115 73 s
064 52 34 4 164 116 74 t
065 53 35 5 165 117 75 u
066 54 36 6 166 118 76 v
067 55 37 7 167 119 77 w
070 56 38 8 170 120 78 x
071 57 39 9 171 121 79 y
072 58 3A : 172 122 7A z
073 59 3B ; 173 123 7B {
074 60 3C < 174 124 7C |
075 61 3D = 175 125 7D }
076 62 3E > 176 126 7E ∼
077 63 3F ? 177 127 7F DEL
(一个有趣的四列版本在罗比的垃圾,四列 ASCII 在 https://garbagecollected.org/2017/01/31/four-column-ascii/
。)
ASCII 最常见的编码使用代码点作为 7 位字节,因此例如“A”的编码是 65。
这套其实是美国 ASCII。由于欧洲人对重音字符的需求,一些标点符号被省略以形成最小集合,ISO 646,而有适合欧洲字符的“国家变体”。朱卡·科尔佩拉的网站 http://www.cs.tut.fi/~jkorpela/chars.html
为感兴趣的人提供了更多信息。但是,对于本书中的工作,您不需要这些变体。
ISO 8859
八位字节现在是字节的标准大小。这为 ASCII 扩展提供了 128 个额外的代码点。许多不同的代码集,以捕捉欧洲各种语言子集的剧目是 ISO 8859 系列。ISO 8859-1 也被称为拉丁语-1,涵盖西欧的许多语言,而本系列中的其他语言涵盖欧洲其他地区,甚至希伯来语、阿拉伯语和泰语。例如,ISO 8859-5 包括俄国等国家的西里尔字符,而 ISO 8859-8 包括希伯来字母。
这些字符集的标准编码是使用它们的码位作为 8 位值。例如,ISO 8859-1 的字符“Á”的代码点为 193,编码为 193。所有 ISO 8859 序列的底部 128 个值都与 ASCII 相同,因此所有这些集合中的 ASCII 字符都是相同的。
用来推荐 ISO 8859-1 字符集的 HTML 规范。HTML 3.2 是最后一个这样做的,之后 HTML 4.0 推荐了 Unicode。2008 年,谷歌估计它看到的网页中,大约 20%仍然是 ISO 8859 格式,20%仍然是 ASCII 格式(见http://googleblog.blogspot.com/2010/01/unicode-nearing-50-of-web.html
“Unicode 接近 50%的网页”)。更多背景信息参见 http://pinyin.info/news/2015/utf-8-unicode-vs-other-encodings-over-time/
和 https://w3techs.com/technologies/history_overview/character_encoding
。
统一码
ASCII 和 ISO 8859 都没有涵盖基于象形文字的语言。据估计,中文大约有 20,000 个独立的汉字,其中大约有 5,000 个是通用的。这些需要不止一个字节,通常使用两个字节。这种双字节字符集有很多:中文的 Big5、EUC-TW、GB2312 和 GBK/GBX,日文的 JIS X 0208 等等。这些编码通常不相互兼容。
Unicode 是一种兼容的标准字符集,旨在涵盖所有正在使用的主要字符集。它包括欧洲、亚洲、印度等等。现在已经到了 9.0 版本,有 128,172 个字符。代码点的数量现在超过了 65,536。这比 2¹⁶.还多这对字符编码有影响。
前 256 个码位对应于 ISO 8859-1,前 128 个是美国 ASCII 码。这样就有了与这些主要字符集的向后兼容性,因为 ISO 8859-1 和 ASCII 的码位在 Unicode 中完全相同。对于其他字符集来说,情况就不一样了:例如,虽然大部分 Big5 字符也是 Unicode 的,但是代码点却不一样。网站 http://moztw.org/docs/big5/table/unicode1.1-obsolete.txt
包含一个从 Big5 到 Unicode 的(大)表映射的例子。
要在计算机系统中表示 Unicode 字符,必须使用编码。编码 UCS 是使用 Unicode 字符的码位值的双字节编码。但是,由于现在 Unicode 中的字符太多,无法全部放入 2 个字节,这种编码已经过时,不再使用。相反,有:
- UTF-32 是一种 4 字节编码,但并不常用,HTML 5 明确警告不要使用它。
- UTF-16 将最常见的字符编码成 2 个字节,另外 2 个字节用于“溢出”,ASCII 和 ISO 8859-1 具有通常的值。
- UTF-8 使用每个字符 1 到 4 个字节,ASCII 具有通常的值(但不是 ISO 8859-1)。
- UTF-7 有时会使用,但并不常见。
UTF 8 号,走,还有符文
UTF 8 是最常用的编码。谷歌估计,在 2008 年,它看到的 50%的网页是用 UTF-8 编码的,而且这个比例还在增加。ASCII 集在 UTF-8 中具有相同的编码值,因此 UTF-8 阅读器可以阅读仅由 ASCII 字符组成的文本以及完整 Unicode 集中的文本。
Go 在其字符串中使用 UTF-8 编码的字符。每个字符的类型都是rune
。这是 int32 的别名。在 UTF-8 编码中,一个 Unicode 字符最多可以有 4 个字节,因此需要 4 个字节来表示所有字符。就字符而言,字符串是一个符文数组,每个符文使用 1、2 或 4 个字节。
字符串也是一个字节数组,但是你必须小心:只有对于 ASCII 子集,一个字节等于一个字符。所有其他字符占用 2、3 或 4 个字节。这意味着字符(符文)中字符串的长度通常与其字节数组的长度不同。只有当字符串仅由 ASCII 字符组成时,它们才相等。
下面的程序片段说明了这一点。如果您获取一个 UTF-8 字符串并测试它的长度,您将获得底层字节数组的长度。但是如果你将字符串转换成一个符文数组[]rune
,那么你会得到一个 Unicode 码位数组,它通常是字符数:
str := "百度一下, 你就知道"
println("String length", len([]rune(str)))
println("Byte length", len(str))
prints
String length 9
Byte length 27
Go 博客(见 https://blog.golang.org/strings
)给出了关于琴弦和符文更详细的解释。
UTF-8 客户端和服务器
可能令人惊讶的是,您不需要做任何特殊的事情来处理客户端或服务器中的 UTF-8 文本。Go 中 UTF-8 字符串的底层数据类型是一个字节数组,正如我们刚刚看到的,Go 会根据需要将字符串编码成 1、2、3 或 4 个字节。字符串的长度就是字节数组的长度,所以你可以通过写字节数组来写任何 UTF-8 字符串。
类似地,要读取一个字符串,只需读入一个字节数组,然后使用string([]byte)
将该数组转换为一个字符串。如果 Go 不能正确地将字节解码成 Unicode 字符,那么它给出 Unicode 替换字符\uFFFD
。结果字节数组的长度是字符串合法部分的长度。
因此,前几章给出的客户机和服务器可以很好地处理 UTF-8 编码的文本。
ASCII 客户端和服务器
ASCII 字符在 ASCII 和 UTF-8 中具有相同的编码。所以普通的 UTF-8 字符处理对 ASCII 字符来说很好。不需要进行特殊处理。
UTF-16 和 Go
UTF-16 处理短 16 位无符号整数数组。utf16 软件包就是为管理这样的数组而设计的。要将一个普通的 Go 字符串(即 UTF-8 字符串)转换成 UTF-16,首先通过将它强制转换成一个[]rune
来提取代码点,然后使用utf16.Encode
来产生一个 uint16 类型的数组。
类似地,要将一个无符号的短 UTF-16 值数组解码成一个 Go 字符串,可以使用utf16.Decode
将其转换成类型为[]rune
的码位,然后转换成一个字符串。以下代码片段说明了这一点:
str := "百度一下, 你就知道"
runes := utf16.Encode([]rune(str))
ints := utf16.Decode(runes)
str = string(ints)
这些类型转换需要由客户机或服务器适当地应用,以读取和写入 16 位短整数,如下所示。
小端和大端
可惜 UTF-16 背后潜伏着一个小恶魔。它基本上是将字符编码成 16 位短整数。大问题是:对于每个 short,如何写成两个字节?先顶一个,还是先顶一个第二?只要接收方使用与发送方相同的约定,任何一种方式都可以。
Unicode 通过一种称为 BOM(字节顺序标记)的特殊字符解决了这个问题。这是一个零宽度的非打印字符,所以你永远不会在文本中看到它。但是它的值0xfffe
是这样选择的,这样您就可以知道字节顺序:
- 在大端系统中,它是 FF FE
- 在小端系统中,它是 FE FF
文本有时会将 BOM 作为文本中的第一个字符。然后,读取器可以检查这两个字节,以确定使用了什么字节序。
UTF-16 客户端和服务器
使用 BOM 约定,您可以编写一个服务器,预先计划一个 BOM,并以 UTF-16 格式编写一个字符串作为UTF16Server.go
:
/* UTF16 Server
*/
package main
import (
"fmt"
"net"
"os"
"unicode/utf16"
)
const BOM = '\ufffe'
func main() {
service := "0.0.0.0:1210"
tcpAddr, err := net.ResolveTCPAddr("tcp", service)
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
str := "j'ai arrêté"
shorts := utf16.Encode([]rune(str))
writeShorts(conn, shorts)
conn.Close() // we're finished
}
}
func writeShorts(conn net.Conn, shorts []uint16) {
var bytes [2]byte
// send the BOM as first two bytes
bytes[0] = BOM >> 8
bytes[1] = BOM & 255
_, err := conn.Write(bytes[0:])
if err != nil {
return
}
for _, v := range shorts {
bytes[0] = byte(v >> 8)
bytes[1] = byte(v & 255)
_, err = conn.Write(bytes[0:])
if err != nil {
return
}
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
而读取字节流、提取并检查 BOM,然后解码流的其余部分的客户端是UTF16Client.go
:
/* UTF16 Client
*/
package main
import (
"fmt"
"net"
"os"
"unicode/utf16"
)
const BOM = '\ufffe'
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host:port")
os.Exit(1)
}
service := os.Args[1]
conn, err := net.Dial("tcp", service)
checkError(err)
shorts := readShorts(conn)
ints := utf16.Decode(shorts)
str := string(ints)
fmt.Println(str)
os.Exit(0)
}
func readShorts(conn net.Conn) []uint16 {
var buf [512]byte
// read everything into the buffer
n, err := conn.Read(buf[0:2])
for true {
m, err := conn.Read(buf[n:])
if m == 0 || err != nil {
break
}
n += m
}
checkError(err)
var shorts []uint16
shorts = make([]uint16, n/2)
if buf[0] == 0xff && buf[1] == 0xfe {
// big endian
for i := 2; i < n; i += 2 {
shorts[i/2] = uint16(buf[i])<<8 + uint16(buf[i+1])
}
} else if buf[1] == 0xff && buf[0] == 0xfe {
// little endian
for i := 2; i < n; i += 2 {
shorts[i/2] = uint16(buf[i+1])<<8 + uint16(buf[i])
}
} else {
// unknown byte order
fmt.Println("Unknown order")
}
return shorts
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
客户端打印服务器发送的"j'ai arrêté"
。
Unicode 哥特式
这本书不是关于 i18n 问题的。特别是,我们不想深究 Unicode 的神秘领域。但是你应该知道 Unicode 不是一个简单的编码,有很多复杂的地方。例如,一些早期的字符集使用非空格字符,尤其是重音字符。这是 Unicode 中引入的,因此您可以用两种方式生成重音字符:作为单个 Unicode 字符,或者作为一对非空格重音加非重音字符。例如 U+04D6,“西里尔文大写字母 ie 带短音符”是单个字符,。相当于 U+0415,“西里尔文大写字母 ie”结合短音符 U+0306“结合短音符”。这使得字符串比较有时很困难。这可能是一些非常难以理解的错误的原因。
Go 实验树中有一个叫golang.org/x/text/unicode/norm
的包,可以规格化 Unicode 字符串。它可以安装到您的 Go 软件包树中:
go get golang.org/x/text/unicode/norm
请注意,它是“子库”Go 项目树中的一个包,可能不稳定。
实际上有四种标准的 Unicode 形式。最常见的是 NFC。一个字符串可以通过norm.NFC.String(str)
转换成 NFC 形式。下面这个名为norm.go
的程序以两种方式形成的字符串,一种是单个字符,另一种是组合字符,并打印字符串、它们的字节,然后是规范化形式及其字节。
package main
import (
"fmt"
"golang.org/x/text/unicode/norm"
)
func main() {
str1 := "\u04d6"
str2 := "\u0415\u0306"
norm_str2 := norm.NFC.String(str2)
bytes1 := []byte(str1)
bytes2 := []byte(str2)
norm_bytes2 := []byte(norm_str2)
fmt.Println("Single char ", str1, " bytes ", bytes1)
fmt.Println("Composed char ", str2, " bytes ", bytes2)
fmt.Println("Normalized char", norm_str2, " bytes ", norm_bytes2)
}
以下是输出:
Single char
bytes
Composed char
bytes
Normalized char
bytes
ISO 8859 和 Go
ISO 8859 系列是 8 位字符集,适用于欧洲的不同地区和其他一些地区。它们在底部都有相同的 ASCII 集,但在顶部有所不同。据谷歌称,ISO 8859 代码约占其所见网页的 20%,但现在这一比例已经下降。
第一个代码是 ISO 8859-1 或 Latin-1,其前 256 个字符与 Unicode 相同。Latin-1 字符的编码值在 UTF-16 和默认的 ISO 8859-1 编码中是相同的。但这实际上没有多大帮助,因为 UTF-16 是 16 位编码,而 ISO 8859-1 是 8 位编码。UTF-8 是一种 8 位编码,但它使用最高位来表示额外的字节,因此 UTF-8 和 ISO 8859-1 只有 ASCII 子集重叠。所以 UTF-8 也帮不上什么忙。
但是 ISO 8859 系列没有任何复杂的问题。每个字符集中的每个字符对应一个唯一的 Unicode 字符。例如,在 ISO 8859-2,字符“带 ogonek 的拉丁文大写字母 I”具有 ISO 8859-2 码位 0xc7(十六进制)和相应的 Unicode 码位 U+012E。ISO 8859 集和相应的 Unicode 字符之间的转换本质上只是一个查找表的过程。
从 ISO 8859 码点到 Unicode 码点的表可以作为 256 个整数的数组来完成。但是其中许多将具有与索引相同的值。所以我们只是使用一个不同的映射,那些不在映射中的就取索引值。
ISO 8859-2 地图的一部分如下:
var unicodeToISOMap = map[int] uint8 {
0x12e: 0xc7,
0x10c: 0xc8,
0x118: 0xca,
// plus more
}
将 UTF-8 字符串转换为 ISO 8859-2 字节数组的函数如下:
/* Turn a UTF-8 string into an ISO 8859 encoded byte array
*/
func unicodeStrToISO(str string) []byte {
// get the unicode code points
codePoints := []int(str)
// create a byte array of the same length
bytes := make([]byte, len(codePoints))
for n, v := range(codePoints) {
// see if the point is in the exception map
iso, ok := unicodeToISOMap[v]
if !ok {
// just use the value
iso = uint8(v)
}
bytes[n] = iso
}
return bytes
}
以类似的方式,您可以将 ISO 8859-2 字节数组更改为 UTF-8 字符串:
var isoToUnicodeMap = map[uint8] int {
0xc7: 0x12e,
0xc8: 0x10c,
0xca: 0x118,
// and more
}
func isoBytesToUnicode(bytes []byte) string {
codePoints := make([]int, len(bytes))
for n, v := range(bytes) {
unicode, ok :=isoToUnicodeMap[v]
if !ok {
unicode = int(v)
}
codePoints[n] = unicode
}
return string(codePoints)
}
这些函数可以用来读写 ISO 8859-2 字节形式的 UTF-8 字符串。通过改变映射表,可以覆盖其他 ISO 8859 码。Latin-1,或 ISO 8859-1,是一个特例—异常映射为空,因为 Latin-1 的代码点在 Unicode 中是相同的。您也可以对基于表映射的其他字符集使用相同的技术,比如 Windows 1252。
其他字符集和 Go
有非常多的字符集编码。根据谷歌的说法,这些通常只在网络文档中有很小的用途,随着时间的推移,有望进一步减少。但是如果你的软件想要占领所有的市场,那么你可能需要处理它们。
在最简单的情况下,查找表就足够了。但这并不总是奏效。字符编码 ISO 2022 通过使用有限状态机交换代码页来最小化字符集的大小。这是借用了一些日本编码,使事情变得非常复杂。
Go 目前只对“子库”包树中的其他字符集提供包支持。例如,包golang.org/x/text/encoding/japanese
处理 EUC-JP 和 Shift JIS。
结论
这一章没有太多代码。相反,出现了一些非常复杂领域的概念。这取决于你:如果你想假设每个人都说美国英语,那么这个世界很简单。但是,如果您希望您的应用程序可供世界其他地方使用,您需要注意这些复杂性。
七、安全
尽管互联网最初被设计为一个抵御敌对代理攻击的系统,但它发展成为一个相对可信的实体的合作环境。唉,那些日子早就过去了。垃圾邮件、拒绝服务(DoS)攻击、网络钓鱼企图等等都表明,任何人使用互联网都要自担风险。
应用程序必须构建为在敌对环境中正常工作。“正确地”不再仅仅意味着程序功能方面的正确,还意味着确保传输数据的隐私性和完整性,只允许合法用户访问,以及其他安全问题。
这当然会使你的程序更加复杂。在使应用程序安全的过程中,涉及到一些困难而微妙的计算问题。尝试自己去做(比如自己编加密库)通常注定会失败。相反,您需要使用安全专业人员设计的库。
如果这让事情变得更困难,你为什么要烦恼呢?几乎每天都有关于泄露信用卡信息的报道,关于政府官员运行的私人服务器被黑客攻击的报道,以及关于系统因拒绝服务攻击而瘫痪的报道。这些攻击中有许多可能是由于面向网络的应用程序中的编码错误造成的,如缓冲区溢出、跨站点脚本和 SQL 注入。但是大量的错误可以追溯到糟糕的网络处理:密码以纯文本形式传递,安全凭证被请求但没有被检查,以及仅仅信任你所处的环境。例如,一位同事最近购买了一台家用物联网设备。他使用 wireshark 查看它在自己的网络上做了什么,发现它正在用认证令牌admin.admin
发送 RTMP 消息。一个简单的攻击媒介,甚至不需要破解密码!一家知名公司制造的无人机使用了存在已知缺陷的加密技术,可以被其他无人机“窃取”。一种越来越常见的窃取数据的方法是充当“流氓”无线接入点,假装是当地咖啡店的合法接入点,但监控通过的一切,包括您的银行账户详细信息。这些都是“低垂的果实”。数据泄露的范围由 http://www.informationisbeautiful.net/visualizations/worlds-biggest-data-breaches-hacks/
的“全球最大数据泄露”显示。
本章介绍了 Go 提供的基本加密工具,您可以将这些工具构建到您的应用程序中。如果你不这样做,你的公司损失了 100 万美元——或者更糟,你的客户损失了 100 万美元——那么责任就会回到你身上。
ISO 安全架构
分布式系统的 ISO OSI(开放系统互连)七层模型是众所周知的,并在图 7-1 中重复。
图 7-1。
The OSI seven-layer model of distributed systems
不太为人所知的是,ISO 在这个架构上建立了一系列的文档。对于我们这里的目的,最重要的是 ISO 安全架构模型,ISO 7498-2。这需要购买,但是 ITU 已经制定了一个技术上与之一致的文件,X.800,可以从 ITU 的 https://www.itu.int/rec/dologin_pub.asp?lang=e&id=T-REC-X.800-199103-I!!PDF-E&type=items
获得。
功能和级别
安全系统所需的主要功能如下:
- 认证:身份证明
- 数据完整性:数据没有被篡改
- 机密性:数据不会暴露给其他人
- 公证/签名
- 访问控制
- 保证/可用性
这些是 OSI 堆栈的以下级别所必需的:
- 对等实体认证(3,4,7)
- 数据源认证(3,4,7)
- 访问控制服务(3,4,7)
- 连接机密性(1,2,3,4,6,7)
- 无连接机密性(1,2,3,4,6,7)
- 选择性字段机密性(6,7)
- 流量机密性(1,3,7)
- 恢复连接完整性(4,7)
- 无恢复的连接完整性(4,7)
- 连接完整性选择字段(7)
- 无连接完整性选择字段(7)
- 起源时的不可否认性(7)
- 收据的不可否认性(7)
机制
实现这种安全级别的机制如下:
- 对等实体认证
- 加密
- 数字签名
- 认证交换
- 数据源认证
- 加密
- 数字签名
- 访问控制服务
- 访问控制列表
- 密码
- 功能列表
- 标签
- 连接保密性
- 加密
- 路由控制
- 无连接保密性
- 加密
- 路由控制
- 选择性字段保密
- 加密
- 交通流量保密性
- 加密
- 交通填充
- 路由控制
- 恢复连接完整性
- 加密
- 数据完整性
- 无恢复的连接完整性
- 加密
- 数据完整性
- 连接完整性选择字段
- 加密
- 数据完整性
- 无连接完整性
- 加密
- 数字签名
- 数据完整性
- 无连接完整性选择字段
- 加密
- 数字签名
- 数据完整性
- 原始不可否认性
- 数字签名
- 数据完整性
- 公证
- 收据的不可否认性
- 数字签名
- 数据完整性
- 公证
数据完整性
确保数据完整性意味着提供一种测试数据未被篡改的方法。通常,这是通过将数据中的字节组成一个简单的数字来实现的。这个过程称为散列,得到的数字称为散列或散列值。
一个简单的哈希算法只是将数据中的所有字节相加。但是,这仍然允许几乎任何数量的数据更改,并且仍然保留哈希值。例如,攻击者可以交换两个字节。这保留了哈希值,但最终您可能欠某人 65,536 美元,而不是 256 美元。
用于安全目的的散列算法必须是“强有力的”,这样攻击者就很难找到具有相同散列值的不同字节序列。这使得很难根据攻击者的目的修改数据。安全研究人员一直在测试哈希算法,看看他们是否能破解它们——也就是说,找到一种简单的方法,用字节序列来匹配哈希值。他们设计了一系列被认为是强大的加密哈希算法。
Go 支持多种哈希算法,包括 MD4、MD5、RIPEMD-160、SHA1、SHA224、SHA256、SHA384 和 SHA512。就 Go 程序员而言,它们都遵循相同的模式:适当包中的函数New
(或类似函数)从hash
包中返回一个Hash
对象。
一个hash
有一个io.Writer
,你把要哈希的数据写到这个 writer。可以通过Size
查询哈希值中的字节数,通过Sum
查询哈希值。
一个典型的例子是 MD5 散列法。这使用了md5
包。哈希值是一个 16 字节的数组。这通常以 ASCII 形式打印为四个十六进制数,每个由四个字节组成。一个简单的程序是MD5Hash.go
:
/* MD5Hash
*/
package main
import (
"crypto/md5"
"fmt"
)
func main() {
hash := md5.New()
bytes := []byte("hello\n")
hash.Write(bytes)
hashValue := hash.Sum(nil)
hashSize := hash.Size()
for n := 0; n < hashSize; n += 4 {
var val uint32
val = uint32(hashValue[n])<<24 +
uint32(hashValue[n+1])<<16 +
uint32(hashValue[n+2])<<8 +
uint32(hashValue[n+3])
fmt.Printf("%x ", val)
}
fmt.Println()
}
这个程序打印"b1946ac9 2492d234 7c6235b4 d2611184"
。
对此的一种变体是 HMAC(键控散列消息验证码),它向散列算法添加了一个密钥。用这个变化不大。要将 MD5 散列与密钥一起使用,请将对hash := md5.New()
的调用替换为:
hash := hmac.New(md5.New, []byte("secret"))
对称密钥加密
有两种主要的数据加密机制。对称密钥加密使用加密和解密都相同的单个密钥。加密和解密代理都需要知道这个密钥。没有讨论这个密钥如何在代理之间传输。
与哈希算法一样,加密算法也有很多种。现在已知许多算法都有弱点,一般来说,随着时间的推移,随着计算机速度的提高,算法会变得越来越弱。Go 支持多种对称密钥算法,如 AES 和 DES。
这些算法是块算法。也就是说,他们处理数据块。如果您的数据与块大小不一致,您必须在末尾用额外的空白填充它。
每个算法由一个Cipher
对象表示。这是由NewCipher
在适当的包中创建的,并以对称密钥作为参数。
一旦有了密码,就可以用它来加密和解密数据块。我们使用 AES-128,其密钥大小为 128 位(16 字节),块大小为 128 位。密钥的大小决定了使用哪个版本的 AES。说明这一点的一个程序是Aes.go
:
/* Aes
*/
package main
import (
"bytes"
"crypto/aes"
"fmt"
)
func main() {
key := []byte("my key, len 16 b")
cipher, err := aes.NewCipher(key)
if err != nil {
fmt.Println(err.Error())
}
src := []byte("hello 16 b block")
var enc [16]byte
cipher.Encrypt(enc[0:], src)
var decrypt [16]byte
cipher.Decrypt(decrypt[0:], enc[0:])
result := bytes.NewBuffer(nil)
result.Write(decrypt[0:])
fmt.Println(string(result.Bytes()))
}
这使用共享的 16 字节密钥"my key, len 16 b"
对 16 字节块"hello 16 b block"
进行加密和解密。
公钥加密
另一种主要的加密类型是公钥加密。公钥加密和解密需要两个密钥:一个用于加密,另一个用于解密。加密密钥通常以某种方式公开,这样任何人都可以加密发送给你的消息。解密密钥必须保密;否则,每个人都可以解密这些消息!公钥系统是非对称的,不同的密钥用于不同的用途。
Go 支持的公钥加密系统有很多。一个典型的例子是 RSA 方案。
从随机数生成 RSA 私钥和公钥的程序是GenRSAKeys.go
:
/* GenRSAKeys
*/
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/gob"
"encoding/pem"
"fmt"
"os"
)
func main() {
reader := rand.Reader
bitSize := 512
key, err := rsa.GenerateKey(reader, bitSize)
checkError(err)
fmt.Println("Private key primes", key.Primes[0].String(), key.Primes[1].String())
fmt.Println("Private key exponent", key.D.String())
publicKey := key.PublicKey
fmt.Println("Public key modulus", publicKey.N.String())
fmt.Println("Public key exponent", publicKey.E)
saveGobKey("private.key", key)
saveGobKey("public.key", publicKey)
savePEMKey("private.pem", key)
}
func saveGobKey(fileName string, key interface{}) {
outFile, err := os.Create(fileName)
checkError(err)
encoder := gob.NewEncoder(outFile)
err = encoder.Encode(key)
checkError(err)
outFile.Close()
}
func savePEMKey(fileName string, key *rsa.PrivateKey) {
outFile, err := os.Create(fileName)
checkError(err)
var privateKey = &pem.Block{Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key)}
pem.Encode(outFile, privateKey)
outFile.Close()
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
该程序还使用gob
序列化来保存证书。它们可以被LoadRSAKeys.go
程序读回:
/* LoadRSAKeys
*/
package main
import (
"crypto/rsa"
"encoding/gob"
"fmt"
"os"
)
func main() {
var key rsa.PrivateKey
loadKey("private.key", &key)
fmt.Println("Private key primes", key.Primes[0].String(), key.Primes[1].String())
fmt.Println("Private key exponent", key.D.String())
var publicKey rsa.PublicKey
loadKey("public.key", &publicKey)
fmt.Println("Public key modulus", publicKey.N.String())
fmt.Println("Public key exponent", publicKey.E)
}
func loadKey(fileName string, key interface{}) {
inFile, err := os.Open(fileName)
checkError(err)
decoder := gob.NewDecoder(inFile)
err = decoder.Decode(key)
checkError(err)
inFile.Close()
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
X.509 证书
公钥基础设施(PKI)是一个框架,用于收集公钥,以及其他信息,如所有者姓名和位置,以及它们之间的链接,从而提供某种批准机制。
目前使用的主要 PKI 基于 X.509 证书。例如,web 浏览器使用它们来验证网站的身份。
为我的网站生成自签名 X.509 证书并将其存储在一个.cer
文件中的示例程序是GenX509Cert.go
:
/* GenX509Cert
*/
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/gob"
"encoding/pem"
"fmt"
"math/big"
"os"
"time"
)
func main() {
random := rand.Reader
var key rsa.PrivateKey
loadKey("private.key", &key)
now := time.Now()
then := now.Add(60 * 60 * 24 * 365 * 1000 * 1000 * 1000) // one year
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "jan.newmarch.name",
Organization: []string{"Jan Newmarch"},
},
NotBefore: now,
NotAfter: then,
SubjectKeyId: []byte{1, 2, 3, 4},
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
DNSNames: []string{"jan.newmarch.name", "localhost"},
}
derBytes, err := x509.CreateCertificate(random, &template,
&template, &key.PublicKey, &key)
checkError(err)
certCerFile, err := os.Create("jan.newmarch.name.cer")
checkError(err)
certCerFile.Write(derBytes)
certCerFile.Close()
certPEMFile, err := os.Create("jan.newmarch.name.pem")
checkError(err)
pem.Encode(certPEMFile, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
certPEMFile.Close()
keyPEMFile, err := os.Create("private.pem")
checkError(err)
pem.Encode(keyPEMFile, &pem.Block{Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(&key)})
keyPEMFile.Close()
}
func
loadKey(fileName string, key interface{}) {
inFile, err := os.Open(fileName)
checkError(err)
decoder := gob.NewDecoder(inFile)
err = decoder.Decode(key)
checkError(err)
inFile.Close()
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
这可以由LoadX509Cert.go
回读:
/* LoadX509Cert
*/
package main
import (
"crypto/x509"
"fmt"
"os"
)
func main() {
certCerFile, err := os.Open("jan.newmarch.name.cer")
checkError(err)
derBytes := make([]byte, 1000) // bigger than the file
count, err := certCerFile.Read(derBytes)
checkError(err)
certCerFile.Close()
// trim the bytes to actual length in call
cert, err := x509.ParseCertificate(derBytes[0:count])
checkError(err)
fmt.Printf("Name %s\n", cert.Subject.CommonName)
fmt.Printf("Not before %s\n", cert.NotBefore.String())
fmt.Printf("Not after %s\n", cert.NotAfter.String())
}
func checkError(err error
) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
坦克激光瞄准镜(Tank Laser-Sight 的缩写)
如果你必须自己做所有的重活,加密/解密方案的用处是有限的。目前,互联网上支持加密消息传递的最流行的机制是 TLS(传输层安全性),其前身是 SSL(安全套接字层)。
在 TLS 中,客户端和服务器使用 X.509 证书协商身份。一旦完成,它们之间就发明了一个密钥,所有的加密/解密都是用这个密钥完成的。协商相对较慢,但一旦完成,就会使用更快的密钥机制。服务器需要有证书;如果需要,客户可以有一个。
基本客户
我们首先说明连接到一个服务器,该服务器具有由“众所周知的”认证机构(CA)如 RSA 签署的证书。从 web 服务器获取报头信息的程序可以适用于从 TLS web 服务器获取报头信息。节目是TLSGetHead.go
。(我们在这里举例说明TLS.Dial
,并将在后面的章节中讨论 HTTPS。)
/* TLSGetHead
*/
package main
import (
"crypto/tls"
"fmt"
"io/ioutil"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host:port")
os.Exit(1)
}
service := os.Args[1]
conn, err := tls.Dial("tcp", service, nil)
checkError(err)
_, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
checkError(err)
result, err := ioutil.ReadAll(conn)
checkError(err)
fmt.Println(string(result))
conn.Close()
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
当针对适当的站点运行时,例如 www.google.com:443
:
go run TLSGetHead.go www.google.com:443
它会产生如下输出:
HTTP/1.0 302 Found
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Location: https://www.google.com.au/?gfe_rd=cr&ei=L3lvWKSXMdPr8AfvhqKIBg
Content-Length: 263
Date: Fri, 06 Jan 2017 11:02:07 GMT
Alt-Svc: quic=":443"; ma=2592000; v="35,34"
其他站点可能会产生其他响应,但是这个客户端仍然很高兴已经与正确的身份验证服务器建立了 TLS 会话。
有趣的是运行这个网站 www.gooogle.com
(注意多余的 o!):
go run TLSGetHead.go www.gooogle.com:443
这个网站实际上属于谷歌,因为他们可能是为了降低欺诈风险而购买的。该程序抛出一个致命错误,因为站点证书不适用于带有三个操作系统的 gooogle:
Fatal error x509: certificate is valid for google.com, *.2mdn.net, *.android.com, *.appengine.google.com, *.au.doubleclick.net, *.cc-dt.com, *.cloud.google.com, ...
指向同一个三 o 网站的浏览器如 Firefox 也会发出安全警报。
使用自签名证书的服务器
如果服务器使用自签名证书,可能在组织内部使用或者在试验时使用,那么 Go package when 将生成一个错误:"x509: certificate signed by unknown authority"
。证书必须安装到客户端的操作系统中(这将依赖于操作系统),或者客户端必须将证书安装为根 CA。我们将展示第二种方式。
使用 TLS 和任何证书的 echo 服务器是TLSEchoServer.go
:
/* TLSEchoServer
*/
package main
import (
"crypto/rand"
"crypto/tls"
"fmt"
"net"
"os"
"time"
)
func main() {
cert, err := tls.LoadX509KeyPair("jan.newmarch.name.pem", "private.pem")
checkError(err)
config := tls.Config{Certificates: []tls.Certificate{cert}}
now := time.Now()
config.Time = func() time.Time { return now }
config.Rand = rand.Reader
service := "0.0.0.0:1200"
listener, err := tls.Listen("tcp", service, &config)
checkError(err)
fmt.Println("Listening")
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println(err.Error())
continue
}
fmt.Println("Accepted")
go handleClient(conn)
}
}
func handleClient(conn net.Conn) {
defer conn.Close()
var buf [512]byte
for {
fmt.Println("Trying to read")
n, err := conn.Read(buf[0:])
if err != nil {
fmt.Println(err)
return
}
_, err = conn.Write(buf[0:n])
if err != nil {
return
}
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
如果证书是自签名的,简单的 TLS 客户端将无法与此服务器一起工作,这里就是这样。我们需要将一个配置作为第三个参数设置为TLS.
Dial
,它将我们的证书安装为根证书。感谢 Josh Bleecher Snyder 在“获取 x509:由未知权威签署的证书”( https://groups.google.com/forum/#!topic/golang-nuts/v5ShM8R7Tdc
)中展示了如何做到这一点。然后,服务器与TLSEchoClient.go
客户机一起工作。
/* TLSEchoClient
*/
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host:port")
os.Exit(1)
}
service := os.Args[1]
// Load the PEM self-signed certificate
certPemFile, err := os.Open("jan.newmarch.name.pem")
checkError(err)
pemBytes := make([]byte, 1000) // bigger than the file
_, err = certPemFile.Read(pemBytes)
checkError(err)
certPemFile.Close()
// Create a new certificate pool
certPool := x509.NewCertPool()
// and add our certificate
ok := certPool.AppendCertsFromPEM(pemBytes)
if !ok {
fmt.Println("PEM read failed")
} else {
fmt.Println("PEM read ok")
}
// Dial, using a config
with root cert set to ours
conn, err := tls.Dial("tcp", service, &tls.Config{RootCAs: certPool})
checkError(err)
// Now write and read
lots
for n := 0; n < 10; n++ {
fmt.Println("Writing...")
conn.Write([]byte("Hello " + string(n+48)))
var buf [512]byte
n, err := conn.Read(buf[0:])
checkError(err)
fmt.Println(string(buf[0:n]))
}
conn.Close()
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
结论
安全性本身是一个很大的领域,本章几乎没有涉及到它。然而,主要的概念已经涵盖。没有强调的是在设计阶段需要构建多少安全性:事后才想到安全性几乎总是失败的。
八、HTTP
万维网是一个主要的分布式系统,拥有数百万用户。通过运行 HTTP 服务器,站点可以成为 web 主机。虽然 web 客户端通常是拥有浏览器的用户,但是还有许多其他的“用户代理”,例如 web 蜘蛛、web 应用程序客户端等等。
Web 建立在 HTTP(超文本传输协议)之上,而 HTTP 又是 TCP 之上的一层。HTTP 已经经历了四个公开的版本。版本 1.1(第三个版本)是最常用的,但是预计将会快速过渡到 HTTP/2,并且这现在占当前流量的 10%以上。
本章是 HTTP 的概述,接着是管理 HTTP 连接的 Go APIs。
URL 和资源
URL 指定资源的位置。资源通常是静态文件,如 HTML 文档、图像或声音文件。但越来越多的是,它可能是一个动态生成的对象,可能基于存储在数据库中的信息。
当用户代理请求资源时,返回的不是资源本身,而是该资源的某种表示。例如,如果资源是一个静态文件,那么发送给用户代理的就是该文件的副本。
多个 URL 可能指向同一个资源,HTTP 服务器将为每个 URL 返回适当的资源表示。例如,一家公司可能使用同一产品的不同 URL 在内部和外部提供产品信息。产品的内部表示可能包括产品的内部联系人等信息,而外部表示可能包括销售产品的商店的位置。
这种资源视图意味着 HTTP 协议可以相当简单直接,而 HTTP 服务器可以任意复杂。HTTP 必须将来自用户代理的请求传递给服务器并返回一个字节流,而服务器可能必须对请求进行大量的处理。
I18n
互联网的日益国际化带来了一些复杂的问题。主机名可能以国际化的形式给出,称为 IDN(国际化域名)。为了保持与不理解 Unicode 的遗留实现(如旧的电子邮件服务器)的兼容性,非 ASCII 域名被映射到称为 punycode 的 ASCII 表示中。例如,域名日本語。jp 具有 punycode 值xn—wgv71a119e.jp
。从非 ASCII 域到 punycode 值的转换不是由 Go net 库自动执行的(从 Go 1.7 开始),但是有一个名为golang.org/x/net/idna
的扩展包可以在 Unicode 和它的 punycode 值之间进行转换。在“弄清 IDNA 一派胡言的故事”( https://github.com/golang/go/issues/13835
)上正在进行一场关于这个话题的讨论。
国际化域名开启了所谓的同形异义词攻击的可能性。许多 Unicode 字符具有相似的外观,例如俄语 o (U+043E)、希腊语 o (U+03BF)和英语 o (U+006F)。使用同形异义词的域名,如google.com
(带有两个俄语 o,可能会引起混乱。已知有多种防御措施,比如总是显示 punycode(这里是xn—ggle-55da.com
,使用 Punycode 转换器)。
URI/URL 中的路径处理起来更复杂,因为它指的是相对于可能在特定本地化环境中运行的 HTTP 服务器的路径,编码可能不是 UTF-8,甚至不是 Unicode。IRI(国际化资源标识符)通过首先将任何本地化字符串转换为 UTF-8,然后对任何非 ASCII 字节进行百分比转义来管理这一点。名为“多语言网址介绍”( https://www.w3.org/International/articles/idn-and-iri/
)
)的 W3C 页面有更多信息。从其他编码到 UTF-8 的转换在第六章中有介绍,而 Go 在Queryescape/Queryunescape
的net
/url 和PathEscape/PathUnescape
的 Go 1.8 中有函数来做百分比转换。
HTTP 特征
HTTP 是一种无状态、无连接、可靠的协议。在最简单的形式中,来自用户代理的每个请求都被可靠地处理,然后连接被中断。
在 HTTP 的最早版本中,每个请求都涉及一个单独的 TCP 连接,所以如果需要很多资源(比如嵌入在 HTML 页面中的图像),那么就必须在很短的时间内建立和拆除很多 TCP 连接。
HTTP 1.1 在 HTTP 中加入了很多优化,在简单的结构上增加了复杂性,但却创造了更高效可靠的协议。为了进一步提高效率,HTTP/2 采用了二进制形式。
版本
HTTP 有四个版本:
- 0.9 版(1991 年):完全过时
- 版本 1.0 (1996):几乎过时
- 版本 1.1 (1999):目前最流行的版本
- 版本 2 (2015):最新版本
每个版本都必须理解早期版本的请求和响应。
HTTP 0.9
请求格式:
Request = Simple-Request
Simple-Request = "GET" SP Request-URI CRLF
响应格式
响应的形式如下:
Response = Simple-Response
Simple-Response = [Entity-Body]
HTTP 1.0
这个版本为请求和响应添加了更多的信息。而不是“增长”0.9 格式,它只是留在新版本旁边。
请求格式
从客户端到服务器的请求格式是:
Request = Simple-Request | Full-Request
Simple-Request = "GET" SP Request-URI CRLF
Full-Request = Request-Line
*(General-Header
| Request-Header
| Entity-Header)
CRLF
[Entity-Body]
一个Simple-Request
是一个 HTTP/0.9 请求,必须由一个Simple-Response
回复。
一个Request-Line
有这样的格式:
Request-Line = Method SP Request-URI SP HTTP-Version CRLF
在哪里
Method = "GET" | "HEAD" | POST |
extension-method
这里有一个例子:
GET http://jan.newmarch.name/index.html HTTP/1.0
响应格式
响应的形式如下:
Response = Simple-Response | Full-Response
Simple-Response = [Entity-Body]
Full-Response = Status-Line
*(General-Header
| Response-Header
| Entity-Header)
CRLF
[Entity-Body]
Status-Line
给出关于请求命运的信息:
Status-Line = HTTP-Version SP Status-Code SP Reason-Phrase CRLF
这里有一个例子:
HTTP/1.0 200 OK
状态行中的状态代码如下:
Status-Code = "200" ; OK
| "201" ; Created
| "202" ; Accepted
| "204" ; No Content
| "301" ; Moved permanently
| "302" ; Moved temporarily
| "304" ; Not modified
| "400" ; Bad request
| "401" ; Unauthorized
| "403" ; Forbidden
| "404" ; Not found
| "500" ; Internal server error
| "501" ; Not implemented
| "502" ; Bad gateway
| "503" | Service unavailable
| extension-code
General-
Header
通常是日期,而Response-Header
是位置、服务器或认证字段。
Entity-
Header
包含了关于Entity-Body
的有用信息,可以跟随:
Entity-Header = Allow
| Content-Encoding
| Content-Length
| Content-Type
| Expires
| Last-Modified
| extension-header
例如,(其中字段类型在//
之后给出):
HTTP/1.1 200 OK // status line
Date: Fri, 29 Aug 2003 00:59:56 GMT // general header
Server: Apache/2.0.40 (Unix) // response header
Content-Length: 1595 // entity header
Content-Type: text/html; charset=ISO-8859-1 // entity header
HTTP 1.1
HTTP 1.1 修复了 HTTP 1.0 的许多问题,但也因此变得更加复杂。这个版本是通过扩展或细化 HTTP 1.0 的可用选项来实现的。例如:
-
还有更多命令如
TRACE
和CONNECT
-
HTTP 1.1 收紧了请求 URL 的规则,以允许代理处理。如果请求是通过代理定向的,URL 应该是绝对 URL,如:
GET http://www.w3.org/index.html HTTP/1.1
否则应该使用绝对路径,并且应该包括一个
Host
头字段,如:GET /index.html HTTP/1.1 Host: www.w3.org
-
有更多的属性,如
If-Modified-Since
,也供代理使用
这些变化包括
- 主机名标识(允许虚拟主机)
- 内容协商(多种语言)
- 持久连接(减少 TCP 开销;这个很复杂)
- 分块传输
- 字节范围(文档的请求部分)
- 代理支持
HTTP/2
所有早期版本的 HTTP 都是基于文本的。HTTP/2 最大的不同在于它是一种二进制格式。为了确保向后兼容,这不能通过向旧服务器发送二进制消息来查看它做了什么来管理。取而代之的是发送一个带有额外属性的 HTTP 1.1 消息,本质上是询问服务器是否想切换到 HTTP/2。如果它不理解额外的字段,它会用一个普通的 HTTP 1.1 响应进行回复,会话继续使用 HTTP 1.1。
否则,服务器可以响应它愿意改变,并且会话可以使用 HTTP/2 继续。
0.9 协议用了一页。在大约 20 页中描述了 1.0 协议,并且包括 0.9 协议。1.1 协议需要 120 页,是对 1.0 的实质性扩展,而 HTTP/2 需要大约 96 页。HTTP/2 规范只是对 HTTP 1.1 规范的补充。
简单用户代理
浏览器等用户代理发出请求并得到响应。这涉及到 Go 类型和相关的方法调用。
响应类型
响应类型如下:
type Response struct {
Status string // e.g. "200 OK"
StatusCode int // e.g. 200
Proto string // e.g. "HTTP/1.0"
ProtoMajor int // e.g. 1
ProtoMinor int // e.g. 0
Header map[string][]string
Body io.ReadCloser
ContentLength int64
TransferEncoding []string
Close bool
Trailer map[string][]string
Request *Request // the original request
TLS *tls.ConnectionState // info about the TLS connection or nil
}
头部方法
我们通过例子来检验这种数据结构。每个 HTTP 请求类型在net/http
包中都有自己的 Go 函数。最简单的请求来自一个名为HEAD
的用户代理,它请求关于资源及其 HTTP 服务器的信息。该功能可用于进行查询:
func Head(url string) (r *Response, err error)
响应的状态在响应字段Status
中,而字段Header
是 HTTP 响应中报头字段的映射。一个名为Head.go
的程序发出这个请求并显示结果如下:
/* Head
*/
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host:port")
os.Exit(1)
}
url := os.Args[1]
response, err := http.Head(url)
if err != nil {
fmt.Println(err.Error())
os.Exit(2)
}
fmt.Println(response.Status)
for k, v := range response.Header {
fmt.Println(k+":", v)
}
os.Exit(0)
}
针对资源运行时,如下所示:
go run Head.go http://www.golang.com/
它会打印出这样的内容:
200 OK
Date: [Fri, 06 Jan 2017 11:20:37 GMT]
Server: [Google Frontend]
Content-Length: [7902]
Alt-Svc: [quic=":443"; ma=2592000; v="35,34"]
Strict-Transport-Security: [max-age=31536000; preload]
Content-Type: [text/html; charset=utf-8]
X-Cloud-Trace-Context: [6e28ebc86bb1026ae7b784c891d0117c]
响应来自我们控制之外的服务器,它可能会在途中经过其他服务器。显示的字段可能会有所不同,当然字段的值也会有所不同。
GET 方法
通常,我们想要检索一个资源的表示,而不仅仅是获取关于它的信息。GET
请求将做到这一点,并且可以使用以下方法来完成:
func Get(url string) (r *Response, finalURL string, err error)
响应的内容在类型为io.ReadCloser
的响应字段Body
中。我们可以用程序Get.go
将内容打印到屏幕上:
/* Get
*/
package main
import (
"fmt"
"net/http"
"net/http/httputil"
"os"
"strings
"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "host:port")
os.Exit(1)
}
url := os.Args[1]
response, err := http.Get(url)
if err != nil {
fmt.Println(err.Error())
os.Exit(2)
}
if response.Status != "200 OK" {
fmt.Println(response.Status)
os.Exit(2)
}
fmt.Println("The response header is")
b, _ := httputil.DumpResponse(response, false)
fmt.Print(string(b))
contentTypes := response.Header["Content-Type"]
if !acceptableCharset(contentTypes) {Arial
fmt.Println("Cannot handle", contentTypes)
os.Exit(4)
}
fmt.Println("The response body is")
var buf [512]byte
reader := response.Body
for {
n, err := reader.Read(buf[0:])
if err != nil {
os.Exit(0)
}
fmt.Print(string(buf[0:n]))
}
os.Exit(0)
}
func acceptableCharset(contentTypes []string) bool {
// each type is like [text/html; charset=utf-8]
// we want the UTF-8 only
for _, cType := range contentTypes {
if strings.Index(cType, "utf-8") != -1 {
return true
}
}
return false
}
http://www.golang.com
运行时为:
go run Get.go http://www.golang.com
响应标头是:
HTTP/2.0 200 OK
Content-Length: 7902
Alt-Svc: quic=":443"; ma=2592000; v="35,34"
Content-Type: text/html; charset=utf-8
Date: Fri, 06 Jan 2017 11:29:12 GMT
Server: Google Frontend
Strict-Transport-Security: max-age=31536000; preload
X-Cloud-Trace-Context: ea9b41b4796f379af487388b1474ed4e
响应正文是:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#375EAB">
<title>The Go Programming Language</title>
...
(注意,这是通过 HTTP/2 发送的。Go 库已经为你进行了版本协商。)
请注意,有一些重要的字符集问题,就像上一章讨论的那样。服务器将使用某种字符集编码,可能还有某种传输编码来传递内容。通常这是用户代理和服务器之间的协商问题,但是我们使用的简单的GET
命令不包括协商的用户代理部分。因此服务器可以发送它想要的任何字符编码。
第一次写作的时候,我在中国(可以访问谷歌)。当我在 www.google.com
上试用这个程序时,谷歌的服务器试图通过猜测我的位置并向我发送中文字符集 Big5 的文本来帮助我。如何告诉服务器什么样的字符编码对我来说是可以的将在后面讨论。
配置 HTTP 请求
Go 还为用户代理提供了一个与 HTTP 服务器通信的底层接口。如您所料,它不仅让您对客户机请求有更多的控制权,而且还要求您在构建请求时花费更多的精力。然而,复杂度只有很小的增加。
用于构建请求的数据类型是类型Request
。这是一个复杂的类型,我们现在只显示主要字段。省略了几个字段和完整的 Go 文档。
type Request struct {
Method string // GET, POST, PUT, etc.
URL *url.URL // Parsed URL.
Proto string // "HTTP/1.0"
ProtoMajor int // 1
ProtoMinor int // 0
// A header maps request lines to their values.
Header Header // map[string][]string
// The message body.
Body io.ReadCloser
// ContentLength records the length of the associated content.
// The value -1 indicates that the length is unknown.
// Values >= 0 indicate that the given number of bytes may be read from Body.
ContentLength int64
// TransferEncoding lists the transfer encodings from outermost to innermost.
// An empty list denotes the "identity" encoding.
TransferEncoding []string
// The host on which the URL is sought.
// Per RFC 2616, this is either the value of the Host: header
// or the host name given in the URL itself.
Host string
}
请求中可以存储许多信息。您不需要填写所有字段,只需填写感兴趣的字段。创建具有默认值的请求的最简单方法是使用,例如:
request, err := http.NewRequest("GET", url.String(), nil)
创建请求后,您可以修改字段。例如,要指定您只想接收 UTF-8,向请求添加一个Accept-Charset
字段,如下所示:
request.Header.Add("Accept-Charset", "UTF-8;q=1, ISO-8859-1;q=0")
(请注意,除非列表中明确提到,否则默认设置 ISO-8859-1 的值始终为 1。HTTP 1.1 规范可以追溯到 1999 年!)
客户端设置一个charset
请求很简单。但是对于服务器的字符集返回值会发生什么情况,还有些困惑。返回的资源应该有一个指定内容媒体类型的Content-Type
,比如text/html
。如果合适,媒体类型应该说明字符集,例如text/html; charset=UTF-8
。如果没有字符集规范,那么根据 HTTP 规范,它应该被视为默认的 ISO8859-1 字符集。但是 HTML4 规范声明,由于很多服务器不符合这一点,所以你不能做任何假设。
如果在服务器的Content-Type
中有指定的字符集,那么假设它是正确的。如果没有指定,因为超过 50%的页面是 UTF-8,有些是 ASCII,它是安全的假设 UTF-8。少于 10%的页面可能是错误的:-(。
客户端对象
要向服务器发送请求并获得回复,便利对象Client
是最简单的方法。这个对象可以管理多个请求,并将处理诸如服务器是否保持 TCP 连接活动等问题。
这在下面的程序ClientGet.go
中有说明。
该程序显示了如何添加 HTTP 头,因为我们添加头Accept-Charset
只接受 UTF-8。这里有一个小问题,是由 Go 中的一个 bug 引起的,这个 bug 只在 Go 1.8 中得到修复。如果得到 301、302、303 或 307 响应,Client.Do
函数将自动进行重定向。在 Go 1.8 之前,它不会在这个重定向中跨 HTTP 头进行复制。
如果你尝试访问一个像 http://www.google.com
这样的站点,那么它会重定向到一个像 http://www.google.com.au
这样的站点,但是会丢失Accept-Charset
头并返回 ISO8859-1(根据 1999 HTTP 1.1 规范它应该这样做!).附带条件是,该程序在 Go 1.8 之前的版本中可能不会给出正确的结果,该程序如下所示:
/* ClientGet
*/
package main
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
)
func
main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "http://host:port/page")
os.Exit(1)
}
url, err := url.Parse(os.Args[1])
checkError(err)
client := &http.Client{}
request, err := http.NewRequest("HEAD", url.String(), nil)
// only accept UTF-8
request.Header.Add("Accept-Charset", "utf-8;q=1, ISO-8859-1;q=0")
checkError(err)
response, err := client.Do(request)
checkError(err)
if response.Status != "200 OK" {
fmt.Println(response.Status)
os.Exit(2)
}
fmt.Println("The response header is")
b, _ := httputil.DumpResponse(response, false)
fmt.Print(string(b))
chSet := getCharset(response)
if chSet != "utf-8" {
fmt.Println("Cannot handle", chSet)
os.Exit(4)
}
var buf [512]byte
reader := response.Body
fmt.Println("got body")
for {
n, err := reader.Read(buf[0:])
if err != nil {
os.Exit(0)
}
fmt.Print(string(buf[0:n]))
}
os.Exit(0)
}
func getCharset(response *http.Response) string {
contentType := response.Header.Get("Content-Type")
if contentType == "" {
// guess
return "utf-8"
}
idx := strings.Index(contentType, "charset=")
if idx == -1 {
// guess
return "utf-8"
}
chSet := strings.Trim(contentType[idx+8:], " ")
return strings.ToLower(chSet)
}
func
checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
程序运行如下,例如:
go run ClientGet.go http://www.golang.com
代理处理
现在 HTTP 请求通过特定的 HTTP 代理是很常见的。这是构成 TCP 连接并在应用层起作用的服务器的补充。公司使用代理来限制他们自己的员工可以看到的内容,而许多组织使用 Cloudflare 等代理服务来充当缓存,从而减少组织自己的服务器上的负载。通过代理访问网站需要客户端进行额外的处理。
简单代理
HTTP 1.1 阐述了 HTTP 应该如何通过代理工作。应该向代理发出一个GET
请求。但是,请求的 URL 应该是目的地的完整 URL。此外,HTTP 头应该包含一个设置为代理的Host
字段。只要代理被配置为传递这样的请求,那么这就是所有需要做的事情。
Go 认为这是 HTTP 传输层的一部分。为了管理这个,它有一个类Transport
。这包含一个可以设置为返回代理 URL 的函数的字段。如果我们有一个 URL 作为代理的字符串,则会创建适当的传输对象,然后将其提供给一个客户端对象,如下所示:
proxyURL, err := url.Parse(proxyString)
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
client := &http.Client{Transport: transport}
然后,客户端可以像以前一样继续。
以下程序ProxyGet.go
说明了这一点
/* ProxyGet
*/
package main
import (
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"os"
)
func main() {
if len(os.Args) != 3 {
fmt.Println("Usage: ", os.Args[0], "http://proxy-host:port http://host:port/page")
os.Exit(1)
}
proxyString := os.Args[1]
proxyURL, err := url.Parse(proxyString)
checkError(err)
rawURL := os.Args[2]
url,err := url.Parse(rawURL)
checkError(err)
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
client := &http.Client{Transport: transport}
request, err := http.NewRequest("GET", url.String(), nil)
urlp, _ := transport.Proxy(request)
fmt.Println("Proxy ", urlp)
dump, _ := httputil.DumpRequest(request, false)
fmt.Println(string(dump))
response, err := client.Do(request)
checkError(err)
fmt.Println("Read ok")
if response.Status != "200 OK" {
fmt.Println(response.Status)
os.Exit(2)
}
fmt.Println("Response ok")
var buf [512]byte
reader := response.Body
for {
n, err := reader.Read(buf[0:])
if err != nil {
os.Exit(0)
}
fmt.Print(string(buf[0:n]))
}
os.Exit(0)
}
func checkError(err error) {
if err != nil {
if err == io.EOF {
return
}
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
如果您在端口 8080 上有一个代理,比如说XYZ.com
,您可以按如下方式进行测试:
go run ProxyGet.go http://XYZ.com:8080/ http://www.google.com
如果你没有合适的代理来测试这个,那么下载并安装 Squid 代理( http://www.squid-cache.org/
)到你自己的电脑上。
这个程序使用了一个已知的代理作为程序的参数。有许多方法可以让应用程序知道代理。大多数浏览器都有一个配置菜单,您可以在其中输入代理信息:这样的信息对于 Go 应用程序是不可用的。一些应用程序可能使用 Web 代理自动发现协议( https://en.wikipedia.org/wiki/Web_Proxy_Autodiscovery_Protocol
)从网络中某个通常称为autoproxy.pac
的文件中获取代理信息。Go 还不知道如何解析这些 JavaScript 文件,所以不能使用它们。特定的操作系统可能具有指定代理的系统特定的方式。Go 无法访问这些。但是如果在操作系统环境变量如HTTP_PROXY
或http_proxy
中设置了代理信息,它可以使用该函数找到代理信息:
func ProxyFromEnvironment(req *Request) (*url.URL, error)
如果您的程序运行在这样的环境中,您可以使用这个函数,而不必显式地知道代理参数。
认证代理
一些代理需要通过用户名和密码进行身份验证,以传递请求。一种常见的方案是“基本认证”,其中用户名和密码被连接成一个字符串"user:password"
,然后进行 Base64 编码。然后由 HTTP 请求头“Proxy-Authorization
”将它提供给代理,并标记它是基本认证
下面的程序ProxyAuthGet.go
说明了这一点,它将Proxy-Authentication
头添加到前面的代理程序中:
/* ProxyAuthGet
*/
package main
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"os"
)
const auth = "jannewmarch:mypassword"
func main() {
if len(os.Args) != 3 {
fmt.Println("Usage: ", os.Args[0], "http://proxy-host:port http://host:port/page")
os.Exit(1)
}
proxy := os.Args[1]
proxyURL, err := url.Parse(proxy)
checkError(err)
rawURL := os.Args[2]
url, err := url.Parse(rawURL)
checkError(err)
// encode the auth
basic := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
client := &http.Client{Transport: transport}
request, err := http.NewRequest("GET", url.String(), nil)
request.Header.Add("Proxy-Authorization", basic)
dump, _ := httputil.DumpRequest(request, false)
fmt.Println(string(dump))
// send the request
response, err := client.Do(request)
checkError(err)
fmt.Println("Read ok")
if response.Status != "200 OK" {
fmt.Println(response.Status)
os.Exit(2)
}
fmt.Println("Response ok")
var buf [512]byte
reader := response.Body
for {
n, err := reader.Read(buf[0:])
if err != nil {
os.Exit(0)
}
fmt.Print(string(buf[0:n]))
}
os.Exit(0)
}
func checkError(err error) {
if err != nil {
if err == io.EOF {
return
}
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
这个项目似乎没有公开的测试场地。我在使用认证代理的工作中测试了它。设置这样的代理超出了本书的范围。有一个关于如何做到这一点的讨论叫做“如何建立一个具有基本用户名和密码认证的 Squid 代理”(见 http://stackoverflow.com/questions/3297196/how-to-set-up-a-squid-proxy-with-basic-username-and-password-authentication
)。
客户的 HTTPS 连接
对于安全、加密的连接,HTTP 使用 TLS,这在第七章中有所描述。HTTP+TLS 的协议被称为 HTTPS,它使用https://
URL 而不是http://
URL。
在客户端接受来自服务器的数据之前,服务器需要返回有效的 X.509 证书。如果证书是有效的,那么 Go 将处理幕后的一切,并且之前给定的客户端可以正常运行 https URLs。也就是说,像前面的ClientGet.go
这样的程序不变地运行——你只需给它们一个 HTTPS URL。
许多网站都有无效的证书。它们可能已经过期,它们可能是自签名的,而不是由公认的证书颁发机构签名的,或者它们可能只是有错误(如服务器名不正确)。像 Firefox 这样的浏览器放了一个大大的警告通知,上面写着“让我离开这里!”按钮,但你可以继续冒险,许多人都这样做。
Go 在遇到证书错误时会立即退出。但是,您可以将客户端配置为忽略证书错误。当然,这是不可取的——证书配置错误的站点可能会有其他问题。
在第七章中,我们生成了自签名的 X.509 证书。在本章的后面,我们将给出一个使用 X.509 证书的 HTTPS 服务器,如果使用了自签名证书,那么ClientGet.go
将生成这个错误:
x509: certificate signed by unknown authority
客户端通过打开传输配置标志InsecureSkipVerify
来移除这些错误并继续。不安全程序是TLSUnsafeClientGet.go
:
/* TLSUnsafeClientGet
*/
package main
import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"crypto/tls"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "https://host:port/page")
os.Exit(1)
}
url, err := url.Parse(os.Args[1])
checkError(err)
if url.Scheme != "https" {
fmt.Println("Not https scheme ", url.Scheme)
os.Exit(1)
}
transport := &http.Transport{}
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
client := &http.Client{Transport: transport}
request, err := http.NewRequest("GET", url.String(), nil)
// only accept UTF-8
checkError(err)
response, err := client.Do(request)
checkError(err)
if response.Status != "200 OK" {
fmt.Println(response.Status)
os.Exit(2)
}
fmt.Println("get a response")
chSet := getCharset(response)
fmt.Printf("got charset %s\n", chSet)
if chSet != "UTF-8" {
fmt.Println("Cannot handle", chSet)
os.Exit(4)
}
var buf [512]byte
reader := response.Body
fmt.Println("got body")
for {
n, err := reader.Read(buf[0:])
if err != nil {
os.Exit(0)
}
fmt.Print(string(buf[0:n]))
}
os.Exit(0)
}
func getCharset(response *http.Response) string {
contentType := response.Header.Get("Content-Type")
if contentType == "" {
// guess
return "UTF-8"
}
idx := strings.Index(contentType, "charset:")
if idx == -1 {
// guess
return "UTF-8"
}
return strings
.Trim(contentType[idx:], " ")
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
服务器
构建客户机的另一面是处理 HTTP 请求的 web 服务器。最简单也是最早的服务器只是返回文件的副本。然而,在当前的服务器中,任何 URL 现在都可以触发任意计算。
文件服务器
我们从一个基本的文件服务器开始。Go 提供了一个多路复用器,即一个可以读取和解释请求的对象。它向在自己线程中运行的handlers,
发出请求。因此,读取 HTTP 请求、对它们进行解码,以及在它们自己的线程中分支到合适的函数的大部分工作已经为我们完成了。
对于文件服务器,Go 也给出了一个FileServer
对象,它知道如何从本地文件系统传递文件。它需要一个“根”目录,即本地系统中文件树的顶部,以及一个匹配 URL 的模式。最简单的模式是/
,它是任何 URL 的顶部。这将匹配所有 URL。
考虑到这些对象,从本地文件系统传送文件的 HTTP 服务器几乎是令人尴尬的琐碎。是FileServer.go
:
/* File Server
*/
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
// deliver files from the directory /var/www
fileServer := http.FileServer(http.Dir("/var/www"))
// register the handler and deliver requests to it
err := http.ListenAndServe(":8000", fileServer)
checkError(err)
// That's it!
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
服务器按如下方式运行:
go run FileServer.go
这个服务器甚至发送"
404 not found
"
消息来请求不存在的文件资源!如果请求的文件是一个目录,它返回一个包含在<pre> ... </pre>
标签中的列表,没有其他 HTML 头或标记。如果使用 Wireshark 或简单的 telnet 客户端,目录以text/html
的形式发送,HTML 文件以text/html
的形式发送,Perl 文件以text/x-perl
的形式发送,Java 文件以text/x-java
的形式发送,等等。FileServer
采用了一些类型识别,并将其包含在 HTTP 请求中,但是它不像 Apache 这样的服务器那样提供对标记的控制。
处理函数
在最后一个程序中,处理程序在第二个参数中给定给了ListenAndServe
。可以通过调用Handle
或HandleFunc
首先注册任意数量的处理程序,签名如下:
func Handle(pattern string, handler Handler)
func HandleFunc(pattern string, handler func(ResponseWriter, *Request))
ListenAndServe
的第二个参数可以是nil
,然后调用被分派给所有注册的处理程序。每个处理程序应该有不同的 URL 模式。例如,文件处理器可能有 URL 模式/
,而函数处理器可能有 URL 模式/cgi-bin
。更具体的模式优先于更一般的模式。
常见的 CGI 程序有test-cgi
(写在 shell 中)和printenv
(写在 Perl 中),它们打印环境变量的值。可以编写一个处理程序,以类似于PrintEnv.go
的方式工作。
/* Print Env
*/
package main
import (
"fmt"
"net/http"
"os"
)
Arial
func main() {
// file handler for most files
fileServer := http.FileServer(http.Dir("/var/www"))
http.Handle("/", fileServer)
// function handler for /cgi-bin/printenv
http.HandleFunc("/cgi-bin/printenv", printEnv)
// deliver requests to the handlers
err := http.ListenAndServe(":8000", nil)
checkError(err)
// That's it!
}
func printEnv(writer http.ResponseWriter, req *http.Request) {
env := os.Environ()
writer.Write([]byte("<h1>Environment</h1>\n<pre>"))
for _, v := range env {
writer.Write([]byte(v + "\n"))
}
writer.Write([]byte("</pre>"))
}
func
checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
Note
为了简单起见,这个程序不提供格式良好的 HTML。它缺少 html、head 和 body 标签。在本地主机上运行程序并将浏览器指向http://localhost/cgi-bin/printenv
会在我的电脑上产生如下输出:
Environment
XDG_VTNR=7
XDG_SESSION_ID=c2
CLUTTER_IM_MODULE=xim
XDG_GREETER_DATA_DIR=/var/lib/lightdm-data/newmarch
SESSION=gnome-flashback-compiz
GPG_AGENT_INFO=/home/newmarch/.gnupg/S.gpg-agent:0:1
TERM=xterm-256color
SHELL=/bin/bash
...
在这个程序中使用cgi-bin
目录有点厚脸皮:它不像 CGI 脚本那样调用外部程序。它只是调用 Go 函数printEnv
。Go 确实有能力使用os.ForkExec
调用外部程序,但是还不支持像 Apache 的mod_perl
这样的动态链接模块。
旁路默认多路复用器
Go 服务器收到的 HTTP 请求通常由多路复用器处理,多路复用器检查 HTTP 请求中的路径并调用适当的文件处理程序等。您可以定义自己的处理程序。这些可以通过调用http.HandleFunc
注册到默认的多路复用器中,它采用一个模式和一个函数。然后,像ListenAndServe
这样的函数接受一个nil
处理函数。这在上一个例子中已经完成了。
然而,如果您想接管多路复用器的角色,那么您可以给一个非nil
函数作为ListenAndServe
的处理函数。这个函数将负责管理请求和响应。
下面的例子很简单,但是说明了它的用法。多路复用器功能简单地为所有对ServerHandler.go
的请求返回一个"204 No content"
:
/* ServerHandler
*/
package main
import (
"net/http"
)
func main() {
myHandler := http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
// Just return no content - arbitrary headers can be set, arbitrary body
rw.WriteHeader(http.StatusNoContent)
})
http.ListenAndServe(":8080", myHandler)
}
可以通过对服务器运行telnet
来测试服务器,给出如下输出:
$telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.0
HTTP/1.0 204 No Content
Date: Tue, 10 Jan 2017 05:32:53 GMT
或者通过使用这个:
curl -v localhost:8080
要给出这个输出:
* Rebuilt URL to: localhost:8080/
* Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 204 No Content
< Date: Wed, 08 Mar 2017 08:46:35 GMT
<
* Connection #0 to host localhost left intact
相反,可以构建任意复杂的行为。
安全超文本传输协议
对于安全、加密的连接,HTTP 使用 TLS,这在第七章中有所描述。HTTP+TLS 的协议被称为 HTTPS,它使用https://
URL 而不是http://
URL。
对于使用 HTTPS 的服务器,它需要一个 X.509 证书和该证书的私钥文件。Go 目前要求这些是在第七章中使用的 PEM 编码。然后用 HTTPS (HTTP+TLS)函数ListenAndServeTLS
替换 HTTP 函数ListenAndServe
。
前面给出的文件服务器程序可以写成一个 HTTPS 服务器为HTTPSFileServer.go
:
/* HTTPSFileServer
*/
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
// deliver files from the directory /var/www
fileServer := http.FileServer(http.Dir("/var/www"))
// register the handler and deliver requests to it
err := http.ListenAndServeTLS(":8000", "jan.newmarch.name.pem",
"private.pem", fileServer)
checkError(err)
// That's it!
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
例如,该服务器由https://localhost:8000/index.html
访问。如果证书是自签名证书,则需要不安全的客户端来访问服务器内容。例如:
curl -kv https://localhost:8000
如果您想要一个同时支持 HTTP 和 HTTPs 的服务器,那么在它自己的go
例程中运行每个监听器。
结论
Go 对 HTTP 有广泛的支持。这并不奇怪,因为 Go 的发明部分是为了满足谷歌对自己服务器的需求。本章讨论了 Go 对 HTTP 和 HTTPS 的各种级别的支持。
九、模板
大多数服务器端语言都有一种机制,主要是获取静态页面并插入动态生成的组件,比如一个项目列表。典型的例子是 Java 服务器页面中的脚本、PHP 脚本和许多其他脚本。Go 在template
包中采用了相对简单的脚本语言。
该包设计为将文本作为输入,并基于使用对象的值转换原始文本来输出不同的文本。不像 JSP 或类似的,它并不局限于 HTML 文件,但它很可能在那里找到最大的用途。我们首先描述text/template
包,然后描述html/template
包。
原始源称为模板,将由不变地传输的文本和可以作用于并改变文本的嵌入命令组成。命令由{{ ... }}
分隔,类似于 JSP 命令<%= ... =%>
和 PHP 的<?php ... ?>
。
插入对象值
模板应用于 Go 对象。来自 Go 对象的字段可以插入到模板中,您可以“挖掘”对象以找到子字段,等等。当前对象表示为光标.
,因此要将当前对象的值作为字符串插入,可以使用{{.}}
。默认情况下,这个包使用fmt
包来计算作为插入值的字符串。
要插入当前光标对象的某个字段的值,可以使用前缀为.
的字段名称。例如,如果当前光标对象的类型为
type Person struct {
Name string
Age int
Emails []string
Jobs []*Job
}
如下插入Name
和Age
的值:
The name is {{.Name}}.
The age is {{.Age}}.
您可以使用range
命令遍历数组或其他列表的元素。因此,要访问Emails
数组的内容,您可以使用:
{{range .Emails}}
The email is {{.}}
{{end}}
在邮件循环期间,光标.
被依次设置到每封邮件。循环结束时,光标返回到人。如果Job
定义如下:
type Job struct {
Employer string
Role string
}
并且我们想要访问一个person
的jobs
的字段,我们可以像上面一样用一个{{range .Jobs}}
来完成。另一种方法是将当前对象切换到Jobs
字段。这是通过使用{{with ...}} ... {{end}}
构造完成的,其中{{.}}
是Jobs
字段,这是一个数组:
{{with .Jobs}}
{{range .}}
An employer is {{.Employer}}
and the role is {{.Role}}
{{end}}
{{end}}
您可以将它用于任何字段,而不仅仅是数组。
使用模板
一旦你有了一个模板,你就可以把它应用到一个对象来生成一个新的字符串,用这个对象来填充模板值。这是一个两步过程,包括解析模板,然后将其应用于对象。结果被输出到一个Writer
,如:
t := template.New("Person template")
t, err := t.Parse(templ)
if err == nil {
buff := bytes.NewBufferString("")
t.Execute(buff, person)
}
将模板应用到对象并打印到标准输出的示例程序是PrintPerson.go
:
/**
* PrintPerson
*/
package main
import (
"fmt"
"text/template"
"os"
)
type Person struct {
Name string
Age int
Emails []string
Jobs []*Job
}
type Job struct {
Employer string
Role string
}
const templ = `The name is {{.Name}}.
The age is {{.Age}}.
{{range .Emails}}
An email is {{.}}
{{end}}
{{with .Jobs}}
{{range .}}
An employer is {{.Employer}}
and the role is {{.Role}}
{{end}}
{{end}}
`
func main() {
job1 := Job{Employer: "Box Hill Institute", Role: "Director, Commerce and ICT"}
job2 := Job{Employer: "Canberra University", Role: "Adjunct Professor"}
person := Person{
Name: "jan",
Age: 66,
Emails: []string{"jan@newmarch.name", "jan.newmarch@gmail.com"},
Jobs: []*Job{&job1, &job2},
}
t := template.New("Person template")
t, err := t.Parse(templ)
checkError(err)
err = t.Execute(os.Stdout, person)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
其输出如下所示:
The name is jan.
The age is 66.
An email is jan@newmarch.name
An email is jan.newmarch@gmail.com
An employer is Canberra University
and the role is Adjunct Professor
An employer is Box Hill Institute
and the role is Director, Commerce and ICT
注意,在这个打印输出中有大量的空白作为换行符。这是因为我们在模板中有空白。如果您想减少这个空白,请删除模板中的换行符,如下所示:
{{range .Emails}} An email is {{.}} {{end}}
另一种方法是使用命令分隔符"{{- " and " -}}"
分别从紧接的前一个文本中删除所有尾随空白,从紧接的后一个文本中删除所有前导空白。
在这个例子中,我们在程序中使用了一个字符串作为模板。您也可以使用template.ParseFiles()
功能从文件中加载模板。由于某种我不理解的原因(在早期版本中不需要),分配给模板的名称必须与文件列表中第一个文件的基本名称相同。这是个 bug 吗?
管道
上述转换将文本片段插入到模板中。这些文本基本上是任意的,不管字段的字符串值是什么。如果我们希望它们作为 HTML 文档(或其他特殊形式)的一部分出现,我们必须对特定的字符序列进行转义。例如,要在 HTML 文档中显示任意文本,我们必须将<
改为<
。Go 模板有许多内置函数,其中之一就是html()
。这些函数以类似于 UNIX 管道的方式工作,从标准输入读取数据并写入标准输出。
要获取当前对象.
的值并对其应用 HTML 转义,您需要在模板中编写一个“管道”:
{{. | html}}
对于其他功能也是如此。
定义函数
模板使用对象的字符串表示来插入值,使用fmt
包将对象转换成字符串。有时候这并不是我们所需要的。例如,为了避免垃圾邮件发送者获得电子邮件地址,很常见的是将符号@
替换为单词“at”,如“jan at newmarch.name”。如果我们想使用模板以那种形式显示电子邮件地址,我们必须构建一个自定义函数来完成这种转换。
每个模板函数都有一个用于模板本身的名称和一个关联的 Go 函数。这些由以下类型链接:
type FuncMap map[string]interface{}
例如,如果我们希望我们的模板函数是emailExpand
,它链接到 Go 函数EmailExpander
,我们将它添加到模板中的函数,如下所示:
t = t.Funcs(template.FuncMap{"emailExpand": EmailExpander})
EmailExpander
的签名通常如下:
func EmailExpander(args ...interface{}) string
对于我们感兴趣的用法,函数应该只有一个参数,它将是一个字符串。Go 模板库中的现有函数有一些初始代码来处理不一致的情况,所以我们只是复制这些代码。然后,只需简单的字符串操作就可以改变电子邮件地址的格式。一个程序是PrintEmails.go
:
/**
* PrintEmails
*/
package main
import (
"fmt"
"os"
"strings"
"text/template"
)
type Person struct {
Name string
Emails []string
}
const templ = `The name is {{.Name}}.
{{range .Emails}}
An email is "{{. | emailExpand}}"
{{end}}
`
func EmailExpander(args ...interface{}) string {
ok := false
var s string
if len(args) == 1 {
s, ok = args[0].(string)
}
if !ok {
s = fmt.Sprint(args...)
}
// find the @ symbol
substrs := strings.Split(s, "@")
if len(substrs) != 2 {
return s
}
// replace the @ by " at "
return (substrs[0] + " at " + substrs[1])
}
func main() {
person := Person{
Name: "jan",
Emails: []string{"jan@newmarch.name", "jan.newmarch@gmail.com"},
}
t := template.New("Person template")
// add our function
t = t.Funcs(template.FuncMap{"emailExpand": EmailExpander})
t, err := t.Parse(templ)
checkError(err)
err = t.Execute(os.Stdout, person)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
输出如下所示:
The name is jan.
An email is "jan at newmarch.name"
An email is "jan.newmarch at gmail.com"
变量
模板包允许您定义和使用变量。作为这样做的动机,考虑我们如何打印每个人的电子邮件地址,并以他们的名字作为前缀。我们使用的类型也是这个:
type Person struct {
Name string
Emails []string
}
为了访问电子邮件字符串,我们使用如下的range
语句:
{{range .Emails}}
{{.}}
{{end}}
但是此时我们不能访问Name
字段,因为.
正在遍历数组元素,而Name
不在这个范围内。解决方案是将Name
字段的值保存在一个变量中,该变量在其作用域内的任何地方都可以被访问。模板中的变量以$
为前缀。所以我们这样写:
{{$name := .Name}}
{{range .Emails}}
Name is {{$name}}, email is {{.}}
{{end}}
程序是PrintNameEmails.go
:
/**
* PrintNameEmails
*/
package main
import (
"text/template"
"os"
"fmt"
)
type Person struct {
Name string
Emails []string
}
const templ = `{{$name := .Name}}
{{range .Emails}}
Name is {{$name}}, email is {{.}}
{{end}}
`
func main() {
person := Person{
Name: "jan",
Emails: []string{"jan@newmarch.name", "jan.newmarch@gmail.com"},
}
t := template.New("Person template")
t, err := t.Parse(templ)
checkError(err)
err = t.Execute(os.Stdout, person)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
以下是输出:
Name is jan, email is jan@newmarch.name
Name is jan, email is jan.newmarch@gmail.com
条件语句
继续Person
的例子,假设您只想打印出电子邮件列表,而不想深入研究它。您可以使用模板来做到这一点:
Name is {{.Name}}
Emails are {{.Emails}}
这将打印以下内容:
Name is jan
Emails are [jan@newmarch.name jan.newmarch@gmail.com]
因为这是fmt
包显示列表的方式。
在许多情况下,这可能是好的,如果这是你想要的。让我们考虑一个几乎正确,但不完全正确的情况。有一个 JSON 包来序列化对象,我们在第四章中讨论过。这将产生以下结果:
{"Name": "jan",
"Emails": ["jan@newmarch.name", "jan.newmarch@gmail.com"]
}
JSON 包是您在实践中使用的包,但是让我们看看是否可以使用模板生成 JSON 输出。我们可以通过现有的模板做类似的事情。作为 JSON 序列化程序,这几乎是正确的:
{"Name": "{{.Name}}",
"Emails": {{.Emails}}
}
它会产生这样的结果:
{"Name": "jan",
"Emails": [jan@newmarch.name jan.newmarch@gmail.com]
}
这有两个问题:地址没有用引号括起来,列表元素应该用,
分隔。
这样如何——看看数组元素,把它们放在引号中,然后加上逗号?
{"Name": {{.Name}},
"Emails": [
{{range .Emails}}
"{{.}}",
{{end}}
]
}
这将产生:
{"Name": "jan",
"Emails": ["jan@newmarch.name", "jan.newmarch@gmail.com",]
}
(加上一些空格。)
同样,这几乎是正确的,但是如果仔细观察,您会看到在最后一个列表元素后面有一个尾随的,
。根据 JSON 语法(参见 http://www.json.org/
),这种尾随的,
是不允许的。实现可能在处理这一问题的方式上有所不同。
我们想要的是打印除了最后一个元素之外的每个元素,后跟一个,
。这实际上有点难做到,所以更好的方法是打印每个以 a 开头的元素,除了第一个。(这个技巧是我在栈溢出— http://stackoverflow.com/questions/201782/can-you-use-a-trailing-comma-in-a-json-object
从“brianb”那里得到的)。这更容易,因为第一个元素的索引是零,许多编程语言,包括 Go 模板语言,都将零视为布尔值false
。
条件语句的一种形式是{{if pipeline}} T1 {{else}} T0 {{end}}
。我们需要将pipeline
作为电子邮件数组的索引。幸运的是,range
声明的一个变体给了我们这个。有两种引入变量的形式:
{{range $elmt := array}}
{{range $index, $elmt := array}}
所以我们通过数组建立了一个循环,如果索引是false
(0),我们就打印这个元素。否则,我们会在它前面打印一个,
。模板如下所示:
{"Name": "{{.Name}}",
"Emails": [
{{range $index, $elmt := .Emails}}
{{if $index}}
, "{{$elmt}}"
{{else}}
"{{$elmt}}"
{{end}}
{{end}}
]
}
完整的程序是PrintJSONEmails.go
:
/**
* PrintJSONEmails
*/
package main
import (
"text/template"
"os"
"fmt"
)
type Person struct {
Name string
Emails []string
}
const templ = `{"Name": "{{.Name}}",
"Emails": [
{{range $index, $elmt := .Emails}}
{{if $index}}
, "{{$elmt}}"
{{else}}
"{{$elmt}}"
{{end}}
{{end}}
]
}
`
func main() {
person := Person{
Name: "jan",
Emails: []string{"jan@newmarch.name", "jan.newmarch@gmail.com"},
}
t := template.New("Person template")
t, err := t.Parse(templ)
checkError(err)
err = t.Execute(os.Stdout, person)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
这给出了正确的 JSON 输出。
在离开本节之前,请注意使用逗号分隔符格式化列表的问题可以通过在 Go 中定义合适的函数来解决,这些函数可以作为模板函数使用。借用另一种编程语言中的一句名言,“有不止一种方法可以做到这一点!”。以下程序是罗杰·佩佩以Sequence.go
的身份发给我的:
/**
* Sequence.go
* Copyright Roger Peppe
*/
package main
import (
"errors"
"fmt"
"os"
"text/template"
)
var tmpl = `{{$comma := sequence "" ", "}}
{{range $}}{{$comma.Next}}{{.}}{{end}}
{{$comma := sequence "" ", "}}
{{$colour := cycle "black" "white" "red"}}
{{range $}}{{$comma.Next}}{{.}} in {{$colour.Next}}{{end}}
`
var fmap = template.FuncMap{
"sequence": sequenceFunc,
"cycle": cycleFunc,
}
func main() {
t, err := template.New("").Funcs(fmap).Parse(tmpl)
if err != nil {
fmt.Printf("parse error: %v\n", err)
return
}
err = t.Execute(os.Stdout, []string{"a", "b", "c", "d", "e", "f"})
if err != nil {
fmt.Printf("exec error: %v\n", err)
}
}
type generator struct {
ss []string
i int
f func(s []string, i int) string
}
func (seq *generator) Next() string {
s := seq.f(seq.ss, seq.i)
seq.i++
return s
}
func sequenceGen(ss []string, i int) string {
if i >= len(ss) {
return ss[len(ss)-1]
}
return ss[i]
}
func cycleGen(ss []string, i int) string {
return ss[i%len(ss)]
}
func sequenceFunc(ss ...string) (*generator, error) {
if len(ss) == 0 {
return nil, errors.New("sequence must have at least one element")
}
return &generator{ss, 0, sequenceGen}, nil
}
func cycleFunc(ss ...string) (*generator, error) {
if len(ss) == 0 {
return nil, errors.New("cycle must have at least one element")
}
return &generator{ss, 0, cycleGen}, nil
}
以下是输出:
a, b, c, d, e, f
a in black, b in white, c in red, d in black, e in white, f in red
HTML/模板包
前面的程序都处理了text/template
包。这将应用转换,而不考虑可能使用文本的任何上下文。例如,如果PrintPerson.go
中的文本变为:
job1 := Job{Employer: "<script>alert('Could be nasty!')</script>", Role: "Director, Commerce and ICT"}
该程序将生成以下文本:
An employer is <script>alert('Could be nasty!')</script>
如果下载到浏览器中,将会产生意想不到的效果。
在管道中使用html
命令可以减少这种情况,如{{。| html}},并将产生以下内容:
An employer is <script>alert('Could be nasty!')</script>
将此过滤器应用于所有表达式将变得繁琐。此外,它可能无法捕捉潜在危险的 JavaScript、CSS 或 URI 表达式。
html/template
包就是为了克服这些问题而设计的。通过用html/template
替换text/template
的简单步骤,适当的转换将被应用到结果文本,净化它,使它适合 web 上下文。
结论
Go 模板包对于某些涉及插入对象值的文本转换非常有用。例如,它不具备正则表达式的能力,但它比正则表达式更快,在许多情况下也更容易使用。
十、完整的网络服务器
这一章主要是对 HTTP 一章的说明,在 Go 中构建一个完整的 Web 服务器。它还展示了如何使用模板,以便在文本文件中使用表达式来插入变量值和生成重复的部分。它处理序列化数据和 Unicode 字符集。本章中的程序足够长且复杂,所以它们并不总是完整地给出,但可以从本书的网站上下载,该网站是 http://www.apress.com/9781484226919
。
我正在学习中文。相反,经过多年的努力,我仍在尝试学习中文。当然,我没有埋头苦干,而是尝试了各种技术辅助手段。我尝试了教科书、视频和许多其他教具。最终我意识到我进步缓慢的原因是没有一个好的中文抽认卡的计算机程序,所以为了学习,我需要建立一个。
我在 Python 中找到了一个程序来完成一些任务。但遗憾的是,它写得不好,在几次试图把它颠倒过来之后,我得出的结论是,最好从零开始。当然,一个网络解决方案要比一个独立的解决方案好得多,因为这样我的中文课上的其他人就可以分享它,还有其他的学习者。当然,服务器应该是用 Go 编写的。
我使用了张芃芃《汉语口语精读》一书中的词汇,但是这个程序适用于任何词汇集。
浏览器站点图
在浏览器中看到的结果程序有三种类型的页面,如图 10-1 所示。
图 10-1。
Browser pages
主页显示抽认卡组列表(见图 10-2 )。它包括当前可用的抽认卡组列表、您希望抽认卡组如何显示(随机卡顺序、首先显示中文或英文,或随机),以及是显示一组卡还是只显示一组卡中的单词。
图 10-2。
The home page of the web site
抽认卡组显示一张抽认卡,一次一张。一个看起来像图 10-3 。
图 10-3。
Typical flashcard showing all the components
抽认卡的单词集如图 10-4 所示。
图 10-4。
The list of words in a flashcard set
浏览器文件
浏览器端有 HTML,CSS,JavaScript 文件。这些措施如下:
- 首页(
flashcards.html
):html/ListFlashcardsStylesheet.css
- 抽认卡组(
ShowFlashcards.html
):css/CardStyleSheet.css
jscript/jquery.js
jscript/slideviewer.js
- 抽认卡设置单词(
ListWords.html
):无额外
基本服务器
该服务器是一个 HTTP 服务器,如前一章所述。它有许多功能来处理不同的网址。这些功能概述如下:
| 小路 | 功能 | HTML 已交付 | | --- | --- | --- | | `/` | `listFlashCards` | `html/ListFlashcards.html` | | `/flashcards.html` | `listFlashCards` | `html/ListFlashcards.html` | | `/flashcardSets` | `manageFlashCards` | `html/showFlashcards.html` | | `/flashcardSets` | `manageFlashCards` | `html/ListWords.html` | | `/jscript/*` | `fileServer` | 目录`/jscript`中的文件 | | `/html/*` | `fileServer` | 目录`/html`中的文件 |暂且省略功能本身,服务器是 http://www.apress.com/9781484226919
的Ch10
下的Server.go
。
/* Server
*/
package main
import (
"fmt"
"net/http"
"os"
"html/template"
)
import (
"dictionary"
"flashcards"
"templatefuncs"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprint(os.Stderr, "Usage: ", os.Args[0], ":port\n")
os.Exit(1)
}
port := os.Args[1]
http.HandleFunc("/", listFlashCards)
fileServer := http.StripPrefix("/jscript/", http.FileServer(http.Dir("jscript")))
http.Handle("/jscript/", fileServer)
fileServer = http.StripPrefix("/html/", http.FileServer(http.Dir("html")))
http.Handle("/html/", fileServer)
http.HandleFunc("/flashcards.html", listFlashCards)
http.HandleFunc("/flashcardSets", manageFlashCards)
// deliver requests to the handlers
err := http.ListenAndServe(port, nil)
checkError(err)
// That's it!
}
func listFlashCards(rw http.ResponseWriter, req *http.Request) {
...
}
/*
* Called from ListFlashcards.html on form submission
*/
func manageFlashCards(rw http.ResponseWriter, req *http.Request) {
...
}
func showFlashCards(rw http.ResponseWriter, cardname, order, half string) {
...
}
func listWords(rw http.ResponseWriter, cardname string) {
...
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
我们现在开始讨论单个函数。
listFlashCards 函数
调用listFlashCards
函数为顶层页面创建 HTML。抽认卡名称列表是可扩展的,是目录flashcardSets
中的一组文件条目。此列表用于在顶级页面中创建表格,最好使用模板包来完成:
<table>
{{range .}}
<tr>
<td>
{{.}}
</td>
</tr>
</table>
其中范围超出了名称列表。文件html/ListFlashcards.html
包含这个模板以及卡片顺序、半卡片显示和底部表单按钮的 HTML。省略了边列表和提交按钮,HTML 如下:
<html>
<head>
<title>
Flashcards
</title>
<link type="text/css" rel="stylesheet"
href="/html/ListFlashcardsStylesheet.css">
</link>
</head>
<body>
<h1>
Flashcards
</h1>
<p>
<div id="choose">
<form method="GET" action="http:flashcardSets">
<table border="1" id="sets">
<tr>
<th colspan="2">
Flashcard Sets
</th>
</tr>
{{range .}}
<tr>
<td>
{{.}}
</td>
<td>
<input type="radio" name="flashcardSets" value="{{.}}" />
</td>
</tr>
{{end}}
</table>
</div>
</p>
</body>
</html>
将模板应用于此的函数listFlashCards
如下:
func listFlashCards(rw http.ResponseWriter, req *http.Request) {
flashCardsNames := flashcards.ListFlashCardsNames()
t, err := template.ParseFiles("html/ListFlashcards.html")
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
t.Execute(rw, flashCardsNames)
}
函数flashcards.ListFlashCardsNames()
只是遍历抽认卡目录,返回一个字符串数组(每个抽认卡集的文件名):
func ListFlashCardsNames() []string {
flashcardsDir, err := os.Open("flashcardSets")
if err != nil {
return nil
}
files, err := flashcardsDir.Readdir(-1)
fileNames := make([]string, len(files))
for n, f := range files {
fileNames[n] = f.Name()
}
sort.Strings(fileNames)
return fileNames
}
manageFlashCards 功能
按下“显示集合中的卡片”按钮或“列出集合中的单词”按钮时,调用manageFlashCards
函数来管理表单提交。它从表单请求中提取值,然后在showFlashCards
和listWords
之间进行选择:
func manageFlashCards(rw http.ResponseWriter, req *http.Request) {
set := req.FormValue("flashcardSets")
order := req.FormValue("order")
action := req.FormValue("submit")
half := req.FormValue("half")
cardname := "flashcardSets/" + set
fmt.Println("cardname", cardname, "action", action)
if action == "Show cards in set" {
showFlashCards(rw, cardname, order, half)
} else if action == "List words in set" {
listWords(rw, cardname)
}
}
汉语词典
前面的代码相当普通:它使用文件服务器交付静态文件,使用基于目录中文件列表的模板创建 HTML 表,并处理来自 HTML 表单的信息。为了进一步了解每张卡片上显示的内容,我们必须了解应用程序的具体细节,这意味着要了解单词的来源(字典),如何表示单词和卡片,以及如何将抽认卡数据发送到浏览器。首先,字典。
汉语是一种复杂的语言——难道它们不都是:-(。书写形式是象形文字,也就是“象形图”,而不是使用字母表。但这种书写形式随着时间的推移而演变,甚至最近分裂成两种形式:在台湾和香港使用的“繁体”中文,以及在 mainland China 使用的“简体”中文。虽然大多数字符是相同的,但大约有 1000 个字符是不同的。因此,一部汉语词典通常会有两种相同的书写形式。
像我这样的西方人,大多看不懂这些文字。所以有一种“拉丁化”的形式,叫做拼音,它是以拉丁字母为基础,用音标书写汉字。它不完全是拉丁字母,因为汉语是一种带声调的语言,拼音形式必须显示声调(很像法语和其他欧洲语言中的重音)。所以一个典型的字典必须显示四样东西:繁体、简体、拼音和英语。另外(就像英语里一样),一个词可能有多个意思。比如 http://www.mandarintools.com/worddict.html
有免费的中/英文词典,更好的是可以下载成 UTF-8 文件。在里面,这个词好有这个条目:
这本字典有点复杂。大多数键盘不擅长表现重音,如中的卡隆音。因此,虽然汉字是用 Unicode 书写的,但拼音字符不是。虽然像\u 这样的字母有 Unicode 字符,但包括这本在内的许多词典都使用拉丁字母 a,并将音调放在单词的末尾。这里是第三声,所以 hǎo 写成 hao3。这使得那些只有美国键盘而没有 Unicode 编辑器的人仍然可以更容易地用拼音交流。网络服务器使用的字典的副本是cedict_ts_u8
。
这种数据格式不匹配不是什么大问题。只是在原始文本字典和浏览器显示之间的某个地方,必须执行数据消息。Go 模板允许通过定义一个自定义模板来实现这一点,所以我选择了这条路线。替代方法包括在读入字典时这样做,或者在 JavaScript 中显示最后的字符。
字典类型
我们使用一个Entry
来保存一个单词的基本信息:
type Entry struct {
Traditional string
Simplified string
Pinyin string
Translations []string
}
上面的单词将由以下内容表示:
Entry{Traditional: 好,
Simplified: 好,
Pinyin: `hao3`
Translations: []string{`good`, `well`,`proper`,
`good to`, `easy to`, `very`, `so`,
`(suffix indicating completion or readiness)`}
}
字典本身就是这些条目的数组:
type Dictionary struct {
Entries []*Entry
}
抽认卡套装
一张抽认卡代表一个中文单词和这个单词的英文翻译。我们已经看到,一个单一的中文单词可以有许多可能的英文意思。但这部词典有时也会多次出现一个中文单词。举个例子,好至少出现两次,一次带有我们已经看到的意思,但也带有另一个意思,“喜欢”。这被证明是多余的,但是考虑到这一点,每个抽认卡都有一个完整的单词字典。通常字典中只有一个条目!抽认卡的其余部分只是作为可能的密钥的简化和英语单词:
type FlashCard struct {
Simplified string
English string
Dictionary *dictionary.Dictionary
}
抽认卡组是这些抽认卡的一个数组,加上抽认卡组的名称,以及将被发送到浏览器以显示抽认卡组的信息:随机或固定顺序,首先显示每张卡片的顶部或底部,或者随机。
type FlashCards struct {
Name string
CardOrder string
ShowHalf string
Cards []*FlashCard
}
我们已经展示了这种类型的一个函数,ListFlashCardsNames()
。这种类型还有一个有趣的功能,为抽认卡集加载 JSON 文件。这使用了第四章的技术,连载。
func LoadJSON(fileName string, key interface{}) {
inFile, err := os.Open(fileName)
checkError(err)
decoder := json.NewDecoder(inFile)
err = decoder.Decode(key)
checkError(err)
inFile.Close()
}
一套典型的抽认卡是由普通单词组成的。当 JSON 文件被 Python ( print json.dump(string, indent=4, separators=(',', ':'))
)漂亮地打印出来时,它的一部分看起来像这样:
{
"ShowHalf":"",
"Cards":[
{
"Simplified":"\u4f60\u597d",
"Dictionary":{
"Entries":[
{
"Traditional":"\u4f60\u597d",
"Pinyin":"ni3 hao3",
"Translations":[
"hello",
"hi",
"how are you?"
],
"Simplified":"\u4f60\u597d"
}
]
},
"English":"hello"
},
{
"Simplified":"\u5582",
"Dictionary":{
"Entries":[
{
"Traditional":"\u5582",
"Pinyin":"wei4",
"Translations":[
"hello (interj., esp. on telephone)",
"hey",
"to feed (sb or some animal)"
],
"Simplified":"\u5582"
}
]
},
"English":"hello (interj., esp. on telephone)"
},
],
"CardOrder":"",
"Name":"Common Words"
}
修正口音
在我们完成服务器的代码之前,还有最后一个主要任务。字典中给出的重音符号将重音符号放在拼音单词的末尾,如 hao3 中的 hǎo。如第九章所述,可以通过自定义模板将重音符号转换为 Unicode。
这里给出了拼音格式化程序的代码。除非你真的有兴趣了解拼音格式的规则,否则不要费心去读它。程序是PinyinFormatter.go
:
package templatefuncs
import (
"fmt"
"strings"
)
func PinyinFormatter(args ...interface{}) string {
ok := false
var s string
if len(args) == 1 {
s, ok = args[0].(string)
}
if !ok {
s = fmt.Sprint(args...)
}
fmt.Println("Formatting func " + s)
// the string may consist of several pinyin words
// each one needs to be changed separately and then
// added back together
words := strings.Fields(s)
for n, word := range words {
// convert "u:" to "ü" if present
uColon := strings.Index(word, "u:")
if uColon != -1 {
parts := strings.SplitN(word, "u:", 2)
word = parts[0] + "ü" + parts[1]
}
println(word)
// get last character, will be the tone if present
chars := []rune(word)
tone := chars[len(chars)-1]
if tone == '5' {
// there is no accent for tone 5
words[n] = string(chars[0 : len(chars)-1])
println("lost accent on", words[n])
continue
}
if tone < '1' || tone > '4' {
// not a tone value
continue
}
words[n] = addAccent(word, int(tone))
}
s = strings.Join(words, ` `)
return s
}
var (
// maps 'a1' to '\u0101' etc
aAccent = map[int]rune{
'1': '\u0101',
'2': '\u00e1',
'3': '\u01ce',
'4': '\u00e0'}
eAccent = map[int]rune{
'1': '\u0113',
'2': '\u00e9',
'3': '\u011b',
'4': '\u00e8'}
iAccent = map[int]rune{
'1': '\u012b',
'2': '\u00ed',
'3': '\u01d0',
'4': '\u00ec'}
oAccent = map[int]rune{
'1': '\u014d',
'2': '\u00f3',
'3': '\u01d2',
'4': '\u00f2'}
uAccent = map[int]rune{
'1': '\u016b',
'2': '\u00fa',
'3': '\u01d4',
'4': '\u00f9'}
üAccent = map[int]rune{
'1': 'ǖ',
'2': 'ǘ',
'3': 'ǚ',
'4': 'ǜ'}
)
func addAccent(word string, tone int) string {
/*
* Based on "Where do the tone marks go?"
* at http://www.pinyin.info/rules/where.html
*/
n := strings.Index(word, "a")
if n != -1 {
aAcc := aAccent[tone]
// replace 'a' with its tone version
word = word[0:n] + string(aAcc) + word[(n+1):len(word)-1]
} else {
n := strings.Index(word, "e")
if n != -1 {
eAcc := eAccent[tone]
word = word[0:n] + string(eAcc) +
word[(n+1):len(word)-1]
} else {
n = strings.Index(word, "ou")
if n != -1 {
oAcc := oAccent[tone]
word = word[0:n] + string(oAcc) + "u" +
word[(n+2):len(word)-1]
} else {
chars := []rune(word)
length := len(chars)
// put tone on the last vowel
L:
for n, _ := range chars {
m := length - n - 1
switch chars[m] {
case 'i':
chars[m] = iAccent[tone]
break L
case 'o':
chars[m] = oAccent[tone]
break L
case 'u':
chars[m] = uAccent[tone]
break L
case 'ü':
chars[m] = üAccent[tone]
break L
default:
}
}
word = string(chars[0 : len(chars)-1])
}
}
}
return word
}
ListWords 函数
我们现在可以回到服务器的突出功能。一个是在一套抽认卡中列出单词。这将使用抽认卡集的模板填充一个 HTML 表。HTML 使用模板包遍历一个FlashCards
结构并插入该结构中的字段:
<html>
<head>
<title>
Words for {{.Name}}
</title>
</head>
<body>
<h1>
Words for {{.Name}}
</h1>
<p>
<table border="1" class="sortable">
<tr>
<th> English </th>
<th> Pinyin </th>
<th> Traditional </th>
<th> Simplified </th>
</tr>
{{range .Cards}}
<div class="card">
<tr>
<div class="english">
<div class="vcenter">
<td>
{{.English}}
</td>
</div>
</div>
{{with .Dictionary}}
{{range .Entries}}
<div class="pinyin">
<div class="vcenter">
<td>
{{.Pinyin|pinyin}}
</td>
</div>
</div>
<div class="traditional">
<div class="vcenter">
<td>
{{.Traditional}}
</td>
</div>
</div>
<div class="simplified">
<div class="vcenter">
<td>
{{.Simplified}}
</td>
</div>
</div>
{{end}}
{{end}}
</tr>
</div>
{{end}}
</table>
</p>
<p class ="return">
<a href="http:/flashcards.html"> Return to Flash Cards list</a>
</p>
</body>
</html>
为此,Server.go
中的 Go 函数使用了上一节讨论的PinyinFormatter
:
func listWords(rw http.ResponseWriter, cardname string) {
cards := new(flashcards.FlashCards)
flashcards.LoadJSON(cardname, cards)
fmt.Println("Card name", cards.Name)
t := template.New("ListWords.html")
t = t.Funcs(template.FuncMap{"pinyin": templatefuncs.PinyinFormatter})
t, err := t.ParseFiles("html/ListWords.html")
if err != nil {
fmt.Println("Parse error " + err.Error())
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
err = t.Execute(rw, cards)
if err != nil {
fmt.Println("Execute error " + err.Error())
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
}
这会将填充的表格发送到浏览器,如图 10-4 所示。
showFlashCards 功能
完成服务器的最后一个函数是showFlashCards
。这将根据浏览器提交的表单,改变抽认卡组中CardOrder
和ShowHalf
的默认值。然后应用PinyinFormatter
并将结果文档发送给浏览器。我使用 UNIX 命令script
捕获命令行会话的输出,然后运行命令:
GET /flashcardSets?flashcardSets=Common+Words&order=Random&half=Chinese&submit=Show+cards+in+set HTTP/1.0
部分结果如下:
<html>
<head>
<title>
Flashcards for Common Words
</title>
<link type="text/css" rel="stylesheet"
href="/html/CardStylesheet.css">
</link>
<script type="text/javascript"
language="JavaScript1.2" src="/jscript/jquery.js">
<!-- empty -->
</script>
<script type="text/javascript"
language="JavaScript1.2" src="/jscript/slideviewer.js">
<!-- empty -->
</script>
<script type="text/javascript"
language="JavaScript1.2">
cardOrder = "RANDOM";
showHalfCard = "CHINESE_HALF";
</script>
</head>
<body onload="showSlides();">
<h1>
Flashcards for Common Words
</h1>
<p>
<div class="card">
<div class="english">
<div class="vcenter">
hello
</div>
</div>
<div class="pinyin">
<div class="vcenter">
nǐ hǎo
</div>
</div>
<div class="traditional">
<div class="vcenter">
你好
</div>
</div>
<div class="simplified">
<div class="vcenter">
你好
</div>
</div>
<div class ="translations">
<div class="vcenter">
hello <br />
hi <br />
how are you? <br />
</div>
</div>
</div>
浏览器上的演示
这个系统的最后一部分是如何在浏览器中显示这个 HTML。图 10-3 显示了一个由四部分组成的屏幕,显示英语、简体中文、备选翻译和繁体/简体对。这是如何通过下载到服务器的 JavaScript 程序完成的(这是使用FileServer
Go 对象完成的)。JavaScript slideviewer.js
文件实际上很长,因此在文本中被省略了。它包含在 http://www.apress.com/9781484226919
的程序文件中。
运行服务器
这是本书中第一个使用我们自己导入的文件的程序。所有以前的程序都只是使用了一个主文件和 Go 标准库。包的dictionary
、抽认卡和pinyin
中导入的文件需要组织好,以便go
命令可以找到它们。
需要将环境变量GOPATH
设置到一个目录中,该目录下有一个子目录src
,该子目录包含适当子目录中导入的源文件:
src/flashcards/FlashCards.go
src/pinyin/PinyinFormatter.go
src/dictionary/Dictionary.go
然后,可以使用如下命令在端口8000
(或其他端口)上运行服务器:
go run Server.go :8000
结论
本章考虑了一个相对简单但完整的 web 服务器,它使用静态和动态 web 页面处理表单,并使用模板来简化编码。
十一、HTML
Web 最初是为 HTML 文档服务而创建的。现在它被用来服务各种各样的文件和不同种类的数据。然而,HTML 仍然是通过网络传递的主要文档类型。
HTML 经历了大量的版本,目前的版本是 HTML5。也有许多 HTML 的“供应商”版本,引入了从未成为标准的标签。
HTML 非常简单,可以手工编辑。因此,许多 HTML 文档是“格式错误的”,这意味着它们不遵循语言的语法。HTML 解析器通常不是很严格,会接受许多“非法”文档。
HTML 包本身只有两个功能——EscapeString
和UnescapeString
。这些适当地处理角色,例如<
,将它们转换成<
,然后再转换回来。
这种方法的主要用途可能是对 HTML 文档中的标记进行转义,这样如果在浏览器中显示,就会显示所有的标记(很像 Linux 上 Chrome 中的 Ctrl+U 或 Mac Chrome 上的 Option+Cmd+U)。
我更倾向于用这个把程序的文本显示成网页。大多数编程语言都有<
符号,许多有&
。除非正确转义,否则这些会搞乱 HTML 查看器。我喜欢直接从文件系统中显示程序文本,而不是复制粘贴到文档中,以避免不同步。
下面的程序EscapeString.go
是一个 web 服务器,它以预格式化的代码显示其 URL,并对麻烦的字符进行了转义:
/*
* This program serves a file in preformatted, code layout
* Useful for showing program text, properly escaping special
* characters like '<', '>' and '&'
*/
package main
import (
"fmt"
"html"
"io/ioutil"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", escapeString)
err := http.ListenAndServe(":8080", nil)
checkError(err)
}
func escapeString(rw http.ResponseWriter, req *http.Request) {
fmt.Println(req.URL.Path)
bytes, err := ioutil.ReadFile("." + req.URL.Path)
if err != nil {
rw.WriteHeader(http.StatusNotFound)
return
}
escapedStr := html.EscapeString(string(bytes))
htmlText := "<html><body><pre><code>" +
escapedStr +
" </code></pre></body></html>"
rw.Write([]byte(htmlText))
}
func checkError(err error) {
if err != nil {
fmt.Println("Error ", err.Error())
os.Exit(1)
}
}
当它运行时,从包括EscapeString.go
程序的目录中提供文件,浏览器将使用 URL localhost:8080/EscapeString.go
正确显示它。
使用以下命令运行服务器:
go run EscapeString.go
例如,使用以下命令运行客户端:
curl localhost:8080/EscapeString.go
Go HTML/模板包
对 web 服务器的攻击有很多种,其中最著名的是 SQL 注入,用户代理将数据输入到 web 表单中,该表单被故意设计为传递到数据库并在那里造成严重破坏。Go 没有任何特定的支持来避免这种情况,因为对于可以成功的 SQL 注入技术,数据库之间存在许多差异。SQL 注入预防备忘单(参见 https://www.owasp.org/index.php/SQL_Injection_Prevention_Cheat_Sheet
)总结了针对此类攻击的防御措施。主要的一点是通过使用 SQL 预准备语句来避免这种攻击,这可以通过使用database/sql
包中的Prepare
函数来完成。
更微妙的攻击是基于 XSS——跨站点脚本。在这种情况下,攻击者不是试图攻击网站本身,而是在服务器上存储恶意代码,以攻击该网站的任何客户端。
这些攻击基于将数据插入到数据库字符串中,例如,当将数据传送到浏览器时,将攻击浏览器,并通过它攻击网站的客户端。(这有几种变体,在“OWASP:跨站点脚本的类型”——https://www.owasp.org/index.php/Types_of_Cross-Site_Scripting
中讨论过)。)
例如,可以在请求博客评论的地方插入 JavaScript,以将浏览器重定向到攻击者的站点:
<script>
window.location='http://attacker/'
</script>
Go html/
template
包设计在text/template
包之上。假设模板是可信的,但它处理的数据可能不可信。html/template
增加的是数据的适当转义,以尽量消除 XSS 的可能性。它基于由 Mike Samuel 和 Prateek Saxena 撰写的名为“使用类型推断使 Web 模板抵抗 XSS”的文档。请在 https://rawgit.com/mikesamuel/sanitized-jquery-templates/trunk/safetemplate.html#problem_definition
阅读该论文,了解软件包背后理论以及软件包文档本身。
简而言之,按照text/template
包准备模板,如果结果文本被交付给 HTML 代理,则使用html/template
包。
标记 HTML
Go 子仓库中的包golang.org/x/net/html
包含一个 HTML 标记器。这允许您构建 HTML 标记的解析树。它符合 HTML5。
运行以下命令后可以使用它:
go get golang.org/x/net/html
使用它的一个示例程序是ReadHTML.go
:
/* Read HTML
*/
package main
import (
"fmt"
"golang.org/x/net/html"
"io/ioutil"
"os"
"strings"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "file")
os.Exit(1)
}
file := os.Args[1]
bytes, err := ioutil.ReadFile(file)
checkError(err)
r := strings.NewReader(string(bytes))
z := html.NewTokenizer(r)
depth := 0
for {
tt := z.Next()
for n := 0; n < depth; n++ {
fmt.Print(" ")
}
switch tt {
case html.ErrorToken:
fmt.Println("Error ", z.Err().Error())
os.Exit(0)
case html.TextToken:
fmt.Println("Text: \"" + z.Token().String() + "\"")
case html.StartTagToken, html.EndTagToken:
fmt.Println("Tag: \"" + z.Token().String() + "\"")
if tt == html.StartTagToken {
depth++
} else {
depth--
}
}
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
当它在一个简单的 HTML 文档上运行时,如下所示:
<html>
<head>
<title> Test HTML </title>
</head>
<body>
<h1> Header one </h1>
<p>
Test para
</p>
</body>
</html>
它产生以下内容:
Tag: "<html>"
Text: "
"
Tag: "<head>"
Text: "
"
Tag: "<title>"
Text: " Test HTML "
Tag: "</title>"
Text: "
"
Tag: "</head>"
Text: "
"
Tag: "<body>"
Text: "
"
Tag: "<h1>"
Text: " Header one "
Tag: "</h1>"
Text: "
"
Tag: "<p>"
Text: "
Test para
"
Tag: "</p>"
Text: "
"
Tag: "</body>"
Text: "
"
Tag: "</html>"
Text: "
"
(它产生的所有空白都是正确的。)
XHTML/HTML
XML 包中对 XHTML/HTML 的支持也是有限的,这将在下一章讨论。
数据
JSON 有很好的支持,如第四章所讨论的。
结论
这个包裹没什么特别的。关于模板的第九章讨论了子包html/template
。
十二、XML
XML 是一种重要的标记语言,主要用于以文本格式表示结构化数据。在我们在第四章使用的语言中,它可以被认为是将数据结构序列化为文本文档的一种手段。它用于描述 DocBook 和 XHTML 等文档。它用于专门的标记语言,如 MathML 和 CML(化学标记语言)。它用于将数据编码为 Web 服务的 SOAP 消息,并且可以使用 WSDL (Web 服务描述语言)来指定 Web 服务。
在最简单的层面上,XML 允许您定义自己的标签,以便在文本文档中使用。标签可以嵌套,也可以穿插文本。每个标签还可以包含带值的属性。例如,文件person.xml
可能包含:
<person>
<name>
<family> Newmarch </family>
<personal> Jan </personal>
</name>
<email type="personal">
jan@newmarch.name
</email>
<email type="work">
j.newmarch@boxhill.edu.au
</email>
</person>
任何 XML 文档的结构都可以用多种方式描述:
- 文档类型定义 DTD 有利于描述结构
- XML 模式适合描述 XML 文档使用的数据类型
- RELAX NG 被提议作为两者的替代方案
对于定义 XML 文档结构的每种方法的相对价值存在争议。我们不会买那个,因为 Go 不支持任何一个。Go 不能根据模式检查任何文档的有效性,只能检查文档的格式是否良好。甚至良构性也是 XML 文档的一个重要特征,并且在实践中经常是 HTML 文档的一个问题。这使得 XML 适合于表示非常复杂的数据,而 HTML 不适合。
本章讨论了四个主题:解析 XML 流、将 Go 数据编组和解组成 XML 以及 XHTML。
解析 XML
Go 有一个 XML 解析器,它是使用来自encoding/xml
包的NewDecoder
创建的。这将一个io.Reader
作为参数,并返回一个指向Decoder
的指针。这种类型的主要方法是Token
,它返回输入流中的下一个令牌。令牌是这些类型之一— StartElement
、EndElement
、CharData
、Comment
、ProcInst
或Directive
。
我们将使用这种类型:
type Name struct {
Space, Local string
}
XML 类型有StartElement
、EndElement
、CharData
、Comment
、ProcInst
和Directive
。接下来将对它们进行描述。
startellemon 类型
类型StartElement
是具有两种字段类型的结构:
type StartElement struct {
Name Name
Attr []Attr
}
在哪里
type Attr struct {
Name Name
Value string
}
EndElement 类型
这也是如下的结构:
type EndElement struct {
Name Name
}
CharData 类型
这种类型表示由标记括起的文本内容,是一种简单类型:
type CharData []byte
注释类型
类似地,对于这种类型:
type Comment []byte
ProcInst 类型
A ProcInst
表示形式为<?target inst?>
的 XML 处理指令:
type ProcInst struct {
Target string
Inst []byte
}
指令类型
一个Directive
表示一个形式为<!text>
的 XML 指令。这些字节不包括<!
和>
标记。
type Directive []byte
打印出 XML 文档树形结构的程序是ParseXML.go
:
/* Parse XML
*/
package main
import (
"encoding/xml"
"fmt"
"io/ioutil"
"os"
"strings"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "file")
os.Exit(1)
}
file := os.Args[1]
bytes, err := ioutil.ReadFile(file)
checkError(err)
r := strings.NewReader(string(bytes))
parser := xml.NewDecoder(r)
depth := 0
for {
token, err := parser.Token()
if err != nil {
break
}
switch t := token.(type) {
case xml.StartElement:
elmt := xml.StartElement(t)
name := elmt.Name.Local
printElmt(name, depth)
depth++
case xml.EndElement:
depth--
elmt := xml.EndElement(t)
name := elmt.Name.Local
printElmt(name, depth)
case xml.CharData:
bytes := xml.CharData(t)
printElmt("\""+string([]byte(bytes))+"\"", depth)
case xml.Comment:
printElmt("Comment", depth)
case xml.ProcInst:
printElmt("ProcInst", depth)
case xml.Directive:
printElmt("Directive", depth)
default:
fmt.Println("Unknown")
}
}
}
func printElmt(s string, depth int) {
for n := 0; n < depth; n++ {
fmt.Print(" ")
}
fmt.Println(s)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
注意,解析器包含所有的CharData
,包括标签之间的空白。
如果我们针对前面给出的person
数据结构运行这个程序,如下所示:
go run ParseXML.go person.xml
它产生以下内容:
person
"
"
name
"
"
family
" Newmarch "
family
"
"
personal
" Jan "
personal
"
"
name
"
"
email
"
jan@newmarch.name
"
email
"
"
email
"
j.newmarch@boxhill.edu.au
"
email
"
"
person
"
"
注意,因为没有使用 DTD 或其他 XML 规范,所以标记器正确地打印出了所有的空白(DTD 可能指定空白可以忽略,但是没有它就不能做出这样的假设)。
使用这个解析器有一个潜在的陷阱。它为字符串重用空间,因此一旦看到一个令牌,如果以后想引用它,就需要复制它的值。Go 有像func (c CharData) Copy() CharData
这样的方法来制作数据的副本。
解组 XML
Go 提供了一个名为Unmarshal
的函数来将 XML 解组到 Go 数据结构中。解组并不完美:Go 和 XML 是不同的语言。
在看细节之前,我们先考虑一个简单的例子。首先考虑前面给出的 XML 文档:
<person>
<name>
<family> Newmarch </family>
<personal> Jan </personal>
</name>
<email type="personal">
jan@newmarch.name
</email>
<email type="work">
j.newmarch@boxhill.edu.au
</email>
</person>
我们希望将其映射到 Go 结构中:
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Type string
Address string
}
这需要几点说明:
-
解组使用 Go 反射包。这要求所有字段都是公共的,即以大写字母开头。早期版本的 Go 使用不区分大小写的匹配来匹配字段,比如 XML 字符串“name”和字段
Name
。不过,现在使用的是区分大小写的匹配。要执行匹配,必须标记结构字段,以显示将要匹配的 XML 字符串。这将Person
更改为以下内容:type Person struct { Name Name `xml:"name"` Email []Email `xml:"email"` }
-
虽然对字段进行标记可以将 XML 字符串附加到字段上,但它不能对结构名进行标记。需要一个附加字段,字段名为
XMLName
。这只影响顶级结构,Person
:type Person struct { XMLName Name `xml:"person"` Name Name `xml:"name"` Email []Email `xml:"email"` }
-
重复的标签映射到 Go 中的一个切片。
-
只有当 Go 字段具有标签
,attr
时,标签中的属性才会与结构中的字段匹配。这发生在Email
的字段Type
中,其中匹配email
标签的属性type
需要xml:"type,attr"
。 -
如果一个 XML 标签没有属性,只有字符数据,那么它匹配一个同名的
string
字段(尽管区分大小写)。因此带有字符数据Newmarch
的标签xml:"family"
映射到字符串字段Family
。 -
但是如果标签有属性,那么它必须映射到一个结构。Go 将字符数据分配给标签为
,chardata
的字段。这发生在email
数据和tag ,chardata
字段Address
中。
解组上面文档的程序是Unmarshal.go
:
/* Unmarshal
*/
package main
import (
"encoding/xml"
"fmt"
"os"
)
type Person struct {
XMLName Name `xml:"person"`
Name Name `xml:"name"`
Email []Email `xml:"email"`
}
type Name struct {
Family string `xml:"family"`
Personal string `xml:"personal"`
}
type Email struct {
Type string `xml:"type,attr"`
Address string `xml:",chardata"`
}
func main() {
str := `<?xml version="1.0" encoding="utf-8"?>
<person>
<name>
<family> Newmarch </family>
<personal> Jan </personal>
</name>
<email type="personal">
jan@newmarch.name
</email>
<email type="work">
j.newmarch@boxhill.edu.au
</email>
</person>`
var person Person
err := xml.Unmarshal([]byte(str), &person)
checkError(err)
// now use the person structure e.g.
fmt.Println("Family name: \"" + person.Name.Family + "\"")
fmt.Println("Second email address: \"" + person.Email[1].Address + "\"")
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
(注意空格是正确的。)封装规范中给出了严格的规则。
编组 XML
Go 还支持将数据结构组织成 XML 文档。功能是:
func Marshal(v interface}{) ([]byte, error)
整理一个简单结构的程序是Marshal.go
:
/* Marshal
*/
package main
import (
"encoding/xml"
"fmt"
)
type Person struct {
Name Name
Email []Email
}
type Name struct {
Family string
Personal string
}
type Email struct {
Kind string "attr"
Address string "chardata"
}
func main() {
person := Person{
Name: Name{Family: "Newmarch", Personal: "Jan"},
Email: []Email{Email{Kind: "home", Address: "jan"},
Email{Kind: "work", Address: "jan"}}}
buff, _ := xml.Marshal(person)
fmt.Println(string(buff))
}
它生成不带空格的文本:
<Person><Name><Family>Newmarch</Family><Personal>Jan</Personal></Name><Email><Kind>home</Kind><Address>jan</Address></Email><Email><Kind>work</Kind><Address>jan</Address></Email></Person>
可扩展的超文本标记语言
HTML 不符合 XML 语法。它有未终止的标签,如<br>
。XHTML 是对 HTML 的清理,使其符合 XML。XHTML 中的文档可以使用上述 XML 技术进行管理。XHTML 似乎没有像最初预期的那样被广泛使用。我个人的怀疑是,HTML 解析器通常是容忍错误的,当在浏览器中使用时,通常可以合理地呈现文档,即使在浏览器中,XHTML 解析器也往往更加严格,经常在遇到甚至一个 XML 错误时也不能呈现任何内容。对于面向用户的软件来说,这通常不是合适的行为。
超文本标记语言
XML 包中有一些处理 HTML 文档的支持,即使它们可能不符合 XML。如果关闭严格的解析检查,前面讨论的 XML 解析器可以处理许多 HTML 文档。
parser := xml.NewDecoder(r)
parser.Strict = false
parser.AutoClose = xml.HTMLAutoClose
parser.Entity = xml.HTMLEntity
结论
Go 具有处理 XML 字符串的基本支持。它还没有处理 XML 规范语言(如 XML Schema 或 Relax NG)的机制。
十三、远程过程调用
套接字和 HTTP 编程都使用消息传递范式。客户端向服务器发送消息,服务器通常会发回消息。双方都负责以双方都能理解的格式创建消息,并从这些消息中读取数据。
然而,大多数独立的应用程序很少使用消息传递技术。通常,首选机制是函数(或方法或过程)调用的机制。在这种风格中,程序将调用一个带有参数列表的函数,在函数调用完成时,将有一组返回值。这些值可能是函数值,或者如果地址已作为参数传递,则这些地址的内容可能已被更改。
远程过程调用是将这种编程风格引入网络世界的一种尝试。因此,客户端将进行看起来像正常的过程调用。客户端会把这个打包成一个网络消息,传输给服务器。服务器将对其进行解包,并将其转换回服务器端的过程调用。这个调用的结果将被打包返回给客户端。
概略地看起来如图 13-1 所示。
图 13-1。
The remote procedure call steps
步骤如下:
- 客户端调用客户端过程存根。存根将参数打包成网络消息。这叫做编组。
- 存根调用 O/S 内核中的网络例程来发送消息。
- 内核将消息发送到远程系统。这可能是面向连接的,也可能是无连接的。
- 服务器过程存根从网络消息中解组参数。
- 服务器过程存根执行服务器过程实现。
- 过程完成,将执行返回给服务器过程存根。
- 服务器存根将返回值整理成网络消息。
- 返回消息被发送回来。
- 客户端程序存根使用网络例程读取消息。
- 消息被解组,返回值被设置在客户端程序的堆栈上。
实现 RPC 有两种常见的方式。第一种以 Sun 的 ONC/RPC 和 CORBA 为代表。在这种情况下,服务的规范以某种抽象语言给出,例如 CORBA IDL(接口定义语言)。然后将其编译成用于客户端和服务器的代码。然后,客户端编写一个包含对过程/函数/方法的调用的普通程序,该程序链接到生成的客户端代码。服务器端代码实际上是一个服务器本身,它链接到您编写的过程实现。
在第一种方式中,客户端代码在外观上与普通的过程调用几乎相同。通常有一点额外的代码来定位服务器。在 Sun 的 ONC 中,服务器的地址必须是已知的;在 CORBA 中,调用一个命名服务来查找服务器的地址;在 Java RMI 中,IDL 就是 Java 本身,命名服务用于查找服务的地址。
在第二种风格中,您必须使用特殊的客户端 API。您将函数名及其参数传递给客户端的这个库。在服务器端,您必须自己显式地编写服务器,以及远程过程实现。
第二种方法被许多 RPC 系统使用,比如 Web 服务。这也是 Go 的 RPC 使用的方法。
Go 的 RPC
Go 的 RPC 是迄今为止唯一的 Go。它不同于其他的 RPC 系统,所以 Go 客户端只与 Go 服务器对话。它使用第四章中讨论的 Gob 序列化系统,该系统定义了可以使用的数据类型。
RPC 系统通常对可以通过网络调用的函数做一些限制。这是为了让 RPC 系统可以正确地确定发送哪些值参数,哪些引用参数接收答案,以及如何发出错误信号。
在 Go 中,限制是
- 方法的类型被导出(以大写字母开始)。
- 该方法被导出。
- 该方法有两个参数,都是导出(或内置)类型。第一个是传递给方法的数据;第二个是返回的结果。
- 该方法的第二个参数是一个指针。
- 它具有类型为
error
的返回值。
例如,下面是一个有效的函数:
F(T1, &T2) error
对参数的限制意味着您通常必须定义一个结构类型。Go 的 RPC 使用gob
包来编组和解组数据,因此参数类型必须遵循 Gob 的规则,如前一章所述。
我们将遵循 Go 文档中给出的例子,因为它说明了重要的几点。服务器执行两个简单的操作——它们不需要 RPC 的“咕哝”,但是很容易理解。这两个操作是将两个整数相乘,并找出第一个整数除以第二个整数后的商和余数。
要操作的两个值在结构中给出:
type Values struct {
A, B int
}
总和只是一个int
,而商/余数是另一个结构:
type Quotient struct {
Quo, Rem int
}
我们将有两个函数,multiply
和divide
,可以在 RPC 服务器上调用。这些函数需要在 RPC 系统中注册。Register
函数接受单个参数,这是一个接口。所以我们需要一个具有这两个功能的类型:
type Arith int
func (t *Arith) Multiply(args *Args, reply *int) error {
*reply = args.A * args.B
return nil
}
func (t *Arith) Divide(args *Args, quo *Quotient) error {
if args.B == 0 {
return errors.New("divide by zero")
}
quo.Quo = args.A / args.B
quo.Rem = args.A % args.B
return nil
}
Arith
的底层类型给定为int
。没关系,任何类型都可以。
现在可以使用Register
注册这种类型的对象,然后 RPC 系统可以调用它的方法。
HTTP RPC 服务器
任何 RPC 都需要一种传输机制来通过网络获取消息。Go 可以使用 HTTP 或者 TCP。HTTP 机制的优点是它可以利用 HTTP 支持库。您需要向 HTTP 层添加一个 RPC 处理程序,这是使用HandleHTTP
完成的,然后启动一个 HTTP 服务器。完整的代码是ArithServer.go
:
/**
* ArithServer
*/
package main
import (
"fmt"
"net/rpc"
"errors"
"net/http"
)
type Values struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
type Arith int
func (t *Arith) Multiply(args *Values, reply *int) error {
*reply = args.A * args.B
return nil
}
func (t *Arith) Divide(args *Values, quo *Quotient) error {
if args.B == 0 {
return errors.New("divide by zero")
}
quo.Quo = args.A / args.B
quo.Rem = args.A % args.B
return nil
}
func main() {
arith := new(Arith)
rpc.Register(arith)
rpc.HandleHTTP()
err := http.ListenAndServe(":1234", nil)
if err != nil {
fmt.Println(err.Error())
}
}
它是由
go run ArithServer.go
HTTP RPC 客户端
客户端需要建立到 RPC 服务器的 HTTP 连接。它需要准备一个包含要发送的值的结构,以及存储结果的变量的地址。然后它可以用这些参数做一个Call
:
- 要执行的远程函数的名称
- 要发送的值
- 存储结果的变量的地址
调用算术服务器的两个功能的客户端是ArithClient.go
:
/**
* ArithClient
*/
package main
import (
"net/rpc"
"fmt"
"log"
"os"
)
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "server")
os.Exit(1)
}
serverAddress := os.Args[1]
client, err := rpc.DialHTTP("tcp", serverAddress+":1234")
if err != nil {
log.Fatal("dialing:", err)
}
// Synchronous call
args := Args{17, 8}
var reply int
err = client.Call("Arith.Multiply", args, &reply)
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d*%d=%d\n", args.A, args.B, reply)
var quot Quotient
err = client.Call("Arith.Divide", args, ")
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d/%d=%d remainder %d\n", args.A, args.B, quot.Quo, quot.Rem)
}
运行时:
go run ArithClient.go localhost
它产生以下内容:
Arith: 17*8=136
Arith: 17/8=2 remainder 1
TCP RPC 服务器
使用 TCP 套接字的服务器版本是TCPArithServer.go
:
/**
* TCPArithServer
*/
package main
import (
"fmt"
"net/rpc"
"errors"
"net"
"os"
)
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
type Arith int
func (t *Arith) Multiply(args *Args, reply *int) error {
*reply = args.A * args.B
return nil
}
func (t *Arith) Divide(args *Args, quo *Quotient) error
{
if args.B == 0 {
return errors.New("divide by zero")
}
quo.Quo = args.A / args.B
quo.Rem = args.A % args.B
return nil
}
func main() {
arith := new(Arith)
rpc.Register(arith)
tcpAddr, err := net.ResolveTCPAddr("tcp", ":1234")
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
/* This works:
rpc.Accept(listener)
*/
/* and so does this:
*/
for {
conn, err := listener.Accept()
if err != nil {
continue
}
rpc.ServeConn(conn)
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
注意对Accept
的调用是阻塞的,只处理客户端连接。如果服务器还想做其他工作,它应该在一个go
例程中调用它。
TCP RPC 客户端
使用 TCP 服务器并调用算术服务器的两个功能的客户端是TCPArithClient.go
:
/**
* TCPArithClient
*/
package main
import (
"net/rpc"
"fmt"
"log"
"os"
)
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "server:port")
os.Exit(1)
}
service := os.Args[1]
client, err := rpc.Dial("tcp", service)
if err != nil {
log.Fatal("dialing:", err)
}
// Synchronous call
args := Args{17, 8}
var reply int
err = client.Call("Arith.Multiply", args, &reply)
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d*%d=%d\n", args.A, args.B, reply)
var quot Quotient
err = client.Call("Arith.Divide", args, ")
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d/%d=%d remainder %d\n", args.A, args.B, quot.Quo, quot.Rem)
}
当它运行时:
go run TCPArithClient.go localhost:1234
它产生以下内容:
Arith: 17*8=136
Arith: 17/8=2 remainder 1
匹配值
您可能已经注意到,在 HTTP 客户机和 HTTP 服务器上,值参数的类型是不同的。在服务器端,我们使用了Values
,而在客户端,我们使用了Args
。这没关系,因为我们遵循 Gob 序列化的规则,并且两个结构的字段的名称和类型是匹配的。更好的编程实践会说,名字当然应该是相同的!
然而,这确实指出了使用 Go RPC 的一个可能的陷阱。如果我们将服务器的结构改为这样:
type Values struct {
C, B int
}
那 Gob 就没问题了。在客户端,解组将忽略服务器给定的值C
,并使用默认的零值A
。比如说,如果除以A
(零)就会产生问题。
使用 Go RPC 将需要程序员严格执行字段名和类型的稳定性。我们注意到没有版本控制机制来做到这一点,Gob 中也没有机制来通知任何可能的不匹配。也不需要外部表示作为参考。如果你只是添加字段,这可能是好的,但它仍然需要控制。也许在数据结构中添加一个version
字段会有所帮助。
数据
这一节没有给前面的概念增加新的内容。它只是对数据使用了不同的“有线”格式,用 JSON 代替 Gob。因此,客户机或服务器可以用理解套接字和 JSON 的其他语言编写。
JSON RPC 服务器
使用 JSON 编码的服务器版本是JSONArithServer.go
:
/* JSONArithServer
*/
package main
import (
"fmt"
"net/rpc"
"net/rpc/jsonrpc"
"os"
"net"
"errors"
)
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
type Arith int
func (t *Arith) Multiply(args *Args, reply *int) error {
*reply = args.A * args.B
return nil
}
func (t *Arith) Divide(args *Args, quo *Quotient) error {
if args.B == 0 {
return errors.New("divide by zero")
}
quo.Quo = args.A / args.B
quo.Rem = args.A % args.B
return nil
}
func main() {
arith := new(Arith)
rpc.Register(arith)
tcpAddr, err := net.ResolveTCPAddr("tcp", ":1234")
checkError(err)
listener, err := net.ListenTCP("tcp", tcpAddr)
checkError(err)
/* This works:
rpc.Accept(listener)
*/
/* and so does this:
*/
for {
conn, err := listener.Accept()
if err != nil {
continue
}
jsonrpc.ServeConn(conn)
}
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
它是由
go run JSONArithServer.go
JSON RPC 客户端
调用算术服务器的两个功能的客户端是JSONArithClient.go
:
/* JSONArithCLient
*/
package main
import (
"net/rpc/jsonrpc"
"fmt"
"log"
"os"
)
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "server:port")
log.Fatal(1)
}
service := os.Args[1]
client, err := jsonrpc.Dial("tcp", service)
if err != nil {
log.Fatal("dialing:", err)
}
// Synchronous call
args := Args{17, 8}
var reply int
err = client.Call("Arith.Multiply", args, &reply)
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d*%d=%d\n", args.A, args.B, reply)
var quot Quotient
err = client.Call("Arith.Divide", args, ")
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d/%d=%d remainder %d\n", args.A, args.B, quot.Quo, quot.Rem)
}
它是这样运行的:
go run JSONArithClient.go localhost:1234
它产生以下输出:
Arith: 17*8=136
Arith: 17/8=2 remainder 1
结论
RPC 是一种流行的分发应用程序的方法。这里介绍了几种方法,基于 Gob 或 JSON 序列化技术,使用 HTTP 和 TCP 进行传输。
十四、REST
在前几章中,我们研究了 HTTP 并给出了一个 web 系统的例子。然而,我们并没有给这个系统任何特别的结构,只是给出了对这个问题来说足够简单的结构。HTTP 1.1 的主要作者之一(Roy Fielding)开发了一种架构风格,称为 REST(表述性状态转移)。在这一章中,我们来看看 REST 风格以及它对构建 web 应用程序的意义。为此,我们必须回到基本面。
如果术语 REST 可以被正确地应用,REST 有许多必须遵循的组件。不幸的是,它已经成为一个时髦的词,许多应用程序有“一些”REST,但不是全部。我们讨论 Richardson 成熟度模型,它表明了 API 在通往 RESTful-ness 的道路上已经走了多远。
在上一章中,我们看了远程过程调用。这是和 REST 完全不同的风格。我们还比较了这两种风格,看看什么时候适合使用每种风格。
URIs 和资源
资源是我们希望在网络或互联网上与之交互的“东西”。我喜欢把它们看作对象,但是并不要求它们的实现应该是基于对象的——它们应该只是“看起来”像一个东西,可能有组件。
每个资源都有一个或多个称为 URIs(统一资源标识符)的地址。
Note
国际化形式是 IRIs—国际化资源标识符。
它们的一般形式如下:
scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
典型的例子是 URL(统一资源定位符),其中方案是http
或https
,主机通过其 IP 地址或 DNS 名称来指代计算机,如下所示:
https://jan.newmarch.name/IoT/index.html
有非 HTTP URL 方案,如 telnet、news 和 ipp(互联网打印协议)。这些还包含位置组件。还有其他的,比如 urn(统一资源名),它们通常是其他标识系统的包装器,并且不包含位置信息。例如,IETF 有一个由 ISBN 标识的图书的标准 URN 方案,例如这本书的 ISBN:
urn:ISBN:978-1-4842-2692-6
这些骨灰盒往往不会被广泛使用,但仍然存在。在 https://www.iana.org/assignments/urn-namespaces/urn-namespaces.xhtml
由 IANA 统一资源名称(URN)命名空间给出列表。最初的方案,如 ISBN,仍在广泛使用。
资源的正式定义可能很难确定。例如, http://www.google.com
在某种意义上代表谷歌(它是一个 URL 的方案和主机部分),但主机肯定不是某个地方的某台固定电脑。类似地,这本书的 ISBN 代表了这本书的一些内容,但肯定不是任何现存的副本(在写这一章的时候,没有副本存在,即使 ISBN 存在!).
然而,我们认为资源的概念是原始的,URIs 是这些资源的标识符。IETF at 统一资源标识符(URI):通用语法( https://www.ietf.org/rfc/rfc3986.txt
)也同样含糊:“术语“资源”在一般意义上用于 URI 可能标识的任何东西”。
一个资源可以有多个 URI。作为一个人,我有许多不同的标识符:我的税务档案号指的是我的一个方面,我的财务;我的 Medicare 号码将我视为健康治疗的接受者;我的名字(相当独特)经常被用来指我的不同方面。我的网址指的是我选择在我的网站上展示的那些方面。以及谷歌、领英、脸书、推特等。大概也有某种 URIs 给我那些他们选择保存的方面贴上了标签。
达成共识的是,资源是名词,而不是动词或形容词。一个写着http://mybank/myaccount/withdraw
的银行账户的 URL 不被算作资源,因为它包含动词withdraw
。类似地,http://amazon.com/buy/book-id
不会标记包含动词buy
的资源(亚马逊没有这样的 URL)。
这是 REST for HTTP 的第一个关键:识别信息系统中的资源,并为它们分配 URL。这里有一些约定,最常见的是如果有一个层次结构,那么它应该反映在 URL 路径中。然而,这是不必要的,因为信息也应该以其他方式给出。
设计 URIs 的 REST 方法仍然是一种艺术形式。合法的(并且完全合法的)URIs 不一定是“好的”REST URIs,许多所谓 RESTful APIs 的例子都有一点都不 RESTful 的 URIs。2PartsMagic in RESTful URI 设计( http://blog.2partsmagic.com/restful-uri-design/
)提供了设计合适的 URIs 的好建议。
陈述
资源的表示是以某种形式捕获关于资源的一些信息。例如,我在 URI 的税务局的代表可能是我在澳洲的纳税申报表。我在当地披萨咖啡馆的表现就是购买披萨的记录。在我的网站上,我的一个表现就是一个 HTML 文档。
这是 REST 的关键之一:URIs 识别资源,对该资源的请求返回该资源的表示。资源本身保留在服务器上,根本不会发送到客户端。事实上,这种资源甚至可能根本不存在于任何具体的形式中。例如,一个表示可能是从一个 SQL 查询的结果生成的,该查询是通过向 URI 发出请求而触发的。
REST 并不特别讨论协商资源表示的可能性。HTTP 1.1 有一个关于如何做到这一点的详细章节,考虑了服务器、客户机和透明协商。客户端可以使用Accept
头来指定,例如:
Accept: application/xml; q=1.0, application/json; q=0.5
Accept-Language: fr
Accept-Charset: utf8
这表明它倾向于使用application/xml
格式,但会接受application/json
。服务器可以接受其中的一个,或者用它将接受的格式回复。
REST 动词
你可以向 URI 提出某些要求。如果你向一个 URL 发出一个 HTTP 请求,HTTP 定义了可以发出的请求:GET
、PUT
、POST
、DELETE
、HEAD
、OPTIONS
、TRACE
和CONNECT
,以及像PATCH
这样的扩展。这些数量有限!这与我们对 O/O 编程的期望大相径庭。比如 Java JLabel 有大约 250 个方法,比如getText
、setHorizontalAlignment
等。
REST 现在通常被解释为只从 HTTP 中提取四个动词:GET
、PUT
、POST
、DELETE. GET
大致对应于 O/O 语言的 getter-methods,而PUT
大致对应于 O/O 语言的 setter-methods。如果 JLabel 是一个 REST 资源(事实并非如此),那么一个动词如何组成 JLabel 的上百个 getter-methods 呢?
答案在于 URIs 的组成部分。标签具有文本、对齐等属性。这些实际上是标签的子资源,可以写成标签的子 URIs。因此,如果标签有一个 URIhttp://jan.newmarch.name/my_label
,那么子资源可以有 URIs:
http://jan.newmarch.name/my_label/text
http://jan.newmarch.name/my_label/horizontalAlignment
如果只想操作标签的文本,可以使用文本资源的 URI,而不是标签本身的 getter/setter 方法。
GET 动词
要检索一个资源的表示,您需要GET
这个资源。这将返回资源的一些表示。这个选择可能有无数种可能性。例如,对这本书的索引的请求可能返回一个用法语表示的索引,使用 UTF 8 字符集,作为 XML 文档,或者许多其他的可能性。客户端和服务器可以协商这些可能性。
GET
动词要求是幂等的。也就是说,重复的请求应该返回相同的结果(在表示类型内)。例如,对传感器温度的多次请求应该返回相同的结果(当然,除非温度已经改变)。
缺省情况下,幂等性允许缓存。这有助于减少网络流量,并可节省传感器的电池电量。缓存并不总是有保证的:一个返回其被访问次数的资源在每次被访问时都会给出不同的结果。这是不寻常的行为,将使用 HTTP Cache-Control
头发出信号。
PUT 动词
如果你想改变一个资源的状态,你可以PUT
新的值。PUT
有两个主要限制:
- 您只能更改 URI 已知的资源的状态
- 您发送的表示必须涵盖资源的所有组件
例如,如果你只想改变一个标签中的文本,你发送PUT
消息到 URL http://jan.newmarch.name/my_label/text
,而不是发送到http://jan.newmarch.name/my_label
。发送到标签需要发送大约一百个字段。
PUT
是幂等的,但不安全。也就是说,它改变了资源的状态,但是重复的调用会将其改变到相同的状态。
PUT
和DELETE
不是 HTML 的一部分,大多数浏览器都不直接支持。可以在支持 Ajax 的浏览器中调用它们。关于为什么不包括它们,有几种讨论。例如,请参见“为什么 HTML 表单上没有 PUT 和 DELETE 方法?” http://softwareengineering.stackexchange.com/questions/114156/why-are-there-are-no-put-and-delete-methods-on-html-forms
。
删除动词
这将删除资源。它是幂等的,但不安全。
后置动词
是 do-everything-else 动词,用于处理其他动词未涵盖的情况。关于POST
的两种用法有共识:
- 如果你想创建一个新的资源,但你不知道它的 URI,那么
POST
一个知道如何创建资源的 URI 的资源表示。返回的表示应该包含新资源的 URI。这很重要。要与一个新资源交互,你必须知道它的 URI,从POST
的返回告诉你这一点。 - 如果一个资源有许多属性,而你只想改变其中的一个或几个,那么
POST
一个只包含改变后的值的表示
关于边缘案例中PUT
和POST
各自的作用存在激烈的争论。如果您想创建一个新资源,并且知道它将拥有的 URI,那么您可以使用PUT
或POST
。你选择哪一个似乎取决于其他因素…
SOAP 被设计成 HTTP 之上的 RPC 系统。它使用POST
做任何事情。HTML 继续在表单中使用POST
,而它应该可以选择使用PUT
。出于这些原因,除非万不得已,否则我不会使用POST
。我想其他人使用POST
而不是PUT
有他们自己的原则原因,但我不知道他们可能是什么:-)。
由于其开放的范围,POST
几乎可以用于任何事情。正如 SOAP 充分说明的那样,这些使用中的许多都可能违反 REST 模型。但是其中一些使用可能是合法的。POST
通常是非幂等的,也不安全,尽管特殊情况下可能是这两种情况之一。
无维护状态
让我们预先确定这一点:cookies 是过时的。Cookies 通常用于通过与服务器的交互来跟踪用户的状态,典型的例子是购物车。在服务器端创建一个结构,并返回一个 cookie 来表示这是要使用的购物车。
REST 决定不在服务器上维护任何客户端状态。这简化了交互,也回避了客户机或服务器崩溃后如何恢复一致性的棘手问题。如果服务器不需要维护任何状态,那么它会导致更健壮的服务器模型。
如果你不能使用 cookies,你会怎么做?这其实很简单:在服务器上创建一个购物车。在 REST 下,这只能在响应一个POST
请求时发生,该请求返回新资源的新 URI。这就是你用的——新的 URI。你可以GET
、PUT
、POST
和DELETE
到这个 URI,直接在资源上做所有你想做的事情,而不需要用 cookies 做变通。
恨死我了
HATEOAS 代表“作为应用程序状态引擎的超媒体”。人们普遍认为这是一个糟糕的首字母缩写,但它一直存在。基本原则是,从一个 URI 导航到以某种方式相关的另一个,不应该由任何带外机制来完成,而是新链接必须以某种方式作为超链接嵌入第一个 URI 的表示中。
REST 没有说明链接的格式。它们可以通过 HTML 链接标签、嵌入在 PDF 文档中的 URL 或 XML 文档中的链接给出。没有简单 URL 表示的格式不被认为是超媒体语言,也不包含在 REST 中。
另外,REST 也没有明确说明链接的含义,也没有说明如何提取适当的链接。菲尔丁在 http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven
的博客《REST APIs 必须是超文本驱动的》中写道:
- 输入 REST API 时,除了初始 URI(书签)和一组适用于目标受众的标准化媒体类型(即,任何可能使用该 API 的客户端都应该理解)之外,不应该有任何先验知识。从那时起,所有应用程序状态转换都必须由客户端选择服务器提供的选项来驱动,这些选项出现在接收到的表示中,或者由用户对这些表示的操作来暗示。
IANA 维护一个可以使用的关系类型注册表(IANA:在 http://www.iana.org/assignments/link-relations/link-relations.xhtml
的链接关系)。网页链接 RFP5988 描述了网页链接注册表。HTML5 规范定义了少量的关系,并在 http://microformats.org/wiki/existing-rel-values#HTML5_link_type_extensions
处指向微格式rel
值以获得更大的列表。
像 cookies 这样的机制,或者像 SOAP 的 WSDL 这样的外部 API 规范,都被 REST 有效地排除了。它们不是包含在资源表示中的超链接。
表示链接
HTML 文档中的链接是标准化的。标签定义了一个只能出现在 HTML 头部分的 HTML 元素。比如有章节的书等等。,如果链接以 HTML link
元素的形式给出,可能是这样的:
<html>
<head>
<link rel= "author" title="Jan Newmarch" href="https://jan.newmarch.name">
<link rel="chapter" title="Introduction" href="Introduction/">
...
HTML 中的链接关系有两种类型:一种是当前文档需要的,比如 CSS 文件,另一种是指向相关资源的,如上所述。第一种类型通常是用户不可见地下载的。第二种类型一般不会被浏览器显示,但是遵循 HATEOAS 原则的用户代理会使用它们。
XML 有各种各样的链接规范。其中包括 XLink 和 Atom。Atom 似乎更受欢迎。
基于 XLink 的链接如下所示:
<People xmlns:xlink="http://www.w3.org/1999/xlink">
<Person xlink:type="simple" xlink:href="http://...">
...
</Person>
...
</People>
基于 Atom 的链接如下所示:
<People xmlns:atom="http://www.w3.org/2005/Atom">
<Person>
<link atom:href="http://..."/>
...
</Person>
...
</People>
对于 JSON,这种格式是不规范的。REST cookbook ( http://restcookbook.com/Mediatypes/json/
)指出了标准化的缺乏,并指出 W3C 规范 JSON-LD 1.0:“基于 JSON 的链接数据序列化”和 HAL(超文本应用语言)。像开放连接基金会这样的机构似乎使用他们自己开发的格式,但那是针对 CoAP 的,另一个基于 REST 的系统。
JSON-LD 使用术语@id
来表示 URL,如:
{
"name": Jan Newmarch:,
"homepage": {"@id": "https://jan.newmarch.name/"}
}
在这方面值得注意的是,W3C 也有一个在 https://www.w3.org/wiki/LinkHeader
的 HTTP 链接头的规范,它可以由服务器返回给客户端。例如,JSON-LD 使用它来指向包含在 HTTP 响应主体中的 JSON 文档的规范。
这可能会影响将链接信息从服务器传递到用户代理的序列化方法。用户代理和服务器必须就要使用的格式达成一致。对于 HTML(或 XHTML),这是标准化的。对于 XML,可以在文档中引用链接系统。对于 JSON-LD,这可以在Accept HTTP
头中作为application/ld+json
发出信号。
REST 交易
REST 是如何处理事务以及其他进程的?在菲尔丁的原始论文中没有讨论它们。
HATEOAS 的 Wikipedia 条目给出了一个管理事务的糟糕例子。它从这样一个 HTTP 请求开始:
GET /account/12345 HTTP/1.1
Host: somebank.org
Accept: application/xml
...
它返回一个 XML 文档作为帐户的表示:
HTTP/1.1 200 OK
Content-Type: application/xml
Content-Length: ...
<?xml version="1.0"?>
<account>
<account_number>12345</account_number>
<balance currency="usd">100.00</balance>
<link rel="deposit" href="http://somebank.org/account/12345/deposit" />
<link rel="withdraw" href="http://somebank.org/account/12345/withdraw" />
<link rel="transfer" href="http://somebank.org/account/12345/transfer" />
<link rel="close" href="http://somebank.org/account/12345/close" />
</account>
这提供了相关资源存放、提取、转移和关闭的 URIs。然而,资源是动词而不是名词,这一点也不好。它们如何与 HTTP 动词交互?你要退出吗?POST
它?PUT
它?如果您DELETE
撤销,会发生什么——是要回滚事务还是什么?
更好的方法,例如,在 Stackoverflow 发布的“REST 中的事务?”(参见 http://stackoverflow.com/questions/147207/transactions-in-rest
)是向账户POST
请求创建新的交易:
POST /account/12345/transaction HTTP/1.1
这将返回新交易的 URL:
http://account/12345/txn123
现在使用这个事务 URL 进行交互,例如通过使用一个新的值来执行和提交事务。
PUT /account/12345/txn123
<transaction>
<from>/account/56789</from>
<amount>100</amount>
</transaction>
Mihindukulasooriya 等人给出了关于事务和 REST 的更详细的讨论。al 在《RESTful 事务模型的七大挑战》(见 http://ws-rest.org/2014/sites/default/files/wsrest2014_submission_4.pdf
)。类似的模型被提议用于管理不仅仅是单一步骤的过程。
理查森成熟度模型
许多系统声称是 RESTful 的。大多数都不是。我甚至碰到一个声称 SOAP 是 RESTful 的,这是一个扭曲的精神状态的明显例子。Martin Fowler 讨论了 Richardson 成熟度模型,该模型根据系统对 REST 的符合性对系统进行分类。(见 https://martinfowler.com/articles/richardsonMaturityModel.html
)。)
0 级
- 该模型的起点是使用 HTTP 作为远程交互的传输系统,但不使用任何 web 机制。本质上,您在这里所做的是使用 HTTP 作为您自己的远程交互机制的隧道机制,通常基于远程过程调用。
级别 1:资源
- 走向 RMM 中 Rest 辉煌的第一步是引入资源。因此,现在我们开始与单个资源对话,而不是向单个服务端点发出所有请求。
第二层:HTTP 动词
- 我在 0 级和 1 级的所有交互中都使用了 HTTP POST 动词,但是有些人使用 GETs 来代替或附加。在这些级别上,没有太大的区别,它们都被用作隧道机制,允许您通过 HTTP 隧道化您的交互。第 2 级远离了这一点,使用了尽可能接近 HTTP 本身使用方式的 HTTP 动词。
级别 3:超媒体控件
- 最后一层引入了你经常听到的 HATEOAS(作为应用程序状态引擎的超文本)的丑陋缩写。它解决了如何从空缺职位列表中了解如何预约的问题。
重温抽认卡
在第十章中,我们考虑了一个由服务器和浏览器中呈现的 HTML 页面组成的 web 系统,使用 JavaScript 和 CSS 来控制浏览器端的交互。没有试图做任何特别结构化的东西,而只是作为一个传统的网络系统。
Recap
第十章的网络系统被用来演示使用所谓的抽认卡学习语言。一次给用户一组卡片,显示一种语言的单词,然后希望记住翻译,通过“翻转”卡片来显示。该系统提供了一个不同卡片组的列表,然后在选定的卡片组中一次显示一张卡片。
我们现在处理与使用 REST 方法构建的 HTTP 客户机-服务器系统相同的情况。我们将做出一些改变:
- 将根据具体情况给出相应的 URL。这将包括“根”URL
/
以及每个抽认卡组的 URL,此外,还有每个抽认卡的 URL。 - 所有用户交互代码(HTML、JavaScript 和 CSS)都被省略了。服务器将与一个任意的用户代理对话,许多人不理解 UI 代码。
- 服务器不会维护或管理任何客户端状态。在 web 示例中,表单数据从浏览器发送到服务器,服务器立即以略有不同的形式返回。想要维护状态的客户端应该自己来做。
- 服务器将被设置为管理许多不同的序列化格式,并将在客户端-服务器协商后适当地交付。
- 大量使用 HTTP 机制,特别是错误处理和内容协商。
资源定位符
该系统的 URL 以及可以执行的操作如下:
| 统一资源定位器 | 行动 | 影响 | | `/` | `GET` | 获取抽认卡集的列表 | | `POST` | 创建新的抽认卡集 | | `/flashcardSets/这与第十章中描述的系统略有不同。主要的结构差异是每张卡都有自己的 URL 作为抽认卡组的成员。
将由服务器处理的示例 URL 包括:
| 根 URL | 抽认卡组的 URL | 一个抽认卡的 URL | | `/` | `/flashcardSet/CommonWords` | `/flashcardSet/CommonWords/你好` |多路分解器
REST 基于应用于 URL 的少量动作。试图使用 REST 原则的系统必须是基于 URL 的。
服务器解复用器将根据 URL 模式检查客户端和调用处理程序请求的 URL。标准的 Go demuxer ServeMux
使用了一种特殊的模式匹配机制:如果一个 URL 以/
结尾,那么它表示以该 URL 为根的 URL 的子树。如果它以没有/
结尾,那么它只代表那个 URL。将 URL 与具有最长模式匹配的处理程序进行匹配。
我们需要一个根 URL 的处理程序/
。这也将匹配任何 URL,如/passwords
,除非另一个处理程序捕捉到它。在这个系统中,没有其他处理程序会这样做,所以在/
的处理程序中,我们需要为这样的尝试返回错误。
一个棘手的部分出现了,因为我们对我们的 URL 使用了层次结构。一组特殊的抽认卡是/flashcardSets/CommonWords
。这将实际上是一个特定的一套卡的目录。我们必须注册两个处理程序:一个用于 URL /flashcardSets/CommonWords
,这是抽认卡集资源,另一个用于/flashcardSets/CommonWords/
(注意后面的/
),这是包含各个卡及其 URL 的子树。
注册这些的主函数中的代码如下:
http.HandleFunc(`/`, handleFlashCardSets)
files, err := ioutil.ReadDir(`flashcardSets`)
checkError(err)
for _, file := range files {
cardset_url := `/flashcardSets/` + url.QueryEscape(file.Name())
http.HandleFunc(cardset_url, handleOneFlashCardSet)
http.HandleFunc(cardset_url + `/`, handleOneFlashCard)
}
注意我们有函数QueryEscape
。这是为了转义 URL 中可能出现的任何特殊字符。例如,文件名中的$
应该编码为%44;
。我们确实需要使用这样一个函数:我们的 URL 将包含中文字符,这些字符需要进行转义编码才能在 URL 中表示。这是由QueryEscape
完成的,只有一个例外:路径中的空格应该编码为%20
,但是在表单数据中应该编码为+
。PathEscape
函数可以正确地做到这一点,但在 Go 1.8 之前不可用。我们将删除网址中的空格以避免这个问题。
内容协商
任何 web 用户代理都可以尝试与任何 web 服务器对话。浏览器与 HTML 服务器对话的典型情况是我们在 Web 上所熟悉的,但许多人会熟悉使用其他用户代理,如 curl、wget,甚至 telnet!浏览器和其他工具将使用 HTTP 回复中的Content-Type
来决定如何处理提供的内容。
对于一个 Web 应用程序,用户代理必须能够理解服务器正在交付什么,因为它试图在一个可能没有用户帮助的交互中扮演一个角色。RPC 系统通常使用客户机和服务器都遵守的外部规范。这里的情况并非如此。
解决方案是双方必须就内容格式达成一致。这是在 HTTP 级别完成的。客户端将声明它将接受一系列格式。如果服务器同意,那么他们继续。如果没有,服务器将告诉客户端它可以接受哪些格式,如果可能的话,客户端可以重新开始。
协商使用 MIME 类型。标准的有几百种:text/html
、application/pdf
、application/xml
、…。浏览器可以呈现它收到的任何 HTML 文档。像 VLC 这样支持 HTTP 的音乐播放器可以播放它接收到的任何 MP3 文件。但是对于抽认卡应用程序,它不能处理任何通用格式,只能处理符合预期结构的消息。这些都不是适用于为这个抽认卡应用程序协商专用协议的标准 MIME 类型。所以,我们自己编。客户机和服务器必须知道它们正在处理一个共享的 MIME 类型,否则它们就不能正常对话。
IANA 有自己创造哑剧类型的规则。我使用类型application/x.flashcards
。服务器将能够交付 JSON 和 XML,因此两种可接受的 MIME 类型是application/x.flashcards+xml
和application/x.flashcards+json
。
HTTP 内容协商表示用户代理可以建议一个可接受格式的列表,权重在 0 到 1 之间,如下所示:
Accept: application/x.flashcards+xml; q=0.8,
application/x.flashcards+json; q=0.4
服务器可以检查请求,并决定是否可以处理该格式。我们在服务器中使用以下代码来确定用户代理是否请求了任何类型的请求,以及请求的权重(零表示未请求):
const flashcard_xml string = "application/x.flashcards+xml"
const flashcard_json string = "application/x.flashcards+json"
type ValueQuality struct {
Value string
Quality float64
}
/* Based on https://siongui.github.io/2015/02/22/go-parse-accept-language/ */
func parseValueQuality(s string) []ValueQuality {
var vqs []ValueQuality
strs := strings.Split(s, `,`)
for _, str := range strs {
trimmedStr := strings.Trim(str, ` `)
valQ := strings.Split(trimmedStr, `;`)
if len(valQ) == 1 {
vq := ValueQuality{valQ[0], 1}
vqs = append(vqs, vq)
} else {
qp := strings.Split(valQ[1], `=`)
q, err := strconv.ParseFloat(qp[1], 64)
if err != nil {
q = 0
}
vq := ValueQuality{valQ[0], q}
vqs = append(vqs, vq)
}
}
return vqs
}
func qualityOfValue(value string, vqs []ValueQuality) float64 {
for _, vq := range vqs {
if value == vq.Value {
return vq.Quality
}
}
// not found
return 0
}
如果服务器不接受用户代理请求的任何类型,它将返回一个 HTTP 代码 406 "Not acceptable"
,并提供一个接受的格式列表。在服务器中执行此操作的代码段如下:
func handleFlashCardSets(rw http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
acceptTypes := parseValueQuality(req.Header.Get("Accept"))
q_xml := qualityOfValue(flashcard_xml, acceptTypes)
q_json := qualityOfValue(flashcard_json, acceptTypes)
if q_xml == 0 && q_json == 0 {
// can't find XML or JSON in Accept header
rw.Header().Set("Content-Type", flashcard_xml + `, ` + flashcard_json)
rw.WriteHeader(http.StatusNotAcceptable)
return
}
...
这说明了 HTTP 服务器的一个常见 REST 模式:给定一个 HTTP 请求,检查它以查看服务器是否能够管理它。如果没有,返回一个 HTTP 错误。如果可以,尝试处理它。如果尝试失败,返回一个 HTTP 错误。成功时,返回适当的 HTTP 成功代码和结果。
获取/
抽认卡组都存放在目录/flashcardSets
中。GET /
请求需要列出所有这些文件,并为客户机准备合适的格式。该格式是一个抽认卡集合名称及其 URL 的列表。HATEOAS 需要 URL:名称列表告诉我们集合是什么,但是客户端需要它们的 URL,以便进入与其中一个进行交互的阶段。
服务器中每个FlashcardSet
的数据类型包含集合的名称及其 URL(字符串形式):
type FlashcardSet struct {
Name string
Link string
}
服务器上的抽认卡组可以从抽认卡组的目录中建立。ioutil.ReadDir()
将创建一个os.FileI
nfo 的数组。这需要转换成如下文件名列表:
files, err := ioutil.ReadDir(`flashcardSets`)
checkError(err)
numfiles := len(files)
cardSets := make([]FlashcardSet, numfiles, numfiles)
for n, file := range files {
cardSets[n].Name = file.Name()
// should be PathEscape, not in go 1.6
cardSets[n].Link = `/flashcardSets/` + url.QueryEscape(file.Name())
}
这将创建一个文件名数组和到服务器上资源的相对链接作为/<name>
。对于CommonWords
集合,相对链接 URL 将是/flashcardSets/CommonWords
。方案(http
或https
)和主机(如“localhost”)由客户端自行解决。
不幸的是,文件名可能包含在 URL 路径名中不合法的字符。函数url.PathEscape
正确地将它们全部转义,但是直到 Go 1.8 才可用。函数url.QueryEscape
除了文件名中的空格外,其他都是正确的,它用+
代替了%20;
。
最后,服务器判断 JSON 或 XML 是首选,并通过一个模板运行它,为客户机生成正确的输出。对于 XML,模板代码如下:
t, err := template.ParseFiles("xml/ListFlashcardSets.xml")
if err != nil {
// parse error occurred in the template. Our error
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", flashcard_xml)
t.Execute(rw, cardSets)
XML 模板如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<cardsets >
{{range .}}
<cardset href="{{.Link}}">
<name>
{{.Name}}
</name>
</cardset>
{{end}}
</cardsets>
对于只有两个集合CommonWords
和Lesson04
的列表,发送到客户端的内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<cardsets >
<cardset href="/CommonWords">
<name>
Common Words
</name>
</cardset>
<cardset href="/Lesson04">
<name>
Lesson04
</name>
</cardset>
</cardsets>
员额/
这里,一个客户要求创建一个新的抽认卡组。期望客户提供抽认卡组的名称。我们让它看起来像表单提交数据:
name=<new flashcard set name>
这比这种情况下的GET
简单多了。以表单数据的形式从请求中获取值。然后检查所请求的名字中没有不愉快的东西,比如调用抽认卡集/etc/passwd
。如果是,返回 403 "Forbidden"
。如果看起来没问题,用那个名字创建一个目录。如果失败,再次返回 403(该目录可能已经存在)。否则,返回 201 "Created"
和新的相对 URL:
if req.Method == "POST" {
name := req.FormValue(`name`)
if hasIllegalChars(name) {
rw.WriteHeader(http.StatusForbidden)
return
}
// lose all spaces as they are a nuisance
name = strings.Replace(name, ` `, ``, -1)
err := os.Mkdir(`flashcardSets/`+name,
(os.ModeDir | os.ModePerm))
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
rw.WriteHeader(http.StatusCreated)
base_url := req.URL.String()
new_url := base_url + `flashcardSets/` + name
rw.Write([]byte(new_url))
处理其他 URL
我们讨论了服务器处理带有GET
和POST
请求的/
URL 的代码。这个应用程序还有另外两种类型的 URL 处理一组卡片和处理每张单独的卡片。然而,就编码而言,这并没有什么新的想法。
- 获得一组中的卡片列表是另一个目录列表。
- 向器械组发送新卡意味着在适当的目录中创建一个包含客户端内容的文件。
- 删除集合意味着删除一个目录。如果目录为空,这是可以的,否则会产生错误。
- 获取卡意味着读取卡文件并发送其内容。
- 删除卡意味着删除文件。
这些都没有什么特别新的东西。我们还没有完成一些操作的代码,比如DELETE
:这些返回 HTTP 代码 501 'Not implemented'
。我们也以text/plain
的形式返回各个卡片的内容:它们有一个复杂的 JSON/Go 结构,如第十章中所使用的,但这对于讨论这个系统的其他方面是不需要的。
完整的服务器
接下来是处理对/
的请求以及从那里到其他 URL 的请求的完整服务器。它需要抽认卡组和独立卡才能运行,这些都在Ch14
文件夹中 http://www.apress.com/9781484226919
的 ZIP 文件中。
/* Server
*/
package main
import (
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
)
type FlashcardSet struct {
Name string
Link string
}
type Flashcard struct {
Name string
Link string
}
const flashcard_xml string = "application/x.flashcards+xml"
const flashcard_json string = "application/x.flashcards+json"
type ValueQuality struct {
Value string
Quality float64
}
/* Based on https://siongui.github.io/2015/02/22/go-parse-accept-language/ */
func parseValueQuality(s string) []ValueQuality {
var vqs []ValueQuality
strs := strings.Split(s, `,`)
for _, str := range strs {
trimmedStr := strings.Trim(str, ` `)
valQ := strings.Split(trimmedStr, `;`)
if len(valQ) == 1 {
vq := ValueQuality{valQ[0], 1}
vqs = append(vqs, vq)
} else {
qp := strings.Split(valQ[1], `=`)
q, err := strconv.ParseFloat(qp[1], 64)
if err != nil {
q = 0
}
vq := ValueQuality{valQ[0], q}
vqs = append(vqs, vq)
}
}
return vqs
}
func qualityOfValue(value string, vqs []ValueQuality) float64 {
for _, vq := range vqs {
if value == vq.Value {
return vq.Quality
}
}
return 0
}
func main() {
if len(os.Args) != 2 {
fmt.Fprint(os.Stderr, "Usage: ", os.Args[0], ":port\n")
os.Exit(1)
}
port := os.Args[1]
http.HandleFunc(`/`, handleFlashCardSets)
files, err := ioutil.ReadDir(`flashcardSets`)
checkError(err)
for _, file := range files {
fmt.Println(file.Name())
cardset_url := `/flashcardSets/` + url.QueryEscape(file.Name())
fmt.Println("Adding handlers for ", cardset_url)
http.HandleFunc(cardset_url, handleOneFlashCardSet)
http.HandleFunc(cardset_url + `/`, handleOneFlashCard)
}
// deliver requests to the handlers
err = http.ListenAndServe(port, nil)
checkError(err)
// That's it!
}
func hasIllegalChars(s string) bool {
// check against chars to break out of current dir
b, err := regexp.Match("[/$∼]", []byte(s))
if err != nil {
fmt.Println(err)
return true
}
if b {
return true
}
return false
}
func handleOneFlashCard(rw http.ResponseWriter, req *http.Request) {
// should be PathUnescape
path, _ := url.QueryUnescape(req.URL.String())
// lose initial '/'
path = path[1:]
if req.Method == "GET" {
fmt.Println("Handling card: ", path)
json_contents, err := ioutil.ReadFile(path)
if err != nil {
rw.WriteHeader(http.StatusNotFound)
rw.Write([]byte(`Resource not found`))
return
}
// Be lazy here, just return the content as text/plain
rw.Write(json_contents)
return
} else if req.Method == "DELETE" {
rw.WriteHeader(http.StatusNotImplemented)
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
return
}
func handleFlashCardSets(rw http.ResponseWriter, req *http.Request) {
if req.URL.String() != `/` {
// this function only handles '/'
rw.WriteHeader(http.StatusNotFound)
rw.Write([]byte("Resource not found\n"))
return
}
if req.Method == "GET" {
acceptTypes := parseValueQuality(req.Header.Get("Accept"))
fmt.Println(acceptTypes)
q_xml := qualityOfValue(flashcard_xml, acceptTypes)
q_json := qualityOfValue(flashcard_json, acceptTypes)
if q_xml == 0 && q_json == 0 {
// can't find XML or JSON in Accept header
rw.Header().Set("Content-Type", flashcard_xml + `, ` + flashcard_json)
rw.WriteHeader(http.StatusNotAcceptable)
return
}
files, err := ioutil.ReadDir(`flashcardSets`)
checkError(err)
numfiles := len(files)
cardSets := make([]FlashcardSet, numfiles, numfiles)
for n, file := range files {
fmt.Println(file.Name())
cardSets[n].Name = file.Name()
// should be PathEscape, not in go 1.6
cardSets[n].Link = `/flashcardSets/` + url.QueryEscape(file.Name())
}
if q_xml >= q_json {
// XML preferred
t, err := template.ParseFiles("xml/ListFlashcardSets.xml")
if err != nil {
fmt.Println("Template error")
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", flashcard_xml)
t.Execute(rw, cardSets)
} else {
// JSON preferred
t, err := template.ParseFiles("json/ListFlashcardSets.json")
if err != nil {
fmt.Println("Template error")
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", flashcard_json)
t.Execute(rw, cardSets)
}
} else if req.Method == "POST" {
name := req.FormValue(`name`)
if hasIllegalChars(name) {
rw.WriteHeader(http.StatusForbidden)
return
}
// lose all spaces as they are a nuisance
name = strings.Replace(name, ` `, ``, -1)
err := os.Mkdir(`flashcardSets/`+name,
(os.ModeDir | os.ModePerm))
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
rw.WriteHeader(http.StatusCreated)
base_url := req.URL.String()
new_url := base_url + `flashcardSets/` + name
// add handlers for the resources
http.HandleFunc(new_url, handleOneFlashCardSet)
http.HandleFunc(new_url + `/`, handleOneFlashCard)
rw.Write([]byte(new_url))
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
return
}
func handleOneFlashCardSet(rw http.ResponseWriter, req *http.Request) {
cooked_url, _ := url.QueryUnescape(req.URL.String())
fmt.Println("Handling one set for: ", cooked_url)
if req.Method == "GET" {
acceptTypes := parseValueQuality(req.Header.Get("Accept"))
fmt.Println(acceptTypes)
q_xml := qualityOfValue(flashcard_xml, acceptTypes)
q_json := qualityOfValue(flashcard_json, acceptTypes)
if q_xml == 0 && q_json == 0 {
// can't find XML or JSON in Accept header
rw.Header().Set("Content-Type", flashcard_xml + `, ` + flashcard_json)
rw.WriteHeader(http.StatusNotAcceptable)
return
}
path := req.URL.String()
// lose leading /
relative_path := path[1:]
files, err := ioutil.ReadDir(relative_path)
checkError(err)
numfiles := len(files)
cards := make([]Flashcard, numfiles, numfiles)
for n, file := range files {
fmt.Println(file.Name())
cards[n].Name = file.Name()
// should be PathEscape, not in go 1.6
cards[n].Link = path + `/` + url.QueryEscape(file.Name())
}
if q_xml >= q_json {
// XML preferred
t, err := template.ParseFiles("xml/ListOneFlashcardSet.xml")
if err != nil {
fmt.Println("Template error")
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", flashcard_xml)
t.Execute(os.Stdout, cards)
t.Execute(rw, cards)
} else {
// JSON preferred
t, err := template.ParseFiles("json/ListOneFlashcardSet.json")
if err != nil {
fmt.Println("Template error")
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", flashcard_json)
t.Execute(rw, cards)
}
} else if req.Method == "POST" {
name := req.FormValue(`name`)
if hasIllegalChars(name) {
rw.WriteHeader(http.StatusForbidden)
return
}
err := os.Mkdir(`flashcardSets/`+name,
(os.ModeDir | os.ModePerm))
if err != nil {
rw.WriteHeader(http.StatusForbidden)
return
}
rw.WriteHeader(http.StatusCreated)
base_url := req.URL.String()
new_url := base_url + `flashcardSets/` + name
_, _ = rw.Write([]byte(new_url))
} else if req.Method == "DELETE" {
rw.WriteHeader(http.StatusNotImplemented)
} else {
rw.WriteHeader(http.StatusMethodNotAllowed)
}
return
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
它的运行方式如下:
go run Server.go :8000
客户
客户端相对简单,不提供任何新的东西。这个客户端只要求 XML 格式的内容。一个新的部分是抽认卡集的内容在一个cardset
标签中包含了作为超文本属性的链接。这可以通过Card
结构中的标签标签xml:"href,attr"
转换成结构的字段。
这个客户端在getFlashcardSets()
函数中获得抽认卡集合及其 URL 的列表(步骤 1)。这将返回一个FlashcardSets
结构。这可以用来向用户呈现一个列表,比如说,供用户选择一个特定的集合。一旦选中,该集合的 URL 就可以用来与资源进行交互。
然后,这个客户端在createFlashcardSet()
函数中创建一个名为NewSet
的新抽认卡集(步骤 2)。第一次运行时,它将创建集合并返回该集合的 URL。第二次运行时,它将从服务器得到一个错误,作为禁止的操作,因为该集合已经存在。
然后,这个客户机从服务器给出的 URL 中取出第一组抽认卡,并请求它持有的那组抽认卡(步骤 3)。然后,它从该组中挑选第一张牌,并获取其内容(步骤 4)。
客户是Client.go
:
/* Client
*/
package main
import (
//"encoding/json"
"encoding/xml"
"fmt"
"io/ioutil"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
)
const flashcard_xml string = "application/x.flashcards+xml"
const flashcard_json string = "application/x.flashcards+json"
type FlashcardSets struct {
XMLName string `xml:"cardsets"`
CardSet []CardSet `xml:"cardset"`
}
type CardSet struct {
XMLName string `xml:"cardset"`
Name string `xml:"name"`
Link string `xml:"href,attr"`
Cards []Card `xml:"card"`
}
type Card struct {
Name string `xml:"name"`
Link string `xml:"href,attr"`
}
func getOneFlashcard(url *url.URL, client *http.Client) string {
// Get the card as a string, don't do anything with it
request, err := http.NewRequest("GET", url.String(), nil)
checkError(err)
response, err := client.Do(request)
checkError(err)
if response.Status != "200 OK" {
fmt.Println(response.Status)
fmt.Println(response.Header)
os.Exit(2)
}
fmt.Println("The response header is")
b, _ := httputil.DumpResponse(response, false)
fmt.Print(string(b))
body, err := ioutil.ReadAll(response.Body)
content := string(body[:])
//fmt.Printf("Body is %s", content)
return content
}
func getOneFlashcardSet(url *url.URL, client *http.Client) CardSet {
// Get one set of cards
request, err := http.NewRequest("GET", url.String(), nil)
checkError(err)
// only accept our media types
request.Header.Add("Accept", flashcard_xml)
response, err := client.Do(request)
checkError(err)
if response.Status != "200 OK" {
fmt.Println(response.Status)
fmt.Println(response.Header)
os.Exit(2)
}
fmt.Println("The response header is")
b, _ := httputil.DumpResponse(response, false)
fmt.Print(string(b))
body, err := ioutil.ReadAll(response.Body)
content := string(body[:])
fmt.Printf("Body is %s", content)
var sets CardSet
contentType := getContentType(response)
if contentType == "XML" {
err = xml.Unmarshal(body, &sets)
checkError(err)
fmt.Println("XML: ", sets)
return sets
}
/* else if contentType == "JSON" {
var sets FlashcardSetsJson
err = json.Unmarshal(body, &sets)
checkError(err)
fmt.Println("JSON: ", sets)
}
*/
return sets
}
func getFlashcardSets(url *url.URL, client *http.Client) FlashcardSets {
// Get the toplevel /
request, err := http.NewRequest("GET", url.String(), nil)
checkError(err)
// only accept our media types
request.Header.Add("Accept", flashcard_xml)
response, err := client.Do(request)
checkError(err)
if response.Status != "200 OK" {
fmt.Println(response.Status)
fmt.Println(response.Header)
os.Exit(2)
}
fmt.Println("The response header is")
b, _ := httputil.DumpResponse(response, false)
fmt.Print(string(b))
body, err := ioutil.ReadAll(response.Body)
content := string(body[:])
fmt.Printf("Body is %s", content)
var sets FlashcardSets
contentType := getContentType(response)
if contentType == "XML" {
err = xml.Unmarshal(body, &sets)
checkError(err)
fmt.Println("XML: ", sets)
return sets
}
return sets
}
func createFlashcardSet(url1 *url.URL, client *http.Client, name string) string {
data := make(url.Values)
data[`name`] = []string{name}
response, err := client.PostForm(url1.String(), data)
checkError(err)
if response.StatusCode != http.StatusCreated {
fmt.Println(`Error: `, response.Status)
return ``
//os.Exit(2)
}
body, err := ioutil.ReadAll(response.Body)
content := string(body[:])
return content
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "http://host:port/page")
os.Exit(1)
}
url, err := url.Parse(os.Args[1])
checkError(err)
client := &http.Client{}
// Step 1: get a list of flashcard sets
flashcardSets := getFlashcardSets(url, client)
fmt.Println("Step 1: ", flashcardSets)
// Step 2: try to create a new flashcard set
new_url := createFlashcardSet(url, client, `NewSet`)
fmt.Println("Step 2: New flashcard set has URL: ", new_url)
// Step 3: using the first flashcard set,
// get the list of cards in it
set_url, _ := url.Parse(os.Args[1] + flashcardSets.CardSet[0].Link)
fmt.Println("Asking for flashcard set URL: ", set_url.String())
oneFlashcardSet := getOneFlashcardSet(set_url, client)
fmt.Println("Step 3:", oneFlashcardSet)
// Step 4: get the contents of one flashcard
// be lazy, just get as text/plain and
// don't do anything with it
card_url, _ := url.Parse(os.Args[1] + oneFlashcardSet.Cards[0].Link)
fmt.Println("Asking for URL: ", card_url.String())
oneFlashcard := getOneFlashcard(card_url, client)
fmt.Println("Step 4", oneFlashcard)
os.Exit(0)
}
func getContentType(response *http.Response) string {
contentType := response.Header.Get("Content-Type")
if strings.Contains(contentType, flashcard_xml) {
return "XML"
}
if strings.Contains(contentType, flashcard_json) {
return "JSON"
}
return ""
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
它的运行方式如下:
go run Client.go http://localhost:8000/
使用 REST 或 RPC
REST 和 RPC 的主要区别在于交互风格。在 RPC 中,您调用函数,将对象或基本类型作为参数传递,并获得对象或基本类型作为回报。这些功能是动词:做这个做那个。另一方面,REST 是关于与对象的交互,要求它们显示它们的状态或者以某种方式改变它。
上一章讨论的 Go RPC 机制和本章的 REST 机制显示了这种差异。在 Go RPC over HTTP 中,服务器注册函数,而在 REST 中,服务器注册 URL 的处理程序。
哪个更好?都不是。哪个更快?都不是。受控环境哪个好?可能是 RPC。开放的环境哪个好?可能是 REST。
您将看到基于速度和资源分配的争论。基于二进制系统的 RPC 可能会比基于文本的 HTTP 系统更快。但是 SOAP 是使用 HTTP 的基于文本的 RPC 系统,可能比 REST 慢。HTTP2 使用二进制格式,在传送二进制数据(如 BSON)时,速度可能与其他二进制系统相当。更令人困惑的是,Apache Thrift RPC 允许选择数据格式(二进制、压缩二进制、JSON 和文本)和传输(套接字、文件和共享内存)。一个系统演示所有选项!
一个更重要的因素可能是操作环境的控制有多严格。RPC 系统是紧密耦合的,一个组件的故障可能会导致整个系统瘫痪。当只有一个管理机构、一组有限的硬件和软件配置以及一个清晰的解决问题的渠道时,RPC 系统就能很好地工作。
另一方面,网络是不受控制的。没有单一的权威机构——即使是像 DNS 这样的“通用”服务也是高度分散的。硬件、操作系统和软件种类繁多;几乎没有实施任何政策的前景;如果有什么东西坏了,通常没有人可以去修理它。在这种情况下,松散耦合的系统可能更好。
HTTP 上的 REST 很好地匹配了这一点。HATEOAS 允许服务器动态重新配置,根据需要改变 URL(甚至指向不同的服务器!).HTTP 被设计成在可能的时候缓存结果。防火墙通常被配置为允许 HTTP 流量并阻止大多数其他流量。在这里 REST 是个不错的选择。
应该注意的是,REST 并不是唯一可能的基于 HTTP 的系统。SOAP 已经提到了。有许多商业和非常成功的系统是“几乎”静止的——Richardson 1 级和 2 级。他们没有享受到 REST/HTTP 匹配的全部好处,但仍然可以工作。
毫无疑问,将来会出现其他模式。在物联网领域,CoAP 因低功耗无线系统而广受欢迎。它也是基于 REST 的,但与 HTTP-REST 略有不同。
结论
REST 是 web 的架构模型。它可以以许多不同的方式应用,特别是作为 HTTP 和 CoAP。本章演示了 REST 在 HTTP 中的应用。
十五、WebSocket
web 用户代理(如浏览器)和 web 服务器(如 Apache)之间的标准交互模型是,用户代理发出 HTTP 请求,服务器对每个请求作出单个回复。在浏览器的情况下,通过点击链接、在地址栏中输入 URL、点击前进或后退按钮等来发出请求。响应被视为一个新页面,并被加载到浏览器窗口中。
这种传统模式有很多弊端。第一个是每个请求打开和关闭一个新的 TCP 连接。HTTP 1.1 通过允许持久连接解决了这个问题,这样一个连接可以在短时间内保持打开,以允许在同一个服务器上进行多个请求(例如,对图像的请求)。
虽然 HTTP 1.1 持久连接缓解了包含大量图形的页面加载缓慢的问题,但它并没有改善交互模型。即使使用表单,模型仍然是提交表单并将响应显示为新页面。JavaScript 有助于在提交前对表单数据进行错误检查,但不会改变模型。
AJAX(异步 JavaScript 和 XML)对用户交互模型做出了重大改进。这允许浏览器发出请求,并使用响应来更新使用 HTML 文档对象模型(DOM)的显示。但是交互模型是一样的。AJAX 只是影响浏览器管理返回页面的方式。Go 中没有对 AJAX 的显式额外支持,因为不需要:HTTP 服务器只是看到一个普通的 HTTP POST 请求,其中可能包含一些 XML 或 JSON 数据,这可以使用已经讨论过的技术来处理。
所有这些仍然是浏览器(或用户代理)到服务器的通信。缺少的是服务器到浏览器的通信,其中浏览器已经建立了到服务器的 TCP 连接并从服务器读取消息。这可以由 WebSockets 来完成:浏览器(或任何用户代理)保持打开一个到 WebSockets 服务器的长期 TCP 连接。TCP 连接允许任何一方发送任意数据包,因此任何应用程序协议都可以在 WebSocket 上使用。
WebSocket 的启动是通过用户代理发送一个特殊的 HTTP 请求来实现的,这个请求说“切换到 WebSockets”。HTTP 请求下面的 TCP 连接保持打开,但是用户代理和服务器都切换到使用 WebSockets 协议,而不是获取 HTTP 响应并关闭套接字。
注意,仍然是浏览器或用户代理发起 WebSockets 连接。浏览器不运行自己的 TCP 服务器。虽然 IETF RFC6455 的规范很复杂(见 https://tools.ietf.org/html/rfc6455
),但该协议被设计得相当容易使用。客户端打开一个 HTTP 连接,然后用自己的 WS 协议替换 HTTP 协议,重用相同的 TCP 或新的连接。
Go 在一个子存储库中有一些对 WebSockets 的支持,但实际上推荐第三方的包。本章考虑了这两种包。
WebSockets 服务器
WebSockets 服务器最初是一个 HTTP 服务器,接受 TCP 连接并处理 TCP 连接上的 HTTP 请求。当一个请求将该连接切换为 WebSockets 连接时,协议处理程序将从 HTTP 处理程序更改为 WebSocket 处理程序。因此,只有 TCP 连接的角色发生了变化,服务器仍然是其他请求的 HTTP 服务器,而该连接下面的 TCP 套接字用作 WebSocket。
在第八章中讨论的简单服务器之一,HTTP 注册了各种处理程序,比如文件处理程序或函数处理程序。为了处理 WebSockets 请求,我们只需注册一个不同类型的处理程序—web sockets 处理程序。服务器使用哪个处理程序取决于 URL 模式。例如,可以为/
注册一个文件处理程序,为/cgi-bin/...
注册一个函数处理程序,为/ws
注册一个 WebSockets 处理程序。
仅预期用于 WebSockets 的 HTTP 服务器可能如下运行:
func main() {
http.Handle("/", websocket.Handler(WSHandler))
err := http.ListenAndServe(":12345", nil)
checkError(err)
}
一个更复杂的服务器可能通过添加更多的处理程序来处理 HTTP 和 WebSockets 请求。
Go 子存储库包
Go 有一个名为golang.org/x/net/websocket
的子库包。要使用它,您必须首先下载它:
go get golang.org/x/net/websocket
这个包的文档陈述如下:
This package currently lacks some features found in another more actively maintained WebSockets package:
https://godoc.org/github.com/gorilla/websocket
这表明使用替代包可能会更好。尽管如此,我们在这里认为这个包与本书其余部分使用 Go 团队的包是一致的。后面一节将介绍替代包。
消息对象
HTTP 是一种流协议。WebSockets 是基于框架的。您准备一个数据块(任何大小)并将其作为一组帧发送。帧可以包含 UTF-8 编码的字符串或字节序列。
使用 WebSockets 最简单的方法就是准备一个数据块,并请求 Go WebSockets 库将其打包为一组帧数据,通过网络发送,并作为同一个数据块接收。websocket
包包含一个名为Message
的便利对象来完成这一任务。Message
对象有两个方法——Send
和Receive
——将 WebSocket 作为第一个参数。第二个参数要么是存储数据的变量的地址,要么是要发送的数据。发送字符串数据的代码如下所示:
msgToSend := "Hello"
err := websocket.Message.Send(ws, msgToSend)
var msgToReceive string
err := websocket.Message.Receive(conn, &msgToReceive)
发送字节数据的代码如下所示:
dataToSend := []byte{0, 1, 2}
err := websocket.Message.Send(ws, dataToSend)
var dataToReceive []byte
err := websocket.Message.Receive(conn, &dataToReceive)
接下来给出一个发送和接收字符串数据的 echo 服务器。请注意,在 WebSockets 中,任何一方都可以发起消息发送,在这个服务器中,当客户端连接(发送/接收)时,我们将消息从服务器发送到客户端,而不是更普通的接收/发送服务器。服务器是EchoServer.go
:
/* EchoServer
*/
package main
import (
"fmt"
"golang.org/x/net/websocket"
"net/http"
"os"
)
func Echo(ws *websocket.Conn) {
fmt.Println("Echoing")
for n := 0; n < 10; n++ {
msg := "Hello " + string(n+48)
fmt.Println("Sending to client: " + msg)
err := websocket.Message.Send(ws, msg)
if err != nil {
fmt.Println("Can't send")
break
}
var reply string
err = websocket.Message.Receive(ws, &reply)
if err != nil {
fmt.Println("Can't receive")
break
}
fmt.Println("Received back from client: " + reply)
}
}
func main() {
http.Handle("/", websocket.Handler(Echo))
err := http.ListenAndServe(":12345", nil)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
它的运行方式如下:
go run EchoServer.go
与此服务器对话的客户端是EchoClient.go
:
/* EchoClient
*/
package main
import (
"fmt"
"golang.org/x/net/websocket"
"io"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "ws://host:port")
os.Exit(1)
}
service := os.Args[1]
conn, err := websocket.Dial(service, "", "http://localhost:12345")
checkError(err)
var msg string
for {
err := websocket.Message
.Receive(conn, &msg)
if err != nil {
if err == io.EOF {
// graceful shutdown by server
break
}
fmt.Println("Couldn't receive msg " + err.Error())
break
}
fmt.Println("Received from server: " + msg)
// return the msg
err = websocket.Message.Send(conn, msg)
if err != nil {
fmt.Println("Couldn’t return msg")
break
}
}
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
它的运行方式如下:
go run EchoClient.go ws://localhost:12345
客户端的输出是服务器发送的内容:
Received from server: Hello 0
Received from server: Hello 1
Received from server: Hello 2
Received from server: Hello 3
Received from server: Hello 4
Received from server: Hello 5
Received from server: Hello 6
Received from server: Hello 7
Received from server: Hello 8
Received from server
: Hello 9
JSON 对象
预计许多 WebSockets 客户机和服务器将以 JSON 格式交换数据。对于 Go 程序,这意味着 Go 对象将被整理成 JSON 格式,如第四章所述,然后作为 UTF-8 字符串发送,而接收者将读取该字符串并将其解组回 Go 对象。
名为JSON
的websocket
便利对象将为您完成这项工作。它有发送和接收数据的Send
和Receive
方法,就像Message
对象一样。
我们考虑这样一种情况,客户端使用 WebSockets(可以双向发送消息)向服务器发送一个Person
对象。从客户端读取消息并将其打印到服务器的标准输出的服务器是PersonServerJSON.go
:
/* PersonServerJSON
*/
package main
import (
"fmt"
"golang.org/x/net/websocket"
"net/http"
"os"
)
type Person struct {
Name string
Emails []string
}
func ReceivePerson(ws *websocket.Conn) {
var person Person
err := websocket.JSON.Receive(ws, &person)
if err != nil {
fmt.Println("Can't receive")
} else {
fmt.Println("Name: " + person.Name)
for _, e := range person.Emails {
fmt.Println("An email: " + e)
}
}
}
func main() {
http.Handle("/", websocket.Handler(ReceivePerson))
err := http.ListenAndServe(":12345", nil)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
发送 JSON 格式的Person
对象的客户端是PersonClientJSON.go
:
/* PersonClientJSON
*/
package main
import (
"fmt"
"golang.org/x/net/websocket"
"os"
)
type Person struct {
Name string
Emails []string
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "ws://host:port")
os.Exit(1)
}
service := os.Args[1]
conn, err := websocket.Dial(service, "",
"http://localhost")
checkError(err)
person := Person{Name: "Jan",
Emails: []string{"ja@newmarch.name", "jan.newmarch@gmail.com"},
}
err = websocket.JSON.Send(conn, person)
if err != nil {
fmt.Println("Couldn't send msg " + err.Error())
}
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
服务器按如下方式运行:
go run PersonServerJSON.go
客户端运行如下:
go run PersonClientJSON.go ws://localhost:12345
服务器端的输出是客户端发送的内容:
Name: Jan
An email: ja@newmarch.name
An email: jan.newmarch@gmail.com
编解码器类型
Message
和JSON
对象都是类型Codec
的实例。该类型定义如下:
type Codec struct {
Marshal func(v interface{}) (data []byte, payloadType byte, err error)
Unmarshal func(data []byte, payloadType byte, v interface{}) (err error)
}
Codec
类型实现了之前使用的Send
和Receive
方法。
很可能 WebSockets 也将用于交换 XML 数据。我们可以通过包装第十二章中讨论的 XML marshal
和unmarshal
方法来构建一个 XML Codec
对象,以给出一个合适的Codec
对象。
我们可以这样创建一个XMLCodec
包,叫做XMLCodec.go
:
package xmlcodec
import (
"encoding/xml"
"golang.org/x/net/websocket"
)
func xmlMarshal(v interface{}) (msg []byte, payloadType byte, err error) {
msg, err = xml.Marshal(v)
return msg, websocket.TextFrame, nil
}
func xmlUnmarshal(msg []byte, payloadType byte, v interface{}) (err error) {
err = xml.Unmarshal(msg, v)
return err
}
var XMLCodec = websocket.Codec{xmlMarshal, xmlUnmarshal}
这个文件应该安装在GOPATH
的src
子目录下:
$GOPATH/src/xmlcodec/XMLCodec.go
然后,我们可以将 Go 对象(如Person
)序列化为 XML 文档,并将它们从客户机发送到服务器。接收文档并将其打印到标准输出的服务器如下:
/* PersonServerXML
*/
package main
import (
"fmt"
"golang.org/x/net/websocket"
"net/http"
"os"
"xmlcodec"
)
type Person struct {
Name string
Emails []string
}
func ReceivePerson(ws *websocket.Conn) {
var person Person
err := xmlcodec.XMLCodec.Receive(ws, &person)
if err != nil {
fmt.Println("Can't receive")
} else {
fmt.Println("Name: " + person.Name)
for _, e := range person.Emails {
fmt.Println("An email: " + e)
}
}
}
func main() {
http.Handle("/", websocket.Handler(ReceivePerson))
err := http.ListenAndServe(":12345", nil)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
发送 XML 格式的Person
对象的客户端是PersonClientXML.go
:
/* PersonClientXML
*/
package main
import (
"fmt"
"golang.org/x/net/websocket"
"os"
"xmlcodec"
)
type Person struct {
Name string
Emails []string
}
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "ws://host:port")
os.Exit(1)
}
service := os.Args[1]
conn, err := websocket.Dial(service, "", "http://localhost")
checkError(err)
person := Person{Name: "Jan",
Emails: []string{"ja@newmarch.name", "jan.newmarch@gmail.com"},
}
err = xmlcodec.XMLCodec.Send(conn, person)
if err != nil {
fmt.Println("Couldn't send msg " + err.Error())
}
os.Exit(0)
}
func
checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
服务器按如下方式运行:
go run PersonServerXML.go
客户端运行如下:
go run PersonClientXML.go ws://localhost:12345
服务器端的输出是客户端发送的内容:
Name: Jan
An email: ja@newmarch.name
An email: jan.newmarch@gmail.com
TLS 上的 WebSockets
WebSocket 可以建立在安全 TLS 套接字之上。我们在第八章中讨论了如何使用第七章中的证书来使用 TLS 套接字。它不加修改地用于 WebSockets。也就是我们用http.ListenAndServeTLS
代替http.ListenAndServe
。
下面是使用 TLS 的 echo 服务器:
/* EchoServerTLS
*/
package main
import (
"fmt"
"golang.org/x/net/websocket"
"net/http"
"os"
)
func Echo(ws *websocket.Conn) {
fmt.Println("Echoing")
for n := 0; n < 10; n++ {
msg := "Hello " + string(n+48)
fmt.Println("Sending to client: " + msg)
err := websocket.Message.Send(ws, msg)
if err != nil {
fmt.Println("Can't send")
break
}
var reply string
err = websocket.Message.Receive(ws, &reply)
if err != nil {
fmt.Println("Can't receive")
break
}
fmt.Println("Received back from client: " + reply)
}
}
func main() {
http.Handle("/", websocket.Handler(Echo))
err := http.ListenAndServeTLS(":12345", "jan.newmarch.name.pem",
"private.pem", nil)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
客户端与之前的 echo 客户端相同。改变的只是 URL,它使用了wss
方案,而不是ws
方案:
EchoClient wss://localhost:12345/
如果服务器提供的 TLS 证书是有效的,这将很好地工作。我使用的证书不是:它是自签名的,这通常是一个信号,表明您正在进入一个危险区域。如果你想继续下去,你需要采用我们在第八章中通过打开 TLS InsecureSkipVerify
标志所做的同样的“移除安全检查”。这是由程序EchoClientTLS.go
完成的,它使用这个标志建立一个配置,然后调用DialConfig
代替Dial
:
/* EchoClientTLS
*/
package main
import (
"fmt"
"crypto/tls"
"golang.org/x/net/websocket"
"io"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "wss://host:port")
os.Exit(1)
}
config, err := websocket.NewConfig(os.Args[1], "http://localhost")
checkError(err)
tlsConfig := &tls.Config{InsecureSkipVerify: true}
config.TlsConfig = tlsConfig
conn, err := websocket.DialConfig(config)
checkError(err)
var msg string
for {
err := websocket.Message.Receive(conn, &msg)
if err != nil {
if err == io.EOF {
// graceful shutdown by server
break
}
fmt.Println("Couldn't receive msg " + err.Error())
break
}
fmt.Println("Received from server: " + msg)
// return the msg
err = websocket.Message.Send(conn, msg)
if err != nil {
fmt.Println("Couldn't return msg")
break
}
}
os.Exit(0)
}
func
checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
HTML 页面中的 WebSockets
WebSockets 最初的驱动程序是允许 HTTP 用户代理(如浏览器和服务器)之间的全双工交互。典型的用例是浏览器中的 JavaScript 程序与服务器交互。在本节中,我们将构建一个 web/WebSockets 服务器,该服务器提供一个 HTML 页面,该页面设置一个 WebSocket 并使用 WebSockets 显示来自该服务器的信息。我们正在观察图 15-1 所示的情况。
图 15-1。
Full duplex interaction situation
物联网时代即将到来。因此,我们可以期待来自传感器和传感器网络的数据被用来驱动执行器,并在浏览器中显示关于物联网网络的信息。关于使用 Raspberry Pi 和 Arduinos 构建传感器网络的书籍数不胜数,但我们将通过在“传感器”上显示 CPU 温度来大大简化这种情况,每隔几秒钟在网页上更新一次。
来自 Debian 包lm-sensors
的 Linux sensors
命令将它所知道的传感器的值写入标准输出。我的桌面计算机上的命令sensors
产生如下输出:
acpitz-virtual-0
Adapter: Virtual device
temp1: +27.8°C (crit = +105.0°C)
temp2: +29.8°C (crit = +105.0°C)
coretemp-isa-0000
Adapter: ISA adapter
Physical id 0: +58.0°C (high = +105.0°C, crit = +105.0°C)
Core 0: +57.0°C (high = +105.0°C, crit = +105.0°C)
Core 1: +58.0°C (high = +105.0°C, crit = +105.0°C)
在刷新时,通常Core 0
和Core 1
上的温度会改变。
在 Windows 上,执行相同操作的命令如下:
wmic /namespace:\\root\wmi PATH MSAcpi_ThermalZoneTemperature get CurrentTemperature
当它运行时,会有如下输出
42.4° C
在 Mac 上,使用 https://github.com/lavoiesl/osx-cpu-temp
中的命令 osx-cpu-temp。
如果你不想经历这些步骤,就用一个更普通的程序代替,比如约会。
我们提供了一个 Go 程序来传递来自ROOT_DIR
目录的 HTML 文档,然后从 URL GetTemp
建立一个 WebSocket。服务器端的 WebSocket 每两秒钟从sensors
获取一次输出,并发送给 Socket 的客户端。web/WebSockets 服务器运行在端口12345
上,没有特别的原因。安装完lm-sensors
包后,这个程序将在 Linux 下运行。对于其他系统,用任何其他有趣的系统调用代替exec.Command
调用。
Go 服务器是TemperatureServer.go
:
/* TemperatureServer
*/
package main
import (
"fmt"
"golang.org/x/net/websocket"
"net/http"
"os"
"os/exec"
"time"
)
var ROOT_DIR = "/home/httpd/html/golang-hidden/websockets"
func GetTemp(ws *websocket.Conn) {
for {
msg, _ := exec.Command("sensors").Output()
fmt.Println("Sending to client: " + string(msg[:]))
err := websocket.Message.Send(ws, string(msg[:]))
if err != nil {
fmt.Println("Can't send")
break
}
time.Sleep(2 * 1000 * 1000 * 1000)
var reply string
err = websocket.Message.Receive(ws, &reply)
if err != nil {
fmt.Println("Can't receive")
break
}
fmt.Println("Received back from client: " + reply)
}
}
func main() {
fileServer := http.FileServer(http.Dir(ROOT_DIR))
http.Handle("/GetTemp", websocket.Handler(GetTemp))
http.Handle("/", fileServer)
err := http.ListenAndServe(":12345", nil)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
它的运行方式如下:
go run TemperatureServer.go
开始这项工作的顶级 HTML 文件是websocket.html
:
<!DOCTYPE HTML>
<html>
<head>
<script type="text/javascript">
function WebSocketTest()
{
if ("WebSocket" in window)
{
alert("WebSocket is supported by your Browser!");
// Let us open a web socket
var ws = new WebSocket("ws://localhost:12345/GetTemp");
ws.onopen = function()
{
alert("WS is opened...");
};
ws.onmessage = function (evt)
{
var received_msg = evt.data;
// uncomment next line if you want to get alerts on each message
//alert("Message is received..." + received_msg);
document.getElementById("temp").innerHTML = "<pre>" + received_msg + "</pre>"
ws.send("Message received")
};
ws.onclose = function()
{
// websocket
is closed.
alert("Connection is closed...");
};
}
else
{
// The browser doesn't support WebSocket
alert("WebSocket NOT supported by your Browser!");
}
}
</script>
</head>
<body>
<div id="temp">
<a href="javascript:WebSocketTest()">Run temperature sensor</a>
</div>
</body>
</html>
该程序使用 JavaScript 打开一个 WebSockets 连接,并处理onopen
,
onmessage
和onclose
事件。它使用evt.data
和send
功能进行读写。它在一个预先格式化的元素中显示数据,就像上面的数据一样。每两秒钟刷新一次。HTML 文档的结构基于 TutorialsPoint ( https://www.tutorialspoint.com/html5/html5_websocket.htm
)的 HTML5 - WebSockets。
大猩猩套餐
WebSockets 的替代包是github.com/gorilla/websocket
包。要使用它,您需要运行以下命令:
go get github.com/gorilla/websocket
回声服务器
使用这个包的 echo 服务器是EchoServerGorilla.go
。它通过引入对一个websocket.Upgrader
对象的调用,使得 HTTP 到 WebSockets 的转换更加明确。它也更清楚地区分了发送文本和二进制消息。
/* EchoServerGorilla
*/
package main
import (
"fmt"
"github.com/gorilla/websocket"
"net/http"
"os"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func Handler(w http.ResponseWriter, r *http.Request) {
fmt.Println("Handling /")
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Println(err)
return
}
for n := 0; n < 10; n++ {
msg := "Hello " + string(n+48)
fmt.Println("Sending to client: " + msg)
err = conn.WriteMessage(websocket.TextMessage, []byte(msg))
_, reply, err := conn.ReadMessage()
if err != nil {
fmt.Println("Can't receive")
break
}
fmt.Println("Received back from client: " + string(reply[:]))
}
conn.Close()
}
func main() {
http.HandleFunc("/", Handler)
err := http.ListenAndServe("localhost:12345", nil)
checkError(err)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
服务器按如下方式运行:
go run EchoServerGorilla
回显客户端
使用这个包的 echo 客户端是EchoClientGorilla.go
:
/* EchoClientGorilla
*/
package main
import (
"fmt"
"github.com/gorilla/websocket"
"io"
"net/http"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Usage: ", os.Args[0], "ws://host:port")
os.Exit(1)
}
service := os.Args[1]
header := make(http.Header)
header.Add("Origin", "http://localhost:12345")
conn, _, err := websocket.DefaultDialer.Dial(service, header)
checkError(err)
for {
_, reply, err := conn.ReadMessage()
if err != nil {
if err == io.EOF {
// graceful shutdown by server
fmt.Println(`EOF from server`)
break
}
if websocket.IsCloseError(err, websocket.CloseAbnormalClosure) {
fmt.Println(`Close from server`)
break
}
fmt.Println("Couldn't receive msg " + err.Error())
break
}
//checkError(err)
fmt.Println("Received from server: " + string(reply[:]))
// return the msg
err = conn.WriteMessage(websocket.TextMessage, reply)
if err != nil {
fmt.Println("Couldn't return msg")
break
}
}
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
客户端运行如下:
go run EchoClientGorila ws://localhost:12345
结论
WebSockets 标准是 IETF RFC,所以预计不会有大的变化。这将允许 HTTP 用户代理和服务器建立双向套接字连接,并使某些交互方式变得更加容易。Go 有两个包支持 WebSockets。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)