WSE 3 部署:MSI 和 ClickOnce
使用示例 WSE 3 启用解决方案的部署技术概述。
引言
平凡的一天……这种日子会让你思考生活意义、你在其中的目的,或者你喜欢的女孩的温暖欢迎与你真正喜欢的女孩的拒绝等哲学问题。这三个主题的坏处是,你可以一遍又一遍地思考它们。所以,与其沉湎于思绪的海洋,我决定写下我这几天在创建安装程序时学到的一些技巧。我将有机会提高我的英语写作能力,巩固我的知识,也许还能帮助一些遇到类似问题的人……所以我想这是打发接下来的几个小时的更好方式。“写完后”注释
我讨厌冗长的文章……而且命运弄人,我刚写了一篇。所以这里有一个索引,以确保在这篇文字中方便导航。你也可以参考它来查看你的特定问题解决方案在哪里。问题
我们 Code Project 的每个人都至少写过几个程序。但我敢打赌,几乎没有人为这些程序写过安装程序。该死,当涉及到部署时,我就是第一个经常使用 xcopy 的人。我只需将我需要的一切复制到 U 盘,然后去客户那里,恢复 SQL 数据库,打开应用程序配置文件,设置参数,然后启动整个东西。很简单;有效;还要问什么?
好吧,当你在处理那些几乎每天都要部署的大项目时,问题就来了。如果项目是内部的,你或许可以通过复制和调整来管理,但如果一切都需要在外部部署到某种生产环境——例如,培训应用程序的未来用户——那么每次使用 xcopy 和手动恢复数据库,你就要么勤奋,要么愚蠢。
在构建安装程序方面,你有多种选择。你可以尝试脚本,即 BAT 文件 || VB 脚本。你可以尝试自定义解决方案,比如 这个名为 Easy Installer 的,它看起来相当不错。最后,你可以尝试适应 Visual Studio 支持的标准,如 MSI 和 ClickOnce。
作为一个“如果没坏就不修”的人,我选择了“Visual Studio 支持”这条路,并且我将在本文中教授这条路。最终目标是提出一种使用 MSI 进行服务器组件部署和 ClickOnce 进行客户端组件部署的方法来解决所有部署问题。
简要解决方案描述
在过去几周里,我一直忙于 Web Services + Win Clients 的组合,试图开发一种替代 Intranet Web Applications 的模型,因为我真的很讨厌它们。在工作中,WSE 3 主要用于解决常见的安全问题。最终结果令人满意,只有一个例外:从部署的角度来看,该解决方案要求很高。如果你在服务器端使用 WSE 3 而不是“纯 Web Services”,你就需要处理证书及其访问权限,同时还要处理部署时始终存在的 SQL Server 和 IIS 配置。
由于我们避开了 ASP.NET 应用程序,因此我们无法依赖客户端的浏览器。所以,自动更新比任何事情都更重要。为访问 WSE 3 增强的 Web Services 在客户端设置证书再次成为一项要求。
在本文中,我将使用一个完全设计且可行的入门解决方案,包含两个 Web Services 和一个 Windows Client 项目。创建 WSE 3 启用解决方案的过程将被跳过,让你听任微软的“书面”文章在官方 WSE 网站上。这是因为我们的重点在这里是安装,而不是开发。但是,如果你们中的一些人觉得创建 WSE 3 解决方案很有趣(或麻烦),请在评论中大声疾呼,我将乐意扩展本文。
在开始之前,让我们先看一下解决方案资源管理器,它展示了我们将要工作的结构。
服务器安装
路线图
安装服务器组件时,我们希望执行以下步骤
- 将 Web Services 复制到 IIS 上的指定网站
- 设置数据库和访问权限
- 修改 web.config 文件以启用对 SQL Server 数据库的访问
- 安装所需的证书
- 允许运行 Web Services 的标识访问证书
卸载也很重要!MSI 项目将负责移除 Web Services,但我们的自定义代码负责
- 删除安装期间创建的 SQL Server 数据库
- 删除安装期间放入存储的证书
创建基本的 MSI 设置
首先,让我们添加一个新的安装项目。在解决方案资源管理器中右键单击解决方案根。选择 添加 -> 新建项目。我们感兴趣的是:其他项目类型 -> 安装和部署 -> Web 安装项目。将其命名为 ServerSetup 并单击确定。您将看到以下屏幕
好的,第一个问题。我们只有一个 Web 应用程序文件夹,但有两个 Web 应用程序。右键单击“目标计算机上的文件系统”并选择添加特殊文件夹 -> Web 自定义文件夹。打开新文件夹的属性,为 (Name) 和 (VirtualDirectory) 属性设置 FancyWebService
。同时为 (Property) 属性输入 FANCYWEBSERVICEPATH
。这听起来很奇怪:)。
我们可以使用 Web 应用程序文件夹来容纳第二个服务,但此时,由于其 (Name) 无法更改,我通常选择创建另一个 Web 自定义文件夹。这正是我们将在本例中为 PlainWebService 所做的。创建它,然后设置 (Name)、(VirtualDirectory) 和 (Property) 属性。怪事又来了。
Web 应用程序文件夹无法删除,所以我使用一个小技巧来阻止它参与安装。我删除了它里面的 bin 子文件夹,并将 (VirtualDirectory) 属性留空。
现在我们有了结构,让我们填充内容。右键单击“目标计算机上的文件系统”中的 FancyWebService,然后选择添加 -> 项目输出。选择 FancyWebService 的内容文件。对 PlainWebService 重复此操作。
完成所有这些之后,我们就可以看到一些结果了。首先,构建 MSI。在解决方案资源管理器中右键单击设置项目,然后选择构建。完成后,通过右键单击并选择安装来启动整个过程。但是,请确保在具有 IIS 的计算机上启动安装。如果你的开发机器没有,比如我的,请将 MSI 复制到另一台计算机。不要指望仅仅禁用 IIS 启动条件(右键单击 -> 查看 -> 启动条件)会有帮助。
对于一个 10 分钟的工作来说,它看起来并不算糟。我们得到了一个漂亮的欢迎页面,并急于点击下一步。之后,一点小失望在等待着我们。在下一个对话框中,我们必须选择安装网站和虚拟目录的名称。唯一的问题是我们有两个虚拟目录,但只有一个用于命名的字段。有利的情况是,该字段绑定到 Web 应用程序文件夹。因此,输入的值不会干扰我们手动添加和定义的文件夹,这意味着只需隐藏此字段即可。
Orca 和自定义设置对话框
通过以下图片所示的用户界面选项,可以在一定程度上更改 MSI 包中对话框的外观和感觉。此选项的缺点是,您只能更改某些属性,即创建对话框的人员已正确公开的属性。
当然,如果您单击用户界面中的“安装地址”对话框并按 F4 查看属性,您将找不到“虚拟目录名称可见”项。那么,如何删除文本框?
幸运的是,微软提供了一个 名为 Orca 的简单工具,可用于编辑 WID 文件,这些文件代表 MSI 对话框。Orca 官方随 Microsoft Windows SDK 一起分发,它相当大。所以,我想您可能想将浏览器指向 此页面 -- 如果页面宕机,请尝试 Google 搜索 -- 而不是 MSDN 来获取副本。
在您的计算机上安装 Orca 后,启动它并打开 %ProgramFiles%\Microsoft Visual Studio 8\Common7\Tools\Deployment\VsdDialogs\1033 文件夹中的 VsdWebFolderDlg.wid。此文件夹包含 Visual Studio .NET 随附的所有 MSI 对话框的定义。更改之前请备份。从左侧列表选择控件表,所有对话框的控件都将显示出来。由于我们只对隐藏 VDirEdit 和 VDirLabel 控件感兴趣,因此将它们两者的宽度和高度设置为 0 即可。
有很多更好的方法可以做到这一点;我同意你的看法。我们可以尝试将控件的宽度和高度作为属性公开。我们可以尝试将 Visible 属性同时公开给它们。然而,对话框定义并没有得到很好的文档记录,而且为了隐藏两个控件而花费全部精力来学习它们的表、关系和设计是过度的。如果你想深入研究自定义对话框,我建议阅读 这篇文章。有关设置对话框中的密码文本框,请查看 此链接。
如果我们只想使用 MSI 将 Web 应用程序部署到服务器,那就行了。然而,雄心勃勃且 Thorough 的我们希望增强我们的设置,至少再做两件事。
SQL Server 数据库
部署数据库是一个相当简单的过程,只包含两个步骤
- 提供登录 SQL Server 的凭据
- 执行脚本 / 恢复备份集 / 附加 MDF
备份现有数据库的选项
人们经常问我关于备份数据库的三种常见选项——即脚本、备份集、分离——之间的区别。老实说,我自己也不是很确定。下面的列表是我根据过去的经验整理的。如果有人有更全面的列表,请在评论部分发布,我将乐意更新文章。这是我的指导方针
- 当你需要一个非常干净的数据库时——例如,标识键从 1 开始——并且备份集最小,就使用脚本。此外,这种选择的一个巨大优势是你可以轻松地修改你的“备份”。只需添加初始化脚本来更改一个表的结构,而无需经历整个脚本创建过程等。所有这些都使得这种选择成为在新 SQL Server 实例上初始化数据库的绝佳选择。
- 当你需要备份一个不能离线的数据库时,使用备份集。这是维护的理想选择。
- 当你需要将数据库转移到另一个位置并且在此操作期间不需要数据库运行时,请使用分离。如果你可以使数据库离线,请不要使用备份集来传输数据库!对于小于 500Mb 的数据库,你可能看不到太大差异。但是,对于较大的数据库,“分离 – 附加”比“备份 – 恢复”机制快得多。
脚本是安装程序的自然选择。别误会我,如果你选择任何其他选项,你都不会损失任何惊人的东西。然而,你最终的 MSI 设置文件大小会相当大,即使是微小的更改,你也需要生成一个新的备份集/MDF。
生成数据库脚本
那么,我们如何创建数据库脚本呢?嗯,一种始终拥有最新脚本版本的好方法是拥有一位优秀的数据库开发人员在你的团队中。他们似乎比任何其他东西都珍视数据库创建脚本。如果你没有专门的数据库开发人员,别担心。SQL Server 为创建它们提供了良好的支持。首先,启动 SQL Management Studio 并找到你的数据库。通过右键单击数据库并选择“任务”菜单项可以访问“生成脚本”选项,如下图所示。
当出现向导时,跳过问候页面,在下一页选择脚本化所选数据库中的所有对象。然后继续到下一个窗口。
选中“脚本所有对象...”选项会将“脚本选项”屏幕上的大多数选项设置为需要的状态,即脚本索引为 True,为依赖对象生成脚本为 True 等。我们唯一要更改的选项是脚本数据库创建为 True,因为这是我们第一次创建脚本,需要定义数据库的命令。
两次单击“完成”后,生成的脚本应该会在几秒钟内显示出来。我通常会进行四项可选修改。第一个是更改 CREATE DATABASE
命令的执行方式。使用具有相同名称的现有数据库不是我想要的。如果它存在,只需删除它。我们无论如何都会覆盖它。此外,数据库创建的额外选项——例如数据库文件的路径、大小增加等——是不必要的。SQL 管理员可能已经按照需要设置了这些选项,所以我们为什么要覆盖默认设置?修改前后的截图如下。
以前
操作后
USE [master]
GO
IF EXISTS (SELECT name FROM sys.databases WHERE name = N'WSEDeployment')
DROP DATABASE [WSEDeployment]
GO
CREATE DATABASE [WSEDeployment]
第二个修改是添加一个将用于访问此数据库的用户。下一屏显示的脚本基本是自解释的,所以我不会详细介绍。只需将其插入到数据库创建命令之后。
/*----------------------------
CREATE DBUser
----------------------------*/
USE [master]
IF EXISTS (SELECT * FROM sys.server_principals WHERE name = N'wseuser')
DROP LOGIN [wseuser]
CREATE LOGIN [wseuser] WITH PASSWORD=N'wsetest', DEFAULT_DATABASE=[WSEDeployment],
DEFAULT_LANGUAGE=[us_english], CHECK_EXPIRATION=OFF, CHECK_POLICY=OFF
GO
USE [{2}]
GO
IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'wseuser')
CREATE USER [wseuser] FOR LOGIN [wseuser] WITH DEFAULT_SCHEMA=[dbo]
EXEC sp_addrolemember 'db_owner', 'wseuser'
GO
/* -----END---- */
你可以在此步骤中采取其他路径。例如,你可以使用集成安全性。NT AUTHORITY\NETWORK SERVICE 帐户默认运行 Web Services,因此你可以授予对数据库的访问权限,一切都会正常工作。这对 Windows 2003 和 IIS 6 有效,但在 Windows XP 和 IIS 5.1 上,ASP.NET 帐户负责运行 ASP.NET 站点。此脚本基本相同;只需删除 DROP LOGIN
和 CREATE LOGIN
命令,然后在 CREATE USER FOR LOGIN
中插入正确的名称。
第三个修改与用初始数据填充表有关。在我们的例子中,入门解决方案中的 Web Services 从 Plain 和 Fancy 表读取数据,因此安装完成后,它们需要包含问候语。因此,以下内容附加到脚本末尾。
最后,第四个修改使我们能够作为参数传递:SQL 帐户的名称、其密码以及数据库的名称。只需使用老式的查找和替换。将 wseuser
替换为 {0}
,将 wsetest
替换为 {1}
,将 WSEDeployment
替换为 {2}
。经过所有这些工作,创建数据库的脚本就准备好了。我们现在只需要访问数据库并执行它。
通过自定义对话框获取登录凭据并执行准备好的 SQL 脚本的一个好方法 可以在这里找到。基本思想是添加一个带有三个文本框的新自定义对话框,将参数从它传递到你的自定义操作并运行脚本。但是,这种方法有一个大的、糟糕的缺点:没有提到对输入数据的验证。因为,如前所述,扩展 MSI 对话框可能非常麻烦,我将在这里使用标准的 Windows Forms 来获取和验证凭据。
认识自定义操作
当你需要安装程序执行默认无法执行的操作时,就会使用自定义操作。要使用它们,你需要提供一个继承自 System.Configuration.Install.Installer
并重写 Install
、Commit
、Rollback
和 Uninstall
中的一个或多个函数的类。这相当直接:使用“添加新项”选项并为模板选择“安装程序类”。进入代码视图后,override
关键字应帮助你完成其余工作并获得如下图所示的结果。
为了正确调用编写的函数,你需要重新构建包含该类的项目,引用程序集,并将设置中的自定义操作指向它。你可能知道如何重新构建项目 ;);另外两个操作并不难。激活设置项目上的“添加 -> 项目输出”操作,然后选择包含该类的项目的“主要输出”。在我们的例子中,它是 Web Service Common。
当 DLL 放入设置项目后,我们需要将其放置在目标文件系统中的某个地方,以便在安装过程中能够访问并调用它。任一 Web 服务的 bin 文件夹都是一个不错的选择,只要我们安装程序类和 Web 服务中的 DLL 不会同时引用另一个 DLL,例如,如果安装程序和 WS 都使用 Util.dll。在这种情况下,如果将所有内容保留在 bin 文件夹中,你可能会遇到“在后台线程上访问窗体的控件时未使用 Invoke,.NET 1.1”的情况。有时一切都会正常工作,有时则不行。
一个双赢的解决方案是创建一个 bin 的子文件夹作为 Install 文件夹,并将 DLL 放在那里。为什么?嗯,IIS 配置为不服务属于 ASP.NET 应用程序的 bin 的内容。大多数时候 Installer.dll 包含敏感信息,我们不希望将其放置在 Web 服务的根目录等位置,使其可以通过 HTTP 下载。
现在你已经在设置项目中有了引用,请转到自定义操作(查看 -> 自定义操作),并在安装、提交、回滚和卸载时添加自定义操作,指向包含我们刚刚添加的安装程序的 DLL。
在执行了这些操作之后,在安装过程中适当的时候,安装程序将正确触发我们 WSEDeploymentInstaller
类中的函数。请确保设置自定义操作所需的参数传递!我们肯定会使用 TARGETDIR
参数来访问 web.config 文件,因此需要将 CustomActionData 设置为相应自定义操作的值:/TARGETDIR="[TARGETDIR]\"
。
如果属性的值可能包含空格,则必须使用引号!文件夹名称可能包含空格,因此此时必须使用引号。如果缺少引号,并且用户尝试部署到 d:\New IIS Root,将会引发异常。
获取数据库凭据并执行脚本
现在要完成数据库安装,剩下的是显示用于获取用户名和密码的对话框,构建连接字符串,执行脚本并更改 Web Services 的 web.config 文件。对于一个不是 C#.NET 完全新手的人来说,这些操作都不应该陌生,所以我不会详细介绍。我主要粘贴代码,只在最有趣的部分进行注释。其中一些代码来自前面提到的 文章。
WSEDeployment.cs
public override void Install(System.Collections.IDictionary stateSaver)
{
base.Install(stateSaver);
// Show dialog and fetch credentials
SqlCredetialsForm frmSd = new SqlCredetialsForm(
"TYRION\\SQLEXPRESS", "dbmaster", "");
DialogResult dr = frmSd.ShowDialog();
if (dr != DialogResult.OK)
throw new InstallException("Invalid Sql Credentials,
aborting installation");
// Crypt connection string for uninstall
RijndaelCryptography rijndael = new RijndaelCryptography();
rijndael.GenKey();
rijndael.Encrypt(SqlScripting.ConnectionString);
stateSaver.Add("key", rijndael.Key);
stateSaver.Add("IV", rijndael.IV);
stateSaver.Add("conStr", rijndael.Encrypted);
// Perform database creation
string dbConnectionString = SqlScripting.InstallDatabase();
// Set connection strings in config
// It was needed to set /TARGETDIR=[TARGETDIR] in
// CustomDataAction to be able to fetch
// this.Context.Parameters["TARGETDIR"] properly
StringDictionary sd = this.Context.Parameters;
WriteToConfig(sd["TARGETDIR"], "FancyWebService", dbConnectionString);
WriteToConfig(sd["TARGETDIR"], "PlainWebService", dbConnectionString);
}
private static void WriteToConfig(string root, string virtualFolder,
string connString)
{
string path = string.Format(@"{0}\{1}\web.config", root, virtualFolder);
FileInfo fi = new FileInfo(path);
fi.Attributes = FileAttributes.Normal;
XmlDocument doc = new XmlDocument();
doc.Load(path);
XmlNode node =
doc.SelectSingleNode(
@"/configuration/connectionStrings/add[@name='WSEDB']");
node.Attributes["connectionString"].InnerText = connString;
doc.Save(path);
fi.Attributes = FileAttributes.ReadOnly;
}
一切都很清楚。我们显示一个表单,该表单初始化静态 SqlScripting
类中的连接字符串,并以 DialogResult
的形式发出操作成功的信号。如果我们没有获得凭据,则会抛出异常,MSI 将回滚更改。我们稍后将更详细地介绍这一点。如果一切正常,连接字符串将被加密并保存在状态中以供卸载。数据库安装脚本将被执行,完成后,该方法将获取 web.config XML,使用 XPath 搜索它并修改所需的值。这是 SqlScripting 的外观。
private const string DB_LOGIN = "wseuser";
private const string HIV_DB_NAME = "WSEDeployment";
private static string _connectionStringFormat =
"Data Source={0};Initial Catalog={1};{2}";
private static string _connectionString = null;
public static string ConnectionString
{
get { return _connectionString; }
set { _connectionString = value; }
}
public static bool InitConnection(string connectionString)
{
string query = "SELECT TOP 1 * FROM sys.objects";
SqlCommand cmd = new SqlCommand();
cmd.CommandText = query;
cmd.CommandType = CommandType.Text;
cmd.Connection = new SqlConnection(connectionString);
try
{
cmd.Connection.Open();
cmd.ExecuteNonQuery();
ConnectionString = connectionString;
return true;
}
catch (Exception ex)
{
ConnectionString = null;
return false;
}
}
public static string InstallDatabase()
{
string password = new Random().Next(1000000000).ToString();
string txtSQL =
string.Format(Resources.SqlInstallScript,
DB_LOGIN, password, HIV_DB_NAME);
Regex regex =
new Regex("^GO", RegexOptions.IgnoreCase | RegexOptions.Multiline);
string[] SqlLine = regex.Split(txtSQL);
SqlCommand cmd = null;
try
{
cmd = new SqlCommand();
cmd.CommandType = CommandType.Text;
cmd.Connection = new SqlConnection(ConnectionString);
cmd.Connection.Open();
foreach (string line in SqlLine)
{
if (line.Length > 0)
{
cmd.CommandText = line;
cmd.ExecuteNonQuery();
}
}
string[] connStringParts = ConnectionString.Split(';');
string databaseConnectionString =
string.Format("{0};Initial Catalog={3};UID={1};PWD={2}",
connStringParts[0], DB_LOGIN, password, HIV_DB_NAME);
return databaseConnectionString;
}
finally
{
cmd.Connection.Close();
}
}
InitConnection
方法只是尝试使用作为参数传递的连接字符串执行测试命令。如果一切正常,它会存储它,如果不行,则重置它。InstallDatabase 有点复杂。在其他任何事情之前,会生成一个随机密码,然后设置脚本的参数——即用户名、密码和数据库名称——并将它们存储到 txtSQL
变量中。使用正则表达式将文本分割成不包含 GO
的单个命令,然后逐个执行。最后,该方法返回一个有效的连接字符串,用于访问新创建的数据库,该字符串将设置在 Web Services 的 web.config 文件中。
如果脚本非常大,使用 sqlcmd.exe 执行它将比使用我们的方法快得多。但是,IIS 机器,也就是我们设置的目标机器,可能不是 SQL Server。为了涵盖所有情况,我们需要将 sqlcmd.exe 嵌入 Installer.dll 中,在运行设置时将其解压缩到临时目录,将脚本写入临时文本文件,然后使用 System.Diagnostics.Process
运行 sqlcmd.exe。如果有人对此解决方案感兴趣,请发表评论,我将把代码添加到文章中。
安装证书
证书所需的工作比 SQL 数据库少得多,或者至少描述起来少得多 :)。我们只需要从 DLL 的资源中提取证书,将其放入所需的存储中,并授予适当的权限。你可以用很多方式向项目的资源添加内容,但对我来说最简单的方法是将其拖放到打开的 Resources.resx 中。
完成此操作后,Visual Studio 会自动包装它,并在 Resources
类中生成相应的代码。仔细阅读 SQL 数据库安装部分代码的人会看到,我将 SqlScriptInstall.txt 文件放入资源中并从中读取。我将在本节中使用的证书也这样做。
修改后的 WSEDeploymentInstaller.cs
public override void Install(System.Collections.IDictionary stateSaver)
{
// ... previous code for installing sql server database ...
// Certificate, depending on OS give permission:
// XP: ASPNET account, WIN2003: NETWORK SERVICE
string user =
string.Format("{0}\\ASPNET", Environment.MachineName);
if (OSInfo.GetOSName() == "Windows Server 2003")
user = "NETWORK SERVICE";
// LOAD CERTIFICATE
X509Certificate2 cert =
new X509Certificate2(Resources.TestCertificateServer, "123",
X509KeyStorageFlags.MachineKeySet|X509KeyStorageFlags.PersistKeySet);
CertificateInstall.PlaceInStore(cert, StoreName.My,
StoreLocation.LocalMachine, user);
}
OSInfo
是一个我从 这篇文章 借来的有用类。它检测操作系统的版本,这样我就可以授予运行 ASP.NET 站点的相应帐户访问权限。证书通过传递字节数组和密码以及加载参数来加载到 X509Certificate2
类中。之后,它被放入存储中。
CertificateInstall.cs
public class CertificateInstall
{
public static void PlaceInStore(X509Certificate2 cert,
StoreName storeName, StoreLocation storeLocation, string user)
{
X509Store store = new X509Store(storeName, storeLocation);
try
{
store.Open(OpenFlags.ReadWrite);
if (!store.Certificates.Contains(cert))
store.Add(cert);
int indexOfCert = store.Certificates.IndexOf(cert);
X509Certificate2 certInStore = store.Certificates[indexOfCert];
if (!string.IsNullOrEmpty(user))
AddAccessToCertificate(certInStore, user);
}
finally
{
store.Close();
}
}
public static void AddAccessToCertificate(X509Certificate2 cert,
string user)
{
RSACryptoServiceProvider rsa =
cert.PrivateKey as RSACryptoServiceProvider;
if (rsa != null)
{
string keyfilepath =
FindKeyLocation(
rsa.CspKeyContainerInfo.UniqueKeyContainerName);
FileInfo file = new FileInfo(keyfilepath + "\\" +
rsa.CspKeyContainerInfo.UniqueKeyContainerName);
FileSecurity fs = file.GetAccessControl();
NTAccount account = new NTAccount(user);
fs.AddAccessRule(new FileSystemAccessRule(account,
FileSystemRights.FullControl, AccessControlType.Allow));
file.SetAccessControl(fs);
}
}
private static string FindKeyLocation(string keyFileName)
{
string text1 =
Environment.GetFolderPath(
Environment.SpecialFolder.CommonApplicationData);
string text2 = text1 + @"\Microsoft\Crypto\RSA\MachineKeys";
string[] textArray1 = Directory.GetFiles(text2, keyFileName);
if (textArray1.Length > 0)
{
return text2;
}
string text3 =
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string text4 = text3 + @"\Microsoft\Crypto\RSA\";
textArray1 = Directory.GetDirectories(text4);
if (textArray1.Length > 0)
{
foreach (string text5 in textArray1)
{
textArray1 = Directory.GetFiles(text5, keyFileName);
if (textArray1.Length != 0)
{
return text5;
}
}
}
return "Private key exists but is not accessible";
}
}
我对这段代码不太满意,因为权限是使用文件系统授予的,而不是使用 X509Certificate2
类公开的方法。我尝试了这段代码,但它就是不起作用。
using (RSACryptoServiceProvider csp =
cert.PrivateKey as RSACryptoServiceProvider)
{
CspKeyContainerInfo kci = csp.CspKeyContainerInfo;
CryptoKeySecurity cks = kci.CryptoKeySecurity;
cks.AddAccessRule(
new CryptoKeyAccessRule("NT Authority\\Network Service",
CryptoKeyRights.GenericRead, AccessControlType.Allow));
}
所以,如果有人知道如何以这种方式授予访问权限,请在评论部分发布。
如何生成证书
我在此入门解决方案中提供了测试证书,但您在开发时可能希望自己创建。要做到这一点,您需要在 Visual Studio 命令提示符中执行以下操作
makecert.exe -sr LocalMachine -ss MY -a sha1 -n CN=
YourCertificate -sky exchange –pe
这将创建一个位于 LocalMachine/Personal 存储中的 YourCertificate
。要查看存储,您需要激活证书管理单元。转到开始 -> 运行,键入 mmc
,然后使用文件 -> 添加/删除管理单元选项,从列表中选择证书。进入其中后,您可以轻松地将证书导出为 PFX 或 CER,并在您的解决方案中使用它。
清理现场:卸载
破坏总比创造容易。安装程序也不例外。在清理时,我们需要
- 使用保存的连接字符串执行用于删除数据库的脚本
- 从存储中移除证书
清理不仅从卸载调用,也从回滚调用。这是因为我们不能确切地说安装过程中不会出现问题。我们负责通过 Install 方法添加的自定义内容,因此我们必须在任何情况下进行清理。
WSEDeploymentInstaller.cs
public override void Rollback(System.Collections.IDictionary savedState)
{
CleanUp(savedState);
base.Rollback(savedState);
}
public override void Uninstall(System.Collections.IDictionary savedState)
{
CleanUp(savedState);
base.Uninstall(savedState);
}
private void CleanUp(System.Collections.IDictionary savedState)
{
// Remove database
if (savedState.Contains("key"))
{
RijndaelCryptography rijndael = new RijndaelCryptography();
rijndael.Key = (byte[])savedState["key"];
rijndael.IV = (byte[])savedState["IV"];
string connectionString =
rijndael.Decrypt((byte[])savedState["conStr"]);
SqlScripting.InitConnection(connectionString);
SqlScripting.UninstallDatabase();
}
// Remove Certificate
try
{
X509Certificate2 cert =
new X509Certificate2(Resources.haisysKey, "123",
X509KeyStorageFlags.MachineKeySet |
X509KeyStorageFlags.PersistKeySet);
CertificateInstall.RemoveFromStore(cert, StoreName.My,
StoreLocation.LocalMachine);
}
catch
{ }
}
用于删除数据库的脚本
USE [master]
ALTER DATABASE [{1}] set SINGLE_USER with ROLLBACK IMMEDIATE
IF EXISTS (SELECT name FROM sys.databases WHERE name = N'{1}')
DROP DATABASE [{1}]
IF EXISTS (SELECT * FROM sys.server_principals WHERE name = N'{0}')
DROP LOGIN [{0}]
GO
SqlScript 类的方法在填充参数后执行前面的脚本
public static void UninstallDatabase()
{
string query = string.Format(
Resources.SqlScriptUninstall, DB_LOGIN, HIV_DB_NAME);
try
{
SqlCommand cmd = dp.PrepareCommand(query, CommandType.Text);
dp.ExecuteNonQuery(cmd);
}
catch (Exception ex)
{
// DB Already dropped
Console.Write(ex);
}
}
Certificate removing method from CertificateInstall class:
public static void RemoveFromStore(X509Certificate2 cert,
StoreName storeName, StoreLocation storeLocation)
{
X509Store store = new X509Store(storeName, storeLocation);
try
{
store.Open(OpenFlags.ReadWrite);
if (store.Certificates.Contains(cert))
store.Remove(cert);
}
finally
{
store.Close();
}
}
移除虚拟目录
在关闭本节之前,我想指出一个不那么棘手的 bug,当您从 IIS 卸载 Web 应用程序时会出现。那就是,虚拟目录仍然被注册。所以,我们需要手动删除虚拟目录的注册。
WSEDeploymentInstaller.cs
public override void Install(System.Collections.IDictionary stateSaver)
{
// ... previous Install actions ...
// Save TargetSite variable
stateSaver.Add("targetSite",
sd["TARGETSITE"].Substring(sd["TARGETSITE"].LastIndexOf('/') + 1));
}
private void CleanUp(System.Collections.IDictionary savedState)
{
// ... previous Cleaning actions ...
// Remove Virutal Directories if needed
DeleteVirtualDirectory((string)savedState["targetSite"],"FancyWebService");
DeleteVirtualDirectory((string)savedState["targetSite"],"PlainWebService");
}
private void DeleteVirtualDirectory(string webSiteId, string virtualDirName)
{
string path = string.Format(@"IIS:///W3SVC/{0}/Root/{1}",
webSiteId, virtualDirName);
bool b = System.DirectoryServices.DirectoryEntry.Exists(path);
if (b)
new System.DirectoryServices.DirectoryEntry(path).DeleteTree();
}
如您所见,TARGETSITE
变量首先在安装期间保存——用户可以选择不安装到默认网站——然后在触发卸载时使用。一旦知道要查找什么,删除虚拟目录就很简单:DirectoryEntry
类。同样,请确保正确设置自定义操作!
移除注册表项:添加/删除程序清理
哦,还有一件事。万一您在卸载时遇到问题——即,您使用有 bug 的 Uninstall 自定义操作进行部署——请务必查看 Windows Installer CleanUp Utility。它在我身上救了我几次,通过删除注册表项,使我能够开始一个新的安装。
客户端安装
ClickOnce 是最近影响我应用程序部署方式的事情之一。开发它的工程师们做得非常出色。它不仅易于使用,而且一旦你了解了它的 API,你就会发现扩展和自定义它以满足你自己的需求多么简单。
路线图
我们的 ClickOnce 设置需要一些东西。
- 应用程序的所有先决条件必须在 ClickOnce 设置继续之前安装在客户端计算机上
- 在应用程序启动时以及应用程序运行时都应该检查应用程序的新版本
- 执行安装时,客户端证书应被安装
设置 ClickOnce
如果您打开项目属性并转到“发布”项,就会找到 ClickOnce 设置。
必备组件
单击“先决条件”按钮,我们会得到一个包含所有可用包的列表。正如您所看到的,您只需选择所需的内容以及它将从何处获取。
列表中显示的包可以在 %ProgramFiles%\Microsoft Visual Studio 8\SDK\v2.0\BootStrapper\Packages\ 找到。如果您沿着这个路径在硬盘上找到它,您会看到每个子文件夹中都有一个不错的结构:除了主 MSI 或 EXE 之外,还有一个 product.xml 元文件,通常还有一个包含本地化资源的文件夹用于安装先决条件。仅供参考:先决条件也可以通过同样的方式为 MSI 设置项目。只需使用设置项目属性表单上的“先决条件”按钮。
创建自己的先决条件轻而易举。您需要一个执行您想要的 MSI 设置——例如,它将 DLL 安装到 GAC ——以及一个像 product.xml 这样的清单文件。您刚刚学习了如何制作 MSI,假设您阅读了本文的服务器安装部分。对于清单,你有两个选择。要么手动创建或更改现有的,通过研究 此链接,要么使用名为 Bootstrap Manifest Generator 的应用程序。当然,我选择 BMG。当你同时拥有 MSI 和清单时,将它们放在之前指定路径内的自己文件夹中——BMG 会自动完成——然后重新启动 Visual Studio .NET 即可。你将在列表中看到自己的先决条件。
应用程序更新
应用程序更新是需要设置的第二个选项。
选中“应用程序应检查更新”是所有其他相关选项的要求。它还会影响 ApplicationDeployment
类,我们稍后将看到。 “为此应用程序指定最低必需版本”是一个很好的选项,当你希望强制用户更新时。例如,如果你发布了三个应用程序版本——即达到 1.0.0.3——并且不勾选最低版本,那么从 1.0.0.0 开始的每个更新都将是可选的。在启动应用程序时,用户将看到一个对话框,在那里他可以选择“跳过”选项,这将完全忽略新的应用程序版本。在这种情况下,连续启动相同版本不会再次显示更新对话框。有些情况下的行为是期望的,但在大多数情况下,你希望客户应用程序是最新的,所以要小心。
发布应用程序
设置好选项后,点击“发布向导”就会进入主要工作。首先,我们需要指定发布位置,即安装文件将放置的位置。
接下来我们指定安装位置,即从那里启动 setup.exe 的位置。大多数情况下,发布和安装是同一位置,但也有不同的时候。一个例子是当你使用 FTP 上传文件并通过 HTTP 访问它们时。
接下来,你需要选择你的应用程序是否只能在线工作,或者也可以离线使用。“脱机模式”是我几乎总是使用的模式,因为它通过将程序集放在客户端硬盘上来提供极大的自由度。它还在机器上注册应用程序(添加/删除)并在开始菜单中设置快捷方式,为用户提供真正的 Windows 应用程序体验。
“在线模式”直接从安装位置启动所有内容,不在客户端计算机上留下开始菜单的快捷方式或其他任何东西。它类似于 Web 应用程序;你只需转到 URL 即可启动应用程序。这种模式的一个好处是提供更好的用户体验或访问客户端机器上的资源。如果你没有足够的知识来用 JavaScript 或 ActiveX 做某事,那么只需在你的 Web 应用程序中提供一个指向 Windows 应用程序的 ClickOnce 清单的链接,该应用程序实现了所需的功能。例如,你可以通过这种方式启动一个需要使用客户端打印机的报表查看器。
就是这样!经过三个简短的步骤,你就可以部署了。只需点击“完成”。
安装证书并手动检查更新
通过使用 ApplicationDeployment
类的方法和属性,可以在一定程度上以编程方式扩展 ClickOnce 部署。有关其完整参考,请访问 MSDN。我将专注于实际应用。
Program.cs
[STAThread]
static void Main()
{
ClickOnceFunctions.CheckForCertificate();
ClickOnceFunctions.StartListeningForUpdates();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
ClickOnceFunctions.StopListeningForUpdates();
}
ClickOnceFunctions.cs
class ClickOnceFunctions
{
public static void CheckForCertificate()
{
if (ApplicationDeployment.IsNetworkDeployed)
{
if (ApplicationDeployment.CurrentDeployment.IsFirstRun)
{
X509Certificate2 cert =
new X509Certificate2(Resources.TestCertificateClient);
CertificateInstall.PlaceInStore(
cert, StoreName.AddressBook,
StoreLocation.CurrentUser, null);
}
}
}
private static System.Timers.Timer _updateTimer = null;
public static void StartListeningForUpdates()
{
if (ApplicationDeployment.IsNetworkDeployed)
{
_updateTimer = new System.Timers.Timer();
_updateTimer.Interval = 10000;
_updateTimer.Elapsed +=
new System.Timers.ElapsedEventHandler(_updateTimer_Elapsed);
_updateTimer.Start();
}
}
public static void StopListeningForUpdates()
{
if (ApplicationDeployment.IsNetworkDeployed)
{
_updateTimer.Stop();
_updateTimer.Dispose();
}
}
private static bool _updating;
static void _updateTimer_Elapsed(object sender,
System.Timers.ElapsedEventArgs e)
{
if (ApplicationDeployment.IsNetworkDeployed)
{
ApplicationDeployment current =
ApplicationDeployment.CurrentDeployment;
if (!_updating)
{
try
{
if (current.CheckForUpdate())
{
//MessageBox.Show("Test");
_updating = true;
current.Update();
DialogResult dr = MessageBox.Show(
"Update downloaded, restart application?",
"Application Update", MessageBoxButtons.YesNo);
if (dr == DialogResult.Yes)
Application.Restart();
}
}
catch (Exception ex)
{
_updating = false;
Console.WriteLine("Clickonce connection failed: " +
ex.ToString());
}
}
}
}
}
当 Windows 应用程序部署到客户端后启动时,会调用 CheckForCertificate
函数。它使用 ApplicationDeployment API 来检查应用程序是否通过 ClickOnce 部署(IsNetworkDeployed
属性)。如果是,则评估 IsFirstRun
属性——当应用程序新版本首次启动时为 True,否则为 False——并在 True 的情况下安装证书。请确保 PlaceInStore
方法不会在证书已存在于存储中时重复添加。
下一个调用 StartListeningForUpdate
有点复杂。它启动一个计时器,在 Elapsed
事件上执行 ApplicationDeployment.CurrentDeployment
的 CheckForUpdate
方法。如果找到新版本,则使用 Update
方法下载它。因为 System.Timers.Timer
默认在后台线程上运行 Elapsed
,所以我们可以使用 Update
而不是 UpdateAsync
。GUI 不会被更新下载阻塞。
部署新版本
现在所有选项都已设置好,部署新版本只需要你点击“发布”菜单中的“立即发布”按钮。添加新功能,修复 bug,然后点击“立即发布”。最终用户将看到两个对话框中的一个,具体取决于他们是启动应用程序还是正在使用它。
部署到你无法访问的环境
通常要求你的 ClickOnce 文件与 ServerSetup.msi 一起提供给对生产环境拥有唯一权限的主要管理员,这意味着你不能使用 Visual Studio .NET 和“立即发布”选项直接部署到用户将安装的文件夹。这意味着安装位置对你来说是未知的,你的 ClickOnce 部署将无法正常工作,直到应用程序清单文件中的正确位置被指定。
在这种情况下,你需要向你的管理员提供四样东西
- 你发布到测试环境的 ClickOnce 文件夹的完整副本,以及关于如何将其作为网络共享、IIS 上的位置或其他内容的说明
- ClickOnceKey.pfx,用于签名程序集的密钥,他将重复使用它来更改应用程序清单文件
- mage.exe,用于签名清单的实用程序,它是 .NET Framework SDK 的一部分;它可以在 %ProgramFiles%\Microsoft Visual Studio 8\SDK\v2.0\Bin\ 找到
- 执行 mage.exe 的 BAT 文件,管理员可以轻松修改以适应他的目的
我经常提供带有以下命令的 BAT 文件
mage.exe
-update <path to application manifest we update,
e.g.: \\productionServer\ClickOnce\WSEDeployment.application />
-providerurl <location of application manifest on production servers,
e.g.: \\productionServer\ClickOnce\WSEDeployment.application />
-certfile Clickoncekey.pfx
-password <your password, in our case it is test />
对于更新,你需要向你的管理员发送更改的文件,新版本文件夹的副本——例如,WSEDeployment_1_0_0_2——以及一个应用程序清单文件,例如 WSEDeployment.application。应用程序清单文件需要使用 mage.exe 进行签名,当它在生产服务器上设置时,当然。
调试 ClickOnce 部署
如果你出于任何原因想调试 ClickOnce 部署的应用程序,使用 Visual Studio .NET 中“工具”菜单的“附加到进程”即可。
但是,你必须确保部署 PDB 文件,以及默认包含的文件。你想正确地附加到进程。要包含 PDB,请使用项目属性中的“发布”项中的“应用程序文件”选项。出现对话框后,只需选择你想随自动包含的文件一起部署的文件。
既然我们已经处理了此选项,我想澄清另一个人经常问的问题。那就是,“文件要出现在此列表中的“自动包含”中需要满足什么条件?”嗯,很简单。对于引用,需要满足的条件是将“复制本地”属性设置为 True。对于项目项,需要满足的条件是将“复制到输出目录”设置为“始终复制”。
结论
我希望本文能让你对两种完全不同的部署技术有一个真正良好而深入的概述。我试图为创建安装程序的所有常见场景提出解决方案,因此我相信你现在已经具备了应对该领域可能出现的几乎任何问题的知识。
从我的角度来看,这篇文章可以在某些方面进一步扩展。关于自定义对话框可以再说更多。关于扩展 ServerSetup 安装程序以设置客户端的 ClickOnce 部署可以再说更多。关于操作目标服务器上的 IIS 可以再说更多。但是,从我的角度来看,我无法说的是人们是否对这些主题感兴趣。这篇文章已经非常大了。
所以,与其猜测并添加不必要的内容,我期待在评论部分阅读你们关于如何进一步改进这篇关于使用 .NET 创建安装程序的文章的建议。无论如何,希望你喜欢阅读,并花时间评价这篇文章。
参考文献
排名不分先后…
文章
- 操作系统名称、版本和产品类型
- 创建自定义安装对话框
- 使用 Installer 类部署 SQL Server 数据库
- 将命令行参数传递给 MSI Installer 自定义操作
- VS.NET 安装项目密码文本框
- Installer CleanUp Utility
书籍
历史
- 2007 年 6 月 26 日 – 文章初版
- 2007 年 7 月 17 日 - 文章内容更新