Перейти к содержанию

Временные ссылки на контент системы

Серверная реализация обработчика контента в проектном решении

Рассмотрим пример создания обработчика контента в рамках проектного решения:

  • Контентом в данном примере будут аватары контрагентов.
  • Ограничим время действия токена одним днем.
  • Как и в случае аватаров пользователей, токен будет привязан к пользователю (UserExclusive).
  • Будем полагать, что файлы аватаров прикрепляются к карточке контрагента с названием “avatar.jpg|jpeg”, и размер такого файла не превышает 20 КиБ.
  • Если файла в карточке нет, возвращаем null (пример можно дополнить, чтобы возвращать некое изображение-плейсхолдер в случае, если аватар контрагента не задан. Это может быть как растровое изображение, так и векторное изображение в формате svg).

Обработка ссылок на контент осуществляется на уровне платформы, в серверной части проектного решения необходимо:

  1. Определить тип для контента (не требует регистрации).
  2. Реализовать интерфейс обработчика IContentHandler, который содержит всю необходимую логику по созданию токена и возврату запрашиваемого контента.
  3. Зарегистрировать обработчик в контейнере 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 - замаскированный токе доступа, который был использован для получения контента.

Пример результата трассировки получения контента аватара по ссылке:

Back to top