Клиент с поддержкой Linq для Microsoft Dynamics CRM (альтернатива клиенту из sdk)

в 10:49, , рубрики: .net, CRM-системы, linq, microsoft dynamics crm, метки: ,

При разработке одного из проектов, мне понадобилась интеграция с MS CRM… Посмотрев на стандартные механизмы запросов в msdn я понял, что это немного неудобно, а так как проект обещает быть длинным, и даже бесконечным (внутренняя автоматизация), я решил что использование QueryExpression в чистом виде приведет к серьезному увеличению трудозатрат и станет рассадником ошибок по невнимательности (так как разработчики по проекту будут частенько меняться — у кого есть время, тот и занимается).

Итак, было принято решение написать обертку над QueryExpression и добавить возможность построения fluent запросов как в EF. Сразу оговорюсь, что при написании этой обертки (где-то в середине) я нашел библиотеку из sdk которая предоставляет такую возможность — sdk crm client но посмотрев на нее повнимательнее я понял что там нет документации(!!!) и нескольких полезных возможностей, например: использование in в where, добавление условий к join и еще несколько помельче. Сравнительную таблицу приведу позже.

Так как проект обещает быть долгим, я все же решил дописать свою реализацию…

Задачи:

  • Дать возможность определить DataContract для нужных сущностей CRM, чтобы при изменении/удалении/добавлении полей нужно было учитывать это только в одном месте
  • Предоставить возможность написания fluent запросов к CRM для поддержки строгой типизации, и выявления максимального количества проблем от удаления или изменения типа поля на этапе компиляции
  • Предоставить возможность настраивать result set получаемый от CRM чтобы было удобно минимизировать трафик (Select, Join, Where и т.д.)
  • Обеспечить безопасность и ролевой доступ к данным в CRM (ASP.NET Impersonation в моем случае)

Что в итоге получилось:

Проект представляет из себя сборку, всего с одной дополнительной зависимостью — microsoft.xrm.sdk.dll, которую просто подключить к проекту.

Клиент

Сборка предоставляет абстрактный базовый класс для создания клиента — CrmClientBase. В этом классе одно абстрактное поле, которое должно быть переопределено:

protected abstract IWcfCrmClient WcfClient { get; } 

IWcfCrmClient — это интерфейс взаимодействия с добавленным к проекту WCF клиентом (Service Reference).

Как создать класс клиента лучше показать на примере (В большинстве случаев достаточно его просто скопировать в проект, подправить using и все должно заработать):

using System;
using CrmClient;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using MsCrmClientTest.MSCRM;

public class OrgCrmClient : CrmClientBase
{
    private class WcfCrmClient : IWcfCrmClient
    {
        private OrganizationServiceClient _client;

        public Guid Create(Entity entity)
        {
            return _client.Create(entity);
        }
        public void Update(Entity entity)
        {
            _client.Update(entity);
        }
        public void Delete(string entityName, Guid id)
        {
            _client.Delete(entityName, id);
        }
        public EntityCollection RetrieveMultiple(QueryBase query)
        {
            return _client.RetrieveMultiple(query);
        }
        public OrganizationResponse Execute(OrganizationRequest request)
        {
            return _client.Execute(request);
        }
        public void Close()
        {
            _client.Close();
        }
        public WcfCrmClient()
        {
            _client = new OrganizationServiceClient();
        }
    }
    private IWcfCrmClient _wcfClient;
    protected override IWcfCrmClient WcfClient
    {
        get
        {
            if (_wcfClient == null)
                _wcfClient = new WcfCrmClient();
            return _wcfClient;
        }
    }
}

OrganizationServiceClient — это клиент из Service Reference

Mapping

Для работы с сущностями CRM нужно их замапить на классы (определить data contract). Для этого есть 2 атрибута (стандартные атрибуты из сборки microsoft.xrm.sdk.dll)

  • EntityLogicalNameAttribute(string name) — определяет имя сущности в CRM
  • AttributeLogicalNameAttribute(string name) — определяет имя поля сущности в CRM

Если атрибуты не заданы, то в качестве имя сущности/поля в CRM используются имя класса/имя свойства.

Каждый data contract должен быть унаследован от базового класса CrmDataContractBase. Это абстрактный класс с одним абстрактным свойством

public abstract Guid Id { get; set; }

которое нужно переопределить и тоже пометить атрибутом AttributeLogicalName.
Пример data contract:

[EntityLogicalName("systemuser")]
public class User : CrmDataContractBase
{
    [AttributeLogicalName("systemuserid")]
    public override Guid Id { get; set; }
    [AttributeLogicalName("fullname")]
    public string Name { get; set; }
    [AttributeLogicalName("parentsystemuserid")]
    public EntityReference Сhief { get; set; }
    [AttributeLogicalName("caltype")]
    public OptionSetValue CALType
}

ВАЖНО!

  • Если свойство является ссылкой на другую сущность CRM (как Сhief в примере) оно должно быть типа EntityReference
  • Если свойство является значением из перечисления CRM (как CALType в примере) оно должно быть типа OptionSetValue

Mapping перечислений CRM

Чтобы замапить перечисления CRM, нужно определить класс, унаследовать его от CrmOptionsSetBase и пометить его атрибутом EntityLogicalName, в котором указать имя перечисления в CRM:

[EntityLogicalName("connectionrole_category")]
public class ConnectionRoleCategoryEnum : CrmOptionsSetBase
{ }

CrmOptionsSetBase реализует интерфейс IEnumerable типа CrmOption, т.е. им сразу можно пользоваться как источником данных для контролов.
Класс CrmOption содержит 2 свойства:

public string Label { get; private set; }
public OptionSetValue Value { get; private set; }

В Label содержится отображаемое имя элемента, а Value это тот самый OptionSetValue, используемый в data contract сущностей CRM

Использование клиента

Добавление, изменение, удаление сущностей CRM

Это простые операции, все должно быть понятно из примера:

[EntityLogicalName("new_nsi")]
public class NSI : ICrmDataContract
{
    [AttributeLogicalName("new_nsiid")]
    public override Guid Id { get; set; }
    [AttributeLogicalName("new_name")]
    public string Name { get; set; }
}
//Добавление
var newnsi = new NSI { Name = "Test NSI" };
_client.Add(newnsi); //Метод 'Add' проставляет свойство 'Id' для добавленной сущности, как в EF
//Изменение
newnsi.Name = "Test NSI 2";
_client.Update(newnsi);
//Удаление
_client.Delete(newnsi);

ВАЖНО! Все изменения применяются в CRM сразу же. Транзакционности нет (не разбирался с этим еще)

Получение перечислений CRM

Для получения перечислений у клиента есть специальный метод

public T OptionsSet<T>()

Где T — data contract перечисления. Пример (data contarct описан выше):

var optionSet = _client.OptionsSet<ConnectionRoleCategoryEnum>();

Запросы к CRM с использованием Linq

В namespace CrmClient.Linq определены следующие методы-расширения, предназначенные для формирования fluent запросов к CRM:

  • CrmSelect
  • CrmWhere
  • CrmJoin
  • CrmOrder
  • CrmPaging
  • CrmDistinct

Методы из этого namespace начинаются с Crm, чтобы сразу было видно где формируется запрос к CRM а где идет работа с уже выгруженными объектами.
Стартовый метод формирования запроса к CRM — Query:

public ICrmQueryable<T> Query<T>()

после него могут использоваться остальные методы формирования запроса.
Сам запрос к CRM выполняется. при вызове метода GetEnumerator(), т.е при попытке перечисления данных (как в EF).

Select

Анонимный тип

var users = _client.Query<CrmUser>()
    .CrmSelect(u => new
    {
        u.Id,
        u.Name,
        Test = 1
    })
    .ToList();

Класс (как и в EF, у класса должен быть конструктор без паарметров)

var users2 = _client.Query<CrmUser>()
    .CrmSelect(u => new TestUser()
    {
        Id = u.Id,
        FullName = u.Name,
        Test = 1
    })
    .ToList();

Where

//простой
var user = _client.Query<CrmUser>()
    .CrmWhere(i => i.Id == _directorUserId)
    .Single();

var list = new[] { _directorUserId };
// in
var filteredUsers = _client.Query<CrmUser>()
               .CrmWhere(i => list.Contains(i.Id))
               .ToList();
// not in
filteredUsers = _client.Query<CrmUser>()
               .CrmWhere(i => !list.Contains(i.Id))
               .ToList();
//like
var users = _client.Query<CrmUser>().ToList();
var firstUser = users.First(i => i.Name.Contains(" ")).Name.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
// like text%
var user = _client.Query<CrmUser>()
    .CrmWhere(i => i.Name.StartsWith(firstUser[0]))
    .ToList();
// like %text
user = _client.Query<CrmUser>()
    .CrmWhere(i => i.Name.EndsWith(firstUser[0]))
    .ToList();
// like %text%
user = _client.Query<CrmUser>()
   .CrmWhere(i => i.Name.Contains(firstUser[0]))
   .ToList();
// not like text%
user = _client.Query<CrmUser>()
    .CrmWhere(i => !i.Name.StartsWith(firstUser[0]))
    .ToList();
// not like %text
user = _client.Query<CrmUser>()
    .CrmWhere(i => !i.Name.EndsWith(firstUser[0]))
    .ToList();
// not like %text%
user = _client.Query<CrmUser>()
    .CrmWhere(i => !i.Name.Contains(firstUser[0]))
    .ToList();

ВАЖНО! Для конструкции 'in' нужно передавать только массив (array). Это ограничение я уберу чуть позже.

Так же есть поддержка составных условий (как в моем расширении для EF Условие «WHERE» по составным ключам в Entity Framework):

var users = _client.Query<CrmUser>().ToList();
var directors = users.Where(u => u.Director != null).Select(u => new { u.Director.Id, u.Director.Name }).Take(2);
var users2 = _client.Query<CrmUser>()
    .CrmWhere(ExpressionType.Or, directors, (u, d) => u.Id == d.Id && u.Name == d.Name, (pn, o) =>
        {
            switch (pn)
            {
                case "Id":
                    return o.Id;
                case "Name":
                    return o.Name;
                default:
                    return null;
            }
        })
    .ToList();

Order

var users = _client.Query<CrmUser>()
    .CrmOrderBy(i => i.Name)
    .CrmOrderByDescending(i => i.Id)
    .ToList();

Результат будет отсортирован по Name asc, потом по Id desc

Distinct

var users = _client.Query<CrmUser>()
    .CrmDistinct()
    .ToList();

Join

C Join дела обстоят чуть посложнее… Например, нужно сделать join пользователей на своих руководителей:

var users = _client.Query<CrmUser>()
    .CrmJoin(_client.Query<CrmUser>(), s => s.Chief.Id, d => d.Id, (s, d) => new { s.Id, s.Name, ChiefId = d.Id, ChiefFullName = d.Name })
    .ToList();

or

var users = _client.Query<CrmUser>()
    .CrmLeftJoin(_client.Query<CrmUser>(), s => s.Chief.Id, d => d.Id, (s, d) => new { s.Id, s.Name, ChiefId = d.Id, ChiefFullName = d.Name })
    .ToList();

Запрос выполнится правильно. Но если присмотреться внимательнее, свойство Id у s.Chief не замаплено никуда, так как это свойство класса EntityReference… Но само свойство s.Chief замаплено на нужное нам 'parentsystemuserid' CRM… Клиент сам разруливает данную ситуацию, подставляя в запрос к CRM 'parentsystemuserid' из s.Chief, а запись s => s.Chief.Id, d => d.Id нужна для совместимости типов.

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

var users = _client.Query<CrmUser>()
    .CrmJoin(_client.Query<CrmUser>().CrmWhere(u => u.Id == _directorUserId), s => s.Chief.Id, d => d.Id, (s, d) => new { s.Id, s.Name, ChiefId = d.Id, ChiefFullName = d.Name })
    .ToList();

or

var users = _client.Query<CrmUser>()
    .CrmLeftJoin(_client.Query<CrmUser>().CrmWhere(u => u.Id == _directorUserId), s => s.Chief.Id, d => d.Id, (s, d) => new { s.Id, s.Name, ChiefId = d.Id, ChiefFullName = d.Name })
    .ToList();

_client.Query().CrmWhere(u => u.Id == _directorUserId) — это и есть условие на join. Т.е. если это inner join то вернутся только пользователи, у которых директор с id _directorUserId. Здесь можно указать и другие условия, например Order, но это не даст никакого эффекта, учитывается только условие Where.

NoLock

Этот метод нужен для того, чтобы запросы на стороне CRM выполнялись с опцией with(nolock) полезно для получения данных для отчета за прошедший период. Пример:

var users = _client.Query<CrmUser>()
    .CrmNoLock()
    .ToList();

Paging

CRM запрос поддерживает постраничную выдачу результатов. Для выполнения постраничного запроса есть метод

List<T> CrmGetPage(int pageNumber, int pageSize, out int totalCount, out bool moreRecordsExists)

Он возвращает сразу List, это означает что его вызов сразу выполняет запрос к CRM.
Этот метод возвращает общее количество записей, и признак что еще есть данные для получения на стороне CRM. Пример:

int total;
bool moreExists;
var users = _client.Query<CrmUser>().CrmGetPage(1, 10, out total, out moreExists);

(Все примеры можно найти в тестовом проекте)

Особенности клиента

В клиенте используется reflection только для первоначально получения данных о mapping, и эти данные кэшируются в памяти. Из-за этого первый запрос будет выполняться медленно…
Код создания экземпляров классов компилируется динамически, так что время получение результата зависит только от времени выполнения запроса на стороне CRM и сетевых задержек. Код создания типов компилируется в памяти — одна сборка на каждый тип. Но здесь есть одна оговорка: создание анонимных типов происходит через конструктор через reflection. Это обусловлено тем, что в разных сборках анонимные типы имеют разный внутренний тип и приведение невозможно. Если кто знает как преодолеть это ограничение, напишите пожалуйста, я это поправлю.

Сравнение с клиентом из SDK

По скорости этот клиент и клиент из SDK практически одинаковы (разница в пределах сетевых задержек при выполнении теста). Из-за небольшого объема данных на тестовой CRM я так и не выяснил все ли клиент из SDK делает на стороне CRM или что-то уже на стороне приложения (например сортировка...), из кода теста этого не понять, так как он использует стандартный IQueryable и стандартные методы-расширения Linq.

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

Operation       ThisCrmClient           SdkCrmClient

Query           00:00:25.6005598        00:01:01.1291123
Select          00:00:03.5173517        00:00:03.6273627
Order           00:00:08.2558255        00:00:08.2338233
Where           00:00:04.1074107        00:00:03.9203920
WhereIn         00:00:05.3745374        not supported
%Like%          00:00:03.3983398        00:00:03.4093409
%Like           00:00:03.4403440        00:00:03.4163416
Like%           00:00:03.3093309        00:00:03.3033303
Join            00:00:03.4313431        00:00:03.4143414
JoinFilter      00:00:03.3833383        not supported
NoLock          00:00:09.6899689        not supported
Distinct        00:00:07.9847984        00:00:08.0328032

Сборки и исходники

Закачать сборку и посмотреть исходники можно на codeplex — mscrmclient. Solution состоит из 3х проектов:

  • MsCrmClient — сам клиент
  • MsCrmClientTest — тесты для клиента
  • PerfomanceTest — консольное приложение, для сравнения производительности с кkиентом из SDK

Вместо заключения

На codeplex я писал документацию на английском, русская версия документации — эта статья.

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

Автор: setsergey

Источник

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


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