14

重构大型“仅状态”对象有哪些常用策略?

我正在开发一个特定的软实时决策支持系统,该系统对国家空域进行在线建模/模拟。该软件使用大量实时数据馈送,并每分钟对空域中大量实体的“状态”进行一次估计。直到我们遇到当前最低级别的实体,问题才被巧妙地分解。

我们的数学模型估计/预测每个实体的过去和未来几个小时的时间线中超过 50 个参数,大约每分钟一次。目前,这些记录被编码为具有大量字段的单个 Java 类(有些被折叠成ArrayList)。我们的模型正在发展,字段之间的依赖关系还没有一成不变,所以每个实例都在一个复杂的模型中徘徊,随着它的进行积累设置。

目前我们有类似以下的东西,它使用构建器模式方法来构建记录的内容,并强制执行已知的依赖关系(作为对程序员错误的检查,作为进化模式。)一旦估计完成,我们使用.build()类型方法将以下内容转换为不可变形式。

final class OneMinuteEstimate {

  enum EstimateState { INFANT, HEADER, INDEPENDENT, ... };
  EstimateState state = EstimateState.INFANT; 

  // "header" stuff
  DateTime estimatedAtTime = null;
  DateTime stamp = null;
  EntityId id = null;

  // independent fields
  int status1 = -1;
  ...

  // dependent/complex fields...
  ... goes on for 40+ more fields... 

  void setHeaderFields(...)
  {
     if (!EstimateState.INFANT.equals(state)) {
        throw new IllegalStateException("Must be in INFANT state to set header");
     }

     ... 
  }

}

一旦完成了大量这些估计,它们就会被组合成时间线,在其中分析聚合模式/趋势。我们曾研究过使用嵌入式数据库,但一直遇到性能问题;我们宁愿根据数据建模来解决这个问题,然后将部分软实时代码逐步移动到嵌入式数据存储中。

一旦完成了“时间敏感”部分,产品就会被刷新到平面文件和数据库中。

问题:

  • 这是一个巨大的类,有太多的领域。
  • 类中编码的行为很少;它主要是数据字段的持有者。
  • 维护build()方法非常麻烦。
  • 仅仅为了确保大量依赖建模组件正确填充数据对象而手动维护“状态机”抽象感觉很笨拙,但是随着模型的发展,它为我们节省了很多挫败感。
  • 有很多重复,特别是当上述记录被聚合成非常相似的“汇总”时,这些“汇总”相当于时间序列中上述结构的滚动总和/平均值或其他统计产品。
  • 虽然一些领域可以聚集在一起,但它们在逻辑上都是彼此的“对等点”,我们尝试的任何故障都会导致行为/逻辑人为地分裂,并且需要达到两个深度的间接层次。

开箱即用的想法很受欢迎,但这是我们需要逐步发展的东西。在其他人说之前,我会指出,如果该模型的数据表示如此难以掌握,那么有人可能会建议我们的数学模型不够清晰。公平点,我们正在努力,但我认为这是一个有很多贡献者的研发环境的副作用,并且有很多并发的假设在起作用。

(没关系,但这是用 Java 实现的。我们使用 HSQLDB 或 Postgres 作为输出产品。我们不使用任何持久性框架,部分原因是不熟悉,部分原因是我们仅使用数据库就有足够的性能问题单独和手动编码的存储例程......我们对转向额外的抽象持怀疑态度。)

4

5 回答 5

5

我有很多和你一样的问题。

至少我认为我做到了,听起来我做到了。表示是不同的,但在 10,000 英尺处,听起来几乎相同。大量离散的“任意”变量和它们之间的一堆临时关系(本质上是业务驱动的),随时可能发生变化。

您还提到了另一个问题,那就是性能要求。听起来越快越好,很可能一个缓慢的完美解决方案会被快速糟糕的解决方案所抛弃,仅仅是因为较慢的解决方案无论多么好都无法满足基线性能要求。

简单地说,我所做的就是为我的系统设计了一种简单的领域特定规则语言。

DSL 的全部意义在于隐式表达关系并将它们打包成模块。

非常粗糙,人为的例子:

D = 7
C = A + B
B = A / 5
A = 10
RULE 1: IF (C < 10) ALERT "C is less than 10"
RULE 2: IF (C > 5) ALERT "C is greater than 5"
RULE 3: IF (D > 10) ALERT "D is greater than 10"
MODULE 1: RULE 1
MODULE 2: RULE 3
MODULE 3: RULE 1, RULE 2

首先,这不代表我的语法。

但是你可以从 Modules 中看到,它是 3,简单的规则。

但关键是,很明显规则 1 依赖于 C,它依赖于 A 和 B,而 B 依赖于 A。这些关系是隐含的。

因此,对于该模块,所有这些依赖项都“随之而来”。您可以查看我是否为模块 1 生成了代码,它可能类似于:

public void module_1() {
    int a = 10;
    int b = a / 5;
    int c = a + b;
    if (c < 10) {
        alert("C is less than 10");
    }
}

而如果我创建了模块 2,我将得到的只是:

public void module_2() {
    int d = 7;
    if (d > 10) {
        alert("D is greater than 10.");
    }
}

在模块 3 中,您会看到“免费”重用:

public void module_3() {
    int a = 10;
    int b = a / 5;
    int c = a + b;
    if (c < 10) {
        alert("C is less than 10");
    }
    if (c > 5) {
        alert("C is greater than 5");
    }
}

因此,即使我有一个规则“汤”,模块也会根植于依赖关系的基础,从而过滤掉它不关心的东西。抓住一个模块,摇动树,让剩下的东西挂起来。

我的系统使用 DSL 生成源代码,但您也可以轻松地让它创建一个迷你运行时解释器。

简单的拓扑排序为我处理了依赖图。

所以,这样做的好处是,虽然在最终生成的逻辑中不可避免地存在重复,至少跨模块,但在规则库中没有任何重复。作为开发人员/知识工作者,您维护的是规则库。

另一个好处是您可以更改方程式,而不必太担心副作用。例如,如果我更改 do C = A / 2,那么,B 会突然完全退出。但是 IF (C < 10) 的规则根本没有改变。

使用一些简单的工具,您可以显示整个依赖关系图,您可以找到孤立变量(如 B)等。

通过生成源代码,它会以你想要的速度运行。

在我的例子中,看到规则删除一个变量并看到 500 行源代码从生成的模块中消失是很有趣的。那是 500 行,我不必在维护和开发过程中手动爬过并删除。我所要做的就是改变我的规则库中的一条规则,让“魔法”发生。

我什至能够做一些简单的窥视孔优化并消除变量。

这并不难做到。您的规则语言可以是 XML,也可以是简单的表达式解析器。如果你不想的话,没有理由去全船 Yacc 或 ANTLR。我会为 S-Expressions 插入一个插件,不需要语法,脑死解析。

实际上,电子表格也是一个很棒的输入工具。只是对格式要求严格。在 SVN 中合并有点糟糕(所以,不要那样做),但最终用户喜欢它。

您很可能能够摆脱基于实际规则的系统。我的系统在运行时不是动态的,也不需要复杂的目标搜索和推理,所以我不需要这样一个系统的开销。但是,如果开箱即用为您工作,那么快乐的一天。

哦,对于一个实现说明,对于那些不相信你可以在 Java 方法中达到 64K 代码限制的人,我可以向你保证它可以做到:)。

于 2011-03-30T23:24:41.097 回答
3

Splitting a Large Data Object is very similar to Normalizing a Large Relational Table (first and second normal form). Follow the rules to reach at least second normal form and you may have a good decomposition of the original class.

于 2011-03-30T22:36:43.700 回答
3

从与具有软实时性能约束(有时是怪物脂肪类)的研发人员一起工作的经验来看,我建议不要使用 OR 映射器。在这种情况下,您最好处理“接触金属”并直接使用 JDBC 结果集。这是我对具有软实时约束和每个包大量数据项的应用程序的建议。更重要的是,如果需要持久化的不同类(不是类实例,而是类定义)的数量很大,并且您的规范中也有内存限制,您还需要避免像 Hibernate 这样的 ORM。

回到你原来的问题:

您似乎遇到的是一个典型的问题:1)将多个数据项映射到一个 OO 模型中,以及 2)这样的多个数据项没有表现出一种好的分组或隔离方式(并且任何分组尝试都倾向于感觉不对。 ) 有时域模型不适合这种聚合,并且想出一种人为的方式这样做通常会导致不能满足所有设计要求和愿望的妥协。

更糟糕的是,OO 模型通常要求/期望您将类中存在的所有项目作为类的字段。这样的类通常没有行为,所以它只是一个struct-like 构造,又名data envelopeor data shuttle

但这种情况引出了以下问题:

您的应用程序是否需要一次读取/写入所有 40、50 多个数据项? *是否必须始终存在所有数据项?*

我不知道您的问题域的具体情况,但总的来说,我发现我们很少需要一次处理所有数据项。这是关系模型大放异彩的地方,因为您不必一次查询表中的所有行。您只提取您需要的那些作为相关表格/视图的投影

在我们可能有大量数据项的情况下,平均而言,通过网络传递的数据项数量少于最大值,您最好使用属性模式。

而不是定义一个包含所有项目的怪物信封类:

// java pseudocode
class envelope
{
   field1, field2, field3... field_n;
   ...
   setFields(m1,m2,m3,...m_n){field1=m1; .... };
   ...
}

定义字典(例如基于地图):

// java pseudocode
public enum EnvelopeField {field1, field2, field3,... field_n);

interface Envelope //package visible
{
   // typical map-based read fields.
   Object get(EnvelopeField  field);
   boolean isEmpty();

   // new methods similar to existing ones in java.lang.Map, but
   // more semantically aligned with envelopes and fields.
   Iterator<EnvelopeField> fields();
   boolean hasField(EnvelopeField field); 
}

// a "marker" interface
// code that only needs to read envelopes must operate on
// these interfaces.
public interface ReadOnlyEnvelope extends Envelope {} 

// the read-write version of envelope, notice that
// it inherits from Envelope, but not from ReadOnlyEnvelope.
// this is done to make it difficult (but not impossible
// unfortunately) to "cast-up" a read only envelope into a
// mutable one.
public interface MutableEnvelope extends Envelope
{
   Object put(EnvelopeField field); 

   // to "cast-down" or "narrow" into a read only version type that
   // cannot directly be "cast-up" back into a mutable.
   ReadOnlyEnvelope readOnly();
}

// the standard interface for map-based envelopes.
public interface MapBasedEnvelope extends 
   Map<EnvelopeField,java.lang.Object>
   MutableEnvelope
{
}

// package visible, not public
class EnvelopeImpl extends HashMap<EnvelopeField,java.lang.Object> 
  implements MapBasedEnvelope, ReadOnlyEnvelope
{
   // get, put, isEmpty are automatically inherited from HashMap
   ... 
   public Iterator<EnvelopeField> fields(){ return this.keySet().iterator(); }
   public boolean hasField(EnvelopeField field){ return this.containsKey(field); }

   // the typecast is redundant, but it makes the intention obvious in code.
   public ReadOnlyEnvelope readOnly(){ return (ReadOnlyEnvelope)this; }
}

public class final EnvelopeFactory
{
    static public MapBasedEnvelope new(){ return new EnvelopeImpl(); }
}

无需设置read-only内部标志。您需要做的就是将信封实例向下转换为Envelope实例(仅提供 getter)。

期望读取的代码应该在只读信封上运行,而期望更改字段的代码应该在可变信封上运行。实际实例的创建将在工厂中划分。

也就是说,您使用编译器通过建立一些代码约定、管理在何处以及如何使用哪些接口的规则来强制事物为只读(或允许事物是可变的)。

您可以将代码分层为需要编写的部分,与只需要阅读的代码分开。一旦完成,简单的代码审查(甚至 grep)就可以识别出使用错误接口的代码。)

问题:

非公开父接口:

Envelope未声明为公共接口,以防止错误/恶意代码将只读信封转换为基本信封,然后再转换为可变信封。预期的流程是从可变到只读的 - 它不是双向的。

这里的问题是扩展Envelope仅限于包含它的包。这是否是一个问题将取决于特定的域和预期用途。

工厂:

问题是工厂可能(并且很可能会)非常复杂。再次,野兽的本性。

验证:

这种方法引入的另一个问题是,现在您必须担心期望字段 X 存在的代码。拥有原始的怪物信封类可以让您部分摆脱这种担忧,因为至少在语法上,所有字段都在那里......

...无论字段是否设置,这是我提议的新模型仍然存在的另一件事。

因此,如果您有希望看到字段 X 的客户端代码,则客户端代码必须在该字段不存在时抛出某种类型的异常(或以某种方式向计算机或读取合理的默认值)。在这种情况下,您将不得不

  1. 识别现场存在的模式。期望字段 X 存在的客户端可能与期望其他字段存在的客户端分开(分层)分组。

  2. 关联自定义验证器(只读信封接口的代理),它们根据某些规则(以编程方式提供的规则、解释器或规则引擎)抛出异常或计算缺失字段的默认值。

缺乏打字:

这可能值得商榷,但过去使用静态类型的人可能会因为采用松散类型的基于映射的方法而失去静态类型的好处而感到不安。与此相反的论点是,大多数 Web 都采用松散类型的方法,即使在 Java 端(JSTL、EL)也是如此。

抛开问题不谈,在任何给定时间,可能字段的最大数量和平均字段数量越少,这种方法的性能将是最有效的。它增加了额外的代码复杂性,但这就是野兽的本性。

这种复杂性不会消失,要么会出现在你的类模型中,要么会出现在你的验证代码中。不过,序列化和沿线传输效率更高,特别是在您期望大量单独数据传输的情况下。

希望能帮助到你。

于 2011-03-31T00:09:26.893 回答
1

实际上,这看起来像是游戏开发人员经常面临的问题,由于继承树很深等原因,拥有大量变量和方法的臃肿类。

有这篇关于如何以及为什么选择组合而不是继承的博客文章,也许它会有所帮助。

于 2011-03-30T21:19:14.987 回答
0

您可以智能地分解大型数据类的一种方法是查看客户端类的访问模式。例如,如果一组类只访问字段 1-20,而另一组类只访问字段 25-30,那么这些字段组可能属于不同的类。

于 2011-04-05T00:58:46.140 回答