专栏名称: python
隔天更新python文章,我希望用我的努力换来劳动的成果帮助更多的人掌握一门技术,因此我要更加努力。
目录
相关文章推荐
Python爱好者社区  ·  “给我滚出贵大!”郑强出任贵州大学校长,打算 ... ·  3 天前  
Python爱好者社区  ·  DeepSeek 最新中国大学排名 ·  昨天  
Python开发者  ·  国产 DeepSeek V3 ... ·  5 天前  
Python爱好者社区  ·  史上最强!PINN杀疯了 ·  4 天前  
Python爱好者社区  ·  英伟达憾失DeepSeek关键人才?美国放走 ... ·  4 天前  
51好读  ›  专栏  ›  python

震惊!原来命令行还可以这么玩?!

python  · 公众号  · Python  · 2017-11-25 14:14

正文

引言

你是否:

  • 好奇过命令行里那些花里胡哨的进度条是如何实现的?

  • 好奇过Spring Boot为什么能够打印五颜六色的日志?

  • 好奇过Python或者PHP等脚本语言的交互式命令行是如何实现的?

  • 好奇过Vim或者Emacs等在Terminal中的编辑器是怎么实现的?

如果你曾经好奇过,或者被这段话勾起了你的好奇心,那么你绝对不能错过这篇文章!

背景

通过本文你可以学到:

  1. 何为Ansi Escape Codes以及它们能干什么?

  2. Ansi Escape Codes的一些高级应用。

  3. JDK9中Jshell的使用。

环境

  • Mac或Linux或者WIn10操作系统。 除了Win10之外的Windows系统暂时不支持Ansi Escape Codes。

  • 因为本文采用Jshell作为演示工具,所以大家需要安装最近刚正式发布的JDK9。

OK!一切准备就绪,让我们开始吧!


富文本

Ansi Escape Codes最基础的用途就是让控制台显示的文字以富文本的形式输出,比如设置字体颜色、背景颜色以及各种样式。让我们先来学习如何设置字体颜色,而不用再忍受那枯燥的黑白二色!

字体颜色

通过Ansi指令(即Ansi Escape Codes)给控制台的文字上色是最为常见的操作。比如:

  • 红色: \u001b[31m

  • 重置: \u001b[0m

绝大部分Ansi Escape Codes都以 \u001b 开头。让我们通过Java代码来输出一段红色的 Hello World

System.out.print("\u001b[31mHello World");

从上图中,我们可以看到,不仅 Hello World 是变成了红色,而且接下来的 jshell> 提示符也变成了红色。其实不管你接下来输入什么字符,它们的字体颜色都是红色。直到你输入了其他颜色的Ansi指令,或者输入了重置指令,字体的颜色才会不再是红色。

让我们尝试输入重置指令来恢复字体的颜色:


1

System.out.print("\u001b[0m");


很好! jshell> 提示符恢复为了白色。所以一个最佳实践就是,最好在所有改变字体颜色或者样式的Ansi Escape Codes的最后加上重置指令,以免造成意想不到的后果。举个例子:


1

System.out.print("\u001b[31mHello World\u001b[0m");


当然,重置指令可以被添加在任何位置,比如我们可以将其插在 Hello World 的中间,使得 Hello 是红色,但是 World 是白色:


1

System.out.print("\u001b[31mHello\u001b[0m World");


8色

刚才我们介绍了 红色 以及 重置 命令。基本上所有的控制台都支持以下8种颜色:

  • 黑色: \u001b[30m

  • 红色: \u001b[31m

  • 绿色: \u001b[32m

  • 黄色: \u001b[33m

  • 蓝色: \u001b[34m

  • 洋红色: \u001b[35m

  • 青色: \u001b[36m

  • 白色: \u001b[37m

  • 重置: \u001b[0m

不如将它们都输出看一下:


1

2

System.out.print("\u001b[30m A \u001b[31m B \u001b[32m C \u001b[33m D \u001b[0m");

System.out.print("\u001b[34m E \u001b[35m F \u001b[36m G \u001b[37m H \u001b[0m");


注意, A 因为是黑色所以与控制台融为一体了。

16色

大多数的控制台,除了支持刚才提到的8色外,还可以输出在此之上更加明亮的8种颜色:

  • 亮黑色: \u001b[30;1m

  • 亮红色: \u001b[31;1m

  • 亮绿色: \u001b[32;1m

  • 亮黄色: \u001b[33;1m

  • 亮蓝色: \u001b[34;1m

  • 亮洋红色: \u001b[35;1m

  • 亮青色: \u001b[36;1m

  • 亮白色: \u001b[37;1m

亮色指令分别在原来对应颜色的指令中间加上 ;1 。我们将所有的16色在控制台打印,方便大家进行比对:


1

2

3

4

System.out.print("\u001b[30m A \u001b[31m B \u001b[32m C \u001b[33m D \u001b[0m");

System.out.print("\u001b[34m E \u001b[35m F \u001b[36m G \u001b[37m H \u001b[0m");

System.out.print("\u001b[30;1m A \u001b[31;1m B \u001b[32;1m C \u001b[33;1m D \u001b[0m");

System.out.print("\u001b[34;1m E \u001b[35;1m F \u001b[36;1m G \u001b[37;1m H \u001b[0m");



从图中我们可以清晰地看到,下面的8色比上面的8色显得更加明亮。比如,原来黑色的 A ,在黑色的控制台背景下,几乎无法看到,但是一旦通过亮黑色输出后,对比度变得更高,变得更好辨识了。

256色

最后,除了16色外,某些控制台支持输出256色。指令的形式如下:

  • \u001b[38;5;${ID}m

让我们输出256色矩阵:


1

2

3

4

5

6

7

for (int i = 0; i < 16; i++) {

for (int j = 0; j < 16; j++) {

int code = i * 16 + j;

System.out.printf("\u001b[38;5;%dm%-4d", code, code);

}

System.out.println("\u001b[0m");

}


关于字体颜色我们就介绍到这,接下来我们来介绍背景色。

背景颜色

刚才所说的字体颜色可以统称为前景色(foreground color)。那么理所当然,我们可以设置文本的背景颜色:

  • 黑色背景: \u001b[40m

  • 红色背景: \u001b[41m

  • 绿色背景: \u001b[42m

  • 黄色背景: \u001b[43m

  • 蓝色背景: \u001b[44m

  • 洋红色背景: \u001b[45m

  • 青色背景: \u001b[46m

  • 白色背景: \u001b[47m

对应的亮色版本:

  • 亮黑色背景: \u001b[40;1m

  • 亮红色背景: \u001b[41;1m

  • 亮绿色背景: \u001b[42;1m

  • 亮黄色背景: \u001b[43;1m

  • 亮蓝色背景: \u001b[44;1m

  • 亮洋红色背景: \u001b[45;1m

  • 亮青色背景: \u001b[46;1m

  • 亮白色背景: \u001b[47;1m

首先让我们看看16色背景:


1

2

3

4

System.out.print("\u001b[40m A \u001b[41m B \u001b[42m C \u001b[43m D \u001b[0m");

System.out.print("\u001b[44m A \u001b[45m B \u001b[46m C \u001b[47m D \u001b[0m");

System.out.print("\u001b[40;1m A \u001b[41;1m B \u001b[42;1m C \u001b[43;1m D \u001b[0m");

System.out.print("\u001b[44;1m A \u001b[45;1m B \u001b[46;1m C \u001b[47;1m D \u001b[0m");


值得注意的是,亮色背景并不是背景颜色显得更加明亮,而是让对应的前景色显得更加明亮。虽然这点有点不太直观,但是实际表现就是如此。

让我们再来试试256背景色,首先指令如下:

  • \u001b[48;5;${ID}m

同样输出256色矩阵:

for (int i = 0; i < 16; i++) {

for (int j = 0; j < 16; j++) {

int code = i * 16 + j;

System.out.printf("\u001b[48;5;%dm%-4d", code, code);

}

System.out.println("\u001b[0m");

}

感觉要被亮瞎眼了呢!至此,颜色设置已经介绍完毕,让我们接着学习样式设置。

样式

除了给文本设置颜色之外,我们还可以给文本设置样式:

  • 粗体: \u001b[1m

  • 下划线: \u001b[4m

  • 反色: \u001b[7m

样式分别使用的效果:


1

System.out.print("\u001b[1m BOLD \u001b[0m\u001b[4m Underline \u001b[0m\u001b[7m Reversed \u001b[0m");


或者结合使用:

System.out.print("\u001b[1m\u001b[4m\u001b[7m BOLD Underline Reversed \u001b[0m");

甚至还可以和颜色结合使用:


1

2

System.out.print("\u001b[1m\u001b[31m Red Bold \u001b[0m");

System.out.print("\u001b[4m\u001b[44m Blue Background Underline \u001b[0m");


是不是很简单,是不是很酷!学会了这些,我们已经能够写出十分酷炫的命令行脚本了。但是如果要实现更复杂的功能(比如进度条),我们还需要掌握更加牛逼的光标控制指令!

光标控制

Ansi Escape Code里更加复杂的指令就是光标控制。通过这些指令,我们可以自由地移动我们的光标至屏幕的任何位置。比如在Vim的命令模式下,我们可以使用 H/J/K/L 这四个键实现光标的上下左右移动。

最基础的光标控制指令如下:

  • 上: \u001b[{n}A

  • 下: \u001b[{n}B

  • 右: \u001b[{n}C

  • 左: \u001b[{n}D

通过光标控制的特性,我们能够实现大量有趣且酷炫的功能。首先我们来看看怎么实现一个进度条。

进度数字显示

作为进度条,怎么可以没有进度数字显示呢?所以我们先来实现进度条进度数字的刷新:


1

2

3

4

5

6

7

void loading() throws InterruptedException {

System.out.println("Loading...");

for (int i = 1; i <= 100; i++) {

Thread.sleep(100);

System.out.print("\u001b[1000D" + i + "%");

}

}


从图中我们可以看到,进度在同一行从1%不停地刷新到100%。为了进度只在同一行显示,我们在代码中使用了 System.out.print 而不是 System.out.println 。在打印每个进度之前,我们使用了 \u001b[1000D 指令,目的是为了将光标移动到当前行的最左边也就是行首。然后重新打印新的进度,新的进度数字会覆盖刚才的进度数字,循环往复,这就实现了上图的效果。

PS: \u001b[1000D 表示将光标往左移动1000个字符。这里的1000表示光标移动的距离,只要你能够确保光标能够移动到最左端,随便设置多少比如设置2000都可以。

为了方便大家更加轻松地理解光标的移动过程,让我们放慢进度条刷新的频率:


1

2

3

4

5

6

7

8

9

void loading() throws InterruptedException {

System.out.println("Loading...");

for (int i = 1; i <= 100; i++) {

System.out.print("\u001b[1000D");

Thread.sleep(1000);

System.out.print(i + "%");

Thread.sleep(1000);

}

}


现在我们可以清晰地看到:

  1. 从左到右打印进度,光标移至行尾。

  2. 光标移至行首,原进度数字还在。

  3. 从左到右打印新进度,新的数字会覆盖老的数字。光标移至行尾。

  4. 循环往复。

Ascii进度条

好了,我们现在已经知道如何通过Ansi Escape Code实现进度数字的显示和刷新,剩下的就是实现进度的读条。废话不多说,我们直接上代码和效果图:


1

2

3

4

5

6

7

8

9

10

void loading() throws InterruptedException {

System.out.println("Loading...");

for (int i = 1; i <= 100; i++) {

int width = i / 4;

String left = "[" + String.join("", Collections.nCopies(width, "#"));

String right = String.join("", Collections.nCopies(25 - width, " ")) + "]";

System.out.print("\u001b[1000D" + left + right);

Thread.sleep(100);

}

}


由上图我们可以看到,每次循环过后,读条就会增加。原理和数字的刷新一样,相信大家阅读代码就能理解,这里就不再赘述。

让我们来点更酷的吧!利用Ansi的光标 向上 以及 向下 的指令,我们还可以同时打印出多条进度条:


1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

void loading(int count) throws InterruptedException {

System.out.print(String.join("", Collections.nCopies(count, "\n"))); // 初始化进度条所占的空间

List allProgress = new ArrayList<>(Collections.nCopies(count, 0));

while (true) {

Thread.sleep(10);

// 随机选择一个进度条,增加进度

List unfinished = new LinkedList<>();

for (int i = 0; i < allProgress.size(); i++) {

if (allProgress.get(i) < 100) {

unfinished.add(i);

}

}

if (unfinished.isEmpty()) {

break;

}

int index = unfinished.get(new Random().nextInt(unfinished.size()));

allProgress.set(index, allProgress.get(index) + 1); // 进度+1

// 绘制进度条

System.out.print("\u001b[1000D"); // 移动到最左边

System.out.print("\u001b[" + count + "A"); // 往上移动

for (Integer progress : allProgress) {

int width = progress / 4;

String left = "[" + String.join("", Collections.nCopies(width, "#"));

String right = String.join("", Collections.nCopies(25 - width, " ")) + "]";

System.out.println(left + right);

}

}

}


在上述代码中:

  • 我们首先执行 System.out.print(String.join("", Collections.nCopies(count, "\n"))); 打印出多个空行,这可以保证我们有足够的空间来打印进度条。

  • 接下来我们随机增加一个进度条的进度,并且打印出所有进度条。

  • 最后我们调用 向上 指令,将光标移回到最上方,继续下一个循环,直到所有进度条都到达100%。

实际效果如下:

效果真是太棒啦!剩下将读条和数字结合在一起的工作就交给读者啦。学会了这招,当你下次如果要做一个在命令行下载文件的小工具,这时候这些知识就派上用场啦!

制作命令行

最后,最为酷炫的事情莫过于利用Ansi Escape Codes实现一个个性化的命令行(Command-Line)。我们平常使用的Bash以及一些解释型语言比如Python、Ruby等都有自己的REPL命令行。接下来,让我们揭开他们神秘的面纱,了解他们背后实现的原理。

PS:由于在Jshell中,方向键、后退键等一些特殊键有自己的作用,所以接下来无法通过Jshell演示。需要自己手动进行编译运行代码才能看到实际效果。

一个最简单的命令行

首先,我们来实现一个最简单的命令行,简单到只实现下面两种功能:

  • 当用户输入一个可打印的字符时,比如abcd等,则在控制台显示。

  • 当用户输入回车时,另起一行,输出刚才用户输入的所有字符,然后再另起一行,继续接受用户的输入。

那么这个最简单的命令行的实现代码会长这样:


1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

import java.io.IOException;

public class CommandLine {

public static void main(String[] args) throws IOException, InterruptedException {

// 设置命令行为raw模式,否则会自动解析方向键以及后退键,并且直到按下回车read方法才会返回

String[] cmd = { "/bin/sh", "-c", "stty raw };

Runtime.getRuntime()

.exec(cmd)

.waitFor();

while (true) {

String input = "";

while (true) {

char ch = (char) System.in.read();

if (ch == 3) {

// CTRL-C

return;

}

else if (ch >= 32 && ch <= 126) {

// 普通字符

input += ch;

}

else if (ch == 10 || ch == 13) {

// 回车

System.out.println();

System.out.print("\u001b[1000D");

System.out.println("echo: " + input);

input = "";

}

System.out.print("\u001b[1000D"); // 首先将光标移动到最左侧

System.out.print(input); // 重新输出input

System.out.flush();

}

}

}

}


好的,让我们来说明一下代码中的关键点:

  1. 首先最关键的是我们需要将我们的命令行设置为raw模式,这可以避免JVM帮我们解析方向键,回退键以及对用户输入进行缓冲。大家可以试一下不设置raw模式然后看一下效果,就可以理解我说的话了。

  2. 通过 System.in.read() 方法获取用户输入,然后对其ascii值进行分析。

  3. 如果发现用户输入的是回车的话,我们这时需要打印刚才用户输入的所有字符。但是我们需要注意,由于设置了raw模式,不移动光标直接打印的话,光标的位置不会移到行首,如下图:

    所以这里需要再次调用 System.out.print("\u001b[1000D"); 将光标移到行首。

好了,让我们来看一下效果吧:

成功了!但是有个缺点,那就是命令行并没有解析方向键,反而以 [D[A[C[B 输出(见动图)。这样我们只能一直往后面写而无法做到将光标移动到前面实现插入的效果。所以接下来就让我们给命令行加上解析方向键的功能吧!

光标移动

简单起见,我们仅需实现按下方向键的左右两键时能控制光标左右移动。左右两键对应的ascii码分别为 27 91 68 27 91 67 。所以我们只要在代码中加上对这两串ascii码的解析即可:

import java.io.IOException;

public class CommandLine {

public static void main(String[] args) throws IOException, InterruptedException {

// 设置命令行为raw模式,否则会自动解析方向键以及后退键,并且直到按下回车read方法才会返回

String[] cmd = { "/bin/sh", "-c", "stty raw };

Runtime.getRuntime()

.exec(cmd)

.waitFor();

while (true) {

String input = "";

int index = 0;

while (true) {

char ch = (char) System.in.read();

if (ch == 3) {

// CTRL-C

return;

}

else if (ch >= 32 && ch <= 126) {

// 普通字符

input = input.substring(0, index) + ch + input.substring(index, input.length());

index++;

}

else if (ch == 10 || ch == 13) {

// 回车

System.out.println();

System.out.print("\u001b[1000D");

System.out.println("echo: " + input);

input = "";

index = 0;

}

else if (ch == 27) {

// 左右方向键

char next1 = (char) System.in.read();

char next2 = (char) System.in.read();

if (next1 == 91) {

if







请到「今天看啥」查看全文