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

Silverlight 中无缝的 WCF 服务消耗 - 第 2 部分:修复“一切都是异步”问题

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (38投票s)

2010 年 9 月 18 日

MIT

20分钟阅读

viewsIcon

122875

downloadIcon

628

简化 WCF 服务异步调用和一般异步编程。

引言

在我上一篇文章中,我展示了如何在 Silverlight 应用程序中通过创建异步双接口来消费 WCF 服务,而无需静态代理生成。本文将展示如何极大地简化异步调用 WCF 服务的代码。虽然该解决方案将在 Silverlight 应用程序的背景下介绍,但您可以轻松地在处理异步方法调用的任何其他代码中使用它。

背景

让我们首先看一个我将用于展示解决方案的示例应用程序。这是一个众所周知的 TODO 列表应用程序。主要用例是:

  • 列出所有任务
  • 创建新任务
  • 标记任务为已完成
  • 删除任务

Task 实体

[DataContract]
public class Task
{
    [DataMember]
    public Guid Id
    {
        get; set;
    }

    [DataMember]
    public string Description
    {
        get; set;
    }

    [DataMember]
    public bool IsCompleted
    {
        get; set;
    }

    [DataMember]
    public DateTime Created
    {
        get; set;
    }

    [DataMember]
    public DateTime Completed
    {
        get; set;
    }
}

TaskService 接口

[ServiceContract]
public interface ITaskService
{
    [OperationContract]
    Task[] GetAll();

    [OperationContract]
    Task Create(string description);

    [OperationContract]
    Task MarkComplete(Guid id);

    [OperationContract]
    void Delete(Guid id);
}

及其实现

public class TaskService : ITaskService
{
    static readonly List<Task> tasks = new List<Task>();

    public Task[] GetAll()
    {
        return tasks.ToArray();
    }

    public Task Create(string description)
    {
        var task = new Task
        {
            Id = Guid.NewGuid(),
            Description = description,
            Created = DateTime.Now
        };

        tasks.Add(task);

        return task;
    }

    public Task MarkComplete(Guid id)
    {
        Task task = tasks.Find(x => x.Id == id);

        task.IsCompleted = true;
        task.Completed = DateTime.Now;

        return task;
    }

    public void Delete(Guid id)
    {
        Task existent = tasks.Find(x => x.Id == id);
        tasks.Remove(existent);
    }
}

用户界面很简单,看起来像下面这样:

Sample UI

我在这里使用了 MVVM 模式(如果您是 WPF/Silverlight 开发人员,就无法避免;如果您不知道 MVVM 是什么,您可以在 CP 或网络上找到大量关于该主题的文章;我建议阅读 Jeremy 关于该主题的文章)。控制按钮绑定到相应的命令,可查看/可编辑的属性绑定到相应的可观察属性。

MVVM Bindings

相应视图的 XAML 代码

<!-- TaskManagementView.xaml -->

<UserControl x:Class="Sample.Silverlight.WCF.Views.TaskManagementView" 
         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
         xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
         xmlns:Utility="clr-namespace:Sample.Silverlight.WCF.Utility" 
         mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="400" 
         Loaded="UserControl_Loaded">

    
    <UserControl.Resources>
        <Utility:BooleanToForegroundConverter x:Key="foregroundConverter"/>
        <Utility:BooleanToVisibilityConverter x:Key="visibilityConverter"/>
    </UserControl.Resources>
    
    <Grid x:Name="LayoutRoot" Width="400" Height="300">
        <Grid>
            <TextBox Text="{Binding Description, Mode=TwoWay, UpdateSourceTrigger=Explicit}" 
                     TextChanged="DescriptionTextChanged"/>
            
            <Button Content="Create" Command="{Binding CreateCommand}"/>

            <ListBox ItemsSource="{Binding Tasks}" 
                     SelectedItem="{Binding Selected, Mode=TwoWay}">
                
                <ListBox.ItemTemplate>
                    <DataTemplate>
                        <Grid>

                            <TextBlock Text="{Binding Description}" 
                                Foreground="{Binding IsCompleted, Converter={StaticResource foregroundConverter}}"/>
                            
                            <Line Stretch="Fill" X2="20" 
                                  X1="0" StrokeThickness="1" 
                                  Stroke="Black" 
                                  Visibility="{Binding IsCompleted, Converter={StaticResource visibilityConverter}}"/>

                           <ToolTipService.ToolTip>
                                <ToolTip>
                                    <Grid>
                                        <TextBlock Text="Created: "/>
                                        <TextBlock Text="{Binding Created}"/>
                                        
                                        <TextBlock Text="Updated: "/>
                                        <TextBlock Text="{Binding Completed}"/>
                                    </Grid>
                                </ToolTip>
                            </ToolTipService.ToolTip>
                        </Grid>
                    </DataTemplate>
                </ListBox.ItemTemplate>
            </ListBox>
            <Button Content="Mark Complete" Command="{Binding MarkCompleteCommand}"/>
            <Button Content="Delete" Command="{Binding DeleteCommand}"/>
        </Grid>
     </Grid>
</UserControl>

我删除了一些布局和外观相关的属性,以免打扰您(您可以在附件示例中查看完整代码)。这里使用了两个值转换器来很好地呈现“已完成”任务。已完成的任务以灰色显示,并带有删除线文本装饰(Silverlight 中没有对删除线文本装饰的本机支持,因此使用了 Line 的小技巧)。工具提示显示任务创建和完成的日期。

代码隐藏非常简单。它只是创建视图模型类的相应实例并将其设置为 DataContext。之后,它通过调用其 Activate() 方法来通知 ViewModel 执行首次初始化。

/// TaskManagementView.xaml.cs

public partial class TaskManagementView
{
    public TaskManagementView()
    {
        InitializeComponent();
    }

    private void UserControl_Loaded(object sender, RoutedEventArgs e)
    {
        var viewModel = new TaskManagementViewModel();
        DataContext = viewModel;

        viewModel.Activate();
    }

    private void DescriptionTextChanged(object sender, TextChangedEventArgs e)
    {
        ((TextBox) sender).GetBindingExpression(TextBox.TextProperty).UpdateSource();
    }
}

默认情况下,TextBox 控件在失去焦点时更新绑定的值,因此我在这里使用事件处理程序来强制立即更新。现在,让我们看看最有趣的部分——ViewModel。

/// TaskManagementViewModel.cs

public class TaskManagementViewModel : ViewModelBase
{
    Task selected;
    string description;

    public TaskManagementViewModel()
    {
        InitializeProperties();
        InitializeCommands();
    }

    void InitializeProperties()
    {
        Tasks = new ObservableCollection<Task>();
    }

    void InitializeCommands()
    {
        var cmd = new ViewModelCommandBuilder(this);

        CreateCommand       = cmd.For(()=> Create());
        DeleteCommand       = cmd.For(()=> Delete());
        MarkCompleteCommand = cmd.For(()=> MarkComplete());
    }

    public ICommand CreateCommand
    {
        get; private set;
    }

    public ICommand MarkCompleteCommand
    {
        get; private set;
    }

    public ICommand DeleteCommand
    {
        get; private set;
    }

    public ObservableCollection<Task> Tasks
    {
        get; private set;
    }

    public Task Selected
    {
        get { return selected; }
        set
        {
            selected = value;

            NotifyOfPropertyChange(() => Selected);
            NotifyOfPropertyChange(() => CanDelete);
            NotifyOfPropertyChange(() => CanMarkComplete);
        }
    }

    public string Description
    {
        get { return description; }
        set
        {
            description = value;

            NotifyOfPropertyChange(() => Description);
            NotifyOfPropertyChange(() => CanCreate);
        }
    }

    public void Activate()
    {
        // TODO: call service to get all tasks and fill tasks collection

        foreach (Task each in all)
        {
            Tasks.Add(each);
        }

        Selected = Tasks[0];
    }

    public void Create()
    {
        // TODO: call service with description entered to create new task

        Tasks.Insert(0, newTask);
        Selected = newTask;

        Description = "";
    }

    public bool CanCreate
    {
        get { return !string.IsNullOrEmpty(Description); }
    }

    public void MarkComplete()
    {
        // TODO: call service to complete selected task and 
        //       update task list with the task returned from service

        Replace(Selected, updatedTask);
        Selected = updatedTask;
    }

    public bool CanMarkComplete
    {
        get { return Selected != null && !Selected.IsCompleted; }
    }

    void Replace(Task prevTask, Task newTask)
    {
        var position = Tasks.IndexOf(prevTask);
            
        Tasks.RemoveAt(position);
        Tasks.Insert(position, newTask);
    }

    public void Delete()
    {
        // TODO: call service to delete task and update task list
            
        Tasks.Remove(Selected);

        if (Tasks.Count > 0)
            Selected = Tasks[0];
    }

    public bool CanDelete
    {
        get { return Selected != null; }
    }
}

这确实是直截了当的代码;唯一需要解释的部分是 ViewModelBase 基类和命令的初始化。我从 Rob Eisenberg 精美的“构建您自己的 MVVM 框架”演示文稿中汲取了这两个想法,我向所有从事 Silverlight/WPF 开发的人推荐该演示文稿。ViewModelBase 类非常简单:

/// ViewModelBase.cs

public class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged = delegate { };

    public void NotifyOfPropertyChange(string propertyName)
    {
        Call.OnUIThread(() => PropertyChanged(this, new PropertyChangedEventArgs(propertyName)));
    }

    public void NotifyOfPropertyChange<TProperty>(Expression<Func<TProperty>> property)
    {
        var lambda = (LambdaExpression) property;

        MemberExpression memberExpression;
        if (lambda.Body is UnaryExpression)
        {
            var unaryExpression = (UnaryExpression)lambda.Body;
            memberExpression = (MemberExpression)unaryExpression.Operand;
        }
        else
        {
            memberExpression = (MemberExpression) lambda.Body;
        }

        NotifyOfPropertyChange(memberExpression.Member.Name);
    }
}

它实现了通常的 INotifyPropertyChanged 基础设施,并允许您拥有强类型属性通知(而不是与字符串纠缠)。此外,由于 PropertyChanged 事件可以从异步代码(即,从与主线程不同的线程)引发,因此需要将其调度到主线程,这是通过使用一个简单的实用程序类 Call 及其 OnUIThread() 例程完成的。

/// Call.cs

public static class Call
{
    public static void OnUIThread(Action action)
    {
        Dispatcher dispatcher = Deployment.Current.Dispatcher;

        if (dispatcher.CheckAccess())
        {
            action();
            return;
        }

        dispatcher.BeginInvoke(action);
    }
}

WPF/Silverlight 中的命令由 ICommand 类表示,具有以下接口:

/// System.Windows.Input.ICommand.cs

public interface ICommand
{
    bool CanExecute(object parameter);
    void Execute(object parameter);
    event EventHandler CanExecuteChanged;
}

为了避免为每个可能的命令定义此接口的实现,Rob 建议使用 ViewModel 中定义的方法。因此,不是为每个命令都有一个实现类,而是只有一个通用类,并且它的每个实例都(通过反射)绑定到指定的方法。当命令执行时,调用绑定的方法。此外,通过简单的约定,如果 ViewModel 中有一个属性与命令方法同名,但带有“Can”前缀,它将用于监视命令的 CanExecute 状态。这是通过利用相同的 INotifyPropertyChanged 基础设施完成的:

/// ViewModelCommandBuilder.cs

public class ViewModelCommandBuilder
{
    readonly ViewModelBase viewModel;

    public ViewModelCommandBuilder(ViewModelBase viewModel)
    {
        this.viewModel = viewModel;
    }

    public ICommand For(Expression<Action> expression)
    {
        var methodCall = (MethodCallExpression)expression.Body;

        return new ViewModelCommand(viewModel, methodCall.Method, 
                                    viewModel.GetType().GetProperty("Can" + methodCall.Method.Name));
    }
}

/// ViewModelCommand.cs

public class ViewModelCommand : ICommand
{
    readonly MethodInfo execute;
    readonly PropertyInfo canExecute;
    readonly ViewModelBase viewModel;

    public event EventHandler CanExecuteChanged = delegate { };

    public ViewModelCommand(ViewModelBase viewModel, MethodInfo execute, PropertyInfo canExecute)
    {
        this.viewModel = viewModel;
        this.execute = execute;
        this.canExecute = canExecute;

        ObserveCanExecuteChanged();
    }

    void ObserveCanExecuteChanged()
    {
        if (canExecute == null)
            return;
            
        viewModel.PropertyChanged += (s, e) =>
        {
            if (e.PropertyName == canExecute.Name)
                CanExecuteChanged(this, EventArgs.Empty);
        };
    }

    public bool CanExecute(object parameter)
    {
        return canExecute == null || (bool) canExecute.GetValue(viewModel, null);
    }

    public void Execute(object parameter)
    {
        execute.Invoke(viewModel, null);
    }
}

它是一种优雅且相当简单的命令解决方案。它极大地减少了我们为了支持 WPF/Silverlight 中使用 MVVM 模式的命令而需要编写的代码量。在示例应用程序中,以下方法/属性对已注册并用于命令:

  • Create/CanCreate
  • MarkComplete/CanMarkComplete
  • Delete/CanDelete

这大概就是您需要了解的有关示例应用程序的所有信息。我将在本文中贯穿这个示例解决方案,并将其用于后续文章(请务必在深入高级主题之前理解它)。

那么,让我们回到主题。如您所见,示例代码中留下了一些空白(标记为 TODO),我将尝试通过尝试各种方法来处理 Silverlight 中 WCF 服务的异步调用来填补这些空白。

仪式

正如您从我的上一篇文章中回忆的那样,我不太喜欢静态服务代理生成,正如之前发现的那样,我们可以通过简单地创建异步双接口来摆脱这个冗余。因此,根据我在上一篇文章中概述的规则,我们 ITaskService 的异步接口应该如下所示:

/// TaskService.cs

[ServiceContract(Name = "ITaskService")]
public interface ITaskServiceAsync
{
    [OperationContract(AsyncPattern = true)]
    IAsyncResult BeginGetAll(AsyncCallback callback, object state);
    Task[] EndGetAll(IAsyncResult result);

    [OperationContract(AsyncPattern = true)]
    IAsyncResult BeginCreate(string description, AsyncCallback callback, object state);
    Task EndCreate(IAsyncResult result);

    [OperationContract(AsyncPattern = true)]
    IAsyncResult BeginMarkComplete(Guid id, AsyncCallback callback, object state);
    Task EndMarkComplete(IAsyncResult result);

    [OperationContract(AsyncPattern = true)]
    IAsyncResult BeginDelete(Guid id, AsyncCallback callback, object state);
    void EndDelete(IAsyncResult result);
}

此接口仅在 Silverlight 端使用,但我发现将其与同步接口放在同一个文件中很方便,因为这使得维护更容易。此外,我们还需要在 Silverlight 项目中链接 Task 实体类定义。

Sl Code Sharing Via Linking

要打开 WCF 通道,我们需要为异步接口创建 ChannelFactory 并将其指向我们正常的同步服务实现:

var factory = new ChannelFactory<ITaskServiceAsync>(
    new BasicHttpBinding(), 
    new EndpointAddress("https:///SampleSilverlightWCF/Services/TaskService.svc"));

通道工厂只应创建一次,然后用于每次服务调用(每次我们需要调用服务时,都需要再次打开新通道)。ViewModel 的构造函数非常适合这种情况:

public class TaskManagementViewModel : ViewModelBase
{
    ChannelFactory<ITaskServiceAsync> factory;
    ...

    public TaskManagementViewModel()
    {
        InitializeFactory();
        InitializeProperties();
        InitializeCommands();
    }

    void InitializeFactory()
    {
        factory = new ChannelFactory<ITaskServiceAsync>(
            new BasicHttpBinding(), 
            new EndpointAddress("https:///SampleSilverlightWCF/Services/TaskService.svc"));
    }
    ...

现在,让我们以 Activate() 方法为例,并研究它的代码可能是什么样子,尝试不同的方法来处理 WCF 服务的异步调用。

public void Activate()
{
    // TODO: call service to get all tasks and fill tasks collection

    foreach (Task each in all)
    {
        Tasks.Add(each);
    }

    Selected = Tasks[0];
}

异步编程模型 (APM)

这是最直接的方法,对于每个至少尝试过 .NET 中异步编程的人来说都应该很熟悉。在 APM 中,异步操作由一对 BeginXXX/EndXXX 方法表示,它要求您将完成回调委托传递给 BeginXXX 方法:

public void Activate()
{
    ITaskServiceAsync service = factory.CreateChannel();

    service.BeginGetAll(ar =>
    {
        try
        {
            var all = service.EndGetAll(ar);

            foreach (Task each in all)
            {
                Tasks.Add(each);
            }

            Selected = Tasks[0];
        }
        catch (FaultException exc)
        {
            // ... some exception handling code ...
        }
    }, null);
}

当操作完成时,异步方法将调用此回调委托。然后在此回调中,您可以获取结果,并且还需要处理抛出的任何异常。这种编码模式存在一些问题:

  1. 反向的代码结构看起来很奇怪
  2. 额外的参数和符号增加了噪音
  3. 样板异常处理代码

在这个简单的示例中,关于问题 1 和 2,代码看起来并不是特别糟糕,但尝试嵌套几个其他异步调用,代码将变得非常丑陋。您可以尝试在某种程度上将 DRY 应用于 3,但只会增加 2 的效果。

Reactive Extensions (RX)

每个人都在大喊,通过使用 RX,您可以极大地简化处理异步方法调用的代码,以及它对于这项任务来说是多么性感。让我们看看:

public void Activate()
{
    ITaskServiceAsync service = factory.CreateChannel();

    Func<IObservable<Task[]>> method =
        Observable.FromAsyncPattern<Task[]>(service.BeginGetAll, service.EndGetAll);

    method.Invoke().Subscribe(

        result =>

        {
            foreach (Task each in result)
            {
                Tasks.Add(each);
            }

            Selected = Tasks[0];
        },

        exception =>
        {
            // ... some exception handling code ...
        }
    );
}

好的,它删除了 try-catch 处理程序,相反,您可以订阅 onError 回调。太棒了。这确实是一个了不起的改进(但代价是增加了额外的符号噪音)。是只有我这样觉得,还是基于 RX 的代码看起来并没有比 APM 好多少?代码仍然受到相同的反向代码结构的影响,如果您尝试嵌套另一个异步调用,代码将比根本不使用 RX 更难阅读(特别是如果您的代码约定包括诸如强制使用“this.”和字段下划线前缀等花招)。事实上,我在一个真实世界的项目中见过足够多的这种情况,其中使用 RX 处理异步模式的代码无非是无限纠缠的 lambda 和特殊符号(=>_.())的混乱。它看起来就像一个密码。

更新:我不是一个人。Sacha Barber 似乎在这里对 RX 得出了类似的结论:这里

GoF 命令模式

这可能是我在 Silverlight 应用程序和与异步服务消费相关的示例中不断看到的最新的趋势。这个想法很简单——将每个服务方法调用表示为它自己的对象。还记得 GoF 命令模式的描述吗?来自维基百科:

在面向对象编程中,命令模式是一种设计模式,其中一个对象用于表示和封装稍后调用方法所需的所有信息。此信息包括方法名称、拥有该方法的对象以及方法参数的值。

GoF Command pattern

基本上,每个输入参数和调用结果都对应一个属性。客户端代码只是创建一个命令实例,填充属性值,然后执行命令;之后,它获取命令执行的结果并继续进行。命令对象隐藏了所有丑陋的异步底层代码,并允许分解通用代码(如异常处理)。此主题的一个变体是为所有服务命令提供一个单一类,将每个命令表示为一个接受完成回调的方法。例如,Michael Washington 在他的 CodeProject 文章中使用了这种风格。

让我们为 GetAll() 服务方法实现命令,看看 Activate() 方法的代码会是什么样子:

/// TaskManagementViewModel.cs

public void Activate()
{
    var cmd = new GetAllTaskServiceCommand(factory);

    cmd.Execute(() =>
    {
        if (cmd.Failed)
        {
            // ... some exception handling code ...
            return;
        }

        foreach (Task each in cmd.Result)
        {
            Tasks.Add(each);
        }

        Selected = Tasks[0];
    });
}

在我看来,现在看起来很干净。现在是实际的命令对象:

public class GetAllTaskServiceCommand
{
    readonly ChannelFactory<ITaskServiceAsync> factory;

    public GetAllTaskServiceCommand(ChannelFactory<ITaskServiceAsync> factory)
    {
        this.factory = factory;
    }

    public Exception Exception
    {
        get; private set;
    }

    public bool Failed
    {
        get { return Exception != null; }
    }

    public Task[] Result
    {
        get; private set;
    }

    public void Execute(Action complete)
    {
        ITaskServiceAsync service = factory.CreateChannel();

        service.BeginGetAll(ar =>
        {
            try
            {
                Result = service.EndGetAll(ar);
            }
            catch (FaultException exc)
            {
                Exception = exc;
            }

            complete();

        }, null);            
    }
}

不错。我们可以分解一些通用代码……但不多。

public abstract class ServiceCommand<T>
{
    public Exception Exception
    {
        get; protected set;
    }

    public bool Failed
    {
        get { return Exception != null; }
    }

    public T Result
    {
        get; protected set;
    }

    public void Execute(Action complete)
    {
        BeginExecute(ar =>
        {
            try
            {
                EndExecute(ar);
            }
            catch (FaultException exc)
            {
                Exception = exc;
            }

            complete();
        });
    }

    protected abstract void BeginExecute(AsyncCallback callback);
    protected abstract void EndExecute(IAsyncResult asyncResult);
}

public class GetAllTaskServiceCommand : ServiceCommand<Task[]>
{
    readonly ChannelFactory<ITaskServiceAsync> factory;
    ITaskServiceAsync service;

    public GetAllTaskServiceCommand(ChannelFactory<ITaskServiceAsync> factory)
    {
        this.factory = factory;
    }

    protected override void BeginExecute(AsyncCallback callback)
    {
        service = factory.CreateChannel();
        service.BeginGetAll(callback, null);            
    }

    protected override void EndExecute(IAsyncResult asyncResult)
    {
        Result = service.EndGetAll(asyncResult);
    }
}

虽然我喜欢客户端代码(Activate() 方法)的样子,但我感觉我只是将复杂性转移到了另一个地方。此外,这种方法需要为每个服务调用创建一个命令类,这很无聊。

打开灯

解决根本原因

我尝试了几乎所有声称可以简化异步编程的方法(甚至包括我在这里没有列出的一些非常奇特的方法),但没有一个让我满意。所有这些时间,我都感觉自己是在试图治愈疾病的症状,而不是解决其根本原因。所有这些复杂性都来自于我们需要显式处理异步接口的事实。它的消费很难泛化,并且需要手动维护(每次更改同步接口时,都需要修改其孪生兄弟)。但是,如果我能以某种方式透明地处理这种异步性呢?我能否完全消除显式处理异步接口的需要?我想让我的客户端代码摆脱那个异步的怪物。

这个想法在我脑海中闪现后,我很快就提出了以下设计:

Approach

一小时后,我拥有了一个完全可用的基于反射的解决方案。它很丑陋,但完成工作相当不错。我使用字符串来指定要调用的方法,并拥有一个接受参数值数组的泛型方法。然后,通过使用反射,我获得了 BeginXXX\EndXXX 方法对,并使用它进行实际调用。所采取的方法并不理想;缺点是:没有类型安全且不利于重构。

每当我看到字符串用于此类场景时,我总是会想起我们(使用 .NET 3.5 及更高版本的开发人员)有更好的方法来处理它们。表达式树来救援!又过了一小时,经过几个重构循环后,我终于得到了一个我完全满意的设计和实现。

Service call design

类型安全(编译时检查)要求区分对 void 服务方法和返回结果的服务方法的调用。让我们看看实际的实现,从基类开始:

/// ServiceCall.cs

public abstract class ServiceCall<TServiceAsync>
{
    readonly ChannelFactory<TServiceAsync> factory;
    readonly MethodCallExpression call;

    TServiceAsync channel;
    Action callback;

    protected ServiceCall(ChannelFactory<TServiceAsync> factory, MethodCallExpression call)
    {
        this.factory = factory;
        this.call = call;
    }

    public bool Failed
    {
        get { return Exception != null; }
    }

    public Exception Exception
    {
        get; private set;
    }

    public void Execute(Action onComplete)
    {
        callback = onComplete;

        channel = factory.CreateChannel();
        object[] parameters = BuildParameters();

        MethodInfo beginMethod = GetBeginMethod();
        beginMethod.Invoke(channel, parameters);
    }

    MethodInfo GetBeginMethod()
    {
        return GetMethod("Begin" + call.Method.Name);
    }

    MethodInfo GetEndMethod()
    {
        return GetMethod("End" + call.Method.Name);
    }

    static MethodInfo GetMethod(string methodName)
    {
        return typeof(TServiceAsync).GetMethod(methodName);
    }

    object[] BuildParameters()
    {
        var parameters = new List<object>();

        foreach (Expression argument in call.Arguments)
        {
            object parameter = Expression.Lambda(argument).Compile().DynamicInvoke();
            parameters.Add(parameter);
        }

        parameters.Add(new AsyncCallback(OnCallCompleted));
        parameters.Add(null);

        return parameters.ToArray();
    }

    void OnCallCompleted(IAsyncResult ar)
    {
        try
        {
            MethodInfo endMethod = GetEndMethod();
            HandleResult(endMethod.Invoke(channel, new object[]{ar}));
        }
        catch (Exception exc)
        {
            Exception = exc;
        }

        Call.OnUIThread(callback);
    }

    protected abstract void HandleResult(object result);
}

当命令执行时,我们从 MethodCallExpression 推导出方法名称并提取传递给它的参数。因为参数可以是任何表达式,而不仅仅是常量,所以我们需要实际评估它们。然后,我们找到异步接口上的 BeginXXX 方法,创建一个通道,并使用它来服务调用。当调用完成时,我们再次查找 EndXXX 方法并使用它来完成调用。然后,我们通过使用提供的回调(首先将执行调度到主线程)向客户端发出调用完成信号。其余代码非常简单且不言自明:

/// ServiceCommand.cs

public class ServiceCommand<TService, TServiceAsync> : ServiceCall<TServiceAsync>

{
    public ServiceCommand(ChannelFactory<TServiceAsync> factory, Expression<Action<TService>> call)
        : base(factory, (MethodCallExpression) call.Body)
    {}

    protected override void HandleResult(object result)
    {
        // do nothing - commands are void
    }
}

/// ServiceQuery.cs

public class ServiceQuery<TResult, TService, TServiceAsync> : ServiceCall<TServiceAsync>
{
    public ServiceQuery(ChannelFactory<TServiceAsync> factory, Expression<Func<TService, TResult>> call)
           : base(factory, (MethodCallExpression) call.Body)
    {}

    public TResult Result
    {
        get; private set;
    }

    protected override void HandleResult(object result)
    {
        Result = (TResult) result;
    }
}

现在,让我们看看客户端代码是什么样子。对于返回结果的服务方法:

public void Activate()
{
    var action = new ServiceQuery<Task[], ITaskService, ITaskServiceAsync>(factory, 
        service => service.GetAll());
            
    action.Execute(() =>
    {
        Task[] all = action.Result;

        foreach (Task each in all)
        {
            Tasks.Add(each);
        }

        Selected = Tasks[0];
    });
}

对于不返回结果(void)的服务方法:

public void Delete()
{
    var action = new ServiceCommand<ITaskService, ITaskServiceAsync>(factory,
        service => service.Delete(Selected.Id));
            
    action.Execute(() =>
    {
        Tasks.Remove(Selected);

        if (Tasks.Count > 0)
            Selected = Tasks[0];
    });
}

太酷了。我们拥有完整的编译时检查;无需费心为每个服务方法创建类,也无需处理肮脏的异步底层。一切都以完全透明的方式完成。

隐藏构建器背后的构建复杂性

如果你注意客户端代码,你会发现它重复地做着同样的事情:构造命令,指定 lambda 表达式,然后执行它。大部分代码实际上与构造有关。而且这种构造代码高度重复。为了构造命令或查询,我们 ViewModel 中的每个方法都指定了相同的一组东西——同步接口、异步接口和工厂。在我看来,这种构造代码占用了大量的屏幕空间,增加了输入量,更重要的是——它分散了对主要内容的注意力,即实际的服务调用及其参数。

当我遇到这类设计上的烦恼时,我通常会使用建造者模式来解决它们。引用建造者模式的定义(结果部分):

它将构造和表示的代码隔离开来。建造者模式通过封装复杂对象的构造和表示方式来提高模块性。客户端无需了解定义产品内部结构的类;此类不会出现在建造者接口中。

这里的关键是:“它封装了复杂对象的构造和表示方式”。我们可以将服务命令和查询的构造和初始化封装在一个方便的接口后面。

/// ServiceCallBuilder.cs

public class ServiceCallBuilder<TService, TServiceAsync> where TService : class
{
    readonly ChannelFactory<TServiceAsync> factory;

    public ServiceCallBuilder(ChannelFactory<TServiceAsync> factory)
    {
        this.factory = factory;
    }

    public ServiceCommand<TService, TServiceAsync> Command(Expression<Action<TService>> call)
    {
        return new ServiceCommand<TService, TServiceAsync>(factory, call);
    }

    public ServiceQuery<TResponse, TService, TServiceAsync> Query<TResponse>(Expression<Func<TService, TResponse>> call)
    {
        return new ServiceQuery<TResponse, TService, TServiceAsync>(factory, call);
    }
}

让我们看看使用构建器时客户端代码发生了怎样的变化。

/// TaskManagementViewModel.cs

public class TaskManagementViewModel : ViewModelBase
{
    ServiceCallBuilder<ITaskService, ITaskServiceAsync> build;
    ...

    public TaskManagementViewModel()
    {
        InitializeServices();
        ...
    }

    void InitializeServices()
    {
        var factory = new ChannelFactory<ITaskService>(
            new BasicHttpBinding(),
            new EndpointAddress("https:///SampleSilverlightWCF/Services/TaskService.svc"));

        build = new ServiceCallBuilder<ITaskService, ITaskServiceAsync>(factory);
    }

    ...

    public void Activate()
    {
        var action = build.Query(service => service.GetAll());
            
        action.Execute(() =>
        {
            Task[] all = action.Result;

            foreach (Task each in all)
            {
                Tasks.Add(each);
            }

            Selected = Tasks[0];
        });
    }

    public void Delete()
    {
        var action = build.Command(service => service.Delete(Selected.Id));
            
        action.Execute(() =>
        {
            Tasks.Remove(Selected);

            if (Tasks.Count > 0)
                Selected = Tasks[0];
        });
    }

在这里,我们只在初始化时创建一次构建器,然后我们用它方便地构建命令/查询。如果您与之前的代码进行比较,清晰度的差异是巨大的。

发出异步接口

我们将客户端代码与异步接口的显式交互隔离开来之后,就开启了进一步简化解决方案的可能性。回顾我的上一篇文章,这个异步双接口只被 Silverlight 应用程序使用。我们也不需要在服务器端使用它。我们还知道构建这个异步接口的规则。那么,为什么还要费心手动创建和维护它呢?我们可以简单地从它的同步对应物的定义中生成它。听起来像是 Reflection.Emit 的完美场景。

我们将只要求客户端代码提供同步接口的类型,以及一些配置值(如终结点地址);通道工厂的创建将在内部处理。

/// TaskManagementViewModel.cs

public class TaskManagementViewModel : ViewModelBase
{
    const string address = "https:///SampleSilverlightWCF/Services/TaskService.svc";
    ServiceCallBuilder<ITaskService> build;

    ...

    public TaskManagementViewModel()
    {
        InitializeServices();
        ...
    }

    void InitializeServices()
    {
        build = new ServiceCallBuilder<ITaskService>(address);
    }

    ...


/// ServiceCallBuilder.cs

public class ServiceCallBuilder<TService> where TService : class
{
    readonly ServiceChannelFactory<TService> factory;

    public ServiceCallBuilder(string address)
    {
        factory = new ServiceChannelFactory<TService>(new EndpointAddress(address));
    }

    ...

ServiceChannelFactory 类负责实际的通道工厂构建和服务通道创建。

/// ServiceChannelFactory.cs

public class ServiceChannelFactory<TService>
{
    dynamic channelFactory;

    public ServiceChannelFactory(EndpointAddress address)
    {
        BuildChannelFactory(address);
    }

    void BuildChannelFactory(EndpointAddress address)
    {
        channelFactory = Activator.CreateInstance(GetChannelFactoryType(), new BasicHttpBinding(), address);
    }

    static Type GetChannelFactoryType()
    {
        Type type = AsyncServiceInterfaceFactory.Instance.Generate(typeof(TService));
        return typeof(ChannelFactory<>).MakeGenericType(type);
    }

    public object CreateChannel()
    {
        return channelFactory.CreateChannel();
    }
}

ServiceChannelFactory 又使用 AsyncServiceInterfaceFactory 来生成异步接口的定义。AsyncServiceInterfaceFactory 是一个单例,它生成并缓存已创建的类型定义;已生成的类型定义将在后续调用中重复使用。我不会展示 AsyncServiceInterfaceFactory 类的代码和接口定义的实际发出;这是非常标准的 Reflection.Emit 内容(您可以在附件的示例项目中查看详细信息)。最终结果——我们不再需要手动创建和维护异步双接口的定义。我们已经到达了名为“Silverlight 中无摩擦 WCF 服务消费”的天堂。但我们到了吗?

最大的幻觉

虽然我对结果非常满意,但仍然有些东西让我夜不能寐。反向的代码结构仍然看起来很奇怪。但我毫无悔意地忽略了这种奇怪;我已经消除了所有的摩擦,代码在我看来真的很好。

直到出现了需要进行两次顺序服务调用的情况。第二次调用依赖于第一次调用执行的结果,因此需要嵌套调用。它看起来像下面的代码:

public void Activate()
{
    var authentication = build.Query(service => service.Authenticate(login, password));

    authentication.Execute(() =>
    {
        if (!authentication.Result.Success)
            return;

        User user = authentication.Result.User;
        var query = build.Query(service => service.GetIssues(user));

        query.Execute(() =>

        {
            foreach (Issue each in query.Result)
            {
                Issues.Add(each);
            }
        });
    });
}

实际上还不错。仍然可读,只是增加了一些缩进。

但是当试图并行执行几个服务调用,等待所有调用完成,然后才继续执行(这是一个典型的分支/合并场景)时,真正的麻烦开始了。我经历了彻底的挫折。我被迫使用一些全局字段来保持当前状态,代码流非常模糊。混乱又回来了。

这仅仅是冰山一角。想象一下我们为轮询场景需要编写什么代码。由于控制流是反向的,我们不能使用通常的循环结构,如“while”。我们需要编写一些辅助类来控制循环,跟踪当前状态,并提供额外的同步。我们需要编写自己的控制机制。虽然我们可以在某种程度上通过利用 TPL 来依赖控制机制——但这并不能消除丑陋。在这种情况下,TPL 只是将一个混乱换成了另一个混乱。

所以,看起来尽管我做了这么多,但实际上这只是一个解决方案的幻觉。根本原因仍未解决。但我们如何解决它呢?我曾认为 APM 回调委托所施加的控制反转带来的反向代码结构是不可避免的。毫无疑问,我一直认为这是异步编程固有的结果。直到我遇到了协程……

协程——缺失的拼图

我第一次看到Caliburn文档中使用协程时,我正在浏览它。直到Rob第二次将协程描述为可以简化异步编程的方法时,我才理解它。他在我之前提到过的精美的“构建您自己的 MVVM 框架”演示文稿中展示了这一点。

这个想法和所有天才的想法一样简单。但让我们首先看看维基百科上关于协程的一些描述:

在计算机科学中,协程是一种程序组件,它概括了子例程,允许在某些位置暂停和恢复执行的多个入口点。

呃,这对我来说太聪明了。让我们看看另一个:

子例程只能返回一次;相比之下,协程可以返回(yield)多次。协程的开始是第一个入口点,随后的入口点是跟随 yield 命令的。实际上,yield 将结果返回给调用协程,并将其控制权交还,就像一个普通的子例程。然而,下次调用协程时,执行不会从协程的开头开始,而是在 yield 调用之后。

好的,这听起来很熟悉。我相信每个 C# 开发人员都知道 yield (http://msdn.microsoft.com/en-us/library/dscyy5s0.aspx) 关键字的用途以及迭代器块通常是如何工作的。在这里,迭代器块最重要的特性是能够暂时将控制权转移给调用者(这正是协程所做的)。

Iterator block

Rob 精妙地利用了这一能力。他在迭代器块的基础上构建了他自己巧妙的通用控制机制。通过反转对异步操作完成的控制,他将代码结构恢复到其顺序形式。编译器提供了其余的魔法(例如自动保持当前状态和暂停/恢复执行)。让我们看看它是如何完成的。

控制反转很简单。异步操作不应接受(in)回调委托,而应通过引发事件(out)来发出完成信号。这就是基于事件的异步模式(EAP)。事实上,它是在 .NET Framework 2.0 中引入的。

Inversion of Control

当然,这一单一的改变并不能神奇地将客户端代码从对异步操作完成的控制中解放出来。而这正是 Rob 编写的通用控制机制发挥作用的地方。

public class Yield
{
    readonly IEnumerator<IAction> iterator;

    Yield(IEnumerable<IAction> routine)
    {
        iterator = routine.GetEnumerator();
    }

    void Iterate()
    {
        ActionCompleted(null, EventArgs.Empty);
    }

    void ActionCompleted(object sender, EventArgs args)
    {
        var previous = sender as IAction;

        if (previous != null)
            previous.Completed -= ActionCompleted;

        if (!iterator.MoveNext())
            return;

        IAction next = iterator.Current;
        next.Completed += ActionCompleted;
        next.Execute();
    }

    public static void Call(IEnumerable<IAction> routine)
    {
        new Yield(routine).Iterate();
    }
}

它接受例程(IAction 元素的序列),并控制其迭代。在迭代时,它执行每个 IAction,并且只有当操作实际完成时才前进到下一个——这可能是在不同的线程上。基本上,操作的完成实际上是移动迭代器的原因。

Coroutine iterator

IAction 接口代表需要执行的操作,并且非常简单。

/// IAction.cs

public interface IAction
{
    void Execute();
    event EventHandler Completed;
}

现在为了能够利用这个机制,我们需要对使用异步操作的方法使用迭代器块。让我们看看这会如何改变客户端代码。

/// TaskManagementViewModel.cs

public IEnumerable<IAction> Activate()
{
    var action = build.Query(service => service.GetAll());
    yield return action;

    foreach (Task each in action.Result)
    {
        Tasks.Add(each);
    }

    Selected = Tasks[0];
}

...

public IEnumerable<IAction> Delete()
{
    var action = build.Command(service => service.Delete(Selected.Id));
    yield return action;
            
    Tasks.Remove(Selected);

    if (Tasks.Count > 0)
        Selected = Tasks[0];
}

如您所见,代码结构展现了同步代码的顺序性质。但不要被愚弄,操作仍然是异步的。这只是一种同步的幻觉,也许是最大的幻觉

为了正确执行这个迭代器块,我们需要使用上述控制机制运行它。所有命令方法都由 ViewModelCommand 类执行,所以我们需要稍微修改它。

/// ViewModelCommand.cs

public class ViewModelCommand : ICommand
{
    ...

    public void Execute(object parameter)
    {
        object returnValue = execute.Invoke(viewModel, null);

        if (returnValue != null)
            HandleReturnValue(returnValue);
    }

    private static void HandleReturnValue(object returnValue)
    {
        var routine = returnValue as IEnumerable<IAction>;

        if (routine != null)
            Yield.Call(routine);
    }
}

最后的修改是对 Activate() 方法的执行……

/// TaskManagementView.cs

private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
    var viewModel = new TaskManagementViewModel();
    DataContext = viewModel;

    Yield.Call(viewModel.Activate());
}

……现在我们终于到达了“在 Silverlight 中流畅地消费 WCF 服务”的天堂。让我们看看这种方法将如何让我们处理不同的异步编程场景。

脚注:如果您不明白所有这些为什么以及如何工作,我建议阅读 Tomas Petricek 的这篇文章,其中对 C# 中的异步编程中的控制反转和协程进行了清晰详细的技术解释。此外,Rob 和 Jeremy 最近也发表了关于协程的文章(此处此处)。

相关调用

以前

public void Activate()
{
    var authentication = build.Query(service => service.Authenticate(login, password));

    authentication.Execute(() =>
    {
        if (!authentication.Result.Success)
            return;

        var user = action.Result.User;
        var query = build.Query(service => service.GetIssues(user));

        query.Execute(() =>
        {
            foreach (Issue each in query.Result)
            {
                Issues.Add(each);
            }
        });
    });
}

操作后

public IEnumerable<IAction> Activate()
{
    var action = build.Query(service => service.Authenticate(login, password));
    yield return action;

    if (!action.Result.Success)
        yield break;

    var user = action.Result.User;

    action = build.Query(service => service.GetIssues(user));
    yield return action;

    foreach (Issue each in action.Result)
    {
        Issues.Add(each);
    }
}

看起来确实如此——一次调用接一次,按顺序进行。

分叉/连接

假设我们需要调用某个天气服务,该服务只能返回单个城市的天气状况。但我们需要同时获取两个城市的天气,然后,出于某种原因,我们需要对累积结果进行一些处理。

IAction 接口表示单个操作。我们可以改变我们的控制机制以接受 IAction 数组,但这会大大增加其复杂性,并且还会降低客户端代码的清晰度。相反,我们有一个更好的解决方案——GoF 的组合模式。

public static class Parallel
{
    public static IAction Actions(params IAction[] actions)
    {
        return new ParallelActions(actions);
    }

    private class ParallelActions : IAction
    {
        public event EventHandler Completed;

        readonly IAction[] actions;
        int remaining;

        public ParallelActions(IAction[] actions)
        {
            this.actions = actions;
        }

        public void Execute()
        {
            remaining = actions.Length;

            foreach (IAction action in actions)
            {
                action.Completed += delegate
                {
                    if (Interlocked.Decrement(ref remaining) == 0)
                        SignalCompleted();
                };

                action.Execute();
            }
        }

        void SignalCompleted()
        {
            EventHandler handler = Completed;

            if (handler != null)
                Call.OnUIThread(() => handler(this, EventArgs.Empty));
        }
    }
}

复合操作接受一批动作,然后执行每个动作,并且只有当所有动作都完成后才发出完成信号。

/// Client code

public IEnumerable<IAction> GetWeather()
{
    var kyiv   = build.Query(service => service.GetWeather("Kyiv"));
    var sydney = build.Query(service => service.GetWeather("Sidney"));

    yield return Parallel.Actions(kyiv, sydney);

    if (kyiv.Result.Temperature > sydney.Result.Temperature)
        MessageBox.Show("It's summer in Kyiv");
}

这将并行执行两个异步调用,并且只有当它们都完成后,执行才会继续。

异步轮询

比方说,我们需要定期轮询天气服务以显示最新的天气状况(我们可以有一个每小时更新一次的酷天气小部件)。我们可以非常容易地做到这一点:

public IEnumerable<IAction> PollWeather()
{
    while (true)
    {
        var query = build.Query(service => service.GetWeather("Kyiv"));
        yield return query;

        WeatherConditions weather = query.Result;
        widget.Update(weather);

        Thread.Sleep(TimeSpan.FromHours(1));
    }
}

问题是我们不能在这里实际使用 Thread.Sleep,因为它会阻塞主线程。我们也无法直接使用 Timer 类,因为一旦计时器启动,控制权就丢失了,循环也会重新开始。相反,我们需要重复这个技巧,并实现另一个巧妙的动作类。

/// Sleep.cs

public static class Sleep
{
    public static IAction Timeout(TimeSpan timeout)
    {
        return new SleepAction(timeout);
    }

    private class SleepAction : IAction
    {
        readonly TimeSpan timeout;
        readonly Timer timer;

        public event EventHandler Completed;

        public SleepAction(TimeSpan timeout)
        {
            this.timeout = timeout;
            timer = new Timer(OnTimeout);
        }

        public void Execute()
        {
            timer.Change(timeout, TimeSpan.FromMilliseconds(-1));
        }

        void OnTimeout(object state)
        {
            timer.Dispose();
            SignalCompleted();
        }

        void SignalCompleted()
        {
            EventHandler handler = Completed;

            if (handler != null)
                Call.OnUIThread(() => handler(this, EventArgs.Empty));
        }
    }
}

/// Client code

public IEnumerable<IAction> PollWeather()
{
    while (true)
    {
        ...

        yield return Sleep.Timeout(TimeSpan.FromHours(1));
    }
}

另一种情况是,当您需要异步轮询某个服务并在满足某些条件时结束轮询时。您可以使用 yield break 关键字轻松实现这一点。

public IEnumerable<IAction> TrackOrderStatus(Guid orderId)
{
    while (true)
    {
        var status = build.Query(service => service.GetOrderStatus(orderId));
        yield return status;

        if (status.Result.Confirmed)
        {
            MessageBox.Show("You order has just been confirmed");
            yield break;
        }

        yield return Sleep.Timeout(TimeSpan.FromSeconds(5));
    }
}

终章

增加便利性

如果您有很多视图(对于企业应用程序来说非常常见)通过 WCF 服务与服务器端通信(通常,整个视图中只使用一种类型的服务),您可以通过将通用操作构造代码移动到泛型基类中来消除代码重复并进一步提高代码清晰度。然后客户端代码可能看起来像下面这样:

public IEnumerable<IAction> GetWeather()
{
    var kyiv   = Query(service => service.GetWeather("Kyiv"));
    var sydney = Query(service => service.GetWeather("Sidney"));

    yield return Parallel(kyiv, sydney);

    if (kyiv.Result.Temperature < sydney.Result.Temperature)
        MessageBox.Show("It's summer in Sydney");
}

简洁明了。

结论

这是一篇冗长的文章,我希望它值得您花费时间。我们消除了 Silverlight 中 WCF 服务异步消费要求引入的所有摩擦。使用纯同步接口和引入协程开启了简化对异步代码进行单元测试的可能,否则这些代码很难测试。在下一篇文章中,我将向您展示如何测试驱动使用异步服务调用的 ViewModels 的逻辑,以及上述解决方案如何辅助单元测试。如果您仍然不理解所有这些是如何连接起来的,只需下载示例应用程序并试用它。这可能是牢固理解这种方法的最佳方式。

源代码的最新版本可以在这里找到。有关该主题的其他材料我将在我的博客上发布。

注意事项

迭代器块的主体有一些细微的限制,此外,别忘了通过 Yield.Call 运行你的协程方法,祝你玩得开心!

历史

  • 2011 年 5 月 17 日
    • 将示例应用程序迁移到使用本地 Web 服务器而不是 IIS
    • 修复了代码格式
    • 更改了代码示例中的 API,以更好地反映最新的 Servelat.Pieces 项目 API
© . All rights reserved.