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

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...点击查看项目介绍 ;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;

截止目前, 星球 内专栏累计输出 63w+ 字,讲解图 2808+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 2200+ 小伙伴加入学习 ,欢迎点击围观

你有没有听说过:“我们真的很喜欢你的产品……除了一些小细节。”?然后,首席信息官推出了一个额外的“必须具备”要求列表,其中有数百个,以添加到您的惊人产品中。你有没有听说过,甚至说过:'团队,我们即将签署一份高利润的合同,但是......'?然后,客户对附加功能的愿望清单成为开发人员头疼的问题。

那么,如何让产品远离客户的潜在危险想法,同时又能满足他们的需求呢?对于一个在技术上设计为以特定方式运行但现在有一层众多插件的产品,如何才能保持最高水平的性能?为开发的解决方案提供可靠和出色支持的基本需求会带来多少挑战?

在商业世界中,产品定制是一种越来越受欢迎的需求,并且为了响应这种客户需求,已经发展出许多常见的做法。您可以在下面找到典型方法的概述;如果您已经熟悉它们,那么欢迎您直接向下滚动到“ 扩展方法 ”段落,了解 我们如何以我们认为更有效的方式解决这些挑战

一体

最直接、最明显的定制解决方案是在一个核心产品中实现所需的一切,然后采用“功能切换”技术来满足每个特定客户的要求。
一体化 方法的主要优点是保持整体产品,这对于某些类型的产品来说似乎是一种好方法,这些产品通常可以满足业务需求而无需进行大量定制。

这种方法的自然局限性隐藏在“不需要太多定制”的假设中。通常,产品开发是从这种信念开始的,但是在多次交付之后,您会意识到需要多少客户特定功能的真实规模。陷入困境的情况并不少见。拒绝定制开发并可能会失去客户,或者将源代码变成 垃圾桶 ,具有单一客户特定的功能,这些功能可能对大多数最终用户毫无用处。

你会选择哪个选项?显然,进退两难并不是成功之道。

简介: 仅当您确定需要很少且有限的定制时,一体化方法才是合适的选择。否则,您将面临在可管理和可支持的产品与客户满意度之间做出选择。让我引用 Jerry Garcia 的话,他说:“不断选择两害相权取其轻,仍然是选择邪恶”。

分枝

如果重要的定制是交付的“必须具备”的部分,那么就不能采用 多合一 技术。还有另一种直接的方法—— 分支 。您只需分支产品代码库并单独更改任何内容。

Branching All in One 相比,最大的优势是没有适用于定制范围的限制。您使用单独的分支来满足不同客户的特定要求,并避免在同一代码库中混合使用所有功能。

然而,它的另一面可能成为产品发展的死胡同。显然,产品分支是主要的开发空间:大部分错误修复、改进、新功能首先被推送到产品中。因此,需要频繁合并以保持所有定制分支与核心产品同步。只要原始产品源代码没有受到自定义分支的影响,合并是一个简单的操作,否则会变得非常耗时,并且会导致不可避免的回归错误。

如果您仅限于极少数自定义分支,则此方法仍然有效。然而,随着交付实例数量的增长,面临“合并折磨”的可能性变得迫在眉睫。

简介: 分支 方法无疑非常灵活和直接——可以修改产品的任何部分。然而,交付后阶段可能非常费力,随着时间的推移变得越来越困难,并且不太可能交付大量可管理的定制分支。

实体属性值模型

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

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

与以往一样,有一个缺点:

  • 适用性有限——EAV 模型的局限性在于只能向实体添加属性,然后根据预编程逻辑自动嵌入到 UI 中。
  • 额外的数据库服务器负载——垂直数据库设计常常成为企业应用程序的瓶颈,这些应用程序通常与大量实体和相关属性一起运行。

最后,如果没有复杂的报告引擎,就无法想象企业系统。由于其“垂直”数据库结构,EAV 模型有可能带来许多复杂情况。

简介: 实体-属性-值 模型在某些情况下具有很大的价值,例如当需要提供通过具有附加信息数据实现的灵活性时,这在业务逻辑中并未明确使用。换句话说,EAV 在适度方面是好的,例如除了标准的关系模型和 插件架构

插件架构

插件架构 是最流行和最强大的方法之一——其中功能逻辑被保存为单独的工件,称为插件。要覆盖现有的开箱即用行为并运行插件,有必要在产品源代码中定义“定制点”(也称为扩展点)。 “定制点”是源代码中的某个位置,应用程序会在其中浏览附加的插件,以检查插件是否包含要在此处运行的覆盖实现。插件架构的变体之一是外部脚本;当功能实现被实现并作为脚本存储在外部时。脚本调用也由预定义的“自定义点”控制。

使用这种插件方法,可以使产品保持“干净”的特定客户需求,“按原样”交付核心产品,并根据插件或脚本的要求自定义行为。这种方法的另一个优点是管理良好的更新过程。产品和插件功能的完全分离使每个功能都可以相互独立更新。

当然,也存在局限性:主要的局限性是不可能完全了解未来可能提出哪些定制要求。因此,只能猜测应该嵌入“定制点”的位置。当然,这些可以作为缓解“以防万一”计划分散在各处,但这将导致代码可读性差、调试困难和额外复杂的支持。

摘要: 如果“定制点”很容易预测, 插件架构 确实有效,但请注意,“定制点”之间的定制是不可能的。

扩展方法

我们在我们的企业软件开发平台 CUBA 中实施了一种独特的方法。正如我们 之前的文章 所述,CUBA 是一个非常实用的有机体,它是通过开发人员驱动的进化过程创建的。因此,根据我们对现成产品的丰富经验,我们提出了两个最终要求:

  • 客户特定代码应与核心产品代码完全分离。
  • 产品代码的每一部分都应该可供修改。

我们设法满足了这些要求,并通过我们的“扩展”机制取得了更多成就。

古巴扩展

扩展 是一个单独的 CUBA 项目,它继承了底层项目(即您的核心产品)的所有功能,将其用作库。这显然使开发人员能够在不影响父项目的情况下实现全新的功能,但由于使用了 开放继承 模式和特殊的 CUBA 设施,您还可以覆盖父项目的任何部分。总之,扩展是您实现本文开头讨论的数百个“一些小细节”的地方。

事实上,每个 CUBA 项目都是 CUBA 平台本身的扩展——因此它可以覆盖任何平台特性。我们自己采用这种方法从核心平台中分离出一些开箱即用的功能(全文搜索、报告、图表等)。因此,如果您在项目中需要它们,只需将它们添加为父项目——就是这样,有点多重继承!

以同样的方式,您可以构建 分层定制模型 。这听起来可能很复杂,但它非常有道理。让我举一个真实的例子: Sherlock - 是 Haulmont 完整的出租车管理解决方案,支持出租车业务运营的各个方面,从预订和调度到应用程序和计费。该解决方案涵盖客户业务的许多不同方面,其中相当一部分与位置相关。例如,所有英国出租车公司都有相同的法律规定,但其中许多不适用于美国,反之亦然。显然,我们不想在核心产品中实施所有这些规定,因为:

  • 这是一个“操作区域特定”功能
  • 地方法规可能对不同国家的出租车车队运营产生完全不同的影响
  • 有些客户根本不需要监管控制

因此,我们组织了多级扩展层次结构:

  1. 核心产品包含出租车业务的通用特征
  2. 第一级定制实现区域特性
  3. 第二级定制涵盖客户的愿望清单(如果有的话!)

干净利落。

如您所见,通过使用扩展,您既不需要 分支 也不需要 将所有需求集成 到核心产品中,代码保持干净且易于管理。这听起来好得令人难以置信,所以让我们看看它在实践中是如何工作的!

向现有实体添加新属性

假设我们有用户实体的产品定义,它由两个字段组成:登录名和密码:


 @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 = "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

}

您可能已经注意到,除 @Extends 之外的所有注释都是常见的 JPA 注释。 @Extends 属性是 CUBA 引擎的一部分,它在全局范围内将 User 实体替换为 ExtUser ,甚至跨产品功能。

使用 @Extends 属性,我们强制平台:

  1. 始终创建“最新子”类型的实体
    
     @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
    

    }

  2. 在执行之前转换所有 JPQL 查询,以便它们始终返回“最新的子集”
    
     @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
    

    }

  3. 始终在关联实体中使用“最新的孩子”
    
     @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
    

    }

换句话说,如果声明了一个扩展实体,则基础实体将在整个解决方案(产品和扩展)中被放弃,并被扩展实体全局覆盖。

屏幕定制

因此,我们通过添加地址属性扩展了用户实体,现在希望将更改反映在用户界面中。首先,让我们看一下原始(产品)屏幕声明:


 @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

}

如您所见,CUBA 屏幕描述符表示为普通的 XML。显然,我们可以简单地在扩展中重新声明整个屏幕描述符,但这意味着复制粘贴其中的大部分内容。结果,如果将来产品屏幕发生变化,我们将不得不手动将这些更改复制到扩展屏幕。为了避免这种情况,CUBA 引入了屏幕继承机制,你只需要描述屏幕的变化:


 @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

}

您使用 extends 属性定义祖先屏幕,并仅描述要更改的主题。

给你!最后让我们看看结果:

修改业务逻辑

为了能够修改业务逻辑,CUBA 平台使用了 Spring Framework,它构成了平台基础架构的核心部分。

例如,您有一个中间件组件来执行价格计算过程:


 @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

}

要覆盖价格计算实现,我们只需要执行两个简单的操作。

首先,扩展产品类并覆盖相应的程序:


 @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

}

最后一点,使用产品 bean 标识符在 Spring 配置中注册新类:


 @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

}

现在 PriceCalculator 注入将始终返回扩展类实例。因此,修改后的实现将在整个产品中使用。

在扩展中升级基础产品版本

随着核心产品的发展和新版本的发布,您最终将决定将您的扩展升级到最新的产品版本。这个过程非常简单:

  1. 在扩展中指定基础产品的新版本。
  2. 重建扩展:
    • 如果扩展是在产品 API 的稳定部分之上构建的,那么它就可以运行了。
    • 如果对产品 API 进行了一些重大修改,并且这些修改与扩展中实现的自定义重叠,则有必要在扩展中支持新的产品 API。

大多数情况下,产品 API 不会因更新而发生重大变化,尤其是在次要版本中。但是,即使 API 发生了“大爆炸”,产品通常至少会保持向下兼容几个未来版本,并且旧的实现会被标记为“已弃用”,从而允许将所有扩展迁移到最新的 API。

结论

作为一个简短的总结,我想以表格形式说明比较分析的结果:

如您所见,Extension 方法非常强大,但它缺乏动态微调系统(动态定制)的能力。为了克服这个问题,CUBA 还提供了对 实体-属性-值 模型和 插件/脚本 方法的全面支持。

我希望您会发现此概述有用,当然非常感谢您的反馈。

相关文章