用Rust编写一个命令行工具(初学)

原创 PumpkinBridge 红细胞安全实验室

前言

今天心血来潮决定写一个Rust命令行工具哈哈哈哈,本人rust初学者,跟着网上一些博客和大佬然后自己编写改进一些好玩的小代码,勿喷(狗头) ,用Rust编写一个命令行工具,对于我这种初学者来说是一个很好的练习,毕竟,大家刚开始接触一个新的语言都是从Hello World的入手的,Rust这门语言感觉还是挺抽象的,语言博大精深,本人还是菜鸡一个,慢慢来吧

Rust优点

首先Rust是一种静态编译的、快速的语言,具有出色的工具支持和迅速增长的生态系统。这使它非常适合编写命令行应用程序。Rust 的运行速度、安全性、单二进制文件输出和跨平台支持使其成为创建命令行程序的绝佳选择,根据官方项目的基础上将创建一个我们自己版本的经典命令行工具: redcellgrep。grep是 "Globally search a Regular Expression and Print" 的首字母缩写。grep最简单的使用场景是在特定文件中搜索指定字符串。为此,grep获取一个文件名和一个字符串作为参数,接着读取文件并找到其中包含字符串参数的行,然后打印出这些行。

0x1创建程序

先在终端输入cargo new Redcellgrep,cd切换到刚创建的目录下

这时候就已经创建了一个应用程序了 然后进入main.rs,自动打印了一个"hello,world"这时候说明程序已经创建成功了

首先第一步肯定是要解析命令行的一些参数,要实现支持查询字符串和文件名 我们可以把参数存到args里面,用env上的args参数来传入参数名 在这串代码中,std::env::args() 调用返回一个迭代器,迭代器中包含了程序的命令行参数。通过调用 collect() 方法,将这个迭代器转换成了一个 Vec,其中的每个元素都是一个命令行参数作为 String 类型的值(别忘了导入env模块)

use std::env;
fn main() {
    let args: Vec<String> = std::env::args().collect();
    println!("{:?}", args);
}

打印字符串,输出args 传入一个query,查询test.txt,发现可以查询了

这时候必须赋值给两个变量分别是query和filename,用字符串打印出来 那如果输入什么都没有报错信息的话,我们可以通过在5-8行写一小段代码加强命令行的安全性,这段代码是在检查命令行参数数量是否足够,如果参数不够立马会终止程序,现在,如果用户没有提供足够的命令行参数,程序将会显示用法说明,而不会继续尝试打开文件,这样就更安全了

use std::env;
fn main() {
    let args: Vec<String> = std::env::args().collect();
    
    if args.len() < 3 {
        println!("Usage: {} <query> <filename>", args[0]);
        return;
    }
    
    let query = &args[1];
    let filename = &args[2];

    println!("Searching for {}", query);
    println!("In file {}", filename);
}

0X2读取文件

现在要增加读取filename命令行参数指定的文件的功能。首先,需要一个用来测试的示例文件:用来确保redcellgrep正常工作的最好的文件是拥有多行少量文本且有一些重复单词的文件。这里我用一首诗-西德尼·兰伯特(Sydney Lambert)  的诗《孤独的一人》(网上随便找的一篇) ,来体现命令行参数的作用,在项目根目录创建一个文件 poem.txt ,并输入诗"I stand amidst the crowd": 文件名: poem.txt 内容如下 "I stand amidst the crowd, The lonely one. I see them come and go, Their busy strides, Hurriedly, As if forever chasing something. They talk and laugh, While I just stand there, In this bustling world, As if I've become an island. I want to escape, Find a quiet corner, To contemplate alone, To live alone, Away from this noisy world. But I know, No matter where I go, No matter where I hide, I'll still be the lonely one, In this clamorous world, Finding no belonging."

现在要读取这个文件里的内容,返回程序 引入fs模块,利用fs模块中的read_to_string函数来读取filename中的文件内容,成功的话就会返回contents,失败的话就打印"读取文件失败!"

use std::fs;
use std::env;
fn main() {
    let args: Vec<String> = std::env::args().collect();
    
    if args.len() < 3 {
        println!("Usage: {} <query> <filename>", args[0]);
        return;
    }
    
    let query = &args[1];
    let filename = &args[2];

    println!("Searching for {}", query);
    println!("In file {}", filename);

    let contents = std::fs::read_to_string(filename)
        .expect("读取文件失败!");
    println!("文本内容:\n{}", contents)
}

现在main函数可以解析文件名也可以解析文件内容了,但是通常一个函数只负责一个事情,这里对于错误处理还需要优化一下,这时候就需要重构一下代码了,通过调用别的库文件可以减轻压力,可以创建一个lib.rs文件来分担main函数的职责,下一步先对main函数进行取出重构

0X3取出重构

把参数query和filename解析取出到一个函数去,可以添加一个function名为parse_config,把args里的字符串做一个解析,其中把args[1]和args[2]分别赋值给query和filename,用元组的形式返回

fn parse_config(args: &[String]) -> (Str, Str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
    
}

现在就不需要上面的解析动作了,可以做一个优化,调用下面的parse_config

use std::fs;
use std::env;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    
    if args.len() < 3 {
        println!("Usage: {} <query> <filename>", args[0]);
        return;
    }

    let (query, filename) = parse_config(&args);

    println!("Searching for {}", query);
    println!("In file {}", filename);

    let contents = fs::read_to_string(filename)
        .expect("读取文件失败!");
    println!("文本内容:\n{}", contents);
}

fn parse_config(args: &[String]) -> (Str, Str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
    
}

现在看起来清晰一点了,但是这个query和filename仍然没表达出想要的那种关联,这时候可以引入一个结构体来表达query和filename是和config配置相关联的,这时候选择引入一个config结构体,下面的元组也要改掉

struct Config {
    query: String,
    filename: String,
}

这时候就不用返回元组了,可以直接返回一个config结构体,运行发现报错,可以让结构体config里的query和filename为引用,这时候就要涉及到生存期了,有点麻烦,可以在args后加clone的方式比上面那种方法简单一些,同理对于错误处理,这时候我们可以导入process模块, 在这个版本中,我们使用了 eprintln! 宏来打印错误信息,使用 process::exit(1) 来终止程序的执行,并且在尝试打开文件时使用了 unwrap_or_else 来处理可能的错误情况。这样可以提高代码可靠性

use std::fs;
use std::env;
use std::process;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    
    if args.len() < 3 {
        eprintln!("Usage: {} <query> <filename>", args[0]);
        process::exit(1);
    }

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(&config.filename)
        .unwrap_or_else(|err| {
            eprintln!("读取文件失败: {}", err);
            process::exit(1);
        });
    println!("文本内容:\n{}", contents);
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let filename = args[2].clone();

    Config{ query, filename }
}

在struct Config和parse_config中两者是紧密联系的,那么为了体现它们两者之间的关系,可以添加一个实现块,把parse_config放在实现块当中去,代码如下,现在它们就是紧密关联了,同时,在实现块中一般是new关联函数,所以把第7行中的parse_config改为new

struct Config {
    query: String,
    filename: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let filename = args[2].clone();
    
        Config{ query, filename }
    }
    
}

加入一行代码使用一个实现块let config = Config::new(&args);query和filename字段前记得都要加上config,现在代码整体结构进一步清晰了一点点,但是对于错误处理我还没做优化,现在开干,加入fs::read_to_string(&config.filename) 尝试读取指定文件的内容。如果读取成功,它会返回文件的内容;如果读取失败,它会返回一个 Result 类型的错误 unwrap_or_else 方法被用来处理可能的错误。如果读取文件失败,它会执行闭包中的代码,打印错误信息并退出程序;如果读取成功,它会将文件内容返回。这样做的目的是为了使程序在遇到错误时能够给出明确的提示,并终止执行,而不是继续运行可能会导致更严重错误的代码

use std::fs;
use std::env;
use std::process;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    
    let config_result = parse_config(&args);
    if let Err(err) = config_result {
        eprintln!("参数错误:{}", err);
        eprintln!("Usage: {} <query> <filename>", args[0]);
        process::exit(1);
    }

    let config = config_result.unwrap();

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(&config.filename)
        .unwrap_or_else(|err| {
            eprintln!("读取文件失败: {}", err);
            process::exit(1);
        });
    println!("文本内容:\n{}", contents);
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Result<Config, String> {
    if args.len() < 3 {
        return Err("缺少参数!".to_string());
    }

    let query = args[1].clone();
    let filename = args[2].clone();

    Ok(Config{ query, filename })
}

最后,文件内容被打印到控制台上

图片

读取文件内容可以放到一个function里面,命名为run,在main函数中调用该方法,读取成功就直接会返回读取内容,如果读取失败就会提示"读取文件失败"

fn run(config:Config) -> Result<(), String> {
    let contents = fs::read_to_string(&config.filename)
        .map_err(|err| format!("读取文件失败: {}", err))?;
    println!("文本内容:\n{}", contents);
    Ok(())
}

实现效果如下,这里报错了snake name一串,表示Redcellgrep是不符合规则的,后面我改成了redcellgrep,cargo.toml里也要改别忘了噢

如果进一步优化的话可以用' ? '运算符来简化错误处理,调用库中的errorr[use std::error::Error],run 函数现在返回 Result<(), Box>,这意味着它要么返回成功,要么返回一个实现了 std::error::Error trait 的错误。在 fs::read_to_string 调用中,同样,在 parse_config 函数中,返回了一个 Result<Config, Box> 类型的值,代码如下

fn parse_config(args: &[String]) -> Result<Config, Box<dyn Error>> {
    if args.len() < 3 {
        return Err("缺少参数!".into());
    }

    let query = args[1].clone();
    let filename = args[2].clone();

    Ok(Config{ query, filename })
}

同时加入一个Err,如果错误就打印错误信息,用process::exit优雅退出(狗头)

if let Err(err) = run(config) {
        eprintln!("错误: {}", err);
        process::exit(1);
    }

到这里基本上代码整体完成的差不多了,整体代码如下,这里我是修改了错误信息的提示内容,接下来把其中的一些逻辑迁移到lib.rs里去

use std::error::Error;
use std::fs;
use std::env;
use std::process;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    
    let config_result = parse_config(&args);
    if let Err(err) = config_result {
        eprintln!("参数错误:{}", err);
        eprintln!("Usage: {} <query> <filename>", args[0]);
        process::exit(1);
    }

    let config = config_result.unwrap();

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(err) = run(config) {
        eprintln!("应用错误: {}", err);
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    if !fs::metadata(&config.filename).is_ok() {
        return Err("No such file or directory".into());
    }

    let contents = fs::read_to_string(&config.filename)?;
    println!("文本内容:\n{}", contents);
    Ok(())
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Result<Config, Box<dyn Error>> {
    if args.len() < 3 {
        return Err("缺少参数!".into());
    }

    let query = args[1].clone();
    let filename = args[2].clone();

    Ok(Config{ query, filename })
}

在src目录下创建一个lib.rs存放run方法,把run方法以下的函数都剪切到lib.rs中去,记得把库也引过去,env和process就不用了,同样main.rs中的error和fs也不用了可以删掉,lib.rs代码如下

use std::error::Error;
use std::fs;

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    if !fs::metadata(&config.filename).is_ok() {
        return Err("No such file or directory".into());
    }

    let contents = fs::read_to_string(&config.filename)?;
    println!("文本内容:\n{}", contents);
    Ok(())
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Result<Config, Box<dyn Error>> {
    if args.len() < 3 {
        return Err("缺少参数!".into());
    }

    let query = args[1].clone();
    let filename = args[2].clone();

    Ok(Config{ query, filename })
}

main.rs怎么访问到llib.rs呢这里我问了一下gpt,可以通过查看Cargo.toml中的Redcellgrep也就是use Redcellgrep::Config;引入进来,现在config就可以访问到啦,但是run依旧访问不到,可以直接引用Redcellgrep模块引入进来,我这里使用了use Redcellgrep::Config;但是程序一直报错无法运行,脑子都快烧掉了,后面通过GPT修改发现要使用 extern crate 语法来导入 Redcellgrep crate,而且得确保 Redcellgrep::run 函数和 Redcellgrep::Config 结构体是公共的。在 Redcellgrep crate 中,需要使用 pub 关键字将它们标记为公共,main.rs和lib.rs的代码分别如下:

extern crate redcellgrep;

use redcellgrep::Config;
use redcellgrep::run;
use std::process;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    
    let config_result = parse_config(&args);
    if let Err(err) = config_result {
        eprintln!("参数错误:{}", err);
        eprintln!("Usage: {} <query> <filename>", args[0]);
        process::exit(1); //退出该程序
    }

    let config = config_result.unwrap();

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(err) = run(config) {
        eprintln!("应用错误: {}", err);
        process::exit(1);
    }
}

fn parse_config(args: &[String]) -> Result<Config, String> {
    if args.len() < 3 {
        return Err("缺少参数!".to_string());
    }

    let query = args[1].clone();
    let filename = args[2].clone();

    Ok(Config { query, filename })
}
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub filename: String,
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    if !fs::metadata(&config.filename).is_ok() {
        return Err("No such file or directory".into());
    }

    let contents = fs::read_to_string(&config.filename)?;
    println!("文本内容:\n{}", contents);
    Ok(())
}

pub fn parse_config(args: &[String]) -> Result<Config, Box<dyn Error>> {
    if args.len() < 3 {
        return Err("缺少参数!".into());
    }

    let query = args[1].clone();
    let filename = args[2].clone();

    Ok(Config{ query, filename })
}

0X4测试Test功能

现在部分的逻辑已经迁移到lib.rs当中去了,现在就可以测试驱动开发了,需要通过查询query等字段来返回我们想要的结果,尝试实现一下功能,加入一个test测试模块代码如下,其中duct是我们要查询的,content是我们的文本内容,通过调用search然后vec会包含我们查询结果的一行,运行发现search报错,功能还没有实现

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_one() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

现在实现search签名,代码如下,发现&a报错,这里要把生存期参数都关联起来加入 'a

pub fn search<'a>(query: &str, contents: &'a &str) -> Vec<&'a str> {
    //报错
    vec![]
}

然后准备实现search的功能,对该行进行遍历,最后要把results结果返回出来,代码如下

pub fn search<'a>(query: &str, contents: &'a &str) -> Vec<&'a str> {
    let mut results = Vec::new();
    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }
   results
}

运行test,结果如下

现在实现search功能代码如下

for line in search(&config.query, &contents) {
        println!("{}", line);
    }

现在cargo run Hurriedly poem.txt 随便找一行,我这里的内容就找Hurriedly吧,结果如下

但是这个search对大小写查询敏感怎么办,还得优化一下,现在加入一个case_insensitive,上面的test_one也要改掉用于测试是否完成大小写敏感

#[test]
    fn case_insensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:","Trust me."], 
            search_case_insensitive(query, contents)
    );
    }
}

加入接口,与上面代码相似,但是lowercase会转换为小写判断,就可以实现case_insensitive的查询模式

pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results: Vec<&str> = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

运行结果cargo test,两个test均成功

图片

那么问题来了,怎么判断使用insensitive还是sensitive呢,可以在pub struct Config下加入一个pub case_sensitive: bool,调整一下run方法,优化一下做一个分支判断

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}
let contents = fs::read_to_string(&config.filename)?;
    
    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

如果case_sensitive为ture就调用第一个方法,否则就调用第二个,接下来就对行进行遍历输出

for line in results {
        println!("{}", line);
    }

new一个sensitive环境变量,代码如下,因为引入了env中的var所以要记得在上方导入use std::env;

impl Config {
    pub fn parse_config(args: &[String]) -> Result<Config, Box<dyn Error>> {
        if args.len() < 3 {
            return Err("缺少参数!".into());
    }

    let query = args[1].clone();
    let filename = args[2].clone();
    let case_sensitive = env::var("CASE_INSENSITIVE").is_err();
    Ok(Config { 
        query, 
        filename,
        case_sensitive })
    }
}

我这里运行报错了,这是因为在main.rs中我的Config 结构体定义了 case_sensitive 字段,但在 parse_config 函数中未提供它的值,为了解决这个问题,可以在 parse_config 函数中为 case_sensitive 字段提供一个默认值,代码修改如下

extern crate redcellgrep;

use redcellgrep::{Config, run};
use std::process;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    
    let config_result = parse_config(&args);
    match config_result {
        Ok(config) => {
            println!("Searching for {}", config.query);
            println!("In file {}", config.filename);

            if let Err(err) = run(config) {
                eprintln!("应用错误: {}", err);
                process::exit(1);
            }
        }
        Err(err) => {
            eprintln!("参数错误:{}", err);
            eprintln!("Usage: {} <query> <filename>", args[0]);
            process::exit(1);
        }
    }
}

fn parse_config(args: &[String]) -> Result<Config, String> {
    if args.len() < 3 {
        return Err("缺少参数!".to_string());
    }

    let query = args[1].clone();
    let filename = args[2].clone();

    Ok(Config { query, filename, case_sensitive: true }) // 提供默认值
}

这时候我们输入cargo run to poem.txt就可以查询到相关的一行啦,cargo run TO poem.txt同理

图片

有时间也可以加一些好玩的功能,比如输出poem可以自动判断该文件类型,这就不细说了,直接上完整代码吧main.rs如下

use std::env;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config_result = redcellgrep::Config::parse_config(&args);
    if let Err(err) = config_result {
        eprintln!("参数错误:{}", err);
        eprintln!("Usage: {} <query> <filename>", args[0]);
        process::exit(1);
    }

    let mut config = config_result.unwrap();

    if env::var("CASE_INSENSITIVE").is_ok() {
        config.case_sensitive = true;
    }

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);
    if let Some(file_type) = &config.file_type {
        println!("File type: {}", file_type);
    }
    if let Some(file_size) = &config.file_size {
        println!("File size: {}", file_size);
    }

    if let Err(err) = redcellgrep::run(config) {
        eprintln!("应用错误: {}", err);
        process::exit(1);
    }
}

lib.rs如下

use std::error::Error;
use std::fs;
use std::env;
use std::path::{Path, };

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
    pub file_type: Option<String>,
    pub file_size: Option<String>,
}

impl Config {
    pub fn parse_config(args: &[String]) -> Result<Config, Box<dyn Error>> {
        if args.len() < 3 {
            return Err("缺少参数!".into());
        }

        let query = args[1].clone();
        let filename = args[2..].join(" ");
        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();
        let mut file_type = get_file_type(&filename);

        if file_type.is_none() {
            // 文件类型为空时,尝试添加 ".txt" 扩展名
            let mut filename_with_txt = filename.clone();
            filename_with_txt.push_str(".txt");
            file_type = get_file_type(&filename_with_txt);
        }

        let file_size = get_file_size(&filename);

        Ok(Config {
            query,
            filename,
            case_sensitive,
            file_type,
            file_size,
        })
    }
}

fn get_file_type(filename: &str) -> Option<String> {
    let extension = Path::new(filename)
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.to_lowercase());

    match extension.as_deref() {
        Some("txt") => Some("Text".to_string()),
        Some("jpg") | Some("jpeg") | Some("png") => Some("Image".to_string()),
        Some("mp3") | Some("wav") => Some("Audio".to_string()),
        Some("mp4") | Some("avi") | Some("mkv") => Some("Video".to_string()),
        Some("pdf") => Some("PDF".to_string()),
        Some("doc") | Some("docx") => Some("Word Document".to_string()),
        Some("xls") | Some("xlsx") => Some("Excel Document".to_string()),
        Some("ppt") | Some("pptx") => Some("PowerPoint Document".to_string()),
        _ => None,
    }
}

fn get_file_size(filename: &str) -> Option<String> {
    let metadata = fs::metadata(filename).ok()?;
    let size_in_bytes = metadata.len();

    let kb = size_in_bytes as f64 / 1024.0;
    let mb = kb / 1024.0;

    if mb >= 1.0 {
        Some(format!("{:.2} MB", mb))
    } else {
        Some(format!("{:.2} KB", kb))
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    if !fs::metadata(&config.filename).is_ok() {
        return Err("No such file or directory".into());
    }

    let contents = fs::read_to_string(&config.filename)?;

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query.to_lowercase(), &contents)
    };

    for line in results {
        println!("{}", line);
    }
    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results: Vec<&str> = Vec::new();
    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }
    results
}

pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results: Vec<&str> = Vec::new();
    for line in contents.lines() {
        if line.to_lowercase().contains(query) {
            results.push(line);
        }
    }
    
    results
}

0X5总结

现在输入cargo run to poem.txt也能打印该文件类型以及内容和大小,后续还有程序还有一些小报错和功能需要优化在空闲时间继续完善吧,也算是一个能动起来的命令行了,总之,老铁们泪目了,只是迈出了小小一步吧,关于Rust还有很多东西要学

本文主要是用Rus编写了一个小命令行,旨在分享一些学习心得,想用Rust编写命令行师傅们也能轻松看懂,从而优化程序,实现更多功能,总结以下几点:了解基本的Rust语法,学会使用工具辅助编程能大大提高效率比如:gpt、插件,总的来说需要多动手多尝试才能不断成长

0X6声明

本文所提供的信息仅供学习和研究网络安全技术之用途。读者在使用这些信息时应自行判断其适用性,并对其行为负全责。作者不对任何读者因使用本文中信息而导致的任何直接或间接损失负责。

转载须知:

如需转载本文,请务必保留本文末尾的免责声明,并标明文章出处为红细胞安全实验室,同时提供原文链接。未经许可,请勿对本文进行修改,以保持信息的完整性。

感谢各位师傅们的理解与支持。

本公众号不定期更新一些技术文章,还麻烦各位师傅们点点关注,这样才不会错过哦。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值