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