网易公开课 Node.js SSR 实践

论坛 期权论坛 期权     
网易传媒技术团队   2019-6-10 04:03   1597   0
网易公开课 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 代替,注意命令参数的区别)。
  • 初始化项目
  1. $ npx egg-init  --type=ts
复制代码
  1. $ cd your-project-directory && yarn
复制代码
  1. $ yarn dev
复制代码
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
  1. import { Controller } from 'egg';
复制代码
  1. [/code][code]export default class ModuleNameController extends Controller {
复制代码
  1.   /**
复制代码
  1.    * 模块中的方法 fooMethod
复制代码
  1.    */
复制代码
  1.   public async fooMethod () {
复制代码
  1.     // ...
复制代码
  1.   }
复制代码
  1.   // ...
复制代码
  1. }
复制代码
5.1.4 Egg 中间件加载顺序优化
在 Egg 中,中间件依然是洋葱模型,一个中间件处理分为两部分,前置语句和后置语句,如下:
  1. export default function 中间件1(options: NuxtMiddlewareOptions, app: Application): any {
复制代码
  1.   // ...
复制代码
  1.   const middlewareHandler = async (ctx: Context, next: () => Promise) => {
复制代码
  1.     // 前置语句
复制代码
  1.     const startTime = Date.now();
复制代码
  1.    
复制代码
  1.     await next();
复制代码
  1.    
复制代码
  1.     // 后置语句
复制代码
  1.     const time = Date.now() - startTime;
复制代码
  1.     ctx.logger.info('请求耗时统计 - %d', time);
复制代码
  1.   };
复制代码
  1.   // ...
复制代码
  1.   return middlewareHandler;
复制代码
  1. }
复制代码
一个请求处理中,每个中间件都会执行两次,第一次执行前置语句,第二次执行后置语句。这样,多个中间件放在一起就变得有意思了,在配置文件中,这些中间件如何排列?
  1. /**
复制代码
  1. * config.default.ts
复制代码
  1. */
复制代码
  1. // ...
复制代码
  1. config.middleware = ['中间件1', '中间件2', '中间件3'];
复制代码
  1. // ...
复制代码
实际执行过程分解如下:
  1. 中间件1{
复制代码
  1.   中间件1前置语句
复制代码
  1.   中间件2{
复制代码
  1.     中间件2前置语句
复制代码
  1.     中间件3{
复制代码
  1.       中间件3前置语句
复制代码
  1.       {controller.module.method}
复制代码
  1.       中间件3后置语句
复制代码
  1.     }
复制代码
  1.     中间件2后置语句
复制代码
  1.   }
复制代码
  1.   中间件1后置语句
复制代码
  1. }
复制代码
你会发现,首先按照中间件排列顺序依次执行前置语句,然后执行 controller 方法,最后按照中间件排列倒叙执行后置语句。而中间件设计最好遵循一个中间件只实现一个功能,这样大部分中间件只有前置语句或后置语句,少数中间件会同时有前后置语句。因此我们可以根据这个特征将中间件分为两类:
  • 前置中间件 - 即只有前置语句或主要实现是前置语句的中间件。比如, ua 中间件,实现是将user-agent 格式化成 json 结构,添加到ctx ,供 controller 里面的方法读取,这就是一个前置中间件。
  • 后置中间件 - 即只有后置语句或主要实现是后置语句的中间件。比如,统一处理请求返回中的 message 的中间件,需要将返回数据里面的message: "MESSAGE_NAME" 翻译成 i18n 的提示信息,这就是个后置中间件。
配置文件做个小改造:
  1. /**
复制代码
  1. * config.default.ts
复制代码
  1. */
复制代码
  1. // ...
复制代码
  1. // 前置中间件,前面的先执行
复制代码
  1. const nextBeforeMiddlewares = ['cms'];
复制代码
  1. // 后置中间件,后面的先执行
复制代码
  1. const nextAfterMiddlewares = ['log', 'nuxt', 'cmsproxy'];
复制代码
  1. config.middleware = [...nextBeforeMiddlewares, ...nextAfterMiddlewares];
复制代码
  1. // ...
复制代码
但有个别特殊情况,如统计请求耗时的中间件,为了前置语句要在第一位,后置语句要在最后面,这种中间件肯定要排在所有中间件的前面。

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 的位置
  1. |____nuxt.config.js
复制代码
  1. |____favicon.ico
复制代码
  1. |____app
复制代码
  1. | |____middleware
复制代码
  1. | |____view
复制代码
  1. -----------nuxt directory start-----------
复制代码
  1. | | |____nuxt
复制代码
  1. | | | |____middleware
复制代码
  1. ...
复制代码
  1. | | | |____store
复制代码
  1. | | | | |____README.md
复制代码
  1. -----------nuxt directory end-----------
复制代码
  1. | | |____pccms
复制代码
  1. | |____router
复制代码
  1. | | |____page
复制代码
  1. | | |____api
复制代码
5.3.2 创建中间件app/middleware/nuxt.ts ,加载 Nuxt
  1. // 模块和类型文件引入略
复制代码
  1. import * as config from '../../nuxt.config';
复制代码
  1. [/code][code]export default function nuxtMiddleware(options: NuxtMiddlewareOptions, app: Application): any {
复制代码
  1.   // 1. 中间件初始化
复制代码
  1.   // 输出中间件加载日志
复制代码
  1.   app.logger.info(`[middleware] nuxt options: %s`, JSON.stringify(options));
复制代码
  1.   // 创建 Nuxt 实例对象
复制代码
  1.   const nuxt = new Nuxt(config);
复制代码
  1.   // 开发环境加载
复制代码
  1.   if (config.dev) {
复制代码
  1.     try {
复制代码
  1.       // 触发项目构建
复制代码
  1.       const builder = new Builder(nuxt);
复制代码
  1.       builder.build();
复制代码
  1.       // 打印构建日志
复制代码
  1.       app.logger.info(`[middleware] nuxt builder.build()`);
复制代码
  1.     } catch (e) {
复制代码
  1.       app.logger.error(e);
复制代码
  1.     }
复制代码
  1.   }  
复制代码
  1.   // 2. 上下文处理
复制代码
  1.   const middlewareHandler = async (ctx: Context, next: () => Promise) => {
复制代码
  1.     // 执行其他逻辑
复制代码
  1.     await next();
复制代码
  1.     // 通过命名空间进行路由过滤
复制代码
  1.     if (ctx.isNuxtRouter) {
复制代码
  1.       // Nuxt 请求处理
复制代码
  1.       ctx.status = 200;
复制代码
  1.       return new Promise((resolve, reject) => {
复制代码
  1.         ctx.res.on('close', resolve);
复制代码
  1.         ctx.res.on('finish', resolve);
复制代码
  1.         ctx.res.on('error', reject);
复制代码
  1.         nuxt.render(ctx.req, ctx.res, promise => {
复制代码
  1.           promise.then(resolve).catch(reject);
复制代码
  1.         });
复制代码
  1.       });
复制代码
  1.     }
复制代码
  1.   };
复制代码
  1.   // 输出中间件加载日志
复制代码
  1.   app.logger.info('[middleware] nuxt load');
复制代码
  1.   return middlewareHandler;
复制代码
  1. }
复制代码
5.3.3 修改配置文件
  • nuxt.config.js 中,重新配置rootDirrouter.basebuildDirbuild.publicPath
  1. ...
复制代码
  1. module.exports = {
复制代码
  1.   //  根目录
复制代码
  1.   rootDir: join(__dirname, './app/view/nuxt/'),
复制代码
  1.   mode: 'universal',
复制代码
  1.   router: {
复制代码
  1.     //  命名空间
复制代码
  1.     base: '/newview/',
复制代码
  1.   },
复制代码
  1.   ...
复制代码
  1.   //  构建输出目录
复制代码
  1.   buildDir: join(__dirname, './app/assets/.nuxt-dist/'),
复制代码
  1.   build: {
复制代码
  1.     ...
复制代码
  1.     //  客户端静态资源路径
复制代码
  1.     publicPath: process.env.NODE_ENV === 'development' ? '/_nuxt/' : '//your.cdn.path.prefix/',
复制代码
  1.     ...
复制代码
  1.   },
复制代码
  1. };
复制代码
  • 修改tsconfig.json 以忽略从开发时修改前端代码导致的自动重启
  1. ...
复制代码
  1. "exclude": [
复制代码
  1.   "app/public",
复制代码
  1.   "app/views",
复制代码
  1.   "node_modules*"
复制代码
  1. ]
复制代码
  1. ...
复制代码
  • config.default.ts 中,加入中间件的引用。可以根据工作场景环境变量动态控制中间件的加载
  1. // 载入nuxt中间件,注意中间件顺序
复制代码
  1. config.middleware = ['nuxt'];
复制代码
  1. // ...
复制代码
  • .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 级别日志都会打印到统一的错误日志文件中,便于追踪
  • 启动日志和运行日志分离
  • 自定义日志
  • 多进程日志
  • 自动切割日志
  • 高性能
我们需要在配置文件中做个简单地配置:
  1. // ...
复制代码
  1. config.logger = {
复制代码
  1.   level: 'INFO',
复制代码
  1.   consoleLevel: 'DEBUG',
复制代码
  1.   dir: './your/log/path',
复制代码
  1. };
复制代码
  1. // ...
复制代码
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)、摄影和工作。
分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:
帖子:
精华:
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP