GNU Mailman 翻译

Barry Warsaw

源自《The Architecture of Open Source Applications》II 第十章

== 注:由于本人英语水平不高,图灵社区的翻译计划恰好又没有这篇文章,故翻译有些磕磕绊绊,若有难以读懂和理解之处,敬请谅解 ==

== 原文地址 http://aosabook.org/en/mailman.html ==

http://www.list.org/ GNU Mailman 是一款管理邮件列表的免费软件。几乎所有书写或使用免费和开源软件的人都会遇到一个邮件列表。邮件列表可以以讨论为基础或以公告为基础,在这两者间有着各种类型的变化。有时邮件列表在Usenet的新闻组上是相互连通的,或者有类似的服务如 http://gmane.org/|Gmane 。邮件列表通常包含一些档案,这些档案涵盖了已发布到邮件列表的所有信息的历史记录。

GNU Mailman在20世纪90年代初期出现,当时John Viega编写了第一个版本来使得球迷与初期的戴夫马休斯乐队相互联系,而乐队的成员是他在大学的朋友。在90年代中期,这个早期的版本引起了Python社区的关注,那时Python领域的中心已不再是 http://www.cwi.nl/|CWI 这个荷兰的科学研究院,而是转到了http://www.cnri.reston.va.us/|CNRI ,在美国弗吉尼亚雷斯顿的国家研究创始公司。那时,CNRI这在使用Majordomo(一款基于Perl的邮件列表管理器)来运行各种类型的与Python相关的邮件列表。当然,这不仅会为Python世界维持大量的Perl代码。更重要的是,由于它的设计,我们发现,为了我们的目修改Majordomo(如添加最小的反垃圾邮件措施)太难了。

Ken Manheimer在许多早期的Mailman工作中提供了许多帮助,并且许多优秀的开发者在那时也为Mailman做了很大贡献。今天,Mark Sapiro维护着稳定的2.1版本分支,而Barry Warsaw,本章的作者,正全神贯注于全新的3.0版本。

许多John提出的最初的体系结构设计一直到Mailman的第三版分支仍旧存留在代码之中,并且在其稳定的版本中可见。在之后的章节中,我将介绍一些在Mailman1和2中有问题的设计决策,并且我们是如何在Mailman3中处理它们的。

在早期Mailman 1的时代,我们遇到许多问题,如信息丢失,或由bug引起的信息一遍又一遍的反复传送。这促使我们明晰了两个在Mailman走向成功的道路上非常重要的准则: 没有一条信息应该丢失。 没有一条信息应该被传送多次。

在Mailman 2中我们重新设计了信息处理系统来保证这两个原则总是最重要的。这部分的系统到现在已经稳定至少十年了,并且是Mailman到现在无所不在的一个重要原因。尽管这个子系统在Mailman 3中进行了现代化处理,这个设计和实现在很大程度上保持不变。

10.1. 报文的解析(The Anatomy of a Message)

Mailman中一个核心数据结构便是邮件报文(email message),表现为一个报文对象(message object)。系统中的许多接口、函数和方法,有三个参数:邮件列表对象、报文对象和当系统中的报文被加工时用于记录和传达状态的元数据字典。

Figure 10.1: A MIME ''multipart/mixed'' message containing text, images, and an audio file

在它的表面,邮件报文是一个简单的对象。它由许多的被称为表头的由冒号分隔的键-值对组成,后面跟一个空行,将表头从报文中分离出来。这种结构的表示方法应是很容易被语法分析、生成、推理、操纵的,但事实上它很快就变得非常复杂。有无数的RFC来描述所有可能发生的变化,例如处理复杂的数据类型,如图像,音频等等。电子邮件可以包含ASCII英文,或是任何存在的语言和字符集。一封电子邮件报文的基本结构已经被其他协议一遍又一遍借鉴,如NNTP和HTTP,但每个稍有不同。我们对于Mailman 的工作已经演化成几个库来处理这种格式的变化(通常被称为“RFC822”,建立于1982年[[http://www.faqs.org/rfcs/rfc822.html|IEFT 标准]])。电子邮件库最初的开发是为了GNU Mailman从而使用了Python标准库,在此开发得以继续并且符合更多的标准,变得更加健壮。

电子邮件可以作为其他类型数据的容器,正如各种MIME标准定义的那样。一个容器报文部件(message part)可以给一张图像,一些音频,或只是任何类型的二进制或文本数据编码,这囊括了其他容器部件。在邮件阅读器中,这些被称为附件。图10.1展示了一个复杂的MIME报文的结构。有实线边框的框架作为容器部件,虚线边框的框架是被二进制数据编码的Base64, 点实线边框的框架代表普通的文本信息。

容器部件还可以任意嵌套;这些被称为混合部件(multiparts),事实上可以变得更深。但是,不管它的复杂性,任何电子邮件都可以被建模为一棵树,它有一个单独的报文对象作为根节点。在Mailman中,我们常称之为报文对象树,并参考其根报文对象来传递这可树。图10.2显示了图10.1中的混合部件报文的对象树。

Figure 10.2: Message object tree of a complex MIME email message

Mailman总是会以某种方式修改原始消息。有时,转换可以是相当良性的,如添加或删除表头。有时我们会完全改变报文对象树的结构,例如内容过滤器去除某些类型像是HTML、图片或者其他非文本部分的内容。Mailman甚至可能崩溃”multipart/alternatives”,其中报文既显示为普通的文本又显示为一些富文本类型,或添加了额外的部分,这部分包含着关于邮件列表本身的信息。

Mailman仅仅解析线上代表一封报文的字节一次,便是当它第一次进入系统之时。从那时起,它只处理报文对象树,直到准备将它发送回开放的邮件服务器。在这一点上,Mailman将这棵树变成一个字节表示。在这种方式下,Mailman https:*docs.python.org/2/library/pickle.html|pickles )报文对象树,为了快速存储和重建文件系统。Pickles是一种能够序列化任何Python对象的Python技术,包括它的所有子对象,一个字节流,并且它完全适合优化邮件对象树的处理。Unpickling是一种使字节流返回到存在对象的反序列化技术。通过在一个文件中存储这些字节流,Python程序获得低成本的持续性。

10.2.邮件列表(The Mailing List)

邮件列表显然是Mailman系统的另一个核心对象,并在Mailman中的大多数操作都是以邮件列表为中心的,如: 根据用户或地址成员资格被定义为已被订阅到一个特定的邮件列表。 邮件列表中有大量的配置选项被存储在数据库中,这用来控制了从邮递权限到最终递送前如何修改报文的全部事情。 邮件列表有所有者和版主,他们有更大的权限来改变列表的某些方面,或批准、或拒绝有问题的邮递。 每一个邮件列表都应有它自己的存档。 * 用户邮递一封新的报文到一个特定的邮件列表。

如上等等。几乎在Mailman中的每一个操作都把邮件列表作为参数————那是最为基本的。邮件列表的对象已经被彻底重新设计在Mailman 3中,为了使它们更高效并且扩展了其灵活性。

John最早的一个设计决定是如何在系统内部表示一个邮件列表对象。为了这个处于最中心的数据类型,他选择了一个具有多个基类的Python类,每一个都实现了邮件列表任务的一小部分。这相互合作的基类,被称为mixin类,这是一种很聪明的组织代码的方式,以致于可以轻易添加全新的功能。通过在一个新的mixin基类上嫁接,核心邮件列表’’MailList’’类可以很容易地容纳一些新的酷炫的东西。

例如,在Mailman 2中添加一个自动回复,一个mixin类被创建来支持数据实现那个功能。当一个新的邮件列表被创建时,数据将自动初始化。mixin类还提供了方法来给自动回复功能必要的支持。这种结构甚至是更加有用的当考虑到邮递’’MailList’’对象的持久性时。

另一个John的早期设计决策是使用Python pickles来存储’’MailList’’状态持续性。

在Mailman 2中,该’’MailList’’对象的状态被存储在一个名为’’config.pck’’的文件中,这只是’’MailList’’对象的字典的pickled代表。每个Python对象都有一个属性字典称为’’__dict__‘’。所以保存邮件列表对象就是是一件简单的事(pickling它的’’__dict__‘’到一个文件中),而加载它仅需从文件中阅读pickle然后重构其’’__dict__‘’。

因此,当一个新的mixin类被加入来实现一些新的功能时,所有mixin的属性都被自动且适当地pickled和unpickled。我们不得不做的唯一额外工作便是维护架构版本号来自动升级旧的邮件列表对象每当新的属性通过mixin被加入时,这是由于旧的’’MailList’’对象的标识被pickled时将会丢失新的属性。

如它过去那般便捷,mixin架构和pickle持久性最终崩塌在自己的重量之下。现场管理员经常要寻求某种方式来通过外部访问邮件列表配置变量,而非Python系统。但是pickle协议完全是Python专用,因此其内部所有有用的数据在均被隔绝了,pickle无法使用它们。同时,由于一个邮件列表的整个状态被包含在’’config.pck’’之中,而Mailman有许多需要读写、修改邮件列表状态的过程,我们不得不实现一种基于文件的并且满足NFS安全的锁来保证数据的一致性。每次Mailman的一些部分想改变邮件列表的状态时,它必须获取锁,写出变化,然后释放锁。甚至读操作也需要申请列表的’’config.pck’’文件的重载,因为在读操作之前一些其他进程可能已经改变了它。这一系列在邮件列表上的操作将造成令人可怕的慢和低效的结果。

因为这些原因,Mailman 3将所有的数据存储在SQL数据库中。默认使用SQLite3,尽管这很容易改变,由于Mailman 3使用了叫做风暴(Storm)的对象关系映射(Object Relational Mapper),它支持广泛的数据库。PostgreSQL支持通过仅仅几行代码被加入其中,并且一个站点管理员可以通过改变一个配置变量来启用它。

另一个存在于Mailman 2中很大的一个问题,便是每个邮件列表是一个仓库。通常操作要跨越多个邮件列表,甚至是所有。例如,用户可能希望在休假时暂时停止所有的订阅。或者站点管理员可能要在他的系统上给那些邮件列表中受欢迎的邮件添加一些免责声明。即使是找出哪些单独地址被请求订阅的邮件列表unpickling系统的每个邮件列表的状态这样的简单事情,也会由于会员信息被保存在’’config.pck’’文件(而跨域多个邮件列表)。

另一个问题是,每个’’config.pck’’文件都在一个以邮件列表命名的目录下,但是Mailman最初设计时没有考虑虚拟域名。这导致了一个非常不幸的问题,在不同的领域,邮件列表可能有不同的名称。例如,如果你拥有’’example.com’’和’’example.org’’域名,你想让它们独立行动并且允许各自邮件列表不同的’’support’’,你在Mailman 2中便无法做到这一点,在没有修改代码、勉强支撑的”挂钩“、或常规的解决方法(强迫不同的列表名称在覆盖之下),而这也是大型网站如SourceForge使用的方法。

这在Mailman 3中通过改变邮件列表确定的方式而得以解决,随着将所有的数据移到一个传统的数据库中。邮件列表表格中的主键是完全限定的列表名称(fully qualified list name),或者你大概已经认出它了————邮件地址。因此''support@example.com‘’和''support@example.org‘’现在在邮件列表表格中是完全独立的行,并可以很容易地共存于一个Mailman系统中。

10.3.Runners

通过一组称为runners的独立的进程,报文流过整个系统。最初设想的方式,在一个特定的目录中对所有已排好队列的报文文件进行有预见性地加工,现在有几个简单独立的runners、执行特定的任务的长期运行的进程并且被一个主要进程所管理;在这以后更是如此。当一个runner在一个目录中管理文件时,它被称为一个queue runner。

Mailman是严格单线程的,即使有重要的并行事务需要开发。例如,Mailman可以接受邮件服务器上的报文同时发送邮件给收件人,或处理反弹,或进行归档。Mailman中的并行是通过多个进程来实现的,以这些runners的形式。例如,有一个输入队列runner,其唯一的工作便是接受(或拒绝)来自上游邮件服务器的邮件。有一个输出队列runner,其唯一的工作便是与其上游邮件服务器通过SMTP通信,也是为了给最终的收件人发送邮件。有一个存档(archiver)队列runner,一个弹跳(bounce)处理队列runner,一个队列runner为了给NNTP服务器转发邮件,一个runner来写摘要,以及几个其他的runner。不管理队列的runners包含一个本地邮件转移协议服务器([[http://tools.ietf.org/html/rfc2033|Local Mail Transfer Protocol]])和一个HTTP管理服务器。

每个队列的runner负责一个单独的目录,也即它的队列。而典型的邮Mailman系统给每个队列一个单独的进程也可以执行得非常出色单,我们使用了一个巧妙的算法允许在一个单一的队列目录中并行,而不需要任何形式的合作或锁定。其中的秘密便是以我们以队列目录来命名文件。

如上所述,每一个流经系统的报文也伴随着一个元数据字典来积累状态并允许Mailman中的独立部件互相通信。Python的’’pickle’’库能够序列化和反序列化多个对象到一个单独文件中,所以我们可以pickle报文对象树和元数据字典合并在一个文件中。

有一类Mailman核心类叫做’’Switchboard’’,提供一个接口使报文对象树和元数据字典在一个特定的队列目录中对文件的入队和出队操作。每个队列目录至少有一个switchboard实例,并且每个队列runner实例恰好有一个switchboard。

Pickle文件均以’’.pck’’为后缀,尽管你也可以看到’’.bak’’、’’.tmp’’、’’.psv’’的文件在队列之中。这些是用来确保Mailman中两个神圣不可侵犯的原则:没有文件会丢失,并且没有信息会被递送超过一次。但事情通常要正常运作,这些文件可能是相当罕见的。

正如所指出的那样,Mailman支持非常繁忙的网络,它在每个队列上完全平行地运行了不只一个runner进程,在处理文件之时它们之间没有任何通信也不必有任何锁定。它通过使用SHA1哈希来给pickle文件命名,然后允许一个单独的队列runner管理哈希空间的一部分。因此,如果一个站点想要在反弹队列(bounces queue)运行两个runners,一个在哈希空间上半部分处理文件,另一个在哈希空间的下半部分处理。通过使用pickled报文对象树的内容、信息已被确定的邮件列表的名字和时间戳来计算哈希值。SHA1哈希值具有高效的随机性,因此平均上一个双流道队列目录对于其每个进程都有相等数量的任务。因为哈希空间可以静态地划分,这些进程可以在同一个队列目录中进行操作,没有任何干扰也无需必要的通信。

这个算法有一个很有趣的限制。由于分裂算法分配给每个空间一个或多个位的哈希值,每个队列目录中runners的数量必须是2的幂。这意味着可以有1个,2个,4个,或8个runner进程在每个队列之中,但不可能是5个。在实践中,这从来不是一个问题,因为几乎没有站点需要超过4个进程来处理它们的负载。

此算法的另一个副作用是在这个系统早期的设计过程中产生了问题。尽管在通常的电子邮件发送是不可预见的,通过以FIFO顺序处理队列文件来提供最佳的用户体验,所以邮件列表的回复将以粗略的时间顺序发送。不去尽最大的努力尝试将使会员们感到困惑。但使用SHA1哈希值作为文件名将去除任何时间戳,为了性能原因’’stat()’’在队列文件中调用,或unpickling内容(例如,读取元数据中的时间戳)应该被避免。

Mailman的解决方案是扩展文件命名算法使其包括一个时间戳的前缀,正如纪元中秒的数字(例如,`+.PCK’’)。每个队列runner循环以执行’’listdir()’’开始,最后返回队列目录下的所有文件。然后对每一个文件,将文件名分开,忽略任何SHA1哈希值不匹配其负责部分的文件。runner然后将以文件名的时间戳部分为基础将剩余的文件分类。确实,多队列runner每个管理哈希空间的不同片段,这可能导致并行runners间的排序问题,但在实践中,时间戳排序足以保护最终用户感觉到最大程度的按序传送。

在实践中,这已经非常好地工作了至少十年,只有偶尔的小错误修正或精心的处理(针对模糊的角落用例和故障模式)。这是Mailman中最稳定的部分之一并且在Mailman 2到Mailman 3的过度中很大部分都是直接移植毫无改动的。

10.4.The Master Runner

拥有这些runner进程,Mailman需要有一种简单的方法能够持续地启动和停止它们;因此主监视进程诞生了。它必须能够处理队列中的runners和不管理队列的runners。例如,在Mailman 3中,我们通过LMTP从上游邮箱服务器的输入端接受信息,这是一种类似SMTP的协议,但其操作仅可本地传送,因此它可以非常简单而不需要处理过一个不可预知的互联网上传送邮件的异常行为。LMTPrunner仅仅监听一个端口,等待其上游的邮件服务器连接并发送一个字节流。然后将这个字节流解析为一个消息对象树,创建初始元数据字典,并且在一个进程队列目录中使其入队。

Mailman也有一个runner监听另一个端口然后处理HTTP上的REST请求。这个进程不处理队列文件。

一个典型的运行时Mailman系统可能有八个或十个进程,并且它们都需要适当且方便地停止和开始。它们也可以偶尔崩溃;例如,当Mailman中的一个bug导致突如其来的异常发生。当这一切发生的时候,被传递的消息被分流到一个固定区域,此时系统的状态正处在异常依旧存在于消息元数据的时间。这确保了未捕获的异常不会导致消息的多次交付。在理论上,Mailman网站点管理员可以解决这个问题,然后对违规的消息不去分流以再次传递,在它离开的地方挑出它。分流有问题的消息后,master重启崩溃的队列runner,开始处理队列中剩余的消息。

当主监视器启动时,它在一个配置文件中查看来确定有多少并且哪些类型的子runners需要启动。对于LMTP和RESTrunners,通常有一个单独的进程。对于队列runners,如上所述,可以有2的幂的个数的并行进程。master基于配置文件’’fork()’’和’’exec()’’所有的runner进程,经过适当的命令行参数相互传递(例如,告诉子进程需要查看哪一块散列空间)。master基本上是处于一个无限循环中,直到它的一个子进程出现时退才堵塞。它跟踪了每个子进程的进程标识,以及子进程已重新启动的次数的计数。此计数防止灾难性的错误(造成一连串不可阻挡的重新启动)。有一个配置变量指定多少次重启是允许的,在那之后一个错误将被记入日志并且runner不再重新启动。

当一个子进程退出时,master查看退出代码和杀死子进程的信号。每个runner进程都安装了大量能处理以下几个语义的信号: ‘’SIGTERM’’:故意停止进程。它不会重新启动。’’SIGTERM’’之行’’init’’当运行级别改变之时会杀死进程,也是Mailman本身用来停止子进程的信号。 ‘’SIGINT’’ :也常用来特意阻止子进程,它是一个信号发生在shell中使用control-C之时。runner不会重启。 ‘’SIGHUP’’ :告诉进程关闭然后重新打开日至文件,但不会持续运行。这将在轮换日至文件时使用。 ‘’SIGUSR1’’ :初始阻止子进程,但不允许master重启子进程。这在’’restart’’命令行的初始代码时使用。

master会相应这四种信号,但工作量不会超过将其转交给其子进程。所以如果你发送’’SIGTERM’’信号给master,所有的子进程都会收到’’SIGTERM’’然后退出。因为’’SIGTERM’’master知道子进程的退出,并且它也知道这是一个有意识的停滞,所以它不会重新启动runner。

为了确保在任何时间只有一个master运行,它获得了一个大约一天半的全时锁。master安装一个’’SIGALRM’’信号处理程序,来每天唤醒master一次以致于它可以重新刷新锁。由于锁的生命周期比唤醒的间隔时间长,锁应该永远不会超时或破坏当Mailman正在运行之时,除非系统崩溃或master被一个不可捕获的信号杀死了。在这些情况下,命令行界面到master进程间提供一个选项来重写一个过期的锁。

这导致master监视器的最后一位,命令行到它的接口。现实的master脚本中需要很少的命令行选项。它和队列runner脚本都是特意保持简单的。这不是Mailman 2中的特例,在此master脚本是相当复杂的并且做的太多,这使得它很困难来理解和调试。在Mailman 3中,真正的对于master进程的命令行接口是在’’bin/mailman’’脚本中,一种包含许多子命令的元脚本,在一个类似Subvision程序营造的受欢迎的风格之中。这减少了需要安装在您的shell的’’Path’’上的程序的数量。’’bin/mailman’’有子命令来启动、停止以及重启master,还有所有的子进程,并导致所有日志文件重新开放。’’start’’子命令fork()和exec()master进程,而其他只是发出相应的信号给master,然后如上所述传到子过程。这种改进后的责任分离使得每一个独立部分更容易理解。

10.5.规则、环节和链(Rules,Links,and Chains)

一个邮件列表的布置经过几个阶段,从第一次收到,直到它发送给列表的成员。在Mailman 2中,每个处理步骤被表示为一个handler,以及一系列的handlers都放在一个管道之中(pipeline)。所以,当一个邮件进入系统,Mailman将首先确定使用哪一个管道来处理它,然后在管道的每个处理程序handler将依次调用。一些处理程序会做一些适度的功能(例如,“这个人可以邮寄到邮件列表吗?”),其他会做修改的功能(例如,“我应该删除或添加哪一个头文件?”),其它的会复制报文到其他队列。后者的几个例子: 一个已经接收并要发送的消息将被复制到归档(‘’archiver’’)队列在某个时刻,因此它的队列runner将把报文添加到存档中。 该消息的副本最终不得不在输出(‘’outgoing’’)队列中结束,以便它可以被传送到上游的邮件服务器,该服务器最终负责将其递送给列表成员。 * 一份邮件的副本必须被放进一个摘要之中,为了那些只是偶尔、定期从列表中通信的人,而不是任何时候某人发送的单独的消息。

处理器的管道架构被证明是相当强大的。它提供了一个十分简单的方法,使人们可以扩展和修改Mailman来做定制一些自定义操作。处理程序的接口是相当直接的,实现一个新的处理程序将是一件十分简单的事,确保它被添加到管道中正确的位置以完成自定义操作。

这里有一个问题,在同一管道中缓和和修改混在一起是有问题的。处理程序必须在管道中被测序,或不可预测或不希望发生的事情则可能发生。例如,如果添加了[[http://www.faqs.org/rfcs/rfc2369.html|RFC 2369]]’’List-*’’表头后处handler将会跟着另一个handler出现把消息复制到摘要整理中,然后正在接收摘要的人们将得到不正确的列表邮件副本。在不同的情况下,它可能有益于缓和报文在修改它之前或修改它之后。在Mailman 3中,缓和和修改的操作已经被分割成独立的子系统,为了更好地控制先后顺序。

如前所述,LMTP runner解析输入字节流到一个消息对象树之中并且给消息创建一个生成初始元数据字典。然后将这些入队进一个或其他的队列目录之中。一些消息可能是电子邮件命令(例如,加入或离开一个邮件列表,获得自动化帮助等),这是由一个单独的队列处理。大多数邮件都是张贴到邮件列表中,这些被放入在输入队列中。输入队列runner按顺序处理每一个消息通过一个包涵了各种链接的链(chain)。有一个内置的链,供大多数的邮件列表使用,但这也是可配置的。

图10.3阐述了在Mailman 3系统的chain中的默认集合。链中的每一个环节(link)都是由一个圆角矩形所示。内置的链是一个适度的初始规则被应用到传入消息的地方,并在这个链中,每一个环节伴随一个规则。规则是简单的代码块,它获取三个特征参数:邮件列表、消息对象树和元数据字典。规则是不支持修改消息的;他们只是作出一个二进制的决定,并返回一个布尔值来回答问题,“规则是否匹配?”。规则还可以在元数据字典中记录信息。

在图中,当规则匹配时,实心箭头指示消息流,而在规则不匹配时,点线箭头指示消息流。每个规则的结果记录在元数据字典中以便日后Mailman会精确地知道(并且能够报告)哪些规则匹配和哪些错过了。虚线箭头指示转换是无条件的,不管规则是否匹配。

Figure 10.3: Simplified view of default chains with their links

着重提醒的一点是,规则本身不根据结果来调度。在内置链中,每一个环节都与规则匹配时执行的动作相关。例如,当“循环”规则匹配(意思是,邮件列表之前看到过这个消息)时,该消息立即被传递给“丢弃”链,它将其登记后丢弃该消息。如果“循环”规则不匹配,则链中的下一个关联会处理这个消息。

在图10.3中,伴随着“新闻”、“最大规模”,和“真理”规则的环节没有二元决策。在前二个的情况下,这是因为其行动被推迟了,所以它们简单地记录结果然后继续下一个关联的处理。如果以前的规则匹配,“任何”规则之后都会匹配。这样的话,Mailman可以报告所有为什么消息不被允许邮递的原因,而不是仅仅第一个原因。为了简单,有几个这样的规则没有在这里说明。

“真理”的规则有点不同。它总是与链中的最后一个环节关联,并且它总是匹配的。在倒数第二的”任何“规则扫除所有以前的匹配信息的组合,最后一个环节就知道任何到达这里的消息被允许可以发布到邮件列表,所以它无条件地将邮件移动到“接受”链。

有几个链处理过程的细节在这里没有被描述,但该架构是非常灵活和可扩展的,所以对任何类型的消息处理都可以实现,站点可以自定义,并且扩展规则、环节和链。

当它击中“接受”链时报文会发生什么?现在适合于邮件列表中的报文,在它被邮递给最终接收者之前,被放到管道队列中修改。这个过程在下面的章节中将更详细地描述。

“hold”链将消息放在一个特殊的桶中,为了核对人审查。“缓和(moderation)”链做了一些额外的处理,以决定是否应该接受消息,把持着为了核对人的批准,放弃或拒绝。为了不让图过于凌乱,“拒绝”链,用来反弹消息给最初的发送者,并是没有说明。

10.6.Handlers和Pipelines

一旦一个消息以其方式通过链和规则,并且被同意发送,消息必须进一步处理,才可以交付给最终收件人。例如,一些表头可能会增加或删除,有些信息可能会得到一些额外的装饰以提供了重要的免责声明或信息,比如如何离开邮件列表。这些修改是由一个包含一系列handlers的管道执行的。在类似的链和规则下,管道和handlers是可扩展的,但对于一般情况,有大量的内置管道。Handlers有类似的接口,就如规则,接受邮件列表、消息对象和元数据字典。然而,与规则不同,handlers可以修改消息。图10.4说明了默认的管道和一组的handlers(为了简单一些handers被省略)。
Figure 10.4: Pipeline queue handlers

例如,一个发布的消息需要有一个优先级’’Precedence’’:在表头添加,告诉其他自动化软件这个消息来自邮件列表。这表头是事实标准,以防止一些闲置程序响应邮件列表。通过“add headers”handler将其添加到表头(在其他表头修改之间)。与规则不同的是,handler顺序通常不重要,而消息总是流过管道中的所有handlers。

一些handler将消息的副本发送到其他队列。如图10.4所示,有一个handler,给哪些想要收到摘要的制作消息的副本。副本也被发送到归档队列中,为了最终交付给邮件列表存档。最后,将邮件复制到发送队列中,以最终传递给邮件列表上的成员。

10.7.VERP

VERP代表可变信封返回路径(http://cr.yp.to/proto/verp.txt|Variable Envelope Return Path ),它是一个众所周知的技术,邮件列表使用它明确收件人地址。当邮件列表上的地址不再活跃时,收件人的邮件服务器将发送一个通知返还给发件人。在邮件列表的这种情况下,你希望这个反弹回到邮件列表,而不是消息的原始作者;作者关于这个反弹不能做任何事情,更糟的是,发送反弹返还到作者可能泄露关于谁订阅了邮件列表的信息。当邮件列表得到了反弹,但是,它可以做一些有用的事,如禁用的弹跳地址或从列表的成员中删除它。

通常这有两个问题。首先,尽管对这些反弹有一个标准格式(称为发送状态通知[[http://www.faqs.org/rfcs/rfc5337.html|delivery status notifications]]),许多部署邮件服务器不符合它。相反,他们的反弹信息可以包含任意数量的机器难以解读的官样文章,为自动进行语法分析带来困难。事实上,Mailman使用的库,包含许多的反弹格式的试探法,所有这些都可以在Mailman存在的15年间看到。

其次,想象一个邮件列表的成员有几个转发的情况。她可能以地址''anne@example.com‘’被订阅,但是这可能转发到''person@example.org‘’,这可能进一步将消息转发到''me@example.net‘’。当’’example.net’’最终目标服务器接收邮件,它通常只发送一个反弹说''me@example.net‘’不再有效。但是发送邮件到Mailman服务器只知道例如''anne@example.com‘’的成员,所以反弹标记''me@example.net‘’将不包含订阅地址,而Mailman会忽略它。

随着VERP的到来,开发基本的SMTP协议需求来提供明确的反弹检测,通过返回这样的反弹报文给信封的发送者。这不是消息体的’’From:’’域,但实际上在SMTP对话期间’’MAIL FROM’’的值将被设定。在传送路线中这将一只保存,并且最终的接收邮件服务器是必需的,根据标准,发送反弹到这个地址。Mailman使用这个事实将原始收件人的电子邮件地址编码成’’MAIL FROM’’值。

如果服务器是''mylist@example.org‘’,然后VERP编码的信封发件人发布给''anne@example.com‘’为了邮件列表的递送:

''mylist-bounce+anne=example.com@example.org‘’

在这里,’’+’’是一个本地地址分隔符,这是一种被大多数现代邮件服务器支持的格式。所以当反弹回来,它会被送到 ''mylist-bounce@example.com‘’但是带着’’To:’’的表头仍旧设置VERP编码的收件人地址。Mailman可以解析这个’’To:’’表头来解码最初收件人例如''anne@example.com‘’。

然而VERP是从邮件列表中剔除坏地址的一个非常强大的工具,它有一个重要的潜在的缺点。使用VERP要求Mailman给每一个收件人确切地发送一封消息的副本。没有VERP,Mailman可以把给多个收件人发送的相同的邮件副本扎成一捆,从而减少整体的带宽和处理时间。但是VERP需要一个特定的’’MAIL FROM’’给每个收件人,而唯一的办法就是发送一个独特的消息的副本。一般来说这是一个可以接受的折衷之策,而事实上,一旦这些个性化的信息为了VERP被发送,就会有大量的Mailman也能做的有用的事。例如,它可以嵌入URL在个性化消息的页脚中,这些消息针对每一个收件人(给他们一个直接的链接从列表中退订)。你甚至可以想象各种类型的邮件合并操作,为每个单独的收件人定制邮件的主体。

10.8.REST

一个在Mailman 3中框架的改变指出来一个多年来的共同需求:让Mailman与外部系统更容易进行集成。当我被Canonical聘用时,一个Ubuntu项目的赞助商,在2007时我的工作最初加入邮件列表针对Launchpad,一个软件项目协作和托管平台。我知道Mailman 2可以做这件事,但有一个要求便是使用Launchpad的Web用户界面而不是Mailman的默认用户界面。由于Lauchpad邮件列表几乎总是在讨论列表之中,我们想要很小的变化以他们操作的方式。列表管理员不需要这么多可用的选项在典型的Mailman站点中,并且几乎没几个他们需要的选项来曝光在Launchpad网络用户界面。

当时,Launchpad并不是免费的软件(这在2009年改变),所以我们不得不以这样一种方式设计一个集成,Mailman 2的GPLv2代码不能侵染 Launchpad。这导致了大量体系结构的设计,那时集成设计相当棘手并且总有些许效率低下。现在因为Launchpad是一款自由软件批准在AGPLv3下,这些黑客即使当天不必做,但也不得不这样做,提供一些非常有价值的课程针对如何使一个网络用户界面极少的Mailman能够被其他的系统集成。一个核心引擎的画面显示出来,它高效可靠地实现了邮件列表操作,并且可以通过任何Web前端管理,包括一些用Zope,Django,或PHP写的程序,或是根本没有网络用户界面。

当时有大量的技术允许这一点,并且事实上,Mailman在Launchpad上的集成是基于XMLRPC的。但是XMLRPC有一些问题使其产生了一个并不理想的协议。

Mailman 3采用表述性状态传递技术(REST)模型便于外部管理员控制。REST是基于HTTP的,而Mailman的默认对象表示的是JSON。这些协议是在一个大范围的编程语言和环境中是无处不在且受到良好支持的,使其很容易集成带有第三方系统的Mailman。REST完美契合于Mailman 3,而现在它的许多功能是通过REST的API被展示的。

这是一个功能强大的范例,以致很多的应用程序应该采用:提供一个核心引擎,更好地实现其基本功能,展露给REST的API以便查询和控制它。REST的 API不但提供了集成Mailman的另一种方法,而且也正在使用命令行界面,编写Python代码来访问内部API。这种架构是非常灵活的,可以被使用和集成,以一种超越了最初的视觉系统设计的方式。

这个设计不仅允许更多更好的选择进行部署,而且甚至允许了官方的系统部件可以被独立地设计和实现。例如,新的官方Mailman 3的网络用户界面在技术上是一个分开的项目,有自己的代码库,主要是通过有经验的网页设计师驱动。这些优秀的开发人员有权作出决定,创新设计,并在没有核心引擎开发的阻碍下执行并实现。网络用户界面工作反馈了核心引擎的实现,通过请求附加功能,通过REST的API展示,但它们不必等待它,因为它们可以在其末端模拟服务器并继续试验和开发网络用户界面,同时核心引擎逐步赶上。

我们计划使用REST的API做更多的事,包括允许将通用操作和IMAP或NNTP服务器的集成编写成脚本为了替代访问存档。

10.9.国际化(Internationalization)

GNU的Mailman是接受国际化的第一批Python程序之一。当然,因为Mailman通常不修改邮递过的邮件消息的内容,这些消息可以是最初的作者所选择的任何语言。然而,在Mailman直接的相互作用下,或者通过Web界面或通过电子邮件发送的命令,用户会更喜欢使用自己的自然语言。

Mailman率先提出了许多用于Python领域里的国际化的技术,但它实际上比大多数应用程序更复杂。在典型的桌面环境中,当用户登录时将选择自然语言,并在整个桌面会话中保持静态。然而,Mailman是一个服务器应用程序,所以它必须能够处理多种语言,独立于其运行系统的语言。事实上,Mailman必须以某种方法确定语言语境,以便响应被返回,并将其文本语言翻译成那种语言。有时响应可能涉及多种语言;例如,如果一个反弹消息从一个日本用户转发到可以说德语,意大利语,和加泰罗尼亚语列表管理员。

此外,Mailman率先提出一些关键的Python技术来处理复杂的语言环境等。它利用一个库,管理语言的栈,随着上下文的变化可以push和pop,甚至处理一个单独的消息。它还实现了一个精心的策划来自定义其基于网站洗好的响应模板,列出所有者的喜好,然后语言选择。例如,如果一个列表所有者想要为她的一个列表定制一个响应模板,但仅针对日本用户,她将把特定的模板放置在文件系统的适当位置,并且这将覆盖更多的通用默认值。

10.10已学习课程(Lessons Learned)

虽然这篇文章已经提供了一篇Mailman 3架构的概述和一个关于这个架构在它存在的15年间(经过了三次主要的重写)是如何演化的领悟,还有很多Mailman中的有趣的架构设计我并没有覆盖。这包括配置子系统、测试基础设施、数据库层、纲领性的形式化接口的使用、归档、邮件列表样式、电子邮件命令和命令行界面以及发送邮件服务器的集成。联系我们在[[https:*mail.python.org/mailman/listinfo/mailman-developers|mailman-developers mailing list]]如果你想了解更多。

这里有一些我们在重写受欢迎的、已确立的和稳定的开源系统片段中获得的经验教训。

  • 使用测试驱动开发(TDD)。真的没有其他的方式!Mailman 2很大程度上缺少一个自动化测试组件,虽然这是真的:不是所有的Mailman 3中的代码库都被测试组件所覆盖了,其中最重要的是,所有伴随着测试所需要的新代码,使用了’’unittests’’或’’doctests’’。做TDD是获得信心的唯一途径,那使你今天所做的在已经存在的代码中不会引入回退。是的,TDD有时需要较长的时间,但把它作为你的未来代码质量的一种投资。在这种方式中,没有一个好的测试组件意味着你只是在浪费你的时间。记住真言:未测试的代码是断码。
  • 使你的字节/字符串的从一开始就是整齐的。在Python 3中,做了明显的区分在Unicode文本字符串和字节数组中,其中,最初的痛苦,是编写正确的代码是一个巨大的效益。Python 2的这条线模糊不清,在Unicode和8位ASCII字符串之中有一些自动的强制转换。虽然看似是一个有用的便利,这条模糊的线带来的问题是Mailman 2错误的头号原因。事实上没有任何帮助,电子邮件分类成字符串和字节是众所周知的困难。在技术上,线上电子邮件表示为一个字节序列,但这些字节几乎都是ASCII码,也有着把消息组件作为文本操作的强烈诱惑。电子邮件自身的标准描述为人类怎样可读,非ASCII文本可以安全地进行编码,所以即使像发现’’Re:’’前缀在一个’’Subject:’’表头之中将是文本操作,而不是字节操作。Mailman的原则是尽可能简单地将所有的收入入数据从字节转换为Unicode,在内部把文本当做Unicode处理,只有在外面将其转换为字节。当你在处理字节和处理文本时,从一开始就非常清楚是极其重要的,因为在转变之后很难再改进这个基础模型。
  • 从一开始就国际化你的程序。你想让你的应用程序只使用在世界上说英语地方的组成区域么?想想这忽略了多少出色的使用者!建立国际化并不难,也有很多好的工具使其变得容易,其中很多都在Mailman中做了先驱者。不要担心开始的翻译,如果你的应用程序是可以访问的对于世界上丰富多彩的语言,你会拥有志愿去翻译的人来敲你的门给你帮助。

GNU的邮差是一个有健康用户基础的充满活力的项目,其中有很多贡献的机会。如果你认为你愿意帮助我们,这也是我所希望你做的,这里有你可以使用的资源!