[Done] 有关 cargo-wipe的源代码阅读,和将来的Julia重构

写在前面

大家好,百忙之中抽出点时间来投入自己喜欢的东西中,比学习考研容易多了,就当是个消遣吧
上次不是组了个队,打算用 Julia 重写一些软件吗,结果拖了好久都没有完成,失败的原因有很多,

  1. 将项目的重写误认为是简单的语言替换
  2. 对于理解代码,先是无脑的重新对着仓库敲一遍代码,费时费力,很不讨好
  3. 没有对项目代码的结构有清晰的认识

因此,只好放弃这个项目
沉寂许久后,我打算一步一步来,我的想法是这样,先解决代码的结构问题,结构弄完后,接下来就简单了,自己用熟悉的语言重新写一遍就好了
这次的项目我自己一个人来,就不麻烦其他人了
这里只完成了 代码结构 的问题,还没有完成 代码重构 的问题,要是一起发的话可能要几个月后了,所以就慢慢发吧
当然,如果你从这篇文章中理解了 cargo-wipe 的结构,你也可以尝试写一个自己的 wipe 程序

代码结构

这个代码库的地址在 https://github.com/mihai-dinculescu/cargo-wipe

第三方包功能

anyhow

提供统一的错误处理机制,简化错误类型的创建和传播

num-format

用于数字格式化,可以将数字转换为带有 千位分隔符 ,货币符号等的字符串

number_prefix

提供数字前缀的格式化,如 将数字转换为带有 K(千) ,M(败亡) ,G(十亿) 等单位的表示

clap

命令行参数解析库,支持自动生成帮助信息,使开发者能够轻松地定义和解析命令行参数

yansi

控制台颜色输出库,用于在终端中输出带颜色文本

parameterized

单元测试框架扩展,用于编写参数化的测试用例,提高测试覆盖率和效率

rand

随机数e生成库,提供多种随机数生成器和分布,常用于模拟,游戏,加密等领域

程序入口

声明

use std::io::stdout;

use clap::StructOpt;

pub mod command;
pub mod dir_helpers;
pub mod wipe;

use crate::{
    command::Command,
    wipe::{Wipe, WipeParams},
};

程序引入了

  1. std::io::stdout
  2. clap::StructOpt
  3. command::Command
    本项目中 command 模块下的 Command 结构体
  4. wipe::{Wipe, WipeParams}
    本项目中 wipe 模块下的 WipeWipeParams 结构体

程序声明了以下文件模块

  1. command
  2. dir_helper
  3. wipe

主函数

主函数的功能是调用 clap 模块的,分发自 StructOpt 宏的结构体的 from_args() 函数生成 Command 结构体,通过模式匹配 Command 结构体来运行处理程序

fn main() -> anyhow::Result<()> {
    let mut stdout = stdout();
    let command: Command = Command::from_args();

    match command {
        Command::Wipe(args) => {
            let params: WipeParams = WipeParams::new(&args)?;
            Wipe::new(&mut stdout, &params).run()?;
        }
    }

    Ok(())
}

在错误处理上,主函数简单粗暴的使用 anyhow::Result<()> 来作为返回类型,这样可以简单的使用 ? 来传递 Result 类型,在函数的最后使用 Ok(()) 来保证函数能正确退出

command 模块

command 模块首先声明了三个结构体,这里我根据依赖顺序从先到后来说明一下

  1. LanguageEnum
  2. Args
  3. Command

还有一个独立的枚举类 DirectoryEnum

然后对 LanguageEnum 实现了

  1. str::FromStr
  2. fmt::Display

又对 DirectoryEnum 实现了

  1. From<LanguageEnum>
  2. fmt::Display

LanguageEnum 枚举

#[derive(Debug, PartialEq, Eq, Clone, StructOpt)]
pub enum LanguageEnum {
    #[structopt(name = "node_modules")]
    NodeModules,
    Node,
    Target,
    Rust,
}

枚举成员的 structopt 属性允许将命令行参数映射到枚举值,使得在命令行界面中可以方便地识别用户指定的语言或目录类型
例如,当用户运行 cargo wipe rust 时,命令行解析器会将 rust 参数转换为 LanguageEnum::Rust 值,从而决定执行哪些操作或处理哪个目录类型

  • NodeModules
    具有 structopt 属性,其 name 字段设置为 "nodemodules"
    这意味着当从命令行传递参数时,用户可以指定 node_modules 作为选项。这通常是指在 JavaScript 项目中由 Node.js 使用的依赖管理目录。
  • Node
    没有附加属性,代表 Node.jsJavaScript 相关的目录或配置
  • Target
    同样没有附加属性,通常在 Rust 项目中指代由 Cargo 构建系统生成的目标二进制和编译产物的目录
  • Rust
    没有附加属性,代表 Rust 语言相关的内容,可能用来标识 Rust 项目或配置

Args 结构体

#[derive(Debug, StructOpt)]
pub struct Args {
    pub language: LanguageEnum,

    #[structopt(short, long)]
    pub wipe: bool,

    #[structopt(short, long, parse(from_os_str))]
    pub ignores: Vec<path::PathBuf>
}
  • #[derive(Debug, StructOpt)]
    这个宏注解使得 Command 枚举能够被 structopt 解析器识别和处理, Debug 派生允许枚举的实例在调试时被打印出来

  • wipe: bool
    控制是否真正执行删除操作,此字段可以通过命令行参数 -w--wipe 来设置

  • ignores: Vec<path::PathBuf>
    这个字段用来存储用户指定的绝对路径列表,这些路径在清理过程中应该被忽略

Command 枚举

#[derive(Debug, StructOpt)]
#[structopt(bin_name = "cargo")]
pub enum Command {
    Wipe(Args),
}
  • #[derive(Debug, StructOpt)]
    这个宏注解使得 Command 枚举能够被 structopt 解析器识别和处理, Debug 派生允许枚举的实例在调试时被打印出来

  • #[structopt(bin_name = "cargo")]
    这个注解告诉 structopt 解析器,这个枚举定义的命令应该作为 cargo 二进制程序的子命令,这使得用户可以想使用其他 cargo 子命令一样使用 cargo wipe

  • Wipe(Args)
    Wipe 只携带一个 Args 结构体的实例,这意味着当用户调用 cargo wipe 时, structopt 会解析用户提供的参数,并将它们转换为 Args 结构体中的字段值

DirectoryEnum

#[derive(Debug, PartialEq, Eq)]
pub enum DirectoryEnum {
    NodeModules,
    Target,
}

这个不用说了吧

impl str::FromStr for LanguageEnum

这个 trait 的实现是把字符串转换成 Result<LanguageEnum>

impl fmt::Display for LangaugeEnum

这个 trait 的实现是把 LanguageEnum 转换为字符串,结构虽然是 Result ,但是没有错误的案例

impl From for DirectoryEnum

LanguageEnum 转换为 DirectoryEnum

impl fmt::Display for DirectoryEnum

Directory 转换成字符串

dir_helper 模块

DirInfo 结构体

这个模块声明了一个描述目录信息的 DirInfo 结构体

#[derive(Debug, Copy, Clone)]
pub struct DirInfo {
    pub dir_count: usize,
    pub file_count: usize,
    pub size: usize,
}

其中,

  • dir_count 表示目录个数
  • file_count 表示文件数量
  • size 表示大小,单位是byte

对于这个结构体,实现了这样几个函数

  1. fn new(dir_count: usize, file_count: usize, size: usize) -> Self
    这相当于其他语言中的构造函数,不是成员函数
  2. fn file_count_formatted(&self) -> String
    将文件数量格式化成字符串,格式是 Locale::en
  3. fn size_formatted_mb(&self) -> String
    将大小格式化为 mb 的大小
  4. fn size_formatted_flex(&self) -> Striing
    根据 size 的大小来动态决定输出的字符串

辅助类型别名

pub type PathsResult = io::Result<Vec<Result<String, io::Error>>>;

独立函数

  1. getpathstodelete

    这个函数的参数有

    • path: impl Into<PathBuf>
    • directory: &DirectoryEnum

    这个函数返回了 PathsResult ,有点复杂

    这个函数的作用是,根据 directory 参数,从指定的 path 中提取路径
    细节方面,这个函数使用了 fs::read_dir 来读取一个路径的属性,从而来获取内部下一个文件夹的属性

  2. dirsize

    这个函数的参数仅有一个

    • path: impl Into<PathBuf>

    返回的结果是

    • io::Result<DirInfo>

    这个函数用来统计一个文件夹中的文件夹数,文件数和总大小,返回一个 DirInfo

    细节方面,他也是使用 fs::read_dir 来读取一个路径的属性,从而来获取内部下一个文件夹的属性

wipe 模块

WipeParams

#[derive(Debug, PartialEq, Eq)]
pub struct WipeParams {
    pub wipe: bool,
    pub path: PathBuf,
    pub language: LanguageEnum,
    pub ignores: Vec<PathBuf>,
}

其中,

  • wipe: bool
    表示是否实际删除找到的路径
  • path: PathBuf
    当前目录的路径
  • language: LangaugeEnum
    指示要查找哪种类型的目录
  • ignores: Vec<PathBuf>
    表示要忽略的目录列表

Wipe

#[derive(Debug)]
pub struct Wipe<'a, W>
where
    W: io::Write,
{
    stdout: &'a mut W,
    params: &'a WipeParams,
    previous_info: Option<DirInfo>,
    wipe_info: Option<DirInfo>,
    ignore_info: Option<DirInfo>,
}

这里

  • stdout
    用于表示输出的写入者对象
  • params
    指向 WipeParams 的引用,包含是否擦除的参数
  • previous_info: Option<DirInfo>
    存储擦除前的目录信息
  • wipe_info: Option<DirInfo>
    存储被擦除的目录信息
  • ignore_info: Option<DirInfo>
    存储被忽略的目录信息

impl WipeParams

这里提供了一个构造函数 new ,可以传入 &Args 参数,这个 Args 参数类型在上面有提到过,在 command 模块中

#[derive(Debug, StructOpt)]
pub struct Args {
    /// rust | node
    pub language: LanguageEnum,
    /// Caution! If set it will wipe all folders found! Unset by default
    #[structopt(short, long)]
    pub wipe: bool,
    /// Absolute paths to ignore
    #[structopt(short, long, parse(from_os_str))]
    pub ignores: Vec<path::PathBuf>,
}

impl Wipe

  1. new

    初始化 Wipe 实例,接收一个写入者对象和 WipeParams 引用

  2. run

    调用 写入头写入内容写入脚 的方法来执行整个擦除过程

  3. write_header

    写入擦除命令的头部信息

  4. write_content

    获取要删除的目录列表,遍历每个目录,输出相关信息,并根据配置执行删除或忽略操作

  5. write_summary

    输出擦除前后目录的信息摘要,包括被忽略和被擦除的目录

  6. write_footer

    写入擦除命令的结束信息,包括下一步操作和确认信息

  7. write_space_line

    写入一个空行,相当于换行

代码重写 with Julia

第三方包功能替换

anyhow

Julia 中错误的处理哲学是,有错误就抛出就行了,用户关注业务逻辑就行了
Rust 是一个注重安全的语言,对错误处理有很细致的需求,两个语言的错误处理方式不同,在 Julia 中关注业务逻辑就行了

num-format

Python 中有一个 format 函数,可以直接将一个数按照千分位分隔,比如

format(10000, ",")

JuliaString/Format.jl 中有关于 formatJulia 实现(应该是吧) ,他对应的写法是

import Format: format

format(10000, commas = true)

于是我们这样定义函数 formatNumber

formatNumber(number::Number) = format(number, commas = true)

num-prefix

这个实现很简单,不需要调用其他语言

const prefixes = ["", "K", "M", "G", "T", "P"]

function prefixNumber(number::Number)::String
    magnitude = 1
    while abs(number) >= 1000
        magnitude += 1
        number /= 1000.0
    end

    return format("{:.1f}{}", number, prefixes[magnitude])
end

这里用到了格式化输出,保留一位小数

clap

虽然自己写了个 ArgumentParser.jl ,但是在这种情形下好像不合适,你想,我写的是一个包,不是一个二进制程序,你可以自己写一个文件导入在这个库,然后

using Wipe
using ArgumentParser

panic() = throw("usage: julia main.jl <language> [-w] [-i/--ignores <ignores...>]")

function main()
    flag = Flag(name ="wipe", short = "-w", defaultValue = false)
    positioned = Positioned(String, name = "language", index = 1)
    argument = Argument(Vector{String}, name = "ignores", short = "-i", long = "--ignores", require = false)


    if !haspositioned(ARGS, positioned)
        panic()
    end

    result = parseArguments(target = ARGS, flags = [flag], positions = [positioned], arguments = [argument])
    language = convert(LanguageEnum.T, result["language"])


    runwipe(pwd(), language, wipe = result["wipe"], ignores = get(result, "ignores", String[]))
end

main()

其中,这个 ArguemntParser.jlhttps://github.com/nesteiner/ArgumentParser.jl

yansi

这个库主要是在终端打印有颜色的字符, Julia 中有 printstyled 这个函数,不过既然是项目吗,我们这里 作死 试试调用 C++ 代码,
这是相关的颜色枚举和生成带有颜色字符串的函数

@enum Colors begin
    RED
    GREEN
    BLUE
    YELLOW
    MAGENTA
    CYAN
    WHITE
    GREY
end

@enum Styles begin
    BOLD
    UNDERLINE
    ITALIC
end

function color(c::Colors, string::String)::StyledString end # 生成带有颜色的字符串,返回 StyledString

function background(color::Colors, string::String)::StyledString end # 生成带有背景颜色的字符串,返回 StyledString

function style(s::Styles, string::String)::StyledString end # 生成带有字体样式的字符串,返回 StyledString

其中, StyledString 是指

@kwdef struct StyledString
    original::String
    result::String
end

为什么要这样使用呢,因为生成的字符串带有 ANSI 特殊字符,而隔壁的 Rust 在格式化成字符串的时候会忽略这些特殊化字符,但打印的时候会输出颜色
Julia 中格式化成字符串时会把这些特殊字符看作普通字符,占用格式化字符串的长度,需要对格式化后的字符串进行替换操作

程序入口

我们来看看 Wipe 这个 module 的定义

module Wipe

import Format: format

export WipeItem, WipeParams, Args, LanguageEnum, runwipe

include("utils.jl")
include("color.jl")
include("command.jl")
include("dir-helper.jl")
include("wipe.jl")

function runwipe(path::String, language::LanguageEnum.T; wipe::Bool = false, ignores::Vector{String} = String[])
    args = Args(language = language, wipe = wipe, ignores = ignores)
    params = WipeParams(path, args)
    item = WipeItem(stdout, params)

    writeHeader(item)
    writeContent(item)
    writeFooter(item)
end

end # module Wipe

其中这个 runwipe 就是整个模块唯一要暴露的函数,函数很简单,调用一些方法即可,没有很复杂的操作

结构体 和 枚举

LanguageEnum 和 DirectoryEnum

LanguageEnum 代表语言的枚举,而 DirectoryEnum 表示目录种类的枚举,代表这个目录是 node_modules 还是 target 目录
这两个枚举中有名字一样的成员 NodeModules ,如果直接定义枚举会造成命名冲突,我们的解决方案是使用模块来分隔
比如说,定义一个 LanguageEnum 模块,在其中定义数据

const NodeModules = 0
const Node = 1
const Target = 2
const Rust = 3

再将他们一一导出,这样就可以使用 LanguageEnum.NodeMoudles 来使用他们,跟完善的处理在 EnumX.jl 这个包里有体现,这里使用这个包

@enumx LanguageEnum begin
    NodeModules
    Node
    Target
    Rust
end

@enumx DirectoryEnum begin
    NodeModules
    Target
end

枚举有枚举类型, @enumx 定义的枚举的类型是 LanguageEnum.TDirectoryEnum.T

Args

Args 表示从命令行接收的参数,这里我照搬了 Rust 项目中的源代码定义,问题不大,这里也可以用自定义的 ArgumentParser.jl 库来生成 Args 结构
对于一个 cargo wipe 命令,我们可以这样调用

cargo wipe rust -w -i path_to_ignore_1 path_to_ignore_2

也可以这样

cargo wipe rust

他们会在当前目录下扫描是否有目录符合参数的定义

  1. 指定语言 rusttarget , node , 还是 node_modules
  2. 是否进行 wipe 擦除
  3. 指定忽略的目录

于是我们有这个结构体定义

@kwdef struct Args
    language::LanguageEnum.T
    wipe::Bool
    ignores::Vector{String}
end

WipeParams

这个也是按照源代码的定义来的,起始可以将 WipeParamsArgs 合并成一个结构体
WipeParamsArgs 相似,不同的是 WipeParams 多了一个 path 来指定扫描路径,源代码中的程序是扫描当前目录,所以 我觉得 这个结构体没有意义

@kwdef struct WipeParams
    wipe::Bool
    path::String
    language::LanguageEnum.T
    ignores::Vector{String}
end

WipeParams(path::String, args::Args) = WipeParams(
    wipe=args.wipe,
    path=path,
    language=args.language,
    ignores=args.ignores
)

DirInfo

这个结构体是用来描述一个目录中的文件数,目录树和总大小(不包括目录本身的大小)

@kwdef mutable struct DirInfo
    dirCount::Int
    fileCount::Int
    size::Int
end

WipeItem

@kwdef mutable struct WipeItem
    params::WipeParams
    stdout::IO
    previousInfo::Union{Nothing,DirInfo}
    wipeInfo::Union{Nothing,DirInfo}
    ignoreInfo::Union{Nothing,DirInfo}
end

这个结构体指定了输出的 IO 流,并在结构体中定义了用于总计的

  1. 先前扫描的目录 previousInfo
  2. 当前正在扫描的目录 wipeInfo
  3. 正在忽略的目录 ignoreInfo

重要方法

来看这张图片
img

其中,第一行开头显示的就是 writeHeader 输出的结果,并带有一个空行
第二部分是每一个扫描到的目录 文件数,大小 的计算,这是 writeContent 输出的结果
最后是统计当前目录有多大,该目录下有多少大小的文件被忽略掉,有多少文件可以擦除,擦除后的大小有多大,这是 writeFooter 输出的结果
这里不会给出具体详解,因为函数实在太简单了,写了用处不大 :smile:

功能实现细节 — yansi 功能库的替换

这里调用的库在 https://github.com/ikalnytskyi/termcolor ,把他的源代码下载过来,编辑下源代码
首先我们添加 extern "C" 告诉C++编译器不要对函数名进行修饰,从而确保C代码能够正确地链接到这个函数,我们有这么几个函数

extern "C" {
    const char * color(const char * s_color, const char * string);
    const char * background(const char * s_color, const char * string);
    const char * style(const char * s_styled, const char * string);
}

这里为什么要返回字符串而不是指定 IO 类型进行输出呢,因为在 Julia 中指定的 IO 类型我不知道怎么转换成 C++ 中的 basic_stream 类型,所以只好返回字符串来过渡一下

由于这个 C++ 库是这样调用的

std::cout << termcolor::red << "Hello, ";   

这个类似于 termcolor::red 的类型是一个函数,我们查看源代码并定义函数指针

typedef std::ostream & (* ftype)(std::ostream &);

这里我们无法将一个 Julia 的函数转为这种函数,因为我不知道这个 ostream 怎么转换 :sob:

并且,我们的自定义函数是这样调用的

const char * string = color("red", "hello world");

我们这样定义一个 map<string, ftype> 类型来将代表颜色的字符串转换为这个函数指针

map<std::string, ftype> color_map = {
    {"red", termcolor::red},
    {"green", termcolor::green},
    {"yellow", termcolor::yellow},
    {"blue", termcolor::blue},
    {"magenta", termcolor::magenta},
    {"cyan", termcolor::cyan},
    {"white", termcolor::white},
    {"grey", termcolor::grey}
};

等等,这里为什么要用 std::string 而不是 const char * 呢,你忘了吗,这个 const char * 是个指针啊,他指向的是地址,当一个 map 查找一个 keyconst char * 类型
的值时,他比较的是地址,而不是字符串的值,一定要记住

这样以后,我们在 termcolor.cc 中来定义 color 函数, termcolor.hpp 中代码太多了,直接 include 一下,然后重新写一个文件好了

const char * color(const char * s_color, const char * string) {
    ftype color = color_map[s_color];

    stringstream stream;

    if (color == nullptr) {
        stream << string;
    } else {
        stream << termcolor::colorize << color << string << termcolor::reset;
    }

    std::string result = stream.str();

    // char * buffer = new char[result.size() + 1];
    char * buffer = (char *) malloc(sizeof(char) * (result.size() + 1));
    std::copy(result.begin(), result.end(), buffer);
    buffer[result.size()] = '\0';

    return buffer;
}   

这里不能声明一个 char 数组,并进行 snprintf 再返回吗,为什么要返回一个 malloc 后的指针呢,这就牵涉到内存回收了,如果你返回的是字符串的字面常量值还好,但是你返回的是
字符串变量时,他就会在函数返回时销毁这个值,为了避免这种情况,将内存分配给指针, free 操作交给 Julia 来做

其他的函数都是类似的,类似的 map<std::string, ftype> 类型和函数定义
定义好函数后,我们在代码的目录中运行

g++ -shared -fPIC termcolor.cc -o libtermcolor.so

Julia 中,这是有关外部库加载的代码

using Libdl
import Base.Libc: free

const libtermcolor = Ref{Ptr{Nothing}}()

function __init__()
    libtermcolor[] = dlopen(joinpath(@__DIR__, "../include/termcolor/libtermcolor.so"))
end

这个写法我也是从 slack 中问到的,原来的代码是

const libtermcolor = dlopen(joinpath(@__DIR__, "../include/termcolor/libtermcolor.so"))

其实你可以发现,这个 libtermcolor 的类型是 Ptr{Nothing} ,翻译成 C 就是 void * ,放心,不是空指针
重新赋值一遍,你会发现这个指针的指向不变,就算你把他 free 掉重新赋值,再次使用它会造成整个 Julia 因为段错误崩溃掉,这就是为什么要用 Ref 的原因

如果你有一个函数需要修改外部作用域中的变量,而这个变量不是数组或其他可变类型,
你可以通过传递 Ref(value) 来实现这一点。
函数内部可以访问和修改 Ref 中的内容,而不改变 Ref 对象本身。

但是,还有一个问题,这个模块要是直接 using ,并调用相关函数时,会造成段错误,因为这个引用所指向的指针可能被释放掉了,需要重载模块的 __init__ 方法来使这个引用在
每次模块调用时重新赋值一次

const libtermcolor = Ref{Ptr{Nothing}}()

function __init__()
    libtermcolor[] = dlopen(joinpath(@__DIR__, "../include/termcolor/libtermcolor.so"))
end

Julia 中,这是有关函数定义的代码

@enum Colors begin
    RED
    GREEN
    BLUE
    YELLOW
    MAGENTA
    CYAN
    WHITE
    GREY
end

@kwdef struct StyledString
    original::String
    result::String
end

const colorMap::Dict{Colors, String} = Dict(
    RED => "red",
    GREEN => "green",
    BLUE => "blue",
    YELLOW => "yellow",
    MAGENTA => "magenta",
    CYAN => "cyan",
    WHITE => "white",
    GREY => "grey",
)

function color(c::Colors, string::String)::StyledString
    symbol = get(colorMap, c, nothing)
    if isnothing(symbol)
        throw("no such 16 color")
    end

    f = dlsym(libtermcolor[], :color)
    p = ccall(f, Cstring, (Cstring, Cstring), symbol, string)

    result = unsafe_string(p)
    free(p)

    return StyledString(original = string, result = result)
end

这里为什么不使用 String 而是另外定义一个结构体 StyledString 呢,因为 Julia 会把 ANSI 字符也看作字符串的一部分,而隔壁的 Rust 没有这种 Feature
需要在 writelnSpacedLine 中进行特殊处理

function writelnSpacedLine(
    wipe::WipeItem, 
    column1::Union{StyledString, String}, 
    column2::Union{StyledString, String}, 
    column3::Union{StyledString, String}, 
    column4::Union{StyledString, String})

    string1 = if column1 isa StyledString
        column1.original
    else
        column1
    end

    string2 = if column2 isa StyledString
        column2.original
    else
        column2
    end

    string3 = if column3 isa StyledString
        column3.original
    else
        column3
    end

    string4 = if column4 isa StyledString
        column4.original
    else
        column4
    end

    output = format("{:>$SPACING_FILES}{:>$SPACING_SIZE}{:>$SPACING_PATH}{}", string1, string2, string3, string4)

    for column in  [column1, column2, column3, column4]
        if column isa StyledString
            output = replace(output, column.original => column.result)
        end
    end

    println(wipe.stdout, output)
end