最近应“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 […]
先简单地介绍一下“桥接模式”: 用一个(抽象化的)接口将实体中的属性独立出来,以保持这个属性的变化独立于宿主,因为这个接口在两个定义(宿主和属性)之间起到桥接的作用,故名“桥接模式”,比如下文中的 Name 接口就是起到桥接 Party 类和 Name 属性之间的“桥”。 桥接模式给对象的灵活性带来了一些变化,它使得对象的某些部分的实现不再完全依赖父类,相反它可以从非继承关系中获的某些好处。就以文中的例子,我们先来看看当它以正面形象运行的时候是什么样的: 很显然我们会得到预期的输出结果: Organization name is “ABC Company” Individual name is “Bill, Gates” 但是需要注意的是“桥接模式”同样是恶魔的制造者,我们只需要对主代码做非常小的一点改动你就会发现问题: 我们“不小心”交换了传入参数的类型,原本应该传入 OrganizationName的地方实际上传入了IndividuaName,相反也是。而编译器居然完全没有发现这个错误,结果我们的到了完全相反的输出结果: Organization name is “Gates, Bill” Individual name is “ABC Company” 为什么会这样?编译器为什么无法发现这个问题呢?难道是编译器开发者的疏忽吗?并不是的,其实这就是编译器做出的正确选择,虽然它不符合我们的预期。要理解这个“错误”,我们还是要从OO的最基础原理中去寻找答案。 OO 允许我们适应不同类型的最根本依据是“多态”,多态是 OO 三大特征中的一个,多态的主要体现是“重载”和“重写”,它们解决的核心问题是对象如何正确地寻找到目标。以 Java 语言规范为例,当一个对象被初始化的时候,它会对对象做“重载解析”(overload resolution)或称 “静态分派”,所谓 “静态” 指的是一个对象在声明的时候的类型,也被称为“静态类型”,在这个例子中也就是 “Name”类型,而等号后面的实例类型,OrganizationName 或 IndividualName,被称为“运行时类型”(runtime type)或“实际类型”(Actual type),顾名思义,实际类型在运行时决定,两者最大的不同是运行时类型可能会在运行时发生变化,但是这种变化不会影响静态类型,也就是说,你可以先给 organization.name 赋予 OrganizationName 类型值,然后再改变成 IndividualName 类型也不会导致程序运行的任何不舒服(而你舒服不舒服就不好说了)。 因为静态类型的判定属于编译期的事,而实际类型是属于运行期的事,编译器对OO 的多态依据是由静态类型决定的,也就是说是静态类型,而不是实际类型决定了重载解析时的变量类型!并且,在 Java 语言中,因为属性是不支持重写的,也就是说如果这个问题发生在属性 Name 上,我们希望通过重写来解决问题也是不可能的,也就是说指望经典多态理论来解决这个问题基本上不可行。 以下重写无法通过编译: 综上所述,桥接模式其实是一个很危险的模式,这种危险性在于它的类型不安全性,那么有没有办法解决这个问题呢?答案当然是有的,就是“泛型”,泛型允许我们在编译期指定运行期类型的范围: 如此一来就避免了类型的不确定性,可以放心地使用 Organization 和 Individual了。 泛型并不排斥多态,甚至泛型弥补了经典多态的缺憾,产生了泛型多态的概念,丰富了多态的领域,它让语言变得更加安全,但是这种加强也导致了语言发展过程中的一次强烈的地震,这场地震的结果是将编程语言的世界一分为二,各自走上不同的道路:在一方面尽可能利用泛型来加强静态类型的约束力,让语言逐渐趋向静态类型,静态类型语言一个最明显的标志是逐渐淘汰了 cast(特指 downgrade cast)指令的使用,而对cast 指令的支持最初被认为是OO多态很重要的一个特征(自行Google cast 的四种模式中的 dynamic cast相关内容),Scala语言算的上是静态类型语言的巅峰之作;而另一方面有些语言则希望极大化地强调运行时侧的类型变化特征,这产生了语言的另一个分支:“动态类型语言”的出现,典型的动态类型语言包括 Python、JavaScript 等。当然也有语言尝试兼取两者优点,比如typescript 等。有关动态类型语言,以及它和泛函编程的渊源的内容有很多,基于时间和篇幅不在这里啰嗦,以后再单独介绍。但是可以提一下今天的 Java 也实现了对动态类型语言的支持,只不过这个支持出现的很晚,一直到 JVM7 才从虚拟机层面支持,而语言层面的支持则一直到 Java 8 发布以后。这个过程罕见地经过两个主版本的迭代,前后历时8年,影响深远。
Java设计范式23式,单例模式(singleton)是第一式,但是也是最复杂的一式。它的复杂性体现在多方面,首先我们先要了解为什么存在单例。其次,在产生单例的过程中,我们要避免哪些风险。最后,我们还要对 JVM 如何载入一个类有一定的认识。可以说一个单例模式综合考验了我们对 Java 在各方面的理解。 首先,为什么需要单例?这个问题比较简单,在任何可以用一个实例解决问题的地方,我们就应该考虑使用单例,前提是它是否同样保持高并发下执行效率不变?也就是说如果系统共享这一个实例,那么是否会出现资源竞争而产生瓶颈?如果不会,那么就应该使用单例。比如所有的只读量、静态量、纯函数对象等,都应该是单例。这种对象在系统中可以有很多,但是我们很少注意到它们,是因为在使用面向对象编程时,我们几乎会为所有的对象都产生一个实例,而不管是否真的需要这么做。但是在网络编程中,“对象“一般是来自用户的请求(值对象),在这种情况下,controller 作为存粹的处理节点(函数),它本身不包含也不寄存对象特例,那么将它单例化就很合理。另外,对于排它性资源的使用,也应该是单例,比如日志文件。我们要保证任何时候只能有一个任务在写入日志,那么单例也是比较合适的。 确定了单例的使用后,就是如何生成一个单例。首先我们必须知道生成单例有两种基本模式,在这两种模式上又演化出多种形式。这两种模式分别是“饥饿”模式和“懒加载”模式。 先来看一个错误的单例模式例子: 这个例子在单线程下不会有问题,但是如果运行在多线程下就不能保证是单例,因为 getInstance() 不能防止多线程重复进入,当第一个线程判断 INSTANCE 为空后,进而准备生成实例之前,如果恰好此时第二个线程也进入了 if 块,它同样会认为实例是空的,然后越过判断保护,执行到和第一个线程相同的地方,于是,你有可能执行了多余一次的构建。我们只需要放大这个执行过程不难看出错误: 现在让我们在 if 代码块的开头加入一段随机的停顿,然后用两个线程分别去获取实例之后比较它们是否相同: 这几乎一定会得到 false 的结果。所以我们必须考虑对 getInstance() 方法加装同步锁,以防止多线程共享: 但是这种将 synchronized 关键词直接加在方法上的做法未免使得加锁的范围太大,synchronized 在大并发条件下可能导致重度加锁,对性能产生较大影响,因此应该尽可能缩小加锁的范围,因此诞生了以下优化的写法: 以上这种模式在大并发条件下效率比前一种要高很多,尤其在Java6之前。它的第一次 if 判断的作用是尽可能避免触发锁机制,只是用来避免由此产生的效率问题的,并不保证此时访问单例一定是安全的。第二次比较才真正地确保单例的安全性。其中的逻辑相信大家不难看出来。因为它在进入临界区之前和之后需要反复确认单例对象是否为空,因此有个名称叫 Double check lock (DCL)模式。 以上模式最大的坑其实是在单例变量的声明上,它必须被声明成“volatile static”。这是因为现代 CPU 大多数有乱序执行的能力。什么是“乱序执行”呢?我们以“将大象装入冰箱”为例,如果我们要将一只大象装进冰箱,那么需要分三个步骤:1)打开冰箱门。2)将大象推进去。3)关上冰箱门。这三个步骤必须先后执行,如果我们“乱”了这个顺序,在执行第二步之前就先关上冰箱的门,那么大象就无法被装进冰箱里。同理,虚拟机生成一个对象也分为三个步骤:1)开辟内存空间。2)初始化对象。3)将变量指向对象。但是 CPU 不保证后两个步骤按顺序执行,如果第三步先于第二部执行,那么这时候我们拿到的将是一个未初始化的空指针(请参考《Java虚拟机规范》中有关对象加载与初始化机制的章节)。而关键词 volatile 的作用之一就是保证指令执行的有序性,它告诉CPU在对该对象的任何写入或读取之前,必须先完成之前的动作,所以必须在声明对象的时候加上该关键词,才能避免其它线程拿到空指针的风险。 以上介绍的这种模式的特点是,当且仅当我们调用 getInstance() 方法的时候,单例才会被建立,因此这种模式被称为“懒加载”模式。而这种懒加载只有最后一个才是完全正确且高效的,但是它需要我们对锁机制和 CPU 指令执行顺序有一定的了解。下一章将介绍一种不需要(显式)加锁的单例生成机制:静态初始化模式。
静态初始化模式 ,直接看例子: 这是最简单的静态初始化模式,可以看到类 EagerStaticSingleton 的内部具有一个静态(单例)常量 “INSTANCE”,它随着类在初始化阶段时就完成了实例的生成。 这种方法的优点是简单,你不必考虑多线程问题,因为虚拟机的类初始化机制本身就是线程安全的,因此无需为生成过程加锁。为了保证这个单例不被意外修改,它应该被声明为 final。但是它的缺点是可能造成内存泄漏,因为我们知道一个类被实例化之前需要先经过加载和初始化。通常虚拟机的加载器为了节约内存,提高执行的效率,仅在必要的时候才会执行相应的步骤(请参考《Java虚拟机规范》)。也就是说一个类被加载,不等于就会被初始化,被初始化不等就会被构建,同样被构建不等于会被使用。所以这个实现等于无论是否使用都强制执行了一次构建,这可能在内存资源紧张的系统中,或对于构建时间较长的类会遇到问题。 与之前的“懒加载”模式相对,这种不管用不用都生成实例的模式称为“饥饿”模式。顾名思义就是单例在静态初始化的同时就生成,而不管它是否真的被使用到。这个例子我们也可以写成以下形式: 相比上面的例子我们仅仅是把对象的初始化过程改到了静态初始化块中。通常情况下(单例对象的类型是一个类)这两者没有什么区别,但是如果单例对象是 Java 的内置类型或者 String,那么这两种写法就有区别了。我们知道 Java 在编译的时候,内置对象和String 类型的静态常量会被直接编译到静态常量池中,而静态常量池中的量在加载的同时就被直接赋予初始值,而不是等到初始化阶段才执行这个过程,它的执行效率会更高,并且可以越过初始化直接使用,不会产生效率或内存瓶颈问题。 为了避免饥饿模式的静态初始化单例的浪费和低效率,我们又有了以下懒加载模式的静态初始化单例: 和饥饿模式相比,懒加载模式下单例的不会在类初始化过程中被强制实例化,我们可以通过以下测试比较以下: 我们会看到控制台只输出了“EagerStaticSingleton”而懒加载类不会输出任何信息,因为它的构造函数不会被触发,一直延迟到 getInstance() 方法被调用的时候,LazyStaticSingleton 才会尝试构造单例实例。 这种模式最有意思的地方是它不仅是懒惰的,而且也是线程安全的。因为内部静态类 Holder 取代了单例原本应该的位置,我们之前说过,类加载过程保证了静态成员的线程安全,而单例又是 Holder 的静态成员,因此 Holder 的初始化过程(被延迟到 getInstance() 被调用的那一刻)反过来又保证了单例的线程安全。 最后,在介绍完利用类静态初始化来完成单例之前,再介绍它的一个变种,枚举单例: 枚举类在 JVM 中上被当作 final class 来看待,它也可以有构造函数和其它功能函数。并且它的枚举成员的类型就是它自己,也就是说以上相当于: 毫无疑问,这是一个饥饿模式的静态初始化单例。 以上所有单例(包括第一章的内容)都是非序列化条件下的单例,如果我们的单例模型需要支持序列化,那么它们都还存在缺陷,因为当一个类被反序列化的时候JVM会生成一个全新的对象。 对于这样一个单例,我们测试以下它反序列化之后是否依然相等呢? 所以,如果我们要得到一个支持反序列化的单例,以上方法还需要改进,我们需要给类加上一个特殊的方法: readResolve() 方法在反序列化方法 readObject() 中被调用。如果一个类实现了这个方法,那么 readObject() 将优先执行这个方法来获得实例,否则执行内建的机制来生成新的实例。而在这个方法调用 getInstance() 来返回当前系统中已经存在的那个实例,因此避免了生成新的实例,就保证了系统中始终存在唯一的单例。