操作日志与数据日志

前言

在《【开源】OSharp框架解说系列(6.1):日志系统设计》中,我们已经设计并实现了一个可扩展的日志系统,只要定义好输出端的Adapter,就可以以任意形式输出日志信息。

在系统开发中,有些日志记录需求是常规需要的,比如操作日志,数据变更日志,系统异常日志等,我们希望把这些常规需求都集成到OSharp框架当中。有了内置的支持,在做开发的时候,只需要很简单的配置,就可以实现相关需求。

关于三类日志,这里先简要描述一下:

  • 操作日志:粗略描述系统用户(如管理员、业务人员、会员等)对系统的业务操作,只需要说清楚“XXX用户在XXX时间做了XXX操作”

  • 数据日志:有时候,为了追溯用户的业务操作对系统产生的影响,需要记录数据变更细节,这就是数据日志

  • 系统日志:主要记录系统在运行过程中产生的与业务无关的常规或异常的日志信息,这些日志信息通常由系统维护人员或开发人员查看

日志记录准备

在OSharp框架中,操作日志与数据日志的记录流程如下图所示:

这里用文字简单描述一下操作日志与数据日志记录的实现思路:

定义了一个“功能信息记录”的实体,用于提取系统中各个功能点的基础信息(名称、MVC的Area-Controller-Action、功能访问类型(匿名访问-登录访问-特定角色访问)、是否启用功能日志,是否启用数据日志、功能URL等),并配置功能的行为 定义了一个“实体信息记录”的实体,用于提取系统中各个数据实体类型的基础信息(实体类型全名、实体名称、是否启用数据日志,实体属性信息集),并配置实体的行为 系统初始化的时候,通过反射加载的程序集,提取并构建各个功能点(主要是MVC的Controller-Action)的功能信息记录,更新到数据库中 系统初始化的时候,通过反射加载的程序集,提取并构建各个实体类型的实体信息记录,更新到数据库中 利用MVC框架的ActionFilter进行AOP拦截,定义一个专门用于操作日志记录的
OperateLogFilterAttribute
,重写
OnActionExecuted
方法进行操作日志的记录 操作日志与数据日志记录的详细流程如下: 在用户的业务操作执行到保存数据的时候(EF执行SaveChanges时),根据操作涉及的实体获取相应的实体信息记录,确定是否创建数据日志,不需创建则跳过 需要创建时,根据实体的状态(Added-Modified-Deleted),创建各个实体的新增-更新-删除的数据日志信息,并存储到临时缓存中 执行到
OperateLogFilterAttribute
OnActionExecuted
方法的时候,根据
ActionExecutedContext
中提供的Area,Controller,Action等信息,查询出当前功能的功能信息记录,确定是否记录操作日志,不需记录则返回 需要根据功能信息记录,创建操作日志信息,并指定当前用户为日志操作人。 根据功能信息是否启用数据日志的配置,确定是否记录数据日志,需要记录时,从临时缓存中提取前面创建的数据日志,作为从数据配置到操作日志中 向系统外部保存操作日志信息,完成操作日志的记录

功能信息与实体信息

记录各个功能点的功能信息接口定义如下:

/// <summary>
/// 功能接口,最小功能信息
/// </summary>
public interface IFunction
{
    /// <summary>
    /// 获取或设置 功能名称
    /// </summary>
    string Name { get; set; }

    /// <summary>
    /// 获取或设置 区域名称
    /// </summary>
    string Area { get; set; }

    /// <summary>
    /// 获取或设置 控制器名称
    /// </summary>
    string Controller { get; set; }

    /// <summary>
    /// 获取或设置 功能名称
    /// </summary>
    string Action { get; set; }

    /// <summary>
    /// 获取或设置 功能类型
    /// </summary>
    FunctionType FunctionType { get; set; }

    /// <summary>
    /// 获取或设置 是否启用操作日志
    /// </summary>
    bool OperateLogEnabled { get; set; }

    /// <summary>
    /// 获取或设置 是否启用数据日志
    /// </summary>
    bool DataLogEnabled { get; set; }

    /// <summary>
    /// 获取或设置 是否锁定
    /// </summary>
    bool IsLocked { get; set; }

    /// <summary>
    /// 获取或设置 功能地址
    /// </summary>
    string Url { get; set; }
}

记录各个数据实体类型的实体信息接口定义如下:

/// <summary>
/// 实体数据接口
/// </summary>
public interface IEntityInfo
{
    /// <summary>
    /// 获取 实体数据类型名称
    /// </summary>
    string ClassName { get; }

    /// <summary>
    /// 获取 实体数据显示名称
    /// </summary>
    string Name { get; }

    /// <summary>
    /// 获取 是否启用数据日志
    /// </summary>
    bool DataLogEnabled { get; }

    /// <summary>
    /// 获取 实体属性信息字典
    /// </summary>
    IDictionary<string, string> PropertyNames { get; }
}

OSharp框架中,已经派生了 Function 与 EntityInfo 两个实体类型,作为功能信息与实体信息的封装。

功能信息与实体信息的初始化实现,主要定义在 FunctionHandlerBase<TFunction, TKey> 与 EntityInfoHandlerBase<TEntityInfo, TKey> 两个基础中,OSharp中已经派生了 public class DefaultFunctionHandler : FunctionHandlerBase<Function, Guid> 与 public class DefaultEntityInfoHandler : EntityInfoHandlerBase<EntityInfo, Guid> 作为系统初始化时,从程序集中提取并更新功能信息与数据信息的默认实现。

由代码图,我们能很直观的看到实体与处理器之间的关系:

关于这两个处理器的实现流程,不是本文的重点,将在后面讲解OSharp初始化实现时再详述,这里先略过。提取的数据展示如下:

提取的功能信息:

提取的实体数据信息:

操作日志与数据日志实体

操作日志实体定义如下:

/// <summary>
/// 操作日志信息类
/// </summary>
[Description("系统-操作日志信息")]
public class OperateLog : EntityBase<int>, ICreatedTime
{
    /// <summary>
    /// 初始化一个<see cref="OperateLog"/>类型的新实例
    /// </summary>
    public OperateLog()
    {
        DataLogs = new List<DataLog>();
    }

    /// <summary>
    /// 获取或设置 执行的功能名称
    /// </summary>
    [StringLength(100)]
    public string FunctionName { get; set; }

    /// <summary>
    /// 获取或设置 操作人信息
    /// </summary>
    public Operator Operator { get; set; }

    /// <summary>
    /// 获取设置 信息创建时间
    /// </summary>
    public DateTime CreatedTime { get; set; }

    /// <summary>
    /// 获取或设置 数据日志集合
    /// </summary>
    public virtual ICollection<DataLog> DataLogs { get; set; }
}

数据日志实体定义如下:

/// <summary>
/// 数据日志信息类
/// </summary>
[Description("系统-数据日志信息")]
public class DataLog : EntityBase<int>
{
    /// <summary>
    /// 初始化一个<see cref="DataLog"/>类型的新实例
    /// </summary>
    public DataLog()
        : this(null, null, OperatingType.Query)
    { }

    /// <summary>
    /// 初始化一个<see cref="DataLog"/>类型的新实例
    /// </summary>
    public DataLog(string entityName, string name, OperatingType operatingType)
    {
        EntityName = entityName;
        Name = name;
        OperateType = operatingType;
        LogItems = new List<DataLogItem>();
    }

    /// <summary>
    /// 获取或设置 类型名称
    /// </summary>
    [StringLength(500)]
    [Display(Name = "类型名称")]
    public string EntityName { get; set; }

    /// <summary>
    /// 获取或设置 实体名称
    /// </summary>
    [Display(Name = "实体名称")]
    public string Name { get; set; }

    /// <summary>
    /// 获取或设置 数据编号
    /// </summary>
    [StringLength(150)]
    [DisplayName("主键值")]
    public string EntityKey { get; set; }

    /// <summary>
    /// 获取或设置 操作类型
    /// </summary>
    [Description("操作类型")]
    public OperatingType OperateType { get; set; }

    /// <summary>
    /// 获取或设置 操作日志信息
    /// </summary>
    public virtual OperateLog OperateLog { get; set; }

    /// <summary>
    /// 获取或设置 操作明细
    /// </summary>
    public virtual ICollection<DataLogItem> LogItems { get; set; }
}

数据日志操作变更明细项

/// <summary>
/// 实体操作日志明细
/// </summary>
[Description("系统-操作日志明细信息")]
public class DataLogItem : EntityBase<Guid>
{
    /// <summary>
    /// 初始化一个<see cref="DataLogItem"/>类型的新实例
    /// </summary>
    public DataLogItem()
        : this(null, null)
    { }

   /// <summary>
    ///初始化一个<see cref="DataLogItem"/>类型的新实例
   /// </summary>
   /// <param name="originalValue">旧值</param>
   /// <param name="newValue">新值</param>
    public DataLogItem(string originalValue, string newValue)
    {
        Id = CombHelper.NewComb();
        OriginalValue = originalValue;
        NewValue = newValue;
    }

    /// <summary>
    /// 获取或设置 字段
    /// </summary>
    public string Field { get; set; }

    /// <summary>
    /// 获取或设置 字段名称
    /// </summary>
    public string FieldName { get; set; }

    /// <summary>
    /// 获取或设置 旧值
    /// </summary>
    public string OriginalValue { get; set; }

    /// <summary>
    /// 获取或设置 新值
    /// </summary>
    public string NewValue { get; set; }

    /// <summary>
    /// 获取或设置 数据类型
    /// </summary>
    public string DataType { get; set; }

    /// <summary>
    /// 获取或设置 所属数据日志
    /// </summary>
    public virtual DataLog DataLog { get; set; }
}

数据日志操作类型的枚举:

/// <summary>
/// 实体数据日志操作类型
/// </summary>
public enum OperatingType
{
    /// <summary>
    /// 查询
    /// </summary>
    Query = 0,

    /// <summary>
    /// 新建
    /// </summary>
    Insert = 10,

    /// <summary>
    /// 更新
    /// </summary>
    Update = 20,

    /// <summary>
    /// 删除
    /// </summary>
    Delete = 30
}

下图以较直观的方式显示操作日志与数据日志之间的关系:

数据日志的创建

数据日志,主要记录业务操作过程中涉及到的各个数据实体的变更,而这里的变更,主要是实体的新增、更新、删除三种情况。

在EntityFramework的数据操作中,实体经过业务处理之后,都是有状态跟踪的,即是 EntityState 枚举类型:

public enum EntityState
{
    Detached = 1,
    Unchanged = 2,
    Added = 4,
    Deleted = 8,
    Modified = 16,
}

我们要关心的状态,主要是Added、Deleted、Modified三个值,分别对应着新增、删除、更新三种状态,在EntityFramework执行到 SaveChanges 的时候,各个实体的状态已经确定。OSharp将在这个时机获取变更的实体并创建数据日志信息。

/// <summary>
/// 提交当前单元操作的更改
/// </summary>
/// <param name="validateOnSaveEnabled">提交保存时是否验证实体约束有效性。</param>
/// <returns>操作影响的行数</returns>
internal virtual int SaveChanges(bool validateOnSaveEnabled)
{
    bool isReturn = Configuration.ValidateOnSaveEnabled != validateOnSaveEnabled;
    try
    {
        Configuration.ValidateOnSaveEnabled = validateOnSaveEnabled;
        //记录实体操作日志
        List<DataLog> logs = new List<DataLog>();
        if (DataLoggingEnabled)
        {
            logs = this.GetEntityDataLogs().ToList();
        }
        int count = base.SaveChanges();
        if (count > 0 && DataLoggingEnabled)
        {
            Logger.Info(logs, true);
        }
        TransactionEnabled = false;
        return count;
    }
    catch (DbUpdateException e)
    {
        if (e.InnerException != null && e.InnerException.InnerException is SqlException)
        {
            SqlException sqlEx = e.InnerException.InnerException as SqlException;
            string msg = DataHelper.GetSqlExceptionMessage(sqlEx.Number);
            throw new OSharpException("提交数据更新时发生异常:" + msg, sqlEx);
        }
        throw;
    }
    finally
    {
        if (isReturn)
        {
            Configuration.ValidateOnSaveEnabled = !validateOnSaveEnabled;
        }
    }
}

以上代码中, DataLoggingEnabled 属性 是当前上下文是否开启数据日志的总开关,当开启数据日志记录功能时,才进行数据日志的创建。

创建数据日志的实现如下,主要是从对象管理器中筛选出指定状态的实体对象,再由实体类型全名获取相应实体的“实体信息记录”,确定是否执行数据日志的创建,然后创建数据日志信息:

/// <summary>
/// 获取数据上下文的变更日志信息
/// </summary>
public static IEnumerable<DataLog> GetEntityDataLogs(this DbContext dbContext)
{
    ObjectContext objectContext = ((IObjectContextAdapter)dbContext).ObjectContext;
    ObjectStateManager manager = objectContext.ObjectStateManager;

    IEnumerable<DataLog> logs = from entry in manager.GetObjectStateEntries(EntityState.Added).Where(entry => entry.Entity != null)
        let entityInfo = OSharpContext.Current.EntityInfoHandler.GetEntityInfo(entry.Entity.GetType())
        where entityInfo != null && entityInfo.DataLogEnabled
        select GetAddedLog(entry, entityInfo);

    logs = logs.Concat(from entry in manager.GetObjectStateEntries(EntityState.Modified).Where(entry => entry.Entity != null)
        let entityInfo = OSharpContext.Current.EntityInfoHandler.GetEntityInfo(entry.Entity.GetType())
        where entityInfo != null && entityInfo.DataLogEnabled
        select GetModifiedLog(entry, entityInfo));

    logs = logs.Concat(from entry in manager.GetObjectStateEntries(EntityState.Deleted).Where(entry => entry.Entity != null)
        let entityInfo = OSharpContext.Current.EntityInfoHandler.GetEntityInfo(entry.Entity.GetType())
        where entityInfo != null && entityInfo.DataLogEnabled
        select GetDeletedLog(entry, entityInfo));

    return logs;
}

创建“新增”实体的数据日志:

/// <summary>
/// 获取添加数据的日志信息
/// </summary>
/// <param name="entry">实体状态跟踪信息</param>
/// <param name="entityInfo">实体数据信息</param>
/// <returns>新增数据日志信息</returns>
private static DataLog GetAddedLog(ObjectStateEntry entry, IEntityInfo entityInfo)
{
    DataLog log = new DataLog(entityInfo.ClassName, entityInfo.Name, OperatingType.Insert);
    for (int i = 0; i < entry.CurrentValues.FieldCount; i++)
    {
        string name = entry.CurrentValues.GetName(i);
        if (name == "Timestamp")
        {
            continue;
        }
        object value = entry.CurrentValues.GetValue(i);
        if (name == "Id")
        {
            log.EntityKey = value.ToString();
        }
        Type fieldType = entry.CurrentValues.GetFieldType(i);
        DataLogItem logItem = new DataLogItem()
        {
            Field = name,
            FieldName = entityInfo.PropertyNames[name],
            NewValue = value == null ? null : value.ToString(),
            DataType = fieldType == null ? null : fieldType.Name
        };
        log.LogItems.Add(logItem);
    }
    return log;
}

创建“更新”实体的数据日志:

/// <summary>
/// 获取修改数据的日志信息
/// </summary>
/// <param name="entry">实体状态跟踪信息</param>
/// <param name="entityInfo">实体数据信息</param>
/// <returns>修改数据日志信息</returns>
private static DataLog GetModifiedLog(ObjectStateEntry entry, IEntityInfo entityInfo)
{
    DataLog log = new DataLog(entityInfo.ClassName, entityInfo.Name, OperatingType.Update);
    for (int i = 0; i < entry.CurrentValues.FieldCount; i++)
    {
        string name = entry.CurrentValues.GetName(i);
        if (name == "Timestamp")
        {
            continue;
        }
        object currentValue = entry.CurrentValues.GetValue(i);
        object originalValue = entry.OriginalValues[name];
        if (name == "Id")
        {
            log.EntityKey = originalValue.ToString();
        }
        if (currentValue.Equals(originalValue))
        {
            continue;
        }
        Type fieldType = entry.CurrentValues.GetFieldType(i);
        DataLogItem logItem = new DataLogItem()
        {
            Field = name,
            FieldName = entityInfo.PropertyNames[name],
            NewValue = currentValue == null ? null : currentValue.ToString(),
            OriginalValue = originalValue == null ? null : originalValue.ToString(),
            DataType = fieldType == null ? null : fieldType.Name
        };
        log.LogItems.Add(logItem);
    }
    return log;
}

创建“删除”实体的数据日志:

/// <summary>
/// 获取删除数据的日志信息
/// </summary>
/// <param name="entry">实体状态跟踪信息</param>
/// <param name="entityInfo">实体数据信息</param>
/// <returns>删除数据日志信息</returns>
private static DataLog GetDeletedLog(ObjectStateEntry entry, IEntityInfo entityInfo)
{
    DataLog log = new DataLog(entityInfo.ClassName, entityInfo.Name, OperatingType.Delete);
    for (int i = 0; i < entry.OriginalValues.FieldCount; i++)
    {
        string name = entry.OriginalValues.GetName(i);
        if (name == "Timestamp")
        {
            continue;
        }
        object originalValue = entry.OriginalValues[i];
        if (name == "Id")
        {
            log.EntityKey = originalValue.ToString();
        }
        Type fieldType = entry.OriginalValues.GetFieldType(i);
        DataLogItem logItem = new DataLogItem()
        {
            Field = name,
            FieldName = entityInfo.PropertyNames[name],
            OriginalValue = originalValue == null ? null : originalValue.ToString(),
            DataType = fieldType == null ? null : fieldType.Name
        };
        log.LogItems.Add(logItem);
    }
    return log;
}

数据日志的传递

前面我们已经完成了数据日志创建,但数据日志是由数据层的EntityFramework的SaveChanges方法创建的,而创建的数据日志,最终将传递到上层定义的 OperateLogFilterAttribute 中进行使用,这就需要我们通过一定的机制将数据日志往上传递。在这里,使用的是日志组件。

OSharp中定义了一个数据日志缓存,专门用于接收数据层创建的数据日志信息:

/// <summary>
/// 数据日志缓存接口
/// </summary>
public interface IDataLogCache : IDependency
{
    /// <summary>
    /// 获取 数据日志集合
    /// </summary>
    IEnumerable<DataLog> DataLogs { get; }

    /// <summary>
    /// 向缓存中添加数据日志信息
    /// </summary>
    /// <param name="dataLog">数据日志信息</param>
    void AddDataLog(DataLog dataLog);
}

在专用于数据日志记录的 DatabaseLog 的 Write 方法重写时,判断数据是否是 DataLog 类型,并存入 IDataLogCache 中,这里使用MVC的依赖注入功能获取IDataLogCache的实现,以保证其在同一Http请求中,获取的是同一实例:

/// <summary>
/// 获取日志输出处理委托实例
/// </summary>
/// <param name="level">日志输出级别</param>
/// <param name="message">日志消息</param>
/// <param name="exception">日志异常</param>
/// <param name="isData">是否数据日志</param>
protected override void Write(LogLevel level, object message, Exception exception, bool isData = false)
{
    if (!isData)
    {
        return;
    }
    IEnumerable<DataLog> dataLogs = message as IEnumerable<DataLog>;
    if (dataLogs == null)
    {
        return;
    }
    IDataLogCache logCache = DependencyResolver.Current.GetService<IDataLogCache>();
    foreach (DataLog dataLog in dataLogs)
    {
        logCache.AddDataLog(dataLog);
    }
}

操作日志的记录

定义了一个 OperateLogFilterAttribute 的ActionFilter,专门用于拦截并记录操作日志。

/// <summary>
/// 操作日志记录过滤器
/// </summary>
public class OperateLogFilterAttribute : ActionFilterAttribute
{
    /// <summary>
    /// 获取或设置 数据日志缓存
    /// </summary>
    public IDataLogCache DataLogCache { get; set; }

    /// <summary>
    /// 获取或设置 操作日志输出者
    /// </summary>
    public IOperateLogWriter OperateLogWriter { get; set; }

    /// <summary>
    /// Called after the action method executes.
    /// </summary>
    /// <param name="filterContext">The filter context.</param>
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        string area = filterContext.GetAreaName();
        string controller = filterContext.GetControllerName();
        string action = filterContext.GetActionName();

        IFunction function = OSharpContext.Current.FunctionHandler.GetFunction(area, controller, action);
        if (function == null || !function.OperateLogEnabled)
        {
            return;
        }
        Operator @operator = new Operator()
        {
            Ip = filterContext.HttpContext.Request.GetIpAddress(),
        };
        if (filterContext.HttpContext.Request.IsAuthenticated)
        {
            ClaimsIdentity identity = filterContext.HttpContext.User.Identity as ClaimsIdentity;
            if (identity != null)
            {
                @operator.UserId = identity.GetClaimValue(ClaimTypes.NameIdentifier);
                @operator.Name = identity.GetClaimValue(ClaimTypes.Name);
                @operator.NickName = identity.GetClaimValue(ClaimTypes.GivenName);
            }
        }

        OperateLog operateLog = new OperateLog()
        {
            FunctionName = function.Name,
            Operator = @operator
        };
        if (function.DataLogEnabled)
        {
            foreach (DataLog dataLog in DataLogCache.DataLogs)
            {
                operateLog.DataLogs.Add(dataLog);
            }
        }
        OperateLogWriter.Write(operateLog);
    }
}

最后,操作日志将由 IOperateLogWriter 进行输出,定义如下:

/// <summary>
/// 操作日志输出接口
/// </summary>
public interface IOperateLogWriter : IDependency
{
    /// <summary>
    /// 输出操作日志
    /// </summary>
    /// <param name="operateLog">操作日志信息</param>
    void Write(OperateLog operateLog);
}

默认的,操作日志将被记录到数据库中:

/// <summary>
/// 操作日志数据库输出实现
/// </summary>
public class DatabaseOperateLogWriter : IOperateLogWriter
{
    private readonly IRepository<OperateLog, int> _operateLogRepository;

    /// <summary>
    /// 初始化一个<see cref="DatabaseOperateLogWriter"/>类型的新实例
    /// </summary>
    public DatabaseOperateLogWriter(IRepository<OperateLog, int> operateLogRepository)
    {
        _operateLogRepository = operateLogRepository;
    }

    /// <summary>
    /// 输出操作日志
    /// </summary>
    /// <param name="operateLog">操作日志信息</param>
    public void Write(OperateLog operateLog)
    {
        operateLog.CheckNotNull("operateLog" );
        _operateLogRepository.Insert(operateLog);
    }
}

操作日志显示

如果一条操作日志中包含有数据日志,那么数据日志将以下级数据的方式展现在操作日志中:

nuget获取程序集及更新

OSharp的相关类库已经发布到nuget上,并且会伴随着项目进度及时更新。

直接通过VisualStudio自带的nuget插件搜索“osharp”关键字即可找到,欢迎试用。

osharp在nuget

获取示例

OSharp 3.0 的使用示例,已提交到 github.com,将会随着框架的发展而及时更新,欢迎关注:

示例源码地址:https://github.com/i66soft/osharp3-demo

在Visual Studio 2013 中,可直接获取最新的源代码,获取方式如下,地址为:https://github.com/i66soft/osharp3-demo.git