【开源】OSharp3.0框架解说系列(6.2):操作日志与数据日志
OSharp是什么?
OSharp是個快速開發框架,但不是一個大而全的包羅萬象的框架,嚴格的說,OSharp中什么都沒有實現。與其他大而全的框架最大的不同點,就是OSharp只做抽象封裝,不做實現。依賴注入、ORM、對象映射、日志、緩存等等功能,都只定義了一套最基礎最通用的抽象封裝,提供了一套統一的API、約定與規則,并定義了部分執行流程,主要是讓項目在一定的規范下進行開發。所有的功能實現端,都是通過現有的成熟的第三方組件來實現的,除了EntityFramework之外,所有的第三方實現都可以輕松的替換成另一種第三方實現,OSharp框架正是要起隔離作用,保證這種變更不會對業務代碼造成影響,使用統一的API來進行業務實現,解除與第三方實現的耦合,保持業務代碼的規范與穩定。
本文已同步到系列目錄:OSharp快速開發框架解說系列
前言
在《【開源】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()]
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()]
[Display(Name = "類型名稱")]
public string EntityName { get; set; } /// <summary>
/// 獲取或設置 實體名稱
/// </summary>
[Display(Name = "實體名稱")]
public string Name { get; set; } /// <summary>
/// 獲取或設置 數據編號
/// </summary>
[StringLength()]
[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 = , /// <summary>
/// 新建
/// </summary>
Insert = , /// <summary>
/// 更新
/// </summary>
Update = , /// <summary>
/// 刪除
/// </summary>
Delete =
}
下圖以較直觀的方式顯示操作日志與數據日志之間的關系:
數據日志的創建
數據日志,主要記錄業務操作過程中涉及到的各個數據實體的變更,而這里的變更,主要是實體的新增、更新、刪除三種情況。
在EntityFramework的數據操作中,實體經過業務處理之后,都是有狀態跟蹤的,即是 EntityState 枚舉類型:
public enum EntityState
{
Detached = ,
Unchanged = ,
Added = ,
Deleted = ,
Modified = ,
}
我們要關心的狀態,主要是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;
//記錄實體操作日志
13 List<DataLog> logs = new List<DataLog>();
14 if (DataLoggingEnabled)
15 {
16 logs = this.GetEntityDataLogs().ToList();
17 }
int count = base.SaveChanges();
19 if (count > 0 && DataLoggingEnabled)
20 {
21 Logger.Info(logs, true);
22 }
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 = ; 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 = ; 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 = ; 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);
}
}
操作日志顯示
如果一條操作日志中包含有數據日志,那么數據日志將以下級數據的方式展現在操作日志中:
開源說明
github.com
OSharp項目已在github.com上開源,地址為:https://github.com/i66soft/osharp,歡迎閱讀代碼,歡迎 Watch(關注),歡迎 Star(推薦),如果您認同 OSharp 項目的設計思想,歡迎參與 OSharp 項目的開發。
在Visual Studio 2013中,可直接獲取 OSharp 的最新源代碼,獲取方式如下,地址為:https://github.com/i66soft/osharp.git
開源項目參與方式
很多童鞋想參與開源項目,為項目做貢獻,但又不知道如何做,這里我簡單說下參與OSharp的步驟吧:
- 在 https://github.com/i66soft/osharp 右上角 Fork 一下項目源碼,在你的賬戶下會有一份代碼的副本
- 使用VisualStudio Clone 你賬戶下的代碼到本地,更改代碼,再提交,就完成代碼的更改了
- 如果覺得有并入 i66soft 主干的價值,可以向主干提交 pull request申請,如果我審核通過,就可以合并到主干了,這就形成了一次開源代碼的貢獻了
- 如果我沒有接受合并,你也可以在你的賬戶上按你的風格去發展osharp
- 我也會經常瀏覽各個Fork版本對項目的更改,如果覺得有價值,也會主動合并到主干代碼中,也能形成一次對開源的貢獻
- 為保證提交的質量,也便于對代碼的合并,每次更改與提交應該只做一件事,只提交必要的更改
nuget
OSharp的相關類庫已經發布到nuget上,歡迎試用,直接在nuget上搜索 “osharp” 關鍵字即可找到
系列導航
本文已同步到系列目錄:OSharp快速開發框架解說系列
總結
以上是生活随笔為你收集整理的【开源】OSharp3.0框架解说系列(6.2):操作日志与数据日志的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: log4j 使用记录
- 下一篇: 康拓展开-排列的hash