作者:李强,腾讯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更新和绑定事件等工作。
以上就是改造直出的整套方案。
四、如何保证直出服务的高可用性?
1、业务可用性的开发调试
首先要讲的是本地开发调试在保证服务可用性方面的问题。
前面提到了框架机,那就先来说一下框架机的开发调试模式。本地开发是以tnpm命令行工具包的形式。对于本地开发调试模式也是和命令行工具包一样,使用 tnpm link命令,建立命令的全局链接。Tnpm其实就是npm,只不过是企业内部私有npm仓库,外部访问不到。
有人说,平时开发时我连这一步也不想要怎么办?于是我们增加了自动化测试。
可以利用Mocha + Chai 帮助我们实现一些代码逻辑上的测试。
2、业务的容灾
接下来就是容灾。在代码报错、服务器崩溃的时候,需要一套容灾方案来让业务尽量正常运作。
兴趣部落设计了一套柔性可用的容灾方案。当直出报错的时候,会让请求自动转发到静态资源,让相对稳定的静态资源接受用户的请求,以保证业务不受干扰。
具体的原理是怎么样的呢?首先由一群Nginx服务器集群去调度用户的请求,这些请求包括了直出服务器、CDN、后台等等。一旦直出服务器挂掉了,它会自动将请求转发到CDN服务器。
上面这里是Nginx接入集群的示例代码。
业务上线前,需要先预估请求的量级,才能预先准备足够的服务器,以抗住大量用户的请求。因此需要做好压力测试。
3、业务的压力测试
兴趣部落在做同构直出的过程中,使用了腾讯 WeTest 压测大师,实现更智能和自动化地压力测试。上图是压测大师的入口界面,能分别从系统角度、用户角度、业务角度,多角度帮助开发人员发析直出业务的“接客”能力。
瞬时TPS图表,分析了服务最优的承载能力。
通过服务器性能趋势图得到CPU、内存的性能瓶颈。
还支持报告的一个对比,帮助比对分析每次业务更新后的压测情况。
4、业务的用户灰度
直出顺利完成,服务器也准备妥当了,此时就已具备了产品发布的基本条件。但为了让产品对业务成效更有把握,这里需要先做一个用户灰度。
兴趣部落这里主要是详情页做了同构直出。因此针对业务场景,我们通过在列表页做一个区分,通过前端来控制灰度。直出的用户走带v2的链接,而非直出用户则不带。
5、业务的监控告警
产品发布上线时,还需要对它进行全方位监控,以防出乱子。
以上的这些数据指标,都是需要时刻关注的。
五、成果
兴趣部落同构直出顺利落地,成果也是相当不错的。页面能达到秒出,慢用户占比也从6.8%,下降到1.25%。
为了帮助开发者发现服务器端的性能瓶颈,腾讯WeTest开放了上文提到的压力测试功能,通过基于真实业务场景和用户行为进行压力测试,实现针对性的性能调优,降低服务器采购和维护成本。
除了兴趣部落以外,压测大师还服务了包括王者荣耀、龙之谷手游、轩辕传奇手游、火影忍者等多款高星级手游,也包括QQ、NOW直播等明星产品。
为了让外部更多产品能够享受到简单易用的压测产品,腾讯WeTest决定将这份服务器测试能力产品化,以产品”压测大师“的形式,正式对外开放,点击链接:http://wetest.qq.com/gaps/ 即可使用。
如果对使用当中有任何疑问,欢迎联系腾讯WeTest企业QQ:800024531