使用 C#、SQL 和 Analysis Service 实现类似 TreeSize 的应用程序,第一部分
用于管理和分析公司中多个文件服务器磁盘空间的工具。
引言
经过数天编码和在此网站上查找资源及信息后,我决定写这篇文章,这是我几乎每天都在系统管理生活中使用的工具。
本文将介绍一个用 C# 编写的工具,该工具将扫描文件夹(UNC、本地驱动器/路径)并将以下信息存储在 SQL 数据库中:
- 名称
- 大小
- 日期/时间信息(创建、访问、修改)
- Owner
- ACL(仅文件夹)和权限继承
一旦存储了这些数据,就可以用于计算一个简单的 MS OLAP 应用程序(MS Analysis Services 2008),该应用程序将汇总多个维度的文件数量和文件大小,重建文件夹树,然后可以以多种方式使用。连接到它的 Excel 数据透视表是访问其中一些数据的好方法。
实际案例:我使用此软件每周扫描我工作的那些文件服务器。总共有 22 个网络共享被扫描,总计约 140 万个文件夹和 1500 万个文件。为了方便起见,扫描在托管数据库的服务器上以命令行方式运行,大约需要 25-26 小时。(扫描是多线程的,同时运行在 22 个共享上)
背景
既然市面上有其他 TreeSize 应用程序,为什么还要重新制作一个 TreeSize?
首先,这个项目在大约 6 年前就开始了,当时我遇到了一个简单的问题:我们的公司文件服务器上有太多的文件!我们能做什么?
Treesize 根本无法满足需求。那时我还在玩 VB6 并部署 Essbase 服务器,所以我开始理解 OLAP 的含义,并寻找 MS 解决方案。我发现我用 SQL DB 开发的 filescan 对于具有父子维度的 OLAP Cube 非常合适(这在 Essbase 上从未奏效……)
这让我能够更有效地分析用户是如何存储文件的。
从那时起,代码不断演进、更改,我集成了 ACL 到扫描中,代码迁移到了 c#,然后我由于文件服务器数量的增长而集成了多线程,最后我迁移到了 .NET 3.5,尝试了 LINQ-to-SQL 并采用了它。
使用代码
必备组件
要运行此代码,需要不少东西:
- VS2008/.net 3.5
- SQL Server 2005 Developper 或 Enterprise Edition(或 2008)
- Analysis Services
准备环境
在 zip 文件中,您应该找到一个 'CreateDB.sql' 脚本,编辑它并更改所有出现的
D:\MSSQL\MSSQL.1\MSSQL\DATA\
替换为所需的数据路径(在运行脚本之前,路径必须存在)。
然后通过运行文件 'TreeAnalysis.xmla' 来创建 OLAP 应用程序。
您需要更改 OLAP 应用程序中的 SQL 连接,进入“数据源”,然后修改连接字符串以连接到您的 SQL Server。
您可以编译并运行代码。
命令行示例
DiskAnalysis.exe -s:localhost -d:DiskReport -o:localhost -a:TreeAnalysis DiskAnalysis.exe -s:localhost -d:DiskReport -o:localhost -a:TreeAnalysis -l:3 -t:4
数据库
数据库相当简单:
- 一个文件属于一个文件夹,并且只有一个所有者(SID)。
- 一个文件夹属于一个文件夹,并且只有一个父文件夹,包含多个子文件夹、多个文件、多个 ACL。
- 这给出了一个类似下面 LINQ-to-SQL 的架构。
- SID 表存储 SID,它是唯一的,并在可能的情况下存储了转换后的名称。
- “Rights”表大致完成了同样的事情,但对于 API 为 ACL 提供的数字值。
这些是关于数据库的基础信息,在 POI 部分有更多有趣的内容。
TreeDetail 表存储要扫描的根文件夹,示例:
您需要手动在此处插入文件夹,因为我还没有(或还没有)制作一个管理界面。
LINQ-to-SQL 类 - DiskReport.dbml
这是将数据库链接到代码的类。
多亏了它,我才得以在扫描过程中删除大约 200 行 SQL 查询。
多亏了 VS2008,我只需从服务器资源管理器中拖放数据库对象,即可完成!
我将此类制作成了一个独立的类库,因为有多个工具在使用这个数据库,例如文件搜索器和“域用户管理器”,显示域用户可以访问哪些文件夹,但这又是另一篇文章了。
DiskAnalysis
核心代码
1 - Program.cs
首先,我从 2 个公共静态成员开始:
public static Semaphore _pool;
public static System.Data.SqlClient.SqlConnectionStringBuilder sb;
_pool
将作为多线程扫描的限制器。sb
是一个简单的方法,可以为我的所有线程提供相同的数据库信息。可能有更好的方法,但我觉得将数据库连接信息传递给运行线程的对象会增加代码负担。
一旦扫描完命令行参数,我就初始化 SqlConnectionStringBuilder。
sb = new System.Data.SqlClient.SqlConnectionStringBuilder();
sb.DataSource = servername;
sb.InitialCatalog = database;
sb.IntegratedSecurity = true;
然后程序将开始扫描,扫描完成后,我将对扫描的 ACL 和 SID 进行一些清理和转换,最后,我将启动 OLAP 数据库的完整处理过程。
1.1 - 准备扫描:start(int level, int maxThread)
if(maxThread > 0) _pool = new Semaphore(maxThread, maxThread);
如果 maxThread 变量不在命令行中,或者设置为 0,则最大线程数没有限制。我决定在这种情况下不初始化信号量,稍后我会检查信号量是否为 null 以便等待。
接下来,我使用字符串构建器初始化 LINQ-to-SQL 类的 DataContext。我初始化数据库,然后获取将要扫描的文件夹列表。
DiskReport.DiskReportDataContext dc = new DiskReport.DiskReportDataContext(sb.ConnectionString);
if (!clearDB(dc)) return;
var q = from t in dc.TreeDetails
where t.Enabled == true
select new { t.id, t.RootFolder };
if (q.Count() == 0) return;
如果 clearDB() 函数不起作用,我将中止程序,扫描将无法正常工作。
如果没有要扫描的文件夹,则退出。
好的,一切准备就绪后,我初始化 4 个数组:Tree[]
将执行实际扫描。ThreadStart[]
将包含 Tree 对象中线程启动函数的引用。Thread[]
将是实际的线程对象。ManualResetEvent[]
将用于在所有子线程完成时通知主线程。
Tree[] at = new Tree[q.Count()];
ThreadStart[] ats = new ThreadStart[q.Count()];
Thread[] ath = new Thread[q.Count()];
ManualResetEvent[] events = new ManualResetEvent[q.Count()];
int i=0;
foreach (var root in q)
{
if(root.RootFolder!="")
{
events[i] = new ManualResetEvent(false);
at[i] = new Tree(root.RootFolder, root.id, level, events[i]);
ats[i] = new ThreadStart(at[i].startProcess);
ath[i] = new Thread(ats[i]);
ath[i].Name = root.RootFolder;
ath[i].Start();
i++;
Thread.Sleep(5000);
}
}
WaitHandle.WaitAll(events);
重要的是用我需要的“StartProcess()”函数的所有参数初始化“Tree”对象。
由于启动问题,我还添加了 5 秒的等待时间,我将在下面的章节中讨论。
1.2 - UpdateACL()
private static void updateACL()
{
Console.WriteLine("- Update ACLs");
DiskReport.DiskReportDataContext dc = new DiskReport.DiskReportDataContext(sb.ConnectionString);
dc.sp_ClearACLs();
var rights = (from r in dc.FoldersACLs
select r.Rights).Distinct();
foreach (Int32 right in rights)
{
FileSystemRights fsr = (FileSystemRights)right;
string str = fsr.ToString();
if (right.ToString() == str) str = fsr.ToString("X");
dc.sp_InsertRight(right, str);
Console.WriteLine("insert {0} as {1}", right, str);
}
}
此函数的目标是将数值(如 0x001F01FF)转换为可读的、人类友好的信息,在这种情况下是“完全控制”。
如果扫描到的 ACE 中的权限值无法转换为文本(在旧的、长期运行的文件服务器上,实时数据中经常发生这种情况),我将该值转换为十六进制字符串。
SQL Server 中的一个存储过程“dc.sp_ClearACLs();
”用于清理大多数这些冗余且通常无用的条目。
1.3 - UpdateSID()
private static void updateSID()
{
Console.WriteLine("- Update SIDs");
System.Security.Principal.SecurityIdentifier sid;
DiskReport.DiskReportDataContext dc = new DiskReport.DiskReportDataContext(sb.ConnectionString);
dc.CommandTimeout = 6000;
dc.sp_UpdateSIDList();
var SSDLs = from s in dc.SIDs select s.SID1;
foreach (string ssdl in SSDLs)
{
sid = new System.Security.Principal.SecurityIdentifier(ssdl);
string ntAccount = "";
try
{
ntAccount = sid.Translate(typeof(System.Security.Principal.NTAccount)).Value;
}
catch (Exception ex)
{
Console.WriteLine("{0} {1}", ssdl, ex.Message);
}
if (ntAccount == "") ntAccount = "<Unknow>";
var s = (from a in dc.SIDs
where a.SID1 == ssdl
select a).Single();
s.Name = ntAccount;
dc.SubmitChanges();
}
}
此函数将 SID 字符串转换为可读的 NT 帐户名称。(例如:“S-1-5-21-3872767328-3467091273-3605603707-1001” = 在我的计算机上是“DESKTOP\Administrator”)
此函数首先调用存储过程 sp_UpdateSIDList(),该存储过程将扫描 Files 和 Folders 表中的 SID 列表,提取所有唯一的 SID 并将它们存储在 SID 表中。
insert into SIDs (SID)
select DISTINCT owner
FROM files
where owner not in (select SID from SIDs)
然后它将尝试使用 translate 函数进行转换:ntAccount = sid.Translate(typeof(System.Security.Principal.NTAccount)).Value;
这两个更新函数(sid 和 acl)在扫描后调用,以提高性能。如果在每次插入时都进行这些转换,扫描将不可行。
ProcessCube()
最后一个函数非常简单,可以概括为:
Server s = new Server();
s.Connect(olapServer);
s.Databases[application].Process(ProcessType.ProcessFull);
Server 属于 Microsoft.AnalysisServices 命名空间。
2 - Tree.cs
这个类是完成工作的地方。
构造函数初始化 Tree 的成员,这些成员将在扫描过程中使用。关于这一行的一个小说明:
dc.ObjectTrackingEnabled = false;
这将允许 DataContext 跟踪更少的对象,并防止使用类似 `Table
当代码从纯粹的“INSERT INTO”SQL 查询切换到 LINQ-to-SQL 语法时,我尝试过这样做,这是一个非常糟糕的主意。
大量的内存泄漏和极差的性能(从 10 分钟,我运行到 12 小时以上)。
然后我切换到 SQL 存储过程来执行文件和文件夹的数据库插入,我发现我的性能与直接 SQL 命令相同。
2.1 StartProcess()
这个函数是启动线程的函数,它以这行开始:
if(Program._pool!=null) Program._pool.WaitOne();
这将在达到允许的运行线程数时等待。
如果信号量池为 null,我不等待,因为没有限制。
然后它调用 initProcess() 函数,该函数将确定要扫描的路径是本地驱动器还是 UNC 路径。如果它是 UNC 路径,它将把该路径映射到一个字母。
出于日志记录的目的,它还会删除路径末尾的“\”。
接下来,它将根文件夹插入数据库并启动递归函数(见下文),该函数将执行扫描。
最后,它会在需要时断开网络驱动器连接,并设置事件,告知主线程它已完成。
2.2 processFolder(path, level, idparent)
此函数将执行以下操作:
- 列出路径中的所有文件,并将它们及其所有属性插入 SQL 数据库。
- 对于路径中的每个子文件夹:
- 将文件夹插入数据库。
- 获取 ACL 并将其插入数据库。
- 使用子文件夹作为参数递归调用自身。
关于文件日期的这一点需要说明。我遇到了数百万个文件,其中一些文件的创建日期非常奇怪(从 1701-xx-xx 到 2400-xx-xx,我猜是来自旧系统,或与 Lotus Mail 分离)。一段时间后,我发现最后访问时间永远不会出错(几乎从不),所以我决定如果创建或修改日期早于 1950 年,我将将其设置为最后访问日期。
这是任意的,可以更改为其他内容,但至少文件会被插入数据库,否则它们会被跳过,导致数据错误。
2.3 InsertFolder()
此函数将在数据库中插入一个新文件夹并返回新的数据库 ID。
首先,我声明:
int? id=0;
这将允许存储过程的“OUTPUT”类型的参数返回数据,否则将生成类型不兼容的错误。
然后它尝试获取文件夹所有者。我在这里遇到了一个奇怪的问题:即使在 getFolderOwner 函数中使用了 try-catch 块,我也需要添加另一个 try-catch。我仍然不知道为什么。
接下来的测试是为了“第一个文件夹还是不是”要插入的。
在数据库的设计中,为了允许一个好的父子表工作,根文件夹必须有一个设置为 null 的父 ID,并且其名称代表完整路径。
例如:\\Fileserve1\share
而子文件夹不能将 parentID 设置为 null,它们的名字不应包含完整路径,只应包含短名称。
这是 name 值的结果赋值:
string name = (idparent.HasValue) ?
folder.Substring(folder.LastIndexOf(@"\") + 1) :
folder;
在调用存储过程时,有这一行:
this.rootFolder + folder.Substring(2)
这将始终代表当前文件夹的完整路径,无论是映射的驱动器还是本地驱动器,无论是根文件夹还是子文件夹。
2.4 - storeACL()
private void StoreACL(Int32 idFolder, string folder)
{
DirectoryInfo dinfo = new DirectoryInfo(folder);
DirectorySecurity dSecurity = dinfo.GetAccessControl();
AuthorizationRuleCollection returninfo = dSecurity.GetAccessRules(true, true, System.Type.GetType("System.Security.Principal.SecurityIdentifier"));
foreach (FileSystemAccessRule fsa in returninfo)
{
try
{
dc.sp_InsertFolderACL(idFolder,
fsa.IdentityReference.Value,
(int)fsa.FileSystemRights,
(fsa.IsInherited) ? 1 : 0,
(int)fsa.InheritanceFlags,
(int)fsa.PropagationFlags);
}catch (Exception ex)
{Console.WriteLine(ex.Message);}
}
}
这个函数从旧的 VB6 和 kernel32/advapi API 调用转换为干净的 .NET 代码是一个痛苦的过程。文档很少,并且找到各种对象如何相互关联最终得到一个 FileSystemAccessRule 对象(它是一个 ACE,即访问控制条目)是一个漫长而困难的过程。
但它在这里。您还会注意到显式转换为 (int),因为 fsa 的这些成员是标志结构,不会按原样存储在数据库中。
FileSystemRights 值是会在 updateACL 函数中转换的值。
2.5 - get owners
private string getFileOwner(string filename)
{
FileSecurity tmp = new FileSecurity(filename, AccessControlSections.Owner);
string owner = "<unknown>";
try
{
owner = tmp.GetOwner(System.Type.GetType("System.Security.Principal.SecurityIdentifier")).Value;
}catch { }
return owner;
}
这个函数也是获取文件(或文件夹)所有者的干净方法,而不是通过 P/Invoke 调用 Win32 API。
3 - NetworkDrive.cs
我需要找到我从哪里得到这段精彩的代码,并将其归功于原始开发者。
我想我从 CodeProject 上抓取的,但不确定,找到它时会更新。
这个类相当自明,它将 UNC 路径映射到一个驱动器。我修改了代码,使“nextFreeDrive”函数公开,以便 Tree 对象可以使用它。
关注点
这个软件是我目前正在开发的其他工具的基础。
有一个文件查找器,可以让我搜索数百万个文件夹,找到我需要的文件(例如网络上禁止使用的所有 .AVI 和 .MP3 文件),查找重复文件等。
(这可以通过 SQL 查询实现,但我希望其他人也能使用)
我正在开发一个 Active Directory 用户管理器,它允许管理员查看用户通过组可以访问哪些文件夹。
还可以通过 OLAP 和 MDX 查询进行报告。目前我不知道它到底是如何工作的,我查询 OLAP 应用程序的唯一方法是通过 Excel。
我认为可以进行更多分析(例如,所有用户的家目录存储用于邮件或其他内容的相同文件夹,精心设计的 MDX 查询可以仅显示这些文件夹的详细信息)。
根据您的需求,此软件可以成为许多有用东西的基础。
OLAP 数据库
这是立方体的设计,事实表和维度在 SQL Server 中是视图。
这是它在 Excel 中的示例外观。
关于数据库的更多信息
如引言中所述,数据库对于它存储的数据来说相当简单。但是,如果只是这样创建,就会出现性能、数据库文件大小、调优等问题。
例如,我实际使用的数据库(拥有 1500 万个文件的那个)大约有 7GB 大,随着文件数量的增长和查询性能的下降,我添加了许多索引。
在开发早期,我发现有些表不是永久数据,只需要在运行时删除并重新创建。我可以使用 truncate table,但它不令我满意,因为数据文件不够干净,随着文件碎片化和总体的维护量增加,性能越来越差,这是一个需要大量维护的工具,而它却在周末独自运行。
然后我重新设计了数据库存储,最终达到了现在的状态。有一个存储过程将删除表、约束和文件,另一个存储过程将重新创建整个内容。
因此,数据库现在由 8 个文件组成:一个数据文件和一个日志文件存储永久数据;然后对于 Files、Folders 和 ACLs 表,一个文件用于数据,一个文件用于索引。
我的 SQL Server 现在运行良好,查询速度也稍快一些。
未来
在接下来的文章(或本文中)我将添加一个管理界面,并尝试提供一个包含所有可能选项(数据库位置、数据库文件大小、计划管理和安全性;目前运行程序的帐户必须拥有文件夹和数据库的访问权限,并且不允许对网络共享进行多重登录)的打包设置。
历史
v1 初始发布:请评论!(任何建议和批评都将受到欢迎,这是我的第一篇文章)并且请原谅我的英语:)