当文件更改时,自动为 JS、CSS 版本化以更新浏览器缓存






4.70/5 (19投票s)
一个 HttpFilter,它处理动态页面生成的输出,并附加 js、css 等静态文件的最后修改日期,以便在文件更改时让浏览器尽快下载最新版本。

引言
当您更新已在用户浏览器中缓存的 JavaScript 或 CSS 文件时,由于浏览器或中间代理的缓存,大多数用户在一段时间内都无法获取更新。您需要一种方法来强制浏览器和代理下载最新文件。从 Web 服务器的角度来看,除非您更改文件名或通过引入唯一的查询字符串来更改文件 URL,否则没有有效的方法可以跨所有浏览器和代理来做到这一点,这样浏览器/代理就会将它们视为新文件。大多数 Web 开发人员使用查询字符串方法,并使用版本后缀来将新文件发送到浏览器。例如:
<script src="someJs.js?v=1001" ></script>
<link href="someCss.css?v=2001"></link>
为了做到这一点,开发人员必须转到所有 html、aspx、ascx、主页面,找到所有指向已更改的静态文件的引用,然后增加版本号。如果您在某个页面上忘记了这一点,该页面可能会中断,因为浏览器使用了旧的缓存脚本。因此,需要大量的回归测试工作来找出更改某些 CSS 或 js 是否会在整个网站的任何地方中断。
另一种方法是运行一个构建脚本,该脚本扫描所有文件并在网站的每个页面中更新对 JavaScript 和 CSS 文件的引用。但是,此方法不适用于动态页面,在这些页面中,JavaScript 和 CSS 引用是在运行时添加的,例如使用ScriptManager
。
如果您无法知道在运行时将添加哪些 JavaScript 和 CSS 到页面,唯一的选择是在运行时分析页面输出,然后动态更改 JavaScript 和 CSS 引用。
这是一个可以为您执行此操作的HttpFilter
。此过滤器会拦截任何 ASPX 请求,然后自动将 JavaScript 和 CSS 文件在生成的 HTML 中最后修改的日期时间附加进去。它这样做时不会将整个生成的 HTML 存储在内存中,也不会进行任何string
操作,因为这会在高负载下导致 Web 服务器的内存和 CPU 消耗过高。该代码直接与字符缓冲区和响应流一起工作,以使其尽可能快。我已经进行了足够的负载测试,以确保即使每小时命中一个 aspx 页面一百万次,它也不会在每个页面响应时间上增加超过 50 毫秒的延迟。
首先,您需要在Global.asax
文件的Application_BeginRequest
事件处理程序中设置名为StaticContentFilter
的过滤器。
Response.Filter = new Dropthings.Web.Util.StaticContentFilter(
Response,
relativePath =>
{
if (Context.Cache[physicalPath] == null)
{
var physicalPath = Server.MapPath(relativePath);
var version = "?v=" +
new System.IO.FileInfo(physicalPath).LastWriteTime
.ToString("yyyyMMddhhmmss");
Context.Cache.Add(physicalPath, version, null,
DateTime.Now.AddMinutes(1), TimeSpan.Zero,
CacheItemPriority.Normal, null);
Context.Cache[physicalPath] = version;
return version;
}
else
{
return Context.Cache[physicalPath] as string;
}
},
"http://images.mydomain.com/",
"http://scripts.mydomain.com/",
"http://styles.mydomain.com/",
baseUrl,
applicationPath,
folderPath);
}
这里唯一棘手的部分是代理,每当过滤器检测到脚本或 CSS 链接时,它都会触发该代理,并要求您为文件返回版本。您返回的任何内容都会附加在脚本或 css 的原始 URL 之后。因此,这里的代理使用文件的最后修改日期时间生成版本为“?v=yyyyMMddhhmmss”
。它还会缓存文件的版本,以确保它不会在每次页面视图中都进行文件 I/O 请求,以获取文件的最后修改日期时间。
例如,HTML 片段中的以下脚本和 CSS:
<script type="text/javascript" src="scripts/jquery-1.4.1.min.js" ></script>
<script type="text/javascript" src="scripts/TestScript.js" ></script>
<link href="Styles/Stylesheet.css" rel="stylesheet" type="text/css" />
它将被输出为:
<script type="text/javascript" src="scripts/jquery-1.4.1.min.js?v=20100319021342" ></script>
<script type="text/javascript" src="scripts/TestScript.js?v=20110522074353" ></script>
<link href="Styles/Stylesheet.css?v=20110522074829" rel="stylesheet" type="text/css" />
您可以看到,每个文件的最后修改日期时间都生成了一个查询字符串。好的一点是,您不必担心在更改文件后生成顺序版本号。它将采用最后修改日期,而该日期仅在文件更改时才会更改。
我将向您展示的HttpFilter
不仅可以附加版本后缀,还可以为您添加到图像、CSS 和链接 URL 的任何内容添加前缀。您可以使用此功能从不同域加载图像,或从不同域加载脚本,并利用浏览器的并行加载功能来提高页面加载性能。例如,以下标签可以添加任何 URL 作为前缀:
<script src="some.js" ></script>
<link href="some.css" />
<img src="some.png" />
它们可以被输出为:
<script src="http://javascripts.mydomain.com/some.js" ></script>
<link href="http://styles.mydomain.com/some.css" />
<img src="http://images.mydomain.com/some.png" />
从不同域加载 JavaScript、CSS 和图像可以显著缩短页面加载时间,因为浏览器一次只能从一个域加载两个文件。如果您从不同的子域加载 JavaScript、CSS 和图像,而页面本身在 www 子域上,则可以并行加载 8 个文件,而不是仅并行加载 2 个文件。
它们是如何做到的?
过滤器中最困难的部分是拦截对Response
流的字节块写入,并处理这些字节以使其有意义,而无需构造string
。您必须一次读取一个字符,并理解字符序列是否表示<script>
标签,然后找到标签的src
属性,然后提取双引号之间的值。您必须在不使用您喜欢的string
操作函数(如indexOf
、substring
等)的情况下完成所有这些操作。
首先,过滤器会覆盖Stream
的Write
方法。
public override void Write(byte[] buffer, int offset, int count)
{
char[] content;
char[] charBuffer = this._Encoding.GetChars(buffer, offset, count);
/// If some bytes were left for processing during last Write call
/// then consider those into the current buffer
if (null != this._PendingBuffer)
{
content = new char[charBuffer.Length + this._PendingBuffer.Length];
Array.Copy(this._PendingBuffer, 0, content, 0, this._PendingBuffer.Length);
Array.Copy(charBuffer, 0, content, this._PendingBuffer.Length, charBuffer.Length);
this._PendingBuffer = null;
}
else
{
content = charBuffer;
}
到目前为止,没有什么特别之处,但要确保我们总是有完整的缓冲区,其中包含完整的 HTML 标签。例如,如果最后一个Write
调用以不完整的缓冲区结束,该缓冲区在标签的中间结束,例如“<script sr”
,那么我们就需要等待下一个Write
调用并获取更多数据,以便获得一个完整的标签进行处理。
以下循环完成了实际工作:
int lastPosWritten = 0;
for (int pos = 0; pos < content.Length; pos++)
{
// See if tag start
char c = content[pos];
if ('<' == c)
{
pos++;
/* Make sure there are enough characters available in the buffer to finish
* tag start. This will happen when a tag partially starts but does not end
* For example, a partial img tag like <img
* We need a complete tag upto the > character.
*/
if (HasTagEnd(content, pos))
{
if ('/' == content[pos])
{
}
else
{
if (HasMatch(content, pos, IMG_TAG))
{
lastPosWritten = this.WritePrefixIf(SRC_ATTRIBUTE,
content, pos, lastPosWritten, this._ImagePrefix);
}
else if (HasMatch(content, pos, SCRIPT_TAG))
{
lastPosWritten = this.WritePrefixIf(SRC_ATTRIBUTE,
content, pos, lastPosWritten, this._JavascriptPrefix);
lastPosWritten = this.WritePathWithVersion(content, lastPosWritten);
}
else if (HasMatch(content, pos, LINK_TAG))
{
lastPosWritten = this.WritePrefixIf(HREF_ATTRIBUTE,
content, pos, lastPosWritten, this._CssPrefix);
lastPosWritten = this.WritePathWithVersion(content, lastPosWritten);
}
// If buffer was written beyond current position, skip
// upto the position that was written
if (lastPosWritten > pos)
pos = lastPosWritten;
}
}
else
{
// a tag started but it did not end in this buffer. Preserve the content
// in a buffer. On next write call, we will take an attempt to check it again
this._PendingBuffer = new char[content.Length - pos];
Array.Copy(content, pos, this._PendingBuffer, 0, content.Length - pos);
// Write from last write position upto pos. the rest is now in pending buffer
// will be processed later
this.WriteOutput(content, lastPosWritten, pos - lastPosWritten);
return;
}
}
}
逻辑是,遍历字符缓冲区中的每个字符,并查找标签开始符‘<
’。找到后,检查缓冲区是否有标签结束符‘>
’。如果没有,则等待下一次 Write 调用以获取完整的缓冲区。如果我们有一个完整的标签,那么就匹配标签名称,并查看它是否是IMG
、SCRIPT
或LINK
。
它使用纯字符匹配来匹配缓冲区中的标签名称,完全没有string
操作,因此对垃圾回收器没有影响。
private bool HasMatch(char[] content, int pos, char[] match)
{
for (int i = 0; i < match.Length; i++)
if (content[pos + i] != match[i]
&& content[pos + i] != char.ToUpper(match[i]))
return false;
return true;
}
正如您所见,没有string
分配,因此也没有引入新变量。它进行纯字符匹配。
一旦找到它正在寻找的正确标签,它就会从href
或src
属性中查找文件的 URL。然后它检查 URL 是绝对的还是相对的。如果是相对的,那么它就会添加前缀。
/// <summary>
/// Write the prefix if the specified attribute was found and the attribute has a value
/// that does not start with http:// prefix.
/// If atttribute is not found, it just returns the lastWritePos as it is
/// If attribute was found but the attribute already has a fully qualified URL,
/// then return lastWritePos as it is
/// If attribute has relative URL, then lastWritePos is the starting position
/// of the attribute value. However,
/// content from lastWritePos to position of the attribute value
/// will already be written to output
/// </summary>
/// <param name="attributeName"></param>
/// <param name="content"></param>
/// <param name="pos"></param>
/// <param name="lastWritePos"></param>
/// <param name="prefix"></param>
/// <returns>The last position upto which content was written.</returns>
private int WritePrefixIf(char[] attributeName, char[] content,
int pos, int lastWritePos, byte[] prefix)
{
// write upto the position where image source tag comes in
int attributeValuePos = this.FindAttributeValuePos(attributeName, content, pos);
// ensure attribute was found
if (attributeValuePos > 0)
{
if (HasMatch(content, attributeValuePos, HTTP_PREFIX))
{
// We already have an absolute URL. So, nothing to do
return lastWritePos;
}
else
{
// It's a relative URL. So, let's prefix the URL with the
// static domain name
// First, write content upto this position
this.WriteOutput(content, lastWritePos, attributeValuePos - lastWritePos);
// Now write the prefix
if (prefix.Length > 0)
this.WriteBytes(prefix, 0, prefix.Length);
else
{
// Turn this on if you want to emit an absolute URL from the relative URL
//this.WriteBytes(this._BaseUrl, 0, this._BaseUrl.Length);
}
// If the attribute value starts with the application path it needs to be skipped
// as that value should be in the prefix. Doubling it will cause problems. This
// occurs with some of the scripts.
if (HasMatch(content, attributeValuePos, _ApplicationPath))
{
// Absolute path starting with / or /Vdir. So, we need to keep the /Vdir/ part.
attributeValuePos = attributeValuePos + _ApplicationPath.Length;
}
else
{
// Relative path. So, we need to emit the current folder path. eg folder/
if (this._CurrentFolder.Length > 0)
this.WriteBytes(this._CurrentFolder, 0, this._CurrentFolder.Length);
}
// Ensure the attribute value does not start with a leading slash
// because the prefix is supposed to have a trailing slash.
// If value does start with a leading slash, skip it
if ('/' == content[attributeValuePos]) attributeValuePos++;
return attributeValuePos;
}
}
else
{
return lastWritePos;
}
}
代码有详细的注释,所以我不会重复它做了什么。
类似地,通过查看 URL 并将其附加到 URL 来附加版本。
private int WritePathWithVersion(char[] content, int lastPosWritten)
{
// We will do it for relative urls only
if (!HasMatch(content, lastPosWritten, HTTP_PREFIX))
{
int pos = lastPosWritten + 1;
while ('"' != content[pos]) pos++;
// pos is now right before the closing double quote
var relativePath = new string(content, lastPosWritten, pos - lastPosWritten);
// Emit the relative path as is
this.WriteOutput(content, lastPosWritten, pos - lastPosWritten);
lastPosWritten = pos;
// get the last modification date time of the file at relative path
var version = this._getVersionOfFile(relativePath).ToCharArray();
// Add a version number at the end of the path
this.WriteOutput(version, 0, version.Length);
}
return lastPosWritten;
}
它首先提取路径,确保它是相对路径,然后触发回调以获取文件的版本。无论回调返回什么,都会将其附加到相对路径。
它有多快?
它非常快。我做了一些 Visual Studio 性能剖析。VS 说过滤器中的整个代码比 .NET Framework 代码更快,例如从缓存中获取项、调用Server.MapPath()
或获取文件的最后修改日期时间。
如果您查看Write
函数花费的时间细分,大部分时间都花在了获取版本号上。
所有for
循环、if
条件等与调用WritePathWithVersion
相比都微不足道,后者会触发回调以获取每个文件的版本。
在WritePathWithVersion
内部,所有时间都花在了调用回调以获取版本号上。
最后,它表明所有时间都花在执行缓存操作和获取文件的
LastWriteTime
上。这证明过滤器中编写的所有代码都比从缓存中读取一项或获取文件的最后修改日期更快。
当我通过产生 20 个并发用户,每个用户执行 30 次连续调用来进行负载测试时,CPU 消耗(**不**使用过滤器)显示:
这是没有过滤器的。您可以看到,除了 ASP.NET 做它们的工作之外,没有其他东西。CPU 消耗平均在 40% 到 60% 之间。
现在,当我打开过滤器时,CPU 消耗看起来像:
CPU 消耗仍在 40% 到 60% 之间。因此,添加过滤器后对 CPU 没有明显影响。我已经确保过滤器做了足够的工作,产生了大约 200 KB 的页面输出。这确保了Write
被调用了很多次,并且过滤器代码做了很多工作。
我确信了,我该如何使用它?
转到http://code.google.com/p/autojscssversion/并下载示例项目。在App_Code
中,您会找到过滤器。您只需要在Global.asax
的Application_BeginRequest
中注册过滤器,如示例所示。就这样!
结论
您需要缓存浏览器和代理中的 JavaScript、CSS、图像,以提供尽可能快的浏览体验。但这可能意味着您无法更新静态文件并将其交付给所有浏览器,除非您更改文件的 URL。手动更新整个网站的文件引用既困难又容易出错。这个HttpFilter
会自动为您完成。