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

Первая команда от начала до конца

Быстрый старт показывает скелет контракта, но оставляет главный вопрос открытым — как из этого скелета вырастает настоящая операция. Здесь мы собираем одну команду целиком, слой за слоем, на реальном API фреймворка, а не на выдуманных обёртках. Возьмём простую, но полноценную операцию — добавление пользователя: она проходит через все уровни сразу, от сущности в состоянии до подписи на клиенте, и на ней видно, из чего вообще складывается приложение на hlf-core.

Поток команды в hlf-core один и тот же для любой операции, и держать его в голове полезно с самого начала:

Command and DTOclient creates and signs it, lives in *-commonHandlercontrollers, guard and role checksServiceproviders, business logic and determinismManagerEntityManagerImpl, writes to stateEventstub.dispatch, reaches the client from the block
Command flow: from a signed command on the client to an event from the block.

Пойдём по этой цепочке слева направо, а в конце замкнём её вызовом с клиента.

Шаг 1. Сущность​

Сущность — это то, что ложится в состояние. В hlf-core её не обязательно наследовать от базового класса: достаточно реализовать интерфейс из @hlf-core/common и разметить поля декораторами class-validator, по которым менеджер потом проверит объект при записи и чтении. Пользователь выглядит так:

import { IsEnum, IsOptional, Matches, IsDate } from 'class-validator';
import { Type } from 'class-transformer';
import { IUser, UserUtil, CryptoKey, ICryptoKey } from '@hlf-core/common';

export enum UserStatus {
ACTIVE = 'ACTIVE',
NON_ACTIVE = 'NON_ACTIVE',
}

export enum UserRole {
ADMIN = 'ADMIN',
USER_MANAGER = 'USER_MANAGER',
}

export class User implements IUser<UserStatus, UserRole> {
public static PREFIX = 'user';

@Matches(UserUtil.UID_REG_EXP)
public uid: string;

@Type(() => Date)
@IsDate()
public created: Date;

@IsEnum(UserStatus)
public status: UserStatus;

@IsOptional()
@IsEnum(UserRole, { each: true })
public roles?: Array<UserRole>;

@IsOptional()
@Type(() => CryptoKey)
public cryptoKey?: ICryptoKey;
}

Два поля здесь важны особо. Статический PREFIX задаёт пространство ключей сущности в состоянии — это он потом превращается в префикс составного ключа. А uid это адрес записи, и регулярное выражение UserUtil.UID_REG_EXP следит, чтобы в него не попало ничего постороннего. Сущность живёт в *-common-пакете, потому что одни и те же поля нужны и chaincode при записи, и клиенту при чтении — общий код не даёт их представлениям разойтись. Подробнее про сущности и ключи — в разделах «Сущности» и «Ключ состояния».

Шаг 2. Команда и DTO​

Команда это небольшой класс со статическим NAME и парой DTO — запроса и ответа. Базовый класс — HlfTransportCommandAsync<U, V>, где U это тип запроса, а V — тип ответа (в нашем случае сама сущность User). Запрос прогоняется через TransformUtil.toClass прямо в конструкторе, чтобы внутрь попасть уже типизированным и готовым к валидации:

import { TransformUtil } from '@ts-core/common';
import { Type } from 'class-transformer';
import { ValidateNested, IsDefined, IsOptional, IsEnum } from 'class-validator';
import { HlfTransportCommandAsync, ICryptoKey, CryptoKey, IInitiatedDto, InitiatedDto } from '@hlf-core/common';
import { CommandName } from './Command';
import { User, UserRole } from '../user';

export class UserAddCommand extends HlfTransportCommandAsync<IUserAddDto, User> {
public static readonly NAME = CommandName.USER_ADD;

constructor(request: IUserAddDto) {
super(UserAddCommand.NAME, TransformUtil.toClass(UserAddDto, request));
}

protected checkResponse(item: User): User {
return TransformUtil.toClass(User, item);
}
}

export interface IUserAddDto extends IInitiatedDto {
roles?: Array<UserRole>;
cryptoKey: ICryptoKey;
}

export class UserAddDto extends InitiatedDto implements IUserAddDto {
@IsDefined()
@Type(() => CryptoKey)
@ValidateNested()
public cryptoKey: CryptoKey;

@IsOptional()
@IsEnum(UserRole, { each: true })
public roles?: Array<UserRole>;
}

DTO наследуется от InitiatedDto, и это не формальность — у него есть поле initiatorUid, по которому в событиях и аудите видно, кто инициировал операцию. Имя команды берётся не строкой, а из enum CommandName: так его нельзя опечатать ни в команде, ни в обработчике, которые эту команду связывают. Что едет в конверте команды и как ошибка доходит до клиента с кодом — в разделе «Команды и события».

Шаг 3. Менеджер​

Менеджер прячет под собой всю рутину работы с key-value состоянием — сборку ключа, сериализацию, валидацию. Абстрактный EntityManagerImpl<T> уже умеет get, save, remove, has и постраничные выборки, так что доменному менеджеру в простом случае достаточно сказать две вещи — как превратить сырые данные в класс (toEntity) и в каком пространстве ключей он живёт (prefix):

import { User } from '@project/common/hlf/user';
import { EntityManagerImpl, IStub } from '@hlf-core/chaincode';
import { UID, getUid, ILogger, TransformUtil } from '@ts-core/common';
import { CryptoKey } from '@hlf-core/common';
import * as _ from 'lodash';

export class UserManager extends EntityManagerImpl<User> {
public toEntity(item: any): User {
return TransformUtil.toClass(User, item);
}

public get prefix(): string {
return User.PREFIX;
}
}

Этого достаточно, чтобы читать и писать пользователя по его uid. Когда у сущности появляется тяжёлое или редко нужное поле вроде cryptoKey, которое разумнее держать отдельным ключом и подгружать только по запросу, менеджер расширяют loadDetails, save и remove; этот приём разобран в разделе «Сущности». Для первой команды хватит и короткой версии.

Шаг 4. Сервис​

Бизнес-логику держат в сервисе, а не в обработчике — так её можно переиспользовать и тестировать отдельно от транспорта. Здесь же впервые появляется детерминизм, и это принципиальный момент: пользователь создаётся не от new Date() и не от случайного идентификатора, а от stub.transaction.date и stub.transaction.hash — значений, одинаковых на всех пирах-эндорсерах. Иначе результат разъехался бы между пирами и транзакция не прошла бы валидацию:

public async add(holder: IUserStubHolder, params: IUserAddDto): Promise<User> {
let item = UserUtil.create(User, holder.stub.transaction.date, holder.stub.transaction.hash);
if (await holder.stub.hasState(item.uid)) {
throw new UserAlreadyExistsError(item);
}

item.roles = params.roles;
item.status = UserStatus.ACTIVE;
item.cryptoKey = TransformUtil.toClass(CryptoKey, params.cryptoKey);
await this.getManager(holder).save(item);

await holder.stub.dispatch(new UserAddedEvent({ user: item, initiatorUid: params.initiatorUid }));
return item;
}

private getManager(holder: IUserStubHolder): UserManager {
if (_.isNil(holder.manager)) {
holder.manager = new UserManager(this.logger, holder.stub);
}
return holder.manager;
}

После записи сервис рассылает событие UserAddedEvent через stub.dispatch — оно приедет клиенту при разборе блока, и на него можно подписаться, не опрашивая состояние. Почему время и уникальность берут именно из транзакции и чем грозит про это забыть, разобрано в разделе «Детерминизм».

Шаг 5. Обработчик​

Обработчик — это тонкий слой между транспортом и сервисом. Он наследует TransportCommandAsyncHandler<U, V, T> из @ts-core/common (тот же базовый класс, что и в обычных сервисах @ts-core), привязывается к имени команды в конструкторе и реализует единственный метод execute. Всю проверку подписи и nonce транспорт уже сделал до него — обработчику достаётся разобранная команда и holder, через который он работает с состоянием:

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

@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);
}
}

Здесь два уровня защиты, и они разные по природе. Декоратор @UserGuard() отрабатывает до execute и отвечает на вопрос «кто ты» — грузит пользователя по stub.user.id и сверяет записанный в ledger ключ с ключом подписи, замыкая связку «ключ принадлежит этому пользователю». А rolesCheck(holder, UserRole.USER_MANAGER) внутри execute отвечает на вопрос «можно ли тебе эту операцию» — проверяет право. Параметр holder приходит через декоратор @StubHolder(), и тонкость в том, что holder и команда это один и тот же объект. Разбор guard'а целиком — в разделах «Обработчики команд» и «Безопасность».

Шаг 6. Регистрация в модуле​

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

import { Module } from '@nestjs/common';
import { UserService } from './service';
import { UserAddHandler, UserGetHandler, UserEditHandler } from './handler';

@Module({
controllers: [UserGetHandler, UserAddHandler, UserEditHandler],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

Дальше доменный модуль собирается в корневой модуль chaincode вместе с остальными, а служебные обработчики вроде метаданных и nonce регистрируются в корневых controllers:

imports: [
LoggerModule.forRoot(settings),
TransportFabricChaincodeModule.forRoot(settings),
UserModule,
CoinModule,
// ... остальные доменные модули
],
controllers: [MetadataGetHandler, NonceGetHandler],

Приятное следствие такой схемы — добавление новой команды не требует переустановки контракта. TransportFabricChaincodeReceiver находит обработчик по имени команды в конверте, поэтому достаточно объявить ещё один обработчик под новым именем, и contract начнёт понимать новую операцию.

Шаг 7. Вызов с клиента​

Остаётся замкнуть цепочку с клиентской стороны. Команда создаётся тем же классом, что живёт в *-common, подписывается ключом отправителя и уходит в chaincode тем же единым API, что и в любом сервисе @ts-core — userId и signature едут в опциях команды:

import { Ed25519, IKeyAsymmetric, ISignature, TransportCommand, TransportCryptoManagerEd25519 } from '@ts-core/common';
import { UserAddCommand } from '@project/common/hlf/transport';

const keys: IKeyAsymmetric = Ed25519.from(privateKey);

const command = new UserAddCommand({ cryptoKey: { value: publicKey }, roles: [UserRole.USER_MANAGER] });

const signature: ISignature = await TransportCommand.sign(
command,
new TransportCryptoManagerEd25519(),
keys,
);

const user = await transport.sendListen(command, { userId, signature });

На этом полный путь пройден: команда, подписанная на клиенте, доехала до chaincode, прошла проверку подписи и nonce в транспорте, была авторизована guard'ом и проверкой роли, исполнена сервисом с детерминированным временем, записана менеджером в состояние и отозвалась событием, а клиент получил назад типизированную сущность User.

Куда дальше​

Правила детерминизма, которые мы здесь затронули мимоходом, стоит прочитать отдельно — на них держится то, пройдёт ли транзакция вообще: «Детерминизм». А типовые приёмы поверх этого скелета — свой алгоритм подписи, выпуск токена, перенос операции в batch — собраны в cookbook.