在上篇中我们使用测试驱动开发方法(Test-Driven Development)实现了一个简单的流水号生成器,并获得了一个初步的软件模型。本篇中,我们将站在领域驱动的角度重审软件模型,结合具体的应用,深入并重构软件模型。
0. 上篇回顾
在上篇中我们使用测试驱动开发方法(Test-Driven Development)实现了一个简单的流水号生成器,并获得了一个初步的软件模型:
|
图1 编号生成器模型(V1)
|
熟悉设计模式的朋友们一眼就会看出来,这里运用了组合模式(Composite Pattern),把每个子流水号当做一个流水号来处理。虽然这个模型还能工作,但是我们仔细分析一下就会有很多疑问:
- ISerialNumberGenerator接口有什么用?为什么不直接使用抽象类(TSerialNumberGeneratorBase)?
- 客户需要验证流水号吗?即使需要,Validate函数应该返回Boolean吗?
- 调用NextSerialNumber函数时需要传入一个流水号,是不是意味着其调用者需要知道当前的流水号?对于Generator来说这样合适吗?
- 既然TConstantCodeSerialNumberGenerator表示固定代码,那调用NextSerialNumber方法是不是很奇怪?
- 如果考虑在流水号中加入日期的话,这个模型需要怎么修改?
- …
除了有这些疑问以外,恐怕这个模型存在最大的问题在于:它看上去更像是一种技术模型——虽然能勉强工作,但是没有表现任何领域知识。
现在,我们该停下来,回到起点,重新思考一下:
What's the Problem?
1. 领域知识
我们要解决的问题其实很简单——就是要获取一个可用的编号(Number)。编号一般是有几部分(Part)组成的。比如某张入库单的编号”RK200901160001”就包含下面3个部分:
- 代码:“RK”
- 日期:“20090116”
- 流水号:“0001”
其中,代码是固定不变的,流水号会自动递增,日期一般是当前系统日期(固定格式,比如YYYYMM、YYYYMMDD),另外当日期变化时再重置流水号。写到这里,我们终于找到了一个重要概念:编号规则(Number Rule)。编号规则定义了多个连续的段(Number Part),各段组合起来就生成了一个编号。正如下图所示:
|
图2 分析模型
|
在实际的应用当中,流水号的规则可能很复杂,也许要支持数字(如‘0000’-‘9999’)、英文字母(如‘A' - 'Z'),甚至是一些自定义的字符(如‘0’-‘Z’)的组合。既然这样,我们可以提取一个抽象概念:序列(Sequence)。如在卡号规则当中规定遇4跳过等等就表示卡号是由除4以外的其他阿拉伯数字组成的序列。{ 序列可以考虑用任意进制的计算器来实现:) }
2. 领域模型
结合上面的领域知识,我设计了新的领域模型:
|
图2 编号规则领域模型(V2)
|
我们来看看客户是如何使用这个模型的:
Code
1 procedure TTestNumberRule.TestCompositeNumber;
2 begin
3 fRule.AddCode('RK')
4 .AddLetters
5 .AddDigits('001', '999');
6 CheckEquals('RKA002', fRule.GetNextNumber('RKA001'));
7 CheckEquals('RKA999', fRule.GetNextNumber('RKA998'));
8 CheckEquals('RKB001', fRule.GetNextNumber('RKA999'));
9 CheckEquals('RKZ999', fRule.GetNextNumber('RKZ998'));
10 end;
11
12 procedure TTestNumberRule.TestDate;
13 begin
14 fRule.AddCode('RK')
15 .AddDateTime('YYYYMM', Self) // TTestNumberRule类实现了IDateTimeProvider接口,返回fDateTime便于测试
16 .AddDigits('0001', '9999');
17
18 fDateTime := EncodeDate(2009, 1, 1);
19 CheckEquals('RK2009010002', fRule.GetNextNumber('RK2009010001'));
20
21 fDateTime := EncodeDate(2009, 2, 1);
22 CheckEquals('RK2009020001', fRule.GetNextNumber('RK2009010999'));
23 end
(呵呵,现在是不是感觉NumberRule比之前的SerialNumberGenerator贴切多了?)
接下来我们再简单看看TNumberRule的实现:
1. 设置规则
Code
1 function TNumberRule.AddCode(const code: string): TNumberRule;
2 begin
3 fParts.Add(TCodeNumberPart.Create(code));
4 Result := Self;
5 end;
6
7 function TNumberRule.AddDigits(const first,
8 last: string): TNumberRule;
9 begin
10 Result := AddSequence('0123456789', Length(first), first, last);
11 end;
12
13 function TNumberRule.AddLetters: TNumberRule;
14 begin
15 Result := AddSequence('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 1, 'A', 'Z');
16 end;
17
18 function TNumberRule.AddDateTime(const format: string): TNumberRule;
19 begin
20 Result := AddDateTime(format, TCurrentDateTimeProvider.Create);
21 end;
22
23 function TNumberRule.AddDateTime(const format: string; const provider: IDateTimeProvider): TNumberRule;
24 begin
25 fParts.Add(TDateTimeNumberPart.Create(format, provider));
26 Result := Self;
27 end;
28
29 function TNumberRule.AddSequence(const dictionary: string; len: Integer;
30 const first, last: string): TNumberRule;
31 begin
32 fParts.Add(TSequenceNumberPart.Create(dictionary, len, first, last));
33 Result := Self;
34 end
2. 生成编号
Code
1 function TNumberRule.GetNextNumber(const number: string): string;
2 begin
3 ParseNumber(number);
4 BuildNumber;
5 Result := GenerateNumber;
6 end;
7
8 // ParseNumber负责根据各个NumberPart的Length把编号拆成多个段,BuildNumber中封装了规则的更新逻辑,GenerateNumber则生成编号。
9
10 procedure TNumberRule.BuildNumber;
11 var
12 part: TNumberPart;
13 value: string; // 拆分的编号
14 carried: Boolean; // 进位标志
15 i: Integer;
16 begin
17 Assert(fParts.Count = fList.Count);
18 carried := False;
19 for i := fParts.Count - 1 downto 0 do // 由低位向高位遍历
20 begin
21 part := TNumberPart(fParts[i]);
22 value := fList[i];
23 if part is TSequenceNumberPart then
24 begin
25 TSequenceNumberPart(part).SetValue(value);
26 if carried or (i = fParts.Count - 1) then
27 begin
28 TSequenceNumberPart(part).Next(carried);
29 end
30 end
31 else if (part is TDateTimeNumberPart) and
32 not SameText(TDateTimeNumberPart(part).Value, value) then
33 begin
34 ResetSequenceParts(part); // 如果日期不同则重置序列部分编号
35 end;
36 end;
37 if carried then
38 begin
39 raise ENumberException.Create(SNumberOutOfRange);
40 end;
41 end
P.S. 序列部分(TSequenceNumberPart)的核心功能实现委托给任意进制计算器(BaseNCalculator),具体可参考源代码。
3. 业务应用
为了实现具体的业务应用,我们还需要做两件事:
1. 编号规则的持久化(一般使用XML,暂省略)
2. 编号的获取和更新
我们可以在业务层定义了下面两个接口,方便供客户使用:
Code
1 INumberGenerator = interface
2 function NextNumber: string;
3 end
Code
1 INumberCalculator = interface
2 procedure Validate(const number: string);
3 function Compare(const startNumber, endNumber: string): Integer;
4 function GetCount(const startNumber, endNumber: string): Int64;
5 function GetEndNumber(const startNumber: string; count: Int64): string;
6 end
我们只需要通过访问一个全局的Factory/Registry来获得一个当前Context的INumberGenerator实例,然后调用NextNumber方法就可以获取编号。其实现可参考:
Code
1 TDBNumberGenerator = class(TInterfacedObject, INumberGenerator)
2 private
3 fRule: TNumberRule;
4 fDataSet: TDataSet;
5 fTypeID: string;
6 public
7 { 访问数据库的编号表,根据TypeID进行行锁定(悲观锁),读取当前可用的编号后,调用fRule的NextNumber,把结果更新回去 }
8 function NextNumber: string;
9 end
INumberCalculator接口主要针对那些手工输入编号或需要进行统计编号数量的应用(比如,输入开始卡号和结束卡号,自动计算数量)。
最后,期待大家的批评和指点。
P.S. NumberGenerator项目及源代码(Delphi 2009,含Test Case)下载地址:https://files.cnblogs.com/baoquan/NumberGenerator.rar
作者:左保权 (Zuo Baoquan)
Blog:http://baoquan.cnblogs.com/
MSN:Baoquan.Zuo[at]hotmail.com
欢迎转载,欢迎讨论,欢迎指正。