image

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

Зачем это все?


Промышленные продукты, такие как MS Entity Framework, NHibernate сложны, имеют огромный функционал и по сути представляют собой отдельную вещь в себе. Часто для получения нужного поведения от таких ORM, необходим отдельный человек, хорошо разбирающийся в тонкостях работы таких систем, что не есть хорошо в командной разработке.

Главный теоретический источник поста — книга Мартина Фаулера «Patterns of Enterprise Application Architecture (P of EAA)».

Существует куча информации о том что такое собственно ORM, DDD, TTD, ГИБДД — на этом я постараюсь не останавливаться, моя цель практическая. Предполагается две части статьи, в самым конце есть ссылка на полный исходный код в git hub.

Что мы хотим получить?


Сразу определимся с терминами: под трекингом будем понимать отслеживание изменений бизнес объектов в рамках одной транзакции, для дальнейшей синхронизации данных, хранящихся
в оперативной памяти и содержимого бд.

Какие же есть основные типы ORM?

  • По использованию трекинга в .NET мире существует два типа ORM: с трекингом изменений и без трекинга изменений.
  • Касательно подхода к генерации sql-запросов: с явным использованием запросов и на основе генераторов запросов из объектных моделей.

Например, среди известных ORM без трекинга и с явным использованием запросов, самый яркий пример — Dapper ORM от Stack Overflow. Основной функционал таких ORM заключается в маппинге реляционной модели бд к объектной модели, при этом клиент явно определяет как будет выглядеть его запрос к бд.

Основные концепции MS Entity Framework и NHibernate в использовании трекинга и генераторов запросов из объектных моделей. Не буду тут рассматривать преимущества и недостатки этих подходов. Все йогурты одинаково полезны и истина в комбинировании подходов.

Заказчик (то есть я) захотел создать ORM с использованием трекинга (с возможностью выключения) и на основе генераторов запросов из объектных моделей. Генерировать sql-запросы будем из лямбда — выражений языка C#, с нуля, без применения Linq2Sql, LinqToEntities (да, только хардкор!).

Из коробки, в MS Entity Framework есть неприятность с пакетным обновлением и удалением, данных: необходимо сначала достать все объекты из бд, далее в цикле обновить\удалить и затем применить изменения к бд. В итоге получаем больше обращений к бд, чем нужно. Проблема описана тут. Решим эту проблему во второй части статьи.

Определимся с основными ингредиентами


Сразу определимся каким образом клиентский код будет взаимодействовать с разрабатываемой библиотекой. Выделим основные компоненты ORM которые будут доступны из клиентского кода.
В основе концепции клиентского взаимодействия с ORM будет лежать идея наследования от абстрактного репозитория. Также необходимо определить суперкласс для ограничения множества бизнес объектов, с которыми будет работать ORM.

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

Пустой репозиторий и суперкласс бизнес объекта
public abstract class Repository<T> : IRepository<T> 
where T : EntityBase,new()
    {
    //Позже реализуем методы
    }

//суперкласс бизнес объекта 
public abstract class EntityBase : IEntity
    {
//в потомках необходимо явно переопределить идентификатор
        public abstract int Id { get; set; }
        public object Clone()
        {
            return MemberwiseClone();
        }
    }

Метод Clone() нам пригодится для копирования объекта, при трекинге, об этом будет чуть ниже.

Пусть у нас будет клиентский бизнес объект, хранящий информацию о пользователе — класс Profile.

Чтобы использовать бизнес объект в ORM необходимо три шага:

  1. Осуществить привязку бизнес объекта к таблице в базе данных на основе атрибутов

    Класс Profile
               //Все бизнес объекты должны быть наследниками суперкласса EntityBase
    	    [TableName("profile")]
    	   public class Profile : EntityBase
    	    {
    	//Аргументом атрибута является точное название поля в бд
    	        [FieldName("profile_id")]
    	        public override int Id { get; }
    	        [FieldName("userinfo_id")]
    	        public int? UserInfoId { get; set; }
    	        [FieldName("role_id")]
    	        public int RoleId { get; set; }
    	        public string Info { get; set; }
    	    }
    	


  2. Определить репозиторий для каждого бизнес-объекта

    Репозиторий профиля будет иметь вид
        public class ProfileRepository : Repository<Profile>
        {
    //в качестве аргумента базовому классу передаем название строки подключения
            public ProfileRepository()
                : base("PhotoGallery")
            {
           //реализация клиентских CRUD методов 
            }
        }


  3. Сформировать строку подключения к бд

    Строка подключения может выглядеть так
    <connectionStrings>
        <add name="PhotoGallery" providerName="System.Data.SqlClient" connectionString="server=PC\SQLEXPRESS; database=db_PhotoGallery"/>
      </connectionStrings>


Для трекинга изменений принято использовать паттерн UnitOfWork. Суть UnitOfWork в отслеживании действий, выполняемых над объектами домена, для дальнейшей синхронизации данных, хранящихся в оперативной памяти, с содержимым базы данных. При этом изменения фиксируются в один момент времени — все и сразу.

Интерфейсная часть UnitOfWork
    public interface IUnitOfWork : IDisposable
    {
        void Commit();
    }


Казалось бы на этом все. Но нужно учесть два момента:

  • Трекинг изменений должен обслуживать всю текущую бизнес транзакцию и должен быть доступен для всех бизнес объектов
  • Бизнес-транзакция должна выполняться в рамках одного потока, поэтому нужно связать единицу работы с запущенным в данный момент потоком, используя локальное хранилище потока

Если с потоком бизнес-транзакции уже связан объект UnitOfWork, то его следует поместить именно в этот объект. Кроме того, с логической точки зрения единица работы принадлежит данному сеансу.

Воспользуемся статическим классом Session
    public static class Session
    {
        //локальное хранилище потока
        private static readonly ThreadLocal<IUnitOfWork> CurrentThreadData = 
        new ThreadLocal<IUnitOfWork>(true);
        public static IUnitOfWork Current
        {
            get { return CurrentThreadData.Value; }
            private set { CurrentThreadData.Value = value; }
        }
        public static IUnitOfWork Create(IUnitOfWork uow)
        {
            return Current ?? (Current = uow);
        }
    }


Если необходимо обойтись без трекинга, то экземляр класса UnitOfWork создавать не нужно, ровно как и вызывать Session.Create.

Итак, после определения всех элементов, необходимых для взаимодействия с ORM, приведем пример работы с ORM.

Пример работы с ORM
            var uow = new UnitOfWork();
            using (Session.Create(uow))
            {
                var profileRepo = new ProfileRepository();
                //Вызов методов репозитория
                uow.Commit();
           }



Приступаем к готовке


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

Не стоит путать бизнес объект и объект для трекинга:

  • Бизнес объект имеет свой тип, тогда как трекинг объект должен быть пригоден для массовых манипуляций над множеством бизнес объектов, то есть не должен зависеть от конкретного типа
  • Трекинг объект есть сущность существующая в рамках конкретной бизнес транзакции, среди множества бизнес объектов, его уникальность должна определяться в рамках этой транзакции

Из чего следует, что такой объект должен обладать свойствами:

  • Уникальным в пределах своего типа
  • Неизменяемым

По сути объект для трекинга является контейнером для хранения бизнес объектов. Как отмечалось ранее, все клиентские бизнес-объекты должны быть предками суперкласса EntityBase и для них должен быть переопределен идентификатор объекта. Идентификатор обеспечивает уникальность в пределах типа, то есть таблицы в бд.

Реализация контейнера объекта для трекинга
    internal struct EntityStruct
    {
       //тип объекта
        internal Type Key { get; private set; }
        internal EntityBase Value { get; private set; }
        internal EntityStruct(Type key, EntityBase value)
            : this()
        {
            Key = key;
            Value = value;
        }
        public override bool Equals(object obj)
        {
            return obj.GetHashCode() == GetHashCode();
        }
        public bool Equals(EntityBase obj)
        {
            return Equals(obj);
        }
        public override int GetHashCode()
        {
//в пределах одной бд, тип объекта и идентификатор однозначно определяют его уникальность
            var code = Key.GetHashCode() + Value.Id.GetHashCode();
            //хэш код должен быть положительным числом
            return code > 0 ? code : (-1) * code;
        }
    }


Трекинг бизнес объектов


Регистрация объектов для трекинга будет происходить на этапе получения этих объектов из репозитория.

После, получения бизнес объектов из бд, необходима их регистрация как объектов для трекинга.
Такие объекты имеют два состояния: сразу после получения из бд и после получения, до фиксации изменений.

Первые будем называть «чистыми», вторых «грязными» объектами.

Пример
           var uow = new UnitOfWork();
            using (Session.Create(uow))
            {
                var profileRepo = new ProfileRepository();
            //регистрируем "чистые" объекты путем копирования полученных из бд, 
           //исходные считаем "грязными"
                var profiles = profileRepo.Get(x=>x.Info = "Хороший юзер");
                //изменяем "грязные" объекты
                foreach (var profile in profiles)
                {
                    profile.Info = "Плохой юзер";
                }
               //фиксация изменений
                uow.Commit();
           }


Важный момент заключается в том, что для сохранения «чистых» объектов необходимы операции копирования, что может губительно сказаться на производительности.

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

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

События регистрации объектов трекинга будут возбуждаться из функционала абстрактного класса репозитория в его CRUD методах.

Реализация функционала регистрации объектов трекинга
    internal interface IObjectTracker
    {
//для простоты приведен код регистрации только для измененных и новых объектов объектов
        ICollection<EntityStruct> NewObjects { get; }
        ICollection<EntityStruct> ChangeObjects { get; }
        //методы регистрации 
        void RegInsertedNewObjects(object sender, AddObjInfoEventArgs e);
        void RegCleanObjects(object sender, DirtyObjsInfoEventArgs e);
    }
    internal class DefaultObjectTracker : IObjectTracker
    {
        //ключ словаря - "грязный" объект, значение - "чистый" объект
        private readonly Dictionary<EntityStruct, EntityStruct> _dirtyCreanPairs;
        public ICollection<EntityStruct> NewObjects { get; private set; }
        public ICollection<EntityStruct> ChangeObjects
        {
            get
            {
               // получаем измененные объекты
                return _dirtyCreanPairs.GetChangesObjects();
            }
        }
        internal DefaultObjectTracker()
        {
            NewObjects = new Collection<EntityStruct>();
  //Чтобы избежать лишних boxing/unboxing операций реализуем свой EqualityComparer
            _dirtyCreanPairs = 
          new Dictionary<EntityStruct, EntityStruct>(new IdentityMapEqualityComparer());
        }
        public void RegInsertedNewObjects(object sender, AddObjInfoEventArgs e)
        {
            NewObjects.Add(e.InsertedObj);
        }
        public void RegCleanObjects(object sender, DirtyObjsInfoEventArgs e)
        {
            var objs = e.DirtyObjs;
            foreach (var obj in objs)
            {
                if (!_dirtyCreanPairs.ContainsKey(obj))
                {
  //получаем "чистый" объект путем клонирования исходного при помощи MemberwiseClone()
                    var cloneObj = new EntityStruct(obj.Key, (EntityBase)obj.Value.Clone());
                    _dirtyCreanPairs.Add(obj, cloneObj);
                }
            }
        }
      }

Функционал выявления измененных клиентом объектов
        public static ICollection<EntityStruct> GetChangesObjects
        (
         this Dictionary<EntityStruct, EntityStruct> dirtyCleanPairs
        )
        {
            var result = new List<EntityStruct>();
            foreach (var cleanObj in dirtyCleanPairs.Keys)
            {
                if (!(cleanObj.Key == dirtyCleanPairs[cleanObj].Key))
                {
                    throw new Exception("incorrect types");
                }
                if (ChangeDirtyObjs(cleanObj.Value, dirtyCleanPairs[cleanObj].Value, cleanObj.Key))
                {
                    result.Add(cleanObj);
                }
            }
            return result;
        }
        public static bool ChangeDirtyObjs(EntityBase cleanObj, EntityBase dirtyObj, Type type)
        {
            var props = type.GetProperties();
           //цикл по каждому свойству объекта
            foreach (var prop in props)
            {
                var cleanValue = prop.GetValue(cleanObj, null);
                var dirtyValue = prop.GetValue(dirtyObj, null);
              //если хоть одно свойство изменено, считаем объект пригодным для регистрации
                if (!cleanValue.Equals(dirtyValue))
                {
                    return true;
                }
            }
            return false;
        }



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

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

Изменим класс UnitOfWork'а
    internal interface IDetector
    {
      //ключ слова - строка подключения к базу, значение - объект трекинга
        Dictionary<string, IObjectTracker> ObjectDetector { get; }
    }

    public sealed class UnitOfWork : IUnitOfWork, IDetector
    {
        private readonly Dictionary<string, IObjectTracker> _objectDetector;
        Dictionary<string, IObjectTracker> IDetector.ObjectDetector
        {
            get { return _objectDetector; }
        }
        public UnitOfWork()
        {
            _objectDetector = new Dictionary<string, IObjectTracker>();
            foreach (ConnectionStringSettings conName in ConfigurationManager.ConnectionStrings)
            {
             //каждому подключению к базе соответствует свой экземпляр трекинга
                _objectDetector.Add(conName.Name, new DefaultObjectTracker());
            }
        }
}


Информация о том какой экземпляр трекинга какой базе будет соответствовать должна быть доступна всем экземплярам репозитория в рамках транзакции. Удобно создать единую точку доступа в статическом классе Session.

Класс Session примет вид
    public static class Session
    {
        private static readonly ThreadLocal<IUnitOfWork> CurrentThreadData = new ThreadLocal<IUnitOfWork>(true);
        public static IUnitOfWork Current
        {
            get { return CurrentThreadData.Value; }
            private set { CurrentThreadData.Value = value; }
        }
        public static IUnitOfWork Create(IUnitOfWork uow)
        {
            return Current ?? (Current = uow);
        }
        //Метод возвращает нужный экземляр трекинга для текущей транзакции
        // по имени строки подключения
        internal static IObjectTracker GetObjectTracker(string connectionName)
        {
            var uow = Current;
            if (uow == null)
            {
                throw new ApplicationException(" Create unit of work context and using Session.");
            }
            var detector = uow as IDetector;
            if (detector == null)
            {
                throw new ApplicationException("Create unit of work context and using Session.");
            }
            return detector.ObjectDetector[connectionName];
        }
    }
}


Доступ к данным


Функционал доступа к данным будет непосредственно вызывать методы обращения к бд. Этот функционал будет использоваться классом абстрактного репозитория в его CRUD методах. В простом случае класс доступа к данным включает в себя CRUD методы для работы с данными.

Реализация класса DbProvider
    internal interface IDataSourceProvider : IDisposable
    {
        State State { get; } 
        //для простоты фиксировать изменения в бд, будем только для измененных объектов
         void Commit(ICollection<EntityStruct> updObjs);
        ICollection<T> GetByFields<T>(BinaryExpression exp) where T : EntityBase, new();  
    }
    internal class DbProvider : IDataSourceProvider
    {
        private IDbConnection _connection;
        internal DbProvider(IDbConnection connection)
        {
            _connection = connection;
            State = State.Open;
        }
        public State State { get; private set; }
        public ICollection<T> GetByFields<T>(BinaryExpression exp) where T : EntityBase, new()
        {
            // делегат возвращающий текст select-запроса по выражению exp
            Func<IDbCommand, BinaryExpression, string> cmdBuilder =                                 SelectCommandBulder.Create<T>;
            ICollection<T> result;
            using (var conn = _connection)
            {
                using (var command = conn.CreateCommand())
                {
                    command.CommandText = cmdBuilder.Invoke(command, exp);
                    command.CommandType = CommandType.Text;
                    conn.Open();
                    result = command.ExecuteListReader<T>();
                }
            }
            State = State.Close;
            return result;
        }
       public void Commit(ICollection<EntityStruct> updObjs)
        {
            if (updObjs.Count == 0)
            {
                return;
            }
              // ключ - делегат возвращающий текст update-запроса по выражению exp
              //значение - измененные объекты
            var cmdBuilder = new Dictionary<Func<IDbCommand, ICollection<EntityStruct>, string>, ICollection<EntityStruct>>();
            cmdBuilder.Add(UpdateCommandBuilder.Create, updObjs);
            ExecuteNonQuery(cmdBuilder, packUpdDict, packDeleteDict);
        }  
       private void ExecuteNonQuery(Dictionary<Func<IDbCommand, ICollection<EntityStruct>, string>, ICollection<EntityStruct>> cmdBuilder)
        {
            using (var conn = _connection)
            {
                using (var command = conn.CreateCommand())
                {
                    var cmdTxtBuilder = new StringBuilder();
                    foreach (var builder in cmdBuilder)
                    {
                        cmdTxtBuilder.Append(builder.Key.Invoke(command, builder.Value));
                    }
                    command.CommandText = cmdTxtBuilder.ToString();
                    command.CommandType = CommandType.Text;
                    conn.Open();
                    if (command.ExecuteNonQuery() < 1)
                        throw new ExecuteQueryException(command);
                }
            }
            State = State.Close;
        }
        private ICollection<T> ExecuteListReader<T>(EntityStruct objs)
        where T : EntityBase, IEntity, new()
        {
            Func<IDbCommand, EntityStruct, string> cmdBuilder = SelectCommandBulder.Create;
            ICollection<T> result;
            using (var conn = _connection)
            {
                using (var command = conn.CreateCommand())
                {
                    command.CommandText = cmdBuilder.Invoke(command, objs);
                    command.CommandType = CommandType.Text;
                    conn.Open();
                    result = command.ExecuteListReader<T>();
                }
            }
            State = State.Close;
            return result;
        }
        private void Dispose()
        {
            if (State == State.Open)
            {
                _connection.Close();
                State = State.Close;
            }
            _connection = null;
            GC.SuppressFinalize(this);
        }
        void IDisposable.Dispose()
        {
            Dispose();
        }
        ~DbProvider()
        {
            Dispose();
        }
    }


Классу DbProvider необходимо существующее соединение с бд. Делегируем создание соединения и дополнительной инфраструктуры отдельному классу на основе фабричного метода. Таким образом создавать экземпляры класса DbProvider необходимо только через вспомогательный класс фабрики.

Фабричный метод DbProvider'а
    class DataSourceProviderFactory
    {
        static DbConnection CreateDbConnection(string connectionString, string providerName)
        {
            if (string.IsNullOrWhiteSpace(connectionString))
            {
                throw new ArgumentException("connectionString is null or whitespace");
            }
            DbConnection connection;
            DbProviderFactory factory;
            try
            {
                factory = DbProviderFactories.GetFactory(providerName);
                connection = factory.CreateConnection();
                if (connection != null) connection.ConnectionString = connectionString;
            }
            catch (ArgumentException)
            {
                try
                {
                    factory = DbProviderFactories.GetFactory("System.Data.SqlClient");
                    connection = factory.CreateConnection();
                    if (connection != null)
                    {
                        connection.ConnectionString = connectionString;
                    }
                }
                catch (Exception)
                {
                    throw new Exception("DB connection has been failed.");
                }
            }
            return connection;
        }
        public static IDataSourceProvider Create(string connectionString)
        {
            var settings = ConfigurationManager.ConnectionStrings[connectionString];

            var dbConn = CreateDbConnection(settings.ConnectionString, settings.ProviderName);
            return new DbProvider(dbConn);
        }
        public static IDataSourceProvider CreateByDefaultDataProvider(string connectionString)
        {
            var dbConn = CreateDbConnection(connectionString, string.Empty);
            return new DbProvider(dbConn);
        }
    }


Регистрация трекинг объектов должна происходить в CRUD методах репозитория, тот в свою очередь делегирует функционал слою доступа к данным. Таким образом необходима реализация интерфейса IDataSourceProvider с учетом трекинга. Регистрировать объекты будем на основе механизма событий, которые будут возбуждаться именно в этом классе. Предполагаемая новая реализация интерфейса IDataSourceProvider должна «уметь» как инициализировать события регистрации на трекинг, так и обращаться к бд. В данном случае удобно декорировать класс DbProvider.

Реализация класса TrackerProvider
    internal class TrackerProvider : IDataSourceProvider
    {
        private event EventHandler<DirtyObjsInfoEventArgs> DirtyObjEvent;
        private event EventHandler<UpdateObjsInfoEventArgs> UpdateObjEvent;
        private readonly IDataSourceProvider _dataSourceProvider;
        private readonly string _connectionName;
        private readonly object _syncObj = new object();
        private IObjectTracker ObjectTracker
        {
            get
            {
                lock (_syncObj)
                {
                   // получаем необходимый экземпляр трекинга
                    return Session.GetObjectTracker(_connectionName);
                }
            }
        }
        public TrackerProvider(string connectionName)
        {
            _connectionName = connectionName;
            _dataSourceProvider = DataSourceProviderFactory.Create(_connectionName);
           // регистрация событий трекинга
            RegisterEvents();
        }
        public State State
        {
            get
            {
                return _dataSourceProvider.State;
            }
        }
        private void RegisterEvents()
        {
           //Использование класса корректно только при использовании трекинга
            if (Session.Current == null)
            {
                throw new ApplicationException("Session has should be used. Create a session.");
            };
          //подписка на события трекинга
            DirtyObjEvent += ObjectTracker.RegCleanObjects;
            UpdateObjEvent += ObjectTracker.RegUpdatedObjects;
        }
        public ICollection<T> GetByFields<T>(BinaryExpression exp) 
        where T : EntityBase, IEntity, new()
        {
            //получаем исходные объекты из бд посредством экземпляра класса DbProvider
            var result = _dataSourceProvider.GetByFields<T>(exp);
            var registratedObjs = result.Select(r => new EntityStruct(typeof(T), r)).ToList();
           //Возбуждаем событие регистрации "грязных" объектов
            var handler = DirtyObjEvent;
            if (handler == null)
                return result;
            handler(this, new DirtyObjsInfoEventArgs(registratedObjs));
            return result;
        }
        public void Commit(ICollection<EntityStruct> updObjs)
        {
              //полностью делегируем выполнение экземпляру класса DbProvider
            _dataSourceProvider.Commit(updObjs, delObjs, addObjs, packUpdObjs, deleteUpdObjs);
        }
        public void Dispose()
        {
            _dataSourceProvider.Dispose();
        }
    }


Промежуточные итоги


Разберемся каким образом теперь будут выглядеть наши public-классы.

Как отмечалось чуть выше, класс репозитория должен делегировать свою функциональность реализациям интерфейса IDataSourceProvider'а. При инициализации класса репозитория, на основе переданной в конструктор строки подключения, необходимо создать нужную реализацию IDataSourceProvider'а в зависимости от использования трекинга. Также необходимо учесть, что класс доступа к данным может в любой момент времени «потерять» соединение с бд, для чего с помощью свойства будем следить за этим соединением.

Класс UnitOfWork'а, как уже отмечалось ранее, в своем конструкторе должен создать список объектов класса DefaultObjectTracker по всем доступным в строке подключения бд. Логично, что фиксация изменений должна также происходить по всем бд: для каждого экземпляра трекинга будет вызываться метод фиксации его изменений.

Public - классы примут вид
public abstract class Repository<T> : IRepository<T> 
where T : EntityBase, IEntity, new()
    {
        private readonly object _syncObj = new object();
        private IDataSourceProvider _dataSourceProvider;
     //c помощью свойства "мониторим" соединение с бд
        private IDataSourceProvider DataSourceProvider
        {
            get
            {
                lock (_syncObj)
                {
                    if (_dataSourceProvider.State == State.Close)
                    {
                        _dataSourceProvider = GetDataSourceProvider();
                    }
                    return _dataSourceProvider;
                }
            }
        }
        private readonly string _connectionName;
        protected Repository(string connectionName)
        {
            if (string.IsNullOrWhiteSpace(connectionName))
            {
                throw new ArgumentNullException("connectionName");
            }
            _connectionName = connectionName;
            var dataSourceProvider = GetDataSourceProvider();
            if (dataSourceProvider == null)
            {
                throw new ArgumentNullException("dataSourceProvider");
            }
            _dataSourceProvider = dataSourceProvider;
        }
        private IDataSourceProvider GetDataSourceProvider()
        {
              //если трекинг включен создаем экземпляр класса DbProvider'а
             // иначе создаем его ////декорированную версию - TrackerProvider
            return Session.Current == null ? DataSourceProviderFactory.Create(_connectionName)
                       : new TrackerProvider(_connectionName);
        }
        public ICollection<T> Get(Expression<Func<T, bool>> exp)
        {
            return DataSourceProvider.GetByFields<T>(exp.Body as BinaryExpression);
        }
    }

    public sealed class UnitOfWork : IUnitOfWork, IDetector
    {
        private readonly Dictionary<string, IObjectTracker> _objectDetector;
        Dictionary<string, IObjectTracker> IDetector.ObjectDetector
        {
            get { return _objectDetector; }
        }
        public UnitOfWork()
        {
            _objectDetector = new Dictionary<string, IObjectTracker>();
            foreach (ConnectionStringSettings conName in ConfigurationManager.ConnectionStrings)
            {
                _objectDetector.Add(conName.Name, new DefaultObjectTracker());
            }
        }
        public void Commit()
        {
            SaveChanges();
        }
        private async void SaveChanges()
        {
            await Task.Run(() =>
                    {
                    //фиксируем изменения в экземплярах трекинга по каждой из бд
                        foreach (var objectDetector in _objectDetector)
                        {
                            var provider = new TrackerProvider(objectDetector.Key);
                            provider.Commit(
                                objectDetector.Value.ChangeObjects);
                        }
                    }
                );
        }
    }


В продолжении статьи рассмотрю работу со связанными сущностями, генерацию sql запросов на основе деревьев выражений, методы пакетного удаления/изменения данных (по типу UpdateWhere, RemoveWhere).

Полностью весь исходный код проекта, без упрощений, лежит тут.
Поделиться с друзьями
-->

Комментарии (37)


  1. lair
    18.12.2016 15:46
    +7

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

    Это сразу плохо. У вас инфраструктурный слой (репозиторий) просочился в виде давления в бизнес-слой. Нет никакой причины, почему все бизнес-классы должны наследовать от одного базового.


    public abstract int Id { get; set; }

    А если у меня не целочисленные идентификаторы?


    Осуществить привязку бизнес объекта к таблице в базе данных на основе атрибутов

    … и опять просочилась зависимость в бизнес-слой. Теперь я не могу создать бизнес-объект, не имея ссылки на сборку с атрибутами маппинга.


    //в качестве аргумента базовому классу передаем название строки подключения

    Зачем?


    Бизнес-транзакция должна выполняться в рамках одного потока

    Это почему? Все async запретили? И почему не использовать уже существующий TransactionScope?


    var uow = new UnitOfWork();
    using (Session.Create(uow))
    {
    var profileRepo = new ProfileRepository();
    //Вызов методов репозитория
    uow.Commit();
    }

    Нелогично. Вы диспозите и коммитите разные вещи. Совершенно не понятно, почему нельзя просто писать


    using(var uow = new UnitOfWork())
    {
      uow.Commit();
    }

    (без учета DI, но он и у вас не учтен)


    public override bool Equals(object obj)
    {
    return obj.GetHashCode() == GetHashCode();
    }
    public override int GetHashCode()
    {
    //в пределах одной бд, тип объекта и идентификатор однозначно определяют его уникальность
    var code = Key.GetHashCode() + Value.Id.GetHashCode();
    //хэш код должен быть положительным числом
    return code > 0 ? code : (-1) * code;
    }

    Колизии на хэшкодах от типов вас не смущают? А еще более смешной вариант "хэшкоды типа и идентификатора взаимно совпадают"?


    Ну и зачем вообще это структура?


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

    Почему?


    1. Varim
      18.12.2016 20:22
      -3

      Необходимо учесть, что бизнес объекты одной транзакции могут быть из разных бд. Логично предположить что для каждой бд должен быть определен свой экземпляр трекинга
      Почему?
      Там два предложения. «Почему» — к какому предложению задано?
      А почему нет?


      1. lair
        18.12.2016 20:36

        Ко второму.


    1. eddHunter
      22.12.2016 00:57

      1. Все равно необходимо будет переключаться на другую бд, при выполнении запроса, так почему бы сразу не скомпоновать их по базам?
      2. Объекты трекинга для одной бд(в простейшем случае, если нет зависимостей) не зависят от объектов в другой.


      1. lair
        22.12.2016 11:45

        Потому что порядок выполнения зависит не от базы.


  1. sand14
    18.12.2016 15:54
    +8

    public override bool Equals(object obj)
    {
    return obj.GetHashCode() == GetHashCode();
    }

    Это же абсолютное зло.
    Лучше разобраться с базовыми вещами, и только потом писать ORM или любые другие библиотечные вещи.


  1. sand14
    18.12.2016 16:10
    +1

    var code = Key.GetHashCode() + Value.Id.GetHashCode();

    Если вы по каким то причинам решили именно так (сложением) вычислять хеш-код, то лучше явно указать unckecked mode:
    var code = unchecked(Key.GetHashCode() + Value.Id.GetHashCode();)

    //хэш код должен быть положительным числом
    return code > 0 ? code : (-1) * code;

    Во-первых, почему хеш-код должен быть положительным?

    Во-вторых:

    • зачем (-1) * code, если можно написать просто -1?
    • зачем пытаться сменить знак для возможного нуля, если можно написать «return code >= 0? code: ...»?
    • что будете делать, если в code окажется Int32.MinValue? — при включенной проверке переполнения при умножении на "-1" получите OverlowException, при выключенной получите «Int32.MinValue» (а как же исходное «хеш-код должен быть положительным»).


    1. eddHunter
      18.12.2016 18:13

      • Да, с unckecked, конечно, правы.
      • Положительный хэш нужен для корректного формирования текста и параметров sql запроса:

      using (var command = conn.CreateCommand())
                      {
                          command.CommandText = cmdBuilder.Invoke(command, objs);
                          command.CommandType = CommandType.Text;
                          conn.Open();
                          result = command.ExecuteListReader<T>();
                      }
      


      1. sand14
        18.12.2016 19:54

        Из статьи неясно, хеши объектов будут подставляться в запрос как идентификаторы? Если да, то это неверное решение. Если нет, то в для чего они используются при построении запроса?

        Сам хеш в вашем случае нужно, для минимизации коллизий (а также для получения неотрицательного значения), вычислять примерно так:
        public override GetHashCode() =>
        (unchecked(someConstant * Key.GetHashCode()) ^ Value.Id.GetHashCode())
        & Int32.MaxValue; // & 0x7FFFFFFF


        И разберитесь с матчастью в части реализации Equals.


      1. Varim
        18.12.2016 20:04
        +1

        Положительный хэш нужен для корректного формирования текста и параметров sql запроса
        Не знаю про какой sql сервер вы говорите, но в MS SQL Server, отрицательный int в PK никто не запрещал.


        1. sand14
          18.12.2016 23:02
          +1

          +Более общее соображения, что использовать хеш в качестве ключа в БД нельзя
          Об этом прямо здесь прямо написано:

          A hash code is not a permanent value. For this reason:
          Do not serialize hash code values or store them in databases.

          То же самое и в других платформах — Java и любой другой, вследствие природы хеш-кода объекта.


    1. merlin-vrn
      18.12.2016 22:01

      если можно написать просто -1

      -code


      1. sand14
        18.12.2016 22:58

        именно это.


  1. MonkAlex
    18.12.2016 17:54
    +2

    Приходит новый человек на проект, а там вместо привычных EF или NHibernate — свой велосипед, со своими багами, со своими ограничениями. Очень позитивно.


    1. eddHunter
      18.12.2016 18:06

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


    1. Xandrmoro
      18.12.2016 22:02

      А заодно со своими возможностями, некоторыми из которых в EF не пахнет.
      Сделать действительно всеобъемлющий ORM невозможно в силу принципиальной разности концепций, всегда что-то приносится в жертву.


      1. MonkAlex
        18.12.2016 22:13

        Не знаю насчет EF, но NHibernate мне хватает по самое не могу даже на крупных проектах. Боюсь представить, что вы там изобрели такого, что вам не хватает.


        1. Xandrmoro
          18.12.2016 22:22

          Автоаудит в отдельные таблицы, CTE, инлайнинг вьюшек, оконные функции.


          1. MonkAlex
            19.12.2016 00:38
            +1

            Я если честно из этих терминов встречал только «оконные функции» и те в другом контексте. Сможете пояснить, как это примерно хотелось бы использовать и зачем?


            1. Xandrmoro
              21.12.2016 21:44

              Автоаудит — это когда кто-то где-то что-то поменял, и в специальную табличку (в моём случае — почти каждая таблица с данными имеет свою собственную таблицу аудита) пишется, кто, что и когда. Можно сделать триггерами, но, во-первых, триггеров нужно катастрофически много, во-вторых — сложно определить юзера.

              CTE — это такая фича T-SQL, когда можно в рамках запроса объявить что-то вроде вьюшки и потом пользоваться ей. В духе

              WITH someCTE AS (
                SELECT a, b FROM (
                  SELECT a, b FROM table1
                  UNION 
                  SELECT a, b FROM table2
                ) un LEFT JOIN table3 t3 ON (t3.a = un.a)
              )
              SELECT c
              FROM table4
              LEFT JOIN someCTE cte on  (cte.a = table4.c)
              LEFT JOIN (
                SELECT * FROM table5 INNER JOIN someCTE cte ON (cte.a = table5.a)
              )
              -- любая другая хтонь
              


              Оконные функции — такая штука, позволяющая агрегировать (и делать некоторые другие вещи) данные без группировок. Или, например, опираться на значение из нескольких строк. Скользящие средние, номера строк внутри групп, средние/суммы/чтоУгодноЕщё по группам,… you name it.

              Инлайнинг — это когда вьюшка подставляется в запрос путём собственно вставки её текста в подзапрос. Зачем?
              Допустим, у нас есть некоторая очень тяжёлая вьюшка, которую нельзя проиндексировать (лефт джойны, недетерминированные функции, таблицы из разных баз, да что угодно). При этом на неё накладываются достаточно жёсткие условия, уменьшающие её на несколько порядков:
              SELECT * FROM matters m
              LEFT JOIN view1 vw ON (vw.matterId = m.id)
              WHERE vw.UserId = 150
              


              Часто можно это самое условие наложить где-нибудь внутри:
              SELECT * FROM matters m
              LEFT JOIN (SELECT * FROM users /*много-много джойнов*/ WHERE vw.UserId = 150) vw -- текст вьюшки
              ON (vw.matterId = m.id)
              

              И получить эти самые порядки прироста производительности. Table-valued функции при этом использовать не представляется возможным, потому что фильтры заранее неизвестны.


              1. MonkAlex
                21.12.2016 22:06

                Почти все перечисленное — только для голого sql в условиях проседания производительности. Слабо верится, что можно на это дело адекватно ОРМ написать.

                Некоторые вещи имхо просто не нужны в крупных приложениях с ОРМ, например первая =)


                1. Xandrmoro
                  21.12.2016 22:47

                  Десять миллионов строк, тридцать терабайт данных и Пепси, GM и Дисней в клиентах — достаточно крупное приложение? :)

                  И все они интегрированы в кастомную ОРМ.


                  1. MonkAlex
                    22.12.2016 00:27

                    Так ОРМ для упрощения работы с базой, а не для производительности.

                    Ессна, что типовые решения не подойдут на большие нагрузки, но и начинать с кастомной ОРМ ради этого точно не стоит.

                    ПС: пилите на здоровье, раз кто-то готов оплачивать. Ура, как говорится =)


                  1. eddHunter
                    22.12.2016 00:46

                    EF и Hibernate, изначально не создавались для вашего проекта.
                    Выбор ORM должен быть продиктован задачами вашего бизнеса.
                    Например, если у вас на проекте или на какой-то его части, большинство запросов легкие, read only, тогда использование тяжелого EF никак не улучшит перформанс — это стрельба из пушки по воробьям. В этом случае стоит написать свое(как и сделали в Stack Overflow).


                1. eddHunter
                  22.12.2016 01:18

                  Зачем для использования временных таблиц или CTE, писать ORM?) В EF или Nhibernate никто не запрещает вам создать обертку ХП, в которой можете развлекаться с sql как угодно.


        1. eddHunter
          18.12.2016 23:59
          +1

          Данное решение for fun, не более того, конечно.


        1. eddHunter
          19.12.2016 04:54

          Это популизм. В первой строчке статьи, я написал о об этом. Этот проект типа for fun.


          1. eddHunter
            19.12.2016 04:58

            Конечно, нет спора лучше чем свой костыль vs EF vs Hbn vs Dapper и т.д.


  1. Varim
    18.12.2016 20:16

    Сразу определимся с терминами: под трекингом будем понимать отслеживание изменений бизнес объектов в рамках одной транзакции, для дальнейшей синхронизации данных, хранящихся
    в оперативной памяти и содержимого бд.

    Слово синхронизация на мой взгляд не подходит, так как предполагает двухстороннее взаимодействие DB<->RAM.
    То есть когда стороннее приложение пишет в БД, трекинг БД кидает сообщение в ORM, вызывая обновление объекта в RAM, как в ADO.NET SqlDependency (ALTER DATABASE <db_name> SET ENABLE_BROKER;).

    В вашем случае лучше как то так — «для дальнейшего сохранения данных, хранящихся в оперативной памяти в содержимое бд».


    1. eddHunter
      19.12.2016 04:46

      Слово «синхронизация» подходит полностью. Причем тут инструкции типа ALTER?


  1. Varim
    18.12.2016 20:27

    //регистрируем «чистые» объекты путем копирования полученных из бд,
    //исходные считаем «грязными»

    Обычно чистые те которые получены из бд.
    Как только чистые-БДшные сущности изменили, их называют грязными, до тех пор пока их не сохранили в БД.


    1. eddHunter
      19.12.2016 05:05

      Я о том и писал, что «dirty objects», это те, кто сохранили свою ссылочную целостность до сохранения в бд.


  1. Varim
    18.12.2016 20:38

    Потом, когда и если доведете ORM до ума, в метод ChangeDirtyObjs можно добавить построение и кэширование Expression Trees, что бы долго не ходить рефлексией по var props = type.GetProperties();
    Первый раз получил type, построил Expression Tree, закэшировал, следующий раз если есть в кэше есть type, вызвал Expression


    1. eddHunter
      19.12.2016 04:39

      Да, конечно, https://habrahabr.ru/post/269699/, знаем. Пока было глупо описывать такие дебри.


  1. dotnetdonik
    18.12.2016 22:43
    +1

    Вы замахнулись на очень сложную задачу и допустили в ней очень много ошибок проектирования и пропустили антипатернов.
    Начинать всегда стоит с простых вещей, построение запросов и Change Tracking это не первоочередного порядка вещи в ОРМ, а плюшки вендора скорее.

    У вас очень много ошибок с тем как вы выстраиваия уровни абстракций и интерфейсы для разных задач. Реализация так же подводит, работа с блокировками, dispose обьектов, object equality и асинхронностью.

    Асинхронные методы возвращающие void и вызывающие синхронный i\o api — грубейшие ошибки.

    public void Commit()
    {
    SaveChanges();
    }
    private async void SaveChanges()
    {
    await Task.Run(() =>
    {
    }
    );
    }



    1. eddHunter
      19.12.2016 04:40
      +1

      Не могли бы разъяснить?


      1. dotnetdonik
        19.12.2016 09:52
        +1

        https://msdn.microsoft.com/en-us/magazine/jj991977.aspx

        Этот код не дает возможности обработать ошибки вызывающему коду, на тред пуле выполняете блокирующую i\o операцию, да еще и потом синхронизируеться неявно в конце через execution контекст выбрасывая silent ошибки.

        Если говорить про Asp.Net например, ваш Save changes выполниться уже когда пользователю прийдет ответ в браузере.
        Мне не совсем понятно зачем там используется Task.Run — но он там используеться неправильно.