前端基础建设与架构30 实现高可用:使用 Puppeteer 生成性能最优的海报系统

在前面第 28 讲“设计性能守卫系统:完善 CI/CD 流程”中我们提到了 Puppeteer。事实上,以 Puppeteer 为代表的 Headless 浏览器在 Node.js 中的应用极为广泛,这一讲,就让我们对 Puppeteer 进行深入分析和应用。

Puppeteer 介绍和原理

我们先对 Puppeteer 进行一个基本介绍。(Puppeteer 官方地址)

Puppeteer 是一个 Node 库,它提供了一整套高级 API,通过 DevTools 协议控制 Chromium 或 Chrome。正如其翻译为“操纵木偶的人”一样,你可以通过 Puppeteer 提供的 API 直接控制 Chrome,模拟大部分用户操作,进行 UI 测试或者作为爬虫访问页面来收集数据。

整个定义非常好理解,这里需要开发者注意的是,Puppeteer 在 1.7.0 版本之后,会同时给开发者提供:

  • Puppeteer

  • Puppeteer-core

两个版本。它们的区别在于载入安装 Puppeteer 时,是否会下载 Chromium。Puppeteer-core 默认不下载 Chromium,同时会忽略所有 puppeteer_* 环境变量。对于开发者来说,使用 Puppeteer-core 无疑更加轻便,但是需要提前保证环境中已经具有可执行的 Chromium(具体说明可见puppeteer vs puppeteer-core)。

具体 Puppeteer 的应用场景有:

  • 为网页生成页面 PDF 或者截取图片;

  • 抓取 SPA(单页应用)并生成预渲染内容;

  • 自动提交表单,进行 UI 测试、键盘输入等;

  • 创建一个随时更新的自动化测试环境,使用最新的 JavaScript 和浏览器功能直接在最新版本的 Chrome 中执行测试;

  • 捕获网站的timeline trace,用来帮助分析性能问题;

  • 测试浏览器扩展。

下面我们就梳理一些 Puppeteer 应用的重点场景,并详细介绍如何使用 Puppeteer 实现一个高性能的海报 Node.js 服务。

Puppeteer 在 SSR 中的应用

区别于第 27 讲介绍的“同构渲染架构:实现一个 SSR 应用”,使用 Puppeteer 实现服务端预渲染出发点完全不同。这种方案最大的好处是不需要对项目代码进行任何调整,却能获取到 SSR 应用的收益。当然,相比同构渲染,基于 Puppeteer 技术的 SSR 在灵活性和扩展性上都有所局限。甚至在 Node.js 端渲染的性能成本也较高,不过该技术也逐渐落地,并在很多场景发挥了重要价值。

比如对于这样的一个页面,代码如下:


<body>
  <div id="container">
    
  div>
body>
<script>
// 使用 JavaScript 脚本,进行 CSR 渲染
function renderPosts(posts, container) {
  const html = posts.reduce((html, post) => {
    return `${html}
      
  • ${post.title}

    ${post.summary}

    ${post.content}

  • `
    ; }, ''); container.innerHTML = `
      ${html}
    `
    ; } (async() => { const container = document.querySelector('#container'); // 发送数据请求 const posts = await fetch('/posts').then(resp => resp.json()); renderPosts(posts, container); })();
    script>

    该页面是一个典型的 CSR 页面,依靠 Ajax,实现了页面动态化渲染。

    当在 Node.js 端使用 Puppeteer 渲染时,我们可以实现ssr.mjs,完成渲染任务,如下代码:

    import puppeteer from 'puppeteer';
    // 将已经渲染过的页面,缓存在内存中
    const RENDER_CACHE = new Map();
    async function ssr(url) {
    	// 命中缓存
      if (RENDER_CACHE.has(url)) {
        return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
      }
      const start = Date.now();
      // 使用 Puppeteer launch 一个无头浏览器
      const browser = await puppeteer.launch();
      const page = await browser.newPage();
      try {
        // 访问页面地址直到页面网络状态为 idle
        await page.goto(url, {waitUntil: 'networkidle0'});
        // 确保 #posts 节点已经存在
        await page.waitForSelector('#posts');
      } catch (err) {
        console.error(err);
        throw new Error('page.goto/waitForSelector timed out.');
      }
    	// 获取 html 内容
      const html = await page.content(); 
      // 关闭无头浏览器
      await browser.close();
      const ttRenderMs = Date.now() - start;
      console.info(`Headless rendered page in: ${ttRenderMs}ms`);
    	// 进行缓存存储
      RENDER_CACHE.set(url, html);
      return {html, ttRenderMs};
    }
    export {ssr as default};
    

    对应server.mjs代码:

    import express from 'express';
    import ssr from './ssr.mjs';
    const app = express();
    app.get('/', async (req, res, next) => {
      // 调用 SSR 方法渲染页面
      const {html, ttRenderMs} = await ssr(`xxx/index.html`);
      res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
      return res.status(200).send(html);
    });
    app.listen(8080, () => console.log('Server started. Press Ctrl+C to quit'));
    

    当然上述实现比较简陋,只是进行原理说明。如果更进一步,我们可以从以下几个角度进行优化:

    • 改造浏览器端代码,防止重复请求接口;

    • 在 Node.js 端,abort 掉不必要的请求,以得到更快的服务端渲染响应速度;

    • 将关键资源内连进 HTML;

    • 自动压缩静态资源;

    • 在 Node.js 端,渲染页面时,重复利用 Chrome 实例。

    这里我们用简单代码进行说明:

      import express from 'express';
      import puppeteer from 'puppeteer';
      import ssr from './ssr.mjs';
      // 重复使用 Chrome 实例
      let browserWSEndpoint = null;
      const app = express();
    

    app.get(‘/’, async (req, res, next) => {
    if (!browserWSEndpoint) {
    // 一下两行代码不必随着渲染重复执行
    const browser = await puppeteer.launch();
    browserWSEndpoint = await browser.wsEndpoint();
    }

    const url = `${req.protocol}://${req.get('host')}/index.html`;
    const {html} = await ssr(url, browserWSEndpoint);
    
    return res.status(200).send(html);
    

    });

    至此,我们从原理和代码层面分析了 Puppeteer 在 SSR 中的应用。接下来我们来了解更多的 Puppeteer 使用场景,请你继续阅读。

    Puppeteer 在 UI 测试中的应用

    Puppeteer 在 UI 测试(即端到端测试)中也可以大显身手,比如和 Jest 结合,通过断言能力实现一个完备的端到端测试系统。

    比如下面代码:

    const puppeteer = require('puppeteer');
    // 测试页面 title 符合预期
    test('baidu title is correct', async () => {
    	// 启动一个无头浏览器
      const browser = await puppeteer.launch()
      // 通过无头浏览器访问页面
      const page = await browser.newPage()
      await page.goto('https://xxxxx')
      // 获取页面 title
      const title = await page.title()
      // 使用 Jest 的 test 和 expect 两个全局函数进行断言
      expect(title).toBe('xxxx')
      await browser.close()
    });
    

    上面代码简单清晰地勾勒出了 Puppeteer 结合 Jest 实现端到端测试的场景。实际上,现在流行的主流端到端测试框架,比如 Cypress 原理都如上代码所示。

    接下来,我们来分析 Puppeteer 结合 Lighthouse 应用场景。

    Puppeteer 结合 Lighthouse 应用场景

    在第 28 讲“设计性能守卫系统:完善 CI/CD 流程”中我们也提到了 Lighthouse,既然 Puppeteer 可以和 Jest 结合实现一个端到端测试框架,当然也可以和 Lighthouse 结合——这就是一个简单的性能守卫系统的雏形。

    我们再通过代码来说明,如下代码:

    const chromeLauncher = require('chrome-launcher');
    const puppeteer = require('puppeteer');
    const lighthouse = require('lighthouse');
    const config = require('lighthouse/lighthouse-core/config/lr-desktop-config.js');
    const reportGenerator = require('lighthouse/lighthouse-core/report/report-generator');
    const request = require('request');
    const util = require('util');
    const fs = require('fs');
    (async() => {
        // 默认配置 
        const opts = {
            logLevel: 'info',
            output: 'json',
            disableDeviceEmulation: true,
            defaultViewport: {
                width: 1200,
                height: 900
            },
            chromeFlags: ['--disable-mobile-emulation']
        };
    		// 使用 chromeLauncher 启动一个 chrome 实例
        const chrome = await chromeLauncher.launch(opts);
        opts.port = chrome.port;
    // 使用 puppeteer.connect 连接 chrome 实例
        const resp = await util.promisify(request)(`http://localhost:${opts.port}/json/version`);
        const {webSocketDebuggerUrl} = JSON.parse(resp.body);
        const browser = await puppeteer.connect({browserWSEndpoint: webSocketDebuggerUrl});
    

    // Puppeteer 访问逻辑
    page = (await browser.pages())[0];
    await page.setViewport({ width: 1200, height: 900});
    console.log(page.url());
    // 使用 lighthouse 产出报告
    const report = await lighthouse(page.url(), opts, config).then(results => {
    return results;
    })
    ;
    const html = reportGenerator.generateReport(report.lhr, ‘html’);
    const json = reportGenerator.generateReport(report.lhr, ‘json’);
    await browser.disconnect();
    await chrome.kill();
    // 将报告写入文件系统
    fs.writeFile(‘report.html’, html, (err) => {
    if (err) {
    console.error(err);
    }
    });
    fs.writeFile(‘report.json’, json, (err) => {
    if (err) {
    console.error(err);
    }
    });
    })();

    整体流程非常清晰,是一个典型的 Puppeteer 与 Lighthouse 结合的案例。事实上,我们看到 Puppeteer 或 Headless 浏览器可以和多个领域能力相结合,在 Node.js 服务上实现平台化能力。接下来,我们再看最后一个案例,请读者继续阅读。

    Puppeteer 实现海报 Node.js 服务

    社区上我们常见生成海报的技术分享。应用场景很多,比如文稿中划线,进行“金句分享”,如下图所示:

    前端基础建设与架构30 实现高可用:使用 Puppeteer 生成性能最优的海报系统_第1张图片

    一般来说,生成海报可以使用html2canvas这样的类库完成,这里面的技术难点主要有跨域处理、分页处理、页面截图时机处理等。整体来说,并不难实现,但是稳定性一般。另一种生成海报的方式就是使用 Puppeteer,构建一个 Node.js 服务来做页面截图。

    下面我们来实现一个名叫 posterMan 的海报服务,整体技术链路如下图:

    前端基础建设与架构30 实现高可用:使用 Puppeteer 生成性能最优的海报系统_第2张图片

    核心技术无外乎使用 Puppeteer,访问页面并截图,这与前面几个场景是一样的,如下图所示:

    前端基础建设与架构30 实现高可用:使用 Puppeteer 生成性能最优的海报系统_第3张图片

    这里需要特别强调的是,为了实现最好的性能,我们设计了一个链接池来存储 Puppeteer 实例,以备所需,如下图所示:

    前端基础建设与架构30 实现高可用:使用 Puppeteer 生成性能最优的海报系统_第4张图片

    在实现上,我们依赖generic-pool库,这个库提供了 Promise 风格的通用池,可以用来对一些高消耗、高成本资源的调用实现防抖或拒绝服务能力,一个典型场景是对数据库的连接。这里我们把它用于 Puppeteer 实例的创建,如下代码所示:

    const puppeteer = require('puppeteer')
    const genericPool = require('generic-pool')
    const createPuppeteerPool = ({
      // pool 的最大容量
      max = 10,
      // pool 的最小容量
      min = 2,
      // 连接在池中保持空闲而不被回收的最小时间值
      idleTimeoutMillis = 30000,
      // 最大使用数
      maxUses = 50,
      // 在连接池交付实例前是否先经过 factory.validate 测试
      testOnBorrow = true,
      puppeteerArgs = {},
      validator = () => Promise.resolve(true),
      ...otherConfig
    } = {}) => {
      const factory = {
      	// 创建实例
        create: () =>
          puppeteer.launch(puppeteerArgs).then(instance => {
            instance.useCount = 0
            return instance
          }),
        // 销毁实例
        destroy: instance => {
          instance.close()
        },
        // 验证实例可用性
        validate: instance => {
          return validator(instance).then(valid =>
            // maxUses 小于 0 或者 instance 使用计数小于 maxUses 时可用
            Promise.resolve(valid && (maxUses <= 0 || instance.useCount < maxUses))
          )
        }
      }
      const config = {
        max,
        min,
        idleTimeoutMillis,
        testOnBorrow,
        ...otherConfig
      }
      // 创建连接池
      const pool = genericPool.createPool(factory, config)
      const genericAcquire = pool.acquire.bind(pool)
      // 池中资源连接时进行的操作
      pool.acquire = () =>
        genericAcquire().then(instance => {
          instance.useCount += 1
          return instance
        })
      pool.use = fn => {
        let resource
        return pool
          .acquire()
          .then(r => {
            resource = r
            return r
          })
          .then(fn)
          .then(
            result => {
              // 释放资源
              pool.release(resource)
              return result
            },
            err => {
              pool.release(resource)
              throw err
            }
          )
      }
      return pool
    }
    module.exports = createPuppeteerPool
    

    使用连接池的方式也很简单,如下代码,./pool.js

    const pool = createPuppeteerPool({
      puppeteerArgs: {
        args: config.browserArgs
      }
    })
    module.exports = pool
    

    有了“武器弹药”,我们来看看渲染一个页面为海报的具体逻辑。如下代码所示render方法,该方法支持接受一个 URL 也支持接受具体的 HTML 字符串去生成相应海报:

    // 获取连接池
    const pool = require('./pool')
    const config = require('./config')
    const render = (ctx, handleFetchPicoImageError) =>
      // 使用连接池资源
      pool.use(async browser => {
        const { body, query } = ctx.request
        // 打开新的页面
        const page = await browser.newPage()
        // 服务支持直接传递 HTML 字符串内容
        let html = body
    		// 从请求服务的 query 获取默认参数
        const {
          width = 300,
          height = 480,
          ratio: deviceScaleFactor = 2,
          type = 'png',
          filename = 'poster',
          waitUntil = 'domcontentloaded',
          quality = 100,
          omitBackground,
          fullPage,
          url,
          useCache = 'true',
          usePicoAutoJPG = 'true'
        } = query
        let image
        try {
        	// 设置浏览器视口
          await page.setViewport({
            width: Number(width),
            height: Number(height),
            deviceScaleFactor: Number(deviceScaleFactor)
          })
          if (html.length > 1.25e6) {
            throw new Error('image size out of limits, at most 1 MB')
          }
    			// 访问 URL 页面
          await page.goto(url || `data:text/html,${html}`, {
            waitUntil: waitUntil.split(',')
          })
    			// 进行截图
          image = await page.screenshot({
            type: type === 'jpg' ? 'jpeg' : type,
            quality: type === 'png' ? undefined : Number(quality),
            omitBackground: omitBackground === 'true',
            fullPage: fullPage === 'true'
          })
        } catch (error) {
          throw error
        }
        ctx.set('Content-Type', `image/${type}`)
        ctx.set('Content-Disposition', `inline; filename=${filename}.${type}`)
        await page.close()
        return image
      })
    module.exports = render
    

    至此,基于 Puppeteer 的海报系统就已经开发完成了。它是一个对外的 Node.js 服务。

    我们也可以生成各种语言的 SDK 客户端,调用该海报服务。比如一个简单的 Python 版 SDK 客户端实现如下代码:

    import requests
    class PosterGenerator(object):
        // ...
        def generate(self, **kwargs):
            """
            生成海报图片,返回二进制海报数据
            :param kwargs: 渲染时需要传递的参数字典
            :return: 二进制图片数据
            """
            html_content = render(self._syntax, self._template_content, **kwargs)
            url = POSTER_MAN_HA_PROXIES[self._api_env.value]
            try:
            		// post 请求海报服务
                resp = requests.post(
                    url,
                    data=html_content.encode('utf8'),
                    headers={
                        'Content-Type': 'text/plain'
                    },
                    timeout=60,
                    params=self.config
                )
            except RequestException as err:
                raise GenerateFailed(err.message)
            else:
                if not resp:
                    raise GenerateFailed(u"Failed to generate poster, got NOTHING from poster-man")
                try:
                    resp.raise_for_status()
                except requests.HTTPError as err:
                    raise GenerateFailed(err.message)
                else:
                    return resp.content
    

    总结

    这一讲我们介绍了 Puppeteer 的各种应用场景,并重点介绍了一个基于 Puppeteer 设计实现的海报服务系统。

    本讲内容总结如下:

    前端基础建设与架构30 实现高可用:使用 Puppeteer 生成性能最优的海报系统_第5张图片

    通过这几讲的学习,希望你能够从实践出发,对 Node.js 落地应用有一个更全面的认知。这里我也给大家留一个思考题,你平时开发中使用过 Puppeteer 吗?你还能基于 Puppeteer 想到哪些使用场景呢?欢迎在留言区和我分享你的经验。


    精选评论

    *聪:

    用puppeteer来做爬虫项目

    *靖:

    请问最后思维导图里面的英文字体是什么呀?好好看

        编辑回复:

        是 XMind 中的 Segoe Print 哦~

    *炜:

    前几天面试腾讯,被问到如果优化puppeteer,链接池还是没有答上来.

        编辑回复:

        多积累一下经验,下次一定OK的!

    **3336:

    老师讲的案例都有代码仓库嘛

        编辑回复:

        注意看文中加超链接的地方,相关地址都在呐~

    在西班牙语中,有一个很特别的的词语叫“Sobremesa”,它专指“吃完饭后,大家在饭桌上意犹未尽交谈的那段短暂而美好时光”。现在,我们的专栏也已经全部更新完毕,历经“枯燥的程序知识”,我希望在最后的内容中,大家能够放松心情,畅所欲言。今天,我将会从“现代项目库的编写”谈基建和架构,从“如何保持竞争力”谈个人价值和方向。就让这些非技术内容作为全部专栏内容的结束语吧。

    库,不仅是能有

    此刻,2021 新年已过,前端技术和解决方案无时无刻不在确立着新的格局。“如何写好一个现代化的开源库”这个话题始终值得讨论。当然,这对于初级开发者也许并不简单。比如,我们要思考:

    • 开源证书如何选择;

    • 库文档如何编写,才能做到让使用者快速上手;

    • TODO 和 CHANGELOG 需要遵循哪些规范,有什么讲究;

    • 如何完成一个流畅 0 error, 0 warning 的构建流程;

    • 如何确定编译范围和实施流程;

    • 如何设计合理的模块化方案;

    • 如何打包输出结果,以适配多种环境;

    • 如何设计自动规范化链路;

    • 如何保证版本规范和 commit 规范;

    • 如何进行测试;

    • 如何引入可持续集成;

    • 如何引入工具使用和配置的最佳实践;

    • 如何设计 APIs 等。

    其中的任何一个点都能牵引出前端语言规范和设计、工程化基建等相关知识。比如,让我们来思考构建和打包过程,如果我是一个库开发者,我的预期将会是:

    • 我要用 ES Next 优雅地写库代码,因此要通过 Babel 或者 Bublé 进行转义;

    • 我的库产出结果要能够运行在浏览器和 Node 环境中,我会有自定义的兼容性要求;

    • 我的库产出结果要支持 AMD 或者 CMD 等模块化方案,因此对于不同环境,采用的模块化方案也不同;

    • 我的库产出结果要能够和 Webpack、Rollup、Gulp 等工具无缝配合。

    根据这些预期,我就要纠结:“到底用 Rollup 对库进行打包还是用 Webpack 进行打包”“如何真正意义上实现 Tree shaking”“如何选择并比较不同的工具”“如何合理地使用 Babel,如何使用插件”等话题

    把这些问题想通,我们距离项目的基建和架构就能更进一步。因此,如果在项目中遇到了公共库使用问题,就让我们花些时间,追根刨底,研究个明白。同时,你要大胆地设计公共库,站在使用者的角度想问题,不断打磨公共库的设计理念和使用体验,很快就会有成长。最后,对社区开源知识保持学习,相信你一定会有所收获。

    如何保持竞争力

    提到竞争力,我想先说一个“程序员”的修养,这个话题非常开放。我们会想到很多关键词,比如:

    • 保持热情

    • 谦虚谨慎

    • 学会阅读

    • 学会提问

    • 善用搜索

    • 学会写作(文档/博客等)

    • “科学上网”

    • 时间管理

    • 知识管理

    • 英语学习

    我个人很不喜欢所谓的成功学和方法论,更讨厌制造焦虑、兜售鸡汤。免入俗套,我打算从两种动物谈起,来说一些“废话”。

    不管是学习进阶之路,还是工作中的项目,我们能够遇到的真正问题只有两类:第一种是看不见的,我把它比作为“黑天鹅”,总会在你意想不到的时间和地点出现,并彻底颠覆一切;第二种是被我们视而不见的,我把它比喻成“灰犀牛”,你知道且习惯于它的存在,但是它会在某个时刻突然爆发,一旦爆发就会席卷一切,令人无从抵抗。

    而项目开发和个人成长都有“黑天鹅”和“灰犀牛”的危机。

    黑天鹅

    “新技术的爆发,技术的更新换代”就是职业生涯的黑天鹅。但我们需要辩证地来认识它:对于菜鸟来说,新技术和未知领域让年轻人有机会弯道超车,减少因为欠缺经验和阅历而带来的劣势;对于有一定工作经验和阅历的程序员来说,“颠覆”和“变革”这样的词语似乎不那么友好。

    但是新技术说到底也只是工具,真正资深程序员的核心价值在于:逻辑、分析、数据、算法等抽象能力。技术工具只是这些抽象能力的表现形式。从汇编语言转到 C 语言,其实更能发挥 C 的强大控制能力;从 C 转到 Java,只需要理解面向对象和虚拟机就能很快适应并脱颖而出;从 Java 转到 Python 的程序员,甚至都会感叹写代码“太简单了”!

    总之,黑天鹅既是危机,也是机会。新技术作为新工具,总能带来新的价值蓝海。如果能把黑天鹅当作机会,保持敏感、好奇和进取的心态,扩展技能树,就能驯服来势汹汹的新技术。希望我们所有人一起共勉。

    灰犀牛

    社会中,很多职业是“越老越值钱”:老警察、老医生、老艺术家,说起来就让人觉得技术高超,令人信赖。

    职业进阶就是一只灰犀牛。在悄然溜走的时间中,我们可能习惯了日复一日的重复劳动。程序员怕的不是变老,而是变老的同时没有变强。如何击退这只灰犀牛,就需要我们从天天接触的工作代码入手,从熟悉的事物出发,找到突破口。

    比如,在这个专栏的《现代化前端开发和架构生态篇》中,我重点突出了:如何增强程序的健硕性、如何提升我们的开发效率、如何持续不断地完善项目、如何从零开始打磨基础构建体系。仔细思考,里面的内容也许就能接入你的项目当中。

    从机械的工作抽象出更完美的工程化流程,这样的话题似乎永远说不完。我也总有新的心得和体会想和大家一起分享、交流。专栏已完结,但是衷心希望我们的技术探险之旅,仅仅是拉开帷幕。

    写在最后的话

    站在跑道的起点,你不知道跑到哪里肌肉会开始发痛,呼吸急促,想要停下来休息;在二三十岁的年纪,我们无从得知学习了一门课程,能对自己的水平提高和职业发展起多大作用。

    也许无论是跑步还是写代码,都是在探索生命的种种可能。

    ——不去跑,永远不知道能跑多远;不去做,永远不知道能做多好。

    本专栏到此结束,衷心希望各位读者一切顺利。


    精选评论

    *浩:

    最后的结语写的真棒!!给老师点赞!!

    **先森:

    很棒的专栏,这个专栏我至少会看5遍,并对其中涉及的思路以及知识进行探究,感谢作者好文。

    **平:

    很实在,很接地气的专栏,解决了自己的很多疑惑

    **训:

    完结

    Darcy:

    完结,撒花。希望还可以看到老师其他的作品

    *祁:

    喜欢最后的总结

    你可能感兴趣的:(前端,教程,前端,node.js,Puppeteer)