egg+nunjucks+puppeteer实现复杂图片接口

374 阅读4分钟

背景

需求

要求生成每日销售数据海报,推送到企微,由于图片布局比较复杂,所以打算由前端用node中间层,实现图片的生成

项目地址

实现技术栈

egg需要提供两个接口,一个用nunjucks模板引擎生成HTML页面,另一个用第一个接口的url传入puppeteer启动的无头浏览器,截图返回图片

生成图片流程.jpg

实现以上方案主要用到两个库:

  • nunjucks 可用于node端的模板引擎,有非常多的功能,模板继承,变量,过滤器,各种表达式,写过Vue的用起来会比较顺手
  • puppeteer 提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。默认以无头模式运行浏览器。

具体实现

目录结构

root
├── ...
├── app
    ├── controller
        └── report.js
    ├── extend
        ├── application.js
        ├── context.js
        └── filter.js #过滤器
    ├── lib
        ├── http.js
        └── browser.js
    ├── public #静态资源文件夹
        ├── images
            └── bg.png
        └── js
            └── waterMark.js
    ├── service
        └── salesReport.js
    ├── view
        ├── index.nj
        └── layout.nj
    └── router.js
├── config
    ├── plugin.js
    └── config.default.js

安装nunjucks

npm i egg-view-nunjucks --save

/* /config/plugin.js */
module.exports = {
    ...
    nunjucks: {
        enable: true,
        package: 'egg-view-nunjucks'
    }
}
/* /config/config.default.js */
const path = require('path')
module.exports = appInfo => {
    const config = {}
    ...
    // nunjucks config
    config.view = {
        // 模板文件的根目录
        root: path.join(appInfo.baseDir, 'app/view'),
        // 默认后缀
        defaultExtension: 'nj',
        // 默认引擎
        defaultViewEngine: 'nunjucks',
        // 文件后缀映射模板引擎
        mapping: {
            '.nj': 'nunjucks'
        }
    }
    return config
}

编写模板&暴露接口

下面就可以开始愉快的写模板文件了

<!--/view/layout.nj-->
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    {% block css %}{% endblock %}
</head>
<body>
<div id="main">
    {% block body %}{% endblock %}
</div>
</body>
</html>
<!--/view/index.nj-->
{% extends "layout.nj" %}
{% block body %}
    <div style="height: 400px;">hello {{name}}!</div>
{% endblock %}

在server中处理数据并返回给controller

/* /app/service/salesReport.js */
const Service = require('egg').Service;

class SalesReportService extends Service {
  async getSalesReport(query) {
    // 模拟获取后端数据
    const res = {
      name: query.name || 'nunjucks'
    }
    return res
  }
}

module.exports = SalesReportService;

controller调用service

/* /app/controller/report.js */
const Controller = require('egg').Controller

class ReportController extends Controller {
  /**
   * 销售数据html
   */
  async getSalesReport() {
    const { ctx, service } = this
    const data = await service.salesReport.getSalesReport(ctx.query)
    await ctx.render('salesReport/index.nj', data)
}

module.exports = ReportController

router暴露接口

/* /app/router.js */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);

  // 本地HTML服务
  router.get('/getReportHtml', controller.report.getSalesReport);
};

浏览器输入 http://127.0.0.1:7001/getReportHtml就会看到刚刚编写的模板HTML

image.png

安装puppeteer

这个包会默认下载Chromium浏览器,所以会比较慢,如果安装不成功建议切换淘宝源;另一种做法可以用puppeteer-core配合本地Chromium puppeteer vs puppeteer-core npm i puppeteer --save

封装Browser类

/* /app/lib/browser.js */
'use strict'

const puppeteer = require('puppeteer')

class Browser {
    constructor(app) {
        this.app = app
        this.browser = null
    }
    _log(...args) {
        this.app.logger.info('[Browser]', ...args)
    }
    _error(...args) {
        this.app.logger.error('[Browser]', ...args)
    }
    async createBrowser() {
        this.browser = await puppeteer.launch({
            args: [
                '--disable-gpu', // GPU硬件加速
                '--disable-dev-shm-usage', // 创建临时文件共享内存
                '--disable-setuid-sandbox', // uid沙盒
                '--no-first-run', // 没有设置首页。在启动的时候,就会打开一个空白页面。
                '--no-sandbox', // 沙盒模式
                '--no-zygote',
                '--single-process' // 单进程运行
            ]
        })
    }
    async getNewPage(ctx) {
        const page = await this.browser.newPage()

        page.on('error', error => {
            this._error('Browser Page Error:', error)
            page.close()
        })
        page.on('pageerror', error => {
            this._error('Browser Page Error:', error)
            page.close()
        })
        page.on('console', msg => {
            this._log('Browser Page Log:', msg.text())
        })

        return page
    }
    async addWaterMark(page, waterMark) {
        try {
            const stringOptions = JSON.stringify(waterMark)
            await page.addScriptTag({
                url: 'public/js/waterMark.js'
            })
            await page.addScriptTag({
                content: `WaterMarker(${stringOptions})`
            })
        } catch (e) {
            this._error(e)
        }
    }

    async getImageByPath({ url, ctx, waterMark }) {
        const page = await this.getNewPage(ctx)
        const res = await page.goto(url)
        if (JSON.stringify(res) !== '{}' && res._status !== 200) {
          this._error(res)
          throw new Error(res._status)
        }
        const ele = await page.$('body')
        waterMark && (await this.addWaterMark(page, waterMark))
        const img = await ele.screenshot({
            type: 'jpeg',
            quality: 90
        })
        page.close()
        return img
    }
}

module.exports = Browser

挂载&调用

get时把browser实例挂在到app上

/* /app/extend/application.js */
'use strict';

const Browser = require('../lib/browser');
const BROWSER = Symbol('Application#browser');

module.exports = {
  get browser() {
    if (!this[BROWSER]) {
      this[BROWSER] = new Browser(this);
    }
    return this[BROWSER];
  }
};

APP启动时创建Browser实例并挂载到app上

/* app.js */
'use strict'

class AppBootHook {
  constructor(app) {
    this.app = app
  }

  async didReady() {
    try {
      // 启动 browser 实例
      await this.app.browser.createBrowser()
    } catch (e) {
      this.app.logger.error(e)
    }
  }
}

module.exports = AppBootHook

service获取图片逻辑

这里的buildURL方法没有写出,这个方法的作用是把query参数对象序列化成url参数拼接到url后面

/* /app/service/salesReport.js */
'use strict';

const Service = require('egg').Service;
const LOCAL_HOST = 'http://127.0.0.1:7001';

const urls = {
  getSalesReportHtml: `${LOCAL_HOST}/getReportHtml`
};

class SalesReportService extends Service {
  ...
  async getSalesReportImg(query) {
    try {
      const {
        ctx,
        app: { browser }
      } = this

      return await browser.getImageByPath({
        url: ctx.http.buildURL(urls.getSalesReportHtml, query),
        waterMark: {
          text: `禁止外传:${123}`,
          size: '30px',
          color: '#DDDDDD',
          position: {
            top: '10px'
          }
        }
      })
    } catch (e) {
      return null
    }
  }
}

module.exports = SalesReportService;

controller调用service

/* /app/controller/report.js */
async getSalesReportImg() {
    const { ctx, service } = this
    const content = await service.salesReport.getSalesReportImg(ctx.query)
    if (content) {
      ctx.body = content
      ctx.response.type = 'image/jpeg'
    } else {
      ctx.status = 400
      ctx.body = '生成图片失败'
    }
}

router暴露接口

/* /app/router.js */
// 获取销售图片
router.get('/getReportImg', controller.report.getSalesReportImg)

浏览器输入http://127.0.0.1:7001/getReportImg就会得到一张图片

hello.png

总结

最开始的时候有使用过node-html-to-image这个包,跑起来之后发现一张图片pending时间居然要两三秒,看了源码后发现这个包每次生成图片的请求进来都会启动一个无头浏览器,截图返回后再关闭浏览器,这应该就是响应慢的主要原因

所以决定用类似的技术在项目中以符合自身需求的方式实现:

  • APP启动时就启动一个无头浏览器挂在到APP上
  • 每当请求进来的时候在浏览器中打开一个tab,截图之后关闭这个tab,而不是关闭浏览器

但是这种做法势必会增加服务器内存占用,所以在启动浏览器的时候,优化了一下内存的占用

// https://peter.sh/experiments/chromium-command-line-switches/
async createBrowser() {
  this.browser = await puppeteer.launch({
    args: [
      '--disable-gpu', // GPU硬件加速
      '--disable-dev-shm-usage', // 创建临时文件共享内存
      '--disable-setuid-sandbox', // uid沙盒
      '--no-first-run', // 没有设置首页。在启动的时候,就会打开一个空白页面。
      '--no-sandbox', // 沙盒模式
      '--no-zygote',
      '--single-process' // 单进程运行
    ]
  })
}

用这种实现方法获取图片,pending时间是毫秒级的

参考资料