diff --git a/src/Microsoft.AspNet.Mvc.Core/ExpiringFileInfoCache.cs b/src/Microsoft.AspNet.Mvc.Core/ExpiringFileInfoCache.cs new file mode 100644 index 0000000000..00b1cf382d --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ExpiringFileInfoCache.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using Microsoft.AspNet.FileSystems; +using Microsoft.Framework.OptionsModel; +using Microsoft.Framework.Runtime; + +namespace Microsoft.AspNet.Mvc.Core +{ + /// + /// A default implementation for the + /// + public class ExpiringFileInfoCache : IFileInfoCache + { + private readonly ConcurrentDictionary _fileInfoCache = + new ConcurrentDictionary(StringComparer.Ordinal); + + private readonly PhysicalFileSystem _fileSystem; + private readonly TimeSpan _offset; + + protected virtual IFileSystem FileSystem + { + get + { + return _fileSystem; + } + } + + protected virtual DateTime UtcNow + { + get + { + return DateTime.UtcNow; + } + } + + public ExpiringFileInfoCache(IApplicationEnvironment env, + IOptionsAccessor optionsAccessor) + { + // TODO: Inject the IFileSystem but only when we get it from the host + _fileSystem = new PhysicalFileSystem(env.ApplicationBasePath); + _offset = optionsAccessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk; + } + + /// + public IFileInfo GetFileInfo([NotNull] string virtualPath) + { + IFileInfo fileInfo; + ExpiringFileInfo expiringFileInfo; + + var utcNow = UtcNow; + + if (_fileInfoCache.TryGetValue(virtualPath, out expiringFileInfo) + && expiringFileInfo.ValidUntil > utcNow) + { + fileInfo = expiringFileInfo.FileInfo; + } + else + { + FileSystem.TryGetFileInfo(virtualPath, out fileInfo); + + expiringFileInfo = new ExpiringFileInfo() + { + FileInfo = fileInfo, + ValidUntil = _offset == TimeSpan.MaxValue ? DateTime.MaxValue : utcNow.Add(_offset), + }; + + _fileInfoCache.AddOrUpdate(virtualPath, expiringFileInfo, (a, b) => expiringFileInfo); + } + + return fileInfo; + } + + private class ExpiringFileInfo + { + public IFileInfo FileInfo { get; set; } + public DateTime ValidUntil { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/IExpiringFileInfoCache.cs b/src/Microsoft.AspNet.Mvc.Core/IExpiringFileInfoCache.cs new file mode 100644 index 0000000000..c60e4d74ab --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/IExpiringFileInfoCache.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.FileSystems; + +namespace Microsoft.AspNet.Mvc.Core +{ + /// + /// Provides cached access to file infos. + /// + public interface IFileInfoCache + { + /// + /// Returns a cached for a given path. + /// + /// The virtual path. + /// + IFileInfo GetFileInfo(string virtualPath); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj b/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj index 93132102fa..65a6bf1e91 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj +++ b/src/Microsoft.AspNet.Mvc.Core/Microsoft.AspNet.Mvc.Core.kproj @@ -27,7 +27,9 @@ + + @@ -207,6 +209,7 @@ + diff --git a/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs b/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs index 4795eb9074..b9ca779677 100644 --- a/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs +++ b/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs @@ -10,9 +10,13 @@ using Microsoft.AspNet.Mvc.Rendering; namespace Microsoft.AspNet.Mvc { + /// + /// Provides programmatic configuration for the MVC framework. + /// public class MvcOptions { private AntiForgeryOptions _antiForgeryOptions = new AntiForgeryOptions(); + private RazorViewEngineOptions _viewEngineOptions = new RazorViewEngineOptions(); public MvcOptions() { @@ -21,6 +25,9 @@ namespace Microsoft.AspNet.Mvc ViewEngines = new List(); } + /// + /// Provides programmatic configuration for the anti-forgery token system. + /// public AntiForgeryOptions AntiForgeryOptions { get @@ -41,6 +48,32 @@ namespace Microsoft.AspNet.Mvc } } + /// + /// Provides programmatic configuration for the default . + /// + public RazorViewEngineOptions ViewEngineOptions + { + get + { + return _viewEngineOptions; + } + + set + { + if (value == null) + { + throw new ArgumentNullException("value", + Resources.FormatPropertyOfTypeCannotBeNull("ViewEngineOptions", + typeof(MvcOptions))); + } + + _viewEngineOptions = value; + } + } + + /// + /// Get a list of the used by the . + /// public List ModelBinders { get; private set; } /// diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/RazorViewEngineOptions.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/RazorViewEngineOptions.cs new file mode 100644 index 0000000000..b4dc8ae008 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/Rendering/RazorViewEngineOptions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Mvc.Core +{ + /// + /// Provides programmatic configuration for the default . + /// + public class RazorViewEngineOptions + { + private TimeSpan _expirationBeforeCheckingFilesOnDisk = TimeSpan.FromSeconds(2); + + /// + /// Controls the caching behavior. + /// + /// + /// of or less, means no caching. + /// of means indefinite caching. + /// + public TimeSpan ExpirationBeforeCheckingFilesOnDisk + { + get + { + return _expirationBeforeCheckingFilesOnDisk; + } + + set + { + if (value.TotalMilliseconds < 0) + { + _expirationBeforeCheckingFilesOnDisk = TimeSpan.Zero; + } + else + { + _expirationBeforeCheckingFilesOnDisk = value; + } + } + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.Core/project.json b/src/Microsoft.AspNet.Mvc.Core/project.json index 522996240a..1769c87200 100644 --- a/src/Microsoft.AspNet.Mvc.Core/project.json +++ b/src/Microsoft.AspNet.Mvc.Core/project.json @@ -4,6 +4,7 @@ "warningsAsErrors": true }, "dependencies": { + "Microsoft.AspNet.FileSystems": "1.0.0-*", "Microsoft.AspNet.Http": "1.0.0-*", "Microsoft.AspNet.Mvc.Common": "", "Microsoft.AspNet.Mvc.ModelBinding": "", diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorCompilationService.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorCompilationService.cs index 461ba34304..85231041c1 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorCompilationService.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorCompilationService.cs @@ -13,7 +13,8 @@ namespace Microsoft.AspNet.Mvc.Razor { public class RazorCompilationService : IRazorCompilationService { - private static readonly CompilerCache _cache = new CompilerCache(); + // This class must be registered as a singleton service for the caching to work. + private readonly CompilerCache _cache = new CompilerCache(); private readonly IApplicationEnvironment _environment; private readonly ICompilationService _baseCompilationService; private readonly IMvcRazorHost _razorHost; diff --git a/src/Microsoft.AspNet.Mvc.Razor/ViewEngine/VirtualPathViewFactory.cs b/src/Microsoft.AspNet.Mvc.Razor/ViewEngine/VirtualPathViewFactory.cs index 68869beb93..3a8884a404 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/ViewEngine/VirtualPathViewFactory.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/ViewEngine/VirtualPathViewFactory.cs @@ -2,36 +2,35 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using Microsoft.AspNet.FileSystems; +using Microsoft.AspNet.Mvc.Core; using Microsoft.AspNet.Mvc.Rendering; using Microsoft.Framework.DependencyInjection; -using Microsoft.Framework.Runtime; namespace Microsoft.AspNet.Mvc.Razor { public class VirtualPathViewFactory : IVirtualPathViewFactory { - private readonly PhysicalFileSystem _fileSystem; private readonly IRazorCompilationService _compilationService; private readonly ITypeActivator _activator; private readonly IServiceProvider _serviceProvider; + private readonly IFileInfoCache _fileInfoCache; - public VirtualPathViewFactory(IApplicationEnvironment env, - IRazorCompilationService compilationService, + public VirtualPathViewFactory(IRazorCompilationService compilationService, ITypeActivator typeActivator, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + IFileInfoCache fileInfoCache) { - // TODO: Continue to inject the IFileSystem but only when we get it from the host - _fileSystem = new PhysicalFileSystem(env.ApplicationBasePath); _compilationService = compilationService; _activator = typeActivator; _serviceProvider = serviceProvider; + _fileInfoCache = fileInfoCache; } public IView CreateInstance([NotNull] string virtualPath) { - IFileInfo fileInfo; - if (_fileSystem.TryGetFileInfo(virtualPath, out fileInfo)) + var fileInfo = _fileInfoCache.GetFileInfo(virtualPath); + + if (fileInfo != null) { var result = _compilationService.Compile(fileInfo); return (IView)_activator.CreateInstance(_serviceProvider, result.CompiledType); diff --git a/src/Microsoft.AspNet.Mvc/MvcServices.cs b/src/Microsoft.AspNet.Mvc/MvcServices.cs index 966a937df1..5e703bdf40 100644 --- a/src/Microsoft.AspNet.Mvc/MvcServices.cs +++ b/src/Microsoft.AspNet.Mvc/MvcServices.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; +using Microsoft.AspNet.Mvc.Core; using Microsoft.AspNet.Mvc.Filters; using Microsoft.AspNet.Mvc.ModelBinding; using Microsoft.AspNet.Mvc.Razor; @@ -41,9 +42,12 @@ namespace Microsoft.AspNet.Mvc yield return describe.Singleton(); yield return describe.Scoped(); - yield return describe.Transient(); - yield return describe.Transient(); + yield return describe.Singleton(); + yield return describe.Singleton(); + // Virtual path view factory needs to stay scoped so views can get get scoped services. + yield return describe.Scoped(); + yield return describe.Singleton(); yield return describe.Transient, ReflectedActionDescriptorProvider>(); diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ExpiringFileInfoCacheTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ExpiringFileInfoCacheTest.cs new file mode 100644 index 0000000000..6e65601252 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ExpiringFileInfoCacheTest.cs @@ -0,0 +1,429 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using Microsoft.AspNet.FileSystems; +using Microsoft.Framework.OptionsModel; +using Microsoft.Framework.Runtime; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Core.Test +{ + public class ExpiringFileInfoCacheTest + { + private const string FileName = "myView.cshtml"; + + public IApplicationEnvironment ApplicationEnvironment + { + get + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(ae => ae.ApplicationBasePath).Returns(Directory.GetCurrentDirectory()); + + return mock.Object; + } + } + + public MvcOptions Options + { + get + { + return new MvcOptions(); + } + } + + public IOptionsAccessor OptionsAccessor + { + get + { + var options = Options; + + var mock = new Mock>(MockBehavior.Strict); + mock.Setup(oa => oa.Options).Returns(options); + + return mock.Object; + } + } + + public ControllableExpiringFileInfoCache GetCache(IOptionsAccessor optionsAccessor) + { + return new ControllableExpiringFileInfoCache(ApplicationEnvironment, optionsAccessor); + } + + public void CreateFile(string FileName, ControllableExpiringFileInfoCache cache) + { + var fileInfo = new DummyFileInfo() + { + Name = FileName, + LastModified = DateTime.Now, + }; + + cache.UnderlyingFileSystem.AddFile(fileInfo); + } + + public void Sleep(ControllableExpiringFileInfoCache cache, int offsetMilliseconds) + { + cache.Sleep(offsetMilliseconds); + } + + public void Sleep(IOptionsAccessor accessor, ControllableExpiringFileInfoCache cache, int offsetMilliSeconds) + { + var baseMilliSeconds = (int)accessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds; + + cache.Sleep(baseMilliSeconds + offsetMilliSeconds); + } + + public void SetExpiration(IOptionsAccessor accessor, TimeSpan expiration) + { + accessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk = expiration; + } + + [Fact] + public void VerifyDefaultOptionsAreSetupCorrectly() + { + var optionsAccessor = OptionsAccessor; + + // Assert + Assert.Equal(2000, optionsAccessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds); + } + + [Fact] + public void GettingFileInfoReturnsTheSameDataWithDefaultOptions() + { + // Arrange + var cache = GetCache(OptionsAccessor); + + CreateFile(FileName, cache); + + // Act + var fileInfo1 = cache.GetFileInfo(FileName); + var fileInfo2 = cache.GetFileInfo(FileName); + + // Assert + Assert.Same(fileInfo1, fileInfo2); + + Assert.Equal(FileName, fileInfo1.Name); + } + + [Fact] + public void GettingFileInfoReturnsTheSameDataWithDefaultOptionsEvenWhenFilesHaveChanged() + { + // Arrange + var cache = GetCache(OptionsAccessor); + + CreateFile(FileName, cache); + + // Act + var fileInfo1 = cache.GetFileInfo(FileName); + + CreateFile(FileName, cache); + + var fileInfo2 = cache.GetFileInfo(FileName); + + // Assert + Assert.Same(fileInfo1, fileInfo2); + + Assert.Equal(fileInfo1.LastModified, fileInfo2.LastModified); + Assert.Equal(FileName, fileInfo1.Name); + Assert.Equal(FileName, fileInfo2.Name); + } + + [Fact] + public void GettingFileInfoReturnsNewDataWithDefaultOptionsAfterExpirationAndFileChange() + { + var optionsAccessor = OptionsAccessor; + + // Arrange + var cache = GetCache(optionsAccessor); + + CreateFile(FileName, cache); + + // Act + var fileInfo1 = cache.GetFileInfo(FileName); + + Sleep(optionsAccessor, cache, 500); + CreateFile(FileName, cache); + + var fileInfo2 = cache.GetFileInfo(FileName); + + // Assert + Assert.NotSame(fileInfo1, fileInfo2); + + Assert.Equal(FileName, fileInfo1.Name); + Assert.Equal(FileName, fileInfo2.Name); + } + + [Fact] + public void GettingFileInfoReturnsNewDataWithDefaultOptionsAfterExpiration() + { + // Arrange + var optionsAccessor = OptionsAccessor; + + var cache = GetCache(optionsAccessor); + + CreateFile(FileName, cache); + + // Act + var fileInfo1 = cache.GetFileInfo(FileName); + + Sleep(optionsAccessor, cache, 500); + + var fileInfo2 = cache.GetFileInfo(FileName); + + // Assert + Assert.NotSame(fileInfo1, fileInfo2); + + Assert.Equal(fileInfo1.LastModified, fileInfo2.LastModified); + Assert.Equal(FileName, fileInfo1.Name); + Assert.Equal(FileName, fileInfo2.Name); + } + + public static IEnumerable ImmediateExpirationTimespans + { + get + { + yield return new object[] + { + TimeSpan.FromSeconds(0.0) + }; + + yield return new object[] + { + TimeSpan.FromSeconds(-1.0) + }; + + yield return new object[] + { + TimeSpan.MinValue + }; + } + } + + [Theory] + [MemberData("ImmediateExpirationTimespans")] + public void GettingFileInfoReturnsNewDataWithCustomImmediateExpiration(TimeSpan expiration) + { + // Arrange + var optionsAccessor = OptionsAccessor; + SetExpiration(optionsAccessor, expiration); + + string FileName = "myfile4.cshtml"; + var cache = GetCache(optionsAccessor); + + CreateFile(FileName, cache); + + // Act + var fileInfo1 = cache.GetFileInfo(FileName); + var fileInfo2 = cache.GetFileInfo(FileName); + + // Assert + Assert.NotSame(fileInfo1, fileInfo2); + Assert.Equal(fileInfo1.LastModified, fileInfo2.LastModified); + + Assert.Equal(FileName, fileInfo1.Name); + Assert.Equal(FileName, fileInfo2.Name); + } + + public static IEnumerable CustomExpirationTimespans + { + get + { + yield return new object[] + { + TimeSpan.FromSeconds(1.0) + }; + + yield return new object[] + { + TimeSpan.FromSeconds(3.0) + }; + } + } + + [Theory] + [MemberData("CustomExpirationTimespans")] + public void GettingFileInfoReturnsNewDataWithCustomExpiration(TimeSpan expiration) + { + // Arrange + var optionsAccessor = OptionsAccessor; + SetExpiration(optionsAccessor, expiration); + + string FileName = "myfile5.cshtml"; + var cache = GetCache(optionsAccessor); + + CreateFile(FileName, cache); + + // Act + var fileInfo1 = cache.GetFileInfo(FileName); + + Sleep(optionsAccessor, cache, 500); + + var fileInfo2 = cache.GetFileInfo(FileName); + + // Assert + Assert.NotSame(fileInfo1, fileInfo2); + + Assert.Equal(FileName, fileInfo1.Name); + } + + [Theory] + [MemberData("CustomExpirationTimespans")] + public void GettingFileInfoReturnsSameDataWithCustomExpiration(TimeSpan expiration) + { + // Arrange + var optionsAccessor = OptionsAccessor; + SetExpiration(optionsAccessor, expiration); + + string FileName = "myfile6.cshtml"; + var cache = GetCache(optionsAccessor); + + CreateFile(FileName, cache); + + // Act + var fileInfo1 = cache.GetFileInfo(FileName); + + Sleep(optionsAccessor, cache, -500); + + var fileInfo2 = cache.GetFileInfo(FileName); + + // Assert + Assert.Same(fileInfo1, fileInfo2); + + Assert.Equal(FileName, fileInfo1.Name); + } + + [Fact] + public void GettingFileInfoReturnsSameDataWithMaxExpiration() + { + // Arrange + var optionsAccessor = OptionsAccessor; + SetExpiration(optionsAccessor, TimeSpan.MaxValue); + + string FileName = "myfile7.cshtml"; + var cache = GetCache(optionsAccessor); + + CreateFile(FileName, cache); + + // Act + var fileInfo1 = cache.GetFileInfo(FileName); + + Sleep(cache, 2500); + + var fileInfo2 = cache.GetFileInfo(FileName); + + // Assert + Assert.Same(fileInfo1, fileInfo2); + + Assert.Equal(FileName, fileInfo1.Name); + } + + public class ControllableExpiringFileInfoCache : ExpiringFileInfoCache + { + public ControllableExpiringFileInfoCache(IApplicationEnvironment env, + IOptionsAccessor optionsAccessor) + : base(env, optionsAccessor) + { + } + + private DateTime? _internalUtcNow { get; set; } + private DummyFileSystem _underlyingFileSystem = new DummyFileSystem(); + + protected override DateTime UtcNow + { + get + { + if (_internalUtcNow == null) + { + _internalUtcNow = base.UtcNow; + } + + return _internalUtcNow.Value.AddTicks(1); + } + } + + protected override IFileSystem FileSystem + { + get + { + return UnderlyingFileSystem; + } + } + + public void Sleep(int milliSeconds) + { + if (milliSeconds <= 0) + { + throw new InvalidOperationException(); + } + + _internalUtcNow = UtcNow.AddMilliseconds(milliSeconds); + } + + public DummyFileSystem UnderlyingFileSystem + { + get + { + return _underlyingFileSystem; + } + } + } + + public class DummyFileSystem : IFileSystem + { + private Dictionary _fileInfos = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public void AddFile(IFileInfo fileInfo) + { + if (_fileInfos.ContainsKey(fileInfo.Name)) + { + _fileInfos[fileInfo.Name] = fileInfo; + } + else + { + _fileInfos.Add(fileInfo.Name, fileInfo); + } + } + + public bool TryGetDirectoryContents(string subpath, out IEnumerable contents) + { + throw new NotImplementedException(); + } + + public bool TryGetFileInfo(string subpath, out IFileInfo fileInfo) + { + IFileInfo knownInfo; + if (_fileInfos.TryGetValue(subpath, out knownInfo)) + { + fileInfo = new DummyFileInfo() + { + Name = knownInfo.Name, + LastModified = knownInfo.LastModified, + }; + + return true; + } + else + { + fileInfo = null; + return false; + } + } + } + + public class DummyFileInfo : IFileInfo + { + public DateTime LastModified { get; set; } + public string Name { get; set; } + + public long Length { get { throw new NotImplementedException(); } } + public bool IsDirectory { get { throw new NotImplementedException(); } } + public string PhysicalPath { get { throw new NotImplementedException(); } } + public Stream CreateReadStream() { throw new NotImplementedException(); } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj index 7d33a4dabf..299a260a5c 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Microsoft.AspNet.Mvc.Core.Test.kproj @@ -30,6 +30,7 @@ +