get-folder
Version:
High-Performance Folder Size Calculator.
433 lines (425 loc) • 13 kB
JavaScript
/*!
* get-folder v0.1.1
* High-Performance Folder Size Calculator.
* Copyright (c) jl15988.
* This source code is licensed under the MIT license.
*/
import { promises } from 'fs';
import { join } from 'path';
import { BigNumber } from 'bignumber.js';
import * as path from 'node:path';
/**
* 简化的信号量类,用于控制并发数量
*
* 工作原理:
* 1. 维护一个固定数量的"令牌"(tokens)
* 2. 每个并发操作需要先获取令牌才能执行
* 3. 没有令牌时,操作会被挂起等待
* 4. 操作完成后释放令牌,唤醒等待的操作
*
* 这样确保同时运行的操作数量不会超过设定的并发限制,
* 避免文件句柄耗尽、内存占用过高等问题
*/
class SimpleSemaphore {
/**
* 当前可用的令牌数量
*/
available;
/**
* 等待队列:存储等待令牌的 resolve 函数
*/
waiters = [];
/**
* 构造函数
* @param concurrency 最大并发数(令牌总数)
*/
constructor(concurrency) {
this.available = concurrency;
}
/**
* 获取令牌(申请执行权限)
*
* 执行逻辑:
* 1. 如果有可用令牌,立即获取并返回
* 2. 如果没有令牌,创建 Promise 并加入等待队列
* 3. 当前操作会被挂起,直到有令牌释放时被唤醒
*
* @returns Promise<void> 当获得令牌时 resolve
*/
async acquire() {
// 情况1:有可用令牌,立即获取
if (this.available > 0) {
// 消耗一个令牌
this.available--;
// 立即返回,继续执行
return;
}
// 情况2:没有可用令牌,需要等待
return new Promise((resolve) => {
// 将 resolve 函数包装后放入等待队列
// 当有令牌释放时,会调用这个函数唤醒等待者
this.waiters.push(() => {
// 消耗令牌
this.available--;
// 唤醒等待的 acquire() 调用
resolve();
});
});
}
/**
* 释放令牌(归还执行权限)
*
* 执行逻辑:
* 1. 优先唤醒等待队列中的第一个等待者
* 2. 如果没有等待者,增加可用令牌数
*
* 这样确保令牌总数始终保持不变
*/
release() {
// 情况1:有等待者,立即唤醒第一个
if (this.waiters.length > 0) {
// 取出队列头部的 resolve 函数
const resolve = this.waiters.shift();
// 调用 resolve,唤醒对应的 acquire() 调用
resolve();
// 注意:这里不增加 available,因为令牌直接转给了等待者
}
// 情况2:没有等待者,归还令牌到令牌池
else {
// 增加可用令牌数
this.available++;
}
}
}
/**
* 路径工具
*/
class PathUtil {
/**
* 判断文件路径是否为隐藏文件(简化实现,仅判断是否以'.'开头)
* @param filePath 文件路径
*/
static isHiddenFile(filePath) {
const name = path.basename(filePath);
// 空文件名或当前目录/上级目录
if (!name || name === '.' || name === '..') {
return false;
}
// 跨平台检查:以 . 开头的文件
return name.startsWith('.');
}
}
/**
* 正则工具
*/
class RegExpUtil {
/**
* 测试 val 是否符合 testRegs 规则
* @param testRegs 正则规则
* @param val 值
*/
static tests(testRegs, val) {
return testRegs.some(pattern => pattern.test(val));
}
}
class BaseScene {
/**
* 已处理的硬链接
*/
processedInodes = new Set();
/**
* 检查是否应该忽略指定路径
* @param ignores 忽略列表
* @param itemPath 项目路径
* @param includeHidden 是否包含隐藏文件
* @returns 是否应该忽略
*/
shouldIgnorePath(ignores, itemPath, includeHidden = true) {
// 检查隐藏文件
if (!includeHidden && PathUtil.isHiddenFile(itemPath)) {
return true;
}
if (ignores && ignores.length > 0) {
// 检查忽略模式
return RegExpUtil.tests(ignores, itemPath);
}
return false;
}
/**
* 获取inode关键值,如果stats为字符串让直接返回
* @param stats fs统计信息
*/
getInodeKey(stats) {
if (typeof stats === "string") {
return stats;
}
return `${stats.dev}-${stats.ino}`;
}
/**
* 判断是否已有 inode
* @param inode inode
*/
hasInode(inode) {
return this.processedInodes.has(this.getInodeKey(inode));
}
/**
* 添加 inode
* @param inode inode
*/
addInode(inode) {
this.processedInodes.add(this.getInodeKey(inode));
}
/**
* 检查 inode,用于防止硬链接重复
*
* 如果已有inode将返回true,否则记录inode并返回false
* @param stats fs统计信息
*/
checkInode(stats) {
const inodeKey = this.getInodeKey(stats);
if (this.hasInode(inodeKey)) {
return true;
}
this.addInode(inodeKey);
return false;
}
/**
* 清除 inode
*/
clearInode() {
this.processedInodes.clear();
}
}
/**
* 文件夹大小计算器类
*/
class FolderSize extends BaseScene {
options;
/**
* 快捷获取文件夹大小
* @param folderPath 文件夹路径
* @param options 大小计算选项
* @returns 文件夹大小结果 Promise
*/
static getSize(folderPath, options = {}) {
return FolderSize.of(options).size(folderPath);
}
/**
* 与构造函数一致
* @param options 大小计算选项
*/
static of(options = {}) {
return new FolderSize(options);
}
/**
* 构造函数
* @param options 大小计算选项
*/
constructor(options = {}) {
super();
this.options = {
maxDepth: Number.MAX_SAFE_INTEGER,
ignores: [],
includeHidden: true,
includeLink: true,
concurrency: 2,
ignoreErrors: false,
inodeCheck: true,
// 默认继续
onError: () => true,
...options
};
}
/**
* 计算文件夹大小
* @param folderPath 文件夹路径
* @returns 文件夹大小结果 Promise
*/
async size(folderPath) {
this.clearInode();
const normalizePath = path.normalize(folderPath);
// 使用 Node.js 实现
return await this.calculateSizeNodeJs(normalizePath);
}
/**
* 计算文件夹大小
* @param folderPath 文件夹路径
* @returns 计算结果
*/
async calculateSizeNodeJs(folderPath) {
let totalSize = new BigNumber(0);
let fileCount = 0;
let directoryCount = 0;
let linkCount = 0;
/**
* 递归处理文件夹项目
* @param itemPath 项目路径
* @param depth 当前深度
*/
const processItem = async (itemPath, depth = 0) => {
// 检查是否应该忽略此路径
if (this.shouldIgnorePath(this.options.ignores, itemPath, this.options.includeHidden)) {
return;
}
let stats;
try {
// 获取文件统计信息
stats = await promises.lstat(itemPath, { bigint: true });
}
catch (error) {
return this.handleError(error, `无法获取文件信息: ${error.message}`, itemPath);
}
if (this.options.inodeCheck) {
if (this.checkInode(stats))
return;
}
const isSymbolicLink = stats.isSymbolicLink();
const isDirectory = stats.isDirectory();
const isFile = stats.isFile();
const fileSize = stats.size;
if (isSymbolicLink) {
linkCount++;
if (this.options.includeLink) {
totalSize = totalSize.plus(fileSize.toString());
}
}
else {
totalSize = totalSize.plus(fileSize.toString());
}
if (isDirectory) {
// 检查文件夹深度限制,只在递归进入子目录前检查
if (depth > this.options.maxDepth) {
return;
}
if (itemPath !== folderPath) {
// 排除当前文件夹
directoryCount++;
}
let entries;
try {
// 读取目录内容
entries = await promises.readdir(itemPath);
}
catch (error) {
return this.handleError(error, `无法读取目录: ${error.message}`, itemPath);
}
// 简化的并发控制
const semaphore = new SimpleSemaphore(this.options.concurrency);
await Promise.all(entries.map(async (entry) => {
await semaphore.acquire();
try {
const childPath = join(itemPath, entry);
await processItem(childPath, depth + 1);
}
catch (error) {
throw error;
}
finally {
semaphore.release();
}
}));
}
else if (isFile) {
fileCount++;
}
};
await processItem(folderPath);
return {
size: totalSize,
fileCount,
directoryCount,
linkCount
};
}
/**
* 处理错误
* @param error 错误对象
* @param message 错误消息
* @param path 错误路径
*/
handleError(error, message, path) {
const errorRes = { error, message, path };
// 如果设置了错误回调,调用它
if (this.options.onError) {
const shouldContinue = this.options.onError(errorRes);
if (!shouldContinue) {
throw new Error(`计算被用户停止: ${message}`);
}
}
if (!this.options.ignoreErrors) {
throw new Error(message);
}
}
}
/**
* 文件系统工具类
* 提供常用的文件系统操作辅助功能
*/
class FileSystemUtils {
/**
* 格式化文件大小为人类可读的字符串
* @param bytes 字节大小
* @param decimals 小数位数
* @returns 格式化后的大小字符串
*/
static formatFileSize(bytes, decimals = 2) {
if (!bytes) {
return '0 Bytes';
}
if (typeof bytes === 'string') {
if (bytes.length === 0 || bytes.trim().length === 0) {
return '0 Bytes';
}
}
const size = new BigNumber(bytes);
// 如果大小为0,直接返回
if (size.isZero()) {
return '0 Bytes';
}
const k = new BigNumber(1024);
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 计算单位级别
let i = 0;
let tempSize = size;
while (tempSize.gte(k) && i < sizes.length - 1) {
tempSize = tempSize.div(k);
i++;
}
// 计算最终结果
const result = size.div(k.pow(i));
return result.toFixed(dm) + ' ' + sizes[i];
}
/**
* 获取路径的相对路径
* @param basePath 基础路径
* @param targetPath 目标路径
* @returns 相对路径
*/
static getRelativePath(basePath, targetPath) {
const normalize = (path) => path.replace(/[\\\/]+/g, '/');
const base = normalize(basePath);
const target = normalize(targetPath);
if (target.startsWith(base)) {
const relative = target.substring(base.length);
return relative.startsWith('/') ? relative.substring(1) : relative;
}
return target;
}
/**
* 验证路径是否安全(防止路径遍历攻击)
* @param path 要验证的路径
* @returns 是否安全
*/
static isPathSafe(path) {
// 检查路径遍历攻击模式
const dangerousPatterns = [
/\.\.\//, // ../
/\.\.\\/, // ..\
/^\/+/, // 绝对路径
/^[a-zA-Z]:\\/ // Windows 绝对路径
];
return !dangerousPatterns.some(pattern => pattern.test(path));
}
}
export { FileSystemUtils, FolderSize, SimpleSemaphore };