模式:工程化实现及扩展(设计模式Java 版)
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.1 说明

本章以工程化使用为目的,对Java SE提供的几个不那么引人注意的特性进行介绍,它们对于提高代码扩展性、灵活性很有帮助。

本章介绍的Java语言特性主要包括:

●Package(包)。

●Generics(泛型)。

●Annotation(标注)。

●Enum(枚举)。

由于Iterator(迭代器)已经融入Java语言之中,所以这部分内容放在具体模式章节中介绍。

2.2 Java部分语法内容扩展

2.2.1 规划和组织代码——包

好的包结构规划是工程化代码与非工程化代码一个最直观的区别。

对于大型组织而言,如果涉及的产品线、项目、平台很多,通过包有层次地组织各类代码,这是实施每个项目前需要花心思考虑的问题。包是树形结构,为了便于开发、测试人员使用和维护,最好有组织的统一分类标准,原因很简单——开发、测试人员使用的时候好定位,方便查找。

但做到这点并不容易,因为开发人员总是存在这样、那样的“个性”和“积习”,主要包括以下几个:

1.术语和缩写

有些项目组会命名 .***XML的包,但事实上按照《Java语言编码规范》的建议应该使用小写的xml,因为 .***XML的命名方式违背了编码规范中包的命名规范,规范条款如下:

The prefix of a unique package name is always written in all-lowercase ASCII letters and should be one of the top-level domain names,currently com,edu,gov,mil,net,org,or one of the English two-letter codes identifying countries as specified in ISO Standard 3166,1981.

但在项目组中,总有同事会定义 .***XML、.htmlCore这样的包,而且屡禁不止。

2.命名不统一

这更容易导致包组织结构的混乱。例如,开发一个涉及加密的代码库,可能代码库A的包被命名为encrypt***,代码库B的包被命名为crypto***或***cryptography,其他开发人员使用时就需要“猜”以前的开发人员是怎么命名的。

不仅如此,项目中尤为难以根治的是匈牙利命名法的“遗毒”,它常常成为新老开发人员统一命名的障碍。例如:按照匈牙利命名法,一个OLE控件的类型名称一般可以命名为COleControl,而按照《Java语言编码规范》,Java中应该命名为OleControl。对于曾经长期接受COM熏陶的开发人员往往会受这种意识影响其类型、参数、成员变量及包的命名,加之一般他们工作年限较长,所以在项目组中“势力”和“影响力”较大,因此与直接从Java入行的开发人员共事时难免会出现命名上的各种冲突。其结果往往是后续的开发人员根据包找合适的类型时也要“碰运气”,天晓得它要用的这个类是追随查尔斯·西蒙尼风格的 “老鸟”完成的,还是一名Java新人完成的。

原始的匈牙利命名法,现在被称为匈牙利应用命名法,由1972年至1981年在施乐帕洛阿尔托研究中心工作的程序员查尔斯·西蒙尼发明的。此人后来成为微软的总设计师。

3.规范包命名这项工作难以得到组织或上司的认同

一般来说,技术总监认为命名规范的事情它关心不着,这个事情太具体;架构师也觉得自己应该关心的是架构,编码的事情是开发工程师的任务;项目经理关注的是资源调度和进度。皮球踢来踢去就踢到开发人员脚下,而对于开发人员,恐怕他没有多少机会规定其他同事怎么命名。

上面这些问题在很多开发机构普遍存在,那么我们是否应该单纯、消极地接受呢?不应该。

无论如何,即便没有办法统一组织级的包结构,为了团队或者仅仅为你自己的未来发展做好“储蓄”,建议读者还是在动手编写第一行程序之前,本着对团队和自己负责的态度,先在记事本上规划好包结构。

这方面可参考的建议来自《Java语言编码规范》,示例代码如下:

Txt
com|org|net|edu….<Company>.(<Product>|<Technology>|<Project>…)[.<Feature>][.<Subpackage>]
例如:
com.sun.eng
edu.cmu.cs.bovik.cheese

本系列图书选择了com.marvellousworks.practicalpattern作为包的根节点,因为公司名称为“MarvellousWorks”(炫技),而所有模式示例代码都是为了实践准备的,因此命名为“practical pattern”。不过这样的划分是假设这个MarvellousWorks公司很小,相关的项目(或产品)很少,而且没有多少组织级通用的代码资源。如果假设它是一个大型软件企业,不妨参考采用下面的一级包结构:

Java(一级包命名)
package com.marvellousworks.app;
package com.marvellousworks.framework;
package com.marvellousworks.util;
package com.marvellousworks.tutorial;

各选项介绍如下。

●app:代表项目或产品。

●framework:代表组织级的通用开发框架,是面向某个开发领域可扩充的公共类库、后台服务、控件等,一般不独立运行,而是集成在具体的项目或产品中,如通用的授权框架、完全Ajax化的前后端组件、报表和打印中间件等。

●util:公司自主开发的各种工具,如现场故障排查工具、日志分析工具等。

●tutorial:完全面向培训用途,是对企业自身app、framework(甚至util)使用的示例程序。这几个一级包的依赖关系如图2-1所示。

图2-1 一级包布局示例

按照上述规划,如果MarvellousWorks公司为大型软件公司,同时该公司认为本书的“practical pattern”应作为实际工程化代码,那么它的包就不应属于com.marvellousworks.tutorial,而应划入com.marvellousworks.framework,并将它作为最核心的算法框架,放入com.marvellousworks.framework.core.practicalpattern包中。

无论最终如何定义包,它体现的是对代码资源的规划,如果读者觉得工程化的设计模式不是团队要考虑的内容,可以把它们放在一些无关痛痒的包下。例如:

com.marvellousworks.accessories.practicalpattern

com.marvellousworks.tutorial.accessories.practicalpattern

test.practicalpattern

2.2.2 正式命名的常量契约——枚举

如果说面向对象设计中类是对现实世界抽象的话,那么成员变量则是体现每个实例差异的关键因素。例如,每个人,尽管五官组成相同,但“实例化”之后就有了美丑之分,谈及学习经历更是如此,因为“毕业院校”、“所学专业”、“导师”等变量的不同,往往也会对很多人的就业、工作产生长期而且深远的影响,但其中也能发现很多相对不变、以前100年不变甚至预计在未来100年也不变的内容。例如,学位名称:学士、硕士、博士,相对我们开发的信息系统而言,它们应该是常量,围绕它们开发的各类信息系统,无论是内部处理逻辑还是外部集成逻辑,都应该将它们视为“常量契约”使用。如果站在更宏观的角度看,在国家和国际层面,为了规范这些“常量契约”,通常以各类规范和标准的形式定期公布相关内容,约束包括软件行业在内的各个行业强制或参考执行。

回到Java代码当中,这个问题同样值得关注,毕竟“散兵游勇”式的分散定义常量也许不会影响代码的执行,但看上去却不够简洁、优雅。所以当选择是否使用枚举时,建议套用如下规则判断:一系列常量具有明确的业务、技术含义,且预期在系统生命周期内不会变化。

采用枚举相当于给代码供需双方就一组常量(或者是准常量)订立了一个契约,并为这份契约起了一个明确的名称。相对于其他语言的开发者(如C#),Java开发人员使用枚举更有优势,因为Java中枚举是一个类,而不是一个结构体,也就是说它可以具有成员(Field)、方法、构造函数,从微观角度看,Java的枚举不仅为一组常量命名了一份契约,更提供了一个关于这组常量的一个“外观”(Facade,后面我们将在结构型模式介绍)。

作为Java开发人员,也许对于Java的枚举语法“身在福中不知福”,在第3章“Java和C#”有一个对照示例,如果站在客户程序的角度看待它们,相信读者会更加珍视Java的这个语法特性,并在未来的项目中善用之。

2.2.3 考验算法的抽象能力——泛型

1.抽象及抽象能力支持

自从第一次使用STL之后,笔者就一直特别喜欢具有模板类的语言,主要有以下3个原因:

●算法更抽象。

●嵌套之后(如C<T<K,V>,E>),可以表示抽象之上的进一步抽象。

●强参数类型无论在开发过程、编译过程,还是运行过程中都有优势。

先谈抽象,如果读者是组织内部的核心开发成员,负责公共平台建设或通用中间件设计,那么抽象就不是“可有可无”而是“十分必要”的事情了,原因很简单——你只能大概知道别人会怎么重用你的工作,大概知道他们要什么样的内容,但你不能替他们做完一切。

那么,Java为我们提供了什么样的抽象能力呢?

●Class:提供了对现实世界的抽象。

●Interface:提供了对Class行为的抽象。

●Annotation:对类型元数据的抽象。

●Generics:给上述因素进一步、进两步直至进N步抽象的机会。

2.应用于模式

实现模式的过程中应用泛型有以下几个潜在的优势:

●GOF中模式有23个。

●每个模式之所以被称为“模式”,是因为它们是相对“固定的解”。

●使用这些模式的时候很多“套路”非常类似。

●项目中,随着模式的实现方式和使用方法逐步固定,可以用泛型对算法的数据结构进行抽象,进而提高算法的适用性。

工厂类型可以加工出符合抽象类型要求的Interface、Abstract Class,甚至在某个语境层面的父类,但工厂类型自身的写法非常一致,如果为接口1写一个、接口2写一个……接口100也写一个,相信开发者自己内心应该是非常“纠结”的。对着100个形似而且神似的工厂类型做修改的时候也一样,再次经历“纠结”。

而事实上,类似的工作可以用一个工厂类完成。在不使用配置系统的情况下,先考虑用类型名称对具有无参构造函数的类提供一个相对通用的工厂类型,这里的类型名称采用的是可以被java.lang.Class<T>类识别的名称,如下面3个类型名称定义:

com.marvellousworks.practicalpattern.test.ClassActivatorFixture$C
java.lang.String
org.junit.Assert

工厂类型的示例代码如下:

Java
/**
 * 原始的泛型工厂类型
* @param <T> 构造结果的目标类型,一般T是抽象类型或接口
 */
public class RawGenericFactory<T> {

/** * 构造 * @param className 类型名称 * @return 构造实例结果 */ @SuppressWarnings("unchecked") public T newInstance(String className) throws InstantiationException, IllegalAccessException, ClassNotFoundException{ if((className == null) || (className.isEmpty())) throw new IllegalArgumentException("className"); return (T)(Class.forName(className).newInstance()); } }
Unit Test
public class RawGenericFactoryFixture {
    @Test
    public void testCreateSimpleType()
        throws
            InstantiationException,
            IllegalAccessException,
            ClassNotFoundException{
        RawGenericFactory<String> factory = new RawGenericFactory<>();
        assertTrue(factory.newInstance("java.lang.String") instanceof
String);
    }

@Test public void testReturnAbstractType() throws InstantiationException, IllegalAccessException, ClassNotFoundException{ @SuppressWarnings("rawtypes") RawGenericFactory<Map> factory = new RawGenericFactory<>(); assertTrue(factory.newInstance("java.util.HashMap") instanceof Map); assertTrue(factory.newInstance("java.util.Properties") instanceof Map); } }

上面的RawGenericFactory<T>虽然功能还不够完备,但已经可以部分解决项目重复定义工厂类型的需要,如本例中就省去了定义StringFactory、MapFactory。

示例中为了简化,className采取在单元测试中硬编码的方式,而在实际工程中类型名称一般都是根据调用上下文传递的,往往工厂类型也会借助配置文件获得相应的类型名称。

3.容器

泛型的另一个主要用途是建立各种强类型的容器,也就是集合类型,类型参数用于定义容器中具体元素的类型。模式中涉及管理“一组”对象的不在少数,仅《设计模式》一书中的示例就包括以下几种:

●Builder的Director。

●Composite。

●Command的Invoker。

●Memento的Care Keeper。

●State和Strategy的Context。

除此之外,项目中还要考虑容器的访问方式,即需要Map<K,V>、List<E>、Stack<E>和Queue<E>之类的类型。如果不使用泛型,那么下游的应用就纯属于“碰运气”访问,它们只能尝试把非强类型的内容转化为所需的类型,这不仅要承受由于装箱、拆箱带来性能损失,而且还不利于在开发和编译阶段获得Java IDE的支持。

例如,同样是操作两个集合,非泛型和泛型在Eclipse中的支持就不同了,如图2-2、图 2-3和图2-4所示,泛型集合不仅在编码时可以提示明确的数据类型,而且会提供随时随地的类型检查,这些提示对于提高开发效率很有帮助。

图2-2 Eclipse中对于非泛型集合的支持

图2-3 Eclipse中对于泛型集合的支持

图2-4 Eclipse中操作泛型集合时的类型检查

泛型对于公共类库的开发者显得更重要,因为它无法确定别人会怎么使用这些公共库,对于非泛型集合的使用者,到底外部传入的集合中每个元素是什么类型并不清楚,因为非泛型的集合类型一般都把内部元素装箱为java.lang.Object,尽管可以假设这些元素是实现了某个接口实体类型实例,但这需要自己验证。试想,如果一个企业的公共代码库处处都基于这样的不确定性,用它做的产品就太不“靠谱”了。相对一般的泛型类型而言,泛型对集合类型的意义更重要,因为:

接口和参数更明确,而且不仅仅停留在UML的图纸上,在编码和调用过程中都起到刚性的检查作用。

4.类型参数约束

Java中泛型的另一个重要用途是它的类型参数约束机制。类型参数的约束作用除了体现在Java SE Tutorial介绍的控制实例化过程外,还有如下注意事项。

●当类成员使用相同类型参数时,该类型参数的约束也同样适用于相关成员。示例代码如下:

Java
public interface ObjectWithName {
    String getName();
}

public class MapLoader <K,V extends ObjectWithName>{
private Map<K,V> map; public MapLoader <K,V> add(K key,V value){ map.put(key,value); trace("[%s : %s]",key.toString(),value.getName()); return this; } }

其中,各处的类型参数“V”均需要满足“V extends ObjectWithName”的约束要求,因此编译器编译时认为“V”类型的value参数具有getName()方法是顺理成章的,可以编译通过。

●参数约束不适用于标注,如图2-5所示。

图2-5 不能定义泛型形式的标注

本书中,类型参数约束的使用会更加频繁,因为作为“玩具代码”没关系,无参数的构造函数、无参数的方法还有void返回值的方法就可以,“玩具”能体现出大体意思就行;但是作为生产代码,没有类型约束除非处理逻辑很简单——就是把元素保存起来,否则只能靠“碰运气”的方式尝试操作传入的变量,但这个变量是否真的能够符合约束要求,具有对应的方法——不得而知。所以,如果不只是为了练手,而是真的要把设计模式应用于具体工程,建议做好以下工作:

●设计每一个类型的时候,要根据客户程序的需要,反复斟酌类型参数约束。l 对于集合类型,尽可能不要在生产代码中出现无约束的类型参数。

不仅如此,Java SE 5\6\7为了兼容遗留的Java环境,在设计泛型特性的时候对类型信息进行了擦除,也就是说,开发人员只能通过一些非官方的方法在运行时获得类型参数信息。

关于这一点作者持保留意见,而且感觉很遗憾。因为这一点对于开发人员很不友好,尤其对于开发基础框架的开发人员更是如此。毕竟,既然要提供泛型特性,是否需要运行时获取类型信息,这个选择权要交给用户,而不是编译器设计者一相情愿决定的。Oracle接收Java后,希望它能有足够的人力和资源对Java语言编译器做些整理,在新版本中把泛型真正做到语言里面;另外,能把那些长期标注为obsolete和deprecated的内容清理一下。

除此之外,如果把设计模式应用到组织级的公共开发库,可能还有如“T extends A | B”或“T extends A & B”之类的需求。也就是说,希望某个特定算法仅仅被满足某几个特定抽象类型的类型使用,这样可以使类型参数的约束控制更加精细,其中:

●“T extends A | B”表示该算法仅适用于实现了接口A或接口B的类型。

●“T extends A & B”表示该算法仅适用于同时实现了接口A和接口B的类型。

例如,我们假设在开发一个交通项目的时候,有这么一条规定“老人和儿童免票”,如果Java能支持“T extends A | B”,那么我们就无须在代码逻辑中反复判断某个人是否是老人或是儿童,只要用类型参数约束即可,后续处理中也无须反复判断当前乘客是否是“老人 | 儿童”,因为类型参数已经做出保证。代码如下:

Java
interface OldMan{}
interface Child{}

/** * 希望能获得的,但现阶段Java还不支持类型参数|操作 */ /*class GenericsTypeParameterBitOperator <T extends OldMan | Child>{ }*/
/** * 目前Java支持类型参数的&操作 */ class GenericsTypeParameterBitOperator <T extends OldMan & Child>{
}

但很可惜,类型参数间的“或”关系目前只是开发人员一个美好的愿望。不过我们可以采用变通的办法,通过引入新对象达到这些目的。示例代码如下:

Java
/**
 * 之前OldMan和Child是两个独立的类型,为了找到两者的共同点
 * 额外定义了一个Person接口,从外围强制建立了一个新的“或”关系
 *
 * 但这样并不严谨,因为等于任何继承自Person接口的类型均适用了
 */
class GenericsTypeParameterBitOperator <T extends Person>{
}

5.小结

如果代码将被反复重用,只要进度允许,建议尽量使用泛型,因为:

●省去开发人员猜来猜去,提高开发效率。

远期也许也能提高运行效率,但这取决于Java编译器和JVM的设计者是否想为我们这么做了。

●抽象给代码带来更多的适应性。

●减少接口和参数的歧义。

●锻炼开发者的抽象能力。

2.2.4 用贴标签的方式扩展对象特性——标注

从使用者的角度看,恐怕没有比标注更方便的了,基于标注的编码完全站在实际逻辑外面,如果说经典装饰模式通过套接在不生成子类的情况下为类添加职责,那么标注则能通过一个更简洁的方法为类“装饰”出新的职责。

以Java EE的EJB 3.1分布式调用为例,为了简化代码逻辑,确保用户关注于业务处理逻辑而不是通信过程细节,EJB 3.1很多官方示例基本都采用基于标注的方式实现,下面以OTN上Java EE Reference的一段代码为例进行分析:(官方网址http://www.oracle.com/technetwork/java/javaee/documentation/code-139018.html)

EJB 3.1 WAR-based Application

Java 用标注标识的无状态Bean
@Stateless
public class HelloBean {

public String sayHello() { String message = propertiesBean.getProperty("hello.message"); return message; } }
Java 用标注标识的示例构造形式及方法执行时机
@Singleton
@Startup
public class PropertiesBean {


@PostConstruct private void startup() {…}
@PreDestroy private void shutdown() {…}
… }

其中,有关通信部分的非功能性需求(组件生命周期类型、方法执行时机……)都定义在标注中,由EJB框架负责解读并执行,而客户程序只负责编写实际的业务逻辑。试想如果不采用标注方式,那么就需要额外定义一系列接口,描述服务接口及其中的服务方法需要实现哪些内容,最终实现的类型更“麻烦”,它除了要实现接口基本业务处理功能外,还要逐个实现这些非功能性要求的各个接口。总而言之,代码很“累赘”,怎么看都不如用标注直接“打标签”省事。

后续,我们在架构模式分册中也将大量使用标注,用标注指导分布式调用内容,这样做对于下游开发人员而言更方便。

1.用标注指导模式

这里以创建者(Builder)模式为例,看一下基于标注的开发有什么妙处。首先,我们按照经典的做法定义抽象的创建者,代码如下:

Java
/**Builder 抽象行为定义*/
public interface Builder {

/*** 具体的装配步骤*/ void buildPartA(); void buildPartB(); void buildPartC();
/** * 获得装配结果 * @return 装配结果 */ Object getResult(); }

此后,用一个名为Director的类指导Builder组装的各个步骤,这些指导性的信息通过 Direct 标注类来表示,而Director在buildUp的过程中,通过反射获得相关Builder的Direct标注信息,进而指导Builder的创建过程,代码如下:

Java 标注类型
/** 定义上声明Direct标注可以在运行时获得类型信息*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Direct{
    /**
     * 需要执行的方法名称
     * 因为Builder执行过程一般涉及多个装配步骤,因此将每个步骤设计为数组
     */
    String[] methodNames();
}
Java
public class Director {

/** * 根据Builder类型的标注信息,动态指导Builder装配类型 * @param builder * @throws InvocationTargetException * @throws IllegalAccessException * @throws IllegalArgumentException */ public void buildUp(Builder builder) throws IllegalArgumentException, IllegalAccessException, InvocationTargetException{
if(!builder.getClass().isAnnotationPresent(Direct.class)) return;
// 根据具体Builder类型上标注的定义通过反射获得待执行的方法 String[] methodNames = builder.getClass(). getAnnotation(Direct.class).methodNames(); List<Method> targetMethods = ReflectionHelper.getMethodByNames( builder.getClass(),Arrays.asList(methodNames));
if(targetMethods != null) for(Method method : targetMethods) method.invoke(builder); } }

接着,用做好的Direct标注来定义Builder的装配过程。示例代码如下:

Java 具体标注类型
/** 具体Builder类型*/
@Direct(methodNames = { "buildPartA","buildPartB","buildPartC" })
public class BuilderA implements Builder{

private List<String> log = new ArrayList<String>();
/**根据Direct标注,会被执行的方法*/ @Override public void buildPartA() { log.add("A"); }
/**根据Direct标注,会被执行的方法*/ @Override public void buildPartB() { log.add("B"); }
/**根据Direct标注,会被执行的方法*/ @Override public void buildPartC() { log.add("C"); }
@Override public Object getResult() { return log; } }
/** 具体Builder类型*/ @Direct(methodNames = { "buildPartC","buildPartB" }) public class BuilderB implements Builder{
private List<String> log = new ArrayList<String>();
/**根据Direct标注,不会被执行的方法*/ @Override public void buildPartA() { log.add("A"); }
/**根据Direct标注,会被执行的方法*/ @Override public void buildPartB() { log.add("B"); }
/**根据Direct标注,会被执行的方法*/ @Override public void buildPartC() { log.add("C"); }
@Override public Object getResult() { return log; } }
Unit Test
public class AnnotatedBuilderFixture {
    Director director;
    Builder builderA;
    Builder builderB;

@Before public void setUp(){ director = new Director(); builderA = new BuilderA(); builderB = new BuilderB(); }
@Test public void testBuilderABuildUp() throws IllegalArgumentException, IllegalAccessException, InvocationTargetException{ director.buildUp(builderA); Object target = builderA.getResult(); assertNotNull(target); assertTrue(target instanceof List);
// 验证构造过程是否与Direct定义一致 @SuppressWarnings("unchecked") List<String> result = (List<String>)target; assertEquals(3,result.size()); assertEquals("A",result.get(0)); assertEquals("B",result.get(1)); assertEquals("C",result.get(2)); }
@Test public void testBuilderBBuildUp() throws IllegalArgumentException, IllegalAccessException, InvocationTargetException{ director.buildUp(builderB); Object target = builderB.getResult(); assertNotNull(target); assertTrue(target instanceof List);
// 验证构造过程是否与Direct定义一致 @SuppressWarnings("unchecked") List<String> result = (List<String>)target; assertEquals(2,result.size()); assertEquals("C",result.get(0)); assertEquals("B",result.get(1)); } }

单元测试验证了基于标注开发的不同之处,尽管定义这样一个标注需要一些额外的工作量,但对于使用标注的开发人员而言就很方便了。

实际工程中,标注常常会和反射、配置一同使用。对于其他行为型和结构型模式,标注同样可以从很多方面对类型进行扩展,减少客户程序使用的复杂程度。例如:

●把状态模式中的状态类型声明为标注。

●把各类代理类型需要的控制特征通过标注体现出来。

2.3 面向插件架构的配置系统设计

工程化代码和“玩具代码”另一个最大的区别就是有关配置的处理,“玩具代码”可以随便列举一些参数,简单验证一下即可。但是在工程化代码中除非软件应用领域非常狭小,而且极少需要重新编译并部署,用户也仅仅是有限的一个群体,可以全部硬编码;如果不是,可能要考虑给代码预留一个部署后可修改的“活扣”——通过配置使软件更具适应性。

现在,大家似乎形成一种共识,即Java世界充斥着过多的.xml配置文件,问题的关键也许和配置文件过多、过散有关系,但对于大型的企业级Java系统,我们应该在设计上预留出一个工具或者是某个管理类,通过它可以从“一点”(而不是分头)管理整个系统各类配置。

配置虽然复杂、分散,但我们应该清楚自己的系统架构,自己开发了什么、集成了什么、集成的产品又集成了什么,依据这个关系管理整个系统的配置是可能而且必要的。如果我们去翻看飞机、汽车的装配手册,也许会感觉轻松很多,毕竟大部分软件系统的配置文件现阶段还没复杂到需要几十个专门人员维护的程度。

所以软件配置化是我们这个产业发展和进步的体现,但使用效果如何关键在于能否把控它。

一般而言,配置可以有很多种表现形式:

●.properties文件或自己写的.xml。

●存在数据库中的参数本身也可以被视为配置。

●.ini文件和.inf文件。

●注册表。

●互联网和SOA时代,配置更是无所不在。

系统中使用的方式也很多:

●彻底“放任自流”,应用实体自己封装相关的访问措施。

●通过通用的配置访问机制,隔离配置的实际物理存储,确保每个应用实体封装的时候仅关心逻辑存储上的各种配置信息。

●彻底让应用逻辑不知道配置信息的存在,“哪里有配置?满眼只有对象。”这方面Apache的很多开源项目做了不错的表率,不过就是实现方式对于一些中小型软件稍微“重”了些。

那么,什么内容应该定义在配置文件呢?

一般而言,主要是针对应用中存在不确定性变化的部分,而且由于配置文件可以在部署后继续修改,根据IT运维职责的不同,有时生产系统只能由系统管理人员(IT Pro.)完成,规模相对较小的机构可以由开发人员自己完成,因此,配置文件定义的信息主要针对那些需要在部署以后可以动态调整的内容。

就好像我们自己装配电脑一样,虽然主机是厂商生产的,但可以后续升级显卡、扩充硬盘,而这些新增的部件很多时候不是原主机厂商的产品。定义在配置文件的内容也一样,开发团队定义了外部程序接入的规范——接口,至于实际接入的内容就不一定是框架开发人员自己完成的,很有可能是其他机构或项目后续开发周期的成果。

本书配置文件主要用来登记需要动态加载的类型。同时为了充分利用Java平台的现有机制,采用JDK提供的XML操作类解析.xml配置文件。布局上,采用如下方式:

●每个模式(设计模式/架构模式)自己独立定义出一个配置节(Configuration Section,XML配置文件中一个相对独立的部分),配置节名称和相关的包结构尽量保持同步。例如:工厂方法模式的配置定义在一个名为<factorymethod>的配置节内,装饰模式的配置定义在一个名为<decorator>的配置节内。

●配置节定义一个OCM(对象/配置映射,Object-Configuration Mapping)的基类ConfigSection,而各模式具体的OCM类均在继承ConfigSection的基础上进一步扩展,其类名与各个模式名称保持一致,如工厂方法模式的OCM类型名为FactoryMethodConfigSection,装饰模式的OCM类型名为DecoratorConfigSection。

●整体上所有配置节又列入一个名为“<practicalpatterns>”的配置节组(Configuration Section Group,一组配置节的逻辑组织单元),它表示本系列各册配置内容的根节点。

●为了便于客户程序按照模式名称获得各个模式的OCM类型,在“<practicalpatterns>”配置节组下增设一个名为“<configSections>”的配置节,专门定义每个模式所使用的OCM类型。

XML配置文件
<?xml version="1.0" encoding="UTF-8"?>
<!--整体的配置节组-->
<practicalpatterns>

<!--定义解析各设计模式配置节的OCM类型--> <configSections> <add name="factorymethod" type="…"/> <add name="decorator" type="…"/> </configSections>
<!--工厂方法模式一章使用的配置节--> <factorymethod>
<!—工厂方法一章使用的配置元素集合--> <mappings> <!--登记抽象类型和具体类型映射关系的配置元素--> <add name="…" abstracttype="…" impltype="…"/> </mappings>
<!--单独使用的配置元素--> <runtime version=“7.0”/> </factorymethod> <!--装饰模式一章使用的配置节--> <decorator> </decorator> </practicalpatterns>

实际设计中,建议按照以下步骤设计并解析自己的配置文件,完整的示例可以参考本书第27章“GOF部分阶段实践”。

(1)首先完成配置文件的“骨架”,即先划分出不同的配置节。

(2)细化每个配置节需要填充内容。

(3)设计每个配置元素(Configuration Element,即保存最细颗粒度配置信息的XML Element)需要包括的属性。

(4)从最下层开始逐个装配每个具有组合关系的复杂配置元素类。

(5)根据需要,设计配置元素集合类(Configuration Element Collection)。这相对要简单些,毕竟它的基本作用是一个容器,负责保存一组配置元素。

(6)按照最初的“骨架”,实现配置节,并把相关的配置元素或配置元素集合组装起来。

(7)为了便于客户程序使用,用专门的配置类型封装对于配置文件的访问过程,实现类似ORM概念的所谓的OCM过程。

(8)在单元测试项目中,生成一个相应的测试配置文件,并用测试信息填充,通过Unit Test确认OCM设计可以正常运行。

2.4 依赖注入

2.4.1 背景介绍

开发过程中,如果发现客户程序依赖某个(或某类)对象时,我们常常会对它们进行一次抽象,形成抽象类、接口,这样客户程序就可以摆脱所依赖的具体类型。

但是,这其中有个环节被忽略了——到底“谁”来选择客户程序所需要的实现类?通过后面的介绍会发现很多时候创建型模式可以比较优雅地解决这个问题。但另一问题出现了,如果设计的不是具体业务逻辑,而是公共库或框架程序,这时很多半成品的外部类型实例会在你的管辖下执行,怎样把这些外部类型所需的抽象类型传给它们就成了一个新的问题。

这个情形也就是常说的“控制反转”(Inverse of Control,IoC);框架程序与抽象类型的调用关系就像常说的好莱坞规则:Don’t call me,I’ll call you.

参考Martin Fowler:Inversion of Control Containers and the Dependency Injection pattern一文的介绍,我们可以采用“依赖注入”的方式将加工好的抽象类型实例“注入”到客户程序中,本书的示例也将大量采用这种方式将各种依赖项“注入”到模式对象和客户程序中。下面结合一个具体的示例看看为什么需要依赖注入,以及Martin Fowler在文中提到的3种经典方式。

2.4.2 示例情景

例如,客户程序需要获得当前的年份,最初它采用系统自带的java.util.Calendar类型完成,实现代码如下:

Java
public class CalendarYearProvider {
    public int getYear(){
        return Calendar.getInstance().get(Calendar.YEAR);
    }
}

@Test public void getYearFromCalendarYearProvider(){ int year = (new CalendarYearProvider()).getYear(); }

后来因为某种原因,发现使用JRE自带的日期类型精度不够,需要提供其他来源的YearProvider,确保在不同精度要求的功能模块中使用不同的YearProvider。这样,问题集中在YearProvider的变化会影响客户程序,但其实客户程序仅需要抽象地使用其获取当前时间的方法。为此,增加一个抽象接口YearProvider,其改造后的示例如下:

Java
public interface YearProvider {
    int getYear();
}

public class CalendarYearProvider implements YearProvider{ public int getYear(){ return Calendar.getInstance().get(Calendar.YEAR); } } /**客户程序*/ @Test public void getYearFromProvider(){ YearProvider provider = new CalendarYearProvider(); int year = provider.getYear(); }

这样,看上去客户程序后续处理全都依赖于抽象的YearProvider即可,那么问题是否解决了?没有,因为客户程序还要知道CalendarYearProvider的存在,并没有真正只依赖于抽象而不是依赖于具体实现。因此,需要增加一个对象,由它选择某种方式把YearProvider实例传递给客户程序,这个对象被称为Assembler,如图2-6所示。

图2-6 增加装配对象后新的依赖关系

这里Assembler的作用很关键,因为它解决了客户程序(或客户类型,也就是待注入类型)与待注入实体类型间的依赖关系。从此Client只需要依赖YearProvider和Assembler即可,它并不知道YearProviderImpl的存在。这里Assembler的职责如下:

●知道每个具体YearProviderImpl的类型。

●可根据客户程序的需要,将抽象YearProvider反馈给客户程序。

●本身还可能负责实例化YearProviderImpl。

下面是一个Assembler的示例实现:

Java
public class Assembler {

//保存“抽象类型/具体类型”的对应关系 private static Map<Class<?>,Class<?>> registry;
/** * 静态构造函数中配置“抽象类型/实体类型”对应关系 * 实际项目中,对应关系一般定义在外部配置文件中 * 另外可以开放配置接口,允许在运行时动态配置对应关系 */ static{ … }
/** * 根据“抽象类型/实体类型”对应关系构造待注入的类型实例 */ public Object newInstance(Class<?> abstractType) … return registry.get(abstractType).newInstance(); } }

2.4.3 构造注入(Constructor)

构造注入方式有时也被称为“构造子注入”、“构造函数注入”,顾名思义这种注入方式就是在构造函数的执行过程中,通过Assembler或其他机制把抽象类型作为参数传递给客户类型。这种方式虽然相对其他方式有些粗糙,而且仅在构造过程中通过“一锤子买卖”的方式设置好,但很多时候我们设计上正好就需要这种“一次性”的注入方式。

其实现方式如下:

Unit Test
class ClientA{
    private YearProvider provider;

/**所需的抽象类型实例作为构造函数参数传入*/ public ClientA(YearProvider provider){ this.provider = provider; }
public YearProvider getProvider(){ return this.provider; } }
/**确认注入效果*/ @Test public void testConstructorInjection() throws ClassNotFoundException, InstantiationException, IllegalAccessException{ assertTrue( (new ClientA((YearProvider)( assembler.newInstance(YearProvider.class)))) .getProvider() instanceof CalendarYearProvider); }

2.4.4 设值注入(Setter)

设值注入是通过属性方法赋值的办法实现的,由于Java等很多语言中没有真正的属性方法,所以它们的设值注入一般通过名为setXXX()方法实现,而对于C#语言由于本身就有属性方法(Property)机制,所以实现起来更简洁,更像Setter。

相比较构造注入方式而言,设值注入给了客户类型后续修改的机会,比较适用于客户类型实例存活时间较长的情景。

其实现方式如下:

Unit Test

class ClientB{

private YearProvider provider;
/**Setter方式注入所需的抽象类型实例*/ public void setProvider(YearProvider provider){ this.provider = provider; }
public YearProvider getProvider(){ return this.provider; } }
/**确认注入效果*/ @Test public void testSetterInjection() throws ClassNotFoundException, InstantiationException, IllegalAccessException{ ClientB client = new ClientB(); client.setProvider((YearProvider)(assembler.newInstance(YearProvider.c lass))); assertTrue(client.getProvider() instanceof CalendarYearProvider); }

从语言发展看,设值注入方式更“Lamada化”,而且使用时可以根据现场环境需要动态装配,因此在新项目中笔者更倾向于使用设值注入。

根据目前Oracle公司的声明,官方版本的Java 8中才会增加闭包(Closures)特性,并提供对于Lamada语法的支持,而Java和C#在对Lamada支持方面的进一步说明可以从Wiki查询:http://en.wikipedia.org/wiki/Closure_%28computer_science%29

2.4.5 接口注入

接口注入是将抽象类型的入口以方法定义在一个接口中,如果客户类型需要获得这个方法,就需要以实现这个接口的方式完成注入。实际上接口注入有很强的侵入性,除了要求客户类型增加前面两种方式所需实现的代码外,还必须显式地定义一个新的接口并要求客户类型实现它。作者并不建议在Java 5.0版本以前的项目中采用接口注入的方式。

下面看一个示例:

Unit Test
/**待注入类型直接实现目标接口,它就是(is)目标接口*/
class ClientC implements YearProvider{

private YearProvider provider;
public void setProvider(YearProvider provider){ this.provider = provider; }
@Override public int getYear() { return provider.getYear(); } }
/**确认注入效果*/ @Test public void testInterfaceInjection() throws ClassNotFoundException, InstantiationException, IllegalAccessException{ assertTrue(new ClientC() instanceof YearProvider); }

不过随着编程语言的发展,接口注入可以采用与设值注入方式相似的实现,为不用真正去实现接口,而是通过泛型参数的方式实现,所以,泛型为实现接口注入提供了“新生”。

Java
class ClientD<T extends YearProvider>{
…
}

尽管Java也提供了泛型语法,但在编译时会进行类型擦除,因此,在Java 5/6/7中尚无法通过T.class直接获得实际类型

2.4.6 小结

依赖注入虽然被Martin Fowler称为一个模式,但平时使用中,它更多的是作为一项实现技巧出现,开发中经常需要借助这项技巧把各个设计模式所加工的成果传递给客户程序。各种实现方式虽然最终目标一致,但在使用特性上有很多区别。

●构造方式:它的注入是一次性的,当客户类型构造的时候就确定了。它很适合那种生命期不长、在其存续期间不需要重新适配的对象。

●设值方式:一个很灵活的实现方式,对于生命期较长的客户对象而言,可以在运行过程中随时注入。

●接口方式:作为注入方式具有侵入性,很大程度上它适于需要同时约束一批客户类型的情况。不过由于Java语言的发展,我们可以考虑通过泛型类型参数的变通方式,减少Martin Fowler介绍的接口注入对于客户类型的侵入性。

2.4.7 自我检验

目标类型定义如下,它依赖于First和Second两个接口,已知实现了First接口的类型有First1、First2和First3,实现了Second接口的类型有Second1和Second2。请使用设值注入方式完成注入,同时对注入的结果进行验证。

XML配置文件
<?xml version="1.0" encoding="UTF-8"?>
<!--整体的配置节组-->
<practicalpatterns>

<!--定义解析各设计模式配置节的OCM类型--> <configSections> <add name="factorymethod" type="…"/> <add name="decorator" type="…"/> </configSections>
<!--工厂方法模式一章使用的配置节--> <factorymethod>
<!--工厂方法一章使用的配置元素集合--> <mappings> <!--登记抽象类型和具体类型映射关系的配置元素--> <add name="…" abstracttype="…" impltype="…"/> </mappings>
<!--单独使用的配置元素--> <runtime version=”7.0”/> </factorymethod>
<!--装饰模式一章使用的配置节--> <decorator> </decorator>
</practicalpatterns>

2.5 连贯接口(Fluent Interface)

Fluent Interface又称连续接口,虽然Fluent很少被直接翻译成“连贯”,但根据Fluent Interface的用意,它不仅有简化编码的作用,同时也是保持对象间贯通一致的方式,加之国内对它还没有很权威的命名,所以本书采用“连贯接口”的称呼。

连贯接口有什么特点呢?下面先看一个示例:

Java 增加具有Fluent特性的接口方法
public class Currency {
    private String code;
    private String name;
    …
}

public interface CurrencyFacade {
/**获得所有已经登记的货币信息*/ List<Currency> all();
/** * 追加新的货币记录 * 连贯接口方法 * @param code 货币编码 * @param name 货币名称 * @return 返回结果为接口类型自身 */ CurrencyFacade add(String code,String name); }
Java
/**连贯接口的实现类*/
public class ListCurrencyFacade implements CurrencyFacade {

List<Currency> registry = new ArrayList<>();
@Override public List<Currency> all() {return registry;}
@Override public CurrencyFacade add(String code,String name) { registry.add(new Currency(code,name)); return this; } }
Unit Test
public class CurrencyFacadeFixture {

CurrencyFacade facade;
@Before public void setUp(){facade = new ListCurrencyFacade();}
@Test public void testCurrencyFacadeFluentInterface(){ int currentRows = facade.all().size(); facade .add("CNY","元") .add("USD","美元") .add("JPY","日元") .add("EUR","欧元") .add("FRF","法郎") .add("GBP","英镑");
assertEquals(currentRows + 6,facade.all().size()); } }

采用连贯接口的方式,一系列调用可以在一行代码内完成(示例中为了演示清晰,将一行代码分行展开了),省去定义中间变量,通过这种方式为使用者提供了很大的便利。在本节的“自我检验”部分将介绍一个更复杂、更全面的连贯接口示例。

下面总结一下实现连贯接口方法的要点:

●相关方法(及属性方法)全部设计为只有输入参数或者无参数。

●每个连贯方法的返回值都是一个类型实例,但不一定是其自身。

尽管在JDK中连贯接口方式定义的函数并不多,但这种代码行文方式充斥在很多第三方开源类库,本书后续章节也将更多地使用该方法设计接口。

2.6 自我检验

由你负责开发一个生成网页表格的程序,表格的格式定义如下:

<table>
    <name>表格名称</name>
    <head>
        <col>名称</col>
        <col>名称</col>
        …
    </head>
    <body>
        <line>
            <n>内容</n>…
        </line>
        …
    </body>
</table>

要求:

(1)<head>和<body>部分是可选项。

(2)<head>中<col>部分是可选项、<body>中<line>部分是可选项、<line>中<n>部分是可选项。

(3)允许表格中某些<col>名称和<n>的内容为空。

(4)表格自上向下逐行生成。

(5)最终生成的表格必须是行列平衡的,空白部分用空字符填满。

请参考本章介绍的连贯接口设计表格生成类,并通过单元测试验证。