From 6600e68fc0d64e382f9172bfc4da1cbcb12bee10 Mon Sep 17 00:00:00 2001 From: YishaiGalatzer Date: Fri, 19 Sep 2014 11:25:48 -0700 Subject: [PATCH] Create a pre compilation module and apis to allow meta programming to precompile razor pages. This is limited to sites where the .cshtml are still deployed. It's current purpose is to speed up startup. Deploying without the razor files is a separate feature. --- .../IMvcRazorHost.cs | 10 ++ .../MvcRazorHost.cs | 10 +- .../Compilation/CompilationResult.cs | 2 +- .../Compilation/CompilerCache.cs | 104 ++++++++++-- .../Compilation/CompilerCacheEntry.cs | 39 +++++ .../Compilation/RoslynCompilationService.cs | 17 +- .../Compilation/SyntaxTreeGenerator.cs | 52 ++++++ .../Compilation/UncachedCompilationResult.cs | 2 + .../IRazorPageFactory.cs | 4 +- .../Razor/IRazorCompilationService.cs | 4 +- .../GeneratorResultExtensions.cs | 35 ++++ .../Razor/PreCompileViews/RazorFileInfo.cs | 35 ++++ .../RazorFileInfoCollection.cs | 12 ++ .../RazorFileInfoCollectionGenerator.cs | 117 +++++++++++++ .../Razor/PreCompileViews/RazorPreCompiler.cs | 158 ++++++++++++++++++ .../Razor/PreCompileViews/RelativeFileInfo.cs | 13 ++ .../Razor/RazorCompilationService.cs | 40 ++--- .../Razor/RazorFileHash.cs | 37 ++++ .../VirtualPathRazorPageFactory.cs | 15 +- src/Microsoft.AspNet.Mvc.Razor/project.json | 1 + .../RazorPreCompileModule.cs | 57 +++++++ .../Filters/DefaultFilterProviderTest.cs | 2 - .../Compilation/CompilerCacheTest.cs | 141 +++++++++++++++- .../RazorCompilationServiceTest.cs | 21 ++- 24 files changed, 853 insertions(+), 75 deletions(-) create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Compilation/SyntaxTreeGenerator.cs create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/GeneratorResultExtensions.cs create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfo.cs create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollection.cs create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RelativeFileInfo.cs create mode 100644 src/Microsoft.AspNet.Mvc.Razor/Razor/RazorFileHash.cs create mode 100644 src/Microsoft.AspNet.Mvc/RazorPreCompileModule.cs diff --git a/src/Microsoft.AspNet.Mvc.Razor.Host/IMvcRazorHost.cs b/src/Microsoft.AspNet.Mvc.Razor.Host/IMvcRazorHost.cs index 023479e13c..1011b242dc 100644 --- a/src/Microsoft.AspNet.Mvc.Razor.Host/IMvcRazorHost.cs +++ b/src/Microsoft.AspNet.Mvc.Razor.Host/IMvcRazorHost.cs @@ -9,5 +9,15 @@ namespace Microsoft.AspNet.Mvc.Razor public interface IMvcRazorHost { GeneratorResults GenerateCode(string rootRelativePath, Stream inputStream); + + /// + /// Represent the prefix off the main entry class in the view. + /// + string MainClassNamePrefix { get; } + + /// + /// Represent the namespace the main entry class in the view. + /// + string DefaultNamespace { get; } } } diff --git a/src/Microsoft.AspNet.Mvc.Razor.Host/MvcRazorHost.cs b/src/Microsoft.AspNet.Mvc.Razor.Host/MvcRazorHost.cs index b1a4f7476a..4f7cc4ec85 100644 --- a/src/Microsoft.AspNet.Mvc.Razor.Host/MvcRazorHost.cs +++ b/src/Microsoft.AspNet.Mvc.Razor.Host/MvcRazorHost.cs @@ -58,7 +58,6 @@ namespace Microsoft.AspNet.Mvc.Razor public MvcRazorHost(string root) : this(new PhysicalFileSystem(root)) { - } #endif @@ -101,6 +100,12 @@ namespace Microsoft.AspNet.Mvc.Razor get { return "dynamic"; } } + /// + public string MainClassNamePrefix + { + get { return "ASPV_"; } + } + /// /// Gets the list of chunks that are injected by default by this host. /// @@ -121,7 +126,8 @@ namespace Microsoft.AspNet.Mvc.Razor /// public GeneratorResults GenerateCode(string rootRelativePath, Stream inputStream) { - var className = ParserHelpers.SanitizeClassName(rootRelativePath); + // Adding a prefix so that the main view class can be easily identified. + var className = MainClassNamePrefix + ParserHelpers.SanitizeClassName(rootRelativePath); using (var reader = new StreamReader(inputStream)) { var engine = new RazorTemplateEngine(this); diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilationResult.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilationResult.cs index 04e628161f..6c6daba5e8 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilationResult.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilationResult.cs @@ -120,7 +120,7 @@ namespace Microsoft.AspNet.Mvc.Razor } } } - catch (IOException) + catch (Exception) { // Don't throw if reading the file fails. return string.Empty; diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs index 619b815235..70bc97e407 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs @@ -3,34 +3,110 @@ using System; using System.Collections.Concurrent; -using Microsoft.AspNet.FileSystems; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; namespace Microsoft.AspNet.Mvc.Razor { public class CompilerCache { - private readonly ConcurrentDictionary _cache; + private readonly ConcurrentDictionary _cache; + private static readonly Type[] EmptyType = new Type[0]; - public CompilerCache() + public CompilerCache([NotNull] IEnumerable assemblies) + : this(GetFileInfos(assemblies)) { - _cache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); } - public CompilationResult GetOrAdd(IFileInfo file, Func compile) + internal CompilerCache(IEnumerable viewCollections) : this() { - // Generate a content id - var contentId = file.PhysicalPath + '|' + file.LastModified.Ticks; - - Type compiledType; - if (!_cache.TryGetValue(contentId, out compiledType)) + foreach (var viewCollection in viewCollections) { - var result = compile(); - _cache.TryAdd(contentId, result.CompiledType); + foreach (var fileInfo in viewCollection.FileInfos) + { + var containingAssembly = viewCollection.GetType().GetTypeInfo().Assembly; + var viewType = containingAssembly.GetType(fileInfo.FullTypeName); + var cacheEntry = new CompilerCacheEntry(fileInfo, viewType); - return result; + // There shouldn't be any duplicates and if there are any the first will win. + // If the result doesn't match the one on disk its going to recompile anyways. + _cache.TryAdd(fileInfo.RelativePath, cacheEntry); + } + } + } + + internal CompilerCache() + { + _cache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + + internal static IEnumerable + GetFileInfos(IEnumerable assemblies) + { + return assemblies.SelectMany(a => a.ExportedTypes) + .Where(Match) + .Select(c => (RazorFileInfoCollection)Activator.CreateInstance(c)); + } + + private static bool Match(Type t) + { + var inAssemblyType = typeof(RazorFileInfoCollection); + if (inAssemblyType.IsAssignableFrom(t)) + { + var hasParameterlessConstructor = t.GetConstructor(EmptyType) != null; + + return hasParameterlessConstructor + && !t.GetTypeInfo().IsAbstract + && !t.GetTypeInfo().ContainsGenericParameters; } - return CompilationResult.Successful(compiledType); + return false; + } + + public CompilationResult GetOrAdd(RelativeFileInfo fileInfo, Func compile) + { + if (!_cache.TryGetValue(fileInfo.RelativePath, out var cacheEntry)) + { + return OnCacheMiss(fileInfo, compile); + } + else + { + if (cacheEntry.Length != fileInfo.FileInfo.Length) + { + // it's not a match, recompile + return OnCacheMiss(fileInfo, compile); + } + + if (cacheEntry.LastModified == fileInfo.FileInfo.LastModified) + { + // Match, not update needed + return CompilationResult.Successful(cacheEntry.ViewType); + } + + var hash = RazorFileHash.GetHash(fileInfo.FileInfo); + + // Timestamp doesn't match but it might be because of deployment, compare the hash. + if (cacheEntry.IsPreCompiled && + string.Equals(cacheEntry.Hash, hash, StringComparison.Ordinal)) + { + // Cache hit, but we need to update the entry + return OnCacheMiss(fileInfo, () => CompilationResult.Successful(cacheEntry.ViewType)); + } + + // it's not a match, recompile + return OnCacheMiss(fileInfo, compile); + } + } + + private CompilationResult OnCacheMiss(RelativeFileInfo file, Func compile) + { + var result = compile(); + + var cacheEntry = new CompilerCacheEntry(file, result.CompiledType); + _cache.AddOrUpdate(file.RelativePath, cacheEntry, (a, b) => cacheEntry); + + return result; } } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs new file mode 100644 index 0000000000..107ddf757b --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs @@ -0,0 +1,39 @@ +// 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.Razor +{ + public class CompilerCacheEntry + { + public CompilerCacheEntry([NotNull] RazorFileInfo info, [NotNull] Type viewType) + { + ViewType = viewType; + RelativePath = info.RelativePath; + Length = info.Length; + LastModified = info.LastModified; + Hash = info.Hash; + } + + public CompilerCacheEntry([NotNull] RelativeFileInfo info, [NotNull] Type viewType) + { + ViewType = viewType; + RelativePath = info.RelativePath; + Length = info.FileInfo.Length; + LastModified = info.FileInfo.LastModified; + } + + public Type ViewType { get; set; } + public string RelativePath { get; set; } + public long Length { get; set; } + public DateTime LastModified { get; set; } + + /// + /// The file hash, should only be available for pre compiled files. + /// + public string Hash { get; set; } + + public bool IsPreCompiled { get { return Hash != null; } } + } +} diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/RoslynCompilationService.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/RoslynCompilationService.cs index 0d1d1b8493..6f9abe6170 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/RoslynCompilationService.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/RoslynCompilationService.cs @@ -7,12 +7,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; -using System.Text; using Microsoft.AspNet.FileSystems; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; -using Microsoft.CodeAnalysis.Text; using Microsoft.Framework.Runtime; namespace Microsoft.AspNet.Mvc.Razor.Compilation @@ -31,27 +29,31 @@ namespace Microsoft.AspNet.Mvc.Razor.Compilation private readonly Lazy> _applicationReferences; + private readonly string _classPrefix; + /// /// Initalizes a new instance of the class. /// /// The environment for the executing application. /// The loader used to load compiled assemblies. /// The library manager that provides export and reference information. + /// The that was used to generate the code. public RoslynCompilationService(IApplicationEnvironment environment, IAssemblyLoaderEngine loaderEngine, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + IMvcRazorHost host) { _environment = environment; _loader = loaderEngine; _libraryManager = libraryManager; _applicationReferences = new Lazy>(GetApplicationReferences); + _classPrefix = host.MainClassNamePrefix; } /// public CompilationResult Compile(IFileInfo fileInfo, string compilationContent) { - var sourceText = SourceText.From(compilationContent, Encoding.UTF8); - var syntaxTrees = new[] { CSharpSyntaxTree.ParseText(sourceText, path: fileInfo.PhysicalPath) }; + var syntaxTrees = new[] { SyntaxTreeGenerator.Generate(compilationContent, fileInfo.PhysicalPath) }; var references = _applicationReferences.Value; @@ -103,9 +105,10 @@ namespace Microsoft.AspNet.Mvc.Razor.Compilation } var type = assembly.GetExportedTypes() - .First(); + .First(t => t.Name. + StartsWith(_classPrefix, StringComparison.Ordinal)); - return UncachedCompilationResult.Successful(type, compilationContent); + return UncachedCompilationResult.Successful(type); } } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/SyntaxTreeGenerator.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/SyntaxTreeGenerator.cs new file mode 100644 index 0000000000..0598a1a90e --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/SyntaxTreeGenerator.cs @@ -0,0 +1,52 @@ +// 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.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public static class SyntaxTreeGenerator + { + private static CSharpParseOptions DefaultOptions + { + get + { + return CSharpParseOptions.Default + .WithLanguageVersion(LanguageVersion.CSharp6); + } + } + + public static SyntaxTree Generate([NotNull] string text, [NotNull] string path) + { + return GenerateCore(text, path, DefaultOptions); + } + + public static SyntaxTree Generate([NotNull] string text, + [NotNull] string path, + [NotNull] CSharpParseOptions options) + { + return GenerateCore(text, path, options); + } + + public static SyntaxTree GenerateCore([NotNull] string text, + [NotNull] string path, + [NotNull] CSharpParseOptions options) + { + var sourceText = SourceText.From(text, Encoding.UTF8); + var syntaxTree = CSharpSyntaxTree.ParseText(sourceText, + path: path, + options: options); + + return syntaxTree; + } + + public static CSharpParseOptions GetParseOptions(CSharpCompilation compilation) + { + return CSharpParseOptions.Default + .WithLanguageVersion(compilation.LanguageVersion); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/UncachedCompilationResult.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/UncachedCompilationResult.cs index 7c4799b7ed..493643dca0 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/UncachedCompilationResult.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/UncachedCompilationResult.cs @@ -14,6 +14,8 @@ namespace Microsoft.AspNet.Mvc.Razor { } + public string RazorFileContent { get; private set; } + /// /// Creates a that represents a success in compilation. /// diff --git a/src/Microsoft.AspNet.Mvc.Razor/IRazorPageFactory.cs b/src/Microsoft.AspNet.Mvc.Razor/IRazorPageFactory.cs index af2276d16e..daaf28222e 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/IRazorPageFactory.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/IRazorPageFactory.cs @@ -11,8 +11,8 @@ namespace Microsoft.AspNet.Mvc.Razor /// /// Creates a for the specified path. /// - /// The path to locate the page. + /// The path to locate the page. /// The IRazorPage instance if it exists, null otherwise. - IRazorPage CreateInstance(string path); + IRazorPage CreateInstance(string relativePath); } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/IRazorCompilationService.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/IRazorCompilationService.cs index 6c6a94f88f..6cc16d396e 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/IRazorCompilationService.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/IRazorCompilationService.cs @@ -1,12 +1,10 @@ // 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.Razor { public interface IRazorCompilationService { - CompilationResult Compile(IFileInfo fileInfo); + CompilationResult Compile(RelativeFileInfo fileInfo); } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/GeneratorResultExtensions.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/GeneratorResultExtensions.cs new file mode 100644 index 0000000000..b083c32417 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/GeneratorResultExtensions.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using Microsoft.AspNet.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public static class GeneratorResultExtensions + { + public static string GetMainClassName([NotNull] this GeneratorResults results, + [NotNull] IMvcRazorHost host, + [NotNull] SyntaxTree syntaxTree) + { + // The mainClass name should return directly from the generator results. + var classes = syntaxTree.GetRoot().DescendantNodes().OfType(); + var mainClass = classes.FirstOrDefault(c => + c.Identifier.ValueText.StartsWith(host.MainClassNamePrefix, StringComparison.Ordinal)); + + if (mainClass != null) + { + var typeName = mainClass.Identifier.ValueText; + + if (!string.IsNullOrEmpty(host.DefaultNamespace)) + { + typeName = host.DefaultNamespace + "." + typeName; + } + + return typeName; + } + + return null; + } + } +} diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfo.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfo.cs new file mode 100644 index 0000000000..211970f045 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfo.cs @@ -0,0 +1,35 @@ +// 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.Razor +{ + public class RazorFileInfo + { + /// + /// Type name including namespace. + /// + public string FullTypeName { get; set; } + + /// + /// Last modified at compilation time. + /// + public DateTime LastModified { get; set; } + + /// + /// The length of the file in bytes. + /// + public long Length { get; set; } + + /// + /// Path to to the file relative to the application base. + /// + public string RelativePath { get; set; } + + /// + /// A hash of the file content. + /// + public string Hash { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollection.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollection.cs new file mode 100644 index 0000000000..412f628d71 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollection.cs @@ -0,0 +1,12 @@ +// 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.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public abstract class RazorFileInfoCollection + { + public IReadOnlyList FileInfos { get; protected set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs new file mode 100644 index 0000000000..f8adf5689f --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs @@ -0,0 +1,117 @@ +// 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.Collections.Generic; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public class RazorFileInfoCollectionGenerator + { + private string _fileFormat; + + protected IReadOnlyList FileInfos { get; private set; } + protected CSharpParseOptions Options { get; private set; } + + public RazorFileInfoCollectionGenerator([NotNull] IReadOnlyList fileInfos, + [NotNull] CSharpParseOptions options) + { + FileInfos = fileInfos; + Options = options; + } + + public virtual SyntaxTree GenerateCollection() + { + var builder = new StringBuilder(); + builder.Append(Top); + + foreach (var fileInfo in FileInfos) + { + var perFileEntry = GenerateFile(fileInfo); + builder.Append(perFileEntry); + } + + builder.Append(Bottom); + + // TODO: consider saving the file for debuggability + var sourceCode = builder.ToString(); + var syntaxTree = SyntaxTreeGenerator.Generate(sourceCode, + "__AUTO__GeneratedViewsCollection.cs", + Options); + + return syntaxTree; + } + + + protected virtual string GenerateFile([NotNull] RazorFileInfo fileInfo) + { + return string.Format(FileFormat, + fileInfo.LastModified.ToFileTimeUtc(), + fileInfo.Length, + fileInfo.RelativePath, + fileInfo.FullTypeName, + fileInfo.Hash); + } + + protected virtual string Top + { + get + { + return +@"using System; +using System.Collections.Generic; +using Microsoft.AspNet.Mvc.Razor; + +namespace __ASP_ASSEMBLY +{ + public class __PreGeneratedViewCollection : " + nameof(RazorFileInfoCollection) + @" + { + public __PreGeneratedViewCollection() + { + var fileInfos = new List<" + nameof(RazorFileInfo) + @">(); + " + nameof(RazorFileInfoCollection.FileInfos) + @" = fileInfos; + " + nameof(RazorFileInfo) + @" info; + +"; + } + } + + protected virtual string Bottom + { + get + { + return + @" } + } +} +"; + } + } + + protected virtual string FileFormat + { + get + { + if (_fileFormat == null) + { + _fileFormat = + " info = new " + + nameof(RazorFileInfo) + @" + {{ + " + nameof(RazorFileInfo.LastModified) + @" = DateTime.FromFileTimeUtc({0:D}), + " + nameof(RazorFileInfo.Length) + @" = {1:D}, + " + nameof(RazorFileInfo.RelativePath) + @" = @""{2}"", + " + nameof(RazorFileInfo.FullTypeName) + @" = @""{3}"", + " + nameof(RazorFileInfo.Hash) + @" = @""{4}"", + }}; + fileInfos.Add(info); +"; + } + + return _fileFormat; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs new file mode 100644 index 0000000000..1f890996c7 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs @@ -0,0 +1,158 @@ +// 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.IO; +using Microsoft.AspNet.FileSystems; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.Runtime; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public class RazorPreCompiler + { + private readonly IServiceProvider _serviceProvider; + private readonly IFileSystem _fileSystem; + private readonly IMvcRazorHost _host; + + protected virtual string FileExtension + { + get + { + return ".cshtml"; + } + } + + public RazorPreCompiler([NotNull] IServiceProvider designTimeServiceProvider) : + this(designTimeServiceProvider, designTimeServiceProvider.GetService()) + { + } + + public RazorPreCompiler([NotNull] IServiceProvider designTimeServiceProvider, + [NotNull] IMvcRazorHost host) + { + _serviceProvider = designTimeServiceProvider; + _host = host; + + var appEnv = _serviceProvider.GetService(); + _fileSystem = new PhysicalFileSystem(appEnv.ApplicationBasePath); + } + + public virtual void CompileViews([NotNull] IBeforeCompileContext context) + { + var descriptors = CreateCompilationDescriptors(context); + var collectionGenerator = new RazorFileInfoCollectionGenerator( + descriptors, + SyntaxTreeGenerator.GetParseOptions(context.CSharpCompilation)); + + var tree = collectionGenerator.GenerateCollection(); + context.CSharpCompilation = context.CSharpCompilation.AddSyntaxTrees(tree); + } + + protected virtual IReadOnlyList CreateCompilationDescriptors( + [NotNull] IBeforeCompileContext context) + { + var options = SyntaxTreeGenerator.GetParseOptions(context.CSharpCompilation); + var list = new List(); + + foreach (var info in GetFileInfosRecursive(string.Empty)) + { + var descriptor = ParseView(info, + context, + options); + + if (descriptor != null) + { + list.Add(descriptor); + } + } + + return list; + } + + private IEnumerable GetFileInfosRecursive(string currentPath) + { + IEnumerable fileInfos; + string path = currentPath; + + if (!_fileSystem.TryGetDirectoryContents(path, out fileInfos)) + { + yield break; + } + + foreach (var fileInfo in fileInfos) + { + if (fileInfo.IsDirectory) + { + var subPath = Path.Combine(path, fileInfo.Name); + + foreach (var info in GetFileInfosRecursive(subPath)) + { + yield return info; + } + } + else if (Path.GetExtension(fileInfo.Name) + .Equals(FileExtension, StringComparison.OrdinalIgnoreCase)) + { + var info = new RelativeFileInfo() + { + FileInfo = fileInfo, + RelativePath = Path.Combine(currentPath, fileInfo.Name), + }; + + yield return info; + } + } + } + + protected virtual RazorFileInfo ParseView([NotNull] RelativeFileInfo fileInfo, + [NotNull] IBeforeCompileContext context, + [NotNull] CSharpParseOptions options) + { + using (var stream = fileInfo.FileInfo.CreateReadStream()) + { + var results = _host.GenerateCode(fileInfo.RelativePath, stream); + if (results.Success) + { + var syntaxTree = SyntaxTreeGenerator.Generate(results.GeneratedCode, fileInfo.FileInfo.PhysicalPath, options); + var fullTypeName = results.GetMainClassName(_host, syntaxTree); + + if (fullTypeName != null) + { + context.CSharpCompilation = context.CSharpCompilation.AddSyntaxTrees(syntaxTree); + + var hash = RazorFileHash.GetHash(fileInfo.FileInfo); + + return new RazorFileInfo() + { + FullTypeName = fullTypeName, + RelativePath = fileInfo.RelativePath, + LastModified = fileInfo.FileInfo.LastModified, + Length = fileInfo.FileInfo.Length, + Hash = hash, + }; + } + } + } + + // TODO: Add diagnostics when view parsing/code generation failed. + return null; + } + } +} + +namespace Microsoft.Framework.Runtime +{ + [AssemblyNeutral] + public interface IBeforeCompileContext + { + CSharpCompilation CSharpCompilation { get; set; } + + IList Resources { get; } + + IList Diagnostics { get; } + } +} diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RelativeFileInfo.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RelativeFileInfo.cs new file mode 100644 index 0000000000..c561fdcba9 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RelativeFileInfo.cs @@ -0,0 +1,13 @@ +// 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.Razor +{ + public class RelativeFileInfo + { + public IFileInfo FileInfo { get; set; } + public string RelativePath { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorCompilationService.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorCompilationService.cs index 2b06869907..af77886ad6 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorCompilationService.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorCompilationService.cs @@ -1,66 +1,48 @@ // 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.Diagnostics.Contracts; -using System.IO; using System.Linq; -using Microsoft.AspNet.FileSystems; using Microsoft.AspNet.Razor; -using Microsoft.Framework.Runtime; namespace Microsoft.AspNet.Mvc.Razor { public class RazorCompilationService : IRazorCompilationService { // 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 CompilerCache _cache; private readonly ICompilationService _baseCompilationService; private readonly IMvcRazorHost _razorHost; - private readonly string _appRoot; - public RazorCompilationService(IApplicationEnvironment environment, - ICompilationService compilationService, + public RazorCompilationService(ICompilationService compilationService, + IControllerAssemblyProvider _controllerAssemblyProvider, IMvcRazorHost razorHost) { - _environment = environment; _baseCompilationService = compilationService; _razorHost = razorHost; - _appRoot = EnsureTrailingSlash(environment.ApplicationBasePath); + _cache = new CompilerCache(_controllerAssemblyProvider.CandidateAssemblies); } - public CompilationResult Compile([NotNull] IFileInfo file) + public CompilationResult Compile([NotNull] RelativeFileInfo file) { return _cache.GetOrAdd(file, () => CompileCore(file)); } - internal CompilationResult CompileCore(IFileInfo file) + internal CompilationResult CompileCore(RelativeFileInfo file) { GeneratorResults results; - using (var inputStream = file.CreateReadStream()) + using (var inputStream = file.FileInfo.CreateReadStream()) { - Contract.Assert(file.PhysicalPath.StartsWith(_appRoot, StringComparison.OrdinalIgnoreCase)); - var rootRelativePath = file.PhysicalPath.Substring(_appRoot.Length); - results = _razorHost.GenerateCode(rootRelativePath, inputStream); + results = _razorHost.GenerateCode( + file.RelativePath, inputStream); } if (!results.Success) { var messages = results.ParserErrors.Select(e => new CompilationMessage(e.Message)); - return CompilationResult.Failed(file, results.GeneratedCode, messages); + return CompilationResult.Failed(file.FileInfo, results.GeneratedCode, messages); } - return _baseCompilationService.Compile(file, results.GeneratedCode); - } - - private static string EnsureTrailingSlash([NotNull]string path) - { - if (!path.EndsWith(Path.DirectorySeparatorChar.ToString())) - { - path += Path.DirectorySeparatorChar; - } - return path; + return _baseCompilationService.Compile(file.FileInfo, results.GeneratedCode); } } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorFileHash.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorFileHash.cs new file mode 100644 index 0000000000..bb752cbb97 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorFileHash.cs @@ -0,0 +1,37 @@ +// 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.IO; +using System.Security.Cryptography; +using Microsoft.AspNet.FileSystems; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public static class RazorFileHash + { + public static string GetHash([NotNull] IFileInfo file) + { + try + { + using (var stream = file.CreateReadStream()) + { + return GetHash(stream); + } + } + catch (Exception) + { + // Don't throw if reading the file fails. + return string.Empty; + } + } + + internal static string GetHash(Stream stream) + { + using (var md5 = MD5.Create()) + { + return BitConverter.ToString(md5.ComputeHash(stream)); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/VirtualPathRazorPageFactory.cs b/src/Microsoft.AspNet.Mvc.Razor/VirtualPathRazorPageFactory.cs index b55ede7a85..304a0b9509 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/VirtualPathRazorPageFactory.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/VirtualPathRazorPageFactory.cs @@ -30,15 +30,22 @@ namespace Microsoft.AspNet.Mvc.Razor } /// - public IRazorPage CreateInstance([NotNull] string path) + public IRazorPage CreateInstance([NotNull] string relativePath) { - var fileInfo = _fileInfoCache.GetFileInfo(path); + var fileInfo = _fileInfoCache.GetFileInfo(relativePath); if (fileInfo != null) { - var result = _compilationService.Compile(fileInfo); + var relativeFileInfo = new RelativeFileInfo() + { + FileInfo = fileInfo, + RelativePath = relativePath, + }; + + var result = _compilationService.Compile(relativeFileInfo); var page = (IRazorPage)_activator.CreateInstance(_serviceProvider, result.CompiledType); - page.Path = path; + page.Path = relativePath; + return page; } diff --git a/src/Microsoft.AspNet.Mvc.Razor/project.json b/src/Microsoft.AspNet.Mvc.Razor/project.json index c6c45fcc33..7cf17263a0 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/project.json +++ b/src/Microsoft.AspNet.Mvc.Razor/project.json @@ -30,6 +30,7 @@ "System.Collections": "4.0.10.0", "System.Collections.Concurrent": "4.0.0.0", "System.ComponentModel": "4.0.0.0", + "System.Security.Cryptography.Hashing.Algorithms": "4.0.0.0", "System.Diagnostics.Contracts": "4.0.0.0", "System.Diagnostics.Debug": "4.0.10.0", "System.Diagnostics.Tools": "4.0.0.0", diff --git a/src/Microsoft.AspNet.Mvc/RazorPreCompileModule.cs b/src/Microsoft.AspNet.Mvc/RazorPreCompileModule.cs new file mode 100644 index 0000000000..560a157143 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc/RazorPreCompileModule.cs @@ -0,0 +1,57 @@ +// 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 Microsoft.AspNet.Mvc.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Framework.Runtime; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.DependencyInjection.Fallback; + +namespace Microsoft.AspNet.Mvc +{ + public abstract class RazorPreCompileModule : ICompileModule + { + private readonly IServiceProvider _appServices; + + public RazorPreCompileModule(IServiceProvider services) + { + _appServices = services; + } + + public virtual void BeforeCompile(IBeforeCompileContext context) + { + var sc = new ServiceCollection(); + sc.Add(MvcServices.GetDefaultServices()); + var sp = sc.BuildServiceProvider(_appServices); + + var viewCompiler = new RazorPreCompiler(sp); + viewCompiler.CompileViews(context); + } + + public void AfterCompile(IAfterCompileContext context) + { + } + } +} + +namespace Microsoft.Framework.Runtime +{ + [AssemblyNeutral] + public interface ICompileModule + { + void BeforeCompile(IBeforeCompileContext context); + + void AfterCompile(IAfterCompileContext context); + } + + [AssemblyNeutral] + public interface IAfterCompileContext + { + CSharpCompilation CSharpCompilation { get; set; } + + IList Diagnostics { get; } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Filters/DefaultFilterProviderTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Filters/DefaultFilterProviderTest.cs index e5c3c6ef12..7da7fe847e 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Filters/DefaultFilterProviderTest.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Filters/DefaultFilterProviderTest.cs @@ -28,8 +28,6 @@ namespace Microsoft.AspNet.Mvc.Filters var provider = CreateProvider(); - //System.Diagnostics.Debugger.Launch(); - //System.Diagnostics.Debugger.Break(); // Act provider.Invoke(context, () => { }); var results = context.Results; diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs index 13a02efabe..3230f626fc 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs @@ -2,6 +2,9 @@ // 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.IO; +using System.Text; using Microsoft.AspNet.FileSystems; using Moq; using Xunit; @@ -15,12 +18,23 @@ namespace Microsoft.AspNet.Mvc.Razor.Test { // Arrange var cache = new CompilerCache(); - var fileInfo = Mock.Of(); + var fileInfo = new Mock(); + + fileInfo + .SetupGet(i => i.LastModified) + .Returns(DateTime.FromFileTimeUtc(10000)); + var type = GetType(); var expected = UncachedCompilationResult.Successful(type, "hello world"); + var runtimeFileInfo = new RelativeFileInfo() + { + FileInfo = fileInfo.Object, + RelativePath = "ab", + }; + // Act - var actual = cache.GetOrAdd(fileInfo, () => expected); + var actual = cache.GetOrAdd(runtimeFileInfo, () => expected); // Assert Assert.Same(expected, actual); @@ -28,6 +42,117 @@ namespace Microsoft.AspNet.Mvc.Razor.Test Assert.Same(type, actual.CompiledType); } + private abstract class View + { + public abstract string Content { get; } + } + + private class PreCompile : View + { + public override string Content { get { return "Hello World it's @DateTime.Now"; } } + } + + private class RuntimeCompileIdentical : View + { + public override string Content { get { return new PreCompile().Content; } } + } + + private class RuntimeCompileDifferent : View + { + public override string Content { get { return new PreCompile().Content.Substring(1) + " "; } } + } + + private class RuntimeCompileDifferentLength : View + { + public override string Content + { + get + { + return new PreCompile().Content + " longer because it was modified at runtime"; + } + } + } + + private class ViewCollection : RazorFileInfoCollection + { + public ViewCollection() + { + var fileInfos = new List(); + FileInfos = fileInfos; + + var content = new PreCompile().Content; + var length = Encoding.UTF8.GetByteCount(content); + + fileInfos.Add(new RazorFileInfo() + { + FullTypeName = typeof(PreCompile).FullName, + Hash = RazorFileHash.GetHash(GetMemoryStream(content)), + LastModified = DateTime.FromFileTimeUtc(10000), + Length = length, + RelativePath = "ab", + }); + } + } + + private static Stream GetMemoryStream(string content) + { + var bytes = Encoding.UTF8.GetBytes(content); + + return new MemoryStream(bytes); + } + + [Theory] + [InlineData(typeof(RuntimeCompileIdentical), 10000, false)] + [InlineData(typeof(RuntimeCompileIdentical), 11000, false)] + [InlineData(typeof(RuntimeCompileDifferent), 10000, false)] // expected failure: same time and length + [InlineData(typeof(RuntimeCompileDifferent), 11000, true)] + [InlineData(typeof(RuntimeCompileDifferentLength), 10000, true)] + [InlineData(typeof(RuntimeCompileDifferentLength), 10000, true)] + public void FileWithTheSameLengthAndDifferentTime_DoesNot_OverridesPrecompilation( + Type resultViewType, + long fileTimeUTC, + bool swapsPreCompile) + { + // Arrange + var instance = (View)Activator.CreateInstance(resultViewType); + var length = Encoding.UTF8.GetByteCount(instance.Content); + + var collection = new ViewCollection(); + var cache = new CompilerCache(new[] { new ViewCollection() }); + + var fileInfo = new Mock(); + fileInfo + .SetupGet(i => i.Length) + .Returns(length); + fileInfo + .SetupGet(i => i.LastModified) + .Returns(DateTime.FromFileTimeUtc(fileTimeUTC)); + fileInfo.Setup(i => i.CreateReadStream()) + .Returns(GetMemoryStream(instance.Content)); + + var preCompileType = typeof(PreCompile); + + var runtimeFileInfo = new RelativeFileInfo() + { + FileInfo = fileInfo.Object, + RelativePath = "ab", + }; + + // Act + var actual = cache.GetOrAdd(runtimeFileInfo, + () => CompilationResult.Successful(resultViewType)); + + // Assert + if (swapsPreCompile) + { + Assert.Equal(actual.CompiledType, resultViewType); + } + else + { + Assert.Equal(actual.CompiledType, typeof(PreCompile)); + } + } + [Fact] public void GetOrAdd_DoesNotCacheCompiledContent_OnCallsAfterInitial() { @@ -42,10 +167,16 @@ namespace Microsoft.AspNet.Mvc.Razor.Test var type = GetType(); var uncachedResult = UncachedCompilationResult.Successful(type, "hello world"); + var runtimeFileInfo = new RelativeFileInfo() + { + FileInfo = fileInfo.Object, + RelativePath = "test", + }; + // Act - cache.GetOrAdd(fileInfo.Object, () => uncachedResult); - var actual1 = cache.GetOrAdd(fileInfo.Object, () => uncachedResult); - var actual2 = cache.GetOrAdd(fileInfo.Object, () => uncachedResult); + cache.GetOrAdd(runtimeFileInfo, () => uncachedResult); + var actual1 = cache.GetOrAdd(runtimeFileInfo, () => uncachedResult); + var actual2 = cache.GetOrAdd(runtimeFileInfo, () => uncachedResult); // Assert Assert.NotSame(uncachedResult, actual1); diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorCompilationServiceTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorCompilationServiceTest.cs index fc43e1e07f..93c2157e53 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorCompilationServiceTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorCompilationServiceTest.cs @@ -2,11 +2,12 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.IO; +using System.Linq; +using System.Reflection; using Microsoft.AspNet.FileSystems; using Microsoft.AspNet.Razor; using Microsoft.AspNet.Razor.Generator.Compiler; using Microsoft.AspNet.Razor.Parser.SyntaxTree; -using Microsoft.Framework.Runtime; using Moq; using Xunit; @@ -20,24 +21,32 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void CompileCoreCalculatesRootRelativePath(string appPath, string viewPath) { // Arrange - var env = new Mock(); - env.SetupGet(e => e.ApplicationName).Returns("MyTestApplication"); - env.SetupGet(e => e.ApplicationBasePath).Returns(appPath); var host = new Mock(); host.Setup(h => h.GenerateCode(@"views\index\home.cshtml", It.IsAny())) .Returns(new GeneratorResults(new Block(new BlockBuilder { Type = BlockType.Comment }), new RazorError[0], new CodeBuilderResult("", new LineMapping[0]))) .Verifiable(); + var ap = new Mock(); + ap.SetupGet(e => e.CandidateAssemblies) + .Returns(Enumerable.Empty()) + .Verifiable(); + var fileInfo = new Mock(); fileInfo.Setup(f => f.PhysicalPath).Returns(viewPath); fileInfo.Setup(f => f.CreateReadStream()).Returns(Stream.Null); var compiler = new Mock(); compiler.Setup(c => c.Compile(fileInfo.Object, It.IsAny())) .Returns(CompilationResult.Successful(typeof(RazorCompilationServiceTest))); - var razorService = new RazorCompilationService(env.Object, compiler.Object, host.Object); + var razorService = new RazorCompilationService(compiler.Object, ap.Object, host.Object); + + var relativeFileInfo = new RelativeFileInfo() + { + FileInfo = fileInfo.Object, + RelativePath = @"views\index\home.cshtml", + }; // Act - razorService.CompileCore(fileInfo.Object); + razorService.CompileCore(relativeFileInfo); // Assert host.Verify();