Skip to content

Commit 7ee7132

Browse files
authored
[StaticWebAssets] Remove multiple file accesses per asset (#43724)
1 parent 2b45b90 commit 7ee7132

File tree

6 files changed

+145
-39
lines changed

6 files changed

+145
-39
lines changed

src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Compression.targets

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,12 @@ Copyright (c) .NET Foundation. All rights reserved.
222222
CandidateAssets="@(_CompressedStaticWebAssets)"
223223
>
224224
<Output TaskParameter="Assets" ItemName="_CompressionBuildStaticWebAsset" />
225+
<Output TaskParameter="AssetDetails" ItemName="_ResolveBuildCompressedStaticWebAssetsDetails" />
225226
</DefineStaticWebAssets>
226227

227228
<DefineStaticWebAssetEndpoints
228229
CandidateAssets="@(_CompressionBuildStaticWebAsset)"
230+
AssetFileDetails="@(_ResolveBuildCompressedStaticWebAssetsDetails)"
229231
ExistingEndpoints="@(StaticWebAssetEndpoint)"
230232
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)"
231233
>
@@ -240,6 +242,7 @@ Copyright (c) .NET Foundation. All rights reserved.
240242

241243
<ApplyCompressionNegotiation
242244
CandidateEndpoints="@(StaticWebAssetEndpoint)"
245+
AssetFileDetails="@(_ResolveBuildCompressedStaticWebAssetsDetails)"
243246
CandidateAssets="@(_CompressionCurrentProjectBuildAssets)"
244247
>
245248
<Output TaskParameter="UpdatedEndpoints" ItemName="_UpdatedCompressionBuildEndpoints" />

src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,10 +672,12 @@ Copyright (c) .NET Foundation. All rights reserved.
672672
BasePath="$(StaticWebAssetBasePath)"
673673
AssetMergeSource="$(StaticWebAssetMergeTarget)">
674674
<Output TaskParameter="Assets" ItemName="StaticWebAsset" />
675+
<Output TaskParameter="AssetDetails" ItemName="_ResolveProjectStaticWebAssetsDetails" />
675676
</DefineStaticWebAssets>
676677

677678
<DefineStaticWebAssetEndpoints
678679
CandidateAssets="@(StaticWebAsset)"
680+
AssetFileDetails="@(_ResolveProjectStaticWebAssetsDetails)"
679681
ExistingEndpoints="@(StaticWebAssetEndpoint)"
680682
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)"
681683
>

src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics;
5+
using System.Globalization;
56
using Microsoft.Build.Framework;
67
using Microsoft.NET.Sdk.StaticWebAssets.Tasks;
78

@@ -15,13 +16,27 @@ public class ApplyCompressionNegotiation : Task
1516
[Required]
1617
public ITaskItem[] CandidateAssets { get; set; }
1718

19+
public ITaskItem[] AssetFileDetails { get; set; }
20+
1821
[Output]
1922
public ITaskItem[] UpdatedEndpoints { get; set; }
2023

2124
public Func<string, long> TestResolveFileLength;
2225

26+
private Dictionary<string, ITaskItem> _assetFileDetails;
27+
2328
public override bool Execute()
2429
{
30+
if (AssetFileDetails != null)
31+
{
32+
_assetFileDetails = new(AssetFileDetails.Length, OSPath.PathComparer);
33+
for (int i = 0; i < AssetFileDetails.Length; i++)
34+
{
35+
var item = AssetFileDetails[i];
36+
_assetFileDetails[item.ItemSpec] = item;
37+
}
38+
}
39+
2540
var assetsById = CandidateAssets.Select(StaticWebAsset.FromTaskItem).ToDictionary(a => a.Identity);
2641

2742
var endpointsByAsset = CandidateEndpoints.Select(StaticWebAssetEndpoint.FromTaskItem)
@@ -55,10 +70,6 @@ public override bool Execute()
5570
}
5671

5772
Log.LogMessage("Processing compressed asset: {0}", compressedAsset.Identity);
58-
59-
var length = TestResolveFileLength != null
60-
? TestResolveFileLength(compressedAsset.Identity)
61-
: new FileInfo(compressedAsset.Identity).Length;
6273
StaticWebAssetEndpointResponseHeader[] compressionHeaders = [
6374
new()
6475
{
@@ -72,9 +83,10 @@ public override bool Execute()
7283
}
7384
];
7485

86+
var quality = ResolveQuality(compressedAsset);
7587
foreach (var compressedEndpoint in compressedEndpoints)
7688
{
77-
if (compressedEndpoint.Selectors.Any(s => string.Equals(s.Name,"Content-Encoding", StringComparison.Ordinal)))
89+
if (compressedEndpoint.Selectors.Any(s => string.Equals(s.Name, "Content-Encoding", StringComparison.Ordinal)))
7890
{
7991
Log.LogMessage(MessageImportance.Low, $" Skipping endpoint '{compressedEndpoint.Route}' since it already has a Content-Encoding selector");
8092
continue;
@@ -102,7 +114,7 @@ public override bool Execute()
102114
{
103115
Name = "Content-Encoding",
104116
Value = compressedAsset.AssetTraitValue,
105-
Quality = Math.Round(1.0 / (length + 1), 12).ToString("F12")
117+
Quality = quality
106118
};
107119
Log.LogMessage(MessageImportance.Low, " Created Content-Encoding selector for compressed asset '{0}' with size '{1}' is '{2}'", encodingSelector.Value, encodingSelector.Quality, relatedEndpointCandidate.Route);
108120
var endpointCopy = new StaticWebAssetEndpoint
@@ -201,6 +213,25 @@ public override bool Execute()
201213
return true;
202214
}
203215

216+
private string ResolveQuality(StaticWebAsset compressedAsset)
217+
{
218+
long length;
219+
if(_assetFileDetails != null && _assetFileDetails.TryGetValue(compressedAsset.Identity, out var assetFileDetail))
220+
{
221+
length = long.Parse(assetFileDetail.GetMetadata("FileLength"));
222+
}
223+
else if (TestResolveFileLength != null)
224+
{
225+
length = TestResolveFileLength(compressedAsset.Identity);
226+
}
227+
else
228+
{
229+
length = new FileInfo(compressedAsset.Identity).Length;
230+
}
231+
232+
return Math.Round(1.0 / (length + 1), 12).ToString("F12", CultureInfo.InvariantCulture);
233+
}
234+
204235
private static bool IsCompatible(StaticWebAssetEndpoint compressedEndpoint, StaticWebAssetEndpoint relatedEndpointCandidate)
205236
{
206237
var compressedFingerprint = compressedEndpoint.EndpointProperties.FirstOrDefault(ep => ep.Name == "fingerprint");

src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics;
5+
using System.IO;
56
using System.Security.Cryptography;
67
using System.Security.Principal;
78
using Microsoft.Build.Framework;
@@ -232,11 +233,13 @@ public void ApplyDefaults()
232233

233234
internal static (string fingerprint, string integrity) ComputeFingerprintAndIntegrity(string identity, string originalItemSpec)
234235
{
235-
using var file = File.Exists(identity) ?
236-
File.OpenRead(identity) :
237-
(File.Exists(originalItemSpec) ?
238-
File.OpenRead(originalItemSpec) :
239-
throw new InvalidOperationException($"No file exists for the asset at either location '{identity}' or '{originalItemSpec}'."));
236+
var fileInfo = ResolveFile(identity, originalItemSpec);
237+
return ComputeFingerprintAndIntegrity(fileInfo);
238+
}
239+
240+
internal static (string fingerprint, string integrity) ComputeFingerprintAndIntegrity(FileInfo fileInfo)
241+
{
242+
using var file = fileInfo.OpenRead();
240243

241244
#if NET6_0_OR_GREATER
242245
var hash = SHA256.HashData(file);
@@ -249,11 +252,14 @@ internal static (string fingerprint, string integrity) ComputeFingerprintAndInte
249252

250253
internal static string ComputeIntegrity(string identity, string originalItemSpec)
251254
{
252-
using var file = File.Exists(identity) ?
253-
File.OpenRead(identity) :
254-
(File.Exists(originalItemSpec) ?
255-
File.OpenRead(originalItemSpec) :
256-
throw new InvalidOperationException($"No file exists for the asset at either location '{identity}' or '{originalItemSpec}'."));
255+
var fileInfo = ResolveFile(identity, originalItemSpec);
256+
return ComputeIntegrity(fileInfo);
257+
}
258+
259+
internal static string ComputeIntegrity(FileInfo fileInfo)
260+
{
261+
using var file = fileInfo.OpenRead();
262+
257263
#if NET6_0_OR_GREATER
258264
var hash = SHA256.HashData(file);
259265
#else
@@ -916,6 +922,24 @@ internal string EmbedTokens(string relativePath)
916922
return pattern.RawPattern;
917923
}
918924

925+
internal FileInfo ResolveFile() => ResolveFile(Identity, OriginalItemSpec);
926+
927+
internal static FileInfo ResolveFile(string identity, string originalItemSpec)
928+
{
929+
var fileInfo = new FileInfo(identity);
930+
if (fileInfo.Exists)
931+
{
932+
return fileInfo;
933+
}
934+
fileInfo = new FileInfo(originalItemSpec);
935+
if (fileInfo.Exists)
936+
{
937+
return fileInfo;
938+
}
939+
940+
throw new InvalidOperationException($"No file exists for the asset at either location '{identity}' or '{originalItemSpec}'.");
941+
}
942+
919943
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
920944
internal class StaticWebAssetResolvedRoute(string pathLabel, string path, Dictionary<string, string> tokens)
921945
{

src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,28 @@ public class DefineStaticWebAssetEndpoints : Task
1818
[Required]
1919
public ITaskItem[] ContentTypeMappings { get; set; }
2020

21+
public ITaskItem[] AssetFileDetails { get; set; }
22+
2123
[Output]
2224
public ITaskItem[] Endpoints { get; set; }
2325

2426
public Func<string, int> TestLengthResolver;
2527
public Func<string, DateTime> TestLastWriteResolver;
2628

29+
private Dictionary<string, ITaskItem> _assetFileDetails;
30+
2731
public override bool Execute()
2832
{
33+
if (AssetFileDetails != null)
34+
{
35+
_assetFileDetails = new(AssetFileDetails.Length, OSPath.PathComparer);
36+
for (int i = 0; i < AssetFileDetails.Length; i++)
37+
{
38+
var item = AssetFileDetails[i];
39+
_assetFileDetails[item.ItemSpec] = item;
40+
}
41+
}
42+
2943
var staticWebAssets = CandidateAssets.Select(StaticWebAsset.FromTaskItem).ToDictionary(a => a.Identity);
3044
var existingEndpoints = StaticWebAssetEndpoint.FromItemGroup(ExistingEndpoints);
3145
var existingEndpointsByAssetFile = existingEndpoints
@@ -87,7 +101,8 @@ public override bool Execute()
87101
private List<StaticWebAssetEndpoint> CreateEndpoints(StaticWebAsset asset, ContentTypeProvider contentTypeMappings)
88102
{
89103
var routes = asset.ComputeRoutes();
90-
var result = new List<StaticWebAssetEndpoint>();
104+
var (length, lastModified) = ResolveDetails(asset);
105+
var result = new List<StaticWebAssetEndpoint>();
91106
foreach (var (label, route, values) in routes)
92107
{
93108
var (mimeType, cacheSetting) = ResolveContentType(asset, contentTypeMappings);
@@ -100,7 +115,7 @@ private List<StaticWebAssetEndpoint> CreateEndpoints(StaticWebAsset asset, Conte
100115
new()
101116
{
102117
Name = "Content-Length",
103-
Value = GetFileLength(asset),
118+
Value = length,
104119
},
105120
new()
106121
{
@@ -115,7 +130,7 @@ private List<StaticWebAssetEndpoint> CreateEndpoints(StaticWebAsset asset, Conte
115130
new()
116131
{
117132
Name = "Last-Modified",
118-
Value = GetFileLastModified(asset)
133+
Value = lastModified
119134
},
120135
];
121136

@@ -187,36 +202,47 @@ private List<StaticWebAssetEndpoint> CreateEndpoints(StaticWebAsset asset, Conte
187202
//
188203
// GMT
189204
// Greenwich Mean Time.HTTP dates are always expressed in GMT, never in local time.
190-
private string GetFileLastModified(StaticWebAsset asset)
205+
private (string length, string lastModified) ResolveDetails(StaticWebAsset asset)
191206
{
192-
var lastWrite = TestLastWriteResolver != null ? TestLastWriteResolver(asset.Identity) : GetFileLastModifiedCore(asset);
193-
return lastWrite.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture);
207+
if (_assetFileDetails != null && _assetFileDetails.TryGetValue(asset.Identity, out var details))
208+
{
209+
return (length: details.GetMetadata("FileLength"), lastModified: details.GetMetadata("LastWriteTimeUtc"));
210+
}
211+
else if (_assetFileDetails != null && _assetFileDetails.TryGetValue(asset.OriginalItemSpec, out var originalDetails))
212+
{
213+
return (length: originalDetails.GetMetadata("FileLength"), lastModified: originalDetails.GetMetadata("LastWriteTimeUtc"));
214+
}
215+
else if (TestLastWriteResolver != null || TestLengthResolver != null)
216+
{
217+
return (length: GetTestFileLength(asset), lastModified: GetTestFileLastModified(asset));
218+
}
219+
else
220+
{
221+
Log.LogMessage(MessageImportance.High, $"No details found for {asset.Identity}. Using file system to resolve details.");
222+
var fileInfo = StaticWebAsset.ResolveFile(asset.Identity, asset.OriginalItemSpec);
223+
var length = fileInfo.Length.ToString(CultureInfo.InvariantCulture);
224+
var lastModified = fileInfo.LastWriteTimeUtc.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture);
225+
return (length, lastModified);
226+
}
194227
}
195228

196-
private static DateTime GetFileLastModifiedCore(StaticWebAsset asset)
229+
// Only used for testing
230+
private string GetTestFileLastModified(StaticWebAsset asset)
197231
{
198-
var path = File.Exists(asset.OriginalItemSpec) ? asset.OriginalItemSpec : asset.Identity;
199-
var lastWrite = new FileInfo(path).LastWriteTimeUtc;
200-
return lastWrite;
232+
var lastWrite = TestLastWriteResolver != null ? TestLastWriteResolver(asset.Identity) : asset.ResolveFile().LastWriteTimeUtc;
233+
return lastWrite.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture);
201234
}
202235

203-
private string GetFileLength(StaticWebAsset asset)
236+
// Only used for testing
237+
private string GetTestFileLength(StaticWebAsset asset)
204238
{
205239
if (TestLengthResolver != null)
206240
{
207241
return TestLengthResolver(asset.Identity).ToString(CultureInfo.InvariantCulture);
208242
}
209243

210-
if (File.Exists(asset.Identity))
211-
{
212-
Log.LogMessage(MessageImportance.Low, $"File {asset.Identity} exists.");
213-
return new FileInfo(asset.Identity).Length.ToString(CultureInfo.InvariantCulture);
214-
}
215-
else
216-
{
217-
Log.LogMessage(MessageImportance.Low, $"File {asset.Identity} does not exist. Using {asset.OriginalItemSpec} instead.");
218-
return new FileInfo(asset.OriginalItemSpec).Length.ToString(CultureInfo.InvariantCulture);
219-
}
244+
var fileInfo = asset.ResolveFile();
245+
return fileInfo.Length.ToString(CultureInfo.InvariantCulture);
220246
}
221247

222248
private (string mimeType, string cache) ResolveContentType(StaticWebAsset asset, ContentTypeProvider contentTypeProvider)

src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Collections.Generic;
5+
using System.Globalization;
56
using System.IO;
67
using System.Linq.Expressions;
78
using System.Net.Http.Headers;
@@ -79,12 +80,16 @@ public class DefineStaticWebAssets : Task
7980
[Output]
8081
public ITaskItem[] CopyCandidates { get; set; }
8182

83+
[Output]
84+
public ITaskItem[] AssetDetails { get; set; }
85+
8286
public override bool Execute()
8387
{
8488
try
8589
{
8690
var results = new List<ITaskItem>();
8791
var copyCandidates = new List<ITaskItem>();
92+
var assetDetails = new List<ITaskItem>();
8893

8994
var matcher = !string.IsNullOrEmpty(RelativePathPattern) ? new Matcher().AddInclude(RelativePathPattern) : null;
9095
var filter = !string.IsNullOrEmpty(RelativePathFilter) ? new Matcher().AddInclude(RelativePathFilter) : null;
@@ -176,22 +181,36 @@ public override bool Execute()
176181
// the asset.
177182
var fingerprint = ComputePropertyValue(candidate, nameof(StaticWebAsset.Fingerprint), null, false);
178183
var integrity = ComputePropertyValue(candidate, nameof(StaticWebAsset.Integrity), null, false);
184+
FileInfo file = null;
179185
switch ((fingerprint, integrity))
180186
{
181187
case (null, null):
182188
Log.LogMessage(MessageImportance.Low, "Computing fingerprint and integrity for asset '{0}'", candidate.ItemSpec);
183-
(fingerprint, integrity) = (StaticWebAsset.ComputeFingerprintAndIntegrity(candidate.ItemSpec, originalItemSpec));
189+
file = StaticWebAsset.ResolveFile(candidate.ItemSpec, originalItemSpec);
190+
(fingerprint, integrity) = (StaticWebAsset.ComputeFingerprintAndIntegrity(file));
184191
break;
185192
case (null, not null):
186193
Log.LogMessage(MessageImportance.Low, "Computing fingerprint for asset '{0}'", candidate.ItemSpec);
187194
fingerprint = FileHasher.ToBase36(Convert.FromBase64String(integrity));
188195
break;
189196
case (not null, null):
190197
Log.LogMessage(MessageImportance.Low, "Computing integrity for asset '{0}'", candidate.ItemSpec);
191-
integrity = StaticWebAsset.ComputeIntegrity(candidate.ItemSpec, originalItemSpec);
198+
file = StaticWebAsset.ResolveFile(candidate.ItemSpec, originalItemSpec);
199+
integrity = StaticWebAsset.ComputeIntegrity(file);
192200
break;
193201
}
194202

203+
if (file != null)
204+
{
205+
// Record the FileLength and LastWriteTimeUtc for the asset so that we don't have to read it again on other tasks
206+
// we'll flow this information to them
207+
assetDetails.Add(new TaskItem(file.FullName, new Dictionary<string, string>
208+
{
209+
["FileLength"] = file.Length.ToString(CultureInfo.InvariantCulture),
210+
["LastWriteTimeUtc"] = file.LastWriteTimeUtc.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture),
211+
}));
212+
}
213+
195214
// If we are not able to compute the value based on an existing value or a default, we produce an error and stop.
196215
if (Log.HasLoggedErrors)
197216
{
@@ -251,6 +270,7 @@ public override bool Execute()
251270

252271
Assets = [.. results];
253272
CopyCandidates = [.. copyCandidates];
273+
AssetDetails = [.. assetDetails];
254274
}
255275
catch (Exception ex)
256276
{

0 commit comments

Comments
 (0)