Go-现代命令行应用构建指南-全-
Go 现代命令行应用构建指南(全)
原文:
zh.annas-archive.org/md5/adf677248f2c8789fe7e1e402740612b
译者:飞龙
前言
如果您想将您的命令行界面(CLI)应用程序开发技能提升到下一个层次,那么《使用 Go 构建 Modern CLI 应用程序》这本书就是您所需要的。本指南提供了一种全面且实用的方法,从零开始使用流行的 Go 编程语言构建 CLI 应用程序。您不仅将学习如何使用 Cobra 和 Termdash 等框架,还将发现如何整合以人为先的设计理念。本书涵盖了整个开发过程,从编译和分发您的应用程序到多个操作系统,到使用 GoReleaser 发布以及通过 Homebrew 公式扩展您的用户群。凭借清晰的解释、实用的示例和有见地的技巧,本书将使您成为一名熟练且富有创造力的 CLI 开发者,能够创建强大、直观且用户友好的应用程序,从而让您 的用户感到愉悦。
本书面向对象
本书面向希望扩展技能集并开发强大、用户友好的命令行 界面应用程序的中级 Go 开发者。
本书涵盖内容
第一章,理解 CLI 标准,命令行界面(CLI)最初是为了在图形用户界面(GUI)发明之前与操作系统交互而创建的。尽管 GUI 和网络已经变得更加普遍,但近年来 CLI 开发又重新兴起,尤其是在公司 API 之外作为一项额外服务。在本章中,您将了解 CLI 的历史和结构,UNIX 的原则以及为什么 Go 是构建 CLI 应用程序的有力语言。
第二章,为 CLI 应用程序结构化 Go 代码,本章为那些不确定如何开始创建新的 CLI 应用程序的人提供指南。它涵盖了结构化代码的流行方式,领域驱动设计的概念,并提供了一个音频元数据 CLI 应用程序的实际用例。到本章结束时,读者将具备根据他们特定的用例和需求开发应用程序的必要技能。
第三章,构建音频元数据 CLI,本章通过引导读者从头到尾构建音频元数据 CLI 的用例来提供实践学习。代码可在网上找到,可以独立探索或与章节一起探索。此外,鼓励读者发挥想象力,考虑实现命令的替代方法。
第四章, 构建 CLIs 的流行框架,本章将探讨开发现代 CLI 应用程序最流行的框架,重点关注 Cobra 及其快速生成 CLI 应用程序所需脚手架的能力。Viper 也将被讨论,它易于与 Cobra 集成,并为应用程序提供广泛的配置选项。
第五章, 定义命令行进程,本章深入探讨了命令行应用程序的结构,分解了不同类型的输入,如子命令、参数和标志,以及其他输入,如 stdin、信号和控制字符。它还提供了处理每种输入类型数据的示例以及如何以易于人类和计算机解释的方式返回结果。
第六章, 调用外部进程,处理错误和超时,本章将向您介绍如何调用外部进程并处理可能在与其他命令或 API 服务交互时发生的错误,包括超时。本章讨论了 os/exec 包,该包允许使用各种选项创建和运行命令,例如从标准输出和标准错误管道检索数据。此外,还探讨了 net/http 包,用于调用外部 API 服务端点,本章最后讨论了捕获和处理可能出现的错误的策略。
第七章, 为不同平台开发,构建命令行应用程序强大的一个因素是能够轻松创建可以在不同机器上运行的代码,无论它们的操作系统是什么。os、time、path 和 runtime 包是帮助开发者创建平台无关代码的强大工具。在本章中,我们将通过简单的示例探讨这些包中的函数和方法,并展示如何使用构建标签指定操作系统代码。到本章结束时,您将对自己的代码能够在多个平台上运行的能力更有信心。
第八章, 为人类和机器构建,考虑到最终用户来开发您的命令行应用程序是提高可用性的一个重要方面。在本章中,我们将探讨为人类和脚本构建的方法,使用 ASCII 艺术来增加信息密度,以及在不同命令和子命令之间保持一致性对于更好的导航的重要性。
第九章, 开发的同理心方面,在本章中,您将学习如何通过考虑输出和错误、提供同理心文档以及为用户提供易于获取的帮助和支持,利用同理心来开发更好的命令行界面(CLI)。通过以用户易于理解的方式重写错误,提供详细的日志记录和帮助功能,如 man 页面、使用示例和错误提交选项,开发者可以创建一个符合用户视角并为他们提供保障的同理心 CLI。
第十章, 使用提示和终端仪表板进行交互性,本章将向您展示如何通过添加提示或终端仪表板来提高您的命令行应用程序的可用性。通过提供创建调查和仪表板的示例和逐步说明,本章将帮助您构建一个更具吸引力和用户友好的界面。然而,当不是输出到终端时,重要的是要禁用交互性。
第十一章, 自定义构建和测试 CLI 命令,为了提高不断增长的 Go 项目的稳定性和可扩展性,将布尔逻辑的构建标签纳入其中以实现有针对性的构建和测试是至关重要的。本章通过一个真实世界的例子,即 audiofile CLI,展示了构建标签和测试的使用,并涵盖了诸如集成级别、启用性能分析以及测试 HTTP 客户端等主题。
第十二章, 跨平台交叉编译,本章解释了 Go 中的交叉编译,包括 Go 可以编译的不同操作系统和架构以及如何确定所需的内容。它涵盖了诸如手动编译与构建自动化工具、使用 GOOS 和 GOARCH、为 Linux、MacOS 和 Windows 编译以及为多个平台编写脚本等主题。
第十三章, 使用容器进行分发,在本章中,我们将深入了解 Docker 容器以及它们在测试和共享您的 CLI 应用程序时如何为您带来好处。我们将从基础知识开始,逐渐过渡到更复杂的话题,例如使用容器进行集成测试。此外,我们还将权衡使用 Docker 的优缺点,帮助您确定它是否是您的正确选择。到本章结束时,您将具备将应用程序容器化、通过 Docker 进行测试以及通过 Docker Hub 与他人共享的能力。
第十四章,使用 GoReleaser 将 Go 二进制文件作为 Homebrew Formula 发布,在本章中,您将学习如何使用 GoReleaser 和 GitHub Actions 自动化 Go 二进制文件的发布。GoReleaser 简化了 Go 二进制文件的创建、测试和分发,GitHub Actions 是一个 CI/CD 平台,它自动化了软件开发工作流程。通过为您的应用程序创建 Homebrew tap,您可以简化 MacOS 用户的安装过程,并扩大您的受众范围。
为了充分利用本书
为了充分利用本书,您应该具备中级水平的 Golang 知识。本书假设您熟悉 Go 的语法、数据类型、控制流和其他基本概念。它侧重于更高级的主题,如创建和测试 CLI 应用程序、使用外部库以及构建和分发二进制文件。如果您是 Go 的新手,可能会觉得材料具有挑战性,但如果有先前的经验,您将能够建立现有的知识并提升您的技能到下一个层次*。**
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
Go 1.19 | Windows, macOS, 或 Linux |
Cobra CLI | |
Docker | |
Docker Compose | |
GoReleaser CLI |
安装 Cobra CLI: github.com/spf13/cobra-cli
安装 Docker Desktop: www.docker.com/products/docker-desktop/
安装 Docker Compose 插件: docs.docker.com/compose/install/
在goreleaser.com/install/
安装 GoReleaser 工具
如果您使用的是本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还提供了来自我们丰富的图书和视频目录中的其他代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/F4Fus
。
使用的约定
本书使用了多种文本约定。
Code in text
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg
磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块设置如下:
func init() {
audioCmd.Flags().StringP("filename", "f", "", "audio
file")
uploadCmd.AddCommand(audioCmd)
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
var (
Filename = ""
)
func init() {
uploadCmd.PersistentFlags().StringVarP(&Filename,
"filename", "f", "", "file to upload")
rootCmd.AddCommand(uploadCmd)
}
任何命令行输入或输出都按照以下方式编写:
cobra-cli add upload audio [-f|--filename]
audio/beatdoctor.mp3
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
customercare@packtpub.com
并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata并填写表格。
copyright@packt.com
,并附上材料的链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《Building Modern CLI Applications in Go》,我们非常乐意听到您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但又无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容。
按照以下简单步骤获取优惠:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781804611654
-
提交您的购买证明
-
就这些!我们将直接将免费 PDF 和其他优惠发送到您的邮箱。
第一部分:从坚实基础开始
本部分涵盖了命令行界面(CLI)及其在流行度上的复兴。讨论了 CLI 的历史、结构和设计原则,重点关注 UNIX 的哲学和利用 Go 构建 CLI 的好处。指南提供了一个逐步构建新应用程序的方法,包括代码结构、领域驱动设计以及一个示例音频元数据 CLI 应用程序。通过示例音频元数据 CLI 应用程序鼓励动手学习,并探讨了如 Cobra 和 Viper 等流行框架以加快开发过程。总体而言,本部分提供了 CLI 及其在现代编程中实际应用的全面概述,为寻求构建高效且有效的 CLI 应用程序的开发者提供了宝贵的指导。
本部分包含以下章节:
-
第一章, 理解 CLI 标准
-
第二章, 为命令行应用程序结构化 Go 代码
-
第三章, 构建音频元数据 CLI
-
第四章, 构建 CLIs 的流行框架
第一章:理解 CLI 标准
命令行界面(CLI)是供人类使用的基于文本的界面,计算机交互最初是为了在桌面图形界面发明之前与操作系统(OS)交互而设计的。正如我们所知,CLI 在 20 世纪 60 年代非常流行,直到十年后图形桌面界面被开发出来。然而,尽管大多数计算机用户习惯于图形用户界面(GUI)和网页,但 CLI 开发在 2017 年左右出现了复兴。复古 CLI 的流行和新用途各不相同,但最流行的用途是作为公司 API 的附加服务,以增加平台的使用。
在本章中,你将了解 CLI 的全面历史、它今天的状况以及其结构的分解。你将了解 UNIX 的哲学以及遵循其原则将如何指导你创建一个成功的 CLI。
到本章结束时,你将更深入地了解 CLI,如何最好地设计和实施经过验证、经得起时间考验的标准,以及为什么 Go 语言,它已经成为越来越受欢迎的语言,有充分的理由成为构建 CLI 的最佳语言。
在本章中,我们将涵盖以下主要主题:
-
命令行的简要介绍和历史
-
CLI 开发的哲学
-
现代 CLI 指南
-
选择 CLI
命令行的简要介绍和历史
命令行界面(CLI)是人类与计算机交互进化的结果,特别是通信和语言处理。让我们从第一个编译器的创建开始讲起,它使我们从使用穿孔卡片过渡到编程语言。
关于历史
第一台计算机编译器是由格蕾丝·霍珀编写的。编译器能够将书面代码翻译成机器代码,从而减轻了当时程序员手动编写机器代码的巨大负担。格蕾丝·霍珀还在 1959 年发明了 COBOL 编程语言。在那个时代,穿孔卡片被用于数据处理应用或控制机械。它们会包含 COBOL、Fortran 和汇编代码。编译器和新编程语言的进步简化了编程任务。
同年,杰克·基尔比和罗伯特·诺伊斯发明了微芯片。这使得小型计算机的成本大幅降低,最终实现了人机交互,即人与计算机之间的双向互动,成为可能。计算机现在可以进行多任务处理和时间共享系统。
到这一点,键盘成为了与计算机交互的主要方法。到 20 世纪 60 年代初,工程师们将阴极射线管(CRT)显示器连接到了电传打字机(TTY)机器上。这种 CRT 和 TTY 的组合被称为“玻璃 TTY”,标志着我们认为是现代显示器时代的开始。
在 1966 年,阴极射线管和电传打字机,它们结合了电报、电话和打字机的技术,即将与最后一块缺失的拼图——计算机——合并。电传计算机接口应运而生。用户会输入一条命令,按下Enter键,然后计算机就会响应。这些被称为命令行界面!
从 1963 年发明 ASCII 字符到 1969 年的互联网,1971 年的 UNIX 和 1972 年的电子邮件,再到 1975 年的词法分析解析器在编程语言发展中的重要作用,以及 1977 年基于文本的冒险游戏为技术爱好者提供娱乐,以及 1970 年代图形用户界面(GUI)的诞生,之后还有许多令人兴奋的发展。
如果没有电话的演变,这些计算机网络的建立是不可能的。在 1964 年,声学调制解调器(modem)被用来在电话线和计算机之间传输数据。声学调制解调器为我们带来了广域网(WAN)、局域网(LAN)以及我们今天所知道的宽带。局域网聚会达到顶峰是在 1990 年代,并且一直持续到 21 世纪初。
在 1978 年,第一个公开的拨号公告板系统(BBS)由沃德·克里斯滕森和兰迪·苏斯开发,他们还创建了计算机化公告板系统(CBBS)。有了调制解调器,用户可以通过终端程序拨入运行 CBBS 软件的服务器并连接。在整个 1980 年代,BBS 的普及度达到了惊人的高度,甚至在 1995 年中期,BBS 比新兴的在线服务提供商如 CompuServe 和美国在线(AOL)服务了更大的集体市场。
注意
对命令行界面历史的深入了解可能会让你更加欣赏它的本质。终端有点像一台时光机。使用许多 UNIX 和 DOS 命令感觉就像站在巨人的肩膀上,俯瞰其下的漫长计算历史。
命令行界面介绍
基于命令行界面的历史,很明显它是一种基于文本的界面,允许用户与计算机、计算机与计算机以及计算机与用户之间的通信。它需要与它所演变而来的早期机器相同的特定指令和清晰的语言。现在,让我们更深入地了解命令行界面,了解它们的种类、一般结构以及它们的使用方式和原因。
解剖学
对于任何命令行界面,无论具体类型如何,理解命令本身的解剖结构都很重要。如果没有特定的结构,计算机就无法正确解析和理解其指令。以下是一个简单的例子,我们将用它来区分命令的不同组成部分:
~ cat -b transcript
在本例的上下文中,UNIX 命令 cat
用于查看文件 transcript
的内容。添加 -b
标志告诉命令在非空输出行旁边打印行号。我们将在以下小节中详细讨论命令的每个组成部分。
提示符
终端上的符号指示用户计算机已准备好接收命令。前面的例子显示了 ~
作为命令提示符;然而,这可能会根据操作系统而有所不同。
命令
命令有两种类型:
-
cd
、date
和time
命令。它们不需要搜索PATH
变量以找到可执行文件。 -
ls
和cat
。这些通常位于 UNIX 的/bin
或/usr/bin
中,需要搜索PATH
变量以找到可执行文件。
之前的例子使用 cat
作为外部命令。
参数和选项
命令通常接受一个或多个参数作为输入,这些参数可以是参数和/或选项:
- 参数是传递信息到命令的参数,例如,
mkdir test/
。
在前面的代码片段中,test/
是 mkdir
命令的输入参数。
- 选项是标志或开关,用于修改命令的操作,例如
mkdir -p test/files/
。
在前面的例子中,-p
是一个选项,如果需要,将创建父目录。
在本节开头示例中,-b
是一个可选标志,简写为 --number-nonblank
,它告诉命令在非空行旁边打印行号,而文件名 transcript
是传递给命令的参数。
空格
为了让操作系统或应用程序正确解析这些命令、参数和选项,每个都由空格分隔。必须特别注意参数本身可能存在空格的事实。这可能会给命令行解释器带来一些歧义。
请注意,通过替换参数内的空格来解决这个问题。在以下示例中,我们用下划线替换空格:
cat Screen_Shot_2021-06-05_at_10.23.16_PM.png
您也可以选择在参数周围放置引号,如下例所示:
cat "Screen Shot 2021-06-05 at 10.23.16 PM.png"
最后,通过在每个空格之前添加转义字符来解决这个问题,如下例所示:
cat Screen\ Shot\ 2021-06-05\ at\ 10.23.16\ PM.png
注意
虽然空格是最常用的分隔符,但它并不通用。
语法和语义
命令行界面提供了与操作系统或应用程序通信的语言。因此,像任何语言一样,为了被正确解释,它需要语法和语义。语法是由操作系统或应用程序供应商定义的语法。语义定义了可能进行的操作。
当你查看一些命令行应用程序时,你可以看到正在使用的语言。有时,不同工具的语法有所不同;我将在本章后面详细说明具体内容,例如,cat -b transcript
是我们之前看过的命令。命令 cat
是一个动词,标志 -b
是一个形容词,而 transcript
是一个名词。这是 cat
UNIX 命令的既定语法:动词、形容词、名词。
命令的语义由可能进行的操作定义。你可以通过查看 cat
命令的选项来看到这一点,这些选项通常显示在帮助页面的 使用 部分,当用户错误地使用应用程序时输出。
帮助页面
由于 CLI 完全是基于文本的,并且缺乏视觉提示,其使用可能是不明确的或未知的。例如 -help
、--help
或 -h
。-h
选项是帮助命令的缩写快捷方式。
在内置帮助和 man 页面中使用的常见语法如下,遵循此标准将使用户熟悉标准,从而轻松使用您的 CLI:
-
必需参数通常用尖括号表示,例如,
ping <hostname>
-
可选参数用方括号表示,例如,
mkdir [``option] <dirname>
-
省略号表示重复项,例如,
cp [option]... <``source>... <directory>
-
竖线表示项目之间的选择,例如,
netstat {-t | -``u}
使用方法
命令行界面(CLI)是用户与操作系统之间的第一个接口,主要用于数值计算,但随着时间的推移,其使用方式已经扩展到更多实用和有趣的方式。
让我们看看一些用法:
-
编辑器宏(Emacs),UNIX 提供的最早的文本编辑器之一,是一种以迷你缓冲区形式的 CLI。命令和参数以按键组合的形式输入:要么是 Ctrl 字符加上一个键,要么是带有 Ctrl 字符前缀的键,输出显示在另一个缓冲区中。
-
读取-评估-打印循环(REPL)是 Python 的交互式外壳,它提供了一个 CLI,根据其名称可以读取、评估、打印和循环。它允许用户在一个游戏环境中验证 Python 命令。
-
MajorMUD 和 Lunatix 是在公告板系统上可用的几款流行游戏。一旦程序员能够将 CLI 转变为游戏,他们就做到了,虽然这些游戏完全是基于文本的,但它们绝对不乏乐趣!
-
现代视频游戏将他们的 CLI 称为游戏控制台。从控制台,模开发者可以运行命令进行调试、作弊或跳过游戏的一部分。
-
辅助程序通常接受参数以以特定方式启动程序。例如,Microsoft Visual Code 有一个命令行选项:
code <filename>
. -
一些 CLI 集成到网络应用程序中,例如,基于网络的 SSH 程序。
-
例如,AWS 这样的公司提供 CLI,作为其 API 之外与平台交互的额外方式。
最后但同样重要的是,脚本允许工程师将他们的 CLI 带到更交互化的水平。在 shell 脚本语言中,程序员可以编写对 CLI 的调用脚本,并捕获和操作输出。一个命令的输出也可以作为输入传递给另一个命令。这使得 CLI 成为开发者非常强大的资源。
类型
主要有两种类型的 CLI:
-
操作系统 CLI
-
应用程序 CLI
操作系统 CLI
操作系统 CLI 通常与操作系统一起提供。这种 CLI 被称为 shell。它是位于内核之上的一层命令行解释器,解释并处理用户输入的命令,并以基于文本的方式与操作系统交互,作为图形显示的替代方案。
应用程序 CLI
第二种类型的 CLI 允许与操作系统上运行的具体应用程序进行交互。
用户可能以三种主要方式与应用程序的 CLI 交互:
-
参数:它们为以特定方式启动应用程序提供输入
-
交互式命令行会话:它们作为独立于应用程序的文本替代控制方法在应用程序之后启动
-
进程间通信:这允许用户将一个程序输出的数据流或管道传输到另一个程序
图形用户界面 (GUI) 与命令行界面 (CLI) 示例
让我们给出一个清晰的例子,看看 CLI 如何在速度上超越 GUI。假设我们有一个包含截图的文件夹。每个截图的名称都包含空格,我们希望将这些文件重命名为用下划线替换空格。
GUI
使用 GUI,重命名包含文件名中空格的整个截图文件夹需要几个手动步骤。让我们在 macOS 或 Darwin 中展示这些步骤:
- 首先,我们需要打开包含所有截图的文件夹:
图 1.1 – 包含每个文件名包含多个空格的截图的文件夹
- 第二,我们会按下控制按钮并左键点击一个文件名,然后从弹出的上下文菜单中选择 重命名 选项。
图 1.2 – 在上下文菜单中,点击重命名选项
- 最后,手动将每个空格替换为下划线。
图 1.3 – 在文件名中用下划线替换了空格
对文件夹中的每个文件重复步骤 1-3。我们很幸运这个文件夹只包含四个截图。如果文件夹中有更多文件,这个过程会很快变得重复且令人疲倦。
CLI
让我们看看 CLI 可以有多快。让我们打开终端并导航到包含文件的文件夹:
cd ~/Desktop/screenshots/
让我们通过输入 ls
命令来查看文件夹中当前存在的内容:
mmontagnino@Marians-MacBook-Pro screenshots % ls
Screen Shot 2022-12-20 at 10.27.55 PM.png
Screen Shot 2022-12-20 at 10.32.48 PM.png
Screen Shot 2022-12-26 at 5.24.48 PM.png
Screen Shot 2022-12-27 at 12.08.12 AM.png
让我们运行一个精心设计的命令,该命令会遍历当前目录中的每个文件,并使用mv
将其重命名(将空格转换为下划线):
for file in *; do mv "$file" `echo $file | tr ' ' '_'` ; done
让我们再次运行ls
命令,看看有什么变化:
mmontagnino@Marians-MacBook-Pro screenshots % ls
Screen_Shot_2022-12-20_at_10.27.55_PM.png
Screen_Shot_2022-12-20_at_10.32.48_PM.png
Screen_Shot_2022-12-26_at_5.24.48_PM.png
Screen_Shot_2022-12-27_at_12.08.12_AM.png
哇!我们刚刚运行了一个命令,文件就自动重命名了!这只是一个例子,展示了命令行界面(CLIs)的力量以及与 GUI 相比任务执行速度有多快。
复兴
近年来,命令行界面(CLI)的复兴归功于这些许多好处。图形用户界面(GUI)可能资源密集,执行重复性任务时可能繁琐,有时还可能缓慢。
在另一端,命令行界面(CLI)轻量级、可脚本化且快速。优势远不止于此。CLI 甚至可能提供 GUI 中不可用或难以想象的命令和参数。有很多值得钦佩的地方,它也有一点神秘。
我在这里对 CLI 有点小迷恋!开个玩笑,为了公平起见,对于吸引人的 GUI,它有视觉提示,使用户能够自我引导。而过于神秘的 CLI,另一方面,则需要帮助文档和 man 页面来理解其可用的参数和选项。
虽然看起来可能难以理解,但一旦理解,命令行界面的力量就会变得明显并鼓舞人心。
命令行界面(CLI)开发的哲学
哲学在计算机科学的发展中起着重要作用。历史上,许多计算机科学家通过哲学对计算机科学做出了重大贡献,部分原因是因为许多计算机科学家同时也是哲学家。每个操作系统都有自己的独特哲学,这并不奇怪。
例如,Windows将大部分智能硬编码在程序或操作系统中,假设用户的无知并限制了他们的灵活性。尽管理解门槛较低,但用户与程序交互时并不了解它是如何工作的。
UNIX的开发者持有相反的哲学:为用户提供几乎无限的选项来赋予他们权力。尽管学习曲线陡峭,但在一个不屏蔽用户对自由复杂性的环境中,可以开发出更多内容。
关于 UNIX 的哲学和将其应用于现实生活的书籍已经有很多了。因此,我相信许多人将编码视为一种手艺。尽管还有许多其他哲学需要审查,但本节的重点将放在 UNIX 的哲学上。
Go 编程语言的传奇设计师,肯·汤普森(Ken Thompson)、罗伯特·格里泽默(Robert Griesemer)和罗布·派克(Rob Pike),与 UNIX 有着悠久的历史,因此讨论其哲学时,将其置于其创造者的背景下是合适的,因为 Go 就是围绕它构建的。
UNIX 的哲学主张简单和模块化的设计,这些设计既可扩展又可组合。其基础是众多小型程序之间的关系比程序本身更强大。例如,许多 UNIX 程序独立处理简单任务,但当它们结合在一起时,这些简单的工具可以以非常强大的方式进行编排。
成功 CLI 的清单
以下是一些受 UNIX 哲学启发的原则,遵循这些原则将有助于创建一个成功的 CLI:
- 构建模块化程序
设计您的 CLI 时,要考虑标准化,以确保它可以轻松与其他应用程序组合。具体来说,利用标准输入和输出、标准错误、信号和退出代码有助于构建既模块化又易于组合的程序。组合性可以通过管道和 shell 脚本简单地处理,但也有一些编程语言可以帮助将程序拼接在一起。持续集成/持续交付(CI/CD)、编排和配置管理工具通常建立在命令行执行和脚本之上,以自动化代码集成或部署或配置机器。考虑您程序的数据输出以及其组合的容易程度。当需要结构时,最佳选项是纯文本或 JSON。
- 以人为本 构建
最初的 CLI 命令是假设它们只会被其他程序使用而编写的。现在情况不再是这样了,因此程序应该首先以人为设计。
对话将成为人机交互的主要方法。想象一下人类对话的自然流程以及这一概念如何应用于帮助误解程序设计的用户。在自然语言中,您的程序可以建议可能的纠正,多步骤过程中的当前状态,并在继续进行有风险的操作之前请求确认。在最佳情况下,您的用户在使用您的 CLI 时感到愉快,感觉有力量去发现操作,并在需要时获得帮助。在最坏的情况下,您的用户感到被忽视和沮丧,看不到任何帮助。不要成为那样的 CLI!
最后,编写可读的代码,以便其他开发人员可以轻松地在将来维护您的程序。
- 将接口与引擎和政策 从机制中分离
通过解耦这些,不同的应用程序可以通过接口使用相同的引擎,或者使用不同的策略使用相同的机制。
- 保持简单
只有在必要时才添加复杂性。当复杂性发生时,将其折叠到数据中而不是逻辑中。在不损害可用性的情况下,使用现有模式。
- 保持小巧
除非没有其他方法,否则不要编写大型程序。
- 保持透明
尽可能地保持透明,让用户能够理解如何使用程序以及正在发生什么。透明的程序具有全面的帮助文本,并提供大量示例,使用户能够轻松发现他们需要的参数和选项,并对其执行有信心。GUI 在透明度和可见性方面确实有优势;然而,我们可以从中学习,看看可以吸收哪些内容来使 CLI 更容易学习和使用。用户求助于 Google 或 Stack Overflow 在这里是一种反模式。
- 保持稳健
稳健性是前述原则的结果:透明性和简单性。程序应以用户期望的方式运行,当出现错误时,应清楚地解释正在发生的情况,并提供解决方案的建议。立即打印堆栈跟踪或不对用户进行清晰且及时的响应,会让用户感觉不稳定。
- 没有惊喜
通过建立在使用者现有知识的基础上来保持程序直观。例如,逻辑运算符如+
应始终表示加法,而-
应始终表示减法。通过保持与现有知识和行为模式的一致性,使程序直观。
- 简洁
不要无谓地打印输出,也不要完全沉默,让用户猜测发生了什么。在沟通中需要找到平衡,准确地说出需要说的内容;不多也不少。太多会导致一大块冗长的文本,迫使用户从中解析出有用的信息。太少则是指命令提示符在沉默中挂起,让用户猜测程序的状态。
- 明显地失败
修复可以修复的部分,当程序失败时,应尽快且明显地报错。这将防止错误输出破坏依赖于它的其他程序。
- 节省你的时间
编写代码以节省开发者的时间,而不是机器的时间,因为现在机器时间相对便宜。此外,编写生成程序的程序。对于计算机来说,生成代码比手动编写更快且错误更少。
- 先构建原型,然后优化
有时,程序员在早期为了微小的收益花费太多时间进行优化。首先,让它工作,然后再进行润色。
- 构建灵活的程序
程序可能以开发者未预料到的方式被使用。因此,使设计灵活和开放将允许程序以未预料到的方式被使用。
- 设计可扩展性
通过允许协议可扩展来延长你程序的使用寿命。
- 成为一个好的 CLI 公民
将同理心融入设计,并与 CLI 生态系统和平共处。
这种哲学直接影响了创建 CLI 的指南。在下一节中,你将清楚地看到与所讨论的哲学原则之间的联系,并且遵循这些指南将增加创建成功 CLI 的可能性。
指南
这些指南自第一个 CLI 以来就已经制定,并且随着多年的开发者和用户体验而不断演变。遵循这些指南将增加 CLI 成功的可能性;然而,有时你可能决定走自己的路,遵循反模式。选择非传统路线可能有多种原因。记住,这些只是指南,并没有硬性规定。
对于生活,以及构建命令行界面(CLI),要使其变得有趣,我们必须允许一点混乱和必要的自由来发挥创造力。
名称
CLI 的名称具有重大意义,因为名称可能传达超出最初意图的象征性思想。人们不喜欢无谓地思考,所以最好选择一个简单、易于记忆且易于发音的名字。令人惊讶的是,许多 CLI 程序名称被随意选择,几乎没有经过深思熟虑。
有研究表明支持语言海森堡原理:给一个概念贴上标签会改变人们对它的感知。
因此,让它简短且易于输入。在名称中使用完全小写的变量,并且只有在绝对必要时才使用破折号。
我最喜欢的应用程序名称之一是它们以清晰的方式直接描述了应用程序的目的。例如,imagemagick
,这是一个命令行应用程序,让你可以神奇地读取、处理或创建图像!确实,正如亚瑟·C·克拉克写道,“任何足够先进的技术都与魔法无法区分。”我们熟悉的内部命令还包括mkdir
(创建目录)、rm
(删除)和mv
(移动)。它们的流行部分是由于它们名称的透明性,使得它们几乎难以忘记。
帮助和文档
UNIX 哲学的一个原则是透明性,这主要可以通过 CLI 中的帮助和文档来实现。对于处于探索模式的 CLI 新用户,帮助和文档是他们最早访问的部分之一。有一些指南可以使帮助和文档更容易为用户访问。
帮助
当只输入命令名称或使用-h
或–help
标志时,默认显示帮助是一个好习惯。当你显示帮助文本时,确保它格式化良好且简洁,最常用的参数和标志选项放在最上面。提供使用示例,如果用户误用 CLI,程序可以猜测用户试图尝试的操作,并提供建议和下一步操作。
文档
提供手册页面或基于终端或基于网页的文档,这些文档可以提供 CLI 使用示例。这些类型的文档可以作为了解 CLI 工作原理的资源扩展,从帮助页面链接。
支持功能
通常,用户会对如何使用 CLI 提出建议或问题。提供反馈和问题的支持路径将使用户能够为 CLI 设计师提供他们 CLI 使用的新视角。在收集分析数据时,要透明,不要未经用户同意收集用户的地址、电话或使用数据。
输入
CLI 获取输入的方式有多种,但主要通过参数、标志和子命令。普遍倾向于使用标志而不是参数,并使默认选项对大多数用户来说都是正确的。
标志
标志的指南是理想情况下,所有标志都应该有一个完整版本。例如,-h
对应 --help
。仅使用单个连字符 –
或常用标志的缩写符号,并在存在标准名称时使用标准名称。
以下是一些已存在的标准标志列表:
标志 | 用法 |
---|---|
-a , --all |
所有 |
-d , –debug |
调试 |
-f , --force |
强制 |
--json |
显示 JSON 输出 |
-h , --help |
帮助 |
--no-input |
禁用提示和交互 |
-o , --output |
输出文件 |
-p , --port |
端口 |
-q , --quiet |
静默模式 |
-u , --user |
用户 |
--version |
版本 |
-v |
版本或详细模式 |
-d |
详细模式 |
表 1.1:标准标志
参数
对于在多个文件上执行简单操作的情况,多个参数是可以的。例如,rm
命令针对多个文件运行。尽管如此,如果存在两个或更多用于不同事物的参数,可能需要重新考虑命令的结构,并选择标志选项而不是额外的参数。
子命令
子命令的指南是它们应保持一致且无歧义。保持子命令的结构一致;无论是名词-动词还是动词-名词顺序都适用,但名词-动词顺序更为常见。有时,程序会提供模糊的子命令,例如 apt update
与 apt upgrade
,这会导致包括我自己在内的许多人感到困惑。尽量避免这种情况!
在早期验证用户的输入,如果无效,则在发生任何严重问题之前尽早失败。在本书的后续部分,我们将指导您使用 Cobra,这是一个流行的、高度推荐的 Go 语言命令行解析器,用于验证用户输入。
输出
因为 CLI 是为人类和机器构建的,所以我们需要考虑输出必须易于两者消费。我将为人类和机器的 stdout
和 stderr
流提供指南。标准输出 stdout
是进程可以写入输出的默认文件描述符,而标准错误 stderr
是进程可以写入错误信息的默认文件描述符:
- stdout
对于人类的标准输出指南是使响应清晰、简短且易于理解。利用 ASCII 艺术、符号、表情符号和颜色来提高信息密度。最后,考虑简单的机器可读输出,以确保不会影响可用性。
对于机器的标准输出指南是从上述响应中提取任何无关物质,使其成为简单的机器可读文本,以便将其管道传输到另一个命令。当默认情况下不输出简单的机器可读文本时,使用-q
标志来抑制非必要输出,并使用--plain
来显示机器可读文本。通过设置NO_COLOR
环境变量或针对您程序的特定MYAPP_NO_COLOR
环境变量来禁用颜色。
此外,不要在stdout
中使用动画,因为它不是一个交互式终端。
- stderr
在命令执行过程中可能会出错,但不必感觉像是一场灾难性事件。有时,响亮的完整堆栈跟踪是对命令失败的响应,这可能会让人心跳加速。捕获错误,并以重写的错误消息优雅地响应用户,这些错误消息可以提供对发生情况以及下一步建议的清晰理解。确保没有无关或嘈杂的输出,考虑到我们希望它易于理解错误。此外,向用户提供额外的调试和跟踪信息,以及提交错误报告的选项。非错误消息不应发送到stderr
,调试和警告消息应发送到stdout
。
注意
关于 CLI 输出的通用指南,成功时返回零退出代码,失败时返回非零代码,机器可以将其解释为不仅仅是失败,甚至可以采取进一步行动的特定类型的失败。
配置
用户可以通过使用标志、环境变量和文件来配置他们的 CLI,以确定如何具体调用命令并在不同的用户和环境之间稳定它:
- 标志和 环境变量
通过使用标志或环境变量,用户可以配置如何运行命令。
以下是一些示例:
-
指定调试输出级别
-
干运行命令
或者,它们可以用来在不同的环境中进行配置。
以下是一些示例:
-
提供程序执行所需的非默认文件路径
-
指定输出类型(文本或 JSON)
-
指定 HTTP 代理服务器以路由请求
在配置中使用环境变量时,适当地设置名称,使用全部大写文本、数字和下划线的组合,并花时间确保您没有使用已存在的环境变量的名称。
- XDG 规范
通过遵循 XDG 规范(X Desktop Group,freesdesktop.org)来配置跨多个环境的稳定性,该规范指定了配置文件可能存在的基目录的位置。这个规范得到了许多流行工具的支持,例如 Yarn、Emacs 和 tmux 等。
安全性
不要将机密信息和密码存储在环境变量中,也不要通过参数或标志传递它们。相反,将它们存储在文件中,并使用 --password-file
参数允许秘密信息被离散地传递。
开源社区
一旦你的 CLI 完成,准备分发,有一些指南需要遵循。如果可能,将程序作为针对用户特定平台和架构的单个二进制文件进行分发。如果用户不再需要或想要你的程序,确保它也易于卸载!
由于你将在 Go 中编写 CLI,因此鼓励对程序做出贡献将是非常好的。你可以提供一个贡献指南文档,指导用户了解提交语法、代码质量、必需的测试和其他标准。你也可以选择允许用户通过编写与你的 CLI 兼容的插件来扩展 CLI,将功能分解成更模块化的组件,并提高可组合性。
软件寿命和鲁棒性
为了确保你的 CLI 将来能够良好地工作,有一些特定的关于鲁棒性的指南需要遵循,以确保你的程序有一个长的寿命:
- 未来兼容性
当你对 CLI 进行任何更改时,随着时间的推移,最好是进行增量更改,但如果不是这样,应提醒用户更改。更改人类可读的输出通常是可以接受的,但最好保持机器可读输出的稳定性。考虑外部依赖可能缩短你的程序寿命的情况,并思考在外部依赖失败的情况下使你的应用程序保持稳定的方法。
- 鲁棒性
为了使命令行界面(CLI)达到最大的鲁棒性,CLI 必须以完全透明的方式进行设计。程序需要感觉对用户负责;因此,如果某项操作耗时较长,应显示进度,并确保程序不会挂起。当程序运行时间过长时,应设置超时。当用户输入命令时,应立即验证其使用情况,并在出现明显误用时提供清晰的反馈。当由于某些暂时性原因出现故障时,应立即在失败或中断时退出程序。当程序再次被调用时,它应立即从上次停止的地方继续执行。
- 同理心
在命令行设计中加入一些深思熟虑的细节将为用户提供愉快的体验。CLI 应该是易于使用的!即使事情出错,有了支持性的设计,用户也可以在成功使用 CLI 的道路上感到鼓舞。现代 CLI 哲学和指南已经体现了对人类的同理心,而且,感谢上帝,我们已经从最初的命令行工具走了很长的路,并将继续做得更好。
Go for CLIs
我建议工程师学习 Go 的所有主要原因,也是我建议使用 Go 来构建你的 CLI 的原因,但现代 CLIs 的历史,始于 20 世纪 60 年代的 UNIX shell,由贝尔实验室的 Ken Thompson 发明,Golang 的共同发明者,具有很大的分量。UNIX 哲学,它启发了我们的现代 CLI 哲学和指南,已经融入了语言中,并且很明显,许多好处都源于这种思维方式:
- 性能、可扩展性和力量
Golang 在编译和执行方面相当快。例如,用 Go 编写的 Kubernetes,有 500 万行应用程序代码,可以在几分钟内编译完成。任何其他语言都需要 10 分钟到几个小时才能编译。Go 在其优化的编译器中将源代码转换为机器代码,这使得依赖关系管理更加容易。此外,由于 Golang 仍然是一个年轻的语言,它是为现代硬件需求而构建的。
Goroutines 是轻量级的线程,可以并发运行。在我的编程经验中,我没有看到简单性和多线程能够紧密结合,但 Golang 极好地实现了这一点。这个特性赢得了我的心。
Go 的性能和可扩展性对云计算社区来说是一个明显的吸引力。许多云计算 CLI 都是用 Go 编写的,包括 Docker 和 Kubernetes。任何拥有增长的用户基础或大量流量请求的应用程序都需要考虑 Golang。像 Uber 和 Comcast 这样的公司也因为这个原因选择了 Go。
- 开发简化
与我遇到过的任何其他语言相比,Golang 感觉更容易学习。这种语言支持一个干净、简单、快速的环境,这在考虑到 Go 创建的强大工具时是非常令人印象深刻的。Golang 还拥有许多工具,允许开发的速度和准确性,包括格式化工具、测试框架、出色的代码检查器和执行静态分析的工具。
- 多功能性
Go 使得交叉编译变得非常简单。你可以为许多不同的操作系统和架构构建你的应用程序,从而增加你的 CLI 的可访问性。尽管在不同的环境中执行,但如果配置得当,它将工作得非常相似。这将减轻用户的心理负担。在本书的后续部分,我们将讨论如何为不同的平台创建二进制文件。
- 提升技能
Golang 是学习最受欢迎的语言之一。鉴于其许多明显的优势,越来越多的初创公司和企业选择 Golang,对 Golang 开发者的需求也在增长。
- 社区
如果你选择 Go,你将成为一个充满热情的开源开发者社区的一员,愿意参与一个年轻编程语言的进化之旅。
对于第一次在 Go 中构建 CLI 的初学者来说,接下来的章节将展示其清晰度。Golang 是构建 CLI 的绝佳选择,当需要可扩展性、性能和交叉编译时,这个选择将对你有利。
摘要
在本章中,你了解了导致 CLI 创建的科学发现和发明,以及今天终端中仍然存在的过去遗留下来的痕迹。
除了将 CLI 详细分解为其各个部分之外,本章还讨论了 CLI 的整体概念及其用途。UNIX 和 Golang 的共同创造者 Ken Thompson 影响了 CLI 和编程的一般哲学。这种哲学受到了几十年来人与计算机交互的影响。与任何历史悠久的事物一样,一些祖先的负担也随之而来。我们了解到,过去 CLI 主要是为计算机编写的,而今天主要是为人类编写的。UNIX 哲学中必须添加一个新的元素:同情作为一种文化规范。
本章深入探讨了最终体现 UNIX 哲学的指南,以及为什么 Golang 是实施这种设计的最佳语言。
在第二章《为 CLI 应用程序结构化 Go 代码》中,我们将讨论项目创建的第一步:文件夹结构。
问题
-
Linux 机器上的 TTY 是什么,这个命令背后的历史是什么?
-
你最认同 UNIX 哲学的哪些核心元素?为什么?
-
Golang 的共同创造者是谁,他们与 UNIX 操作系统有什么关系?
-
你今天还能访问 BBS 并玩一些过去基于文本的游戏吗?
-
哪些 CLI 指南对你来说感觉像是第二本能,哪些指南需要额外的努力?
答案
-
TTY 是 UNIX 和 Linux 中的一个命令,用于显示连接到标准输入的终端名称。TTY 一词来源于电传打字机,这是与大型迷你计算机和主计算机交互的默认形式。
-
这个答案具有主观性。然而,我喜欢先构建原型再进行优化的元素。我更喜欢构建一个简单的概念验证,它可以作为灵感的来源。优化和精炼总是可以稍后进行。
-
Golang 是由 Robert Griesemer、Rob Pike 和 Ken Thompson 共同创建的。Ken Thompson 还创建了 UNIX 操作系统,而 Rob Pike 曾是 UNIX 团队的一员。
-
你仍然可以通过下载 telnet 客户端,例如 CGTerm,连接到今天仍在运行的 1000 多个不同的 BBS。查看列表请访问
www.telnetbbsguide.com/
。 -
这个答案具有主观性。然而,我发现投入精力构建建设性的帮助文本是自然而然的。相反,我认为编写完整且最新的文档需要额外的努力。
进一步阅读
-
PhiloComp.net (
philocomp.net/
) 是一个既面向计算机科学家也面向哲学家教育的网站,旨在学习学科之间的联系,拓展他们对彼此以及自身的视野 -
命令行界面指南 (
clig.dev
) 是一个优秀的资源,提供了大量遵循 UNIX 哲学创建 CLI 应用程序的示例
第二章:为 CLI 应用程序结构化 Go 代码
编程就像任何其他创造性过程一样。它从一张白纸开始。不幸的是,当面对一张白纸并且对从头开始编程应用的经验有限时,可能会产生怀疑——如果您不知道如何开始,可能会觉得这根本不可能。
本章是关于创建新应用程序的第一步指南,从一些最流行的代码结构方式开始,描述每种方式,权衡它们的优缺点。讨论了领域驱动设计,因为这也可以影响应用程序的最终结构。
一个音频元数据 CLI 应用程序的例子让我们了解一些现实世界的用例或需求可能看起来是什么样子。学习如何定义应用程序的用例和需求是确保项目成功并且满足所有相关方需求的繁琐但必要的步骤。
到本章结束时,您将学会构建基于您特定用例和需求的应用程序所需的所有技能。
本章将涵盖以下主题:
-
适用于健壮应用的常用程序布局
-
确定用例和需求
-
结构化音频元数据 CLI 应用程序
技术要求
您可以在 GitHub 上找到程序布局示例:github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter02/Chapter-2
。
适用于健壮应用的常用程序布局
在您的编程旅程中,您可能会遇到许多不同的应用程序结构。对于 Go 来说,没有标准的编程布局。然而,鉴于所有这些自由,结构的选择必须谨慎做出,因为它将决定我们是否理解并且知道如何维护我们的应用程序。理想的应用程序结构也将是简单、易于测试,并且直接反映业务设计和代码的工作方式。
在选择 Go 应用程序的结构时,使用您的最佳判断。不要随意选择。听取上下文中的建议,并学会为您的选择辩护。没有理由过早地选择结构,因为随着时间的推移,您的代码会演变,某些结构更适合小型应用程序,而其他结构更适合中型到大型应用程序。
程序布局
让我们深入了解到目前为止为 Go 语言开发的常见和新兴的结构模式。理解每个选项将帮助您为您的下一个应用程序选择最佳的设计结构。
平面结构
这是开始时最简单的结构,也是当你开始一个应用程序、只有少量文件并且仍在了解需求时最常见的情况。将平面结构演变为模块化结构要容易得多,因此最好在开始时保持简单,并在项目增长时再进行分区。
让我们来看看这种结构的优点和缺点:
-
优点:
-
对于小型应用程序和库来说非常好
-
没有循环依赖
-
它更容易重构为模块化结构
-
-
缺点:
-
随着项目的增长,这可能会变得复杂和无序
-
任何东西都可以被其他任何东西访问和修改
-
-
示例
-
如其名称所示,所有文件都位于根目录中,呈平面结构。没有层次结构或组织,当文件数量较少时,这种结构效果很好:
图 2.1 – 平面代码结构的示例
随着你的项目增长,有几种不同的方式可以对你的代码进行分组以保持其组织性,每种方式都有其优点和缺点。
按功能分组代码
代码根据其相似功能进行分离。例如,在Go REST API项目中,Go 文件通常根据处理器和模型进行分组。
让我们来看看这种结构的优点和缺点:
-
优点:
-
容易将代码重构为其他模块化结构
-
容易组织
-
它阻止了全局状态
-
-
缺点:
-
共享变量或功能可能没有明确的位置可以存放
-
初始化发生的位置可能不清楚
-
为了减轻可能出现的任何混淆,最好遵循 Go 的最佳实践。如果你选择从项目根目录的main.go
文件初始化应用程序。这种结构,正如其名称所暗示的,是根据功能来分离代码的。以下图示是按功能分组和可能落入这些不同类别的代码类型的示例:
图 2.2 – 按功能分组的示例
- 示例:以下是一个遵循按功能分组结构的文件夹组织示例。类似于示例分组,与处理器相关的文件夹包含每种类型处理器的代码,与提取器相关的文件夹包含每种特定提取类型的代码,存储也按类型组织:
图 2.3 – 按功能分组结构的示例
按模块分组
不幸的是,这种架构风格的标题有点冗余。为了澄清,按模块分组意味着创建单独的包,每个包都服务于一个功能,并包含完成这些功能所需的所有内容:
-
优点:
-
它更容易维护
-
开发速度更快
-
存在低耦合和高内聚
-
-
缺点:
-
它复杂且难以理解
-
它必须有严格的规则才能保持良好的组织
-
它可能会导致包方法名称的重复
-
可能不清楚如何组织聚合功能
-
可能会出现循环依赖
-
以下是如何按模块分组包的视觉表示。在以下示例中,代码根据提取器接口的实现进行分组:
图 2.4 – 模块分组的视觉表示
-
示例
-
以下是一个示例,说明如何在特定的模块文件夹中组织代码。在以下示例中,提取、存储和定义类型、标签、转录和其他元数据的代码存储在单个定义的文件夹中:
图 2.5 – 基于模块结构的示例
基于上下文分组
这种结构通常由项目开发的领域或特定主题驱动。在开发者和领域专家之间通信中使用的通用领域语言通常被称为通用语言。它有助于开发者理解业务,并帮助领域专家理解变化的技術影响。
六边形架构,也称为端口和适配器,是一种流行的领域驱动设计架构,在概念上将应用程序的功能区域划分为多个层次。这些层次之间的边界是接口,也称为端口,它们定义了它们如何相互通信,适配器存在于层次之间。在这个分层架构中,外层只能与内层通信,反之则不行:
-
优点:
-
业务团队成员和开发者之间的沟通增加
-
当业务需求变化时,它具有灵活性
-
它易于维护
-
-
缺点:
-
它需要领域专业知识,并且开发者在实施之前必须首先理解业务
-
由于它需要更长的初始开发时间,因此成本较高
-
它不适合短期项目
-
以下提供了一个典型的六边形结构的视觉表示。箭头指向内部实体,以区分外层可以访问内层,但反之则不行:
图 2.6 – 六边形架构的视觉表示
- 示例:以下是一个按上下文组织的文件夹结构。具有单个业务功能的服务被分别放入各自的文件夹中:
图 2.7 – 基于上下文结构的示例
这就总结了 Go 应用程序的不同类型的组织结构。对于你的应用程序来说,不一定有一个正确或错误的文件夹结构可以使用;然而,业务结构、项目大小以及你的总体偏好可能会在最终决策中发挥作用。这是一个重要的决定,所以在继续之前要仔细思考!
常见文件夹
无论选择哪种结构,现有的 Go 项目中通常都有一些命名文件夹。遵循这种模式将有助于维护者和未来的开发者更好地理解应用程序:
-
cmd
文件夹是应用程序的主要入口点。目录名称与应用程序名称匹配。 -
pkg
文件夹包含可能被外部应用程序使用的代码。尽管关于这个文件夹的有用性存在争议,但pkg
是明确的,明确性使得理解变得清晰。我之所以支持只保留这个文件夹,是因为它的清晰性。 -
internal
文件夹包含私有代码和库,这些代码和库不能被外部应用程序访问。 -
vendor
文件夹包含应用程序的依赖项。它是由go mod vendor
命令创建的。除非你正在创建一个库,否则通常不会将其提交到代码仓库;然而,有些人觉得有一个备份会更安全。 -
api
文件夹通常包含应用程序的 REST API 代码。它也是 Swagger 规范、模式定义文件和协议定义文件的地方。 -
web
文件夹包含特定的网络资源和应用程序组件。 -
configs
文件夹包含配置文件,包括任何confd
或consul-template
文件。 -
init
文件夹包含任何系统初始化(启动)和进程管理(停止/启动)脚本以及监督配置。 -
scripts
文件夹包含执行各种构建、安装、分析和操作的脚本。将这些脚本分离出来将有助于保持 makefile 小而整洁。 -
build
文件夹包含用于打包和持续集成的文件。任何云、容器或打包配置和脚本通常存储在/build/package
文件夹下,而持续集成文件存储在build/ci
下。 -
deployments
文件夹存储与系统和容器编排相关的配置和模板文件。 -
test
文件夹或把测试文件直接放在代码文件旁边。这是一个个人偏好的问题。
注意
无论你的项目结构中包含哪些文件夹,使用清楚地表明包含内容的文件夹名称。这将帮助当前和未来的项目维护者和开发者找到他们需要的东西。有时,确定包的最佳名称可能会有困难。避免过度使用的术语,如 util、common 或 script。将包名格式化为全部小写,不要使用 snake_case
或 camelCase
,并考虑包的功能责任,找到一个反映它的名称。
所述的所有上述常见文件夹和结构模式都适用于构建 CLI 应用程序。根据 CLI 是否是现有应用程序的新功能,你可能正在继承现有的结构。如果存在一个现有的cmd
文件夹,那么最好在该文件夹下定义一个 CLI 的入口,并使用一个标识 CLI 应用程序的文件夹名称。如果是一个新的 CLI 应用程序,从扁平结构开始,并从那里发展成模块化结构。从上述示例中,你可以看到扁平结构如何随着应用程序扩展到提供更多功能而自然增长。
确定用例和需求
在构建 CLI 应用程序之前,你需要对应用程序的目的和职责有一个概念。应用程序的目的可以定义为一种总体描述,但为了开始实施,有必要将目的分解为用例和需求。用例和需求的目标是围绕应用程序应该做什么进行有效讨论,结果是每个人都对将要构建的内容有一个共同的理解,并且随着应用程序的发展继续这些讨论。
用例
用例是记录项目功能性需求的一种方式。对于 CLI 来说,这一步通常是在从内部或外部业务客户那里收集了一些高级需求后由工程师处理的。在技术实施之前,对应用程序的目的有一个清晰的了解并记录用例是很重要的,因为用例本身不会包含任何特定于实现的术语或关于界面的细节。在与客户讨论需求的过程中,可能会出现与实现相关的话题。理想情况下,最好将对话引导回用例需求,一次处理一个问题,并专注于与正确的人进行正确的讨论。结果产生的用例将反映应用程序的目标。
需求
需求文档记录了系统在执行用例时所有非功能性约束。尽管系统可以在不满足这些非功能性需求的情况下仍然工作,但结果可能无法满足用户或消费者的期望。以下是一些常见的需求类别,接下来将逐一详细讨论:
- 安全
安全需求确保敏感信息得到安全传输,并且应用程序遵守安全的编码标准和最佳实践。
一些示例安全需求包括以下内容:
-
从日志中排除与会话和系统相关的敏感数据
-
删除未使用的账户
-
应用程序中没有使用默认密码
-
容量
容量需求处理应用程序必须处理以实现生产目标的数据量大小。确定今天的存储需求以及您的应用程序如何随着增加的容量需求而扩展。以下是一些容量需求的例子:
-
日志存储空间需求
-
几个并发用户可以在任何给定时间使用该应用程序
-
可以传递到应用程序中的数据量的限制
-
兼容性
兼容性需求确定了应用程序正常运行所需的最小硬件和操作系统要求。以下是一些例子,包括在安装要求中声明以下内容:
-
所需的架构
-
所有兼容和非兼容的硬件
-
CPU 和内存需求
-
可靠性和可用性
可靠性和可用性需求定义了在完全或部分故障期间会发生什么,并设定了您应用程序可访问性的标准。以下是一些例子:
-
每个交易或时间框架允许的最小失败次数
-
为您的应用程序定义可访问时间
-
可维护性 和可管理性
可维护性需求决定了当发现错误或需要新功能时,应用程序可以被修复或增强的难易程度。可管理性需求决定了管理员管理应用程序的难易程度。以下是一些可维护性需求的例子:
-
必须快速检测错误并在适当的时间内修复
-
应用程序应与最新的硬件和操作系统版本保持兼容
-
可扩展性
可扩展性需求决定了您的应用程序在还能按预期运行的最高工作量。这主要受两个因素驱动:早期的软件决策和基础设施。扩展可以是横向的或纵向的,其中横向扩展涉及向系统中添加更多节点,而纵向扩展意味着向机器添加更多内存或更快的 CPU。以下是一些例子:
-
几个并发连接的用户可以使用该应用程序并得到预期的结果
-
每毫秒的交易数量是有限的
-
可用性
可用性需求决定了用户体验的质量。以下是一些简单的例子:
-
当用户做错事时,应用程序帮助引导用户使用正确的操作
-
帮助和文档告知用户使用新参数和标志
-
在长时间操作期间,用户可以了解其进度
-
性能
性能需求决定了应用程序的响应性。这包括以下内容:
-
用户等待特定操作完成的最低所需时间
-
对用户操作的响应性
-
环境
环境需求决定了系统预期将在哪些环境中运行。以下是一些例子:
-
必须设置的所需环境变量
-
需要与应用程序一起安装的第三方软件的依赖性
通过花时间定义用例和需求,所有相关人员都将获得清晰的画面,并对应用程序的目的和功能有一个共同的理解。共同的理解将导致一个在多个方面受益的产品,我们现在将讨论这些。
用例和需求的不利因素和优势
通过用例和需求映射功能性和非功能性需求可以极大地提高应用程序的结果。
确定用例和需求的一些不利因素:
-
由于需求需要时间来正确定义,这会减慢开发过程
-
用例和需求可能会随时间变化
接下来,我们有一些确定用例和需求的优势:
-
它提供了最佳的可能结果
-
与你的团队进行问题解决讨论确定潜在问题、误用或误解
-
它定义了应用程序的目标、未来目标和估计成本
-
你可以对每个需求进行优先级排序
目标是获得一种清晰度,帮助开发者专注于以最少的不确定性解决问题。与团队一起确定应用程序目标所花费的有益讨论和协作时间是过程中必要的方面,这些方面可以在定义用例和需求的同时实现。
CLI 的用例、图表和需求
让我们讨论一个理论场景,以说明如何为 CLI 构建用例和图表。假设有一家大型音频公司,有一个团队完全专注于元数据提取。这个团队向他们的客户和同一音频公司内的其他内部团队提供音频元数据。目前,他们有一个可供公司内部网络中任何人使用的 API,但一个运维团队请求一个 CLI 工具。运维团队认识到围绕 CLI 应用程序快速构建脚本的益处,这可能为团队开辟新的创新机会。
现有的面向客户的 API 用例应与 CLI 相似,因为实现和用户界面不是文档的一部分。考虑一下元数据团队面向内部的 CLI 用例。为了记录,我们将对它们进行编号,并取前几个用例作为例子:
-
上传音频
-
请求元数据
-
提取元数据
-
将语音转换为文本
-
请求语音到文本的转录本
-
在存储中列出音频元数据
-
在存储中搜索音频元数据
-
从存储中删除音频
为了记录,我们将对它们进行编号,并取前三个用例作为例子。
用例 1 – 上传音频
一个经过身份验证的操作团队成员可以通过提供文件路径来上传音频。上传过程将自动将上传保存到存储并触发音频处理以提取元数据,并且应用程序将响应一个唯一的 ID,用于请求元数据。
这个用例可以被分解成一些常见的组件:
-
行动者是最终用户。这可以定义为人类或另一个机器过程。在这个示例用例中,主要行动者是操作团队成员,但由于团队希望使用此 CLI 进行脚本编写,另一个机器过程也是一个行动者。
-
前提条件是必须发生以使用例发生的陈述。在这个例子中,成员必须在任何用例成功运行之前进行身份验证。图 2**.8中的前提条件由指向验证 TLS 证书的实线箭头表示,它通过证书管理客户端确认用户已通过身份验证。
-
upload
命令。此用例触发另一个用例,即内部提取音频文件元数据并将其保存到存储的用例 3,提取元数据。这由元数据提取器过程框表示。 -
当一切按预期进行,没有异常或错误时,基本流程被激活。在图 2**.8中,基本流程是一条实线。用户上传音频,并最终返回一个 ID 作为响应。成功!
-
替代流程显示了基本流程的变体,其中发生错误或异常。在图 2**.8中,替代流程是一条虚线。用户上传音频,但发生错误 – 例如,用户无效或音频文件不存在。
注意
使用用例图通过实线表示基本流程,虚线表示替代流程来展示上传音频的用例。
图 2.8:使用元数据 CLI 上传音频的用例图
与图(图 2**.8)一起,我们可以将用例完全写出来如下:
-
用例 1 – 上传音频:
-
名称:上传音频。
-
描述:行动者通过提供文件路径上传音频。应用程序返回一个唯一的 ID,用于请求音频元数据。行动者代表操作团队成员或另一个机器过程。
-
前提条件:行动者必须经过身份验证。在图 2.8中,这由从上传音频框到验证 TLS 证书菱形的实线表示。
-
触发:行动者在传递有效文件路径作为标志时触发上传命令 – 在图 2.8中,指向行动者到上传音频的箭头。
-
-
基本流程:
行动者通过 CLI 运行upload
命令 – 在图 2**.8中,指向行动者到上传音频的箭头:**
-
一旦前提条件得到验证,音频将被验证。在图 2.8中,这通过验证音频框表示。
-
在图 2.8中,验证后的音频移动到处理元数据步骤,这涉及到提取元数据,这通过指向元数据提取器过程框的箭头表示。
-
验证后的音频移动到上传音频的下一个步骤,将音频保存到数据库(DB),在图 2.8中由上传到数据库框表示。
-
在图 2.8中,返回 ID框表示从数据库返回的ID,稍后传递给参与者。
-
备选流程:
-
未认证用户的错误: 当 TLS 证书失败时,错误返回给参与者。
- 最终用例: 在图 2.8中,如果用户无效,错误返回,如虚线从无效用户到错误框,然后箭头回到参与者。
-
无效音频的错误: 当音频未能通过验证过程时,错误返回给参与者。
- 最终用例: 在图 2.8中,如果音频无效,错误返回给参与者,如验证失败到错误框,然后箭头回到参与者。
-
将验证后的音频上传到存储时出错: 当音频上传到数据库失败时,错误返回给参与者。
- 最终用例: 在图 2.8中,从上传到数据库返回的虚线连接到失败上传,然后箭头回到参与者的错误框。
-
用例 2 - 请求元数据
操作团队成员的认证成员可以通过提供get
命令检索音频元数据,将输出请求的音频元数据,匹配的 ID,以指定的格式(纯文本或 JSON)。
注意
请求音频用例的用例图用实线表示基本流程,用虚线表示备选流程。
图 2.9:请求元数据用例的使用情况图
在手头有先前的图(图 2.9)的情况下,让我们进入以下用例:
用例 2 - 请求元数据:
-
名称: 请求元数据。
-
get
命令并提供音频的ID。应用程序将以纯文本或 JSON 格式输出请求的音频元数据。 -
前提条件: 参与者必须经过认证。在图 2.9中,这通过从请求音频框到验证 TLS 证书菱形的实线表示。
-
get
命令并传入ID作为参数-在图 2.9中,指向请求音频框的箭头。
注意,在先前的用例 1 中使用了不同的格式级别 - 在整个章节中对所有用例保持一致?
基本流程
-
演员在 CLI 中运行
get
命令。在图 2.9中,基本流程由实线表示,并从指向演员到请求 音频框的箭头开始。 -
一旦前提条件得到验证,音频元数据就通过其ID从数据库中检索。在图 2.9中,这由连接通过到请求通过 ID 获取元数据到数据库的实线表示。
-
数据库成功返回元数据。在图 2.9中,这由连接请求通过 ID 获取元数据到通过框的线表示。
-
最后,格式化的元数据返回给演员。在图 2.9中,这由连接通过到演员的实线表示。
备选流程
-
未认证用户的错误:当 TLS 证书失败时,向演员返回错误。
- 最终用途:在图 2.9中,如果用户无效,则返回错误,如从无效用户框到错误框然后返回到演员的虚线所示。
-
未找到错误:如果没有匹配的元数据与ID相匹配,则返回错误。
- 最终用途:在图 2.9中,流程由从失败框到错误框的虚线和然后返回到演员的箭头表示。
用例 3 – 提取元数据
由上传音频触发,从音频文件中提取元数据,包括标签和文本数据,并将其放置在存储中。
注意
请求音频的用例图用实线表示基本流程,用虚线表示备选流程。
图 2.10:处理元数据的用例图
考虑到前面的图(图 2.10),让我们进入匹配的用例:
-
用例 3 – 提取元数据:
-
名称:提取元数据
-
描述:元数据提取过程包括提取特定的元数据,包括专辑、艺术家、年份和语音到文本转录,并将其存储在存储中的元数据对象中。
-
前提条件:验证过的音频
-
触发器:上传音频
-
-
基本流程:
-
一旦满足前提条件,元数据提取器过程提取标签并将数据存储在元数据对象上。在图 2.10中,这由成功验证的音频,从通过框到元数据提取器过程框,然后从提取标签框到通过框的实线表示。
-
接下来,提取文本。在图 2.10中,这由从通过框到提取文本框再到下一个通过框的实线表示。
-
元数据提取完成并更新元数据对象。在图 2.10中,这一步由从通过框到完成的实线和连接到元数据对象的实线表示。
-
元数据对象被存储。在 图 2**.10 中,这由从 元数据对象 到 数据库 的实线表示。
-
-
备选流程:
注意,上述两个用例中“结束用例”的格式不同 - 检查并确保章节中所有用例的一致性
-
提取标签数据错误:在 图 2**.10 中,这由从 提取标签 到 错误 的虚线表示。
-
在元数据对象上存储错误:在 图 2.10 中,这由从 错误 到 元数据对象 的虚线表示。
-
结束用例:在 图 2.10 中,这由从 元数据对象 到 数据库 的实线表示。
-
-
提取转录错误:在提取转录元数据时发生错误。在 图 2**.10 中,这由从 提取转录 到 错误 的虚线表示。
-
在元数据对象上存储错误:在 图 2.10 中,这由从 错误 到 元数据对象 的虚线表示。
-
结束用例:在 图 2.10 中,这是从 元数据对象 到 数据库 的实线。
为了理解概念,没有必要为每个用例编写完整的文档。通常,由用例描述的功能性要求由利益相关者审查,并讨论以确保达成一致。
元数据 CLI 的要求
考虑到我们处理所有音频元数据的内部团队的理论场景,内部团队和他们的客户之间可能还会请求和定义一些非功能性要求。例如,这些要求可能包括以下内容:
-
应用程序必须在 Linux、macOS 和 Windows 上运行
-
当音频上传时返回的 ID 必须立即返回
-
应用程序必须明确指出用户误用应用程序并上传了除音频以外的文件类型
对于这个元数据 CLI 应用程序,可能有更多可能的要求,但最重要的是理解什么是要求,如何形成自己的要求,以及它与用例的不同。用例和要求可以分解为阶段以获得更多粒度,特别是对于可扩展性。应用程序会随着时间的推移而增长,并且某些功能将被添加以匹配不断增长的要求。为了参考早期的 CLI 指南,“先原型设计,后优化”,最好的做法是先让应用程序运行起来,然后再进行优化。根据遇到的问题类型,无论是处理速度慢、无法支持大量并发用户,还是无法处理每分钟的特定数量的交易,您需要以不同的方式解决这些问题。例如,在优化并发使用时可以进行负载测试,或者使用内存缓存与数据库一起优化每分钟处理的交易数量。
在定义用例和需求的同时,可以并行构建您应用程序的简单原型。
结构化音频元数据 CLI 应用程序
构建 CLI 应用程序的第一步是创建文件夹结构,但如果您不是从头开始,请确定 CLI 应用程序可能添加的位置。假设音频元数据 API 应用程序的现有结构是使用领域驱动架构构建的。为了了解其结构,让我们将应用程序的构建块进行分类。
限界上下文
限界上下文为实体和对象带来了更深的含义。在我们的元数据应用程序中,消费者使用 API 搜索音频转录。操作团队希望使用 CLI 搜索音频元数据。API 消费者可能对元数据和音频转录都感兴趣,但其他团队可能更关注音频转录的结果。每个团队都为元数据带来了不同的上下文。然而,由于标签、专辑、艺术家、标题和转录都被视为元数据,它们可以封装在一个单一实体中。
语言
用于区分不同上下文的语言被称为通用语言。因为团队对不同的术语有不同的理解,这种语言有助于以所有相关方都同意的术语描述应用程序。
对于元数据应用程序,术语元数据包括从音频中提取的所有数据,包括转录,元数据提取是从音频中提取技术元数据和转录的过程。术语用户指代更大组织内部团队中的任何成员,而术语音频指代在特定长度限制内的任何录音。
实体和值对象
实体是由语言定义的对象模型。值对象是存在于实体中的字段。例如,元数据 CLI 的主要实体是音频和元数据。元数据是音频实体中的一个值对象。此外,每种提取类型可能都是元数据实体中的一个值对象。此音频元数据 CLI 应用程序的实体和值对象列表包括以下内容:
-
音频
-
元数据
-
标签
-
转录
聚合
聚合是指两个独立实体的合并。假设在音频公司的元数据团队中,用户希望对转录进行更正,这主要是由人工智能处理的。尽管转录可能达到 95%的准确率,但有一个审阅团队可以对转录进行更正,以达到 99-100%的准确率。元数据应用程序中可能需要两个微服务,一个是元数据提取,另一个是转录审阅。可能需要一个新聚合的实体:TranscriptionReview。
服务
术语服务是通用的,所以这特别指的是业务领域的服务。在元数据应用程序的情况下,领域服务是从音频中提取元数据的元数据服务以及允许用户添加更正的转录审查服务。
事件
在领域驱动设计的背景下,事件是领域特定的,并通知同一领域内的其他进程其发生。在这种情况下,当用户上传音频时,他们会立即收到一个 ID。然而,元数据提取过程在幕后被触发,而不是不断地轮询请求元数据命令或端点以检索元数据对象的状态,可以向事件监听服务发送一个事件。CLI 可以有一个命令,持续监听处理完成。
仓库
仓库是领域或实体对象的集合。仓库有添加、更新、获取和删除对象的责任。它使聚合成为可能。仓库在领域层中实现,因此不应了解特定的数据库或存储 – 在领域内,仓库只是一个接口。在这种情况下,这个元数据应用程序的仓库可以有不同的实现 – MongoDB、ElasticSearch 或平面文件。
创建结构
理解特定于音频元数据 CLI 的领域驱动设计组件,我们可以开始构建特定于元数据 CLI 的文件夹结构。以下是一个示例布局:
/Users/username/go/src/github.com/audiocompany/audiofile
|--cmd
|----api
|----cli
|--extractors
|----tags
|----transcript
|--internal
|----interfaces
|--models
|--services
|----metadata
|--storage
|--vendor
主要文件夹
每个文件夹如下:
-
cmd
:命令文件夹是音频元数据项目中两个不同应用程序的主要入口点:API 和 CLI。 -
extractors
:此文件夹将包含从音频中提取元数据的包。尽管这个提取器列表会增长,但我们可以从几个提取器包开始:tags
和transcript
。 -
models
:此文件夹将包含所有领域实体的结构。要包含的主要实体是音频和元数据。每个提取器也可能有自己的数据模型,可以存储在这个文件夹中。 -
services
:在我们的前一次讨论中定义了三个服务 – 元数据(提取)服务、转录审查服务以及一个事件监听服务,该服务将监听处理事件并输出通知。现有和新服务都存在于这个文件夹中。 -
storage
:存储的接口和个别实现都存在于这个文件夹中。
摘要
在本章中,我们学习了如何根据业务领域的独特需求创建新应用程序的结构。我们探讨了应用程序最流行的文件夹结构以及每种结构的优缺点,以及如何编写用例和非功能性需求的文档。
虽然这一章提供了一个示例布局和该示例中存在的文件夹,但请记住,这是一个更发达项目的示例。开始时要简单,始终使用扁平结构,但随着你继续构建,开始组织你的未来文件夹结构。只需记住,你的代码结构需要时间。罗马不是一天建成的。
在讨论了这些主题之后,我们接着讨论了一个假设的现实世界例子,即一家完全专注于音频元数据的团队的公司。随后,我们讨论了一些 CLI 提供的潜在用例,这将是一个快速高效的现有 API 的替代方案。
最后,我们讨论了一种文件夹结构,它可以满足 CLI 和 API 音频元数据应用程序的需求。在第三章 构建音频元数据 CLI 中,我们将构建包含模型、接口和实现的文件夹结构,以使 CLI 应用程序工作。这标志着本章节关于如何结构化你的 Go CLI 应用程序的结束!希望这能帮助你开始。
问题
-
如果你想与外部应用程序或用户共享包,这些包通常会驻留在哪个常见的文件夹中?
-
在端口和适配器,或六边形架构中,端口和适配器是什么?
-
对于列出音频,在一个现实世界的例子中,你将如何定义这个用例的参与者、前提条件和触发器?
答案
-
pkg
文件夹包含可能被外部应用程序使用的代码。 -
在六边形架构中,端口是接口,适配器是实现。端口允许架构的不同层之间进行通信,而适配器提供实际的实现。
-
参与者是操作团队成员或任何 CLI 用户。用例的前提是用户必须首先进行身份验证。该用例由 API 的/list 端点(用于元数据服务)或运行列出音频的 CLI 命令触发。
进一步阅读
- Kat Zein – 如何结构化你的 Go 应用 来自 2018 年的 GopherCon (
www.youtube.com/watch?v=oL6JBUk6tj0
) – 一场关于 Go 应用程序最常见文件夹结构的精彩演讲
第三章:构建音频元数据 CLI
动手学习 是最好的学习方法之一。因此,在本章中,我们将从头到尾构建一些示例音频元数据 CLI 用例。代码可在网上找到,可以与本章一起或独立探索。鼓励你分叉 GitHub 仓库并玩弄代码,添加新的用例和测试,因为这些是在下一章深入探讨如何改进 CLI 之前学习的好方法。
尽管本章中的示例并非基于空代码库构建——它是基于现有的 REST API 构建的——但值得注意的是,命令的实现并不一定依赖于 API。这只是一个示例,并鼓励你在本章中发挥想象力,思考如果不依赖 API,命令应该如何实现。本章将提供一个实验性的代码库,你将学习以下主题:
-
定义组件
-
实现用例
-
测试和模拟
技术要求
下载以下代码以进行跟随:
github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter03/audiofile
安装最新版本的 VS Code 并带有最新的 Go 工具。
定义组件
以下是我们音频元数据 CLI 的文件夹结构。在上一个章节中描述了该结构中的主要文件夹。在这里,我们将进一步详细说明每个文件夹包含的内容,以及从上到下存在的文件和代码:
|--cmd
|----api
|----cli
|------command
|--extractors
|----tags
|----transcript
|--internal
|----interfaces
|--models
|--services
|----metadata
|--storage
|--vendor
cmd/
如前所述,在第二章的“为 CLI 应用程序结构化 Go 代码”部分中,在“常用程序布局以构建健壮应用程序”一节中,cmd
文件夹是项目不同应用程序的主要入口点。
cmd/api/
位于 cmd/api/
文件夹中的 main.go
文件将开始在本地机器上运行音频元数据 API。它接受一个可选的端口号标志,默认为 8000
,并将端口号传递到 services
方法中的 Run
方法,以启动元数据服务:
package main
import (
metadataService "audiofile/services/metadata"
"flag"
"fmt"
)
func main() {
var port int
flag.IntVar(&port, "p", 8000, "Port for metadata
service")
flag.Parse()
fmt.Printf("Starting API at http://localhost:%d\n",
port)
metadataService.Run(port)
}
我们使用了 flag
包,该包实现了简单的命令行标志解析。可以定义不同的标志类型,例如 String
、Bool
和 Int
。在先前的示例中,定义了一个 -p
标志来覆盖默认端口 8000
。在定义所有标志之后调用 flag.Parse()
来解析行到定义的标志。使用 Go 的 flag
包允许通过几种语法方法将标志传递给命令。无论哪种方式,8080
的值都将被解析:
-p=8080
-p 8080 // this works for non-boolean flags only
有时,一个标志不需要参数,仅凭它本身就足以让代码知道确切要做什么:
-p
可以对传递的标志采取行动,但变量将在定义时包含默认值8000
。
要从项目的根目录启动 API,请运行go run cmd/api/main.go
,你将看到以下输出:
audiofile go run cmd/api/main.go
Starting API at http://localhost:8000
cmd/cli/
在cmd/cli/
文件夹中的这个main.go
文件运行 CLI,像许多其他 CLI 一样,这个 CLI 将通过调用它来利用 API。由于 CLI 需要 API 运行才能工作,因此请在单独的终端或后台首先运行 API。cmd/cli/main.go
文件包含以下代码:
package main
import (
"audiofile/internal/command"
"audiofile/internal/interfaces"
"fmt"
"net/http"
"os"
)
func main() {
client := &http.Client{}
cmds := []interfaces.Command{
command.NewGetCommand(client),
command.NewUploadCommand(client),
command.NewListCommand(client),
}
parser := command.NewParser(cmds)
if err := parser.Parse(os.Args[1:]); err != nil {
os.Stderr.WriteString(fmt.Sprintf("error: %v",
err.Error()))
os.Exit(1)
}
}
在main.go
文件中,命令被添加到interface
类型的Command
切片中。每个命令被定义并添加:
command.NewGetCommand(client),
command.NewUploadCommand(client),
command.NewListCommand(client),
每个命令都接受一个client
变量,一个默认的http.Client
,作为参数来使用,以便向音频元数据 API 端点发送 HTTP 请求。传递client
命令允许它轻松地进行模拟测试,我们将在下一节中讨论。
然后,将命令传递给NewParser
方法,该方法创建一个指向command.Parser
的指针:
parser := command.NewParser(cmds)
这个Parse
函数通过os.Args[1:]
参数值接收应用程序名称之后的全部参数。例如,假设命令行如下调用:
./audiofile-cli upload -filename recording.m4v
然后,第一个参数os.Args[0]
返回以下值:
audiofile-cli
为了进一步解释,让我们看看Command
结构体及其中的字段:
图 3.1 – 命令结构体和 flag.FlagSet 实体
让我们看看图中所示的GetCommand
结构体:
type GetCommand struct {
fs *flag.FlagSet
client interfaces.Client
id string
}
每个命令都有一个标志集,其中包含命令的名称、错误处理、客户端和 ID。
Go 程序的参数存储在os.Args
切片中,它是一组字符串的集合。正在运行的可执行文件名称存储在os.Args
切片的第一个元素中(即os.Args[0]
),而传递给可执行文件的参数存储在后续元素中(os.Args[1:]
)。
当你看到代码parser.Parse(os.Args[1:])
时,这意味着你正在将命令行参数的其余部分传递给parse.Parse
函数,跳过了第一个参数(程序名称)。在这种情况下,除了程序名称之外的所有命令行参数都将传递给该函数。
这意味着当我们传递os.Args[1:]
时,我们正在将程序名称之后的全部参数传递给parse.Parse
:
upload –filename recording.m4v
Parse
函数接收一个字符串列表args
,并返回一个error
类型。该函数将命令行参数转换为可执行命令。
让我们结合以下流程图来逐步分析代码:
-
它检查参数是否少于 1 个。如果是这样,
help()
返回nil
。 -
如果切片中至少有一个项目,则
Args[0]
被分配给子命令。这显示了用户的命令。 -
函数随后遍历
Parser
结构体的p.commands
属性。它通过执行Name()
方法来检查每个命令的名称,并与subcommand
变量进行比较。 -
如果找到匹配项,则函数使用
args[1:]
的其余args
切片执行命令的ParseFlags
方法。最后,函数运行命令并返回结果。 -
如果没有找到匹配项,则方法使用
fmt.Errorf
函数返回一个未知子命令错误信息。
实质上,代码从命令行参数中查找并执行一个命令。然后,运行匹配的命令。
图 3.2 – 解析方法的流程图
对于每个 API 端点都存在一个命令。例如,UploadCommand
将调用/upload
端点,ListCommand
将调用/list
端点,而GetCommand
将调用 REST API 的/get
端点。
在Parse
方法中,检查args
的长度。如果没有传递任何参数,则打印帮助信息,程序返回nil
:
audiofile ./audiofile-cli
usage: ./audiofile-cli <command> [<args>]
These are a few Audiofile commands:
get Get metadata for a particular audio file by id
list List all metadata
upload Upload audio file
cmd/cli/command
在cmd/cli/command
文件夹中,有与每个 audiofile API 端点匹配的命令。在下一节中,我们将编写upload
、list
和get
命令以实现上一章中描述的几个用例。而不是在这里定义这些命令中的任何一个的代码,我将提供一个用于定义满足Command
接口的随机命令的结构:
package command
import (
"github.com/marianina8/ audiofile/internal/cli"
"github.com/marianina8/ audiofile/internal/interfaces"
"flag"
"fmt"
)
func NewRandomCommand(client interfaces.Client)
*RandomCommand {
gc := &RandomCommand{
fs: flag.NewFlagSet("random",
flag.ContinueOnError),
client: client,
}
gc.fs.StringVar(&gc.flag, "flag", "", "string flag for
random command")
return gc
}
type RandomCommand struct {
fs *flag.FlagSet
flag string
}
func (cmd *RandomCommand) Name() string {
return cmd.fs.Name()
}
func (cmd *RandomCommand) ParseFlags(flags []string) error {
return cmd.fs.Parse(flags)
}
func (cmd *RandomCommand) Run() error {
fmt.Println(rand.Intn(100))
return nil
}
upload
、get
和list
命令遵循相同的结构,但构造函数和Run
方法的实现不同。
此外,在cmd/cli/command
文件夹中,还有一个结构类型的解析器,它具有解析参数、将它们与命令匹配以及解析子命令之后找到的任何标志的方法。NewParser
函数创建一个Parser
结构的新实例。它接受一个类型为[]interfaces.Command
的切片作为输入,并返回一个指向Parser
结构的指针。此初始化方法提供了一种轻松设置具有一组所需命令的结构的方法。以下是在parser.go
文件内的代码:
package command
import (
"github.com/marianina8/audiofile/internal/interfaces"
"fmt"
)
type Parser struct {
commands []interfaces.Command
}
func NewParser(commands []interfaces.Command) *Parser {
return &Parser{commands: commands}
}
func (p *Parser) Parse(args []string) error {
if len(args) < 1 {
help()
return nil
}
subcommand := args[0]
for _, cmd := range p.commands {
if cmd.Name() == subcommand {
cmd.ParseFlags(args[1:])
return cmd.Run()
}
}
return fmt.Errorf("Unknown subcommand: %s", subcommand)
}
代码检查传递给Parse
方法的参数数量。如果参数数量少于 1,则调用来自单独的help.go
文件的help
函数以打印帮助文本,指导用户正确使用:
func help() {
help := `usage: ./audiofile-cli <command> [<flags>]
These are a few Audiofile commands:
get Get metadata for a particular audio file by id
list List all metadata
upload Upload audio file
`
fmt.Println(help)
}
extractors/
此文件夹包含不同音频元数据的提取器实现。在这种情况下,存在用于tags
和transcript
实现的子文件夹。
extractors/tags
tags
包在extractors/tags
文件夹中实现。标签元数据可能包括标题、专辑、艺术家、作曲家、流派、发行年份、歌词以及任何附加注释。代码可在 GitHub 仓库中找到,并使用github.com/dhowden/tag
Go 包。
extractors/transcript
transcript
包在extractors/transcript
文件夹中实现。与其他提取包一样,代码可以在 GitHub 仓库中找到。然而,转录分析由第三方 API AssemblyAI 处理,需要 API 密钥,该密钥可以设置为本地的ASSEMBLY_API_KEY
。
internal/interfaces
internal/interfaces
文件夹包含应用程序使用的接口。它包括Command
和Storage
接口。接口为开发者提供了一种创建满足相同接口规范的多种类型的方式,这允许在应用程序的设计中具有灵活性和模块化。storage.go
文件定义了存储接口:
package interfaces
import (
"audiofile/models"
)
type Storage interface {
Upload(bytes []byte, filename string) (string, string,
error)
SaveMetadata(audio *models.Audio) error
List() ([]*models.Audio, error)
GetByID(id string) (*models.Audio, error)
Delete(id string, tag string) error
}
前面的接口满足所有可能的用例。具体的实现可以在storage
文件夹中定义。如果你选择在配置中定义存储类型,你可以轻松地更换实现,并从一种存储类型切换到另一种。在这个例子中,我们定义了平面文件存储,并为每个方法实现了一个实现来满足接口。
首次在cmd/cli/main.go
文件中使用,Command
接口在internal/interfaces/command.go
文件中通过以下代码定义:
type Command interface {
ParseFlags([]string) error
Run() error
Name() string
}
注意cmd/cli/command/
文件夹中的每个命令是如何实现前面提到的接口的。
models/
models
文件夹包含在不同应用程序之间共享的结构。为audiofile
应用程序定义的第一个结构是Audio
:
type Audio struct {
Id string
Path string
Metadata Metadata
Status string
Error []error
}
Id
变量包含唯一的Audio
文件。该路径存储了音频文件的本地副本。Metadata
变量包含从音频文件中提取的数据。在以下示例中,存储了标签和语音到文本的转录数据:
type Metadata struct {
Tags Tags `json:"tags"`
Transcript string `json:"transcript"`
}
不必了解每种提取类型的结构。最重要的是主要实体类型Audio
及其值字段Metadata
。
services/metadata
尽管可以在services
文件夹中实现多个服务,但我们目前只使用了一个 API 服务,即音频元数据服务。metadata.go
文件中只存在一个方法,即CreateMetadataServer
方法,它在metadata
包中被调用,以及Run
方法,它在cmd/api/main.go
文件中被调用。此文件还包含了MetadataService
的结构:
type MetadataService struct {
Server *http.Server
Storage interfaces.Storage
}
CreateMetadataService
接受一个参数,即int
类型的端口,用于定义在本地主机上运行的服务器端口。它还接受一个参数storage
,它是Storage
接口的一个实现。声明 API 服务器每个端点的处理程序也被定义。此函数返回MetadataService
的指针:
func CreateMetadataService(port int, storage
interfaces.Storage) *MetadataService {
mux := http.NewServeMux()
metadataService := &MetadataService{
Server: &http.Server{
Addr: fmt.Sprintf(":%v", port),
Handler: mux,
},
Storage: storage,
}
mux.HandleFunc("/upload",
metadataService.uploadHandler)
mux.HandleFunc("/request",
metadataService.getByIDHandler)
mux.HandleFunc("/list", metadataService.listHandler)
return metadataService
}
Run
方法接受一个参数port
,该参数由p
标志的值或默认值8000
定义,调用CreateMetadataService
方法,并通过在服务器上调用ListenAndServer
方法来启动服务器。启动 API 时出现的任何错误将立即返回:
func Run(port int) {
flatfileStorage := storage.FlatFile{}
service:= CreateMetadataService(port, flatfileStorage)
err := service.Server.ListenAndServe()
if err != nil {
fmt.Println("error starting api: ", err)
}
}
每个处理器的实现将在下一节中讨论,当处理一些用例时。
storage/
在storage
文件夹中,有flatfile.go
文件,它实现了一种将元数据本地存储到本地磁盘上按 ID 组织的平面文件的方法。由于这超出了 CLI 关注的范围,本书将不讨论代码实现。然而,你可以在 GitHub 仓库中查看代码。
vendor/
vendor
目录包含所有直接和间接依赖。
实现用例
记得上一章中定义的用例吗?让我们尝试实现其中的一些:
-
UC-01 上传音频
-
UC-02 请求元数据
上传音频
在本用例中,经过身份验证的用户通过提供设备上文件的存储位置来上传音频文件,目的是提取其元数据。在底层,上传过程将保存本地副本并在音频文件上运行元数据提取过程。立即返回音频文件的唯一 ID。
在我们开始实现此用例之前,让我们考虑一下上传命令可能的样子。假设我们已经确定了以下最终的命令结构:
./audiofile-cli upload -filename <filepath>
由于/cmd/cli/main.go
已经定义,我们只需确保upload
命令存在并满足command
接口,包括ParseFlags
、Run
和Name
方法。在internal/command
文件夹中,我们在command
包内的upload.go
文件中定义了upload
命令:
package command
import (
"github.com/marianina8/audiofile/internal/interfaces"
"bytes"
"flag"
"fmt"
"io"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
)
func NewUploadCommand(client interfaces.Client)
*UploadCommand {
gc := &UploadCommand{
fs: flag.NewFlagSet("upload",
flag.ContinueOnError),
client: client,
}
gc.fs.StringVar(&gc.filename, "filename", "", "full
path of filename to be uploaded")
return gc
}
type UploadCommand struct {
fs *flag.FlagSet
client interfaces.Client
filename string
}
func (cmd *UploadCommand) Name() string {
return cmd.fs.Name()
}
func (cmd *UploadCommand) ParseFlags(flags []string)
error {
if len(flags) == 0 {
fmt.Println("usage: ./audiofile-cli
upload -filename <filename>")
return fmt.Errorf("missing flags")
}
return cmd.fs.Parse(flags)
}
func (cmd *UploadCommand) Run() error {
// implementation for upload command
return nil
}
NewUploadCommand
方法通过为upload
命令定义一个新的标志集来实现我们期望的命令结构:
flag.NewFlagSet("upload", flag.ContinueOnError)
此方法调用将字符串upload
传递到方法的name
参数和标志。flag.ErrorHandling
参数中的ContinueOnError
定义了如果解析标志时发生错误,应用程序应该如何反应。处理解析错误的不同选项,大多数都是自我解释的,包括以下内容:
-
flag.ContinueOnError
-
flag.ExitOnError
-
flag.PanicOnError
现在我们已经定义并添加了upload
命令,我们可以测试它。在测试过程中,你会看到upload
命令运行无误,但立即退出且没有响应。现在,我们准备实现upload
命令的Run
方法。
当我们最初开始为 audiofile 应用程序实现 CLI 时,API 已经存在。我们讨论了 API 如何启动和运行MetadataServer
,该服务器处理对一些现有端点的请求。对于此用例,我们关注的是http://localhost/upload
端点。
考虑到这一点,让我们深入了解这个 REST API 上传端点的文档,这样我们就可以确切地知道如何构造curl
命令。
上传音频
为了上传音频,我们需要知道如何与 API 通信以处理某些任务。以下是设计请求以处理上传音频所需的具体细节:
-
POST
-
http://localhost/upload
-
Content-Type: multipart/form-data
-
键 ("file") 值 (bytes) 文件名的基础名(base)
确保 API 正在运行,然后使用curl
测试端点。立即返回上传文件的 ID:
curl --location --request POST 'http://localhost/upload' \
--form 'file=@"recording.mp3"'
8a6dc954-d6df-4fc0-882e-14eb1581d968%
在成功测试了 API 端点之后,我们可以在UploadCommand
的Run
方法中编写 Go 代码,以实现与之前curl
命令相同的功能。
现在可以定义新的Run
方法。该方法提供传递给upload
命令的作为标志参数的文件名,并将该文件的字节保存到http://localhost/upload
端点的 multipart 表单POST
请求中:
func (cmd *UploadCommand) Run() error {
if cmd.filename == "" {
return fmt.Errorf("missing filename")
}
fmt.Println("Uploading", cmd.filename, "...")
url := "http://localhost/upload"
method := "POST"
payload := &bytes.Buffer{}
multipartWriter := multipart.NewWriter(payload)
file, err := os.Open(cmd.filename)
if err != nil {
return err
}
defer file.Close()
partWriter, err := multipartWriter
.CreateFormFile("file", filepath.Base(cmd.filename))
if err != nil {
return err
}
_, err = io.Copy(partWriter, file)
if err != nil {
return err
}
err = multipartWriter.Close()
if err != nil {
return err
}
client := cmd.client
req, err := http.NewRequest(method, url, payload)
if err != nil {
return err
}
req.Header.Set("Content-Type",
multipartWriter.FormDataContentType())
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
fmt.Println("Audiofile ID: ", string(body))
return err
}
第一个 CLI 命令upload
已经实现!让我们实现另一个用例,通过 ID 请求元数据。
请求元数据
在请求元数据用例中,认证用户通过音频文件的 ID 请求音频元数据。在底层,请求元数据的过程将在平面文件存储实现中搜索与音频文件对应的metadata.json
文件,并将其内容解码到Audio
结构体中。
在实现请求元数据用例之前,让我们考虑请求元数据的命令将是什么样子。最终的命令结构将如下所示:
./audiofile-cli get -id <ID>
为了简化,get
是请求元数据的命令。让我们定义新的get
命令,并在/cmd/cli/main.go
中确认当应用程序运行时它存在于可识别的命令列表中。定义get
命令的结构与第一个命令upload
类似:
package command
import (
"github.com/marianina8/audiofile/internal/interfaces"
"bytes"
"flag"
"fmt"
"io"
"net/http"
"net/url"
)
func NewGetCommand(client interfaces.Client) *GetCommand {
gc := &GetCommand{
fs: flag.NewFlagSet("get",
flag.ContinueOnError),
client: client,
}
gc.fs.StringVar(&gc.id, "id", "", "id of audiofile
requested")
return gc
}
type GetCommand struct {
fs *flag.FlagSet
client interfaces.Client
id string
}
func (cmd *GetCommand) Name() string {
return cmd.fs.Name()
}
func (cmd *GetCommand) ParseFlags(flags []string) error {
if len(flags) == 0 {
fmt.Println("usage: ./audiofile-cli get -id <id>")
return fmt.Errorf("missing flags")
}
return cmd.fs.Parse(flags)
}
func (cmd *GetCommand) Run() error {
// implement get command
return nil
}
NewGetCommand
方法通过为get
命令定义一个新的标志集flag.NewFlagSet("get", flag.ContinueOnError)
来实现我们想要的命令结构。此方法接收方法参数name
中的字符串get
和flag.ErrorHandling
参数中的flag.ContinueOnError
。
让我们深入查阅这个 REST API 的 get 端点文档,以便我们确切知道如何构造 curl 命令。
请求元数据
为了请求音频元数据,我们需要知道如何与 API 通信以处理此任务。以下是设计音频元数据请求所需的相关细节:
-
GET
-
http://localhost/get
-
id
– 音频文件 ID
确保 API 正在运行,然后使用curl
测试get
端点。立即以 JSON 格式返回请求的音频文件的元数据。这些数据可以以不同的格式返回,我们可以添加一个额外的标志来决定返回元数据的格式:
curl --location --request GET
'http://localhost/request?id=270c3952-0b48-4122-bf2a-
e4a005303ecb'
{audiofile metadata in JSON format}
在确认 API 端点按预期工作后,我们可以在GetCommand
的Run
方法中编写 Go 代码,以实现与之前的curl
命令相同的功能。现在可以定义新的Run
方法:
func (cmd *GetCommand) Run() error {
if cmd.id == "" {
return fmt.Errorf("missing id")
}
params := "id=" + url.QueryEscape(cmd.id)
path := fmt.Sprintf("http://localhost/request?%s",
params)
payload := &bytes.Buffer{}
method := "GET"
client := cmd.client
req, err := http.NewRequest(method, path, payload)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("error reading response: ",
err.Error())
return err
}
fmt.Println(string(b))
return nil
}
现在请求元数据用例已经实现,让我们编译代码并测试前几个 CLI 命令:upload
用于上传和处理音频元数据,以及 get
用于通过音频文件 ID 请求元数据。
给 CLI 起一个更具体的名字,audiofile-cli
,然后运行以下命令来生成构建:
go build -o audiofile-cli cmd/cli/main.go
测试 CLI
现在我们已经成功构建了 CLI 应用程序,我们可以进行一些测试以确保它正在正常工作。我们可以测试我们创建的命令,然后编写适当的测试以确保未来的更改不会破坏当前的功能。
手动测试
要上传音频文件,我们将运行以下命令:
./audiofile-cli upload -filename audio/beatdoctor.mp3
结果正如预期:
Uploading audio/beatdoctor.mp3 ...
Audiofile ID: 8a6a8942-161e-4b10-bf59-9d21785c9bd9
现在我们有了音频文件 ID,我们可以立即获取元数据,这些元数据将在每次提取过程更新后发生变化。请求元数据的命令如下:
./audiofile-cli get -id=8a6a8942-161e-4b10-bf59-
9d21785c9bd9
结果是填充的 Audio
结构体,以 JSON 格式呈现:
{
"Id": "8a6a8942-161e-4b10-bf59-9d21785c9bd9",
"Path": "/Users/marian/audiofile/8a6a8942-161e-4b10-
bf59-9d21785c9bd9/beatdoctor.mp3",
"Metadata": {
"tags": {
"title": "Shot In The Dark",
"album": "Best Bytes Volume 4",
"artist": "Beat Doctor",
"album_artist": "Toucan Music (Various
Artists)",
"genre": "Electro House",
"comment": "URL: http://freemusicarchive.org/
music/Beat_Doctor/Best_Bytes_Volume_4/
09_beat_doctor_shot_in_the_dark\r\nComments:
http://freemusicarchive.org/\r\nCurator: Toucan
Music\r\nCopyright: Attribution-NonCommercial
3.0 International: http://creativecommons.org/
licenses/by-nc/3.0/"
},
"transcript": "This is Sharon."
},
"Status": "Complete",
"Error": null
}
结果正如预期。然而,并非所有传入 CLI 的音频都会返回相同的数据。这只是一个例子。有些音频可能没有任何标签,如果你没有设置带有 AssemblyAI API 密钥的 ASSEMBLYAI_API_KEY
环境变量,转录将被跳过。理想情况下,API 密钥不应作为环境变量设置,因为它们很容易泄露,但这是一个临时的选项。在 第四章 构建 CLIs 的流行框架 中,你将了解 Viper,这是一个与 Cobra CLI 框架完美搭配的配置库。
测试和模拟
现在,我们可以开始编写一些单元测试。在 main.go
文件中,有一个解析传递给应用程序的参数的根函数。使用 VS Code 和 Go 支持的扩展,你可以右键单击一个函数并看到一个用于生成单元测试的选项,Go: Generate Unit Tests For Function。
图 3.3 –VS Code 中 Go 选项菜单的截图
在 commands
包中选择 Parse
函数,然后点击选项在 parser_test.go
文件内生成以下表驱动的单元测试,我们可以看到解析功能的测试函数:
func TestParser_Parse(t *testing.T) {
type fields struct {
commands []interfaces.Command
}
type args struct {
args []string
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Parser{
commands: tt.fields.commands,
}
if err := p.Parse(tt.args.args); (err != nil)
!= tt.wantErr {
t.Errorf("Parser.Parse() error = %v,
wantErr %v", err, tt.wantErr)
}
})
}
}
这为我们提供了一个很好的模板,我们可以根据方法中使用的不同参数和标志组合来实现一些测试。在运行测试时,我们不希望客户端调用 REST 端点,因此我们模拟客户端并伪造响应。我们都在 parser_test.go
文件中完成这些操作。由于每个命令都接受一个客户端,我们可以轻松地模拟接口。这是通过以下代码在文件中完成的:
type MockClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
}
func (m *MockClient) Do(req *http.Request) (*http.Response,
error) {
if strings.Contains(req.URL.String(), "/upload") {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser
(strings.NewReader("123")),
}, nil
}
if strings.Contains(req.URL.String(), "/request") {
value, ok := req.URL.Query()["id"]
if !ok || len(value[0]) < 1 {
return &http.Response{
StatusCode: 500,
Body: io.NopCloser(strings.NewReader("url param 'id' is
missing")),
}, fmt.Errorf("url param 'id' is missing")
}
if value[0] != "123" {
return &http.Response{
StatusCode: 500,
Body: io.NopCloser
(strings.NewReader("audiofile id does not
exist")),
}, fmt.Errorf("audiofile id does not exist")
}
file, err := os.ReadFile("testdata/audio.json")
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser
(strings.NewReader(string(file))),
}, nil
}
return nil, nil
}
MockClient
接口由 http.DefaultClient
满足。Do
方法被模拟。在 Do
方法中,我们检查哪个端点被调用(/upload
或 /get
)并返回模拟响应。在上一个示例中,对 /upload
端点的任何调用都返回一个 OK
状态和一个字符串,123
,表示音频文件的 ID。对 /get
端点的调用检查作为 URL 参数传递的 ID。如果 ID 与 123
的音频文件 ID 匹配,则模拟客户端将返回一个包含响应体中音频 JSON 的成功响应。如果请求的 ID 不是 123
,则返回一个状态码为 500 的错误消息,指出 ID 不存在。
现在模拟客户端已经完成,我们在 Parse
函数的单元测试中为每个命令,upload
和 get
,填写成功和失败的情况:
func TestParser_Parse(t *testing.T) {
mockClient := &MockClient{}
type fields struct {
commands []interfaces.Command
}
type args struct {
args []string
}
tests
变量包含一个数组,其中包含测试名称、可用的字段或命令、可能传递到命令行应用程序的字符串参数,以及一个 wantErr
布尔值,该值根据我们是否期望在测试中返回错误而设置。让我们逐一过一下每个测试:
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
第一次测试,名为 upload – failure – does not exist
,模拟以下命令:
./audiofile-cli upload -filename doesNotExist.mp3
文件名 doesNotExist.mp3
是一个在根文件夹中不存在的文件。在 upload
命令的 Run()
方法中打开文件。这就是错误发生的地方,输出是一个错误消息,file does not exist
:
{
name: "upload - failure - does not exist",
fields: fields{
commands: []interfaces.Command{
NewUploadCommand(mockClient),
},
},
args: args{
args: []string{"upload", "-filename",
"doesNotExist.mp3"},
},
wantErr: true, // error = open
doesNotExist.mp3: no such file or directory
},
命名为 upload – success – uploaded
的测试检查文件成功上传到存储的情况,并返回一个音频文件 ID 作为响应。为了使这个测试能够运行,command
包中有一个 testdata
文件夹,其中包含一个用于测试的小型音频文件,模拟以下命令:
./audiofile-cli upload -filename testdata/exists.mp3
此文件成功打开并发送到 /upload
端点。模拟客户端的 Do
函数看到请求是到 /upload
端点,并发送一个 OK
状态,以及响应体中的音频文件 ID 123
和没有错误。这与 wantErr
的值 false
匹配:
{
name: "upload - success - uploaded",
fields: fields{
commands: []interfaces.Command{
NewUploadCommand(mockClient),
},
},
args: args{
args: []string{"upload", "-filename", "
testdata/exists.mp3"},
},
wantErr: false,
},
上传后,我们现在可以 获取 与音频文件相关的元数据。下一个测试用例 get – failure – id does not exist
测试对不存在音频文件 ID 的请求。我们不是传递 123
,即存在的音频文件 ID,而是传递一个不存在的 ID,通过 CLI 模拟以下命令:
./audiofile-cli get -id 567
wantErr
设置为 true
,我们得到了预期的错误,audiofile id does not exist
。来自 /request
端点的响应在响应体中返回错误消息。
{
name: "get - failure - id does not exist",
fields: fields{
commands: []interfaces.Command{
NewGetCommand(mockClient),
},
},
args: args{
args: []string{"get", "-id", "567"},
},
wantErr: true, // error = audiofile id does not
exist
},
测试名为 get – success – requested
的测试检查 get
命令是否成功检索到一个存在的音频文件的 ID。传入的 ID 是 "123"
,在模拟客户端中,您可以看到当特定的 ID 传入请求时,API 端点返回一个 200 成功代码,并带有音频文件元数据的正文。
这可以通过以下命令来模拟:
./audiofile-cli get -id 123
{
name: "get - success - requested",
fields: fields{
commands: []interfaces.Command{
NewGetCommand(mockClient),
},
},
args: args{
args: []string{"get", "-id", "123"},
},
wantErr: false,
},
}
以下代码遍历之前描述的 tests
数组,为每个测试传递命令参数,并检查最终的 wantErr
值是否与预期错误匹配:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Parser{
commands: tt.fields.commands,
}
if err := p.Parse(tt.args.args); (err != nil)
!= tt.wantErr {
t.Errorf("Parser.Parse() error = %v,
wantErr %v", err, tt.wantErr)
}
})
}
}
要运行这些测试,请在仓库中输入以下命令:
go test ./cmd/cli/command -v
这将执行所有前面的测试并打印以下输出:
--- PASS: TestParser_Parse (0.06s)
--- PASS: TestParser_Parse/upload_-_failure_-
_does_not_exist (0.00s)
--- PASS: TestParser_Parse/upload_-_success_-_uploaded
(0.06s)
--- PASS: TestParser_Parse/get_-_failure_-
_id_does_not_exist (0.00s)
--- PASS: TestParser_Parse/get_-_success_-_requested
(0.00s)
PASS
ok github.com/marianina8/audiofile/cmd/cli/command
(cached)
测试所有命令的成功和失败情况是很重要的。尽管这只是一个入门示例;可以添加更多测试用例。例如,在前一章中,我们详细讨论了上传用例。您可以测试大文件是否超过限制,或者传入 upload
命令的文件是否是音频文件。在当前实现的状态下,大文件可以成功上传。由于这并不是我们想要的,我们可以修改 UploadCommand
的 Run
方法,在调用 /upload
端点的请求之前检查文件的大小。然而,这只是一个示例,希望它能给您一个关于如何构建 CLI 的想法,以及它是如何与现有的 API 一起构建的。
摘要
在本章中,我们通过构建音频元数据 CLI 的示例,逐一分析了构成该 CLI 的不同组件。通过分析这些组件,我们确定了 CLI 的结构以及文件的结构,无论是作为现有代码库的一部分还是作为新的 CLI。
我们学习了如何实现 CLI 的前两个主要用例,即上传音频和获取音频元数据。关于命令结构的详细信息让您了解到了在不使用任何额外解析包的情况下如何构建命令。您还学习了如何实现一个用例,测试您的 CLI,以及模拟客户端接口。
尽管本章为您提供了构建 CLI 的想法,但一些命令,如嵌套子命令和标志组合可能会变得复杂。在下一章中,我们将讨论如何使用一些流行的框架来帮助解析复杂的命令,并提高 CLI 开发过程的整体效率。您将看到这些框架如何指数级地加快新 CLI 的开发速度!
问题
-
使用存储接口的好处是什么?如果您要使用不同的存储选项,替换当前的平面文件存储实现会有多容易?
-
参数和标志之间的区别是什么?在以下现实世界的示例中,什么可以被视为参数或标志?
./audiofile-cli upload -filename music.mp3
-
假设您想为用户在没有传递任何参数或标志的情况下运行
get
命令创建一个额外的测试:./audiofile-cli get
tests
数组中添加一个额外的条目会是什么样子?
答案
-
接口在编写模块化代码时对我们有益,这种代码是解耦的,并且减少了代码库不同部分之间的依赖。由于我们有一个接口,替换实现就变得容易得多。在现有代码中,你会在
metadata
包的Run
方法中替换实现类型。 -
在这个例子中:
./audiofile-cli upload -filename music.mp3
upload
、-filename
和 music.mp3
都被视为参数。然而,标志是特定参数,它们通过特定的语法进行特别标记。在这种情况下,-filename
是一个标志。
-
当用户在未传递任何参数或标志的情况下运行
get
命令时,一个额外的测试看起来会是这样:{ name: "get - failure - missing required id flag", fields: fields{ commands: cmds, }, args: args{ args: []string{"get"}, }, wantErr: true, },
第四章:构建 CLIs 的流行框架
本章将探讨可用于快速开发现代 CLI 应用程序的最受欢迎的框架。在看到手动创建命令和结构化 CLI 应用程序所需的所有代码后,您将看到 Cobra 如何使开发者能够快速生成 CLI 应用程序所需的全部框架,并轻松添加新命令。
Viper 可以轻松与 Cobra 集成,以多种格式在本地或远程配置您的应用程序。选项非常广泛,开发者可以选择他们认为最适合他们项目且他们感到舒适的方式。本章将通过以下主题深入探讨 Cobra 和 Viper:
-
Cobra – 用于构建现代 CLI 应用程序的库
-
Viper – 为 CLI 提供简单配置
-
使用 Cobra 和 Viper 的基本计算器 CLI
技术要求
为了轻松跟随本章中的代码,您需要执行以下操作:
-
安装 Cobra CLI:
github.com/spf13/cobra-cli
-
获取 Cobra 包:
github.com/spf13/cobra
-
获取 Viper 包:
github.com/spf13/viper
-
下载以下代码:
github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter04
Cobra – 用于构建现代 CLI 应用程序的库
Cobra 是一个 Go 库,用于构建强大且现代的 CLI 应用程序。它使得定义简单和复杂的嵌套命令变得容易。Cobra Command
对象的广泛字段列表允许您访问完整的自文档帮助和 man 页面。Cobra 还提供了一些额外的有趣功能,包括智能 shell 自动完成、CLI 框架、代码生成以及与 Viper 配置解决方案的集成。
Cobra 库提供的命令结构比从头开始编写的命令结构要强大得多。正如之前提到的,使用 Cobra CLI 有许多优点,因此我们将通过一些示例来展示其功能。从头开始使用 Cobra 创建 CLI 只需要三个步骤。首先,确保 cobra-cli
已正确安装。为您的项目创建一个新的文件夹,并按顺序执行以下步骤以设置新的 CLI:
- 切换到您的项目文件夹,
audiofile-cli
:
cd audiofile-cli
- 创建一个模块并初始化您的当前目录:
go mod init <模块路径>
- 初始化您的 Cobra CLI:
cobra-cli init
只需运行三个命令,ls
就会显示文件夹结构已经创建,并且可以添加命令。运行 main.go
文件会返回默认的长描述,但一旦添加了命令,audiofile CLI 的用法将显示帮助和示例。
如果你单独运行cobra-cli
以查看可用的选项,你会看到只有四个命令,add
、completion
、help
和init
。由于我们已经使用init
初始化了我们的项目,接下来,我们将使用add
来创建新命令的模板代码。
创建子命令
从 Cobra CLI 添加新命令最快的方法是运行cobra-cli
命令,add
。要获取有关此命令的更多详细信息,我们运行cobra-cli
add
–help
,这显示了运行add
命令的语法。
要尝试从上一章创建示例upload
命令,我们会运行以下命令:
cobra-cli add upload
让我们快速尝试调用为upload
命令生成的代码:
audiofile-cli go run main.go upload
upload called
默认情况下,返回upload called
输出。现在,让我们看看生成的代码。在同一个文件中,有一个init
函数,它将此命令添加到root
或entry
命令。
让我们清理这个文件并为我们upload
命令填写一些细节:
package cmd
import (
"github.com/spf13/cobra"
)
// uploadCmd represents the upload command
var uploadCmd = &cobra.Command{
Use: "upload [audio|video] [-f|--filename]
<filename>",
Short: "upload an audio or video file",
Long: `This command allows you to upload either an
audio or video file for metadata extraction.
To pass in a filename, use the -f or --filename flag
followed by the path of the file.
Examples:
./audiofile-cli upload audio -f audio/beatdoctor.mp3
./audiofile-cli upload video --filename video/
musicvideo.mp4`,
}
func init() {
rootCmd.AddCommand(uploadCmd)
}
现在,让我们为upload
命令创建这两个新的子命令,以指定音频或视频:
cobra-cli add audio
audio created at /Users/marian/go/src/github.com/
marianina8/audiofile-cli
cobra-cli add video
video created at /Users/marian/go/src/github.com/
marianina8/audiofile-cli
我们将audioCmd
和videoCmd
添加为uploadCmd
的子命令。仅包含生成代码的audio
命令需要修改,以便被识别为子命令。此外,我们还需要为audio
子命令定义文件名标志。audio
命令的init
函数将如下所示:
func init() {
audioCmd.Flags().StringP("filename", "f", "", "audio
file")
uploadCmd.AddCommand(audioCmd)
}
文件名标志的解析发生在Run
函数中。然而,我们希望在文件名标志缺失时返回错误,因此我们将audioCmd
上的函数更改为返回错误并使用RunE
方法:
RunE: func(cmd *cobra.Command, args []string) error {
filename, err := cmd.Flags().GetString("filename")
if err != nil {
fmt.Printf("error retrieving filename: %s\n",
err.Error())
return err
}
if filename == "" {
return errors.New("missing filename")
}
fmt.Println("uploading audio file, ", filename)
return nil
},
让我们先尝试这段代码,看看我们是否在未传递子命令时得到错误,以及当我们运行正确的示例命令时:
cobra-cli add upload
我们现在得到一个与upload
命令使用相关的错误消息:
go run main.go upload
This command allows you to upload either an audio or video
file for metadata extraction.
To pass in a filename, use the -f or --filename flag
followed by the path of the file.
Examples:
./audiofile-cli upload audio -f audio/beatdoctor.mp3
./audiofile-cli upload video --filename video/musicvideo.mp4
Usage:
audiofile-cli upload [command]
Available Commands:
audio sets audio as the upload type
video sets video as the upload type
让我们正确地使用简写或长写标志名称运行命令:
cobra-cli add upload audio [-f|--filename]
audio/beatdoctor.mp3
命令随后返回预期的输出:
go run main.go upload audio -f audio/beatdoctor.mp3
uploading audio file,audio/beatdoctor.mp3
我们已经为upload
命令创建了一个子命令audio
。现在,视频和音频的实现通过单独的子命令调用。
全局、本地和必需标志
Cobra 允许用户定义不同类型的标志:全局和本地标志。让我们快速定义每种类型:
-
全局:全局标志对分配给它的命令及其所有子命令可用
-
本地:本地标志仅对分配给它的命令可用
注意到视频和audio
子命令都需要一个标志来解析filename
字符串。可能更容易将此标志设置为uploadCmd
的全局标志。让我们从audioCmd
的init
函数中删除标志定义:
func init() {
uploadCmd.AddCommand(audioCmd)
}
相反,让我们将其添加为uploadCmd
的全局命令,以便它也可以被videoCmd
使用。现在uploadCmd
的init
函数将如下所示:
var (
Filename = ""
)
func init() {
uploadCmd.PersistentFlags().StringVarP(&Filename,
"filename", "f", "", "file to upload")
rootCmd.AddCommand(uploadCmd)
}
这个 PersistentFlags()
方法将标志设置为全局和持久,适用于所有子命令。运行命令以 upload
音频文件仍然按预期工作:
go run main.go upload audio -f audio/beatdoctor.mp3
uploading audio file, audio/beatdoctor.mp3
在 audio
子命令实现中,我们检查文件名是否已设置。如果我们使文件成为必需的,这是一个不必要的步骤。让我们将 init
改成这样:
func init() {
uploadCmd.PersistentFlags().StringVarP(&Filename,
"filename", "f", "", "file to upload")
uploadCmd.MarkPersistentFlagRequired("filename")
rootCmd.AddCommand(uploadCmd)
}
对于本地标志,命令将是 MarkFlagRequired("filename")
。现在让我们尝试不传递文件名标志来运行该命令:
go run main.go upload audio
Error: required flag(s) "filename" not set
Usage:
audiofile-cli upload audio [flags]
Flags:
-h, --help help for audio
Global Flags:
-f, --filename string file to upload
exit status 1
Cobra 会抛出错误,而无需手动检查是否解析了文件名标志。因为音频和视频命令是 upload
命令的子命令,它们需要新定义的持久文件名标志。正如预期的那样,会抛出一个错误来提醒用户文件名标志尚未设置。CLI 应用程序还可以在用户输入命令错误时帮助指导用户。
智能建议
默认情况下,如果用户输入了错误的命令,Cobra 将提供命令建议。一个例子是当命令输入为:
go run main.go uload audio
Cobra will automatically respond with some intelligent
suggestions:
Error: unknown command "uload" for "audiofile-cli"
Did you mean this?
upload
Run 'audiofile-cli --help' for usage.
exit status 1
要禁用智能建议,只需在根命令的 init
函数中添加 rootCmd.DisableSuggestions = true
行。要更改建议的 Levenshtein 距离,修改命令上的 SuggestionsMinimumDistance
的值。您还可以使用命令上的 SuggestFor
属性来明确指定建议,这对于逻辑上是替代品但 Levenshtein 距离不接近的命令是有意义的。另一种指导 CLI 的首次用户的方法是提供您应用程序的帮助和 man 页面。Cobra 框架提供了一个简单的方法来自动生成帮助和 man 页面。
自动生成的帮助和 man 页面
正如我们已经看到的,输入错误的命令,或者将 -h
或 –help
标志添加到命令中,将导致 CLI 返回帮助文档,这些文档是自动从 cobra.Command
结构内设置的详细信息生成的。此外,通过添加以下导入可以生成 man 页面:"github.com/spf13/cobra/doc"
。
注意
如何生成 man 页面文档的详细信息将在 第九章 中详细说明,开发的同理心一面,其中包括如何编写正确的帮助和文档。
为 CLI 提供动力
如您所见,使用 Cobra 库为 CLI 提供动力有许多好处,默认提供了许多功能。该库还附带了自己的 CLI,用于为新的应用程序生成脚手架以及添加命令,这些命令在 cobra.Command
结构中提供了所有可用的选项,使您能够构建一个强大且高度可定制的 CLI。
与从头开始编写 CLI 而不使用框架相比,您可以使用许多内置优势节省数小时的时间:命令脚手架、优秀的命令、参数和标志解析、智能建议以及自动生成的帮助文本和 man 页面。您还可以将 Cobra CLI 与 Viper 配对,以获得额外的优势来配置您的应用程序。
Viper – CLI 的简单配置
Steve Francia,Cobra 的作者,还创建了一个配置工具 Viper,以便轻松与 Cobra 集成。对于您在本地机器上运行的单个简单应用程序,您可能最初不需要配置工具。然而,如果您的应用程序可能运行在不同的环境中,这些环境需要不同的集成、API 密钥或更适用于配置文件而不是硬编码的一般自定义,Viper 将帮助简化配置应用程序的过程。
配置类型
Viper 允许您以多种方式设置应用程序的配置:
-
从配置文件中读取
-
使用环境变量
-
使用远程配置系统
-
使用命令行标志
-
使用缓冲区
这些配置类型接受的配置格式包括 JSON、TOML、YAML、HCL、INI、envfile 和 Java 属性格式。为了更好地理解,让我们逐一介绍每种配置类型。
配置文件
假设我们根据不同的环境需要连接到不同的 URL 和端口值。我们可以设置一个 YAML 配置文件 config.yml
,其外观如下,并存储在我们的应用程序的主文件夹中:
environments:
test:
url: 89.45.23.123
port: 1234
prod:
url: 123.23.45.89
port: 5678
loglevel: 1
keys:
assemblyai: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
使用代码读取配置并测试,打印出生产环境的 URL:
viper.SetConfigName("config) // config filename, omit
extension
viper.AddConfigPath(".") // optional locations for
searching for config files
err = viper.ReadInConfig() // using the previous
settings above, attempt to find and read in the
configuration
if err != nil { // Handle errors
panic(fmt.Errorf("err: %w \n", err))
}
fmt.Println("prod environment url:",
viper.Get("environments.prod.url"))
运行代码确认 Println
将返回 environments.prod.url
为 123.23.45.89
。
环境变量
配置也可以通过环境变量设置;请注意,Viper 对环境变量的识别是区分大小写的。在处理环境变量时,可以使用几种方法。
SetEnvPrefix
告诉 Viper,使用 BindEnv
和 AutomaticEnv
方法时使用的环境变量将被添加一个特定的唯一前缀。例如,假设测试 URL 是通过环境变量设置的:
viper.SetEnvPrefix("AUDIOFILE")
viper.BindEnv("TEST_URL")
os.Setenv("AUDIOFILE_TEST_URL", "89.45.23.123") //sets the
environment variable
fmt.Println("test environment url from environment
variable:", viper.Get("TEST_URL"))
如前所述,前缀 AUDIOFILE
附加到传递给 BindEnv
或 Get
方法的每个环境变量的开头。当运行前面的代码时,从 AUDIOFILE_TEST_URL
环境变量打印出的测试环境 URL 值为 89.45.23.123
,正如预期的那样。
命令行标志
Viper 支持通过几种不同类型的标志进行配置。
-
标志:使用标准 Go 库 flag 包定义的标志
-
Pflags:使用 Cobra/Viper 的
pflag
定义定义的标志 -
标志接口:满足 Viper 所需的标志接口的自定义结构
让我们详细检查这些内容。
标志
在标准 Go 标志包的基础上构建。Viper 的 flags
包扩展了标准标志包的功能,提供了额外的特性,例如环境变量支持和为标志设置默认值的能力。使用 Viper 标志,您可以定义字符串、布尔值、整数和浮点数类型的标志,以及这些类型的数组。
一些示例代码可能如下所示:
viper.SetDefault("host", "localhost")
viper.SetDefault("port", 1234)
viper.BindEnv("host", "AUDIOFILE_HOST")
viper.BindEnv("port", "AUDIOFILE_PORT")
在前面的示例中,我们为“host
”和“port
”标志设置了默认值,然后使用 viper.BindEnv
将它们绑定到环境变量。设置环境变量后,我们可以使用 viper.GetString("host")
和 viper.GetInt("port")
访问标志的值。
Pflags
pflag
是 Cobra 和 Viper 特定的标志包。值可以被解析和绑定。viper.BindPFFlag
用于单个标志,而 viper.BindPFFlags
用于标志集,用于在访问时绑定标志的值,而不是在定义时。
一旦解析并绑定标志,就可以使用 Viper 的 Get
方法在任何代码位置访问这些值。为了检索端口,我们会使用以下代码:
port := viper.GetInt("port")
在 init
函数中,您可以定义一个命令行标志集,并在访问值后绑定它们。以下是一个示例:
pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
pflag.Int("port", 1234, "port")
pflag.String("url", "12.34.567.123", "url")
plag.Parse()
viper.BindPFlags(pflag.CommandLine)
标志接口
Viper 还允许自定义标志,这些标志满足以下 Go 接口:FlagValue
和 FlagValueSet
。
FlagValue
接口如下:
type FlagValue interface {
HasChanged() bool
Name() string
ValueString() string
ValueType() string
}
Viper 接受的第二个接口是 FlagValueSet
:
type FlagValueSet interface {
VisitAll(fn func(FlagValue))
}
满足此接口的代码示例如下:
type customFlagSet struct {
flags []customFlag
}
func (set customFlagSet) VisitAll(fn func(FlagValue)) {
for i, flag := range set.flags {
fmt.Printf("%d: %v\n", i, flag)
fn(flag)
}
}
缓冲区
最后,Viper 允许用户使用缓冲区来配置他们的应用程序。使用第一个示例中配置文件中存在的相同值,我们将 YAML 数据作为原始字符串传递到一个字节切片中:
var config = []byte(`
environments:
test:
url: 89.45.23.123
port: 1234
prod:
url: 123.23.45.89
port: 5678
loglevel: 1
keys:
assemblyai: ad915a59802309238234892390482304
`)
viper.SetConfigType("yaml")
viper.ReadConfig(bytes.NewBuffer(config))
viper.Get("environments.test.url") // 89.45.23.123
现在您已经了解了配置您的命令行应用程序的不同类型或方式——从文件、环境变量、标志或缓冲区——让我们看看如何监视这些配置类型的实时更改。
监视实时配置更改
可以监视远程和本地配置。确保所有配置路径都已添加后,调用 WatchConfig
方法来监视任何实时更改,并通过实现一个函数传递给 OnConfigChange
方法来采取行动:
viper.OnConfigChange(func(event fsnotify.Event) {
fmt.Println("Config modified:", event)
})
viper.WatchConfig()
要监视远程配置的更改,首先使用 ReadRemoteConfig()
读取远程配置,然后在 Viper 配置实例上调用 WatchRemoteConfig()
方法。以下是一些示例代码:
var remoteConfig = viper.New()
remoteConfig.AddRemoteProvider("consul",
"http://127.0.0.1:2380", "/config/audiofile-cli.json")
remoteConfig.SetConfigType("json")
err := remoteConfig.ReadRemoteConfig()
if err != nil {
return err
}
remoteConfig.Unmarshal(&remote_conf)
以下是一个示例 goroutine,它将连续监视远程配置的更改:
go func(){
for {
time.Sleep(time.Second * 1)
_:= remoteConfig.WatchRemoteConfig()
remoteConfig.Unmarshal(&remote_conf)
}
}()
我认为利用配置库而不是从头开始有很多好处,这再次可以节省您数小时并加快您的开发过程。除了您可以为应用程序配置的不同方式外,您还可以提供远程配置并实时监视任何更改。这进一步创建了一个更健壮的应用程序。
使用 Cobra 和 Viper 的基本计算器 CLI
让我们将一些部分组合起来,使用 Cobra CLI 框架和 Viper 创建一个独立且简单的 CLI。一个我们可以轻松实现的基本想法是能够进行加、减、乘、除的基本计算器。这个演示的代码位于Chapter-4-Demo
仓库中,供您参考。
Cobra CLI 命令
命令是通过以下cobra-cli
命令调用来创建的:
cobra-cli add add
cobra-cli add subtract
cobra-cli add multiply
cobra-cli add divide
成功调用这些命令将生成每个命令的代码,以便我们填充细节。让我们展示每个命令以及它们各自相似和不同的地方。
添加命令
add
命令addCmd
被定义为cobra.Command
类型的指针。在这里,我们设置了命令的字段:
// addCmd represents the add command
var addCmd = &cobra.Command{
Use: "add number",
Short: "Add value",
Run: func(cmd *cobra.Command, args []string) {
if len(args) > 1 {
fmt.Println("only accepts a single argument")
return
}
if len(args) == 0 {
fmt.Println("command requires input value")
return
}
floatVal, err := strconv.ParseFloat(args[0], 64)
if err != nil {
fmt.Printf("unable to parse input[%s]: %v",
args[0], err)
return
}
value = storage.GetValue()
value += floatVal
storage.SetValue(value)
fmt.Printf("%f\n", value)
},
}
让我们快速浏览一下Run
字段,它是一个一等函数。在进行任何计算之前,我们检查args
。命令只接受一个数值字段;如果更多或更少,将打印用法说明并返回以下内容:
if len(args) > 1 {
fmt.Println("only accepts a single argument")
return
}
if len(args) == 0 {
fmt.Println("command requires input value")
return
}
我们取第一个且唯一的参数,返回它,将其设置在args[0]
中,并使用以下代码将其解析为一个扁平变量。如果转换到float64
值失败,则命令会打印出无法解析输入的消息,然后返回:
floatVal, err := strconv.ParseFloat(args[0], 64)
if err != nil {
fmt.Printf("unable to parse input[%s]: %v", args[0],
err)
return
}
如果转换成功,并且字符串转换没有返回错误,那么我们为floatVal
设置了一个值。在我们的基本计算器 CLI 中,我们将其存储在文件中,这是本例中存储它的最简单方式。storage
包和 Viper 在配置中的使用将在命令之后讨论。在更高层次上,我们从存储中获取当前值,将其应用于floatVal
,然后将其保存回存储:
value = storage.GetValue()
value += floatVal
storage.SetValue(value)
最后但同样重要的是,将值打印回用户:
fmt.Printf("%f\n", value)
这就结束了我们对add
命令的Run
函数的探讨。Use
字段描述了用法,Short
字段给出了命令的简要描述。这就结束了添加命令的浏览。在各自的命令上,减法、乘法和除法的Run
函数非常相似,所以我只会指出一些需要注意的差异。
减法命令
subtractCmd
的Run
函数使用了相同的代码,只有一个小的例外。我们不是将值添加到floatVal
,而是用以下行进行减法操作:
value -= floatVal
乘法命令
同样的代码用于multiplyCmd
的Run
函数,除了我们用以下行进行乘法操作:
value *= floatVal
除法命令
最后,同样的代码用于divideCmd
的Run
函数,除了用floatVal
进行除法操作:
value /= floatVal
清除命令
clear
命令将存储的值重置为0
。clearCmd
的代码简短且简单:
// clearCmd represents the clear command
var clearCmd = &cobra.Command{
Use: "clear",
Short: "Clear result",
Run: func(cmd *cobra.Command, args []string) {
if len(args) > 0 {
fmt.Println("command does not accept args")
return
}
storage.SetValue(0)
fmt.Println(0.0)
},
}
我们检查是否传递了任何 args
,如果是,则打印该命令不接受任何参数并返回。如果命令是 ./calculator clear
,则存储 0
值并将其打印回用户。
Viper 配置
现在,让我们讨论使用 Viper 配置的简单方法。为了跟踪对其应用了操作的值,我们需要存储此值。存储数据的最简单方法是将其保存在文件中。
存储包
在存储库中,有一个名为 storage/storage.go
的文件,其中包含以下代码来设置值:
func SetValue(floatVal float64) error {
f, err := os.OpenFile(viper.GetString("filename"),
os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(fmt.Sprintf("%f", floatVal))
if err != nil {
return err
}
return nil
}
此代码将数据写入 viper.GetString("filename")
返回的文件名。从文件获取值的代码如下:
func GetValue() float64 {
dat, err := os.ReadFile(viper.GetString("filename"))
if err != nil {
fmt.Println("unable to read from storage")
return 0
}
floatVal, err := strconv.ParseFloat(string(dat), 64)
if err != nil {
return 0
}
return floatVal
}
再次强调,获取文件名、读取、解析然后返回包含的数据的方法是相同的。
初始化配置
在 main
函数中,我们在执行命令之前调用 Viper 方法来初始化我们的配置:
func main() {
viper.AddConfigPath(".")
viper.SetConfigName("config")
viper.SetConfigType("json")
err := viper.ReadInConfig()
if err != nil {
fmt.Println("error reading in config: ", err)
}
cmd.Execute()
}
注意
AddConfigPath
方法用于设置 Viper 搜索配置文件的路径。SetConfigName
方法允许你设置配置文件名,不带扩展名。实际的配置文件是 config.json
,但我们传递 config
。最后,ReadInConfig
方法读取配置,使其在整个应用程序中可用。
配置文件
最后,配置文件 config.json
存储文件名值:
{
"filename": "storage/result"
}
此文件位置适用于基于 UNIX 或 Linux 的系统。根据您的平台进行更改,并亲自尝试演示!
运行基本计算器
要在 UNIX 或 Linux 上快速构建基本计算器,请运行 go build -o calculator main.go
。在 Windows 上,请运行 go build -o calculator.exe main.go
。
我在我的基于 UNIX 的终端上运行了这个应用程序,并得到了以下输出:
% ./calculator clear
0
% ./calculator add 123456789
123456789.000000
% ./calculator add 987654321
1111111110.000000
% ./calculator add 1
1111111111.000000
% ./calculator multiply 8
8888888888.000000
% ./calculator divide 222222222
40.000000
% ./calculator subtract 40
0.000000
希望这个简单的演示能让你对如何使用 Cobra CLI 加速开发以及 Viper 以简单方式配置应用程序有一个良好的理解。
摘要
本章向您介绍了构建现代 CLI 最受欢迎的库——Cobra——及其配置伙伴库——Viper。详细解释了 Cobra 包,并使用示例描述了 CLI 的用法。我们通过示例引导你使用 Cobra CLI 生成初始应用程序代码,添加新命令和修改脚手架,以自动生成有用的帮助和 man 页面。Viper 作为与 Cobra 完美搭配的配置工具,其许多选项也进行了详细描述。
在下一章中,我们将讨论如何处理 CLI 的输入——无论是命令、参数或标志形式的文本,还是允许你退出终端仪表板的控制字符。我们还将讨论处理这种输入的不同方式以及如何将结果输出给用户。
问题
-
如果你想定义一个可以被命令及其所有子命令访问的标志,应该定义什么样的标志以及如何定义?
-
Viper 接受哪些配置格式选项?
答案
-
在定义命令上的标志时使用
PersistentFlag()
方法创建的全局标志。 -
JSON、TOML、YAML、HCL、INI、envfile 和 Java 属性格式。
进一步阅读
- Cobra – Go 中现代 CLI 应用程序的框架 (
cobra.dev/
) 为 Cobra 提供了广泛的文档,包括使用 Cobra 的示例以及指向 Viper 文档的链接
第二部分:CLI 的内外
本部分重点介绍命令行应用程序的解剖结构以及它可以接收的不同类型的输入,例如子命令、参数和标志,以及其他输入,如标准输入、信号和控制字符。它还涵盖了处理数据的不同方法以及如何返回结果,包括与外部命令或 API 服务交互时处理错误和超时。本章还突出了 Go 的跨平台能力,使用诸如 os、time、path 和 runtime 等包。
本部分包含以下章节:
-
第五章,定义命令行进程
-
第六章,调用外部进程,处理错误和超时
-
第七章,为不同平台开发
第五章:定义命令行过程
命令行应用程序的核心能力是处理用户输入并返回一个结果,这个结果要么用户可以轻松理解,要么其他进程可以将其作为标准输入读取。在第一章《理解 CLI 标准》中,我们讨论了命令行应用程序的解剖结构,但本章将详细探讨其解剖结构的每个方面,分解不同类型的输入:子命令、参数和标志。此外,还将讨论其他输入:stdin
、信号和控制字符。
正如命令行应用程序可以接收许多类型的输入一样,处理数据的方法也有很多。本章不会让你失望——每种输入类型的处理示例将会随后提供。
最后,了解如何以人类和计算机都容易理解的方式返回结果,无论是成功时的数据还是失败时的错误,同样重要。
本章将介绍如何为每个最终用户输出数据以及 CLI 成功的最佳实践。我们将涵盖以下主题:
-
接收输入和用户交互
-
处理数据
-
返回结果输出并定义最佳实践
技术要求
为了轻松跟随本章中的代码,你需要做以下事情:
下载以下代码:github.com/PacktPublishing/Building-Modern-CLI-Applications-In-Go/tree/main/Chapter05/application
接收输入和用户交互
通过命令行应用程序接收输入的主要方法是通过其子命令、参数和选项,也称为stdin
、信号和控制字符。在本节中,我们将分解每种不同的输入类型以及何时以及如何与用户交互。
定义子命令、参数和标志
在我们开始描述主要输入类型之前,让我们重申一下解释每种输入类型在可预测性和熟悉性方面的通用位置的架构模式。在Cobra 框架文档中有对这种模式的出色描述。这是最好的解释之一,因为它将结构比作自然语言,就像说话和写作一样,语法需要正确解释:
APPNAME NOUN VERB –ADJECTIVE
注意
参数是名词,命令或子命令是动词。像任何修饰语一样,标志是形容词,并添加描述。
注意
大多数其他编程语言建议使用两个连字符而不是一个。Go 是独特的,因为单连字符和双连字符与内部标志包等价。然而,需要注意的是,Cobra CLI 标志区分单连字符和双连字符,其中单连字符用于短形式标志,双连字符用于长形式标志。
在前面的例子中,命令和参数,或 NOUN VERB
,也可以按 VERB NOUN
的顺序排列。然而,NOUN VERB
更常用。使用对你有意义的:
APPNAME ARGUMENT <COMMAND | SUBCOMMANDS> --FLAG
你可能会遇到命令行解析器的限制。然而,如果可能的话,使参数、标志和子命令的顺序无关。现在,让我们更详细地定义每个部分,并使用 Cobra 创建一个利用每种输入类型的命令。
命令和子命令
在非常基本层面上,命令是对命令行应用程序给出的特定指令。在我们刚才看到的模式中,这些是动词。想想我们自然说话的方式。如果我们与狗交谈,我们会给它下达命令,比如“roll over”、“speak”或“stay”。由于你定义了应用程序,你可以选择动词来定义指令。然而,在选择命令(和子命令)时,最重要的是记住名称要清晰且一致。
模糊性会给新用户带来很多压力。假设你有两个命令:yarn update
和 yarn upgrade
。对于一个第一次使用 yarn
的开发者来说,你认为这些命令的不同之处是否清晰?清晰至关重要。这不仅使你的应用程序更容易使用,而且还能让开发者感到安心。
当你对应用程序有一个广泛的了解时,你可以直观地确定在定义命令时更清晰、更简洁的语言。如果你的应用程序感觉有点复杂,你可以利用子命令进行简化,并在可能的情况下,为命令和子命令使用熟悉的单词。
让我们以 Docker 应用程序为例,说明子命令是如何明确定义的。Docker 有以下一系列管理命令:
-
container
用于管理容器 -
image
用于管理镜像
你会注意到,当你运行 docker
container
或 docker
image
时,会打印出用法,以及一系列子命令,你也会注意到,这两个命令中使用了几个子命令。它们保持一致。
Docker 的用户知道,动作(例如 ls
、rm
或 inspect
)与主题(例如 image
或 container
)相关。命令遵循预期的模式 "APPNAME ARGUMENT COMMAND"
– docker
image
ls
和 docker
container
ls
也是如此。请注意,docker
也使用了熟悉的 Unix 命令 – ls
和 rm
。始终在可能的情况下使用熟悉的命令。
使用 Cobra CLI,让我们创建两个命令,其中一个作为另一个的子命令。以下是我们要添加的第一个命令:
cobra-cli add command
command created at /Users/marian/go/src/github.com/
marianina8/application
然后,添加子命令:
cobra-cli add subcommand
subcommand created at /Users/marian/go/src/github.com/
marianina8/application
然后,通过修改默认行以在 commandCmd
上运行 AddCommand
来将其创建为子命令:
func init() {
commandCmd.AddCommand(subcommandCmd)
}
Cobra CLI 使得创建命令及其子命令变得极其简单。现在,当使用子命令调用命令时,我们得到确认子命令已被调用的信息:
./application command subcommand
subcommand called
现在,让我们了解参数。
参数
参数是名词——被命令作用的对象。它们位于命令的位置,通常位于命令之前。顺序不是严格的;只需在整个应用程序中保持顺序的一致性即可。然而,第一个参数是应用程序名称。
对于针对多个文件或多个输入字符串的操作,多个参数是可以接受的。以 rm
命令和删除多个文件为例。例如,rm arg1.txt arg2.txt arg3.txt
将对命令之后列出的多个文件(通过删除)进行操作。允许在合理的地方使用通配符。如果用户想要删除当前目录中的所有文本文件,那么 rm *.txt
的例子也应该可以工作。现在,考虑 mv
命令,它需要两个参数,用于源文件和目标文件。mv old.txt new.txt
的例子将把源文件 old.txt
移动到目标文件 new.txt
。通配符也可以与这个命令一起使用。
注意
对于不同的事情有多个参数可能意味着需要重新思考您构建命令的方式。这也可能意味着您可以使用标志来代替。
再次强调,熟悉对您有利。如果有标准名称,请使用它,您的用户会感谢您。以下是一些常见参数的例子:history
、tag
、volume
、log
和 service
。
让我们修改子命令生成的 Run
字段,以识别并打印出其参数:
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
fmt.Println("subcommand called")
} else {
fmt.Println("subcommand called with arguments: ",
args)
}
},
现在,当我们用参数运行相同的子命令时,以下输出将被打印出来:
./application command subcommand argument1 argument2
subcommand called with arguments: [argument1 argument2]
有趣的是,标志可以为参数提供更多的清晰度。一般来说,它确实需要更多的输入,但标志可以使正在发生的事情更加清晰。另一个额外的优点是,如果您决定更改接收输入的方式,添加或删除标志比修改现有命令(这可能会破坏某些东西)要容易得多。
标志
标志是形容词,为动作或命令添加描述。它们是有名称的参数,可以用不同的方式表示,带有或不带有用户指定的值:
-
A
-h
) -
A
--help
) -
A
--file audio.txt
, 或–-file=audio.txt
)
拥有所有标志的全长版本很重要——单个字母仅适用于常用标志。如果您为所有可用的标志使用单个字母,则可能有多个标志以相同的字母开头,并且这个单个字母对多个标志来说在直观上是有意义的。这可能会造成混淆,因此最好不要使单字母标志列表过于杂乱。
单字母标志也可以连接在一起。例如,考虑ls
命令。你可以运行ls -l -h -F
或ls -lhF
,结果是一样的。显然,这取决于所使用的命令行解析器,但由于 CLI 应用程序通常允许你连接单字母标志,因此允许这样做是个好主意。
最后,标志的顺序通常不是严格的,所以用户运行ls –lhF
、ls –hFl
或ls –Flh
,结果都是相同的。
作为示例,我们可以在根命令中添加一些标志,一个本地和一个持久,这意味着它对命令及其所有子命令都可用。在commandCmd
中,在init()
函数内,以下这些行正是这样做的:
commandCmd.Flags().String("localFlag", "", "a local string
flag")
commandCmd.PersistentFlags().Bool("persistentFlag", false,
"a persistent boolean flag")
在commandCmd
的Run
字段中,我们添加以下这些行:
localFlag, _ := cmd.Flags().GetString("localFlag")
if localFlag != "" {
fmt.Printf("localFlag is set to %s\n", localFlag)
}
在subcommandCmd
的Run
字段中,我们也添加以下这些行:
persistentFlag, _ := cmd.Flags().GetBool("persistentFlag")
fmt.Printf("persistentFlag is set to %v\n", persistentFlag)
现在,当我们编译代码并再次运行它时,我们可以测试这两个标志。请注意,有几种传递标志的方式,在两种情况下,结果都是相同的:
./application command --localFlag=”123”
command called
localFlag is set to 123
./application command --localFlag “123”
command called
localFlag is set to 123
持久标志,尽管在commandCmd
级别定义,但在subcommandCmd
中可用,并且当标志缺失时,使用默认值:
./application command subcommand
subcommand called
persistentFlag is set to false
./application command subcommand --persistentFlag
subcommand called
persistentFlag is set to true
现在,我们已经介绍了接收 CLI 输入的最常见方法:命令、参数和标志。接下来要介绍的方法包括管道、信号和控制字符,以及直接的用户交互。让我们现在深入探讨这些内容。
管道
在 Unix 中,管道将一个命令行应用程序的标准输出重定向到另一个命令行应用程序的标准输入。它由|
字符表示,它组合了两个或多个命令。一般结构是cmd1 | cmd2 |cmd3 | .... | cmdN
,cmd1
的标准输出是cmd2
的标准输入,依此类推。
创建一个简单的命令行应用程序,只做一件事并且做得很好,遵循 Unix 哲学。它简化了单个 CLI 的复杂性,因此你会看到许多可以将它们通过管道连接在一起的不同应用程序的示例。以下是一些示例:
-
cat file.txt | grep "word" | sort
-
sort list.txt | uniq
-
find . -type f –name main.go | grep audio
作为示例,让我们创建一个从常见应用程序接收标准输入的命令。让我们称它为piper
:
cobra-cli add piper
piper created at /Users/marian/go/src/github.com/
marianina8/application
对于新创建的piperCmd
的Run
字段,添加以下这些行:
reader := bufio.NewReader(os.Stdin)
s, _ := reader.ReadString('\n')
fmt.Printf("piped in: %s\n", s)
现在,使用一些管道输入编译并运行piper
命令:
echo “hello world” | ./application piper
piper called
piped in: hello world
现在,假设你的命令有一个标准输出被写入一个损坏的管道;内核将引发一个SIGPIPE
信号。这被作为命令行应用程序的输入接收,然后可以输出有关损坏管道的错误。除了从内核接收信号外,其他信号,如SIGINT
,可以由按下控制字符键组合(如Ctrl + C)来中断应用程序的用户触发。这只是信号和控制字符的一种类型,但将在下一节中讨论更多。
信号和控制字符
正如其名所示,信号是通过向命令行应用程序发出信号来通过信号传递特定和可操作输入的另一种方式。有时,这些信号可能来自内核,或者来自按下控制字符键组合的用户,从而触发发送给应用程序的信号。有两种不同类型的信号:
-
SIGBUS
、SIGFPE
和SIGSEGV
。 -
SIGHUP
、SIGINT
、SIGQUIT
和SIGPIPE
。
注意
一些信号,如SIGKILL
和SIGSTOP
,可能不会被程序捕获,因此使用os/signal
包进行自定义处理不会影响结果。
在信号方面有很多深入讨论的内容,但主要观点是它们只是接收输入的另一种方法。我们将专注于数据是如何被命令行应用程序接收的。以下是一个表格,解释了一些最常用的信号、控制字符组合及其描述:
图 5.1 – 信号及其相关键组合和描述的表格
以下是在rootCmd
中添加的两个函数调用,用于在接收到SIGINT
或SIGTSTP
信号时优雅地退出您的应用程序。现在调用rootCmd
的Execute
函数看起来是这样的:
func Execute() {
SetupInterruptHandler()
SetupStopHandler()
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
SetupInterruptHandler
代码如下:
func SetupInterruptHandler() {
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGINT)
go func() {
<-c
fmt.Println("\r- Wake up! Sleep has been
interrupted.")
os.Exit(0)
}()
}
类似地,SetupStopHandler
代码如下:
func SetupStopHandler() {
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTSTP)
go func() {
<-c
fmt.Println("\r- Wake up! Stopped sleeping.")
os.Exit(0)
}()
}
现在,我们需要一个命令来中断或停止应用程序。让我们使用 Cobra CLI 并添加一个sleep
命令:
cobra-cli add sleep
sleep created at /Users/marian/go/src/github.com/
marianina8/application
sleepCmd
的Run
字段被更改为运行一个无限循环,打印出一些 Z(Zzz
),直到信号中断sleep
命令并唤醒它:
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("sleep called")
for {
fmt.Println("Zzz")
time.Sleep(time.Second)
}
},
通过运行sleep
命令然后使用Ctrl + C,我们得到以下输出:
./application sleep
sleep called
Zzz
Zzz
- Wake up! Sleep has been interrupted.
- Wake up! Stopped sleeping.
再次尝试,但现在使用Ctrl + Z,我们得到以下输出:
./application sleep
sleep called
Zzz
Zzz
Zzz
- Wake up! Stopped sleeping.
您可以使用信号来优雅地中断或退出您的应用程序,或者在警报触发时采取行动。虽然命令、参数和标志是命令行应用程序最常见类型的输入,但考虑处理这些信号输入以创建更健壮的应用程序是很重要的。如果一个终端挂起并且收到SIGHUP
信号,您的应用程序可以保存最后状态的信息并在必要时处理清理。在这种情况下,虽然这种情况并不常见,但同样重要。
用户交互
虽然您的用户输入可以是命令、参数和标志的形式,但用户交互更多的是用户与应用程序之间的来回交互。假设用户错过了一个特定子命令所需的标志,您的应用程序可以提示用户并通过标准输入接收该标志的值。有时,而不是使用更标准的命令、参数和标志的输入,可以构建一个交互式命令行应用程序。
一个交互式 CLI 会提示输入,然后通过 stdin
接收输入。在 Go 中有一些有用的包用于构建交互式和可访问的提示。在以下示例中,我们将使用 github.com/AlecAivazis/survey
包。使用 survey
包有多种提示输入的有趣方式。一个 survey
命令会询问需要存储在变量中的问题。让我们将其定义为 qs
,一个 *survey.Question
类型的切片:
var qs = []*survey.Question{}
survey
可以提示用户输入不同类型的数据,如以下所示:
- 简单 文本输入
在非常基本的层面上,用户可以接收基本的文本输入:
{
Name: "firstname",
Prompt: &survey.Input{Message: "What is your first
name?"},
Validate: survey.Required,
Transform: survey.Title,
},
Output:
? What is your first name?
- 建议选项
此终端选项允许您为提示问题提供用户建议:
{
Name: "favoritecolor",
Prompt: &survey.Select{
Message: "What's your favorite color?",
Options: []string{"red", "orange", "yellow",
"green", "blue", "purple", "black", "brown",
"white"},
Default: "white",
},
Output:
? What is your favorite color? [tab for suggestions]
按下 Tab 键将显示可用选项:
? What is your favorite color? [Use arrows to
navigate, enter to select, type to complement
answer]
red
orange
yellow
green
blue
purple
black
brown
white
- 输入 多行
在接收输入时,有时按下 Return 键会立即将接收到的任何文本直接作为输入传递给程序。利用 survey
包允许您在接收输入之前输入多行:
{
Name: "story",
Prompt: &survey.Multiline{
Message: "Tell me a story.",
},
},
Output:
? Tell me a story [Enter 2 empty lines to finish]
A long line time ago in a faraway town, there lived a
princess who lived in a castle far away from the
city. She was always sleeping, until one day…
- 保护 密码输入
为了保护数据隐私,在输入个人信息时,survey
包会将字符替换为 *
符号:
{
Name: "secret",
Prompt: &survey.Password{
Message: "Tell me a secret",
},
},
Output:
? Tell me a secret: ************
- 确认 是 或 否
用户可以对命令提示符简单地回答是或否:
{
Name: "good",
Prompt: &survey.Confirm{
Message: "Are you having a good day?",
},
},
Output:
? Are you having a good day? (Y/n)
现在,让我们看看如何从复选框选项中选择。
- 从复选框选项中选择
在垂直复选框选项中可以选择多个选项。使用上下箭头导航选项,使用空格键选择:
{
Name: "favoritepies",
Prompt: &survey.MultiSelect{
Message: "What pies do you like:",
Options: []string{"Pumpkin", "Lemon Meringue",
"Cherry", "Apple", "Key Lime", "Pecan", "Boston
Cream", "Rhubarb", "Blackberry"},
},
},
Output:
? What pies do you like: [Use arrows to move, space to
select, <right> to all, <left> to none, type to
filter]
> [ ] Pumpkin
[ ] Lemon Meringue
[ ] Cherry
[ ] Apple
[ ] Key Lime
[ ] Pecan
….
使用以下方式创建一个新的 survey
命令:
cobra-cli
add survey
surveyCmd
的 Run
字段创建一个结构体,用于接收所有问题的答案:
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("survey called")
answers := struct {
FirstName string
FavoriteColor string
Story string
Secret string
Good bool
FavoritePies []string
}{}
然后 Ask
方法接收问题 qs
,并将所有问题的答案接收到一个指向 answers
结构体的指针中:
err := survey.Ask(qs, &answers)
if err != nil {
fmt.Println(err.Error())
return
}
最后,结果将被打印出来:
fmt.Println("*********** SURVEY RESULTS ***********")
fmt.Printf("First Name: %s\n", answers.FirstName)
fmt.Printf("Favorite Color: %s\n",
answers.FavoriteColor)
fmt.Printf("Story: %s\n", answers.Story)
fmt.Printf("Secret: %s\n", answers.Secret)
fmt.Printf("It's a good day: %v\n", answers.Good)
fmt.Printf("Favorite Pies: %s\n", answers.FavoritePies)
},
测试 survey
命令,我们得到以下结果:
./application survey
survey called
? What is your first name? Marian
? What's your favorite color? white
? Tell me a story.
I went to the dodgers game last night and
they lost, but I still had fun!
? Tell me a secret ********
? Are you having a good day? Yes
? What pies do you prefer: Pumpkin, Lemon Meringue, Key
Lime, Pecan, Boston Cream
*********** SURVEY RESULTS ***********
First Name: Marian
Favorite Color: white
Story: I went to the dodgers game last night and
they lost, but I still had fun!
Secret: a secret
It's a good day: true
Favorite Pies: [Pumpkin Lemon Meringue Key Lime Pecan
Boston Cream]
虽然这些示例只是 survey
包提供的许多输入提示中的一部分,但您可以访问 GitHub 页面查看所有可能选项的示例。玩弄提示让我想起了早期基于文本的角色扮演游戏,它们使用它们来提示玩家的角色。在了解了多种不同类型的输入后,无论是基于用户的、来自内核的,还是来自其他管道应用程序的,让我们讨论如何处理这些传入的数据。
处理数据
123
,我们可以通过利用 strconv
包的 Atoi
方法进行类型检查,该方法将 ASCII 字符串转换为整数:
val, err := strconv.Atoi("123")
如果字符串值无法转换,因为它不是整数的字符串表示,则会抛出错误。如果字符串是整数的表示,则整数值将存储在 val
变量中。
strconv
包可用于检查、转换许多其他类型,包括布尔值、浮点值和uint
值等。
另一方面,标志可以具有预定义的类型。在 Cobra 框架中,使用的是pflag
包,它只是标准 go flag
包的扩展。例如,当定义一个标志时,您可以将其明确定义为String
、Bool
、Int
或自定义类型。如果将前面的123
值作为Int
标志读取,可以使用以下代码行定义:
var intValue int
flag.IntVar(&intValue, "flagName", 123, "help message")
这同样适用于String
和Bool
标志。您甚至可以使用Var
方法创建具有自定义、特定接口的标志:
var value Custom
flag.Var(&value, "name", "help message")
确保Custom
结构满足pflag
包内定义的以下接口,以用于自定义标志:
// (The default value is represented as a string.)
type Value interface {
String() string
Set(string) error
Type() string
}
我将Custom
结构定义为以下内容:
type Custom struct {
Value string
}
因此,Set
方法简单地定义为以下内容:
func (c *Custom) Set(value string) error {
c.Value = value
return nil
}
将值传递给标志由flag: --name="custom value"
处理。然后使用String
方法打印值:
fmt.Println(cmd.Flag("name").Value.String())
它看起来是这样的:
custom value
除了传递可以转换为不同类型的字符串值之外,通常还会传递一个指向文件的路径。有多种从文件中读取数据的方法。让我们列出每种方法,以及处理这种读取文件方式的方法,以及每种方法的优缺点:
-
os.ReadFile
方法读取整个文件并返回其内容。在遇到文件结束(EOF)时不会出错:func all(filename string) { content, err := os.ReadFile(filename) if err != nil { fmt.Printf("Error reading file: %s\n", err) return } fmt.Printf("content: %s\n", content) }
-
优点:性能更快
-
缺点:在较短时间内消耗更多内存
-
file.Read
方法以预定的缓冲区大小读取缓冲区,并返回字节,这些字节在转换为字符串后可以打印。与ioutil.ReadFile
方法不同,从缓冲区读取file.Read
会在达到 EOF 时出错:func chunk(file *os.File) { const size = 8 // chunk size buff := make([]byte, size) fmt.Println("content: ") for { // read content to buffer of size, 8 bytes read8Bytes, err := file.Read(buff) if err != nil { if err != io.EOF { fmt.Println(err) } break } // print content from buffer fmt.Println(string(buff[:read8Bytes])) }
-
优点:易于实现,消耗内存少
-
缺点:如果选择的块不正确,可能会导致结果不准确,比较或分析数据时复杂性增加,以及潜在的错误传播。
-
split
函数。scanner.Text()
方法读取到下一个分隔每个扫描的标记——在以下示例中,是逐行。最后,scanner.Scan()
在遇到 EOF 时不会返回错误:func line(file *os.File) { scanner := bufio.NewScanner(file) lineCount := 0 for scanner.Scan() { fmt.Printf("%d: %s\n", lineCount, scanner.Text()) lineCount++ } if err := scanner.Err(); err != nil { fmt.Printf("error scanning line by line: %s\n", err) } }
-
优点:易于实现——一种直观的数据读取和输出方式。
-
缺点:处理非常大的文件可能会导致内存限制。如果数据不适合逐行处理,增加的复杂性可能会导致结果不准确。
-
Split
函数,将bufio.ScanWords
传递给Split
函数。这样就会定义每个单词之间的标记,并在每个标记之间进行扫描。再次强调,以这种方式扫描不会在 EOF 处遇到错误:func word(file *os.File) { scanner := bufio.NewScanner(file) scanner.Split(bufio.ScanWords) wordCount := 0 for scanner.Scan() { fmt.Printf("%d: %s\n", wordCount, scanner.Text()) wordCount++ } if err := scanner.Err(); err != nil { fmt.Printf("error scanning by words: %s\n", err) } }
-
优点:易于实现——一种直观的数据读取和输出方式
-
缺点:对于大文件来说,效率低下且耗时。如果数据不适合逐词处理,增加的复杂性可能会导致结果不准确。
选择处理从文件接收到的数据的方式取决于用例。此外,主要有三种数据处理类型:批量、在线和实时。
如您从名称中可以猜到的,批量处理收集或分批收集的类似任务,然后同时运行它们。在线处理需要互联网连接以到达 API 端点,以完全处理数据并返回结果。实时处理是在极短的时间内执行数据,数据被即时输出。
需要特定类型处理的不同用例的示例各不相同。银行交易、计费和报告通常使用批量处理。
在幕后使用 API 的 CLI 通常会需要互联网访问来处理在线处理。当及时性至关重要时,会使用实时处理,通常在制造业、欺诈检测和计算机视觉工具中。
一旦数据被处理,结果必须返回给用户或接收进程。在下一节中,我们将讨论返回输出的细节和定义返回数据的最佳实践。
返回结果输出和定义最佳实践
当从进程返回输出时,了解您返回数据给谁或什么非常重要。返回可读性强的输出至关重要。然而,为了确定您是否将数据返回给人类或机器,请检查您是否正在写入 TTY。还记得 TTY 吗?您可以参考第一章,理解 CLI 标准,其中我们讨论了 CLI 界面的历史和 TTY 术语,即电传打字机或电传打字机。
如果写入 TTY,我们可以检查stdout
文件描述符是否指向一个终端,并根据结果更改输出。
让我们检查以下代码块,该代码块检查stdout
文件描述符是否正在写入 TTY:
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() &
os.ModeCharDevice) != 0 {
fmt.Println("terminal")
} else {
fmt.Println("not a terminal")
}
让我们在名为tty
的命令的Run
方法中使用以下命令调用它:
./application tty
然后,输出如下:
terminal
然而,如果我们通过调用./application tty > file.txt
将输出管道到一个文件,那么文件的内容如下:
not a terminal
当然,当向人类返回输出时添加彩色 ASCII 文本是有意义的,但对于机器处理输出来说,这通常是无用的且是额外信息。
在编写输出时,始终将人类放在首位,特别是在可用性的方面。然而,如果机器可读的输出不影响可用性,那么输出机器可读的输出。由于文本流是 Unix 中的通用输入,程序通常通过管道链接在一起。输出通常是文本行,程序也期望以文本行作为输入。用户应该期望编写可以轻松使用 grep
搜索的输出。你无法确定输出将被发送到何处,以及哪些其他进程可能正在消耗输出。始终检查输出是否发送到终端,如果不是,则为另一个程序打印。然而,如果使用机器可读的输出会破坏可用性,但人类可读的输出又不能被另一个机器进程轻松处理,则默认使用人类可读的输出,并定义 –plain
标志以将此输出显示为机器可读的输出。以表格格式整洁的文本行可以轻松与 grep
和 awk
集成。这使用户可以选择定义输出的格式。
除了定义针对人类和机器的输出之外,通常还会添加一个标志来定义返回数据的特定格式。当请求以 JSON 格式返回数据时,使用 –json
标志,而请求 XML 格式时使用 –xml
标志。有一个 Unix 工具 jq
可以与程序的 JSON 输出集成。实际上,这个工具可以操作任何以 JSON 格式返回的数据。Unix 生态系统中的许多工具都利用了这一点,你也可以这样做。
从历史上看,由于许多较老的 Unix 程序是为脚本或其他程序编写的,通常在成功时不会返回任何输出。这可能会使用户感到困惑。不能总是假设成功,因此显示输出是理想的。没有必要详细说明,所以保持简明扼要且信息丰富。如果需要,定义一个 –quit
(或 –q
)标志可以抑制不必要的信息。
有时,CLI 可以跟踪状态。例如 git status
。这些信息需要对用户透明,因为它经常可以确认预期改变状态的预期结果。用户通过了解状态,可以理解他们可能的下一步行动。
这些后续步骤中的一些也可能被建议给用户。事实上,向用户提供建议是理想的,因为这会给人一种被引导的感觉,而不是在野外独自使用一个新的 CLI 应用程序。当用户第一次与 CLI 互动时,最好让学习体验类似于一个引导冒险。让我们以 GitHub 的 CLI 为例,快速给出一个例子。考虑当你需要将主分支合并到当前分支时。合并后,有时会出现冲突,当你在 git status
中检查时,CLI 会引导你:
On branch {branch name}
Your branch and 'origin/{branch name}' have diverged
And have 1 and 1 different commits each, respectively.
(use "git pull" to merge the remote branch into yours)
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge –abort" to abort the merge)
Unmerged paths:
(use "git add <file>..." to mark resolution)
Both modified: merge.json
注意
响应会提醒用户当前的分支和状态,以及建议用户可以采取的不同选项。并非所有 CLI 都处理状态,但当你这么做时,最好让它广为人知,并为用户提供清晰的下一步路径。
如果有任何与远程服务器的通信,文件的读取或写入(除了缓存),或任何跨越程序内部边界的其他操作,都要将这些操作通知用户。我喜欢 HomeBrew 的 CLI 上的install
命令。当你使用brew install
安装应用程序时,幕后发生的事情非常清晰。
当正在下载或创建文件时,会明确指出:
==> Downloading https://ghcr.io/v2/homebrew/core/dav1d/manifests/1.0.0
###########################################################
############# 100.0%
看看如何使用哈希标签来指定进度——它们以增加信息密度的这种方式使用 ASCII 字符。我喜欢Cellar
文件夹中文件旁边的冰镇啤酒图标。这会让你想到啤酒地窖中存在的所有酿酒配方。表情符号价值千言万语。
当引发错误时,文本以红色显示,目的是唤起紧迫感和警觉性。如果使用颜色,必须有意为之。绿色的失败或红色的成功对用户来说都是令人困惑的。我确信,就像利用 ASCII 艺术来增加信息密度一样,颜色也有同样的目的。绿色的成功不容易被误认为是失败,反之亦然。确保通过不频繁使用颜色来使重要信息突出。过多的颜色会使任何东西都难以突出。
然而,虽然颜色可能会让一些人兴奋,但它会惹恼其他人。可能有无数个原因让某人想在 CLI 中禁用颜色。无论出于什么原因继续在黑白世界中前行,都有特定的时候不应该使用颜色:
-
当将数据管道传输到另一个程序时
-
当设置
NO_COLOR
环境变量时 -
当
TERM
环境变量设置为dumb
时 -
当传递
–no-color
标志时 -
当你的应用的
MYAPP_NO_COLOR
环境变量被设置时
如果我们不允许使用颜色,那么动画也不允许!好吧,我不会告诉你该怎么做,只是让你亲自试试——通过stdout
将动画管道传输到文件。我敢打赌!你可能会得到一些很棒的 ASCII 艺术,但它会显得很忙,难以理解数据。目标是清晰。使用 ASCII 艺术、颜色意图和动画来增加信息密度,我们最终需要明白我们需要使用所有人都理解清晰的文字。从第一次使用你的 CLI 的用户的角度考虑你的措辞。用你的话引导用户。
至于打印日志输出,只有在详细模式下才这么做,详细模式由–verbose
标志和简写的–v
表示。不要使用stderr
文件描述符作为日志文件。
如果 CLI 一次输出大量文本,例如 git diff
,则使用分页器。谢天谢地。这使得逐页查看输出以审查差异变得容易得多,而不是一次性接收所有文本。这只是 GitHub 为其用户提供非常周到 CLI 的许多方式之一。
最后,让错误突出显示——如果发生错误,使用红色文本或红色 x 表情符号来增加理解。如果颜色被禁用,那么使用文本来传达已发生错误,并提供下一步的建议——而且,更好的是,通过电子邮件或网站提供支持途径。
摘要
在本章中,你学习了关于命令行进程的知识——接收输入、处理数据和返回输出。已经讨论了最流行的不同类型输入:从 子命令、参数和标志到信号和控制字符。
我们创建了一个交互式调查来收集用户的输入,并讨论了数据处理。我们还学习了如何处理的第一步:转换参数字符串数据,转换和检查类型,接收来自键入和自定义标志的数据,最后,从文件中读取数据。
我们还简要介绍了不同类型处理的不同类型:批量、在线和实时处理。最终,用例将引导你了解你需要什么样的输入,以及是否需要在批量、通过互联网或实时运行任务。
返回输出与接收输出一样重要,甚至更重要!这是你为用户提供更愉快体验的机会。现在,你首先是为人类开发,你有机会站在他们的立场上。
你希望以何种方式接收数据,让你感到放心,理解失败以及下一步该做什么,以及在哪里找到帮助?并非所有进程都能成功运行,所以至少让用户感到他们正在通往成功的道路上。在 第二部分,第六章,调用外部进程,处理错误和超时,我们将更详细地讨论命令行进程,重点关注外部进程以及如何有效地处理超时和错误,并将它们传达给用户。
问题
-
对于 CLI 程序,是参数或标志更受欢迎?为什么?
-
哪个快捷键组合可以中断计算机进程?
-
可以添加到你的 CLI 的哪个标志来将输出修改为纯文本输出,以便可以轻松地与
grep
和awk
等工具集成?
答案
-
对于 CLI 程序,标志更受欢迎,因为它们使得添加或删除功能变得容易得多。
-
Ctrl + C.
-
–plain
标志可以添加到输出中,以删除任何不必要的数据。
进一步阅读
-
NO_COLOR (https://no-color.org/)
-
12 Factor CLI Apps (https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46)
第六章:调用外部进程并处理错误和超时
许多命令行应用程序会与其他外部命令或 API 服务交互。本章将指导你如何调用这些外部进程,以及当它们发生时如何处理超时和其他错误。本章将从深入探讨os/exec
包开始,该包包含了创建调用外部进程的命令所需的一切,它为你提供了创建和运行命令的多种选项。你将学习如何从标准输出和标准错误管道中检索数据,以及为类似用途创建额外的文件描述符。
另一个外部进程涉及调用外部 API 服务端点。net/http
包被讨论,这是我们开始定义客户端的地方,然后创建它执行的请求。我们将讨论请求可以创建和执行的不同方式。
当调用任何类型的进程时,都可能发生超时和其他错误。我们将以探讨如何在我们的代码中捕获超时和错误的发生结束本章。重要的是要意识到这些事情可能发生,因此编写能够处理它们的代码很重要。错误发生时采取的具体操作取决于用例,因此我们只将讨论捕获这些情况的代码。总结来说,我们将涵盖以下主题:
-
调用外部进程
-
与 REST API 交互
-
处理预期的 – 超时和其他错误
技术要求
为了理解并运行本章中分享的示例,你需要一个 UNIX 操作系统。
你也可以在 GitHub 上找到代码示例,链接为github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter06
。
调用外部进程
在你的命令行应用程序中,你可能需要调用一些外部进程。有时,第三方工具提供了作为包装器的 Golang 库。例如,Go CV,gocv.io/
,是针对 OpenCV(一个开源计算机视觉库)提供的 Golang 包装器。然后是 GoFFmpeg,github.com/xfrr/goffmpeg
,它是针对 FFmpeg(一个用于录制、转换和流式传输音频和视频文件的库)提供的包装器。通常,你需要安装底层工具,如 OpenCV 或 FFmpeg,然后库与它交互。调用这些外部进程意味着导入包装器包,并在你的代码中调用其方法。通常,当你深入研究代码时,你会发现这些库为 C 代码提供了包装器。
除了导入外部工具的包装器之外,你也可以使用os/exec
Golang 库来调用外部应用程序。这是库的主要目的,在本节中,我们将深入探讨如何使用它来调用外部应用程序。
首先,让我们通过每个示例来回顾一下 os/exec
包中存在的变量、类型和函数。
os/exec 包
通过深入研究 exec
包,你会发现它是对 os.StartProcess
方法的包装,这使得处理标准输入和标准输出的重映射、通过管道连接输入和输出以及处理其他修改变得更加容易。
为了清晰起见,重要的是要注意,这个包不会调用操作系统的 shell,因此不处理通常由 shell 处理的任务:展开全局模式、管道或重定向。如果需要展开全局模式,可以直接调用 shell 并确保转义值以使其安全,或者也可以使用路径或文件路径的 Glob
函数。要展开字符串中存在的任何环境变量,请使用 os
包的 ExpandEnv
函数。
在以下小节中,我们将开始讨论 os/exec
包中存在的不同变量、类型、函数和方法。
变量
ErrNotFound
是当在应用程序的 $``PATH
变量中找不到可执行文件时返回的错误变量。
类型
Cmd
是一个表示外部命令的结构体。定义这种类型的变量只是为了准备运行命令。一旦通过 Run
、Output
或 CombinedOutput
方法运行了 Cmd
类型的变量,它就不能再被重用。这个 Cmd
结构体上还有几个我们可以详细说明的字段:
-
Path string
这是唯一必需的字段。它是要运行的命令的路径;如果路径是相对的,那么它将相对于存储在Dir
字段中的值。 -
Args []string
这个字段包含命令的参数。Args[0]
表示命令。Path
和Args
在运行命令时设置,但如果Args
是nil
或空的,则在执行期间只使用{Path}
。 -
Env []string
Env
字段表示要运行的命令的环境。切片中的每个值都必须以下列格式:"key=value"
。如果值为空或nil
,则命令使用当前环境。如果切片有重复的键值,则使用重复键的最后一个值。 -
Dir string
Dir
字段表示命令的工作目录。如果没有设置,则使用当前目录。 -
Stdin io.Reader
Stdin
字段指定了命令进程的标准输入。如果数据是nil
,则进程从os.DevNull
(空设备)读取。然而,如果标准输入是*os.File
,则内容将通过管道传输。在执行过程中,一个 goroutine 从标准输入读取,然后将该数据发送到命令。Wait
方法将在 goroutine 开始复制之前不会完成。如果它没有完成,那么可能是因为 文件结束(EOF)、读取或写入管道错误。 -
Stdout io.Writer
Stdout
字段指定了命令进程的标准输出。如果标准输出是nil
,则进程连接到os.DevNull
空设备。如果标准输出是*os.File
,则输出将发送到它。在执行过程中,一个 goroutine 从命令进程读取并发送数据到写入器。 -
Stderr io.Writer
Stderr
字段指定了命令进程的标准错误输出。如果标准错误是nil
,则进程连接到os.DevNull
空设备。如果标准错误是*os.File
,则错误输出将发送到它。在执行过程中,一个 goroutine 从命令进程读取并发送数据到写入器。 -
ExtraFiles []*os.File
ExtraFiles
字段指定了命令进程继承的附加文件。它不包括标准输入、标准输出或标准错误,因此如果非空,条目 x 成为 3+x 文件描述符。此字段在 Windows 上不受支持。 -
SysProcAttr *syscall.SysProcAttr
SysProcAttr
保留系统特定的属性,这些属性作为os.ProcAttr
的Sys
字段传递给os.StartProcess
。 -
Process *os.Process
Process
字段在命令运行后持有底层进程。 -
ProcessState *os.ProcessState
ProcessState
字段包含有关进程的信息。在调用等待或运行方法后变得可用。
方法
以下是在 exec.Cmd
对象上存在的方法:
-
func (c *Cmd) CombinedOutput() ([]byte, error)
CombinedOutput
方法将标准输出和标准错误都返回到 1 字节字符串输出中。 -
func (c *Cmd) Output ([]byte, error)
Output
方法仅返回标准输出。如果发生错误,它通常将是*ExitError
类型,如果命令的标准错误c.Stderr
是nil
,则Output
将填充ExitError.Stderr
。 -
func (c *Cmd) Run() error
Run
方法开始执行命令并等待其完成。如果没有问题复制标准输入、标准输出或标准错误,并且命令以零状态退出,则返回的错误将是nil
。如果命令以错误退出,它通常将是*ExitError
类型,但也可能是其他错误类型。 -
func (c *Cmd)
Start() error
-
Start
方法将启动执行命令而不会等待其完成。如果Start
方法运行成功,那么c.Process
字段将被设置。然后c.Wait
字段将返回退出代码并在完成后释放资源。 -
func (c* Cmd) StderrPipe() (io.ReadCloser, error)
StderrPipe
返回一个连接到命令标准错误的管道。不需要关闭管道,因为Wait
方法将在命令退出时关闭管道。不要在所有从标准错误管道读取完成之前调用Wait
方法。不要与Run
方法一起使用此命令,原因相同。 -
func (c* Cmd) StdinPipe() (io.WriteCloser, error
)StdinPipe
返回一个连接到命令标准输入的管道。在Wait
之后,管道将被关闭,并且命令将退出。然而,有时命令将不会运行,直到标准输入管道被关闭,因此您可以调用Close
方法来提前关闭管道。 -
func (c *Cmd) StdoutPipe() (io.ReadCloser, error
)StdoutPipe
方法返回一个连接到命令标准输出的管道。不需要关闭管道,因为Wait
将在命令退出时关闭管道。同样,不要在所有从标准输出管道读取完成之前调用Wait
。不要使用此命令与Run
方法,原因相同。 -
func (c *Cmd) String() string
String
方法返回一个人类可读的命令描述,c
,用于调试目的。具体的输出可能在不同版本的 Go 发布之间有所不同。此外,不要将其用作 shell 的输入,因为它不适合这个目的。 -
func (c *Cmd) Wait() error
Wait
方法等待任何复制到标准输入、标准输出或标准错误完成,以及命令退出。要使用Wait
方法,命令必须是由Start
方法而不是Run
方法启动的。如果没有复制管道的错误,并且进程以0
退出状态码退出,则返回的错误将是nil
。如果命令的Stdin
、Stdout
或Stderr
字段未设置为*os.File
,则Wait
还确保相应的输入-输出循环过程也完成。
Error
是一个结构体,表示当LookPath
函数无法识别文件为可执行文件时返回的错误。我们将详细定义这个特定错误类型的几个字段和方法。
以下是在Error
类型上存在的几种方法:
func (e *Error) Unwrap() error
如果返回的错误是错误链,则可以使用Unwrap
方法来展开它并确定它是哪种错误。
ExitError
是一个结构体,表示命令未成功退出时的错误。*os.ProcessState
被嵌入到这个结构体中,因此所有值和字段也将对ExitError
类型可用。最后,我们可以更详细地定义这种类型的几个字段:
Stderr []byte
此字段保存了如果没有从Cmd.Output
方法收集的标准错误输出响应的集合。如果错误输出足够长,Stderr
可能只包含前缀和后缀。中间将包含关于省略的字节数的文本。为了调试目的,如果您想包含整个错误消息,请重定向到Cmd.Stderr
。
以下是在ExitError
类型上存在的几种方法:
func (e *ExitError) Error() string
Error
方法返回表示退出错误的字符串。
函数
以下是在os/exec
包中存在的函数:
-
func LookPath(file string) (string, error)
LookPath
函数检查文件是否是可执行的并且可以找到。如果文件是相对路径,则相对于当前目录。 -
func Command(name string, arg ...string) *Cmd
Command
函数返回一个只设置了路径和参数的Cmd
结构体。如果name
有路径分隔符,则使用LookPath
函数来确认文件已找到且可执行。否则,直接使用name
作为路径。此函数在 Windows 上的行为略有不同。例如,它将整个命令行作为一个单独的字符串执行,包括引号内的参数,然后处理自己的解析。 -
func CommandContext(ctx context.Context, name string, arg ...string) *Cmd
与Command
函数类似,但接收上下文。如果上下文在命令完成之前执行,则将通过调用os.Process.Kill
来杀死进程。
现在我们已经深入研究了os/exec
包以及执行函数所需的 struct、函数和方法,让我们实际在代码中使用它们来执行外部函数。让我们使用Cmd
结构体创建命令,同时使用Command
和CommandContext
函数。然后我们可以取一个示例命令,使用Run
、Output
或CombinedOutput
方法之一来运行它。最后,我们将处理这些方法通常返回的一些错误。
注意
如果你想跟随即将出现的示例,在Chapter-6
仓库中,安装必要的应用程序。在 Windows 上,使用.\build-windows.p1
PowerShell 脚本。在 Darwin 上,使用make install
命令。一旦应用程序安装完毕,运行go run main.go
。
使用 Cmd 结构体创建命令
创建命令有几种不同的方式。第一种方式是在exec
包中的Cmd
结构体。
使用 Cmd 结构体
我们首先使用未设置的Cmd
结构体定义cmd
变量。以下代码位于/examples/command.go
中的CreateCommandUsingStruct
函数:
cmd := exec.Cmd{}
每个字段都是单独设置的。路径是通过filepath.Join
设置的,这在不同的操作系统上都是安全的:
cmd.Path = filepath.Join(os.Getenv("GOPATH"), "bin", "uppercase")
每个字段都是单独设置的。Args
字段包含命令名,位于Args[0]
位置,后面跟其余要传递的参数:
cmd.Args = []string{"uppercase", "hack the planet"}
以下三个文件描述符被设置 - Stdin
、Stdout
和Stderr
:
cmd.Stdin = os.Stdin // io.Reader
cmd.Stdout = os.Stdout // io.Writer
cmd.Stderr = os.Stderr // io.Writer
然而,有一个writer
文件描述符被传递到ExtraFiles
字段。这个特定的字段被命令进程继承。需要注意的是,如果不将 writer 传递到ExtraFiles
,管道将无法工作,因为子进程必须获取 writer 才能写入它:
reader, writer, err := os.Pipe()
if err != nil {
panic(err)
}
cmd.ExtraFiles = []*os.File{writer}
if err := cmd.Start(); err != nil {
panic(err)
}
在实际调用的命令中,cmd/uppercase/uppercase.go
中有代码,它接受命令名之后的第一个参数并将其转换为大写。然后新的大写文本被编码到管道或额外的文件描述符中:
input := os.Args[1:]
output := strings.ToUpper(strings.Join(input, ""))
pipe := os.NewFile(uintptr(3), "pipe")
err := json.NewEncoder(pipe).Encode(output)
if err != nil {
panic(err)
}
回到 CreateCommandUsingStruct
函数,现在可以通过管道的 read
文件描述符读取编码到管道中的值,然后使用以下代码输出:
var data string
decoder := json.NewDecoder(reader)
if err := decoder.Decode(&data); err != nil {
panic(err)
}
fmt.Println(data)
我们现在知道了一种使用 Cmd
结构体创建命令的方法。所有内容都可以在初始化命令的同时一次性定义,这取决于您的偏好。
使用 Command 函数
创建命令的另一种方式是使用 exec.Command
函数。以下代码位于 /examples/command.go
中的 CreateCommandUsingCommandFunction
:
cmd := exec.Command(filepath.Join(os.Getenv("GOPATH"), "bin", "uppercase"), "hello world")
reader, writer, err := os.Pipe()
if err != nil {
panic(err)
}
exec.Command
函数将命令的文件路径作为第一个参数。可选地传递一个表示参数的字符串切片作为剩余参数。函数的其余部分相同。因为 exec.Command
不接受任何额外的参数,所以我们同样在原始变量初始化之外定义了 ExtraFiles
字段。
运行命令
现在我们知道了如何创建命令,有多种不同的方式来运行或开始运行一个命令。虽然这些方法在本节前面已经详细描述过,但我们现在将分享使用每个方法的示例。
使用 Run 方法
如前所述,Run
方法启动命令进程,然后等待其完成。此代码从 main.go
文件中调用,但可以在 /examples/running.go
中找到。在这个例子中,我们调用了一个名为 lettercount
的不同命令,该命令计算字符串中的字母数,然后打印出结果:
cmd := exec.Command(filepath.Join(os.Getenv("GOPATH"), "bin", "lettercount"), "four")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
var count int
再次,我们使用 ExtraFiles
字段传递一个额外的文件描述符以写入结果:
reader, writer, err := os.Pipe()
if err != nil {
panic(err)
}
cmd.ExtraFiles = []*os.File{writer}
if err := cmd.Run(); err != nil {
panic(err)
}
if err := json.NewDecoder(reader).Decode(&count); err != nil {
panic(err)
}
最终使用以下代码打印结果:
fmt.Println("letter count: ", count)
使用 Start 命令
Start
方法类似于 Run
方法;然而,它不会等待进程完成。您可以在 examples/running.go
中找到使用 Start
命令的代码。大部分是相同的,但您将替换包含 cmd.Run
的代码块,如下所示:
if err := cmd.Start(); err != nil {
panic(err)
}
err = cmd.Wait()
if err != nil {
panic(err)
}
调用 cmd.Wait
方法非常重要,因为它释放了命令进程占用的资源。
使用 Output 命令
如方法名所示,Output
方法返回所有已通过标准输出管道传入的内容。将命令推送到标准输出管道的最常见方式是通过 fmt
包中的任何打印方法。为 lettercount
命令在 main
函数的末尾添加了一行:
fmt.Printf("successfully counted the letters of \"%v\" as %d\n", input, len(runes))
在使用此 Output
方法的代码中,唯一的区别可以在 examples/running.go
文件下的 OutputMethod
函数中找到,即这一行代码:
out, err := cmd.Output()
out
变量是一个字节切片,稍后可以将其转换为字符串以打印输出。这个变量捕获标准输出,当函数运行时,显示的输出如下:
output: successfully counted the letters of "four" as 4
使用 CombinedOutput 命令
如方法名所示,CombinedOutput
方法返回标准输出和标准错误管道数据的组合输出。在lettercount
命令的main
函数末尾添加一行:
fmt.Fprintln(os.Stderr, "this is where the errors go")
与上一个函数的调用和当前函数CombinedOutputMethod
之间的唯一重大区别是这一行:
CombinedOutput, err := cmd.CombinedOutput()
同样,它返回一个字节切片,但现在包含标准错误和标准输出的组合输出。
在 Windows 上执行命令
与示例并列的是以_windows.go
结尾的类似文件。在先前的示例中,需要注意的主要事项是ExtraFiles
在 Windows 上不受支持。这些针对 Windows 的特定和简单示例执行了一个指向google.com
的外部ping
命令。让我们看一下其中一个:
func CreateCommandUsingCommandFunction() {
cmd := exec.Command("cmd", "/C", "ping", "google.com")
output, err := cmd.CombinedOutput()
if err != nil {
panic(err)
}
fmt.Println(string(output))
}
就像我们为 Darwin 编写的命令一样,我们可以使用exec.Command
函数或结构体来创建命令,并调用Run
、Start
、Wait
、Output
和CombinedOutput
等操作。
此外,对于分页,Linux 和 UNIX 机器上使用less
,而 Windows 上使用more
。让我们快速展示这段代码:
func Pagination() {
moreCmd := exec.Command("cmd", "/C", "more")
moreCmd.Stdin = strings.NewReader(blob)
moreCmd.Stdout = os.Stdout
moreCmd.Stderr = os.Stderr
err := moreCmd.Run()
if err != nil {
panic(err)
}
}
var (
blob = `
…
`
)
同样,我们可以使用exec.Command
方法传递名称和所有参数。我们还把长文本传递到moreCmd.Stdin
字段。
因此,os/exec
包提供了创建和运行外部命令的不同方式。无论您是使用exec.Command
方法创建一个快速命令,还是直接使用exec.Cmd
结构体创建一个命令然后运行Start
命令,您都有选择。最后,您可以分别或一起检索标准输出和错误输出。了解os/exec
包将使您能够轻松地从 Go 命令行应用程序成功运行外部命令。
与 REST API 交互
通常,如果公司或用户已经创建了一个 API,命令行应用程序将向 REST API 或 gRPC 端点发送请求。让我们首先谈谈使用 REST API 端点。了解net/http
包非常重要。这是一个相当大的包,包含许多类型、方法和函数,其中许多用于服务器端开发。在这种情况下,命令行应用程序将是 API 的客户端,因此我们不会详细讨论每个部分。不过,我们将从客户端的角度探讨一些基本用例。
GET 请求
让我们回顾一下第三章中的代码,构建音频元数据 CLI。在/cmd/cli/command/get.go
文件中 CLI 命令代码的Run
命令中,有一个调用相应 API 请求端点的代码片段,使用的是GET
方法:
params := "id=" + url.QueryEscape(cmd.id)
path := fmt.Sprintf("http://localhost/request?%s", params)
payload := &bytes.Buffer{}
method := "GET"
client := cmd.client
注意,在前面的代码中,我们取了字段值id
,它已经在cmd
变量上设置,并将其作为参数传递到 HTTP 请求中。考虑要传递的标志和参数,这些参数将用作 HTTP 请求的参数。以下代码执行请求:
req, err := http.NewRequest(method, path, payload)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
最后,响应被读取到一个字节字符串中并打印出来。在访问响应体之前,检查响应或体是否为nil
。这可以让你避免一些未来的麻烦:
b, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Println(string(b))
return nil
然而,在现实中,会有更多的事情要做:
-
如果返回
200
OK
,则我们可以返回输出,因为它是一个成功的响应。否则,在下一节“处理预期的 – 超时和错误”中,我们将讨论如何处理其他响应。 -
记录响应:如果我们认为响应不包含任何敏感数据,理想情况下我们会记录响应。这些详细信息可以写入日志文件或在详细模式下输出。
-
存储响应:有时,响应可能会存储在本地数据库或缓存中。
-
标头中的
Content-Type
设置为application/json
,我们将 JSON 响应反序列化到结构体中。
目前,在 audiofile 应用程序中,我们将数据转换成如下Audio
结构体:
var audio Audio
If err := json.Unmarshal(b, &audio); err != nil {
fmt.Println("error unmarshalling JSON response"
}
但如果响应体不是 JSON 格式,而是其他内容类型呢?在一个完美的世界里,我们会有一份 API 文档,它会告诉我们预期的内容,这样我们就可以相应地处理它。或者,你可以使用以下方法先检查确认类型:
contentType := http.DetectContentType(b) // b are the bytes from reading the resp.Body
在互联网上快速搜索 HTTP 内容类型将返回一个长长的列表。在前面的示例中,音频公司可能已经决定返回一个Content-Type
值为audio/wave
。在这种情况下,我们既可以下载也可以流式传输结果。net/http
包中定义了不同的 HTTP 方法类型作为常量:
-
MethodGet
: 用于请求数据 -
MethodPost
: 用于插入数据 -
MethodPut
: 请求是幂等的,用于插入或更新整个资源 -
MethodPatch
: 与MethodPut
类似,但只发送部分数据以更新,而不修改整个资源 -
MethodDelete
: 用于删除或移除数据 -
MethodConnect
: 在与代理通信时使用,当 URI 以https://
开头时 -
MethodOptions
: 用于描述与目标之间的通信选项或允许的方法 -
MethodTrace
: 通过提供目标路径上的消息回环用于调试
数据返回的方法类型和内容类型有很多可能性。在前面的Get
示例中,我们使用客户端的Do
方法调用该方法。另一种选择是使用http.Get
方法。如果我们使用该方法,那么我们将使用以下代码来执行请求:
resp, err := http.Get(path)
if err != nil {
return err
}
defer resp.Body.Close()
类似地,与其使用client.Do
方法进行 POST 操作或提交表单,不如使用特定的http.Post
和http.PostForm
方法。有时,一个方法更适合你所做的事情。在这个时候,重要的是要了解你的选项。
分页
假设请求返回了大量的数据。与其一次性接收所有数据而使客户端过载,通常分页是一个选项。调用中可以传递两个作为参数的字段:
-
Limit
:要返回的对象数量 -
Page
:返回多页结果的游标
我们可以内部定义这些,然后按照以下方式构建路径:
path := fmt.Sprintf("http://localhost/request?limit=%d&page=%d", limit, page)
如果你使用外部 API,请确保使用适当的分页和用法参数构建它们的文档。这只是一个通用示例。实际上,还有几种其他实现分页的方法。你可以在循环中发送额外的请求,增加页面数,直到检索到所有数据。
然而,从命令行方面,你可以在分页后返回所有数据,也可以在 CLI 端处理分页。在从 HTTP Get
请求收集了大量数据后,在客户端处理分页的一种方法是将数据管道化。这些数据可以被管道化到操作系统的分页命令中。对于 UNIX,less
是分页命令。我们创建命令,然后将字符串输出管道化到Stdin
管道。这段代码可以在examples/pagination.go
文件中找到。类似于我们分享的其他示例,在创建命令时,我们创建一个管道并将写入器作为额外的文件描述符传递给命令,以便可以写出数据:
pagesCmd := exec.Command(filepath.Join(os.Getenv("GOPATH"), "bin", "pages"))
reader, writer, err := os.Pipe()
if err != nil {
panic(err)
}
pagesCmd.Stdin = os.Stdin
pagesCmd.Stdout = os.Stdout
pagesCmd.Stderr = os.Stderr
pagesCmd.ExtraFiles = []*os.File{writer}
if err := pagesCmd.Run(); err != nil {
panic(err)
}
再次,从读取器中解码的数据被编码到data
string
变量中:
var data string
decoder := json.NewDecoder(reader)
if err := decoder.Decode(&data); err != nil {
panic(err)
}
然后,这个字符串被传递到Strings.NewReader
方法中,并定义为less
UNIX 命令的输入:
lessCmd := exec.Command("/usr/bin/less")
lessCmd.Stdin = strings.NewReader(data)
lessCmd.Stdout = os.Stdout
err = lessCmd.Run()
if err != nil {
panic(err)
}
当命令运行时,数据以页面的形式输出。然后用户可以按空格键继续到下一页,或者使用任何命令键来导航数据输出。
速率限制
经常在处理第三方 API 时,特定时间内可以处理多少请求是有限制的。这通常被称为速率限制。对于单个命令,你可能需要向 HTTP 端点发送多个请求,因此你可能更喜欢限制发送这些请求的频率。大多数公共 API 都会通知用户他们的速率限制,但有时你会意外地达到 API 的速率限制。我们将讨论如何限制你的请求以保持在限制内。
有一个有用的库x/time/rate
,可以用来定义限制,即某事应该执行的频率,以及限制器,它控制过程在限制内执行。让我们使用一些示例代码,假设我们想要每五秒执行一次。
这个特定示例的代码位于examples/limiting.go
文件中。再次强调,这只是一个示例,使用runner
的方式有很多种。我们将只介绍基本用法。我们首先定义一个包含函数Run
和limiter
字段的struct
,后者控制其运行频率。Limit()
函数将使用runner
结构体在速率限制内调用函数:
type runner struct {
Run func() bool
limiter *rate.Limiter
}
func Limit() {
thing := runner{}
start := time.Now()
在将thing
定义为runner
实例之后,我们获取开始时间,然后定义thing
的功能。如果调用在规定时间内允许,因为不超过限制,我们打印当前时间戳并返回一个false
变量。当至少过去 30 秒时,我们退出函数:
thing.Run = func() bool {
if thing.limiter.Allow() {
fmt.Println(time.Now()) // or call request
return false
}
if time.Since(start) > 30*time.Second {
return true
}
return false
}
我们为thing
定义了限制器。我们使用了一个自定义变量,我们将在稍后详细讨论。简单来说,NewLimiter
方法接受两个变量。第一个参数是限制,每五秒一个事件,第二个参数允许最多一个令牌的突发:
thing.limiter = rate.NewLimiter(forEvery(1, 5*time.
Second), 1)
对于那些不熟悉限制和突发之间区别的人来说,突发定义了 API 可以处理的并发请求数量。速率限制是每单位时间内允许的请求数量。
接下来,在for
循环内部,我们调用Run
函数,并且只有在它返回true
时才退出循环,这通常是在 30 秒后:
for {
if thing.Run() {
break
}
}
}
如前所述,返回速率限制的forEvery
函数被传递到NewLimiter
方法中。它简单地调用rate.Every
方法,该方法接受事件之间的最小时间间隔并将其转换为限制:
func forEvery(eventCount int, duration time.Duration) rate.Limit {
return rate.Every(duration / time.Duration(eventCount))
}
我们运行这段代码,时间戳每五秒输出一次。注意,它们每五秒输出一次:
2022-09-11 18:45:44.356917 -0700 PDT m=+0.000891459
2022-09-11 18:45:49.356877 -0700 PDT m=+5.000891042
2022-09-11 18:45:54.356837 -0700 PDT m=+10.000891084
2022-09-11 18:45:59.356797 -0700 PDT m=+15.000891084
2022-09-11 18:46:04.356757 -0700 PDT m=+20.000891167
2022-09-11 18:46:09.356718 -0700 PDT m=+25.000891167
处理限制请求还有其他方法,例如在循环内部调用的代码之后使用time.Sleep(d Duration)
方法。我建议使用rate
包,因为它不仅适用于限制执行,还适用于处理突发情况。它具有更多功能,可以在发送请求到外部 API 时用于更复杂的情况。
你现在已经学会了如何向外部 API 发送请求以及如何处理响应,当收到成功的响应时,如何转换和分页结果。此外,由于速率限制对于 API 来说是常见的,我们已经讨论了如何实现这一点。由于本节只处理了成功的情况,让我们在下一节中考虑如何处理失败的情况。
处理预期的 – 超时和错误
当构建一个调用外部命令或向外部 API 发送 HTTP 请求的 CLI 时,使用用户传入的数据,预期意外情况是一个好主意。在理想的世界里,您可以防止不良数据。我相信您熟悉短语垃圾输入,垃圾输出。您可以创建测试,以确保您的代码覆盖了尽可能多的坏情况。然而,超时和错误是会发生的。这是软件的本质,当您在开发和生产中遇到它们时,您可以修改您的代码来处理新情况。
外部命令进程的超时
让我们先讨论在调用外部命令时如何处理超时。超时代码位于examples/timeout.go
文件中。以下是一个完整的方法,它调用了timeout
命令。如果您查看位于cmd/timeout/timeout.go
中的timeout
命令代码,您会看到它包含一个基本的无限循环。此命令将超时,但我们需要使用以下代码来处理超时:
func Timeout() {
errChan := make(chan error, 1)
cmd := exec.Command(filepath.Join(os.Getenv("GOPATH"),
"bin", "timeout"))
if err := cmd.Start(); err != nil {
panic(err)
}
go func() {
errChan <- cmd.Wait()
}()
select {
case <-time.After(time.Second * 10):
fmt.Println("timeout command timed out")
return
case err := <-errChan:
if err != nil {
fmt.Println("timeout error:", err)
}
}
}
我们首先定义一个错误通道,errChan
,它将接收来自cmd.Wait()
方法的任何错误。然后定义命令cmd
,接下来调用cmd
的Start
方法来启动外部进程。在 Go 函数中,我们使用cmd.Wait()
方法等待命令返回。errChan
仅在命令退出并且标准输入和标准错误复制完成后才会接收错误值。在下面的select
块中,我们等待从两个不同的通道接收。第一种情况等待 10 秒后返回的时间。第二种情况等待命令完成并接收错误值。此代码使我们能够优雅地处理任何超时问题。
外部命令进程的错误或恐慌
首先,让我们定义错误和恐慌之间的区别。错误发生在应用程序可以被恢复但处于异常状态时。如果发生恐慌,则表示发生了意外情况。例如,我们尝试访问nil
指针上的字段或尝试访问超出数组索引范围的索引。我们可以从处理错误开始。
os/exec
包中存在一些错误:
-
exec.ErrDot
:当命令的文件路径在当前目录"."
中无法解析时发生的错误,因此命名为ErrDot
-
exec.ErrNotFound
:当可执行文件在定义的文件路径中无法解析时发生的错误
您可以检查类型以独特地处理每个错误。
处理命令路径找不到时的错误
以下代码位于examples/error.go
文件中的HandlingDoesNotExistErrors
函数中:
cmd := exec.Command("doesnotexist", "arg1")
if errors.Is(cmd.Err, exec.ErrDot) {
fmt.Println("path lookup resolved to a local directory")
}
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
fmt.Println("executable failed to resolve")
}
}
在检查命令类型时,使用errors.Is
方法,而不是检查cmd.Err == exec.ErrDot
,因为错误不是直接返回的。errors.Is
方法检查错误链中是否存在特定错误类型的任何发生。
处理其他错误
此外,在examples/error.go
文件中,还处理了命令进程本身抛出的错误。第二种方法HandlingOtherMethods
将命令的标准错误设置为我们可以稍后使用的缓冲区。让我们看看代码:
cmd := exec.Command(filepath.Join(os.Getenv("GOPATH"), "bin", "error"))
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
fmt.Println(fmt.Sprint(err) + ": " + stderr.String())
return
}
fmt.Println(out.String())
当遇到错误时,我们不仅打印错误,退出状态 1
,还打印任何已通过标准错误管道管道的数据,这应该使用户能够获得更多关于错误发生原因的详细信息。
为了进一步了解这段代码的工作原理,让我们看看cmd/error/error.go
文件中存在的错误命令实现:
func main() {
if len(os.Args) != 0 { // not passing in any arguments in this example throws an error
fmt.Fprintf(os.Stderr, "missing arguments\n")
os.Exit(1)
}
fmt.Println("executing command with no errors")
}
由于我们没有将任何参数传递给命令函数,在检查os.Args
的长度后,我们将退出原因打印到标准错误管道。这是一种非常简单但有效处理错误的方法。在调用外部进程时,我们只是返回错误,但正如我们可能都经历过的一样,错误信息可能有点晦涩。在后面的章节中,我们将讨论如何将这些错误信息重写为更易读的格式,并提供一些示例。
在第四章 构建 CLIs 的流行框架中,我们讨论了在 Cobra 命令结构中RunE
函数的使用,这允许我们在命令运行时返回一个错误值。如果你在RunE
方法中调用外部进程,那么你可以捕获并返回错误给用户,当然,在将其重写为更易读的格式之后!
对于恐慌(panic),处理方式与错误不同,但提供一个从恐慌中优雅恢复的方法是良好的编程实践。你可以在examples/panic.go
文件中的Panic
方法中看到这段代码的初始化。这个方法调用位于cmd/panic/panic.go
中的panic
命令。这个命令简单地引发恐慌然后恢复。它将恐慌信息返回到标准错误管道,打印堆栈,并以非零退出代码退出:
defer func() {
if panicMessage := recover(); panicMessage != nil {
fmt.Fprintf(os.Stderr, "(panic) : %v\n", panicMessage)
debug.PrintStack()
os.Exit(1)
}
}()
panic("help!")
在运行此命令的一侧,我们像处理任何其他错误一样处理它,通过捕获错误并打印到标准错误管道中的数据。
HTTP 请求中的超时和其他错误
类似地,你发送请求到外部 API 服务器时也可能遇到错误。为了清楚起见,超时也被视为错误。此示例的代码位于examples/http.go
中,其中包含两个函数:
-
HTTPTimeout()
-
HTTPError()
在深入研究之前的方法之前,让我们谈谈为了使这些方法正确执行,需要运行的代码。
cmd/api/
文件夹包含定义处理程序和本地启动 HTTP 服务器的代码。mux.HandleFunc
方法定义请求模式并将其与handler
函数匹配。服务器通过其地址定义,运行在 localhost,端口8080
,以及Handler
,mux
。最后,在定义的服务器上调用server.ListenAndServe()
方法:
func main() {
mux := http.NewServeMux()
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
mux.HandleFunc("/timeout", timeoutHandler)
mux.HandleFunc("/error", errorHandler)
err := server.ListenAndServe()
if err != nil {
fmt.Println("error starting api: ", err)
os.Exit(1)
}
}
超时处理程序被简单地定义。它等待两秒钟后通过使用time.After(time.Second*2)
通道发送响应:
func timeoutHandler(w http.ResponseWriter, r *http.Request) {
fmt.Println("got /timeout request")
<-time.After(time.Second * 2)
w.WriteHeader(http.StatusOK)
w.Write([]byte("this took a long time"))
}
错误处理程序返回状态码http.StatusInternalServerError
:
func errorHandler(w http.ResponseWriter, r *http.Request) {
fmt.Println("got /error request")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal service error"))
}
在另一个终端中,在存储库的根目录下运行make install
命令以启动 API 服务器。现在,让我们看看调用每个端点的代码,并展示我们如何处理它。让我们首先讨论第一种错误类型——超时:
-
HTTPTimeout
:在examples/http.go
文件中存在HTTPTimeout
方法。让我们一起走过这段代码:- 首先,我们使用
http.Client
结构体定义客户端,指定超时为一秒。记住,由于 API 上的超时处理程序在两秒后返回响应,因此请求肯定会超时:
- 首先,我们使用
client := http.Client{
Timeout: 1 * time.Second,
}
- 接下来,我们定义请求:一个对
/timeout
端点的GET
方法。我们传递一个空的主体:
body := &bytes.Buffer{}
req, err := http.NewRequest(http.MethodGet, "http://localhost:8080/timeout", body)
if err != nil {
panic(err)
}
- 客户端
Do
方法使用请求变量作为参数被调用。我们等待服务器在一秒内响应,如果没有,则返回错误。客户端Do
方法返回的错误将是*url.Error
类型。您可以访问此错误类型的不同字段,但在以下代码中,我们检查错误的Timeout
方法是否返回true
。在这个语句中,我们可以按自己的意愿行事。我们可以暂时返回错误,我们可以退避并重试,或者我们可以退出。这取决于您的具体用例:
resp, err := client.Do(req)
if err != nil {
urlErr := err.(*url.Error)
if urlErr.Timeout() {
fmt.Println("timeout: ", err)
return
}
}
defer resp.Body.Close()
当此方法执行时,输出如下:
timeout: Get "http://localhost:8080/timeout": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
超时只是其中一种错误,但您可能会遇到许多其他错误。由于客户端Do
方法在net/url
包中返回特定的错误类型,让我们来讨论一下。在net/url
包中存在url.Error
类型定义:
type Error struct {
Op string // Operation
URL string // URL
Err error // Error
}
错误包含Timeout()
方法,当请求超时时返回true
,并且需要注意的是,当响应状态不是200 OK
时,错误不会被设置。然而,状态码指示错误响应。错误响应可以分为两个不同的类别:
-
(
400
到499
)表示客户端发生错误。一些例子包括Bad Request (400)
、Unauthorized (401)
和Not Found (404)
。 -
(
500
到599
)表示服务器端发生错误。一些常见的例子包括Internal Server Error (500)
、Bad Gateway (502)
和Service Unavailable (503)
。
HTTPErrors
:如何在examples/http.go
文件中的HTTPErrors
方法中处理这种情况的示例代码存在。同样,在执行此代码之前确保 API 服务器正在运行非常重要:
- 方法中的代码首先通过调用对
/error
端点的GET
请求开始:
resp, err := http.Get("http://localhost:8080/error")
- 如果错误不是
nil
,那么我们将它转换为url.Error
类型以访问其中的字段和方法。例如,我们检查urlError
是否是超时或临时网络错误。如果不是这两种情况,那么我们可以输出我们所知道的所有关于错误的信息到标准输出。这些附加信息可以帮助我们确定下一步要采取的措施:
if err != nil {
urlErr := err.(*url.Error)
if urlErr.Timeout() {
// a timeout is a type of error
fmt.Println("timeout: ", err)
return
}
if urlErr.Temporary() {
// a temporary network error, retry later
fmt.Println("temporary: ", err)
return
}
fmt.Printf("operation: %s, url: %s, error: %s\n", urlErr.
Op, urlErr.URL, urlErr.Error())
return
}
- 由于状态码错误响应不被视为 Go 语言错误,响应体可能包含一些有用的信息。如果它不是
nil
,那么我们可以读取状态码:
if resp != nil {
defer resp.Body.Close()
- 我们最初检查
StatusCode
是否不等于http.StatusOK
。从那里,我们可以检查特定的错误消息并采取适当的行动。在这个例子中,我们只检查了三种不同类型的错误响应,但你可以检查对你所做的事情有意义的任何类型:
if resp.StatusCode != http.StatusOK {
// action for when status code is not okay
switch resp.StatusCode {
case http.StatusBadRequest:
fmt.Printf("bad request: %v\n", resp.Status)
case http.StatusInternalServerError:
fmt.Printf("internal service error: %v\n", resp.
Status)
default:
fmt.Printf("unexpected status code: %v\n", resp.
StatusCode)
}
}
- 最后,客户端或服务器错误状态并不一定意味着响应体是
nil
。如果其中包含任何有用的信息,我们可以输出响应体:
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println("err:", err)
}
fmt.Println("response body:", string(data))
}
这就结束了处理 HTTP 超时和其他错误的章节。尽管示例很简单,但它们为你提供了处理超时、临时网络和其他错误所必需的信息和指导。
摘要
在本章中,你深入了解了os/exec
包。这包括学习创建命令的不同方式:使用command
结构体或Command
方法。我们不仅创建了命令,还向它们传递文件描述符以接收信息。我们学习了使用Run
或Start
方法运行命令的不同方式,以及从标准输出、标准错误类型和其他文件描述符检索数据的多重方式。
在本章中,我们还讨论了net/http
和net/url
包,当创建对外部 API 服务器的 HTTP 请求时,熟悉这些包非常重要。几个示例教会了我们如何使用http.Client
上的方法创建请求,包括Do
、Get
、Post
和PostForm
。
学习如何构建健壮的代码非常重要,而优雅地处理错误是这个过程的一部分。我们需要知道如何首先捕获错误,因此我们讨论了在运行外部进程或向外部 API 服务器发送请求时可能发生的某些常见错误的检测方法。捕获和处理其他错误使我们确信我们的代码在它们发生时能够采取适当的行动。最后,我们现在知道如何在响应不正常时检查不同的状态码。
在学习了本章的所有信息后,我们现在应该更有信心构建一个与外部命令交互或向外部 API 发送请求的 CLI。在下一章,我们将学习如何编写可以在多个不同的架构和操作系统上运行的代码。
问题
-
在
time
包中,我们使用什么方法通过通道接收特定持续时间后的时间? -
http.Client
的Do
方法返回的错误类型是什么? -
当 HTTP 请求收到一个状态码不是
StatusOK
的响应时,请求返回的错误是否被填充?
答案
-
time.After(d Duration) <-chan Time
-
*url.Error
-
否
进一步阅读
第七章:为不同平台开发
Go 语言之所以成为构建命令行应用程序的强大语言之一,主要原因是它很容易开发出可以在多台机器上运行的应用程序。Go 提供了几个包,允许开发者编写与计算机交互的代码,而无需考虑特定的操作系统。这些包包括 os
、time
、path
和 runtime
。在第一部分,我们将讨论这些包中的一些常用函数,并提供一些简单的示例与解释相匹配。
为了进一步强调这些文件的重要性,我们将重新审视 audiofile
代码,并实现一些利用这些包中存在的一些方法的新功能。毕竟,通过使用新学到的函数和方法实现新功能,是学习最好的方式。
然后,我们将学习如何使用 runtime
库来检查应用程序正在运行的操作系统,然后使用该信息在代码之间切换。通过了解构建标签、它们是什么以及如何使用它们,我们将学习一种更干净的方法来在代码块之间切换,以实现一个可以在三个不同的操作系统上运行的新功能:Darwin、Windows 和 Linux。到本章结束时,当你构建应用程序时,你会更有信心,知道你编写的代码将无缝运行,不受平台限制。
在本章中,我们将涵盖以下关键主题:
-
用于平台无关功能的包
-
实现独立或平台特定代码
-
针对特定平台的构建标签
技术要求
- 本章的代码文件可在以下链接获取:
github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter07
.
用于平台无关功能的包
当你构建 os
、time
和 path
时。另一个有用的包是 runtime
包,它有助于检测应用程序正在运行的操作系统,以及其他事情。我们将通过一些简单的示例来回顾这些包,以展示如何应用一些可用的方法。
os
包
os
包是你的首选包。我们在上一章讨论了调用外部命令;现在我们将从更高层次讨论这个问题,并专注于某些组中的命令:环境、文件和进程操作。
环境操作
如其名所示,os
包包含提供关于应用程序运行环境信息的函数,以及为未来的方法调用更改环境。以下是一些常见操作的工作目录:
-
func Chdir(dir string) error
: 这将更改当前工作目录 -
func Getwd() (dir string, err error)
: 这将获取当前工作目录
环境操作也有以下内容:
-
func Environ() []string
: 列出环境键和值 -
func Getenv(key string) string
: 通过键获取环境变量 -
func Setenv(key, value string) error
: 通过键和值设置环境变量 -
func Unsetenv(key string) error
: 通过键取消设置环境变量 -
func Clearenv()
: 清除环境变量 -
func ExpandEnv(s string) string
: 将字符串中环境变量键的值展开为其值
代码第七章位于 GitHub 的environment.go
文件中,我们在这里提供了一些示例代码,展示了如何使用这些操作:
func environment() {
dir, err := os.Getwd()
if err != nil {
fmt.Println("error getting working directory:", err)
}
fmt.Println("retrieved working directory: ", dir)
fmt.Println("setting WORKING_DIR to", dir)
err = os.Setenv("WORKING_DIR", dir)
if err != nil {
fmt.Println("error setting working directory:", err)
}
fmt.Println(os.ExpandEnv("WORKING_DIR=${WORKING_DIR}"))
fmt.Println("unsetting WORKING_DIR")
err = os.Unsetenv("WORKING_DIR")
if err != nil {
fmt.Println("error unsetting working directory:", err)
}
fmt.Println(os.ExpandEnv("WORKING_DIR=${WORKING_DIR}"))
fmt.Printf("There are %d environment variables:\n", len(os.
Environ()))
for _, envar := range os.Environ() {
fmt.Println("\t", envar)
}
}
简要描述前面的代码,我们首先获取工作目录,然后将其设置为WORKING_DIR
环境变量。为了显示变化,我们使用os.ExpandEnv
来打印键值对。然后我们取消设置WORKING_DIR
环境变量。同样,我们再次使用os.ExpandEnv
来打印键值对。如果环境变量未设置,os.ExpandEnv
变量将打印空字符串。最后,我们打印出环境变量的数量,然后遍历所有变量来打印它们。运行前面的代码将产生以下输出:
retrieved working directory: /Users/mmontagnino/Code/src/github.com/marianina8/Chapter-7
setting WORKING_DIR to /Users/mmontagnino/Code/src/github.com/marianina8/Chapter-7
WORKING_DIR=/Users/mmontagnino/Code/src/github.com/marianina8/Chapter-7
There are 44 environment variables.
key=WORKING_DIR, value=/Users/mmontagnino/Code/src/github.com/marianina8/Chapter-7
unsetting WORKING_DIR
WORKING_DIR=
如果你在你的机器上运行此代码而不是 Linux、Unix 或 Windows,结果输出将相似。自己试试看。
关于运行以下示例的说明
要运行第七章的示例,你首先需要运行安装命令将 sleep 命令安装到你的 GOPATH。在类 Unix 系统中,运行make install
命令后跟make run
命令。在 Linux 系统中,运行./build-linux.sh
脚本后跟./run-linux.sh
脚本。在 Windows 上,运行.\build-windows.ps1
后跟.\run-windows.ps1
PowerShell 脚本。
文件操作
os
包还提供了适用于不同操作系统的广泛文件操作,这些操作可以通用。许多函数和方法可以应用于文件,所以我不一一列举名称,而是将功能分组,并列举一些:
-
以下可用于更改文件、目录和链接的权限和所有者:
-
func Chmod(name string, mode FileMode) error
-
func Chown(name string uid, gid int) error
-
func Lchown(name string uid, gid int) error
-
-
以下可用于创建管道、文件、目录和链接:
-
func Pipe() (r *File, w *File, err error)
-
func Create(name string) (*File, error)
-
func Mkdir(name string, perm FileMode) error
-
func Link(oldname, newname string) error
-
-
以下用于从文件、目录和链接中读取:
-
func ReadFile(name string) ([]byte, error)
-
func ReadDir(name string) ([]DirEntry, error)
-
func Readlink(name string) (string, error)
-
-
以下操作用于检索特定用户的数据:
-
func UserCacheDir() (string, error)
-
func UserConfigDir() (string, error)
-
func UserHomeDir() (string, error)
-
-
以下用于写入文件:
-
func (f *File) Write(b []byte) (n int, err error)
-
func (f *File) WriteString(s string) (n int, err error)
-
func WriteFile(name string, data []byte, perm FileMode) error
-
-
以下用于文件比较:
func SameFile(fi1, fi2 FileInfo) bool
在 GitHub 上第七章的代码中有一个file.go
文件,其中包含一些使用这些操作的示例代码。在该文件中,有多个函数,第一个是func createFiles() error
,它处理创建三个文件以供操作:
func createFiles() error {
filename1 := "file1"
filename2 := "file2"
filename3 := "file3"
f1, err := os.Create(filename1)
if err != nil {
return fmt.Errorf("error creating %s: %v\n", filename1,
err)
}
defer f1.Close()
f1.WriteString("abc")
f2, err := os.Create(filename2)
if err != nil {
return fmt.Errorf("error creating %s: %v\n", filename2,
err)
}
defer f2.Close()
f2.WriteString("123")
f3, err := os.Create(filename3)
if err != nil {
return fmt.Errorf("error creating %s: %v", filename3,
err)
}
defer f3.Close()
f3.WriteString("xyz")
return nil
}
os.Create
方法允许在不同的操作系统上无缝地创建文件。下一个函数file()
利用这些文件来展示如何使用存在于os
包中的方法。file()
函数主要获取或更改当前工作目录,并运行不同的函数,包括以下内容:
-
func createExamplesDir() (string, error)
: 这个函数在用户主目录中创建一个examples
目录 -
func printFiles(dir string) error
: 这个函数打印出由dir string
表示的目录下的文件/目录 -
func sameFileCheck(f1, f2 string) error
: 这个函数检查由f1
和f2
字符串表示的两个文件是否是同一个文件
让我们先展示file()
函数,以了解整体情况:
originalWorkingDir, err := os.Getwd()
if err != nil {
fmt.Println("getting working directory: ", err)
}
fmt.Println("working directory: ", originalWorkingDir)
examplesDir, err := createExamplesDir()
if err != nil {
fmt.Println("creating examples directory: ", err)
}
err = os.Chdir(examplesDir)
if err != nil {
fmt.Println("changing directory error:", err)
}
fmt.Println("changed working directory: ", examplesDir)
workingDir, err := os.Getwd()
if err != nil {
fmt.Println("getting working directory: ", err)
}
fmt.Println("working directory: ", workingDir)
createFiles()
err = printFiles(workingDir)
if err != nil {
fmt.Printf("Error printing files in %s\n", workingDir)
}
err = os.Chdir(originalWorkingDir)
if err != nil {
fmt.Println("changing directory error: ", err)
}
fmt.Println("working directory: ", workingDir)
symlink := filepath.Join(originalWorkingDir, "examplesLink")
err = os.Symlink(examplesDir, symlink)
if err != nil {
fmt.Println("error creating symlink: ", err)
}
fmt.Printf("created symlink, %s, to %s\n", symlink, examplesDir)
err = printFiles(symlink)
if err != nil {
fmt.Printf("Error printing files in %s\n", workingDir)
}
file := filepath.Join(examplesDir, "file1")
linkedFile := filepath.Join(symlink, "file1")
err = sameFileCheck(file, linkedFile)
if err != nil {
fmt.Println("unable to do same file check: ", err)
}
// cleanup
err = os.Remove(symlink)
if err != nil {
fmt.Println("removing symlink error: ", err)
}
err = os.RemoveAll(examplesDir)
if err != nil {
fmt.Println("removing directory error: ", err)
}
让我们回顾一下前面的代码。首先,我们获取当前工作目录并打印出来。然后,我们调用createExamplesDir()
函数并进入该目录。
我们在更改工作目录后获取当前工作目录,以确保它现在是examplesDir
值。接下来,我们调用createFiles()
函数在examplesDir
文件夹内创建这三个文件,并调用printFiles()
函数列出examplesDir
工作目录中的文件。
我们将工作目录改回原始工作目录,并在主目录下创建一个指向examplesDir
文件夹的symlink
。我们打印出symlink
下的文件,以确认它们是相同的。
然后,我们从examplesDir
中取出file0
和从symlink
中取出的file0
,在sameFileCheck
函数中进行比较,以确保它们相等。
最后,我们运行一些清理函数来删除symlink
和examplesDir
文件夹。
file()
函数利用了os
包中许多可用的方法,从获取工作目录到更改它,创建symlink
,以及删除文件和目录。展示单独的函数调用代码将展示os
包的更多用途。首先,让我们展示createExamplesDir
的代码:
func createExamplesDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("getting user's home directory:
%v\n", err)
}
fmt.Println("home directory: ", homeDir)
examplesDir := filepath.Join(homeDir, "examples")
err = os.Mkdir(examplesDir, os.FileMode(int(0777)))
if err != nil {
return "", fmt.Errorf("making directory error: %v\n",
err)
}
fmt.Println("created: ", examplesDir)
return examplesDir, nil
}
前面的代码在获取用户主目录时使用了os.UserHomeDir
方法,然后使用os.Mkdir
方法创建了一个新文件夹。下一个函数printFiles
从os.ReadDir
方法获取要打印的文件:
func printFiles(dir string) error {
files, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read directory error: %s\n", err)
}
fmt.Printf("files in %s:\n", dir)
for i, file := range files {
fmt.Printf(" %v %v\n", i, file.Name())
}
return nil
}
最后,sameFileCheck
接受两个由字符串表示的文件,f1
和f2
。要获取每个文件的文件信息,我们将在文件字符串上调用os.Lstat
方法。os.SameFile
接受此文件信息并返回一个boolean
值来表示结果——如果文件相同则返回true
,否则返回false
:
func sameFileCheck(f1, f2 string) error {
fileInfo0, err := os.Lstat(f1)
if err != nil {
return fmt.Errorf("getting fileinfo: %v", err)
}
fileInfo0Linked, err := os.Lstat(f2)
if err != nil {
return fmt.Errorf("getting fileinfo: %v", err)
}
isSameFile := os.SameFile(fileInfo0, fileInfo0Linked)
if isSameFile {
fmt.Printf("%s and %s are the same file.\n", fileInfo0.
Name(), fileInfo0Linked.Name())
} else {
fmt.Printf("%s and %s are NOT the same file.\n", fileInfo0.
Name(), fileInfo0Linked.Name())
}
return nil
}
这总结了使用os
包中与文件操作相关的方法的代码示例。接下来,我们将讨论一些与机器上运行的进程相关的操作。
进程操作
当调用外部命令时,我们可以使用os
包,我们可以对进程执行操作,发送进程信号,或等待进程完成,然后接收一个包含有关已完成的进程信息的进程状态。在第七章代码中,我们有一个process()
函数,它利用以下方法对进程和进程状态进行操作:
-
func Getegid() int
: 这返回调用者的有效组 ID。注意,Windows 不支持此功能,组 ID 的概念仅适用于 Unix-like 或 Linux 系统。例如,在 Windows 上这将返回-1
。 -
func Geteuid() int
: 这返回调用者的有效用户 ID。注意,Windows 不支持此功能,用户 ID 的概念仅适用于 Unix-like 或 Linux 系统。例如,在 Windows 上这将返回-1
。 -
func Getpid() int
: 这获取调用者的进程 ID。 -
func FindProcess(pid int) (*Process, error)
: 这返回与pid
关联的进程。 -
func (p *Process) Wait() (*ProcessState, error)
: 当进程完成时返回进程状态。 -
func (p *ProcessState) Exited() bool
: 如果进程已退出,则返回true
。 -
func (p *ProcessState) Success() bool
: 如果进程成功退出,则返回true
。 -
func (p *ProcessState) ExitCode() int
: 这返回进程的退出代码。 -
func (p *ProcessState) String() string
: 这以字符串格式返回进程状态。
以下代码如下,并开始于几个打印行语句,这些语句返回调用者的有效组、用户和进程 ID。接下来,定义了一个cmd
睡眠命令。启动命令,并从cmd
值中,我们得到 pid:
func process() {
fmt.Println("Caller group id:", os.Getegid())
fmt.Println("Caller user id:", os.Geteuid())
fmt.Println("Process id of caller", os.Getpid())
cmd := exec.Command(filepath.Join(os.Getenv("GOPATH"),
"bin", "sleep"))
fmt.Println("running sleep for 1 second...")
if err := cmd.Start(); err != nil {
panic(err)
}
fmt.Println("Process id of sleep", cmd.Process.Pid)
this, err := os.FindProcess(cmd.Process.Pid)
if err != nil {
fmt.Println("unable to find process with id: ", cmd.
Process.Pid)
}
processState, err := this.Wait()
if err != nil {
panic(err)
}
if processState.Exited() && processState.Success() {
fmt.Println("Sleep process ran successfully with exit
code: ", processState.ExitCode())
} else {
fmt.Println("Sleep process failed with exit code: ",
processState.ExitCode())
}
fmt.Println(processState.String())
}
从进程的 pid,然后我们可以使用os.FindProcess
方法找到进程。我们调用进程的Wait()
方法以获取os.ProcessState
。这个Wait()
方法,就像cmd.Wait()
方法一样,等待进程完成。一旦完成,就返回进程状态。我们可以使用Exited()
方法检查进程状态是否已退出,以及是否成功使用Success()
方法。如果是这样,我们将打印进程运行成功,以及我们从ExitCode()
方法获取的退出代码。最后,我们可以使用String()
方法干净地打印进程状态。
时间包
操作系统通过两种不同类型的内部时钟提供对时间的访问:
-
墙钟:这用于告知时间,并且由于与 网络时间协议(NTP)的时钟同步,可能会出现变化
-
单调时钟:这用于测量时间,并且不受时钟同步变化的影响
要更具体地说明这些变化,如果墙钟注意到它比 NTP 移动得更快或更慢,它将调整其时钟速率。单调时钟不会调整。在测量持续时间时,使用单调时钟非常重要。幸运的是,在 Go 中,Time
结构体包含墙钟和单调时钟,我们不需要指定使用哪一个。在 第七章 的代码中,有一个 timer.go
文件,展示了如何获取当前时间和持续时间,无论操作系统如何:
func timer() {
start := time.Now()
fmt.Println("start time: ", start)
time.Sleep(1 * time.Second)
elapsed := time.Until(start)
fmt.Println("elapsed time: ", elapsed)
}
当运行以下代码时,你们将看到类似的输出:
start time: 2022-09-24 23:47:38.964133 -0700 PDT m=+0.000657043
elapsed time: -1.002107875s
此外,你们中的许多人也看到过有一个 time.Now().Unix()
方法。它返回自 Unix 纪元(即自 1970 年 1 月 1 日 UTC 以来经过的时间)的纪元时间。这些方法在它们运行的操作系统和架构上都将以类似的方式工作。
路径包
当为不同的操作系统开发命令行应用程序时,你们很可能会不得不处理文件或目录路径名。为了在不同操作系统中适当地处理这些路径,你们需要使用 path
包。因为这个包不处理像我们在前面的例子中使用的那样带有驱动器字母或反斜杠的 Windows 路径,我们将使用 path/filepath
包。
path/filepath
包根据操作系统使用正斜杠或反斜杠。为了好玩,在 第七章 的 walking.go
文件中,我使用了 filepath
包来遍历一个目录。让我们看看代码:
func walking() {
workingDir, err := os.Getwd()
if err != nil {
panic(err)
}
dir1 := filepath.Join(workingDir, "dir1")
filepath.WalkDir(dir1, func(path string, d fs.DirEntry, err
error) error {
if !d.IsDir() {
contents, err := os.ReadFile(path)
if err != nil {
return err
}
fmt.Printf("%s -> %s\n", d.Name(),
string(contents))
}
return nil
})
}
我们使用 os.Getwd()
获取当前工作目录。然后使用 filepath.Join
方法为 dir1
目录创建一个可用于任何操作系统的路径。最后,我们使用 filepath.WalkDir
遍历目录,并打印出文件名及其内容。
运行时包
在本节中要讨论的最后一个包是 runtime
包。它之所以被提及,是因为它被用来轻松确定代码运行的操作系统,并因此执行代码块,但你可以从 runtime
系统中获得大量信息:
-
GOOS
: 这返回运行应用程序的目标操作系统 -
GOARCH:
这返回运行应用程序的目标架构 -
func GOROOT() string
: 这返回 Go 树的根目录 -
Compiler
: 这返回构建二进制的编译器工具链的名称 -
func NumCPU() int
: 这返回当前进程可用的逻辑 CPU 数量 -
func NumGoroutine() int
: 这返回当前存在的 goroutine 数量 -
func Version() string
: 这返回 Go 树的版本字符串
此包将为您提供足够的信息来理解runtime
环境。在checkRuntime.go
文件中的第七章代码中是checkRuntime
函数,它将这些方法付诸实践:
func checkRuntime() {
fmt.Println("Operating System:", runtime.GOOS)
fmt.Println("Architecture:", runtime.GOARCH)
fmt.Println("Go Root:", runtime.GOROOT())
fmt.Println("Compiler:", runtime.Compiler)
fmt.Println("No. of CPU:", runtime.NumCPU())
fmt.Println("No. of Goroutines:", runtime.NumGoroutine())
fmt.Println("Version:", runtime.Version())
debug.PrintStack()
}
运行代码将提供类似于以下输出的结果:
Operating System: darwin
Architecture: amd64
Go Root: /usr/local/go
Compiler: gc
No. of CPU: 10
No. of Goroutines: 1
Version: go1.19
goroutine 1 [running]:
runtime/debug.Stack()
/usr/local/go/src/runtime/debug/stack.go:24 +0x65
runtime/debug.PrintStack()
/usr/local/go/src/runtime/debug/stack.go:16 +0x19
main.checkRuntime()
/Users/mmontagnino/Code/src/github.com/marianina8/Chapter-7/checkRuntime.go:17 +0x372
main.main()
/Users/mmontagnino/Code/src/github.com/marianina8/Chapter-7/main.go:9 +0x34
现在我们已经学习了构建跨多个操作系统和架构的命令行应用程序所需的一些包,在下一节中,我们将回到之前章节中的audiofile
CLI,并实现一些新的功能,展示我们在这部分学到的方法和函数如何发挥作用。
实现独立或平台特定代码
学习的最佳方式是将所学知识付诸实践。在本节中,我们将重新审视audiofile
CLI 以实现一些新的命令。在我们将要实现的新功能代码中,重点将放在os
和path
/filepath
包的使用上。
平台无关代码
现在我们将为audiofile
CLI 实现一些新的功能,这些功能将独立于操作系统运行:
-
Delete
:通过 ID 删除存储的元数据 -
Search
:在存储的元数据中搜索特定的搜索字符串
这些新功能命令的创建是从 cobra-CLI 开始的;然而,特定平台的代码被隔离在storage/flatfile.go
文件中,这是存储接口的平面文件存储。
首先,让我们展示Delete
方法:
func (f FlatFile) Delete(id string) error {
dirname, err := os.UserHomeDir()
if err != nil {
return err
}
audioIDFilePath := filepath.Join(dirname, "audiofile", id)
err = os.RemoveAll(audioIDFilePath)
if err != nil {
return err
}
return nil
}
平面文件存储存储在用户主目录下的audiofile
目录中。然后,随着每个新的音频文件和匹配的元数据的添加,它们被存储在其唯一的标识符 ID 中。从os
包中,我们使用os.UserHomeDir()
来获取用户的主目录,然后使用filepath.Join
方法创建删除与 ID 相关的所有元数据和文件的所需路径,这些路径独立于操作系统。确保您在平面文件存储中存储了一些音频文件。如果没有,添加一些文件。例如,使用audio/beatdoctor.mp3
文件,并使用以下命令上传:
./bin/audiofile upload --filename audio/beatdoctor.mp3
成功上传后返回 ID:
Uploading audio/beatdoctor.mp3 ...
Audiofile ID: a5d9ab11-6f5f-4da0-9307-a3b609b0a6ba
您可以通过运行list
命令来确保数据已被添加:
./bin/audiofile list
返回了audiofile
元数据,因此我们已经检查了它在存储中的存在:
{
"Id": "a5d9ab11-6f5f-4da0-9307-a3b609b0a6ba",
"Path": "/Users/mmontagnino/audiofile/a5d9ab11-6f5f-4da0-9307-a3b609b0a6ba/beatdoctor.mp3",
"Metadata": {
"tags": {
"title": "Shot In The Dark",
"album": "Best Bytes Volume 4",
"artist": "Beat Doctor",
"album_artist": "Toucan Music (Various Artists)",
"composer": "",
"genre": "Electro House",
"year": 0,
"lyrics": "",
"comment": "URL: http://freemusicarchive.org/music/Beat_Doctor/Best_Bytes_Volume_4/09_beat_doctor_shot_in_the_dark\r\nComments: http://freemusicarchive.org/\r\nCurator: Toucan Music\r\nCopyright: Attribution-NonCommercial 3.0 International: http://creativecommons.org/licenses/by-nc/3.0/"
},
"transcript": ""
},
"Status": "Complete",
"Error": null
},
现在,我们可以删除它:
./bin/audiofile delete --id a5d9ab11-6f5f-4da0-9307-a3b609b0a6ba
success
然后通过尝试通过 ID 获取音频来确认它已被删除:
./bin/audiofile get --id a5d9ab11-6f5f-4da0-9307-a3b609b0a6ba
Error: unexpected response: 500 Internal Server Error
Usage:
audiofile get [flags]
Flags:
-h, --help help for get
--id string audiofile id
unexpected response: 500 Internal Server Error%
看起来发生了一个意外的错误,我们没有正确实现当搜索已删除文件的元数据时如何处理这种情况。我们需要修改services/metadata/handler_getbyid.go
文件。在第 20 行,当我们调用GetById
方法并处理错误时,在确认错误与找不到文件夹有关后,让我们返回200
而不是500
。这并不一定是用户正在搜索一个不存在的 ID 的错误:
audio, err := m.Storage.GetByID(id)
if err != nil {
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no such file or directory") {
io.WriteString(res, "id not found")
res.WriteHeader(200)
return
}
res.WriteHeader(500)
return
}
让我们再试一次:
./bin/audiofile get --id a5d9ab11-6f5f-4da0-9307-a3b609b0a6ba
id not found
现在好多了!现在让我们实现搜索功能。实现再次被隔离到storage/flatfile.go
文件中,你将在其中找到Search
方法:
func (f FlatFile) Search(searchFor string) ([]*models.Audio, error) {
dirname, err := os.UserHomeDir()
if err != nil {
return nil, err
}
audioFilePath := filepath.Join(dirname, "audiofile")
matchingAudio := []*models.Audio{}
err = filepath.WalkDir(audioFilePath, func(path string,
d fs.DirEntry, err error) error {
if d.Name() == "metadata.json" {
contents, err := os.ReadFile(path)
if err != nil {
return err
}
if strings.Contains(strings.
ToLower(string(contents)), strings.
ToLower(searchFor)) {
data := models.Audio{}
err = json.Unmarshal(contents, &data)
if err != nil {
return err
}
matchingAudio = append(matchingAudio, &data)
}
}
return nil
})
return matchingAudio, err
}
如同存储中大多数方法一样,我们首先使用os.UserHomeDir()
方法获取用户的家目录,然后再次使用filepath.Join
来获取根audiofile
路径目录,我们将从这里开始遍历。调用filepath.WalkDir
方法从audioFilePath
开始。我们检查每个metadata.json
文件,看searchFor
字符串是否存在于内容中。该方法返回一个*models.Audio
切片,如果searchFor
字符串在内容中找到,音频将被追加到稍后返回的切片中。
让我们用以下命令尝试一下,看看是否返回了预期的元数据:
./bin/audiofile search --value "Beat Doctor"
现在我们已经创建了一些新的命令来展示如何在实际例子中使用os
包和path/filepath
包,让我们尝试编写一些可以在特定操作系统上运行的代码。
平台特定代码
假设你的命令行应用程序需要操作系统上存在的外部应用程序,但所需的应用程序在不同操作系统之间可能不同。对于audiofile
命令行应用程序,假设我们想要创建一个命令来通过命令行播放音频文件。每个操作系统都需要使用不同的命令来播放音频,如下所示:
-
macOS:
afplay <filepath>
-
Windows:
start <filepath>
-
Linux:
aplay <filepath>
再次,我们使用 Cobra-CLI 来创建新的play
命令。让我们看看每个操作系统播放音频文件需要调用的不同函数。首先是 macOS 的代码:
func darwinPlay(audiofilePath string) {
cmd := exec.Command("afplay", audiofilePath)
if err := cmd.Start(); err != nil {
panic(err)
}
fmt.Println("enjoy the music!")
err := cmd.Wait()
if err != nil {
panic(err)
}
}
我们创建了一个命令来使用afplay
可执行文件并传入audiofilePath
。接下来是 Windows 的代码:
func windowsPlay(audiofilePath string) {
cmd := exec.Command("cmd", "/C", "start", audiofilePath)
if err := cmd.Start(); err != nil {
return err
}
fmt.Println("enjoy the music!")
err := cmd.Wait()
if err != nil {
return err
}
}
这是一个非常类似的功能,除了它使用 Windows 中的start
可执行文件来播放音频。接下来是 Linux 的代码:
func linuxPlay(audiofilePath string) {
cmd := exec.Command("aplay", audiofilePath)
if err := cmd.Start(); err != nil {
panic(err)
}
fmt.Println("enjoy the music!")
err := cmd.Wait()
if err != nil {
panic(err)
}
}
再次,代码几乎完全相同,只是调用播放音频的应用程序不同。在另一种情况下,此代码可能需要针对操作系统更具体,需要不同的参数,甚至需要操作系统特定的完整路径。无论如何,我们已准备好在play
命令的RunE
字段中使用这些函数。完整的play
命令如下:
var playCmd = &cobra.Command{
Use: "play",
Short: "Play audio file by id",
RunE: func(cmd *cobra.Command, args []string) error {
b, err := getAudioByID(cmd)
if err != nil {
return err
}
audio := models.Audio{}
err = json.Unmarshal(b, &audio)
if err != nil {
return err
}
switch runtime.GOOS {
case "darwin":
darwinPlay(audio.Path)
return nil
case "windows":
windowsPlay(audio.Path)
return nil
case "linux":
linuxPlay(audio.Path)
return nil
default:
fmt.Println(`Your operating system isn't supported
for playing music yet.
Feel free to implement your additional use
case!`)
}
return nil
},
}
这段代码的重要部分是我们为runtime.GOOS
值创建了一个 switch case,它告诉我们应用程序正在运行在哪个操作系统上。根据操作系统,会调用不同的方法来启动一个进程来播放音频文件。让我们重新编译并尝试使用存储的音频文件 ID 之一来测试播放方法:
./bin/audiofile play --id bf22c5c4-9761-4b47-aab0-47e93d1114c8
enjoy the music!
本章的最后部分将向我们展示如果我们想的话,如何使用构建标签来实现这一点。
针对平台的构建标签
构建标签,或构建约束,可用于许多目的,但在这个部分,我们将讨论如何使用构建标签来识别在为特定操作系统构建时应该包含哪些文件。构建标签位于文件顶部的注释中:
//go:build
在运行 go build
时,构建标签作为标志传入。一个文件上可能有多个标签,并且它们遵循以下注释语法:
//go:build [tags]
每个标签之间由一个空格分隔。假设我们想表明这个文件只会在 Darwin 操作系统的构建中包含,那么我们就可以将其添加到文件的顶部:
//go:build darwin
然后在构建应用程序时,我们会使用类似以下的内容:
go build –tags darwin
这只是一个关于如何使用构建标签来限制特定于操作系统的文件的快速概述。在我们进入实现之前,让我们更详细地讨论一下 build
包。
构建包
build
包收集有关 Go 包的信息。在 第七章 代码仓库中,有一个 buildChecks.go
文件,该文件使用 build
包来获取当前包的信息。让我们看看这段代码能提供哪些信息:
func buildChecks() {
ctx := build.Context{}
p1, err := ctx.Import(".", ".", build.AllowBinary)
if err != nil {
fmt.Println("err: ", err)
}
fmt.Println("Dir:", p1.Dir)
fmt.Println("Package name: ", p1.Name)
fmt.Println("AllTags: ", p1.AllTags)
fmt.Println("GoFiles: ", p1.GoFiles)
fmt.Println("Imports: ", p1.Imports)
fmt.Println("isCommand: ", p1.IsCommand())
fmt.Println("IsLocalImport: ", build.IsLocalImport("."))
fmt.Println(ctx)
}
我们首先创建 context
变量,然后调用 Import
方法。Import
方法在文档中的定义如下:
func (ctxt *Context) Import(path string, srcDir string, mode ImportMode) (*Package, error)
它返回由 path
和 srcDir
源目录参数命名的 Go 包的详细信息。在这种情况下,main
包从包中返回,然后我们可以检查所有变量和方法,以获取有关包的更多信息。在本地运行此方法将返回类似以下内容:
Dir: .
Package name: main
AllTags: [buildChecks]
GoFiles: [checkRuntime.go environment.go file.go main.go process.go timer.go walking.go]
Imports: [fmt io/fs os os/exec path/filepath runtime runtime/debug strings time]
isCommand/main package: true
IsLocalImport: true
我们检查的大多数值都是不言自明的。AllTags
返回存在于 main
包中的所有标签。GoFiles
返回包含在 main
包中的所有文件。Imports
包含包中存在的所有唯一导入。IsCommand()
如果包被视为要安装的命令,或者它是主包,则返回 true
。最后,IsLocalImport
方法检查导入文件是否为本地文件。这是一个有趣的小细节,可以让你对 build
包可能提供的功能更加感兴趣。
构建标签
现在我们对 build
包有了更多了解,让我们用它来完成本章的主要目的,为特定操作系统构建包。构建标签应该有意命名,因为我们使用它们来完成特定目的,所以我们可以按操作系统命名每个构建标签:
//go:build darwin
//go:build linux
//go:build windows
让我们回顾一下音频文件代码。记得在 play
命令中,我们检查 runtime
操作系统,然后调用一个特定方法。让我们使用构建标签重写这段代码。
音频文件中的示例
让我们先简化命令的代码如下:
var playCmd = &cobra.Command{
Use: "play",
Short: "Play audio file by id",
Long: `Play audio file by id`,
RunE: func(cmd *cobra.Command, args []string) error {
b, err := getAudioByID(cmd)
if err != nil {
return err
}
audio := models.Audio{}
err = json.Unmarshal(b, &audio)
if err != nil {
return err
}
return play(audio.Path)
},
}
通过删除操作系统切换语句和实现每个操作系统播放功能的三个函数,我们极大地简化了代码。相反,我们创建了三个新的文件:play_darwin.go
、play_windows.go
和 play_linux.go
。在每个文件中都有一个针对每个操作系统的构建标签。以 Darwin 文件 play_darwin.go
为例:
//go:build darwin
package cmd
import (
"fmt"
"os/exec"
)
func play(audiofilePath string) error {
cmd := exec.Command("afplay", audiofilePath)
if err := cmd.Start(); err != nil {
return err
}
fmt.Println("enjoy the music!")
err := cmd.Wait()
if err != nil {
return err
}
return nil
}
注意到 play
函数已被重命名为与 play.go
中 play
命令中调用的函数相匹配。由于只有一个文件被包含在构建中,因此不会混淆哪个 play
函数被调用。我们确保在 make
文件中只调用一个,这是我们目前运行应用程序的方式。在 Makefile
中,我指定了一个专门为 Darwin 构建的命令:
build-darwin:
go build -tags darwin -o bin/audiofile main.go
chmod +x bin/audiofile
为 Windows 和 Linux 创建了一个包含 play
函数的 Go 文件。在构建应用程序时,每个操作系统的特定标签也需要类似地传递给 -tags
标志。在后面的章节中,我们将讨论交叉编译,这是下一步。但在我们这样做之前,让我们通过回顾一个列表来结束这一章,这个列表列出了在为多个平台开发时需要记住的操作系统级别的差异。
操作系统级别的差异
由于您将为主要的操作系统构建应用程序,了解它们之间的差异以及需要注意的事项非常重要。让我们从以下列表开始深入了解:
-
在 Windows 中,反斜杠(
\
)用作目录分隔符,而 Linux 和 Unix 使用正斜杠(/
)。 -
权限:
-
类 Unix 系统使用文件模式来管理权限,其中权限分配给文件和目录。
-
Windows 使用 访问控制列表(ACL)来管理权限,其中权限以更灵活和细粒度的方式分配给文件或目录中的特定用户或组。
-
通常,在开发任何命令行应用程序时,无论它将在哪个操作系统上运行,仔细考虑用户和组权限都是一个好习惯。* Go 中的
exec
包提供了一种方便的方式,可以在终端中以相同的方式运行命令。然而,需要注意的是,命令及其参数必须以正确的格式传递给每个操作系统。* 在 Windows 上,您需要指定文件扩展名(例如,.exe
、.bat
等)以运行可执行文件。* 环境变量: -
环境变量可以用来配置您的应用程序,但它们的名称和值在 Windows 和 Linux/Unix 之间可能不同。
-
在 Windows 上,环境变量名称不区分大小写,而在 Linux/Unix 上,它们是区分大小写的。*
\r
) 后跟一个换行符 (\n
),而 Linux/Unix 只使用换行符 (\n
)。*os/signal
包提供了一种处理发送到您应用程序的信号的方法。然而,此包在 Windows 上不受支持。* 要以跨平台的方式处理信号,您可以使用os/exec
包代替。*os.Stdin
属性,而在 Linux/Unix 上,您可以使用os.Stdin
或bufio
包来读取用户输入。*go-colorable
提供了一种平台无关的方式来处理控制台颜色。*os.Stdin
、os.Stdout
和os.Stderr
在 Windows 和 Linux/Unix 之间可能表现不同。在两个平台上测试您的代码以确保它按预期工作是很重要的。
-
这些是在 Go 中为不同操作系统开发命令行应用程序时需要注意的一些差异。在各个平台上彻底测试您的应用程序以确保它按预期工作是很重要的。
摘要
您的应用程序支持的操作系统越多,事情就会变得越复杂。希望您掌握了开发平台无关应用程序的一些支持包的知识,您将对自己的应用程序能够在不同的操作系统上以类似的方式运行而感到自信。此外,通过检查 runtime
操作系统,甚至使用构建标签将代码分离成不同的操作系统特定文件,您至少有几种定义代码组织方式的选择。这一章可能比必要的更深入,但希望它能给您带来灵感。
为多个操作系统构建将扩展您命令行应用程序的使用范围。您不仅能够接触到 Linux 或 Unix 用户,还能接触到 Darwin 和 Windows 用户。如果您想扩大用户群,那么构建支持更多操作系统的应用程序是一种简单的方法。
在下一章,第八章,为人类构建与为机器构建,我们将学习如何构建一个根据接收者是谁(机器或人类)输出 CLI 的命令行界面。我们还将学习如何构建清晰的语言结构,并为与社区中其他 CLI 保持一致而命名命令。
问题
-
操作系统中存在两种不同的时钟,是哪一个?Go 中的
time.Time
结构体存储哪一个时钟,或者两者都存储?计算持续时间应该使用哪一个? -
哪个包常量可以用来确定
runtime
操作系统? -
在 Go 文件中,构建标签注释设置在哪里——顶部、底部还是定义函数之上?
答案
-
墙上时钟和单调时钟。
time.Time
结构体存储了两个时间值。计算持续时间时应使用单调时钟值。 -
runtime.GOOS
-
在 Go 文件的顶部第一行。
进一步阅读
- 访问在线文档,了解在
pkg.go.dev/
中讨论的包。
第三部分:交互性和同理心驱动的设计
本部分主要介绍如何从最终用户的角度出发,开发一个更用户友好的命令行界面(CLI)。它涵盖了诸如为人类而非机器构建、使用 ASCII 艺术提高信息密度、确保标志名称和参数的一致性等主题。本节还强调了同理心在 CLI 开发中的重要性,包括以用户友好的方式重写错误、提供详细的日志记录、创建手册页和用法示例。此外,还讨论了通过提示和终端仪表板进行交互的好处,并提供了如何使用 Termdash 库构建用户提示和仪表板的示例。
本部分包含以下章节:
-
第八章,为人类而非机器构建
-
第九章,开发的同理心方面
-
第十章,使用提示和终端仪表板进行交互
第八章:为人类与机器构建
在开发你的命令行应用程序时考虑你的最终用户会使你成为一个更有同理心的开发者。不仅要考虑你对某些命令行界面(CLIs)行为的感受,还要考虑你如何改进自己和他人体验。可用性方面有很多内容,不可能全部压缩在一个章节中,所以我们建议你阅读进一步阅读部分中建议的文章和书籍。
当你构建命令行界面时,首先要考虑的是,尽管它将主要用于人类,但它也可以在脚本中调用,你的程序输出可以被用作其他应用程序的输入,例如grep或awk。在本章中,我们将介绍如何为两者构建以及如何判断你是在输出给一个还是另一个。
第二点是使用 ASCII 艺术来增加信息密度。无论你是以表格形式输出数据,还是添加颜色或表情符号,目的都是让信息以终端用户能够快速理解的方式从终端中跳出来。
最后,一致性也增加了用户界面的清晰度。当你的 CLI 在不同命令和子命令的标志名称和位置参数中使用一致性时,用户在导航 CLI 时可以更有信心。到本章结束时,你可能会在构建 CLI 时有更多的考虑,并会被提示进行可用性改进。在本章中,我们将涵盖以下主题:
-
为人类与机器构建
-
使用 ASCII 艺术增加信息密度
-
保持命令行界面的一致性
技术要求
你需要有一个 Unix 操作系统来理解并运行本章中分享的示例。
你也可以在 GitHub 上找到代码示例:github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter08
。
为人类与机器构建
CLI 有着悠久的历史,它们的交互是为其他程序和机器量身定制的。它们的设计更类似于程序内的函数,而不是图形界面。正因为如此,许多 Unix 程序今天仍然基于它们将与另一个程序交互的假设运行。
然而,如今,CLI 更多地被人类使用,而不是其他机器,尽管它们仍然携带过时的交互设计。是我们为它们的主要用户——人类构建 CLI 的时候了。
在本节中,我们将比较以机器为先的设计与以人为先的设计,并学习如何检查你是否正在向 TTY 输出。正如我们可以从 第一章 中回忆起的,理解 CLI 标准,TTY 是 TeleTYpewriter 的缩写,它演变成了与大型主机交互的输入和输出设备。在当今世界,操作系统的桌面环境,或简称为 OSs,提供了一个终端窗口。这个终端窗口是一个虚拟的电传打字机。它们通常被称为 伪电传打字机,或简称为 PSY。这也表明另一端是一个人类,而不是一个程序。
它是 TTY 吗?
首先,让我们了解设备。设备可以是硬盘、RAM 磁盘、DVD 播放器、键盘、鼠标、打印机、磁带驱动器,到 TTY 等。设备驱动程序提供了操作系统和设备之间的接口;它提供了一个操作系统理解和接受的 API。
图 8.1 – 显示通过设备驱动程序从操作系统到 TTY 设备的通信的图
在基于 Unix 的操作系统上,有两个主要的设备驱动程序:
-
块 – 硬盘、RAM 磁盘和 DVD 播放器等设备的接口
-
字符 – 键盘、鼠标、打印机、磁带驱动器、TTY 等的接口
如果你检查标准输入、stdin 或标准输出、stdout 是一个 字符 设备,那么你可以假设你正在从人类接收输入或将输出发送给人类。
在 Unix 或 Linux 操作系统中它是 TTY 吗?
在终端中,如果你输入 tty
命令,它将输出连接到 stdin 的文件名。实际上,这是终端窗口的编号。
让我们在 Unix 终端窗口中运行这个命令,看看结果是什么:
mmontagnino@Marians-MacBook-Pro marianina8 % tty
/dev/ttys014
有一个简短的静默,-s
,标志可以用来抑制输出。然而,应用程序仍然返回一个退出码:
-
退出码 0 – 标准输入来自 TTY
-
退出码 1 – 标准输入不是来自 TTY
-
退出码 2 – 无效参数的语法错误
-
退出码 3 – 写入错误
在 Unix 中,在命令后跟 &&
表示第二个命令只有在第一个命令成功运行,退出码为 0 时才会执行。所以,让我们尝试这段代码来看看我们是否在 TTY 中运行:
mmontagnino@Marians-MacBook-Pro marianina8 % tty -s && echo "this is a tty"
this is a tty
由于我们在终端中运行了这些命令,结果是 this is
a tty
。
在 Unix 或 Linux 操作系统中程序化检查
有几种方法可以编程地做到这一点。我们可以使用位于 Chapter-8/isatty.go
文件中的代码:
func IsaTTY() {
fileInfo, _ := os.Stdout.Stat()
if (fileInfo.Mode() & os.ModeCharDevice) != 0 {
fmt.Println("Is a TTY")
} else {
fmt.Println("Is not a TTY")
}
}
之前的代码使用以下代码从标准输出,stdout,文件中获取文件信息:
fileInfo, _ := os.Stdout.Stat()
然后,我们检查 fileInfo.Mode()
和 os.ModeCharDevice
之间的位运算,&
的结果。位运算符 &
如果两个操作数中都有该位,则将其复制到结果中。
让我们举一个非常简单的例子:在真值表中 7&6
。7
的值用二进制 111
表示,6
的值用 110
表示。
图 8.2 – 显示 & 操作计算的真值表
&
操作检查每个位,并判断它们是否相同,如果是,则进位一个位,或 1。如果位不同,则不进位,或 0。结果值是 110
。
现在,在我们的更复杂的例子中,以下代码 fileInfo.Mode() & os.ModeCharDevice
在 fileInfo.Mode()
和 os.ModeCharDevice
之间执行位运算。让我们看看当代码的标准输出连接到终端时,这个操作看起来像什么:
是 一个 TTY |
---|
代码 |
fileInfo.Mode() |
os.ModeCharDevice |
fileInfo.Mode() & os.ModeCharDevice |
(fileInfo.Mode() & os.ModeCharDevice ) != 0 |
图 8.3 – 当标准输出连接到 TTY 时,代码与其值的相邻
在 *图 8**.3 中,标准输出的文件模式由 fileInfo.Mode()
方法调用定义;其值是 os.ModeDevice
,os.ModeCharDevice
,stdin
对 os.ModCharDevice
,我们看到相同的位被进位,结果不等于零,因此 (fileInfo.Mode() & os.ModeCharDevice) != 0
是 true,设备是一个 TTY。
如果输出被管道传输到另一个进程,这段代码会是什么样子?让我们看看:
不是 一个 TTY |
---|
代码 |
fileInfo.Mode() |
os.ModeCharDevice |
fileInfo.Mode() & os.ModeCharDevice |
(fileInfo.Mode() & os.ModeCharDevice ) != 0 |
图 8.4 – 当标准输出未连接到 TTY 时,代码与其值的相邻
现在标准输出的值是 os.ModeNamedPipe
,os.ModeCharDevice
,我们看到没有位被复制,因此 (fileInfo.Mode() & os.ModeCharDevice) != 0
是 false,设备不是一个 TTY。
在任何操作系统上编程检查
我们建议使用一个已经为检查更大集合的操作系统确定代码的包,以检查标准输出是否发送到 TTY。我们发现最受欢迎的包是 github.com/mattn/go-isatty,我们在 Chapter-8/utils/isatty.go
文件中使用了它:
package utils
import (
"fmt"
"os"
isatty "github.com/mattn/go-isatty"
)
func IsaTTY() {
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.
IsCygwinTerminal(os.Stdout.Fd()) {
fmt.Println("Is a TTY")
} else {
fmt.Println("Is not a TTY")
}
}
现在我们知道了我们是输出到一个 TTY,这表明另一端有一个人,而不是 TTY,我们可以相应地调整我们的输出。
为机器设计
如前所述,CLI 最初是为机器设计的。了解设计另一个程序的确切含义很重要。虽然我们希望将我们的应用程序调整为以人为中心的设计,但有时我们需要以可以轻松传递给grep
或awk
命令的方式输出,因为其他应用程序会期望接收纯文本或 JSON 文本的流。
用户可能会以许多意想不到的方式使用您的 CLI。其中一些方式通常是在 bash 脚本中,将您的命令的输出作为输入传递给另一个应用程序。如果您的应用程序,正如它应该做的那样,首先以人类可读的格式输出,那么当标准输入未连接到 TTY 终端时,它也需要以机器可读的格式输出。在后一种情况下,请确保任何颜色和 ASCII 艺术,例如进度条,都被禁用。文本应该是单行表格数据,可以轻松地与grep
和awk
工具集成。
此外,当需要时,为用户提供几个持久标志,以便以机器可读的输出格式输出也很重要:
-
--plain
,用于输出每行一个数据记录的纯文本 -
--json
,用于输出可以传递到 curl 命令的 JSON 文本 -
--quiet
、-q
或--silent
、-s
,用于抑制非必要输出
当不影响可用性时,提供纯文本。在其他情况下,提供可选的前置标志,使用户能够轻松地将输出传递到另一个应用程序的输入。
为人类设计
现代的命令行应用程序是为其主要消费者——人类设计的。这可能会看似使界面复杂化,因为需要考虑的因素更多。数据的输出方式和数据返回的速度可能会影响用户对您的 CLI 的质量和鲁棒性的感知。我们将讨论一些关键的设计领域:
-
对话成为常态
-
同理心
-
个性化
-
视觉语言
让我们更详细地探讨每一个方面,以便我们能够完全理解这对以人为中心的设计有何影响。
对话成为常态
由于您的 CLI 将响应人类而不是另一个程序,因此交互应该像对话一样流畅。将您的应用程序视为 CLI 使用的指南,也会让用户感到更加自在。
当用户运行命令而缺少重要的标志或参数时,则您的应用程序可以提示这些值。提示或调查是包括提问和从用户那里接收答案的对话式来回流程的方式。然而,提示不应成为必需的,因为标志和参数应该是命令的可选选项。我们将在第十章“使用提示和终端仪表板进行交互”中更详细地讨论提示。
如果您的应用程序包含状态,则类似于git
提供的status
命令并通知用户任何命令更改状态的方式,传达当前状态。同样,如果您的应用程序提供工作流程,通常由一系列命令定义,那么您可以建议运行下一个命令。
在与用户沟通时,简洁很重要。就像在对话中一样,如果我们用过多的无关信息混淆我们的语言,人们可能会对我们的意图感到困惑。通过传达重要信息,但保持简短,我们的用户将快速获得最重要的信息。
上下文很重要。如果您在与最终用户而不是开发者沟通,这会有所不同。在这种情况下,除非您处于详细模式,否则没有必要输出只有开发者才能理解的内容。
如果用户正在执行任何危险的操作,请请求确认,并将确认级别与命令可能引发的危险级别相匹配:
-
delete
命令,不需要确认 -
如果不是
delete
命令,则提示确认 -
适度:
-
示例:删除目录、远程资源或无法轻易撤销的大批量修改
-
确认:
-
提示确认。
-
提供一个dry run操作。dry run操作用于查看操作的结果,而实际上不对数据进行任何修改。*
–confirm="name-of-resource"
使其仍然可脚本化
-
-
通常,我们希望让用户越来越难以执行更难的事情。这是一种引导用户避免任何意外的方法。
任何用户输入都应该尽早进行验证,以防止发生不必要的坏事情。确保返回的错误对传递了坏数据的用户是可理解的。
在对话中,必须确保任何机密信息得到保护。确保任何密码都得到保护,并为用户提供安全的方法提交他们的凭证。例如,仅考虑通过文件接受敏感数据。您可以提供一个–password-file
标志,允许用户通过标准输入传递文件或数据。这种方法为传递秘密数据提供了一种隐蔽的方法。
在对话中保持透明。任何超出程序边界的操作都应明确说明。这包括读取或写入用户未作为参数传递的文件,除非这些文件在缓存中存储内部状态。这还可能包括与远程服务器通信时的任何操作。
最后,响应时间比速度更重要。在 100 毫秒内向用户打印一些内容。如果您正在发起网络请求,请在请求之前打印一些内容,这样就不会看起来像应用程序挂起或出现故障。这将使您的应用程序对最终用户看起来更健壮。
让我们回顾我们的音频元数据 CLI 项目。在第八章的audiofile
仓库中,我们将进行一些更改以创建可能缺失的对话流程。
示例 1:当标志缺失时提示信息
使用 Cobra CLI,如果需要标志,当命令调用时如果标志缺失,它会自动返回错误。根据本节中提到的某些指南,而不是仅仅返回错误,让我们提示缺失的数据。在第八章的audiofile
代码中,在utils/ask.go
文件中,我们使用调查包github.com/AlecAivazis/survey/v2创建了两个函数,如下所示:
func AskForID() (string, error) {
id := ""
prompt := &survey.Input{
Message: "What is the id of the audiofile?",
}
survey.AskOne(prompt, &id)
if id == "" {
return "", fmt.Errorf("missing required argument: id")
}
return id, nil
}
func AskForFilename() (string, error) {
file := ""
prompt := &survey.Input{
Message: "What is the filename of the audio to upload
for metadata extraction?",
Suggest: func(toComplete string) []string {
files, _ := filepath.Glob(toComplete + "*")
return files
},
}
survey.AskOne(prompt, &file)
if file == "" {
return "", fmt.Errorf("missing required argument:
file")
}
return file, nil
}
当检查传递的标志和值是否仍然为空时,现在可以调用这两个函数。例如,在cmd/get.go
文件中,我们检查id
标志的值,如果它仍然为空,则提示用户输入id
:
id, _ := cmd.Flags().GetString("id")
if id == "" {
id, err = utils.AskForID()
if err != nil {
return nil, err
}
}
运行此命令会给用户以下体验:
mmontagnino@Marians-MBP audiofile % ./bin/audiofile get
? What is the id of the audiofile?
类似地,在cmd/upload.go
文件中,我们检查文件名标志的值,如果它仍然为空,则提示用户输入文件名。因为提示允许用户深入查看建议的文件,所以我们现在得到了以下体验:
mmontagnino@Marians-MBP audiofile % ./bin/audiofile upload
? What is the filename of the audio to upload for metadata extraction? [tab for suggestions]
然后,按 Tab 键以显示建议并揭示下拉菜单:
mmontagnino@Marians-MBP audiofile % ./bin/audiofile upload
? What is the filename of the audio to upload for metadata extraction? audio/beatdoctor.mp3 [Use arrows to move, enter to select, type to continue]
audio/algorithms.mp3
> audio/beatdoctor.mp3
audio/nightowl.mp3
提供提示有助于引导用户并让他们了解如何运行命令。
示例 2:确认删除
另一种帮助用户安全使用 CLI 并防止他们犯错误的方法是在进行危险操作时请求用户确认。尽管在明确的删除操作中这样做不是必需的,但我们创建了一个可以在任何类型的危险情况下使用、带有可配置信息的确认函数。该函数位于utils/confirm.go
文件中:
func Confirm(confirmationText string) bool {
confirmed := false
prompt := &survey.Confirm{
Message: confirmationText,
}
survey.AskOne(prompt, &confirmed)
return confirmed
}
示例 3:在网络请求时通知用户
在发出任何 HTTP 请求之前,通知用户有助于他们了解正在发生的事情,特别是如果请求挂起或变得无响应。我们在每个命令的网络请求之前添加了一条消息。get
命令现在在客户端运行Do
方法之前有以下一行:
fmt.Printf("Sending request: %s %s %s...\n",
http.MethodGet, path, payload)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
同理心
您可以对命令行应用程序进行一些简单的修改,以体现对用户的同理心:
-
帮助:
-
提供帮助文本和文档
-
建议命令
-
以可理解的方式重写错误
-
-
邀请用户反馈和错误提交
在第九章《开发的同理心一面》中,我们将探讨您可以通过帮助文本、文档、广泛的支持以及为用户提供轻松提供反馈和提交错误的方式,帮助用户走向成功的方法。
示例 1:提供命令建议
当用户误输入命令时,Cobra CLI 会提供一些同情。让我们看看以下示例,其中用户将 upload
错误地输入为 upolad
:
mmontagnino@Marians-MacBook-Pro audiofile % ./bin/audiofile upolad
Error: unknown command "upolad" for "audiofile"
Did you mean this?
upload
Run 'audiofile --help' for usage.
示例 2 – 提供一种轻松提交错误报告的方式
在 第九章“开发的同理心方面”,我们定义了一个错误命令,该命令将启动默认浏览器并导航到 GitHub 仓库的新问题页面以提交错误报告:
mmontagnino@Marians-MacBook-Pro audiofile % ./bin/audiofile bug --help
Bug opens the default browser to start a bug report which will include useful system information.
Usage:
audiofile bug [flags]
Examples:
audiofile bug
示例 3:使用错误的打印用法命令
假设用户在运行搜索命令时没有输入要搜索的值。CLI 应用程序将提示用户输入要搜索的值。如果用户没有传入值,CLI 将输出命令的正确用法:
mmontagnino@Marians-MacBook-Pro audiofile % ./bin/audiofile search
? What value are you searching for?
Error: missing required argument (value)
Usage:
audiofile search [flags]
Flags:
-h, --help help for search
--json return json format
--plain return plain format
--value string string to search for in metadata
个性化
通常,使默认设置对大多数用户来说都是正确的,但同时也允许用户通过 CLI 个性化他们的体验。配置给用户一个机会来个性化他们的 CLI 体验,使其更加符合他们的需求。
示例 1:使用 Viper 的技术配置
以 audiofile
为例,让我们使用 Viper 创建一个简单的配置设置,使用户能够根据他们的喜好更改任何默认设置。我们创建的配置是为 API 和 CLI 应用程序。对于 API,我们定义了 configs/api.json
文件,其中包含以下内容:
{
"api": {
"port": 8000
}
}
API 将始终在执行的地方本地执行。然后,对于 CLI,我们定义了一个类似的简单文件,configs/cli.json
,包含以下内容:
{
"cli": {
"hostname": "localhost",
"port": 8000
}
}
如果 API 在具有不同端口号的外部主机上运行,则可以在配置中修改这些值。为了使 CLI 指向新的主机名,我们需要更新 CLI 命令中的任何引用以使用配置中的值。例如,在 cmd/get.go
文件中,路径被定义为:
path := fmt.Sprintf("http://%s:%d/request?%s",
viper.Get("cli.hostname"), viper.GetInt("cli.port"),
params)
为了初始化这些值并在配置中缺少任何必需的值时提供默认值,我们运行定义在 cmd/root.go
中的 Configure
函数:
func Configure() {
viper.AddConfigPath("./configs")
viper.SetConfigName("cli")
viper.SetConfigType("json")
viper.ReadInConfig()
viper.SetDefault("cli.hostname", "localhost")
viper.SetDefault("cli.port", 8000)
}
在 cmd/api.go
文件中存在类似的代码,用于收集一些相同的信息。现在,如果用户想要更改主机名、日志级别或端口号,只需修改一个配置文件。
示例 2:环境变量配置
假设有一个特定于应用程序的环境变量,允许用户定义要使用的前景色和背景色。这个环境变量可以命名为 AUDIOFILE_COLOR_MODE
。再次使用 Viper 配置,可以使用前景色和背景色文本来覆盖默认设置。虽然这在我们 CLI 中没有实现,但 Viper 配置可能看起来如下:
{
"cli": {
"colormode": {
"foreground": "white",
"background": "black",
}
}
}
示例 3:存储位置
有时,用户希望某些输出,例如日志,存储在特定区域。在 Viper 中提供详细信息可以允许覆盖默认值。再次强调,这目前还没有在我们的 CLI 中实现,但如果我们在配置中提供此选项,它可能看起来像这样:
{
"api": {
"local_storage": "/Users/mmontagnino/audiofile"
}
}
任何其他新的配置值都可以用类似的方法添加。提供配置应用程序的能力是个性化的起点。想想你有多少种方式可以配置你的 CLI:颜色设置、禁用提示或 ASCII 艺术、默认格式化等等。
分页
当你输出大量文本时,请使用分页器,但要注意,有时实现可能会出错。
Unix 或 Linux 的分页
在 Unix 或 Linux 机器上,你可以使用less
命令进行分页。使用合理的选项集调用less
命令,如less -FIRX
,如果内容适合单屏,则不会发生分页,搜索时忽略大小写,启用颜色和格式,当less
退出时内容保持在屏幕上。我们将在下一节输出表格数据时使用这个例子,作为准备,在utils
包中,我们添加以下文件:pager_darwin.go
和pager_linux.go
,包含一个Pager
函数。然而,在我们的情况下,我们只使用-r
标志,因为我们想继续在表格中显示颜色:
func Pager(data string) error {
lessCmd := exec.Command("less", "-r")
lessCmd.Stdin = strings.NewReader(data)
lessCmd.Stdout = os.Stdout
lessCmd.Stderr = os.Stderr
err := lessCmd.Run()
if err != nil {
return err
}
return nil
}
Windows 的分页
在 Windows 机器上,我们使用more
命令。在utils
包中,我们添加了pager_windows.go
文件,并跟随一个Pager
函数:
func Pager(data string) error {
moreCmd := exec.Command("cmd", "/C", "more")
moreCmd.Stdin = strings.NewReader(data)
moreCmd.Stdout = os.Stdout
moreCmd.Stderr = os.Stderr
err := moreCmd.Run()
if err != nil {
return err
}
return nil
}
现在,你已经知道了如何在三个主要操作系统上处理输出分页。当你输出大量数据以便用户轻松滚动时,这也会帮助用户。
视觉语言
根据数据,用户可能更容易以纯文本、表格格式或 JSON 格式查看它。请记住,使用-plain
或-json
标志为用户提供选项,以他们喜欢的格式返回数据。
注意
有时,为了使所有数据都显示在用户的窗口中,某些行可能被包裹在单元格中。这将破坏脚本。
有许多视觉提示可以显示给用户,以增加信息密度。例如,如果某件事情需要很长时间,可以使用进度条并提供剩余时间的估计。如果成功或失败,利用颜色代码为用户提供额外的信息层次。
我们现在知道如何确定我们是通过终端输出给人类还是输出给另一个应用程序,因此了解这些差异使我们能够适当地输出数据。让我们继续到下一节,讨论一些有趣的例子,通过 ASCII 可视化提供数据以增加信息密度。
使用 ASCII 艺术增加信息密度
如本节标题所述,您可以使用 ASCII 艺术来增加信息密度。例如,运行ls
命令会以用户易于用眼睛扫描和理解的方式显示文件权限。同样,在教科书学习时使用荧光笔实际突出显示一句话或一组单词,可以使某些短语显得更加重要。在本节中,我们将讨论一些 ASCII 艺术的常见用途,以增加共享信息重要性的理解。
使用表格显示信息
向用户展示数据最清晰的方式可能是以表格格式。就像ls
格式一样,在表格格式中,模式更容易跳出来。有时记录可能包含比屏幕宽度更长的数据,行会自动换行。这可能会破坏依赖于每行一个记录的脚本。
让我们以我们的音频文件为例,而不是返回 JSON 输出,而是使用该包以整洁的方式返回数据,以表格形式。我们可以保留返回 JSON 输出的能力,以便当用户决定使用–json
标志要求它时使用。
使用pterm
包以表格形式输出数据的最简单方法是使用默认表格。在模型旁边,目前存在一个JSON()
方法,它将接受结构并将其以 JSON 格式输出。同样,我们在结构指针上添加了一个Table()
方法。在models/audio.go
文件中,我们添加以下代码用于表头:
var header = []string{
"ID",
"Path",
"Status",
"Title",
"Album",
"Album Artist",
"Composer",
"Genre",
"Artist",
"Lyrics",
"Year",
"Comment",
}
这定义了音频表的表头。然后我们添加一些代码将audio
结构体转换为行:
func row(audio Audio) []string {
return []string{
audio.Id,
audio.Path,
audio.Status,
audio.Metadata.Tags.Title,
audio.Metadata.Tags.Album,
audio.Metadata.Tags.AlbumArtist,
audio.Metadata.Tags.Composer,
audio.Metadata.Tags.Genre,
audio.Metadata.Tags.Artist,
audio.Metadata.Tags.Lyrics,
strconv.Itoa(audio.Metadata.Tags.Year),
strings.Replace(audio.Metadata.Tags.Comment, "\r\n",
"", -1),
}
}
现在我们使用pterm
包从表头行和将音频项转换为行的函数创建表格。Audio
和AudioList
结构体的Table()
方法定义如下:
func (list *AudioList) Table() (string, error) {
data := pterm.TableData{header}
for _, audio := range *list {
data = append(
data,
row(audio),
)
}
return pterm.DefaultTable.WithHasHeader()
.WithData(data).Srender()
}
func (audio *Audio) Table() (string, error) {
data := pterm.TableData{header, row(*audio)}
return pterm.DefaultTable.WithHasHeader().WithData(data).
Srender()
}
本例中的所有数据都是按每行一个记录输出的。如果您决定使用不同的实现方式,并且您的代码不是这种情况,请确保添加–plain
标志作为可选标志,一旦调用,它将按行打印一个记录。这样做将确保脚本不会在命令输出时中断。无论如何,根据数据的大小和终端的大小,您可能会注意到数据会自动换行,这可能难以阅读。如果您正在运行 Unix,请运行tput rmam
命令从terminal.app
中删除行换行,然后运行tput smam
将行换行添加回来。在 Windows 上,您可以在控制台属性下找到设置。无论如何,这应该会使查看表格数据变得更容易!
如果表格中返回了大量的数据,那么添加分页对于提高可用性很重要。如上一节所述,我们在 utils
包中添加了一个 Pager
函数。让我们修改代码,使其检查数据是否被输出到终端,如果是,则使用 Pager
函数分页数据。在 utils/print.go
文件中的 Print
函数内,对 JSON 格式的数据进行分页,例如如下所示:
if jsonFormat {
if IsaTTY() {
err = Pager(string(b))
if err != nil {
return b, fmt.Errorf("\n paging: %v\n ", err)
}
} else {
return b, fmt.Errorf("not a tty")
}
}
如果输出返回到终端,那么我们进行分页,否则我们返回带有错误的字节数,通知调用函数它不是一个终端。例如,cmd/list.go
文件调用了前面的 Print
函数:
formatedBytes, err := utils.Print(b, jsonFormat)
if err != nil {
fmt.Fprintf(cmd.OutOrStdout(), string(formatedBytes))
}
当它收到错误时,它就只将字符串值打印到标准输出。
使用表情符号进行澄清
一图胜千言。只需添加一个表情符号,就能分享如此多的信息。例如,想想那个简单的绿色勾选框,,它在 Slack 或 GitHub 上经常被用来表示批准。然后,还有相反的情况,一个红色的叉号,
,用来表示出了问题。
表情符号是存在于 UTF-8(Unicode)字符集中的字母,它涵盖了世界上几乎所有的字符和符号。有一些网站会分享这个 Unicode 表情符号映射。访问 https://unicode.org/emoji/charts/full-emoji-list.html
来查看完整的字符列表。
示例 1 – 成功操作的绿色勾选标记
在我们的音频文件中,我们将表情符号添加到 upload
命令的输出中。在文件顶部,我们添加了表情符号常量及其 UTF-8 字符代码:
const (
checkMark = "\U00002705"
)
然后,我们在以下输出中使用它:
fmt.Println(checkMark, " Successfully uploaded!")
fmt.Println(checkMark, " Audiofile ID: ", string(body))
在重新编译和运行之后运行上传命令,可以看到表情符号紧邻输出,表示上传成功。绿色的勾选标记确保用户一切按预期运行,没有错误:
Successfully uploaded!
Audiofile ID: b91a5155-76e9-4a70-90ea-d659c66d39e2
示例 2 – 搜索操作中的放大镜
当用户在没有 --value
标志的情况下运行搜索命令时,我们也以类似的方式添加了一个放大镜,。新的提示符看起来像这样:
? What value are you searching for?
示例 3 – 错误信息的红色
如果有无效操作或错误信息,你还可以添加一个红色的叉号来表示出了问题:
Error message!
表情符号不仅为你的 CLI 增加了趣味性,而且非常有价值。这个小表情符号是另一种增加信息密度并将重要观点传达给用户的方式。
有意使用颜色
添加颜色可以突出显示对最终用户重要信息。不过,不要过度使用;如果你经常使用多种不同的颜色,那么任何东西都很难显得重要。所以,要适量使用,但也要有目的性。
对于错误来说,一个明显的颜色选择是红色,而对于成功则是绿色。一些软件包使向 CLI 添加颜色变得容易。在我们的示例中,我们将使用的一个这样的软件包是 https://github.com/fatih/color
。
在 audiofile 中,我们查看了一些可以集成颜色的示例。例如,我们刚刚列出的表格的 ID。我们导入库,然后使用它来改变ID
字段的颜色:
var IdColor = color.New(color.FgGreen).SprintFunc()
func row(audio Audio) []string {
return []string{
IdColor(audio.Id),
...
}
}
在utils/ask.go
文件中,我们定义了一个error
函数,该函数可以在三个询问提示中使用。
var (
missingRequiredArumentError =
func(missingArg string) error {
return fmt.Errorf(errorColor(fmt.Sprintf("missing
required argument (%s)", missingArg)))
}
)
fmt.Errorf
函数接收errorColor
函数,该函数定义在新的utils/errors.go
文件中:
package utils
import "github.com/fatih/color"
var errorColor = color.New(color.BgRed,
color.FgWhite).SprintFunc()
一起,我们重新编译代码并尝试再次运行它,故意省略命令中所需的标志。我们看到命令出错,并以红色背景和白色前景打印错误,这些由color.BgRed
和color.FgWhite
值定义。有许多添加颜色的方法。在我们使用的color
包中,前缀Fg
代表前景,前缀Bg
代表背景。
有意使用颜色,您将能够轻松地将最重要的信息视觉上传递给最终用户。
旋转器和进度条
旋转器和进度条表示命令仍在处理中;唯一的区别是进度条可以直观地显示进度。由于在应用程序中构建并发是常见的,您也可以同时显示多个进度条。想想 Docker CLI 经常同时显示多个文件下载的情况。这有助于用户理解正在发生某些事情,进度正在取得,没有停滞不前。
示例 1 – 播放音乐时的旋转器
您可以将旋转器添加到 Golang 项目的不同方式。在 audiofile 项目中,我们将展示使用github.com/pterm/pterm
包快速添加旋转器的方法。在 audiofile 项目中,对于每个操作系统的特定播放命令,我们添加一些代码来启动和停止旋转器。以play_darwin.go
为例:
func play(audiofilePath string) error {
cmd := exec.Command("afplay", audiofilePath)
if err := cmd.Start(); err != nil {
return err
}
spinnerInfo := &pterm.SpinnerPrinter{}
if utils.IsaTTY() {
spinnerInfo, _ = pterm.DefaultSpinner.Start("Enjoy the
music...")
}
err := cmd.Wait()
if err != nil {
return err
}
if utils.IsaTTY() {
spinnerInfo.Stop()
}
return nil
}
对任何音频文件运行play
命令将显示以下输出:
▀ Enjoy the music... (3m54s)
在上一行中很难捕捉到旋转器,但黑色盒子在音乐播放时在圆形中旋转。
示例 2 – 上传文件时的进度条
接下来,在upload
命令中,我们可以展示代码来显示上传文件的进度。由于 API 仅使用本地平面文件存储,上传速度非常快,以至于很难看到进度条的变化,但您可以在每次增加之间添加一些time.Sleep
调用,以便更逐渐地显示进度。在cmd/upload.go
文件中,我们添加了几个语句来创建进度条,并随着标题更新来增加进度:
p, _ := pterm.DefaultProgressbar.WithTotal(4).WithTitle("Initiating upload...").Start()
这第一行初始化进度条,然后要更新进度条,以下行被使用:
pterm.Success.Println("Created multipart writer")
p.Increment()
p.UpdateTitle("Sending request...")
注意,当我们首次定义进度条时,我们调用了 WithTotal
方法,它接受总步数。这意味着对于每次调用 p.Increment()
的步骤,进度条会前进 25% 或总步数的 100 分之 1。当运行旋转器时,添加可视化器以让用户知道应用程序目前正在运行可能需要一些时间的命令是很好的:
Process response... [4/4] ███████████ 65% | 5s
进度条为用户提供了一个快速的可视化,显示了命令的执行进度。对于任何需要很长时间执行且可以明显分为多个步骤的命令来说,这是一个很好的视觉指示器。再次强调,除非输出被显示在终端或 TTY 上,否则不应显示旋转器或进度条。确保在输出进度条或旋转器之前添加对 TTY 的检查。
禁用颜色
有多种原因可能导致 CLI 中禁用颜色。其中一些原因包括:
-
标准输出或标准错误管道未连接到 TTY 或交互式终端。有一个例外。如果 CLI 在 CI 环境中运行,例如 Jenkins,那么通常支持颜色,建议保持颜色开启。
-
NO_COLOR
或MYAPP_NO_COLOR
环境变量被设置为 true。这可以定义并设置为禁用检查它的所有程序的颜色,或者专门为您的程序禁用颜色。 -
TERM
环境变量被设置为 dumb。 -
用户传递了
–no-color
标志。
您的用户中可能有一部分是色盲。允许用户交换一种颜色为另一种颜色是考虑您用户基础中这一特定部分的一个好方法。这可以在配置文件或应用程序中完成。允许他们指定一种颜色,然后用首选颜色覆盖它,这再次允许用户自定义 CLI。这种自定义将为用户提供更好的体验。
在您的应用程序中包含 ASCII 艺术可以增加信息密度——这是一个易于帮助用户理解一些重要信息的视觉指示器。它增加了清晰度和简洁性。现在让我们讨论一种通过一致性使您的 CLI 更直观的方法。
在 CLI 之间保持一致性
了解命令行语法、标志和环境变量需要 upfront 成本,但如果程序在各方面保持一致,则长期来看会带来效率上的回报。例如,终端约定已经深深地印在我们的指尖上。通过遵循现有的模式来重用这些约定,有助于使 CLI 更直观和可预测。这正是使用户高效的原因。
有时候,现有的模式会破坏可用性。如前所述,许多 Unix 命令默认不返回任何输出,这可能会让新接触终端或 CLI 的人感到困惑。在这种情况下,为了提高可用性,打破这种模式是可以接受的。
在维护与更大社区中的 CLIs 的一致性时,需要考虑一些特定主题,但也要在应用程序内部保持一致:
-
命名
-
位置参数与标志参数
-
标志命名
-
使用方法
命名
使用一致的命令、子命令和标志名称,以帮助用户直观地了解你的命令行应用程序。一些现代命令行应用程序,如 AWS 命令行应用程序,将使用 Unix 命令以保持一致性。例如,看看这个 AWS 命令:
aws s3 ls s3://mybucket --summarize
之前的命令使用ls
命令列出S3
存储桶中的S3
对象。在 CLI 中重用 shell 命令之外,使用常见且非模糊的命令名称很重要。以下是一些可以按类型逻辑分组的例子:
表 8.1 – 按类型分组命令示例
这些是 CLIs 中的常见名称。你也可以考虑集成一些常见的 Unix 命令:
-
cp
(复制) -
ls
(列出) -
mv
(移动)
这些常见的命令名称从一系列模糊或独特的名称中消除了混淆。一个常见的混淆是更新和升级命令之间的区别。最好使用其中一个,因为保留两个只会让用户感到困惑。此外,对于经常使用的命令名称,也要遵循这些流行命令的标准缩写。例如:
-
-v
,--version
-
-h
,--help
-
-a
,--all
-
-p
,--port
而不是列出所有示例,只需考虑一些你常用的最常见命令行应用程序。想想哪些命令名称在整体上具有一致性。这将不仅有利于你的应用程序,而且有利于整个命令行应用程序社区,因为随着进一步标准的巩固。
位置参数与标志参数
保持参数及其位置的一致性很重要。例如,在 AWS CLI 中,s3
参数始终紧随其后:
aws s3 ls s3://<target-bucket>
aws s3 cp <local-file> <s3-target-location>/<local-file>
特定参数的一致位置将建立一个用户会直观遵循的清晰模式。
如果标志(我们之前提到的),在一个命令中可用,它们也可以在另一个命令中使用,前提是它们是有意义的。而不是为每个命令更改标志名称,保持命令之间的连贯性。对子命令也做同样的事情。让我们看看 GitHub CLI 的一些例子:
gh codespace list --json
gh issue list –json
GitHub CLI 在不同命令中保持列表子命令的一致性,并重用具有相同行为的–json
标志。
注意
必要参数通常作为位置参数而不是标志参数更好。
标志命名
不仅在不同命令中保持参数位置和标志名称的一致性很重要,而且在命名上也要保持一致。例如,有一些标志可以用驼峰式命名,–camelCase
,蛇形命名,--SnakeCase
,或者用连字符,--flag-with-dashes
。在应用程序中保持标志命名的连贯性也很重要!
使用方法
在前面的章节中,我们讨论了命令的语法以及如何通过一致的架构定义应用程序:名词-动词或动词-名词。保持结构的一致性也有助于设计出更加直观。
当构建你的命令行应用程序时,如果你考虑如何在不同程序和应用程序内部保持一致性,你将创建一个更加直观且易于学习的命令行应用程序,让你的用户感到自然地得到支持。
摘要
在本章中,你学习了一些在为机器或人类构建时需要考虑的具体要点。机器喜欢简单的文本,并对从其他应用程序返回的数据有一定的期望。机器的输出有时会破坏可用性。首先为人类设计,我们讨论了如何使用一些流行的标志(如--json
,--plain
和--silence
)在需要时轻松切换到机器友好的输出。
一个可用的设计需要很多工作,我们讨论了一些你可以提高你的命令行界面(CLI)可用性的方法——从有目的地使用颜色,以表格形式输出数据,分页浏览长文本,以及保持一致性。上述所有元素都将帮助用户在使用你的 CLI 时感到更加舒适和有指导性,这是我们想要实现的主要目标之一。我们可以用一个简短的表格来总结一个好的 CLI 设计与一个不好的 CLI 设计之间的区别:
图 8.5 – 好的与不好的 CLI 设计
在下一章(第九章**Chapter 9),开发的同理心方面,我们将继续讨论如何通过增加同理心来为人类开发。
问题
-
在脚本中,哪些常见的标志可以与命令行应用程序一起使用,以保持输出稳定?
-
你应该检查哪个标志来查看最终用户是否不想在终端中设置颜色?以及可以使用哪个常见的标志来禁用输出中的颜色?
-
考虑到可能会有两个具有相似名称的命令,以及这如何增加歧义。你在 CLI 的使用经验中遇到过哪些歧义的命令?
进一步阅读
-
《反 Mac 界面》The Anti-Mac Interface:
www.nngroup.com/articles/anti-mac-interface/
-
《人性化的界面:设计交互式系统的新方向》 by Jef Raskin
答案
-
--json
和--plain
标志保持数据一致性并降低破坏脚本的风险。 -
要么是
TERM=dumb
,NO_COLOR
,要么是MYAPP_NO_COLOR
环境变量。禁用颜色的最常见标志是–no-color
标志。 -
更新与升级常常被混淆,以及名称和主机。
第九章:开发的同理心方面
同理心最近成为了一个热门话题,它与软件的关系也不例外。本章将讨论如何使用同理心来开发更好的 CLI。以同理心驱动的 CLI 开发会考虑到所编写的输出和错误以及它们可能给用户带来的清晰度和信心。采用同理心方法的书面文档还为用户提供了一种轻松上手的方式,而当用户需要时,帮助和支持也随时可用。
本章将给出如何以用户易于理解的方式重写错误的示例,不仅使错误发生更加清晰,还包括如何以及在哪里(通过调试和回溯信息)提供,这些可以通过--verbose
标志和详细的日志来实现。为用户提供日志非常重要,当讨论调试和回溯信息时,这种实现将被描述。用户还可以通过手册页、每个命令的使用示例、同理心编写的文档以及快速轻松提交在应用程序中遇到的 bug 的方式来感到更加放心。
将同理心方法应用到应用程序的许多不同领域以及你的生活中,这不仅是一种自我关爱,也是一种对他人的关爱。希望这些建议能帮助你创建一个能够满足用户视角并给他们带来安心感的 CLI。具体来说,本章将涵盖以下主题:
-
将错误重写为人类可读格式
-
提供调试和回溯信息
-
无障碍的 bug 提交
-
帮助、文档和支持
技术要求
这是本章的要求:
-
一个 Unix 操作系统,以便理解和运行本章中共享的示例
-
你也可以在 GitHub 上找到代码示例:
github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter09
将错误重写为人类可读格式
错误可能会成为用户的一大挫折点,因为它们可能会打乱用户的原始计划。然而,如果你能尽可能地使这个过程不那么痛苦,用户会非常感激。在本节中,我们将讨论一些在发生错误时减轻用户痛苦的方法,并提供一些创建更好的错误信息和避免一些常见错误指南。创建清晰且有帮助的错误信息往往被忽视,但它们对最佳用户体验有着非常重大的影响。
想想你在使用命令行界面(CLI)时的一些主观体验以及你遇到的一些错误。这是一个思考如何改进自己使用 CLI 时的体验的机会,同时也为他人考虑。
编写错误信息的指南
在编写错误信息时,以下是一些有用的指南:
-
具体化:针对实际发生的任务定制信息。如果任务需要输入凭证或最终命令来完成工作流程,这个错误信息至关重要。最好的体验包括指定确切的问题并提供解决问题的方法。具体的指导有助于用户保持参与并愿意进行更正。
-
提醒用户另一端有真人:一个通用的错误信息可能会让大多数用户听起来非常技术化和令人生畏。通过重新编写错误信息,你可以使它们更有用,不那么令人生畏。同情你的用户,并确保你不会责怪用户,这可能会特别令人沮丧。通过理解、友好地交流,并且字面和比喻上都使用相同的语言,鼓励用户是非常重要的!你用的词在对话中听起来怎么样?
-
保持轻松愉快:保持轻松愉快的语气可以帮助在发生错误时缓解紧张情绪,但要注意!在某些情况下,这可能会使情况变得更糟——尤其是如果这是一个关键任务。用户不希望感到被嘲笑。无论如何,无论是否有幽默感,错误信息仍然应该是信息性的、清晰的和礼貌的。
-
让它变得简单:这需要你做更多的工作,但最终绝对值得。提供清晰的下一步操作或要运行的命令,以解决问题并帮助用户回到他们最初想要做的事情上。有了有用的建议,用户至少可以看到穿过树林的道路,并知道下一步该做什么。
-
考虑最佳位置:在输出错误信息时,最好将它们放置在用户首先会看的地方。在 CLI 的情况下,最可能是在输出的末尾。
-
合并错误:如果有多个错误信息,尤其是相似的信息,将它们分组在一起。这样看起来会比反复重复相同的错误信息要好得多。
-
使用图标和文本优化错误信息:通常,重要信息被放置在输出的末尾,但如果屏幕上有任何红色文本,用户通常会注意到那里。鉴于颜色的力量,要谨慎使用,并有目的地使用。
-
考虑大小写和标点符号:不要全部大写或使用多个感叹号。还要考虑一致性——你的错误信息是否以大写字母开头?如果它们被输出到日志中,错误可能全部以小写字母开头。
装饰错误
在错误信息中添加额外的信息和上下文是一个非常重要的步骤。具体任务失败的原因是什么?这有助于用户了解发生了什么。提供采取行动以解决问题的方法也将帮助用户感到更有支持感,并愿意继续前进。
首先,有几种方法可以装饰你的错误信息以提供更多信息。你可以使用fmt.Errorf
函数:
func Errorf(format string, a ...interface{}) error
使用这个函数,你可以打印出带有任何附加上下文的错误字符串。以下是在Chapter-9
仓库中的errors/errors.go
文件中的一个示例:
birthYear := -1981
err := fmt.Errorf("%d is negative\nYear can't be negative", birthYear)
if birthYear < 0 {
fmt.Println(err)
} else {
fmt.Printf("Birth year: %d\n", birthYear)
}
下一种装饰错误的方法是使用errors.Wrap
方法。该方法完全定义如下:
func Wrap(err error, message string) error
它返回一个错误,在方法调用点注释err
的消息和堆栈跟踪。如果err
是nil
,则Wrap
函数也返回nil
。
在wrapping()
函数中,我们展示了这一点:
func wrapping() error {
err := errors.New("error")
err1 := operation1()
if err1 != nil {
err1 = errors.Wrap(err, "operation1")
}
err2 := operation2()
if err != nil {
err2 = errors.Wrap(err1, "operation2")
}
err3 := operation3()
if err != nil {
err3 = errors.Wrap(err2, "operation3")
}
return err3
}
注意,前面的错误被包裹到下一个错误中,依此类推,直到返回最终错误。wrapping()
函数返回的错误输出如下。为了清晰起见,我已经移除了较长的路径:
error
.../errors.wrapping
.../errors/errors.go:73
.../errors.Examples
.../errors/errors.go:39
main.main
.../main.go:6
runtime.main
/usr/local/go/src/runtime/proc.go:250
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1594
operation1
.../errors.wrapping
.../errors/errors.go:76
.../errors.Examples
.../errors/errors.go:39
main.main
.../main.go:6
runtime.main
/usr/local/go/src/runtime/proc.go:250
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1594
operation2
.../errors.wrapping
.../errors/errors.go:80
.../errors.Examples
.../errors/errors.go:39
main.main
.../main.go:6
runtime.main
/usr/local/go/src/runtime/proc.go:250
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1594
operation3
.../errors.wrapping
.../errors/errors.go:84
.../errors.Examples
.../errors/errors.go:39
main.main
.../main.go:6
runtime.main
/usr/local/go/src/runtime/proc.go:250
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1594
注意,operation1
、operation2
和operation3
的错误都包裹在原始error
实例之下。
因为wrapping()
函数将错误与堆栈跟踪和消息一起注释,所以调用New()
或Wrap()
方法时,调用wrapping()
函数的行会打印出错误消息,随后是堆栈跟踪。
自定义错误
创建自定义错误允许你将你认为对用户有价值的信息存储在错误中,以便在打印时,所有信息都可在单个结构体中找到。首先,你需要考虑错误结构:
type error interface {
Error() string
}
简单地创建任何实现Error() string
方法的类型。考虑一下你想要存储在自定义错误结构体中的数据,这可能对用户有用,甚至对你作为开发者进行调试也有用。这可能包括错误发生的方法名称、错误的严重性或错误的类型。在Chapter-9
仓库的errors.go
文件中,我提供了一些示例。为了简化,只向customError
结构体添加了一个额外的字段Task
:
type customError struct {
Task string
Err error
}
这里定义了满足先前接口的Error()
方法。为了好玩,我们使用了上一章中使用的github.com/fatih/color
颜色页面以及一个表情符号(一个红色的十字标记)与错误消息一起:
func (e *customError) Error() string {
var errorColor = color.New(color.BgRed,
color.FgWhite).SprintFunc()
return fmt.Sprintf("%s: %s %s", errorColor(e.Task),
crossMark, e.Err)
}
现在,我们可以演示如何在eligibleToVote
函数中使用这个自定义错误:
func eligibleToVote(age int) error {
fmt.Printf("%s Attempting to vote at %d years
old...\n", votingBallot, age)
minimumAge := 18
err := &customError{
Task: " eligibleToVote",
}
if age < minimumAge && age > 0 {
years := minimumAge - age
err.Err = fmt.Errorf("too young to vote, at %d,
wait %d more years", age, years)
return err
}
if age < 0 {
err.Err = fmt.Errorf("age cannot be negative: %d",
age)
return err
}
fmt.Println("Voted.", checkMark)
return nil
}
注意,这里有多个错误,错误最初在函数顶部定义,只设置了Task
字段。对于每个发生的错误,然后设置Err
字段并返回。在Examples
方法中,我们使用以下行调用函数:
birthYear = 2010
currentYear := 2022
age := currentYear - birthYear
err = eligibleToVote(age)
if err != nil {
fmt.Println("error occurred: ", err)
}
当前面的代码运行时,会输出以下错误:
图 9.1 – 投票错误截图
创建自定义错误有 plenty of 其他方法,但以下是一些可以考虑添加到自定义错误中的内容:
-
用于日志记录的错误严重性
-
任何可能对指标有价值的资料
-
错误类型,这样你可以在错误发生时轻松过滤掉任何意外的错误
编写更好的错误消息
现在我们知道了如何添加更多细节到错误信息中,让我们回顾一下audiofile
CLI,并使用本节前面提到的指南重写我们的错误信息,使其更加人性化。在仓库中,对于这个特定的分支,我已经添加了额外的错误信息,以便用户或开发者更好地理解错误发生的位置以及原因。
由于audiofile
CLI 与audiofile
API 交互,存在可以处理和重写的 HTTP 响应。在utils/http.go
文件中存在一个CheckResponse
函数,它执行以下操作:
func CheckResponse(resp *http.Response) error {
if resp != nil {
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusInternalServerError:
return fmt.Errorf(errorColor("retry the command
later"))
case http.StatusNotFound:
return fmt.Errorf(errorColor("the id cannot be
found"))
default:
return fmt.Errorf(errorColor(fmt.
Sprintf("unexpected response: %v", resp.
Status)))
}
}
return nil
} else {
return fmt.Errorf(errorColor("response body is nil"))
}
}
考虑在您自己的 CLI 中扩展这一点,它可能也会与 REST API 交互。您可以检查您喜欢的任何响应并将它们重写为命令返回的错误。
在audiofile
CLI 的先前版本中,如果将id
参数传递给get
或delete
命令,如果 ID 未找到,则不会返回任何内容。然而,通过返回http.StatusNotFound
响应并添加额外的错误装饰,之前会静默错误并返回无数据的命令现在可以返回一些有用的信息:
mmontagnino@Marians-MacBook-Pro audiofile % ./bin/audiofile get --id 1234
Sending request: GET http://localhost:8000/request?id=1234 ...
Error:
checking response: the id cannot be found
Usage:
audiofile get [flags]
Flags:
-h, --help help for get
--id string audiofile id
--json return json format
我们甚至可以通过额外建议如何查找 ID 来提升等级。潜在的做法是要求用户运行list
命令以确认 ID。另一件事,类似于我们处理 HTTP API 请求的状态码的方式,是检查从本地命令返回的错误。无论是命令未找到还是命令缺少可执行权限,你都可以使用开关来处理在启动或运行命令时可能发生的潜在错误。这些潜在错误可以使用更用户友好的语言类似地重写。
提供调试和跟踪信息
调试和跟踪信息主要对您或其他开发者有用,但它也可以帮助您的最终用户与您分享有价值的信息,以帮助调试在您的代码中发现的潜在问题。有几种不同的方式可以提供这些信息。调试和跟踪信息主要输出到日志文件,通常,添加一个verbose
标志将打印此输出,这通常是被隐藏的。
日志数据
由于调试数据通常位于日志文件中,让我们讨论如何在命令行应用程序中包含日志,并确定与日志相关的级别——info
、error
和debug
级别的严重性。在这个例子中,让我们使用一个简单的日志包来演示这一点。有几个不同的流行结构化日志包,包括以下:
-
Zap (
github.com/uber-go/zap
)—由 Uber 开发的快速结构化日志记录器 -
ZeroLog (
github.com/rs/zerolog
)—专注于 JSON 格式的快速简单日志记录器 -
Logrus (
github.com/sirupsen/logrus
)—一个为 Go 提供结构化日志记录和 JSON 格式输出选项的日志记录器(目前处于维护模式)
虽然 logrus
是一个非常受欢迎的日志记录器,但有一段时间没有更新了,所以我们选择使用 zap
。一般来说,选择一个积极维护的开源项目是一个有希望的想法。
初始化一个日志记录器
回到 audiofile
项目,让我们添加一些用于调试目的的日志记录。在我们 audiofile
仓库中运行的第一件事是:
go get -u go.uber.org/zap
它将获取更新的 Zap 日志记录器依赖项。之后,我们可以在项目的 Go 文件中开始引用导入。在 utils
目录下,我们添加一个 utils/logger.go
文件来定义一些初始化 Zap 日志记录器的代码,该代码在 main
函数中被调用:
package utils
import (
"go.uber.org/zap"
)
var Logger *zap.Logger
var Verbose *zap.Logger
func InitCLILogger() {
var err error
var cfg zap.Config
config := viper.GetStringMap("cli.logging")
configBytes, _ := json.Marshal(config)
if err := json.Unmarshal(configBytes, &cfg); err != nil {
panic(err)
}
cfg.EncoderConfig = encoderConfig()
err = createFilesIfNotExists(cfg.OutputPaths)
if err != nil {
panic(err)
}
cfg.Encoding = "json"
cfg.Level = zap.NewAtomicLevel()
Logger, err = cfg.Build()
if err != nil {
panic(err)
}
cfg.OutputPaths = append(cfg.OutputPaths, "stdout")
Verbose, err = cfg.Build()
if err != nil {
panic(err)
}
defer Logger.Sync()
}
虽然不是必需的,但我们在这里定义了两个日志记录器。一个是 Logger
日志记录器,它将输出到配置文件中定义的输出路径,另一个是详细模式的 Verbose
日志记录器,它将输出到标准输出和之前定义的输出路径。两者都使用 *zap.Logger
类型,这在类型安全和性能至关重要的场合使用。Zap 还提供了一个糖化日志记录器,当性能是可取的但不是关键时使用。SugarLogger
也允许结构化日志记录,但除此之外,还支持 printf
风格的 API。
在这个仓库的 Chapter-9
分支版本中,我们将一些通用的 fmt.Println
或 fmt.Printf
输出替换为可以在 verbose
模式下显示的日志。此外,我们在打印信息时区分了 Info
级别和 Error
级别。
以下代码使用 Viper 从配置文件中读取,该文件已被修改以包含一些额外的日志记录器配置:
{
"cli": {
"hostname": "localhost",
"port": 8000,
"logging": {
"level": "debug",
"encoding": "json",
"outputPaths": [
"/tmp/log/audiofile.json"
]
}
}
}
在前面的配置中,我们设置了 level
和 encoding
字段。我们选择 debug
级别,以便将调试和错误语句输出到日志文件。对于 encoding
值,我们选择了 json
,因为它提供了一个标准的结构,可以使得每个字段都有标签,从而更容易理解错误信息。编码器配置也在同一 utils/logger.go
文件中定义:
func encoderConfig() zapcore.EncoderConfig {
return zapcore.EncoderConfig{
MessageKey: "message",
LevelKey: "level",
TimeKey: "time",
NameKey: "name",
CallerKey: "file",
StacktraceKey: "stacktrace",
EncodeName: zapcore.FullNameEncoder,
EncodeTime: timeEncoder,
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
}
由于 InitCLILogger()
函数在 main
函数中被调用,所以两个 Logger
和 Verbose
日志记录器将可用于任何命令中使用。
实现一个日志记录器
让我们看看我们如何有效地开始使用这个日志记录器。首先,我们知道在详细模式中,我们将记录所有数据并输出给用户。我们在 cmd/root.go
文件中将 verbose
标志定义为持久标志。这意味着 verbose
标志不仅可在根级别使用,而且对于添加到其中的每个子命令也是可用的。在该文件的 init()
函数中,我们添加了以下行:
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose")
现在,而不是在verbose
标志被调用时检查每个错误并在返回之前打印出错误,我们创建了一个简单的函数,它可以重复用于检查,也可以返回错误值。在utils/errors.go
文件中,我们定义以下函数以供重用:
func Error(errString string, err error, verbose bool) error {
errString = cleanup(errString, err)
if err != nil {
if verbose {
// prints to stdout also
Verbose.Error(errString)
} else {
Logger.Error(errString)
}
return fmt.Errorf(errString)
}
return nil
}
以一个命令为例,比如delete
命令,它展示了如何调用此功能:
var deleteCmd = &cobra.Command{
Use: "delete",
Short: "Delete audiofile by id",
Long: `Delete audiofile by id. This command removes the
entire folder containing all stored metadata`,
命令的大部分代码通常位于Run
或RunE
方法中,该方法接收cmd
变量,一个*cobra.Command
实例,以及args
变量,它包含一个strings
切片中的参数。在每种方法非常早期的时候,我们创建客户端并提取我们可能需要的任何标志——在这个例子中,是verbose
、silence
和id
标志:
RunE: func(cmd *cobra.Command, args []string) error {
client := &http.Client{
Timeout: 15 * time.Second,
}
var err error
silence, _ := cmd.Flags().GetBool("silence")
verbose, _ := cmd.Flags().GetBool("verbose")
id, _ := cmd.Flags().GetString("id")
if id == "" {
id, err = utils.AskForID()
if err != nil {
return utils.Error("\n %v\n try again and
enter an id", err, verbose)
}
}
接下来,我们构建我们发送给HTTP
客户端的请求,它使用id
值:
params := "id=" + url.QueryEscape(id)
path := fmt.Sprintf("http://%s:%d/delete?%s",
viper.Get("cli.hostname"),
viper.GetInt("cli.port"), params)
payload := &bytes.Buffer{}
req, err := http.NewRequest(http.MethodGet,
path, payload)
if err != nil {
return utils.Error("\n %v\n check configuration
to ensure properly configured hostname and
port", err, verbose)
}
我们检查在创建请求时是否有任何错误,这很可能是配置错误的结果。接下来,我们记录请求,以便我们了解任何与外部服务器的通信:
utils.LogRequest(verbose, http.MethodGet, path,
payload.String())
我们将通过客户端的Do
方法执行请求,如果请求未成功则返回错误:
resp, err := client.Do(req)
if err != nil {
return utils.Error("\n %v\n check configuration
to ensure properly configured hostname and
port\n or check that api is running", err,
verbose)
}
defer resp.Body.Close()
在请求之后,我们检查响应并读取resp.Body
,即响应体,如果响应成功。如果不成功,将返回并记录错误消息:
err = utils.CheckResponse(resp)
if err != nil {
return utils.Error("\n checking response: %v",
err, verbose)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return utils.Error("\n reading response: %v
\n ", err, verbose)
}
utils.LogHTTPResponse(verbose, resp, b)
最后,我们检查响应是否返回success
字符串,这表明删除成功。然后将结果打印给用户:
if strings.Contains(string(b), "success") && !silence {
fmt.Printf("\U00002705 Successfully deleted
audiofile (%s)!\n", id)
} else {
fmt.Printf("\U0000274C Unsuccessful delete of
audiofile (%s): %s\n", id, string(b))
}
return nil
},
}
你会看到每当遇到错误时都会调用utils.Error
函数。你还会看到几个其他的日志函数:utils.LogRequest
和utils.LogHTTPResponse
。第一个,utils.LogRequest
,被定义为将请求记录到标准输出、日志文件或两者:
func LogRequest(verbose bool, method, path, payload string) {
if verbose {
Verbose.Info(fmt.Sprintf("sending request: %s %s
%s...\n", method, path, payload))
} else {
Logger.Info(fmt.Sprintf("sending request: %s %s
%s...\n", path, path, payload))
}
}
第二个,utils.LogHTTPResponse
,同样将前一个请求的响应记录到标准输出、日志文件或两者:
func LogHTTPResponse(verbose bool, resp *http.Response, body []byte) {
if verbose && resp != nil {
Verbose.Info(fmt.Sprintf("response status: %s,
body: %s", resp.Status, string(body)))
} else if resp != nil {
Logger.Info(fmt.Sprintf("response status: %s, body:
%s", resp.Status, string(body)))
}
}
现在这个记录器已经为所有的audiofile
命令实现,让我们试一试,看看现在命令有了verbose
标志输出调试数据时输出是什么样子:
尝试详细模式以查看堆栈跟踪
重新编译项目后,我们使用无效的 ID 运行delete
命令,并传递verbose
命令:
./bin/audiofile delete --id invalidID --verbose
{"level":"info","time":"2022-11-06 21:21:44","file":"utils/logger.go:112","message":"sending request: GET http://localhost:8000/delete?id=invalidID ...\n"}
{"level":"error","time":"2022-11-06 21:21:44","file":"utils/errors.go:17","message":"checking response: \u001b[41;37mthe id cannot be found\u001b[0m","stacktrace":"github.com/marianina8/audiofile/utils.Error\n\t/Users/mmontagnino/Code/src/github.com/marianina8/audiofile/utils/errors.go:17\ngithub.com/marianina8/audiofile/cmd.glob..func2\n\t/Users/mmontagnino/Code/src/github.com/marianina8/audiofile/cmd/delete.go:54\ngithub.com/spf13/cobra.(*Command).execute\n\t/Users/mmontagnino/Code/src/github.com/marianina8/audiofile/vendor/github.com/spf13/cobra/command.go:872\ngithub.com/spf13/cobra.(*Command).ExecuteC\n\t/Users/mmontagnino/Code/src/github.com/marianina8/audiofile/vendor/github.com/spf13/cobra/command.go:990\ngithub.com/spf13/cobra.(*Command).Execute\n\t/Users/mmontagnino/Code/src/github.com/marianina8/audiofile/vendor/github.com/spf13/cobra/command.go:918\ngithub.com/marianina8/audiofile/cmd.Execute\n\t/Users/mmontagnino/Code/src/github.com/marianina8/audiofile/cmd/root.go:21\nmain.main\n\t/Users/mmontagnino/Code/src/github.com/marianina8/audiofile/main.go:11\nruntime.main\n\t/usr/local/go/src/runtime/proc.go:250"}
Error: checking response: the id cannot be found
Usage:
audiofile delete [flags]
Flags:
-h, --help help for delete
--id string audiofile id
Global Flags:
-v, --verbose verbose
使用verbose
标志,打印出调试语句,当发生错误时,堆栈跟踪也会输出。这对于用户与开发者共享以调试出了什么问题的重要数据。现在,让我们学习如何给用户提交错误报告的选项:
无需努力的错误提交
让我们使用 Cobra 生成器创建一个bug
命令,让用户可以向audiofile
CLI 的开发者提交问题:
cobra-cli add bug
bug created at /Users/mmontagnino/Code/src/github.com/marianina8/audiofile
现在我们已经创建了bug
命令,将Run
字段更改为提取应用程序的详细信息并使用已添加和准备好的数据启动网络浏览器,以便用户只需添加一些额外细节即可完成提交:
var bugCmd = &cobra.Command{
Use: "bug",
Short: "Submit a bug",
Long: "Bug opens the default browser to start a bug
report which will include useful system
information.",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return fmt.Errorf("too many arguments")
}
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("**Audiofile
version**\n%s\n\n", utils.Version()))
buf.WriteString(description)
buf.WriteString(toReproduce)
buf.WriteString(expectedBehavior)
buf.WriteString(additionalDetails)
body := buf.String()
url := "https://github.com/marianina8/audiofile/issues/new?title=Bug Report&body=" + url.QueryEscape(body)
// we print if the browser fails to open
if !openBrowser(url) {
fmt.Print("Please file a new issue at https://github.com/marianina8/audiofile/issues/new using this template:\n\n")
fmt.Print(body)
}
return nil
},
}
传递给buf.WriteString
方法的字符串定义在同一文件cmd/bug.go
中的命令之外,但一旦命令运行,完整的模板体如下所示:
**Audiofile version**
1.0.0
**Description**
A clear description of the bug encountered.
**To reproduce**
Steps to reproduce the bug.
**Expected behavior**
Expected behavior.
**Additional details**
Any other useful data to share.
调用./bin/audiofile bug
命令会启动浏览器打开 GitHub 仓库中的新问题页面:
图 9.2 – 浏览器打开到新问题的截图
从浏览器窗口中打开新问题页面;CLI 的版本会被自动填充,然后用户可以用自己的文本替换描述、复现步骤、预期行为和其他步骤的默认文本。
帮助、文档和支持
创建一个能够体谅用户的 CLI 的一部分是提供足够的帮助和文档,以及支持各种用户。幸运的是,Cobra CLI 框架支持从 Cobra 命令的短字段和长字段生成帮助,以及生成 man 页面。然而,将同情心融入 CLI 的扩展文档可能需要几种技术。
生成帮助文本
到目前为止,已经有很多创建命令的例子了,但为了重申,命令结构和在帮助中显示的字段是 Cobra 命令中的字段。让我们来看一个好例子:
var playCmd = &cobra.Command{
Use: "play",
Short: "Play audio file by id",
Long: `Play audio file by id using the default audio
player for your current system`,
Example: `./bin/audiofile play –id
45705eba-9342-4952-8cd4-baa2acc25188`,
RunE: func(cmd *cobra.Command, args []string) error {
// code
},
}
确保你只提供命令的简短和长描述以及一个或几个示例,你就是在提供一些帮助文本,至少可以让用户开始使用该命令。运行此命令将显示以下输出:
audiofile % ./bin/audiofile play --help
Play audio file by id using the default audio player for your current system
Usage:
audiofile play [flags]
Examples:
./bin/audiofile play –id 45705eba-9342-4952-8cd4-baa2acc25188
Flags:
-h, --help help for play
--id string audiofile id
Global Flags:
-v, --verbose verbose
一个简单的命令不需要太多的解释,所以这些就足够帮助用户了解如何使用。
生成 man 页面
在audiofile
仓库中,我们添加了一些额外的代码来为现有的命令和Makefile
中的命令生成 man 页面,以便快速运行代码来完成这项工作。在仓库中存在一个新程序,定义在documentation/main.go
下:
import (
"log"
"github.com/marianina8/audiofile/cmd"
"github.com/spf13/cobra/doc"
)
func main() {
header := &doc.GenManHeader{
Title: "Audiofile",
Source: "Auto generated by marianina8",
}
err := doc.GenManTree(cmd.RootCMD(), header, "./pages")
if err != nil {
log.Fatal(err)
}
}
我们传递root
命令并在./pages
目录中生成页面。在Makefile
中添加make pages
命令会在调用时创建 man 页面:
manpages:
mkdir -p pages
go run documentation/main.go
在终端中,如果你运行make manpages
然后通过运行man pages/audiofile.1
来检查新页面是否存在,你会看到为audiofile
CLI 生成的 man 页面:
图 9.3 – 终端中 audiofile man 页面的截图
你还可以看到在pages
目录中,为添加到root
命令的所有命令都创建了一个单独的 man 页面。
将同情心融入你的文档
当用户到达你的文档时,他们可能已经遇到了问题并且感到沮丧或困惑。你的文档需要考虑到这一点,并展现出对用户情况的了解。
虽然可能感觉文档编写会从其他开发领域消耗大量时间和精力,但它对你的命令行应用程序的未来至关重要。
在过去几年中,出现了一个新术语,同理心倡导,它与技术文档有关。这个术语是由技术及 UX 作家、同理心倡导者 Ryan Macklin 提出的。这个术语用来描述一个以同理心和现实中对人类情感的尊重为中心的技术传播子领域。它可以被视为与用户沟通方式的框架。因为很多人会阅读你的文档,我们知道其中包含了各种各样的脑化学、生活经验和最近的事件。同理心倡导是解决这个美好挑战的一种方法。
Macklin 提出了基于同理心倡导的七个哲学文档技巧。这些原则受到了 UX、创伤心理治疗、神经生物学、游戏设计以及文化和语言差异等学科的启发。让我们讨论这些原则及其为何有效:
-
运用视觉叙事—人类大脑很容易抓住故事,视觉用户可以从视觉中受益。然而,这迫使开发者思考不同类型的可访问性:视觉、认知、运动等。讲述故事迫使作者思考结构。另一方面,密集且冗长的文本是可访问性敌对的。作为备注,这个想法并不适用于每个人。
-
使用摘要—使用tl;dr(代表too long, don’t read,即“太长,不读”)、总结行或横幅为疲惫和压力山大的读者提供一个简短的解释,这些读者从认知成本较低的选择中受益。完成高水平智力所需的认知任务需要认知粘合剂。认知粘合剂需要能量,因此提供摘要将为那些已经处于低能量状态的用户提供低成本选项。
-
提供时间框架—一般来说,不确定性会创造恶性空白,而在未知的时间框架中逗留会引发强烈的情绪反应。提供时间框架可以帮助稳定空白。如果服务器端出现故障、上传到服务器或完成特定任务的通用时间,都可以提供时间框架。
-
包含短视频—这对于一些阅读理解有困难的用户来说是一个很好的替代方案。通常,年轻观众习惯于视频,当你将视频拆分成单个主题时,较短的播放时间可以提供安慰。安慰是一种强大的调节情绪的方式。然而,视频也有一些陷阱——主要是,视频需要更多的时间和能量来制作。
-
减少截图—提供截图可能会有帮助,但只有在用户界面可能令人困惑时。仅提供足够的信息让用户自己弄清楚一些事情,有助于培养认知粘性。否则,被视觉信息轰炸会伤害到每个人。
-
重新思考 FAQ—与其传统的问答,不如将文档拆分为单范围文档。提供具体的标题,避免过度承诺。
-
选择你的战斗—不是每一场战斗都值得打;尽你所能,选择你的战斗。你做的事情并不一定适合每个人——在过程中学习。毕竟,倡导同理心是自我关怀的另一种方式。
希望这些描述同理心倡导哲学的原理能帮助你重新思考你在文档中使用的词语。当你编写文档时,要考虑你的词语可能如何影响处于恐慌或挫败状态的人。还要考虑你如何帮助那些即将放弃或缺乏完成任务能量的用户取得成功。
摘要
在本章中,你学习了具体步骤来使你的命令行应用程序更具同理心。从错误处理、调试和回溯信息、轻松的错误提交,到技术沟通中的同理心倡导,你学到了在应用程序中应用的技术和同理心技能。
现在错误可以用颜色重写,从屏幕上跳出来,并装饰上提供用户关于错误发生的确切位置以及可能需要做什么来解决问题的额外信息。当错误看起来无法解决时,用户可以运行带有--verbose
标志的相同命令,查看详细日志,这些日志可能包含追踪错误可能发生位置的必要的服务器请求和响应,直到代码的行。
如果遇到错误,新增的bug
命令允许用户从终端直接启动一个新的浏览器,直接打开 GitHub 新问题提交表单中的新模板。
最后,通过采取同理心方法来弥合技术文档和用户视角之间的差距。在编写文档时使用同理心框架的几个哲学原则已被讨论。
问题
-
你可以使用哪两种常见方法来装饰你的错误?
-
在 Zap 和 Logrus 日志记录器之间,你为什么选择 Zap?
-
什么是同理心倡导?
进一步阅读
-
同理心 倡导:
empathyadvocacy.org
-
编写文档:
www.writethedocs.org
答案
-
fmt.Errorf(format string, a ...any) error 或 errors.Wrap(err error, message string) error
。 -
Zap 更快,并且正在积极维护。
-
同理心倡导是技术交流的一个子领域,它以同理心和现实中对人类情感的尊重为核心。它可以被视为一种编写技术文档的方式框架,以及为具有不同背景和可访问性的多种类型人群写作的解决方案。
第十章:提示和终端仪表板的交互性
提高用户体验的一种强大方式是将交互性与提示或终端仪表板集成。提示很有用,因为它们在请求输入的同时创建了一种对话式的方法。仪表板很有用,因为它们允许开发者从 ASCII 字符创建图形界面。通过仪表板创建的图形界面可以产生强大的视觉提示,使用户能够导航不同的命令。
本章将给出如何从一系列提示中构建用户调查问卷以及终端仪表板的示例 – 不论是学习 Termdash 库、设计原型还是将其应用于音频文件命令行界面。
交互性很有趣。它是一种更人性化和富有同理心的命令行界面方法。然而,如果你不是输出到终端,请记住禁用交互性。本章将涵盖调查问卷的基础知识,并深入探讨终端仪表板。到本章结束时,你将拥有创建自己的调查问卷或仪表板所需的一切。我们将涵盖以下内容:
-
使用提示引导用户
-
设计一个有用的终端仪表板
-
实现终端仪表板
技术要求
-
你需要 Unix 操作系统来理解并运行本章中共享的示例
-
在
github.com/mum4k/termdash
获取termdash
包 -
在
github.com/go-survey/survey
获取调查问卷包 -
你也可以在 GitHub 上找到代码示例
github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter10
使用提示引导用户
有许多简单提示用户的方法,但如果你想要创建一个可以检索信息的完整调查问卷,使用各种不同的提示 – 文本输入、多选、单选、多行文本、密码等 – 使用现有的库来处理这些可能很有用。让我们使用survey
包创建一个通用的客户调查问卷。
为了展示如何使用这个包,我将创建一个可以提示用户不同类型输入的调查问卷:
-
文本输入 – 例如,电子邮件地址
-
选择 – 例如,用户对命令行界面的体验
-
多选 – 例如,遇到的任何问题
-
多行 – 例如,开放式的反馈
在Chapter-10
仓库中,已经编写了一个调查问卷来处理这四个提示。问题存储在qs
变量中,定义为*survey.Question
的切片:
questions := []*survey.Question{
{
Name: "email",
Prompt: &survey.Input{
Message: "What is your email address?"
},
Validate: survey.Required,
Transform: survey.Title,
},
{
Name: "rating",
Prompt: &survey.Select{
Message: "How would you rate your experience with
the CLI?",
Options: []string{"Hated it", "Disliked", "Decent",
"Great", "Loved it"},
},
},
{
Name: "issues",
Prompt: &survey.MultiSelect{
Message: "Have you encountered any of these
issues?",
Options: []string{"audio player issues", "upload
issues", "search issues", "other
technical issues"},
},
},
{
Name: "suggestions",
Prompt: &survey.Multiline{
Message: "Please provide any other feedback or
suggestions you may have.",
},
},
}
我们需要一个answers
结构体来存储所有来自提示的结果:
results := struct {
Email string
Rating string
Issues []string
Suggestions string
}{}
最后,询问问题和存储结果的方法:
err := survey.Ask(questions, &results)
if err != nil {
fmt.Println(err.Error())
return
}
现在我们已经创建了调查问卷,我们可以尝试一下:
mmontagnino@Marians-MacBook-Pro Chapter-10 % go run main.go
? What is your email? mmontagnino@gmail.com
? How would you rate your experience with the CLI? Great
? Have you encountered any of these issues? audio player issues, search issues
? Please provide any other feedback or suggestions you may have. [Enter 2 empty lines to finish]I want this customer survey embedded into the CLI and email myself the results!
提示用户是集成交互到您的命令行应用程序的一种简单方法。然而,还有更多丰富多彩和有趣的方式来与您的用户交互。在下一节中,我们将详细讨论终端仪表板,termdash
包,以及如何模拟和实现终端仪表板。
设计一个有用的终端仪表板
命令行界面不必局限于文本。使用termdash,一个流行的 Golang 库,你可以构建一个终端仪表板,为用户提供一个用户界面,以直观地查看进度、警报、文本等。在整洁布局的仪表板中放置色彩丰富的组件可以增加信息密度,并以非常用户友好的方式向用户展示大量信息。在本节中,我们将了解这个库以及不同的布局选择和组件选项。在本章末尾,我们将设计一个可以在我们的音频文件命令行界面中实现的终端仪表板。
了解 Termdash
Termdash 是一个 Golang 库,它提供了一个可定制的、跨平台的基于终端的仪表板。在项目的 GitHub 页面上,一个有趣且多彩的演示展示了在动态布局中演示的所有可能的组件。从演示中,你可以看到你可以打造一个豪华的仪表板。要做到这一点,你需要了解如何布局仪表板,如何与键盘和鼠标事件交互,添加组件,以及如何通过对齐和颜色进行微调来调整外观。在本节中,我们将分解 Termdash 界面的层以及可以组织其中的组件。
Termdash 仪表板由四个主要层组成:
-
终端层
-
基础设施层
-
容器层
-
小部件层
让我们深入了解每一个。
终端层
将仪表板的终端层想象成一个存在于缓冲区内的 2D 单元格网格。每个单元格包含一个 ASCII 或 Unicode 字符,可以选择自定义前景色、文本颜色、背景色或单元格内非字符空间的颜色。鼠标和键盘的交互也发生在这一层。
可以使用两个终端库在终端的单元格级别进行交互:
-
tcell:受termbox启发,并有许多新的改进
-
termbox:不再受支持,尽管它仍然是一个选项
以下示例将使用tcell
包与终端进行交互。首先,创建一个新的tcell
实例以通过终端 API 进行交互:
terminalLayer, err := tcell.New()
if err != nil {
return err
}
defer terminalLayer.Close()
注意,在这个例子中,tcell
有两个方法:New
和Close
。New
用于创建一个新的tcell
实例以与终端交互,而Close
用于关闭终端。在创建后立即延迟关闭对tcell
的访问是一个好的实践。尽管没有将选项传递给New
方法,但还有一些可选的方法可以调用:
-
ColorMode
在初始化终端时设置颜色模式 -
ClearStyle
在清除终端时设置前景色和背景色
初始化单元格到 ColorMode
以访问所有 256 种可用的终端颜色的示例可能如下所示:
terminalLayer, err := tcell.New(tcell.ColorMode(terminalapi.ColorMode256))
if err != nil {
return err
}
defer terminalLayer.Close()
默认情况下,ClearStyle
如果未设置特定的 ClearStyle
,将使用 ColorDefault
。这个 ColorDefault
通常是指终端模拟器的默认前景色和背景色,通常是黑色和白色。要将终端设置为在清除时使用黄色前景色和海军蓝背景色,可以通过以下方式修改接受选项切片的 New
方法:
terminalLayer, err := tcell.New(tcell.ColorMode(terminalapi.ColorMode256), tcell.ClearStyle(cell.ColorYellow, cell.ColorNavy))
if err != nil {
return err
}
defer terminalLayer.Close()
现在我们已经创建了一个新的 tcell
,它使我们能够访问终端 API,让我们讨论下一层——基础设施。
基础设施层
终端仪表板的基础设施提供了结构的组织。基础设施层的主要元素包括对齐、线型和 Termdash。
对齐
对齐由 align
包提供,该包提供两种对齐选项——align.Horizontal
,它包括预定义的 left
、center
和 right
值,以及 align.Vertical
,它具有预定义的 top
、middle
和 bottom
值。
线型
线型定义了在终端上绘制框或边框时线条的样式。
该包通过 LineStyle
暴露了可用的选项。LineStyle
类型代表遵循 Unicode 选项的样式。
Termdash
Termdash 为开发者提供了主要入口点。其最重要的目的是启动和停止仪表板应用程序,控制屏幕刷新,处理任何运行时错误,并订阅和监听键盘和鼠标事件。termdash.Run
方法是启动 Termdash 应用程序的最简单方式。终端可能运行直到上下文过期,键盘快捷键被调用或超时。开始使用仪表板的最简单方法是以下最小代码示例,它为终端层创建了一个新的 tcell
,并创建了一个新的 termdash
包,我们将在下一节中深入了解。我们使用 2 分钟的过期时间创建上下文,然后调用 termdash
包的 Run
方法:
if terminalLayer, err := tcell.New()
if err != nil {
return err
}
defer terminalLayer.Close()
containerLayer, err := container.New(terminalLayer)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if err := termdash.Run(ctx, terminalLayer, containerLayer); err != nil {
return err
}
在前面的代码示例中,仪表板将运行直到上下文过期,即 60 秒。
为您的终端仪表板进行屏幕重绘或刷新可以通过几种方式完成:基于周期的、基于时间的重绘或手动触发的重绘。只能使用一种方法,因为使用一种方法意味着忽略另一种方法。除此之外,每次发生输入事件时,屏幕都会刷新。termdash.RedrawInterval
方法是一个选项,可以传递给 Run
方法,告诉仪表板应用程序在特定间隔内重绘或刷新屏幕。Run
方法可以通过每 5 秒刷新一次的选项进行修改:
termdash.Run(ctx, terminalLayer, containerLayer, termdash.RedrawInterval(5*time.Second))
仪表板也可以通过控制器重新绘制,这可以通过手动触发。此选项意味着仪表板只绘制一次,与 Run
方法不同,用户保持对主 goroutine 的控制。以下是一个示例代码,使用之前定义的 tcell
和 container
变量,可以传递给一个新的控制器以手动绘制:
termController, err := termdash.NewController(terminalLayer, containerLayer)
if err != nil {
return err
}
defer termController.Close()
if err := termController.Redraw(); err != nil {
return fmt.Errorf("error redrawing dashboard: %v", err)
}
Termdash API 提供了一个 termdash.ErrorHandler
选项,它告诉仪表板如何优雅地处理错误。如果没有为错误处理器提供实现,仪表板将在所有运行时错误上崩溃。错误可能发生在处理或检索事件、订阅事件或容器无法绘制自身时。
错误处理器是一个回调方法,它接收一个错误并适当地处理该错误。它可以定义为变量,在最简单的情况下,只是打印运行时错误:
errHandler := func(err error) {
fmt.Printf("runtime error: %v", err)
}
当使用 Run
或 NewController
方法启动 Termdash 应用程序时,错误处理器可以作为选项通过 termdash.ErrorHandler
方法传递。例如,可以使用新选项修改 Run
方法:
termdash.Run(ctx, terminalLayer, containerLayer, termdash.ErrorHandler(errHandler))
虽然 NewController
方法可以类似地进行修改:
termdash.NewController(terminalLayer, containerLayer, termdash.ErrorHandler(errHandler))
通过 termdash
包,您还可以订阅键盘和鼠标事件。通常,容器和某些小部件会订阅键盘和鼠标事件。开发者也可以订阅某些鼠标和键盘事件以执行全局操作。例如,开发者可能希望在按下特定键时终端运行特定函数。termdash.KeyboardSubscriber
用于实现此功能。以下代码中,用户订阅了字母 q
和 Q
,并通过运行代码来响应用户的键盘事件以退出仪表板:
keyboardSubscriber := func(k *terminalapi.Keyboard) {
switch k.Key {
case 'q':
case 'Q':
cancel()
}
}
if err := termdash.Run(ctx, terminalLayer, containerLayer, termdash.KeyboardSubscriber(keyboardSubscriber)); err != nil {
return fmt.Errorf("error running termdash with keyboard subscriber: %v", err)
}
另一个选项是使用 termdash.MouseSubscriber
调用 Run
方法以监听鼠标事件。同样,以下代码可以在仪表板内点击鼠标按钮时执行随机操作:
mouseClick := func(m *terminalapi.Mouse) {
switch m.Button {
case mouse.ButtonRight:
// when the left mouse button is clicked - cancel
cancel()
case mouse.ButtonLeft:
// when the left mouse button is clicked
case mouse.ButtonMiddle:
// when the middle mouse button is clicked
}
}
if err := termdash.Run(ctx, terminalLayer, containerLayer, termdash.MouseSubscriber(mouseClick)); err != nil {
return fmt.Errorf("error running termdash with mouse subscriber: %v", err)
}
容器层
容器层提供了仪表板布局、容器样式、键盘焦点以及边距和填充的选项。它还提供了一个在容器内放置小部件的方法。
从之前的示例中,我们看到使用 container.New
函数调用一个新的容器。我们将提供一些如何组织容器以及使用不同布局设置容器的示例。
有两种主要的布局选项:
-
二叉树
-
网格布局
二叉树布局将容器组织成一个二叉树结构,其中每个容器是树中的一个节点,除非为空,否则可能包含两个子容器或一个小部件。子容器可以进一步按照相同的规则分割。有两种分割方式:
-
container.SplitHorizontal
方法,将创建由container.Top
和container.Bottom
指定的顶部和底部子容器 -
container.SplitVertical
方法将创建由container.Left
和container.Right
指定的左右子容器
container.SplitPercent
选项指定了在垂直或水平分割容器时使用的容器分割百分比。当未指定分割百分比时,默认使用 50%。以下是一个使用所有描述的方法的简单二叉树布局示例:
terminalLayer, err := tcell.New(tcell.ColorMode(terminalapi.ColorMode256),
tcell.ClearStyle(cell.ColorYellow, cell.ColorNavy))
if err != nil {
return fmt.Errorf("tcell.New => %v", err)
}
defer terminalLayer.Close()
leftContainer := container.Left(
container.Border(linestyle.Light),
)
rightContainer :=
container.Right(
container.SplitHorizontal(
container.Top(
container.Border(linestyle.Light),
),
container.Bottom(
container.SplitVertical(
container.Left(
container.Border(linestyle.Light),
),
container.Right(
container.Border(linestyle.Light),
),
),
),
)
)
containerLayer, err := container.New(
terminalLayer,
container.SplitVertical(
leftContainer,
rightContainer,
container.SplitPercent(60),
),
)
注意我们是如何在分割终端为容器时进行钻取的。首先,我们垂直分割终端,将其分为左右两部分。然后,我们将右侧部分水平分割。右下角的水平分割部分再次垂直分割。运行此代码将呈现以下仪表板:
图 10.1 – 显示使用二叉布局分割容器的仪表板
注意到左侧的容器占据了整个宽度的约 60%。其他分割没有定义百分比,占据了容器的一半。
仪表板的另一种选项是使用网格布局,它将布局组织成行和列。与二叉树布局不同,网格布局需要一个网格构建器对象。然后,将行、列或小部件添加到网格构建器对象中。
列是通过grid.ColWidthPerc
函数定义的,该函数定义了一个具有父宽度指定百分比的列,或者通过grid.ColWidthPercWithOpts
定义,这是一个允许开发者在表示列时额外指定选项的替代方案。
行是通过grid.RowHeightPerc
函数定义的,该函数定义了一个具有父高度指定百分比的行,或者通过grid.RowHeightPercWithOpts
定义,这是一个允许开发者在表示行时额外指定选项的替代方案。
要在网格布局中添加小部件,请使用grid.Widget
方法。以下是一个使用grid
包实现的布局的简单示例。代码使用了所有相关方法,并在每个单元格中添加了一个省略号文本小部件:
t, err := tcell.New()
if err != nil {
return fmt.Errorf("error creating tcell: %v", err)
}
rollingText, err := text.New(text.RollContent())
if err != nil {
return fmt.Errorf("error creating rolling text: %v",
err)
}
err = rollingText.Write("...")
if err != nil {
return fmt.Errorf("error writing text: %v", err)
}
builder := grid.New()
builder.Add(
grid.ColWidthPerc(60,
grid.Widget(rollingText,
container.Border(linestyle.Light),
),
),
)
builder.Add(
grid.RowHeightPerc(50,
grid.Widget(rollingText,
container.Border(linestyle.Light),
),
),
)
builder.Add(
grid.ColWidthPerc(20,
grid.Widget(rollingText,
container.Border(linestyle.Light),
),
),
)
builder.Add(
grid.ColWidthPerc(20,
grid.Widget(rollingText,
container.Border(linestyle.Light),
),
),
)
gridOpts, err := builder.Build()
if err != nil {
return fmt.Errorf("error creating builder: %v", err)
}
c, err := container.New(t, gridOpts...)
运行代码会生成以下仪表板:
图 10.2 – 显示使用网格布局创建的容器的仪表板
注意到列宽百分比等于 100%;任何超过这个值都会导致编译错误。
还有一个动态布局的选项,允许你在仪表板上切换不同的布局。使用container.ID
选项,你可以通过一些文本标识一个容器,这可以在以后被引用,以便使用container.Update
方法动态更新哪个容器:
t, err := tcell.New()
if err != nil {
return fmt.Errorf("error creating tcell: %v", err)
}
defer t.Close()
b1, err := button.New("button1", func() error {
return nil
})
if err != nil {
return fmt.Errorf("error creating button: %v", err)
}
b2, err := button.New("button2", func() error {
return nil
})
if err != nil {
return fmt.Errorf("error creating button: %v", err)
}
c, err := container.New(
t,
container.PlaceWidget(b1),
container.ID("123"),
)
if err != nil {
return fmt.Errorf("error creating container: %v", err)
}
update := func(k *terminalapi.Keyboard) {
if k.Key == 'u' || k.Key == 'U' {
c.Update(
"123",
container.SplitVertical(
container.Left(
container.PlaceWidget(b1),
),
container.Right(
container.PlaceWidget(b2),
),
),
)
}
}
ctx, cancel := context.WithTimeout(context.Background(),
5*time.Second)
defer cancel()
if err := termdash.Run(ctx, t, c, termdash.
KeyboardSubscriber(update)); err != nil {
return fmt.Errorf("error running termdash: %v", err)
}
在此代码中,容器 ID 设置为123
。最初,小部件只包含一个按钮。update
方法将单个按钮替换为垂直分割的容器,左侧有一个按钮,右侧有另一个按钮。运行此代码时,按下 u 键将在布局上运行update
。
原始布局显示单个按钮:
图 10.3 – 显示单个按钮的布局
按下 u 或 U 键后,布局会更新:
图 10.4 – 再次按下 u 键后的两个按钮布局
容器层可以通过使用外边距和填充设置进一步配置。外边距是容器边框外的空间,而填充是容器边框内侧与其内容之间的空间。以下图像提供了外边距和填充的最佳视觉表示:
图 10.5 – 外边距和填充
外边距和填充可以使用绝对值或相对值设置。绝对外边距可以使用以下选项设置:
-
container.MarginTop
-
container.MarginRight
-
container.MarginBottom
-
container.MarginLeft
绝对填充可以使用以下选项设置:
-
container.PaddingTop
-
container.PaddingRight
-
container.PaddingBottom
-
container.PaddingLeft
外边距和填充的相对值以百分比设置。外边距和填充的顶部和底部百分比值相对于容器的高度:
-
container.MarginTopPercent
-
container.MarginBottomPercent
-
container.PaddingTopPercent
-
container.PaddingBottomPercent
外边距和填充的右侧和左侧百分比值相对于容器的宽度:
-
container.MarginRightPercent
-
container.MarginLeftPercent
-
container.PaddingRightPercent
-
container.PaddingLeftPercent
容器内的另一种定位形式是对齐。以下是从对齐 API 中可用的方法,用于在容器内对齐内容:
-
container.AlignHorizontal
-
container.AlignVertical
让我们在一个简单的例子中将所有这些放在一起,这个例子扩展了二叉树代码示例:
b, err := button.New("click me", func() error {
return nil
})
if err != nil {
return err
}
leftContainer :=
container.Left(
container.Border(linestyle.Light),
container.PlaceWidget(b),
container.AlignHorizontal(align.HorizontalLeft),
)
rightContainer :=
container.Right(
container.SplitHorizontal(
container.Top(
container.Border(linestyle.Light),
container.PlaceWidget(b),
container.AlignVertical(align.VerticalTop),
),
container.Bottom(
container.SplitVertical(
container.Left(
container.Border(linestyle.Light),
container.PlaceWidget(b),
container.PaddingTop(3),
container.PaddingBottom(3),
container.PaddingRight(3),
container.PaddingLeft(3),
),
container.Right(
container.Border(linestyle.Light),
container.PlaceWidget(b),
container.MarginTop(3),
container.MarginBottom(3),
container.MarginRight(3),
container.MarginLeft(3),
),
),
),
),
)
containerLayer, err := container.New(
terminalLayer,
container.SplitVertical(
leftContainer,
rightContainer,
container.SplitPercent(60),
),
)
结果布局如下所示:
图 10.6 – 显示按钮不同对齐方式、不同外边距和填充的容器
您也可以定义一个键来使用container.KeyFocusNext
和container.KeyFocusPrevious
选项将焦点切换到下一个或上一个容器。
小部件层
在之前的几个例子中,我们展示了将小部件放置在网格或二叉树容器布局中的代码,并且还自定义了对齐、边距和填充。然而,除了简单的按钮或文本之外,还有不同的小部件选项,GitHub 页面上的演示展示了每个选项的示例:
图 10.7 – 显示仪表板中所有小部件的 Termdash 示例截图
让我们通过一段代码片段快速演示每个小部件的创建方法。要将每个小部件添加到容器中,只需使用之前用于简单文本和按钮示例的container.PlaceWidget
方法。让我们来看几个其他示例:柱状图、饼图和仪表。有关其他小部件的详细代码,请访问非常详细的 termdash 维基百科并查看演示页面。
柱状图
以下是一些创建带有相对于max
值的单个值的柱状图小部件的示例代码:
barChart, err := barchart.New()
if err != nil {
return err
}
values := []int{20, 40, 60, 80, 100}
max := 100
if err := barChart.Values(values, max); err != nil {
return err
}
上述代码创建了一个新的barchart
实例,并添加了值,一个int
的切片,以及最大int
值。生成的终端仪表板看起来如下:
图 10.8 – 柱状图示例
更改values
和max
变量的值以查看图表的变化。条的颜色也可以根据个人喜好进行修改。
饼图
饼图,或进度圆图表,表示进度的完成。以下是一些创建饼图以显示百分比的示例代码:
greenDonut, err := donut.New(
donut.CellOpts(cell.FgColor(cell.ColorGreen)),
donut.Label("Green", cell.FgColor(cell.ColorGreen)),
)
if err != nil {
return err
}
greenDonut.Percent(75)
上述代码创建了一个带有标签和前景色设置为绿色的donut
新实例。生成的终端仪表板看起来如下:
图 10.9 – 75%的绿色饼图
再次提醒,颜色可以根据个人喜好进行修改,并且请记住,由于 Termdash 提供动态刷新,数据可以自动更新并重绘,这使得它非常适合显示进度。
仪表
仪表,或进度条,是衡量完成量的另一种方式。以下是一些展示如何创建进度仪表的示例代码:
progressGauge, err := gauge.New(
gauge.Height(1),
gauge.Border(linestyle.Light),
gauge.BorderTitle("Percentage progress"),
)
if err != nil {
return err
}
progressGauge.Percent(75)
此代码创建了一个带有轻边框、标题和1
选项的仪表的新实例。百分比,就像饼图一样,是 75%。生成的终端仪表板看起来如下:
图 10.10 – 75%进度的仪表
如前所述,由于动态重绘,这是显示进度更新的另一个绝佳选择。
现在我们已经展示了如何在终端仪表板中包含不同的小部件的示例,让我们使用这些小部件草拟一个设计,我们可以在稍后将其实现到我们的音频文件命令行界面中。假设我们想在终端仪表板中构建一个音乐播放器。以下是一个示例布局:
图 10.11 – 终端仪表板布局
这个布局可以通过二进制布局轻松创建。音乐库列表部分可以从带有编号标识符的歌曲列表生成,这些标识符可以在文本输入部分使用,通过 ID 选择歌曲。与输入 ID 相关的任何错误信息将直接显示在其下方。如果输入正确,所选歌曲部分将显示带有歌曲标题的滚动 ASCII 文本,并且元数据部分将显示所选歌曲的文本元数据。点击播放按钮将开始播放所选歌曲,而停止按钮将停止播放。接下来,我们将进入下一个部分,我们将使这个终端仪表板成为现实。
实现终端仪表板
当创建终端仪表板时,你可以将其创建为一个独立的独立应用程序,或者作为一个从命令行应用程序调用的命令。在我们的特定示例中,对于播放器终端仪表板,我们将调用仪表板,当调用./bin/audiofile player
命令时。
首先,从音频文件的根存储库中,我们需要使用cobra-cli
创建命令:
cobra-cli add player
Player created at /Users/mmontagnino/Code/src/github.com/marianina8/audiofile
现在,我们可以创建代码来生成终端仪表板,该代码在player
命令的Run
字段中调用。记住,终端仪表板由四个主要层组成:终端、基础设施、容器和小部件。就像一幅画一样,我们将从基础层开始:终端。
创建终端层
你需要做的第一件事是创建一个提供对任何输入和输出的访问的终端。Termdash 有一个tcell
包用于创建基于tcell
的新终端。许多终端默认只支持 16 种颜色,但其他更现代的终端可以支持多达 256 种颜色。以下代码特别创建了一个具有 265 色模式的新的终端。
t, err := tcell.New(tcell.ColorMode(terminalapi.ColorMode256))
创建终端层之后,我们接着创建基础设施层。
创建基础层
基础层处理终端设置、鼠标和键盘事件以及容器。在我们的终端仪表板播放器中,我们想要处理几个任务:
-
退出信号的键盘事件
-
运行终端仪表板,该仪表板订阅了此键盘事件
让我们编写代码来处理终端仪表板所需的这两个功能。
订阅键盘事件
如果我们想监听键事件,我们创建一个键盘订阅者来指定要监听的键:
quitter := func(k *terminalapi.Keyboard) {
if k.Key == 'q' || k.Key == 'Q' {
...
}
}
现在我们已经定义了一个键盘订阅者,我们可以将其用作 termdash 的Run
方法的输入参数。
运行终端
当运行终端时,你需要终端变量、容器以及键盘和鼠标订阅者,以及定时重绘间隔和其他选项。以下代码运行了我们创建的基于tcell
的终端和quitter
键盘订阅者,该订阅者监听q或Q键事件以退出应用程序:
if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(100*time.Millisecond)); err != nil {
panic(err)
}
将作为第三个参数传递给 termdash.Run
方法的 c
变量是容器。现在让我们定义容器。
创建容器层
在创建容器时,查看布局的更大图景然后逐步缩小范围是有帮助的。例如,当你第一次查看计划布局时,你会看到最大的部分是由左右垂直分割组成的。
图 10.12 – 初始垂直分割
当我们开始定义容器时,我们会逐渐通过更具体的信息进行钻取,但我们将从以下内容开始:
-
垂直分割(左侧) – 音乐库
-
垂直分割(右侧) – 所有其他小部件
最终的代码反映了这一钻取过程。由于我们保持左侧垂直分割作为音乐库,我们通过左侧的容器进行钻取,始终从较大的容器开始,并在其中添加较小的容器。
图 10.13 – 右侧垂直空间的水平分割
接下来是一个水平分割,它将左侧垂直分割分为以下几部分:
-
水平分割(顶部)30% – 文本输入、错误信息和滚动歌曲标题文本
-
水平分割(底部)70% – 元数据和播放/停止按钮
让我们将顶部水平分割再次水平分割:
-
水平分割(顶部)30% – 文本输入和错误信息
-
水平分割(底部)70% – 滚动歌曲标题文本
图 10.14 – 顶部水平空间的水平分割
我们将之前顶部部分水平分割成分离的文本输入和错误信息:
-
水平分割(顶部)60% – 文本输入
-
水平分割(底部)40% – 错误信息
图 10.15 – 顶部水平空间的水平分割
现在,让我们钻入右侧垂直容器初始水平分割的底部 70%。让我们将其分割成两个水平部分:
-
水平分割(顶部)80% – 元数据部分
-
水平分割(底部)20% – 按钮部分(播放/停止)
图 10.16 – 底部水平空间的水平分割
最后,我们需要钻取到的是底部水平分割,我们将将其垂直分割:
-
垂直分割(左侧)50% – 播放按钮
-
垂直分割(右侧)50% – 停止按钮
图 10.17 – 底部水平空间的垂直分割
整个布局通过容器代码分解显示了这一钻取过程 – 我已经添加了注释,以供参考小部件将被放置的位置:
c, err := container.New(
t,
container.SplitVertical(
container.Left(), // music library
container.Right(
container.SplitHorizontal(
container.Top(
container.SplitHorizontal(
container.Top(
container.SplitHorizontal(
container.Top(), // text input
container.Bottom(), // error
msgs
container.SplitPercent(60),
),
),
container.Bottom(), // rolling song
title
container.SplitPercent(30),
),
),
container.Bottom(
container.SplitHorizontal(
container.Top(), // metadata
container.Bottom(
container.SplitVertical(
container.Left(), // play
button
container.Right(), // stop
button
)
),
container.SplitPercent(80),
),
),
container.SplitPercent(30),
),
),
),
)
接下来,让我们创建小部件并将它们放置在适当的容器中,以最终完成终端仪表板。
创建小部件层
回到原始布局,所有我们需要实现的不同小部件都一目了然:
-
音乐库列表
-
输入文本
-
错误信息
-
滚动文本 – 选中歌曲(艺术家名称下的标题)
-
元数据
-
播放按钮
-
停止按钮
到目前为止,我已经知道列表中每个项目应该使用哪个小部件。然而,如果你还没有决定,现在是时候确定每个项目最适合使用的 Termdash 小部件了:
-
文本:
-
音乐库列表
-
错误信息
-
滚动文本 – 选中歌曲(艺术家名称下的标题),元数据
-
-
文本输入:
- 输入字段
-
按钮:
-
播放按钮
-
停止按钮
-
让我们至少创建每种类型的一个作为例子。完整的代码可在Chapter10
GitHub 仓库中找到。
为音乐库列表创建文本小部件
音乐库列表将接受音频列表并在一个将列出歌曲索引(旁边是标题和艺术家)的区域内打印文本。我们使用以下函数定义此小部件:
func newLibraryContent(audioList *models.AudioList) (*text.Text, error) {
libraryContent, err := text.New(text.RollContent(), text.
WrapAtWords())
if err != nil {
panic(err)
}
for i, audiofile := range *audioList {
libraryContent.Write(fmt.Sprintf("[id=%d] %s by %s\n",
i, audiofile.Metadata.Tags.Title, audiofile.Metadata.
Tags.Artist))
}
return libraryContent, nil
}
函数在Run
函数字段中调用,如下所示:
libraryContent, err := newLibraryContent(audioList)
错误信息和元数据项也是文本小部件,所以我们将省略这些代码示例。接下来,我们将创建输入文本。
创建一个用于设置当前歌曲 ID 的输入文本小部件
输入文本部分是用户输入音乐库部分显示的歌曲 ID 的地方。输入文本在以下函数中定义:
func newTextInput(audioList *models.AudioList, updatedID chan<- int, updateText, errorText chan<- string) *textinput.TextInput {
input, _ := textinput.New(
textinput.Label("Enter id of song: ", cell.
FgColor(cell.ColorNumber(33))),
textinput.MaxWidthCells(20),
textinput.OnSubmit(func(text string) error {
// set the id
// set any error text
return nil
}),
textinput.ClearOnSubmit(),
)
return input
}
创建一个按钮以开始播放与输入 ID 关联的歌曲
最后一种小部件是按钮。我们需要两种不同的按钮,但以下代码是为播放按钮编写的:
func newPlayButton(audioList *models.AudioList, playID <-chan int) (*button.Button, error) {
playButton, err := button.New("Play", func() error {
stopTheMusic()
}
go func() {
if audiofileID <= len(*audioList)-1 && audiofileID >= 0 {
pID, _ = play((*audioList)[audiofileID].Path, false,
true)
}}()
return nil
},
button.FillColor(cell.ColorNumber(220)),
button.GlobalKey('p'),
)
if err != nil {
return playButton, fmt.Errorf("%v", err)
}
return playButton, nil
}
- 函数在
Run
函数字段中调用:
playButton, err := newPlayButton(audioList, playID)
- 一旦所有小部件都创建好了,它们将按照以下代码行放置在容器中的适当位置:
container.PlaceWidget(widget)
- 一旦小部件被放置在容器中,我们可以使用以下命令运行终端仪表板:
./bin/audiofile player
- 神奇的是,玩家终端仪表板出现了,我们可以选择一个 ID 进入并播放一首歌:
图 10.18 – 音频文件播放终端仪表板
- 哇!我们已经创建了一个终端仪表板,可以在我们的音频文件库中播放音乐。虽然你可以通过命令行应用程序的
get
和list
命令查看元数据,并通过play
命令播放音乐,但新的播放终端仪表板允许你以更用户友好的方式查看音频文件库中的内容。
摘要
在本章中,你学习了如何创建一个包含不同交互提示和包含各种小部件的终端仪表板的调查。这些只是可以激发你自己在命令行应用程序中交互性的例子。
调查示例向您展示了如何使用各种不同类型的提示;您可以提示用户他们的用户体验,但如您在音频文件 CLI 中看到的,您也可以仅提示缺失信息。这些提示可以在代码中需要提示的地方输入,或者将它们串联在一系列其他问题中,从而为您的用户提供更全面的调查。
玩家终端仪表板为您提供了一个如何为命令行界面创建终端仪表板的示例。考虑您的用户将通过命令行界面发送或检索的数据类型,并让这些数据类型引导您设计更直观的界面。
问题
-
创建终端层使用什么方法?
-
在容器内放置小部件使用什么方法?
-
二进制布局和网格布局之间的区别是什么?
答案
-
tcell.New()
-
container.PlaceWidget(widget)
-
网格布局允许您将容器分割成水平行和垂直列。二进制布局允许您水平或垂直分割子容器。
进一步阅读
- 《大数据仪表板宝典:使用真实世界商业场景可视化您的数据》由 Wexler、Shaffer 和 Cotgreave 著
第四部分:为不同平台构建和分发
本书本部分主要介绍如何使用 Docker 和 GoReleaser 构建、测试和分发您的 CLI 应用程序。它首先解释了构建和测试的重要性,以及如何使用布尔逻辑的构建标签创建有针对性的构建和测试,以在每个新功能中进一步稳定您的项目。本部分还涵盖了 Go 的强大功能——交叉编译,它允许您为不同的操作系统和架构编译应用程序。容器化的好处也得到了探讨,重点是 Docker 容器用于测试和分发应用程序。最后,我们讨论了如何结合使用 GoReleaser 和 GitHub Actions 来自动化 CLI 应用程序作为 Homebrew 公式的发布,这使得 MacOS 用户只需一个命令就可以轻松找到并安装您的软件。
本部分包含以下章节:
-
第十一章,自定义构建和测试 CLI 命令
-
第十二章,跨平台交叉编译
-
第十三章,使用容器进行分发
-
第十四章,使用 GoReleaser 将 Go 二进制文件发布为 Homebrew 公式
第十一章:自定义构建和测试 CLI 命令
对于任何 Golang 应用程序,您都需要构建和测试。然而,随着项目和用户基础的扩大,这变得越来越重要。使用布尔逻辑的构建标签使您能够创建有针对性的构建和测试,并随着每个新功能的推出进一步稳定您的项目。
在对构建标签及其使用方法有更深入的理解之后,我们将使用一个真实世界的示例,即音频文件 CLI,来集成(免费和专业)级别并启用分析功能。
构建标签不仅在构建时用作输入,在测试时也用作输入。我们将在本章的后半部分专注于测试。我们将具体学习如何模拟 CLI 使用的 HTTP 客户端,在本地配置测试,为单个命令编写测试,并运行它们。在本章中,我们将详细介绍以下主题:
-
构建标签是什么?您如何使用它们?
-
使用标签进行构建
-
测试 CLI 命令
技术要求
-
为了理解并运行本章中分享的示例,您需要一个 Unix 操作系统。
-
您还可以在 GitHub 上找到代码示例:
github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter11/audiofile
构建标签是什么?您如何使用它们?
构建标签是代码文件在构建过程中应包含的指示符。在 Go 中,它们由任何源文件顶部或接近顶部的单行定义,而不仅仅是 Go 文件。它们必须位于包声明之前,并后跟一个空白行。它们具有以下语法:
//go:build [tag]
这一行只能在文件中定义一次。如果有多个定义,将会生成错误。然而,当使用多个标签时,它们会通过布尔逻辑进行交互。在第七章“为不同平台开发”中,我们简要介绍了标签及其逻辑。处理不同平台开发的另一种方法是使用一系列的if-else
语句来检查运行时的操作系统。另一种方法是将在文件名中包含操作系统。例如,如果文件名以_windows.go
结尾,我们指示编译器在构建windows
时仅包含此文件。
标签可以帮助在编译不同操作系统时分离代码,使用$GOOS
和$GOARCH
。操作系统和架构的有效组合可以在以下位置找到:go.dev/doc/install/source#environment
。
除了针对平台外,构建标签还可以定制以分离功能代码或集成测试。通常,集成标签会收到一个特定的标签,因为它们通常需要更长的时间来运行。将单元测试与集成测试分离,在测试您的应用程序时增加了控制级别。
这些构建约束组合使用时,可以强大地编译您代码的不同版本。如前所述,它们使用布尔逻辑一起评估。表达式包含使用 ||
、&&
和 !
运算符以及括号组合的构建标签。要了解更多关于构建约束的信息,请在您的终端中运行以下命令:
go help buildconstraint
例如,以下构建标签将文件约束为在满足 linux
或 openbsd
标签、满足 amd64
且 cgo
不满足时构建:
//go:build (linux || openbsd) && amd64 && !cgo
在您的终端中运行 go env
命令,以查看在构建应用程序时哪些标签会自动满足。您将看到目标操作系统($GOOS
)、架构($GOARCH
)以及如果操作系统是 Unix 或 Unix-like,则会显示 unix
。cgo
字段由 CGO_ENABLED
环境变量、每个 Go 主要版本的术语以及通过 –tags
标志提供的任何附加标签决定。
如前所述,您可以根据代码文件顶部的标签创建自己的专业版和免费版,例如 //go:build pro
或 //go:build free
。集成测试文件可以标记为 //go:build int
。无论您如何想要自定义构建,都可以利用标签和布尔逻辑来实现。现在,在下一节中,让我们在我们的代码中使用标签来完成这项工作。
如何利用构建标签
如前所述,我们可以使用构建标签根据操作系统和架构来分离构建。在音频文件存储库中,我们已经在以下文件中这样做,这些文件与 play
和 bug
命令相关联。对于 bug
命令,我们有以下文件:
-
bug_darwin.go //
仅在 Darwin 系统上构建 -
bug_linux.go //
仅在 Linux 系统上构建 -
bug_windows.go //
仅在 Windows 平台上构建
这些文件中的每一个都包含一个针对目标平台专门编写的函数。文件后缀具有与构建标签类似的功能。您可以选择与确切平台和架构匹配的文件后缀。然而,当您想要针对多个平台和架构时,更倾向于使用构建标签。在文件内部,是匹配的构建标签,例如,在 bug_darwin.go
文件中,文件顶部如下所示:
//go:build darwin
由于我们已经在整个存储库中设置了这些构建标签,以针对需要的目标平台,让我们探索一些其他利用构建标签的方法。
创建专业版、免费版和开发版
假设命令行界面利用构建标签来创建对应用程序功能的不同访问级别。这可能适用于管理员或基本级别用户,或者根据权限级别进行限制,但如果是针对外部客户的 CLI,则可能是您应用程序的专业版和免费版。
首先,重要的是决定每个版本将有哪些命令可用。让我们以音频文件应用程序为例来尝试一下:
表 11.1 – 包含在免费或专业级别的命令列表
让我们也包括一个开发版本;这仅仅允许 API 在本地运行。在实际场景中,应用程序将被配置为调用公共 API,并且存储可以在数据库中完成。这为我们提供了另一个创建构建标签的理由。
现在,让我们使用构建标签来区分免费、专业和开发版本。开发版本构建标签放置在cmd/api.go
文件的顶部,使得只有指定了dev
标签时 API 命令才可用:
//go:build dev
然后,区分专业版本的标签如下:
//go:build !free && pro
如前所述,有几个文件已经具有构建标签以针对平台。此构建标签意味着该文件将在免费、专业和开发版本中可用:
//go:build darwin
前面的构建标签利用布尔逻辑声明,当定义了darwin
和free
标签时,该文件应包含在构建过程中。
让我们用布尔逻辑语法示例来分解这里的标签:
表 11.2 – 布尔逻辑示例
构建标签内包含的布尔逻辑将允许开发者为任何平台和版本的组合进行构建。
添加构建标签以启用 pprof
利用构建标签的另一种方法是启用 API 服务的分析。pprof
是一个用于可视化和分析分析数据的工具。该工具读取proto
或协议缓冲区格式的样本集合,然后创建有助于可视化和分析数据的报告。此工具可以生成文本和图形报告。
注意
要了解更多关于如何使用此工具的信息,请访问pkg.go.dev/net/http/pprof
。
对于这个案例,我们将定义一个名为pprof
的构建标签,以适当地匹配其使用。在services/metadata/metadata.go
文件中,我们定义了用于从通过命令行界面上传的音频文件中提取信息的元数据服务。CreateMetadataService
函数创建元数据服务并定义所有与匹配处理程序匹配的端点。为了启用分析,我们将添加以下新的代码块:
if profile {
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/{action}", pprof.Index)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
}
在文件顶部,输入之后,我们将定义它所依赖的变量:
var (
profile = false
)
然而,我们需要某种方法来将profile
变量设置为true
。为此,我们创建了一个新文件:services/metadata/pprof.go
。此文件包含以下内容:
//go:build profile && (free || pro)
package metadata
func init() {
profile = true
}
如您所见,无论构建free
、pro
还是dev
版本,如果将profile
构建标签作为标签输入添加,则init
函数将被调用以将profile
变量设置为true
。现在,我们有了另一种使用构建标签的方法——设置作为功能标志的布尔变量。现在我们已经更改了必要的文件以包含构建标签,让我们将这些作为构建命令的输入。
带标签的构建
到目前为止,我们已经使用Makefile
构建了我们的应用程序,其中包含以下特定于构建 Darwin 应用程序的命令:
build-darwin:
go build -tags darwin -o bin/audiofile main.go
chmod +x bin/audiofile
对于 Darwin 构建,我们还可以构建一个免费版和专业版以及配置文件版以启用pprof
。
构建免费版
要为 Darwin 操作系统构建一个free
版本,我们需要修改前面的make
命令并创建一个新的命令:
build-darwin-free:
go build -tags "darwin free" -o bin/audiofile main.go
chmod +x bin/audiofile
在build-darwin-free
命令中,我们传递了两个构建标签:darwin
和free
。这将包括如bug_darwin.go
和play_darwin.go
等文件,这些文件在 Go 文件顶部包含以下行:
//go:build darwin
类似地,当构建pro
版本时,文件也会被包含在构建中。
构建专业版
要为 Darwin 操作系统构建一个pro
版本,我们需要添加一个新的build
命令:
build-darwin-pro:
go build -tags "darwin pro" -o bin/audiofile main.go
chmod +x bin/audiofile
在build-darwin-pro
命令中,我们传递了两个构建标签:darwin
和pro
。
在专业版上启用 pprof 的构建
要构建一个启用了pprof
的pro
版本,我们需要添加以下build
命令:
build-darwin-pro-profile:
go build -tags "darwin pro profile" -o bin/audiofile main.go
chmod +x bin/audiofile
在build-darwin-pro-profile
命令中,我们传递了三个构建标签:darwin
、pro
和profile
。这将包括services/metadata/pprof.go
文件,该文件包含文件顶部的以下行:
//go:build profile
类似地,当为免费版构建时,文件也会被包含在构建中。
到目前为止,我们已经了解了构建标签是什么,如何在代码中使用构建标签的不同方式,以及最后如何使用构建标签构建针对特定用途的应用程序。具体来说,虽然构建标签可以用来定义不同级别的功能(免费版与专业版),但你也可以使用构建标签启用性能分析或其他调试工具。现在我们已经了解了如何为不同的目标构建我们的命令行应用程序,接下来让我们学习如何测试我们的 CLI 命令。
测试 CLI 命令
在构建你的命令行应用程序时,围绕它构建测试也很重要,这样你可以确保应用程序按预期工作。通常需要做一些事情,包括以下内容:
-
模拟 HTTP 客户端
-
处理测试配置
-
为每个命令创建测试
我们将回顾音频文件仓库中第十一章中存在的每个步骤的代码。
模拟 HTTP 客户端
要模拟 HTTP 客户端,我们需要创建一个接口来模拟客户端的Do
方法,以及一个返回此接口的函数,这个接口既满足真实客户端也满足模拟客户端。
在cmd/client.go
文件中,我们编写了一些代码来处理所有这些:
type AudiofileClient interface {
Do(req *http.Request) (*http.Response, error)
}
var (
getClient = GetHTTPClient()
)
func GetHTTPClient() AudiofileClient {
return &http.Client{
Timeout: 15 * time.Second,
}
}
我们现在可以轻松地通过将getClient
变量替换为一个返回模拟客户端的函数来创建一个模拟客户端。如果你查看每个命令的代码,它会使用getClient
变量。例如,upload.go
文件使用以下行调用Do
方法:
resp, err := getClient.Do(req)
当应用程序运行时,这返回具有 15 秒超时的实际 HTTP 客户端。然而,在每次测试中,我们将 getClient
变量设置为模拟的 HTTP 客户端。
被模拟的 HTTP 客户端设置在 cmd/client_test.go
文件中。首先,我们定义类型:
type ClientMock struct {
}
然后,为了满足之前定义的 AudiofileClient
接口,我们实现了 Do
方法:
func (c *ClientMock) Do(req *http.Request) (*http.Response, error) {
一些请求,包括 list
、get
和 search
端点,将返回存储在 cmd/testfiles
文件夹下 JSON 文件中的数据。我们读取这些文件并将它们存储在相应的字节切片中:listBytes
、getBytes
和 searchBytes
:
listBytes, err := os.ReadFile("./testfiles/list.json")
if err != nil {
return nil, fmt.Errorf("unable to read testfile/list.json")
}
getBytes, err := os.ReadFile("./testfiles/get.json")
if err != nil {
return nil, fmt.Errorf("unable to read testfile/get.json")
}
searchBytes, err := os.ReadFile("./testfiles/search.json")
if err != nil {
return nil, fmt.Errorf("unable to read testfile/search.json")
}
从这些文件中读取的数据用于响应中。由于 Do
方法接收请求,我们可以为每个请求端点创建一个 switch case,然后单独处理响应。你可以创建更详细的 case 来处理错误,但在这个例子中,我们只返回成功的 case。对于第一个 case,即 /request
端点,我们返回 200 OK
,但响应体中也包含从 getBytes
获取的字符串值。你可以在 ./testfiles/get.json
文件中看到实际数据:
switch req.URL.Path {
case "/request":
return &http.Response{
Status: "OK",
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewBufferString(string(getBytes))),
ContentLength: int64(len(getBytes)),
Request: req,
Header: make(http.Header, 0),
}, nil
对于 /upload
端点,我们返回 200 OK
,但响应体中也包含 "123"
字符串值:
case "/upload":
return &http.Response{
Status: "OK",
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewBufferString("123")),
ContentLength: int64(len("123")),
Request: req,
Header: make(http.Header, 0),
}, nil
对于 /list
端点,我们返回 200 OK
,但响应体中也包含从 listBytes
获取的字符串值。你可以在 ./testfiles/list.json
文件中看到实际数据:
case "/list":
return &http.Response{
Status: "OK",
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.
NewBufferString(string(listBytes))),
ContentLength: int64(len(listBytes)),
Request: req,
Header: make(http.Header, 0),
}, nil
对于 /delete
端点,我们返回 200 OK
,但响应体中也包含 "成功删除音频,id: 456"
:
case "/delete":
return &http.Response{
Status: "OK",
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.
NewBufferString("successfully deleted
audio with id: 456")),
ContentLength: int64(len("successfully
deleted audio with id:
456")),
Request: req,
Header: make(http.Header, 0),
}, nil
对于 /search
端点,我们返回 200 OK
,但响应体中也包含从 searchBytes
获取的字符串值。你可以在 ./testfiles/search.json
文件中看到实际数据:
case "/search":
return &http.Response{
Status: "OK",
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.
NewBufferString(string(searchBytes))),
ContentLength: int64(len(list searchBytes
Bytes)),
Request: req,
Header: make(http.Header, 0),
}, nil
}
return &http.Response{}, nil
}
最后,如果请求路径与 switch
语句中的任何端点都不匹配,则返回空响应。
处理测试配置
我们在 cmd/root_test.go
文件中处理测试配置:
var Logger *zap.Logger
var Verbose *zap.Logger
func ConfigureTest() {
getClient = &ClientMock{}
viper.SetDefault("cli.hostname", "testHostname")
viper.SetDefault("cli.port", 8000)
utils.InitCLILogger()
}
在 ConfigureTest
函数中,我们将 getClient
变量设置为 ClientMock
类型的指针。因为当调用命令时检查 viper
配置值,所以我们为 CLI 的主机名和端口设置了随机测试值作为默认值。最后,在这个文件中,常规日志记录器 Logger
和详细日志记录器 Verbose
都被定义,然后通过 utils.InitCLILogger()
方法调用进行初始化。
为命令创建测试
现在我们已经设置了模拟客户端、配置和日志记录器,让我们为命令创建一个测试。在我深入到每个测试的代码之前,重要的是要提到每个测试开始时重复使用的代码行:
ConfigureTest()
前一节讨论了这个函数的细节,但它为每个状态准备了一个模拟客户端、默认配置值和初始化的日志记录器。在我们的示例中,我们使用了testing
包,它为 Go 中的自动化测试提供支持。它被设计为与go test
命令一起使用,该命令执行代码中定义的任何函数,格式如下:
func TestXxx(*testing.T)
Xxx
可以用任何其他东西替换,但第一个字符需要是大写。这个名字本身用来识别正在执行的测试类型。我不会逐一介绍每个测试,只举三个作为例子。要查看所有测试,请访问本章的音频文件仓库。
测试 bug 命令
测试bug
命令的函数定义在这里。它接受一个参数,即指向testing.T
类型的指针,并符合上一节中定义的函数格式。让我们分析一下代码:
func TestBug(t *testing.T) {
ConfigureTest()
b := bytes.NewBufferString("")
rootCmd.SetOut(b)
rootCmd.SetArgs([]string{"bug", "unexpected"})
err := rootCmd.Execute()
if err != nil {
fmt.Println("err: ", err)
}
actualBytes, err := ioutil.ReadAll(b)
if err != nil {
t.Fatal(err)
}
expectedBytes, err := os.ReadFile("./testfiles/bug.txt")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(actualBytes)) != strings.
TrimSpace(string(expectedBytes)) {
t.Fatal(string(actualBytes), "!=",
string(expectedBytes))
}
}
在这个函数中,我们首先定义输出缓冲区b
,稍后我们可以从中读取以与预期输出进行比较。我们使用SetArgs
方法设置参数并传递一个意外的参数。命令通过rootCmd.Execute()
方法执行,实际结果从缓冲区中读取并保存到actualBytes
变量中。预期的输出存储在./testfiles/bug.txt
文件中,并读取到expectedBytes
变量中。我们比较这些值以确保它们相等。由于我们传递了一个意外的参数,因此打印出命令用法。这个测试被设计为通过;然而,如果修剪后的字符串不相等,则测试失败。
测试获取命令
测试get
命令的函数定义在这里。同样,函数定义符合go test
命令可以识别的格式。记住模拟客户端和get
命令调用/request
端点。响应体包含在./testfiles/get.json
文件中找到的值。让我们分析一下代码:
func TestGet(t *testing.T) {
ConfigureTest()
b := bytes.NewBufferString("")
rootCmd.SetOut(b)
我们传递以下参数来模拟audiofile get –id 123 –``json
调用:
rootCmd.SetArgs([]string{"get", "--id", "123", "--json"})
我们使用前面的参数执行 root 命令:
err := rootCmd.Execute()
if err != nil {
fmt.Println("err: ", err)
}
我们从rootCmd
的执行中读取实际的数据输出并将其存储在actualBytes
变量中:
actualBytes, err := ioutil.ReadAll(b)
if err != nil {
t.Fatal(err)
}
我们从./``testfiles/get.json
文件中读取预期的数据输出:
expectedBytes, err := os.ReadFile("./testfiles/get.json")
if err != nil {
t.Fatal(err)
}
然后,将actualBytes
和expectedBytes
的数据反序列化到models.Audio
结构体中,然后进行比较:
var audio1, audio2 models.Audio
json.Unmarshal(actualBytes, &audio1)
json.Unmarshal(expectedBytes, &audio2)
if !(audio1.Id == audio2.Id &&
audio1.Metadata.Tags.Album == audio2.Metadata.Tags.Album &&
audio1.Metadata.Tags.AlbumArtist == audio2.Metadata.Tags.AlbumArtist &&
audio1.Metadata.Tags.Artist == audio2.Metadata.Tags.Artist &&
audio1.Metadata.Tags.Comment == audio2.Metadata.Tags.Comment &&
audio1.Metadata.Tags.Composer == audio2.Metadata.Tags.Composer &&
audio1.Metadata.Tags.Genre == audio2.Metadata.Tags.Genre &&
audio1.Metadata.Tags.Lyrics == audio2.Metadata.Tags.Lyrics &&
audio1.Metadata.Tags.Year == audio2.Metadata.Tags.Year) {
t.Fatalf("expected %q got %q", string(expectedBytes), string(actualBytes))
}
}
这个测试被设计为成功,但如果数据不符合预期,则测试失败。
测试上传命令
测试upload
命令的函数定义在这里。同样,函数定义符合go test
命令可以识别的格式。记住模拟客户端和upload
命令调用/upload
端点,带有包含"123"
值的模拟响应体。让我们分析一下代码:
func TestUpload(t *testing.T) {
ConfigureTest()
b := bytes.NewBufferString("")
rootCmd.SetOut(b)
rootCmd.SetArgs([]string{"upload", "--filename", "list.
go"})
err := rootCmd.Execute()
if err != nil {
fmt.Println("err: ", err)
}
expected := "123"
actualBytes, err := ioutil.ReadAll(b)
if err != nil {
t.Fatal(err)
}
actual := string(actualBytes)
if !(actual == expected) {
t.Fatalf("expected \"%s\" got \"%s\"", expected,
actual)
}
}
rootCmd
的参数被设置为模拟以下命令调用:
audiofile upload –filename list.go
文件类型和数据没有经过验证,因为那是在 API 端进行的,这里进行了模拟。然而,由于我们知道响应体包含 123
值,我们将期望变量设置为 123
。然后,包含命令执行输出的 actual
值随后与期望值进行比较。测试被设计为成功,但如果值不相等,则测试失败。
我们已经讨论了如何测试 CLI Cobra 命令的几个示例。现在,你可以通过模拟自己的 HTTP 客户端并为每个单独的命令创建测试来为你的 CLI 创建自己的测试。我们在这个章节中没有这样做,但了解构建标签也可以用来区分不同类型的测试是很有用的——例如,集成测试和单元测试。
运行测试
要测试你的命令,你可以运行 go test
并传递一些额外的标志:
-
-v
用于详细模式 -
-tags
用于指定你想要特别针对的任何文件
在我们的测试中,我们只想针对 pro
构建标签,因为这将涵盖所有命令。我们添加了两个额外的 Makefile
命令,一个用于以详细模式运行测试,另一个则不是:
test:
go test ./... -tags pro
test-verbose:
go test –v ./... -tags pro
在从终端保存 Makefile
之后,你可以执行以下命令:
make test
预期的输出如下:
go test ./cmd -tags pro
ok github.com/marianina8/audiofile/cmd
我们现在知道如何利用构建标签来运行测试。这应该是运行你自己的 CLI 测试所需的所有工具。
摘要
在这一章中,你学习了构建标签是什么以及如何用于不同的目的。构建标签可以用来生成不同级别的构建,分离我们的特定测试,或者添加调试功能。你还学习了如何生成带有你添加到文件顶部的构建标签的构建,以及如何利用标签的布尔逻辑来快速确定文件是否会被包含。
你还学习了如何使用 Golang 的默认 testing
包来测试你的 Cobra CLI 命令。一些必要的工具也被包括在内,比如学习如何模拟 HTTP 客户端。结合构建标签,你现在不仅可以使用标签构建目标应用程序,还可以使用相同的标签运行测试以针对特定的测试。在下一章,第十二章,跨平台交叉编译,我们将学习如何使用这些标签并为不同的主要操作系统编译:darwin
、linux
和 windows
。
问题
-
构建标签在 Golang 文件中的位置在哪里,语法是什么?
-
用于
go build
和go test
的哪个标志用于传递构建标签? -
你可以在集成测试的 Golang 文件上放置哪个构建标签,以及如何使用该标签运行
go test
?
答案
-
它位于文件顶部,在包声明之前,后面跟着一个单独的空行。语法是:
//``go:build [tag]
。 -
–tags
标志用于传递go build
和go test
方法中的构建标签。 -
你可以在任何集成测试文件顶部添加
//go:build int
构建标签,然后修改测试文件以运行此命令:go test ./cmd -tags "pro int"
。
进一步阅读
- 在
pkg.go.dev/go/build
了解更多关于build
包的信息,在pkg.go.dev/testing
了解更多关于testing
包的信息。
第十二章:跨不同平台的交叉编译
本章向用户介绍了 Go 在不同平台之间的交叉编译,这是 Go 的一个强大功能。尽管存在构建自动化工具,但了解如何进行交叉编译在必要时进行调试和定制时提供了基本知识。本章将解释 Go 可以编译的不同操作系统和架构,以及如何确定需要哪些。在您的环境中安装 Go 之后,有一个命令 go env
,您可以通过它查看所有与 Go 相关的环境变量。我们将讨论用于构建的两个主要变量:GOOS
和 GOARCH
。
我们将给出如何为每个主要操作系统(Linux、macOS 和 Windows)构建或安装应用程序的示例。您将学习如何根据您的环境和每个主要操作系统的可用架构确定 Go 的操作系统和架构设置。
本章以一个示例脚本结束,用于自动化跨不同操作系统和架构的交叉编译。提供了一个在 Darwin、Linux 或 Windows 环境上运行的脚本。在本章中,我们将详细介绍以下主题:
-
手动编译与构建自动化工具
-
使用
GOOS
和GOARCH
-
为 Linux、macOS 和 Windows 编译
-
编写脚本以编译多个平台
技术要求
- 您还可以在 GitHub 上找到代码示例:
github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter12/audiofile
手动编译与构建自动化工具
在第十四章 使用 GoReleaser 将您的 Go 二进制文件作为 Homebrew 公式发布 中,我们将深入了解一个出色的开源工具 GoReleaser,它可以自动化构建和发布 Go 二进制文件的过程。尽管它功能强大且有用,但了解如何手动编译您的 Go 代码至关重要。您会看到,并非所有项目都可以使用 GoReleaser 进行构建和发布。例如,如果您的应用程序需要独特的构建标志或依赖项,手动编译可能是必要的。此外,了解如何手动编译您的代码对于在构建过程中可能出现的任何问题至关重要。本质上,像 GoReleaser 这样的工具可以使过程更加顺畅,但掌握手动编译过程对于确保您的 命令行界面 (CLI) 应用程序可以在各种场景下构建和发布至关重要。
使用 GOOS 和 GOARCH
在开发你的命令行应用程序时,通过尽可能地为多个平台开发来最大化受众是很重要的。然而,你也可能只想针对特定的操作系统和架构。在过去,将应用程序部署到与开发平台不同的平台上要困难得多。实际上,在 macOS 平台上开发并在 Windows 机器上部署涉及到设置一个 Windows 构建机器来构建二进制文件。工具需要同步,还有其他需要考虑的因素,使得协作测试和分发变得繁琐。
幸运的是,Golang 通过直接将支持多个平台的功能构建到语言的工具链中解决了这个问题。如在第 7 章 和 第十一章 中讨论的,为不同的平台开发 和 自定义构建和测试 CLI 命令,我们学习了如何编写平台无关的代码,并使用 go build
命令和构建标签来针对特定的操作系统和架构。你也可以使用环境变量来针对操作系统和架构。
首先,了解哪些操作系统和架构可用于分发是很好的。为了找出这些信息,在你的终端中运行以下命令:
go tool dist list
列表以以下格式输出:GOOS
/GOARCH
。GOOS
是一个本地环境变量,用于定义要编译的操作系统,代表 GOARCH
,发音为“戈-arch”,是一个本地环境变量,用于定义要编译的架构,代表Go 架构。
图 12.1 – 支持的操作系统和架构列表
你也可以使用 –json
标志调用前面的命令来查看更多详细信息。例如,对于 linux/arm64
,你可以从 "CgoSupported"
字段中看到它由 Cgo
支持,但也可以看到它是一个一级的 GOOS/GOARCH
对,由 "FirstClass"
字段指示:
{
"GOOS": "linux",
"GOARCH": "arm64",
"CgoSupported": true,
"FirstClass": true
},
一级端口具有以下属性:
-
发布被损坏的构建所阻塞
-
提供官方的二进制文件
-
安装有文档说明
接下来,通过在你的终端中运行以下命令来确定你的本地操作系统和架构设置:
go env GOOS GOARCH
目前,在我的 macOS 机器上运行此命令,具有 AMD64 架构,得到以下输出:
darwin
amd64
第一个环境变量 GOOS
被设置为 darwin
,第二个环境变量 GOARCH
被设置为 amd64
。我们现在知道了在 Go 环境中 GOOS
和 GOARCH
是什么,可能的值,以及你的机器上设置了哪些值。让我们学习如何使用这些环境变量。
你可以使用这两个环境变量进行编译。让我们生成一个构建来针对 darwin/amd64
端口。你可以通过设置 GOOS
或 GOARCH
环境变量,然后运行 go build
命令,或者更具体地说,与 build
命令一起运行:
GOOS=darwin GOARCH=amd64 go build
让我们用音频文件 CLI 来尝试这个命令,并学习如何为三个主要操作系统:Linux、macOS 和 Windows 编译。
编译 Linux、macOS 和 Windows
有几种不同的方式来编译我们的命令行应用程序以适应不同的操作系统,我们将逐一介绍这些示例。首先,你可以通过构建或安装你的应用程序来编译:
-
–o
(输出)标志 -
$GOPATH/bin
文件夹或$GOBIN
如果已设置,将缓存所有非主包,这些包被导入到$GOPATH/pkg
文件夹
使用标签构建
在我们之前的章节 第十一章,自定义构建和测试 CLI 命令,我们学习了如何专门为 macOS 或 Darwin 操作系统构建。为了更好地理解如何使用 build
命令,我们运行 go build –help
来查看用法:
mmontagnino@Marians-MacBook-Pro audiofile % go build -help
usage: go build [-o output] [build flags] [packages]
Run 'go help build' for details
运行 go help build
将会显示可用的构建标志。然而,在这些示例中,我们只使用了 tags
标志。在 Makefile
中,我们已经有以下命令:
build-darwin-free:
go build -tags "darwin free" -o bin/audiofile main.go
chmod +x bin/audiofile
build-darwin-pro:
go build -tags "darwin pro" -o bin/audiofile main.go
chmod +x bin/audiofile
build-darwin-pro-profile:
go build -tags "darwin pro profile" -o bin/audiofile main.go
chmod +x bin/audiofile
在这些命令中,我们编译应用程序并将其输出到 bin/audiofile
文件名。为了指定 Darwin 操作系统,我们传递 Darwin 构建标签来指定与 Darwin 操作系统相关的文件。我们需要修改输出文件到一个指定 Darwin 的文件夹,但也要为其他具体细节,比如免费版与专业版,因为我们将为其他操作系统和级别构建。让我们修改这些。
使用标签构建 Darwin 操作系统应用程序
编译 Darwin 操作系统应用程序的新 Makefile
命令现在如下所示:
build-darwin-free:
go build -tags "darwin free" -o builds/free/darwin/audiofile main.go
chmod +x builds/free/darwin/audiofile
build-darwin-pro:
go build -tags "darwin pro" -o builds/pro/darwin/audiofile main.go
chmod +x builds/pro/darwin/audiofile
build-darwin-pro-profile:
go build -tags "darwin pro profile" -o builds/profile/darwin/audiofile main.go
chmod +x builds/profile/darwin/audiofile
我们已经将 bin/audiofile
输出替换为更具体的内容。Darwin 的免费版现在输出到 builds/free/darwin/audiofile
,专业版输出到 builds/pro/darwin/audiofile
,配置文件版输出到 builds/profile/darwin/audiofile
。让我们继续下一个操作系统,Linux。
我们可以为 Linux 和 Windows 做同样的事情,如下所示:
build-linux-free:
go build -tags "linux free" -o builds/free/linux/audiofile main.go
chmod +x builds/free/linux/audiofile
build-linux-pro:
go build -tags "linux pro" -o builds/pro/linux/audiofile main.go
chmod +x builds/pro/linux/audiofile
build-linux-pro-profile:
go build -tags "linux pro profile" -o builds/profile/linux/audiofile main.go
chmod +x builds/profile/linux/audiofile
build-windows-free:
go build -tags "windows free" -o builds/free/windows/ audiofile.exe main.go
build-windows-pro:
go build -tags "windows pro" -o builds/pro/windows/audiofile.exe main.go
build-windows-pro-profile:
go build -tags "windows pro profile" -o builds/profile/windows/audiofile.exe main.go
免费版的 Windows 版本输出到 builds/free/windows/audiofile.exe
,专业版的 Windows 版本输出到 builds/pro/windows/audiofile.exe
,而 Windows 配置文件版本输出到 builds/profile/windows/audiofile.exe
。现在,假设我们不想逐个运行这些命令,因为有很多命令需要运行!我们可以编写一个命令来使用标签构建所有版本。
使用标签构建适用于所有操作系统的应用程序
让我们添加一个新的 Makefile
命令来构建所有操作系统。基本上,我们写一个命令来调用所有其他命令:
build-all: build-darwin-free build-darwin-pro build-darwin-pro-profile build-linux-free build-linux-pro build-linux-pro-profile build-windows-free build-windows-pro build-windows-pro-profile
让我们尝试通过终端运行这个命令:
make build-all
如果您正在运行 Darwin,您将看到以下输出:
mmontagnino@Marians-MacBook-Pro audiofile % make build-all
go build -tags "darwin free" -o builds/free/darwin/audiofile main.go
chmod +x builds/free/darwin/audiofile
go build -tags "darwin pro" -o builds/pro/darwin/audiofile main.go
chmod +x builds/pro/darwin/audiofile
go build -tags "darwin pro profile" -o builds/profile/darwin/audiofile main.go
chmod +x builds/profile/darwin/audiofile
go build -tags "linux free" -o builds/free/linux/audiofile main.go
# internal/goos
/usr/local/go/src/internal/goos/zgoos_linux.go:7:7: GOOS redeclared in this block
/usr/local/go/src/internal/goos/zgoos_darwin.go:7:7: other declaration of GOOS
/usr/local/go/src/internal/goos/zgoos_linux.go:9:7: IsAix redeclared in this block
/usr/local/go/src/internal/goos/zgoos_darwin.go:9:7: other declaration of IsAix
/usr/local/go/src/internal/goos/zgoos_linux.go:10:7: IsAndroid redeclared in this block
...
/usr/local/go/src/internal/goos/zgoos_linux.go:17:7: too many errors
make: *** [build-linux-free] Error 2
我已经删除了部分错误信息;然而,最重要的信息是 GOOS redeclared in this block
。当操作系统被设置但与 GOOS
环境变量冲突时,会出现此错误信息。例如,失败的命令使用了操作构建标签来指定 Linux 构建:
go build -tags "linux free" -o builds/free/linux/audiofile main.go
然而,在我的 macOS 终端中运行 go env | grep GOOS
显示了 GOOS
环境变量的值:
GOOS="darwin"
让我们修改构建命令,以设置 GOOS
环境变量,使其与基于构建标签的输出类型相匹配。
使用 GOOS 环境变量进行构建
Linux 构建已被修改,通过在 build
命令之前添加 GOOS=linux
来设置 GOOS
环境变量:
build-linux-free:
GOOS=linux go build -tags "linux free" -o builds/free/linux/audiofile main.go
chmod +x builds/free/linux/audiofile
build-linux-pro:
GOOS=linux go build -tags "linux pro" -o builds/pro/linux/audiofile main.go
chmod +x builds/pro/linux/audiofile
build-linux-pro-profile:
GOOS=linux go build -tags "linux pro profile" -o builds/profile/linux/audiofile main.go
chmod +x builds/profile/linux/audiofile
Windows 构建已被修改,通过在 build
命令之前添加 GOOS=windows
来设置 GOOS
环境变量:
build-windows-free:
GOOS=windows go build -tags "windows free" -o builds/free/windows/audiofile.exe main.go
build-windows-pro:
GOOS=windows go build -tags "windows pro" -o builds/pro/windows/audiofile.exe main.go
build-windows-pro-profile:
GOOS=windows go build -tags "windows pro profile" -o builds/profile/windows/audiofile.exe main.go
现在,让我们再次尝试 build-all
命令。它运行成功,并且我们可以通过在 repo 中运行 find –type –f ./builds
来查看 build
命令生成的所有文件:
mmontagnino@Marians-MacBook-Pro audiofile % find ./builds -type f
./builds/pro/linux/audiofile
./builds/pro/darwin/audiofile
./builds/pro/windows/audiofile.exe
./builds/free/linux/audiofile
./builds/free/darwin/audiofile
./builds/free/windows/audiofile.exe
./builds/profile/linux/audiofile
./builds/profile/darwin/audiofile
./builds/profile/windows/audiofile.exe
使用 GOARCH 环境变量进行构建
单个操作系统可以关联许多不同的架构值。我们不会为每个架构创建一个命令,而是从仅一个示例开始:
build-darwin-amd64-free:
GOOS=darwin GOARCH=amd64 go build -tags "darwin free" -o builds/free/darwin/audiofile main.go
chmod +x builds/free/darwin/audiofile
此示例指定了操作系统,GOOS
环境变量为 darwin
,然后是架构,GOARCH
环境变量为 amd64
。
如果为每个主要操作系统的每个架构创建一个 build
命令,将会产生太多的命令。我们将把这个留到本章最后部分的脚本中。
使用标签和 GOOS 环境变量进行安装
- 如前所述,另一种编译您的命令行应用程序的方法是通过安装它。
install
命令编译应用程序,就像go build
命令一样,但还额外包含将编译后的应用程序移动到$GOPATH/bin
文件夹或$GOBIN
值的步骤。要了解更多关于install
命令的信息,我们运行以下go install –help
命令:
mmontagnino@Marians-MacBook-Pro audiofile % go install -help
usage: go install [build flags] [packages]
Run 'go help install' for details
- 构建时使用的标志对于安装也是可用的。再次强调,我们只会使用
tags
标志。让我们首先在 macOS 系统上运行install
命令:
go install -tags "darwin pro" github.com/marianina8/audiofile
然而,在我的 macOS 终端中运行 go env | grep GOPATH
显示了 GOOS
环境变量的值:
mmontagnino@Marians-MacBook-Pro audiofile % go env | grep GOPATH
GOPATH="/Users/mmontagnino/Code"
确认音频文件 CLI 可执行文件存在于 $GOPATH/bin
或 /Users/mmontagnino/Code/bin
文件夹中。
如前所述,我们可以使用构建标签根据操作系统和架构来分离构建。在音频文件仓库中,我们已经在以下文件中这样做,这些文件与 play
和 bug
命令相关联。对于 bug
命令,我们有以下文件。现在,既然我们已经了解了如何使用构建标签和 GOOS
环境变量,让我们在 Makefile
中添加一些 install
命令。
Darwin 操作系统的 install 命令
Darwin 操作系统的 install
命令包括传递特定的标记,包括 darwin
,以及由标记定义的级别,以安装:
install-darwin-free:
go install -tags "darwin free" github.com/marianina8/audiofile
install-darwin-pro:
go install -tags "darwin pro" github.com/marianina8/audiofile
install-darwin-pro-profile:
go install -tags "darwin pro profile" github.com/marianina8/audiofile
Linux 操作系统的安装命令
Linux 操作系统的 install
命令包括传递特定的标记,包括 linux
,以及要安装的软件包。为了确保命令不会因为冲突的 GOOS
设置而出错,我们将匹配的环境变量 GOOS
设置为 linux
:
install-linux-free:
GOOS=linux go install -tags "linux free" github.com/marianina8/audiofile
install-linux-pro:
GOOS=linux go install -tags "linux pro" github.com/marianina8/audiofile
install-linux-pro-profile:
GOOS=linux go install -tags "linux pro profile" github.com/marianina8/audiofile
Windows 操作系统的安装命令
Windows 操作系统的 install
命令包括传递特定的标记,包括 windows
,以及要安装的软件包。为了确保命令不会因为冲突的 GOOS
设置而出错,我们将匹配的环境变量 GOOS
设置为 windows
:
install-windows-free:
GOOS=windows go install -tags "windows free" github.com/marianina8/audiofile
install-windows-pro:
GOOS=windows go install -tags "windows pro" github.com/marianina8/audiofile
install-windows-pro-profile:
GOOS=windows go install -tags "windows pro profile" github.com/marianina8/audiofile
请记住,对于您的 Makefile
,如果您在自己的账户下分叉了仓库,您需要更改软件包的位置。运行您需要的操作系统的 make
命令,并通过检查 $GOPATH/bin
或 $GOBIN
文件夹来确认应用程序已安装。
使用标记和 GOARCH 环境变量进行安装
尽管许多不同的架构值可以与单个操作系统相关联,但让我们从一个使用 GOARCH
环境变量的安装示例开始:
install-linux-amd64-free:
GOOS=linux GOARCH=amd64 go install -tags "linux free" github.com/marianina8/audiofile
此示例指定了操作系统,GOOS
环境变量为 linux
,然后是架构,GOARCH
环境变量为 amd64
。我们不会为每一对操作系统和架构创建命令,再次,我们将此保存为本章最后部分的脚本中。
编译多个平台的脚本
我们已经学习了使用 GOOS
和 GOARCH
环境变量以及使用构建标记编译操作系统的几种不同方法。Makefile
可能会很快填满所有不同的 GOOS
/GOARCH
对组合,如果您想要为更多特定的架构生成构建,脚本可能提供更好的解决方案。
创建用于在 Darwin 或 Linux 中编译的 bash 脚本
让我们先创建一个 bash 脚本。让我们称它为 build.sh
。要创建文件,我只需输入以下内容:
touch build.sh
前一个命令在文件不存在时创建文件。文件扩展名是 .sh
,虽然添加它不是必需的,但它清楚地表明该文件是 bash 脚本类型。接下来,我们想要编辑它。如果使用 vi
,请使用以下命令:
vi build.sh
否则,使用您选择的编辑器编辑文件。
添加 shebang
bash 脚本的第一行被称为 shebang。它是一个字符序列,指示程序加载器的第一条指令。它定义了在读取或解释脚本时要运行的解释器。以下是要使用 bash 解释器的第一行指示:
#!/bin/bash
shebang 由几个元素组成:
-
#!
指示程序加载器加载代码的解释器 -
/bin/bash
表示 bash 或解释器的位置
这些是不同解释器的典型 shebang:
解释器 | Shebang |
---|---|
Bash | #!/``bin/bash |
Bourne shell | #!/``bin/sh |
Powershell | #!/``user/bin/pwsh |
其他脚本语言 | #!/``user/bin/env <解释器> |
表 12.1 – 不同解释器的 shebang 行
添加注释
要向您的 bash 脚本添加注释,只需用 #
符号和井号开始注释,然后是注释文本。这些文本可以由您和其他开发者使用,以记录可能仅从代码本身难以理解的信息。它也可以只是添加一些有关脚本使用、作者等信息。
添加打印行
在 bash 文件中,要打印行,只需使用 echo
命令。这些打印行将帮助您了解应用程序在其运行过程中的确切位置。有目的地使用这些行,它们将为您和您的用户提供一些有用的见解,甚至可以使调试更容易。
添加代码
在 bash 脚本中,我们将为每个操作系统和架构对生成所有不同的构建标签的构建。让我们首先看看 Darwin 可用的架构值:
go tool dist list | grep darwin
返回的值如下:
darwin/amd64
darwin/arm64
让我们使用以下代码生成所有架构的不同 Darwin 构建 – 免费版、专业版和配置文件版:
# Generate darwin builds
darwin_archs=(amd64 arm64)
for darwin_arch in ${darwin_archs[@]}
do
echo "building for darwin/${darwin_arch} free version..."
env GOOS=darwin GOARCH=${darwin_arch} go build -tags free -o builds/free/darwin/${darwin_arch}/audiofile main.go
echo "building for darwin/${darwin_arch} pro version..."
env GOOS=darwin GOARCH=${darwin_arch} go build -tags pro -o builds/pro/darwin/${darwin_arch}/audiofile main.go
echo "building for darwin/${darwin_arch} profile version..."
env GOOS=darwin GOARCH=${darwin_arch} go build -tags profile -o builds/profile/darwin/${darwin_arch}/audiofile main.go
done
接下来,让我们用 Linux 做同样的事情,首先获取可用的架构值:
go tool dist list | grep linux
返回的值如下:
linux/386 linux/mips64le
linux/amd64 linux/mipsle
linux/arm linux/ppc64
linux/arm64 linux/ppc64le
linux/loong64 linux/riscv64
linux/mips linux/s390x
linux/mips64
让我们使用以下代码生成所有架构的不同 Linux 构建 – 免费版、专业版和配置文件版:
# Generate linux builds
linux_archs=(386 amd64 arm arm64 loong64 mips mips64 mips64le mipsle ppc64 ppc64le riscv64 s390x)
for linux_arch in ${linux_archs[@]}
do
echo "building for linux/${linux_arch} free version..."
env GOOS=linux GOARCH=${linux_arch} go build -tags free -o builds/free/linux/${linux_arch}/audiofile main.go
echo "building for linux/${linux_arch} pro version..."
env GOOS=linux GOARCH=${linux_arch} go build -tags pro -o builds/pro/linux/${linux_arch}/audiofile main.go
echo "building for linux/${linux_arch} profile version..."
env GOOS=linux GOARCH=${linux_arch} go build -tags profile -o builds/profile/linux/${linux_arch}/audiofile main.go
done
接下来,让我们用 Windows 做同样的事情,首先获取可用的架构值:
go tool dist list | grep windows
返回的值如下:
windows/386
windows/amd64
windows/arm
windows/arm64
最后,让我们使用以下代码生成所有架构的不同 Windows 构建 – 免费版、专业版和配置文件版:
# Generate windows builds
windows_archs=(386 amd64 arm arm64)
for windows_arch in ${windows_archs[@]}
do
echo "building for windows/${windows_arch} free version..."
env GOOS=windows GOARCH=${windows_arch} go build -tags free -o builds/free/windows/${windows_arch}/audiofile.exe main.go
echo "building for windows/${windows_arch} pro version..."
env GOOS=windows GOARCH=${windows_arch} go build -tags pro -o builds/pro/windows/${windows_arch}/audiofile.exe main.go
echo "building for windows/${windows_arch} profile version..."
env GOOS=windows GOARCH=${windows_arch} go build -tags profile -o builds/profile/windows/${windows_arch}/audiofile.exe main.go
done
这里是来自 Darwin/macOS 或 Linux 终端的运行代码:
./build.sh
我们可以检查是否已生成可执行文件。完整的列表相当长,并且它们已被组织在以下嵌套文件夹结构中:
/builds/{level}/{operating-system}/{architecture}/{audiofile-executable}
图 12.2 – 从构建 bash 脚本生成的文件夹截图
如果在 Windows 上运行,例如,生成这些构建的脚本可能需要不同。如果您在 Darwin 或 Linux 上运行应用程序,请尝试运行构建脚本并查看生成的构建。您现在可以与其他在平台不同的用户共享这些构建。接下来,我们将创建一个 PowerShell 脚本来生成在 Windows 上运行的相同构建。
在 Windows 中创建 PowerShell 脚本
让我们从创建一个 PowerShell 脚本开始。让我们将其命名为 build.ps1
。在 PowerShell 中输入以下命令以创建文件:
notepad build.ps1
前面的命令会在文件不存在时创建文件。文件扩展名是 .ps1
,这表示文件是 PowerShell 脚本类型。接下来,我们想要编辑它。你可以使用记事本或其他你选择的编辑器。
与 bash 脚本不同,PowerShell 脚本不需要 shebang。要了解更多关于如何编写 PowerShell 脚本的信息,你可以在此处查看文档:learn.microsoft.com/en-us/powershell/
。
添加注释
要为你的 PowerShell 脚本添加注释,只需在注释文本前加上一个 #
符号和货币符号。
添加打印行
在 PowerShell 文件中,要打印行,只需使用 Write-Output
命令:
Write-Output "building for windows/amd64..."
输出写作将帮助你确切了解你的应用程序在其运行过程中的位置,使调试更容易,并给用户一种正在运行的感觉。完全没有输出不仅无聊,而且对用户没有任何沟通。
添加代码
在 PowerShell 脚本中,我们将为每个操作系统和架构对生成所有不同的构建标签。让我们先通过 Windows 命令查看 Darwin 可用的架构值:
PS C:\Users\mmontagnino\Code\src\github.com\marianina8\audiofile> go tool dist list | Select-String darwin
使用 Select-String
命令,我们可以只返回包含 darwin
的值。这些值被返回:
darwin/amd64
darwin/arm64
我们可以为 Linux 运行一个类似的命令:
PS C:\Users\mmontagnino\Code\src\github.com\marianina8\audiofile> go tool dist list | Select-String linux
以及一个 Windows 的命令:
PS C:\Users\mmontagnino\Code\src\github.com\marianina8\audiofile> go tool dist list | Select-String windows
在前面的部分中返回了相同的值,所以我不需要打印它们。然而,既然我们已经知道如何为每个操作系统获取架构,我们就可以添加代码来生成所有操作系统的构建。
生成 Darwin 构建的代码如下:
# Generate darwin builds
$darwin_archs="amd64","arm64"
foreach ($darwin_arch in $darwin_archs)
{
Write-Output "building for darwin/$($darwin_arch) free version..."
$env:GOOS="darwin";$env:GOARCH=$darwin_arch; go build -tags free -o .\builds\free\darwin\$darwin_arch\audiofile main.go
Write-Output "building for darwin/$($darwin_arch) pro version..."
$env:GOOS="darwin";$env:GOARCH=$darwin_arch; go build -tags pro -o .\builds\pro\darwin\$darwin_arch\audiofile main.go
Write-Output "building for darwin/$($darwin_arch) profile version..."
$env:GOOS="darwin";$env:GOARCH=$darwin_arch; go build -tags profile -o .\builds\profile\darwin\$darwin_arch\audiofile main.go
}
生成 Linux 构建的代码如下:
# Generate linux builds
$linux_archs="386","amd64","arm","arm64","loong64","mips","mips64","mips64le","mipsle","ppc64","ppc64le","riscv64","s390x"
foreach ($linux_arch in $linux_archs)
{
Write-Output "building for linux/$($linux_arch) free version..."
$env:GOOS="linux";$env:GOARCH=$linux_arch; go build -tags free -o .\builds\free\linux\$linux_arch\audiofile main.go
Write-Output "building for linux/$($linux_arch) pro version..."
$env:GOOS="linux";$env:GOARCH=$linux_arch; go build -tags pro -o .\builds\pro\linux\$linux_arch\audiofile main.go
Write-Output "building for linux/$($linux_arch) profile version..."
$env:GOOS="linux";$env:GOARCH=$linux_arch; go build -tags profile -o .\builds\profile\linux\$linux_arch\audiofile main.go
}
最后,生成 Windows 构建的代码如下:
# Generate windows builds
$windows_archs="386","amd64","arm","arm64"
foreach ($windows_arch in $windows_archs)
{
Write-Output "building for windows/$($windows_arch) free version..."
$env:GOOS="windows";$env:GOARCH=$windows_arch; go build -tags free -o .\builds\free\windows\$windows_arch\audiofile.exe main.go
Write-Output "building for windows/$($windows_arch) pro version..."
$env:GOOS="windows";$env:GOARCH=$windows_arch; go build -tags pro -o .\builds\pro\windows\$windows_arch\audiofile.exe main.go
Write-Output "building for windows/$($windows_arch) profile version..."
$env:GOOS="windows";$env:GOARCH=$windows_arch; go build -tags profile -o .\builds\profile\windows\$windows_arch\audiofile.exe main.go
}
每个部分为三个主要操作系统之一和所有可用的架构生成一个构建。要从 PowerShell 运行脚本,只需运行以下脚本:
./build.ps1
每个端口的输出如下:
building for $GOOS/$GOARCH [free/pro/profile] version...
检查 builds
文件夹以查看所有成功生成的端口。完整的列表相当长,并且它们已经被组织在以下嵌套文件夹结构中:
/builds/{level}/{operating-system}/{architecture}/{audiofile-executable}
现在,我们可以从 PowerShell 脚本中生成所有操作系统和架构的构建,该脚本可以在 Windows 上运行。如果你运行任何主要操作系统——Darwin、Linux 或 Windows——你现在可以为你的平台或任何希望使用你的应用程序的其他人生成构建。
摘要
在本章中,你学习了 GOOS
和 GOARCH
环境变量是什么,以及如何使用它们,以及如何使用构建标签来根据操作系统、架构和级别自定义构建。这些环境变量帮助你了解你正在构建的环境,并可能理解为什么构建可能在其他平台上执行时遇到问题。
编译应用程序有两种方式——构建或安装。在本章中,我们讨论了如何构建或安装应用程序以及它们之间的区别。每个命令都提供了相同的标志,但我们讨论了如何使用Makefile
在各个主要操作系统上构建或安装。然而,这也显示了Makefile
可以变得多么庞大!
最后,我们学习了如何创建一个简单的脚本,在 Darwin、Linux 或 Windows 上运行,以生成所有主要操作系统的所有构建。你学习了如何编写 bash 和 PowerShell 脚本以生成构建。在下一章第十三章《使用容器进行分发》中,我们将学习如何在由不同操作系统镜像创建的容器上运行这些编译后的应用程序。最后,在第十四章第十四章《使用 GoReleaser 将 Go 二进制文件作为 Homebrew 公式发布》中,你将探索自动化构建和发布 Go 二进制文件到各种操作系统和架构所需的工具。通过学习如何使用 GoReleaser,你可以显著加快发布和部署应用程序的过程。这样,你可以专注于开发新功能和处理错误,而不是陷入构建和编译过程。最终,使用 GoReleaser 可以节省你宝贵的时间和精力,你可以将这些时间用于使你的应用程序更加出色。
问题
-
哪些 Go 环境变量定义了操作系统和架构?
-
使用一流端口构建可以获得哪些额外的安全性?
-
在 Linux 上,你会运行什么命令来找到 Darwin 操作系统的端口号?
答案
-
GOOS
是 Golang 操作系统,而GOARCH
是 Golang 架构值。 -
为什么一流端口更安全有几个原因:发布被损坏的构建阻止,提供官方的二进制文件,以及安装有文档说明。
-
go tool dist list |
grep darwin
.
进一步阅读
-
在
go.dev/doc/tutorial/compile-install
了解更多关于编译的信息 -
在
pkg.go.dev/cmd/go
了解更多关于 Go 环境变量的信息
第十三章:使用容器进行分发
在本章中,我们将探讨容器化的世界,并检查为什么你应该使用 Docker 容器来测试和分发你的应用程序的许多原因。术语容器化指的是一种软件打包风格,它使得在任何环境中部署和运行变得简单。首先,我们将通过一个可以构建成镜像并作为容器运行的应用程序来介绍 Docker 的基础知识。然后,我们将回到我们的 audiofile 应用程序,作为一个更高级的例子,学习如何创建多个可以组合和一起运行的 Docker 容器。这些例子不仅让你理解了用于运行容器的基本标志,还展示了如何使用映射的网络堆栈、卷和端口来运行容器。
我们还解释了如何使用 Docker 容器进行集成测试,这增加了你的信心,因为坦白说,模拟 API 响应只能覆盖这么多。单元测试和集成测试的良好组合不仅提供了覆盖率,还提供了整体系统工作的信心。
最后,我们将讨论采用 Docker 的一些不利因素。考虑管理容器化应用程序的复杂性增加,以及在单个主机上运行多个容器的额外开销。Docker 作为一个外部依赖项本身可能就是一个不利因素。本章将帮助你确定何时以及何时不使用容器来处理你的应用程序。
到本章结束时,你将深刻理解如何利用 Docker 容器以及它们如何可能帮助你开发、测试和部署工作流程。你将能够将你的应用程序容器化,使用 Docker 进行测试,并通过 Docker Hub 发布。具体来说,我们将涵盖以下主题:
-
为什么使用容器?
-
使用容器进行测试
-
使用容器进行分发
技术要求
对于本章,你需要做以下事情:
-
在
www.docker.com/products/docker-desktop/
下载并安装 Docker Desktop -
安装 Docker Compose 插件
你也可以在 GitHub 上找到代码示例:github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter13
为什么使用容器?
首先,让我们谈谈什么是容器。容器是一个标准化的软件单元,它通过将应用程序的代码及其所有依赖项打包成一个单一的封装,允许程序从一个计算环境快速且可靠地传输到另一个环境。简单来说,容器允许您将所有依赖项打包到一个容器中,以便它可以在任何机器上运行。容器彼此隔离,并捆绑自己的系统库和设置,因此它们不会与其他容器或宿主系统冲突。这使得它们成为虚拟机(VMs)的轻量级和便携式替代品。流行的容器化工具包括Docker和Kubernetes。
从容器中获益
让我们分析一下在 Go 项目中使用容器的部分好处:
-
便携性:容器使得在不同环境中保持行为一致性成为可能,降低了错误和不兼容的可能性。
-
隔离性:它们提供了一定程度的与宿主系统和其他容器的隔离,这提高了它们的安全性并减少了冲突的可能性。
-
轻量级:与虚拟机相比,容器更小,启动速度更快,这提高了它们的运行效率。
-
可伸缩性:它们可以轻松地进行扩展或缩减,从而实现有效的资源利用。例如,如果您为应用程序使用容器,那么您可以在多个服务器上部署运行应用程序的多个相同容器。
-
版本控制:容器可以进行版本控制,这使得在需要时简单地回滚到早期迭代变得简单。
-
模块化:由于容器可以单独创建和管理,因此它们易于更新和维护。
-
经济高效:通过减少运行应用程序所需的系统数量,容器可以帮助您在基础设施和维护方面节省资金。
容器使得创建和运行命令行应用程序变得简单且可靠。无论宿主机的配置如何,这意味着应用程序始终以相同的方式进行构建和运行。通过在容器镜像中包含所有必要的依赖项和运行时环境,容器显著简化了跨不同操作系统的应用程序开发和部署。最后,容器使得复制开发环境变得简单,使得多个开发人员或团队能够在同一领域内协作,同时确保应用程序在各种环境中统一开发和执行。
此外,使用容器使得将应用程序与持续集成和持续部署(CI/CD)管道集成变得更加简单。由于所有必要的依赖项都存在于容器镜像中,因此管道可以更可靠、更轻松地构建和运行应用程序,从而消除了配置管道宿主机开发环境的需要。
最后,使用容器实现的隔离环境的稳定性是另一个好处,这使得在保证应用程序按预期运行的同时,更容易分发您的命令行应用程序。用户不再需要为应用程序配置环境,这使得容器,虽然轻量级,成为在各种环境和平台间分发的好方法。
如您所清晰看到的,有许多情况下容器可以证明是有用的,包括命令行应用程序开发和测试!现在,让我们讨论一下您可能不想使用容器的情况。
不使用容器的决定
虽然容器通常很有帮助,但在某些情况下,它们可能不是最佳选择:
-
高性能计算:由于它们造成的额外开销,高性能计算和其他需要直接访问宿主机系统资源的任务可能不适合容器。
-
需要高安全级别:容器共享宿主机的内核,可能不会提供与虚拟机(VM)一样多的隔离。如果您的负载需要高安全级别,VM 可能是一个更好的选择。
-
忽视容器原生特性:如果您不打算使用任何用于扩展、滚动更新、服务发现和负载均衡的内置特性,您可能看不到使用容器的优势。
-
不灵活的应用程序:如果一个应用程序需要非常特定的操作系统环境才能正常运行,那么它可能甚至无法容器化,因为支持的操作系统和平台有限。
-
团队惯性:如果您或您的团队不愿意学习容器和容器编排,那么将难以引入新的工具。
然而,重要的是要注意,这些情况并不总是如此,并且有一些解决方案可用,包括使用虚拟机(VM)、容器编排平台特定的安全特性、专门的容器运行时如gVisor或Firecracker,以及其他。
在以下示例和接下来的章节中,我们将使用 Docker 来展示开始使用 Docker 以及如何用它来创建一个用于测试和分发的一致性环境是多么容易。
在Chapter-13
GitHub 仓库中,我们介绍了一个构建镜像和运行容器的非常简单的示例。main.go
文件很简单:
func main() {
var helloFlag bool
flag.BoolVar(&helloFlag, "hello", false, "Print 'Hello,
World!'")
flag.Parse()
if helloFlag {
fmt.Println("Hello, World!")
}
}
向构建的应用程序传递hello
标志将会打印出"Hello, World!"
。
构建简单的 Docker 镜像
首先,软件可以被打包成一个镜像,这是一个小型、自包含的可执行文件,包含程序的源代码、库、配置文件、运行时和环境变量。镜像是容器的基本构建块,用于创建和运行它们。
让我们为这个非常简单的应用程序构建一个 Docker 镜像。为此,我们需要创建一个Dockerfile
,当您运行命令行 Docker 命令时,它将自动被识别,或者创建一个具有.dockerfile
扩展名的文件,这将需要–f
或--file
标志来传递文件名。
Dockerfile 包含构建 Docker 镜像的指令,如下所示图所示。每个指令在镜像中创建一个新的层。这些层被组合起来创建最终的镜像。您可以在 Dockerfile 中放置许多不同类型的指令。例如,您可以告诉 Docker 将文件复制到基镜像中,设置环境变量,运行命令,并指定在容器初始化时要运行的可执行文件:
图 13.1 – Dockerfile 通过构建命令转换为具有层的图像的视觉表示
对于我们的基础镜像,让我们访问 Docker Hub 网站hub.docker.com
,并搜索 Go v1.19
的官方 Golang Docker 基础镜像。我们看到我们可以使用带有标签1.19
的golang
镜像。FROM
指令是 Dockerfile 的第一行,它设置要使用的基镜像:
FROM golang:1.19
然后,复制所有文件:
COPY . .
构建hello
world
应用程序:
RUN go build main.go
最后,运行应用程序时传递hello
标志:
CMD ["./main", "--hello"]
总的来说,Dockerfile 包含了前面的指令和一些用#
作为行首字符的描述性注释。
要从 Dockerfile 构建 Docker 镜像,我们调用docker build
命令。该命令采用以下语法:
docker build [options] path| url | -
当运行时,该命令执行以下操作:
-
读取 Dockerfile 中指定的指令并按顺序执行
-
每个指令在镜像中创建一个新的层,最终的镜像将它们全部组合起来
-
使用指定的或生成的名称标记新镜像,并且可选地使用
name:tag
格式标记
options
参数可以用来向命令传递不同的选项,这可以包括构建时变量、目标等。path | url | -
参数指定 Dockerfile 的位置。
让我们尝试从为我们的 hello world 应用程序创建的 Dockerfile 构建这个镜像。在存储库的根目录下运行以下命令:
docker build --``tag hello-world:latest
运行命令后,你应该看到类似以下输出:
[+] Building 2.4s (8/8) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 238B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/golang:1.19 1.2s
=> [internal] load build context 0.0s
=> => transferring context: 2.25kB 0.0s
=> CACHED [1/4] FROM docker.io/library/golang:1.19@sha256:bb9811fad43a7d6fd217324 0.0s
=> [2/4] COPY . . 0.0s
=> [3/4] RUN go build main.go 1.0s
=> exporting to image 0.1s
=> => exporting layers 0.0s
=> => writing image sha256:91f97dc0109218173ccae884981f700c83848aaf524266de20f950 0.0s
=> => naming to docker.io/library/hello-world:latest 0.0s
从输出大约一半的位置开始,您会看到镜像的层被构建,最后以标记为hello-world:latest
的最终镜像结束。
您可以通过在终端运行以下命令来查看现有的镜像:
% docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world latest 91f97dc01092 18 minutes ago 846MB
现在我们已经成功构建了这个简单 hello world 应用程序的 Docker 镜像,让我们继续在容器中运行它。
运行一个简单的 Docker 容器
当您运行 Docker 容器时,Docker Engine 会从一个现有的镜像创建一个新的运行实例。这个容器存在于一个具有自己的文件系统、网络接口和进程空间的隔离环境中。然而,镜像是创建或运行容器的一个必要起点。
注意
当容器运行时,它可以对文件系统进行更改,例如创建或修改文件。然而,这些更改不会保存在镜像中,当容器停止时将会丢失。如果您想保存这些更改,可以使用docker commit
命令为容器创建一个新的镜像。
要从镜像创建和运行 Docker 容器,我们调用docker run
命令。该命令采用以下语法:
docker run [options] image[:tag] [command] [arg...]
docker run
命令检查镜像是否本地存在;如果不存在,则从 Docker Hub 拉取。然后 Docker Engine 从这个镜像创建一个新的容器,应用所有层或指令。我们在这里将其分解:
图 13.2 – 使用 run 命令创建容器的图像可视化
如前所述,当调用docker run
时,以下步骤会发生:
-
Docker 检查请求的镜像是否本地存在;如果不存在,则从注册表,如 Docker Hub 检索它。
-
从镜像中,它创建了一个新的容器。
-
它启动容器并执行 Dockerfile 指令中指定的命令。
-
它将终端连接到容器的进程,以便显示命令的任何输出。
options
参数可以用来向命令传递不同的选项,这可以包括端口映射、设置环境变量等。image[:tag]
参数指定用于创建容器的镜像。最后,command
和[arg...]
参数用于指定在容器内运行的任何命令。
在我们调用docker run
命令的每个示例中,我们传递--rm
标志,这告诉 Docker 在退出时自动删除容器。这将避免您意外地留下许多停止的容器在后台占用大量空间。
尝试从我们为 hello world 应用程序创建的hello-world:latest
镜像运行一个镜像。在存储库的根目录下运行以下命令,并查看文本输出:
% docker run --rm hello-world:latest
Hello, World!
我们做到了!一个简单的 Dockerfile 用于简单的 hello world 应用程序。在接下来的两个部分中,我们将回到 audiofile 命令行应用程序示例,并使用构建镜像和运行容器的新技能进行测试和分发。
使用容器进行测试
到目前为止,在我们的命令行应用程序之旅中,我们已经构建了测试并模拟了服务输出。除了在任意主机机器上运行测试的一致性和隔离环境之外,使用容器的好处是你可以使用它们来运行集成测试,这为你的应用程序提供了更可靠的测试覆盖率。
创建集成测试文件
我们创建了一个新的integration_test.go
文件来处理集成测试的配置和执行,但我们不希望它与所有其他测试一起运行。为了指定其独特性,让我们用int
标签标记它,代表集成。在文件的顶部,我们添加以下构建标签:
//go:build int && pro
我们包含pro
构建标签,因为我们正在测试所有可用的功能。
编写集成测试
首先,让我们编写ConfigureTest()
函数来为我们的集成测试做准备:
func ConfigureTest() {
getClient = &http.Client{
Timeout: 15 * time.Second,
}
viper.SetDefault("cli.hostname", "localhost")
viper.SetDefault("cli.port", 8000)
utils.InitCLILogger()
}
在前面的代码中,你可以看到我们使用的是实际客户端,而不是当前在单元测试中使用的模拟客户端。我们使用viper
来设置 API 的 hostname 和 port,将其连接到本地的8000
端口。最后,我们初始化日志文件,以避免在日志记录时出现任何恐慌。
对于集成测试,让我们使用一个特定的工作流程:
-
上传音频:首先,我们想要确保在本地存储中存在一个音频文件。
-
通过 ID 获取音频:从上一步,我们可以检索返回的音频文件 ID,并使用它从存储中检索音频元数据。
-
列出所有音频:我们列出所有音频元数据,并确认之前上传的音频存在于列表中。
-
通过值搜索音频:根据我们知道的描述中存在的元数据搜索已上传的音频。
-
通过 ID 删除音频:最后,通过从步骤 1中检索到的 ID 删除我们最初上传的音频文件。
顺序是特定的,因为工作流程中的后续步骤依赖于第一步。
集成测试类似于单元测试,但传递给实际文件的路径,并调用实际的 API。在integration_tests.go
文件中存在一个TestWorkflow
函数,它按照之前列出的顺序调用命令。由于代码与单元测试相似,让我们只概述前两个命令调用,然后直接进入使用 Docker 执行集成测试!
在测试任何方法之前,通过调用ConfigureTest
函数来配置集成测试:
ConfigureTest()
fmt.Println("*** Testing upload ***")
b := bytes.NewBufferString("")
rootCmd.SetOut(b)
rootCmd.SetArgs([]string{"upload", "--filename",
"../audio/algorithms.mp3"})
err := rootCmd.Execute()
if err != nil {
fmt.Println("err: ", err)
}
uploadResponse, err := ioutil.ReadAll(b)
if err != nil {
t.Fatal(err)
}
id := string(uploadResponse)
if id == "" {
t.Fatalf("expected id returned")
}
在前面的代码中,我们随后使用rootCmd
调用upload
命令,并将文件名设置为../audio/algorithms.mp3
。我们执行命令,并将响应作为字节切片读取回来,然后将其转换为字符串并存储在id
变量中。这个id
变量随后用于后续的测试。我们运行get
命令,并传入相同的id
变量来检索之前上传的音频文件的元数据:
fmt.Println("*** Testing get ***")
rootCmd.SetArgs([]string{"get", "--id", id, "--json"})
err = rootCmd.Execute()
if err != nil {
fmt.Println("err: ", err)
}
getResponse, err := ioutil.ReadAll(b)
if err != nil {
t.Fatal(err)
}
var audio models.Audio
json.Unmarshal(getResponse, &audio)
if audio.Id != id {
t.Fatalf("expected matching audiofile returned")
}
我们继续以类似的方式测试 list
、search
和 delete
命令,并确保每次都返回具有匹配 id
变量的特定元数据。当测试完成后,我们尝试运行集成测试。如果没有本地运行的 API,运行以下命令将失败得非常惨烈:
go test ./cmd -tags "int pro"
在我们再次尝试之前,让我们构建一个 Dockerfile 来在容器环境中运行 API。
编写 Dockerfile
在现实世界中,我们的 API 可能托管在一些外部网站上。然而,我们目前运行在 localhost
,在容器中运行它将使用户能够轻松地在任何机器上运行它。在本节中,我们将创建两个 Dockerfile:一个用于 CLI,另一个用于 API。
编写 API Dockerfile
首先,我们将创建一个 api.Dockerfile
文件来包含构建镜像和运行容器以运行 audiofile API 的所有指令:
FROM golang:1.19
# Set the working directory
WORKDIR /audiofile
# Copy the source code
COPY . .
# Download the dependencies
RUN go mod download
# Expose port 8000
EXPOSE 8000
# Build the audiofile application with the pro tag so all
# features are available
RUN go build -tags "pro" -o audiofile main.go
RUN chmod +x audiofile
# Start the audiofile API
CMD ["./audiofile", "api"]
让我们构建这个镜像。–f
标志允许你指定要使用的 api.Dockerfile
文件,而 –t
标志允许你命名和标记镜像:
% docker build -f api.Dockerfile -t audiofile:api .
命令执行后,你可以运行 docker images
命令来确认其创建:
% docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
audiofile api 12afba7f3fb7 9 minutes ago 1.75GB
现在我们看到镜像已成功构建,让我们运行容器并测试它!
运行以下命令来运行容器:
% docker run -p 8000:8000 --rm audiofile:api
Starting API at http://localhost:8000
Press Ctrl-C to stop.
如果 API 启动成功,你会看到前面的输出。我们在主机内的容器中运行了 audiofile API。记住,任何命令都会检查指向在 home
目录下创建的 audiofile
目录的平面文件存储。除非我们提交更改,否则上传、处理并存储在容器内的任何音频文件都不会被保存。由于我们只是在运行集成测试,所以这不会是必要的。
注意
docker run
命令中的 –p
标志允许你指定主机和容器之间的端口映射。语法是 -p host_port:container_port
。这会将主机的端口映射到容器的端口。
在另一个终端中,让我们再次运行集成测试并查看它们通过:
% go test ./cmd -tags "int pro"
ok github.com/marianina8/audiofile/cmd 0.909s
成功!我们现在已经运行了连接到容器内 audiofile API 的集成测试。
编写 CLI Dockerfile
现在,为了在容器中运行 CLI 集成测试,我们将创建一个 cli.Dockerfile
文件。它将包含构建镜像和运行容器以进行集成测试的所有指令:
FROM golang:1.19
# Set the working directory
WORKDIR /audiofile
# Copy the source code
COPY . .
# Download the dependencies
RUN go mod download
# Execute `go test -v ./cmd -tags int pro` when the
# container is running
CMD ["go", "test", "-v", "./cmd", "-tags", "int pro"]
前面的注释解释了每条指令,但让我们分解 Docker 指令:
-
将基础镜像指定为
golang:1.19
并从中拉取。 -
将工作目录设置为
/audiofile
。 -
将所有源代码复制到工作目录中。
-
下载所有 Go 依赖项。
-
执行
go test –v ./cmd -tags
int pro
。
让我们构建这个镜像:
% docker build -f cli.Dockerfile -t audiofile:cli .
然后,在确保 audiofile:api
容器已经运行的情况下,运行 audiofile:cli
容器:
% docker run --rm --network host audiofile:cli
你会看到集成测试运行成功。
注意
docker run
命令中的 --network host
标志用于将容器连接到主机的网络堆栈。这意味着容器将能够访问主机的网络接口、IP 地址和端口。如果容器运行任何服务,请小心安全。
现在,我们已经为 API 和 CLI 创建了两个容器,但与其在两个独立的终端中分别单独运行每个容器,不如使用 docker-compose.yml
文件,通过单个 stop/start
命令启动和停止整个应用程序。
编写 Docker Compose 文件
在 docker-compose.yml
Docker Compose 文件中,我们定义了需要运行的容器,同时指定了我们之前通过 docker run
命令的标志设置的任何参数:
version: '3'
services:
cli:
build:
context: .
dockerfile: cli.Dockerfile
image: audiofile:cli
network_mode: host
depends_on:
- api
api:
build:
context: .
dockerfile: api.Dockerfile
image: audiofile:api
ports:
- "8000:8000"
让我们来解释前面的文件。首先,定义了两个服务:cli
和 api
。在每个服务下面是一组类似的键:
-
build
键用于指定 Dockerfile 的上下文和位置。 -
context
键用于指定查找 Dockerfile 的位置。两者都设置为.
,这告诉 Docker Compose 服务在当前目录中查找。 -
dockerfile
键允许我们指定 Dockerfile 的名称——在本例中,为cli
服务指定cli.Dockerfile
,为api
服务指定api.Dockerfile
。 -
image
键允许我们给镜像命名和打标签。
对于 cli
服务,我们添加了一些额外的键:
-
network_mode
键用于指定服务的网络模式。当它设置为host
时,就像cli
服务一样,这意味着使用主机机器的网络堆栈(就像调用docker run
时使用的–network host
标志)。 -
depends_on
键允许我们指定服务的运行顺序。在这种情况下,api
服务必须首先运行 -
对于
api
服务,还有一个额外的键: -
ports
键用于指定主机机器和容器之间的端口映射。其语法是host_port:container_port
,类似于调用docker run
命令时使用的–p
或--publish
标志。
现在我们已经完成了 Docker Compose 文件,我们只需一个简单的命令 docker-compose up
就可以在容器化环境中运行集成测试:
% docker-compose up
[+] Running 3/2
Network audiofile_default Created 0.1s
Container audiofile-api-1 Created 0.0s
Container audiofile-cli-1 Created 0.0s
Attaching to audiofile-api-1, audiofile-cli-1
audiofile-api-1 | Starting API at http://localhost:8000
audiofile-api-1 | Press Ctrl-C to stop.
audiofile-cli-1 | === RUN TestWorkflow
audiofile-cli-1 | --- PASS: TestWorkflow (1.14s)
…
audiofile-cli-1 | ok github.com/marianina8/audiofile/cmd 1.163s
现在,无论你在哪个平台上运行容器,在容器内运行测试的结果都将保持一致。集成测试提供了更全面的测试,因为它将捕获从命令到 API 到文件系统再到命令的端到端流程中可能存在的错误。因此,我们可以通过确保我们的 CLI 和 API 作为整体更加稳定和可靠来提高我们的信心。在下一节中,我们将讨论如何使用容器分发 CLI 应用程序。
使用容器进行分发
在容器内运行 CLI 而不是直接在主机上运行有许多优势。利用容器使得程序的设置和安装更加容易。如果应用程序需要大量难以安装的依赖项或库,这可能很有帮助。此外,无论用于构建程序的编程语言或工具是什么,采用容器可以提供更可靠和统一的方法进行分发。使用容器作为分发方法可以成为大多数可以在 Linux 环境中运行的应用程序的灵活解决方案,尽管可能存在特定语言的替代方案。最后,对于不熟悉 Go 语言但已在机器上安装了 Docker 工具箱的开发者来说,通过容器进行分发将非常有用。
构建作为可执行程序运行的镜像
要构建一个可以作为可执行程序运行的镜像,我们必须在镜像上创建一个ENTRYPOINT
指令来指定主可执行程序。让我们创建一个新的 Dockerfile,名为dist.Dockerfile
,其中包含以下指令:
FROM golang:1.19
# Set the working directory
WORKDIR /audiofile
# Copy the source code
COPY . .
# Download the dependencies
RUN go mod download
# Expose port 8000
EXPOSE 8000
# Build the audiofile application with the pro tag so all
# features are available
RUN go build -tags "pro" -o audiofile main.go
# Start the audiofile API
ENTRYPOINT ["./audiofile"]
由于这些说明与前面章节中解释的其他 Dockerfile 大致相同,我们不会进行详细说明。需要注意的是ENTRYPOINT
指令,它指定./audiofile
作为主可执行程序。
我们可以使用以下命令构建此镜像:
% docker build -f dist.Dockerfile -t audiofile:dist .
在确认镜像成功构建后,我们现在可以运行容器并将其作为可执行程序进行交互。
将容器作为可执行程序进行交互
要将容器作为可执行程序进行交互,您可以通过在 Docker 中使用ENTRYPOINT
命令配置容器以使用交互式 TTY(终端)。-i
和–t
选项分别代表交互式和TTY,当这两个标志一起使用时,您可以在类似终端的环境中与ENTRYPOINT
命令进行交互。请记住首先启动 API。现在,让我们看看当我们运行audiofile:dist
镜像的容器时它将如何显示:
% docker run --rm --network host -ti audiofile:dist help
A command line interface allows you to interact with the Audiofile service.
Basic commands include: get, list, and upload.
Usage:
audiofile [command]
Available Commands:
...
Use "audiofile [command] --help" for more information about a command.
只需在docker run
命令的末尾键入help
,就会将help
作为输入传递给主可执行程序或ENTRYPOINT
:./audiofile
。正如预期的那样,帮助文本被输出。
docker run
命令使用了一些额外的命令;–network host
标志使用主机的网络堆栈为容器,而–rm
命令告诉 Docker 在容器退出时自动删除它。
您可以通过将help
一词替换为其他命令的名称来运行任何命令。例如,要运行upload
,请运行以下命令:
% docker run --rm --network host -ti audiofile:dist upload –filename audio/algorithms.mp3
你现在可以通过容器与命令行应用程序交互,传递命令,而无需担心它是否会根据主机机器有所不同。如前所述,任何文件系统更改或上传的文件,如前所述的文件,在容器退出时都不会保存。有一种方法可以运行 API,使得本地文件存储映射到容器路径。
将主机机器映射到容器文件路径
如前所述,你可以将主机机器路径映射到 Docker 容器文件路径,以便从容器内部访问主机计算机上的文件。这有助于诸如给容器访问数据卷或应用程序配置文件等情况。
-v
或—volume
选项可以在执行容器时将主机机器路径转换为容器路径。此标志的语法是host path:container path
。例如,docker run -v /app/config:/etc/config imageName:tag
命令将用于将主机机器的/app/config
目录映射到容器的/etc/config
目录。
记住这一点至关重要,即主机路径和容器路径都必须在容器执行之前存在于容器镜像中。如果它不在容器镜像中,你必须在使用容器之前构建容器路径。
如果你深入研究运行在本地主机上的 audiofile API,你会看到平面文件存储被映射到主机home
目录下的/audiofile
文件夹。在我的 macOS 实例中,如果我想在 Docker 容器中运行 audiofile API,但又能从容器内部读取、访问或上传数据到平面文件存储,那么我需要将HOME
目录下的audiofile
目录映射到适当的位置。这个docker run
命令就可以做到:
docker run -p 8000:8000 --rm -v $HOME/audiofile:/root/audiofile audiofile:api
首先运行前面的命令,然后运行 CLI 容器,或者修改docker-compose.yml
文件的 API 服务以包含以下内容:
volumes:
- "${HOME}/audiofile:/root/audiofile"
无论哪种方式,当你运行用于集成测试或作为可执行文件的容器时,你将与容器内映射到/root/audiofile
目录的本地存储进行交互。如果你一直在尝试使用 audiofile CLI 上传目录,那么当你启动容器并运行list
命令时,你会看到现有的元数据而不是返回一个空列表。
将主机路径映射到容器是你在指导用户如何使用 audiofile 应用程序时可以与他们分享的选项。
通过多阶段构建减少图像大小
通过运行docker images
命令,您会看到构建的一些镜像相当大。为了减小这些镜像的大小,您可能需要重写您的 Dockerfile 以使用多阶段构建。多阶段构建是一个将构建过程分割成多个阶段的过程,在这个过程中可以从最终镜像中删除不必要的依赖项、工件和配置。这对于构建大型应用程序的镜像特别有用,因为您可以在部署时间和基础设施成本上节省。
单阶段和多阶段构建之间的一个区别是多阶段构建允许您使用多个FROM
语句,每个语句定义构建过程的新阶段。您可以选择性地复制来自一个阶段或另一个阶段的工件或构建,允许您取所需并丢弃其余部分,本质上允许您删除任何不必要的部分并清理空间。
让我们考虑dist.Dockerfile
文件并重写它。在我们的多阶段构建过程中,让我们定义我们的阶段:
-
阶段 1:构建我们的应用程序
-
阶段 2:复制可执行文件,暴露端口,并创建入口点
首先,我们创建一个新的文件,dist-multistage.Dockerfile
,包含以下指令:
# Stage 1
FROM golang:1.19 AS build
WORKDIR /audiofile
COPY . .
RUN go mod download
RUN go build -tags "pro" -o audiofile main.go
# Stage 2
FROM alpine:latest
COPY --from=build /audiofile/audiofile .
EXPOSE 8000
ENTRYPOINT ["./audiofile"]
在阶段 1中,我们复制所有代码文件,下载所有依赖项,然后构建应用程序——基本上与dist.Dockerfile
中的原始指令相同,但没有EXPOSE
和ENTRYPOINT
指令。需要注意的是,我们已将阶段命名为build
,如下行所示:
FROM golang:1.19 AS build
在阶段 2中,我们仅从build
阶段复制编译的二进制文件,不复制其他任何内容。为此,我们运行以下指令:
COPY --from=build /audiofile/audiofile .
该命令允许我们从上一个阶段,即build
阶段,将文件或目录复制到当前阶段。--from=build
选项指定了要复制文件的阶段名称。/audiofile/audiofile
是build
阶段中文件的路径,命令末尾的.
指定了目标目录,即当前阶段的根目录。
让我们尝试构建它,并将新大小与原始大小进行比较:
REPOSITORY TAG IMAGE ID CREATED SIZE
audiofile dist 1361cbc7be3e 2 minutes ago 1.78GB
audiofile dist-multistage ab5640f99ef2 5 minutes ago 24MB
这是个很大的差异!使用多阶段构建可以帮助您节省部署时间和基础设施成本,所以花时间使用此过程编写 Dockerfile 绝对是值得的。
分发您的 Docker 镜像
有许多方法可以使您的 Docker 镜像对他人可用。Docker Hub,一个公共注册表,您可以在此处发布您的镜像并使其对他人易于访问,是一个流行的替代方案。另一种替代方案是使用GitHub Packages来存储和分发您的 Docker 镜像以及其他类型的包。还有其他基于云的注册表,如Amazon Elastic Container Registry(ECR)、Google Container Registry(GCR)和Azure Container Registry(ACR),它们提供额外的服务,例如图像扫描(例如,用于操作系统漏洞)和签名。
在存储镜像的仓库的 README 文件中提供如何使用你的镜像和运行容器的说明是一个好主意。对使用你的镜像感兴趣的人将能够轻松地获取有关如何检索镜像、使用镜像运行容器以及其他相关信息的说明。
发布 Docker 镜像有多个优点,包括简单分发、版本控制、部署、协作和可扩展性。你的镜像可以迅速且方便地分发给其他人,使得其他人能够轻松地使用和操作你的应用程序。版本控制帮助你跟踪多个版本的镜像,以便在必要时可以回滚到早期版本。易于部署允许你在几乎不需要修改的情况下将应用程序部署到多个环境。通过注册表共享镜像有助于与其他开发者进行项目协作。通过使用相同的镜像创建所需数量的容器,可扩展性变得简单,这使得扩展你的应用程序变得容易。
在本章中,我们将以我们的 audiofile CLI 项目为例,将镜像发布到 Docker Hub。
发布你的 Docker 镜像
要将镜像发布到 Docker Hub,你首先需要在网站上创建一个账户。一旦你有了账户,你可以登录并创建一个新的仓库来存储你的镜像。之后,你可以使用 Docker 命令行工具登录到你的 Docker Hub 账户,用仓库名称标记你的镜像,并将镜像推送到仓库。以下是你将使用的命令示例:
docker login --username=your_username
docker tag your_image your_username/your_repository:your_tag
docker push your_username/your_repository:your_tag
-
让我们用我们的 audiofile API 和 CLI 镜像试一试。首先,我将使用我的用户名和密码登录:
% docker login --username=marianmontagnino Password: Login Succeeded Logging in with your password grants your terminal complete access to your account. For better security, log in with a limited-privilege personal access token. Learn more at https://docs.docker.com/go/access-tokens/
-
接下来,我将标记我的 CLI 镜像:
% docker tag audiofile:dist marianmontagnino/audiofile:latest
-
最后,我将镜像发布到 Docker Hub:
% docker push marianmontagnino/audiofile:latest The push refers to repository [docker.io/marianmontagnino/audiofile] c0f557e70e4f: Pushed 98f8be277d74: Pushed 6c199763ccbe: Pushed 8f2f7ffa843f: Pushed 10bb928a2e24: Pushed f1ce3f3654c3: Mounted from library/golang 3685241d2bbb: Mounted from library/golang dddbac67c6fa: Mounted from library/golang 85f9ebffaf4d: Mounted from library/golang 72235aad06ad: Mounted from library/golang 5d37ad02a8e2: Mounted from library/golang ea8ab45f064e: Mounted from library/golang latest: digest: sha256:b7b3f58da01d360fc1a3f2e2bd617a44d3f7be d6b6625464c9d787b8a71ead2e size: 2851
让我们在 Docker Hub 上确认以确保容器存在:
图 13.3 – Docker Hub 网站截图,显示带有最新标记的 audiofile 镜像
在存储镜像的仓库的 README 文件中包含运行容器的说明是一个好主意。这使得想要使用该镜像的人能够轻松地学习如何拉取镜像并正确运行容器。以下是我们之前上传的 audiofile CLI 镜像的示例说明:
要运行 audiofile CLI 容器,请确保 audiofile API 容器首先运行。然后,运行 docker
命令:
% docker run --rm --network host -ti marianmontagnino/audiofile:latest help
你会看到输出帮助文本。让我们更新 Docker Hub 仓库上的说明。
更新 README 文件
从存储我们镜像的 Docker Hub 仓库(在这个例子中,是 audiofile 仓库),我们可以滚动到页面底部查看一个 README 部分:
图 13.4 – Docker Hub 仓库中 README 部分的截图
点击此处编辑仓库描述。添加我们之前讨论的说明,然后点击更新按钮:
图 13.5 – 更新后的 README 部分的截图
按照这些说明,类似地将音频文件 API 镜像发布到你的 Docker Hub 仓库。现在,这些镜像已存在于 Docker Hub 的公共仓库中,可供分享和分发给其他用户。
依赖于 Docker
用户必须在他们的计算机上安装 Docker,这是利用 Docker 部署 CLI 的一个主要缺点。然而,如果你的程序有复杂的依赖项或设计用于在多个平台上运行,这个 Docker 依赖项可能更容易处理。使用 Docker 可以帮助避免许多库的问题以及与各种系统设置的意外交互。
摘要
在本章中,我们已经进入了容器化的领域,并探讨了利用 Docker 容器为你的应用程序提供众多优势。解释了创建和运行简单 Docker 镜像和容器的基础知识,以及使用我们的 audiofile 应用程序的一些更复杂的实例,该应用程序需要构建多个可以一起组合和运行的容器。
显然,利用 Docker 进行集成测试可以提高你对整个系统的信任度,我们讨论了如何使用 Docker Compose 运行集成测试。
同时,我们也承认了 Docker 的一些缺点,例如维护容器化应用程序的复杂性增加,单个主机上运行多个容器的额外负担,以及 Docker 本身的依赖性。
总体而言,本章已经为你提供了强大的知识,了解何时为命令行应用程序利用 Docker 容器进行测试和分发。现在,你可以确保应用程序在任何主机机器上都能一致运行。然而,是否决定外部依赖和一定程度的复杂性带来的好处超过弊端,这取决于你。
在下一章,第十四章,使用 GoReleaser 将 Go 二进制文件作为 Homebrew 公式发布,我们将把分发提升到新的水平。我们将使你的应用程序在官方 Homebrew 仓库中可用,以进一步增加你应用程序的分发。
问题
-
哪个命令用于从镜像创建并运行一个容器?
-
哪个
docker run
标志用于将主机机器路径附加到容器路径? -
哪个 Docker 命令用于查看所有已创建的容器?
进一步阅读
-
由肖恩·P·凯恩和卡尔·马蒂亚斯所著的Docker:运行起来:在生产中可靠地发送容器
-
由拉法尔·莱斯科所著的使用 Docker 和 Jenkins 进行持续交付:大规模交付软件
-
《Docker 实战》由Jeff Nickoloff和Stephen Kuenzli合著
答案
-
docker
run
命令。 -
-v
或--volume
标志用于在执行期间将主机机器路径附加到容器路径。 -
docker ps
或docker
container ls
。
第十四章:使用 GoReleaser 将你的 Go 二进制文件作为 Homebrew 公式发布
在本章中,我们将探讨 GoReleaser 和 GitHub Actions 以及它们如何协同使用来自动化将 Go 二进制文件作为 Homebrew 公式发布的过程。首先,我们将探讨 GoReleaser,这是一个流行的开源工具,它简化了 Go 二进制文件的创建、测试和分发。我们将探讨其各种配置和选项,以及它是如何与 GitHub Actions 一起工作的。
在此之后,我们将探讨 GitHub Actions,这是一个 CI/CD 平台,允许你自动化软件开发工作流程并与其他工具(如 GoReleaser)集成。我们将探讨如何使用它来确保构建、测试和部署的一致性和可靠性。
在我们掌握这两个工具之后,我们将专注于触发发布、创建 Homebrew tap 以及与 Homebrew 集成以实现简单的安装和测试。Homebrew 是一个流行的 macOS 包管理器,可以用于轻松安装和管理你的 CLI 应用程序。将你的软件发布到 Homebrew 不仅简化了 macOS 用户安装过程,还让你能够接触到更广泛的受众。你可以接触到一群喜欢使用包管理器安装程序的 macOS 开发者和消费者,例如 Homebrew。用户只需一条命令就可以快速找到并安装你的软件,这提高了其可用性和可访问性。这可以帮助你接触到比以往更大的受众,并提高你程序的可见性、使用率和采用率。
到本章结束时,你将牢固掌握如何结合 GoReleaser 和 GitHub Actions 来创建一个自动化和高效发布流程,包括发布到 Homebrew。有了这些知识,你将能够根据你的具体需求定制自己的工作流程。以下将涵盖以下主题:
-
GoReleaser 工作流程
-
触发发布
-
使用 Homebrew 安装和测试
技术要求
对于本章,你需要执行以下操作:
-
你也可以在 GitHub 上找到代码示例,地址为
github.com/PacktPublishing/Building-Modern-CLI-Applications-in-Go/tree/main/Chapter14/audiofile
-
一个 GitHub 账户
-
在
goreleaser.com/install/
安装 GoReleaser 工具
GoReleaser 工作流程
发布软件可能是一个漫长且具有挑战性的过程,尤其是对于有多个依赖项和平台的工程项目。除了节省时间外,自动化发布过程可以降低人为错误的可能性,并确保发布可靠且有效。GoReleaser 是 Go 开发者自动化发布过程的一个流行选择。然而,也有其他选择,如 CircleCI、GitLab CI 和 GitHub Actions,每个都有其特定的优点和功能。在本节中,我们将探讨自动化发布流程的优点,并更详细地查看其中的一些选择,特别是 GoReleaser 和 GitHub Actions。
与其他替代方案相比,GoReleaser 在以下方面脱颖而出:
-
易于使用:设置简单直观,使开发者能够轻松开始使用发布自动化。他们的 CLI 可以快速初始化带有默认配置的仓库,通常可以即插即用。
-
平台支持:支持包括主要操作系统和云服务在内的各种操作系统。
-
每个步骤的定制:程序员可以在发布过程的每个步骤中进行定制,包括构建、测试和发布到各种平台。
-
发布工件:可以生成各种发布工件,包括 Debian 软件包、Docker 镜像和二进制文件。
-
多功能性:与 CI/CD 管道(如 GitHub Actions)结合使用,使开发者能够完全自动化他们的发布流程。
-
开源:程序员可以访问 GoReleaser 项目的源代码,并根据他们的需求对其进行修改。
-
社区支持:GoReleaser 拥有庞大且活跃的用户群,这使得开发者能够轻松地为项目做出贡献并找到他们问题的答案。
虽然使用 GoReleaser 有许多好处,但也有一些原因可能需要考虑不使用 GoReleaser 进行您的项目:
-
依赖 GitHub:如果您更喜欢使用不同的工具或工作流程,这可能不是最佳选择。
-
特定平台要求:虽然 GoReleaser 支持许多流行的操作系统或云服务提供商,但您可能需要一个不受支持的平台。
-
复杂的发布需求:虽然每个步骤都允许定制,但 GoReleaser 可能不足以灵活地满足您特定的复杂程度。
总之,虽然市场上还有其他选择,但请选择适合您特定用例的工具。我们确实认为 GoReleaser 是用于 audiofile CLI 用例的一个很好的工具,所以让我们继续。
定义工作流程
分析了使用 GoReleaser 的优缺点后,让我们首先概述整体流程,然后更详细地探讨每个阶段:
-
配置您的项目以使用 GoReleaser。
-
配置 GitHub Actions。
-
设置您的 GitHub 仓库。
-
为 GitHub Actions 设置您的 GitHub 令牌。
-
标记并推送代码。
可能更直观的是通过视觉来查看,这就是我们试图实现的目标:
图 14.1 – 使用 GitHub Actions 和 GoReleaser 的发布流程
现在我们对工作流程有了大致的了解,让我们更深入地了解每个步骤,我们将探索如何使用 GoReleaser 与 GitHub Actions 结合,并学习如何自动化自己的发布。
配置你的项目以使用 GoReleaser
安装 GoReleaser 工具后,你现在可以初始化你的 CLI 仓库。在这种情况下,我们将通过执行以下命令来初始化 audiofile CLI 项目仓库的根目录:
goreleaser init
你应该注意到已经生成了一个新文件:goreleaser.yml
。在检查文件之前,我们可以通过执行以下命令来运行一个仅限本地的发布,以确认配置没有问题:
goreleaser release --snapshot --clean
命令的输出为你提供了一个关于发布过程中所有步骤的清晰概念。我们将在下一节“触发发布”中详细介绍这些步骤。在输出的最后,你应该看到一个表示发布成功的消息,类似于以下内容:
• release succeeded after 10s
虽然默认配置成功了,但我们需要更深入地查看配置设置,并相应地修改和添加以定制我们的发布过程。首先,让我们一起查看默认的.goreleaser.yaml
文件,并对其进行分解。
全局钩子
在文件的最顶部,我们看到一些默认的全局钩子。before
字段允许你在发布过程开始之前指定要运行的哪些自定义脚本:
before:
hooks:
- go mod tidy
- go generate ./...
在前面的例子中,我们配置了自动化在发布过程之前运行go mod tidy
和go generate ./...
命令。然而,你可能运行执行以下任务的脚本:
-
更新项目中代码的版本号
-
生成变更日志
-
运行自动化测试以确保你的代码按预期工作
-
构建你的项目和创建发布工件
-
将更改推送到你的版本控制系统
你可以从 GoReleaser 中的before
钩子部分调用的脚本可以是用任何语言编写的,只要它们可以从命令行执行。例如,你可能用 Go、Python、Bash 或任何支持 shell 执行的其它语言编写脚本。
构建和环境变量
接下来,我们看到一些默认构建和一些环境变量已设置。builds
字段允许你确定由GOOS
字段定义的操作系统组合、由GOARCH
字段定义的架构以及由GOARM
字段定义的架构模式。它还允许你添加额外的字段,例如env
字段,该字段允许你为构建设置环境变量。还可以定义的其他方面包括二进制文件、标志、钩子和更多:
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
在默认配置中存在的先前的例子中,我们定义了环境变量 CGO_ENABLED
为 0
,然后配置构建过程为 Linux、Windows 和 Darwin 操作系统生成二进制文件。
注意
env
字段可以在全局级别设置,以便在发布过程的各个阶段都可以使用环境变量,或者它可以在构建上下文中指定,例如在先前的例子中。
最终配置需要进行一些额外的修改,例如指定一些额外的架构,amd64
和 arm64
,以及在钩子之前移除 go generate ./...
,这是不必要的。此外,我们还通过设置构建标志为 pro
和 dev
来修改了 builds
字段:
flags:
- -tags=pro dev
虽然您可以在 builds
字段下设置许多其他选项,但我们将不会在本节中介绍它们。我们鼓励您查看 goreleaser.com/customization/builds/
上可用的完整自定义列表。
归档
接下来,我们查看一些默认的 archives
设置。在 GoReleaser 中,有一个 README
文件和一个 LICENSE
文件。目标是把应用程序的关键组件打包到一个文件中,从而使其更容易分发和部署。默认配置将 archives
字段设置为以下内容:
archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
在 archives
字段的先前默认设置中,您可以看到默认的归档格式是 tar.gz
,适用于所有操作系统,除非 GOOS
设置为 Windows。在这种情况下,归档格式是 zip
。文件的名称由一个模板定义。要了解更多关于 GoReleaser 命名模板的信息,请访问 goreleaser.com/customization/templates/
,因为有许多字段可以自定义归档的名称。让我们至少回顾一下 naming_template
字段中使用的键:
-
.ProjectName
– 项目名称。如果未设置,GoReleaser 将使用包含 Go 项目的目录名称。在我们的例子中,它是audiofile
。 -
.Os
–GOOS
的值。 -
.Arch
–GOARCH
的值。 -
.Arm
–GOARM
的值。
现在我们已经了解了这些模板键的含义,让我们假设我们为我们的 audiofile CLI 项目生成一个归档,用于 Linux,架构为 amd64
。归档文件的名称将是 audiofile_Linux_x86x64.tar.gz
。
校验和
GoReleaser 自动创建并包含一个名为 project 1.0.0 checksums.txt
的文件,其中包含发布包。您可以通过 naming_template
生成 checksum
文件的名称。然而,在我们的配置中,checksum
字段的默认值仅仅是 checksums.txt
:
checksum:
name_template: 'checksums.txt'
定义一个 checksum
文件很重要,因为它有助于确保正在分发的数据的完整性。checksum
文件包含一个独一无二的代码,可用于验证下载的文件是否与原始文件相同。如果没有提供 checksum
文件,发布文件在下载过程中可能会被修改或损坏。这可能导致您的应用程序出现不可预测的行为,并为您的用户带来问题。为了避免这种情况,始终在您的发布中提供 checksum
文件,以便每个人都知道他们正在获取您产品的正确版本。
快照
GoReleaser 配置文件中的 snapshot
字段指定发布是“快照”还是稳定发布。快照是软件项目的非生产版本,可供测试和反馈使用。
如果 snapshot
字段设置为 true
,则生成的发布工件将被标记为快照。这意味着版本号将附加 -SNAPSHOT
后缀,并且发布将不会发布到任何远程仓库,例如 GitHub Releases。如果 snapshot
字段设置为 false
或未提供,则发布被视为稳定版本,并正常发布。与之前的两个字段 archives
和 checksum
一样,snapshot
字段也有一个 name_template
可以使用:
snapshot:
name_template: "{{ incpatch .Version }}-next"
如果未设置,则默认版本为 0.0.1
。基于之前的模板,快照的名称将是 0.0.1-next
。incpatch
,根据 GoReleaser 文档,它会增加给定版本的补丁,同时附带说明,如果它不是一个语义版本,则会引发恐慌。major.minor.patch
用于传达软件发布中变化的级别。
更新日志
changelog
字段定义了您项目更新日志文件的路径。更新日志文件包含对软件项目所做的所有更改、改进和错误修复的列表,通常按版本组织。
目标是记录这些更改,以便用户和开发者可以轻松地发现特定版本中的新内容。更新日志还有助于调试和支持,因为它记录了开发过程。让我们看看 changelog
字段的默认配置:
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
在前面的配置块中,我们定义了更新日志生成过程的行为。使用 sort
字段,我们指定了更新日志条目应显示的顺序,在这种情况下,为 asc
,即升序。filters
字段使用 exclude
子字段指定了与要排除的提交匹配的正则表达式列表。要查看 changelog
字段的所有可用选项,请访问 https://goreleaser.com/customization/changelog/
。
因此,现在我们已经完成了对默认 GoReleaser 配置的分析,让我们确定我们想要考虑添加的内容。
发布
GoReleaser 配置中的以下代码块规定,如果 Git 仓库中存在任何更改,它将自动生成一个预发布版本。预发布版本将包含一个带有预发布后缀的版本号,例如 1.0.0-beta.1
:
release:
prerelease: auto
这个自动化过程为开发者提供了一个方便的方式来创建用于测试目的的软件的早期版本。通过利用预发布版本,他们可以快速轻松地收集对最新更改的反馈,并在向公众发布最终版本之前进行任何必要的修改。
通用二进制
想象一下只有一个文件可以在操作系统的多个架构上工作,例如在配备 M1 或 Intel 芯片的 macOS 机器上安装的安装程序。这就是 通用二进制,也称为 胖二进制。您不需要为不同的架构分别拥有单独的二进制文件,而只需一个可以在两者上工作的通用二进制文件。这使得开发者将软件推广到不同的平台变得更加方便,并且用户只需下载一个文件,就可以在他们的系统上运行它,而无需担心兼容性问题:
universal_binaries:
- replace: true
我们通过添加 universal_binaries
字段并将 replace
值设置为 true
来告诉 GoReleaser 使用通用二进制。
Brews
brews
字段允许开发者指定创建和发布 Homebrew 作为其发布过程的一部分的详细信息。让我们看看我们对 audiofile CLI 项目的配置中以下添加的内容:
brews:
-
name: audiofile
homepage: https://github.com/marianina8
tap:
owner: marianina8
name: homebrew-audiofile
commit_author:
name: marianina8
至少让我们定义一下这些字段在 Homebrew 创建和发布过程中的定义。一个 tap 仓库 是一个包含一个或多个公式文件的 GitHub 仓库,这些文件定义了如何在 Homebrew 上安装特定的软件包。请注意,虽然 tap 仓库在配置中定义,但将在 步骤 3,设置您的 GitHub 仓库 中创建:
-
name
– 默认为项目名称,audiofile。 -
homepage
– 您的 CLI 应用程序的首页。默认为空,但将其设置为您的 GitHub 仓库名称。 -
tap
– 定义将公式发布到的 GitHub/GitLab 仓库。owner
字段是仓库的所有者。name
字段是仓库的名称。 -
commit_author
– 这是提交到仓库时显示的 Git 作者。默认为goreleaserbot
,但在我们的情况下,我们将其设置为我们的 GitHub 昵称。
您可以在 goreleaser.com/customization/homebrew/
查看所有可用的 brew
字段自定义选项。
接下来是下一步!
配置 GitHub Actions
在本节中,我们将了解 GitHub Actions 以及它们如何与 GoReleaser 工具集成。首先,GitHub Actions,如您所回忆的那样,是一个 CI/CD 工具,但准备好这个,它还有一个令人难以置信的功能,允许您在发生特定事件时在您的仓库中启动任何您喜欢的代码的执行!您可能已经知道了这一点,但对于那些现在才知道的人来说,新的机会之门正在打开。让我们讨论 GitHub Actions 的主要组件:
-
事件:任何 GitHub 事件,例如推送代码、创建新分支、打开 PR(拉取请求)或评论问题。事件会触发工作流。
-
运行者:运行者是一个在由事件触发时开始执行工作流的进程。运行者与任务之间存在一对一的关系。
-
/.github/workflows
目录。 -
乔布斯:一份工作是一系列任务的集合。一个任务可能是一个脚本或另一个 GitHub 动作。
-
动作:一个动作就是一个任务。一些任务可能执行复杂任务,例如将 Go 包发布到 Homebrew,或者简单的任务,例如设置环境变量。
以下图表可能有助于说明 GitHub Actions 四个主要组件之间的关系:
图 14.2 – 事件、运行者、工作流、任务和动作之间的关系
现在我们已经掌握了 GitHub Actions 的概念,让我们看看我们如何将事件,例如推送标签,触发 GoReleaser 任务,它为我们执行将 Go 包发布到 Homebrew 的复杂任务。首先,我们需要创建配置文件。从仓库的根目录开始,执行以下操作:
-
创建一个名为
.github
的文件夹。 -
在
.github
文件夹内创建一个子文件夹,命名为workflows
。 -
创建一个
release.yml
文件。
GoReleaser 网站在他们的网站上提供了 GitHub Actions 的默认配置,网址为goreleaser.com/ci/actions/
。您可以从他们的网站复制并粘贴以获得良好的起点。我们将进行一些修改,但在我们这样做之前,让我们一起浏览默认配置。让我们从讨论 GitHub Actions release.yml
文件中存在的字段开始。
在
Github Actions 仓库中的on
字段指定了触发工作流的事件。它可以是单个事件或多个事件。让我们浏览一些事件:
Push
:push
字段用于告诉动作触发推送。例如,这可以自定义以指定推送到分支或标签。此字段的语法定义如下:
on.push.<branches|tags|branches-ignore|tags-ignore>.<paths|paths-ignore>
-
使用
branches
过滤器包含特定的分支名称,使用branches-ignore
过滤器排除某些分支名称。记住,不要在同一个工作流事件中使用branches
和branches-ignore
。 -
使用
tags
过滤器来包含特定的标签名称,使用tags-ignore
来排除某些标签名称。再次提醒,不要在同一个工作流程中同时使用tags
和tags-ignore
!显然,如果那样做,工作流程将不会运行。 -
paths
和paths-ignore
字段可以用来指定特定路径内是否有代码变更。这些字段的值可以设置为使用*
和**
通配符字符的 glob 模式。paths
和paths-ignore
过滤器允许您控制哪些路径被包含或排除在路径模式匹配之外。 -
Pull request
:pull_request
字段用于告诉动作触发一个拉取请求。和前面的字段一样,我们可以指定branches
过滤器来包含特定的分支名称或branches-ignore
过滤器来排除分支名称。同样,paths
和paths-ignore
字段也可以设置。branches
和branches-ignore
字段也接受 glob 模式。 -
event_name
:event_name
字段定义了将触发工作流程执行的活动类型。在 GitHub 中,有可以从多个活动触发的事件。包含此字段的全定义事件的语法如下:
on.<事件名>.types
可用的可用事件列表相当长,包括我们之前提到的两个事件,push
和 pull_request
,还包括 check_run
、label
、release
以及更多。
GitHub Actions 可以做很多事情,所以为了查看自定义 on
字段的所有选项,请访问 docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
。
现在我们已经对 GitHub Actions 有了一个很好的理解,让我们看看默认配置并看看它为 on
字段设置了什么:
on:
push:
# run only against tags
tags:
- '*'
完美!这正是我们所需要的。前面的代码块指定了由标签推送触发的工作流程运行。
权限
permissions
字段用于定义 GitHub Actions 工作流程对您 GitHub 仓库内各种资源的访问级别。本质上,它帮助您控制工作流程在仓库内可以做什么和不能做什么。让我们看看 permissions
字段的默认配置:
permissions:
contents: write
# packages: write
# issues: write
最后两行被注释掉了,但我们仍然可以讨论它们。在前面的代码中,指定了三种权限类型:contents
、packages
和 issues
。由于这些权限都设置为 write
,但后两个被注释掉了,因此我们将工作流程权限限制为对仓库的 contents: write
。根据 GoReleaser 的文档,contents:write
权限是上传存档作为 GitHub 发布或发布到 Homebrew 所必需的。
如果您想将 Docker 镜像推送到 GitHub,您需要启用 packages: write
权限。如果您使用里程碑关闭容量,您需要启用 issues: write
权限。
作业
jobs
字段定义了组成您工作流程的各个任务。它基本上是工作流程的蓝图,定义了每个作业以及它们将按何种顺序执行。让我们看看我们配置文件中设置的默认值:
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v3
with:
go-version: '>=1.20.0'
cache: true
- uses: goreleaser/goreleaser-action@v4
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
现在,让我们澄清前面的代码。在 jobs
字段下只定义了一个名为 goreleaser
的作业,该作业按照以下顺序定义了以下步骤:
-
actions/checkout@v3
动作用于从您的 GitHub 仓库检出代码。fetch-depth
参数设置为0
,这确保了从仓库中获取所有分支和标签。 -
git fetch --force --tags
命令,该命令从 Git 仓库获取所有标签。 -
actions/setup-go@v3
动作用于设置 Go 环境。go-version
参数设置为>=1.20.0
,指定此作业所需的 Go 的最低版本。cache
参数设置为true
,告诉 GitHub Actions 缓存 Go 环境,加快此作业后续运行的执行速度。 -
goreleaser/goreleaser-action@v4
动作用于使用 GoReleaser 发布代码。distribution
参数设置为goreleaser
,指定要使用的分发类型。version
参数设置为latest
,指定要使用的 GoReleaser 的最新版本。args
参数设置为release --clean
,指定在执行发布时传递给 GoReleaser 的命令行参数。
我们需要修改默认配置的唯一修改是修改 goreleaser/goreleaser-action
步骤的 with.version
字段。当前默认值设置为 latest
。让我们将其替换为 ${{ env.GITHUB_REF_NAME }}
。环境变量 env.GITHUB_REF_NAME
由 GitHub 自动设置,代表当前 Git 引用的分支或标签名称。
最后的注意事项,在配置文件的底部,设置了环境变量,以便在 goreleaser
运行时使用。必须将 secrets.GITHUB_TOKEN
替换为 secrets.PUBLISHER_TOKEN
。此令牌将在发布到我们的其他仓库,即 Homebrew tap 仓库时使用。我们已经完成了 GitHub Actions 的配置,因此现在我们可以继续下一步。
设置您的 GitHub 仓库
如果您一直在跟随 audiofile CLI 仓库,那么该仓库已经在 GitHub 上存在。然而,如果您正在同时创建自己的 CLI 应用程序,现在就是确保该仓库存在于 GitHub 上的时间。
除了将您的 CLI 应用程序的仓库推送到 GitHub 之外,我们还需要创建在 GoReleaser 配置文件中先前定义的 Homebrew tap 仓库。将 Homebrew homebrew/core
仓库推送到他们的计算机上。
让我们按照创建新的 Homebrew tap 仓库的步骤进行:
-
登录 GitHub
github.com
。 -
从您的 GitHub 仪表板点击新建仓库按钮。
-
输入仓库详细信息。在我们的例子中,输入名称,homebrew-audiofile,这与我们在 GoReleaser 配置中设置的名称相匹配。确保将仓库设置为
Public
。 -
通过点击创建 仓库按钮来创建仓库。
-
将仓库克隆到您的本地机器。
目前没有理由添加任何文件。一旦我们运行发布过程,GoReleaser 工具会将公式推送到这个仓库,但首先,我们需要创建一个令牌来使用。
设置 GitHub Token 以用于操作
为了使 GoReleaser 和 GitHub Actions 工作流程正常工作,我们需要创建一个 GitHub 令牌和操作秘密。
要创建 GitHub 令牌,点击您的用户菜单并选择设置选项:
图 14.3 – 选择设置选项的用户菜单
一旦您进入设置页面,滚动菜单以查看最后一个选项,开发者设置。当您选择开发者设置时,您现在应该能在左侧菜单中看到个人访问令牌选项。
图 14.4 – 带有生成新令牌选项的开发者设置页面
点击生成新令牌按钮。如果您设置了双因素认证,可能需要再次进行身份验证,但之后您应该会被路由到新个人访问令牌(经典)页面。从该页面,按照以下步骤创建您的 GitHub 令牌:
-
为
audiofile
输入一个值,因为这个值将被用于 audiofile CLI 项目。 -
在选择范围部分,选择repo。这将赋予它运行针对您仓库的操作的权限。然后,滚动到页面底部并点击生成 令牌按钮。
图 14.5 – 生成令牌后个人访问令牌页面
-
复制生成的令牌(在先前的屏幕截图中被遮挡)。
-
返回到您的 CLI 仓库;在我们的例子中,我们返回到了
github.com/marianina8/audiofile
。 -
点击设置。
-
从左侧菜单中,点击秘密和变量,这将展开显示更多选项。点击操作选项。
-
点击屏幕右上角的新建仓库秘密。
图 14.6 – 操作秘密和变量页面
-
从模板中的
env.GITHUB_TOKEN
值设置为secrets.PUBLISHER_TOKEN
。将PUBLISHER_TOKEN
值输入到名称字段。 -
将你在步骤 3中复制的秘密粘贴到秘密字段中。
-
点击添加秘密按钮。
-
确认秘密现在存在于你的动作秘密和变量页面。
图 14.7 – 显示新创建的 PUBLISHER_TOKEN 的存储库秘密
现在发布者令牌已经设置好,让我们继续到最后一步。
触发发布
现在,GoReleaser 和 GitHub Actions 的配置文件已经设置好,用于更改存储库的访问权限的发布者令牌也已创建并共享,我们准备通过工作流程的下一步来触发发布:标记并推送代码。在我们这样做之前,让我们回顾一下当你触发 goReleaser 作业时会发生什么:
-
准备:GoReleaser 检查配置文件,验证环境,并设置必要的环境变量
-
构建:构建 Go 可执行文件,并为多个平台(如 Windows、Linux 和 macOS)编译
-
版本控制:根据现有版本和用户的配置生成新的版本号
-
创建发布工件:为每个平台生成发布工件,例如 tarball、deb/rpm 软件包和 zip 文件
-
创建 Git 标签:为发布创建一个新的 Git 标签,用于将来引用发布
-
上传工件:将生成的发布工件上传到指定的位置,例如 GitHub 发布、文件服务器或云存储服务
-
更新 Homebrew 公式:如果你使用 Homebrew,它将更新 Homebrew 公式以反映新发布
-
通知利益相关者:如果设置好,GoReleaser 可以通过电子邮件、Slack 或 webhook 等多种渠道通知利益相关者关于新发布的消息
注意,前面的步骤可能根据 GoReleaser 使用的特定配置和插件而有所不同。继续前进,让我们通过标记的推送来触发它。
标记并推送代码
在这一点上,确保你已经将所有代码更改推送到你的 CLI 项目的远程存储库:
-
使用适当的版本标记你的 CLI。对于我们的 CLI 项目,在 audiofile 存储库中,我们运行以下 Git 命令:
git tag -a v0.1 -m "Initial deploy"
-
现在将标签推送到存储库。这应该会触发 GitHub Actions 的执行:
git push origin v0.1
-
访问 CLI 存储库,你将注意到文件列表顶部出现一个黄色点:
图 14.8 – 显示黄色点的存储库
- 点击黄色点,将出现一个弹出窗口。要查看 GoReleaser 进程的详细信息,请点击详细信息链接:
图 14.9 – goReleaser 进程的详细信息弹出窗口
- 点击 详情 链接将带您到一个页面,您可以观看 GoReleaser 工作流程通过每个任务的进度:
图 14.10 – goreleaser 作业中的任务及其进度列表
- 一旦成功完成,从 CLI 存储库中,点击页面右侧 发布 部分下列出的标签。从那里,您将看到变更日志和 资产 列表:
图 14.11 – 由 goreleaser 作业生成的资产列表
看起来所有构建都已成功生成和归档,并且作为 发布 页面上的资产可用。如果它能成功通过 Homebrew 安装呢?为了最终确认,让我们跳到下一节。
使用 Homebrew 安装和测试
由于 GoReleaser 作业运行成功,我们应该能够使用 Homebrew 安装 CLI 应用程序。让我们首先告诉 Homebrew 使用我们为公式创建的存储库:
brew tap marianina8/audiofile
您应该看到以下输出,这是来自上一个命令的:
==> Tapping marianina8/audiofile
Cloning into '/opt/homebrew/Library/Taps/marianina8/homebrew-audiofile'...
remote: Enumerating objects: 6, done.
remote: Counting objects: 100% (6/6), done.
remote: Compressing objects: 100% (4/4), done.
remote: Total 6 (delta 0), reused 3 (delta 0), pack-reused 0
Receiving objects: 100% (6/6), done.
Tapped 1 formula (13 files, 6.3KB).
如我们所知,添加存储库会将 Homebrew 公式列表扩展。接下来,让我们尝试安装 audiofile CLI:
brew install marianina8/audiofile/audiofile
您应该看到以下输出,这是为应用程序安装生成的:
==> Fetching marianina8/audiofile/audiofile
==> Downloading https://github.com/marianina8/audiofile/releases/download/v0.2/audiofile_Darwin_all.tar.gz
==> Downloading from https://objects.githubusercontent.com/github-production-release-asset-2e65be/483881004/ccc2302f-a4a5-454a
######################################################################## 100.0%
==> Installing audiofile from marianina8/audiofile
/opt/homebrew/Cellar/audiofile/0.2: 4 files, 19.2MB, built in 3 seconds
==> Running `brew cleanup audiofile`...
Disable this behaviour by setting HOMEBREW_NO_INSTALL_CLEANUP.
Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).
现在,为了最后的测试,让我们运行 audiofile
命令并查看输出:
图 14.12 – 由 Homebrew 安装的 audiofile 命令的输出
现在,让我们尝试一些命令;首先,让我们在一个终端窗口中启动 API:
mmontagnino@Marians-MacBook-Pro audiofile % audiofile api
Starting API at http://localhost:8000
Press Ctrl-C to stop.
在另一个终端中,让我们通过调用以下命令来运行播放器:
audiofile player
您应该看到以下内容:
图 14.13 – audiofile 播放器
我们已经能够使用 Homebrew 软件包管理器安装并测试 audiofile,以确认其工作良好。这标志着我们关于使用 GoReleaser 将 Go 二进制文件作为 Homebrew 公式发布的章节结束。虽然 Homebrew 只是一个软件包管理器,但您可以为 GoFish(一个跨平台系统软件包管理器,允许用户在 Linux 和 Windows 上轻松安装应用程序)遵循类似的过程。结合使用,您将能够扩大您的用户群,并使您的用户能够轻松安装和更新您的 CLI 应用程序。
摘要
在本章中,我们更深入地探讨了 GoReleaser 和 GitHub Actions 如何协同工作,使发布 CLI 应用程序变得轻而易举。首先,我们了解了 GoReleaser,这是一个方便的工具,可以轻松构建、测试和部署 Go 二进制包。我们讨论了默认配置文件,并介绍了一些你可以进行简单调整以适应你需求的修改。然后,我们探讨了 GitHub Actions 以及如何将其与 GoReleaser 集成。
到本章结束时,我们已很好地理解了如何使用这些工具创建一个无缝且高效的发布过程,包括在 Homebrew 上发布。通过 Homebrew 发布可以打开接触更多喜欢使用包管理器的用户的可能性。
问题
-
before
钩子在何时运行?有after
钩子吗? -
GitHub 中的
PUBLISHER_TOKEN
令牌用于什么? -
你可以在拉取请求上触发 GitHub Action 工作流程吗?
进一步阅读
-
GoReleaser 文档可以在
goreleaser.com/
找到 -
GitHub Actions 文档可以在
docs.github.com/en/actions
找到 -
Homebrew 文档可以在
docs.brew.sh/
找到
答案
-
before
钩子字段指定了在发布过程之前运行的脚本。是的,尽管本章没有讨论,但还有after
钩子! -
PUBLISHER_TOKEN
GitHub 令牌在release.yml
文件中设置为环境变量,该文件定义了 GitHub Actions 发布工作流程。该令牌在 GitHub 中配置,以允许 GitHub Actions 访问仓库,从而使goreleaser
作业能够将 Homebrew 公式发布到homebrew-audiofile
仓库。 -
是的,在本章描述的许多其他触发器中,拉取请求也可以触发 GitHub Action 工作流程。