ORM для Sitecore своими руками

в 9:57, , рубрики: .net, cms, sitecore, метки: , ,

Здравствуйтее.

sitecore
Sitecore мало освещается на хабре, однако это очень функциональная (и дорогая) CMS довольно популярна у тех, кто может её себе позволить. Вместе с тем, люди разрабатывающие (и особенно поддерживающие) сайты на sitecore часто жалуются на трудности модификации темплейтов. Так, простое переименование темплейта или одного поля может привести к непредсказуемым и, главное, трудно поддающимся диагностике и исправлению нарушениям в работе сайта. Причём вылезти они могут только через несколько месяцев. Кроме того, использование стандартных сайткоровских FieldRenderer-ов затрудняет контроль над разметкой, что было критично в нашем случае.

Зачем велосипед?

Существуют решения для генерации классов на основе темплейтов (как trac.sitecore.net/CompiledDomainModel), однако они не очень удобны в использовании и не устраняют привязку к структуре темплейта, именам полей. Упомянутый CompiledDomainModel требует регенерации всех моделей после любых изменений. Также он слабо подходит для совместной разработки (постоянные конфликты в сгенерированном коде), требует уникальных имён для всех темплейтов, завязывается на пути к темплейтам и ID-шки и генерирует чудовищный код одним файлом (на одном из проектов там было больше 60 000 строк и открыть его в VS было делом очень не быстрым).


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

Далее о форме колёс

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

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

Note: здесь и далее в код сокращён для передачи основной мысли и удобочитаемости

[DataContract(Name = "Base text page")]
public class BaseTextPage : Template
{...}

public class Template
{
    private readonly Item item;

    public Template(Item item)
    {
        var missedTemplates = GetMissedTemplates(item, this.GetType()); //тут мы читаем DataContract и проверяем валидность создания класса из данного Item-а.
        if (missedTemplates.Any())
        {
          ...                
            throw new InvalidDataException("Item is not of required template”); // с указанием что и где не хватает
        }

        this.item = item;
    }
…
}

В том же классе Template находятся несколько полезных функций для обращения к полям темплейта:

protected T GetField<T>(string name, T @default = default(T))
{
          var dataType = typeof(T);
          var field = this.Item.Fields[name];
…

    //несколько конструкций вида:

    if (dataType == typeof(string))
        {
            if (string.IsNullOrEmpty(field.Value))
            {
                return @default;
            }
            return (T)(object)field.Value;
        }

        if (dataType == typeof(LinkField))
        {
            return (T)(object)new LinkField(field);
        }

        if (dataType == typeof(ImageField))
        {
            return (T)(object)new ImageField(field);
        }
… //etc for all field types
}

protected T GetFromField<T>(string name) where T : Template
{
          var link = this.GetField<ReferenceField>(name);
          if (link != null && link.TargetItem != null)
          {
              return (T)Activator.CreateInstance(typeof(T), link.TargetItem);
          }
          return null;
 }

 protected T GetFromParent<T>() where T : Template
 {
        if (this.Item == null || this.Item.Parent == null)
        {
            return default(T);
        }
        return (T)Activator.CreateInstance(typeof(T), this.Item.Parent);
}

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

[DataContract(Name = "Base text page")]
public class BaseTextPage : Template
{
    [DataMember(Name = "Big text content")]
    public string Text
    {
        get
         {
             return this.Item[this.GetFieldName(x => x.QuestionText)];
         }
}

    [DataMember(Name = "Logo Image")]
    public string LogoImage
    {
        get
        {
            return this.GetField<ImageField>(this.GetFieldName(x => x.BigImage)).GetMediaUrl();
        }
    }
…
}

Самое важное тут — функция GetFieldName, объявленная как extension-method вида:

private static readonly Dictionary<string, string> fieldNameCache = new Dictionary<string, string>();

public static string GetFieldName<T, TResult>(this T obj, Expression<Func<T, TResult>> memberExpression) where T : class
{
    if (obj == null)
    {
        throw new ArgumentNullException("obj");
    }
    var member = memberExpression.ToMember();

    if (member.MemberType != MemberTypes.Property)
    {
        throw new ArgumentException("Not a property access", "memberExpression");
    }

    var fieldCahceKey = typeof(T).Name + member.Name;

    if (fieldNameCache.ContainsKey(fieldCahceKey))
    {
        return fieldNameCache[fieldCahceKey];
    }
    var fieldName = typeof(T)
          .GetProperty(member.Name)
          .GetCustomAttributes(typeof(DataMemberAttribute), true)
          .Cast<DataMemberAttribute>()
          .Select(curr => curr.Name)
          .FirstOrDefault();

    if (string.IsNullOrEmpty(fieldName))
    {
        return null;
    }

    fieldNameCache[fieldCahceKey] = fieldName;

    return fieldName;
}

private static MemberInfo ToMember<TMapping, TReturn>(
          this Expression<Func<TMapping, TReturn>> propertyExpression)
{
    if (propertyExpression == null)
    {
        throw new ArgumentNullException("propertyExpression");
    }

    var expression = propertyExpression.Body;

    if (expression.NodeType == ExpressionType.MemberAccess)
    {
        var memberExpression = expression as MemberExpression;

        if (memberExpression != null)
        {
            return memberExpression.Member;
        }
    }

    throw new ArgumentException("Not a member access", "propertyExpression");
}

На этом этапе мы можем написать что-нибудь типа:

BaseTextPage page = new BaseTextPage(Sitecore.Context.Item);
var text = page.Text;
var imageUrl = page.LogoImage;

и получить данные из полей “Big text content”/”Logo image” текущего Item-а, при условии, что его темплейт подойдёт классу BaseTextPage.

Далее мы делаем обёртку для того темплейта, который будет базовым для всех других наших темплейтов. Как минимум это будет “Standart template”, но обычно лучше сделать что-нибудь более полезное. К примеру

[DataContract(Name = "Base page")]
public class BasePage : Template
{
    [DataMember(Name = "Show in menu")]
    public bool ShowInMenu
    {
      get
          {
              return this.Item[this.GetFieldName(x => x.ShowInMenu)].GetBoolValue();
          }
    }

    [DataMember(Name = "Page title")]
    public string Title
    {
        get
            {
                return this.Item[this.GetFieldName(x => x.Title)];
            }
    }
}

Теперь внедряем всё это в Sublayout-ы:

public class BaseSublayout<T> : UserControl
      where T : BasePage
{
    protected virtual T Model
    {
      get
          {
              return (T)Activator.CreateInstance(typeof(T), Sitecore.Context.Item);
          }
    }
}


public partial class ConcreteTextPage: BaseSublayout<MyProject.ORM.Content.ConcreteMapping>
{
    protected void Page_Load(object sender, EventArgs e)
    {
        var smthUsefull = this.Model.HeaderText;
    }
}

С этого момента содержимое .aspx файлов начинает напоминать таковую в ASP.MVC. Для усиления эффекта удобства сделан комплект extension-методов для вывода разметки со стандартными проверками на присутствие/валидность данных (например, не выводить картинки с пустым src или ссылки без href).

<h1><%= this.Model.Header %></h1>
<%= HtmlHelper.RenderImage(this.Model.SomeEntity.MainImage) %>
<% foreach (var link  in this.Model.SelectedLinks) { %>
    <%= HtmlHelper.Anchor(link.Url, link.Text) %>
<% } %>

Плюсы подхода:
+ все нужные поля контекстного Item-а под рукой, в виде свойств
+ контекстный Item всегда имеет правильный темплейт
+ все стандартные для сайткора проверки на наличие данных сделаны в одном месте
+ централизованное обращение к настройкам сайта, для которых написаны аналогичные обёртки
+ чистый (минимальный, отсутствующий) код страниц
+ полный контроль вёрстки
Минусы
— все маппинги руками
— расходы на извлечение имён полей/темплейтов

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

Автор: danl

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js