本篇Blog继续学习创建型模式,创建型模式的主要关注点是怎样创建对象,它的主要特点是将对象的创建与使用分离,这样可以降低系统的耦合度,使用者不需要关注对象的创建细节。本篇学习的是原型模式。由于学习的都是设计模式,所有系列文章都遵循如下的目录:
- 模式档案:包含模式的定义、模式的特点、解决什么问题、优缺点、使用场景等
- 模式结构:包含模式的结构,包含的角色定义及调用关系
- 模式实现:包含模式的实现方式代码举例或者生活中简单问题映射代码举例
- 模式实践:如果工作中或开源项目用到了该模式,就将使用过程贴到这里,并且客观讨论使用的是否恰当
- 模式对比:如果模式相似或模式有额外的替换方法,有必要体现其相似点及不同点,区分使用,说明哪些场景下使用哪种模式比较好
- 模式扩展:如果模式有与标准结构定义不同的变体形式,一并体现出其变体结构;对模式的思考需要进行发散等。
接下来所有设计模式的介绍都暂且遵循此基本行文逻辑吗,如果某一条目没有则无需体现,但条目顺序遵循此结构,本文的模式实践案例大多来自极客时间。
模式档案
在有些系统中,存在大量相同或相似对象的创建问题,如果用传统的构造函数来创建对象,会比较复杂且耗时耗资源,用原型模式生成对象就很高效。
模式定义:用一个已经创建的实例作为原型,通过复制该原型对象来创建一个和原型相同或相似的新对象。在这里,原型实例指定了要创建的对象的种类。用这种方式创建对象非常高效,根本无须知道对象创建的细节。在有些系统中,存在大量相同或相似对象的创建问题,如果用传统的构造函数来创建对象,会比较复杂且耗时耗资源,用原型模式生成对象就很高效
模式特点:主要特点是用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。浅拷贝通过实现 Cloneable重写,深拷贝是通过实现 Serializable 读取二进制流
解决什么问题:主要解决复杂或常用对象的高性能快速创建问题,当所需对象和原型对象相差无几且创建成本高时,使用原型模式比使用构造方法性能更高、限制更少。
优点:性能高;逃避构造函数的约束(可访问性等)。
缺点:有如下几个使用不便的地方或缺点
- 需要为每一个类都配置一个 clone 方法
- clone 方法位于类的内部,当对已有类进行改造的时候,需要修改代码,违背了开闭原则。
- 当实现深克隆时,需要编写较为复杂的代码,而且当对象之间存在多重嵌套引用时,为了实现深克隆,每一层对象对应的类都必须支持深克隆,实现起来会比较麻烦。因此,深克隆、浅克隆需要运用得当。
使用场景: 原型模式通常适用于以下场景。
- 对象之间相同或相似,即只是个别的几个属性不同的时候。
- 创建对象成本较大,例如初始化时间长,占用CPU太多,或者占用网络资源太多等,需要优化资源。
- 创建一个对象需要繁琐的数据准备或访问权限等,需要提高性能或者提高安全性。
- 系统中大量使用该类对象,且各个调用者都需要给它的属性重新赋值。
在 Spring 中,原型模式应用的非常广泛,例如 scope='prototype'
、JSON.parseObject()
等都是原型模式的具体应用
模式结构
原型模式包含抽象原型类、具体原型类、访问类三个角色
- 抽象原型类:规定了具体原型对象必须实现的接口。
- 具体原型类:实现抽象原型类的
clone() 方法
,它是可被复制的对象。
角色的相互调用关系如下图所示:
原型模式的克隆分为浅克隆和深克隆,详细可以看我这篇Blog: 【Java SE基础 四】封装、继承、多态
- 浅克隆:创建一个新对象,新对象的属性和原来对象完全相同,对于非基本类型属性,仍指向原有属性所指向的对象的内存地址,基本类型属性复制一份。
- 深克隆:创建一个新对象,属性中引用的其他对象也会被克隆,不再指向原有对象地址。
Java 中的 Object 类提供了浅克隆的 clone() 方法,具体原型类只要实现 Cloneable 接口就可实现对象的浅克隆,这里的 Cloneable 接口就是抽象原型类。
模式实现
依据结构来看下具体的各个角色代码:
抽象原型类
Java 中的 Object 类提供了浅克隆的 clone() 方法,具体原型类只要实现 Cloneable 接口就可实现对象的浅克隆,这里的 Cloneable 接口就是抽象原型类
具体原型类
具体原型类
//具体原型类 class Realizetype implements Cloneable { Realizetype() { System.out.println("具体原型创建成功!"); } public Object clone() throws CloneNotSupportedException { System.out.println("具体原型复制成功!"); return (Realizetype) super.clone(); } }
访问类
//原型模式的测试类 public class PrototypeTest { public static void main(String[] args) throws CloneNotSupportedException { Realizetype obj1 = new Realizetype(); Realizetype obj2 = (Realizetype) obj1.clone(); System.out.println("obj1==obj2?" + (obj1 == obj2)); } }
请求结果如下:
具体原型创建成功! 具体原型复制成功! obj1==obj2?false
模式实践
实际上,创建对象包含的申请内存、给成员变量赋值这一过程,本身并不会花费太多时间,或者说对于大部分业务系统来说,这点时间完全是可以忽略的。应用一个复杂的模式,只得到一点点的性能提升,这就是所谓的过度设计,得不偿失。但是,如果对象中的数据需要经过复杂的计算才能得到(比如排序、计算哈希值),或者需要从 RPC、网络、数据库、文件系统等非常慢速的 IO 中读取,这种情况下,我们就可以利用原型模式,从其他已有对象中直接拷贝得到,而不用每次在创建新对象的时候,都重复执行这些耗时的操作
设计一个可快速升级的数据容器
我们来看一个关于数据版本升级的例子,假设数据库中存储了大约 10 万条搜索关键词信息,每条信息包含关键词、关键词被搜索的次数、信息最近被更新的时间等。系统 A 在启动的时候会加载这份数据到内存中,用于处理某些其他的业务需求。为了方便快速地查找某个关键词对应的信息,我们给关键词建立一个散列表索引,使用 HashMap 容器来实现。其中,HashMap 的 key 为搜索关键词,value 为关键词详细信息(比如搜索次数)。我们只需要将数据从数据库中读取出来,放入 HashMap 就可以了。不过,我们还有另外一个系统 B,专门用来分析搜索日志,定期(比如间隔 10 分钟)批量地更新数据库中的数据,并且标记为新的数据版本。比如,在下面的示例图中,我们对 v2 版本的数据进行更新,得到 v3 版本的数据。这里我们假设只有更新和新添关键词,没有删除关键词的行为
1 保证系统数据实时性
为了保证系统 A 中数据的实时性(不一定非常实时,但数据也不能太旧),系统 A 需要定期根据数据库中的数据,更新内存中的索引和数据,我们可以按照如下步骤实现:
- 在系统 A 中,记录当前数据的版本 Va 对应的更新时间 Ta,从数据库中捞出更新时间大于 Ta 的所有搜索关键词,也就是找出 Va 版本与最新版本数据的差集
- 针对差集中的每个关键词进行处理。如果它已经在散列表中存在了,我们就更新相应的搜索次数、更新时间等信息;如果它在散列表中不存在,我们就将它插入到散列表中。
代码示例如下:
public class Demo { private ConcurrentHashMap<String, SearchWord> currentKeywords = new ConcurrentHashMap<>(); private long lastUpdateTime = -1; public void refresh() { // 从数据库中取出更新时间>lastUpdateTime的数据,放入到currentKeywords中 List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime); long maxNewUpdatedTime = lastUpdateTime; for (SearchWord searchWord : toBeUpdatedSearchWords) { if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) { maxNewUpdatedTime = searchWord.getLastUpdateTime(); } if (currentKeywords.containsKey(searchWord.getKeyword())) { currentKeywords.replace(searchWord.getKeyword(), searchWord); } else { currentKeywords.put(searchWord.getKeyword(), searchWord); } } lastUpdateTime = maxNewUpdatedTime; } private List<SearchWord> getSearchWords(long lastUpdateTime) { // TODO: 从数据库中取出更新时间>lastUpdateTime的数据 return null; } }
2 保证系统版本整体升级且不停机更新
这时我们有个新的需求:任何时刻,系统 A 中的所有数据都必须是同一个版本的,要么都是版本 a,要么都是版本 b,不能有的是版本 a,有的是版本 b。那刚刚的更新方式就不能满足这个要求了。除此之外,我们还要求:在更新内存数据的时候,系统 A 不能处于不可用状态,也就是不能停机更新数据
- 我们把正在使用的数据的版本定义为服务版本,当我们要更新内存中的数据的时候,并不直接在服务版本(假设是版本 a 数据)上更新,而是重新创建另一个版本数据(假设是版本 b 数据),等新的版本数据建好之后,再一次性地将服务版本从版本 a 切换到版本 b。这样既保证了数据一直可用,又避免了中间状态的存在
代码如下所示:
public class Demo { private HashMap<String, SearchWord> currentKeywords=new HashMap<>(); public void refresh() { HashMap<String, SearchWord> newKeywords = new LinkedHashMap<>(); // 从数据库中取出所有的数据,放入到newKeywords中 List<SearchWord> toBeUpdatedSearchWords = getSearchWords(); for (SearchWord searchWord : toBeUpdatedSearchWords) { newKeywords.put(searchWord.getKeyword(), searchWord); } currentKeywords = newKeywords; } private List<SearchWord> getSearchWords() { // TODO: 从数据库中取出所有的数据 return null; } }
3 使用原型模式快速复制数据容器
不过,在上面的代码实现中,newKeywords 构建的成本比较高。我们需要将这 10 万条数据从数据库中读出,然后给每一条数据计算哈希值,构建 newKeywords。这个过程显然是比较耗时。为了提高效率,原型模式就派上用场了。我们拷贝 currentKeywords 数据到 newKeywords 中,然后从数据库中只捞出新增或者有更新的关键词,更新到 newKeywords 中。而相对于 10 万条数据来说,每次新增或者更新的关键词个数是比较少的,所以,这种策略大大提高了数据更新的效率。newKeywords 更新完后再整体替换currentKeywords做到整体版本切换
public class Demo { private HashMap<String, SearchWord> currentKeywords=new HashMap<>(); private long lastUpdateTime = -1; public void refresh() { // 原型模式就这么简单,拷贝已有对象的数据,更新少量差值 HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone(); // 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中 List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime); long maxNewUpdatedTime = lastUpdateTime; for (SearchWord searchWord : toBeUpdatedSearchWords) { if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) { maxNewUpdatedTime = searchWord.getLastUpdateTime(); } if (newKeywords.containsKey(searchWord.getKeyword())) { SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword()); oldSearchWord.setCount(searchWord.getCount()); oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime()); } else { newKeywords.put(searchWord.getKeyword(), searchWord); } } lastUpdateTime = maxNewUpdatedTime; currentKeywords = newKeywords; } private List<SearchWord> getSearchWords(long lastUpdateTime) { // TODO: 从数据库中取出更新时间>lastUpdateTime的数据 return null; } }
但以上代码有个问题,在 Java 语言中,Object 类的 clone()
方法执行的就是我们刚刚说的浅拷贝。它只会拷贝对象中的基本数据类型的数据(比如,int、long),以及引用对象(SearchWord)的内存地址,不会递归地拷贝引用对象本身。
if (newKeywords.containsKey(searchWord.getKeyword())) { SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword()); oldSearchWord.setCount(searchWord.getCount()); oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime()); } else { newKeywords.put(searchWord.getKeyword(), searchWord); }
以上这段代码在修改新集合中SearchWord中值的时候也会影响当前集合,就不能满足我们的版本整体更新的要求了。所以在遇到引用对象的位置需要深拷贝。
4 深浅拷贝结合使用
我们可以先采用浅拷贝的方式创建 newKeywords。对于需要更新的 SearchWord 对象,我们再使用深度拷贝的方式创建一份新的对象,替换 newKeywords 中的老对象。毕竟需要更新的数据是很少的。这种方式既利用了浅拷贝节省时间、空间的优点,又能保证 currentKeywords 中的中数据都是老版本的数据
public class Demo { private HashMap<String, SearchWord> currentKeywords=new HashMap<>(); private long lastUpdateTime = -1; public void refresh() { // Shallow copy HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone(); // 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中 List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime); long maxNewUpdatedTime = lastUpdateTime; for (SearchWord searchWord : toBeUpdatedSearchWords) { if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) { maxNewUpdatedTime = searchWord.getLastUpdateTime(); } if (newKeywords.containsKey(searchWord.getKeyword())) { newKeywords.remove(searchWord.getKeyword()); } newKeywords.put(searchWord.getKeyword(), searchWord); } lastUpdateTime = maxNewUpdatedTime; currentKeywords = newKeywords; } private List<SearchWord> getSearchWords(long lastUpdateTime) { // TODO: 从数据库中取出更新时间>lastUpdateTime的数据 return null; } }
总结一下
和单例模式略有不同,原型模式要做的是进行对象复制。如果说单例是一份结构一份数据,那么原型则是一份结构多份数据。当系统中存在复杂但常用对象,且不同的对象属性值略微不同时,也就是当所需对象和原型对象相差无几且创建成本高时,使用原型模式比使用构造方法性能更高、限制更少,在Java中很简单,就是clone方法,但是需要注意的是深浅拷贝的不同含义,避免拷贝对象的使用对原型对象造成影响。还有一点需要注意,原型模式和我们经常用的spring的beanutils工具类进行对象拷贝有些差异,beanutils工具类主要(不排除同类)做的是两个不同类对象之间的相同属性数据传输工作,例如不同的数据对象转换,VO、DTO等。而原型模式通常是对同类对象的拷贝。