为什么需要从按技术分层(dao,控制器,实体)转移到按业务功能(userMgmt,productMgmt)打包?- phauer


一种流行的方法是出于技术考虑进行包装Package。但是这种方法有一些缺点。相反,我们可以按功能打包并创建自包含且独立的程序包,结果是一个易于理解且不易出错的代码库。

  • 按技术打包类的缺点:
    • 对属于某个要素的所有类的概述不佳。
    • 通用代码,重用代码和复杂代码趋向于难以理解,并且由于难以把握变更的影响,因此变更很容易破坏其他用例。
  • 按业务功能打包并创建包含功能所需的所有类的程序包。好处是:
    • 更好的可发现性和概览
    • 独立且独立
    • 更简单的代码
    • 可测性

逐层打包
项目结构的一种非常流行的方法是逐层打包。这将为每个技术组的类提供一个软件包。


让我们将调用层次结构添加到图片中,以“清楚地”看到哪个类取决于哪个类。

那么,按技术层打包的缺点是什么?

  • 功能概述不佳。通常,当我们在项目中处理代码时,我们会想到要更改的特定领域或功能。因此,我们从领域的角度出发。不幸的是,按技术打包迫使我们从一种包装过渡到另一种包装,以掌握整个业务功能的概况。
  • 通用,重用和复杂代码的趋势。通常,这种方法导致中心类包含每个用例的所有方法。随着时间的流逝,这些方法越来越抽象化(带有额外的参数和泛型)来满足更多用例。仅在上述画面的一个例子是ProductDAO,其中用于所述方法ProductController和ExportController所在的位置。结果是:
    • 当添加更多方法时,类将变得更大。因此,仅凭代码量,就很难理解它。
    • 更改通用重用代码很危险。尽管您只想处理一个用例,但您可以轻松地打破所有用例。
    • 由于以下两个原因,难以理解抽象方法和通用方法:首先,要通用,通常需要其他技术构造(例如,switch,参数,泛型),这使得查看与之相关的业务逻辑更加困难。当前用例(信号噪声比)。其次,认知需求更高,因为您必须了解所有其他用例,以确保不会破坏它们

我们虽然实现了DRY,但违反了KISS。

按业务功能打包
让我们将这些类重新排列成独立的功能包。

新程序包userManagement包含属于此功能的所有类:控制器,DAO,DTO和实体。


新软件包productManagement包含相同的类类型,再加上StockServiceClient和StockDTO。这个事实清楚地表明:库存服务仅由产品管理人员使用。

userManagement和productManagement使用不同的域实体和表。将它们分成不同的包很简单。但是,当一个功能需要与另一个功能相似域实体时,会发生什么?


现在,它变得越来越有趣。产品导出功能包的软件包exportProduct还处理产品实体,但是具有不同的用例。
我们的目标是拥有独立的独立功能包:exportProduct即使看起来可能类似于productManagement,似乎不应该具有自己的DAO,DTO类和实体类。抵制重用productManagement中类的冲动。

  • 我们可以使用针对export用例量身定制的结构(DTO,实体)。它们仅包含相关字段,并且可以基于具有相关列的良好投影的查询来创建实体-别无其他。
  • 专用ExportProductDAO包含特定于export的查询和预测。

我们可能不得不再次编写更多代码,但最终会遇到非常有利的情况:
  • 更改productManagement将永远不会破坏exportProduct代码,反之亦然。它们可以独立发展。
  • 更改代码时,我们仅需牢记当前功能。
  • 代码本身将变得更加简单易懂,因为它不是通用的,并且不必在两个用例中都可以使用。

上面的功能包很棒,但实际上,我们将始终需要一个common包。

通用软件包包含技术配置和可重复使用的代码
  • 它包含技术配置类(例如,用于DI,Spring,对象映射,http客户端,数据库连接,连接池,日志记录,线程池)
  • 它包含可以重用的有用的小代码段。但是,过早抽象代码要非常小心。我总是首先将util代码尽可能地接近其用法,这是功能包甚至using类。仅当我确实对代码段有更多用法(不是:我认为将来可能会使用)时,才将其移动到common程序包中。该三个规则给出了很好的指导。
  • 它可能是有意义的定位在所有实体common包。我们还对某些项目执行了此操作,其中许多功能包一次又一次地使用相同的实体。一些开发人员还希望将所有实体放在中心位置,以便能够整体查看数据库架构的映射。在这一点上,我并不信条,因为实体的两个位置都可以合理。尽管如此,我始终总是开始将尽可能多的代码移至功能部件包,并依赖于量身定制的特定于用例的实体和预测。

 大图景
最终,我们的战略大图景看起来像这样:

让我们简要总结一下好处:

  • 从域的角度来看,更好的可发现性和概述。属于业务功能的大多数代码位于一起。这很关键,因为我们通常会在考虑某个业务需求的情况下访问代码库。
  • 独立的和独立的。功能所需的大多数代码都位于程序包中。因此,我们避免依赖其他功能包。结果是:
    • 我们在开发功能时破坏其他功能的可能性较小。
    • 需要较少的认知能力来估计变化的影响。通常,我们只需要记住当前的软件包即可。
  • 更简单的代码。由于我们避免使用通用和抽象的代码,因此代码变得更加简单,因为它只需要处理一个用例。因此,更容易理解和发展代码。
  • 可测性。通常,与试图满足所有用例的技术包中的“上帝类”相比,功能包中的类具有较少的依赖关系。因此,由于我们必须创建更少的测试夹具,因此测试变得更加容易。

缺点
  • 我们必须编写更多代码。
  • 我们可能会多次编写类似的代码。
  • 决定何时最好将代码移至common程序包并重用它是很难的。有疑问时,“三定律”很有用。

我想强调指出,重用仍然是允许且有用的。我们不会在教条上采用这种方法。
但是,我认为优点大于缺点。

背后的原理
提出的按功能打包的方法遵循了一个非常贴切的原则:
KISS>DRY


再次,我想引用桑迪·梅斯(Sandi Metz)
“优先选择复制而不是错误的抽象。” 桑迪·梅斯(Sandi Metz)。参见编码智慧之墙

按功能打包案例
我们的团队记录了其遵循的编码准则和原则。关于按功能打包的部分如下所示:
我们基于功能打包代码。每个功能包均包含提供该功能所需的大多数代码。每个功能包都应独立且独立。

├── feature1
│   ├── Feature1Controller
│   ├── Feature1DAO
│   ├── Feature1Client
│   ├── Feature1DTOs.kt
│   ├── Feature1Entities.kt
│   └── Feature1Configuration
├── feature2
├── feature3
└── common

  • 这种方法影响所有层。例如,每个程序包都有自己的DAO和客户端。不应有庞大的DAO类神。
  • 一个程序包应该与其他程序包只有几个关系。该功能所需的所有物品都应放在包装内。
  • 经验法则:如果要删除功能,则只需删除相应的程序包。
  • 仍然可以重复使用common软件包中的内容,但它只应包含多次使用的代码(请参阅三则规则)。它不包含业务逻辑。技术人员还可以。
  • 如果有特定于功能的Spring Bean,我们会将其配置放在功能包中。

问题
1. 功能包有多大?
术语“功能”很棘手。“产品export”是功能吗?抑或是更大的“产品管理”功能的一部分(包含许多其他内容)?在这种情况下,您也可以将其称为“ 模块 ”。我不想为此提供任何指导,只要您构建从您的域派生的独立且自包含的包/模块,您就走上了正确的道路。实际范围取决于域,您的喜好以及要在此模块中重用的数量。

2.功能包可以包含子包吗?
绝对没错。由于包装的大小可以变化,因此在包装内提出子包装结构是有意义的。在要素包中逐层打包也是合理的。如果有帮助,您可以考虑使用“模块”而不是“功能包”。

3.我最终会一次又一次写相同的代码吗?
是的,会有一些重复,但是根据我的经验,您可能不会相信那么多100%相同的代码。由于相似的代码涵盖了不同的用例,因此通常是不同的。例如,两种方法可以按产品名称查询产品,但是它们在计划的字段,排序和其他条件方面有所不同。因此,最好将方法分开放在不同的程序包中。
而且,复制本身并不是邪恶的。在开始将代码提取到通用重用方法之前,我喜欢应用三规则
最后,我想强调指出,仍然允许集中使用可重用的代码,有时甚至是合理的,但是这些情况不再那么常见了。

4.Kotlin可以支持这种方法吗?
打包方法与语言无关。但是Kotlin使其易于遵循:

  • 使用数据类,编写量身定制的特定于功能的结构(如DTO或实体)仅需几行,而无需样板。
  • Kotlin允许将多个类放在一个文件中。因此,对于每个POJO类,没有子包dtos或entities包含很多Java文件,我们可以有一个包含所有数据类定义的文件DTOs.kt或Entities.kt文件。