How Does The Unix `history` Command Work?

Matthias Endler

Unix `history` 命令是如何工作的?

原文由 Matthias Endler 发布,订阅该博客

由 vectorpouch 创作的温馨阁楼和由 catalyststuff 创作的 tux — freepik.com
来源:由 vectorpouch 创作的温馨阁楼和由 catalyststuff 创作的 tux — freepik.com

夜色渐深,我终于有了一个小时完全属于自己的时间。正好可以听听 Billie Joel(我最近不是听 Billie Joel 就是听 Billie Eilish),顺便研究一下 Unix 的 history 命令是怎么工作的。生活真美好。

琢磨 Unix 背后的运行机制算是我的一个小爱好。
之前我已经写过 yeslscat 了。别笑话我。

history 到底是怎么工作的?

每一条命令都会被记录下来,所以当我运行 history 时,就能看到机器上最近执行的几条命令。

❯❯❯ history
8680  cd endler.dev
8682  cd content/2021
8683  mkdir history
8684  cd history
8685  vim index.md

是啊,但它到底是怎么做到的呢?

我 Mac 上的手册页没什么帮助——而且一开始我就没找到多少资料。

我找到了这篇文章(按现在的礼节得先提醒你这是个 Medium 链接),它大致讲了一下背后的原理。

每条命令都会被存到 $HISTFILE 里,在我的机器上它指向 ~/.zsh_history

❯❯❯ tail $HISTFILE
: 1586007759:0;cd endler.dev
: 1586007763:0;cd content/2021
: 1586007771:0;mkdir history
: 1586007772:0;cd history
: 1586007777:0;vim index.md
...

来看看。我们有一个 :,后面跟着时间戳,再跟着 :0,然后是一个分隔符(;),最后才是命令本身。每条新命令都会被追加到文件末尾。复刻起来应该不难。

等等,那个 0 又是怎么回事!?

原来它是命令执行时长,而整个格式被称为扩展历史格式

: <beginning time>:<elapsed seconds>;<command>

(根据你的设置,你的文件看起来可能会有所不同。)

接入 history

但话说回来,history 究竟是怎么工作的。

它一定是在我每次执行命令时都会运行一段代码——某种钩子

💥 Swoooooosh 💥

来自未来的 Matthias 从一道刺眼的光球中走出来:等等!事实根本不是这样的!

事实证明,像 bash 和 zsh 这样的 shell 根本不会为 history真正调用什么钩子。它们为什么要这么做呢?既然 history 本身就是 shell 的内置命令,它们完全可以在内部直接跟踪命令。

幸好我的主编、常驻 Unix 老炮 Simon Brüggen 给我解释了这一点——不过那已经是我把本文初稿发给他之后了。😓

因此,接下来的这一节有点像《指环王》:一个惹人同情却又天真的家伙,带着一个值得商榷的使命,却完全不知道自己将要陷入什么。

为自己辩解一下,《指环王》之所以受欢迎,主要也是因为它的娱乐价值,而不是它的历史准确性……而且就像这部史诗一样,我保证最后我们一定会把事情弄个水落石出。

我找到了 add-zsh-hook,还在 atuin 的源代码里看到了一个使用示例。

我可能没完全看懂那里写的所有内容,但我是个行动派,我可以把一份像样的代码拆开来研究。

东西不多,但这就是我拼凑出来的:

# Source this in your ~/.zshrc
autoload -U add-zsh-hook

_past_preexec(){
    echo "preexec"
}

_past_precmd(){
    echo "precmd"
}

add-zsh-hook preexec _past_preexec
add-zsh-hook precmd _past_precmd

这设置了两个钩子:第一个在命令执行前被调用,第二个在命令执行后立即被调用。(我决定把我这个小小的 history 替代品叫做 past。我喜欢短名字。)

好了,让 zsh 在我们每次执行命令时都来运行这个文件:

source src/shell/past.zsh

……然后

❯❯❯ date
preexec
Fri May 28 18:53:55 CEST 2021
precmd

成功了!✨ 太让人兴奋了!

其实我现在才想起来,两年多前我就为自己的那个小环境配置管理工具 envy 做过同样的事不过嘛

那么,有了新到手的能力,我们该做点什么呢?

来跑一段 Rust 代码

情况是这样的:只有 preexec 能拿到“真正的”命令。precmd 什么也拿不到:

_past_preexec(){
    echo "preexec $@"
}

_past_precmd(){
    echo "precmd $@"
}

$@ 的意思是“把你拿到的都给我看看”,而它拿到的就是这些:

❯❯❯ date
preexec date date date
Fri May 28 19:02:11 CEST 2021
precmd

一个“date”难道还不够吗?
嗯……来看看 关于 preexec 的 zsh 文档是怎么说的:

如果历史机制处于活动状态 […],用户输入的字符串会作为第一个参数传入,否则传入空字符串。将要执行的实际命令(包括展开后的别名)会以两种不同形式传入:第二个参数是命令的单行、长度受限版本(会省略函数体等内容);第三个参数则包含正在执行的完整文本。

不知道你怎么想,反正我觉得第三个参数就足够我们用的了?🤨

验证一下……

❯❯❯ ls -l
preexec ls -l lsd -l lsd -l

(向 lsd 致敬,新一代的 ls 命令)

好了,够用了。我们来用 Rust 代码解析 $3,然后把它写入我们自己的历史文件。

use std::env;
use std::error::Error;
use std::fs::OpenOptions;
use std::io::Write;

const HISTORY_FILE: &str = "lol";

fn main() -> Result<(), Box<dyn Error>> {
    let mut history = OpenOptions::new()
        .create(true)
        .append(true)
        .open(HISTORY_FILE)?;

    if let Some(command) = env::args().nth(3) {
        writeln!(history, "{}", command)?;
    };
    Ok(())
}
❯❯❯ cargo run -- dummy dummy hello
❯❯❯ cargo run -- dummy dummy world
❯❯❯ cat lol
hello
world

我们快完成了——至少如果我们愿意稍微作弊一下的话。😏 来把那个格式字符串硬编码进去:

use std::env;
use std::error::Error;
use std::fs::OpenOptions;
use std::io::Write;
use std::time::SystemTime;

const HISTORY_FILE: &str = "lol";

fn timestamp() -> Result<u64, Box<dyn Error>> {
    let n = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
    Ok(n.as_secs())
}

fn main() -> Result<(), Box<dyn Error>> {
    let mut history = OpenOptions::new()
        .create(true)
        .append(true)
        .open(HISTORY_FILE)?;

    if let Some(command) = env::args().nth(3) {
        writeln!(history, ": {}:0;{}", timestamp()?, command)?;
    };
    Ok(())
}

现在,如果眯起眼睛看看,它多少也算是按我的历史格式写入命令了。(关于 Unix 时间戳的那部分是直接从文档里抄来的。毫不后悔。)

还记得我说过 precmd 什么也拿不到吗?

我撒谎了。

实际上,你是可以读到已执行命令的退出码的(通过 $?)。这很有用,但我们就当没这回事,再也不提了。

把这个问题抛开不谈,我们最终的 past.zsh 钩子文件看起来是这样的:

autoload -U add-zsh-hook

_past_preexec(){
    past $@
}

add-zsh-hook preexec _past_preexec

现在来到危险环节!看我把原版的 history 命令替换成我自己的,大家先往后退一步。千万别在家尝试。(其实我有点夸张了。尽管去试,最坏的情况也不过是丢掉一点历史记录,但别来告我。)

首先,把历史文件的路径改成我真实的那个:

// You should read the ${HISTFILE} env var instead ;)
const HISTORY_FILE: &str = "/Users/mendler/.zhistory";

然后安装 past

❯❯❯ cargo install --path .
# bleep bloop...

装好之后就可以用了。把这家伙加到我的 ~/.zshrc 里:

source "/Users/mendler/Code/private/past/src/shell/past.zsh"

终于可以测试了。

我们打开一个新的 shell,运行几条命令,然后执行 history

❯❯❯  date
...
❯❯❯ ls
...
❯❯❯ it works
...
❯❯❯ history
 1011  date
 1012  ls
 1013  it works

成功了。past 的源代码在 Github 上。

真正真正的工作原理

我们的实验大获成功,但后来我了解到,现实情况有点不一样。

“在早期版本的 Unix 中,history 命令是一个独立的程序”,但大多数现代 shell 都把 history 做成了内置命令。

zsh 在它的主运行循环中跟踪历史记录。下面是关键部分。(假设所有类型都已在作用域内。)

Eprog prog;

/* Main zsh run loop */
for (;;)
{
    /* Init history */
    hbegin(1);
    if (!(prog = parse_event(ENDINPUT)))
    {
        /* Couldn't parse command. Stop history */
        hend(NULL);
        continue;
    }
    /* Store command in history */
    if (hend(prog))
    {
        LinkList args;
        args = newlinklist();
        addlinknode(args, hist_ring->node.nam);
        addlinknode(args, dupstring(getjobtext(prog, NULL)));
        addlinknode(args, cmdstr = getpermtext(prog, NULL, 0));

        /* Here's the preexec hook that we used.
        * It gets passed all the args we saw earlier.
        */
        callhookfunc("preexec", args, 1, NULL);

        /* Main routine for executing a command */
        execode(prog);
    }
}

历史记录行被保存在一个哈希表中,同时也保存在一个环形缓冲区里,以防止历史记录变得过大。(见此处。)

这很聪明!如果没有环形缓冲区,恶意用户就可以用随机命令不断刷爆历史记录,直到触发缓冲区溢出。我可从没想过这一点。

历史时刻(明白我的梗了吗?)

最初的 history 命令是在 1978 年被加入到 Unix C shell(csh)中的。这里有一篇 论文,作者是 Bill Joy(嘿,又是一个 Bill!)。他从 Interlisp 中的 REDO 命令获得了灵感。你可以在 Interlisp 原始手册的第 8.7 节中找到它的规范。

收获

  • 不懂的东西,就亲手重做一遍。
  • 历史文件是人类可读的,而且相当直观。
  • history 命令是 shell 的内置命令,但我们可以用钩子来自己实现一个。
  • 冷知识:你知道在 zsh 中,history 其实只是 fc -l 的一个别名吗?更多信息见此查看源代码

“我无法创造的东西,我就无法理解”——理查德·费曼

本文章由 muse-spark-1.2-contributor 进行翻译

评论