在 .Net Core 2 中模拟 Hangfire RecurringJob 依赖
Mocking Hangfire RecurringJob Dependency in .Net Core 2
考虑以下控制器:
public class SubmissionController : Controller
{
public SubmissionController()
{ }
public IActionResult Post()
{
RecurringJob.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);
return Ok("Periodic submission triggered");
}
}
Hangfire 是否为 RecurringJob class 提供抽象注入依赖项?我做了一些研究,唯一可用的抽象是 IBackgroundJobClient
,它没有安排重复作业的选项。
我需要验证作业是否已添加到单元测试中。
如果你检查 RecurringJob
class 的 source code,你会看到它的静态方法导致调用 RecurringJobManager
class:
public static class RecurringJob
{
private static readonly Lazy<RecurringJobManager> Instance = new Lazy<RecurringJobManager>(
() => new RecurringJobManager());
// ...
public static void AddOrUpdate(
Expression<Action> methodCall,
string cronExpression,
TimeZoneInfo timeZone = null,
string queue = EnqueuedState.DefaultQueue)
{
var job = Job.FromExpression(methodCall);
var id = GetRecurringJobId(job);
Instance.Value.AddOrUpdate(id, job, cronExpression, timeZone ?? TimeZoneInfo.Utc, queue);
}
// ...
}
RecurringJobManager
实现了 IRecurringJobManager
接口,你可以在 UT 中使用它进行依赖注入和模拟。
但是 RecurringJob
具有从 lambda 获取作业并构建作业 ID 的内部逻辑:
var job = Job.FromExpression(methodCall);
var id = GetRecurringJobId(job);
Job.FromExpression()
是一个可以放心使用的public 方法。但是 GetRecurringJobId
是一个私有方法,定义如下:
private static string GetRecurringJobId(Job job)
{
return $"{job.Type.ToGenericTypeString()}.{job.Method.Name}";
}
GetRecurringJobId
基本上 returns 作业方法的名称,形式为 SubmissionController.InitiateSubmission
。它基于内部 class TypeExtensions
和 Type
的扩展方法。你不能直接使用这个 class 因为它是内部的,所以你应该复制那个逻辑。
如果您采用这种方法,您的最终解决方案将是:
TypeExtensions(从 Hangfire sources 复制):
static class TypeExtensions
{
public static string ToGenericTypeString(this Type type)
{
if (!type.GetTypeInfo().IsGenericType)
{
return type.GetFullNameWithoutNamespace()
.ReplacePlusWithDotInNestedTypeName();
}
return type.GetGenericTypeDefinition()
.GetFullNameWithoutNamespace()
.ReplacePlusWithDotInNestedTypeName()
.ReplaceGenericParametersInGenericTypeName(type);
}
private static string GetFullNameWithoutNamespace(this Type type)
{
if (type.IsGenericParameter)
{
return type.Name;
}
const int dotLength = 1;
// ReSharper disable once PossibleNullReferenceException
return !String.IsNullOrEmpty(type.Namespace)
? type.FullName.Substring(type.Namespace.Length + dotLength)
: type.FullName;
}
private static string ReplacePlusWithDotInNestedTypeName(this string typeName)
{
return typeName.Replace('+', '.');
}
private static string ReplaceGenericParametersInGenericTypeName(this string typeName, Type type)
{
var genericArguments = type.GetTypeInfo().GetAllGenericArguments();
const string regexForGenericArguments = @"`[1-9]\d*";
var rgx = new Regex(regexForGenericArguments);
typeName = rgx.Replace(typeName, match =>
{
var currentGenericArgumentNumbers = int.Parse(match.Value.Substring(1));
var currentArguments = string.Join(",", genericArguments.Take(currentGenericArgumentNumbers).Select(ToGenericTypeString));
genericArguments = genericArguments.Skip(currentGenericArgumentNumbers).ToArray();
return string.Concat("<", currentArguments, ">");
});
return typeName;
}
public static Type[] GetAllGenericArguments(this TypeInfo type)
{
return type.GenericTypeArguments.Length > 0 ? type.GenericTypeArguments : type.GenericTypeParameters;
}
}
RecurringJobManagerExtensions:
public static class RecurringJobManagerExtensions
{
public static void AddOrUpdate(this IRecurringJobManager manager, Expression<Action> methodCall, Func<string> cronExpression, TimeZoneInfo timeZone = null, string queue = EnqueuedState.DefaultQueue)
{
var job = Job.FromExpression(methodCall);
var id = $"{job.Type.ToGenericTypeString()}.{job.Method.Name}";
manager.AddOrUpdate(id, job, cronExpression(), timeZone ?? TimeZoneInfo.Utc, queue);
}
}
注入控制器 IRecurringJobManager
:
public class SubmissionController : Controller
{
private readonly IRecurringJobManager recurringJobManager;
public SubmissionController(IRecurringJobManager recurringJobManager)
{
this.recurringJobManager = recurringJobManager;
}
public IActionResult Post()
{
recurringJobManager.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);
return Ok("Periodic submission triggered");
}
public void InitiateSubmission()
{
// ...
}
}
好吧,这种方法行得通,但我不喜欢它。它基于一些将来可能会更改的内部 Hangfire 内容。
这就是为什么我建议使用另一种方法。您可以添加新的外观接口(例如 IRecurringJobFacade
),它将模仿您将要使用的 RecurringJob
中的方法。该接口的实现只会调用相应的 RecurringJob
方法。然后你将这个 IRecurringJobFacade
注入到控制器中,并且可以很容易地在 UT 中模拟它。这是一个示例:
IRecurringJobFacade:
public interface IRecurringJobFacade
{
void AddOrUpdate(Expression<Action> methodCall, Func<string> cronExpression);
// Mimic other methods from RecurringJob that you are going to use.
// ...
}
RecurringJobFacade:
public class RecurringJobFacade : IRecurringJobFacade
{
public void AddOrUpdate(Expression<Action> methodCall, Func<string> cronExpression)
{
RecurringJob.AddOrUpdate(methodCall, cronExpression);
}
}
注入控制器 IRecurringJobFacade
:
public class SubmissionController : Controller
{
private readonly IRecurringJobFacade recurringJobFacade;
public SubmissionController(IRecurringJobFacade recurringJobFacade)
{
this.recurringJobFacade = recurringJobFacade;
}
public IActionResult Post()
{
recurringJobFacade.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);
return Ok("Periodic submission triggered");
}
public void InitiateSubmission()
{
// ...
}
}
如您所见,这种方法更简单,最重要的是它更可靠,因为它不深入研究 Hangfire 内部结构,只是像往常一样调用 RecurringJob
方法。
当无法直接模拟代码时(静态方法或 classes 不基于接口),通常使用此类外观接口。我在实践中使用的其他一些示例:mock of System.IO.File
、DateTime.Now
、System.Timers.Timer
等
我有一个类似的案例:RecurringJob.RemoveIfExists
。我试试这个(我在 github 中看到了原始代码并设置了我的模拟):
private void SetupHangfire()
{
Mock<JobStorage> _jobStorageMock = new Mock<JobStorage>();
Mock<IStorageConnection> _storageConnectionMock = new Mock<IStorageConnection>();
Mock<IWriteOnlyTransaction> _transactionConnectionMock = new Mock<IWriteOnlyTransaction>();
JobStorage.Current = _jobStorageMock.Object;
_jobStorageMock
.Setup(y => y.GetConnection())
.Returns(_storageConnectionMock.Object);
_storageConnectionMock
.Setup(y => y.AcquireDistributedLock(It.IsAny<string>(), It.IsAny<TimeSpan>()))
.Returns(_transactionConnectionMock.Object);
_storageConnectionMock
.Setup(y => y.CreateWriteTransaction())
.Returns(_transactionConnectionMock.Object);
_transactionConnectionMock
.Setup(y => y.RemoveHash(It.IsAny<string>()));
_transactionConnectionMock
.Setup(y => y.RemoveFromSet(It.IsAny<string>(), It.IsAny<string>()));
_transactionConnectionMock
.Setup(y => y.Commit());
}
考虑以下控制器:
public class SubmissionController : Controller
{
public SubmissionController()
{ }
public IActionResult Post()
{
RecurringJob.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);
return Ok("Periodic submission triggered");
}
}
Hangfire 是否为 RecurringJob class 提供抽象注入依赖项?我做了一些研究,唯一可用的抽象是 IBackgroundJobClient
,它没有安排重复作业的选项。
我需要验证作业是否已添加到单元测试中。
如果你检查 RecurringJob
class 的 source code,你会看到它的静态方法导致调用 RecurringJobManager
class:
public static class RecurringJob
{
private static readonly Lazy<RecurringJobManager> Instance = new Lazy<RecurringJobManager>(
() => new RecurringJobManager());
// ...
public static void AddOrUpdate(
Expression<Action> methodCall,
string cronExpression,
TimeZoneInfo timeZone = null,
string queue = EnqueuedState.DefaultQueue)
{
var job = Job.FromExpression(methodCall);
var id = GetRecurringJobId(job);
Instance.Value.AddOrUpdate(id, job, cronExpression, timeZone ?? TimeZoneInfo.Utc, queue);
}
// ...
}
RecurringJobManager
实现了 IRecurringJobManager
接口,你可以在 UT 中使用它进行依赖注入和模拟。
但是 RecurringJob
具有从 lambda 获取作业并构建作业 ID 的内部逻辑:
var job = Job.FromExpression(methodCall);
var id = GetRecurringJobId(job);
Job.FromExpression()
是一个可以放心使用的public 方法。但是 GetRecurringJobId
是一个私有方法,定义如下:
private static string GetRecurringJobId(Job job)
{
return $"{job.Type.ToGenericTypeString()}.{job.Method.Name}";
}
GetRecurringJobId
基本上 returns 作业方法的名称,形式为 SubmissionController.InitiateSubmission
。它基于内部 class TypeExtensions
和 Type
的扩展方法。你不能直接使用这个 class 因为它是内部的,所以你应该复制那个逻辑。
如果您采用这种方法,您的最终解决方案将是:
TypeExtensions(从 Hangfire sources 复制):
static class TypeExtensions
{
public static string ToGenericTypeString(this Type type)
{
if (!type.GetTypeInfo().IsGenericType)
{
return type.GetFullNameWithoutNamespace()
.ReplacePlusWithDotInNestedTypeName();
}
return type.GetGenericTypeDefinition()
.GetFullNameWithoutNamespace()
.ReplacePlusWithDotInNestedTypeName()
.ReplaceGenericParametersInGenericTypeName(type);
}
private static string GetFullNameWithoutNamespace(this Type type)
{
if (type.IsGenericParameter)
{
return type.Name;
}
const int dotLength = 1;
// ReSharper disable once PossibleNullReferenceException
return !String.IsNullOrEmpty(type.Namespace)
? type.FullName.Substring(type.Namespace.Length + dotLength)
: type.FullName;
}
private static string ReplacePlusWithDotInNestedTypeName(this string typeName)
{
return typeName.Replace('+', '.');
}
private static string ReplaceGenericParametersInGenericTypeName(this string typeName, Type type)
{
var genericArguments = type.GetTypeInfo().GetAllGenericArguments();
const string regexForGenericArguments = @"`[1-9]\d*";
var rgx = new Regex(regexForGenericArguments);
typeName = rgx.Replace(typeName, match =>
{
var currentGenericArgumentNumbers = int.Parse(match.Value.Substring(1));
var currentArguments = string.Join(",", genericArguments.Take(currentGenericArgumentNumbers).Select(ToGenericTypeString));
genericArguments = genericArguments.Skip(currentGenericArgumentNumbers).ToArray();
return string.Concat("<", currentArguments, ">");
});
return typeName;
}
public static Type[] GetAllGenericArguments(this TypeInfo type)
{
return type.GenericTypeArguments.Length > 0 ? type.GenericTypeArguments : type.GenericTypeParameters;
}
}
RecurringJobManagerExtensions:
public static class RecurringJobManagerExtensions
{
public static void AddOrUpdate(this IRecurringJobManager manager, Expression<Action> methodCall, Func<string> cronExpression, TimeZoneInfo timeZone = null, string queue = EnqueuedState.DefaultQueue)
{
var job = Job.FromExpression(methodCall);
var id = $"{job.Type.ToGenericTypeString()}.{job.Method.Name}";
manager.AddOrUpdate(id, job, cronExpression(), timeZone ?? TimeZoneInfo.Utc, queue);
}
}
注入控制器 IRecurringJobManager
:
public class SubmissionController : Controller
{
private readonly IRecurringJobManager recurringJobManager;
public SubmissionController(IRecurringJobManager recurringJobManager)
{
this.recurringJobManager = recurringJobManager;
}
public IActionResult Post()
{
recurringJobManager.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);
return Ok("Periodic submission triggered");
}
public void InitiateSubmission()
{
// ...
}
}
好吧,这种方法行得通,但我不喜欢它。它基于一些将来可能会更改的内部 Hangfire 内容。
这就是为什么我建议使用另一种方法。您可以添加新的外观接口(例如 IRecurringJobFacade
),它将模仿您将要使用的 RecurringJob
中的方法。该接口的实现只会调用相应的 RecurringJob
方法。然后你将这个 IRecurringJobFacade
注入到控制器中,并且可以很容易地在 UT 中模拟它。这是一个示例:
IRecurringJobFacade:
public interface IRecurringJobFacade
{
void AddOrUpdate(Expression<Action> methodCall, Func<string> cronExpression);
// Mimic other methods from RecurringJob that you are going to use.
// ...
}
RecurringJobFacade:
public class RecurringJobFacade : IRecurringJobFacade
{
public void AddOrUpdate(Expression<Action> methodCall, Func<string> cronExpression)
{
RecurringJob.AddOrUpdate(methodCall, cronExpression);
}
}
注入控制器 IRecurringJobFacade
:
public class SubmissionController : Controller
{
private readonly IRecurringJobFacade recurringJobFacade;
public SubmissionController(IRecurringJobFacade recurringJobFacade)
{
this.recurringJobFacade = recurringJobFacade;
}
public IActionResult Post()
{
recurringJobFacade.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);
return Ok("Periodic submission triggered");
}
public void InitiateSubmission()
{
// ...
}
}
如您所见,这种方法更简单,最重要的是它更可靠,因为它不深入研究 Hangfire 内部结构,只是像往常一样调用 RecurringJob
方法。
当无法直接模拟代码时(静态方法或 classes 不基于接口),通常使用此类外观接口。我在实践中使用的其他一些示例:mock of System.IO.File
、DateTime.Now
、System.Timers.Timer
等
我有一个类似的案例:RecurringJob.RemoveIfExists
。我试试这个(我在 github 中看到了原始代码并设置了我的模拟):
private void SetupHangfire()
{
Mock<JobStorage> _jobStorageMock = new Mock<JobStorage>();
Mock<IStorageConnection> _storageConnectionMock = new Mock<IStorageConnection>();
Mock<IWriteOnlyTransaction> _transactionConnectionMock = new Mock<IWriteOnlyTransaction>();
JobStorage.Current = _jobStorageMock.Object;
_jobStorageMock
.Setup(y => y.GetConnection())
.Returns(_storageConnectionMock.Object);
_storageConnectionMock
.Setup(y => y.AcquireDistributedLock(It.IsAny<string>(), It.IsAny<TimeSpan>()))
.Returns(_transactionConnectionMock.Object);
_storageConnectionMock
.Setup(y => y.CreateWriteTransaction())
.Returns(_transactionConnectionMock.Object);
_transactionConnectionMock
.Setup(y => y.RemoveHash(It.IsAny<string>()));
_transactionConnectionMock
.Setup(y => y.RemoveFromSet(It.IsAny<string>(), It.IsAny<string>()));
_transactionConnectionMock
.Setup(y => y.Commit());
}