node js并发加载页面缓慢_网易公开课 Node.js SSR 实践

网易公开课 PC 端 Web 网站基于公司内部的 CMS(内容管理系统,Content Management System 缩写)维护管理,当用户访问一个页面时,CMS 系统根据路由匹配一个主模板,然后交给模板引擎,模板引擎解析模板内容并获取子模板和服务端数据,组装成一个完整的页面,返回给用户。CMS 有强大的分组功能和权限管理系统,供多方业务使用,我们只需要在 CMS 后台维护公开课频道下模板和内容即可。在门户网站时代,一般只需要一次开发,网站编辑便可以长期在 CMS 后台直接管理网站内容,无需其他开发成本。但今天,公开课内容形式更加复杂,管理粒度更细化,用户需求更多样,在 CMS 系统上需要维护很复杂的模板,而且模板关系引用复杂,维护成本非常高。如果继续在 CMS 维护这些复杂的模板,这显然不再是 CMS 的长项了。我们决定将网易公开课 PC 业务迁移到 Node.js,并为持续开发提供新方案。

我们一直在追求敏捷高效,这是衡量团队是否优秀的重要指标之一。对于研发团队来说,敏捷高效离不开得心应手的框架和工具。大家都在努力创造一种可持续性方案。前端最流行的方案就是构建自己的开发生态,充分利用开源资源和出色的构建工具,开发项目,将优秀的优化方案和开发方式应用于项目,比如绝对的前后端分离、同构 SSR(Server-Side Rendering 缩写,意为服务端渲染) 等。

1336e4e9-391f-eb11-8da9-e4434bdf6706.png

本文记录了在一次重构中,如何搭建一个高效的 Node.js SSR 服务并且建立完善的模块。

1736e4e9-391f-eb11-8da9-e4434bdf6706.png

一、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

七、总结

1a36e4e9-391f-eb11-8da9-e4434bdf6706.png

  • SSR 模块 - 新业务开发

  • API 模块 - 前端数据处理和接口代理层

  • CMS 模块 - 解析CMS模板,平滑迁移

1d36e4e9-391f-eb11-8da9-e4434bdf6706.png

2036e4e9-391f-eb11-8da9-e4434bdf6706.jpeg

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 服务。

2336e4e9-391f-eb11-8da9-e4434bdf6706.png

CMS 业务迁移到 Node.js 服务,我们需要做:

  • 支持 CMS 模板渲染

  • 改造渲染引擎和实现自定义方法

  • 用 CMS 服务兜底解决页面统计不完全问题

2636e4e9-391f-eb11-8da9-e4434bdf6706.png

我们将 Nuxt 和 CMS 模块以中间件的方式整合到了 Egg。模块关系如图所示:

2d36e4e9-391f-eb11-8da9-e4434bdf6706.png

图1 Node 功能模块关系图

3436e4e9-391f-eb11-8da9-e4434bdf6706.png

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 中,重新配置 rootDirrouter.basebuildDirbuild.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 钩子里有事件监听。

3736e4e9-391f-eb11-8da9-e4434bdf6706.png

图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 分钟)

3a36e4e9-391f-eb11-8da9-e4434bdf6706.png

图 3 首页单台机器压测数据(TPS:338.6T/s)

3f36e4e9-391f-eb11-8da9-e4434bdf6706.png

网易公开课 Web 域名是 open.163.com,Nginx 分为公共层和业务层:

  • 公共层 - 运维团队负责维护,负责业务无关的配置,如 https 证书;

  • 业务层 - 业务部门维护,open.163.com 业务已迁移至 nodejs,nginx 在前端团队维护。

Nginx HTTP Upstream 模块通过调度算法根据 upstream 配置分配请求到多台服务器。由于目前我们的 Node.js 服务是无状态的,因此 upstream 调度规则使用默认的轮询算法。

4236e4e9-391f-eb11-8da9-e4434bdf6706.png

总结一下迁移过程的主要工作分为以下两部分:

  • 搭建一个可靠的 Node.js,提供优秀的 SSR 服务,推动基础建设;

  • 了解 CMS 系统本身和 Nginx 架构,确定迁移 Node.js 可行性方案。

项目能够支撑当前的较高的并发并且有不错的增长空间。

需要特别注意的点:

  • 在集成 nuxt 过程中,根据自己情况合理修改配置文件。

  • 从开发者角度提取 scripts 命令。

  • 做充分的压力和功能测试。

  • 在投产前完成关键优化。

  • 有长远的规划,构建标准化项目将为容器部署提供了很好的基础。

在这次重构迁移开始前压力挺大的,在我们研发团队和运维团队同学的帮助下,顺利完成,感谢大家的支持。对我个人来说,将所学理论在实践中得到充分应用,并且学到了不少运维知识,可谓收获满满。通过我的简单分享,希望对大家有参考意义。项目后期还将通过监控数据继续优化,有兴趣的同学可以关注,谢谢。

1336e4e9-391f-eb11-8da9-e4434bdf6706.png

作者简介


齐超  2018年加入网易传媒,高级前端工程师,目前在网易公开课搬砖。热爱长板(longboard)、摄影和工作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值