第 8 章
项目文件的腔调
红灯还在亮着,看灯的人越来越少。测试实践的手艺传递在社区扩大后出现断层,那些没有找到师傅的新入者,可能留下一批没有测试的库,然后从视野里消失。这个未决问题如何影响生态的稳定性,答案并不只在测试目录里。把时间拨回更早——早到项目第一次被声明的时刻。那个时刻最具体的痕迹,不是源码,不是测试文件,而是一份所有开发者都会先打开、却很少逐行想过的文件。它的第一行通常有一个左圆括号,括号里写着defproject,后面跟着项目名称和版本号。这份文件后来被称作项目声明。它不像源码那样表达计算逻辑,却规定了一个项目向社区介绍自己的方式:依赖谁、版本落在哪里、入口在何处。测试需要统一的编译环境和依赖解析,项目文件恰好为这种协作前提提供了物质基础。而一张名片的写法,从来不是中性的技术选择。最早期的克洛杰尔(Clojure)项目并没有统一的声明格式。
2008年,邮件列表里有人分享代码,通常只是在正文里贴一段源码,附上一句关于把某个压缩包放进类路径的提示。当时项目规模还小,一个命名空间、几百行代码,依赖关系用手指就能数完。开发者之间的信任建立在邮件列表的声誉上。你见过这个人回答过问题,知道他的代码风格,就能判断他的东西值不值得用。项目声明还没有从人际信任中独立出来,成为一套需要专门学习的规矩。变化出现在2009年。随着克洛杰尔包仓库(Clojars)被广泛使用,项目之间的依赖关系迅速复杂化。一个库可能依赖另外三四个库,那三四个库又各自有自己的依赖。手工管理类路径变成一件头疼的事。开发者需要在某个地方写下这些依赖关系,让工具自动去解析和下载。这个“某个地方”,就是项目声明文件。但怎么写这份文件,不同的人有不同的想法。最早被社区广泛采用的构建工具是安特(Ant),一个来自爪哇(Java)世界的通用构建系统。里奇·希基在公开发布Clojure之前,花了大约两年半的时间开发,没有外部资金,大部分时间都专门投入其中。开发快要完成时,他向Common Lisp社区的一些朋友发送电子邮件,宣布完成。这个细节提醒我们,Clojure从一开始就依赖个人投入和社区信任,而项目声明文件的演变正是这种信任结构在工程操作中的具体化。
安特的构建文件使用可扩展标记语言(XML)格式,冗长、啰嗦,充满尖括号和闭合标签。一个只有三个依赖的项目,它的安特构建文件可能膨胀到上百行。更麻烦的是,这种标记语言本身不具备编程语言的表达能力。你不能在里面写循环,不能定义函数,也不能根据条件分支。它只是一份静态的指令清单。对于那些刚从即时求值器(REPL)的手感中体会到这门语言表达力的开发者来说,用标记语言写构建文件,就像从诗词回到账本。但安特也有道理。爪哇生态里几乎所有开发者都熟悉它,文档齐全,工具链成熟。对于希望让这门语言被爪哇开发者接受的早期推动者来说,使用安特意味着降低进入门槛。这不是技术优劣的问题,而是关于项目应当如何被描述的规矩之争:是用爪哇世界通用的方式,还是用这门语言自己的方式。2010年初,一个叫莱宁根(Leiningen)的工具开始出现在邮件列表讨论中。这个名字取自童话里一个裁缝学徒,含有把东西组合起来的意思。莱宁根为Maven集成提供支持,处理项目包管理和依赖项,其配置使用Clojure语法。
作者是菲尔·哈格尔贝格(Phil Hagelberg),社区里不少人用网名称呼他。他做了一件在当时看来相当大胆的事:用这门语言的S表达式语法来写项目声明文件。一份典型的莱宁根项目文件看起来像这样:一对圆括号,里面是defproject宏,跟着项目名称、版本号、一串键值对。依赖关系写在一个向量里,每个依赖是另一个向量,包含库名和版本号。这份文件本身就是合法的克洛杰尔代码。这意味着可以在里面使用变量、函数甚至宏——如果需要的话。这个选择引发了社区里第一次关于项目文件腔调的讨论。支持者说,这才是这门语言的方式。项目声明文件不应该是一份死板的配置,而应该是活的、可编程的。如果需要在开发环境和生产环境使用不同依赖,不需要学习另一套条件语法,只需要写一个条件表达式。如果有一组经常一起使用的库,可以把它们定义成一个列表,在多个项目里引用。莱宁根的项目文件把这门语言的表达力延伸到了构建过程本身。反对者的声音也不小。
他们担心另一件事:可编程性意味着不可预测性。一份项目文件如果可以包含任意代码,那么仅仅解析它就可能执行任意逻辑。这对安全性来说是个隐患——一个人克隆仓库,还没读源码,先执行了它的项目文件。更重要的是,当项目文件变得过于复杂,它就不再是一份清晰的声明,而是一个需要调试的程序。依赖关系本应透明,可编程性却可能让它变得不透明。这场讨论没有产生正式决议。没有核心团队发布过项目文件最佳实践,也没有人在邮件列表里发起投票。但一种腔调逐渐形成:莱宁根的项目文件应该保持简洁。可以用这门语言的全部表达能力,但大多数时候不应该用。依赖向量应当一目了然,版本号应当写死而不是浮动,自定义逻辑应当限制在绝对必要的情况下。这不是通过规则强制执行,而是通过示范和模仿。一个新开发者克隆几个流行开源库后,会看到它们的项目文件都很短、很整齐,依赖关系清清爽爽地列着。他就会照着这个格式写自己的。
如果有人把项目文件写得太复杂,其他开发者在代码审查或邮件列表讨论中,可能会委婉地建议这段逻辑放进源码更合适。这种温和的纠正比任何明文规范都更有效,因为它保留选择的余地:真的需要的话,仍然可以那样做,但会知道自己偏离了社区期望。莱宁根的项目文件腔调在二〇一一年到二〇一三年间稳定下来,成为社区最普遍的门面语言。一个开发者加入新项目,第一眼看的就是项目文件。从这份文件里,能读出项目的依赖、目标平台和入口。他也能读出一些不那么显眼的信息:这个项目的作者是否遵循社区惯例,是否理解那些不成文的约束,是否值得信任。这正是项目文件作为门面的深层含义。它不只是技术元数据,也是一份社会性的自我介绍。一个把版本号写成“最新”的项目文件,暗示作者可能不太关心构建的可复现性。一个列出了三十几个依赖却没有说明理由的项目文件,让人觉得作者可能没有仔细甄别依赖的必要性。一个依赖了某个无人维护的库却不解释原因的项目文件,会让有经验的开发者在心里打一个问号。
这些判断并没有写进任何正式文档,却在社区中广泛共享。它们构成了一种非成文治理规则在日常工程操作中的执行。没有人会因为违反这些惯例而被驱逐,但违反者会发现自己的库被采用得更慢,收到的问题报告更多,获得贡献者的概率更低。这是一种温和但有效的约束机制。然而,莱宁根的主导地位并不意味着项目文件腔调的争议就此结束。二〇一三年到二〇一四年间,社区经历了一次显著增长。新加入的开发者带来了不同的背景和期望。有些人来自另一个以约定优先著称的网络应用框架社区,习惯了包管理器的风格;有些人来自脚本语言社区,习惯了数据格式写成的依赖清单;有些人来自爪哇世界,习惯了马文(Maven)的构建描述。当他们第一次看到莱宁根项目文件时,反应各不相同。有人觉得用S表达式写依赖关系很自然,因为整门语言本来就是这样的。有人觉得这很奇怪:为什么构建配置要用代码来表达?配置不应该是声明式的数据吗?还有人提出更具体的担忧:项目文件和源码使用相同的语法,但语义完全不同。
源码表达运行时逻辑,项目文件表达构建时配置。用同一种语法表达两种不同的东西,会不会造成混淆?这些讨论在邮件列表里反复出现,但从未升级为正式争议。原因在于,莱宁根的项目文件虽然使用这门语言的语法,实践中却被当作数据而非代码来使用。大多数项目文件只包含字面量数据结构——字符串、向量、映射——没有任何函数调用或控制流。这种约定俗成的用法消解了可编程配置可能带来的复杂性。开发者们在不知不觉中达成平衡:语法是代码的,内容是数据的。这种平衡的达成过程,恰好展示了社区处理分歧的典型方式。没有权威机构裁定正确写法,没有规范文档定义合法格式。正确做法通过示例传播,通过模仿扩散,通过温和的纠正维持边界。那些偏离惯例的写法不会遭到禁止,但会被忽视。在这个社区里,被忽视往往比被批评更有效。2015年之后,一个新的变量进入项目文件腔调的讨论:布特(Boot)构建工具的出现。它采用完全不同的设计哲学。构建文件不是静态声明,而是可执行程序。
在它的理念中,构建过程本身就是一系列函数的组合,项目文件应该充分利用语言的表达力来描述这个过程。布特的构建文件通常叫作build.boot,比莱宁根的项目文件更像真正的代码。它定义任务、组合中间件、设置环境,整个文件从头到尾都是函数调用。对于习惯莱宁根简洁风格的开发者来说,布特的构建文件看起来过于复杂。但对那些需要精细控制构建流程的项目来说,布特提供了莱宁根难以比拟的灵活性。这不仅仅是两个工具的竞争,也是两种关于项目应当如何被描述的理念之争。莱宁根代表的是声明式传统:项目文件应该是一份清晰规格,告诉你这个项目是什么、依赖什么、如何启动。布特代表的是编程式传统:项目文件应该是构建过程本身的可执行描述,构建逻辑不应该隐藏在工具实现细节里。这场争论在二〇一五年到二〇一六年间达到高潮。邮件列表里出现长篇技术辩论,会议演讲中有人为各自工具辩护,博客文章分析两者优劣。但有趣的是,争论从未演变成社区分裂。
大多数开发者同时使用两种工具:用莱宁根处理常规项目,用布特处理需要复杂构建逻辑的项目。项目文件腔调因此变得更加多元:不再是单一模板,而是一个可以选择的光谱,从纯声明到纯编程,每个项目根据自己的需求找到位置。这种多元化恰恰印证了隐性宪法的运作方式。社区没有选择赢家,没有宣布某种方式为官方标准,而是允许不同实践共存,让使用者在具体情境中做选择。这不是混乱,而是一种更复杂的秩序——一种基于实践而非规则的秩序。但多元化的另一面是困惑。对新人来说,面对两种构建工具和两种截然不同的项目文件风格,选择变得困难。他们不知道该学哪一个,不知道哪一种写法才是对的。邮件列表里开始出现相同的提问:到底应该学哪一个工具,哪一种写法才算对。回答通常是看需求,但对刚刚入门的人来说,这并不够有帮助。这种困惑在测试实践的断层中已有先兆。当社区规模超过面对面示范可以自然覆盖的范围,那些依赖模仿和耳濡目染的隐性知识就开始流失。
项目文件腔调原本通过阅读别人的项目文件来学习,但当项目文件出现两种主流风格,新人失去了清晰的模仿对象。他们可能随机选择一种工具,照着文档写一个能用的文件,却不理解其中的惯例和约束。这正是隐性宪法的脆弱之处。它依赖社区成员之间的持续接触和共同实践来维持约束力。当社区增长到一定规模,接触变得稀疏,共同实践变得碎片化,那些不成文的规则就开始松动。项目文件腔调从一种广泛共享的共识,变成不同小群体各自维持的局部惯例。二〇一七年之后,又一个因素加剧了这种碎片化:依赖数据(deps.edn)的出现。这是核心团队在一点九版本中引入的新依赖管理方式,使用数据表示格式(EDN)而不是S表达式来声明依赖。依赖数据严格来说是数据而非代码,不支持可编程性,所有依赖解析逻辑都隐藏在工具实现中。依赖数据的设计哲学与莱宁根和布特都不同。它是最纯粹的声明式:项目文件就是一份数据,描述需要什么,至于怎么获取、怎么解析、怎么加载,那是工具的事。
这种设计回归到配置作为数据的理念,放弃了莱宁根的可编程性和布特的完全可编程性。但对已经在使用莱宁根或布特的开发者来说,依赖数据的出现带来新的选择焦虑。现在有三种主流方式声明一个项目。每一种都有自己的哲学、自己的语法、自己的最佳实践。新入者面对的不再是一个清晰的模仿对象,而是三个各说各话的门面语言。项目文件腔调从一种不成文但广泛共享的共识,变成一场尚未结束的谈判。谈判焦点不再是莱宁根项目文件应该怎么写,而是更根本的问题:一个项目应该用什么方式声明自己?谁有权决定这个问题的答案?核心团队推出依赖数据,本身就带有权威的暗示:这是语言官方提供的依赖管理方式。但社区反应并非一边倒接受。许多资深开发者继续使用莱宁根,因为它成熟、稳定、生态完善。布特的用户数量较少,但忠诚度高,因为他们真正需要布特提供的灵活性。三种方式并存,各有各的道理,各有各的拥护者。这种局面在二〇一九年之后逐渐稳定下来,但稳定并不意味着解决。
它更像一种僵持:各方都保留自己的实践,没有人愿意彻底转向另一种方式,也没有人能够说服所有人接受单一标准。项目文件腔调因此失去了曾经具有的统一性。打开一个项目仓库,不再能确定第一眼看到的是什么文件:是莱宁根的项目文件、布特的构建文件,还是依赖数据文件。每一种都暗示作者所属的工具阵营和设计哲学。这种多元化的代价是信任门槛的提高。在莱宁根主导的年代,一个开发者只需要学会阅读项目文件,就能理解绝大多数项目的结构和依赖关系。现在需要熟悉三种不同声明格式,理解各自的特性和局限。对想为项目做贡献的人来说,这意味着额外学习成本。对想评估依赖库可靠性的人来说,这意味着更多需要检查的变量。回到开头的问题:测试实践的断层如何影响生态的稳定性?项目文件的多元化提供了一个结构性解释。测试需要统一的编译环境和依赖解析,而编译环境和依赖解析依赖项目文件的正确配置。当项目文件写法变得多元,正确配置的门槛就提高了。
新人可能在配置阶段就遇到困惑,还没走到写测试那一步就已经消耗大量精力。那些没有找到师傅的人,不仅在测试实践上缺乏指导,在项目文件写法上同样缺乏示范。这并不是说多元化本身是坏事。不同项目确实有不同需求,多种工具的存在提供选择空间。但多元化的另一面是碎片化,而碎片化对依赖共同实践传递的隐性知识来说,是一种侵蚀。当每个人都按自己的理解写项目文件,那些曾经通过模仿传播的惯例就逐渐淡化。最终留下的不是一种共识,而是多种习惯并存:每一种都有道理,但没有一种拥有足够约束力来维持社区的凝聚力。2020年的某一天,有人在邮件列表里报告自己的项目本地可以编译,换到持续集成服务器上却失败。排查几轮后,问题出在他的依赖数据文件里——他写错了一个依赖的版本号格式。没有人责怪他,因为依赖数据的版本号规范确实与莱宁根略有不同。但这个小插曲折射出一个更大变化:曾经那些通过阅读别人项目文件就能自然学会的东西,现在需要专门查阅文档才能搞清楚。
隐性知识正在变成显性知识,而显性知识总是有遗漏的。这就是项目文件腔调演变的后果之一。它从一个不成文共识变成需要明确学习的技能。过程中有些东西丢失了:那种通过阅读代码就能感受到的社区惯例,那种不需要解释就能意会的写法规范,那种让新人感到自己终于知道怎么做的清晰感。但硬币的另一面是,显性化也带来新的可能。依赖数据的文档比莱宁根的项目文件规范更清晰、更完整。新人虽然需要学习更多,但学到的知识更系统、更可查证。那些曾经只能通过接触传递的手感,正在慢慢沉淀为可以书写的规则。这不是隐性宪法的消亡,而是它向成文转化的努力——尽管社区刻意保持一定模糊性,以保留共识的灵活性。2022年,克洛杰尔共同资助组织(Clojurists Together)发布了一份调查,询问开发者使用的构建工具。结果显示莱宁根仍占多数,但依赖数据使用率快速增长,布特则维持一个小而稳定的用户群。三种工具谁也没有消灭谁,谁也没有成为绝对的官方标准。
这份调查本身就像一面镜子,映照出社区在项目文件问题上的真实状态:不是统一,而是多元共存;不是共识,而是相互容忍。这份调查没有引发新的争论。邮件列表里没有人呼吁统一工具链,也没有人提议投票决定官方构建工具。社区似乎已经接受这种多元化状态,将其视为成熟的表现而非分裂的征兆。但这种接受也意味着,项目文件腔调的统一性已成为历史。未来的开发者将生活在一个多种门面语言并存的世界里,需要学会在不同腔调之间切换,理解每一种背后的设计哲学和使用惯例。对于一个没有大厂背书的语言来说,这种多元化既是韧性的证明,也是脆弱的源头。韧性在于,社区不依赖单一工具或标准维持运转;不同实践可以并行发展,互相借鉴,即使某一种工具停止维护,整个生态也不会崩溃。脆弱在于,碎片化实践使隐性知识的传递变得更加困难;那些不成文规则在不同工具阵营中各自演化,跨阵营共识变得越来越稀薄。这里有必要回答一种相反的解释。
有人认为,这门语言的存活主要归功于创建者里奇·希基(Rich Hickey)的个人权威,以及爪哇平台生态带来的寄生优势;社区共识不过是事后合理化的表象,并没有真正的治理力量。这个解释不能简单否定。个人权威确实存在,在语言设计的重要关头,里奇·希基的判断常常一锤定音。爪哇平台提供了成熟虚拟机、海量库和工具链,这种基础条件也确实降低了这门语言的生存成本。但项目文件腔调的演变,恰好提供了一个反例。如果个人权威和寄生优势足以解释一切,那么项目声明文件的写法就应当由创建者或核心团队早早规定,社区只需要照办。事实并非如此。从安特到莱宁根,从布特到依赖数据,没有哪一种工具是由里奇·希基直接设计的,也没有哪一种项目文件风格是通过行政命令推行的。它们先后出现,各自赢得一批使用者,又在日常实践中形成不同的腔调。即便是核心团队后来推出的依赖数据,也没有取代另外两种工具。这说明,在工程操作层面,社区确实拥有某种独立于创建者个人权威的治理力量。
这种力量的来源,不是正式授权,而是对违反后果的集体预期。一个不使用莱宁根的项目不会受到惩罚,但它的构建方式会偏离大多数人的日常路径,其他人参与时会多一层不确定。一个把项目文件写得太复杂的作者不会被除名,但他的库在代码审查时会被多看几眼,被采用时会被多问几句。这些后果没有写在任何章程里,却在无数次选择中反复出现,逐渐形成约束。这正是隐性宪法在项目文件问题上的运作方式。也需要承认,爪哇生态的寄生优势并没有替这门语言解决依赖管理问题。恰恰相反,最早期的安特方式才是直接借用爪哇生态的做法,但社区最终没有停留在那里,而是发展出用这门语言自身语法书写的项目文件。这个选择本身,就是把信任结构从外部生态拉回社区内部。莱宁根项目文件里列出的依赖,并不直接来自马文中心,而更多来自社区自己维护的包仓库。这意味着,当一个库被写入项目文件,它进入的是社区内部信任网络,而不仅仅是爪哇生态的一部分。项目文件因此不止是一面镜子,也是一道门槛。
它决定了哪些库可以进入许多项目的日常路径,决定了依赖解析从何处开始,也决定了构建失败时最先被检查的是哪几行。这种决定权分散在每一个写项目文件的人手里,但它同时受到工具惯例、社区示范和温和纠正的约束。那些看似微小的版本号写法、依赖排列顺序、注释有无,其实都是围绕这种决定权的日常磋商。这场磋商至今没有结束。它变得比以往更安静,却也更深刻。当三种工具并存,当项目文件不再有统一格式,那些原本可以通过示范自然传递的腔调,正在变成需要明说的规则。一些规则被写进文档;另一些仍然只在代码审查的沉默里生效。两者的界限并不稳定,社区也没有急着把它固定下来。这种刻意保持的模糊,不是无能,而是一种选择——只要还没有写成白纸黑字,共识就仍然可以修改。最终,项目声明文件仍然是开发者最先读到的文本,但读者从中读到的东西,已经和十年前不同。过去,他们读到一种心照不宣的惯例;现在,他们读到多种惯例的并置,以及这些惯例之间的空隙。
空隙里藏着问题:谁有权决定共同体未来的日常秩序?这个问题没有答案,只有一次次的临时安排。每一次安排都凝结在一份文件的第一行,一个左圆括号,一个版本号。灯还亮着,看灯的人还在自己的项目文件前犹豫,不知道该把依赖写进哪一套语法。这个犹豫,不再是测试缺席的直接后果,却比测试缺席更早地拦在每个人面前。它提醒后来者,在代码开始运行之前,必须先回答一个更基本的问题:项目如何被声明,以及由谁来决定这种声明的样子。