Hello World
Spiga

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

2009-01-05 14:59 by 老赵, 16652 visits

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

陷阱何在?

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

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的数量无关。

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

相关文章

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

29 条回复

  1. kkun
    *.*.*.*
    链接

    kkun 2009-01-05 15:10:00

    有深度,我能占个沙发吗?

  2. 老赵
    admin
    链接

    老赵 2009-01-05 15:13:00

    --引用--------------------------------------------------
    kkun: 有深度,我能占个沙发吗?
    --------------------------------------------------------
    这……我能说不吗?

  3. Anytao
    *.*.*.*
    链接

    Anytao 2009-01-05 15:16:00

    赶巧抢个位子

  4. Anders Liu
    *.*.*.*
    链接

    Anders Liu 2009-01-05 15:19:00

    终于写完了,恭喜老赵!

  5. 老赵
    admin
    链接

    老赵 2009-01-05 15:34:00

    --引用--------------------------------------------------
    Anders Liu: 终于写完了,恭喜老赵!
    --------------------------------------------------------
    谢谢谢谢,因为接下来又要写LINQ了

  6. Sprite03[未注册用户]
    *.*.*.*
    链接

    Sprite03[未注册用户] 2009-01-05 15:34:00

    --引用--------------------------------------------------
    Jeffrey Zhao: --引用--------------------------------------------------
    kkun: 有深度,我能占个沙发吗?
    --------------------------------------------------------
    这……我能说不吗?
    --------------------------------------------------
    可以说不啊,把他删除了,嘎嘎

  7. 青羽
    *.*.*.*
    链接

    青羽 2009-01-05 15:41:00

    确实是一个完整的方案,从上一直看到下~~

  8. 5yplan
    *.*.*.*
    链接

    5yplan 2009-01-05 16:42:00

    一直没细看,“下”都出来了,下班后得细细看下去。:-)

  9. kkun
    *.*.*.*
    链接

    kkun 2009-01-05 16:55:00

    赵老师您好!
    请教一个初级的问题,使用HttpModule是否可以为所有页面添加一行HTML代码?如JS的引用?
    <script src="/Script/common.js" type="text/javascript"></script>
    并且是加到Head里的?
    非常感谢~

  10. 过路人[未注册用户]
    *.*.*.*
    链接

    过路人[未注册用户] 2009-01-05 16:57:00

    等你的linq~

  11. 老赵
    admin
    链接

    老赵 2009-01-05 16:58:00

    @kkun
    能做到,但不合适——者用Master Page多方便啊

  12. kkun
    *.*.*.*
    链接

    kkun 2009-01-05 17:06:00

    --引用--------------------------------------------------
    Jeffrey Zhao: @kkun
    能做到,但不合适——者用Master Page多方便啊
    --------------------------------------------------------
    项目已经成型...从没自己实现过HttpModule,所以...嘿嘿
    我先自个儿查查MSDN去,不明白了再向您请教~

  13. 上不了岸的鱼{ttzhang}
    *.*.*.*
    链接

    上不了岸的鱼{ttzhang} 2009-01-05 19:36:00

    也凑个热闹学习一把

  14. Microshaoft
    *.*.*.*
    链接

    Microshaoft 2009-01-05 19:40:00

    @kkun
    httpModule global.asax 均可实现
    begin request

    endrequest

    用 userControl 也很方便,只要在 aspx 前代码中加即可代码,理论上你原来的后代码不必重新编译,执行效率应该比前两种高,可以按需添加,不必让 httpmodule Global.asax干预处理每一个请求

  15. 烙馅饼喽
    *.*.*.*
    链接

    烙馅饼喽 2009-01-05 19:52:00

    我有个想法不知道是否可行,字符串过滤时,如果使用unsafe标记,使用指针操作,是否可以提高replace的效率?

  16. 老赵
    admin
    链接

    老赵 2009-01-05 20:33:00

    --引用--------------------------------------------------
    烙馅饼喽: 我有个想法不知道是否可行,字符串过滤时,如果使用unsafe标记,使用指针操作,是否可以提高replace的效率?
    --------------------------------------------------------
    没有任何看法,希望看到此类比较。

  17. 老赵
    admin
    链接

    老赵 2009-01-05 20:35:00

    @Microshaoft
    怎么说呢,Global.asax和HttpModule一个概念的东西,没错。
    但是页面上放UserControl就trick了,而且是只对一个页面生效。

  18. 戏水
    *.*.*.*
    链接

    戏水 2009-01-05 22:50:00

    还是不太明白 你说的 前缀树 怎么个用法。

  19. Kevin-moon
    *.*.*.*
    链接

    Kevin-moon 2009-01-05 23:55:00

    等了N久 终于看完了 呵呵!

  20. 老头2.4[未注册用户]
    *.*.*.*
    链接

    老头2.4[未注册用户] 2009-01-06 00:52:00

    评论没有技术含量,鉴定完毕

  21. 超越自我、永不放弃
    *.*.*.*
    链接

    超越自我、永不放弃 2009-01-06 09:26:00

    @kkun
    用HttpHandlerFactory实现,多方便啊!

  22. airwolf2026
    *.*.*.*
    链接

    airwolf2026 2009-01-06 09:30:00

    在老赵的地盘请教老赵个题外问题哈,关于linq to sql了.
    最近有个项目在用这个.现在遇到一个比较奇怪的问题.
    就是有个表,其中的一个数据库自动生成的字段AutoID,我已经设置成了不同步了,可是还是在插入的时候,给select出来了,现在就是想不让它select出来,后来直接在dbml文件对应的designer.cs文件里面给AutoID字段加上AutoSync=AutoSync.Never就不再Select出来了.

    AutoID属性
    运行输出:

    INSERT INTO [dbo].[DlvRfdMst]([DrNo], [ShopNo], [HelpNo], [DrType], [WhID], [Qty], [Amt], [Status], [Auditor], [AuditTime], [Remark], [OptTime], [StlID], [OptNo], [PortNo])
    VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11, @p12, @p13, @p14)

    SELECT [t0].[AutoID]
    FROM [dbo].[DlvRfdMst] AS [t0]
    WHERE [t0].[DrNo] = @p15
    -- @p0: Input VarChar (Size = 5; Prec = 0; Scale = 0) [11110]
    -- @p1: Input VarChar (Size = 5; Prec = 0; Scale = 0) [00010]
    -- @p2: Input VarChar (Size = 0; Prec = 0; Scale = 0) [Null]

    .....
    问题来了,另外一个表也有类似这样的字段,只是不是数据库自动生成的而已(不是标识字段),同样的设置它就不会select出来,虽然在自动生成的代码文件上给AutoID加属性可以解决,但是一旦那个文件重新生成了,就又要手动添加了.

    另外两个小疑问:
    1.linq to sql 的orm设计器里面给某个表一些字段设置一些属性后,把这个表删除,再拖进来,那些先前设置的属性还在,这是怎么实现的?因为原先的设置都在那个代码自动生成文件里面的,vs是怎么做到的呢?莫非还有其他保存的地方?

    2.就是昨天搜索上面问题的时候,在ScottGu??博客上,看到他回了一个问题是这样说的.
    var orders = db.orders.select();
    foreach(order o in orders)
    {
        o.Name="test";
    }
    db.submitChanges();
    他说上面的操作虽然是有多个Update语句,但是他相信Linq to sql 会一次性把这些语句发给数据库,只要 one trip 就可以,不知道'他相信'的是不是真的,如果是的话,俺就用这样更新就足够我用了.

    呵呵,不好意思问和话题不相干的问题

  23. 老赵
    admin
    链接

    老赵 2009-01-06 09:38:00

    --引用--------------------------------------------------
    超越自我、永不放弃: @kkun
    用HttpHandlerFactory实现,多方便啊!
    --------------------------------------------------------
    要不,您来一个?

  24. 老赵
    admin
    链接

    老赵 2009-01-06 09:41:00

    1、修改文件问题,要设法用自动操作替代,不要手动替换。
    2、VS会有所缓存,去Server Explorer里刷新才行。
    3、没错一次发给数据库

  25. airwolf2026
    *.*.*.*
    链接

    airwolf2026 2009-01-06 09:46:00

    非常感谢老赵的解答,特别是后面两个问题的疑虑给解决了.但是第一个问题,我就是想不手动替换,可是照理说可以的方法,它却不行,我再试试看

  26. www.guyazi.com
    *.*.*.*
    链接

    www.guyazi.com 2009-01-06 15:19:00

    嗯,没仔细看,这个关键字过滤应该比我的好很多,收藏,需要的时候再来好好学习。

  27. 嘿嘿[未注册用户]
    *.*.*.*
    链接

    嘿嘿[未注册用户] 2009-03-08 12:18:00

    楼主能不能给个完整的代码看看,谢谢 weisheng.lu@hotmail.com

  28. 温暖闪耀[未注册用户]
    *.*.*.*
    链接

    温暖闪耀[未注册用户] 2009-06-03 21:27:00

    老赵这是不是太复杂了一点。。。

    单就关键字过滤来说,我采用的是在输入的时候直接过滤,因为一来“读”页面远比“写”页面频繁,二来“写入”的字段和内容是有限的。

    当然问题也有,就是关键字库更新了,那么以前未屏蔽的东西不能再次屏蔽(这个也可以专门写个页面重新刷一遍数据库),或者以前屏蔽现在取消的无法恢复。不过这样的机会不多,实际操作起来还是“写”的时候屏蔽比较方便吧?

  29. 老赵
    admin
    链接

    老赵 2009-06-03 22:01:00

    @温暖闪耀
    嗯,是,其实任何解决方案都有优势和限制。
    例如我这个的优势就是“易用”,一旦加上个Module,就全部进行过滤了,不用一处一处去改——除非有特殊情况。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我