From a82b0f20936f533584439b9923641840b4aa6021 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Sat, 21 Mar 2020 22:51:28 -0700 Subject: [PATCH] Use MSBuild in process to grovel project information This replaces the (dirty) technique of target-based injection with hosting MSBuild in process. This is a speedup of about 800ms per project for every project past the first one. Given that Tye is for microservices, hopefully you have more than one project. --- .../Microsoft.Tye.Core.csproj | 17 +- src/Microsoft.Tye.Core/OutputContext.cs | 10 + src/Microsoft.Tye.Core/ProjectReader.cs | 175 +++++++++++------- .../Resources/Imports.targets | 8 - src/Microsoft.Tye.Core/TargetInstaller.cs | 48 ----- src/Microsoft.Tye.Core/build/Tye.targets | 28 --- .../buildMultiTargeting/Tye.targets | 20 -- 7 files changed, 133 insertions(+), 173 deletions(-) delete mode 100644 src/Microsoft.Tye.Core/Resources/Imports.targets delete mode 100644 src/Microsoft.Tye.Core/TargetInstaller.cs delete mode 100644 src/Microsoft.Tye.Core/build/Tye.targets delete mode 100644 src/Microsoft.Tye.Core/buildMultiTargeting/Tye.targets diff --git a/src/Microsoft.Tye.Core/Microsoft.Tye.Core.csproj b/src/Microsoft.Tye.Core/Microsoft.Tye.Core.csproj index 7ca082ec..16e4ad6e 100644 --- a/src/Microsoft.Tye.Core/Microsoft.Tye.Core.csproj +++ b/src/Microsoft.Tye.Core/Microsoft.Tye.Core.csproj @@ -12,16 +12,23 @@ - + + + + + + + - - @@ -30,9 +37,5 @@ - - - - diff --git a/src/Microsoft.Tye.Core/OutputContext.cs b/src/Microsoft.Tye.Core/OutputContext.cs index e21fc20b..8b766504 100644 --- a/src/Microsoft.Tye.Core/OutputContext.cs +++ b/src/Microsoft.Tye.Core/OutputContext.cs @@ -101,11 +101,21 @@ namespace Microsoft.Tye WriteLine(Verbosity.Info, message); } + public void WriteInfo(string message) + { + Write(Verbosity.Info, message); + } + public void WriteInfoLine(string message) { WriteLine(Verbosity.Info, message); } + public void WriteDebug(string message) + { + Write(Verbosity.Debug, message); + } + public void WriteDebugLine(string message) { WriteLine(Verbosity.Debug, message); diff --git a/src/Microsoft.Tye.Core/ProjectReader.cs b/src/Microsoft.Tye.Core/ProjectReader.cs index 0dc3eb53..427b8d0b 100644 --- a/src/Microsoft.Tye.Core/ProjectReader.cs +++ b/src/Microsoft.Tye.Core/ProjectReader.cs @@ -3,17 +3,26 @@ // See the LICENSE file in the project root for more information. using System; -using System.CommandLine.Invocation; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; using System.Threading.Tasks; +using Microsoft.Build.Definition; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Locator; +using Microsoft.Build.Logging; using Semver; namespace Microsoft.Tye { public static class ProjectReader { - public static async Task ReadProjectDetailsAsync(OutputContext output, FileInfo projectFile, Project project) + private static bool registered; + public static Task ReadProjectDetailsAsync(OutputContext output, FileInfo projectFile, Project project) { if (output is null) { @@ -30,94 +39,136 @@ namespace Microsoft.Tye throw new ArgumentNullException(nameof(project)); } + EnsureMSBuildRegistered(projectFile); + using (var step = output.BeginStep("Reading Project Details...")) { - await EvaluateMSBuildAsync(output, projectFile, project); + EvaluateProject(output, projectFile, project); if (!SemVersion.TryParse(project.Version, out var version)) { - output.WriteInfoLine($"No version or invalid version 'application.Version' found, using default."); + output.WriteInfoLine($"No version or invalid version '{project.Version}' found, using default."); version = new SemVersion(0, 1, 0); project.Version = version.ToString(); } step.MarkComplete(); } + + return Task.CompletedTask; + } + + private static void EnsureMSBuildRegistered(FileInfo projectFile) + { + if (!registered) + { + // It says VisualStudio - but we'll just use .NET SDK + var instances = MSBuildLocator.QueryVisualStudioInstances(new VisualStudioInstanceQueryOptions() + { + DiscoveryTypes = DiscoveryType.DotNetSdk, + + // Using the project as the working directory. We're making the assumption that + // all of the projects want to use the same SDK version. This library is going + // load a single version of the SDK's assemblies into our process, so we can't + // use supprt SDKs at once without getting really tricky. + // + // The .NET SDK-based discovery uses `dotnet --info` and returns the SDK + // in use for the directory. + // + // https://github.com/microsoft/MSBuildLocator/blob/master/src/MSBuildLocator/MSBuildLocator.cs#L320 + WorkingDirectory = projectFile.DirectoryName, + }); + + var instance = instances.SingleOrDefault(); + if (instance == null) + { + throw new CommandException("Failed to find dotnet. Make sure the .NET SDK is installed and on the PATH."); + } + + MSBuildLocator.RegisterInstance(instance); + registered = true; + } } - private static async Task EvaluateMSBuildAsync(OutputContext output, FileInfo projectFile, Project project) + // Do not load MSBuild types before using EnsureMSBuildRegistered. + [MethodImpl(MethodImplOptions.NoInlining)] + private static void EvaluateProject(OutputContext output, FileInfo projectFile, Project project) { + var sw = Stopwatch.StartNew(); + + ProjectInstance projectInstance; try { - output.WriteDebugLine("Installing msbuild targets."); - TargetInstaller.Install(projectFile.FullName); - output.WriteDebugLine("Installed msbuild targets."); + output.WriteDebugLine($"Loading project '{projectFile.FullName}'."); + projectInstance = ProjectInstance.FromFile(projectFile.FullName, new ProjectOptions()); + output.WriteDebugLine($"Loaded project '{projectFile.FullName}'."); } - catch (Exception ex) + catch { - throw new CommandException("Failed to install targets.", ex); + throw new CommandException($"Failed to load project: '{projectFile.FullName}'."); } - var outputFilePath = Path.GetTempFileName(); + // Currently we only log at debug level. + var logger = new ConsoleLogger( + verbosity: LoggerVerbosity.Normal, + write: message => output.WriteDebug(message), + colorSet: null, + colorReset: null); try { - var capture = output.Capture(); - var programRoot = Path.GetDirectoryName(typeof(Program).Assembly.Location); - - var restore = string.Empty; - if (!File.Exists(Path.Combine(projectFile.DirectoryName, "obj", "project.assets.json"))) - { - restore = "/restore"; - } - - output.WriteDebugLine("Running 'dotnet msbuild'."); - var msbuildCommand = $"msbuild {restore} /t:EvaluateTyeProjectInfo \"/p:TyeTargetLocation={programRoot}\" \"/p:TyeOutputFilePath={outputFilePath}\""; - - output.WriteCommandLine("dotnet", msbuildCommand); - var exitCode = await Process.ExecuteAsync( - $"dotnet", - msbuildCommand, - workingDir: projectFile.DirectoryName, - stdOut: capture.StdOut, - stdErr: capture.StdErr); - - output.WriteDebugLine($"Done running 'dotnet msbuild' exit code: {exitCode}"); - if (exitCode != 0) - { - throw new CommandException("'dotnet msbuild' failed."); - } + AssemblyLoadContext.Default.Resolving += ResolveAssembly; + var result = projectInstance.Build( + targets: new[] { "Restore", "ResolveReferences", "ResolvePackageDependenciesDesignTime", "PrepareResources", "GetAssemblyAttributes", }, + loggers: new[] { logger, }); + + // If the build fails, we're not really blocked from doing our work. + // For now we just log the output to debug. There are errors that occur during + // running these targets we don't really care as long as we get the data. + } + finally + { + AssemblyLoadContext.Default.Resolving -= ResolveAssembly; + } - var lines = await File.ReadAllLinesAsync(outputFilePath); - for (var i = 0; i < lines.Length; i++) + // Reading both InformationalVersion and Version is more resilant in the face of build failures. + var version = projectInstance.GetProperty("InformationalVersion")?.EvaluatedValue ?? projectInstance.GetProperty("Version").EvaluatedValue; + project.Version = version; + output.WriteDebugLine($"Found application version: {version}"); + + var targetFramework = projectInstance.GetPropertyValue("TargetFramework"); + project.TargetFramework = targetFramework; + output.WriteDebugLine($"Found target framework: {targetFramework}"); + + var sharedFrameworks = projectInstance.GetItems("FrameworkReference").Select(i => i.EvaluatedInclude).ToList(); + project.Frameworks.AddRange(sharedFrameworks.Select(s => new Framework(s))); + output.WriteDebugLine($"Found shared frameworks: {string.Join(", ", sharedFrameworks)}"); + + output.WriteDebugLine($"Evaluation Took: {sw.Elapsed.TotalMilliseconds}ms"); + + // The Microsoft.Build.Locator doesn't handle the loading of other assemblies + // that are shipped with MSBuild (ex NuGet). + // + // This means that the set of assemblies that need special handling depends on the targets + // that we run :( + // + // This is workaround for this limitation based on the targets we need to run + // to resolve references and versions. + // + // See: https://github.com/microsoft/MSBuildLocator/issues/86 + Assembly? ResolveAssembly(AssemblyLoadContext context, AssemblyName assemblyName) + { + if (assemblyName.Name is object && assemblyName.Name.StartsWith("NuGet.")) { - var line = lines[i]; - if (line.StartsWith("version=")) + var msbuildDirectory = Environment.GetEnvironmentVariable("MSBuildExtensionsPath")!; + var assemblyFilePath = Path.Combine(msbuildDirectory, assemblyName.Name + ".dll"); + if (File.Exists(assemblyFilePath)) { - project.Version = line.Substring("version=".Length).Trim(); - output.WriteDebugLine($"Found application version: {line}"); - continue; - } - - if (line.StartsWith("tfm")) - { - project.TargetFramework = line.Substring("tfm=".Length).Trim(); - output.WriteDebugLine($"Found target framework: {line}"); - continue; - } - - if (line.StartsWith("frameworks=")) - { - var right = line.Substring("frameworks=".Length).Trim(); - project.Frameworks.AddRange(right.Split(",").Select(s => new Framework(s))); - output.WriteDebugLine($"Found shared frameworks: {line}"); - continue; + return context.LoadFromAssemblyPath(assemblyFilePath); } } - } - finally - { - File.Delete(outputFilePath); + + return default; } } } diff --git a/src/Microsoft.Tye.Core/Resources/Imports.targets b/src/Microsoft.Tye.Core/Resources/Imports.targets deleted file mode 100644 index e695c12c..00000000 --- a/src/Microsoft.Tye.Core/Resources/Imports.targets +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/Microsoft.Tye.Core/TargetInstaller.cs b/src/Microsoft.Tye.Core/TargetInstaller.cs deleted file mode 100644 index 49261a44..00000000 --- a/src/Microsoft.Tye.Core/TargetInstaller.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.IO; -using System.Reflection; - -namespace Microsoft.Tye -{ - public static class TargetInstaller - { - public static void Install(string projectFilePath) - { - if (projectFilePath is null) - { - throw new ArgumentNullException(nameof(projectFilePath)); - } - - var projectDirectory = Path.GetDirectoryName(projectFilePath); - var intermediateDirectory = Path.Combine(projectDirectory!, "obj"); - - Directory.CreateDirectory(intermediateDirectory); - - var fileName = $"{Path.GetFileName(projectFilePath)}.Tye.targets"; - var targetFilePath = Path.Combine(intermediateDirectory, fileName); - - if (File.Exists(targetFilePath)) - { - return; - } - - var toolType = typeof(TargetInstaller); - var toolAssembly = toolType.GetTypeInfo().Assembly; - var toolImportTargetsResourceName = $"Tye.Resources.Imports.targets"; - - using var stream = toolAssembly.GetManifestResourceStream(toolImportTargetsResourceName); - if (stream == null) - { - throw new CommandException("Failed to find resource. Valid names: " + string.Join(", ", toolAssembly.GetManifestResourceNames())); - } - - var targetBytes = new byte[stream.Length]; - stream.Read(targetBytes, 0, targetBytes.Length); - File.WriteAllBytes(targetFilePath, targetBytes); - } - } -} diff --git a/src/Microsoft.Tye.Core/build/Tye.targets b/src/Microsoft.Tye.Core/build/Tye.targets deleted file mode 100644 index 80be5c4d..00000000 --- a/src/Microsoft.Tye.Core/build/Tye.targets +++ /dev/null @@ -1,28 +0,0 @@ - - - - - $(EvaluateTyeProjectInfoDependsOn); - ResolveReferences; - ResolvePackageDependenciesDesignTime; - PrepareResources; - GetAssemblyAttributes; - - - - - - - - <_Frameworks>@(FrameworkReference, ',') - - - <_Lines Include="version=$(InformationalVersion)" /> - <_Lines Include="tfm=$(TargetFramework)" /> - <_Lines Include="frameworks=$(_Frameworks)" /> - - - - - - diff --git a/src/Microsoft.Tye.Core/buildMultiTargeting/Tye.targets b/src/Microsoft.Tye.Core/buildMultiTargeting/Tye.targets deleted file mode 100644 index ad18d2af..00000000 --- a/src/Microsoft.Tye.Core/buildMultiTargeting/Tye.targets +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file