作者:李强,腾讯 web 开发工程师
商业转载请联系腾讯 WeTest 获得授权,非商业转载请注明出处。
原文链接:http://wetest.qq.com/lab/view/348.html
直出这个名词是在 node 出现后才有的,在 node 出现前叫做服务端渲染。
所以可以把直出定义为:“以 node 作为后端语言实现的服务端渲染并输出 HTML 字符串到客户端的一项技术”。这样浏览器渲染首屏的过程就由非直出下的先请求 HTML,再请求 js、css,最后再请求后台数据。改为直出下的直接向 node 服务器发起请求,然后通过内网获取到首屏数据后,组装成 HTML 直接返回给浏览器。这里说明下:直出并不一定就比非直出快,但是它能保证用户在不同机型、不同网络条件下都有一个比较好的体验。
那什么是同构呢?
同构就是解决直出的一种思想,node 出现后使得 javascript 脚本也可以在服务器端执行,通过维护一套项目代码,实现在前后端都可以执行的目的。
QQ 兴趣部落拥有页面 80 多个,开发人员 14 个,参与改造直出人力 2 个,使用同构的做法无疑可以最大程度上降低改造和维护成本。
亿万级用户意味着什么呢?目前部落用户注册和使用量达亿万级, 这样大量的用户意味着存在高并发,服务随时都有可能挂掉的风险。前端页面作为整个 web 服务中最直接面向用户的,一旦服务不可用就将是件让所有人都很崩溃的事情了。
本文的目的在于解决两个问题:
1、 部落是怎样从一个纯前端项目改造成同构直出项目的
2、在访问量这么大的情况下,如何保证直出服务的可用性的问题。
首先明确同构直出要做好哪些工作,总结下来有三点,可称之为同构直出三要素。
1、保证 DOM 的一致性,如果说本来浏览器通过纯客户端代码渲染出来的页面结构是下图这样,服务端渲染出来却少了一个 dom 节点,那肯定会导致页面显示有问题。
2、保证前后端数据的一致性,服务端不能执行 dom 操作,所以像绑定事件这样的工作,就需要浏览器拉取到 js 脚本后才能进行,如果使用服务端获取到的数据渲染出来的 HTML 结构与前端绑定事件时用到的数据不一致,就会导致问题。
3、保证路由的一致性,不能让用户访问 a 页面的时候,返回 b 页面给用户。
这样就可以明确做同构直出的方向,对于部落来说,原来的项目中就使用了 react 和 redux,所以接下来会使用这两个框架进行讲解。
同构直出是一种优化的思想,不受任何框架限制,理解其中的原理才是最重要的。那么问题就来了,如何使用 react 来保证 dom 一致性,又如何使用 redux 保证数据一致性?先来看一下 dom 一致性的实现。
在使用 react 做同构直出时,很关键的一个因素就是它提供了虚拟 DOM 的支持,是一种在内存中的对象数,使其可以支持在浏览器和 node 环境下执行,这也是代码可以同构的关键所在。在浏览器端通过 render 方法生成虚拟 dom 并挂载到真实 DOM 上。在服务端通过 renderToString 方法将虚拟 dom 拼装成 HTML 字符串。使用这两个方法就可以解决 dom 一致性的问题了,来看一下具体的实现。
首先服务端通过调用 rendertostring 方法将 react 组件渲染为 html 字符串,但是通过 react 组件渲染出来的并不是标准的 html 格式,需要将其嵌入 HTML 模板中才能够被浏览器解析。当浏览器向直出服务器发起请求后,服务端将渲染好的 html 字符串返回,浏览器收到响应后进行渲染。浏览器通过解析 html 拉取到 js 脚本后,会执行 render 方法,在 render 方法处理过程中会校验节点中的 checksum 属性,该属性是在服务端调用 rendertostring 方法时追加的,用于前端校验 dom 一致性,当校验一致时,直接执行脚本中后续的绑定事件等行为,如果不一致,将会进行虚拟 DOM 的 diff 操作,然后再进行增量更新 DOM、绑定事件。在红框处,可以看到同构代码的部分。
但是,Node 环境和浏览器环境毕竟还是不一样的,有这么多前端代码是不能直接在 node 端执行的,应该怎样在同构代码上做好平台区分呢?
解答这个问题之前,再来看一下数据一致性是如何保证的。
Redux 使用单一的 Store 对象保存、管理页面中的所有状态,和虚拟 dom 一样,是一种驻在内存中的对象,代码完全可以同构。
保证数据一致性的原理其实很简单。只要在最后组装 HTML 字符串时,将服务端的状态通过 script 标签一起输出给前端,然后在前端初始化 Store 时使用该数据,即可完成了数据的传递和共享,达到保证数据一致性的目的。
这里其实也存在一点问题,页面的状态大都来自于后台数据,而发送异步请求的方法在前端是 ajax 方法,在 node 端是使用 http 模块的 request 方法,这样,我们又该怎样保证代码的同构呢?
接下来可以了解下怎样解决上面遇到的一些问题,以及部落同构直出的改造方案。
整个解决问题和改造的过程我把它比作是一次装修房子的过程,在装修房子过程中有这样一些关键的角色,户型结构图、设计师、通过设计师设计出来的效果图、还有房子,如果此时又买了一套户型结构完全一样的房子需要装修,那就和前后端需要渲染出来的 HTML 结构一样是类似的场景了。所以可以就户型结构图看做是源码,设计师看做构建工具,效果图看做构建打包后的 bundle,已经装修好的房子看做浏览器,等待装修的房子看做 node 服务器。大家还记得我们前面提到的第一个问题吗?前端代码中有些代码是不能在 node 端执行的,该怎么解决呢?
先来看一下如果在设计过程中,想去掉一些东西该怎么做?
是不是只需要在户型结构图上做些标识,然后告诉设计师红圈中的内容表示想去掉这部分的内容就可以了?
就是按照这种思路,我们在源码中做了些标记,然后告诉构建工具被这个标记包裹的代码是打包 node 端代码时需要删掉的,让构建工具识别这个标签的方法可以使用自定义 webpack loader 或者 babel 插件。
然后回想下第二个问题,发送异步请求前端使用的是 ajax 方法,node 端使用的是 http 模块的 request 方法,这个问题怎么解决?同样的,在设计过程中,如果想改个门,是不是直接告诉设计师就可以了? 都没必要在原始图上进行任何修改了。
借助这种思考方式,通过构建工具处理,就不需要对源码进行任何更改。源码中使用的是 ajax 方法,同时在 node 服务器上在全局变量下实现了一个 window.ajax 的方法,这样通过自定义 babel 插件,在对源码打包时,将 ajax 方法名替换成为 window.ajax 方法名,问题就得到了解决。
到了这一阶段——结束了设计工作,有了效果图,也就是已经打包出了一份可以在 node 端执行的 bundle,就下来就是需要到房子里面去还原设计稿的时候了。
施工的话,单凭我们自己肯定不行,所以需要一个施工队。
施工队里面有包工头,负责承接项目,分发任务给各个工种按照设计稿进行施工。
同样的原理,我们在 node 服务器上引入了直出框架机的概念,帮我们统一管理直出服务。框架机的第一层就是玄武和 TSW(不理解玄武的同学,这里可以把它当做是起了一个 koa 的 server,负责监听端口,接受请求并转发到业务逻辑层按照打包好的 bundle 去处理。)为了让业务逻辑层不必针对每个页面做兼容,所以需要打包出来的 server bundle 具有固定的结构,那我们就来看一下 bundle 是怎样的一个结构。
源码的结构大致是这样子的,大家可以看到这里面有一个前端程序的打包入口,实现上是这样的,里面有对 store 和 main 组件入口的引用。因为源码中没有对服务端程序的打包入口,所以需要对 store 和 main 进行单独打包。
最终构建出来的目录大致是这样的,以 a 页面为例,有 HTML 模板、组件入口脚本、创建 store 对象的脚本,最后还有一个首屏 action 的脚本。
这个脚本是做什么的呢?
在 action 的脚本中封装了所有异步请求的方法,对于页面来说,由很多组件构成,每个组件调用各自的 action 方法更新自身状态,但是,首屏并不一定需要渲染所有组件,可能只需要展示组件 1 和组件 2,所以这时就需要提取出首屏所需的 action creator 方法了,我们把它封装在了名为 firstAction 脚本中以便构建工具打包后在服务端进行调用。这样打包后的 bundle 中每个页面就都有了相同的结构。
这时就可以在框架机中的业务逻辑层统一对直出页面做处理了。当浏览器发起对页面 A 的请求时,通过玄武将请求转发到业务逻辑层,首先进行路由解析,确保路由一致性,这里使用正则匹配获取 url 中的模块名,通过模块名获取页面 A 的存放路径。
然后为请求创建沙箱环境,让每个请求都能在独立的上下文环境中执行,实现上使用的是 node 的 vm 模块,如果之前没有接触过的话可以把框架机想象成是浏览器,每当有一个请求过来就会新开一个 tab 页,请求处理完后关闭 tab 页。
接着就是初始化一些全局对象,比如前面提到的 window.ajax 方法。然后将页面 A 的脚本引入,通过 store 脚本创建 store 对象,通过 firstAction 脚本获取首屏所需数据,执行 rendertostring 方法渲染组件,最后读取 A 页面的 HTML 模板,组装成 HTML 字符串输出给浏览器。这就是框架机基本的一个工作流程了。
最后对直出改造方案进行一下总结。首先是在 node 服务器上部署了一个直出框架机的服务,使用单独的代码仓库进行维护和发布。
然后通过打包构建工具构建出客户端的 bundle 和服务端的 bundle。由于客户端和服务端的一些差异,需要在源码中使用特定的标签将 node 端不能执行的代码做个标记,同时还要新增一个供服务端使用的封装了首屏 action 的脚本,在构建工具中新增 server 端的打包配置,并加入一些自定义的 loader 和 babel 插件帮助我们构建出 server 端的 bundle。
然后将 server bundle 发送到 node 服务器上,当浏览器发起请求后,框架机帮我们组装首屏 html 字符串并输出给浏览器。浏览器进行渲染后,引入前端的 js 脚本,进行后续的 dom 更新和绑定事件等工作。
以上就是改造直出的整套方案。
首先要讲的是本地开发调试在保证服务可用性方面的问题。
前面提到了框架机,那就先来说一下框架机的开发调试模式。本地开发是以 tnpm 命令行工具包的形式。对于本地开发调试模式也是和命令行工具包一样,使用 tnpm link 命令,建立命令的全局链接。Tnpm 其实就是 npm,只不过是企业内部私有 npm 仓库,外部访问不到。
有人说,平时开发时我连这一步也不想要怎么办?于是我们增加了自动化测试。
可以利用 Mocha + Chai 帮助我们实现一些代码逻辑上的测试。
接下来就是容灾。在代码报错、服务器崩溃的时候,需要一套容灾方案来让业务尽量正常运作。
兴趣部落设计了一套柔性可用的容灾方案。当直出报错的时候,会让请求自动转发到静态资源,让相对稳定的静态资源接受用户的请求,以保证业务不受干扰。
具体的原理是怎么样的呢?首先由一群 Nginx 服务器集群去调度用户的请求,这些请求包括了直出服务器、CDN、后台等等。一旦直出服务器挂掉了,它会自动将请求转发到 CDN 服务器。
上面这里是 Nginx 接入集群的示例代码。
业务上线前,需要先预估请求的量级,才能预先准备足够的服务器,以抗住大量用户的请求。因此需要做好压力测试。
兴趣部落在做同构直出的过程中,使用了腾讯 WeTest 压测大师,实现更智能和自动化地压力测试。上图是压测大师的入口界面,能分别从系统角度、用户角度、业务角度,多角度帮助开发人员发析直出业务的 “接客” 能力。
瞬时 TPS 图表,分析了服务最优的承载能力。
通过服务器性能趋势图得到 CPU、内存的性能瓶颈。
还支持报告的一个对比,帮助比对分析每次业务更新后的压测情况。
直出顺利完成,服务器也准备妥当了,此时就已具备了产品发布的基本条件。但为了让产品对业务成效更有把握,这里需要先做一个用户灰度。
兴趣部落这里主要是详情页做了同构直出。因此针对业务场景,我们通过在列表页做一个区分,通过前端来控制灰度。直出的用户走带 v2 的链接,而非直出用户则不带。
产品发布上线时,还需要对它进行全方位监控,以防出乱子。
以上的这些数据指标,都是需要时刻关注的。
兴趣部落同构直出顺利落地,成果也是相当不错的。页面能达到秒出,慢用户占比也从 6.8%,下降到 1.25%。
为了帮助开发者发现服务器端的性能瓶颈,腾讯 WeTest 开放了上文提到的压力测试功能,通过基于真实业务场景和用户行为进行压力测试,实现针对性的性能调优,降低服务器采购和维护成本。
除了兴趣部落以外,压测大师还服务了包括王者荣耀、龙之谷手游、轩辕传奇手游、火影忍者等多款高星级手游,也包括 QQ、NOW 直播等明星产品。
为了让外部更多产品能够享受到简单易用的压测产品,腾讯 WeTest 决定将这份服务器测试能力产品化,以产品” 压测大师 “的形式,正式对外开放,点击链接:http://wetest.qq.com/gaps/ 即可使用。
如果对使用当中有任何疑问,欢迎联系腾讯 WeTest 企业 QQ:800024531