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

Создание хранилища файлов с собственными правилами размещения контента

Создание хранилища файлов с собственными правилами размещения контента

Для интеграции с файловыми сервисами, предоставляющими собственные средства размещения файлов (например, в виде REST-сервисов), используйте свою реализацию интерфейса ICardContentStrategy. В этом примере будет показано, как создать и задействовать такую реализацию.

Для контента файла помимо идентификатора хранилища важны следующие атрибуты:

  • VersionRowID - уникальный идентификатор версии файла. Его достаточно для хранения любого количества файлов, а остальные атрибуты полезны для группировки по подпапкам, но их необязательно использовать для определения способа хранения контента.

  • FileID - уникальный идентификатор файла, к одной из версий которого относится контент. Каждый раз при редактировании файла создаётся версия, и содержимое всех версии хранится, причём, в зависимости от бизнес-логики, версии одного файла могут размещаться на разных хранилищах. Например, если небольшие файлы должны хранится на одном хранилище, а большие файлы - на другом, то при редактировании файла пороговый размер может быть превышен, в этом случае предыдущие версии будут на одном хранилище, а новая большая версия - на втором. За выбором хранилища и определением соответствия версии файла с хранилищем следит система, на вход ICardContentStrategy приходит информация по выбранному хранилищу.

  • CardID - уникальный идентификатор карточки. В этой карточке для одного из файлов есть версия файла, с контентом которой надо выполнить действие, поскольку контент относится к соответствующей реализации ICardContentStrategy.

Для примера определим, что требуется разработать файловое хранилище, которое содержит файлы по путям CardID\VersionRowID, т.е. с именами, равными идентификаторам версий файлов VersionRowID, которые сгруппированы по папкам с идентификатором карточки CardID, при этом отдельная подпапка для каждого файла FileID отсутствует.

В сборке расширений Tessa.Extensions.Server создадим папку Files, в которую добавим класс с реализацией ICardContentStrategy. В следующем коде есть комментарии по назначению каждого метода.

using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Tessa.Cards; using Tessa.Cards.ComponentModel; using Tessa.Platform.Data; using Tessa.Platform.IO; using Tessa.Platform.Validation;

namespace Tessa.Extensions.Server.Files { /// <summary> /// Будем хранить файлы в виде структуры папок CardID/VersionRowID (минуя подпапку для FileID) /// </summary> public sealed class CustomContentStrategy : ICardContentStrategy { private readonly IDbScope dbScope;

private readonly string fileBasePath;

/// <summary> /// В конструкторе принимается объект <see cref="ICardFileSource"/> с настройками хранилища /// из карточки "Настройки сервера", а также любые другие зависимости из Unity, /// такие как <paramref name="dbScope"/> для выполнения запросов к БД. /// </summary> /// <param name="dbScope">Объект для выполнения запросов к базам данных.</param> /// <param name="settings">Объект с настройками из карточки "настройки сервера".</param> public CustomContentStrategy(IDbScope dbScope, ICardFileSource settings) { this.dbScope = dbScope; this.fileBasePath = settings.Path; }

private string GetCardFolderPath(Guid cardID) { // C:\BaseFolder\cardID string cardFolder = cardID.ToString("D"); return Path.Combine(this.fileBasePath, cardFolder); }

private static string GetContentPath(Guid versionRowID, string cardFolderPath) { // C:\BaseFolder\cardID => C:\BaseFolder\cardID\versionRowID return Path.Combine(cardFolderPath, versionRowID.ToString("D")); }

private string GetContentPath(CardContentContext context) { // C:\BaseFolder\cardID\versionRowID string cardFolder = context.CardID.ToString("D"); string versionFileName = context.VersionRowID.ToString("D"); return Path.Combine(this.fileBasePath, cardFolder, versionFileName); }

/// <summary> /// Возвращает поток с содержимым версии файла, который обычно загружается на клиент или же используется на сервере. /// /// К возвращаемому потоку нет отдельных требований, т.е. он может не возвращать свой размер в свойстве /// <c>stream.Length</c> и не поддерживать перемещение на позицию в потоке. /// /// Система будет только читать этот поток до тех пор, пока он не завершится. /// Точный размер такого потока в байтах должен вернуть метод <see cref="GetSizeAsync"/>. /// </summary> /// <param name="context">Местоположение контента, включая идентификаторы карточки, файла и версии.</param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns>Поток с содержимым файла.</returns> public async ValueTask<Stream> GetAsync( CardContentContext context, CancellationToken cancellationToken = default) { string filePath = this.GetContentPath(context); if (!File.Exists(filePath)) { ValidationSequence .Begin(context.ValidationResult) .SetObjectName(this) .Error( CardValidationKeys.FileContentNotFound, context.CardID, context.FileID, context.VersionRowID) .End();

return null; }

return FileHelper.OpenRead(filePath, bufferSize: FileHelper.DefaultCopyBufferSize); }

/// <summary> /// Возвращает точный размер в байтах версии файла в хранилище. Используется при загрузке содержимого файла, /// чтобы система могла указать, что следующие N байт возвращаемого потока занимает содержимое файла. /// </summary> /// <param name="context">Местоположение контента, включая идентификаторы карточки, файла и версии.</param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns>Размер версии файла в байтах.</returns> public async ValueTask<long> GetSizeAsync( CardContentContext context, CancellationToken cancellationToken = default) { string filePath = this.GetContentPath(context);

var fileInfo = new FileInfo(filePath); return fileInfo.Length; }

/// <summary> /// Сохраняет версию файла в хранилище. /// </summary> /// <param name="context">Местоположение контента, включая идентификаторы карточки, файла и версии.</param> /// <param name="contentStream">Содержимое версии файла, которое требуется записать в хранилище.</param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns>Асинхронная задача.</returns> public async Task StoreAsync( CardContentContext context, Stream contentStream, CancellationToken cancellationToken = default) { string fileFolderPath = this.GetCardFolderPath(context.CardID); FileHelper.CreateDirectoryIfNotExists(fileFolderPath);

string filePath = GetContentPath(context.VersionRowID, fileFolderPath); await using FileStream fileStream = FileHelper.Create(filePath, bufferSize: FileHelper.DefaultCopyBufferSize); await contentStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); }

/// <summary> /// Удаляет версию файла из хранилища. /// </summary> /// <param name="context">Местоположение контента, включая идентификаторы карточки, файла и версии.</param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns>Асинхронная задача.</returns> public async Task DeleteAsync( CardContentContext context, CancellationToken cancellationToken = default) { string filePath = this.GetContentPath(context);

// файл может не существовать, если при передаче файла на сервер возникла ошибка и файл не был передан // тогда при удалении файла не должно быть ошибок if (File.Exists(filePath)) { File.Delete(filePath); } }

/// <summary> /// Копирует файл из одной карточки в другую в пределах хранилища. Метод не вызывается, /// если хранилище <see cref="CardContentContext.Source"/> разное для разных файлов, /// или же оно не соответствует текущему классу. /// /// Метод используется при создании шаблона карточки или при создании карточки по шаблону. /// /// Копирование можно выполнить просто комбинацией вызовов Get() и Store(), но если для хранилища существует /// более быстрый способ, такой как File.Copy(), то его надо использовать. /// </summary> /// <param name="sourceContext"> /// Местоположение исходного контента, включая идентификаторы карточки, файла и версии. /// </param> /// <param name="targetContext"> /// Местоположение целевого контента, включая идентификаторы карточки, файла и версии. /// </param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns><c>true</c>, если файл успешно скопирован; <c>false</c>, если добавлена ошибка.</returns> public async Task<bool> CopyAsync( CardContentContext sourceContext, CardContentContext targetContext, CancellationToken cancellationToken = default) { string targetFolderPath = this.GetCardFolderPath(targetContext.CardID); string targetFilePath = GetContentPath(targetContext.VersionRowID, targetFolderPath);

if (File.Exists(targetFilePath)) { ValidationSequence .Begin(targetContext.ValidationResult) .SetObjectName(this) .Error( CardValidationKeys.FileTargetContentExists, targetContext.CardID, targetContext.FileID, targetContext.VersionRowID) .End();

return false; }

string sourceFilePath = this.GetContentPath(sourceContext); if (!File.Exists(sourceFilePath)) { ValidationSequence .Begin(targetContext.ValidationResult) .SetObjectName(this) .Error( CardValidationKeys.FileContentNotFound, sourceContext.CardID, sourceContext.FileID, sourceContext.VersionRowID) .End();

return false; }

FileHelper.CreateDirectoryIfNotExists(targetFolderPath); await FileHelper.CopyAsync(sourceFilePath, targetFilePath, cancellationToken).ConfigureAwait(false); return true; }

/// <summary> /// Перемещает файл из одной карточки в другую в пределах хранилища. Метод не вызывается, /// если хранилище <see cref="CardContentContext.Source"/> разное для разных файлов, /// или же оно не соответствует текущему классу. /// /// В текущей версии метод не используется в платформе или типовом решении, /// но может использоваться в решениях на базе платформы. /// /// Перемещение можно выполнить как копирование с последующим удалением файла. При этом копирование можно /// выполнить комбинацией вызовов Get() и Store(). Если для хранилища существует более быстрый способ /// перемещать файлы, такой как File.Move(), то его надо использовать. /// </summary> /// <param name="sourceContext"> /// Местоположение исходного контента, включая идентификаторы карточки, файла и версии. /// </param> /// <param name="targetContext"> /// Местоположение целевого контента, включая идентификаторы карточки, файла и версии. /// </param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns><c>true</c>, если файл успешно скопирован; <c>false</c>, если добавлена ошибка.</returns> public async Task<bool> MoveAsync( CardContentContext sourceContext, CardContentContext targetContext, CancellationToken cancellationToken = default) { string sourceFilePath = this.GetContentPath(sourceContext);

string targetFolderPath = this.GetCardFolderPath(targetContext.CardID); string targetFilePath = GetContentPath(targetContext.VersionRowID, targetFolderPath);

if (string.Equals(sourceFilePath, targetFilePath, StringComparison.Ordinal)) { // исходный и целевой файл расположены в одном месте, не падаем return true; }

if (File.Exists(targetFilePath)) { ValidationSequence .Begin(targetContext.ValidationResult) .SetObjectName(this) .Error( CardValidationKeys.FileTargetContentExists, targetContext.CardID, targetContext.FileID, targetContext.VersionRowID) .End();

return false; }

if (!File.Exists(sourceFilePath)) { ValidationSequence .Begin(targetContext.ValidationResult) .SetObjectName(this) .Error( CardValidationKeys.FileContentNotFound, sourceContext.CardID, sourceContext.FileID, sourceContext.VersionRowID) .End();

return false; }

FileHelper.CreateDirectoryIfNotExists(targetFolderPath); File.Move(sourceFilePath, targetFilePath); return true; }

/// <summary> /// Вызывается при удалении карточки. /// /// К моменту вызова метода система вызывает CleanFile для каждого файла в этой карточке, /// поэтому на диске остаётся пустая папка (может быть непустой, если были какие-то ошибки, /// например, при удалении файл был заблокирован). /// /// Здесь нам рекомендуется удалить папку с файлами карточки. /// </summary> /// <param name="cardID">Идентификатор карточки.</param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns>Асинхронная задача.</returns> /// <remarks> /// Метод может не выполнять действий, если при удалении карточки в хранилище не остаётся лишних пустых папок. /// </remarks> public async Task CleanCardAsync( Guid cardID, CancellationToken cancellationToken = default) { string folderPath = this.GetCardFolderPath(cardID); if (Directory.Exists(folderPath)) { // к моменту вызова CleanCard система вызывает CleanFile для каждого файла в этой карточке Directory.Delete(folderPath, recursive: true); } }

/// <summary> /// Вызывается при удалении файла (в т.ч. при удалении карточки для каждого его файла). /// /// К моменту вызова метода система вызывает Delete для каждой версии в этом файле, /// поэтому на диске остаётся пустая папка (может быть непустой, если были какие-то ошибки, /// например, при удалении файл был заблокирован). /// /// Здесь нам рекомендуется удалить папку с файлом. Но поскольку в этом примере все файлы карточки /// располагаются в одной папке, то отдельной папки под файл нет, т.е. удалять нечего. /// </summary> /// <param name="cardID">Идентификатор карточки.</param> /// <param name="fileID">Идентификатор файла.</param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns>Асинхронная задача.</returns> /// <remarks> /// Метод может не выполнять действий, если при удалении файла в хранилище не остаётся лишних пустых папок. /// </remarks> public async Task CleanFileAsync( Guid cardID, Guid fileID, CancellationToken cancellationToken = default) { // к моменту вызова CleanFile система вызывает Delete для каждой версии в этом файле // к этому моменту все вложенные файлы с версиями уже должны быть удалены вызовами Delete }

/// <summary> /// Метод перемещения всех файлов из одной карточки в другую в пределах хранилища. /// В системе используется при удалении карточек или их восстановлении из корзины. /// </summary> /// <param name="sourceCardID">Идентификатор карточки, файлы которой переносятся.</param> /// <param name="targetCardID"> /// Идентификатор целевой карточки, в которую переносятся все файлы исходной карточки. /// </param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns>Асинхронная задача.</returns> /// <remarks> /// Если хранилище не разделяет версии файлов по карточкам и файлам, т.е. хранит прямое соответствие идентификатора /// версии VersionRowID и собственно контента, то этот метод может не выполнять каких-либо действий. /// /// В таком случае местоположение версии файла в хранилище не меняется при перемещении между карточками /// и при изменении идентификатора файла (не версии). /// </remarks> public async Task MoveFilesAsync( Guid sourceCardID, Guid targetCardID, CancellationToken cancellationToken = default) { if (sourceCardID == targetCardID) { return; }

string sourceFolder = this.GetCardFolderPath(sourceCardID); if (!Directory.Exists(sourceFolder)) { // нет файлов для перемещения return; }

string targetFolder = this.GetCardFolderPath(targetCardID);

if (Directory.Exists(targetFolder)) { // в папке назначения уже есть какие-то файлы, поэтому Directory.Move не сработает, // перемещаем файлы по одному

foreach (string sourceFileName in Directory.EnumerateFiles(sourceFolder)) { string fileNameWithoutDir = Path.GetFileName(sourceFileName); string targetFileName = Path.Combine(targetFolder, fileNameWithoutDir); File.Move(sourceFileName, targetFileName); } } else { Directory.Move(sourceFolder, targetFolder); } }

/// <summary> /// Метод перемещения единственного файла из одной карточку в другую в пределах хранилища. /// В системе используется при удалении или восстановлении карточек-сателлитов задач, к которым прикреплены файлы. /// </summary> /// <param name="sourceCardID">Идентификатор карточки, файл которой переносится.</param> /// <param name="sourceFileID"> /// Идентификатор переносимого файла, для него надо найти все версии файла и перенести их. /// </param> /// <param name="targetCardID"> /// Идентификатор целевой карточки, в которую переносятся версии указанного файла. /// </param> /// <param name="targetFileID">Идентификатор файла в целевой карточке.</param> /// <param name="cancellationToken">Объект, посредством которого можно отменить асинхронную задачу.</param> /// <returns>Асинхронная задача.</returns> /// <remarks> /// Если хранилище не разделяет версии файлов по карточкам и файлам, т.е. хранит прямое соответствие идентификатора /// версии VersionRowID и собственно контента, то этот метод может не выполнять каких-либо действий. /// /// В таком случае местоположение версии файла в хранилище не меняется при перемещении между карточками /// и при изменении идентификатора файла (не версии). /// </remarks> public async Task MoveFileAsync( Guid sourceCardID, Guid sourceFileID, Guid targetCardID, Guid targetFileID, CancellationToken cancellationToken = default) { if (sourceCardID == targetCardID && sourceFileID == targetFileID) { return; }

string sourceCardFolder = this.GetCardFolderPath(sourceCardID); if (!Directory.Exists(sourceCardFolder)) { // нет файлов для перемещения return; }

// по структуре папок непонятно, какие из файлов в папке карточки относятся к версиям этого файла, // потому что в нашем примере нет отдельной подпапки на файл; поэтому получаем идентификаторы версий // для файла из основной БД Тессы, где они должны быть

List<Guid> sourceVersionIdentifiers; using (this.dbScope.CreateNew()) { DbManager db = this.dbScope.Db; sourceVersionIdentifiers = await db .SetCommand( this.dbScope.BuilderFactory .Select().C("RowID") .From("FileVersions").NoLock() .Where().C("ID").Equals().P("FileID") .Build(), db.Parameter("FileID", sourceFileID)) .ExecuteListAsync<Guid>(cancellationToken).ConfigureAwait(false); }

if (sourceVersionIdentifiers.Count == 0) { // у файла нет версий - где-то в структуре базы ошибка return; }

string targetCardFolder = this.GetCardFolderPath(targetCardID); if (!Directory.Exists(targetCardFolder)) { FileHelper.CreateDirectoryIfNotExists(targetCardFolder); }

foreach (Guid versionRowID in sourceVersionIdentifiers) { string sourceFilePath = GetContentPath(versionRowID, sourceCardFolder); string targetFilePath = GetContentPath(versionRowID, targetCardFolder); File.Move(sourceFilePath, targetFilePath); } } } }

Теперь сообщим системе, когда использовать эту реализацию вместо типовых. Для этого напишем следующий класс регистратора, в котором определим способ выбора хранилища: если его название начинается со строки "custom-", то используется реализация из примера выше, иначе используется стандартная реализация.

using System; using Tessa.Cards; using Tessa.Cards.ComponentModel; using Tessa.Platform.Data; using Unity; using Unity.Lifetime;

namespace Tessa.Extensions.Server.Files { [Registrator] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() { // хранилище с выключенным флажком "База данных" и с названием "custom-***" // будет работать по правилам CustomContentStrategy

this.UnityContainer .RegisterFactory<CardSourceContentStrategy>( c => new CardSourceContentStrategy( c.Resolve<ICardFileSourceSettings>(), settings => new CardDatabaseContentStrategy(c.Resolve<IDbScope>(), settings), settings => settings.Name.StartsWith("custom-", StringComparison.Ordinal) ? new CustomContentStrategy(c.Resolve<IDbScope>(), settings) : (ICardContentStrategy)new CardFileSystemContentStrategy(settings)), new ContainerControlledLifetimeManager()) ; } } }

Note

В версии платформы 3.1.1 или раньше вместо метода RegisterFactory<CardSourceContentStrategy> используйте метод RegisterType<CardSourceContentStrategy>, которому передайте фабрику с лямбда-выражением new InjectionFactory(c => ...).

Скомпилируйте расширения, скопируйте с заменой Tessa.Extensions.Server.dll в папки веб-сервиса web и фонового сервиса chronos (подпапка extensions), перезапустите сервисы. Теперь откройте приложение TessaClient или web-клиента, выполните вход от сотрудника-администратора и перейдите в карточку настроек “Настройки сервера” (на правой боковой панели).

Добавьте новое хранилище файлов в таблицу. Важно, чтобы у этого хранилище в поле “Название” была строка, начинающаяся с "custom-", как было обозначено выше. В поле “Местоположение” введите путь на файловой системе, который используйте в конструкторе класса CustomContentStrategy (это может быть любая строка). Хранилище можно указать как используемое по умолчанию для всех новых файлов (кнопка “Установить по умолчанию”) или же только для файлов с определёнными расширениями (поле “Расширения файлов”).

Сохраните карточку, и проверьте функционирование хранилища. Например, приложите к карточке документа файл, и убедитесь:

  1. При сохранении содержимое файла создаётся в папке, указанной в “Местоположение”, причём для карточки создана папка с идентификатором, и внутри неё расположен контент как файл с идентификатором версии. Это проверяет метод Store.

  2. Сохранённый файл можно скачать на клиент и открыть. Это проверяет методы Get и GetSize.

  3. Создайте шаблон для этой карточки (в левой боковой панели). Убедитесь, что после сохранения файлы в шаблоне доступны для скачивания. Это проверяет метод Copy.

  4. Удалите файл в исходной карточке. Контент в хранилище также должен быть удалён. Это проверяет метод Delete.

  5. Добавьте в документ ещё файл, и удалите карточку документа. Перейдите в представление “Удалённые карточки”, откройте там двойным кликом карточку и убедитесь, что содержимое файлов доступно. Это проверяет метод MoveFiles.

  6. Восстановите карточку из удалённых. Откройте её, поставьте задачу плиткой “Поставить задачу” на левой панели, далее нажмите иконку скрепки в задаче, и добавьте файл, сохраните карточку. Это прикрепило файл к карточке задачи, у которой другой идентификатор, чем в карточке документа (это можно увидеть в хранилище по именам папок). Теперь вернитесь в карточку документа, и удалите карточку. Перейдите в “Удалённые карточки”, и восстановите карточку. Откройте восстановленную карточку и убедитесь, что для скачивания доступны как файл в документе, так и файл в задаче. Это проверяет метод MoveFile, где при удалении карточки файлы, прикреплённые ко всем задачам, были перемещены в удалённую карточку, при восстановлении которой отдельные её файлы были снова перемещены в карточки задач.

На этом можно считать, что хранилище успешно функционирует. В реальном примере вместо файловых папок могут отправляться запросы к внешней системе, где по идентификатору версии сохраняются файлы.

Back to top