代码改变世界

一个较完整的关键字过滤解决方案(下)

2009-01-05 14:59 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

在这篇文章里,我们来针对一些问题进行讨论。如果您觉得有哪些您感兴趣但是没有涉及到的问题则请在评论中补充,我会修改文章添加一下内容。

陷阱何在?

首先,我们来分析上一篇文章最后谈到的“陷阱”。很可惜啊,过了两个星期还是没有朋友能够指出这个问题,其实很简单,运行一下就能发觉有异常抛出:

public partial class Default : System.Web.UI.Page, IForbiddenWordFilter
{
    ...

    FilterForbiddenWordType IForbiddenWordFilter.GetFilterType(string key)
    {
        if (key.EndsWith(this.txtPassword.ID)) return FilterForbiddenWordType.Ignored;
        return FilterForbiddenWordType.Normal;
    }
}

在运行至this.txtPassword.ID时会抛出NullReferenceException。其原因就是,我们的FilterForbiddenWordModule在OnPostMapRequestHandler过程中进行调用,而此时Handler对象已经生成(意味着IForbiddenWordFilter.GetFilterType方法已经可以调用),但是直到Handler被执行时this.txtPassword才被实例化(从现象得出的结论,是否确切有待考证),自然会抛出NullReferenceException了。可是我实在想不出一个办法可以在得到this.txtPassword.ID的值,甚至退一步讲,我无法在运行时得到this.txtPassword这个field的名称——即一个字符串“txtPassword”。我不可能直接使用这个字符串常量,这样就会使我们的改进效果付之东流。我们需要通过代码来访问,因为我们需要能够得到编译及重构的支持,不是吗?

起初我很绝望,但是10分钟后忽然灵光一闪,想到了这种方式来获得field的名称:

FilterForbiddenWordType IForbiddenWordFilter.GetFilterType(string key)
{
    Expression<Func<object>> action = () => this.txtPassword;
    var name = (action.Body as MemberExpression).Member.Name;

    if (key.EndsWith(name)) return FilterForbiddenWordType.Ignored;
    return FilterForbiddenWordType.Normal;
}

这是一个非常实用的技巧:通过Lambda表达式来构造一个表达式树,然后通过这个表达式树的成员来获取field的名称。我们享受到了我们所需的便利,因为个中实现已经由编译器完成(或许我会另写文章来阐述一下我在关于这个方面的思维过程)。

适用场合

有的时候我觉得谈适用场合比较虚,因为其实关键是在“思考”。“官方”提出的适用场合并不一定完整和正确,了解了一个解决方案之后慢慢会有更好的体会,甚至更真实。如果一个解决方案是通过一个适用场合引发的,那么这个解决方案的适用场合“似乎”不言而喻。此外,如果一个解决方案是像我们现在的这样一样,从实际出发,再发散,慢慢将功能补充完整,最后几经权衡之后反而有些违背初衷,那么谈适用场合其实就是在谈“理解”,当你理解了这个解决方案的特性,适用场合和不适用场合都可以简单地判断出来。所以再虚还是要谈,至少要摆个样子思考一下。

例如:我们是在输入的时候进行过滤,那么服务器端得到的数据已经是替换后的内容,因此如果你要用户原本输入的内容,肯定就不能采用这个方法。

嗯?完了?当然没完,但是下面就要由您来进行思考了。:)

输入过滤和输出过滤

关于这个问题,讨论得纠结啊。我们现在整理一下输入过滤和输出过滤的优点和缺点(欢迎补充):

输入过滤:

  • 优点:
    • 在输入时控制,需要替换的次数少,性能高。
    • 可控制的粒度小,方便地对于输入定制各种过滤方式。
  • 缺点:
    • 解决方案相对不够普适,有时需要为不同的Handler定制不同替换策略,虽然这点很简单。
    • 无法获得用户原始输入。

输出过滤:

  • 优点:
    • 普适,Plug & Play,过滤一切输出。
    • 可以保留用户原始输入。
  • 缺点:
    • 每次输出都需要替换,性能低下。 

可以发现,基本上输出缓存是在实用性能换取绝对透明、以及。有朋友说某些场景下只能使用输出缓存,不该把它一棒子打死——但是至少也要打个半死不活。原因就在于这个性能问题实在过于难以处理了。

首先是输出过滤时在每次生成HTML时都要对完整的字符串进行替换,首先HTML中大部分的字符是不用替换的(因为是我们自定义的文字或HTML代码),其次每秒过滤数百次大字符串是一个很伤CPU运算的做法——无论在哪个平台下。而避免大量运算的常用手段就是将运算结果保留起来并多次使用。这就是所谓的缓存,可惜……

输出过滤难以缓存。这一点不是因为实现困难而放弃,而是实在是没有好的办法进行缓存。输出过滤往往使用Response.Filter,它的最小单位是“一个Response”,因此我们传统缓存机制中唯一可用的可能只有整页静态化了(连局部内容缓存都无法生效)。现在的Web应用大都“变化多端”,整页静态的适用程度愈发有限,这是由于整页缓存难以设过期依赖,一是依赖项过多,页面所表现的业务中任何一个数据的变化都会造成整页修改,这种业务与页面之间多对多的关系使维度急剧增加,难以操作。再者就是这样的缓存依赖项往往要打通表现层和业务逻辑层甚至数据访问层,在一个设计良好的系统里不能出现这样的状况。

当然输出缓存既然有优点,我们可能也就需要想一些办法来缓解一部分问题。思路就是使用比Response粒度低的输出过滤。例如在CRUD的R方法上做文章,这样内容缓存就变成了数据缓存(关于这两种缓存的优劣我在《输出缓存与CachePanel》一文中有过简单讨论)。如果页面中需要替换的内容部分变化不多,也可以使用一个简单的带有过滤功能的CachePanel来进行此部分工作。但我思考了很久,还是觉得不容易。不知哪位朋友会有更好的想法,只希望能有个确实的示例或说明,而不要简单的一句话思路,似乎有道理却让人无从考究。

如果真要保留用户原始输入,其实我认为最恰当的方式是保留原始拷贝——当然这也很麻烦。弟兄们还是权衡为上。

我们真的需要HttpModule吗?

第一篇文章里我就说了,全站级别的操作,往往解决方案只有一个,那就是HttpModule——其实这句话补充完整应该是:在使用统一模型的解决方案中,可以使用横切的方式来为该模型的数据输入作统一处理。换句话说,假如我们整站都使用了一个统一的自定义模型,那么我们自然可以在这个模型上做文章。如果没有这样的(自定义)模型,那么我们能找到的唯一共同之处就只有“ASP.NET网站”这一点了。此时针对这一模型的横切方式,自然就是HttpModule。

那么我们可能还会有哪些模型呢?至少我们现在已经有一个了:那就是ASP.NET MVC。ASP.NET MVC改变了之前开发ASP.NET站点的理念,它统一了服务器端对于客户端请求处理方式,将请求与方法进行了映射。如果说在使用ASP.NET WebForms时不可避免的需要编写Generic Handler(ashx)来进行非页面的请求处理,那么在ASP.NET MVC中也应该使用同样的Controller-Action方式——如果还出现ashx的话,您就要思考一下这么做的合理性了。好,既然我们将全站统一至ASP.NET MVC模型之上,则接下来要做的就是在它的数据输入方式上做文章了。ASP.NET MVC使用一个名为Model Binder的机制将Request中的数据转化为Action方法的参数。如果我们使用一个自定义的Model Binder,参数构造时进行文字过滤,自然也可以满足我们的要求。

性能

全站替换从感觉上似乎会影响性能,但是细想之下,并没有带来多大损害,因为“需要过滤的地方”它“总归要被过滤”嘛。但是一些措施可能还是需要的:例如在GET请求时不替换Form里的数据(其实本就应该没有数据)、对Handler做合适标记(尽可能减少需要过滤的内容)、在没有替换任何内容时不更新原有集合(减少折腾次数),亦或是Filter on Demand(只在读取某字段时替换内容)。

最后,其实最影响性能的可能就是过滤算法了——这本不该在文章中出现,但是我还是想提一下。字符串操作往往是系统的命门,处理不好会因此大量字符串的产生,加大GC压力,因此StringBuilder自然是不可少的。还有关键的一点是String的Replace方法绝对不可以使用,因为需要过滤的关键字往往不在少数,使用Replace方法会在内存中出现大量的字符串。更进一步,假如有N个关键字,需要过滤一个长度为M的字符串,那么使用Replace方法的时间复杂度至少是O(M * N);而如果换种方式,例如使用前缀树构造一个索引,实现复杂度最多也就是O(M * H)了——H为树的高度,比N要小许许多多,而且与N的数量无关。

关于这一点,第一篇文章一开始引用的两篇文章中方式大体是正确的,值得参考。

相关文章