Bun技术评估 - 14 Shell

140 阅读4分钟

概述

本文是笔者的系列博文 《Bun技术评估》 中的第十四篇。

在本文的内容中,笔者主要想要来探讨一下bun提供的一个有趣,而且在特定场景下也可能比较实用的特性:

bun shell。

简单理解,Bun Shell是一个bun内置的,跨平台的“类似于”bash的“外壳”。在这个外壳中,可以执行有限的指令,来达成和操作系统shell程序类似的功能。和操作系统提供的标准命令相比,bun shell可以提供一定的跨平台的功能,开发者不需要为这些简单的操作来达成不同平台的实现,此外,相对而言bun shell更加简单直接,容易实用,并且启动性能更好一点。

bash shell的一般操作

bun shell的一般应用和操作流程,可以参考下面的代码:

// 引用shell
import { $ } from "bun";

// 执行shell命令
await $`echo Hello, world!`; // => "Hello, world!"

// 获取执行结果
const output = await $`ls`.text();

// 变量结果
for await (const line of $`ls`.lines()) {
  console.log(line);
}

这个shell在bun中的名字叫“$”,使用前需要引用。然后使用一个字符字面量模板就可以执行,执行结果是一个response promise,然后按照普通输出方式来处理就可以了。

但是笔者觉得,直接使用$,看起来很酷(这不就是jquery嘛),但有时候代码看起来很奇怪,也容易出问题,需要再看看在应用程序里面使用的效果再来评价吧。

输出处理

和response一样,bun shell提供了很多方便的输出处理和处理方式:

// 输出 json
const result = await $`echo '{"foo": "bar"}'`.json();

console.log(result); // { foo: "bar" }

// 行处理
for await (let line of $`echo "Hello World!"`.lines()) {
  console.log(line); // Hello World!
}

// blob
const result = await $`echo "Hello World!"`.blob();
console.log(result); // Blob(13) { size: 13, type: "text/plain" }

错误处理

bun shell使用try-catch模式,来处理可能的错误:

import { $ } from "bun";

try {
  const output = await $`something-that-may-fail`.text();
  console.log(output);
} catch (err) {
  console.log(`Failed with code ${err.exitCode}`);
  console.log(err.stdout.toString());
  console.log(err.stderr.toString());
}

当然,bun shell可选不抛出或者处理错误:


const { stdout, stderr, exitCode } = await $`something-that-may-fail`
  .nothrow()
  .quiet();

if (exitCode !== 0) {
  console.log(`Non-zero exit code ${exitCode}`);
}

console.log(stdout);
console.log(stderr);

重定向和流水线

bun shell支持非常丰富灵活的输入输出重定向方式。而且使用简单强大的定向操作符就可以实现:

  • < 标准输入(stdin) 重定向
  • > 或 1> 标准输出重定向
  • 2> 重定向标准错误(stderr)
  • &> 重定向标准输出和标准错误
  • >> or 1>> 重定向stdout, 扩展(追加)模式
  • 2>> 重定向stderr, 扩展模式
  • &>> 扩展模式,stdout和stderr
  • 1>&2 将stdout重定向到stderr (都输出到stderr)
  • 2>&1 重定向stderr到stdout (都输出到stdout)

和linux系统下的定义相同,1是标准输出文件描述符,2是标准错误文件描述符(但好像没使用0文件描述符)。下面是一些简单的例子,涉及到常用的操作方式:

import { $ } from "bun";

// 输出到buffer
const buffer = Buffer.alloc(100);
await $`echo "Hello World!" > ${buffer}`;

console.log(buffer.toString()); // Hello World!\n

// 处理输入
const response = new Response("hello i am a response body");

const result = await $`cat < ${response}`.text();

console.log(result); // hello i am a response body

// 从文件输入
await $`cat < myfile.txt`;

// 输出错误到
await $`bun run index.ts 2> errors.txt`;

// err合并到标准输出
await $`bun run ./index.ts 2>&1`;

看起来,合理的使用重定向,在某些场景下,可以大幅度简化开发工作(如输出到文件)。

环境变量和工作目录

bun shell执行时,可以设置和使用环境变量:


// 设置和引用环境变量
await $`FOO=foo bun -e 'console.log(process.env.FOO)'`; // foo\n

// 环境变量操作
const foo = "bar123";

await $`FOO=${foo + "456"} bun -e 'console.log(process.env.FOO)'`; // bar123456\n

// 使用 $.env方法操作环境变量
$.env({ FOO: "bar" });

// the globally-set $FOO
await $`echo $FOO`; // bar

在bun shell执行时,也可以控制切换和使用工作目录:


// 切换并使用工作目录
await $`pwd`.cwd("/tmp"); // /tmp

// 使用 .cwd方法
$.cwd("/tmp");

// the globally-set working directory
await $`pwd`; // /tmp

局限性

按照bun技术文档的说法,这个shell是一个内部的简化实现,所以支持的标准命令有限,而且使用的方式也是有限的,包括了以下跨平台的shell命令:

cd: change the working directory, 变更当前文件夹
ls: list files in a directory, 列表文件
rm: remove files and directories, 删除文件或文件夹
echo: print text,打印文本
pwd: print the working directory, 打印当前工作文件夹
bun: run bun in bun, 运行bun
cat 输出信息
touch 创建文件
mkdir 创建文件夹
which 查找命令
mv 移动文件
exit 退出
true 
false
yes
seq
dirname
basename

笔者理解,这些命令,其实是跨平台的限制。在不同的平台上,还是可以使用对应平台的标准化的shell命令的。当然这就需要开放过程中充分测试和验证了。

笔者在测试这个功能时,还发现有另外一些限制。比如如果增加参数,"ls -l"就是没法正确执行的,只能用简单的方式执行:

 // 获取执行结果
8 | const output = await $`ls -l`.text();
                   ^
ShellError: Failed with exit code 1
 exitCode: 1,
   stdout: "",
   stderr: "usage: ls [-@ABCFGHILOPRSTUWabcdefghiklmnopqrstuvwxy1%,] [--color=when] [-D format] [file ...]\n",

      at new ShellError (13:16)
      at new ShellPromise (75:16)
      at BunShell (191:35)
      at /home/yanjh/l.ts:8:2

杂项特性

bun shell还有一些有趣的杂项特性,值得了解一下:

  • 嵌套执行

bun shell支持一种“嵌套”的命令执行方式,形式是“$(...)”,如下面的代码示例:

// Prints out the hash of the current commit
await $`echo Hash of current commit: $(git rev-parse HEAD)`;
  • brace expansion(大括号扩展)

笔者觉得,这好像是python的一个语法糖吧,方法是调用 $.braces方法,它就会按照规则,将命令展开为多个类似的命令来执行,这个操作在很多批处理的场合非常有效(好像JS中,没有类似的机制??):

await $.braces(`echo {1,2,3}`);
// => ["echo 1", "echo 2", "echo 3"]

  • pipeline 流水线

在bun shell命令中,也是支持linux风格的流水线操作符的:

const result = await $`echo "Hello World!" | wc -w`.text();
console.log(result); // 2\n

  • 转义

可以使用.escape方法,来定义转义;也可以使用raw对象,指定不转义的内容:

console.log($.escape('$(foo) `bar` "baz"'));
// => \$(foo) \`bar\` \"baz\"

await $`echo ${{ raw: '$(foo) `bar` "baz"' }}`;
// => bun: command not found: foo
// => bun: command not found: bar
// => baz

  • sh加载器

其实,bun除了执行js程序之外,也可以作为一个简单的sh执行器来使用:

script.sh
echo "Hello World! pwd=$(pwd)"

bun ./script.sh
Hello World! pwd=/home/demo

有意思的是,bun执行sh脚本,还可以是跨平台的。这对于提高程序的一致性是非常有益的。

OS Shell

如果不用ban shell,或者说bun shell的功能或者执行有限制,无法达成业务需求的时候,可能就需要退回到标准的shell命令执行方式了。

关于执行外部命令行程序,我们已经在前面的章节讨论过了。简单而言,就是需要使用Bun.spawn来封装一个命令行应用。但是这类需要注意的情况就是在不同的操作系统之上,可能需要进行不同的处理,才能达成类似的任务。因为它们确实就是运行在不同的操作系统环境当中。

下面是一个简单的例子,在不同的操作系统平台上,完成一些文件的合并工作:


const mergeFile = async(files,target)=>{

    let cmdList;
    if (process.platform.startsWith("win")) {
        cmdList = [ "cmd", "/c"];
        cmdList.push(`copy /b ${files} ${target}`);
    } else {
        cmdList = [ "sh","-c"];
        cmdList.push(`cat ${files} > ${target}`);
    }

    const proc = Bun.spawn(cmdList, { stdout: "pipe", stderr: "pipe" });

    const rtext = await new Response(proc.stdout).text();
    if (rtext) console.log("执行结果:\n", rtext);

    const err = await new Response(proc.stderr).text();    
    if (err) console.error("错误输出:\n", err);
};

// mergeFile("*.bin", "all.bin");

原理也不复杂,就是利用process.platform属性,判断当前程序所在的运行平台,然后决定选用不同的shell程序和需要执行的命令行指令。后续spawn的调用和处理,都是bun提供的机制,都是一样的。

这里也可以看到,由于需要使用操作系统提供的shell环境,它必然是先启动shell,然后再来执行shell命令,多了一个shell加载的过程,需要付出额外的启动时间和资源,效率上而言,应该是不如bun shell的。

小结

本文探讨了bun内置的shell特性,包括它的一般形式、输出输出、错误处理、重定向、环境变量和工作目录,以及局限性等方面的内容,最后还简单回顾了标准的OS Shell操作方面的内容。