技术头条 - 一个快速在微博传播文章的方式     搜索本站
您现在的位置首页 --> 系统架构 --> 异步编程与响应式框架

异步编程与响应式框架

浏览:3775次  出处信息

前言

    异步操作是强大的,它是许多高伸缩性架构的基石。异步操作在许多情况下是必须的,例如在客户端保持用户界面的响应能力,以及在日益兴起的云计算场景中。但是,异步编程又是十分困难的,它让这让许多程序员敬而远之。因此,越来越多的编程语言都对异步编程提供了相当程度的支持,其中的典型代表便是F#中的异步工作流以及Scala的Actor模型。不过目前的一些主流编程语言,如C#或是JavaScript,它们在设计之时并没有在异步编程上考虑太多,我们便会根据它们的语言特性,提供合适的异步编程模型及其实现。而本文介绍的便是其中一例:响应式编程(Reactive Programming)模型及响应式框架(Reactive Framework,简称Rx)。

异步编程的难点

    异步编程之所以困难,主要有三大难点。

    首先是对于状态的维护。在普通编程中,我们已经习惯了根据各种状态采取不同做法的编程方式。在异步编程中,状态对于操作的影响则往往更为复杂。例如,我们在编写一个鼠标“拖动及绘图”的行为时,一般会采用这样的逻辑:

  • 在MouseDown事件中将isDragging标记设为true,表示“拖动开始”,并记录当前鼠标位置prevPos。
  • 在MouseUp事件中将isDragging标记设为false,表示“拖动结束”。
  • 在MouseMove事件中检查isDragging标记,如果为true,根据鼠标当前位置currPos和之前记录的prevPos进行绘图,并将currPos的值写入prevPos。
  •     仅在这样一个最基本的场景中,我们便需要编写三个事件处理器(Event Handler),控制isDragging,prevPos等外部状态,并根据这些状态决定事件触发时的效果。这样的例子数不胜数,尤其是在各式拖放操作中,几乎都会涉及大量状态的控制(例如,判断物体是否进入某个特定区域)。

        异步编程的另一个难点,在于异步操作之间的组合及交互。例如在如上的简单拖放操作中,我们便涉及到了MouseDown,MouseUp及MouseMove三个事件。从某些角度来说,客户端的UI事件还是比较容易处理的,因为它们往往都是在单一线程上依次执行。但是在另外一些场景中,如云计算时,我们往往会同时发起多个异步操作,并根据这些操作的结果进行后续处理,甚至还会有一个额外的超时监控,这样便很有可能会出现并发操作的竞争(Race)情况,这将会成为程序复杂度的灾难。

        此外,异步操作还会破坏“代码局部性?ode Locality)”,这可能也是异步操作中最为常见的阻碍。程序员早已习惯了“线性”地表达逻辑,但即便是多个顺序执行的异步操作,也会因为大量的回调函数而将算法拆得支离破碎,更何况还会出现各种循环及条件判断。同时,在线性的代码中,我们可以使用“局部变量”保存状态,而在编写异步代码时则需要手动地在多个函数中传递状态。此外,由于逻辑被拆分至多个方法,因此我们也无法使用传统的try/catch进行统一异常处理。

    推模型与拉模型

        平时我们使用最多的便是“交互式(Interactive)”的编程方式,采用的是组件之间的相互调用来表现逻辑。例如,对象A向对象B请求数据并等待返回,待对象B完成并返还数据之后A才继续进行后面的操作。交互式编程的一个典型应用便是GoF23中的迭代器(Iterator)模式,它在.NET中的实现为IEnumerable及IEnumerator接口,例如:

    void Traverse(IEnumerable source)
    {
        var etor = source.GetEnumerator();
        while (etor.MoveNext())
        {
            Console.WriteLine(etor.Current);
        }
    }

        为了更好地说明问题,这里我们将标准的foreach操作展开为传统的迭代器使用形式,并省略了using语句。在使用时,我们先调用一个IEnumerable对象的GetEnumerator方法,获得一个迭代器,再根据MoveNext及Current进行遍历。在调用MoveNext时,迭代器会去“准备”下一个元素,并根据存在与否返回true或者false。试想,如果其中某个MoveNext的“准备”工作涉及到一个耗时较长的操作,则迭代器的使用者也必须眼巴巴地等待其返回。

        这是一种“拉(Pull)”模型,数据由消费者?onsumer)从生产者(Producer)那里主动“拉”来。这是一种同步的交互方式,数据消费者会依赖于数据生产者的表现。这就好比我们去食堂吃饭时必须主动去取餐,此时则必须从队伍的最后排起,我们什么时候能结束等待并进行下一步操作(即“吃饭”),则要看食堂的生产速度如何。很显然,有些时候这种交互方式是不可接受的,例如我们在实现一个搜索引擎的“关键字提示”功能时,不可能让用户在输入一个字符后,必须等待远程的提示请求返回才能继续输入下一个字符。

        而与交互式编程对应的便是“响应式(Reactive)”编程。响应式编程是一种基于“改变”的编程方式。例如在交互式编程中,A = B + C这样的表达式意味着将B与C之和赋给A,而此后B与C的改变都与A无关。而在响应式编程中,A会去“响应”B或C的变化,即一旦B或C改变之后,A的值也会随之变化。响应式编程的一个典型应用便是GoF23中的观察者(Observer)模式。与迭代器的IEnumerable/IEnumerator不同,在之前的.NET框架中并没有对这样一种编程模型指定“标准化(Formallized)”接口,不过在.NET 4.0的基础类库中增加了IObservable及IObserver接口,签名如下:

    public interface IObservable
    {
        IDisposable Subscribe(IObserver observer);
    }
    
    public interface IObserver
    {
        void OnCompleted();
        void OnError(Exception error);
        void OnNext(T value);
    }

        如果我们仔细比较“迭代器”与“观察者”的标准化接口,则会发现它们是完全“对偶(dual)”的:

  • IEnumerable.GetEnumerator方法“输出”一个IEnumerater对象;IObservable.Subscribe方法“输入”一个IObserver对象。
  • 在遍历元素用尽时,IEnumerator.MoveNext方法返回false;在响应内容用尽时,IObserver.OnCompleted方法被调用。
  • 在有新元素需要遍历时,IEnumerator.MoveNext方法返回true,并通过Current属性“输出”;在有新元素需要响应时,IObserver.OnNext方法被调用,并通过参数“输入”。
  • 在出现错误时,IEnumerator.MoveNext方法会“抛出”一个异常;在出现错误时,IObserver.OnError方法会被调用,并通过参数“接受”异常信息。
  •     至于IObservable.Subscribe方法返回的IDisposable对象,则用于“退定”操作,即让输入的IObserver对象再也不需要继续响应IObservable对象的新元素了。

        从比较中可以看出,如果说IEnumerator对象是由数据消费者使用的话,那么IObserver对象则是由数据的生产者,即IObservable对象使用的。换句话说,数据是由数据的生产者“推”给数据消费者的,是一种“推(Push)”模型。在这种异步的交互方式中,数据消费者不必依赖于数据生产者的表现。这就好比我们去饭店吃饭,点菜后便可坐下和同伴聊聊天或是用手机上上网,而作为菜品的生产者,饭店,则会在产出之后主动端上桌来。这么做无疑解放了数据的消费者,例如用户可以在文本框里不断地输入字符,而只需等远程服务器将提示结果“推”给客户端后再显示即可。

        许多模型都可以统一至标准的生产者接口IObservable上,如MouseMove事件便可以认做是“永不停止的MouseEventArgs对象的生产者”。而另一方面,单个异步操作则可以被视为“只产出单个数据便结束的生产者”。

    LINQ to Observable

        在许多人眼中,C# 3.0中新增的LINQ特性只是一种用于操作数据的DSL,它的主要作用也仅仅是针对IEnumerable或是IQueryable的数据操作。事实上,LINQ本身的能力远不止此。LINQ是一种非常简单的语言特性,编译器只是将LINQ查询语句转化为“字面等价”的“LINQ标准方法”调用(如Where,Select等等)和“和Lambda表达式”参数(如x => x > 0)而已。但是这里的关键便是“字面等价”四个字,LINQ本身并不规定“LINQ标准方法”是对象的实例方法还是扩展方法,“Lambda表达式”是构造出一个匿名函数还是表达式树,这一切都是由利用LINQ的类库来决定的。因此,微软能够基于LINQ实现了PLINQ这样的并行类库。直到最近的访谈中,LINQ的设计者Erik Meijer依旧认为LINQ还是被低估了,他们自己也还在继续挖掘LINQ的更多能力。

        虽然LINQ本身在语法上只是一些方法调用,但是它在语义上是针对数据流的一系列操作,因此LINQ to Object,LINQ to SQL以及PLINQ可以认为是最自然,最符合LINQ语义的应用。如今,微软的“云编程能力团队?loud Programmability Team)”在其“响应式框架(Reactive Framework,简称Rx)”提供了LINQ to Observable,这又是另一个LINQ的经典使用案例。IObservable本身可以被认为是一股“推送数据”的数据流,因此也可以对其进行“过滤”或是“投影”等操作,这便是LINQ的应用场景,LINQ to Observable是对IObservable接口实现的一系列LINQ标准方法。因此,我们可以认为,LINQ to Observable是一套与LINQ to Object对偶的类库,事实上在响应式框架中,还有一套与LINQ to Queryable对偶的LINQ to Qbservable,请注意第一个字母是Q,我们可以把它看作是LINQ to Observable的“远程查询”版本。

        利用LINQ to Object可以编写出声明式(表示“做什么”)的代码,其可读性往往远高于等价的命令式(表示“怎么做”)代码。LINQ to Observable也有类似的效果。假设现在有一个需求:利用ADWS键控制小球的位置。传统的写法可能是这样的:

    void OnKeyPress(object sender, KeyPressEventArgs e)
    {
        // 如果游戏已经开始
        if (isPlaying) 
        {
            // 向左且小球没有超出边界
            if (e.KeyChar == \'a\' && ball.Left > 0)
            {
                ball.Left -= 5;
            }
            // 向上且小球没有超出边界
            else if (e.KeyChar == \'w\' && ball.Top > 0)
            {
                ball.Top -= 5;
            }
            else ... 
        }
        else ...
    }

        由于KeyPress事件总是不断触发,因此我们只能它的事件处理器中进行判断各种状态,采取不同措施。而如果我们利用LINQ to Observable,则几乎是另外一种思维方式:

    // 过滤出isPlaying时的keyPress事件
    var keyPress = GetKeyPress().Where(_ => isPlaying);
    
    // 过滤出向左移动的事件
    var moveLeft = from ev in keyPress
                   where ev.EventArgs.KeyChar == \'a\'
                   where ball.Left > 0
                   select ev;
    moveLeft.Subscribe(_ => ball.Left -= 5);
    
    // 过滤出向上移动的事件
    var moveTop = from ev in keyPress
                  where ev.EventArgs.KeyChar == \'w\'
                  where ball.Top > 0
                  select ev;
    moveTop.Subscribe(_ => ball.Top -= 5);

        我们可以将“KeyPress事件”视为“推送KeyPressEventArgs对象”这一数据流的数据源(由GetKeyPress方法返回),那么如今的代码便是使用LINQ过滤出“需要”的数据,并针对真正需要的那部分进行响应。这么做,便将“条件”与“操作”解耦,显著增强了代码的语义表达能力。事实上,只要补充一些辅助方法,可以利用LINQ表示更为完整复杂的逻辑。例如,微软咨询师Matthew Podwysocki便在博客中展示过一段代码,基于LINQ to Observable实现了创建一个WebRequest对象,设置属性,异步发送及下载数据的一系列操作。

    更多扩展

        .NET基础类库针对IEnumerable定义了大量的函数式的辅助方法,开发人员可以直接将它们组合运用在项目中。除了标准的LINQ操作方法之外,响应式框架中同样定义了大量辅助方法,可以配合LINQ to Observable组合使用。例如本文开头所设想的鼠标“拖动及绘图”功能,便可以使用如下代码完成:

    var mouseMove = GetMouseMove();
    var mouseDiff = mouseMove.Zip(mouseMove.Skip(1), (prev, curr) =>
        new
        {
            PrevPos = new Point(prev.EventArgs.X, prev.EventArgs.Y),
            CurrPos = new Point(curr.EventArgs.X, curr.EventArgs.Y)
        });
    
    var mouseDrag = from _ in GetMouseDown()
                    from diff in mouseDiff.TakeUntil(GetMouseUp())
                    select diff;
    mouseDrag.Subscribe(diff => DrawLine(diff.PrevPos, diff.CurrPos));

        在这段代码中,我们首先将mouseMove事件使用Skip跳开一项,再与自身通过Zip方法组合成mouseDiff,这是一个输出相邻两次MouseMove事件坐标的数据源;接着,我们利用LINQ从触发MouseDown事件开始,向mouseDiff数据源获取每一项diff,直至(TakeUntil)触发MouseUp事件,以此生成最终的mouseDrag;最后再将绘图功能订阅至这个数据源上。您会发现此时我们已经无须手动维护操作过程中的各种状态了,从事件的“开始”到“结束”均使用响应式框架的辅助方法“声明”而来。

        以上便是一个利用了Skip,Zip,TakeUntil等辅助方法的例子。当然,这些辅助方法在IEnumerable上都有语义相同的对应操作,而在响应式框架中还有更多辅助方法是针对特性异步场景的。假设我们现在要编写一个即时翻译功能,同时发起三个请求,将中文分别翻译至英语、法语及西班牙语,并显示最先返回的两个结果(真是个奇怪的需求)。此外,我们不会在用户输入每个字符的时候便发起一个远程请求,而是在用户停止输入0.5秒之后才根据当前的输入框中的文字进行提示。于是我们可以编写这样的代码:

    var limit = TimeSpan.FromSeconds(0.5);
    var translate =
        from _ in GetKeyPress().Throttle(limit)
        let text = this.txtInput.Text
        where text.Length > 0
        let english = Bing.Translate(text, "en")
        let french = Bing.Translate(text, "fr")
        let spanish = Bing.Translate(text, "es")
        from result in Observable.Join(
           english.And(french).Then((en, fr) =>
               new { English = en, French = fr, Spanish = "" }),
           english.And(spanish).Then((en, es) =>
               new { English = en, French = "", Spanish = es }),
           french.And(spanish).Then((fr, es) =>
               new { English = "", French = fr, Spanish = es }))
        select result;
    
    translate.Subscribe(...);

        这里用到了Throottle方法,它会过滤某个数据源的输出,确保在该数据源“静默”特定时间之后,才将最近的一条数据推送至外部。此外,这里还使用了Observable.Join方法控制多个数据源,根据返回结果的先后获得合适的结果。响应式框架提供了大量针对某种异步场景的辅助方法,例如用于定期推送数据的Interval方法,从一个数据源根据特定条件进行采样的Sample方法,合并多个数据源的ForkJoin方法,以及表示流程控制的For,While,If等等。这些方法内部会维护各种所需要的状态,为我们打理各种复杂的竞争情况,以此节省了开发人员的精力。

        如果这些还不能满足我们的要求,我们也可以根据自己的需要开发特定的辅助方法,就像我们在使用LINQ to Object时为IEnumerable所作的各种扩展那样。响应式框架也提供了一系列Subject类型,简化了IObservable自定义扩展的开发过程。由于响应式框架尚未正式发布,微软目前建立了一个Wiki,用于展示关于各辅助方法及Subject类的使用示例及其他相关信息。

    响应式框架的JavaScript版本

        响应式编程的重要使用场景之一便是与用户交互的GUI界面。例如,Silverlight禁止任何阻塞的IO操作,换言之Silverlight中的所有网络操作都是异步的,微软也正是出于简化异步开发的目的才设计了响应式框架(事实上响应式框架已经集成到Silverlight Toolkit中)。不过与Silverlight相比,基于浏览器的原生JavaScript应用程序无疑使用地更为广泛。对于这样的应用程序来说,动画是异步的,AJAX请求也是异步的,我们几乎可以断言,如果有一套面向JavaScirpt应用程序的响应式框架,一定会比面向Silverlight的框架更有意义得多。

        微软也想到了这一点。之前我们讨论的“响应式框架”,其实只是响应式编程模型的一种实现。更确切地说,我们只是讨论了这套框架的.NET版本,微软还提供了JavaScript版本的响应式框架。JavaScript版本的API与.NET版本几乎完全一致,例如我们之前讨论的拖放操作,使用JavaScript即可写作:

    var target = $("#dragTarget");
    var mouseMove = target.toObservable("mousemove");
    var mouseDiff = mouseMove.Zip(mouseMove.Skip(1), 
        function(prev, curr) {
            return {
                PrevPos: { x: prev.clientX, y: prev.clientY },
                CurrPos: { x: curr.clientX, y: curr.clientY }
            };
        });
    
    var mouseDown = target.toObservable("mousedown");
    var mouseUp = target.toObservable("mouseup");
    var mouseDrag = mouseDown.SelectMany(function() {
        mouseDiff.TakeUntil(mouseUp);
    });
    
    mouseDrag.Subscribe(...);

        由于没有C#中的LINQ查询语言,我们只能直接使用展开后的方法,如SelectMany来编写逻辑。JavaScript版本的响应式框架还提供了一系列的“胶合”层,能够与jQuery,Dojo,MooTools,Prototype等流行框架同时使用。例如,上一段代码中的toObservable便是在jQuery根对象上扩展的方法。

    总结

        异步编程在用户交互式界面及一些云计算场景中尤其重要。微软的云编程能力团队针对.NET平台和JavaScirpt分别提供了一套响应式框架,希望以此简化异步程序的开发。不过,这套响应式框架所表现出的理念是通用的。而且,事实上只要是拥有匿名函数及闭包的语言,例如Scala,Python,Ruby等等,实现这样一套框架其实都不是十分困难的事情。

    建议继续学习:

    1. 响应式网页设计    (阅读:18351)
    2. 关于IO的同步,异步,阻塞,非阻塞    (阅读:14291)
    3. fsockopen 异步处理    (阅读:8901)
    4. 配合jquery实现异步加载页面元素    (阅读:5282)
    5. 如何寻找一个不会让你后悔的PHP开发框架    (阅读:5217)
    6. 使用django+celery+RabbitMQ实现异步执行    (阅读:4892)
    7. 自己写的一个轻量级javascript框架的设计模式    (阅读:3938)
    8. 对于PHP大型开发框架的看法    (阅读:3937)
    9. PHP API 框架开发的学习    (阅读:3765)
    10. 多核与异步并行    (阅读:3716)
    QQ技术交流群:445447336,欢迎加入!
    扫一扫订阅我的微信号:IT技术博客大学习
    © 2009 - 2024 by blogread.cn 微博:@IT技术博客大学习

    京ICP备15002552号-1