Обработчики команд
Если «Команды и события» описывают, что едет в конверте, то обработчик — это то, что исполняет команду на стороне 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 парсит конверт, проверяет подпись и
находит обработчик по имени команды. Если имени нет — команда завершается ошибкой «нет слушателя».
Отсюда удобное следствие: добавление новой команды не требует переустановки контракта, достаточно
объявить ещё один обработчик под её именем — как именно контракт собирается и запускается, мы
разбираем в разделе «Начало работы».