Детали реализации процесса активации Document ID в Sharepoint – часть 2

Это вторая часть серии статей о внутренних деталях реализации процесса активации функциональности “Document ID Service” в Sharepoint, которая добавляет уникальный идентификатор к документам, хранящимся в библиотеках документов сайта, в котором происходит активация. Первая часть доступна по этому адресу. В первой части мы закончили на том, что необходимо подождать как минимум 0,5 часа или изменить системное время прежде, чем запускать задание “Document ID enable/disable”. Давайте продолжим исследование и посмотрим, что происходит дальше.

После активации фичи “Document ID Service”, в настройках сайт коллекции появляется новая ссылка на страницу с настройками Document ID. Если открыть ее сразу после активации, она будет выглядеть следующим образом:

Как было установлено в первой части, теперь мы должны временно изменить системное время: добавим 1 час к текущему времени и перезапустим службу таймера Sharepoint. После этого выбираем задание CA > Monitoring > Job definitions > “Document ID enable/disable” для соответствующего веб-приложения и запускаем его. Если после этого проверить исполняемые задания мы увидим индикатор прогресса задания «Document ID enable/disable» (если бы мы запустили задание, не изменяя системное время, то в истории исполненных заданий мы бы лишь увидели, что задание «Document ID enable/disable» работало 00:00:00, т.е. завершило работу сразу после начала):

После того, как задание завершит свою работу, идем в SQL Management Studio и попробуем выполнить Sql запрос, который мы использовали в первой части. Он не должен вернуть никаких строк, что означает, что рабочий элемент был удален из таблицы WorkItems:

declare @enable uniqueidentifier
set @enable = cast('749FED41-4F86-4277-8ECE-289FBF18884F' as uniqueidentifier)
select * from [dbo].[ScheduledWorkItems] where [Type] = @enable

На этот момент если пойти на страницу настроек Document ID, она будет выглядеть следующим образом:

Т.е. теперь в поле «Begin IDs with the following characters» установлено значение (случайный набор букв и цифр), а также показывается следующее предупреждение:

Configuration of the Document ID feature is scheduled to be completed by an automated process.

Это означает, что процесс активации до сих пор не завершен. Это происходит потому, что задание “Document ID enable/disable” создает другое рабочий элемент с типом A83644F5-78DB-4F95-9A9D-25238862048C. Для того, чтобы проверить это утверждение, исполним следующий Sql запрос:

declare @assign uniqueidentifier
set @assign = cast('A83644F5-78DB-4F95-9A9D-25238862048C' as uniqueidentifier)
select * from [dbo].[ScheduledWorkItems] where [Type] = @assign

Этот рабочий элемент запускается со следующими параметрами, которые указываются в столбце TextPayload таблицы ScheduledWorkItems:

<?xml version="1.0" encoding="utf-16"?>
<DocIdWorkitem xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <fOverwriteExisting>false</fOverwriteExisting>
  <Cookie>1</Cookie>
</DocIdWorkitem>

Этот рабочий элемент обрабатывается заданием «Document ID assignment». Как указано выше, он добавляется заданием «Document ID enable/disable» и устанавливает значения в поле Document ID для всех существующих документов на сайте. Здесь также следует обратить внимание на значение в столбце DeliveryDate. Оно должно быть близким ко времени запуска задания «Document ID enable/disable» на предыдущем шаге. Т.к. мы еще не возвращали системное время назад к правильному значению, для обработки этого элемента нам нужно лишь запустить задание “Document ID assignment” в Central Administration. После этого мы должны увидеть индикатор прогресса этого задания:

и после некоторого времени рабочий элемент будет удален из таблицы ScheduledWorkItems (можете проверить с помощью Sql запроса, который мы использовали выше).

После этого можно вернуть системное время назад и перезапустить службу таймера. Теперь на странице настроек Document ID не должно быть предупреждения. Но поле «Begin IDs with the following characters» по-прежнему будет содержать значение из случайных букв и цифр:

Если вам нужно изменить префикс Document ID на собственное значение, необходимо пройти процесс с начала. После того, как вы нажмете Ok, будет создан рабочий элемент типа 749FED41-4F86-4277-8ECE-289FBF18884F со следующими параметрами:

<?xml version="1.0" encoding="utf-16"?>
<DocIdEnableWorkitem xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <fEnable>true</fEnable>
  <fScheduleAssignment>true</fScheduleAssignment>
  <fOverwriteExisting>false</fOverwriteExisting>
  <fDocsetCtUpdateOnly>false</fDocsetCtUpdateOnly>
  <prefix>TEST</prefix>
</DocIdEnableWorkitem>

(В этом примере используется префикс TEST). А также рабочий элемент типа A83644F5-78DB-4F95-9A9D-25238862048C с параметрами:

<?xml version="1.0" encoding="utf-16"?>
<DocIdWorkitem xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <fOverwriteExisting>false</fOverwriteExisting>
  <Cookie>2</Cookie>
</DocIdWorkitem>

После того, как оба задания будут завершены, для тестирования добавьте любой документ в библиотеку документов. На форме просмотра свойств документа должно быть поле Document ID – ссылка с текстом “TEST-1-1” и адресом http://example.com/_layouts/15/DocIdRedir.aspx?ID=TEST-1-1:

Таким образом, вы можете использовать стандартное поле Document ID в ваших типах содержимого. Этой статьей мы завершаем серию о функциональности Document ID. Надеюсь, что представленная информация поможет в вашей работе.

Рубрика: Document management, Sharepoint | Оставить комментарий

Детали реализации процесса активации Document ID в Sharepoint – часть 1

При активации фичи Document ID Service на уровне сайт коллекции Sharepoint добавляется поле Document ID в стандартный тип содержимого Документ. Основная проблема это то, что активация происходит асинхронно с помощью заданий таймера один раз в день (см. ниже), что вызывает проблемы в разработчиков и администраторов. На Sharepoint Online ускорить процесс проблематично, но в собственном окружении это возможно и в этой статье я покажу как это сделать и какие механизмы при этом используются. Добавим также, что на Sharepoint Online указанная фича доступна только при использовании Enterprise плана.

Прежде чем продолжать читать эту статью я настоятельно рекомендую ознакомится со следующим моим постом (пост на английском): Internal details of SPWorkItemJobDefinition or several reasons of why SPWorkItemJobDefinition doesn’t work. В нем описаны внутренние детали реализации рабочих элементов (work items) в Sharepoint, которые активно используются при активации Document ID Service. Так что мы должны хорошо понимать, как они работают прежде чем продолжить изучение фичи Document ID.

Рабочие элементы, которые непосредственно используются при активации, обрабатываются следующими заданиями таймера:

Название: Document ID enable/disable
Тип: Microsoft.Office.DocumentManagement.Internal.DocIdEnableWorkItemJobDefinition
Тип рабочего элемента: 749FED41-4F86-4277-8ECE-289FBF18884F
Описание: Рабочий элемент, который устанавливает изменения в тип содержимого во всех сайтах при изменении конфигурации Document ID
Расписание по умолчанию: Каждый день 21:30-21:45

Название: Document ID assignment
Тип: Microsoft.Office.DocumentManagement.Internal.DocIdWorkItemJobDefinition
Тип рабочего элемента: A83644F5-78DB-4F95-9A9D-25238862048C
Описание: Рабочий элемент, который присваивает Document ID ко всем элементам в сайт коллекции
Расписание по умолчанию: Каждый день 22:00-22:30

Оба задания устанавливаются при активации фичи DocumentManagement (Id = «3A4CE811-6FE0-4e97-A6AE-675470282CF2») со скопом WebApplication. Первое задание добавляет поля в типы содержимого, второе – устанавливает значение для указанных документов. Если вы не видите эти задания в Central administration > Monitoring > Job definitions, активируйте DocumentManagement через PowerShell с указанием флага force. Также обратите внимание на типы рабочих элементов (749FED41-4F86-4277-8ECE-289FBF18884F и A83644F5-78DB-4F95-9A9D-25238862048C) – они скоро нам понадобятся.

Прежде всего нам нужно активизировать фичу “Document ID Service” в Настройках сайта > Компоненты сайт коллекции (Site settings > Site collection features). Давайте посмотрим, что происходит в обработчике фичи Microsoft.Office.DocumentManagement.DocIdFeatureReceiver во время активации и деактивации:

public override void FeatureActivated(SPFeatureReceiverProperties receiverProperties)
{
    FeatureActivateDeactivate(receiverProperties, true);
}
 
public override void FeatureDeactivating(SPFeatureReceiverProperties receiverProperties)
{
    FeatureActivateDeactivate(receiverProperties, false);
}

Внутренний метод FeatureActivateDeactivate() показан ниже:

private static void FeatureActivateDeactivate(SPFeatureReceiverProperties
receiverProperties, bool fActivate)
{
    SPSite parent = receiverProperties.Feature.Parent as SPSite;
    ULS.SendTraceTag(0x61326d37, ULSCat.msoulscat_DLC_DM, ULSTraceLevel.High,
        "Document ID/FeatureActivateDeactivate: Entering  with fActivate = {0}",
        new object[] { fActivate });
    DocumentId.EnableAssignment(parent, null, fActivate, fActivate, false, false);
    ULS.SendTraceTag(0x61326d38, ULSCat.msoulscat_DLC_DM, ULSTraceLevel.High,
        "Document ID/FeatureActivateDeactivate: Leaving");
}

Давайте теперь посмотрим на метод DocumentId.EnableAssignment():

public static void EnableAssignment(SPSite site, string prefix, bool fEnable,
bool fScheduleAssignment, bool  fOverwriteExistingIds, bool fDocsetCtUpdateOnly)
{
    if (site == null)
    {
        throw new ArgumentNullException("site");
    }
    if ((site.RootWeb == null) || (site.RootWeb.ContentTypes == null))
    {
        throw new ArgumentException("site.RootWeb.ContentTypes");
    }
    if (fEnable && !IsFeatureEnabled(site))
    {
        throw new InvalidOperationException(
"Assignment cannot be enabled with the feature deactivated");
    }
    if (DocIdHelpers.IsSiteTooBig(site))
    {
        DocIdEnableWorkitem.ScheduleWorkitem(site, prefix, fEnable,
            fScheduleAssignment, fOverwriteExistingIds, fDocsetCtUpdateOnly);
    }
    else
    {
        DocIdEnableWorkItemJobDefinition.EnableAssignment(site, prefix, fEnable,
            fScheduleAssignment, fOverwriteExistingIds,  fDocsetCtUpdateOnly);
    }
    new DocIdUiSettings(fEnable, prefix).Save(site);
}

Итак сначала проверяется, является ли текущая сайт коллекция “слишком большой”, при помощи метода DocIdHelpers.IsSiteTooBig(). Если да, сначала создается рабочий элемент для добавления полей в типы содержимого и затем второй рабочий элемент для установки значения в добавленные поля для существующих документов (см. ниже). Если сайт не является “слишком большим”, изменения типов содержимого будут произведены синхронно и затем также будет создан рабочий элемент для заполнения полей существующих документов. Т.е. второй рабочий элемент будет создан в любом случае, но мы вернемся к нему позже.

Теперь давайте посмотрим что значит “сайт слишком большой”. Вот как реализована функция DocIdHelpers.IsSiteTooBig():

public static bool IsSiteTooBig(SPSite site)
{
    return IsSiteTooBig(site, 1, 40, 20);
}
 
public static bool IsSiteTooBig(SPSite site, int maxWebs, int maxListsPerWeb, int maxDoclibsPerWeb)
{
    ...
}

Т.е. сайт является “слишком большим”, когда в нем содержится более 1 под сайта, более 40 списков или более 20 библиотек документов. Если одно из этих условий истинно для вашего сайте, типы содержимого будут изменятся асинхронно с помощью рабочего элемента. Как было показано выше, он создается внутри метода DocIdEnableWorkitem.ScheduleWorkitem():

public static void ScheduleWorkitem(SPSite site, string Prefix, bool Enable, bool ScheduleAssignment, bool OverwriteExisting, bool DocsetCtUpdateOnly)
{
    if (site == null)
    {
        throw new ArgumentNullException("site");
    }
    string url = site.Url;
    ULS.SendTraceTag(0x636e7037, ULSCat.msoulscat_DLC_DM, ULSTraceLevel.Medium,
"Document ID Enable:ScheduleWorkitem for '{0}':enable={1},schedule={2},overwrite={3}",
        new object[] { url, Enable, ScheduleAssignment, OverwriteExisting });
    ULS.SendTraceTag(0x636e7038, ULSCat.msoulscat_DLC_DM, ULSTraceLevel.Medium,
"Document ID Enable: ScheduleWorkitem called for site '{0}' by this stack: {1}",
        new object[] { url, Environment.StackTrace });
    XmlSerializer serializer = new XmlSerializer(typeof(DocIdEnableWorkitem));
    StringWriter writer = new StringWriter(CultureInfo.InvariantCulture);
    DocIdEnableWorkitem o = new DocIdEnableWorkitem();
    o.fEnable = Enable;
    o.fScheduleAssignment = ScheduleAssignment;
    o.fOverwriteExisting = OverwriteExisting;
    o.fDocsetCtUpdateOnly = DocsetCtUpdateOnly;
    o.prefix = Prefix;
    serializer.Serialize((TextWriter) writer, o);
    string strTextPayload = writer.ToString();
    writer.Dispose();
    site.AddWorkItem(Guid.Empty, DateTime.Now.AddMinutes(30.0).ToUniversalTime(), 
DocIdEnableWorkItemJobDefinition.DocIdEnableWIType, site.RootWeb.ID, site.ID, 1,
false, Guid.Empty, Guid.Empty, site.RootWeb.CurrentUser.ID, null, strTextPayload,
Guid.Empty);
    ULS.SendTraceTag(0x636e7039, ULSCat.msoulscat_DLC_DM, ULSTraceLevel.Medium,
"Document ID Enable: ScheduleWorkitem for '{0}' leaving", new object[] { url });
}

Рабочий элемент добавляется в строках 25-28 с помощью вызова метода SPSite.AddWorkItem(). При этом добавляется рабочий элемент с типом 749FED41-4F86-4277-8ECE-289FBF18884F, который обрабатывается заданием “Document ID enable/disable” (см. выше). Этот элемент добавляет поле Document ID в типы содержимого. Более точно, он добавляет 2 поля: Document ID (SPFieldUrlValue) и Document ID Value (string). Эти поля не отображаются ни на странице просмотра деталей стандартного типа содержимого Документ (Настройки сайта > Типы содержимого) ни в списке столбцов в библиотеках Документы. Вы увидите это поле только после того, как добавите какой-либо документ в библиотеку Документы (или любую другую библиотеку, в которой используется стандартный тип содержимого Документ или кастомный тип содержимого, наследующий стандартный Документ) и затем перейдете на форму просмотра свойств документа. Поле Document ID будет содержать значение и будет работать как ссылка, ведущая на http://example.com/_layouts/15/DocIdRedir.aspx?ID={docId}. Эта ссылка является глобальной и уникальной для документа и будет работать даже после перемещения документа, что является большим преимуществом по сравнению с обычным адресом документа.

Если вы прочитали статью, которую я рекомендовал вначале, вы уже знаете, что рабочие элементы хранятся в таблице ScheduledWorkItems контентной базы данных. Для того, чтобы посмотреть их, запустите следующий Sql запрос:

declare @enable uniqueidentifier
set @enable = cast('749FED41-4F86-4277-8ECE-289FBF18884F' as uniqueidentifier)
select * from [dbo].[ScheduledWorkItems] where [Type] = @enable

Запрос должен вернуть 1 строку:

Этот элемент был создан со следующими параметрами, которые указаны в столбце TextPayload:

<?xml version="1.0" encoding="utf-16"?>
<DocIdEnableWorkitem xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <fEnable>true</fEnable>
  <fScheduleAssignment>true</fScheduleAssignment>
  <fOverwriteExisting>false</fOverwriteExisting>
  <fDocsetCtUpdateOnly>false</fDocsetCtUpdateOnly>
</DocIdEnableWorkitem>

Обратите внимание на столбец DeliveryDate. Он содержит самое раннее время в формате UTC (добавьте 3 часа для того, чтобы добавить локальное время), когда задание может быть обработано. Но если вы попробуете запустить задание вручную в Central administration раньше указанного времени, ничего не произойдет. Этот момент является наиболее частой причиной непонимания: администратор пытается запустить задания сразу после активации фичи. Но как мы видели в листинге метода DocIdEnableWorkitem.ScheduleWorkitem() в столбец DeliverDate записывается значение DateTime.Now.AddMinutes(30.0).ToUniversalTime(). Это означает, что мы должны подождать хотя бы 30 минут (либо изменить системное время), перезапустить службу таймера, запустить задание и затем изменить системное время назад и перезапустить таймер снова (более точно, перед тем как вернуть системное время назад, нужно также запустить задание “Document ID assignment”, как раз после того, как задание “Document ID enable/disable” будет завершено – см. вторую часть для подробностей).

Причина такого поведения в хранимой процедуре [dbo].[proc_GetRunnableWorkItems]:

CREATE PROCEDURE [dbo].[proc_GetRunnableWorkItems] (
        @ProcessingId          uniqueidentifier,
        @SiteId                uniqueidentifier,
        @WorkItemType          uniqueidentifier,
        @BatchId               uniqueidentifier,
        @MaxFetchSize          int = 1000,
        @ThrottleThreshold     int = 0,
        @RequestGuid           uniqueidentifier = NULL OUTPUT     
        )
AS
    SET NOCOUNT ON
    IF (dbo.fn_IsOverQuotaOrWriteLocked(@SiteId) >= 1)
    BEGIN
        RETURN 0
    END
    DECLARE @iRet int
    SET @iRet = 0
    DECLARE @oldTranCount int
    SET @oldTranCount = @@TRANCOUNT
    DECLARE @Now datetime
    SET @Now = dbo.fn_RoundDateToNearestSecond(GETUTCDATE())
    DECLARE @InProgressCount int
    DECLARE @ThrottledFetch int
    DECLARE @ReturnWorkItems bit
    SET @ReturnWorkItems = 0
    BEGIN TRAN
    SET @InProgressCount = 0
    SET @ThrottledFetch = 0
    SET @ThrottleThreshold = @ThrottleThreshold + 1
    IF @ThrottleThreshold > 1
    BEGIN
        SET ROWCOUNT @ThrottleThreshold
        SELECT 
            @InProgressCount = COUNT(DISTINCT BatchId)
        FROM
            dbo.ScheduledWorkItems WITH (NOLOCK)
        WHERE
            Type = @WorkItemType AND
            DeliveryDate <= @Now AND
            (InternalState & (1 | 16)) = (1 | 16)
    END
    IF @BatchId IS NOT NULL
    BEGIN
        SET @ThrottledFetch = 16
    END
    IF @InProgressCount < @ThrottleThreshold
    BEGIN
        UPDATE
            dbo.ScheduledWorkItems
        SET
            InternalState = InternalState | 1 | @ThrottledFetch,
            ProcessingId = @ProcessingId
        FROM
            (
            SELECT TOP(@MaxFetchSize)
                Id
            FROM
                dbo.ScheduledWorkItems
            WHERE
                Type = @WorkItemType AND
                DeliveryDate <= @Now AND
                (@SiteId IS NULL OR 
                    SiteId = @SiteId) AND
                (@BatchId IS NULL OR
                    BatchId = @BatchId) AND
                (InternalState & ((1 | 2))) = 0
            ORDER BY
              DeliveryDate
            ) AS work
        WHERE
            ScheduledWorkItems.Id = work.Id
        SET @InProgressCount = @@ROWCOUNT
        SET ROWCOUNT 0            
        IF @InProgressCount <> 0
        BEGIN
          EXEC @iRet = proc_AddFailOver @ProcessingId, NULL, NULL, 20, 0
        END
        SET @ReturnWorkItems = 1
    END
CLEANUP:
        SET ROWCOUNT 0
        IF @iRet <> 0
        BEGIN
            IF @@TRANCOUNT = @oldTranCount + 1
            BEGIN
                ROLLBACK TRAN
            END
        END
        ELSE
        BEGIN
            COMMIT TRAN
            IF @InProgressCount <> 0
               AND @InProgressCount <> @MaxFetchSize 
               AND @WorkItemType = 'BDEADF09-C265-11d0-BCED-00A0C90AB50F'
               AND @BatchId IS NOT NULL AND @SiteId IS NOT NULL
            BEGIN
                UPDATE
                    dbo.Workflow
                SET
                    InternalState = InternalState & ~(1024)
                WHERE
                    SiteId = @SiteId AND
                    Id = @BatchId    
            END            
            IF @ReturnWorkItems = 1
            BEGIN
                SELECT ALL
DeliveryDate, Type, ProcessMachineId as SubType, Id,
SiteId, ParentId, ItemId, BatchId, ItemGuid, WebId, UserId, Created,
BinaryPayload, TextPayload,
                    InternalState
                FROM
                    dbo.ScheduledWorkItems
                WHERE
                    Type = @WorkItemType AND
                    DeliveryDate <= @Now AND
                    ProcessingId = @ProcessingId
                ORDER BY
                    DeliveryDate
                IF @@ROWCOUNT <> 0
                BEGIN
                    EXEC @iRet = proc_UpdateFailOver @ProcessingId, NULL, 20
                END
            END
        END
        RETURN @iRet
 
GO

Как видите процедура возвращает элементы, для которых выполняется условие DeliveryDate <= @Now. Вот почему нужно изменять системное время для того, чтобы рабочие элементы были обработаны.

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

Рубрика: Document management, Sharepoint | Оставить комментарий

Релиз Camlex 3.6 и Camlex.Client 1.5

Некоторое время назад вышел очередной релиз версии 3.6 библиотеки с открытым исходным кодом Camlex (а также параллельно вышла версия Camlex.Client 1.5 для управляемых клиентских приложений). Для тех, кто еще не слышал об этом проекте, это библиотека, которая помогает составлять динамические CAML запросы (т.е. запросы, построенные во время исполнения, на основе каких-либо параметров), используя лямбда-выражения C# и fluent-интерфейсы. Множество самых разных примеров использования библиотеки можно найти здесь. Также в моем блоге вы можете найти другие статьи, связанные с проектом.

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

string caml = Camlex.Query().Where(x => (bool)x["Foo"] == true).ToString();

что в свою очередь производит следующий CAML запрос:

<Where>
  <Eq>
    <FieldRef Name="Foo" />
    <Value Type="Boolean">1</Value>
  </Eq>
</Where>

то теперь их можно написать в более простом и коротком виде без явного сравнения с true или false:

string caml = Camlex.Query().Where(x => (bool)x["Foo"]).ToString();

Эта запись короче и не вызовет проблем с ReSharper-ом, который может посоветовать удалить ненужное на его взгляд явное сравнение (в предыдущей версии библиотеки если бы вы последовали этому совету, это вызвало бы исключение во время исполнения).

Для того чтобы получить CAML запрос, в котором используется сравнение с false, используйте оператор НЕ (!):

string caml = Camlex.Query().Where(x => !(bool)x["Foo"]).ToString();

который произведет:

<Where>
  <Eq>
    <FieldRef Name="Foo" />
    <Value Type="Boolean">0</Value>
  </Eq>
</Where>

Такой же упрощенный синтаксис доступен в сложных запросах, использующих операции сравнения && и ||:

string caml = Camlex.Query().Where(x => ((bool)x["foo1"] && !(bool)x["foo2"]) ||
    (bool)x["foo3"]).ToString();

что даст нам следующий запрос:

<Where>
  <Or>
    <And>
      <Eq>
        <FieldRef Name="Foo1" />
        <Value Type="Boolean">1</Value>
      </Eq>
      <Eq>
        <FieldRef Name="Foo2" />
        <Value Type="Boolean">0</Value>
      </Eq>
    </And>
    <Eq>
      <FieldRef Name="Foo3" />
      <Value Type="Boolean">1</Value>
    </Eq>
  </Or>
</Where>

Также соответствующее изменение было добавлено в reverse engeneering функционал Camlex-а, который позволяет получить C# представление CAML запроса с использованием Camlex, что является обратным преобразованием по сравнению с примерами, показанными выше. Т.е. если ввести например CAML запрос из последнего примера в бесплатный сервис Camlex online, то вы получите также короткую версию преобразования булевого выражения без явного сравнения с true или false. Сервис Camlex online был создан специально для упрощения использования библиотеки Camlex.Net разработчиками, работавшими до этого с обычными строковыми CAML запросами.

Новую версию можно скачать с сайта проекта на Codeplex либо с из NuGet с помощью следующих команд:

Install-Package Camlex.NET.dll
Install-Package Camlex.Client.dll

Если у вас есть комментарии или идеи для будущих релизов, не стесняйтесь писать их на страницу обсуждений на сайте проекта. Также можете создавать свои fork-и и присылать мне pull-запросы. По мере нахождения свободного времени я их просматриваю и, если целесообразно, включаю в проект.

Рубрика: CAML, Camlex.NET, Sharepoint | Оставить комментарий

Восстанавливаем бэкап сайт коллекции с более поздней версии Sharepoint

Иногда на практике возникает ситуация, когда баг воспроизводится только на продакшене, а не на QA или локальном окружении разработки. Такие проблемы сложнее исследовать. Часто они возникают из-за контента, который добавляется только в продакшен. И если у вас нет доступа к нему (напр. если поддержкой инфраструктуры занимается третья компания), вам потребуется запросить бэкап контентной базы данных или сайт коллекции, восстановить его локально и попробовать воспроизвести проблему. Но что делать, если у вас локально установлена более ранняя версия Sharepoint, чем на продакшене? Конечно, лучше иметь такую же версию (имеется ввиду соответствии в установленных кумулятивных обновлений в пределах одной основной версии 14 или 15). В этой статье я покажу трюк, как можно восстановить бэкап с более поздней версии. Прежде чем продолжить, хочу предупредить, что по сути это хак, и нет гарантий того, что в вашем конкретном случае он сработает. В зависимости от версии схема данных Sharepoint может измениться, поэтому стандартные механизмы не поддерживают этот способ. Хотя на практике в большинстве случаев он должен работать.

Хорошо, предположим, что у нас есть бэкап сайт коллекции с продакшена, полученный с помощью командлета Backup-SPSite:

Backup-SPSite http://example1.com -Path C:\Backup\example1.bak

Мы копируем его на локальное окружение и пытаемся восстановить с помощью Restore-SPSite:

Restore-SPSite http://example2.com -Path C:\Backup\example1.bak –Confirm:$false

(я специально использовал разные урлы для source и target сайт коллекций, чтобы показать, что с помощью указанных командлетов можно восстанавливать бэкап в другую сайт коллекцию, т.е. необязательно настраивать идентичное окружение, изменяя hosts файл или local dns). Если у нас установлена более ранняя версия Sharepoint, команда завершится с ошибкой, показав непонятное <nativehr> исключение, которое ничего не скажет о причинах ошибки. Но если переключить мониторинг в режим Verbose, то мы обнаружим следующее сообщение в логе:

Could not deserialize site from C:\Backup\example1.bak. Microsoft.SharePoint.SPException: Schema version of backup 15.0.4505.1005 does not match current schema version 15.0.4420.1017.

(конкретные номера версий не важны. Для данной статьи важно лишь, что версия Sharepoint на текущем окружении 15.0.4420.1017 меньше, чем версия, с которой мы взяли бэкап 15.0.4505.1005).

Что можно сделать в этом случае? Mount-SPContentDatabase не сработает по той же причине, т.е. бэкап контентной базы не поможет. Мы можем либо обновить локальное окружение (и вы должны рассматривать этот вариант как основной), либо пойти по нестандартному пути. Для последнего нам понадобится hex-редактор. Сначала я предположил, что бэкап сайт коллекции, выполненный показанным выше способом, является cab-файлом, который можно будет распаковать, подредактировать текстовые файлы внутри и запаковать обратно (подобный трюк я описывал в статье Сохранение идентичности объектов при экспорте и импорте сайтов в Sharepoint в различные позиции в топологии сайт коллекции). Но это оказалось не так в случае с бэкапом сайт коллекции – они похожи на обычные бинарные файлы. Поэтому для работы с ним нам понадобится hex-редактор. Я использовал HxD, но вы можете использовать любой на ваш вкус.

Если мы откроем файл бэкапа и попытаемся найти номер версии, который мы получили из сообщения в логе, мы обнаружим, что она указана в одном месте в начале файла:

image

Нужно изменить номер версии на тот, который используется на продакшене:

image

Сохраняем файл и запускаем Restore-SPSite снова. Теперь восстановление должно пройти успешно.

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

Рубрика: Content deployment, Sharepoint | Оставить комментарий

Проблема с обрезанными заголовками на странице результатов поиска в Sharepoint 2013

Во время разработки search-driven сайта на Sharepoint 2013 мы столкнулись со следующей проблемой: для некоторых страниц заголовки были обрезаны в результатах поиска:

image

Т.е. для большинства страниц заголовки отображались нормально, но для некоторых они обрезались, как показано на примере выше (“Find the right solution” –> “he right”, “We as business partner” –> “as busine”, …). Эти заголовки не совсем такие, как в реальном сайте, но они хорошо иллюстрируют проблему. При этом не было очевидного общего признака для страниц, заголовки которых обрезались: для некоторых страниц отображалось несколько полных слов из заголовка, для других слова были обрезаны и ключевые слова, использованные для поиска не показывались.

Исследование показало, что проблема в стандартном шаблоне отображения (display template) Item_CommonItem_Body.html. Заголовок отображается с помощью следующего кода:

var title = Srch.U.getHighlightedProperty(id, ctx.CurrentItem, "Title");
...
var titleHtml = String.format(
'<a clicktype="{0}" id="{1}" href="{2}" class="ms-srch-item-link" ' +
'title="{3}" onfocus="{4}" {5}>{6}</a>',
$htmlEncode(clickType), $htmlEncode(id + Srch.U.Ids.titleLink), $urlHtmlEncode(url),
$htmlEncode(ctx.CurrentItem.Title), showHoverPanelCallback, appAttribs,
Srch.U.trimTitle(title, maxTitleLengthInChars, termsToUse));
...
<div id="_#= $htmlEncode(id + Srch.U.Ids.title) =#_" class="ms-srch-item-title"> 
  <h3 class="ms-srch-ellipsis">
    _#= titleHtml =#_
  </h3>
</div>

Т.е. сначала получаем выделенные заголовки с помощью метода Srch.U.getHighlightedProperty() (строка 1), и затем делаем тримминг для добавления в ссылку с помощью метода Srch.U.trimTitle() (строка 8). Сначала я подумал, что метод trimTitle работает неправильно по какой-то причине. Он, как и метод getHighlightedProperty, определен в файле Search.ClientControls.js, расположенном в папке /15/Template/Layouts. Я добавил в Item_CommonItem_Body.html временные трейсы, которые показали, что обрезанные заголовки возвращаются из getHighlightedProperty. Вот код этого метода:

Srch.U.getHighlightedProperty =
function Srch_U$getHighlightedProperty(key, result, property) {
    var $v_0 = null;
    if (!Srch.U.e(key)) {
        if (!((key) in Srch.ScriptApplicationManager.get_current().$23_1)) {
            Srch.ScriptApplicationManager.get_current().$23_1[key] =
                Srch.U.$5H(result['HitHighlightedProperties']);
        }
        var $v_1 = Srch.ScriptApplicationManager.get_current().$23_1[key];
        if (!Srch.U.n($v_1)) {
            if (property === 'Title') {
                property = 'HHTitle';
            }
            else if (property === 'Path') {
                property = 'HHUrl';
            }
            else {
                property = property.toLowerCase();
            }
            $v_0 = $v_1[property];
        }
    }
    return $v_0;
}

Параметр key содержит идентификатор элемента, который отображается в результатах поиска, result – ctx.CurrentItem, и property в нашем случае содержит “Title”. Исследование показало, что он доходит до строки 12 (property = ‘HHTitle’) и затем читает свойство “HHTitle” из объекта, который возвращается из следующего вызова: Srch.U.$5H(result[‘HitHighlightedProperties’]) (строки 6-7). Т.е. проблема вызвана неправильным вычислением выделенных заголовков, записанных в ctx.CurrentItem[‘HitHighlightedProperties’].

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

'<a clicktype="{0}" id="{1}" href="{2}" class="ms-srch-item-link" ' +
'title="{3}" onfocus="{4}" {5}>{3}</a>',
$htmlEncode(clickType), $htmlEncode(id + Srch.U.Ids.titleLink), $urlHtmlEncode(url),
$htmlEncode(ctx.CurrentItem.Title), showHoverPanelCallback, appAttribs);

Т.е. использовать $htmlEncode(ctx.CurrentItem.Title), который возвращает исходный неизмененный заголовок, вместо выделенного. После этого надо указать этот шаблон отображения в свойствах веб части ResultScriptWebPart, которая отображает результаты поиска. Но с этим решением ключевые слова, использованные для поиска, не будут выделяться в заголовках.

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

Рубрика: Search, Sharepoint | Оставить комментарий

Поддержка операции IN в Camlex 3.5 и Camlex.Client 1.3

Этим летом я выпустил новые версии проектов с открытым исходным кодом: Camlex и Camlex.Client (на момент написания этой статьи, был выпущен еще один релиз для клиентской версии Camlex-а 1.4, однако здесь речь пойдет об изменениях, добавленных в версии 1.3. Версия основного Camlex-а не изменилась, т.е. мы будем говорить о Camlex 3.5). Сначала я покажу несколько примеров использования, а затем скажу несколько слов об использовании Camlex в разных версиях Sharepoint.

Что такое операция IN? Вот что говорит msdn по этому поводу:

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

Например, если нам надо получить список элементов, у которых целочисленное поле содержит значение в интервале [0..9], то мы можем использовать следующий запрос:

<br>&lt;Where&gt;<br>&lt;In&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Values&gt;<br>&lt;Value Type="Integer"&gt;0&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;1&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;2&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;3&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;4&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;5&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;6&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;7&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;8&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;9&lt;/Value&gt;<br>&lt;/Values&gt;<br>&lt;/In&gt;<br>&lt;/Where&gt;<br>

В предыдущих версиях Camlex для получения такого же результата можно было скомбинировать несколько Eq (==) условий с помощью операции Or. Camlex специально проектировался для выполнения таких задач, для упрощения создания динамических CAML запросов, так что решить задачу можно очень просто одной строкой:

<br>string caml = Camlex.Query().WhereAny(<br>Enumerable.Range(0, 9).Select&lt;int, Expression&lt;Func&lt;SPListItem, bool&gt;&gt;&gt;(<br>i =&gt; x =&gt; (int) x["Count"] == i)).ToString();<br>

Давайте посмотрим на результат выполнения этого кода:

<br>&lt;Where&gt;<br>&lt;Or&gt;<br>&lt;Or&gt;<br>&lt;Or&gt;<br>&lt;Or&gt;<br>&lt;Or&gt;<br>&lt;Or&gt;<br>&lt;Or&gt;<br>&lt;Or&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;0&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;1&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;/Or&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;2&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;/Or&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;3&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;/Or&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;4&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;/Or&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;5&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;/Or&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;6&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;/Or&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;7&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;/Or&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;8&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;/Or&gt;<br>&lt;/Where&gt;<br>

Сравнивая с первым примером, выглядит не очень красиво. Интервал значения может быть и больше, в этом случае запрос будет еще более развернутым. Однако теперь, с добавлением поддержки операции IN, можно написать запрос, используя следующий синтаксис:

<br>string caml =<br>Camlex.Query().Where(x =&gt; Enumerable.Range(0, 9).Contains((int)x["Count"]))<br>.ToString();<br>

Он создаст CAML запрос, который мы уже видели:

<br>&lt;Where&gt;<br>&lt;In&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Values&gt;<br>&lt;Value Type="Integer"&gt;0&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;1&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;2&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;3&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;4&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;5&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;6&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;7&lt;/Value&gt;<br>&lt;Value Type="Integer"&gt;8&lt;/Value&gt;<br>&lt;/Values&gt;<br>&lt;/In&gt;<br>&lt;/Where&gt;<br>

Для тех, кто работал с NHibernate или Linq 2 Sql, такой синтаксис не будет новым: эти фреймворки используют похожий прием для генерации Sql запросов с операцией IN. Я специально использовал пример с динамически наполняемым массивом значений (Enumerable.Range(0, 9)), т.к. хотел показать, что код будет работать с любым выражением или функцией, возвращающей IEnumerable:

<br>var caml = Camlex.Query().Where(x =&gt; getArray().Contains((int)x["Count"])).ToString();<br>...<br>List&lt;int&gt; getArray()<br>{<br>var list = new List&lt;int&gt;();<br>for (int i = 0; i &lt; 10; i++)<br>{<br>list.Add(i);<br>}<br>return list;<br>}<br>

(Вы видите разницу между последним примером, в котором функция возвращает List<int>, и предыдущим примером, который использует IEnumerable? Если да, то вы хорошо знаете C# 🙂 Класс List<T> имеет свой Contains метод, тогда как для IEnumerable<int> из первого примера, используется метод расширения. Как видно, Camlex может работать с обоими. Если вы используете Linq, не забудьте добавить “using System.Linq;” в начало вашего cs файла).

Т.е. основной синтаксис для новой функциональности следующий:

<br>var caml = Camlex.Query().Where(x =&gt;<br>enumerable.Contains((Type)x["FieldTitle"])).ToString();<br>

Конечно, также вы можете использовать массив с константами:

<br>string c = Camlex.Query().Where(x =&gt; new[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}<br>.Contains((int) x["Count"])).ToString();<br>

Также он будет работать с любыми типами для элемента Value:

<br>string c = Camlex.Query().Where(x =&gt; new[]<br>{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"}<br>.Contains((string) x["Title"])).ToString();<br>

Этот пример создаст следующий CAML:

<br>&lt;Where&gt;<br>&lt;In&gt;<br>&lt;FieldRef Name="Title" /&gt;<br>&lt;Values&gt;<br>&lt;Value Type="Text"&gt;zero&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;one&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;two&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;three&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;four&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;five&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;six&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;seven&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;eight&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;nine&lt;/Value&gt;<br>&lt;/Values&gt;<br>&lt;/In&gt;<br>&lt;/Where&gt;<br>

Как видите, теперь он использует Text тип в элементах Value.

Это была только половина истории. Если вы следите за Camlex-ом, то возможно помните, что начиная с версии 3.0 он стал двунаправленным (см. здесь: Camlex.NET became bidirectional and goes online. Version 3.0 is released). Так что преобразования были добавлены также в обе стороны: из дерева выражений (expression tree) в CAML и из CAML в дерево выражений. Т.е. можно взять CAML запрос в строковой переменной, и получить дерево выражений из него:

<br>var xml =<br>"&lt;Query&gt;" +<br>" &lt;Where&gt;" +<br>" &lt;In&gt;" +<br>" &lt;FieldRef Name=\"Title\" /&gt;" +<br>" &lt;Values&gt;" +<br>" &lt;Value Type=\"Text\"&gt;zero&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;one&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;two&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;three&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;four&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;five&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;six&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;seven&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;eight&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;nine&lt;/Value&gt;" +<br>" &lt;/Values&gt;" +<br>" &lt;/In&gt;" +<br>" &lt;/Where&gt;" +<br>"&lt;/Query&gt;";<br><br>var expr = Camlex.QueryFromString(xml).ToExpression();<br>

Эта возможность дает много интересных приложений:

1. на http://camlex-online.org/ добавьте xml, показанный выше в поле ввода и нажмите Convert to C#. Он покажет вам, как такой запрос можно написать на Camlex-е:

<br>Camlex.Query().Where(x =&gt; new[] {<br>"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine" }<br>.Contains((string)x["Title"]))<br>

2. возможность создавать смешанные запросы, т.е. добавлять новые условия в существующий запрос в строковой переменной, с помощью выражений и лямбд:

<br>var xml =<br>"&lt;Query&gt;" +<br>" &lt;Where&gt;" +<br>" &lt;In&gt;" +<br>" &lt;FieldRef Name=\"Title\" /&gt;" +<br>" &lt;Values&gt;" +<br>" &lt;Value Type=\"Text\"&gt;one&lt;/Value&gt;" +<br>" &lt;Value Type=\"Text\"&gt;two&lt;/Value&gt;" +<br>" &lt;/Values&gt;" +<br>" &lt;/In&gt;" +<br>" &lt;/Where&gt;" +<br>"&lt;/Query&gt;";<br><br>string caml = Camlex.Query().WhereAll(xml, x =&gt; (int)x["Count"] == 1).ToString();<br>

произведет следующий CAML:

<br>&lt;Where&gt;<br>&lt;And&gt;<br>&lt;Eq&gt;<br>&lt;FieldRef Name="Count" /&gt;<br>&lt;Value Type="Integer"&gt;1&lt;/Value&gt;<br>&lt;/Eq&gt;<br>&lt;In&gt;<br>&lt;FieldRef Name="Title" /&gt;<br>&lt;Values&gt;<br>&lt;Value Type="Text"&gt;one&lt;/Value&gt;<br>&lt;Value Type="Text"&gt;two&lt;/Value&gt;<br>&lt;/Values&gt;<br>&lt;/In&gt;<br>&lt;/And&gt;<br>&lt;/Where&gt;<br>

Кроме комбинирования строковых запросов с запросами на лямбдах, Camlex может соединять несколько строковых запросов с разными условиями в один результирующий запрос.

Это была часть с примерами. Теперь несколько слов про использования Camlex в различных версиях Sharepoint. Изначально Camlex был создан для Sharepoint 2007 и он использует ссылку на Microsoft.SharePoint.dll версии 12.0.0.0 (он использует ссылку только для компиляции. Camlex не вызывает никаких методов Sharepoint API). Благодаря автоматическому перенаправлению биндинга сборок в Sharepoint (assembly binding redirect, про который я писал здесь: Assembly binding redirect in Sharepoint 2010: how old code for SP 2007 works in SP 2010) он также работает в версиях 2010 и 2013. Операция IN была добавлена в схему CAML в Sharepoint 2010, т.е. запросы с ней не будут работать в Sharepoint 2007. Так что перед релизом версии 3.5 мне нужно было сделать выбор между несколькими путями дальнейшего развития Camlex:

1. добавить новую функциональность в существующую ветку Camlex и продолжать использовать Microsoft.SharePoint.dll версии 12.0.0.0. Преимущество: одна сборка работает со всеми версиями Sharepoint, что проще для разработчиков. Недостаток: элемент IN можно будет также создать в Sharepoint 2007, но по факту такой запрос не будет работать.

2. создать отдельную ветку для Sharepoin 2007 и оставить ссылку на версию 12.0.0.0 только в ней, в то время, как в основной ветке изменить ее на 14.0.0.0, так что ее можно будет использовать только начиная с Sharepoint 2010, где операция IN поддерживается. Преимущества и недостатки подхода, такие же как в 1-м подходе, только в обратном направлении.

Также во втором подходе было бы необходимо поддерживать еще одну ветку (сейчас я поддерживаю 2 ветки: default для основного Camlex и client для клиентской версии. Операция IN добавлена в Camlex.Client в версии 1.3). Так как на текущий момент я единственный активный contributor проекта и время (хотя все чаще приходят pull request-ы, за что большое спасибо сообществу), которое я могу тратить на проект, очень ограничено, я выбрал первый путь (по крайней мере пока). В будущем я не исключаю возможности создать различные ветки, но пока будет одна ветка, из которой будет собираться Camlex для всех версий Sharepoint-а.

С обновлением сборок на Codeplex-е, я также обновил пакеты nuget. Вы можете добавить последнюю версию с помощью следующих команд:

Install-Package Camlex.NET.dll

и для клиентской версии:

Install-Package Camlex.Client.dll

В следующих релизах я планирую добавить оставшиеся новые элементы, добавленные в схему CAML с Sharepoint 2010 (Include, NotInclude) и Join-ы (которые тоже конечно надо добавить наконец).

Рубрика: CAML, Camlex.NET, Sharepoint | 11 комментариев

Как получить адрес текущей сайт коллекции и другие серверные свойства на клиентской стороне в Sharepoint

Что вы будете делать, если вам понадобится получить адрес текущей сайт коллекции в javascript? Как вы понимаете, location.href для этого использовать нельзя, т.к. имея только адрес текущей страницы, мы не сможем сказать, какая его часть относится к сайту, какая к сайт коллекции (для этого в Sharepoint определены managed paths). Если есть возможность внедрить серверный код, то эта задача может быть решена добавлением следующего кода напрямую в aspx, ascx или master файл:

<script type="text/javascript"> 
var siteCollUrl = '<%= SPContext.Current.Site.Url %>';
</script>

После рендеринга страницы вы получите инициализированную переменную siteCollUrl, в которой будет установлен адрес сайт коллекции. Далее вы сможете использовать ее в javascript коде на странице. Но что если у вас нет доступа к серверному коду? Например, если вы создаете кастомный шаблон отображения (display template) для поиска и единственные доступные инструменты, которые вы можете использовать, это html, css и javascript.

Один из способов – это использовать клиентскую объектную модель, в частности объект ClientContext. В этом случае показанный выше код будет переписан следующим образом:

var siteCollUrl = "";
SP.SOD.executeFunc("SP.js", "SP.ClientContext", function()
{
    var clientContext = new SP.ClientContext.get_current();
    var site = clientContext.get_site();
    clientContext.load(site);
    clientContext.executeQueryAsync(Function.createDelegate(this, function(){
        siteCollUrl = site.get_url();
    }))
});

Прежде всего весь код обернут в SP.SOD.executeFunc() для того, чтобы убедиться, что скрипт с SP.ClientContext загружен и готов к использованию на момент выполнения нашего кода (строка). В этом примере мы получаем объект javascript-аналог серверного класса SPSite, который представляет сайт коллекцию (строка), и затем инициализируем его свойства с помощью вызовов методов SP.ClientContext.load() и SP.ClientContext.executeQueryAsync(). Это в свою очередь приводит к асинхронному вызову Sharepoint end-point-а на серверной стороне (строки).

Этот метод работает, но выглядит сложно. Есть ли более простой способ получить серверные свойства на клиентской стороне? Такие способы есть, и один из них объект _spPageContextInfo. Если вы проверите html код вашей страницы на Sharepoint сайте, вы обнаружите эту переменную, инициализированную следующим образом (предполагаем, что вы находитесь на сайт коллекции с адресом http://example.com/test):

var _spPageContextInfo = {
    webServerRelativeUrl: "\u002ftest",
    webAbsoluteUrl: "http:\u002f\u002fexample\u002ftest",
    siteAbsoluteUrl: "http:\u002f\u002fexample.com\u002ftest",
    serverRequestPath: "\u002f_layouts\u002f15\u002fosssearchresults.aspx",
    layoutsUrl: "_layouts\u002f15",
    webTitle: "Home",
    webTemplate: "53",
    tenantAppVersion: "0",
    webLogoUrl: "_layouts\u002f15\u002fimages\u002fsiteicon.png",
    webLanguage: 1033,
    currentLanguage: 1033,
    currentUICultureName: "en-US",
    currentCultureName: "en-US",
    clientServerTimeDelta: new Date("2013-06-07T19:07:01.3494510Z") - new Date(),
    siteClientTag: "40$$15.0.4420.1017",
    crossDomainPhotosEnabled: false,
    webUIVersion: 15,
    webPermMasks: {
        High: 2147483647,
        Low: 4294967295
    },
    pagePersonalizationScope: 1,
    userId: 1,
    systemUserKey: "i:0\u0029.w|s-1-5-21-...",
    alertsEnabled: false,
    siteServerRelativeUrl: "\u002ftest",
    allowSilverlightPrompt: 'True'
};

Как видно из примера, _spPageContextInfo содержит много полезных свойств таких, как адреса сайта и сайт коллекции, информация о культуре и даже маски разрешений (permission mask). С объектом _spPageContextInfo наш код будет переписан следующим образом:

<script type="text/javascript">
var siteCollUrl = _spPageContextInfo.siteAbsoluteUrl;
</script>

Последний пример выглядит гораздо более привлекательнее, чем с ClientContext. Однако остается вопрос, как именно _spPageContextInfo добавляется на страницу, и где он доступен, а где – нет. Для ответа на этот вопрос необходимо изучить код сборок Sharepoint-а в рефлекторе. Объект _spPageContextInfo регистрируется в приватном методе SharePointClientJs_Register() класса ScriptLink (я решил показать код всего метода здесь, чтобы было понятно, как именно устанавливаются javascript свойства с помощью серверного кода):

private static void SharePointClientJs_Register(Page page)
{
    if (((SPContext.Current != null) && !SPPageContentManager.IsStartupScriptRegistered(page,
typeof(ScriptLink), "pagecontextinfo")) && !SPRibbon.ToolbarRibbonAdapterDataRenderingOnly)
    {
        SPWeb web = SPContext.Current.Web;
        SPSite site = SPContext.Current.Site;
        string scriptLiteralToEncode = string.IsNullOrEmpty(web.SiteLogoUrl) ?
(SPUtility.GetLayoutsFolder(web) + "/images/siteicon.png") : web.SiteLogoUrl;
        StringBuilder sb = new StringBuilder();
        if (web != null)
        {
            PersonalizationScope shared;
            sb.Append("var _fV4UI=");
            sb.Append((web.UIVersion > 3) ? "true;" : "false;");
            sb.Append("var _spPageContextInfo = {webServerRelativeUrl: \"");
            sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(web.ServerRelativeUrl));
            sb.Append("\", webAbsoluteUrl: \"");
            sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(web.Url));
            sb.Append("\", siteAbsoluteUrl: \"");
            sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(web.Site.Url));
            sb.Append("\", serverRequestPath: \"");
            sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(HttpContext.Current.Request.Path));
            sb.Append("\", layoutsUrl: \"");
            sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(SPUtility.ContextLayoutsFolder));
            sb.Append("\", webTitle: \"");
            if (web.DoesUserHavePermissions(SPBasePermissions.EmptyMask | SPBasePermissions.Open))
            {
                sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(web.Title));
            }
            else
            {
                sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(""));
            }
            sb.Append("\", webTemplate: \"");
            if (web.DoesUserHavePermissions(SPBasePermissions.EmptyMask | SPBasePermissions.Open))
            {
                sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(
web.WebTemplateId.ToString(CultureInfo.InvariantCulture)));
            }
            else
            {
                sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(""));
            }
            sb.Append("\", tenantAppVersion: \"");
            sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(
SPTenantAppUtils.GetTenantAppEtag(web)));
            sb.Append("\", webLogoUrl: \"");
            sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(scriptLiteralToEncode));
            sb.Append("\", webLanguage: ");
            sb.Append(web.Language);
            sb.Append(", currentLanguage: ");
            sb.Append(Thread.CurrentThread.CurrentUICulture.LCID);
            sb.Append(", currentUICultureName: \"");
            sb.Append(Thread.CurrentThread.CurrentUICulture.Name);
            sb.Append("\", currentCultureName: \"");
            sb.Append(Thread.CurrentThread.CurrentCulture.Name);
            sb.Append("\", clientServerTimeDelta: new Date(\"");
            sb.Append(DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture));
            sb.Append("\") - new Date()");
            sb.Append(", siteClientTag: \"");
            sb.Append(web.SiteClientTag.ToString(CultureInfo.InvariantCulture));
            sb.Append("\", crossDomainPhotosEnabled:");
            sb.Append(site.WebApplication.CrossDomainPhotosEnabled ? "true" : "false");
            sb.Append(", webUIVersion:");
            sb.Append(web.UIVersion.ToString(CultureInfo.InvariantCulture));
            sb.Append(", webPermMasks:");
            sb.Append(SPUtility.BasePermissionsToJson(web.EffectiveBasePermissions));
            if (SPContext.Current.ListId != Guid.Empty)
            {
                sb.AppendFormat(",pageListId:\"{0}\"", SPContext.Current.ListId.ToString("B"));
            }
            if (SPContext.IsPageDoclibItem)
            {
                sb.AppendFormat(",pageItemId:{0}", SPContext.Current.PageItemId);
            }
            if (web.WebPartManager != null)
            {
                shared = web.WebPartManager.Scope;
            }
            else
            {
                shared = PersonalizationScope.Shared;
            }
            sb.AppendFormat(", pagePersonalizationScope:{0}", (int) shared);
            if (web.CurrentUser != null)
            {
                sb.Append(",userId:");
                sb.Append(web.CurrentUser.ID.ToString(CultureInfo.InvariantCulture));
                sb.AppendFormat(", systemUserKey:\"{0}\"",
SPHttpUtility.EcmaScriptStringLiteralEncode(web.CurrentUser.SystemUserKey));
            }
            if (site != null)
            {
                SPWebApplication webApplication = site.WebApplication;
                sb.Append(", alertsEnabled:");
                if (((webApplication != null) && webApplication.AlertsEnabled) &&
webApplication.IsEmailServerSet)
                {
                    sb.Append("true");
                }
                else
                {
                    sb.Append("false");
                }
                sb.Append(", siteServerRelativeUrl: \"");
                sb.Append(SPHttpUtility.EcmaScriptStringLiteralEncode(site.ServerRelativeUrl));
                sb.AppendFormat("\", allowSilverlightPrompt:'{0}'", site.WebApplication.AllowSilverlightPrompt);
            }
            string themedCssFolderUrl = SPUtility.GetThemedCssFolderUrl();
            if (!string.IsNullOrEmpty(themedCssFolderUrl))
            {
                sb.Append(",\"themedCssFolderUrl\" : \"" + themedCssFolderUrl + "\"");
                StringCollection themedImages = new StringCollection();
                StringCollection strings2 = new StringCollection();
                HashSet<string> addedThemedImages = new HashSet<string>();
                HashSet<string> set2 = new HashSet<string>();
                AddImageToCollection(themedImages, "spcommon.png", addedThemedImages);
                AddImageToCollection(themedImages, "ellipsis.11x11x32.png", addedThemedImages);
                AddImageToCollection(themedImages, "O365BrandSuite.95x30x32.png", addedThemedImages);
                AddImageToCollection(themedImages, "socialcommon.png", addedThemedImages);
                AddImageToCollection(themedImages, "spnav.png", addedThemedImages);
                SPWebPartManager webPartManager = web.WebPartManager;
                WebPartCollection webParts = null;
                if (webPartManager != null)
                {
                    webParts = webPartManager.WebParts;
                }
                if (webParts != null)
                {
                    for (int i = 0; i < webParts.Count; i++)
                    {
                        WebPart part = webParts[i] as WebPart;
                        if (part != null)
                        {
                            AddWebpartThemedImagesToCollection(themedImages,
part.GetThemedImages(), addedThemedImages);
                            AddWebpartThemedImagesToCollection(strings2,
part.GetThemedLocalizedImages(), set2);
                        }
                    }
                }
                GenerateThemedImageJson(themedImages, strings2, sb);
            }
            sb.Append("};");
        }
        SPPageContentManager.RegisterClientScriptBlock(page, typeof(ScriptLink),
"pagecontextinfo", sb.ToString());
    }
}

Анализ использования данного метода показывает, что он вызывается при регистрации на стандартных скриптов init.js и sp.core.js, которые добавляются на страницу с помощью контрола ScriptLink:

internal static void RegisterForControl(Control ctrl, Page page, string name,
bool localizable, bool defer, bool loadAfterUI, string language, bool injectNoDefer,
bool controlRegistration, [Optional, DefaultParameterValue(false)] bool loadInlineLast,
[Optional, DefaultParameterValue(false)] bool ignoreFileNotFound)
{
    ...
    RegisterStringsJsIfNecessary(name, page);
    if ((string.Compare(name, "core.js", StringComparison.OrdinalIgnoreCase) == 0) || (string.Compare(name, "bform.js", StringComparison.OrdinalIgnoreCase) == 0))
    {
        ...
    }
    if (string.Compare(name, "SP.Core.js", StringComparison.OrdinalIgnoreCase) == 0)
    {
        ...
        SharePointClientJs_Register(page);
    }
    else if (string.Compare(name, "init.js", StringComparison.OrdinalIgnoreCase) == 0)
    {
        InitJs_Register(page);
    }
    ...
}

Также в методе InitJs_Register():

private static void InitJs_Register(Page page)
{
    ...
    SharePointClientJs_Register(page);
}

Эти 2 скрипта являются одними из основных клиентских скриптов Sharepoint-а и добавляются практически на все страницы на Sharepoint сайтах (например, по умолчанию присутствуют на publishing и на application layout страницах). Т.е. будет достаточно безопасно использовать объект _spPageContextInfo в вашем коде, определенном для этих страниц. Одна проблема, которую я нашел на текущий момент, связана с тем, что он не определен в Sharepoint приложениях (apps). Тем не менее, это достаточно удобное стандартное средство, которое может помочь вам в повседневной работе и уменьшить количество кода.

Рубрика: Client object model, Sharepoint | Оставить комментарий

Конвертируем проект для Sharepoint 2010 в Visual Studio в проект для Sharepoint 2013

Недавно мы мигрировали несколько сайтов с Sharepoint 2010 на Sharepoint 2013. Солюшен Visual Studio с кастомизациями содержал несколько стандартных проектов для Sharepoint 2010 (wsp). Для миграции нужно было перекомпилировать их с использованием API 2013. Однако в Visual Studio 2012 мы не нашли стандартного способа изменить целевую платформу с 2010 на 2013. Так что для миграции были созданы несколько пустых проектов для Sharepoint 2013 и все файлы были скопированы туда вручную. Затем изменили все ссылки на Microsoft.SharePoint.*.dll с версии 14 на 15 (т.к. Sharepoint 2013 основан на .Net 4.5, сборки с его API находятся в новом GAC-е: C:\Windows\Microsoft.NET\assembly\, а не в C:\Windows\assembly\ как это было для .Net 3.5. В этом обсуждении на форуме можно найти информацию, почему это было сделано: .NET 4.0 has a new GAC, why?). (Кроме замены ссылок, нужно было сделать еще несколько фиксов, но о них я напишу в отдельном посте про миграцию). После этого мы собрали wsp и сравнили содержимое с помощью утилиты сравнения файлов и папок.

Описанный способ работал, но ручное копирование файлов из старых проектов и проверка, что при копировании мы ничего не потеряли, заняло время. Rainer Dümmler в переписке рассказал мне о способе автоматизировать этот процесс, которые они нашли, и я с разрешения автора размещаю его в своем блоге (у него пока нет своего блога. Надеюсь, что это только временно). Для изменения целевой платформы на Sharepoint 2013 необходимо отредактировать .csproj файл (это можно сделать в блокноте или в Visual Studio, предварительно выгрузив проект): надо заменить значение для тега TargetFrameworkVersion с v3.5 на v4.5. В принципе это можно было сделать и через UI. Но это не все. После этого необходимо добавить тег TargetOfficeVersion сразу после него:

 <targetofficeversion>15.0</targetofficeversion> 

После изменений файл должен выглядеть следующим образом:

image

После этого в подпапке Package отредактируйте файл Package.package: добавьте атрибут sharePointProductVersion=»15.0″ после атрибута resetWebServer.

После этого загрузите .csproj файл в Visual Studio 2012 – студия завершит конвертацию автоматически, измените ссылки на 15 API, перекомпилируйте и пересоберите wsp. После этого wsp можно устанавливать на ферму Sharepoint 2013 и завершать миграцию апгрейдом сайт коллекций на 2013 mode.

Рубрика: Uncategorized | Оставить комментарий

Создаем асинхронные веб-части в Sharepoint

Один из способов улучшить производительность вашего Sharepoint сайта – добавить поддержку ajax в ваши кастомные веб-части (мне больше нравиться название веб-парта, но в переводах чаще используется термин веб-часть, поэтому я тоже буду использовать его в статье). Как вы наверное знаете, в Sharepoint есть несколько стандартных веб-частей, которые поддерживают ajax “из коробки”. Например, в свойствах веб-части Search core results есть категория AJAX Options, в которой можно установить настройки асинхронной загрузки (нет русской версии Sharepoint-а, поэтому скриншот английский):

image

Если выбрать опцию “Enable Asynchronous Load”, веб-часть будет загружаться асинхронно и не будет блокировать загрузку страницы. Однако такая возможность есть не у всех стандартных веб-частей. Кастомные веб-части по умолчанию также загружаются синхронно, и, чтобы добавить поддержку ajax в них, необходимо совершить дополнительные действия.

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

image

Начнем с класса веб-части. Он должен наследовать класс BaseWebPart из проекта фреймворка (в свою очередь BaseWebPart наследует WebPart – стандартный базовый класс веб-частей ASP.Net). BaseWebPart содержит два метода: один для создания контейнера для отображения индикатора загрузки, другой – для регистрации прокси веб-сервиса для его использования в javascript. Упрощенная версия выглядит следующим образом:

public class BaseWebPart : WebPart
{
    protected Panel contentContainer;

    protected void PrepareContentContainer()
    {
        try
        {
            if (HttpContext.Current.Request.Browser.EcmaScriptVersion.Major >= 1)
            {
                //Busy Box View (JS enabled)
                Image spinnerImage = new Image();
                spinnerImage.ImageUrl = String.Format("{0}{1}",
SPContext.Current.Web.Url, "/_layouts/AsyncWebpart/Images/loadingspinner.gif");
                Label spinnerText = new Label()
{ Text = "Please wait while the results are being loaded.." };

                contentContainer.ID = "" + this.ID + "Context";
                contentContainer.Controls.Add(spinnerImage);
                contentContainer.Controls.Add(new LiteralControl("<br />"));
                contentContainer.Controls.Add(spinnerText);
                //AJAX Enabled
            }
            else
            {
                //AJAX Disabled - Fallback mode
                 //Alternative content in case the user's browser does not support JS
                contentContainer.Controls.Add(
new LiteralControl("It seems your browser doesn't support Javascript"));
            }
        }
        catch (Exception ex)
        {
            // log
        }
    }

    protected void AddScriptManagerReferenceProxy(string referenceProxyUrl,
string referenceProxyJS)
    {
        try
        {
            ScriptManager thisScriptManager = ScriptManager.GetCurrent(this.Page);
            if (thisScriptManager == null)
            {
                thisScriptManager = new ScriptManager();
                this.Controls.Add(thisScriptManager);
            }

            ServiceReference referenceProxy = new ServiceReference();
            referenceProxy.Path = referenceProxyUrl;

            referenceProxy.InlineScript = true;
            //If you are having issues identifying the names of the web service JS methods, uncomment the line above ^

            thisScriptManager.Services.Add(referenceProxy);

            //JS file contains proxy methods for web service - see file for details

            this.Page.ClientScript.RegisterClientScriptInclude(typeof(Page), referenceProxyUrl, referenceProxyJS);
        }
        catch (Exception ex)
        {
            // log
        }
    }
}

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

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

public class AsyncCustomWebPart : BaseWebPart
{
    [WebBrowsable(true)]
    [Personalizable(PersonalizationScope.Shared)]
    public int Delay { get; set; }

    protected override void OnLoad(EventArgs e)
    {
        try
        {
            EnsureChildControls();

            // register script to automatically load the web part into the content container
            // pass all the property values through asynchronous call
            this.Page.ClientScript.RegisterStartupScript(typeof (Page),
                "startupScript" + this.ClientID,
                string.Format("AsyncWebPartGetData('{0}', '{1}');",
                    this.Delay, contentContainer.ClientID), true);

            base.OnLoad(e);
        }
        catch (Exception ex)
        {
            // handle error
        }
    }

    protected override void CreateChildControls()
    {
        try
        {
            contentContainer = new Panel();

            // prepare container to hold the content
            PrepareContentContainer();
            // add web service reference and js handler
            AddScriptManagerReferenceProxy(
                "/_layouts/AsyncWebPart/AsyncWebPartService.asmx",
                "/_layouts/AsyncWebPart/JS/AsyncWebPart.js");
        }
        catch (Exception ex)
        {
            // handle error
        }
    }

    public override void RenderControl(HtmlTextWriter writer)
    {
        contentContainer.RenderControl(writer);
        base.RenderControl(writer);
    }
}

Здесь в методе OnLoad мы вызываем EnsureChildControls для того, чтобы быть уверенными, что дочерние элементы управления созданы (строка 11). В методе CreateChildControls (строки 28-45) мы создаем элемент управления Panel, который будет играть роль контейнера, в который будет загружаться разметка, полученная из веб-сервиса. Как мы уже видели в методе PrepareContentContainer (строка 35) мы загружаем анимированный gif, отображающий индикатор загрузки веб-части до тех пор, пока ее содержимое не будет готово:

image

После этого мы добавляем прокси для асинхронного вызова веб-сервиса и ссылку на файл AsyncWebPart.js, который является частью фреймворка (строки). Код из этого файла будет показан ниже.

Затем в методе OnLoad мы регистрируем javascript код, который должен запускаться при загрузке страницы, для вызова нашей функции AsyncWebPartGetData. Эта функция также определена в AsyncWebPart.js. При вызове AsyncWebPartGetData мы передаем все кастомные свойства нашей веб-части. В свою очередь эти свойства далее передаются в веб-метод. Таким образом в асинхронной версии веб-части мы имеем возможность менять ее поведение, изменяя свойства, так же, как в синхронной версии.

Что происходит после этого? При загрузке страницы вызывается наша javascript функция. Посмотрим код AsyncWebPart.js:

function AsyncWebPartGetData(delay, containerID) 
{
    //Remember to use entire namespace + class before method name
    AsyncWebPart.AsyncWebPartService.GetUIHtmlForAsyncWebPart(
        delay, AsyncWebPartComplete, AsyncWebPartError, containerID);
}

//Client-side onComplete handler
function AsyncWebPartComplete(response, containerID) 
{
    //Populates the container with the response
    var containerDiv = document.getElementById(containerID);
    containerDiv.innerHTML = response;
}

//Client-side onError handler
function AsyncWebPartError(response, containerID) 
{
    var containerDiv = document.getElementById(containerID);
    containerDiv.innerHTML = "An error has occurred. Please contact administrator.";
}

Функция AsyncWebPartGetData используется только для нашей конкретной веб-части (более точно, для типа этой веб-части). Т.е. если вам нужно сделать другую веб-часть асинхронной, то будет нужно создать новую функцию. Две другие функции (AsyncWebPartComplete и AsyncWebPartError) могут использоваться для многих веб-частей. Как я уже говорил выше, AsyncWebPartGetData получает все кастомные свойства веб-части в параметрах (конечно, можно передавать и стандартные свойства, если это необходимо). В нашем примере это delay и containerID (containerID нужно передавать последним параметром во всех других функциях, которые вы создадите для ваших веб-частей. Он используется в функциях обратного вызова для загрузки html разметки из веб-сервиса). Внутри функции AsyncWebPartGetData асинхронно вызывается сгенерированный прокси, которому передаются delay, callbacks и containerID (строки 4,5). После этого управление возвращается странице.

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

Когда запрос достигает веб-сервиса, он делает следующее:

[System.Web.Script.Services.ScriptService]
[WebService(Namespace = "http://tempuri.org/", Name = "AsyncWebPartService")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[ToolboxItem(false)]
public class AsyncWebPartService : System.Web.Services.WebService
{
    [ScriptMethod]
    [WebMethod]
    public string GetUIHtmlForAsyncWebPart(int delay)
    {
        var ctrl = new MyControl();
        ctrl.Delay = delay;

        var retOutputBuilder = new StringBuilder();
        using (var stringWriter = new StringWriter(retOutputBuilder))
        {
            using (var textWriter = new HtmlTextWriter(stringWriter))
            {
                ctrl.RenderControl(textWriter);
            }
        }
        return retOutputBuilder.ToString();
    }
}

Сначала создается экземпляр элемента управления (строка 11). Затем ему передаются свойства, полученные из веб-части (строка 12). В этом примере мы передаем свойство delay, и элемент управления не делает ничего кроме того, что вызывает Thread.Sleep(). В реальных приложениях вы можете таким образом передавать все необходимые свойства веб-части. Затем веб-сервис рендерит элемент управления в строку (строки 14-22) и возвращает результат.

Обратите внимание, что т.к. в веб-методе GetUIHtmlForAsyncWebPart мы фактически получаем разметку для нашей веб-части, такие методы нужно будет создавать для каждой веб-части, которую необходимо сделать асинхронной. Затем, сгенерировать прокси для использования на клиенте и создать javascript функцию в AsyncWebPart.js.

Еще один момент, который стоит упомянуть. В нашем примере мы использовали кастомный элемент управления (custom control), но также возможно использовать пользовательские элементы управления (user controls) – см. например ASP.NET how to Render a control to HTML.

После этого html возвращается вызывающей стороне и обработчик javascript загружает его в контейнер внутри веб-части. Это происходит асинхронно без перезагрузки страницы.

Последнее, что я хотел бы отметить, это то, что если вы захотите использовать описанный подход, нужно будет разделить код веб-части от непосредственной логики UI. Т.е. веб-часть будет лишь легковесным контейнером. Но это скорее хороший стиль программирования, чем ограничение (например стандартные visual веб-части в Sharepoint реализованы таким образом: веб-часть – это контейнер, который загружает пользовательский элемент управления динамически).

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

Рубрика: Ajax, Sharepoint, UI | Оставить комментарий

Проблема с программным обновлением настроек текущей навигации в Sharepoint

В этой статье я хочу рассказать об одной проблеме, с которой мы столкнулись при изменении настроек навигации. В интранете было создано много сайтов публикаций (publishing sites). Нужно было обновить настройки текущий навигации (current navigation) на всех сайтах, используя установку “Отображать элементы с родительского сайта” (русского языкового пакета нет, поэтому перевод может быть неточным. Оригинал: Display the same navigation items as the parent site):

Я создал небольшую утилиту, которая обновляет настройки навигации на всех сайтах:

class Program
{
    static void Main(string[] args)
    {
        if (args.Length != 1)
        {
            Console.WriteLine("Usage: UpdateNavigation.exe <site_url>");
            return;
        }

        Console.WriteLine("Start");
        using (var site = new SPSite(args[0]))
        {
            using (var web = site.OpenWeb())
            {
                foreach (SPWeb subweb in web.Webs)
                {
                    walk(subweb);
                }
            }
        }
        Console.WriteLine("Finish");
    }

    private static void walk(SPWeb web)
    {
        if (web == null)
        {
            return;
        }
        
        Console.WriteLine("Check web '{0}'", web.Url);
        var pweb = PublishingWeb.GetPublishingWeb(web);
        if (!pweb.Navigation.InheritCurrent)
        {
            Console.WriteLine("\tInherit current navigation for '{0}'", web.Url);
            pweb.Navigation.InheritCurrent = true;
            pweb.Web.Update();
        }

        foreach (SPWeb subweb in web.Webs)
        {
            walk(subweb);
        }
    }
}

Как видно из примера, она очень простая: перебираем все сайты рекурсивно и изменяем текущую навигацию. При этом она проверяет, установлено ли свойство PublishingWeb.Navigation.InheritCurrent в true, и устанавливает его только в случае, если свойство установлено в false (чтобы минимизировать обновления и обращения к контент базе на production-е). Утилита успешно отработала и обновила настройки всех сайтов сайт-коллекции, чтобы наследовать навигацию от родительского (рутового) сайта. Однако после этого на некоторых сайтах (не на всех) на странице Настройки сайта >Навигация для текущей навигации была выбрана опция “Отображать текущий сайт, элементы под текущим сайтом, и соседей” (Display the current site, the navigation items below the current site, and the current site’s siblings):

При этом, что было еще более странным, в UI навигация отображалась правильно на всех сайтах, т.е. на всех них использовалась навигация родительского сайта. Более того, когда я второй раз запустил утилиту, она не обновила ни одного сайта, что означало что PublishingWeb.Navigation.InheritCurrent установлено в true.

После этого я начал изучать логику класса страницы AreaNavigationSettings.aspx (именно она открывается когда вы выбираете Настройки сайта >Навигация): Microsoft.SharePoint.Publishing.Internal.CodeBehind.AreaNavigationSettingsPage. В нем имеются следующие элементы управления типа HtmlInputRadioButton для каждой опции настроек текущей навигации:

Идентификатор Название
inheritLeftNavRadioButton Отображать элементы с родительского сайта
showSiblingsLeftNavRadioButton Отображать текущий сайт, элементы под текущим сайтом, и соседей
uniqueLeftNavRadioButton Отображать только элементы под текущим сайтом

Вот как эти контролы используются:

protected override void OnLoad(EventArgs e)
{
    ...
    this.inheritLeftNavRadioButton.Checked =
        base.CurrentPublishingWeb.Navigation.InheritCurrent;
    this.showSiblingsLeftNavRadioButton.Checked =
        base.CurrentPublishingWeb.Navigation.ShowSiblings;
    this.uniqueLeftNavRadioButton.Checked =
        !base.CurrentPublishingWeb.Navigation.InheritCurrent && !this.showSiblingsLeftNavRadioButton.Checked;
    ...
}

protected void OKButton_Click(object sender, EventArgs e)
{
    ...
    publishingWeb.Navigation.InheritCurrent =
        this.inheritLeftNavRadioButton.Checked;
    publishingWeb.Navigation.ShowSiblings =
        this.showSiblingsLeftNavRadioButton.Checked;
    ...
    publishingWeb.Web.Update();
    base.RedirectToFinishUrl();
}

Как видно, несмотря на то, что все 3 элемента управления относится к одной группе (это означает, что через UI можно установить только один из них), программно можно установить оба свойства PublishingWeb.Navigation.InheritCurrent и PublishingWeb.Navigation.ShowSiblings в true. Через UI так сделать нельзя. При этом, как видно из кода, когда оба свойства установлены в true, Sharepoint выделит настройку “Отображать текущий сайт, элементы под текущим сайтом, и соседей”, потому что контрол showSiblingsLeftNavRadioButton, который соответствует данной опции, инициализируется в коде после контрола inheritLeftNavRadioButton, соответствующего опции “Отображать элементы с родительского сайта”. Такая реализация, на мой взгляд, способствует появлению ошибок, потому что если пользователь нажмет Ok на странице с настройками, то, ничего явно не меняя на странице, он изменит PublishingWeb.Navigation.InheritCurrent с true на false, при этом PublishingWeb.Navigation.ShowSiblings останется true.

Для того, чтобы исправить эту проблему, я добавил одну строку кода в утилиту. После того, как она отработала, все сайты показывали правильное значение “Отображать элементы с родительского сайта”:

var pweb = PublishingWeb.GetPublishingWeb(web);
pweb.Navigation.InheritCurrent = true;
pweb.Navigation.ShowSiblings = false;
pweb.Web.Update();

Имейте ввиду эту особенность, когда будете обновлять настройки навигации через объектную модель.

Рубрика: Navigation, Sharepoint, UI | Оставить комментарий