推荐图书: 疯狂Java讲义精粹(附光盘)
你可曾听过:“我真的很喜欢你的产品,除了一些小的细节点”?然后CIO(首席信息官)推出一个必须有的需求清单,这清单中的数百个需求点需要添加到你的了不起的产品中。你可曾听过或者甚至说过:“同志们,我们将签署一个高利润的合同,但是。。。”?然后客户期望的额外的功能变成了开发工程师的头痛点。
所以,尽管现在你的产品能满足顾客的需求,但怎样才能让你的产品远离顾客潜在的有风险的想法?怎样才能在已有无数加载项的情况下,维系一个产品技术设计的最高水平,来让其在特定的方式下起作用?这些为成熟的解决方案提供可靠且突出支持的基础性需求,将面临多少挑战?
在商业世界,产品定制是一种越来越可取的要求和一些共通的做法,这些做法已经演变为响应这一客户需求。下面你将找到一个大致的方法,如果你已经对它们比较了解了,你可以直接拖到到“方法五:扩展法”章节,来了解我们怎样以一种我们自认为有效的方式克服这些挑战。
方法一:在一个产品中包含所有定制功能(All in One)
最直接最容易想到的定制化的办法就是在一个产品核心中实现所有的需求,然后通过“ 属性开关 ”的技术来匹配不同客户的需求。
这种方法的主要优点是始终保持一个单一产品,对于不需要太多扩展就能覆盖大多数业务需求的场景是比较管用的。
但这种方法其实隐藏了一个假设,即“没有太多了定制需求”。通常,产品的开发都以这个信念开发,但在几伦迭代交付之后,你会看到客户真正的定制需求量。陷入一个左右为难的困境并不罕见:拒绝定制或者潜在地丢失客户,或者把代码变成了一个“垃圾桶”,因为为一个客户定制太多的个性化需求对其他主要客户都是无用的。
你会选择哪种?很显然,从一个岩石和一个坚硬的东西中选择一个都不会成功。
总结:在一个产品中包含所有定制功能,只在定制需求很少或者有限时才算是合理的选择。否则,你将在可维护性和客户满意度之间做出选择。在此引用Jerry Garcia的话:“从两个不太坏的恶魔中选择一个,不还是恶魔吗”。
方法二:分支开发(Branching)
如果重要的定制是交付中必须要有的部分,那么 All in One 的技术方案就行不通了。有另外的更直接了当的方法—— 分支开发 。你可以很容易的创建一个独立的产品分支,随意修改。
与All in One相比,分支开发最大的优势是没有定制范围的限制,通过分支的方式来分隔不同客户的特定需求,避免了在同一套代码中混杂所有的功能。
但是,这个优势的另一面可能让产品演化走向死路。显而易见的,产品分支是主要的开发领域:大部分的bugfix、改进、新功能首先添加到产品中,那么,要保持定制的分支和主产品保持同步,就需要频繁的合并代码,合并代码时要是和主干产品没有冲突,那合并代码是很容易的操作;要是有冲突,合并操作将非常耗时,且不可避免的需要回归测试。
如果定制分支很少时,这种分支开发的方式是可行的,但是当交付的产品数量增加以后,面临痛苦合并的可能性将迫在眉睫。
总结:分支开发无疑是非常灵活和直接的方法——产品的任意部分都可以修改。但是在交付的后期将很费力,随着时间的推移,将变得更困难,也不太可能有太多的定制分支。
方法三: 实体-属性-值模型 (EAV)
实体-属性-值 模型(Entity-Attribute-Value model又名:Object-Attribute-Value model,垂直数据库模型和开放schema)是一种熟知的广泛使用的数据模型。EAV可以支持动态的实体属性,它适合用于平行的标准关系模型。
从产品角度来看EAV的优势是:你可以交付你的产品“as is”,然后通过在运行时添加需要的属性来调整数据模型,而不需要修改源码。
还是一样的有点不足:
- 适用性有限:该模型仅适用于这样的场景,基于已经编写好的逻辑,使用添加属性的方式,使其自动的嵌入到界面中
- 额外的DB服务器负担:垂直数据库的设计通常会变成企业级应用的瓶颈,因为它通常操作大量的与应用相关的实体和属性。
- 最后,企业级系统不可能没有复杂的报表引擎。EAV模型将因为它的“垂直”数据库结构带来许多潜在的并发症。
总结: EAV 模型在某些场景下很有有用,比如:提供一种附加信息数据即可获得灵活性的需求,这种附加的信息数据并不显示地在业务逻辑中使用。 换句话说,EAV适度是好的,除了标准的关系模型和 插件架构 。
方法四:插件架构(The Plugin Architecture)
插件架构是最常用最强大的一种方式之一:功能逻辑分别放在名为插件的东西里面。要想覆盖现有的盒子外的行为,并运行插件,就必须在产品的源码中定义“定制点”(又名扩展点)。一个“定制点”是某些源码中的片段,应用会扫描所有的插件,来检查是否有插件重写了指定的实现,如果实现了就执行该插件;另一种插件架构方式是外部脚本,功能实现被实现和存储在外部的脚本中,脚本的调用被预定义的“定制点”控制。
使用插件方式可以保持产品代码的纯净,可以不做修改的交付核心产品,而把定制的行为放在插件或者脚本中。另一个优点是可以很好的管理更新的过程。产品和插件的完全分离使他们可以相互独立的升级。
当然,不足之处在于:原则上,你不可能知道客户在未来提出的需求。因此,只可能猜测“定制点”要嵌在哪里。当然,你可以把“定制点”分散在所有可能的地方以缓解“万一需要的场景”,但这将导致代码可读性很差,代码很难调试,增添了复杂性。
总结:插件模式确实在“定制点”可预测的场景下工作,但要注意的是:在“定制点”间的定制是不可能的。
方法五:扩展法(The Extensions Approach)
在我们的企业级软件开发平台 CUBA 中,我们已经实现了一个独特的方法。正如 我们之前的文章 ,CUBA是非常实用的,在一套开发驱动的演化中创造出来的活的有机体。所以,基于我们丰富的已有产品的经验,我们提出了两个终极需求:
- 为客户定制的代码必须和核心产品代码完全分离
- 每个产品的代码部分必须可以被修改
我们设法满足这些需求,甚至从这种扩展机制中获得了更多好处。
CUBA的扩展
一个扩展就是一个独立的CUBA工程,它把底层工程当作依赖库来使用,继承了所有底层工程的特性(比如:你的核心产品),这很显然允许开发者实现新功能,而不影响父工程,但得益于 开放继承 模式和专门的CUBA设施,你也可以重写任何父工程的部分功能。总之,一个扩展就是你可以实现成千上万个文章开头提到的“小细节点”的地方。
事实上,每个CUBA工程是一个CUBA平台自身的扩展,所以它能重写任意的平台功能。我们自己采用这种方式来区分出核心平台之外的功能。所以如果在你的工程中需要他们,你只需要把他们当作父工程添加到你的工程中即可,类似多重继承!
用相同的方法,你可以构建一个可继承的自定义模型。这可能听起来比较复杂,但它很管用。让我给个真实的例子: Sherlock 是 Haulmont’s 的完备的出租车管理方案,支持每个运行出租车业务的各个方面,从预定和分派到应用和结算。这个方案涵盖了需要不同的客户业务,并且这些业务中很多是和本地化相关的。比如:所有英国的出租车公司有相同的法规,但有的法规在美国却行不通,反之亦然。很显然,我们不想在核心产品中实现所有这些法规,因为:
- 这是一个“操作层面的具体的”功能
- 在不同的国家,本地化的法规可能对出租车队管理的影响完全不同
- 有的客户根本不需要法规
所以,我们组织一下多层扩展:
- 核心产品包含通用出租车业务功能
- 第一层定制实现区域特殊化
- 第二层定制覆盖客户的需求清单(如果有的话!)
![](http://img1.tuicool.com/Zr6RRf.png!web)
CUBA的扩展架构图
非常干净。
如你所见,使用扩展的方式,保持了代码的干净和可维护性,你既不需要以分支方式开发你的产品,也不需要迁移所有的需求到你的核心产品中。看起来真的很好,所以让我们看看它在代码实践中如何工作!
给已有的实体添加新的属性
让我们假设我们有一个User实体定义,它由两个属性组成:login和password:
User entity
Java
@Entity(name = "product$User")
@Table(name = "PRODUCT_USER")
public class User extends StandardEntity {
@Column(name = "LOGIN")
protected String login;
@Column(name = "PASSWORD")
protected String password;
//getters and setters
}
现在我们的客户有个额外的需求,要添加一个“home address”属性到User实体中,要做到这个,我们在扩展中继承User实体:
Java
@Entity(name = "ext$User")
@Extends(User.class)
public class ExtUser extends User {
@Column(name = "ADDRESS", length = 100)
private String address;
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
}
你可能已经发现,所有的注解,除了@Extend,都是JPA的注解。@Extend注解是CUBA引擎的一部分,它会用ExtUser全局替换User实体,甚至跨产品功能。
使用@Extend注解,我们必须让平台:
- 总是创建继承结构中叶子类型的实体
Java
User user = metadata.create(User.class); //ExtUser entity will be created
- 在执行前,转换所有JPQL的查询语句,以便我们总是返回叶子类型的集合
Java
select u from product$User u where u.name = :name //returns a list of ExtUsers
- 在关联的实体中总是使用叶子类型
Java
userSession.getUser(); //returns an instance of ExtUser type
换句话说,如果声明了一个继承的实体,父实体就在整个产品和扩展中被遗弃,并且被继承的实体全局替换。
界面定制
我们已经通过添加address属性来继承了User实体,现在我们想要让这个继承效果作用到用户界面中。首先,让我们看看原始界面是什么样子:
Java
<window datasource="userDs" caption="msg://caption" class="com.haulmont.cuba.gui.app.security.user.edit.UserEditor" messagesPack="com.haulmont.cuba.gui.app.security.user.edit" > <dsContext> <datasource id="userDs" class="com.haulmont.cuba.security.entity.User" view="user.edit"> </datasource> </dsContext> <layout> <fieldGroup id="fieldGroup" datasource="userDs"> <column> <field id="login"/> <field id="password"/> </column> </fieldGroup> <iframe id="windowActions" screen="editWindowActions"/> </layout> </window>
可以看到,一个CUBA的界面描述是以XML的方式呈现。显然,我们可以简单的在扩展中重新声明整个界面描述,但是这意味着复制-粘贴大部分的描述代码,它导致如果以后产品界面有点变化,我们就需要手动地把这些变化点复制到扩展的产品界面中。为了避免这个,CUBA引入了界面继承机制,你所需要做的仅是描述不同的界面部分:
Java
<window extends="/com/haulmont/cuba/gui/app/security/user/edit/user-edit.xml"> <layout> <fieldGroup id="fieldGroup"> <column> <field id="address"/> </column> </fieldGroup> </layout> </window>
使用extends属性来定义祖先界面描述,然后只需要描述扩展需求中不同的部分即可。
走你,让我们最后看看界面效果
![](http://img0.tuicool.com/jQJfEnn.png!web)
CUBA扩展后的界面效果
业务逻辑修改
为了修改CUBA平台的业务逻辑,CUBA平台使用了Spring框架作为其平台基础的核心部分。
比如:你有一个中间件来执行价格计算:
Java
@ManagedBean("product_PriceCalculator")
public class PriceCalculator {
public void BigDecimal calculatePrice() {
//price calculation
}
}
要重写价格计算的实现,我们仅需要两个步骤:
第一步:继承产品类,并重写相应的方法:
Java
public class ExtPriceCalculator extends PriceCalcuator {
@Override
public void BigDecimal calculatePrice() {
//modified logic goes here
}
}
第二部:在spring中配置这个产品bean:
Java
<bean id="product_PriceCalculator" class="com.sample.extension.core.ExtPriceCalculator"/>
现在PriceCalculator注入将总是返回继承的类的实例,这样,继承的实现将作用到整个产品中。
在扩展中升级基础产品的版本
随着核心产品的演进和新版本的发布,你终究会决定把你的扩展升级到最新的产品版本。这个过程非常简单:
- 在扩展中指定底层产品的新版本
- 重新构建扩展:
- 如果扩展扩展是构建在稳定的产品API基础上,那么它应该可以跑起来了
- 如果产品API有些重要的修改发生,且这些修改与定制的实现有冲突,那就要在扩展中支持新的API
大多数时候,产品API不会再更新中有太多的变更,尤其是小版本的发布。但是就算API的大改发生了,一个产品通常也会在未来的几个版本中保持向下兼容,并在老的实现中标记“deprecated”,允许所有的扩展迁移到新的API上。
总结
简单的汇总一下,我比较喜欢用对比分析的表格形式来阐述:
/ | All in One | Branching | EAV | Plugins | CUBA Extensions |
Architecture independent | + | + | – | – | – |
Dynamic customization | – | – | + | +/- | – |
Business logic customization | + | + | – | +/- | + |
Data model customization | + | + | + | +/- | + |
UI customization | + | + | +/- | +/- | + |
Code quality and readability | – | +/- | +/- | +/- | + |
Doesn’t affect performance | + | + | – | + | + |
Risk of software regression | High | High | Low | Medium | Medium |
The complexity of long term support | Extremal | Extremal | Low | Medium | Medium |
Scalability | Low | Low | High | High | High |
如你所见,扩展方式是很强大的,但它有一条不足,就是在运行中动态微调的能力不够。为了克服这个问题,CUBA也提供了全面的EAV模型和插件/脚本方式的支持。
翻译自:http://www.javacodegeeks.com/2015/07/how-to-develop-a-highly-customizable-product.html