diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs index 91b420c248..0679fc06c6 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. 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 System.IO; using System.Text; @@ -22,7 +23,7 @@ public class DistributedCacheTagHelperService : IDistributedCacheTagHelperServic private readonly IDistributedCacheTagHelperStorage _storage; private readonly IDistributedCacheTagHelperFormatter _formatter; private readonly HtmlEncoder _htmlEncoder; - private readonly ConcurrentDictionary> _workers; + private readonly ConcurrentDictionary> _workers; public DistributedCacheTagHelperService( IDistributedCacheTagHelperStorage storage, @@ -34,11 +35,11 @@ HtmlEncoder HtmlEncoder _storage = storage; _htmlEncoder = HtmlEncoder; - _workers = new ConcurrentDictionary>(); + _workers = new ConcurrentDictionary>(); } /// - public async Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options) + public async Task ProcessContentAsync(TagHelperOutput output, CacheTagKey key, DistributedCacheEntryOptions options) { IHtmlContent content = null; @@ -55,8 +56,10 @@ public async Task ProcessContentAsync(TagHelperOutput output, stri try { - var value = await _storage.GetAsync(key); - + var serializedKey = Encoding.UTF8.GetBytes(key.GenerateKey()); + var storageKey = key.GenerateHashedKey(); + var value = await _storage.GetAsync(storageKey); + if (value == null) { var processedContent = await output.GetChildContentAsync(); @@ -74,20 +77,62 @@ public async Task ProcessContentAsync(TagHelperOutput output, stri value = await _formatter.SerializeAsync(formattingContext); - await _storage.SetAsync(key, value, options); + + using (var buffer = new MemoryStream()) + { + // The stored content is + // - Length of the serialized cache key in bytes + // - Cache Key + // - Content + + var keyLength = BitConverter.GetBytes(serializedKey.Length); + + await buffer.WriteAsync(keyLength, 0, keyLength.Length); + await buffer.WriteAsync(serializedKey, 0, serializedKey.Length); + await buffer.WriteAsync(value, 0, value.Length); + + await _storage.SetAsync(storageKey, buffer.ToArray(), options); + } content = formattingContext.Html; } else { - content = await _formatter.DeserializeAsync(value); + // Extract the length of the serialized key + byte[] contentBuffer = null; + using (var buffer = new MemoryStream(value)) + { + var keyLengthBuffer = new byte[sizeof(int)]; + await buffer.ReadAsync(keyLengthBuffer, 0, keyLengthBuffer.Length); + + var keyLength = BitConverter.ToInt32(keyLengthBuffer, 0); + var serializedKeyBuffer = new byte[keyLength]; + await buffer.ReadAsync(serializedKeyBuffer, 0, serializedKeyBuffer.Length); + + // Ensure we are reading the expected key before continuing + if (serializedKeyBuffer == serializedKey) + { + contentBuffer = new byte[value.Length - keyLengthBuffer.Length - serializedKeyBuffer.Length]; + await buffer.ReadAsync(contentBuffer, 0, contentBuffer.Length); + } + } - // If the deserialization fails, it can return null, for instance when the - // value is not in the expected format. - if (content == null) + try { - content = await output.GetChildContentAsync(); + if (contentBuffer != null) + { + content = await _formatter.DeserializeAsync(contentBuffer); + } } + finally + { + // If the deserialization fails, it can return null, for instance when the + // value is not in the expected format, or the keys have collisions. + if (content == null) + { + content = await output.GetChildContentAsync(); + } + } } tcs.TrySetResult(content); diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs index 5843dbd38c..15abaab386 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs @@ -21,6 +21,6 @@ public interface IDistributedCacheTagHelperService /// The key in the storage. /// The . /// A cached or new content for the cache tag helper. - Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options); + Task ProcessContentAsync(TagHelperOutput output, CacheTagKey key, DistributedCacheEntryOptions options); } } diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs index 66c114c159..ab2092f8a6 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs @@ -23,6 +23,7 @@ public class CacheTagHelper : CacheTagHelperBase /// Prefix used by instances when creating entries in . /// public static readonly string CacheKeyPrefix = nameof(CacheTagHelper); + private const string CachePriorityAttributeName = "priority"; /// @@ -63,14 +64,15 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu if (Enabled) { - var key = GenerateKey(context); + var cacheKey = CacheTagKey.From(this, context); + MemoryCacheEntryOptions options; while (content == null) { Task result = null; - if (!MemoryCache.TryGetValue(key, out result)) + if (!MemoryCache.TryGetValue(cacheKey, out result)) { var tokenSource = new CancellationTokenSource(); @@ -82,7 +84,7 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu var tcs = new TaskCompletionSource(); - // The returned value is ignored, we only do this so that + MemoryCache.Set(cacheKey, tcs.Task, options); // the compiler doesn't complain about the returned task // not being awaited var localTcs = MemoryCache.Set(key, tcs.Task, options); @@ -167,16 +169,6 @@ internal MemoryCacheEntryOptions GetMemoryCacheEntryOptions() return options; } - protected override string GetUniqueId(TagHelperContext context) - { - return context.UniqueId; - } - - protected override string GetKeyPrefix(TagHelperContext context) - { - return CacheKeyPrefix; - } - private async Task ProcessContentAsync(TagHelperOutput output) { var content = await output.GetChildContentAsync(); diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs index 929f257e56..ad98e6e071 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs @@ -2,14 +2,10 @@ // 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.Security.Cryptography; -using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; -using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.TagHelpers { @@ -122,191 +118,5 @@ public override int Order [HtmlAttributeName(EnabledAttributeName)] public bool Enabled { get; set; } = true; - // Internal for unit testing - protected internal string GenerateKey(TagHelperContext context) - { - var builder = new StringBuilder(GetKeyPrefix(context)); - builder - .Append(CacheKeyTokenSeparator) - .Append(GetUniqueId(context)); - - var request = ViewContext.HttpContext.Request; - - if (!string.IsNullOrEmpty(VaryBy)) - { - builder - .Append(CacheKeyTokenSeparator) - .Append(nameof(VaryBy)) - .Append(CacheKeyTokenSeparator) - .Append(VaryBy); - } - - AddStringCollectionKey(builder, nameof(VaryByCookie), VaryByCookie, request.Cookies, (c, key) => c[key]); - AddStringCollectionKey(builder, nameof(VaryByHeader), VaryByHeader, request.Headers, (c, key) => c[key]); - AddStringCollectionKey(builder, nameof(VaryByQuery), VaryByQuery, request.Query, (c, key) => c[key]); - AddVaryByRouteKey(builder); - - if (VaryByUser) - { - builder - .Append(CacheKeyTokenSeparator) - .Append(nameof(VaryByUser)) - .Append(CacheKeyTokenSeparator) - .Append(ViewContext.HttpContext.User?.Identity?.Name); - } - - // The key is typically too long to be useful, so we use a cryptographic hash - // as the actual key (better randomization and key distribution, so small vary - // values will generate dramatically different keys). - using (var sha = SHA256.Create()) - { - var contentBytes = Encoding.UTF8.GetBytes(builder.ToString()); - var hashedBytes = sha.ComputeHash(contentBytes); - return Convert.ToBase64String(hashedBytes); - } - } - - protected abstract string GetUniqueId(TagHelperContext context); - - protected abstract string GetKeyPrefix(TagHelperContext context); - - protected static void AddStringCollectionKey( - StringBuilder builder, - string keyName, - string value, - IDictionary sourceCollection) - { - if (string.IsNullOrEmpty(value)) - { - return; - } - - // keyName(param1=value1|param2=value2) - builder - .Append(CacheKeyTokenSeparator) - .Append(keyName) - .Append("("); - - var values = Tokenize(value); - - // Perf: Avoid allocating enumerator - for (var i = 0; i < values.Count; i++) - { - var item = values[i]; - builder - .Append(item) - .Append(CacheKeyTokenSeparator) - .Append(sourceCollection[item]) - .Append(CacheKeyTokenSeparator); - } - - if (values.Count > 0) - { - // Remove the trailing separator - builder.Length -= CacheKeyTokenSeparator.Length; - } - - builder.Append(")"); - } - - protected static void AddStringCollectionKey( - StringBuilder builder, - string keyName, - string value, - TSourceCollection sourceCollection, - Func accessor) - { - if (string.IsNullOrEmpty(value)) - { - return; - } - - // keyName(param1=value1|param2=value2) - builder - .Append(CacheKeyTokenSeparator) - .Append(keyName) - .Append("("); - - var values = Tokenize(value); - - // Perf: Avoid allocating enumerator - for (var i = 0; i < values.Count; i++) - { - var item = values[i]; - - builder - .Append(item) - .Append(CacheKeyTokenSeparator) - .Append(accessor(sourceCollection, item)) - .Append(CacheKeyTokenSeparator); - } - - if (values.Count > 0) - { - // Remove the trailing separator - builder.Length -= CacheKeyTokenSeparator.Length; - } - - builder.Append(")"); - } - - protected static IList Tokenize(string value) - { - var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries); - if (values.Length == 0) - { - return values; - } - - var trimmedValues = new List(); - - for (var i = 0; i < values.Length; i++) - { - var trimmedValue = values[i].Trim(); - - if (trimmedValue.Length > 0) - { - trimmedValues.Add(trimmedValue); - } - } - - return trimmedValues; - } - - protected void AddVaryByRouteKey(StringBuilder builder) - { - var tokenFound = false; - - if (string.IsNullOrEmpty(VaryByRoute)) - { - return; - } - - builder - .Append(CacheKeyTokenSeparator) - .Append(nameof(VaryByRoute)) - .Append("("); - - var varyByRoutes = Tokenize(VaryByRoute); - for (var i = 0; i < varyByRoutes.Count; i++) - { - var route = varyByRoutes[i]; - tokenFound = true; - - builder - .Append(route) - .Append(CacheKeyTokenSeparator) - .Append(ViewContext.RouteData.Values[route]) - .Append(CacheKeyTokenSeparator); - } - - if (tokenFound) - { - builder.Length -= CacheKeyTokenSeparator.Length; - } - - builder.Append(")"); - } - } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagKey.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagKey.cs new file mode 100644 index 0000000000..1625c16295 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagKey.cs @@ -0,0 +1,428 @@ +// Copyright (c) .NET Foundation. 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.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers +{ + /// + /// base implementation for caching elements. + /// + public class CacheTagKey : IEquatable + { + private const string CacheKeyTokenSeparator = "||"; + private static readonly char[] AttributeSeparator = new[] { ',' }; + + private string _key; + private string _prefix; + private string _varyBy; + private DateTimeOffset? _expiresOn; + private TimeSpan? _expiresAfter; + private TimeSpan? _expiresSliding; + private IList> _headers; + private IList> _queries; + private IList> _routeValues; + private IList> _cookies; + private bool _varyByUser; + private string _username; + + private string _generatedKey; + private int? _hashcode; + + private CacheTagKey() + { + } + + public static CacheTagKey From(CacheTagHelper tagHelper, TagHelperContext context) + { + var httpContext = tagHelper.ViewContext.HttpContext; + var request = httpContext.Request; + + var cacheKey = new CacheTagKey(); + + cacheKey._key = context.UniqueId; + cacheKey._prefix = nameof(CacheTagHelper); + + cacheKey._expiresAfter = tagHelper.ExpiresAfter; + cacheKey._expiresOn = tagHelper.ExpiresOn; + cacheKey._expiresSliding = tagHelper.ExpiresSliding; + cacheKey._varyBy = tagHelper.VaryBy; + cacheKey._cookies = ExtractCookies(tagHelper.VaryByCookie, request.Cookies); + cacheKey._headers = ExtractHeaders(tagHelper.VaryByHeader, request.Headers); + cacheKey._queries = ExtractQueries(tagHelper.VaryByQuery, request.Query); + cacheKey._routeValues = ExtractRoutes(tagHelper.VaryByRoute, tagHelper.ViewContext.RouteData.Values); + cacheKey._varyByUser = tagHelper.VaryByUser; + + if (cacheKey._varyByUser) + { + cacheKey._username = httpContext.User?.Identity?.Name; + } + + return cacheKey; + } + + public static CacheTagKey From(DistributedCacheTagHelper tagHelper, TagHelperContext context) + { + var httpContext = tagHelper.ViewContext.HttpContext; + var request = httpContext.Request; + + var cacheKey = new CacheTagKey(); + + cacheKey._key = tagHelper.Name; + cacheKey._prefix = nameof(DistributedCacheTagHelper); + + cacheKey._expiresAfter = tagHelper.ExpiresAfter; + cacheKey._expiresOn = tagHelper.ExpiresOn; + cacheKey._expiresSliding = tagHelper.ExpiresSliding; + cacheKey._varyBy = tagHelper.VaryBy; + cacheKey._cookies = ExtractCookies(tagHelper.VaryByCookie, request.Cookies); + cacheKey._headers = ExtractHeaders(tagHelper.VaryByHeader, request.Headers); + cacheKey._queries = ExtractQueries(tagHelper.VaryByQuery, request.Query); + cacheKey._routeValues = ExtractRoutes(tagHelper.VaryByRoute, tagHelper.ViewContext.RouteData.Values); + cacheKey._varyByUser = tagHelper.VaryByUser; + + if (cacheKey._varyByUser) + { + cacheKey._username = httpContext.User?.Identity?.Name; + } + + return cacheKey; + } + + private static IList> ExtractCookies(string keys, IRequestCookieCollection cookies) + { + if (string.IsNullOrEmpty(keys)) + { + return null; + } + + var values = Tokenize(keys); + + if (values.Count == 0) + { + return null; + } + + var result = new List>(); + + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + var cookie = cookies[item]; + if (!string.IsNullOrEmpty(cookie)) + { + result.Add(new KeyValuePair(item, cookie)); + } + } + + return result; + } + + private static IList> ExtractHeaders(string keys, IHeaderDictionary headers) + { + if (string.IsNullOrEmpty(keys)) + { + return null; + } + + var values = Tokenize(keys); + + if (values.Count == 0) + { + return null; + } + + var result = new List>(); + + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + var header = headers[item]; + if (!string.IsNullOrEmpty(header)) + { + result.Add(new KeyValuePair(item, header)); + } + } + + return result; + } + + private static IList> ExtractQueries(string keys, IQueryCollection queries) + { + if (string.IsNullOrEmpty(keys)) + { + return null; + } + + var values = Tokenize(keys); + + if (values.Count == 0) + { + return null; + } + + var result = new List>(); + + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + var query = queries[item]; + if (!string.IsNullOrEmpty(query)) + { + result.Add(new KeyValuePair(item, query)); + } + } + + return result; + } + + private static IList> ExtractRoutes(string keys, RouteValueDictionary routeValues) + { + if (string.IsNullOrEmpty(keys)) + { + return null; + } + + var values = Tokenize(keys); + + if (values.Count == 0) + { + return null; + } + + var result = new List>(); + + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + var routeValue = routeValues[item]; + if (routeValue != null) + { + result.Add(new KeyValuePair(item, routeValue.ToString())); + } + } + + return result; + } + + /// + /// Creates a representation of the key. + /// + /// A uniquely representing the key. + public string GenerateKey() + { + if (_generatedKey != null) + { + return _generatedKey; + } + + var builder = new StringBuilder(_prefix); + builder + .Append(CacheKeyTokenSeparator) + .Append(_key); + + if (!string.IsNullOrEmpty(_varyBy)) + { + builder + .Append(CacheKeyTokenSeparator) + .Append(nameof(_varyBy)) + .Append(CacheKeyTokenSeparator) + .Append(_varyBy); + } + + AddStringCollection(builder, nameof(_cookies), _cookies); + AddStringCollection(builder, nameof(_headers), _headers); + AddStringCollection(builder, nameof(_queries), _queries); + AddStringCollection(builder, nameof(_routeValues), _routeValues); + + if (_varyByUser) + { + builder + .Append(CacheKeyTokenSeparator) + .Append(nameof(_varyByUser)) + .Append(CacheKeyTokenSeparator) + .Append(_username); + } + + return _generatedKey = builder.ToString(); + } + + /// + /// Creates a hashed value of the key. + /// + /// A cryptographic hash of the key. + public string GenerateHashedKey() + { + var key = GenerateKey(); + + // The key is typically too long to be useful, so we use a cryptographic hash + // as the actual key (better randomization and key distribution, so small vary + // values will generate dramatically different keys). + using (var sha = SHA256.Create()) + { + var contentBytes = Encoding.UTF8.GetBytes(key); + var hashedBytes = sha.ComputeHash(contentBytes); + return Convert.ToBase64String(hashedBytes); + } + } + + private static void AddStringCollection( + StringBuilder builder, + string collectionName, + IList> values) + { + if (values == null || values.Count == 0) + { + return; + } + + // keyName(param1=value1|param2=value2) + builder + .Append(CacheKeyTokenSeparator) + .Append(collectionName) + .Append("("); + + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + + builder + .Append(item.Key) + .Append(CacheKeyTokenSeparator) + .Append(item.Value) + .Append(CacheKeyTokenSeparator); + } + + builder.Append(")"); + } + + protected static IList Tokenize(string value) + { + var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries); + if (values.Length == 0) + { + return values; + } + + var trimmedValues = new List(); + + for (var i = 0; i < values.Length; i++) + { + var trimmedValue = values[i].Trim(); + + if (trimmedValue.Length > 0) + { + trimmedValues.Add(trimmedValue); + } + } + + return trimmedValues; + } + + private static int ComputeValuesHashCode( + int hash, + string collectionName, + IList> values) + { + hash = hash * 23 + collectionName.GetHashCode(); + + if (values != null) + { + for (var i = 0; i < values.Count; i++) + { + var item = values[i]; + hash = hash * 23 + item.Key.GetHashCode(); + hash = hash * 23 + item.Value.GetHashCode(); + } + } + + return hash; + } + + public override int GetHashCode() + { + if (_hashcode.HasValue) + { + return _hashcode.Value; + } + + unchecked // Overflow is fine, just wrap + { + int hash = 17; + hash = hash * 23 + _key.GetHashCode(); + hash = hash * 23 + _expiresAfter.GetHashCode(); + hash = hash * 23 + _expiresOn.GetHashCode(); + hash = hash * 23 + _expiresSliding.GetHashCode(); + hash = hash * 23 + _varyBy.GetHashCode(); + hash = hash * 23 + _username.GetHashCode(); + + hash = ComputeValuesHashCode(hash, nameof(_cookies), _cookies); + hash = ComputeValuesHashCode(hash, nameof(_headers), _headers); + hash = ComputeValuesHashCode(hash, nameof(_queries), _queries); + hash = ComputeValuesHashCode(hash, nameof(_routeValues), _routeValues); + + _hashcode = hash; + + return hash; + } + } + + public bool Equals(CacheTagKey other) + { + if (other._key != _key || + other._expiresAfter != _expiresAfter || + other._expiresOn != _expiresOn || + other._expiresSliding != _expiresSliding || + other._varyBy != _varyBy || + !AreSame(_cookies, other._cookies) || + !AreSame(_headers, other._headers) || + !AreSame(_queries, other._queries) || + !AreSame(_routeValues, other._routeValues) || + _varyByUser != other._varyByUser || + _varyByUser && _username != other._username + ) + { + return false; + } + + return false; + } + + private static bool AreSame(IList> values1, IList> values2) + { + if (values1 == values2) + { + return true; + } + + if (values1 == null || values2 == null) + { + return false; + } + + if (values1.Count != values2.Count) + { + return false; + } + + for (var i = 0; i < values1.Count; i++) + { + if (values1[i].Key != values2[i].Key || + values1[i].Value != values2[i].Value) + { + return false; + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs index 94cb218b99..206346066c 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs @@ -73,9 +73,9 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu if (Enabled) { - var key = GenerateKey(context); + var cacheKey = CacheTagKey.From(this, context); - content = await _distributedCacheService.ProcessContentAsync(output, key, GetDistributedCacheEntryOptions()); + content = await _distributedCacheService.ProcessContentAsync(output, cacheKey, GetDistributedCacheEntryOptions()); } else { @@ -109,15 +109,6 @@ internal DistributedCacheEntryOptions GetDistributedCacheEntryOptions() return options; } - - protected override string GetUniqueId(TagHelperContext context) - { - return Name; - } - - protected override string GetKeyPrefix(TagHelperContext context) - { - return CacheKeyPrefix; - } + } } \ No newline at end of file