多应用程序项目,更常被称为伞形项目,是大多数项目在业务环境中的结构方式,主要是因为它们使得在单个存储库中维护多个 OTP 应用程序变得更容易。在本章中,我们将介绍它们的结构,它们在何时最有用和最无用,这种新结构的一些细微之处,最后,提供一些提示来帮助将单体库正确地拆分为多个 OTP 应用程序。
组织多应用项目
伞形项目的一个例子是我们之前一直在使用的service_discovery 存储库。让你立即知道它是一个伞形项目的明显标志就在目录列表中可见。
$ ls
apps cloudbuild.yaml deployment Dockerfile README.md rebar.lock Tiltfile
ci config docker-compose.yml LICENSE rebar.config test
多应用项目需要一个目录,其中包含所有顶级应用程序的源代码,并且此目录位于apps/
目录中(也支持libs/
目录)。每当你看到带有rebar.config
文件的apps/
或libs/
时,你就可以非常确定这是一个伞形项目。
查看该目录,你就可以对项目的大致内容有一个很好的了解。
$ ls apps
service_discovery service_discovery_http service_discovery_storage
service_discovery_grpc service_discovery_postgres
关于如何在项目中命名 OTP 应用程序,没有硬性规定。根据我们的经验,我们发现有效的规则是始终使用某种命名空间,因为 VM 不支持类似的功能。在前面的列表中,我们可以看到我们有一个service_discovery
应用程序,然后是一堆service_discovery_<thing>
应用程序。
这告诉我们所有这些应用程序都是相关的。主要应用程序可能是service_discovery
,而其他应用程序是辅助应用程序:_grpc
和_http
应用程序可能是前端或客户端库(剧透:它们是前端),_storage
应用程序应该明显地处理存储,而_postgres
应用程序可能也处理某种存储。
如果你深入代码,你会发现_storage
是一种通用的存储 API(请参阅其.app.src
文件的description
字段),而_postgres
是一种特定的实现:它是为可扩展性而编写的。
应用程序在该目录中可以具有截然不同的名称。例如,我们可以决定编写或引入某种身份验证库,因此在应用程序方面,我们也可以在其中包含类似authlib
和authlib_http
的内容。
这种模式就是为什么命名空间在大型项目中很有用的原因。随着项目的增长,它们往往会获得越来越多的 API、端点、客户端以及与之交互的方式,虽然在编写过程中很麻烦,但这种手动命名空间在必要时允许非常清晰的分离。
在所有情况下,这个多应用目录都是单应用项目和多应用项目之间最大的结构差异。不过,还有一个小的变化:你可以拥有多个rebar.config
和测试目录。在service_discovery
的特定情况下,你可以看到它有一个顶级rebar.config
文件和一个test/
目录。但是,如果你查看所有单个应用程序,则会发现更多内容。
$ ls apps/service_discovery*
apps/service_discovery:
src
apps/service_discovery_grpc:
proto rebar.config src
apps/service_discovery_http:
src
apps/service_discovery_postgres:
priv src
apps/service_discovery_storage:
src
所有应用程序都保持 OTP 应用程序的基本需求,即拥有一个src/
目录,但它们可以自由地添加自己的测试目录、priv/
目录或任何其他需要的目录,以及新的rebar.config
文件。
让我们看一下service_discovery_grpc
的配置。
{grpc, [{protos, "proto"},
{gpb_opts, [{descriptor, true},
{module_name_prefix, "sdg_"},
{module_name_suffix, "_pb"}]}]}.
此配置专门用于在顶级rebar.config
中声明的grpcbox_plugin
,但允许插件仅对确实需要它的 OTP 应用程序运行。
简而言之,这创建了一个用于构建和组织应用程序的多层动态。
- 顶级的所有内容都由所有顶级应用程序(甚至测试)共享。
- 每个应用程序都可以通过创建目录或测试文件的本地版本来变得更具体。
这里有一些例外。例如,依赖项是为项目共享的:虽然每个顶级应用程序都可以指定自己的依赖项,但 Erlang 和 Rebar3 每次只允许加载一个版本的库(不包括实时代码升级)。这意味着依赖项解析将为所有冲突版本选择一个获胜应用程序,因此将它们视为共享是有意义的。相反,像priv/
这样的目录用于单个应用程序的私有文件,虽然任何人都可以读取其内容,但同一个目录不能由多个应用程序拥有。
另一个细微差别与钩子有关;一些钩子可以为单个 OTP 应用程序和整个项目定义。例如,在顶级定义的compile
钩子将在构建所有应用程序之前或之后运行,而为apps/
中的单个应用程序定义的相同钩子将仅在该应用程序编译之前或之后运行。
是否应该迁移
与数十个单应用存储库相比,伞形项目的主要优势在于,你的大部分代码开发都集中在一个地方,在那里你可以轻松地为许多工具拥有一个大型共享配置。它们还可以轻松地在单个存储库中跟踪所有内容,例如审查、迁移和历史记录。这听起来像是一个非常简单的决定,但实际上并非总是如此简单。
切换到多应用项目有两个主要注意事项。第一个是,Erlang 项目与 Rebar3 唯一的依赖项需要是单应用存储库,至少在添加新功能以允许它之前是这样。如果你打算编写要在工作场所的多个项目中使用的库,那么在多应用项目中执行此操作将无法正常工作,除非所有开发也移动到多应用项目中。
第二个注意事项是,将所有开发迁移到大型多应用项目中也并非易事。大多数工具都假设你可能每个项目构建一个(或不超过几个)版本,因此不会犹豫地在所有顶级应用程序上同时运行代码分析或重建。
这意味着,如果你不是拥有几个中等规模的存储库,而是拥有一个巨大的存储库,你可能会发现常见命令需要花费更多时间,仅仅是因为它们预计大型项目会更少见。
在 Rebar3(和其他工具)能够赶上单体存储库之前,你可能希望按如下方式构建事物。
- 每个较大的项目(例如服务或微服务)创建一个多应用存储库。
- 所有跨大型项目共享的通用库都单独维护和发布,并在特定项目需要时引入。
- 使用存储在某些通用插件中的模板,你的团队成员将全局安装这些模板来自动化服务的布局和规范、Web API、CI 配置等。
如果多应用项目中的某个库在某个时刻对组织内的其他用户变得有用,则可以轻松地将其提取到其自己的存储库中,发布它,并将其作为依赖项重新导入。类似地,孤立的库或分支的库可以在每个项目中本地维护。
当你的组织打算修补或开发,然后发布开源代码时,这种结构也很有用,并且使得在库中进行更改的成本相对较低,而不必同时同步所有用户。
在为每个项目开发围绕部署和 CI 的特定脚本时,它也使事情变得相对简单;开源工具往往可以继续正常工作。但是,当你的组织已经拥有单体存储库和为此开发的工具时,它会产生更高的成本。另一个常见的障碍是,它要求一个项目的 CI 和构建服务器能够读取依赖项的 CI 和构建服务器,这并非所有组织都具备。
拆分应用
无论你偏好哪种方法——单应用、多应用还是单体存储库——你都必须弄清楚如何将代码最佳地拆分为可管理的块。
这始终具有挑战性,无论你编写什么内容。与有无数关于函数的完美大小、模块或类应该包含和公开多少内容以及微服务应该有多小或多大等博客文章和文章一样,关于最佳 OTP 应用程序大小也没有一个规范的参考。
我们倾向于根据对良好隔离感的某种直觉来构建它们,而不是提供硬性规定。这通常是经验的结果,很难教授,但这里有一些我们喜欢提出的问题,可以简化决策过程。
- 特定功能是否可能是其他项目最终需要的?如果是,将其赋予自己的 OTP 应用程序可能会有所帮助,以便于提取和共享。
- 它是否包含与项目关注点非常相关的代码?对于服务发现,它是否与跟踪服务有关,或者是一些通用的内容,例如“存储数据”?越具体,它就越应该靠近主应用程序;越通用,越容易想象成一个独立的 OTP 应用程序。
- 它是否需要一些特殊的配置值或领域知识?如果是,将其中的所有调用捆绑到其自身 OTP 应用程序中的一组受限模块中可能是一个好主意,以便其他人可以将其用作良好的抽象来源(例如,处理身份验证或特定协议)。
- 你能想象它仅在某些特定上下文中或构建中启动吗?如果是,将其设为一个独立的应用程序将在以后使事情变得更容易。一个很好的例子可能是健康检查或监控端点,它们可能依赖于你的主应用程序,但不需要用于它们的测试或特定构建。
所有这些问题都是代理,应该让你更容易地评估业务逻辑(它往往始终存在于顶级存储库和应用程序中)与你当前正在编写的其余代码之间存在多少耦合。
在沙滩上画这样一条线通常是一个有用的练习,可以帮助你弄清楚如何构建事物。
以service_discovery
为例,从技术上讲,没有强烈的要求将service_discovery_storage
与主service_discovery
应用程序区分开来。但是,我们认为,主应用程序不关心存储层的细节是有意义的,无论它是通用的、备份到磁盘的、在另一个服务上还是在内存中。它并不关心。所有这些复杂性和间接性都可以在一个定义非常狭窄的应用程序(service_discovery_storage
)中处理,该应用程序可以使用特定插件(例如service_discovery_postgres
库)进行配置。
我们只是认为这种隔离可以更好地确保主应用程序永远不会关心存储特定的问题,而是在外部调用它们作为通用抽象。可切换后端的复杂性仍然存在,但已隔离在代码的一个清晰标记的区域中,我们希望这将简化维护。
最终,构建和组织代码和代码库的最佳方式是选择您和您的团队在当前组织环境下认为最有效的方式。我们(作者)偏好的方式是我们过去十年在多个组织工作中发现的一种合理的折衷方案,但它可能不如完全采用目前为您提供的方案有效。
一次解决一个问题;如果您的团队第一次学习 Erlang,那么考虑到您组织现有的部署和构建系统,从阻力最小的路径开始可能更有意义,同时完全了解您以后会对其进行重构以使其更舒适。当您的精力、时间或资源有限时,试图一次解决所有问题并试图第一次完美地解决所有问题可能会适得其反。