Распределённые развертывания

Обслуживание одного документа с нескольких узлов

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

Если ваше приложение сейчас вызывает UseDoconutWebFarm(...), эта страница является его заменой. Ни UseDoconutWebFarm, ни WebFarmOptions не существуют в этом релизе.

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

Что заменило middleware web‑farm

Раньше один middleware делал всё: он обслуживал предварительно отрендеренные изображения из общей папки и получал их, пока другой узел ещё их записывал. Ожидание было слепым бюджетом — «блокировать до трёх минут в надежде, что страница появится».

Сейчас эта же задача разбита на два вызова, которые вы можете видеть и контролировать:

text
node A            OpenDocument request
                      -> PublishAsync(...)   writes pages, document info,
                                             search index and metadata
                                             into  RootPath/{token}/
                      -> returns the token

node B            ?token=...&page=7
                      -> UseDoconutCloud<FileShareCloudHandler>
                      -> reads RootPath/{token}/ and serves the page

Общая конфигурация, необходимая обеим частям

Публикующий пишет с помощью FileShareConfig, а обработчик чтения обслуживает с тем же. Если RootPath или UsePerTokenSubfolders различаются между ними, другой узел вернёт 404. Создайте конфигурацию в одном месте и используйте её с обеих сторон — в ссылочном приложении есть вспомогательный метод SharedStore.Config(...) именно для этого:

csharp
internal static class SharedStore
{
    public static FileShareConfig Config(string rootPath, bool asyncPublish) => new()
    {
        Location              = CloudLocation.FileShare,
        RootPath              = rootPath,               // e.g. \\fileserver\doconut
        UsePerTokenSubfolders = true,
        ReadReadyPollMs       = asyncPublish ? 250 : 0,
    };
}

FileShareConfig находится в Doconut.Clouds вместе с AzureConfig, AmazonConfig, GoogleCloudConfig, DropBoxConfig, RedisConfig, CDNConfig и FTPConfig. В предыдущей библиотеке они находились в Doconut.Configs.Cloud; имена классов не изменились.

СвойствоНазначение
LocationCloudLocation.FileShare для UNC/локального общего ресурса
RootPathОбщая папка, доступная каждому узлу
UsePerTokenSubfoldersХранить артефакты каждого документа в отдельной папке {token}
ReadReadyPollMsКак долго сторона чтения ждёт страницу, которая ещё записывается; 0 отключает опрос

Сторона записи — публикация документа

Внедрите DistributedDocumentPublisher и публикуйте вместо открытия локальной сессии. Один вызов рендерит и записывает полный набор артефактов — страницы, информацию о документе, поисковый индекс и метаданные:

csharp
var shareConfig = SharedStore.Config(rootPath, asyncPublish: false);

var documentOptions = new DocOptions { TimeOut = 60 };
var config          = new PdfConfig { AllowSearch = true };

var publishOptions = new DistributedDocumentPublishOptions
{
    CleanUpStagingDirectory = true,
    FirstPagePriority       = true,
};

DistributedDocumentPublishResult result = await _publisher.PublishAsync(
    pathToFile, shareConfig, config, documentOptions, publishOptions, ct);

return Content(result.Token);

DistributedDocumentPublishResult содержит Token, ArtifactPath, TotalPages и Format. Токен передаётся браузеру точно так же, как обычный токен просмотрщика.

Этот единый вызов заменяет прежнюю пару — открытие документа в локальной сессии и последующее выгрузку изображений на общий ресурс с помощью ExportToPng(sharedPath). ExportToPng и ExportToCloud здесь не имеют эквивалентов.

Сторона чтения — обслуживать из общего ресурса

Подмонтируйте обработчик FileShare на той же ветке, которую уже вызывает клиент:

csharp
app.UseDoconutResources();

app.MapWhen(
    ctx => ctx.Request.Path.Value?.EndsWith("DocImage.axd", StringComparison.OrdinalIgnoreCase) == true,
    branch => branch.UseDoconutCloud<FileShareCloudHandler>(options =>
    {
        options.Location          = CloudLocation.FileShare;
        options.CloudUploadConfig = SharedStore.Config(rootPath, asyncPublish: false);
    }, "/"));

UseDoconutCloud является обобщённым в этом выпуске: провайдер передаётся как типовой аргумент, а не как значение Location, переключаемое внутри middleware. Обработчики называются <Provider>CloudHandler — FileShareCloudHandler, AzureCloudHandler, AmazonS3CloudHandler, GoogleCloudHandler, DropBoxCloudHandler, RedisCloudHandler, FTPCloudHandler и CDNCloudHandler.

Обработчик CDN является только для чтения — он обслуживает то, что уже находится на CDN; загрузка не производится.

Приоритет первой страницы и HTTP 202

FirstPagePriority = true делает страницу 1 доступной сразу. Последующие страницы отвечают HTTP 202 Accepted, пока они не будут записаны, вместо блокировки запроса.

Это переносит ожидание на клиент, где оно ограничено и видно. Включите повторные попытки в виджете:

javascript
const objViewer = $('#div_ctlDoc').docViewer({
    BasePath:   '/',
    ResPath:    'doconut-res/images',
    retryOn409: true      // retries 202 (and legacy 409) readiness responses
});

Клиент, который не делает повторных попыток, показывает сломанные плитки для страниц, которые ещё рендерятся. Ограничения повторных попыток и их значения по умолчанию (retryInitialDelayMs, retryBackoffFactor, retryMaxDelayMs, retryMaxAttempts, retryMaxTotalMs) задокументированы в КонфигурацияПросмотрщика.

Эта пара — FirstPagePriority плюс опрос со стороны чтения — заменяет предыдущие настройки PageWaitTimeSeconds и StartWaitFromPage. Ни одна из них больше не существует.

Необязательно: публикация в фоновом режиме

По умолчанию PublishAsync рендерит до возврата. Чтобы вернуть ответ сразу после создания токена и позволить размещённому воркеру рендерить в ту же папку, зарегистрируйте очередь с согласия:

csharp
builder.Services.AddDoconut(options => { /* … */ });
builder.Services.AddDoconutDistributedAsyncPublish();

Затем ставьте задачи в очередь вместо непосредственной публикации и задайте Async = true в параметрах публикации. Очередь ограничена: при её насыщении EnqueueAsync бросает DistributedPublishQueueFullException. Ответьте статусом 503 и заголовком Retry-After, чтобы клиенты делали паузу, а не зависали:

csharp
try
{
    var token = await _publishQueue.EnqueueAsync(
        pathToFile, shareConfig, config, documentOptions, publishOptions, ct);
    return Content(token);
}
catch (DistributedPublishQueueFullException)
{
    Response.Headers.RetryAfter = "5";
    Response.StatusCode = 503;
    return Content("The publish queue is full. Please retry.");
}

Без этой регистрации ничего не регистрируется, и публикация остаётся синхронной — путь по умолчанию идентичен побайтно.

Когда включена фоновая публикация, задайте стороне чтения ненулевое значение ReadReadyPollMs, чтобы она коротко ожидала страницу, которую воркер ещё не записал.

Безопасность между узлами

Токены, привязанные к сессии, предполагают, что обслуживающий узел видел исходный запрос. В ферме это не так, поэтому проверка на уровне сессии отклоняла бы легитимные запросы. Поэтому распределённое демонстрационное приложение устанавливает UnsafeMode = true.

UnsafeMode = true не является моделью безопасности — это удаление модели, которая больше не применима. Замените её на подписанные билеты доступа, которые каждый узел может проверять независимо:

csharp
builder.Services.AddDoconutDistributedDocumentSecurity(options => { /* … */ });

Никогда не используйте UnsafeMode = true в однопользовательском развертывании и никогда не рассматривайте его как обходную схему для неработающей проверки сессии на одном узле — в этом случае правильное решение — AddSession() / UseSession(). Смотрите Сессии и безопасность.

Закрытие опубликованного документа

CloseDocument(token) на внедрённом Viewer завершает локальную сессию. Чтобы удалить опубликованные артефакты документа из общего ресурса, издатель предоставляет:

text
bool CloseDistributedDocument(FileShareConfig fileShareConfig, string token)

Передайте тот же FileShareConfig, с которым документ был опубликован.

Сопоставление параметр за параметром

РанееСейчас
UseDoconutWebFarm(new WebFarmOptions { … })PublishAsync(...) на стороне записи плюс UseDoconutCloud<FileShareCloudHandler>(...) на стороне чтения
WebFarmOptions.PathFileShareConfig.RootPath
PageWaitTimeSeconds / StartWaitFromPageDistributedDocumentPublishOptions.FirstPagePriority плюс FileShareConfig.ReadReadyPollMs
viewer.OpenDocument(...) then viewer.ExportToPng(share)один вызов PublishAsync(...)
viewer.ExportToCloud(cloudConfig)PublishAsync(...) с конфигурацией провайдера
UseDoconutCloud(new CloudOptions { Location = … })UseDoconutCloud<THandler>(options => …, pathPrefix)
DocOptions.IsWebfarm / WebfarmPathПо‑прежнему присутствует, написано IsWebFarm / WebFarmPath — не требуется в пути публикации
Doconut.Configs.CloudDoconut.Clouds

Контрольный список

  1. Создайте FileShareConfig в одном вспомогательном методе и вызывайте его из обеих частей.
  2. Укажите RootPath на общий ресурс, который вы уже используете.
  3. Публикуйте с помощью PublishAsync(...); удалите любые вызовы ExportToPng / ExportToCloud.
  4. Обслуживайте с помощью UseDoconutCloud<FileShareCloudHandler>(...) на ветке, которую вызывает клиент.
  5. Включите одновременно FirstPagePriority и retryOn409, либо отключите оба.
  6. Замените проверку на сеанс подписанными билетами доступа — не поставляйте UnsafeMode = true отдельно.
  7. Проверьте, открыв документ на одном узле и запросив позднюю страницу с другого.

Была ли эта страница полезной?