继 PowerBI DAX MVC 设计模式 导论 引发了很多会员伙伴的询问,希望罗叔给出一个相对完整和复杂的案例来体会 MVC 架构和设计模式的作用。
本文将结合设计模式与 MVC 架构设计演示一个真实的案例:竞争交叉分析。用户任选两个对比实体,来看两个参与对比实体的某种度量值表现。例如:
- 对于办公用品大类,其中的纸张和装订机同时出现在不同类型客户的订单中的概率是怎样的?
- 对于办公用品大类,其中的纸张和装订机出现在不同地区的销售是怎样的?
- …
效果
为了更加清楚的理解这种对比,罗叔先和大家一起看看效果:
如上图所示,其功能包括:
- 分为两个对比项切片器,且该切片器按照顶部切片器(类别)进行联动;
- 交叉订单数,用于显示同时满足左右对比项交叉(同时包括)时的订单数;
- 交叉销售额按地域,用于显示按地域且同时考虑两个对比项的四种可能模式:
- 仅包括左边的选择,不包括右边的选择的订单销售额;
- 仅包括右边的选择,不包括左边的选择的订单销售额;
- 同时包括左右两边的选择的订单销售额;
- 不包括任何一边的订单销售额。
不难看出,本案例是购物篮分析的深度增强版。处于教学目的,罗叔故意增加了分析的灵活性和动态性,问题是如何实现上述的分析?
难点分析
在罗叔给出正确设计方案前,我们先一起来看看其中的难点以及你是否已经想到这些:
- 如何构建两个对比切片器?虽然数据都是产品子类别,但应该如何构建?
- 构建的两个切片器是否应该与原有模型建立关系?
- 如果构建的两个切片器与原有模型没有关系,那类别切片器如何影响这两个切片器联动?
- 如何实现交叉分析的计算?
- 如何实现四种模式下交叉销售额的计算?
对于初学者,为了让可视化效果产生联动,会构建子类别并与数据模型进行关联,这是很自然的想法,虽然这个思路确实可以实现最终效果,但这个思路是错误的。在真正的复杂项目中,这种类似交叉分析的分析主题可能会非常多,多到几十个页面甚至需要上百个度量值,如果使用这个思路,必然会使得模型变得非常复杂。
下面罗叔基于 MVC 架构设计给出标准的实现并指出我们应该遵守的设计思想和设计模式。
非侵入式设计
这里正式提出重要的设计思想:非侵入式设计。罗叔并不记得这个思路来自哪里,在 PowerBI DAX 领域,该思想由我们首次提出,其内涵为:不应该为了展现而破坏业务数据模型。
由于我们整体采用了 MVC 架构设计,在导论中我们指出数据模型包括:数据模型和视图模型,由于这里是以分析和展现为目的的,并没有引入任何新的业务逻辑,因此,我们在完全不影响数据模型的前提下完成所有设计。
视图模型
首先给出满足非侵入式设计的视图模型:
可以看出,这由三个游离的表构成,它们均由 DAX 构造,如下:
代码语言:javascript复制View.Competitor.LeftItem = VALUES( Model_Product[子类别] )
View.Competitor.RightItem = VALUES( Model_Product[子类别] )
View.Competitor.Legend =
VAR X = {
( "L1R0" , "LeftOnly" , 1 ),
( "L0R1" , "RightOnly" , 2 ),
( "L1R1" , "Both" , 3 ),
( "L0R0" , "None" , 4 )
}
RETURN SELECTCOLUMNS( X , "ID" , [Value1] , "Name" , [Value2] , "OrderBy" , [Value3] )
这三个表(或者称列表)与主数据模型(或者称业务数据模型)没有任何筛选关系,也就不会影响业务模型的计算或变更。
展现逻辑 - 交叉订单数计算
在进行图表展现时,一个最佳实践是:
- 第一步,将你希望呈现的最终效果用维度和度量值来表示,其中度量值可以是占位符;
- 第二步,实现这个度量值。
可视化大概的效果为:
现在给出这个度量值的 DAX 表达式:
代码语言:javascript复制View.Competior.SharedOrderNumber = // 共同出现的订单数
VAR vOrdersFromLeft =
CALCULATETABLE(
VALUES( Model_Order[订单ID] ) , TREATAS( { SELECTEDVALUE( 'View.Competitor.LeftItem'[子类别] ) } , Model_Product[子类别] ) )
VAR vOrdersFromRight =
CALCULATETABLE(
VALUES( Model_Order[订单ID] ) , TREATAS( { SELECTEDVALUE( 'View.Competitor.RightItem'[子类别] ) } , Model_Product[子类别] ) )
RETURN COUNTROWS( INTERSECT( vOrdersFromLeft , vOrdersFromRight ) )
其思路如下:
- vOrdersFromLeft - 将左侧切片器所选内容动态挂载到数据模型,以筛选出相应的订单集合;
- vOrdersFromRight - 将右侧切片器所选内容动态挂载到数据模型,以筛选出相应的订单集合;
- 求上述两个集合的交集的行数即可;
- 注意,在这个过程数据模型始终保持被细分或行业筛选。
展现逻辑 - 交叉销售额的计算
类似地,不同类型的交叉销售额也需要得到展现时的计算,最终效果:
按照展现的最佳实践:
- 第一步,将你希望呈现的最终效果用维度和度量值来表示,其中度量值可以是占位符;
- 第二步,实现这个度量值。
这里涉及一个图例维度,如下:
代码语言:javascript复制View.Competitor.Legend =
VAR X = {
( "L1R0" , "LeftOnly" , 1 ),
( "L0R1" , "RightOnly" , 2 ),
( "L1R1" , "Both" , 3 ),
( "L0R0" , "None" , 4 )
}
RETURN SELECTCOLUMNS( X , "ID" , [Value1] , "Name" , [Value2] , "OrderBy" , [Value3] )
该图例给出了四种可能的交叉情况,进而继续实现度量值,如下:
代码语言:javascript复制View.Competior.SalesByLegend =
VAR vLeftItem = SELECTEDVALUE( 'View.Competitor.LeftItem'[子类别] )
VAR vRightItem = SELECTEDVALUE( 'View.Competitor.RightItem'[子类别] )
// 计算当前图例
VAR vLegendItem = SELECTEDVALUE( 'View.Competitor.Legend'[ID] )
// 左右元素对应的订单集合
VAR vOrdersFromLeft = CALCULATETABLE( VALUES( Model_Order[订单ID] ) , TREATAS( { vLeftItem } , Model_Product[子类别] ) )
VAR vOrdersFromRight = CALCULATETABLE( VALUES( Model_Order[订单ID] ) , TREATAS( { vRightItem } , Model_Product[子类别] ) )
// 四种交叉的集合可能
VAR vSetL1R0 = EXCEPT( vOrdersFromLeft , vOrdersFromRight )
VAR vSetL0R1 = EXCEPT( vOrdersFromRight , vOrdersFromLeft )
VAR vSetL1R1 = INTERSECT( vOrdersFromLeft , vOrdersFromRight )
VAR vSetL0R0 = EXCEPT( ALL( Model_Order[订单ID] ) , UNION( vOrdersFromLeft , vOrdersFromRight ) )
// 对应不同图例,计算与该图例一致的交叉销售额
RETURN SWITCH( TRUE() ,
vLegendItem = "L1R0" , CALCULATE( [KPI.Sales] , vSetL1R0 ) ,
vLegendItem = "L0R1" , CALCULATE( [KPI.Sales] , vSetL0R1 ) ,
vLegendItem = "L1R1" , CALCULATE( [KPI.Sales] , vSetL1R1 ) ,
vLegendItem = "L0R0" , CALCULATE( [KPI.Sales] , vSetL0R0 ) ,
BLANK()
)
对该 DAX 表达式的注解参见上述表达式注释。
MVC 架构设计
上述设计按照非侵入式设计思想构建,在构建的过程中,我们始终是在 MVC 框架下进行的,我们整理这个框架,视图如下:
视图的展现逻辑:
视图模型:
我们再回顾一下 MVC 架构的模型如下:
不难看出这里的设计完全严格遵守了 MVC 架构设计,具体说来:
- 视图,依赖于视图模型与展现度量值;
- 视图模型,是从数据模型导出的,在展现度量值计算时,动态挂载到数据模型以产生筛选效应;
- 展现度量值,完全按照展现效果设计,将视图模型与数据模型实现动态挂载。
可以看出,这样的 MVC 架构设计与非侵入式设计思想融为一体。要实现非侵入式设计,采用 MVC 架构设计是通用的思路;而采用 MVC 架构设计便实现了非侵入式设计。
数据模型与视图模型的联动
至此,我们仍然有一个问题没有给出答案,那就是:
- 子类别来自于孤立的视图模型表;
- 类别来自于数据模型;
- 它们之间没有任何关系是如何实现联动的?
这要得益于 PowerBI 最近几个月更新所支持的用度量值控制切片器的元素,这样就具有了动态性。我们构造了一个度量值如下:
代码语言:javascript复制View.Competitor.LeftItem.Check =
IF(
CALCULATE(
SELECTEDVALUE( Model_Product[类别] ) ,
TREATAS( { SELECTEDVALUE( 'View.Competitor.LeftItem'[子类别] ) } , Model_Product[子类别] )
) = SELECTEDVALUE( Model_Product[类别] ) ,
"有效"
)
并将其置于切片器的筛选中,如下:
这个有效性由度量值给出,而该度量值是与数据模型动态计算关联的“桥梁”。
总结
罗叔正式提出 MVC 架构设计以及非侵入式设计其实已经等候多时,它需要几个 PowerBI 的构件做支撑,具体包括:
- 度量值可以用文件夹组织,用于分类;
- 切片器可以被度量值筛选,以实现视图模型与数据模型的桥接联动效应;
- 可视化元素可以被编组以实现视图级可视化元素与展现度量值的对应关系;
- 模型可以创建新的布局以区分数据模型和视图模型;
- DAX 可以驱动更多视觉元素的可视化以便形成强大的展现计算能力。
在 2019 年几个月来 PowerBI 的更新后,我们终于迎来了正式推出这套思想,并给出案例以明显体会到这种模式的优越性。
本文给出了一个基于 MVC 架构的典型案例,该案例要求复杂的展现分析,而我们的设计不但可以实现目的,还完全不影响数据模型本身,这便是我们需要的。
值得注意的是:我们在设计视图模型时,对维度的命名为:View.Competitor.RightItem,这个命名根本没有提及子类别,而子类别是蕴含在其中的,也就是说这个命名是抽象的,我们完全可以继续扩展这种设计,以实现按产品子类别分析或者其他实体(如:产品)来分析。也就是这是依赖于抽象,而不依赖于具体的,这使得我们的设计有最大化的可复用潜力。这在设计模式中叫做面向接口的设计。我们真正打开了 PowerBI DAX 通用设计模式的大门,我们会在后续的文章中不断给出通用设计模式,以使得我们的 PowerBI 设计更加完美,无懈可击。