Создание хранилища файлов с собственными правилами размещения контента
Создание хранилища файлов с собственными правилами размещения контента¶
Для интеграции с файловыми сервисами, предоставляющими собственные средства размещения файлов (например, в виде 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
(это может быть любая строка). Хранилище можно указать как используемое по умолчанию для всех новых файлов (кнопка “Установить по умолчанию”) или же только для файлов с определёнными расширениями (поле “Расширения файлов”).
Сохраните карточку, и проверьте функционирование хранилища. Например, приложите к карточке документа файл, и убедитесь:
-
При сохранении содержимое файла создаётся в папке, указанной в “Местоположение”, причём для карточки создана папка с идентификатором, и внутри неё расположен контент как файл с идентификатором версии. Это проверяет метод
Store
. -
Сохранённый файл можно скачать на клиент и открыть. Это проверяет методы
Get
иGetSize
. -
Создайте шаблон для этой карточки (в левой боковой панели). Убедитесь, что после сохранения файлы в шаблоне доступны для скачивания. Это проверяет метод
Copy
. -
Удалите файл в исходной карточке. Контент в хранилище также должен быть удалён. Это проверяет метод
Delete
. -
Добавьте в документ ещё файл, и удалите карточку документа. Перейдите в представление “Удалённые карточки”, откройте там двойным кликом карточку и убедитесь, что содержимое файлов доступно. Это проверяет метод
MoveFiles
. -
Восстановите карточку из удалённых. Откройте её, поставьте задачу плиткой “Поставить задачу” на левой панели, далее нажмите иконку скрепки в задаче, и добавьте файл, сохраните карточку. Это прикрепило файл к карточке задачи, у которой другой идентификатор, чем в карточке документа (это можно увидеть в хранилище по именам папок). Теперь вернитесь в карточку документа, и удалите карточку. Перейдите в “Удалённые карточки”, и восстановите карточку. Откройте восстановленную карточку и убедитесь, что для скачивания доступны как файл в документе, так и файл в задаче. Это проверяет метод
MoveFile
, где при удалении карточки файлы, прикреплённые ко всем задачам, были перемещены в удалённую карточку, при восстановлении которой отдельные её файлы были снова перемещены в карточки задач.
На этом можно считать, что хранилище успешно функционирует. В реальном примере вместо файловых папок могут отправляться запросы к внешней системе, где по идентификатору версии сохраняются файлы.