likes
comments
collection
share

NestJS小技巧26-使用Node.js生成PDF

作者站长头像
站长
· 阅读数 10

原文链接

PDF生成是许多需要创建可打印文档的Web应用程序的关键部分。无论是生成发票还是报告,能够在后端产生PDF都是必不可少的。在本文中,我们将探讨后端可用的PDF生成的各种选项。我们还将讨论每种方法的优点和缺点,以及在Web应用程序中实施PDF生成的最佳实践。

直到一年前,我从未考虑过PDF生成是一个问题。然而,当被指派为为客户和承包商创建发票和收据时,我迅速意识到了其中的复杂性。我的输入是来自数据库的一系列订单,目标是从模板中生成若干PDF文件,这些文件会自动发送给收件人。这个任务非常常见,我们将在这篇文章中探讨解决它的可能选项。

首先,有必要概述必要的步骤:

  1. 从数据库接收订单列表。
  2. 将原始数据转化为可渲染的值。
  3. 用这些值填充一个模板。
  4. 将模板转换为PDF。
  5. 将PDF上传到文件存储中。
  6. 将PDF文件附加到电子邮件并发送。 前两步相对简单;我们必须检索所需的数据并计算价格、增值税和总计。

在第三步中,我们必须决定定义模板的方法。这可能涉及直接生成PDF文件或使用中间格式。

直接PDF渲染

由于我使用Node.js + TypeScript,我只会分享这个技术栈的库。对于PDF渲染,我发现了pdfjshttps://www.npmjs.com/package/pdfjs。这个库及其类似产品的方法是绘制元素,就像在这个来自https://github.com/rkusa/pdfjs的示例中一样:

module.exports = function(doc, { lorem })  {
  doc.text(lorem.short, { fontSize: 20 })

  const table = doc.table({
    widths: [256, 256],
    padding: 0,
    borderWidth: 10,
  })

  const row = table.row()

  row.cell(lorem.short, { textAlign: 'justify', fontSize: 20, padding: 10, backgroundColor: 0xdddddd })
  row.cell(lorem.shorter, { textAlign: 'justify', fontSize: 20, padding: 10, backgroundColor: 0xeeeeee })
}

我打赌使用这种方法生成PDF的速度很快,但以这种方式创建模板可能会耗费时间。虽然直接PDF渲染在某些情况下可能有所帮助,但对于一般任务,如生成发票,这可能不是最合适的选项。

使用HTML模板

当涉及到布局模板时,不可能不提到最明显的解决方案——HTML。它是众所周知的,它可以呈现我们需要的一切。此外,还有许多HTML模板选项,例如:

你可以选择最适合你的。我个人真的很喜欢Pug,但它的语法与HTML不同,为了布局预览,它必须首先被编译。但以Handlebars为例,你可以直接在浏览器中预览布局。

重要提示:每次你渲染东西时,都不要编译模板!对某些人来说这是显而易见的,但有些开发者忘记了模板编译是一个阻塞且CPU密集的操作。如果你没有大量的模板,你可以在应用程序启动时编译它们。使用Nest.js和Handlebars的一个例子:

import type { OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as Handlebars from 'handlebars';

export enum TemplateEnum {
  INVOICE = 'invoice',
  REFUND = 'refund',
}

@Injectable()
export class HtmlService implements OnModuleInit {
  private readonly templateDir = './src/templates';

  private readonly templateFileMap: Record<TemplateEnum, string> = {
    [TemplateEnum.INVOICE]: 'invoice.hbs',
    [TemplateEnum.REFUND]: 'refund.hbs',
  };

  private hbsTemplateMap: Record<TemplateEnum, HandlebarsTemplateDelegate>;

  private readonly partials = ['head', 'top', 'footer'];

  render<T>(template: TemplateEnum, data: T): string {
    const hbsTemplate = this.hbsTemplateMap[template];

    return hbsTemplate(data);
  }

  async onModuleInit(): Promise<void> {
    // initialize all common handlebars partials
    await Promise.all(
      this.partials.map((partialName) => this.initPartial(partialName)),
    );
    
    // initialize all handlebars templates
    await Promise.all(
      Object.keys(this.templateFileMap).map((template: TemplateEnum) =>
        this.initTemplate(template),
      ),
    );
  }

  private async initTemplate(template: TemplateEnum): Promise<void> {
    const file = this.templateFileMap[template];
    const data = await fs.promises.readFile(
      `${this.templateDir}/${file}`,
      'utf-8',
    );
    const hbsTemplate = Handlebars.compile(data, { noEscape: true });

    if (!this.hbsTemplateMap) {
      this.hbsTemplateMap = {} as Record<
        TemplateEnum,
        HandlebarsTemplateDelegate
      >;
    }

    this.hbsTemplateMap[template] = hbsTemplate;
  }

  private async initPartial(name: string): Promise<void> {
    const data = await fs.promises.readFile(
      `${this.templateDir}/${name}.hbs`,
      'utf-8',
    );
    Handlebars.registerPartial(name, data);
  }
}

所有的模板和模板部分都在onModuleInit方法中初始化,该方法在应用程序启动时触发。如果你有大量的模板,我建议你使用缓存并根据需求编译模板。

将HTML转换为PDF

NestJS小技巧26-使用Node.js生成PDF

好的,我们已经将发票渲染为HTML了,但是我们如何将其转换为PDF呢?在NPM registry中搜索后,我们得到了几个结果:

  • pdf2html。这个模块基于Java编写的Apache PDFBox,并要求安装java。
  • electron-pdf。这个可以与Node.js一起使用,但需要electron,听起来像一个巨大的过度。
  • bits-to-dead-treesPlaywright的包装器,是一个端到端的测试框架。它运行一个浏览器将HTML转换为PDF。
  • phantom-html-to-pdf基于phantomjs,一个无头浏览器。但根据它的官方网页https://phantomjs.org/,“PhantomJS的开发已经暂停”。
  • percollate是一个将HTML、PDF、EPUB和markdown文件格式转换的强大工具。在底层,它使用Puppeteer,另一个与浏览器进行端到端测试的工具。
  • html-template-to-pdf, html-pdf-node也基于Puppeteer,但专注于将HTML转换为PDF。 从搜索结果中的其他库是上面列表中项目的类似物。

所有这些库的一般思路是:

  1. 使用可以从HTML渲染PDF的外部工具(如浏览器)运行一个单独的进程。
  2. 将HTML或链接到一个网页传递给启动的进程。
  3. 从中读取数据。 除了安装额外的依赖项外,这种方法的主要缺点是外部工具(如浏览器)将与你的后端服务一起在同一台机器/容器上启动,这增加了管理后端资源的复杂性。换句话说,它使你的后端变得单一。如果你有一个小应用程序并且不需要生成成千上万的PDF,那么它可能适合你。但如果你想构建一个可扩展的系统,PDF生成必须是独立的。

专门用于PDF生成的服务

当然,你可以将上面列表中的库封装成一个独立的微服务。你需要做的只是创建一个端点,该端点接受HTML作为输入并返回一个PDF或已上传到文件存储的PDF文件的链接。这不是什么大问题。

但让我为你节省一些时间,为你介绍Gotenberg(Go语言开发者喜欢给库取名,其中包含GO这个词)。

Gotenberg是一个现成的服务,用于将HTML转换为PDF,你不必担心安装其他依赖项,Gotenberg的docker已经预先安装了所有内容。对于本地开发,你只需运行一个docker hub.docker.com/r/gotenberg… 或将其添加到你的本地docker-compose文件中。如果你使用kubernetes,可以使用helm chart来部署Gotenberg。 Gotenberg启动了一个chromium实例,将输入数据传递给它,并代理渲染后的PDF。你需要考虑运行chromium所需的资源,并限制对Gotenberg的并发请求数量。你可以为其配置kubernetes的自动缩放,但启动新的pods需要一些时间。我建议在PDF生成的顶部添加一个队列,如bull,用于执行PDF转换任务。

Gotenberg提供了一个简单的HTTP API,你可以使用像chromiumly这样的库客户端,或者自己发请求,只需几行代码。使用NestJS和用于HTTP请求的got库的示例:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import FormData from 'form-data';
import got from 'got';

@Injectable()
export class GotenbergClientService {
  private readonly scale = 1.2;

  constructor(private configService: ConfigService) {}

  createPdfFromHtml(html: string): NodeJS.ReadableStream {
    const formData = new FormData();
    formData.append('files', Buffer.from(html), { filename: 'index.html' });
    formData.append('scale', this.scale);

    return got.stream('forms/chromium/convert/html', {
      prefixUrl: this.configService.get('gotenbergUrl'),
      method: 'POST',
      body: formData,
    });
  }
}

createPdfFromHtml方法接受一个HTML字符串作为输入,并返回ReadableStream

上传PDF至AWS S3

我们已经将HTML转换为PDF,现在我们有一个带有PDF文件内容数据的ReadableStream。下一步是将其上传到文件存储,作为示例,我选择了AWS S3(也可以选择七牛云,根据您自己情况来选择):

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { S3 } from 'aws-sdk';

@Injectable()
export class AwsS3Service {
  private readonly s3 = new S3({
    apiVersion: '2006-03-01',
  });

  constructor(private configService: ConfigService) {}

  uploadStream(key: string, body: S3.Body): Promise<S3.ManagedUpload.SendData> {
    return this.s3
      .upload({
        Bucket: this.configService.get('AWS_S3_BUCKET_NAME')!,
        Key: key,
        Body: body,
        ACL: 'public-read',
      })
      .promise();
  }
}

ReadableStream可以作为body参数传递,Gotenberg的响应将流式传输到AWS S3。让我们整合一切:

@Injectable()
export class PdfService {
  constructor(
    private htmlService: HtmlService,
    private gotenbergClientService: GotenbergClientService,
    private awsS3Service: AwsS3Service,
  ) {}

  async generateAndUpload<T>(
    template: TemplateEnum,
    key: string,
    data: T,
  ): Promise<string> {
    const html = this.htmlService.render(template, data);
    const pdfStream = this.gotenbergClientService.createPdfFromHtml(html);
    const sendData = await this.awsS3Service.uploadStream(key, pdfStream);

    return sendData.Location;
  }
}

我们传递template——一个模板的名称,key——一个要存储在AWS S3上的文件的键,以及要与模板一起渲染的data,作为输出,我们得到一个上传的PDF文件的链接。至于最后一步“将PDF文件附加到电子邮件并发送”,我敢打赌你自己可以处理,反正这是特定于提供者的 :)

还有一个重要的提示:不要让你的客户等待PDF生成完成,这可能需要一些时间,始终在后台进行此操作。

结论

PDF生成并非小事,但它仍然是一个可以解决的问题。幸运的是,我们并不是第一批解决这个问题的人,有大量的工具可以帮助我们。今天我们已经涉及了大部分这些工具,并比较了不同的方法。现在,你有能力选择最适合你需求的解决方案。 顺便说一下,我差点忘了提到现成的用于PDF生成的SaaS服务,当然,它们都不是免费的。我谦逊的建议是:不要浪费你或你公司的钱,只需花一天时间,一劳永逸地解决这个任务。

下次见!

转载自:https://juejin.cn/post/7264752537538789416
评论
请登录