Создание Silverlight приложений для SharePoint. Часть 2.

Полгода прошло с тех пор как я опубликовал первую часть статьи. Как раз полгода назад произошло смещение приоритетов веб-разработки Microsoft  в сторону HTML5, и злые языки начали пророчить скорую смерть Silverlight. Тем не менее вышла пятая версия SL и, скорее всего, будет еще и шестая. Тем не менее фокус больше смещается в сторону HTML\JS, и по слухам в следующей версии SharePoint будет гораздо больше javascript и гораздо меньше SL.

Поэтому данная статья будет больше обзорной.

Варианты развертывания Silverlight приложений

Как обычно их три:

  1. В sandbox решении в виртуальную файловую систему sharepoint.
  2. В fulltrust решении в физическую файловую систему.
  3. Приложение на внешнем сервере, с использованием fluid application model.
Размещение Silverlight на портале.

Самый часто используемый способ – веб-часть silverlight. Её можно найти в категории Media. Также возможно создание своих веб-частей, которые отображают silverlight приложение, чтобы передать дополнительные параметры и\или сделать fallback. Но лучше такие веб-части не создавать, а воспользоваться расширением для visual studio. А в следующей версии visual studio такая веб-часть доступна “изкаропки”.

Еще один вариант – создание field control на silverlight. Пример можно посмотреть в статье на msdn.

Демо-приложение

Для демонстрации всех способов развертывания напишу простое приложение, при запуске оно будет выводить Title узла, на котором запущено приложение.

Сначала надо добавить в проект SL сборки Microsoft.SharePoint.Client.Silverlight  и Microsoft.SharePoint.Client.Silverlight.Runtime. Их можно найти в папке {SharePointRoot}\TEMPLATE\LAYOUTS\ClientBin.

Теперь можно написать немного кода:

var ctx = ClientContext.Current;
var web = ctx.Web;
ctx.Load(web, w => w.Title);

ctx.ExecuteQueryAsync(
(o, args) => //success
{
    Dispatcher.BeginInvoke(() =>
    {
        tbTitle.Text = web.Title;
    });

},
(o, args) => //failure
{
    Dispatcher.BeginInvoke(() =>
        {
            MessageBox.Show(args.Exception.ToString());
        });
});

 

Для тех кто не знаком с клиентской объектной моделью SharePoint краткий ликбез. Точка входа в клиентскую объектную модель – класс ClientContext, в SL приложении можно получить “текущий контекст”, если приложение развернуто в SharePoint.

Все объекты в клиентской объектной модели являются”обещаниями” (promise), то есть на момент получения они не содержат значений, а только общение что значения когда-нибудь там будут.  Чтобы загрузить свойства объекта с сервера надо выполнить метод ClientContext.Load. В методе Load можно указать с помощью лямбда-выражений какие свойства загружать.

Другая особенность клиентской объектной модели заключается в в том что команды не выполняются сразу, а складываются в очередь и отправляются на сервер при вызове ExecuteQuery\ExecuteQueryAsync. Так как в SL нельзя блокировать поток UI, то воспользуемся асинхронным вариантом.

Третья особенность заключается в том что коллбеки завершения асинхронного вызова клиентской объектной модели не маршалятся в поток UI (сколько непонятных слов, сам в шоке). Поэтому надо вызывать Dispatcher.BeginInvoke чтобы поменять что-либо в UI или вывести Message Box.

На первый взгляд код выглядит сложным, но при некоторой сноровке пишется “на автомате”.

Развертывание на уровне фермы

Это самый простой и прямолинейный вариант. Он вполне может быть оправдан если вы используете Silverlight приложение как field control для кастомного поля, в остальных случаях я бы не рекомендовал.

Чтобы развернуть приложение в физическую файловую систему надо создать  один пустой элемент (SPI), даже не нужен манифест (Element.xml) и фича, их можно просто удалить.

image

Далее в необходимо добавить в элемент project output из проекта silverlight, обязательно указав Deployment Type=TemplateFile

image

После этого надо добавить элемент в package (не в фичу).

Последний шаг перед развертыванием – включить отладку Silverlight.

image

После этого можно жать F5 на проекте SharePoint, добавлять на страницу веб-часть Silverlight и ставить точки останова в проекте Silverlight.

image

Развертывание в sandbox

Для развертывания решения в sandbox надо выполнить те же шаги, что и для fulltrust решения, но вместо пустого элемента создать модуль, указать Deployment Type=ElementFile в project output references и использовать фичу для развертывания.

image

image

И все, в манифест модуля автоматически попадет элемент с xap файлом.

Далее как обычно: F5, веб-часть, отладка.

Развертывание на внешнем хосте

Для начала надо включить поддержку внешних приложений в SharePoint. Это можно сделать в powershell небольшим скриптом.

$cs = [Microsoft.SharePoint.Administration.SPWebService]::ContentService
$cs.ExternalApplicationSettings.Enabled = $true
$cs.Update()

Я не проверял включены ли внешние приложения в office 365, очень надеюсь что включены. Иначе я зря это все пишу.

Чтобы разместить внешнее приложение в SharePoint надо передать веб-части silverlight манифест приложения (applicationXml). При добавлении веб-части появится запрос манифеста (после включения внешних приложений).

<?xml version="1.0" encoding="utf-8" ?>
<applicationParts xmlns='http://schemas.microsoft.com/sharepoint/2009/fluidapp'>
  <applicationPart>
    <metaData>
      <applicationId>F0F0E6C1-5B42-4277-9EFF-777F1330BCD8</applicationId>
      <applicationUrl>http://localhost:21351/HostWebSite/ClientBin/SlApp.xap</applicationUrl>
      <principal>contoso\sp-app</principal>
      <sharepointRequestHandlerUrl>/HostWebSite/sp.ashx</sharepointRequestHandlerUrl>
    </metaData>
    <data>
      <webPartProperties>
        <property name='Title'>SlApp</property>
        <property name='Height'>400px</property>
        <property name='Width'>300px</property>
        <property name='MinRuntimeVersion'>3.0</property>
      </webPartProperties>
      <customProperties>
      </customProperties>
    </data>
  </applicationPart>
</applicationParts>

 

До того как создавать веб-часть необходимо дать доступ пользователю, который указан в разделе principal, с помощью метода AddApplicationPrincipal. Лучше всего это делать в коде активации фичи.

public override void FeatureActivated(SPFeatureReceiverProperties properties)
{
    var web = properties.Feature.Parent as SPWeb;
    var principal = web.AddApplicationPrincipal("contoso\\sp-app", true, false);
    web.RoleAssignments.Add(new SPRoleAssignment(principal)
    {
        //Don't do this in podution code
        RoleDefinitionBindings = { web.RoleDefinitions.GetByType(SPRoleType.Administrator) }
    });           
}

public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
{
    var web = properties.Feature.Parent as SPWeb;
    var principal = web.AddApplicationPrincipal("contoso\\sp-app", true, false);
    web.RoleAssignments.Remove(principal);
}

После деплоя решения и активации фичи можно попробовать добавить на страницу веб-часть Silverlight и указать application xml. Приложение отобразится но сразу будет падать с ошибкой.

Небольшой ликбез про кросс-доменные вызовы в silverlight. По умолчанию приложение на SL может обращаться только к тому домену, откуда оно загружено. В модели external application в sharepoint приложение silverlight находится в другом домене и ему кросс-доменные вызовы запрещены.

Иногда может помочь файл clientaccesspolicy.xml, который разрешает silvelight обращаться за пределы домена, но там надо явно прописать куда можно обращаться или указать что обращаться можно везде. Но это небезопасно, так как  в случае sharepoint приложение на Silverlight будет передавать пароль по сети для аутентификации.

Поэтому сделана такая система. В манифесте приложения указывается куда silverlight приложение будет отправлять запросы при вызове клиентской объектной модели (элемент sharepointRequestHandlerUrl). Обработчик по указанному url будет определять какой сайт sharepoint обращается и передавать логин и пароль именно для этого сайта.

Чтобы создать этот обработчик надо в сайте, где хостится silverlight создать обычный ashx-хендлер.

public class sp : IHttpHandler {
    
    public void ProcessRequest (HttpContext context) {
        RequestForwarder forwarder = new RequestForwarder(context);
        if (!String.IsNullOrEmpty(forwarder.Url))
        {
            forwarder.WebRequest.Credentials
                = new System.Net.NetworkCredential("sp-app", "P@ssw0rd", "contoso");
            forwarder.ProcessRequest();
        }
    }
 
    public bool IsReusable {
        get {
            return false;
        }
    }
}

 

Класс RequestForwarder находится во “взрослой” клиентской объектной модели.

После добавления хендлера приложение перестает выдавать ошибку и работает нормально.

Заключение

Как видите довольно много возможностей развертывания  приложений Silverlight в SharePoint. При этом само приложение не поменялось ни в одном сценарии.



Поиск в приложениях SharePoint. Часть 1.

Одна из наиболее часто возникающих задач при разработке порталов на SharePoint  - создание веб-части , отображающей ближайшие дни рождения пользователей.

В SharePoint 2010 есть служба профилей пользователей, которая хранит данные о пользователях, в том числе дни рождения, в масштабах фермы. Для отображения списка пользователей можно использовать веб-часть "Основные результаты поиска людей". Но если разместить веб-часть на форму, то она не выводит результатов.

image

Чтобы отобразить результаты необходимо "Добавить текст в запрос".

image

Так как используется поиск надо заранее выполнить обход и в службе профилей должны быть записи.

 

Веб-часть отображения результатов поиска наследует DataFormWebPart, поэтому можно настроить отображение в виде xslt. Но для этого надо отключить отображение по-умолчанию.

image

XSL для отображения:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" version="1.0" encoding="UTF-8" indent="yes"/>
<xsl:template match="/">
  <table>
    <tr>
      <th>
        User name
      </th>
      <th>
        Account name
      </th>
    </tr>
    <xsl:apply-templates select="All_Results/Result"/>
  </table>
</xsl:template>
  <xsl:template match="Result">
    <tr>
      <td>
        <xsl:value-of select="preferredname"/>
      </td>
      <td>
        <xsl:value-of select="accountname"/>
      </td>
    </tr>
  </xsl:template>
</xsl:stylesheet>

Веб-части поиска выводят управляемые свойства поиска, которые задаются в central administration. Чтобы выводить (и использовать в запросах) дату рождения необходимо создать управляемое свойство, которое отображается на свойство профиля.

image

image

После добавления свойства обязательно надо выполнить полный обход содержимого.

После обходи можно добавить элемент в свойства columns веб-части и дописать xslt для вывод нужного значения.

image

Даты в профиле пользователя вводятся в формате “Месяц День”, 2000 год дописывается сам (чтобы можно было 29 февраля ввести). При выводе вам надо самостоятельно форматировать даты.

Теперь остается две проблемы:

  • Сортировка результатов. По-умолчанию веб часть не поддерживает сортировку по произвольному полю.
  • Динамический запрос. В параметрах веб-части можно ввести только статическую строку и получить текущую дату нельзя.

Для того чтобы победить обе проблемы можно наследоваться от веб-части поиска, но веб-часть поиска людей sealed. Поэтому надо наследоваться от веб-части поиска контента.

Для этого нужно создать веб-часть в visual studio.

public class WebPart1 : CoreResultsWebPart
{
    protected override string DefaultSearchLocation
    {
        get
        {
            //For people search
            return "LocalPeopleSearchIndex";
        }
    }

    protected override void ConfigureDataSourceProperties()
    {
        base.ConfigureDataSourceProperties();

        var ds = this.DataSource as CoreResultsDatasource;
        ds.SortOrder.Clear();
        ds.SortOrder.Add("Birthday", Microsoft.Office.Server.Search.Query.SortDirection.Descending);
    }

    protected override void OnInit(EventArgs e)
    {
        base.OnInit(e);

        var today2000 = new DateTime(2000, DateTime.Today.Month, DateTime.Today.Day);           
        this.AppendedQuery = string.Format("Birthday>=\"{0}\" AND Birthday<\"{1}\" ", 
                                    today2000.ToShortDateString(),
                                    today2000.AddDays(3).ToShortDateString());
    }
}

Свойство AppendedQuery – это “Добавить текст в запрос” в интерфейсе.

Результат после размещения на форме и правки представления:

image

Заключение

Стандартные веб-части поиска – очень мощный механизм вывода данных. Если мощности стандартных веб-частей не хватает, то их очень легко расширить с помощью кода.



Миф о кастомизации страниц в SharePoint

Немного теории. В SharePoint есть виртуальная файловая система, которая содержится в контентной БД. Когда вы в farm solution помещаете в нее файл, с помощью модуля, то файл лежит на диске в {SharePointRoot}\TEMPLATE\Features, а контентной БД появляется ссылка на этот файл.

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

Такие файлы называются uncustomized, ghosted (в терминологии 2007 шарика) или просто некастомизироваными. Если же попробовать внести какое-либо изменение в файл в виртуальной файловой системе (!), то измененное состояние файла записывается в контентную БД и SharePoint перестает обращаться к диску за файлом. такие файлы называются customized, unghosted или кастомизированные.

Причем это относится не только к файлам в виртуальной ФС, но и к типам контента, шаблонам списков итд. Но об этом другой раз…

Миф

Кастомизированные страницы работают медленнее некастомизированных.

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

Я решил просто померить как изменяется скорость работы SharePoint от кастомизации страниц. Сделал простой веб-тест в Visual Studio, который обращается к странице по-умолчанию, а потом создал нагрузочный тест и замерял Page Response Time.

Вот график (кликабельно):

Центральный пик - сохранение кастомизированной страницы, слева - время ответда для некастомизированной старницы, справа - время ответа кастомизированной страницы. Среднее время ответа некастомизированной страницы – 1.7 сек, кастомизироанной – 1.9 сек. Разница около 10%. Если на странице будут “тяжелые” контролы, то разница станет еще менее заметной.

При кастомизации мастер страницы график еще интереснее:

Большой пик – кастомизация мастер-страницы. Справа пик поменьше – было сделано Revert To Definition для мастер-страницы. Получается результаты почти одинаковые, за исключением того, что для некастомизированного masterpage результаты гораздо менее стабильны.

Заключение

Использовать исключительно некастомизированные страницы имеет смысл только для hiload публичных сайтов. Хотя кто использует sharepoint для hiload сценариев???….



Оптимизация процессинга в Windows Azure. Часть 3.

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

А нужны ли вообще воркеры?

Оказывается не нужны. Если у вас небольшое приложение и вы используете очереди для надежной (reliable) асинхронной обработки, причем сама обработка не требует больших вычислительных затрат, то вам и не нужны воркеры. Можете использовать пару методов ToObserver\ToObservable из предыдущего поста, а для оповещений обычный Subject<Unit>.

Отказываться  от очередей в данном случае не надо, так как при масштабировании работу подхватят воркеры.

Scale Down

Как вы уже могли догадаться возможность масштабировать “вниз” в облаке не менее важна, чем масштабирование “вверх”. С учетом всех ранее перечисленных подходов можно любое приложение развернуть на одном Extra Small Instance в Windows Azure за $30 и тысячей транзакций хранилища (меньше $0.01) в месяц, если к нему будет мало обращений. Это уже сопоставимо с ценой shared-хостинга.

На этом история scale down заканчивается и начинается история…

Scale Up\Out

Сразу же рекомендую посмотреть на Autoscale Application Block (кодовое имя WASABi) из комплекта Enterprise Library. Ссылка на Enterprise Library 5.0 Windows Azure Integration Pack. Этот модуль позволяет задавать правила в соответствии с которыми будет изменяться количество экземпляров ролей в вашем приложении.

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

К сожалению Windows Azure тут не исключение. В блоге Windows Azure Storage описаны scalability targets. Вы можете обнаружить очень интересные сведения о том что максимальное количество сообщений очереди, обрабатываемых в секунду – 500 (по другим сведениям это количество транзакций в секунду). Это очень-очень  мало. И надо не забывать что это предельное значение, на практике его достигнуть будет непросто. Кроме того латентность очереди может достигать 100ms.

Первое что необходимо чтобы избежать высокой латентности на маленьких сообщениях в очереди - установить ServicePointManager.UseNagleAlgorithm значение false.

Следующая проблема – максимальный размер сообщения в очереди – 8KB, так как для передачи используется Base64 кодировка, то реально данных можно передать около 6KB, кстати строки по-умолчанию не кодируются. Добрые люди уже придумали как решать такую проблему: http://msdn.microsoft.com/en-us/library/windowsazure/hh690942(v=VS.103).aspx

Масштабирование воркеров

Как вы думаете что будет если взять “наивную” реализацию воркера, как в первом посте и запустить на Extra Large Instance, насколько быстрее будет работать?

На самом деле вообще не будет быстрее. С этой точки зрения большое количество маленьких воркеров лучше чем один большой. Хотя тоже не лучший вариант по словам представителей Microsoft. С другой стороны куча маленьких воркеров будут пинать Azure Storage гораздо чаще, что несомненно отразится на ценнике. Того же можно добиться если запустить вручную несколько потоков с наивным циклом в воркере, развернутом на Medium instance или более крутой машине.

Чтобы этого избежать надо использовать метод CloudQueue.GetMessages. Пример ниже показывает кусок кода для итератора, который потом обрабатывается Rx.

while (true)
{
    var msgsObs = getMessages(32).ToListObservable();
    yield return msgsObs;
    var msgs = msgsObs[0];

    var hasMessages = false;
    foreach (var msg in msgs)
    {
        hasMessages = true;
        idleCount = 0;

        result.OnNext(msg);
    }

    if (!hasMessages)
    {
        var delay = CalulateDelay(idleCount++, MinimumIdleIntervalMs, MaximumIdleIntervalMs, 100);
        if (delay.TotalMilliseconds >= MaximumIdleIntervalMs)
        {
            yield break;
        }

        yield return Observable.Timer(delay).ToListObservable();
    }
}

Обратите внимание что вызов OnNext должен быть упорядочен, чтобы не возникало Race Condition. Это требование указано в Rx Design Guidelines, и если вы его не читали, то крайне рекомендую это сделать.

Кроме того удаление сообщения из очереди в таком коде возлагается на внешний код.

Пример:

from m in queue.ToObservable(notifications)
from _1 in Observable.Start(() => /*work*/, Scheduler.TaskPool)
from _2 in queue.DeleteMessageAsync(m)
select Unit.Default;

Само это выражение не приводит ни к какому эффекту. Для него надо выполнить Subscribe чтобы запустить вычисления. Тогда будет использоваться TaskPool, который довольно эффективно распределяет вычисления по процессорам. Если вычисления длительные (более 10ms - 100ms), то лучше использовать Scheduler.NewThread. Если же у вас IO-bound код, то лучше будет использовать Scheduler.ThreadPool.

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

Вместо одной очереди вы создаете N очередей. При добавлении сообщения в очередь выбираете случайную. Считываете сразу из всех. Надо как-то разбираться из какой очереди пришло сообщение. Реализация такого нетривиальна и уже есть первый подобный проект на codeplex: http://partitioncloudqueue.codeplex.com/.

Но Rx как всегда рулит и с его помощью очень просто сделать такой partitioning.

На клиенте:

List<CloudQueue> queues = /*...*/;
var observers = queues.Select(q => q.ToObserver(/*notifier*/))
                      .ToList();

var rnd = new Random();
var partitionedObserver = Observer.Create<CloudQueueMessage>(
        m => observers[rnd.Next(observers.Count)].OnNext(m),
        e => observers.ForEach(obs => obs.OnError(e)),
        () => observers.ForEach(obs => obs.OnCompleted())
    );
partitionedObserver.OnNext(new CloudQueueMessage(/*message*/));

На сервере:

IObservable<Unit> ProcessMessages(CloudQueue queue, /*notifier*/, /*scheduler*/)
{
    return from m in queue.ToObservable(/*notifier*/)
           from _1 in Observable.Start(/*action*/, /*scheduler*/)
           from _2 in queue.DeleteMessageAsync(m)
           select Unit.Default;
}

/*.....*/

List<CloudQueue> queues = /*...*/;
queues.Select(q => ProcessMessages(q,/*notifier*/, /*scheduler*/))
      .Merge()
      .Subscribe();

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

Заключение

Все описанные выше способы помогут вам более эффективно реализовывать процессинг в Windows Azure. Для тех кто дочитал до сюда – сюрприз. Весь код с примерами использования есть на codeplex, а также библиотека для работы с очередями доступна в NuGet.



Оптимизация процессинга в Windows Azure. Часть 2.

В первой части я показал сколько стоит использование воркер-ролей и очередей в Windows Azure и что с этим можно сделать.

Довольной хороший подход – адаптировать интервал опроса новых сообщений и отключать опрос в случае их отсутствия продолжительное время. Но после выключения надо как-то включать.

Для этого был создан extension-метод:

public static IObservable<CloudQueueMessage> ToObservable<T>(
                                                        this CloudQueue queue, 
                                                        IObservable<T> haveMoreMessages)

Этот метод возвращает сообщения очереди в виде IObservable коллекции. Включение опроса осуществляется появлением элемента в последовательности haveMoreMessages.

Теперь о том как реализовать последовательность haveMoreMessages.

Самый дешевый вариант взаимодействия между экземплярами ролей это internal wcf communication. Для того чтобы работать с WCF необходимо определить контракты.

[ServiceContract]
public interface IQueueNotifier
{
    [OperationContract(IsOneWay = true)]
    void MessageAdded(string queueName);

    [OperationContract(IsOneWay = true)]
    void NoMoreMessages(string queueName);
}

Контракт содержит всего два метода оповещения о новом сообщении в очереди и об окончании сообщений.

Реализация тоже тривиальна:

public class QueueNotifier : IQueueNotifier
{
    private ISubject<string> moreMessages = new Subject<string>();
    private ISubject<string> queueCompleted = new Subject<string>();

    public IObservable<string> MoreMessages
    {
        get
        {
            return this.moreMessages;
        }
    }

    public IObservable<string> QueueCompleted
    {
        get
        {
            return this.queueCompleted;
        }
    }

    public void MessageAdded(string queueName)
    {
        moreMessages.OnNext(queueName);
    }

    public void NoMoreMessages(string queueName)
    {
        queueCompleted.OnNext(queueName);
    }
}

Далее комбинируя два потока получаем IObservable<Unit> пригодный для метода, описанного в начале поста.

public static IObservable<Unit> GetQueueNotifications(this QueueNotifier service, string queueName)
{
    return Observable.Create<Unit>(obs =>
    {
        var sub1 = service.MoreMessages
                          .Where(q => q == queueName)
                          .Subscribe(q => obs.OnNext(Unit.Default));

        var sub2 = service.QueueCompleted
                          .Where(q => q == queueName)
                          .Subscribe(q => obs.OnCompleted());

        return new CompositeDisposable(sub1, sub2);
    });
}

Теперь захостив QueueNotifier в воркере можно передавать ему оповещения из других ролей.

Клиентская сторона

Чтобы отправлять оповещения нужно создать ChannelFactory<IQueueNotifier> и получить экземпляр прокси на клиенте.

Далее надо получить IObserver:

public static IObserver<Unit> CreateQueueNotifierObserver(this IQueueNotifier proxy, string queueName)
{
    return Observer.Create<Unit>(
                _ => proxy.MessageAdded(queueName),
                _ => proxy.NoMoreMessages(queueName),
                () => proxy.NoMoreMessages(queueName)
            );
}

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

public static IObserver<Unit> CombineObservers(List<IObserver<Unit>> notifiers)
{
    var rnd = new Random();

    return Observer.Create<Unit>(
            u => notifiers[rnd.Next(notifiers.Count)].OnNext(u),
            e => notifiers.ForEach(obs => obs.OnError(e)),
            () => notifiers.ForEach(obs => obs.OnCompleted())
        );
}

Обратите внимание что OnCompleted рассылается всем воркерам, чтобы можно было остановить обработку сообщений.

Остается только скомбинировать отправку сообщения в очередь с отправкой оповещения.

public static IObserver<CloudQueueMessage> ToObserver(this CloudQueue queue, IObserver<Unit> notifier)
{
    var addMessage = Observable.FromAsyncPattern<CloudQueueMessage>(queue.BeginAddMessage, queue.EndAddMessage);

    return Observer.Create<CloudQueueMessage>(
            m => addMessage(m).Subscribe(notifier.OnNext, notifier.OnError),
            notifier.OnError,
            notifier.OnCompleted);
}

Таким образом получается достигнуть того что воркеры не обращаются постоянно к Azure Storage, экономя деньги и ресурсы виртуальных машин. При этом мы получили на клиенте и сервере очень простые интерфейсы, позволяющие тем не менее выполнять сложные действия  с ними.

В следующей части дальнейшая оптимизация, библиотека и пример приложения.