网易公开课 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 代替,注意命令参数的区别)。
初始化项目
$ npx egg-init --type=ts$ cd your-project-directory && yarn$ 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:
import { Controller } from 'egg';export default class ModuleNameController extends Controller { /** * 模块中的方法 fooMethod */ 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(); await next(); // ⚠️后置语句 const time = Date.now() - startTime; ctx.logger.info('请求耗时统计 - %d', time); }; // ... return middlewareHandler;}
一个请求处理中,每个中间件都会执行两次,第一次执行前置语句,第二次执行后置语句。这样,多个中间件放在一起就变得有意思了,在配置文件中,这些中间件如何排列?
/** * config.default.ts */// ...config.middleware = ['中间件1', '中间件2', '中间件3'];// ...
实际执行过程分解如下:
中间件1{ 中间件1前置语句 中间件2{ 中间件2前置语句 中间件3{ 中间件3前置语句 {controller.module.method} 中间件3后置语句 } 中间件2后置语句 } 中间件1后置语句}
你会发现,首先按照中间件排列顺序依次执行前置语句,然后执行 controller 方法,最后按照中间件排列倒叙执行后置语句。而中间件设计最好遵循一个中间件只实现一个功能,这样大部分中间件只有前置语句或后置语句,少数中间件会同时有前后置语句。因此我们可以根据这个特征将中间件分为两类:
前置中间件 - 即只有前置语句或主要实现是前置语句的中间件。比如, ua 中间件,实现是将 user-agent 格式化成 json 结构,添加到 ctx ,供 controller 里面的方法读取,这就是一个前置中间件。
后置中间件 - 即只有后置语句或主要实现是后置语句的中间件。比如,统一处理请求返回中的 message 的中间件,需要将返回数据里面的 message: "MESSAGE_NAME" 翻译成 i18n 的提示信息,这就是个后置中间件。
配置文件做个小改造:
/** * config.default.ts */// ...// 前置中间件,前面的先执行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.config.js|____favicon.ico|____app| |____middleware| |____view-----------nuxt directory start-----------| | |____nuxt| | | |____middleware...| | | |____store| | | | |____README.md-----------nuxt directory end-----------| | |____pccms| |____router| | |____page| | |____api
5.3.2 创建中间件 app/middleware/nuxt.ts ,加载 Nuxt
// 模块和类型文件引入略import * as config from '../../nuxt.config';export default function nuxtMiddleware(options: NuxtMiddlewareOptions, app: Application): any { // 1. 中间件初始化 // 输出中间件加载日志 app.logger.info(`[middleware] nuxt options: %s`, JSON.stringify(options)); // 创建 Nuxt 实例对象 const nuxt = new Nuxt(config); // 开发环境加载 if (config.dev) { try { // 触发项目构建 const builder = new Builder(nuxt); builder.build(); // 打印构建日志 app.logger.info(`[middleware] nuxt builder.build()`); } catch (e) { app.logger.error(e); } } // 2. 上下文处理 const middlewareHandler = async (ctx: Context, next: () => Promise<any>) => { // 执行其他逻辑 await next(); // 通过命名空间进行路由过滤 if (ctx.isNuxtRouter) { // Nuxt 请求处理 ctx.status = 200; 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
...module.exports = { // ⭐️ 根目录 rootDir: join(__dirname, './app/view/nuxt/'), mode: 'universal', router: { // ⭐️ 命名空间 base: '/newview/', }, ... // ⭐️ 构建输出目录 buildDir: join(__dirname, './app/assets/.nuxt-dist/'), build: { ... // ⭐️ 客户端静态资源路径 publicPath: process.env.NODE_ENV === 'development' ? '/_nuxt/' : '//your.cdn.path.prefix/', ... },};
修改 tsconfig.json 以忽略从开发时修改前端代码导致的自动重启
..."exclude": [ "app/public", "app/views", "node_modules*"]...
在 config.default.ts 中,加入中间件的引用。可以根据工作场景环境变量动态控制中间件的加载
// 载入nuxt中间件,注意中间件顺序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 级别日志都会打印到统一的错误日志文件中,便于追踪
启动日志和运行日志分离
自定义日志
多进程日志
自动切割日志
高性能
我们需要在配置文件中做个简单地配置:
// ...config.logger = { level: 'INFO', consoleLevel: 'DEBUG', dir: './your/log/path',};// ...
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)、摄影和工作。