Временные ссылки на контент системы¶
Серверная реализация обработчика контента в проектном решении¶
Рассмотрим пример создания обработчика контента в рамках проектного решения:
- Контентом в данном примере будут аватары контрагентов.
- Ограничим время действия токена одним днем.
- Как и в случае аватаров пользователей, токен будет привязан к пользователю (
UserExclusive
). - Будем полагать, что файлы аватаров прикрепляются к карточке контрагента с названием “avatar.jpg|jpeg”, и размер такого файла не превышает 20 КиБ.
- Если файла в карточке нет, возвращаем
null
(пример можно дополнить, чтобы возвращать некое изображение-плейсхолдер в случае, если аватар контрагента не задан. Это может быть как растровое изображение, так и векторное изображение в формате svg).
Обработка ссылок на контент осуществляется на уровне платформы, в серверной части проектного решения необходимо:
- Определить тип для контента (не требует регистрации).
- Реализовать интерфейс обработчика
IContentHandler
, который содержит всю необходимую логику по созданию токена и возврату запрашиваемого контента. - Зарегистрировать обработчик в контейнере Unity с именем, соответствующим типу контента.
В проекте Tessa.Extensions.Shared
создайте подпапку Content
, в которую добавьте класс AbContentTypes
. Здесь Ab
- это короткий префикс проектного решения, который также будет использован в именах типов далее в этом примере. Для вашего решения укажите любой свой префикс.
namespace Tessa.Extensions.Shared
{
public static class AbContentTypes
{
/// <summary>
/// Тип контента для аватаров контрагентов.
/// </summary>
public const string PartnerAvatar = "partner-avatars";
}
}
В проекте Tessa.Extensions.Server
создайте подпапку Content
, в которую добавьте интерфейс IPartnerAvatarContentTokenProvider
:
using Tessa.Content;
namespace Tessa.Extensions.Server.Content
{
/// <summary>
/// Предоставляет токен для получения контента аватаров контрагентов.
/// </summary>
public interface IPartnerAvatarContentTokenProvider : IUserExclusiveContentTokenProvider { }
}
В данном интерфейсе можно описать дополнительную логику получения токена, которая не описана в IUserExclusiveContentTokenProvider
, который является провайдером токенов типа UserExclusive
.
В этой же подпапке добавьте класс-реализацию этого интерфейса PartnerAvatarContentTokenProvider
:
using System;
using Tessa.Content;
using Tessa.Extensions.Shared.Content;
using Tessa.Platform.Runtime;
using Tessa.Roles;
namespace Tessa.Extensions.Server.Content
{
public sealed class PartnerAvatarContentTokenProvider : UserExclusiveContentTokenProvider, IPartnerAvatarContentTokenProvider
{
#region Constants
/// <summary>
/// Срок действия токена.
/// </summary>
private const int ExpirationDaysCount = 3;
/// <summary>
/// Название скоупа.
/// </summary>
private const string ScopeName = AbContentTypes.PartnerAvatar;
#endregion
#region Constructor
public PartnerAvatarContentTokenProvider(
IUserExclusiveContentTokenRepository tokenRepository,
IContentAccessTokenGenerator tokenGenerator,
IContentTokenSignatureProvider tokenSignatureProvider,
IRoleGetStrategy roleGetStrategy,
ISession session)
: base(
new(ScopeName, TimeSpan.FromDays(ExpirationDaysCount)),
tokenRepository,
tokenGenerator,
tokenSignatureProvider,
roleGetStrategy,
session)
{
}
#endregion
}
}
Этот класс использует логику хранения, кэширования и получения токенов из базового класса UserExclusiveContentTokenProvider
и определяет способ выделения нового токена.
Далее в текущую подпапку добавьте класс-обработчик для контента AbContentTypes.PartnerAvatar
PartnerAvatarContentHandler
:
using LinqToDB;
using System;
using System.Linq;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Tessa.Cards;
using Tessa.Content;
using Tessa.Platform.Data;
using Tessa.Platform.Runtime;
using Tessa.Platform.Storage;
using Tessa.Platform.Validation;
namespace Tessa.Extensions.Server.Content
{
/// <summary>
/// Обработчик контента с типом <see cref="AbContentTypes.PartnerAvatar"/>.
/// </summary>
public sealed class PartnerAvatarContentHandler : IContentHandler
{
#region Constants
private const int MaxImageSizeInBytes = 20 * 1024;
#endregion
#region Fields
private readonly IDbScope dbScope;
private readonly ISession session;
private readonly IPartnerAvatarContentTokenProvider tokenProvider;
private readonly ICardStreamServerRepository cardStreamServerRepository;
private static readonly string[] avatarPatterns = { "avatar.jpg", "avatar.jpeg" };
#endregion
#region Constructor
public PartnerAvatarContentHandler(
IDbScope dbScope,
ISession session,
IPartnerAvatarContentTokenProvider tokenProvider,
ICardStreamServerRepository cardStreamServerRepository)
{
this.dbScope = NotNullOrThrow(dbScope);
this.session = NotNullOrThrow(session);
this.tokenProvider = NotNullOrThrow(tokenProvider);
this.cardStreamServerRepository = NotNullOrThrow(cardStreamServerRepository);
}
#endregion
#region IContentHandler
/// <inheritdoc/>
public bool AllowTokenRequestFromClient => true;
/// <inheritdoc/>
public async Task<ContentResponse> GetContentAsync(ContentRequest request, CancellationToken cancellationToken = default)
{
ThrowIfNull(request);
ThrowIfNullOrEmpty(request.Token);
var validationResultBuilder = new ValidationResultBuilder();
// Пытаемся получить contentID
var contentID = ContentHelper.TryParseGuidContentID(request.ContentID);
if (contentID is null)
{
validationResultBuilder.AddError(this, await LocalizeFormatAsync("$Content_Message_InvalidContentID", cancellationToken));
return new(validationResultBuilder);
}
// Пытаемся получить валидный токен по его идентификатору
var tokenInfo = await this.tokenProvider.TryGetTokenInfoAsync(request.Token, validationResultBuilder, cancellationToken);
if (tokenInfo is null)
{
return new(validationResultBuilder);
}
// Проверяем, что запрашивается аватар существующего контрагента
if (!await this.CheckIfPartnerExistsAsync(contentID.Value, cancellationToken))
{
validationResultBuilder.AddError(this, $"Контрагент с идентификатором {contentID} не существует.", cancellationToken);
return new(validationResultBuilder);
}
// Получаем контент аватара и возвращаем его
var (content, contentValidationResult) = await this.GetContentAsync(contentID.Value, cancellationToken);
validationResultBuilder.Add(contentValidationResult);
return new(validationResultBuilder, content, GetCacheOptions(tokenInfo));
}
/// <inheritdoc/>
public async Task<ContentTokenResponse> GetTokenAsync(ContentTokenRequest request, CancellationToken cancellationToken = default)
{
ThrowIfNull(request);
var forceReissue = request.Info.TryGet<bool?>("ForceReissue") ?? false;
var validationResultBuilder = new ValidationStorageResultBuilder();
var tokenRequest = new UserExclusiveContentTokenRequest
{
UserID = this.session.User.ID,
ForceReissue = forceReissue
};
// Запрашиваем токен для текущего пользователя
var tokenInfo = await this.tokenProvider.GetOrCreateTokenInfoAsync(tokenRequest, validationResultBuilder, cancellationToken);
return new(validationResultBuilder, tokenInfo?.ToAccessToken());
}
#endregion
#region Private methods
private async Task<bool> CheckIfPartnerExistsAsync(Guid partnerID, CancellationToken cancellationToken = default)
{
await using var _ = this.dbScope.Create();
var query = this.dbScope.BuilderFactory
.SelectExists(b => b
.Select().V(null)
.From("Partners").NoLock()
.Where().C("ID").Equals().P("PartnerID")
).Build();
return await this.dbScope.Db
.SetCommand(query, this.dbScope.Db.Parameter("PartnerID", partnerID, DataType.Guid))
.LogCommand()
.ExecuteAsync<bool>(cancellationToken);
}
private async Task<(ContentResult? Result, ValidationResult ValidationResult)> GetContentAsync(
Guid partnerID,
CancellationToken cancellationToken = default)
{
var fileRequest = await this.GetSourceFileContentRequestAsync(partnerID, cancellationToken);
if (fileRequest is null)
{
return (null, ValidationResult.Empty);
}
var fileContentResult = await this.cardStreamServerRepository.GetFileContentAsync(fileRequest, cancellationToken);
var validationResult = fileContentResult.Response.ValidationResult.Build();
if (!validationResult.IsSuccessful
|| !fileContentResult.HasContent)
{
return (null, validationResult);
}
var stream = await fileContentResult.GetContentOrThrowAsync(cancellationToken);
var contentResult = new ContentResult(stream, fileRequest.FileName!, MediaTypeNames.Image.Jpeg);
return (contentResult, ValidationResult.Empty);
}
private async Task<CardGetFileContentRequest?> GetSourceFileContentRequestAsync(
Guid partnerID,
CancellationToken cancellationToken = default)
{
await using var _ = this.dbScope.Create();
var query = this.dbScope.BuilderFactory
.Select().Top(1)
.C("f", "ID").As(nameof(CardGetFileContentRequest.CardID))
.C("f", "RowID").As(nameof(CardGetFileContentRequest.FileID))
.C("f", "VersionRowID").As(nameof(CardGetFileContentRequest.VersionRowID))
.C("f", "Name").As(nameof(CardGetFileContentRequest.FileName))
.C("f", "TypeID").As(nameof(CardGetFileContentRequest.FileTypeID))
.C("f", "TypeCaption").As(nameof(CardGetFileContentRequest.FileTypeName))
.From("Files", "f").NoLock()
.InnerJoin("FileVersions", "fv").NoLock()
.On().C("fv", "RowID").Equals().C("f", "VersionRowID")
.Where().C("f", "ID").Equals().P("PartnerID")
.And().LowerC("f", "Name").In(avatarPatterns.AsEnumerable())
.And().C("fv", "Size").Less().P("MaxSize")
.Limit(1)
.Build();
return await this.dbScope.Db
.SetCommand(query,
this.dbScope.Db.Parameter("PartnerID", partnerID, DataType.Guid),
this.dbScope.Db.Parameter("MaxSize", MaxImageSizeInBytes, DataType.Int32))
.LogCommand()
.ExecuteAsync<CardGetFileContentRequest>(cancellationToken);
}
private static ContentCacheOptions GetCacheOptions(IContentTokenInfo token)
{
var maxAge = (int) ((token.Expires - DateTime.UtcNow).TotalSeconds); // кешируем на время остатка жизни токена
return new()
{
MaxAge = maxAge > 0 ? maxAge : 0
};
}
#endregion
}
}
Класс регистратора Registrator
добавьте в ту же подпапку:
using Tessa.Content;
using Tessa.Extensions.Shared;
using Unity;
namespace Tessa.Extensions.Server.Content
{
[Registrator]
public sealed class Registrator : RegistratorBase
{
public override void RegisterUnity() => this.UnityContainer
.RegisterSingleton<IPartnerAvatarContentTokenProvider, PartnerAvatarContentTokenProvider>()
.RegisterSingleton<IContentHandler, PartnerAvatarContentHandler>(AbContentTypes.PartnerAvatar);
}
}
Important
Регистрация обработчика обязательно должна быть именованной, где имя соответствует уникальному названию типа контента.
-
Для часто запрашиваемого контента можно добавить кэширование в памяти процесса (для кэширования можно использовать
ICardCache.Settings
). -
В текущем примере используется наследник класса
UserExclusiveContentTokenProvider
, который обеспечивает управление токенами типаUserExclusive
и их кэширование в памяти. ЕслиUserExclusiveContentTokenProvider
не используется, но нужно дополнительно обеспечить кэширование токенов в памяти, используйтеContentTokenInMemoryCache
. (подробнее в разделе Интерфейсы для доступа к общей функциональности)
Так как доступ к аватарам контрагентов может быть частым, токен можно передать на клиент при инициализации, не дожидаясь, пока клиент сам запросит токен.
В проекте Tessa.Extensions.Server
создайте подпапку Initialization
, в которую добавьте расширение PartnerAvatarContentTokenServerInitializationExtension
:
using NLog;
using System.Threading.Tasks;
using Tessa.Content;
using Tessa.Extensions.Server.Content;
using Tessa.Platform;
using Tessa.Platform.Initialization;
using Tessa.Platform.Runtime;
using Tessa.Platform.Storage;
using Tessa.Platform.Validation;
namespace Tessa.Extensions.Server.Initialization
{
public sealed class PartnerAvatarContentTokenServerInitializationExtension : ServerInitializationExtension
{
#region Fields
private readonly IPartnerAvatarContentTokenProvider contentTokenProvider;
private readonly ISession session;
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
#endregion
#region Constructor
public PartnerAvatarContentTokenServerInitializationExtension(
IPartnerAvatarContentTokenProvider contentTokenProvider,
ISession session)
{
this.contentTokenProvider = NotNullOrThrow(contentTokenProvider);
this.session = NotNullOrThrow(session);
}
#endregion
#region Overrides
public override async Task AfterRequest(IServerInitializationExtensionContext context)
{
if (!context.RequestIsSuccessful)
{
return;
}
var tokenRequest = new UserExclusiveContentTokenRequest
{
UserID = this.session.User.ID
};
var validationResult = new ValidationResultBuilder();
var token = await this.contentTokenProvider.GetOrCreateTokenInfoAsync(tokenRequest, validationResult, context.CancellationToken);
logger.LogResult(validationResult);
context.Response!.Info["PartnerAvatarContentToken"] = token?.ToAccessToken().ToSerializedDictionary();
}
#endregion
}
}
Класс регистратора Registrator
добавьте в ту же подпапку:
using Tessa.Platform.Initialization;
using Tessa.Platform.Runtime;
using Unity;
namespace Tessa.Extensions.Server.Initialization
{
[Registrator]
public sealed class Registrator : RegistratorBase
{
public override void RegisterUnity()
{
this.UnityContainer
.RegisterSingleton<PartnerAvatarContentTokenServerInitializationExtension>();
}
public override void RegisterExtensions(IExtensionContainer extensionContainer)
{
extensionContainer
.RegisterExtension<IServerInitializationExtension, PartnerAvatarContentTokenServerInitializationExtension>(x => x
.WithOrder(ExtensionStage.AfterPlatform, 1)
.WithUnity(this.UnityContainer)
.WhenApplications(ApplicationIdentifiers.WebClient));
}
}
}
Расширения web-клиента для использования ссылок на контент¶
Реализуем клиентскую логику для отображения аватаров контрагентов.
В папке проектных расширений web-клиента solution
(в сборке она расположена в WebClient SDK/src/solution
) создайте подпапку content
. Добавьте в неё файл abContentTypes.ts
с константой PartnerAvatar
, аналогичной реализации в C# AbContentTypes.cs
:
/**
* @helper
*/
export namespace AbContentTypes {
export const partnerAvatar = 'partner-avatars';
}
В эту же подпапку добавьте файл contentTypes.ts
, который будет содержать описание типов:
import { ValidationResult } from '@tessa/core';
import { IUserExclusiveContentTokenManager } from '@tessa/platform';
/** Manages partner avatar tokens operations. */
export interface IPartnerAvatarTokenManager extends IUserExclusiveContentTokenManager {}
/** Manages common avatar operations. */
export interface IPartnerAvatarManager {
/**
* Returns partner avatar link for partner {@link partnerId}
* @param partnerId Partner identifier.
*/
getAvatarLink(partnerId: string): Promise<[string | null, ValidationResult]>;
}
Менеджер токенов IPartnerAvatarTokenManager
обеспечивает хранение пользовательского токена, кроме этого он запрашивает новый токен, когда срок действия текущего подходит к концу.
Менеджер аватаров IPartnerAvatarManager
формирует ссылку на аватар контрагента, используя токен из IPartnerAvatarTokenManager
.
Функциональность обоих менеджеров можно расширить при необходимости.
Сначала реализуем менеджер токенов, для этого добавьте файл partnerAvatarTokenManager.ts
:
import { inject, injectable } from '@tessa/application';
import {
IContentService,
IContentService$,
UserExclusiveContentTokenManager
} from '@tessa/platform';
import { AbContentTypes } from './abContentTypes';
import { IPartnerAvatarTokenManager } from './contentTypes';
@injectable()
export class PartnerAvatarTokenManager
extends UserExclusiveContentTokenManager
implements IPartnerAvatarTokenManager
{
//#region constructor
constructor(@inject(IContentService$) contentService: IContentService) {
super(contentService, AbContentTypes.partnerAvatar);
}
//#endregion
}
После этого добавьте файл самого менеджера partnerAvatarManager.ts
:
import { binary_to_base58 } from 'base58-js';
import { IApiClient, IApiClient$, inject, injectable } from '@tessa/application';
import { ValidationResult } from '@tessa/core';
import { AbContentTypes } from './abContentTypes';
import { IPartnerAvatarTokenManager$ } from './contentInjects';
import { IPartnerAvatarManager, IPartnerAvatarTokenManager } from './contentTypes';
@injectable()
export class PartnerAvatarManager implements IPartnerAvatarManager {
//#region ctor
constructor(
@inject(IPartnerAvatarTokenManager$)
private readonly _tokenManager: IPartnerAvatarTokenManager,
@inject(IApiClient$) private readonly _apiClient: IApiClient
) {}
//#endregion
//#region IPartnerAvatarManager
async getAvatarLink(partnerId: string): Promise<[string | null, ValidationResult]> {
const [tokenInfo, validationResult] = await this._tokenManager.getTokenInfo();
if (!tokenInfo) {
return [null, validationResult];
}
const enc = new TextEncoder();
const contentId = binary_to_base58(enc.encode(partnerId));
const url = await this._apiClient.getUrl(
`/content/${AbContentTypes.partnerAvatar}/${contentId}`,
{
searchParams: {
token: tokenInfo.token
}
}
);
return [url, ValidationResult.empty];
}
//#endregion
}
Оба менеджера должны быть зарегистрированы в DI-контейнере, для этого сначала добавьте файл contentInjects.ts
:
import { createInjectToken } from '@tessa/application';
import { IPartnerAvatarManager, IPartnerAvatarTokenManager } from './contentTypes';
/** @category injects */
export const IPartnerAvatarTokenManager$ = createInjectToken<IPartnerAvatarTokenManager>(
'IPartnerAvatarTokenManager'
);
/** @category injects */
export const IPartnerAvatarManager$ =
createInjectToken<IPartnerAvatarManager>('IPartnerAvatarManager');
А после регистратор abContentRegistrator.ts
:
import { ExtensionRegistrator } from '@tessa/application';
import { IPartnerAvatarManager$, IPartnerAvatarTokenManager$ } from './contentInjects';
import { PartnerAvatarManager } from './partnerAvatarManager';
import { PartnerAvatarTokenManager } from './partnerAvatarTokenManager';
export const AbContentRegistrator: ExtensionRegistrator = {
async registerTypes(container) {
container.bind(IPartnerAvatarManager$).to(PartnerAvatarManager).inSingletonScope();
container.bind(IPartnerAvatarTokenManager$).to(PartnerAvatarTokenManager).inSingletonScope();
}
};
Important
Все регистраторы нужно добавлять в bundleRegistrator.ts
.
Для удобства получения ссылки на аватар добавим хелперный метод. Создайте файл abContentLinks.ts
в текущей подпапке content
:
import { ValidationResult } from '@tessa/core';
import { IPartnerAvatarManager$ } from './contentInjects';
/**
* @helper
*/
export namespace AbContentLinks {
/**
* Returns partner avatar link for partner with identifier {@link partnerId}.
* @param userId User identifier.
*/
export async function getPartnerAvatarLink(
partnerId: string
): Promise<[string | null, ValidationResult]> {
const manager = window.tessa.diContainer.get(IPartnerAvatarManager$);
return await manager.getAvatarLink(partnerId);
}
}
Так как токен передается при инициализации с сервера, реализуем расширение для его получения и установки на клиенте.
В папке проектных расширений web-клиента solution
создайте подпапку initialization
и добавьте файл partnerAvatarContentTokenInitializationExtension.ts
:
import {
ContentAccessTokenInfo,
IInitializationExtensionContext,
InitializationExtension
} from '@tessa/platform';
import { extension, inject } from '@tessa/application';
import { StorageAccessor } from '@tessa/core';
import { IPartnerAvatarTokenManager$ } from '../content/contentInjects';
import { IPartnerAvatarTokenManager } from '../content/contentTypes';
@extension({ name: 'PartnerAvatarContentTokenInitializationExtension' })
export class PartnerAvatarContentTokenInitializationExtension extends InitializationExtension {
//#region constructors
constructor(
@inject(IPartnerAvatarTokenManager$) private readonly _tokenManager: IPartnerAvatarTokenManager
) {
super();
}
//#endregion
//#region base overrides
public override async afterRequest(context: IInitializationExtensionContext): Promise<void> {
const info = context.response?.tryGetInfo();
if (!info) {
return;
}
const storageAccessor = new StorageAccessor(info);
const tokenInfo = storageAccessor.tryGetObject('PartnerAvatarContentToken', storage =>
new ContentAccessTokenInfo().deserializeFromStorage(storage)
);
if (tokenInfo) {
await this._tokenManager.setTokenInfo(tokenInfo);
}
}
//#endregion
}
Таким образом, при первом запросе ссылки на аватар контрагента токен уже будет храниться на клиенте, и его можно сразу использовать.
Добавьте файл регистратора abInitializationRegistrator.ts
в эту же подпапку:
import { ExtensionRegistrator, ExtensionStage } from '@tessa/application';
import { PartnerAvatarContentTokenInitializationExtension } from './partnerAvatarContentTokenInitializationExtension';
export const AbInitializationRegistrator: ExtensionRegistrator = {
async registerTypes() {},
async registerExtensions(container) {
container.registerExtension({
extension: PartnerAvatarContentTokenInitializationExtension,
stage: ExtensionStage.AfterPlatform,
singleton: true
});
}
};
Добавьте регистратор в bundleRegistrator.ts
.
Для демонстрации применения ссылок на аватары контрагентов создадим расширение на узел рабочего места, которое будет добавлять в ячейку представления “Контрагенты” аватар контрагента, указанного в строке представления.
Для этого в папке проектных расширений web-клиента solution
создайте подпапку views
и добавьте файл partnerAvatarViewExtension.ts
:
import { WorkplaceViewComponentExtension } from 'tessa/ui/views/extensions';
import { IWorkplaceViewComponent, StandardViewComponentContentItemFactory } from 'tessa/ui/views';
import { TableGridViewModelBase } from 'tessa/ui/views/content';
import { extension } from '@tessa/application';
import React, { FC, useEffect, useState } from 'react';
import { AbContentLinks } from '../content/abContentLinks';
@extension({ name: 'PartnerAvatarViewExtension' })
export class PartnerAvatarViewExtension extends WorkplaceViewComponentExtension {
public getExtensionName(): string {
return 'PartnerAvatarViewExtension';
}
// расширение выполняется только для представления "Контрагенты"
public shouldExecute(model: IWorkplaceViewComponent): boolean {
return model.getViewMetadata(model)?.alias === 'Partners';
}
public initialize(model: IWorkplaceViewComponent): void {
const tableFactory = model.contentFactories.get(StandardViewComponentContentItemFactory.Table);
if (!tableFactory) {
return;
}
model.contentFactories.set(StandardViewComponentContentItemFactory.Table, c => {
const vm = tableFactory(c);
if (vm instanceof TableGridViewModelBase) {
// переопределим метод создания ячейки представления
const initAction = vm.createCellAction;
vm.createCellAction = options => {
const cell = initAction(options);
// добавляем аватар в ячейку "Краткое наименование"
if (
options.column.columnName === 'PartnerName' &&
(!options.value || typeof options.value === 'string')
) {
// получаем идентификатор контрагента из соответствующей колонки представления
const partnerId = options.row.data.get('PartnerID');
if (partnerId && typeof partnerId === 'string') {
// переопределяем метод получения контента ячейки на кастомный компонент
cell.getContent = () => (
<PartnerAvatar partnerId={partnerId} partnerName={options.value} />
);
}
}
return cell;
};
}
return vm;
});
}
}
// Компонент для отображения аватара и наименования контрагента
const PartnerAvatar: FC<{ partnerId: string; partnerName: string }> = ({
partnerId,
partnerName
}) => {
const [partnerLink, setPartnerLink] = useState<string | null>(null);
// асинхронно получаем ссылку на аватар
useEffect(() => {
AbContentLinks.getPartnerAvatarLink(partnerId).then(([link]) => {
setPartnerLink(link);
});
}, [partnerId]);
if (!partnerLink) {
return <span>{partnerName}</span>;
}
return (
<div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
<img src={partnerLink} width="44" height="44" style={{ borderRadius: '100%' }} />
<span>{partnerName}</span>
</div>
);
};
После этого добавьте в эту же подпапку регистратор abViewsRegistrator.ts
:
import { ExtensionRegistrator, ExtensionStage } from '@tessa/application';
import { PartnerAvatarViewExtension } from './partnerAvatarViewExtension';
export const AbViewsRegistrator: ExtensionRegistrator = {
async registerTypes() {},
async registerExtensions(container) {
container.registerExtension({
extension: PartnerAvatarViewExtension,
stage: ExtensionStage.AfterPlatform,
singleton: true
});
}
};
Добавьте регистратор в bundleRegistrator.ts
.
После компиляции WebClient SDK
и публикации клиентских расширений на сервере можно убедиться, что аватар контрагента доступен по ссылке и может быть отображен в web-клиенте. Кроме этого, ссылку можно скопировать и использовать в любом другом месте, контент будет доступен, пока не истек срок действия токена.
Интерфейсы для доступа к общей функциональности¶
В платформе определен ряд интерфейсов, которые можно использовать при реализации обработчиков контента в проектном решении, получив их из контейнера Unity:
IContentAccessTokenGenerator
- генерирует уникальные токены доступа.IContentTokenSignatureProvider
- предоставляет методы для подписания и проверки подписи для данных токена.IContentTokenRepository
- репозиторий для управления токенами доступа. Предоставляет методы для получения, сохранения и удаления токенов. В платформе для него зарегистрированы две именованные реализации для управленияplain
иprotected
токенами -nameof(ContentTokenType.Plain)
иnameof(ContentTokenType.Protected)
соответственно.IContentTokenProvider
- провайдер токенов доступа, надстройка надIContentTokenRepository
. Предоставляет методы для создания, получения и удаления токенов. В платформе для него зарегистрированы две именованные реализации для управленияplain
иprotected
токенами -nameof(ContentTokenType.Plain)
иnameof(ContentTokenType.Protected)
соответственно.IUserExclusiveContentTokenRepository
- репозиторий для управленияplain
токенами доступа типаUserExclusive
. Обеспечивает эксклюзивность токена для пользователя в рамках определенного скоупа.IUserExclusiveContentTokenProvider
- провайдер токенов доступа типаUserExclusive
, его базовая реализацияUserExclusiveContentTokenProvider
обеспечивает кэширование токенов в памяти. Зависит отIUserExclusiveContentTokenRepository
и является его надстройкой.ContentTokenInMemoryCache
- потокобезопасный кэш токенов доступа в памяти процесса.
Кэширование возвращаемого контента браузером¶
Контент, который используется на клиенте и часто им запрашивается (например, аватары пользователей), целесообразно кэшировать на стороне браузера, чтобы снизить нагрузку на сервер и обеспечить максимально быстрое отображение для пользователя.
Для этого при возврате контента в обработчике IContentHandler
в параметре CacheOptions
можно указать количество секунд, в течение которых контент может быть получен из кэша браузера. Если этот параметр указан и отличен от 0, в заголовки ответа будет добавлен заголовок Cache-Control
с указанным значением. При этом стоит учитывать следующее:
- Кэширование происходит по ссылке. Пока ссылка на контент остается неизменной, контент может быть получен из кэша.
- Единственным изменяемым параметром в ссылке на контент, который уникально определяется скоупом и идентификатором контента, является токен.
- На период действия, указанный в
CacheOptions
, контент будет браться из кэша, даже несмотря на то, что сам контент на сервере может быть изменен, так как ссылка на контент изменяется только с изменением токена. - Если необходимо, чтобы клиент получил обновленный контент сразу, то нужно принудительно сбросить текущий токен и создать новый (в примере выше при запросе токена для этого использовался параметр
ForceReissue
), после чего переформировать ссылку с новым токеном. - Стоит учитывать, что если токен является
UserExclusive
, то он указывается в ссылках на все ресурсы в рамках скоупа, соответственно, при изменении токена обновятся все ссылки для этого скоупа, и браузер будет вынужден запросить контент по всем ссылкам заново. - Выбор срока действия токена для кэшируемого контента является компромиссом между производительностью и сроком получения обновленного контента.
- Ссылка на контент формируется вручную. Если ссылка формируется на клиенте, то необходимо отслеживать срок действия токена и получать новый токен по истечении срока действия текущего.
- Если контент закэширован браузером, то он может быть получен пользователем, даже если указанный в ссылке токен был сброшен.
Мониторинг запросов получения контента по ссылке¶
Для запросов получения контента по ссылке в платформе реализованы счетчики производительности и источник данных для трассировки (подробнее в разделе Веб-сервис Monitor для диагностики и трассировки).
Список счетчиков производительности:
total-content-by-link-requests
- количество запросов на получение контента по ссылке.total-content-by-link-requests-duration
- суммарное время выполнения запросов на получение контента по ссылке в миллисекундах.current-content-by-link-requests
- количество запросов на получение контента по ссылке в данный момент.
Все счетчики имеют параметр ContentType
, в котором указан тип запрашиваемого контента.
Чтобы отследить среднее время выполнения запросов за некоторый период (например, 5 минут), можно использовать следующее выражение:
rate(total-content-by-link-requests-duration[5m]) / rate(total-content-by-link-requests[5m])
.
Для трассировки запросов доступен источник трассировки GetContentByLink
, который предоставляет специфичные для контента тэги:
ContentType
- тип запрашиваемого контента.Token
- замаскированный токе доступа, который был использован для получения контента.
Пример результата трассировки получения контента аватара по ссылке: