过去的开发模式中,我们采用了以后端为主的 MVC 架构方式。具体来说,每次项目评审后,前后端会先一起约定好接口,之后分别进行开发,开发完,前端需要把页面提供给后端,后端配置上数据,然后返回出来。正式基于这样的开发模式,导致了总工作量的增加,同时沟通和联调成本的消耗也十分显著。
前后端分离
为了摆脱这种前后端过分依赖的情况,(其实前端也不想每次修改或者发布都要后端这边发布,后端也不想每次前端只改个标题,都要发布一下,影响服务的稳定性),那么先从前后端分离开始吧~
前后端分离,最基本的两种模式,有中间层和没有中间层。
第一种,没有web中间层就很简单,提供一个html模板放到静态资源机上面,html模板里面引用了所需的js和css,访问页面的时候 把这个静态模板返回给用户,然后执行js 在浏览器端通过ajax请求api拿到数据,渲染页面。
(前后端分离)
第二种,有node中间层,随着2009年,Node的横空出世,把前端慢慢的推向了后端,有了node之后,JavaScript可以做更多的事情。
B站,一开始做前后端分离的时候,也确实按照第一种方式去做的,现在还有一些页面仍然是这种模式,例如:https://www.bilibili.com/account/history (可查看网页源代码)。对于不需要seo的页面来说,是一个不错的方式。前端开发完成之后,通过webpack打包出对应的js和css 上传到cdn上面,然后将webpack打包出来的 引用了对应的资源的html文件 上传到一台专门的静态机上面,然后运维配置路由 将页面流量导过去就好了。后端的同学只需要提供对应的api接口就可以。前后端分开维护,自己按照自己的节奏走,降低了页面与服务的耦合度
这种方式确实是一种很快能够进行前后端分离的方法。我们花了一段时间,在pc端使用vue 进行重构,移动端H5端 用react进行了重构。 进度很快,但是也慢慢展现出了弊端。
首屏的时候,因为他要等待资源加载完成,然后再进行渲染,会导致了首屏有白屏,如果是单页面还好,如果是spa应用 那么 他的加载时间就会变得很长,白屏时间会很影响用户体验,再有就是由于国内的搜索公司 对于spa 应用没有很好的兼容,导致了客户端渲染会对seo非常的不友好,有seo 需求的页面就很迫切的需要服务端渲染。
(B站的首页,右边模块做了服务端渲染,左边模块没有做服务端渲染)
那么,依赖node 进行服务端渲染就被提上了日程。
选型
首先进行node 框架的选型,市面上主流框架有三种,hapi express koa ,还有一些是经过一些封装和定制的框架,例如:eggjs等。
一开始我就把eggjs 排除在外了,第一因为eggjs,的功能很强大,有很多功能,多到有些根本用不着,从而导致了他会重 不轻量级,第二,eggjs对于我来说是个黑盒,如果有什么问题,我解决起来将会花费很长的时间。(但是有很多地方 我还是借鉴了eggjs的,毕竟 很强大)
然后剩下的三种框架,express的使用相对简单,文档也比较多,比较全面,所以我就选择了express(后来还是重构掉了 = =!)
然后是前端框架的选型 因为前端框架主流的有很多,ng r v 等等,我站在用的是react和vue, 他们有个优势就是可以进行前后端同构,一样的逻辑不用写两份,很棒。
(同构逻辑大概如此吧)
由于之前前后端分离的时候,pc上面已经再用vue 进行了重构,所以自然,这次服务端渲染也建立在vue上面 用的是vue ssr (这也为我后面的一个想法埋下了伏笔)
首先 我们选择一个简单的页面来做打样,就用tag页吧(被神选中的孩子:https://www.bilibili.com/tag/3503159 )
开发
目录结构
- client 【客户端代码 同构代码】
- build 【构建相关】
- PC 【pc 端 vue项目】
- package.json
- config
- config.local.js 【本地开发配置】
- dist 【构建目录 挂载资源目录】
- server 【服务端代码】
- controller 【控制器】
- PC
- route.js
- core [核心代码库]
- service [方法库]
- view [视图]
- PC [vue 构建后文件]
- tag.html [构建后的模板]
- tag.json [构建后的bundle]
- manifest.json
- apps.js [启动项]
在一开始设计的时候,客户端代码和服务端代码放在同一个git库里面,client里面是vue的代码和webpack的打包逻辑。Server里是服务端的代码,用的是类mvc结构。
Client里面的vue的开发代码,参照的就是vue ssr 官方给的例子来做的,用的是 createBundleRender方法
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, {
... })
构建配置也是用的推荐的配置(参考:https://ssr.vuejs.org/zh/build-config.html)
简单来说,就是提供两个入口,一个entry-client.js,主要是客户端的执行入口, 打包出来的是客户端的引用代码集合(manifest),另外一个是entry-server.js 打包出来的是服务端运行的逻辑,整合到了bundle.json里面。然后传给上面的createBundleRender方法就可以了
对于server文件夹里面的逻辑就非常简单了,core里面是启动项目的一些express的核心代码 路由注册什么的逻辑,值得一说的是,这边的路由,借鉴了eggjs的路由注册方式,稍微做了一点修改,用的是配置化的方式
配置优于代码,将访问地址和对应的controller 做了关联。
这边还有一个filter 其实就是在执行controller之前 注册进一个middlewares 优先执行(其实这边有点局限性,后处理没法做)。
这边我忽略了压力测试,压力测试我后面再说吧。
上线部署
上线部署用的是docker来部署的,配置是1C 4G的配置,用了两个实例来运行,(之前的构建镜像逻辑什么的 就不具体介绍了)
上线之后 每天的访问量大概在100W左右,服务表现挺稳定,期间出现了一个bug,就是 这边有一个状态与用户的登陆状态有关,所以在服务端请求接口的时候,需要带上cookie去请求,当时忘记加了 后来加上,发现这个有点弊端比较麻烦。
需要在调用vuesssr的时候带在context 里面,然后asyncData方法里面都要一层一层的传递,最后在action 里面拿到,带给api。
这时候 我们再来看下tag页。
ssr html
(不错 把数据都带上了)
重构
其实也没过多久,大概三个月吧,node的版本涨的很快,在7.6版本之后,node 就支持了async/await语法糖,不需要再用yield 和*函数了,那么 无疑 koa 是对于await/async 支持最好的,我们果断放弃了express,选择了koa2 进行重构。
其实不单单是koa2对于async的支持,另外一个原因在于,我们koa 是洋葱式的执行方式,这样就解决了上面我说的,只有controller的前处理,没有后处理,这样子我就可以很方便的去执行前后处理。Koa的执行效率也要好于express.
上面我说过,选择vue 对后面重构埋下了一个伏笔就在这里
首先,我给项目接入了配置中心,配置中心是干嘛用的呢? 用来记录脚本的版本号,这样子我就可以很轻松的通过配置中心来控制前端页面使用什么版本的脚本。而不用因为改了个脚本的版本号,就需要进行一次服务的重启更新。
然后,我对vue的打包组件进行了魔改,将他打包出来的文件带上了对应的版本号(版本号为hash值)。
这样子我就可以通过配置中心来控制,到底我需要使用什么版本的vue 构建产物,vue 前端逻辑更新了,我也只需要通过配置中心去分发给服务端,而不需要重启服务了。一举两得。
配置托管
图中 conf 就是配置中心,我们的server 会与conf进行一个长连接,如果conf中的配置更新了,就会通知到服务,然后服务去拉去新的bundle和manifest 来进行渲染。Ok 很棒
全民SSR
重构完,那么再接入一个项目试试吧
首页,好,就首页吧
首页跟tag 页
其实也都差不多,没有什么特别的地方,唯一不同的就是 量比较大,可能一天有1000W的访问量左右。那么我们就在CDN上面加上一层缓存,然后在我们服务上面也加上一层缓存。破费(perfect)!~
服务端的缓存是通过文件落地来的,就是在第一个请求进来的时候 在渲染完成之后,写一个文件到本地,然后下次访问的时候就可以直接用这个丢这个本地文件出去,不用再次渲染了,然后通过过期时间去控制。
这里发现了一个问题,就是每次更新 我都会将tag 和index 都进行打包,而我需要的是对项目进行单独的打包,单独的更新,能不能通过参数来控制我打包哪个呢,可以啊,首先先把webpack.config.js 重写,公用部分整合,然后私有的分开写成多个,通过package.json里面来多配置几个script就好啦
这样子每次更新项目的时候,我就只需要打包对应的项目就可以了,不会因为项目接入了很多之后,打包和开发时候的热加载变得很慢很慢。