Сущности, связи и пагинация
Состояние 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);
На этих же менеджерах построены токены — модель счетов и балансов разобрана в разделе «Токены».