我们的250k线Clojure Codebase之旅

2021-06-04 03:25:51

在红色的行星实验室,我们已经悄悄地开发了多年的新型开发工具。我们的工具通过多个数量级来降低建立大型端到端应用的成本,而Clojure是我们能够用一支小型团队解决这样一个雄心勃勃的项目的重要原因。

我们的Codebase由250k行的Clojure在源和测试代码之间均匀分开。它是世界上最大的Clojure Codebase之一。在这篇文章中,我将参考我们如何组织我们的代码,因此可以在团队中,我们使用的开发和测试技术中可以理解这一规模的项目,从而利用Clojure的独特品质,以及关键库的概述我们用。

我们的Codebase的最酷部分之一是其基础的新通用语言。虽然语言的语义与Clojure的语义不同,但它在使用宏中完全在Clojure内定义,以表达不同的行为。它使用ASM库直接编译为字节码。我们的其余系统是使用这种语言和Vanilla Clojure建造的,无缝互操作。

我们的语言的一个引人注目的能力,Vanilla Clojure并不是一流的持续。我们的语言表达持续的方式使其在异步,平行和无功规划中非常擅长。所有这些都是我们建造的大型分布式基础设施的基础。

您可以在Clojure内使用完全不同的语义构建完全新的语言,证明了Clojure的强大程度。有很多你得到了很多"免费"在以这种方式构建语言时:LEXING,解析,数据类型,名称空间,不可变数据结构以及CLOJURE和JVM的整个库生态系统。最终,我们的新语言是Clojure,因为它在Clojure中定义,因此它与Clojure和JVM都有效益。

绝大多数申请不需要像我们一样开发完整的语言。但是有很多用例子,其中聚焦的DSL是合适的,我们也有一个例子。使用Clojure进行自定义代码本身是如何解释的,通过宏和元编程,是一种令人难以置信的强大功能。

任何CodeBase的核心是创建,管理和操纵的数据。我们发现它必须仔细,并清楚地记录系统周围的数据。同时,类型或架构注释增加了开销,所以要进行体贴,而不是过度思考是很重要的。

我们使用模式库来定义Codebase中的数据类型。它易于使用,我们喜欢灵活地定义超出类型的模式约束:例如任意谓词,枚举和工会。我们的Codebase包含大约600个类型的定义,其中大部分都是使用架构注释的。

围绕架构我们有一个叫做" defrecord +"它定义了也执行验证的构造函数(例如,它为其生成" - > - >有效的 - foo"和" map->有效 - foo")。如果架构检查失败,这些函数会抛出描述性异常。

在Clojure中没有静态类型检查,并且静态类型检查将无法检查我们使用架构定义的所有类型的约束(例如,数字在某个范围内的值)。我们发现我们只需要插入架构检查:

施工类型,我们的自动生成和#34;有效"构造函数功能删除所有仪式。在创建记录时检测到错误比在稍后使用时更好,如在创建期间,您就可以使用所需的上下文来调试问题。

我们偶尔只会注释函数args和返回值的类型。我们发现,始终如一,我们将如何命名为何种方式足以了解代码。我们在我们的代码库中有大约500个断言,但这些通常是关于更高级别的属性而不是简单类型的检查。

我们采取了模式定义和实施的方法是重量轻,全面,并没有通过我们的方式。 Clojure中缺乏静态打字吓到了很多从未使用Clojure的程序员,我们可以说的只是在你组织你的代码中有一点思考,它根本不是一个问题。并动态做事意味着我们可以使用静态类型系统来强制执行更强大的限制。

我们的Codebase存在于单个Git repo中,具有四个模块来分割实现:

"核心"其中包含我们编译器的定义和并行编程的相应抽象

我们使用Leiningen和Deps.edn为我们的构建。将本地目标指定为Deps.edn文件中的依赖性的能力是我们的多模块设置的关键,我们的源树的基本组织如下所示:

此设置允许我们在任何一个模块中开发,并自动查看其他模块中的任何源更改,而无需进行显式的Maven依赖项。

加载整个代码库进行运行测试或加载REPL非常慢(主要是从使用我们的自定义语言编译代码),因此我们使用AOT编译速度迅速启动。由于我们花费大部分时间在“分布式”中发展,我们将AOT编译“核心”来加速。

幽灵是一个我们开发的库,用于加强我们使用数据结构的能力,尤其是嵌套和递归数据。幽灵基于“路径”的概念,进入数据结构,其中路径可以从数据结构的根目录“导航”到任意数量的值。路径可以包括遍历,视图和过滤器,并且它们非常可编译。

我们的编译器将代码编译为抽象表示,并为我们的语言中的每种操作都有不同的记录类型。每个操作类型都必须以统一的方式曝光各种属性。例如,其中一个属性是“需要的字段”,关闭该操作的字段需要执行它的工作。表达这种多态行为的典型方式是使用界面或协议,如下所示:

这种方法的问题只是涵盖查询。我们的编译器的一些阶段必须在整个抽象表示中重写字段(例如,唯一定义删除阴影),此协议不支持该字段。 a(设置所需的字段[此字段])方法可以添加到此协议中,但这并不清晰适合具有固定数量的输入字段的数据类型。它也没有很好地讨论嵌套操作。

相反,我们使用幽灵的"协议路径"组织不同编译器类型的公共属性的功能。这是我们编译器的摘录:

(defprotocolpath所需的字段[])(Defrecord + OperationInput [字段: - [(s / predopopvar?)]应用?: - boolean])(defrecord +调用[op: - (s / cond -pre(s / pred opvar? )IFN RFN)输入: - OperationInput])(扩展-ProtocolPath所需的字段调用(Multi -Path [:Op Opvar?] [:输入:字段全部]))(Defrecord + Varannotation [var: - (s / predopvar?)选项: - {s /关键字对象}])(扩展-protocolpath所需字段SARANnotation:var)(Defrecord + Producer [Producer: - (S / Cond-Pre(S / Prep Opvar?)PFN))(扩展-ProtocolPath所需菲尔德生产者[:生产者OPVAR?])

"例如,Invoke",例如表示调用另一个函数的类型。 :OP字段可能是静态功能或对闭包中的函数的VAR引用。另一条路径导航到用作函数调用的所有字段。

这种结构非常灵活,允许通过直接与幽灵集成来表达疑问的修改。例如,我们可以附加A" --foo"在一系列操作中的所有所需字段的后缀如此:

如果我们希望在一系列OPS中使用的独特字段,则代码是:

协议路径是使数据本身多态性并且能够与幽灵的增压能力集成的方式。它们大大减少了否则所需的操作辅助功能的数量,并使CodeBase更加可理解。

包括我们构建的分布式系统的守护进程由数十个子系统组成,这些子系统彼此构建并彼此依赖。子系统需要以特定顺序启动,并且在测试中,必须以特定的顺序撕下。此外,在测试中,我们需要能够为某些子系统注入模型或完全禁用某些子系统。

我们使用组件库以管理生命周期的方式组织我们的子系统,并为我们提供重新注入替代依赖项或禁用子系统的灵活性。在内部,我们建造了一个" defrcomponent"帮助统一领域和依赖声明。例如,来自我们的codebase:

这会自动检索字段"转移和#34 ;,"服务处理程序"和#34;集群 - 猎犬"从系统映射开始,它已启动并使其在关闭组件的实现中可用。它期望一个字段"端口"在组件的构造函数中,它生成另一个字段" jetty-instance"启动进入其内部关闭。

我们还将组件生命周期范例扩展为" start-async"和#34; stop-async"协议方法。某些组件在其他线程上执行部分初始化/拆除,对我们的其余系统(特别是确定性模拟,下面描述的)对于那些以非阻塞方式进行可行而重要。

我们的测试基础架构在组件上构建了依赖注入。例如,来自我们的测试代码:

第一个映射是依赖注入映射,此代码禁用“Ticker”组件。 “股票机”导致仿真测试偶尔推进时间,并且由于该测试想要明确控制时间它禁用它。该依赖注入地图可用于覆盖或禁用系统中的任何组件,提供写入测试所需的灵活性。

Clojure提供宏"有重复的"这可以重新定义在该形式范围内执行的任何功能,包括其他线程。我们发现这是写作测试的宝贵功能。

有时,我们将在我们测试的依赖关系中使用 - 重新使用来模拟特定的行为,以便我们可以在隔离中测试该功能。其他时候我们使用它来注入故障以测试容错。

在我们的代码库中最有趣的是重定码以及我们最常见的最常见的使用情况,它与我们插入我们的源代码的无op函数一起使用它。这些功能有效地提供了一个结构化事件日志,可以根据测试对其感兴趣的方式动态挖掘。

这是我们使用此模式的一个示例(在我们的代码库中的数百个)。我们的系统的一个部分以分布式方式执行用户指定的工作,需要:1)如果失败,则重试工作,并且2)在阈值工作成功后检查到持久的复制商店的进度。尝试第一次工作的测试失败的测试之一,然后验证系统重试工作。

执行工作的源函数被称为#34;进程数据!",这里是摘录的摘录:

在一个完全单独的函数中,称为#34;检查点 - 状态!",no-op函数"持久状态检查点和#34;在完成复制和写入磁盘的进度信息之后被调用。在我们的测试代码中,我们有:

(排放重试 - 用户 - 工作 - 致催化 - 最终(让[检查点(Volatile!0)重试 - uccesses(Volatile!0)](使用-ReDefs [Manager / idured -State -CheckPointed(FN [](vswap! CheckPoints Inc)))Manager / Retry -Succeeded(FN [](vswap!Retry -successes inc))] ...)))

然后在测试的正文中,我们检查正确的时刻发生正确的内部事件。

最重要的是,由于这种点菜事件日志方法基于No-Op功能,因此在生产中运行时,它基本没有开销。我们发现这种方法是一种令人难以置信的强大的测试技术,以独特的方式利用Clojure的设计。

我们有大约400个通过我们的代码库定义的宏,其中70%是源代码的一部分,其中30%仅用于测试代码。我们已经找到了宏的常见建议,就像使用函数时不要使用宏,以明智的指导。我们有400个宏,做你不能做的事情,你不能用常规函数演示我们制作抽象的程度远远超出您可以使用没有强大宏系统的典型语言。

我们约100只宏观是简单的"与 - "在开始时打开资源的样式宏并确保在表单退出时清除资源。我们使用这些宏进行管理文件生命周期,管理日志级别,范围配置和管理复杂系统生命周期。

我们大约60个宏定义了自定义语言的抽象。在所有这些中,在内部的形式的解释与香草克洛库不同。

我们的许多宏都是Utility Macros,喜欢" Letlocals"这让我们更容易与副作用混合变化。我们在测试代码中使用它很大,如下所示:

(LetLocals(绑定一个(mk -a -thing))(do -something!a)(bind b(mk-anthoththing))(是(=(foo b)(bar a))))))

其余的宏是内部抽象的混合,如我们构建的状态机DSL,以及各种特殊的实现细节,其中宏删除无法删除的代码复制。

宏是一种语言功能,可以滥用来产生非常令人困惑的代码,或者可以利用它们以产生奇妙优雅的代码。就像软件开发中的任何其他东西一样,您最终结束的结果由使用它的人的技能决定。在红色行星实验室,我们无法想象在我们的工具箱中没有宏的构建软件系统。

正如我们之前写的那样,我们通过在单个线程上运行我们的整个系统并随机化从随机种子开始执行事件的顺序来编写100%可重复的分布式系统测试。仿真是一种主要的码级跨越能力,其大量利用上述依赖性注入和重复性的技术。例如:

在生产中的系统的任何部分都是唯一的线程在执行者服务方面被编码。要为系统的该特定部分获得执行者服务,它请求来自AN"执行者服务工厂"在生产中,这将返回新的线程。但是,在仿真中,我们覆盖该组件从我们的单线程全局管理源提供执行者服务。

我们的大部分系统都依赖于时间(例如,超时),因此时间从我们的实施中抽象出来。对时间感兴趣的系统的任何部分咨询A"时间来源"依赖。在生产中,这是系统时钟,但在模拟中,组件被A&#34覆盖;模拟时间源"可以在我们的模拟测试中明确控制。

承诺在整个CodeBase中使用相当多的位置来管理异步,非阻塞行为。仿真用与重定款到图层的额外功能进入有用用于踩下模拟的承诺。

我们的产品提供了一个UI,让用户了解它们在群集中运行的内容,以及缩放等操作的当前状态,以及遥测,显示其应用程序发生了什么。

前端是在CLOJUSERCRIPT中编码的基于Web的单页应用程序。 Clojurescript生态系统有许多成熟,精心设计的图书馆,使开发有效和乐趣。

审查图书馆及其优势可能是一个博客文章本身,但简要介绍:我们使用重新框架,因为它的数据导向状态管理和事件处理模型很容易理解和检查。我们使用reitit for frontend路由;我们喜欢其数据导向的设计如何使我们能够将任意数据与每个路线相关联,这反过来让我们在路线更改上的调度重新框架事件中进行整洁的事物。我们使用Shadow-CLJS编译项目,部分原因是它大大简化了使用JavaScript库和处理外部的过程。

我们使用UPLOT来显示时间序列数据。我们的API后端使用Jetty Server提供服务,我们使用Compojure来定义后端路由。

定义我们的前端与我们的代码库的其余部分具有相同的语言,尤其是在Clojure和Clojurescript之间来回穿梭数据的易用性。 Clojure强调的不可变形的风格与前端代码中的前端代码一样有益,因此能够利用始终如一地利用我们产品的生产力和强大的稳健性。

以下是我们在Codebase中使用的许多外部库,Clojure,Clojurescript,Java和JavaScript库的混合:

Clojure是开发产品的奇妙。它使我们能够以其他语言构建不可能的强大抽象,删除所有仪式,并利用强大的测试技术。此外,我们在我们的团队中有多个成员开始,没有Clojure或功能规划体验,他们能够快速加快速度。

如果您有兴趣与我们合作帮助定义软件开发的未来,我们正在招聘!我们努力努力使用编译器,数据库和分布式系统推动可能的事情。我们的团队完全分发,我们正在开放招聘世界上的任何地方。