百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术资源 > 正文

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

off999 2025-03-28 20:16 20 浏览 0 评论

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一层层传递,页面通用配置也不会散落在项目各个地方,方便统一管理。

我是综艺页面

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的查询串可视化为配置。

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组件来提供所有页面的通用功能,以减少冗余代码。

2.4 缓存

由于Vue SSR是基于虚拟DOM,而Java是基于字符串,所以性能上相比之前会慢一些,因此我们从页面和组件两个粒度上做了缓存策略

我们使用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 灾备

对于无缓存的页面,除了限流以外,我们还需要有灾备方案,否则一旦服务出错返回非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查询到页面渲染日志,以及这个页面发出去的所有请求日志了。

// http.js 
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的到来以及性能和开发体验上的进一步提升。

相关推荐

Python钩子函数实现事件驱动系统(created钩子函数)

钩子函数(HookFunction)是现代软件开发中一个重要的设计模式,它允许开发者在特定事件发生时自动执行预定义的代码。在Python生态系统中,钩子函数广泛应用于框架开发、插件系统、事件处理和中...

Python函数(python函数题库及答案)

定义和基本内容def函数名(传入参数):函数体return返回值注意:参数、返回值如果不需要,可以省略。函数必须先定义后使用。参数之间使用逗号进行分割,传入的时候,按照顺序传入...

Python技能:Pathlib面向对象操作路径,比os.path更现代!

在Python编程中,文件和目录的操作是日常中不可或缺的一部分。虽然,这么久以来,钢铁老豆也还是习惯性地使用os、shutil模块的函数式API,这两个模块虽然功能强大,但在某些情况下还是显得笨重,不...

使用Python实现智能物流系统优化与路径规划

阅读文章前辛苦您点下“关注”,方便讨论和分享,为了回馈您的支持,我将每日更新优质内容。在现代物流系统中,优化运输路径和提高配送效率是至关重要的。本文将介绍如何使用Python实现智能物流系统的优化与路...

Python if 语句的系统化学习路径(python里的if语句案例)

以下是针对Pythonif语句的系统化学习路径,从零基础到灵活应用分为4个阶段,包含具体练习项目和避坑指南:一、基础认知阶段(1-2天)目标:理解条件判断的逻辑本质核心语法结构if条件:...

[Python] FastAPI基础:Path路径参数用法解析与实例

查询query参数(上一篇)路径path参数(本篇)请求体body参数(下一篇)请求头header参数本篇项目目录结构:1.路径参数路径参数是URL地址的一部分,是必填的。路径参...

Python小案例55- os模块执行文件路径

在Python中,我们可以使用os模块来执行文件路径操作。os模块提供了许多函数,用于处理文件和目录路径。获取当前工作目录(CurrentWorkingDirectory,CWD):使用os....

python:os.path - 常用路径操作模块

应该是所有程序都需要用到的路径操作,不废话,直接开始以下是常用总结,当你想做路径相关时,首先应该想到的是这个模块,并知道这个模块有哪些主要功能,获取、分割、拼接、判断、获取文件属性。1、路径获取2、路...

原来如此:Python居然有6种模块路径搜索方式

点赞、收藏、加关注,下次找我不迷路当我们使用import语句导入模块时,Python是怎么找到这些模块的呢?今天我就带大家深入了解Python的6种模块路径搜索方式。一、Python模块...

每天10分钟,python进阶(25)(python进阶视频)

首先明确学习目标,今天的目标是继续python中实例开发项目--飞机大战今天任务进行面向对象版的飞机大战开发--游戏代码整编目标:完善整串代码,提供完整游戏代码历时25天,首先要看成品,坚持才有收获i...

python 打地鼠小游戏(打地鼠python程序设计说明)

给大家分享一段AI自动生成的代码(在这个游戏中,玩家需要在有限时间内打中尽可能多的出现在地图上的地鼠),由于我现在用的这个电脑没有安装sublime或pycharm等工具,所以还没有测试,有兴趣的朋友...

python线程之十:线程 threading 最终总结

小伙伴们,到今天threading模块彻底讲完。现在全面总结threading模块1、threading模块有自己的方法详细点击【threading模块的方法】threading模块:较低级...

Python信号处理实战:使用signal模块响应系统事件

信号是操作系统用来通知进程发生了某个事件的一种异步通信方式。在Python中,标准库的signal模块提供了处理这些系统信号的机制。信号通常由外部事件触发,例如用户按下Ctrl+C、子进程终止或系统资...

Python多线程:让程序 “多线作战” 的秘密武器

一、什么是多线程?在日常生活中,我们可以一边听音乐一边浏览新闻,这就是“多任务处理”。在Python编程里,多线程同样允许程序同时执行多个任务,从而提升程序的执行效率和响应速度。不过,Python...

用python写游戏之200行代码写个数字华容道

今天来分析一个益智游戏,数字华容道。当初对这个游戏颇有印象还是在最强大脑节目上面,何猷君以几十秒就完成了这个游戏。前几天写2048的时候,又想起了这个游戏,想着来研究一下。游戏玩法用尽量少的步数,尽量...

取消回复欢迎 发表评论: