Конвертируем проект для 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 | Оставить комментарий

Camlex.Net 3.1: динамическое создание ViewFields для CAML запросов в Sharepoint

На этой неделе была выпущена новая версия 3.1 проекта Camlex.Net. В этом релизе была добавлена возможность динамически формировать ViewFields для CAML запросов. Недавно в разделе discussions на сайте проекта был задан вопрос о такой фиче. Это не первый случай, когда мы добавляем функциональность в проект на основе фидбека, который мы получаем от разработчиков, и по мере возможностей и наличия свободного времени развиваем наш open source проект. Кроме этого мы сами интенсивно используем его в работе, поэтому стараемся добавлять функциональность по мере поступления новых запросов, чтобы проект был все более полезен в разработке для Sharepoint.

В версии 3.1 были добавлены следующие новые методы:

public interface IQueryEx : IQuery
{
    ...
    string ViewFields(IEnumerable<string> titles);
    string ViewFields(IEnumerable<string> titles, bool includeViewFieldsTag);
    string ViewFields(IEnumerable<Guid> ids);
    string ViewFields(IEnumerable<Guid> ids, bool includeViewFieldsTag);
}

Таким образом, теперь вы можете динамически сформировать список имен полей или идентификаторов (типа Guid) и передать его в Camlex. На основе этого списка Camlex сформирует CAML для ViewFields. Например, используя следующий код:

var items = new [] { "Title", "FileRef" };

string caml = Camlex.Query().ViewFields(items);

вы получите следующий CAML:

<FieldRef Name=\"Title\" />
<FieldRef Name=\"FileRef\" />

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

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

До новой версии в интерфейсе IQueryEx были следующие методы (они остались и в новой версии):

public interface IQueryEx : IQuery
{
    string ViewFields(Expression<Func<SPListItem, object>> expr);
    string ViewFields(Expression<Func<SPListItem, object>> expr, bool includeViewFieldsTag);
    string ViewFields(Expression<Func<SPListItem, object[]>> expr);
    string ViewFields(Expression<Func<SPListItem, object[]>> expr, bool includeViewFieldsTag);
}

С их помощью ViewFields можно сформировать с помощью следующего кода:

string caml =
    Camlex.Query().ViewFields(x => new [] { x["Title"], x["FileRef"] });

Чтобы создать список динамически вам самим пришлось бы написать код, который создает выражение (NewArrayInit) на основе списка имен полей (“Title” и “FileRef” в этом примере). Это не то, чем надо заниматься при работе над бизнес задачей. Библиотека должна делать это за нас. Когда я думал над реализацией, я хотел использовать существующие методы, чтобы не писать много нового кода. Чтобы это сделать надо было ответить на вопрос: как, имея массив строк или Guid-ов (напр. {“Title”, “FileRef”}), создать лямбда выражение x => new [] { x[“Title”], x[“FileRef”] }? Решение получилось довольно элегантным:

public string ViewFields(IEnumerable<string> titles, bool includeViewFieldsTag)
{
    ...
    return this.ViewFields(this.createExpressionFromArray(titles), includeViewFieldsTag);
}

public string ViewFields(IEnumerable<Guid> ids, bool includeViewFieldsTag)
{
    ...
    return this.ViewFields(this.createExpressionFromArray(ids), includeViewFieldsTag);
}

private Expression<Func<SPListItem, object[]>>
createExpressionFromArray<T>(IEnumerable<T> items)
{
    return Expression.Lambda<Func<SPListItem, object[]>>(
        Expression.NewArrayInit(typeof(object),
        (IEnumerable<Expression>)items.Select(
            t => Expression.Call(Expression.Parameter(typeof(SPListItem), "x"),
                typeof(SPListItem).GetMethod("get_Item", new[] { typeof(T) }),
                new[] { Expression.Constant(t) })).ToArray()),
        Expression.Parameter(typeof(SPListItem), "x"));
}

Т.е. для каждого элемента элемента списка items создается выражение – доступ к индексеру x[item], которое затем добавляется в список инициализации NewArrayInit. Обратите внимание на вызов ToArray() – без него ленивое LINQ выражение не будет развернуто и инициализация NewArrayInit упадет с исключением.

Это все, что я хотел рассказать о новом релизе. В данный момент у нас уже есть достаточно внушительный todo list, но мы всегда открыты новым предложениям и идеям. Пишете о них на сайте проекта на codeplex.

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

Использование класса ContentIterator в разработке для Sharepoint

В этом посте я хочу рассказать о ContentIterator — полезном классе, который вы можете использовать в повседневной разработке для Sharepoint. Он определен в сборке Microsoft.Office.Server.dll и помогает работать с такими общими задачами, как итерация по списку элементов списка Sharepoint (SPListItem), файлов (SPFile), веб сайтов (SPWeb) и др. Итерация по элементам и файлам реализована через SPQuery, что значит он имеет очень хорошую производительность (близкую к максимальной из того, что предлагает Sharepoint object model). ContentIterator – новый класс, который появился в Sharepoint 2010. Как сказано в документации к классу:

Sharepoint сервер предоставляет новый API, ContentIterator, для помощи при работе с большими списками, содержащими более 5000 элементов, позволяющий обойти throttling-предел и генерации SPQueryThrottleException.

Например, представим, что у нас есть ссылка на объект типа SPFolder, представляющий папку в Sharepoint списке, и мы хотим пройтись в цикле по всем элементам внутри этой папки. Для этого мы можем использовать метод ContentIterator.ProcessFilesInFolder:

SPFolder folder = web.GetFolder(...);
SPList doclib = web.Lists[...];
ContentIterator contentIterator = new ContentIterator();

bool isFound = false;
contentIterator.ProcessFilesInFolder(doclib, folder, false,
    f =>
        {
            // this is item iteration handler
            ...
        },
    (f, e) =>
        {
            // error handler
            ...
        });

Работа с ContentIterator основана на использовании делегатов. В параметрах метода мы передаем ссылку на родительскую библиотеку документов (либо список) и папку в ней, в которой нужно проитерировать элементы. Третий параметр указывает, нужно ли делать обход рекурсивным если внутри нашей папки есть подпапки. Четвертый параметр – это делегат, который вызывается из ContentIterator-а и который получает непосредственно каждый файл (объект типа SPFile). Последний параметр – тоже делегат, представляющий обработчик ошибок.

Давайте посмотрим как реализован этот метод. Внутри он использует другой метод ContentIterator.ProcessListItems:

public void ProcessListItems(SPList list, string strQuery, uint rowLimit,
    bool fRecursive, SPFolder folder, ItemsProcessor itemsProcessor,
    ItemsProcessorErrorCallout errorCallout)
{
    ...
    SPQuery query = new SPQuery();
    if (!string.IsNullOrEmpty(strQuery))
    {
        query.Query = strQuery;
    }
    query.RowLimit = rowLimit;
    if (folder != null)
    {
        query.Folder = folder;
    }
    if (fRecursive)
    {
        query.ViewAttributes = "Scope=\"RecursiveAll\"";
    }
    this.ProcessListItems(list, query, itemsProcessor, errorCallout);
}

Для начала создается объект SPQuery. Непосредственный CAML-запрос формируется выше по стеку:

public static string ItemEnumerationOrderByPath
{
    get
    {
        return "<OrderBy Override='TRUE'><FieldRef Name='FileDirRef' /><FieldRef Name='FileLeafRef' /></OrderBy>";
    }
}

И наиболее интересная реализация перегруженного метода ProcessListItems:

public void ProcessListItems(SPList list, SPQuery query, ItemsProcessor itemsProcessor,
    ItemsProcessorErrorCallout errorCallout)
{
    string str2;
    SPListItemCollection items;
    ...
    if (!list.get_HasExternalDataSource() && (list.ItemCount == 0))
    {
        return;
    }
    if (list.get_HasExternalDataSource() && (query.RowLimit == 0))
    {
        query.RowLimit = 0x7fffffff;
    }
    else if ((query.RowLimit == 0) || (query.RowLimit == 0x7fffffff))
    {
        query.RowLimit = string.IsNullOrEmpty(query.ViewFields) ? 200 : 0x7d0;
    }
    if (!list.get_HasExternalDataSource() && this.StrictQuerySemantics)
    {
        query.set_QueryThrottleMode(2);
    }
    string strListId = list.ID.ToString("B");
    this.ResumeProcessListItemsBatch(strListId, out str2);
    if (!string.IsNullOrEmpty(str2))
    {
        query.ListItemCollectionPosition = new SPListItemCollectionPosition(str2);
    }
    int batchNo = 0;
Label_012B:
    items = list.GetItems(query);
    int count = items.Count;
    batchNo++;
    try
    {
        itemsProcessor(items);
        this.OnProcessedListItemsBatch(strListId, items, batchNo, count);
    }
    catch (Exception exception)
    {
        if ((errorCallout == null) || errorCallout(items, exception))
        {
            throw;
        }
    }
    if (!this.ShouldCancel(IterationGranularity.Item))
    {
        query.ListItemCollectionPosition = items.ListItemCollectionPosition;
        if (query.ListItemCollectionPosition != null)
        {
            goto Label_012B;
        }
    }
}

Внутри метода проверяется свойство RowLimit и, если оно не установлено, устанавливается в значения по умолчанию (в 200 или 2000 в зависимости от того, установлено ли свойство ViewFields или нет). Затем свойство SPQuery.QueryThrottleMode устанавливается в Strict. Документация говорит про это следующее:

Throttling запросов будет определяться числом элементов, а также полями Lookup, Person/Group и Workflow, вне зависимости от прав пользователей.

Также используется SPQuery.ListItemCollectionPosition для выборки элементов за один запрос (число элементов, возвращаемых за один запрос определяется свойством RowLimit).

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

Рубрика: CAML, Sharepoint | 1 комментарий

Меняем адрес страницы с комментариями в Sharepoint 2010 без установки SP1

Комментарии – одна из новых возможностей, добавленных как часть социальных вычислений Sharepoint 2010. Они позволяют пользователям сайта, построенном на Sharepoint, оставлять отзывы и комментарии к контенту. Однако при этом есть проблема — технически комментарии привязаны к странице с использованием абсолютного адреса данной страницы. Это означает, что если вы перенесете страницу с одного сайта на другой, или измените доменное имя сайта – комментарии не будут отображаться (но не потеряются – они по-прежнему будут храниться в базе данных, как мы увидим ниже).

В этом году Microsoft выпустили пакет обновлений (Service pack 1) для Sharepoint 2010. Вместе с ним появился новый метод MergeSocialNotes() в классе SocialDataManager. Также появился новый cmdlet для PowerShell Move-SPSocialComments – обертка над указанным выше методом. Следующий пост описывает другие улучшения и новые фичи: Changes to Social Computing features in SharePoint Server 2010 Service Pack 1. Все это полезно, но что если на ваших серверах SP1 не установлен, но вам нужно изменить адреса страниц комментариев? Такая ситуация вполне возможна, например если заказчик еще не принял решение устанавливать пакет обновлений или нет (кто работал с крупными заказчиками знает, сколько времени может потребоваться на принятие решения и исполнение). В этом посте я расскажу, как можно перенести страницы, не потеряв при этом комментарии.

Прежде всего, нужно знать, что комментарии хранятся в отдельной базе данных для социальных вычислений – в таблице SocialComments:

Как видно на рисунке данная таблица содержит UrlID – внешний ключ к таблице Urls, в которой хранятся абсолютные урлы:

Теперь давайте посмотрим, как реализован метод SocialDataManager.MergeSocialNotes():

internal static bool MergeSocialNotes(UserProfileApplication userProfileApplication,
Guid partitionID, string oldUrl, string newUrl)
{
    if (null == userProfileApplication)
    {
        throw new ArgumentNullException("userProfileApplication");
    }
    if (string.IsNullOrEmpty(oldUrl))
    {
        throw new ArgumentNullException("oldUrl");
    }
    if (string.IsNullOrEmpty(newUrl))
    {
        throw new ArgumentNullException("newUrl");
    }
    if (!userProfileApplication.CheckAdministrationAccess(
UserProfileApplicationAdminRights.ManageSocialData))
    {
        throw new UnauthorizedAccessException();
    }
    using (SqlCommand command = new SqlCommand("dbo.proc_SocialData_MergeSocialNotes"))
    {
        command.CommandType = CommandType.StoredProcedure;
        command.Parameters.Add("@partitionID", SqlDbType.UniqueIdentifier).Value = partitionID;
        command.Parameters.Add("@correlationId", SqlDbType.UniqueIdentifier).Value = ULS.CorrelationGet();
        command.Parameters.Add("@oldUrl", SqlDbType.VarChar).Value = oldUrl;
        command.Parameters.Add("@newUrl", SqlDbType.VarChar).Value = newUrl;
        SqlParameter parameter = new SqlParameter("@success", SqlDbType.Bit);
        parameter.Direction = ParameterDirection.Output;
        command.Parameters.Add(parameter);
        userProfileApplication.SocialDatabase.SqlSession.ExecuteNonQuery(command);
        if (!((bool) command.Parameters["@success"].Value))
        {
            return false;
        }
    }
    return true;
}

Реализация достаточно простая: он вызывает хранимую процедуру proc_SocialData_MergeSocialNotes с переданными параметрами. Эта процедура создается в скриптах socialsrp.sql и socialup.sql, расположенными в папке 14\Template\SQL\SPS (они обновляются в SP1, т.е. в предшествующей версии Sharepoint указанной хранимой процедуры в них нет). В обоих скриптах код хранимый процедуры одинаковый:

IF  EXISTS (SELECT * FROM dbo.sysobjects
WHERE id = OBJECT_ID(N'[dbo].[proc_SocialData_MergeSocialNotes]')
AND OBJECTPROPERTY(id,N'IsProcedure') = 1)
DROP PROCEDURE [dbo].[proc_SocialData_MergeSocialNotes] 
GO

CREATE PROCEDURE [dbo].[proc_SocialData_MergeSocialNotes] 
    @partitionID uniqueidentifier,
    @oldUrl varchar(2048),
    @newUrl varchar(2048),
    @success bit = null output,
    @correlationId uniqueidentifier = null
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @oldUrlID bigint
    DECLARE @newUrlID bigint

    exec proc_Social_GetUrlID @partitionID, @oldUrl, @oldUrlID output, @correlationId
    exec proc_Social_GetUrlID @partitionID, @newUrl, @newUrlID output, @correlationId

    IF (@oldUrlID is null)
    BEGIN
        -- nothing to merge
        set @success = 1
        return
    END

    IF (@newUrlID is null)
    BEGIN
        EXEC proc_Social_EnsureUrlID @partitionID = @partitionID, @url = @newUrl,
@urlID = @newUrlID output, @correlationId = @correlationId;
    END

    IF (@newUrlID is null)
    BEGIN
        -- not able to create new Url - error
        set @success = 0
        return
    END

    UPDATE SocialComments
    SET UrlID = @newUrlID
    WHERE UrlID = @oldUrlID

    -- we succeed, even if there is no data to migrate
    set @success = 1
END
GO

Как видно из примера, используется код двух других хранимых процедур: proc_Social_GetUrlID и proc_Social_EnsureUrlID (которые существовали до установки пакета обновлений). Код, приведенный выше тоже достаточно простой: он проверяет, что newUrl существует в таблице Urls (если нет, то добавляет), и обновляет внешний ключ UrlID в таблице SocialComments для того, чтобы связать комментарий с новым урлом.

Для того чтобы использовать этот код на серверах без установленного пакета обновлений, прежде всего нужно создать процедуру proc_SocialData_MergeSocialNotes в базе данных социальных вычислений (выполнить скрипт, показанный выше). После этого останется лишь написать SQL скрипт, который обновит адреса страниц в зависимости от ваших требований.

Например, предположим, что нам нужно переместить новости с подсайта http://example.com/Departments/IT на родительский сайт http://example.com/Departments. Во время миграции новостей, комментарии должны быть тоже обновлены так, чтобы они по-прежнему были видны на новом сайте. Это можно сделать с помощью следующего скрипта:

declare @comment nvarchar(4000), @url nvarchar(2048), @newUrl nvarchar(2048)
declare @partitionId uniqueidentifier
declare @b bit

set @partitionId = CAST('...' as uniqueidentifier)

declare CommentsCursor cursor for
select sc.Comment, u.Url from dbo.SocialComments sc
    inner join dbo.Urls u on sc.UrlID = u.UrlID
where Url like '%/Departmens/IT/%'

open CommentsCursor

FETCH NEXT FROM CommentsCursor 
INTO @comment, @url

while @@FETCH_STATUS = 0
begin
    print @url + '        ' + @comment
    
    set @newUrl = replace(@url, '/Departmens/IT/', '/Departmens/')
    if @newUrl <> @url
    begin
        print 'Change url ' + @url + ' -> ' + @newUrl
        exec proc_SocialData_MergeSocialNotes @partitionId, @url, @newUrl, @b output
        print 'Result: ' + str(@b)
    end
    
    FETCH NEXT FROM CommentsCursor 
    INTO @comment, @url
end

close CommentsCursor
deallocate CommentsCursor

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

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

Последний вопрос – это где взять значение для @partitionId. Простейший способ найти его – это выполнить следующий запрос в базе данных социальных вычислений:

select sc.Comment, sc.PartitionID, u.Url, u.PartitionID from dbo.SocialComments sc
    inner join dbo.Urls u on sc.UrlID = u.UrlID

и посмотреть какой PartitionID используется в вашем веб-приложении.

После выполнения скрипта, комментарии будут привязаны к страницам, перемещенным на родительский сайт.

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

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

Программное создание иерархических таксономий в обработчике событий компонента

Скажу сразу что под обработчиком событий компонента имеется ввиду feature receiver. Но так как этот блог на русском языке и я не знаю более менее удачного перевода – для заголовка статьи я решил использовать перевод из Msdn. Дальше по тексту я буду использовать термин фича, понятный всем Sharepoint разработчикам.

Провиженинг управляемых метаданных (managed metadata) – достаточно частое требование в проектах на Sharepoint 2010. Часто вместе с созданием сайт коллекции необходимо добавить начальные данные для упрощения задачи сотрудникам, ответственным за контент (напр. страницы публикаций, данные в lookup листах и др.). Управляемые метаданные также могут быть созданы при активации фичи во время провиженинга. Например, заказчик может предоставить вам метаданные в виде xml файла (либо в любом другом формате, который можно распарсить программно) и вам нужно создать иерархию таксономий на основе этого файла. Одна из возможностей – использовать стандартный механизм импорта из csv файла, доступный в Term store. Но при этом во-первых у вам еще один шаг ручной настройки, а во-вторых стандартный импорт ограничен в кастомизации, напр. вы не сможете установить переводы ваших метаданных, синонимы, а также смержить метаданные с существующими наборами терминов (term sets). В этом посте я покажу как программно установить таксономии с любым уровнем вложенности на основе xml файла.

Прежде всего нужно создать фичу с областью действия – сайт коллекция. Это удобно, т.к. мы сможем добавить ее в onet.xml кастомного определения сайта (в секцию<SiteFeatures>) – и управляемые метаданные будут созданы при провиженинге сайт коллекции. Но при этом нужно иметь ввиду, что метаданные могут быть изменены после того, как они были запровиженены. Поэтому если планируется активировать фичу, устанавливающую метаданные, несколько раз в течение периода существования сайт коллекции, нужно позаботиться о том, чтобы по ошибке не перезаписать изменения, сделанные вручную администраторами метаданных. Простой способ – проверить что группа или набор терминов с текущим названием уже существуют и не перезаписывать их. Более сложный – мержить наборы терминов. Но последний сценарий вне скопа данной статьи.

Вернемся к нашей фиче и ее feature.xml файлу:

<?xml version="1.0" encoding="utf-8" ?>
<Feature
    Id="2FCC2C0B-A5D9-480B-B15B-189B5755185B"
    Title="Managed metadata example"
    Description=""
    Version="1.0.0.0"
    Scope="Site"
    Hidden="FALSE"
    ReceiverAssembly="ManagedMetadataProgrammaticalExample, Version=1.0.0.0, Culture=neutral, PublicKeyToken=d71c4ad7a0705ced"
    ReceiverClass="ManagedMetadataProgrammaticalExample.TermSetsFeatureReceiver"
    xmlns="http://schemas.microsoft.com/sharepoint/">
    <ElementManifests>
        <ElementFile Location="DefaultMetadata.xml"/>
    </ElementManifests>
</Feature>

Мы определили обработчик событий фичи, в котором будет выполняться непосредственная работы, т.е. парсинг xml файла и создание таксономии в Managed metadata service application. Также мы добавили ссылку на файл DefaultMetadata.xml, который содержит управляемые метаданные. Напр. так моэет выглядеть метаданные для интернет магазина (для простоты используем небольшую выборку):

<?xml version="1.0" encoding="utf-8" ?>
<TermSets>
  <TermSet Name="Categories">
    <Term Name="Books">
      <Term Name="IT">
        <Term Name="Sharepoint" />
        <Term Name="ASP.Net MVC" />
        <Term Name=".Net" />
      </Term>
      <Term Name="Math">
        <Term Name="Algebra" />
        <Term Name="Geometry" />
        <Term Name="Math analysis" />
      </Term>
    </Term>
    <Term Name="Films">
      <Term Name="The Big Bang Theory" />
      <Term Name="Haus MD" />
      <Term Name="Numb3rs" />
    </Term>
  </TermSet>
</TermSets>

Хотя в примере выше только один набор терминов, код приведенный в статье будет работать также для многих наборов (т.е. если xml будет содержать несколько наборов – все будут созданы). Провиженинг метаданных производится в 2 шага:

  1. Парсинг xml и представление данных в объектной модели
  2. Создание наборов терминов и терминов

Нам понадобятся 2 DTO класса:

public class TermSetDTO
{
    public string Name { get; set; }
    public List<TermDTO> Terms { get; set; }
}

public class TermDTO
{
    public string Name { get; set; }
    public List<TermDTO> Terms { get; set; }
}

Как видно, это очень простое представление стандартных сущностей TermSet и Term.

Теперь обратимся к коду обработчика событий:

public class TermSetsFeatureReceiver : SPFeatureReceiver
{
    public override void FeatureActivated(SPFeatureReceiverProperties properties)
    {
        var site = properties.Feature.Parent as SPSite;
        if (site == null)
        {
            return;
        }

        SPSecurity.RunWithElevatedPrivileges(
            () =>
                {
                    using (var elevatedSite = new SPSite(site.ID, site.Zone))
                    {
                        string defaultMetadataFilePath = Path.Combine(properties.Feature.Definition.RootDirectory, "DefaultMetadata.xml");
                        var termSetsService = new TermSetsService();
                        termSetsService.CreateTermSetsFromXml(elevatedSite, defaultMetadataFilePath);
                    }
                });
    }
}

Важный момент: для определенности код исполняется под elevated privileges. Строго говоря, сами elevated privileges не нужны – нам нужно знать под каким аккаунтом исполняется код, чтобы добавить его в группу администраторов банка терминов  (Term store administrators). Поэтому если сайт коллекция создается из Central Administration, то необходимо добавить аккаунт пула приложений в IIS в группу администраторов банка терминов (Term store administrators) в Приложении службы метаданных (Managed metadata service application).

Код обработчика достаточно простой – он передает путь к DefaultMetadata.xml в экземпляр TermSetsService, который выполняет непосредственную работу (это полезный паттерн – по возможности отвязывать функциональность от Sharepoint артефактов):

public class TermSetsService
{
    private const string TERM_SET_TAG = "TermSet";
    private const string TERM_TAG = "Term";
    private const string NAME_ATTR = "Name";
    private const string GROUP_NAME = "Shop";
    private const int ENGLISH_LOCALE_ID = 1033;

    public void CreateTermSetsFromXml(SPSite site, string fileName)
    {
        TermStore termStore = null;
        try
        {
            var taxonomySession = new TaxonomySession(site);
            termStore = taxonomySession.DefaultKeywordsTermStore;

            if (termStore == null)
            {
                return;
            }

            if (!termStore.Groups.IsNullOrEmpty() && termStore.Groups.Any(g => g.Name == GROUP_NAME))
            {
                return;
            }

            var taxonomyGroup = termStore.CreateGroup(GROUP_NAME);
            if (taxonomyGroup == null)
            {
                return;
            }

            // load terms sets from xml file
            var termSets = this.load(fileName);
            if (termSets.IsNullOrEmpty())
            {
                return;
            }

            createTermSets(taxonomyGroup, termSets);

            termStore.CommitAll();
        }
        catch (Exception x)
        {
            if (termStore != null)
            {
                termStore.RollbackAll();
            }
            throw;
        }
    }

    private List<TermSetDTO> load(string fileName)
    {
        try
        {
            var result = new List<TermSetDTO>();

            XDocument doc = XDocument.Load(fileName);
            foreach (var termSetElement in doc.Descendants(TERM_SET_TAG))
            {
                var nameAttr = termSetElement.Attributes().FirstOrDefault(a => a.Name == NAME_ATTR);
                if (nameAttr == null)
                {
                    continue;
                }

                string termSetName = nameAttr.Value;

                var termSet = new TermSetDTO { Name = termSetName };
                termSet.Terms = this.getTerms(termSetElement);
                result.Add(termSet);
            }

            return result;
        }
        catch (Exception x)
        {
            return new List<TermSetDTO>();
        }
    }

    private List<TermDTO> getTerms(XElement e)
    {
        try
        {
            if (e == null)
            {
                return null;
            }

            var terms = new List<TermDTO>();
            foreach (var termElement in e.Elements(TERM_TAG))
            {
                var nameAttr = termElement.Attributes().FirstOrDefault(a => a.Name == NAME_ATTR);
                if (nameAttr == null)
                {
                    continue;
                }

                var term = new TermDTO {Name = nameAttr.Value};
                term.Terms = this.getTerms(termElement);
                terms.Add(term);
            }
            return terms;
        }
        catch (Exception x)
        {
            return new List<TermDTO>();
        }
    }

    private void createTermSets(Group taxonomyGroup, List<TermSetDTO> termSets)
    {
        if (taxonomyGroup == null)
        {
            return;
        }

        if (termSets == null)
        {
            return;
        }

        // create managed metadata term set for each term set dto
        foreach (var ts in termSets)
        {
            if (taxonomyGroup.TermSets.Any(t => t.Name == ts.Name))
            {
                continue;
            }

            var termSet = taxonomyGroup.CreateTermSet(ts.Name);
            if (termSet == null)
            {
                continue;
            }

            this.createTerms(termSet, ts.Terms);
        }
    }

    private void createTerms(TermSet termSet, List<TermDTO> terms)
    {
        if (termSet == null)
        {
            return;
        }

        if (terms.IsNullOrEmpty())
        {
            return;
        }

        foreach (var term in terms)
        {
            if (termSet.Terms.Any(t => t.Name == term.Name))
            {
                continue;
            }
            var parentTerm = termSet.CreateTerm(term.Name, ENGLISH_LOCALE_ID);
            this.createTerms(parentTerm, term.Terms);
        }
    }

    private void createTerms(Term parentTerm, List<TermDTO> terms)
    {
        if (parentTerm == null)
        {
            return;
        }

        if (terms.IsNullOrEmpty())
        {
            return;
        }

        foreach (var term in terms)
        {
            if (parentTerm.Terms.Any(t => t.Name == term.Name))
            {
                continue;
            }
            var subTerm = parentTerm.CreateTerm(term.Name, ENGLISH_LOCALE_ID);
            this.createTerms(subTerm, term.Terms);
        }
    }
}

Код достаточно очевидный: во-первых он рекурсивно парсит xml файл и загружает результат в DTO объекты, определенные выше (см. методы load() и getTerms()). Затем он создает таксономии в банке терминов из объектной модели (см. методы createTermSets() и createTerms()) – также рекусрсивно. Для компиляции вам потребуется добавить ссылку на сборку Micosoft.SharePoint.Taxonomy.dll.

Также для компиляции нужно добавить метод-расширение IsNullOrEmpty() для типа IEnumerable<T>, описанный Филом Хааком в его блоге.

Код, приведенный выше, использует английскую локаль (lcid = 1033) для терминов. Вы можете расширить его и добавить поддержку переводов и синонимов.

После того, как вы установите wsp-пакет и активируете фичу в вашей сайт коллекции, будет создана иерархия таксономии в банке терминов:

Managed metadata

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

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