1.缘起:
当数据源中的数据量多到一定程度时,我们在查询时就经常使用分页策略。如果数据源是一个完整的整体,这没有什么大不了的,我们经常就在做类似的事情。但是,如果数据源不是一个完整的整体,而是由很多有序的片段构成的,并且不同的片段可能位于不同的位置(比如,位于不同的服务器节点上的内存中),甚至,每个片段内的数据还会随着时间的变化而变化的。
在这种假设的情况下,来从这个“虚拟的完整”数据源获取某个分页就不再是那么简单的事情了。一个分页可能位于一个片段的内部的某个区间,也有可能会跨越多个片段。
我们举个例子,以更形象地说明上面描述的问题。
假设,我们有如下几个包含整数的有序片段:
片段A:2、3、5、8
片段B:33、34、45、51、56、78、86
片段C:9、12、14、15、18、23
片段D:90、92、97、108、127
A、B、C、D四个片段,每个片段内部包含的元素的个数都不一样,每个片段内部的整数都是有序的。我们还发现,任意两个片段的取值区间是没有交集的,如我们可以认为A的区间为[2,8],B的区间为[33,86] ,C的区间为[9,23],D的区间为[90,127]。由于任意两个片段的取值区间没有交集,所以,我们可以对这些片段的取值区间进行排序,排序的结果是:A、C、B、D。
现在我们把排序后的A、C、B、D四个片段作为一个虚拟的整体数据源,然后对其进行分页。假设PageSize为5,如果从小到大取第二页的数据应该是:12、14、15、18、23;如果从大到小取第二页的数据应该是:86、78、56、51、45。
你也许会想,这个很容易啊,我把所有片段中的数据取出来,按顺序排列好:
2、3、5、8、9、12、14、15、18、23、33、34、45、51、56、78、86、90、92、97、108、127。
现在想取第几页就可以直接取第几页了,问题解决。是吗?记得我们前面还有一个假设的需求在这里没有考虑进来,那就是每个片段内的数据还会随着时间的变化而变化的,当然,约定好的取值区间是不会变化的。比如,B片段的数据在下一时刻增加了几个数据变成这样:
片段B:33、34、40、42、45、50、51、56、62、78、83、86
片段B增加的数据都是属于其取值区间[33,86]的,所以B的取值区间并不会发生变化。
上面提到的把每个片段中的所有的数据都提取出来排列好的方法就不好用了吧,因为每查询某分页一次,就需要重新排序一次。如果我们的片段非常多,而且每个片段中的数据也非常多,那么,其效率可想而知。
我设计了ESBasic.ObjectManagement.Integration.ScatteredSegmentPicker片段整合提取器来解决类似的问题。
2.适用场合:
当满足以下条件时,你可以使用ScatteredSegmentPicker来对数据源进行分页操作。
(1)数据源由多个片段(Segment)组成。
(2)每个片段内部的数据都是有序的。
(3)任意两个片段的取值区间的交集为空。
(4)片段内的数据可能发生变化,但是其最初预定的取值区间一直是恒定的。
(5)每个片段都有唯一的ID来标志它。
(6)需要从小到大的顺序或从大到小的顺序对所有片段中的数据作为一个整体进行分页。
3.设计思想与实现
根据上述的描述看到,片段是一个核心概念,我们使用ISegment接口来抽象它,ISegment定义如下:
/// <summary>
/// ISegment 片段,一个片段有有序的多个元素TVal构成。
/// </summary>
/// <typeparam name="TSegmentID">片段标志的类型</typeparam>
/// <typeparam name="TVal">构成片段的元素的类型</typeparam>
public interface ISegment<TSegmentID, TVal>
{
/// <summary>
/// ID 每个片段的唯一标志。
/// </summary>
TSegmentID ID { get; }
/// <summary>
/// GetContent 获取片段中的所有元素,从小到大排列。
/// </summary>
IList<TVal> GetContent();
}
该接口有两个泛型参数:TSegmentID、 TVal。TSegmentID是片段ID的类型,TVal是片段中包含的元素的类型。GetContent方法返回片段中所有元素的列表,并且这个列表中的元素是从小到大的顺序排列的。
我们设计ISegmentContainer接口来提供所有的片段访问,ISegmentContainer接口定义如下:
/// <summary>
/// ISegmentContainer 用于存放片段ISegment的容器。
/// </summary>
/// <typeparam name="TSegmentID">片段标志的类型</typeparam>
/// <typeparam name="TVal">构成片段的元素的类型</typeparam>
public interface ISegmentContainer<TSegmentID, TVal>
{
ISegment<TSegmentID, TVal> GetSmallestSegment();
ISegment<TSegmentID, TVal> GetBiggestSegment();
/// <summary>
/// GetNextSegment 按照fromSmallToBig指定的顺序返回下一个Segment。
/// 如果返回null,则表示不再有后续的Segment了。
/// </summary>
ISegment<TSegmentID, TVal> GetNextSegment(TSegmentID curSegmentID, bool fromSmallToBig);
}
这个接口也有两个与ISegment一样的泛型参数,GetSmallestSegment方法和GetBiggestSegment方法用于返回取值区间最小和最大的片段。
从GetNextSegment方法的设计我们知道,ISegmentContainer不是一下子返回所有的片段列表,而是根据当前使用的片段的ID返回下一个片段。之所以这样做,是因为我们一次性获取所有片段列表的代价可能是巨大的,所以一次只返回一个必需的片段。fromSmallToBig参数的值取决于我们是按降序分页还是按升序分页。
如果不再有后续分页,GetNextSegment方法将返回null。
在做了上述这些铺垫后,接下来我们来看本节真正的主角:ScatteredSegmentPicker,正是它将所有的片段整合为一个虚拟的完整数据源,然后对其进行分页提取。
ScatteredSegmentPicker类图如下所示:
PickFromSmallToBig属性表明是要按升序还是降序来提取分页数据。Pick方法执行真正的分页提取动作,startIndex参数是指要以哪条记录作为分页的起始,pickCount参数表示要提取多少条记录,即分页的大小。
Pick方法之所以传递startIndex参数和pickCount参数,而不是传递pageSize和pageIndex,是为了更灵活地支持从任意位置开始的连续记录的提取,而且,这也兼容了分页的提取。比如,你将pageSize*pageIndex作为startIndex参数传入,将pageSize作为pickCount参数传入即是一个标准的分页获取动作。
关于ScatteredSegmentPicker的实现要注意以下几点:
(1)虽然ScatteredSegmentPicker内部实现没有使用到任何加锁机制,但是它可以被使用在多线程的环境中。
(2)ScatteredSegmentPicker的DoPick方法采用遍历策略来提取目标页的数据,PickFromSmallToBig属性决定了遍历片段的方向,是从小到大还是从大到小。
(3)如果要查询的分页不存在,则将返回一个不包含任何元素的List,而不是返回null。
4. 使用时的注意事项
(1)在使用ScatteredSegmentPicker之前,你必须根据你的应用的需求实现ISegment接口和ISegmentContainer接口,然后将ISegmentContainer实例的引用注入到ScatteredSegmentPicker。
(2)虽然我们使用ISegmentContainer来提供所需的每个片段,但是在实现该接口时,不一定非要将所有的片段都存放在ISegmentContainer这个容器中。ISegmentContainer可以作为一个片段获取器的角色从其它地方获取某个片段。比如,某个片段可能位于另外一台服务器的内存中,ISegmentContainer可以通过Remoting的方式从那台服务器获取这个片段的数据。
(3)也许你的片段中的数据不是从小到大或从大到小的顺序,而是依据另外一个性质进行排序,比如由不重要到重要,由不急迫到急迫等,这个只需要与PickFromSmallToBig做正确映射就可以正常使用ScatteredSegmentPicker了。
5.扩展
片段整合提取器 ScatteredSegmentPicker暂时没有任何扩展。
注: ESBasic已经开源,点击这里下载源码。
ESBasic开源前言