函数式反应型编程 (FRP) —— 实时互动应用开发的新思路

1,050 阅读19分钟
原文链接: www.infoq.com

一、Reactive?

请先看一个非常简单的小应用,它允许用户在一个搜索输入框里输入关键词,然后在其下方的结果区域实时显示从Flicker网站搜索得到的图片,当用户输入的关键词发生变化,显示的图片也会随即跟着发生变化。

查看图片

这实际上便是一种reactive能力。而类似这种能自动对外部环境的变化作出响应的系统我们称之为反应型系统(Reactive System)。典型的外部环境变化包括外部输入信号的变化、事件的发生,而且系统的响应通常是实时的。互动系统是最重要的一种反应型系统,它不但能响应外部环境的变化,还会将自己的内部状态通过某种方式反馈给外部观察者。

二、如何构建Reactive System?

如果要开发上面的这个Flicker实时搜索小应用,应该怎么做呢?

别忙,程序员在动手开发之前应该首先把软件要做什么尽可能搞清楚。虽然这是个很小的应用,但我们仍然可以把它做的事情用一个简单但更清晰的流程图表示出来:

查看图片

用户在输入框键入各种关键词向Flicker发出搜索请求,它们就像一道不断流向Flicker服务器的”请求流”,服务器处理完请求后向客户端返回搜索结果,形成了一道“应答流”,客户端将“应答流”转换成“图片流”注入到网页中的结果区域。只要用户的输入发生变化,这些流中的内容也跟着改变,网页中显示的图片也就跟着变化。

功能不复杂,试着把它变成程序。

Callback Style

通过处理相应的事件便能响应用户的输入,那么就可以采用大家所熟悉的注册event callback的方式来做:

//处理response的回调函数
var responseHandler = function (answer)
{
  //根据response创建image nodes
  var nodes = buildImages(answer);
  //用image nodes动态更新显示区域
  swapChildren($('images'), nodes);
}

//搜索框回调函数
var searchHandler = function (txt)
{
  //发送request,并注册该次request的回调
  requestFlickerSearch(txt, responseHandler);
}

//使指定的输入框具有实时flicker搜索能力
function enableFlickerSearch(nodeId)
{
  //注册搜索输入框回调
  registerCallback($(nodeId), searchHandler);
}

enableFlickerSearch('search');

X Style

上面这种方式是大家最熟悉的,也是最常用的。但今天我要给大家看看另外一种不同的方式,暂时称之为X Style吧。

核心代码如下:

//将指定的输入框包装成具有flicker search能力的输入框,
//并返回相关联的实时图片流
function makeFlickerImagesStream(searchEditId)
{
  //根据指定的搜索输入框内容创建request stream
  var requestE = extractValueE(searchEditId).
                  calmE(1000).
                  mapE(flickrSearchRequest);
  //根据request stream创建response stream
  var responseE = getForeignWebServiceObjectE(requestE); 
  //根据response stream创建image DOM stream
  var imagesE = responseE.mapE(createImageNodes);
  return imagesE;
}

function start() 
{
  //创建一个实时图片流,流中的内容根据"search"输入框内容的变化而变化
  var imageNodes = makeFlickerImagesStream("search");

  //用imageNodes图片流里的内容动态更新images节点
  insertDomE(imageNodes,"images");
}

注意到makeFlickerImageStream的实现里有requestE, responseE, imagesE这样的变量,从名字上很容易将它们和之前流程图中的“请求流”“应答流”“图片流”对应起来。

目前为止,这两种方式看起来代码量差不多,没感觉X风格有什么特别的。 好,假设这个Flicker实时搜索框被做成了独立的控件,对于使用者来说是一个黑盒子,内部实现不可见,如果想添加一个黄色图片过滤功能,不是一古脑儿显示搜索得到的所有图片,而是过滤掉黄色图片只在结果区域显示健康图片,这时该怎么做?

对于callback风格的实现来说,应用代码只有这一行,其它的都不可见。

enableFlickerSearch('search');

你能想到一种不修改搜索框控件内部实现的方案吗?至少我没有想到。

一个比单纯修改模块内部实现更好的方案也许是同时修改接口和控件实现以便让用户去定制如何处理response:

enableFlickerSearch('search', customResponseHandler);

那么用户可以在这个定制的处理函数中先过滤图片再显示出来。

对于X风格实现,start函数才是应用层的代码。如果要过滤掉黄色图片,很简单,只要将最后一行代码改变一下就行了:

function start() 
{
  //创建一个实时图片流,流中的内容根据"search"输入框内容的变化而变化
  var imageNodes = makeFlickerImagesStream("search");

  //用指定的健康图片判断函数healthy
  //对imageNodes进行过滤得到健康图片流
  var healthyImageNodes = 
        imageNodes.filterE(healthy);
  //用过滤得到的健康图片流里的内容动态更新images节点
  insertDomE(healthyImageNodes,"images");
}

看见了吗?完全不需要修改控件内部的实现就轻松实现了新功能。

三、另外一个例子-Drag and Drop

现在请看另一个鼠标拖拽的小例子

对于这样一种交互操作,如果抛开实现细节,用自然语言通常会怎么描述它呢?我想极有可能是这样:

当鼠标左键按下时便开始移动拖拽,直到鼠标左键抬起时停止。

这句话实际上定义出了拖拽交互的功能规约(function specification),有了规约便可以写出代码。

这次先来看看X风格的代码实现:

function start() {  
  //为节点创建和它绑定的拖拽消息流
  var posE = dragE($('dragTarget'));

  //根据拖拽消息去调整节点的位置
  posE.doE(function(m) {
    $('dragTarget').style.left = m.clientX;
    $('dragTarget').style.top = m.clientY;
  });
}

这段代码很简单,注释里的解释应该清楚了。但是,最关键的用来创建拖拽消息流的dragE是怎么实现的呢?它在这里:

function dragE(target) 
{
  //鼠标左键按下消息流
  var mousedown = extractEventE(target,'mousedown');
  //鼠标左键抬起消息流
  var mouseup = extractEventE(document, "mouseup");
  //鼠标移动消息流
  var mousemove = extractEventE(document, "mousemove");
  //返回一个拖拽消息流:
  //每当有鼠标左键按下消息时它就输出鼠标移动消息,
  //直到鼠标左键抬起才停止输出鼠标移动消息,
  //当下一次鼠标左键按下时又重复以上模式。
  return mousedown.thenE(function (_)
                         { 
                           return mousemove.untilE(mouseup);
                         });
}

这些代码暂时看不懂没关系,只要注意最后return出去的表达式中的thenE和untilE,这和之前自然语言描述中的“当...发生时便开始... ,直到....”一一对应,代码就象对自然语言的直接翻译!

如果换用callback的方式怎么做呢?这里就不写了,读者可以自己尝试一下,看看最终出来的代码的语义和自然语言的描述差别有多大。

四、Functional Reactive Programming

上面两例中X风格的代码主要描述系统中数据(消息)流的结构关系,至于环境变化如何导致某些数据流变化,这些变化了的数据流又如何导致其它的相关数据流变化,压根儿不需要关心。这是一种编程范式,称之为Reactive Programming

而主要利用函数式编程(Functional Programming)的思想和方法(函数、高阶函数)来支持Reactive Programming就是所谓的Functional Reactive Programming,简称FRP。

现在可以给X正名了,它就是FRP。

FRP vs. Callback

程序员在写代码之前,首先会需要一个功能规约,而规约的描述形式并不固定,可以是流程图(第一个例子),可以是自然语言(第二个例子),也可以是其它的方式,只要能精准方便地表达意图就好。

当有了规约之后,FRP编程就像照镜子,代码基本上就是规约的直接映射。

而写callback风格的代码,就得费力地咀嚼规约,艰难地消化,最后挤出的代码就像一坨缠来绕去的线团,它呈现出来的样子和程序真正的意图相去甚远。

查看图片

为什么?

为什么callback风格的代码会表现成这样,背后的原因是什么?请读者带着这个问题边思考边往下看,在文章的后面作者会给出自己的结论。

五、Flapjax

本文的所有示例都利用了一个叫Flapjax的库。简单来说,它就是一个支持FRP的JavaScript框架。接下来将以它为参考详细介绍FRP的各种概念。

1、基本思想

引起实时互动系统发生变化的最根本的自变量是时间,变化的环境和各种事件(比如键盘、鼠标、网络)归根结底都是由时间的变化引起的,时间是本质特征,从一开始就要考虑。

那些对外部环境具有反应能力、随时间变化的数据被抽象为一种叫信号(Signal)的概念。

2、信号

信号分为两种,分别叫事件流(Event Stream)和行为(Behavior)。

Event Stream

查看图片

事件流可以看成时间轴上无限长的数据流,这个里面流淌的数据代表着一个个事件,它们在时间轴上是离散的。

Behavior

查看图片

行为也是随时间变化的数据,它在时间轴上是连续的。

信号的类型

信号(EventStream和Behavior)都是First-Class Value,所以它们也有自己的类型。 信号所代表的随时间变化的数据的类型X决定了信号的类型。记为:EventStream X 或 Behavior X。

比如:

  • 代表输入框内容变化的事件流,类型为EventStream String
  • 随时间不停变化的数值,类型为Behavior Number

函数角度看信号

如果从函数式编程的角度来看,behavior可以看成这样一个函数:输入某个时刻,它计算得到在那个时刻自身的采样值。

查看图片

同样地,event stream也可以看成这样一个函数:输入也是某个时刻,如果在这个时刻有事件发生就把相应的event数据返回出来,否则就返回一个特殊的nothing表示没有任何事件发生。

查看图片

3、Event Stream

基本API

//为指定DOM节点创建鼠标左键按下的事件流
es1 = extractEventE(targetDom, "mousedown");

//为指定DOM节点创建内容动态更新事件流,
//每当节点内容发生变化,这个事件流里都会产生相应的事件
es2 = extractValueE(searchEdit);

//创建会且只会发生一个事件的事件流,
//这个唯一的事件所携带的信息是调用者传入的参数
es3 = oneE("hello world");

//永远也不会有事件发生的事件流
nothingEs = zeroE();

//将事件流中的动态内容注入到指定的dom节点上,
//此后只要事件流里有新的事件发生,
//这个节点的内容就会跟着新事件的内容自动发生变化。
insertDomE(es2, "result");

转换和组合

除了基本API,框架还提供了相应的API对事件流进行转换或者将多个事件流组合成新的更复杂的事件流。常用的有:

  • 过滤
    filterE(sourceEs, pred)会用谓词函数pred对事件流sourceEs进行过滤生成一个新的事件流,所有出现在sourceEs并且被pred判断为true的事件都会出现在新生成的事件流中。

    查看图片

  • 映射
    mapE(f, sourceEs)会用转换函数f对事件流sourceEs进行转换生成一个新的事件流,所有出现在sourceEs中的事件都会被f转换成新的事件并出现在新生成的事件流中。

    查看图片

  • 合并
    mergeE(es1, es2)会把两个事件流es1和es2合并成一个新的事件流,所有出现在es1或者es2中的事件都会出现在新生成的事件流中。

    查看图片

  • 直到
    es1.untilE(es2)会根据两个事件流es1和es2生成一个新的事件流,es1出现的所有事件都会出现在新生成的事件流中,直到es2的第一个事件发生,此后结果事件流中将不再发生任何事件。就好像es2中的事件是一个永久的阻断信号,它一旦出现,意味着此后es1中的事件都被阻断不再进入结果事件流中。

    查看图片

  • 当...发生时便...
    srcEs.thenE(f)会根据事件流srcEs和函数f生成一个新的事件流,这里的f必须是一个输入为event输出为EventStream的函数。每当srcEs中出现一个事件,都会把这个事件传给f计算得到一个临时事件流,接下来这个临时事件流中的事件都会出现在最终的结果事件流中,直到srcEs中再次有新的事件发生,又会重新调用f得到另一个临时事件流并替换掉老的,同时结果事件流开始接收这个最新产生的临时事件流中的事件。srcEs中不断有事件发生,上述过程也就不断重复。在前文鼠标拖拽的例子中,正是这个方法使得拖拽事件流的表达非常接近自然语言的描述。熟悉FP的读者一定已经发现,如果把事件流看成是[Monad](http://en.wikipedia.org/wiki/Monad(functionalprogramming))的话,那么thenE就相当于它的bind操作。实际上在Flapjax里,这个方法的名字确实是叫bindE,为了使代码的可读性更好,作者对Flapjax做了小小的修改和扩展,为bindE取了个别名thenE。本文所有示例使用的都是这个定制版Flapjax。

    查看图片

4、Behavior

基本API

//最基本的behavior就是时间本身!
//timerB创建一个表示系统时间值的behavior,
//输入参数表示它每隔多长时间(毫秒)更新一次
behavior1 = timerB(1000);

//创建一种特殊的常量型behavior
//任何时刻它的值都等于你传进去的值
cb = constantB("老子是个常量");

//valueNow获取behavior在当前时刻的值
v = valueNow(behavior1);

//为指定DOM节点创建相关的behavior,
//它的值会自动随着节点的内容变化而变化
domb = extractValueB(dom);

//把behavior的动态内容注入到dom节点上,
//此后只要behavior的值发生变化,
//这个dom节点的内容就会自动发生变化。
insertDomB(behavior1, "timer");

Lift

Number类型的数据有加减乘除等运算操作,和它对应的Behavior Number类型的行为也有相应的addB、subB、mulB、divB这些运算操作。比如这样一个调用

b3 = addB(b1, b2);

就会根据b1和b2这两个行为生成一个新的行为b3,并且任何时刻b3的值都会等于同时刻b1和b2的值之和。其它运算符的语义可以依此类推。有了这些操作后就意味着behavior是可以直接运算的!这使得我们可以像写普通运算表达式那样方便地创建复杂行为。

addB可以看成是把+这个原本作用在Number类型数据上的运算提升(lift)后得到的作用在Behavior Number类型上的运算,其它运算符同此类比。

查看图片

Flapjax提供了lift这个API,它能将计算普通值的函数提升为计算相应behavior的函数。比如标准数学库中的sqrt函数的类型是输入参数为Number类型输出结果为Number类型:

Math.sqrt: Number -> Number

它的提升版本(lifted version)可以这样得到:

sqrtB = lift(Math.sqrt);

得到的sqrtB是一个新的函数,类型是输入参数为Behavior Number输出结果为Behavior Number:

sqrtB: Behavior Number -> Behavior Number

很明显,lift是一个输入参数为函数输出结果也为函数的高阶函数。

接下来就可以直接调用sqrtB:

//b1是个behavior,b2是调用生成的新的behavior,
//b2在任何时刻的值都等于b1同时刻值的平方根。
b2 = sqrtB(b1); 

实际上之前的addB的定义是这样的:

addB = lift(function (v1,v2){
              return v1 + v2;
            });

请看这个演示,它通过以上介绍的方式定义了多个behavior,并将它们在网页上呈现出来。

5、更多的信号和组合操作

Flapjax还提供了更多的信号:

mouseLeftB
mouseTopB
getWebServiceObjectE(requestE)
......

更多的组合操作:

collectE
calmE
switchB
condB
......

具体请查阅参考手册。

六、FRP的优势

在FRP的编程模型里,基本信号可以在不做任何修改的情况下被转换或者组合成新的复杂信号,而新的复杂信号又可以在不做任何修改的情况下被转换或者组合成更复杂的信号,就像乐高积木的搭建,这个过程可以一直进行下去,直到构建出足够复杂的信号以满足系统的需求。

查看图片

这正是程序员梦寐以求的组合能力(Composability)!

现在重新回到前文提出的那个问题,为什么callback风格的代码总是像一坨线团一样那么地杂乱?让我们先来看看一个通用的event-driven框架大概是什么样的:

查看图片

请读者回想一下,程序中注册的事件回调处理函数的返回值一般都是什么类型?想必要么是void要么是一个表示执行状态的状态码,不太可能让返回值可以随意使用各种数据类型,这是因为一个事件驱动框架要通用地处理各种callback,就只能让它的返回值类型足够通用,void便是首选。因此,回调与回调之间是无法直接传递类型丰富的数据的,它们只能通过修改应用程序的共享状态来间接地通讯,这迫使程序员不得不把应用逻辑分割得支离破碎,从而丧失了核心的组合能力!这便是callback风格程序的最大问题。

Callback模型关注控制流,但它对控制流的描述不具有很好的组合性。FRP模型换了一个视角,关注数据流,且数据流的组合能力极佳,使得代码更接近于只描述做什么(what)的声明式(declarative)代码,而不是描述怎么做(how)的命令式(imperative)代码,相当简洁和直观,更符合人的自然思维!

七、实现简述

实现一个FRP框架最关键的是要在信号之间合理有序地传播变化,通常分为两种做法:

1、Push方式

信号主动把变化传播给受影响的其它信号。

  • 优点:外部的变化是同步处理的,系统的反应延迟小。
  • 缺点:
    • 所有构建出来的信号不管程序是否真的需要,都会参与更新和计算,有可能存在大量无用功;
    • 无用信号必须显式地从数据流网络中删除才有可能被回收,资源管理较麻烦,常常造成资源泄漏。

2、Pull方式

信号主动去查询可能会影响到自己的信号是否发生了变化,如果发生变化就进行重计算。

  • 优点:
    • 按需计算,只有真正被程序需要(即被取值)的信号才参与更新和计算。
    • 信号在程序里没有了任何引用就会被自动回收掉,资源管理简单。
  • 缺点:
    • 外部的变化采用异步处理,系统的反应延迟依赖于pull的频率。
    • 当没有变化发生时,仍然会以固定频率pull,也存在一定的消耗。

Flapjax采用的是push的方式。

八、其它的FRP框架

FRP最早发源于Haskell社区,在Haskell社区里有许多关注点不同实现方式各异的FRP框架,比如:

  • Fran
  • Yampa
  • Reactive
  • ....

FrTime是用Racket(Scheme)语言实现的一款FRP框架,它背后的团队和Flapjax的团队是同一个,所以它们在理念、设计和实现上都极为相似。

LuaTime是笔者在几年前为Lua语言实现的FRP框架,它在API的设计上主要参考了FrTime,但是底层的实现采用了pull的方式。这个项目计划在今年年内开源。

九、相关研究

  • Microsoft Reactive Extensions
    微软用于解决实时互动、异步编程问题的跨语言的开发框架,基本思想和FRP极为类似:将变化的数据抽象出来,称之为IObservable,并为其定义各种转换和组合操作。它背后的主要设计者是Haskell社区的大牛。
  • Arrowlets
    FRP关注数据流,Arrowlets和callback一样关注控制流,但它利用[Arrow](http://en.wikipedia.org/wiki/Arrow(computerscience) )这种计算模型使得控制流具备了很好的组合能力。
  • Promise or Future
    在JavaScript社区中很流行的对异步编程的解决方案,有无数的实现库。
  • Sandglass
    笔者所在团队设计的一种基于Behavior Tree模型的AI编程语言,为Behavior Tree引入了协作式多任务机制和显式的时间控制机制,支持以同步化的思维来写异步程序,能编译成标准的JavaScript。

关于作者

邓际锋,来自网易公司杭州研究院前台技术中心引擎技术组。

我们团队的努力方向包括:

  • 开发跨平台实时互动多媒体应用解决方案;
  • 为儿童和非技术人员开发更自然更有趣的创作工具;
  • 探索各种更自然高效的编程模型并积极实践;
  • 编程语言及相关工具的设计与实现;

你可以通过新浪微博@邓际锋或者邮箱soloist.deng at gmail.com与我联系。

【ArchSummit北京2016】100+研发案例,一起见证60+互联网公司业务架构年终总结:微软Sam、领英Kartik、阿里巴巴研究员小邪将展示变革世界的技术,Facebook侯潇潇、滴滴技术研究员许令波等大牛将为我们演绎高可用架构之道,立即查看详情>>