Перейти к основному содержимому

Обработчики команд

Если «Команды и события» описывают, что едет в конверте, то обработчик — это то, что исполняет команду на стороне chaincode. Одна команда — один обработчик, и вся бизнес-логика живёт именно в них. Транспорт берёт на себя приём, проверку подписи и nonce, а обработчику отдаёт уже разобранную команду.

Базовый класс и метод execute​

Обработчик наследует TransportCommandAsyncHandler<U, V, T> из @ts-core/common — тот же базовый класс, что и в обычных сервисах @ts-core, потому что модель команд у блокчейна и у сервиса одна. Здесь U — тип запроса (DTO), V — тип ответа, T — тип команды. Разработчик реализует один метод execute(request, holder), а база оборачивает его: прогоняет запрос через checkRequest, выполняет execute, пропускает результат через checkResponse и завершает команду. Ошибку, брошенную из execute, она ловит и возвращает клиенту как ExtendedError с кодом — то самое свойство, благодаря которому доменная ошибка не теряется (см. «Команды и события»).

Привязка к команде задаётся в конструкторе — третьим аргументом super(logger, transport, Command.NAME). Отдельного «флага readonly» у обработчика нет: читающая команда это просто та, чей execute не делает ни putState, ни dispatch.

import { Injectable } from '@nestjs/common';
import { Logger, Transport, TransportCommandAsyncHandler } from '@ts-core/common';
import { StubHolder } from '@hlf-core/transport-chaincode';
import { UserAddCommand, IUserAddDto } from '@project/common/hlf/transport';
import { User, UserRole } from '@project/common/hlf/user';
import { IUserStubHolder, UserGuard, rolesCheck } from '@project/module/core/guard';
import { UserService } from '../service';

@Injectable()
export class UserAddHandler extends TransportCommandAsyncHandler<IUserAddDto, User, UserAddCommand> {
constructor(logger: Logger, transport: Transport, private service: UserService) {
super(logger, transport, UserAddCommand.NAME);
}

@UserGuard()
protected async execute(params: IUserAddDto, @StubHolder() holder: IUserStubHolder): Promise<User> {
await rolesCheck(holder, UserRole.USER_MANAGER);
return this.service.add(holder, params);
}
}

Доступ к состоянию: holder​

Второй аргумент execute — это holder, через который обработчик работает с ledger. Тонкость в том, что holder и команда — один и тот же объект: обёртка команды реализует сразу и интерфейс команды, и IStubHolder из @hlf-core/chaincode. Чтобы получить его в нужном параметре, параметр помечают декоратором @StubHolder() из @hlf-core/transport-chaincode.

Через holder.stub доступно состояние и контекст транзакции — getState / putState / hasState / removeState, события dispatch, пользователь stub.user (с полями id и publicKey, которые chaincode извлёк из подписи) и, что важно, stub.transaction с его date и hash. Именно их, а не Date.now() и не случайные значения, берут как источник времени и энтропии — иначе результат разъедется у разных пиров-эндорсеров и транзакция не пройдёт валидацию. Доменный код обычно расширяет holder под себя — добавляет загруженного пользователя и его менеджер:

import { IStubHolder } from '@hlf-core/chaincode';
import { ITransportCommand } from '@ts-core/common';

export interface IUserStubHolder<U = any> extends IStubHolder, ITransportCommand<U> {
user?: User;
manager?: UserManager;
}

Guard: авторизация перед execute​

Готового auth-guard во фреймворке нет, и это сделано сознательно: транспорт не знает доменную модель пользователя. Поэтому авторизацию пишут отдельным декоратором метода execute, который отрабатывает до бизнес-логики и может отклонить команду. Канонический рецепт — UserGuard: он грузит пользователя из состояния по stub.user.id, проверяет статус (ACTIVE) и — ключевое — сверяет записанный в ledger cryptoKey.value с публичным ключом подписи (stub.user.publicKey). Здесь и замыкается связка «ключ принадлежит этому пользователю», которой транспорт сам по себе не делает (см. «Безопасность»).

export const UserGuard = (options?: IUserGuardOptions): any => {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
if (_.isNil(options)) {
options = { isNeedCheck: true };
}
let originalMethod = descriptor.value;
descriptor.value = async function (...args): Promise<any> {
let holder = await getUserStubHolder(this.logger, target, args);
await validateUserStubHolder(this.logger, holder, options);
return originalMethod.apply(this, args);
};
};
};

async function validateUserStubHolder<U>(logger: ILogger, item: IUserStubHolder<U>, options: IUserGuardOptions): Promise<void> {
if (_.isEmpty(options) || !options.isNeedCheck) {
return;
}
let { id } = item.stub.user;
let { user } = item;
if (_.isNil(user)) {
item.manager = new UserManager(logger, item.stub);
item.user = user = await item.manager.get(id, ['cryptoKey']);
}
if (_.isNil(user)) {
throw new UserNotFoundError(id);
}

let { status, cryptoKey } = user;
if (status !== UserStatus.ACTIVE) {
throw new UserStatusForbiddenError({ value: status, expected: UserStatus.ACTIVE });
}
if (_.isNil(cryptoKey)) {
throw new UserCryptoKeyNotFoundError(id);
}
if (cryptoKey.value !== item.stub.user.publicKey) {
throw new UserCryptoKeyInvalidError(id);
}
}

Опция @UserGuard({ isNeedCheck: false }) пропускает проверку — для читающих команд вроде UserGetCommand или CoinBalanceGetCommand, которым ни пользователь, ни ключ не нужны. А проверку ролей держат отдельной функцией rolesCheck(holder, ...roles) — её вызывают прямо в execute там, где право зависит от операции, а не от самого факта авторизации. Рядом с ней — rolesSomeOf(...) для условия «хотя бы одна из ролей».

@Injectable()
export class CoinBalanceGetHandler extends TransportCommandAsyncHandler<ICoinBalanceGetDto, ICoinBalanceGetDtoResponse, CoinBalanceGetCommand> {
constructor(logger: Logger, transport: Transport, private service: CoinService) {
super(logger, transport, CoinBalanceGetCommand.NAME);
}

@UserGuard({ isNeedCheck: false })
protected async execute(params: ICoinBalanceGetDto, @StubHolder() holder: IUserStubHolder): Promise<ICoinBalanceGetDtoResponse> {
return this.service.balanceGet(holder, params);
}
}

Регистрация через NestJS​

Централизованного списка обработчиков нет, и это важный, легко упускаемый момент: обработчик подписывается на транспорт прямо в конструкторе, поэтому «регистрация» это просто его создание. В NestJS-проекте обработчики объявляют в controllers модуля, а не в providers — провайдер создаётся лениво, по первому обращению, и команда без него просто не будет услышана, тогда как контроллеры Nest инстанцирует сразу.

@Module({
controllers: [CoinGetHandler, CoinAddHandler, CoinEmitHandler],
providers: [CoinService],
})
export class CoinModule {}

Когда приходит транзакция, TransportFabricChaincodeReceiver парсит конверт, проверяет подпись и находит обработчик по имени команды. Если имени нет — команда завершается ошибкой «нет слушателя». Отсюда удобное следствие: добавление новой команды не требует переустановки контракта, достаточно объявить ещё один обработчик под её именем — как именно контракт собирается и запускается, мы разбираем в разделе «Начало работы».