原文出处:关于大型网站技术演进的思考(十七)--网站静态化处理—满足静态化的前后端分离(9)

  前文讲到了CSI技术,这就说明网站静态化技术的讲述已经推进到了浏览器端了即真正到了web前端的范畴了,而时下web前端技术的前沿之一就是前后端分离技术了 ,那么在这里网站静态化技术和前后端分离技术产生了交集,所以今天我将讨论下前后端分离技术,前后端分离技术讨论完后,下一篇文章我将会以网站静态化技术的角度回过头来重新审视下前后端分离技术,希望通过这种审视来加深我们对两套技术的理解。

  前后端分离技术我个人认为是web前端被专业化以后的必由之路,而nodejs的出现是前后端分离技术的一个强兴的催化剂,原因是nodejs的出现削平了前端技术和服务端技术之间的鸿沟,使得前后端两套不同技术体系进行真正意义的解耦提供了无限的可能性。但是如果我们把nodejs技术的使用认为就是实现了前后端分离,这种理解又实在太肤浅了,下面我将讲讲我研究过的前后端分离技术方案,以及这些技术方案隐藏在背后思考,希望这些思考能给大家以一个新的思路来理解前后端分离技术。

  我们要深刻理解前后端分离技术有一个重要的前提,那就是要把前后端分离技术认为是传统的web**应用里的MVC**设计模式的进一步演进。那么我们首先来看看MVC的定义,下面的内容摘录于维基百科的解释,具体如下:

MVC模式Model-View-Controller是软件工程中的一种软件架构模式把软件系统分为三个基本部分模型Model)、视图View和控制器Controller)。
MVC模式最早由Trygve Reenskaug在1978年提出[1] 是施乐帕罗奥多研究中心Xerox PARC在20世纪80年代为程序语言Smalltalk发明的一种软件设计模式MVC模式的目的是实现一种动态的程式设计使后续对程序的修改和扩展简化并且使程序某一部分的重复利用成为可能除此之外此模式通过对复杂度的简化使程序结构更加直观软件系统通过对自身基本部分分离的同时也赋予了各个基本部分应有的功能专业人员可以通过自身的专长分组
控制器 Controller- 负责转发请求对请求进行处理
视图 View - 界面设计人员进行图形界面设计
模型 Model - 程序员编写程序应有的功能实现算法等等)、数据库专家进行数据管理和数据库设计(可以实现具体的功能)

  各类用于Web应用开发的语言里都有属于自己的MVC框架,例如本人最熟悉的服务端语言java里就有大名鼎鼎的struts2,springMVC的MVC应用框架,我早期从事java的web开发时候认为这些MVC框架都是非常的博大精深,用途广泛,但是当我逐渐转向了web前端技术开发以后又觉得这些框架的很多功能显得那么的多余和累赘,因此我曾写过一篇文章专门讨论过这些问题,该文章的名字叫做《为什么做java的web开发我们会使用struts2,springMVC和spring这样的框架?》。

  其实这篇文章被写的源头就是在于我认为像struts2和springMVC这样的框架做了太多浏览器本身就可以完成的工作,例如:页面的渲染操作,因为服务端抢了浏览器端的部分工作,这其实也就等于限制了web前端技术的深入运用,像很多前端的优化技术以及很多提升用户体验的技术就很难派上用场,之所以产生这些问题,我认为传统的**MVC框架本质其实是一个服务端的MVC框架**,虽然MVC设计模式里的V即View视图层是想把界面开发工作专业化,让界面设计人员能专心于界面开发,但是传统的MVC框架下的View层的本质却是一个不折不扣的服务端技术。

  我们以java的web开发里jsp为例,JSP**全名为Java Server Pages,中文名叫java服务器页面,其根本是一个简化的Servlet设计,它是java里动态网页的技术标准**,这就说明jsp虽然看起来像html,其实它并不是真正的html,它需要被java的web容器进行解析转化为浏览器可以解析的html页面,然后通过网络传输到浏览器后,浏览器才能正确的展示这个jsp页面,其他web开发语言里都有类似的动态网页技术标准,但是不管什么语言的动态网页技术标准,我们使用它时候就是让web前端技术被服务端技术所绑架,这也就是为什么每个招聘web前端工程师的岗位都要问你是否会java,php语言的源头。但是随着互联网的大发展,对web前端的要求是越来越专业化,web前端本身所包含的技术难度已经不亚于任何一个服务端语言开发难度,因此我们需要web前端更高的专业化,而不希望web前端工程师被服务端技术束缚的更多而限制了自身能力的发展,这就导致前后端分离技术的出现。

  不过前后端分离技术的第一阶段倒不是从改变view层即视图层开始的,而是从连接客户端和服务端的C层即控制层开始的,控制层既要作用于客户端又要作用于服务端, 如果一个功能页面是一个程序员从浏览器端一直写到模型层,控制层也就不是什么问题了,但是如果当我们想按MVC的设计思想,让界面开发人员专注于页面开发,服务端开发人员专注于服务端开发,那么这个时候控制层的归属问题就显的非常重要了。在传统的MVC框架里,因为M层和C层是使用同样的语言体系,因此我们很自然会把M层和C层的 开发工作都交由服务端开发人员完成,这个决定无可厚非,但是传统的MVC框架里V层和C层其本质也是同一个技术体系下的(例如java的web开发里的jsp本质就是个servlet),因此V层和C层也是紧耦合的,因此界面开发人员开发页面时候如何没有C层支撑,那么这个页面其实是根本跑不起来的,如果前端开发人员这时候跑去写写C层即控制层的代码,这就打破了原有的横向分工,这个时候控制层的编码工作就会变得混乱而难以控制,看到这里有人一定会说既然控制层是属于服务端的,那么前端技术人员就等等服务端的开发进度,再不行就自己写个mock模拟下服务端的控制层,听到这种建议,我相信不管是前端的还是服务端的技术人员都会头脑发麻,第一反应就是这不是自找麻烦啊,还不如一个人全部搞定算了。由此第一阶段的前后端分离技术方案出现了,这个方案需要解决的问题就是如何能让web前端技术人员和web服务端技术人员协同 起来工作,合理的分工,换句话说就是按web前端和web服务端角度如何能横向的分解web的开发工作。

前后端分离的第一阶段需要解决问题的核心就是控制层的归属问题从技术角度而言就是控制层到底是应该和视图层解耦比较合理还是跟模型层解耦比较合理的问题那么我们这里先回顾下MVC设计模式里对控制层的定义维基百科里的定义是


控制器 Controller- 负责转发请求对请求进行处理

  不过这个解释我认为并不全面,以java的web开发里的控制层设计为例,我们发现控制层以沟通视图层和模型层的角度而言,控制层其实主要完成三项具体的工作,它 们分别是:

  工作一:控制层起到一个路由的作用。客户端请求到达控制层后,控制层根据请求内容将请求路由到服务端某个模型层进行处理,模型层将请求处理完毕后,会把响应结果返回给控制层,控制层在根据响应信息路由到特定的页面。

  工作二:控制层起到一个报文信息格式转化的作用。这里以java的web开发为例,浏览器的数据都是以http报文形式发送给服务端,而控制层就是将http报文信息解析成java的对象,当然也可以是java的基本数据类型,然后控制层把解析好的信息传递给模型层进行处理。

  工作三:传统的MVC**框架里,控制层其实深入参入到了页面渲染的操作。**在java的web开发里的控制层不管如何被包装,其本质就是一个servlet,而jsp页面本质也是个serlvet,因此我们可以这么理解jsp,jsp就是以页面开发的方式写java,而servlet就是以java的方式写页面,所以我们可以在servlet里以文件流的方式输出页面,也可以让servlet跳转到jsp页面。

  由上面的论述里我们发现,其实传统MVC框架里控制层和模型层的联系方式相对很简单的,它们的联系主要是路由和报文格式的转化上,而控制层与视图层的联系除此之外还多了一个页面渲染,而页面渲染本身应该是属于浏览器的技术范畴,是浏览器技术不可分割的一部分,也是我上面内容里诟病传统MVC框架问题所在,如果控制层承担了页面渲染工作,那么控制层和视图层的耦合度就变得非常高,要想将其解耦是十分困难,一般只有我们打破了现有MVC框架的技术体系才能完成,相比之下,控制层与模型层的解耦就显得容易多了。那么控制层与模型层如何解耦呢?具体如下:

  首先我们来解决下报文格式转化的问题,这个技术方案很简单就是借鉴http统一报文格式的特点,我们为控制层和模型层定义一套统一的报文格式,例如我们定义控制层 和模型层都以map的数据类型进行数据传递,这个map里有个专门的字段用来定义被路由到的模型接口信息,有个字段专门存储需要传递的数据,具体的设计方案可以根据实 际的业务需要来设计。

  接下来就是路由的问题了,在解决报文格式转化问题的论述里我讲到要在统一报文格式里专门定义一个字段用来存储该数据到底路由到哪个模型进行处理,不过这个字段并不能完全解决路由问题,因此我们需要模型层对控制层提供一个统一的接口,任何控制层与模型层的沟通都通过这个统一接口来完成,只不过不同请求报文组装的内容不一样而已,而这个接口还有个重要职责就是解析报文里的路由信息,让请求能被正确的路由到对应的模型接口所处理。当然这个接口的返回值最好也是一个统一的报文格式,这样控制层解析模型层的返回数据也会便利的多了。

  由上所述,我们发现第一阶段的前后端分离工作控制层应该归属于web前端,这么做更加合理,也更加容易实现,其实之后进化版的前后端分离方案,控制层也都是属于w eb前端,只不过形式不同而已,这个我在下一篇文章里继续讨论。

  第一阶段前后端分离方案解决的核心就是让控制层和模型层解耦,这个方案进一步演化一下,我们可以把控制层和视图层独立成一个web应用,模型层也独立成一个web 应用,两个web应用之间通过远程调用方式进行沟通,这个方案我在以前文章里写过,这篇文章的名字叫做《我设计的网站的分布式架构》。

  这个进化版的方案增加了系统开发的难度,因为我们需要增加网络通信的编程以及远程调用的实现,更麻烦的是我们还需要进行复杂的多线程编程,既然增加了开发的难度为什么我还要这么做呢?首先我们通过应用分层,可以动态的调节web前端和web服务端的负载压力,还可以在模型层之前提供一道安全屏障,不过被服务端绑架的web前端 在提升整个web应用负载能力这块还是很有限的,其实这种做法的最大好处就是利于SOA框架的设计,也就是说这种架构我们可以为服务端的SOA化提供有力的保障,因为控制层和模型层的解耦,可以让模型层真正做到专注于业务,而不会再发生那种把业务逻辑写到控制层的问题了从而降低代码的健壮性。

 


原文出处:一个简单粗暴的前后端分离方案

项目背景

刚刚参加完一个项目,背景:后端是用java,后端服务已经开发的差不多了,现在要通过web的方式对外提供服务,也就是B/S架构。后端专注做业务逻辑,不想在后端做页面渲染的事情,只向前端提供数据接口。于是协商后打算将前后端完全分离,页面上的所有数据都通过ajax向后端取,页面渲染的事情完全由前端来做。另外还有一个紧急的情况,项目要紧急上线,整个web站点的开发时间只有两周,两周啊!于是在这样的背景下,决定开始一次前后端完全分离的尝试。

之前开发都是同步渲染和异步渲染混搭的,有些东西可以有后端PHP帮你编译好,如通用的页面模板,后端传回的页面参数等。提前预感到这次完全分离可能会遇到一些困难,但是项目上线要紧,也不能深入搞架构,于是打算就用jQuery+handlebars,jQuery来完成页面逻辑和DOM操作,用handlebars来完成页面渲染,这个方案是如此的简单粗暴,但好处能最稳妥的保证项目按期完成。其实前后端分离并不是一件容易的工作,这么做会有诸多不完善之处,后面再谈。

浅谈前后端分离

所谓的前后端分离,到底是分离什么呢?其实就是页面的渲染工作,之前是后端渲染好页面,交给前端来显示,分离后前端需要自己拼装html代码,然后再显示。前端来管理页面的渲染有很多好处,比如减少网络请求量,制作单页面应用等。事情听起来简单,但这么一分离又会牵扯到很多问题,比如:

以上每一个问题都够棘手,要处理好需要有设计精良又符合实际项目的方案。现在已经有很多框架可以帮我们做这些事情,Backbone, EmberJS, KnockoutJS, AngularJS, React, avalon等等,利用它们可以架构起一个富前端。但框架毕竟是框架,要利用到实际项目中,还是需要有自己的设计,框架并不能解决所有的问题。

之前也有看过淘宝团队的实践,利用nodejs做一个中间层,处理页面渲染、路由控制、SEO等事情,将前后端的分界线进行了重新定义。个人感觉这应该是一个正确的方向,有点颠覆的感觉,前端走向工程化,将变成真正的全栈式大前端。不知现在这种架构是否在淘宝全面铺开,真有点期待看看效果。

以上的框架,还有淘宝的实践,毕竟都是大牛之作,我这个小辈也只是参考学习过,未能在实际项目中使用。低头看看自己现在手头的项目,1个前端,2周时间,要完成一个完整的web项目,还是用最稳妥最低级的方式来搞吧~

基本结构

项目整体并不是一个单页应用,但有些模块需要做成局部的单页操作,像这种需要分步完成的操作,只需局部加载子页面即可。

  

因此,一个模块有一个主html页面,初始只有一些基本的骨架,有一个名字相同的js文件,该模块逻辑都在此js文件中,有一个名字相同的css文件,该模块的所有样式都定义在此css文件中。

需要异步加载的子页面,像上图中每个步骤的页面,我都使用jQuery的$.load()方法来加载,此方法能在页面某个容器中加载内容,并可指定回调函数,使用起来很方便。被异步加载的子页面我都用_开头,如_step1.html,用于做区分。

为了确保浏览器的前进后退按钮可用,我使用了hash来做路由标记,页面地址如:publish.html#step2。有个缺陷是hash并不会发送给服务器,所以SEO就废了。事实上使用history API也可以更优雅的解决问题,但需要考虑兼容性,还有额外工作要做,考虑时间因素,退而求其次,况且本项目也无需做SEO。或者像淘宝的方案那样,nodejs层与浏览器层统一路由,SEO问题可以迎刃而解。但又明显不在本人的实力范围之内,汗--!

除了用$.load异步加载的子页面,剩余的局部页面就是用handlebars提供的模板渲染了,我使用了handlebars的预编译功能,不得不说很强大,一来节约了页面加载阶段所需的编译时间(编译handlebars模板),二来编译后的模板(js文件)方便复用。

接下来就是前端逻辑如何组织,因为没有用mv*框架,所以只能靠自己来写一个便于开发的结构。如上面所述,每个模块有一个主js文件,文件内容结构如下:

var publish = {
     //该模块初始化入口
     init : function(){
          this.renderData(param);
          this.initListeners();
     },
     //内部所用的函数
     renderData : function(param){
          //渲染数据。。
     },
     //统一绑定监听器
     initListeners : function(){
          $(document.body).delegates({
               '.btn' : function(){
                    //点击事件
               },
               '.btn2' : function(){
                    //点击事件2
               },
               '.checkbox' : {
                    'change' : function(){
                         //change事件
                    }
               }
          });
     }
}

每个模块给一个命名空间,所有的方法都挂在上面,js文件中只做函数的定义,不立即执行任何东西,然后在html文件中调用入口方法:publish.init()。业务逻辑都封装到函数中,如上面的renderData,然后供其他地方调用。页面的事件监听器统一都注册在body元素上,用事件代理来完成,为了避免写太多的on、click之类代码,为jQuery扩展了一个delegates方法,用来以配置的方式统一绑定监听器,用法如上所示。把delegates定义的代码也放出来吧:

//以配置的方式代理事件
$.fn.delegates = function(configs) {
     el = $(this[0]);
     for (var name in configs) {
          var value = configs[name];
          if (typeof value == 'function') {
               var obj = {};
               obj.click = value;
               value = obj;
          };
          for (var type in value) {
               el.delegate(name, type, value[type]);
          }
     }
     return this;
}

基本的结构就是这样,没有什么新技术,只是把现有的东西做了一下组合。但工作到此还远远没有结束,在实际应用中还会有一些东西需要处理,下面来详细说说:

公共头部底部的引用

这是一个比较棘手的问题,一般通用的头部和底部会放一些公共的代码,如页面外层结构html代码,站点使用的库如jQuery、handlebars,站点通用js和css文件。在传统的开发中,通常是写一个单独的文件如head.html,在其他页面中用后端代码如include语句引入,由此来进行复用。

现在前后端分离后,无法依靠后端来给你渲染,所以得在前端做了。既然用了handlebars,很容易想到把公用部分写成一个模板,然后预编译出来,生成一个header.js文件,然后在其他页面引用。然而在实际操作中发现了一个问题,handlebars是静态模板,编译后生成的字符串通过innerHTML的方式插入到页面,在一般的模板中这样是没问题的。现在有个问题是header中有一些script标签,外链着要使用的库,通过innerHTML插入scirpt标签,浏览器并不会发送请求加载对应的js文件,所以就出问题了。

搜索、尝试了多种方法后,最终的方案定为:用document.write()将编译结果写到页面,这样script标签能够正常加载。所以每个页面使用头部的代码就变成这样:

<script src="static/js/tpl/head.js"></script>
 <div id="header">
      <script src="static/js/includeHead.js"></script>
 </div>

includeHead.js中的代码如下:

function includeHead(){
     var header = document.getElementById('header');
     var compileHead = Handlebars.templates['head'];
     var head = compileHead({});
     document.write(head);
}
includeHead();

看着是有点别扭,不过为了实现功能,目前也就只能这样了。

----------补充于 2015.1.27---------------

虽然用原生的innerHTML无法加载scrip标签中的内容,但是jQuery的$().html()方法进行了优化,可以查找到script标签并且执行里面的代码,所以用$().html()是可以完成上面的工作的。

  这么一看,这个蹩脚的方案就可以替换了。

路由控制

如上面所述,jQuery的$.load()方法可以满足加载子页面的需求,现在需要解决的问题是,不管用户刷新页面还是前进后退,我们都得根据hash值来渲染对应的视图,其实就是路由控制。这个时候就需要监听hashchange事件了,我定义了一个loadPage方法用来加载子页面,然后绑定监听器如下:

window.onhashchange = this.loadPage;

在loadPage方法中,根据hash的值来调用$.load()方法,子页面的初始化工作,在$.load()的回调函数中指定。

这样做还有一个便捷之处,我们切换视图不必手动调loadPage方法,只需要修改页面的hash就可以了,hash发生变化被监听到,自动加载对应的子页面。例如,点击下一步进入步骤二:

'.next' : function(){
        location.href = '#step2';
}

如此便实现了一个简单的路由控制,由于不是整站单页面,也没有多级路由,这样完全可以满足需求。至于SEO,就只能呵呵了,正好项目也不需要做SEO,否则此方法得作罢。

另外想说的一点就是页面的缓存,异步加载来的内容可以存在localStorage中,也可以放在页面上进行显隐控制,这样用户在频繁切换视图的时候无需再次请求,回到上一步的时候之前填好的表单数据也不会消失,体验会非常好。

页面间参数传递

有时候我们需要给访问的页面传参数,比如访问一个设备的详细信息页,要把设备id给传过去,detail.html?id=1,这样detail页面可以根据id去请求对应的数据。传统由后端渲染的页面,url中的参数会发送到服务端,服务端接收后可以再渲染到页面上供js使用。我们现在不行了,请求页面压根不跟后端打交道,但这个参数是必不可少的,所以需要前端有一套传递参数的机制。

其实非常简单,通过location.href可以拿到当前的url地址,然后进行字符串匹配,把参数提取出来就可以了。看上去挺土鳖的,但工作起来良好,另外也有考虑过用cookie来传递,感觉有点麻烦。

由于这些参数通常是写在a标签上的,而a标签又是根据动态数据渲染出来的(因为是动态参数),我们不可能在页面渲染完后,用js修改所有a标签的href值,给它追加一个参数。怎么办呢?这时候handlebars就派上用场了,我们可以使用handlebars万能的helper,在渲染页面的时候直接查询url中的参数,然后输出在编译好的代码中。我在handlebars中注册了一个helper,如下:

Handlebars.registerHelper('param', function(key, options){
    var url = location.href.replace(/^[^?=]*\?/ig, '').split('#')[0];
    var json = {};
    url.replace(/(^|&)([^&=]+)=([^&]*)/g, function (a, b, key , value){
        try {
            key = decodeURIComponent(key);
        } catch(e) {}
        try {
            value = decodeURIComponent(value);
        } catch(e) {}
        if (!(key in json)) {
            json[key] = /\[\]$/.test(key) ? [value] : value;
        }
        else if (json[key] instanceof Array) {
            json[key].push(value);
        }
        else {
            json[key] = [json[key], value];
        }
    });
    return key ? json[key] : json;
});

这个名为param的helper可以输出你所要查询的参数值然后可以直接写在模板中


<a href="detail.html?id={{param id}}">设备详细信息</a>

这样就方便多了!但是这么做有没有问题呢?其实是有些不完美的,如果你考虑"性能"二字的话。一个url中参数的值是固定的,而你每次使用这个helper都会计算一遍,白白做了多余的事情。如果handlebars可以在模板中定义常量就好了,可惜我找遍文档没发现有这个功能。只能为了方便牺牲性能了,也正印证了我标题中所说的"简单粗暴",呵呵。

数据的校验和处理

由于数据是由后端传来的,有很多不确定性,数据可能不合法,或者结构有错,或者直接是空的。因此前端有必要对数据做一个合法性的校验。借助handlebars,可以很方便的进行数据校验。没错,就是利用helper。handlebars内置的helper如if、each都支持else语句,出错信息可以在else中输出。如果需要个性化的校验,我们可以自己定义helper来完成,关于如何自定义helper,我之前研究了下,写过一篇文章:http://www.cnblogs.com/lvdabao/p/handlebars_helper.html。总之自定义helper很强大,可以完成你所需的任何逻辑。

数据的格式化,如日期、数字等,也可以通过helper来完成。

另外一方面,前端还应对数据进行html转义,避免xss,由于handlebars已经给做了html转义,所以我们可以直接忽略此项了。

总结

本文是我刚刚参加完一个项目后所写,记录一下整个过程遇到的问题及处理方式,其他的一些细碎点如表单异步提交什么的,不是本文重点,不写了。这是我第一次实践前后端完全分离的项目,整个前端全由我来设计、开发。2周时间,凭着这套方案,项目按期开发完成,而且还提前完成了,预留出一天多的时间测试了一遍。

虽然开发任务是完成了,但是回头看一下整个方案,并不是很优雅也没有什么技术含量,文章开头提到的几个问题都没有解决。所以命题为简单粗暴的方案,都是为了赶工期啊。

最后,如果给我再来一次的机会,并且时间充足,我一定要尝试用mv*方案来搞一下,或angular,或avalon。