最近应“IT资深专业协会”的邀请做了一场有关设计原则和函数式编程的科普演讲,案例有些复杂,在这里将内容和代码简单重复一下,以便没有听明白的协友可以复习。 需求分析 案例是一个假设的电商系统的用户信息管理接口,需求大致为以下三点: 设计一个电商系统的用户信息管理子模块(ProfileService),满足新用户注册、登陆、和个人信息管理,包括头像、密码、住址等; 用户信息管理模块必须能够支持来自订单系统(OrderService)的调用,提供必要的相关功能子集。 用户信息管理模块必须能够支持来自客服系统(HelpDeskService)的调用,提供必要的相关功能子集。 先大致梳理一下订单系统到用户系统的 use case: 用户可以直接注册/登陆/或取回密码,也可以先下单(order),然后在提交订单的时候再注册/登陆,并且可以添加/修改寄送地址,因此 OrderService 需要能够访问用户的 Profile。基于这样的需求,我们得到以上 use case,并根据 use case 的功能,得到以下 ProfileService 及其接口的类图设计: 接口隔离 我们得到了一个初步的版本,但是这个版本是有缺陷的。很显然它没有考虑接口的应用。 对于订单模块 OrderService 和负责登陆认证的 AuthService 模块,ProfileService 应该提供不同的功能集合,因此我们需要将大而全的接口分割成多个不同的小接口及其组合,以便为不同的业务模块提供不同的外观,因此我们需要重新考虑接口的设计以满足对接口隔离的要求: 我们从 IProfileService 中分离出两个小的接口:ICustomerService 和 ISecurityService,让它们分别作为 OrderService 和 AuthService 的依赖项,同时 ICustomerService 和 ISecurityService 又共享了注册/登陆功能,这些更基础的功能项被放在更基础的 IRegisterService 中。自此,我们为项目成功提供了隔离接口,让每个模块彼此保持了低耦合性。目前为止来说,传统面向对象架构都还能够很好地胜任。 项目演进 现在让我们来添加第三个 HelpDeskService 模块,先来看一下这个模块的 use case: Help desk 模块相比 AuthService 而言,出于安全考虑,我们不打算让它能替用户开户,更不能替用户登陆。但是它可以在用户忘记密码的时候协助用户发送密码重设邮件,因此它可以访问 ResetPassword 功能,而我们假设这个能力是 OrderService 没有的。同时它又比 AuthService 有更丰富的用户信息管理能力,比如添加或修改用户的寄送地址等,这部分功能则与 OrderService 共享。因此它看上去更像是 AuthService 和 OrdereService 的部分子功能的集合。也就是说我们需要从 ICustomerService 和 ISecurityService 中屏蔽掉(来自最基本的 IRegisterService 的)部分功能,然后再分离出一些更小的元素重新组合出新的集合,假设这个集合叫 IHelpService,于是我们再次重构接口得到以下类图: 重新设计的接口组合,我们首先从最基本的层面拆解了 IRegisterService,得到了四个更小粒度的接口,分别是 IPasswordReset、IProfileReadOnly、IRegisterService 和 IAddressService 四个基本接口,然后在这四个接口的基础上,我们从新组合得到 IHelpService、ICustomerService 和 ISecurityService 这三个分别对应 Help desk、订单和认证的功能接口,以及基于以上接口的最上一层 IProfileService 模块的总接口。 以上设计基本上达到了接口设计的目的,它保持了接口隔离。ICustomerService 和 ISecurityService 从客户端的角度看完全没有发生变化,但是我们成功新增了 IHelpService 接口,其中包含了分离出来的部分函数,隔离了不需要的函数。 虽然我们达到了设计目标,但是感觉似乎有一些不对劲。仔细品味一下,我们在添加 Help desk 接口的时候,明显比前两个接口要费劲的多,我们几乎重构了之前的所有接口,而结果也让类图中的元素看上去增加了许多,这会不会是个问题呢? 问题及思考 经过重新的梳理,我们发现以上这些接口虽然能够让我们完整地遵守接口隔离原则,只暴露必要的功能给相应的客户端模块。但是很显然相比在没有加入 Help desk 之前的设计,接口逻辑的复杂度已经提升了很多。这是因为当三个或三个以上的模块彼此产生联系的时候,它们之间的关系不是以线性的方式,而是以曲线方式递增的。我们每增加一个新的模块,它都有可能和已经存在的所有模块发生交集,并且在最坏的情况下可能导致重构所有有关联的接口模块的继承关系。由此可见,接口隔离原则虽然很重要,但是它给工程带来的设计成本的增加,在随着项目规模的升级,会越来越可观,很快就会达到难以承受的高度。成本是我们在实际的工程中必须要解决的首要问题,否则它将严重影响质量。 另一个方面,我们之前的分析一直聚焦在接口上,暂时地忽略了实现层面的问题。从第一个版本开始,我们只是将所有的功能实现都集中在 ProfileService 这一个大类(class)中,它实现了 IProfileService 接口,是所有功能最终的提供者。虽然从接口层面我们不必关心这一点,但是在实际项目中,我们几乎一定会考虑代码解藕,而不仅仅是接口的解藕。因此,我们有必要思考一下 ProfileService 的实现应该怎样适应接口的变化。 如何实现功能 class 这个问题其实和第一个问题有一定的关联。我们设想一下,带来第一个问题的根本是因为接口隔离原则着眼的是功能的组合,而不是功能本身,因此如果我们在最极端的情况下,将每单个功能都以一个单一接口来表达,然后基于一个一个零散的实现来组合隔离接口。这好比,与其靠从其它车辆上拆卸零件来组装新的汽车,为何不一开始就提供最原子化的零件来组装汽车呢?也就是说,如果我们抛弃“继承”,直接以单个函数为对象来实现我们的类(特质),然后再通过“混入”(mix in),来实现不同的接口组合,那么就能够实现模块的数量和复杂度的线性关系。因此从对功能的实现上引入“蛋糕模型”,而不是接口继承模型来设计我们的功能组合将明显有利于成本控制。 那么蛋糕模型对我们的传统设计是否构成挑战呢?首先,当我们为每个方法都设立一个对应的接口时,编码量会明显地增加,只是相比不可控地增加有所减小,至少它是线性的,你可以基于此做出预算了。其次,让我们以这样一个例子来看一下具体的实现:假设我们将实现 addAddress(Int profileId, Address address) 这个函数,它的代码大致如下: 在添加新的地址之前,我们需要先根据 profile id 来获得相应的 profile 引用。传统的继承模型中,我们可以通过父类提供的 getProfile 方法来获得 profile 的引用,但是现在我们使用的是蛋糕模型,很显然当前上下文中没有 getProfile 方法可供调用。它来自蛋糕的另一块切片,两个切片之间没有继承关系!如果我们使用 new 在这里加入一个实体变量来指向 getProfile,那么我们可以想象在整个工程中,类似的操作将累积起惊人的内存消耗和无端的运行开销,并且它也不是代码管理的最佳实践,因此这条路显然也走不通。 我们似乎陷入了另一个迷宫:虽然借助蛋糕模型暂时控制了成本不成比例上涨的问题,但是照成了另一个更难解决的技术难题。如何让不同的,没有继承关系的类彼此发生调用关系呢?这成为了我们要解决的第二个难题。当然,这也是传统架构遗留下来的问题,因为传统架构没有为我们提供突破此障碍的解决方案。自此,我们不得不重新审视我所选择的开发工具,采用更全面的函数式编程来解决以上工程和技术难题。 函数式解决方案 我将采用 Scala 来重构以上设计。首先要声明,我们将在不牺牲接口隔离原则的前提下,以可控的成本来解决技术上的挑战。以 HelpService 接口为例: 打完收工…… 是的,结束了,以上就是实现 HelpService 接口的全部(如果神奇的 “???” 也算的话)代码。让我来解释一下: trait(特质)是一个融合了 Interface 和 Abstract class 的,函数式风格的“接口”,通过 trait,我们避免了为无数的函数提供同样多数量的 Interface,这极大地节约了代码量。并且它的编写成本和单个函数相差无几,这是我们保证工程成本的关键,基于此我们可以得到更可承受的成本预算。同时,它提供了比函数,甚至比接口和抽象基类更强大的能力,它是函数式语言实现蛋糕模型的核心。在 Scala 中,我们甚至可以在 trait 中通过“自类型”来让没有关系的两个 trait […]