软件架构之前后端分离与前端模块化发展史

2022-11-30 15:45:34 浏览数 (2)

在现行的软件架构中,前端和后端是分离的,即前端只专注于页面渲染,而后台专注于业务逻辑,前端和后端是两个不同的工种,而前后端交互最常见的方式就是通过接口。

前后端分离架构

在正式说明前后台架构分离之前,我们来看一下多年之前,传统软件开发的架构模式。

为什么要前后端分离

还记得零几年我上大学的时候,在初学 Java Web 开发时,课本上介绍的还是 JSP Servlet 这种很传统的架构模式,这时候前端和后端业务逻辑代码都在一个工程里面,还没有分离开来,这种开发模式属于 Model1 模式,虽然实现了逻辑功能和显示功能的分离,但是由于视图层和控制层都是由 JSP 页面实现的,即视图层和控制层并没有实现分离。

随着学习的深入以及渐渐流行的企业应用开发,我们渐渐的摈弃这种技术选型,并开始在项目中使用了若干开源框架,常用的框架组合有 Spring Struts/Spring MVC Hibernate/Mybatis 等等,由于框架的优越性以及良好的封装性使得这套开发框架组合迅速成为各个企业开发中的不二之选,这些框架的出现也减少了开发者的重复编码工作,简化开发,加快开发进度,降低维护难度,随之而火热的是这套技术框架背后的开发模式,即 MVC 开发模式,它是为了克服 Model1 存在的不足而设计的。

MVC 的具体含义是:Model View Controller,即模型 视图 控制器,

  • Model 模型层: 它常常使用 JavaBean 来编写,它接受视图层请求的数据,然后进行相应的业务处理并返回最终的处理结果,它负担的责任最为核心,并利用 JavaBean 具有的特性实现了代码的重用和扩展以及给维护带来了方便。
  • View 视图层: 代表和用户交互的界面,负责数据的采集和展示,通常由 JSP 实现。
  • Controller 控制层: 控制层是从用户端接收请求,然后将请求传递给模型层并告诉模型层应该调用什么功能模块来处理该请求,它将协调视图层和模型层之间的工作,起到中间枢纽的作用,它一般交由 Servlet 来实现。

MVC的工作流程如下图所示。

同时,项目开发在进行模块分层时也会划分为三层:控制层,业务层,持久层。控制层负责接收参数,调用相关业务层,封装数据,以及路由并将数据渲染到 JSP 页面,然后在 JSP 页面中将后台的数据展现出来,相信大家对这种开发模式都十分熟悉,不管是企业开发或者是个人项目的搭建,这种开发模式都是大家的首选,不过,随着开发团队的扩大和项目架构的不断演进,这套开发模式渐渐有些力不从心。

接下来,我们来分析下这套开发模式的痛点。

痛点一:JSP 效率问题

首先,JSP 必须要在 Servlet 容器中运行(例如 Tomcat,jetty 等),在请求 JSP 时也需要进行一次编译过程,最后被译成 Java 类和 class 文件,这些都会占用 PermGen 空间,同时也需要一个新的类加载器加载,JSP 技术与 Java 语言和 Servlet 有强关联,在解耦上无法与模板引擎或者纯 html 页面相媲美。其次每次请求 JSP 后得到的响应都是 Servlet 通过输出流输出的 html 页面,效率上也没有直接使用 html 高。由于 JSP 与 Servlet 容器的强关联,在项目优化时也无法直接使用 Nginx 作为 JSP 的 web 服务器,性能提升不高。

痛点二:人员分工不明

在这种开发模式下的工作流程通常是:设计人员给出页面原型设计后,前端工程师只负责将设计图切成 html 页面,之后则需要由后端开发工程师来将 html 转为 JSP 页面进行逻辑处理和数据展示。在这种工作模式下,人为出错率较高,后端开发人员任务更重,修改问题时需要双方协同开发,效率低下,一旦出现问题后,前端开发人员面对的是充满标签和表达式的 JSP 页面,后端人员在面对样式或者交互的问题时本就造诣不高的前端技术也会捉襟见肘。

在某些紧急情况下也会出现前端人员调试后端代码,后端开发人员调试前端代码这些让人捧腹的现象,分工不明确,且沟通成本大,一旦某些功能需要返工则需要前后端开发人员,这种情况下,对于前后端人员的后期技术成长也不利,后端追求的是高并发、高可用、高性能、安全、架构优化等,前端追求的是模块化、组件整合、速度流畅、兼容性、用户体验等等,但是在 MVC 这种开发模式下显然会对这些技术人员都有一定的掣肘。

痛点三:不利于项目迭代

项目初期,为了快速上线应用,选择使用这种开发模式来进行 Java Web 项目的开发是非常正确的选择,此时流量不大,用户量也不高,并不会有非常苛刻的性能要求,但是随着项目的不断成长,用户量和请求压力也会不断扩大,对于互联网项目的性能要求是越来越高,如果此时的前后端模块依旧耦合在一起是非常不利于后续扩展的。举例说明一下,为了提高负载能力,我们会选择做集群来分担单个应用的压力,但是模块的耦合会使得性能的优化空间越来越低,因为单个项目会越来越大,不进行合理的拆分无法做到最好的优化,又或者在发版部署上线的时候,明明只改了后端的代码,前端也需要重新发布,或者明明只改了部分页面或者部分样式,后端代码也需要一起发布上线,这些都是耦合较严重时常见的不良现象,因此原始的前后端耦合在一起的架构模式已经逐渐不能满足项目的演进方向,需要需找一种解耦的方式替代当前的开发模式。

痛点四:不满足业务需求

随着公司业务的不断发展,仅仅只有浏览器端的 Web 应用已经逐渐显得有些不够用了,目前又是移动互联网急剧增长的时代,手机端的原生 App 应用已经非常成熟,随着 App 软件的大量普及越来越多的企业也加入到 App 软件开发当中来,为了尽可能的抢占商机和提升用户体验,你所在的公司可能也不会把所有的开发资源都放在 web 应用上,而是多端应用同时开发,此时公司的业务线可能就是如下的几种或者其中一部分:

浏览器端的 Web 应用、iOS 原生 App、安卓端原生 App、微信小程序等等,可能只是开发其中的一部分产品,但是除了 web 应用能够使用传统的 MVC 模式开发外,其他的都无法使用该模式进行开发,像原生 App 或者微信小程序都是通过调用 RESTful api 的方式与后端进行数据交互。

随着互联网技术的发展,更多的技术框架被提了出来,其中最革命性的就是前后端分离概念的提出。

什么是前后端分离

何为前后端分离,我认为应该从以下几个方面来理解。

前后端分离是一种项目开发模式

当业务变得越来越复杂或者产品线越来越多,原有的开发模式已经无法满足业务需求,当端上的产品越来越多,展现层的变化越来越快、越来越多,此时就应该进行前后端分离分层抽象,简化数据获取过程,比如目前比较常用的就是前端人员自行实现跳转逻辑和页面交互,后端只负责提供接口数据,二者之间通过调用 RESTful api 的方式来进行数据交互,如下图所示:

此时就不会出现 HTML 代码需要转成 JSP 进行开发的情况,前端项目只负责前端部分,并不会掺杂任何后端代码,这样的话代码不再耦合。同时,前端项目与后端项目也不会再出现耦合严重的现象,只要前后端协商和定义好接口规范及数据交互规范,双方就可以并行开发,互不干扰,业务也不会耦合,两端只通过接口来进行交互。

在 MVC 模式开发项目时,往往后端过重,“控制权”也比较大,既要负责处理业务逻辑、权限管理等后端操作,也需要处理页面跳转等逻辑,在前后端分离的模式中,后端由原来的大包大揽似的独裁者变成了接口提供者,而前端也不仅仅是原来那样仅处理小部分业务,页面跳转也不再由后端来处理和决定,整个项目的控制权已经由后端过渡至前端来掌控,前端需要处理的更多。

前端项目和后端项目隔离开来、互不干涉,通过接口和数据规范来完成项目功能需求,这也是目前比较流行的一种开发方式。

前后端分离是一种人员分工

在前后端分离的架构模式下,后台负责数据提供,前端负责显示交互,在这种开发模式下,前端开发人员和后端开发人员分工明确,职责划分十分清晰,双方各司其职,不会存在边界不清晰的地方,并且从业人员也各司其职。

前端开发人员包括 Web 开发人员、原生 App 开发人员,后端开发则是指 Java 开发人员(以 Java 语言为例),不同的开发人员只需要注重自己所负责的项目即可。后端专注于控制层(RESTful API)、服务层 、数据访问层,前端专注于前端控制层、 视图层,不会再出现前端人员需要维护部分后端代码,或者后端开发人员需要去调试样式等等职责不清和前后端耦合的情况,我们通过两张项目开发流程简图来对比:

此时,开发过程中会存在前后端耦合的情况,如果出现问题前端需要返工、后端也需要返工,开发效率会有所影响。现在,前后端分离后流程简图如下:

前后端分离后,服务器端开发人员和前端开发人员各干各的,大家互不干扰,。在设计完成后,Web 端开发人员、App 端开发人员、后端开发人员都可以投入到开发工作当中,能够做到并行开发,前端开发人员与后端开发人员职责分离,即使出现问题,也是修复各自的问题不会互相影响和耦合,开发效率高且满足企业对于多产品线的开发需求。

前后端分离是一种架构模式

前后端分离后,各端应用可以独立打包部署,并针对性的对部署方式进行优化,不再是前后端一个统一的工程最终打成一个部署包进行部署。以 Web 应用为例,前端项目部署后,不再依赖于 Servlet 容器,可以使用吞吐量更大的 Nginx 服务器,采用动静分离的部署方式,既提升了前端的访问体验,也减轻了后端服务器的压力,再进一步优化的话,可以使用页面缓存、浏览器缓存等设置,也可以使用 CDN 等产品提升静态资源的访问效率。对于后端服务而言,可以进行集群部署提升服务的响应效率,也可以进一步的进行服务化的拆分等等。前后端分离后的独立部署维护以及针对性的优化,可以加快整体响应速度和吞吐量。

前端发展历程

当我们去了解某个事物的时候,首先我们需要去了解它的历史,才能更好的把握它的未来。

原始时代

世界上第一款浏览器 NCSAMosaic ,是网景公司(Netscape)在1994年开发出来的,它的初衷是为了方便科研人员查阅资料、文档(这个时候的文档大多是图片形式的)。那个时代的每一个交互,按钮点击、表单提交,都需要等待浏览器响应很长时间,然后重新下载一个新页面。

同年 PHP(超文本预处理器) 脚本语言被开发出来,开启了数据嵌入模板的 MVC 模式,同时期比较类似的做法有以下几种:

  • PHP 直接将数据内嵌到 HTML 中。
  • ASP 的 ASPX,在 HTML 中嵌入 C# 代码。
  • Java 的 JSP 直接将数据嵌入到网页中。

这个时期,浏览器的开发者,以后台开发人员居多,大部分前后端开发是一体的,大致开发流程是:后端收到浏览器的请求 —> 发送静态页面 —> 发送到浏览器。即使是有专门的前端开发,也只是用 HTML 写写页面模板、CSS 给页面排个好看点的版式。在这一时期,前端的作用有限,往往只是切图仔的角色。

铁器时代

1995年,网景公司的一位叫布兰登·艾奇的大佬,希望开发出一个类似 Java 的脚本语言,用来提升浏览器的展示效果,增强动态交互能力。结果大佬喝着啤酒抽着烟,十来天就把这个脚本语言写出来了,功能很强大,就是语法一点都不像 Java。这样就渐渐形成了前端的雏形:HTML 为骨架,CSS 为外貌,JavaScript 为交互。

同时期微软等一些公司也针对自家浏览器开发出了自己的脚本语言。浏览器五花八门,虽然有了比较统一的 ECMA 标准,但是浏览器先于标准在市场上流行开来,成为了事实标准。导致,现在前端工程师还要在做一些政府古老项目的时候,还要去处理浏览器兼容(万恶的 IE 系列)。

不管怎么说,前端开发也算是能写点逻辑代码了,不再是只能画画页面的低端开发了。随着1998年 AJax 的出现,前端开发从 Web1.0迈向了Web2.0,前端从纯内容的静态展示,发展到了动态网页,富交互,前端数据处理的新时期。这一时期,比较知名的两个富交互动态的浏览器产品是。

  • Gmail(2004年)
  • Google 地图(2005年)

由于动态交互、数据交互的需求增多,还衍生出了jQuery(2006) 这样优秀的跨浏览器的 js 工具库,主要用于 DOM 操作,数据交互。有些古老的项目,甚至近几年开发的大型项目现在还在使用 jQuery,以至于 jQuery 库现在还在更新,虽然体量上已经远远不及 React、Vue 这些优秀的前端库。

信息时代

自 2003 以后,前端发展渡过了一段比较平稳的时期,各大浏览器厂商除了按部就班的更新自己的浏览器产品之外,没有再作妖搞点其他事情。但是我们程序员们耐不住寂寞啊,工业化推动了信息化的快速到来,浏览器呈现的数据量越来越大,网页动态交互的需求越来越多,JavaScript 通过操作 DOM 的弊端和瓶颈越来越明显(频繁的交互操作,导致页面会很卡顿),仅仅从代码层面去提升页面性能,变得越来越难。于是优秀的大佬们又干了点惊天动地的小事儿:

  • 2008 年,谷歌 V8 引擎发布,终结微软 IE 时代。
  • 2009 年 AngularJS 诞生、Node诞生。
  • 2011 年 ReactJS 诞生。
  • 2014 年 VueJS 诞生。

其中,V8 和 Node.JS 的出现,使前端开发人员可以用熟悉的语法糖编写后台系统,为前端提供了使用同一语言的实现全栈开发的机会(JavaScript不再是一个被嘲笑只能写写页面交互的脚本语言)。React、Angular、Vue 等 MVVM 前端框架的出现,使前端实现了项目真正的应用化(SPA单页面应用),不再依赖后台开发人员处理页面路由 Controller,实现页面跳转的自我管理。同时也推动了前后端的彻底分离(前端项目独立部署,不再依赖类似的 template 文件目录)。

至于为啥 MVVM 框架能提升前端的渲染性能,这里简单的说一下原理,因为大量的 DOM 操作是性能瓶颈的罪魁祸首,那通过一定的分析比较算法,实现同等效果下的最小 DOM 开销是可行的。React、Vue 这类框架大都是通过这类思想实现的,具体实现可以去看一下相关资料。前后端分离也导致前端的分工发生了一些变化。

![在这里插入图片描述](https://img-blog.csdnimg.cn/20200823224633557.png

而后端开发更加关注数据服务,前端则负责展示和交互。当然相应的学习成本也越来越大,Node.JS的出现也使得前端前后端一起开发成为可能,好多大公司在 2015 年前后就进行了尝试,用 Node.JS 作为中间数据转接层,让后端更加专注于数据服务和治理。

前端模块化发展历程

自 2009 年 5 月 Node.js 发布以来,前端能干的事情越来越多。短短 10 来年的时间,前端便从刀耕火种的年代走向了模块化、工程化的时代。各种前端框架百家争鸣,前端赢来了真正属于自己的时代。

原始时代

时间回到 2009年,记得那时候还没有流行前后端分离,很多项目还是混在一起,而那时候的前端开发人员大多数也都是“切图仔”。前端完成静态页面,由服务端同事完成数据的嵌入,也就是所谓的套页面操作,每当有类似的功能,都会回到之前的页面去复制粘贴,由于处于不同的页面,类名需要更换,但是换汤不换药。

久而久之,重复代码越来越多,但凡改动一个小的地方,都需要改动很多代码,显得极不方便,也不利于大规模的进行工程化开发。虽然市面上也慢慢出现了 Angular、 Avalon 等优秀的前端框架,但是考虑到 SEO 和维护人员并不好招,很多公司还是选择求稳,用套页面的形式制作网页,这对前端的工程化、模块化是一个不小的阻碍。

构建工具的出现

不过,随着 Node 被大力推崇,市面上涌现出大量的构建工具,如 Npm Scripts、Grunt、Gulp、FIS、Webpack、Rollup、Parcel等等。构建工具解放了我们的双手,帮我们处理一些重复的机械劳动。

举个简单的例子:我们用 ES6 写了一段代码,需要在浏览器执行。但是由于浏览器厂商对浏览器的更新非常保守,使得很多 ES6 的代码并不能直接在浏览器上运行。这个时候我们总不能手动将 ES6 代码改成 ES5 的代码。于是乎就有了下面的转换。

代码语言:javascript复制
//编译前
[1,2,3].map(item => console.log(item))
//编译后
[1, 2, 3].map(function (item) {
  return console.log(item);
});
//代码压缩后
[1,2,3].map(function(a){return console.log(a)});

就是做了上述的操作,才能使得我们在写前端代码的时候,使用最新的 ECMAScript 语法,并且尽可能的压缩代码的体积,使得浏览器加载静态脚本时能更加快速。

传统的模块化

随着 Ajax 的流行,前端工程师能做的事情就不只是“切图” 这么简单,现在前端工程师能做的越来越多,开始出现了明确的分工,并且能够与服务端工程师进行数据联调。这里说的传统模块化还不是后现代的模块化,早期的模块化是不借助任何工具的,纯属由 JavaScript 完成代码的结构化。在传统的模块化中我们主要是将一些能够复用的代码抽成公共方法,以便统一维护和管理,比如下面代码。

代码语言:javascript复制
function show(id) {
  document.getElementById(id).setAttribute('style', "display: block")
}
function hide(id) {
  document.getElementById(id).setAttribute('style', "display: none")
}

然后,我们将这些工具函数封装到一个 JS 脚本文件里,在需要使用它们的地方进行引入。

代码语言:javascript复制
<script scr="./utils.js"></script>

但是,这种做法会衍生出两个很大的问题,一个是全局变量的污染,另一个是人工维护模块之间的依赖关系会造成代码的混乱。

例如,当我们的项目有十几个甚至几十个人维护的时候,难免会有人在公用组件中添加新的方法,比如 show 这个方法一旦被覆盖了,使用它的人会得到和预期不同的结果,这样就造成的全局变量的污染。另一个问题,因为真实项目中的公用脚本之间的依赖关系是比较复杂的,比如 c 脚本依赖 b 脚本,a 脚本依赖 b 脚本,那么我们在引入的时候就要注意必须要这样引入。

代码语言:javascript复制
<script scr="c.js"></script>
<script scr="b.js"></script>
<script scr="a.js"></script>

要这样引入才能保证 a 脚本的正常运行,否则就会报错。对于这类问题,我们该如何解决这样的问题呢?

全局变量的污染

解决这个问题有两种,先说说治标不治本的方法,我们通过团队规范开发文档,比如说我有个方法,是在购物车模块中使用的,可以如下书写。

代码语言:javascript复制
var shop.cart.utils = {
  show: function(id) {
    document.getElementById(id).setAttribute('style', "display: block")
  },
  hide: function(id) {
    document.getElementById(id).setAttribute('style', "display: none")
  }
}

这样就能比较有效的避开全局变量的污染,把方法写到对象里,再通过对象去调用。专业术语上这叫命名空间的规范,但是这样模块多了变量名会比较累赘,一写就是一长串,所以我叫它治标不治本。

还有一种比较专业的方法技术通过立即执行函数完成闭包封装,为了解决封装内变量的问题,立即执行函数是个很好的办法,这也是早期很多开发正在使用的方式,如下所示。

代码语言:javascript复制
(function() { 
   var Cart = Cart || {};
   function show (id) {
     document.getElementById(id).setAttribute('style', "display: block")
   }
   function hide (id) {
     document.getElementById(id).setAttribute('style', "display: none")
   }
   Cart.Util = {
     show: show,
     hide: hide
   }
})();

上述代码,通过一个立即执行函数,给予了模块的独立作用域,同时通过全局变量配置了我们的模块,达到了模块化的目的。

当前的模块化方案

先来说说 CommonJS 规范,在 Node.JS 发布之后,CommonJS 模块化规范就被用在了项目开发中,它有几个概念给大家解释一下。

  • 每个文件都是一个模块,它都有属于自己的作用域,内部定义的变量、函数都是私有的,对外是不可见的;
  • 每个模块内部的 module 变量代表当前模块,这个变量是一个对象;
  • module 的 exports 属性是对外的接口,加载某个模块其实就是在加载模块的 module.exports 属性;
  • 使用 require 关键字加载对应的模块,require 的基本功能就是读入并执行一个 JavaScript 文件,然后返回改模块的 exports 对象,如果没有的话会报错的;

下面来看一下示例,我们就将上面提到过的代码通过 CommonJS 模块化。

代码语言:javascript复制
module.exports = {
  show: function (id) {
    document.getElementById(id).setAttribute('style', "display: block")
  },
  hide: function (id) {
    document.getElementById(id).setAttribute('style', "display: none")
  }
}
// 也可以输出单个方法
module.exports.show = function (id) {
  document.getElementById(id).setAttribute('style', "display: block")
}

// 引入的方式
var utils = require('./utils')
// 使用它
utils.show("body")

除了 CommonJS 规范外,还有几个现在只能在老项目里才能看到的模块化模式,比如以 require.js 为代表的 AMD(Asynchronous Module Definition) 规范 和 玉伯团队写的 sea.js 为代表的 CMD(Common Module Definition) 规范。 AMD 的特点:是一步加载模块,但是前提是一开始就要将所有的依赖项加载完全。CMD 的特点是:依赖延迟,在需要的时候才去加载。

AMD

首先,我们来看一下如何通过 AMD 规范的 require.js 书写上述模块化代码。

代码语言:javascript复制
define(['home'], function(){
  function show(id) {
    document.getElementById(id).setAttribute('style', "display: block")
  }
    function hide(id) {
    document.getElementById(id).setAttribute('style', "display: none")
  }
  return {
    show: show,
    hide: hide
  };
});

// 加载模块
require(['utils'], function (cart){
  cart.show('body');
});

require.js 定义了一个函数 define,它是全局变量,用来定义模块,它的语法规范如下:

define(id, dependencies, factory)

  • id:它是可选参数,用于标识模块;
  • dependencies:当前模块所依赖的模块名称数组,如上述模块依赖 home 模块,这就解决了之前说的模块之间依赖关系换乱的问题,通过这个参数可以将前置依赖模块加载进来;
  • factory:模块初始化要执行的函数或对象。

require([dependencies], function(){})

然后,在其他文件中使用 require 进行引入,第一个参数为需要依赖的模块数组,第二个参数为一个回调函数,当前面的依赖模块被加载成功之后,回调函数会被执行,加载进来的模块将会以参数的形式传入函数内,以便进行其他操作。

CMD

sea.js 和 require.js 解决的问题其实是一样的,只是运行的机制不同,遵循的是就近依赖,来看下使用CMD方式实现的模块化代码。

代码语言:javascript复制
define(function(require, exports, module) {
  function show(id) {
    document.getElementById(id).setAttribute('style', "display: block")
  }
  exports.show = show
});

<script type="text/javascript" src="sea.js"></script>
<script type="text/javascript">
  // 引入模块通过seajs.use,然后可以在回调函数内使用上面模块导出的方法
  seajs.use('./utils.js',function (show) {
        show('#box');
  }); 
</script>

首先是引入 sea.js 库,定义和导出模块分别是 define() 和 exports,可以在定义模块的时候通过 require 参数手动引入需要依赖的模块,使用模块通过 seajs.use。

ES6

ES6 提出了最新的模块化方案,并且引入了类的机制,让 JavaScript 从早期的表单验证脚本语言摇身一变成了一个面向对象的语言了。ES6 的模块化使用的是 import/export 关键字来实现导入和导出,并且自动采用的是严格模式(use strict),考虑到都是运行在模块之中,所以 ES6 实际上把整个语言都升到了严格模式。

在 ES6 中每一个模块即是一个文件,在文件中定义变量、函数、对象在外部是无法获取的。如果想要获取模块内的内容,就必须使用 export 关键字来对其进行暴露。我们把之前的公用脚本用 ES6 的形式再重构一遍。

代码语言:javascript复制
// utils.js
const show = () => {
  document.getElementById(id).setAttribute('style', 'display: block');
}
const hide = () => {
  document.getElementById(id).setAttribute('style', 'display: none');
}

export {
    show,
  hide
}
// 或者直接抛出方法
export const show = (id) => {
  document.getElementById(id).setAttribute('style', 'display: block');
}
export const hide = (id) => {
  document.getElementById(id).setAttribute('style', 'display: none');
}

// 外部引入模块
import { show, hide } from './utils'

可以发现,ES6 的写法更加清晰。具备了面向对象和面向函数的特征,可读性更强。

0 人点赞