格式化和着色 Web(日志)的 C# 代码






4.76/5 (23投票s)
自动以四种不同的方式发布格式化和着色的 C# 源代码。
引言
我阅读的许多技术博客都非常专业。这并不令人惊讶,因为我是一名软件开发人员,并且经常撰写技术性文章。有些观察、技巧或技术最好通过源代码来演示。在某种程度上,这类似于“一张图片胜过千言万语”,只不过这次是代码胜过千言万语 :-)。
现代开发环境已经让我变得懒惰,以至于我无法像在记事本那样高效地工作,而我最怀念的功能是智能感知和源代码着色。这种“坏习惯”已经延伸到了网络,我发现很难阅读没有着色和格式不佳的源代码。
我见过许多试图解决这个问题的方法。大多数都是基于正则表达式,着色像关键字和字符串,也许还有注释。但这些工具不理解你发布的代码结构,也永远无法正确地着色大部分(如果不是全部)的结构,也无法格式化代码。
隆重推出 Colorizer。
解析很难
为什么现有工具不提供更好的格式化和着色?因为解析很难。在开始开发 Colorizer 之前,我认为我非常了解大多数 C# 语言构造。我错了!在这个项目过程中,我遇到了一些我以前从未见过的构造。我也更欣赏那些构建 C# 编译器的程序员的工作。如果微软的一个团队需要花费大量精力来妥善处理这个问题,那么我怎么能在业余时间独自完成呢?
艾萨克·牛顿爵士曾说过:“如果我看得比别人远,那是因为我站在巨人的肩膀上”,在这种情况下,我站在 Coco-R 的肩膀上。
什么是 Coco-R
它是 **co**mpiler **co**mpiler(我想 coco 就是这个意思)。你可能听说过像 lex 和 YACC 这样的工具——Coco-R 是这些工具的现代化版本。它最棒的地方在于,它支持移植到多种语言,**包括** C#。这非常方便,因为你在解析过程中可能进行的任何额外处理都可以用工具本身编写的语言(即 C#)来完成。
它是如何工作的?让我们从一个例子开始。假设你想为 Pascal 编写一个编译器/解析器。这种语言有自己的语法(就像口语一样,只不过语法更简单)。有一种广为人知的表示语法的方式,称为(扩展)巴科斯-诺尔形式。Coco-R 的输入称为属性文法,它是基于 EBNF 符号设计的,看起来是这样的
Block = "begin" (. Console.Write("Inside a block!"); .) {Statement} "end" .
VarDeclaration = Ident {',' Ident} ':' Type ';'.
这描述了一个 `Block` ——它以文本“begin”开头,后跟零个或多个 `Statement`,然后是文本“end”。`Statement` 本身也需要进一步定义,直到语言的所有构造都以这种方式描述为止。第二行描述了变量声明的结构——它由一个或多个用逗号分隔的标识符组成,后跟冒号,然后是变量的类型,最后是一个分号——你就明白了。你还可以看到嵌入在 `(.` 和 `.)` 之间的 C# 代码。在编写完文法后,将其输入 Coco-R,Coco-R 会生成能够解析该文法所描述的语言(仅此语言,它是“硬编码”到其中的)的 C# 代码。上面嵌入的 C# 代码被插入到适当的位置,成为解析器的一部分——例如,在上面的例子中,当解析器在解析 `Block` 构造时找到文本“begin”后,你的代码将向控制台输出“Inside a block!”。
Coco-R 的作者已经为 C# 生成了文法文件,因此生成 C# 的解析器非常简单。由于你可以在解析器中嵌入自己的代码,我正是这样做的——当识别出每个构造时,根据我内部保留的信息,我会将带有格式化和着色信息的代码(我只是将代码包装在带有命名 CSS 类的 `span` 元素中,以便我可以使用 CSS 自定义颜色)添加到 `StringBuilder` 的一个实例中。我内部保留的一些信息是
- 嵌套深度——如果我在一个块内(以及在哪个级别),这样我就可以增加/减少缩进。
- 作用域(命名空间、类或函数),以便正确识别局部变量或字段。
- pragma 信息,以便我能够启用区域展开/折叠。
请注意,当你有一个小的代码片段时,不可能做到 100% 正确的解析,所以在某些地方我不得不根据我获得的所有信息进行猜测。无论如何,我的代码与 Coco-R 的其余代码是分开的,并在文件 `CSharp.atg`(Coco-R 提供的 C# 1.1 的属性文法,我对其进行了修改)中标记为“My auxiliary methods”,因此你可以更详细地检查它。管道如下
- C# 文法(带有我嵌入的代码)»» Coco-R »» C# 解析器/格式化器/着色器。
- C# 源代码(要发布到 Web 的代码)»» C# 解析器/格式化器/着色器 »» HTML 片段。
Coco-R 生成的解析器包含三个重要类——`Scanner`、`Parser` 和 `Errors`。它们都包含(非常少量的)静态方法/字段,并且易于使用(这是一个我稍后会经常引用的包含核心解析例程的辅助类)
internal class Helper
{
private static Object _lock = new Object();
public static String CodePath
{
get { return ConfigurationSettings.AppSettings["CodePath"]; }
}
public static String StylePath
{
get { return ConfigurationSettings.AppSettings["StylePath"]; }
}
public static String FromFile(String path)
{
lock (_lock)
{
Scanner.Init(path);
return Colorize();
}
}
private static String Colorize()
{
Parser.Reset();
Parser.Parse();
if(0 == Errors.count)
return Parser.Colorized;
else
return String.Format(@"Parse complete -- {0} error(s) detected",
Errors.count);
}
public static String FromString(String code)
{
lock (_lock)
{
using(MemoryStream ms = new MemoryStream(Encoding.UTF8.GetBytes(code)))
{
Scanner.Init(ms);
return Colorize();
}
}
}
}
你总是需要初始化 `Scanner`,可以使用文件路径或 `Stream`。为了简化,我添加了两个小的包装器:`FromFile` 和 `FromString`;后者将 `String` 包装到 `Stream` 中并以此初始化。然后你使用 `Reset` 重置解析器状态(将错误计数重置为零并清除上述提到的 `StringBuilder`)。然后你调用 `Parse` 并检查 `Errors.count` 中的错误计数。如果计数为零,你将在 `Colorized` 属性中找到结果,该属性返回着色代码的文本(基本上是获取上述 `StringBuilder` 的字符串值)。`lock` 块的存在是为了防止你在解析一个片段时又开始解析另一个片段——别忘了,ASP.NET 会从线程池中随机一个线程来执行你的页面代码,而且由于我们想从 ASP.NET 应用程序中使用这段代码,所以我们需要确保解析是串行的。有许多小细节我不得不处理,甚至稍微改变一些文法规则,以便能够正确识别每种可能的上下文。但结果是一个功能齐全的 C# 解析器,在此基础上还加上了格式化和着色。所有颜色都可以通过单个 CSS 文件(`style.css`)进行自定义,这里提供了一个摘录
pre.code .key /* keyword: this, for, if, while... */
{
color:Blue;
}
pre.code .typ /* type: any FCL type or your type */
{
color:Navy;
}
pre.code .met /* method */
{
color:Maroon;
}
pre.code .var /* local variable, or parameter */
{
color:Gray;
}
pre.code .str /* hard-coded string (not variables of type string!) */
{
color:Olive;
}
pre.code .num /* hard-coded number (not variables of numeric types!) */
{
color:Olive;
}
pre.code .val /* enumeration values */
{
color:Purple;
}
当前的局限性和额外功能
某些符号无法正确识别,因为它们定义在后面。例如,你在类的方法中使用了一个 `enum`,但这个 `enum` 定义在方法之后。这段代码是合法的,但解析器直到遇到这个 `enum` 才“看到”它。解决方案是使用两遍扫描,但为了简单起见,我只选择了一遍。在提供的示例代码中,你实际上可以看到这个问题在实际应用中。
格式规则不可自定义。目前无法指定你是否喜欢将开头的花括号放在同一行,是否在函数调用和开括号之间添加空格等。如果你想修改提供的代码,这一点很容易改变。当前的默认设置是尽可能少地使用空格,同时保持可读性,始终将花括号放在新行上,并使用两个空格进行缩进。
目前不支持 C# 2.0(泛型、迭代器、部分类等)。Coco-R 作者最近为 C# 2.0 生成了文法,这使得这项工作容易得多。基本上,你只需要做和我为 C# 1.1 所做的工作差不多,并将其集成到这个文法中。如果有人对此代码感兴趣,我可能会在后续版本中实现这一点。
如果你有一个相对较大的 C# 代码片段,不用担心——我已经添加了对区域的支持。只需一点 JavaScript(注意,因此客户端必须启用 JavaScript),你就可以像在 Visual Studio 中一样折叠/展开区域(脚本在 `code.js` 文件中)!而且,这段代码在 Internet Explorer 和 Mozilla Firefox(分别测试了 6.0 和 1.0 版本)中都可以工作。
使用解析器
既然我们有了一个简单的方法来做我们想做的事情(格式化和着色 C# 源代码),我们如何尽可能轻松地使用它呢?事实证明,有四种基本方法可以使用这段代码
- 静态解析源代码,生成 HTML 文件供直接使用。
- ASP.NET 处理程序,可以即时解析任何 `*.cs` 文件。
- ASP.NET Web 控件,指向磁盘上的源代码文件或直接包装代码,即时解析。
- ASP.NET 模块,即时解析输出流中专门标记的代码段。
让我们逐一 بررسی这些解决方案。
静态解析
这是最简单的方法——我提供了一个简单的控制台应用程序,它接受 C# 源文件的路径,并生成一个具有所需名称的 HTML 文件。代码包括核心解析例程(见上文)和一些命令行选项处理——大约十几行代码。生成的 HTML 包含对 JavaScript 代码(区域处理)和 CSS 样式表的引用,因此这两个文件必须与生成的 HTML 文件保存在同一个目录中(这两个文件都在源文件下载包中提供)。虽然灵活性最低,但这种方法提供了最佳性能——所有文件在发布到 Web 之前都已处理完毕。
ASP.NET 处理程序
拥有一个处理程序比预处理 C# 源文件更灵活一些,但它会带来性能损失——现在每次用户访问 C# 源文件时都会解析你的代码。如果你的访问者经常浏览网站,你可能想使用一些缓存来分摊这种性能损失。你为什么要直接暴露 C# 源代码?嗯,也许你有一个公开的(或者仅供你使用的)源代码存储库的 Web 视图,其中的文件一直在变化,而你不想在它们每次更改时都(重新)处理它们。请注意,默认情况下,ASP.NET 明确**禁止**客户端直接在 URL 中访问 `*.cs` 文件,可能是为了防止你意外地将网站源代码泄露给访问者。查看 `machine.config` 文件,它应该位于 `<windows_folder>\Microsoft.NET\Framework\<framework_version>\config\machine.config`。**不要**修改此文件!ASP.NET 架构允许你在非常精细的级别上设置配置,直到你网站文件夹层次结构的最后一个子文件夹。`machine.config` 提供了合理的默认设置,你可以随时覆盖它们。我们要覆盖的设置在以下部分
<httpHandlers>
<!-- ... -->
<add verb="*" path="*.config" type="System.Web.HttpForbiddenHandler"/>
<add verb="*" path="*.cs" type="System.Web.HttpForbiddenHandler"/>
<add verb="*" path="*.csproj" type="System.Web.HttpForbiddenHandler"/>
<add verb="*" path="*.vb" type="System.Web.HttpForbiddenHandler"/>
<add verb="*" path="*.vbproj" type="System.Web.HttpForbiddenHandler"/>
<!-- ... -->
</httpHandlers>
正如你所见,许多源代码文件都与 `HttpForbiddenHandler` 关联,这意味着你无法在 URL 中使用它们。我们仍然可以在自己的 `web.config` 文件中使用以下行来允许这样做
<httpHandlers>
<add verb="GET" path="*.cs"
type="NanoBriq.Colorizer.Web.Handler, NanoBriq.Colorizer.Web"/>
</httpHandlers>
现在,任何以 `*.cs` 结尾的 URL 都将由 `NanoBriq.ColorizerWeb.Handler` 处理。实现处理程序非常简单——你只需继承 `System.Web.IHttpHandler` 并实现一个方法和一个属性,如下所示
namespace NanoBriq.Colorizer.Web
{
public class Handler : IHttpHandler
{
public Boolean IsReusable
{
get { return false; }
}
void ProcessRequest(HttpContext context)
{
String path = context.Server.MapPath(context.Request.FilePath);
context.Response.Write("<html><head><script>");
context.Response.WriteFile(Helper.CodePath);
context.Response.Write("</script><style>");
context.Response.WriteFile(Helper.StylePath);
context.Response.Write("</style></head><body>");
context.Response.Write(Helper.FromFile(path));
context.Response.Write("</body></html>");
}
}
}
归根结底(再次)就是使用上面的核心解析例程,不多也不少。我们将 JavaScript 和 CSS 内嵌在 `
` 标签中,仅此而已。从现在开始,如果你输入类似 `https:///colorizer/demo.cs` 的内容(假设你的虚拟目录设置为“colorizer”并且包含名为 `demo.cs` 的文件),你将获得格式精美、着色良好的 C# 源代码。ASP.NET Web 控件
对整个 C# 源文件进行着色很好而且效果也很好,但有时你需要更多的灵活性。也许你经常写文章,有一个博客,甚至有一个像 CodeProject 这样的网站,其他人在上面贡献技巧和窍门。如果是这样,你可能会有很多带有嵌入代码片段的文本,而你仍然想格式化和着色它们。对于所有 Web 服务器在你控制之下的情况,你可以频繁地构建自己的 `*.aspx` 页面,那么这个解决方案就很合适。你会这样做
<%@ Page language="c#" %>
<%@ Register TagPrefix="nbc"
Namespace="NanoBriq.Colorizer.Web" Assembly="NanoBriq.Colorizer.Web" %>
<html>
<head></head>
<body>
<form id="frm1" runat="server">
<p>Some great programming technique...</p>
<nbc:WebUIControl id="ctlr1" Path="Demo.cs"/>
<p>More of the same...</p>
<nbc:WebUIControl id="ctlr2">// Some inline code
String fileName;
Boolean itIs = Path.IsRooted(fileName);
// ...
</nbc:WebUIControl>
<p>Closing thoughts...</p>
</form>
</body>
</html>
你可以通过两种方式使用此控件——通过 `SourcePath` 属性指向磁盘上的文件(ID 为 `ctrl1` 的控件),或者将一些 C# 代码内联(ID 为 `ctrl2` 的控件)。别忘了使用 `<%@ Register %>` 指令将颜色器控件注册到 ASP.NET——从那时起,你就可以像使用 ASP.NET 控件一样使用它了。实现一个简单的 Web 控件并不难。
namespace NanoBriq.Colorizer.Web
{
public class WebUIControl : Control
{
private String _path;
public String SourcePath
{
set { _path = value; }
}
protected override void OnInit(EventArgs e)
{
String code, style;
using (TextReader tr = new StreamReader(Helper.CodePath))
code = tr.ReadToEnd();
Page.RegisterClientScriptBlock("CodeClientBlock",
"<script>" + code + "</script>");
using (TextReader tr = new StreamReader(Helper.StylePath))
style = tr.ReadToEnd();
Page.RegisterClientScriptBlock("StyleClientBlock",
"<style>" + style + "</style>");
}
protected override void Render(HtmlTextWriter writer)
{
String toOpen = _path;
if(null != _path && "" != _path)
{
if(!Path.IsPathRooted(_path))
toOpen = Context.Server.MapPath(_path);
writer.Write(Helper.FromFile(toOpen));
}
else if(1 == Controls.Count && Controls[0] is LiteralControl)
{
toOpen = HttpUtility.HtmlDecode(((LiteralControl)Controls[0]).Text);
writer.Write(Helper.FromString(toOpen));
}
}
}
}
最低限度,你应该实现 `Render`,但在本例中,我们需要在初始化方法 `OnInit` 中做更多的事情。问题在于我们需要嵌入 CSS 和 JavaScript,但又不希望在单个页面上有多个自定义控件时多次嵌入它们。因此,在 `OnInit` 中,我们调用 `Page.RegisterClientScriptBlock`,这将确保如果使用相同的键(第一个参数)多次调用,页面不会最终出现多个相同值的副本(第二个参数)。Web 控件的核心功能(再次)无非是核心解析例程,并检查 `SourcePath` 属性是否已设置或控件是否包含嵌入的源。
ASP.NET 模块
最后,如果你想要最灵活的解决方案,那么你会选择这条路。最后一个方法的问题在于,你不能总是确保你的代码片段嵌入在你的控件中(或从它引用)。例如,你有一个博客,你通过允许你使用 WYSIWYG 模式或 HTML 模式的内部控件进行编辑,但两者都不假设你会将 **code** 添加到你的 `aspx` 页面——它们都只是 **content**。你在这里能做的最好的事情就是用一个特殊的标签来标记你的代码片段,例如 `
...`。请注意,这与你在向本网站提交文章时所做的事情非常相似——文章提交规则规定,如果你想让你的代码片段着色,你应该将它们包装在 `` 块中。思路是处理所有输出,找到这些特殊的标签,并即时解析/格式化/着色代码。这非常强大,但性能损失最大,因为现在你所有的出站内容都要检查是否存在这些特殊标签。我们可以通过只检查某些内容类型来在一定程度上分摊成本,但在最常见的情况下,几乎所有页面都是 HTML 内容类型,所以要记住这一点。不要将此解决方案与之前列出的解决方案混在一起!你可能会尝试解析两次相同的代码(如果 `
` 标签使用相同的 `class` 属性),这可能导致各种奇怪的结果。模块实现看起来是这样的namespace NanoBriq.Colorizer.Web { public class Module : IHttpModule { public void Init(HttpApplication context) { context.BeginRequest += new EventHandler(OnBeginRequest); } private void OnBeginRequest(Object sender, EventArgs args) { HttpApplication context = sender as HttpApplication; context.Response.Filter = new Filter(context.Response.Filter); } public void Dispose() { } } }这是输出过滤的经典方法——ASP.NET 内置了对它的支持。首先,你需要订阅 `BeginRequest` 事件,该事件将在每次新请求进来时触发——这是处理程序 `Init` 方法的好地方。然后,你构建自定义的 `Stream` 派生类,并将其放入 `Response.Filter` 属性中,确保先保存先前的值(我将其保存在我的 `Filter` 类的 `_inner` 成员中)。现在所有输出都将通过你的代码,你可以选择只是传递它或修改它。`Stream` 是一个抽象类,它有很多方法和字段,但其中大多数可以实现为简单的转发到 `_inner` `Stream`。有趣的事情发生在 `Write` 方法中
internal class Filter : Stream { private Stream _inner; private StringBuilder _toParse = new StringBuilder(1024); private Int32 _colorized = 0; internal Filter(Stream inner) { _inner = inner; } private String AddScriptStyle(Match match) { String code, style; StringBuilder whole = new StringBuilder(); whole.Append("<head>").Append(match.Groups["head"].Value); using (TextReader tr = new StreamReader(Helper.CodePath)) code = tr.ReadToEnd(); whole.Append("<script>").Append(code).Append("</script>"); using (TextReader tr = new StreamReader(Helper.StylePath)) style = tr.ReadToEnd(); whole.Append("<style>").Append(style).Append("</style></head>"); return whole.ToString(); } private String ColorizeCodeSegment(Match match) { _colorized++; return Helper.FromString(HttpUtility.HtmlDecode(match.Groups["toParse"].Value)); } public override void Write(byte[] buffer, int offset, int count) { String piece = Encoding.UTF8.GetString(buffer, offset, count); _toParse.Append(piece); if(!Regex.IsMatch(piece, "</html>", RegexOptions.IgnoreCase)) return; String result = Regex.Replace(_toParse.ToString(), @"<pre\s+class\s*=\s*['""]csharp_source[""']\s*" + @">(?<toParse>[\w\s\W\S]*?)</pre>", new MatchEvaluator(ColorizeCodeSegment), RegexOptions.IgnoreCase); if(_colorized > 0) result = Regex.Replace(result, @"<head>(?<head>[\w\s\W\S]*?)</head>", new MatchEvaluator(AddScriptStyle), RegexOptions.IgnoreCase); Byte[] all = Encoding.UTF8.GetBytes(result); _inner.Write(all, 0, all.GetLength(0)); } // ... more methods ... }我没有实现一个复杂的有限状态机来跟踪我们是否在自定义 `
` 块的开始、内部或外部,而是决定等到找到 `` 结束标签,并缓冲到该点为止的所有输出。为了匹配自定义 `` 标签并一次性获取其内部文本,我使用了一个有点复杂的正则表达式,用英语来说就是“匹配所有(类和 = 之间有一些空格,并且可能使用撇号而不是引号),然后匹配(并存储到命名组 '`”。每页可能存在多个此类块——我们需要将每个块替换为格式化和着色的代码。.NET 中的 Regex 支持非常强大,它允许我们使用 `Regex.Replace` 在一行代码中完成所有操作!对于每个匹配的块,我们的评估器将通过 `MatchEvaluator` 委托被调用,而它返回的内容将替换匹配的块——这非常适合我们的需求!由于命名组“toParse
' 中)直到匹配 `toParse
”,提取要解析的代码非常简单,其余的则是标准的(第四次提到的)核心解析例程 :-) 我们还需要将脚本和 CSS 内容添加到 `` 元素中(只需将其附加到已有的内容中)。为了使我们的模块生效,我们需要将以下内容添加到 `web.config`——只为你想要进行此处理的文件夹执行此操作!<httpModules> <add name="ColorizerModule" type="NanoBriq.Colorizer.Web.Module, NanoBriq.Colorizer.Web"/> </httpModules>最后一件事——我假设你的所有文件和页面都使用 UTF-8 编码。如果不是这样,请使用 `Response.ContentEncoding` 或确保你的文件已保存,以便你可以正确检测编码。
包中有什么
源代码下载包含了构建和使用颜色器所需的一切。由于复杂的构建要求——我们需要先构建 Coco-R,然后从属性文法构建解析器源,然后是核心解析代码,最后是 Web 组件——我使用了 NAnt 进行构建。使用的版本是 0.85 RC3,它与最终版本之间应该没有重大差异,但请记住这一点。我还提供了一个测试文件夹,其中包含一些 `*.aspx` 文件来测试代码。你所需要做的就是创建一个虚拟目录并指向这个测试文件夹,并在 `web.config` 中配置 JavaScript 区域代码和 CSS 样式表的路径。就是这样!希望你喜欢使用这段代码,就像我喜欢编写它一样。
历史
- 2005 年 3 月 13 日:版本 1.0。
- 2005 年 7 月 8 日:版本 1.01。感谢 Hanspeter Mössenböck(Coco/R 的作者)提供了一个用于正确检测 C 风格注释的解决方法。