网易公开课 PC 端 Web 网站基于公司内部的 CMS(内容管理系统,Content Management System 缩写)维护管理,当用户访问一个页面时,CMS 系统根据路由匹配一个主模板,然后交给模板引擎,模板引擎解析模板内容并获取子模板和服务端数据,组装成一个完整的页面,返回给用户。CMS 有强大的分组功能和权限管理系统,供多方业务使用,我们只需要在 CMS 后台维护公开课频道下模板和内容即可。在门户网站时代,一般只需要一次开发,网站编辑便可以长期在 CMS 后台直接管理网站内容,无需其他开发成本。但今天,公开课内容形式更加复杂,管理粒度更细化,用户需求更多样,在 CMS 系统上需要维护很复杂的模板,而且模板关系引用复杂,维护成本非常高。如果继续在 CMS 维护这些复杂的模板,这显然不再是 CMS 的长项了。我们决定将网易公开课 PC 业务迁移到 Node.js,并为持续开发提供新方案。
我们一直在追求敏捷高效,这是衡量团队是否优秀的重要指标之一。对于研发团队来说,敏捷高效离不开得心应手的框架和工具。大家都在努力创造一种可持续性方案。前端最流行的方案就是构建自己的开发生态,充分利用开源资源和出色的构建工具,开发项目,将优秀的优化方案和开发方式应用于项目,比如绝对的前后端分离、同构 SSR(Server-Side Rendering 缩写,意为服务端渲染) 等。
本文记录了在一次重构中,如何搭建一个高效的 Node.js SSR 服务并且建立完善的模块。
一、Node.js 功能模块设计
二、技术选型和意义
2.1 Node.js 框架选择 - Egg.js TypeScript 方案
2.2 SSR 框架选择 - Nuxt.js
2.3 SSR 的意义
2.4 Egg 充当的角色
三、CMS 梳理
四、模块关系设计
五、Node.js 服务搭建
5.1 创建一个 Egg 项目
5.2 使用 create-nuxt-app 工具创建 Nuxt App
5.3 Nuxt 与 Egg 集成,使 Nuxt 服务端的设施更完善
5.4 CMS 渲染中缓存优化
5.5 日志收集
5.6 错误上报和服务监控
5.7 开发调试及规范
5.8 压测数据报告
六、配置 Nginx
七、总结
- SSR 模块 - 新业务开发
- API 模块 - 前端数据处理和接口代理层
- CMS 模块 - 解析CMS模板,平滑迁移
2.1 Node.js 框架选择 - Egg.js TypeScript 方案
- Egg 基于 Koa2,继承了 Koa2 特性。Koa 由 Express 原班人马打造,koa2 通过利用 async 函数,帮你丢弃回调函数,并有力地增强错误处理,独特的洋葱模型中间件流程控制,性能卓越
- Egg 提供一套约定系统,保持书写的一致性,减少了学习成本、认知差异
- 轻量,高度插件化,企业级框架生态,减少了开发者主观依赖
- 自带多进程管理,进程间通信和进程管理很方便
- 上手简单
2.2 SSR 框架选择 - Nuxt.js
- Nuxt 是一个基于 Vue.js 的通用应用框架,也是 Vue 栈的 SSR 优秀的框架实现,没有之一。拥有良好的文档和非常高的人气。
- 它主要关注的是应用的 UI 渲染。它预设了利用 Vue.js 开发服务端渲染的应用所需要的各种配置。
- Nuxt 还具有自动代码分层、强大的路由功能、本地化热加载、支持 HTTP/2 推送等特性。
2.3 SSR的意义
SSR 意味着,更好的 SEO,搜索引擎爬虫抓取工具可以直接查看完全渲染的页面;更快的内容到达时间 (time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。这为前端性能优化提供了很好的帮助。
2.4 Egg充当的角色
由于 Nuxt 主要负责 UI 渲染,但在实际项目中,服务端渲染需要有一套完整的服务端架构体系,如轻量的数据处理,便捷的扩展开发,日志收集,监控,运行管理等。因此我们选择了 Egg 来完成这项工作,以构建出色的企业 SSR 服务。
CMS 业务迁移到 Node.js 服务,我们需要做:
- 支持 CMS 模板渲染
- 改造渲染引擎和实现自定义方法
- 用 CMS 服务兜底解决页面统计不完全问题
我们将 Nuxt 和 CMS 模块以中间件的方式整合到了 Egg。模块关系如图所示:
图1 Node 功能模块关系图
5.1 创建一个 Egg 项目
5.1.1 基于egg-init 创建项目
- 系统版本:macOS/Linux/Windows
- Node.js 版本:建议选择 LTS 版本,最低要求 8.x。
- yarn(非必须,可以使用 npm 代替,注意命令参数的区别)。
- $ cd your-project-directory && yarn
复制代码 5.1.2 路由添加命名空间
默认情况下 Egg 路由文件路径为app/router.ts,但在具体项目开发中,需要对 router 进行模块化,我这里使用egg-router-plus 添加模块化,该插件会自动加载app/router/**/*.js 文件来初始化路由。
- 安装模块egg-router-plus
- 设置插件引用
- 修改路由目录结构
- 为路由加入命名空间
5.1.3 创建一个 controller
新建文件app/controller/moduleName.ts:
- import { Controller } from 'egg';
复制代码- [/code][code]export default class ModuleNameController extends Controller {
复制代码- public async fooMethod () {
复制代码 5.1.4 Egg 中间件加载顺序优化
在 Egg 中,中间件依然是洋葱模型,一个中间件处理分为两部分,前置语句和后置语句,如下:
- export default function 中间件1(options: NuxtMiddlewareOptions, app: Application): any {
复制代码- const middlewareHandler = async (ctx: Context, next: () => Promise) => {
复制代码- const startTime = Date.now();
复制代码- const time = Date.now() - startTime;
复制代码- ctx.logger.info('请求耗时统计 - %d', time);
复制代码- return middlewareHandler;
复制代码 一个请求处理中,每个中间件都会执行两次,第一次执行前置语句,第二次执行后置语句。这样,多个中间件放在一起就变得有意思了,在配置文件中,这些中间件如何排列?
- config.middleware = ['中间件1', '中间件2', '中间件3'];
复制代码 实际执行过程分解如下:
- {controller.module.method}
复制代码 你会发现,首先按照中间件排列顺序依次执行前置语句,然后执行 controller 方法,最后按照中间件排列倒叙执行后置语句。而中间件设计最好遵循一个中间件只实现一个功能,这样大部分中间件只有前置语句或后置语句,少数中间件会同时有前后置语句。因此我们可以根据这个特征将中间件分为两类:
- 前置中间件 - 即只有前置语句或主要实现是前置语句的中间件。比如, ua 中间件,实现是将user-agent 格式化成 json 结构,添加到ctx ,供 controller 里面的方法读取,这就是一个前置中间件。
- 后置中间件 - 即只有后置语句或主要实现是后置语句的中间件。比如,统一处理请求返回中的 message 的中间件,需要将返回数据里面的message: "MESSAGE_NAME" 翻译成 i18n 的提示信息,这就是个后置中间件。
配置文件做个小改造:
- const nextBeforeMiddlewares = ['cms'];
复制代码- const nextAfterMiddlewares = ['log', 'nuxt', 'cmsproxy'];
复制代码- config.middleware = [...nextBeforeMiddlewares, ...nextAfterMiddlewares];
复制代码 但有个别特殊情况,如统计请求耗时的中间件,为了前置语句要在第一位,后置语句要在最后面,这种中间件肯定要排在所有中间件的前面。
5.2 使用 create-nuxt-app 工具创建 Nuxt App
执行命令$ npx create-nuxt-app 创建一个项目。在创建过程中需要选择一些框架和模块,请根据你的需要进行选择。我选择了koa框架,Universal渲染模式,axios模块,yarn包管理工具,启用eslint和prettier。这个 Nuxt 项目我们暂时不需要修改,省略 404、500 页面自定义的过程。
5.3 Nuxt 与 Egg 集成,使 Nuxt 服务端的设施更完善
把 Nuxt 以中间件的形式集成到 Egg 上比较简单,只需要把前面创建 Nuxt 项目和 Egg 项目的目录进行合理的合并,然后将 Nuxt 服务端实现server/index.js 改写为 Egg 中间件的格式,最后修改 Nuxt 配置文件使其正常工作即可。我这里着重列出一下合并过程:
5.3.1 把 Nuxt 项目代码拷贝到 Egg,注意nuxt.config.js 的位置
- -----------nuxt directory start-----------
复制代码- -----------nuxt directory end-----------
复制代码 5.3.2 创建中间件app/middleware/nuxt.ts ,加载 Nuxt
- import * as config from '../../nuxt.config';
复制代码- [/code][code]export default function nuxtMiddleware(options: NuxtMiddlewareOptions, app: Application): any {
复制代码- app.logger.info(`[middleware] nuxt options: %s`, JSON.stringify(options));
复制代码- const nuxt = new Nuxt(config);
复制代码- const builder = new Builder(nuxt);
复制代码- app.logger.info(`[middleware] nuxt builder.build()`);
复制代码- const middlewareHandler = async (ctx: Context, next: () => Promise) => {
复制代码- return new Promise((resolve, reject) => {
复制代码- ctx.res.on('close', resolve);
复制代码- ctx.res.on('finish', resolve);
复制代码- ctx.res.on('error', reject);
复制代码- nuxt.render(ctx.req, ctx.res, promise => {
复制代码- promise.then(resolve).catch(reject);
复制代码- app.logger.info('[middleware] nuxt load');
复制代码- return middlewareHandler;
复制代码 5.3.3 修改配置文件
- 在nuxt.config.js 中,重新配置rootDir、router.base、buildDir、build.publicPath
- rootDir: join(__dirname, './app/view/nuxt/'),
复制代码- buildDir: join(__dirname, './app/assets/.nuxt-dist/'),
复制代码- publicPath: process.env.NODE_ENV === 'development' ? '/_nuxt/' : '//your.cdn.path.prefix/',
复制代码- 修改tsconfig.json 以忽略从开发时修改前端代码导致的自动重启
- 在config.default.ts 中,加入中间件的引用。可以根据工作场景环境变量动态控制中间件的加载
- config.middleware = ['nuxt'];
复制代码- .gitignore
- 合并package.json
5.4 CMS 渲染中缓存优化
5.4.1 CMS 渲染中缓存设计
为了提高 CMS 并发量和控制渲染开销,我们对 CMS 渲染进行了精心的设计。
- 页面渲染结果须加入缓存文件,以备复用。
- 渲染中获取的外部数据须缓存,可以减少外部接口调用,降低接口服务压力,节省时间、流量和带宽等。
- 同一个路由最多同时只能有一个渲染执行,其它的请求根据缓存文件信息来决定使用缓存或是等待渲染结果,尽可能减少处理时间和服务器压力。
- 是否使用缓存是由缓存文件是否存在、是否有效、该路由是否正在渲染这些条件共同决定,计算出合理的缓存使用条件。
- 何时触发渲染呢?没有缓存文件、缓存文件过期和强制更新缓存时。
5.4.2 CMS 渲染中缓存控制的实现
由于 Node.js 是单进程运行的,我们的生产环境一般是多进程的集群的架构向用户提供服务。缓存策略要依据业务场景来选择合适的方式来实现。我这里的缓存共享范围设定为单台云主机,在同一台主机上实现接口数据和渲染页面缓存共享,渲染状态共享。
在生产环境下,Egg 都以多进程方式运行,我们通过 Egg 的进程间通信来实现渲染状态共享。Egg 有三种进程,如下:
- Master - 负责管理进程,并且完成进程间消息转发,不运行业务代码。Master是唯一的,稳定性非常高。
- Agent - 负责后台运行工作,比如长链接客户端。它具有较高的稳定性,但比 Master 略低一些。运行少量的业务代码,也是唯一的。
- Worker - 负责执行业务代码,进程数一般设置为CPU核数。稳定性比 Agent 低一些。
它们的稳定性排序是 Master > Agent > Worker。
我们在 Agent 存放页面的渲染状态并实时广播给 Worker 进程。当 Worker 进程接收到一个 CMS 请求时,如果不强制重新渲染并且有过期的缓存文件,则直接返回缓存文件,然后触发渲染,同时向 Agent 进程发送渲染状态变更通知,Agent 接受到通知后存储并通知所有 Worker。如果没有缓存文件,将触发渲染任务并发送渲染状态变化通知,在渲染结束前,相同的请求都会进入等待状态。渲染结束后,再次发送渲染状态变更通知,Agent 将最新的渲染状态广播到各个 Worker,等待中的请求结束等待,返回最新渲染结果。
5.5 日志收集
Egg 内置了企业级日志模块egg-logger,引用官方的特性描述:
- 日志分级
- 统一错误日志,所有 logger 中使用.error() 打印的 ERROR 级别日志都会打印到统一的错误日志文件中,便于追踪
- 启动日志和运行日志分离
- 自定义日志
- 多进程日志
- 自动切割日志
- 高性能
我们需要在配置文件中做个简单地配置:
5.6 错误上报和服务监控
接入监控平台在关键时刻很有用,比如 Sentry,探活,服务器 CPU、内存、磁盘、IO、句柄等多维度监控,可以帮助开发者分析问题所在。比如之前有个 Vue SSR 项目触发内存报警,推断出是 Vue 在服务端渲染时里面可能写了计时器或事件绑定,经排查果不其然,created 钩子里有事件监听。
图2 Node 错误上报和服务监控
关于 Sentry,简单介绍一下。Sentry 是一个日志的收集分析监控平台。它包括客户端和服务端。服务端提供日志持久化、分析、监控、web 可视化展示。在客户端发生错误时,Sentry 将错误日志实时发送到 Sentry 服务,服务端接收到日志后进行计算,生成可视化数据,并且可发布报告和监控报警。这可以大大减轻我们对用户反馈的依赖度,快速定位问题,提高效率和系统的可用性。Sentry 是一个开源项目,服务端可使用官方的付费服务,也可以部署自己的服务,docker 部署非常方便。
这里以 Nuxt SSR 接入为例做个讲述:
- 在 Sentry 服务中创建项目,创建完成后查看 Sentry 的接入文档。Sentry 提供了多种语言的接入方法。
- 使用@nuxt/sentry 开源模块来实现 Nuxt 的接入非常简单方便。
- 最后,我们在 Sentry 管理界面中,添加一下邮件报警和报警规则。
这样我们就可以通过报告邮件和定期查看 Sentry 平台来快速及时准群的定位 Nuxt 的问题,保证项目正常运行。我们还在 Egg 的其他部分也接入了 Sentry,整个项目都在 Sentry 上得到监控。
5.7 开发调试及规范
- 在script 中配置常用命令,为了节约启动时间,我们添加了多种运行脚本,比如:dev-nuxt 不加载 CMS 中间件;
- 修改本地 Egg 日志配置。日志输出到项目的子目录中,方便产看。consoleLevel 修改为DEBUG,这样在 Terminal 可以看到不同级别的日志;
- 中间件参数统一写到配置文件,根据环境变量动态匹配配置文件;
- 调试时除了 logger 日志输出,还可以使用 debug 模块。可以用 debug 日志,也可以用 node 的 debug 模块调试。
- 我们可以用egg-bin 来断点调试,还有 DevTools。Egg 可以借助开发工具调试,比如 VSCode 扩展。
代码规范采用 standard 规范,团队使用统一的 vscode 配置,项目使用 eslint 和 tslint 对代码进行校验。
5.8 压测数据报告
【压测工具】Siege
【压测环境】Linux 云服务器,4 核 8G,多台之间切换
【压测极限】CPU 到达 80%+,内存使用率小于 40%,错误率小于 0.1%
【压测维度】
- 请求资源类型
- Nuxt 页面 3 个
- CMS 页面 3 个
- 接口 3 个
- 压测时间:1 分钟、5 分钟、10 分钟、15 分钟
- 缓存情况
- Nuxt 页面 无缓存
- CMS 页面 无缓存、仅缓存数据接口、仅缓存页面、页面接口均缓存
- 接口 无缓存
- 缓存时间:0、1 分钟、5 分钟
- 进程数:1、2、3、4、5、6、7、8(机器为 4 核 8G)
【压测结果】
- 不同类型的资源请求,CMS 的请求 TPS 最低
- 缓存时间 1-5 分钟为最理想状态
- CMS 的页面和数据均使用缓存
- 进程数大于 4 个之后几乎无差别
Ps:压测过程中,开始本地 Siege 访问 CMS 页面,测试数据很差,发现是网速到了上限,改为在服务器安装了 Siege 进行最终测试。
最后给出一组首页的压测数据(压测条件:单台 4 核 8G、4 进程、缓存 5 分钟、压测 10 分钟)
图 3 首页单台机器压测数据(TPS:338.6T/s)
网易公开课 Web 域名是 open.163.com,Nginx 分为公共层和业务层:
- 公共层 - 运维团队负责维护,负责业务无关的配置,如 https 证书;
- 业务层 - 业务部门维护,open.163.com 业务已迁移至 nodejs,nginx 在前端团队维护。
Nginx HTTP Upstream 模块通过调度算法根据upstream 配置分配请求到多台服务器。由于目前我们的 Node.js 服务是无状态的,因此upstream 调度规则使用默认的轮询算法。
总结一下迁移过程的主要工作分为以下两部分:
- 搭建一个可靠的 Node.js,提供优秀的 SSR 服务,推动基础建设;
- 了解 CMS 系统本身和 Nginx 架构,确定迁移 Node.js 可行性方案。
项目能够支撑当前的较高的并发并且有不错的增长空间。
需要特别注意的点:
- 在集成 nuxt 过程中,根据自己情况合理修改配置文件。
- 从开发者角度提取scripts 命令。
- 做充分的压力和功能测试。
- 在投产前完成关键优化。
- 有长远的规划,构建标准化项目将为容器部署提供了很好的基础。
在这次重构迁移开始前压力挺大的,在我们研发团队和运维团队同学的帮助下,顺利完成,感谢大家的支持。对我个人来说,将所学理论在实践中得到充分应用,并且学到了不少运维知识,可谓收获满满。通过我的简单分享,希望对大家有参考意义。项目后期还将通过监控数据继续优化,有兴趣的同学可以关注,谢谢。
作者简介
齐超 2018年加入网易传媒,高级前端工程师,目前在网易公开课搬砖。热爱长板(longboard)、摄影和工作。
|
|