自动化 Windows 桌面应用程序的云服务
利用云服务作为 Amazon S3 和 EC2 Windows 实例中的匿名桌面环境
前言
当我们发现需要自动执行某些任务,但这些任务需要桌面应用程序(如 Microsoft Office、Web 浏览器以及其他需要桌面环境的旧式软件)时,我们通常会使用各种技术,例如 VBA、宏、命令脚本、PowerShell,以及通过 Win32 进行消息处理,这些都可以自动模拟用户的输入。
为什么桌面应用程序无法集成到服务器端?
然而,如果我们想要同样的功能,但需要作为一种在服务器端运行的 Web 服务来服务大量用户,那么实现起来将非常棘手,甚至不可能。这是因为桌面应用程序不适合在服务器环境中运行,原因有两个。
桌面环境 vs. 服务器环境
首先,桌面应用程序只能在桌面环境中运行,通常是 Microsoft Windows,它使用原生的 Win32 和 GDI API。大多数 Web 服务器(包括 IIS)不支持原生 API。它还需要用户凭据、注册表和存储才能正常运行。Web 服务器无法为每个客户端提供如此广泛但隔离的环境。
内存泄漏和蓝屏
其次,即使 Web 服务器能够为每个客户端提供桌面环境,桌面应用程序也很可能无法 100% 将资源返还给系统。我们称之为“内存泄漏”。这是由于桌面环境和服务器环境之间的差异造成的。如果桌面应用程序存在少量内存泄漏,用户最终会关闭计算机,这样应用程序仍然可以使用。然而,在服务器端,应用程序应该长时间重复运行。应用程序不会返还它们从操作系统获得的相同资源,最终操作系统会因为内存泄漏而耗尽资源。例如,如果一个应用程序存在 0.01% 的内存泄漏,每次运行时不返还 0.01% 的系统资源,那么在运行 10,000 次后将导致资源耗尽。(当我们在 Windows 中遇到这种情况时,曾经出现过“蓝屏”。)在服务器环境中,内存泄漏对服务的稳定性和持久性至关重要。
主要思想
建立稳定的桌面自动化 Web 服务的主要想法是在虚拟化技术出现时产生的,它为我们提供了一个清晰的虚拟环境,可以回滚、克隆并完美隔离。然而,可扩展性仍然是一个问题,即服务如何根据潜在请求在技术上运行无限的实例。一个隔离的 VM 需要相当长的时间来启动和关闭以将资源返还给主服务器,并且每个高端配置文件服务器最多只能有 8 个活动 VM。如果同一时间最多只有 200 个活动用户请求隔离自动化,那么就需要 25 个高端服务器加上一个主服务器,这需要花费大量的金钱来购买和维护 26 台服务器;即使大多数服务器在服务期间也不会处于活动状态。
服务结构
它需要是隔离的和可扩展的
与冗长的介绍相比,使用云实现桌面自动化服务相当简单。它主要有 2 层。
Web 服务区域
第一层是 Web 服务区域,包括 Web 服务器、通信服务器和数据库。我使用 IIS 7.0 和 MVC2 作为 Web 服务器,WCF (Windows Communication Foundation) 作为通信服务器,SQL Server 2008 R2 作为数据库。它可以被任何替代品替换,例如 Apache 和 MySQL。在此层中,每个组件都在同一个网络层,因此它们可以互相调用。Web 服务器自然对公共网络开放,通信服务器仅通过 VPN 对云网络开放。
云区域
另一层是存储文件并运行桌面应用程序的云区域。它由云存储、云实例控制器和云实例组成。在云实例中,有一个名为 Instance Runner 的应用程序,它在启动时启动并执行自动化。我使用了 Amazon S3 作为云存储,Amazon EC2 作为云实例服务,它们都是 Amazon Cloud Service 的一部分。
网络结构
出于安全原因,云存储仅允许 Web 服务区域和内部网络服务器访问。不允许从外部网络访问云实例,但允许通过公共 TCP 端口从内部访问互联网。云实例通过 VPN 访问通信服务器,VPN 只允许云实例连接。总之,云区域是封装的,并且只能由 Web 服务区域访问,而 Web 服务区域是唯一暴露给用户的层。
实现
工作原理
在云中运行自动化服务的重大步骤包括四个操作。场景如下。用户访问服务网站,注册为新用户,然后创建一个项目。接下来,他将输入文件上传到 Web 服务器。当他上传所有必需文件后,他会发出运行自动化的命令。网站会持续显示为他的任务预留的实例状态。一旦任务完成,实例将被终止,并将更新后的文件返还给存储。最后,用户可以下载在桌面环境的桌面应用程序中打开和修改过的更新后的文件。
在这种场景下,用户使用起来非常简单方便,但对于开发人员来说,开发、调试和测试服务会有些复杂,因为它涉及多个层和部署;开发人员需要监督在不同位置与不同层通信的所有方面。
Excel 云服务
更详细地说,我实现了一个原型服务,该服务可以在云桌面环境中自动执行 Microsoft Excel 计算,并将分享开发此类服务模式的一些挑战和技巧。为了方便起见,我们将此服务称为“ExcelCloudService (ECS)”。
操作 I. 自动化设计
在此操作中,用户创建一个用于自动化的项目,并上传将在自动化中打开的文件。大部分工作是使用成员资格服务进行 Web 前端编程。我在 MVC2 环境中使用了内置的 ASP.NET 成员资格进行身份验证。它可以被 PHP、JSP 或 AWS (Amazon Web Service) SDK 支持的其他同类产品替换。在 ExcelCloudService 中,文件安全是首要任务,因此文件通过 SSL 上传到 Web 服务,然后由 Web 服务器上传到 S3 存储桶。S3 存储桶不对公众开放,其位置不会暴露给任何用户,因此文件只能通过 Web 服务器访问。
public static void SendFileToS3(string filename, Stream ImgStream)
{
string accessKey = ConfigurationManager.AppSettings["AWSAccessKey"];
string secretAccessKey = ConfigurationManager.AppSettings["AWSSecretKey"];
string bucketName = ConfigurationManager.AppSettings["AWSS3OutputBucket"];
string keyName = filename;
AmazonS3 client = Amazon.AWSClientFactory.CreateAmazonS3Client
(accessKey, secretAccessKey);
PutObjectRequest request = new PutObjectRequest();
request.WithInputStream(ImgStream);
request.WithBucketName(bucketName);
request.WithKey(keyName);
request.StorageClass =
S3StorageClass.ReducedRedundancy; //set storage to reduced redundancy
try
{
client.PutObject(request);
}
catch (AmazonS3Exception ex)
{
throw ex;
return;
}
}
代码 1. SendFileToS3
// GET: /ExcelCloud/Details/5
[Authorize]
public ActionResult Details(int id)
{
if (Request.Files.Count > 0) // If files are uploaded
{
String savedFileName = "";
var r = new List<ViewDataUploadFilesResult>();
foreach (string file in Request.Files)
{
HttpPostedFileBase hpf = Request.Files[file] as HttpPostedFileBase;
if (hpf.ContentLength == 0) // If file is empty
continue;
//Put file into directory named by id
savedFileName = Convert.ToString(id) + "/" + Path.GetFileName(hpf.FileName);
try
{
SendFileToS3(savedFileName, hpf.InputStream);
}
catch(Exception e)
{
Debug.Fail(e.Message);
}
r.Add(new ViewDataUploadFilesResult()
{
Name = savedFileName,
Length = hpf.ContentLength
});
}
// Insert file info to database
try
{
// Create entity class
var entities = new EXCELCLOUDDBEntities();
// Add ExcelCloudFile class into entity
entities.ExcelCloudFiles.AddObject(new ExcelCloudFile() {
filename = Request.Files[0].FileName,
projectid = id,
description = Request["excelcloudform_description"],
S3path = savedFileName });
entities.SaveChanges();
// If status of selected project is zero (ready to run),
if ((from files in entities.ExcelCloudFiles
where files.projectid == id
select files.status).FirstOrDefault() == 0)
{
// Run button info
ViewData["RunURL"] = "/ExcelCloud/Run/" + id;
// Project status
ViewData["Status"] =
(int)(from projects in entities.ExcelCloudProjectDetails
where projects.id == id
select projects.status).FirstOrDefault();
}
// Query selected project and send it to view
return View(from files in entities.ExcelCloudFiles
where files.projectid == id
orderby files.executionorder
select files);
}
catch
{
//Exception view here
return View();
}
}
else // If files are not uploaded, view a project detail
{
var entities = new EXCELCLOUDDBEntities();
// If status of selected project is zero (ready to run),
if ((from files in entities.ExcelCloudFiles
where files.projectid == id
select files.status).FirstOrDefault() == 0)
{
// Run button info
ViewData["RunURL"] = "/ExcelCloud/Run/" + id;
// Project status
ViewData["Status"] = (int)(from projects in entities.ExcelCloudProjectDetails
where projects.id == id
select projects.status).FirstOrDefault();
}
// Query selected project and send it to view
return View(from files in entities.ExcelCloudFiles
where files.projectid == id
orderby files.executionorder
select files);
}
}
代码 2. 上传操作
操作 II. 请求运行自动化
当用户请求运行自动化时,Web 服务器会在 Amazon EC2 上请求新实例。
// Create AWS EC2 Client object
AmazonEC2 ec2 = AWSClientFactory.CreateAmazonEC2Client(
ConfigurationManager.AppSettings["AWSAccessKey"],
ConfigurationManager.AppSettings["AWSSecretKey"]
);
RunInstancesRequest ec2RIRequest = new RunInstancesRequest();
// Set instance type
ec2RIRequest.InstanceType = "t1.micro";
// Get AMI id
ec2RIRequest.ImageId = ConfigurationManager.AppSettings["AWSImageId"];
ec2RIRequest.MinCount = 1;
ec2RIRequest.MaxCount = 1;
// Set security group
ec2RIRequest.SecurityGroup.Add("DomusEC2Windows");
ec2RIRequest.KeyName = "domus";
RunningStatus model = new RunningStatus();
model.Messages = new List<string>();
RunInstancesResponse ec2RIResponse = new RunInstancesResponse();
try
{
ec2RIResponse = ec2.RunInstances(ec2RIRequest);
}
catch (AmazonEC2Exception ex)
{
// Put exception
Debug.Fail(ex.Message);
}
代码 3. 请求新实例
一旦新实例完成启动并准备好进行自动化,它应该自动从 S3 存储中提取文件并运行自动化。此过程需要满足以下状态的几个先决条件:
用于实例的 AMI (Amazon Machine Image) 应该为自动化设计和定制。
尽管 Amazon EC2 提供了各种启动映像,但它仍需要为自动化过程进行定制。

对于 ECS,安装了 Microsoft Excel 和 COM 自动化对象来自动化 Excel 过程。还安装了 AWS SDK for .NET 来访问 S3 存储。Instance Runner 需要 DotNet 4.0 Framework 客户端配置文件来与 WCF 服务器通信。
自动化应该能够访问 GUI1
如果您将 Windows 服务视为 Instance Runner,您将难以访问 GUI。在 Windows Vista 之前,有一个选项“允许服务与桌面交互”可以从服务中启用 GUI 访问,但在包括 Windows 2008 和 R2 在内的较新 Windows 版本中已被阻止。此外,某些旧式应用程序需要一个完全登录的桌面环境,提供用户目录,例如“我的文档”、“临时文件”和“应用程序数据”。最后,我决定使用控制台应用程序作为 Instance Runner,该应用程序在 Windows 中设置为启动应用程序。
实例应设置为自动登录为用户以获取桌面环境2
通常,所有服务器操作系统都设置为在启动时等待登录,但我们需要自定义 AMI 以自动登录。要进行设置,请打开注册表并找到以下子项:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
并添加 DefaultUserName
字符串条目(使用您的 AMI 用户名),DefaultPassword
字符串条目(使用密码),以及 AutoAdminLogon
字符串条目(值为“1
”)。为了安全起见,您应该在 DefaultPassword
中输入加密的密码3。有一个实用程序可以使用加密密码设置自动登录4。
实例不应暴露给公众
如果 EC2 实例可以公开访问,这对服务来说至关重要,因为它包含 AWS Access Key 和 Secret Key。当用于自动化的 AMI 准备好部署时,您需要阻止防火墙中的所有出站连接,包括 RPC (Remote Procedure Call) 和 RDP (Remote Desktop Protocol)。
自动化应通过实例 ID 从通信服务器获取任务
创建新实例后,我们可以获取其实例 ID。ECS 将其存储在实例表中,并关联项目 ID,因此实例可以获取正确项目的任务。要在 Instance Runner 中获取实例 ID,请发送如下 HTTP GET 请求5:
WebClient wget = new WebClient();
byte[] instanceid = null;
try
{
instanceid = wget.DownloadData("http://169.254.169.254/latest/meta-data/instance-id");
}
catch (Exception e)
{
evt.WriteEntry("Error opening socket: "+e.Message, EventLogEntryType.Error);
}
if (instanceid == null)
{
evt.WriteEntry("No instance id", EventLogEntryType.Error);
return;
}
代码 4. 获取实例 ID
最后,您将有两个版本的 AMI:开发版和部署版。您可以在开发 AMI 中开发、调试和升级旧式软件或组件。一旦 AMI 上的一切准备就绪,您就可以通过复制并禁用出站端口来构建部署版本。
操作 III. 执行
当实例启动时,它通过 WCF 向通信服务器发送初始请求。通信服务器通过实例 ID 识别实例,然后发送需要 Excel 计算的文件列表。为了获取 DataMember
形式的集合,我创建了一个使用 ICollection
的 EntityCollection
类6。最近,WCF 有一个特性可以将集合类型作为 DataMember
7。
[DataContract]
public class EntityCollection<EntityType> : ICollection<EntityType>
{
#region Constructor
public EntityCollection()
{
Entities = new List<EntityType>();
}
#endregion
[DataMember]
public int AdditionalProperty { get; set; }
[DataMember]
public List<EntityType> Entities { get; set; }
#region ICollection<T> Members
public void Add(EntityType item)
{
Entities.Add(item);
}
public void Clear()
{
this.Entities.Clear();
}
public bool Contains(EntityType item)
{
return Entities.Contains(item);
}
public void CopyTo(EntityType[] array, int arrayIndex)
{
this.Entities.CopyTo(array, arrayIndex);
}
public int Count
{
get
{
return this.Entities.Count;
}
}
public bool IsReadOnly
{
get
{
return false;
}
}
public bool Remove(EntityType item)
{
return this.Entities.Remove(item);
}
public EntityType this[int index]
{
get
{
return this.Entities[index];
}
set
{
this.Entities[index] = value;
}
}
#endregion
#region IEnumerable<T> Members
public IEnumerator<EntityType> GetEnumerator()
{
return this.Entities.GetEnumerator();
}
#endregion
#region IEnumerable Members
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return this.Entities.GetEnumerator();
}
#endregion
}
代码 5. 用于 WCF 响应的 EntityCollection
使用集合类型 DataMember
,PingHostResponse
类返回一个包含成功值的 S3 路径列表。
[DataContract]
public class PingHostResponse
{
bool pResultSuccess;
EntityCollection<string> pFileSequence;
[DataMember]
public bool ResultSuccess
{
get { return pResultSuccess; }
set { pResultSuccess = value; }
}
[DataMember]
public EntityCollection<string> FileSequence
{
get { return pFileSequence; }
set { pFileSequence = value; }
}
}
代码 6. PingHostReponse
Instance Runner 创建 Office OLE 自动化对象。您需要通过设置 DisplayAlerts
属性来禁用显示会阻止自动化过程的对话框。
Microsoft.Office.Interop.Excel.Application app;
try
{
app = new Microsoft.Office.Interop.Excel.Application();
}
catch (Exception ex)
{
evt.WriteEntry("Fail to start Excel object: " + ex.Message, EventLogEntryType.Error);
return;
}
app.DisplayAlerts = false;
代码 7. 创建 Office 自动化对象
文件存储在临时目录中。这些文件将在实例完成其作业并终止时被删除。
List<string> LocalFileSequence = new List<string>();
foreach (String filename in result.FileSequence.Entities)
{
evt.WriteEntry("Start downloading a file :
" + filename, EventLogEntryType.Information);
string tempfile = Path.Combine(Path.GetTempPath(), Path.GetFileName(filename));
LocalFileSequence.Add(tempfile);
try
{
GetFileFromS3(filename, tempfile);
}
catch (Exception ex)
{
evt.WriteEntry("Fail to download : " + ex.Message, EventLogEntryType.Error);
return;
}
}
代码 8. 从 S3 获取文件
关闭 Office 自动化 COM 对象时,需要通过调用 ReleaseComObject
方法来清理互操作对象。此外,许多文章建议强制进行垃圾回收(如果安装了 Visual Studio Tools for Office (VSTO)),甚至进行两次8。
evt.WriteEntry("Closing excel object...", EventLogEntryType.Information);
try
{
app.Quit();
}
catch (Exception ex)
{
evt.WriteEntry("Fail to calculate : " + ex.Message, EventLogEntryType.Error);
return;
}
Marshal.ReleaseComObject(app);
app = null;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
代码 9. 终止自动化
Instance Runner 向通信服务器发送一个包含其实例 ID 的 Complete
消息,以便通信服务器可以标记任务已完成并请求终止该实例。
public void Complete(string value)
{
var entity = new EXCELCLOUDDBEntities();
EntityCollection<string> pFileSequence = new EntityCollection<string>();
// If there is a record whose instance id is value,
if (entity.ExcelCloudInstances.Any(i => i.instance == value))
{
var thisinstance = entity.ExcelCloudInstances.FirstOrDefault
(i => i.instance == value);
// Set status to complete
thisinstance.status = 2;
entity.SaveChanges();
}
AmazonEC2 ec2 = AWSClientFactory.CreateAmazonEC2Client(
ConfigurationManager.AppSettings["AWSAccessKey"],
ConfigurationManager.AppSettings["AWSSecretKey"]
);
// Create a terminate instance request
TerminateInstancesRequest ec2TIRequest = new TerminateInstancesRequest();
// Put instance id into request object
ec2TIRequest.WithInstanceId(value);
// Request termination
TerminateInstancesResponse ec2TIResponse = ec2.TerminateInstances(ec2TIRequest);
}
代码 10. 终止实例
操作 IV. 获取结果
所有文件都已准备好下载,用户可以在 Web 浏览器上下载计算后的文件。唯一需要注意的是,存储在 S3 中的文件出于安全原因不应直接提供给用户。Web 服务器必须获取文件并将其发送给用户。最终,只有拥有足够权限的用户才能从 S3 存储中下载文件。任何 S3 的连接信息都不应暴露给公众。
// GET: /ExcelCloud/Download/5
[Authorize]
public ActionResult Download(int id)
{
var entities = new EXCELCLOUDDBEntities();
var filerec = (from files in entities.ExcelCloudFileDetails
where files.id == id
select files).FirstOrDefault();
if (filerec.owner == User.Identity.Name)
{
S3FileResult temp = new S3FileResult(filerec.S3path, filerec.filename);
return temp.Result;
}
else return RedirectToAction("Details",filerec.projectid);
}
代码 11. 下载文件
为了将文件从 S3 返回到 MVC 的 FileStreamResult
对象,我创建了一个 S3FileResult
类来封装 S3 API 和 FileStreamResult
类,因此它会从 S3 存储桶下载文件并将其存储到 MemoryStream
对象(不需要临时文件或目录),然后将其作为 FileStreamResult
对象返回。
public class S3FileResult
{
public FileStreamResult Result { get; set; }
protected MemoryStream fs;
public S3FileResult(string S3Path, string FileName)
{
string accessKey = ConfigurationManager.AppSettings["AWSAccessKey"];
string secretAccessKey = ConfigurationManager.AppSettings["AWSSecretKey"];
string bucketName = ConfigurationManager.AppSettings["AWSS3OutputBucket"];
string keyName = S3Path;
// Init Amazon S3 Client
AmazonS3 client = Amazon.AWSClientFactory.CreateAmazonS3Client
(accessKey, secretAccessKey, new AmazonS3Config()
{ CommunicationProtocol = Protocol.HTTP });
GetObjectRequest request = new GetObjectRequest();
request.WithBucketName(bucketName);
request.WithKey(keyName);
GetObjectResponse response;
try
{
response = client.GetObject(request); // Request to get a file
}
catch (AmazonS3Exception ex)
{
// Exception here
throw ex;
return;
}
// Create a memory steram to route the file
fs = new MemoryStream();
byte[] data = new byte[32768];
int byteread = 0;
do
{
// Read stream
byteread = response.ResponseStream.Read(data, 0, data.Length);
// Write to memory stream
fs.Write(data, 0, byteread);
} while (byteread > 0);
fs.Flush();
// Init stream position
fs.Position = 0;
// Create a MVC FileStreamResult based on memory stream
Result = new FileStreamResult(fs, "application/force-download")
{ FileDownloadName = FileName };
}
}
代码 12. 适用于 MVC2 的 S3FileResult 类
潜在异常
我们简要回顾了用于自动化桌面应用程序的云服务的基本架构。它结构简单,但有相对多的层,可能会出现多种异常或安全漏洞。
与云存储服务交互
当 EC2 Instance Runner 尝试从 S3 获取文件时,需要检查文件下载是否成功。如果失败,它应该向通信服务器返回失败信号,以便通信服务器可以终止实例并向用户显示失败消息。如果结果文件成功上传到 S3 但下载失败,则应显示“云存储暂时不可用”。
EC2 实例
再次强调,每个实例应与公共域隔离非常重要,因为它包含 AWS Access Key 和 Secret Key。强烈不建议让用户将自己的应用程序上传到实例,因为这可能是强迫实例被黑客访问的特洛伊木马代码。如果您启动一个使用第三方应用程序的服务,可以将其安装在 AMI 上。即使在 Excel 中,宏也可能控制服务器并入侵系统。ACL 权限必须设置得尽可能严格。登录用户应具有有限的管理功能访问权限。它只需要凭据来运行 Instance Runner 和自动化应用程序,以及访问临时工作存储。然而,实例不可避免地会通过某些 Web 启用应用程序(如 Web 浏览器)暴露其 IP 地址,因此应通过防火墙和网络安全严格禁止出站连接。除此之外,Instance Runner 还应监控自动化应用程序的每个操作,以查看它是否因某些原因而冻结。如果是这样,Instance Runner 需要向通信服务器发送失败信号并开始终止该进程。因此,无论进程如何终止,通信服务器都可以终止发生故障的实例,并让用户和管理员知晓。
结论
最后,我想提一下我的原型服务中尚未实现的内容。首先,尚未实现支付系统,因为 Amazon AWS API 没有提供足够好的 API 来确定每个实例的成本。我可以得到工作时间,所以可以估算一个大概的数字,但不够准确。Devpay 似乎只适用于 AWS 注册用户。其次,我没有实现各种可选功能;例如选择实例类型、通过屏幕截图监控实例以及通过 AJAX 在 Web 服务器上广播实例状态。如果允许,我将在下一篇文章中讨论这些。此外,新的 MVC3 已发布,但我还没有机会仔细研究。也许,如果可能,我可以发布 MVC3 源代码。我们的服务仍处于 alpha 阶段,因此我将在本文中随时更新任何公告。如果您对云项目有任何疑问,请访问 DomusDigital.com。
1 http://msdn.microsoft.com/en-us/library/ms683502(v=vs.85).aspx
2 http://support.microsoft.com/kb/324737
3 http://msdn.microsoft.com/en-us/library/ms995355.aspx
4 http://technet.microsoft.com/en-us/sysinternals/bb963905.aspx
5 https://forums.aws.amazon.com/thread.jspa?messageID=109571
6 http://social.msdn.microsoft.com/forums/en-US/wcf/thread/45dcd32f-605a-41df-ba63-8042b31a511d
7 http://msdn.microsoft.com/en-us/library/aa347850.aspx
8 http://stackoverflow.com/questions/158706/how-to-properly-clean-up-excel-interop-objects-in-c
历史
- (2011/03/01) 创建第一个版本
- (2011/03/02) 格式小修