awk语法太难记了
awk作为一个文本处理的常用指令,功能非常强大,我们之前视频介绍过。个人感觉awk唯一的缺点就是其语法是独立的,与任何已存在的语言的用法都不一样,或者说awk本身就是一门编程语言。我每次能准确记住的语法是这样,print是打印的表达式,$1代表按照空格分隔的第一列。
awk '{print $1}' xxx
那现在如果要加上正则匹配,字符串替换,字符串截取,我想我能记得对应的函数名,但是记不住函数的每一个参数的含义了,即使我之前还盘点过awk指令的用法。主要是大多数函数并不经常使用,只有print $n这种最常用的能被记住,其他的可能都在让gpt帮我写了。
awk其实是一个很简单的程序,他的基本思路就是单线程逐行读取文件,对文件的每一行进行处理,处理的逻辑是命令行传入的。于是我就在想,能不能自己写一个程序,把awk '{执行逻辑}' filename中,执行逻辑的部分换成我们熟悉的编程语言的语法呢?比如javascript。
rust + quickjs
为了更好的性能,我觉得使用rust来写比较合适,于是用rust简单组织了这个应用的框架,通过clap库来处理命令行的参数接收jtxt [option] function [file],我们的应用至少接收一个function参数是处理逻辑,file可选参数是文件,如果没有指定文件,则从stdin中读取,这样和awk一样可以支持管道处理,最后option参数支持-b和-e分别制定一段开始和结束时候执行的逻辑,这与awk的BEGIN、END一样。
那接下来要做的是逐行处理文件,然后把当前行的字符串line,由js解释器来处理,所以这里还需要一个js解释器,rust中有个很轻量的解释器quick-js库,那么只需要clap和quick-js两个库就可以把代码简单组织起来了。
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use quick_js::{Context, JsValue};
use clap::{Arg, Command};
use std::thread;
use std::sync::mpsc;
fn main() {
// 定义参数形式为 jtxt [option] function [file]
let matches = Command::new("Flat")
.version("1.0")
.author("Your Name <sunwu51@126.com>")
.about("Processes lines of input with JavaScript")
.arg(Arg::new("begin")
.short('b')
.long("begin")
.help("JavaScript code to execute before processing any lines")
.value_parser(clap::builder::ValueParser::string()))
.arg(Arg::new("end")
.short('e')
.long("end")
.help("JavaScript code to execute after processing all lines")
.value_parser(clap::builder::ValueParser::string()))
.arg(Arg::new("function")
.help("JavaScript code to process each line, l is the origin string")
.required(true)
.index(1))
.arg(Arg::new("file")
.help("Path to the input file. If not provided, reads from stdin.")
.index(2))
.get_matches();
let begin_code = matches.get_one::<String>("begin").cloned();
let end_code = matches.get_one::<String>("end").cloned();
let js_function = matches.get_one::<String>("function").unwrap().clone();
let file_path = matches.get_one::<String>("file").cloned();
// 创建JavaScript 执行上下文
let context = Context::new().unwrap();
// 创建全局变量
let _ = context.eval("var ctx = {}, n1=0, n2=0, n3=0, s='', arr=[];");
// 执行begin的代码块,如果有的话
if let Some(code) = &begin_code {
if let Err(e) = context.eval(code) {
eprintln!("Error executing BEGIN JS: {:?}", e);
}
}
// 创建通道
let (sender, receiver) = mpsc::channel();
// 在新线程中读取文件
let reader_thread = thread::spawn(move || {
let input: Box<dyn BufRead> = if let Some(path) = file_path {
Box::new(BufReader::new(File::open(path).expect("Failed to open file")))
} else {
Box::new(io::stdin().lock())
};
for line in input.lines() {
if let Ok(line) = line {
if sender.send(line).is_err() {
break;
}
}
}
});
// 注册为process函数
let _ = context.eval(&format!("var process = function() {{ {} }};", js_function));
// 在主线程中处理每一行
for line in receiver {
let _ = context.set_global("l", line);
// 将每一行传递给 JavaScript 函数
let _ = context.eval("process()");
}
// 等待读取线程结束
reader_thread.join().unwrap();
// Execute end code if provided
if let Some(code) = end_code {
let result = context.eval(&code);
// 处理执行结果
match result {
Ok(JsValue::String(s)) => println!("{}", s),
Ok(JsValue::Int(s)) => println!("{}", s),
Ok(JsValue::Bool(s)) => println!("{}", s),
Ok(JsValue::Float(s)) => println!("{}", s),
Ok(JsValue::Array(s)) => println!("{:?}", s),
Ok(JsValue::Object(s)) => println!("{:?}", s),
Ok(_) => (),
Err(e) => eprintln!("Error executing JS: {:?}", e),
}
}
}
# 准备一个100w行的文本文件
gitpod /workspace/jtxt (base) $ head -1000000 largefile.txt >> 100w.txt
gitpod /workspace/jtxt (base) $ wc -l 100w.txt
1000000 100w.txt
gitpod /workspace/jtxt (base) $ du -h 100w.txt
165M 100w.txt
# 执行统计字母个数的简单程序
gitpod /workspace/jtxt (base) $ time cargo run -r -- 'n1+=l.length;' -e 'n1' 100w.txt
Finished `release` profile [optimized] target(s) in 0.09s
Running `target/release/jtxt 'n1+=l.length;' -e n1 100w.txt`
171980954
real 0m2.569s
user 0m2.208s
sys 0m0.599s
# 对比awk执行相同操作的耗时
gitpod /workspace/jtxt (base) $ time awk '{n1+=length($0)}END {print n1}' 100w.txt
171980954
real 0m0.509s
user 0m0.441s
sys 0m0.067s
我们发现我们程序耗时2s多,而awk不足1s,并且还有个很要命的问题,当处理的文件特别大的时候,进程的内存占用飙升,猜测是quick-js在运行时存活阶段不会进行垃圾回收导致不断读取文件的内容,内存持续上升,读取160M文件占用了200M+内存,当我尝试读取10G文件的时候,内存占用超过7G,并且10G文件是160M的60倍,按理说应该2min左右能处理完成,但实际如下运行了3分多钟也没有运行完,猜测是quickjs没有gc导致前面的内存无法释放,进程卡死在这里了。
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
16271 gitpod 20 0 12.6g 7.7g 2584 D 64.5 12.2 3:22.92 jtxt
最后对10G的大文件的字数统计运行了6min,awk对10G大文件处理32s,并且内存占用100M以内。
gitpod /workspace/jtxt (base) $ time cargo run -r -- 'n1+=l.length;' -e 'n1' largefile.txt
Finished `release` profile [optimized] target(s) in 0.09s
Running `target/release/jtxt 'n1+=l.length;' -e n1 largefile.txt`
10675354954
real 6m3.952s
user 2m32.773s
sys 1m52.977s
# awk32s
gitpod /workspace/jtxt (base) $ time awk '{n1+=length($0)}END {print n1}' largefile.txt
10675354954
real 0m32.940s
user 0m28.976s
sys 0m3.900s
由此可见,上述方案虽然除了性能比awk要差一些,主要还有资源占用问题,以及大文件会处理变慢的问题。
rust + deno_core
想到了资源回收,就想起了js runtime v8引擎,而deno正好是rust写的,果然就找到了核心库deno_core,把quickjs换成这个,应该会显著提升效率。
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use clap::{Arg, Command};
use deno_core::JsRuntime;
use deno_core::RuntimeOptions;
// use deno_core::serde_v8;
use deno_core::v8;
fn main() {
// 定义参数形式为 jtxt [option] function [file]
let matches = Command::new("jtxt")
.version("1.0")
.author("Your Name <sunwu51@126.com>")
.about("Processes lines of input with JavaScript")
.arg(Arg::new("begin")
.short('b')
.long("begin")
.help("JavaScript code to execute before processing any lines")
.value_parser(clap::builder::ValueParser::string()))
.arg(Arg::new("end")
.short('e')
.long("end")
.help("JavaScript code to execute after processing all lines")
.value_parser(clap::builder::ValueParser::string()))
.arg(Arg::new("function")
.help("JavaScript code to process each line, l is the origin string")
.required(true)
.index(1))
.arg(Arg::new("file")
.help("Path to the input file. If not provided, reads from stdin.")
.index(2))
.get_matches();
let begin_code = matches.get_one::<String>("begin");
let end_code = matches.get_one::<String>("end").cloned();
let js_function = matches.get_one::<String>("function").unwrap();
let file_path = matches.get_one::<String>("file");
// 创建JavaScript 执行上下文
let mut runtime = JsRuntime::new(RuntimeOptions::default());
// 创建全局变量
runtime.execute_script("<anon>", "var ctx = {}, n1=0, n2=0, n3=0, s='', arr=[];").expect("Eval failed");
// 执行begin的代码块,如果有的话
if let Some(code) = begin_code {
runtime.execute_script("<anon>", code.to_string()).expect("Eval failed");
}
// 逐行读取文件或者管道内容
let input: Box<dyn BufRead> = if let Some(path) = file_path {
Box::new(BufReader::new(File::open(path).expect("Failed to open file")))
} else {
Box::new(io::stdin().lock())
};
runtime.execute_script("<anon>",
format!("function process(){{ {} }}", js_function)).expect("Eval failed");
for line in input.lines() {
let line = line.unwrap();
{
// 创建一个 V8 值
let scope = &mut runtime.handle_scope();
let value = v8::String::new(scope, &line).unwrap();
// 将 V8 值绑定到全局对象
let global = scope.get_current_context().global(scope);
let l = v8::String::new(scope, "l").unwrap().into();
global.set(scope, l, value.into());
}
runtime.execute_script("<anon>", "process()").expect("Eval failed");
}
if let Some(code) = end_code {
runtime.execute_script("<anon>", code).expect("Eval failed");
}
}
对160M和10G文件统计字数,可以看到内存占用一直在100M以内,而160M文件处理速度是2s以内,10G是2min以内,已经和awk的40s非常接近了。
# 内存占用一直在100M以内
$ top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28640 gitpod 20 0 33.5g 62864 18428 R 101.0 0.1 0:27.12 jtxt
gitpod /workspace/jtxt (base) $ time cargo run -r -- 'n1+=l.length' -e 'console.log(n1)' 100w.txt
Finished `release` profile [optimized] target(s) in 0.11s
Running `target/release/jtxt n1+=l.length -e 'console.log(n1)' 100w.txt`
171980954
real 0m1.882s
user 0m1.745s
sys 0m0.149s
gitpod /workspace/jtxt (base) $ time cargo run -r -- 'n1+=l.length' -e 'console.log(n1)' largefile.txt
Finished `release` profile [optimized] target(s) in 0.09s
Running `target/release/jtxt n1+=l.length -e 'console.log(n1)' largefile.txt`
10675354954
real 1m47.251s
user 1m43.004s
sys 0m3.177s
这个方案性能比awk差几倍,但是整体还可以接受,并且也没有过多的资源占用问题。
rust + mocha-rs
前段时间自己还写了一个类js的解释器mocha和他的rust版本mocha-rs。在这个项目的基础上将main.rs修改,来基于rust+我们自己的简单解释器,来看性能表现。当然,我们的解释器没有bytecode没有jit,纯解释运行,应该是比v8要差一些的。
extern crate core;
mod eval;
mod lexer;
mod parser;
mod sdk;
use crate::eval::eval::{eval_block, eval_statements};
use crate::eval::{get_null, Context, Element, ErrorElement, SimpleError};
use lexer::lexer as LEX;
use parser::parser as PARSER;
use std::cell::RefCell;
use std::{fs, io, panic};
use std::io::Write;
use std::panic::{panic_any, AssertUnwindSafe};
use std::rc::Rc;
use crate::sdk::get_build_in_ctx;
use clap::{Arg, Command};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::collections::HashMap;
fn main() {
panic::set_hook(Box::new(|_| {}));
env_logger::init();// 定义参数形式为 jtxt [option] function [file]
let matches = Command::new("jtxt")
.version("1.0")
.author("Your Name <sunwu51@126.com>")
.about("Processes lines of input with JavaScript")
.arg(
Arg::new("begin")
.short('b')
.long("begin")
.help("JavaScript code to execute before processing any lines")
.value_parser(clap::builder::ValueParser::string()),
)
.arg(
Arg::new("end")
.short('e')
.long("end")
.help("JavaScript code to execute after processing all lines")
.value_parser(clap::builder::ValueParser::string()),
)
.arg(
Arg::new("function")
.help("JavaScript code to process each line, l is the origin string")
.required(true)
.index(1),
)
.arg(
Arg::new("file")
.help("Path to the input file. If not provided, reads from stdin.")
.index(2),
)
.get_matches();
let begin_code = matches.get_one::<String>("begin");
let end_code = matches.get_one::<String>("end");
let js_function = matches.get_one::<String>("function").unwrap();
let file_path = matches.get_one::<String>("file");
// 逐行读取文件或者管道内容
let input: Box<dyn BufRead> = if let Some(path) = file_path {
Box::new(BufReader::with_capacity(1024 * 1024,
File::open(path).expect("Failed to open file"),
))
} else {
Box::new(io::stdin().lock())
};
let ctx = Rc::new(RefCell::new(get_build_in_ctx()));
// 预设的全局变量
eval_code(r#"
var ctx = {};
var n1 = 0;
var n2 = 0;
var n3 = 0;
var s = "";
var arr = []; "#, ctx.clone());
// 执行begin的代码块,如果有的话
if let Some(code) = begin_code {
eval_code(code, ctx.clone());
}
let tokens = LEX::lex(js_function);
let statements = PARSER::Parser::new(tokens).parse();
for line in input.lines() {
ctx.borrow_mut().set("l", Rc::new(RefCell::new(Element::new_string(line.unwrap()))));
eval_statements(&statements, ctx.clone(), true);
}
// 执行end的代码块,如果有的话
if let Some(code) = end_code {
eval_code(code, ctx.clone());
}
}
fn eval_code(code: &str, ctx : Rc<RefCell<Context>>) {
let tokens = LEX::lex(code);
let statements = PARSER::Parser::new(tokens).parse();
eval_statements(&statements, ctx.clone(), true);
}
同样对10G文件进行字数统计,最后的结果也是2min左右,可以看出虽然没有JIT的优化,但是已经和deno性能非常接近,理论上v8有字节码、JIT等优化,应该比我纯解释要快很多。
gitpod /workspace/jtxt (base) $ time cargo run -r -- 'n1+=l.length()' -e 'print(n1)' largefile.txt
Finished `release` profile [optimized] target(s) in 0.09s
Running `target/release/jtxt n1+=l.length -e 'console.log(n1)' largefile.txt`
10675354954
real 2m4.803s
user 1m50.721s
sys 0m17.201s
这个方案只是一个实验,因为我的mocha解释器中没有实现丰富的内置函数,不能真正拿来做工具。但在性能上已经和deno_core+rust的方案接近,并且资源占用上只用了不到10M内存。
经过我多次修改代码来对比测试,我发现:
- 1 系统读取文件的速度很快,10G的文件,这台gitpod机器只需要20s即可读完,而且即使用nodejs或者python等其他解释型语言读取文件都是20s,因为都是使用的read系统调用,与语言本身性能无关。
- 2 deno的变量赋值这一步非常消耗时间,我将上面
n1+=l.length改为空字符串,最后的耗时也是100s+,减去读文件20s,可见变量赋值占了大概80s。
基于上述结论,我决定尝试直接改为nodejs来实现这个jtxt应用,因为node读文件也很快,并且node读取出来文件内容就已经是自身的js变量了,不需要变量再赋值的过程,即从rust字符串set到jsruntime的全局上下文字符串变量的这一步就没有了,这样理论上能节省80s的耗时。最终达到40s,即和awk性能平齐。
nodejs
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { program } = require('commander');
// 定义命令行选项
program
.version('1.0.0')
.argument('<logic>', '处理逻辑的 JavaScript 代码')
.argument('[filename]', '要读取的文件名', null)
.option('-b, --begin <code>', '初始化的逻辑代码')
.option('-e, --end <code>', '结束后的逻辑代码')
.parse(process.argv);
// 解析命令行参数
const options = program.opts();
const logic = program.args[0];
const filename = program.args[1];
var beginFunc = function(){}, endFunc = function(){},
processFunc = new Function('l', 'ctx', 'print', logic);
if (options.begin) {
beginFunc = new Function('ctx', 'print', options.begin);
}
if (options.end) {
endFunc = new Function('ctx', 'print', options.end);
}
// 预定义全局变量
var ctx = {
n1: 0, n2: 0, n3: 0, s: '', arr: []
};
var print = console.log;
// 处理文件或标准输入
const processStream = (stream) => {
beginFunc(ctx, print);
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity // 适用于 \r\n 和 \n 换行符
});
rl.on('line', (line) => {
try {
processFunc(line, ctx, print);
} catch (error) {
console.error('Error processing line:', error);
}
});
rl.on('close', () => {
// 执行结束逻辑
endFunc(ctx, print);
});
};
// 判断处理文件还是标准输入
if (filename) {
try {
const filePath = path.resolve(filename);
const fileStream = fs.createReadStream(filePath);
fileStream.on('error', (err) => {
console.error(`Error reading file: ${err.message}`);
process.exit(1);
});
processStream(fileStream);
} catch (err) {
console.error(`Error processing file path: ${err.message}`);
process.exit(1);
}
} else {
processStream(process.stdin);
}
运行时,内存占用一直是100M以内,最后的性能是40s,对比awk的32s已经是非常接近了,并且资源占用也是可以接受范围了。
$ top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
36589 gitpod 20 0 1073956 89628 45508 S 94.0 0.1 0:11.49 node
gitpod /workspace/jtxt (base) $ time node jtxt.js 'ctx.n1+=l.length' -e 'console.log(ctx.n1)' largefile.txt
10675354954
real 0m40.498s
user 0m28.419s
sys 0m9.661s
这个方案是整体性能表现最好,也是代码结构最简单的。
目前放到了github这里,其实上面就已经是全部代码了。也可以通过npm i -g jtxt来安装体验。
小结
这个应用最开始想要效仿其他一些翻新cli的思路,想借助rust的性能优势,但是后来发现rust在这个过程中,只负责文件读取,而这个过程都基于os的系统调用差距并不大。而从rust变量转到v8引擎变量的耗时成为了系统的主要瓶颈。最后使用nodejs来写,简化了代码的同时也提高了效率。
上面基于最简单的字符统计的例子测试的性能,而复杂的测试中,尤其是正则相关的测试中,node版本的性能要超过awk,这可能与底层正则实现有关,当然更令人兴奋的是node版本的jtxt,在保持了强大的性能的同时,提供了简单熟悉的js语法,和强大的nodejs sdk。