第3章 理解系统调用
# 第3章 理解系统调用
在本章中,你将开启一段探索系统调用世界的旅程。系统调用是连接用户级程序和操作系统内核的基础接口。通过形象的类比和现实生活中的相似场景,我们将揭开软件执行过程中复杂机制的神秘面纱,重点介绍内核、用户模式和内核模式的关键作用。
对于任何想要开发高效且健壮应用程序的软件开发人员来说,理解系统调用及其与操作系统的交互至关重要。在本书的大背景下,本章为后续关于高级操作系统交互和系统级编程的讨论奠定了基础。此外,在现实世界中,掌握这些概念能让开发人员拥有优化软件性能、在系统层面排查问题,以及充分利用操作系统功能的能力。
在本章中,我们将涵盖以下主要内容:
- 系统调用简介
syscall
包- 深入探究
os
和x/sys
包 - 日常使用的系统调用
- 开发并测试一个命令行界面(CLI,Command-Line Interface)程序
在本章结束时,你不仅会掌握系统调用的理论基础,还将通过使用Go语言构建一个命令行界面应用程序获得实践经验。
# 系统调用简介
系统调用(通常也称为 “syscalls”)是操作系统接口的基础组成部分。它们是操作系统内核提供的低级函数,允许用户级进程向内核请求服务。
如果你对这个概念还很陌生,一些类比可能会让理解变得更加容易。我们可以将其与旅行进行关联。
用户模式与内核模式 处理器(或CPU)有两种操作模式:用户模式和内核模式(也称为超级用户模式或特权模式)。这些模式决定了程序对系统资源的访问和控制级别。用户模式受到限制,不允许直接访问某些关键系统资源,而内核模式具有更多特权,可以访问这些资源。获得许可后,请谨慎操作 |
---|
说到系统调用,内核就像是一位严格的边境管制官员。系统调用就像是我们在软件执行的各种场景中通行所需要的 “护照”。把内核想象成戒备森严的国际边境检查站,就像旅行者需要获得许可才能进入外国一样,我们的进程也需要得到批准才能访问内核资源。系统调用就像护照,让我们能够跨越用户空间和内核空间之间的 “边境”。
# 服务目录与标识
就像旅行指南一样,内核通过系统调用应用程序编程接口(API,Application Programming Interface)提供了全面的服务目录。这些服务涵盖从创建新进程到处理输入输出(I/O,Input/Output)操作等各个方面,就如同外国的各种便利设施和景点。
每个系统调用都由一个数字代码唯一标识,就像你的护照号码一样。不过,这个编号系统在日常使用中是隐藏的。相反,我们通过系统调用的名称与之交互,就像旅行者通过当地名称而不是代码来识别服务和地标一样。例如,在某些内核的内部系统调用表中,open
系统调用可能由数字5标识。然而,作为程序员,我们使用它的名称 “open” 来引用这个系统调用,就像旅行者用地名而不是GPS坐标来识别地点一样。
系统调用表的位置取决于操作系统和架构。如果你对这些标识符感兴趣,可以访问以下链接中的手工制作表格:https://filippo.io/linux-syscall-table/ (opens new window)。
# 信息交换
系统调用不是单向的事务,它们涉及到信息的仔细交换。每个系统调用都带有参数,这些参数决定了哪些数据需要在用户空间(你的进程所在的领域)和内核空间(内核的领域)之间传输。可以把它想象成一场协调良好的跨境对话,信息在双方之间无缝传递。
当你使用 write()
系统调用将数据保存到文件时,你不仅要传递数据,还要传递关于写入位置的信息(例如,文件描述符和数据缓冲区)。这种数据交换就像跨境对话,数据在用户空间和内核空间之间顺畅地流动。
# syscall包
我们注意到一个一致的趋势:我们所需的绝大多数功能都可以在标准库中轻松获取,这充分证明了标准库的全面性和实用性。然而,syscall
包是这个模式中的一个显著例外。
这个包一直是在各种架构和操作系统上与系统调用和常量进行交互的基础。但随着时间的推移,出现了一些问题,导致它被弃用:
- 过于庞大:谁会不喜欢一个不断膨胀的包呢?
syscall
包几乎包含了你能想到(甚至想不到)的每一个系统调用和常量的定义,它就像那个你一直说要清理,但却始终没动手的堆满东西的衣柜。 - 测试限制:这个包的很大一部分缺乏明确的测试。此外,由于其设计问题,跨平台测试难以实现。
- 管理难题:在变更列表方面,这个包就像 “狂野西部” 一样 —— 几乎任何变更都被欣然接受。它不仅是人人都想参加的聚会,还是支持众多其他包和系统的中心。然而,这种受欢迎程度是有代价的。确定这些无数变更的价值变得越来越困难,结果呢?
syscall
包成为了标准库中维护最少、测试最少、文档最少的包之一。 - 文档问题:
syscall
包对于不同的系统有独特的变体,对开发人员来说就像一个神秘的谜团。虽然人们希望能有清晰的文档,但godoc
工具只能提供一个大致的预览,就像电影预告片只展示精彩片段一样。这种根据本地环境进行的选择性展示,再加上整体文档的缺乏,使得理解和有效使用这个包成为一项具有挑战性的任务。 - 兼容性不稳定:尽管Go团队努力维护Go 1的兼容性保证,但
syscall
包常常让人感觉像是在追逐独角兽,始终难以捉摸。操作系统在不断发展,这带来的挑战超出了Go团队的控制范围。例如,FreeBSD系统的变化就影响了这个包的兼容性。
为了解决这些问题,Go团队提出了以下方案:
- 冻结
syscall
包:从Go 1.3开始,syscall
包被冻结,这意味着不再对其进行任何更改。即使它所涉及的操作系统发生变化,该包也不会更新。 - 引入
x/sys
包:创建了一个新的包x/sys
(https://pkg.go.dev/golang.org/x/sys (opens new window))来取代syscall
包。这个新包更易于维护、编写文档,并且更适合用于跨平台开发。 - 弃用
syscall
包:虽然syscall
包会继续存在并保持功能,但所有新的公开开发都转移到了x/sys
包。syscall
包的文档会引导用户使用这个新的代码库。
从本质上讲,虽然 syscall
包在一段时间内发挥了作用,但它在维护、文档和兼容性方面带来的挑战,使得用更具结构化和可维护性的 x/sys
包来取代它成为必然。
关于这个决策的更多信息,可以查看Rob Pike发布的一篇文章,解释了做出该决策的原因(https://go.googlesource.com/proposal/+/refs/heads/master/design/freeze-syscall.md (opens new window))。
# 深入探究os和x/sys包
正如我们在Go语言文档中关于 x/sys
包的描述中看到的:
“x/sys
包的主要用途是在其他包内部,这些包为系统提供更具可移植性的接口,比如 os
、time
和 net
包。
如果可以的话,请使用那些包,而不是这个包。有关这个包中函数和数据类型的详细信息,请查阅相应操作系统的手册。这些调用返回 err == nil
表示成功;否则,err
是一个描述失败原因的操作系统错误。在大多数系统中,这个错误的类型是 syscall.Errno
。”
# x/sys包 - 低级系统调用
Go语言中的 x/sys
包提供了对低级系统调用的访问。在需要直接与操作系统交互或进行特定平台操作时,通常会使用这个包。使用 x/sys
包时需要格外小心,因为错误的使用可能会导致系统不稳定或安全问题。
要使用这个包,你应该使用Go工具进行下载:
go get -u golang.org/x/sys
让我们来探索一下这个包能提供哪些功能。
# 系统调用
以下是一些系统调用的调用方式和常量:
unix.Syscall()
:使用参数调用特定的系统调用unix.Syscall6()
:与Syscall()
类似,但用于有六个参数的系统调用unix.SYS_*
:代表各种系统调用的常量(例如,unix.SYS_READ
、unix.SYS_WRITE
)
例如,下面两段代码片段会产生相同的结果,即打印出 “Hello World!”。使用 fmt
包,你会得到以下输出:
fmt.Println("Hello World!")
而使用 x/sys
包,你会得到以下代码:
unix.Syscall(unix.SYS_WRITE, 1,
uintptr(unsafe.Pointer(&[]byte("Hello, World!")[0])),
uintptr(len("Hello, World!")),
)
2
3
4
如果我们决定使用低级抽象而不是 fmt
包,事情会很容易变得复杂。我们可以按类别继续探索这个包的API。
# 文件操作
这些函数让我们能够与普通文件进行交互:
unix.Create()
:创建一个新文件unix.Unlink()
:删除一个文件unix.Mkdir()
、unix.Rmdir()
和unix.Link()
:创建和删除目录以及链接unix.Getdents()
:获取目录项
# 信号
以下是两个与操作系统信号交互的函数示例:
unix.Kill()
:向一个进程发送终止信号unix.SIGINT
:中断信号(通常称为Ctrl + C
)
# 用户和组管理
我们可以使用以下调用管理用户和组:
syscall.Setuid()
、syscall.Setgid()
、syscall.Setgroups()
:设置用户和组ID
# 系统信息
我们可以使用 Sysinfo()
函数分析内存和交换空间使用情况以及系统负载平均值等统计信息:
syscall.Sysinfo()
:获取系统信息
# 文件描述符
虽然这不是日常操作,但我们也可以直接与文件描述符进行交互:
unix.FcntlInt()
:对文件描述符执行各种操作unix.Dup2()
:复制文件描述符
# 内存映射文件
Mmap
是内存映射文件(memory-mapped files)的缩写。它提供了一种无需依赖系统调用即可读写文件的机制。使用 Mmap()
时,操作系统会分配程序虚拟地址空间的一部分,这部分空间会直接 “映射” 到相应的文件部分。如果程序从该地址空间的这部分访问数据,它将检索存储在文件相关部分的数据:
syscall.Mmap()
:将文件或设备映射到内存
# 操作系统功能
Go语言中的 os
包提供了丰富的函数集,用于与操作系统进行交互。它被划分为几个子包,每个子包专注于操作系统功能的特定方面。
以下是文件和目录操作相关的函数:
os.Create()
:创建或打开一个文件用于写入os.Mkdir()
和os.MkdirAll()
:创建目录os.Remove()
和os.RemoveAll()
:删除文件和目录os.Stat()
:获取文件或目录的信息(元数据)os.IsExist()
、os.IsNotExist()
和os.IsPermission()
:检查文件/目录是否存在或权限错误os.Open()
:打开一个文件用于读取os.Rename()
:重命名或移动文件os.Truncate()
:调整文件大小os.Getwd()
:获取当前工作目录os.Chdir()
:更改当前工作目录os.Args
:命令行参数os.Getenv()
:获取环境变量os.Setenv()
:设置环境变量
以下是与进程和信号相关的函数:
os.Getpid()
:获取当前进程IDos.Getppid()
:获取父进程IDos.Getuid()
和os.Getgid()
:获取用户和组IDos.Geteuid()
和os.Getegid()
:获取有效用户和组IDos.StartProcess()
:启动一个新进程os.Exit()
:退出当前进程os.Signal
:表示信号(例如,SIGINT
、SIGTERM
)os/signal.Notify()
:在接收到信号时发出通知
os
包允许你创建和管理进程。你可以启动新进程、获取当前进程的信息并修改其属性:
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
// Start a new process
cmd := exec.Command("ls", "-l")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
fmt.Println(err)
return
}
// Get the current process ID
pid := os.Getpid()
fmt.Println("Current process ID:", pid)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这个程序的主要部分如下:
exec.Command("ls", "-l")
:创建一个新的命令,用于运行带有-l
标志的ls
命令。cmd.Stdout = os.Stdout
:将ls
命令的标准输出重定向到主程序的标准输出。cmd.Stderr = os.Stderr
:同样地,将ls
命令的标准错误重定向到主程序的标准错误。err := cmd.Run()
:运行ls
命令。如果在执行过程中出现错误,错误信息将存储在err
变量中。os.Getpid()
:获取当前进程的进程ID。
虽然 os
包为许多与系统相关的任务提供了高级接口,但 syscall
(以及 x/sys
)包允许你直接进行低级系统调用。当你需要对系统资源进行精细控制时,这会非常有用。
# 可移植性
虽然x/sys
包是进行系统调用(syscall)的常用包,但你必须在Unix和Windows系统之间明确选择。与操作系统交互的推荐方式是使用os
包。当你针对特定的操作系统和架构构建程序时,编译器会自动选用合适版本的系统调用。
例如,在Windows系统中,你需要调用具有以下签名的函数:
SetEnvironmentVariable(name *uint16, value *uint16) (err error)
而在基于Unix的系统中,函数签名则完全不同,如下所示:
Setenv(key, value string) error
为了避免这种 “签名拼凑” 的情况,我们可以使用os
包中语义相同的函数:
Setenv(key, value string) error
(没错!这个签名与Unix版本的一样。)
注意syscall (https://pkg.go.dev/syscall)的主要用途是在其他为系统提供更具可移植性接口的包中,比如os 、time 和net 包。从现在起,我们主要使用 os 包,只有在特殊情况下才会直接调用x/sys 包。 |
---|
# 最佳实践
作为使用Go语言中os
和x/sys
包的系统程序员,应遵循以下最佳实践:
- 大多数任务使用
os
包,因为它提供了更安全、更具可移植性的接口。 - 仅在需要对系统调用进行精细控制的情况下使用
x/sys
包。 - 使用
x/sys
包时,注意特定平台的常量和类型,以确保跨平台兼容性。 - 认真处理系统调用和
os
包函数返回的错误,以维护应用程序的可靠性。 - 在不同的操作系统上测试你的系统级代码,以验证其在不同环境中的行为。
接下来,让我们探索如何追踪日常在终端中执行的命令背后发生的事情。
# 日常系统调用
每次运行程序时,都有许多系统调用在悄然发生。我们可以使用strace
工具来追踪这些调用。
# 追踪系统调用
strace
工具可能并非在所有Linux发行版中都预装,但在大多数官方软件源中都能找到。以下是在一些主流发行版中安装它的方法:
- Debian(使用APT):运行以下命令:
apt-get install strace -y
- Red Hat系列(使用DNF和YUM)
- 使用
yum
时,运行以下命令:
yum install strace
1- 使用
dnf
时,运行这个命令:
dnf install strace
1 - 使用
- Arch Linux(使用Pacman):运行以下命令:
pacman -S strace
# strace基本用法
使用strace
的基本方式是在strace
工具后加上要追踪的程序名,例如:
strace ls
这会生成一个输出,展示系统调用、它们的参数以及返回值。例如,execve
系统调用(https://man7.org/linux/man-pages/man2/execve.2.html (opens new window))可能看起来像这样:
execve("/usr/bin/ls", ["ls"], 0x7ffdee76b2a0 /* 71 vars */) = 0
# 追踪特定系统调用
如果你只想追踪特定的系统调用,可以使用-e
标志,后面跟上系统调用的名称。例如,要追踪ls
命令的execve
系统调用,运行以下命令:
strace -e execve ls
现在,我们可以使用这个新工具来追踪程序中的系统调用了。考虑以下简单的main.go
文件:
package main
import "unix"
func main() {
unix.Write(1, []byte{"Hello, World!"})
}
2
3
4
5
6
7
这个程序需要通过向标准输出(即我们的控制台)写入数据来与硬件设备进行交互。为了访问控制台并执行此操作,程序需要获得内核的许可。这个许可是通过系统调用获得的,就像请求访问特定功能一样,例如向控制台发送消息,这使得程序能够使用控制台的资源。
unix.Write
函数带有两个参数:
- 第一个参数是
1
,在类Unix系统中,它是标准输出(stdout
)的文件描述符。这意味着程序将数据写入运行该程序的控制台或终端。 - 第二个参数是
[]byte{"Hello, World!"}
,这是一个包含字符串"Hello, World!"
的字节切片。
我们将程序构建为名为app
的二进制文件:
go build -o app main.go
然后使用strace
工具运行,并过滤write
系统调用:
strace -e write./app 2>&1
你应该会看到如下输出结果:
write(1, "Hello, World!", 13Hello, World!) = 13
现在,是时候探索一个与操作系统交互的程序了。让我们创建并测试我们的第一个命令行界面(CLI)应用程序。
# 开发和测试CLI程序
CLI应用程序在软件开发、系统管理和自动化领域是必不可少的工具。在创建CLI应用程序时,与标准输入(stdin
)、标准错误(stderr
)和标准输出(stdout
)进行交互对于确保其有效性和用户友好性起着至关重要的作用。
在本节中,我们将探讨为什么这些标准流是CLI开发中不可或缺的组成部分。
# 标准流
stdin
、stderr
和stdout
的概念深深植根于Unix “万物皆文件” 的理念中(我们将在第4章 “文件和目录操作” 中进一步探讨)。这些标准化的流为CLI应用程序与用户及其他进程进行通信提供了一种一致的方式。用户期望CLI工具以特定方式工作,遵循这些约定可以提高应用程序的可预测性和用户友好性。
CLI应用程序最强大的功能之一是它们能够通过管道(详见第6章 “管道”)无缝协作。在类Unix系统中,你可以将多个CLI工具链接在一起,每个工具处理前一个工具stdout
输出的数据。这种模式允许高效处理数据并实现复杂任务的自动化。当你的应用程序与stdout
交互时,它就成为了这些管道中有价值的一环,使用户能够轻松创建复杂的工作流程。
# 输入灵活性
通过使用stdin
,你的CLI应用程序可以从各种来源接受输入。用户可以通过键盘交互式地提供输入,也可以将其他进程的数据通过管道直接输入到你的工具中。此外,你的应用程序还可以从文件中读取输入,使用户能够处理存储在不同格式和位置的数据。这种灵活性使你的应用程序能够适应广泛的使用场景。
# 输出灵活性
同样,通过使用stdout
,你的CLI应用程序可以以一种易于重定向、保存到文件或作为其他进程输入的格式提供输出。这种适应性确保了用户可以以多种方式利用你的工具输出,提高了他们工作流程的效率和通用性。
# 错误处理
stderr
专门用于输出错误消息。将错误消息与常规程序输出分开,简化了用户对错误的检测和处理。当你的应用程序遇到问题时,stderr
提供了一个专门的通道来传达错误信息。这种分离使用户更容易快速识别和解决问题。
# 跨平台兼容性
stdin
、stderr
和stdout
的优点在于它们与平台无关的特性。这些流在不同的操作系统和环境中都能一致地工作。因此,我们的CLI应用程序可以保持可移植性和兼容性,确保它们在各种系统上无需修改即可可靠运行。
# 测试和调试
遵循使用stderr
输出错误的约定,使测试和调试变得更加简单。用户可以轻松地将错误消息与程序的标准输出分开捕获和分析。这种分离有助于在开发和生产环境中定位和解决问题。
# 日志记录
许多CLI应用程序使用stderr
来记录错误消息。这种做法使用户能够监控应用程序的行为并有效地排查问题。适当的日志记录提高了应用程序的可维护性,并有助于增强其整体稳健性。
# 用户体验
一致地使用stdin
、stderr
和stdout
有助于提升用户体验。用户熟悉这些流,并期望CLI应用程序以标准方式运行。这种熟悉度降低了新用户的学习成本,并提高了用户的整体满意度。
# 符合约定
在整个软件开发和脚本编写社区中,许多最佳实践和既定约定都假定使用stdin
、stderr
和stdout
。遵循这些约定使你的CLI应用程序更容易融入现有的工作流程和实践中,为开发者和用户节省时间和精力。
# 文件描述符
你是否曾好奇,计算机是如何轻松管理所有打开的文件、网络连接和设备的呢?其实,有一个鲜为人知的秘密让一切顺利运行:文件描述符。这些看似普通的数字ID,却是计算机处理文件、目录、设备等能力背后的无名英雄。
正式来说,文件描述符是操作系统用于唯一标识和管理打开的文件、套接字、管道及其他I/O资源的抽象表示或数字标识符。它是程序引用打开资源的一种方式。
文件描述符可以表示不同类型的资源:
- 普通文件:这些是磁盘上存储数据的文件。
- 目录:磁盘上目录的表示。
- 字符设备:用于访问处理字符流的设备,如键盘和串行端口。
- 块设备:用于访问面向块的设备,如硬盘驱动器。
- 套接字:用于进程间的网络通信。
- 管道:用于进程间通信(IPC)。
当一个Shell启动一个进程时,该进程通常会继承三个打开的文件描述符。描述符0
代表标准输入,即向进程提供输入的文件。描述符1
代表标准输出,即进程写入输出的文件。描述符2
代表标准错误,即进程写入错误消息和异常情况通知的文件。在交互式Shell或程序中,这些描述符通常连接到终端。在os
包中,stdin
、stdout
和stderr
是指向标准输入、输出和错误描述符的打开文件(https://cs.opensource.google/go/go/+/refs/tags/go1.21.1:src/os/file.go;l=64)。
总之,stdin
、stderr
和stdout
是开发高效、用户友好且可互操作的CLI应用程序的重要组成部分。这些标准化的流为处理输入、输出和错误提供了一种通用、灵活且可靠的方式。通过使用这些流,我们的CLI应用程序对用户来说更易于使用且更有价值,增强了他们自动化任务、处理数据和高效实现目标的能力。
# 创建CLI应用程序
让我们遵循标准流的最佳实践,创建并测试我们的第一个CLI应用程序。
这个程序将捕获所有传入的参数(以下称为 “单词”)。如果单词长度为偶数,就将其发送到stdout
;否则,发送到stderr
:
words := os.Args[1:]
if len(words) == 0 {
fmt.Fprintln(os.Stderr, "No words provided.")
os.Exit(1)
}
2
3
4
5
第一行获取传递给程序的命令行参数,但不包括程序名本身。程序名始终是os.Args
切片中的第一个元素(os.Args[0]
),因此通过使用[1:]
切片,它获取了程序名之后的所有参数。
条件语句检查words
切片的长度是否为零,这意味着在程序名之后没有提供任何命令行参数。如果没有提供参数,它会使用fmt.Fprintln(os.Stderr, "No words provided.")
向标准错误流打印一条 “No words provided.” 的错误消息。
然后,程序以非零退出代码(os.Exit(1)
)退出。在类Unix操作系统中,退出代码0
通常表示成功,而非零退出代码表示错误。在这种情况下,程序表示由于缺少命令行参数而遇到了错误:
for _, w := range words {
if len(w)%2 == 0 {
fmt.Fprintf(os.Stdout, "word %s is even\n", w)
} else {
fmt.Fprintf(os.Stderr, "word %s is odd\n", w)
}
}
2
3
4
5
6
7
这段代码遍历words
切片中的每个单词,检查其长度是偶数还是奇数,然后将相应的消息打印到标准输出或标准错误。
main.go
文件的内容如下:
package main
import (
"fmt"
"os"
)
func main() {
words := os.Args[1:]
if len(words) == 0 {
fmt.Fprintln(os.Stderr, "No words provided.")
os.Exit(1)
}
for _, w := range words {
if len(w)%2 == 0 {
fmt.Fprintf(os.Stdout, "word %s is even\n", w)
} else {
fmt.Fprintf(os.Stderr, "word %s is odd\n", w)
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
为了查看程序的运行效果,我们需要传入参数,如下例所示:
go run main.go alex golang error
为了查看哪些单词被打印到stdout
(标准输出),哪些被打印到stderr
(标准错误),你可以在终端中使用重定向:
go run main.go word1 word2 word3 > stdout.txt 2> stderr.txt
运行上述命令后,你可以查看stdout.txt
和stderr.txt
的内容,以确定哪些单词被打印到了每个流中:
cat stdout.txt
cat stderr.txt
2
长度为偶数的单词将出现在stdout.txt
中,长度为奇数的单词将出现在stderr.txt
中。
# 重定向和标准流
还记得标准输出(stdout)的文件描述符是1,标准错误(stderr)的文件描述符是2吗?现在,它们要派上用场了。
当我们使用> stdout.txt
时,我们使用的是一个Shell重定向操作符。它将操作符左边命令的标准输出重定向到右边的文件中。由于stdout就是标准输出,所以数字1通常会省略,不过对于2>
来说可不是这样,它专门用于重定向标准错误。
注意stdout.txt 和stderr.txt 文件分别用于写入go run 命令的标准输出和标准错误。如果这两个文件中任何一个不存在,就会被创建;如果存在,则会被覆盖。 |
---|
# 使其可测试
我们不想每次对程序做一点小改动都在终端中执行程序来确保其仍然正常工作。在这方面,我们希望添加自动化测试。让我们重构代码以便编写测试。
# 分离核心逻辑
将检查单词长度并打印结果的核心逻辑移动到一个名为app
的单独函数中。这会使代码更有条理,也更易于测试:
func app(words []string) {
for _, w := range words {
if len(w)%2 == 0 {
fmt.Fprintf(os.Stdout, "word %s is even\n", w)
} else {
fmt.Fprintf(os.Stderr, "word %s is odd\n", w)
}
}
}
2
3
4
5
6
7
8
9
# 引入灵活的配置
添加一个CliConfig
结构体来保存命令行界面(CLI)的配置值。这为未来的修改提供了灵活性。目前,我们希望让标准流在测试中易于更改:
type CliConfig struct {
ErrStream, OutStream io.Writer
}
func app(words []string, cfg CliConfig) {
for _, w := range words {
if len(w)%2 == 0 {
fmt.Fprintf(cfg.OutStream, "word %s is even\n", w)
} else {
fmt.Fprintf(cfg.ErrStream, "word %s is odd\n", w)
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 函数选项
函数选项(Functional Options)是Go语言中的一种设计模式,它允许灵活且清晰地配置对象。当一个对象有许多可选配置时,这种模式特别有用。
这种模式有几个优点:
- 可读性:无需记住参数的顺序,就能清楚地知道正在设置哪些选项。
- 可扩展性:可以轻松添加新选项,而无需更改现有函数的签名或调用。
- 安全性:可以确保对象在构造后始终处于有效状态。在构造函数中可以轻松提供默认值。如果未提供某个选项,则使用默认值。
在我们的程序中,有两个可选配置:outStream
和errStream
。
我们不使用带有多个参数的构造函数或配置结构体,而是可以使用函数选项:
type Option func(*CliConfig) error
func WithErrStream(errStream io.Writer) Option {
return func(c *CliConfig) error {
c.ErrStream = errStream
return nil
}
}
func WithOutStream(outStream io.Writer) Option {
return func(c *CliConfig) error {
c.OutStream = outStream
return nil
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
现在,我们可以为CliConfig
结构体编写一个接受这些选项的构造函数:
func NewCliConfig(opts ...Option) (CliConfig, error) {
c := CliConfig{
ErrStream: os.Stderr,
OutStream: os.Stdout,
}
for _, opt := range opts {
if err := opt(&c); err != nil {
return CliConfig{}, err
}
}
return c, nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
有了上述设置,创建一个新的CliConfig
结构体就变得直观且易读:
NewCliConfig(WithOutStream(&var1),WithErrStream(&var2))
NewCliConfig(WithOutStream(&var1))
NewCliConfig(WithErrStream(&var2))
2
3
# 更新主函数
我们可以修改主函数,以使用新的CliConfig
结构体和app
函数,并处理NewCliConfig
可能返回的错误:
func main() {
words := os.Args[1:]
if len(words) == 0 {
fmt.Fprintln(os.Stderr, "No words provided.")
os.Exit(1)
}
cfg, err := NewCliConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err)
os.Exit(1)
}
app(words, cfg)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 测试
让我们看看测试函数,并研究一下我们通过它实现了什么:
package main
import (
"bytes"
"strings"
"testing"
)
func TestMainProgram(t *testing.T) {
var stdoutBuf, stderrBuf bytes.Buffer
config, err := NewCliConfig(WithOutStream(&stdoutBuf),
WithErrStream(&stderrBuf))
if err != nil {
t.Fatal("Error creating config:", err)
}
app([]string{"main", "alex", "golang", "error"}, config)
output := stdoutBuf.String()
if len(output) == 0 {
t.Fatal("Expected output, got nothing")
}
if !strings.Contains(output, "word alex is even") {
t.Fatal("Expected output does not contain 'word alex is even'")
}
if !strings.Contains(output, "word golang is even") {
t.Fatal("Expected output does not contain 'word golang is even'")
}
errors := stderrBuf.String()
if len(errors) == 0 {
t.Fatal("Expected errors, got nothing")
}
if !strings.Contains(errors, "word error is odd") {
t.Fatal("Expected errors does not contain 'word error is odd'")
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
让我们详细分析这个测试的关键部分和步骤:
TestMainProgram
函数是用于检查app
函数行为的测试函数。- 创建了两个
bytes.Buffer
变量stdoutBuf
和stderrBuf
。这些缓冲区将分别捕获程序的标准输出和标准错误流。这使得你可以在测试中捕获并检查程序的输出和错误消息。 - 调用
NewCliConfig
函数创建一个带有自定义输出和错误流的CliConfig
配置。使用WithOutStream
和WithErrStream
选项将输出流和错误流分别设置为stdoutBuf
和stderrBuf
缓冲区。这样做是为了捕获程序的输出和错误,以便在测试中进行检查。 - 使用一组单词作为输入调用
app
函数,并提供自定义的CliConfig
结构体作为配置。在这种情况下,将"main"
、"alex"
、"golang"
和"error"
作为参数传递,以模拟程序的行为。
然后,测试会检查程序输出和错误的各个方面:
- 检查
stdoutBuf
中是否捕获到任何输出。如果没有输出,则测试失败。 - 检查捕获的输出中是否包含预期的输出消息,例如
"word alex is even"
和"word golang is even"
。如果缺少任何预期的输出,则测试失败。 - 检查
stderrBuf
中是否捕获到任何错误。如果没有错误,则测试失败。 - 检查捕获的错误中是否包含预期的错误消息
"word error is odd"
。如果缺少预期的错误,则测试失败。
我们可以使用go test
命令运行测试,会显示类似如下的输出:
=== RUN TestMainProgram
--- PASS: TestMainProgram (0.00s)
PASS
2
3
总之,这个单元测试验证了在给定一组特定单词时,app
函数是否能正确生成预期的输出和错误消息。它使用bytes.Buffer
捕获程序的输出和错误,检查是否存在预期的消息,如果缺少任何预期的输出或错误消息,则报告测试失败。这个测试有助于确保app
函数在不同场景下都能按预期运行,避免了使用终端进行手动测试。
现在,我们可以将程序与其他Linux工具一起使用:
go build -o cli-app main.go
ls -l | xargs app | grep even
2
最后这条命令会列出当前目录的内容,将列表的每一行作为参数传递给app
命令,然后过滤app
命令的输出,只显示包含“even”这个词的行。
在继续学习之前,总结一下本章的关键概念会很有帮助。
# 总结
以旅行做类比,我们了解了系统调用如何像护照一样,让进程在软件执行的广阔领域中通行。我们区分了用户模式和内核模式,强调了每种模式相关的特权和限制。本章还揭示了Go语言中syscall
包面临的挑战,这导致它最终被更易于维护的x/sys
包所取代。此外,在本章中,我们成功构建了一个命令行界面应用程序,利用了Go语言的os
包和x/sys
包的强大功能。我们亲眼看到了系统调用如何集成到实际的软件解决方案中,实现与操作系统的直接交互。在后续的学习中,请记住所强调的最佳实践和所掌握的技能,确保在Go语言中进行安全、高效的系统级编程,并创建健壮的命令行工具。
在下一章中,我们将探索Go语言中文件和目录操作的相关知识,这对于任何处理文件系统的开发者来说都是至关重要的技能。我们的主要重点将是识别不安全的权限、确定目录大小以及查找重复文件。这些技术对于所有与文件系统打交道的开发者都非常重要,因为它们在维护软件应用程序中的数据完整性和保障安全性方面发挥着关键作用。