Re: Аудит доменной модели
От: Alkersan  
Дата: 07.07.09 19:29
Оценка:
Сегодня попробовал реализовать историю изменений. Вот что получилось:

Есть объект в домене: Resume. Представляет собой сущность, описывающую соискателя работы, содержит — фамилию, имя, отчество, дату рождения ( и еще несколько полей и коллекцию прикрепленных файлов к резюме, которые я опустил для экономии). Объект унаследован от базового шаблонного класса AuditableDomainObject<T>, который в свою очередь расширяет базовый класс DomainObject<T>.

DomainObject<T> — взят из статьи на codeproject.com — здесь; он отвечает за управление идентификатором (Id) сущности и позволяет отличать сохраненные (Persistent) объекты, новосозданных (Transient) исходя из значения идентификатора.
Этот класс расширяется классом AuditableDomainObject<T>, которым я "помечаю" те объекты, подлежат аудиту. Он содержит такой функционал: коллекцию AuditLog и обертку для нее (собственно сам AuditLog и есть обертка, а сама коллекция это список записей IList<AuditLogEntry>) — в этой коллекции накапливаются изменеия с момента последней транзакции. Также существует коллекция auditableEntitys, в которой хрянятся свойства данного объекта, которые "поддаются" аудиту. Т.е. возможна ситуация, что не за всеми полями объекта Resume нужно следить, тогда такое поле просто не попадает в эту коллекцию, и его измения игнорируются. Метод LogChanges() проходится по этой коллекции полей, выявляет изменившиеся или же новосозданные значения ( поле считается созданным — если объект Transient,т.е. если у него идентификатор имеет значение по умолчанию, в данном случае 0, или же поле считается изменённым, если первоначальное значение не равно текущему, см. далее ).

Собственно поля полежащие аудиту — это члены класса AuditableEntity<T>. Содержат 2 члена — Original и Current, начальное значение и текущее соответсвенно. На основе их принимается решение — писать ли данное состояние в лог или нет. Также есть строковый член FieldName с "человеческим" названием текущего поля (_FName — это "Фамилия" например). Несколько конструкторов... и метод LogChanges(), который пишет в переданный при создании экземпляр AuditLog ткущее состояние. Этот метод вызывается извне, при опросе коллекции auditableEntitys. В эти моменты и происходит запись в лог.

Вся эта иерархия пока-что нормально уживается с NHibernate. Маппинг Resume.hbm.xml приведен дальше. Все AuditableEntity<T> скрыты от чужих, и создаюся при вызове set соотсвествующей Property. NHibernate был настроен на доступ к членам класса через Propert`я, а не через private члены, как было ранее (свойство маппинга access не указано, поэтому по умолчанию для доступа использутся Property). Таким образом когда объект берется из базы с помошью NHibernate, создаются заодно объекты и коллекции для аудита.

Написал несколько простых тестов (я пока несилен в юнит тестах), довел их до работоспособности.


public class Resume : AuditableDomainObject<int>
    {
        #region Constructors
        /// <summary>
        /// Needed by ORM for reflective creation.
        /// </summary>
        private Resume() { }

        public Resume(string fname, string lname, DateTime birth)
        {
            FName = fname;
            LName = lname;
            SName = sname;

            if (!birth.Equals(DateTime.MinValue)) BirthDate = birth;
            else BirthDate = null;
        }
        #endregion

        #region Properties
        public string FName
        {
            get { return _FirstName.Current; }
            set
            {
                Check.Require(!string.IsNullOrEmpty(value), "A valid FirstName name must be provided in Resume");
                //_FName = value;
                if (_FirstName == null)
                {
                    _FirstName = new AuditableEntity<string>(value, "Имя", base.AuditLog);
                    base.AddEntityToAuditCollection(_FirstName);
                }
                else _FirstName.Current = value;
            }
        }

        public string LName
        {
            get { return _LastName.Current; }
            set
            {
                Check.Require(!string.IsNullOrEmpty(value), "A valid LastName name must be provided in Resume");
                //_LName = value;
                if (_LastName == null)
                {
                    _LastName = new AuditableEntity<string>(value, "Фамилия", base.AuditLog);
                    base.AddEntityToAuditCollection(_LastName);
                }
                else _LastName.Current = value;
            }
        }

        public string SName
        {
            get { return _SurName.Current; }
            set
            {
                //_SName = value;
                if (_SurName == null)
                {
                    _SurName = new AuditableEntity<string>(value, "Отчество", base.AuditLog);
                    base.AddEntityToAuditCollection(_SurName);
                }
                else _SurName.Current = value;
            }
        }

        public DateTime? BirthDate
        {
            get { return _DateOfBirth.Current; }
            set 
            { 
                //_BirthDate = value;
                if (_DateOfBirth == null)
                {
                    _DateOfBirth = new AuditableEntity<DateTime?>(value, "Дата рождения", base.AuditLog);
                    base.AddEntityToAuditCollection(_DateOfBirth);
                }
                else _DateOfBirth.Current = value;
            }
        }
        #endregion

        #region Methods

        public override int GetHashCode()
        {
            return (GetType().FullName + "|" +
                    FName + "|" +
                    LName + "|" +
                    SName + "|" +
                    ((BirthDate.HasValue) ? BirthDate.Value.ToString(): ""))
                    .GetHashCode();
        }
        #endregion

        #region Members
        //private string _FName;
        //private string _LName;
        //private string _SName;
        //private DateTime? _BirthDate;
        private AuditableEntity<string> _FirstName;
        private AuditableEntity<string> _LastName;
        private AuditableEntity<string> _SurName;
        private AuditableEntity<DateTime?> _DateOfBirth;
        #endregion
    }




    public abstract class AuditableDomainObject <IdT> : DomainObject<IdT>, IAuditableObject
    {
        /// <summary>
        /// AuditLog collection contains a historical data of current Business Entity
        /// </summary>
        public AuditLog AuditLog
        {
            get
            {
                if (_auditLogWrapper == null)
                    _auditLogWrapper = new AuditLog(_auditLog);
                return _auditLogWrapper;
            }
        }
        private AuditLog _auditLogWrapper;
        private IList<AuditLogEntry> _auditLog = new List<AuditLogEntry>();

        /// <summary>
        /// AuditableEntitys collection contains a list of auditable properties
        /// </summary>
        public ReadOnlyCollection<IAuditableEntity> AuditableEntitys
        {
            get { return new ReadOnlyCollection<IAuditableEntity>(auditableEntitys); }
        }
        private IList<IAuditableEntity> auditableEntitys = new List<IAuditableEntity>();
        
        /// <summary>
        /// Adds an IAuditableEntity to the list of auditable fields.
        /// </summary>
        /// <param name="entity"></param>
        protected void AddEntityToAuditCollection(IAuditableEntity entity)
        {
            auditableEntitys.Add(entity);
        }
                
        /// <summary>
        /// Review the AuditableEntitys collection and record changed to the AuditLog collection
        /// </summary>
        public void LogChanges()
        {
            if (auditableEntitys.Count != 0)
            {
                bool isTransient = base.IsTransient();
                foreach (IAuditableEntity entity in auditableEntitys)
                {
                    //Log only those properties, who are created or modified
                    if (isTransient || entity.Modified) 
                        entity.LogChanges(isTransient);
                }
            }
        }

        // These two methods are not used anywhere... They can be deleted.....
        public void LogChanges(AuditLogEntry entry)
        {
            _auditLog.Add(entry);
        }
        public void LogChanges(string aspect, string oldValue, string newValue, DateTime timestamp, string user, bool onCreating)
        {
            AuditLogEntry newEntry = new AuditLogEntry(aspect, oldValue, newValue, timestamp, user, onCreating);
            LogChanges(newEntry);
        }
        
    }



    public abstract class DomainObject<IdT>
    {
        /// <summary>
        /// ID may be of type string, int, custom type, etc.
        /// Setter is protected to allow unit tests to set this property via reflection and to allow 
        /// domain objects more flexibility in setting this for those objects with assigned IDs.
        /// </summary>
        public IdT ID
        {
            get { return id; }
            protected set { id = value; }
        }

        public override sealed bool Equals(object obj)
        {
            DomainObject<IdT> compareTo = obj as DomainObject<IdT>;

            return (compareTo != null) &&
                   (HasSameNonDefaultIdAs(compareTo) ||
                // Since the IDs aren't the same, either of them must be transient to 
                // compare business value signatures
                    (((IsTransient()) || compareTo.IsTransient()) &&
                     HasSameBusinessSignatureAs(compareTo)));
        }

        /// <summary>
        /// Transient objects are not associated with an item already in storage.  For instance,
        /// a <see cref="Customer" /> is transient if its ID is 0.
        /// </summary>
        public bool IsTransient()
        {
            return ID == null || ID.Equals(default(IdT));
        }

        /// <summary>
        /// Must be provided to properly compare two objects
        /// </summary>
        public abstract override int GetHashCode();

        private bool HasSameBusinessSignatureAs(DomainObject<IdT> compareTo)
        {
            Check.Require(compareTo != null, "compareTo may not be null");

            return GetHashCode().Equals(compareTo.GetHashCode());
        }

        /// <summary>
        /// Returns true if self and the provided persistent object have the same ID values 
        /// and the IDs are not of the default ID value
        /// </summary>
        private bool HasSameNonDefaultIdAs(DomainObject<IdT> compareTo)
        {
            Check.Require(compareTo != null, "compareTo may not be null");

            return (ID != null && !ID.Equals(default(IdT))) &&
                   (compareTo.ID != null && !compareTo.ID.Equals(default(IdT))) &&
                   ID.Equals(compareTo.ID);
        }

        private IdT id = default(IdT);
    }



    public class AuditableEntity <T> : IAuditableEntity
    {
        private AuditLog _auditLog;
        public T Current {get; set;}
        public T Original { get; set; }
        public string FieldName { get; set; }
        public bool Modified
        {
            get { return !(Current.Equals(Original)); }
        }

        #region Constructors
        public AuditableEntity(T value)
        {
            Current = Original = value;
        }
        public AuditableEntity(T value, AuditLog auditLog)
        {
            _auditLog = auditLog;
            Current = Original = value;
        }
        public AuditableEntity(T value, string fieldName)
        {
            Current = Original = value;
            FieldName = fieldName;
        }
        public AuditableEntity(T value, string fieldName, AuditLog auditLog)
        {
            _auditLog = auditLog;
            Current = Original = value;
            FieldName = fieldName;            
        }
        #endregion

        public void LogChanges(bool onCreating)
        {
            if (Modified) onCreating = false;
            _auditLog.Add(new AuditLogEntry(FieldName, Original.ToString(), Current.ToString(), DateTime.Now, "", onCreating));
        }

        public void LogChanges()
        {
            LogChanges(false);
        }
    }



<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2">
  <class name="Terrier.Core.Domain.Resume, Core" table="Resume" lazy="false">
    <id name="ID" column="ResumeId">
      <generator class="native" />
    </id>

    <property name="FName" column="FName" type="string" />
    <property name="LName" column="LName" type="string" />
    <property name="SName" column="SName" type="string" />
    <property name="BirthDate" column="BirthDate" type="datetime"/>

  </class>
</hibernate-mapping>



        [TestMethod]
        public void ResumeSaveTest()
        {
            NHibernateDaoFactory factory = new NHibernateDaoFactory(TestGlobals.SessionFactoryConfigPath);
            IResumeDao resumeDao = factory.GetResumeDao();

            int id = 520;
            Terrier.Core.Domain.Resume resume = resumeDao.GetById(id, true);
            Assert.IsNotNull(resume, "Resume with ID {0} was not returned", id);

            string newName = "NOT " + resume.FName;
            resume.FName = newName;

            resume.LogChanges();
            Assert.IsTrue(resume.AuditLog.Count == 1);

            resumeDao.Save(resume);
            resumeDao.CommitChanges();

            resume = resumeDao.GetById(id, true);
            Assert.IsTrue(resume.FName == newName);
        }

        [TestMethod]
        public void AuditLogCreating()
        {
            Resume resume = new Resume("Alex", "Alex", "Alex", DateTime.Now, "alkersan@gmail.com", "---", "8(050)", "AM");
            resume.LogChanges();
            Persist(resume);
            
            Assert.IsTrue(resume is AuditableDomainObject<int>);
            Assert.IsTrue(resume.AuditLog.Count == (resume as AuditableDomainObject<int>).AuditableEntitys.Count, "After creation of new resume not all Auditale Properties were recorded to log");

            // Make some changes and record them to AuditLog
            resume.FName = "Not Alex";
            resume.LogChanges();

            // To AuditLog must be added 1 new value
            Assert.IsTrue(resume.AuditLog.Count == (resume as AuditableDomainObject<int>).AuditableEntitys.Count + 1);
            Assert.IsTrue(resume.AuditLog[resume.AuditLog.Count - 1].NewValue == "Not Alex", "Change of FName was not recorded to AuditLog");
        }

        private void Persist(DomainObject<int> transientEntity)
        {
            var t = transientEntity.GetType().BaseType;
            var f = t.GetProperty("ID");
            int v = new Random().Next(1,9999);
            f.SetValue(transientEntity, v, null);
        }
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.