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

使用 C# 4.0 创建一个多线程应用程序来下载唯一图像

starIconstarIconstarIconstarIconstarIcon

5.00/5 (12投票s)

2012 年 8 月 17 日

CPOL

18分钟阅读

viewsIcon

32496

downloadIcon

1619

本文介绍了一个用于 Internet Explorer 的应用程序的详细信息和问题,该应用程序可以仅将唯一图像下载到选定的文件夹。DUIapp 在文件夹中创建并维护一个索引,以包含从 IE 网页中选择的唯一图像并排除重复图像。

Sample Image

痛点

我喜欢从网上下载图片到我不同文件夹里的收藏。像 http://Images.Google.com/http://commons.wikimedia.org/wiki/Main_Page 这样的地方是查找大型精美图片的绝佳去处。编写和运行脚本或小程序来通过查找每个文件的 MD5 摘要来清理重复项,长期以来一直相对容易。

我的痛点是:尽管很少发生,但仍然足够频繁地让我感到烦恼,我仍然会遇到重复项。.JPG 文件可能因 EXIF 标签(参考 [01])而与其他文件不同。然而图像本身可能是重复的。.PNG 文件可能是 .GIF 文件的重复项。编码不同,但您看到的像素可能相同。

DUIapp 解决了这两个问题。首先,在尝试下载时会检测到重复项。重复项永远不会落地。其次,DUIapp 几乎肯定能保证下载文件的像素数据是唯一的,无论标签或图像类型如何。我说“几乎肯定”是因为 MD5 摘要理论上不是唯一的。但在所有实际应用中,它们确实是唯一的。

DUIapp 在每个选定的下载文件夹中(文件名 _zjqxkImgIdx.bin_)以及在内存中创建并维护一个磁盘索引。此索引用于快速检查网页图像对于给定的下载文件夹是否是唯一的。

等等,我想要更多

当我开始时,我除了功能性之外还有三个目标。

我想要一个响应式的 UI,即使在索引完成之前也能显示图像索引的并发使用情况。窗体底部的区域显示此索引始终可用。如果您选择下载文件夹并选择您 PC 上包含大量图像文件的文件夹(我的测试文件夹有 6671 张图像,占 1.7GB),您会在窗体底部中央看到越来越多的“已知”或“已索引”文件(参见 UI 截图中的“2222 个已知文件”)。

当此数字增加时,如果您移动 TrackBar 滑块,您会在底部的 TextBox 中看到不同的选定图像文件名。单击第一个[ |<- ]、上一个[ <- ]、下一个[ -> ]或最后一个[ ->| ]导航按钮将选择索引中的不同文件名。当您单击最后一个时,TrackBar 滑块将向左漂移,因为新文件会添加到索引中。

我希望设计包含可用性功能。除了“响应式 UI”目标外,我还提出了几项。一项功能是使用应用程序标题栏作为状态行。我设想使用此应用程序,使其标题栏刚好位于上方的 Internet Explorer 窗口之上。当一个唯一的网页新图像下载到选定的文件夹时,它在网络上的名称会显示在标题栏中,为用户提供浏览时的反馈。图像如果唯一但“太小”以至于不满足最小图像宽度或高度要求,也会在标题栏中注明。这是一项可用性功能,可以防止我下载不符合我大型精美图像标准的小尺寸图像。最小宽度和高度在 NumericTextBox 控件中指定——这是一个很棒的可用性控件,之前已提交到 CodeProject(参见参考 [02])。

最后,我想要一种移除应用程序对注册表和支持的磁盘文件的更改的方法。单击禁用扩展即可完成此操作。启用扩展将更改恢复。实现这些按钮的事件处理程序出现了线程和关闭问题,这些问题对于应用程序的可靠性来说是宝贵的。事实上,完成此应用程序的开发导致了一种控制线程使用模式(对我来说是新颖的)的新方法,一种对我来说也是新的事件处理程序模式,以及一种可靠地完成应用程序关闭的非平凡机制。

我将在以下各节中进一步解释这些内容。

零件和组件

DUIapp 使用 Internet Explorer 的上下文菜单扩展,这在网络上的许多地方都有很好的介绍。这个链接是一个不错的起点。当 DUIapp 运行时,并且启动了一个新的 IE 浏览器窗口或选项卡时,该扩展的用法如下:

在 Internet Explorer 中右键单击正在查看的图像。看到“另存为图片...”菜单项。我们有点像那样。往下看并单击“下载唯一图像”项。

Using Download Unique Image menu item

单击“下载唯一图像”会导致 IE 执行我们在菜单扩展子项中指定的 JavaScript 代码块。此 JavaScript 文件(_TwineBakery.html_)首先写入特殊文件夹“_MyDocuments_”,然后指定在子项中。JavaScript 生成一个易于检测的 cookie,其中包含下载图像的 URI,我们的应用程序稍后会使用它来下载图像文件。

要查看此扩展的实现,请查看 DUIapp 项目中 _Form1.cs_ 的 Constants 区域以及事件处理程序 buttonEnable_ClickbuttonDisable_Click。由于以下原因,此扩展并不常见:

  • Internet Explorer 的扩展
  • CodeProject 文章的一部分
  • 仅使用 C# 和 JavaScript
  • 假设应用程序用户具有足够的权限来编辑注册表

现在这是一个完全非标准的 DUIapp 图

Diagram of DUIapp

启动时,如果注册表中缺少扩展子项,DUIapp 的 Form1 会创建扩展子项以及用于下载文件夹和最小宽度和高度的附加注册表值。否则,将使用现有的注册表值来填充 UI 中的字段。Form1 的构造函数最后启动一个 Index Initialize 任务,启动 imgAdderImageQConsumer 的实例)中的线程成员,并启动 CookieWatcher 中的线程。

    Task.Factory.StartNew(() => IndexInitializer(GetDLFolder()));
    imgAdder.Start(this); //imgAdder needs this to use delegates on main thread
    watcher.Start();
}

ImageQConsumerCookieWatcher 元素源于我之前使用的一个模式,即一个类有自己的线程成员来帮助隔离线程代码。这种模式有时用处不大。CookieWatcher 与 UI 耦合很少,所以这种模式效果很好。只有两个对 Form1.infrequentCookieName 的引用会阻止此类在不更改的情况下被重用。ImageQConsumerForm1 之间的链接让我事后才意识到一个更好的解决方案,该解决方案仍然不会增加 _Form1.cs_ 中的代码行数,值得进一步研究。

CookieWatcher 本质上是一个 FileSystemWatcher,用于查找当选择我们的菜单扩展时由我们的 JavaScript 创建的新 cookie。CookieWatcher 包含一个 ConcurrentQueue,它被填充了从新 cookie 收集的图像 URI,以及一个 EventWaitHandle,用于向任何从队列中出列 URI 的使用者(我们的 ImageQConsumer)发出信号。穿过 Form1 的蓝色箭头旨在显示使用者通过 Form1 访问 CookieWatcher 队列。在 _Form1.cs_ 中,我们有:

/// <summary>
/// static CookieWatcher used to queue image Uri's.
/// Shares public queue with consumer
/// </summary>
internal static CookieWatcher watcher = new CookieWatcher();

在 _CookieWatcher.cs_ 中,我们有:

/// <summary>
/// EventWaitHandle used to signal consumer of cookie file name change events
/// </summary>
public EventWaitHandle qHandle = new AutoResetEvent(false); // initially unsignalled

/// <summary>
/// ConcurrentQueue used to enqueue web image Uri's for a consumer
/// </summary>
public ConcurrentQueue<string> quri = new ConcurrentQueue<string>();

然后,在 _ImageQConsumer.cs_ 中,我们执行:

// amounts to polling every 1/4 sec or whenever watcher signals
Form1.watcher.qHandle.WaitOne(250);
if (Form1.watcher.quri.TryDequeue(out uri) == true)
{
    ... process the URI

疯狂的用户

那么,这个分裂的 Form1 气泡和上面叠加的几个 Index Initialize 任务是什么意思?

如果您和我一样,喜欢在开发过程中对组件和方法进行压力测试和边界情况测试。问题总是“如果…会怎样”或“有人会这样做吗”。再次查看用户界面,注意重新启动索引更新按钮。这样做的动机是“如果有人在制作索引时将大量图像文件复制或移动到下载文件夹怎么办?”他们将希望重新启动制作索引以包含新文件。因此有了这个按钮。

啊,但是,如果一个疯狂的用户坐在那里,疯狂地尽快点击重新启动索引更新按钮,就像他们的小手指能做到的一样?好吧,您会知道,或者您会发现,即使您在单击事件处理程序的开头将 button's.Enabled 属性设置为 false,您的 UI 的消息泵仍然可能进入处理程序的两到三条消息。每一次都会启动一个 Index Initialize 任务来重建索引。

怎么办?如果我无法一次阻止多条消息(即任务),那么也许我应该学会与之共存。我只是在处理两个线程类之间使用的队列。如果我使用队列来记录所有这些任务实例,但只完全处理最后一个(直到疯狂用户再次疯狂的最后一个)?

这是我新颖的线程处理解决方案。在 IndexInitializer 方法的检查点,我们检查任务是否在队列的开头,以及队列中是否有多于一个任务。如果是,则直接退出。当前任务执行的后继者可以处理制作索引。这是 IndexInitializer 中“测试并让后继者处理索引”检查点代码的示例,位于 _Form1.cs_ 中:

// Check if we are supposed to shutdown or have a successor and return.
// Otherwise reset and rebuild the index.
lock (shutdown)
{
    if (shutdown.Bool || initIdx.Count > 1 && initIdx.Peek() == Task.CurrentId)
    {
        initIdx.Dequeue();
        saveIfDirty(dlFolder);
        return;
    }

    idx = 0;
    ordinalFiles.Clear();
    digestFiles.Clear();
    sortedFiles.Clear();
}
...

shutdown.Bool 为 true 表示我们正在尝试停止所有线程(任务)以关闭应用程序。当然,我们也应该为此返回。initIdx 是我们的任务队列。如果队列中有多个条目(Count > 1),我们就有后继者,或者我们是后继者。我们查看队列的开头,看看是否是当前执行。如果是,我们就知道有一个后继者将制作索引。我们出列当前执行任务 ID。然后我们检查内存中的现有索引是否需要写入磁盘(saveIfDirty),因为 imgAdder 可能在我们不注意的时候添加了图像。现在我们可以返回了。我们的后继者(如果它本身没有被后继)将清除索引组成的 SortedListDictionary,并开始重建。

很好,但还有最后一个问题。一个后继者可以被启动来索引不同的文件夹(执行了选择下载文件夹)。如果该文件夹可以非常快速地被索引,因为它没有或很少有图像文件,那么后继者可能会“跑过”之前的执行,该执行正在解码大量图像数据像素并创建它们的摘要。当在下一个检查点测试任务队列时,较慢的任务可能会完全错过后继者,并继续构建一个不再需要的索引。需要的是“暂停”代码,以使所有后继者等待直到先前的执行退出。这是执行此操作的代码。它位于 IndexInitialzer 中的第一个检查点(如上所示)之前,位于 Form1.cs 中:

// Wait if we are a successor.  Prevent execution for very quick folders,
// folders with few or no images, from 'running past' execution for slow folders
while (true)
{
    lock (shutdown)
    {
        // let all tasks through at shutdown or just the task at beginning of queue
        if (shutdown.Bool || initIdx.Peek() == Task.CurrentId)
        {
            lock (dirtyFlag)
            {
                if (dirtyFlag.ContainsKey(dlFolder) && dirtyFlag[dlFolder])
                    saveIndex(dlFolder); // save any ImgQConsumer added files
                dirtyFlag[dlFolder] = false; // initialize or reset dirty flag
                break;
            }
        }
    }
    Thread.Sleep(2);
}

在控件事件处理程序中使用 lock()(或者不使用)

UI 线程在控件的事件处理程序中尝试 lock 语句可能会导致严重的死锁。如果 lock 对象被广泛用于保护对多个资源的访问,则几乎肯定会发生死锁。(广域 lock 对象是另一个话题。)

不过,很容易修复。只需生成 .NET 4.0 中可用的 System.Threading.Tasks Task 之一,让它锁定资源以进行访问并更改控件。

但是,非 UI 线程更改 UI 控件的任何内容都是不好的。微软表示控件更改不是线程安全的,只能由创建它们的线程进行。事实上,如果您在 Visual Studio 中执行您的应用程序,如果您尝试这样做,Visual Studio 将引发跨线程异常(InvalidOperationException)。

怎么办?您想从 UI 线程开始并使用关键资源来更改控件的属性。

简单的修复。从生成的任务,只需在 UI 线程上Invoke更改回即可。这是通用模式。“Disp”是窗体构造期间保存的 Dispatcher.CurrentDispatcher

private delegate void ControlChangeDelegate();
private void ContolChange()
{
    //... use critical resource to make change in control.
}
private void ControlChangeTask()
{
    lock (lockObj)
        Disp.Invoke(new ControlChangeDelegate(ControlChange)); // Invoke is synchronous
}
private void control1_Change(object sender, EventArgs e)
{
    Task.Factory.StartNew(() => ControlChangeTask());
}

从底部开始向上移动。常规事件处理程序(control1_Change)由用户执行的操作触发。常规事件处理程序生成异步 ControlChangeTask 并退出。ControlChangeTask 锁定一个将在 ControlChange 中使用的关键资源。锁定将一直保持,直到 UI 线程的 Dispatcher 的同步 Invoke 完成。ControlChange 使用关键资源进行控件更改。

DUIapp 窗体底部的 TrackBar 和导航按钮都使用此模式。

你以为妈妈总会在那里

与工作线程共享 Dispatcher.CurrentDispatcher 甚至 lock 对象一直是个问题。直到我阅读了参考 [03] 中的第一个答案,我才真正理解了“失去妈妈”。我相信这种重写是必要的,也是正确的解决方案。这里可能新颖的是重写在关闭时使用了两次。

让我退一步。

在正常执行过程中,工作线程(通用术语,不特指 BackgroundWorker)可能会使用窗体对象的锁或在主线程上调用方法。这是 _Form1.cs_ 中 IndexInitializer 中的一个典型片段:
lock (shutdown)
{
   if (ordinalFiles.Count == 1 && textBox2.Text.Length == 0)
        Disp.Invoke(new ActivateButtonsDelegate(ActivateButtons));
}

当用户单击大红 X 退出应用程序时,工作线程就“失去妈妈”了。Lock 对象丢失,UI 线程的 Dispatcher 消失了。窗体将从桌面消失,但使用任务管理器,您会看到孤立的线程使应用程序保持运行。

application form gone, application still in TaskManager

诀窍是让“妈妈”留下来,直到我们可以告别。我们重写 Form1FormClosing 事件处理程序:

protected override void OnFormClosing(FormClosingEventArgs e)
{
    var rk = GetRegistryKey(false);
    // Is the extension disabled and this FormClosing event the first?
    if (null == rk && !shutdown.Bool)
    {
        var result = MessageBox.Show("Exit without Enabled Extension?",
                        "Extension Disabled", MessageBoxButtons.OKCancel);
        if (result == System.Windows.Forms.DialogResult.Cancel)
        {
            e.Cancel = true;
            base.OnFormClosing(e);
            return;
        }
    }
    else if (rk != null)
        rk.Close();

    // Is this the first FormClosing event (Extension Enabled or user OK with Disabled)
    if (!shutdown.Bool)
    {
        watcher.EndWatcher(); // exit the CookieWatcher now
        // keep form around until non-UI threads see and use shutdown.Bool == true.
        e.Cancel = true;
        base.OnFormClosing(e);
    }
    // Set shutdown.Bool without lock.  UI thread is only setter, other threads will
    // get and see shutdown.Bool == true sooner or later
    shutdown.Bool = true; // imgAdder will raise last FormClosing event via delegate.
}

我们还定义了一个委托,可用于发送 FormClosing 事件:

internal delegate void FormCloseDelegate();
internal void FormClose()
{
   this.Close();
}

该处理程序需要一些解释。首先,我想在用户无意中决定退出但实际上想保留注册表中的扩展和磁盘上的 JavaScript 时发出警告。如果他们在 MessageBox 中选择取消,则 FormClosing 事件将被取消,重写将退出,用户将在关闭应用程序之前再次单击启用扩展

如果扩展已启用,或者用户选择不带扩展退出,我们会检查这是否是第一次关闭事件。如果是第一次(shutdown.Bool 为 false),我们结束观察器,但通过也在此处取消事件来保留窗体。这次我们会在退出重写之前将 shutdown.Bool = true 设置为 true。

我需要将关闭窗体的责任交给某个线程,在所有线程都看到 shutdown.Bool == true 之后。但是谁呢?此时 UI 线程并不忙,但似乎很笨拙地在设置关闭标志后进入循环来检查 imgAdder 和任何 Index Initialize 任务是否都已退出。Index Initialize 任务可能存在也可能不存在,因此它们不是好的选择。我们希望保持我们的 CookieWatcher 纯净,并且不知道任何关闭舞蹈。如果 ImageQConsumer 负责处理此问题,它只需检查 Index Initialize 任务是否已退出并调用 FormClose 委托。它知道通过退出它本身不再存在。所以这是 ImageQConsumer 在看到 shutdown.Bool == true 后离开其处理循环时执行的代码:

// while (true) loop broken by Form1.shutdown.Bool set true.
int retries = 100; // give any initializer tasks a chance to exit
while (Form1.initIdx.Count > 0 && retries-- > 0)
    Thread.Sleep(30);
// save a dirty index if one exists then send FormClosing event
lock (Form1.shutdown)
lock (Form1.dirtyFlag)
{
    foreach (string s in Form1.dirtyFlag.Keys)
    {
        if (Form1.dirtyFlag[s])
        {
            Form1.saveIndex(s);
            break;
        }
    }
    Form1.Disp.Invoke(new Form1.FormCloseDelegate(uiForm.FormClose));
}

为什么我等了这么久?等待长达 3 秒(100 x 30 毫秒)的选择是一个判断。我有一个 44 MB 的测试文件,在我的 PC 上需要大约 18 秒才能解码并创建像素数据的摘要。我敢肯定还有更大的、更长的图像。

我认为用户最多会忍受等待窗口消失 3 秒钟,然后他们才会开始担心或感到烦恼。如果需要,可以缩短此时间或删除 while 语句。

接下来,我们保存任何需要在规定时间内(foreach 语句)未保存的索引,以代表可能失败的 Index Initialize 任务。然后调用 FormClose 委托,这次 FormClosing 事件没有被取消,imgAdder 消失了,“妈妈”唱着再见。(不用担心。她只需双击即可。)

压缩的问题

如今,TB 级硬盘很普遍,磁盘使用率不如以前那么紧迫。尽管如此,您可能仍希望减小此应用程序创建的磁盘索引的大小。即使有 MD5 摘要,压缩后的索引大小约为未压缩索引的 60%。如本文所示,DUIapp 不使用压缩索引文件。如果您想使用 SevenZip 压缩,我将提供步骤:

  • SevenZip DLL 已包含在演示下载文件夹中。但您可能需要获取最新版本。如果是这样...
  • http://www.7-zip.org/ 下载并运行 SevenZip 安装程序。有两个安装程序。一个用于 32 位 Windows,一个用于 64 位 Windows。您以后可以从控制面板卸载它,但现在这是获取最新签名的 7z.dll 或 7z64.dll 库的最佳方法。它们安装在 \Program Files\7-zip\ 目录下。您可以考虑保留 SevenZip 的安装。它本身也有一个很好的文件资源管理器菜单扩展。
  • http://sevenzipsharp.codeplex.com/ 下载 _SevenZipSharp.dll_。将此 DLL 和 _7z.dll_ 或 _7z64.dll_ 放在包含或将包含 DUIapp.exe(我们的应用程序)的目录中。下载源代码中包含了空的 bin\Debug 和 bin\Release 文件夹。
  • 在下载源代码的 dui 文件夹中找到 _dui.sln_ 文件。
  • 使用 Visual Studio 2010 打开 _dui.sln_。VS2012 应该也没问题。我没有在 VS2008 中测试过。在解决方案资源管理器中,右键单击 DUIapp 项目并选择属性。在“生成”选项卡中,在“条件编译符号:”框中输入 COMPRESS。重新生成解决方案。请记住删除下载文件夹中任何以前制作的索引(_zjqxkImgIdx.bin_),因为 DUIapp 现在将尝试读取并制作压缩索引。
  • 如果您将 DUIapp.exe“部署”到任何地方,请务必包含 _SevenZipSharp.dll_ 以及正确(或两个)7-zip 签名库。

跳转到条件

当我提到索引可以在完成之前使用时,是否听到微弱的蜂鸣声?难道不会需要整个索引来保证唯一性吗?BZZZZZ。好的。你抓到我了。

想象一下这种情况:DUIapp 启动并开始在一个包含数千张图像的文件夹中创建新索引,每张图像都需要解码和摘要才能添加到索引条目中。ImgQConsumer 正在运行,等待图像 URI。由于创建新索引需要几分钟时间,用户浏览到一个带有图像的页面并选择“下载唯一图像”。ImgQConsumer 将图像检索到内存中,并发现其摘要目前是唯一的。唯一,因为某些现有文件 X 的摘要尚未添加。ImgQConsumer 将检索到的图像写入磁盘。几分钟后,Index Initialize 任务遇到了文件 X 的重复摘要。怎么办?

或者考虑一个更简单的情况:用户将大量重复图像复制到下载文件夹并启动 DUIapp。即使我们要求在允许任何下载之前完全初始化索引,这也不能解决这个更简单的情况。

对我来说,有三个解决方案是合理的:

  • 静默方式,简单地删除新发现的重复项。
  • 仅在发现新重复项时,在标题栏中通知用户。
  • 询问用户我们是否应删除新重复项或将其保留。

照常,DUIapp 采用最后一个解决方案。在运行时选择不同的解决方案似乎令人困惑且混乱。即使是命令行开关或持久化的视觉启动模式选择器也是一种过度复杂化。我认为有意识地重新编译是最干净的方法。如果将“SILENT”添加到 DUIapp 项目的生成属性的条件编译符号中,将始终采用第一种解决方案。使用“ADVISE”来生成第二种解决方案。或者保持原样,让 DUIapp 询问。

零件和组件,第二部分

此应用程序由三部分组成:一个 exe 文件和两个 DLL(用于 NumericTextBoxImageWebControl)。如果您使用 SevenZip 压缩,则总共需要五个部分。所有这些都需要在同一个地方才能启动和加载。一种简陋的部署方式,就像上面提供的演示下载一样,所有部分都在一个文件夹中。要执行,您只需双击该文件夹中的 DUIapp.exe

有方法可以将这些部分打包到一个 exe 文件中(如果打包器支持,还可以添加混淆)。一个流行的打包器是微软的ILMerge(参考 [06])。但是,请务必查看参考 [07] 和 [08],因为我们正在勇敢地迈向 .NET 4.5。有人会想,“这在 Metro 中会是什么样子?”

摘要

我展示了一个应用程序,允许您将图像保留在所选文件夹的文件中是唯一的。此应用程序创建并使用一个可能需要几分钟才能创建的索引。该索引用于检查唯一性。

为我的测试文件夹(6671 张图像文件,1.7 GB)在内存中创建全新的完整索引,在我的 PC 上大约需要 10 分钟。如果更改了选定的文件夹或退出了应用程序,正在创建的索引将被保存到中断点。重新启动将继续扩展部分索引。使用我的测试文件夹并带有完整索引驻留在磁盘上启动应用程序,将完整索引带回内存大约需要 1.5 分钟。

此应用程序包含一个 Internet Explorer 菜单扩展,可以通过两个按钮(启用扩展禁用扩展)随时持久化或移除。当此扩展存在时,用户可以启动一个 IE 浏览器窗口或选项卡,并在右键单击网页图像时选择“下载唯一图像”。这些图像将被检索,但除非其图像数据是[当前]唯一的,否则不会存储到磁盘。当索引完成时,您可以删除“[当前]”。普通版和静默版在标题栏中注明索引完成。ADVISE 版不报告完成,因为这会覆盖找到的最后一个重复项。

讨论了几种多线程问题。

DUIapp 已在以下环境中进行了测试:

  • XP,IE 7 和 IE 8,32 位
  • W7,IE 8,32 位
  • W7,IE 9,64 位
  • W8CP,IE 10CP,64 位

参考文献

  1. https://codeproject.org.cn/Articles/27242/ExifTagCollection-An-EXIF-metadata-extraction-librLev Danielyan 提交于 2008 年 6 月 24 日
  2. https://codeproject.org.cn/Articles/30812/Simple-Numeric-TextBoxDaveyM69 提交于 2008 年 11 月 9 日
  3. http://stackoverflow.com/questions/1731384/how-to-stop-backgroundworker-on-forms-closing-event/
  4. http://www.codethinked.com/net-40-and-systemthreadingtasks - 关于 4.0 Tasks 的好博客
  5. https://codeproject.org.cn/Articles/19682/A-Pure-NET-Single-Instance-Application-SolutionShy Agam 提交于 2007 年 11 月 17 日
  6. http://www.microsoft.com/en-us/download/details.aspx?id=17630 - ILMerge
  7. http://research.microsoft.com/en-us/people/mbarnett/ilmerge.aspx - Mike Barnett 关于 ILMerge
  8. http://www.mattwrock.com/post/2012/02/29/What-you-should-know-about-running-ILMerge-on-Net-45-Beta-assemblies-targeting-Net-40.aspx - Matt Wrock 的参考博文

历史

提交给 CodeProject 2012 年 8 月 17 日。

© . All rights reserved.