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

Сущности, связи и пагинация

Состояние Fabric это key-value хранилище, и работать с ним напрямую — значит каждый раз вручную собирать ключ, сериализовать объект, валидировать его при чтении и помнить, какие записи связаны с какими. @hlf-core/chaincode закрывает эту рутину доменными менеджерами: мы перестаём думать ключами и начинаем думать сущностями, а StateKey и сериализация остаются под капотом.

EntityManager: CRUD поверх состояния​

Иерархия менеджеров в два слоя. Абстрактный EntityManager<T> даёт get, save, remove, has, find и findPaginated. Его реализация EntityManagerImpl<T> добавляет сериализацию с валидацией: перед записью сущность прогоняется через ValidateUtil.validate и TransformUtil.fromClass, при чтении — восстанавливается через toEntity, снова валидируется и дополняется через loadDetails. В типичном случае доменному менеджеру достаточно реализовать два метода — toEntity, который превращает сырые данные в класс, и prefix, задающий пространство ключей:

import { EntityManagerImpl } from '@hlf-core/chaincode';
import { TransformUtil } from '@ts-core/common';
import { User } from '@project/common/hlf/user';

export class UserManager extends EntityManagerImpl<User> {
public toEntity(item: any): User {
return TransformUtil.toClass(User, item);
}
public get prefix(): string {
return User.PREFIX; // 'user'
}
}

Ключом записи служит uid сущности, так что get, save и has работают прямо по объекту:

await manager.save(user);
const item = await manager.get(user.uid);
const exists = await manager.has(user.uid);

Вынос полей в отдельные ключи и ленивая загрузка​

Не всё, что принадлежит сущности, разумно хранить внутри её записи. Крупное или редко нужное поле лучше вынести в собственный ключ состояния, а подгружать его только по явному запросу — через loadDetails и параметр details. Канонический пример — cryptoKey пользователя: в самой записи user его нет, он живёт отдельным ключом и подтягивается, только когда мы перечислили его в details:

export class UserManager extends EntityManagerImpl<User> {
protected getCryptoKeyUid(user: UID): string {
return `→${this.prefix}~cryptoKey:${getUid(user)}`;
}

protected async serialize(item: User): Promise<any> {
delete item.cryptoKey; // не хранить ключ внутри записи user
return super.serialize(item);
}

public async loadDetails(item: User, details?: Array<keyof User>): Promise<void> {
await super.loadDetails(item, details);
if (_.isEmpty(details)) {
return;
}
let { cryptoKey } = item;
if (details.includes('cryptoKey') && _.isNil(cryptoKey)) {
item.cryptoKey = await this.stub.getState(this.getCryptoKeyUid(item), CryptoKey);
}
}

public async save(item: User): Promise<User> {
let { cryptoKey } = item;
if (!_.isNil(cryptoKey)) {
await this.stub.putState(this.getCryptoKeyUid(item), cryptoKey, { isValidate: true, isTransform: true, isSortKeys: true });
}
return super.save(item);
}

public async remove(item: UID): Promise<void> {
await this.stub.removeState(this.getCryptoKeyUid(item));
await super.remove(item);
}

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

// cryptoKey подтянется только здесь:
const user = await manager.get(uid, ['cryptoKey']);

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

Связи между сущностями​

В key-value хранилище нет внешних ключей, поэтому связь «многие ко многим» приходится вести самостоятельно — отдельными записями-индексами. Этим занимается EntityRelation: на каждую связь он пишет два composite-ключа, прямой и обратный, и это не избыточность, а необходимость — прямой ключ позволяет перечислить детей по родителю, обратный снять связь, зная только ребёнка. Менеджер, которому нужен вторичный индекс, заводит EntityRelation в конструкторе и вызывает его из save и remove:

import { EntityManagerImpl, EntityRelation, IEntityQuery, IStub } from '@hlf-core/chaincode';

export class DocumentManager extends EntityManagerImpl<Document> {
protected relation: EntityRelation;

constructor(logger: ILogger, stub: IStub) {
super(logger, stub);
this.relation = new EntityRelation(stub, Company.PREFIX, Document.PREFIX); // parent, child
}

protected getListQuery(company: UID, owner?: UID): IEntityQuery<Document> {
return this.relation.childListQuery(..._.compact([company, owner]));
}

public async save(item: Document): Promise<Document> {
await super.save(item);
await this.relation.childAdd(item, item.companyUid, item.ownerUid);
return item;
}

public async remove(item: UID): Promise<void> {
await this.relation.childRemove(item);
await super.remove(item);
}

public findByCompany(company: UID, owner?: UID): Promise<Array<Document>> {
return this.find(this.getListQuery(company, owner));
}
}

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

Пагинация​

Перечислять большие наборы целиком нельзя: и по стоимости, и по лимитам Fabric. Поэтому выборки по префиксу делаются постранично: findPaginated принимает запрос с размером страницы и закладкой, а возвращает IPaginationBookmark<T> с самими элементами и закладкой на следующую страницу. Клиент гонит запросы, пока не увидит isAllLoaded:

let bookmark = '';
do {
const page = await manager.findPaginated({ pageSize: 50, pageBookmark: bookmark, prefix });
handle(page.items);
bookmark = page.pageBookmark;
if (page.isAllLoaded) {
break;
}
} while (bookmark);

На этих же менеджерах построены токены — модель счетов и балансов разобрана в разделе «Токены».