Go语言系统编程——理解系统调用

153 阅读29分钟

在本章中,您将进入系统调用的世界,这些基础接口将用户级程序与操作系统内核连接起来。通过易于理解的类比和现实世界的对比,我们将揭开软件执行的复杂过程,强调内核、用户模式和内核模式在其中的关键角色。

理解系统调用及其与操作系统的交互,对于任何旨在开发高效且可靠应用程序的软件开发人员来说至关重要。在本书的广义框架中,本章为后续关于高级操作系统交互和系统级编程的讨论奠定了基础。此外,在现实世界的应用中,掌握这些概念将为开发人员提供优化软件性能、解决系统级问题以及充分利用操作系统功能的工具。

在本章中,我们将涵盖以下主要内容:

  • 系统调用简介
  • syscall 包
  • 详细了解 osx/sys
  • 常见的系统调用
  • 开发和测试命令行接口(CLI)程序

到本章结束时,您不仅将理解系统调用的理论基础,还将通过使用 Go 构建一个 CLI 应用程序获得实践经验。

技术要求

您可以在以下网址找到本章的源代码:GitHub - Chapter 3

系统调用简介

系统调用(通常称为“syscalls”)是操作系统接口的基础。它们是操作系统内核提供的低级函数,允许用户级进程请求内核服务。

如果您是第一次接触这一概念,可以通过一些类比来帮助理解。让我们通过旅行来类比这个过程。

用户模式与内核模式

处理器(或 CPU)有两种操作模式:用户模式和内核模式(也称为监控模式或特权模式)。这两种模式决定了程序对系统资源的访问和控制权限。用户模式是受限的,不允许直接访问某些关键的系统资源;而内核模式则有更高的权限,能够访问这些资源。简单来说,内核模式拥有对系统资源的完全控制,而用户模式则受到限制。

谈到系统调用时,内核扮演着严格的边境管制员的角色。系统调用就像是我们在软件执行过程中需要用到的“护照”。可以把内核看作是一个防御严密的国际边境检查站。就像旅行者需要获得进入外国的许可一样,我们的进程需要获得批准才能访问内核的资源。系统调用则充当了“护照”的角色,使我们能够跨越用户空间和内核空间的边界。

服务目录与标识

就像旅行者的指南一样,内核通过系统调用应用程序编程接口(API)提供了一个全面的服务目录。这些服务涵盖了从创建新进程到处理输入输出(I/O)操作等各个方面,就像在外国国家中的设施和景点一样。

每个系统调用都有一个唯一的数字代码来标识,就像护照号码一样。然而,这种编号系统通常是隐蔽的,在日常使用中并不显现。我们与系统调用的互动通常是通过其名称进行的,就像旅行者通过本地名称而不是代码来识别服务和地标一样。例如,一个 open 系统调用可能在内核的系统调用表中由数字 5 来标识。然而,作为程序员,我们会通过系统调用的名称“open”来调用它,就像旅行者通过地方名称而不是 GPS 坐标来识别地方一样。

系统调用表的位置取决于操作系统和架构,但如果你对这些标识符感兴趣,可以访问以下链接查看精心制作的表格:Linux syscall table

信息交换

系统调用不是单向的事务;它们涉及的是一种精密的信息交换。每个系统调用都有其参数,这些参数决定了数据在用户空间(你进程的领域)和内核空间(内核的领域)之间传输的内容。可以把它看作一次精心协调的跨境对话,信息在两边之间无缝地流动。

当你使用 write() 系统调用将数据保存到文件时,你不仅传递数据本身,还会传递一些关于写入位置的信息(例如,文件描述符和数据缓冲区)。这种数据交换就像跨境对话一样,数据在用户空间和内核空间之间无缝地流动。

syscall 包

我们观察到一个一致的趋势:我们所需的大多数功能都可以在标准库中轻松获得,这证明了标准库的全面性和实用性。然而,这个模式中有一个显著的例外——syscall 包。

syscall 包是用于跨各种架构和操作系统与系统调用和常量接口的基础。然而,随着时间的推移,出现了一些问题,导致该包被弃用:

  • 膨胀:谁不喜欢一个不断膨胀的包呢?syscall 包几乎包含了所有你能想到(或想不到)的系统调用和常量,就像那个你承诺某天清理的塞满东西的衣橱。
  • 测试限制:该包的大部分内容缺乏明确的测试。此外,由于包的设计,跨平台测试变得不可行。
  • 管理挑战:在修改列表方面,syscall 包就像是西部片中的“荒野”——一个自由竞争的地方,几乎任何修改都会受到欢迎。它不仅是大家都想要参与的派对,也是支持众多包和系统的中心。然而,这种受欢迎的状态付出了代价。评估这些无数变化的价值变得越来越困难,结果是什么?syscall 包成为标准库中维护、测试和文档最少的包之一,几乎成了“最不被关注”的包。
  • 文档syscall 包由于其针对不同系统的独特变种,对于开发人员来说就像一个谜。虽然我们希望有更多的清晰解释,但 godoc 工具只能提供一个简短的预览,就像电影预告片,只展示了一些亮点。这种量身定制的展示方式,加上文档的总体缺失,使得理解和有效使用该包变得异常困难。
  • 兼容性?一个不断变化的目标:尽管 Go 团队竭尽全力保持 Go 1 兼容性保证,syscall 包经常给人一种无法追赶的感觉,就像追逐一只独角兽。操作系统不断演化,超出了 Go 团队的控制。例如,FreeBSD 的一些变化影响了该包的兼容性。

为了应对这些问题,Go 团队提出了以下方案:

  • 冻结 syscall:从 Go 1.3 开始,syscall 包将被冻结,这意味着它不再进行任何更新。即使操作系统发生变化,syscall 包也不会再进行修改。
  • 引入 x/sys:创建了一个新的包 x/syspkg.go.dev/golang.org/… syscall 包。这个新包更容易维护、记录和用于跨平台开发。
  • 弃用:虽然 syscall 包将继续存在并功能正常,但所有新的公共开发工作将转移到 x/sys 包。syscall 包的文档将引导用户迁移到这个新的仓库。

本质上,虽然 syscall 包在一段时间内发挥了作用,但它在维护、文档和兼容性方面的挑战促使了它的弃用,取而代之的是 x/sys 这样一个更有结构、更易维护的解决方案。

有关这一决策的更多信息,可以参考 Rob Pike 的一篇文章,解释了这一决策的背景:Freeze syscall proposal

更深入了解 osx/sys

正如我们在 Go 文档中关于 x/sys 包的描述所看到的:

x/sys 包的主要用途是在其他包中提供对系统的低级访问,这些包为系统提供了更具可移植性的接口,如 ostimenet
如果可以,请使用这些包,而不是直接使用 x/sys 包。有关该包中函数和数据类型的详细信息,请查阅相关操作系统的手册。这些调用返回 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_READunix.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!")),
)

如果决定使用低级抽象而不是 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():获取当前进程的 ID
  • os.Getppid():获取父进程的 ID
  • os.Getuid()os.Getgid():获取用户和组 ID
  • os.Geteuid()os.Getegid():获取有效的用户和组 ID
  • os.StartProcess():启动一个新进程
  • os.Exit():退出当前进程
  • os.Signal:表示信号(例如,SIGINT,SIGTERM)
  • os/signal.Notify():接收到信号时通知

os 包允许你创建和操作进程。你可以启动新进程、获取当前进程的信息并操作其属性:

package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    // 启动一个新进程
    cmd := exec.Command("ls", "-l")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
        return
    }

    // 获取当前进程 ID
    pid := os.Getpid()
    fmt.Println("当前进程 ID:", pid)
}

这个程序的主要部分如下:

  • 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 是执行系统调用的首选包,但你必须显式地选择在 Unix 和 Windows 之间进行选择。与操作系统交互的推荐方式是使用 os 包。当你将程序构建为特定的操作系统和架构时,编译器会自动处理,使用适当的系统调用版本。

例如,在 Windows 中,你需要调用具有以下签名的函数:

SetEnvironmentVariable(name *uint16, value *uint16) (err error)

对于基于 Unix 的系统,这个签名甚至没有相同的名称,如下所示:

Setenv(key, value string) error

为了避免这种“签名拼图”(signature Tetris),我们可以使用 os 包中的函数,它具有相同的语义:

Setenv(key, value string) error

(是的!签名与 Unix 版本相同。)

注意
syscall 包(pkg.go.dev/syscall)的主要用途是用于其他包中,这些包提供了更具可移植性的系统接口,例如 ostimenet
从现在开始,我们将利用 os 包,只有在特殊情况下,我们才会直接调用 x/sys 包。

最佳实践

作为一名使用 Go 中的 osx/sys 包的系统程序员,建议遵循以下最佳实践:

  • 大多数任务使用 os,因为它提供了更安全、更具可移植性的接口。
  • 仅在需要精细控制系统调用时,才使用 x/sys
  • 在使用 x/sys 包时,注意平台特定的常量和类型,以确保跨平台兼容性。
  • 处理系统调用和 os 包函数返回的错误,以保持应用程序的可靠性。
  • 在不同操作系统上测试你的系统级代码,以验证其在不同环境中的行为。

接下来,让我们探讨如何追踪我们每天在终端中执行的命令,了解背后发生了什么。

日常系统调用

在我们的程序中,许多系统调用在我们不注意的情况下不断发生。我们可以使用 strace 工具追踪这些调用。

追踪系统调用

strace 工具可能不是所有 Linux 发行版的默认安装工具,但它通常可以在大多数官方仓库中找到。以下是如何在一些主要发行版上安装 strace

  • Debian(使用 APT) :运行以下命令:

    apt-get install strace -y
    
  • Red Hat 系列(使用 DNF 和 YUM)

    • 使用 yum 时,运行以下命令:

      yum install strace
      
    • 使用 dnf 时,运行以下命令:

      dnf install strace
      
  • Arch Linux(使用 Pacman) :运行以下命令:

    pacman -S strace
    

strace 的基本用法

使用 strace 的基本方式是通过调用 strace 工具并指定程序的名称。例如:

strace ls

这将输出系统调用、它们的参数和返回值。例如,execve 系统调用(参考链接)的输出可能如下所示:

execve("/usr/bin/ls", ["ls"], 0x7ffdee76b2a0 /* 71 vars */) = 0

这表示程序执行了 execve 系统调用,用于启动 ls 命令,并且调用成功(返回值为 0)。

追踪特定的系统调用

如果你只想追踪特定的系统调用,可以使用 -e 标志,后跟系统调用的名称。例如,要追踪 ls 命令的 execve 系统调用,可以运行以下命令:

strace -e execve ls

现在,我们可以使用我们新加入工具集中的工具来追踪程序中的系统调用。考虑下面这个简单的 main.go 文件:

package main

import "unix"

func main() {
    unix.Write(1, []byte{"Hello, World!"})
}

这个程序需要与硬件设备交互,通过向标准输出(即我们的控制台)写入数据。为了访问控制台并执行此操作,程序需要从内核获得权限。这种权限是通过系统调用获取的,就像请求访问特定功能(例如,向控制台发送消息),允许程序使用控制台的资源。

在这个程序中,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

这表示程序通过 write 系统调用将 "Hello, World!" 写入标准输出,并成功完成了 13 字节的写入。

现在,是时候探索一个与操作系统交互的程序了。让我们制作并测试我们的第一个 CLI 应用。

开发和测试 CLI 程序

CLI(命令行界面)应用程序在软件开发、系统管理和自动化中是必不可少的工具。在创建 CLI 应用程序时,与标准输入(stdin)、标准错误(stderr)和标准输出(stdout)的交互在确保其有效性和用户友好性方面起着至关重要的作用。本节将探讨为什么这些标准流是 CLI 开发中不可或缺的组成部分。

标准流

stdinstderrstdout 的概念深深植根于 Unix 哲学中,即“一切皆文件”(我们将在第四章《文件与目录操作》中进一步探讨)。这些标准化的流为 CLI 应用程序提供了一种一致的方式来与用户和其他进程进行通信。用户已经习惯了 CLI 工具按特定方式工作,遵循这些约定能够增强应用程序的可预测性和用户友好性。

CLI 应用程序的一个最强大的特点是它们能够通过管道无缝协作(更多内容见第六章《管道》)。在类 Unix 系统中,你可以将多个 CLI 工具连接起来,每个工具处理前一个工具标准输出(stdout)中的数据。这种模式使得数据能够高效处理并自动化复杂任务。当你的应用程序与 stdout 进行交互时,它就成为了这些管道中的一个有价值的构建模块,能够让用户轻松创建复杂的工作流。

输入灵活性

通过利用 stdin,你的 CLI 应用程序可以接受来自不同来源的输入。用户可以通过键盘交互提供输入,或者将数据从其他进程直接管道到你的工具中。此外,你的应用程序还可以从文件中读取输入,允许用户处理存储在不同格式和位置的数据。这种灵活性使得你的应用程序能够适应多种使用场景。

输出灵活性

同样地,通过使用 stdout,你的 CLI 应用程序可以以一种易于重定向、保存到文件或作为其他进程输入的格式提供输出。这种适应性确保了用户能够以多种方式利用你的工具的输出,促进了工作流的效率和多样性。

错误处理

stderr 专门用于错误消息。将错误消息与常规程序输出分开,简化了用户的错误检测和处理。当你的应用程序遇到问题时,stderr 提供了一个专用的通道来传递错误信息。这种分离使得用户更容易及时识别和解决问题。

跨平台兼容性

stdinstderrstdout 的美妙之处在于它们的跨平台性。这些流在不同的操作系统和环境中能够一致地工作。因此,我们的 CLI 应用程序可以保持可移植性和兼容性,确保它们在各种系统上可靠地运行,无需修改。

测试和调试

通过遵循使用 stderr 输出错误的约定,你可以让测试和调试变得更加简单。用户可以轻松地捕获和分析与程序标准输出分开的错误消息。这种分离有助于在开发和生产环境中快速定位和解决问题。

日志记录

许多 CLI 应用程序使用 stderr 记录错误信息。这种做法使得用户能够有效地监控应用程序的行为并排查问题。正确的日志记录增强了应用程序的可维护性,并有助于其整体稳定性。

用户体验

一致地使用 stdinstderrstdout 有助于提供良好的用户体验。用户熟悉这些流,并期望 CLI 应用程序以标准方式运行。这种熟悉度减少了新用户的学习曲线,提升了整体用户满意度。

遵守约定

在软件开发和脚本编写社区中,许多最佳实践和既定约定假定使用 stdinstderrstdout。遵循这些约定使得你的 CLI 应用程序更容易集成到现有的工作流和实践中,从而为开发人员和用户节省时间和精力。

文件描述符

你有没有想过,计算机是如何在不出一滴汗的情况下处理所有打开的文件、网络连接和设备的?其实,背后有一个鲜为人知的秘密,它帮助一切运转得井井有条:文件描述符。这个不起眼的数字 ID,实际上是你电脑能够处理文件、目录、设备等资源的无名英雄。

从正式的术语来看,文件描述符是操作系统用来唯一标识和管理打开的文件、套接字、管道以及其他 I/O 资源的抽象表示或数字标识符。它是程序用来引用已打开资源的一种方式。

文件描述符可以代表不同类型的资源:

  • 常规文件:磁盘上包含数据的文件
  • 目录:磁盘上表示目录的对象
  • 字符设备:提供对与字符流相关的设备(如键盘和串口)的访问
  • 块设备:用于访问面向块的设备(如硬盘)
  • 套接字:用于进程间的网络通信
  • 管道:用于进程间通信(IPC)

当一个 shell 启动一个进程时,它通常会继承三个打开的文件描述符。描述符 0 代表标准输入(stdin),即提供输入给进程的文件;描述符 1 代表标准输出(stdout),即进程写入输出的文件;描述符 2 代表标准错误(stderr),即进程写入错误信息和异常通知的文件。这些描述符通常连接到终端,尤其是在交互式 shell 或程序中。在 Go 的 os 包中,stdin、stdout 和 stderr 是指向标准输入、输出和错误描述符的打开文件(参见)。

总结来说,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)
}

第一行代码从 os.Args 中获取传递给程序的命令行参数,去除程序本身的名称。程序名称总是 os.Args 切片中的第一个元素(os.Args[0]),所以使用 [1:] 切片来获取程序后的所有参数。
条件判断检查 words 切片的长度是否为零,意味着没有提供命令行参数。如果没有参数提供,它会通过 fmt.Fprintln(os.Stderr, "No words provided.") 向标准错误流打印 "No words provided." 的错误消息。
然后它通过 os.Exit(1) 退出程序,非零的退出码(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)
    }
}

这段代码遍历 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)
        }
    }
}

为了查看我们的程序运行结果,我们可以传递一些参数,如下所示:

go run main.go alex golang error

为了查看哪些单词被打印到标准输出(stdout),哪些被打印到标准错误(stderr),你可以在终端中使用重定向:

go run main.go word1 word2 word3 > stdout.txt 2> stderr.txt

运行上述命令后,你可以检查 stdout.txtstderr.txt 的内容,看看哪些单词被打印到每个流:

cat stdout.txt
cat stderr.txt

长度为偶数的单词将出现在 stdout.txt 中,而长度为奇数的单词将出现在 stderr.txt 中。

重定向和标准流

还记得 stdout 是文件描述符 1,stderr 是文件描述符 2 吗?现在它们将会结合在一起。
当我们使用 > stdout.txt 时,我们正在使用一个 shell 重定向操作符。它将命令左侧的标准输出(stdout)重定向到右侧的文件。由于 stdout 是标准输出,数字 1 通常是省略的,而 2> 则是专门用来重定向标准错误(stderr)的。

注意
stdout.txtstderr.txt 文件分别是标准输出和标准错误输出被写入的位置。如果这些文件不存在,它们会被创建;如果文件已存在,则会被覆盖。

使其可测试

我们不希望每次修改后都在终端执行程序来确保程序仍然有效。因此,我们需要添加自动化测试。让我们重构代码来写测试。

移动核心逻辑

将检查单词长度和打印结果的核心逻辑移到一个名为 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)
        }
    }
}

引入灵活的配置

添加一个 CliConfig 结构体来保存 CLI 的配置值,这为将来的修改提供了灵活性。目前,我们主要关注使标准流在测试中容易改变:

type CliConfig struct {
    ErrStream, OutStream io.Writer
}

然后将 app 函数修改为接受 CliConfig 配置:

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)
        }
    }
}

函数式选项

函数式选项 是 Go 中的一种设计模式,允许灵活和清晰地配置对象。当一个对象有很多可选配置时,尤其有用。

这种模式带来了多个好处:

  • 可读性:不需要记住参数的顺序,清晰地显示正在设置哪些选项。
  • 可扩展性:可以轻松添加新的选项,而不需要修改现有的函数签名或调用。
  • 安全性:可以确保对象在构造后始终处于有效状态。你可以在构造函数中提供默认值,如果没有提供某个选项,则使用默认值。

在我们的程序中,有两个可选配置:outStreamerrStream
可以使用函数式选项来代替使用带有多个参数的构造函数或配置结构体:

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
    }
}

现在,可以为 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
}

通过上述设置,创建新的 CliConfig 结构体变得直观且易于理解:

NewCliConfig(WithOutStream(&var1), WithErrStream(&var2))
NewCliConfig(WithOutStream(&var1))
NewCliConfig(WithErrStream(&var2))

通过使用函数式选项,我们不仅使得配置变得更加灵活,还提高了代码的可维护性和扩展性。

更新主函数

我们可以修改主函数,使用新的 CliConfig 结构体和 app 函数,并处理 NewCliConfig 可能产生的错误:

func main() {
    words := os.Args[1:]
    if len(words) == 0 {
        fmt.Fprintln(os.Stderr, "没有提供单词。")
        os.Exit(1)
    }
    cfg, err := NewCliConfig()
    if err != nil {
        fmt.Fprintf(os.Stderr, "创建配置时出错: %v\n", err)
        os.Exit(1)
    }
    app(words, cfg)
}

测试

让我们看看测试函数,并分析它的实现:

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("创建配置时出错:", err)
    }
    app([]string{"main", "alex", "golang", "error"}, config)
    output := stdoutBuf.String()
    if len(output) == 0 {
        t.Fatal("预期输出,但没有输出")
    }
    if !strings.Contains(output, "word alex is even") {
        t.Fatal("预期输出中不包含 'word alex is even'")
    }
    if !strings.Contains(output, "word golang is even") {
        t.Fatal("预期输出中不包含 'word golang is even'")
    }
    errors := stderrBuf.String()
    if len(errors) == 0 {
        t.Fatal("预期错误,但没有错误")
    }
    if !strings.Contains(errors, "word error is odd") {
        t.Fatal("预期错误中不包含 'word error is odd'")
    }
}

我们来分解一下这个测试的关键部分和步骤:

  1. TestMainProgram 函数是检查 app 函数行为的测试函数。

  2. 创建了两个 bytes.Buffer 变量,stdoutBufstderrBuf。这些缓冲区将分别捕获程序的标准输出和标准错误流。这使得我们可以在测试中捕获并检查程序的输出和错误信息。

  3. 调用 NewCliConfig 函数创建一个 CliConfig 配置,并使用自定义的输出和错误流。WithOutStreamWithErrStream 选项用来将输出流和错误流分别设置为 stdoutBufstderrBuf,这样可以捕获程序的输出和错误,并在测试中进行检查。

  4. 调用 app 函数并传入一个单词列表作为输入,同时提供自定义的 CliConfig 结构体作为配置。在这个例子中,传入了单词 "main"、"alex"、"golang" 和 "error",模拟程序的行为。

  5. 测试检查程序的输出和错误的各个方面:

    • 检查 stdoutBuf 中是否有任何输出。如果没有输出,测试失败。
    • 检查捕获的输出中是否包含预期的输出消息,如 "word alex is even" 和 "word golang is even"。如果缺少任何预期的输出,测试失败。
    • 检查 stderrBuf 中是否捕获到任何错误。如果没有错误,测试失败。
    • 检查捕获的错误中是否包含预期的错误消息 "word error is odd"。如果缺少预期的错误,测试失败。

我们可以使用 go test 命令运行测试,并看到类似的输出:

=== RUN   TestMainProgram
--- PASS: TestMainProgram (0.00s)
PASS

总结来说,这个单元测试验证了 app 函数是否在给定特定单词集时正确地生成预期的输出和错误消息。它通过 bytes.Buffer 捕获程序的输出和错误,检查是否包含预期的消息,并在缺少任何预期的输出或错误消息时报告测试失败。这个测试有助于确保 app 函数在不同场景下按预期工作,避免了使用终端进行手动测试。

现在我们可以将程序与其他 Linux 工具结合使用:

go build -o cli-app main.go
ls -l | xargs app | grep even

最后一个命令列出当前目录的内容,将该目录的每一行作为参数传递给 app 命令,然后筛选 app 命令的输出,仅显示包含 "even" 的行。

在继续之前,最好总结一下本章的关键概念。

总结

借用旅行的类比,我们已经看到系统调用像护照一样,允许进程在软件执行的广阔领域中导航。我们区分了用户模式和内核模式,强调了每种模式所具有的特权和限制。本章还阐明了 Go 中 syscall 包所面临的挑战,最终导致其被废弃,取而代之的是更易维护的 x/sys 包。此外,在本章中,我们成功地构建了一个 CLI 应用程序,利用了 Go 的 osx/sys 包的强大功能。我们亲眼见证了系统调用如何集成到实际的软件解决方案中, enabling 与操作系统的直接交互。

在接下来的章节中,我们将探索 Go 中的文件和目录操作,这是任何与文件系统打交道的开发者都必须掌握的关键技能。我们的主要重点将放在识别不安全的权限、确定目录大小和定位重复文件上。这些技术对所有与文件系统交互的开发者来说都至关重要,因为它们在维护数据完整性和保障软件应用的安全性方面发挥着重要作用。