我也遇到了这个问题,并找到了我认为非常令人满意的解决方案。
请注意,使用查询参数.../foo.js?v=1 可能意味着该文件显然不会被某些代理服务器缓存。最好直接修改路径。
我们需要浏览器在内容更改时强制重新加载。因此,在我编写的代码中,路径包含被引用文件的 MD5 哈希值。如果文件重新发布到 Web 服务器但具有相同的内容,则其 URL 是相同的。更重要的是,使用无限期缓存也是安全的,因为该 URL 的内容永远不会改变。
此哈希在运行时计算(并缓存在内存中以提高性能),因此无需修改构建过程。事实上,自从将这段代码添加到我的网站后,我就不必考虑太多了。
您可以在这个网站上看到它的实际效果:Dive Seven - Online Dive Logging for Scuba Divers
在 CSHTML/ASPX 文件中
<head>
@Html.CssImportContent("~/Content/Styles/site.css");
@Html.ScriptImportContent("~/Content/Styles/site.js");
</head>
<img src="@Url.ImageContent("~/Content/Images/site.png")" />
这会生成类似的标记:
<head>
<link rel="stylesheet" type="text/css"
href="/c/e2b2c827e84b676fa90a8ae88702aa5c" />
<script src="/c/240858026520292265e0834e5484b703"></script>
</head>
<img src="/c/4342b8790623f4bfeece676b8fe867a9" />
在 Global.asax.cs 中
我们需要创建一个路由来在这个路径上提供内容:
routes.MapRoute(
"ContentHash",
"c/{hash}",
new { controller = "Content", action = "Get" },
new { hash = @"^[0-9a-zA-Z]+$" } // constraint
);
内容控制器
这节课很长。它的症结很简单,但事实证明,您需要注意文件系统的更改才能强制重新计算缓存的文件哈希。我通过 FTP 发布我的网站,例如,bin 文件夹在 Content 文件夹之前被替换。在此期间请求该站点的任何人(人类或蜘蛛)都会导致旧哈希值被更新。
代码看起来比读/写锁定要复杂得多。
public sealed class ContentController : Controller
{
#region Hash calculation, caching and invalidation on file change
private static readonly Dictionary<string, string> _hashByContentUrl = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, ContentData> _dataByHash = new Dictionary<string, ContentData>(StringComparer.Ordinal);
private static readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
private static readonly object _watcherLock = new object();
private static FileSystemWatcher _watcher;
internal static string ContentHashUrl(string contentUrl, string contentType, HttpContextBase httpContext, UrlHelper urlHelper)
{
EnsureWatching(httpContext);
_lock.EnterUpgradeableReadLock();
try
{
string hash;
if (!_hashByContentUrl.TryGetValue(contentUrl, out hash))
{
var contentPath = httpContext.Server.MapPath(contentUrl);
// Calculate and combine the hash of both file content and path
byte[] contentHash;
byte[] urlHash;
using (var hashAlgorithm = MD5.Create())
{
using (var fileStream = System.IO.File.Open(contentPath, FileMode.Open, FileAccess.Read, FileShare.Read))
contentHash = hashAlgorithm.ComputeHash(fileStream);
urlHash = hashAlgorithm.ComputeHash(Encoding.ASCII.GetBytes(contentPath));
}
var sb = new StringBuilder(32);
for (var i = 0; i < contentHash.Length; i++)
sb.Append((contentHash[i] ^ urlHash[i]).ToString("x2"));
hash = sb.ToString();
_lock.EnterWriteLock();
try
{
_hashByContentUrl[contentUrl] = hash;
_dataByHash[hash] = new ContentData { ContentUrl = contentUrl, ContentType = contentType };
}
finally
{
_lock.ExitWriteLock();
}
}
return urlHelper.Action("Get", "Content", new { hash });
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
private static void EnsureWatching(HttpContextBase httpContext)
{
if (_watcher != null)
return;
lock (_watcherLock)
{
if (_watcher != null)
return;
var contentRoot = httpContext.Server.MapPath("/");
_watcher = new FileSystemWatcher(contentRoot) { IncludeSubdirectories = true, EnableRaisingEvents = true };
var handler = (FileSystemEventHandler)delegate(object sender, FileSystemEventArgs e)
{
// TODO would be nice to have an inverse function to MapPath. does it exist?
var changedContentUrl = "~" + e.FullPath.Substring(contentRoot.Length - 1).Replace("\\", "/");
_lock.EnterWriteLock();
try
{
// if there is a stored hash for the file that changed, remove it
string oldHash;
if (_hashByContentUrl.TryGetValue(changedContentUrl, out oldHash))
{
_dataByHash.Remove(oldHash);
_hashByContentUrl.Remove(changedContentUrl);
}
}
finally
{
_lock.ExitWriteLock();
}
};
_watcher.Changed += handler;
_watcher.Deleted += handler;
}
}
private sealed class ContentData
{
public string ContentUrl { get; set; }
public string ContentType { get; set; }
}
#endregion
public ActionResult Get(string hash)
{
_lock.EnterReadLock();
try
{
// set a very long expiry time
Response.Cache.SetExpires(DateTime.Now.AddYears(1));
Response.Cache.SetCacheability(HttpCacheability.Public);
// look up the resource that this hash applies to and serve it
ContentData data;
if (_dataByHash.TryGetValue(hash, out data))
return new FilePathResult(data.ContentUrl, data.ContentType);
// TODO replace this with however you handle 404 errors on your site
throw new Exception("Resource not found.");
}
finally
{
_lock.ExitReadLock();
}
}
}
辅助方法
如果不使用 ReSharper,可以删除属性。
public static class ContentHelpers
{
[Pure]
public static MvcHtmlString ScriptImportContent(this HtmlHelper htmlHelper, [NotNull, PathReference] string contentPath, [CanBeNull, PathReference] string minimisedContentPath = null)
{
if (contentPath == null)
throw new ArgumentNullException("contentPath");
#if DEBUG
var path = contentPath;
#else
var path = minimisedContentPath ?? contentPath;
#endif
var url = ContentController.ContentHashUrl(contentPath, "text/javascript", htmlHelper.ViewContext.HttpContext, new UrlHelper(htmlHelper.ViewContext.RequestContext));
return new MvcHtmlString(string.Format(@"<script src=""{0}""></script>", url));
}
[Pure]
public static MvcHtmlString CssImportContent(this HtmlHelper htmlHelper, [NotNull, PathReference] string contentPath)
{
// TODO optional 'media' param? as enum?
if (contentPath == null)
throw new ArgumentNullException("contentPath");
var url = ContentController.ContentHashUrl(contentPath, "text/css", htmlHelper.ViewContext.HttpContext, new UrlHelper(htmlHelper.ViewContext.RequestContext));
return new MvcHtmlString(String.Format(@"<link rel=""stylesheet"" type=""text/css"" href=""{0}"" />", url));
}
[Pure]
public static string ImageContent(this UrlHelper urlHelper, [NotNull, PathReference] string contentPath)
{
if (contentPath == null)
throw new ArgumentNullException("contentPath");
string mime;
if (contentPath.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
mime = "image/png";
else if (contentPath.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || contentPath.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase))
mime = "image/jpeg";
else if (contentPath.EndsWith(".gif", StringComparison.OrdinalIgnoreCase))
mime = "image/gif";
else
throw new NotSupportedException("Unexpected image extension. Please add code to support it: " + contentPath);
return ContentController.ContentHashUrl(contentPath, mime, urlHelper.RequestContext.HttpContext, urlHelper);
}
}
感谢您的反馈!