MIT-缺失的一学期中文官方讲义-全-
MIT 缺失的一学期中文官方讲义(全)
译者:飞龙
2019
课程概述
动机
麻省理工学院的课程不会详细讲解这些内容。熟练掌握你的工具非常有好处:这将为你节省大量时间(回报时间非常短)。
我们希望教你关于新工具的知识,如何充分利用你的工具,如何定制你的工具,以及如何扩展你的工具。
课程结构
我们有 6 个讲座,涵盖了各种主题。我们在线上有讲义,但课堂上将涵盖大量内容(例如以演示的形式),这些内容可能不在讲义中。我们将录制讲座。
每节课分为两个 50 分钟的讲座,中间有 10 分钟的休息时间。讲座主要是现场演示,随后是动手练习。我们可能在每节课结束时留出一些时间,以办公时间的方式开始练习。
为了充分利用课程,你应该独立完成所有练习。我们将激励你更多地了解你的工具,并展示可能性和详细讲解一些基础知识,但我们无法在有限的时间内教你所有内容。
编辑此页。
根据CC BY-NC-SA许可。
虚拟机和容器
虚拟机
虚拟机是模拟计算机。你可以配置一个带有某些操作系统和配置的客户机虚拟机,并使用它而不会影响你的主机环境。
对于这门课程,你可以使用虚拟机来实验操作系统、软件和配置,而无需承担风险:你不会影响你的主要开发环境。
通常情况下,虚拟机有很多用途。它们通常用于运行仅在特定操作系统上运行的软件(例如,在 Linux 上使用 Windows 虚拟机来运行 Windows 特定的软件)。它们经常用于尝试可能有害的软件。
有用的功能
-
隔离:虚拟机管理程序在隔离客户机与主机方面做得相当不错,因此你可以使用虚拟机以相对安全的方式运行有缺陷或不可信的软件。
-
快照:你可以对你的虚拟机进行“快照”,捕获整个机器状态(磁盘、内存等),对你的机器进行更改,然后恢复到早期状态。这对于测试可能具有破坏性的操作等非常有用。
缺点
虚拟机通常比在裸机上运行要慢,因此它们可能不适合某些应用程序。
设置
-
资源:与主机机器共享;在分配物理资源时要留意这一点。
-
网络:有很多选项,默认 NAT 应该适用于大多数用例。
-
客户机插件:许多虚拟机管理程序可以在客户机中安装软件,以实现与主机系统的更好集成。如果可能的话,你应该使用这个功能。
资源
-
虚拟机管理程序
-
VirtualBox(开源)
-
Virt-manager(开源,管理 KVM 虚拟机和 LXC 容器)
-
VMWare(商业,IS&T 为 MIT 学生提供)
-
如果你已经熟悉流行的虚拟机管理程序/虚拟机,你可能想了解如何从命令行友好的方式来做这件事。一个选项是libvirt工具包,它允许你管理多个不同的虚拟化提供商/虚拟机管理程序。
练习
-
下载并安装虚拟机管理程序。
-
创建一个新的虚拟机并安装一个 Linux 发行版(例如,Debian)。
-
尝试使用快照进行实验。尝试你一直想尝试的事情,比如运行
sudo rm -rf --no-preserve-root /,看看你是否能轻松恢复。 -
了解什么是fork-bomb (
:(){ :|:& };:),并在虚拟机上运行它,以查看资源隔离(CPU、内存等)是否工作。 -
安装客户机插件并尝试不同的窗口模式、文件共享和其他功能。
容器
虚拟机相对较重;如果您想以自动化的方式启动机器,该怎么办?容器就出现了!
-
Amazon Firecracker
-
Docker
-
rkt
-
lxc
容器主要只是各种 Linux 安全特性的组合,如虚拟文件系统、虚拟网络接口、chroots、虚拟内存技巧等,这些特性共同呈现出虚拟化的外观。
容器的安全性或隔离性不如虚拟机,但非常接近,并且正在变得更好。通常性能更高,启动速度更快,但并非总是如此。
性能提升的原因是,与运行整个操作系统副本的虚拟机不同,容器与主机共享 Linux 内核。然而请注意,如果您在 Windows/macOS 上运行 Linux 容器,则需要一个 Linux 虚拟机作为中间层来激活。
Docker 容器和虚拟机的对比。来源:blog.docker.com
当您想在标准化的设置中运行自动化任务时,容器很方便:
-
构建系统
-
开发环境
-
预包装服务器
-
运行不受信任的程序
-
评分学生提交
-
(一些)云计算
-
-
持续集成
-
Travis CI
-
GitHub Actions
-
此外,像 Docker 这样的容器软件也被广泛用作解决 依赖地狱 的解决方案。如果一个机器需要运行许多具有冲突依赖的服务,它们可以使用容器进行隔离。
通常,您会编写一个文件来定义如何构建您的容器。您从一个最小的 基础镜像(如 Alpine Linux)开始,然后运行一系列命令来设置您想要的 环境(安装软件包、复制文件、构建内容、编写配置文件等)。通常,还可以指定任何应可用的外部端口,以及一个 入口点,它指定容器启动时应运行的命令(如评分脚本)。
类似于代码仓库网站(如 GitHub),还有一些容器仓库网站(如 DockerHub),在这些网站上,许多软件服务都有预先构建的镜像,可以轻松部署。
练习
-
选择一种容器软件(Docker、LXC 等)并安装一个简单的 Linux 镜像。尝试通过 SSH 连接到它。
-
搜索并下载一个流行的 Web 服务器(nginx、apache 等)的预构建容器镜像。
根据 CC BY-NC-SA 许可。
Shell 和脚本
壳是计算机的一个高效文本界面。
壳提示符:当你打开终端时看到的。允许你运行程序和命令;常见的有:
-
使用
cd来更改目录 -
使用
ls来列出文件和目录 -
使用
mv和cp移动和复制文件
但壳允许你做更多的事情;你可以调用你电脑上的任何程序,并且存在用于执行几乎所有你想做的事情的命令行工具。而且它们通常比它们的图形界面更高效。我们将在本课程中介绍其中的一些。
壳提供了一种交互式编程语言(“脚本”)。有许多壳:
-
你可能已经使用过
sh或bash。 -
还有一些与语言匹配的壳:
csh。 -
或者“更好的”壳:
fish、zsh、ksh。
在本课程中,我们将重点关注无处不在的 sh 和 bash,但你可以随意尝试其他壳。我喜欢 fish。
Shell 编程是工具箱中一个非常有用的工具。可以直接在提示符下编写程序,或者写入文件。#!/bin/sh + chmod +x 使壳可执行。
使用壳
多次运行命令:
for i in $(seq 1 5); do echo hello; done
有很多要解释:
-
for x in list; do BODY; done-
;终止命令 – 等同于换行符 -
分割
list,将每个分配给x,并运行主体 -
分割是“空白分割”,我们稍后会再谈
-
壳中没有花括号,所以
do+done
-
-
$(seq 1 5)-
使用参数
1和5运行程序seq -
用该程序的输出替换整个
$() -
等价于
for i in 1 2 3 4 5
-
-
echo hello-
壳脚本中的每一件事都是一个命令
-
在这种情况下,运行
echo命令,它使用参数hello打印其参数。 -
所有命令都在
$PATH中搜索(冒号分隔)
-
我们有变量:
for f in $(ls); do echo $f; done
将打印当前目录中的每个文件名。也可以使用 = 设置变量(没有空格!):
foo=bar
echo $foo
还有许多“特殊”变量:
-
$1到$9:脚本的参数 -
$0脚本本身的名称 -
$#参数数量 -
$$当前壳的进程 ID
仅打印目录
for f in $(ls); do if test -d $f; then echo dir $f; fi; done
更多要解释的内容:
-
if CONDITION; then BODY; fi-
CONDITION是一个命令;如果它返回退出状态 0(成功),则运行BODY。 -
也可以连接
else或elif -
再次,没有花括号,所以
then+fi
-
-
test是另一个提供各种检查和比较的程序,如果它们为真则退出状态为 0 ($?)-
man COMMAND是你的朋友:man test -
也可以用
[+]调用:[ -d $f ]- 查看
man test和which "["
- 查看
-
但等等!这是错误的!如果文件名为“我的文档”怎么办?
-
for f in $(ls)展开为for f in My Documents -
首先在
My上进行测试,然后在Documents上 -
不是我们想要的!
-
壳脚本中最大的错误来源
参数分割
Bash 通过空白分割参数;这不一定是你想要的!
-
需要使用引号来处理参数中的空格,
for f in "My Documents"将正确工作 -
在其他地方遇到相同的问题——你看到在哪里了吗?
test -d $f:如果$f包含空格,test将会出错! -
echo碰巧是可以的,因为通过空格分割和连接,但如果文件名包含换行符呢?!它将变成空格! -
引用所有你不希望分割的变量
-
但我们如何修复上面的脚本?你认为
for f in "$(ls)"做了什么?
通配符匹配是答案!
-
bash 知道如何使用模式查找文件:
-
*代表任意字符串 -
?代表任意单个字符 -
{a,b,c}代表这些字符中的任意一个
-
-
for f in *:这个目录下的所有文件 -
当进行通配符匹配时,每个匹配的文件都成为其自己的参数
- 仍然需要在使用时确保引用:
test -d "$f"
- 仍然需要在使用时确保引用:
-
可以创建高级模式:
-
for f in a*:当前目录下以a开头的所有文件 -
for f in foo/*.txt:foo目录下所有的.txt文件 -
for f in foo/*/p??.txt:foo子目录中以p开头的前三个字母的文本文件
-
空格问题并没有到此为止:
-
if [ $foo = "bar" ]; then——看到问题了吗? -
如果
$foo为空怎么办?[的参数是=和bar... -
可以用
[ x$foo = "xbar" ]来解决这个问题,但很糟糕 -
相反,使用
[[:bash 内置的比较器具有特殊的解析功能- 还允许使用
&&代替-a,||代替-o等。
- 还允许使用
可组合性
Shell 之所以强大,部分原因在于其可组合性。可以链式连接多个程序,而不是只有一个程序做所有的事情。
关键字符是|(管道)。
a | b意味着运行a和b,将a的所有输出作为输入传递给b,并打印b的输出
你启动的所有程序(“进程”)都有三个“流”:
-
STDIN:当程序读取输入时,它从这里来 -
STDOUT:当程序打印某些内容时,它将在这里 -
STDERR:程序可以选择使用的第二个输出 -
默认情况下,
STDIN是你的键盘,STDOUT和STDERR都是你的终端。但你可以更改它们!-
a | b使得a的STDOUT成为b的STDIN。 -
还包括:
-
a > foo(a的STDOUT输出到文件foo) -
a 2> foo(a的STDERR输出到文件foo) -
a < foo(a的STDIN从文件foo读取) -
提示:
tail -f会在文件被写入时打印文件
-
-
-
这有什么用?让你可以操作程序的输出!
-
ls | grep foo:包含单词foo的所有文件 -
ps | grep foo:包含单词foo的所有进程 -
journalctl | grep -i intel | tail -n5:包含单词intel(不区分大小写)的最后 5 条系统日志消息 -
who | sendmail -t me@example.com将登录用户列表发送到me@example.com -
这构成了大量数据处理的基础,我们稍后会讨论
-
Bash 还提供了其他一些组合程序的方法。
你可以使用(a; b) | tac来组合命令:先运行a,然后运行b,并将所有输出发送到tac,它以相反的顺序打印其输入。
一个不太为人所知但非常实用的功能是 进程替换。b <(a) 将运行 a,为它的输出流生成一个临时文件名,并将该文件名传递给 b。例如:
diff <(journalctl -b -1 | head -n20) <(journalctl -b -2 | head -n20)
将显示最后启动日志的前 20 行与之前的差异。
作业和进程控制
如果你想在后台运行长期任务怎么办?
-
&后缀在后台运行程序-
它会立即给你返回提示符
-
如果你想同时运行两个程序,例如服务器和客户端,这很方便:
server & client -
注意,运行中的程序仍然将你的终端作为
STDOUT!尝试:server > server.log & client
-
-
使用
jobs查看所有此类进程- 注意它显示“正在运行”
-
使用
fg %JOB将其带到前台(没有参数表示最新) -
如果你想要将当前程序放入后台:
^Z+bg(这里的^Z表示按Ctrl+Z)-
^Z停止当前进程并将其变为“作业” -
bg在后台运行最后一个作业(就像你执行了&一样)
-
-
背景作业仍然与你的当前会话相关联,如果你注销,它们会退出。
disown允许你切断这种连接。或者使用nohup。 -
$!是最后一个后台进程的进程 ID
关于你电脑上运行的其他东西怎么办?
-
ps是你的朋友:列出正在运行的进程-
ps -A: 打印所有用户的进程(也ps ax) -
ps有 很多 参数:参见man ps
-
-
pgrep: 通过搜索查找进程(类似于ps -A | grep)pgrep -af: 搜索并显示带有参数
-
kill: 通过 ID 向进程发送 信号(pkill通过搜索 +-f)-
信号告诉进程“做某事”
-
最常见:
SIGKILL(-9或-KILL): 告诉它立即退出,相当于^\ -
也
SIGTERM(-15或-TERM): 告诉它优雅地退出,相当于^C
-
标志
大多数命令行实用程序使用 标志 来接受参数。标志通常有简短形式(-h)和长形式(--help)。通常运行 CMD -h 或 man CMD 会给出程序接受的标志列表。简短标志通常可以组合使用,运行 rm -r -f 等同于运行 rm -rf 或 rm -fr。一些常见的标志是事实上的标准,你会在许多应用程序中看到它们:
-
-a通常指所有文件(即也包括以点开头的文件) -
-f通常表示强制执行,例如rm -f -
-h显示大多数命令的帮助信息 -
-v通常启用详细输出 -
-V通常打印命令的版本
此外,双横线 -- 在内置命令和许多其他命令中用于表示命令选项的结束,之后只接受位置参数。所以如果你有一个名为 -v 的文件(你可以这样做)并想要使用 grep 搜索它,grep pattern -- -v 会工作,而 grep pattern -v 则不会。实际上,创建此类文件的一种方法是通过 touch -- -v。
练习
-
如果你完全不了解 shell,你可能想阅读更全面的指南,例如 BashGuide。如果你想更深入地了解,The Linux Command Line 是一个很好的资源。
-
PATH, which, type
我们简要讨论了
PATH环境变量用于通过命令行定位你运行的程序。让我们进一步探讨这一点。-
运行
echo $PATH(或echo $PATH | tr -s ':' '\n'以进行美观打印)并检查其内容,列出了哪些位置? -
命令
which在用户 PATH 中定位程序。尝试运行which常见命令,如echo、ls或mv。请注意,which有点局限,因为它不理解 shell 别名。尝试运行type和command -v对这些相同的命令。输出有何不同? -
运行
PATH=并再次尝试运行之前的命令,有些命令可以执行,有些则不行,你能找出原因吗?
-
-
特殊变量
-
变量
~展开成什么?.和..又如何? -
变量
$?的作用是什么? -
变量
$_的作用是什么? -
变量
!!展开成什么?!!*和!l又如何? -
查找这些选项的文档,并熟悉它们。
-
-
xargs
有时候管道并不完全起作用,因为被管道传输的命令没有期望以换行符分隔的格式。例如,
file命令告诉你文件的属性。尝试运行
ls | file和ls | xargs file。xargs在做什么? -
Shebang
当你编写脚本时,你可以通过使用 shebang 行来指定你的 shell 应该使用哪个解释器来解释脚本。创建一个名为
hello的脚本,内容如下,使用chmod +x hello使其可执行。然后执行它./hello。然后删除第一行并再次执行它?shell 是如何使用那第一行的?#! /usr/bin/python print("Hello World!")你经常会看到一些程序使用看起来像
#! usr/bin/env bash的 shebang。这是一个更便携的解决方案,它有其自身的优点和缺点。advantages and disadvantages。env与which有何不同?env使用哪个环境变量来决定运行哪个程序? -
管道、进程替换、子 shell
创建一个名为
slow_seq.sh的脚本,内容如下,并执行chmod +x slow_seq.sh以使其可执行。#! /usr/bin/env bash for i in $(seq 1 10); do echo $i; sleep 1; done管道(和进程替换)与使用子 shell 执行(即
$())的方式有所不同。运行以下命令并观察差异:-
./slow_seq.sh | grep -P "[3-6]" -
grep -P "[3-6]" <(./slow_seq.sh) -
echo $(./slow_seq.sh) | grep -P "[3-6]"
-
-
杂项
-
尝试运行
touch {a,b}{a,b}然后ls,出现了什么? -
有时候你想要保留 STDIN 并将其管道传输到文件中。尝试运行
echo HELLO | tee hello.txt -
尝试运行
cat hello.txt > hello.txt,你预期会发生什么?实际发生了什么? -
运行
echo HELLO > hello.txt然后运行echo WORLD >> hello.txt。hello.txt的内容是什么?>和>>有什么区别? -
运行
printf "\e[38;5;81mfoo\e[0m\n"。输出有什么不同?如果你想知道更多,搜索 ANSI 颜色转义序列。 -
运行
touch a.txt然后运行^txt^log,bash 为你做了什么?同样地,运行fc。它做了什么?
-
-
键盘快捷键
就像任何你经常使用的应用程序一样,熟悉其键盘快捷键是值得的。输入以下快捷键,并尝试找出它们的功能以及它们可能在什么情况下使用起来很方便。对于其中一些,可能更容易在网上搜索它们的功能。(记住
^X表示按Ctrl+X)-
^A,^E -
^R -
^L -
^C,^\和^D -
^U和^Y
-
本文档受 CC BY-NC-SA 许可。
命令行环境
别名与函数
正如您所想象的那样,输入涉及许多标志或详细选项的长命令可能会变得令人厌烦。尽管如此,大多数 shell 都支持别名。例如,bash 中的别名具有以下结构(注意等号=周围没有空格):
alias alias_name="command_to_alias"
别名有许多方便的功能
# Alias can summarize good default flags
alias ll="ls -lh"
# Save a lot of typing for common commands
alias gc="git commit"
# Alias can overwrite existing commands
alias mv="mv -i"
alias mkdir="mkdir -p"
# Alias can be composed
alias la="ls -A"
alias lla="la -l"
# To ignore an alias run it prepended with \
\ls
# Or can be disabled using unalias
unalias la
然而,在许多场景中,别名可能会有限制,尤其是当您尝试编写具有相同参数的链式命令时。存在一个替代方案,即函数,它是别名和自定义 shell 脚本之间的中间点。
这里有一个示例函数,它创建一个目录并将其移动到其中。
mcd () {
mkdir -p $1
cd $1
}
别名和函数默认情况下不会在 shell 会话中持久化。要使别名持久化,您需要将其包含在 shell 启动脚本文件之一中,如.bashrc或.zshrc。我的建议是将它们分别写入一个.alias文件,然后从您的不同 shell 配置文件中source该文件。
Shell 与框架
在 shell 和脚本编写过程中,我们介绍了bash shell,因为它是最普遍的 shell,大多数系统都将其作为默认选项。尽管如此,它并不是唯一的选择。
例如,zsh shell 是bash的超集,并提供了许多开箱即用的方便功能,例如:
-
智能通配符,
** -
内联通配符/通配符扩展
-
拼写纠正
-
更好的标签完成/选择
-
路径扩展(
cd /u/lo/b将扩展为/usr/local/bin)
此外,许多 shell 可以通过框架来改进,一些流行的通用框架如prezto或oh-my-zsh,以及专注于特定功能的较小框架,例如zsh-syntax-highlighting或zsh-history-substring-search。其他 shell 如fish默认包含了许多这些用户友好的功能。其中一些功能包括:
-
右侧提示
-
命令语法高亮
-
历史子串搜索
-
基于 manpage 的标志完成
-
智能自动完成
-
提示主题
使用这些框架时要注意的一点是,如果它们运行的代码没有适当优化或代码量过多,您的 shell 可能会开始变慢。您始终可以对其进行性能分析,并禁用您不常用或不太重视速度的功能。
终端模拟器与多路复用器
除了定制您的 shell 之外,花些时间了解您的终端模拟器及其设置也是值得的。市面上有各种各样的终端模拟器(这里有一个比较)。
由于你可能会在终端中花费数百到数千小时,因此查看其设置是有益的。你可能希望在终端中修改的一些方面包括:
-
字体选择
-
颜色方案
-
键盘快捷键
-
标签/分割支持
-
滚动配置
-
性能(一些较新的终端,如 Alacritty 提供 GPU 加速)
值得一提的是 终端复用器,如 tmux。tmux 允许你分割和标签多个 shell 会话。它还支持附加和分离,这在你在远程服务器上工作并希望保持你的 shell 运行而不必担心终止当前进程(默认情况下,当你注销时,进程会被终止)时是一个非常常见的用例。这样,通过 tmux,你可以跳入和跳出复杂的终端布局。类似于终端仿真器,tmux 通过编辑 ~/.tmux.conf 文件支持大量的自定义。
命令行实用程序
大多数基于 UNIX 的操作系统默认提供的命令行实用程序已经足够完成你通常需要做的 99%的工作。
在接下来的几个小节中,我将介绍用于极其常见的 shell 操作的替代工具,这些工具使用起来更方便。其中一些工具为命令添加了新的改进功能,而其他工具则专注于提供更简单、更直观的界面和更好的默认设置。
fasd 与 cd
即使有了改进的路径展开和制表符自动完成,更改目录也可能变得相当重复。 Fasd(或 autojump)通过跟踪你最近访问过的文件夹和频繁访问的文件夹来解决此问题,并执行模糊匹配。
因此,如果我访问过路径 /home/user/awesome_project/code,运行 z code 将会 cd 到它。如果我有多个名为 code 的文件夹,我可以通过运行 z awe code 来消除歧义,这将是一个更接近的匹配。与 autojump 不同,fasd 还提供了命令,这些命令不是执行 cd,而是展开频繁和/或最近的文件、文件夹或两者。
bat 与 cat
尽管 cat 完美地完成了它的任务,但 bat 通过提供语法高亮、分页、行号和 Git 集成来改进了它。
exa/ranger 与 ls
ls 是一个很好的命令,但一些默认设置可能会令人烦恼,例如显示原始字节数。 exa 提供了更好的默认设置。
如果你需要导航多个文件夹和/或预览多个文件,ranger 由于其出色的界面,可能比 cd 和 cat 更有效率。它相当可定制,并且通过正确的设置,你甚至可以在你的终端中 预览图片。
fd 与 find
fd 是一个简单、快速且用户友好的 find 命令替代品。find 默认需要使用 --name 标志(你 99%的时间都会这样做),这使得它在日常使用中更加方便。它还默认支持 git,会跳过 .gitignore 和 .git 文件夹中的文件。它还默认具有漂亮的颜色编码。
rg/fzf 与 grep
grep 是一个伟大的工具,但如果你想要一次性在多个文件中进行搜索,有更好的工具可以完成这个任务。ack、ag 和 rg 会递归地搜索当前目录中的正则表达式模式,同时尊重你的 gitignore 规则。它们的工作方式非常相似,但我更喜欢 rg,因为它可以快速搜索我的整个家目录。
类似地,你可能会发现自己反复地执行 CMD | grep PATTERN。fzf 是一个命令行模糊查找器,它允许你交互式地过滤几乎任何命令的输出。
rsync 与 cp/scp
虽然 mv 和 scp 在大多数情况下都很完美,但在复制/移动大量文件、大文件或某些数据已经存在于目标位置时,rsync 是一个巨大的改进。rsync 会跳过已经传输的文件,并且使用 --partial 标志可以从之前中断的复制中恢复。
trash 与 rm
rm 是一个危险的命令,一旦你删除了文件,就无法恢复。然而,现代操作系统在文件资源管理器中删除文件时并不像那样操作,它们只是将其移动到回收站,而回收站会定期清理。
由于垃圾回收的管理方式因操作系统而异,因此没有单一的 CLI 工具。在 macOS 中有 trash,在 Linux 中有 trash-cli 等等。
mosh 与 ssh
ssh 是一个非常实用的工具,但如果你有一个慢速连接,延迟可能会变得令人烦恼,如果连接中断,你必须重新连接。mosh 是一个实用的工具,它允许漫游,支持间歇性连接,并提供智能本地回显。
tldr 与 man
你可以使用 man 和 -h/‘–help’ 标志来了解命令的功能和选项。然而,在某些情况下,如果它们很详细,导航这些命令可能会有些令人畏惧。
tldr 命令是一个社区驱动的文档系统,它可以从命令行访问,并提供了几个简单的示例来说明命令的功能和最常见的参数选项。
aunpack 与 tar/unzip/unrar
如 这个 xkcd 所示,记住 tar 的选项可能相当棘手,有时你可能需要完全不同的工具,例如用于 .rar 文件的 unrar。[atool](https://www.nongnu.org/atool/) 软件包提供了 aunpack 命令,该命令将确定正确的选项,并将提取的存档始终放在一个新文件夹中。
练习
-
运行
cat .bash_history | sort | uniq -c | sort -rn | head -n 10(或对于 zsh,使用cat .zhistory | sort | uniq -c | sort -rn | head -n 10)以获取最常用的前 10 个命令,并考虑为它们编写更短的别名。 -
选择一个终端模拟器,并找出如何更改以下属性:
-
字体选择
-
主题颜色方案。标准方案有多少种颜色?为什么?
-
滚动历史记录大小
-
-
安装
fasd或类似软件,并编写一个名为v的 bash/zsh 函数,该函数对传递的参数执行模糊匹配,并在你选择的编辑器中打开顶部结果。然后,修改它以便在存在多个匹配项时,你可以使用fzf选择它们。 -
由于
fzf在执行模糊搜索方面非常方便,而 shell 历史记录很容易进行这类搜索,因此调查如何将fzf绑定到^R。你可以在 这里 找到一些信息。 -
ack中的--bar选项是做什么的?
本作品受 CC BY-NC-SA 许可。
数据整理
你有没有过一大堆文本,想要对它做些什么?很好。这就是数据整理的全部内容!具体来说,是将数据从一种格式转换为另一种格式,直到你得到你想要的确切内容。
我们已经看到了基本的数据整理:journalctl | grep -i intel。
-
查找所有提及 Intel 的系统日志条目(不区分大小写)
-
真的,大部分数据整理都是关于知道你有什么工具,以及如何将它们组合起来。
让我们从一开始:我们需要一个数据源,以及与之相关的事情。日志通常是一个很好的用例,因为你经常想要调查它们,而阅读整个日志是不切实际的。让我们通过查看我的服务器日志来找出谁试图登录我的服务器:
ssh myserver journalctl
这已经太多了。让我们限制为 ssh 相关的内容:
ssh myserver journalctl | grep sshd
注意,我们正在使用管道将远程文件通过本地计算机上的grep进行流式传输!ssh是神奇的。但这仍然比我们想要的要多得多。而且很难读。让我们做得更好:
ssh myserver journalctl | grep sshd | grep "Disconnected from"
这里仍然有很多噪音。有很多方法可以去除这些噪音,但让我们看看你的工具箱中最强大的工具之一:sed。
sed是一个“流编辑器”,它建立在旧的ed编辑器之上。在其中,你基本上给出修改文件的简短命令,而不是直接操作其内容(尽管你也可以这样做)。有大量的命令,但其中最常见的一个是s:替换。例如,我们可以写:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed 's/.*Disconnected from //'
我们刚才写的是一个简单的正则表达式;这是一个强大的结构,允许你将文本与模式进行匹配。s命令的格式如下:s/REGEX/SUBSTITUTION/,其中REGEX是你想要搜索的正则表达式,而SUBSTITUTION是你想要替换匹配文本的文本。
正则表达式
正则表达式足够常见且有用,值得花时间了解它们是如何工作的。让我们先看看我们上面使用的:/.*Disconnected from /。正则表达式通常(尽管不总是)被/包围。大多数 ASCII 字符只是携带它们正常的意义,但一些字符有“特殊”的匹配行为。确切地说,哪些字符做什么在不同的正则表达式实现之间有所不同,这是令人非常沮丧的一个原因。非常常见的模式包括:
-
.表示“任何单个字符”,除了换行符 -
*前一个匹配的零个或多个 -
+前一个匹配的一个或多个 -
[abc]a、b和c中的任意一个字符 -
(RX1|RX2)要么匹配RX1,要么匹配RX2 -
^行的开始 -
$行的结束
sed的正则表达式有些奇怪,你需要在这些字符之前加上\来赋予它们特殊的意义。或者你可以传递-E。
因此,回顾一下 /.*Disconnected from /,我们看到它匹配任何以任意数量的字符开头的文本,后面跟着字面字符串“Disconnected from “。这正是我们想要的。但请注意,正则表达式是有点棘手的。如果有人尝试使用用户名“Disconnected from”登录呢?我们会得到:
Jan 17 03:13:00 thesquareplanet.com sshd[2631]: Disconnected from invalid user Disconnected from 46.97.239.16 port 55920 [preauth]
我们最终会得到什么呢?* 和 + 默认是“贪婪”的。它们会匹配尽可能多的文本。所以,在上面的例子中,我们只会得到
46.97.239.16 port 55920 [preauth]
这可能不是我们想要的。在一些正则表达式实现中,你可以在 * 或 + 后面加上 ? 来使它们非贪婪,但遗憾的是 sed 不支持这一点。不过,我们可以切换到 perl 的命令行模式,它确实支持这种结构:
perl -pe 's/.*?Disconnected from //'
尽管如此,我们仍将坚持使用 sed,因为它是这些类型工作中最常见的工具。sed 还能做其他一些方便的事情,比如打印匹配给定字符串之后的行,每次调用进行多次替换,搜索内容等。但在这里我们不会过多涉及这些。sed 本身就是一个完整的主题,但通常有更好的工具可用。
好吧,所以我们还想去除一个后缀。我们该如何做到这一点呢?匹配用户名之后的文本有点棘手,尤其是如果用户名可以有空白字符等!我们需要匹配的是整行:
| sed -E 's/.*Disconnected from (invalid |authenticating )?user .* [^ ]+ port [0-9]+( \[preauth\])?$//'
让我们看看 regex debugger 中发生了什么。好的,所以开始部分和之前一样。然后,我们匹配任何“用户”变体(日志中有两个前缀)。然后我们匹配用户名所在字符串的任意字符序列。然后我们匹配一个单词([^ ]+;任何非空的非空格字符序列)。然后是单词“port”后面跟着一串数字。然后可能是后缀 [preauth],然后是行尾。
注意,使用这种技术后,用户名“Disconnected from”将不再会让我们困惑。你能看出为什么吗?
然而,这里有一个问题,那就是整个日志会变成空。我们毕竟还是想保留用户名。为此,我们可以使用“捕获组”。任何由正则表达式匹配并被括号包围的文本都会存储在一个编号的捕获组中。这些捕获组在替换(以及在某些引擎中,甚至在模式本身!)中可用,如 \1、\2、\3 等。所以:
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
如你所能想象,你可以想出非常复杂的正则表达式。例如,这里有一篇文章介绍了如何匹配一个 电子邮件地址。这并不容易(链接)。而且有很多讨论。人们还编写了测试。还有测试矩阵。你甚至可以编写一个正则表达式来检测一个给定的数字是否为质数。
正则表达式通常很难正确使用,但它们在你的工具箱中也非常有用!
回到数据处理
好吧,所以我们现在有
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
我们可以用 sed 来做,但为什么要这么做呢?为了好玩。
ssh myserver journalctl
| sed -E
-e '/Disconnected from/!d'
-e 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
这展示了 sed 的一些功能。sed 还可以插入文本(使用 i 命令),显式打印行(使用 p 命令),通过索引选择行,以及许多其他功能。查看 man sed!
无论如何。我们现在得到的是一个所有尝试登录的用户名的列表。但这没什么帮助。让我们寻找常见的:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
sort 命令会,嗯,对它的输入进行排序。uniq -c 会将连续的相同行合并成一行,并在前面加上出现次数的计数。我们可能还想对它们进行排序,并只保留最常见的登录:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| sort -nk1,1 | tail -n10
sort -n 会按数字(而不是字典)顺序排序。-k1,1 表示“只按第一列排序,列由空格分隔”。-n 部分表示“排序直到第 n 个字段,默认是行尾。在这个特定的例子中,按整个行排序并不重要,但我们在这里是为了学习!
如果我们想找最少见的,我们可以用 head 而不是 tail。还有 sort -r,它会按逆序排序。
好吧,这很酷,但我们可能只想提供用户名,也许不是每行一个?
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| sort -nk1,1 | tail -n10
| awk '{print $2}' | paste -sd,
让我们从 paste 命令开始:它允许你通过一个给定的单字符分隔符(-d)来组合行(-s)。但这个 awk 是什么意思呢?
awk – 另一个编辑器
awk 是一种编程语言,它恰好擅长处理文本流。如果你要正确学习 awk,有很多东西可以说,但就像这里的其他许多事情一样,我们只会介绍基础知识。
首先,{print $2} 做了什么?嗯,awk 程序的形式是一个可选的模式加上一个块,该块说明了如果模式与给定的行匹配,应该做什么。默认模式(我们上面使用的)匹配所有行。在块内部,$0 被设置为整行的内容,而 $1 到 $n 被设置为该行的第 n 个字段,当使用 awk 字段分隔符(默认为空白字符)分隔时。在这种情况下,我们说的是对于每一行,打印第二个字段的内容,碰巧这是用户名!
让我们看看我们能否做一些更复杂的事情。让我们计算以 c 开头并以 e 结尾的单次使用用户名的数量:
| awk '$1 == 1 && $2 ~ /^c[^ ]*e$/ { print $2 }' | wc -l
这里有很多东西要解释。首先,注意我们现在有一个模式({...} 之前的东西)。模式说明行的第一个字段应该等于 1(这是 uniq -c 的计数),第二个字段应该匹配给定的正则表达式。而块只是说打印用户名。然后我们使用 wc -l 计算输出中的行数。
然而,awk 是一种编程语言,记得吗?
BEGIN { rows = 0 }
$1 == 1 && $2 ~ /^c[^ ]*e$/ { rows += $1 }
END { print rows }
BEGIN 是一个匹配输入开始的模式(而 END 匹配结束)。现在,按行块只是将第一个字段(在这个例子中始终是 1)的计数添加进去,然后在结束时打印出来。实际上,我们可以完全去掉 grep 和 sed,因为 awk 可以完成所有这些,但我们将其留作读者的练习。
数据分析
你可以做数学!
| paste -sd+ | bc -l
echo "2*($(data | paste -sd+))" | bc -l
你可以通过各种方式获取统计数据。st 非常不错,但如果你已经有了 R:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| awk '{print $1}' | R --slave -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'
R 是另一种(奇怪)的编程语言,擅长数据分析以及绘图。我们不会过多深入细节,但可以说 summary 会打印出矩阵的摘要统计信息,而我们是从数字输入流中计算出一个矩阵,所以 R 给出了我们想要的统计信息!
如果你只想做一些简单的绘图,gnuplot 是你的朋友:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| sort -nk1,1 | tail -n10
| gnuplot -p -e 'set boxwidth 0.5; plot "-" using 1:xtic(2) with boxes'
数据整理以形成论点
有时候你想进行数据整理,以根据更长的列表找到要安装或删除的内容。我们之前讨论的数据整理加上 xargs 可以是一个强大的组合:
rustup toolchain list | grep nightly | grep -vE "nightly-x86|01-17" | sed 's/-x86.*//' | xargs rustup toolchain uninstall
练习
-
如果你不太熟悉正则表达式,这里有一个简短的交互式教程,涵盖了大多数基础知识。
-
sed s/REGEX/SUBSTITUTION/g与常规的sed有什么不同?关于/I或/m又如何? -
要进行原地替换,可能会很诱人地做类似
sed s/REGEX/SUBSTITUTION/ input.txt > input.txt的事情。然而,这是一个坏主意,为什么?这是sed特有的吗? -
使用你熟悉的语言通过正则表达式实现一个简单的 grep 等效工具。如果你想输出像 grep 那样带有颜色高亮,搜索 ANSI 颜色转义序列。
-
有时候,像重命名文件这样的操作使用原始命令如
mv可能会很棘手。rename是一个很棒的工具,可以完成这项任务,并且具有类似sed的语法。尝试创建一些名字中包含空格的文件,并使用rename将它们替换为下划线。 -
寻找在过去三次重启之间不共享的启动消息(参见
journalctl的-b标志)。你可能只想将所有启动日志合并到一个文件中,这样可能会更容易。 -
使用消息的日志时间戳统计系统过去十次启动时间的统计数据
Logs begin at ...和
systemd[577]: Startup finished in ... -
找出包含至少三个
a且不以's结尾的单词(在/usr/share/dict/words中)。这些单词中最常见的最后两个字母是什么?sed的y命令或tr程序可能有助于处理不区分大小写的情况。有多少这样的双字母组合?挑战一下:哪些组合没有出现? -
找一个在线数据集,比如这个或者这个。也许还有这里的另一个。使用
curl获取它,并提取出仅包含两列数值数据。如果你在获取 HTML 数据,pup(https://github.com/EricChiang/pup)可能会有帮助。对于 JSON 数据,尝试使用jq(https://stedolan.github.io/jq/)。使用单个命令找到一列的最小值和最大值,以及两列之间差异的总和。
本作品受CC BY-NC-SA许可。
编辑器
编辑器的重要性
作为程序员,我们大部分时间都在编辑纯文本文件。花时间学习一个适合你需求的编辑器是值得的。
你如何学习一个新的编辑器?你强迫自己使用那个编辑器一段时间,即使它暂时会阻碍你的生产力。很快就会得到回报(两周足够学习基础知识)。
我们将教你使用 Vim,但我们鼓励你尝试其他编辑器。这是一个非常个人化的选择,人们对此有强烈的意见。
我们不能在 50 分钟内教你如何使用一个强大的编辑器,所以我们将专注于教你基础知识,展示一些更高级的功能,并为你提供掌握工具的资源。我们将以 Vim 的上下文来教授课程,但大多数想法将适用于你使用的任何其他强大编辑器(如果它们不适用,那么你可能不应该使用那个编辑器!)。

编辑器学习曲线图是一个神话。学习一个强大编辑器的基础知识相当容易(尽管可能需要数年才能精通)。
今天哪些编辑器受欢迎?参见这个 Stack Overflow 调查(可能存在一些偏见,因为 Stack Overflow 用户可能不能代表所有程序员)。
命令行编辑器
即使你最终决定使用图形用户界面编辑器,学习一个命令行编辑器对于轻松编辑远程机器上的文件也是值得的。
Nano
Nano 是一个简单的命令行编辑器。
-
使用箭头键移动
-
所有其他快捷键(保存、退出)显示在底部
Vim
Vi/Vim 是一个强大的文本编辑器。它是一个通常安装在所有地方的命令行程序,这使得在远程机器上编辑文件变得方便。
Vim 还有一些图形版本,如 GVim 和 MacVim。这些提供了额外的功能,如 24 位颜色、菜单和弹出窗口。
Vim 的哲学
-
当编程时,你大部分时间都在阅读/编辑,而不是编写
- Vim 是一个模式编辑器:插入文本和操作文本有不同的模式
-
Vim 是可编程的(使用 Vimscript 以及其他语言如 Python)
-
Vim 的界面本身就像是一种编程语言
-
按键(带有助记名)是命令
-
命令是可组合的
-
-
不要使用鼠标:太慢了
-
编辑器应该以你认为的速度工作
初识 Vim
模式
Vim 在左下角显示当前模式。
-
正常模式:用于在文件中移动和进行编辑
- 在这里花大部分的时间
-
插入模式:用于插入文本
-
视觉(视觉、行或块)模式:用于选择文本块
你通过按<ESC>从任何模式切换回普通模式来更改模式。从普通模式,使用i进入插入模式,使用v进入视觉模式,使用V进入视觉行模式,使用<C-v>进入视觉块模式。
使用 Vim 时,你会经常使用<ESC>键:考虑将 Caps Lock 映射到 Escape。
基础
Vim ex 命令通过普通模式下的:{命令}发出。
-
:q退出(关闭窗口) -
:w保存 -
:wq保存并退出 -
:e {文件名}打开文件进行编辑 -
:ls显示打开的缓冲区 -
:help {topic}打开帮助-
:help :w打开:w命令的帮助 -
:help w打开w移动的帮助
-
移动
Vim 关于高效移动。在普通模式下导航文件。
-
禁用箭头键以避免养成坏习惯
nnoremap <Left> :echoe "Use h"<CR> nnoremap <Right> :echoe "Use l"<CR> nnoremap <Up> :echoe "Use k"<CR> nnoremap <Down> :echoe "Use j"<CR> -
基本移动:
hjkl(左,下,上,右) -
单词:
w(下一个单词),b(单词开头),e(单词结尾) -
行:
0(行首),^(第一个非空白字符),$(行尾) -
屏幕:
H(屏幕顶部),M(屏幕中间),L(屏幕底部) -
文件:
gg(文件开头),G(文件结尾) -
行号:
:{数字}<CR>或{数字}G(第{数字}行) -
其他:
%(对应项) -
查找:
f{字符},t{字符},F{字符},T{字符}- 在当前行上向前/向后查找
-
重复 N 次:
{number}{movement},例如10j向下移动 10 行 -
搜索:
/{正则表达式},n/N用于导航匹配项
选择
视觉模式:
-
视觉
-
视觉行
-
视觉块
可以使用移动键来选择。
操作文本
你现在用键盘(和强大的可组合命令)来做你以前用鼠标做的所有事情。
-
i进入插入模式- 但对于操纵/删除文本,想要使用比退格键更强大的工具
-
o/O在下方/上方插入行 -
d{移动}删除- 例如
dw是删除单词,d$是删除到行尾,d0是删除到行首
- 例如
-
c{移动}改变-
例如
cw是更改单词 -
例如
d{移动}后跟i
-
-
x删除字符(等同于dl) -
s替换字符(等同于xi) -
视觉模式 + 操作
- 选择文本,
d删除它或c更改它
- 选择文本,
-
u撤销,<C-r>重做 -
还有更多要学习:例如
~可以翻转字符的大小写
资源
-
vimtutor命令行程序,教你使用 vim -
Vim 冒险游戏,学习 Vim
自定义 Vim
Vim 通过~/.vimrc中的纯文本配置文件(包含 Vimscript 命令)进行自定义。可能有很多基本设置你想打开。
查看 GitHub 上的人的 dotfiles 以获取灵感,但尽量不要复制粘贴人们的完整配置。阅读它,理解它,并取你需要的。
一些可以考虑的定制化:
-
语法高亮:
syntax on -
主题
-
行号:
set nu/set rnu -
通过所有内容退格:
set backspace=indent,eol,start
高级 Vim
这里有一些示例来展示编辑器的强大功能。我们无法教你所有这些类型的东西,但你在使用过程中会学到它们。一个好的启发式方法:每当你使用你的编辑器并认为“一定有更好的方法来做这件事”,通常确实有:在网上查找。
搜索和替换
:s (替换) 命令 (文档).
-
%s/foo/bar/g- 在文件中全局替换 foo 为 bar
-
%s/\.*\)/\1/g- 将命名的 Markdown 链接替换为纯 URL
多窗口
-
使用
sp/vsp来分割窗口 -
可以有多个相同缓冲区的视图。
鼠标支持
-
set mouse+=a- 可以点击,滚动选择
宏
-
使用
q{字符}开始在{字符}寄存器中记录宏 -
使用
q停止记录 -
使用
@{字符}重放宏 -
宏执行在错误时停止
-
{数字}@{字符}执行宏 {数字} 次 -
宏可以是递归的
-
首先使用
q{字符}q清除宏 -
记录宏,使用
@{字符}递归调用宏(在记录完成前将不会执行任何操作)
-
-
示例:将 xml 转换为 json (文件)
-
包含键 "name" / "email" 的对象数组
-
使用 Python 程序?
-
使用 sed / 正则表达式
-
g/people/d -
%s/<person>/{/g -
%s/<name>\(.*\)<\/name>/"name": "\1",/g -
…
-
-
Vim 命令 / 宏
-
Gdd,ggdd删除第一行和最后一行 -
格式化单个元素(寄存器
e)的宏-
使用
<name>跳转到行 -
qe^r"f>s": "<ESC>f<C"<ESC>q
-
-
格式化人员的宏
-
使用
<person>跳转到行 -
qpS{<ESC>j@eA,<ESC>j@ejS},<ESC>q
-
-
格式化人员并跳转到下一个人
-
使用
<person>跳转到行 -
qq@pjq
-
-
执行宏直到文件末尾
999@q
-
手动删除最后一个
,并添加[和]分隔符
-
-
扩展 Vim
有许多插件可以扩展 vim。
首先,使用插件管理器如 vim-plug,Vundle,或 pathogen.vim 进行设置。
一些可以考虑的插件:
-
ctrlp.vim: 模糊文件查找器
-
vim-fugitive: git 集成
-
vim-surround: 操作 "surroundings”
-
gundo.vim: 导航撤销树
-
nerdtree: 文件浏览器
-
syntastic: 语法检查
-
vim-easymotion: 魔法移动
-
vim-over: 替换预览
插件列表:
Vim-mode 在其他程序中
对于许多流行的编辑器(例如 vim 和 emacs),许多其他工具支持编辑器模拟。
-
Shell
-
bash:
set -o vi -
zsh:
bindkey -v -
export EDITOR=vim(由git等程序使用的环境变量)
-
-
~/.inputrcset editing-mode vi
甚至有适用于网页浏览器的 vim 键绑定扩展 browsers,一些流行的有 Vimium 用于 Google Chrome 和 Tridactyl 用于 Firefox。
资源
-
Vim Advent Calendar:各种 Vim 技巧
-
Neovim 是一个现代的 vim 重实现,拥有更活跃的开发。
-
Vim Golf:各种 Vim 挑战
练习
-
尝试一些编辑器。至少尝试一个命令行编辑器(例如 Vim)和至少一个图形用户界面编辑器(例如 Atom)。通过像
vimtutor(或其他编辑器的等效教程)这样的教程来学习。为了真正感受一个新的编辑器,承诺在几天内只使用它来完成你的工作。 -
个性化你的编辑器。在网上查找技巧和窍门,并查看其他人的配置(通常,它们都有很好的文档)。
-
尝试为你的编辑器添加插件。
-
至少连续几周坚持使用一个强大的编辑器:那时你应该开始看到它的好处。在某个时候,你应该能够让你的编辑器工作得和你思考一样快。
-
安装一个代码检查器(例如 python 的 pyflakes),将其链接到你的编辑器,并测试它是否工作。
根据CC BY-NC-SA许可。
版本控制
无论你正在处理的是随时间变化的东西,能够跟踪这些变化都是有用的。这可能出于许多原因:它为你记录了发生了什么变化,如何撤销它,谁进行了更改,甚至可能还有为什么。版本控制系统(VCS)提供了这种能力。它们允许你将更改提交到一组文件中,包括描述更改的消息,以及查看和撤销你过去所做的更改。
大多数 VCS 支持在多个用户之间共享提交历史。这允许方便的协作:你可以看到我所作的更改,我也可以看到你所作的更改。由于 VCS 跟踪更改,它通常(尽管不总是)能够找出如何结合我们的更改,只要它们相对不重叠。
有很多 VCS,它们在支持的内容、功能和交互方式上差异很大。这里有很多,我们将重点介绍 git,这是更常用的一种,但我建议你也要看看 Mercurial。
说了这么多——直接进入要点!
Git 是黑暗魔法吗?
还不完全是这样……你需要理解数据模型。我们将跳过一些细节,但大致来说,git 的 核心 “东西”是一个提交。
-
每个提交都有一个唯一的名称,“修订哈希”一个像
998622294a6c520db718867354bf98348ae3c7e2这样长的哈希值,通常缩短为一个短(独特)的前缀:9986222 -
提交包含作者 + 提交信息
-
还包含任何 祖先提交 的哈希值,通常是上一个提交的哈希值
-
提交也代表了一个 diff,即从提交的祖先到提交的表示(例如,删除此文件中的这一行,添加此文件中的这些行,重命名该文件等)。
-
事实上,Git 存储了完整的前后状态
-
可能不想存储经常更改的大文件!
-
初始时,仓库(大致上:git 管理的文件夹)没有内容,也没有提交。让我们设置一下:
$ git init hackers
$ cd hackers
$ git status
这里的输出实际上为我们提供了一个很好的起点。让我们深入挖掘,确保我们完全理解它。
首先,“位于分支 master”。
-
不想总是使用哈希值。
-
分支是指向哈希值的名称。
-
master 是传统上指代“最新”提交的名称。每次创建新的提交时,master 名称都会指向新的提交哈希。
-
特殊名称
HEAD指的是“当前”名称 -
你也可以使用
git branch(或git tag)创建自己的名称,我们稍后会回到这一点
让我们跳过“还没有提交”,因为这没什么可说的。
然后,“没有提交内容”。
-
每个提交都包含一个包含所有你所作更改的 diff。但这个 diff 是如何最初构建的呢?
-
可以总是提交自上次提交以来所做的所有更改
-
有时你只想提交其中的一些(例如,不包括
TODOs) -
有时你希望将一个更改拆分成多个提交,为每个提交提供单独的提交信息
-
-
git 允许你暂存更改以构建一个提交
-
使用
git add将文件或文件的更改添加到暂存更改中-
使用
git add -p仅添加文件中的某些更改 -
没有参数时,
git add对“所有已知文件”操作
-
-
使用
git rm删除文件并将其删除暂存 -
使用
git reset清空暂存更改集-
注意,这不会更改任何文件!它只意味着不会有更改被包含在提交中
-
要移除仅一些暂存更改:
git reset FILE或git reset -p
-
-
使用
git diff --staged检查暂存更改 -
使用
git diff查看剩余的更改 -
当你对暂存满意时,使用
git commit创建提交-
如果你只想提交所有更改:
git commit -a -
git help add有更多有用的信息
-
-
当你在玩弄上述内容时,尝试运行 git status 来查看 git 认为你正在做什么——这出奇地有帮助!
你说的一个提交...
好的,我们有一个提交,现在怎么办?
-
我们可以查看最近的变化:
git log(或git log --oneline) -
我们可以使用
git log -p查看完整更改 -
我们可以使用
git show master显示特定的提交- 或者使用
-p进行完整的 diff/patch
- 或者使用
-
我们可以使用
git checkout NAME回到提交的状态- 如果
NAME是一个提交哈希,git 会说我们处于“分离”状态。这仅仅意味着没有NAME指向这个提交,所以如果我们进行提交,没有人会知道。
- 如果
-
我们可以用
git revert NAME撤销一个更改- 反向应用
NAME提交中的更改
- 反向应用
-
我们可以使用
git diff NAME..将较旧版本与此版本进行比较a..b是一个提交范围。如果任一为空,则表示HEAD。
-
我们可以使用
git log NAME..显示使用之间的所有提交-p在这里也适用
-
我们可以用
git reset NAME将master指向特定的提交(实际上是从那时起撤销所有更改):-
哎,为什么?
reset不是用来更改暂存更改的吗?reset有一个“第二种”形式(见git help reset),它将HEAD设置为给定名称指向的提交。 -
注意,这并没有改变任何文件——
git diff现在实际上显示git diff NAME..。
-
名称的意义是什么?
很明显,在 git 中名称很重要。它们是理解 git 中发生的大部分事情的关键。到目前为止,我们已经讨论了提交哈希、master 和 HEAD。但还有更多!
-
你可以用
git branch b创建自己的分支(如 master)-
创建一个新的名称,
b,它指向HEAD处的提交 -
尽管如此,你仍然“在”master 上,所以如果你创建一个新的提交,master 将指向那个新提交,
b不会。 -
使用
git checkout b切换到分支-
你现在所做的任何提交都将更新
b名称 -
使用
git checkout master切换回 masterb中的所有更改都被隐藏起来
-
这是一个非常方便的方法,可以轻松测试更改
-
-
-
标签是永远不会改变的另一个名称,并且有自己的信息。常用于标记发布和变更日志。
-
NAME^表示“NAME之前的提交”-
可以递归应用:
NAME^^^ -
当你使用
~时,你很可能是指~-
~是“时间性的”,而^通过祖先 -
~~与^^相同 -
使用
~你还可以写X~3表示“比X早 3 个提交” -
你不想要
³
-
-
git diff HEAD^
-
-
-表示“前一个名称” -
除非你给出另一个参数,否则大多数命令都操作于
HEAD。
清理你的混乱
你的提交历史通常会变成:
-
add feature x– 可能甚至有一个关于x的提交信息! -
忘记添加文件 -
fix bug -
typo -
typo2 -
实际上修复 -
实际上实际上修复 -
tests pass -
fix example code -
typo -
x -
x -
x -
x
对于 git 来说,这是可以接受的,但对未来的你或其他好奇变化内容的人并不太有帮助。git 允许你清理这些事情:
-
git commit --amend: 将暂存更改合并到上一个提交- 注意,这会改变之前的提交,给它一个新的哈希值!
-
git rebase -i HEAD~13是神奇的。对于过去 13 次提交中的每一次,选择要执行的操作:-
默认是
pick;不执行任何操作 -
r: 修改提交信息 -
e: 更改提交(添加或删除文件) -
s: 将提交与上一个提交合并并编辑提交信息 -
f: “修复” – 将提交与上一个提交合并;丢弃提交信息 -
最后,
HEAD被设置为指向现在的最后一个提交 -
通常被称为压缩提交
-
它真正做的事情:将
HEAD回滚到变基的起点,然后按照指示的顺序重新应用提交。
-
-
git reset --hard NAME: 将所有文件的状态重置为NAME(如果没有给出名称,则为HEAD)。这对于撤销更改很有用。
与他人一起玩耍
版本控制的一个常见用例是允许多个人对一组文件进行更改,而不会相互干扰。或者更确切地说,确保如果他们相互干扰,他们不会默默地覆盖彼此的更改。
git 是一个分布式版本控制系统:每个人都有一个整个仓库的本地副本(好吧,是其他人选择发布的所有内容)。一些 VCS 是集中式的(例如,subversion):服务器拥有所有提交,客户端只有他们“检出”的文件。基本上,他们只有当前的文件,并且需要向服务器请求其他内容。
每个 git 仓库的副本都可以列为“远程”。你可以使用git clone ADDRESS(而不是git init)复制现有的 git 仓库。这创建了一个名为origin的远程,它指向ADDRESS。你可以使用git fetch REMOTE从远程获取名称和它们指向的提交。远程的所有名称都对你可用,作为REMOTE/NAME,你可以像使用本地名称一样使用它们。
如果你具有对远程仓库的写权限,你可以通过使用 git push 来更改远程仓库中的名称,使其指向你已提交的提交。例如,让我们将远程仓库 origin 中的 master 名称(分支)指向当前 master 分支指向的提交:
-
git push origin master:master -
为了方便起见,你可以将
origin/master设置为当你从当前分支使用-u推送时的默认目标 -
考虑:这会做什么?
git push origin master:HEAD^
通常,你会使用 GitHub、GitLab、BitBucket 或其他类似的服务作为你的远程仓库。就 git 而言,这并没有什么“特殊”之处。它只是名称和提交。如果有人更改了 master 分支并更新 github/master 以指向他们的提交(我们稍后会回到这一点),那么当你执行 git fetch github 时,你将能够使用 git log github/master 看到他们的更改。
与他人协作
到目前为止,分支似乎没什么用:你可以创建它们,在上面做工作,但然后呢?最终,你还是会将 master 指向它们,对吧?
-
如果你在开发一个大型功能时需要修复某些问题怎么办?
-
如果在此期间有人对
master分支进行了更改怎么办?
不可避免地,你将不得不将一个分支的更改与另一个分支的更改合并,无论这些更改是由你还是其他人做出的。git 允许你使用 git merge NAME 来做这件事,不出所料。merge 将:
-
寻找
HEAD和NAME共享的提交祖先的最新点(即它们分叉的地方) -
(尝试)将所有这些更改应用到当前的
HEAD -
生成一个包含所有这些更改的提交,并将
HEAD和NAME列为其祖先 -
将
HEAD设置为该提交的哈希值
一旦你的大型功能完成,你可以将其分支合并到 master 中,git 将确保你不会丢失任何分支上的更改!
如果你以前使用过 git,你可能通过不同的名称认识 merge:pull。当你执行 git pull REMOTE BRANCH 时,这意味着:
-
git fetch REMOTE -
git merge REMOTE/BRANCH -
其中,就像
push一样,REMOTE和BRANCH通常省略并使用“跟踪”远程分支(记得-u吗?)
这通常工作得非常好。只要合并的分支之间的更改是互斥的。如果不是,你就会遇到一个 合并冲突。听起来很可怕...
-
合并冲突只是 git 告诉你它不知道最终的差异应该是什么样子
-
git 暂停并要求你完成“合并提交”的暂存
-
在你的编辑器中打开冲突的文件,并寻找许多角度符号 (
<<<<<<<)。=======之上的内容是自共享祖先提交以来在HEAD中所做的更改。以下是自共享提交以来在NAME中所做的更改。 -
git mergetool非常方便——打开一个差异编辑器 -
一旦你通过确定文件现在应该是什么样子来 解决 冲突,使用
git add将这些更改暂存。 -
当所有冲突都解决后,使用
git commit完成- 你可以使用
git merge --abort放弃合并
- 你可以使用
你刚刚解决了你的第一个 git 合并冲突!\o/ 现在你可以使用 git push 发布你的最终更改
当世界相撞时
当你 push 时,git 会检查如果你更新了你要推送的远程名称,是否没有人丢失了工作。它是通过检查远程名称的当前提交是否是你推送的提交的祖先来做到这一点的。如果是,git 可以安全地只更新名称;这被称为 快速前进。如果不是,git 将拒绝更新远程名称,并告诉你有更改。
如果你的推送被拒绝,你会怎么做?
-
使用
git pull合并远程更改(即,fetch+merge) -
使用
--force强制推送:这将丢失其他人的更改!-
还有
--force-with-lease,这只有在远程名称自上次从该远程获取以来没有更改时才会强制更改。更安全! -
如果你已经将之前推送的本地提交进行了变基(“历史重写”;可能不要这样做),你将不得不强制推送。想想为什么!
-
-
尝试将你的更改“叠加”在远程更改之上
进一步阅读
练习
-
在一个仓库中尝试修改一个现有文件。当你执行
git stash时会发生什么?运行git log --all --oneline你会看到什么?通过运行git stash pop来撤销你用git stash做的操作。在什么情况下这可能会很有用? -
学习 Git 时常见的错误之一是提交不应由 Git 管理的大文件或添加敏感信息。尝试将文件添加到仓库中,进行一些提交,然后从历史记录中删除该文件(你可能需要查看 这个)。另外,如果你希望 Git 为你管理大文件,查看 Git-LFS
-
Git 对于撤销更改非常方便,但一个人甚至需要熟悉最不可能发生的变化。
-
如果某个提交中不小心修改了文件,可以使用
git revert来撤销更改。然而,如果提交涉及多个更改,revert可能不是最佳选择。我们如何使用git checkout从特定提交中恢复文件版本? -
创建一个分支,在该分支中提交并删除它。你还能恢复该提交吗?尝试查看
git reflog。(注意:快速恢复悬空对象,Git 会定期自动清理没有任何引用指向的提交。) -
如果一个人过于冲动地使用
git reset --hard而不是git reset,那么更改可能会轻易丢失。然而,由于更改已经被暂存,我们可以恢复它们。(查看git fsck --lost-found和.git/lost-found)
-
-
在任何 Git 仓库中,查看
.git/hooks文件夹,你会找到一些以.sample结尾的脚本。如果你将它们重命名并去掉.sample,它们将根据其名称运行。例如,pre-commit将在提交之前执行。尝试使用它们进行实验。 -
与许多命令行工具一样,
git提供了一个名为~/.gitconfig的配置文件(或点文件)。使用~/.gitconfig创建一个别名,这样当你运行git graph时,你会得到git log --oneline --decorate --all --graph的输出(这是一个快速可视化提交图的不错命令)。 -
Git 还允许你在
~/.gitignore_global下定义全局忽略模式,这有助于防止常见的错误,例如添加 RSA 密钥。创建一个~/.gitignore_global文件,并添加模式*rsa,然后测试它在仓库中是否有效。 -
一旦你开始更熟悉
git,你会发现自己在遇到常见任务,例如编辑你的.gitignore。git extras 提供了一些小工具,它们可以与git集成。例如,git ignore PATTERN将将指定的模式添加到你的仓库中的.gitignore文件,而git ignore-io LANGUAGE将从 gitignore.io 获取该语言的常见忽略模式。安装git extras并尝试使用一些工具,如git alias或git ignore。 -
Git 图形界面程序有时可以是一个很好的资源。尝试在一个 Git 仓库中运行 gitk,并探索界面的不同部分。然后运行
gitk --all,看看有什么不同? -
一旦你习惯了命令行应用程序,GUI 工具可能会感觉笨重/臃肿。两者之间一个不错的折衷方案是基于 ncurses 的工具,这些工具可以从命令行导航,同时仍然提供交互式界面。Git 有 tig,尝试安装它并在一个仓库中运行它。你可以在这里找到一些使用示例。
本作品受 CC BY-NC-SA 许可。
Dotfiles
许多程序使用名为“dotfiles”的纯文本文件进行配置(因为文件名以.开头,例如 ~/.gitconfig,所以默认情况下它们在目录列表 ls 中是隐藏的)。
你使用的许多工具可能有很多可以微调的设置。通常,工具会使用专门的编程语言进行自定义,例如 Vimscript 用于 Vim 或 shell 自己的语言用于 shell。
自定义和调整你的工具以适应你喜欢的流程会使你更有效率。我们建议你花时间自己定制工具,而不是从 GitHub 克隆别人的 dotfiles。
你可能已经设置了一些 dotfiles。以下是一些可以查找的地方:
-
~/.bashrc -
~/.emacs -
~/.vim -
~/.gitconfig
一些程序并没有直接将文件放在你的主目录下,而是将它们放在 ~/.config 下的一个文件夹中。
Dotfiles 并不局限于命令行应用程序,例如 MPV 视频播放器可以通过编辑 ~/.config/mpv 下的文件进行配置
学习自定义工具
你可以通过阅读在线文档或 man 页面 来了解你的工具设置。另一个很好的方法是搜索关于特定程序的博客文章,作者会告诉你他们偏好的自定义设置。还有另一种了解自定义设置的方法是查看其他人的 dotfiles:你可以在 GitHub 上找到大量的 dotfiles 仓库 — 看看最受欢迎的一个这里(但我们建议你不要盲目地复制配置)。
组织
你应该如何组织你的 dotfiles?它们应该在自己的文件夹中,处于版本控制之下,并使用脚本链接到适当的位置。这有以下好处:
-
易于安装:如果你登录到一台新机器,应用你的自定义设置只需一分钟
-
便携性:你的工具将在任何地方以相同的方式工作
-
同步:你可以在任何地方更新你的 dotfiles 并保持它们同步
-
变更跟踪:你可能会在整个编程生涯中维护你的 dotfiles,对于长期项目来说,版本历史是很有用的
cd ~/src
mkdir dotfiles
cd dotfiles
git init
touch bashrc
# create a bashrc with some settings, e.g.:
# PS1='\w > '
touch install
chmod +x install
# insert the following into the install script:
# #!/usr/bin/env bash
# BASEDIR=$(dirname $0)
# cd $BASEDIR
#
# ln -s ${PWD}/bashrc ~/.bashrc
git add bashrc install git commit -m 'Initial commit'
高级主题
机器特定的自定义
大多数时候,你希望在机器之间保持相同的配置,但有时你可能在特定机器上想要一个小的差异。这里有几种处理这种情况的方法:
每台机器一个分支
使用版本控制来维护每台机器的分支。这种方法在逻辑上很简单,但可能相当重量级。
If 语句
如果配置文件支持,可以使用类似 if 语句的方式来应用机器特定的自定义。例如,你的 shell 可能会有如下内容:
if [[ "$(uname)" == "Linux" ]]; then {do_something else}; fi
# Darwin is the architecture name for macOS systems
if [[ "$(uname)" == "Darwin" ]]; then {do_something}; fi
# You can also make it machine specific
if [[ "$(hostname)" == "myServer" ]]; then {do_something}; fi
包含
如果配置文件支持,请使用包含功能。例如,~/.gitconfig 可以有一个设置:
[include]
path = ~/.gitconfig_local
然后在每台机器上,~/.gitconfig_local 可以包含特定于机器的设置。你甚至可以将这些设置跟踪在一个单独的仓库中。
如果你希望不同的程序共享一些配置,这个想法也很实用。例如,如果你想让 bash 和 zsh 共享同一组别名,你可以在 .aliases 下编写它们,并在两个地方都有以下块。
# Test if ~/.aliases exists and source it
if [ -f ~/.aliases ]; then source ~/.aliases
fi
资源
-
GitHub does dotfiles: dotfile 框架、实用工具、示例和教程
-
Shell 启动脚本:解释用于你的 shell 的不同配置文件
练习
-
为你的 dotfiles 创建一个文件夹并设置 版本控制。
-
至少为一种程序添加配置,例如你的 shell,并进行一些定制(为了开始,这可以是通过设置
$PS1来定制你的 shell 提示符这样简单的事情)。 -
设置一种快速(且无需手动操作)安装你的 dotfiles 到新机器上的方法。这可以是一个简单的 shell 脚本,它为每个文件调用
ln -s,或者你可以使用 专门的实用工具。 -
在一个全新的虚拟机上测试你的安装脚本。
-
将你当前的所有工具配置迁移到你的 dotfiles 仓库中。
-
在 GitHub 上发布你的 dotfiles。
根据 CC BY-NC-SA 许可。
备份
人类有两种类型:
-
那些进行备份的人
-
那些会进行备份的人
你拥有的任何未备份的数据都可能随时永远消失。在这里,我们将介绍一些良好的备份基础和一些方法的陷阱。
3-2-1 规则
3-2-1 规则是备份数据的一般推荐策略。它指出你应该有:
-
至少3 份你的数据副本
-
2 份副本在不同的介质上
-
1 份副本离线存储
这个建议背后的主要思想不是把所有的鸡蛋放在一个篮子里。拥有 2 个不同的设备/磁盘可以确保单个硬件故障不会夺走你所有的数据。同样,如果你只在家里存储唯一的备份,而房子着火了或被盗了,你将失去一切!这就是离线副本的作用。本地备份为你提供了可用性和速度,离线备份则在发生灾难时提供了弹性。
测试你的备份
在执行备份时,一个常见的陷阱是盲目相信系统所说的操作,而不验证数据是否可以正确恢复。Toy Story 2 几乎丢失,他们的备份没有工作,幸运最终救了他们。
版本控制
你应该了解RAID不是备份,并且通常镜像不是备份解决方案。简单地将文件同步到某个地方在几种情况下不会有所帮助,例如:
-
数据损坏
-
恶意软件
-
错误删除文件
如果你的数据变化传播到备份中,那么在这些场景中你将无法恢复。请注意,这适用于许多云存储解决方案,如 Dropbox、Google Drive、One Drive 等。其中一些确实会保留删除的数据一段时间,但通常恢复接口不是你想要用来恢复大量文件的东西。
一个合适的备份系统应该进行版本控制,以防止这种故障模式。通过提供不同时间点的快照,可以轻松导航以恢复丢失的内容。这类最知名的软件是 macOS Time Machine。
数据去重
然而,制作多个数据副本可能会在磁盘空间方面非常昂贵。尽管如此,从一份版本到下一份版本,大多数数据将是相同的,不需要再次传输。这就是数据去重发挥作用的地方,通过跟踪已经存储的内容,可以进行增量备份,只需存储从一份版本到下一份版本的变化。这显著减少了备份所需的空间,除了第一份副本之外。
加密
由于我们可能会备份到不受信任的第三方,如云服务提供商,因此值得考虑的是,如果你备份的数据是原样复制的,那么它可能被不受欢迎的代理查看。像你的税务文件这样的文档是敏感信息,不应以明文格式备份。为了防止这种情况,许多备份解决方案提供了客户端加密功能,即在数据发送到服务器之前对其进行加密。这样,服务器无法读取它存储的数据,但你可以用你的密钥解密它。
作为附带说明,如果你的磁盘(或家庭分区)未加密,那么任何获得你的电脑的人都可以设法绕过用户访问控制并读取你的数据。现代硬件支持加密数据的快速和高效读写,因此你可能想考虑启用全盘加密。
仅追加
到目前为止审查的属性主要集中在硬件故障或用户错误上,但未能解决恶意代理想要删除你的数据时会发生什么。也就是说,如果有人黑入你的系统,他们能否删除你关心的所有数据副本?如果你担心这种情况,那么你需要某种形式的仅追加备份解决方案。通常,这意味着拥有一个服务器,它允许你发送新数据,但拒绝删除现有数据。通常用户有两个密钥,一个仅追加密钥,支持创建新的备份,以及一个完整访问密钥,它还允许删除不再需要的旧备份。后者存储在离线状态。
注意,这是一个相当具有挑战性的场景,因为你需要在防止恶意用户删除你的数据的同时,具备修改数据的能力。现有的商业解决方案包括Tarsnap和Borgbase。
其他考虑因素
你可能还想了解的一些其他事情是:
-
定期备份:过时的备份可能变得相当无用。定期备份应该成为你系统的一个考虑因素。
-
可启动备份:一些程序允许你克隆整个磁盘。这样,你就有了一个包含整个系统副本的镜像,可以直接从它启动。
-
差异备份策略,你可能并不一定对所有数据都关心得一样。你可以为不同类型的数据定义不同的备份策略。
-
仅追加备份,另一个需要考虑的是,为了防止恶意代理在获取你的机器后删除它们,需要强制执行对备份存储库的仅追加操作。
Webservices
您使用的并非所有数据都存储在您的硬盘上。如果您使用 webservices,那么您关心的某些数据,如 Google Docs 演示文稿或 Spotify 播单,可能存储在网上。另一个容易忘记的简单例子是具有网络访问权限的电子邮件账户,如 Gmail。在这些情况下,找出备份解决方案可能有些棘手。然而,有许多服务允许您直接或通过 API 下载您的数据。例如,gmvault 这样的工具可以将电子邮件文件下载到您的计算机上。
网页
类似地,一些高质量的内容可以以网页的形式在网上找到。如果这些内容是静态的,可以通过保存整个网站及其所有附件来轻松备份。另一个选择是 Wayback Machine,这是一个由 Internet Archive 管理的庞大的网络档案馆,该档案馆是一个专注于所有类型媒体保护的非营利组织。Wayback Machine 允许您捕获和存档网页,以便以后检索该网站存档的所有快照。如果您觉得它很有用,请考虑 捐赠 给该项目。
资源
我们使用过并真诚推荐的某些优秀的备份程序和服务:
-
Tarsnap - 为真正偏执的人提供的去重、加密在线备份服务。
-
Borg Backup - 支持压缩和认证加密的去重备份程序。如果您需要云提供商,BorgBase 是一个流行的选择。
-
rsync 是一种提供快速增量文件传输的实用程序。它不是一个完整的备份解决方案。
-
rclone 类似于 rsync,但适用于云存储提供商,如 Amazon S3、Dropbox、Google Drive、rsync.net 等。支持远程文件夹的客户端加密。
练习
-
考虑您是如何(或不)备份您的数据,并查找改进的方法。
-
找出如何备份您的电子邮件账户
-
选择您经常使用的网络服务(如 Spotify、Google Music 等),并找出备份您数据的选择。通常,人们已经基于可用的 API 制作了工具(如 youtube-dl)解决方案。
-
想想您多年来反复访问的网站,并在 archive.org 中查找它,它有多少个版本?
-
实现去重的一种高效方法是使用硬链接。与符号链接(也称为软链接或符号链接)是指向另一个文件或文件夹的文件不同,硬链接是指针的精确副本(它使用相同的 inode 并指向磁盘上的同一位置)。因此,如果删除原始文件,符号链接将停止工作,而硬链接则不会。然而,硬链接仅适用于文件。尝试使用
ln命令创建硬链接,并将它们与使用ln -s创建的符号链接进行比较。(在 macOS 上,您需要安装 gnu coreutils 或 hln 包)。
本文档遵循CC BY-NC-SA许可协议。
Automation
有时您编写了一个执行某些操作的脚本,但您希望它定期运行,比如备份任务。您始终可以编写一个在后台运行并定期在线的临时解决方案。然而,大多数 UNIX 系统都带有 cron 守护进程,它可以基于简单的规则以每分钟一次的频率运行任务。
在大多数 UNIX 系统中,cron 守护进程crond默认会运行,但您始终可以使用ps aux | grep crond来检查。
crontab
cron 的配置文件可以通过运行crontab -l来显示,通过运行crontab -e来编辑。cron 使用的时间格式是五个由空格分隔的字段,以及用户和命令
-
minute - 命令将在小时中的哪一分钟运行,范围是‘0’到‘59’
-
hour - 这控制命令将在哪个小时运行,并使用 24 小时制指定,值必须在 0 到 23 之间(0 是午夜)
-
dom - 这是您想要命令运行的月份中的日期,例如,要在每个月的 19 日运行命令,dom 将是 19。
-
month - 这是指定命令将在哪个月份运行,可以是数字(0-12),也可以是月份的名称(例如五月)
-
dow - 这是您想要命令运行的星期几,它也可以是数字(0-7)或天名的名称(例如 sun)。
-
user - 这是运行命令的用户。
-
command - 这是您想要运行的命令。该字段可以包含多个单词或空格。
注意,使用星号*表示所有,使用星号后跟斜杠和数字表示每第 n 个值。所以*/5表示每五次。一些例子是
*/5 * * * * # Every five minutes
0 * * * * # Every hour at o'clock
0 9 * * * # Every day at 9:00 am
0 9-17 * * * # Every hour between 9:00am and 5:00pm
0 0 * * 5 # Every Friday at 12:00 am
0 0 1 */2 * # Every other month, the first day, 12:00am
您可以在crontab.guru中找到更多常见的 crontab 计划的示例。
Shell 环境与日志记录
使用 cron 时常见的陷阱是它不会加载与常见 shell 相同的相同环境脚本,例如.bashrc、.zshrc等,并且默认情况下不会在任何地方记录输出。结合最大频率为每分钟一次,这可能会在最初调试 cron 脚本时变得相当痛苦。
为了处理环境,确保在所有脚本中使用绝对路径,并修改环境变量,如PATH,以便脚本可以成功运行。为了简化日志记录,一个好的建议是将您的 crontab 写成如下格式
* * * * * user /path/to/cronscripts/every_minute.sh >> /tmp/cron_every_minute.log 2>&1
并且将脚本写入单独的文件。记住,>>将内容追加到文件中,而2>&1将stderr重定向到stdout(尽管您可能希望将它们分开)。
Anacron
使用 cron 的一个注意事项是,如果 cron 脚本应该运行时计算机处于关机或休眠状态,则它不会执行。对于频繁的任务,这可能没问题,但如果任务运行频率较低,您可能希望确保它被执行。anacron的工作方式与cron类似,但频率是以天为单位的。与 cron 不同,它不假设机器持续运行。因此,它可以用在不是每天 24 小时运行的机器上,以控制定期任务,如每日、每周和每月任务。
练习
-
编写一个脚本,每分钟检查您的下载文件夹中是否有任何图片文件(您可以查看MIME 类型或使用正则表达式匹配常见扩展)并将它们移动到您的图片文件夹中。
-
编写一个 cron 脚本,用于每周检查系统中过时的软件包,并提示您更新它们或自动更新。
本文档遵循CC BY-NC-SA许可协议。
硬件自我检查
有时候,计算机会表现异常。而且,你通常想知道为什么会这样。让我们看看一些可以帮助你做到这一点的工具!
但首先,让我们确保你能够进行自我检查。通常,系统自我检查需要你拥有某些权限,比如成为某个组(如 power 用于关机)的成员。root 用户拥有最高权限;他们几乎可以做任何事情。你可以使用 sudo 以 root 用户身份运行命令(但请小心!)。
发生了什么?
如果出现问题,首先要查看事情出错时的情况。为此,我们需要查看日志。
传统上,日志都存储在 /var/log 中,许多系统仍然如此。通常每个程序都有一个文件或文件夹。使用 grep 或 less 来浏览它们。
此外,你还可以使用 dmesg 命令查看内核日志。这曾经是一个纯文本文件,但如今你通常需要通过 dmesg 来访问它。
最后,是“系统日志”,这通常是所有日志消息的去处。在大多数(但不全是)Linux 系统上,该日志由 systemd(“系统守护进程”)管理,它控制所有在后台运行的(以及更多)服务。如果你是 root 用户或 admin 或 wheel 组的成员,可以通过 journalctl 工具访问该日志,尽管使用起来有些不便。
对于 journalctl,你应该特别注意以下标志:
-
-u UNIT: 仅显示与指定 systemd 服务相关的消息 -
--full: 不截断长行(最愚蠢的功能) -
-b: 仅显示最新引导的消息(也见-b -2) -
-n100: 仅显示最后 100 条记录
发生了什么?
如果有错误发生,或者你只是想了解系统中的情况,你可以使用一些工具来检查当前运行的系统:
首先,有 top 和改进版的 htop,它们显示了系统当前运行进程的各种统计信息。CPU 使用率、内存使用率、进程树等。有很多快捷键,但 t 用于启用树视图特别有用。你还可以使用 pstree(+ -p 包括 PIDs)查看进程树。如果你想知道这些程序在做什么,你通常会想查看它们的日志文件。journalctl -f、dmesg -w 和 tail -f 是这里的帮手。
有时候,你可能想了解系统整体使用的资源。dool(github.com/scottchiefbaker/dool)在这方面非常出色。它为你提供了许多不同子系统(如 I/O、网络、CPU 利用率、上下文切换等)的实时资源指标。man dool 是开始的地方。
如果你快用完磁盘空间了,有两个主要的实用工具你可能需要了解:df和du。前者显示了系统上所有分区的状态(尝试使用-h),而后者测量你给出的所有文件夹的大小,包括其内容(也请参阅-h和-s)。
要找出你打开了哪些网络连接,ss是最佳选择。ss -t会显示所有打开的 TCP 连接。ss -tl会显示系统上所有监听(即服务器)端口。-p会包括使用该连接的进程,而-n会给你原始端口号。
系统配置
配置系统有多种方法,但我们将介绍两种非常常见的方法:网络和服务。你系统上的大多数应用程序在其 manpage 中都会告诉你如何配置它们,通常这会涉及到编辑/etc中的文件;系统配置目录。
如果你想要配置你的网络,ip命令可以让你做到这一点。它的参数采用了一种稍微奇怪的形式,但ip help command会带你走得很远。ip addr会显示你的网络接口及其配置信息(IP 地址等),而ip route会显示网络流量是如何路由到不同的网络主机的。网络问题通常可以通过ip工具完全解决。还有iw用于管理无线网络接口。ping是一个检查事物损坏程度的实用工具。尝试 ping 一个主机名(google.com),一个外部 IP 地址(1.1.1.1),以及一个内部 IP 地址(192.168.1.1 或默认网关)。你可能还想调整/etc/resolv.conf来检查你的 DNS 设置(主机名如何解析为 IP 地址)。
要配置服务,现在你几乎不得不与systemd进行交互,不管好坏。你系统上的大多数服务都将有一个 systemd 服务文件,它定义了一个 systemd unit。这些文件定义了在启动该服务时运行什么命令,如何停止它,在哪里记录日志等。它们通常不难阅读,你可以在/usr/lib/systemd/system/中找到大部分。你也可以在/etc/systemd/system中定义自己的。
一旦你有了 systemd 服务的想法,你就使用systemctl命令与之交互。systemctl enable UNIT会将服务设置为启动时启动(disable会再次将其移除),而start、stop和restart会做你期望的事情。如果出了问题,systemd 会通知你,你可以使用journalctl -u UNIT来查看应用程序的日志。你也可以使用systemctl status来查看所有系统服务的情况。如果你的启动感觉很慢,那可能是因为有几个服务运行缓慢,你可以使用systemd-analyze(尝试使用blame)来找出是哪些服务。
本文档遵循CC BY-NC-SA许可协议。
程序内省
调试
当 printf 调试不够用时:使用调试器。
调试器让你与程序的执行进行交互,让你能够做诸如:
-
当程序到达某一行时停止执行
-
单步执行程序
-
检查变量的值
-
许多更高级的功能
GDB/LLDB
让我们看看 example.c。使用调试标志编译:gcc -g -o example example.c。
打开 GDB:
gdb example
一些命令:
-
run -
b {函数名}- 设置断点 -
b {file}:{line}- 设置断点 -
c- 继续 -
step/next/finish- 进入 / 跳过 / 跳出 -
p {变量}- 打印变量的值 -
watch {expression}- 设置一个当表达式值变化时触发的观察点 -
rwatch {expression}- 设置一个当值被读取时触发的观察点 -
layout
PDB
PDB 是 Python 调试器。
在你想进入 PDB 的地方插入import pdb; pdb.set_trace(),基本上是一个调试器(如 GDB)和 Python 壳的混合体。
网络浏览器开发者工具
另一个调试器的例子,这次带有图形界面。
strace
观察程序发出的系统调用:strace {program}。
性能分析
性能分析类型:CPU、内存等。
最简单的性能分析器:time。
Go
使用 CPU 性能分析器运行测试代码:go test -cpuprofile=cpu.out
分析配置文件:go tool pprof -web cpu.out
使用内存性能分析器运行测试代码:go test -memprofile=mem.out
分析配置文件:go tool pprof -web mem.out
Perf
基本性能统计:perf stat {command}
使用性能分析器运行程序:perf record {command}
分析配置文件:perf report
本文档许可协议为CC BY-NC-SA。
软件包管理和依赖关系管理
软件通常建立在(其他)软件之上,这需要依赖关系管理。
软件包/依赖关系管理程序是语言特定的,但许多共享共同的理念。
软件包仓库
软件包托管在软件包仓库中。不同语言有不同的仓库(有时特定语言有多个),例如 PyPI 用于 Python,RubyGems 用于 Ruby,以及 crates.io 用于 Rust。它们通常存储软件(源代码和有时为特定平台预编译的二进制文件)的各个版本。
语义版本控制
软件随着时间的推移而发展,我们需要一种方式来引用软件版本。一些简单的方法可以是按照序列号或提交哈希来引用软件,但我们可以做得更好,以便传达更多信息:使用版本号。
有许多方法;其中一种流行的方法是语义版本控制:
x.y.z
^ ^ ^
| | +- patch
| +--- minor
+----- major
当您进行不兼容的 API 更改时,增加主版本。
当您以向后兼容的方式添加功能时,增加次级版本。
当您进行向后兼容的错误修复时,增加补丁版本。
例如,如果您依赖于某些软件 v1.2.0 中引入的功能,那么您可以安装 v1.x.y 的任何次级版本 x >= 2 和任何补丁版本 y。您需要安装主版本 1(因为 2 可能会引入不兼容的更改),并且您需要安装次级版本 >= 2(因为您依赖于该次级版本中引入的功能)。您可以使用任何更新的次级版本或补丁版本,因为它们不应该引入任何不兼容的更改。
锁文件
除了指定版本之外,强制执行依赖项的内容没有变化,以防止篡改,这也是一件好事。一些工具使用锁文件来指定依赖项的加密哈希(以及版本),这些哈希在安装软件包时进行检查。
指定版本
工具通常允许您以多种方式指定版本,例如:
-
精确版本,例如
2.3.12 -
最小主版本,例如
>= 2 -
特定主版本和最小补丁版本,例如
>= 2.3, <3.0
指定一个精确版本可以避免由于安装的依赖项不同而导致的不同行为(如果所有依赖项都忠实遵循 semver,则不应发生这种情况,但有时人们会犯错误)。指定最小需求的好处是允许安装错误修复(例如,补丁升级)。
依赖关系解析
包管理器使用各种依赖项解析算法来满足依赖项需求。这通常在复杂的依赖项(例如,一个包可以由多个顶级依赖项间接依赖,并且可能需要不同的版本)中具有挑战性。不同的包管理器在依赖项解析方面的复杂性不同,但这是需要注意的:如果您正在调试依赖项,您可能需要了解这一点。
虚拟环境
如果您正在开发多个软件项目,它们可能依赖于特定软件的不同版本。有时,您的构建工具会自然地处理这个问题(例如,通过构建静态二进制文件)。
对于其他构建工具和编程语言,一种处理依赖项的方法是使用虚拟环境(例如,使用 Python 的virtualenv工具)。您不必在系统范围内安装依赖项,而是可以在虚拟环境中为每个项目安装依赖项,并在您在特定项目上工作时激活您想要使用的虚拟环境。
供应商模式
另一种非常不同的依赖项管理方法是供应商模式。您不是使用依赖项管理器或构建工具来获取软件,而是将依赖项的整个源代码复制到您的软件仓库中。这有一个优点,那就是您总是针对依赖项的同一版本进行构建,并且您不需要依赖于包仓库,但升级依赖项需要更多的努力。
本文档遵循CC BY-NC-SA许可协议。
系统定制
您可以对操作系统进行很多定制,而不仅仅是设置菜单中可用的设置。
键盘映射
您的键盘可能有一些您很少使用的键。与其让这些键无用,不如将它们映射到有用的功能。
映射到其他键
最简单的事情是将键映射到其他键。例如,如果您很少使用大写锁定键,那么您可以将其映射到更有用的键。例如,如果您是 Vim 用户,您可能希望将大写锁定键映射到 Esc 键。
在 macOS 上,您可以通过系统偏好设置中的键盘设置进行一些映射;对于更复杂的映射,您需要特殊的软件。
映射到任意命令
您不仅可以将键映射到其他键:还有一些工具可以让您将键(或键的组合)映射到任意命令。例如,您可以将 command-shift-t 映射为打开一个新的终端窗口。
定制隐藏的 OS 设置
macOS
macOS 通过defaults命令公开了大量有用的设置。例如,您可以使隐藏应用的 Dock 图标透明:
defaults write com.apple.dock showhidden -bool true
没有一个包含所有可能设置的单一列表,但您可以在网上找到特定自定义设置的列表,例如 Mathias Bynens 的 .macos.
窗口管理
平铺窗口管理
平铺窗口管理器是窗口管理的一种方法,其中您将窗口组织成非重叠的框架。如果您使用的是基于 Unix 的操作系统,您可以安装平铺窗口管理器;如果您使用的是 Windows 或 macOS 等操作系统,您可以安装允许您近似这种行为的应用程序。
屏幕管理
您可以设置键盘快捷键来帮助您在屏幕间操作窗口。
布局
如果您有特定的屏幕布局方式,而不是手动“执行”该布局,您可以编写脚本,使布局实例化变得简单。
资源
-
Hammerspoon - macOS 桌面自动化
-
矩形窗口管理器 - macOS 窗口管理器
-
Karabiner - 复杂的 macOS 键盘映射
-
r/unixporn - 人们复杂配置的截图和文档
练习
-
找出如何将您的 Caps Lock 键映射到您更常用的键(例如 Esc 或 Ctrl 或 Backspace)。
-
创建一个自定义的全局键盘快捷键来打开一个新的终端窗口或一个新的浏览器窗口。
根据CC BY-NC-SA许可。
远程机器
对于程序员来说,在日常工作中使用远程服务器变得越来越普遍。如果你需要使用远程服务器来部署后端软件或者你需要一台具有更高计算能力的服务器,你最终会使用安全外壳(SSH)。与大多数覆盖的工具一样,SSH 具有高度的可配置性,因此了解它是有价值的。
执行命令
ssh 的一个常被忽视的功能是能够直接运行命令。
-
ssh foobar@server ls将在 foobar 的家目录中执行 ls 命令。 -
它与管道一起工作,所以
ssh foobar@server ls | grep PATTERN将在本地 grepls的远程输出,而ls | ssh foobar@server grep PATTERN将在远程 grepls的本地输出。
SSH 密钥
基于密钥的认证利用公钥加密来向服务器证明客户端拥有秘密私钥,而不泄露密钥。这样你就不需要每次都重新输入密码。然而,私钥(例如 ~/.ssh/id_rsa)实际上就是你的密码,所以要像对待密码一样对待它。
- 密钥生成。要生成一对密钥,你可以简单地运行
ssh-keygen -t rsa -b 4096。如果你没有选择密码短语,那么任何获得你的私钥的人都可以访问授权服务器,因此建议选择一个并使用ssh-agent来管理会话。
如果你已经配置了使用 SSH 密钥推送至 Github,你可能已经完成了这里概述的步骤,并且已经有一对有效的密钥。要检查你是否有一个密码短语并验证它,你可以运行 ssh-keygen -y -f /path/to/key。
- 基于密钥的认证。
ssh将检查.ssh/authorized_keys文件以确定哪些客户端可以进入。要复制公钥,我们可以使用ssh-copy-id。
cat .ssh/id_dsa.pub | ssh foobar@remote 'cat >> ~/.ssh/authorized_keys'
如果可用,可以通过 ssh-copy-id 实现一个更简单的解决方案。
ssh-copy-id -i .ssh/id_dsa.pub foobar@remote
通过 ssh 复制文件
有许多方法可以通过 ssh 复制文件。
-
ssh+tee,最简单的方法是使用ssh命令执行和标准输入,通过执行cat localfile | ssh remote_server tee serverfile。 -
当复制大量文件/目录时,
scp命令比scp更方便,因为它可以轻松地递归地遍历路径。语法是scp path/to/local_file remote_host:path/to/remote_file。 -
rsync通过检测本地和远程中的相同文件并防止再次复制它们来改进scp。它还提供了对符号链接、权限的更精细控制,并具有像--partial标志这样的额外功能,可以从之前中断的复制中恢复。rsync的语法与scp类似。
背景化进程
默认情况下,当中断 ssh 连接时,父 shell 的子进程也会随之终止。有几个替代方案。
-
nohup-nohup工具有效地允许进程在终端被杀死时继续运行。尽管有时可以通过&和disown实现,但nohup是更好的默认选择。更多详细信息可以在 这里 找到。 -
tmux、screen- 而nohup则有效地将进程置于后台,对于交互式 shell 会话来说并不方便。在这种情况下,使用终端多路复用器如screen或tmux是一个方便的选择,因为可以轻松地分离和重新附加相关的 shell。
最后,如果你想要将一个程序从当前终端中分离出来,并重新附加到当前终端,你可以查看 reptyr。使用 reptyr PID 可以获取进程 ID 为 PID 的进程并将其附加到你的当前终端。
端口转发
在许多场景中,你将遇到通过机器上的端口进行监听的软件。当这种情况发生在你的本地机器上时,你可以简单地做 localhost:PORT 或 127.0.0.1:PORT,但对于没有通过网络/互联网直接提供端口的远程服务器,你该怎么办?这被称为端口转发,它有两种类型:本地端口转发和远程端口转发(见图片以获取更多详细信息,图片来自 这个 SO 帖子)。
本地端口转发 
远程端口转发 
最常见的场景是本地端口转发,其中远程机器上的服务在一个端口上监听,而你想要将本地机器上的一个端口链接到远程端口。例如,如果我们远程服务器上执行 jupyter notebook 并监听端口 8888。因此,要将它转发到本地端口 9999,我们将执行 ssh -L 9999:localhost:8888 foobar@remote_server,然后在本地机器上导航到 localhost:9999。
图形转发
有时仅转发端口是不够的,因为我们想在服务器上运行基于 GUI 的程序。你总是可以求助于远程桌面软件,这些软件发送整个桌面环境(例如,RealVNC、Teamviewer 等)。然而,对于单个 GUI 工具,SSH 提供了一个很好的替代方案:图形转发。
使用 -X 标志告诉 SSH 进行转发
对于受信任的 X11 转发,可以使用 -Y 标志。
最后一点是,为了使此功能正常工作,服务器上的 sshd_config 必须具有以下选项
X11Forwarding yes X11DisplayOffset 10
Roaming
连接到远程服务器时常见的痛苦是因关闭/睡眠您的计算机或更改网络而导致的断开连接。此外,如果有一个有显著延迟的连接,使用 ssh 可能会变得相当令人沮丧。Mosh,移动壳,在 ssh 的基础上进行了改进,允许漫游连接、间歇性连接并提供智能本地回显。
Mosh 存在于所有常见的发行版和软件包管理器中。Mosh 需要服务器上的 SSH 服务器正在运行。您不需要超级用户权限来安装 mosh,但它确实需要服务器上打开端口 60000 到 60010(它们通常都是打开的,因为它们不在特权范围内)。
mosh 的一个缺点是它不支持漫游端口/图形转发,所以如果您经常使用这些功能,mosh 就不会很有帮助。
SSH 配置
客户端
我们已经介绍了许多我们可以传递的参数。一个诱人的替代方案是创建类似于 alias my_server="ssh -X -i ~/.id_rsa -L 9999:localhost:8888 foobar@remote_server" 的 shell 别名,然而有一个更好的替代方案,使用 ~/.ssh/config。
Host vm
User foobar
HostName 172.16.174.141
Port 22
IdentityFile ~/.ssh/id_rsa
RemoteForward 9999 localhost:8888
# Configs can also take wildcards
Host *.mit.edu
User foobaz
使用 ~/.ssh/config 文件而不是别名的另一个优点是,其他程序如 scp、rsync、mosh 等也能够读取它并将设置转换为相应的标志。
注意,~/.ssh/config 文件可以被视为点文件,通常情况下,将其包含在您的其他点文件中是正常的。但是,如果您将其公开,请考虑您可能提供给互联网陌生人的信息:您服务器的地址、您使用的用户、开放的端口等。这可能有助于某些类型的攻击,因此在分享您的 SSH 配置时要三思。
警告:永远不要将您的 RSA 密钥 (~/.ssh/id_rsa*) 包含在公共存储库中!
服务器端
服务器端配置通常在 /etc/ssh/sshd_config 中指定。在这里,您可以进行更改,例如禁用密码认证、更改 ssh 端口、启用 X11 转发等。您可以根据用户指定配置设置。
远程文件系统
有时挂载远程文件夹很方便。sshfs 可以在本地挂载远程服务器上的文件夹,然后您可以使用本地编辑器。
练习
-
要使 SSH 的工作,主机需要运行 SSH 服务器。在虚拟机中安装 SSH 服务器(如 OpenSSH),以便您可以完成其余的练习。要找出机器的 IP 地址,运行命令
ip addr并查找 inet 字段(忽略127.0.0.1条目,它对应于环回接口)。 -
前往
~/.ssh/并检查您是否在那里有一对 SSH 密钥。如果没有,使用ssh-keygen -t rsa -b 4096生成它们。建议您使用密码并使用ssh-agent,更多信息这里。 -
使用
ssh-copy-id将密钥复制到你的虚拟机。测试你是否可以无密码 ssh。然后,编辑服务器上的sshd_config以通过编辑PasswordAuthentication的值来禁用密码认证。通过编辑PermitRootLogin的值来禁用 root 登录。 -
在服务器上编辑
sshd_config以更改 ssh 端口,并检查你是否仍然可以 ssh。如果你有一个面向公众的服务器,使用非默认端口和密钥登录将显著减少恶意攻击。 -
在你的服务器/虚拟机上安装 mosh,建立连接然后断开服务器/虚拟机的网络适配器。mosh 能否正确恢复?
-
本地端口转发的另一个用途是将某些主机隧道到服务器。如果你的网络过滤某些网站,例如
reddit.com,你可以通过以下方式将其隧道通过服务器:-
运行
ssh remote_server -L 80:reddit.com:80 -
在
/etc/hosts中将reddit.com和www.reddit.com设置为127.0.0.1 -
检查你是否通过服务器访问该网站
-
如果不明显,可以使用像 ipinfo.io 这样的网站,它会根据你的主机公网 IP 进行变化。
-
-
背景端口转发可以通过添加几个额外的标志轻松实现。研究
ssh中的-N和-f标志的作用,并找出像这样ssh -N -f -L 9999:localhost:8888 foobar@remote_server的命令做什么。
参考文献
根据 CC BY-NC-SA 许可。
网络和浏览器
除了终端之外,网络浏览器是您会发现自己投入大量时间的工具。因此,学习如何高效地使用它是非常有价值的。
快捷键
在浏览器中四处点击通常不是最快的选择,熟悉常见的快捷键在长期来看会非常有帮助。
-
在链接上点击鼠标中键将其在新标签页中打开
-
Ctrl+T打开新标签页 -
Ctrl+Shift+T重新打开最近关闭的标签页 -
Ctrl+L选择搜索栏的内容 -
Ctrl+F在网页内搜索。如果您经常这样做,您可能从支持搜索正则表达式的扩展程序中受益。
搜索运算符
网络搜索引擎,如 Google 或 DuckDuckGo,提供搜索运算符以实现更复杂的网络搜索:
-
"bar foo"强制 bar foo 的精确匹配 -
foo site:bar.com在 bar.com 内搜索 foo -
foo -bar排除包含 bar 的搜索项 -
foobar filetype:pdf搜索该扩展名的文件 -
(foo|bar)搜索包含 foo 或 bar 的匹配项
对于像 Google 和 DuckDuckGo 这样的流行搜索引擎,还有更多通过列表可用。
搜索栏
搜索栏也是一个强大的工具。大多数浏览器可以从网站推断出搜索引擎并将它们存储起来。通过编辑关键字参数
-
在 Google Chrome 中,它们位于 chrome://settings/searchEngines
-
在 Firefox 中,它们位于 about:preferences#search
例如,您可以设置 y SOME SEARCH TERMS 以直接在 YouTube 中搜索。
此外,如果您拥有一个域名,您可以使用您的注册商设置子域名转发。例如,我已经将 https://ht.josejg.com 映射到这个课程网站。这样,我只需输入 ht.,搜索栏就会自动完成。这个设置的另一个优点是,与书签不同,它们在所有浏览器中都能工作。
隐私扩展
现在,由于广告和追踪器的侵扰,上网可能会变得相当烦恼。此外,一个好的广告拦截器不仅能拦截大部分广告内容,还能拦截那些可疑和恶意网站,因为它们通常会被包含在常见的黑名单中。它们有时还能通过减少请求的数量来减少页面加载时间。以下是一些建议:
-
uBlock origin (Chrome, Firefox): 根据预定义的规则拦截广告和追踪器。您还应该考虑查看设置中的启用黑名单,因为您可以根据您的地区或浏览习惯启用更多黑名单。您甚至可以从网络上的资源安装过滤器。
-
Privacy Badger:自动检测并阻止跟踪器。例如,当你从一个网站跳转到另一个网站时,广告公司会跟踪你访问了哪些网站,并为你建立一个档案。
-
HTTPS everywhere 是一个很棒的扩展,如果可用,它会自动将网站重定向到 HTTPS 版本。
你可以在 这里 找到更多这类插件。
样式定制
网络浏览器只是运行在你机器上的另一款软件,因此你通常有权决定它们应该显示什么或如何表现。一个例子就是自定义样式。浏览器通过层叠样式表(通常缩写为 CSS)来决定如何渲染网页样式。
你可以通过检查网站并临时更改其内容和样式来访问网站的源代码(这也是为什么你不应该信任网页截图的原因)。
如果你想要永久性地告诉你的浏览器覆盖网页的样式设置,你需要使用一个扩展。我们的推荐是 Stylus (Firefox, Chrome)。
例如,我们可以为班级网站编写以下样式
body {
background-color: #2d2d2d;
color: #eee;
font-family: Fira Code;
font-size: 16pt;
}
a:link {
text-decoration: none;
color: #0a0;
}
此外,Stylus 可以找到其他用户编写并发布在 userstyles.org 上的样式。大多数常见网站都有一套或几套暗色主题样式表,例如。顺便说一句,你不应该使用 Stylish,因为它已被证明会泄露用户数据,更多信息
功能定制
就像你可以修改样式一样,你也可以通过编写自定义 JavaScript 并使用像 Tampermonkey 这样的网络浏览器扩展来修改网站的行为。
例如,以下脚本通过使用 J 和 K 键启用类似 vim 的导航。
// ==UserScript==
// @name VIM HT
// @namespace http://tampermonkey.net/
// @version 0.1
// @description Vim JK for our website
// @author You
// @match https://hacker-tools.github.io/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
window.onkeyup = function(e) {
var key = e.keyCode ? e.keyCode : e.which;
if (key == 74) { // J is key 74
window.scrollBy(0,500);;
}else if (key == 75) { // K is key 75
window.scrollBy(0,-500);;
}
}
})();
此外,还有像 OpenUserJS 和 Greasy Fork 这样的脚本仓库。然而,请注意,从他人那里安装用户脚本可能非常危险,因为它们几乎可以做任何事情,比如窃取你的信用卡号码。除非你自己阅读了整个脚本,理解了它所做的事情,并且绝对确信它没有做任何可疑的事情,否则永远不要安装脚本!永远不要安装包含你无法阅读的压缩或混淆代码的脚本!
网络 API
随着越来越多的网络服务提供应用程序接口,也就是 Web API,以便您可以与这些服务进行交互,进行 Web 请求,这已经变得越来越普遍。关于这个主题的更深入介绍可以在这里找到。有许多公共 API。Web API 可以出于许多原因非常有用:
- 检索。Web API 可以非常容易地提供信息,如地图、天气或您的公共 IP 地址。例如,
curl ipinfo.io将返回一个包含有关您的公共 IP、地区、位置等详细信息的 JSON 对象。通过适当的解析,这些工具甚至可以与命令行工具集成。以下 bash 函数与 Google 的自动完成 API 通信,并返回前十个匹配项。
function c() {
url='https://www.google.com/complete/search?client=hp&hl=en&xhr=t'
# NB: user-agent must be specified to get back UTF-8 data!
curl -H 'user-agent: Mozilla/5.0' -sSG --data-urlencode "q=$*" "$url" |
jq -r ".[1][][0]" |
sed 's,</\?b>,,g'
}
-
交互。Web API 端点也可以用来触发操作。这些通常需要某种认证令牌,您可以通过服务获取。例如,执行以下
curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' "https://hooks.slack.com/services/$SLACK_TOKEN"将会在某个频道发送一条Hello, World!消息。 -
管道。由于一些提供 Web API 的服务相当受欢迎,常见的 Web API“粘合”功能已经实现,并且随服务器提供。例如,If This Then That和Zapier等服务就是这种情况。
Web 自动化
有时候,Web API 可能不足以满足需求。如果只需要读取,可以使用像pup这样的 HTML 解析器,或者使用库,例如 Python 有 BeautifulSoup。然而,如果需要交互或 JavaScript 执行,这些解决方案就不够用了。WebDriver
例如,以下脚本将使用 Wayback Machine 模拟输入网站的方式,保存指定的 URL。
from selenium.webdriver import Firefox
from selenium.webdriver.common.keys import Keys
def snapshot_wayback(driver, url):
driver.get("https://web.archive.org/")
elem = driver.find_element_by_class_name('web-save-url-input')
elem.clear()
elem.send_keys(url)
elem.send_keys(Keys.RETURN)
driver.close()
driver = Firefox()
url = 'https://hacker-tools.github.io'
snapshot_wayback(driver, url)
练习
-
编辑你在网络浏览器中经常使用的关键词搜索引擎
-
安装提到的扩展。研究如何禁用 uBlock Origin/Privacy Badger 以访问网站。您看到了什么不同?尝试在一个广告众多的网站(如 YouTube)上这样做。
-
安装 Stylus 并使用提供的 CSS 为班级网站编写自定义样式。以下是一些常见的编程字符
= == === >= => ++ /= ~=。当将字体更改为 Fira Code 时,它们会发生什么?如果您想了解更多,请搜索编程字体连字符。 -
找到一个 Web API 来获取你所在城市/地区的天气。
-
使用像Selenium这样的 WebDriver 软件来自动化您在浏览器中经常执行的一些重复性手动任务。
本作品受CC BY-NC-SA许可。
安全和隐私
世界是一个可怕的地方,每个人都想抓住你。
好吧,可能不是,但这并不意味着你想炫耀所有的秘密。安全和隐私通常都是关于提高攻击者的门槛。找出你的威胁模型,然后围绕那个模型设计你的安全机制!如果威胁模型是国家安全局或摩萨德,你可能会遇到麻烦。
有很多种方法可以使你的技术形象更加安全。我们在这里会涉及很多高级话题,但这是一个过程,自我教育是你能做的最好的事情之一。所以:
跟随正确的人
提高你的安全知识的最好方法之一是跟随那些在安全方面有声音的人。以下是一些建议:
参考以下列表获取更多建议:这个列表
一般安全建议
Tech Solidarity 有一个相当不错的关于记者的应该做和不应该做的事情列表,其中包含很多合理的建议,并且相当更新。@thegrugq 还有一篇关于旅行安全建议的好博客文章,值得一读。我们将在这里重复很多来自这些来源的建议,以及一些额外的建议。此外,购买一个USB 数据阻断器,因为USB 很可怕。
认证
如果你还没有做的话,第一件事应该是下载一个密码管理器。一些不错的选择有:
如果你特别偏执,请使用一种在本地计算机上加密密码的方法,而不是在服务器上以纯文本形式存储。用它为所有你关心的网站生成密码。然后,开启双因素认证,理想情况下使用 FIDO/U2F 撬棍(例如 YubiKey,它为学生提供 20% 的折扣)。在紧急情况下,TOTP(如 Google Authenticator 或 Duo)也可以使用,但它不能防止钓鱼。除非你的威胁模型只包括随机陌生人捡起你传输中的密码,否则短信几乎毫无用处。
此外,关于纸质密钥的注意事项。通常,服务会提供一个“备份密钥”,你可以在丢失真正的第二因素时作为备用因素使用(顺便说一句,始终要在一个安全的地方保留备份)。虽然你可以将这些密钥存放在密码管理器中,但这意味着如果有人获取了你的密码管理器,你将完全处于不利地位(但也许你对此模型可以接受)。如果你真的非常偏执,请打印出这些纸质密钥,永远不要以数字形式存储,并将它们放在现实世界中的保险箱中。
私人通信
使用 Signal (设置说明. Wire 同样不错(www.securemessagingapps.com/); WhatsApp 可以接受;不要使用 Telegram). 桌面即时通讯工具相当不完善(部分原因是通常依赖于 Electron,这是一个庞大的信任堆栈)。
电子邮件尤其有问题,即使经过 PGP 签名。它通常不是前向安全的,密钥分发问题相当严重。keybase.io 有所帮助,并且出于许多其他原因也很有用。此外,PGP 密钥通常在桌面计算机上处理,这是最不安全的计算环境之一。相关地,考虑购买 Chromebook,或者只需在带键盘的平板电脑上工作。
文件安全
文件安全是一项艰巨的任务,它涉及多个层面。你试图防范的是什么?
-
离线攻击(有人在你关闭笔记本电脑时偷走它):启用全磁盘加密。(Linux 上的 cryptsetup + LUKS,Windows 上的 BitLocker,macOS 上的 FileVault。请注意,如果攻击者也拥有你并且真的想要你的秘密,这不会有所帮助)。
-
在线攻击(有人拥有你的笔记本电脑并且它处于开启状态):使用文件加密。为此,有两种主要的机制
-
合理否认(看起来有什么问题官员?):通常性能较低,更容易丢失数据。实际上很难证明它提供了可否认加密!请参阅这里的讨论,然后考虑你是否可能想要尝试VeraCrypt(老牌 TrueCrypt 的维护分支)。
-
- 考虑一下,如果攻击者获得了你的笔记本电脑,他们是否可以删除你的备份!
互联网安全与隐私
互联网是一个非常可怕的地方。开放的 WiFi 网络很可怕。是。确保你之后删除它们,否则你的手机会高兴地宣布并重新连接到具有相同名称的东西!
如果你曾经连接到一个你不信任的网络,VPN 可能是有价值的,但请记住,你是在很大程度上信任 VPN 提供商。你真的比你的 ISP 更信任他们吗?如果你真的想要 VPN,请使用你确信可以信任的提供商,并且你可能需要为此付费。或者为自己设置WireGuard – 它是出色的!
在cipherlist.eu上也有许多互联网应用程序的安全配置设置。如果你特别注重隐私,privacytools.io也是一个很好的资源。
一些朋友可能会对 Tor 感到好奇。请记住,Tor 并非特别抵抗强大的全球攻击者,且在流量分析攻击中较为脆弱。它可能在小型范围内隐藏流量有用,但在隐私方面并不会给你带来太多好处。你最好一开始就使用更安全的服务(Signal、TLS + 证书固定等)。
网络安全
那么,你也想上网吗?哎呀,你真的在冒险了。
安装 HTTPS Everywhere。SSL/TLS 是 至关重要的,它不仅仅是关于加密,还在于能够验证你最初是在与正确的服务进行交流!如果你运行自己的网站服务器,对其进行测试。TLS 配置 可能会变得复杂。HTTPS Everywhere 将竭尽全力,在存在替代方案时,永远不会导航你到 HTTP 网站。这并不能完全保护你,但确实有所帮助。如果你真的非常谨慎,可以黑名单任何你绝对不需要的 SSL/TLS CA。
安装 uBlock Origin。它是一个 广谱拦截器,不仅阻止广告,还能阻止页面可能尝试的所有第三方通信。以及内联脚本等。如果你愿意花时间进行配置以使一切工作,请访问 中等模式 或甚至 困难模式。这些模式确实会使一些网站在调整设置之前无法工作,但也会显著提高你的在线安全性。
如果你使用 Firefox,请启用 多账户容器。为社交网络、银行、购物等创建单独的容器。Firefox 将为每个容器保留完全独立的 cookie 和其他状态,因此在一个容器中访问的网站无法窥探其他容器的敏感数据。在 Google Chrome 中,你可以使用 Chrome 配置文件来实现类似的效果。
练习
-
使用 PGP 加密文件
-
使用 veracrypt 创建一个简单的加密卷
-
为您最敏感数据的账户启用双因素认证,例如 GMail、Dropbox、Github 等。
本文档遵循 CC BY-NC-SA 许可协议。
2020
课程概述 + Shell
动机
作为计算机科学家,我们知道计算机擅长辅助重复性任务。然而,我们往往忘记这一点同样适用于我们使用计算机的方式,就像它适用于我们希望程序执行的计算一样。我们手头上有大量的工具,这些工具使我们能够更高效地工作并解决更复杂的问题。然而,我们中的许多人只利用了其中的一小部分;我们只是通过死记硬背知道足够的咒语来应付,当我们遇到困难时,就盲目地从互联网上复制粘贴命令。
本课程试图解决这个问题。
我们想教你如何充分利用你已知的工具,向你展示可以添加到你的工具箱中的新工具,并希望在你心中培养一些探索(也许还有自己构建)更多工具的热情。这是我们相信的大多数计算机科学课程中缺失的学期。
课程结构
本课程由 11 个 1 小时长的讲座组成,每个讲座都围绕一个特定主题展开。讲座之间大部分是独立的,但随着学期的进行,我们假设你已经熟悉了早期讲座的内容。我们已经在网上提供了讲座笔记,但课堂上会有很多内容(例如以演示的形式)可能不在笔记中。我们将录制讲座并在网上发布。
我们试图在短短 11 个 1 小时讲座中涵盖大量内容,因此讲座内容相当密集。为了让你有时间以自己的节奏熟悉内容,每个讲座都包含一系列练习,引导你了解讲座的关键点。在每个讲座之后,我们将举办办公时间,届时我们将到场帮助回答你可能有的任何问题。如果你在线参加课程,你可以通过 missing-semester@mit.edu 发送问题。
由于我们有限的时间,我们无法像完整课程那样详细地介绍所有工具。在可能的情况下,我们将尝试引导你了解进一步探索工具或主题的资源,但如果有什么特别吸引你的,请不要犹豫,向我们寻求指导!
主题 1:Shell
什么是 Shell?
今天的计算机有各种各样的命令接口;幻想般的图形用户界面、语音界面,甚至 AR/VR 无处不在。这些对于 80%的使用场景来说都很棒,但它们在允许你做什么方面通常存在根本性的限制——你不能按一个不存在的按钮,也不能给出一个未编程的语音命令。为了充分利用计算机提供的工具,我们必须回到老式的方法,降低到文本界面:Shell。
几乎所有你可以接触到的平台都至少有一种形式的 shell,其中许多平台提供了多个 shell 供你选择。虽然它们在细节上可能有所不同,但它们的本质大致相同:它们允许你运行程序,向它们提供输入,并以半结构化的方式检查它们的输出。
在这次讲座中,我们将重点关注 Bourne Again SHell,简称“bash”。这是最广泛使用的 shell 之一,其语法与你在许多其他 shell 中看到的大致相同。要打开一个 shell 提示符(在那里你可以输入命令),你首先需要一个终端。你的设备可能已经预装了一个终端,或者你可以相对容易地安装一个。
使用 shell
当你启动你的终端时,你会看到一个提示符,通常看起来有点像这样:
missing:~$
这是访问 shell 的主要文本界面。它告诉你你现在位于名为missing的机器上,以及你的“当前工作目录”,或者说你现在所在的位置,是~(代表“家”)。$符号告诉你你不是 root 用户(稍后会有更多关于这个话题的讨论)。在这个提示符下,你可以输入一个命令,然后 shell 会解释这个命令。最基本的命令是执行一个程序:
missing:~$ date
Fri 10 Jan 2020 11:49:31 AM EST missing:~$
在这里,我们执行了date程序,它(可能不出所料)打印了当前的日期和时间。然后 shell 会要求我们输入另一个要执行的命令。我们还可以使用参数来执行命令:
missing:~$ echo hello
hello
在这种情况下,我们告诉 shell 执行名为echo的程序,并带有参数hello。echo程序简单地打印出其参数。shell 通过按空白字符分割命令来解析命令,然后运行由第一个单词指示的程序,并将每个后续单词作为程序可以访问的参数提供。如果你想提供一个包含空格或其他特殊字符的参数(例如,名为“我的照片”的目录),你可以用'或"将参数引起来("我的照片"),或者用\转义相关字符(My\ Photos)。
但 shell 是如何知道如何找到date或echo程序的?嗯,shell 是一个编程环境,就像 Python 或 Ruby 一样,因此它有变量、条件语句、循环和函数(下节课将介绍!)。当你你在 shell 中运行命令时,你实际上是在编写一小段代码,shell 会解释这段代码。如果 shell 被要求执行一个不匹配其编程关键词的命令,它会咨询一个名为$PATH的环境变量,该变量列出了 shell 在接收到命令时应搜索哪些目录以查找程序:
missing:~$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin missing:~$ which echo
/bin/echo missing:~$ /bin/echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
当我们运行 echo 命令时,shell 会看到它应该执行程序 echo,然后搜索 $PATH 中以 : 分隔的目录列表,以找到该名称的文件。当它找到时,它会运行它(假设文件是 可执行 的;关于这一点稍后会有更多介绍)。我们可以使用 which 程序找出给定程序名称所执行的文件。我们也可以通过给出我们想要执行的文件的 路径 来完全绕过 $PATH。
在 shell 中导航
在 shell 中,路径是一系列由分隔符分隔的目录;在 Linux 和 macOS 上由 / 分隔,在 Windows 上由 \ 分隔。在 Linux 和 macOS 上,路径 / 是文件系统的“根”,所有目录和文件都位于其下,而在 Windows 上,每个磁盘分区都有一个根(例如,C:\)。我们通常假设你在这个课程中使用的是 Linux 文件系统。以 / 开头的路径称为 绝对 路径。任何其他路径都是 相对 路径。相对路径相对于当前工作目录,我们可以使用 pwd 命令查看它,并使用 cd 命令更改它。在路径中,. 表示当前目录,而 .. 表示其父目录:
missing:~$ pwd
/home/missing missing:~$ cd /home
missing:/home$ pwd
/home missing:/home$ cd ..
missing:/$ pwd
/ missing:/$ cd ./home
missing:/home$ pwd
/home missing:/home$ cd missing
missing:~$ pwd
/home/missing missing:~$ ../../bin/echo hello
hello
注意,我们的 shell 提示符一直告诉我们当前的工作目录是什么。你可以配置你的提示符以显示各种有用的信息,我们将在以后的讲座中介绍。
通常,当我们运行一个程序时,它将在当前目录中操作,除非我们告诉它否则。例如,它通常会在那里搜索文件,如果需要,会在那里创建新文件。
要查看给定目录中有什么,我们使用 ls 命令:
missing:~$ ls
missing:~$ cd ..
missing:/home$ ls
missing missing:/home$ cd ..
missing:/$ ls
bin
boot
dev
etc
home ...
除非将目录作为其第一个参数给出,否则 ls 将打印当前目录的内容。大多数命令接受标志和选项(带有值的标志),以 - 开头以修改其行为。通常,使用 -h 或 --help 标志运行程序将打印一些帮助文本,告诉你有哪些标志和选项可用。例如,ls --help 告诉我们:
-l use a long listing format
missing:~$ ls -l /home
drwxr-xr-x 1 missing users 4096 Jun 15 2019 missing
这为我们提供了关于每个现有文件或目录的更多信息。首先,行首的 d 告诉我们 missing 是一个目录。然后是三组三个字符(rwx)。这些表示文件所有者(missing)、所属组(users)和所有人分别对相关项拥有的权限。一个 - 表示给定的主体没有该权限。在上面,只有所有者被允许修改(w)missing 目录(即,在其中添加/删除文件)。要进入目录,用户必须在该目录(及其父目录)上拥有“搜索”(表示为“执行”:x)权限。要列出其内容,用户必须在该目录上拥有读取(r)权限。对于文件,权限正如你所期望的那样。注意,/bin 中几乎所有文件的最后一个组“所有人”都设置了 x 权限,这样任何人都可以执行这些程序。
在这个阶段,还有一些实用的程序需要了解,比如mv(重命名/移动文件)、cp(复制文件)和mkdir(创建新目录)。
如果你想要关于程序参数、输入、输出或其一般工作方式的更多信息,可以尝试使用man程序。它接受一个程序名称作为参数,并显示其手册页。按q键退出。
missing:~$ man ls
连接程序
在 shell 中,程序与其相关联的两个主要“流”是:它们的输入流和输出流。当程序尝试读取输入时,它从输入流中读取,当它打印某些内容时,它将其打印到输出流。通常,程序输入和输出都是你的终端。也就是说,你的键盘作为输入,你的屏幕作为输出。然而,我们也可以重新连接这些流!
重定向的最简单形式是< file和> file。这些允许你将程序的输入和输出流分别重新连接到文件:
missing:~$ echo hello > hello.txt
missing:~$ cat hello.txt
hello missing:~$ cat < hello.txt
hello missing:~$ cat < hello.txt > hello2.txt
missing:~$ cat hello2.txt
hello
如上例所示,cat是一个将文件连接在一起的程序。当提供文件名作为参数时,它按顺序将其输出流中的每个文件的 内容打印出来。但是当cat没有提供任何参数时,它将其输入流中的内容打印到输出流(如上例中的第三个例子所示)。
你也可以使用>>来追加到文件。这种输入/输出重定向真正发挥作用的地方在于管道的使用。|运算符允许你“链”程序,使得一个程序的输出成为另一个程序的输入:
missing:~$ ls -l / | tail -n1
drwxr-xr-x 1 root root 4096 Jun 20 2019 var missing:~$ curl --head --silent google.com | grep --ignore-case content-length | cut --delimiter=' ' -f2
219
我们将在数据整理的讲座中详细介绍如何利用管道。
一个多用途且强大的工具
在大多数类 Unix 系统中,有一个用户是特殊的:“root”用户。你可能在上面的文件列表中见过它。root 用户高于(几乎)所有访问限制,可以创建、读取、更新和删除系统中的任何文件。尽管如此,你通常不会以 root 用户登录系统,因为这样很容易不小心破坏某些东西。相反,你会使用sudo命令。正如其名称所暗示的,它允许你“作为 su”执行某些操作(su 是“super user”或“root”的缩写)。当你遇到权限拒绝错误时,通常是因为你需要以 root 身份执行某些操作。尽管如此,请确保你首先确认你真的想那样做!
你需要以 root 权限执行的一件事是写入位于/sys下的sysfs文件系统。sysfs将许多内核参数作为文件暴露出来,这样你就可以在不使用专用工具的情况下轻松地即时重新配置内核。请注意,sysfs 在 Windows 或 macOS 上不存在。
例如,你的笔记本电脑屏幕的亮度通过位于
/sys/class/backlight
通过将值写入该文件,我们可以改变屏幕亮度。你的第一反应可能是做些像这样的事情:
$ sudo find -L /sys/class/backlight -maxdepth 2 -name '*brightness*'
/sys/class/backlight/thinkpad_screen/brightness $ cd /sys/class/backlight/thinkpad_screen
$ sudo echo 3 > brightness
An error occurred while redirecting file 'brightness'
open: Permission denied
这个错误可能让你感到惊讶。毕竟,我们是用 sudo 运行命令的!这是关于 shell 的一个重要知识点。像 |、> 和 < 这样的操作是由 shell 来完成的,而不是由单个程序完成的。echo 和其他命令并不“知道” |。它们只是从它们的输入读取并写入输出,无论输出是什么。在上面的例子中,shell(与你的用户一样进行认证)试图在设置 sudo echo 的输出之前打开亮度文件进行写入,但由于 shell 并不以 root 身份运行,因此被阻止了。利用这个知识,我们可以绕过这个问题:
$ echo 3 | sudo tee brightness
由于 tee 程序是打开 /sys 文件进行写入的,并且它以 root 身份运行,因此权限设置都是正确的。你可以通过 /sys 控制各种有趣和有用的东西,例如各种系统 LED 的状态(你的路径可能不同):
$ echo 1 | sudo tee /sys/class/leds/input6::scrolllock/brightness
下一步
到目前为止,你已经足够熟悉 shell,可以完成基本任务了。你应该能够导航到找到感兴趣的文件,并使用大多数程序的基本功能。在下一讲中,我们将讨论如何使用 shell 和许多实用的命令行程序来执行和自动化更复杂的任务。
练习
本课程的所有课程都附有一系列练习。有些给出了特定的任务,而有些则是开放式的,例如“尝试使用 X 和 Y 程序”。我们强烈鼓励你尝试它们。
我们没有为练习编写解决方案。如果你在某个特定的问题上遇到了困难,请随时发送电子邮件描述你迄今为止尝试的内容,我们将尽力帮助你。
-
对于这门课程,你需要使用类似 Bash 或 ZSH 的 Unix shell。如果你使用的是 Linux 或 macOS,你不需要做任何特殊操作。如果你使用的是 Windows,你需要确保你并没有运行 cmd.exe 或 PowerShell;你可以使用 Windows Subsystem for Linux 或 Linux 虚拟机来使用 Unix 风格的命令行工具。为了确保你正在运行合适的 shell,你可以尝试运行命令
echo $SHELL。如果它显示类似/bin/bash或/usr/bin/zsh的内容,这意味着你正在运行正确的程序。 -
在
/tmp目录下创建一个名为missing的新目录。 -
查找
touch程序。man程序是你的好朋友。 -
使用
touch命令在missing目录下创建一个名为semester的新文件。 -
将以下内容逐行写入该文件:
#!/bin/sh curl --head --silent https://missing.csail.mit.edu第一行可能有点难以正确运行。了解
#在 Bash 中表示注释,!在双引号(") 字符串中具有特殊含义是有帮助的。Bash 对单引号(') 字符串的处理方式不同:在这种情况下它们会起作用。有关更多信息,请参阅 Bash 的 引号 手册页面。 -
尝试执行该文件,即在你的 shell 中输入脚本的路径(
./semester)并按回车键。通过咨询ls的输出来理解为什么它不起作用(提示:查看文件的权限位)。 -
通过显式启动
sh解释器,并将文件semester作为第一个参数传递来运行该命令,即sh semester。为什么这样做会成功,而./semester不行呢? -
查找
chmod程序(例如,使用man chmod)。 -
使用
chmod使./semester命令可执行,而不是必须输入sh semester。你的 shell 如何知道该文件应该使用sh解释器来解释?请参阅关于 shebang 行的此页面以获取更多信息。 -
使用
|和>将semester输出的“最后修改日期”写入你主目录下的名为last-modified.txt的文件中。 -
编写一个命令,从
/sys中读取笔记本电脑电池的电量或台式机的 CPU 温度。注意:如果你是 macOS 用户,你的操作系统没有 sysfs,因此你可以跳过这个练习。
本文档受 CC BY-NC-SA 许可。
Shell 工具和脚本
在本讲座中,我们将介绍使用 bash 作为脚本语言的一些基本知识,以及一些覆盖了你在命令行中会不断执行的一些常见任务的 Shell 工具。
Shell 脚本
到目前为止,我们已经看到了如何在 Shell 中执行命令并将它们管道连接起来。然而,在许多场景中,你可能想要执行一系列命令并使用控制流表达式,如条件或循环。
Shell 脚本在复杂性上是下一步。大多数 Shell 都有自己的脚本语言,包括变量、控制流和自己的语法。使 Shell 脚本与其他脚本编程语言不同的地方在于,它针对执行 Shell 相关任务进行了优化。因此,创建命令管道、将结果保存到文件和从标准输入读取是 Shell 脚本中的基本操作,这使得它比通用脚本语言更容易使用。在本节中,我们将重点关注 bash 脚本,因为它是最常见的。
在 bash 中分配变量时,使用语法 foo=bar 并使用 $foo 访问变量的值。请注意,foo = bar 不会工作,因为它被解释为调用 foo 程序,参数为 = 和 bar。一般来说,在 Shell 脚本中,空格字符将执行参数分割。这种行为一开始可能会让人困惑,所以总是要检查这一点。
在 bash 中,字符串可以使用 ' 和 " 分隔符定义,但它们并不等价。使用 ' 分隔的字符串是字面字符串,不会替换变量值,而使用 " 分隔的字符串会。
foo=bar
echo "$foo"
# prints bar
echo '$foo'
# prints $foo
与大多数编程语言一样,bash 支持控制流技术,包括 if、case、while 和 for。同样,bash 有接受参数并可以与之操作的函数。以下是一个创建目录并 cd 进入该目录的函数示例。
mcd () {
mkdir -p "$1"
cd "$1"
}
在这里,$1 是脚本/函数的第一个参数。与其它脚本语言不同,bash 使用各种特殊变量来引用参数、错误代码和其他相关变量。以下是一些它们的列表。更全面的列表可以在这里找到。
-
$0- 脚本名称 -
$1到$9- 脚本的参数。$1是第一个参数,以此类推。 -
$@- 所有参数 -
$#- 参数数量 -
$?- 上一个命令的返回码 -
$$- 当前脚本的进程标识号(PID) -
!!- 包括参数在内的整个最后一个命令。一个常见的模式是执行一个命令,但由于缺少权限而失败;你可以通过执行sudo !!来快速重新执行该命令。 -
$_- 上一个命令的最后一个参数。如果你在一个交互式 Shell 中,你也可以通过输入Esc后跟.或Alt+.来快速获取这个值。
命令通常会通过 STDOUT 返回输出,通过 STDERR 返回错误,并通过返回码以更脚本友好的方式报告错误。返回码或退出状态是脚本/命令必须用来传达执行情况的方式。值为 0 通常表示一切正常;任何不同于 0 的值都表示发生了错误。
可以使用退出码来通过 &&(和操作符)和 ||(或操作符)条件性地执行命令,这两个都是 短路求值 操作符。也可以使用分号 ; 在同一行内分隔命令。true 程序总是有 0 返回码,而 false 命令总是有 1 返回码。让我们看看一些示例。
false || echo "Oops, fail"
# Oops, fail
true || echo "Will not be printed"
#
true && echo "Things went well"
# Things went well
false && echo "Will not be printed"
#
true ; echo "This will always run"
# This will always run
false ; echo "This will always run"
# This will always run
另一个常见的模式是想要将命令的输出作为变量。这可以通过 命令替换 来完成。每次你放置 $( CMD ),它将执行 CMD,获取命令的输出并将其替换在原位置。例如,如果你执行 for file in $(ls),shell 将首先调用 ls,然后遍历这些值。一个不太为人所知的相关特性是 进程替换,<( CMD ) 将执行 CMD 并将输出放置在临时文件中,并用 <() 替换该文件名。这在命令期望通过文件而不是通过 STDIN 传递值时很有用。例如,diff <(ls foo) <(ls bar) 将显示 foo 和 bar 目录中文件的差异。
由于那是一个大量信息堆叠,让我们看看一个示例,展示了一些这些功能。它将遍历我们提供的参数,使用 grep 搜索字符串 foobar,如果未找到,则将其作为注释追加到文件中。
#!/bin/bash
echo "Starting program at $(date)" # Date will be substituted
echo "Running program $0 with $# arguments with pid $$"
for file in "$@"; do grep foobar "$file" > /dev/null 2> /dev/null
# When pattern is not found, grep has exit status 1
# We redirect STDOUT and STDERR to a null register since we do not care about them
if [[ $? -ne 0 ]]; then echo "File $file does not have any foobar, adding one"
echo "# foobar" >> "$file"
fi
done
在比较测试中,我们测试了 $? 是否不等于 0。Bash 实现了许多此类比较 - 你可以在 test 的手册页中找到一个详细的列表(test)。在 Bash 中执行比较时,尽量使用双括号 [[ ]] 而不是简单的括号 [ ]。这样犯错的几率会低一些,尽管它不会移植到 sh。更详细的解释可以在这里找到。
当启动脚本时,你通常会想要提供相似的参数。Bash 有一些方法可以使这变得更容易,通过执行文件名扩展来展开表达式。这些技术通常被称为 shell 通配符。
-
通配符 - 无论何时你想执行某种通配符匹配,你可以使用
?和*来分别匹配一个或任意数量的字符。例如,给定文件foo、foo1、foo2、foo10和bar,命令rm foo?将会删除foo1和foo2,而rm foo*将会删除除了bar以外的所有文件。 -
大括号
{}- 当你在一系列命令中有一个共同的子字符串时,你可以使用大括号让 bash 自动展开。这在移动或转换文件时非常有用。
convert image.{png,jpg}
# Will expand to
convert image.png image.jpg
cp /path/to/project/{foo,bar,baz}.sh /newpath
# Will expand to
cp /path/to/project/foo.sh /path/to/project/bar.sh /path/to/project/baz.sh /newpath
# Globbing techniques can also be combined
mv *{.py,.sh} folder
# Will move all *.py and *.sh files
mkdir foo bar
# This creates files foo/a, foo/b, ... foo/h, bar/a, bar/b, ... bar/h
touch {foo,bar}/{a..h}
touch foo/x bar/y
# Show differences between files in foo and bar
diff <(ls foo) <(ls bar)
# Outputs
# < x
# ---
# > y
编写 bash 脚本可能很棘手且不直观。有一些工具,如 shellcheck,可以帮助你找到 sh/bash 脚本中的错误。
注意,脚本不一定要用 bash 编写才能从终端调用。例如,这里有一个简单的 Python 脚本,它会以相反的顺序输出其参数:
#!/usr/local/bin/python import sys
for arg in reversed(sys.argv[1:]):
print(arg)
内核知道使用 Python 解释器而不是 shell 命令来执行此脚本,因为我们已经在脚本顶部包含了 shebang 行。使用 env(链接)命令编写 shebang 行是一种良好的实践,该命令将解析到系统中命令所在的位置,从而提高脚本的可移植性。为了解析位置,env 将使用我们在第一讲中介绍过的 PATH 环境变量。对于这个例子,shebang 行将看起来像 #!/usr/bin/env python。
你应该记住的一些 shell 函数和脚本之间的区别是:
-
函数必须与 shell 使用相同的语言,而脚本可以用任何语言编写。这就是为什么为脚本包含 shebang 行很重要的原因。
-
函数在读取其定义时只加载一次。脚本每次执行时都会被加载。这使得函数的加载速度略快,但每次更改它们时,你将不得不重新加载它们的定义。
-
函数在当前 shell 环境中执行,而脚本则在它们自己的进程中执行。因此,函数可以修改环境变量,例如更改你的当前目录,而脚本则不能。使用
export(链接)导出的环境变量会按值传递给脚本。 -
与任何编程语言一样,函数是一种强大的结构,可以实现模块化、代码重用和 shell 代码的清晰性。通常,shell 脚本会包含它们自己的函数定义。
Shell 工具
查找如何使用命令
到目前为止,你可能想知道如何在别名部分找到命令的标志,例如 ls -l、mv -i 和 mkdir -p。更普遍地说,给定一个命令,你如何找到它的作用及其不同的选项?你总是可以从谷歌开始搜索,但由于 UNIX 早在 StackOverflow 之前就存在,所以有一些内置的方法可以获取这些信息。
正如我们在 shell 课程中看到的,一阶方法是使用-h或--help标志调用该命令。更详细的方法是使用man命令。简称为手册,man为指定的命令提供了一个手册页(称为 manpage)。例如,man rm将输出rm命令的行为以及它所接受的标志,包括我们之前展示的-i标志。实际上,到目前为止,我链接的每个命令都是 Linux 手册页的在线版本。即使是您安装的非原生命令,如果开发者在安装过程中编写了它们并将其包含在内,也会有 manpage 条目。对于基于 ncurses 的交互式工具,通常可以使用:help命令或输入?在程序内部访问命令的帮助。
有时,manpages 可能会提供过于详细的命令描述,使得难以分辨出在常见用例中应该使用哪些标志/语法。TLDR 页面是一个巧妙的补充解决方案,它专注于提供命令的示例用例,以便您可以快速确定要使用哪些选项。例如,我发现自己在tar和ffmpeg的 tldr 页面上比 manpages 上引用得更多。
查找文件
每个程序员都面临的最常见的重复性任务之一是查找文件或目录。所有类 UNIX 系统都预装了find,这是一个伟大的 shell 工具,用于查找文件。find将递归地搜索符合某些标准的文件。以下是一些示例:
# Find all directories named src
find . -name src -type d
# Find all python files that have a folder named test in their path
find . -path '*/test/*.py' -type f
# Find all files modified in the last day
find . -mtime -1
# Find all zip files with size in range 500k to 10M
find . -size +500k -size -10M -name '*.tar.gz'
除了列出文件外,find还可以对匹配您查询的文件执行操作。这个特性可以非常有助于简化可能相当单调的任务。
# Delete all files with .tmp extension
find . -name '*.tmp' -exec rm {} \;
# Find all PNG files and convert them to JPG
find . -name '*.png' -exec magick {} {}.jpg \;
尽管find无处不在,但其语法有时可能难以记住。例如,要简单地查找匹配某些模式PATTERN的文件,您必须执行find -name '*PATTERN*'(或者如果您想使模式匹配不区分大小写,则使用-iname)。您可以为这些场景开始构建别名,但 shell 哲学的一部分是探索替代方案。记住,shell 最好的特性之一就是您只是在调用程序,因此您可以找到(甚至自己编写)一些程序的替代品。例如,fd是find的一个简单、快速且用户友好的替代品。它提供了一些很好的默认设置,如彩色输出、默认正则表达式匹配和 Unicode 支持。在我看来,它还具有更直观的语法。例如,查找模式PATTERN的语法是fd PATTERN。
大多数人都会同意find和fd是好的,但有些人可能想知道每次查找文件与编译某种索引或数据库以快速搜索之间的效率问题。这正是locate的作用所在。locate使用的是通过updatedb更新的数据库。在大多数系统中,updatedb是通过cron每天更新的。因此,这两种方法之间的权衡是速度与新鲜度。此外,find和类似工具还可以使用文件大小、修改时间或文件权限等属性来查找文件,而locate仅使用文件名。更深入的比较可以在这里找到。
查找代码
通过文件名查找文件是有用的,但很多时候你希望根据文件内容进行搜索。一个常见的场景是想要搜索包含某些模式的所有文件,以及这些模式在这些文件中的位置。为了实现这一点,大多数类 UNIX 系统提供了grep,这是一个从输入文本中匹配模式的通用工具。grep是一个极其有价值的 shell 工具,我们将在数据整理讲座中更详细地介绍它。
目前,要知道grep有许多标志使其成为一个非常通用的工具。我经常使用的是-C,用于获取匹配行的上下文,以及-v用于反转匹配,即打印所有不匹配模式的行。例如,grep -C 5将打印匹配前后各 5 行。当需要快速搜索多个文件时,应使用-R,因为它将递归地进入目录并查找匹配的字符串。
但grep -R可以通过许多方式改进,例如忽略.git文件夹,使用多 CPU 支持等。已经开发了许多grep替代工具,包括ack、ag和rg。所有这些都非常出色,几乎提供了相同的功能。目前我坚持使用 ripgrep(rg),因为它既快又直观。以下是一些示例:
# Find all python files where I used the requests library
rg -t py 'import requests'
# Find all files (including hidden files) without a shebang line
rg -u --files-without-match "^#\!"
# Find all matches of foo and print the following 5 lines
rg foo -A 5
# Print statistics of matches (# of matched lines and files )
rg --stats PATTERN
注意,与find/fd一样,了解这些问题可以通过使用这些工具之一快速解决是很重要的,而你所使用的具体工具并不那么重要。
查找 shell 命令
到目前为止,我们已经看到了如何查找文件和代码,但随着你在 shell 中花费更多时间,你可能想要找到你之前输入的特定命令。首先要知道的是,按上箭头键会给你回退到最后一条命令,如果你继续按它,你将逐渐浏览你的 shell 历史记录。
history 命令允许你以编程方式访问你的 shell 历史记录。它将你的 shell 历史记录打印到标准输出。如果我们想在那里搜索,可以将该输出通过 grep 管道搜索模式。history | grep find 将打印包含子字符串“find”的命令。
在大多数 shell 中,你可以使用 Ctrl+R 来执行历史记录的反向搜索。按下 Ctrl+R 后,你可以输入你想要匹配历史记录中命令的子字符串。随着你持续按下它,你将遍历历史记录中的匹配项。这也可以通过 zsh 中的 UP/DOWN 光标键来启用。在 Ctrl+R 的基础上,使用 fzf 绑定是一个很好的补充。fzf 是一个通用的模糊查找器,可以与许多命令一起使用。在这里,它用于模糊匹配你的历史记录,并以方便和视觉上令人愉悦的方式呈现结果。
另一个我非常喜欢的、与历史记录相关的技巧是基于历史的自动补全。这一功能最初由 fish shell 提出,它动态地使用与当前 shell 命令共享相同前缀的最近输入的命令来自动补全你的当前 shell 命令。在 zsh 中可以启用此功能,这对于你的 shell 来说是一个极好的提升生活质量的技巧。
你可以修改你的 shell 的历史行为,例如防止以空格开头的命令被包含。当你输入带有密码或其他敏感信息的命令时,这会很有用。为此,将 HISTCONTROL=ignorespace 添加到你的 .bashrc 或将 setopt HIST_IGNORE_SPACE 添加到你的 .zshrc。如果你不小心没有添加前导空格,你总是可以通过编辑你的 .bash_history 或 .zsh_history 来手动删除条目。
目录导航
到目前为止,我们假设你已经处于执行这些操作所需的位置。但你是如何快速导航目录的呢?有许多简单的方法可以实现这一点,例如编写 shell 别名或使用 ln -s 创建符号链接,但事实是,开发者现在已经找到了相当巧妙和复杂的解决方案。
与本课程的主题一样,你通常希望优化常见情况。通过工具如 fasd 和 autojump 可以找到频繁和/或最近的文件和目录。Fasd 通过 frecency 对文件和目录进行排序,即通过 频率 和 最近修改时间。默认情况下,fasd 添加了一个 z 命令,你可以用它通过 frecent 目录的子字符串快速 cd。例如,如果你经常访问 /home/user/files/cool_project,你可以简单地使用 z cool 来跳转到那里。使用 autojump,同样的目录更改可以通过 j cool 完成。
存在更复杂的工具,可以快速查看目录结构:tree、broot 或甚至完整的文件管理器如 nnn 或 ranger。
练习
-
阅读
man ls(链接)并编写一个ls命令,以以下方式列出文件-
包括所有文件,包括隐藏文件
-
大小以人类可读格式列出(例如,454M 而不是 454279954)
-
文件按最近修改顺序排列
-
输出已着色
一个示例输出可能看起来像这样
-rw-r--r-- 1 user group 1.1M Jan 14 09:53 baz drwxr-xr-x 5 user group 160 Jan 14 09:53 . -rw-r--r-- 1 user group 514 Jan 14 06:42 bar -rw-r--r-- 1 user group 106M Jan 13 12:12 foo drwx------+ 47 user group 1.5K Jan 12 18:08 .. -
-
编写 bash 函数
marco和polo,执行以下操作。每次执行marco时,当前工作目录应以某种方式保存,然后当执行polo时,无论你在哪个目录,polo应该将你cd回执行marco的目录。为了便于调试,你可以将代码写入文件marco.sh,并通过执行source marco.sh将定义重新加载到你的 shell 中。 -
假设你有一个很少失败的命令。为了调试它,你需要捕获其输出,但获取失败运行可能很耗时。编写一个 bash 脚本,该脚本会一直运行直到失败,并将标准输出和错误流捕获到文件中,并在最后打印一切。如果你还能报告脚本失败所需的运行次数,则加分。
#!/usr/bin/env bash n=$(( RANDOM % 100 )) if [[ n -eq 42 ]]; then echo "Something went wrong" >&2 echo "The error was using magic numbers" exit 1 fi echo "Everything went according to plan" -
正如我们在讲座中提到的,
find的-exec可以非常强大,用于对搜索到的文件执行操作。然而,如果我们想对 所有 文件做些什么,比如创建一个压缩文件呢?如你所见,到目前为止,命令将从参数和 STDIN 中获取输入。当管道命令时,我们连接 STDOUT 到 STDIN,但一些命令如tar从参数中获取输入。为了弥合这种脱节,有xargs(链接)命令,它将使用 STDIN 作为参数执行命令。例如,ls | xargs rm将删除当前目录中的文件。你的任务是编写一个命令,以递归方式查找文件夹中的所有 HTML 文件,并将它们打包成 zip 文件。请注意,即使文件名中包含空格,你的命令也应该能够正常工作(提示:检查
xargs的-d标志)。如果你使用的是 macOS,请注意,默认的 BSD
find命令与包含在 GNU coreutils 中的命令不同。你可以在find命令中使用-print0,并在xargs中使用-0标志。作为 macOS 用户,你应该知道 macOS 中的命令行工具可能与 GNU 版本不同;如果你喜欢,可以通过 使用 brew 安装 GNU 版本。 -
(高级) 编写一个命令或脚本,以递归方式查找目录中最新的文件。更普遍地说,你能按时间顺序列出所有文件吗?
本作品受 CC BY-NC-SA 许可。
编辑器(Vim)
写英语单词和编写代码是非常不同的活动。在编程时,你花费更多的时间在切换文件、阅读、导航和编辑代码上,与写作长篇流相比。因此,对于写英语单词和编写代码来说,有不同的程序类型(例如,Microsoft Word 与 Visual Studio Code)是有道理的。
作为程序员,我们大部分时间都在编辑代码,因此花时间掌握一个适合你需求的编辑器是值得的。以下是如何学习新编辑器的方法:
-
从教程开始(即这次讲座,以及我们指出的资源)
-
坚持使用编辑器来满足你所有的文本编辑需求(即使它最初会减慢你的速度)
-
随时查找信息:如果你觉得有更好的方法来做某事,那么很可能确实有。
如果你遵循上述方法,完全承诺使用新程序进行所有文本编辑,学习复杂文本编辑器的时程表看起来是这样的。在一小时或两小时内,你将学会基本的编辑器功能,如打开和编辑文件、保存/退出和导航缓冲区。一旦你投入了 20 小时,你应该和以前使用旧编辑器一样快。之后,好处开始显现:你将拥有足够的知识和肌肉记忆,使用新编辑器可以节省你的时间。现代文本编辑器是花哨且强大的工具,因此学习永远不会停止:随着你学习的深入,你会变得更加快速。
学习哪个编辑器?
程序员们对他们使用的文本编辑器有强烈的意见。
今天哪些编辑器受欢迎?请参阅这个Stack Overflow 调查(可能存在一些偏差,因为 Stack Overflow 用户可能不能代表所有程序员)。Visual Studio Code是最受欢迎的编辑器。Vim是最受欢迎的基于命令行的编辑器。
Vim
本课程的所有讲师都使用 Vim 作为他们的编辑器。Vim 有着丰富的历史;它起源于 1976 年的 Vi 编辑器,并且至今仍在开发中。Vim 背后有一些非常巧妙的思想,因此许多工具支持 Vim 模拟模式(例如,有 140 万人安装了VS Code 的 Vim 模拟)。即使你最终切换到其他文本编辑器,学习 Vim 也可能值得。
在 50 分钟内不可能教授 Vim 的所有功能,因此我们将专注于解释 Vim 的哲学,教你基础知识,展示一些更高级的功能,并为你提供掌握这个工具的资源。
Vim 的哲学
在编程时,您大部分时间都在阅读/编辑,而不是写作。因此,Vim 是一个 模式编辑器:它有插入文本和操作文本的不同模式。Vim 是可编程的(使用 Vimscript 以及其他语言如 Python),Vim 的界面本身也是一种编程语言:按键(带有助记名)是命令,这些命令可以组合。Vim 避免使用鼠标,因为它太慢;Vim 甚至避免使用箭头键,因为它需要太多的移动。
最终结果是,一个可以匹配您思考速度的编辑器。
模式编辑
Vim 的设计基于这样一个理念:程序员的大部分时间都花在阅读、导航和进行小幅度编辑上,而不是编写长篇文本。因此,Vim 有多个操作模式。
-
正常模式:用于在文件中移动和编辑
-
插入模式:用于插入文本
-
替换模式:用于替换文本
-
视觉模式(平面、行或块):用于选择文本块
-
命令行模式:用于运行命令
在不同的操作模式下,按键有不同的含义。例如,在插入模式下,字母 x 只会插入一个字符 'x',但在正常模式下,它将删除光标下的字符,在视觉模式下,它将删除选定的文本。
在默认配置下,Vim 在左下角显示当前模式。初始/默认模式是正常模式。您通常会花费大部分时间在正常模式和插入模式之间。
您可以通过按下 <ESC>(退格键)来切换模式,从任何模式返回到正常模式。从正常模式,使用 i 进入插入模式,使用 R 进入替换模式,使用 v 进入视觉模式,使用 V 进入视觉行模式,使用 <C-v>(Ctrl-V,有时也写作 ^V)进入视觉块模式,以及使用 : 进入命令行模式。
使用 Vim 时,您会经常使用 <ESC> 键:考虑将 Caps Lock 映射到 Escape (macOS 指令) 或创建一个 替代映射 以简单的键序列来映射 <ESC>。
基础知识
插入文本
从正常模式,按 i 键进入插入模式。现在,Vim 的行为就像任何其他文本编辑器一样,直到您按下 <ESC> 返回到正常模式。这,加上上面解释的基本知识,就是您开始使用 Vim 编辑文件所需的所有内容(尽管如果您一直从插入模式进行编辑,效率可能不是特别高)。
缓冲区、标签页和窗口
Vim 维护一组打开的文件,称为“缓冲区”。Vim 会话包含多个标签页,每个标签页又包含多个窗口(分割的窗格)。每个窗口显示一个单独的缓冲区。与您熟悉的其他程序不同,如网页浏览器,缓冲区和窗口之间没有一一对应的关系;窗口仅仅是视图。一个特定的缓冲区可以在多个窗口中打开,甚至在同一标签页内。这可以非常方便,例如,同时查看文件的不同部分。
默认情况下,Vim 以单个标签页打开,其中包含一个窗口。
命令行
在普通模式下输入:可以进入命令模式。在按下:后,光标将跳转到屏幕底部的命令行。此模式具有许多功能,包括打开、保存和关闭文件,以及退出 Vim。
-
:q退出(关闭窗口) -
:w保存(“写入”) -
:wq保存并退出 -
:e {文件名}打开文件进行编辑 -
:ls显示打开的缓冲区 -
:help {主题}打开帮助-
:help :w打开:w命令的帮助 -
:help w打开:w移动的帮助
-
Vim 的界面是一种编程语言
Vim 最重要的思想是 Vim 的界面本身是一种编程语言。按键(带有记忆名称)是命令,这些命令可以组合。这使得移动和编辑变得高效,尤其是当命令成为肌肉记忆后。
移动
您应该大部分时间都在普通模式下,使用移动命令在缓冲区中导航。Vim 中的移动也称为“名词”,因为它们指的是文本块。
-
基本移动:
hjkl(左,下,上,右) -
单词:
w(下一个单词),b(单词开头),e(单词结尾) -
行:
0(行首),^(第一个非空白字符),$(行尾) -
屏幕:
H(屏幕顶部),M(屏幕中间),L(屏幕底部) -
滚动:
Ctrl-u(向上),Ctrl-d(向下) -
文件:
gg(文件开头),G(文件结尾) -
行号:
:{数字}<CR>或{数字}G(第{数字}行) -
其他:
%(对应项) -
查找:
f{字符},t{字符},F{字符},T{字符}-
在当前行上查找/向前/向后
-
,/;用于导航匹配项
-
-
搜索:
/{正则表达式},n/N用于导航匹配项
选择
可视模式:
-
可视:
v -
可视行:
V -
可视块:
Ctrl-v
可以使用移动键进行选择。
编辑
您以前用鼠标做的所有事情,现在都可以通过使用与移动命令组合的编辑命令在键盘上完成。这就是 Vim 的界面开始看起来像编程语言的地方。Vim 的编辑命令也称为“动词”,因为动词作用于名词。
-
i进入插入模式- 但对于操纵/删除文本,想要使用比退格键更强大的工具
-
o/O在下方/上方插入行 -
d{动作}删除- 例如,
dw是删除单词,d$是删除到行尾,d0是删除到行首
- 例如,
-
c{动作}更改-
例如,
cw是更改单词 -
如
d{motion}后跟i
-
-
x删除字符(等同于dl) -
s替换字符(等同于cl) -
视觉模式 + 操作
- 选择文本,
d删除它或c更改它
- 选择文本,
-
u撤销,<C-r>重做 -
y复制/“yank”(一些其他命令如d也复制) -
p粘贴 -
还有更多要学习的内容:例如,
~切换字符的大小写
计数
你可以将名词和动词与一个计数器结合使用,这将执行给定的动作多次。
-
3w向前移动 3 个单词 -
5j向下移动 5 行 -
7dw删除 7 个单词
修饰符
你可以使用修饰符来改变名词的含义。一些修饰符是 i,表示“内部”或“里面”,和 a,表示“周围”。
-
ci(更改当前括号对内的内容 -
ci[更改当前方括号对内的内容 -
da'删除单个引号字符串,包括周围的引号
示例
这里是一个损坏的 fizz buzz 实现:
def fizz_buzz(limit):
for i in range(limit):
if i % 3 == 0:
print('fizz')
if i % 5 == 0:
print('fizz')
if i % 3 and i % 5:
print(i)
def main():
fizz_buzz(10)
我们将修复以下问题:
-
主函数从未被调用
-
从 0 开始而不是 1
-
在 15 的倍数上分别打印“fizz”和“buzz”
-
对于 5 的倍数打印“fizz”
-
使用硬编码的 10 作为参数而不是接受命令行参数
观看演示视频。比较使用 Vim 实现上述更改的方式与你可能使用其他程序进行相同编辑的方式。注意在 Vim 中所需的按键非常少,这让你可以以你认为的速度进行编辑。
自定义 Vim
Vim 通过位于 ~/.vimrc 的纯文本配置文件(包含 Vimscript 命令)进行自定义。可能有很多基本的设置是你想要启用的。
我们提供了一个文档齐全的基本配置,你可以将其用作起点。我们建议使用这个配置,因为它修复了 Vim 一些古怪默认行为。在此处下载我们的配置 here 并保存到 ~/.vimrc。
Vim 可以高度自定义,花时间探索自定义选项是值得的。例如,你可以查看 GitHub 上人们的 dotfiles 以获得灵感,例如,你的指导老师的 Vim 配置(Anish,Jon(使用 neovim),Jose)。关于这个主题也有很多好的博客文章。尽量不要复制粘贴人们的完整配置,而是阅读它,理解它,并取你需要的部分。
扩展 Vim
有很多插件可以扩展 Vim。与你在互联网上可能找到的过时建议相反,你不需要为 Vim 使用插件管理器(自 Vim 8.0 以来)。相反,你可以使用内置的包管理系统。只需创建目录 ~/.vim/pack/vendor/start/,并将插件放在那里(例如,通过 git clone)。
这里有一些我们最喜欢的插件:
-
ctrlp.vim:模糊文件查找器
-
ack.vim:代码搜索
-
nerdtree:文件浏览器
-
vim-easymotion:魔法移动
我们在这里尽量避免提供一个过于冗长的插件列表。你可以查看讲师的配置文件(Anish, Jon, Jose)来了解我们使用哪些其他插件。查看 Vim Awesome 以获取更多令人惊叹的 Vim 插件。还有大量关于这个主题的博客文章:只需搜索“最佳 Vim 插件”。
其他程序中的 Vim 模式
许多工具支持 Vim 模拟。质量从好到优秀不等;根据工具的不同,它可能不支持更复杂的 Vim 功能,但大多数都很好地覆盖了基础功能。
Shell
如果你使用 Bash,请使用 set -o vi。如果你使用 Zsh,请使用 bindkey -v。对于 Fish,请使用 fish_vi_key_bindings。此外,无论你使用什么 shell,你都可以 export EDITOR=vim。这是决定当程序想要启动编辑器时使用哪个编辑器的环境变量。例如,git 将使用此编辑器进行提交消息。
Readline
许多程序使用 GNU Readline 库作为它们的命令行界面。Readline 还支持 (基本) Vim 模拟,可以通过在 ~/.inputrc 文件中添加以下行来启用:
set editing-mode vi
使用此设置,例如,Python REPL 将支持 Vim 绑定。
其他
甚至有针对网页浏览器的 vim 键绑定扩展 browsers - 一些流行的有 Vimium 用于 Google Chrome 和 Tridactyl 用于 Firefox。你甚至可以在 Jupyter notebooks 中获得 Vim 绑定。这里有一个 长列表 的具有类似 vim 键绑定的软件。
高级 Vim
这里有一些示例来展示编辑器的强大功能。我们无法教你所有这些类型的东西,但你在使用过程中会学到它们。一个好的经验法则是:每当你使用你的编辑器并想“一定有更好的方法来做这件事”,通常确实有:在网上查找。
搜索和替换
:s(替换)命令 (文档)。
-
%s/foo/bar/g- 在文件中全局替换 foo 为 bar
-
%s/\.*\)/\1/g- 将命名的 Markdown 链接替换为纯 URL
多窗口
-
:sp/:vsp用于分割窗口 -
可以有同一缓冲区的多个视图。
宏
-
q{字符}在寄存器{字符}中开始录制宏 -
q停止录制 -
@{字符}重新播放宏 -
宏执行在出错时停止
-
{number}@{character}执行宏 {number} 次 -
宏可以是递归的
-
首先使用
q{character}q清除宏 -
记录宏,使用
@{character}来递归调用宏(在记录完成前将不会执行)
-
-
示例:将 xml 转换为 json (文件)
-
带有“name”/“email”键的对象数组
-
使用 Python 程序?
-
使用 sed / 正则表达式
-
g/people/d -
%s/<person>/{/g -
%s/<name>\(.*\)<\/name>/"name": "\1",/g -
…
-
-
Vim 命令/宏
-
Gdd,ggdd删除第一行和最后一行 -
格式化单个元素的宏(寄存器
e)-
使用
<name>跳转到行 -
qe^r"f>s": "<ESC>f<C"<ESC>q
-
-
格式化人员的宏
-
使用
<person>跳转到行 -
qpS{<ESC>j@eA,<ESC>j@ejS},<ESC>q
-
-
格式化人员并跳转到下一个人
-
使用
<person>跳转到行 -
qq@pjq
-
-
执行宏直到文件末尾
999@q
-
手动删除最后的
,并添加[和]分隔符
-
-
资源
-
vimtutor是一个与 Vim 一起安装的教程 - 如果 Vim 已安装,你应该可以从你的 shell 中运行vimtutor -
Vim Adventures 是一个学习 Vim 的游戏
-
Vim Advent Calendar 有各种 Vim 技巧
-
Practical Vim(书籍)
练习
-
完成
vimtutor。注意:它在 80x24(80 列 x 24 行)的终端窗口中看起来最好。 -
下载我们的基本 vimrc 并将其保存到
~/.vimrc。仔细阅读这个有良好注释的文件(使用 Vim!),并观察 Vim 在新配置下的外观和行为略有不同。 -
安装并配置插件:ctrlp.vim。
-
为了练习使用 Vim,在自己的机器上重新做讲座中的演示。
-
在接下来的一个月内使用 Vim 进行所有文本编辑。每当遇到效率低下或认为“一定有更好的方法”时,尝试在 Google 上搜索,可能真的有。如果你卡住了,请来办公室或给我们发电子邮件。
-
将你的其他工具配置为使用 Vim 绑定(见上方说明)。
-
进一步自定义你的
~/.vimrc并安装更多插件。 -
(高级) 使用 Vim 宏将 XML 转换为 JSON (示例文件)。尽量自己完成这个任务,但如果遇到困难,可以查看上面的宏部分。
编辑此页.
本文档遵循CC BY-NC-SA许可协议。
数据整理
你是否曾经想要将一种格式的数据转换为另一种格式?当然,你肯定有!在非常一般的意义上,这正是本讲座的主题。具体来说,就是将数据(无论是文本还是二进制格式)处理成你想要的样子。
我们已经在之前的讲座中看到了一些基本的数据整理。几乎每次你使用 | 操作符时,你都在执行某种数据整理。考虑一个像 journalctl | grep -i intel 这样的命令。它找到所有提及 Intel(不区分大小写)的系统日志条目。你可能不会把它看作是数据整理,但它确实是从一个格式(你的整个系统日志)转换到一个对你更有用的格式(仅仅是 intel 日志条目)。大多数数据整理都是关于知道你有什么工具可用,以及如何组合它们。
让我们从一开始。要整理数据,我们需要两样东西:要整理的数据,以及处理它的东西。日志通常是一个很好的用例,因为你经常想要调查它们,而阅读整个日志是不切实际的。让我们通过查看我的服务器日志来找出谁试图登录我的服务器:
ssh myserver journalctl
这已经太多东西了。让我们限制到 ssh 相关的内容:
ssh myserver journalctl | grep sshd
注意,我们正在使用管道将一个 远程 文件通过本地计算机上的 grep 流式传输!ssh 是神奇的,我们将在下一讲中更多地讨论命令行环境。但这仍然比我们想要的要多。而且很难阅读。让我们做得更好:
ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' | less
为什么需要额外的引号?嗯,我们的日志可能相当大,将所有内容都流式传输到我们的计算机上然后再进行过滤是浪费的。相反,我们可以在远程服务器上进行过滤,然后在本地上处理数据。less 给我们一个“分页器”,允许我们滚动查看长输出。在调试命令行时,我们甚至可以将当前过滤的日志保存到文件中,这样在开发过程中就不需要访问网络:
$ ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' > ssh.log
$ less ssh.log
这里仍然有很多噪音。有很多方法可以去除它,但让我们看看工具箱中最强大的工具之一:sed。
sed 是一个“流编辑器”,它建立在旧的 ed 编辑器之上。在其中,你基本上给出简短的命令来修改文件,而不是直接操作其内容(尽管你也可以这样做)。有很多命令,但最常见的一个是 s:替换。例如,我们可以写:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed 's/.*Disconnected from //'
我们刚才写的是一个简单的 正则表达式;一个强大的结构,允许你将文本与模式匹配。s 命令的格式是:s/REGEX/SUBSTITUTION/,其中 REGEX 是你想要搜索的正则表达式,而 SUBSTITUTION 是你想要替换匹配文本的文本。
(你可能会从我们的 Vim 讲义的“搜索和替换”部分认出这种语法!实际上,Vim 使用的搜索和替换语法与sed的替换命令类似。学习一个工具通常可以帮助你更熟练地使用其他工具。)
正则表达式
正则表达式很常见且非常有用,因此花些时间去了解它们的工作原理是值得的。让我们先看看我们上面使用的正则表达式:/.*Disconnected from /。正则表达式通常(尽管不总是)被/包围。大多数 ASCII 字符只是携带它们正常的意义,但一些字符有“特殊”的匹配行为。确切地说,哪些字符做什么在不同的正则表达式实现之间有所不同,这是令人非常沮丧的一个原因。非常常见的模式有:
-
.表示“任何单个字符”,除了换行符 -
*匹配前面的匹配项零次或多次 -
+匹配前面的匹配项一次或多次 -
[abc]a、b和c中的任意一个字符 -
(RX1|RX2)匹配RX1或RX2中的任意一个 -
^行首 -
$行尾
sed的正则表达式有些奇怪,并且你需要在这些字符之前加上\来赋予它们特殊意义。或者你可以传递-E选项。
因此,回顾一下/.*Disconnected from /,我们看到它匹配任何以任意数量的字符开始的文本,后面跟着字面字符串“Disconnected from ”。这正是我们想要的。但请注意,正则表达式很复杂。如果有人尝试使用用户名“Disconnected from”登录呢?我们会得到:
Jan 17 03:13:00 thesquareplanet.com sshd[2631]: Disconnected from invalid user Disconnected from 46.97.239.16 port 55920 [preauth]
我们最终会得到什么呢?*和+默认是“贪婪”的。它们会尽可能多地匹配文本。所以,在上面的例子中,我们最终只会得到
46.97.239.16 port 55920 [preauth]
这可能不是我们想要的。在一些正则表达式实现中,你可以在*或+后面加上一个?来使它们变得非贪婪,但遗憾的是sed不支持这一点。不过,我们可以切换到 perl 的命令行模式,它确实支持这种结构:
perl -pe 's/.*?Disconnected from //'
我们将在这部分内容中继续使用sed,因为它是这些类型工作中最常见的工具。sed还可以执行其他一些方便的操作,比如打印给定匹配之后的行,每次调用进行多次替换,搜索内容等。但在这里我们不会过多地介绍这些。sed基本上是一个完整的主题,但通常有更好的工具。
好的,所以我们还有一个后缀想要去除。我们该如何做到这一点呢?匹配用户名后面的文本有点棘手,尤其是如果用户名可以有空格等特殊字符时!我们需要匹配的是整行:
| sed -E 's/.*Disconnected from (invalid |authenticating )?user .* [^ ]+ port [0-9]+( \[preauth\])?$//'
让我们用一个 正则表达式调试器 来看看发生了什么。好的,所以开始部分和之前一样。然后,我们匹配任何“用户”变体(日志中有两个前缀)。然后我们匹配用户名所在的所有字符字符串。然后我们匹配任何单个单词([^ ]+;任何非空的非空格字符序列)。然后是单词“端口”后面跟着一系列数字。然后可能是后缀 [preauth],然后是行尾。
注意,使用这种技术,用户名为“断开连接”的名称不会再让我们困惑了。你能看出为什么吗?
但是这里有一个问题,那就是整个日志都变空了。我们希望 保留 用户名。为此,我们可以使用“捕获组”。任何由正则表达式匹配并包围在括号中的文本都会存储在一个编号的捕获组中。这些在替换(以及在某些引擎中,甚至在模式本身!)中可用,如 \1、\2、\3 等。所以:
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
如你所能想象,你可以想出 非常 复杂的正则表达式。例如,这里有一篇文章介绍了如何匹配 电子邮件地址。这并不容易(链接)。而且有很多 讨论。人们还 编写了测试。还有 测试矩阵。你甚至可以编写一个用于确定给定数字 是否为素数 的正则表达式。
正则表达式通常很难正确使用,但它们也是你的工具箱中非常有用的工具!
回到数据处理
好的,所以我们现在有
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
sed 可以做各种其他有趣的事情,比如插入文本(使用 i 命令),显式打印行(使用 p 命令),按索引选择行,以及其他很多事情。查看 man sed!
无论如何。我们现在得到的是所有尝试登录的用户名列表。但这并没有什么帮助。让我们寻找常见的:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
sort 将按其输入排序。uniq -c 将将连续相同的行合并为单行,并在前面加上出现次数的计数。我们可能还想对它进行排序,并只保留最常见的用户名:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| sort -nk1,1 | tail -n10
sort -n 将按数字(而不是按字典顺序)排序。-k1,1 表示“仅按第一个空格分隔的列排序”。,n 部分表示“排序到第 n 个字段,默认为行尾。在这个 特定 的例子中,按整行排序并不重要,但我们在这里是为了学习!
如果我们想要最不常见的,我们可以用 head 代替 tail。还有 sort -r,它按逆序排序。
好的,那确实很酷,但如果我们只想提取用户名作为逗号分隔的列表,而不是每行一个,可能用于配置文件呢?
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| sort -nk1,1 | tail -n10
| awk '{print $2}' | paste -sd,
如果你正在使用 macOS:请注意,如所示命令在 macOS 中的 BSD paste 中无法正常工作。有关 BSD 和 GNU coreutils 之间的差异以及如何在 macOS 上安装 GNU coreutils 的说明,请参阅壳工具讲座中的练习 4。
让我们从 paste 开始:它允许你通过给定的单个字符分隔符(-d;在这种情况下是 ,)组合行。但这是什么 awk 的事情?
awk – 另一个编辑器
awk 是一种编程语言,它恰好擅长处理文本流。如果你真正学习 awk,有很多东西可以说,但就像这里的其他许多事情一样,我们只会介绍基础知识。
首先,{print $2} 做什么?嗯,awk 程序的形式为可选的模式加上一个块,该块说明如果模式与给定行匹配,则执行什么操作。默认模式(我们上面使用的)匹配所有行。在块内部,$0 被设置为整行内容,而 $1 到 $n 被设置为该行的第 n 个字段,当用 awk 字段分隔符(默认为空白字符)分隔时。在这种情况下,我们说的是,对于每一行,打印第二个字段的内容,碰巧这是用户名!
让我们看看我们能否做一些更复杂的事情。让我们计算以 c 开头并以 e 结尾的单次使用用户名的数量:
| awk '$1 == 1 && $2 ~ /^c[^ ]*e$/ { print $2 }' | wc -l
这里有很多东西要解释。首先,注意我们现在有一个模式({...} 之前的东西)。模式说明行的第一个字段应该等于 1(这是 uniq -c 的计数),第二个字段应该匹配给定的正则表达式。块只是说明打印用户名。然后我们使用 wc -l 来计算输出中的行数。
然而,awk 是一种编程语言,记得吗?
BEGIN { rows = 0 }
$1 == 1 && $2 ~ /^c[^ ]*e$/ { rows += $1 }
END { print rows }
BEGIN 是一个匹配输入开始的模式(END 匹配结束)。现在,按行块只是将第一个字段(尽管在这种情况下总是 1)的计数累加,然后在结束时打印出来。实际上,我们可以完全去掉 grep 和 sed,因为 awk 可以完成所有这些,但我们将把这留作读者的练习。
数据分析
你可以直接在你的 shell 中使用 bc 进行数学运算,这是一个可以从 STDIN 读取的计算器!例如,通过连接每一行中的数字,并用 + 分隔来相加:
| paste -sd+ | bc -l
或者产生更复杂的表达式:
echo "2*($(data | paste -sd+))" | bc -l
你可以通过各种方式获取统计数据。st 非常不错,但如果你已经有了 R:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| awk '{print $1}' | R --no-echo -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'
R 是另一种(奇怪)的编程语言,擅长数据分析以及 绘图。我们不会过多深入细节,但可以说 summary 会为向量打印摘要统计信息,我们创建了一个包含数字输入流的向量,因此 R 给出了我们想要的统计信息!
如果你只想进行一些简单的绘图,gnuplot 是你的朋友:
ssh myserver journalctl
| grep sshd
| grep "Disconnected from"
| sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
| sort | uniq -c
| sort -nk1,1 | tail -n10
| gnuplot -p -e 'set boxwidth 0.5; plot "-" using 1:xtic(2) with boxes'
数据整理以构建论点
有时候,你可能想要进行数据整理以找到基于更长列表中要安装或删除的内容。我们之前讨论的数据整理加上 xargs 可以是一个强大的组合。
例如,正如在讲座中看到的,我可以通过以下命令从我的系统中卸载旧的 Rust 夜间构建版本,通过使用数据整理工具提取旧的构建名称,然后通过 xargs 传递给卸载器:
rustup toolchain list | grep nightly | grep -vE "nightly-x86" | sed 's/-x86.*//' | xargs rustup toolchain uninstall
整理二进制数据
到目前为止,我们主要讨论了文本数据的整理,但管道对于二进制数据同样有用。例如,我们可以使用 ffmpeg 从我们的相机捕获图像,将其转换为灰度,压缩,通过 SSH 发送到远程机器,在那里解压缩,复制,然后显示。
ffmpeg -loglevel panic -i /dev/video0 -frames 1 -f image2 -
| convert - -colorspace gray -
| gzip
| ssh mymachine 'gzip -d | tee copy.jpg | env DISPLAY=:0 feh -'
练习
-
参考这个 简短的交互式正则表达式教程。
-
找出包含至少三个
a且不以's'结尾的单词(在/usr/share/dict/words中)。这些单词中最常见的最后两个字母是什么?sed的y命令或tr程序可能有助于处理大小写不敏感。有多少这样的双字母组合?而且作为一个挑战:哪些组合没有出现? -
要进行原地替换,很诱人做类似
sed s/REGEX/SUBSTITUTION/ input.txt > input.txt的事情。然而,这是一个坏主意,为什么?这是特定于sed吗?使用man sed来找出如何完成这个任务。 -
找出过去十次引导的平均、中位数和最大系统引导时间。在 Linux 上使用
journalctl,在 macOS 上使用log show,查找每次引导开始和结束附近的日志时间戳。在 Linux 上,它们可能看起来像这样:Logs begin at ...和
systemd[577]: Startup finished in ...在 macOS 上,查找:
=== system boot:和
Previous shutdown cause: 5 -
查找在过去三次重启中不共享的引导消息(参见
journalctl的-b标志)。将这个任务分解成多个步骤。首先,找到一种方法来获取过去三次引导的日志。你使用的工具可能有适用的标志来提取引导日志,或者你可以使用sed '0,/STRING/d'来删除所有在匹配STRING的行之前的行。接下来,删除任何始终变化的行部分(如时间戳)。然后,去重输入行并统计每个行数(uniq是你的朋友)。最后,消除任何计数为 3 的行(因为它是所有引导中共享的)。 -
找到一个在线数据集,例如这个,这个,或者可能从这里找到一个。使用
curl获取它,并提取出仅包含两列数值数据。如果你正在获取 HTML 数据,pup可能会有帮助。对于 JSON 数据,尝试使用jq。使用单个命令找到一列的最小值和最大值,并在另一个命令中找到每列总和的差值。
本内容受CC BY-NC-SA许可。
命令行环境
在这次讲座中,我们将探讨几种方法,这些方法可以帮助你在使用 shell 时提高工作效率。我们已经使用 shell 有一段时间了,但我们主要关注执行不同的命令。现在我们将看到如何同时运行多个进程并跟踪它们,如何停止或暂停特定进程,以及如何使进程在后台运行。
我们还将了解不同的方法来改进你的 shell 和其他工具,通过定义别名和使用 dotfiles 进行配置。这两者都可以帮助你节省时间,例如,在所有机器上使用相同的配置,而无需输入长命令。我们将探讨如何使用 SSH 与远程机器协同工作。
作业控制
在某些情况下,你可能需要在进程执行过程中中断它,例如,如果某个命令完成时间过长(例如,搜索非常大的目录结构的find命令)。大多数时候,你可以按Ctrl-C,命令就会停止。但这是如何实际工作的,为什么有时它无法停止进程?
终止一个进程
你的 shell 正在使用一种称为信号的 UNIX 通信机制来向进程传递信息。当进程收到信号时,它会停止执行,处理信号,并根据信号传递的信息可能改变执行流程。因此,信号是软件中断。
在我们的情况下,当按Ctrl-C时,这会提示 shell 向进程发送SIGINT信号。
这是一个捕获SIGINT并忽略它的 Python 程序的简单示例,不再停止。现在我们可以使用SIGQUIT信号来终止这个程序,通过按Ctrl-\。
#!/usr/bin/env python import signal, time
def handler(signum, time):
print("\nI got a SIGINT, but I am not stopping")
signal.signal(signal.SIGINT, handler)
i = 0
while True:
time.sleep(.1)
print("\r{}".format(i), end="")
i += 1
如果我们向这个程序发送两次SIGINT信号,然后是SIGQUIT信号,会发生什么情况。请注意,^是当在终端中输入时显示Ctrl的方式。
$ python sigint.py
24^C
I got a SIGINT, but I am not stopping
26^C
I got a SIGINT, but I am not stopping
30^\[1] 39913 quit python sigint.py
虽然SIGINT和SIGQUIT通常都与终端相关请求相关联,但一个更通用的信号,用于请求进程优雅地退出,是SIGTERM信号。要发送此信号,我们可以使用kill命令,语法为kill -TERM <PID>。
暂停和后台处理进程
信号可以执行除终止进程之外的其他操作。例如,SIGSTOP会暂停一个进程。在终端中,按Ctrl-Z会提示 shell 发送一个SIGTSTP信号,即终端版本的SIGSTOP。
然后,我们可以使用fg或bg命令分别在前台或后台继续暂停的作业。
jobs(jobs)命令列出了与当前终端会话关联的未完成作业。你可以通过它们的 pid(你可以使用pgrep(pgrep)来找出)来引用这些作业。更直观的是,你也可以使用百分号后跟其作业号(由jobs显示)来引用一个进程。要引用最后一个后台作业,你可以使用特殊参数$!。
还有一件事需要知道的是,命令中的&后缀将在后台运行命令,这样你就可以获得提示符,尽管它仍然会使用 shell 的 STDOUT,这可能会很烦人(在这种情况下使用 shell 重定向)。
要将正在运行的程序放入后台,你可以按Ctrl-Z然后按bg。请注意,后台进程仍然是你的终端的子进程,如果你关闭终端,它们将会死亡(这将发送另一个信号,SIGHUP)。为了避免这种情况发生,你可以使用nohup(nohup)运行程序(这是一个忽略SIGHUP的包装器),或者如果进程已经启动,可以使用disown。或者,你可以使用我们在下一节中将要看到的终端复用器。
下面是一个示例会话,以展示一些这些概念。
$ sleep 1000
^Z
[1] + 18653 suspended sleep 1000
$ nohup sleep 2000 &
[2] 18745
appending output to nohup.out
$ jobs
[1] + suspended sleep 1000
[2] - running nohup sleep 2000
$ bg %1
[1] - 18653 continued sleep 1000
$ jobs
[1] - running sleep 1000
[2] + running nohup sleep 2000
$ kill -STOP %1
[1] + 18653 suspended (signal) sleep 1000
$ jobs
[1] + suspended (signal) sleep 1000
[2] - running nohup sleep 2000
$ kill -SIGHUP %1
[1] + 18653 hangup sleep 1000
$ jobs
[2] + running nohup sleep 2000
$ kill -SIGHUP %2
$ jobs
[2] + running nohup sleep 2000
$ kill %2
[2] + 18745 terminated nohup sleep 2000
$ jobs
一个特殊的信号是SIGKILL,因为它不能被进程捕获,并且它将立即终止它。然而,它可能会有不良的副作用,例如留下孤儿子进程。
你可以在这里了解更多关于这些和其他信号的信息,或者输入man signal或kill -l。
终端复用器
当使用命令行界面时,你通常会想要同时运行多个任务。例如,你可能想要同时运行你的编辑器和你的程序。虽然这可以通过打开新的终端窗口来实现,但使用终端复用器是一个更灵活的解决方案。
类似于tmux(tmux)的终端复用器允许你使用面板和标签来复用终端窗口,这样你可以与多个 shell 会话进行交互。此外,终端复用器还允许你在某个时间点之后断开当前终端会话并重新连接。当与远程机器一起工作时,这可以使你的工作流程变得更好,因为它避免了使用nohup和类似技巧的需求。
目前最受欢迎的终端复用器是tmux(tmux)。tmux高度可配置,通过使用相关的快捷键,你可以创建多个标签和面板,并快速在其中导航。
tmux期望你知道它的快捷键,它们的形式都是<C-b> x,这意味着(1)按Ctrl+b,(2)释放Ctrl+b,然后(3)按x。tmux有以下对象层次结构:
-
会话 - 会话是一个包含一个或多个窗口的独立工作空间
-
tmux启动一个新会话。 -
tmux new -s NAME以该名称启动它。 -
tmux ls列出当前会话 -
在
tmux中,输入<C-b> d可以断开当前会话 -
tmux a连接到最后一个会话。您可以使用-t标志来指定
-
-
窗口 - 与编辑器或浏览器中的标签页相当,它们是同一会话的视觉上分离的部分
-
<C-b> c创建一个新窗口。要关闭它,你可以简单地终止 shell,执行<C-d> -
<C-b> N跳转到第 N 个窗口。注意它们是编号的 -
<C-b> p跳转到上一个窗口 -
<C-b> n跳转到下一个窗口 -
<C-b> ,重命名当前窗口 -
<C-b> w列出当前窗口
-
-
面板 - 与 vim 的分割类似,面板允许您在同一视觉显示中拥有多个 shell。
-
<C-b> "水平分割当前面板 -
<C-b> %垂直分割当前面板 -
<C-b> <direction>移动到指定 方向 的面板。方向在这里意味着箭头键。 -
<C-b> z切换当前面板的缩放 -
<C-b> [开始滚动。然后您可以按<space>开始选择,按<enter>复制该选择。 -
<C-b> <space>在面板布局之间循环。
-
对于进一步阅读,这里 是关于 tmux 的快速教程,这个 有更详细的解释,涵盖了原始的 screen 命令。您可能还想熟悉一下 [screen](https://www.man7.org/linux/man-pages/man1/screen.1.html),因为它安装在大多数 UNIX 系统中。
别名
输入涉及许多标志或详细选项的长命令可能会变得令人厌烦。因此,大多数 shell 都支持 别名 功能。shell 别名是另一个命令的简写形式,shell 会自动为您替换。例如,bash 中的别名具有以下结构:
alias alias_name="command_to_alias arg1 arg2"
注意,等号 = 周围没有空格,因为 [alias](https://www.man7.org/linux/man-pages/man1/alias.1p.html) 是一个接受单个参数的 shell 命令。
别名具有许多方便的功能:
# Make shorthands for common flags
alias ll="ls -lh"
# Save a lot of typing for common commands
alias gs="git status"
alias gc="git commit"
alias v="vim"
# Save you from mistyping
alias sl=ls
# Overwrite existing commands for better defaults
alias mv="mv -i" # -i prompts before overwrite
alias mkdir="mkdir -p" # -p make parent dirs as needed
alias df="df -h" # -h prints human readable format
# Alias can be composed
alias la="ls -A"
alias lla="la -l"
# To ignore an alias run it prepended with \
\ls
# Or disable an alias altogether with unalias
unalias la
# To get an alias definition just call it with alias
alias ll
# Will print ll='ls -lh'
注意,别名默认不会持久化 shell 会话。要使别名持久化,您需要将其包含在 shell 启动文件中,如 .bashrc 或 .zshrc,我们将在下一节介绍。
配置文件
Many programs are configured using plain-text files known as dotfiles (because the file names begin with a ., e.g. ~/.vimrc, so that they are hidden in the directory listing ls by default).
Shells are one example of programs configured with such files. On startup, your shell will read many files to load its configuration. Depending on the shell, whether you are starting a login and/or interactive the entire process can be quite complex. Here is an excellent resource on the topic.
对于 bash,编辑您的 .bashrc 或 .bash_profile 在大多数系统中都会起作用。在这里,您可以包括在启动时想要运行的命令,比如我们刚才描述的别名或对您的 PATH 环境变量的修改。实际上,许多程序会要求您在 shell 配置文件中包含一行类似 export PATH="$PATH:/path/to/program/bin" 的内容,以便它们的二进制文件可以被找到。
一些可以通过 dotfiles 配置的工具的其他示例包括:
-
bash-~/.bashrc,~/.bash_profile -
git-~/.gitconfig -
vim-~/.vimrc和~/.vim文件夹 -
ssh-~/.ssh/config -
tmux-~/.tmux.conf
您应该如何组织您的 dotfiles?它们应该放在自己的文件夹中,置于版本控制之下,并使用脚本链接到适当的位置。这样做有以下好处:
-
易于安装:如果您登录到一台新机器,应用您的自定义设置只需一分钟。
-
便携性:您的工具将在任何地方以相同的方式工作。
-
同步:您可以在任何地方更新您的 dotfiles 并保持它们同步。
-
变更跟踪:您可能在整个编程生涯中维护您的 dotfiles,对于长期项目来说,版本历史记录是非常有用的。
您应该在 dotfiles 中放置什么内容?您可以通过阅读在线文档或 man 页面 了解您的工具设置。另一种很好的方法是搜索有关特定程序的博客文章,作者会告诉您他们偏好的自定义设置。还有另一种了解自定义设置的方法是查看其他人的 dotfiles:您可以在 GitHub 上找到大量的 dotfiles 存储库 — 请参阅最受欢迎的一个 这里(我们建议您不要盲目复制配置)。这里 是关于此主题的另一个很好的资源。
所有课程讲师的 dotfiles 都可以在 GitHub 上公开访问:Anish,Jon,Jose。
便携性
使用 dotfiles 的一种常见问题是,当与多台机器一起工作时,配置可能无法正常工作,例如,如果它们有不同的操作系统或 shell。有时您也只想在特定机器上应用某些配置。
有一些技巧可以使这更容易。如果配置文件支持,请使用类似 if 语句的等效功能来应用特定于机器的自定义设置。例如,您的 shell 可以有如下内容:
if [[ "$(uname)" == "Linux" ]]; then {do_something}; fi
# Check before using shell-specific features
if [[ "$SHELL" == "zsh" ]]; then {do_something}; fi
# You can also make it machine-specific
if [[ "$(hostname)" == "myServer" ]]; then {do_something}; fi
如果配置文件支持,请使用包含功能。例如,一个 ~/.gitconfig 可以有一个设置:
[include]
path = ~/.gitconfig_local
然后在每台机器上,~/.gitconfig_local 可以包含特定于机器的设置。您甚至可以将这些设置跟踪在单独的存储库中。
如果你想让不同的程序共享一些配置,这个想法也很有用。例如,如果你想让 bash 和 zsh 共享同一组别名,你可以在 .aliases 下写入它们,并在两个中都有以下块:
# Test if ~/.aliases exists and source it
if [ -f ~/.aliases ]; then source ~/.aliases
fi
远程机器
对于程序员来说,在日常工作中使用远程服务器变得越来越普遍。如果你需要使用远程服务器来部署后端软件或者你需要一个具有更高计算能力的服务器,你最终会使用安全外壳(SSH)。与大多数工具一样,SSH 具有高度的可配置性,因此了解它是有价值的。
要通过 ssh 连接到服务器,你执行以下命令
ssh foo@bar.mit.edu
在这里,我们尝试以用户 foo 的身份在服务器 bar.mit.edu 上进行 ssh 连接。服务器可以通过 URL(如 bar.mit.edu)或 IP 地址(例如 foobar@192.168.1.42)指定。稍后我们将看到,如果我们修改 ssh 配置文件,你可以仅使用类似 ssh bar 的方式访问。
执行命令
ssh 的一个常被忽视的功能是能够直接运行命令。ssh foobar@server ls 将在 foobar 的家目录中执行 ls 命令。它与管道一起工作,所以 ssh foobar@server ls | grep PATTERN 将在本地 grep ls 的远程输出,而 ls | ssh foobar@server grep PATTERN 将在远程 grep ls 的本地输出。
SSH 密钥
基于密钥的认证利用公钥加密技术向服务器证明客户端拥有秘密私钥,而不泄露密钥。这样你就不需要每次都重新输入密码。然而,私钥(通常是 ~/.ssh/id_rsa,最近更多是 ~/.ssh/id_ed25519)实际上就是你的密码,所以要像对待密码一样对待它。
密钥生成
要生成一对密钥,你可以运行 ssh-keygen。
ssh-keygen -a 100 -t ed25519 -f ~/.ssh/id_ed25519
你应该选择一个密码短语,以防止有人获取你的私钥来访问授权服务器。使用 ssh-agent 或 gpg-agent,这样你就不必每次都输入密码短语。
如果你曾经使用 SSH 密钥配置 GitHub 推送,那么你可能已经完成了这里概述的步骤,并且已经有一个有效的密钥对。要检查你是否有一个密码短语并验证它,你可以运行 ssh-keygen -y -f /path/to/key。
基于密钥的认证
ssh 会查看 .ssh/authorized_keys 以确定它应该允许哪些客户端进入。要复制一个公钥,你可以使用:
cat .ssh/id_ed25519.pub | ssh foobar@remote 'cat >> ~/.ssh/authorized_keys'
如果可用,可以使用 ssh-copy-id 实现一个更简单的解决方案:
ssh-copy-id -i .ssh/id_ed25519 foobar@remote
通过 SSH 复制文件
有许多方法可以通过 ssh 复制文件:
-
ssh+tee,最简单的方法是使用ssh命令执行和 STDIN 输入,执行cat localfile | ssh remote_server tee serverfile。回想一下,tee将 STDIN 的输出写入文件。 -
scp在复制大量文件/目录时,由于它可以轻松地对路径进行递归,所以安全的复制scp命令更方便。语法是scp 本地路径/本地文件 远程主机:远程路径/远程文件 -
rsync通过检测本地和远程的相同文件,并防止再次复制它们来改进scp。它还提供了对符号链接、权限的更精细控制,并具有像--partial标志这样的额外功能,可以从之前中断的复制中恢复。rsync的语法与scp类似。
端口转发
在许多场景中,你可能会遇到在机器上监听特定端口的软件。当这种情况发生在你的本地机器上时,你可以输入 localhost:PORT 或 127.0.0.1:PORT,但对于没有通过网络/互联网直接提供端口的远程服务器,你该怎么办呢?
这被称为 端口转发,它有两种形式:本地端口转发和远程端口转发(见图片以获取更多详细信息,图片来自 这篇 StackOverflow 帖子)。
本地端口转发 
远程端口转发 
最常见的场景是本地端口转发,其中远程机器上的服务在一个端口上监听,你想要将你的本地机器上的一个端口链接到远程端口。例如,如果我们在一个监听端口 8888 的远程服务器上执行 jupyter notebook。因此,要将它转发到本地端口 9999,我们会这样做 ssh -L 9999:localhost:8888 foobar@remote_server,然后在我们的本地机器上导航到 localhost:9999。
SSH 配置
我们已经介绍了许多我们可以传递的参数。一个诱人的替代方案是创建看起来像
alias my_server="ssh -i ~/.id_ed25519 --port 2222 -L 9999:localhost:8888 foobar@remote_server"
然而,有一个更好的替代方案使用 ~/.ssh/config。
Host vm
User foobar
HostName 172.16.174.141
Port 2222
IdentityFile ~/.ssh/id_ed25519
LocalForward 9999 localhost:8888
# Configs can also take wildcards
Host *.mit.edu
User foobaz
使用 ~/.ssh/config 文件而不是别名的另一个优点是,其他程序如 scp、rsync、mosh 等也能够读取它并将设置转换为相应的标志。
注意,~/.ssh/config 文件可以被视为点文件,通常情况下将其包含在其他点文件中是没问题的。然而,如果你将其公开,要考虑你可能会提供给互联网上陌生人的信息:你的服务器、用户、开放端口等。这可能会方便某些类型的攻击,因此在分享你的 SSH 配置时要三思。
服务器端配置通常在/etc/ssh/sshd_config中指定。在这里,你可以进行更改,如禁用密码认证、更改 ssh 端口、启用 X11 转发等。你可以根据每个用户指定配置设置。
杂项
连接到远程服务器时常见的痛苦是因你的电脑关闭、进入睡眠状态或更改网络而断开连接。此外,如果有一个有显著延迟的连接,使用 ssh 可能会变得相当令人沮丧。Mosh,移动壳,在 ssh 的基础上进行了改进,允许漫游连接、间歇性连接并提供智能本地回显。
有时挂载远程文件夹很方便。sshfs可以在本地挂载远程服务器上的文件夹,然后你可以使用本地编辑器。
Shell 与框架
在介绍 shell 工具和脚本时,我们介绍了bash shell,因为它是最普遍的 shell,大多数系统都将其作为默认选项。然而,它并不是唯一的选择。
例如,zsh shell 是bash的超集,并提供了许多开箱即用的便利功能,例如:
-
智能通配符,
** -
内联通配符/通配符展开
-
拼写纠正
-
更好的标签完成/选择
-
路径展开(
cd /u/lo/b将展开为/usr/local/bin)
框架也可以改善你的 shell。一些流行的通用框架包括prezto或oh-my-zsh,以及专注于特定功能的较小框架,如zsh-syntax-highlighting或zsh-history-substring-search。像fish这样的 shell 默认包含了许多用户友好的功能。其中一些功能包括:
-
右侧提示符
-
命令语法高亮
-
历史子串搜索
-
基于手册页的标志完成
-
智能自动完成
-
提示主题
使用这些框架时要注意的一点是,它们可能会减慢你的 shell 速度,特别是如果它们运行的代码没有正确优化,或者代码量过多。你总是可以对其进行性能分析,并禁用不常用或不如速度重要的功能。
终端模拟器
除了自定义你的 shell,花些时间了解你的终端模拟器及其设置也是值得的。市面上有各种各样的终端模拟器(这里有一个比较)。
由于你可能会在终端上花费数百到数千小时,因此查看其设置是值得的。你可能想在终端中修改的一些方面包括:
练习
任务控制
-
从我们所看到的,我们可以使用一些
ps aux | grep命令来获取我们的作业的进程 ID,然后杀死它们,但还有更好的方法。在终端中启动一个sleep 10000作业,使用Ctrl-Z将其置于后台,然后继续执行。现在使用pgrep来找到它的 pid,使用pkill来杀死它,而无需输入 pid 本身。(提示:使用-af标志)。 -
假设你不想在另一个进程完成之前启动一个进程。你将如何操作?在这个练习中,我们的限制进程始终是
sleep 60 &。实现这一目标的一种方法是用[wait](https://www.man7.org/linux/man-pages/man1/wait.1p.html)命令。尝试启动 sleep 命令,并让ls`等待后台进程完成。然而,如果我们从一个不同的 bash 会话开始,这个策略就会失败,因为
wait命令只对子进程有效。笔记中没有讨论的一个特性是,kill命令的退出状态在成功时为零,否则为非零。kill -0不会发送信号,但如果进程不存在,将给出非零的退出状态。编写一个名为pidwait的 bash 函数,它接受一个进程 ID(pid)并等待直到指定的进程完成。你应该使用sleep来避免不必要的 CPU 浪费。
Terminal multiplexer
Aliases
-
创建一个别名
dc,当输入错误时解析为cd。 -
运行
history | awk '{$1="";print substr($0,2)}' | sort | uniq -c | sort -n | tail -n 10来获取你使用最多的前 10 个命令,并考虑为它们编写更短的别名。注意:这适用于 Bash;如果你使用 ZSH,请使用history 1而不是仅仅history。
Dotfiles
让我们让你熟悉一下 dotfiles。
-
为你的 dotfiles 创建一个文件夹并设置版本控制。
-
至少为其中一个程序添加配置,例如你的 shell,并做一些定制(为了开始,这可以是通过设置
$PS1来自定义你的 shell 提示符这样简单的事情)。 -
设置一种方法,以便快速(且无需手动操作)在新机器上安装你的 dotfiles。这可以是一个简单的 shell 脚本,它为每个文件调用
ln -s,或者你可以使用一个专用工具。 -
在一个全新的虚拟机上测试你的安装脚本。
-
将你当前的所有工具配置迁移到你的 dotfiles 仓库中。
-
在 GitHub 上发布你的 dotfiles。
Remote Machines
安装一个 Linux 虚拟机(或使用已经存在的虚拟机)进行这个练习。如果你不熟悉虚拟机,请查看这个教程来安装一个。
-
前往
~/.ssh/并检查你是否在那里有一对 SSH 密钥。如果没有,使用ssh-keygen -a 100 -t ed25519生成它们。建议你使用密码,并使用ssh-agent,更多信息这里。 -
编辑
.ssh/config以添加以下条目Host vm User username_goes_here HostName ip_goes_here IdentityFile ~/.ssh/id_ed25519 LocalForward 9999 localhost:8888 -
使用
ssh-copy-id vm将你的 SSH 密钥复制到服务器。 -
在你的虚拟机中通过执行
python -m http.server 8888启动一个 web 服务器。通过在你的机器上导航到http://localhost:9999来访问虚拟机 web 服务器。 -
通过执行
sudo vim /etc/ssh/sshd_config编辑你的 SSH 服务器配置,并通过编辑PasswordAuthentication的值来禁用密码认证。通过编辑PermitRootLogin的值来禁用 root 登录。使用sudo service sshd restart重启ssh服务。再次尝试 SSH 登录。 -
(挑战) 在虚拟机中安装 mosh 并建立连接。然后断开服务器/虚拟机的网络适配器。mosh 是否能正确恢复?
-
(挑战) 查看在
ssh中-N和-f标志的作用,并找出一个命令来实现后台端口转发。
本内容受 CC BY-NC-SA 许可。
版本控制(Git)
版本控制系统(VCSs)是用于跟踪源代码(或其他文件和文件夹集合)更改的工具。正如其名所示,这些工具有助于维护更改的历史记录;此外,它们促进了协作。VCSs 通过一系列快照跟踪文件夹及其内容的更改,其中每个快照封装了顶层目录内文件/文件夹的整个状态。VCSs 还维护诸如谁创建了每个快照、与每个快照相关的消息等元数据。
为什么版本控制有用?即使你一个人工作,它也可以让你查看项目的旧快照,记录为什么做出某些更改,并行开发分支,等等。当与他人合作时,它是一个无价之宝,可以查看其他人所做的更改,以及在并发开发中解决冲突。
现代版本控制系统(VCSs)也让你可以轻松(并且经常自动)回答如下问题:
-
谁编写了这个模块?
-
这个特定文件中的特定行是什么时候被编辑的?由谁编辑的?为什么编辑?
-
在过去 1000 次修订中,何时以及为什么某个特定单元测试停止工作?
虽然存在其他版本控制系统,但Git是版本控制的默认标准。这个XKCD 漫画捕捉了 Git 的声誉:

因为 Git 的界面是一个有漏洞的抽象,从上至下学习 Git(从其界面/命令行界面开始)可能会导致很多困惑。人们可以记住一些命令,并将它们视为魔法咒语,并在漫画中描述的方法出现问题时遵循。
虽然 Git 的界面确实很丑陋,但其底层设计和理念是美丽的。虽然丑陋的界面需要记忆,但美丽的设计可以理解。因此,我们从下至上解释 Git,从其数据模型开始,然后涵盖命令行界面。一旦理解了数据模型,就可以更好地从它们如何操作底层数据模型的角度来理解命令。
Git 的数据模型
你可以采取许多临时性的方法来进行版本控制。Git 有一个经过深思熟虑的模型,它使得版本控制的所有良好功能都成为可能,例如维护历史记录、支持分支和促进协作。
快照
Git 将顶层目录内的一组文件和文件夹的历史记录建模为一系列快照。在 Git 术语中,文件被称为“blob”,它只是一堆字节。目录被称为“tree”,它将名称映射到 blob 或 tree(因此目录可以包含其他目录)。快照是正在跟踪的顶级树。例如,我们可能有一个如下所示的树:
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")
顶层树包含两个元素,一个名为“foo”的树(它本身包含一个元素,一个 blob“bar.txt”),和一个 blob“baz.txt”。
历史建模:关联快照
版本控制系统应该如何关联快照?一个简单的模型可能是有一个线性历史。历史将是一系列按时间顺序排列的快照。由于许多原因,Git 不使用这种简单的模型。
在 Git 中,历史是一系列快照的定向无环图(DAG)。这可能听起来像是一个复杂的数学术语,但不要感到害怕。这仅仅意味着 Git 中的每个快照都引用一组“父代”,即先于它的快照。它是一组父代而不是单个父代(就像线性历史中那样),因为一个快照可能来自多个父代,例如,由于合并(合并)两个平行的开发分支。
Git 将这些快照称为“提交”。可视化提交历史可能看起来像这样:
o <-- o <-- o <-- o
^
\
--- o <-- o
在上面的 ASCII 艺术中,o对应于单个提交(快照)。箭头指向每个提交的父代(这是一个“先于”关系,而不是“后于”关系)。在第三个提交之后,历史分支成两个独立的分支。这可能对应于,例如,两个独立开发的功能。在未来,这些分支可能会合并以创建一个新的快照,该快照包含这两个功能,产生一个新的历史,如下所示,新创建的合并提交以粗体显示:
o <-- o <-- o <-- o <---- **o**
^ /
\ v
--- o <-- o
Git 中的提交是不可变的。但这并不意味着错误无法被纠正;只是对提交历史的“编辑”实际上是在创建全新的提交,并且引用(见下文)被更新以指向新的提交。
数据模型,伪代码形式
看看 Git 的数据模型用伪代码写下来可能会有所帮助:
// a file is a bunch of bytes
type blob = array<byte>
// a directory contains named files and directories
type tree = map<string, tree | blob>
// a commit has parents, metadata, and the top-level tree
type commit = struct {
parents: array<commit>
author: string
message: string
snapshot: tree
}
这是一个干净、简单的历史模型。
对象和内容寻址
“对象”是一个 blob、tree 或提交:
type object = blob | tree | commit
在 Git 数据存储中,所有对象都通过它们的SHA-1 哈希进行内容寻址。
objects = map<string, object>
def store(object):
id = sha1(object)
objects[id] = object
def load(id):
return objects[id]
Blob、tree 和提交以这种方式统一:它们都是对象。当它们引用其他对象时,它们实际上并不在磁盘表示中“包含”它们,而是通过它们的哈希值引用它们。
例如,示例目录结构上面的树(使用git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d可视化),看起来像这样:
100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85 baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87 foo
树本身包含对其内容的指针,baz.txt(一个 blob)和foo(一个 tree)。如果我们用git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85查看对应于 baz.txt 的哈希值的内容,我们得到以下结果:
git is wonderful
引用
现在,所有快照都可以通过它们的 SHA-1 哈希值来识别。这很不方便,因为人类不擅长记住由 40 个十六进制字符组成的字符串。
Git 解决这个问题的方法是使用人类可读的名称来表示 SHA-1 散列,称为“引用”。引用是指向提交的指针。与不可变的对象不同,引用是可变的(可以更新以指向新的提交)。例如,master引用通常指向开发主分支中的最新提交。
references = map<string, string>
def update_reference(name, id):
references[name] = id
def read_reference(name):
return references[name]
def load_reference(name_or_id):
if name_or_id in references:
return load(references[name_or_id])
else:
return load(name_or_id)
这样,Git 可以使用人类可读的名称,如“master”,来引用历史中的一个特定快照,而不是一个长的十六进制字符串。
一个细节是,我们通常希望在历史中有一个“我们现在在哪里”的概念,这样当我们拍摄一个新的快照时,我们就知道它是相对于什么的(如何设置提交的parents字段)。在 Git 中,“我们现在在哪里”是一个特殊的引用,称为“HEAD”。
仓库
最后,我们可以定义(大致上)什么是 Git 仓库:它是数据objects和references。
在磁盘上,所有 Git 存储都是对象和引用:这就是 Git 数据模型的所有内容。所有git命令都映射到通过添加对象和添加/更新引用对提交 DAG 进行某种操作。
每次你输入任何命令时,都要考虑该命令对底层图数据结构进行的操作。相反,如果你试图对提交 DAG 进行某种特定的更改,例如“丢弃未提交的更改并使‘master’引用指向提交5d83f9e”,可能有一个命令可以做到这一点(例如,在这种情况下,git checkout master; git reset --hard 5d83f9e)。
暂存区
这是一个与数据模型正交的概念,但它构成了创建提交的接口的一部分。
你可能想象的一种实现上述快照的方法是有一个“创建快照”命令,该命令基于工作目录的当前状态创建一个新的快照。一些版本控制工具就是这样工作的,但 Git 不是。我们想要干净的快照,并且从当前状态创建快照可能并不总是理想的。例如,想象一个场景,你实现了两个独立的功能,并且想要创建两个独立的提交,第一个提交引入了第一个功能,下一个提交引入了第二个功能。或者想象一个场景,你在代码中添加了调试打印语句,并修复了一个错误;你想要提交错误修复,同时丢弃所有的打印语句。
Git 通过允许您通过称为“暂存区”的机制指定哪些修改应包含在下一个快照中,来适应这种场景。
Git 命令行界面
为了避免重复信息,我们不会详细解释下面的命令。请参阅高度推荐的Pro Git以获取更多信息,或观看讲座视频。
基础知识
-
git help <command>:获取 git 命令的帮助 -
git init:创建一个新的 git 仓库,数据存储在.git目录中 -
git status:告诉你发生了什么 -
git add <filename>:将文件添加到暂存区 -
git commit:创建一个新的提交 -
git log:显示简化的历史日志 -
git log --all --graph --decorate:将历史可视化为一个有向无环图(DAG) -
git diff <文件名>:显示相对于暂存区的更改 -
git diff <修订> <文件名>:显示快照之间文件的差异 -
git checkout <修订>:更新 HEAD(如果检出分支,则更新当前分支)
分支和合并
-
git branch:显示分支 -
git branch <名称>:创建一个分支 -
git checkout -b <名称>:创建一个分支并切换到它- 等同于
git branch <名称>; git checkout <名称>
- 等同于
-
git merge <修订>:合并到当前分支 -
git mergetool:使用一个花哨的工具来帮助解决合并冲突 -
git rebase:将一系列补丁重新基于新的基础
远程仓库
-
git remote:列出远程仓库 -
git remote add <名称> <url>:添加一个远程仓库 -
git push <远程> <本地分支>:<远程分支>:发送对象到远程,并更新远程引用 -
git branch --set-upstream-to=<远程>/<远程分支>:设置本地分支和远程分支之间的对应关系 -
git fetch:从远程检索对象/引用 -
git pull:等同于git fetch; git merge -
git clone:从远程下载仓库
撤销
-
git commit --amend:编辑提交的内容/信息 -
git reset HEAD <文件>:取消暂存文件 -
git checkout -- <文件>:放弃更改
高级 Git
-
git config:Git 是高度可定制的 -
git clone --depth=1:浅克隆,不包含整个版本历史 -
git add -p:交互式暂存 -
git rebase -i:交互式变基 -
git blame:显示谁最后编辑了哪一行 -
git stash:临时移除工作目录的修改 -
git bisect:二分搜索历史(例如用于回归) -
.gitignore:指定有意不跟踪的文件以忽略
杂项
-
GUIs:Git 有很多图形界面客户端。我们个人不使用它们,而是使用命令行界面。
-
Shell 集成:将 Git 状态作为 shell 提示符的一部分非常方便(zsh, bash)。通常包含在像Oh My Zsh这样的框架中。
-
编辑器集成:类似于上述内容,有很多方便的集成和功能。fugitive.vim是 Vim 的标准之一。
-
工作流程:我们教了你数据模型和一些基本命令;我们没有告诉你在大项目上工作时应该遵循哪些实践(而且有 很多 不同的 方法)。
-
GitHub: Git 并非 GitHub。GitHub 有一种特定的方式向其他项目贡献代码,称为 pull requests。
-
其他 Git 提供商: GitHub 并非特殊:有许多 Git 仓库托管平台,如 GitLab 和 BitBucket。
资源
-
Pro Git 是 强烈推荐阅读 的。现在你理解了数据模型,通过阅读第 1-5 章,你应该可以学会如何熟练使用 Git。后面的章节有一些有趣的高级材料。
-
Oh Shit, Git!?! 是一份关于如何从一些常见的 Git 错误中恢复的简短指南。
-
Git for Computer Scientists 是对 Git 数据模型的一种简短解释,比这些讲义有更少的伪代码和更多的复杂图表。
-
从底层理解 Git 是对 Git 实现细节的详细解释,不仅限于数据模型,适合好奇心强的人。
-
Learn Git Branching 是一个基于浏览器的游戏,教你 Git。
练习
-
如果你没有 Git 的过往经验,要么尝试阅读 Pro Git 的前几章,要么通过一个教程,如 Learn Git Branching。在阅读过程中,将 Git 命令与数据模型联系起来。
-
克隆 班级网站仓库。
-
通过将版本历史可视化成图表来探索版本历史。
-
最后修改
README.md的人是谁?(提示:使用带有参数的git log)。 -
与
_config.yml文件中collections:行的最后一次修改相关的提交信息是什么?(提示:使用git blame和git show)。
-
-
学习 Git 时常见的错误之一是提交应该由 Git 管理的大型文件或添加敏感信息。尝试将一个文件添加到仓库中,进行一些提交,然后从历史记录中删除该文件(你可能想查看 这个)。
-
从 GitHub 克隆一些仓库,并修改其现有文件之一。当你执行
git stash时会发生什么?运行git log --all --oneline时你会看到什么?通过执行git stash pop来撤销你用git stash所做的操作。在什么情况下这可能是有用的? -
像许多命令行工具一样,Git 提供了一个名为
~/.gitconfig的配置文件(或点文件)。在~/.gitconfig中创建一个别名,以便当你运行git graph时,你得到git log --all --graph --decorate --oneline的输出。你可以直接编辑~/.gitconfig文件,或者你可以使用git config命令来添加别名。有关 git 别名的信息可以在这里找到。 -
在运行
git config --global core.excludesfile ~/.gitignore_global之后,你可以在~/.gitignore_global中定义全局忽略模式。这设置了 Git 将使用的全局忽略文件的位置,但你仍然需要手动在该路径创建文件。设置你的全局 gitignore 文件以忽略特定于操作系统或编辑器的临时文件,如.DS_Store。 -
分叉班级网站仓库,找到一个错误或你可以进行的其他改进,并在 GitHub 上提交一个 pull request(你可能想看看这个)。请只提交有用的 PR(请不要发垃圾邮件,谢谢!)如果你找不到可以改进的地方,你可以跳过这个练习。
根据CC BY-NC-SA许可。
调试和性能分析
编程中的一个黄金法则是代码不会做你期望它做的事情,而是做你告诉它做的事情。弥合这个差距有时可能是一项相当困难的任务。在本讲座中,我们将介绍处理有缺陷和资源密集型代码的有用技术:调试和性能分析。
调试
Printf 调试和日志记录
“最有效的调试工具仍然是仔细思考,辅以恰当地放置的打印语句”—— 布莱恩·柯林汉,《Unix 初学者指南》。
调试程序的第一种方法是添加打印语句在你检测到问题的周围,并持续迭代,直到你提取出足够的信息来理解导致问题的原因。
第二种方法是使用程序中的日志记录,而不是临时的打印语句。日志记录比常规的打印语句有以下几个优点:
-
你可以将日志记录到文件、套接字甚至远程服务器,而不是标准输出。
-
日志支持严重级别(如 INFO、DEBUG、WARN、ERROR 等),这允许你相应地过滤输出。
-
对于新问题,你的日志很可能包含足够的信息来检测出问题所在。
这里是一个记录消息的示例代码:
$ python logger.py
# Raw output as with just prints
$ python logger.py log
# Log formatted output
$ python logger.py log ERROR
# Print only ERROR levels and above
$ python logger.py color
# Color formatted output
我最喜欢的使日志更易读的小技巧之一是给它们上色。到现在你可能已经意识到你的终端使用颜色来使内容更易读。但是它是如何做到的呢?像ls或grep这样的程序正在使用ANSI 转义码,这是一系列特殊的字符序列,用来指示你的 shell 改变输出的颜色。例如,执行echo -e "\e[38;2;255;0;0mThis is red\e[0m"会在你的终端上以红色打印消息This is red,只要它支持真彩色。如果你的终端不支持这个(例如 macOS 的 Terminal.app),你可以使用更广泛支持的 16 种颜色的转义码,例如 echo -e "\e[31;1mThis is red\e[0m"。
以下脚本展示了如何将多种 RGB 颜色打印到你的终端(再次强调,只要它支持真彩色)。
#!/usr/bin/env bash
for R in $(seq 0 20 255); do
for G in $(seq 0 20 255); do
for B in $(seq 0 20 255); do printf "\e[38;2;${R};${G};${B}m█\e[0m";
done
done
done
第三方日志
当你开始构建更大的软件系统时,你很可能会遇到作为独立程序运行的依赖项。Web 服务器、数据库或消息代理是这类依赖项的常见例子。与这些系统交互时,通常需要读取它们的日志,因为客户端的错误消息可能不足以说明问题。
幸运的是,大多数程序会在你的系统中某个地方记录自己的日志。在 UNIX 系统中,程序在/var/log下记录日志是很常见的。例如,NGINX 网络服务器将其日志放在/var/log/nginx下。最近,系统开始使用系统日志,这越来越成为所有日志消息的存放地。大多数(但不是所有)Linux 系统使用systemd,这是一个系统守护进程,它控制着你的系统中的许多事情,比如哪些服务被启用和运行。systemd将日志放在/var/log/journal下,并采用专门的格式,你可以使用[journalctl](https://www.man7.org/linux/man-pages/man1/journalctl.1.html)命令来显示消息。同样,在 macOS 上,仍然有/var/log/system.log,但越来越多的工具使用系统日志,可以使用log show](https://www.manpagez.com/man/1/log/)来显示。在大多数 UNIX 系统中,你也可以使用[dmesg`命令来访问内核日志。
对于在系统日志下进行日志记录,你可以使用[logger](https://www.man7.org/linux/man-pages/man1/logger.1.html) shell 程序。以下是一个使用logger`的示例以及如何检查条目是否已进入系统日志。此外,大多数编程语言都有绑定到系统日志的日志记录功能。
logger "Hello Logs"
# On macOS
log show --last 1m | grep Hello
# On Linux
journalctl --since "1m ago" | grep Hello
正如我们在数据处理讲座中看到的,日志可能非常冗长,它们需要一定程度的处理和过滤才能获取你想要的信息。如果你发现自己正在大量过滤journalctl和log show,你可以考虑使用它们的标志,这些标志可以对它们的输出进行初步的过滤。还有一些工具,如[lnav](https://lnav.org/)`,提供了对日志文件改进的展示和导航。
调试器
当 printf 调试不足以满足需求时,你应该使用调试器。调试器是允许你与程序执行交互的程序,它允许以下操作:
-
当程序到达某一特定行时停止执行。
-
逐条指令地执行程序。
-
在程序崩溃后检查变量的值。
-
当满足给定条件时,有条件地停止执行。
-
许多更多高级功能
许多编程语言都附带某种形式的调试器。在 Python 中,这是 Python 调试器[pdb](https://docs.python.org/3/library/pdb.html)`。
下面是pdb支持的命令的简要描述:
-
l(ist) - 显示当前行周围的 11 行或继续之前的列表。
-
s(tep) - 执行当前行,并在第一个可能的情况下停止。
-
n(ext) - 继续执行,直到当前函数中的下一行或它返回。
-
b(reak) - 设置断点(取决于提供的参数)。
-
p(rint) - 在当前上下文中评估表达式并打印其值。还有一个 pp,用于使用
[pprint](https://docs.python.org/3/library/pprint.html)显示。 -
r(eturn) - 继续执行直到当前函数返回。
-
q(uit) - 退出调试器。
让我们通过一个使用 pdb 修复以下有问题的 Python 代码的示例。 (参见讲座视频)。
def bubble_sort(arr):
n = len(arr)
for i in range(n):
for j in range(n):
if arr[j] > arr[j+1]:
arr[j] = arr[j+1]
arr[j+1] = arr[j]
return arr
print(bubble_sort([4, 2, 1, 8, 7, 6]))
注意,由于 Python 是一种解释型语言,我们可以使用 pdb 命令行来执行命令和指令。[ipdb](https://pypi.org/project/ipdb/) 是一个改进的 pdb,它使用 IPython REPL,支持自动完成、语法高亮、更好的跟踪和更好的内省,同时保留了与 pdb 模块相同的接口。
对于更底层的编程,你可能需要考虑 [gdb](https://www.gnu.org/software/gdb/)(及其生活质量改进版 pwndbg)和 [lldb`](https://lldb.llvm.org/)。它们针对 C 类语言调试进行了优化,但几乎可以让你探测任何进程并获取其当前机器状态:寄存器、堆栈、程序计数器等。
专用工具
即使你试图调试的是一个黑盒二进制文件,也有工具可以帮助你。当程序需要执行只有内核才能执行的操作时,它们会使用 系统调用。有一些命令可以跟踪程序执行的系统调用。在 Linux 中有 [strace](https://www.man7.org/linux/man-pages/man1/strace.1.html),而在 macOS 和 BSD 中有 [dtrace](https://dtrace.org/about/)。dtrace使用其自己的D语言,可能难以使用,但有一个名为dtruss 的包装器,它提供了一个与 strace 更相似的接口(更多详情这里)。
下面是一些使用 strace 或 dtruss 来显示 ls 执行的 stat 系统调用跟踪的示例。对于更深入的了解 strace,可以阅读这篇文章和这本杂志。
# On Linux
sudo strace -e lstat ls -l > /dev/null
# On macOS
sudo dtruss -t lstat64_extended ls -l > /dev/null
在某些情况下,你可能需要查看网络数据包以确定程序中的问题。像 [tcpdump](https://www.man7.org/linux/man-pages/man1/tcpdump.1.html) 和 Wireshark 这样的工具是网络数据包分析器,允许你读取网络数据包并根据不同的标准进行过滤。
对于 Web 开发,Chrome/Firefox 开发者工具非常方便。它们提供大量工具,包括:
-
源代码 - 检查任何网站的 HTML/CSS/JS 源代码。
-
实时 HTML、CSS、JS 修改 - 修改网站内容、样式和行为以进行测试(你可以亲自看到网站截图不是有效的证据)。
-
JavaScript 壳 - 在 JS REPL 中执行命令。
-
网络 - 分析请求时间线。
-
存储 - 查看 Cookies 和本地应用程序存储。
静态分析
对于某些问题,你不需要运行任何代码。例如,只需仔细查看一段代码,你可能会意识到你的循环变量正在遮蔽一个已经存在的变量或函数名;或者程序在定义变量之前就读取了它。这就是静态分析工具发挥作用的地方。静态分析程序将源代码作为输入,并使用编码规则进行分析,以推理其正确性。
在下面的 Python 代码片段中存在几个错误。首先,我们的循环变量foo遮蔽了函数foo的先前定义。我们还把最后一行中的baz写成了bar,所以程序在完成sleep调用(将花费一分钟)后将会崩溃。
import time
def foo():
return 42
for foo in range(5):
print(foo)
bar = 1
bar *= 0.2
time.sleep(60)
print(baz)
静态分析工具可以识别这类问题。当我们对代码运行pyflakes时,我们会得到与这两个错误相关的错误。mypy是另一个可以检测类型检查问题的工具。在这里,mypy会警告我们bar最初是一个int,然后被转换为float。再次注意,所有这些问题都是在无需运行代码的情况下被检测到的。
$ pyflakes foobar.py
foobar.py:6: redefinition of unused 'foo' from line 3
foobar.py:11: undefined name 'baz'
$ mypy foobar.py
foobar.py:6: error: Incompatible types in assignment (expression has type "int", variable has type "Callable[[], Any]")
foobar.py:9: error: Incompatible types in assignment (expression has type "float", variable has type "int")
foobar.py:11: error: Name 'baz' is not defined
Found 3 errors in 1 file (checked 1 source file)
在壳工具讲座中,我们介绍了shellcheck,这是一个类似用于 shell 脚本的工具。
大多数编辑器和 IDE 都支持在编辑器本身中显示这些工具的输出,突出显示警告和错误的位置。这通常被称为代码检查,它也可以用来显示其他类型的错误,如风格违规或不安全的结构。
在 vim 中,插件ale或syntastic将允许你这样做。对于 Python,pylint和pep8是风格检查器的示例,而bandit是一个旨在发现常见安全问题的工具。对于其他语言,人们已经编制了综合的静态分析工具列表,例如Awesome Static Analysis(你可能想看看写作部分),而对于检查器,有Awesome Linters。
风格化 linting 的补充工具是代码格式化工具,例如 Python 的black(black),Go 的gofmt,Rust 的rustfmt,以及 JavaScript、HTML 和 CSS 的prettier(prettier)。这些工具会自动格式化你的代码,使其与给定编程语言的常见风格模式保持一致。尽管你可能不愿意将代码风格控制权交给他人,但标准化代码格式将有助于其他人阅读你的代码,并使你更好地阅读其他人的(风格标准化的)代码。
分析
即使你的代码功能上表现如你所预期,但如果它占用了所有的 CPU 或内存,那可能还不够好。算法课程通常教授大O表示法,但并不教授如何找到程序中的热点。由于过早优化是万恶之源,你应该了解分析器和监控工具。它们将帮助你理解程序中哪些部分花费了最多时间和/或资源,这样你就可以专注于优化这些部分。
时间测量
类似于调试的情况,在许多场景中,只需打印代码在两个点之间花费的时间就足够了。以下是一个使用 Python 的time模块的示例。
import time, random
n = random.randint(1, 10) * 100
# Get current time start = time.time()
# Do some work print("Sleeping for {} ms".format(n))
time.sleep(n/1000)
# Compute time between start and now print(time.time() - start)
# Output
# Sleeping for 500 ms
# 0.5713930130004883
然而,墙钟时间可能会误导,因为你的电脑可能同时运行其他进程或等待事件发生。工具通常会在实际、用户和系统时间之间做出区分。一般来说,用户 + 系统时间告诉你进程实际在 CPU 上花费了多少时间(更详细的解释这里)。
-
实际 - 程序从开始到结束的墙钟时间,包括其他进程占用的时间和阻塞时的时间(例如,等待 I/O 或网络)。
-
用户 - 在 CPU 上运行用户代码所花费的时间
-
系统 - 在 CPU 上运行内核代码所花费的时间
例如,尝试运行一个执行 HTTP 请求的命令,并在其前加上time(time)。在慢速连接下,你可能会得到如下所示的输出。这里请求完成用了超过 2 秒钟,但进程只消耗了 15 毫秒的用户 CPU 时间和 12 毫秒的内核 CPU 时间。
$ time curl https://missing.csail.mit.edu &> /dev/null
real 0m2.561s
user 0m0.015s
sys 0m0.012s
分析器
CPU
大多数时候,当人们提到 profilers 时,他们实际上是指 CPU 分析器,这是最常见的。主要有两种类型的 CPU 分析器:跟踪 和 采样 分析器。跟踪分析器会记录程序执行的每个函数调用,而采样分析器会定期(通常每毫秒)探测程序,并记录程序的堆栈。他们使用这些记录来展示程序花费最多时间做什么的汇总统计。如果你想要更多关于这个主题的细节,这里 是一篇很好的介绍文章。
大多数编程语言都有一些命令行分析器,你可以用来分析你的代码。它们通常与完整的集成开发环境(IDE)集成,但在这个讲座中,我们将专注于命令行工具本身。
在 Python 中,我们可以使用 cProfile 模块来分析函数调用的耗时。以下是一个简单的示例,它实现了 Python 中的基本 grep 功能:
#!/usr/bin/env python
import sys, re
def grep(pattern, file):
with open(file, 'r') as f:
print(file)
for i, line in enumerate(f.readlines()):
pattern = re.compile(pattern)
match = pattern.search(line)
if match is not None:
print("{}: {}".format(i, line), end="")
if __name__ == '__main__':
times = int(sys.argv[1])
pattern = sys.argv[2]
for i in range(times):
for file in sys.argv[3:]:
grep(pattern, file)
我们可以使用以下命令来分析此代码。分析输出我们可以看到,I/O 占用了大部分时间,同时编译正则表达式也花费了相当多的时间。由于正则表达式只需要编译一次,我们可以将其从循环中提取出来。
$ python -m cProfile -s tottime grep.py 1000 '^(import|\s*def)[^,]*$' *.py
[omitted program output]
ncalls tottime percall cumtime percall filename:lineno(function)
8000 0.266 0.000 0.292 0.000 {built-in method io.open}
8000 0.153 0.000 0.894 0.000 grep.py:5(grep)
17000 0.101 0.000 0.101 0.000 {built-in method builtins.print}
8000 0.100 0.000 0.129 0.000 {method 'readlines' of '_io._IOBase' objects}
93000 0.097 0.000 0.111 0.000 re.py:286(_compile)
93000 0.069 0.000 0.069 0.000 {method 'search' of '_sre.SRE_Pattern' objects}
93000 0.030 0.000 0.141 0.000 re.py:231(compile)
17000 0.019 0.000 0.029 0.000 codecs.py:318(decode)
1 0.017 0.017 0.911 0.911 grep.py:3(<module>)
[omitted lines]
Python 的 cProfile 分析器(以及许多分析器)的一个缺点是它们显示的是每次函数调用的耗时。这可能会很快变得不直观,尤其是如果你在代码中使用第三方库,因为内部函数调用也会被计算在内。显示分析信息的一个更直观的方法是包括每行代码所花费的时间,这正是 line profilers 所做的。
例如,以下 Python 代码执行了对班级网站的请求,并解析响应以获取页面中的所有 URL:
#!/usr/bin/env python import requests
from bs4 import BeautifulSoup
# This is a decorator that tells line_profiler
# that we want to analyze this function @profile
def get_urls():
response = requests.get('https://missing.csail.mit.edu')
s = BeautifulSoup(response.content, 'lxml')
urls = []
for url in s.find_all('a'):
urls.append(url['href'])
if __name__ == '__main__':
get_urls()
如果我们使用 Python 的 cProfile 分析器,我们会得到超过 2500 行的输出,即使排序后也难以理解时间都花在了哪里。使用 line_profiler(line_profiler)快速运行一下,可以看到每行代码所花费的时间:
$ kernprof -l -v a.py
Wrote profile results to urls.py.lprof
Timer unit: 1e-06 s
Total time: 0.636188 s
File: a.py
Function: get_urls at line 5
Line # Hits Time Per Hit % Time Line Contents
==============================================================
5 @profile
6 def get_urls():
7 1 613909.0 613909.0 96.5 response = requests.get('https://missing.csail.mit.edu')
8 1 21559.0 21559.0 3.4 s = BeautifulSoup(response.content, 'lxml')
9 1 2.0 2.0 0.0 urls = []
10 25 685.0 27.4 0.1 for url in s.find_all('a'):
11 24 33.0 1.4 0.0 urls.append(url['href'])
内存
在像 C 或 C++ 这样的语言中,内存泄漏可能导致你的程序无法释放不再需要的内存。为了帮助内存调试过程,你可以使用像 Valgrind 这样的工具,它可以帮助你识别内存泄漏。
在像 Python 这样的垃圾回收语言中,使用内存分析器仍然很有用,因为只要你在内存中有对象的指针,它们就不会被垃圾回收。以下是一个示例程序及其使用 memory-profiler 运行时的相关输出(注意与 line-profiler 中的装饰器类似)。
@profile
def my_func():
a = [1] * (10 ** 6)
b = [2] * (2 * 10 ** 7)
del b
return a
if __name__ == '__main__':
my_func()
$ python -m memory_profiler example.py
Line # Mem usage Increment Line Contents
==============================================
3 @profile
4 5.97 MB 0.00 MB def my_func():
5 13.61 MB 7.64 MB a = [1] * (10 ** 6)
6 166.20 MB 152.59 MB b = [2] * (2 * 10 ** 7)
7 13.61 MB -152.59 MB del b
8 13.61 MB 0.00 MB return a
事件分析
就像 strace 用于调试一样,你可能想要忽略你正在运行的代码的细节,并在剖析时将其视为黑盒。[perf](https://www.man7.org/linux/man-pages/man1/perf.1.html) 命令抽象掉了 CPU 差异,并且不报告时间或内存,而是报告与你的程序相关的系统事件。例如,perf` 可以轻松报告较差的缓存局部性、大量的页面错误或活锁。以下是该命令的概述:
-
perf list- 列出可以用 perf 跟踪的事件 -
perf stat COMMAND ARG1 ARG2- 获取与进程或命令相关的不同事件的计数 -
perf record COMMAND ARG1 ARG2- 记录命令的运行并将统计数据保存到名为perf.data的文件中 -
perf report- 格式化和打印在perf.data中收集的数据
可视化
由于软件项目的固有复杂性,真实世界程序的剖析输出将包含大量信息。人类是视觉生物,在阅读大量数字并理解它们方面相当糟糕。因此,有许多工具可以以更易于解析的方式显示剖析器的输出。
显示采样剖析器的 CPU 剖析信息的一种常见方式是使用 火焰图,它将在 Y 轴上显示函数调用的层次结构,X 轴上的时间与函数调用成比例。它们也是交互式的,允许你放大程序的具体部分并获取它们的堆栈跟踪(尝试点击下面的图像)。
调用图或控制流图通过将函数作为节点和函数调用作为有向边包含在内,显示程序内子程序之间的关系。当与调用次数和时间等信息结合时,调用图对于解释程序流程非常有用。在 Python 中,你可以使用 pycallgraph 库来生成它们。

资源监控
有时,分析程序性能的第一步是了解其实际资源消耗。当程序资源受限时,例如没有足够的内存或在慢速网络连接上运行时,它们通常会运行得较慢。有大量的命令行工具用于探测和显示不同的系统资源,如 CPU 使用率、内存使用率、网络、磁盘使用率等等。
-
通用监控 - 最受欢迎的可能就是
htop(https://htop.dev/),它是top(https://www.man7.org/linux/man-pages/man1/top.1.html) 的改进版。htop显示系统上当前运行进程的各种统计信息。htop有许多选项和快捷键,其中一些有用的包括:<F6>用于排序进程,t用于显示树形层次结构,h用于切换线程。还可以参考glances(https://nicolargo.github.io/glances/),它具有类似实现和出色的用户界面。对于获取所有进程的聚合度量,dool(https://github.com/scottchiefbaker/dool) 是另一个巧妙的工具,它计算许多不同子系统(如 I/O、网络、CPU 利用率、上下文切换等)的实时资源指标。 -
I/O 操作 -
iotop显示实时 I/O 使用信息,便于检查进程是否正在进行大量的 I/O 磁盘操作 -
磁盘使用 -
df(https://www.man7.org/linux/man-pages/man1/df.1.html) 显示每个分区的指标,而du(https://man7.org/linux/man-pages/man1/du.1.html) 显示当前目录中每个文件的磁盘使用情况。在这些工具中,-h标志告诉程序以可读的格式打印。du的一个更交互式的版本是ncdu(https://dev.yorhel.nl/ncdu),它允许你在导航文件夹的同时删除文件和文件夹。 -
内存使用 -
free显示系统中的总空闲和已用内存。内存也在htop等工具中显示。 -
打开的文件 -
lsof列出进程打开的文件信息。这对于检查哪个进程打开了特定文件非常有用。 -
网络连接和配置 -
ss允许你监控进出网络数据包的统计信息以及接口统计信息。ss的一个常见用途是确定机器上使用特定端口的进程。对于显示路由、网络设备和接口,你可以使用ip(https://man7.org/linux/man-pages/man8/ip.8.html)。请注意,netstat和ifconfig已经被弃用,分别被上述工具取代。 -
网络使用 -
nethogs(https://github.com/raboof/nethogs) 和iftop(`https://pdw.ex-parrot.com/iftop/") 是用于监控网络使用的良好交互式 CLI 工具。
如果你想测试这些工具,也可以使用 stress 命令在机器上人为地施加负载。
专用工具
有时候,黑盒基准测试就足以确定要使用哪种软件。像hyperfine这样的工具让你可以快速基准测试命令行程序。例如,在 shell 工具和脚本讲座中,我们推荐使用fd而不是find。我们可以使用hyperfine来比较我们经常运行的任务。例如,在下面的例子中,fd在我的机器上比find快 20 倍。
$ hyperfine --warmup 3 'fd -e jpg' 'find . -iname "*.jpg"'
Benchmark #1: fd -e jpg
Time (mean ± σ): 51.4 ms ± 2.9 ms [User: 121.0 ms, System: 160.5 ms]
Range (min … max): 44.2 ms … 60.1 ms 56 runs
Benchmark #2: find . -iname "*.jpg"
Time (mean ± σ): 1.126 s ± 0.101 s [User: 141.1 ms, System: 956.1 ms]
Range (min … max): 0.975 s … 1.287 s 10 runs
Summary
'fd -e jpg' ran
21.89 ± 2.33 times faster than 'find . -iname "*.jpg"'
就像调试一样,浏览器也提供了一套出色的工具来分析网页加载,让你可以找出时间都花在了哪里(加载、渲染、脚本等)。有关Firefox和Chrome的更多信息。
练习
调试
-
在 Linux 上使用
journalctl或在 macOS 上使用log show来获取过去一天的超级用户访问和命令。如果没有,你可以执行一些无害的命令,如sudo ls,然后再次检查。 -
安装
shellcheck并尝试检查以下脚本。代码有什么问题?修复它。在你的编辑器中安装一个代码检查插件,这样你就可以自动获得警告。#!/bin/sh ## Example: a typical script with several problems for f in $(ls *.m3u) do grep -qi hq.*mp3 $f \ && echo -e 'Playlist $f contains a HQ file in mp3 format' done -
(高级)阅读有关可逆调试的内容,并使用
rr或RevPDB来获取一个简单的示例。性能分析
-
这里有一些排序算法的实现。使用
cProfile和line_profiler来比较插入排序和快速排序的运行时间。每个算法的瓶颈在哪里?然后使用memory_profiler来检查内存消耗,为什么插入排序更好?现在检查快速排序的原地版本。挑战:使用perf查看每个算法的循环计数和缓存命中与缺失。 -
这里有一些(可能有些复杂)的 Python 代码,用于使用每个数字的函数来计算斐波那契数。
#!/usr/bin/env python def fib0(): return 0 def fib1(): return 1 s = """def fib{}(): return fib{}() + fib{}()""" if __name__ == '__main__': for n in range(2, 10): exec(s.format(n, n-1, n-2)) # from functools import lru_cache # for n in range(10): # exec("fib{} = lru_cache(1)(fib{})".format(n, n)) print(eval("fib9()"))将代码放入文件并使其可执行。安装先决条件:
pycallgraph和graphviz。(如果你可以运行dot,那么你已经有 GraphViz 了。)使用pycallgraph graphviz -- ./fib.py运行代码,并检查pycallgraph.png文件。fib0被调用了多少次?我们可以通过缓存函数做得更好。取消注释注释的行并重新生成图像。现在我们调用每个fibN函数的次数是多少? -
一个常见的问题是,你想要监听的端口已经被另一个进程占用。让我们学习如何发现该进程的进程 ID。首先执行
python -m http.server 4444以启动一个监听在端口4444上的最小化 Web 服务器。在另一个终端运行lsof | grep LISTEN以打印所有监听进程和端口。找到该进程的进程 ID,并通过运行kill <PID>来终止它。 -
限制进程的资源可以是你的工具箱中的另一个实用工具。尝试运行
stress -c 3并使用htop可视化 CPU 消耗。现在,执行taskset --cpu-list 0,2 stress -c 3并可视化它。stress是否正在占用三个 CPU?为什么不是?阅读man taskset。挑战:使用cgroups实现相同的效果。尝试限制stress -m的内存消耗。 -
(高级) 命令
curl ipinfo.io执行一个 HTTP 请求并获取关于你的公共 IP 的信息。打开 Wireshark 并尝试嗅探curl发送和接收的请求和回复数据包。(提示:使用http过滤器仅观察 HTTP 数据包)。
本文档遵循 CC BY-NC-SA 许可协议。
元编程
我们所说的“元编程”是什么意思?嗯,这是我们能为那些更关注 过程 而不是编写代码或更高效工作的东西所能想到的最好的集体术语。在本讲座中,我们将探讨构建和测试你的代码的系统,以及管理依赖项的系统。这些可能看起来在你作为学生的日常工作中重要性有限,但当你通过实习或进入“现实世界”后与更大的代码库交互时,你会到处看到它们。我们应该注意,“元编程”也可以指“操作程序的程序”,而这不是我们在这个讲座中使用的定义。
构建系统
如果你用 LaTeX 写论文,你需要运行哪些命令来生成你的论文?又或者,那些用于运行你的基准测试、绘制图表并将图表插入论文中的命令?或者编译你在课程中提供的代码,然后运行测试?
对于大多数项目,无论它们是否包含代码,都有一个“构建过程”。这是一系列你需要执行的从输入到输出的操作。通常,这个过程可能包含许多步骤和分支。运行这个来生成这个图表,运行那个来生成那些结果,还有其他操作来生成最终的论文。就像我们在本课程中看到的许多事情一样,你不是第一个遇到这种烦恼的人,幸运的是,存在许多工具可以帮助你!
这些通常被称为“构建系统”,并且有 很多 种。你使用哪一种取决于手头的任务、你偏好的语言和项目的规模。尽管如此,它们的本质是非常相似的。你定义了一组 依赖项、一组 目标 和从一项到另一项的 规则。你告诉构建系统你想要一个特定的目标,它的任务就是找到该目标的传递依赖项,然后应用规则来产生中间目标,直到最终目标生成。理想情况下,构建系统会这样做,而不会不必要地执行那些依赖项没有变化且结果可以从之前的构建中获取的目标的规则。
make 是最常用的构建系统之一,你通常可以在几乎任何基于 UNIX 的计算机上找到它。它有一些瑕疵,但对于简单到中等规模的项目来说工作得相当好。当你运行 make 时,它会参考当前目录中名为 Makefile 的文件。所有目标、它们的依赖关系和规则都定义在这个文件中。让我们看看其中一个:
paper.pdf: paper.tex plot-data.png
pdflatex paper.tex
plot-%.png: %.dat plot.py
./plot.py -i $*.dat -o $@
该文件中的每个指令都是一个规则,用于如何使用右侧的内容来生成左侧的内容。或者,换一种说法,右侧命名的事物是依赖项,左侧是目标。缩进的块是一系列程序,用于从这些依赖项生成目标。在 make 中,第一个指令还定义了默认目标。如果你不带参数运行 make,这将是要构建的目标。或者,你可以运行类似 make plot-data.png 的命令,然后它会构建那个目标。
规则中的 % 是一个“模式”,会在左右两侧匹配相同的字符串。例如,如果请求目标 plot-foo.png,make 将寻找依赖项 foo.dat 和 plot.py。现在让我们看看如果我们在一个空的源目录中运行 make 会发生什么。
$ make
make: *** No rule to make target 'paper.tex', needed by 'paper.pdf'. Stop.
make 有帮助地告诉我们,为了构建 paper.pdf,它需要 paper.tex,并且它没有规则告诉它如何生成那个文件。让我们尝试生成它!
$ touch paper.tex
$ make
make: *** No rule to make target 'plot-data.png', needed by 'paper.pdf'. Stop.
嗯,有趣的是,确实有一个规则来生成 plot-data.png,但它是一个模式规则。由于源文件不存在(data.dat),make 简单地声明它无法生成那个文件。让我们尝试创建所有这些文件:
$ cat paper.tex
\documentclass{article}
\usepackage{graphicx}
\begin{document}
\includegraphics[scale=0.65]{plot-data.png}
\end{document} $ cat plot.py
#!/usr/bin/env python
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('-i', type=argparse.FileType('r'))
parser.add_argument('-o')
args = parser.parse_args()
data = np.loadtxt(args.i)
plt.plot(data[:, 0], data[:, 1])
plt.savefig(args.o) $ cat data.dat
1 1
2 2
3 3
4 4
5 8
现在如果我们运行 make 会发生什么?
$ make
./plot.py -i data.dat -o plot-data.png
pdflatex paper.tex
... lots of output ...
看看,它为我们生成了一个 PDF!如果我们再次运行 make 会怎样?
$ make
make: 'paper.pdf' is up to date.
它什么都没做!为什么?好吧,因为它不需要这么做。它检查了所有之前构建的目标是否仍然与它们的列出的依赖项保持最新。我们可以通过修改 paper.tex 然后重新运行 make 来测试这一点:
$ vim paper.tex
$ make
pdflatex paper.tex ...
注意到 make 并没有重新运行 plot.py,因为那不是必要的;plot-data.png 的依赖项没有任何变化!
依赖项管理
在更宏观的层面上,你的软件项目可能依赖于自身也是项目的依赖项。你可能依赖于已安装的程序(如 python),系统包(如 openssl),或者你编程语言中的库(如 matplotlib)。如今,大多数依赖项都可通过一个 仓库 获取,该仓库在一个地方集中管理大量此类依赖项,并提供方便的安装机制。一些例子包括 Ubuntu 系统包的 Ubuntu 软件包仓库,你可以通过 apt 工具访问,RubyGems 用于 Ruby 库,PyPI 用于 Python 库,或者 Arch Linux 用户贡献的包的 Arch 用户仓库。
由于与这些存储库交互的确切机制在每个存储库和工具之间差异很大,我们不会深入探讨任何特定存储库的细节。我们将要讨论的是它们都使用的某些常见术语。其中之一是版本控制。大多数其他项目依赖的项目在每次发布时都会提供一个版本号,通常类似于 8.1.3 或 64.1.20192004。它们通常是数字的,但并不总是。版本号有很多用途,其中最重要的用途之一是确保软件保持运行。例如,想象一下,如果我发布了一个新版本的库,其中我重命名了一个特定的函数。如果有人在我发布该更新后尝试构建依赖于我的库的软件,构建可能会失败,因为它调用了一个不再存在的函数!版本控制试图通过让项目声明它依赖于某个特定版本或版本范围的其他项目来解决此问题。这样,即使底层库发生变化,依赖软件也可以通过使用我的库的较旧版本继续构建。
但这也不是理想的!如果我发布了一个安全更新,它没有更改我库的公共接口(其“API”),并且任何依赖于旧版本的项都应该立即开始使用?这就是版本号中不同数字组的作用所在。每个数字的确切含义在不同项目之间有所不同,但一个相对常见的标准是语义版本控制。使用语义版本控制,每个版本号的形式为:主要.次要.补丁。规则如下:
-
如果新版本没有更改 API,则增加补丁版本。
-
如果你以向后兼容的方式向 API 添加内容,则增加次要版本。
-
如果你以非向后兼容的方式更改 API,则增加主要版本。
这已经提供了一些主要优势。现在,如果我的项目依赖于你的项目,使用与我开发时构建的相同主要版本的最新版本应该是安全的,只要其次要版本至少与那时相同。换句话说,如果我依赖于你的库版本1.3.7,那么使用1.3.8、1.6.1甚至1.3.0来构建它应该是没有问题的。版本2.2.4可能就不太合适了,因为主要版本已经增加。我们可以从 Python 的版本号中看到语义版本控制的例子。你们中许多人可能都知道,Python 2 和 Python 3 代码混合得不是很好,这就是为什么那是一个主要版本的增加。同样,为 Python 3.5 编写的代码可能在 Python 3.7 上运行良好,但在 3.4 上可能就不行。
当与依赖管理系统一起工作时,你也可能会遇到锁文件的概念。锁文件简单来说就是一个列出每个依赖项当前所依赖的确切版本的文件。通常,你需要显式地运行一个更新程序来升级到依赖项的新版本。这样做的原因有很多,比如避免不必要的重新编译、确保构建的可重复性,或者不自动更新到最新版本(可能存在错误)。这种依赖锁定的极端形式是供应商版本控制,即你将所有依赖项的代码复制到自己的项目中。这让你可以完全控制其任何变化,并允许你对其引入自己的更改,但这也意味着你必须随着时间的推移显式地从上游维护者那里拉取任何更新。
持续集成系统
当你处理越来越大的项目时,你会发现,每当你对项目进行更改时,通常都需要执行额外的任务。你可能需要上传文档的新版本、上传编译版本到某处、将代码发布到 pypi、运行测试套件,以及所有其他事情。也许每次有人给你发送 GitHub 上的 pull request 时,你希望他们的代码经过样式检查,并运行一些基准测试?当这些需求出现时,是时候看看持续集成了。
持续集成,或 CI,是一个涵盖“每当你的代码发生变化时就会运行的东西”的术语,并且有许多公司提供各种类型的 CI,通常对开源项目免费。其中一些大型公司包括 Travis CI、Azure Pipelines 和 GitHub Actions。它们的工作方式大致相同:你向你的仓库添加一个文件,描述当仓库发生各种事件时应该发生什么。最常见的一种规则是“当有人推送代码时,运行测试套件”。当事件触发时,CI 提供商启动一个或多个虚拟机,运行你的“配方”中的命令,然后通常会在某处记录结果。你可能设置它,以便在测试套件停止通过时通知你,或者在你仓库的测试通过时,出现一个小徽章。
以 CI 系统为例,班级网站是使用 GitHub Pages 设置的。Pages 是一个 CI 操作,它会在每次向master分支推送时运行 Jekyll 博客软件,并将构建的网站发布在特定的 GitHub 域名上。这使得我们更新网站变得非常简单!我们只需在本地进行更改,使用 git 提交,然后推送。CI 会处理其余的工作。
简单谈谈测试
大多数大型软件项目都附带一个“测试套件”。你可能已经熟悉测试的一般概念,但我们认为快速提及一些你可能会在野外遇到的测试方法和测试术语是有用的:
-
测试套件:所有测试的统称
-
单元测试:一个“微观测试”,用于单独测试特定功能。
-
集成测试:一个“宏观测试”,运行系统的一部分以检查不同的功能或组件是否 协同工作。
-
回归测试:一个实现特定模式以确保 之前 导致错误的测试,以确保错误不会再次出现。
-
模拟:用假实现替换函数、模块或类型,以避免测试无关的功能。例如,你可能“模拟网络”或“模拟磁盘”。
练习
-
大多数 makefile 都提供了一个名为
clean的目标。这并不是为了生成一个名为clean的文件,而是为了清理任何可以被 make 重建的文件。将其视为一种“撤销”所有构建步骤的方法。为上面的paper.pdfMakefile实现一个clean目标。你可能需要将该目标 伪化。你可能发现git ls-files子命令很有用。其他一些非常常见的 make 目标列在 这里。 -
查看在 Rust 的构建系统 中指定依赖项版本要求的各种方法。大多数包仓库支持类似的语法。对于每一种( caret,tilde,通配符,比较,和多个),尝试想出一个特定类型的依赖项在某个用例中是有意义的场景。
-
Git 可以作为一个简单的 CI 系统独立运行。在任何 git 仓库的
.git/hooks目录中,您将找到(当前不活跃)的文件,当发生特定操作时,这些文件作为脚本运行。编写一个pre-commit钩子,该钩子运行make paper.pdf并在make命令失败时拒绝提交。这应该可以防止任何提交包含无法构建的论文版本。 -
使用 GitHub Pages 设置一个简单的自动发布页面。将一个 GitHub Action 添加到仓库中,以在该仓库的任何 shell 文件上运行
shellcheck(这里有一种 实现方式)。检查它是否工作! -
构建自己的 GitHub action 来在仓库中的所有
.md文件上运行proselint或write-good。在您的仓库中启用它,并通过提交包含错别字的 pull request 来检查它是否工作。
根据 CC BY-NC-SA 许可。
安全和密码学
去年的安全和隐私讲座重点介绍了你作为计算机用户如何变得更安全。今年,我们将关注与理解本课程早期介绍的工具相关的安全和密码学概念,例如 Git 中哈希函数的使用或 SSH 中的密钥派生函数和对称/非对称加密系统。
这场讲座不能替代更严格和完整的计算机系统安全(6.858)或密码学(6.857和 6.875)课程。在没有正式安全培训的情况下不要进行安全工作。除非你是专家,否则不要自己实现密码学。同样的原则也适用于系统安全。
这场讲座对基本密码学概念进行了非常非正式(但我们认为实用)的处理。这场讲座不足以教你如何设计安全系统或密码协议,但我们希望它足以让你对已经使用的程序和协议有一个一般性的理解。
熵
熵是随机性的度量。这在确定密码强度时很有用。

如上XKCD 漫画所示,像“correcthorsebatterystaple”这样的密码比像“Tr0ub4dor&3”这样的密码更安全。但如何量化这种东西呢?
熵以比特为单位进行测量,当从一组可能的结果中均匀随机选择时,熵等于log_2(可能性数量)。公平的硬币抛掷给出 1 比特的熵。掷骰子(六面骰子)大约有 2.58 比特的熵。
你应该考虑攻击者知道密码的模型,但不知道用于选择特定密码的随机性(例如来自dice rolls)。
需要多少比特的熵才足够?这取决于你的威胁模型。正如 XKCD 漫画所指出的,大约 40 比特的熵相当不错。为了抵抗离线猜测,需要更强的密码(例如 80 比特,或更多)。
哈希函数
一种加密哈希函数将任意大小的数据映射到固定大小,并具有一些特殊属性。一个哈希函数的大致规范如下:
hash(value: array<byte>) -> vector<byte, N> (for some fixed N)
一个哈希函数的例子是SHA1,它在 Git 中使用。它将任意大小的输入映射到 160 位的输出(可以表示为 40 个十六进制字符)。我们可以使用sha1sum命令尝试对输入进行 SHA1 哈希:
$ printf 'hello' | sha1sum
aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d $ printf 'hello' | sha1sum
aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d $ printf 'Hello' | sha1sum
f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0
在高层次上,哈希函数可以被视为一个难以逆推的看似随机的(但确定性的)函数(这就是哈希函数的理想模型)。哈希函数具有以下特性:
-
确定性:相同的输入总是生成相同的输出。
-
不可逆:很难找到一个输入
m,使得对于某个期望的输出h,有hash(m) = h。 -
目标碰撞抵抗:给定一个输入
m_1,很难找到一个不同的输入m_2,使得hash(m_1) = hash(m_2)。 -
抗碰撞:很难找到两个输入
m_1和m_2,使得hash(m_1) = hash(m_2)(注意,这比目标碰撞抵抗是一个更强的特性)。
注意:虽然它可能适用于某些目的,但 SHA-1 已不再被认为是强大的加密哈希函数。不再使用。你可能对加密哈希函数的寿命表感兴趣。然而,请注意,推荐特定的哈希函数超出了本次讲座的范围。如果你在进行与此相关的工作,你需要接受安全/密码学的正规培训。
应用
-
Git,用于内容地址存储。哈希函数的概念是一个更通用的概念(存在非加密哈希函数)。为什么 Git 使用加密哈希函数?
-
文件内容的简要总结。软件通常可以从(可能不太可靠的)镜像站点下载,例如 Linux ISO,而且最好不需要信任它们。官方站点通常会在下载链接(指向第三方镜像)旁边发布哈希值,以便在下载文件后可以检查哈希值。
-
承诺方案。假设你想对某个特定值进行承诺,但稍后才会揭示该值。例如,我想在“我脑海中”进行公平的硬币投掷,而不需要一个双方都能看到的可信共享硬币。我可以选择一个值
r = random(),然后分享h = sha256(r)。然后,你可以叫正面或反面(我们将同意r为偶数表示正面,奇数表示反面)。在你叫出之后,我可以揭示我的值r,你可以通过检查sha256(r)是否与之前分享的哈希值匹配来确认我没有作弊。
密钥派生函数
与加密哈希相关的一个概念是密钥派生函数(KDFs),它们用于许多应用,包括为其他加密算法生成固定长度的输出作为密钥。通常,KDFs 会故意设计得较慢,以减缓离线暴力攻击。
应用
-
从密码短语生成密钥,用于其他加密算法(例如,对称加密,见下文)。
-
存储登录凭证。存储明文密码是错误的;正确的方法是为每个用户生成和存储一个随机的 盐
salt = random(),存储KDF(password + salt),并通过重新计算 KDF 来验证登录尝试,给定输入的密码和存储的盐。
对称密码学
在考虑密码学时,隐藏消息内容可能是您首先想到的概念。对称密码学通过以下功能集实现这一点:
keygen() -> key (this function is randomized)
encrypt(plaintext: array<byte>, key) -> array<byte> (the ciphertext)
decrypt(ciphertext: array<byte>, key) -> array<byte> (the plaintext)
加密函数具有以下属性:给定输出(密文),如果没有密钥,很难确定输入(明文)。解密函数具有明显的正确性属性,即 decrypt(encrypt(m, k), k) = m。
目前广泛使用的对称密码系统的一个例子是 AES。
应用
- 将文件加密以存储在不信任的云服务中。这可以与 KDFs 结合使用,因此您可以使用密码加密文件。生成
key = KDF(passphrase),然后存储encrypt(file, key)。
非对称密码学
“非对称”一词指的是存在两个密钥,它们具有不同的角色。私钥,正如其名称所暗示的,意味着应该保持私密,而公钥可以公开共享,这不会影响安全性(与对称密码系统中的密钥共享不同)。非对称密码系统提供以下功能集,用于加密/解密和签名/验证:
keygen() -> (public key, private key) (this function is randomized)
encrypt(plaintext: array<byte>, public key) -> array<byte> (the ciphertext)
decrypt(ciphertext: array<byte>, private key) -> array<byte> (the plaintext)
sign(message: array<byte>, private key) -> array<byte> (the signature)
verify(message: array<byte>, signature: array<byte>, public key) -> bool (whether or not the signature is valid)
加密/解密函数具有与对称密码系统中的类似属性。可以使用 公钥 加密一条消息。给定输出(密文),如果没有 私钥,很难确定输入(明文)。解密函数具有明显的正确性属性,即 decrypt(encrypt(m, public key), private key) = m。
对称加密和非对称加密可以与物理锁进行比较。对称密码系统就像一个门锁:任何拥有密钥的人都可以锁定和解锁它。非对称加密就像一个带钥匙的挂锁。您可以给某人一把未锁的锁(公钥),他们可以将信息放入一个盒子中,然后锁上,之后,只有您才能打开锁,因为您保留了钥匙(私钥)。
签名/验证函数具有您希望物理签名具有的相同属性,即很难伪造签名。无论消息如何,如果没有 私钥,很难生成一个签名,使得 verify(message, signature, public key) 返回 true。当然,验证函数具有明显的正确性属性,即 verify(message, sign(message, private key), public key) = true。
应用
-
PGP 电子邮件加密。人们可以将他们的公钥发布在网上(例如,在 PGP 密钥服务器上或在[Keybase](https://keybase.io/)上)。任何人都可以发送加密电子邮件。
-
签名软件。Git 可以有 GPG 签名的提交和标签。通过发布的公钥,任何人都可以验证下载的软件的真实性。
密钥分发
非对称密钥加密非常出色,但它面临着将公钥/将公钥映射到现实世界身份的大挑战。针对这个问题有许多解决方案。Signal 提供了一个简单的解决方案:首次使用时信任,并支持带外公钥交换(你亲自验证你朋友的“安全号码”)。PGP 有一个不同的解决方案,即信任网。Keybase 还有一个不同的解决方案,即社会证明(以及其他一些巧妙的想法)。每种模型都有其优点;我们(讲师)喜欢 Keybase 的模型。
案例研究
密码管理器
这是一个每个人都应该尝试使用的必备工具(例如 KeePassXC,pass,和 1Password)。密码管理器使得使用唯一、随机生成的高熵密码来管理所有登录变得方便,并且它们将所有密码保存在一个地方,使用从口令生成密钥的对称加密算法加密。
使用密码管理器让你避免密码重复使用(因此当网站被入侵时影响较小),使用高熵密码(因此你被入侵的可能性较小),并且只需要记住一个高熵密码。
双因素认证
双因素认证(2FA)要求你使用一个口令(“你知道的某物”)以及一个 2FA 认证器(例如 YubiKey,“你拥有的某物”),以防止被盗密码和钓鱼攻击。
全盘加密
保持笔记本电脑整个磁盘加密是保护数据的一种简单方法,以防你的笔记本电脑被盗。你可以在 Linux 上使用 cryptsetup + LUKS,在 Windows 上使用 BitLocker,或在 macOS 上使用 FileVault。这将使用对称加密算法加密整个磁盘,密钥由口令保护。
私人消息
使用 Signal 或 Keybase。端到端安全是从非对称密钥加密开始的。获取你联系人的公钥是这里的关键步骤。如果你想获得良好的安全性,你需要通过带外方式(使用 Signal 或 Keybase)验证公钥,或者信任社会证明(使用 Keybase)。
SSH
我们在之前的讲座中介绍了 SSH 和 SSH 密钥的使用。让我们看看这个的密码学方面。
当你运行 ssh-keygen 时,它会生成一个非对称密钥对,public_key, private_key。这是随机生成的,使用操作系统提供的熵(从硬件事件等收集)。公钥以原样存储(它是公开的,所以保密并不重要),但在静止状态下,私钥应该在磁盘上加密。ssh-keygen 程序提示用户输入一个口令短语,然后通过密钥派生函数生成一个密钥,该密钥随后用于使用对称加密算法加密私钥。
在使用中,一旦服务器知道客户端的公钥(存储在.ssh/authorized_keys文件中),连接的客户端可以使用非对称签名来证明其身份。这是通过挑战-响应来完成的。在高层上,服务器选择一个随机数并发送给客户端。客户端然后对这条消息进行签名并发送签名回服务器,服务器将签名与记录中的公钥进行比对。这实际上证明了客户端拥有与服务器.ssh/authorized_keys文件中的公钥相对应的私钥,因此服务器可以允许客户端登录。
资源
-
去年的笔记:从这次讲座更侧重于作为计算机用户的安全和隐私的角度
-
密码学正确答案:为许多常见的 X 提供“我应该为 X 使用什么密码学?”的答案。
练习
-
熵。
-
假设密码是一个由四个小写词典单词组成的连接,其中每个单词是从大小为 100,000 的词典中随机选择的。这样一个密码的例子是
correcthorsebatterystaple。这个密码有多少位的熵? -
考虑一个替代方案,其中密码被选为一个由 8 个随机字母数字字符组成的序列(包括大小写字母)。一个例子是
rg8Ql34g。这个密码有多少位的熵? -
哪个密码更强大?
-
假设攻击者每秒可以尝试猜测 10,000 个密码。平均来说,破解每个密码需要多长时间?
-
-
密码学哈希函数。 从一个镜像(例如这个阿根廷镜像)下载 Debian 镜像。使用
sha256sum命令等交叉检查哈希值,与从官方 Debian 网站(例如这个文件)检索的哈希值进行比对,如果已从阿根廷镜像下载了链接的文件。 -
对称加密。使用 AES 加密对文件进行加密,使用OpenSSL:
openssl aes-256-cbc -salt -in {输入文件名} -out {输出文件名}。使用cat或hexdump查看内容。使用openssl aes-256-cbc -d -in {输入文件名} -out {输出文件名}进行解密,并使用cmp确认内容与原始文件匹配。 -
非对称加密。
本文档遵循CC BY-NC-SA许可协议。
大杂烩
目录
-
键盘映射
-
守护进程
-
FUSE
-
备份
-
APIs
-
常见的命令行标志/模式
-
窗口管理器
-
VPN
-
Markdown
-
Hammerspoon(macOS 上的桌面自动化)
-
引导 + Live USBs
-
Docker, Vagrant, 虚拟机, 云, OpenStack
-
笔记本编程
-
GitHub
键盘映射
作为程序员,您的键盘是您的主要输入方法。与您的计算机中的几乎所有事物一样,它是可配置的(并且值得配置)。
最基本的变化是映射按键。这通常涉及一些监听软件,每当按下某个键时,它会拦截该事件并将其替换为对应不同键的另一个事件。以下是一些示例:
-
将 Caps Lock 映射到 Ctrl 或 Escape。我们(讲师)强烈建议此设置,因为 Caps Lock 的位置非常方便,但很少使用。
-
将 PrtSc 映射到播放/暂停音乐。大多数操作系统都有播放/暂停键。
-
交换 Ctrl 和 Meta(Windows 或 Command)键。
您还可以将按键映射到您选择的任意命令。这对于您经常执行的任务非常有用。在这里,一些软件会监听特定的按键组合,并在检测到该事件时执行一些脚本。
-
打开一个新的终端或浏览器窗口。
-
插入一些特定的文本,例如您的长电子邮件地址或您的麻省理工学院 ID 号。
-
睡眠计算机或显示器。
您还可以配置更多复杂的修改:
-
映射按键序列,例如,按五次 Shift 切换 Caps Lock。
-
按键映射在按下和保持之间的区别,例如,如果快速按下 Caps Lock 键,则将其映射到 Esc,但如果按住并用作修饰符,则将其映射到 Ctrl。
-
映射是键盘或软件特定的。
一些入门该主题的软件资源:
-
macOS - karabiner-elements, skhd 或 BetterTouchTool
-
Windows - 控制面板内建,AutoHotkey 或 SharpKeys
-
QMK - 如果您的键盘支持自定义固件,您可以使用 QMK 来配置硬件设备本身,以便映射适用于您使用键盘的任何机器。
守护进程
你可能已经熟悉了守护进程的概念,即使这个词听起来很新。大多数计算机都有一系列始终在后台运行而不是等待用户启动和与之交互的进程。这些进程被称为守护进程,作为守护进程运行的程序通常以 d 结尾以表示这一点。例如,sshd,SSH 守护进程,是负责监听传入 SSH 请求并检查远程用户是否有必要凭证登录的程序。
在 Linux 中,systemd(系统守护进程)是运行和设置守护进程的最常见解决方案。你可以运行 systemctl status 来列出当前正在运行的守护进程。其中大部分可能听起来不熟悉,但它们负责系统的核心部分,如管理网络、解决 DNS 查询或显示系统的图形界面。可以通过 systemctl 命令与 systemd 交互,以 enable、disable、start、stop、restart 或检查服务的 status(这些都是 systemctl 命令)。
更有趣的是,systemd 提供了一个相当易于访问的界面来配置和启用新的守护进程(或服务)。下面是一个运行简单 Python 应用的守护进程示例。我们不会深入细节,但正如你所见,大多数字段都是相当自解释的。
# /etc/systemd/system/myapp.service [Unit]
Description=My Custom App
After=network.target
[Service]
User=foo
Group=foo
WorkingDirectory=/home/foo/projects/mydaemon
ExecStart=/usr/bin/local/python3.7 app.py
Restart=on-failure
[Install]
WantedBy=multi-user.target
此外,如果你只想以给定的频率运行某个程序,没有必要构建自定义守护进程,你可以使用 cron(系统已经运行的守护进程),来执行计划任务。
FUSE
现代软件系统通常由更小的构建块组成,这些构建块组合在一起。你的操作系统支持使用不同的文件系统后端,因为有一个通用的语言来描述文件系统支持的操作。例如,当你运行 touch 来创建文件时,touch 会向内核执行系统调用以创建文件,内核执行适当的文件系统调用以创建指定的文件。需要注意的是,UNIX 文件系统传统上作为内核模块实现,只有内核被允许执行文件系统调用。
FUSE(用户空间文件系统)允许通过用户程序实现文件系统。FUSE 允许用户运行用户空间代码以进行文件系统调用,并将必要的调用桥接到内核接口。在实践中,这意味着用户可以为文件系统调用实现任意功能。
例如,FUSE 可以用来确保每次你在虚拟文件系统中执行操作时,该操作都会通过 SSH 转发到远程机器,在那里执行,并将输出返回给你。这样,本地程序可以看到文件就像它在你的计算机中一样,而实际上它位于远程服务器上。这正是 sshfs 所做的。
一些有趣的 FUSE 文件系统示例包括:
-
sshfs - 通过 SSH 连接在本地打开远程文件/文件夹。
-
rclone - 挂载云存储服务,如 Dropbox、GDrive、Amazon S3 或 Google Cloud Storage,并在本地打开数据。
-
gocryptfs - 加密覆盖系统。文件以加密形式存储,但一旦文件系统挂载,它们在挂载点中显示为纯文本。
-
kbfs - 具有端到端加密的分布式文件系统。您可以拥有私有、共享和公共文件夹。
-
borgbackup - 通过挂载,方便地浏览您的去重、压缩和加密的备份。
备份
您尚未备份的任何数据都可能在任何时候永远消失。复制数据很容易,但可靠地备份数据却很难。以下是一些良好的备份基础和一些方法的陷阱。
首先,同一磁盘上的数据副本不是备份,因为磁盘是所有数据的单一故障点。同样,您家中的外置驱动器也是一个薄弱的备份解决方案,因为它可能会在火灾/抢劫等情况下丢失。相反,拥有异地备份是推荐的做法。
同步解决方案不是备份。例如,Dropbox/GDrive 是方便的解决方案,但当数据被删除或损坏时,它们会传播这种变化。同样,磁盘镜像解决方案如 RAID 也不是备份。如果数据被删除、损坏或被勒索软件加密,它们无法提供帮助。
良好的备份解决方案的一些核心功能是版本控制、去重和安全。版本控制备份确保您可以访问更改的历史记录并有效地恢复文件。高效的备份解决方案使用数据去重只存储增量更改并减少存储开销。至于安全,您应该问自己,为了读取您的数据,某人需要知道/拥有什么,更重要的是,为了删除所有您的数据和相关的备份。最后,盲目信任备份是一个糟糕的想法,您应该定期验证您是否可以使用它们来恢复数据。
备份不仅限于您电脑上的本地文件。鉴于网络应用的显著增长,大量数据仅存储在云端。例如,如果您失去了对应账户的访问权限,您的网络邮件、社交媒体照片、流媒体音乐播放列表或在线文档都将消失。拥有这些信息的离线副本是明智之举,您还可以找到人们构建的在线工具来抓取数据并保存。
对于更详细的解释,请参阅 2019 年的备份讲义。
API
我们在这个课程中已经讨论了很多关于如何更有效地使用电脑来完成本地任务的话题,但你将会发现,这些课程中的许多内容也适用于更广泛的互联网。大多数在线服务都会有“APIs”,允许你以编程方式访问它们的数据。例如,美国政府有一个 API,允许你获取天气预报,你可以使用它轻松地在你的 shell 中获取天气预报。
大多数这些 API 都有类似的格式。它们是结构化的 URL,通常以api.service.com为根,路径和查询参数指示你想要读取的数据或想要执行的操作。例如,对于美国天气数据,要获取特定位置的预报,你可以向 https://api.weather.gov/points/42.3604,-71.094 发出 GET 请求(例如使用curl)。响应本身包含了一组其他 URL,允许你获取该地区的特定预报。通常,响应格式为 JSON,然后你可以通过像jq这样的工具将其转换为所需的内容。
一些 API 需要身份验证,这通常是以某种形式的秘密令牌的形式,你需要将其包含在请求中。你应该阅读 API 的文档,以了解你正在寻找的特定服务使用的是什么,但“OAuth”是一个你经常会看到的协议。OAuth 的核心是一种给你提供可以在特定服务上“代表你”的令牌的方式,并且只能用于特定目的。请记住,这些令牌是秘密的,任何获得你的令牌的人都可以在你的账户下执行令牌允许的操作!
IFTTT是一个以 API 为中心的网站和服务——它提供了与众多服务的集成,并允许你以几乎任意的方式链接着些事件。给它一个看看!
常见的命令行标志/模式
命令行工具种类繁多,在使用之前,你通常会想要查看它们的man页面。尽管如此,它们通常有一些共同的特征,了解这些特征是有益的:
-
大多数工具都支持某种
--help标志来显示工具的简要使用说明。 -
许多可以造成不可逆更改的工具支持“dry run”的概念,在这种情况下,它们只会打印出它们将要执行的操作,但实际上不会执行更改。同样,它们通常有一个“interactive”标志,会提示你进行每个破坏性操作。
-
你通常可以使用
--version或-V来让程序打印其自己的版本(这对于报告错误很有用!)。 -
几乎所有工具都有一个
--verbose或-v标志来生成更详细的输出。你通常可以多次包含该标志(-vvv)以获得更多详细的输出,这对于调试很有帮助。同样,许多工具都有一个--quiet标志,用于在出错时仅打印信息。 -
在许多工具中,文件名位置上的
-代表“标准输入”或“标准输出”,具体取决于参数。 -
可能具有破坏性的工具通常默认不是递归的,但支持“递归”标志(通常为
-r)以使它们递归。 -
有时候,你可能想将看起来像标志的东西作为正常参数传递。例如,想象一下你想删除名为
-r的文件。或者你想通过另一个程序“运行”一个程序,比如ssh machine foo,并且你想向“内部”程序(foo)传递一个标志。特殊的参数--使程序停止处理随后的标志和选项(以-开头的选项),让你可以传递看起来像标志的东西,而不会将它们解释为标志:rm -- -r或ssh machine --for-ssh -- foo --for-foo。
窗口管理器
大多数人都习惯于使用“拖放”窗口管理器,比如 Windows、macOS 和 Ubuntu 默认提供的。有些窗口只是悬挂在屏幕上,你可以拖动它们,调整大小,使它们重叠。但这些只是窗口管理器的一种类型,通常被称为“浮动”窗口管理器。还有许多其他类型,尤其是在 Linux 上。一个特别常见的替代方案是“平铺”窗口管理器。在平铺窗口管理器中,窗口永远不会重叠,而是作为屏幕上的平铺排列,有点像 tmux 中的窗格。使用平铺窗口管理器时,屏幕总是被打开的窗口填满,按照某种布局排列。如果你只有一个窗口,它将占据整个屏幕。如果你再打开另一个,原始窗口会缩小以腾出空间(通常是 2/3 和 1/3)。如果你打开第三个,其他窗口将再次缩小以适应新窗口。就像 tmux 窗格一样,你可以使用键盘在这些平铺窗口之间导航,你可以调整它们的大小并移动它们,所有这些都不需要触摸鼠标。它们值得一看!
VPN
VPN(虚拟私人网络)如今非常流行,但并不清楚这是否有任何正当理由。你应该了解 VPN 能做什么和不能做什么。在最佳情况下,VPN 实际上只是让你在互联网上改变互联网服务提供商的一种方式。所有流量都将看起来像是从 VPN 提供商那里发出的,而不是从你的“真实”位置发出的,而你连接到的网络只会看到加密的流量。
虽然这听起来可能很有吸引力,但请记住,当你使用 VPN 时,你实际上只是将你的信任从当前的互联网服务提供商(ISP)转移到了 VPN 托管公司。无论你的 ISP可能看到什么,VPN 提供商现在取而代之看到。如果你比 ISP 更信任他们,那是一个胜利,但否则,并不清楚你是否获得了很多。如果你坐在机场的一些可疑的未加密公共 Wi-Fi 上,那么你可能不太信任这个连接,但在家里,这种权衡并不那么明显。
你应该还知道,如今,你的大部分流量,至少是敏感的流量,已经通过 HTTPS 或更普遍的 TLS 加密了。在这种情况下,你是在“坏”网络中还是不是,通常关系不大——网络运营商只会知道你与哪些服务器交谈,但不会知道交换的数据。
注意,我上面说的是“在最佳情况下”。VPN 提供商意外地错误配置其软件,导致加密要么很弱,要么完全禁用,这种情况并不少见。一些 VPN 提供商是恶意的(或者至少是机会主义的),他们会记录你所有的流量,并可能将其信息出售给第三方。选择一个差的 VPN 提供商通常比一开始就不使用 VPN 更糟糕。
在紧急情况下,麻省理工学院(MIT)为它的学生运行 VPN,所以这可能值得一看。此外,如果你打算自己搭建,可以看看WireGuard。
Markdown
在你的职业生涯中,你很可能要写一些文本。而且,你通常会想以简单的方式标记这些文本。你想要一些文本是粗体或斜体,或者你想要添加标题、链接和代码片段。与其使用像 Word 或 LaTeX 这样的重型工具,你可能会考虑使用轻量级标记语言Markdown。
你可能已经见过 Markdown 了,或者至少是其某种变体。它的子集几乎在所有地方都被使用和支持,即使它不叫 Markdown。在本质上,Markdown 是一种尝试规范人们在编写纯文本文档时通常如何标记文本的尝试。通过用*包围一个单词来添加强调(斜体)。使用**添加强强调(粗体)。以#开头的行是标题(#的数量是副标题级别)。任何以-开头的行是项目符号列表项,任何以数字加.开头的行是编号列表项。反引号用于显示代码字体中的单词,可以通过缩进一行四个空格或用三重反引号包围来输入代码块:
code goes here
要添加链接,将链接的 文本 放在方括号内,并在其后立即跟一个 URL:name。Markdown 很容易上手,你几乎可以在任何地方使用它。实际上,这次讲座的讲义,以及其他所有讲义,都是用 Markdown 编写的,你可以在这里看到原始的 Markdown。
Hammerspoon(macOS 上的桌面自动化)
Hammerspoon 是一个针对 macOS 的桌面自动化框架。它允许你编写 Lua 脚本来钩入操作系统功能,让你能够与键盘/鼠标、窗口、显示器、文件系统等进行交互。
你可以用 Hammerspoon 做的一些事情示例:
-
绑定热键以将窗口移动到特定位置
-
创建一个菜单栏按钮,自动将窗口布局成特定布局
-
当你到达实验室时(通过检测 Wi-Fi 网络)关闭扬声器
-
如果你意外地拿走了朋友的电源,显示一个警告
在高层次上,Hammerspoon 允许你运行任意 Lua 代码,并将其绑定到菜单按钮、按键或事件上,Hammerspoon 还提供了一套广泛的库来与系统交互,因此基本上没有你无法用它做到的事情。许多人已经将他们的 Hammerspoon 配置公开,所以你通常可以通过网络搜索找到你需要的东西,但你也可以从头开始编写自己的代码。
资源
启动 + Live USB
当你的机器启动时,在操作系统加载之前,BIOS/UEFI 会初始化系统。在这个过程中,你可以按下一个特定的键组合来配置这一层软件。例如,在启动过程中,你的电脑可能会说“按 F9 配置 BIOS。按 F12 进入启动菜单。”。你可以在 BIOS 菜单中配置各种硬件相关设置。你还可以进入启动菜单,从其他设备启动而不是硬盘。
Live USB 是包含操作系统的 USB 闪存驱动器。你可以通过下载一个操作系统(例如 Linux 发行版)并将其烧录到闪存驱动器来创建一个这样的驱动器。这个过程比简单地复制一个 .iso 文件到磁盘要复杂一些。有一些工具如 UNetbootin 可以帮助你创建 Live USB。
Live USB 在许多用途中都很有用。例如,如果你的现有操作系统安装损坏,无法启动,你可以使用 Live USB 恢复数据或修复操作系统。
Docker、Vagrant、VMs、Cloud、OpenStack
虚拟机和类似工具如容器允许你模拟整个计算机系统,包括操作系统。这可以用于创建用于测试、开发或探索的隔离环境(例如运行可能有害的代码)。
Vagrant是一个工具,允许你用代码描述机器配置(操作系统、服务、软件包等),然后通过简单的vagrant up命令实例化虚拟机。Docker在概念上类似,但它使用容器。
你还可以在云上租用虚拟机,这是一种快速获取以下资源的便捷方式:
-
一台便宜的始终在线的机器,具有公网 IP 地址,用于托管服务
-
一台拥有大量 CPU、磁盘、RAM 和/或 GPU 的机器
-
拥有的机器数量远超过你物理上能接触到的(通常按秒计费,所以如果你想在短时间内进行大量计算,租用 1000 台计算机几分钟是可行的)。
流行的服务包括Amazon AWS、Google Cloud、Microsoft Azure、DigitalOcean。
如果你属于 MIT CSAIL,你可以通过CSAIL OpenStack 实例获得免费的研究用虚拟机。
笔记本编程
笔记本编程环境在执行某些类型的交互式或探索性开发时非常方便。目前最受欢迎的笔记本编程环境可能是Jupyter,适用于 Python(以及几种其他语言)。Wolfram Mathematica是另一个非常适合进行数学方向编程的笔记本编程环境。
GitHub
GitHub是开源软件开发中最受欢迎的平台之一。我们在这个课程中讨论的许多工具,从vim到Hammerspoon,都托管在 GitHub 上。开始为开源项目贡献力量,帮助改进你每天使用的工具非常简单。
人们向 GitHub 上的项目做出贡献主要有两种方式:
-
创建一个问题。这可以用来报告错误或请求新功能。这些都不涉及阅读或编写代码,所以操作起来可以相当轻松。高质量的错误报告对开发者来说可能非常有价值。对现有讨论进行评论也可能很有帮助。
-
通过拉取请求贡献代码。这通常比创建问题更复杂。你可以在 GitHub 上分叉一个仓库,克隆你的分叉,创建一个新的分支,做一些修改(例如修复错误或实现功能),推送分支,然后创建一个拉取请求。之后,通常会有一些与项目维护者的来回交流,他们会给你关于你的补丁的反馈。最后,如果一切顺利,你的补丁将被合并到上游仓库。通常,较大的项目会有一个贡献指南,标记适合初学者的议题,有些甚至有导师计划来帮助新贡献者熟悉项目。
本作品受CC BY-NC-SA许可。
Q&A
对于最后一堂课,我们回答了学生提交的问题:
-
关于学习操作系统相关主题(如进程、虚拟内存、中断、内存管理等)有任何推荐吗?
-
你优先学习哪些工具?
-
我何时使用 Python、Bash 脚本或其它语言?
-
source script.sh和./script.sh之间的区别是什么? -
各种软件包和工具存储在哪些地方?如何引用它们?
/bin或/lib是什么? -
我应该使用
apt-get install安装 python-whatever,还是使用pip install安装任何包? -
使用哪些最容易和最好的性能分析工具来提高我的代码性能?
-
你使用哪些浏览器插件?
-
还有其他有用的数据处理工具吗?
-
Docker 和虚拟机之间的区别是什么?
-
每个操作系统的优缺点是什么?我们如何在这之间做出选择(例如,选择最适合我们目的的最佳 Linux 发行版)?
-
Vim 与 Emacs?
-
对于机器学习应用有什么技巧或窍门吗?
-
还有更多 Vim 技巧吗?
-
什么是双因素认证(2FA)以及为什么我应该使用它?
-
关于不同网络浏览器之间的差异有什么评论吗?
关于学习操作系统相关主题(如进程、虚拟内存、中断、内存管理等)有任何推荐吗?
首先,你是否真的需要非常熟悉所有这些主题还不明确,因为它们都是非常底层的主题。当你开始编写更底层的代码,如实现或修改内核时,它们才会变得重要。否则,大多数主题将不会相关,除了在其它讲座中简要提到的进程和信号。
一些关于这个主题的好资源:
-
MIT 的 6.828 课程 - 操作系统工程研究生课程。课程材料公开可用。
-
《现代操作系统》(第 4 版)- 安德鲁·S·坦南鲍姆所著,对许多提到的概念提供了良好的概述。
-
《FreeBSD 操作系统的设计与实现》- 关于 FreeBSD 操作系统的好资源(请注意,这并非 Linux)。
-
其他指南,如 在 Rust 中编写操作系统,其中人们以各种语言逐步实现内核,主要用于教学目的。
您会优先学习哪些工具?
一些值得优先考虑的主题:
-
学习如何更多地使用键盘而不是鼠标。这可以通过键盘快捷键、更改界面等方式实现。
-
熟练掌握您的编辑器。作为一名程序员,您的大部分时间都花在编辑文件上,因此熟练掌握这项技能是非常有价值的。
-
学习如何自动化和/或简化您的工作流程中的重复性任务,因为这将节省大量时间……
-
了解版本控制工具如 Git 以及如何将其与 GitHub 结合使用以在现代软件项目中协作。
我在何时使用 Python、Bash 脚本或其他语言?
通常,bash 脚本适用于短小简单的单次脚本,当您只想运行一系列特定命令时。bash 有一些怪癖,使得它难以用于较大的程序或脚本:
-
bash 对于简单用例来说很容易正确使用,但对于所有可能的输入来说,它可能非常难以正确使用。例如,脚本参数中的空格在 bash 脚本中导致了无数错误。
-
bash 不适合代码重用,因此重用先前编写的程序组件可能很困难。更普遍地说,bash 中没有软件库的概念。
-
bash 依赖于许多魔法字符串,如
$?或$@来引用特定值,而其他语言则明确地引用它们,例如exitCode或sys.args。
因此,对于更大和/或更复杂的脚本,我们建议使用更成熟的脚本语言,如 Python 或 Ruby。您可以在网上找到无数已经编写来解决这些语言中常见问题的库。如果您发现某个库实现了您关心的特定功能,通常最好的做法就是使用那种语言。
source script.sh 和 ./script.sh 之间的区别是什么?
在这两种情况下,script.sh都会在 bash 会话中读取和执行,区别在于哪个会话正在运行这些命令。对于source,命令将在您的当前 bash 会话中执行,因此对当前环境所做的任何更改,如更改目录或定义函数,将在source命令执行完毕后持续存在于当前会话中。当像./script.sh那样独立运行脚本时,您的当前 bash 会话将启动一个新的 bash 实例来运行script.sh中的命令。因此,如果script.sh更改目录,新的 bash 实例将更改目录,但一旦它退出并将控制权返回给父 bash 会话,父会话将保持在同一位置。同样,如果script.sh定义了一个您想在终端中访问的函数,您需要source它,以便在您的当前 bash 会话中定义。否则,如果您运行它,新的 bash 进程将处理函数定义,而不是您的当前 shell。
各种包和工具存储在哪里?如何引用它们?/bin或/lib是什么?
关于您在终端中运行的程序,它们都位于您的PATH环境变量中列出的目录中,您可以使用which命令(或type命令)来检查您的 shell 正在寻找特定程序的位置。一般来说,有一些关于特定类型文件存放位置的传统。以下是我们讨论的一些内容,有关更全面的列表,请参阅文件系统层次标准。
-
/bin- 重要的命令二进制文件 -
/sbin- 重要的系统二进制文件,通常由 root 运行 -
/dev- 设备文件,通常是硬件设备的接口 -
/etc- 主机特定的系统级配置文件 -
/home- 系统中用户的家目录 -
/lib- 系统程序的公共库 -
/opt- 可选的应用软件 -
/sys- 包含系统信息和配置(在第一讲中介绍) -
/tmp- 临时文件(也有/var/tmp)。通常在重启之间被删除。 -
/usr/- 只读用户数据-
/usr/bin- 非重要命令二进制文件 -
/usr/sbin- 非重要系统二进制文件,通常由 root 运行 -
/usr/local/bin- 用户编译程序的二进制文件
-
-
/var- 变量文件,如日志或缓存
我应该使用apt-get install安装一个 python-whatever,还是使用pip install安装任何包?
对于这个问题没有统一的答案。它与更普遍的问题有关,即您应该使用系统包管理器还是语言特定的包管理器来安装软件。以下是一些需要考虑的事项:
-
常见包将通过两者都提供,但不太受欢迎的或较新的包可能不在您的系统包管理器中提供。在这种情况下,使用语言特定的工具是更好的选择。
-
类似地,语言特定的包管理器通常比系统包管理器提供更新的包版本。
-
当使用您的系统包管理器时,库将被系统范围内安装。这意味着,如果您需要为开发目的安装不同版本的库,系统包管理器可能就不够用了。在这种情况下,大多数编程语言都提供某种隔离或虚拟环境,这样您就可以在不发生冲突的情况下安装库的不同版本。对于 Python,有 virtualenv,而对于 Ruby,则有 RVM。
-
根据操作系统和硬件架构,这些包中的一些可能包含二进制文件,或者可能需要编译。例如,在像树莓派这样的 ARM 计算机上,如果系统包管理器提供的是二进制文件,而语言特定的包管理器需要编译,那么使用系统包管理器可能比使用语言特定的包管理器更好。这高度依赖于您的具体配置。
您应该尝试使用一种解决方案或另一种解决方案,而不是两者都使用,因为这可能导致难以调试的冲突。我们的建议是在可能的情况下使用语言特定的包管理器,并使用隔离环境(如 Python 的 virtualenv)来避免污染全局环境。
使用什么是最简单和最好的分析工具来提高我代码的性能?
对于分析目的来说,最简单且相当有用的工具是打印计时。您只需手动计算代码不同部分之间所花费的时间。通过反复这样做,您可以有效地在代码上执行二分搜索,并找到耗时最长的代码段。
对于更高级的工具,Valgrind 的Callgrind允许您运行程序并测量每项操作所需的时间以及所有调用栈,即哪个函数调用了哪个其他函数。然后,它会产生带有每行所花费时间的程序源代码的注释版本。然而,它会使程序的速度慢一个数量级,并且不支持线程。对于其他情况,perf工具和其他语言特定的采样分析器可以快速输出有用的数据。火焰图是上述采样分析器输出的良好可视化工具。您还应该尝试使用针对您正在使用的编程语言或任务的特定工具。例如,对于 Web 开发,Chrome 和 Firefox 内置的开发工具具有出色的分析器。
有时候,你的代码中较慢的部分可能是因为你的系统正在等待像磁盘读取或网络数据包这样的事件。在这些情况下,检查关于硬件能力的理论速度的粗略计算是否与实际读数不偏离是值得的。还有专门的分析系统调用等待时间的工具。这些包括像eBPF这样的工具,它执行用户程序的内核跟踪。特别是如果你需要执行这种低级分析,bpftrace(https://github.com/iovisor/bpftrace)是值得检查的。
你使用哪些浏览器插件?
我们的一些最爱,大多与安全和可用性相关:
-
uBlock Origin - 它是一个宽频带拦截器,不仅阻止广告,还阻止页面可能尝试的所有第三方通信。这也包括内联脚本和其他类型的资源加载。如果你愿意花些时间进行配置以使一切正常工作,请转到中等模式或甚至困难模式。这将使一些网站在调整设置足够后才能正常工作,但也会显著提高你的在线安全性。否则,简单模式已经是一个很好的默认设置,可以阻止大多数广告和跟踪。你还可以定义自己的规则,关于要阻止哪些网站对象。
-
Stylus - 是 Stylish 的一个分支(不要使用 Stylish,因为它已被证明会窃取用户的浏览历史),允许你向网站侧载自定义 CSS 样式表。使用 Stylus,你可以轻松地自定义和修改网站的外观。这包括移除侧边栏、更改背景颜色,甚至文本大小或字体选择。这对于使你经常访问的网站更易读是非常棒的。此外,Stylus 还可以找到其他用户编写并发布在userstyles.org上的样式。例如,大多数常见网站都有一个或多个暗色主题样式表。
-
全页屏幕截图 - 集成在 Firefox 中和Chrome 扩展。允许你截取整个网站的截图,通常比打印用于参考更好。
-
多账户容器 - 允许你将 cookie 分离到“容器”中,这样你可以使用不同的身份浏览网页,或者确保网站之间无法共享信息。
-
密码管理器集成 - 大多数密码管理器都有浏览器扩展,可以让你在网站上输入凭据不仅更方便,而且更安全。与简单地复制粘贴用户名和密码相比,这些工具首先会检查网站域名是否与条目中列出的域名匹配,从而防止冒充知名网站以窃取凭据的钓鱼攻击。
-
Vimium - 一个提供基于键盘导航和控制网页的浏览器扩展,其灵感来源于 Vim 编辑器。
其他有用的数据处理工具有哪些?
在数据处理讲座中我们没有时间涵盖的一些数据处理工具包括jq或pup,它们分别是针对 JSON 和 HTML 数据的专用解析器。Perl 编程语言也是更高级数据处理管道的好工具。另一个技巧是column -t命令,它可以用来将空白文本(不一定对齐)转换为正确对齐的文本。
更普遍地说,还有一些不太常规的数据处理工具是 vim 和 Python。对于一些复杂的多行转换,vim 宏可以是一个非常有价值的工具。你可以记录一系列动作,并重复执行任意次数,例如在编辑器的讲义(以及去年的视频)中,有一个使用 vim 宏将 XML 格式文件转换为 JSON 的例子。
对于通常以 CSV 格式呈现的表格数据,Python 的pandas库是一个很好的工具。不仅因为它使得定义复杂的操作(如分组、连接或过滤器)变得相当容易;还因为它使得绘制数据的不同属性变得相当容易。它还支持导出到多种表格格式,包括 XLS、HTML 或 LaTeX。或者,R 编程语言(一个有争议的糟糕编程语言)在计算数据统计方面有很多功能,可以作为管道的最后一步非常有用。ggplot2是 R 中一个优秀的绘图库。
Docker 和虚拟机之间的区别是什么?
Docker 基于一个更通用的概念,称为容器。容器和虚拟机之间的主要区别在于虚拟机会执行整个操作系统堆栈,包括内核,即使内核与宿主机器相同。与虚拟机不同,容器避免运行另一个内核实例,而是与宿主共享内核。在 Linux 中,这是通过称为 LXC 的机制实现的,并利用一系列隔离机制来启动一个认为它在自己的硬件上运行的程序,但实际上它与宿主共享硬件和内核。因此,容器比完整虚拟机的开销更低。另一方面,容器的隔离性较弱,并且只有在宿主运行相同内核的情况下才能工作。例如,如果你在 macOS 上运行 Docker,Docker 需要启动一个 Linux 虚拟机以获取初始 Linux 内核,因此开销仍然很大。最后,Docker 是容器的一个特定实现,它针对软件部署进行了定制。因此,它有一些怪癖:例如,默认情况下,Docker 容器在重启之间不会持久化任何形式的存储。
每个操作系统的优缺点是什么?我们如何在这之间做出选择(例如,选择最适合我们目的的最佳 Linux 发行版)?
关于 Linux 发行版,尽管种类繁多,但大多数发行版在大多数使用场景下表现相当相似。Linux 和 UNIX 的许多特性和内部工作原理在任何发行版中都可以学习到。发行版之间的一个基本区别在于它们处理软件包更新的方式。一些发行版,如 Arch Linux,采用滚动更新策略,这意味着技术前沿但偶尔可能会出现问题。另一方面,一些发行版如 Debian、CentOS 或 Ubuntu LTS 版本在它们的仓库中发布更新时更为保守,因此通常更稳定,但牺牲了一些新功能。我们推荐使用 Debian 或 Ubuntu 来获得桌面和服务器上既简单又稳定的体验。
Mac OS 是 Windows 和 Linux 之间一个很好的中间点,它有一个非常精致的界面。然而,Mac OS 基于 BSD 而不是 Linux,因此系统的一些部分和命令是不同的。另一个值得检查的替代方案是 FreeBSD。尽管一些程序在 FreeBSD 上无法运行,但 BSD 生态系统比 Linux 更少碎片化,文档也更好。我们不建议使用 Windows,除非是用于开发 Windows 应用程序或需要某些决定性功能,例如良好的游戏驱动程序支持。
对于双启动系统,我们认为 macOS 的 Boot Camp 是最有效的实现方式,而任何其他组合在长期使用中都可能存在问题,特别是如果你将其与其他功能(如磁盘加密)结合使用。
Vim 与 Emacs?
我们三个人使用 vim 作为我们的主要编辑器,但 Emacs 也是一个不错的选择,值得尝试两者以看哪个更适合你。Emacs 不遵循 vim 的模式编辑,但可以通过 Emacs 插件如 Evil 或 Doom Emacs 启用。使用 Emacs 的一个优点是扩展可以在 Lisp 中实现,这是一种比 Vimscript 更好的脚本语言,Vim 的默认脚本语言。
有关于机器学习应用的任何技巧或窍门吗?
本课程的一些经验和教训可以直接应用于机器学习应用。正如许多科学学科的情况一样,在机器学习中,你经常进行一系列实验,并希望检查哪些事情有效,哪些无效。你可以使用 shell 工具轻松快速地搜索这些实验,并以合理的方式汇总结果。这可能意味着在给定的时间范围内选择所有实验,或者使用特定的数据集。通过使用简单的 JSON 文件记录所有相关实验参数,这可以非常简单,因为我们在这个课程中介绍的工具。最后,如果你不与某种类型的集群一起工作,其中你提交 GPU 作业,你应该考虑如何自动化此过程,因为这可能是一个相当耗时的任务,也会消耗你的精神能量。
还有更多 Vim 技巧吗?
一些额外的技巧:
-
插件 - 仔细探索插件领域。有很多优秀的插件解决了 vim 的一些不足,或者添加了与现有 vim 工作流程很好地结合的新功能。为此,VimAwesome 和其他程序员的 dotfiles 是很好的资源。
-
标记 - 在 vim 中,你可以通过
m<X>为某个字母X设置一个标记。然后你可以通过'<X>'返回到那个标记。这让你可以快速导航到文件中的特定位置,甚至跨文件。 -
导航 -
Ctrl+O和Ctrl+I分别将你向后和向前移动到最近访问的位置。 -
撤销树 - Vim 有一种相当花哨的机制来跟踪更改。与其他编辑器不同,vim 存储一个更改树,因此即使你撤销更改后进行不同的更改,你仍然可以通过导航撤销树回到原始状态。一些插件,如 gundo.vim 和 undotree,以图形方式展示了这个树。
-
基于时间的撤销 -
:earlier和:later命令将允许你使用时间参考而不是逐个更改来导航文件。 -
持久撤销 是 vim 的一个惊人的内置功能,默认情况下是禁用的。它会在 vim 调用之间持续撤销历史。通过在
.vimrc中设置undofile和undodir,vim 将存储每个文件的更改历史。 -
领导键 - 领导键是一个特殊键,通常留给用户配置自定义命令。通常的做法是按下并释放此键(通常是空格键),然后按下另一个键来执行特定命令。通常,插件会使用此键来添加它们自己的功能,例如 UndoTree 插件使用
<Leader> U来打开撤销树。 -
高级文本对象 - 类似于搜索的文本对象也可以与 vim 命令组合。例如,
d/<pattern>将删除到下一个匹配的该模式,或者cgn将更改最后一个搜索字符串的下一个出现。
什么是 2FA,为什么我应该使用它?
双因素认证(2FA)在密码的基础上为您的账户增加了额外的安全层。为了登录,您不仅要知道一些密码,还必须以某种方式“证明”您有权访问某些硬件设备。在最简单的情况下,这可以通过在您的手机上接收短信来实现,尽管存在已知问题与短信 2FA 相关。我们推荐的一个更好的替代方案是使用通用第二因素解决方案,如YubiKey。
对不同网络浏览器之间的差异有何评论?
截至 2020 年的浏览器格局是,大多数浏览器都像 Chrome 一样,因为它们使用相同的引擎(Blink)。这意味着基于 Blink 的 Microsoft Edge 和基于与 Blink 类似的引擎 WebKit 的 Safari 只是 Chrome 的较差版本。Chrome 在性能和可用性方面都是一个相当不错的浏览器。如果您想要一个替代品,我们推荐 Firefox。它在几乎所有方面都与 Chrome 相当,并且由于隐私原因而表现出色。另一个名为Flow的浏览器尚未准备好供用户使用,但它正在实施一个承诺比当前引擎更快的新的渲染引擎。
本文档遵循CC BY-NC-SA许可协议。
2026
课程概述 + Shell 简介
我们是谁?
这门课程由Anish、Jon和Jose共同授课。我们都是前 MIT 学生,当时我们作为学生时就开始了这门 MIT IAP 课程。您可以通过 missing-semester@mit.edu 与我们联系。
我们并不为此课程获得报酬,也不会以任何方式将课程货币化。我们将所有课程材料和讲座录音免费提供给在线用户。如果您想支持我们的工作,最好的方式就是简单地将课程信息传播出去。如果您是一家公司、大学或其他组织,并且将此内容推广给更大的群体,请通过电子邮件发送经验报告/推荐信,这样我们就能了解到相关信息了 😃
动机
作为计算机科学家,我们知道计算机在辅助重复性任务方面非常出色。然而,我们往往忘记了这一点同样适用于我们使用计算机的方式,就像它适用于我们希望程序执行的计算一样。我们手头上有各种各样的工具,这些工具使我们能够在处理任何与计算机相关的问题时更加高效并解决更复杂的问题。然而,我们中的许多人只利用了其中的一小部分工具;我们只是通过死记硬背知道足够的咒语来应付,当我们遇到困难时,就盲目地从互联网上复制粘贴命令。
这门课程试图解决这个问题。
我们希望教会您如何充分利用您已知的工具,向您展示可以添加到工具箱中的新工具,并希望激发您探索(也许还有自己构建)更多工具的热情。这是我们相信的大多数计算机科学课程中缺失的学期。
课程结构
这门不计学分的课程由九个 1 小时讲座组成,每个讲座都围绕一个特定主题展开。讲座在很大程度上是独立的,但随着学期的进行,我们将假设您已经熟悉了早期讲座的内容。我们在线上有讲座笔记,但课堂上可能有一些内容(例如以演示的形式)没有包含在笔记中。至于往年的情况,我们将录制讲座并在线发布。
我们试图在短短几个 1 小时的讲座中涵盖大量内容,因此讲座内容相当密集。为了让你有时间以自己的节奏熟悉内容,每个讲座都包含一系列练习,引导你了解讲座的关键点。我们不会开设专门的办公时间,但我们鼓励你在OSSU Discord的#missing-semester-forum频道提问,或者通过电子邮件发送给我们 missing-semester@mit.edu。
由于我们有限的时间,我们无法像完整规模的课程那样详细地介绍所有工具。在可能的情况下,我们将尝试引导你了解进一步挖掘工具或主题的资源,但如果有什么特别吸引你的,请不要犹豫,向我们寻求指导!
最后,如果您对这门课程有任何反馈,请通过电子邮件发送给我们,邮箱地址是 missing-semester@mit.edu。
主题 1:Shell
什么是 shell?
今天的计算机有各种各样的命令输入接口;花哨的图形用户界面、语音界面、AR/VR,以及最近:LLMs。这些对于 80%的使用场景来说都很棒,但它们在允许你做什么方面通常有根本性的限制——你不能按一个不存在的按钮,也不能给出一个未编程的语音命令。为了充分利用计算机提供的工具,我们必须回到老式的方法,降级到文本界面:Shell。
几乎所有你可以接触到的平台都以某种形式提供 shell,其中许多平台提供了多个 shell 供你选择。虽然它们在细节上可能有所不同,但它们的本质大致相同:它们允许你运行程序,向它们提供输入,并以半结构化的方式检查它们的输出。
要打开 shell 提示符(在那里你可以输入命令),你首先需要一个 终端,它是 shell 的视觉界面。你的设备可能已经预装了一个,或者你可以相对容易地安装一个:
-
Linux: 按
Ctrl + Alt + T(在大多数发行版上有效)。或者在你的应用程序菜单中搜索“终端”。 -
Windows: 按
Win + R,输入cmd或powershell,然后按 Enter。或者你可以在开始菜单中搜索“终端”或“命令提示符”。 -
macOS: 按
Cmd + Space打开 Spotlight,输入“Terminal”,然后按 Enter。或者你可以在应用程序→实用工具→Terminal 中找到它。
在 Linux 和 macOS 上,这通常将打开 Bourne Again SHell,或简称“bash”。这是最广泛使用的 shell 之一,其语法与你在许多其他 shell 中看到的大致相同。在 Windows 上,你将遇到“批处理”或“powershell”shell,具体取决于你运行了哪个命令。这些是 Windows 特有的,我们在这个课程中不会关注它们,尽管它们与我们将要教授的大部分内容都有类似之处。你将需要Windows Subsystem for Linux或 Linux 虚拟机。
其他 shell 也存在,通常在 bash(fish 和 zsh 是最常见的)上有很多用户体验改进。虽然这些非常受欢迎(所有讲师都使用一个),但它们远不如 bash 普遍,并且依赖于许多相同的概念,所以我们不会在这个讲座中关注它们。
你为什么应该关心它?
shell 不仅仅是(通常)比“点击”快得多,它还提供了你在任何单个图形程序中都难以找到的表达能力。正如我们将看到的,shell 让你能够以创造性的方式组合程序,从而自动化几乎任何任务。
熟悉 shell 的使用对于导航开源软件的世界非常有用(这些软件通常带有需要 shell 的安装说明),为你的软件项目构建持续集成(如代码质量讲座中所述),以及在其他程序失败时调试错误。
在 shell 中导航
当你启动终端时,你会看到一个提示符,通常看起来有点像这样:
missing:~$
这是 shell 的主要文本界面。它告诉你你正在机器missing上,你的“当前工作目录”,或你目前所在的位置,是~(代表“家”)。$告诉你你不是 root 用户(稍后会有更多关于这个话题的讨论)。在这个提示符下,你可以输入一个命令,然后 shell 会解释这个命令。最基本的命令是执行一个程序:
missing:~$ date
Fri 10 Jan 2020 11:49:31 AM EST missing:~$
在这里,我们执行了date程序,它(可能不出所料)打印了当前的日期和时间。然后 shell 会要求我们执行另一个命令。我们也可以用参数执行命令:
missing:~$ echo hello
hello
在这种情况下,我们告诉 shell 执行程序echo,并带有参数hello。echo程序简单地打印出其参数。shell 通过按空格分割命令来解析它,然后运行由第一个单词指示的程序,并将每个后续单词作为程序可以访问的参数提供。如果你想提供一个包含空格或其他特殊字符的参数(例如,名为“我的照片”的目录),你可以用'或"("我的照片")引用参数,或者用\(My\ Photos)转义相关字符。
当你刚开始时,最重要的命令可能是 man,简称“手册”。man 程序(以及其他功能)允许你查找有关系统上任何命令的更多信息。例如,如果你运行 man date,它会解释 date 是什么,以及你可以传递给它以改变其行为的各种参数。你通常也可以通过将 --help 作为参数传递给大多数命令来获取简短的帮助信息。
除了
man之外,考虑安装并使用tldr,因为它会在终端中直接显示常见的使用示例。大型语言模型(LLMs)通常也非常擅长解释命令的工作原理以及如何调用它们以实现你想要达成的目标。
在 man 之后,最重要的命令要学习的是 cd,或称为“更改目录”。这个命令实际上是内置于 shell 中的,不是一个单独的程序(即,which cd 会显示“未找到 cd”)。你传递给它一个路径,该路径将成为你的当前工作目录。你也会在 shell 提示符中看到工作目录的反映:
missing:~$ cd /bin
missing:/bin$ cd /
missing:/$ cd ~
missing:~$
注意,shell 自带自动补全功能,所以你可以通过按
<TAB>来更快地完成路径!
许多命令在没有指定其他路径的情况下都会在当前工作目录下操作。如果你不确定自己在哪里,可以运行 pwd 或打印 $PWD 环境变量(使用 echo $PWD),这两个命令都会显示当前工作目录。
当前工作目录非常有用,因为它允许我们使用 相对 路径。到目前为止,我们看到的路径都是 绝对 的——它们以 / 开头,并给出从文件系统根目录(/)导航到某个位置所需的所有目录。在实践中,你更可能使用相对路径;之所以称为相对路径,是因为它们相对于当前工作目录。在相对路径(不以 / 开头的任何路径)中,第一个路径组件将在当前工作目录中查找,后续组件将按常规遍历。例如:
missing:~$ cd /
missing:/$ cd bin
missing:/bin$
每个目录中还存在两个“特殊”组件:. 和 ..。. 表示“当前目录”,而 .. 表示“父目录”。所以:
missing:~$ cd /
missing:/$ cd bin/../bin/../bin/././../bin/..
missing:/$
你通常可以在任何命令参数中使用绝对和相对路径互换,只需在使用相对路径时记住你的当前工作目录即可!
考虑安装并使用
zoxide来加速你的cd操作——z会记住你经常访问的路径,并允许你通过更少的输入来访问它们。
在 shell 中有什么可用?
但 shell 是如何知道如何找到像 date 或 echo 这样的程序的?如果 shell 被要求执行一个命令,它会咨询一个名为 $PATH 的 环境变量,该变量列出了 shell 在接收到命令时应搜索哪些目录以查找程序:
missing:~$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin missing:~$ which echo
/bin/echo missing:~$ /bin/echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
当我们运行echo命令时,shell 会看到它应该执行程序echo,然后会在$PATH中以冒号分隔的目录列表中搜索同名文件。当找到它时,就会运行它(假设文件是可执行的;关于这一点稍后还会详细说明)。我们可以使用which程序找出给定程序名对应的文件。我们也可以通过给出要执行的文件的路径来完全绕过$PATH。
这也为我们提供了如何确定在 shell 中可以执行的所有程序的线索:通过列出$PATH上所有目录的内容。我们可以通过将给定的目录路径传递给ls程序来实现这一点,该程序会列出文件:
missing:~$ ls /bin
考虑安装并使用
eza来获得一个更人性化的ls命令。
在大多数计算机上,这将打印出大量的程序,但在这里我们只关注其中一些最重要的。首先是一些简单的:
-
cat file,它打印file的内容。 -
sort file,它以排序顺序打印file的行。 -
uniq file,它从file中删除连续重复的行。 -
head file和tail file,分别打印file的第一行和最后一行。
考虑安装并使用
bat代替cat来进行语法高亮和滚动。
还有grep pattern file,它会在file中查找匹配pattern的行。这个命令非常实用,并且具有比预期更广泛的功能。pattern实际上是一个正则表达式,可以表达非常复杂的模式——我们将在代码质量讲座中介绍这些内容。您也可以指定一个目录而不是文件(或省略它以使用.),并通过传递-r来递归搜索目录中的所有文件。
考虑安装并使用
ripgrep代替grep,以获得更快且更人性化的(但不太便携)替代方案。ripgrep默认也会递归搜索当前工作目录!
同时也有一些非常实用的工具,但界面稍微复杂一些。其中最重要的是sed,这是一个程序化的文件编辑器。它拥有自己的编程语言,用于对文件进行自动化编辑,但最常用的用法是:
missing:~$ sed -i 's/pattern/replacement/g' file
在 file 中替换所有 pattern 实例为 replacement。-i 表示我们希望替换在行内发生(而不是留下未修改的 file 并打印替换后的内容)。s/ 是在 sed 编程语言中表示我们想要进行替换的方式。/ 将模式与替换项分开。尾随的 /g 表示我们想要替换每行的所有出现,而不仅仅是第一个。与 grep 一样,这里的 pattern 是一个正则表达式,它赋予你强大的表达能力。正则表达式替换还允许 replacement 引用匹配模式的各个部分;我们将在下一秒看到一个例子。
接下来是 find,它允许你查找符合某些条件的文件(递归地)。例如:
missing:~$ find ~/Downloads -type f -name "*.zip" -mtime +30
在下载目录中查找超过 30 天的 ZIP 文件。
missing:~$ find ~ -type f -size +100M -exec ls -lh {} \;
在你的主目录中查找大于 100M 的文件并将它们列出。注意 -exec 接受一个以独立 ; 结尾的 命令(我们像对待空格一样需要转义它),其中 {} 由 find 替换为每个匹配的文件路径。
missing:~$ find . -name "*.py" -exec grep -l "TODO" {} \;
查找包含 TODO 项的任何 .py 文件。
find 的语法可能有点令人畏惧,但希望这能让你感受到它有多有用!
考虑安装并使用
fd(github.com/sharkdp/fd)来代替find,以获得更人性化的(但不太便携的)体验。
接下来是 awk,它就像 sed 一样有自己的编程语言。sed 是为编辑文件而构建的,而 awk 是为解析文件而构建的。到目前为止,awk 最常见的用途是处理具有常规语法的数据文件(如 CSV 文件),你只想提取每条记录的特定部分(即行):
missing:~$ awk '{print $2}' file
打印 file 中每行的第二个空白分隔的列。如果你添加 -F,,它将打印每行的第二个逗号分隔的列。awk 可以做更多的事情——过滤行、计算聚合等——详见练习以了解其风味。
将这些工具组合起来,我们可以做一些很酷的事情,比如:
missing:~$ ssh myserver 'journalctl -u sshd -b-1 | grep "Disconnected from"' \
| sed -E 's/.*Disconnected from .* user (.*) [^ ]+ port.*/\1/' \
| sort | uniq -c \
| sort -nk1,1 | tail -n10 \ | awk '{print $2}' | paste -sd, postgres,mysql,oracle,dell,ubuntu,inspur,test,admin,user,root
这从远程服务器抓取 SSH 日志(我们将在下一讲中更多地讨论 ssh),搜索断开连接的消息,从每个此类消息中提取用户名,并以逗号分隔打印前 10 个用户名。所有这些都在一个命令中完成!我们将把分析每个步骤作为练习留给你们。
Shell 语言(bash)
之前的例子介绍了一个新概念:管道(|)。这些允许你将一个程序的输出与另一个程序输入连接起来。这是因为大多数命令行程序如果没有给出 file 参数,将会操作它们的“标准输入”(你的按键通常去的地方)。| 会将程序在 | 之前的“标准输出”(通常打印到你的终端的内容)作为 | 之后程序的标准输入。这允许你 组合 shell 程序,这也是 shell 成为一个高效工作环境的一部分!
事实上,大多数 shell 都实现了一种完整的编程语言(如 bash),就像 Python 或 Ruby 一样。它有变量、条件语句、循环和函数。当你你在 shell 中运行命令时,你实际上是在编写一小段代码,由 shell 进行解释。我们今天不会教你所有的 bash,但有一些功能你会发现特别有用:
首先,重定向:>file 允许你将程序的输出标准输出写入到 file 中,而不是你的终端。这使得事后分析更容易。>>file 将会追加到 file 而不是覆盖它。还有 <file,它告诉 shell 从 file 读取,而不是从你的键盘读取作为程序的标准输入。
这正是提到
tee程序的好时机。tee将标准输入打印到标准输出(就像cat一样!)但也会 同时 写入到文件中。所以verbose cmd | tee verbose.log | grep CRITICAL将会保留完整的详细日志到文件中,同时保持你的终端整洁!
接下来,条件语句:if command1; then command2; command3; fi 会执行 command1,如果它没有产生错误,将会运行 command2 和 command3。你也可以根据需要有一个 else 分支。最常用的 command1 命令是 test 命令,通常简写为 [,它允许你评估条件,如“文件是否存在” (test -f file / [ -f file ]) 或“字符串是否等于另一个” ([ "$var" = "string" ])。在 bash 中,还有 [[ ]],这是一个“更安全”的 test 内置版本,它在引号周围有更少奇怪的行为。
Bash 还有两种循环形式,while 和 for。while command1; do command2; command3; done 的功能与等效的 if 命令类似,只不过它会在 command1 不出错的情况下重复执行整个操作。for varname in a b c d; do command; done 会执行 command 四次,每次将 $varname 设置为 a、b、c 和 d 中的一个。你通常不会明确列出项目,而是会使用“命令替换”,例如:
for i in $(seq 1 10); do
这将执行 seq 1 10 命令(打印从 1 到 10 的所有数字)并将整个 $() 替换为该命令的输出,从而提供一个 10 次迭代的 for 循环。在旧代码中,你有时会看到实际的反引号(如 for i in `seq 1 10`; do)而不是 $(),但你应该强烈偏好 $() 形式,因为它可以嵌套。
虽然 你可以 直接在你的提示符中编写长的 shell 脚本,但你通常会将它们写入一个 .sh 文件。例如,以下是一个脚本,它将循环运行程序直到它失败,只打印失败的运行输出,同时在后台压力测试 CPU(例如,用于重现不可靠的测试):
#!/bin/bash
set -euo pipefail
# Start CPU stress in background
stress --cpu 8 &
STRESS_PID=$!
# Setup log file
LOGFILE="test_runs_$(date +%s).log"
echo "Logging to $LOGFILE"
# Run tests until one fails
RUN=1
while cargo test my_test > "$LOGFILE" 2>&1; do echo "Run $RUN passed"
((RUN++))
done
# Cleanup and report
kill $STRESS_PID
echo "Test failed on run $RUN"
echo "Last 20 lines of output:"
tail -n 20 "$LOGFILE"
echo "Full log: $LOGFILE"
这其中包含了一些新的内容,我建议你花些时间深入研究,因为它们在构建有用的 shell 调用方面非常有用,例如后台作业 (&) 以并发运行程序,更复杂的 shell 重定向,以及 算术扩展。
虽然值得花点时间关注程序的前两行。第一行是“shebang” – 你也会在其他文件的顶部看到它。当一个以魔法咒语 #!/path 开头的文件被执行时,shell 将在 /path 启动程序,并将文件的内容作为输入传递给它。在 shell 脚本的情况下,这意味着将 shell 脚本的内容传递给 /bin/bash,但你也可以编写具有 /usr/bin/python shebang 行的 Python 脚本!
第二行是一种使 bash “更严格”的方法,并在编写 shell 脚本时减轻了许多陷阱。set 可以接受大量的参数,但简要来说:-e 使得如果任何命令失败,脚本会提前退出;-u 使得使用未定义的变量会导致脚本崩溃,而不是简单地使用空字符串;-o pipefail 使得如果 | 序列中的程序失败,整个 shell 脚本也会提前退出。
Shell 编程是一个深奥的主题,就像任何编程语言一样,但请注意:bash 有很多陷阱,以至于有 多个 网站 列出它们。我强烈建议在编写它们时大量使用 shellcheck。LLMs 在编写和调试 shell 脚本以及将它们转换为“真实”编程语言(如 Python)方面也非常出色,当它们在 bash 中变得难以控制时(100+ 行)。
下一步
到目前为止,你已经掌握了足够多的 shell 知识来完成基本任务。你应该能够导航到找到感兴趣的文件,并使用大多数程序的基本功能。在下一讲中,我们将讨论如何使用 shell 和许多实用的命令行程序来执行和自动化更复杂的任务。
练习
本课程中的所有课程都附有一系列练习。有些练习会给你一个具体的任务去做,而有些则是开放式的,例如“尝试使用 X 和 Y 程序”。我们强烈鼓励你尝试它们。
我们没有为练习编写解决方案。如果你在某个特定的问题上遇到了困难,请随时在Discord上的#missing-semester-forum帖子中发布,或者发送一封电子邮件描述你迄今为止所尝试的内容,我们将尽力帮助你。这些练习也可能作为与 LLM 对话的初始提示非常有效,你可以在其中交互式地深入研究主题。这些练习的真正价值在于发现答案的过程,而不是答案本身。我们鼓励你在解决它们时跟随思路,并询问“为什么”,而不是仅仅寻找解决问题的最短路径。
-
对于这门课程,你需要使用像 Bash 或 ZSH 这样的 Unix shell。如果你使用 Linux 或 macOS,你不需要做任何特殊的事情。如果你使用 Windows,你需要确保你不在运行 cmd.exe 或 PowerShell;你可以使用Windows Subsystem for Linux或 Linux 虚拟机来使用 Unix 风格的命令行工具。为了确保你正在运行适当的 shell,你可以尝试运行命令
echo $SHELL。如果它显示类似/bin/bash或/usr/bin/zsh的信息,这意味着你正在运行正确的程序。 -
ls命令的-l标志有什么作用?运行ls -l /并检查输出。每一行的前 10 个字符代表什么?(提示:man ls) -
在命令
find ~/Downloads -type f -name "*.zip" -mtime +30中,*.zip是一个“通配符”。什么是通配符?创建一个包含一些文件的测试目录,并尝试使用ls *.txt、ls file?.txt和ls {a,b,c}.txt等模式进行实验。参见 Bash 手册中的模式匹配。 -
“单引号”,
"双引号"和$'ANSI 引号'之间有什么区别?写一个命令,输出一个包含字面量$,!和换行符的字符串。参见引号。 -
shell 有三个标准流:stdin(0)、stdout(1)和 stderr(2)。运行
ls /nonexistent /tmp并将 stdout 重定向到一个文件,stderr 重定向到另一个文件。你将如何将两者都重定向到同一个文件?参见重定向。 -
$?保存了最后一个命令的退出状态(0 = 成功)。&&仅在之前的命令成功时运行下一个命令;||仅在之前的命令失败时运行。编写一个单行命令,仅在目录不存在时创建/tmp/mydir。参见 退出状态。 -
为什么
cd必须集成到 shell 本身而不是一个独立程序中?(提示:考虑子进程可以和不能影响其父进程的内容。) -
编写一个脚本,该脚本接受一个文件名作为参数(
$1)并使用test -f或[ -f ... ]检查文件是否存在。根据文件是否存在打印不同的信息。参见 Bash 条件表达式。 -
将之前练习中的脚本保存到文件中(例如,
check.sh)。尝试使用./check.sh somefile运行它。发生了什么?现在运行chmod +x check.sh再试一次。为什么这一步是必要的?(提示:在chmod之前和之后使用ls -l check.sh查看。) -
如果你在脚本中的
set标志中添加-x会发生什么?用一个简单的脚本试一试,并观察输出。参见 Set 内置命令。 -
编写一个命令,将文件复制到带有今天日期的备份文件中(例如,
notes.txt→notes_2026-01-12.txt)。(提示:$(date +%Y-%m-%d))。参见 命令替换。 -
修改讲座中的易出错的测试脚本,使其接受测试命令作为参数而不是硬编码
cargo test my_test。(提示:$1或$@)。参见 特殊参数。 -
使用管道查找主目录中 5 个最常见的文件扩展名。(提示:结合
find、grep或sed或awk、sort、uniq -c和head。) -
xargs将标准输入的行转换为命令参数。将find和xargs结合使用(而不是find -exec)来查找目录中的所有.sh文件,并使用wc -l计算每个文件的行数。加分项:使其能够处理包含空格的文件名。(提示:-print0和-0)。参见man xargs。 -
使用
curl获取课程网站的 HTML(https://missing.csail.mit.edu/),并将其通过grep输出以计算列出的讲座数量。(提示:查找每个讲座出现一次的模式;使用curl -s来静音进度输出。) -
jq是处理 JSON 数据的强大工具。使用curl从https://microsoftedge.github.io/Demos/json-dummy-data/64KB.json获取示例数据,并使用jq提取版本号大于 6 的人的名字。(提示:首先通过jq .管道查看结构;然后尝试jq '.[] | select(...) | .name') -
awk可以根据列值过滤行并操作输出。例如,awk '$3 ~ /pattern/ {$4=""; print}'只打印第三列匹配pattern的行,同时省略第四列。编写一个awk命令,只打印第二列大于 100 的行,并交换第一列和第三列。测试用例:printf 'a 50 x\nb 150 y\nc 200 z\n' -
从讲座中剖析 SSH 日志管道:每一步做什么?然后构建类似的东西,从
~/.bash_history(或~/.zsh_history)中找出你使用最多的 shell 命令。
编辑此页.
根据 CC BY-NC-SA 许可。
命令行环境
正如我们在上一节课中提到的,大多数 shell 不仅仅是启动其他程序的启动器,实际上它们提供了一个完整的编程语言,其中充满了常见的模式和抽象。然而,与大多数编程语言不同,在 shell 脚本中,一切都是围绕运行程序以及使它们简单高效地相互通信来设计的。
尤其是 shell 脚本与约定紧密相连。为了使命令行界面(CLI)程序能够在更广泛的 shell 环境中良好地运行,它需要遵循一些常见的模式。现在我们将介绍许多理解命令行程序如何工作以及如何使用和配置它们的通用约定所需的概念。
命令行界面
在大多数编程语言中,编写一个函数看起来可能像这样:
def add(x: int, y: int) -> int:
return x + y
在这里,我们可以明确地看到程序的输入和输出。相比之下,shell 脚本在第一眼看起来可能相当不同。
#!/usr/bin/env bash
if [[ -f $1 ]]; then echo "Target file already exists"
exit 1
else
if $DEBUG; then grep 'error' - | tee $1
else grep 'error' - > $1
fi exit 0
fi
为了正确理解像这样的脚本中发生的事情,我们首先需要介绍一些在 shell 程序相互通信或与 shell 环境通信时经常出现的一些概念:
-
参数
-
流
-
环境变量
-
返回码
-
信号
参数
当 shell 程序执行时,它们会接收到一个参数列表。在 shell 中,参数是普通的字符串,程序如何解释它们取决于程序本身。例如,当我们执行ls -l folder/时,我们正在执行程序/bin/ls,并带有参数['-l', 'folder/']。
在 shell 脚本中,我们通过特殊的 shell 语法来访问这些函数。要访问第一个参数,我们访问变量$1,第二个参数$2,以此类推,直到$9。要访问所有参数作为一个列表,我们使用$@,要获取参数的数量则使用$#。此外,我们还可以通过$0访问程序的名称。
对于大多数程序来说,参数将包括标志和常规字符串的混合。标志可以通过它们前面带有连字符(-)或双连字符(--)来识别。标志通常是可选的,它们的作用是修改程序的行为。例如,ls -l会改变ls格式化输出的方式。
你会看到带有像--all这样的长名称的双连字符标志,以及像-a这样的单连字符标志,它们通常后面跟着一个字母。相同的选项可能以两种格式指定,ls -a和ls --all是等效的。单连字符标志通常成组出现,所以ls -l -a和ls -la也是等效的。标志的顺序通常也不重要,ls -la和ls -al会产生相同的结果。一些标志非常普遍,随着你对 shell 环境的熟悉,你会本能地使用它们,例如(--help,--verbose,--version)。
标志是 shell 约定的一个好例子。shell 语言并不要求我们的程序以这种方式使用
-或--。没有什么阻止我们编写一个具有语法myprogram +myoption myfile的程序,但这样会导致混淆,因为期望我们使用连字符。在实践中,大多数编程语言都提供了带有连字符语法的 CLI 标志解析库(例如 python 中的argparse,用于解析带有连字符语法的参数)。
CLI 程序中的另一个常见约定是程序接受相同类型的可变数量的参数。当以这种方式给出参数时,命令会对每个参数执行相同的操作。
mkdir src
mkdir docs
# is equivalent to
mkdir src docs
这种语法糖一开始可能看起来不必要,但与 通配符 结合使用时,它变得非常强大。通配符或 glob 是 shell 在调用程序之前会展开的特殊模式。
假设我们想要非递归地删除当前文件夹中的所有 .py 文件。根据我们在上一节课中学到的知识,我们可以通过运行以下命令来实现:
for file in $(ls | grep -P '\.py$'); do rm "$file"
done
但我们可以用 rm *.py 来替换它!
当我们在终端中输入 rm *.py 时,shell 不会用参数 ['*.py'] 调用 /bin/rm 程序。相反,shell 将在当前文件夹中搜索匹配模式 *.py 的文件,其中 * 可以匹配任何类型的零个或多个字符。所以如果我们的文件夹中有 main.py 和 utils.py,那么 rm 程序将接收参数 ['main.py', 'utils.py']。
你最常遇到的通配符是通配符 *(零个或多个任何字符)、?(恰好一个任何字符)和大括号。大括号 {} 将逗号分隔的多个模式扩展为多个参数。
在实践中,最好通过激励性示例来理解通配符。
touch folder/{a,b,c}.py
# Will expand to
touch folder/a.py folder/b.py folder/c.py
convert image.{png,jpg}
# Will expand to
convert image.png image.jpg
cp /path/to/project/{setup,build,deploy}.sh /newpath
# Will expand to
cp /path/to/project/setup.sh /path/to/project/build.sh /path/to/project/deploy.sh /newpath
# Globbing techniques can also be combined
mv *{.py,.sh} folder
# Will move all *.py and *.sh files
一些 shell(例如 zsh)支持更高级的通配符形式,如
**,它将展开以包括递归路径。所以rm **/*.py将递归地删除所有.py文件。
流
每当我们执行一个程序管道,如
cat myfile | grep -P '\d+' | uniq -c
我们可以看到,grep 程序正在与 cat 和 uniq 程序进行通信。
这里的一个重要观察是,三个程序都是同时执行的。也就是说,shell 不是首先调用 cat,然后是 grep,然后是 uniq。相反,所有三个程序都被启动,shell 将 cat 的输出连接到 grep 的输入,将 grep 的输出连接到 uniq 的输入。当使用管道操作符 | 时,shell 对从链中的前一个程序流向下一个程序的数据流进行操作。
我们可以通过以下命令演示这种并发性,管道中的所有命令都立即开始:
$ (sleep 15 && cat numbers.txt) | grep -P '^\d$' | sort | uniq &
[1] 12345 $ ps | grep -P '(sleep|cat|grep|sort|uniq)'
32930 pts/1 00:00:00 sleep
32931 pts/1 00:00:00 grep
32932 pts/1 00:00:00 sort
32933 pts/1 00:00:00 uniq
32948 pts/1 00:00:00 grep
我们可以看到,除了 cat 之外的所有进程都立即开始运行。shell 在任何进程完成之前都会启动所有进程并将它们的流连接起来。cat 只会在 sleep 结束后开始,cat 的输出将被发送到 grep 以及其他进程。
每个程序都有一个输入流,标记为 stdin(标准输入)。在管道操作中,stdin 会自动连接。在脚本中,许多程序接受 - 作为文件名,表示“从 stdin 读取”:
# These are equivalent when data comes from a pipe
echo "hello" | grep "hello"
echo "hello" | grep "hello" -
同样,每个程序都有两个输出流:stdout 和 stderr。标准输出是最常遇到的,它用于将程序的输出通过管道传递到管道中的下一个命令。标准错误是一个替代流,旨在让程序报告警告和其他类型的问题,而不会让输出被链中的下一个命令解析。
$ ls /nonexistent
ls: cannot access '/nonexistent': No such file or directory $ ls /nonexistent | grep "pattern"
ls: cannot access '/nonexistent': No such file or directory # The error message still appears because stderr is not piped
$ ls /nonexistent 2>/dev/null
# No output - stderr was redirected to /dev/null
Shell 提供了重定向这些流语的语法。以下是一些示例。
# Redirect stdout to a file (overwrite)
echo "hello" > output.txt
# Redirect stdout to a file (append)
echo "world" >> output.txt
# Redirect stderr to a file
ls foobar 2> errors.txt
# Redirect both stdout and stderr to the same file
ls foobar &> all_output.txt
# Redirect stdin from a file
grep "pattern" < input.txt
# Discard output by redirecting to /dev/null
cmd > /dev/null 2>&1
另一个体现 Unix 哲学的强大工具是 fzf(fzf),一个模糊查找器。它从 stdin 读取行并提供一个交互式界面来过滤和选择:
$ ls | fzf
$ cat ~/.bash_history | fzf
fzf 可以与许多 Shell 操作集成。当我们讨论 Shell 定制化时,我们将看到更多关于它的用法。
环境变量
在 bash 中分配变量时,我们使用 foo=bar 的语法,然后使用 $foo 语法访问变量的值。请注意,foo = bar 是无效的语法,因为 Shell 会将其解析为调用程序 foo 并带有参数 ['=', 'bar']。在 Shell 脚本中,空格字符的作用是执行参数分割。这种行为可能会令人困惑且难以适应,所以请记住这一点。
Shell 变量没有类型,它们都是字符串。请注意,在 Shell 中编写字符串表达式时,单引号和双引号是不可互换的。用 ' 定界的字符串是字面字符串,不会展开变量,不会执行命令替换,也不会处理转义序列,而用 " 定界的字符串则会。
foo=bar
echo "$foo"
# prints bar
echo '$foo'
# prints $foo
要将命令的输出捕获到变量中,我们使用命令替换。当我们执行
files=$(ls)
echo "$files" | grep README
echo "$files" | grep ".py"
ls 命令的输出(具体来说是 stdout)被放入变量 $files 中,我们可以在之后访问它。$files 变量的内容确实包括 ls 输出的换行符,这就是像 grep 这样的程序如何独立操作每个项目的原因。
一个不太为人所知的类似功能是 进程替换,<( CMD ) 将执行 CMD 并将输出放入一个临时文件,并用 <() 替换该文件名。这在命令期望通过文件而不是 STDIN 传递值时很有用。例如,diff <(ls src) <(ls docs) 将显示 src 和 docs 目录中文件的差异。
每当 Shell 程序调用另一个程序时,它会传递一组通常被称为 环境变量 的变量。在 Shell 中,我们可以通过运行 printenv 来找到当前的环境变量。要显式传递环境变量,我们可以在命令前加上变量赋值
环境变量传统上以全大写(例如,
HOME,PATH,DEBUG)书写。这是一个约定,而不是技术要求,但遵循它有助于区分环境变量和本地 shell 变量,后者通常是小写。
TZ=Asia/Tokyo date # prints the current time in Tokyo
echo $TZ # this will be empty, since TZ was only set for the child command
或者,我们可以使用 export 内置函数来修改当前环境,因此所有子进程都将继承该变量:
export DEBUG=1
# All programs from this point onwards will have DEBUG=1 in their environment
bash -c 'echo $DEBUG'
# prints 1
要删除一个变量,请使用 unset 内置命令,例如 unset DEBUG。
环境变量是另一个 shell 约定。它们可以用来隐式地修改许多程序的行为,而不是显式地修改。例如,shell 使用
$HOME环境变量设置当前用户的家目录路径。然后程序可以访问这个变量来获取这个信息,而不是需要显式的--home /home/alice。另一个常见的例子是$TZ,许多程序使用它来根据指定的时区格式化日期和时间。
返回代码
如我们之前所见,shell 程序的主要输出是通过 stdout/stderr 流和文件系统副作用传达的。
默认情况下,shell 脚本将返回退出代码零。约定是零表示一切顺利,而非零表示遇到了一些问题。要返回非零退出代码,我们必须使用 exit NUM shell 内置命令。我们可以通过访问特殊变量 $? 来获取最后运行的命令的返回码。
shell 有布尔运算符 && 和 ||,分别用于执行 AND 和 OR 操作。与在常规编程语言中遇到的不同,shell 中的这些运算符作用于程序的返回码。这两个都是 短路求值 运算符。这意味着它们可以用来根据先前命令的成功或失败有条件地运行命令,其中成功是根据返回码是否为零来确定的。一些例子:
# echo will only run if grep succeeds (finds a match)
grep -q "pattern" file.txt && echo "Pattern found"
# echo will only run if grep fails (no match)
grep -q "pattern" file.txt || echo "Pattern not found"
# true is a shell program that always succeeds
true && echo "This will always print"
# and false is a shell program that always fails
false || echo "This will always print"
同样的原则适用于 if 和 while 语句,它们都使用返回码来做决策:
# if uses the return code of the condition command (0 = true, nonzero = false)
if grep -q "pattern" file.txt; then echo "Found"
fi
# while loops continue as long as the command returns 0
while read line; do echo "$line"
done < file.txt
信号
在某些情况下,你可能需要在程序执行时中断它,例如,如果某个命令完成得太慢。中断程序的最简单方法是按 Ctrl-C,命令可能会停止。但这是如何实际工作的,为什么有时它无法停止进程?
$ sleep 100
^C $
注意,在这里
^C是在终端中输入Ctrl-C时显示的方式。
在底层,这里发生的情况如下:
-
我们按下了
Ctrl-C -
shell 识别了特殊字符组合
-
shell 进程向
sleep进程发送了 SIGINT 信号 -
信号中断了
sleep进程的执行
信号是一种特殊的通信机制。当进程收到信号时,它会停止执行,处理该信号,并根据信号提供的信息可能改变执行流程。因此,信号是 软件中断。
在我们的情况下,当输入 Ctrl-C 时,这会提示 shell 向进程发送一个 SIGINT 信号。以下是一个捕获 SIGINT 并忽略它的 Python 程序的最小示例,这样它就不再停止了。现在,我们可以使用 SIGQUIT 信号来终止这个程序,方法是输入 Ctrl-\。
#!/usr/bin/env python import signal, time
def handler(signum, time):
print("\nI got a SIGINT, but I am not stopping")
signal.signal(signal.SIGINT, handler)
i = 0
while True:
time.sleep(.1)
print("\r{}".format(i), end="")
i += 1
如果我们向这个程序发送两次 SIGINT 信号,然后是 SIGQUIT 信号,会发生什么。注意,^ 是在终端中输入时显示 Ctrl 的方式。
$ python sigint.py
24^C
I got a SIGINT, but I am not stopping
26^C
I got a SIGINT, but I am not stopping
30^\[1] 39913 quit python sigint.py
虽然 SIGINT 和 SIGQUIT 都通常与终端相关请求相关联,但一个更通用的信号,用于请求进程优雅地退出的是 SIGTERM 信号。要发送此信号,我们可以使用 kill 命令,语法为 kill -TERM <PID>。
信号可以做其他事情,而不仅仅是终止进程。例如,SIGSTOP 会暂停一个进程。在终端中,按 Ctrl-Z 会提示 shell 发送一个 SIGTSTP 信号,即终端的 SIGSTOP 版本。
然后,我们可以使用 fg 或 bg 分别在前景或后台继续暂停的作业。
jobs 命令列出了与当前终端会话关联的未完成作业。你可以通过它们的进程 ID(你可以使用 pgrep 来找出它)来引用这些作业。更直观的是,你也可以通过在其后跟百分号和作业号(由 jobs 显示)来引用一个进程。要引用最后一个后台作业,你可以使用 $! 特殊参数。
还有一件事需要知道,命令中的 & 后缀将在后台运行命令,让你获得提示符,尽管它仍然会使用 shell 的 STDOUT,这可能会很烦人(在这种情况下,请使用 shell 重定向)。同样,要后台运行一个已经运行的程序,你可以先按 Ctrl-Z,然后按 bg。
注意,后台进程仍然是你的终端的子进程,如果你关闭终端,它们将会死亡(这将发送另一个信号,SIGHUP)。为了避免这种情况,你可以使用 nohup(一个忽略 SIGHUP 的包装器)来运行程序,或者如果进程已经启动,可以使用 disown。或者,你可以使用终端多路复用器,就像我们将在下一节看到的那样。
下面是一个示例会话,展示了这些概念的一些应用。
$ sleep 1000
^Z
[1] + 18653 suspended sleep 1000
$ nohup sleep 2000 &
[2] 18745
appending output to nohup.out
$ jobs
[1] + suspended sleep 1000
[2] - running nohup sleep 2000
$ kill -SIGHUP %1
[1] + 18653 hangup sleep 1000
$ kill -SIGHUP %2 # nohup protects from SIGHUP
$ jobs
[2] + running nohup sleep 2000
$ kill %2
[2] + 18745 terminated nohup sleep 2000
特殊的信号是SIGKILL,因为它不能被进程捕获,并且它总是会立即终止它。然而,它可能会有不良的副作用,例如留下孤儿子进程。
你可以在这里或通过输入[man signal](https://www.man7.org/linux/man-pages/man7/signal.7.html)或kill -l`来了解更多关于这些和其他信号的信息。
在 shell 脚本中,你可以使用内置的trap来在接收到信号时执行命令。这对于清理操作很有用:
#!/usr/bin/env bash
cleanup() {
echo "Cleaning up temporary files..."
rm -f /tmp/mytemp.*
}
trap cleanup EXIT # Run cleanup when script exits
trap cleanup SIGINT SIGTERM # Also on Ctrl-C or kill
远程机器
程序员在日常工作中与远程服务器协作变得越来越普遍。这里最常用的工具是 SSH(安全壳),它可以帮助我们连接到远程服务器并提供现在熟悉的 shell 界面。我们可以使用如下命令连接到服务器:
ssh alice@server.mit.edu
在这里,我们尝试以用户alice的身份在服务器server.mit.edu上 ssh。
ssh的一个常被忽视的功能是能够非交互式地运行命令。ssh正确地处理了命令的 stdin 和 stdout 的发送和接收,因此我们可以将它与其他命令结合使用
# here ls runs in the remote, and wc runs locally
ssh alice@server ls | wc -l
# here both ls and wc run in the server
ssh alice@server 'ls | wc -l'
尝试安装Mosh作为 SSH 的替代品,它可以处理断开连接、进入/退出睡眠、更改网络和处理高延迟链路。
为了让ssh允许我们在远程服务器上运行命令,我们需要证明我们有权限这样做。我们可以通过密码或 ssh 密钥来完成。基于密钥的认证利用公钥加密学来向服务器证明客户端拥有秘密私钥,而不泄露密钥。基于密钥的认证既方便又安全,因此你应该优先选择它。请注意,私钥(通常是~/.ssh/id_rsa,最近更多是~/.ssh/id_ed25519)实际上是你的密码,所以要像对待密码一样对待它,永远不要共享其内容。
要生成一对密钥,你可以运行ssh-keygen。
ssh-keygen -a 100 -t ed25519 -f ~/.ssh/id_ed25519
如果你曾经使用 SSH 密钥配置过 GitHub 的推送,那么你可能已经完成了这里概述的步骤,并且已经有一个有效的密钥对。要检查你是否设置了密码并验证它,你可以运行ssh-keygen -y -f /path/to/key。
在服务器端,ssh会查看.ssh/authorized_keys以确定它应该允许哪些客户端进入。要复制公钥,你可以使用:
cat .ssh/id_ed25519.pub | ssh alice@remote 'cat >> ~/.ssh/authorized_keys'
# or more simply (if ssh-copy-id is available)
ssh-copy-id -i .ssh/id_ed25519 alice@remote
除了运行命令之外,ssh 建立的连接还可以用来安全地从服务器传输文件。[scp](https://www.man7.org/linux/man-pages/man1/scp.1.html)是最传统的工具,其语法是scp 本地文件路径 远程主机:远程文件路径。[rsync](https://www.man7.org/linux/man-pages/man1/rsync.1.html) 通过检测本地和远程的相同文件,并防止再次复制它们来改进 scp。它还提供了对符号链接、权限的更精细控制,并具有像 --partial 标志这样的额外功能,可以从之前中断的复制中恢复。rsync 的语法与 scp 类似。
SSH 客户端配置位于 ~/.ssh/config,它允许我们声明主机并为它们设置默认设置。此配置文件不仅被 ssh 读取,还被 scp、rsync、mosh 等其他程序读取。
Host vm
User alice
HostName 172.16.174.141
Port 2222
IdentityFile ~/.ssh/id_ed25519
# Configs can also take wildcards
Host *.mit.edu
User alice
终端多路复用器
当使用命令行界面时,你通常会想要同时运行多个任务。例如,你可能想要同时运行你的编辑器和你的程序。虽然这可以通过打开新的终端窗口来实现,但使用终端多路复用器是一个更灵活的解决方案。
类似于 [tmux](https://www.man7.org/linux/man-pages/man1/tmux.1.html)的终端多路复用器允许你使用窗格和标签页来多路复用终端窗口,这样你可以以高效的方式与多个 shell 会话进行交互。此外,终端多路复用器允许你在某个时间点断开当前终端会话,并在稍后重新连接。正因为如此,终端多路复用器在处理远程机器时非常方便,因为它避免了使用nohup` 和类似技巧的需要。
当今最受欢迎的终端多路复用器是 [tmux](https://www.man7.org/linux/man-pages/man1/tmux.1.html)。tmux` 可以高度配置,通过使用相关的快捷键,你可以创建多个标签页和窗格,并快速在其中导航。
tmux 预期你需要了解其快捷键,它们的形式都是 <C-b> x,这意味着(1)按下 Ctrl+b,(2)释放 Ctrl+b,然后(3)按下 x。tmux 有以下对象层次结构:
-
会话 - 会话是一个包含一个或多个窗口的独立工作空间
-
tmux启动一个新的会话。 -
使用
tmux new -s NAME可以以该名称启动它。 -
tmux ls列出当前会话 -
在
tmux中,输入<C-b> d可以断开当前会话 -
使用
tmux a可以连接到最后的会话。你可以使用-t标志来指定哪个
-
-
窗口 - 相当于编辑器或浏览器中的标签页,它们是同一个会话的视觉上分离的部分
-
<C-b> c创建一个新的窗口。要关闭它,你可以简单地终止 shell,使用<C-d> -
<C-b> N跳转到第 N 个窗口。注意它们是有编号的 -
<C-b> p跳转到上一个窗口 -
<C-b> n跳转到下一个窗口 -
<C-b> ,重命名当前窗口 -
<C-b> w列出当前窗口
-
-
窗格 - 类似于 vim 分割,窗格允许你在同一个视觉显示中拥有多个 shell。
-
<C-b> "水平分割当前窗口 -
<C-b> %垂直分割当前窗口 -
<C-b> <direction>移动到指定 方向 的窗口。这里的方向意味着箭头键。 -
<C-b> z切换当前窗口的缩放 -
<C-b> [开始滚动。然后你可以按<space>开始选择,按<enter>复制选择的内容。 -
<C-b> <space>在窗口布局之间循环。
-
在你的工具箱中有了 tmux 和 SSH 后,你可能会想让你的环境在任何机器上都有家的感觉。这就是 shell 定制的作用所在。
定制 Shell
使用称为 dotfiles 的纯文本文件配置了一系列命令行程序(因为文件名以.开头,例如 ~/.vimrc,所以默认情况下在目录列表 ls 中被隐藏)。这些文件被称为 dotfiles(因为文件名以.开头,例如 ~/.vimrc,所以它们在目录列表 ls 中默认被隐藏)。
Dotfiles 是另一种 shell 约定。前面的点是为了在列表中“隐藏”它们(是的,另一个约定)。
Shell 是使用此类文件配置的程序的一个例子。在启动时,你的 shell 会读取许多文件来加载其配置。根据 shell 以及你是否正在启动登录和/或交互式会话,整个过程可能相当复杂。这里是关于这个主题的优秀资源。
对于 bash,编辑你的 .bashrc 或 .bash_profile 在大多数系统中都会起作用。一些其他可以通过 dotfiles 配置的工具的例子包括:
-
bash-~/.bashrc,~/.bash_profile -
git-~/.gitconfig -
vim-~/.vimrc和~/.vim文件夹 -
ssh-~/.ssh/config -
tmux-~/.tmux.conf
一个常见的配置更改是添加新的位置供 shell 查找程序。当你安装软件时,你会遇到这种模式:
export PATH="$PATH:path/to/append"
在这里,我们告诉 shell 将$PATH 变量的值设置为当前值加上一个新路径,并让所有子进程继承这个新的 PATH 值。这将允许子进程找到位于path/to/append下的程序。
定制你的 shell 通常意味着安装新的命令行工具。包管理器使这变得容易。它们处理下载、安装和更新软件。不同的操作系统有不同的包管理器:macOS 使用Homebrew,Ubuntu/Debian 使用apt,Fedora 使用dnf,而 Arch 使用pacman。我们将在发货代码讲座中更深入地介绍包管理器。
这是如何在 macOS 上使用 Homebrew 安装两个有用工具的示例:
# ripgrep: a faster grep with better defaults
brew install ripgrep
# fd: a faster, user-friendly find
brew install fd
安装这些程序后,你可以使用 rg 代替 grep,使用 fd 代替 find。
关于
curl | bash的警告:您经常会看到类似curl -fsSL https://example.com/install.sh | bash的安装说明。这种模式下载一个脚本并立即执行,虽然方便但风险较大;您正在运行未经检查的代码。一种更安全的方法是先下载,然后审查,最后再执行:curl -fsSL https://example.com/install.sh -o install.sh less install.sh # review the script bash install.sh一些安装程序使用一个稍微安全一点的变体:
/bin/bash -c "$(curl -fsSL https://url)",这至少确保 bash 解释脚本而不是您的当前 shell。
当您尝试运行一个未安装的命令时,您的 shell 将显示command not found。网站command-not-found.com是一个有用的资源,您可以使用它来搜索任何命令,以了解如何在不同的包管理器和发行版中安装它。
另一个有用的工具是tldr,它提供简化的、以示例为重点的 man 页面。您不必阅读冗长的文档,可以快速查看常见的使用模式:
$ tldr fd
An alternative to find.
Aims to be faster and easier to use than find.
Recursively find files matching a pattern in the current directory:
fd "pattern"
Find files that begin with "foo":
fd "^foo"
Find files with a specific extension:
fd --extension txt
有时您不需要一个全新的程序,而只需要为现有命令添加带有特定标志的快捷方式。这正是别名的作用所在。
我们也可以使用alias shell 内建命令创建自己的命令别名。shell 别名是另一个命令的简写形式,shell 会在评估表达式之前自动替换它。例如,bash 中的别名具有以下结构:
alias alias_name="command_to_alias arg1 arg2"
注意,等号
=周围没有空格,因为alias是一个接受单个参数的 shell 命令。
别名有许多方便的功能:
# Make shorthands for common flags
alias ll="ls -lh"
# Save a lot of typing for common commands
alias gs="git status"
alias gc="git commit"
# Save you from mistyping
alias sl=ls
# Overwrite existing commands for better defaults
alias mv="mv -i" # -i prompts before overwrite
alias mkdir="mkdir -p" # -p make parent dirs as needed
alias df="df -h" # -h prints human readable format
# Alias can be composed
alias la="ls -A"
alias lla="la -l"
# To ignore an alias run it prepended with \
\ls
# Or disable an alias altogether with unalias
unalias la
# To get an alias definition just call it with alias
alias ll
# Will print ll='ls -lh'
别名有一些限制:它们不能在命令中间接受参数。对于更复杂的行为,您应该使用 shell 函数。
大多数 shell 都支持使用Ctrl-R进行反向历史搜索。按下Ctrl-R并开始输入以搜索之前的命令。我们之前介绍了fzf作为模糊查找器;当配置了 fzf 的 shell 集成后,Ctrl-R将变为通过整个历史记录的交互式模糊搜索,比默认的搜索功能强大得多。
您应该如何组织您的配置文件?它们应该放在自己的文件夹中,置于版本控制之下,并使用脚本链接到正确的位置。这样做有以下好处:
-
简易安装:如果您登录到一台新机器,应用您的自定义设置只需一分钟。
-
便携性:您的工具将在任何地方以相同的方式工作。
-
同步:您可以在任何地方更新您的配置文件,并保持它们同步。
-
变更跟踪:您可能在整个编程生涯中维护您的配置文件,对于长期项目来说,版本历史记录是非常有用的。
你应该在 dotfiles 中放入什么内容?你可以通过阅读在线文档或man 页面来了解你的工具设置。另一种很好的方法是搜索关于特定程序的博客文章,作者会告诉你他们的首选自定义设置。还有另一种了解自定义设置的方法是查看其他人的 dotfiles:你可以在 GitHub 上找到大量的dotfiles 仓库 — 看看最受欢迎的一个这里(我们建议你不要盲目复制配置)。这里是关于这个主题的另一个很好的资源。
所有班级讲师的 dotfiles 都公开可在 GitHub 上访问:Anish,Jon,Jose。
框架和插件 也可以提升你的 shell。一些流行的通用框架包括 prezto 或 oh-my-zsh,以及专注于特定功能的较小插件:
-
zsh-syntax-highlighting - 在你输入时为有效/无效命令着色
-
zsh-autosuggestions - 在你输入时建议历史命令
-
zsh-completions - 额外的完成定义
-
zsh-history-substring-search - 类似 fish 的历史搜索
-
powerlevel10k - 快速、可定制的提示主题
像 fish 这样的 shell 默认包含了许多这些功能。
你不需要像 oh-my-zsh 这样的庞大框架来获得这些功能。安装单个插件通常更快,并且给你更多的控制。大型框架可能会显著减慢 shell 启动时间,因此考虑只安装你实际使用的功能。
Shell 中的 AI
在 shell 中融入 AI 工具的方式有很多。以下是一些不同集成级别的示例:
命令生成:像 simonw/llm 这样的工具可以帮助从自然语言描述中生成 shell 命令:
$ llm cmd "find all python files modified in the last week"
find . -name "*.py" -mtime -7
管道集成:LLMs 可以集成到 shell 管道中,以处理和转换数据。当你需要从格式不一致的信息中提取信息,而正则表达式会变得痛苦时,它们特别有用:
$ cat users.txt
Contact: john.doe@example.com
User 'alice_smith' logged in at 3pm
Posted by: @bob_jones on Twitter
Author: Jane Doe (jdoe)
Message from mike_wilson yesterday
Submitted by user: sarah.connor $ INSTRUCTIONS="Extract just the username from each line, one per line, nothing else"
$ llm "$INSTRUCTIONS" < users.txt
john.doe
alice_smith
bob_jones
jdoe
mike_wilson
sarah.connor
注意我们如何使用 "$INSTRUCTIONS"(引号内)因为变量包含空格,以及 < users.txt 来将文件的内容重定向到 stdin。
AI shell:像 Claude Code 这样的工具充当元 shell,接受英语命令并将它们转换为 shell 操作、文件编辑以及更复杂的多步任务。
终端模拟器
除了自定义你的 shell 之外,花些时间了解你的终端模拟器及其设置也是值得的。终端模拟器是一个 GUI 程序,它提供了一个基于文本的界面,你的 shell 在其中运行。市面上有很多终端模拟器。
由于你可能会在终端中花费数百到数千小时,因此了解其设置是有益的。你可能希望在终端中修改的一些方面包括:
练习
参数和通配符
-
你可能会看到像
cmd --flag -- --notaflag这样的命令。--是一个特殊参数,它告诉程序停止解析标志。--之后的所有内容都被视为位置参数。这有什么用?尝试运行touch -- -myfile然后不带--移除它。 -
阅读
man ls并编写一个ls命令,以以下方式列出文件:-
包含所有文件,包括隐藏文件
-
大小以人类可读格式列出(例如 454M 而不是 454279954)
-
文件按时间顺序排列
-
输出已着色
一个示例输出可能看起来像这样:
-rw-r--r-- 1 user group 1.1M Jan 14 09:53 baz drwxr-xr-x 5 user group 160 Jan 14 09:53 . -rw-r--r-- 1 user group 514 Jan 14 06:42 bar -rw-r--r-- 1 user group 106M Jan 13 12:12 foo drwx------+ 47 user group 1.5K Jan 12 18:08 .. -
-
进程替换
<(command)允许你将命令的输出用作文件。使用进程替换与diff比较printenv和export的输出。为什么它们不同?(提示:尝试diff <(printenv | sort) <(export | sort))。
环境变量
- 编写 bash 函数
marco和polo,它们的功能如下:每次你执行marco时,当前工作目录应以某种方式保存,然后当你执行polo时,无论你在哪个目录,polo应将你cd回执行marco的目录。为了便于调试,你可以将代码写入一个文件marco.sh,并通过执行source marco.sh将定义重新加载到你的 shell 中。
返回码
-
假设你有一个很少失败的命令。为了调试它,你需要捕获其输出,但获取失败运行可能需要花费时间。编写一个 bash 脚本,该脚本会一直运行以下脚本直到它失败,并将标准输出和错误流捕获到文件中,并在最后打印出所有内容。如果你还能报告脚本失败所需的运行次数,那么将获得加分。
#!/usr/bin/env bash n=$(( RANDOM % 100 )) if [[ n -eq 42 ]]; then echo "Something went wrong" >&2 echo "The error was using magic numbers" exit 1 fi echo "Everything went according to plan"
信号和作业控制
-
在终端中启动一个
sleep 10000作业,使用Ctrl-Z将其置于后台,然后使用bg继续其执行。现在使用pgrep查找其 pid,并使用pkill来杀死它,而无需输入 pid 本身。(提示:使用-af标志)。 -
假设你不想在另一个进程完成之前启动一个进程。你会怎么做?在这个练习中,我们的限制进程始终是
sleep 60 &。实现这一目标的一种方法是用wait命令。尝试启动 sleep 命令,并让ls等待后台进程完成。然而,如果我们从不同的 bash 会话开始,这个策略将失败,因为
wait只对子进程有效。在笔记中我们没有讨论的一个特性是,kill命令的退出状态在成功时为零,否则为非零。kill -0不会发送信号,但如果进程不存在,将给出非零的退出状态。编写一个名为pidwait的 bash 函数,它接受一个 pid 并等待直到指定的进程完成。你应该使用sleep来避免不必要的 CPU 资源浪费。
文件和权限
- (高级)编写一个命令或脚本,以递归方式查找目录中最最近修改的文件。更普遍地说,你能按时间顺序列出所有文件吗?
终端多路复用器
别名和 Dotfiles
-
创建一个别名
dc,当输入错误时它将解析为cd。 -
运行
history | awk '{$1="";print substr($0,2)}' | sort | uniq -c | sort -n | tail -n 10来获取你使用最多的前 10 个命令,并考虑为它们编写更短的别名。注意:这适用于 Bash;如果你使用 ZSH,请使用history 1而不是仅仅history。 -
为你的 dotfiles 创建一个文件夹并设置版本控制。
-
为至少一个程序(例如你的 shell)添加配置,并进行一些自定义(一开始,可以通过设置
$PS1来自定义你的 shell 提示符)。 -
设置一种方法,以便快速(且无需手动操作)在新机器上安装你的 dotfiles。这可以是一个简单的 shell 脚本,它为每个文件调用
ln -s,或者你可以使用一个专用工具。 -
在一个全新的虚拟机上测试你的安装脚本。
-
将你当前的所有工具配置迁移到你的 dotfiles 仓库中。
-
在 GitHub 上发布你的 dotfiles。
远程机器(SSH)
安装一个 Linux 虚拟机(或使用已经存在的虚拟机)来进行这些练习。如果你不熟悉虚拟机,可以查看这个教程来安装一个。
-
前往
~/.ssh/目录,检查你是否有一对 SSH 密钥。如果没有,使用ssh-keygen -a 100 -t ed25519命令生成它们。建议你使用密码,并使用ssh-agent,更多信息这里。 -
编辑
.ssh/config文件,添加以下条目:Host vm User username_goes_here HostName ip_goes_here IdentityFile ~/.ssh/id_ed25519 LocalForward 9999 localhost:8888 -
使用
ssh-copy-id vm命令将你的 SSH 密钥复制到服务器。 -
在你的虚拟机中通过执行
python -m http.server 8888启动一个 web 服务器。通过在你的机器上导航到http://localhost:9999来访问虚拟机 web 服务器。 -
通过执行
sudo vim /etc/ssh/sshd_config编辑你的 SSH 服务器配置,并通过编辑PasswordAuthentication的值来禁用密码认证。通过编辑PermitRootLogin的值来禁用 root 登录。使用sudo service sshd restart重启ssh服务。再次尝试 SSH 登录。 -
(挑战) 在虚拟机中安装
mosh(mosh) 并建立连接。然后断开服务器/虚拟机的网络适配器。mosh 能否正确恢复? -
(挑战) 研究在
ssh中-N和-f标志的作用,并找出一个实现后台端口转发的命令。
本文档受 CC BY-NC-SA 许可。
开发环境和工具
开发环境是一套用于开发软件的工具。开发环境的核心是文本编辑功能,以及伴随的功能,如语法高亮、类型检查、代码格式化和自动完成。集成开发环境(IDE)如VS Code将所有这些功能集成到一个单一的应用程序中。基于终端的开发工作流程结合了诸如tmux(一个终端多路复用器)、Vim(一个文本编辑器)、Zsh(一个 shell)以及特定语言的命令行工具,如Ruff(一个 Python 代码检查器和代码格式化工具)和Mypy(一个 Python 类型检查器)。
IDE 和基于终端的工作流程各有其优势和劣势。例如,图形 IDE 可能更容易学习,并且今天的 IDE 通常具有更好的开箱即用的 AI 集成,如 AI 自动完成;另一方面,基于终端的工作流程轻量级,在您没有 GUI 或无法安装软件的环境中,它们可能是您的唯一选择。我们建议您对两者都建立基本熟悉度,并至少精通其中一个。如果您还没有首选的 IDE,我们建议从VS Code开始。
在本讲座中,我们将涵盖:
-
文本编辑和 Vim
-
代码智能和语言服务器
-
AI 驱动的开发
-
扩展和其他 IDE 功能
文本编辑和 Vim
在编程时,你大部分时间都在通过代码导航、阅读代码片段和编辑代码,而不是编写长串的代码或从头到尾阅读文件。Vim是一个针对这种任务分布优化的文本编辑器。
Vim 的哲学。Vim 以其美丽的基础理念为基石:其界面本身就是一种编程语言,旨在导航和编辑文本。按键(带有助记名称)是命令,这些命令是可组合的。Vim 避免使用鼠标,因为它太慢;Vim 甚至避免使用箭头键,因为它需要太多的移动。结果是:一个感觉像大脑-计算机接口的编辑器,并且与你的思考速度相匹配。
Vim 在其他软件中的支持。你不必使用 Vim 本身来受益于其核心思想。许多涉及任何类型文本编辑的程序都支持“Vim 模式”,无论是作为内置功能还是作为插件。例如,VS Code 有 VSCodeVim 插件,Zsh 有 内置支持 用于 Vim 模拟,甚至 Claude Code 也 内置支持 Vim 编辑器模式。很可能你使用的任何涉及文本编辑的工具都以某种方式支持 Vim 模式。
模式编辑
Vim 是一个 模式编辑器:它针对不同类别的任务有不同的操作模式。
-
正常:用于在文件中移动和编辑
-
插入:用于插入文本
-
替换:用于替换文本
-
视觉(平面、行或块):用于选择文本块
-
命令行:用于运行命令
在不同的操作模式下,按键有不同的含义。例如,在插入模式下,字母 x 仅插入字符x,但在正常模式下,它将删除光标下的字符,在视觉模式下,它将删除选定的文本。
在默认配置下,Vim 在左下角显示当前模式。初始/默认模式是正常模式。你通常会花费大部分时间在正常模式和插入模式之间。
你可以通过按 <ESC>(退格键)从任何模式切换回正常模式来更改模式。从正常模式,使用 i 进入插入模式,使用 R 进入替换模式,使用 v 进入视觉模式,使用 V 进入视觉行模式,使用 <C-v>(Ctrl-V,有时也写作 ^V)进入视觉块模式,使用 : 进入命令行模式。
使用 Vim 时,你会经常使用 <ESC> 键:考虑将 Caps Lock 映射到 Escape (macOS 指令) 或创建一个 替代映射 以简单的键序列来映射 <ESC>。
基础:插入文本
从正常模式,按 i 键进入插入模式。现在,Vim 的行为就像任何其他文本编辑器一样,直到你按 <ESC> 键返回正常模式。这,加上上面解释的基础知识,就是你开始使用 Vim 编辑文件所需的所有内容(尽管如果你一直从插入模式进行编辑,效率可能不是特别高)。
Vim 的界面是一种编程语言
Vim 的界面是一种编程语言。按键(带有助记名)是命令,这些命令 组合。这使得移动和编辑变得高效,尤其是当命令成为肌肉记忆后,就像一旦你学会了键盘布局,打字就会变得超级高效一样。
移动
你应该大部分时间都在正常模式下,使用移动命令在文件中导航。Vim 中的移动也被称为“名词”,因为它们指的是文本块。
-
基本移动:
hjkl(左,下,上,右) -
单词:
w(下一个单词),b(单词开头),e(单词结尾) -
行:
0(行首),^(第一个非空白字符),$(行尾) -
屏幕模式:
H(屏幕顶部),M(屏幕中间),L(屏幕底部) -
滚动:
Ctrl-u(向上),Ctrl-d(向下) -
文件:
gg(文件开头),G(文件末尾) -
行号:
:{数字}<CR>或{数字}G(行{数字})<CR>指的是回车/换行键
-
杂项:
%(匹配项,如括号或花括号) -
查找:
f{字符},t{字符},F{字符},T{字符}-
在当前行上查找/向前/向后查找
-
,/;用于导航匹配项
-
-
搜索:
/{正则表达式},n/N用于导航匹配项
选择
视觉模式:
-
视觉:
v -
视觉行模式:
V -
视觉块:
Ctrl-v
可以使用移动键来选择。
编辑
你现在可以用键盘通过编辑命令来完成你以前用鼠标做的所有事情。这就是 Vim 界面开始看起来像编程语言的地方。Vim 的编辑命令也被称为“动词”,因为动词作用于名词。
-
i进入插入模式- 但对于操作/删除文本,想要使用比退格键更强大的工具
-
o/O在下方/上方插入行 -
d{动作}删除- 例如:
dw是删除单词,d$是删除到行尾,d0是删除到行首
- 例如:
-
c{动作}更改-
例如:
cw是更改单词 -
例如:
d{动作}后跟i
-
-
x删除字符(相当于dl) -
s替换字符(相当于cl) -
视觉模式+操作
- 选择文本,
d删除它或c更改它
- 选择文本,
-
u撤销,<C-r>重做 -
y复制/“粘贴”(一些其他命令如d也复制) -
p粘贴 -
还有更多要学习的内容:例如,
~切换字符的大小写,J将行连接起来
计数
你可以将名词和动词与一个计数器结合使用,这将执行给定动作多次。
-
3w向前移动 3 个单词 -
5j向下移动 5 行 -
7dw删除 7 个单词
修饰符
你可以使用修饰符来改变名词的含义。一些修饰符是i,表示“内部”或“里面”,和a,表示“周围”。
-
ci(改变当前对括号内的内容 -
ci[改变当前对括号内的内容 -
da'删除单引号字符串,包括周围的单引号
将所有这些放在一起
这里是一个损坏的fizz buzz实现:
def fizz_buzz(limit):
for i in range(limit):
if i % 3 == 0:
print("fizz", end="")
if i % 5 == 0:
print("fizz", end="")
if i % 3 and i % 5:
print(i, end="")
print()
def main():
fizz_buzz(20)
我们使用以下命令序列来修复问题,从普通模式开始:
-
主函数永远不会被调用
-
G跳到文件末尾 -
o在下方打开新行 -
输入
if __name__ == "__main__": main()- 如果你的编辑器有 Python 语言支持,它可能会在插入模式中为你进行一些自动缩进
-
<ESC>返回到普通模式
-
-
从 0 开始而不是 1
-
/后跟范围和<CR>搜索“范围” -
ww用于向前移动两个 单词(你也可以使用2w,但在实际操作中,对于小的计数,重复键通常比使用计数功能更常见) -
i用于切换到插入模式,并添加1, -
<ESC>用于返回到普通模式 -
e用于跳转到下一个单词的末尾 -
a用于开始追加文本,并添加+ 1 -
<ESC>用于返回到普通模式
-
-
对于 5 的倍数打印“fizz”
-
:6<CR>用于跳转到第 6 行 -
ci"用于在‘“’内更改,更改为"buzz" -
<ESC>用于返回到普通模式
-
学习 Vim
学习 Vim 的最好方法是学习基础知识(我们到目前为止所涵盖的内容),然后只是在你所有的软件中启用 Vim 模式并开始实际使用它。避免使用鼠标或箭头键的诱惑;在某些编辑器中,你可以取消绑定箭头键来强迫自己养成好习惯。
其他资源
-
上一次迭代此课程中的 Vim 讲座——我们在那里更深入地介绍了 Vim
-
vimtutor是与 Vim 一起安装的教程——如果 Vim 已安装,你应该可以从你的 shell 中运行vimtutor -
Vim Adventures 是一个学习 Vim 的游戏
-
Vim Advent Calendar 提供了各种 Vim 技巧
-
实用 Vim(书籍)
代码智能和语言服务器
IDE 通常提供特定于语言的支撑,这需要通过连接到实现 语言服务器协议 的语言服务器扩展来对代码进行语义理解。例如,VS Code 的 Python 扩展 依赖于 Pylance,而 VS Code 的 Go 扩展 依赖于第一方的 gopls。通过安装你使用的语言的扩展和语言服务器,你可以在你的 IDE 中启用许多特定于语言的功能,例如:
-
代码补全。更好的自动完成和自动建议,例如在输入
object.后能够看到对象的字段和方法。 -
内联文档。在悬停和自动建议时查看文档。
-
跳转到定义。从使用位置跳转到定义,例如能够从字段引用
object.field跳转到字段的定义。 -
查找引用。上述操作的逆操作,查找特定项(如字段或类型)的所有引用位置。
-
导入帮助。 组织导入,删除未使用的导入,标记缺失的导入。
-
代码质量。 这些工具可以独立使用,但此类功能通常也由语言服务器提供。代码格式化自动缩进和自动格式化代码,类型检查器和代码检查器在您键入时查找代码中的错误。我们将在关于代码质量的讲座中更深入地介绍此类功能。
配置语言服务器
对于某些语言,您只需安装扩展和语言服务器即可。对于其他语言,为了从语言服务器中获得最大好处,您需要告诉 IDE 您的环境。例如,将 VS Code 指向您的 Python 环境将使语言服务器能够看到您安装的包。环境在我们在关于打包和分发代码的讲座中进行了更深入的介绍。
根据语言的不同,您可能可以为您的语言服务器配置一些设置。例如,使用 VS Code 中的 Python 支持,您可以禁用对不使用 Python 可选类型注解的项目进行静态类型检查。
智能化开发
自从 2021 年中引入 GitHub Copilot 并使用 OpenAI 的 Codex 模型 以来,大型语言模型 已在软件工程中得到广泛应用。目前主要有三种形式:自动完成、内联聊天和编码代理。
自动完成
智能化自动完成与您 IDE 中的传统自动完成具有相同的形式,在您键入时在光标位置提供完成建议。有时,它被用作一个“只需工作”的被动功能。除此之外,AI 自动完成通常使用代码注释进行提示。
例如,让我们编写一个脚本来下载这些讲义的内容并提取所有链接。我们可以从以下开始:
import requests
def download_contents(url: str) -> str:
模型将自动完成函数的主体:
response = requests.get(url)
return response.text
我们可以使用注释进一步引导完成。例如,如果我们开始编写一个提取所有 Markdown 链接的函数,但它的名称没有特别描述性:
def extract(contents: str) -> list[str]:
模型将自动完成如下内容:
lines = contents.splitlines()
return [line for line in lines if line.strip()]
我们可以通过代码注释来引导完成:
def extract(content: str) -> list[str]:
# extract all Markdown links from the content
这次,模型给出了更好的完成建议:
import re
pattern = r'\[.*?\]\((.*?)\)'
return re.findall(pattern, content)
这里,我们看到了这个 AI 编码工具的一个缺点:它只能提供光标位置的完成建议。在这种情况下,将 import re 放在模块级别而不是函数内部会更好。
上面的例子使用了一个命名不佳的函数来演示如何通过注释来引导代码补全;在实践中,您会希望编写具有更具描述性的函数名称,如extract_links,并且您会希望编写文档字符串(基于此,模型应该生成类似于上面的补全)。
为了演示目的,我们可以完成脚本:
print(extract(download_contents("https://raw.githubusercontent.com/missing-semester/missing-semester/refs/heads/master/_2026/development-environment.md")))
内联聊天
内联聊天允许您选择一行或一个代码块,然后直接提示 AI 模型提出编辑建议。在这种交互模式下,模型可以对现有代码进行修改(这与自动完成不同,自动完成只会在光标之后完成代码)。
继续上面的例子,假设我们决定不使用第三方requests库。我们可以选择相关的三行代码,调用内联聊天,并说类似的话:
use built-in libraries instead
模型建议:
from urllib.request import urlopen
def download_contents(url: str) -> str:
with urlopen(url) as response:
return response.read().decode('utf-8')
编码代理
编码代理在代理编码讲座中进行了深入探讨。
推荐软件
一些流行的 AI IDE 包括带有GitHub Copilot扩展的VS Code和Cursor。GitHub Copilot 目前对学生、教师和流行开源项目的维护者免费提供。这是一个快速发展的领域。许多领先的产品功能大致相当。
扩展和其他 IDE 功能
IDEs(集成开发环境)是强大的工具,通过扩展功能变得更加强大。我们无法在一个讲座中涵盖所有这些功能,但在这里我们提供了一些指向几个流行扩展的指南。我们鼓励您自己探索这个领域;网上有许多流行的 IDE 扩展列表,例如Vim Awesome用于 Vim 插件和按受欢迎程度排序的 VS Code 扩展。
-
开发容器:由流行的 IDE 支持(例如,由 VS Code 支持),开发容器允许您使用容器来运行开发工具。这可能有助于便携性或隔离。关于打包和分发代码的讲座更深入地介绍了容器。
-
远程开发:使用 SSH 在远程机器上进行开发(例如,使用VS Code 的远程 SSH 插件)。这可能很有用,例如,如果您想在云中的强大 GPU 机器上开发和运行代码。
-
协同编辑:以 Google Docs 风格编辑同一文件(例如,使用VS Code 的 Live Share 插件)。
练习
-
在所有支持 Vim 模式的软件中启用 Vim 模式,例如你的编辑器和你的 shell,并在接下来的一个月内使用 Vim 模式进行所有文本编辑。每当遇到效率低下的问题,或者当你想“一定有更好的方法”时,尝试在 Google 上搜索,可能真的有更好的方法。
-
完成VimGolf的一个挑战。
-
为你正在工作的项目配置一个 IDE 扩展和语言服务器。确保所有预期的功能,如跳转到库依赖项的定义,都能按预期工作。如果你没有可用于此练习的代码,你可以使用 GitHub 上的某些开源项目(例如这个项目)。
-
浏览 IDE 扩展列表并安装一个对你有用的扩展。
本文档受CC BY-NC-SA许可协议保护。
调试和性能分析
编程中的一个黄金法则是代码不会做你期望它做的事情,而是做你告诉它做的事情。弥合这个差距有时可能是一项相当困难的任务。在本讲座中,我们将介绍处理有缺陷和资源密集型代码的有用技术:调试和性能分析。
调试
Printf 调试和日志
“最有效的调试工具仍然是仔细思考,辅以巧妙放置的打印语句”—— 布莱恩·克尼汉,《Unix 初学者指南》。
调试程序的第一种方法是添加打印语句在检测到问题的位置周围,并持续迭代,直到你提取了足够的信息来理解导致问题的原因。
第二种方法是在程序中使用日志,而不是临时的打印语句。日志本质上是一种“更加谨慎的打印”,通常通过包含内置对诸如:
-
将日志(或日志的子集)直接输出到其他输出位置的能力;
-
设置严重级别(如 INFO、DEBUG、WARN、ERROR 等)并允许你根据这些级别过滤输出;以及
-
支持与日志条目相关的结构化日志数据,这样在事后可以更容易地提取。
在编程过程中,你通常会主动添加日志语句,以便所需调试的数据已经存在!确实,一旦你使用打印语句找到并修复了问题,在删除之前将这些打印语句转换为合适的日志语句通常是有价值的。这样,如果将来出现类似的错误,你将已经拥有所需的诊断信息,而无需修改代码。
第三方日志:许多程序在运行时支持
-v或--verbose标志来打印更多信息。这有助于发现为什么某个命令失败。一些程序甚至允许重复使用该标志以获取更多详细信息。当调试服务(数据库、Web 服务器等)的问题时,检查它们的日志——通常在 Linux 的/var/log/目录下。使用journalctl -u <service>查看 systemd 服务的日志。对于第三方库,检查它们是否支持通过环境变量或配置进行调试日志记录。
调试器
当你知道要打印什么,并且可以轻松修改和重新运行代码时,打印调试效果很好。当你不确定需要什么信息,错误仅在难以复现的条件下出现,或者修改和重新启动程序成本很高(启动时间过长、需要重建的复杂状态等)时,调试器变得很有价值。
调试器是允许你在程序执行过程中与之交互的程序,它允许你:
-
当程序执行到达特定行时停止执行。
-
逐条指令地执行。
-
在崩溃后检查变量的值。
-
当满足给定条件时条件性地停止执行。
-
以及许多其他高级功能。
大多数编程语言都支持(或附带)某种形式的调试器。最通用的是通用调试器,如gdb(GNU 调试器)和lldb(LLVM 调试器),它们可以调试任何本地二进制文件。许多语言也有语言特定调试器,它们与运行时集成得更紧密(如 Python 的 pdb 或 Java 的 jdb)。
gdb 是 C、C++、Rust 和其他编译语言的默认标准调试器。它允许你探测几乎任何进程并获取其当前机器状态:寄存器、堆栈、程序计数器等等。
一些有用的 GDB 命令:
-
run- 启动程序 -
b {函数}或b {文件}:{行}- 设置断点 -
c- 继续执行 -
step/next/finish- 进入 / 跳过 / 退出 -
p {变量}- 打印变量的值 -
bt- 显示回溯(调用栈) -
watch {表达式}- 当值改变时中断
考虑使用 GDB 的 TUI 模式(
gdb -tui或在 GDB 内部按Ctrl-x a)以分屏视图显示源代码和命令提示符。
记录-回放调试
一些最令人沮丧的 bug 是海森堡 bug:当你尝试观察它们时,这些 bug 似乎会消失或改变行为。竞争条件、时间依赖性 bug 以及仅在特定系统条件下出现的问题都属于这一类。传统的调试在这里通常是无用的,因为再次运行程序会产生不同的行为(例如,打印语句可能会足够慢地减慢代码,以至于竞争条件不再发生)。
记录-回放调试通过记录程序的执行并允许你根据需要确定性回放它来解决此问题。更好的是,你可以反向执行以找到问题发生的确切位置。
rr 是一个强大的 Linux 工具,它可以记录程序执行并允许具有完整调试功能的确定性回放。它与 GDB 兼容,所以你已熟悉其界面。
基本用法:
# Record a program execution
rr record ./my_program
# Replay the recording (opens GDB)
rr replay
奇迹发生在回放过程中。因为执行是确定的,你可以使用反向调试命令:
-
reverse-continue(rc) - 向后运行直到遇到断点 -
reverse-step(rs) - 向后一步 -
reverse-next(rn) - 向后一步,跳过函数调用 -
reverse-finish- 向后运行直到进入当前函数
这对于调试来说非常强大。比如说你遇到了一个崩溃——而不是猜测 bug 的位置并设置断点,你可以:
-
运行到崩溃
-
检查损坏的状态
-
在损坏的变量上设置观察点
-
reverse-continue以找到它被损坏的确切位置
何时使用 rr:
-
偶发失败的测试
-
竞争条件和线程 bug
-
难以复现的崩溃
-
任何你希望可以“回到过去”修复的 bug
注意:rr 仅在 Linux 上工作,需要硬件性能计数器。它不适用于不公开这些计数器的虚拟机,例如大多数 AWS EC2 实例,并且不支持 GPU 访问。对于 macOS,请查看Warpspeed。
rr 和并发:因为 rr 记录执行是确定性的,所以它序列化了线程调度。这意味着如果某些竞争条件依赖于特定的时机,那么在 rr 下可能不会表现出来。rr 仍然对调试竞争条件有用——一旦捕获到失败的运行,就可以可靠地回放它——但您可能需要多次记录尝试来捕捉间歇性错误。对于不涉及并发的错误,rr 最为出色:您总能重现确切的执行,并使用反向调试来追踪损坏。
系统调用跟踪
有时您需要了解您的程序如何与操作系统交互。程序通过系统调用请求内核的服务——打开文件、分配内存、创建进程等。跟踪这些调用可以揭示程序为何挂起、它试图访问哪些文件,或者它在等待的地方花费了多长时间。
strace(Linux)和 dtruss(macOS)
strace(https://www.man7.org/linux/man-pages/man1/strace.1.html)允许您观察程序发出的每一个系统调用:
# Trace all system calls
strace ./my_program
# Trace only file-related calls
strace -e trace=file ./my_program
# Follow child processes (important for programs that start other programs)
strace -f ./my_program
# Trace a running process
strace -p <PID>
# Show timing information
strace -T ./my_program
在 macOS 和 BSD 上,使用
dtruss(https://www.manpagez.com/man/1/dtruss/)(它包装了dtrace)来实现类似的功能:要深入了解
strace,请查看 Julia Evans 出色的《strace 杂志》。
bpftrace 和 eBPF
eBPF(扩展伯克利包过滤器)是一种强大的 Linux 技术,它允许在内核中运行沙盒程序。bpftrace(https://github.com/iovisor/bpftrace)提供了编写 eBPF 程序的高级语法。这些是运行在内核中的任意程序,因此具有巨大的表达能力(尽管语法有些笨拙,类似于 awk 的语法)。它们最常见的使用场景是调查正在调用的系统调用,包括聚合(如计数或延迟统计)或内省(甚至过滤)系统调用参数。
# Trace file opens system-wide (prints immediately)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'
# Count system calls by name (prints summary on Ctrl-C)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }'
然而,您也可以使用像bcc(https://github.com/iovisor/bcc)这样的工具链直接在 C 中编写 eBPF 程序,bcc还附带了许多实用工具,如biosnoop用于打印磁盘操作的延迟分布或opensnoop用于打印所有打开的文件。
由于strace易于“快速启动”,因此当您需要更低的开销、需要跟踪内核函数、需要进行任何类型的聚合等时,您应该选择bpftrace。请注意,bpftrace必须以root身份运行,并且它通常监控整个内核,而不仅仅是特定的进程。要针对特定的程序,您可以按命令名或 PID 进行过滤:
# Filter by command name (prints summary on Ctrl-C)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* /comm == "bash"/ { @[probe] = count(); }'
# Trace a specific command from startup using -c (cpid = child PID)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* /pid == cpid/ { @[probe] = count(); }' -c 'ls -la'
-c 标志运行指定的命令并将 cpid 设置为其 PID,这对于从程序启动时追踪程序非常有用。当追踪的命令退出时,bpftrace 打印汇总的结果。
网络调试
对于网络问题,tcpdump 和 Wireshark 允许你捕获和分析网络数据包:
# Capture packets on port 80
sudo tcpdump -i any port 80
# Capture and save to file for Wireshark analysis
sudo tcpdump -i any -w capture.pcap
对于 HTTPS 流量,加密使得 tcpdump 变得不太有用。像 mitmproxy 这样的工具可以充当拦截代理来检查加密流量。浏览器开发者工具(网络标签)通常是调试来自 Web 应用的 HTTPS 请求的最简单方法——它们显示了解密后的请求数据、响应数据、头部信息和时间。
内存调试
内存错误——缓冲区溢出、使用后释放、内存泄漏是最危险且难以调试的。它们通常不会立即崩溃,但会以导致后续问题的方式损坏内存。
清理器
寻找内存错误的一种方法是使用 清理器,这是编译器功能,它会对你的代码进行操作以在运行时检测错误。例如,广泛使用的 AddressSanitizer (ASan) 检测:
-
缓冲区溢出(栈、堆和全局)
-
使用后释放
-
使用后返回
-
内存泄漏
# Compile with AddressSanitizer
gcc -fsanitize=address -g program.c -o program
./program
有各种各样的有用的清理器:
-
ThreadSanitizer (TSan):检测多线程代码中的数据竞争(
-fsanitize=thread) -
MemorySanitizer (MSan):检测未初始化内存的读取(
-fsanitize=memory) -
UndefinedBehaviorSanitizer (UBSan):检测未定义行为,如整数溢出(
-fsanitize=undefined)
清理器需要重新编译,但足够快,可以在 CI 管道和常规开发中使用。
Valgrind:当你无法重新编译时
Valgrind 的工作原理是运行你的程序在类似虚拟机的东西中,以检测内存错误。它比清理器慢,但不需要重新编译:
valgrind --leak-check=full ./my_program
当以下情况发生时使用 Valgrind:
-
你没有源代码
-
你无法重新编译(第三方库)
-
你需要特定的工具,这些工具不能作为清理器使用
Valgrind 实际上是一个非常强大的受控执行环境,我们将在后续的剖析中看到更多关于它的内容!
调试用的 AI
大型语言模型已成为令人惊讶的有用的调试助手。它们在特定的调试任务上表现出色,这些任务补充了传统工具。
LLM 的优势所在:
-
解释晦涩的错误信息:编译器错误,尤其是来自 C++ 模板或 Rust 的借用检查器的,可能是臭名昭著的晦涩。LLM 可以将它们翻译成普通英语并建议修复方法。
-
跨越语言和抽象边界:如果你正在调试跨越多个语言的程序(例如,一个 C 库的 bug 通过 Python 绑定表现出来),LLM 可以帮助导航不同的层。它们特别擅长理解 FFI 边界、构建系统问题和跨语言调试(例如,我的程序出错,但我认为是因为我的依赖中的一个 bug)。
-
关联症状与根本原因:“我的程序运行良好,但使用的内存比预期多 10 倍”是 LLM 可以帮助调查的模糊症状,建议可能的原因和要查找的内容。
-
分析崩溃转储和堆栈跟踪:粘贴堆栈跟踪并询问可能的原因。
关于调试符号的说明:为了获得有意义的堆栈跟踪和调试,确保你的二进制文件(以及任何链接的库)是用调试符号编译的(
-g标志)。调试信息通常存储在 DWARF 格式中。此外,使用帧指针编译(-fno-omit-frame-pointer)可以使堆栈跟踪更可靠,特别是对于性能分析工具。没有这些,堆栈跟踪可能只显示内存地址或是不完整的。这对于本地编译的程序(如 C++、Rust)比 Python 或 Java 更重要。
需要注意的限制:
-
LLMs can hallucinate plausible-sounding but wrong explanations
-
它们可能建议的修复方案会掩盖错误而不是修复它
-
Always verify suggestions with actual debugging tools
-
它们最好作为理解你代码的补充,而不是替代品
这与在开发环境讲座中涵盖的通用 AI 编码能力不同。这里我们具体讨论的是使用 LLM 作为调试辅助。
性能分析
即使你的代码在功能上表现如你所预期,但如果它占用了所有的 CPU 或内存,可能还不够好。算法课程通常教授大 O 符号,但并不教授如何找到程序中的热点。由于过早优化是万恶之源,你应该了解分析器和监控工具。它们将帮助你了解程序中哪些部分花费了大部分时间或资源,这样你就可以专注于优化这些部分。
计时
测量性能的最简单方法是计时。在许多情况下,只需打印代码在两个点之间花费的时间就足够了。
然而,墙钟时间可能会误导,因为你的计算机可能同时运行其他进程或等待事件发生。time命令区分了真实、用户和系统时间:
-
真实 - 从开始到结束的墙钟时间,包括等待时间
-
用户 - 运行用户代码所花费的 CPU 时间
-
系统 - 运行内核代码所花费的 CPU 时间
$ time curl https://missing.csail.mit.edu &> /dev/null
real 0m0.272s
user 0m0.079s
sys 0m0.028s
在这里,请求耗时近 300 毫秒(实际时间),但只有 107 毫秒的 CPU 时间(用户+系统)。其余时间都在等待网络。
资源监控
有时分析程序性能的第一步是了解其实际资源消耗。当程序资源受限时,它们通常会运行得较慢。
-
通用监控:
htop(链接)是top的改进版本,它展示了当前运行进程的各种统计数据。有用的快捷键:<F6>用于排序进程,t用于显示树形层次结构,h用于切换线程。还有btop(链接),它可以监控更多的事情。 -
I/O 操作:
iotop(链接)显示实时 I/O 使用信息。 -
内存使用情况:
free(链接)显示总空闲和已用内存。 -
打开的文件:
lsof(链接)列出由进程打开的文件信息。用于检查哪个进程打开了特定的文件。 -
网络连接:
ss(链接)允许您监控网络连接。一个常见的用例是确定哪个进程正在使用特定的端口:ss -tlnp | grep :8080。
可视化性能数据
人类在图表中识别模式的速度远快于在数字表中。在分析性能时,绘制数据通常能揭示趋势、峰值和异常,这些在原始数字中是看不见的。
使数据可绘图:在添加打印或日志语句进行调试时,请考虑格式化输出,以便以后容易绘图。简单的 CSV 格式的时间戳和值(1705012345,42.5)比散文句子更容易绘图。结构化日志(JSON)也可以轻松解析和绘图。换句话说,以整洁的方式记录数据。
使用 gnuplot 进行快速绘图:对于简单的命令行绘图,gnuplot(链接)可以直接从数据文件生成图表:
# Plot a simple CSV with timestamp,value
gnuplot -e "set datafile separator ','; plot 'latency.csv' using 1:2 with lines"
使用 matplotlib 和 ggplot2 进行迭代探索:为了进行更深入的分析,Python 的matplotlib和 R 的ggplot2允许迭代探索。与一次性绘图不同,这些工具让您可以快速切片和转换数据以检验假设。ggplot2 的分割图特别强大——您可以通过类别将单个数据集拆分到多个子图中(例如,按端点或一天中的时间分割请求延迟),以揭示其他情况下可能隐藏的模式。
示例用例:
-
随时间绘制请求延迟可以揭示原始百分位数所掩盖的周期性减速(垃圾回收、cron 作业、流量模式)。
-
可视化增长数据结构的插入时间可以暴露算法复杂性问题——向量插入的图表将在支持数组大小加倍时显示特征性的峰值。
-
通过不同的维度(请求类型、用户群体、服务器)细分指标通常揭示一个“系统级”问题实际上仅限于一个类别。
CPU 性能分析器
大多数时候,当人们提到性能分析器时,他们指的是CPU 性能分析器。主要有两种类型:
-
跟踪性能分析器会记录程序中每个函数调用的记录。
-
采样性能分析器定期(通常每毫秒)探测程序,并记录程序的堆栈。
采样性能分析器具有更低的开销,通常更适合生产使用。
perf:采样性能分析器
perf是标准的 Linux 性能分析器。它可以分析任何程序,无需重新编译:
perf stat为你提供了一个关于时间花费的快速概述:
$ perf stat ./slow_program
Performance counter stats for './slow_program':
3,210.45 msec task-clock # 0.998 CPUs utilized
12 context-switches # 3.738 /sec
0 cpu-migrations # 0.000 /sec
156 page-faults # 48.587 /sec
12,345,678,901 cycles # 3.845 GHz
9,876,543,210 instructions # 0.80 insn per cycle
1,234,567,890 branches # 384.532 M/sec
12,345,678 branch-misses # 1.00% of all branches
实际程序的性能分析输出将包含大量信息。人类是视觉生物,阅读大量数字相当困难。火焰图是一种可视化工具,它使得性能分析数据更容易理解。
火焰图在 Y 轴上显示函数调用的层次结构,X 轴上显示的时间与 X 轴成比例。它们是交互式的——你可以点击放大程序的具体部分。
要从perf数据生成火焰图:
# Record profile
perf record -g ./my_program
# Generate flame graph (requires flamegraph scripts)
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
考虑使用Speedscope作为基于网络的交互式火焰图查看器,或者Perfetto进行全面的系统级分析。
Valgrind 的 Callgrind:跟踪性能分析器
callgrind是一个性能分析工具,它记录了程序的调用历史和指令计数。与采样性能分析器不同,它提供精确的调用次数,并可以显示调用者和被调用者之间的关系:
# Run with callgrind
valgrind --tool=callgrind ./my_program
# Analyze with callgrind_annotate (text) or kcachegrind (GUI)
callgrind_annotate callgrind.out.<pid>
kcachegrind callgrind.out.<pid>
Callgrind 比采样性能分析器慢,但提供精确的调用次数,并且可以选择性地模拟缓存行为(使用--cache-sim=yes),如果你需要这些信息的话。
如果你使用特定的语言,可能会有更专业的性能分析器。例如,Python 有
cProfile和py-spy,Go 有go tool pprof,Rust 有cargo-flamegraph(实际上适用于任何编译程序!)。
内存性能分析器
内存性能分析器帮助你了解程序随时间如何使用内存,并找到内存泄漏。
Valgrind 的 Massif
massif (massif) 分析堆内存使用情况:
valgrind --tool=massif ./my_program
ms_print massif.out.<pid>
这显示了随时间变化的堆使用情况,有助于识别内存泄漏和过度分配。
对于 Python,
memory-profiler(memory-profiler) 提供逐行内存使用信息。
基准测试
当你需要比较不同实现或工具的性能时,hyperfine (hyperfine) 是基准测试命令行程序的绝佳选择:
$ hyperfine --warmup 3 'fd -e jpg' 'find . -iname "*.jpg"'
Benchmark #1: fd -e jpg
Time (mean ± σ): 51.4 ms ± 2.9 ms [User: 121.0 ms, System: 160.5 ms]
Range (min … max): 44.2 ms … 60.1 ms 56 runs
Benchmark #2: find . -iname "*.jpg"
Time (mean ± σ): 1.126 s ± 0.101 s [User: 141.1 ms, System: 956.1 ms]
Range (min … max): 0.975 s … 1.287 s 10 runs
Summary
'fd -e jpg' ran
21.89 ± 2.33 times faster than 'find . -iname "*.jpg"'
对于 Web 开发,浏览器开发者工具包括出色的分析器。查看 Firefox Profiler 和 Chrome DevTools 文档。
练习
调试
-
调试排序算法:以下伪代码实现了归并排序但包含一个错误。用你选择的任何语言实现它,然后使用调试器(gdb、lldb、pdb 或你 IDE 的调试器)来找到并修复错误。
function merge_sort(arr): if length(arr) <= 1: return arr mid = length(arr) / 2 left = merge_sort(arr[0..mid]) right = merge_sort(arr[mid..end]) return merge(left, right) function merge(left, right): result = [] i = 0, j = 0 while i < length(left) AND j < length(right): if left[i] <= right[j]: append result, left[i] i = i + 1 else: append result, right[i] j = j + 1 append remaining elements from left and right return result测试向量:
merge_sort([3, 1, 4, 1, 5, 9, 2, 6])应返回[1, 1, 2, 3, 4, 5, 6, 9]。使用断点并逐步执行合并函数以找到选择错误元素的位置。 -
安装
rr(rr) 并使用反向调试来找到损坏错误。将此程序保存为corruption.c:#include <stdio.h> typedef struct { int id; int scores[3]; } Student; Student students[2]; void init() { students[0].id = 1001; students[0].scores[0] = 85; students[0].scores[1] = 92; students[0].scores[2] = 78; students[1].id = 1002; students[1].scores[0] = 90; students[1].scores[1] = 88; students[1].scores[2] = 95; } void curve_scores(int student_idx, int curve) { for (int i = 0; i < 4; i++) { students[student_idx].scores[i] += curve; } } int main() { init(); printf("=== Initial state ===\n"); printf("Student 0: id=%d\n", students[0].id); printf("Student 1: id=%d\n", students[1].id); curve_scores(0, 5); printf("\n=== After curving ===\n"); printf("Student 0: id=%d\n", students[0].id); printf("Student 1: id=%d\n", students[1].id); if (students[1].id != 1002) { printf("\nERROR: Student 1's ID was corrupted! Expected 1002, got %d\n", students[1].id); return 1; } return 0; }使用
gcc -g corruption.c -o corruption进行编译并运行它。学生 1 的 ID 被损坏,但损坏发生在只接触学生 0 的函数中。使用rr record ./corruption和rr replay来找出罪魁祸首。在students[1].id上设置观察点,并在损坏后使用reverse-continue来找到覆盖它的确切代码行。 -
使用 AddressSanitizer 调试内存错误。将其保存为
uaf.c:#include <stdlib.h> #include <string.h> #include <stdio.h> int main() { char *greeting = malloc(32); strcpy(greeting, "Hello, world!"); printf("%s\n", greeting); free(greeting); greeting[0] = 'J'; printf("%s\n", greeting); return 0; }首先不使用清理器编译和运行:
gcc uaf.c -o uaf && ./uaf。它可能看起来可以工作。现在使用 AddressSanitizer 编译:gcc -fsanitize=address -g uaf.c -o uaf && ./uaf。阅读错误报告。ASan 发现了什么错误?修复它识别的问题。 -
使用
strace(Linux) 或dtruss(macOS) 来跟踪ls -l等命令的系统调用。它执行了哪些系统调用?尝试跟踪一个更复杂的程序并查看它打开了哪些文件。 -
使用 LLM(大型语言模型)来帮助调试一个难以理解的错误信息。尝试复制编译器错误(特别是来自 C++模板或 Rust)并请求解释和修复。尝试将
strace或地址清理器的部分输出放入其中。
分析
-
使用
perf stat获取所选程序的基本性能统计信息。不同的计数器代表什么? -
使用
perf record进行性能分析。将其保存为slow.c:#include <math.h> #include <stdio.h> double slow_computation(int n) { double result = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < 1000; j++) { result += sin(i * j) * cos(i + j); } } return result; } int main() { double r = 0; for (int i = 0; i < 100; i++) { r += slow_computation(1000); } printf("Result: %f\n", r); return 0; }使用调试符号编译:
gcc -g -O2 slow.c -o slow -lm。运行perf record -g ./slow,然后perf report来查看时间花费在哪里。尝试使用 flamegraph 脚本来生成火焰图。 -
使用
hyperfine对同一任务的两种不同实现进行基准测试(例如,find与fd,grep与ripgrep,或你自己的代码的两个版本)。 -
在运行资源密集型程序时,使用
htop来监控系统。尝试使用taskset来限制进程可以使用的 CPU:taskset --cpu-list 0,2 stress -c 3。为什么stress不使用三个 CPU? -
常见问题之一是你想要监听的端口已被另一个进程占用。了解如何发现该进程的方法:首先执行
python -m http.server 4444以在 4444 端口启动一个最小化 Web 服务器。在另一个终端运行ss -tlnp | grep 4444以查找该进程。使用kill <PID>终止它。
本文档遵循CC BY-NC-SA许可协议。
版本控制和 Git
版本控制系统(VCSs)是用于跟踪源代码(或其他文件和文件夹集合)更改的工具。正如其名称所暗示的,这些工具有助于维护更改的历史;此外,它们还促进了协作。从逻辑上讲,VCSs 通过一系列快照跟踪文件夹及其内容的更改,其中每个快照封装了顶级目录内文件/文件夹的整个状态。VCSs 还维护诸如谁创建了每个快照、与每个快照相关的消息等元数据。
版本控制有什么用?即使你一个人工作,它也能让你查看项目的旧快照,记录为什么做出某些更改,并行开发分支,等等。当与他人合作时,它是一个无价之宝,可以查看其他人所做的更改,以及在并发开发中解决冲突。
现代的版本控制系统(VCSs)也让你能够轻松(并且经常自动地)回答如下问题:
-
谁编写了这个模块?
-
这个特定文件的特定行是什么时候被编辑的?由谁编辑的?为什么编辑?
-
在过去 1000 次修订中,何时/为什么某个特定的单元测试停止工作?
尽管存在其他 VCSs,但Git是版本控制的既定标准。这个XKCD 漫画捕捉了 Git 的声誉:

因为 Git 的界面是一个有缺陷的抽象,所以从上到下学习 Git(从其界面/命令行界面开始)可能会导致很多困惑。人们可以记住一些命令,并将它们视为魔法咒语,并在任何问题出现时遵循上面漫画中的方法。
虽然 Git 的界面确实很丑陋,但其底层设计和理念是美丽的。丑陋的界面需要记忆,而美丽的设计则可以理解。因此,我们从 Git 的数据模型开始,自下而上地解释 Git,然后涵盖命令行界面。一旦理解了数据模型,就可以更好地理解命令,了解它们是如何操作底层数据模型的。
Git 的数据模型
Git 的独创性在于其精心设计的数据库模型,它使得版本控制的所有良好功能成为可能,如维护历史、支持分支和促进协作。
快照
Git 将顶级目录内的一组文件和文件夹的历史视为一系列快照。在 Git 术语中,文件被称为“blob”,它只是一堆字节。目录被称为“tree”,它将名称映射到 blob 或 tree(因此目录可以包含其他目录)。快照是正在跟踪的顶级树。例如,我们可能有一个如下所示的树:
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")
顶层树包含两个元素,一个树“foo”(它本身包含一个元素,一个 blob“bar.txt”),和一个 blob“baz.txt”。
历史建模:关联快照
版本控制系统应该如何关联快照?一个简单的模型可能是有一个线性历史。历史将是一系列按时间顺序排列的快照。由于许多原因,Git 不使用这种简单的模型。
在 Git 中,历史是一个快照的有向无环图(DAG)。这可能听起来像是一个复杂的数学术语,但不要感到害怕。这仅仅意味着 Git 中的每个快照都引用了一组“父快照”,即在其之前出现的快照。它是一组父快照而不是单个父快照(如线性历史中那样),因为一个快照可能来自多个父快照,例如,由于合并(合并)两个平行的开发分支。
Git 将这些快照称为“提交”。可视化提交历史可能看起来像这样:
o <-- o <-- o <-- o
^
\
--- o <-- o
在上面的 ASCII 艺术中,o对应于单个提交(快照)。箭头指向每个提交的父提交(这是一个“先于”关系,而不是“后于”关系)。在第三个提交之后,历史分支成两个独立的分支。这可能对应于,例如,两个独立的功能并行开发。在未来,这些分支可能会合并以创建一个新的快照,该快照包含这两个功能,产生一个新的历史,如下所示,新创建的合并提交以粗体显示:
o <-- o <-- o <-- o <---- **o**
^ /
\ v
--- o <-- o
Git 中的提交是不可变的。这并不意味着错误不能被纠正;只是“编辑”提交历史实际上是在创建全新的提交,并且引用(见下文)被更新以指向新的提交。
数据模型,伪代码
看到 Git 的数据模型以伪代码的形式写下来可能会有所帮助:
// a file is a bunch of bytes
type blob = array<byte>
// a directory contains named files and directories
type tree = map<string, tree | blob>
// a commit has parents, metadata, and the top-level tree
type commit = struct {
parents: array<commit>
author: string
message: string
snapshot: tree
}
这是一个干净、简单的历史模型。
对象和内容寻址
“对象”是一个 blob、树或提交:
type object = blob | tree | commit
在 Git 的数据存储中,所有对象都通过其SHA-1 哈希值进行内容寻址。
objects = map<string, object>
def store(object):
id = sha1(object)
objects[id] = object
def load(id):
return objects[id]
Blob、树和提交以这种方式统一:它们都是对象。当它们引用其他对象时,它们实际上并不在磁盘表示中包含它们,而是通过哈希值引用它们。
例如,示例目录结构的树(使用git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d可视化)看起来像这样:
100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85 baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87 foo
树本身包含对其内容的指针,baz.txt(一个 blob)和foo(一个树)。如果我们使用git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85查看对应于 baz.txt 的哈希值的内容,我们得到以下结果:
git is wonderful
引用
现在,所有快照都可以通过它们的 SHA-1 哈希值来识别。这不太方便,因为人类不擅长记住由 40 个十六进制字符组成的字符串。
Git 针对这个问题的解决方案是为 SHA-1 散列提供人类可读的名称,称为“引用”。引用是指向提交的指针。与不可变的对象不同,引用是可变的(可以更新以指向新的提交)。例如,master引用通常指向开发主分支中的最新提交。
references = map<string, string>
def update_reference(name, id):
references[name] = id
def read_reference(name):
return references[name]
def load_reference(name_or_id):
if name_or_id in references:
return load(references[name_or_id])
else:
return load(name_or_id)
通过这种方式,Git 可以使用人类可读的名称,如“master”,来引用历史中的一个特定快照,而不是一个长的十六进制字符串。
一个细节是,我们经常想要一个关于“当前我们在历史中的位置”的概念,这样当我们创建一个新的快照时,我们就知道它是相对于什么的(如何设置提交的parents字段)。在 Git 中,“当前我们在哪里”是一个特殊的引用,称为“HEAD”。
仓库
最后,我们可以定义(大致上)什么是 Git 仓库:它是数据objects和references。
在磁盘上,Git 存储的所有内容都是对象和引用:这就是 Git 数据模型的所有内容。所有的git命令都映射到通过添加对象和添加/更新引用来对提交 DAG 进行某种操作。
每次你输入任何命令时,都要考虑该命令对底层图数据结构进行的操作。相反,如果你试图对提交 DAG 进行某种特定的更改,例如“丢弃未提交的更改并使‘master’引用指向提交5d83f9e”,可能有一个命令可以做到这一点(例如,在这种情况下,git checkout master; git reset --hard 5d83f9e)。
预处理区域
这是另一个与数据模型正交的概念,但它构成了创建提交的接口的一部分。
你可能会想象的一种实现上述快照的方法是有一个“创建快照”命令,该命令基于工作目录的当前状态创建一个新的快照。一些版本控制工具就是这样工作的,但 Git 不是。我们想要干净的快照,并且从当前状态创建快照可能并不总是理想的。例如,想象一个场景,你已经实现了两个独立的功能,并且想要创建两个独立的提交,第一个提交引入了第一个功能,下一个提交引入了第二个功能。或者想象一个场景,你在代码中添加了调试打印语句,并修复了一个错误;你想要提交错误修复,同时丢弃所有的打印语句。
Git 通过允许你通过称为“暂存区域”的机制指定应包含在下一个快照中的修改来适应此类场景。
Git 命令行界面
为了避免信息重复,我们不会在这些讲义中详细解释下面的命令。有关更多信息,请参阅高度推荐的Pro Git,或者观看讲座视频。
基础知识
-
git help <command>: 获取 git 命令的帮助 -
git init: 创建一个新的 git 仓库,数据存储在.git目录中 -
git status: 告诉你发生了什么 -
git add <filename>:将文件添加到暂存区 -
git commit:创建新的提交 -
git log:显示简化的历史记录 -
git log --all --graph --decorate:将历史可视化为一个 DAG -
git diff <filename>:显示相对于暂存区的更改 -
git diff <revision> <filename>:显示快照之间文件的不同 -
git checkout <revision>:更新 HEAD(如果检出分支,则更新当前分支)
分支和合并
-
git branch:显示分支 -
git branch <name>:创建分支 -
git switch <name>:切换到分支 -
git checkout -b <name>:创建分支并切换到它- 与
git branch <name>; git switch <name>相同
- 与
-
git merge <revision>:合并到当前分支 -
git mergetool:使用一个花哨的工具来帮助解决合并冲突 -
git rebase:将一系列补丁变基到新基础
远程
-
git remote:列出远程 -
git remote add <name> <url>:添加远程 -
git push <remote> <local branch>:<remote branch>:发送对象到远程,并更新远程引用 -
git branch --set-upstream-to=<remote>/<remote branch>:设置本地分支和远程分支之间的对应关系 -
git fetch:从远程检索对象/引用 -
git pull:等同于git fetch; git merge -
git clone:从远程下载仓库
撤销
-
git commit --amend:编辑提交的内容/消息 -
git reset <file>:取消暂存文件 -
git restore:撤销更改
高级 Git
-
git config:Git 是高度可定制 -
git clone --depth=1:浅克隆,不包含整个版本历史 -
git add -p:交互式暂存 -
git rebase -i:交互式变基 -
git blame:显示谁最后编辑了哪一行 -
git stash:临时移除工作目录的修改 -
git bisect:二分搜索历史(例如,用于回归) -
git revert:创建一个新的提交,以撤销早期提交的效果 -
git worktree:同时检出多个分支 -
.gitignore:指定要忽略的故意未跟踪文件
杂项
-
GUIs:有许多GUI 客户端可供 Git 使用。我们个人不使用它们,而是使用命令行界面。
-
Shell 集成:将 Git 状态作为 shell 提示符的一部分非常方便(zsh, bash)。通常包含在像Oh My Zsh这样的框架中。
-
编辑器集成:类似于上述内容,许多功能的便捷集成。fugitive.vim是 Vim 的标准之一。
-
Workflows: 我们教了你数据模型和一些基本命令;我们没有告诉你在大项目上工作时应该遵循哪些实践(而且有很多 不同的 方法)。
-
GitHub: Git 并非 GitHub。GitHub 有一种特定的方式向其他项目贡献代码,称为 pull requests。
-
其他 Git 提供商: GitHub 并非特殊:有许多 Git 仓库托管服务,如 GitLab 和 BitBucket。
资源
-
Pro Git 是强烈推荐阅读的书籍。现在你理解了数据模型,通过阅读第 1-5 章,你应该可以学会如何熟练使用 Git。后面的章节有一些有趣的高级材料。
-
Oh Shit, Git!?! 是一个关于如何从一些常见的 Git 错误中恢复的简短指南。
-
Git for Computer Scientists 是对 Git 数据模型的一个简短解释,比这些讲义有更少的伪代码和更多的复杂图表。
-
Git from the Bottom Up 是对 Git 实现细节的详细解释,不仅限于数据模型,适合好奇心强的人。
-
Learn Git Branching 是一个基于浏览器的游戏,教你 Git。
练习
-
如果你没有任何 Git 经验,要么尝试阅读 Pro Git 的前几章,要么通过 Learn Git Branching 这样的教程。当你学习时,将 Git 命令与数据模型联系起来。
-
克隆班级网站仓库。
-
通过将其可视化成图表来探索版本历史。
-
最后修改
README.md的人是谁?(提示:使用带有参数的git log)。 -
与
_config.yml中collections:行的最后修改相关的提交信息是什么?(提示:使用git blame和git show)。
-
-
学习 Git 时常见的错误之一是提交应该由 Git 管理的大型文件或添加敏感信息。尝试将文件添加到仓库中,进行一些提交,然后从 历史记录(而不仅仅是最新提交)中删除该文件。你可能想看看这个。
-
从 GitHub 克隆一些仓库,并修改其现有文件之一。当你执行
git stash时会发生什么?运行git log --all --oneline时你会看到什么?运行git stash pop来撤销你使用git stash所做的操作。在什么情况下这可能是有用的? -
与许多命令行工具一样,Git 提供了一个名为
~/.gitconfig的配置文件(或点文件)。在~/.gitconfig中创建一个别名,以便当你运行git graph时,你将得到git log --all --graph --decorate --oneline的输出。你可以直接 编辑~/.gitconfig文件,或者你可以使用git config命令来添加别名。有关 git 别名的信息可以在 这里 找到。 -
在运行
git config --global core.excludesfile ~/.gitignore_global命令后,你可以在~/.gitignore_global文件中定义全局忽略模式。这设置了 Git 将使用的全局忽略文件的位置,但你仍然需要手动在该路径创建文件。设置你的全局 gitignore 文件以忽略特定于操作系统或编辑器的临时文件,例如.DS_Store。 -
分叉用于课程网站的 仓库,找到你可以改进的错误或内容,并在 GitHub 上提交一个 pull request(你可能想查看 这个)。请只提交有用的 PR(请不要垃圾邮件,谢谢!)如果你找不到可以改进的地方,你可以跳过这个练习。
-
通过模拟协作场景来练习解决合并冲突:
-
使用
git init创建一个新的仓库,并创建一个名为recipe.txt的文件,包含几行内容(例如,一个简单的食谱)。 -
提交更改,然后创建两个分支:
git branch salty和git branch sweet。 -
在
salty分支中,修改一行(例如,将“1 杯糖”改为“1 杯盐”)并提交。 -
在
sweet分支中,修改相同的行以不同的方式(例如,将“1 杯糖”改为“2 杯糖”)并提交。 -
现在,切换到
master分支,尝试git merge salty,然后git merge sweet。会发生什么?查看recipe.txt的内容 -<<<<<<<、=======和>>>>>>>标记代表什么? -
通过编辑文件以保留你想要的内容,删除冲突标记,并使用
git add和git commit(或git merge --continue)完成合并来解决冲突。或者,尝试使用git mergetool通过图形化或基于终端的合并工具来解决冲突。 -
使用
git log --graph --oneline命令来可视化你刚刚创建的合并历史。
-
本文档受 CC BY-NC-SA 许可。
打包和分发代码
让代码按预期工作很难;让相同的代码在不同于你自己的机器上运行通常更难。
分发代码意味着将你编写的代码转换成其他人可以在没有你电脑的精确设置下运行的可使用形式。分发代码有多种形式,并取决于编程语言、系统库和操作系统等多种因素。它还取决于你正在构建的内容:软件库、命令行工具和 Web 服务都有不同的要求和部署步骤。无论如何,所有这些场景之间都有一个共同的模式:我们需要定义可交付的内容——即工件——以及它对其周围环境的假设。
在本讲座中,我们将涵盖:
-
依赖项与环境
-
工件与打包
-
发布与版本控制
-
可重复性
-
虚拟机与容器
-
配置
-
服务与编排
-
发布
我们将通过 Python 生态系统中的示例来解释这些概念,因为具体的例子有助于理解。虽然其他编程语言生态系统中的工具不同,但概念在很大程度上是相同的。
依赖项与环境
在现代软件开发中,抽象层无处不在。程序自然地将逻辑卸载到其他库或服务中。然而,这引入了程序与其所需库之间的依赖关系。例如,在 Python 中,为了获取网站的内容,我们通常这样做:
import requests
response = requests.get("https://missing.csail.mit.edu")
然而,requests 库并没有与 Python 运行时捆绑在一起,所以如果我们尝试在没有安装 requests 的情况下运行此代码,Python 将引发错误:
$ python fetch.py
Traceback (most recent call last):
File "fetch.py", line 1, in <module> import requests
ModuleNotFoundError: No module named 'requests'
为了使这个库可用,我们首先需要运行 pip install requests 来安装它。pip 是 Python 编程语言提供的用于安装包的命令行工具。执行 pip install requests 会产生以下一系列操作:
-
在 Python 包索引中搜索 requests (PyPI)
-
搜索适用于我们运行平台的适当工件
-
解析依赖关系——
requests库本身依赖于其他包,因此安装程序必须找到所有传递依赖的兼容版本并在安装之前安装它们 -
下载工件,然后解包并将文件复制到我们的文件系统中的正确位置
$ pip install requests
Collecting requests
Downloading requests-2.32.3-py3-none-any.whl (64 kB) Collecting charset-normalizer<4,>=2
Downloading charset_normalizer-3.4.0-cp311-cp311-manylinux_x86_64.whl (142 kB) Collecting idna<4,>=2.5
Downloading idna-3.10-py3-none-any.whl (70 kB) Collecting urllib3<3,>=1.21.1
Downloading urllib3-2.2.3-py3-none-any.whl (126 kB) Collecting certifi>=2017.4.17
Downloading certifi-2024.8.30-py3-none-any.whl (167 kB)
Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests
Successfully installed certifi-2024.8.30 charset-normalizer-3.4.0 idna-3.10 requests-2.32.3 urllib3-2.2.3
在这里我们可以看到 requests 有自己的依赖项,例如 certifi 或 charset-normalizer,并且它们必须在安装 requests 之前安装。一旦安装,Python 运行时可以在导入时找到这个库。
$ python -c 'import requests; print(requests.__path__)'
['/usr/local/lib/python3.11/dist-packages/requests'] $ pip list | grep requests
requests 2.32.3
编程语言在安装和发布库方面有不同的工具、约定和实践。在某些语言中,如 Rust,工具链是统一的——cargo 负责构建、测试、依赖管理和发布。在其他语言中,如 Python,统一发生在规范层面——而不是一个单独的工具,存在标准化的规范来定义打包的工作方式,允许每个任务有多个竞争工具(pip 与 uv(链接),setuptools 与 hatch(链接)与 poetry(链接))。在某些生态系统,如 LaTeX 中,TeX Live 或 MacTeX 这样的发行版会预装数千个已安装的包。
引入依赖项也会引入依赖冲突。冲突发生在程序需要同一依赖项的不兼容版本时。例如,如果 tensorflow==2.3.0 需要 numpy>=1.16.0,<1.19.0,而 pandas==1.2.0 需要 numpy>=1.16.5,那么任何满足 numpy>=1.16.5,<1.19.0 的版本都是有效的。但如果项目中的另一个包需要 numpy>=1.19,那么将存在一个没有有效版本满足所有约束的冲突。
这种情况——多个包需要相互不兼容的共享依赖版本——通常被称为 依赖地狱。解决冲突的一种方法是将每个程序的依赖项隔离到它们自己的 环境 中。在 Python 中,我们可以通过运行以下命令来创建虚拟环境:
$ which python
/usr/bin/python $ pwd
/home/missingsemester $ python -m venv venv
$ source venv/bin/activate
$ which python
/home/missingsemester/venv/bin/python $ which pip
/home/missingsemester/venv/bin/pip $ python -c 'import requests; print(requests.__path__)'
['/home/missingsemester/venv/lib/python3.11/site-packages/requests'] $ pip list
Package Version
------- -------
pip 24.0
你可以将环境视为一个完整的独立语言运行时版本,它有一组自己安装的包。这个虚拟环境或 venv 将安装的依赖项与全局 Python 安装隔离开来。为每个项目创建一个包含其所需依赖项的虚拟环境是一个好习惯。
虽然许多现代操作系统都预装了编程语言运行时,如 Python,但修改这些安装是不明智的,因为操作系统可能依赖于它们来执行其自身功能。建议使用独立的运行环境。
在某些语言中,安装协议不是由工具定义的,而是由规范定义的。在 Python 中,PEP 517 定义了构建系统接口,PEP 621 规定了项目元数据在 pyproject.toml 中的存储方式。这使开发者能够改进 pip 并产生更优化的工具,如 uv。要安装 uv,只需执行 pip install uv 即可。
使用 uv 而不是 pip 拥有相同的接口,但速度要快得多:
$ uv pip install requests
Resolved 5 packages in 12ms
Prepared 5 packages in 0.45ms
Installed 5 packages in 8ms
+ certifi==2024.8.30
+ charset-normalizer==3.4.0
+ idna==3.10
+ requests==2.32.3
+ urllib3==2.2.3
我们强烈推荐尽可能使用
uv pip而不是pip,因为它可以显著减少安装时间。
除了依赖隔离之外,环境还允许你拥有编程语言运行时的不同版本。
$ uv venv --python 3.12 venv312
Using CPython 3.12.7
Creating virtual environment at: venv312 $ source venv312/bin/activate && python --version
Python 3.12.7 $ uv venv --python 3.11 venv311
Using CPython 3.11.10
Creating virtual environment at: venv311 $ source venv311/bin/activate && python --version
Python 3.11.10
当你需要跨多个 Python 版本测试你的代码或项目需要特定版本时,这很有帮助。
在某些编程语言中,每个项目都会自动为其依赖项获得自己的环境,而不是手动创建,但原理是相同的。如今,大多数语言也都有一种机制来管理单个系统上的多个语言版本,并指定为各个项目使用哪个版本。
工件与打包
在软件开发中,我们区分源代码和工件。开发者编写和阅读源代码,而工件是从源代码产生的打包、可分发输出——准备好安装或部署。一个工件可以简单到我们运行的代码文件,也可以复杂到包含应用程序所有必要组件的整个虚拟机。考虑以下示例,我们当前目录中有一个 Python 文件greet.py:
$ cat greet.py
def greet(name):
return f"Hello, {name}!" $ python -c "from greet import greet; print(greet('World'))"
Hello, World! $ cd /tmp
$ python -c "from greet import greet; print(greet('World'))"
ModuleNotFoundError: No module named 'greet'
一旦我们移动到不同的目录,导入就会失败,因为 Python 只会在特定的位置(当前目录、已安装的包和PYTHONPATH中的路径)搜索模块。打包通过将代码安装到已知位置来解决此问题。
在 Python 中,打包一个库涉及到生成一个工件,包安装器(如pip或uv)可以使用它来安装相关文件。Python 工件被称为轮子,包含安装包所需的所有必要信息:代码文件、关于包的元数据(名称、版本、依赖项)以及将文件放置在环境中的说明。构建工件需要我们编写一个项目文件(也常称为清单),详细说明项目的具体信息、所需依赖项、包的版本和其他信息。在 Python 中,我们使用pyproject.toml来完成这个目的。
pyproject.toml是现代且推荐的方式。虽然早期的打包方法如requirements.txt或setup.py仍然受到支持,但只要可能,你应该优先选择pyproject.toml。
这里是一个为也提供命令行工具的库的最小pyproject.toml示例:
[project]
name = "greeting"
version = "0.1.0"
description = "A simple greeting library"
dependencies = ["typer>=0.9"]
[project.scripts]
greet = "greeting:main"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
typer库是一个流行的 Python 包,用于使用最少样板代码创建命令行界面。
以及相应的greeting.py:
import typer
def greet(name: str) -> str:
return f"Hello, {name}!"
def main(name: str):
print(greet(name))
if __name__ == "__main__":
typer.run(main)
使用这个文件,我们现在可以构建轮子:
$ uv build
Building source distribution...
Building wheel from source distribution...
Successfully built dist/greeting-0.1.0.tar.gz
Successfully built dist/greeting-0.1.0-py3-none-any.whl $ ls dist/
greeting-0.1.0-py3-none-any.whl
greeting-0.1.0.tar.gz
.whl文件是轮子(一个具有特定结构的 zip 存档),而.tar.gz是源代码分发,用于需要从源代码构建的系统。
你可以检查轮子的内容,看看打包了什么:
$ unzip -l dist/greeting-0.1.0-py3-none-any.whl
Archive: dist/greeting-0.1.0-py3-none-any.whl
Length Date Time Name
--------- ---------- ----- ----
150 2024-01-15 10:30 greeting.py
312 2024-01-15 10:30 greeting-0.1.0.dist-info/METADATA
92 2024-01-15 10:30 greeting-0.1.0.dist-info/WHEEL
9 2024-01-15 10:30 greeting-0.1.0.dist-info/top_level.txt
435 2024-01-15 10:30 greeting-0.1.0.dist-info/RECORD
--------- -------
998 5 files
现在如果我们把这个轮子(wheel)给其他人,他们可以通过运行以下命令来安装:
$ uv pip install ./greeting-0.1.0-py3-none-any.whl
$ greet Alice
Hello, Alice!
这会将我们之前构建的库安装到他们的环境中,包括greet命令行工具。
这种方法存在局限性。特别是如果我们的库依赖于特定平台的库,例如用于 GPU 加速的 CUDA,那么我们的工件只能在安装了这些特定库的系统上运行,我们可能需要为不同的平台(Linux、macOS、Windows)和架构(x86、ARM)构建单独的 wheel 文件。
在安装软件时,从源安装和安装预构建二进制文件之间存在一个重要的区别。从源安装意味着下载原始代码并在您的机器上编译它——这需要安装编译器和构建工具,对于大型项目可能需要花费大量时间。
安装预构建的二进制文件意味着下载其他人已经编译好的工件——更快、更简单,但二进制文件必须与您的平台和架构匹配。例如,ripgrep 的发布页面显示了适用于 Linux(x86_64、ARM)、macOS(Intel、Apple Silicon)和 Windows 的预构建二进制文件。
发布与版本控制
代码是在持续的过程中构建的,但以离散的方式发布。在软件开发中,开发和生产环境之间存在明显的区别。代码需要在 dev 环境中证明其工作后才能被发货到 prod。发布过程涉及许多步骤,包括测试、依赖项管理、版本控制、配置、部署和发布。
软件库不是静态的,随着时间的推移会演变,获得修复和新功能。我们通过离散的版本标识符跟踪这种演变,这些标识符对应于库在某个时间点的状态。库行为的变化可能从修复非关键功能的补丁,到扩展其功能的新功能,再到破坏向后兼容性的更改。变更日志记录了版本引入了哪些更改——这些是软件开发者用来传达与新版本相关更改的文档。
然而,跟踪每个依赖项的持续变化是不切实际的,尤其是当我们考虑到传递依赖——即我们依赖项的依赖项。
您可以使用
uv tree可视化您项目的整个依赖树,它以树形格式显示所有包及其传递依赖项。
为了简化这个问题,存在关于如何对软件进行版本控制的约定,其中最普遍的是语义版本控制或 SemVer。在语义版本控制下,版本有一个 MAJOR.MINOR.PATCH 形式的标识符,其中每个值都取整数值。简而言之,升级:
-
PATCH(例如,1.2.3 → 1.2.4)应仅包含错误修复并且完全向后兼容
-
MINOR(例如,1.2.3 → 1.3.0)以向后兼容的方式添加新功能
-
MAJOR(例如,1.2.3 → 2.0.0)表示可能需要代码修改的重大更改
这是一个简化,我们鼓励阅读完整的 SemVer 规范,以了解例如从 0.1.3 到 0.2.0 可能引起破坏性更改的原因,或者 1.0.0-rc.1 代表什么。Python 打包原生支持语义版本控制,因此当我们指定依赖项的版本时,我们可以使用各种指定符:
在pyproject.toml中,我们有不同的方式来约束依赖项兼容版本的范围:
[project]
dependencies = [
"requests==2.32.3", # Exact version - only this specific version "click>=8.0", # Minimum version - 8.0 or newer "numpy>=1.24,<2.0", # Range - at least 1.24 but less than 2.0 "pandas~=2.1.0", # Compatible release - >=2.1.0 and <2.2.0 ]
版本指定符存在于许多包管理器(如 npm、cargo 等)中,具有不同的精确语义。~=运算符是 Python 的“兼容发布”运算符——~=2.1.0表示“与 2.1.0 兼容的任何版本”,这相当于 npm 和 cargo 中的 caret(^)运算符,它遵循 SemVer 的兼容性概念。
并非所有软件都使用语义版本控制。一个常见的替代方案是日历版本控制(CalVer),其中版本基于发布日期而不是语义意义。例如,Ubuntu 使用类似24.04(2024 年 4 月)和24.10(2024 年 10 月)的版本。CalVer 使得查看发布版本有多旧变得容易,尽管它并没有传达任何关于兼容性的信息。最后,语义版本控制并非完美无缺,有时维护者无意中在次要或补丁版本中引入了破坏性更改。
可重复性
在现代软件开发中,你编写的代码位于大量抽象层之上。这包括编程语言运行时、第三方库、操作系统,甚至是硬件本身。这些层中的任何差异都可能改变代码的行为,甚至阻止它按预期工作。此外,底层硬件的差异甚至会影响你发布软件的能力。
锁定库指的是指定一个确切版本而不是一个范围,例如requests==2.32.3而不是requests>=2.0。
包管理器的一部分工作是考虑所有由依赖项——以及传递依赖项——提供的约束,然后生成一个有效的版本列表,该列表将满足所有约束。然后可以将具体的版本列表保存到文件中,用于可重复性目的;这些文件被称为锁定文件。
$ uv lock
Resolved 12 packages in 45ms $ cat uv.lock | head -20
version = 1 requires-python = ">=3.11" [[package]]
name = "certifi"
version = "2024.8.30"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/...", hash = "sha256:..." }
wheels = [
{ url = "https://files.pythonhosted.org/...", hash = "sha256:..." },
] ...
在处理依赖项版本和可重现性时,一个关键的区别是库和应用程序/服务之间的区别。库旨在被其他代码导入和使用,这些代码可能有自己的依赖项,因此指定过于严格的版本约束可能会与用户的其他依赖项发生冲突。相比之下,应用程序或服务是软件的最终消费者,通常通过用户界面或 API 而不是通过编程接口来公开其功能。对于库,指定版本范围是一种良好的实践,以最大限度地提高与更广泛的包生态系统的兼容性。对于应用程序,固定确切版本确保了可重现性——运行应用程序的每个人使用的是完全相同的依赖项。
对于需要最大可重现性的项目,像Nix和Bazel这样的工具提供了密封构建——其中每个输入,包括编译器、系统库,甚至构建环境本身都被固定和内容寻址。这保证了无论何时何地构建,输出都是位对位的完全相同。
你甚至可以使用 NixOS 来管理你的整个计算机安装,这样你就可以轻松地启动你计算机设置的副本,并通过版本控制的配置文件来管理它们的完整配置。
软件开发中一直存在一种永无止境的紧张关系,即新的软件版本有意或无意地引入了故障,而另一方面,旧的软件版本随着时间的推移会因安全漏洞而变得不安全。我们可以通过使用持续集成管道(我们将在代码质量和 CI 讲座中了解更多)来解决这个问题,这些管道会测试我们的应用程序与新的软件版本兼容性,并设置自动化检测我们依赖项新版本发布的机制,例如Dependabot。
即使有 CI 测试在位,在升级软件版本时仍然会出现问题,这通常是因为开发和生产环境之间不可避免的不匹配。在这种情况下,最好的行动方案是制定一个回滚计划,其中版本升级被撤销,并重新部署已知的良好版本。
虚拟机(VMs)与容器
当你开始依赖更复杂的依赖项时,你的代码的依赖项可能会超出包管理器可以处理的范围。一个常见的原因是必须与特定的系统库或硬件驱动程序进行接口。例如,在科学计算和人工智能领域,程序通常需要专门的库和驱动程序来利用 GPU 硬件。许多系统级依赖项(GPU 驱动程序、特定的编译器版本、如 OpenSSL 之类的共享库)仍然需要系统级安装。
传统上,这个问题通常通过虚拟机(VMs)来解决。虚拟机抽象了整个计算机,并提供了一个完全隔离的环境,拥有自己的专用操作系统。一个更现代的方法是容器,它们将应用程序及其依赖项、库和文件系统打包在一起,但与主机操作系统内核共享,而不是虚拟化整个计算机。由于它们共享内核,容器比虚拟机更轻量级,这使得它们启动更快,运行更高效。
最受欢迎的容器平台是 Docker。Docker 引入了一种标准化的方式来构建、分发和运行容器。在底层,Docker 使用 containerd 作为其容器运行时——这是一个行业标准,其他工具如 Kubernetes 也使用它。
运行容器很简单。例如,要在容器中运行 Python 解释器,我们使用 docker run(-it 标志使容器与终端交互。当您退出时,容器会停止。)
$ docker run -it python:3.12 python
Python 3.12.7 (main, Nov 5 2024, 02:53:25) [GCC 12.2.0] on linux >>> print("Hello from inside a container!")
Hello from inside a container!
在实践中,您的程序可能依赖于整个文件系统。为了克服这个问题,我们可以使用将应用程序的整个文件系统作为工件发送的容器镜像。容器镜像是通过编程创建的。使用 Docker,我们使用 Dockerfile 语法精确指定镜像的依赖项、系统库和配置:
FROM python:3.12
RUN apt-get update
RUN apt-get install -y gcc
RUN apt-get install -y libpq-dev
RUN pip install numpy
RUN pip install pandas
COPY . /app
WORKDIR /app
RUN pip install .
一个重要的区别是:Docker 镜像是打包的工件(如模板),而容器是该镜像的运行实例。您可以从同一个镜像运行多个容器。镜像是在层中构建的,其中 Dockerfile 中的每个指令(FROM、RUN、COPY 等)都会创建一个新的层。Docker 缓存这些层,因此如果您更改 Dockerfile 中的某一行,只需重建该层及其后续层。
之前的 Dockerfile 存在几个问题:它使用的是完整的 Python 镜像而不是精简版本,运行了单独的 RUN 命令创建了不必要的层,版本没有固定,并且没有清理包管理器的缓存,发送了不必要的文件。其他常见的错误包括以 root 身份不安全地运行容器以及意外地将机密嵌入层中。
这里有一个改进版本
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock ./
RUN uv pip install --system -r uv.lock
COPY . /app
在前面的例子中,我们看到我们不是从源安装 uv,而是从 ghcr.io/astral-sh/uv:latest 镜像中复制预构建的二进制文件。这被称为构建者模式。使用这种模式,我们不需要发送编译我们代码所需的所有工具,只需发送运行应用程序所需的最终二进制文件(在这种情况下是 uv)。
Docker 有一些重要的限制需要注意。首先,容器镜像通常是平台特定的——为linux/amd64构建的镜像在没有仿真的情况下无法在linux/arm64(苹果硅 Mac)上原生运行,仿真速度较慢。其次,Docker 容器需要一个 Linux 内核,因此在 macOS 和 Windows 上,Docker 实际上在底层运行一个轻量级的 Linux 虚拟机,这增加了开销。第三,Docker 的隔离性不如虚拟机——容器共享宿主内核,这在多租户环境中是一个安全问题。
这些天,更多的项目也在使用 nix 来管理甚至“系统级”的库和应用程序,通过nix flakes按项目进行。
配置
软件本质上是可配置的。在命令行环境讲座中,我们看到了程序通过标志、环境变量或甚至配置文件(即点文件)接收选项。这一点对于更复杂的应用程序也适用,并且存在用于大规模管理配置的既定模式。软件配置不应嵌入到代码中,而应在运行时提供。其中一些常见的包括环境变量和配置文件。
这里有一个通过环境变量进行配置的应用程序的示例:
import os
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///local.db")
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
API_KEY = os.environ["API_KEY"] # Required - will raise if not set
应用程序也可以通过配置文件进行配置(例如,一个通过yaml.load加载配置的 Python 程序),config.yaml:
database:
url: "postgresql://localhost/myapp"
pool_size: 5
server:
host: "0.0.0.0"
port: 8080
debug: false
考虑配置的一个好右手法则是,相同的代码库应该可以通过仅更改配置来部署到不同的环境(开发、测试、生产),而不需要更改代码。
在许多配置选项中,经常会有敏感数据,如 API 密钥。密钥需要小心处理,以避免意外泄露,并且不应包含在版本控制中。
服务与编排
现代应用程序很少孤立存在。一个典型的 Web 应用程序可能需要一个数据库用于持久存储,一个缓存用于性能,一个消息队列用于后台任务,以及各种其他支持服务。而不是将所有内容捆绑成一个单一的大型应用程序,现代架构通常将功能分解成独立的服务,这些服务可以独立开发、部署和扩展。
例如,如果我们确定我们的应用程序可能从使用缓存中受益,而不是自己实现,我们可以利用现有的经过实战检验的解决方案,如Redis或Memcached。我们可以通过将其作为容器的一部分来构建,将 Redis 嵌入到我们的应用程序依赖中,但这意味着需要协调 Redis 和我们的应用程序之间的所有依赖关系,这可能具有挑战性,甚至不可行。相反,我们可以将每个应用程序分别部署在其自己的容器中。这通常被称为微服务架构,其中每个组件作为一个独立的服务运行,通过网络进行通信,通常是通过 HTTP API。
Docker Compose是一个用于定义和运行多容器应用程序的工具。而不是单独管理容器,你可以在单个 YAML 文件中声明所有服务,并将它们一起编排。现在我们的完整应用程序包含多个容器:
# docker-compose.yml
services:
web:
build: .
ports:
- "8080:8080"
environment:
- REDIS_URL=redis://cache:6379
depends_on:
- cache
cache:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
redis_data:
使用docker compose up,两个服务同时启动,并且 Web 应用程序可以使用主机名cache(Docker 的内部 DNS 自动解析服务名称)连接到 Redis。Docker Compose 让我们声明我们想要如何部署一个或多个服务,并处理它们的编排,包括一起启动它们,设置它们之间的网络,以及管理用于数据持久性的共享卷。
对于生产部署,你通常希望你的 docker compose 服务在启动时自动启动,并在失败时重启。一种常见的方法是使用 systemd 来管理 docker compose 部署:
# /etc/systemd/system/myapp.service [Unit]
Description=My Application
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.target
这个 systemd 单元文件确保在系统启动时(在 Docker 准备好之后)启动应用程序,并提供标准控制,如systemctl start myapp、systemctl stop myapp和systemctl status myapp。
随着部署需求变得更加复杂——需要跨多台机器的可伸缩性、服务崩溃时的容错性和高可用性保证——组织转向复杂的容器编排平台,如 Kubernetes(k8s),它可以管理跨机器集群的数千个容器。尽管如此,Kubernetes 的学习曲线陡峭,运营成本高,因此对于较小的项目来说通常过度设计。
这种多容器设置部分可行,因为现代服务通过标准化的 API 进行通信,例如 HTTP REST API。例如,每当一个程序与 LLM 提供商(如 OpenAI 或 Anthropic)交互时,在底层它正在向他们的服务器发送 HTTP 请求并解析响应:
$ curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \ -H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model": "claude-sonnet-4-20250514", "max_tokens": 256,
"messages": [{"role": "user", "content": "Explain containers vs VMs in one sentence."}]}'
发布
一旦你证明了你的代码可以工作,你可能会对将其分发给其他人下载和安装感兴趣。分发有多种形式,并且本质上与你在其中操作的编程语言和环境紧密相关。
最简单的分发形式是将工件上传供人们下载和本地安装。这仍然很常见,您可以在像 Ubuntu 的软件包存档 这样的地方找到它,它本质上是一个 .deb 文件的 HTTP 目录列表。
现在,GitHub 已经成为发布源代码和工件的事实平台。虽然源代码通常是公开可用的,但 GitHub 发布允许维护者将预构建的二进制文件和其他工件附加到标记版本。
软件包管理器有时支持直接从 GitHub 安装,无论是从源安装还是从预构建的 wheel 安装:
# Install from source (will clone and build)
$ pip install git+https://github.com/psf/requests.git
# Install from a specific tag/branch
$ pip install git+https://github.com/psf/requests.git@v2.32.3
# Install a wheel directly from a GitHub release
$ pip install https://github.com/user/repo/releases/download/v1.0/package-1.0-py3-none-any.whl
事实上,一些像 Go 这样的语言使用去中心化的分发模型——而不是中央软件包仓库,Go 模块直接从它们的源代码仓库分发。模块路径如 github.com/gorilla/mux 指示代码所在的位置,而 go get 直接从那里获取。然而,大多数软件包管理器如 pip、cargo 或 brew 都有预打包项目的中央索引,以简化分发和安装。如果我们运行
$ uv pip install requests --verbose --no-cache 2>&1 | grep -F '.whl'
DEBUG Selecting: requests==2.32.5 [compatible] (requests-2.32.5-py3-none-any.whl)
DEBUG No cache entry for: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl.metadata
DEBUG No cache entry for: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
我们可以看到我们从哪里获取 requests wheel。注意文件名中的 py3-none-any — 这意味着该 wheel 与任何 Python 3 版本兼容,在任何操作系统和任何架构上。对于包含编译代码的软件包,wheel 是平台特定的:
$ uv pip install numpy --verbose --no-cache 2>&1 | grep -F '.whl'
DEBUG Selecting: numpy==2.2.1 [compatible] (numpy-2.2.1-cp312-cp312-macosx_14_0_arm64.whl)
这里 cp312-cp312-macosx_14_0_arm64 表示这个 wheel 专门用于 macOS 14+ 上的 CPython 3.12,适用于 ARM64(苹果硅)。如果您使用的是不同的平台,pip 将下载不同的 wheel 或从源构建。
相反,为了让人们能够找到我们创建的软件包,我们需要将其发布到这些注册表中。在 Python 中,主要注册表是 Python 软件包索引 (PyPI)。与安装一样,发布软件包有多种方式。uv publish 命令为上传软件包到 PyPI 提供了一个现代界面:
$ uv publish --publish-url https://test.pypi.org/legacy/
Publishing greeting-0.1.0.tar.gz
Publishing greeting-0.1.0-py3-none-any.whl
这里我们使用 TestPyPI — 一个用于测试发布工作流程而不污染真实 PyPI 的独立软件包注册表。一旦上传,您就可以从 TestPyPI 安装:
$ uv pip install --index-url https://test.pypi.org/simple/ greeting
发布软件时的一个关键考虑因素是信任。用户如何验证他们下载的软件包确实来自您,并且没有被篡改?软件包注册表使用校验和来验证完整性,并且一些生态系统支持软件包签名,以提供作者身份的加密证明。
不同的语言有自己的软件包注册表:crates.io 用于 Rust,npm 用于 JavaScript,RubyGems 用于 Ruby,以及 Docker Hub 用于容器镜像。同时,对于私有或内部软件包,组织通常部署自己的软件包仓库(如私有 PyPI 服务器或私有 Docker 注册表)或使用云提供商的管理解决方案。
将网络服务部署到互联网涉及额外的基础设施:域名注册、DNS 配置以将您的域名指向您的服务器,以及通常需要一个反向代理如 nginx 来处理 HTTPS 和路由流量。对于像文档或静态站点这样的简单用例,GitHub Pages 提供了从存储库直接提供的免费托管服务。
练习
-
使用
printenv将您的环境保存到文件中,创建一个 venv,激活它,然后将printenv输出到另一个文件,并执行diff before.txt after.txt。环境中有何变化?为什么 shell 更喜欢 venv?(提示:查看激活前后的$PATH。)运行which deactivate并思考 deactivate bash 函数正在做什么。 -
创建一个带有
pyproject.toml的 Python 包,并在虚拟环境中安装它。创建一个 lockfile 并检查它。 -
安装 Docker 并使用它通过 docker compose 在本地构建 Missing Semester 课程网站。
-
为一个简单的 Python 应用程序编写 Dockerfile。然后编写一个
docker-compose.yml文件,以便在 Redis 缓存旁边运行您的应用程序。 -
将 Python 包发布到 TestPyPI(除非值得分享,否则不要发布到真正的 PyPI!)。然后使用该包构建 Docker 镜像并将其推送到
ghcr.io。 -
使用 GitHub Pages 创建一个网站。额外(非)学分:配置自定义域名。
本文档遵循CC BY-NC-SA许可协议。
代理编码
编码代理是具有访问诸如读取/写入文件、网络搜索和调用 shell 命令等工具的对话式 AI 模型。它们存在于 IDE 中或独立的命令行或 GUI 工具中。编码代理是高度自主且功能强大的工具,能够实现各种用例。
本讲座建立在 开发环境和工具 讲座中的人工智能驱动开发材料之上。作为一个快速演示,让我们继续使用 人工智能驱动开发 部分中的示例:
from urllib.request import urlopen
def download_contents(url: str) -> str:
with urlopen(url) as response:
return response.read().decode('utf-8')
def extract(content: str) -> list[str]:
import re
pattern = r'\[.*?\]\((.*?)\)'
return re.findall(pattern, content)
print(extract(download_contents("https://raw.githubusercontent.com/missing-semester/missing-semester/refs/heads/master/_2026/development-environment.md")))
我们可以尝试用以下任务提示编码代理:
Turn this into a proper command-line program, with argparse for argument parsing. Add type annotations, and make sure the program passes type checking.
代理将读取文件以理解它,然后进行一些编辑,最后调用类型检查器以确保类型注解正确。如果它犯了导致类型检查失败的错误,它可能会迭代,尽管这是一个简单的任务,所以这种情况不太可能发生。由于编码代理可以访问可能有害的工具,因此默认情况下,代理 harness 会提示用户确认工具调用。
如果编码代理犯了错误——例如,如果你在
$PATH上有可用的mypy二进制文件,但代理尝试调用python -m mypy——你可以提供文本反馈来帮助它纠正方向。
编码代理支持多轮交互,因此你可以通过与代理的来回对话迭代工作。你甚至可以打断代理,如果它走错了方向。一个有用的思维模型可能是一个实习生的经理:实习生会做琐碎的工作,但需要指导,偶尔会做错事并需要纠正。
为了更直观的演示,尝试让代理作为后续操作运行生成的脚本。观察输出,并尝试让它做出一些更改(例如,要求它只包含绝对 URL)。
AI 模型和代理是如何工作的
完全解释现代大型语言模型(LLMs)和代理 harness 等基础设施的内部工作原理超出了本课程的范畴。然而,对一些关键概念有高层次的理解对于有效地使用这项尖端技术并理解其局限性是有帮助的。
LLMs 可以被视为对给定提示字符串(输入)的完成字符串(输出)的概率分布进行建模。LLM 推理(例如,当你向对话聊天应用提供查询时发生的情况)采样从这个概率分布中。LLMs 有一个固定的上下文窗口,即输入和输出字符串的最大长度。
诸如对话聊天和编码代理等 AI 工具建立在这样的基础之上。对于多轮交互,聊天应用和代理使用轮次标记,并在每次有新用户提示时提供整个对话历史作为提示字符串,每次用户提示都会调用 LLM 推理一次。对于工具调用代理,工具调用程序将某些 LLM 输出解释为调用工具的请求,并将工具调用的结果作为提示字符串的一部分返回给模型(因此每次有工具调用/响应时都会再次运行 LLM 推理)。工具调用代理的核心概念可以用 200 行代码实现。
隐私
大多数 AI 编码工具在其标准配置中会将大量数据发送到云端。有时工具调用程序在本地运行,而 LLM 推理在云端运行,有时甚至更多的软件在云端运行(例如,服务提供商可能会有效地获得你整个存储库的副本以及你与 AI 工具的所有交互)。
有开源的 AI 编码工具和开源的 LLM,它们相当不错(尽管不如专有模型),但就目前而言,由于硬件限制,对于大多数用户来说,在本地运行最新的开放 LLM 是不切实际的。
用例
编码代理可以用于各种任务。以下是一些例子:
-
实现新功能。正如上面的例子所示,你可以要求编码代理实现一个功能。在这个阶段,给出一个好的规范更多的是一种艺术而非科学;你希望提供给代理的输入足够描述性,以便代理能够做你希望它做的事情(至少是朝着正确的方向,这样你可以迭代),但不要过于描述,以至于你自己在做太多工作。测试驱动开发可以特别有效:编写测试(或使用编码代理帮助你编写测试),审核它们以确保它们捕捉到你想要的内容,然后要求编码代理实现该功能。模型持续改进,因此你需要保持对模型能力的直觉更新。
我们使用 Claude Code 来实现这些 Tufte 风格的边注。实现方式。
-
修复错误。如果你有来自编译器、代码检查器、类型检查器或测试的错误,你可以要求你的代理进行修正,例如使用提示“修复 mypy 的问题”。当你可以将它们放入反馈循环时,编码模型特别有效,因此尽量设置好,以便模型可以直接运行失败的检查,这将允许它自主迭代。如果这不可行,你可以手动给模型提供反馈。
在 missing-semester 仓库的提交f552b55中,我们向 Claude Code 提示“审查代理编码讲座中的错别字和语法问题”,随后要求它修复发现的问题,这些问题已在f1e1c41中提交。
-
重构。您可以使用编码代理以各种方式重构代码,从简单的任务如重命名方法(这种重构也由代码智能支持)到更复杂的任务,如将功能拆分到单独的模块中。
我们使用 Claude Code 将代理编码拆分成了它自己的讲座。
-
代码审查。您可以要求编码代理审查代码。您可以提供基本指导,如“审查我尚未提交的最新更改”。如果您想审查一个 pull request,并且您的编码代理支持 web fetch,或者您安装了像GitHub CLI这样的命令行工具,您甚至可以要求编码代理“审查 pull request {链接}”,然后它会从那里处理。
-
代码理解。您可以向编码代理询问有关代码库的问题,这对于入职尤其有帮助。
-
作为 Shell。您可以要求编码代理使用特定工具来解决问题,因此您可以使用自然语言调用 shell 命令,例如“使用 find 命令查找所有 30 天前的文件”或“使用 mogrify 将所有 jpg 文件的大小调整为原始大小的 50%”。
-
Vibe 编码。代理足够强大,以至于您可以在不编写任何代码的情况下实现一些应用程序。
这是一个真实世界的项目示例,其中一位讲师使用了 vibe 编码。
高级代理
在这里,我们简要概述了一些更高级的使用模式和编码代理的能力。
-
可重用提示。创建可重用的提示或模板。例如,您可以编写一个详细的提示来以特定方式执行代码审查,并将其保存为可重用提示。
代理工具迅速发展。在某些工具中,作为独立功能的可重用提示已被弃用。例如,在 Codex 和 Claude Code 中,它们被包含在技能中。
-
并行代理。 编码代理可能会很慢:你可以提示代理,然后它可以花费数十分钟来解决问题。你可以同时运行多个代理副本,要么是执行相同的任务(LLMs 是随机的,所以多次运行相同的内容并选择最佳解决方案可能很有帮助)或者不同的任务(例如,同时实现两个不重叠的功能)。为了防止不同代理的更改相互干扰,你可以使用git worktrees,我们将在关于版本控制的讲座中介绍。
-
MCPs。 MCP 代表模型上下文协议,是一个开放协议,你可以用它来连接你的编码代理和工具。例如,这个Notion MCP 服务器可以让你的代理读取/写入 Notion 文档,实现“读取{Notion 文档}中链接的规范,在 Notion 中草拟一个新的实施计划页面,然后实现原型”这样的用例。为了发现 MCPs,你可以使用如Pulse和Glama这样的目录。
-
上下文管理。 正如我们上面提到的,支撑编码代理的 LLMs 有一个有限的上下文窗口。有效使用编码代理需要充分利用上下文。你想要确保代理能够访问到它所需的信息,但避免不必要的上下文,以避免上下文窗口溢出或降低模型的性能(即使上下文窗口没有溢出,随着上下文大小的增加,这种情况也往往会发生)。代理自动提供,并在一定程度上管理上下文,但大部分控制权留给了用户。
-
清除上下文窗口。 最基本的控制,编码代理支持清除上下文窗口(开始新的对话),你应该在不相关的查询时这样做。
-
回滚对话。 一些编码代理支持在对话历史中撤销步骤。在“撤销”操作更合理的情况下,而不是给出一个引导代理走向不同方向的后续消息,这更有效地管理了上下文。
-
压缩。 为了使对话长度不受限制,编码代理支持上下文的压缩:如果对话历史过长,它们会自动调用 LLM 来总结对话的前缀,并用总结替换对话历史。一些代理允许用户在需要时调用压缩。
-
llms.txt。
/llms.txt文件是一个为 LLMs 在推理时使用的文档的提议标准位置。产品(例如,cursor.com/llms.txt)、软件库(例如,ai.pydantic.dev/llms.txt)和 API(例如,apify.com/llms.txt)可能包含llms.txt文件,这些文件对开发很有用。这些文档每令牌的信息密度更高,因此它们比要求编码代理获取和读取 HTML 页面更具有上下文效率。当编码代理没有内置关于你试图使用的依赖项的知识时(例如,因为它是在 LLM 的知识截止日期之后发布的),外部文档就很有用。 -
AGENTS.md。 大多数编码代理支持 AGENTS.md 或类似的(例如,Claude Code 会查找
CLAUDE.md)作为编码代理的 README。当代理启动时,它会预先填充上下文,包含AGENTS.md的全部内容。你可以使用它来给出跨会话通用的建议(例如,指示它在进行代码更改后始终运行类型检查器,解释如何运行单元测试,或提供代理可以浏览的第三方文档链接)。一些编码代理可以自动生成此文件(例如,Claude Code 中的/init命令)。有关AGENTS.md的实际示例,请参阅这里。 -
技能。
AGENTS.md中的内容始终完整地加载到代理的上下文窗口中。技能 添加了一层间接性以避免上下文膨胀:你可以向代理提供技能列表及其描述,代理可以根据需要“打开”技能(将其加载到其上下文窗口中)。 -
子代理。 一些编码代理允许你定义子代理,这些子代理是针对特定工作流程的代理。顶级编码代理可以调用子代理来完成特定任务,这使顶级代理和子代理能够更有效地管理上下文。顶级代理的上下文不会被子代理看到的所有内容所膨胀,子代理可以仅获取其任务所需的上下文。例如,一些编码代理将网络研究作为一个子代理实现:顶级代理将查询提交给子代理,子代理将运行网络搜索,检索单个网页,分析它们,并将查询的答案提供给顶级代理。这样,顶级代理的上下文不会被所有检索到的网页的完整内容所膨胀,子代理的上下文中也不会包含顶级代理的其他对话历史。
-
对于许多需要编写提示(例如技能或子代理)的高级功能,你可以使用大型语言模型(LLMs)来开始。一些编码代理甚至内置了对这一功能的支持。例如,Claude Code 可以从简短的提示(调用 /agents 并创建新代理)中生成子代理。尝试使用以下提示创建子代理:
A Python code checking agent that uses `mypy` and `ruff` to type-check, lint, and format *check* any files that have been modified from the last git commit.
然后,你可以使用顶级代理通过消息如“使用代码检查子代理”显式调用子代理。你也可能能够让顶级代理在适当的时候自动调用子代理,例如在修改任何 Python 文件之后。
注意事项
人工智能工具可能会出错。它们建立在大型语言模型(LLMs)之上,这些模型只是概率性的下一个标记预测模型。它们在“智能”方面并不像人类。检查人工智能输出的正确性和安全漏洞。有时验证代码可能比亲自编写代码更困难;对于关键代码,考虑手动编写。人工智能可能会陷入死胡同并试图让你感到困惑;注意调试螺旋。不要将人工智能作为拐杖,警惕过度依赖或对人工智能有浅薄的理解。仍然有一类巨大的编程任务,人工智能仍然无法完成。计算思维仍然有价值。
推荐软件
许多集成开发环境(IDEs)/人工智能编码扩展包括编码代理(参见 开发环境讲座 的推荐)。其他流行的编码代理包括 Anthropic 的 Claude Code、OpenAI 的 Codex 以及开源代理 opencode。
练习
-
通过四次执行相同的编程任务来比较手动编码、使用人工智能自动完成、内联聊天和代理的编码体验。最佳候选者是从你正在工作的项目中提取的小型功能。如果你在寻找其他想法,可以考虑在 GitHub 上的开源项目中完成“良好入门问题”风格的任务,或者 Advent of Code 或 LeetCode 问题。
-
使用人工智能编码代理来导航一个不熟悉的代码库。这最好是在想要调试或为真正关心的项目添加新功能的情况下进行。如果你想不出任何项目,可以尝试使用人工智能代理来了解在 opencode 代理中安全相关功能是如何工作的。
-
从头开始编写一个小型应用程序的代码。不要手动编写任何一行代码。
-
为你选择的编码代理,创建并测试一个
AGENTS.md(或适用于你选择的代理的类似文件,例如CLAUDE.md),一个技能(例如,Claude Code 中的技能 或 Codex 中的技能),以及一个子代理(例如,Claude Code 中的子代理)。考虑一下在什么情况下你会想要使用其中一个而不是另一个。请注意,你选择的编码代理可能不支持这些功能中的一些;你可以选择跳过它们,或者尝试一个具有这些支持的不同的编码代理。 -
使用编码代理来完成与 Code Quality 讲座 中的 Markdown 列表点正则表达式练习相同的目标。它是通过直接文件编辑来完成任务的吗?直接编辑文件以完成此类任务的代理有哪些缺点和限制?找出如何提示代理,使其不通过直接文件编辑来完成任务。提示:要求代理使用 第一讲 中提到的命令行工具之一。
-
大多数编码代理支持一种“yolo 模式”(例如,在 Claude Code 中,
--dangerously-skip-permissions)。直接使用此模式是不安全的,但将编码代理在虚拟机或容器等隔离环境中运行并启用自主操作可能是可接受的。在你的机器上运行此设置。例如,Claude Code devcontainers 或 Docker Sandboxes / Claude Code 的文档可能会有所帮助。设置方法不止一种。
本文档受 CC BY-NC-SA 许可。
代码之外
成为一个优秀的软件工程师并不仅仅是写出能工作的代码。它还意味着写出其他人(包括未来的你)能够理解、维护和在此基础上构建的代码。这关乎清晰沟通、深思熟虑的贡献,以及在参与的生态系统中成为一个好公民——无论是开源还是专有。
单向沟通
软件工程的大部分工作涉及为缺乏你当前背景的人写作:后来加入的队友、继承你代码的维护者,或者六个月后的你自己,当你已经忘记了为什么做出某个特定选择。对所有这类写作的关键建议是,你的目标是捕捉和传达为什么,而不仅仅是是什么。是什么往往是自我解释的,而为什么则是经过努力获得的知识,很容易随着时间的流逝而丢失。
除了代码本身之外,工程师之间最常见的沟通形式可能是代码注释。我个人发现,许多代码注释都是无用的。但它们不必是无用的!好的注释解释了代码本身无法解释的事情:为什么以某种特定方式做某事,而不是如何工作(这是代码所展示的)。它们可以节省数小时的困惑,而差的注释只会增加噪音,甚至更糟。
几乎总是有价值的注释类型:
-
待办事项:标记不完整或不完善的代码,但留下足够的信息,以便其他人理解未完成的工作及其延迟的原因。“待办事项:优化”是无用的;“待办事项:这个 O(n²)循环对于 n<100 是可行的,但如果我们扩展,将需要索引”是可执行的。
-
参考文献:当代码实现来自论文的算法、改编自其他地方的代码或编码文档中指定的行为时,链接到外部来源。使用永久链接。注意任何与参考文献的差异。
-
正确性论证:解释非平凡代码产生正确结果的原因。代码展示了步骤;注释解释了这些步骤为什么有效。
-
苦学到的教训:如果你花了 30 多分钟调试某个问题,而修复是一个非显而易见的咒语,请记录下来。你过去的自己没有意识到这是必要的;未来的读者也不会。
-
常数的理由:魔法数字需要解释。为什么是 1492?为什么是 16 位?它是随机选择的、来自测试还是为了正确性而需要的?即使是“任意选择”也是有用的信息。
-
承重选择:如果正确性依赖于看似无害的实现细节(例如,“必须是一个 BTreeSet,因为迭代顺序很重要”),则明确指出这一点。
-
“为什么不”:当你故意避免明显的方法时,解释原因。否则,有人会在以后“修复”它并弄糟事情。
READMEs(你有一个,对吧?)也是与其他开发者常见的第一个接触点。一个好的 README 可以立即回答四个问题:这是做什么的?我为什么要关心?我该如何使用它?我该如何安装它?按照这个顺序。结构化它就像一个漏斗:顶部有一行,可能还有视觉演示,这样人们可以在几秒钟内决定这能否解决他们的问题,然后逐步增加深度。在安装之前展示用法——人们希望在投入设置步骤之前看到他们能得到什么。
提交信息是另一种常被忽视的“为他人写作”的方式。它们通常被写成“修复了什么”或“添加了什么”,虽然在某些情况下这可能足够,但很容易忘记它们构成了代码库如何演变的历史记录。当有人(包括你!)运行git blame试图理解一个令人困惑的更改时,好的提交信息应该给他们提供答案。
通常,正文应该回答:
-
什么问题迫使这种改变?
-
你考虑了哪些替代方案?
-
有哪些权衡或影响?
-
这种方法可能令人惊讶的是什么?
显然,你应该根据复杂度调整细节。一行错误的修复只需要一个主题。需要花费数小时调试的微妙竞态条件修复值得用段落来解释问题和解决方案。
对于复杂更改,遵循问题→解决方案→影响的结构可能很有用:从强制函数或限制开始,然后解释发生了什么以及关键的设计决策,然后列出值得注意的后果(正面和负面)。最后一部分尤其重要;真正的工程涉及权衡关注点,记录权衡是故意的可以防止未来的开发者认为你错过了问题。
LLMs可以在编写提交信息时有所帮助。然而,如果你只是将 LLM 指向你的更改并要求它为该更改编写提交信息,LLM 将只能访问什么,而不是为什么。因此,生成的提交信息将主要描述性(这与我们想要的相反!)。如果你最初使用 LLM 来帮助你进行更改,那么在同一个会话中要求 LLM 编写提交信息可以是一个更好的选择,因为与 LLM 的对话本身就是关于更改的丰富上下文的来源!否则,或者作为补充,一个有用的技巧是具体告诉 LLM 你想要一个专注于“为什么”(以及上述其他细微差别)的提交信息,然后告诉它查询你缺失的上下文。本质上,你正在扮演一个 MCP“工具”的角色,编码代理可以使用它来“读取”上下文。
随着你的更改变得更加复杂,确保也要逻辑地拆分提交(git add -p是你的朋友)。每个提交应代表一个可以独立理解和审查的连贯更改。不要将重构与新的功能混合,也不要将不相关的错误修复组合在一起,因为这会模糊更改解决了什么问题的故事,并且几乎肯定会减慢最终审查你的更改的速度。这也通过git bisect赋予你超级能力,但这将是另一个故事。
当你开始更加勤奋地进行技术写作,并更广泛地使用它时,请注意尊重读者。一旦开始,很容易就过度解释,但你必须抵制这种冲动,以免读者阅读你写的任何内容。解释“为什么”,并相信他们能够为自己的情况找出“如何”。
协作
作为工程师,我们可能在工作的大部分时间里都在自己的键盘上编码,但我们相当一部分时间也被用于与他人沟通。这部分时间通常分为协作和教育,而投资于提高这两方面的能力所带来的回报是显著的。
贡献
无论你是提交错误报告、贡献简单的错误修复,还是实现一个巨大的功能,都值得记住,通常用户数量比贡献者多几个数量级,贡献者数量又比维护者多一个数量级。因此,维护者的时间非常紧张。如果你想提高你的贡献被用于生产的机会,你必须确保你的贡献具有高信号与噪声比,并且值得维护者花费时间。
例如,一个好的错误报告会尊重维护者的时间,提供理解并重现问题的所有必要信息:
-
环境:操作系统、版本号、相关配置
-
你期望的与实际发生的
-
重现步骤:要具体。“点击按钮”不如“以管理员身份登录时,在/settings 页面点击提交按钮”有用。
-
你已经尝试过的:这可以防止重复建议,并显示你已经进行了一些调查
如果你发现一个安全漏洞,不要公开发布。首先私下联系维护者,并在披露之前给他们合理的时间来修复它。许多项目都有一个用于此目的的 SECURITY.md 文件或类似文件。
确保你搜索现有的问题。你的错误或功能请求可能已经被报告,而且添加信息到现有讨论比创建重复内容要好得多。更不用说,这还能减少维护者的噪音。
如果你能想出一个最小可复现示例,那将是宝贵的。它们可以节省维护者大量的时间和精力,并且可靠地复现错误往往是修复它的最困难的部分。不用说,你投入的精力来隔离问题也常常帮助你更好地理解它,有时甚至能让你自己找到解决方案。
如果你没有立即得到回复,请记住,维护者通常是志愿者,时间有限。如果你在等待他们的回复,几周后礼貌地跟进是可以的;每天打扰不是。同样,“我也是”评论,或者只是复制粘贴一些终端输出的错误报告通常对你的问题获得关注是负面的。
如果你打算做出代码贡献,你还需要熟悉贡献指南。许多项目都有CONTRIBUTING.md——遵循它。通常,你也会想从小处着手;一个拼写错误修复或文档改进是一个很好的首次贡献,因为它可以帮助你学习项目流程,而无需在内容上过多来回沟通。
检查项目使用的许可证,因为任何你贡献的代码都将受到同一许可证的约束。特别是,要注意那些要求衍生作品也必须是开源的 copyleft 许可证(如 GPL),如果你涉及到它,可能会对你的雇主产生影响。选择许可证网站有更多有用的信息。
当你决定打开一个拉取请求(“PR”)时,首先确保你隔离了你实际上想要被接受的变化。如果你的 PR 同时改变了许多其他无关的事情,那么审阅者很可能会让你将其退回,要求你清理。这类似于你应该如何将你的 git 提交分解成语义相关的块。
在某些情况下,如果你有很多看似不相关的变更,但它们都是启用一个功能所必需的,那么可能可以打开一个包含所有变更的大型 PR。然而,在这种情况下,提交卫生尤其重要,以便维护者有选择性地按“提交”审查变更。
接下来,确保你很好地解释了变更背后的“为什么”。不要只是描述“什么”发生了变化——解释为什么需要这种变化,以及为什么这是解决问题的关键方法。如果你有任何需要特别关注的部分,也应主动指出。根据CONTRIBUTING.md和你的变更性质,审阅者可能还期望看到额外的信息,比如你做出的权衡或如何测试变更。
我们建议向上游项目贡献代码,而不是“分支”项目,至少作为首选方法。在许可允许的情况下,分支应该保留用于当你想要做出的贡献超出了原始项目的范围时。如果你确实进行了分支,请确保你承认了原始项目!
人工智能可以让你快速生成看起来合理的代码和 PR,但这并不能免除你理解你所贡献的内容。提交你无法解释的人工智能生成的代码,会给维护者带来审查和可能维护代码的负担,即使其作者也不理解。使用人工智能来帮助你识别问题和生成修复/功能是可以的,只要你仍然进行尽职调查,将其打磨成有价值的贡献,而不是将这项工作转交给(已经负担过重的)维护者。
记住,对于维护者来说,接受一个 PR 意味着接受长期的责任。他们将在贡献者离开后很久还要维护这段代码,因此可能会拒绝那些虽然好意但不符合项目方向、增加他们不想维护的复杂性或需求没有得到充分记录的更改。作为贡献者,你有责任说明为什么接受贡献是值得维护负担的。
当收到 PR 的反馈时,记住你的代码不是你本人!审阅者试图让代码变得更好,而不是批评你个人。如果你不同意,请提出澄清问题——你可能会学到一些东西,或者他们可能会。
审查
你可能认为代码审查是资深开发者做的事情,但你可能会比你预期的更早被要求审查代码,你的观点是有价值的。新视角可以发现经验丰富的开发者忽略的事情,而来自对代码不太熟悉的人的问题常常会揭示应该记录或简化的假设。
审查也是最快的学习方式之一。你会看到其他人如何解决问题,学习模式和惯用语,并培养对使代码可读性的直觉。除了个人成长之外,审查可以在代码进入生产之前捕捉到错误,在整个团队中传播知识,并通过协作提高代码质量。它们不仅仅是官僚主义的额外开销。
良好的代码审查是一项需要长期磨练的技能,但有一些技巧可以使它们更快地变得更好:
-
审查代码,而不是审查人:“这个函数让人困惑”与“你写了一段让人困惑的代码。”
-
优先选择可操作的评论:“你能将这些全局变量替换为配置数据类吗?”比“这里不要使用全局变量”更容易处理。
-
提出问题而不是提出要求:“如果这里 X 为空会发生什么?”比“处理空值情况”更能引发讨论。
-
解释“为什么”:“考虑在这里使用一个常量”不如“考虑在这里使用一个常量,这样我们可以根据环境轻松调整超时时间。”
-
区分阻塞问题和建议:明确指出必须改变的内容与仅仅是个人偏好的内容。
-
认可好的方面:指出巧妙的解决方案或干净的实现方式可以鼓励作者,并帮助他们知道什么该继续做。
-
知道何时停止:贡献者只有有限的时间和耐心,而且并不总是最好将它们花在处理所有细节上。关注大事,并在事后考虑自己整理细节。
人工智能工具可以捕捉到某些问题,但它们不能替代人工审查。它们可能忽略上下文,不理解产品需求,并且可能会自信地提出错误建议。它们可以作为初步审查的有用工具,但不能替代深思熟虑的人工审查。
教育
作为工程师,我们的大部分非编码时间都花在提问或回答问题上,可能是两者的混合;在协作中,与同事对话,或者在学习过程中。提出好的问题是让你从任何人那里学习,而不仅仅是完美解释者的技能。Julia Evans 有一些关于“如何提出好问题”和“如何得到对你问题的有用回答”的优秀博客文章,值得一读。
一些特别有价值的建议包括:
-
首先阐述你的理解:说出你认为你知道的内容,并问“这是正确的吗?”这有助于回答者识别你的实际知识差距。
-
提出是/否问题:“X 是否正确?”可以防止旁征博引的解释,并且通常能促使有用的阐述。
-
要具体明确:“SQL 连接是如何工作的?”这个问题太模糊了。“LEFT JOIN 是否包括右表中没有匹配的行?”这样的问题是可以回答的。
-
承认你不懂:打断并询问不熟悉的术语。这反映的是自信,而不是弱点。同样,如果他们问你你不知道答案的问题,最好说“我不知道”,并可能接着说“但我认为……”或者甚至“但我可以查一下”。
-
不要接受不完整的答案:继续追问,直到你真正理解。
-
先做一些研究:基本调查有助于你提出更有针对性的问题(尽管同事间的随意问题是可以的)。
记住:精心设计的问题对整个社区都有益。它们揭示了其他人也需要理解的一些隐藏假设。
注意,这条建议在与大型语言模型(LLMs)交流时同样适用!
人工智能礼仪
随着大型语言模型(LLMs)和人工智能在软件工程中的日益普及,围绕它们的社交和专业规范仍在变化。我们已经在代理编码讲座中讨论了许多战术性考虑,但它们的使用也有一些“更软性”的部分值得讨论。
这些规范中的第一条是,当人工智能对你的工作有实质性贡献时,披露这一点。这并不是关于羞耻——这是关于诚实、设定适当的期望并确保最终的工作得到适当的审查。还值得披露你使用人工智能的哪些部分——“整个东西都是用 vibe 编写的”和“我写了这个备份工具并使用 LLM 来格式化前端”之间有一个有意义的区别。例如,我们使用 LLM 来帮助编写一些这些讲义,包括校对、头脑风暴和生成代码片段和练习的初稿。
你还应该遵循你正在贡献的团队和项目的规范。一些团队在人工智能的使用方面比其他团队有更严格的政策(例如,出于合规或数据驻留的原因),你不想无意中违反这些规定。公开你的使用情况有助于防止可能代价高昂的错误。
如果你旨在在工作中学习,请记住,如果你让人工智能为你完成所有或大部分工作,可能会适得其反;你可能会更多地了解提示(也许还会审查 AI 输出),而不是任务本身。特别是当你学习时,重点可能是过程,而不是目的地,所以使用 AI 来“快速得到解决方案”是一个反目标。
在面试和其他评估情况下,会出现一个相关的问题。这些通常是为了特别评估你的技能和能力,而不是 LLM 的。现在越来越多的公司允许你在面试中使用 LLM 和其他 AI 辅助工具,只要你让他们观察这些交互作为面试的一部分(即,他们也在评估你使用这些工具的技能!),但这些仍然属于少数。如果你不确定人工智能辅助是否适用于特定任务,请询问!
应该不言而喻,如果评估情况明确要求不使用外部工具、LLM 等,你不应该使用它们。试图在不被发现的情况下秘密地这样做将会让你自食其果。
练习
-
浏览知名项目的源代码(例如,Redis 或 curl)。找到讲座中提到的某些注释类型的示例:一个有用的 TODO,对外部文档的引用,一个解释避免方法的“为什么不”注释,或者一个艰难学到的教训。如果那个注释不存在,会失去什么?
-
选择一个你感兴趣的开源项目,并查看其最近的提交历史(
git log)。找到一个有良好信息的提交,解释了为什么要进行更改,以及一个只有描述什么更改的弱信息提交。对于弱信息提交,查看 diff (git show <hash>) 并尝试根据问题 → 解决方案 → 后果的结构编写一个更好的提交信息。注意在事后重新组装必要上下文所需的工作量有多大! -
比较三个拥有 1000+星标的 GitHub 项目的 README 文件。它们是否都同样有用?寻找对你来说主要是噪音的东西,作为你未来自己编写的 README 的教训。
-
在你使用的项目上找到一个开放的问题(如果有“good first issue”或“help wanted”标签,请检查)。根据讲座中的标准评估这个问题:它是否尊重维护者的时间,并包含调试它所需的所有信息,或者你预计维护者可能需要与提交者进行多轮问答才能找到根本问题?
-
想想你使用过的软件中遇到的一个 bug(或在问题跟踪器中找到一个)。练习创建一个最小可重复示例:移除与 bug 无关的所有内容,直到你得到一个仍然可以展示问题的最小案例。写下你移除的内容及其原因。
-
在你熟悉的项目中找到一个有实质性审查评论的合并拉取请求(不仅仅是“LGTM”)。阅读审查内容。所有评论是否同样富有成效?如果你是 PR 的作者,你会如何描述收到所有这些评论的经历?
-
前往 Stack Overflow,寻找你熟悉技术领域中的一个高票回答的问题。然后找到一个被关闭或被大量反对的问题。将它们与讲座中的建议进行比较;你能预测哪个问题会得到更好的答案吗?
本文档遵循CC BY-NC-SA许可协议。
代码质量
有各种工具和技术支持开发者编写高质量的代码。在本讲座中,我们将涵盖:
-
格式化
-
代码检查
-
测试
-
提交前钩子
-
持续集成
-
命令运行器
作为额外的话题,我们还将涵盖正则表达式,这是一个跨领域的主题,它在代码质量(例如,运行匹配模式的测试子集)以及其他领域(例如 IDEs,例如用于搜索和替换)中都有应用。
许多这些工具将是语言特定的(例如,Python 的Ruff代码检查器/格式化工具)。在某些情况下,工具将支持多种语言(例如,Prettier代码格式化工具)。然而,这些概念几乎是通用的——您可以为任何编程语言找到代码格式化工具、代码检查器、测试库等。
格式化
代码自动格式化工具会自动美化表面语法。这样,您可以专注于更深入和更具挑战性的问题,而自动格式化工具则处理诸如字符串的'与"语法的一致性、二元运算符周围的空格(例如x + y而不是x+y)、导入语句的排序顺序以及避免过长的行等日常细节。代码格式化工具的一个主要好处是它们可以标准化所有在代码库上工作的开发者的代码风格。
一些工具,如 Prettier,高度可配置;您应该将配置文件存入您项目的版本控制。其他工具,如Black和gofmt,具有有限的或没有可配置性,以减少无谓的争论。
您可以使用代码格式化工具设置 IDE 集成,这样您的代码在您输入时或保存文件时将自动格式化。您还可以将EditorConfig文件添加到您的项目中,该文件会向您的 IDE 传达某些项目级别的设置,例如每个文件类型的缩进大小。
代码检查
代码检查器执行静态分析(在不运行代码的情况下分析您的代码),以找到代码中的反模式和潜在问题。这些工具比自动格式化工具更深入,它们超越了表面语法。分析深度因工具而异。
代码检查器配备了规则列表,这些规则可以在项目级别上进行配置。一些代码检查规则会产生误报,因此您可以在每个文件或每行的基础上禁用它们。
良好的代码检查工具将内置帮助或文档,解释每个代码检查规则——该规则寻找什么,为什么它不好,以及代码模式的更好替代方案。例如,查看Ruff中SIM102规则的文档,该规则可以捕获 Python 代码中不必要的嵌套if语句。
一些代码检查工具不仅能标记问题,还能自动修复某些问题。
除了语言特定的代码检查工具外,另一个可能很有用的工具是semgrep,这是一个在抽象语法树(AST)级别(而不是像 grep 那样的字符级别)工作的“语义 grep”工具,支持许多语言。你可以使用 semgrep 轻松地为你的项目编写自定义的代码检查规则。例如,如果你想防止 Python 中的危险subprocess.Popen(..., shell=True),你可以用以下方式找到那个代码模式:
semgrep -l python -e "subprocess.Popen(..., shell=True, ...)"
测试
软件测试是提高你对代码正确性的信心的一种标准技术。你编写代码,然后编写测试代码来执行你编写的代码,并在代码不符合预期时引发错误。
你可以为不同粒度的代码块编写测试:单元测试用于单个函数,集成测试用于模块或服务之间的交互,以及功能测试用于端到端场景。你可以进行测试驱动开发,在你编写任何实现代码之前先编写测试。当你发现代码中的错误时,你可以编写回归测试,这样你就可以捕捉到功能在未来是否出现故障。你可以编写属性测试,这是在 Haskell 中的QuickCheck中首创的,并在许多库中实现,如 Python 中的Hypothesis。哪种测试方法正确取决于你的项目;很可能会采用一些组合。
如果你的程序有外部依赖,如数据库或 Web API,在测试中模拟这些依赖可能很有帮助,而不是让代码在测试时与第三方依赖交互。
代码覆盖率
代码覆盖率是一个衡量测试好坏的指标。代码覆盖率检查在测试运行时你的代码哪些行被执行,以确保你覆盖了所有代码路径。代码覆盖率工具可以逐行显示覆盖率,以指导你编写测试。例如,Codecov这样的服务提供了跟踪和查看项目历史代码覆盖率的网络界面。
像任何指标一样,代码覆盖率并不完美;不要过度依赖覆盖率,专注于编写高质量的测试。
预提交钩子
Git 预提交 钩子,通过 pre-commit 框架简化,在每次 Git 提交之前自动运行用户指定的代码。项目通常使用预提交钩子来运行格式化工具和代码检查器,有时还会运行测试,以确保提交的代码符合项目代码风格且无特定问题。
持续集成
持续集成(CI)服务,如 GitHub Actions,可以在您每次推送代码(或每次拉取请求,或按计划)时运行脚本。开发者通常使用 CI 服务来运行代码质量工具,包括格式化工具、代码检查器和测试。对于编译型语言,您可以确保代码可以编译;对于静态类型语言,您可以确保它通过类型检查。在每次推送新提交时运行 CI 可以捕获引入到主代码版本中的错误;在拉取请求上运行可以捕获贡献者提交的问题;按计划运行可以捕获外部依赖项的问题(例如,开发者意外发布了破坏性更改,但作为 semver 兼容的)。
由于 CI 脚本在开发机器之外单独运行,您可以在那里轻松运行长时间运行的任务。这可以用来,例如,在不同的操作系统和编程语言版本上运行 矩阵 测试,以确保软件在所有这些环境中都能正常工作。
通常,在持续集成(CI)中运行的脚本不会直接更改您的代码:它将以“仅检查”模式而不是“修复”模式运行工具,例如,自动格式化器会在代码不符合格式时引发错误。
存储库通常在 README 中包含 状态徽章,显示 CI 状态和其他信息,如代码覆盖率。例如,下面是 Missing Semester 的当前构建状态。
我们的 链接检查器,它使用 proof-html GitHub Action,经常失败,通常是由于第三方网站的问题。尽管如此,它帮助我们捕获并修复了许多损坏的链接(有时是由于拼写错误,大多数情况下是由于网站移动内容而没有添加重定向或网站消失)。
通过示例学习 CI 服务、格式化程序、代码检查器和测试库的细节是一个好方法。在 GitHub 上找到高质量的开放源代码项目——它们在编程语言、领域、大小和范围等方面越接近你的项目越好——并研究它们的pyproject.toml、.github/workflows/、DEVELOPMENT.md和其他相关文件。
持续部署
持续部署利用 CI 基础设施来实际部署更改。例如,Missing Semester 仓库使用持续部署到 GitHub pages,这样每当我们将更新后的讲义git push时,网站会自动构建和部署。你可以在 CI 中构建其他类型的工件,例如应用程序的二进制文件或服务的 Docker 镜像。
命令运行器
命令运行器如 just 简化了在项目上下文中运行命令的任务。随着你为项目建立代码质量基础设施,你不想让你的开发者记住像uv run ruff check --fix这样的命令。使用命令运行器,这可以变成just lint,并且你可以为所有可能为你的项目运行的开发者工具执行类似的调用,如just format、just typecheck等。
一些特定语言的项目或包管理器内置了对这种功能的支持,这意味着你不需要使用像just这样的语言无关工具。例如,npm(Node.js)的package.json中的scripts部分和Hatch(Python)的pyproject.toml中的tool.hatch.envs.*.scripts部分支持此功能。
正则表达式
正则表达式,通常缩写为“regex”,是一种用于表示字符串集合的语言。正则表达式模式常用于各种上下文中的模式匹配,例如命令行工具和 IDE。例如,ag 支持代码库范围内的正则表达式模式搜索(例如,ag "import .* as .*"将在 Python 中找到所有重命名的导入),而 go test 支持用于选择测试子集的-run [regexp]选项。此外,编程语言内置了对正则表达式匹配的支持或第三方库,因此你可以使用正则表达式进行功能,如模式匹配、验证和解析。
为了帮助建立直觉,以下是一些正则表达式模式的示例。在本讲座中,我们使用 Python 正则表达式语法。正则表达式有许多变体,它们之间略有差异,尤其是在更复杂的功能方面。你可以使用在线正则表达式测试器如 regex101 来开发和调试正则表达式。
-
abc— 匹配字面量abc。 -
missing|semester— 匹配字符串“missing”或字符串“semester”。 -
\d{4}-\d{2}-\d{2}— 匹配 YYYY-MM-DD 格式的日期,例如“2026-01-14”。除了确保字符串由四个数字、一个连字符、两个数字、一个连字符和两个数字组成外,这并不验证日期,所以“2026-01-99”也匹配这个正则表达式模式。 -
.+@.+— 匹配电子邮件地址,包含一些文本,然后是“@”,然后是更多文本。这仅进行最基本的验证,并匹配像“nonsense@@@email”这样的字符串。存在一个匹配电子邮件地址且没有假阳性或阴性的正则表达式存在,但不太实用。
正则表达式语法
你可以在这份文档(或许多其他在线资源)中找到一个关于正则表达式语法的全面指南。以下是一些基本构建块:
-
abc匹配字面字符串,当字符没有特殊意义时(例如,在这个例子中,abc) -
.匹配任何单个字符 -
[abc]匹配括号内包含的单个字符(在这个例子中,“a”、“b”或“c”) -
[^abc]匹配括号内不包含的单个字符(例如,“d”) -
[a-f]匹配括号内指定范围内的单个字符(例如,“c”,但不匹配“q”) -
a|b匹配任一模式(例如,“a”或“b”) -
\d匹配任何数字字符(例如,“3”) -
\w匹配任何单词字符(例如,“x”) -
\b匹配任何单词边界(例如,在字符串“missing semester”中,匹配在“m”之前,在“g”之后,在“s”之前和在“r”之后) -
(...)匹配模式的组 -
...?匹配零个或一个模式,例如words?匹配“word”或“words” -
...*匹配任意数量的模式,例如.*匹配任意数量的任意字符 -
...+匹配一个或多个模式,例如\d+匹配任何非零数字 -
...{N}匹配模式中的确切 N 个,例如\d{4}匹配 4 个数字 -
\.匹配字面“。” -
\\匹配字面“\” -
^匹配行首 -
$匹配行尾
捕获组和引用
如果你使用正则表达式组(...), 你可以引用匹配的子部分以进行提取或搜索和替换。例如,要从 YYYY-MM-DD 风格的日期中提取月份,你可以使用以下 Python 代码:
>>> import re
>>> re.match(r"\d{4}-(\d{2})-\d{2}", "2026-01-14").group(1)
'01'
在你的文本编辑器中,你可以使用替换模式中的引用捕获组。语法可能在不同的 IDE 之间有所不同。例如,在 VS Code 中,你可以使用变量如$1、$2等,而在 Vim 中,你可以使用\1、\2等来引用组。
局限性
正则语言功能强大但有限;有一些字符串类不能表示为标准正则表达式(例如,不可能写出一个匹配字符串集合 {a^n b^n | n ≥ 0} 的正则表达式,该集合包含由相同数量的“a”后跟相同数量的“b”组成的字符串;更实际地说,像 HTML 这样的语言不是正则语言)。在实践中,现代正则表达式引擎支持诸如前瞻和后引用等特性,它们扩展了对正则语言的支持,并且实际上非常实用,但重要的是要知道它们在表达能力上仍然有限。对于更复杂的语言,你可能需要求助于更强大的解析器类型(例如,参见pyparsing,一个PEG解析器)。
学习正则表达式
我们建议学习基础知识(我们在本次讲座中已经涵盖的内容),然后在需要时查看正则表达式引用,而不是记住整个语言的全部内容。
对话式人工智能工具可以帮助你生成正则表达式。例如,尝试用以下查询提示你的最喜欢的 LLM:
Write a Python-style regex pattern that matches the requested path from log lines from Nginx. Here is an example log line:
169.254.1.1 - - [09/Jan/2026:21:28:51 +0000] "GET /feed.xml HTTP/2.0" 200 2995 "-" "python-requests/2.32.3"
练习
-
为你正在工作的项目配置格式化器、代码检查器和预提交钩子。如果你有很多错误:自动格式化应该会处理格式错误。对于代码检查错误,尝试使用一个 AI 代理来修复所有的代码检查错误。确保 AI 代理可以运行代码检查并观察结果,以便它可以运行在迭代循环中修复所有问题。仔细检查结果,确保 AI 不会破坏你的代码!
-
学习你熟悉的一种语言的测试库,并为你的项目编写一个单元测试。运行代码覆盖率工具,生成一个 HTML 格式的覆盖率报告,并观察结果。你能找到被覆盖的行吗?你的代码覆盖率可能非常低。尝试手动编写一些测试来提高它。尝试使用一个 AI 代理来提高覆盖率;确保编码代理可以运行带有覆盖率的测试并生成逐行覆盖率报告,以便它知道应该关注哪里。AI 生成的测试实际上好吗?
-
为你正在工作的项目设置持续集成,以便在每次推送时运行。让 CI 运行格式化、代码检查和测试。故意破坏你的代码(例如,引入一个代码检查违规),并确保 CI 能够捕获它。
-
尝试编写一个正则表达式模式,并使用
grep命令行工具 在你的代码中查找subprocess.Popen(..., shell=True)的出现。现在,尝试“破坏”正则表达式模式。semgrep 是否仍然成功匹配到导致你的 grep 调用失败的危险代码? -
在你的 IDE 或文本编辑器中练习正则表达式的搜索和替换,将这些讲义中的 Markdown 项目符号标记
-替换为*项目符号标记。请注意,仅仅替换文件中的所有-字符是不正确的,因为该字符有多个用途,并不都是项目符号标记。 -
编写一个正则表达式,从形式为
{"name": "Alyssa P. Hacker", "college": "MIT"}的 JSON 结构中捕获名称(例如,在这个例子中是Alyssa P. Hacker)。提示:在第一次尝试中,你可能最终会编写一个提取Alyssa P. Hacker", "college": "MIT"的正则表达式;阅读关于 Python 正则表达式的Python 正则表达式文档,以了解如何修复它。-
让正则表达式即使在名称中包含
"(双引号)字符的情况下也能正常工作(在 JSON 中,双引号可以用\"来转义)。 -
我们不推荐在实际中用正则表达式来解决复杂的解析问题。找出如何使用你编程语言的 JSON 解析器来完成这个任务。编写一个命令行程序,它从 stdin 读取上述形式的 JSON 结构,并在 stdout 输出名称。你只需要几行代码就能做到这一点。在 Python 中,你可以在
import json之后用一行代码轻松完成。
-
本作品受CC BY-NC-SA许可。

(
浙公网安备 33010602011771号