开发效率提升50%以上,爱奇艺官网主站的Nuxt实践

4,022 阅读9分钟

01 背景

让每一个用户获取到稳定、及时的页面体验,是前端工程师们一直以来努力的方向。

作为一个拥有丰富内容资源的视频网站,爱奇艺官网主站需要频繁进行节目上线或者下线、各种活动配置等操作调整,对于页面SSR服务的可用性及稳定性,都有着极高的要求。

在2019年之前,爱奇艺官网主站页面的SSR采用的是在CMS平台中书写Velocity模板,由Java编译,优点是渲染速度快,但缺点也非常明显

(1)在CMS平台中开发体验不好:没有传统IDE方便,不能配置快捷键、不能安装插件等等,导致开发效率低下。

(2)前后端代码不同构:由于后端使用Velocity模板,而前端需要使用Vue,导致前后端代码不同构。

(3)破坏Vue组件封装性:由于Java无法编译Vue组件,所有的Vue组件都需要用Slot的方式在CMS平台中书写以达到SEO和SSR的目的。

基于以上所有原因,我们决定使用Node来进行SSR。因为我们的前端框架是Vue,因此我们选择了配套的Nuxt框架进行SSR。

使用Nuxt进行SSR,难点并不在于如何使用Nuxt,而在于如何维护这个服务,保证其性能、稳定性等,因此,本文将不会介绍Nuxt的使用,其语法可以参考官网,这里将主要从性能、缓存、限流、灾备、日志等几个方面来介绍我们是如何保证Nuxt服务的可用性及稳定性的。

02 Nuxt 稳定性提升之路

2.1 页面配置

首先介绍一个很重要的配置文件。在我们的项目根目录下,创建了一个页面配置文件,用来存放每个页面的通用配置,例如页面的缓存配置、Purge信息、主题色配置、广告信息配置等等,该文件导出一个Object, 键值为页面的Router Name,Value值为页面的配置信息:

// configs/pageinfo.js
export default {
 'dianshiju-id': {...},
 'zongyi': {
     theme: 'dark', // 页面主题色配置
  },
 'home2020': {...},
 'rank-hot': {...}
}

然后我们在Nuxt插件中根据请求的路由信息,读取对应的页面配置,并将其注入到所有的组件实例中,方便随时取用:

// plugins/pageinfo.js
import config from 'configs/pageinfo.js'
export default ({ route }, inject) => {
	inject('pageInfo', config[route.name]) // 注入页面配置信息
}

因此你可以在组件的任何地方获取到页面配置信息而不需要通过Props一层层传递,页面通用配置也不会散落在项目各个地方,方便统一管理。

<div :class="$pageInfo.theme">我是综艺页面</div>

2.2 浏览器兼容性

虽然Nuxt理论上可以支持IE9,但IE9在很多方面都需要添加Polyfill,例如对History API的支持等,为了保持代码的简洁性,我们放弃了支持IE9-,但我们依然在框架中保留了一套机制来支持jQuery,使得高低版本可以共用HTML,而不需要单独为低版本写模板,从而最大程度的减少兼容低版本浏览器的成本。

大致思路为,Nuxt提供了一个’render.route’的钩子函数,该钩子函数的执行时机在生成HTML后,返回给用户之前。在这个钩子函数中,我们可以根据用户请求的UA信息判断用户版本**,如果是低版本浏览器用户则移除HTML中高版本JS并注入低版本打包后的入口文件即可。

// nuxt.config.js

    'render:route': (url, result, { req }) => {
      if (isLowBrowser(req)) { // 根据用户ua信息判断是否是低版本
        const $ = cheerio.load(result.html)

        $('body script[src*=\'pcw/ssr\']').remove() // 移除高版本js
        $('body').append('<script src="//stc.iqiyipic.com/jquery.js"></script') // 添加jquery 
        $('body').append('<script src="//stc.iqiyipic.com/index.js"></script') // 添加低版本入口js
        
        result.html = $.html()
      }
    },

2.3 性能优化

2.3.1 数据过滤

Nuxt有一个很重要的机制在于,它会将所有asyncData函数返回的数据挂在window.__NUXT__,通过HTML返回给客户端,从而避免客户端再次请求这些数据,因此,asyncData函数中返回的数据量对性能的影响变得更加重要,它不仅影响了接口数据的传输时间,还影响了HTML的体积,因此,我们需要对这些数据进行压缩,在NUXT中,我们尝试了三种方案

  1. 在asyncData中做数据过滤

  2. GraphQL

  3. 数据过滤平台

在asyncData中做数据过滤仅减少了HTML体积,却并无法减少冗余数据的传输。

GraqhQL虽然解决了冗余数据的传输问题,但代码不利于维护,因为它需要写大量的查询参数, 查询参数太长时还需要使用POST。

// 非常不利于维护的查询字符串
const query = ` 
query {
  qipuGetVideoBriefList (
    album_id: "${params.album_id}"
    type: "EPISODE_LIST"
    play_platform: "PC_QIYI"
    order: "DESC"
  ) {
    rpc_status
    episode {
      id
      g_corner_mark_s
      brief {
        title
        short_title
        subtitle
        page_url
      }
      release {
        publish_time
      }
    }
  }
}
`
axios.get(`http://xxx.iqiyi.com/graphql?query=${query}`)

最终,我们搭建了一个数据过滤平台,以可视化的方式来配置接口数据源、数据的字段过滤和映射,最终生成一个接口,该接口从配置的数据源获取数据,然后经过字段映射和字段过滤,仅仅返回我们需要的字段,这样既过滤了冗余数据又不需要维护GraphQL的查询参数,而是将GraphQL的查询串可视化为配置。

image.png

2.3.2 Layout

Nuxt提供了Layout配置项,看似非常的方便,但通过分析Nuxt生成的.nuxt/App.js入口文件,我们发现所有的Layout不管有没有被使用到,都会被打包进来,例如A页面使用了LayoutA, B页面使用了LayoutB, C页面使用了LayoutC,则A、B、C三个页面的入口JS会有LayoutA、LayoutB、LayoutC的所有代码。

// .nuxt/App.js
import _8daa19aa from '../src/layouts/a.vue'
import _8daa19a8 from '../src/layouts/b.vue'
import _8daa19a6 from '../src/layouts/c.vue'
import _6f6c098b from './layouts/default.vue'

因此,如果Layout的逻辑很复杂,并且如果代码量很大,所有页面的JS体积就会变大许多。基于以上原因,我们放弃了使用Nuxt的Layout,而是自己封装了一个I71Layout组件来提供所有页面的通用功能,以减少冗余代码。 由于Vue SSR是基于虚拟DOM,而Java是基于字符串,所以性能上相比之前会慢一些,因此我们从页面和组件两个粒度上做了缓存策略。

2.4 缓存

我们使用Nginx反向代理来控制页面级别的缓存,默认每个页面缓存5分钟,当Nuxt返回非200时,Nginx则使用过期缓存返回。

组件缓存我们使用的是官方的@nuxtjs/component-cache模块,它提供了一个serverCacheKey配置项,Nuxt会以这个配置项的值作为缓存的Key。因此我们为每个需要缓存的组件定义了一个cache-key的Props, 传递后则会根据传递的值做缓存,未传递则无缓存。这样对于所有无缓存的页面在调用组件时,可以传递一个cache-key来使得组件被缓存,从而加速页面的SSR。

2.5 purge

对于有缓存的页面,我们需要对应的Purge接口来清除页面缓存。页面的Purge分为两个部分,一部分是我们Nginx反向代理的缓存Purge, 另一部分是CDN缓存的Purge,他们的Purge原理相同,因此这里我们只讲Nuxt服务的Nginx反向代理的缓存Purge。

我们希望提供一个Purge接口,通过传递页面名参数来Purge指定的页面。我们的Nuxt框架本身是基于Koa搭建,所以我们只需要在SSR之前插入koa-router,就可以提供我们的Purge接口。

// server/index.js
const app = new Koa()
const router = new Router()
router.get('/api/purge/page/:pageName', async (ctx) => { // 定义purge接口,支持传递pageName
  ctx.body = await purgePage(ctx) // purge nginx缓存和cdn缓存
})
app.use(router.routes()) // 插入我们需要的api
app.use(ctx => { // nuxt 进行 ssr
    nuxt.render(ctx.req, ctx.res)
})

那我们如何知道每个pageName要Purge哪些URL呢?这里我们需要在之前提到的页面配置文件中进行配置来将pageName和Purge URL关联起来:

// configs/pageinfo.js
zongyi: {
   purge: {
      purgeUrl: [
        'https://zongyi.iqiyi.com/', 
        'https://www.iqiyi.com/zongyi'
      ],
   },
}

接下来我们只需要Purge所有服务上的这些URL,服务部署在公司的应用平台,一共有4个集群,上百个Docker容器,我们需要Purge所有宿主机上的Nginx缓存,具体操作如下。

首先我们需要在Nginx中配置让其支持Purge:

location / {
    proxy_cache_purge PURGE from all;
}

这样就可以通过调用http://{宿主机的域名}:{宿主机的端口}/purge/{uri}来Purge该宿主机上uri对应的缓存了。

接下来我们只需要逐个调用所有宿主机上的Purge接口就可以Purge所有的宿主机上的页面缓存了。

2.6 限流

对于无缓存页面,为了谨防恶意刷量行为,要进行限流。我们从WAF, 单IP限流, IP黑名单进行了三方面的限制。

2.6.1 WAF(Web Application Firewall)

首先我们接入了公司的防火墙平台,通过智能识别以过滤掉一些恶意请求。其次,对于一些动态路由的页面,我们对请求的URL进行了正则匹配,不符合正则的请求全部拒绝访问并返回403。

2.6.2 单IP限流

为了防止单IP脚本刷量,我们在Nginx反向代理使用limit_req模块进行单IP限流。对于普通用户和爬虫,我们设置了不同的访问频次,超过频次的请求拒绝访问并返回503。

2.6.3 IP黑名单

除此之外,我们通过日志分析会发现一些很明显的刷量IP,对于这样的IP,我们希望直接封禁。

如果直接在Nginx配置中添加Deny语句,会发现Deny并不会生效,是因为请求经过了网关,到我们的Nginx服务时,Remote Address变成了网关的IP,而我们Deny的是真是用户的IP,所以我们需要想办法让Nginx知道用户的真实IP是什么。

通常用户的真实IP存储在x-forwarded-for字段中,为了拿到用户的真实IP,我们需要在Nginx中做以下配置:

# nginx.conf

server {
    real_ip_header X-Forwarded-For; # 告诉Nginx,用户的真实IP存储在x-forwarded-for字段中
    real_ip_recursive on;
}

但光有以上配置还不够,因为x-forwarded-for字段为一个字符串,每经过一个节点,这个节点就会向里面追加一个IP,所以到达我们的Nginx时,该字段的值为x-forwarded-for: {用户的真实IP},{网关的IP},而Nginx读取IP时,会默认从后往前读取IP, 如果这个IP是受信任的IP,则会继续往前读取,直到不被信任的IP就会当做是用户的真实IP,因此,如果没有额外配置,Nginx读取到的IP依然是网关的IP,因此,我们还需要将所有网关IP添加到信任IP的列表中,Nginx才能继续往前读取到用户的真实IP。我们可以将整个内网网段都设置成信任IP:

# nginx.conf

server {
	set_real_ip_from xxx.0.0.0/8; # 设置内网网段为信任IP
    real_ip_header X-Forwarded-For; # 告诉Nginx,用户的真实IP存储在x-forwarded-for字段中
    real_ip_recursive on;
}

现在Nginx可以读取到用户的真实IP了,这时候我们只需要创建一个IP黑名单即可:

# nginx.conf

server {
	set_real_ip_from xxx.0.0.0/8; # 设置内网网段为信任IP
    real_ip_header X-Forwarded-For; # 告诉Nginx,用户的真实IP存储在x-forwarded-for字段中
    real_ip_recursive on;

    include ip-blacklist.conf # 导入IP黑名单
}
# ip-blacklist.conf

deny xx.xx.xx.xx;

2.7 灾备

image.png 对于无缓存的页面,除了限流以外,我们还需要有灾备方案,否则一旦服务出错返回非200,用户将看到错误页面。

我们部署了一套独立的灾备服务,使用Node脚本每隔三分钟从线上服务拉取所有重要页面,如果页面返回200,则将其存储为HTML文件,否则抛弃该页面,然后使用Nginx做反向代理来Serve灾备页面。

CDN先从线上服务拉取页面,若返回非200,则从灾备服务拉取对应的页面返回给用户,以此保证用户永远不会看见出错的页面

2.8 服务端日志

服务端日志主要用来记录Nuxt渲染页面的记录、错误信息等,它们对于排查问题、统计流量来说是非常重要的,我们的服务端日志分为两大部分:页面渲染日志、接口请求日志。

页面渲染日志即每一次来一个页面请求,则写一条日志,记录页面的URL、Referer、用户Cookie、用户IP等信息,若页面渲染未出错则写入到logs/page/info.log中,若页面渲染出错,则写一条日志到logs/page/error.log中。

接口日志是每一次页面渲染中发出的请求日志,封装在底层发送请求的HTTP函数中,记录了调用该接口的页面URL、接口URL、接口参数等信息,若请求成功,则写一条日志到logs/api/info.log, 若请求失败,则写一条日志到logs/api/error.log中。

// nuxt.config.js

hooks: {
   'render:setupMiddleware': app => { // 在nuxt初始化时插入一个中间件,每次请求都生成一个logParams对象
      app.use(async (req, res, next) => {
        req.logParams = { 
          requestId: generateRandomString(), // 生成requestId随机串
          pageUrl: req.url
        }
        next()
      })
    },
    'render:routeDone': (url, result, { req, res }) => { // 渲染完毕
      logger.page.info({ type: 'render', ...req.logParams}, req) // 写日志时带上requestId
    },
    'render:errorMiddleware': app => app.use(async (error, req, res, next) => { // 渲染错误
      logger.page.error({ type: 'render', error, ...req.logParams }, req) // 错误日志带上requestId

      next(error)
    }),
}

为了让页面渲染日志、这一次渲染的接口日志关联起来,我们会在渲染前生成一个唯一的RequestId, 然后在该次渲染的所有日志中都带上这个RequestId,就可以通过一个RequestId查询到页面渲染日志,以及这个页面发出去的所有请求日志了。

class Resource {
  async http (opts)
    let data
    try {
        data = await axios(opts)
        process.server && logger.api.info(opts, this.req.logParams) // api日志带上requestid
	} catch (error) {
	    process.server && logger.api.error(opts, error, this.req.logParams) // api错误日志带上requestid
	}
	return data
  }
}

2.9 日志采集

我们采用了Filebeat + Elasticsearch + Kibana进行日志管理,首先通过Filebeat进行实时日志采集,然后上报至指定 kafka 集群,然后对日志进行分析并建立索引,最终生成一个可视化的日志查询页面,这样我们就可以查看一段时间内符合查询条件的日志了。

2.10 流量监控

基于服务端日志,就可以据此统计流量经由了CDN的缓存、WAF的拦截、Nginx反向代理的缓存,最后计算出到达我们的Nuxt服务的实际流量到底有多少。我们可以根据日志的time字段筛选出指定时间段且type= 'render'的日志,就是该时间段内Nuxt服务承受的总流量了,如果想看各个页面的流量,还可以进一步对日志中的pageUrl字段进行筛选。

03 总结

Nuxt从根本上解决了之前在CMS平台使用Velocity开发遇到的所有问题,但同时也带来了一些别的问题,例如域名冲突的问题、服务端变量共享的问题、渲染性能问题等。不过总体来说,瑕不掩瑜,开发体验得到了质的提升,开发效率提升了50%以上;组件复用率更高、组件封装性更好,代码可读性、可维护性都得到了飞跃性的提升;在CDN缓存、Nginx反向代理缓存、组件缓存的强力加持下,页面的渲染性能也并没有下降;由于移除了一些由于前后端代码不一致、大量使用Slot等一些复杂逻辑后,首屏渲染性能反而提高了许多,服务器响应时间维持在平均0.5s左右,错误率维持在0.2%左右,而在有灾备服务兜底的情况下,可访问性也几乎达到100%。

最后,期待Nuxt3的到来以及性能和开发体验上的进一步提升。