原文:
zh.annas-archive.org/md5/62FC08F1461495F0676A88A03EA0ECBA
译者:飞龙
前言
本书将提供对各种有趣的 Go 概念的深入解释。它从 Unix 和系统编程开始,这将帮助您了解 Unix 操作系统提供了哪些组件,从内核 API 到文件系统,并让您熟悉系统编程的基本概念。
接下来,它继续涵盖 I/O 操作的应用,重点放在 Unix 操作系统中的文件系统、文件和流上。它涵盖了许多主题,包括从文件中读取和写入等 I/O 操作。
本书还展示了各种进程如何相互通信。它解释了如何在 Go 中使用基于 Unix 管道的通信,如何在应用程序内部处理信号,以及如何使用网络进行有效通信。此外,它还展示了如何对数据进行编码以提高通信速度。
本书最后将帮助您了解 Go 最现代的特性——并发。它将向您介绍语言的工具,包括 sync 和通道,以及如何何时使用每一个。
本书适合对象
本书适合希望学习 Go 系统编程的开发人员。虽然不需要 Unix 和 Linux 系统编程的先前知识,但一些中级 Go 知识将有助于您理解本书中涵盖的概念。
本书涵盖的内容
第一章,系统编程简介,向您介绍了 Go 和系统编程,并提供了一些基本概念以及 Unix 及其资源的概述,包括内核 API。它还定义了本书其余部分中使用的许多概念。
第二章,Unix 操作系统组件,重点放在 Unix 操作系统以及您将与之交互的组件上——文件和文件系统、进程、用户和权限、线程等。它还解释了操作系统的各种内存管理技术,以及 Unix 如何处理驻留内存和虚拟内存。
第三章,Go 概述,介绍了 Go,从语言的历史开始,然后逐一解释了所有基本概念,从命名空间和类型系统、变量和流程控制,到内置函数和并发模型,同时还解释了 Go 如何交互和管理其内存。
第四章,使用文件系统,帮助您了解 Unix 文件系统的工作原理,以及如何掌握 Go 标准库来处理文件路径操作、文件读取和文件写入。
第五章,处理流,帮助您了解 Go 用于抽象数据流的输入和输出流的接口。它解释了它们的工作原理,以及如何组合它们并在不泄露信息的情况下最好地使用它们。
第六章,构建伪终端,帮助您了解伪终端应用程序的工作原理以及如何创建一个。结果将是一个使用标准流的交互式应用程序,就像命令行一样。
第七章,处理进程和守护进程,提供了进程是什么以及如何在 Go 中处理它们的解释,如何从 Go 应用程序启动子进程,以及如何创建一个将保持在后台(守护进程)并与其交互的命令行应用程序。
第八章,“退出代码、信号和管道”,讨论了 Unix 进程间通信。它解释了如何有效地使用退出代码。它向您展示了应用程序内部如何默认处理信号,以及如何使用一些模式来有效地处理信号。此外,它解释了如何使用管道连接不同进程的输出和输入。
第九章,“网络编程”,解释了如何使用网络进行进程通信。它解释了网络通信协议的工作原理。它最初专注于低级套接字通信,如 TCP 和 UDP,然后转向使用众所周知的 HTTP 协议进行 Web 服务器开发。最后,它展示了如何使用 Go 模板引擎。
第十章,“使用 Go 进行数据编码”,解释了如何利用 Go 标准库对复杂数据结构进行编码,以便促进进程通信。它分析了基于文本的协议,如 XML 和 JSON,以及基于二进制的协议,如 GOB。
第十一章,“处理通道和 Goroutines”,解释了并发和通道的基础知识,以及一些通用规则,可以防止在应用程序中创建死锁和资源泄漏。
第十二章,“使用 sync 和 atomic 进行同步”,讨论了sync
和sync/atomic
标准库的同步包,以及如何使用它们来轻松实现并发,而不是使用通道。它还专注于避免资源泄漏和回收资源。
第十三章,“使用上下文进行协调”,讨论了Context
,这是 Go 中一个相对较新的包,提供了一种有效处理异步操作的简单方法。
第十四章,“实现并发模式”,使用前三章的工具,并演示如何有效地使用和组合它们进行通信。它专注于 Go 中最常用的并发模式。
第十五章,“使用反射”,解释了反射是什么,以及是否应该使用它。它展示了标准库中的反射用途,并指导您创建一个实际的例子。它还展示了如何在没有必要使用反射的情况下避免使用它。
第十六章,“使用 CGO”,解释了 CGO 的工作原理,以及为什么以及何时应该使用它。它解释了如何在 Go 应用程序中使用 C 代码,以及如何在 C 代码中使用 Go。
充分利用本书
尝试示例和构建现代应用程序需要一些 Go 的基础知识。
每一章都包括一组问题,这些问题将帮助您评估对该章节的理解。这些问题将对您非常有益,因为它们将帮助您快速复习每一章。
此外,每一章都提供了如何运行代码文件的说明,而书的 GitHub 存储库提供了必要的细节。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接通过电子邮件接收文件。
您可以按照以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载和勘误”。
-
在搜索框中输入书名,并按照屏幕上的说明操作。
下载文件后,请确保使用以下最新版本解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,链接为github.com/PacktPublishing/Hands-On-System-Programming-with-Go
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在**github.com/PacktPublishing/
**上找到。快去看看吧!
下载彩色图片
我们还提供了一份 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:static.packt-cdn.com/downloads/9781789804072_ColorImages.pdf
。
代码示例
访问以下链接查看代码运行的视频:bit.ly/2ZWgJb5
。
示例演示
在本书中,您会发现许多代码片段,后面跟着一个链接到play.golang.org
,这是一个允许您以一定限制运行 Go 应用程序的服务。您可以在blog.golang.org/playground
上了解更多信息。
要查看此类示例的完整源代码,您需要访问 Playground 链接。一旦进入网站,您可以点击运行按钮来执行应用程序。页面底部将显示输出。以下是代码在 Go Playground 中运行的示例:
如果您愿意,您可以通过向示例添加和编辑更多代码,然后运行它们来进行实验。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“这种服务包括load
,它将程序加载到内存并在将控制传递给程序本身之前准备执行程序,或者execute
,它在现有进程的上下文中运行可执行文件。”
代码块设置如下:
<meta name="go-import" content="package-name vcs repository-url">
任何命令行输入或输出都将按如下方式书写:
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:“与此同时,系统开始变得分布式,应用程序开始以容器的形式进行交付,并由其他系统软件进行编排,比如Kubernetes。”
警告或重要说明会显示为这样。
提示和技巧会显示为这样。
第一部分:系统编程和 Go 语言简介
本节是对 Unix 和系统编程的介绍。它将帮助您了解 Unix 操作系统提供了哪些组件,从内核 API 到文件系统,您将熟悉系统编程的基本概念。
本节包括以下章节:
-
第一章,系统编程简介
-
第二章,Unix 操作系统组件
-
第三章,Go 语言概述
第一章:系统编程简介
本章是系统编程的介绍,探讨了从最初定义到随着系统演变而发生变化的一系列主题。本章提供了一些基本概念和 Unix 及其资源的概述,包括内核和应用程序编程接口(API)。这些概念中的许多是在这里定义的,并在本书的其余部分中使用。
本章将涵盖以下主题:
-
什么是系统编程?
-
应用程序编程接口
-
了解保护环如何工作
-
系统调用概述
-
POSIX 标准
技术要求
如果您使用 Linux,则本章不需要您安装任何特殊软件。
如果您是 Windows 用户,可以安装 Windows 子系统用于 Linux(WSL)。按照以下步骤安装 WSL:
- 以管理员身份打开 PowerShell 并运行以下命令:
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux
-
在提示时重新启动计算机。
-
从 Microsoft Store 安装您喜欢的 Linux 发行版。
开始系统编程
多年来,IT 领域发生了巨大变化。挑战冯·诺伊曼机的多核 CPU、互联网和分布式系统只是过去 30 年发生的一些变化。那么,系统编程在这个领域中处于什么位置呢?
为软件而设计的软件
让我们首先从标准教科书的定义开始。
系统编程(或系统编程)是编程计算机系统软件的活动。与应用程序编程相比,系统编程的主要区别特征在于,应用程序编程旨在生产直接为用户提供服务的软件(例如,文字处理器),而系统编程旨在生产为其他软件提供服务的软件和软件平台,并且设计为在性能受限的环境中工作,例如操作系统、计算科学应用程序、游戏引擎和 AAA 视频游戏、工业自动化和软件即服务应用程序。
该定义突出了系统应用程序的两个主要概念如下:
-
被其他软件使用的软件,而不是直接由最终用户使用。
-
软件具有硬件意识(它知道硬件如何工作),并且面向性能。
这使得很容易将操作系统内核、硬件驱动程序、编译器和调试器等系统软件识别为系统软件,而不是系统软件、聊天客户端或文字处理器。
从历史上看,系统程序是使用汇编语言和 C 创建的。然后出现了用于将系统程序提供的功能联系在一起的 shell 和脚本语言。系统语言的另一个特征是对内存分配的控制。
语言和系统演变
在过去的十年中,脚本语言变得越来越受欢迎,以至于一些脚本语言有了显著的性能改进,并且整个系统都是用它们构建的。例如,让我们想一想 JavaScript 的 V8 引擎和 Python 的 PyPy 实现,它们显著改变了这些语言的性能。
其他语言,如 Go,证明了垃圾收集和性能并不是互斥的。特别是,Go 在 1.5 版中成功用 Go 编写的本地版本替换了 C 编写的内存分配器,将性能提高到可比较的水平。
与此同时,系统开始变得分布式,应用程序开始以容器的形式进行部署,由其他系统软件(例如 Kubernetes)进行编排。这些系统旨在维持巨大的吞吐量,并通过两种主要方式实现:
-
通过扩展-增加托管系统的机器数量或资源
-
通过优化软件以提高资源利用效率
系统编程和软件工程
系统编程的一些实践——比如将应用程序与硬件绑定、以性能为导向、在资源受限的环境中工作——是一种在构建分布式系统时也可以有效的方法,其中限制资源使用可以减少所需的实例数量。看起来系统编程是解决通用软件工程问题的好方法。
这意味着学习系统编程的概念,关于如何有效地使用机器资源——从内存使用到文件系统访问——将有助于构建任何类型的应用程序。
应用程序编程接口
API 是一系列子例程定义、通信协议和构建软件的工具。API 最重要的方面是它提供的功能,以及它的文档,这些文档可以帮助用户使用和实现软件在另一个软件中的使用。API 可以是允许应用软件使用系统软件的接口。
API 通常有一个特定的发布政策,旨在供特定的接收者使用。这可以是以下内容:
-
私有的,仅供内部使用。
-
合作伙伴和可由确定的群体使用——这可能包括希望将服务与自己的公司整合的公司
-
公开并可供每个用户使用
API 的类型
我们将看到有几种类型的 API,从用于使不同的应用软件一起工作的 API,到操作系统向其他软件公开的内部 API。
操作系统
API 可以指定如何与应用程序和操作系统进行接口。例如,Windows、Linux 和 macOS 都有一个接口,可以操作文件系统和文件。
库和框架
与软件库相关的 API 描述并规定(提供如何使用它的说明)其每个元素应该如何行为,包括最常见的错误场景。API 的行为和接口通常被称为库规范,而库是这种规范中描述的规则的实现。库和框架通常是语言绑定的,但也有一些工具可以使库在不同的语言中使用。你可以使用 CGO 在 Go 中使用 C 代码,在 Python 中你可以使用 CPython。
远程 API
这些使得可以使用特定的通信标准来操作远程资源,这些标准允许不同的技术一起工作,无论语言或平台如何。一个很好的例子是Java 数据库连接(JDBC)API,它允许使用相同的函数查询许多不同类型的数据库,或者 Java 远程方法调用 API(Java RMI),它允许像本地函数一样使用远程函数。
Web API
Web API 是定义有关使用的协议、消息编码和可用端点及其预期输入和输出值的一系列规范的接口。这种 API 有两种主要的范式——REST 和 SOAP:
-
REST API 具有以下特点:
-
它们将数据视为资源。
-
每个资源都由 URL 标识。
-
操作类型由 HTTP 方法指定。
-
SOAP 协议具有以下特点:
-
它们由 W3C 标准定义。
-
XML 是消息的唯一编码方式。
-
它们使用一系列 XML 模式来验证数据。
理解保护环
保护环,也称为分层保护域,是用于保护系统免受故障的机制。它的名称源自其权限级别的分层结构,由同心圆环表示,当移动到外部环时,特权会减少。在每个环之间有特殊的门,允许外部环以受限的方式访问内部环的资源。
架构差异
环的数量和顺序取决于 CPU 架构。它们通常以权限降低的顺序编号,使 ring 0 成为最具特权的环。这对于使用四个环(从 ring 0 到 ring 3)的 i386 和 x64 架构是正确的,但对于使用相反顺序(从 EL3 到 EL0)的 ARM 架构是不正确的。大多数操作系统不使用所有四个级别;它们最终使用两级层次结构—用户/应用程序(ring 3)和内核(ring 0)。
内核空间和用户空间
在操作系统下运行的软件将在用户(ring 3)级别执行。为了访问机器资源,它将必须与运行在 ring 0 的操作系统内核进行交互。以下是 ring 3 应用程序无法执行的一些操作:
-
修改当前段描述符,确定当前环
-
修改页表,防止一个进程看到其他进程的内存
-
使用 LGDT 和 LIDT 指令,防止它们注册中断处理程序
-
使用 I/O 指令,比如 in 和 out,可以忽略文件权限并直接从磁盘读取。
例如,对磁盘内容的访问将由内核进行调解,内核将验证应用程序是否有权限访问数据。这种协商方式提高了安全性,避免了故障,但会带来重要的开销,影响应用程序的性能。
有些应用程序可以直接在硬件上运行,而不需要操作系统提供的框架。这对于实时系统来说是真实的,因为在实时系统中,响应时间和性能都不容许妥协。
深入系统调用
系统调用是操作系统为应用程序提供对资源访问的方式。这是内核实现的 API,用于安全地访问硬件。
提供的服务
有一些类别可以用来分割操作系统提供的众多功能。这些包括控制运行应用程序及其流程、文件系统访问和网络。
进程控制
这种类型的服务包括load
,它将程序添加到内存并在将控制传递给程序本身之前准备执行,或者execute
,它在现有进程的上下文中运行可执行文件。属于这一类别的其他操作如下:
-
end
和abort
—第一个要求应用程序退出,而第二个强制退出。 -
CreateProcess
,也称为 Unix 系统上的fork
或 Windows 中的NtCreateProcess
。 -
终止进程。
-
获取/设置进程属性。
-
等待时间、等待事件或信号事件。
-
分配和释放内存。
文件管理
文件和文件系统的处理属于文件管理系统调用。有create和delete文件,可以向文件系统添加或删除条目,以及open
和close
操作,可以控制文件以执行读写操作。还可以读取和更改文件属性。
设备管理
设备管理处理除文件系统之外的所有其他设备,如帧缓冲区或显示器。它包括从设备的请求开始的所有操作,包括与设备的通信(读取、写入、寻址)以及其释放。它还包括更改设备属性和逻辑附加和分离设备的所有操作。
信息维护
读取和写入系统日期和时间属于信息维护类别。这个类别还负责其他系统数据,比如环境。还有一组重要的操作属于这里,包括请求和处理进程、文件和设备属性。
通信
所有网络操作,从处理套接字到接受连接,都属于通信类别。这包括连接的创建、删除和命名,以及发送和接收消息。
操作系统之间的差异
Windows 具有一系列不同的系统调用,涵盖了所有内核操作。其中许多与 Unix 等效的操作完全对应。以下是一些重叠的系统调用列表:
Windows | Unix |
---|
| 进程控制 | CreateProcess()
ExitProcess()
WaitForSingleObject()
| fork()
exit()
wait()
|
| 文件操作 | CreateFile()
ReadFile()
WriteFile()
CloseHandle()
| open()
read()
write()
close()
|
| 文件保护 | SetFileSecurity()
InitializeSecurityDescriptor()
SetSecurityDescriptorGroup()
| chmod()
umask()
chown()
|
| 设备管理 | SetConsoleMode()
ReadConsole()
WriteConsole()
| ioctl()
read()
write()
|
| 信息维护 | GetCurrentProcessID()
SetTimer()
Sleep()
| getpid()
alarm()
sleep()
|
| 通信 | CreatePipe()
CreateFileMapping()
MapViewOfFile()
| pipe()
shmget()
mmap()
|
理解 POSIX 标准
为了确保操作系统之间的一致性,IEEE 对操作系统进行了一些标准化。这些标准在以下部分中描述。
POSIX 标准和特性
Unix 的可移植操作系统接口(POSIX)代表了操作系统接口的一系列标准。第一个版本可以追溯到 1988 年,涵盖了诸如文件名、shell 和正则表达式等一系列主题。
POSIX 定义了许多特性,它们分为四个不同的标准,每个标准都专注于 Unix 兼容性的不同方面。它们都以 POSIX 加一个数字命名。
POSIX.1 - 核心服务
POSIX.1 是 1988 年的原始标准,最初被命名为 POSIX,但后来改名以便在不放弃名称的情况下添加更多的标准。它定义了以下特性:
-
进程创建和控制
-
信号:
-
浮点异常
-
分段/内存违规
-
非法指令
-
总线错误
-
定时器
-
文件和目录操作
-
管道
-
C 库(标准 C)
-
I/O 端口接口和控制
-
进程触发器
POSIX.1b 和 POSIX.1c - 实时和线程扩展
POSIX.1b 专注于实时应用程序和需要高性能的应用程序。它专注于以下方面:
-
优先级调度
-
实时信号
-
时钟和定时器
-
信号量
-
消息传递
-
共享内存
-
异步和同步 I/O
-
内存锁定接口
POSIX.1c 引入了多线程范式,并定义了以下内容:
-
线程创建、控制和清理
-
线程调度
-
线程同步
-
信号处理
POSIX.2 - shell 和实用程序
POSIX.2 为命令行解释器和实用程序(如cd
、echo
或ls
)指定了标准。
操作系统遵从性
并非所有操作系统都符合 POSIX 标准。例如,Windows 诞生于该标准之后,因此不符合标准。从认证的角度来看,macOS 比 Linux 更符合标准,因为后者使用了另一个建立在 POSIX 之上的标准。
Linux 和 macOS
大多数 Linux 发行版遵循Linux 标准基础(LSB),这是另一个包括 POSIX 和更多内容的标准,专注于维护不同 Linux 发行版之间的互操作性。它并未被认为是官方符合标准,因为开发人员没有进行认证过程。
然而,自 2007 年的 Snow Leopard 发行版起,macOS 已经完全兼容,并且自那时起就获得了 POSIX 认证。
Windows
Windows 不符合 POSIX 标准,但有许多尝试使其符合。有一些开源倡议,如 Cygwin 和 MinGW,它们提供了一个不太符合 POSIX 标准的开发环境,并支持使用 Microsoft Visual C 运行时库的 C 应用程序。微软本身也尝试过 POSIX 兼容性,比如 Microsoft POSIX 子系统。微软最新的兼容层是 Windows Linux 子系统,这是 Windows 10 中可选的功能,受到了开发人员(包括我自己)的好评。
总结
在本章中,我们看到了系统编程的含义——编写具有严格要求的系统软件,例如与硬件绑定、使用低级语言以及在资源受限的环境中工作。在构建通常需要优化资源使用的分布式系统时,它的实践可以非常有用。我们讨论了 API,定义了允许软件被其他软件使用的不同类型,包括操作系统中的 API、库和框架中的 API,以及远程和 Web API。
我们分析了在操作系统中,对资源的访问是通过称为保护环的分层级别进行安排的,以防止不受控制的使用,以提高安全性并避免应用程序的故障。Linux 模型简化了这种层次结构,只将其分为称为用户和内核空间的两个级别。所有应用程序都在用户空间中运行,为了访问机器的资源,它们需要内核进行干预。
然后我们看到了一种特定类型的 API,称为系统调用,它允许应用程序向内核请求资源,并调解进程控制、文件访问和管理,以及设备和网络通信。
我们概述了 POSIX 标准,该标准定义了 Unix 系统的互操作性。在定义的特性中,还包括 C API、CLI 实用程序、shell 语言、环境变量、程序退出状态、正则表达式、目录结构、文件名和命令行实用程序 API 约定。
在下一章中,我们将探讨 Unix 操作系统资源,如文件系统和 Unix 权限模型。我们将研究进程是什么,它们如何相互通信,以及它们如何处理错误。
问题
-
应用程序编程和系统编程之间有什么区别?
-
什么是 API?API 为什么如此重要?
-
你能解释一下保护环是如何工作的吗?
-
你能举一些在用户空间无法完成的例子吗?
-
什么是系统调用?
-
Unix 中使用哪些调用来管理进程?
-
POSIX 为什么有用?
-
Windows 是否符合 POSIX 标准?
第二章:Unix 操作系统组件
本章将重点放在 Unix 操作系统上,以及用户将与之交互的组件:文件和文件系统、进程、用户和权限等。它还将解释一些基本的进程通信以及系统程序错误处理的工作原理。在创建系统应用程序时,我们将与操作系统的所有这些部分进行交互。
本章将涵盖以下主题:
-
内存管理
-
文件和文件系统
-
进程
-
用户、组和权限
-
进程通信
技术要求
与上一章类似,本章不需要安装任何软件:任何其他符合 POSIX 标准的 shell 都足够了。
您可以选择,例如,Bash (www.gnu.org/software/bash/
),这是推荐的,Zsh (www.zsh.org/
),或者 fish (fishshell.com/
)。
内存管理
操作系统处理应用程序的主要和辅助内存使用。它跟踪内存的使用情况,由哪个进程使用,哪些部分是空闲的。它还处理从进程分配新内存以及进程完成时的内存释放。
管理技术
处理内存有不同的技术,包括以下内容:
-
单一分配:除了为操作系统保留的部分外,所有内存都可供应用程序使用。这意味着一次只能执行一个应用程序,就像在Microsoft 磁盘操作系统(MS-DOS)中一样。
-
分区分配:这将内存分成不同的块,称为分区。使用其中一个块来执行一个以上的进程是可能的。分区可以重新定位和压缩,以获得下一个进程的更连续的内存空间。
-
分页内存:内存被分成称为帧的部分,其大小固定。进程的内存被分成相同大小的部分,称为页面。页面和帧之间有映射,使进程看到自己的虚拟内存是连续的。这个过程也被称为分页。
虚拟内存
Unix 使用分页内存管理技术,将每个应用程序的内存抽象为连续的虚拟内存。它还使用一种称为交换的技术,将虚拟内存扩展到辅助内存(硬盘或固态硬盘(SSD))使用交换文件。
当内存稀缺时,操作系统将处于休眠状态的进程的页面放入交换分区,以为正在请求更多内存的活动进程腾出空间,执行称为换出的操作。当执行中的进程需要交换文件中的页面时,它会被加载回主内存以执行。这称为换入。
交换的主要问题是与辅助内存交互时的性能下降,但它对于扩展多任务处理能力以及处理比物理内存更大的应用程序非常有用,只需在给定时间加载实际需要的部分。创建内存高效的应用程序是通过避免或减少交换来提高性能的一种方式。
top
命令显示有关可用内存、交换和每个进程的内存消耗的详细信息:
-
RES
是进程使用的物理主内存。 -
VIRT
是进程使用的总内存,包括交换内存,因此它等于或大于RES
。 -
SHR
是实际可共享的VIRT
的部分,例如加载的库。
了解文件和文件系统
文件系统是在磁盘中结构化数据的方法,文件是指示自包含信息的抽象。如果文件系统是分层的,这意味着文件是组织在目录树中的,目录是用于安排存储文件的特殊文件。
操作系统和文件系统
在过去的 50 年中,已经发明和使用了大量文件系统,每个文件系统都有其自己的特点,包括空间管理、文件名和目录、元数据和访问限制。每个现代操作系统主要使用一种类型的文件系统。
Linux
Linux 的首选文件系统是extended filesystem(EXT)家族,但也支持其他文件系统,包括 XFS、Journaled File System(JFS)和B-tree File System(Btrfs)。它还兼容旧的File Allocation Table(FAT)家族(FAT16 和 FAT32)和New Technology File System(NTFS)。最常用的文件系统仍然是最新版本的 EXT(EXT4),它于 2006 年发布,扩展了其前身的功能,包括对更大磁盘的支持。
macOS
macOS 使用Apple File System(APFS),支持 Unix 权限并具有日志记录。它还具有丰富的元数据和保留大小写,同时又是大小写不敏感的文件系统。它支持其他文件系统,包括 HFS+和 FAT32,支持 NTFS 进行只读操作。要向这样的文件系统写入,我们可以使用实验性功能或第三方应用程序。
Windows
Windows 主要使用的文件系统是 NTFS。除了大小写不敏感外,区分 Windows 文件系统与其他文件系统的特征是在路径中使用字母后跟冒号来表示分区,结合使用反斜杠作为文件夹分隔符,而不是正斜杠。驱动器字母和使用 C 表示主分区来自 MS-DOS,其中 A 和 B 是保留的驱动器字母,用于软盘驱动器。
Windows 还原生支持其他文件系统,如 FAT,这是一个在 70 年代末到 90 年代末非常流行的文件系统家族,以及由 Microsoft 开发的Extended File Allocation Table(exFAT),用于可移动设备的格式。
文件和硬链接和软链接
大多数文件都是常规文件,包含一定数量的数据。例如,文本文件包含一系列由特定编码表示的可读字符,而位图包含有关每个像素的大小和使用的位的一些元数据,然后是每个像素的内容。
文件被安排在目录中,这使得可以有不同的命名空间来重用文件名。这些文件通过名称引用,它们的人类可读标识符,并以树结构组织。路径是表示目录的唯一标识符,由所有父目录的名称通过分隔符(Unix 中为/
,Windows 中为\
)连接而成,从根目录到所需的叶子。例如,如果一个名为a
的目录位于另一个名为b
的目录下,后者位于名为c
的目录下,它将从根目录开始并连接所有目录,直到文件:/c/b/a
。
当多个文件指向相同的内容时,我们有一个硬链接,但这在所有文件系统中都不允许(例如 NTFS 和 FAT)。软链接是指向另一个软链接或硬链接的文件。硬链接可以被删除或删除而不会破坏原始链接,但对于软链接来说并非如此。符号链接是一个具有自己数据的常规文件,它是另一个文件的路径。它还可以链接其他文件系统或不存在的文件和目录(这将是一个损坏的链接)。
在 Unix 中,一些实际上不是文件的资源被表示为文件,并且与这些资源的通信是通过写入或从它们对应的文件中读取来实现的。例如,/dev/sda
文件代表整个磁盘,而/dev/stdout
,dev/stdin
和/dev/stderr
是标准输出,输入和错误。一切皆文件的主要优势是可以使用于文件的相同工具也可以与其他设备(网络和管道)或实体(进程)进行交互。
Unix 文件系统
本节中包含的原则特定于 Linux 使用的文件系统,如 EXT4。
根和 inode
在 Linux 和 macOS 中,每个文件和目录都由一个inode表示,这是一种特殊的数据结构,存储有关文件的所有信息,除了其名称和实际数据。
inode 0
用于空值,这意味着没有 inode。inode 1
用于记录磁盘上的任何坏块。文件系统的分层结构的根使用 inode 2
。它由/
表示。
从最新的 Linux 内核源代码中,我们可以看到保留了第一个 inode。如下所示:
#define EXT4_BAD_INO 1 /* Bad blocks inode */
#define EXT4_ROOT_INO 2 /* Root inode */
#define EXT4_USR_QUOTA_INO 3 /* User quota inode */
#define EXT4_GRP_QUOTA_INO 4 /* Group quota inode */
#define EXT4_BOOT_LOADER_INO 5 /* Boot loader inode */
#define EXT4_UNDEL_DIR_INO 6 /* Undelete directory inode */
#define EXT4_RESIZE_INO 7 /* Reserved group descriptors inode */
#define EXT4_JOURNAL_INO 8 /* Journal inode */
此链接是上述代码块的来源:elixir.bootlin.com/linux/latest/source/fs/ext4/ext4.h#L212
。
目录结构
在 Unix 文件系统中,根目录下还有一系列其他目录,每个目录用于特定目的,使得可以在不同操作系统之间保持一定的互操作性,并使得编译的软件可以在不同的操作系统上运行,使得二进制文件具有可移植性。
这是一个包含其范围的目录的全面列表:
目录 | 描述 |
---|---|
/bin | 所有用户的可执行文件 |
/boot | 用于引导系统的文件 |
/dev | 设备驱动程序 |
/etc | 应用程序和系统的配置文件 |
/home | 用户的主目录 |
/kernel | 内核文件 |
/lib | 共享库文件和其他与内核相关的文件 |
/mnt | 临时文件系统,从软盘和 CD 到闪存驱动器 |
/proc | 用于活动进程的进程号文件 |
/sbin | 管理员的可执行文件 |
/tmp | 应该安全删除的临时文件 |
/usr | 管理命令,共享文件,库文件等 |
/var | 变长文件(日志和打印文件) |
导航和交互
在使用 shell 时,其中一个目录将是工作目录,当路径是相对的时(例如,file.sh
或dir/subdir/file.txt
)。工作目录用作前缀以获得绝对路径。这通常显示在命令行的提示中,但可以使用pwd
命令(打印工作目录)打印出来。
cd
(更改目录)命令可用于更改当前工作目录。要创建新目录,有mkdir
(创建目录)命令。
要显示目录的文件列表,有ls
命令,它接受一系列选项,包括更多信息(-l
),显示隐藏文件和目录(-a
),以及按时间(-t
)和大小(-S
)排序。
还有一系列其他命令可用于与文件交互:touch
命令创建一个具有给定名称的新空文件,要编辑其内容,可以使用一系列编辑器,包括 vi 和 nano,而cat
,more
和less
是一些可以读取它们的命令。
挂载和卸载
操作系统将硬盘分割为称为分区的逻辑单元,每个分区可以是不同的文件系统。当操作系统启动时,它使用mount
命令使一些分区可用,每行对应/etc/fstab
文件,看起来更或多是这样:
# device # mount-point # fstype # options # dumpfreq # passno
/dev/sda1 / ext4 defaults 0 1
此配置将/dev/sda1
挂载到*/*disk
,使用ext4
文件系统和默认选项,不备份(0
),并进行根完整性检查(1
)。mount
命令可以随时用于在文件系统中公开分区。它的对应命令umount
用于从主文件系统中删除这些分区。用于操作的空目录称为挂载点,它代表连接文件系统的根目录。
进程
当启动应用程序时,它变成一个进程:操作系统提供的特殊实例,包括运行应用程序所使用的所有资源。为了允许操作系统解释其指令,该程序必须是可执行和可链接格式(ELF)。
进程属性
每个进程都是一个五位数的标识符进程 ID(PID),它代表了进程的整个生命周期。这意味着在同一时间不能有两个具有相同 PID 的进程。它们的唯一性使得可以通过知道其 PID 来访问特定的进程。一旦进程终止,其 PID 可以在需要时被重用于另一个进程。
与 PID 类似,还有其他特性来表征一个进程。它们如下:
-
P****PID:启动此进程的进程的父进程 ID
-
优先级数:此进程对其他进程的友好程度
-
终端或 TTY:进程连接的终端
-
RUID/EUID:进程的真实/有效用户 ID,属于进程所有者
-
RGID/EGID:进程的真实/有效组所有者
要查看活动进程的列表,有ps
(进程状态)命令,显示活动用户的当前运行进程列表:
> ps -f
UID PID PPID C STIME TTY TIME CMD
user 8 4 0 Nov03 pts/0 00:00:00 bash -l -i
user 43 8 0 08:53 pts/0 00:00:00 ps -f
进程生命周期
创建新进程可以以两种不同的方式发生:
-
使用
fork
:这会复制调用进程。子进程(新进程)是父进程(调用进程)的精确副本(内存),除了以下内容: -
PID 是不同的。
-
子进程的 PPID 等于父进程的 PID。
-
子进程不会从父进程继承以下内容:
-
内存锁
-
信号量调整
-
未完成的异步 I/O 操作
-
异步 I/O 上下文
-
使用
exec
:这将用新的进程图像替换当前进程,将程序加载到当前进程空间中,并从其入口点运行。
前台和后台
当启动进程时,通常处于前台,这将阻止与 shell 的通信,直到作业完成或中断。在命令的末尾使用&
符号启动进程(cat file.txt &
)将其启动到后台,从而可以继续使用 shell。可以使用*C**trl *+ Z发送SIGTSTP
信号,允许用户从 shell 挂起前台进程。可以使用fg
命令恢复它,或使用bg
命令将其放到后台。
jobs
命令报告正在运行的作业及其编号。在输出中,方括号中的数字是进程控制命令使用的作业编号,如fg
和bg
。
终止作业
前台进程可以使用*C**trl *+ Z发送SIGINT
信号来终止。为了终止后台进程,或向进程发送任何信号,可以使用kill
命令。
kill
命令接收一个参数,可以是以下之一:
-
发送到进程的信号
-
PID 或作业号(带有
%
前缀)
更显著使用的信号如下:
-
SIGINT
:表示由用户输入引起的终止,可以使用kill
命令发送-2
值 -
SIGTERM
:表示由用户生成的通用终止请求,也是kill
命令的默认信号,值为-6
-
SIGKILL
:由操作系统直接处理的终止,立即终止进程,值为-9
用户,组和权限
用户和组以及权限是 Unix 操作系统中用于控制对资源访问的主要实体。
用户和组
用户和组提供对文件和其他资源的授权。用户具有唯一的用户名,这些用户名是人类友好的标识符,但从操作系统方面来看,每个用户都由唯一的正整数表示:用户 ID(UID)。组是另一个授权机制,与用户一样,它们有一个名称和一个组 ID(GID)。在操作系统中,每个进程都与一个用户关联,每个文件和目录都属于一个用户和一个组。
/etc/passwd
文件包含所有这些信息以及更多信息:
# username : encrypted password : UID : GID : full name : home directory : login shell
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
...
user:x:1000:1000:"User Name":/home/user:/bin/bash
用户不直接使用 UID;他们使用用户名和密码的组合来启动他们的第一个进程,即交互式 shell。第一个 shell 的子进程从中继承他们的 UID,因此他们仍然属于同一用户。
UID0
保留给一个名为 root 的用户,该用户具有特殊权限,并且几乎可以在系统上执行任何操作,例如读取/写入/执行任何文件,终止任何进程,并更改正在运行的进程 UID。
组是用户的逻辑集合,用于在它们之间共享文件和目录。每个组都独立于其他组,它们之间没有特定的关系。要查看当前用户所属的组的列表,可以使用groups
命令。要更改文件的组所有权,可以使用chgrp
。
所有者,组和其他人
Unix 文件属于用户和组。这创建了三个授权层次:
-
所有者:与文件关联的 UID
-
组:属于与文件关联的 GID 的 UIDS
-
其他人:其他所有人
可以为这些组的每个组指定不同的权限,并且这些权限通常从所有者到其他人递减。文件的所有者具有较少的权限,这是没有意义的,因为它自己的组或该组外的用户。
读,写和执行
用户和组被用作访问文件的前两个保护层。拥有文件的用户具有与文件组不同的权限集。不是所有者且不属于该组的人具有不同的权限。这三组权限被称为所有者,组和其他。
对于集合的每个元素,可以执行三个操作:读取,写入和执行。这对于文件来说非常直接,但对于目录来说意味着不同的东西。读取使其可能列出内容,写入用于在内部添加新链接,执行用于导航。
三个权限由八进制值表示,其中第一个位是读取权限,第二个是写入,第三个是执行。它们也可以按顺序用字母r
,w
和x
表示,可能的值如下:
-
0 或---
:没有权限 -
1 或--x
:执行权限(执行文件或导航到目录) -
2 或-w-
:写权限(写文件或在目录中添加新文件) -
3 或-wx
:写和执行 -
4 或 r--
:读取权限(读取文件或列出目录的内容) -
5 或 r-x
:读取和执行 -
6 或 rw-
:读取和写入 -
7 或 rwx
:读取,写入和执行
三个八进制值的序列表示用户,组和其他人的权限:
-
777
:每个人都可以读取,写入和执行。 -
700
:所有者可以读取,写入和执行。 -
664
:所有者和组可以读取和写入。 -
640
:所有者可以读取和写入,组可以读取。 -
755
:所有者可以读取,写入和执行,而组和其他人可以读取和执行。
带有-l
标志(或其别名ll
)的ls
命令显示当前目录的文件和文件夹列表及其权限。
更改权限
chmod
命令使得可以更改文件或目录的权限。这可以用于覆盖当前权限或修改它们:
-
为了替换权限,必须发出
chmod xxx file
命令。*xxx*
可以是表示各自层权限的三个八进制值,也可以是指定权限的字符串,例如u=rwx
,g=rx
或o=r
。 -
要添加或删除一个或多个权限,可以使用
chmod +x file
或chmod -x file
。
有关更多信息,请使用帮助标志(chmod --help
)的chmod
命令。
进程通信
操作系统负责进程之间的通信,并具有不同的机制来交换信息。这些进程是单向的,例如退出代码、信号和管道,或者是双向的,例如套接字。
退出代码
应用程序通过返回称为退出状态的值将其结果传达给操作系统。这是在进程结束时传递给父进程的整数值。常见的退出代码列表可以在/usr/include/sysexits.h
文件中找到,如下所示:
#define EX_OK 0 /* successful termination */
#define EX__BASE 64 /* base value for error messages */
#define EX_USAGE 64 /* command line usage error */
#define EX_DATAERR 65 /* data format error */
#define EX_NOINPUT 66 /* cannot open input */
#define EX_NOUSER 67 /* addressee unknown */
#define EX_NOHOST 68 /* host name unknown */
#define EX_UNAVAILABLE 69 /* service unavailable */
#define EX_SOFTWARE 70 /* internal software error */
#define EX_OSERR 71 /* system error (e.g., can't fork) */
#define EX_OSFILE 72 /* critical OS file missing */
#define EX_CANTCREAT 73 /* can't create (user) output file */
#define EX_IOERR 74 /* input/output error */
#define EX_TEMPFAIL 75 /* temp failure; user is invited to retry */
#define EX_PROTOCOL 76 /* remote error in protocol */
#define EX_NOPERM 77 /* permission denied */
#define EX_CONFIG 78 /* configuration error */
#define EX__MAX 78 /* maximum listed value */
此来源如下:elixir.bootlin.com/linux/latest/source/fs/ext4/ext4.h#L212
。
上一个命令的退出代码存储在$?
变量中,可以测试它以控制操作的流程。一个常用的运算符是&&
(双和号),它仅在第一个命令的退出代码为0
时执行下一个指令,例如stat file && echo something >> file
,仅在文件存在时向文件附加内容。
信号
退出代码连接进程和它们的父进程,但信号使得任何进程与另一个进程进行接口交互成为可能,包括它自己。它们也是异步的和单向的,但它们代表来自进程外部的通信。
最常见的信号是SIGINT
,它告诉应用程序终止,并且可以通过在 shell 中使用Ctrl + C组合键将其发送到前台进程。但是,还有许多其他选项,如下表所示:
名称 | 编号 | 描述 |
---|---|---|
SIGHUP | 1 | 控制终端关闭 |
SIGINT | 2 | 中断信号(Ctrl + C) |
SIGQUIT | 3 | 退出信号(Ctrl + D) |
SIGFPE | 8 | 尝试非法数学运算 |
SIGKILL | 9 | 立即退出应用程序 |
SIGALRM | 14 | 闹钟信号 |
kill
命令允许您向任何应用程序发送信号,并可以使用-l
标志显示可用信号的全面列表:
管道
管道是进程之间最后一种单向通信方法。顾名思义,管道连接两端 - 一个进程的输入与另一个进程的输出 - 使得在同一台主机上进行处理以便交换数据成为可能。
这些被分类为匿名或命名:
-
匿名管道将一个进程的标准输出链接到另一个进程的标准输入。可以在 shell 中使用
|
运算符轻松完成,将管道之前的命令的输出链接为管道之后的命令的输入。ls -l | grep "user"
获取ls
命令的输出并将其用作grep
的输入。 -
命名管道使用特定文件来执行重定向。输出可以使用
>
(大于)运算符重定向到文件,而<
(小于)符号允许您将文件用作另一个进程的输入。ls -l > file.txt
将命令的输出保存到文件中。cat < file.txt
将文件的内容发送到命令的标准输入,标准输入将其复制到标准输出。
还可以使用>>
(双大于)运算符将内容附加到命名管道,这将从文件末尾开始写入。
套接字
Unix 域套接字是同一台机器上应用程序之间的双向通信方法。它们是由内核处理并管理数据交换的逻辑端点。
套接字的性质允许将它们用作流导向或数据报导向。流导向协议确保在转移到下一个数据块之前交付消息,以保持消息完整性。相反,消息导向协议忽略未接收的数据,并继续发送下一个消息,使其成为一个更快但不太可靠且延迟非常低的协议。
套接字分为以下几类:
-
SOCK_STREAM
: 连接导向,有序,可靠地传输数据流 -
SOCK_SEQPACKET
: 连接导向,有序,可靠地传输具有记录边界的消息数据 -
SOCK_DGRAM
: 无序且不可靠地传输消息
总结
本章概述了主要的 Unix 组件及它们之间的交互。我们从内存管理开始,了解 Unix 中的工作原理,理解诸如分页和交换等概念。
然后我们分析了文件系统,看了现代操作系统的支持,并解释了现有文件类型之间的区别:文件、目录以及硬链接和软链接。
在了解了 inode 的概念之后,我们看了 Unix 操作系统中目录的结构,并解释了如何浏览和与文件系统交互,以及如何挂载和卸载其他分区。
我们继续讨论了在 Unix 中运行应用程序的进程及其结构和属性。我们分析了进程的生命周期,从通过fork
或exec
创建到通过kill
命令结束或终止。
另一个重要的主题是用户、组和权限。我们了解了用户是什么,什么是组,如何加入组,以及这些概念如何用于将权限分为三组:用户、组和其他。这有助于更好地理解 Unix 权限模型,以及如何更改文件和目录的权限。
最后,我们看到了进程之间的通信是如何工作的,包括单向通道如信号和退出码,或双向通信如套接字。
在下一章中,我们将快速概述 Go 语言。
问题
-
现代操作系统使用哪种文件系统?
-
什么是 inode?Unix 中的 inode
0
是什么? -
PID 和 PPID 之间有什么区别?
-
如何终止在后台运行的进程?
-
用户和组之间有什么区别?
-
Unix 权限模型的范围是什么?
-
你能解释一下信号和退出码之间的区别吗?
-
什么是交换文件?
第三章:Go 概述
本章将概述 Go 语言及其基本功能。我们将简要解释语言及其特性,并在接下来的章节中详细阐述。这将帮助我们更好地理解 Go,同时使用其所有功能和应用。
本章将涵盖以下主题:
-
语言的特点
-
包和导入
-
基本类型、接口和用户定义类型
-
变量和函数
-
流程控制
-
内置函数
-
并发模型
-
内存管理
技术要求
从本章开始,您需要在计算机上安装 Go。按照以下步骤进行操作:
-
从
golang.org/dl/
下载 Go 的最新版本。 -
使用
tar -C /usr/local -xzf go$VERSION.$OS-$ARCH.tar.gz
进行提取。 -
使用
export PATH=$PATH:/usr/local/go/bin
将其添加到PATH
中。 -
确保使用
go version
安装了 Go。 -
在您的
.profile
中添加 export 语句以自动添加。 -
如果要使用不同的目录来存放代码,也可以更改
GOPATH
变量(默认为~/go
)。
我还建议安装 Visual Studio Code(https://code.visualstudio.com/)及其 vscode-go(https://github.com/Microsoft/vscode-go)扩展,其中包含一个助手,可安装所有需要改进 Go 开发体验的工具。
语言特性
Go 是一种具有出色并发原语和大部分自动化内存系统的现代服务器语言。一些人认为它是 C 的继任者,并且在许多场景中都能做到这一点,因为它的性能良好,具有广泛的标准库,并且有一个提供许多第三方库的伟大社区,这些库涵盖、扩展和改进了其功能。
Go 的历史
Go 是在 2007 年创建的,旨在解决 Google 的工程问题,并于 2009 年公开宣布,2012 年达到 1.0 版本。主要版本仍然相同(版本 1),而次要版本(版本 1.1、1.2 等)随着其功能一起增长。这样做是为了保持 Go 对所有主要版本的兼容性承诺。2018 年提出了两个新功能(泛型和错误处理)的草案,这些功能可能会包含在 2.0 版本中。
Go 背后的大脑如下:
-
Robert Griesemer:谷歌研究员,参与了许多项目,包括 V8 JavaScript 引擎和设计,以及 Sawzall 的实现。
-
Rob Pike:Unix 团队成员,Plan 9 和 Inferno 操作系统开发团队成员,Limbo 编程语言设计团队成员。
-
Ken Thompson:计算机科学的先驱,原始 Unix 的设计者,B 语言的发明者(C 的前身)。Ken 还是 Plan 9 操作系统的创造者和早期开发者之一。
优势和劣势
Go 是一种非常有主见的语言;有些人喜欢它,有些人讨厌它,主要是由于其一些设计选择。以下是一些未受好评的功能:
-
冗长的错误处理
-
缺乏泛型
-
缺少依赖和版本管理
前两点将在下一个主要版本中解决,而后者首先由社区(godep、glide 和 govendor)和 Google 自己(dep 用于依赖项)以及 gopkg.in(http://labix.org/gopkg.in)在版本管理方面解决。
语言的优势是无数的:
-
这是一种静态类型的语言,具有静态类型检查等带来的所有优势。
-
它不需要集成开发环境(IDE),即使它支持许多 IDE。
-
标准库非常强大,对许多项目来说可能是唯一的依赖项。
-
它具有并发原语(通道和 goroutines),隐藏了编写高效且安全的异步代码的最困难部分。
-
它配备了一个格式化工具
gofmt
,统一了 Go 代码的格式,使其他人的代码看起来非常熟悉。 -
它生成没有依赖的二进制文件,使部署快速简便。
-
它是极简主义的,关键字很少,代码非常容易阅读和理解。
-
它是鸭子类型的,具有隐式接口定义(如果它走起来像鸭子,游泳像鸭子,嘎嘎叫像鸭子,那么它可能就是鸭子)。这在测试系统的特定功能时非常方便,因为它可以被模拟。
-
它是跨平台的,这意味着它能够为与托管平台不同的架构和操作系统生成二进制文件。
-
有大量的第三方包,因此在功能方面留下了很少。托管在公共存储库上的每个包都是可索引和可搜索的。
命名空间
现在,让我们看看 Go 代码是如何组织的。GOPATH
环境变量决定了代码的位置。里面有三个子目录:
-
src
包含所有源代码。 -
pkg
包含已编译的包,分为架构/操作系统。 -
bin
包含编译后的二进制文件。
源文件夹下的路径对应包的名称($GOPATH/src/my/package/name
将是my/package/name
)。
go get
命令使得可以使用它来获取和编译包。go get
调用http://package_name?go-get=1
,如果找到go-import
元标签,就会使用它来获取包。该标签应包含包名称、使用的 VCS 和存储库 URL,所有这些都用空格分隔。让我们看一个例子:
<meta name="go-import" content="package-name vcs repository-url">
go get
下载一个包后,它会尝试对其他无法递归解析的包执行相同的操作,直到所有必要的源代码都可用。
每个文件都以package
定义开头,即package package_name
,需要对目录中的所有文件保持一致。如果包生成一个二进制文件,那么包就是main
。
导入和导出符号
包声明后是一系列import
语句,指定所需的包。
导入未使用的包(除非它们被忽略)是编译错误,这就是为什么 Go 格式化工具gofmt
会删除未使用的包。还有一些实验性或社区工具,如 goimports (godoc.org/golang.org/x/tools/cmd/goimports
)或 goreturns (github.com/sqs/goreturns
),也会向 Go 文件添加丢失的导入。避免循环依赖非常重要,因为它们将无法编译。
由于不允许循环依赖,包需要与其他语言设计不同。为了打破循环依赖,最好的做法是从一个包中导出功能,或者用接口替换依赖关系。
Go 将所有符号可见性减少到一个二进制模型 - 导出和未导出 - 不像许多其他语言有中间级别。对于每个包,所有以大写字母开头的符号都是导出的,而其他所有内容只能在包内部使用。导出的值也可以被其他包使用,而未导出的值只能在包内部使用。
一个例外是,如果包路径中的一个元素是internal
(例如my/package/internal/pdf
),这将限制它及其子包只能被附近的包导入(例如my/package
)。如果有很多未导出的符号,并且希望将它们分解为子包,同时阻止其他包使用它,这将非常有用,基本上是私有子包。看一下以下内部包的列表:
-
my/package/internal
-
my/package/internal/numbers
-
my/package/internal/strings
这些只能被my/package
使用,不能被任何其他包导入,包括my
。
导入可以有不同的形式。标准导入形式是完整的包名称:
import "math/rand"
...
rand.Intn
命名导入将包名称替换为自定义名称,引用包时必须使用该名称:
import r "math/rand"
...
r.Intn
相同的包导入使符号可用,而无需命名空间:
import . "math/rand"
...
Intn
忽略的导入用于导入包,而无需使用它们。这使得可以在不在代码中引用包的情况下执行包的init
函数:
import _ math/rand
// still executes the rand.init function
类型系统
Go 类型系统定义了一系列基本类型,包括字节,字符串和缓冲区,复合类型如切片或映射,以及应用程序定义的自定义类型。
基本类型
这些是 Go 的基本类型:
类别 | 类型 |
---|---|
字符串 | string |
布尔值 | bool |
整数 | int ,int8 ,int16 ,int32 和int64 |
无符号整数 | uint ,uint8 ,uint16 ,uint32 和uint64 |
整数指针 | uinptr |
浮点数 | float32 和float64 |
复数 | complex64 和complex128 |
int
,uint
和uiptr
的位数取决于架构(例如,x86 为 32 位,x86_64 为 64 位)。
复合类型
除了基本类型外,还有其他类型,称为复合类型。它们如下:
类型 | 描述 | 示例 |
---|---|---|
指针 | 变量在内存中的地址 | *int |
数组 | 具有固定长度的相同类型元素的容器 | [2]int |
切片 | 数组的连续段 | []int |
映射 | 字典或关联数组 | map[int]int |
结构体 | 可以具有不同类型字段的集合 | struct{ value int } |
函数 | 具有相同参数和输出的一组函数 | func(int, int) int |
通道 | 用于通信相同类型元素的管道 | chan int |
接口 | 一组特定的方法,具有支持它们的基础值 | interface{} |
空接口interface{}
是一个通用类型,可以包含任何值。由于此接口没有要求(方法),因此可以满足任何值。
接口,指针,切片,函数,通道和映射可以具有空值,在 Go 中用nil
表示:
-
指针是不言自明的;它们不指向任何变量地址。
-
接口的基础值可以为空。
-
其他指针类型,如切片或通道,可以为空。
自定义定义的类型
包可以通过使用type defined definition
表达式定义自己的类型,其中定义是共享定义内存表示的类型。自定义类型可以由基本类型定义:
type Message string // custom string
type Counter int // custom integer
type Number float32 // custom float
type Success bool // custom boolean
它们也可以由切片,映射或指针等复合类型定义:
type StringDuo [2]string // custom array
type News chan string // custom channel
type Score map[string]int // custom map
type IntPtr *int // custom pointer
type Transform func(string) string // custom function
type Result struct { // custom struct
A, B int
}
它们也可以与其他自定义类型结合使用:
type Broadcast Message // custom Message
type Timer Counter // custom Counter
type News chan Message // custom channel of custom type Message
自定义类型的主要用途是定义方法并使类型特定于范围,例如定义名为Message
的string
类型。
接口定义的工作方式不同。可以通过指定一系列不同的方法来定义它们,例如:
type Reader interface {
Read(p []byte) (n int, err error)
}
它们也可以是其他接口的组合:
type ReadCloser interface {
Reader
Closer
}
或者,它们可以是两个接口的组合:
type ReadCloser interface {
Reader // composition
Close() error // method
}
变量和函数
既然我们已经看过了类型,我们将看看如何在语言中实例化不同的类型。我们将首先了解变量和常量的工作原理,然后再讨论函数和方法。
处理变量
变量表示映射到连续内存部分的内容。它们具有定义内存扩展量的类型,以及指定内存中内容的值。类型可以是基本类型,复合类型或自定义类型,其值可以通过声明初始化为零值,也可以通过赋值初始化为其他值。
声明
使用 var
关键字和指定名称和类型来声明变量的零值;例如 var a int
。对于来自其他语言(如 Java)的人来说,这可能有些反直觉,因为类型和名称的顺序是颠倒的,但实际上更易读。
var a int
的例子描述了一个带有名称 a
的变量(var
),它是一个整数(int
)。这种表达式创建了一个新的变量,其选定类型的零值为:
类型 | 零值 |
---|---|
数值类型(int 、uint 和 float 类型) | 0 |
字符串(string 类型) | "" |
布尔值 | false |
指针、接口、切片、映射和通道 | nil |
初始化变量的另一种方式是通过赋值,可以有推断类型或特定类型。通过以下方式实现推断类型:
-
变量名称,后跟
:=
运算符和值(例如a := 1
),也称为短声明。 -
var
关键字,后跟名称、=
运算符和一个值(例如var a = 1
)。
请注意,这两种方法几乎是等效的,也是多余的,但 Go 团队决定保留它们两者,以遵守 Go 1 的兼容性承诺。就短声明而言,主要区别在于类型不能被指定,而是由值推断出来。
具有特定类型的赋值是通过声明进行的,后跟等号和值。如果声明的类型是接口,或者推断的类型是不正确的,这将非常有用。以下是一些示例:
var a = 1 // this will be an int
b := 1 // this is equivalent
var c int64 = 1 // this will be an int64
var d interface{} = 1 // this is declared as an interface{}
有些类型需要使用内置函数才能正确初始化:
-
new
可以创建某种类型的指针,同时为底层变量分配一些空间。 -
make
初始化切片、映射和通道: -
切片需要一个额外的参数用于大小,以及一个可选的参数用于底层数组的容量。
-
映射可以有一个参数用于其初始容量。
-
通道也可以有一个参数用于其容量。它们不同于映射,映射不能改变。
使用内置函数初始化类型的方法如下:
a := new(int) // pointer of a new in variable
sliceEmpty := make([]int, 0) // slice of int of size 0, and a capacity of 0
sliceCap := make([]int, 0, 10) // slice of int of size 0, and a capacity of 10
map1 = make(map[string]int) // map with default capacity
map2 = make(map[string]int, 10) // map with a capacity of 10
ch1 = make(chan int) // channel with no capacity (unbuffered)
ch2 = make(chan int, 10) // channel with capacity of 10 (buffered)
操作
我们已经看到了赋值操作,使用 =
运算符给变量赋予一个新值。让我们再看看一些运算符:
-
有比较运算符
==
和!=
,用于比较两个值并返回一个布尔值。 -
有一些数学运算可以在相同类型的所有数字变量上执行,即
+
、-
、*
和/
。求和运算也用于连接字符串。++
和--
是用于将数字增加或减少一的简写形式。+=
、-=
、*=
和/=
在等号之前执行操作,并将其赋给左边的变量。这四种运算产生与所涉及的变量相同类型的值;还有其他一些特定于数字的比较运算符:<
、<=
、>
和>=
。 -
有些运算仅适用于整数,并产生其他整数:
%
、&
、|
、^
、&^
、<<
和>>
。 -
还有一些仅适用于布尔值的运算符,产生另一个布尔值:
&&
、||
和!
。 -
一个运算符仅适用于通道,即
<-
,用于从通道接收值或向通道发送值。 -
对于所有非指针变量,也可以使用
&
,即引用运算符,来获取可以分配给指针变量的变量地址。*
运算符使得可以对指针执行解引用操作,并获取其指示的变量的值:
运算符 | 名称 | 描述 | 示例 |
---|---|---|---|
= | 赋值 | 将值赋给变量 | a = 10 |
:= | 声明和赋值 | 声明一个变量并给它赋值 | a := 0 |
== | 等于 | 比较两个变量,如果它们相同则返回布尔值 | a == b |
!= | 不等于 | 比较两个变量,如果它们不同则返回布尔值 | a != b |
+ | 加 | 相同数值类型之间的求和 | a + b |
- | 减 | 相同数值类型之间的差异 | a - b |
* | 乘 | 相同数值类型的乘法 | a * b |
/ | 除 | 相同数值类型之间的除法 | a / b |
% | 取模 | 相同数值类型的除法后余数 | a % b |
& | 和 | 按位与 | a & b |
&^ | 位清除 | 位清除 | a &^ b |
<< | 左移 | 位向左移动 | a << b |
>> | 右移 | 位向右移动 | a >> b |
&& | 和 | 布尔与 | a && b |
` | ` | 或 | |
! | 非 | 布尔非 | !a |
<- | 接收 | 从通道接收 | <-a |
-> | 发送 | 发送到通道 | a <- b |
& | 引用 | 返回变量的指针 | &a |
* | 解引用 | 返回指针的内容 | *a |
转换
将一种类型转换为另一种类型的操作称为转换,对于接口和具体类型的工作方式略有不同:
-
接口可以转换为实现它的具体类型。此转换可以返回第二个值(布尔值),并显示转换是否成功。如果省略布尔变量,则应用程序将在转换失败时出现恐慌。
-
具体类型之间可以发生类型转换,这些类型具有相同的内存结构,或者可以在数值类型之间发生转换:
type N [2]int // User defined type
var n = N{1,2}
var m [2]int = [2]int(N) // since N is a [2]int this casting is possible
var a = 3.14 // this is a float64
var b int = int(a) // numerical types can be casted, in this case a will be rounded to 3
var i interface{} = "hello" // a new empty interface that contains a string
x, ok := i.(int) // ok will be false
y := i.(int) // this will panic
z, ok := i.(string) // ok will be true
有一种特殊的条件运算符用于转换,称为类型开关,它允许应用程序一次尝试多次转换。以下是使用interface{}
检查底层值的示例:
func main() {
var a interface{} = 10
switch a.(type) {
case int:
fmt.Println("a is an int")
case string:
fmt.Println("a is a string")
}
}
作用域
变量具有作用域或可见性,这也与其生命周期相关。这可以是以下之一:
-
包: 变量在所有包中可见;如果变量被导出,它也可以从其他包中可见。
-
函数: 变量在声明它的函数内可见。
-
控制: 变量在定义它的块内可见。
可见性下降,从包到块。由于块可以嵌套,外部块对内部块的变量没有可见性。
同一作用域中的两个变量不能具有相同的名称,但内部作用域的变量可以重用标识符。当这种情况发生时,外部变量在内部作用域中不可见 - 这称为遮蔽,需要牢记以避免出现难以识别的问题,例如以下情况:
// this exists in the outside block
var err error
// this exists only in this block, shadows the outer err
if err := errors.New("Doh!"); err !=
fmt.Println(err) // this not is changing the outer err
}
fmt.Println(err) // outer err has not been changed
常量
Go 的变量没有不可变性,但定义了另一种不可变值的类型称为常量。这由const
关键字定义(而不是var
),它们是不能改变的值。这些值可以是基本类型和自定义类型,如下所示:
-
数值(整数,
float
) -
复杂
-
字符串
-
布尔
指定的值在分配给变量时没有类型。数值类型和基于字符串的类型都会自动转换,如下面的代码所示:
const PiApprox = 3.14
var PiInt int = PiApprox // 3, converted to integer
var Pi float64 = PiApprox // is a float
type MyString string
const Greeting = "Hello!"
var s1 string = Greeting // is a string
var s2 MyString = Greeting // string is converted to MyString
数值常量对数学运算非常有用,因为它们只是常规数字,所以可以与任何数值变量类型一起使用。
函数和方法
Go 中的函数由func
关键字标识,后跟标识符、可能的参数和返回值。Go 中的函数可以一次返回多个值。参数和返回类型的组合称为签名,如下面的代码所示:
func simpleFunc()
func funcReturn() (a, b int)
func funcArgs(a, b int)
func funcArgsReturns(a, b int) error
括号中的部分是函数体,return
语句可以在其中用于提前中断函数。如果函数返回值,则return
语句必须返回相同类型的值。
return
值可以在签名中命名;它们是零值变量,如果return
语句没有指定其他值,那么这些值就是返回的值:
func foo(a int) int { // no variable for returned type
if a > 100 {
return 100
}
return a
}
func bar(a int) (b int) { // variable for returned type
if a > 100 {
b = 100
return // same as return b
}
return a
}
在 Go 中,函数是一级类型,它们也可以被分配给变量,每个签名代表不同的类型。它们也可以是匿名的;在这种情况下,它们被称为闭包。一旦一个变量被初始化为一个函数,相同的变量可以被重新分配为具有相同签名的另一个函数。以下是将闭包分配给变量的示例:
var a = func(item string) error {
if item != "elixir" {
return errors.New("Gimme elixir!")
}
return nil
}
由接口声明的函数称为方法,它们可以由自定义类型实现。方法的实现看起来像一个函数,唯一的区别是名称前面有一个实现类型的单个参数。这只是一种语法糖——方法定义在幕后创建一个函数,它接受一个额外的参数,即实现方法的类型。
这种语法使得可以为不同类型定义相同的方法,每个方法将作为函数声明的命名空间。通过这种方式,可以以两种不同的方式调用方法,如下面的代码所示:
type A int
func (a A) Foo() {}
func main() {
A{}.Foo() // Call the method on an instance of the type
A.Foo(A{}) // Call the method on the type and passing an instance as argument
}
重要的是要注意,类型及其指针共享相同的命名空间,因此同一个方法只能为其中一个实现。同一个方法不能同时为类型和其指针定义,因为对于类型和其指针声明两次方法将产生编译错误(方法重复声明)。方法不能为接口定义,只能为具体类型定义,但接口可以用于复合类型,包括函数参数和返回值,如下面的示例所示:
// use error interface with chan
type ErrChan chan error
// use error interface in a map
type Result map[string]error
type Printer interface{
Print()
}
// use the interface as argument
func CallPrint(p Printer) {
p.Print()
}
内置包已经定义了一个接口,它在标准库中和所有在线可用的包中都被使用——error
接口:
type error interface {
Error() string
}
这意味着任何类型都可以使用Error() string
方法作为错误,并且每个包都可以根据自己的需要定义其错误类型。这可以用来简洁地携带有关错误的信息。在这个例子中,我们定义了ErrKey
,它指定了未找到string
键。除了键之外,我们不需要任何其他东西来表示我们的错误,如下面的代码所示:
type ErrKey string
func (e Errkey) Error() string {
returm fmt.Errorf("key %q not found", e)
}
值和指针
在 Go 中,一切都是按值传递的,所以当函数或方法被调用时,变量的副本会被放在堆栈中。这意味着对值所做的更改不会反映在被调用的函数之外。即使切片、映射和其他引用类型也是按值传递的,但由于它们的内部结构包含指针,它们的行为就像是按引用传递一样。如果为一种类型定义了一个方法,则不能为其指针定义该方法,反之亦然。下面的示例已经用来检查值只在方法内部更新,而这种更改不会反映在main
函数中:
package main
import (
"fmt"
)
type A int
func (a A) Foo() {
a++
fmt.Println("foo", a)
}
func main() {
var a A
fmt.Println("before", a) // 0
a.Foo() // 1
fmt.Println("after", a) // 0
}
为了改变原始变量,参数必须是指向变量本身的指针——指针将被复制,但它将引用相同的内存区域,从而可以改变其值。请注意,分配另一个值指针,而不是其内容,不会改变原始指针所引用的内容,因为它是一个副本。
如果我们为类型而不是其指针使用方法,我们将看不到更改在方法外传播。
在下面的示例中,我们使用了值接收器。这使得Birthday
方法中的User
值成为main
中的User
值的副本:
type User struct {
Name string
Age int
}
func (u User) Birthday() {
u.Age++
fmt.Println(u.Name, "turns", u.Age)
}
func main() {
u := User{Name: "Pietro", Age: 30}
fmt.Println(u.Name, "is now", u.Age)
u.Birthday()
fmt.Println(u.Name, "is now", u.Age)
}
完整的示例可在play.golang.org/p/hnUldHLkFJY
中找到。
由于更改是应用于副本的,原始值保持不变,正如我们从第二个打印语句中所看到的。如果我们想要更改原始对象中的值,我们必须使用指针接收器,这样被复制的对象将是指针,更改将被应用于底层值:
func (u *User) Birthday() {
u.Age++
fmt.Println(u.Name, "turns", u.Age)
}
完整示例可在play.golang.org/p/JvnaQL9R7U5
中找到。
我们可以看到使用指针接收器允许我们更改底层值,并且我们可以更改struct
的一个字段或替换整个struct
本身,如下面的代码所示:
func (u *User) Birthday() {
*u = User{Name: u.Name, Age: u.Age + 1}
fmt.Println(u.Name, "turns", u.Age)
}
完整示例可在play.golang.org/p/3ugBEZqAood
中找到。
如果我们尝试更改指针的值而不是底层值,我们将编辑一个与在main
中创建的对象无关的新对象,并且更改不会传播:
func (u *User) Birthday() {
u = &User{Name: u.Name, Age: u.Age + 1}
fmt.Println(u.Name, "turns", u.Age)
}
完整示例可在play.golang.org/p/m8u2clKTqEU
中找到。
Go 中的一些类型是自动按引用传递的。这是因为这些类型在内部被定义为包含指针的结构。这创建了一个类型列表,以及它们的内部定义:
类型 | 内部定义 |
---|---|
map |
struct {
m *internalHashtable
}
|
slice |
---|
struct {
array *internalArray
len int
cap int
}
|
channel |
---|
struct {
c *internalChannel
}
|
理解流控制
为了控制应用程序的流程,Go 提供了不同的工具 - 一些语句如if
/else
,switch
和for
用于顺序场景,而go
和select
等其他语句用于并发场景。
条件
if
语句验证一个二进制条件,并在条件为true
时执行if
块内的代码。当存在else
块时,当条件为false
时执行。该语句还允许在条件之前进行短声明,用;
分隔。这个条件可以与else if
语句链接,如下面的代码所示:
if r := a%10; r != 0 { // if with short declaration
if r > 5 { // if without declaration
a -= r
} else if r < 5 { // else if statement
a += 10 - r
}
} else { // else statement
a /= 10
}
另一个条件语句是switch
。这允许短声明,就像if
一样,然后是一个表达式。这样的表达式的值可以是任何类型(不仅仅是布尔类型),并且它与一系列case
语句进行比较,每个case
语句后面都跟着一段代码。第一个与表达式匹配的语句,如果switch
和case
条件相等,将执行其块。
如果在中断块的执行中存在break
语句,但有一个fallthrough
,则执行以下case
块内的代码。一个称为default
的特殊情况可以用来在没有满足条件的情况下执行其代码,如下面的代码所示:
switch tier { // switch statement
case 1: // case statement
fmt.Println("T-shirt")
if age < 18{
break // exits the switch block
}
fallthrough // executes the next case
case 2:
fmt.Println("Mug")
fallthrough // executes the next case
case 3:
fmt.Println("Sticker pack")
default: // executed if no case is satisfied
fmt.Println("no reward")
}
循环
for
语句是 Go 中唯一的循环语句。这要求您指定三个表达式,用;
分隔:
-
对现有变量进行短声明或赋值
-
在每次迭代之前验证的条件
-
在迭代结束时执行的操作
所有这些语句都是可选的,没有条件意味着它总是true
。break
语句中断循环的执行,而continue
跳过当前迭代并继续下一个:
for { // infinite loop
if condition {
break // exit the loop
}
}
for i < 0 { // loop with condition
if condition {
continue // skip current iteration and execute next
}
}
for i:=0; i < 10; i++ { // loop with declaration, condition and operation
}
当switch
和for
的组合嵌套时,continue
和break
语句将引用内部流控制语句。
外部循环或条件可以使用name:
表达式进行标记,其中名称是其标识符,loop
和continue
都可以在后面跟着名称,以指定在哪里进行干预,如下面的代码所示:
label:
for i := a; i<a+2; i++ {
switch i%3 {
case 0:
fmt.Println("divisible by 3")
break label // this break the outer for loop
default:
fmt.Println("not divisible by 3")
}
}
探索内置函数
我们已经列出了一些用于初始化一些变量的内置函数,即make
和new
。现在,让我们逐个查看每个函数并了解它们的作用:
-
func append(slice []Type, elems ...Type) []Type
: 此函数将元素追加到切片的末尾。如果底层数组已满,则在追加之前将内容重新分配到一个更大的切片中。 -
func cap(v Type) int
: 返回数组、或者如果参数是切片则返回底层数组的元素数量。 -
func close(c chan<- Type)
: 关闭一个通道。 -
func complex(r, i FloatType) ComplexType
: 给定两个浮点数,返回一个复数。 -
func copy(dst, src []Type) int
: 从一个切片复制元素到另一个切片。 -
func delete(m map[Type]Type1, key Type)
: 从映射中删除一个条目。 -
func imag(c ComplexType) FloatType
: 返回复数的虚部。 -
func len(v Type) int
: 返回数组、切片、映射、字符串或通道的长度。 -
func make(t Type, size ...IntegerType) Type
: 创建一个新的切片、映射或通道。 -
func new(Type) *Type
: 返回指向指定类型变量的指针,并初始化为零值。 -
func panic(v interface{})
: 停止当前 goroutine 的执行,并且如果没有被拦截,整个程序也会停止。 -
func print(args ...Type)
: 将参数写入标准错误。 -
func println(args ...Type)
: 将参数写入标准错误,并在末尾添加一个新行。 -
func real(c ComplexType) FloatType
: 返回复数的实部。 -
func recover() interface{}
: 停止 panic 序列并捕获 panic 值。
延迟、panic 和 recover
一个隐藏了很多复杂性但使得执行许多操作变得容易的非常重要的关键字是defer
。这个关键字应用于函数、方法或闭包的执行,并使得它之前的函数在函数返回之前执行。一个常见且非常有用的用法是关闭资源。在成功打开资源后,延迟的关闭语句将确保它被执行,而不受退出点的影响,如下面的代码所示:
f, err := os.Open("config.txt")
if err != nil {
return err
}
defer f.Close() // it will be closed anyways
// do operation on f
在函数的生命周期内,所有延迟语句都被添加到一个列表中,并在退出之前按相反的顺序执行,从最后一个defer
到第一个。
即使发生 panic,这些语句也会被执行,这就是为什么带有recover
调用的延迟函数可以用于拦截相应 goroutine 中的 panic 并避免否则会终止应用程序的 panic。除了手动调用panic
函数外,还有一组操作会引发 panic,包括以下操作:
-
访问负数或不存在的数组/切片索引(索引超出范围)
-
将整数除以
0
-
向关闭的通道发送数据
-
对
nil
指针进行解引用(nil
指针) -
使用递归函数调用填充堆栈(堆栈溢出)
Panic 应该用于不可恢复的错误,这就是为什么在 Go 中错误只是值。恢复 panic 应该只是尝试在退出应用程序之前对该错误进行处理。如果发生了意外问题,那是因为它没有被正确处理或者缺少了一些检查。这代表了一个需要处理的严重问题,程序需要改变,这就是为什么它应该被拦截和解除。
并发模型
并发对于 Go 来说是如此核心,以至于它的两个基本工具只是关键字——chan
和go
。这是一种非常巧妙的方式,它隐藏了一个设计良好且实现简单易懂的并发模型的复杂性。
理解通道和 goroutine
通道是用于通信的,这就是为什么 Go 的口号是:
“不要通过共享内存来通信,而是通过通信来共享内存。”
通道用于共享数据,通常连接应用程序中的两个或多个执行线程,这使得可以发送和接收数据而不必担心数据安全性。Go 具有由运行时而不是操作系统管理的轻量级线程的实现,它们之间进行通信的最佳方式是通过使用通道。
创建一个新的 goroutine 非常简单 - 只需要使用go
运算符,后面跟着一个函数执行。这包括方法调用和闭包。如果函数有任何参数,它们将在例程开始之前被评估。一旦开始,如果不使用通道,就无法保证来自外部作用域的变量更改会被同步:
a := myType{}
go doSomething(a) // function call
go func() { // closure call
// ...
}() // note that the closure is executed
go a.someMethod() // method call
我们已经看到如何使用make
函数创建一个新的通道。如果通道是无缓冲的(0
容量),向通道发送数据是一个阻塞操作,它会等待另一个 goroutine 从同一个通道接收数据以解锁它。容量显示了通道在进行下一个发送操作之前能够容纳多少消息:
unbuf := make(chan int) // unbuffered channel
buf := make(chan int, 3) // channel with size 3
为了向通道发送数据,我们可以使用<-
运算符。如果通道在运算符的左边,那么这是一个发送操作,如果在右边,那么这是一个接收操作。从通道接收到的值可以被赋给一个变量,如下所示:
var ch = make(chan int)
go func() {
b := <-ch // receive and assign
fmt.Println(b)
}()
ch <- 10 // send to channel
使用close()
函数可以关闭一个通道。这个操作意味着不能再向通道发送更多的值。这通常是发送者的责任。向关闭的通道发送数据会导致panic
,这就是为什么应该由接收者来完成。此外,当从通道接收数据时,可以在赋值中指定第二个布尔变量。如果通道仍然打开,这将为真,以便接收者知道通道何时已关闭。
var ch = make(chan int)
go func() {
b, ok := <-ch // channel open, ok is true
b, ok = <-ch // channel closed, ok is false
b <- ch // channel close, b will be a zero value
}()
ch <- 10 // send to channel
close(ch) // close the channel
有一个特殊的控制语句叫做select
,它的工作方式与switch
完全相同,但只能在通道上进行操作:
var ch1 = make(chan int)
var ch2 = make(chan int)
go func() { ch1 <- 10 }
go func() { <-ch2 }
switch { // the first operation that completes is selected
case a := <-ch1:
fmt.Println(a)
case ch2 <- 20:
fmt.Println(b)
}
理解内存管理
Go 是垃圾收集的;它以计算成本管理自己的内存。编写高效的应用程序需要了解其内存模型和内部工作,以减少垃圾收集器的工作并提高总体性能。
栈和堆
内存被分为两个主要区域 - 栈和堆。应用程序入口函数(main
)有一个栈,每个 goroutine 都有一个栈,它们存储在堆中。栈就像其名字所暗示的那样,是一个随着每个函数调用而增长的内存部分,在函数返回时会收缩。堆由一系列动态分配的内存区域组成,它们的生命周期不像栈中的项目那样事先定义;堆空间可以随时分配和释放。
所有超出定义它们的函数生存期的变量都存储在堆中,比如返回的指针。编译器使用一个叫做逃逸分析的过程来检查哪些变量进入堆中。可以使用go tool compile -m
命令来验证这一点。
栈中的变量随着函数的执行而来而去。让我们看一个栈如何工作的实际例子:
func main() {
var a, b = 0, 1
f1(a,b)
f2(a)
}
func f1(a, b int) {
c := a + b
f2(c)
}
func f2(c int) {
print(c)
}
我们有main
函数调用一个名为f1
的函数,它调用另一个名为f2
的函数。然后,同一个函数直接被main
调用。
当main
函数开始时,栈会随着被使用的变量而增长。在内存中,这看起来像下表,每一列代表栈的伪状态,表示栈随时间变化的方式,从左到右:
main 调用 | f1 调用 | f2 调用 | f2 返回 | f1 返回 | f2 调用 | f2 返回 | main 返回 |
---|---|---|---|---|---|---|---|
main() | main() | main() | main() | main() | main() | main() | // 空 |
a = 0 | a = 0 | a = 0 | a = 0 | a = 0 | a = 0 | a = 0 | |
b = 1 | b = 1 | b = 1 | b = 1 | b = 1 | b = 1 | b = 1 | |
f1() | f1() | f1() | f2() | ||||
a = 0 | a = 0 | a = 0 | c = 0 | ||||
b = 1 | b = 1 | b = 1 | |||||
c = 1 | c = 1 | c = 1 | |||||
f2() | |||||||
c = 1 |
当调用f1
时,堆栈再次增长,通过将a
和b
变量复制到新部分并添加新变量c
来实现。f2
也是同样的情况。当f2
返回时,堆栈通过摆脱函数及其变量来缩小,这就是f1
完成时发生的情况。当直接调用f2
时,它会通过回收用于f1
的相同内存部分来再次增长。
垃圾收集器负责清理堆中未引用的值,因此避免在其中存储数据是降低垃圾收集器(GC)工作量的好方法,这会在 GC 运行时导致应用程序性能略微下降。
Go 中 GC 的历史
GC 负责释放堆中任何栈中未引用的区域。这最初是用 C 编写的,并且具有停止世界行为。程序会在一小段时间内停止,释放内存,然后恢复其流程。
Go 1.4 开始将运行时,包括垃圾收集器,转换为 Go。将这些部分翻译成 Go 为更容易的优化奠定了基础,这在 1.5 版本中已经开始,其中 GC 变得更快,并且可以与其他 goroutine 并发运行。
从那时起,该过程进行了大量的优化和改进,成功将 GC 时间减少了几个数量级。
构建和编译程序
现在我们已经快速概述了所有语言特性和功能,我们可以专注于如何运行和构建我们的应用程序。
安装
在 Go 中,有不同的命令来构建软件包和应用程序。第一个是go install
,后面跟着路径或软件包名称,它将在$GOPATH
内的pkg
目录中创建软件包的编译版本。
所有编译的软件包都按操作系统和架构组织,这些都存储在$GOOS
和$GOARCH
环境变量中。可以使用go env
命令查看这些设置,以及其他信息,比如编译标志:
$ go env
GOARCH="amd64"
...
GOOS="linux"
GOPATH="/home/user/go"
...
GOROOT="/usr/lib/go-1.12"
...
对于当前的架构和操作系统,所有编译的软件包将被放置在$GOOS_$GOARCH
子目录中:
$ ls /home/user/go/pkg/
linux_amd64
如果软件包名称是main
并且包含一个main
函数,该命令将生成一个可执行的二进制文件,该文件将存储在$GOPATH/bin
中。如果软件包已经安装并且源文件没有更改,它将不会被重新编译,这将在第一次编译后显著加快构建时间。
构建
也可以使用go build
命令在特定位置构建二进制文件。可以使用-o
标志定义特定的输出文件,否则将在工作目录中构建,使用软件包名称作为二进制文件名:
# building the current package in the working directory
$ go build .
# building the current package in a specific location
$ go build . -o "/usr/bin/mybinary"
执行go build
命令时,参数可以是以下之一:
-
一个软件包作为相对路径(比如
go build .
用于当前软件包或go build ../name
) -
一个软件包作为绝对路径(
go build some/package
)将在$GOPATH
中查找 -
一个特定的 Go 源文件(
go build main.go
)
后一种情况允许您构建一个位于$GOPATH
之外的文件,并且将忽略同一目录中的任何其他源文件。
运行
还有第三个命令,它类似于构建,但也运行二进制文件。它使用build
命令使用临时目录作为输出创建二进制文件,并即时执行二进制文件:
$ go run main.go
main output line 1
main output line 2
$
当您对源代码进行更改时,可以使用运行而不是构建或安装。如果代码相同,最好是构建一次,然后多次执行。
摘要
在本章中,我们看了一些 Go 的历史以及它当前的优缺点。在了解了命名空间后,我们探讨了包系统和导入的工作方式,以及基本、复合和用户定义类型的类型系统。
我们通过查看变量的声明和初始化方式,允许类型之间的操作,如何将变量转换为其他类型,以及如何查看接口的基础类型,来重点关注变量。我们看到了作用域和屏蔽的工作方式,以及常量和变量之间的区别。之后,我们进入了函数,它是一种一等类型,以及每个签名代表不同类型的方式。然后,我们了解了方法实际上是伪装成函数并附加到允许自定义类型满足接口的类型。
此外,我们学习了如何使用诸如if
、for
和switch
之类的语句来控制应用程序流程。我们分析了各种控制语句和循环语句之间的区别,并查看了每个内置函数的作用。然后,我们看到了基本并发是如何通过通道和 goroutine 工作的。最后,我们对 Go 的内部内存分配方式有了一些了解,以及其垃圾收集器的历史和性能,以及如何构建、安装和运行 Go 二进制文件。
在下一章中,我们将看到如何通过与文件系统交互将其中一些内容付诸实践。
问题
-
导出符号和未导出符号之间有什么区别?
-
自定义类型为什么重要?
-
短声明的主要限制是什么?
-
什么是作用域,它如何影响变量屏蔽?
-
如何访问一个方法?
-
解释一下一系列
if
/else
和switch
之间的区别。 -
在典型的用例中,通常谁负责关闭通道?
-
什么是逃逸分析?
第二部分:高级文件 I/O 操作
本节涵盖了应用程序的输入和输出操作,重点放在 Unix 操作系统中的文件/文件系统和流上。它涵盖了许多主题,包括从文件中读取和写入,以及其他 I/O 操作。然后我们解释了 Go 在 I/O 方面还有多少更多的接口和实现。
本节包括以下章节:
-
第四章,与文件系统一起工作
-
第五章,处理流
-
第六章,构建伪终端
第四章:处理文件系统
本章主要讲解与 Unix 文件系统的交互。在这里,我们将从基本的读写操作到更高级的缓冲操作,如标记扫描和文件监控,一切都会涉及。
Unix 中所有用户或系统的信息都存储为文件,因此为了与系统和用户数据交互,我们必须与文件系统交互。
在本章中,我们将看到执行读写操作的不同方式,以及每种方式更注重代码的简单性,应用程序的内存使用和性能,以及执行速度。
本章将涵盖以下主题:
-
文件路径操作
-
读取文件
-
写文件
-
其他文件系统操作
-
第三方包
技术要求
本章需要安装 Go 并设置您喜欢的编辑器。有关更多信息,请参阅第三章,Go 概述。
处理路径
Go 提供了一系列函数,可以操作与平台无关的文件路径,主要包含在path/filepath
和os
包中。
工作目录
每个进程都有一个与之关联的目录,称为工作目录,通常从父进程继承。这使得可以指定相对路径 - 不以根文件夹开头。在 Unix 和 macOS 上为/
,在 Windows 上为C:\
(或任何其他驱动器号)。
绝对/完整路径以根目录开头,并且表示文件系统中的相同位置,即/usr/local
。
相对路径不以根目录开头,路径以当前工作目录开头,即documents
。
操作系统将这些路径解释为相对于当前目录,因此它们的绝对版本是工作目录和相对路径的连接。让我们看一个例子:
user:~ $ cd documents
user:~/documents $ cd ../videos
user:~/videos $
在这里,用户位于他们的家目录~
。用户指定要切换到documents
目录,cd
命令会自动将工作目录作为前缀添加到它,并移动到~/documents
。
在进入第二个命令之前,让我们介绍一下在所有操作系统的所有目录中都可用的两个特殊文件:
-
.
: 点是指当前目录。如果它是路径的第一个元素,则是进程的工作目录,否则它指的是它前面的路径元素(例如,在~/./documents
中,.
指的是~
)。 -
..
: 双点是指当前目录的父目录,如果它是路径的第一个元素,或者是它前面的目录的父目录(例如,在~/images/../documents
中,..
指的是~/images
的父目录,~
)。
了解这一点,我们可以轻松推断出第二个路径首先加入~/documents/../videos
,然后解析父元素..
,得到最终路径~/videos
。
获取和设置工作目录
我们可以使用os
包的func Getwd() (dir string, err error)
函数来找出表示当前工作目录的路径。
更改工作目录是使用同一包的另一个函数完成的,即func Chdir(dir string) error
,如下面的代码所示:
wd, err := os.Getwd()
if err != nil {
fmt.Println(err)
return
}
fmt.Println("starting dir:", wd)
if err := os.Chdir("/"); err != nil {
fmt.Println(err)
return
}
if wd, err = os.Getwd(); err != nil {
fmt.Println(err)
return
}
fmt.Println("final dir:", wd)
路径操作
filepath
包包含不到 20 个函数,与标准库的包相比数量较少,它用于操作路径。让我们快速看一下这些函数:
-
func Abs(path string) (string, error)
: 返回传递的路径的绝对版本(如果它不是绝对的,则将其连接到当前工作目录),然后清理它。 -
func Base(path string) string
: 给出路径的最后一个元素(基本路径)。例如,path/to/some/file
返回file
。请注意,如果路径为空,此函数将返回一个*.*
(点)路径。 -
func Clean(path string) string
: 通过应用一系列定义的规则返回路径的最短版本。它执行操作,如替换.
和..
,或删除尾部分隔符。 -
func Dir(path string) string
: 获取不包含最后一个元素的路径。这通常返回元素的父目录。 -
func EvalSymlinks(path string) (string, error)
: 在评估符号链接后返回路径。如果提供的路径也是相对的,并且不包含绝对路径的符号链接,则路径是相对的。 -
func Ext(path string) string
: 获取路径的文件扩展名,即以路径的最后一个元素的最终点开始的后缀,如果没有点,则为空字符串(例如docs/file.txt
返回.txt
)。 -
func FromSlash(path string) string
: 用操作系统路径分隔符替换路径中找到的所有/
(斜杠)。如果操作系统是 Windows,则此函数不执行任何操作,并在 Unix 或 macOS 下执行替换。 -
func Glob(pattern string) (matches []string, err error)
: 查找与指定模式匹配的所有文件。如果没有匹配的文件,则结果为nil
。它不报告在路径探索过程中发生的任何错误。它与Match
共享语法。 -
func HasPrefix(p, prefix string) bool
: 此函数已弃用。 -
func IsAbs(path string) bool
: 显示路径是否为绝对路径。 -
func Join(elem ...string) string
: 通过使用文件路径分隔符连接多个路径元素来连接它们。请注意,这也在结果上调用Clean
。 -
func Match(pattern, name string) (matched bool, err error)
: 验证给定的名称是否与模式匹配,允许使用通配符字符*
和?
,以及使用方括号的组或字符序列。 -
func Rel(basepath, targpath string) (string, error)
: 返回从基本路径到目标路径的相对路径,如果不可能则返回错误。此函数在结果上调用Clean
。 -
func Split(path string) (dir, file string)
: 使用最终的尾部斜杠将路径分成两部分。结果通常是输入路径的父路径和文件名。如果没有分隔符,dir
将为空,文件将是路径本身。 -
func SplitList(path string) []string
: 使用列表分隔符字符返回路径列表,Unix 和 macOS 中为:
,Windows 中为;
。 -
func ToSlash(path string) string
: 执行与FromSlash
函数执行的相反替换,将每个路径分隔符更改为/
,在 Unix 和 macOS 上不执行任何操作,并在 Windows 上执行替换。 -
func VolumeName(path string) string
: 在非 Windows 平台上不执行任何操作。它返回引用卷的路径组件。这对本地路径和网络资源都适用。 -
func Walk(root string, walkFn WalkFunc)
error
: 从根目录开始,此函数递归地遍历文件树,对树的每个条目执行遍历函数。如果遍历函数返回错误,则遍历停止,并返回该错误。该函数定义如下:
type WalkFunc func(path string, info os.FileInfo, err error) error
在继续下一个示例之前,让我们介绍一个重要的变量:os.Args
。此变量至少包含一个值,即调用当前进程的路径。这可以跟随在同一调用中指定的可能参数。
我们想要实现一个小应用程序,列出并计算目录中的文件数量。我们可以使用刚刚看到的一些工具来实现这一点。
在下面的代码中显示了列出和计数文件的示例:
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
if len(os.Args) != 2 { // ensure path is specified
fmt.Println("Please specify a path.")
return
}
root, err := filepath.Abs(os.Args[1]) // get absolute path
if err != nil {
fmt.Println("Cannot get absolute path:", err)
return
}
fmt.Println("Listing files in", root)
var c struct {
files int
dirs int
}
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
// walk the tree to count files and folders
if info.IsDir() {
c.dirs++
} else {
c.files++
}
fmt.Println("-", path)
return nil
})
fmt.Printf("Total: %d files in %d directories", c.files, c.dirs)
}
从文件中读取
可以使用io/ioutil
包中的辅助函数,以及ReadFile
函数来获取文件的内容,该函数一次性打开、读取和关闭文件。这使用一个小缓冲区(512 字节)并将整个内容加载到内存中。如果文件大小非常大,未知,或者文件内容可以一次处理一部分,这不是一个好主意。
一次从磁盘读取一个巨大的文件意味着将所有文件内容复制到主内存中,这是有限的资源。这可能会导致内存不足,以及运行时错误。一次读取文件的一部分可以帮助读取大文件的内容,而不会导致大量内存使用。这是因为在读取下一块时将重用相同部分的内存。
一次性读取所有内容的示例如下所示:
package main
import (
"fmt"
"io/ioutil"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("Please specify a path.")
return
}
b, err := ioutil.ReadFile(os.Args[1])
if err != nil {
fmt.Println("Error:", err)
}
fmt.Println(string(b))
}
读取器接口
对于所有从磁盘读取的操作,有一个至关重要的接口:
type Reader interface {
Read(p []byte) (n int, err error)
}
它的工作非常简单 - 用已读取的内容填充给定的字节片,并返回已读取的字节数和错误(如果发生错误)。有一个特殊的错误变量由io
包定义,称为EOF
(文件结束),当没有更多输入可用时应返回它。
读取器使得可以以块的方式处理数据(大小由切片确定),如果同一切片用于后续操作,则由此产生的程序始终更加内存高效,因为它使用了相同的有限内存部分来分配切片。
文件结构
os.File
类型满足读取器接口,并且是用于与文件内容交互的主要角色。获取用于读取目的的实例的最常见方法是使用os.Open
函数。在使用完文件后记得关闭文件非常重要 - 对于短暂存在的程序可能不明显,但如果一个应用程序不断打开文件而不关闭已经完成的文件,应用程序将达到操作系统强加的打开文件限制并开始失败打开操作。
shell 提供了一些实用程序,如下所示:
-
获取打开文件的限制 -
ulimit -n
-
另一个是检查某个进程打开了多少文件 -
lsof -p PID
前面的示例仅打开文件以将其内容显示到标准输出,通过将所有内容加载到内存中来实现。可以很容易地通过刚才提到的工具进行优化。在下面的示例中,我们使用一个小缓冲区并在下一次读取之前打印其内容,使用小缓冲区以将内存使用量保持在最低。
使用字节数组作为缓冲区的示例如下所示:
func main() {
if len(os.Args) != 2 {
fmt.Println("Please specify a file")
return
}
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Println("Error:", err)
return
}
defer f.Close() // we ensure close to avoid leaks
var (
b = make([]byte, 16)
)
for n := 0; err == nil; {
n, err = f.Read(b)
if err == nil {
fmt.Print(string(b[:n])) // only print what's been read
}
}
if err != nil && err != io.EOF { // we expect an EOF
fmt.Println("\n\nError:", err)
}
}
如果一切正常,读取循环将继续执行读取操作,直到文件内容结束。在这种情况下,读取循环将返回一个io.EOF
错误,表明没有更多内容可用。
使用缓冲区
数据缓冲区,或者只是一个缓冲区,是用于在数据移动时存储临时数据的一部分内存。字节缓冲区是在bytes
包中实现的,并且由一个底层切片实现,该切片能够在需要存储的数据量不适合时进行扩展。
如果每次分配新缓冲区,旧缓冲区最终将被 GC 自行清理,这不是一个最佳解决方案。最好始终重用缓冲区而不是分配新的。这是因为它们使得可以重置切片同时保持容量不变(数组不会被 GC 清除或收集)。
缓冲区还提供了两个函数来显示其底层长度和容量。在下面的示例中,我们可以看到如何使用Buffer.Reset
重用缓冲区以及如何跟踪其容量。
缓冲重用及其底层容量的示例如下所示:
package main
import (
"bytes"
"fmt"
)
func main() {
var b = bytes.NewBuffer(make([]byte, 26))
var texts = []string{
`As he came into the window`,
`It was the sound of a crescendo
He came into her apartment`,
`He left the bloodstains on the carpet`,
`She ran underneath the table
He could see she was unable
So she ran into the bedroom
She was struck down, it was her doom`,
}
for i := range texts {
b.Reset()
b.WriteString(texts[i])
fmt.Println("Length:", b.Len(), "\tCapacity:", b.Cap())
}
}
窥视内容
在前面的示例中,我们固定了一定数量的字节,以便在打印之前存储内容。bufio
包提供了一些功能,使得可以使用用户无法直接控制的底层缓冲区,并且可以执行一个非常重要的操作,名为peek。
Peeking 是在不推进阅读器光标的情况下读取内容的能力。在这里,在幕后,被窥视的数据存储在缓冲区中。每次读取操作都会检查这个缓冲区是否有数据,如果有,那么数据将被返回并从缓冲区中移除。这就像一个队列(先进先出)。
这个简单操作打开的可能性是无穷的,它们都源于窥视直到找到所需的数据序列,然后实际读取感兴趣的块。这个操作的最常见用途包括以下内容:
-
缓冲区从阅读器中读取,直到找到换行符(一次读取一行)。
-
直到找到空格为止(一次读取一个单词)。
允许应用程序实现这种行为的结构是bufio.Scanner
。这使得可以定义分割函数是什么,并具有以下类型:
type SplitFunc func(data []byte, atEOF bool) (advance int, token []byte, err error)
当返回错误时,此函数停止,否则它返回要在内容中前进的字节数,最终返回一个标记。包中实现的函数如下:
-
ScanBytes
:字节标记 -
ScanRunes
:符文标记 -
ScanWord
:单词标记 -
ScanLines
:行标记
我们可以实现一个文件阅读器,只需一个阅读器就可以计算行数。结果程序将尝试模拟 Unix 的wc -l
命令的功能。
一个打印文件并计算行数的示例在以下代码中显示:
func main() {
if len(os.Args) != 2 {
fmt.Println("Please specify a path.")
return
}
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Println("Error:", err)
return
}
defer f.Close()
r := bufio.NewReader(f) // wrapping the reader with a buffered one
var rowCount int
for err == nil {
var b []byte
for moar := true; err == nil && moar; {
b, moar, err = r.ReadLine()
if err == nil {
fmt.Print(string(b))
}
}
// each time moar is false, a line is completely read
if err == nil {
fmt.Println()
rowCount++
}
}
if err != nil && err != io.EOF {
fmt.Println("\nError:", err)
return
}
fmt.Println("\nRow count:", rowCount)
}
Closer 和 seeker
还有另外两个与阅读器相关的接口:io.Closer
和io.Seeker
:
type Closer interface {
Close() error
}
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}
这些通常与io.Reader
结合使用,得到的接口如下:
type ReadCloser interface {
Reader
Closer
}
type ReadSeeker interface {
Reader
Seeker
}
Close
方法确保资源被释放并避免泄漏,而Seek
方法使得可以移动当前对象(例如Writer
)的光标到文件的起始/结束位置,或者从当前位置移动。
os.File
结构实现了这个方法,以满足所有列出的接口。在操作结束时关闭文件是可能的,或者根据你想要实现的目标移动当前光标。
写入文件
正如我们在阅读中所看到的,写入文件有不同的方式,每种方式都有其自身的缺点和优势。例如,在ioutil
包中,我们有另一个名为WriteFile
的函数,它允许我们在一行中执行整个操作。这包括打开文件,写入其内容,然后关闭文件。
一个一次性写入文件所有内容的示例在以下代码中显示:
package main
import (
"fmt"
"io/ioutil"
"os"
)
func main() {
if len(os.Args) != 3 {
fmt.Println("Please specify a path and some content")
return
}
// the second argument, the content, needs to be casted to a byte slice
if err := ioutil.WriteFile(os.Args[1], []byte(os.Args[2]), 0644); err != nil {
fmt.Println("Error:", err)
}
}
这个示例一次性写入所有内容。这要求我们使用一个字节切片在内存中分配所有内容。如果内容太大,内存使用可能会成为操作系统的问题,这可能会终止我们的应用程序的进程。
如果内容的大小不是很大,而且应用程序的生命周期很短,那么如果内容加载到内存中并用单个操作写入,这不是问题。这对于长期运行的应用程序来说不是最佳实践,这些应用程序对许多不同的文件进行读取和写入。它们必须在内存中分配所有内容,而该内存将在某个时刻被 GC 释放 - 这个操作不是免费的,这意味着它在内存使用和性能方面有缺点。
Writer 接口
对于写入也适用于阅读的相同原则 - 在io
包中有一个确定写入行为的接口,如下所示:
type Writer interface {
Write(p []byte) (n int, err error)
}
io.Writer
接口定义了一个方法,给定一个字节片,返回已写入多少字节以及是否有任何错误。写入器使得可以一次写入一块数据,而无需一次性拥有所有数据。os.File
结构也恰好是一个写入器,并且可以以这种方式使用。
我们可以使用字节片作为缓冲区逐段写入信息。在下面的示例中,我们将尝试将从上一节读取的内容与写入相结合,使用io.Seeker
的能力在写入之前反转其内容。
下面的代码示例显示了反转文件内容的示例:
// Let's omit argument check and file opening, we obtain src and dst
cur, err := src.Seek(0, os.SEEK_END) // Let's go to the end of the file
if err != nil {
fmt.Println("Error:", err)
return
}
b := make([]byte, 16)
在移动到文件末尾并定义字节缓冲区后,我们进入一个循环,该循环在文件中稍微向后移动,然后读取其中的一部分,如下面的代码所示:
for step, r, w := int64(16), 0, 0; cur != 0; {
if cur < step { // ensure cursor is 0 at max
b, step = b[:cur], cur
}
cur = cur - step
_, err = src.Seek(cur, os.SEEK_SET) // go backwards
if err != nil {
break
}
if r, err = src.Read(b); err != nil || r != len(b) {
if err == nil { // all buffer should be read
err = fmt.Errorf("read: expected %d bytes, got %d", len(b), r)
}
break
}
然后,我们将内容反转并将其写入目标,如下面的代码所示:
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
switch { // Swap (\r\n) so they get back in place
case b[i] == '\r' && b[i+1] == '\n':
b[i], b[i+1] = b[i+1], b[i]
case j != len(b)-1 && b[j-1] == '\r' && b[j] == '\n':
b[j], b[j-1] = b[j-1], b[j]
}
b[i], b[j] = b[j], b[i] // swap bytes
}
if w, err = dst.Write(b); err != nil || w != len(b) {
if err != nil {
err = fmt.Errorf("write: expected %d bytes, got %d", len(b), w)
}
}
}
if err != nil && err != io.EOF { // we expect an EOF
fmt.Println("\n\nError:", err)
}
缓冲区和格式
在前一节中,我们看到bytes.Buffer
可以用于临时存储数据,并且通过附加底层切片来处理自己的增长。fmt
包广泛使用缓冲区来执行其操作;由于依赖原因,这些不是字节包中的缓冲区。这种方法是 Go 的谚语之一:
“少量复制胜过少量依赖。”
如果必须导入一个包来使用一个函数或类型,那么应该考虑将必要的代码复制到自己的包中。如果一个包包含的内容远远超出了你的需求,复制可以减少最终二进制文件的大小。您还可以自定义代码并根据自己的需求进行调整。
缓冲区的另一个用途是在写入之前组成消息。让我们编写一些代码,以便我们可以使用缓冲区格式化书籍列表:
const grr = "G.R.R. Martin"
type book struct {
Author, Title string
Year int
}
func main() {
dst, err := os.OpenFile("book_list.txt", os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
fmt.Println("Error:", err)
return
}
defer dst.Close()
bookList := []book{
{Author: grr, Title: "A Game of Thrones", Year: 1996},
{Author: grr, Title: "A Clash of Kings", Year: 1998},
{Author: grr, Title: "A Storm of Swords", Year: 2000},
{Author: grr, Title: "A Feast for Crows", Year: 2005},
{Author: grr, Title: "A Dance with Dragons", Year: 2011},
// if year is omitted it defaulting to zero value
{Author: grr, Title: "The Winds of Winter"},
{Author: grr, Title: "A Dream of Spring"},
}
b := bytes.NewBuffer(make([]byte, 0, 16))
for _, v := range bookList {
// prints a msg formatted with arguments to writer
fmt.Fprintf(b, "%s - %s", v.Title, v.Author)
if v.Year > 0 {
// we do not print the year if it's not there
fmt.Fprintf(b, " (%d)", v.Year)
}
b.WriteRune('\n')
if _, err := b.WriteTo(dst); true { // copies bytes, drains buffer
fmt.Println("Error:", err)
return
}
}
}
缓冲区用于组成书籍描述,如果不存在年份,则会被省略。在处理字节时,这是非常高效的,如果每次都重用缓冲区,效果会更好。如果此类操作的输出应该是一个字符串,则strings
包中有一个非常相似的结构称为Builder
,它具有相同的写入方法,但也有一些不同之处,例如以下内容:
-
String()
方法使用unsafe
包将字节转换为字符串,而不是复制它们。 -
不允许复制
strings.Builder
,然后对副本进行写入,因为这会导致panic
。
高效写入
每次执行os.File
方法,即Write
,这将转换为系统调用,这是一个带有一些开销的操作。一般来说,通过一次写入更多的数据来减少在此类调用上花费的时间是一个好主意,从而最小化操作的数量。
bufio.Writer
结构是一个包装另一个写入器(如os.File
)的写入器,并且仅在缓冲区满时执行写入操作。这使得可以使用Flush
方法执行强制写入,通常保留到写入过程的结束。使用缓冲区的一个良好模式如下:
var w io.WriteCloser
// initialise writer
defer w.Close()
b := bufio.NewWriter(w)
defer b.Flush()
// write operations
defer
语句在返回当前函数之前以相反的顺序执行,因此第一个Flush
确保将缓冲区中的任何内容写入,然后Close
实际关闭文件。如果两个操作以相反的顺序执行,flush 将尝试写入一个关闭的文件,返回错误,并且无法写入最后一块信息。
文件模式
我们看到os.OpenFile
函数使得可以选择如何使用文件模式打开文件,文件模式是一个uint32
,其中每个位都有特定含义(类似于 Unix 文件和文件夹权限)。os
包提供了一系列值,每个值都指定一种模式,正确的组合方式是使用|
(按位或)。
下面的代码显示了可用的代码,并且直接从 Go 的源代码中获取:
// Exactly one of O_RDONLY, O_WRONLY, or O_RDWR must be specified.
O_RDONLY int = syscall.O_RDONLY // open the file read-only.
O_WRONLY int = syscall.O_WRONLY // open the file write-only.
O_RDWR int = syscall.O_RDWR // open the file read-write.
// The remaining values may be or'ed in to control behavior.
O_APPEND int = syscall.O_APPEND // append data to the file when writing.
O_CREATE int = syscall.O_CREAT // create a new file if none exists.
O_EXCL int = syscall.O_EXCL // used with O_CREATE, file must not exist.
O_SYNC int = syscall.O_SYNC // open for synchronous I/O.
O_TRUNC int = syscall.O_TRUNC // if possible, truncate file when opened.
前三个表示允许的操作(读、写或两者),其他的如下:
-
O_APPEND
: 在每次写入之前,文件偏移量被定位在文件末尾。 -
O_CREATE
: 可以创建文件(如果文件不存在)。 -
O_EXCL
: 如果与创建一起使用,如果文件已经存在,则失败(独占创建)。 -
O_SYNC
: 执行读/写操作并验证其完成。 -
O_TRUNC
: 如果文件存在,其大小将被截断为0
。
其他操作
读和写不是文件上可以执行的唯一操作。在下一节中,我们将看看如何使用os
包来执行它们。
创建
要创建一个空文件,可以调用一个名为Create
的辅助函数,它以0666
权限打开一个新文件,并在文件不存在时将其截断。或者,我们可以使用OpenFile
与O_CREATE|O_TRUNCATE
模式来指定自定义权限,如下面的代码所示:
package main
import "os"
func main() {
f, err := os.Create("file.txt")
if err != nil {
fmt.Println("Error:", err)
return
}
f.Close()
}
截断
要截断文件内容到一定尺寸,并且如果文件较小则保持不变,可以使用os.Truncate
方法。其用法非常简单,如下面的代码所示:
package main
import "os"
func main() {
// let's keep thing under 4kB
if err := os.Truncate("file.txt", 4096); err != nil {
fmt.Println("Error:", err)
}
}
删除
为了删除文件,还有另一个简单的函数,称为os.Remove
,如下面的代码所示:
package main
import "os"
func main() {
if err := os.Remove("file.txt"); err != nil {
fmt.Println("Error:", err)
}
}
移动
os.Rename
函数可以更改文件名和/或其目录。请注意,如果目标文件已经存在,此操作将替换目标文件。
更改文件名或其目录的代码如下:
import "os"
func main() {
if err := os.Rename("file.txt", "../file.txt"); err != nil {
fmt.Println("Error:", err)
}
}
复制
没有唯一的函数可以复制文件,但可以使用io.Copy
函数轻松实现。下面的示例显示了如何使用它从一个文件复制到另一个文件:
func CopyFile(from, to string) (int64, error) {
src, err := os.Open(from)
if err != nil {
return 0, err
}
defer src.Close()
dst, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return 0, err
}
defer dst.Close()
return io.Copy(dst, src)
}
统计
os
包提供了FileInfo
接口,返回文件的元数据,如下面的代码所示:
type FileInfo interface {
Name() string // base name of the file
Size() int64 // length in bytes for regular files; system-dependent for others
Mode() FileMode // file mode bits
ModTime() time.Time // modification time
IsDir() bool // abbreviation for Mode().IsDir()
Sys() interface{} // underlying data source (can return nil)
}
os.Stat
函数返回指定路径文件的信息。
更改属性
为了与文件系统交互并更改这些属性,有三个函数可用:
-
func Chmod(name string, mode FileMode) error
: 更改文件的权限 -
func Chown(name string, uid, gid int) error
: 更改文件的所有者和组 -
func Chtimes(name string, atime time.Time, mtime time.Time) error
: 更改文件的访问和修改时间
第三方包
社区提供了许多可以完成各种任务的包。我们将在本节中快速浏览其中一些。
虚拟文件系统
在 Go 中,文件是一个具体类型的结构体,没有围绕它们的抽象,而文件的信息由os.FileInfo
表示,它是一个接口。这有点不一致,已经有许多尝试创建一个完整和一致的文件系统抽象,通常称为虚拟文件系统。
最常用的两个包如下:
-
vfs
: github.com/blang/vfs -
afero
: github.com/spf13/afero
即使它们是分开开发的,它们都做同样的事情——它们定义了一个具有os.File
所有方法的接口,然后定义了一个实现os
包中可用的函数的接口,比如创建、打开和删除文件等。
它们提供了基于标准包实现的os.File
版本,但也有一个使用模拟文件系统的数据结构的内存版本。这对于为任何包构建测试非常有用。
文件系统事件
Go 在golang.org/x/
包中有一些实验性功能,这些功能位于 Go 的 GitHub 处理程序下(github.com/golang/
)。golang.org/x/sys
包是其中之一,包括一个专门用于 Unix 系统事件的子包。这已被用于构建一个在 Go 的文件功能中缺失的功能,可以非常有用 - 观察某个路径上的文件事件,如创建、删除和更新。
最著名的两个实现如下:
-
notify
:github.com/rjeczalik/notify -
fsnotify
:github.com/fsnotify/fsnotify
这两个包都公开了一个函数,允许创建观察者。观察者是包含负责传递文件事件的通道的结构。它们还公开了另一个负责终止/关闭观察者和底层通道的函数。
总结
在本章中,我们概述了如何在 Go 中执行文件操作。为了定位文件,filepath
包提供了广泛的函数数组。这些函数可以帮助您执行各种操作,从组合路径到从中提取元素。
我们还看了如何使用各种方法读取操作,从位于io/ioutil
包中的最简单和内存效率较低的方法到需要io.Writer
实现来读取固定大小的字节块的方法。在bufio
包中实现的查看内容的能力的重要性,允许进行一整套操作,如读取单词或读取行,当找到一个标记时停止读取操作。有其他对文件非常有用的接口;例如,io.Closer
确保资源被释放,io.Seeker
用于在不需要实际读取文件和丢弃输出的情况下移动读取光标。
将字节切片写入文件可以通过不同的方式实现 - io/ioutil
包可以通过函数调用实现,而对于更复杂或更节省内存的操作,可以使用io.Writer
接口。这使得可以一次写入一个字节切片,并且可以被fmt
包用于打印格式化数据。缓冲写入用于减少在磁盘上的实际写入量。这是通过一个收集内容的缓冲区来实现的,然后每次缓冲区满时将其传输到磁盘上。
最后,我们看到了如何在文件系统上执行其他文件操作(创建、删除、复制/移动和更改文件属性),并查看了一些与文件系统相关的第三方包,即虚拟文件系统抽象和文件系统事件通知。
下一章将讨论流,并将重点放在与文件系统无关的所有读取器和写入器的实例上。
问题
-
绝对路径和相对路径有什么区别?
-
如何获取或更改当前工作目录?
-
使用
ioutil.ReadAll
的优点和缺点是什么? -
为什么缓冲对于读取操作很重要?
-
何时应该使用
ioutil.WriteFile
? -
使用允许查看的缓冲读取器时有哪些操作可用?
-
何时最好使用字节缓冲区读取内容?
-
缓冲区如何用于写入?使用它们有什么优势?