编写 NHibernate 二级缓存使用测试
通过利用自定义的 log4net appender 来编写 NHibernate Level 2 缓存相关的测试。
动机
距离我写上一篇博客文章已经很久了。我一直在忙于我们的新项目,终于,我们完成了编码并进入了验收测试阶段。那个新项目在架构方面带来了一些挑战。其中一些挑战包括:
- 我们不得不针对一个遗留数据库进行开发。
- 我们不得不更换一个现有的系统,引入一些新概念是一个稍微棘手お过程。
- 我们的系统设计是针对遗留数据库的一小部分;然而,那一部分被一些外部进程/系统访问,带来了一些同步问题。
- 我们设定了非常严格的代码覆盖率和测试目标。
在这篇文章中,我将尝试分享一个非常具体的问题:为 NHibernate Level 2 缓存相关功能编写好的测试,我们遇到的情况以及我们如何解决这个问题。
测试 NHibernate 项目
我认为测试 NHibernate 项目有点棘手。让我用一个例子来解释为什么它如此棘手。
由于我们是针对遗留数据库进行开发,我们不得不使用一个遗留的存储过程来检索某些类型的数据。为了实现这一目标,我们引入了:
- 一个名为
RetreiveDataViaStoredProcedure
的数据访问层 (DAL) 方法。 - 一个数据传输对象 (DTO) 来保存我们的 DAL 方法返回的行数据。
- 在我们的 NHibernate 映射中一个命名查询。
我们想测试我们的 DAL 方法是否按我们预期的方式工作。作为通用的单元测试实践,我们模拟了我们的 DAL 方法。实际上,这个方法定义在一个接口上,可以很容易地被模拟,然后我们写了我们的测试。如果我们不模拟 DAL 方法,我们就必须针对数据库进行测试,这会违反“单元测试最佳实践:单元测试必须在几毫秒内运行”原则,并带来“在单个测试完成后尽快将数据库恢复到已知状态”的挑战。乍一看,这种情况似乎很简单;我们知道该做什么,不该做什么。
但很抱歉地告诉您,模拟并不能真正测试我们的 DAL 方法是否正常工作,这可能会导致微妙的 bug 泄漏到我们的生产环境中。因为
- NHibernate 提供的 Transformer,用于从返回的结果集生成 DTO,可能因为列数据类型与 DTO 属性类型不匹配而失败,这可能是由于 DAL 方法或存储过程中存在的开发人员错误导致的。
- 如果我们更改了存储过程,我们的测试应该会失败,以便我们修改 DTO 来处理修改后的结果集。但是,由于我们的测试调用的是我们方法的模拟版本,即使存储过程被更改,我们也无法得到一个失败的测试。
- 我们不知道我们的命名查询映射是否出错。
所以,测试我们 DAL 方法的唯一方法是设计一个针对测试数据库运行的测试,这并不是一个好习惯,甚至都不是单元测试。关键在于
- 我们必须编写直接访问测试数据库的测试,因为我们想测试 NHibernate 特有的行为。
- 我们也可以为项目中的业务逻辑相关部分编写真实的单元测试。例如,测试不同的税务场景。
测试 Level 2 缓存的问题
在我们项目的最后一个阶段,我们决定使用 NHibernate 提供的 Level 2 缓存。我们通过更改一些配置和映射设置轻松地将此功能引入了我们的项目。然后,我们想测试我们是否真的按照我们想要的方式使用了 Level 2 缓存。我们写了一些看起来像这样的测试:
/// <summary>
/// This test method demonstrates the difficulties
/// of testing if an object was fetched from level2 cache
/// </summary>
[Test]
public void TheWayWeDoNotWantIt()
{
Parent parent = new Parent();
parent.Data = "ParentData";
Child child = new Child();
child.Parent = parent;
child.Data = "ChildData";
parent.Children.Add(child);
_parentDao.SaveOrUpdate(parent);
UnitOfWork.CommitCurrentAndStartNew();
Child c1 = _childDao.Get(child.Id);
UnitOfWork.CommitCurrentAndStartNew();
// Instance comes from the level2 cache
Child c2 = _childDao.Get(child.Id);
// We can not determine if instance
// comes from level2 cache with this assert
Assert.IsTrue(c2.Id == child.Id);
}
我们有一个非常简单的域,有两个实体类,名为 Parent
和 Child
。每个父对象可能有 0 个或多个 Child
对象,每个 Child
对象有一个 Parent
。示例测试方法中的问题在于第 26 行的 Assert.IsTrue(c2.Id == child.Id)
调用。此断言的成功并不能保证第 23 行对 Child c2 = _childDao.Get(child.Id)
的调用导致了缓存命中,因此我们是从 Level 2 缓存中检索了 Child
对象,而不是数据库。所以,我们必须找到一种方法来测试第 23 行是否真的导致了缓存命中,这是一个有点棘手的¡问题。
解决方案
NHibernate 在很大程度上利用 log4net 来提供不同级别的非常详细的运行时日志消息。我过去经常使用 NHibernate 日志消息来捕获 NHibernate 在数据访问相关测试失败时实际发生的情况。因此,作为一种本能反应,我检查了 NHibernate 在执行 Level 2 缓存相关操作时记录的内容,当我看到 NHibernate 提供了格式精美的日志消息时,我并不感到惊讶。这正是我需要的。我可以捕获 NHibernate.Cache.ReadWriteCache
logger 记录的 Level 2 缓存相关消息,并围绕这个概念开发一些工具。待办事项清单是:
- 开发自定义 log4net appender,并配置 log4net 使用此 appender 来捕获
NHibernate.Cache.ReadWriteCache
logger 提供的消息。 - 解码日志消息,因为它们是遵循某种预定义格式的字符串,以便创建结构良好且易于管理的缓存实体信息对象。
- 设计一个观察者类,可用于观察由我的自定义 appender 捕获的与缓存相关的日志消息。
CachedEntityInfo 和 CachedEntityInfoList
CachedEntityInfo
类用于以结构化的方式保存解析后的日志消息数据。CachedEntityInfoList
类继承自 List<CachedEntityInfo>
,用于保存解码为 CachedEntityInfo
对象的日志消息,并提供自定义包装器方法来查询特定类型的日志对象。这是代码列表:
public class CachedEntityInfo
{
public static readonly CachedEntityInfo Empty = new CachedEntityInfo();
public string TypeName { get; set; }
public string Id { get; set; }
public CacheLogAppender.CacheActionType CacheAction { get; set; }
public CachedEntityInfo(string typeName, string id, string actionName)
{
TypeName = typeName;
Id = id;
CacheAction = CacheLogAppender.ParseCacheActionTypeFromName(actionName);
}
private CachedEntityInfo(){}
public override string ToString()
{
return String.Format("{0}:{1}#{2}", CacheAction, TypeName, Id);
}
}
public class CachedEntityInfoList : List<CachedEntityInfo>
{
public IList<CachedEntityInfo> FindMultiple(string typeName, object id,
CacheLogAppender.CacheActionType actionType)
{
string idValue = id == null ? String.Empty : id.ToString();
return this.Where<CachedEntityInfo>(c => c.TypeName == typeName &&
(String.IsNullOrEmpty(idValue) || c.Id == idValue ) &&
c.CacheAction == actionType).ToList<CachedEntityInfo>();
}
public IList<CachedEntityInfo> FindMultiple(Type type, object id,
CacheLogAppender.CacheActionType actionType)
{
if (type == null)
throw new NullReferenceException("type parameter is null. Can not perform FindAll.");
return FindMultiple(type.FullName,id,actionType);
}
public IList<CachedEntityInfo> FindMultiple(Type type,
CacheLogAppender.CacheActionType actionType)
{
return FindMultiple(type.FullName, null, actionType);
}
public IList<CachedEntityInfo> FindMultiple(Type type, Type compositeIdType,
CacheLogAppender.CacheActionType actionType)
{
if (compositeIdType == null)
throw new NullReferenceException("compositeIdType parameter is null." +
" Can not perform FindMultiple.");
return FindMultiple(type.FullName, compositeIdType.FullName, actionType);
}
public CachedEntityInfo FindSingle(string typeName, object id,
CacheLogAppender.CacheActionType actionType)
{
string idValue = id == null ? String.Empty : id.ToString();
return this.SingleOrDefault<CachedEntityInfo>(c => c.TypeName ==
typeName && (String.IsNullOrEmpty(idValue)
|| c.Id == idValue) && c.CacheAction == actionType);
}
public CachedEntityInfo FindSingle(Type type, object id,
CacheLogAppender.CacheActionType actionType)
{
if (type == null)
throw new NullReferenceException("type parameter is null." +
" Can not perform FindSingle.");
return FindSingle(type.FullName, id, actionType);
}
public CachedEntityInfo FindSingle(Type type,
CacheLogAppender.CacheActionType actionType)
{
return FindSingle(type.FullName, null, actionType);
}
public CachedEntityInfo FindSingle(Type type, Type compositeIdType,
CacheLogAppender.CacheActionType actionType)
{
if (compositeIdType == null)
throw new NullReferenceException("compositeIdType parameter" +
" is null. Can not perform FindSingle.");
return FindSingle(type.FullName, compositeIdType.FullName, actionType);
}
}
CacheLogAppender:自定义 log4net appender
Log4Net 允许我们通过实现 IAppender
接口来实现自定义日志 appender。而不是从头开始实现 IAppender
,我们还可以使用 AppenderSkeleton
抽象类作为我们自定义 appender 实现的基类。这个类有一些简单的职责:
- 捕获
NHibernate.Cache.ReadWriteCache
logger 提供的日志消息。 - 解析日志消息并创建
CachedEntityInfo
实例。 - 一旦收到新的日志消息,立即通过事件通知附加的观察者。
这是 CacheLogAppender
的代码列表:
public delegate void CacheLogAppendDelegate(CachedEntityInfo cacheInfo);
/// <summary>
/// Log4Net appender implementation
/// </summary>
public class CacheLogAppender : AppenderSkeleton
{
/// <summary>
/// Cache action enumeration.
/// </summary>
public enum CacheActionType
{
Unknow,
Invalidate,
Release,
Caching,
Cached,
CacheLookup,
CacheMiss,
CacheHit,
Locked,
Inserting,
Inserted
}
private CacheLogAppendDelegate _onLogAppend;
public event CacheLogAppendDelegate OnLogAppend
{
add { _onLogAppend += value; }
remove { _onLogAppend -= value; }
}
/// <summary>
/// Parse CacheActionType from name
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
public static CacheActionType ParseCacheActionTypeFromName(string name)
{
string invariantName = name.ToLowerInvariant();
switch (invariantName)
{
case "invalidating":
return CacheActionType.Invalidate;
case "releasing":
return CacheActionType.Release;
case "caching":
return CacheActionType.Caching;
case "cached":
return CacheActionType.Cached;
case "cache lookup":
return CacheActionType.CacheLookup;
case "cache miss":
return CacheActionType.CacheMiss;
case "cache hit":
return CacheActionType.CacheHit;
case "cached item was locked":
return CacheActionType.Locked;
case "inserting":
return CacheActionType.Inserting;
case "inserted":
return CacheActionType.Inserted;
default:
return CacheActionType.Unknow;
}
}
/// <summary>
/// Append log
/// </summary>
/// <param name="loggingEvent"></param>
protected override void Append(LoggingEvent loggingEvent)
{
if (loggingEvent.MessageObject == null)
return;
CachedEntityInfo cachedEntity = ParseMessageObject(loggingEvent.MessageObject);
if(cachedEntity != null && _onLogAppend != null)
_onLogAppend(cachedEntity);
}
/// <summary>
/// Append logs
/// </summary>
/// <param name="loggingEvents"></param>
protected override void Append(LoggingEvent[] loggingEvents)
{
base.Append(loggingEvents);
}
/// <summary>
/// Parse message object to produce a CachedEntityInfo instance.
/// </summary>
/// <param name="messageObject"></param>
/// <returns></returns>
private CachedEntityInfo ParseMessageObject(object messageObject)
{
if (messageObject == null)
throw new NullReferenceException("Message object is null." +
" Can not parse log message object.");
string logMessage = messageObject.ToString();
Match m = Regex.Match(logMessage,
@"(?<ActionName>.*)\s*:\s*(?<TypeName>.*)\s*\#\s*(?<Id>.*)",
RegexOptions.IgnoreCase);
if (!m.Success)
throw new Exception("Log message does not match the pattern!");
string actionName = m.Groups["ActionName"].Value;
string typeName = m.Groups["TypeName"].Value;
string id = m.Groups["Id"].Value;
return new CachedEntityInfo(String.IsNullOrEmpty(typeName) ?
typeName : typeName.Trim(), String.IsNullOrEmpty(id) ? id : id.Trim(),
String.IsNullOrEmpty(actionName) ? actionName : actionName.Trim());
}
}
CacheLogObserver
实现
我们将在测试中使用此类来检查 Level 2 缓存相关的消息。
- 实现
IDisposable
,这样我们就可以将我们的测试放在 using 块中。 - 获取当前实例化的
CacheLogAppender
实例,并由 log4net 框架使用。 - 将委托附加到
CacheLogAppender
的OnLogAppend
事件。 - 将与缓存相关的日志消息存储在
CachedEntityInfoList
实例中。
这是代码列表。CacheLogObserver
中唯一棘手的部分是构造函数代码,我们在这里获取 log4net 仓库并获取 log4net 当前实例化的 CacheLogAppender
实例。
public class CacheLogObserver : IDisposable
{
private CacheLogAppender _appender = null;
private CachedEntityInfoList _cachedEntityInfos =
new CachedEntityInfoList();
public CachedEntityInfoList CachedEntityInfos
{
get { return _cachedEntityInfos; }
}
#region IDisposable Members
private bool _disposing = false;
public void Dispose()
{
if (_disposing)
return;
_disposing = true;
if (_appender != null)
_appender.OnLogAppend -=
new CacheLogAppendDelegate(appender_OnLogAppend);
}
#endregion
public CacheLogObserver(string logRepositoryName)
{
ILoggerRepository loggerRepo =
log4net.Core.LoggerManager.GetRepository(logRepositoryName);
_appender = loggerRepo.GetAppenders().Single<IAppender>(a =>
a.GetType() == typeof(CacheLogAppender)) as CacheLogAppender;
_appender.OnLogAppend += new CacheLogAppendDelegate(appender_OnLogAppend);
}
/// <summary>
/// Default constructor. Gets default log4net repository.
/// </summary>
public CacheLogObserver()
: this("log4net-default-repository"){}
private void appender_OnLogAppend(CachedEntityInfo cacheInfo)
{
_cachedEntityInfos.Add(cacheInfo);
}
}
用法
这是我们解决方案的实际应用。我们只需重写我们的测试方法以使用 CacheLogObserver
,并更改我们的测试断言以检查预期的 Level 2 缓存操作(缓存命中)是否包含在 Observer 实例的 CachedEntityInfos
列表中。请注意,我们将测试代码放在了一个 using
块中。
[Test]
public void TheWayWeWantIt()
{
Parent parent = new Parent();
parent.Data = "TestParent";
Child child = new Child();
child.Parent = parent;
child.Data = "TestChild";
parent.Children.Add(child);
_parentDao.SaveOrUpdate(parent);
UnitOfWork.CommitCurrentAndStartNew();
Child c1 = _childDao.Get(child.Id);
UnitOfWork.CommitCurrentAndStartNew();
using (CacheLogObserver observer = new CacheLogObserver())
{
// Instance comes from the level2 cache
Child c2 = _childDao.Get(child.Id);
CachedEntityInfo cacheInfo = observer.CachedEntityInfos.FindSingle(c2.GetType(),
c2.Id, CacheLogAppender.CacheActionType.CacheHit);
Assert.IsNotNull(cacheInfo);
}
}
配置我们的测试项目
请按照以下步骤修改您的测试项目配置文件(App.config 或 Web.config):
- 在
configSections
下添加一个log4net
部分,如第 5 行所示。 - 在 log4net 部分下添加
CacheLogAppender
,如第 39 行至第 42 行所示。 - 在
log4net
部分下添加一个新的 logger,如第 55 行至第 58 行所示。
您的配置必须如下所示:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="NHibernate.Burrow"
type="NHibernate.Burrow.Configuration.NHibernateBurrowCfgSection,
NHibernate.Burrow, Version=1.0.0.2002, Culture=neutral,
PublicKeyToken=null" />
<section name="log4net"
type="log4net.Config.Log4NetConfigurationSectionHandler,
log4net, Version=1.2.10.0, Culture=neutral,
PublicKeyToken=1b44e1d426115821" />
<section name="syscache"
type="NHibernate.Caches.SysCache.SysCacheSectionHandler,
NHibernate.Caches.SysCache" />
</configSections>
<appSettings>
<add key="ClientSettingsProvider.ServiceUri" value="" />
<!-- Connection string used for mocking database records/classes-->
</appSettings>
<NHibernate.Burrow>
<persistenceUnits>
<add name="NHibernate.Caches.Testing"
nh-config-file="~\..\..\Config\NHTest.config"/>
</persistenceUnits>
</NHibernate.Burrow>
<system.web>
<membership defaultProvider="ClientAuthenticationMembershipProvider">
<providers>
<add name="ClientAuthenticationMembershipProvider"
type="System.Web.ClientServices.Providers.
ClientFormsAuthenticationMembershipProvider,
System.Web.Extensions, Version=3.5.0.0, Culture=neutral,
PublicKeyToken=31bf3856ad364e35"
serviceUri="" />
</providers>
</membership>
<roleManager defaultProvider="ClientRoleProvider" enabled="true">
<providers>
<add name="ClientRoleProvider"
type="System.Web.ClientServices.Providers.ClientRoleProvider,
System.Web.Extensions, Version=3.5.0.0, Culture=neutral,
PublicKeyToken=31bf3856ad364e35"
serviceUri="" cacheTimeout="86400" />
</providers>
</roleManager>
</system.web>
<log4net debug="false">
<!-- Define some output appenders -->
<appender name="trace" type="log4net.Appender.TraceAppender">
<layout type="log4net.Layout.PatternLayout">
</layout>
</appender>
<appender name="CacheLogAppender" type="Tests.CacheLogAppender">
<layout type="log4net.Layout.PatternLayout">
</layout>
</appender>
<!-- Setup the root category, add the appenders and set the default priority -->
<!-- levels: DEBUG, INFO, WARN, ERROR, FATAL -->
<root>
<priority value="DEBUG" />
</root>
<logger name="NHibernate" additivity="true">
<level value="DEBUG"/>
<appender-ref ref="trace"/>
</logger>
<logger name="NHibernate.Cache.ReadWriteCache" additivity="true">
<level value="DEBUG"/>
<appender-ref ref="CacheLogAppender"/>
</logger>
</log4net>
<syscache>
<cache region='NHibernate.Caches.Testing' expiration="60" priority="3" />
</syscache>
</configuration>
解决方案结构和外部依赖项
此条目附带的可下载解决方案包含两个项目:
- Domain 项目是我们定义实体和映射的地方。
- Tests 项目是我们包含测试、log4net appender 实现以及其他一些 NHibernate 相关实用类的地方。
示例解决方案依赖于:
- NHibernate.Burrow:一个出色的框架,实现了 SessionInView 模式、事务管理以及其他一些很棒的功能。
- NHibernate.Caches:提供了不同的 Level 2 缓存提供程序实现。
- MBUnit:单元测试框架。您可以通过对
TestBase
类进行一些代码更改,用任何其他单元测试框架替换 MBUnit。 - Log4Net
其他可能性和一些推测
您可以使用 NHibernate 日志消息来执行许多其他操作,例如 Ayende 使用 NHibernate Profiler (NHProf) 所做的。我对 NHProf 的内部机制一无所知,但我从 这篇文档 中了解到,为了在我们的项目中启用 NHProf,我们需要设置自定义的 NHProf appender。但老实说,尽管 Ayende 是一个我非常尊敬的人,因为他做出了宝贵的贡献,但我对 NHibernate Profiler 有点困惑,因为:
- Ayende 在 其中一篇博文中 写道,为了给 NHProf 添加一些功能,他不得不修改 NHibernate 的主干。我认为修改像 NHibernate 这样被广泛使用的开源项目,来支持像 NHProf 这样的商业工具的新付费功能,是一个很大的错误。
- 如果 NHibernate 团队更改了某些日志消息的格式,将会发生什么,Ayende 将如何控制消息的格式,以便我们可以确信 NHProf 能够进行真正出色的分析。
- NHProf 是一个商业产品,每个座位 200 欧元。
撇开这些令人困惑的点不谈,我仍然欣赏 NHProf 在分析方面所做的工作。关于这些推测就到此为止。