воскресенье, 7 сентября 2008 г.

Интеграция данных: REST + LINQ = ADO.NET Data Services

Одним из интересных аспектов SOA является интеграция данных. Выдержка из Wikipedia:

Интеграция данных – это процесс комбинирования данных из разнообразных источников и предоставление их пользователи в неком унифицированном виде. Необходимость в разработке этого процесса появляется в различных областях. Например, в коммерческой (когда двум схожим компаниям необходимо объединить свои базы данных) и в научной (объединение данных из нескольких разных хранилищ биометрической информации).

Необходимость в таком виде интеграции возникла в одном из текущих проектов. Со стороны разработчика хотелось получить максимально прозрачный API, не зависящий от способа реализации и предоставляющий удобный и максимально "родной" механизм работы с данными.

Введение

Главный вопрос, на который пришлось найти ответ - это технология открытия доступа к удаленной базе данных. Выбор пал на технологию REST. Вкратце, она позволяет нам получать нужные данные, формируя запросы в виде GET-параметров к сервису. Дополнительно к этому хотелось бы получить транслятор с LINQ к REST, используя LINQ как раз как тот самый унифицированный и "родной" механизм, который позволит связать воедино данные из любых источников, для которых реализована соответствующая библиотека Linq2***.

Поиски соответствующей библиотеки были недолгими: не так давно Microsoft выпустило в составе .NET 3.5 SP1 свой проект Astoria, назвав его ADO.NET Data Services. С одной стороны эта библиотека позволяет легко создавать сервисы данных, открывающие доступ к данным на сервере посредством протокола REST. А с другой стороны, предоставляет удобные средства интеграции этих сервисов с клиентскими приложениями, оборачивая всю рутину по доступу к сервису в автосгенерированные прокси-классы. Ну и конечно же эти клиентские прокси-классы реализуют LINQ-транслятор. Кроме того, сервисы данных реализуются на базе стека WCF, предоставляя широкие возможности по тонкой низкоуровневой настройке.

Реализация сервиса

Для начала добавим в веб-проект сервис данных:

Add New Item - ADO.NET Data Service

Исходный код сервиса будет таким:

public class WebDataService : DataService< /* TODO: put your data source class name here */ >
{
    // This method is called only once to initialize service-wide policies.
    public static void InitializeService(IDataServiceConfiguration config)
    {
        // TODO: set rules to indicate which entity sets and service operations are visible, updatable, etc.
        // Examples:
        // config.SetEntitySetAccessRule("MyEntityset", EntitySetRights.AllRead);
        // config.SetServiceOperationAccessRule("MyServiceOperation", ServiceOperationRights.All);
    }
}

Если вы обратитесь к спецификации класса DataService<T>, то увидите, что к типу Т не предоставляются такие требования, как реализация какого-либо инетрфейса. Все дело в том, что необходимые требования невозможно описать в виде какой-либо простой модели, которая бы не усложнила разработку этих сервисов. Но требования, тем не менее, есть:

  1. Сервис данных может опубликовывать все IQueryable-свойства данного источника данных.
    Наш источник данных может выглядеть таким образом:
    public class DomainDataContext
    {
        private readonly Supplier[] _suppliers;
        private readonly Product[] _products;
    
        public DomainDataContext()
        {
            // Инициализация _suppliers и _products тестовыми данными
        }
    
        public IQueryable<Supplier> Suppliers
        {
            get { return _suppliers.AsQueryable(); }
        }
    
        public IQueryable<Product> Products
        {
            get { return _products.AsQueryable(); }
        }
    }
  2. Классы-сущности должны быть "идентифицируемыми". 
    Это правило в данном случае означает, что в классах обязательно должны быть свойства, заканчивающиеся на ID:
    public class Product
    {
        public string ProductID { get; set; }
        public string SupplierID { get; set; }
        public string ProductName { get; set; }
    }
    
    public class Supplier
    {
        public string SupplierID { get; set; }
        public string CompanyName { get; set; }
        public string ContactName { get; set; }
        public string Phone { get; set; }
    } 

И это не удивительно, что в качестве источников данных можно использовать модели Linq2Sql и ADO.NET Entity Framework.

Посмотрев на наш сервис в браузере, мы увидим такой XML:

<?xml version="1.0" encoding="utf-8" standalone="yes" ?> 
<service xml:base="http://localhost:7593/WebDataService.svc/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app" xmlns="http://www.w3.org/2007/app">
   <workspace>
      <atom:title>Default</atom:title> 
   </workspace>
</service>

Никаких упоминаний о наших Products и Suppliers. Мы забыли сконфигурировать наш сервис, указав доступ к коллекциям объектов и операциям. Но так как наш сервис не предоставляет никаких методов для манипуляции данными, нам нужно сконфигурировать только доступ к коллекциям в источнике данных:

public static void InitializeService(IDataServiceConfiguration config)
{
    config.SetEntitySetAccessRule("*", EntitySetRights.AllRead);
}

Открываем сервис в браузере и видим наши коллекции:

<?xml version="1.0" encoding="windows-1251" standalone="yes"?>
<service xml:base="http://localhost:7593/WebDataService.svc/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app" xmlns="http://www.w3.org/2007/app">
  <workspace>
    <atom:title>Default</atom:title>
    <collection href="Suppliers">
      <atom:title>Suppliers</atom:title>
    </collection>
    <collection href="Products">
      <atom:title>Products</atom:title>
    </collection>
  </workspace>
</service>

Итак, наш сервис готов к использованию.

Реализация клиента

Так как наш сервис данных построен на основе WCF, то подключение сервиса ничем не отличается от подключения любого другого WCF-сервиса. Самое же интересное начинается во время его использования.

В качестве примера реализуем логику поиска поставщиков по названию товара. Локально поиск будет проводиться по базе Northwind с использованием Linq2Sql, а потом результат локального поиска будет объединяться с поиском через сервис данных.

Локальная схема данных будет выглядеть следующим образом:

Local data schema

Поисковый запрос к локальной базе достаточно тривиален:

var localData = new LocalDataDataContext();

var localSuppliers = from p in localData.Products
                     join s in localData.Suppliers on p.SupplierID equals s.SupplierID
                     where p.ProductName == productName
                     select
                         new 
                         {
                             Company = s.CompanyName,
                             PhoneNumber = s.Phone
                         };

Попробуем сделать то же самое для поиска по удаленному сервису данных:

var serviceData = new DomainDataContext(new Uri("http://localhost:7593/WebDataService.svc"));

var dataServiceSuppliers = from p in serviceData.Products
                           join s in serviceData.Suppliers on p.SupplierID equals s.SupplierID
                           where p.ProductName == productName
                           select
                               new
                                   {
                                       Company = s.CompanyName,
                                       PhoneNumber = s.Phone
                                   };

При попытке получить данные по этому запросу мы получим исключение "The method 'Join' is not supported.". По сути, это Законы дырявых асбстракций в действии :) Все дело в том, что не вся функциональность Linq однозначно транслируется в REST-запросы. REST-сервисы предоставляют лишь несколько параметров запросов: expand, orderby, skip, top, filter. Как видно, никаких inner join и даже агрегатов, типа банального count тут нет.

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

var serviceData = new DomainDataContext(new Uri("http://localhost:7593/WebDataService.svc"));

var matchedProducts = from p in serviceData.Products
                                        where p.ProductName == productName
                                        select p;

Следующий фрагмент кода динамически соберет условие для параметра filter так, чтобы получилось что-то в таком духе:

Func<ServiceReference1.Supplier, bool> filterCondition =
    s => s.SupplierID == "001" || s.SupplierID == "002";

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

var param = Expression.Parameter(typeof(ServiceReference1.Supplier), "s");

Func<string, IEnumerable<string>, Expression> buildOrElseExpression = null;

Func<string, Expression> buildEqualExpression =
    val => Expression.Equal(Expression.Property(param, "SupplierID"), Expression.Constant(val));

Func<IEnumerable<string>, Expression> buildTailExpression = tail => tail.Count() == 1
                                       ? buildEqualExpression(tail.First())
                                       : buildOrElseExpression(tail.First(), tail.Skip(1));

buildOrElseExpression =
    (head, tail) => Expression.OrElse(
                        buildEqualExpression(head),
                        buildTailExpression(tail));

// Вычитываем данные из сервиса и получаем отключенный список идентификаторов поставщиков
var serviceSupplierIDs = matchedProducts.ToArray().Select(p => p.SupplierID);
var filterCondition =
    Expression.Lambda<Func<ServiceReference1.Supplier, bool>>(
    buildTailExpression(serviceSupplierIDs), param);

// Получаем отключенную копию результатов поиска для дальнейших манипуляций
var serviceSuppliers = serviceData.Suppliers.
    Where(filterCondition).
    ToArray().
    Select(s => new
                    {
                        Company = s.CompanyName,
                        PhoneNumber = s.Phone
                    });

 

Ну и наконец-то финальное объединение:

// Перед объединением получаем отключенную копию данных.
// В противном случае Linq2Sql попытается транслировать этот метод в sql-инструкцию UNION
var allSuppliers = localSuppliers.ToArray().Union(serviceSuppliers);

Выводы

Конечно же, ADO.NET Data Services - это решение многих проблем интеграции данных внутри системы. Но использование этой системы накладывает и ряд требований:

  1. Модель данных для таких сервисов должна быть подготовлена специальным образом так, чтобы избежать множества повторяющихся простых запросов из-за ограничения протокола REST. В общем случае, эта подготовка сведется к денормализации исходной модели данных, к укрупнению классов сущностей и введению всяких агрегатных полей с данными из дочерних сущностей.
  2. Для вычисления агрегатных функций (таких, как count(), sum() и т.д.) нужно будет реализовать специальные методы вне контекста сервисов данных.
  3. Все операции проекции должны проводиться на отсоединенной выборке. Таким образом, с учетом требований в п.1 такими запросами не стоит злоупотреблять по причиные избыточности данных, передаваемых через транспортный уровень. Для решения этой проблемы можно было бы расширить нашу модель одинаковыми сущностями с различными уровнями детализации.

Наверное, стоит наедятся, что в будущем многие из недостающих сегодня возможностей будут реализованы. Но даже то, что у нас есть на сегодняшний день - это большой шаг вперед в области интеграции приложений.

Исходный код тестового приложения

Опубликовать

2 комментария:

  1. ADO.NET Data Services - это не Linq-over-HTTP. Не надо пытаться использовать реляционные операторы в запросах к такому сервису.
    ADO.NET Data Services вполне поддерживает навигацию по связанным сущностям, используйте нормальную модель и вам не придется писать джоины на клиенте.

    ОтветитьУдалить
  2. Почему же нельзя - можно :)
    Другое дело - как и насколько эффективно это будет.

    Я как раз и показал, что в лоб этим способом задачи интеграции не решаются.

    ОтветитьУдалить