shell 是一个编程环境,所以它具备变量、条件、循环和函数。在 shell 中执行命令时,实际上是在执行一段 shell 可以解释执行的简短代码。如果要求 shell 执行某个指令,但是该指令并不是 shell 所了解的编程关键字,那么它会去咨询 环境变量 $PATH
,它会列出当 shell 接到某条指令时,进行程序搜索的路径:
在 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
还可以使用 >>
来向一个文件追加内容。使用管道( pipes ),我们能够更好的利用文件重定向。 |
操作符允许我们将一个程序的输出和另外一个程序的输入连接起来:
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 user)。 您应该已经注意到了,在上面的输出结果中,根用户几乎不受任何限制,他可以创建、读取、更新和删除系统中的任何文件。 通常在我们并不会以根用户的身份直接登录系统,因为这样可能会因为某些错误的操作而破坏系统。 取而代之的是我们会在需要的时候使用 sudo 命令。顾名思义,它的作用是让您可以以 su(super user 或 root 的简写)的身份执行一些操作。 当您遇到拒绝访问(permission denied)的错误时,通常是因为此时您必须是根用户才能操作。然而,请再次确认您是真的要执行此操作。
有一件事情是您必须作为根用户才能做的,那就是向 sysfs 文件写入内容。系统被挂载在 /sys 下,sysfs 文件则暴露了一些内核(kernel)参数。 因此,您不需要借助任何专用的工具,就可以轻松地在运行期间配置系统内核。注意 Windows 和 macOS 没有这个文件
例如,笔记本电脑的屏幕亮度写在 brightness 文件中,它位于
/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
前尝试打开 brightness 文件并写入,但是系统拒绝了 shell 的操作因为此时 shell 不是根用户。
明白这一点后,我们可以这样操作:
echo 3 | sudo tee brightness
因为打开 /sys
文件的是 tee
这个程序,并且该程序以 root
权限在运行,因此操作可以进行。
很多情况下我们需要执行一系列的操作并使用条件或循环这样的控制流。
shell 脚本是一种更加复杂度的工具。
大多数shell都有自己的一套脚本语言,包括变量、控制流和自己的语法。shell脚本与其他脚本语言不同之处在于,shell 脚本针对 shell 所从事的相关工作进行来优化。因此,创建命令流程(pipelines)、将结果保存到文件、从标准输入中读取输入,这些都是 shell 脚本中的原生操作,这让它比通用的脚本语言更易用。本节中,我们会专注于 bash 脚本,因为它最流行,应用更为广泛。
在bash中为变量赋值的语法是foo=bar
,访问变量中存储的数值,其语法为 $foo
。 需要注意的是,foo = bar
(使用空格隔开)是不能正确工作的,因为解释器会调用程序foo
并将 =
和 bar
作为参数。 总的来说,在shell脚本中使用空格会起到分割参数的作用,有时候可能会造成混淆,请务必多加检查。
Bash中的字符串通过'
和 "
分隔符来定义,但是它们的含义并不相同。以'
定义的字符串为原义字符串,其中的变量不会被转义,而"
定义的字符串会将变量值进行替换。
foo=bar
echo "$foo"
# 打印 bar
echo '$foo'
# 打印 $foo
和其他大多数的编程语言一样,bash也支持if
, case
, while
和for
这些控制流关键字。同样地, bash 也支持函数,它可以接受参数并基于参数进行操作。
下面这个函数是一个例子,它会创建一个文件夹并使用cd
进入该文件夹。
mcd () {
mkdir -p "$1"
cd "$1"
}
这里 $1
是脚本的第一个参数。与其他脚本语言不同的是,bash使用了很多特殊的变量来表示参数、错误代码和相关变量。下面是列举来其中一些变量,更完整的列表可以参考 这里。
$0
- 脚本名$1
到$9
- 脚本的参数。$1
是第一个参数,依此类推。$@
- 所有参数$#
- 参数个数$?
- 前一个命令的返回值$$
- 当前脚本的进程识别码!!
- 完整的上一条命令,包括参数。常见应用:当你因为权限不足执行命令失败时,可以使用sudo !!
再尝试一次。$_
- 上一条命令的最后一个参数。如果你正在使用的是交互式 shell,你可以通过按下 Esc 之后键入.
来获取这个值。
命令通常使用STDOUT
来返回输出值,使用STDERR
来返回错误及错误码,便于脚本以更加友好的方式报告错误。 返回码或退出状态是脚本/命令之间交流执行状态的方式。返回值0表示正常执行,其他所有非0的返回值都表示有错误发生。
退出码可以搭配 &&
(与操作符)和 ||
(或操作符)使用,用来进行条件判断,决定是否执行其他程序。它们都属于短路运算符(short-circuiting) 同一行的多个命令可以用;
分隔。程序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"
#
false ; echo "This will always run"
# This will always run
另一个常见的模式是以变量的形式获取一个命令的输出,这可以通过 命令替换(command substitution)实现。
当您通过 $( CMD )
这样的方式来执行CMD 这个命令时,它的输出结果会替换掉 $( CMD )
。例如,如果执行 for file in $(ls)
,shell首先将调用ls
,然后遍历得到的这些返回值。还有一个冷门的类似特性是进程替换(process substitution),<( CMD )
会执行 CMD 并将结果输出到一个临时文件中,并将 <( CMD )
替换成临时文件名。这在我们希望返回值通过文件而不是STDIN传递时很有用。例如,diff <(ls foo) <(ls bar)
会显示文件夹 foo
和 bar
中文件的区别。
说了很多,现在该看例子了,下面这个例子展示了一部分上面提到的特性。这段脚本会遍历我们提供的参数,使用grep
搜索字符串 foobar
,如果没有找到,则将其作为注释追加到文件中。
#!/bin/bash
echo "Starting program at $(date)" # date会被替换成日期和时间
echo "Running program $0 with $# arguments with pid $$"
for file in "$@"; do
grep foobar "$file" > /dev/null 2> /dev/null
# 如果模式没有找到,则grep退出状态为 1
# 我们将标准输出流和标准错误流重定向到Null,因为我们并不关心这些信息
if [[ $? -ne 0 ]]; then
echo "File $file does not have any foobar, adding one"
echo "# foobar" >> "$file"
fi
done
在条件语句中,我们比较 $?
是否等于0。 Bash实现了许多类似的比较操作,您可以查看 test 手册。 在bash中进行比较时,尽量使用双方括号[[ ]]
而不是单方括号 [ ]
,这样会降低犯错的几率,尽管这样并不能兼容 sh
。 更详细的说明参见这里。
当执行脚本时,我们经常需要提供形式类似的参数。bash使我们可以轻松的实现这一操作,它可以基于文件扩展名展开表达式。这一技术被称为shell的 通配(globbing)
- 通配符: 当你想要利用通配符进行匹配时,你可以分别使用
?
和*
来匹配一个或任意个字符。例如,对于文件foo
,foo1
,foo2
,foo10
和bar
,rm foo?
这条命令会删除foo1
和foo2
,而rm foo*
则会删除除了bar
之外的所有文件。 花括号{}
:当你有一系列的指令,其中包含一段公共子串时,可以用花括号来自动展开这些命令。这在批量移动或转换文件时非常方便。
convert image.{png,jpg}
# 会展开为
convert image.png image.jpg
cp /path/to/project/{foo,bar,baz}.sh /newpath
# 会展开为
cp /path/to/project/foo.sh /path/to/project/bar.sh /path/to/project/baz.sh /newpath
# 也可以结合通配使用
mv *{.py,.sh} folder
# 会移动所有 *.py 和 *.sh 文件
mkdir foo bar
# 下面命令会创建foo/a, foo/b, ... foo/h, bar/a, bar/b, ... bar/h这些文件
touch {foo,bar}/{a..h}
touch foo/x bar/y
# 比较文件夹 foo 和 bar 中包含文件的不同
diff <(ls foo) <(ls bar)
# 输出
# < 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。
在 shebang
行中使用 env
命令是一种好的实践,它会利用环境变量中的程序来解析该脚本,这样就提高来您的脚本的可移植性。env
会利用我们第一节讲座中介绍过的PATH
环境变量来进行定位。 例如,使用了env
的shebang
看上去时这样的#!/usr/bin/env python
。
shell函数和脚本有如下一些不同点:
- 函数只能与shell使用相同的语言,脚本可以使用任意语言。因此在脚本中包含
shebang
是很重要的。 - 函数仅在定义时被加载,脚本会在每次被执行时加载。这让函数的加载比脚本略快一些,但每次修改函数定义,都要重新加载一次。
- 函数会在当前的shell环境中执行,脚本会在单独的进程中执行。因此,函数可以对环境变量进行更改,比如改变当前工作目录,脚本则不行。脚本需要使用
export
将环境变量导出,并将值传递给环境变量。 - 与其他程序语言一样,函数可以提高代码模块性、代码复用性并创建清晰性的结构。shell脚本中往往也会包含它们自己的函数定义。
最常用的方法是为对应的命令行添加-h
或 --help
标记。另外一个更详细的方法则是使用man
命令。man
命令是手册(manual)的缩写,它提供了命令的用户手册。
例如,man rm
会输出命令 rm
的说明,同时还有其标记列表,包括之前我们介绍过的-i
。 事实上,目前我们给出的所有命令的说明链接,都是网页版的Linux命令手册。即使是您安装的第三方命令,前提是开发者编写了手册并将其包含在了安装包中。在交互式的、基于字符处理的终端窗口中,一般也可以通过 :help
命令或键入 ?
来获取帮助。
所有的类UNIX系统都包含一个名为 find 的工具,它是 shell 上用于查找文件的绝佳工具。find命令会递归地搜索符合条件的文件,例如:
# 查找所有名称为src的文件夹
find . -name src -type d
# 查找所有文件夹路径中包含test的python文件
find . -path '*/test/*.py' -type f
# 查找前一天修改的所有文件
find . -mtime -1
# 查找所有大小在500k至10M的tar.gz文件
find . -size +500k -size -10M -name '*.tar.gz'
除了列出所寻找的文件之外,find 还能对所有查找到的文件进行操作。这能极大地简化一些单调的任务。
# 删除全部扩展名为.tmp 的文件
find . -name '*.tmp' -exec rm {} \;
# 查找全部的 PNG 文件并将其转换为 JPG
find . -name '*.png' -exec convert {} {}.jpg \;
尽管 find
用途广泛,它的语法却比较难以记忆。例如,为了查找满足模式 PATTERN 的文件,您需要执行 find -name '*PATTERN*'
(如果您希望模式匹配时是不区分大小写,可以使用-iname
选项)
您当然可以使用 alias
设置别名来简化上述操作,但 shell 的哲学之一便是寻找(更好用的)替代方案。 记住,shell 最好的特性就是您只是在调用程序,因此您只要找到合适的替代程序即可(甚至自己编写)。
例如,fd
就是一个更简单、更快速、更友好的程序,它可以用来作为find的替代品。它有很多不错的默认设置,例如输出着色、默认支持正则匹配、支持unicode并且我认为它的语法更符合直觉。以模式PATTERN 搜索的语法是 fd PATTERN。
大多数人都认为 find
和 fd
已经很好用了,但是有的人可能想知道,我们是不是可以有更高效的方法,例如不要每次都搜索文件而是通过编译索引或建立数据库的方式来实现更加快速地搜索。
这就要靠 locate 了。 locate 使用一个由 updatedb负责更新的数据库,在大多数系统中 updatedb 都会通过 cron 每日更新。这便需要我们在速度和时效性之间作出权衡。而且,find 和类似的工具可以通过别的属性比如文件大小、修改时间或是权限来查找文件,locate则只能通过文件名。 这里有一个更详细的对比。
查找文件是很有用的技能,但是很多时候您的目标其实是查看文件的内容。一个最常见的场景是您希望查找具有某种模式的全部文件,并找它们的位置。
为了实现这一点,很多类UNIX的系统都提供了grep命令,它是用于对输入文本进行匹配的通用工具。它是一个非常重要的shell工具,我们会在后续的数据清理课程中深入的探讨它。
grep
有很多选项,这也使它成为一个非常全能的工具。其中我经常使用的有 -C
:获取查找结果的上下文(Context);-v
将对结果进行反选(Invert),也就是输出不匹配的结果。举例来说,grep -C 5
会输出匹配结果前后五行。当需要搜索大量文件的时候,使用 -R
会递归地进入子目录并搜索所有的文本文件。
但是,我们有很多办法可以对 grep -R
进行改进,例如使其忽略.git
文件夹,使用多CPU等等。
因此也出现了很多它的替代品,包括 ack, ag 和 rg。它们都特别好用,但是功能也都差不多,我比较常用的是 ripgrep (rg) ,因为它速度快,而且用法非常符合直觉。例子如下:
# 查找所有使用了 requests 库的文件
rg -t py 'import requests'
# 查找所有没有写 shebang 的文件(包含隐藏文件)
rg -u --files-without-match "^#!"
# 查找所有的foo字符串,并打印其之后的5行
rg foo -A 5
# 打印匹配的统计信息(匹配的行和文件的数量)
rg --stats PATTERN
与 find/fd 一样,重要的是你要知道有些问题使用合适的工具就会迎刃而解,而具体选择哪个工具则不是那么重要。
目前为止,我们已经学习了如何查找文件和代码,但随着你使用shell的时间越来越久,您可能想要找到之前输入过的某条命令。首先,按向上的方向键会显示你使用过的上一条命令,继续按上键则会遍历整个历史记录。
history
命令允许您以程序员的方式来访问shell中输入的历史命令。这个命令会在标准输出中打印shell中的里面命令。如果我们要搜索历史记录,则可以利用管道将输出结果传递给 grep 进行模式搜索。 history | grep find
会打印包含find子串的命令。
对于大多数的shell来说,您可以使用 Ctrl+R
对命令历史记录进行回溯搜索。敲 Ctrl+R
后您可以输入子串来进行匹配,查找历史命令行。
反复按下就会在所有搜索结果中循环。在 zsh 中,使用方向键上或下也可以完成这项工作。
Ctrl+R
可以配合 fzf
使用。fzf
是一个通用对模糊查找工具,它可以和很多命令一起使用。这里我们可以对历史命令进行模糊查找并将结果以赏心悦目的格式输出。
另外一个和历史命令相关的技巧我喜欢称之为基于历史的自动补全。 这一特性最初是由 fish shell 创建的,它可以根据您最近使用过的开头相同的命令,动态地对当前对shell命令进行补全。这一功能在 zsh 中也可以使用,它可以极大的提高用户体验。
你可以修改 shell history 的行为,例如,如果在命令的开头加上一个空格,它就不会被加进shell记录中。当你输入包含密码或是其他敏感信息的命令时会用到这一特性。 为此你需要在.bashrc中添加HISTCONTROL=ignorespace或者向.zshrc 添加 setopt HIST_IGNORE_SPACE。 如果你不小心忘了在前面加空格,可以通过编辑。bash_history或 .zhistory 来手动地从历史记录中移除那一项。
之前对所有操作我们都默认一个前提,即您已经位于想要执行命令的目录下,但是如何才能高效地在目录 间随意切换呢?有很多简便的方法可以做到,比如设置alias
,使用 ln -s
创建符号连接等。而开发者们已经想到了很多更为精妙的解决方案。
由于本课程的目的是尽可能对你的日常习惯进行优化。因此,我们可以使用fasd
和 autojump
这两个工具来查找最常用或最近使用的文件和目录。
Fasd 基于 frecency 对文件和文件排序,也就是说它会同时针对频率(frequency)和时效(recency)进行排序。默认情况下,fasd使用命令 z 帮助我们快速切换到最常访问的目录。例如, 如果您经常访问/home/user/files/cool_project 目录,那么可以直接使用 z cool 跳转到该目录。对于 autojump,则使用j cool代替即可。
还有一些更复杂的工具可以用来概览目录结构,例如 tree, broot 或更加完整的文件管理器,例如 nnn 或 ranger。
当您在使用命令行时,您通常会希望同时执行多个任务。举例来说,您可以想要同时运行您的编辑器,并在终端的另外一侧执行程序。尽管再打开一个新的终端窗口也能达到目的,使用终端多路复用器则是一种更好的办法。
像 tmux 这类的终端多路复用器可以允许我们基于面板和标签分割出多个终端窗口,这样您便可以同时与多个 shell 会话进行交互。
不仅如此,终端多路复用使我们可以分离当前终端会话并在将来重新连接。
这让您操作远端设备时的工作流大大改善,避免了 nohup 和其他类似技巧的使用。
现在最流行的终端多路器是 tmux。tmux 是一个高度可定制的工具,您可以使用相关快捷键创建多个标签页并在它们间导航。
tmux 的快捷键需要我们掌握,它们都是类似 <C-b> x
这样的组合,即需要先按下Ctrl+b,松开后再按下 x。tmux 中对象的继承结构如下:
- 会话 - 每个会话都是一个独立的工作区,其中包含一个或多个窗口
- tmux 开始一个新的会话
- tmux new -s NAME 以指定名称开始一个新的会话
- tmux ls 列出当前所有会话
- 在 tmux 中输入
<C-b> d
,将当前会话分离 - tmux a 重新连接最后一个会话。您也可以通过 -t 来指定具体的会话
- 窗口 - 相当于编辑器或是浏览器中的标签页,从视觉上将一个会话分割为多个部分
<C-b> c
创建一个新的窗口,使用<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> <方向>
切换到指定方向的面板,<方向> 指的是键盘上的方向键<C-b> z
切换当前面板的缩放<C-b> [
开始往回卷动屏幕。您可以按下空格键来开始选择,回车键复制选中的部分<C-b> <
空格> 在不同的面板排布间切换
扩展阅读: 这里 是一份 tmux 快速入门教程, 而这一篇 文章则更加详细,它包含了 screen
命令。您也许想要掌握 screen 命令,因为在大多数 UNIX 系统中都默认安装有该程序。
输入一长串包含许多选项的命令会非常麻烦。因此,大多数 shell 都支持设置别名。shell 的别名相当于一个长命令的缩写,shell 会自动将其替换成原本的命令。例如,bash 中的别名语法如下:
alias alias_name="command_to_alias arg1 arg2" 注意, =两边是没有空格的,因为 alias 是一个 shell 命令,它只接受一个参数。
别名有许多很方便的特性:
# 创建常用命令的缩写
alias ll="ls -lh"
# 能够少输入很多
alias gs="git status"
alias gc="git commit"
alias v="vim"
# 手误打错命令也没关系
alias sl=ls
# 重新定义一些命令行的默认行为
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 la="ls -A"
alias lla="la -l"
# 在忽略某个别名
\ls
# 或者禁用别名
unalias la
# 获取别名的定义
alias ll
# 会打印 ll='ls -lh'
值得注意的是,在默认情况下 shell 并不会保存别名。为了让别名持续生效,您需要将配置放进 shell 的启动文件里,像是.bashrc
或 .zshrc
,下一节我们就会讲到。
很多程序的配置都是通过纯文本格式的被称作点文件的配置文件来完成的(之所以称为点文件,是因为它们的文件名以 .
开头,例如 ~/.vimrc
。也正因为此,它们默认是隐藏文件,ls
并不会显示它们)。
shell 的配置也是通过这类文件完成的。在启动时,您的 shell 程序会读取很多文件以加载其配置项。根据 shell 本身的不同,您从登录开始还是以交互的方式完成这一过程可能会有很大的不同。关于这一话题,这里 有非常好的资源
对于 bash来说,在大多数系统下,您可以通过编辑 .bashrc
或 .bash_profile
来进行配置。在文件中您可以添加需要在启动时执行的命令,例如上文我们讲到过的别名,或者是您的环境变量。
实际上,很多程序都要求您在 shell 的配置文件中包含一行类似 export PATH="$PATH:/path/to/program/bin"
的命令,这样才能确保这些程序能够被 shell 找到。
还有一些其他的工具也可以通过点文件进行配置:
- bash - ~/.bashrc, ~/.bash_profile
- git - ~/.gitconfig
- vim - ~/.vimrc 和 ~/.vim 目录
- ssh - ~/.ssh/config
- tmux - ~/.tmux.conf
我们应该如何管理这些配置文件呢,它们应该在它们的文件夹下,并使用版本控制系统进行管理,然后通过脚本将其 符号链接 到需要的地方。这么做有如下好处:
- 安装简单: 如果您登录了一台新的设备,在这台设备上应用您的配置只需要几分钟的时间;
- 可以执行: 您的工具在任何地方都以相同的配置工作
- 同步: 在一处更新配置文件,可以同步到其他所有地方
- 变更追踪: 您可能要在整个程序员生涯中持续维护这些配置文件,而对于长期项目而言,版本历史是非常重要的
配置文件的一个常见的痛点是它可能并不能在多种设备上生效。例如,如果您在不同设备上使用的操作系统或者 shell 是不同的,则配置文件是无法生效的。或者,有时您仅希望特定的配置只在某些设备上生效。
有一些技巧可以轻松达成这些目的。如果配置文件 if 语句,则您可以借助它针对不同的设备编写不同的配置。例如,您的 shell 可以这样做:
if [[ "$(uname)" == "Linux" ]]; then {do_something}; fi
# 使用和 shell 相关的配置时先检查当前 shell 类型
if [[ "$SHELL" == "zsh" ]]; then {do_something}; fi
# 您也可以针对特定的设备进行配置
if [[ "$(hostname)" == "myServer" ]]; then {do_something}; fi
如果配置文件支持 include 功能,您也可以多加利用。例如:~/.gitconfig 可以这样编写:
[include]
path = ~/.gitconfig_local
然后我们可以在日常使用的设备上创建配置文件 ~/.gitconfig_local
来包含与该设备相关的特定配置。您甚至应该创建一个单独的代码仓库来管理这些与设备相关的配置。
如果您希望在不同的程序之间共享某些配置,该方法也适用。例如,如果您想要在 bash
和 zsh
中同时启用一些别名,您可以把它们写在 .aliases
里,然后在这两个 shell 里应用:
# Test if ~/.aliases exists and source it
if [ -f ~/.aliases ]; then
source ~/.aliases
fi
在编程的时候,你会把大量时间花在阅读/编辑而不是在写代码上。所以,Vim 是一个多模态编辑器:它对于插入文字和操纵文字有不同的模式。Vim 是可编程的(可以使用 Vimscript 或者像 Python 一样的其他程序语言),Vim 的接口本身也是一个程序语言:键入操作(以及其助记名)是命令,这些命令也是可组合的。Vim 避免了使用鼠标,因为那样太慢了;Vim 甚至避免用上下左右键因为那样需要太多的手指移动。
这样的设计哲学使得 Vim 成为了一个能跟上你思维速度的编辑器。
Vim 的设计以大多数时间都花在阅读、浏览和进行少量编辑改动为基础,因此它具有多种操作模式:
- 正常模式:在文件中四处移动光标进行修改
- 插入模式:插入文本
- 替换模式:替换文本
- 可视化模式(一般,行,块):选中文本块
- 命令模式:用于执行命令
在不同的操作模式下,键盘敲击的含义也不同。比如,x 在插入模式会插入字母 x,但是在正常模式 会删除当前光标所在的字母,在可视模式下则会删除选中文块。
在默认设置下,Vim 会在左下角显示当前的模式。Vim 启动时的默认模式是正常模式。通常你会把大部分时间花在正常模式和插入模式。
你可以按下 <ESC>
(退出键)从任何其他模式返回正常模式。在正常模式,键入i
进入插入模式,R
进入替换模式,v
进入可视(一般)模式,V
进入可视(行)模式,<C-v>
(Ctrl-V, 有时也写作 ^V)进入可视(块)模式,:
进入命令模式。
因为你会在使用 Vim 时大量使用 <ESC>
键,所以可以考虑把大小写锁定键重定义成 <ESC>
键(MacOS 教程)。
Vim 会维护一系列打开的文件,称为“缓存”。一个 Vim 会话包含一系列标签页,每个标签页包含一系列窗口(分隔面板)。每个窗口显示一个缓存。跟网页浏览器等其他你熟悉的程序不一样的是,缓存和窗口不是一一对应的关系;窗口只是视角。一个缓存可以在多个窗口打开,甚至在同一个标签页内的多个窗口打开。这个功能其实很好用,比如在查看同一个文件的不同部分的时候。
Vim 默认打开一个标签页,这个标签也包含一个窗口。
在正常模式下键入:
进入命令行模式。在键入:
后,你的光标会立即跳到屏幕下方的命令行。这个模式有很多功能,包括打开,保存,关闭文件,以及退出 Vim。
- :q 退出(关闭窗口)
- :w 保存(写)
- :wq 保存然后退出
- :e {文件名} 打开要编辑的文件
- :ls 显示打开的缓存
- :help {标题} 打开帮助文档
- :help :w 打开 :w 命令的帮助文档
- :help w 打开 w 移动的帮助文档
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{移动命令} 再 i
- x 删除字符(等同于 dl)
- s 替换字符(等同于 xi)
- 可视化模式 + 操作
- 选中文字, d 删除 或者 c 改变
- u 撤销, 重做
- y 复制 / “yank” (其他一些命令比如 d 也会复制)
- p 粘贴
- 更多值得学习的: 比如 ~ 改变字符的大小写
你可以用一个计数来结合“名词”和“动词”,这会执行指定操作若干次。
- 3w 向前移动三个词
- 5j 向下移动5行
- 7dw 删除7个词
你可以用修饰语改变“名词”的意义。修饰语有 i,表示“内部”或者“在内“,和 a, 表示”周围“。
- ci( 改变当前括号内的内容
- ci[ 改变当前方括号内的内容
- da' 删除一个单引号字符串, 包括周围的单引号
:s (替换)命令(文档)。
- %s/foo/bar/g
- 在整个文件中将 foo 全局替换成 bar
- %s/[.]((.))/\1/g
- 将有命名的 Markdown 链接替换成简单 URLs
- 用 :sp / :vsp 来分割窗口
- 同一个缓存可以在多个窗口中显示。
- q{字符} 来开始在寄存器{字符}中录制宏
- q停止录制
- @{字符} 重放宏
- 宏的执行遇错误会停止
- {计数}@{字符}执行一个宏{计数}次
- 宏可以递归
- 首先用q{字符}q清除宏
- 录制该宏,用 @{字符} 来递归调用该宏 (在录制完成之前不会有任何操作)
- 例子:将 xml 转成 json (file)
- 一个有 “name” / “email” 键对象的数组
- 用一个 Python 程序?
- 用 sed / 正则表达式
- g/people/d
- %s//{/g
- %s/(.*)</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
- 手动移除最后的 , 然后加上 [ 和 ] 分隔符
正则表达式非常常见也非常有用,值得您花些时间去理解它。让我们从这一句正则表达式开始学习: /.*Disconnected from /。正则表达式通常以(尽管并不总是) /开始和结束。大多数的 ASCII 字符都表示它们本来的含义,但是有一些字符确实具有表示匹配行为的“特殊”含义。不同字符所表示的含义,根据正则表达式的实现方式不同,也会有所变化,这一点确实令人沮丧。常见的模式有:
- . 除换行符之外的”任意单个字符”
- * 匹配前面字符零次或多次
- + 匹配前面字符一次或多次
- [abc] 匹配 a, b 和 c 中的任意一个
- (RX1|RX2) 任何能够匹配RX1 或 RX2的结果
- ^ 行首
- $ 行尾
版本控制系统 (VCSs) 是一类用于追踪源代码(或其他文件、文件夹)改动的工具。顾名思义,这些工具可以帮助我们管理代码的修改历史;不仅如此,它还可以让协作编码变得更方便。VCS通过一系列的快照将某个文件夹及其内容保存了起来,每个快照都包含了文件或文件夹的完整状态。同时它还维护了快照创建者的信息以及每个快照的相关信息等等。
进行版本控制的方法很多。Git 拥有一个经过精心设计的模型,这使其能够支持版本控制所需的所有特性,例如维护历史记录、支持分支和促进协作。
Git 将顶级目录中的文件和文件夹作为集合,并通过一系列快照来管理其历史记录。在Git的术语里,文件被称作Blob对象(数据对象),也就是一组数据。目录则被称之为“树”,它将名字与 Blob 对象或树对象进行映射(使得目录中可以包含其他目录)。快照则是被追踪的最顶层的树。例如,一个树看起来可能是这样的:
<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 中,历史记录是一个由快照组成的有向无环图。有向无环图,听上去似乎是什么高大上的数学名词。不过不要怕,您只需要知道这代表 Git 中的每个快照都有一系列的“父辈”,也就是其之前的一系列快照。注意,快照具有多个“父辈”而非一个,因为某个快照可能由多个父辈而来。例如,经过合并后的两条分支。
在 Git 中,这些快照被称为“提交”。通过可视化的方式来表示这些历史提交记录时,看起来差不多是这样的:
o <-- o <-- o <-- o
^
\
--- o <-- o
上面是一个 ASCII 码构成的简图,其中的 o 表示一次提交(快照)。
箭头指向了当前提交的父辈(这是一种“在…之前”,而不是“在…之后”的关系)。在第三次提交之后,历史记录分岔成了两条独立的分支。这可能因为此时需要同时开发两个不同的特性,它们之间是相互独立的。开发完成后,这些分支可能会被合并并创建一个新的提交,这个新的提交会同时包含这些特性。新的提交会创建一个新的历史记录,看上去像这样(最新的合并提交用粗体标记):
o <-- o <-- o <-- o <---- o
^ /
\ v
--- o <-- o
Git 中的提交是不可改变的。但这并不代表错误不能被修改,只不过这种“修改”实际上是创建了一个全新的提交记录。而引用(参见下文)则被更新为指向这些新的提交。
以伪代码的形式来学习 Git 的数据模型,可能更加清晰:
// 文件就是一组数据
type blob = array<byte>
// 一个包含文件和目录的目录
type tree = map<string, tree | blob>
// 每个提交都包含一个父辈,元数据和顶层树
type commit = struct {
parent: array<commit>
author: string
message: string
snapshot: tree
}
这是一种简洁的历史模型。
Git 中的对象可以是 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]
Blobs、树和提交都一样,它们都是对象。当它们引用其他对象时,它们并没有真正的在硬盘上保存这些对象,而是仅仅保存了它们的哈希值作为引用。
例如,上面例子中的树(可以通过 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 的解决方法是给这些哈希值赋予人类可读的名字,也就是引用(references)。引用是指向提交的指针。与对象不同的是,它是可变的(引用可以被更新,指向新的提交)。例如,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” 这样人类可读的名称来表示历史记录中某个特定的提交,而不需要在使用一长串十六进制字符了。
有一个细节需要我们注意, 通常情况下,我们会想要知道“我们当前所在位置”,并将其标记下来。这样当我们创建新的快照的时候,我们就可以知道它的相对位置(如何设置它的“父辈”)。在 Git 中,我们当前的位置有一个特殊的索引,它就是 “HEAD”。
最后,我们可以粗略地给出 Git 仓库的定义了:对象 和 引用。
在硬盘上,Git 仅存储对象和引用:因为其数据模型仅包含这些东西。所有的 git 命令都对应着对提交树的操作,例如增加对象,增加或删除引用。
当您输入某个指令时,请思考一下这条命令是如何对底层的图数据结构进行操作的。另一方面,如果您希望修改提交树,例如“丢弃未提交的修改和将 ‘master’ 引用指向提交 5d83f9e 时,有什么命令可以完成该操作(针对这个具体问题,您可以使用 git checkout master; git reset --hard 5d83f9e)
Git 中还包括一个和数据模型完全不相关的概念,但它确是创建提交的接口的一部分。
就上面介绍的快照系统来说,您也许会期望它的实现里包括一个 “创建快照” 的命令,该命令能够基于当前工作目录的当前状态创建一个全新的快照。有些版本控制系统确实是这样工作的,但 Git 不是。我们希望简洁的快照,而且每次从当前状态创建快照可能效果并不理想。例如,考虑如下场景,您开发了两个独立的特性,然后您希望创建两个独立的提交,其中第一个提交仅包含第一个特性,而第二个提交仅包含第二个特性。或者,假设您在调试代码时添加了很多打印语句,然后您仅仅希望提交和修复 bug 相关的代码而丢弃所有的打印语句。
Git 处理这些场景的方法是使用一种叫做 “暂存区(staging area)”的机制,它允许您指定下次快照中要包括那些改动。
git help : 获取 git 命令的帮助信息 git init: 创建一个新的 git 仓库,其数据会存放在一个名为 .git 的目录下 git status: 显示当前的仓库状态 git add : 添加文件到暂存区 git commit: 创建一个新的提交 如何编写 良好的提交信息! 为何要 编写良好的提交信息 git log: 显示历史日志 git log --all --graph --decorate: 可视化历史记录(有向无环图) git diff : 显示与暂存区文件的差异 git diff : 显示某个文件两个版本之间的差异 git checkout : 更新 HEAD 和目前的分支
git branch: 显示分支 git branch : 创建分支 git checkout -b : 创建分支并切换到该分支 相当于 git branch ; git checkout git merge : 合并到当前分支 git mergetool: 使用工具来处理合并冲突 git rebase: 将一系列补丁变基(rebase)为新的基线
git remote: 列出远端 git remote add : 添加一个远端 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 restore: git2.32版本后取代git reset 进行许多撤销操作
git config: Git 是一个 高度可定制的 工具 git clone --depth=1: 浅克隆(shallow clone),不包括完整的版本历史信息 git add -p: 交互式暂存 git rebase -i: 交互式变基 git blame: 查看最后修改某行的人 git stash: 暂时移除工作目录下的修改内容 git bisect: 通过二分查找搜索历史记录 .gitignore: 指定 故意不追踪的文件