38.1.1 规格模式的实现
不知道诸位有没有使用C#3.5做过开发,它有一个非常重要的新特性——LINQ(Language INtegrated Query,语言集成查询),它提供了类似于SQL语法的遍历、筛选等功能,能完成对对象的查询,就像通过SQL语句查询数据库一样,例如这样的一个程序片段:
Dim DataList As String = {"abc", "def", "ght"}Dim Result = From T As String In DataList Where T = "abc"
这句话的意思就是从一个数组中查找出值为abc的元素,返回结果为IEnumerable,枚举器类型。注意看第二句话,它使用了类似SQL的Select语法结构,from、where关键字都有了,而且还支持类似的Orderby、Groupby功能,很强大,有兴趣的读者可以查阅有关资料。那在Java世界中是否也存在这样的辅助框架呢?有,JoSQL、Quaere都可以提供类似的LINQ语言,读者可以到网上研究一下JavaDoc,同样非常简单,功能强大。
我们今天要讲的主题与LINQ有很大关系,它是实现LINQ的核心。想想SQL语句中什么是最复杂的,是where后面的查询条件,看看自己写的SQL语句基本上都是一长串的条件判断,中间一堆的and、or、not逻辑符。我们今天的任务就是要实现条件语句的解析,该部分实现了,基本上LINQ语法已经实现了一大半。
我们以一个案例来讲解该技术,在内存中有10个User对象,根据不同的条件查找出用户,比如姓名包含某个字符、年龄小于多少岁等条件,类似这样的SQL:
Select * From User where name like '%国庆%'
查找出姓名中包含“国庆”两个字的用户,这在关系型数据库中很容易实现,但是在对象群中怎么实现这样的查询呢?好,看似很简单,先设计一个用户类,然后提供一个用户查找工具类,类图非常容易,如图38-1所示。
很简单的类图,有一个用户类,同时提供了一个操作用户的辅助类,我们先来看User类,如代码清单38-1所示。
图38-1 简单用户查询类图
代码清单38-1 用户类
public class User { //姓名 private String name; //年龄 private int age; public User(String _name,int _age){ this.name = _name; this.age = _age; } public String getName { return name; } public void setName(String name) { this.name = name; } public int getAge { return age; } public void setAge(int age) { this.age = age; }//用户信息打印 @Override public String toString{ return "用户名:" + name+"/t年龄:" + age; }}
User就是一个简单BO业务对象,再来看用户操作接口,它定义一个用户操作类必须具有的方法,如代码清单38-2所示。
代码清单38-2 用户操作对象接口
public interface IUserProvider { //根据用户名查找用户 public ArrayList<User> findUserByNameEqual(String name); //年龄大于指定年龄的用户 public ArrayList<User> findUserByAgeThan(int age);}
在这里只定义了两个查询实现,分别是名字相同的用户和年龄大于指定年龄的用户,大家都知道,相似的查询条件还有很多,比如名字中包含指定字符、年龄小于指定年龄等,我们仅以实现这两个查询作为代表,如代码清单38-3所示。
代码清单38-3 用户操作类
public class UserProvider implements IUserProvider { //用户列表 private ArrayList<User> userList; //构造函数传递用户列表 public UserProvider(ArrayList<User> _userList){ this.userList = _userList; } //年龄大于指定年龄的用户 public ArrayList<User> findUserByAgeThan(int age) { ArrayList<User> result = new ArrayList<User>; for(User u:userList){ if(u.getAge>age){ //符合条件的用户 result.add(u); } } return result; } //姓名等于指定姓名的用户 public ArrayList<User> findUserByNameEqual(String name) { ArrayList<User> result = new ArrayList<User>; for(User u:userList){ if(u.getName.equals(name)){//符合条件 result.add(u); } } return result; }}
通过for循环遍历一个动态数组,判断用户是否符合条件,将符合条件的用户放置到另外一个数组中,比较简单。我们编写场景类来模拟该情景,如代码清单38-4所示。
代码清单38-4 场景类
public class Client { public static void main(String args) { //首先初始化一批用户 ArrayList<User> userList = new ArrayList<User>; userList.add(new User("苏大",3)); userList.add(new User("牛二",8)); userList.add(new User("张三",10)); userList.add(new User("李四",15)); userList.add(new User("王五",18)); userList.add(new User("赵六",20)); userList.add(new User("马七",25)); userList.add(new User("杨八",30)); userList.add(new User("侯九",35)); userList.add(new User("布十",40)); //定义一个用户查询类 IUserProvider userProvider = new UserProvider(userList); //打印出年龄大于20岁的用户 System.out.println("===年龄大于20岁的用户==="); for(User u:userProvider.findUserByAgeThan(20)){ System.out.println(u); } }}
运行结果如下所示:
===年龄大于20岁的用户===
用户名:马七 年龄:25
用户名:杨八 年龄:30
用户名:侯九 年龄:35
用户名:布十 年龄:40
结果非常正确,但是这样的一个框架基本上是不能适应业务变化的,为什么呢?业务变化虽然无规则,但是可以预测,比如我们这个查询,今天要查找年龄大于20岁的用户,明天要查找年龄小于30岁的用户,后天要查找姓名中包含“国庆”两个字的用户,想想看IUserProvider接口是不是要一直修改下去?接口是契约,而且我们一直提倡面向接口编程,但是在这里接口竟然都可以修改,是不是发现设计有很大问题了!
问题发现了,就要想办法解决。再回顾一下编写的代码,注意看findUserByAgeThan和findUserByNameEqual两个方法,两者的代码有什么不同呢?除了if后面的判断条件不同外,就没有不同的地方了,我们一直在说封装变化,这两段程序就仅仅有这一个变化点,我们是不是可以把它封装起来呢?完全可以,把它们两者的共同点抽取出来,先修改一下接口,如代码清单38-5所示。
代码清单38-5 修正后的接口
public interface IUserProvider { //根据条件查找用户 public ArrayList<User> findUser(boolean condition);}
这个接口的设计想法非常好,但是参数condition很难实现,看看findUserByAgeThan、findUserByNameEqual这两个方法,怎么才能把两者的不同点设置成一个布尔型呢?如果需要在IUserProvider对象外判断后传递进来,那我们的封装就没有任何意义了——目前为止,这个方案有问题了。
继续考虑,既然不能在封装外运算,那就把整个条件都进行封装,由IUserProvider自己实现运算。好方法!那我们就设计一个这样的类,我们叫它规格类,什么意思呢?它是对一批对象的说明性描述,它依照基准判断候选对象是否满足条件。
思考后,我们设计出类图,如图38-2所示。
图38-2 加入规格后的设计类图
在该类图中建立了一个规格书接口,它的作用就是定制各种各样的规格,比如名字相等的规格UserByNameEqual、年龄大于基准年龄的规格UserByAgeThan等,然后在用户操作类中采用该规格进行判断。User类没有任何改变,如代码清单38-1所示,不再赘述。
规格书接口是对全体规格书的声明定义,如代码清单38-6所示。
代码清单38-6 规格书接口
public interface IUserSpecification { //候选者是否满足要求 public boolean isSatisfiedBy(User user); }
规格书接口只定义一个方法,判断候选用户是否满足条件。再来看姓名相同的规格书,它实现了规格书接口,如代码清单38-7所示。
代码清单38-7 姓名相同的规格书
public class UserByNameEqual implements IUserSpecification { //基准姓名 private String name; //构造函数传递基准姓名 public UserByNameEqual(String _name){ this.name = _name; } //检验用户是否满足条件 public boolean isSatisfiedBy(User user) { return user.getName.equals(name); }}
代码很简单,通过构造函数传递进来基准用户名,然后判断候选用户是否匹配。大于基准年龄的规格书与此类似,如代码清单38-8所示。
代码清单38-8 大于基准年龄的规格书
public class UserByAgeThan implements IUserSpecification { //基准年龄 private int age; //构造函数传递基准年龄 public UserByAgeThan(int _age){ this.age = _age; } //检验用户是否满足条件 public boolean isSatisfiedBy(User user) { return user.getAge > age; }}
规格书都已经定义完毕,我们再来看用户操作类,先看用户操作的接口,如代码清单38-9所示。
代码清单38-9 用户操作接口
public interface IUserProvider { //根据条件查找用户 public ArrayList<User> findUser(IUserSpecification userSpec);}
只有一个方法——根据指定的规格书查找用户。再来看其实现类,如代码清单38-10所示。
代码清单38-10 用户操作
public class UserProvider implements IUserProvider { //用户列表 private ArrayList<User> userList; //传递用户列表 public UserProvider(ArrayList<User> _userList){ this.userList = _userList; } //根据指定的规格书查找用户 public ArrayList<User> findUser(IUserSpecification userSpec) { ArrayList<User> result = new ArrayList<User>; for(User u:userList){ if(userSpec.isSatisfiedBy(u)){//符合指定规格 result.add(u); } } return result; }}
程序改动很小,仅仅在if判断语句中根据规格书进行判断,我们持续地扩展规格书,有多少查询分类就可以扩展出多少个实现类,而IUserProvider则不需要任何改动,它的一个方法就覆盖了我们刚刚提出的N多查询路径。我们设计一个场景来看看效果如何,如代码清单38-11所示。
代码清单38-11 场景类
public class Client { public static void main(String args) { //首先初始化一批用户 ArrayList<User> userList = new ArrayList<User>; userList.add(new User("苏大",3)); userList.add(new User("牛二",8)); userList.add(new User("张三",10)); userList.add(new User("李四",15)); userList.add(new User("王五",18)); userList.add(new User("赵六",20)); userList.add(new User("马七",25)); userList.add(new User("杨八",30)); userList.add(new User("侯九",35)); userList.add(new User("布十",40));//定义一个用户查询类 IUserProvider userProvider = new UserProvider(userList); //打印出年龄大于20岁的用户 System.out.println("===年龄大于20岁的用户==="); //定义一个规格书 IUserSpecification userSpec = new UserByAgeThan(20); for(User u:userProvider.findUser(userSpec)){ System.out.println(u); } }}
在场景类中定义了一个规格书,然后把规格书提交给UserProvider就可以查找到自己需要的用户了,运行结果相同,不再赘述。
大家想想看,如果现在需求变更了,比如需要一个年龄小于基准年龄的用户,该怎么修改?增加一个小于基准年龄的规格书,实现IUserSpecification接口,然后在新的业务中调用即可,别的什么都不需要修改。再比如需要一个类似SQL中like语句的处理逻辑,这个也不难,如代码清单38-12所示。
代码清单38-12 Like规格书
public class UserByNameLike implements IUserSpecification { //like的标记 private final static String LIKE_FLAG = "%"; //基准的like字符串 private String likeStr; //构造函数传递基准姓名 public UserByNameLike(String _likeStr){ this.likeStr = _likeStr; } //检验用户是否满足条件 public boolean isSatisfiedBy(User user) { boolean result = false; String name = user.getName; //替换掉%后的干净字符串 String str = likeStr.replace("%",""); //是以名字开头,如'国庆%' if(likeStr.endsWith(LIKE_FLAG) && !likeStr.startsWith(LIKE_FLAG)){ result = name.startsWith(str); }else if(likeStr.startsWith(LIKE_FLAG) && !likeStr.endsWith(LIKE_FLAG)){ //类似 '%国庆' result = name.endsWith(str); }else{ result = name.contains(str); //类似于'%国庆%' } return result; }}
同时,场景类也要适当地改动,毕竟业务已经发生了变化,高层模块要适应这种变化,如代码清单38-13所示。
代码清单38-13 场景类
public class Client { public static void main(String args) { //首先初始化一批用户 ArrayList<User> userList = new ArrayList<User>; userList.add(new User("苏国庆",23)); userList.add(new User("国庆牛",82)); userList.add(new User("张国庆三",10)); userList.add(new User("李四",10)); //定义一个用户查询类 IUserProvider userProvider = new UserProvider(userList); //打印出名字包含"国庆"的人员 System.out.println("===名字包含国庆的人员==="); //定义一个规格书 IUserSpecification userSpec = new UserByNameLike("%国庆%"); for(User u:userProvider.findUser(userSpec)){ System.out.println(u); } }}
运行结果如下所示:
===名字包含国庆的人员===
用户名:苏国庆 年龄:23
用户名:国庆牛 年龄:82
用户名:张国庆三 年龄:10
到目前为止,我们已经设计了一个可扩展的对象查询平台,但是我们还有遗留问题未解决,看看SQL语句,为什么where后面会很长?是因为有AND、OR、NOT这些逻辑操作符的存在,它们可以串联起多个判断语句,然后整体反馈出一个结果来。想想看,我们上面的平台能支持这种逻辑操作符吗?不能,你要说能,那也说得通,需要两次过滤才能实现,比如要找名字包含“国庆”并且年龄大于25岁的用户,代码该怎么修改?如代码清单38-14所示。
代码清单38-14 复合查询
public class Client { public static void main(String args) { //定义一个规格书 IUserSpecification userSpec1 = new UserByNameLike("%国庆%"); IUserSpecification userSpec2 = new UserByAgeThan(20); userList = userProvider.findUser(userSpec1); for(User u:userProvider.findUser(userSpec2)){ System.out.println(u); } }}
能够实现,但是思考一下程序逻辑,它采用了两次过滤,也就是两次循环,如果对象数量少还好说,如果对象数量巨大,这个效率就太低了,这是其一;其二,组合方式非常多,比如“与”、“或”、“非”可以自由组合,姓名中包含“国庆”但年龄小于25的用户,姓名中不包含国庆但年龄大于25岁的用户等,我们还能如此设计吗?太多的组合方式,产生组合爆炸,这种设计就不妥了,应该有更优秀的方案。
我们换个方式思考该问题,不管是AND或者OR或者NOT操作,它们的返回结果都还是一个规格书,只是逻辑更复杂了而已,这3个操作符只是提供了对原有规格书的复合作用,换句话说,规格书对象之间可以进行与或非操作,操作的结果不变,分析到这里,我们就可以开始修改接口了,如代码清单38-15所示。
代码清单38-15 带与或非的规格书接口
public interface IUserSpecification { //候选者是否满足要求 public boolean isSatisfiedBy(User user); //and操作 public IUserSpecification and(IUserSpecification spec); //or操作 public IUserSpecification or(IUserSpecification spec); //not操作 public IUserSpecification not;}
在规格书接口中增加了与或非的操作,接口修改了,实现类当然也要修改。先全面思考一下业务,与或非是不可扩展的操作,规格书(也就是规格对象)之间的操作只有这三种方法,是不需要扩展也不用预留扩展空间的。如此,我们就可以把与或非的实现放到基类中,那现在的问题变成了怎么在基类中实现与或非。注意看它们的返回值都需要返回规格书类型,很明显,我们在这里要用到递归调用了。可以这样理解,基类需要子类提供业务逻辑支持,因为基类是一个抽象类,不能实例化后返回,我们把简单类图画出来,如图38-3所示。
图38-3 与规格的示意
基类对子类产生了依赖,然后进行递归计算,大家一定会发出这样的疑问:父类怎么可能依赖子类,这还是面向接口编程吗?想想看,我们提出面向接口编程的目的是什么?是为了适应变化,拥抱变化,对于不可能发生变化的部分为什么不能固化呢?与或非操作符号还会增加修改吗?规格书对象之间的操作还有其他吗?思考清楚这些问题后,答案就迎刃而解了。
注意 父类依赖子类的情景只有在非常明确不会发生变化的场景中存在,它不具备扩展性,是一种固化而不可变化的结构。
分析完毕,我们设计出详细的类图,如图38-4所示。
可能大家有很多的疑问,我们先来分析代码,代码分析完毕估计能解决你大部分的疑问。规格书接口如代码清单38-15所示,不再赘述。我们来看组合规格书(CompositeSpecification),它是一个抽象类,实现了与或非的操作,如代码清单38-16所示。
代码清单38-16 组合规格书
public abstract class CompositeSpecification implements IUserSpecification { //是否满足条件由实现类实现 public abstract boolean isSatisfiedBy(User user); //and操作 public IUserSpecification and(IUserSpecification spec) { return new AndSpecification(this,spec); } //not操作 public IUserSpecification not { return new NotSpecification(this); } //or操作 public IUserSpecification or(IUserSpecification spec) { return new OrSpecification(this,spec); }}
图38-4 完整规格书类图
候选对象是否满足条件是由isSatisfiedBy方法决定的,它代表的是一个判断逻辑,由各个实现类实现。三个与或非操作在抽象类中实现,它是通过直接new了一个子类,如此设计非常符合单一职责原则,每个子类都有一个独立的职责,要么完成“与”操作,要么完成“或”操作,要么完成“非”操作。我们先来看“与”操作规格书,如代码清单38-17所示。
代码清单38-17 与规格书
public class AndSpecification extends CompositeSpecification { //传递两个规格书进行and操作 private IUserSpecification left; private IUserSpecification right; public AndSpecification(IUserSpecification _left,IUserSpecification _right){ this.left = _left; this.right = _right; } //进行and运算 @Override public boolean isSatisfiedBy(User user) { return left.isSatisfiedBy(user) && right.isSatisfiedBy(user); }}
通过构造函数传递过来两个需要操作的规格书,然后通过isSatisfiedBy方法返回两者and操作的结果。或规格书和非规格书与此类似,分别如代码清单38-18、代码清单38-19所示。
代码清单38-18 或规格书
public class OrSpecification extends CompositeSpecification { //左右两个规格书 private IUserSpecification left; private IUserSpecification right; public OrSpecification(IUserSpecification _left,IUserSpecification _right){ this.left = _left; this.right = _right; } //or运算 @Override public boolean isSatisfiedBy(User user) { return left.isSatisfiedBy(user) || right.isSatisfiedBy(user); }}
代码清单38-19 非规格书
public class NotSpecification extends CompositeSpecification { //传递一个规格书 private IUserSpecification spec; public NotSpecification(IUserSpecification _spec){ this.spec = _spec; } //not操作 @Override public boolean isSatisfiedBy(User user) { return !spec.isSatisfiedBy(user); }}
这三个规格书都是不发生变化的,只要使用该框架,三个规格书都要实现的,而且代码基本上是雷同的,所以才有了父类依赖子类的设计,否则是严禁出现父类依赖子类的情况的。大家再仔细看看这三个规格书和组合规格书,代码很简单,但也很巧妙,它跳出了我们面向对象设计的思维,不变部分使用一种固化方式实现。
姓名相同、年龄大于基准年龄、Like格式等规格书都有少许改变,把实现接口变为继承基类,我们以名字相等规格书为例,如代码清单38-20所示。
代码清单38-20 姓名相同规格书
public class UserByNameEqual extends CompositeSpecification { //基准姓名 private String name; //构造函数传递基准姓名 public UserByNameEqual(String _name){ this.name = _name; } //检验用户是否满足条件 public boolean isSatisfiedBy(User user) { return user.getName.equals(name); }}
仅仅修改了黑体部分,其他没有任何改变。另外两个规格书修改相同,不再赘述。其他的User及UserProvider没有任何改动,不再赘述。
我们修改一下场景类,如代码清单38-21所示。
代码清单38-21 场景类
public class Client { public static void main(String args) { //首先初始化一批用户 ArrayList<User> userList = new ArrayList<User>; userList.add(new User("苏国庆",23)); userList.add(new User("国庆牛",82)); userList.add(new User("张国庆三",10)); userList.add(new User("李四",10)); //定义一个用户查询类 IUserProvider userProvider = new UserProvider(userList); //打印出名字包含"国庆"的人员 System.out.println("===名字包含国庆的人员==="); //定义一个规格书 IUserSpecification spec = new UserByAgeThan(25); IUserSpecification spec2 = new UserByNameLike("%国庆%"); for(User u:userProvider.findUser(spec.and(spec2))){ System.out.println(u); } }}
在场景类中我们建立了两个规格书,一个是年龄大于25的用户,另一个是名字中包含“国庆”两个字的用户,这两个规格书之间的关系是“与”关系,运行结果如下:
===名字包含国庆的人员===
用户名:国庆牛 年龄:82
到此为止我们的LINQ已经完成了很大一部分了,SQL语句中的where后面部分已经可以解析了,完全可以再增加年龄相等的规格书、姓名字数规格书等,你在SQL中使用过的条件在这里都能实现了。功臣还是依赖于三个与或非规格书,有了它们三个栋梁才能组合出一个精彩的条件查询世界。
38.1.2 最佳实践
我们在例子中多次提到规格两个字,该实现模式就叫做规格模式(Specification Pattern),它不属于23个设计模式,它是其中一个模式的扩展,是哪个模式呢?
我们用全局的观点思考一下,基类代表的是所有的规格书,它的目的是描述一个完整的、可组合的规格书,它代表的是一个整体,其下的And规格书、Or规格书、Not规格书、年龄大于基准年龄规格书等都是一个真实的实现,也就是一个局部,现在我们又回到了整体和部分的关系了,那这是什么模式?对,组合模式,它是组合模式的一种特殊应用,我们来看它的通用类图,如图38-5所示。
图38-5 规格模式通用类图
为什么在通用类图中把方法名称都定义出来呢?是因为只要使用规格模式,方法名称都是这四个,它是把组合模式更加具体化了,放在一个更狭小的应用空间中。我们再仔细看看,还能不能找到其他模式的身影?对,策略模式,每个规格书都是一个策略,它完成了一系列逻辑的封装,用年龄相等的规格书替换年龄大于指定年龄的规格书上层逻辑有什么改变吗?不需要任何改变!
规格模式非常重要,它巧妙地实现了对象筛选功能。我们来看其通用源码,首先看抽象规格书,如代码清单38-22所示。
代码清单38-22 抽象规格书
public interface ISpecification { //候选者是否满足要求 public boolean isSatisfiedBy(Object candidate); //and操作 public ISpecification and(ISpecification spec); //or操作 public ISpecification or(ISpecification spec); //not操作 public ISpecification not;}
组合规格书实现与或非的算法,如代码清单38-23所示。
代码清单38-23 组合规格书
public abstract class CompositeSpecification implements ISpecification { //是否满足条件由实现类实现 public abstract boolean isSatisfiedBy(Object candidate); //and操作 public ISpecification and(ISpecification spec) { return new AndSpecification(this,spec); } //not操作 public ISpecification not { return new NotSpecification(this); } //or操作 public ISpecification or(ISpecification spec) { return new OrSpecification(this,spec); }}
与或非规格书代码分别如代码清单38-24至代码清单38-26所示。
代码清单38-24 与规格书
public class AndSpecification extends CompositeSpecification { //传递两个规格书进行and操作 private ISpecification left; private ISpecification right; public AndSpecification(ISpecification _left,ISpecification _right){ this.left = _left; this.right = _right; } //进行and运算 @Override public boolean isSatisfiedBy(Object candidate) { return left.isSatisfiedBy(candidate) && right.isSatisfiedBy(candidate); }}
代码清单38-25 或规格书
public class OrSpecification extends CompositeSpecification { //左右两个规格书 private ISpecification left; private ISpecification right; public OrSpecification(ISpecification _left,ISpecification _right){ this.left = _left; this.right = _right; } //or运算 @Override public boolean isSatisfiedBy(Object candidate) { return left.isSatisfiedBy(candidate) || right.isSatisfiedBy(candidate); }}
代码清单38-26 非规格书
public class NotSpecification extends CompositeSpecification { //传递一个规格书 private ISpecification spec; public NotSpecification(ISpecification _spec){ this.spec = _spec; } //not操作 @Override public boolean isSatisfiedBy(Object candidate) { return !spec.isSatisfiedBy(candidate); }}
以上一个接口、一个抽象类、3个实现类只要在适用规格模式的地方都完全相同,不用做任何的修改,大家闭着眼照抄就成,要修改的是下面的规格书——业务规格书,如代码清单38-27所示。
代码清单38-27 业务规格书
public class BizSpecification extends CompositeSpecification { //基准对象 private Object obj; public BizSpecification(Object _obj){ this.obj = _obj; } @Override public boolean isSatisfiedBy(Object candidate) { //根据基准对象和候选对象,进行业务判断,返回boolean return false; }}
然后就是看怎么使用了,场景类如代码清单38-28所示。
代码清单38-28 场景类
public class Client { public static void main(String args) { //待分析的对象 ArrayList<Object> list = new ArrayList<Object>; //定义两个业务规格书 ISpecification spec1 = new BizSpecification(new Object); ISpecification spec2 = new BizSpecification(new Object); //规则的调用 for(Object obj:list){ if(spec1.and(spec2).isSatisfiedBy(obj)){ //and操作 System.out.println(obj); } } }}
规格模式已经是一个非常具体的应用框架了(相对于23个设计模式),大家遇到类似多个对象中筛选查找,或者业务规则不适于放在任何已有实体或值对象中,而且规则的变化和组合会掩盖那些领域对象的基本含义,或者是想自己编写一个类似LINQ的语言工具的时候就可以照搬这部分代码,只要实现自己的逻辑规格书即可。