腐蚀rust服务器命令_【使用 Rust 写 Parser】2. 解析Redis协议

系列所有文章

https://zhuanlan.zhihu.com/p/115017849​zhuanlan.zhihu.com
https://zhuanlan.zhihu.com/p/139387293​zhuanlan.zhihu.com
https://zhuanlan.zhihu.com/p/146455601​zhuanlan.zhihu.com
https://zhuanlan.zhihu.com/p/186217695​zhuanlan.zhihu.com

在基本熟悉 nom 之后, 这次我们准备用 nom 实现一个 redis 通信协议的解析器. 选择 redis 是因为 redis 的通信协议易读且比较简单.

准备

如果你对 redis 通信协议不熟悉的话可以查阅 通信协议(protocol). 简单来说 redis 通信协议分为统一请求协议(这里只讨论新版请求协议)和回复协议, 请求协议可以方便地通过 Rust 内置的 format! 拼接构成, 而通信协议则使用 nom 解析. redis 协议非常简单, 这里不再赘述.

首先我们需要一个 redis 服务器, 这里我在开发的机器上用 docker 启动一个 redis 服务器:

docker run -d --name redis -p 6379:6379 redis redis-server --appendonly yes

测试下 redis 服务

telnet localhost 6379
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
ping
+PONG

出现 +PONG 说明服务器已正常运行

实现基本功能

首先创建项目

cargo new rcli && cd rcli

添加如下依赖

[dependencies]
tokio = { version = "0.2", features = ["full"]}
nom = "5"
bytes = "0.5.4"
structopt = "0.3.14"

structopt 可以帮助我们快速构建命令行工具输入 redis 命令帮助测试, bytes 则可以帮助我们处理字节, tokio 依赖是上个测试代码遗留的依赖, 刚好新代码也需要 tcp 连接, 索性使用 tokio 处理 tcp 连接, nom 自然是用于解析回复.

首先我们需要创建 tcp 连接与 redis 通信, 并且写入一些数据看看协议是否管用:

use bytes::{BufMut, BytesMut};
use std::error::Error;
use tokio::net::TcpStream;
use tokio::prelude::*;#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {let mut stream = TcpStream::connect("127.0.0.1:6379").await?;let mut buf = [0u8; 1024];let mut resp = BytesMut::with_capacity(1024);let (mut reader, mut writer) = stream.split();// 向服务器发送 PINGwriter.write(b"*1rn$4rnPINGrn").await?;let n = reader.read(&mut buf).await?;resp.put(&buf[0..n]);// 返回结果应该是 PONGprintln!("{:?}", resp);Ok(())
}

如上面代码展示的, 我们创建一个 tcp 连接和一个缓冲 buf, 在成功连接后根据协议尝试写入 *1rn$4rnPINGrn, 预期结果是服务器返回 "+PONGrn".

现在我们可以创建 CLI 实现几个常用的 redis 命令, 方便我们向服务器发送命令. 创建 commands.rs 文件, 记得在 main.rs 中导入它.

rpush 为例, rpush 命令用法为 RPUSH key value [value …]

使用 structopt 可以这样定义一个枚举(使用结构体也可以, 但因为将来有很多子命令, 所以枚举更合适)

use structopt::StructOpt;#[derive(Debug, Clone, StructOpt)]
pub enum Commands {/// push value to listRpush {/// redis keykey: String,/// valuevalues: Vec<String>,},
}

接着在 main.rs 中使用 Commands 解析命令行

use structopt::StructOpt;
mod commands;#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {// 创建 tcp 连接, buf 等...let com = commands::Commands::from_args();// 发送命令 ...
}

运行项目看下效果

cargo run -- helppush value to listUSAGE:rrdis-cli rpush <key> [values]...FLAGS:-h, --help       Prints help information-V, --version    Prints version informationARGS:<key>          redis key<values>...    value

接下来要把从命令行传来的参数转换为 redis 统一请求. redis 以 rn 为分隔符, redis 请求格式以 *argc 开头, argc 是此次请求的参数个数, 每个参数先以 $<参数长度> 声明参数长度, 接着 rn 分割符, 然后是参数数据, 若有多个参数则重复此步骤. 最后以 rn 结尾.

比如上面的 PING 转换为 *1rn$4rnPINGrn, 而 GET 转换为 *2rn$3rnGETrn$3rnkeyrn.

可以使用一个 builder 帮助我们转换:

use bytes::{BufMut, BytesMut};#[derive(Debug, Clone)]
struct CmdBuilder {args: Vec<String>,
}impl CmdBuilder {fn new() -> Self {CmdBuilder { args: vec![] }}fn arg(mut self, arg: &str) -> Self {self.args.push(format!("${}", arg.len()));self.args.push(arg.to_string());self}fn add_arg(&mut self, arg: &str) {self.args.push(format!("${}", arg.len()));self.args.push(arg.to_string());}fn to_bytes(&self) -> BytesMut {let mut bytes = BytesMut::new();bytes.put(&format!("*{}rn", self.args.len() / 2).into_bytes()[..]);bytes.put(&self.args.join("rn").into_bytes()[..]);bytes.put(&b"rn"[..]);bytes}
}

CmdBuilder 做的很简单, 保存通过 argadd_arg 传入的参数, 在 to_bytes 方法中拼接这些参数为有效的请求.

例如可以通过如下方式构建一个 GET 命令

let cmd = CmdBuilder::new().arg("GET").arg("key").to_bytes()

接下来使用 CmdBuilderCommands 实现 to_bytes 方法

impl Commands {pub fn to_bytes(&self) -> bytes::BytesMut {let cmd = match self {Commands::Rpush { key, values } => {let mut builder = CmdBuilder::new().arg("RPUSH").arg(key);values.iter().for_each(|v| builder.add_arg(v));builder.to_bytes()}};cmd}
}

改写 main 函数发送构建的请求

// ... 省略
let com = commands::Commands::from_args();
writer.write(&com.to_bytes()).await?;
cargo run -- rpush list a b c d# redis 成功返回响应
:3rn

All is well, 对于其他命令可以通过相同方法实现, 可以在 rrdis-cli/src/commands.rs 看到完整实现.

解析回复

现在终于到 nom 出场了. 新建 reply.rs 文件, 并在 main.rs 导入. 首先导入需要使用的 nom 方法, 接着定义 Reply, 因为 redis 回复种类有限, 所以用一个枚举是非常合适的.

use nom::branch::alt;
use nom::bytes::complete::tag;
use nom::bytes::complete::{take_while, take_while1, take_while_m_n};
use nom::combinator::map;
use nom::multi::many_m_n;
use nom::sequence::delimited;
use nom::IResult;#[derive(Debug)]
pub enum Reply {// 状态回复或单行回复SingleLine(String),// 错误回复Err(String),// 整数回复Int(i64),// 批量回复Batch(Option<String>),// 多条批量回复MultiBatch(Option<Vec<Reply>>),// 回复中没有, 这里是为了方便进行错误处理添加的BadReply(String),
}

单行回复

协议中单行回复定义如下:

一个状态回复(或者单行回复,single line reply)是一段以 "+" 开始、 "rn" 结尾的单行字符串。

所以解析思路是: 如果回复以"+"开头, 则读取余下字节存作为回复, 直到 "rn", 伪代码如下

take_if("+"), take_util_new_line, take_if("rn")

nom 中的 tag 可以完美实现伪代码中的 take_if 功能, 令人惊喜的是对于"消耗输入直到不符合某种条件"这个常见解析模式, nom 提供了 take_while 函数, 所以我们的解析函数可以写成:

fn parse_single_line(i: &str) -> IResult<&str, Reply> {let (i, _) = tag("+")(i)?;let (i, resp) = take_while(|c| c != 'r' && c != 'n')(i)?;let (i, _) = tag("rn")(i)?;Ok((i, Reply::SingleLine(resp.to_string())))
}

tagtake_while 让解析函数的功能非常直观地展现出来, 这让它看着想伪代码, 但它真的能运行!

在函数中只有 take_while 返回的结果是我们想要的, 但两个 tag 又是不可或缺, 对于这一常见解析模式 nom 提供了 delimited 这个组合子函数, 这个组合子函数接受三个类似 tag("xx") 这样的基本函数, 依次应用这三个函数, 如果成功, 则返回第二个函数解析的结果.

所以我们的函数可以这样写:

fn parse_single_line(i: &str) -> IResult<&str, Reply> {let (i, resp) = delimited(tag("+"),take_while(|c| c != 'r' && c != 'n'),tag("rn"),)(i)?;Ok((i, Reply::SingleLine(String::from(resp))))
}

错误回复

错误回复定义:

错误回复和状态回复非常相似, 它们之间的唯一区别是, 错误回复的第一个字节是 "-" , 而状态回复的第一个字节是 "+"

所以错误回复解析函数和上面的差不多:

fn parse_err(i: &str) -> IResult<&str, Reply> {let (i, resp) = delimited(tag("-"),// take_while1 与 take_while 类似, 但要求至少一个字符符合条件take_while1(|c| c != 'r' && c != 'n'),tag("rn"),)(i)?;Ok((i, Reply::Err(String::from(resp))))
}

整数回复

整数回复就是一个以 ":" 开头, CRLF 结尾的字符串表示的整数,

整数回复结构与前两种类似, 区别在于中间是整数, 需要将 take_while1 的返回值转换为整数.

如果没有进行类型转换解析函数可以这样实现:

fn parse_int(i: &str) -> IResult<&str, Reply> {let (i, int) = delimited(tag(":"),// 注意负数前缀take_while1(|c: char| c.is_digit(10) || c == '-'),tag("rn"),)(i)?;// ... 类型转换Ok((i, Reply::Int(int)))
}

注意到 nom 提供的基本解析工厂函数如 tag 创建的解析函数返回值都是 IResult, 它与 Result 类似, 可以应用 map 运算子, 不过这个 map 需使用 nom 提供的

map(take_while1(|c: char| c.is_digit(10) || c == '-'), |int: &str| int.parse::<i64>().unwrap())

通过 nom 的 map 函数可以把返回值从 IResult<&str, &str> 映射为 IResult<&str, i64>, 最后解析函数可以写成

fn parse_int(i: &str) -> IResult<&str, Reply> {let (i, int) = delimited(tag(":"),map(take_while1(|c: char| c.is_digit(10) || c == '-'),|int: &str| int.parse::<i64>().unwrap(),),tag("rn"),)(i)?;Ok((i, Reply::Int(int)))
}

批量回复

服务器发送的内容中: - 第一字节为 "$" 符号 - 接下来跟着的是表示实际回复长度的数字值 - 之后跟着一个 CRLF - 再后面跟着的是实际回复数据 - 最末尾是另一个 CRLF

同时批量回复还有特殊情况

如果被请求的值不存在, 那么批量回复会将特殊值 -1 用作回复的长度值, 这种回复称为空批量回复(NULL Bulk Reply)

此时协议要求客户端返回空对象, 对于 Rust 则是 None, 所以 BatchReply 才会被定义为 BatchReply<Option<String>>.

所以这个函数的解析可能稍微复杂点, 但方法与上面没有太大差异, 除了新的 take_while_m_n, take_while_m_ntake_while 类似, 不同的是它可以指定消耗输入最小数和最大数m, n.

如果是空回复则尝试匹配 rn, 如果成功, 直接返回, 否则根据拿到的回复长度, 获取那么多长度的字符, 接着应该碰到 rn.

fn parse_batch(i: &str) -> IResult<&str, Reply> {let (i, _) = tag("$")(i)?;let (i, len) = (take_while1(|c: char| c.is_digit(10) || c == '-'))(i)?;if len == "-1" {let (i, _) = tag("rn")(i)?;Ok((i, Reply::Batch(None)))} else {let len = len.parse::<usize>().unwrap();let (i, resp) = delimited(tag("rn"), take_while_m_n(len, len, |_| true), tag("rn"))(i)?;Ok((i, Reply::Batch(Some(String::from(resp)))))}
}

多条批量回复

多条批量回复是由多个回复组成的数组, 数组中的每个元素都可以是任意类型的回复, 包括多条批量回复本身。 多条批量回复的第一个字节为 "*" , 后跟一个字符串表示的整数值, 这个值记录了多条批量回复所包含的回复数量, 再后面是一个 CRLF

多条批量回复其实是对上面四种回复的嵌套, 但需要注意"空白多条批量回复"和"无内容多条批量回复"这两种特殊情况.

空白多条回复为 "*0rn", 无内容多条批量回复为 "*-1rn", 在解析时需要对这两种特殊情况进行处理. 在其他情况则可以应用 nom 提供的 alt 组合子服用之前的四个解析函数; alt 即"可选的", 它接受多个解析函数元组, 依次尝试应用每个函数, 返回第一个成功解析结果或抛出错误.

同时对于重复应用某个解析函数 m 到 n 次这种模式, nom 提供了 many_m_n 组合子, 对于 fn parse_item(&str) -> IResult<&str, Reply> 这样的函数, many_m_n(parse_item, 0, 12) 返回值为 IResult<&str, Vec<Reply>>.

理清逻辑后解析多条批量回复的解析函数虽然有些长但还是很清晰的:

fn parse_multi_batch(i: &str) -> IResult<&str, Reply> {let (i, count) = delimited(tag("*"),take_while1(|c: char| c.is_digit(10) || c == '-'),tag("rn"),)(i)?;if count == "-1" {let (i, _) = tag("rn")(i)?;Ok((i, Reply::MultiBatch(None)))} else {let count = count.parse::<usize>().unwrap();let (i, responses) = many_m_n(count,count,alt((parse_single_line, parse_err, parse_int, parse_batch)),)(i)?;// 做个严格检查, 检查解析到的个数与预期的是否一致if responses.len() != count {Ok((i,Reply::BadReply(format!("expect {} items, got {}", count, responses.len())),))} else {Ok((i, Reply::MultiBatch(Some(responses))))}}
}

最后用 alt 做个"汇总"

fn parse(i: &str) -> IResult<&str, Reply> {alt((parse_single_line,parse_err,parse_int,parse_batch,parse_multi_batch,))(i)
}

至此我们我们的解析函数到完成了, 为 Reply 实现 Display 特性后对 redis 返回的消息应用 parse 然后把解析结果打印出来即可验证解析函数正确性. 完整代码在

rrdis-cli/src/reply.rs​github.com

汇总

完整代码可以在我的 rrdis-cli 查看. 不知道大家对 nom 的评价如何, 我觉得使用 nom 提供的基本函数和一系列组合子从最小元素出发, 搭积木似的构建出更复杂的解析函数, 即降低了开发难度, 熟悉之后代码逻辑还挺清晰的.

整个 rrdis-cli 项目实现 set, get, incr, lrange, rpush 和 ping 这基本命令, 实现其他命令也是非常简单; 并且实现了绝大部分(还有一些特殊错误情况没处理)协议解析, 整个项目代码量如下

tokei .
-------------------------------------------------------------------------------Language            Files        Lines         Code     Comments       Blanks
-------------------------------------------------------------------------------Markdown                1            4            4            0            0Rust                    3          332          284           20           28TOML                    1           15           12            1            2
-------------------------------------------------------------------------------Total                   5          351          300           21           30
-------------------------------------------------------------------------------

Rust 代码只有 332 行, 挺简洁的, 估计比我用 Python 实现都少.

下一篇使用 nom 写什么还不确定, 随缘更新吧~

怎么说也是万字长文, 如果觉得文章可以, 请点个赞, 谢谢~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/532061.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

python中dic_python之dic {字典}(重要指数*****)

1. 什么是字典{name: 汪峰, age: 18} 键:值 别的语言键值对数据键: 必须是可哈希(不可变的数据类型),并且是唯一的值: 任意可以保存任意类型的数据字典是无序的python3.6版本以上,默认定义了顺序,python3.5以下是随机显示不能进⾏切片⼯作. 它只能通过key来获取dict中的数据字典…

python装饰器带参数函数二阶导数公式_一文搞定Python装饰器,看完面试不再慌

本文始发于个人公众号&#xff1a;TechFlow&#xff0c;原创不易&#xff0c;求个关注今天是Python专题的第12篇文章&#xff0c;我们来看看Python装饰器。一段囧事差不多五年前面试的时候&#xff0c;我就领教过它的重要性。那时候我Python刚刚初学乍练&#xff0c;看完了廖雪…

centos7源码安装mysql报错_CentOS7 下源码安装MySQL数据库 8.0.11

本文主要向大家介绍了CentOS7 下源码安装MySQL数据库 8.0.11&#xff0c;通过具体的内容向大家展现&#xff0c;希望对大家学习MySQL数据库有所帮助。CentOS7 下源码安装MySQL 8.0.11系统环境&#xff1a;CentOS7&#xff0c; 内核&#xff1a;Linux 3.10.0-862.el7.x86_64如果…

python全排列问题_Python基于回溯法子集树模板解决全排列问题示例

本文实例讲述了Python基于回溯法子集树模板解决全排列问题。分享给大家供大家参考&#xff0c;具体如下&#xff1a;问题实现 a, b, c, d 四个元素的全排列。分析这个问题可以直接套用排列树模板。不过本文使用子集树模板。分析如下&#xff1a;一个解x就是n个元素的一种排列&a…

file js new 传到后台_js 图片上传传给后台的3种格式

$("#imgfile").change(function () {var formData new FormData();$.each($(#imgfile)[0].files, function (i, file) {formData.set(idcard, file); //idcard 字段 根据自己后端接口定});//processData: false, contentType: false,多用来处理异步上传二进制文件。…

usbserialcontroller驱动安装不了_win10-有NVIDIA独显提示未安装控制面板的离线安装方式...

最近越来越多的用户反映NVIDIA显卡驱动设置不了啦&#xff0c;找不到NVIDIA显卡的控制面板。 也不知道NVIDIA在什么版本开始驱动安装包就不自带NVIDIA显卡控制面板了。 全新安装的显卡驱动就没有控制面板&#xff1b;或者Windows 10自带更新了显卡新版驱动后导致没有。 每次带N…

mysql 多实例 独立配置文件_三、安装配置多实例MYSQL5.6-多独立配置文件方法

三、安装配置多实例MYSQL5.6-多独立配置文件方法1、准备工作检查操作系统版本、内核版本、selinux是否关闭、防火墙策略、IP地址、主机名配置、host表配置、yum配置上传cmake、mysql5.6软件包具体步骤参考源码安装mysql-单实例配置文档2、安装cmake软件2.1 安装编译软件环境[[e…

python做什么模型_主题模型初学者指南[Python]

引言近年来涌现出越来越多的非结构化数据&#xff0c;我们很难直接利用传统的分析方法从这些数据中获得信息。但是新技术的出现使得我们可以从这些轻易地解析非结构化数据&#xff0c;并提取出重要信息。主题模型是处理非结构化数据的一种常用方法&#xff0c;从名字中就可以看…

python实现队列_Python学习教程:用队列实现栈

接着上一期跟大家说的用栈实现队列&#xff0c;这期的Python学习教程跟大家讲用队列实现栈题目&#xff1a;使用队列实现栈的下列操作&#xff1a;push(x) – 元素 x 入栈pop() – 移除栈顶元素top() – 获取栈顶元素empty() – 返回栈是否为空Implement the following operati…

vue 点击li 中的img 怎么不冒泡_Vue全解

一.Vue实例内存图&#xff1a;1.把Vue的实例命名为vm&#xff0c;vm对象封装了对视图的所有操作包括数据读写、事件绑定、DOM更新2.vm的构造函数是Vue&#xff0c;按照ES6的说法vm所属的类是Vue3.options是new Vue的参数一般称为选项或构造选项1.options里面有什么英文文档搜op…

python布局管理_Python基础=== Tkinter Grid布局管理器详解

本文转自&#xff1a;https://www.cnblogs.com/ruo-li-suo-yi/p/7425307.html 箬笠蓑衣Grid(网格)布局管理器会将控件放置到一个二维的表格里。主控件被分割成一系列的行和列&#xff0c;表格中的每个单元(cell)都可以放置一个控件。注意&#xff1a;不要试图在一个主…

python面向对象类_python面向对象-类和对象

一. 类的定义class类名():代码#定义类classWasher():defwash(self):print("洗衣服")注意&#xff1a;类名要满足标识符命名规则&#xff0c;同时遵循大驼峰命名习惯。二. 创建对象对象名 类名()#创建对象w Washer()#调用方法w.wash() #洗衣服三. selfself指的是调用…

vant部署_vant ui rem配置流程

参考地址 https://www.cnblogs.com/WQLong/p/7798822.html1.下载lib-flexible使用的是vue-cliwebpack&#xff0c;通过npm来安装的npm i lib-flexible --save2.引入lib-flexible在main.js中引入lib-flexibleimport ‘lib-flexible/flexible‘3.设置meta标签通过meta标签&#…

terminal services 找不到_电脑局域网中查看不到其他计算机或无法连接的解决办法...

在办公环境中&#xff0c;电脑经常需要打开网络&#xff0c;进行一些文件共享的操作&#xff0c;但是有时会出现很多无法共享的情况&#xff0c;之前有一篇文章讲过解决办法&#xff0c;今天再来将一下具体无法共享的错误提示和相对应的处理方法&#xff0c;主要有以下几种情况…

如何避免mysql回表查询_mysql如何避免回表查询

《迅猛定位低效SQL&#xff1f;》留了一个尾巴&#xff1a;select id,name where name‘shenjian‘select id,name,sexwhere name‘shenjian‘多查询了一个属性&#xff0c;为何检索过程完全不同&#xff1f;什么是回表查询&#xff1f;什么是索引覆盖&#xff1f;如何实现索引…

python爬虫开发数据库设计入门经典_Python3实现的爬虫爬取数据并存入mysql数据库操作示例...

本文实例讲述了Python3实现的爬虫爬取数据并存入mysql数据库操作。分享给大家供大家参考&#xff0c;具体如下&#xff1a;爬一个电脑客户端的订单。罗总推荐&#xff0c;抓包工具用的是HttpAnalyzerStdV7&#xff0c;与chrome自带的F12类似。客户端有接单大厅&#xff0c;罗列…

python中multiply函数_python中numpy库内multiply()、dot()和 * 三种乘法运算的区别小计...

首先&#xff0c;导入函数包&#xff1a;import numpy as np1.np.multiply()函数:数组&#xff1a;(点对点)对应位置元素相乘矩阵&#xff1a;对应位置元素相乘示例&#xff1a;A np.array([[1,2],[3,4]])B np.array([[1,3],[2,4]])A_mat np.mat(A)B_mat np.mat(B)A_B_mult…

安装python3.6.1_如何安装python3.6.1/

如何在win7下安装Python及配置1、首先&#xff0c;从搜索python官载适合自己电脑python版本。2标右击桌面“计算机”择打开菜单栏中的性”。3、WindowsXP时&#xff0c;在新弹出的属性窗口&#xff0c;选择“高级”->“环境变量”。Windows7是&#xff0c;在新弹出的属性窗口…

编程入门python java和c语言_学习编程适不适合从Python入门?哪种语言更适合入门?...

本文对比了C语言和Python语言&#xff0c;分析它们作为编程入门语言各自的利弊&#xff0c;并给出了我推荐的编程学习道路。我本身已经入门了Python脚本语言&#xff0c;在进阶C语言和JAVA语言后&#xff0c;Python重学就轻松很多&#xff0c;几个小时就拾起了忘记的语法&#…

mysql 备份 一张表_mysql 备份表的一个方法

#--- start# 新建表create table sp2_match_comment_tmp like sp2_match_comment; # 这种方式 外键索引&#xff0c;触发器不会在新表中有&#xff0c;要自己添加LOCK TABLES sp2_match_comment write, sp2_match_comment AS smc2 read, sp2_match_comment_tmp write;# 导出最新…