Skip to main content
Version: 6.4

第 1 章:第一个实体

设置

¥Setting up

在我们开始之前,请确保你首先满足以下先决条件:

¥Before we start, ensure you meet the following pre-requisites first:

  1. 安装 Node.js 版本 18.12 或更高版本,但最好是版本 20。

    ¥Have Node.js version 18.12 or higher installed, but preferably version 20.

  2. 安装 NPM,或使用你选择的任何其他包管理器。

    ¥Have NPM installed, or use any other package manager of your choice.

    • NPM 与 Node.js 打包在一起,因此你应该已经拥有它。如果不是,请重新安装 Node.js。要使用其他包管理器,请考虑使用 corepack

      ¥NPM comes bundled with Node.js, so you should already have it. If not, reinstall Node.js. To use other package managers, consider using corepack.

如果不确定,请通过运行确认先决条件:

¥If not certain, confirm the prerequisites by running:

node -v
npm -v

创建新项目

¥Creating a new project

让我们从基本的文件夹结构开始。正如我们所说,我们将有 3 个模块,每个模块都有自己的目录:

¥Let's start with the basic folder structure. As we said we will have 3 modules, each having its own directory:

# create the project folder and `cd` into it
mkdir blog-api && cd blog-api
# create module folders, inside `src` folder
mkdir -p src/modules/{user,article,common}

现在添加依赖:

¥Now add the dependencies:

npm install @mikro-orm/core \
@mikro-orm/sqlite \
@mikro-orm/reflection \
fastify

还有一些开发依赖:

¥And some development dependencies:

npm install --save-dev @mikro-orm/cli \
typescript \
ts-node \
@types/node \
vitest

ECMAScript 模块

¥ECMAScript Modules

你可能听说过 ECMAScript 模块 (ESM),但这可能是你第一次尝试它们。现在请记住 - 整个生态系统还远未准备就绪,本指南主要使用 ESM 来展示它是如何实现的。许多依赖尚未准备好 ESM,并且通常需要一些奇怪的解决方法。MikroORM 也不例外 - 也存在怪癖,即在测试设置下动态导入 TypeScript 文件。

¥You probably heard about ECMAScript Modules (ESM), but this might easily be the first time you try them. Now keep in mind - the whole ecosystem is far away from ready, and this guide is using the ESM mainly to show how it is possible. Many dependencies are not ESM ready, and often there are weird workarounds needed. MikroORM is no exception to this - there are quirks as well, namely in dynamic imports of TypeScript files under test setup.

你不必使用 ESM 来使用 MikroORM。MikroORM 可以在 ESM 项目以及 CommonJS (CJS) 项目中工作。

¥You do not have to use ESM to use MikroORM. MikroORM can work in ESM projects, as well as CommonJS (CJS) projects.

简而言之,对于 ESM 项目,我们需要:

¥In a nutshell, for ESM project we need to:

  • "type": "module" 添加到 package.json

    ¥add "type": "module" to package.json

  • 使用 import/export 语句而不是 require 调用

    ¥use import/export statements instead of require calls

  • 在这些 import 中使用 .js 扩展,即使在 TypeScript 文件中也是如此

    ¥use .js extension in those imports, even in TypeScript files

  • 配置 TypeScript 和 ts-node 属性,如下一节所述

    ¥configure TypeScript and ts-node property, as described in the following section

你可以阅读有关 Node.js 此处 中的 ESM 支持的更多信息。

¥You can read more about the ESM support in Node.js here.

除此之外,使用装饰器定义实体还有一个陷阱。MikroORM 获取属性类型的默认方式是通过 reflect-metadata。虽然这本身引入了 一些挑战和限制,但我们不能在 ESM 项目中使用它。这是因为,使用 ES 模块,依赖是异步并行解析的,这与 reflect-metadata 模块当前的工作方式不兼容。因此,我们需要使用其他方式来定义实体元数据 - 在本指南中,我们将使用 @mikro-orm/reflection 包,它在后台使用 ts-morph 从 TypeScript 编译器 API 获取信息。这适用于 ESM 项目,并且还开辟了编译 TypeScript 文件的新方法,例如 esbuild(不支持装饰器元数据)。

¥In addition to this, there is one gotcha with defining entities using decorators. The default way MikroORM uses to obtain a property type is via reflect-metadata. While this itself introduces some challenges and limitations, we can't use it in an ESM project. This is because, with ES modules, the dependencies are resolved asynchronously, in parallel, which is incompatible with how the reflect-metadata module currently works. For this reason, we need to use other ways to define the entity metadata - in this guide, we will use the @mikro-orm/reflection package, which uses ts-morph under the hood to gain information from TypeScript Compiler API. This works fine with ESM projects, and also opens up new ways of compiling the TypeScript files, like esbuild (which does not support decorator metadata).

定义实体的另一种方法是通过 EntitySchema,这种方法也适用于原始 JavaScript 项目,并且允许通过接口而不是类来定义实体。查看 定义实体部分,那里的所有示例都有通过 EntitySchema 定义的代码选项卡。

¥Another way to define your entities is via EntitySchema, this approach works also for vanilla JavaScript projects, as well as allows to define entities via interfaces instead of classes. Check the Defining Entities section, all examples there have code tabs with definitions via EntitySchema too.

ts-morph 的反射性能很差,因此当你更改实体定义(或更新 ORM 版本)时,元数据已缓存 会进入 temp 文件夹并自动失效。你应该将此文件夹添加到 .gitignore 文件中。请注意,在构建生产包时,你可以利用 CLI 在构建时生成生产缓存以加快启动时间。有关更多示例,请参阅 的更多详细信息,请参阅 部署部分 部分。

¥The reflection with ts-morph is performance heavy, so the metadata are cached into temp folder and invalidated automatically when you change your entity definition (or update the ORM version). You should add this folder to .gitignore file. Note that when you build your production bundle, you can leverage the CLI to generate production cache on build time to get faster start-up times. See the deployment section for more about this.

配置 TypeScript

¥Configuring TypeScript

我们将使用以下 TypeScript 配置,因此创建 tsconfig.json 文件并将其复制到那里。如果你知道自己在做什么,则可以调整配置以满足你的需求。

¥We will use the following TypeScript config, so create the tsconfig.json file and copy it there. If you know what you are doing, you can adjust the configuration to fit your needs.

为了使 ESM 支持正常工作,我们需要将 modulemoduleResolution 设置为 NodeNext 并将目标设为 ES2022。我们还启用了 strict 模式和 experimentalDecorators,以及 declaration 选项来生成 @mikro-orm/reflection 包所需的 .d.ts 文件。最后,我们告诉 TypeScript 通过 outDir 编译到 dist 文件夹中,并将其设置为 src 文件夹中的所有 *.ts 文件。

¥For ESM support to work, we need to set module and moduleResolution to NodeNext and target ES2022. We also enable strict mode and experimentalDecorators, as well as the declaration option to generate the .d.ts files, needed by the @mikro-orm/reflection package. Lastly, we tell TypeScript to compile into dist folder via outDir and make it include all *.ts files inside src folder.

tsconfig.json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"strict": true,
"outDir": "dist",
"declaration": true,
"experimentalDecorators": true
},
"include": [
"./src/**/*.ts"
]
}

使用 ts-node 直接运行 TypeScript 文件

¥Using ts-node to run TypeScript files directly

在开发过程中,你经常需要运行应用来测试你的更改。为此,使用 ts-node 直接运行 TypeScript 文件很方便,而不是将第一个文件编译为 JavaScript。默认情况下,ts-node 将以 CommonJS 模式运行,因此我们需要通过 tsconfig.json 对其进行配置以启用 ESM 支持。为了加快速度,我们可以使用 transpileOnly 选项,禁用类型检查 - 我们将在编译应用以供生产使用时进行类型检查,因此在开发过程中这并不重要(尤其是当你有一个可以显示错误的 IDE 时)。

¥During the development, you will often want to run the app to test your changes. For that, it is handy to use ts-node to run the TypeScript files directly, instead of compiling the first to JavaScript. By default, ts-node will operate in CommonJS mode, so we need to configure it via tsconfig.json to enable ESM support. To speed things up, we can use the transpileOnly option, which disables type checking - we will get the type checks when compiling the app for production use, so it does not matter that much during development (especially when you have an IDE that shows you the errors anyway).

tsconfig.json
{
"compilerOptions": { ... },
"include": [ ... ],
"ts-node": {
"esm": true,
"transpileOnly": true
}
}

设置 CLI

¥Setting up CLI

接下来,我们将设置 CLI 配置 MikroORM。然后此配置也将自动导入到你的应用中。使用显式 Options 类型定义配置变量,这样你就可以获得最佳类型的安全性 - 自动补齐以及有关不存在选项的警告(与使用 { ... } as Options 相反,它不会警告你)。

¥Next, we will set up the CLI config MikroORM. This config will be then automatically imported into your app too. Define the config variable with explicit Options type, that way you get the best level of type safety - autocomplete as well as warning about not existing options (as opposed to using { ... } as Options, that won't warn you for such).

对于测试,你可以导入配置并在评估之前覆盖一些选项。

¥For tests, you can import the config and override some options before evaluating it.

src/mikro-orm.config.ts
import { Options, SqliteDriver } from '@mikro-orm/sqlite';
import { TsMorphMetadataProvider } from '@mikro-orm/reflection';

const config: Options = {
// for simplicity, we use the SQLite database, as it's available pretty much everywhere
driver: SqliteDriver,
dbName: 'sqlite.db',
// folder-based discovery setup, using common filename suffix
entities: ['dist/**/*.entity.js'],
entitiesTs: ['src/**/*.entity.ts'],
// we will use the ts-morph reflection, an alternative to the default reflect-metadata provider
// check the documentation for their differences: https://mikro-orm.nodejs.cn/docs/metadata-providers
metadataProvider: TsMorphMetadataProvider,
// enable debug mode to log SQL queries and discovery information
debug: true,
};

export default config;

请注意,我们正在从 @mikro-orm/sqlite 包导入 Options - 这是 Options<SqliteDriver> 的别名。

¥Note that we are importing Options from the @mikro-orm/sqlite package - this is an alias to Options<SqliteDriver>.

或者,我们可以使用 defineConfig 助手,它甚至可以在 JavaScript 文件中提供智能感知,而无需类型提示:

¥Alternatively, we can use the defineConfig helper that should provide intellisense even in JavaScript files, without the need for type hints:

import { defineConfig } from '@mikro-orm/sqlite';
import { TsMorphMetadataProvider } from '@mikro-orm/reflection';

// no need to specify the `driver` now, it will be inferred automatically
export default defineConfig({
dbName: 'sqlite.db',
// folder-based discovery setup, using common filename suffix
entities: ['dist/**/*.entity.js'],
entitiesTs: ['src/**/*.entity.ts'],
// we will use the ts-morph reflection, an alternative to the default reflect-metadata provider
// check the documentation for their differences: https://mikro-orm.nodejs.cn/docs/metadata-providers
metadataProvider: TsMorphMetadataProvider,
// enable debug mode to log SQL queries and discovery information
debug: true,
});

将此文件保存到 src/mikro-orm.config.ts 中,这样它将与应用的其余部分一起编译。接下来,你需要通过 package.json 文件中的 mikro-orm 部分告诉 ORM 启用对 CLI 的 TypeScript 支持。

¥Save this file into src/mikro-orm.config.ts, so it will get compiled together with the rest of your app. Next, you need to tell the ORM to enable TypeScript support for CLI, via mikro-orm section in the package.json file.

或者,你可以在项目的根目录中使用 mikro-orm.config.js 文件,这样的文件将自动加载。有关更多信息,请参阅 文档

¥Alternatively, you can use mikro-orm.config.js file in the root of your project, such a file will get loaded automatically. Consult the documentation for more info.

package.json
{
"type": "module",
"dependencies": { ... },
"devDependencies": { ... }
}

最后,添加一些 NPM 脚本以简化开发。我们将通过 tsc 构建应用,通过 vitest 测试它,并通过 ts-node 在本地运行它。ESM 和动态导入有一个问题。虽然它对于常规 JavaScript 文件运行良好,但一旦我们通过 ts-nodevitest/esbuild 混合对 TypeScript 的运行时支持,你就会开始遇到像 Unknown file extension ".ts" 这样的错误。为了解决这个问题,我们可以通过 NODE_OPTIONS 环境变量使用 ts-node/esm 加载器 - 但这可能会变得很糟糕,我们可以做得更好 - 至少对于 CLI,我们有 mikro-orm-esm 脚本,它会自动注册 ts-node/esm 加载器并禁用实验性警告。

¥Lastly, add some NPM scripts to ease the development. We will build the app via tsc, test it via vitest and run it locally via ts-node. There is one gotcha with ESM and dynamic imports. While it works fine for regular JavaScript files, once we mix runtime support for TypeScript via ts-node or vitest/esbuild, you start hitting the wall with errors like Unknown file extension ".ts". To get around that, we can use the ts-node/esm loader via NODE_OPTIONS environment variable - but that can get ugly, and we can do better - at least for the CLI, we have the mikro-orm-esm script, which automatically registers the ts-node/esm loader as well as disables the experimental warning.

所以记住 - 在带有 TypeScript 的 ESM 项目中始终使用 mikro-orm-esm。请注意,它需要安装 ts-node 依赖,如果你不使用 TypeScript,常规 mikro-orm 脚本将适合你。

¥So remember - always use mikro-orm-esm in the ESM projects with TypeScript. Note that it requires the ts-node dependency to be installed, if you don't use TypeScript, the regular mikro-orm script will work fine for you.

动态导入的这个问题可能出现在 CLI 使用和 vitest 中。虽然 --loader 解决方案适用于 CLI,但我们可以为 vitest 使用更原生的 vite,让我们在你开始编写第一个测试时稍后讨论该部分。

¥This issue with dynamic imports can surface for both the CLI usage and vitest. While the --loader solution works for the CLI, we can use something more vite-native for vitest, let's talk about that part later when you start writing the first test.

ts-node 二进制文件仅适用于较旧的 Node.js 版本,对于 v20 或更高版本,我们需要改用 node --loader ts-node/esm

¥The ts-node binary works only on older Node.js versions, for v20 or above, we need to use node --loader ts-node/esm instead.

package.json
{
"type": "module",
"dependencies": { ... },
"devDependencies": { ... },
"mikro-orm": { ... },
"scripts": {
"build": "tsc",
"start": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/server.ts",
"test": "vitest"
}
}

我们在 start 脚本中引用文件 src/server.ts,我们稍后会创建它,现在不用担心。

¥We refer to a file src/server.ts in the start script, we will create that later, no need to worry about it right now.

现在通过 npx mikro-orm-esm debug 测试 CLI,你应该看到类似以下内容:

¥Now test the CLI via npx mikro-orm-esm debug, you should see something like the following:

Current MikroORM CLI configuration
- dependencies:
- mikro-orm 6.0.0
- node 20.9.0
- knex 3.0.1
- sqlite3 5.1.6
- typescript 5.3.3
- package.json found
- ts-node enabled
- searched config paths:
- /blog-api/src/mikro-orm.config.ts (found)
- /blog-api/dist/mikro-orm.config.js (found)
- /blog-api/mikro-orm.config.ts (not found)
- /blog-api/mikro-orm.config.js (not found)
- configuration found
- database connection successful
- will use `entities` array (contains 0 references and 1 paths)
- /blog-api/dist/**/*.entity.js (not found)
- could use `entitiesTs` array (contains 0 references and 1 paths)
- /blog-api/src/**/*.entity.ts (not found)

这看起来不错,我们得到了正在安装的内容的很好的摘要,我们可以看到配置被正确加载,并且如预期的那样,没有发现任何实体 - 因为你需要先创建它们!

¥This looks good, we get a nice summary of what is being installed, we can see the config being loaded correctly, and as expected, no entities were discovered - because you need to create them first!

如果你使用 npx mikro-orm debug 而不是 npx mikro-orm-esm debug,则配置将无法加载,并且会出现类似于此的错误:

¥If you used npx mikro-orm debug instead of npx mikro-orm-esm debug, the configuration would fail to be loaded and an error similar to this one would be present:

Unknown file extension ".ts" for ./blog-api/src/mikro-orm.config.ts

然后测试 TypeScript 构建,因为我们现在有了可以编译的第一个文件。使用 npm run build 并检查 dist 文件夹是否使用我们的配置文件的 JavaScript 版本生成。

¥Then test the TypeScript build, as we now have the first file we can compile. Use npm run build and check if the dist folder gets generated with the JavaScript version of our config file.

在我们开始创建第一个实体之前,让我们快速进行健全性检查 - 这是我们迄今为止的初始目录结构:

¥Before we get to creating the very first entity, let's do a quick sanity check - this is our initial directory structure so far:

.
├── dist
│ └── mikro-orm.config.js
├── node_modules
├── package-lock.json
├── package.json
├── src
│ ├── mikro-orm.config.ts
│ └── modules
│ ├── article
│ ├── common
│ └── user
└── tsconfig.json

第一个实体

¥First Entity

这需要相当多的设置,但别担心,大部分繁重的工作都已经完成了。是时候创建你的第一个实体了 - User!在 src/modules/user 中创建一个 user.entity.ts 文件,其中包含以下内容:

¥This was quite a lot of setup, but don't worry, most of the heavy lifting is behind you. Time to create your first entity - the User! Create a user.entity.ts file in src/modules/user with the following contents:

信息

查看 定义实体 部分,其中提供了各种属性类型的许多示例以及定义实体的不同方法。

¥Check out the Defining Entities section which provides many examples of various property types as well as different ways to define your entities.

user.entity.ts
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';

@Entity()
export class User {

@PrimaryKey()
id!: number;

@Property()
fullName!: string;

@Property()
email!: string;

@Property()
password!: string;

@Property({ type: 'text' })
bio = '';

}

那么我们这里有什么?实体是一个 JavaScript 类,用 @Entity() 装饰器装饰,用其他装饰器(如 @Property())定义属性。实体代表数据库表,属性代表其列。

¥So what do we have here? An entity is a JavaScript class, decorated with an @Entity() decorator, that defines properties with other decorators (like @Property()). An entity represents a database table, and the properties represent its columns.

我们使用 *.entity.ts 后缀,以便于跨模块边界进行基于文件夹的发现。或者,你可以在 ORM 配置中明确提供实体类引用,例如 entities: [User]。使用显式设置,事情变得更加简化且不易出错,没有动态导入,也不涉及文件系统。但基于文件夹的发现可能很方便,尤其是当我们的应用增长到许多实体时。

¥We use the *.entity.ts suffix for easy folder-based discovery across module boundaries. Alternatively, you could explicitly provide the entity class references in the ORM config, e.g. entities: [User]. With the explicit setup, things are more streamlined and less error-prone, there is no dynamic importing, and no file system is involved. But folder-based discovery can be handy, especially when our app grows to many entities.

定义主键

¥Defining the primary key

每个实体都需要有一个主键,我们将使用一个简单的自动递增数字主键。当 MikroORM 看到具有 number 类型的单个主键属性时,它会默认使用该模式,因此执行以下操作就足够了:

¥Every entity needs to have a primary key, we will use a simple auto-increment numeric one. MikroORM defaults to that when it sees a single primary key property with a number type, so doing the following is enough:

@PrimaryKey()
id!: number;

如果你想使用 bigint 列类型,只需在装饰器选项中传递 type: 'bigint'BigInt 映射到 string,因为它们无法安全地放入 JavaScript number

¥In case you want to use bigint column type, just pass type: 'bigint' in the decorator options. BigInts are mapped to string, as they would not fit into JavaScript number safely.

@PrimaryKey({ type: 'bigint' })
id!: string;

另一个常见用例是 UUID。我们可以利用这样一个事实,即 MikroORM 在创建托管实体实例(从数据库加载的实例)时从不调用你的实体构造函数。这意味着属性初始化器(或一般构造函数)仅对将产生 INSERT 查询的实体执行。

¥Another common use case is UUID. We can leverage the fact, that MikroORM never calls your entity constructor when creating managed entity instances (those loaded from your database). This means property initializers (or in general constructors) are executed only for entities that will produce an INSERT query.

@PrimaryKey({ type: 'uuid' })
uuid = uuid.v4();

标量属性

¥Scalar properties

要映射常规数据库列,我们可以使用 @Property() 装饰器。它的工作方式与上面的 @PrimaryKey() 装饰器描述器相同。你可以说它扩展了它 - 你可以传递给 @Property() 装饰器的所有属性在 @PrimaryKey() 中也可用。

¥To map regular database columns we can use the @Property() decorator. It works the same as the @PrimaryKey() decorator describer above. You could say it extends it - all the properties you can pass to the @Property() decorator are also available in @PrimaryKey() too.

我们使用 ts-morph 元数据提供程序,它有助于高级类型推断。如果你想使用基于 reflect-metadata 的默认元数据提供程序,请查看 文档 以了解差异。

¥We are using the ts-morph metadata provider, which helps with advanced type inference. Check out the documentation for the differences if you'd like to use the default metadata provider which is based on reflect-metadata.

ORM 会自动将 string 属性映射到 varchar,对于 User.bio,我们想使用 text,因此我们通过 type 装饰器选项进行更改:

¥The ORM will automatically map string properties to varchar, for the User.bio we want to use text instead, so we change it via the type decorator option:

@Property({ type: 'text' })
bio = '';

此处的 type 选项允许多种输入形式。我们在这里使用 text 类型名称,它映射到 TextType - ORM 内部使用的映射类型表示。如果你在那里提供字符串值,并且它与任何已知类型别名都不匹配,它将被视为列类型。我们还可以提供类型类实现而不是 string 类型名称:

¥The type option here allows several input forms. We are using the text type name here, which is mapped to the TextType - a mapped type representation used internally by the ORM. If you provide a string value there, and it won't match any known type alias, it will be considered as the column type. We can also provide a type class implementation instead of a string type name:

import { TextType } from '@mikro-orm/core';

@Property({ type: TextType })
bio = '';

还有一个 types 映射(为简洁起见,也导出为 t):

¥There is also a types map (exported also as just t for brevity):

import { t } from '@mikro-orm/core'; // `t` or `types`

@Property({ type: t.text })
bio = '';

我们始终可以提供显式列类型(甚至可以与 type 选项结合使用,以覆盖映射类型的实现方式):

¥We can always provide the explicit column type (and this can be even combined with the type option, to override how the mapped type is implemented):

import { t } from '@mikro-orm/core'; // `t` or `types`

@Property({ columnType: 'character varying(1000)' })
bio = '';

使用 columnType 时,请谨慎使用 lengthprecision/scale 等选项 - columnType 始终按原样使用,无需进行任何修改。这意味着你需要在那里传递最终值,包括长度,例如 columnType: 'decimal(10,2)'

¥When using the columnType, be careful about options like length or precision/scale - columnType is always used as-is, without any modification. This means you need to pass the final value there, including the length, e.g. columnType: 'decimal(10,2)'.

初始化 ORM

¥Initializing the ORM

最后一个缺少的步骤是初始化 MikroORM 以访问 EntityManager 和其他方便的工具(如 SchemaGenerator)。

¥The last missing step is to initialize the MikroORM to get access to the EntityManager and other handy tools (like the SchemaGenerator).

server.ts
import { MikroORM } from '@mikro-orm/sqlite'; // or any other driver package

// initialize the ORM, loading the config file dynamically
const orm = await MikroORM.init();
console.log(orm.em); // access EntityManager via `em` property
console.log(orm.schema); // access SchemaGeneartor via `schema` property

我们使用了没有任何参数的 init() 方法,这会导致 ORM 自动加载 CLI 配置。更明确地说,它与以下代码相同:

¥We used the init() method without any parameters, which results in the ORM loading the CLI config automatically. In a more explicit way, it's the same as the following code:

server.ts
import { MikroORM } from '@mikro-orm/sqlite';
import config from './mikro-orm.config.js';

const orm = await MikroORM.init(config);
同步初始化

与异步 MikroORM.init 方法相反,你可以选择使用同步变体 initSync()。此方法有一些限制:

¥As opposed to the async MikroORM.init method, you can prefer to use synchronous variant initSync(). This method has some limitations:

  • 首次与数据库交互时将建立数据库连接(或者你可以明确使用 orm.connect()

    ¥database connection will be established when you first interact with the database (or you can use orm.connect() explicitly)

  • 不加载 config 文件,options 参数是必需的

    ¥no loading of the config file, options parameter is mandatory

  • 不支持基于文件夹的发现

    ¥no support for folder-based discovery

  • 不检查不匹配的包版本

    ¥no check for mismatched package versions

使用实体管理器

¥Working with Entity Manager

所以现在你可以访问 EntityManager 了,让我们来谈谈它是如何工作的以及如何使用它。

¥So now you have the access to EntityManager, let's talk about how it works and how you can use it.

持久化和刷新

¥Persist and Flush

我们应该首先描述 2 种方法来了解 MikroORM 中的持久化工作原理:em.persist()em.flush()

¥There are 2 methods we should first describe to understand how persisting works in MikroORM: em.persist() and em.flush().

em.persist(entity) 用于标记新实体以供将来持久化。它将使实体由 EntityManager 管理,一旦调用 flush,它将被写入数据库。

¥em.persist(entity) is used to mark new entities for future persisting. It will make the entity managed by the EntityManager and once flush will be called, it will be written to the database.

const user = new User();
user.email = 'foo@bar.com';

// first mark the entity with `persist()`, then `flush()`
em.persist(user);
await em.flush();

// we could as well use fluent API here and do this:
await em.persist(user).flush();

要了解 flush,让我们首先定义什么是托管实体:如果实体从数据库(通过 em.find())获取或通过 em.persist() 注册为新实体并稍后刷新(仅在 flush 之后才成为托管实体),则该实体是托管实体。

¥To understand flush, let's first define what managed entity is: An entity is managed if it's fetched from the database (via em.find()) or registered as new through em.persist() and flushed later (only after the flush it becomes managed).

em.flush() 将遍历所有托管实体,计算适当的变更集并执行相应的数据库查询。由于从数据库加载的实体会自动进行管理,因此我们不必调用 persist 来更新它们,flush 就足以更新它们。

¥em.flush() will go through all managed entities, compute appropriate change sets and perform according database queries. As an entity loaded from the database becomes managed automatically, we do not have to call persist on those, and flush is enough to update them.

const user = await em.findOne(User, 1);
user.bio = '...';

// no need to persist `user` as it's already managed by the EM
await em.flush();

让我们尝试在数据库中创建第一条记录,将其添加到 server.ts 文件而不是 console.log

¥Let's try to create our first record in the database, add this to the server.ts file instead of the console.log:

server.ts
// create new user entity instance
const user = new User();
user.email = 'foo@bar.com';
user.fullName = 'Foo Bar';
user.password = '123456';

// first mark the entity with `persist()`, then `flush()`
await orm.em.persist(user).flush();

// after the entity is flushed, it becomes managed, and has the PK available
console.log('user id is:', user.id);

现在通过 npm start 再次运行脚本,你将看到一个错误:

¥Now run the script again via npm start, and you will see an error:

ValidationError: Using global EntityManager instance methods for context specific actions is disallowed.
If you need to work with the global instance's identity map, use `allowGlobalContext` configuration option
or `fork()` instead.

还记得我们说过 orm.em 是一个全局 EntityManager 实例吗?看起来使用它不是一个好主意,事实上,默认情况下不允许使用它。在我们深入了解此消息之前,让我们快速定义两个我们尚未触及的术语 - 身份映射和工作单元。

¥Remember we said the orm.em is a global EntityManager instance? Looks like it is not a good idea to use it, in fact, it is disallowed by default. Before we get to the bottom of this message, let's quickly define two more terms we haven't touched yet - the Identity Map and Unit of Work.

  • 工作单元维护受业务事务影响的对象(实体)列表,并协调更改的写出。

    ¥Unit of Work maintains a list of objects (entities) affected by a business transaction and coordinates the writing out of changes.

  • Identity Map 通过将每个加载的对象保存在映射中来确保每个对象(实体)只加载一次。引用对象时使用映射查找对象。

    ¥Identity Map ensures that each object (entity) gets loaded only once by keeping every loaded object in a map. Looks up objects using the map when referring to them.

MikroORM 是一个试图实现持久性无知的数据映射器。这意味着你将 JavaScript 对象映射到关系数据库中,而该数据库根本不需要了解数据库。它如何工作?

¥MikroORM is a data-mapper that tries to achieve persistence-ignorance. This means you map JavaScript objects into a relational database that doesn't necessarily know about the database at all. How does it work?

工作单元和身份映射

¥Unit of Work and Identity Map

MikroORM 使用身份映射模式来跟踪对象。每当你从数据库中获取对象时,MikroORM 都会在其 UnitOfWork 中保留对该对象的引用。这为 MikroORM 提供了优化空间。如果你两次调用 EntityManager 并请求具有特定 ID 的实体,它将返回相同的实例:

¥MikroORM uses the Identity Map pattern to track objects. Whenever you fetch an object from the database, MikroORM will keep a reference to this object inside its UnitOfWork. This allows MikroORM room for optimizations. If you call the EntityManager and ask for an entity with a specific ID twice, it will return the same instance:

const jon1 = await em.findOne(Author, 1);
const jon2 = await em.findOne(Author, 1);

// identity map in action
console.log(jon1 === jon2); // true

身份映射仅通过 id 识别对象,因此不同条件的查询必须转到数据库,即使它之前刚刚执行过。但是,MikroORM 不会创建第二个 Author 对象,而是首先从行中获取主键,并检查它是否已经在 UnitOfWork 中有一个具有该主键的对象。

¥The Identity Map only knows objects by id, so a query for different criteria has to go to the database, even if it was executed just before. But instead of creating a second Author object MikroORM first gets the primary key from the row and checks if it already has an object inside the UnitOfWork with that primary key.

更改跟踪

¥Change Tracking

身份映射有第二个更重要的用例。每当你调用 em.flush() 时,ORM 都会遍历身份映射,并且对于每个实体,它会将原始状态与当前在实体上设置的值进行比较。如果检测到更改,则将对象排队等待 SQL UPDATE 操作。只有更改的字段才是更新查询的一部分。

¥The identity map has a second, more important use-case. Whenever you call em.flush(), the ORM will iterate over the Identity Map, and for each entity it compares the original state with the values that are currently set on the entity. If changes are detected, the object is queued for an SQL UPDATE operation. Only the fields that changed are part of the update query.

即使你没有调用 em.persist(),以下代码也会使用对 Author 对象所做的更改更新你的数据库:

¥The following code will update your database with the changes made to the Author object, even if you did not call em.persist():

const jon = await em.findOne(Author, 1);

jon.email = 'foo@bar.com';

await em.flush();

隐式事务

¥Implicit Transactions

拥有工作单元最重要的含义是它允许自动处理事务。

¥The most important implication of having Unit of Work is that it allows handling transactions automatically.

当你调用 em.flush() 时,所有计算的更改都会在数据库事务中查询。这意味着你可以通过调用 em.persist() 来控制事务的边界,一旦所有更改都准备好了,调用 flush() 将在事务内运行它们。

¥When you call em.flush(), all computed changes are queried inside a database transaction. This means that you can control the boundaries of transactions by calling em.persist() and once all your changes are ready, calling flush() will run them inside a transaction.

你还可以通过 em.transactional(cb) 手动控制事务边界。

¥You can also control the transaction boundaries manually via em.transactional(cb).

const user = await em.findOne(User, 1);

user.email = 'foo@bar.com';
const car = new Car();
user.cars.add(car);

await em.flush();

你可以在 事务和并发 页面中找到有关事务的更多信息。

¥You can find more information about transactions in Transactions and concurrency page.

为什么需要请求上下文?

¥Why is Request Context needed?

现在回到关于全局上下文的验证错误。凭借新获得的知识,我们知道 EntityManager 维护对身份映射中所有托管实体的引用。假设我们将在整个应用中使用单个身份映射(因此单个全局上下文,全局 EntityManager)。它将在所有可能并行运行的请求处理程序之间共享。

¥Now back to the validation error about global context. With the freshly gained knowledge, we know EntityManager maintains a reference to all the managed entities in the Identity Map. Imagine we would use a single Identity Map throughout our application (so a single global context, global EntityManager). It will be shared across all request handlers, that can run in parallel.

  1. 内存占用增加

    ¥growing memory footprint

    由于只有一个共享身份映射,我们不能在请求结束后就清除它。可能还有另一个请求正在使用它,因此从一个请求中清除身份映射可能会破坏并行运行的其他请求。这将导致内存占用量增加,因为在某个时间点被管理的每个实体都将保留在身份映射中。

    ¥As there would be only one shared Identity Map, we can't just clear it after our request ends. There can be another request working with it so clearing the Identity Map from one request could break other requests running in parallel. This will result in a growing memory footprint, as every entity that became managed at some point in time would be kept in the Identity Map.

  2. API 端点响应不稳定

    ¥unstable response of API endpoints

    每个实体都有 toJSON() 方法,该方法会自动将其转换为序列化形式如果我们只有一个共享身份映射,则可能会发生以下情况:

    ¥Every entity has toJSON() method, that automatically converts it to serialized form If we have only one shared Identity Map, the following situation may occur:

    假设有 2 个端点

    ¥Let's say there are 2 endpoints

    1. GET /article/:id 仅返回文章,不填充任何内容

      ¥GET /article/:id that returns just the article, without populating anything

    2. GET /article-with-author/:id 返回已填充的文章及其作者

      ¥GET /article-with-author/:id that returns the article and its author populated

    现在,当有人通过这两个端点请求同一篇文章时,我们最终可能会得到相同的输出:

    ¥Now when someone requests the same article via both of those endpoints, we could end up with both returning the same output:

    1. GET /article/1 返回 Article,但不填充其属性 author 属性

      ¥GET /article/1 returns Article without populating its property author property

    2. GET /article-with-author/1 返回 Article,这次填充了 author

      ¥GET /article-with-author/1 returns Article, this time with author populated

    3. GET /article/1 返回 Article,但这次也填充了 author

      ¥GET /article/1 returns Article, but this time also with author populated

    发生这种情况是因为有关正在填充的实体关联的信息存储在身份映射中。

    ¥This happens because the information about entity association being populated is stored in the Identity Map.

分叉取胜!

¥Fork to the win!

所以我们现在更好地理解了这个问题,解决方案是什么?错误表明了这一点 - 分叉。使用 fork() 方法,我们得到一个干净的 EntityManager 实例,它有一个新的工作单元,具有自己的上下文和身份映射。

¥So we understand the problem better now, what's the solution? The error suggests it - forking. With the fork() method we get a clean EntityManager instance, that has a fresh Unit of Work with its own context and Identity Map.

server.ts
// fork first to have a separate context
const em = orm.em.fork();

// first mark the entity with `persist()`, then `flush()`
await em.persist(user).flush();

再次运行 npm start,我们绕过了全局上下文验证错误,但只发现了另一个错误:

¥Running npm start again, we get past the global context validation error, but only to find another one:

TableNotFoundException: insert into `user` (`bio`, `email`, `full_name`, `password`) values ('', 'foo@bar.com', 'Foo Bar', '123456') - no such table: user

我们忘记创建数据库模式了。幸运的是,我们手头有所有需要的工具。你可以使用 MikroORM 提供的 SchemaGenerator 来创建模式,并在更改实体时保持同步。对于初始测试,让我们使用 refreshDatabase() 方法,这对于测试很方便 - 如果模式已经存在,它将首先删除该模式,然后根据实体定义(元数据)从头开始创建它。

¥We forgot to create the database schema. Fortunately, we have all the tools we need at hand. You can use the SchemaGenerator provided by MikroORM to create the schema, as well as to keep it in sync when you change your entities. For the initial testing, let's use the refreshDatabase() method, which is handy for testing - it will first drop the schema if it already exists and create it from scratch based on entity definition (metadata).

server.ts
// recreate the database schema
await orm.schema.refreshDatabase();

最后,npm start 应该会成功,如果你在配置中启用了调试模式,你将在日志中看到 SQL 查询,以及最后的 user.id 值。

¥Finally, npm start should succeed, and if you enabled the debug mode in your config, you will see the SQL queries in the logs, as well as the user.id value at the very end.

[query] create table `user` (`id` integer not null primary key autoincrement, `full_name` text not null, `email` text not null, `password` text not null, `bio` text not null); [took 1 ms]
[query] pragma foreign_keys = on; [took 0 ms]
[query] begin
[query] insert into `user` (`bio`, `email`, `full_name`, `password`) values ('', 'foo@bar.com', 'Foo Bar', '123456') [took 0 ms]
[query] commit
user id is: 1

你可以看到插入查询被封装在事务中。这是工作单元的另一个效果。em.flush() 调用将在事务内执行所有查询。如果出现故障,整个事务将回滚。

¥You can see the insert query being wrapped inside a transaction. That is another effect of the Unit of Work. The em.flush() call will perform all the queries inside a transaction. If something fails, the whole transaction will be rolled back.

获取实体

¥Fetching Entities

我们将第一个实体存储在数据库中。要从那里读取它,我们可以使用 find()findOne() 方法。

¥We have our first entity stored in the database. To read it from there we can use find() and findOne() methods.

server.ts
// find user by PK, same as `em.findOne(User, { id: 1 })`
const userById = await em.findOne(User, 1);
// find user by email
const userByEmail = await em.findOne(User, { email: 'foo@bar.com' });
// find all users
const allUsers = await em.find(User, {});

我们已经多次提到身份映射 - 是时候测试它是如何工作的了。我们说实体是受管理的,工作单元将跟踪其变化,并在我们调用 flush() 时计算它们。我们还表示,标记为 persist() 的新实体将在刷新后变为可管理的。

¥We mentioned the Identity Map several times already - time to test how it works. We said the entity is managed, and the Unit of Work will track its changes, and compute them when we call flush(). We also said a new entity that is marked with persist() will become managed after flushing.

将以下代码放入你的 server.ts 文件中,就在 orm.close() 调用之前:

¥Put the following code into your server.ts file, right before the orm.close() call:

server.ts
// user entity is now managed, if we try to find it again, we get the same reference
const myUser = await em.findOne(User, user.id);
console.log('users are the same?', user === myUser)

// modifying the user and flushing yields update queries
user.bio = '...';
await em.flush();

再次运行 npm start 并验证日志:

¥Run the npm start again and verify the logs:

users are the same? true
[query] begin
[query] update `user` set `bio` = '...' where `id` = 1 [took 0 ms]
[query] commit

接下来,让我们尝试做同样的事情,但使用 EntityManager 分叉:

¥Next, let's try to do the same, but with an EntityManager fork:

server.ts
// now try to create a new fork, does not matter if from `orm.em` or our existing `em` fork, as by default we get a clean one
const em2 = em.fork();
console.log('verify the EM ids are different:', em.id, em2.id);
const myUser2 = await em2.findOneOrFail(User, user.id);
console.log('users are no longer the same, as they came from different EM:', user === myUser2);

记录以下内容:

¥Which logs the following:

verify the EM ids are different: 3 4
[query] select `u0`.* from `user` as `u0` where `u0`.`id` = 1 limit 1 [took 0 ms]
users are no longer the same, as they came from different EM: false
信息

我们刚刚使用了 em.findOneOrFail() 而不是 em.findOne(),正如你可能已经猜到的那样,它的目的是始终返回一个值,否则抛出异常。

¥We just used em.findOneOrFail() instead of em.findOne(), as you may have guessed, its purpose is to always return a value, or throw otherwise.

你可以看到有一个选择查询来加载用户。这是因为我们使用了一个新的 fork,默认情况下它是干净的 - 它有一个空的身份图,因此它需要从数据库加载实体。在前面的例子中,我们在调用 em.findOne() 时已经拥有它。你通过主键查询实体,这样的查询将始终首先检查身份映射并优先选择其中的结果,而不是查询数据库。

¥You can see there is a select query to load the user. This is because we used a new fork, that is clean by default—it has an empty Identity Map, and therefore it needs to load the entity from the database. In the previous example, we already had it present by the time we were calling em.findOne(). You queried the entity by its primary key, and such a query will always first check the identity map and prefer the results from it instead of querying the database.

刷新已加载的实体

¥Refreshing loaded entities

上面描述的行为通常是我们想要的,并用作第一级缓存,但如果你总是想重新加载该实体,而不管现有状态如何,该怎么办?有几种选择:

¥The behavior described above is often what we want and serves as a first-level cache, but what if you always want to reload that entity, regardless of the existing state? There are several options:

FindOptionsem.find/findOne 方法的最后一个参数。

¥FindOptions is the last parameter of em.find/findOne methods.

  1. 首先分叉,以获得清晰的上下文

    ¥fork first, to have a clear context

  2. FindOptions 中使用 disableIdentityMap: true

    ¥use disableIdentityMap: true in the FindOptions

  3. 使用 em.refresh(entity)

    ¥use em.refresh(entity)

前两个的效果几乎相同,使用 disableIdentityMap 只是在幕后为我们分叉。让我们谈谈最后一个 - 刷新。使用 em.refresh()EntityManager 将忽略身份映射的内容并始终从数据库中获取实体。

¥The first two have pretty much the same effect, using disableIdentityMap just does the forking for us behind the scenes. Let's talk about the last one - refreshing. With em.refresh(), the EntityManager will ignore the contents of the Identity Map and always fetch the entity from the database.

server.ts
// change the user
myUser2.bio = 'changed';

// reload user with `em.refresh()`
await em2.refresh(myUser2);
console.log('changes are lost', myUser2);

// let's try again
myUser2!.bio = 'some change, will be saved';
await em2.flush();

再次运行 npm start 脚本,我们得到以下内容:

¥Running the npm start script again, we get the following:

[query] select `u0`.* from `user` as `u0` where `u0`.`id` = 1 limit 1 [took 1 ms, 1 result]
changes are lost User {
fullName: 'Foo Bar',
email: 'foo@bar.com',
password: '123456',
bio: '...',
id: 1
}
[query] begin
[query] update `user` set `bio` = 'some change, will be saved' where `id` = 1 [took 0 ms, 1 row affected]
[query] commit

删除实体

¥Removing entities

我们谈到了创建、读取和更新实体,CRUD 谜题的最后一块拼图是删除操作。要通过 EntityManager 删除实体,我们有两种可能性:

¥We touched on creating, reading and updating entities, the last piece of the puzzle to the CRUD riddle is the delete operation. To delete entities via EntityManager, we have two possibilities:

  1. 通过 em.remove() 标记实体实例 - 这意味着我们首先需要拥有实体实例。但不要担心,即使不从数据库加载它,你也可以获得一个 - 通过 em.getReference()

    ¥Mark entity instance via em.remove() - this means we first need to have the entity instance. But don't worry, you can get one even without loading it from the database - via em.getReference().

  2. 通过 em.nativeDelete() 触发 DELETE 查询 - 当你想要的只是一个简单的删除查询时,它可以很简单。

    ¥Fire DELETE query via em.nativeDelete() - when all you want is a simple delete query, it can be simple as that.

让我们测试第一种方法,通过实体实例删除:

¥Let's test the first approach with removing by entity instance:

server.ts
// finally, remove the entity
await em2.remove(myUser3!).flush();

实体引用

¥Entity references

那么上面提到的 em.getReference() 方法有什么作用,实体引用又是什么呢?

¥So what does the em.getReference() method mentioned above do and what is an entity reference in the first place?

MikroORM 将每个实体表示为一个对象,即使那些未完全加载的实体也是如此。这些被称为实体引用 - 它们实际上是常规实体类实例,但只有其主键可用。这使得无需查询数据库即可创建它们成为可能。引用像任何其他实体一样存储在身份映射中。

¥MikroORM represents every entity as an object, even those that are not fully loaded. Those are called entity references - they are in fact regular entity class instances, but only with their primary key available. This makes it possible to create them without querying the database. References are stored in the identity map just like any other entity.

上一个代码片段的替代方法也可以是这样的:

¥An alternative to the previous code snippet could be as well this:

const userRef = em.getReference(User, 1);
await em.remove(userRef).flush();

这个概念对于关系尤其重要,可以与所谓的 Reference 封装器结合使用以增加类型安全性,但我们稍后会讨论。

¥This concept is especially important for relationships and can be combined with the so-called Reference wrapper for added type safety, but we will get to that later.

实体状态和 WrappedEntity

¥Entity state and WrappedEntity

我们刚刚说实体引用是一个常规实体,但只带有主键。它如何工作?在实体发现期间(当你调用 MikroORM.init() 时发生),ORM 将修补实体原型并为 WrappedEntity 生成一个惰性 getter - 包含有关实体的各种元数据和状态信息的类。每个实体实例都会有一个,在隐藏的 __helper 属性下可用 - 要以类型安全的方式访问其 API,请使用 wrap() 辅助程序:

¥We just said that entity reference is a regular entity, but only with a primary key. How does it work? During entity discovery (which happens when you call MikroORM.init()), the ORM will patch the entity prototype and generate a lazy getter for the WrappedEntity - a class holding various metadata and state information about the entity. Each entity instance will have one, available under a hidden __helper property - to access its API in a type-safe way, use the wrap() helper:

import { wrap } from '@mikro-orm/core';

const userRef = em.getReference(User, 1);
console.log('userRef is initialized:', wrap(userRef).isInitialized());

await wrap(userRef).init();
console.log('userRef is initialized:', wrap(userRef).isInitialized());

你还可以扩展 MikroORM 提供的 BaseEntity。它定义了通过 wrap() 助手可用的所有公共方法,因此你可以执行 userRef.isInitialized()userRef.init()

¥You can also extend the BaseEntity provided by MikroORM. It defines all the public methods available via wrap() helper, so you could do userRef.isInitialized() or userRef.init().

WrappedEntity 实例还保存实体在加载或刷新时的状态 - 然后,工作单元在刷新期间使用此状态来计算差异。另一个用例是序列化,我们可以使用 toObject()toPOJO()toJSON() 方法将实体实例转换为普通的 JavaScript 对象。

¥The WrappedEntity instance also holds the state of the entity at the time it was loaded or flushed - this state is then used by the Unit of Work during flush to compute the differences. Another use case is serialization, we can use the toObject(), toPOJO() and toJSON() methods to convert the entity instance to a plain JavaScript object.

⛳ 检查点 1

¥⛳ Checkpoint 1

目前,我们的应用由一个 User 实体和一个 server.ts 文件组成,我们在其中测试了如何使用 EntityManager 使用它。你可以在此处找到适用于当前状态的 StackBlitz:

¥Currently, our app consists of a single User entity and a server.ts file where we tested how to work with it using EntityManager. You can find working StackBlitz for the current state here:

由于 ts-node 中 ESM 支持的工作方式,无法在 StackBlitz 项目中使用它 - 我们需要改用 node --loader。我们还使用内存数据库,SQLite 功能可通过特殊数据库名称 :memory: 获得。

¥Due to the nature of how the ESM support in ts-node works, it is not possible to use it inside StackBlitz project - we need to use node --loader instead. We also use in-memory database, SQLite feature available via special database name :memory:.

这是我们迄今为止的 server.ts file

¥This is our server.ts file so far: