代码改变世界

按月统计博客园单个用户的发文数量

2010-01-11 00:07 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

这几天在家闲着,便试着写一些小程序。之前有朋友问到“F#能不能写Web”,于是我也就打算这么一试。虽然我能肯定,用F#写Web应用程序不会是问题,不过倒真还没有做过这方面的尝试。我想,如果用F#写Web应用程序,那么它很重要的一点,应该是利用其在异步编程方面的强大特性。最后我决定,使用F#编写一个按月统计博客园单个用户发文数量的简单服务。尝试的结果是——还有些问题没有解决。不管怎么样,我先把其主体逻辑描述一下吧。

按月统计博客园单个用户的发文数量,这个并不困难,只要利用博客园的“按月汇总”页面就行了——前几天我也利用这个页面来捕获我所有文章ID,这次还是使用这个方法。由于这是一个打算公开的服务,为了性能着想我打算利用起博客园的gzip压缩,因此我们这里为WebClient类扩展一个异步获取数据流的GetDataAsync函数:

type WebClient with 

    member c.GetDataAsync(url) =
        async {
            do c.DownloadDataAsync(new Uri(url))
            let! args = Async.AwaitEvent(c.DownloadDataCompleted)
            return args.Result
        } 

当得到了页面的数据流之后,我们便可以使用GZipStream将其解压缩了。如此,我们便可以写一个函数,生成一个异步工作流,其效果是返回某个月指定用户所有文章的URL:

let getPostUrlsAsync alias (beginMonth: DateTime) (endMonth: DateTime) = 

    if (beginMonth > endMonth) then
        failwith "beginMonth must smaller then or equals to endMonth"

    let getPostUrlsAsync' alias (m: DateTime) = 
        async {
            let webClient = new WebClient();
            webClient.Headers.Add(HttpRequestHeader.AcceptEncoding, "gzip")

            let url = sprintf "http://%s.cnblogs.com/archive/%i/%i.html" alias m.Year m.Month
            let! data = webClient.GetDataAsync(url)
            
            let rawStream = new MemoryStream(data)
            let gzipStream = new GZipStream(rawStream, CompressionMode.Decompress)
            let reader = new StreamReader(gzipStream)
            let html = reader.ReadToEnd()

            let regex = @"..."
            return [ for m in Regex.Matches(html, regex) -> m.Groups.Item(1).Value ]
        }

    ...

在getPostUrlsAsync'函数中,我们首先拼接出该用户月份汇总页的URL——使用子域名的方式。博客园有两种方式可以访问某个博客,一是子域名,二是普通的URL形式。在这里我使用子域名的访问方式是避免.NET类库中对单个域名2个连接的限制。现在这个服务可以同时统计不同用户的信息而不会冲突。不过对于单个用户,还是只能同时出现2个连接——嗯,这是个Feature,避免在虚拟主机上消耗太多资源的Feature。

上面还省略了一个正则表达式,其实它是这样的:

<a\s[^>]*href=["|'](http://www.cnblogs.com/\w+/archive/\d{4}/\d{2}/\d{2}/[^.]*\.html)["|'][^>]*>\s*阅读全文\s*</a>

这个正则表达式实在不容易找。博客园的不同皮肤的HTML可能截然相反,我原本以为都会有EditPosts.aspx?postid=12345这样的链接,但事实证明……有些模板中这样的链接是使用JavaScript生成的,于是“此路不通”。后来我又想通过“评论”链接来捕获URL,但发现有的皮肤使用#FeedBack,有的却使用#Comments。总之,很难统一起来便是了。

最后,我决定通过使用每篇文章中的“阅读全文”链接来识别一篇文章——便是这样,您从这个正则表达式中也可以发现,这是一个较为宽泛的Pattern,甚至href属性还要分单引号和双引号两种情况。这个方法有缺陷,因为对于一些非常短的文章,博客园是不会为其生成“阅读全文”链接的——不过,这也算是个Feature吧,太短的文章咱就不算了。:P

接下来便是从beginMonth和endMonth参数中收集任务,并进行“汇总”了:

let getPostUrlsAsync alias (beginMonth: DateTime) (endMonth: DateTime) = 

    ...

    let executeAsync tasks =
        let rec executeAsync' (tasks: (_ * Async<_>) list) acc = 
            async {
                match tasks with
                | [] -> return acc |> List.rev
                | (pre, task) :: ts ->
                    let! result = task
                    return! executeAsync' ts ((pre, result) :: acc)
            }
        
        executeAsync' tasks List.empty

    Seq.initInfinite (fun i -> endMonth.AddMonths(-i))
    |> Seq.takeWhile (fun m -> m >= beginMonth)
    |> Seq.map (fun m -> (m, getPostUrlsAsync' alias m))
    |> Seq.toList
    |> executeAsync

F#与Haskell不同,它不是延迟的语言,因此它的List必须是有限的,即时生成的。不过Seq便不同了,F#中的Seq可以认作是IEnumerable的对应物,可以是无限的,而Seq.initInfinite便是初始化这样一个无限的序列。不过Seq.takeWhile却只是取到这个序列大于等于beginMonth的那些元素——然后我们再将其映射成月份与“获取单月文章URL”这个异步工作流的“元组”。最后,再使用executeAsync将DateTime * Async<string list>汇总成Async<(DateTime * string list) list>类型。由于我打算把它部署在虚拟主机上,为了节省资源没有把它们并行处理——因此在最后执行时耗时会有些长,不过最后感觉下来,速度基本还可以接受。

最后,我们与上次相同,写个异步Handler用于处理请求:

#light

namespace CnBlogsMonitoring

open System
open System.Web

type PostsOfMonthsHandler() =
    let mutable m_context = null
    let mutable m_endWork = null

    interface IHttpAsyncHandler with
        member h.IsReusable = false
        member h.ProcessRequest(context) = failwith "not supported"

        member h.BeginProcessRequest(c, cb, state) =
            m_context <- c

            let alias = c.Request.QueryString.Item("alias").Trim()
            let bm = DateTime.ParseExact(c.Request.QueryString.Item("begin"), "yyyy/MM", null)
            let em = DateTime.ParseExact(c.Request.QueryString.Item("end"), "yyyy/MM", null)

            let monthDiff = (em.Month - bm.Month) + (em.Year - bm.Year) * 12
            if monthDiff > 12 then failwith "Please pick a range no larger than 12 months."

            let title = sprintf "%s: %i/%i ~ %i/%i" alias bm.Year bm.Month em.Year em.Month
            m_context.Items.Item("Title") <- title

            let work = PostMonitor.getPostUrlsAsync alias bm em
            let beginWork, endWork, _ = Async.AsBeginEnd work
            m_endWork <- new Func<_, _>(endWork)

            beginWork (cb, state)

        member h.EndProcessRequest(ar) =
            m_context.Items.Item("PostsOfMonths") <- m_endWork.Invoke ar
            m_context.Server.Transfer("PostsOfMonths.aspx")

在EndProcessRequest方法中,我们得到了统计结果。在这里我的处理方式是将请求Transfer到PostsOfMonths.aspx这个页面去显示HTML——传递数据的方式是利用HttpContext.Items集合。

在PostsOfMonths.aspx中,显示HTML的方式与普通页面毫无二致:

public partial class PostsOfMonths : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        this.Title = this.Context.Items["Title"].ToString();

        this.rptPostsOfMonths.DataSource = this.Context.Items["PostsOfMonths"];
        this.rptPostsOfMonths.DataBind();
    }
}
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <h1><%= this.Title %></h1>
    
    <asp:Repeater runat="server" ID="rptPostsOfMonths">
        <ItemTemplate>
            <h2><%# Eval("Item1", "{0:yyyy/MM}") %> - <%# Eval("Item2.Length") %> post(s)</h2>
            
            <asp:Repeater runat="server" DataSource='<%# Eval("Item2") %>'>
                <HeaderTemplate><ul></HeaderTemplate>
                <ItemTemplate>
                    <li>
                        <a href="<%# Container.DataItem %>"><%# Container.DataItem %></a>
                    </li>
                </ItemTemplate>
                <FooterTemplate></ul></FooterTemplate>
            </asp:Repeater>
        </ItemTemplate>
    </asp:Repeater>
</body>
</html>

从F#代码中我们知道,HttpContext.Items["PostsOfMonths"]的类型是(DateTime * string list) list,也就是说,绑定至rptPostsOfMonths中的每一项都是个DateTime * string list对象——写成C#的形式便是Tuple<DateTime, FSharpList<string>>,其中包含Item1和Item2两个属性,分别是DateTime和FSharpList<string>类型,而后者又会绑定至内层的Repeater中,最终生成整页的HTML。

那么我为什么不写个异步的WebForm页面呢?因为经过我的简单尝试,在WebClient.GetDataAsync扩展里的Async.AwaitEvent操作中会抛出InvalidOperationException异常:

Asynchronous operations are not allowed in this context. Page starting an asynchronous operation has to have the Async attribute set to true and an asynchronous operation can only be started on a page prior to PreRenderComplete event.

经过了多番检查和比对,我始终没有发现出了什么问题,因此最后还是使用了异步Handler的方式编写这个服务。按理说,异步Hander可以正常工作,异步页面也应该没有什么问题,具体原因我会继续探究一下。不过,如果只是编写一个同步页面的话,不会出现任何问题。

最后,我把这个简单服务部署到了免费的虚拟主机上(以前用的Hosting由于众所周知的原因,在国内已经无法使用了——现在这个只能用到1月底),您可以在文末访问到该服务的入口页:这是一个简单的静态页面,填好信息后点击Submit便会提交至那个异步Handler进行处理——稍等片刻,便会得到结果。F#的方便之处,在于它编译后的结果便是标准的.NET程序集,使用时也只是复制一些dll便可——无需额外支持。这样看来,有一个统一的虚拟机平台的确方便,例如GAE在支持Java平台之后便相当于直接支持Scala语言了。同理,在.NET平台上使用Rails(Ruby)、Django(Python)等框架似乎都不是个梦想。

谁说.NET封闭?我感觉没有比.NET更海纳百川的平台了,呵呵——这也是我喜欢.NET平台的最大原因。

服务入口:http://user868.netfx4lab.discountasp.net/PostsOfMonths.html

本文代码:http://gist.github.com/273556