在现代前端应用的工程实践中,前后端分离的架构会为两端带来更多的灵活性,已成为主流趋势。与之相对的,传统的单体 Web 应用(Monolithic Web Application)则将前后端代码放在一起,虽然耦合性较强,但在产品研发的特定阶段仍具有较强的优势,早期的 ASP.NET、Java Spring MVC,以及 Ruby On Rails 都是单体应用的代表性框架。
本文将以 FreeWheel 从单体应用改造为前后端分离的实践为例,着重介绍其间前端所遇到的挑战和解决方案。
相较消费者,商业用户对前端应用的需求更具复杂性,且更强调质量。FreeWheel 深耕企业级的视频广告领域 10 年,其基于 Ruby On Rails 框架为广告主打造的 Web 管理应用已经历多轮迭代和演进,目前已达到 20 多个产品模块,1200+ 页面,代码量已达到 143.5 万行代码,其中包含 39 万行基于 jQuery 的传统 JS 代码。为保证其质量,其中包含了 20.2 万行单元测试代码,除此以外,还有独立的近 2 万个自动化测试脚本。在两年前,我们感受到了单体应用的局限性,并决定将其改造成为前后端分离的架构。
技术选型
FreeWheel 前端展现的业务多种多样,但其用户体验强调高效性和高一致性。为辅助业务研发团队进一步提升前端开发的效率和效果,我们在改造前期订立了组件化的目标,力求将统一的用户体验和复杂的内部交互逻辑封装进组件,通过自动化测试保证其质量,并最终在业务模块中广泛复用。
针对以上目标,我们选择 React 作为新前端核心技术,以 ES6 作为开发语言,利用 Webpack 和 Babel 进行编译打包。以 Mocha 全家桶加 Enzyme 作为单元测试框架保证组件质量。每个业务模块均开发、打包并发布为一个独立的 SPA(Single Page Application,单页应用),多个模块 SPA 之间,除了以统一的 SSO 服务保证用户认证外,并无更多的耦合,这一点保证了多个业务模块团队的工作不会互相制约。
在单向数据流框架选择上,我们基于 Facebook 的 Flux 推进了相当长的一段时间。在上线两个业务模块后,我们认识到 FreeWheel 的业务对前端数据流需求的复杂度远高于常见的 TodoMVC 样例,Flux 实现这些需求时会遇到较多困难。我们评估了当时的社区新秀 Redux,它能一定程度缓解我们遇到的问题但仍有局限性。我们最终决定以 Redux 和 ImmutableJS 为基础,开发一套新的单向数据流框架 spark-modula。这点在下一节会有详细描述。
类似的还有前端路由。我们初期的选型是 react-router,随后根据项目需要开发了新的前端路由框架 spark-router。
更详细的前端框架选型如下:
至于后端,我们的选型是以 Golang 开发的微服务。借此契机,团队将原来内置于单体应用中的后端服务重新做了一次梳理,并逐步重构成微服务架构中的若干个微服务。前端在通过 SSO 验证后,以 JSON 格式与微服务交换数据。这些微服务除了满足前端使用,也会通过 gateway 作为 API 暴露给我们的客户,更会为公司内部的其他微服务提供基础。后端架构不是本文重点,故不赘述,有兴趣的读者请参见 FreeWheel 发表的其他文章。
新轮子 SparkUI
为了推进前后端分离改造,我们成立了一个专门的前端小团队,与业务模块开发团队紧密合作,经历数十个迭代,开发并完善了一套基于 React 的前端框架,内部名称为“SparkUI”(这一名称与 Apache Spark 或 Java Spark 无关)。下图是 SparkUI 框架的简要架构:
其中上游的 React、Redux、ImmutableJS 等框架为 SparkUI 的直接依赖,下游的 Business Components 业务组件、Business Modules 业务模块则为基于 SparkUI 框架开发业务代码的产出;衔接上下游的,则是 SparkUI 的核心组成部分。
可重用组件 Library Components
SparkUI 截止至截稿日已积累了 40 个子 package,其中很大一部分为可重用的 UI 组件,我们称之为 Library Components,例如 spark-loading、spark-calendar、spark-raw-grid 等。凡是业务模块提出的对前端组件的需求,只要与业务并不直接相关的,我们都会设计并迭代开发相应的可重用组件。
我们在设计可重用组件时,遵循的一些要点包括:
- 无状态组件(Stateless Component)优于状态化组件(Stateful Component);
- 组合组件(Composing Components)优于具有 DSL(Domain Specific Language)属性的单一组件;
- 高阶组件(HOC,Higher-Order Component)优于混合属性(Mixins)。
应用状态管理框架 spark-modula
上一节提到 Flux 所提供的单向数据流不能完全满足我们的业务需求。我们在对比了 Flux 和 Redux 后,决定自主开发一只新轮子。当时面对的挑战包括但不限于:
- 在 Ruby On Rails 应用中,开发团队曾设计并开发了大量的 Model,这不仅是因为要遵守 RoR 的 MVC 实践,更是因为业务的复杂程度客观要求有完整的建模,并基于模型推进前端的开发。我们的新轮子需要以类似的方式来消化业务的复杂性;
- 在业务的前端需求中,常常有一个页面内包含 2 个甚至多个 Grid,这些 Grid 之间会互相影响。比如一个典型场景:“Grid A 在自动加载后,如果只包含一条记录,则自动选中这条记录,并按该记录 ID 读取 Grid B”。这样的交互在 React 社区被称为副作用即 Side Effects。我们的新轮子需要用相对简单的方式支持 Side Effects 的处理。
这只应用状态管理的新轮子我们起名为 Modula,并入 spark-modula 包。经过快速迭代,Modula 框架已正式替代早期的 Flux,应用于业务模块开发。Modula 包括 Model 模型、Constants 常量、Container 容器、Test Utility 测试工具四个组成部分,其中 Model 包含 Props/Hierarchy、Context、Sender/Receiver、Delegates、Bubble Event、Lifecycle Methods、Services、Local Props 等概念/API。以下是一个典型的 Model 例子:
可以看出上半部分相当于 Model 的 schema,Props/Hierarchy/Context 基于 Immutable 数据结构实现了数据模型;而下半部分相当于 Model 的行为,Sender/Receiver + Modula Container 实现了单向数据流。
Modula 框架基于 Redux 但并不限于 Redux,与部分 Redux 生态(如 redux-devtools)兼容,且已完整封装并隐藏了底层的 Redux。
关于 SparkUI 更完整的介绍,请参见后续更详细的文章。
前后端整合
从单体应用改造成前后端分离的架构后,理想状态下,前后端可以分别独立开发、测试、部署,然而若想实现整体业务,则需要将前后两端整合。本节将介绍我们开展改造工作以来,在前后端整合领域积累的部分最佳实践。
RESTful 接口
后端接口均按照社区 RESTful 接口标准定义:
- 语义化 URL,活用 GET/POST/PUT/DELETE 四种 HTTP 方法;
- 支持 JSON 与 XML 两种数据呈现格式,默认情况下,HTTP 请求和响应均使用 JSON,加入 XML 参数,请求和响应改为使用 XML;
- 优先使用 HTTP 状态码(Status Code)表现后端成功状态或各类常见错误,如 HTTP 200(OK)、401(Unauthorized)、422(Unprocessable Entity) 等;
- 统一业务错误码和错误消息;
- 以 ISO 8061 标准输入输出日期时间,如:2015-09-08T01:55:28Z。
在前端我们基于浏览器 fetch 接口,封装了 spark-fetch 包,提供如下功能:
- 浏览器 fetch 的所有功能;
- JSON 序列化、反序列化;
- 为 HTTP 错误统一显示对话框,其中 401 状态会跳转至登录页面;
- 根据用户需要缓存特定资源;
- 防止 Cross-Site Request Forgery (CSRF)。
我们为前端开发了一套简单的 Discover 服务发现,以 key-value 方式描述前端中会用到的 RESTful 服务,spark-fetch 包在发起 HTTP 请求时只要传入 key 和相关参数即可。目前主要用来防止前端代码里 hard-code 服务 URL,之后会与整个公司级别的服务发现整合起来。
除此之外,我们还在后端开发了一套 API Gateway,提供认证(authentication)、限流(throttling)、跨域等公共功能。上述 RESTful 接口本身无须处理认证等逻辑。在部署后端服务后,只有 API Gateway 开放给外网访问,其他 RESTful 接口均限于机房内网访问,经由 API Gateway 的反向代理提供给外网。即前端在调用这些接口时,必须经过 API Gateway 调用。
认证授权
文章一开始提到的单体 Web 应用其实在 FreeWheel 有多套,分别对应于多个业务线或产品线。这些单体应用开发的阶段有先有后,架构和实现的设计也存在着差别,其中很重要的一点就是认证方式的差别,为了满足多个应用联合登录的需求,尤其是向后兼容 SPA 的联合登录,我们在后端以 Golang 开发了新的 SSO 服务。SPA 在登录页面调用 SSO 接口,登录成功则获取 token 并存入 cookie,这样后续的接口请求就会将 cookie 传入 API Gateway 以获取认证信息。
至于授权(authorization),我们在现有的 Ruby On Rails 应用中大部分是基于 CanCan 框架实现的,改造为前后端分离架构后,我们将与导航、功能入口相关的授权信息从后端完整传回前端,用前端代码判断特定导航或组件是否显示、是否禁用。当然,RESTful 接口中仍有完整的授权判断逻辑。如果有恶意用户通过 hack 的方式修改了前端授权信息访问了本不能访问的界面,他依旧无法获得列表数据、也无法提交数据修改。
后端 Docker 容器化
在业务模块开发过程中,开发人员需要在开发前端代码的同时能访问到后端接口及测试数据。如果是单体应用的开发,开发人员只要配置一套开发环境即可达到这个目标,但在前后端分离后,前端开发人员除了配置前端开发环境,还要配置后端。后端代码有更新时,需要及时检出代码并顺利编译,数据库有更新时也需要执行相应的 SQL 脚本。这些日常工作成为前端开发人员的痛点。
后端 Docker 容器化有效解决了这一痛点。我们目前的 CI (Continuous Integration) Pipeline 会在后端代码检入远程 Git 后触发编译,编译成功后会创建一个包含该编译版本的 Docker image 并上传至公司内部的 Docker image 仓库,类似的还有数据库,以及其他中间件的 image。前端开发人员不再需要搭建后端开发环境,只需在开发机上安装 Docker(如 Docker for Mac),在前端工程内会维护一个 docker-compose.xml,声明了前端工程所需要的后端 Docker image,每次该文件更新后,前端开发人员只需要运行 docker-compose up -d 即可启动一系列 Docker container,在本机运行完整的后端服务,这里甚至包含了适用于开发的部分测试数据。
整合测试
前后端的分离和整合对质量保证提出了新的要求。我们在前端编写 fetch 逻辑时,会以 mock 方式编写对应的单元测试。后端每个接口也有响应的单元测试。而这两端分别的单元测试还不足以保证软件质量,理论上讲,纵使两者单元测试覆盖率均达到 100%,也不能保证覆盖所有用例。作为质量保证的关键环节,在两端的单元测试都通过后,我们的 CI 会执行端到端的自动化测试。这些自动化测试模仿了用户的使用场景,完整的覆盖了前端、后端、数据库乃至其他中间件。
渐进改造
SparkUI 的产生为前后端分离改造提供了坚实的基础。如果按最理想的方式推进,只要业务开发团队基于 SparkUI 对现有的 Ruby On Rails 的单体应用的前端部分、基于 Golang 微服务方式对其后端部分进行重构改写、践行前后端整合的最佳实践,即可达成前后端分离的目标。而文章开头曾提到,现存的 Rails 应用体积大、复杂度高,纵使有着业务开发团队的全力支持,我们也很难在一个较短时间内彻底完成前后端分离的改造。更何况市场千变万化,在业务部门服务老客户、获取新客户过程中,产品经理们也会不断地提出新的产品需求给我们的开发团队,技术演进和业务推进两者需要取得一个平衡。我们为达成这一平衡,所提出的方案是:渐进改造。
混合工程结构
我们的业务模块在 Ruby On Rails 工程中是以 Module 方式存在的,除了公共的 MVC 和资源放在统一的 Module 里,每个业务 Module 都有自己的 MVC 和资源(这里的资源特指 Javascript 和 CSS)。我们以业务 Module 作为改造的单元。
由于资源等限制,前后端分离改造在前端、后端的推进节奏并不一致。比较多的情况是 Module 前端改造先行,后端依旧沿用 Rails 原有的 Controller(也有部分适配工作)。在这种情况下,Module 经 SparkUI 改写的前端(以下统称为“新前端”)独立于 Rails 工程之外进行打包部署所带来的好处并不明显,故将这部分新前端代码的源码依旧放在 Rails 工程 Module 目录下,通过 Webpack 打包的 bundle JS/CSS 也按照 Module 对资源文件的约定(convention)放在 modules/my_module/app/assets/javascripts/my_module/compiled 目录下,并藉由 Rails Asset Pipeline 打包进 Rails 工程发布包进行统一部署。
对于上述 bundle JS/CSS,我们仍使用 Rails 页面模版作为入口,以期减少对 Rails 工程的影响:
<%= javascript_include_tag "my_module/compiled/my_module" %> <%- @js_module_alias = "my_module" %> <div id="spa"></div> <script> (function() { var React = require('react'); var ReactDOM = require('react-dom'); var AppContainer = require('<%= @js_module_alias %>').AppContainer; ReactDOM.render( React.createElement(AppContainer), document.getElementById('spa') ); })(); </script>
至于路由,既然我们已经在新前端中实现前端路由,那在 Rails 端的后端(页面)路由就可以委托给前端:
scope 'spa' do get '/', :to => 'spa#index', :as => 'spa' get '*pages', :to => 'spa#index' end
经由以上方案,我们在尽量短的周期改写了更多的业务模块,对运维的影响也非常小。对于这些业务模块,我们预期在其改写后端微服务时将前端代码从 Rails 里彻底分离出来,完成该模块的前后端分离。
在上述 Ruby On Rails 项目之外,FreeWheel 也启动了若干个新项目。这些项目一步到位,直接按照前后端分离架构设计开发,其前端完全基于 SparkUI。我们也基于 Nginx 开发了一套轻量的静态资源服务器,前端利用 Webpack 编译打包成 tar 包并独立上线。
SparkUI 独立工程
在小步快跑阶段,我们将 SparkUI 源码直接放在 Rails 公共 Module 中,令我们可以快速验证可重用组件的设计是否满足业务需要。然而这样的结构会带来几方面问题:
- 版本管理。任何对 Spark 的迭代都会直接影响到业务模块;
- 开发效率。SparkUI 是纯 JS 库,Rails 工程开发环境给 SparkUI 开发带来一定负担;
- 源码权限。任何业务模块开发人员均可修改 SparkUI 代码,带来潜在代码冲突;
- 跨工程复用。任何 Rails 工程之外的工程在利用 SparkUI 时都会比较繁琐。
我们在 SparkUI 推出 1.0 版本时,将其源码从 Rails 工程中摘出,移入一个新的纯前端工程。SparkUI 在这个新工程中,仍由 Babel 和 Webpack 打包,但会作为 library 发布到公司 Nexus 上私有 NPM Repository 里。Rails 工程或其他纯前端工程在其 package.json 和.npmrc 配置中声明对特定版本 SparkUI 的依赖,执行 npm install 后则可以在前端代码中使用 SparkUI。
这一改变大大解放了 SparkUI 和业务模块两方的生产力:
- 独立的代码库可以隐藏部分 SparkUI 的内部 API 或工具代码,防止业务模块中滥用;
- 不同的发版节奏令 SparkUI 可以追逐更高的代码质量,目前其源代码共计 9.3 万行,单元测试覆盖率高达 99.43%;
- 业务模块代码可以更有计划地升级 SparkUI 版本,在此之前无须反复回归测试。
新老 JS 代码混用
对于 Rails 工程的部分功能模块,其前端实现有很大一部分是基于 jQuery 开发的 JS。虽然这些代码并不是基于 React 或 SparkUI 开发的,但它们也可以直接在前后端分离后的前端中独立使用。我们在统一的粒度下,创建了一层对 React 友好的适配器 spark-adapter,对原有 jQuery JS 接口进行了封装和隔离。业务模块开发人员可以自行决定对于这一部分 JS 代码是基于 SparkUI 重写还是放在 Adapter 中以继续沿用。
质量保证
作为商业应用,其软件质量是绝不能妥协的。前后端分离改造也不能成为降低软件质量的理由。我们保证质量的核心是测试:
- SparkUI 组件库本身要具有最高标准的单元测试覆盖率;
- 业务模块改写为新前端时,也要基于 SparkUI 提供的基础设施编写单元测试;
- 对于 Rails 工程原有的自动化测试脚本,在业务模块改造为基于 SparkUI 的新前端时,也要同时更新;
- 将测试加入 CI (Continuous Integration) Pipeline,一有 Merge Request 提交就执行测试,测试成功才允许 Merge;
- 各组 lead 在 Merge Request 上做代码审查时严格把关。
另外一个有效实践是为新上线新前端的模块提供回滚机制。因为在这一阶段,Rails 工程里特定功能模块的新老前端代码可以同时存在,只需在功能入口处设置一个开关,就可以在线上执行新前端遇到严重问题时随时切换回老前端。
总结
前后端分离架构是诸多前端应用系统的必经之路,而现实情况往往需要顾及诸多历史架构。本文以单体应用为背景,设计开发可重用组件库为手段,在保证效率与质量的基础上,逐步改造为前后端分离架构。希望对同样面对这一情况的读者有所帮助。
文中提到的 SparkUI 框架,其中与 FreeWheel 业务并不直接相关的纯技术部分,比如 spark-modula、spark-router 等包,我们已计划将其逐步开源。希望届时能与更多的前端技术专家和群体深入探讨、共同进步,并最终对前端社区有所贡献。
前端之巅
「前端之巅」是 InfoQ 旗下关注前端技术的垂直社群。投稿请发邮件到 editors@cn.infoq.com,注明“前端之巅投稿”。
评论