原型模式最早出现于 1963 年的一个叫 Sketchpad 的系统中,说起 Sketchpad 你可能并不熟悉,但是说起 CAD(计算机辅助设计),现在在工程设计领域几乎无人不知,其实 Sketchpad 就被认为是现代 CAD 程序的鼻祖,主要思想是拥有一张可以实例化成许多副本的原图,如果用户更改了主工程图,则所有实例也会更改。这便是原型模式最初的思维模型。
不过在面向对象编程中,对象的原型在变化时通常不会影响新实例对象。实际上,原型模式不仅在 Java、C++ 等主要基于类的编程语言中有广泛应用,而且还在一开始就是基于原型的 JavaScript 等编程语言中得到了发扬光大。原型模式的原理与使用其实很简单,但是要想用好它,我们除了要了解它的优点以外,更要重点注意它带来的问题以及掌握如何规避这些问题。下面,我们就一起来看看吧。
一、模式原理分析
原型模式的定义是:使用原型实例指定创建对象的种类,然后通过拷贝这些原型来创建新的对象。
你可以看到,定义中清晰地指出了两个关键点,一个是要建立原型,另一个是基于原型做拷贝。原理很简单,在现代编程中,我们经常会用到的 Ctrl+C 加 Ctrl+V 编程,可以说就是最直接的原型模式的实践之一。
我们来看看原型模式原始的 UML 图:
从这个 UML 图中,我们可以看到原型模式中的关键角色有三个:
-
使用者;
-
原型;
-
新实例。
使用者需要建立一个原型,才能基于原型拷贝出新实例。除此之外,使用者还需要决策什么时候使用原型、什么时候使用新实例,以及从原型到新实例之间的拷贝应该采用什么样的算法策略,这些都是使用者来进行控制和决定的。只不过通常我们会使用一个通用的抽象拷贝接口来对外提供拷贝。
那 UML 图对应的代码该怎么实现呢?可参考下面这段基于 Java Cloneable 接口的代码实现:
代码中,我们定义的 PrototypeInterface 接口通过继承 Cloneable 接口并重写 clone() 方法来实现对象的拷贝,而 ProtypeA 和 ProtypeB 都可以在建立自己的原型对象后,调用 clone() 方法来创建新对象。这里要注意的是,Cloneable 接口本身是空方法,调用的 clone() 方法其实是 Object.clone() 方法。
从以上内容会发现,原型模式封装了如下变化:
-
原始对象的构造方式;
-
对象的属性与其他对象间的依赖关系;
-
对象运行时状态的获取方式;
-
对象拷贝的具体实现策略。
所以说,原型模式从建立原型到拷贝原型生成新实例,都是对用户透明的,一旦中间任何一个小细节出现问题,可能获取的就是一个错误的对象。
二、使用场景分析
原型模式常见的使用场景有以下六种。
-
资源优化场景。也就是当进行对象初始化需要使用很多外部资源时,比如,IO 资源、数据文件、CPU、网络和内存等。
-
复杂的依赖场景。 比如,F 对象的创建依赖 A,A 又依赖 B,B 又依赖 C……于是创建过程是一连串对象的 get 和 set。
-
性能和安全要求的场景。 比如,同一个用户在一个会话周期里,可能会反复登录平台或使用某些受限的功能,每一次访问请求都会访问授权服务器进行授权,但如果每次都通过 new 产生一个对象会非常烦琐,这时则可以使用原型模式。
-
同一个对象可能被多个修改者使用的场景。 比如,一个商品对象需要提供给物流、会员、订单等多个服务访问,而且各个调用者可能都需要修改其值时,就可以考虑使用原型模式。
-
需要保存原始对象状态的场景。 比如,记录历史操作的场景中,就可以通过原型模式快速保存记录。
-
结合工厂模式来使用。 在实际项目中,原型模式除了单独基于对象使用外,还可以结合工厂方法模式一起使用,通过定义统一的复制接口,比如 clone、copy。使用一个工厂来统一进行拷贝和新对象创建, 然后由工厂方法提供给调用者使用。
在实际的一些类库和组件中都有原型模式的应用。比如,Spring 中使用@Scope(“prototype”)注解来使得注入的 Java Bean 使用原型模式。Fastjson 中的 JSON.parseObject() 也是一种原型模式的实践。再比如,在 JDK 中,使用 cloneable 接口的都能实现原型模式。
原型模式的适用场景通常都是对已有的复杂对象或大型对象的创建上,在这样的场景中,创建对象通常是一件非常烦琐的事情,通过拷贝对象能快速地创建对象。
其实这里还涉及一个扩展知识点:浅拷贝与深拷贝。当我们在做对象拷贝时,需要在浅拷贝和深拷贝之间做取舍。如果类仅包含原始字段和不可变字段,可以使用浅拷贝;如果类还包含有可变字段的引用(比如,对象中包含对象),那么我们就应该使用深拷贝。
三、为什么要使用原型模式?
分析完原型模式的原理和使用场景后,我们再来说说使用原型模式的原因,主要有以下四个。
第一个,减少每次创建对象的资源消耗。 当类初始化消耗资源特别多时,原型模式特别有用。比如,在 AI 系统中,我们经常需要频繁使用大量不同分类的数据模型文件,在对这一类文件建立对象模型时,不仅会长时间占用 IO 读写资源,还会消耗大量 CPU 运算资源,如果频繁创建模型对象,就会很容易造成服务器 CPU 被打满而导致系统宕机。通过原型模式我们可以很容易地解决这个问题,当我们完成对象的第一次初始化后,新创建的对象便使用对象拷贝(在内存中进行二进制流的拷贝),虽然拷贝也会消耗一定资源,但是相比初始化的外部读写和运算来说,内存拷贝消耗会小很多,而且速度快很多。
第二个,降低对象创建的时间消耗。 比如,需要查询数据库来创建对象时,如果数据库正好繁忙或锁表中,那么这个创建过程就可能出现长时间等待的情况。在很多高并发场景中,稍微长时间的等待可能都是致命的,因为大量的数据和请求如洪水一般涌入服务器,很容易引起雪崩效应。这时使用原型模式就是相当于对对象创建的过程进行了一次缓存读取,而不必一直阻塞程序的执行。
第三个,快速复制对象运行时状态。原型模式相比于传统的使用 new 关键字创建对象还有一个巨大的优势,那就是当构造函数中包含大量属性或定制化业务逻辑时,不用完全了解创建过程也能快速创建对象。比如,当一个对象类有 30 个以上的属性或方法时(属性字段可能为另一个对象),如果你都通过 get 和 set 方法来创建对象,你会发现复制粘贴都是一件痛苦的事,因为你可能都忘记了哪些字段是必选、哪些又是有数据的。这也是我们在接收 HTTP 和 RPC 传输的 JSON 数据时,更愿意采用反序列化(也是一种原型模式的实践)到对象的方式,而不是 new 一个新对象再赋值的原因。
第四个,能保存原始对象的副本。 在某些需要保存历史状态的场景中,比如,聊天消息、上线发布流程、需要撤销操作的程序等,原型模式能快速地复制现有对象的状态并留存副本,方便快速地回滚到上一次保存或最初的状态,避免因网络延迟、误操作等原因而造成数据的不可恢复。
四、原型模式的优缺点
使用原型模式主要有以下三个大的优点。
-
原型并不基于继承,因此没有继承的缺点。原型模式是对对象的直接复制,当新对象发生变化时,并不会对原始对象有任何影响,而继承的对象一旦发生了修改则会影响到父类。
-
复制大对象时,性能更优。比如,Java 使用的原型模式是基于内存二进制流的拷贝,而直接 new 一个大对象是 JVM 进行堆内分配并可能触发 Full GC,相比之下,使用 new 关键字时所做的操作实际上更多,而使用内存拷贝的方式在复制的性能上会更优。
-
可以快速扩展运行时对象的属性和方法。原型模式一方面简化了对象的创建过程,另一方面能够保留原始的对象状态,这样的优势是:在程序运行过程中,如果想要动态扩展对象的功能(增减方法或属性值),可以在不影响原有对象的情况下,动态扩展对象的功能。比如,结合 AOP 切面编程可以实现录制业务调用轨迹,加入应用性能监控,做动态数据埋点等操作。
当然,原型模式也不是十全十美的,它也有一些缺点。
-
虽然不基于继承,但原型需要一个被初始化过的正确对象。如果被复制的对象在进行复杂的初始化时失败或出现错误的初始化后,那么复制的对象也可能是错误的。
-
复制大对象时,可能出现内存溢出的 OOM 错误。虽然复制对象有诸多优点,但是不要忘记内存的大小是有限制的,如果你想要复制的对象已经占用了 80% 的内存空间,那么复制时大概率会导致内存溢出,而这时的解决办法要么是增加内存,要么是拆分对象。
-
动态扩展对象功能时可能会掩盖新的风险。虽然原型模式能够在运行时帮助我们快速扩展功能,但同时也可能使新对象的负荷更重。比如,埋点服务中我们通常会拷贝一份对象在某个时间节点的数据,并添加一些追踪数据后再推送给埋点服务,这样就可能增加过多的内存消耗,影响原有功能执行的性能,有时还可能引起 OOM,导致系统宕机。切记,如果没有充分验证过动态扩展功能的话,那么就不要轻易使用动态扩展,因为加入额外的新功能,大概率是会影响原有功能的。
原型模式可以说是创建型模式中灵活性最高的模式,不过在带来灵活性的同时,也带来了更大的风险,这对我们的设计与实现间接提出了更高的要求。
文章(专栏)将持续更新,欢迎关注公众号:服务端技术精选。欢迎点赞、关注、转发。