1 响应式编程之道
1.1 什么是响应式编程?
在开始讨论响应式编程(Reactive Programming)之前,先来看一个我们经常使用的一款堪称“响应式典范”的强大的生产力工具——电子表格。
举个简单的例子,
- 某电商网站正在搞促销活动,任何单品都可以参加“满199减40”的活动,
- 而且“满500包邮”。
- 吃货小明有选择障碍(当然主要原因还是一个字:穷),他有个习惯,就是先在Excel上根据预算算好自己要买的东西:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eswTFUPO-1612524491971)(https://leanote.com/api/file/getImage?fileId=5a79abd6ab64413e66002707)]
相信大家都用过Excel中的公式,这是一个统计购物车商品和订单应付金额的表格,其中涉及到一些公式:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d29Xzv7M-1612524491973)(https://leanote.com/api/file/getImage?fileId=5a7a5481ab64411b470005ae)]
上图中蓝色的线是公式的引用关系,从中可以看出,
- “商品金额”是通过“单价x数量”得到的,
- “满199减40”会判断该商品金额是否满199并根据情况减掉40,
- 右侧“订单总金额”是“满199减40”这一列的和,
- “邮费”会根据订单总金额计算,“最终应付款”就是订单总金额加上邮费。
1.1.1 变化传递(propagation of change)
为什么说电子表格软件是“响应式典范”呢,
- 因为“单价”和“数量”的任何变动,都会被引用(“监听”)它的单元格实时更新计算结果,
- 如果还有图表或数据透视图引用了这块数据,那么也会相应变化,做到了实时响应。变化的时候甚至还有动画效果,用户体验一级棒!
这是响应式的核心特点之一:变化传递(propagation of change)。
- 一个单元格变化之后,会像多米诺骨牌一样,导致直接和间接引用它的其他单元格均发生相应变化。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YSk8xjHf-1612524491974)(https://leanote.com/api/file/getImage?fileId=5a7a57f4ab64411d33000655)]
看到这里,你可能会说,“切~ 不就是算付款金额吗,购物网站上都有这个最基础不过的功能啊~”,这就“响应式”啦?
- 但凡一个与用户交互的系统都得“响应”用户交互啊~
但是在 响应式编程中,基于“变化传递”的特点,触发响应的主体发生了变化。
- 假设购物车管理 和 订单付款是两个不同的模块,或者至少是两个不同的类——
Cart
和Invoice
。也许我们的代码是这样的:
Product.java(假设商品有两个属性name
和price
,简单起见,price
就不用BigDecimal
类型了)
public class Product {
private String name;
private double price;
// 构造方法、getters、setters
}
Cart模块中:
import com.example.Invoice; // 2
public class Cart {
//添加商品
public boolean addProduct(Product product, int quantity) {
//得到商品的价格 * 数量
double figure = product.getPrice() * quantity;
invoice.update(figure); // 1 执行更新商品
...
}
...
}
- 是由
Cart
的对象去调用Invoice
对象的更新订单金额的方法; Cart
的代码中需要importInvoice
。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mm68syxM-1612524491976)(https://leanote.com/api/file/getImage?fileId=5a7af2c2ab64411d33001651)]
而我们再观察这个Excel,
- 发现“订单总金额”的计算公式不仅位于自己的单元格中,
- 而且这个公式能主动监听和响应购物车数据的变化事件。
- 对于购物车来说,它没有对订单付款方面的任何公式引用。感觉就像这样:
假设数据流有操作的商品product
和变化个数quantity
两个属性:
public class CartEvent {
private Product product;
private int quantity;//变化个数
// 构造方法、getters、setters
}
Invoice模块中:
import com.example.Cart // 2
public class Invoice {
...
public Invoice(Cart cart) {
...
this.listenOn(cart); // 1
...
}
// 回调方法
public void onCartChange(CartEvent event) {
...
}
...
}
- 是由
Invoice
的对象在初始化的时候就声明了对Cart
对象的监听,从而一旦Cart
对象有响应的事件(比如添加商品)发生的时候,Invoice
就会响应; Invoice
的代码中importCart
。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F8HTsEll-1612524491977)(https://leanote.com/api/file/getImage?fileId=5a7af2e1ab64411b4700162c)]
做过Java桌面开发的朋友可能会想到Java swing中的各种监听器,比如MouseListener
能够监听鼠标的操作,并实时做出响应。
- 所以C/S的客户端总是比B/S的Web界面更具有响应性嘛。
所以,这里我们说的是一种生产者只负责生成并发出数据/事件, 消费者来监听并负责 定义如何处理数据/事件的变化传递方式。
那么,Cart
对象如何在发生变化的时候“发出”数据或事件呢?
1.1.2 数据流(data stream)
这些数据 / 事件在响应式编程里会以数据流的形式发出。
我们再观察一下购物车,
- 这里有若干商品,小明每次往购物车里添加或移除一种商品,
- 或调整商品的购买数量,
- 这种事件都会像过电一样流过这由公式串起来的多米诺骨牌一次。
- 这一次一次的操作事件连起来就是一串数据流(data stream),
- 如果我们能够及时 对数据流的每一个事件做出响应,会有效提高系统的响应水平。这是响应式的另一个核心特点:基于数据流(data stream)。
如下图是小明选购商品的过程,为了既不超预算,又能省邮费,有时加有时减:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BEIrXbyb-1612524491978)(https://leanote.com/api/file/getImage?fileId=5a7a6a6fab64411b4700089a)]
这一次一次的操作就构成了一串数据流。Invoice模块中的代码可能是这样:
public Invoice(Cart cart) {
...
this.listenOn(cart.eventStream()); // 1
...
}
- 其中,
cart.eventStream()
是要监听的购物车的操作事件数据流, listenOn
方法能够对数据流中到来的元素依次进行处理。
1.1.3 声明式(declarative)
我们再到listenOn
方法去看一下:
Invoice模块中,上边的一串公式被组装成如下的伪代码:
public void listenOn(DataStream<CartEvent> cartEventStream) { // 1
double sum = 0;
double total = cartEventStream
// 分别计算商品金额
.map(cartEvent -> cartEvent.getProduct().getPrice() * cartEvent.getQuantity()) // 2
// 计算满减后的商品金额
.map(v -> (v > 199) ? (v - 40) : v)
// 将金额的变化累加到sum
.map(v -> {sum += v; return sum;})
// 根据sum判断是否免邮,得到最终总付款金额
.map(sum -> (sum > 500) ? sum : (sum + 50));
...
cartEventStream
是数据流,DataStream
是某种数据流类型,- 可以暂时想象成类似在Java 8版本增加的对数据流进行处理的Stream API(下节会说到为啥不用Java Stream)。
map
方法用于对数据流中的元素 进行映射,- 比如第一个将
cartEvent
中的商品价格和数量拿到, - 然后算出本次操作的金额;第二个判断是否能享受“满199减40”的活动。
- 比如第一个将
这里的伪代码用到了lambda,它非常适用于数据流的处理。没有接触过lambda的话没有关系,我们后续会再聊到它。
这是一种**“声明式(declarative)”**的编程范式。通过四个串起来的map
调用,
- 我们先声明好了对于数据流“将会”进行什么样的处理,
- 当有数据流过来时,就会按照声明好的处理流程逐个进行处理。
比如对于第一个map
操作:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7P1XB1GD-1612524491979)(https://leanote.com/api/file/getImage?fileId=5aa1eb95ab644172e5000527)]
**声明式编程范式的威力在于以不变应万变。**无论到来的元素是什么,计算逻辑是不变的,从而形成了一种对计算逻辑的“绑定”。
再举个简单的例子方便理解:
a = 1;
b = a + 1;
a = 2;
这个时候,b是多少呢?在Java以及多数语言中,b的结果是2,第二次对a的赋值并不会影响b的值。
假设Java引入了一种新的赋值方式:=
,表示一种对a的绑定关系,如
a = 1;
b := a + 1;
a = 2;
由于b保存的不是某次计算的值,而是针对a的一种绑定关系,
- 所以b能够随时根据a的值的变化而变化,
- 这时候
b==3
,我们就可以说:=
是一种声明式赋值方式。 - 而普通的
=
是一种命令式赋值方式。 - 事实上,我们绝大多数的开发都是命令式的,如果需要用命令式编程表达类似上边的这种绑定关系,
- 在每次a发生变化并需要拿到b的时候都得执行
b = a + 1
来更新b的值。
如此想来,“绑定美元政策”不也是一种声明式的范式吗~
总结来说,命令式是面向过程的,声明式是面向结构的。
不过命令式和声明式本身并无高低之分,只是声明式比较适合基于流的处理方式。这是响应式的第三个核心特点:声明式(declarative)。结合“变化传递”的特点,声明式能够让基于数据流的开发更加友好。
1.1.4 总结
总结起来,响应式编程(reactive programming)是
- 一种基于数据流(data stream)和
- 变化传递(propagation of change)的
- 声明式(declarative)的编程范式。
响应式编程的“变化传递”就相当于
- 果汁流水线的管道;在入口放进橙子,出来的就是橙汁;
- 放西瓜,出来的就是西瓜汁,
- 橙子和西瓜、以及机器中的果肉果汁以及残渣等,都是流动的“数据流”;
- 管道的图纸是用“声明式”的语言表示的。
这种编程范式如何让Web应用更加“reactive”呢?
我们设想这样一种场景,我们从底层数据库驱动,经过持久层、服务层、MVC层中的model,到用户的前端界面的元素,全部都采用声明式的编程范式,从而搭建一条能够传递变化的管道,这样我们只要更新一下数据库中的数据,用户的界面上就相应的发生变化,岂不美哉?尤其重要的是,一处发生变化,我们不需要各种命令式的调用来传递这种变化,而是由搭建好的“流水线”自动传递。
这种场景用在哪呢?
- 比如一个日志监控系统,我们的前端页面将不再需要通过“命令式”的轮询的方式不断向服务器请求数据然后进行更新,而是在建立好通道之后,
- 数据流从系统源源不断流向页面,从而展现实时的指标变化曲线;
- 再比如一个社交平台,朋友的动态、点赞和留言不是手动刷出来的,
- 而是当后台数据变化的时候自动体现到界面上的。
具体如何来实现呢,请看下一节关于响应式流的介绍。