案例——《puppeteer-ssr》解析

其实之前有些过两篇关于用 headless-chrome(puppeteer 是其封装) 做服务端渲染(ssr)的文章,但时隔太久,还是另写一篇清晰完整的好。

项目介绍

尚不成熟的 puppeteer 服务端渲染方案。

原理是当用户访问页面(xxx.xxx.xxx/xxx)的时候,nginx 代理到 puppeteer 渲染服务(127.0.0.1:8080),渲染服务根据配置文件向网站地址(比如:127.0.0.1:8081)发起请求,渲染好页面后返回给用户。

因为是通过浏览器渲染,QPS 当然好不到哪里去,但作为通用渲染方案,其适用性广,代码修改少。

目前这个 demo 还有点问题。比如:当访问某些网站的时候会报错,然后之后的所有请求都会报错。总之,服务是崩掉了。原因未知。

项目地址:https://github.com/tonyzhou1890/puppeteer-ssr

实现解析

思路

nginx 配置两个 server,一个是域名访问的,还有一个是端口访问。其中,域名访问的代理到 ssr 服务,ssr 服务再根据配置文件访问网站所在端口。

ssr 服务接到代理过来的页面地址,先检查是否有有效缓存,如果有,直接返回缓存,如果没有再交给 puppeteer 渲染。返回渲染结果的同时,根据配置的条件决定是否放入缓存。

项目结构

puppeteer-ssr:
│  package.json
│  
├─src
│      config.js
│      server.js
│      ssr.js
│      util.js

配置文件

// config.js
const config = {
  hosts: { // 域名配置
    'xxx.xxx.xxx': {
      proxyHost: '127.0.0.1:8081', // 本地地址
      expire: 10, // 全局页面有效期,单位为秒
      waitForNetworkIdleTime: 300, // 网络空闲等待时间,单位毫秒
      rules: [
        {
          path: '/',
          expire: 0
        },
        {
          path: /^\/collection$/,
          expire: 0,
          waitForNetworkIdleTime: 0
        },
        {
          path: (url) => {
            return /^\/poemList/.test(url)
          },
          expire: 3600
        }
      ]
    },
  },
  pages: 2, // 可同时渲染的页面
}

module.exports = config

上面的配置是一个示例。

  • rules 部分是路径匹配规则,支持字符串(完全匹配)、正则、函数三种方式。
  • expire 是缓存有效时间。如果是 0,则不缓存。
  • waitForNetworkIdleTime 等待网络空闲时间。如果是 0,则不等待页面内部请求完成就返回页面。

工具函数

// util.js
/**
 * 网络等待
 * @param {*} page 页面引用
 * @param {*} timeout 网络空闲时间
 * @param {*} maxInflightRequests 忽略请求数量
 */
async function waitForNetworkIdle(page, timeout, maxInflightRequests = 0) {
  page.on('request', onRequestStarted);
  page.on('requestfinished', onRequestFinished);
  page.on('requestfailed', onRequestFinished);

  let inflight = 0;
  let fulfill;
  let promise = new Promise(x => fulfill = x);
  let timeoutId = setTimeout(onTimeoutDone, timeout);
  return promise;

  function onTimeoutDone() {
    page.removeListener('request', onRequestStarted);
    page.removeListener('requestfinished', onRequestFinished);
    page.removeListener('requestfailed', onRequestFinished);
    
    fulfill();
  }

  function onRequestStarted() {
    ++inflight;
    if (inflight > maxInflightRequests)
      clearTimeout(timeoutId);
  }

  function onRequestFinished() {
    if (inflight === 0)
      return;
    --inflight;
    if (inflight === maxInflightRequests)
      timeoutId = setTimeout(onTimeoutDone, timeout);
  }
}

/**
 * 获取页面配置
 * @param {*} req 
 */
function getPageConfig(req, config) {
  const host = config.hosts[req.get('host')]
  if (host && host.proxyHost) {
    // 当前域名全局规则
    const pageConfig = {
      url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
      version: host.version || '',
      expire: host.expire || 0,
      proxyHost: host.proxyHost,
      waitForNetworkIdleTime: host.waitForNetworkIdleTime === undefined ? 300 : host.waitForNetworkIdleTime, // 默认等待 300 毫秒
    }
    // 当前页面规则
    if (Array.isArray(host.rules)) {
      const rule = host.rules.find(item => {
        // 字符串
        if (typeof item.path === 'string' && item.path === req.originalUrl) {
          return true
        }
        // 正则
        if (typeof item.path === 'object' && item.path instanceof RegExp && item.path.test(req.originalUrl)) {
          return true
        }
        // 函数
        if (typeof item.path === 'function' && item.path(req.originalUrl)) {
          return true
        }
        return false
      })
      if (rule) {
        pageConfig.expire = rule.expire !== undefined ? rule.expire : pageConfig.expire
        pageConfig.waitForNetworkIdleTime = rule.waitForNetworkIdleTime !== undefined ? rule.waitForNetworkIdleTime : pageConfig.waitForNetworkIdleTime
      }
    }
    return pageConfig
  } else {
    return false
  }
}

module.exports = {
  waitForNetworkIdle,
  getPageConfig
}

因为 headless-chrome 自带的网络空闲等待时间配置不够灵活(只能 500ms),所以这里实现了一个相关函数 waitForNetworkIdle。

请求处理

// server.js
const express = require('express')
const Cacheman = require('cacheman')
const CachemanFile = require('cacheman-file')
const router = express.Router()
const ssr = require("./ssr.js")
const config = require('./config')
const util = require('./util')

const factorytList = [] // 正在渲染的页面
let requestList = [] // 等待渲染的页面
const cache = new Cacheman('htmls', {
  engine: 'file'
}) // 缓存

router.get("/*", async (req, res, next) => {
  console.log(req.originalUrl)
  req._pageConfig = util.getPageConfig(req, config)
  // 如果没有相关配置返回错误
  if (!req._pageConfig) {
    endRequest(res, {
      status: 404
    })
    return
  }
  // 先检查缓存
  const html = await getCachePage(req, cache)
  // 缓存里有就直接返回
  if (html) {
    endRequest(res, {
      url: req._pageConfig.url,
      html
    })
  } else { // 否则放入渲染等待队列
    requestList.push({req, res, next})
    assignPage()
  }
})

module.exports = router

/**
 * 分配页面渲染
 */
function assignPage() {
  if (requestList.length && factorytList.length < config.pages) {
    renderPage(requestList.shift(), factorytList)
    console.info('render:' + requestList.length, 'factorytList:' + factorytList.length)
  }
}

/**
 * 检查缓存
 * @param {*} req 
 * @param {*} cache 
 */
async function getCachePage(req, cache) {
  const pageConfig = req._pageConfig
  let result = null
  try {
    result = await cache.get(pageConfig.url)
  } catch (e) {
    console.log(e)
  }
  // 过期或者版本不合,缓存失效
  if (result
    && (result.createTime + result.expire * 1000 < Date.now()
      || (result.version !== pageConfig.version)
    )) {
    result = null
    try {
      await cache.del(pageConfig.url)
    } catch (e) {
      console.log(e)
    }
  }
  return result ? result.html : null
}

/**
 * 存到缓存
 * @param {*} req 
 */
async function setCachePage(req, cache, html) {
  const pageConfig = req._pageConfig
  try {
    // 有期限才缓存
    if (pageConfig.expire) {
      await cache.set(pageConfig.url, {
        createTime: Date.now(),
        expire: pageConfig.expire,
        version: pageConfig.version,
        html
      })
    }
    return true
  } catch (e) {
    console.log(e)
    return false
  }
}

/**
 * 渲染页面
 * @param {*} request 
 */
async function renderPage(request) {
  const pageConfig = request.req._pageConfig
  let html = ''
  let ttRenderMs = ''
  let start = Date.now()
  factorytList.push(start)

  const result = await ssr(
    `${request.req.protocol}://${pageConfig.proxyHost}${request.req.originalUrl}`,
    pageConfig
  )

  factorytList.map((item, index) => {
    if (item === start) {
      ttRenderMs = Date.now() - start
      factorytList.splice(index, 1)
    }
  })

  html = result.html
  // 结束本次请求
  endRequest(request.res, {
    version: pageConfig.version,
    url: pageConfig.url,
    html,
    ttRenderMs
  })
  // 渲染没有错误,尝试缓存
  if (result.renderError === false) {
    setCachePage(request.req, cache, html)
  }
  
  // 队列中相同请求一并返回
  requestList = requestList.filter(item => {
    if (item.req._pageConfig.url === pageConfig.url) {
      endRequest(item.res, {
        url: pageConfig.url,
        html,
        ttRenderMs
      })
      return false
    }
    return true
  })
  // 检查是否还有需要渲染的页面
  assignPage()
  return
}


/**
 * 结束请求
 * @param {*} res 
 * @param {object} options
 */
function endRequest(res, options = {}) {
  if (!options.status || options.status === 200) {
    console.info(`Headless rendered page ${options.url} in: ${options.ttRenderMs || 0}ms`);
  }
  
  const defaultHtml = '<h1>error</h1>'
  // Add Server-Timing! See https://w3c.github.io/server-timing/.
  res.set(
    "Server-Timing",
    `Prerender;dur=${options.ttRenderMs || 0};desc="Headless render time (ms)"`
  )
  return res.status(options.status || 200).send(options.html || defaultHtml) // Serve prerendered page as response.
}

请求进来后先检查配置和缓存,如果命中,返回缓存,否则加入待渲染队列,通过 assignPage 分配渲染。

这里的缓存可以是内存缓存,也可以是磁盘缓存。鉴于页面比较多,所以使用磁盘缓存更合理。

渲染页面

// ssr.js
const puppeteer = require("puppeteer");
const util = require('./util')

// 存储browsweWSendpoint
let WS = null

/**
 * 渲染页面
 * @param {*} url 
 * @param {*} pageConfig
 */
async function ssr(url, pageConfig) {
  let renderError = false // 渲染是否出错
  let browser = null
  let html = '' // 渲染结果
  if (WS) {
    browser = await puppeteer.connect({ browserWSEndpoint: WS })
  } else {
    browser = await puppeteer.launch({headless: true,
      args: [
        '--no-sandbox',
        '--disable-gpu',
        '--disable-dev-shm-usage',
        '--disable-setuid-sandbox',
        '--no-zygote',
        '--single-process'
      ]});
    WS = browser.wsEndpoint()
  }
  
  const page = await browser.newPage();
  // 1. Intercept network requests.
  await page.setRequestInterception(true);

  page.on('request', req => {
    // 2. Ignore requests for resources that don't produce DOM
    // (images, stylesheets, media).
    const whitelist = ['document', 'script', 'xhr', 'fetch'];
    if (!whitelist.includes(req.resourceType())) {
      return req.abort();
    }

    // 3. Pass through all other requests.
    req.continue();
  });
  try {
    // util.waitForNetworkIdle 等待网络请求完成
    await Promise.all([
      page.goto(url, {
        timeout: 5000
      }),
      util.waitForNetworkIdle(page, pageConfig.waitForNetworkIdleTime, 0)
    ])
  } catch (err) {
    console.error(err);
    await page.close();
    await browser.close()
    
    ws = null
    renderError = true
    // throw new Error("page.goto/waitForSelector timed out.");
  }

  // 检查渲染是否出错
  if (renderError) {
    html = '<h1>发生了一些错误,请待会再试!</h1>'
  } else {
    html = await page.content(); // serialized HTML of page DOM.
    await page.close();
    await browser.disconnect()

    // 解决闪屏
    html = html.replace('id="app"', 'id="app" data-server-rendered="true"')
  }
  
  return { html, renderError }
}

module.exports = ssr;

这里解决闪屏代码是针对 vue 项目的。其他项目可以针对性的改造,比如通过配置文件指定。

性能

我的虚拟机配置如下:

  • cup:i5-6300h——分配两核
  • 内存:分配 1G

测试的性能如下(使用的简单页面:poem.tony93.top):

  • 无缓存——QPS 大概为 5
  • 磁盘缓存——QPS 大概为 25。这个波动比较大,和缓存命中有关。

使用的是谷歌浏览器,每十个页面为一组。因为浏览器并发限制,这里的结果仅供参考。

相比于 nuxt 和传统的后端模板方案,这个性能并不好(使用内存缓存应该会好很多,比如将访问频繁的页面放到内存,其他的仍然放磁盘),但是因为是 SPA 应用,只有用户打开网站的时候会走 ssr,后续的页面操作基本都在用户浏览器渲染,所以性能应该还可以满足一般网站。

后来我也测试了线上环境(阿里云 1 核 2G 1M),结果差不多。从后台资源使用率来看,cpu 压力很大。两个页面并行时,cpu 占用达到 80%。

注意事项

  • 在渲染某些页面(我一个非根目录 hash 模式的项目)的时候,puppeteer 会报错,代码中的 try catch 并没有解决该问题。此后的所有请求都无法进行,也就是服务崩了。具体原因还不清楚。应该不是 hash 模式的问题。目前只能说确保项目是 history 模式很重要。

总结

puppeteer 作为通用渲染方案,还是值得一试的。并且其功能不仅仅是针对 SEO 的服务端渲染,还可以进行页面截图、后台海报渲染、爬虫页面抓取等。

不过,自己摸索肯定是要成本的。对于访问量不是特别大的项目,建议还是购买第三方服务。

此外,服务端渲染可以仅针对搜索引擎爬虫。google 的爬虫已经支持 SPA 应用,可以排除。