From 083bab05280d7d797d41f9c924259ae24b87ddcf Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Sun, 11 Nov 2012 09:09:06 +0500 Subject: [PATCH] Added HashCodeCombiner and unit tests to support. Added ability to create a hashed file based on scanned assemblies in the PluginManager and added a cached plugin xml file to the PluginManager. Now we don't actually scan any assemblies on app startup during plugin finding if none of the DLLs have changed. Added trace statements to the UmbracoModule for better benchmarking for how long a full request takes to load from start to finish. --- src/Umbraco.Core/HashCodeCombiner.cs | 76 ++++++ src/Umbraco.Core/IO/SystemDirectories.cs | 1 + src/Umbraco.Core/PluginManager.cs | 280 ++++++++++++++++++++- src/Umbraco.Core/Umbraco.Core.csproj | 1 + src/Umbraco.Tests/HashCodeCombinerTests.cs | 154 ++++++++++++ src/Umbraco.Tests/PluginManagerTests.cs | 80 ++++++ src/Umbraco.Tests/Umbraco.Tests.csproj | 1 + src/Umbraco.Web/UmbracoModule.cs | 16 +- 8 files changed, 603 insertions(+), 6 deletions(-) create mode 100644 src/Umbraco.Core/HashCodeCombiner.cs create mode 100644 src/Umbraco.Tests/HashCodeCombinerTests.cs diff --git a/src/Umbraco.Core/HashCodeCombiner.cs b/src/Umbraco.Core/HashCodeCombiner.cs new file mode 100644 index 0000000000..78f5eb6839 --- /dev/null +++ b/src/Umbraco.Core/HashCodeCombiner.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace Umbraco.Core +{ + /// + /// Used to create a hash code from multiple objects. + /// + /// + /// .Net has a class the same as this: System.Web.Util.HashCodeCombiner and of course it works for all sorts of things + /// and is probably more stable in general, however we just need a quick easy class for this in order to create a unique + /// hash of plugins to see if they've changed. + /// + internal class HashCodeCombiner + { + private int _combinedHash; + + internal void AddInt(int i) + { + _combinedHash = ((_combinedHash << 5) + _combinedHash) ^ i; + } + + internal void AddObject(object o) + { + AddInt(o.GetHashCode()); + } + + internal void AddDateTime(DateTime d) + { + AddInt(d.GetHashCode()); + } + + internal void AddCaseInsensitiveString(string s) + { + if (s != null) + AddInt((StringComparer.InvariantCultureIgnoreCase).GetHashCode(s)); + } + + internal void AddFile(FileInfo f) + { + AddCaseInsensitiveString(f.FullName); + AddDateTime(f.CreationTimeUtc); + AddDateTime(f.LastWriteTimeUtc); + AddInt(f.Length.GetHashCode()); + } + + internal void AddFolder(DirectoryInfo d) + { + AddCaseInsensitiveString(d.FullName); + AddDateTime(d.CreationTimeUtc); + AddDateTime(d.LastWriteTimeUtc); + foreach (var f in d.GetFiles()) + { + AddFile(f); + } + foreach (var s in d.GetDirectories()) + { + AddFolder(s); + } + } + + /// + /// Returns the hex code of the combined hash code + /// + /// + internal string GetCombinedHashCode() + { + return _combinedHash.ToString("x", CultureInfo.InvariantCulture); + } + + } +} diff --git a/src/Umbraco.Core/IO/SystemDirectories.cs b/src/Umbraco.Core/IO/SystemDirectories.cs index e7f6b29c60..6828b0e353 100644 --- a/src/Umbraco.Core/IO/SystemDirectories.cs +++ b/src/Umbraco.Core/IO/SystemDirectories.cs @@ -12,6 +12,7 @@ namespace Umbraco.Core.IO //all paths has a starting but no trailing / public class SystemDirectories { + //TODO: Why on earth is this even configurable? You cannot change the /Bin folder in ASP.Net public static string Bin { get diff --git a/src/Umbraco.Core/PluginManager.cs b/src/Umbraco.Core/PluginManager.cs index b01e7c0683..66b4126574 100644 --- a/src/Umbraco.Core/PluginManager.cs +++ b/src/Umbraco.Core/PluginManager.cs @@ -1,10 +1,16 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Threading; +using System.Xml; +using System.Xml.Linq; +using Umbraco.Core.Configuration; +using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.PropertyEditors; using umbraco.interfaces; @@ -21,16 +27,35 @@ namespace Umbraco.Core /// /// This class can expose extension methods to resolve custom plugins /// + /// Before this class resolves any plugins it checks if the hash has changed for the DLLs in the /bin folder, if it hasn't + /// it will use the cached resolved plugins that it has already found which means that no assembly scanning is necessary. This leads + /// to much faster startup times. /// internal class PluginManager { internal PluginManager() { + _tempFolder = IOHelper.MapPath("~/App_Data/TEMP/PluginCache"); + //create the folder if it doesn't exist + if (!Directory.Exists(_tempFolder)) + { + Directory.CreateDirectory(_tempFolder); + } + //do the check if they've changed + HaveAssembliesChanged = CachedAssembliesHash != CurrentAssembliesHash; + //if they have changed, we need to write the new file + if (HaveAssembliesChanged) + { + WriteCachePluginsHash(); + } } static PluginManager _resolver; static readonly ReaderWriterLockSlim Lock = new ReaderWriterLockSlim(); + private readonly string _tempFolder; + private long _cachedAssembliesHash = -1; + private long _currentAssembliesHash = -1; /// /// We will ensure that no matter what, only one of these is created, this is to ensure that caching always takes place @@ -55,6 +80,204 @@ namespace Umbraco.Core set { _resolver = value; } } + #region Hash checking methods + + /// + /// Returns a bool if the assemblies in the /bin have changed since they were last hashed. + /// + internal bool HaveAssembliesChanged { get; private set; } + + /// + /// Returns the currently cached hash value of the scanned assemblies in the /bin folder. Returns 0 + /// if no cache is found. + /// + /// + internal long CachedAssembliesHash + { + get + { + if (_cachedAssembliesHash != -1) + return _cachedAssembliesHash; + + var filePath = Path.Combine(_tempFolder, "umbraco-plugins.hash"); + if (!File.Exists(filePath)) + return 0; + var hash = File.ReadAllText(filePath, Encoding.UTF8); + Int64 val; + if (Int64.TryParse(hash, out val)) + { + _cachedAssembliesHash = val; + return _cachedAssembliesHash; + } + //it could not parse for some reason so we'll return 0. + return 0; + } + } + + /// + /// Returns the current assemblies hash based on creating a hash from the assemblies in the /bin + /// + /// + internal long CurrentAssembliesHash + { + get + { + if (_currentAssembliesHash != -1) + return _currentAssembliesHash; + + _currentAssembliesHash = GetAssembliesHash(new DirectoryInfo(IOHelper.MapPath(SystemDirectories.Bin)).GetFiles("*.dll")); + return _currentAssembliesHash; + } + } + + /// + /// Writes the assembly hash file + /// + private void WriteCachePluginsHash() + { + var filePath = Path.Combine(_tempFolder, "umbraco-plugins.hash"); + File.WriteAllText(filePath, CurrentAssembliesHash.ToString(), Encoding.UTF8); + } + + /// + /// Returns a unique hash for the combination of FileInfo objects passed in + /// + /// + /// + internal static long GetAssembliesHash(IEnumerable plugins) + { + using (DisposableTimer.TraceDuration("Determining hash of plugins on disk", "Hash determined")) + { + var hashCombiner = new HashCodeCombiner(); + //add each unique folder to the hash + foreach (var i in plugins.Select(x => x.Directory).DistinctBy(x => x.FullName)) + { + hashCombiner.AddFolder(i); + } + return ConvertPluginsHashFromHex(hashCombiner.GetCombinedHashCode()); + } + } + + /// + /// Converts the hash value of current plugins to long from string + /// + /// + /// + internal static long ConvertPluginsHashFromHex(string val) + { + long outVal; + if (Int64.TryParse(val, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out outVal)) + { + return outVal; + } + return 0; + } + + /// + /// Attempts to resolve the list of plugin + assemblies found in the runtime for the base type 'T' passed in. + /// If the cache file doesn't exist, fails to load, is corrupt or the type 'T' element is not found then + /// a false attempt is returned. + /// + /// + /// + internal Attempt>> TryGetCachedPluginsFromFile() + { + var filePath = Path.Combine(_tempFolder, "umbraco-plugins.list"); + if (!File.Exists(filePath)) + return Attempt>>.False; + + try + { + var xml = XDocument.Load(filePath); + if (xml.Root == null) + return Attempt>>.False; + + var typeElement = xml.Root.Elements() + .SingleOrDefault(x => + x.Name.LocalName == "baseType" + && ((string) x.Attribute("type")) == typeof (T).FullName); + if (typeElement == null) + return Attempt>>.False; + + //return success + return new Attempt>>( + true, + typeElement.Elements("add") + .Select(x => new Tuple( + (string) x.Attribute("type"), + (string) x.Attribute("assembly")))); + } + catch (Exception) + { + //if the file is corrupted, etc... return false + return Attempt>>.False; + } + } + + /// + /// Adds/Updates the type list for the base type 'T' in the cached file + /// + /// + /// + /// + /// THIS METHOD IS NOT THREAD SAFE + /// + /// + /// + /// + /// + /// + /// + /// + /// ]]> + /// + internal void UpdateCachedPluginsFile(IEnumerable typesFound) + { + var filePath = Path.Combine(_tempFolder, "umbraco-plugins.list"); + XDocument xml; + try + { + xml = XDocument.Load(filePath); + } + catch + { + //if there's an exception loading then this is somehow corrupt, we'll just replace it. + File.Delete(filePath); + //create the document and the root + xml = new XDocument(new XElement("plugins")); + } + if (xml.Root == null) + { + //if for some reason there is no root, create it + xml.Add(new XElement("plugins")); + } + //find the type 'T' element to add or update + var typeElement = xml.Root.Elements() + .SingleOrDefault(x => + x.Name.LocalName == "baseType" + && ((string)x.Attribute("type")) == typeof(T).FullName); + if (typeElement == null) + { + //create the type element + typeElement = new XElement("baseType", new XAttribute("type", typeof(T).FullName)); + //then add it to the root + xml.Root.Add(typeElement); + } + + + //now we have the type element, we need to clear any previous types as children and add/update it with new ones + typeElement.ReplaceNodes(typesFound + .Select(x => + new XElement("add", + new XAttribute("type", x.FullName), + new XAttribute("assembly", x.Assembly.FullName)))); + //save the xml file + xml.Save(filePath); + } + + #endregion + private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(); private readonly HashSet _types = new HashSet(); private IEnumerable _assemblies; @@ -198,7 +421,7 @@ namespace Umbraco.Core using (DisposableTimer.TraceDuration( String.Format("Starting resolution types of {0}", typeof(T).FullName), String.Format("Completed resolution of types of {0}", typeof(T).FullName))) - { + { //check if the TypeList already exists, if so return it, if not we'll create it var typeList = _types.SingleOrDefault(x => x.IsTypeList(resolutionType)); //if we're not caching the result then proceed, or if the type list doesn't exist then proceed @@ -209,9 +432,41 @@ namespace Umbraco.Core typeList = new TypeList(resolutionType); - foreach (var t in finder()) + //we first need to look into our cache file (this has nothing to do with the 'cacheResult' parameter which caches in memory). + if (!HaveAssembliesChanged) { - typeList.AddType(t); + var fileCacheResult = TryGetCachedPluginsFromFile(); + if (fileCacheResult.Success) + { + var successfullyLoadedFromCache = true; + //we have a previous cache for this so we don't need to scan we just load what has been found in the file + foreach(var t in fileCacheResult.Result) + { + try + { + var type = Assembly.Load(t.Item2).GetType(t.Item1); + typeList.AddType(type); + } + catch (Exception ex) + { + //if there are any exceptions loading types, we have to exist, this should never happen so + //we will need to revert to scanning for types. + successfullyLoadedFromCache = false; + LogHelper.Error("Could not load a cached plugin type: " + t.Item1 + " in assembly: " + t.Item2 + " now reverting to re-scanning assemblies for the base type: " + typeof (T).FullName, ex); + break; + } + } + if (!successfullyLoadedFromCache ) + { + //we need to manually load by scanning if loading from the file was not successful. + LoadViaScanningAndUpdateCacheFile(typeList, finder); + } + } + } + else + { + //we don't have a cache for this so proceed to look them up by scanning + LoadViaScanningAndUpdateCacheFile(typeList, finder); } //only add the cache if we are to cache the results @@ -226,6 +481,25 @@ namespace Umbraco.Core } } + /// + /// This method invokes the finder which scans the assemblies for the types and then loads the result into the type finder. + /// Once the results are loaded, we update the cached type xml file + /// + /// + /// + /// + /// THIS METHODS IS NOT THREAD SAFE + /// + private void LoadViaScanningAndUpdateCacheFile(TypeList typeList, Func> finder) + { + //we don't have a cache for this so proceed to look them up by scanning + foreach (var t in finder()) + { + typeList.AddType(t); + } + UpdateCachedPluginsFile(typeList.GetTypes()); + } + /// /// Generic method to find the specified type and cache the result /// diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index d44959f632..00140be3ad 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -66,6 +66,7 @@ + diff --git a/src/Umbraco.Tests/HashCodeCombinerTests.cs b/src/Umbraco.Tests/HashCodeCombinerTests.cs new file mode 100644 index 0000000000..b1c808e7f8 --- /dev/null +++ b/src/Umbraco.Tests/HashCodeCombinerTests.cs @@ -0,0 +1,154 @@ +using System; +using System.IO; +using System.Reflection; +using NUnit.Framework; +using Umbraco.Core; + +namespace Umbraco.Tests +{ + [TestFixture] + public class HashCodeCombinerTests : PartialTrust.AbstractPartialTrustFixture + { + + private DirectoryInfo PrepareFolder() + { + var assDir = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory; + var dir = Directory.CreateDirectory(Path.Combine(assDir.FullName, "HashCombiner", Guid.NewGuid().ToString("N"))); + foreach (var f in dir.GetFiles()) + { + f.Delete(); + } + return dir; + } + + [Test] + public void HashCombiner_Test_String() + { + var combiner1 = new HashCodeCombiner(); + combiner1.AddCaseInsensitiveString("Hello"); + + var combiner2 = new HashCodeCombiner(); + combiner2.AddCaseInsensitiveString("hello"); + + Assert.AreEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + + combiner2.AddCaseInsensitiveString("world"); + + Assert.AreNotEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + } + + [Test] + public void HashCombiner_Test_Int() + { + var combiner1 = new HashCodeCombiner(); + combiner1.AddInt(1234); + + var combiner2 = new HashCodeCombiner(); + combiner2.AddInt(1234); + + Assert.AreEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + + combiner2.AddInt(1); + + Assert.AreNotEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + } + + [Test] + public void HashCombiner_Test_DateTime() + { + var dt = DateTime.Now; + var combiner1 = new HashCodeCombiner(); + combiner1.AddDateTime(dt); + + var combiner2 = new HashCodeCombiner(); + combiner2.AddDateTime(dt); + + Assert.AreEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + + combiner2.AddDateTime(DateTime.Now); + + Assert.AreNotEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + } + + [Test] + public void HashCombiner_Test_File() + { + var dir = PrepareFolder(); + var file1Path = Path.Combine(dir.FullName, "hastest1.txt"); + File.Delete(file1Path); + using (var file1 = File.CreateText(Path.Combine(dir.FullName, "hastest1.txt"))) + { + file1.WriteLine("hello"); + } + var file2Path = Path.Combine(dir.FullName, "hastest2.txt"); + File.Delete(file2Path); + using (var file2 = File.CreateText(Path.Combine(dir.FullName, "hastest2.txt"))) + { + //even though files are the same, the dates are different + file2.WriteLine("hello"); + } + + var combiner1 = new HashCodeCombiner(); + combiner1.AddFile(new FileInfo(file1Path)); + + var combiner2 = new HashCodeCombiner(); + combiner2.AddFile(new FileInfo(file1Path)); + + var combiner3 = new HashCodeCombiner(); + combiner3.AddFile(new FileInfo(file2Path)); + + Assert.AreEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + Assert.AreNotEqual(combiner1.GetCombinedHashCode(), combiner3.GetCombinedHashCode()); + + combiner2.AddFile(new FileInfo(file2Path)); + + Assert.AreNotEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + } + + [Test] + public void HashCombiner_Test_Folder() + { + var dir = PrepareFolder(); + var file1Path = Path.Combine(dir.FullName, "hastest1.txt"); + File.Delete(file1Path); + using (var file1 = File.CreateText(Path.Combine(dir.FullName, "hastest1.txt"))) + { + file1.WriteLine("hello"); + } + + //first test the whole folder + var combiner1 = new HashCodeCombiner(); + combiner1.AddFolder(dir); + + var combiner2 = new HashCodeCombiner(); + combiner2.AddFolder(dir); + + Assert.AreEqual(combiner1.GetCombinedHashCode(), combiner2.GetCombinedHashCode()); + + //now add a file to the folder + + var file2Path = Path.Combine(dir.FullName, "hastest2.txt"); + File.Delete(file2Path); + using (var file2 = File.CreateText(Path.Combine(dir.FullName, "hastest2.txt"))) + { + //even though files are the same, the dates are different + file2.WriteLine("hello"); + } + + var combiner3 = new HashCodeCombiner(); + combiner3.AddFolder(dir); + + Assert.AreNotEqual(combiner1.GetCombinedHashCode(), combiner3.GetCombinedHashCode()); + } + + public override void TestSetup() + { + + } + + public override void TestTearDown() + { + + } + } +} diff --git a/src/Umbraco.Tests/PluginManagerTests.cs b/src/Umbraco.Tests/PluginManagerTests.cs index 69fb1afa9a..3812835115 100644 --- a/src/Umbraco.Tests/PluginManagerTests.cs +++ b/src/Umbraco.Tests/PluginManagerTests.cs @@ -1,4 +1,8 @@ +using System; +using System.Globalization; +using System.IO; using System.Linq; +using System.Reflection; using NUnit.Framework; using SqlCE4Umbraco; using Umbraco.Core; @@ -54,6 +58,82 @@ namespace Umbraco.Tests }; } + private DirectoryInfo PrepareFolder() + { + var assDir = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory; + var dir = Directory.CreateDirectory(Path.Combine(assDir.FullName, "PluginManager", Guid.NewGuid().ToString("N"))); + foreach (var f in dir.GetFiles()) + { + f.Delete(); + } + return dir; + } + + [Test] + public void Create_Cached_Plugin_File() + { + var types = new[] {typeof (PluginManager), typeof (PluginManagerTests), typeof (UmbracoContext)}; + + var manager = new PluginManager(); + //yes this is silly, none of these types inherit from string, but this is just to test the xml file format + manager.UpdateCachedPluginsFile(types); + + var plugins = manager.TryGetCachedPluginsFromFile(); + Assert.IsTrue(plugins.Success); + Assert.AreEqual(3, plugins.Result.Count()); + var shouldContain = types.Select(x => new System.Tuple(x.FullName, x.Assembly.FullName)); + //ensure they are all found + Assert.IsTrue(plugins.Result.ContainsAll(shouldContain)); + } + + [Test] + public void PluginHash_From_String() + { + var s = "hello my name is someone".GetHashCode().ToString("x", CultureInfo.InvariantCulture); + var output = PluginManager.ConvertPluginsHashFromHex(s); + Assert.AreNotEqual(0, output); + } + + [Test] + public void Get_Plugins_Hash() + { + //Arrange + var dir = PrepareFolder(); + var d1 = dir.CreateSubdirectory("1"); + var d2 = dir.CreateSubdirectory("2"); + var d3 = dir.CreateSubdirectory("3"); + var d4 = dir.CreateSubdirectory("4"); + var f1 = new FileInfo(Path.Combine(d1.FullName, "test1.dll")); + var f2 = new FileInfo(Path.Combine(d1.FullName, "test2.dll")); + var f3 = new FileInfo(Path.Combine(d2.FullName, "test1.dll")); + var f4 = new FileInfo(Path.Combine(d2.FullName, "test2.dll")); + var f5 = new FileInfo(Path.Combine(d3.FullName, "test1.dll")); + var f6 = new FileInfo(Path.Combine(d3.FullName, "test2.dll")); + var f7 = new FileInfo(Path.Combine(d4.FullName, "test1.dll")); + f1.CreateText().Close(); + f2.CreateText().Close(); + f3.CreateText().Close(); + f4.CreateText().Close(); + f5.CreateText().Close(); + f6.CreateText().Close(); + f7.CreateText().Close(); + var list1 = new[] { f1, f2, f3, f4, f5, f6 }; + var list2 = new[] { f1, f3, f5 }; + var list3 = new[] { f1, f3, f5, f7 }; + + //Act + var hash1 = PluginManager.GetAssembliesHash(list1); + var hash2 = PluginManager.GetAssembliesHash(list2); + var hash3 = PluginManager.GetAssembliesHash(list3); + + //Assert + + //both should be the same since we only create the hash based on the unique folder of the list passed in, yet + //all files will exist in those folders still + Assert.AreEqual(hash1, hash2); + Assert.AreNotEqual(hash1, hash3); + } + [Test] public void Ensure_Only_One_Type_List_Created() { diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index df5601255e..c9111cd6f9 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -59,6 +59,7 @@ + diff --git a/src/Umbraco.Web/UmbracoModule.cs b/src/Umbraco.Web/UmbracoModule.cs index 24292936cf..37125b8b4d 100644 --- a/src/Umbraco.Web/UmbracoModule.cs +++ b/src/Umbraco.Web/UmbracoModule.cs @@ -37,7 +37,10 @@ namespace Umbraco.Web { // do not process if client-side request if (IsClientSideRequest(httpContext.Request.Url)) - return; + return; + + //write the trace output for diagnostics at the end of the request + httpContext.Trace.Write("UmbracoModule", "Umbraco request begins"); // ok, process @@ -94,6 +97,8 @@ namespace Umbraco.Web if (!EnsureUmbracoRoutablePage(umbracoContext, httpContext)) return; + httpContext.Trace.Write("UmbracoModule", "Umbraco request confirmed"); + // ok, process var uri = umbracoContext.OriginalRequestUrl; @@ -400,7 +405,7 @@ namespace Umbraco.Web { app.BeginRequest += (sender, e) => { - var httpContext = ((HttpApplication)sender).Context; + var httpContext = ((HttpApplication)sender).Context; BeginRequest(new HttpContextWrapper(httpContext)); }; @@ -417,7 +422,12 @@ namespace Umbraco.Web PersistXmlCache(new HttpContextWrapper(httpContext)); }; - // todo: initialize request errors handler + app.EndRequest += (sender, args) => + { + var httpContext = ((HttpApplication)sender).Context; + //write the trace output for diagnostics at the end of the request + httpContext.Trace.Write("UmbracoModule", "Umbraco request completed"); + }; } public void Dispose()