by 雪隐 from juejin.cn/user/143341...
文欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可联系授权
重要笔记: 如果你想简单的进行高级事务管理,请复制下面的仓库: github.com/muhammetoze...
介绍-理解事务
在这篇文章中我将会讨论如何在NestJS中处理事务。在开始之前,明白什么是事务是非常重要的(如果您已经知道什么是事务,您可以跳过这个章节去看下一个章节)。让我们想象一个剧本我们试图进行下单。假定有3个数据表:products
,orders
和items
。显而易见,在我们的应用中products
表包含所有的商品。orders
表存储下单数据,如下单的数量,日期等等,items
表存储根据他们的物件/商品的被买数量。表的结构如下:
如我们看到的,我有2件商品和另外2张表(orders&items)是空的因为我还没有进行下单。如果我进行下单将会发送一个请求给如POST /api/place-order
这样的端口,这个端口将会发送2个写入请求到数据库。一个将会插入下单信息进orders表来创建一个下单对象,另一个将会插入物件/商品信息并且这个请求是为了存储我们在这个订单中买了什么。
如果2个请求都成功了,我们的表数据将会看上去像下面这样:
这里,在orders
表中我有一个id100
的下单对象,另外在items
表中我有和这个下单关联的另外2个对象。第一个是computer(因为商品id是1)并且电脑的数量也是1。第二个对象是鼠标并且数量是3。到现在为止所有的东西都看上去不错。但是当我第一个写入请求成功因此我能够创建下单对象,但是第二个插入items请求失败。在这种情况下,整个API都将失败并且将会发送给用户异常返回,但是接下来我将会有在我的数据库中有不一致的数据,因为下单数据将会被留在表中并且没有关联任何物件/商品。下面展示了一个请求成功另外的请求失败时的表情况:
在这种情况,无论发送何种异常,不应该有下单数据还留在表中。这样这条失败API才不会搅乱任何表和记录。所以我们看到的结果,我有一条下单数据但是没有任何商品和它关联。这个时候正是使用事务的时机。当您创建一个事务,没人任何东西会被永久的插入数据库除非您运行了COMMIT 。如果您不执行COMMIT 提交事务,它将会像您没有执行过任何语句一样。只有我进行一次提交后,所有的写入语句才能成功的执行。如果发生了什么错误并且您想要恢复所有您在API调中中执行的变更(insert,update,delete语句),那么您会想要执行ROLLBACK,它会回滚从事务开始后所有的变更。
您也许会想,我将会在POST /api/place-order
端点中开始一个事务并试图在这个事务中执行这2个语句。如果我们没有收到异常,我提交事务并把数据写入数据库。如果抛出任何异常,我会回滚然后所有的写入操作将会恢复并且这个失败的API将不会改变任何数据。因此这个规则是在任何时候您必须在单个路由中执行多个不同的写入语句,您必须把他们包在一个事务中。现在我们对事务有了一个很好的理解,然后我们可以继续看下面的文章了。
事务的不同实现方式
在这篇文章中,我将会向您们展示实现事务的2种不同的方式。第一种,我将会简单的使用query runner对象来开始一个事务。然后所有的写入语句都会使用这个query runner对象来确保我们所有的写入操作都会在这个事务中。第二种方式将是高级方式,我们将利用拦截器和请求范围的仓库来自动包装特定路由的所有查询在一个事务中,这样我们就不必每次手动创建查询运行器对象。
注意
我只想指出,我们正在构建的这个数据库和API并不是为生产使用而设计的,因为我们缺少很多概念,比如身份验证、用户注册等等。我只是创建了这个应用程序,以便更容易实现和解释事务。
简单实现
就像我说的那样,这是一个简单的方法,我创建一个query runner,取得连接,开始事务,并在这个事务中执行所有的语句。想象下我有2个TypeORM实体Order
和Item
。没有事务的执行方式应该像下面这样:
less
constructor(
// first inject repositories
@InjectRepository(Order)
private orderRepository: Repository<Order>,
@InjectRepository(Item)
private itemRepository: Repository<Item>
) {}
@Post()
async routeHandler() {
// then make queries
await this.orderRepository.save({
/* order data */
});
await this.itemRepository.save({
/* Item data */
});
// ...
}
然而,就像我说的那样,这是一个坏的方法我们需要把这2个语句放到事务中。下面是如何使用query runner对象来实现事务:
ts
constructor(
// inject data source
private dataSource: DataSource
) {}
@Post()
async routeHandler() {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.save(Order, {
/* order data */
});
await queryRunner.manager.save(Item, {
/* item data */
});
await queryRunner.commitTransaction();
} catch (e) {
await queryRunner.rollbackTransaction();
throw e;
} finally {
await queryRunner.release();
}
// ...
}
这里有一些东西需要指出:
queryRunner.connect()
方法从连接池获得一个连接queryRunner.startTransaction()
是创建事务的异步方法- 我使用
queryRunner.manager
对象来访问实体Manager它允许我们在打开的事务中通过连接执行数据库语句。这意味着,所有的我发送的写入语句要通过这个实体Manager,它被包裹在事务之中。实体Manager导出的方法有save
,find
,findOne
等等。同样当使用实体manager,第一个参数必须是实体类这样实体Manager才能知道要把数据插入到哪个表中。 - 一旦我执行了所有的语句并且没有异常发生,我执行
queryRunner.commitTransaction()
来提交事务,它将会把数据写入表中。 - 如果异常发生了,我执行
queryRunner.rollbackTransaction()
来恢复所有的变化并返回一个个给Nest处理的异常 - 最后,我在最后的代码块中执行
queryRunner.release()
方法来返还连接给连接池。这一步是非常重要的并且如果您不释放这个连接这会导致发生一些性能问题。
我只是写了一个非常基础的方法来说明在事务中执行这些语句。这篇文章接下来的章节,我将从零开始设置一个新项目,我将展示一种高级的方式来使用拦截器、请求范围的仓库和自定义装饰器来管理事务。如果你喜欢高级的内容,就一直关注下去吧 😃
高级的事务管理
现在我们知道了如何实现一个基本的事务,是时候使用设计模式来实现事务了。
注意:
我假设您已经知道如何使用TypeORM并且理解NestJS架构的一些基础知识了。这个项目的重点是如何实现事务。当然我只会创建最小数量的端点来说明如何实现事务。如果您想添加更多的端点来扩展应用,请随意。
数据库设计
让我们开始设计数据库。正如我们看到的,我有product
表它拥有商品记录。order
表的职责是保存下单关联的数据,最后item
表,它保存了特定某个下单的买入的物品(商品)
正如这些关系,在order
和item
表之间这有一个一对多关系,一些下单可能有多个物品。另外item
和product
也是一对多的关系一件商品可能在物品表里面有多件。我将会把这些关系记在脑子里用来实现实体类。
文件结构
bash
/src
/common
base-repository.ts
transaction.interceptor.ts
/modules
/items
/dto
create-item.dto.ts
items.entity.ts
items.module.ts
items.repository.ts
items.service.ts
/orders
orders.controller.ts
orders.entity.ts
orders.module.ts
orders.repository.ts
orders.service.ts
/products
products.controller.ts
products.entity.ts
products.module.ts
products.repository.ts
products.service.ts
app.module.ts
main.ts
正如我们看到的,我们有3个不同的模块:
- Items: 负责向订单添加商品的责任
- Orders: 负责管理下单
- Products: 负责从数据库取得商品信息
公共
我将会描述各个模块更多的细节但是首先我想先看一看公共的目录:
ts
// transaction.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable, catchError, concatMap, finalize } from 'rxjs';
import { DataSource } from 'typeorm';
export const ENTITY_MANAGER_KEY = 'ENTITY_MANAGER';
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
constructor(private dataSource: DataSource) {}
async intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Promise<Observable<any>> {
// get request object
const req = context.switchToHttp().getRequest<Request>();
// start transaction
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
// attach query manager with transaction to the request
req[ENTITY_MANAGER_KEY] = queryRunner.manager;
return next.handle().pipe(
// concatMap gets called when route handler completes successfully
concatMap(async (data) => {
await queryRunner.commitTransaction();
return data;
}),
// catchError gets called when route handler throws an exception
catchError(async (e) => {
await queryRunner.rollbackTransaction();
throw e;
}),
// always executed, even if catchError method throws an exception
finalize(async () => {
await queryRunner.release();
}),
);
}
}
每当将这个拦截器应用于一个路由时,我都会启动一个事务,并将包含事务的实体管理器附加到请求中,键名为 'ENTITY_MANAGER'。为了避免可能的拼写错误,我们会将该值导出为一个常量。
接下来,我们运行实际的请求处理方法,使用 next.handle()
。紧接其后的 pipe()
调用有三个参数。第一个参数是一个映射或 tap 函数的类型,在成功处理请求后处理响应数据。在这里,我们提交事务并原样返回数据。第二个参数是错误处理程序,在控制器抛出异常时运行。在该函数中,我们回滚事务,因为 API 调用失败,并抛出错误。最后一个参数,也就是 finalize
方法,将始终被执行。我们使用该函数来释放从连接池中保留的连接。这一步非常重要。
接着实现类在公共文件夹下放一个公共的仓库:
ts
import { Request } from 'express';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { ENTITY_MANAGER_KEY } from './transaction.interceptor';
export class BaseRepository {
constructor(private dataSource: DataSource, private request: Request) {}
protected getRepository<T>(entityCls: new () => T): Repository<T> {
const entityManager: EntityManager =
this.request[ENTITY_MANAGER_KEY] ?? this.dataSource.manager;
return entityManager.getRepository(entityCls);
}
}
这个类建议作为所有仓库类的父类并且它在构造函数中有2个参数:
- dataSource:连接池中的一个简单的连接,它没有任何事务
- request:请求对象,我直接访问请求对象,因为我将会在请求范围来定义我们的仓库类。
然后,我们可以看到有一个受保护的方法,其唯一任务是从请求对象中获取实体管理器(如果存在)。只有在上述拦截器被使用时,我们才能从请求中获取实体管理器。如果请求对象中没有实体管理器,那么意味着该操作不是在事务中运行的,我们会从 dataSource 对象中获取实体管理器。一旦获取了实体管理器,我们将通过传递适当的实体类来调用 getRepository 方法,以获取相应的存储库实例。
简而言之,这个方法将为我们提供一个存储库实例,而该存储库将在事务拦截器被使用时在事务内运行查询。如果未使用事务拦截器,它将在任何事务之外运行查询。
仓库
接着,写仓库类,仓库类的职责是和数据库进行交互。将数据库通信提取到另一层的好处是拥有更清晰的设计,同时也使单元测试更容易进行。说到这里,我们需要详细了解项目中的3个存储库类:
ts
// products.repository.ts
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { BaseRepository } from 'src/common/base-repository';
import { DataSource } from 'typeorm';
import { Product } from './products.entity';
@Injectable({ scope: Scope.REQUEST })
export class ProductsRepository extends BaseRepository {
constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) {
super(dataSource, req);
}
async getAllProducts() {
return await this.getRepository(Product).find();
}
}
ts
// items.repository.ts
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { BaseRepository } from 'src/common/base-repository';
import { DataSource } from 'typeorm';
import { Item } from './items.entity';
import { CreateItemDto } from './dtos/create-item.dto';
@Injectable({ scope: Scope.REQUEST })
export class ItemsRepository extends BaseRepository {
constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) {
super(dataSource, req);
}
// Create multiple items
async createItems(orderId: number, data: CreateItemDto[]) {
const items = data.map((e) => {
return {
order: { id: orderId },
product: { id: e.productId },
quantity: e.quantity,
} as Item;
});
await this.getRepository(Item).insert(items);
}
}
ts
// orders.repository.ts
import { Inject, Injectable, Scope } from '@nestjs/common';
import { Request } from 'express';
import { BaseRepository } from 'src/common/base-repository';
import { DataSource } from 'typeorm';
import { Order } from './orders.entity';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.REQUEST })
export class OrdersRepository extends BaseRepository {
constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) {
super(dataSource, req);
}
async getOrders() {
return await this.getRepository(Order).find({
relations: {
items: {
product: true,
},
},
});
}
async createOrder(orderNo: string) {
const ordersRepository = this.getRepository(Order);
const order = ordersRepository.create({
date: new Date(),
orderNo: orderNo,
});
await ordersRepository.insert(order);
return order;
}
}
正如我们所看到的,所有这些存储库都扩展了基本存储库,并且它们都调用了一个 super
调用,以将数据源和请求对象传递给父类构造函数(BaseRepository)。另外,我们可以看到所有这些存储库都是请求范围的,这意味着它们在每个请求上都会重新初始化,这允许Nest注入请求对象,以便我们可以知道请求中是否有活动事务。
在产品存储库中,我们只有一个方法用于获取所有产品。我们没有在产品存储库中实现所有的 CRUD
操作,因为我们将手动创建测试数据。
在项目存储库中,我们只有一个方法,用于创建属于同一订单的多个项目。通过使用 map
函数,我们将 DTO
转换为正确格式的项目对象数组,并将它们全部保存在数据库中。下面是 CreateItemDto
的示例:
ts
import { IsNumber } from 'class-validator';
export class CreateItemDto {
@IsNumber()
productId: number;
@IsNumber()
quantity: number;
}
最后,我们有订单存储库,可以获取所有订单,也可以根据订单号创建订单。
实体
现在是时候来谈论实体了。让我们回一下表设计:
实体是在TypeORM中数据库表的代表并且我们必须定义实体,它会映射到数据库表中。下面是实体类:
ts
// items.entity.ts
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Order } from '../orders/orders.entity';
import { Product } from '../products/products.entity';
@Entity()
export class Item {
@PrimaryGeneratedColumn()
id: number;
@Column()
quantity: number;
@ManyToOne((type) => Order, (order) => order.items)
order: Order;
@ManyToOne((type) => Product, (product) => product.items)
product: Product;
}
ts
// orders.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Item } from '../items/items.entity';
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id: number;
@Column()
orderNo: string;
@Column({ type: 'datetime' })
date: Date;
@OneToMany((type) => Item, (item) => item.order)
items: Item[];
}
ts
// products.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Item } from '../items/items.entity';
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
price: number;
@OneToMany((type) => Item, (item) => item.product)
items: Item[];
}
正如我们所看到的,我们通过在每个实体中定义所需的列,并使用 @ManyToOne 和 @OneToMany 装饰器将它们彼此关联来定义了所有实体。我们在订单(orders)和商品(products)之间有一对多关系,还在商品(products)和商品项(items)之间有另一个一对多关系。现在我们了解了这些实体,接下来我们应该为每个模块定义其他组件,比如控制器(controllers)和服务(services)。
控制器&服务
让我们从定义控制器(controllers)和服务(services)开始。控制器应该只负责处理HTTP请求和响应,而服务则应包含实际的业务逻辑。因此,我们将业务逻辑与控制器分开。解释了这一点之后,现在我们可以继续解释每个模块。
商品模块
less
// products.controller.ts
@Controller('products')
export class ProductsController {
constructor(private productsService: ProductsService) {}
@Get()
async getAllProducts() {
return await this.productsService.getAllProducts();
}
}
ts
// products.service.ts
@Injectable()
export class ProductsService {
constructor(private productsRepository: ProductsRepository) {}
async getAllProducts() {
return await this.productsRepository.getAllProducts();
}
}
从产品控制器(products controller)可以看出,我们在这个模块中只定义了一个端点:
GET /products
这个端点允许我们获取所有的产品。我们没有实现所有的端点,因为我们不需要它们来演示事务。该控制器调用了getAllProducts
服务方法,然后该服务方法调用产品存储库(products repository)以获取所有产品。
物品模块
在物品(items)模块中,我们没有控制器,因为我们不希望通过HTTP端点公开项目服务(items.service.ts)中的任何功能。项目服务将被订单服务使用。以下是项目服务的实现:
ts
@Injectable()
export class ItemsService {
constructor(private itemsRepository: ItemsRepository) {}
async createItems(orderId: number, items: CreateItemDto[]) {
await this.itemsRepository.createItems(orderId, items);
}
}
我认为这相当容易理解。它只接受订单ID和项目数组,并在项目表中创建它们,调用了我们刚刚讨论过的项目存储库中的createItems
方法。
下单模块
ts
// orders.controller.ts
@Controller('orders')
export class OrdersController {
constructor(private ordersService: OrdersService) {}
@Get()
async getOrders() {
return await this.ordersService.getOrders();
}
@Post()
@UseInterceptors(TransactionInterceptor)
async createOrder(
@Body(new ParseArrayPipe({ items: CreateItemDto }))
data: CreateItemDto[],
) {
return await this.ordersService.createOrder(data);
}
}
正如我们所看到的,订单控制器(orders controller)公开了2个端点:
GET /orders
POST /orders
这些端点将用于检索订单以及创建新订单。让我们看一下被调用的服务方法,以理解业务逻辑:
ts
@Injectable()
export class OrdersService {
constructor(
private ordersRepository: OrdersRepository,
private itemsService: ItemsService,
) {}
async getOrders() {
return await this.ordersRepository.getOrders();
}
async createOrder(items: CreateItemDto[]) {
const orderNo = `ORD_${randomUUID()}`;
const order = await this.ordersRepository.createOrder(orderNo);
await this.itemsService.createItems(order.id, items);
return order;
}
}
getOrders
方法非常简单。它只是通过调用ordersRepository
的getOrders
方法从数据库中获取所有订单。然而,有趣的部分是我们还在构造函数中注入了itemsService
。当我们查看createOrder
方法时,我们有一个要保存在特定订单下的项目列表。我们首先生成一个随机的订单ID,创建订单,然后在其下创建项目。然而,创建订单对象和项目将需要至少2个查询。这就是为什么我们需要为这个端点使用事务。如果您仔细查看订单控制器,您会看到我们使用@UseInterceptors(TransactionInterceptor)
注解了createOrder
路由。这简单地确保在这个特定路由中所有写操作都将被包装在一个事务中,而存储库首先查看请求,看看是否有任何附加的活动事务,如果有一个,那个事务实体管理器将用来包装一切在一个事务中。这样,我们在创建订单中发送的两个查询(首先是createOrder
,然后是createItems
)将被包装在一个事务中。
这里有一点需要注意。除非导入整个模块,否则无法注入另一个模块的服务。为了使这个设置工作,项目模块必须导出其服务,而订单模块必须导入项目模块以能够使用其服务。以下是项目和订单模块的模块文件:
ts
@Module({
providers: [ItemsService, ItemsRepository],
exports: [ItemsService], // items module exports the service
})
export class ItemsModule {}
ts
@Module({
imports: [ItemsModule], // orders module imports items module
controllers: [OrdersController],
providers: [OrdersService, OrdersRepository],
})
export class OrdersModule {}
有了正确的导出和导入设置,一切都会正常工作。
App模块
最后我想提到的是应用程序模块(app module),它是每个 Nest 应用程序的根模块。在这里,我编写了一个简单的逻辑,在应用程序启动时创建一些产品,这样我们就不必手动编写 SQL 脚本来执行了。
ts
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.HOST,
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DATABASE,
entities: [Product, Order, Item],
synchronize: true,
logging: true, // log all the queries
dropSchema: true, // start with a clean db on each run, DO NOT USE FOR PRODUCTION
}),
ProductsModule,
OrdersModule,
ItemsModule,
],
controllers: [],
providers: [],
})
export class AppModule implements OnModuleInit {
constructor(private dataSource: DataSource) {}
async onModuleInit() {
await this.dataSource.query(`
insert into product (title, price) values
('Computer', 1000), ('Mouse', 19);
`);
}
}
正如我们所看到的,每当应用程序初始化时,我们都会向数据库中插入计算机(computer)和鼠标(mouse)的记录。此外,我们还可以看到在TypeOrmModule.forRoot
调用中的数据库配置和所有选项。
结果
让我们通过手动发送HTTP请求来测试端点,以查看查询是否实际上在事务内运行。您可以使用任何HTTP客户端来执行此操作。
我们发送以下请求:
ts
POST http://localhost/orders
Content-Type: application/json
[
{
"productId": 1,
"quantity": 3
},
{
"productId": 2,
"quantity": 5
}
]
这是响应内容:
json
{
"orderNo": "ORD_1dd3570d-b82e-4a8d-bb97-8a3b42766302",
"date": "2023-09-17T21:50:06.434Z",
"id": 1
}
此外,当我们查看控制台日志时,我们看到以下查询正在执行:
js
START TRANSACTION
INSERT INTO `order`(`id`, `orderNo`, `date`) VALUES (DEFAULT, ?, ?) -- PARAMETERS: ["ORD_1dd3570d-b82e-4a8d-bb97-8a3b42766302","2023-09-17T21:50:06.434Z"]
INSERT INTO `item`(`id`, `quantity`, `orderId`, `productId`) VALUES (DEFAULT, ?, ?, ?), (DEFAULT, ?, ?, ?) -- PARAMETERS: [3,1,1,5,1,2]
COMMIT
理解依赖注入链路
这里需要注意的一点是,请求作用域的提供者会在依赖注入链中传播。如果我们查看订单模块的依赖注入链,例如:
OrdersController → OrdersService → OrdersRepository
我们可以看到OrdersController
依赖于OrdersService
,而OrdersService
依赖于OrdersRepository
。然而,只要OrdersRepository
是请求作用域的,它也会使依赖于它的任何其他提供者成为请求作用域的。在这种情况下,OrdersController
和OrdersService
也将是请求作用域的。因此,在技术上,这种方法将使我们应用程序中几乎所有的提供者都成为请求作用域的。尽管这会带来轻微的性能开销,但在大多数情况下,这都是可以忽略的。然而,我认为值得提一下这一点。
总结
在本文中,我们深入研究了处理事务的简单和高级方法。高级方法的优势在于它可以有效地防止代码重复。当在路由上使用事务拦截器时,只要存储库类得到正确的实现,查询将自动在事务中运行,使整个过程更加流畅。
当然,这种方法也有一些不足之处:
- 有时可能会导致创建不必要的事务。
- 需要在其他接口(如RPC或命令应用程序)中进行类似的实现工作。
- 在某种程度上,已经偏离了规范,将事务性业务移到了控制器层面。
我希望这篇文章能够为广大开发人员提供有益的信息,并祝愿大家编码愉快 😃。