При разработке одного из проектов, мне понадобилась интеграция с 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