后端忙?前端自己干:Egg+Pupeteer实现导出pdf服务化

1,632 阅读4分钟

前言

业务需要将页面导出为pdf,并尽可能保证模块在分页时不被切割,所以分享一下踩坑的过程

客户端导出PDF

优点:

  1. 资源节省:不需要服务器处理,减少了服务器负担,用户的浏览器直接生成PDF。
  2. 即时反馈:用户可以快速看到生成的结果,适合小型文档或简单内容的导出。
  3. 易于实现:使用库如html2canvasjsPDF,可以快速实现导出功能,代码量较少。

缺点:

  1. 性能限制:受限于用户的浏览器性能,处理复杂文档时可能会出现卡顿。
  2. 兼容性问题:不同浏览器对CSS和字体的支持不一致,可能导致PDF格式不一致或乱码。
  3. 功能限制:对于复杂的布局和样式,可能无法完全还原,尤其是涉及动态内容时。

服务器端导出PDF

优点:

  1. 强大的处理能力:服务器通常有更强的计算能力,可以处理复杂的文档和大量数据。
  2. 一致性:生成的PDF格式在不同设备和浏览器上保持一致,避免了客户端的兼容性问题。
  3. 灵活性:可以使用如Puppeteer等工具,支持更复杂的样式和布局,甚至可以在生成PDF前进行数据处理。

缺点:

  1. 资源消耗:服务器需要处理PDF生成请求,可能增加服务器负担,尤其是在高并发情况下。
  2. 延迟:用户需要等待服务器处理完成,可能导致体验不如客户端即时。
  3. 实现复杂性:需要设置服务器环境和处理请求,开发和维护成本较高

客户端导出

这里引用一篇文章: juejin.cn/post/713837…

服务端导出

核心能力

基于pupeteer,那pupeteer是什么? Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome 浏览器。它默认以无头(headless)模式运行,但也可以配置为在可见("有头")浏览器中运行。使用 Puppeteer,你可以实现几乎所有在浏览器中手动执行的操作,例如:

  • 自动提交表单、UI 测试、键盘输入等;
  • 创建自动化测试环境,使用最新的 JavaScript 和浏览器功能;
  • 捕获网站的 timeline trace 以帮助分析性能问题;
  • 测试 Chrome 扩展程序;
  • 生成页面的屏幕截图和 PDF;
  • 抓取 SPA(单页应用)并生成预渲染内容(即 "SSR"(服务器端渲染))

开发一个简单版接口

步骤1:基于koa搭建http服务

...
const Koa = require('koa')
const Router = require('koa-router')
const http = require('http')

const app = new Koa()
const router = new Router();

router.get('/export/pdf', async (ctx) => {
   await createPdf({
    hostname: ctx.hostname,
    id: ctx.query.id,
    type: ctx.query.type,
    token: ctx.query.token
  });
  let filePath = './page.pdf'
  let file = fs.createReadStream(filePath)
  let fileName = filePath.split('\').pop()
  ctx.set('Content-disposition', 'attachment;filename=' + encodeURIComponent(fileName))
  ctx.set('Content-type', 'application/pdf')
  ctx.body = file
});


app.use(router.routes()).use(router.allowedMethods())
http.createServer(app.callback()).listen(9527);

步骤2:调用pupeteer实现导出

async function createPdf(obj) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setBypassCSP(false);
  await page.setExtraHTTPHeaders({
    Authorization:
      "Bearer " + obj.token,
  });
  await page
    .mainFrame()
    .goto(`http://${obj.hostname}:3002/exportReport?id=${obj.id}&type=${obj.type}`, {
      waitUntil: ["networkidle2"],
    });
  // await page.reload({ timeout: 60000, waitUntil: ['networkidle2'] })
  await page.pdf({
    path: "page.pdf",
    displayHeaderFooter: false,
    footerTemplate: "<p>pdf测试</p>",
    format: "A4",
    preferCSSPageSize: false,
    printBackground: true,
    margin: {
      top: "20",
      bottom: "20",
    },
  });
  // page.close()
  browser.close();
}

基于以上逻辑,即可完成简单的导出pdf

用于生产环境的服务

  • 如果对业务稳定性,性能没有要求:通过以上单文件的方式+pm2的方式即可部署
  • 考虑到后期利用pupeteer的其他能力,以及服务稳定性,所以考虑使用成熟的基于koa的框架,最终选型egg
遇到的问题
1.如何解决打开客户端提供的url时,依赖的接口鉴权问题?

解决办法有两种:

  1. 方式一:将url中依赖的接口设置为白名单(不鉴权)
  2. 方式二:客户端提供url时,携带接口鉴权的信息,通过pupeteer的api完成设置
...
const browser = await puppeteer.launch({});
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
    ['token']: 'xxxxxx',
  });
2.pupeteer打开客户端提供的url时,如何判断整个页面已经渲染完成?

使用goto方法时,官方提供了如下等待方式

export type PuppeteerLifeCycleEvent =
  /**
   * Waits for the 'load' event.
   */
  | 'load'
  /**
   * Waits for the 'DOMContentLoaded' event.
   */
  | 'domcontentloaded'
  /**
   * Waits till there are no more than 0 network connections for at least `500`
   * ms.
   */
  | 'networkidle0'
  /**
   * Waits till there are no more than 2 network connections for at least `500`
   * ms.
   */
  | 'networkidle2';

设置"domcontentloaded"时发现导出的pdf模块数据缺失,单页应用中,此事件只会触发一次,不适用于打开url时有路由跳转;

这里改用由客户端业务侧来控制完成标识

  • 步骤1:客户端调用依赖的接口数据后,设置完成标识,调用接口时将标识告诉服务端

<template>
    <div>....</div>
    <div v-if="flag" class="is-rendered-tag"></div>
</template>

flag=false;

async getData() {
    await api1();
    await api2();
    ...
    
    nextTick(()=>{
        flag=true;
    })
}

  • 步骤2:服务端接收到完成标识后,等待元素出现则认为页面渲染完成
const browser = await puppeteer.launch({});
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
    ['token']: 'xxxxxx',
  });
// 打开url
await page.mainFrame().goto(fullUrl);
// 等待渲染成功的标识元素出现
await page.waitForSelector(completeTag);
3.导出的pdf中图片或者其他模块被切割的问题?

基于CSS中的属性page-break-afterpage-break-beforepage-break-inside

  1. page-break-before属性:

    • 定义:指定在元素之前是否应该插入分页符。

    • 可能的值:

      • auto:由浏览器决定是否在元素前进行分页。
      • always:总是在元素前进行分页。
      • avoid:避免在元素前进行分页。
      • left:如果元素在页面的左侧,则总是在元素前进行分页。
      • right:如果元素在页面的右侧,则总是在元素前进行分页。
  2. page-break-after属性:

    • 定义:指定在元素之后是否应该插入分页符。
    • 可能的值与page-break-before相同。
  3. page-break-inside属性:

    • 定义:指定元素内部的内容是否允许在分页时被分割到不同的页面上。
    • 可能的值:
      • auto:由浏览器决定元素内部内容是否可以跨页。
      • avoid:避免将元素内部的内容分割到不同的页面上。

在实际应用中,这些属性可以帮助你控制页面的布局,尤其是在打印长文档或创建PDF文件时,确保内容的连续性和格式的正确性。例如,如果你不希望一个图片或表格被分割到两页上,可以在该元素上使用page-break-inside: avoid;来避免这种情况

代码示例:

// 元素child1会在它之前插入分页符
// 元素child2会在它之后插入分页符
// 元素child3内部不允许插入换行符(内容不能超过一页,当前页放不下,自动换到下一页)

<template>
    <div>
        <div class="child1" style="page-break-before: always"></div>
        <div class="child2" style="page-break-after: always"></div>
        <div class="child3" style="page-break-after: avoid"></div>
    </div>
</template>


// css中可以通过如下方式设置打印样式
@media print {
  table 
        tr {
          page-break-inside: avoid !important; // tr不能被分割
        }

        thead {
          display: table-header-group !important; // 分页后每一页都有表头
        }
      }
   }
}
4.服务如何在不同项目中部署复用?

如果项目A,项目B(内网部署的项目)等等都需要该导出pdf的服务,如何复用呢? 当然是将服务docker化,然后集成到CI/CD流程中;

上Dockerfile脚本:

# 使用官方的 Node.js 镜像作为基础镜像
FROM node:20.15.0-slim

# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Note: this installs the necessary libs to make the bundled version of Chrome for Testing that Puppeteer
# installs, work.
RUN apt-get update \
    && apt-get install -y wget gnupg \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt-get update \
    && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \
      --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

# 创建工作目录
RUN mkdir -p /home/app
RUN mkdir -p /home/etahadmin
# 挂在路径
VOLUME /home/etahadmin
# 设置工作目录
WORKDIR /home/app

# 将 package.json 和 package-lock.json 复制到工作目录
COPY package*.json ./

# 安装依赖
RUN npm install --registry=https://registry.npmmirror.com/

# 将所有文件复制到工作目录
COPY . /home/app


# 暴露端口
EXPOSE 7001

# 启动应用
CMD ["npm", "run", "start"]
导出pdf时会生成临时文件,何时删除?
  • 如果不清理这些临时文件,则垃圾文件会越来越多
  • 清理策略:每日凌晨删除前一天产生的文件

egg的schedule模块: daily_task.js

const Subscription = require('egg').Subscription;
const fileHelper = require('../extend/fileHelper');
const dateHelper = require('../extend/date');

class DailyTask extends Subscription {
  // 通过 schedule 属性来设置定时任务的执行间隔等配置
  static get schedule() {
    return {
      // interval: '1m', // 1 分钟间隔
      cron: '0 2 * * *', // 凌晨执行
      type: 'worker', // 某一个 worker执行
    };
  }

  // subscribe 是真正定时任务执行时被运行的函数
  async subscribe() {
    // 删除前一天的
    const prevDay = dateHelper.addSpecificDate(null, 0);
    fileHelper.deleteDirectory(fileHelper.join(this.app.config.tempPathName, prevDay));
  }
}

module.exports = DailyTask;
api完整代码实现
  1. 初始化框架
npm init egg --type=simple

目录结构:

project-dir-list.jpg

  1. 定义路由
/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.get('/api/generatePdf', controller.api.index);
};
  1. 开发api
const { Controller } = require('egg');
const fileHelper = require('../extend/fileHelper');

class ApiController extends Controller {
  async index() {
    const { ctx } = this;
    const fullUrl = ctx.query.fullUrl;// 完成url,包含id等信息
    const friendlyName = ctx.query.friendlyName || '';// 友好名,用于下载
    const authKey = ctx.query.authKey || ''; // 需要设置的请求头
    const authValue = ctx.query.authValue || ''; // 需要设置的请求头
    const completeTag = ctx.query.completeTag || '.is-rendered-tag';
    const config = ctx.config || {}; // 其他配置项


    let error = '';
    // 验证
    if (!fullUrl) {
      error = 'fullUrl is required';
    }

    if (!completeTag) {
      error = 'completeTag is required';
    }

    if (error) {
      ctx.body = error;
      return false;
    }

    // 根据当前请求生成文件名
    const { filePath, uniqueName } = ctx.service.api.generateFileNameAndPath(this.app.config.tempPathName, fullUrl);

    // 生成pdf
    await ctx.service.api.createPdf({
      fullUrl,
      filePath,
      authKey,
      authValue,
      completeTag,
      config,
    });

    // 读取文件流
    const file = fileHelper.readStream(filePath);
    ctx.set('Content-disposition', 'attachment;filename=' +
      encodeURIComponent(friendlyName ? friendlyName : uniqueName));
    ctx.set('Content-type', 'application/pdf');
    ctx.body = file;
  }
}

module.exports = ApiController;
service层完整代码实现
const Service = require('egg').Service;
const stringHelper = require('../extend/string');
const fileHelper = require('../extend/fileHelper');
const dateHelper = require('../extend/date');
const puppeteer = require('puppeteer');
class ApiService extends Service {

  // 生成名称
  generateFileNameAndPath(tempPath, fullUrl) {
    const ext = '.pdf';
    // 根据当前请求生成文件名
    let uniqueName = stringHelper.md5(fullUrl);
    const timePath = dateHelper.formatDateTime(dateHelper.getCurrentTimeInt(false), dateHelper.FORMAT_TYPE.TYPE_YMD);

    // 当前路径
    const currentPath = fileHelper.join(tempPath, timePath);
    fileHelper.createDirectory(currentPath);
    const filenameWithExt = `${uniqueName}${ext}`;
    const filePath = fileHelper.join(currentPath, filenameWithExt);
    return { uniqueName: filenameWithExt, filePath };
  }


  // 生成pdf
  async createPdf(pdfParams = {}) {
    const { fullUrl, filePath, authKey, authValue, completeTag, config } = pdfParams;
    // 是否有footer
    const footerTip = config?.footerTip || 'pdf测试';
    const margin = config.margin || 20;

    const browser = await puppeteer.launch({
      args: [
        '--ignore-certificate-errors', // 忽略证书错误
        '--no-sandbox',
      ],
    });
    const page = await browser.newPage();
    // await page.setBypassCSP(false);
    if (authKey && authValue) {
      await page.setExtraHTTPHeaders({
        [`${authKey}`]: authValue,
      });
    }

    // 打开url
    await page.mainFrame().goto(fullUrl);

    // 等待渲染成功的标识元素出现
    await page.waitForSelector(completeTag);

    // 导出pdf
    await page.pdf({
      path: filePath,
      displayHeaderFooter: false,
      footerTemplate: `<p>${footerTip}</p>`,
      format: 'A4',
      preferCSSPageSize: false,
      printBackground: true,
      margin: {
        top: `${margin}`,
        bottom: `${margin}`,
      },
    });
    // page.close()
    await browser.close();
  }

}

module.exports = ApiService;

优化

1.如何提高接口的响应速度
  • 节省launch时间(见如何支持更多并发)
  • 加缓存

伪代码实现


...
// 需要导出的url
const fullUrl = ctx.query.fullUrl;// 完成url,包含id等信息

// 根据url生成一个唯一key,作为pdf文件名
const md5Key = md5(fullUrl);

// 检查文件是否存在
const flag = fs.fileExistsSync(`${filePath}/${md5Key}.pdf`);

if(flag) {
    // 直接返回缓存的文件
} else {
    // 1.调用pupeteer生成pdf文件
    // 2.直接返回缓存的文件
}

缓存效果对比:

缓存对比时间.jpg

2.如何支持更多并发
  1. 写一个链接池的类 genericPool.js
  2. 在程序启动时,初始化连接池
  3. 从连接池获取实例

...
// 写一个链接池的类 genericPool.js
// 写一个链接池的类 genericPool.js
function initPuppeteerPool() {
    
    const factory = {
      create: () =>
        puppeteer.launch(puppeteerArgs).then(instance => {
          // 创建一个 puppeteer 实例 ,并且初始化使用次数为 0
          instance.useCount = 0;
          return instance;
        }),
      destroy: instance => {
        instance.close();
      },
      validate: instance => {
        // 执行一次自定义校验,并且校验校验 实例已使用次数。 当 返回 reject 时 表示实例不可用
        return validator(instance).then(valid => Promise.resolve(valid && (maxUses <= 0 || instance.useCount < maxUses)));
      },
    };
    
   
    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 resource;
        })
        .then(fn)
        .then(
          result => {
            // 不管业务方使用实例成功与后都表示一下实例消费完成
            pool.release(resource);
            return result;
          },
          err => {
            pool.release(resource);
            throw err;
          }
        );
    };

    return pool;

}

// 生命周期中启用以及销毁
const initPuppeteerPool = require('./app/extend/genericPool');

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

  async didLoad() {
    // 所有的配置已经加载完毕
    // 可以用来加载应用自定义的文件,启动自定义的服务
    this.app.pool = initPuppeteerPool({
      puppeteerArgs: {
        args: [
          '--ignore-certificate-errors', // 忽略证书错误
          '--no-sandbox',
        ],
      },
    });
  }
  async beforeClose() {
    // 释放资源
    if (this.app.pool.drain) {
      await this.app.pool.drain().then(() => this.app.pool.clear());
    }
  }
}


// service层
createPdf() {
  
    // 从链接池中取出实例
    await this.app.pool.use(async instance => {
      // 取出连接后直接就是newPage,节省了lanuch时间
      const page = await instance.newPage();
      ...
    })
}

完整代码见如下git仓库:

yc-lm/document-helper (github.com)

参考文档

  1. juejin.cn/post/713837…
  2. Puppeteer (pptr.dev)