1.1前言
实时更新是现代应用程序中用户体验的一个组成部分。随着用户期待这样的行为,越来越多的应用程序都正在实时地向用户推送数据的变化。通过传统的3层架构很难实现实时的数据同步,其需要开发者管理他们自己的运维、服务器以及伸缩。通过维护到客户端的实时的、双向的通信,Firebase提供了一种即使的直观体验,允许开发人员在几分钟之内跨越不同的客户端进行应用程序数据的同步——这一切都不需要任何的后端工作、服务器、运维或者伸缩。
实现这种能力提出了一项艰难的技术挑战,而Netty则是用于在Firebase内构建用于所有网络通信的底层框架的最佳解决方案。这个案例研究概述了Firebase的架构,然后审查了Firebase使用Netty以支撑它的实时数据同步服务的3种方式:长轮询、HTTP 1.1 keep-alive和流水线化、控制SSL处理器
2.1、Firebase的架构
Firebase允许开发者使用两层体系结构来上线运行应用程序。开发者只需要简单地导入Firebase库,并编写客户端代码。数据将以JSON格式暴露给开发者的代码,并且在本地进行缓存。该库处理了本地高速缓存和存储在Firebase服务器上的主副本(master copy)之间的同步。对于任何数据进行的更改都将会被实时地同步到与Firebase相连接的潜在的数十万个客户端上。
Firebase的服务器接收传入的数据更新,并将它们立即同步给所有注册了对于更改的数据感兴趣的已经连接的客户端。为了启用状态更改的实时通知,客户端将会始终保持一个到Firebase的活动连接。该连接的范围是:从基于单个Netty Channel的抽象到基于多个Channel的抽象,甚至是在客户端正在切换传输类型时的多个并存的抽象。
因为客户端可以通过多种方式连接到Firebase,所以保持连接代码的模块化很重要。Netty的Channel抽象对于Firebase继承新的传输来说简直是梦幻般的构件块,此外,流水线和处理器模式使得可以简单地传输相关的细节隔离开来,并为应用程序代码提供了一个公共的消息流抽象。同样的,这也极大地简化了添加新的协议支持所需要的工作。Firebase只通过简单地添加几个新的ChannelHandler到ChannelPipeline中,便添加了对一种二进制传输的支持。对于实现客户端和服务器之间的实时连接而言,Netty的速度、抽象的级别以及细粒度的控制都使得它成为了一个卓绝的框架。
2.2、长轮询
Firebase同时使用了长轮询和WebSocket传输。长轮询传输是高度可靠的,覆盖了所有的浏览器、网络以及运营商;而基于WebSocket的传输、速度更快,但是由于浏览器/客户端的局限性,并不总是可用的。开始时,Firebase将会使用长轮询进行连接,然后在WebSocket可用时再升级到WebSocket。对于少数不支持WebSocket的Firebase流量,Firebase使用Netty实现了一个自定义的库来进行长轮询,并且经过调优具有非常高的性能和响应性。
Firebase的客户端库逻辑处理双向消息流,并且会在任意一端关闭流时进行通知。虽然这在TCP或者WebSocket协议上实现起来相对简单,但是在处理长轮询传输时它仍然是一项挑战。对于长轮询的场景来说,下面两个属性必须被严格地保证:
——保证消息的按顺序投递
——关闭通知
1-保证消息的按顺序投递
可以通过使得在某个指定的时刻有且只有一个未完成的请求,来实现长轮询的按顺序投递。因为客户端不会在它收到它的上一个请求的响应之前发出另一个请求,所以这就保证了它之前所发出的所有消息都被接收,并且可以安全地发送更多的请求了。同样,在服务器端,直到客户端收到之前的响应之前,将不会发出新的请求。因此,总是可以安全地发送缓存在两个请求之间的任何东西。然而,这将导致一个严重的缺陷。使用单一请求技术,客户端和服务器端都将花费大量的时间来对消息进行缓冲。例如,如果客户端有新的数据需要发送,但是这是已经有了一个未完成的请求,那么它在发出新的请求之前,就必须得等待服务器的响应。如果这时在服务器上没有可用的数据,则可能需要很长时间。
一个更加高性能的解决方案则是容忍更多的正在并发进行的请求。在实践中,这可以通过将单一请求的模式切换为最多两个请求的模式。这个算法包含了两个部分:
——每当客户端有新的数据需要发送时,它都将发送一个新的请求,除非已经有两个请求正在被处理
——每当服务器接收到来自客户端的请求时,如果它已经有了一个来自客户端的未完成的请求,那么即使没有数据,他也将立即回应第一个请求。
相对于单一请求的模式,这种方式提供了一个重要的改进:客户端和服务器的缓冲时间都被限定在了最多一次的网络往返时间里。
当然,这种性能的增加并不是没有代价的;它导致了代码复杂性的相应增加。该长轮询算法也不再保证消息的按顺序投递,但是一些来自TCP协议的理念可以保证这些消息的按顺序投递。由客户端发送的每个请求都包含一个序列号,每次请求时都将会递增。此外,每个请求都包含了关于有效负载中的消息数量的元数据。如果一个消息跨越了多个请求,那么在有效负载中所包含的消息的序号也会被包含在元数据中。
服务器维护了一个传入消息分段的环形缓冲区,在它们完成之后,如果它们之前没有不完整的消息,那么会立即对它们进行处理,下行要简单点,因为长轮询传输响应时HTTP GET请求,而且对于有效载荷的大小没有相同的限制。在这种情况下,将包含一个对于每个响应都将会递增的序列号,只要客户端接收到了达到指定序列号的所有响应,他就可以开始处理列表中的所有消息;如果它没有收到,那么它将缓冲该列表,直到它接收到这些为完成的响应。
2-关闭通知
在长轮询传输中第二个需要保证的属性是关闭通知。在这种情况下,使得服务器意识到传输已经关闭,明显要重要与使得客户端识别到传输的关闭。客户端所使用的Firebase库将会在连接断开时将操作放入队列以便稍后执行,而且这些被放入队列的操作可能也会对其它仍然连接着的客户端造成影响。因此,知道客户端什么时候实际上已经断开了是非常重要的。实现由服务器发起的关闭操作是相对简单的,其可以通过使用一个特殊的协议级别的关闭消息响应下一个请求来实现。
实现客户端的关闭通知是比较棘手的。虽然可以使用相同的关闭通知,但是有两种情况可能会导致这种方式失效:用户可以关闭浏览器标签页,或者网络连接也可能会消失。标签页关闭的这种情况可以通过iframe来处理,iframe会在页面卸载时发送一个包含关闭消息的请求。第二种情况则可以通过服务器超时来处理。小心谨慎地选择超时值大小很重要,因为服务器无法区分慢速的网络和断开的客户端。也就是说,对于服务器来说,无法知道一个请求是被实际推迟了一分钟,还是该客户端丢失了它的网络连接。相对于应用程序需要多快地意识到断开的客户端来说,选取一个平衡了误报所带来的成本的合适的超时大小是很重要的。
下图演示了Firebase的长轮询传输是如何处理不同类型的请求的。
在这个图中,每个长轮询请求都代表了不同类型的场景,最初,客户端向服务器发送了一个轮询(轮询0)。一段时间之后,服务器从系统内的其它地方接收到了发送给客户端的数据,所以它使用该数据响应了轮询0。在该轮询返回之后,因为客户端目前没有任何未完成的请求,所以客户端有立即发送了一个新的轮询(轮询1)。过了一会儿,客户端需要发送数据给服务器。因为它只有一个未完成的轮询,所以它有发送了一个新的轮询(轮询2),其中包含了需要被递交的数据。根据协议,一旦在服务器同时存在两个来自相同的客户端的轮询时,它将响应第一个轮询。在这种情况下,服务器没有任何已经就绪的数据可以用于该客户端,因此它发送回了一个空响应。客户端也维护了一个超时,并将在超时被触发时发送第二次轮询,即使它没有任何额外的数据需要发送。这将系统从由于浏览器超时缓慢的请求所导致的故障中隔离开来。
2.3、HTTP 1.1 keep-alive和流水线化
通过HTTP 1.1 keep-alive特性,可以在同一个连接上发送多个请求到服务器。这使得HTTP流水线化——可以发送新的请求而不必等待来自服务器的响应,成为了可能。实现对于HTTP流水线化以及keep-alive特性的支持通常是直截了当的,但是当混入了长轮询之后,他就明显变得更加复杂起来。
如果一个长轮询请求紧跟着一个REST(表征状态转移)请求,那么将有一些注意事项需要被考虑在内,以确保浏览器能够正确工作。一个Channel可能会混和异步消息(长轮询请求)和同步消息(REST请求)。当一个Channel上出现了一个同步请求时,Firebase必须按照顺序同步响应该Channel中所有之前的请求。例如,如果有一个未完成的长轮询请求,那么在处理该REST请求之前,需要使用一个空操作对该长轮询传输进行响应。
下图说明了Netty是如何让Firebase在一个套接字上响应多个请求的。
如果浏览器有多个打开的连接,并且正在使用长轮询,那么它将重用这些连接来处理来自这两个打开的标签页的消息。对于长轮询请求来说,这是很困难的,并且还需要妥善地管理一个HTTP请求队列。长轮询请求可以被中断,但是被处理的请求却不能。Netty使服务于多种类型的请求很轻松。
——静态的HTML页面:缓存的内容,可以直接返回而不需要进行处理;例子包括一个单页面的HTTP应用程序、robots.txt和crossdomain.xml
——REST请求:Firebase支持传统的GET、POST、PUT、DELETE以及OPTIONS请求
——WebSocket:浏览器和Firebase服务器之间的双向链接,拥有它自己的分帧协议
——长轮询:这些类似于HTTP的GET请求,但是应用程序的处理方式有所不同。
——被代理的请求:某些请求不能由接收它们的服务器处理。在这种情况下,Firebase将会把这些请求代理到集群中正确的服务器。以便最终用户不必担心数据存储的具体位置。这些类似于REST请求,但是代理服务器处理它们的方式有所不同。
——通过SSL的原始字节:一个简单的TCP套接字,运行Firebase自己的分帧协议,并且优化了握手过程。
Firebase使用Netty来设置好它的ChannelPipeline以解析传入的请求,并随后适当地重新配置ChannelPipeline剩余的其它部分。在某些情况下,如WebSocket和原始字节,一旦某个特定类型的请求被分配给某个Channel之后,它就会在它的整个生命周期内保持一致。在其他情况下,如各种HTTP请求,该分配则必须以每个消息为基础进行赋值。同一个Channel可以处理REST请求、长轮询请求以及被代理的请求。
2.4、控制SslHandler
Netty的SslHandler类是Firebase如何使用Netty来对它的网络通信进行细粒度控制的一个例子。当传统的Web技术栈使用Apache或者Nginx之类的HTTP服务器来将请求传递给应用程序时,传入的SSL请求在被应用程序的代码接收到的时候就已经被解码了。在多租户的架构体系中,很难将部分的加密流量分配给使用了某个特定服务的应用程序的租户。这很复杂,因为事实上多个应用程序可能使用了相同的加密Channel来和Firebase通信(例如,用户可能在不同的标签页中打开了两个Firebase应用程序)。为了解决这个问题,Firebase需要在SSL请求被解码之前对它们拥有足够的控制来处理它们。
Firebase基于带宽向客户进行收费。然而,对于某个消息来说,在SSL解密被执行之前,要收取费用的账户通常是不知道的,因为它被包含在加密了的有效负载中。Netty使得Firebase可以在ChannelPipeline中的多个位置对流量进行拦截,因此对于字节数的统计可以从字节刚被从套接字读取出来时便立即开始。在消息被解密并且被Firebase的服务器端逻辑处理之后,字节计数便可以被分配给对应的账户。在构建这项功能时,Netty在协议栈的每一层上,都提供了对于处理网络通信的控制,并且也使得非常精确的计费、限流以及速率限制成为了可能,所有的这一切都对业务具有显著的影响。
Netty使得通过少量的Scala代码便可以拦截所有的入站消息和出站消息并且统计字节数成为了可能。
2.5、Firebase小结
在Firebase的实时数据同步服务的服务器端架构中,Netty扮演了不可或缺的角色。它使得可以支持一个异构的客户端生态系统,其中包括了各种各样的浏览器,以及完全由Firebase控制的客户端。使用Netty,Firebase可以在每个服务器上每秒钟处理数以万计的消息。Netty之所以非常了不起,有以下几个原因:
——他很快:开发原型只需要几天时间,并且从来不是生成瓶颈
——它的抽象层次具有良好的定位:Netty提供了必要的细粒度控制,并且允许在控制流的每一步进行自定义。
——它支持在同一个端口上支撑多种协议:HTTP、WebSocket、长轮询以及独立的TCP协议
——它的GitHub库是一流的:精心编写的javadoc使得可以无障碍地利用它进行开发