捆绑 NestJS + TypeORM 应用程序(使用 webpack)

Bundle a NestJS + TypeORM application (with webpack)

我最近需要考虑一个新软件的部署方法,它是用以下语言编写的:

该软件将部署在 160 多台服务器上,分布在整个欧洲,其中一些服务器的互联网连接非常糟糕。

我做了一些研究 people explicitly advices against bundling. The main argument is that native extension will fails with bundlers like webpack or rollup (Spoiler: it's true, but there is a solution). In my opinion, it's largely due to the fact that people don't care for this: the author of node-pre-gyp used nearly the same words for this use case. So usually, I was told to either use yarn install or sync the node_modules/ folder

项目是新建的,但是node_modules/文件夹已经超过480MB了。使用最大压缩的 XZ 给我一个 20 MB 的存档。这对我来说仍然太大了,似乎是对资源的巨大浪费。

我也看了下面的问答:

TypeORM 也有一些单独的问答,但似乎都需要安装 ts-nodetypescript:

更新 2021-08-25

最初的响应是使用 Webpack 4 的 NestJS 6 完成的。 由于NestJS 8使用了Webpack 5,所以chunk split是可用的,提供了更好的解决方案。

我也集成了webpack-merge的使用,只有一个配置文件。 这只会更改您在阅读时将看到的 Webpack 配置。

原回答

我设法找到了一个很好的解决方案,它使用以下工具生成了 2.7 MB 的独立 RPM:

  • webpack 特殊配置
  • RPM,使用 webpack,以便分发生成的文件。

该软件是一个 API 服务器,使用 PostgreSQL 进行持久化。用户通常使用外部服务器进行身份验证,但我们可以有本地(紧急)用户,因此我们使用 bcrypt 来存储和检查密码。

我必须坚持:我的解决方案不适用于本机扩展。幸运的是,popular bcrypt can be replaced with a pure JS implementation, and the most popular postgresql package 能够同时使用已编译或纯 JS。

如果想与本机扩展捆绑,您可以尝试使用在一些初步测试中对我有用的 ncc. They managed to implement a solution for node-pre-gyp dependent packages。当然,编译后的扩展应该与您的目标平台相匹配,就像编译后的东西一样。

我个人选择 webpack 因为 NestJS support this in it's build command。这只是 webpack 编译器的直通,但它似乎调整了一些路径,因此更容易一些。

那么,如何实现呢? webpack 可以将所有内容捆绑在一个文件中,但在这个用例中,我需要其中三个:

  • 主程序
  • TypeORM migration CLI tool
  • TypeORM 迁移脚本,因为它们不能与该工具捆绑在一起,因为它依赖于文件名

并且由于每个捆绑包都需要不同的选项……我使用了 3 个 webpack 文件。这是布局:

webpack.config.js
webpack
├── migrations.config.js
└── typeorm-cli.config.js

所有这些文件都基于相同的 template kindly provided by ZenSoftware. The main difference is that I switched from IgnorePlugin to externals,因为它更易于阅读,并且非常适合用例。

// webpack.config.js
const { NODE_ENV = 'production' } = process.env;

console.log(`-- Webpack <${NODE_ENV}> build --`);

module.exports = {
  target: 'node',
  mode: NODE_ENV,
  externals: [
    // Here are listed all optional dependencies of NestJS,
    // that are not installed and not required by my project
    {
      'fastify-swagger': 'commonjs2 fastify-swagger',
      'aws-sdk': 'commonjs2 aws-sdk',
      '@nestjs/websockets/socket-module': 'commonjs2 @nestjs/websockets/socket-module',
      '@nestjs/microservices/microservices-module': 'commonjs2 @nestjs/microservices/microservices-module',
      
      // I'll skip pg-native in the production deployement, and use the pure JS implementation
      'pg-native': 'commonjs2 pg-native'
    }
  ],
  optimization: {
    // Minimization doesn't work with @Module annotation
    minimize: false,
  }
};

TypeORM 的配置文件比较冗长,因为我们需要明确使用 TypeScript。幸运的是,他们有一些advices for this in their FAQ。但是,捆绑迁移工具还需要两个技巧:

  • 忽略文件开头的shebang。使用 shebang-loader 轻松解决(5 年后仍然按原样工作!)
  • 强制 webpack 不替换 requiredynamic configuration file, used to load configuration from JSON or env files. I was guided by and finally build my own package 的调用。
// webpack/typeorm-cli.config.js

const path = require('path');
// TypeScript compilation option
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
// Don't try to replace require calls to dynamic files
const IgnoreDynamicRequire = require('webpack-ignore-dynamic-require');

const { NODE_ENV = 'production' } = process.env;

console.log(`-- Webpack <${NODE_ENV}> build for TypeORM CLI --`);

module.exports = {
  target: 'node',
  mode: NODE_ENV,
  entry: './node_modules/typeorm/cli.js',
  output: {
    // Remember that this file is in a subdirectory, so the output should be in the dist/
    // directory of the project root
    path: path.resolve(__dirname, '../dist'),
    filename: 'migration.js',
  },
  resolve: {
    extensions: ['.ts', '.js'],
    // Use the same configuration as NestJS
    plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.build.json' })],
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' },
      // Skip the shebang of typeorm/cli.js
      { test: /\.[tj]s$/i, loader: 'shebang-loader' }
    ],
  },
  externals: [
    {
      // I'll skip pg-native in the production deployement, and use the pure JS implementation
      'pg-native': 'commonjs2 pg-native'
    }
  ],
  plugins: [
    // Let NodeJS handle are requires that can't be resolved at build time
    new IgnoreDynamicRequire()
  ]
};
// webpack/migrations.config.js

const glob = require('glob');
const path = require('path');
// TypeScript compilation option
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
// Minimization option
const TerserPlugin = require('terser-webpack-plugin');

const { NODE_ENV = 'production' } = process.env;

console.log(`-- Webpack <${NODE_ENV}> build for migrations scripts --`);

module.exports = {
  target: 'node',
  mode: NODE_ENV,
  // Dynamically generate a `{ [name]: sourceFileName }` map for the `entry` option
  // change `src/db/migrations` to the relative path to your migration folder
  entry: glob.sync(path.resolve('src/migration/*.ts')).reduce((entries, filename) => {
    const migrationName = path.basename(filename, '.ts');
    return Object.assign({}, entries, {
      [migrationName]: filename,
    });
  }, {}),
  resolve: {
    // assuming all your migration files are written in TypeScript
    extensions: ['.ts'],
    // Use the same configuration as NestJS
    plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.build.json' })],
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' }
    ]
  },
  output: {
    // Remember that this file is in a subdirectory, so the output should be in the dist/
    // directory of the project root
    path: __dirname + '/../dist/migration',
    // this is important - we want UMD (Universal Module Definition) for migration files.
    libraryTarget: 'umd',
    filename: '[name].js',
  },
  optimization: {
    minimizer: [
      // Migrations rely on class and function names, so keep them.
      new TerserPlugin({
        terserOptions: {
          mangle: true, // Note `mangle.properties` is `false` by default.
          keep_classnames: true,
          keep_fnames: true,
        }
      })
    ],
  },
};

更新 2021-08-25 Nest 8/Webpack 5

自从 nest-cli 迁移到 webpack 5 后,现在可以使用一个有趣的功能:节点目标的块拆分。

我也很不爽以相同的逻辑管理多个文件,所以我决定使用webpack-merge只有一个配置文件。

您必须 yarn add -D webpack-merge 并具有以下 webpack.config.js

// webpack.config.js
const { merge } = require("webpack-merge")
const path = require('path')
const glob = require('glob')
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const MomentLocalesPlugin = require('moment-locales-webpack-plugin')
const IgnoreDynamicRequire = require('webpack-ignore-dynamic-require')

const { NODE_ENV = 'production', ENTRY, npm_lifecycle_event: lifecycle } = process.env

// Build platform don't support ?? and ?. operators
const entry = ENTRY || (lifecycle && lifecycle.match(/bundle:(?<entry>\w+)/).groups["entry"])

if (entry === undefined) {
  throw new Error("ENTRY must be defined")
}

console.log(`-- Webpack <${NODE_ENV}> build  for <${entry}> --`);

const BASE_CONFIG = {
  target: 'node',
  mode: NODE_ENV,
  resolve: {
    extensions: ['.ts', '.js'],
    plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.build.json' })],
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' }
    ]
  },
  output: {
    path: path.resolve(__dirname, 'dist/'),
    filename: '[name].js',
  },
}

const MIGRATION_CONFIG = {
  // Dynamically generate a `{ [name]: sourceFileName }` map for the `entry` option
  // change `src/db/migrations` to the relative path to your migration folder
  entry: glob.sync(path.resolve('src/migration/*.ts')).reduce((entries, filename) => {
    const migrationName = path.basename(filename, '.ts')
    return Object.assign({}, entries, {
      [migrationName]: filename,
    })
  }, {}),
  output: {
    path: path.resolve(__dirname, 'dist/migration'),
    // this is important - we want UMD (Universal Module Definition) for migration files.
    libraryTarget: 'umd',
    filename: '[name].js',
  },
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          mangle: true, // Note `mangle.properties` is `false` by default.
          keep_classnames: true,
          keep_fnames: true,
        }
      })
    ],
  }
}

const TYPEORM_CONFIG = {
  entry: {
    typeorm: './node_modules/typeorm/cli.js'
  },
  externals: [
    {
      'pg-native': 'commonjs2 pg-native',
    }
  ],
  plugins: [
    new IgnoreDynamicRequire(),
  ],
  module: {
    rules: [
      { test: /\.[tj]s$/i, loader: 'shebang-loader' }
    ],
  },
}

const MAIN_AND_CONSOLE_CONFIG = {
  entry: {
    main: './src/main.ts',
    console: "./src/console.ts"
  },
  externals: [
    {
      'pg-native': 'commonjs2 pg-native',
      'fastify-swagger': 'commonjs2 fastify-swagger',
      '@nestjs/microservices/microservices-module': 'commonjs2 @nestjs/microservices/microservices-module',
      '@nestjs/websockets/socket-module': 'commonjs2 @nestjs/websockets/socket-module',
      // This one is a must have to generate the swagger document, but we remove it in production
      'swagger-ui-express': 'commonjs2 swagger-ui-express',
      'aws-sdk': 'commonjs2 aws-sdk',
    }
  ],
  plugins: [
    // We don't need moment locale
    new MomentLocalesPlugin()
  ],
  optimization: {
    // Full minization doesn't work with @Module annotation
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          mangle: true, // Note `mangle.properties` is `false` by default.
          keep_classnames: true,
          keep_fnames: true,
        }
      })
    ],
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      }
    }
  }
}

const withPlugins = (config) => (runtimeConfig) => ({
  ...config,
  plugins: [
    ...runtimeConfig.plugins,
    ...(config.plugins || [])
  ]
})

const config = entry === "migrations" ? merge(BASE_CONFIG, MIGRATION_CONFIG)
  : entry === "typeorm" ? merge(BASE_CONFIG, TYPEORM_CONFIG)
  : entry === "main" ? merge(BASE_CONFIG, MAIN_AND_CONSOLE_CONFIG)
    : undefined


module.exports = withPlugins(config)

使用此文件,webpack 配置从当前命令中选择:bundle:main 将 select 主入口点的配置。

您还会注意到,main 中现在有多个入口点:mainconsole。前者用于主应用程序,后者用于 CLI 助手。但它们都共享相同(且巨大)的代码量,而 Webpack 5 能够通过 splitChunks 部分来做到这一点。这在 Webpack 4 中可用,但 不工作 node 目标。

最后,当您保留 class 和函数名称时,即使使用装饰器(使用反射),一些优化现在也完全有效。

Bundle 更小,代码共享,package.json 更清晰,大家都很高兴。

更新结束

之后,为了简化构建过程,我在 package.json 中添加了一些目标:

{
  "scripts": {
    "bundle:application": "nest build --webpack",
    "bundle:migrations": "nest build --webpack --webpackPath webpack/typeorm-cli.config.js && nest build --webpack --webpackPath webpack/migrations.config.js",
    "bundle": "yarn bundle:application && yarn bundle:migrations"
  },
}

而且……您快完成了。您可以调用 yarn bundle,输出将被构建在 dist/ 目录中。我没有设法删除一些生成的 TypeScript 定义文件,但这不是真正的问题。

最后一步是编写 RPM 规范文件:

%build
mkdir yarncache
export YARN_CACHE_FOLDER=yarncache

# Setting to avoid node-gype trying to download headers
export npm_config_nodedir=/opt/rh/rh-nodejs10/root/usr/

%{_yarnbin} install --offline --non-interactive --frozen-lockfile
%{_yarnbin} bundle

rm -r yarncache/

%install
install -D -m644 dist/main.js $RPM_BUILD_ROOT%{app_path}/main.js

install -D -m644 dist/migration.js $RPM_BUILD_ROOT%{app_path}/migration.js
# Migration path have to be changed, let's hack it.
sed -ie 's/src\/migration\/\*\.ts/migration\/*.js/' ormconfig.json
install -D -m644 ormconfig.json $RPM_BUILD_ROOT%{app_path}/ormconfig.json
find dist/migration -name '*.js' -execdir install -D -m644 "{}" "$RPM_BUILD_ROOT%{app_path}/migration/{}" \;

并且 systemd 服务文件可以告诉您如何启动它。目标平台是CentOS7,所以不得不用NodeJS 10 from software collections。您可以将路径调整为您的 NodeJS 二进制文件。

[Unit]
Description=NestJS Server
After=network.target

[Service]
Type=simple
User=nestjs
Environment=SCLNAME=rh-nodejs10
ExecStartPre=/usr/bin/scl enable $SCLNAME -- /usr/bin/env node migration migration:run
ExecStart=/usr/bin/scl enable $SCLNAME -- /usr/bin/env node main
WorkingDirectory=/export/myapplication
Restart=on-failure

# Hardening
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only

[Install]
WantedBy=multi-user.target

最终统计:

  • 在双核虚拟机上构建时间 3 分 30 秒。
  • RPM 大小为 2.70 MB,自包含,包含 3 个 JavaScript 文件和 2 个配置文件(.production.env 用于主应用程序,ormconfig.json 用于迁移)