65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 C#、SQL 和 Analysis Service 实现类似 TreeSize 的应用程序,第一部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.64/5 (6投票s)

2009 年 4 月 23 日

CPOL

13分钟阅读

viewsIcon

51909

downloadIcon

822

用于管理和分析公司中多个文件服务器磁盘空间的工具。

引言

经过数天编码和在此网站上查找资源及信息后,我决定写这篇文章,这是我几乎每天都在系统管理生活中使用的工具。

本文将介绍一个用 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 表存储要扫描的根文件夹,示例:

rootFolders.jpg

您需要手动在此处插入文件夹,因为我还没有(或还没有)制作一个管理界面。

LINQ-to-SQL 类 - DiskReport.dbml

这是将数据库链接到代码的类。

DB-LINQ.jpg

多亏了它,我才得以在扫描过程中删除大约 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

Class_Tree.jpg

这个类是完成工作的地方。

构造函数初始化 Tree 的成员,这些成员将在扫描过程中使用。关于这一行的一个小说明:

dc.ObjectTrackingEnabled = false; 

这将允许 DataContext 跟踪更少的对象,并防止使用类似 `Table newline = new 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 中是视图。

cube.jpg

这是它在 Excel 中的示例外观。

Excel.jpg

关于数据库的更多信息

如引言中所述,数据库对于它存储的数据来说相当简单。但是,如果只是这样创建,就会出现性能、数据库文件大小、调优等问题。

例如,我实际使用的数据库(拥有 1500 万个文件的那个)大约有 7GB 大,随着文件数量的增长和查询性能的下降,我添加了许多索引。

在开发早期,我发现有些表不是永久数据,只需要在运行时删除并重新创建。我可以使用 truncate table,但它不令我满意,因为数据文件不够干净,随着文件碎片化和总体的维护量增加,性能越来越差,这是一个需要大量维护的工具,而它却在周末独自运行。

然后我重新设计了数据库存储,最终达到了现在的状态。有一个存储过程将删除表、约束和文件,另一个存储过程将重新创建整个内容。

因此,数据库现在由 8 个文件组成:一个数据文件和一个日志文件存储永久数据;然后对于 Files、Folders 和 ACLs 表,一个文件用于数据,一个文件用于索引。

我的 SQL Server 现在运行良好,查询速度也稍快一些。

未来

在接下来的文章(或本文中)我将添加一个管理界面,并尝试提供一个包含所有可能选项(数据库位置、数据库文件大小、计划管理和安全性;目前运行程序的帐户必须拥有文件夹和数据库的访问权限,并且不允许对网络共享进行多重登录)的打包设置。

历史

v1 初始发布:请评论!(任何建议和批评都将受到欢迎,这是我的第一篇文章)并且请原谅我的英语:)

© . All rights reserved.