Rust文本处理快速入门教程
编程过程中有许多类型的数据要处理,其中文本处理必不可少,本文主要是记录在使用Rust开发的过程中处理文本相关数据的一些代码,而文本可以分为结构化和非结构化的文本,比如JSON和小说文本(没有固定格式的文本)。
这里以两种格式文本为例
- Nginx的访问日志
- Caddy的访问日志
为了不使文章过于冗长,大家可以根据自己需要将下面的数据复制成多行,然后自行测试, 或者问ChatGPT之类的AI给你生成一些样本数据, 比如问AI问题:“给我十条NGINX的访问日志样本数据”。
nginx
的访问日志测试样本如下:
172.17.0.1 - - [20/Dec/2023:01:37:27 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.29.0" "-"
172.17.0.1 - - [20/Dec/2023:01:37:27 +0000] "GET /hello HTTP/1.1" 200 612 "-" "curl/7.29.0" "-"
172.17.0.1 - - [20/Dec/2023:01:37:27 +0000] "GET /hello HTTP/1.1" 200 612 "-" "curl/7.29.0" "-"
上面的日志对应的日志格式如下:
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
caddy
的访问日志测试样本如下:
{"level":"info","ts":1683783840.9822006,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"::1","remote_port":"56352","proto":"HTTP/1.1","method":"GET","host":"localhost:20023","uri":"/","headers":{"Accept":["*/*"],"User-Agent":["curl/7.29.0"]}},"user_id":"","duration":0.000221154,"size":17060,"status":200,"resp_headers":{"Server":["Caddy"],"Etag":["\"rudac9d5w\""],"Content-Type":["text/html; charset=utf-8"],"Last-Modified":["Tue, 09 May 2023 01:19:21 GMT"],"Accept-Ranges":["bytes"],"Content-Length":["17060"]}}
{"level":"info","ts":1683783841.9822006,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"::1","remote_port":"56352","proto":"HTTP/1.1","method":"GET","host":"localhost:20023","uri":"/hello","headers":{"Accept":["*/*"],"User-Agent":["curl/7.29.0"]}},"user_id":"","duration":0.000221154,"size":17060,"status":200,"resp_headers":{"Server":["Caddy"],"Etag":["\"rudac9d5w\""],"Content-Type":["text/html; charset=utf-8"],"Last-Modified":["Tue, 09 May 2023 01:19:21 GMT"],"Accept-Ranges":["bytes"],"Content-Length":["17060"]}}
{"level":"info","ts":1683783841.9822006,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"::1","remote_port":"56352","proto":"HTTP/1.1","method":"GET","host":"localhost:20023","uri":"/hello","headers":{"Accept":["*/*"],"User-Agent":["curl/7.29.0"]}},"user_id":"","duration":0.000221154,"size":17060,"status":200,"resp_headers":{"Server":["Caddy"],"Etag":["\"rudac9d5w\""],"Content-Type":["text/html; charset=utf-8"],"Last-Modified":["Tue, 09 May 2023 01:19:21 GMT"],"Accept-Ranges":["bytes"],"Content-Length":["17060"]}}
Caddy
的访问日志是JSON
格式,就不需要什么额外的说明了。
本文代码的所有Rust依赖如下:
因为Rust的标准库非常精简(简陋), 所以很多操作都需要借助第三方库,比如这里处理
JSON
的库serde
.
[dependencies]
encoding_rs = "0.8.33"
regex = "1.10.2"
serde_json = "1.0.108"
快速入门
假设我们的任务是统计日志中每个URL的访问次数。
Caddy日志解析
Caddy
的日志格式是每行都是一个合法的JSON格式的文本,所以直接使用serde_json
处理即可。
// https://youerning.top/post/rust-text-processing-tutorial/
use std::collections::HashMap;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Result;
use std::fs::File;
use serde_json::Value;
fn main() -> Result<()>{
let filepath = "caddy.log";
let file = File::open(filepath)?;
let reader = BufReader::new(file);
let mut url_counter = HashMap::new();
for line in reader.lines() {
match line {
Ok(line) => {
// println!("line: {line}");
if let Err(_) = serde_json::from_str::<Value>(&line) {
continue
}
let data: Value = serde_json::from_str(&line).unwrap();
if let None = data.get("request") {
continue
}
// 这样的代码太形式化了,应该有类似于GJSON之类的库, 不够我没有用过
// 所以这里就这样吧, 后文用展开宏节省一下代码。
// 其实这里也可以用Options的and_then方法,但是还需要写一个匿名函数,不是很喜欢。
if let None = data.get("request").unwrap().get("uri") {
continue
}
let uri = data.get("request").unwrap().get("uri").unwrap();
if let None = uri.as_str() {
continue
}
let uri = uri.as_str().unwrap();
// *url_counter.entry(uri.to_owned()).or_insert(0) += 1;
let v = url_counter.entry(uri.to_owned()).or_insert(0);
*v += 1;
},
Err(err) => {
return Err(err)
}
}
}
println!("url_counter: {url_counter:?}");
Ok(())
}
Nginx日志解析
类似于Nginx这样的纯文本格式,必须得预先知道文本的格式,这可以通过肉眼观察或者查看输出端的配置来了解格式,不然的话没办法精确的处理,至少是不能将每个字段的值剥离出来。
根据观察或者说查看Nginx的配置文件,我们知道我们要取的数据在第一个用双引号"“包裹起来的字符串内, 比如"GET / HTTP/1.1"
。
解析文本有很多办法,大致分为两种,使用正则表达式或者不使用正则表达式,这里选择的方法是不使用正则表达式,因为正则表达式的维护难度有点大。
// https://youerning.top/post/rust-text-processing-tutorial/
use std::collections::HashMap;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Result;
use std::fs::File;
fn main() -> Result<()>{
let filepath = "nginx.log";
let file = File::open(filepath)?;
let reader = BufReader::new(file);
let mut url_counter = HashMap::new();
for line in reader.lines() {
match line {
Ok(line) => {
// println!("line: {line}");
let spilts:Vec<&str> = line.split_whitespace().collect();
if spilts.len() < 13 {
continue
}
// 注意: 这里不会考虑包含代理的日志记录
// 如果是代理的日志记录可能是 http://xxxx:xxx/abc这种格式
if !spilts.get(6).unwrap().starts_with("/") {
continue
}
let uri = *spilts.get(6).unwrap();
// *url_counter.entry(uri.to_owned()).or_insert(0) += 1;
let v = url_counter.entry(uri.to_owned()).or_insert(0);
*v += 1;
},
Err(err) => {
return Err(err)
}
}
}
println!("url_counter: {url_counter:?}");
Ok(())
}
两个的代码结果应该都是如下:
url_counter: {"/": 1, "/hello": 2}
文件读取
一般来说文本都是以文件的形式存在的,这里讨论的也主要是以文件形式存在的文本,至于网络数据的文本需要根据对应的协议来处理了。
获取文件句柄(打开文件)
在读取文本之前自然是需要先打开文件或者说获得文件句柄的。
如果只关心打不打得开,那么可以直接通过问号?
操作符将错误直接往外抛。
use std::io::Result;
use std::fs::File;
fn main() -> Result<()>{
let filepath = "caddy.log";
let file = File::open(filepath)?;
Ok(())
}
如果我们关心错误,那么可以用模式匹配判断一下, **io::Error
有很多类型的, 这里仅判断了不存在的类型 **
use std::io::{Result, ErrorKind};
use std::fs::File;
fn main() -> Result<()>{
let filepath = "caddy.log";
let file = match File::open(filepath) {
Ok(file) => file,
Err(err) => {
if err.kind() == ErrorKind::NotFound{
println!("文件不存在");
}
return Err(err)
}
};
Ok(())
}
如果只是判断文件不存在还有一些简单的方法,比如:
use std::path::Path;
fn main() {
let path = Path::new("caddy.logx");
if !path.exists() {
println!("文件不存在");
}
}
编码
当获取了文件句柄就可以读取文件内容了,但是我们总要时刻注意文件的编码是什么,默认情况下Rust提供的一些方法都是以UTF8格式来读取文件的,比如
use std::io::{Result, Read};
use std::fs::File;
fn main() -> Result<()>{
let filepath = "caddy.log";
let mut file = File::open(filepath)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
println!("content: {content}");
Ok(())
}
虽然UTF8是主流,但是,但是,但是。。。还有一些例外,比如GBK。
如果我们使用上面的代码读取GBK格式的文件,那么会有以下报错。
Error: Error { kind: InvalidData, message: "stream did not contain valid UTF-8" }
所以,我们需要指定编码,这需要使用第三方库encoding_rs
, 可以通过cargo add encoding_rs
添加依赖,本文使用的是0.8.33
值得注意的是: 非GBK的数据不一定会失败, 比如全是ASCII字符的文本。
use std::io::{Result, Error, ErrorKind};
use std::fs;
use encoding_rs::GBK;
fn main() -> Result<()>{
let filepath = "gbk.log";
let content = fs::read(&filepath)?;
println!("{}", content.len());
let (content, _, had_err) = GBK.decode(&content);
if had_err {
return Err(Error::new(ErrorKind::Other, "使用GBK解码失败"))
}
println!("{}", content.len());
println!("content: {content:?}");
Ok(())
}
字符串处理
字符串的操作,大家可以直接查阅官方文档,这里就不一一列举它有的工作方法了,参考文档: https://doc.rust-lang.org/std/string/struct.String.html
正则表达式
正则表达式很多时候还是很好用的,特别是匹配文本和获取特定的模式字段,这里还是匹配Nginx的访问日志记录,数据样本如下。
172.17.0.1 - - [20/Dec/2023:01:37:27 +0000] "GET /hello HTTP/1.1" 200 612 "-" "curl/7.29.0" "-"
这需要依赖第三方库regex
, 可通过cargo add regex
命令添加。
假设我们想获取/hello
这个字符串。
use regex::Regex;
fn main() {
let log = r#"172.17.0.1 - - [20/Dec/2023:01:37:27 +0000] "GET /hello HTTP/1.1" 200 612 "-" "curl/7.29.0" "-""#;
let pattern = Regex::new(r#".+?"GET\s+(.+)\s+HTTP.+?"#).unwrap();
// 判断是否匹配
if pattern.is_match(log) {
println!("该日志匹配正则表达式")
} else {
panic!("无法匹配正则表达式")
}
// 获取匹配的部分
if let Some(caps) = pattern.captures(log) {
println!("{caps:?}");
let uri = caps.get(1).unwrap().as_str();
println!("uri: {uri}");
} else {
panic!("无法捕获表达式里的内容")
}
}
输出结果如下:
该日志匹配正则表达式
Captures({0: 0..61/"172.17.0.1 - - [20/Dec/2023:01:37:27 +0000] \"GET /hello HTTP/", 1: 49..55/"/hello"})
uri: /hello
如果你看不懂我写的那串正则表达式,我觉得也没关系,因为这东西需要额外的学习。因为正则表达式的性能不好预测(针对长文本的时候),所以尽可能的还是用比较好理解的各种字符串方法来获取所需要的字段吧,如果可以的话。
用展开宏处理嵌套结构
前面在获取Caddy
的uri
字段的时候,因为不在最外层,所以需要先判断request
字段在不在,然后再判断request
的值里面有没有uri字段,这还只是在第二层,如果是更加深的层次,那么需要写很多的无聊代码,这实在是无趣的事情,所以我们可以将这种有着相同模式的代码用rust声明宏
来完成。
use serde_json::json;
macro_rules! serde_get {
($value: ident, $first: expr) => {
{
match ($value).get($first) {
Some(val) => Some(val),
None => {
None
}
}
}
};
($value: ident, $first: expr, $($others:expr)+) => {
{
match ($value).get($first) {
Some(val) => {
serde_get!(val, $($others)+)
},
None => {
None
}
}
}
};
// 使用声明宏处理递归调用的关键在于$($others:tt)*
($value: ident, $first: expr, $($others:tt)* ) => {
{
match ($value).get($first) {
Some(val) => {
serde_get!(val, $($others)+)
}
None => None
}
}
};
}
fn main() {
let object = json!({
"key11": {"key12": "key13"},
"key21": {"key22": {"key23": "key24"}}
});
if let None = serde_get!(object, "xx") {
println!("不存在键xx");
}
if let Some(val) = serde_get!(object, "key11", "key12") {
println!(r#"object["key11"]["key12"] = {val:}"#);
}
if let Some(val) = serde_get!(object, "key21", "key22", "key23") {
println!(r#"object["key21"]["key21"]["key23"] = {val:}"#);
}
if let Some(val) = serde_get!(object, "key21", "key22", "key23", "key24") {
println!(r#"object["key21"]["key21"]["key23"]["key33"] = {val:}"#);
} else {
println!(r#"object["key21"]["key21"]["key23"]["key33"]不存在"#);
}
}
代码的输出结果如下:
不存在键xx
object["key11"]["key12"] = "key13"
object["key21"]["key21"]["key23"] = "key24"
object["key21"]["key21"]["key23"]["key33"]不存在
除了使用声明宏也可以使用递归函数,这就看大家的喜好了。如果大家看得不是太懂,可以搜索关键字rust TT muncher
或者rust 标记树撕咬机
。
这个例子写完,我才发现serde_json可以直接使用["key21"]["key21"]["key23"]
这样的语法直接判断!!!, 不过serde_json的返回结果都是null, 如果键值对不存在的话。
总结
说实话,就处理文本数据这块,我感觉rust的体验远远比不上动态类型的编程语言,比如Python, 但是为了开发的一致性,我还是会很多情况使用Rust,在本文稍微提及了一下rust的宏编程,下一篇文章是关于声明函的教程, 有兴趣的可以关注一下。
参考链接: