我掌握了少數人才知道持續集成系統的日志密碼
前言
前段時間在使用 Travis CI 的時候發現它的部署日志包含了很多帶色彩的日志。
并且我們知道,在使用命令行終端的時候也會出現這些可愛的色彩。
當然我不是為了吹它而吹它,它是有實際的作用的,能夠幫助我們快速定位問題!
對此我就產生了好奇,Travis CI 是怎么把這些彩色日志搬到瀏覽器的?
我猜想肯定不是通過對關鍵字詞特征識別來做的,因為那樣太 low 了。
進行了查詢后,查到了一個終于查到了關鍵詞,它就是 ANSI escape sequences。
ANSI轉義序列是帶內信令的標準,用于控制終端和終端仿真器上的光標位置,顏色和一些其他選項。--維基百科
通俗地講,就是那些在終端輸出彩色的文字中包含了一些轉義序列字符,只不過我們看不到,被終端進行了解析。然后終端將這些字符解析成了我們現在看到的形形色色多彩的日志(包括一些顏色、下劃線、粗體等)。
例如,我們在終端進行npm 的安裝,git 分支的切換,包括運行報錯的時候都能看到。
正是有了這些色彩,讓我們的調試工作效率大大提高,一眼便能看到哪些命令出錯了,以及如何解決的方案。
現在我們要做的就是如何將這些色彩日志輸出到瀏覽器端。而進行這個步驟之前,我們得先知道,這些ANSI轉義序列的形態是什么樣子的?
根據wiki我們可以知道 ANSI 轉義序列可以操作很多功能,例如光標位置、顏色、下劃線和其他選項。下面我們就 顏色部分 來進行講解。
ANSI 轉義序列
ANSI 轉義序列 也是跟隨著終端的發展而發展,顏色的規范也是隨著設備的不同有所區別。例如在早期的設備只支持 3 / 4 Bit ,支持的顏色分別為 8 / 16 種。
ANSI 轉義序列大多數以 ESC 和'['開頭嵌入到文本中,終端會查找并解釋為命令,而不是字符串。
ESC 的 ANSI 值為 27 ,8進制表示為 \033 ,16進制表示為 \u001B。
3/4 bit
原始規格只有 8/16 種顏色。
比如ESC[30;47m 它是以 ESC[ 開頭 m 結束,中間為code碼,以分號進行分割。
color 取值為30-37,background 取值為 40-47。例如 :
- echo -e "\u001B[31m hello"
(如果想要清除顏色就需要使用 ESC [39;49m(某些終端不支持) 或者ESC[0m )
后來的終端增加了直接指定 90-97 和 100-107 的“明亮”顏色的能力。
效果如下:
以下是其色彩對照表:
8-bit
后來由于256色在顯卡上很常見,因此添加了轉義序列以從預定義的256種顏色中進行選擇,也就是說在原來的書寫方式上增加了新的一位來代表更多的顏色。
- ESC[ 38;5;<n> m // 設置字體顏色
- ESC[ 48;5;<n> m // 設置背景顏色
- 0-7: standard colors (as in ESC [ 30–37 m)
- 8-15: high intensity colors (as in ESC [ 90–97 m)
- 16-231: 6 × 6 × 6 cube (216 colors): 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
- 232-255: grayscale from black to white in 24 steps
在支持更多色彩的終端中,例如:
- echo -e "\u001B[38;5;11m hello"
代表輸出黃色字體。
- echo -e "\u001B[48;5;14;38;5;13m hello"
代表輸出藍色背景,粉紅色字體。
以下是其色彩對照表:
24-bit
再往后發展就是支持 24 位真彩的顯卡,Xterm, KDE 的Konsole,以及所有基于 libvte 的終端(包括GNOME終端)支持24位前景和背景顏色設置。
- ESC[ 38;2;<r>;<g>;<b>m // 前景色
- ESC[ 48;2;<r>;<g>;<b>m // 背景色
例如:
- echo -e "\u001B[38;2;100;228;75m hello"
輸出綠色的字體代表 rgb(100,228,75)。
解析工具
我們知道了轉義的規范后,那么我們需要將 ANSI 字符進行解析。
由于規范比較多,因此我們先調研一下在 js 中常用的色彩庫,來進行一個小小的探索。
由于 3 / 4bit 的兼容性更好,大多數工具(如chalk)會采用這 8 / 16 色來做高亮,因此我們先實現一個 8 / 16 色的解析。
這里參考了 ansiparse 這個解析庫:
核心思路為:
- const ansiparse = require('ansiparse')
- const ansiStr = "\u001B[34mHello \u001B[39m World \u001B[31m! \u001B[39m"
- const json = ansiparse(ansiStr)
- console.log(json)
- // json輸出如下:
- [
- { foreground: 'blue', text: 'Hello ' },
- { text: ' World ' },
- { foreground: 'red', text: '! ' }
- ]
然后我們可以寫一個函數來遍歷上面解析得到的 JSON數組,輸出 HTML。
- function createHtml(ansiList, wrap = '') {
- let html = '';
- for (let i = 0; i < ansiList.length; i++) {
- const htmlFrame = ansiList[i];
- const {background = '', text, foreground = ''} = htmlFrame;
- if(background && foreground) {
- if(text.includes('\n')) {
- html += wrap;
- continue;
- }
- html += fontBgCode(text, foreground, background);
- continue;
- }
- if (background || foreground) {
- const color = background ? `bg-${background}` : foreground;
- let textColor = bgCode(text, color);
- textColor = textColor.replace(/\n/g, wrap);
- html += textColor;
- continue;
- }
- if (text.includes('\n')) {
- const textColor = text.replace(/\n/g, wrap);
- html += textColor;
- continue;
- }
- html += singleCode(text);
- }
- html += ''
- return html;
- }
- function fontBgCode(value, color, bgColor) {
- return `<span class="${color} bg-${bgColor}">${value}</span>`
- }
- function bgCode(value, color) {
- return `<span class="${color}">${value}</span>`
- }
- function singleCode(value) {
- return `<span>${value}</span>`
- }
使用示例如下:
- const str = "\u001B[34mHello \u001B[39m World \u001B[31m! \u001B[39m";
- console.log(createHtml(parseAnsi(str)));
- // <span class="blue">Hello</span><span> World</span><span class="red">!</span>
部署實戰
有了上面的部分我們就來用一個簡單的demo實際演示一下部署日志吧!
- // 項目目錄結構
- demo
- |- package.json
- |- index.html
- |- webpack.config.js
- |- /src
- |- index.js
- index.js
- build.sh
我們在 index.js 中啟動一個 build 腳本,來模擬一下我們真實的部署場景。
- const { spawn } = require('child_process');
- const cmd = spawn('sh', ['build.sh']);
- cmd.stdout.on('data', (data) => {
- console.log(`stdout: ${data}`);
- });
- cmd.stderr.on('data', (data) => {
- console.log(`stderr: ${data}`);
- });
- cmd.on('close', (code) => {
- console.log(`child process exited with code ${code}`);
- });
- // build.sh
- cd demo
- npx webpack
我們在終端嘗試一下,控制臺輸入 node index.js
發現在輸出的日志中,并沒有看到對應的色彩。
為什么從 child_process 為什么無法輸出色彩,而我們如果在終端中直接打包項目卻能夠輸出色彩呢?
Why?
第一反應就是去查找根源,也就是使用頻率最高的幾個色彩輸出的庫。
以簡單的方式給控制臺的輸出標記顏色。
https://github.com/Marak/colors.js
https://github.com/chalk/chalk
在看了webpack-cli的源碼后,查到它是用了colorette作為色彩輸出庫的。
那么我們就來查看一下colorette的源碼一探究竟。
在入口文件的開頭就看到一個變量isColorSupported來判斷是否支持色彩輸出。
https://github.com/jorgebucaran/colorette/blob/main/index.js#L17
- // colorette/index.js
- import * as tty from "tty"
- const env = process.env || {}
- const argv = process.argv || []
- const isDisabled = "NO_COLOR" in env || argv.includes("--no-color")
- const isForced = "FORCE_COLOR" in env || argv.includes("--color")
- const isWindows = process.platform === "win32"
- const isCompatibleTerminal = tty && tty.isatty && tty.isatty(1) && env.TERM && env.TERM !== "dumb"
- const isCI = "CI" in env && ("GITHUB_ACTIONS" in env || "GITLAB_CI" in env || "CIRCLECI" in env)
- export const isColorSupported = !isDisabled && (isForced || isWindows || isCompatibleTerminal || isCI)
可以看到這種工具判斷了很多條件,來對我們的輸出流進行處理。
在以上條件成立下,才會輸出 ANSI 日志。在不滿足以上情況的條件下,就會切換輸出更容易解析的方式。
const isWindows = process.platform === "win32"
參考:https://stackoverflow.com/questions/8683895/how-do-i-determine-the-current-operating-system-with-node-js
dumb: "啞終端"
啞終端指不能執行諸如“刪行”、“清屏”或“控制光標位置”的一些特殊ANSI轉義序列的計算機終端
參考:https://zh.wikipedia.org/wiki/%E5%93%91%E7%BB%88%E7%AB%AF
也就是說我們的 child_process 的輸出流關閉了終端模式(TTY),上面的四種情況都不滿足。所以我們得不到帶有 ANSI 的色彩日志。
How?
我們可以顯示傳入環境變量 FORCE_COLOR=1 或者命令帶上參數 --color 強制啟動顏色來解決這個問題。
這樣我們就拿到了帶有 ANSI 顏色信息的輸出文本,最終解析得到 HTML。
- <div>asset <span class="green">main.js</span><span> 132 bytes </span><span class="yellow">[compared for emit]</span><span> </span><span class="green">[minimized]</span> (name: main)</div><div><span>./src/index.js</span><span> 289 bytes </span><span class="yellow">[built]</span><span> </span><span class="yellow">[code generated]</span></div><div></div><div><span class="yellow">WARNING</span><span> in </span>configuration</div><div>The <span class="red">'mode' option has not been set</span>, webpack will fallback to 'production' for this value.</div><div><span class="green">Set 'mode' option to 'development' or 'production'</span> to enable defaults for each environment.</div><div>You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/</div><div></div><div>webpack 5.53.0 compiled with <span class="yellow">1 warning</span> in 201 ms</div><div></div>
然后就可以在瀏覽器中展示我們彩色的輸出日志了,與在終端里輸出的一致。
參考
https://www.twilio.com/blog/guide-node-js-logging
https://github.com/jorgebucaran/colorette/blob/main/index.js#L17
https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
https://stackoverflow.com/questions/4842424/list-of-ansi-color-escape-sequences
https://stackoverflow.com/questions/15011478/ansi-questions-x1b25h-and-x1be
https://bluesock.org/~willg/dev/ansi.html
https://www.cnblogs.com/gamesky/archive/2012/07/28/2613264.html
https://github.com/mmalecki/ansiparse