实现js语法的awk

192 阅读6分钟

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分别制定一段开始和结束时候执行的逻辑,这与awkBEGIN、END一样。

那接下来要做的是逐行处理文件,然后把当前行的字符串line,由js解释器来处理,所以这里还需要一个js解释器,rust中有个很轻量的解释器quick-js库,那么只需要clapquick-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