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

如何构建灵活且可重用的 WCF 服务

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (13投票s)

2013 年 3 月 30 日

GPL3

13分钟阅读

viewsIcon

78893

downloadIcon

1915

用于构建灵活且可重用 WCF 服务的设计模式和最佳实践。

引言

正如您可能知道的,在构建健壮的多层应用程序时,设计和构建灵活且可重用的服务层至关重要。在本文中,我们将讨论构建服务层的不同架构方法的优缺点。

您将学习有助于构建可重用服务的ю设计模式,并且我们将演示如何使用开源 Xomega Framework 为 WCF 服务实现它们。

我们还将讨论在 Silverlight 中使用 WCF 的挑战以及克服这些挑战的解决方案。

架构概览

在开始设计服务层之前,您面临的第一个问题是您的服务层是固有无状态的还是潜在有状态的。

无状态服务的优缺点

对于纯粹的无状态服务,每个请求都完全独立,这本身有其优点,但正如我们接下来将解释的,它并不完全适用于构建灵活且可重用的服务。无状态服务的优势在于出色的可伸缩性和低的资源利用率,例如内存消耗和线程使用。您可以通过运行一个服务器集群来负载均衡您的服务,每个服务器都能够独立处理任何请求,而无需消耗内存来存储状态,然后在请求处理完毕后释放所有资源,例如线程或数据库连接。

然而,缺点是您必须将整个状态与每个请求一起传递。如果您的状态始终相对较小,例如只读/查询操作或作为一个工作单元执行的简单更新,这可能不是问题。但在大型系统和企业应用程序中,在数据可以保存之前需要多级验证,用户状态可能包含跨越多个对象的众多编辑。在这种情况下,设计一个可以一次性处理所有用户编辑的服务可能是一项非常艰巨的任务,您可能需要为每种不同的场景设计单独的服务或操作,这将使您的服务不可重用,并最终可能导致维护噩梦。

有状态服务如何帮助实现灵活性和可重用性

另一方面,对于有状态服务,客户端可以在同一会话中发出的一系列请求,这些请求会将所有更改本地存储在服务器上的会话中,直到客户端发出专用请求来验证这些更改并将其保存到数据库中。这种方法允许您定义一小组相对简单的可重用更新操作,您可以在同一会话中的任何可能组合中调用它们来实现不同的场景。

例如,您的服务可能有一个更新某个实体的操作,例如员工,还有一个创建新员工的操作,这可能需要与更新操作相同的输入。与其为这两个操作重复输入结构并复制其实现的逻辑,不如让创建操作简单地创建一个带有临时键的空白新员工,然后将该临时键返回给客户端。在这种情况下,客户端会将临时键传递给后续的更新操作,以便设置实体值,然后再进行保存。我们将在接下来的章节中向您展示如何在框架级别实现此ю设计模式。

有状态服务的陷阱

即使您已经在服务层中实现了有状态服务,客户端启动和结束会话的方式也可能有所不同,这可能会产生严重的影响。

一种方法是,客户端可以在用户开始进行任何编辑时就启动编辑会话,这可能发生在用户打开表单时,然后在用户点击“保存”时结束会话。这类似于用户开始编辑时打开数据库事务,并在用户保存更改之前一直保持打开状态。虽然这可能略微简化了客户端的编程逻辑,因为客户端无需跟踪任何更改,只需直接响应用户更改即可调用服务操作,但这也会对服务层的可伸缩性和可靠性产生负面影响。由于用户编辑会话可能耗时很长,服务层必须在整个会话期间在内存中维护会话,并且必须将所有请求路由到同一个服务实例,这使得应用程序的可伸缩性或容错性降低。此外,如果用户在未保存的情况下关闭表单且会话未正确终止,则会处理悬空会话的问题。

兼顾两全其美

更好的解决方案是,客户端在用户实际点击“保存”之前 **不** 向服务器发送任何更新,届时它将打开一个会话,发出所有必要的更新请求,最后发出一个提交所有更改的请求。这种方法兼顾了两全其美,因为在单个会话中调用多个操作仍然允许以灵活且可重用的方式设计服务操作,并且它们同时被调用这一事实最小化了对可伸缩性和性能的影响,这几乎与无状态服务一样好。

WCF 中的会话

我们在 WCF 中描述的有状态服务的最直接的方法是进行配置,以便客户端可以启动一个会话,该会话将创建一个专门的服务实现对象实例,该实例将处理同一会话中的所有客户端请求,并且按照客户端发送的顺序进行处理。作为最后一个请求,客户端将发送一个明确的保存所有更改的命令,该命令将验证更改并将其提交到数据库。然后客户端将关闭会话,这将销毁专用服务实例并释放所有关联的资源。

WCF 对这样的会话提供了良好的支持,但并非所有 WCF 绑定都支持。例如,NetTcpBindingWSHttpBinding 都支持会话,而 BasicHttpBinding 不支持。它还提供了许多属性来控制会话行为,如下所示。

  • SessionMode 是一个服务契约属性,允许您强制或阻止在该服务上使用会话,或者仅允许在使用当前绑定支持的会话,这是我们建议保留的默认行为。
  • InstanceContext 是一个服务实现类属性,指定您希望如何实例化服务对象:每个调用、每个会话或作为全局单例。
  • ConcurrencyMode 是一个服务对象属性,指示单个实例是否可以同时处理多个请求。我们建议保留默认设置,这使其基本上是单线程的。
  • OperationContract 是服务接口上的一个操作级别属性,除了其他所有内容之外,它允许指示操作是否总是启动新会话或终止先前会话或两者兼有。默认行为是 None,这是我们推荐的,以便客户端可以显式控制会话何时开始和完成。

总而言之,为了实现我们描述的有状态服务,您需要使用支持会话的 WCF 绑定,并保留默认的实例化行为,该行为将在客户端打开与服务器的新通道时创建新会话,将在该通道上的任何通信中使用该会话,并在通道关闭时最终终止会话。以下代码片段演示了相应的客户端代码。

private void btnSave_Click(object sender, RoutedEventArgs e)
{
    ChannelFactory<IEmployeeService> cfEmployee = 
      new ChannelFactory<IEmployeeService>("IEmployeeService");
    IEmployeeService svcEmployee = cfEmployee.CreateChannel();
    Employee_UpdateInput inUpdate = new Employee_UpdateInput();
    obj.ToDataContract(inUpdate);

    svcEmployee.Update(inUpdate);

    svcEmployee.SaveChanges(true);
    cfEmployee.Close();
}

服务ю设计模式的实现

为了演示构建灵活且可重用服务的ю设计模式,我们将使用 Entity Framework 来允许进行数据更改,然后单独进行验证和保存,因为这是我们开源 Xomega Framework 目前支持的内容。

验证和保存

该框架定义了一个任何服务都可以继承的基接口,该接口具有一个常见的操作,用于验证并将会话中的所有更改保存如下。

/// <summary>
/// A base class for all Xomega service interfaces
/// that provides common functionality for all interfaces.
/// </summary>
[ServiceContract]
public interface IServiceBase
{
    /// <summary>
    /// Validates and saves all changes that have been made during prior service calls in the same session.
    /// If there are any validation errors during saving of the changes than a fault will be raised
    /// with an error list that contains all the errors. A fault will also be raised if there are only
    /// validation warnings and the <c>suppressWarnings</c> flag is passed in as false. In this case
    /// the client can review the warnings and re-issue the service call with this flag set to true
    /// to proceed regardless of the warnings.
    /// </summary>
    /// <param name="suppressWarnings">True to save changes even if there are warnings,
    /// False to raise a fault if there are any warnings.</param>
    /// <returns>The number of objects that have been added,
    /// modified, or deleted in the current session.</returns>
    /// <seealso cref="System.Data.Objects.ObjectContext.SaveChanges()"/>
    [OperationContract]
    [FaultContract(typeof(ErrorList))]
    int SaveChanges(bool suppressWarnings);

    /// <summary>
    /// An explicit call to end the service session to support custom session mechanism for http bindings
    /// in Silverlight. This will allow releasing the instance of the service object on the server.
    /// </summary>
    [OperationContract]
    void EndSession();
}

此操作允许您验证所有更改,并按严重性将任何验证错误消息报告回客户端。如果在验证期间遇到关键错误,例如某个字段为空,并且这会阻止进一步验证,则过程将停止并立即报告错误。否则,验证将完全执行,如果这会产生任何错误,那么这些错误将报告给客户端并且保存将不会成功。但是,如果验证仅导致警告而没有错误,那么服务器将根据传递的 suppressWarnings 标志的值,将其报告给客户端而不保存,或者继续并保存更改。这允许客户端调用此操作两次 - 第一次不抑制警告以向用户显示任何警告并给他们纠正数据和重新提交的机会,第二次实际抑制警告,如果用户选择忽略它们。

该框架还定义了一个用于所有服务实现扩展的模板基类,它使用 Entity Framework 的对象上下文作为模板参数。基服务使用我们描述的行为实现了 SaveChanges 操作,并验证当前对象上下文中实现 Xomega Framework 的 IValidatable 接口的所有已修改实体。要实现实际的验证,您可以为任何实体类添加一个部分类并使其实现 IValidatable

以下代码片段演示了具体的服务实现如何继承 Xomega Framework 基服务类。

// Employee service that extends the base service class using the AdvWorksEntities
// object context, which is available as the objCtx member from the base class.
public class EmployeeService : EntityServiceBase<AdvWorksEntities>, IEmployeeService
{
    public EmployeeService()
    {
        // Initialize the resource manager for the errors,
        // so that you could use error codes and return localized error messages.
        ErrorList.ResourceManager = Resources.ResourceManager;
    }
}

下面是一个实现自验证实体并报告本地化错误或警告消息的示例。

// Complements the generated Empoloyee entity
public partial class Employee : IValidatable
{
    public void Validate(bool force)
    {
        // Validate employee's age and report an error using an error code,
        // which is also a key for a localized message from the resource file.
        // Examples of the resulting message are shown in the comments.
        if (BirthDate > DateTime.Today.AddYears(-18))
        {
            // Invalid new employee. Employee cannot be younger than 18 years old.
            // Invalid employee 123. Employee cannot be younger than 18 years old.
            ErrorList.Current.AddError("EMP_TOO_YOUNG", KeyParams());
        }

        // Validate employee's age and marital status and report a warning using
        // the key for a localized message from the resource file.
        // Examples of the resulting message are shown in the comments.
        if (BirthDate > DateTime.Today.AddYears(-20) && MaritalStatus != "S")
        {
            // Please confirm the marital status of the new employee, who is younger than 20 years old.
            // Please confirm the marital status of the employee 123, who is younger than 20 years old.
            ErrorList.Current.AddWarning("EMP_TOO_YOUNG_TO_MARRY", KeyParams());
        }
    }

    // Utility method to return employee key for existing employees
    // or a word 'new' for substitution into the error messages as parameters.
    public object[] KeyParams()
    {
        if (EntityKey.IsTemporary) return new string[] { Resources.NEW, "" };
        else return new string[] { "", " " + EmployeeId };
    }
}

实体创建

另一个构建灵活且可重用服务的ю设计模式围绕着创建空白实体,然后使用相同的更新服务操作来设置实体值,与用于编辑现有实体相同的操作。

Entity Framework 本身支持类似的概念,但临时实体键无法在客户端和服务之间序列化和来回传递,因为相同的临时实体键应该实际上引用内存中的同一个 EntityKey 对象。

Xomega Framework 通过使用临时实体键的哈希码作为哈希键并将它们存储在哈希表中来解决此问题。这样,创建操作就可以将整数哈希键作为临时键返回给客户端,客户端可以通过调用基服务的 TempKeyId 方法并传递临时 EntityKey 来获取该键。这使得创建操作可以返回整数哈希键作为临时键给客户端,客户端可以通过调用基服务的 TempKeyId 方法并传递临时 EntityKey 来获取该键。

然后,更新操作将接受临时键和实际键值,并调用基服务的 GetEntityKey 方法来获取实际的 EntityKey,然后可以使用它来在对象上下文中查找实体。以下示例说明了服务实现类中的这种方法。

// Employee service that extends the base service class using the AdvWorksEntities
// object context, which is available as the objCtx member from the base class.
public class EmployeeService : EntityServiceBase<AdvWorksEntities>, IEmployeeService
{
    public EmployeeService()
    {
        // Initialize the resource manager for the errors,
        // so that you could use error codes and get localized error messages.
        ErrorList.ResourceManager = Resources.ResourceManager;
    }

    public EmployeeKey Create()
    {
        Employee emp = new Employee();
        objCtx.AddToEmployee(emp);
        EmployeeKey res = new EmployeeKey();
        // create a temporary key to be returned
        res.TemporaryKey = TempKeyId(emp);
        return res;
    }

    public void Update(Employee_UpdateInput input)
    {
        // get the entity key by either temporary or real key
        EntityKey key = GetEntityKey(typeof(Employee), input.TemporaryKey, input.EmployeeId);
        // find the entity by entity key
        Employee emp = objCtx.GetObjectByKey(key) as Employee;
        // copy values from properties with the same names
        ServiceUtil.CopyProperties(input, emp);
        // find and set a reference to the manager employee
        if (input.Manager.HasValue)
        {
            EntityKey mgrKey = GetEntityKey(typeof(Employee), input.Manager.Value);
            emp.ManagerObject = objCtx.GetObjectByKey(mgrKey) as Employee;
        }
        emp.ModifiedDate = DateTime.Now;
    }
}

Silverlight 的挑战

如果您尝试从 Silverlight 应用程序调用 WCF 服务,由于以下 Silverlight 限制,您很可能会遇到某些挑战。

  1. Silverlight 仅支持非常有限的绑定集(主要是 BasicHttpBinding),不支持会话。
  2. Silverlight 只允许异步调用服务。
  3. Silverlight 的 WCF 服务应与 Silverlight 应用程序托管在同一个 Web 应用程序中。

第一个 Silverlight 限制是不支持会话,这是最难解决的。Xomega Framework 通过允许您将 Silverlight 客户端通道注册到 Xomega 客户端会话管理器来提供一个易于使用的解决方案,该管理器会将您的通道的会话信息发送到 HTTP 标头中。由于在通道关闭时会自动注销,因此无需手动注销。下面是一个说明此问题的代码片段。

EmployeeServiceClient cltEmployee = new EmployeeServiceClient();
ClientSessionManager.Register(cltEmployee.InnerChannel);

然而,在服务器端,您需要确保来自同一会话的请求被路由到同一服务实例。为了做到这一点,Xomega Framework 提供了一个特殊的实例行为,您可以轻松地将其配置为 WCF 服务模型配置的一部分,如下所示。

<system.serviceModel>
  <behaviors>
    <endpointBehaviors>
      <behavior name="BasicInstanceBehavior">
        <HttpSessionInstanceBehavior/>
      </behavior>
    </endpointBehaviors>
  </behaviors>
  <services>
    <service name="AdventureWorks.Services.EmployeeService">
      <endpoint address="" binding="basicHttpBinding"
                contract="AdventureWorks.Services.IEmployeeService"
                behaviorConfiguration="BasicInstanceBehavior">
      </endpoint>
    </service>
  </services>
  <extensions>
    <behaviorExtensions>
      <add name="HttpSessionInstanceBehavior"
           type="Xomega.Framework.Services.HttpSessionInstanceBehavior, 
                 Xomega.Framework, Version=1.2.0.0, Culture=neutral, 
                 PublicKeyToken=null"/>
    </behaviorExtensions>
  </extensions>
</system.serviceModel>

第二个需要仅异步 WCF 调用的 Silverlight 限制意味着,为了实现需要前一个服务调用的结果的服务调用对话,您需要将每个后续的服务调用链接到您作为前一个服务调用一部分传递的单独回调函数中。

为了利用 Xomega 的会话支持,您需要确保对每个此类调用使用相同的通道实例。如果您的回调函数全部内联在同一个方法中,那么这很容易实现,因为该方法将能够看到在方法开头声明的通道。但是,如果您重用某些回调函数,则需要特别注意。

例如,您可能有一个读取实体的*方法,您使用它来显示现有实体。但是,如果用户创建了一个新实体并保存了它,那么您可能希望调用相同的读取方法来检索永久键而不是临时键,并刷新在保存过程中可能已更改的任何其他字段。在这种情况下,读取操作必须与相应的创建和保存操作在同一会话中执行,否则您的临时键将不会被识别。您需要为此将通道传递给读取方法。以下代码演示了这种情况。

public partial class EmployeeObjectPage : Page
{
    private EmployeeObject obj;
    private bool isNew = false;

    public EmployeeObjectPage()
    {
        InitializeComponent();
    }

    protected override void OnNavigatedTo(NavigationEventArgs e)
    {
        obj = new EmployeeObject();
        pnlMain.DataContext = obj;
        isNew = NavigationContext.QueryString.Count == 0;
        if (!isNew) Load(null);
    }

    protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
    {
        bool? modified = obj.IsModified();
        if (modified.HasValue && modified.Value && MessageBox.Show(
            "You have unsaved changes. Do you want to discard them and navigate away?",
            "Unsaved Changes", MessageBoxButton.OKCancel) == MessageBoxResult.Cancel)
            e.Cancel = true;
    }

    private void Load(EmployeeServiceClient client)
    {
        if (client == null)
        {
            int kEmployeeId;
            if (!int.TryParse(NavigationContext.QueryString["EmployeeId"], out kEmployeeId)) return;
            obj.EmployeeIdProperty.Value = kEmployeeId;
        }
        EmployeeServiceClient cltEmployee = client ?? new EmployeeServiceClient();
        cltEmployee.ReadCompleted += delegate(object sender, ReadCompletedEventArgs e)
        {
            FaultException<ErrorList> fex = e.Error as FaultException<ErrorList>;
            if (fex != null && fex.Detail != null)
                MessageBox.Show(fex.Detail.ErrorsText, "Service Errors", MessageBoxButton.OK);
            else
            {
                obj.FromDataContract(e.Result);
                isNew = false;
                btnDelete.IsEnabled = true;
            }
            // end the session initiated by the ClientSessionManager.Register
            if (client != null) cltEmployee.EndSessionAsync();
            cltEmployee.CloseAsync();
        };
        EmployeeKey inRead = new EmployeeKey();
        obj.ToDataContract(inRead);
        cltEmployee.ReadAsync(inRead);
    }

    private void btnSave_Click(object sender, RoutedEventArgs e)
    {
        obj.Validate(true);
        ErrorList valErr = obj.GetValidationErrors();
        if (valErr.HasErrors())
        {
            MessageBox.Show(valErr.ErrorsText, "Validation Errors", MessageBoxButton.OK);
            return;
        }

        EmployeeServiceClient cltEmployee = new EmployeeServiceClient();
        ClientSessionManager.Register(cltEmployee.InnerChannel);
        cltEmployee.SaveChangesCompleted += delegate(object s, SaveChangesCompletedEventArgs args)
        {
            FaultException<ErrorList> fex = args.Error as FaultException<ErrorList>;
            if (fex != null && fex.Detail != null)
                MessageBox.Show(fex.Detail.ErrorsText, "Service Errors", MessageBoxButton.OK);
            else
            {
                obj.SetModified(false, true);
                Load(cltEmployee);
            }
        };
        cltEmployee.UpdateCompleted += delegate(object s, AsyncCompletedEventArgs args)
        {
            cltEmployee.SaveChangesAsync(true);
        };
        if (!isNew)
        {
            Employee_UpdateInput inUpdate = new Employee_UpdateInput();
            obj.ToDataContract(inUpdate);
            cltEmployee.UpdateAsync(inUpdate);
        }

        // for new objects create the object and store a temporary key
        cltEmployee.CreateCompleted += delegate(object s, CreateCompletedEventArgs args)
        {
            obj.FromDataContract(args.Result);
            Employee_UpdateInput inUpdate = new Employee_UpdateInput();
            obj.ToDataContract(inUpdate);
            cltEmployee.UpdateAsync(inUpdate);
        };
        if (isNew) cltEmployee.CreateAsync();
    }

    private void btnDelete_Click(object sender, RoutedEventArgs e)
    {
        if (MessageBox.Show(
            "Are you sure you want to delete this object?\nThis action cannot be undone.",
            "Delete Confirmation", MessageBoxButton.OKCancel) == MessageBoxResult.Cancel) return;
        if (isNew) Close();

        EmployeeServiceClient cltEmployee = new EmployeeServiceClient();
        ClientSessionManager.Register(cltEmployee.InnerChannel);
        cltEmployee.SaveChangesCompleted += delegate(object s, SaveChangesCompletedEventArgs args)
        {
            FaultException<ErrorList> fex = args.Error as FaultException<ErrorList>;
            if (fex != null && fex.Detail != null)
                MessageBox.Show(fex.Detail.ErrorsText, "Service Errors", MessageBoxButton.OK);
            else
            {
                obj.SetModified(false, true);
                cltEmployee.CloseAsync();
                Close();
            }
        };
        cltEmployee.DeleteCompleted += delegate(object s, AsyncCompletedEventArgs args)
        {
            cltEmployee.SaveChangesAsync(true);
        };
        EmployeeKey inDelete = new EmployeeKey();
        obj.ToDataContract(inDelete);
        cltEmployee.DeleteAsync(inDelete);
    }

    private void btnClose_Click(object sender, RoutedEventArgs e)
    {
        Close();
    }
        
    private void Close()
    {
        NavigationService.GoBack();
    }
}

至于第三个限制,最简单的解决方案是使用同一个 ASP.NET Web 应用程序来托管您的 Silverlight 应用程序和 WCF 服务。如果这不是一个选项,例如因为您的 WCF 服务是分开托管的,那么您需要为您的服务配置跨域策略。这篇 文章对此进行了很好的概述。

实际应用中查看此方法

如果您想尝试这些用于构建灵活且可重用 WCF 服务的ю设计模式,您可以从 CodePlex 下载我们的开源 Xomega Framework,或使用 NuGet 包管理器将其作为包安装。它还包括一个强大且创新的 UI 框架,以及对最佳实践ю设计模式的支持,这提供了一种极其简单快捷的方式来构建强大的企业级多层 WPF、Silverlight 或 ASP.NET 应用程序,并带有 WCF 服务层和基于 Entity Framework 的业务逻辑层。

我们重视您的反馈,因此如果您喜欢这篇文章、有任何疑问或想分享您在构建服务层或使用我们的框架方面的经验,请留下评论。 

额外资源

要了解 Xomega Framework 强大的 UI 框架功能,请阅读我们的文章:将 MVC 提升到 .Net 的新水平。 

历史

  • 2012年1月20日 - 文章初版发布。
  • 2012年1月24日 - 上传了基于 AdventureWorks 的解决方案和示例。 
© . All rights reserved.