/ 杂想

另类MVC模式 - 优势及实现

继续大逆不道系列……

上一篇中,提出了一个另类的MVC模型,与经典MVC模型有一些不同,那么自然需要描述这样的另类模型有什么优势,又能在怎么样的场景中使用。

逻辑划分

正如上一篇所说,这种模式下,最大的优势莫过于逻辑的清晰划分。在该模式的作用下,每一个Action都只要处理真正与自己有关的逻辑及数据,而不需要关心一些“通用”的内容,因为这些通用内容也成了独立的Action

例如,继续引用上一篇中的页面设计,根据经典的MVC模式,我们不得不在一个Action中准备所有数据:

public ActionResult ViewPost(int id) {
    ViewBag.Friends = FriendsRepository.ByUser(CurrentUser);
    ViewBag.RecentVisitors = VisitorsRepository.ByPost(id, TimeSpan.FromMinutes(30));

    Post post = PostRepository.ById(id);
    return View(post);
}

即便你按上一篇提到的方案使用Action Filter,依旧没有办法彻底地将这些数据从当前Action产生的数据模型中消除,当模块越来越多时,各种“通用”数据一起扔在ViewBag中,造成臃肿、冲突,没有清晰的文档更加大了维护的难度。

而如果使用修改后的MVC模型,每一个Action都只照顾自己相关的逻辑:

public ActionResult ViewPost(int id) {
    Post post = PostRepository.ById(id);
    return View(post);
}

public ActionResult Friends() {
    IList<User> friends = FriendsRepository.ByUser(CurrentUser);
    return View(friends);
}

public ActionResult RecentVisitors(int post) {
    IList<User> visitors = VisitorsRepository.ByPost(post, TimeSpan.FromMinutes(30));
    return View(visitors);
}

各个Action相互独立,同时负责本模块从逻辑到数据到视图的全套流程,清晰明了。

更多切面

而另一方面,划分更清晰的Action,也产生了更多的切面,可以针对性地进行逻辑的拦截和优化。例如,因为用户的朋友关系并不常变,因此针对“好友列表”模块,可以加入缓存:

[CacheView]
public ActionResult Friends() {
    IList<User> friends = FriendsRepository.ByUser(CurrentUser);
    return View(friends);
}

而在经典的MVC模式中,由于数据是一并获取,再统一完成视图渲染的,因此至多可以在数据的层面进行缓存,要做到视图区块的缓存则需要较大的代价,例如Donut Hole Cache技术,但总得来说并不是非常方便。

区块引入技术多样化

至此,将讲述一下另类MVC模式的最大优势,即区块引入的多样化。

习惯了经典MVC模式的开发者,通常认为局部视图的引入是这样的:

<div>
    @section.render('friends', data.friends);
</div>

这是非常经典的思路,其语义是“在当前位置引入名称为friends的区块,并将data.friends作为其数据”。这当然无可厚非,但是你是否有想过,我们可以用更有趣的手段引入一个视图?

例如异步地引入:

<div>
    @section.renderAs.async('friends', { user: data.currentUser });
</div>

又例如使用BigPipe技术来输出:

<div>
    @section.renderAs.bigPipe('friends', { user: data.currentUser });
</div>

再例如甚至在页面完全输出后发起AJAX请求来获取区块内容,由前端渲染:

<div>
    @section.renderAs.ajax('friends', { user: data.currentUser });
</div>

利益于另类MVC模式中,每一个区块都有自己独立的Action->Model->Action->View的完整逻辑,因此一但在View的层面上异步引入,也即代表着“数据查询”、“区块视图拼装”等过程也隐式地被异步化,完全不需要在数据处理的逻辑中采用各种异步的手段来提升性能。

而使用BigPipe或者AJAX等技术,也能更进一步地在各个层面(HTTP响应、用户视觉体验)进行特定的优化,而这一切,仅仅是“不同方式引入区块”便可以做到的。当你的同事辛辛苦苦地修改框架响应模型,完成了一个页面的BigPipe输出而倍感高兴的时候,你却在嘴角掠过一个微笑的弧度,轻轻地敲击键盘,将renderAs.normal改成renderAs.bigPipe,刷新页面,BigPipe就在手中,这是多么美妙的事情啊。

更进一步地,我们可以来个“大一统”方案:

<div>
    @section.renderAs.auto('friends', { user: data.currentUser });
</div>

所谓auto,即根据当前的情况和各种性能监控的数据,例如服务器可用带宽该模块历史在客户端渲染用时服务器负载等数据,自动化、智能化地选择输出的方式,可能是直接输出,可能是异步加载,可能是BigPipe,也可能是AJAX等等……

因为我们知道,不同的输出方式,有着不同的优势:

  • 普通输出

    即串行的获取数据,生成视图,在当前位置输出,随后视图引擎继续向下解析。这种方案最为简单,同时不会有额外的开销,逻辑简单,接近最广泛的开发模式。

  • 异步输出

    视图引擎在当前位置设置一个占位,继续向下解析并生成其他的区块。当前区块的逻辑将异步处理,而视图引擎在处理完整个视图之后,会通过Buffer保持所有的内容并不会向客户端输出。当最终区块的View返回时,视图引擎使用区块的View替换占位符,再将完整的视图输出至客户端。这种方案引入了基本的异步模式,可有效利用多核的硬件环境,一定程度上提高效率,适用于Action和Model逻辑需要消耗较多资源的情况下。

  • BigPipe输出

    视图引擎获取当前位置的容器元素,以异步的方式调用该区块的Action,同时继续向下解析并生成其他区块。当视图处理完毕后,视图引擎会直接通过chunked方式将已经获取的视图输出到客户端,随后等待BigPipe模式的区块对应的Action返回View,将View分解并包装为一个<script>标签,再通过一个chunked输出,形成一个BigPipe的输出效果。这种方案有效利用客户端的资源,当一个区块的内容较为复杂,客户端渲染需要消耗较多资源时,可以先输出其他内容,最后单独通过BigPipe输出区块,实现下载和渲染2条线程的复用效果,提高渲染的效率。

  • AJAX输出

    视图引擎获取当前位置的容器元素,并写入一个<script>标签,该标签中使用javascript通过XMLHttpRequest向服务器发起针对Action的请求,获取视图并填充。此方案完全不会阻塞当前视图的输出,能保证重要的内容第一时间被用户所见,但会引起冗余的HTTP请求。

正因为不同的输出方式有不同的优缺点,因此通过对性能的监控和历史性能数据的分析,动态、智能地选择最合适的输出方案,让系统变得可“自我进化”,很大程度上减少运维的成本,也是该另类MVC方案的目标之一。

总结

综上所述,我认为该另类MVC模式的优点有:

  • 更清晰地划分Action的逻辑,不做无关模块的处理。
  • 提供更多地切面,可针对区块进行缓存、日志等功能的织入。
  • 产生多种不同效果的区块输出方案,合理混搭使用提高效率。
  • 可基于性能数据的监控数据,自动化选择区块输出方案,实现系统自我进化的高智能方案。

而在拥有这些优势的前提之下,实现功能所需要的代码却没有特别的增长,视图依旧是视图,逻辑依旧是逻辑,实现了低成本高效率的开发方案。

实现

关于实现,首先不得不行,我本没有想过去实现这个方案,因此肯定会被世人所唾弃,做不讨好的事不是我的风格。但是我又不能提了一个概念,却说其实我根本不知道能不能实现……所以在这里,就稍微说一下这个方案的简单的原理:

首先,请求将被Locator组件所接收,通过Locator定位到一个View,这个过程与现代几乎所有MVC框架的路由功能类似,因此不需要详细的提及。

在View中,视图引擎对视图的解析,以及如何再次调用不同的Action,如何异步调用还能拼装成统一的页面,这个才是关键。

于是视图引擎成了该方案的重中之重。在我的设想中,视图引擎依旧自上而下地扫描视图,但需要提供一个section对象:

section {
    // 注册一个renderAs.xxx函数
    registerRenderer: function(name, renderer) {
    },

    // 默认自带
    renderAs: {
        normal: function(context) { /* ... */ },
        async: function(context) { /* ... */ },
        bigPipe: function(context) { /* ... */ },
        ajax: function(context) { /* ... */ },
    }
}

在视图引擎的解析过程中,已经解析的内容将会进入到Buffer中,可以使用一个简单的数组来表示。而当遇到section时,在Buffer中会添加一个占位符。因此最终Buffer可能是这个样子:

buffer: [
    '<!DOCTYPE html>',
    '<html manifest="xxx">',
    '<body>',
        '<div id="main">',
            { uid: 1, action: 'post', parameters: {} },
        '</div>',
        '<aside id="sidebar">',
            '<h3>好友列表</h3>',
            '<div id="friends>',
                { uid: 2, action: 'friends', parameters: {} },
            '</div>',
            '<h3>最近访客</h3>',
            '<div id="visitors>',
                { uid: 3, action: 'visitors', parameters: {} },
            '</div>',
        '</aside>',
    '</body>',
    '</html>'
];

注意到以上buffer对象的定义中,有几块并不是字符串,而是一个对象,这个对象正是一个Section的占位。

视图引擎会拼装一个上下文对象,该对象中有Action执行需要的信息,并保证有足够的信息让Action执行完成后可找回这个占位符:

var context = {
    request: request, 
    response: response,
    parameters: parameters,
    placeholder: uid,
    callback: renderSection,
    buffer: buffer
};

作为简易的实现,只使用uid作为标识,随后根据区块的输出方式,对应地调用Action,例如async则有可能通过folk来建立新的线程,利用多核资源最高效地处理逻辑并返回一个View:

function async(context) {
    folk(
        function() {
            var view = framework.executeAction(action, parameters, context);
            context.callback(view);
        }
    );
}

而在一个统一的回调函数中,视图引擎又会利用uid找到对应的占位,将之变换为字符串:

function callback(view) {
    var buffer = this.buffer;
    var uid = this.placeholder;
    for (var i = 0; i < buffer.length; i++) {
        var section = buffer[i];
        if (typeof section === 'object' && section.uid === uid) {
            // 注意此处processView又有可能有子section,视图处理是个递归过程
            buffer[i] = framework.processView(view, context);
        }
    }

    // 如果buffer中全是字符串,没有对象,则为true
    if (framework.isViewComplete(buffer)) {
        this.response.write(framework);
        // 如果不存在BigPipe等需要在视图输出后再输出chunk的功能,则结束响应
        if (this.responseEnd) {
            response.end();
        }
    }
}

至此,视图引擎也就实现完毕,而framework.executeAction的方式和多数MVC框架是一样的,根据路由定位到Action并执行即可,没有什么难度。

关于NodeJS

在本文中,也许你已经看到,我的代码都是javascript编写的。这并不是因为我是一个前端工程师所以我只会写javascript,事实上不管你信不信,我的C#能力要强过javascript……

在此使用javascript作为实现的示例的原因是,由于在区块输出中引入了asyncbigPipe等方式,这本质上形成了一个异步的效果,而我所认识的,无论是struts还是ASP.NET MVC,都是将同步放在第一位,而将异步作为一个备选或增强方案的。这直接导致在现阶段的多数平台下,异步的实现是一个比较麻烦的事。

除了NodeJS。

NodeJS因为其天生即异步的编程模型,致使让我来“实现”我的设计非常顺手,而且非常贴合多数该平台下开发者的思维模式,这才是NodeJS的真正优势。

要在最后提出NodeJS,是因为作为一个边缘人物,其实我是不懂NodeJS的(虽然厚脸皮地翻译了小半本书),但我所见到的,现在的CNodeJS社区推广NodeJS的方式并不合理,因此希望借本文的机会,提一些自己的想法:

无论是朴灵EventProxy还是老赵Jscex,都只是解决Node上开发的问题,让大家无法适应的模式变得能让更多人适应。

而其他的各种框架,只是在模仿已有的现成的开发框架,让Node拥有和其他平台相似的功能。

但是这一些,都没有解决一个问题:

虽然Node的问题解决了,其他平台的功能模块也有了,但为什么我不直接用其他平台?

你可能会说“效率高”,但事实上有多少人关心效率,又有多少人真正知道自己系统的瓶颈能够被Node的效率所改善,又有多少系统的真正效率瓶颈在V8和PHP的差距上?

你又可能会说“统一语言”,但是在这个“配置生成代码”的时代,又有多少系统真正需要这样的统一?君不见rails一路写ruby,不写一行javascript都能生成个带验证的漂亮表单,那又为何非要后端来写javascript?

所以,Node的真正优势何在?我认为其不同即为其劣,亦为其之所长。Node的不同在于天生异步的编程模型,这给很多人带来了烦恼,但绝境之下可逢生,他所带来的又何尝不是其在一定环境下的不可取代性和对应的开发人员吸引力?正如我上面所“实现”的内容,个人认为用Node比用.NET框架更为简单,也更为被该平台的开发者所普遍接受。而普遍接受意味着能迅速培养出社区,意味着有更多的人能参与进来……

所以,虽然个人对Node不冷不热,但我想对希望推广Node的那些拥护者们说,与其不断让Node变得更像其他框架,不如告诉别人有些事Node能用30%的代价来实现120%的效果,扬长避短的道理谁都懂,但又有多少人正在实践?