本文要点
- 在 Web 时代的前二十年,在用户视图及其现实或虚拟世界间的 MVC 可观察的事件驱动同步已经不再发挥什么作用了。
- 近期的一些新进展使这一基础理念得以在 Web 开发社区复苏。
- dWMVC 和 pWMVC 架构范式可以用于完成端到端变化观察“事件环”去创建无缝高效的实时响应应用行为。
- 传统中间件架构和新生代的服务器运动期环境都可被用于去完成这些实时响应行为。
- 非传统服务运行环境和数据库享有使用非标准化技术去创建创新解决方案的架构自由。
之前在《多形态MVC 式Web 架构的分类》中,我们阐述并讨论了WMVC(基于Web 的 MVC ) 架构范式的三个种类。它们是服务器端 WMVC (sWMVC)、双重 WMVC (dWMVC) 以及点对点 WMVC (pWMVC)。 sWMVC 通常本质上是静态的,而其他两个架构范式可用于构建实时响应的 Web 应用组件。这是其后续文章,在这篇文章中,我们将利用这两个架构范式去设计和演示完全动态和响应式现代 Web 组件。
MVC 架构方法的核心是实现用户视图与它们所反映的真实或虚拟世界之间同步的事件驱动的观察者模式。该视图(包括或未包括来自于用户的额外指令)预期是反映世界的变化。在许多 MVC 实现中都体现了这一思想,从最初的桌面界面到现代增强和虚拟现实( AR 和 VR )。在《多形态 MVC 式 Web 架构的分类》的讨论中提到,这个基本思想在 Web 的前二十年之后已经不再发挥什么作用了。在这段时间,Web 应用以基于 sWMVC 的方法为主导。在最近几年,它在 WUI(Web 用户界面)应用开发社区中有所复兴。这个新运动是由许多技术产品和标准协议驱动的。
在本文中,我们将运用一些新的进步去实现异步的、自然的、无缝的以及高效的从 WUI 到后端 SoR 原始记录(source of record)变化观察响应式“事件环”。这方面关键实用技术是:
以下讨论相关的源码可点击在 GitHub 上获取。
用户故事
我们假定客户想要一个基于浏览器的博客评论系统。该 Web 应用允许用户观看一个博客主题并发表评论。
下面是一张 Web 页面的概念设计截图,由三个子视图构成。最上层那一块显示的是博客主题,其后是评论输入和提交域。最后一块区域负责显示所有用户输入的评论。
图 1 博客评论设计截图
该日志系统应该包括两个很有特色的应用:
- 第一个应用会获取博客评论的所有权并把它们存储进集中式数据库中。
- 第二个应用在集中式数据库中不保存任何用户评论,以确保用户隐私及客户责任。
该系统的第三个组件是把其他来源的博客评论整合到这个集中式数据库中,它将在未来开发。
所有应用用户应该有一个视图永远能看到最新的博客评论。
在一个用户正在阅读评论时,由其他用户或通过自动化整合添加了新的评论,那就应该立即显示在所有用户 Web 页面上,而不必他或她手工刷新。
系统架构
具有集中式数据库的博客 Web 应用将用 dWMVC 范式予以设计和开发。总的来说,应用组件间的通讯将用 AngularJS、SSE、InSoR 和 CDC 来实现。这些技术将使系统能够响应任何对集中式数据库中记录的修改(通过这个 Web 应用或未来的集成模块),并实时传递这种变化给最终用户,概览图如图 2。
图 2 集中式实时博客 Web 应用系统架构
客户端与服务器端之间的通讯基于的是 HTTP 和 SSE 协议,因为 InSoR 和 CDC 完全是在应用服务器和数据库之间往返的。
第二个 Web 应用将以 pWMVC 模式实现(如图 3)。它将担任一个使能者的角色,在不必改变内容所有权的情况下把用户聚到一起。
图 3 点对点实时博客 Web 应用的系统架构
通过 dWMVC 实现的集中式 Web 应用
下面的图 4 是基于 dWMVC 的博客 Web 应用设计概览。在浏览器端,视图和控制器组件是基于 AngularJS 的。两个不同的服务器端技术栈组合被用于 dWMVC 模型组件的实现。左侧的是传统 Java 栈和 J2EE 架构,以及关系型数据库 PostgresSQL 。NodeJS 和 RethinkDB 那一侧用于图解基于 JavaScript 的服务器端运行环境和 NoSQL 数据库的架构范式。这些不同的服务器端设计和实现代表了实现同一功能的两种不同方法。除了 NodeJS 的异步特性,在 InSoR 和 CDC 中也存在特别明显的差异,在 NoSQL 数据库提供者中可自由控制架构,从而可以使用非标准化的技术去创建创新的解决方案(比如 lazy evaluation 和 lazy loading )。这两种实现还提供了很多技术性选择,以满足Web 开发社区(从传统中间件实践者到NodeJS/NoSQL 狂热者)广泛的兴趣。
图4 博客应用dWMVC 设计模式的架构图。客户端WMVC 视图和控制器是基于AngularJS 的。服务器端模型组件的两个选择是:Java-RDBMS(左侧)和NodeJS-NoSQL(右侧)。
dWMVC 的视图和控制器
该博客网页是用 AngularJS 局部模板实现的。它是一个复合视图,用于为博客日志的提交和显示提供服务。
<div class="blocker1"> <h3>Topic: WMVC Real Time Reactive Fulfillment</h3> </div> <div id="castingId" class="blocker2"> <div> <h4>Post a Comment:</h4> </div> <form id="commentFormId"> <div> <input type="text" style="width: 30%" name="newCommentId" placeholder="What is in your mind?" ng-model="newComment"/> <button role="submit" class="btn btn-default" ng-click="addComment()"><span class="glyphicon glyphicon-plus"></span>Send</button> </div> </form> </div> <div> <h4>All Comments:</h4> </div> <div class="view" ng-switch on="comments.comments.length" ng-cloak> <ul ng-switch-when="0"> <li> <em>No comments yet available. Be the first to add a comment.</em> </li> </ul> <ul ng-switch-default> <li ng-repeat="comment in comments.comments"> <span>{{comment.comment}} </span> </li> </ul> </div>
该 HTML 页面依赖于 dWMVC 控制器(如图 5)与服务器端的通信去增加新的评论,并为其他用户刷新页面。
图 5 博客评论应用的视图和控制器组件。
为了为用户显示和刷新博客评论,该控制器:
- 通过 HTTP 之上的 SSE 连接后端服务器。
- 如果有的话,则异步接收和显示所有已有的日志评论。
- 保持连接并监听未来的 SSE 事件,它将更新的评论事件作为事件负载进行传递。
- 当 SSE 事件发生时,推送和绑定更新的日志评论到用户的视图页面。
所有这些交互和反应是用以下代码段实现的:
var dataHandler = function (event) { var data = JSON.parse(event.data); console.log('Real time feeding => ' + JSON.stringify(data)); $scope.$apply(function () { $scope.comments = data; }); }; var eventSource = new EventSource('/wmvcapp/svc/comments/all'); eventSource.addEventListener('message', dataHandler, false);
当一名用户增加一个新的评论时,它会直接传送到服务器端用于处理:
$scope.addComment = function () { var newInput = $scope.newComment.trim(); if (!newInput.length) { return; } var url = '/wmvcapp/svc/comments/cast'; $http.post(url, newInput); $scope.newComment = ''; };
接下来,在下面将讨论由服务器模型组件捕获和处理它所关联的数据变更。
dWMVC 的 Java 和 PostgreSQL 模型组件
主要组件都包含在传统技术栈内,一个基于 Java 的中间件应用程序库组合和一个关系型数据库,如图 6 所示。
图 6 基于 Java 和 PostgreSQL 的 dWMVC 模型组件。
这些模型中的交互和响应如图 7 所示。它展示了两个访问该博客应用的用户。
(点击放大图像)
图7 为查看博客评论的用户提供实时惰性更新的一系列交互,基于的是Java 和PostgreSQL 关系型数据库。
当一个用户打开博客页面时,dWMVC 控制器立即实例化一个SSE 实例,它启动与服务器的通信以接收博客评论。其相关的服务器组件如下所示,注解了SSE 请求和实现基于SSE 的输出。当服务器端组件接收到来自于dWMVC 控制器基于SSE 的请求时,它首先针对已有评论查询一下数据库,然后广播一个异步 EventOutput 到该控制器,从而将该评论显示给用户浏览器。与此期间,为了接收在该 PostgreSQL 内对该博客主题后续变更的持续通知,该服务器端组件增加一个监听以保持对 PostgreSQL 数据的“主题观察者”的监听。
@GET @Path("/all") @Produces(SseFeature.SERVER_SENT_EVENTS) public EventOutput getAllComments() throws Exception { final EventOutput eventOutput = new EventOutput(); Statement sqlStatement = null; //Query and return current data String comments = BlogByPostgreSQL.getInstance().findComments(ConfigStringConstants.TOPIC_ID); this.writeToEventOutput(comments, eventOutput); //Listen to future change notifications PGConnection conn = (PGConnection)BlogByPostgreSQL.getInstance().getConnection(); sqlStatement = conn.createStatement(); sqlStatement.execute("LISTEN topics_observer"); conn.addNotificationListener("topics_observer", new PGNotificationListener() { @Override public void notification(int processId, String channelName, String payload) { JSONObject plJson = new JSONObject(payload); String plComments = plJson.getJSONObject("topic_comments").toString(); writeToEventOutput(plComments, eventOutput); } }); return eventOutput; } private void writeToEventOutput(String comments, EventOutput eventOutput) { OutboundEvent.Builder eventBuilder = new OutboundEvent.Builder(); eventBuilder.mediaType(MediaType.APPLICATION_JSON_TYPE); if(comments == null || comments.trim().equals("")) { comments = NO_COMMENTS; } eventBuilder.data(String.class, comments); OutboundEvent event = eventBuilder.build(); eventOutput.write(event); }
PostgreSQL 是一个开源的关系型数据库。它近期添加的其中一个特性是,捕获并发送所有记录的变更作为连接应用组件的入站负载。这个 InSoR 能力是以一对数据库触发器和函数配置实现的。针对我们的博客主题表进行如下配置:
CREATE OR REPLACE FUNCTION proc_topics_notify_trigger() RETURNS trigger AS $$ DECLARE BEGIN PERFORM pg_notify('topics_observer', json_build_object('topic_id', NEW.topic_id, 'topic_comments', NEW.comments)::text); RETURN new; END; $$ LANGUAGE plpgsql DROP TRIGGER trigger_topics_notify ON topics; CREATE TRIGGER trigger_topics_notify AFTER INSERT OR UPDATE OR DELETE ON topics FOR EACH ROW EXECUTE PROCEDURE proc_topics_notify_trigger()
假设,在有些用户正在阅读博客评论的同时,其中有人打算增加一条新的评论。
@POST @Path("/cast") @Consumes(MediaType.APPLICATION_JSON) public void addComment(String newComment) throws Exception { if(newComment != null && !newComment.trim().equals("")) { ObjectMapper mapper = new ObjectMapper(); TopicComments topicComments; String comments = BlogByPostgreSQL.getInstance().findComments(ConfigStringConstants.TOPIC_ID); if(comments == null || comments.trim().equals("")) { topicComments = new TopicComments(); topicComments.addComment(newComment); String topicCommentsStr = mapper.writeValueAsString(topicComments); BlogByPostgreSQL.getInstance().addTopic(topicCommentsStr); } else { if(!comments.contains(newComment)) { topicComments = mapper.readValue(comments, TopicComments.class); topicComments.addComment(newComment); String topicCommentsStr = mapper.writeValueAsString(topicComments); BlogByPostgreSQL.getInstance().updateTopic(topicCommentsStr); } } } }
然后,数据库中该记录被这条新评论一改,该数据库的“trigger_topics_notify”触发器就将调用其相关的“proc_topics_notify_trigger”函数针对“topic_observer”发起一个变更事件通知。该“topic_observer”通知将立即推送给“topic_observer”的监听者,连同 JSON 格式的更新评论一起作为数据负载。该应用组件与这些监听者保持联系,依次处理和编写另外的 SSE EventOutput 到该控制器去刷新这些更新的评论到所有用户视图。这样所有事就都已经实现了,不需要为用户发起新的请求(如图 7)。
dWMVC 的节点和 RethinkDB 模型组件
过去的几年里,NodeJS 已经成为用于构建 Web 应用服务器端运行期环境新锐选择。它的核心架构是事件驱动、异步处理。RethinkDB 是一个开源 NoSQL 数据库,它将实时 Web 应用的开发放到它的架构和设计中进行了深思熟虑。其中一个内置的特性是,提供了变更事件的通知去调用应用组件。
对比图 6,下面的图 8 最大的不同是数据库触发器和过程函数不再需要由 RethinkDB 来配置。它的数据库变更事件的通知是用可链接(chainable)的查询语言 ReQL 实现的。
图 8 基于 NodeJS 和 RethinkDB 数据库的 dWMVC 模型组件。
图 9 展示了应用和数据库组件间的一系列交互和响应。
(点击放大图像)
图 9 基于 NodeJS 和 RethinkDB 数据库,通过一系列交互为正在查看博客评论的用户提供实时更新。
当服务器端组件 blogApp.js 接收到一个基于 SSE 的 getAllComments 请求时,它首先按照新增的特定 HTTP 报头准备响应,如下所示,为最初的响应去和 dWMVC 控制器握手。这使该控制器可以持续监听后续 SSE 流事件。
function setUpSSE(res) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Transfer-Encoding': 'chunked' }); res.write('\n'); }
接下来,它通过 BlogByRethinkDB.js 执行一个可链接的 ReQL 查询去通知该数据库,它想要观察和接收该数据记录的未来任何的变更。这条可观察的查询使该数据库一发生变更就将该变更惰性流化发回给应用组件。
BlogByRethinkDB.prototype.observeComments = function(process) { connectToRDB(function(err, rdbConn) { if(err) { if(rdbConn) rdbConn.close(); return console.error(err); } //Listen for blog comment change events r.table(config.wmvcBlog.dbTable).filter(r.row('topicId').eq(config.wmvcBlog.wmvcBlogTopicId)) .changes({includeInitial: false}).run(rdbConn, function(err, cursor) { if(err) { if(rdbConn) rdbConn.close(); return console.error(err); } //Retrieve all the comments in an array. cursor.each(function(err, row) { if(err) { if(rdbConn) rdbConn.close(); return console.error(err); } if(row) { return process(null, row.new_val); } }); }); }); };
然后,它重新获取该请求主题的所有已有评论。
BlogByRethinkDB.prototype.getAllComments = function(process) { connectToRDB(function(err, rdbConn) { if(err) { if(rdbConn) rdbConn.close(); return console.error(err); } //Query for comments r.table(config.wmvcBlog.dbTable).filter(r.row('topicId').eq(config.wmvcBlog.wmvcBlogTopicId)) .run(rdbConn, function(err, cursor) { if(rdbConn) rdbConn.close(); if(err) { return console.error(err); } //Retrieve all the comments in an array. cursor.toArray(function(err, result) { if(err) { return console.error(err); } if(result && result.length > 0) { return process(null, result[0]); } else { return process(null, null); } }); }); }); };
最后,该服务器端对象组织并返回一个 HTTP 响应,它带有 SEE 兼容格式的数据。
function handleSSEResponse(err, blogComments, res, next) { if(err) { return next(err); } if(blogComments) { var id = new Date().getTime(); res.write('id: ' + id + '\n'); res.write('data: ' + JSON.stringify(blogComments) + '\n\n'); } else { var empty = new Array(); var noResult = { comments: empty }; var id = new Date().getTime(); res.write('id: ' + id + '\n'); res.write('data: ' + JSON.stringify(noResult) + '\n\n'); } }
其后,当新的评论添加到系统中时,observeComments 将异步响应它的数据库变更事件,并广播该更新的评论给所有正在查看的用户,如图 9 所示。
pWMVC 点对点 Web 应用
pWMVC 架构方案的支柱是 WebRTC 协议。特别是其主要组件中的 RTCDataChannel 已在此博客应用的实现中得到了应用。该组件提供了在浏览器间双向点对点数据传输的能力,而不需安装额外的插件。
DataChannelJS 是一个针对 RTCDataChannel 的 JavaScript 包装器类库,用它可以使底层不必太过复杂,从而简化实现。出现同样的目的, PusherJS 被选择来提供信号服务。WebRTC-aware 的应用需要一个信号通道,用于特定客户端交换会议描述和网络可达性的信息。整个应用整合部署为一个 NodeJS Web 服务器。
需要注意的是,NodeJS 服务器和 PusherJS 信号装置都不保留浏览器间的任何数据交换。如图 10 所示,参与其中的信息交换保存在每个用户的浏览器本地存储中。连同这些本地存储一起,所有主要应用组件也都位于浏览器端,并在运行期执行。该NodeJS 组件只在浏览器间转播博客评论,维护组连接状态,保持通信通道的开通。
图10 该博客应用pWMVC 实现的架构图。所有应用评论和数据存储都在用户浏览器上。 Node.js 的主要职责是负责所有参与者浏览器间的信号传输。
图11 阐述了两个用户之间建立和形成博客主题组的顺序过程流。第一个用户在他的浏览器上访问和初始化pWMVC 应用,p2pController 通过若干步骤打开了一个DataChannelJS 实例,绑定到一个PusherJS 信号频道,并开始发送通信信号。此时,由于没有其他同样的参与者,该应用为首个用户显示一个默认页面。接下来,另一位用户打开该博客Web 页面。p2pCcontroller 检测该博客组已经打开,于是它就直接连接这第二位用户的DataChannelJS 并绑定它到PusherJS 信号装置。然后,这两个浏览器进行一系列 ICE (交互式连接建立)通信并协商完成一次 p2p 握手。这个过程在浏览器控制台窗口通过一块接一块的方式来表示,而出于简洁考虑就不显示细节了。在握手之后,这两个用户准备好私下交换信息了,此仅限于现在在它们之间开通的 DataChannelJS。
(点击放大图像)
图 11 基于 Pusher.js、DataChannel.js 和 Node.js(延续图 10), 两个用户浏览器之间建立基于 WebRTC 通信的一系列交互
一旦两个用户之间开通了 DataChannelJS(如图 12),该应用就会首先从该浏览器本地存储接收和显示该主题已有的评论(如果有的话),以便他们了解上次交流至今错过的内容。
webRTCDatachannel.onopen = function (userId) { p2pModel.getAllComments(groupName) .success(function(updatedComments) { if(updatedComments === null) { updatedComments = { comments: new Array() }; } $scope.comments = updatedComments; }) .error(function(error) { alert('Failed to save the new comment' + error); }); } getAllComments: function (groupName) { var savedComments = $window.localStorage.getItem(groupName); if(savedComments !== null) { savedComments = JSON.parse(savedComments); } var updatedComments = aggregateComments("", null, savedComments); return handlePromise($q, updatedComments); }
图 12 延续图 11,基于 Pusher.js、DataChannel.js 和 Node.js,两个用户浏览器交换基于 WebRTC 信息的一系列交互。该信息保存在个人用户浏览器的本地存储中。
在这些用户查看评论的同时,他们的浏览器会继续给彼此发信号以保持通信通道的开通。因此,用户可以发表其他新的评论,如图 12 及下面的代码片段所示。
$scope.addComment = function () { var newInput = $scope.newComment.trim(); if (!newInput.length) { return; } var currentComments = $scope.comments; p2pModel.aggregateAndStoreComments(groupName, newInput, currentComments) .success(function(updatedComments) { webRTCDatachannel.send(updatedComments); $scope.comments = updatedComments; }) .error(function(error) { alert('Failed to save the new comment' + error); }); $scope.newComment = ''; }
当新的评论发表出来时,p2pController 首先使用 p2pModel 针对该主题聚集和更新本地存储(如下所示)。然后,通过 DataChannelJS 将更新的评论发送给其他参与者。
aggregateAndStoreComments: function (groupName, comment, currentComments) { var savedComments = $window.localStorage.getItem(groupName); if(savedComments !== null) { savedComments = JSON.parse(savedComments); } var updatedComments = aggregateComments(comment, currentComments, savedComments); storeComments(groupName, updatedComments, $window); return handlePromise($q, updatedComments); }
当其他参与者接收到更新的评论时,评论被显示在该 Web 页面上,并保存到他们的本地存储中。
webRTCDatachannel.onmessage = function (newInput, userId) { p2pModel.aggregateAndStoreComments(groupName, "", newInput) .success(function(updatedComments) { $scope.comments = updatedComments; }) .error(function(error) { alert('Failed to save the new comment' + error); }); }
总结
尽管 MVC 架构方法的交互和响应的典范在万维网前二十年期间的 Web 应用领域的应用减弱了,但近期的进步又使该基础理论得以在 Web 开发社区复兴。标准通信协议及其特有的 InSoR 能力使信息变更事件可以实时地动态和异步循环遍历 Web 应用系统的边界。这些使现代 Web 应用开发人员可以利用 dWMVC 和 pWMVC 架构范式去完成 MVC-esque 实时变更观察“事件循环”,按多变的流行风尚创建无缝、高效的响应式应用行为。这些工具不仅可应用于现代新的服务器端运行期环境,也可以用于传统的中间件架构。
关于作者
Victor Chen 渴求追赶计算机领域的创新和改革。他具有虚拟现实、移动和 Web 应用方面的设计和实现的丰富经验。他还成功地设计和建造了数个竞赛机器人。
Brent Chen先生自上世纪九十年代以来就一直致力于系统架构和应用开发。他所提出的解决方案涵盖了众多的专业领域,其中包括:工资管理、人力资源管理、职工福利管理、监管合规、卫生保健和政府事务等。Brent Chen 先生曾供职于一些主要的解决方案和服务提供商,诸如:Computer Sciences Corp、Northrop Grumman、ADP、LLC 等。他的一个研究兴趣就是去探究当前正在发展的 Web 架构和技术中的新机遇和新兴前沿领域。
查看英文原文: Article: Polymorphism of MVC-esque Web Architecture: Real Time Reactive Fulfillment
评论