rsup
是使用 rust 编写的一个前端 npm 依赖包管理工具,可以获取到项目中依赖包的最新版本信息,并通过 web 服务的形式提供查看、升级操作等一一系列操作。
在前一篇文章中,记录初始的功能设计,自己的想法实现过程。在自己的使用过程功能中,也会发现一些存在的问题,有一些问题值得记录的再次标记,供大家参考。
rsup 工具安装
在上一篇文章中描写的安装rsup
工具部分错误,因为我本地是 macos 系统,
rust 默认执行cargo build
构建的是适合 macos 的可执行文件,对于 windows、linux 是不能直接用;还有一个问题,就是rsup-web
静态服务资源是不会被编译进工具包的,我本地能用也仅仅是我本地有源代码,它指向静态资源路径的就是我电脑的绝对地址。
可以采取将静态资源链接打包进二进制文件中。
- 使用
include_bytes!
rust 内置的宏将静态文件的内容嵌入到二进制文件 - 使用第三方 crate,比如
embed-resource
或者rust-embed
但是为了方便控制 web 静态资源,比如可以单独更新。采取了静态文件和可执行文件分离的方式,提供下载器同时下载rsup
可执行文件和rsup-web
web 静态资源。针对不同的系统定义默认的下载路径,然后通过配置文件读取 web 静态资源提供 web 服务。
rsup
工具包包含了配置文件、可执行文件、web 服务文件等。根据不同的系统,提供了三种安装工具包包括 linux、macos、windows。
macos installer
ubuntu instanller
windows instanller
提供了安装脚本文件sh
一键下载解压、安装。无需手动配置环境变量。
curl -fsSL https://github.com/ngd-b/rsup/blob/main/install.sh | sh
windows
用户需要手动下载安装包,解压后执行installer.exe
即可,并且需要手动配置环境变量。
installer
子包下载资源
这是为了解决上述问题新增的一个安装器,更友好的交互方式进行安装。也方便后面对下载方式进行更友好的优化。
执行安装器需要使用管理员权限。windows
右键以管理员身份执行 exe;类 linux 系统需要使用sudo
执行。
提供了从 github 或者 gitee 下载资源两种方式。使用第三方库 crate 目前只提供了从dialoguer
进行交互选择。github
下载资源。
use clap::{Parser, ValueEnum};
use dialoguer::{theme::ColorfulTheme, Select};#[derive(Parser, Debug, Clone, ValueEnum)]
pub enum Origin {Github,Gitee,
}impl Origin {// ...pub fn as_str(&self) -> &'static str {match self {Origin::Github => "github",Origin::Gitee => "gitee",}}/// 将枚举pub fn choices() -> Vec<&'static str> {vec![Origin::Github.as_str(), Origin::Gitee.as_str()]}
}/// 提示用户选择下载源
/// @return 下载源
pub fn prompt_origin() -> Origin {let select = Select::with_theme(&ColorfulTheme::default()).with_prompt("Please select download source...").default(0).items(Origin::choices().as_slice()).interact().unwrap();match select {0 => Origin::Github,1 => Origin::Gitee,_ => unreachable!(),}
}
使用reqwest
下载资源,并将资源保存到默认路径。文件路径output
的目录必须要提前创建,而fs::File::create(output)
创建了资源文件,如果文件已经存在会直接覆盖。
use reqwest::Client;
use tokio::fs;/// 下载文件
///
async fn download_file(client: &Client, url: &str, output: &str) -> Result<(), Box<dyn Error>> {// 下载地址let res = client.get(url).send().await?;if res.status().is_success() {// 下载成功// 保存文件到指定目录// 文件路径let mut file = fs::File::create(output).await?;// 保存文件let bytes = res.bytes().await?;file.write_all(&bytes).await?;Ok(())} else {let error_message = format!("Request failed with status code: {}", res.status());Err(Box::new(std::io::Error::new(std::io::ErrorKind::Other,error_message,)))}
}
文件下载完成后需要解压。所有的资源文件都是.tar.gz
格式的,使用flate2
解压文件,并且需要使用tar
进行解包提取到指定目录。
use flate2::read::GzDecoder;
use tar::Archive;/// 解压文件
///
/// @param url 下载地址
/// @param target_dir 保存目录
async fn decompress_file(url: &str, target_dir: &str) -> Result<(), Box<dyn Error>> {let tar_gz = File::open(url)?;let decomppress = GzDecoder::new(tar_gz);let mut archive = Archive::new(decomppress);// 处理解压目录,不存在则创建目录if !Path::new(target_dir).exists() {fs::create_dir_all(target_dir).await?;}archive.unpack(target_dir)?;Ok(())
}
所需要的资源下载解压完成后,现在默认目录下(类 linux 系统下是/opt/rsup
)有三个文件
rsup
可执行文件config.toml
配置文件web
web 静态资源
可以直接去执行rsup
可执行文件。但是当前目录下没有package.json
文件,我们可以指定参数--dir
去访问指定目录下的package.json
。为了方便命令的使用,安装时经将命令添加到环境变量中。
针对不同的操作系统,环境变量的配置文件不一样。windows
系统需要用户自行配置,macos
系统下是.zshrc
;其他类系统默认为.bashrc
use std::io::Write;
use std::{error::Error, fs::OpenOptions};/// 提示用户是否添加命令到环境变量
/// 默认添加
pub fn prompt_add_to_env(path: &str) -> Result<(), Box<dyn Error>> {// ... 省略部分代码let home_dir = std::env::var("HOME")?;// 确定系统使用的shelllet shell_file_name = match os {"macos" => ".zshrc",_ => ".bashrc",};// 环境变量配置目录let shell_config_path = format!("{}/{}", home_dir, shell_file_name);// 写入配置let mut file = OpenOptions::new().append(true).open(shell_config_path)?;writeln!(file, "\n# Add rsup to PATH\nexport PATH=\"{}:$PATH\"", path)?;
}
写入配置文件后,需要重新加载配置文件。执行source ~/.zshrc
或者.bashrc
,这样就可以全局使用rsup
命令了。
config
子包管理配置文件
配置文件的读取和写入使用config
子包,提供配置文件读写操作。installer
安装时会默认生成配置文件,在rsup
执行时会读取配置文件。为了方便配置文件管理,新增config
子包。
使用了 crate toml
对配置文件config.toml
进行读写序列化和反序列化。
use std::{error::Error,fs::{self, File},io::{self, Write},path::Path,
};impl Config {/// 读取配置文件///pub async fn read_config() -> Result<(), Box<dyn Error>> {// 读取配置文件let config_dir = Config::get_url();let config_file_dir = format!("{}/config.toml", config_dir);// ... 省略部分代码let config_content = fs::read_to_string(&config_file_dir)?;let config: Config = toml::from_str(&config_content)?;Ok(())}/// 写入配置文件pub async fn write_config() -> Result<Config, Box<dyn Error>> {let config_dir = Config::get_url();// ... 省略部分代码// 配置文件let config_url = format!("{}/config.toml", config_dir);let mut file = File::create(config_url.clone())?;let mut config = Config::default();// 配置文件路径config.dir = config_dir.clone();// 静态文件目录config.web.static_dir = format!("{}/web", &config_dir);let config_content = toml::to_string(&config)?;file.write_all(config_content.as_bytes())?;Ok(config)}
}
在主入口main
中执行读取配置文件,然后可以在各个子包中读取。为了方便使用,在config
中提供了静态全局变量CONFIG
,使用了第三方 crateonce_cell
实现。
use once_cell::sync::OnceCell;// 全局共享配置
pub static CONFIG: OnceCell<Config> = OnceCell::new();impl Config {pub async fn read_config() -> Result<(), Box<dyn Error>> {// ... 省略部分代码// 保存配置数据共享CONFIG.set(config).unwrap();}/// 父级包获取配置pub fn get_config() -> &'static Config {CONFIG.get().unwrap()}
}
这样就可以在其他子包中直接使用config::Config::get_config()
获取配置数据了。
配置文件中包含的配置项有:
name = "rsup"
version = "0.3.0"
dir = "/opt/rsup"[web]
port = 8888
static_dir = "/opt/rsup/web"[pkg]
npm_registry = "https://registry.npmmirror.com"
配置文件中的dir
字段是安装目录,默认安装在/opt/rsup
;web.port
字段是 web 服务的端口号,默认8888;pkg.npm_registry
字段是 npm 依赖源地址,默认为国内镜像。通常只建议修改pkg.npm_registry
设置源地址,方便请求依赖包。
command
子包提供命令行交互
提供了新的子包command
,用于解析命令行参数。统一管理命令行参数,方便使用。并且提供了一些方法使用。
在使用rsup
命令时,可以指定目录使用前端 npm 依赖管理web服务;也可以通过输入自命令进行交互式操作。
子命令包含了两部分:Config
配置命令;Update
更新命令。新创建了command
子包,在主包解析参数时进行逻辑判断,如果输入命令则执行对应的子命令;未输入子命令则默认执行 web 服务;
#[tokio::main]
async fn main() {let args = Cli::parse();match args.command {Some(Commands::Config { .. }) | Some(Commands::Update { .. }) => {run().await;}_ => {let package = Package::new();// 默认启动pkg解析服务let package_clone = package.clone();task::spawn(async move {pkg::run(args.pkg_args, package_clone).await;});web::run(package.clone()).await;}}
}
执行run()
方法调用了子包command
中的方法,并解析命令行参数,根据参数执行对应的操作。
pub async fn run() {let cli = Commands::parse();let _ = match cli {Commands::Config { config } => match config {ConfigOptions::List => ConfigOptions::list_config().await,ConfigOptions::Set { key, value } => ConfigOptions::set_config_value(&key, value).await,ConfigOptions::Get { key } => ConfigOptions::get_config_value(&key).await,ConfigOptions::Delete => todo!(),},Commands::Update { update } => {// 获取最新的包地址let (rsup_url, rsup_web_url) = utils::get_pkg_url(None);// 获取命令安装目录let config = external_config::Config::get_config().await;match update {UpdateOptions::Rsup => UpdateOptions::rsup_update(rsup_url, &config.dir).await,UpdateOptions::Web => {UpdateOptions::rsup_web_update(rsup_web_url, &config.dir).await}}}};
}
Config
配置命令
Config
配置命令用来管理配置文件,提供交互式操作。我们之前在installer
安装时,默认生成配置文件。通过config
命令可以查看、修改、删除配置项。
config list
可以展示出配置文件config.toml
,在我们安装好rsup
命令后,执行rsup config list
可以看到配置文件内容。
config set key value
可以修改配置文件中的值,例如:rsup config set web.port 9999
修改web服务端口号。
对于配置文件的访问、修改,主要是使用了子包config
中的方法。为了方便修改,对于子包config
的实现进行了调整,文章上面提到的实现为第一版实现,可以做对比差异。
初始实现的需要在core
主入口中调用一次读取配置文件,然后在其他子包中通过config::Config::get_config()
获取。这种方式在config
子包中不方便直接修改配置文件,需要重新读取。
使用tokio::sync::RwLock
实现读写锁,它是线程安全的。使用once_cell::sync::Lazy
实现懒加载,在首次使用时才去读取配置文件。
pub static CONFIG: Lazy<RwLock<Config>> = Lazy::new(|| {// 这里调用初始化let config = Config::read_config().unwrap();RwLock::new(config)
});
在使用set
设置配置项时,需要管理员权限,配置更新后会同步更新config.toml
配置文件
Update
更新命令
rsup
工具包含自身和web
服务两部分,提供了更新命令,可以更新rsup
工具和web
服务。
通过rsup update rsup
更新工具,通过rsup update web
更新web服务。
utils
子包提供公共方法
为了方便子包之间的共用方法的服用,提供了utils
子包,提供了一些公共方法。
遇到的问题
记录一下遇到的问题,方便后续查阅。
在使用本地config
模块与配置文件config
发生命名冲突
通过extern
明确导入外部模块
// 引入外部crate
extern crate config as external_config;
发布包到crates-io
时名称重复,本地引用修改名称
本地开发时使用的名称utils
,为了发布到crates-io
时,需要修改名称rsup_utils
,避免名称重复。然后本地引用时使用package
字段指定名称,这样不需要去调整代码里的引用。
[package]
utils = { version = "0.1.0", path = "../utils", package = "rsup_utils" }
下载文件时展示进度条
之前的文件下载时,控制台会陷入长时间的阻塞状态,没有任何反应,为了提供更好的交互体验,使用indicatif
展示进度条。
要采用进度条,在下载文件时就要使用流式读取文件,以便更新进度条。
增加两个新的lib库,futures-util
提供对于stream
的扩展函数。
cargo add indicatif
cargo add futures-util
修改请求reqwest
增加特性支持stream
[dependencies]
reqwest = { version = "0.12.9", features = ["stream"] }
修改之前的下载函数download_file
,不再使用write_all
一次性写入文件,通过分批次读取写入,并同步更新进度条。
/// 下载文件
///
async fn download_file(client: &Client, url: &str, output: &str) -> Result<(), Box<dyn Error>> {// 下载地址let res = client.get(url).send().await?;if res.status().is_success() {// 获取文件大小let content_size = res.content_length().ok_or("无法获取文件大小")?;// 下载成功// 保存文件到指定目录// 文件路径let mut file = fs::File::create(output).await?;// 创建进度条let pb = ProgressBar::new(content_size);pb.set_style(ProgressStyle::default_bar().template("{msg} [{elapsed_precise}] {bar:80} {percent}%")?.progress_chars("##-"),);// 创建流式响应体let mut downloaded = 0;let mut stream = res.bytes_stream();while let Some(item) = stream.next().await {let chunk = item?;file.write_all(&chunk).await?;let len = chunk.len() as u64;downloaded += len;pb.set_position(downloaded);}pb.finish_with_message("下载完成");// 保存文件// let bytes = res.bytes().await?;// file.write_all(&bytes).await?;Ok(())} else {let error_message = format!("Request failed with status code: {}", res.status());Err(Box::new(std::io::Error::new(std::io::ErrorKind::Other,error_message,)))}
}
解决web
服务自动后刷新页面加载不到的问题
这是典型的SPA的问题,由于我们使用的是history路由模式,路由由前端控制。我们刷新页面比如http://localhost:8888/home
时,会请求http://localhost:8888/home
,但是web
服务没有这个路由,所以会返回404
,导致刷新页面加载不到。
为了处理这个问题,需要增加通配符路由处理跳转route("/{tail:.*}", web::get().to(index))
,{tail:.*}
是一个路径参数,它可以匹配任何路径。
let server = HttpServer::new(move || {//...App::new().app_data(web::Data::new(ms.clone())).route("/", web::get().to(index)).wrap(cors).service(web::scope("/api").configure(api::api_config)).service(Files::new("/static", format!("{}/static/", &static_file_path)).prefer_utf8(true),).route("/ws", web::get().to(socket_index))// SPA fallback route.route("/{tail:.*}", web::get().to(index))
})
windos
系统下不同的命令执行名称
在windows
系统下,我们执行npm -v
时,实际内部执行的是npm.cmd -v
,而在mac系统下,执行npm -v
时,实际内部执行的是npm -v
,所以需要根据系统类型,使用不同的命令。
// 判断系统,如果是windows,则使用npm.cmd
let npm_cmd = if cfg!(windows) { "npm.cmd" } else { "npm" };
如果安装时是.exe
的话就不需要添加后缀了,直接使用即可。比如node
web
服务API参数映射处理
在处理API请求参数时,通过枚举定义了参数类型,然后通过解析匹配到指定的数据结构。
async fn update_pkg(req: web::Json<ReqParams>,data: web::Data<Ms>,
) -> Result<impl Responder, Error> {match &*req {ReqParams::UpdatePkg(params) => {}err => {// ...}
}
如果定义的数据结构字段存在重叠,某个结构完全包含另一个结构的字段,在匹配时就需要将完全包含的结构放在前面,否则可能会匹配到错误的结构。
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
pub enum ReqParams {UpdatePkg(UpdateParams),// 删除// 目前接受一个nameRemovePkg(RemoveParams),
}
UpdateParams
和RemoveParams
存在字段重叠,UpdateParams
包含了RemoveParams
的所有字段,要想匹配到UpdateParams
,需要将RemoveParams
放在前面。
最后
部署了rsup
文档服务网站rsup|Npm Helper
往期rsup
文章:
- 模式匹配、trait 特征行为、必包、宏
- 多线程任务执行
- 并发线程间的数据共享
- 包、模块,引用路径
- 开发一个命令行工具
- rust 命令行工具rsup管理前端npm依赖