一次失败的过度抽象


Author: Kimmy

众所周知,服务端应用往往可以一刀切地划分成两种结构,单体应用(monolithic)和分布式应用(distributed)。这两者本质的区别就是所有相关联的组件是否同部署和运行在一个进程之中(至于为了性能采用多进程的单体应用,属于进一步的优化,不属于本文讨论的范畴)。

在长期实践单体仓库(monorepo)的项目结构以后,我一时间产生了一个幻想,如果有那么一种办法能够灵活的控制我们最终的应用是单体还是分布式的,是不是更好呢?在资源并不紧张的并且需要考虑scale的时候可以以分布式的形态部署,而在一些资源有限或者仅仅是用于简单的测试场景的时候就能换成单体形态。

这个想法成型于很久之前,因为之前团队工作的过程中因为各方面的原因很难快速启动一套用于测试/验证的系统而令人抓狂。主要是在分布式场景下,各种隐式的依赖很难追踪,即便通过测试环境也只能保证当前组件是可控的,其他依赖项依然是不稳定状态。

我的第一套设计就是为了解决这个问题。

在讲述细节之前我们先提几个概念:

上面的概念非常抽象,我们可以用一些具体的例子来说明一下。

假如有一个在线的宠物商店,用户可以在线浏览宠物,把喜爱的宠物加入购物车,并且下单购买。以加入购物车并下单这个场景为例,我们可能的组件有下面三个:

这三部分可能会同时打包在一个jar/war文件(一个deployable)中,在服务器端启动,也可能分别部署到不同节点上(多个deployable),根据相互间的依赖关系进行远程过程调用。

因为依赖关系总是单向的(没错,如果不是的话扔了重写),所以我们可以把任何复杂的关系都能简化到两个通用的抽象组件的依赖。为了不受具体示例的干扰,后面讲述这套结构的时候,我会分别叫他们xxx和yyy。yyy依赖了xxx。

首先为了隔离,我们给xxx提供一套接口,xxxIntf。这样在yyy的实现yyyImpl里,就引用了xxxIntf来完成进一步的动作。

而同样的因为我们要具体对应到xxx的实现xxxImpl,所以xxxImpl也实际上实现了xxxIntf。这样根据我们之前提到过的依赖注入的操作,就把xxx这块儿可能存在的硬依赖给分离开了。

这样对于组件xxx来说,包含了模块xxxImpl和xxxIntf,组件yyy包含了yyyImpl、xxxIntf和其他相关模块。两者直接组合到一起实际就可以构成一个单体应用。

而如果我们需要变成分布式的要怎么办呢。按照六边形架构的原则,只要在xxx上wrap一层对外的接口,变成xxxServer,无论是http也好,什么样的其他rpc也好,然后再提供一个实现了xxxIntf 的 xxxClient 给 yyy,这样我们实际上就能组合出来两个deployable:

而本身作为server和client,他们并不需要了解具体的实现和调用方的细节,所以xxxServer只要实现了特定的远程接口,并且引用xxxIntf把请求转发过去就行,xxxClient也只需要实现xxxIntf,把来自xxxIntf的调用发送到远端就行。

作为一个大聪明,我觉得我这样糊出来简直棒极了。除了多写了一堆boilerplate之外好像并没有啥坏处。第一个版本的结构就出现了,远程调用使用的是grpc。最开始我还是能坚持下去的,但是隔了一段回来再维护这堆结构的时候,我发现这件事情实在是太折磨人了。首先我要想尽办法把各种模块隔离开,然后是client server分别手写一套,然后有任何改动基本上要从头串到尾。而为了偷懒,xxxIntf接口部分的value object其实用的是protobuf生成的类,所以整体并没有跟grpc完全隔离开。而要想完全隔离开,还要再手写一堆的mapper。

然后进一步地,我发现grpc居然有InProcessChannel。意思是人家本来就支持同进程内进行不同组件的通信。就啥server client你都塞到一起也没问题,用个inproc channel能够当成单体应用跑了。还拿什么鬼Intf做隔离,人家grpc stub本来就是天然的interface。

这堆东西写起来麻烦,删起来倒还挺简单的,没多久就完全替换完了。

创建时间:2022-11-02 最近更新时间:2023-11-03