且听疯吟 如此生活三十年

分析 WhatsApp 架构的时候提到了他们使用的 Erlang + ejabberd 架构。看起来非常神奇,但感觉想要直接搬过来也有一些问题。

Erlang

  • 优秀的工程师

    Erlang 看起来语法简单、函数式编程、并发能力强,但就是——会的人太少了,连 Facebook 都找不到足够的优秀 Erlang 程序员,这是为什么呢……反正我们 HR 表示目前连投简历的人都没有 XD
  • 语言本身

    刚接触 Erlang,只能说下初步的感受,也有可能是见识不及,权当一看。
    • 语法简单,特性少。很难说到底是优点还是缺点。Erlang 的语法可能一页纸就能写完。但是带来的是「我应该怎么开始?」这样的问题
    • 学习成本。无论鼓吹者宣扬函数式多么好,语言始终是用来解决问题的。Java 再烂依然大把公司用得很 happy 。大部分程序员是务实的,只有能带来好处才会去原因学习。但是对习惯了面向对象命令式编程的程序员来说,学习和思维转换的成本可能根本比带来的好处要大得多。Golang 比 Erlang 火不是没有理由的
    • 数据类型。比如不存在字符串而是使用 List ,不管从性能或是直觉上来说,总觉得有那么点奇怪
    • 数据抽象能力。用 Erlang 开发的复杂应用实在看得头疼,数据抽象能力弱好处是灵活,但导致的后果就是代码里到处充斥着不知道意义的 tuple 和 record,它们之间可能还层层嵌套,让我感觉脑子时刻准备 StackOverflow
    • 和其他语言的交互。Erlang 处处透露着一股高冷范儿,比如独特的错误恢复机制,自实现的进程等等,很难做到和其他语言合群。但是在自己的领域里,Erlang 做得足够好。

XMPP 协议

  • XMPP 协议实在是太「重」了!XMPP 协议基于 XML ,很大的问题是信息冗余太大,传输数据的很大部分(一般超过 60%)是协议相关,其中既有协议本身繁冗的关系,也有 XML 的原因。
    移动设备来说这将是个很大的问题——更多的流量消耗、以及相关的电量、网络效率等等。
  • XMPP 协议的另一个问题在于扩展的复杂。XMPP 协议本身已经定义了一大堆协议流程,如果想要在基础之上扩展,将会非常麻烦。
    基本上使用 XMPP 协议的服务最后都会改换成自定义协议(比如 WhatsApp),也不是没有原因的。

想要快速开发 Erlang + ejabberd 还是不错的,XMPP 协议本身很开放和完善,各种平台的实现也很多,用来快速原型还是不错的。但是想要实现深度的定制和优化可能就需要更多的时间和精力了,尤其对于移动平台来说。

最近的项目需要做一个跨平台的 IM 服务,想要参考下其他项目的架构。国内分享不多,微信基本没有参考价值,毕竟 QQ 的积累在那。陌陌从招聘及据网上抓包的内容看是 XMPP 协议,看起来可能是 Java 的 openfire 之类的 XMPP 路线。
国外的分享稍微多一些,像 WhatsApp 每年都会做一些相关的分享,看起来有些参考价值。

WhatsApp 目前的数据

  • 月用户约为 4.65 亿
  • 每天有 190 亿 消息进站/ 400 亿消息出站
  • 6 亿图片,2 亿音频, 1 亿视频
  • 峰值并发连接为 1.47 亿
  • 消息峰值为每秒 34.2 万进站/ 71.2 万出站
  • 节日期间流量更加惊人

支撑这些数据的硬件

  • 约 550 台服务器
  • 约 150 台 chat server, 可以支持 1.5 亿连接
  • 约 250 台 mms 服务器
  • 数据服务器内存为 512 GB,标准节点是 64 GB
  • 除了视频,其他数据都存储在 SSD 上
  • 超过 11000 个核心
  • Mnesia 使用了约 2TB 的内存

服务端架构

  • 几乎全部使用 Erlang (经过了自己的改造)
  • 服务器使用的是 FreeBSD 9.2
  • 数据库是 Mnesia
  • ejabberd (做了大量改造,包括使用自己的协议替代 XMPP)
  • Yaws, lighttpd
  • 非常重视性能监控

Erlang

  • 大约 10 人的 Erlang 团队,工作包括开发和 ops
  • 服务端基本全部使用 Erlang 开发
  • 据说 Facebook Chat 开始也想要使用 Erlang ,但是由于优秀的 Erlang 工程师太难找而放弃了。不知真假,但是使用 Erlang 的好像的确不多。
  • 团队对 Erlang 和 ejabberd 做了大量优化以满足越来越高的要求。但是在初期 Erlang 的表现就已经很好了
  • 选择 Erlang 的原因是:
    • 只用很少的服务器就支撑起如此庞大的活跃用户,团队认为这归功于 Erlang
    • 在 SMP 上的优秀扩展性
    • 支持热更新

ejabberd

  • 最初从 ejabberd 开始
  • 选择 ejabberd 是因为这是个非常优秀的开源 jabber 服务器
  • 而且 ejabberd 是用 Erlang 写的
  • 持续的对 ejabberd 进行重写和修改,包括从 XMPP 转换到内部开发协议、调整代码库以及重设计一些核心组件,并对 Erlang VM 做了大量的修改以获得高性能 Whats

当年 WhatsApp 创始人去 Facebook 等企业求职被拒,据说原因之一是——年龄太大了。但也许只有老程序员才能坚持使用 Erlang 构建整个服务,并且用丰富的经验支撑起了整个 WhatsApp 庞大的架构——虽然主要是为了 WhatsApp 的庞大用户量而不单单是为了技术,Facebook 依然付出了 190 亿美元的代价。

Windows 的计划任务功能不够用,于是试着写了个支持插件和动态加载的定时执行任务工具 Qzzz,暂时是个坑…

Qzzz

A scheduler app for windows.

How to use

  • Run as a windows service

    Use Install.bat to install the QzzzService
  • Run as a console

    Run Qzzz.exe and do not close the console.

Create a plugin

  • create a config file qzzz.json

    {
        "Id": "323ADBF530C4307B336C670B3F5BD229",
        "Name": "ToastPluginDemo",
        "Version": "1.0",
        "Description": "Toast plugin demo",
        "CronExpression": "*/15 * * * * ?",
        "StartAt": "2014-06-01 00:00:00",
        "EndAt": "2014-07-01 00:01:00",
        "PluginFileName": "Qzzz.ToastPluginDemo.dll",
        "Author": "caoyue",
        "Url": "https://github.com/caoyue/Qzzz"
    }
    
    • Id should be a guid string
    • CronExpression is a Quartz Cron Expression, a little different from *nix
    • StartAt and EndAt is optional
  • Create a CSharp library project

  • Add Qzzz.Plugin.dll to the references

  • Implement IPlugin interface

    public class SimplePlugin : IPlugin
    {
        public void Execute(PluginContext pluginContext)
        {
            // do your job here...
        }
    
        public bool Pause(PluginContext pluginContext)
        {
            // pause job while the return is true...
        }
    }
    
    • You can find a demo in Plugins\Qzzz.SimplePluginDemo
    • Add Qzzz.Toast.dll to use toast notification on windows 8/8.1, like Plugins\Qzzz.ToastPluginDemo
  • Build your project and copy the plugin folder to Qzzz\Plugins

最近坐久了就感觉腰酸背痛,想要做个定时提醒,提醒自己定时休息走动一下。
现在的定时提醒软件要么太复杂要么太难看,本来想要使用系统自带的定时任务,但是 Windows 8 中废弃了计划任务中的弹出消息功能。

想到 Windows 8 中新增了 Toast Notification,能不能利用这个方式来提醒?但是没见过 Desktop app 使用这个功能,一直以为是 Windows Store App 专用的 API。
在翻 MSDN 的时候发现原来 Metro Toast Notification 适用范围也包括 Desktop app。然后折腾了一下,居然成功了:)

demo

在 Desktop app 中使用 Windows 8/Windows RT API

Toast 位于 Windows.UI.Notifications 命名空间中,但是默认情况下没法引用该命名空间,因为其中一些 API 在 Desktop 环境下是有限制的。幸运的是 Toast Notification 不属于其中。可以从 MSDN 上找到这些 API 的详细信息,其中就包括了适用范围 。

  • 建立一个 Console Application,为了引用 Windows.UI.Notifications,需要编辑项目文件 .csproj。在第一个 PropertyGroup 节点中添加
<TargetPlatformVersion>8.0</TargetPlatformVersion>
  • 重新 Load 项目,添加引用。可以看到引用管理器左边添加了新的一列 Windows,添加 Windows.Data.Xml.Dom Windows.UI.Notifications
    Windows.Foundation 的引用。
  • 添加对 System.RuntimeSystem.Runtime.InteropService.WindowsRuntime 的引用。

使用 Console app 创建 Toast Notification

接下来需要创建一个 ToastTemplateType 对应的 XmlDocument 对象。
关于不同的 ToastTemplateType ,可以在 MSDN 找到详细的信息。
ToastTemplateType.ToastImageAndText04 为例,这是一个带有 Image 和三行文字的 Toast Template

  • 创建 Template 如下:
XmlDocument toastXml = ToastNotificationManager.GetTemplateContent(ToastTemplateType.ToastImageAndText04);

Windows.Data.Xml.Dom.XmlNodeList stringElements = toastXml.GetElementsByTagName("text");
stringElements[0].AppendChild(toastXml.CreateTextNode("Head"));
stringElements[1].AppendChild(toastXml.CreateTextNode("Content1"));
stringElements[2].AppendChild(toastXml.CreateTextNode("Content2"));

String imagePath = "file:///" + Path.GetFullPath("toastImageAndText.png");
Windows.Data.Xml.Dom.XmlNodeList imageElements = toastXml.GetElementsByTagName("image");
imageElements[0].Attributes.GetNamedItem("src").NodeValue = imagePath;
  • 创建 Toast 并添加事件:
ToastNotification toast = new ToastNotification(toastXml);
toast.Activated += ToastActivated;
toast.Dismissed += ToastDismissed;
toast.Failed += ToastFailed;
  • 弹出 Toast:

    在 Desktop app 中使用 Toast 时需要一个 App id,否则会出现 找不到元素 的异常。
string appId = "Microsoft.Samples.DesktopToastsSample";
ToastNotificationManager.CreateToastNotifier(appId).Show(toast);

其他

这里是个简单的示例项目源码 Windows8ToastNotification
考虑用这个写个小玩具,可以获取关注的 Twitter、世界杯比分、天气预报之类的通知,支持 API 自定义,想想还挺好玩的,完成后会更新到 Github。

准备

好吧,其实没啥准备,完全是一场说滚就滚的旅行。

直接从车站打的到了天外村。一家叫做「麓山鲁菜馆」的饭馆,还没等到吃完就后悔了,价格略贵而且难吃。
比如有道特色菜叫「泰山三美」,上来才发现就是白菜豆腐汤,不说食材质量不怎么好,还差一美呢,问了老板说是泰山水——这不叫「珍珠翡翠白玉汤」真是可惜了……

上山

徒步上山都是从红门开始的。隔天外村也比较近。到的时候是晚上八点左右。
挺热闹的,到处都是专业和不专业的背包客,很多大妈在推销她们的登山装备。有些还是挺有用的。比如拐杖,竹子做的,很便宜,两元一支。开始不想买来着,后来大妈吓唬我说没这个很难登上去,我就没骨气的买了……后来事实证明确实也挺有用的,基本是人手一个。
由于我们是晚上登山,所以买了手电,确实也有用,因为山上没路灯,不带连上厕所都得摸黑。另外看天气可能下雨,买了雨衣,挡雨防寒都不错。

然后由于兴奋,犯了个错误。吃完饭过来就开始爬山了。其实应该先休息消化一会儿。
另外一个错误就是一开始就一顿猛冲,还没到中天门就已经累的不行了。结果听人说还不到一半,后面更陡,要不是晚上缆车不开,估计就直接坐缆车了(所以晚上爬山还有这么个好处,想放弃也没办法,上不去也下不来,只能往上爬了)。

过了中天门,确实更陡。
台阶从面前往上铺开,简直一眼望不到头,只看到天边不知道是星光还是灯光。
我这个除了每天下楼买宵夜之外没有其他锻炼的新手爬山者心里一股感觉油然而生,「我 X,真他妈高!」
爬过这一折,还没来得及回望下面的芸芸众生感叹一下「老子真牛 X 」就发现面前又冒出来一条比之前更长更陡的路,简直要怀疑人生到底是他妈为了为什么人类何苦要自我折磨。

不过还好和朋友一起的,有人一起支持鼓励的感觉好多了。也学会了不要看其他地方,一心看着脚下的台阶,一步一步走。路上看到不少人感觉都是机械式的在动了……
到了极限的时候感觉腿都发软,T 恤也不知道湿了又干几次了。不过真的像以前体育老师说的,超过了极限反而就好了。
后来到了十八盘的时候,爬上去之后还小小得瑟了下「十八盘也不过如此」然后收到了一堆鄙视的眼神……

到了南天门往下看的时候还真的是不敢相信。看远处的万家灯火和下面的人群,简直难以想象是自己一步一步爬上来的。
然后去日观峰等日出,到的时候大概是夜里一点左右,花了五个小时从山脚上来。对我来说,平时估计步行五个小时都够呛……

日观峰上都是租个军大衣就靠着山石或者躺着休息下的人群,挺热闹的,打牌玩杀人游戏的都有。
结果天公不作美,下起了小雨,一直下下停停的。到了凌晨虽然雨停了,不过一点日出的迹象都没了。
虽然没看到日出云海,但是见识了下人海。夜里没发现,天亮才发现满山都是各种奇葩姿势等日出的人,不知道哪位喊了句「山上的朋友你们好吗」莫名其妙的就戳中笑点了……

下山

没看到日出只能下山了。而且还下起了雨。极其影响心情啊。
不过慢慢发现雨中也别有一番趣味来着,顺便好好欣赏了下晚上上山错过的景色。
就是下山的时候看着下面腿发抖,真怕一不小心就直接圆润的滚下去了……所幸最后平安下山 :)
也有缆车下山,不过只到中天门。之后可以坐旅游大巴或者走下山。

下山比上山还是快得多,除了小心一点之外也没上山那么累,全程没有怎么休息,用了三个小时左右,下来之后差点出租车上睡着……

Tips

推荐大家准备:

  • 2L 左右的饮用水。如果怕负担的话山上每隔一段有卖,基本是 5 块左右。有盐水之类的更好
  • 零食,补充体力,巧克力糖什么的不错
  • 山下卖的拐杖,上山下山都挺好用
  • 如果是晚上登山或者下山,请带手电筒
  • 看天气预报,如果可能下雨请带雨衣
  • 毛巾。擦汗用,虽然擦不过来……
  • 防寒衣物,羽绒服什么的。没有洁癖的可以在山上租 10 块一件的军大衣,防寒效果不错
  • Money。山上每过一段都会有小商店,需要的东西基本都可以买到,价格当然也会贵点不过也还好,另外木有刷卡的地方
  • 移动电源。山上下山加起来可能要七八个小时,对微博狂人来说手机电源可能不够用,还是带个移动电源比较保险
  • 朋友。登上去之后简直难以相信自己能爬上来。都是靠朋友支持鼓励,聊聊天插科打诨之类的也很能放松。如果自己一个人来的就在路上勾搭个基友/妹子吧。山上大部分地段 3G 信号都不错,没事发个微博朋友圈什么的炫耀下也不错
  • 信心。千万不要想放弃,也不要想还有多远。反正走着走着就发现到南天门了。感觉撑不住的时候休息下缓一缓,宁可走一回站几分钟休息下,千万不要休息太长时间,因为一旦完全放松下来就很难有斗志继续了
雨中泰山

今天发现 Google 的时候发现收录了一个奇怪的地址,形如 23.244.200.000000000354,而且这个地址居然还是可以访问的。很显然这不是域名,尝试 ping 一下,返回地址来自于 23.244.200.236,可以看出 000000000354236 的八进制表示形式。

###混合进制
查了下,原来 IP 地址居然可以混合进制来显示……前导的 0 的数字会被当作八进制来处理。而且可以同时使用不同进制:包括十六进制、八进制和十进制。
比如 http://0x17.244.0xc8.00000354 这样奇葩的地址也是可以访问的……

###DWORD 形式的 IP
还有更奇葩的,整数型的 IP —— 比如这个:http://401918188 ,同样可以访问。
转换过程是将 23.244.200.236 转换为二进制形式,不足 8 为的数字用 0 补足: 00010111111101001100100011101100,然后将这一串二进制数字转换为整数,即可得到整数型的 IP 地址。
当然,如果将这个整数换成其他进制也是可以的,比如:
http://02775144354http://0x17f4c8ec

###可省略的 0
当然,奇葩还没有结束。比如这样的 IP 地址 192.168.1 也是可以访问的……因为某部分为 0 则可以省略。OK,那我们怎么知道省略的是哪一部分呢?别急,「聪明」的标准的制定者们早就想到这一点了,规则如下:

abbrIP
A0.0.0.A
A.BA.0.0.B
A.B.CA.B.0.C
A.B.C.DA.B.C.D

###其他
原 IP 127.0.0.1 按上面的方法改变下形式

http://0x7f.0.000000000001
 http://2130706433
 http://017700000001
 http://0x7f000001
 http://127.1
 ……

这个特性在不同的平台上是由 socket 库实现的。在 Linux 上是 inet_aton ,在 Windows 上是 inet_addr
但是相关的标准文档我还没有找到。rfc 2396rfc 1738 也都是语焉不详。

btw,虽然主流浏览器和很多其他软件支持这种表示方法。但在某些地方可能导致问题,一般都不推荐这种做法,比如 Mac OS X: Do Not Use Leading Zeros in IP Address

给出一个字符串的计算表达式如 1+2*(3-4)/5 ,不使用其他库如何计算其结果?
在脚本语言中这也许不算什么问题,但是在 C# 这样的静态语言中则需要我们自己来解析表达式实现计算。

中缀表达式

中缀表达式 (Infix Expression),即形如 a+b-c*d 这样的表达式。运算符位于两个操作数的中间,也是我们习惯的写法。但是这种写法对于计算机运算来说是不够效率的,每次计算表达式,都需要先分析整个表达式,然后根据优先级来逐步计算。

后缀表达式

后缀表达式 (Suffix Expression),也叫做逆波兰表达式,即将运算符记在操作数之后,如 a+b 记作 a b +。使用后缀表达式不需要关注运算符的优先级,计算机能够按表达式从左向右来计算,从而利用堆栈并提高计算效率。

表达式转换

既然后缀表达式有这些好处,那么如何将常见的中缀表达式转为后缀表达式?一般使用 调度场算法。实际上语法树的后序遍历也是后缀表示法。

简单分析下从中缀表达式到后缀表达式的过程:

  1. 定义两个栈 Operand(操作数栈)和 Operator(运算符栈);
  2. 从左到右遍历字符串,按如下规则:
    • 如果该字符为左括号,直接压入 Operator 栈中;
    • 如果该字符为右括号,则依次弹出 Operator 栈中的元素,并压入 Operand 栈中,直到遇到左括号为止。将左括号弹出,但是不压入栈;
    • 如果该字符是操作符:
      • 首先将临时变量中两操作符之间的字符取出,此处可以判断是否是数字,如果不是,则说明字符串不是标准的表达式;如果是,将其存入 Operand 栈中并清空临时变量;
      • 查看 Operator 栈中是否存在运算符:
        • 如不存在,将该操作符压入 Operator 栈中;
        • 如存在,判断栈顶元素是否是左括号,如果是,将运算符压入 Operator 栈中;否则,比较该操作符和 Operator 栈顶操作符的优先级:
          • 该操作符优先级较高,将该操作符压入 Operand 栈中;
          • 该操作符优先级较低或相等,则弹出 Operator 栈顶元素,将其压入 Operand 中,然后循环执行比较和弹出操作,直到遇到左括号或 Operator 为空或栈顶操作符优先级低于该操作符,将该运算符压入 Operator 栈中;
    • 如果该字符不是操作符也不是括号,则将其存入临时变量;
  3. 循环完成,将临时变量(即最后一个数字)压入 Operand 栈中;
  4. 将 Operator 栈依次弹出并压入到 Operand 栈中;
  5. 将 Operand 栈按从底部到顶部读取,即可记作后缀表达式。

后缀表达式计算

  1. 将后缀表达式转换成堆栈 Suffix;
  2. 定义一个新的栈 Result;
  3. 依次弹出 Suffix 栈顶元素:
    • 如果该元素不是运算符,将其压入 Result 栈中;
    • 如果该元素是运算符,则弹出 Result 顶端两个元素(即 Pop 两次),将其作为左操作数和右操作数,按该运算符进行运算,将结果压入 Result 栈中;
  4. Result 栈顶元素即计算结果

这是简单的表达式计算方法,但是运用该原理,我们可以实现包含自定义函数的复杂计算。

Demo:
gist ea924d24131d7c48dc9c

###关于 Wox
最初是看到 V2EX 上的一个帖子 v2ex.com/t/93922,然后作者真的去写了并且发布在 Github 上了。
Windows 上的快速启动软件已经很多了,但是不管从功能、界面乃至理念来讲和 Alfred 都相去甚远,尤其是 Workflow 的概念。
Wox 现在发布了第一个 beta 版本,看上去已经很不错了,希望这个项目能坚持下去吧。

###Wox.Plugin.FirefoxBookmarks
Wox 的插件开发也比较简单,可以参考文档。写了个搜索 Firefox 书签的插件。
原理是读取 Firefox 书签,然后根据参数查询返回结果。

首先我们要获取到 Firefox Profile 的位置。Firefox 支持多个 Profile,我们可以获取到默认的 Profile 。Firefox Profile 路径及配置都保存在 %appdata%\Mozilla\Firefox\profiles.ini 中。打开这个配置文件可以看到每个 Profile 配置包含的参数:“IsRelative” 值为 “0” 代表绝对路径,“1” 代表相对路径;“Default=1” 代表默认启动的 Profile。
虽然可以通过引入 WindowsAPI (即 [DllImport("kernel32")])来解析 ini 配置文件,不过无需如此小题大做,毕竟只需要找到其中一行即可。下面是一个读取 Firefox 默认 Profile 的方法:

gist 11324134

这种读取方法对某些 Portable 版本无效,所以同时也提供了手动配置路径的方法。

Firefox 书签以及历史记录都保存在 Profile 里的 places.sqlite 中。其表结构可以参考 developer.mozilla.org/en-US/docs/The_Places_database
其中 moz_bookmarks 表中存储了书签及书签文件夹,且通过外键 fk 和 moz_places 关联。
Sqlite 的查询语法基本和标准 SQL 相同,比如:

SELECT moz_places.url, moz_bookmarks.title,parent.title as parent
FROM moz_bookmarks
JOIN moz_places ON moz_places.id = moz_bookmarks.fk
JOIN moz_bookmarks as parent ON parent.id = moz_bookmarks.parent
WHERE moz_bookmarks.type = 1

PS. 另一个快速切换电源计划的插件 Wox.Plugin.PowerPlan

##问题
在使用 Firefox 测试网站的时候遇到一个奇怪的问题。
把一份数据写入类型为 hidden 的 input 中时,比如

<input type="hidden" value="@Model.Count" id="Count" />

然后在用 Javascript 修改这个值,比如后台数据原为 0,

var count = parseInt($("#Count").val());
$("#Count").val(count + 1);

不提交表单,刷新页面,发现 $("#Count").val() 的值却不是后台数据的 0,而是我们修改过后的值,这个值被缓存了。

##原因
使用其他浏览器测试可以发现,这个问题仅出现在 Firefox 中。从而可以考虑是否 Firefox 对表单缓存的实现「有问题」。

##解决
设置 form 的 autocomplete="off"

Whoosh 是一个纯 Python 实现的全文搜索组件。基础架构和 Lucene 比较像。使用试了试,记录一些东西。

中文分词
Whoosh 本身只有英文分词,因此需要添加中文分词组件。
最后选择了 Jieba 这个 Python 中文分词组件,初步测试分词效果还不错。有时间会把几个中文分词组件对比一下看看。
Jieba 已经封装好了 ChineseAnalyzer for Whoosh,只需要引用 from jieba.analyse import ChineseAnalyzer 来替换 Whoosh 的 Analyzer 即可。

HTML 抽取
对于纯文本直接分析建立索引即可。
而对于 HTML 文本,我们需要先将其中的文本抽取出来再进行运行分析程序。否则其中的 HTML 标签将会被当作文本来分析,比如搜索 “span” 将会得到所有包含 <span></span> 的内容。举个例子,用 HTMLParser 来提取文本,其他类似功能的模块也有不少。

def html_strip(html):
    from HTMLParser import HTMLParser
    html = html.strip()
    html = html.strip("\n")
    result = []
    parse = HTMLParser()
    parse.handle_data = result.append
    parse.feed(html)
    parse.close()
    return "".join(result)

关键词 Highlight
默认的高亮结果只会包含结果命中的部分碎片,需要不同展示可以使用不同的 Fragmenters 。比如展示全文需要 whoosh.highlight.WholeFragmenter
然而 HTML 的高亮有一个问题。简单的基于匹配的替换带来的问题就是 HTML 标签的属性内容也被替换了,比如 a 标签的 href 属性,导致结构发生错乱。对此除了自己写 HTMLFragmenter 之外似乎没有现成的解决办法。
考虑到服务端解析的效率问题,放弃 Whoosh 和服务端的高亮,使用 js 在客户端高亮(其原理也是通过判断关键词前后的标签匹配,并经过一系列的正则替换最终实现只替换文本关键词而忽略标签)。试过效果比较好的高亮方案,https://github.com/jbr/jQuery.highlightRegex
只需要在 results = searcher.search(q, terms=True) 时设置 terms=True 即可从 results 或 results 的 hit 中取得关键词 terms = results.matched_terms(),然后将关键词传递给前端用 highlightRegex 来高亮。

结果分页
对于结果的分页,whoosh 提供了 search_page 方法。但是这个方法可以说是个半成品。首先,search_page 方法支持的参数设置较少,很多功能没法在 search_page 中完成。其次,search_page 方法返回的结果为 ResultsPage 类型,而 search 方法返回 Results 类型,且这两者之前并无继承关系,Results 中包含的属性比 ResultsPage 丰富得多。
最重要的是,到目前为止,使用 search_page 方法从所有结果中获取中间页时,其性能与使用 search 获取所有结果然后手动分页是一样的,从源代码可以看到 search_page 仅仅是对 search 的一次封装。search_page 仅仅是出于方便使用的功能(虽然我也没看出 search_page 存在的意义和方便在哪…… )
因此,还是使用 search 的 limit 参数来满足分页需求。limit 参数限制了返回的结果数目。可以使用

results = searcher.search(q, limit=page * pagesize)

来控制返回的结果,然后使用

results[(page - 1) * pagesize:page * pagesize]

获取指定的分页。

词典选择
中文分词的效果有很大一部分取决于词典,但并不是词典越大越全越好。分析词典 Build Trie 是一个比较消耗 CPU 的过程(虽然只是在第一次需要进行这个过程,之后会读取 Cache 中的 Model),越大的词典分析时消耗的资源也越大。因此根据实际情况选择词典比较好。
此外,如果需要分析的文本包含许多专业性词汇,也可以考虑设置自定义词典来增强歧义分析能力。
词典的设置很简单,使用 jieba.set_dictionary(dict_path) 即可。

其他

虽然 Whoosh 的性能不尽如人意,相关资料和扩展也缺乏。
但总体来说,对于小规模的使用,whoosh 开发简单,基本可以满足需求,如果使用过 Lucene 也可以很容易上手。而且纯 Python 实现,看源代码也方便。