MSDN杂志1月刊:设计 Windows Azure 的服务

 

Thomas Erl、Arman Kurtagic 和 Herbjörn Wilhelmsen

下载代码示例

Windows Azure 是 Microsoft 正在开发的新型云计算平台 (microsoft.com/windowsazure)。通过云计算,开发人员可以在可通过 Internet 访问的虚拟环境中托管应用程序。该环境以透明方式提供应用程序所需的硬件、软件、网络和存储。

与其他云环境一样,Windows Azure 为应用程序提供了托管环境。Windows Azure 增添的优点是可通过尽可能少地更改 .NET Framework 应用程序在桌面同级应用程序中对其进行部署。

在将您的服务和应用程序转移到新的云计算环境时,应用面向服务的体系结构 (SOA) 模式并借鉴实现面向服务的解决方案的过程中积累的经验是成功的关键。为了更好地理解如何将 SOA 模式应用到 Windows Azure 部署,让我们来看一下一个虚构的银行将其服务转移到云环境的案例。

云银行

Woodgrove Bank 是一个小型金融机构,已经决定开始重点构建新的名为 Woodgrove Bank Online 的网上银行。Woodgrove Bank 的最重要客户之一 Fourth Coffee 主动要求试用处理信用卡交易的新解决方案。此时已经可以使用为解决方案规划的服务的一个子集,并且其他客户也表示了对这些服务的可用性感兴趣。然而,由于计划对解决方案的更多内容进行初次公开展示,因此出现了很多挑战。

第一个问题与可伸缩性和可靠性有关。Woodgrove Bank 从不希望对托管其 IT 解决方案的行为负责。相反,它与本地名为 Sesame Hosting Company 的 ISP 签订了配置协议。迄今为止,Sesame Hosting 一直可以满足 Woodgrove Bank 的 Web 托管需求,但新的信用卡处理解决方案带来了可伸缩性需求,Sesame Hosting 尚不能处理这些需求。

Woodgrove Bank 技术体系结构团队建议按照冗余实现模式(可在 soapatterns.org 中找到此处讨论的模式的描述)以冗余的方式部署 Woodgrove Bank Online 服务。本质上,该模式建议了一种方法来对服务进行有计划地冗余部署,从而增加可伸缩性和提高故障转移功能。Sesame Hosting 公司研究了这个建议,但无法承担为适应冗余服务部署而扩大其基础结构的费用。该公司没有资源或资金来处理需要增加的硬件、运营软件维护和网络设备。

期限也是一个问题。即使 Sesame Hosting 拥有必要的基础结构,也不能及时使 Woodgrove Bank 实现预定的首次公开展示计划。另外由于需要招聘和培训人员,也会延迟实现基础结构扩展的时间,使之远远超出 Woodgrove Bank 的时间表。

意识到 Sesame Hosting 无法满足其需求后,Woodgrove Bank 团队开始探索在公共云中托管其服务。Windows Azure 平台提供了一种虚拟化自然应用冗余实现模式的服务的方法。Windows Azure 的这一功能称为“按需应用程序实例”(在 2009 年 5 月刊中进行了讨论)。此功能以及无需长期运作即可使用 Microsoft 数据中心的能力,使 Woodgrove Bank 团队看到了希望。让我们进一步了解一下 Woodgrove Bank 如何将其解决方案迁移到 Windows Azure。

部署基本信息

首要任务是按照遵循标准化服务约定原理的“约定优先”方法部署 Web 服务。该团队使用 WSCF.blue 工具利用为实现最佳互操作性建模的 WSDL 和 XSD 生成 Windows Communication Foundation (WCF) 约定。这些服务约定如图 1 所示。

图 1 初始服务约定

由于服务需要随着时间的推移不断更改和改进,开发人员还决定使其数据约定实现 IExtensibleObject 接口来支持向前兼容模式(请参见图 2)。

图 2 初始数据约定

为了存储必要数据,Woodgrove Bank 团队希望使用 SQL Azure,因为它已经包含团队希望保留的现有数据库结构。如果开发人员能够使用非关系存储,他们可能会考虑使用 Windows Azure Storage。

Woodgrove Bank 架构师会继续创建 Visual Studio 模板云服务并使用 Visual Studio 将其发布。接着他们登录到 Windows Azure 门户来创建新的云服务(请参见图 3)。

图 3 在 Windows Azure 门户中创建服务

接下来,他们将看到一个允许他们开始部署服务的屏幕。单击“部署”按钮并指定应用程序包、配置设置和部署名称。经过几次单击操作之后,他们的服务会驻留在云中。

图 4 显示了服务配置的一个示例。

图 4 Windows Azure 服务配置

复制代码

 <Role name="BankWebRole">
  <Instances count="1" />
  <ConfigurationSettings>
    <Setting 
      name="DataConnectionString" 
      value="DefaultEndpointsProtocol=https;AccountName=YOURACCOUNTNAME;AccountKey=YOURKEY" />
    <Setting 
      name="DiagnosticsConnectionString" 
      value="DefaultEndpointsProtocol=https;AccountName=YOURDIAGNOSTICSACCOUNTNAME;AccountKey=YOURKEY" />

使解决方案灵活地符合 Woodgrove Bank 的可伸缩性要求的关键是以下配置元素:

复制代码

 <Instances count="1" />

例如,如果开发人员需要 10 个实例,应将此元素设置为:

复制代码

 <Instances count="10" />

图 5 显示了确认仅启动了一个实例且其处于运行状态的屏幕。单击“配置”按钮将显示一个屏幕,可在其中编辑服务配置并根据需要更改实例设置。

图 5 在 Windows Azure 中运行的实例

性能和灵活性

进行一些压力测试后,Woodgrove Bank 开发团队发现仅在 SQL Azure 中设置一个中心数据存储会导致在流量增加时响应时间越来越慢。开发人员决定通过使用 Windows Azure 表存储解决此性能问题,Windows Azure 表存储旨在通过在许多存储节点间分发分区来提高可伸缩性。Windows Azure 表存储还提供快速数据访问,因为系统要监视分区的使用情况并自动对这些分区进行负载平衡。但是,由于 Windows Azure 表存储不是关系数据存储,团队必须设计一些新的数据存储结构并选择一个分区和行键的组合,该组合将提供快速的响应时间。

他们最终得到了图 6 中所示的三个表。UserAccountBalance 将存储用户帐户余额。AccountTransactionLogg 将用于存储特定帐户的所有交易信息。UserAccountTransaction 表将用于存储帐户交易。可通过连接 UserId 和 AccountId 来创建 UserAccountTransaction 和 AccountTransactionLogg 表的分区键,因为它们是所有查询的一部分并可以提供快速响应时间。UserAccountBalance 表的分区键是 UserId,行键是 AccountId。二者结合使用可以提供用户及其帐户的唯一标识。

图 6 Windows Azure 表存储模型

Woodgrove Bank 认为到目前为止项目是成功的,并希望更多客户开始使用该解决方案。全球进口商即将加入,但提出了一些新的功能性要求。

其中最重要的要求是应当更改服务接口(或信息结构)。对于全球进口商来说,Woodgrove Bank 使用的信息结构与他们所要求的不兼容。由于该特定客户的重要性,Woodgrove Bank 开发团队建议应用数据模型转换模式。开发人员将使用全球进口商请求的接口创建多种新服务,这些服务将包含在全球进口商数据模型和 Woodgrove Bank 数据模型之间转换请求的逻辑。

要满足这一要求,应为 UserAccount 创建新结构。开发人员要注意确保在 UserAccountWwi 和 UserAccount 类之间存在清晰的映射,如图 7 所示。

图 7 数据模型转换的 UserAccount 结构

服务约定需要接受特定数据约定 (UserAccountWwi),该数据约定在将调用传送到解决方案的其他部分之前将请求转换为 UserAccount,然后在回复中转换回来。Woodgrove Bank 的架构师意识到他们在实现这些新要求时可以重用基本服务接口。最后的设计如图 8 所示。

图 8 全球进口商的服务约定

开发人员选择通过创建 UserAccount 类的多种扩展方法(包括 TransformToUserAccountWwi 和 TransformToUserAccount 方法)实现数据转换。

新服务接受 UserAccountWwi 数据约定。在将请求发送到其他层之前,通过调用 TransformToUserAccount 扩展方法将数据转换为 UserAccount。在将响应发送回使用者之前,通过调用 TransformToUserAccountWwi 将 UserAccount 约定转换回 UserAccountWwi。有关这些元素的详细信息,请参阅本文的代码下载中 UserAccountServiceAdvanced 的源代码。

消息传送和队列

尽管 Woodgrove Bank 现在已经启动且正在运行,并可以满足大量的传入请求,但分析人员注意到服务使用情况中有许多重要峰值。其中一些峰值定期出现(具体来说,在星期一早上和星期四下午)。然而,某些浮动不可预见。

通过 Windows Azure 配置将更多的资源放到网上是一个简单易行的解决方案,但因为一些大客户(如全球进口商)对新服务感兴趣,所以并发使用量浮动预计还会增加。

Woodgrove Bank 的开发人员更进一步研究了 Windows Azure 产品,发现了与应用可靠消息传送模式和异步队列模式相关的功能。他们的结论是,可靠消息传送不是最合适的选择,因为其会限制客户的技术选择。而异步队列无需客户使用任何特殊技术,因此他们将对其进行重点研究。然而,在 Windows Azure 云内部,可靠消息传送更适用,原因是其中所使用的技术均由 Microsoft 提供。

目标是即使由于错误条件或计划的维护导致服务脱机,也不应丢失任何消息。异步队列模式符合此条件,但某些产品不适合于此模式。例如,处理在线信用卡交易时有必要提示答案(确认或拒绝资金划转)。但在其他情况下,该模式可正常使用。

可以使用 Windows Azure Queues(自 11 月的 CTP 版本起,可以直接在角色实例之间进行通信)实现 Web 角色和工作者角色之间的通信(有关这些角色的说明,请参见 msdn.microsoft.com/magazine/dd727504),Windows Azure Queues 在默认情况下同时具有异步性和可靠性。这并非自动表示最终用户和 Woodgrove Bank 的服务之间的通信是可靠的。事实上,客户和 Web 角色中的服务之间的通信线路明显不可靠。Woodgrove Bank 团队决定不解决这个问题,因为在要进行通信的客户的通信线路上完全实现可靠性机制需要客户遵循与 Woodgrove Bank 相同的技术选择。这是不切实际并且令人不快的。

开始使用队列

在客户向 UserAccountService 发送消息后,该消息将被置于 Windows Azure Queue 中,客户会接收到确认消息。接着,UserAccountWorker 将能够从队列获取消息。如果与 UserAccountWorker 的连接发生中断,由于消息安全存储在队列中,将不会丢失。

如果 UserAccountWorker 内的处理出现问题,消息不会从队列中删除。要确保这一点,应仅在完成工作后调用队列中的 DeleteMessage 方法。如果在超时(将超时硬编码为 20 秒)之前 UserAccountWorker 没有完成消息处理,则该消息将再次在队列中可见,以便其他 UserAccountWorker 实例可以尝试对其进行处理。

在客户向 UserAccountService 发送消息后,该消息将被置于队列中,客户将接收到 TransactionResponse 类型的确认消息。从客户的角度出发,应使用异步队列。ReliableMessaging 用于 UserAccountStorageAction 和 AccountStorageWorker 之间的通信,它们分别驻留在 Web 角色和工作者角色中。以下显示了调用处理程序如何将消息置于队列中:

复制代码

 public TransactionResponse ReliableInsertMoney(
  AccountTransactionRequest accountTransactionrequest) {

//last parameter (true) means that we want to serialize
//message to the queue as XML (serializeAsXml=true)
  return UserAccountHandler.ReliableInsertMoney(
    accounttransactionRequest.UserId, 
    accounttransactionRequest.AccountId, 
    accounttransactionRequest.Amount, true);
}

UserAccountHandler 是返回 IUserAccountAction 的属性,将注入到运行时中。这使将实现与约定分离继而更改实现变得更容易:

复制代码

 public IUserAccountAction<Models.UserAccount> UserAccountHandler
  {get;set;}

public UserAccountService(
  IUserAccountAction<Models.UserAccount> action) {

  UserAccountHandler = action;
}

在将消息发送到其中一个可靠的操作之后,该消息将被置于队列中。图 9 中的第一种方法显示了如何将数据存储为可序列化的 XML,第二种方法显示了如何将数据存储为队列中的字符串。请注意,Windows Azure Queues 中存在消息大小限制,最大为 8KB。

图 9 存储数据

复制代码

 public TransactionResponse ReliableHandleMoneyInQueueAsXml( 
  UserAccountTransaction accountTransaction){ 

  using (MemoryStream m = new MemoryStream()){ 
    XmlSerializer xs = 
      new XmlSerializer(typeof(UserAccountTransaction)); 
    xs.Serialize(m, accountTransaction); 

    try 
    { 
      QueueManager.AccountTransactionsQueue.AddMessage( 
        new CloudQueueMessage(m.ToArray())); 
      response.StatusForTransaction = TransactionStatus.Succeded; 
    } 
    catch(StorageClientException) 
    { 
      response.StatusForTransaction = TransactionStatus.Failed; 
      response.Message = 
        String.Format("Unable to insert message in the account transaction queue userId|AccountId={0}, messageId={1}", 
        accountTransaction.PartitionKey, accountTransaction.RowKey); 
    } 
  } 
  return response; 
} 

public TransactionResponse ReliableHandleMoneyInQueue( 
  UserAccountTransaction accountTransaction){ 

  TransactionResponse response = this.CheckIfTransactionExists( 
    accountTransaction.PartitionKey, accountTransaction.RowKey); 
       
  if (response.StatusForTransaction == TransactionStatus.Proceed) 
  { 
    //userid|accountid is partkey 
    //userid|accountid|transactionid|amount 
    string msg = string.Format("{0}|{1}|{2}", 
      accountTransaction.PartitionKey, 
      accountTransaction.RowKey, 
      accountTransaction.Amount); 

    try 
    { 
      QueueManager.AccountTransactionsQueue.AddMessage( 
        new CloudQueueMessage(msg)); 
      response.StatusForTransaction = TransactionStatus.Succeded; 
    } 
    catch(StorageClientException) 
    { 
      response.StatusForTransaction = TransactionStatus.Failed; 
      response.Message = 
        String.Format("Unable to insert message in the account transaction queue userId|AccountId={0}, messageId={1}", 
        accountTransaction.PartitionKey, accountTransaction.RowKey); 
    } 
  } 
  return response; 
}

QueueManager 类将使用配置提供的定义初始化队列:

复制代码

 CloudQueueClient queueClient = 
  CloudStorageAccount.FromConfigurationSetting(
    "DataConnectionString").CreateCloudQueueClient();

accountTransQueue = queueClient.GetQueueReference(
  Helpers.Queues.AccountTransactionsQueue);
accountTransQueue.CreateIfNotExist();

loggQueue = queueClient.GetQueueReference(
  Helpers.Queues.AccountTransactionLoggQueue);
loggQueue.CreateIfNotExist();

AccountStorageWorker 侦听有关 AccountTransactionQueue 的消息并获取队列中的消息。要侦听消息,工作人员必须打开正确的队列:

复制代码

 var storageAccount = CloudStorageAccount.FromConfigurationSetting(
  "DataConnectionString");
// initialize queue storage 
CloudQueueClient queueStorage = storageAccount.CreateCloudQueueClient();
accountTransactionQueue = queueStorage.GetQueueReference(
  Helpers.Queues.AccountTransactionsQueue);

在打开队列并且 AccountStorageWorker 读取消息后,该消息将在队列中保持 20 秒(可见性超时设置为 20 秒)的不可见状态。在这段时间内,工作人员将尝试处理该消息。

如果消息处理成功,则会从队列中删除该消息。如果处理失败,会将该消息放回队列中。

处理消息

ProcessMessage 方法首先需要获取消息的内容。这可以通过两种方法实现。第一种方法是,可以将消息作为字符串存储在队列中:

复制代码

 //userid|accountid|transactionid|amount
var str = msg.AsString.Split('|');...

第二种方法是,可以将消息序列化为 XML:

复制代码

 using (MemoryStream m = 
  new MemoryStream(msg.AsBytes)) {

  if (m != null) {
    XmlSerializer xs = new XmlSerializer(
      typeof(Core.TableStorage.UserAccountTransaction));
    var t = xs.Deserialize(m) as 
      Core.TableStorage.UserAccountTransaction;

    if (t != null) { ....... }
  }
}

当 AccountStorageWorker 由于某种原因发生中断或无法处理消息时,由于消息都保存在队列中,所以不会丢失。如果 AccountStorageWorker 内进行的处理失败,消息将不会从队列中删除并且 20 秒后将重新在队列中可见。

要确保这种行为,应仅在完成工作后调用队列中的 DeleteMessage 方法。如果在超时之前 AccountStorageWorker 没有完成消息处理,则消息将再次在队列中可见,以便其他 AccountStorageWorker 实例可以尝试对其进行处理。图 10 处理存储为字符串的消息。

图 10 处理排队的消息

复制代码

 if (str.Length == 4){
  //userid|accountid|transactionid|amount
  UserAccountSqlAzureAction ds = new UserAccountSqlAzureAction(
    new Core.DataAccess.UserAccountDB("ConnStr"));
  try
  {
    Trace.WriteLine(String.Format("About to insert data to DB:{0}", str),      
      "Information");
    ds.UpdateUserAccountBalance(new Guid(str[0]), new Guid(str[1]), 
      double.Parse(str[3]));
    Trace.WriteLine(msg.AsString, "Information");
    accountTransactionLoggQueue.DeleteMessage(msg);
    Trace.WriteLine(String.Format("Deleted:{0}", str), "Information");
  }
  catch (Exception ex)
  {
    Trace.WriteLine(String.Format(
      "fail to insert:{0}", str, ex.Message), "Error");
  }
}

幂等功能

如果 Woodgrove Bank 的某位客户发送了一个将资金从一个帐户划转到另一个帐户的请求但该消息丢失了,应该怎么办?如果客户重新发送该消息,则有可能两个或多个请求到达服务并被分别处理。

其中一位 Woodgrove Bank 团队成员立即确定这是需要冥等功能模式的一种情况。该模式要求以一种可以安全重复的方式来实现功能或操作。简言之,Woodgrove Bank 希望实现的解决方案需要客户执行正确的行为,即将唯一的 ID 附加到每个请求并承诺在重试时重新发送包含相同的唯一 ID 的完全相同的消息。要处理此情况,应将唯一 ID 保存在 Windows Azure 表存储中。在处理任何请求之前,都需要检查是否已经处理包含该 ID 的消息。如果已经处理,则会创建正确的回复,但不会进行与新请求关联的处理。

虽然这意味着额外的查询会干扰中心数据存储,但这是必要的。这将导致性能降低,因为在进行其他处理之前要对中心数据存储执行一些查询。然而,为满足 Woodgrove Bank 的需要,允许此行为占用更多时间和其他资源是合理的选择。

Woodgrove Bank 团队通过添加交易 ID 更新了 IUserAccountAction 中的 ReliableInsertMoney 和 ReliableWithDrawMoney 方法及其实现:

复制代码

 TransactionResponse ReliableInsertMoney(
  Guid userId, Guid accountId, Guid transactionId, 
  double amount, bool serializeToQueue);

TransactionResponse ReliableWithDrawMoney(
  Guid userId, Guid accountId, Guid transactionId, 
  double amount, bool serializeToQueue);

已通过将 TransactionId 添加为 RowKey 更新 UserAccountTransaction 表 (Windows Azure Storage),因此每次插入到表中的内容都将具有唯一的交易 ID。

将为每次唯一交易发送唯一消息 ID 的任务分配给客户:

复制代码

 WcfClient.Using(new AccountServiceClient(), client =>{ 
  using (new OperationContextScope(client.InnerChannel)) 
  { 
    OperationContext.Current.OutgoingMessageHeaders.MessageId = 
      messageId; 
    client.ReliableInsertMoney(new AccountTransactionRequest { 
      UserId = userId, AccountId = accountId, Amount = 1000 }); 
  } 
});

此处使用的帮助程序类可在 soamag.com/I32/0909-4.asp 中找到。

IUserAccountService 定义没有改变。实现此功能所需的唯一更改是从客户发送的传入消息头中读取 MessageId,并在后台处理过程中使用它(请参见图 11)。

图 11 捕获消息 ID

复制代码

 public TransactionResponse ReliableInsertMoney(
  AccountTransactionRequest accountTransactionrequest) {
  var messageId = 
    OperationContext.Current.IncomingMessageHeaders.MessageId;
  Guid messageGuid = Guid.Empty;
  if (messageId.TryGetGuid(out messageGuid))
    //last parameter (true) means that we want to serialize
    //message to the queue as XML (serializeAsXml=true)
    return UserAccountHandler.ReliableInsertMoney(
      accounttransactionRequest.UserId, 
      accounttransactionRequest.AccountId, messageId, 
      accounttransactionRequest.Amount, true);
  else 
    return new TransactionResponse { StatusForTransaction = 
      Core.Types.TransactionStatus.Failed, 
      Message = "MessageId invalid" };      
}

更新的 UserAccountAction 现在可以获取每个幂等操作的交易 ID。服务尝试完成一个冥等操作后,将检查该交易是否存在于表存储中。如果该交易存在,则该服务将返回存储在 AccountTransactionLogg 表中的交易消息。交易 ID 会保存为存储表 UserAccountTransaction 中的 RowKey。为查找正确的用户和帐户,该服务会发送分区键 (userid|accountid)。如果未找到交易 ID,则消息会被置于 AccountTransactionsQueue 中进行进一步处理:

复制代码

 public TransactionResponse ReliableHandleMoneyInQueueAsXml(
  UserAccountTransaction accountTransaction) {
  TransactionResponse response = this.CheckIfTransactionExists(
    accountTransaction.PartitionKey, accountTransaction.RowKey);
  if(response.StatusForTransaction == TransactionStatus.Proceed) {
    ...
  }
  return response;
}

CheckIfTransactionExists 方法(请参见图 12)用于确保尚未对交易进行处理。它将尝试查找特定用户帐户的交易 ID。如果找到交易 ID,客户将获得包含已完成交易的详细信息的响应消息。

图 12 检查交易状态和交易 ID

复制代码

 private TransactionResponse CheckIfTransactionExists(
  string userIdAccountId, string transId) {

  TransactionResponse transactionResponse = 
    new Core.Models.TransactionResponse();

  var transaction = this.TransactionExists(userIdAccountId, transId);
  if (transaction != null) {
    transactionResponse.Message = 
      String.Format("messageId:{0}, Message={1}, ", 
      transaction.RowKey, transaction.Message);
    transactionResponse.StatusForTransaction = 
      TransactionStatus.Completed;
  }
  else
    transactionResponse.StatusForTransaction = 
      TransactionStatus.Proceed;
  return transactionResponse;
}

private UserAccountTransaction TransactionExists(
  string userIdAccountId, string transId) {
  UserAccountTransaction userAccountTransaction = null;
  using (var db = new UserAccountDataContext()) {
    try {
      userAccountTransaction = 
        db.UserAccountTransactionTable.Where(
        uac => uac.PartitionKey == userIdAccountId && 
        uac.RowKey == transId).FirstOrDefault();
      userAccountTransaction.Message = "Transaction Exists";
    }
    catch (DataServiceQueryException e) {
      HttpStatusCode s;
      if (TableStorageHelpers.EvaluateException(e, out s) && 
        s == HttpStatusCode.NotFound) {
        // this would mean the entity was not found
        userAccountTransaction = null;
      }
    }
  }
  return userAccountTransaction;
}

CheckIfTransactionExists 具有一个有趣属性,即如果找不到要查找的数据,Windows Azure Storage 将返回 404 HTTP 状态代码(因为其使用 REST 接口)。并且,如果找不到这些数据,ADO.NET 客户端服务 (System.Data.Services.Client) 会引发一个异常。

更多信息

有关实现此概念验证解决方案的详细信息,请检查在线提供的源代码。soapatterns.org 上发布了 SOA 模式说明。如有问题,请发送邮件至 herbjorn@wilhelmsen.se

Arman Kurtagić 是一位致力于开发 Microsoft 新技术的顾问,在 Omegapoint 工作,后者提供业务驱动的安全 IT 解决方案。之前他曾担任多种角色,包括开发人员、架构师、导师和企业家,并曾涉足过金融、游戏和媒体等行业。

Herbjörn Wilhelmsen 是 Forefront Consulting Group 的一位顾问,定居于斯德哥尔摩。他主要研究面向服务的体系结构和业务体系结构。Wilhelmsen 是 SOA 模式核查委员会的主席,当前在 IASA 的瑞典语一章中领导 Business 2 IT 小组。他是《SOA with .NET and Azure》的合著者之一,此书为 Prentice Hall Service-Oriented Computing Series from Thomas Erl 的一部分。

Thomas Erl 是全球最畅销的 SOA 作者,Prentice Hall Service-Oriented Computing Series from Thomas Erl 的系列编辑以及《SOA Magazine》的编辑。Erl 是 SOA Systems Inc. 及 SOASchool.com SOA 认证专家计划的创始人。Erl 创立了 SOA Manifesto 工作组,并且是私有事件和公共事件的演讲者和讲师。有关详细信息,请访问 thomaserl.com

衷心感谢以下技术专家审阅本文:Steve Marx

Current Issue

Browse All MSDN Magazines

Tip: Click the printer button in your browser toolbar to get the printer friendly version of this article.