Как стать автором
Обновить

Как совершить транзакцию в Nest.js

Время на прочтение15 мин
Количество просмотров9K

Во множестве случаев разработчики должны использовать транзации при совершении различных операций на сервере. К примеру - перевод денег, либо другой измеримой ценности, да много чего еще. При таких операциях очень не хочется получить ошибку, которая прервет процесс и нарушит целостность данных.

А что вообще такое "транзакция"? Википедия говорит нам, что это - группа последовательных операций с базой данных, которая представляет собой логическую единицу работы с данными. Транзакция может быть выполнена либо целиком и успешно, соблюдая целостность данных и независимо от параллельно идущих других транзакций, либо не выполнена вообще, и тогда она не должна произвести никакого эффекта. Транзакции обрабатываются транзакционными системами, в процессе работы которых создаётся история транзакций.

Теперь, рассмотрим ситуацию, когда может произойти ошибка, ведущая к очень неприятным последствиям, если не использовать транзакции.

Я сделал небольшой проект, в котором есть две сущности:

  • Пользователь

  • Кошелек

Пользователи могут переводить друг другу деньги. При переводе проверяется достаточность суммы на балансе того, кто переводит, а также много других проверок. Если произойдет ситуация, когда с баланса отправителя деньги списаны, а на счет получателя не переведены, либо наоборот - мы увидим либо очень грустного, разъяренного человека, либо не увидим очень счастливого (зависит от суммы перевода).

Отлично, с тем, что транзакции важны и нужны разобрались (надеюсь, с этим согласны все). Но как их применять?

Для начала рассмотрим варианты запросов с ошибками и без ошибок, которые будут происходить, если использовать PostgreSQL.

Обычный набор запросов без ошибок:

// ...
SELECT "User"."id" AS "User_id", "User"."name" AS "User_name", "User"."defaultPurseId" AS "User_defaultPurseId"  
FROM "user" "User"  
WHERE "User"."id" IN ($1)
START TRANSACTION  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
START TRANSACTION  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT

К слову - этот запрос я не писал руками, а вытащил из логов ORM, но суть он отражает. Все довольно просто и понятно. Для построения запросов использовалась TypeORM, к которой мы вернемся немного позднее.

Настройки ORM и Postgres выставлены по умолчанию, поэтому каждая операция будет выполняться в своей транзации, но чтобы воспользоваться этим преимуществом, необходимо написать один запрос, в котором будет происходить сразу вся логика, связанная с базой данных.

Ниже приведен пример исполнения нескольких запросов, исполняемых в одной транзакции:

START TRANSACTION
// ...
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT

Ключевая разница с предыдущим примером запросов в том, что в данном случае все запросы выполняются в одной транзакции, а поэтому, если на каком-то этапе возникнет ошибка, то откатится вся транзакция со всеми запросами внутри нее. Примерно так:

START TRANSACTION
// ...
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
ROLLBACK

А вот, кстати, и код, который производил все предыдущие SQL-запросы. В нем имеется флаг, при установке которого возникает ошибка в самых неподходящий момент:

// ...
async makeRemittance(
  fromId: number,
  toId: number,
  sum: number,
  withError = false,
  transaction = true,
): Promise<RemittanceResultDto> {  
  const fromUser = await this.userRepository.findOne(fromId, { transaction });  
  const toUser = await this.userRepository.findOne(toId, { transaction });  
  if (fromUser === undefined) {  
    throw new Error(NOT_FOUND_USER_WITH_ID(fromId));  
  }  
  if (toUser === undefined) {  
    throw new Error(NOT_FOUND_USER_WITH_ID(toId));  
  }  
  if (fromUser.defaultPurseId === null) {  
    throw new Error(USER_DOES_NOT_HAVE_PURSE(fromId));  
  }  
  if (toUser.defaultPurseId === null) {  
    throw new Error(USER_DOES_NOT_HAVE_PURSE(toId));  
  }  
  const fromPurse = await this.purseRepository.findOne(fromUser.defaultPurseId, { transaction });  
  const toPurse = await this.purseRepository.findOne(toUser.defaultPurseId, { transaction });  
  const modalSum = Math.abs(sum);  
  if (fromPurse.balance < modalSum) {  
    throw new Error(NOT_ENOUGH_MONEY(fromId));  
  }  
  fromPurse.balance -= sum;  
  toPurse.balance += sum;  
  await this.purseRepository.save(fromPurse, { transaction });  
  if (withError) {  
    throw new Error('Unexpectable error was thrown while remittance');  
  }  
  await this.purseRepository.save(toPurse, { transaction });  
  const remittance = new RemittanceResultDto();  
  remittance.fromId = fromId;  
  remittance.toId = toId;  
  remittance.fromBalance = fromPurse.balance;  
  remittance.sum = sum;  
  return remittance;  
}
// ...

Отлично! Мы уберегли себя от убытков или очень огорченных пользователей (по крайней мере в вопросах, связанных с переводами денег).

Другие способы

Что дальше? Какие еще есть способы написать транзакцию? Так уж получилось, что человек, статью которого вы сейчас читаете (это я) очень любит один замечательный фреймворк, когда ему приходится писать backend. Имя этому фреймворку - Nest.js. Работает он на платформе Node.js, а код в нем пишется на Typescript. В этом прекрасном фреймворке имеется поддержка, практически из коробки, той самой TypeORM. Которая (или который?) мне, так уж получилось, тоже очень нравится. Не нравилось только одно - довольно запутанный, как мне кажется, излишне усложненный подход к написанию транзакций.

Это официальный пример по написанию транзакций:

import { getConnection } from 'typeorm';  

await getConnection().transaction(async transactionalEntityManager => {  
  await transactionalEntityManager.save(users);  
  await transactionalEntityManager.save(photos);  
  // ...  
});

Второй способ создания транзакций из документации:

@Transaction()  
save(user: User, @TransactionManager() transactionManager: EntityManager) {  
  return transactionManager.save(User, user);
}

В целом, смысл этого подхода заключается в следующем: вам необходимо получить transactionEntityManager: EntityManager - сущность, которая позволит выполнять запросы в рамках транказции. А затем использовать эту сущность для всех действий с базой. Звучит неплохо, до тех пор, пока не придется столкнуться с использованием данного подхода на практике.

Для начала - мне не очень нравится идея прокидывания зависимостей непосредственно в методы классов-сервисов, а также то, что написанные таким образом методы становятся обособленными в части использования вн едренных в сам сервис зависимостей. Все необходимые для работы метода зависимости придется в него же и прокидывать. Но самое неприятное - если ваш метод будет обращаться к другим сервисам, внедренным в ваш, то вам придется создавать такие же специальные методы в тех сторонних сервисах. И в них же передавать transactionEntityManager. При этом, стоит иметь в виду то, что если вы решили использовать подход через декораторы, то при передаче transactionEntityManager из одного сервиса во второй, и метод второго сервиса будет также отдекорирован - во втором методе вы получите не переданный в качестве зависимости transactionEntityManager, а тот, что создается декоратором, а значит - две разные транзакции, а значит горе-пользователей.

Начнем с примеров

Ниже показан код экшена контроллера, обрабатывающего пользовательские запросы:

// ...
@Post('remittance-with-typeorm-transaction')  
@ApiResponse({  
  type: RemittanceResultDto,  
})  
async makeRemittanceWithTypeOrmTransaction(@Body() remittanceDto: RemittanceDto) {  
  return await this.connection.transaction(transactionManager => {  
    return this.appService.makeRemittanceWithTypeOrmV1(
      transactionManager,
      remittanceDto.userIdFrom,
      remittanceDto.userIdTo,
      remittanceDto.sum,
      remittanceDto.withError,
    );  
  });  
}
// ...

В нём нам необходимо иметь доступ к объекту соединения connection, чтобы создать transactionManager. Мы могли бы поступить, как советуют в документации к TypeORM - и просто использовать функцию getConnection, как было показано выше:

import { getConnection } from 'typeorm';  
// ...
@Post('remittance-with-typeorm-transaction')  
@ApiResponse({  
  type: RemittanceResultDto,  
})  
async makeRemittanceWithTypeOrmTransaction(@Body() remittanceDto: RemittanceDto) {  
  return await getConnection().transaction(transactionManager => {  
    return this.appService.makeRemittanceWithTypeOrmV1(
      transactionManager,
      remittanceDto.userIdFrom,
      remittanceDto.userIdTo,
      remittanceDto.sum,
      remittanceDto.withError,
    );  
  });  
}
// ...

Но сдается мне, что такой код будет тестироваться уже сложнее, да и это просто неправильно (отличный аргумент). Поэтому нам придется прокидывать зависимость connection в конструктор контроллера. Очень повезло, что Nest позволяет это сделать просто описав поле в конструкторе с указанием соответствующего типа:

@Controller()  
@ApiTags('app')  
export class AppController {  
  constructor(  
    private readonly appService: AppService,  
    private readonly connection: Connection,  // <-- it is - what we need
  ) {  
 }
 // ...
 }

Таким образом мы приходим к выводу, что чтобы иметь возможность использовать транзакции в Nest при использовании TypeORM - необходимо прокидывать в конструктор контроллера / сервиса - класс connection, пока просто запомним это.

Теперь посмотрим на метод makeRemittanceWithTypeOrmV1 нашего appService:

async makeRemittanceWithTypeOrmV1(transactionEntityManager: EntityManager, fromId: number, toId: number, sum: number, withError = false) {  
  const fromUser = await transactionEntityManager.findOne(User, fromId);  // <-- we need to use only provided transactionEntityManager, for make all requests in transaction
  const toUser = await transactionEntityManager.findOne(User, toId);  // <-- and there
  if (fromUser === undefined) {  
    throw new Error(NOT_FOUND_USER_WITH_ID(fromId));  
  }  
  if (toUser === undefined) {  
    throw new Error(NOT_FOUND_USER_WITH_ID(toId));  
  }  
  if (fromUser.defaultPurseId === null) {  
    throw new Error(USER_DOES_NOT_HAVE_PURSE(fromId));  
  }  
  if (toUser.defaultPurseId === null) {  
    throw new Error(USER_DOES_NOT_HAVE_PURSE(toId));  
  }  
  const fromPurse = await transactionEntityManager.findOne(Purse, fromUser.defaultPurseId);  // <-- there
  const toPurse = await transactionEntityManager.findOne(Purse, toUser.defaultPurseId);  // <-- there
  const modalSum = Math.abs(sum);  
  if (fromPurse.balance &lt; modalSum) {  
    throw new Error(NOT_ENOUGH_MONEY(fromId));  
  }  
  fromPurse.balance -= sum;  
  toPurse.balance += sum;  
  await this.appServiceV2.savePurse(fromPurse);  // <-- oops, something was wrong
  if (withError) {  
    throw new Error('Unexpectable error was thrown while remittance');  
  }  
  await transactionEntityManager.save(toPurse);  
  const remittance = new RemittanceResultDto();  
  remittance.fromId = fromId;  
  remittance.toId = toId;  
  remittance.fromBalance = fromPurse.balance;  
  remittance.sum = sum;  
  return remittance;  
}

Весь проект синтетический, но чтобы показать неприятность сего подхода - я вынес в отдельный сервис appServiceV2 метод savePurse, используемый для сохранения кошелька, и использовал этот сервис с этим методом внутри рассматриваемого метода makeRemittanceWithTypeOrmV1. Код данного метода и сервиса вы можете увидеть ниже:

@Injectable()  
export class AppServiceV2 {  
  constructor(
    @InjectRepository(Purse)  
    private readonly purseRepository: Repository<Purse>,  
  ) {  
 }  
  async savePurse(purse: Purse) {  
    await this.purseRepository.save(purse);  
  }
  // ...
}

Собственно, при этой ситуации мы получаем такие SQL-запросы:

START TRANSACTION  
// ...
SELECT "User"."id" AS "User_id", "User"."name" AS "User_name", "User"."defaultPurseId" AS "User_defaultPurseId"  
FROM "user" "User"  
WHERE "User"."id" IN ($1)
START TRANSACTION  // &lt;-- this transaction from appServiceV2
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT

Если мы отправим запрос, чтобы происходила ошибка, то явно увидим, что внутренняя транзакция, от appServiceV2 не откатывается, а поэтому - мы огребаем от наших пользователей.

START TRANSACTION
// ...
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
START TRANSACTION  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT  
ROLLBACK

Тут мы делаем вывод, что для стандартного подхода к транкациям необходимо иметь специальные методы, в которые будет нужно прокидывать transactionEntityManager.

Если же мы хотим избавиться от необходимости явного внедрения transactionEntityManager в соответствующие методы - то документация советует нам взглянуть на декораторы.

Применив их мы получим такого вида экшен контроллера:

// ...
@Post('remittance-with-typeorm-transaction-decorators')  
@ApiResponse({  
  type: RemittanceResultDto,  
})  
async makeRemittanceWithTypeOrmTransactionDecorators(@Body() remittanceDto: RemittanceDto) {  
  return this.appService.makeRemittanceWithTypeOrmV2(remittanceDto.userIdFrom, remittanceDto.userIdTo, remittanceDto.sum, remittanceDto.withError);  
}
// ...

Теперь он стал проще - нет необходимости в использовании класса connection, ни в конструкторе, ни вызывая глобальный метод TypeORM. Прекрасно. Но метод нашего сервиса, по прежнему, должен получать зависимость - transactionEntityManager. Тут на помощь и приходят те самые декораторы:

// ...
@Transaction()  // <-- this
async makeRemittanceWithTypeOrmV2(fromId: number, toId: number, sum: number, withError: boolean, @TransactionManager() transactionEntityManager: EntityManager = null /* <-- and this */) {  
  const fromUser = await transactionEntityManager.findOne(User, fromId);  
  const toUser = await transactionEntityManager.findOne(User, toId);  
  if (fromUser === undefined) {  
    throw new Error(NOT_FOUND_USER_WITH_ID(fromId));  
  }  
  if (toUser === undefined) {  
    throw new Error(NOT_FOUND_USER_WITH_ID(toId));  
  }  
  if (fromUser.defaultPurseId === null) {  
    throw new Error(USER_DOES_NOT_HAVE_PURSE(fromId));  
  }  
  if (toUser.defaultPurseId === null) {  
    throw new Error(USER_DOES_NOT_HAVE_PURSE(toId));  
  }  
  const fromPurse = await transactionEntityManager.findOne(Purse, fromUser.defaultPurseId);  
  const toPurse = await transactionEntityManager.findOne(Purse, toUser.defaultPurseId);  
  const modalSum = Math.abs(sum);  
  if (fromPurse.balance &lt; modalSum) {  
    throw new Error(NOT_ENOUGH_MONEY(fromId));  
  }  
  fromPurse.balance -= sum;  
  toPurse.balance += sum;
  await this.appServiceV2.savePurseInTransaction(fromPurse, transactionEntityManager);  // <-- we will check is it will working
  if (withError) {  
    throw new Error('Unexpectable error was thrown while remittance');  
  }  
  await transactionEntityManager.save(toPurse);  
  const remittance = new RemittanceResultDto();  
  remittance.fromId = fromId;  
  remittance.toId = toId;  
  remittance.fromBalance = fromPurse.balance;  
  remittance.sum = sum;  
  return remittance;  
}
// ...

С тем, что простое использование метода стороннего сервиса ломает наши транзакции - мы уже разобрались. Поэтому мы использовали новый метод стороннего сервиса transactionEntityManager, который имеет следующий вид:

// ..
@Transaction()  
async savePurseInTransaction(purse: Purse, @TransactionManager() transactionManager: EntityManager = null) {  
  await transactionManager.save(Purse, purse);  
}
// ...

Как видно из кода, в данном методе мы также применили декораторы - так мы достигаем единообразия по всем методам в проекте (ага), а также избавляемся от необходимости использования connection в конструкторе контроллеров, использующих наш сервис appServiceV2.

При таком подходе мы получаем такие запросы:

START TRANSACTION
// ... 
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
START TRANSACTION  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT

И, как следствие - разрушение транзакции и логики приложения при ошибке:

START TRANSACTION
// ...
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
START TRANSACTION  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT  
ROLLBACK

Единственный рабочий способ, который описывает документация - это отказ от использования декораторов, т.к. если использовать декораторы во всех методах сразу - то в те из них, что будут использоваться другими сервисами, будут внедрены свои собственные transactionEntityManager'ы, как это произошло с нашим сервисом appServiceV2 и его методом savePurseInTransaction. Попробуем заменить данный метод другим:

// app.service.ts
@Transaction()  
async makeRemittanceWithTypeOrmV2(fromId: number, toId: number, sum: number, withError: boolean, @TransactionManager() transactionEntityManager: EntityManager = null) {  
  // ...
  await this.appServiceV2.savePurseInTransactionV2(fromPurse, transactionEntityManager);  
  // ...
}

// app.service-v2.ts
// ..
async savePurseInTransactionV2(purse: Purse, transactionManager: EntityManager) {  
  await transactionManager.save(Purse, purse);  
}
// ..

Т.к. для единообразия наших методов, и избавления появившейся иерархии, проявляющейся в том, что одни методы могут вызывать другие, но третьи не смогут вызывать первые - мы изменим и метод класса appService, придя к первому способу из документации.

Рояль в кустах

Что же, кажется, нам все равно придется внедрять этот connection в конструкторы контроллеров. Но предлагаемый способ написания кода с транзакциями по прежнему выглядит очень громоздким и неудобным. Что делать? Решая данную неприятность я сделал пакет, который позволяет наиболее простым способом использовать транзакции. Называется он nest-transact.

Что он делает? Тут все просто. На нашем примере с пользователями и переводами посмотрим на ту же логику, написанную с помощью nest-transact.

Код нашего контроллера не изменился, и, раз уж мы убедились в том, что без connection в конструкторе не обойтись - укажем его:

@Controller()  
@ApiTags('app')  
export class AppController {  
  constructor(  
    private readonly appService: AppService,  
    private readonly connection: Connection,  // <-- use this
  ) {  
 }
 // ...
}

Экшен контроллера:

// ...
@Post('remittance-with-transaction')  
@ApiResponse({  
  type: RemittanceResultDto,  
})  
async makeRemittanceWithTransaction(@Body() remittanceDto: RemittanceDto) {  
  return await this.connection.transaction(transactionManager => {  
    return this.appService.withTransaction(transactionManager)/* <-- this is interesting new thing*/.makeRemittance(remittanceDto.userIdFrom, remittanceDto.userIdTo, remittanceDto.sum, remittanceDto.withError);  
  });  
}
// ...

Его отличие от экшена, в случае использования первого способа из документации:

@Post('remittance-with-typeorm-transaction')  
@ApiResponse({  
  type: RemittanceResultDto,  
})  
async makeRemittanceWithTypeOrmTransaction(@Body() remittanceDto: RemittanceDto) {  
  return await this.connection.transaction(transactionManager => {  
    return this.appService.makeRemittanceWithTypeOrmV1(transactionManager, remittanceDto.userIdFrom, remittanceDto.userIdTo, remittanceDto.sum, remittanceDto.withError);  
  });  
}

В том, что мы можем использовать обычные методы сервисов, не создавая специфические вариации для транзакций, в которые необходимо прокидывать transactionManager. А также - что перед использованием нашего бизнес-метода сервиса, мы вызываем метод withTransaction, на этом же сервисе, передавая в него наш transactionManager. Тут можно задаться вопросом - откуда взялся этот метод? Отсюда:

@Injectable()  
export class AppService extends TransactionFor<AppService> /* <-- step 1 */ {  
  constructor(  
    @InjectRepository(User)  
    private readonly userRepository: Repository<user>,  
    @InjectRepository(Purse)  
    private readonly purseRepository: Repository<purse>,  
    private readonly appServiceV2: AppServiceV2,  
    moduleRef: ModuleRef, // <-- step 2
  ) {  
  super(moduleRef);  
  }
  // ...
}

А вот и код запросов:

START TRANSACTION
// ... 
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT

И с ошибкой:

START TRANSACTION
// ...
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
ROLLBACK

Но вы его уже видели в самом начале.

Чтобы эта магия заработала - нужно выполнить два шага:

  • Наш сервис должен наследоваться от класса TransactionFor<servicetype>

  • Наш сервис должен иметь в списке зависимостей конструктора специальный класс moduleRef: ModuleRef

Все. Кстати, т.к. внедрение зависимостей самим фреймворком никуда не делось - явно прокидывать moduleRef не придется. Только при тестировании.

Возможно, вы подумаете - А зачем мне наследоваться от этого класса? Вдруг мой сервис должен будет наследоваться от какого-то другого? Если подумали - то предлагаю посчитать, сколько ваших сервисов отнаследованы от других классов, и используются при транзакциях.

Теперь, как это работает? Появившийся метод withTransaction - пересоздает для данной транзакции ваш сервис, а также все зависимости вашего сервиса и зависимости зависимостей - всё, всё, всё. Отсюда следует, что если вы каким-то образом храните некое состояние в ваших сервисах (ну а вдруг?) - то его не будет при создании транзакции таким образом. Оригинальный инстанс вашего сервиса все так же существует и при его вызове все будет как и раньше.

В дополнение к предыдущему примеру я добавил и жадный метод: перевод с комиссией, в котором используются сразу два сервиса в одном экшене контроллера:

// ...
@Post('remittance-with-transaction-and-fee')  
@ApiResponse({  
  type: RemittanceResultDto,  
})  
async makeRemittanceWithTransactionAndFee(@Body() remittanceDto: RemittanceDto) {  
  return this.connection.transaction(async manager => {  
    const transactionAppService = this.appService.withTransaction(manager); // <-- this is interesting new thing  
    const result = await transactionAppService.makeRemittance(remittanceDto.userIdFrom, remittanceDto.userIdTo, remittanceDto.sum, remittanceDto.withError);  
    result.fromBalance -= 1; // <-- transfer fee  
    const senderPurse = await transactionAppService.getPurse(remittanceDto.userIdFrom);  
    senderPurse.balance -= 1; // <-- transfer fee, for example of using several services in one transaction in controller  
    await this.appServiceV2.withTransaction(manager).savePurse(senderPurse);  
    return result;  
  });  
}
// ...

Этот метод производит следующие запросы:

START TRANSACTION
// ...
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
// this is new requests for fee:
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."userId" = $1  
LIMIT 1  
SELECT "Purse"."id" AS "Purse_id", "Purse"."balance" AS "Purse_balance", "Purse"."userId" AS "Purse_userId"  
FROM "purse" "Purse"  
WHERE "Purse"."id" IN ($1)  
UPDATE "purse"  
SET "balance" = $2  
WHERE "id" IN ($1)  
COMMIT

Из которых мы видим, что все запросы, по прежнему, происходят в одной транзации и работать она будет корректно.

Подводя итоги, хочется сказать - при использовании данного пакета в нескольких реальных проектах я получил намного более удобный способ написания транзакций, разумеется - в рамках стека Nest.js + TypeORM. Надеюсь, что он будет полезен и вам. Если вам понравится данный пакет и вы решите попробовать его использовать, маленькая просьба - поставьте ему звездочку на GitHub. Вам не сложно, а мне и проекту полезно. Также буду рад услышать конструктивную критику и возможные способы улучшения данного решения.

Теги:
Хабы:
Всего голосов 10: ↑10 и ↓0+10
Комментарии4

Публикации

Истории

Работа

Ближайшие события