70 months ago

如何开发高度可定制的产品

本文探讨了几种开发高度可定制项目的方案,包括CUBA平台。

        那么,如何既能满足客户的需求,又能使核心产品远离客户潜在的危险想法? 以特定技术方式设计的产品,如何既支持更多功能插件又能持续保证最高性能? 为解决方案提供可靠和出色的支持,需要面对多大的挑战?

做为开发人员肯定听客户说过:“你们的产品还不错,但是还有些细节需要完善”, 然后收到一份有数百个需求的“待完善细节”清单。 做为项目经理肯定也跟团队说过:"公司即将拿到一个大单,但是需要咱们先。。。。。。" ,结果往往变成开发人员头疼不已地去满足客户的各项愿望清单。

        随着商业世界对定制产品的需求越来越强烈,软件开发行业发展出了许多通用方法来满足客户的定制需求。下面我们会介绍一些典型的方案,如果您对这些方案已经很熟悉,那么欢迎您直接向下滚动到“扩展方案”段落,了解我们如何以更有效的方式解决这些具有挑战性的问题。

"全家桶"方案(All In One)

最直接,最显而易见的解决方案就是在一个核心产品里实现所有的功能,然后为了满足不同客户的需求,再给各个功能加个“开关”。

        这个方案的主要优点就是“大”而“全”,对于某些类型的产品适用,这样的产品通常涵盖该行业所有业务需求所以无需大量定制

        这种方法的先天局限性隐藏在“无需大量定制”的假设中。通常,产品开发始于这一设想,但经过多次交付后,就会意识到客户定制化需求的真实规模。然后就是进退两难的局面:拒绝定制开发则可能失去客户,继续在核心产品上添加定制功能则导致源码成为一个“垃圾箱”,核心产品代码包含了多数用(户根本用不到的功能、臃肿、难以维护。

        这种情况下你会如何选择?显然,不管怎么选都不是成功之道

        总结:只有在你确定产品不会出现很多的个性化需求时,“All in One”方案才是合适的选择。 否则,你只能在产品的可维护性、可控性与客户满意度之间二选一。 对此,我们引用Jerry Garcia说过的一句话做出评价:“Constantly choosing the lesser of two evils is still choosing evil(两害相权取其轻也是害)__”。

        如果确实有重量级的定制需求“必须”交付,又不能使用大而全的方案,另一种直截了当的方式就是使用代码分支 - 创建一个新的代码分支,然后在这个分支上独立修改。

分支方案与All in One比较,最大的优点是可以随心所欲的做定制化开发,不用担心一个分支的代码修改影响其它客户。 使用不同的分支来满足不同客户的特定需求,避免了在同一代码库中混合所有功能。

然而随着产品发展,这种方案将会面对另一种“死法”。 因为大多数bug修复,产品改进,新功能都应用到核心产品分支上,而定制化开发在客户分支上,因此,需要频繁地合并分支以使所有定制分支与核心产品保持同步。 原始产品代码没有受到客户分支的影响的话合并还算是一个简单的操作,否则的话代码合并会变得非常耗时并且无法避免地,回归测试时肯定有bug。

如果只需要少量客户分支,这个方案还是有效的。 然而,随着交付的增加,频繁地代码合并将会成为开发人员的噩梦。

__        总结:分支方案无疑非常灵活和直接 - 产品的任何部分都可以修改。 然而,费力的部分发生在交付之后,并且随着时间的推移会越来越费力,并且客户分支太多的话就更加难以管理。__

EAV:实体 - 属性 - 值模型 ( entity–attribute–value model)

EAV,即实体 - 属性 - 值模型(又名对象 - 属性 - 值(object-attribute-value)模型,垂直数据库模型或开放模式)是众所周知且广泛使用的数据模型。 EAV支持动态实体属性,通常与标准关系模型并行使用。

从产品化的角度来看,使用EAV的主要优点是您可以“按原样”交付产品,然后通过在运行时添加所需的属性来调整数据模型,从而保持源代码的清洁。

但这个方案也有缺陷:

适用范围有限 - EAV模型受限于仅允许向实体添加属性,然后根据预先编写的逻辑将其自动嵌入到UI中。

额外的数据库服务器负载 - 垂直数据库设计经常成为企业应用程序的瓶颈,因为企业应用程序通常有大量实体,实体也可能具有很多属性。

最后,企业级应用往往需要成熟的报表引擎,EAV的“垂直”数据库结构会给报表引擎开发增加大量复杂度。

        总结:实体 - 属性 - 值模型在某些情况下具有很大的价值,例如通过增加额外的_不会在业务逻辑中明确使用的信息数据来实现灵活性。换句话说,EAV是对标准的关系模型和插件架构的一个很好的补充。_

插件架构方案

插件架构是最流行和最强大的方法之一 - 定制功能逻辑被发布为单独的制件,称为插件。这种方案需要在产品源代码中定义“定制点”(也称为扩展点),应用程序在定制点检查是否有插件要覆盖开箱功能。 插件的一个变体是外部脚本:所需功能采用外部脚本实现并在外部存储,通过预定义的“定制点”控制调用外部脚本实现插件功能。

使用插件方案,产品代码保持一直“干净”, 不会被不同的客户化影响。研发团队“按原样”交付核心产品,定制化功能做在插件或者脚本中。另一个优点是软件升级更新更易于管理,产品和插件的完全分离使得它们都可以彼此独立地更新而互不影响。

当然,这个方案也有限制:主要是不可能完全预测客户将来会提出哪些定制要求,只能先猜测应该嵌入“定制点”的位置。也许可以在各处都添加定制点以防万一,但结果就是代码可读性差、难以调试,技术支持复杂度提高。

总结:如果“定制点”易于预测,插件架构方案确实有效,需要注意的就是“定制点”以外无法添加定制功能。

扩展方案

我们的企业级应用开发平台CUBA采取了一种独特的方法。CUBA是一个具有实践性、开发人员驱动演进的平台。
基于我们对现有产品的丰富经验,在定制化开发方面,我们提出了两个终极要求:

  • 客户定制化代码应与核心产品代码完全分离。
  • 产品代码的每个部分都应该支持可修改定制。

最终,通过CUBA的“扩展”机制,我们不但满足了以上需求而且做到了更多。

CUBA扩展

扩展是一个独立的CUBA项目(lib库),它继承了父项目(比如说核心产品)的所有功能。开发人员能够在扩展里开发新的功能而且不会影响到父项目,更厉害的是由于使用了Open Inheritance模式和CUBA的特别机制,开发人员可以在扩展里重写父项目的任何部分。总之,CUBA扩展就是开发团队实现本文开头提到的数百个“小细节”的地方。

实际上,每个CUBA项目都是CUBA平台本身的扩展 - 因此任何平台功能都可以被重写。我们自己也采用这种方法从核心平台中分离出一些开箱即用的功能(全文搜索,报表,图表等)。如果您在项目中需要它们,就把它们添加为父项目 - 就像多重继承!

用同样的方式还可以构建多级扩展结构。听上去有点复杂但是很实用。举一个现成的例子:Sherlock - 是Haulmont的出租车全周期管理解决方案,支持出租车业务从预订、派单到计费的方方面面。该解决方案满足了客户许多不同的业务,其中相当一部分与地域相关。例如,所有英国出租车公司都有相同的法律规定,但其中许多不适用于美国,反之亦然。显然,我们不希望在核心产品中实施所有这些规定,原因

  • 这是“特定运营区域”的功能

  • 不同国家当地的法规可能对出租车车队运营产生完全不同的影响

  • 一些客户根本不需要类似的法律规定

    因此,我们这样组织多级扩展结构:

  1. 核心产品包含出租车业务的通用功能
  2. 第一级定制实现区域定制化功能
  3. 第二级定制涵盖特定客户的定制需求(如果有的话)

text

显而易见,通过使用扩展,既不需要客户分支也不需要把客户需求都集成到核心产品中,代码还清晰可控。 看上去好得难以置信,接下来看看它是如何实现的:

向现有实体添加新属性(实体定制)

假设Product产品中有一个User实体,它包含两个字段:login和password:

@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
}

然后根据客户要求需要向上述实体添加“家庭住址”字段,我们就需要在"扩展"中扩展User实体:

@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;
    }
}

上述代码中除了@Extends之外的所有注解都是常见的JPA注解。 @Extends注解是CUBA引擎的一部分,它全局地将User实体替换为ExtUser,包括替换掉原Product产品里的User实体。

   使用@Extends属性,我们在平台里强制做以下事情:
  1. 始终创建“最新子类"的实体

    User user = metadata.create(User.class); //ExtUser entity will be created
    
  2. 在JPQL执行之前做转换,以便它们始终返回“最新子类”的集合

    select u from product$User u where u.name = :name //returns a list of ExtUsers
    
  3. 始终在关联实体中使用“最新子类”实体

    userSession.getUser(); //returns an instance of ExtUser type
    

换句话说,如果声明了扩展实体,整个解决方案(核心产品和扩展)中的基础实体都会被扩展实体替换。

界面定制

 以上我们通过添加地址属性扩展了用户实体,现在要反映到用户界面中。 原始产品(Product)界面声明是这样的:
<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语法。 虽然我们可以简单地在扩展中重写整个界面的XML描述(先复制粘贴原XML的大部分内容),但是如果将来原界面某些内容发生变化就得手动将这些更改复制到扩展界面。为了避免这种情况,CUBA引入了界面继承机制,在扩展中只需要描述对界面的更改即可:

<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属性定义要继承的界面,然后描述要更改的界面元素。

    看看结果:

text

修改业务逻辑

CUBA平台使用Spring Framework实现业务逻辑,Spring Framework构成了平台基础架构的核心部分。

    举例,有一个bean用来做价格计算:
@ManagedBean("product_PriceCalculator")
public class PriceCalculator {
    public void BigDecimal calculatePrice() { 
        //price calculation
    }
}
  要重写价格计算逻辑,只需要简单的两步:

    首先,扩展(继承)核心产品中的类并重写相应的方法:
public class ExtPriceCalculator extends PriceCalcuator {
    @Override
    public void BigDecimal calculatePrice() { 
               //modified logic goes here
    }
}

其次,使用原有的bean标识符在Spring配置中注册新类:

 <bean id="product_PriceCalculator" class="com.sample.extension.core.ExtPriceCalculator"/>

然后对PriceCalculator的注入将始终返回扩展类实例,所以新逻辑将在整个产品范围内生效。

在扩展中升级核心产品版本

随着核心产品的发展和新版本的发布,您很可能会打算将扩展升级到最新的核心产品版本。 这个过程也很简单:

  1. 在扩展中指定核心产品的新版本号
  2. 重新构建扩展:
  • 如果扩展是基于产品API的稳定部分构建的,则可以直接运行扩展。
  • 如果核心产品API进行了一些重大修改,并且这些修改与扩展中实现的定制化开发重复了,则有必要在扩展中支持新的核心产品API

大多数情况下,产品API在更新时不会发生显著的变化,尤其是在小版本中。 但即使出现API“大爆炸”,产品通常会保持至少两个未来版本的向下兼容性,旧的实现标记为“已弃用”,允许将所有扩展迁移到最新的API。

总结

现在我们以表格形式对以上方案做一个简短的总结:

text

  显而易见,扩展方案很强大,但它不支持运行时定制(动态定制)。为了弥补这方面,

CUBA也支持Entity-Attribute-Value模型和Plugin/Scripting方案。