Решение задач. Задача таймера, совсем конец.

Посты в этой серии:

  1. Список задач для проверки навыков.
  2. Создание задачи таймера.
  3. Использование подходящих классов.
  4. Передача команд задаче таймера.
  5. Взаимодействие веб-фронтэнда с задачами таймера (этот пост).

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

Теперь необходимо придумать как оправлять эти сообщения. В 2010 есть почти универсальный способ для размещения функционала – Ribbon. Даже если у вас возникает непреодолимое желание сделать ссылку\кнопку\пункт меню в другом месте, то скорее всего для целей usability надо будет повторить его в ribbon.

Для того чтобы добавить кнопку в ribbon надо создать custom action, я для этого использую cks:dev. Руководство по расширению риббона можно найти как на MSDN, так и в блогах глубоко уважаемых людей (надеюсь меня не забанят за такое количество ссылок).

Мой код получился такой:

<CustomAction Id="CleanupLibraryButton"
              Title="Cleanup"
              RegistrationType="List"
              RegistrationId="101"
              Location="CommandUI.Ribbon">
  <CommandUIExtension>
    <CommandUIDefinitions>
      <CommandUIDefinition Location="Ribbon.Library.Settings.Controls._children">
        <Button Id="Ribbon.Library.Settings.Cleanup"
                Command="CleanupLibraryCommand"
                TemplateAlias="o2"
                LabelText="Cleanup"
                Sequence="100"
                Image16by16="/_layouts/images/warning16by16.gif"
                Image32by32="/_layouts/images/CRIT_32.GIF"                   
                />
      </CommandUIDefinition>
    </CommandUIDefinitions>
    <CommandUIHandlers>
      <CommandUIHandler Command="CleanupLibraryCommand" 
                        CommandAction="???"/>
    </CommandUIHandlers>
  </CommandUIExtension>
</CustomAction>

Немного громоздко, но если разобраться в схеме, то довольно очевидно. Кнопка добавляется в последнюю группу кнопок на закладке “Билиотека”.

Теперь самый интересный вопрос, что написать в CommandAction, где я в схеме поставил вопросы.

Самый прямолинейный способ – указать ссылку на application page, который вызовет site.AddWorkItem, но это кардинально противоречит самой идее ribbon.

 

SharePoint 2007 way

В SharePoint 2007 часто применялся delegate control, и действия в интерфейсе выполняли postback, а этот самый контрол обрабатывал форму и производил действия.

Для начала добавлю еще один cutom action с location равным ScriptLink. В таком custom action можно размещать ссылку на javascript или блок кода, который будет выведен на каждой странице.

<CustomAction 
    Location="ScriptLink"
    ScriptSrc="~site/_layouts/CleanupTimerJob/script.js" />

Команда для ribbon будет выглядеть так:

<CommandUIHandler 
    Command="CleanupLibraryCommand" 
    CommandAction="javascript:submitLibraryToCleanup()"/>

В самом же js файле будет простой код:

function submitLibraryToCleanup() {
    var listId = SP.ListOperation.Selection.getSelectedList();
    if (listId) {
        __doPostBack('CleanupPostBackEvent', listId);
    }
}

SP.ListOperation.Selection – класс, позволяющий получать данные о текущем представлении списка. Представлений списка на странице может быть больше одного, а ribbon – один.

Функция __doPostBack создается веб-формами, в первом параметре обычно указывается id элемента управления, которому адресуется postback, а во втором передаются параметры. Но узнать id нашего delegate control (который будет ниже) не представляется возможным, поэтому требуется заранее определенная константа и ручной анализ в теле контрола.

Сам контрол:

[Guid("0a3b8df1-0a43-485d-be83-983c1df8b30d")]
public class PostbackHandler : WebControl
{
    protected override void OnLoad(EventArgs e)
    {
        if (this.Page.Request["__EVENTTARGET"] == Constants.CleanupPostBackEvent)
        {
            var listId = new Guid(this.Page.Request["__EVENTARGUMENT"]);
            var web = SPContext.Current.Web;
                            
            SPSecurity.RunWithElevatedPrivileges(() =>
                {
                    using (var site = new SPSite(SPContext.Current.Site.ID))
                    {
                        site.AddWorkItem(
                            new Guid(), DateTime.UtcNow, Constants.WorkItemType,
                            web.ID, listId, -1,
                            true, new Guid(), web.ID,
                            web.CurrentUser.ID, null, null, new Guid());
                    }
                });
        }
    }
}

Основное внимание стоит уделить функции AddWorkItem. Во-первых её необходимо вызывать с правами администратора семейства сайтов, иначе будет ошибка. Во-вторых при указании времени доставки WorkItem необходимо указывать время в формате UTC. В-третьих, даже если не используются параметры listItemId и  userId, то все равно надо указывать ненулевые значения иначе SharePoint попытается записать в базу null, что приведет к ошибке так как эти поля not null. И в-четвертых лучше всего указывать batchId, такой же как у webId, если нет других соображений. Судя по документации msdn и реализации TimerJobUtility это может сэкономить ресурсы.

Чтобы ваш delegate control заработал необходимо добавить элемент в фичу. Здесь также поможет cks:dev, в нем уже есть шаблон.

<Control Id="AdditionalPageHead" 
         Sequence="1000" 
         ControlAssembly="$SharePoint.Project.AssemblyFullName$" 
         ControlClass="$SharePoint.Type.0a3b8df1-0a43-485d-be83-983c1df8b30d.FullName$" />

Но сразу после развертывания этого элемента контрол не заработает, нужно еще добавить класс в safecontrols в web.config. Ни в коем случае не надо писать модификацию конфига самостоятельно, для этого уже есть инструменты в студии.

image

Ну вот и все…

SharePoint 2010 way

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

Для этого нужно сделать: веб-сервис в sharepoint, который обработает запрос и поменять script.js, чтобы он вызывал этот веб-сервис.

Чтобы сделать веб-сервис снова нужно воспользоваться cks:dev с готовым шаблоном. В готов шаблоне надо поменять в svc файле тип фабрики с MultipleBaseAddressBasicHttpBindingServiceHostFactory на MultipleBaseAddressWebServiceHostFactory чтобы можно было вызывать методы сервиса из js.

Выкинув все лишнее можно получим до безобразия простой сервис:

[Guid("36471285-d168-49ea-b191-6c83cfe1fe3e")]
[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Required)]
[ServiceContract]
public class CleanupService
{
    [OperationContract]
    [WebInvoke(
        UriTemplate = "/SubmitLibraryToCleanup({listId})",
        BodyStyle = WebMessageBodyStyle.Bare,
        RequestFormat = WebMessageFormat.Json,
        ResponseFormat = WebMessageFormat.Json)]
    public void SubmitLibraryToCleanup(string listId)
    {
        CleanupUtility.AddCleanupWorkitem(SPContext.Current.Web, new Guid(listId));
    }
}

Как видите, большую часть сервиса занимают атрибуты. Метод вызывается с помощью POST, параметры передаются прямо в строке запроса.

Далее необходимо в script.js файле поправить метод submitLibraryToCleanup чтобы он вызывал веб-сервис.

ВНИМАНИЕ. Приведенный далее код работает, но крайне не рекомендую использовать его в своих решениях. Используйте более человеческие библиотеки, вроде jQuery.

function submitLibraryToCleanup(isOldSchool) {
    var listId = SP.ListOperation.Selection.getSelectedList();
    if (listId) {
        var notification = null;
        var request = new Sys.Net.WebRequest();
        request.set_url(GetWebUrl() + "_vti_bin/CleanupTimerJob/CleanupService.svc/SubmitLibraryToCleanup("+listId+")");
        request.set_httpVerb("POST");
        request.add_completed(function(executor, eventArgs) {
            SP.UI.Notify.removeNotification(notification);
            SP.UI.Notify.addNotification("Done",false);
        });             
        notification = SP.UI.Notify.addNotification("Submitting library to cleanup",true);
        request.invoke();
    }
}

Этот код использует так называемую Microsoft Ajax Library для выполнения запроса на сервер, и методы Client OM SharePoint для отображения оповещений.

Функция GetWebUrl скопирована отсюда.

Заключение

Надеюсь вы все таки дочитали до сюда и узнали что-то новое. Весь код можно найти на spsamples.codeplex.com



Решение задачи. Задача таймера, почти конец.

Посты в этой серии:

  1. Список задач для проверки навыков.
  2. Создание задачи таймера.
  3. Использование подходящих классов.
  4. Передача команд задаче таймера (этот пост).
  5. Взаимодействие веб-фронтэнда с задачами таймера.

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

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

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

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

Код Feature Receiver

public override void FeatureActivated(SPFeatureReceiverProperties properties)
{
    SetCleanupFlag(properties, true);
}

public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
{
    SetCleanupFlag(properties, false);
}

private void SetCleanupFlag(SPFeatureReceiverProperties properties, bool flag)
{
    var web = properties.Feature.Parent as SPWeb;
    web.Properties[Constants.FlagPropertyName] = flag.ToString();
    web.Properties.Update();
}

Изменения в Timer Job:

private void ProcessWeb(SPWeb web)
{
    if (Convert.ToBoolean(web.Properties[Constants.FlagPropertyName]))
    {
        tju.ProcessLists(web.Lists, ProcessList, null);
    }
}

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

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

WorkItems

Про механизм “очередей” в SharePoint я писал ранее, но тогда не было подходящей задачи для иллюстрации.

Используем простую идею: некоторое действие пользователя ставит в очередь задачу (SPWorkItem), а задача таймера (worker) анализирует очередь на наличие новых задач и обрабатывает их.

Для создания worker необходимо создать класс наследник SPWorkItemJobDefinition, в котором переопределить два метода WorkItemType и ProcessWorkItem. Документация на MSDN утверждает что вам необходимо переопределить один из методов ProcessWorkItems (с буквой s в конце) – не верьте.

При обработке work item есть много тонкостей, но я не буду их тут описывать, а просто использую уже знакомый класс TimerJobUtility, в котором уже сделана вся “грязная” работа.

public class CleaupWorker : SPWorkItemJobDefinition
{
    TimerJobUtility tju;
    WorkItemTimerJobState wiJobState = new WorkItemTimerJobState(true);

    public CleaupWorker() : base() { }

    public CleaupWorker(SPWebApplication webApp)
        : base(Constants.WorkerJobName, webApp)
    {
        this.Title = "Folder cleanup worker";
    }

    public override Guid WorkItemType()
    {
        return Constants.WorkItemType;
    }

    protected override bool ProcessWorkItem(SPContentDatabase contentDatabase, SPWorkItemCollection workItems, SPWorkItem workItem, SPJobState jobState)
    {
        tju = new TimerJobUtility(Constants.TimerJobName, jobState);
        return tju.ProcessWorkItem(workItems, workItem, wiJobState, ProcessWorkItemCore);
    }

    private void ProcessWorkItemCore(SPWorkItem wi, WorkItemTimerJobState timerJobstate)
    {
        var list = timerJobstate.Web.Lists[wi.ParentId];
        CleanupUtility.CleanupList(list);
    }
}

Тем, кто занимается разработкой для SharePoint Foundation повезло гораздо меньше, им придется все подводные камни обходить самостоятельно. Крайне рекомендую для этого посмотреть реализацию TimerJobUtility.

Чтобы поместить WorkItem в очередь необходимо вызывать метод SPSite.AddWorkItem. Остается самый интересный вопрос: где вызывать этот метод. Об этом, и многом другом в следующей статье.



Убрать левую панель в SharePoint

Именно с этим вопросом ко мне обратились трое за последнюю неделю.

Сделать это довольно просто c помощью CSS

#s4-leftpanel
{
    display:none;
}

.s4-ca
{
    margin-left:auto;
}

Но вот где написать этот стиль…

  1. Если вам нужно получить такой эффект на одной странице, то необходимо добавить в контент AdditionalPageHead.
  2. Если нужно для всего сайта, то поправить MasterPage или CSS, который используется на мастер-странице.
  3. Если нужно такое на странице веб-частей, то ничего не нужно делать. Уже существует шаблон страницы веб-частей где убрана левая панель.
  4. Если нужно убрать левую панель во всей коллекции сайтов, то можно создать в корневом сайте файл и подключить его с помощью SPWeb.AlternateCssUrl. При включении фич публикации этой настройкой можно управлять из админки сайта.


Решение задачи. Задача таймера, продолжение.

  1. Список задач для проверки навыков.
  2. Создание задачи таймера.
  3. Использование подходящих классов (этот пост).
  4. Передача команд задаче таймера.
  5. Взаимодействие веб-фронтэнда с задачами таймера.

В прошлый раз я писал о том как создать задачу таймера в SharePoint. Такой код писать не нужно. Надеюсь никто не успел скопипастить код к себе в проект.

В SharePoint 2010 основной класс для создания задач таймера – SPPausableJobDefinition. В отличии от обычного SPJobDefinition, как вы можете догадаться из названия, SPPausableJobDefinition можно останавливать.

Чтобы создать приостанавливаемую задачу таймера надо переопределить метод Execute с параметром SPJobState. Объект класса SPJobState содержат как свойства, говорящие о том что задача должна остановиться, так и методы сохранения текущего состояния задачи таймера для продолжения работы.

С одной стороны возможность приостанавливать выполнение задачи таймера ведет только к усложнению кода, но нету необходимости писать код, он уже есть в сборке Microsoft.SharePoint.

Класс, который больше всего подходит к нашей задаче – SPAllSitesJobDefinition. Задачи таймера, наследующиеся от этого класса должны переопределить только один метод ProcessSite.

public class TimerJob : SPAllSitesJobDefinition
{
    public TimerJob()
        : base()
    {

    }

    public TimerJob(SPWebApplication webApp)
        : base(Constants.TimerJobName, webApp)
    {
        this.Title = "Folder cleanup job";
    }

    public override void ProcessSite(SPSite site, SPJobState jobState)
    {            
        foreach (SPWeb web in site.AllWebs)
        {
            try
            {
                ProcessWeb(web);
            }
            finally
            {
                web.Dispose();
            }
        }
    }

    private void ProcessWeb(SPWeb web)
    {
        //omited for clarity
    }
}

Кода получилось даже меньше чем в первом варианте, при этом он поддерживает приостановку и запуск с места остановки, а также обновляет значения прогресса выполнения (это новая фича SharePoint 2010).

В прошлом посте писал о выборе значения SPJobLockType. В SharePoint 2010 нет такой необходимости. Разные варианты запуска задач таймера реализованы разными классами в сборке Microsoft.SharePoint.
Например: SPContentDatabaseJobDefinition, SPFirstAvailableServiceJobDefinition, SPServerJobDefinition, SPServiceJobDefinition.

Далее можно модифицировать код ProcessSite и ProcessWeb чтобы поддерживать перезапуск задачи с конкретной библиотеки документов. Но если вы разрабатываете для SharePoint Server 2010 (платной версии), то вам и это не надо делать. Код уже написан.

TimerJobUtility

TimerJobUtility – класс из сборки Microsoft.Office.Server. Он позволяет обходить содержимое SharePoint учитывая возможность остановки и перезапуска задачи таймера.

С использованием TimerJobUtility методы будут выглядеть так:

TimerJobUtility tju;
public override void ProcessSite(SPSite site, SPJobState jobState)
{
    tju = new TimerJobUtility(Constants.TimerJobName, jobState);
    tju.DisableEventFiring = false;
    tju.CancellationGranularity = IterationGranularity.List;
    tju.ResumeGranularity = IterationGranularity.List;
    tju.ProcessSite(site, s => tju.ProcessSite(s, ProcessWeb, null));
}

private void ProcessWeb(SPWeb web)
{
    tju.ProcessLists(web.Lists, ProcessList, null);
}

private void ProcessList(SPList list)
{
    if (!list.Hidden && list is SPDocumentLibrary)
    {
        DeleteEmptyFolders(list.RootFolder.SubFolders);
    }
}

private void DeleteEmptyFolders(SPFolderCollection folders)
{
    //omited for clarity
}

С таким кодом в случае остановки и перезапуска задачи она продолжит выполнять начиная с того списка, который не был обработан. Кроме того есть возможность блокировать срабатывание обработчиков событий установкой флага DisableEventFiring.

SPFolderHierarchy

5000… Для всех разработчиков SharePoint 2010 это магическая цифра. Если у вас запрос должен обработать более 5000 строк, то выпадает SPQueryThrottledException. Причем даже если реально будет возвращено мало строк, но для их вычисления придется просмотреть более 5000 элементов, то будет ошибка. В таких случаях помогает индекс или постраничное разбиение.

Нетрудно догадаться что стандартная реализация свойства SubFolders считывает все элементы из папки, которых может оказаться более 5000., что вызовет ошибку. Чтобы обрабатывать такие случаи в SharePoint Server 2010 (платной версии) есть класс SPFolderHierarchy, который помогает избежать проблем и содержит множество эвристик для максимального быстродействия навигации по папкам.

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

public class TimerJob : SPAllSitesJobDefinition
{
    TimerJobUtility tju;

    public TimerJob(): base() { }

    public TimerJob(SPWebApplication webApp)
        : base(Constants.TimerJobName, webApp)
    {
        this.Title = "Folder cleanup job";
    }

    public override void ProcessSite(SPSite site, SPJobState jobState)
    {
        tju = new TimerJobUtility(Constants.TimerJobName, jobState);
        tju.DisableEventFiring = false;
        tju.CancellationGranularity = IterationGranularity.List;
        tju.ResumeGranularity = IterationGranularity.List;
        tju.ProcessSite(site, s => tju.ProcessSite(s, ProcessWeb, null));
    }

    private void ProcessWeb(SPWeb web)
    {
        tju.ProcessLists(web.Lists, ProcessList, null);
    }

    private void ProcessList(SPList list)
    {
        if (!list.Hidden && list is SPDocumentLibrary)
        {
            DeleteEmptyFolders(new SPFolderHierarchy(list));
        }
    }

    private void DeleteEmptyFolders(SPFolderHierarchy h)
    {
        foreach (SPFolder folder in (h as IEnumerable<SPFolder>))
        {
            if (folder.Item != null)
            {
                DeleteEmptyFolders(h.GetSubFolders(folder.ServerRelativeUrl));
                if (folder.ItemCount == 0)
                {
                    folder.Delete();
                }
            }
        }
    }
}

Кроме повышенной надежности такого кода есть еще одно преимущество: классы TimerJobUtility и SPFolderHierarchy очень активно пишут диагностические сообщения в ULS. Таким образом вам гораздо будет легче отлаживать такой код на удаленной машине.



Решение задачи. Задача таймера.

  1. Список задач для проверки навыков.
  2. Создание задачи таймера (этот пост).
  3. Использование подходящих классов.
  4. Передача команд задаче таймера.
  5. Взаимодействие веб-фронтэнда с задачами таймера.

Ранее я приводил список задач для проверки навыков программирования для SharePoint. Сегодня напишу о решении четвертой задачи про задачу таймера для очистки библиотек документов от пустых папок.

Задача

Создать задачу таймера (Timer Job), которая буде находить в библиотеках документов пустые папки, в которых нет файлов и которые содержат пустые папки, и удалять их.

Класс задачи таймера

Чтобы создать задачу таймера необходимо создать класс, унаследованный от Microsoft.SharePoint.Administration.SPJobDefinition. Этот класс недоступен в sandbox, поэтому вам нужен farm solution.

В этом классе необходимо переопределить метод Execute и конструктор с параметрами.

public TimerJob(SPWebApplication webApp)
    : base(Constants.TimerJobName, webApp, null,
           SPJobLockType.ContentDatabase)
{
    this.Title = "Folder cleanup job";
}

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

Так как ферма SharePoint может состоять из нескольких серверов, то появляется интересный вопрос – где и сколько раз будет запускаться задача таймера. Особое внимание надо уделить параметру SPJobLockType. Детальное описание по ссылке. Возможные варианты:

  • SPJobLockType.ContentDatabase  - задача таймера запускается для каждой контентной базы данных родительского приложения.
  • SPJobLockType.Job – задача таймера выполняется один раз на всю ферму. В данном режиме учитывается параметр SPServer конструктора, позволяющий указать конкретный сервер для запуска Timer Job.
  • SPJobLockType.None – запускается на каждом сервере в ферме, где развернут родительский сервис. Очень полезно если вам надо запустить некоторый некоторый процесс на каждом сервере в ферме.
Добавление и удаление задачи таймера

Для добавления задачи таймера удобно использовать фичу уровня фермы или веб-приложения с флагом Activate On Default равным true. При разветрывании решения с такой фичей она автоматически активируется в указанной области действия.

Код feature receiver:

public override void FeatureActivated(SPFeatureReceiverProperties properties)
{
    var webApp = properties.Feature.Parent as SPWebApplication;
    var job = new TimerJob(webApp);
    job.Schedule = new SPHourlySchedule()
    {
        BeginMinute = 0,
        EndMinute = 59
    };

    job.Update();
}


public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
{
    var webApp = properties.Feature.Parent as SPWebApplication;
    var job = webApp.JobDefinitions.GetValue<TimerJob>(Constants.TimerJobName);
    job.Delete();
}

Класс SPHourlySchedule является наследником SPSchedule и позволяет задавать расписание запуска задачи таймера.

Важно. Если вы попытаетесь добавить задачу таймера в фиче уровня Site или Web, то при деплое из Visual Studio оно сработает, а при попытке активировать фичу из веб-интерфейса упадет. Это новое ограничение SharePoint 2010, не позволяющее делать Update для классов наследников SPPersistedObject из контекста веб-приложения.

Если же у вас есть унаследованный код, который вы не можете поправить, то вам скорее всего пригодится эта статья.

Обработка элементов списков

Теперь перейдем к основной функции задачи таймера – очистка папок.

public override void Execute(Guid targetInstanceId)
{
    SPWebApplication webApplication = this.Parent as SPWebApplication;
    SPContentDatabase contentDb = webApplication.ContentDatabases[targetInstanceId];

    ProcessDatabase(contentDb);
}

Так как при создании Timer Job был указан SPJobLockType.ContentDatabase, то в качестве параметра targetInstanceId будет ID базы данных контента.

Далее циклы по SPSite и SPWeb:

private void ProcessDatabase(SPContentDatabase contentDb)
{
    foreach (SPSite site in contentDb.Sites)
    {
        try
        {
            ProcessSite(site);
        }
        finally
        {
            site.Dispose();
        }
    }
}

private void ProcessSite(SPSite site)
{
    foreach (SPWeb web in site.AllWebs)
    {
        try
        {
            ProcessWeb(web);
        }
        finally
        {
            web.Dispose();
        }
    }
}

Вот такие конструкции необходимо использовать чтобы пройтись по всем SPSite и SPWeb в базе данных. Если не напишите Dispose в циклах, то на сервере очень быстро закончится память.

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

Для того чтобы обезопасить себя и ваших пользователей необходимо использовать утилиту SPDisposeCheck. Она вам подскажет где надо совободить объекты.

Ну и наконец удаление папок:

private void ProcessWeb(SPWeb web)
{
    foreach (var lib in web.Lists.OfType<SPDocumentLibrary>())
    {
        if (!lib.Hidden)
        {
            DeleteEmptyFolders(lib.RootFolder.SubFolders);
        }
    }
}

private void DeleteEmptyFolders(SPFolderCollection folders)
{
    foreach (var folder in folders.OfType<SPFolder>().ToList())
    {
        DeleteEmptyFolder(folder);
    }

}

private void DeleteEmptyFolder(SPFolder folder)
{
    if (folder.Item != null)
    {
        DeleteEmptyFolders(folder.SubFolders);

        if (folder.ItemCount == 0)
        {
            folder.Delete();
        }
    }
}

Не копипастите код из статьи до того как прочитаете следующую часть.