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

Posted

技术标签:

【中文标题】捆绑一个 NestJS + TypeORM 应用程序(使用 webpack)【英文标题】:Bundle a NestJS + TypeORM application (with webpack) 【发布时间】:2021-05-16 01:51:43 【问题描述】:

我最近不得不考虑一种新软件的部署方法,它是用以下代码编写的:

NestJS 6 / 快递 TypeORM 0.2 使用了 TypeScript

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

我做了一些研究和很多people explicitly advices 反对捆绑。主要论点是原生扩展将与 webpackrollup 之类的捆绑程序一起失败(剧透:这是真的,但有一个解决方案)。在我看来,这很大程度上是由于人们不关心这一点:node-pre-gyp 的作者使用了nearly the same words for this use case。所以通常,我被告知要么使用yarn install 要么使用sync the node_modules/ folder。

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

我还看了以下问答:

How to correctly build NestJS app for production with node_modules dependencies in bundle? 说明 NestJS 支持 webpack without node_modules/ 开箱即用。 Single file bundle with NestJS + Typescript + Webpack + node_modules 工作答案,无需进一步解释。 IgnorePlugins 似乎也有点过头了。 How to bundle nestjs application with webpack 似乎从 ZenSoftware 复制/粘贴了一个工作解决方案而没有引用。指向另一个答案的链接。 NestJS optimization minimize not work with webpack 建议使用 NestJS 应用程序禁用最小化

TypeORM也有一些单独的Q&A,不过好像都需要安装ts-node或者typescript

TypeORM + Webpack causes SyntaxError: Unexpected token for entity file Isomoprhic application, problem with TypeORM && TypeScript && Express && Webpack setup SyntaxError: Unexpected token import typeORM entity

【问题讨论】:

【参考方案1】:

2021 年 8 月 25 日更新

最初的响应是用 NestJS 6 完成的,它使用了 Webpack 4。 由于 NestJS 8 使用 Webpack 5,因此可以使用分块拆分,并提供更好的解决方案。

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

原答案

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

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

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

我必须坚持:我的解决方案不适用于原生扩展。幸运的是,popular bcrypt 可以替换为pure JS implementation,most popular postgresql package 既可以使用编译的 JS,也可以使用纯 JS。

如果想捆绑原生扩展,可以尝试使用ncc。他们设法 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。主要区别在于我从IgnorePlugin 切换到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 不替换require 调用dynamic configuration file,用于从JSON 或env 文件加载配置。我在this QA 的指导下最终构建了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 年 8 月 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 将选择主入口点的配置。

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

最后,当您保留类和函数名称时,一些优化现在甚至可以使用装饰器(使用反射)。

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 用于迁移)

【讨论】:

你能提供一个包含所有这些配置的示例项目吗?

以上是关于捆绑一个 NestJS + TypeORM 应用程序(使用 webpack)的主要内容,如果未能解决你的问题,请参考以下文章

使用 NestJS 和 TypeOrm,在我运行 NestJS 应用程序后不会自动创建表

运行nestjs应用程序时typeorm迁移中的“不能在模块外使用import语句”

未找到连接“默认”-TypeORM、NestJS 和外部 NPM 包

Nx-NestJS-TypeOrm: SyntaxError: Unexpected token

迁移文件中的 NestJS TypeORM 语法错误

使用 TypeORM 和 NestJs 和 Typescript 创建新迁移时出错 [关闭]