PBIR 的 C# Script 编写

此内容由人工智能翻译,尚未经过人工编辑审核。图像和图表保持其原始语言。

关键要点

  • 得益于 Power BI Enhanced Report 格式(PBIR),Power BI 的 Report 层不再是脚本的禁区。 现在你可以使用 Tabular Editor 的 C# Script 来操控 Report 和 Visual,并简化相关修改。
  • 这仍然是一个正在发展的领域。 虽然有 Sempy Labs 可以对已发布的 Report 进行一些操作,但功能仍然非常有限。 在 PBIR 文件上编写脚本时,我们基本上要从零开始。
  • 更改还不会自动显示在 Power BI Desktop 中(暂时还不行)。 潜在的使用场景很多,但要看到差异,我们需要先关闭再重新打开 Report,这在一定程度上降低了这类脚本的吸引力。

本摘要由作者撰写,并非 AI 生成。


引言

Tabular Editor 的 C# Script 功能长期以来一直是它的王牌功能之一。 它让你可以用编程方式操作模型,帮助你更高效地整理流程或自动化许多任务。 你甚至可以用它来调用 API,例如用于 格式化 Power Query使用 LLMs。 即使你不会 C#,也可以 用 LLM 来帮你编写脚本

如果你在网上搜索,会找到很多脚本:从为所选列创建 SUM 度量值,到更复杂的场景,例如 创建度量值以识别模型中的引用完整性违规,或者 添加计算组 以及 依赖模型的 DAX UDF。 但你知道现在也可以对 Report 层编写脚本了吗? 我一直在做这方面的实验;在 Tabular Editor,我们也相信更多人会从中受益。

这里有一个示例:将某一列的条件格式复制到该 Report 中的其他列:

该示例的.csx 宏代码可以在 这里 找到。 本文将通过另一个使用场景,更全面地讨论如何编写这类脚本。

在 Report 层编写脚本的潜力

大多数 Power BI 开发者都会同意:要做出一个好看的 Report,往往需要在格式窗格里点来点去。 即使你已经有一套精心配置的自定义主题(你应该有),仍然有一些操作需要大量点击,比如为一张宽表中的所有字段设置相同的条件格式——也就是我们前面看到的那个示例。

有些场景需要同时修改模型和 Report 层。 例如,在创建了上面提到的那些引用完整性度量值之后,如果脚本还能继续配置一个页面,用于精确定位到底是哪些值造成了违规,那就更好了。 幸运的是,这两个宏(以及其他一些) 已经存在,但还有更多场景值得覆盖。 那这些脚本是如何工作的? 它们会修改采用新 PBIR 格式 的 Report 元数据文件。 我们来详细看看。

为方便起见,这段视频会带你从头到尾完整走一遍流程。 它演示了如何同时对 Report 模型进行脚本化操作:

 

视频中的所有内容都会在下文中解释清楚。

什么是 PBIR,以及如何修改它?

PBIR 是一种用于 Power BI Report 的新格式。 本文不展开介绍 PBIR 格式的细节;你只需要知道,它会将 Report 的所有元素拆分成更小的 JSON 文件。 Report 的大部分定义都存储在 visual.json 文件中,每个 Visual 对象对应一个文件。 虽然还会有少量其他文件,但你对 Visual 做的任何配置,大概率都会体现在对应的 visual.json 文件中。 你可以手动修改这些文件,但既费时又不容易。 C# Script 可以帮你快速完成这类修改。 但也有一些挑战需要克服。

Report 文件在哪里?

能用脚本操作 Report 层当然很棒,但要对 PBIR 格式进行脚本处理就比较复杂。 首先,Tabular Editor 是连接到模型来工作的。但即使你想在同一个 PBIP 项目中修改该模型所关联的 Report,也没有办法在电脑上自动找到它;你需要在脚本里明确指向它的位置。 由于 PBIR 基本总是遵循相同的结构,一种可行的实现方式是指向“.pbir” 文件(位于 Report 定义文件夹根目录下的一个很小的文件),脚本就可以从那里找到其他所有相关文件。 不过,在开发脚本时每次都要指向同一个文件很折腾,所以才会有一个“最近文件”列表,就像你在本文前面视频里看到的那样。 下一节中,我们会进一步说明如何在脚本中利用这一点以及其他通用功能。

如何使用 C# 修改 JSON 文件?

接下来你会遇到的问题,是如何处理这些 JSON 文件。 在 C# 中你可以定义对象;更关键的是,C# 已经提供了原生功能,可以在 JSON 文件与对象之间相互转换。 将 JSON 转换为 C# 对象称为反序列化,英文为 deserialization;将 C# 对象转换为 JSON 文件称为序列化,英文为 serialization。 因此,我们的策略是:先将所有 JSON 文件反序列化为便于操作的对象,进行必要的修改,然后把所有被修改过的文件再序列化回去。

B001 Figure 2 - Diagram showing three-step C# workflow for working with JSON files: deserialize JSON to C# objects, modify the objects in C#, then serialize back to JSON

当然,你定义的对象结构必须与 JSON 文件中实际出现的内容一致。 麻烦也正是从这里开始。 尽管这些文件的架构在技术上是公开的,但它非常复杂(而且分散在很多地方),要创建一个能够覆盖所有可能的 Visual 对象定义的类非常困难。 针对这个用例,我们将利用一个公开的 repository 作为起点。

当你把 JSON 文件解析成 C# 对象后,就可以像操作模型一样对它进行各种处理。

注意

对象的复杂性极高,这意味着 Tabular Editor 2 所使用的 C# 版本并不够用。 好在你可以配置不同的编译器,从而在代码中使用更高级的表达式。 在 Tabular Editor 3 中则不需要额外配置。

要保存还是不保存?

当 Power BI Desktop 处于打开状态时,如果你修改了 PBIR 的 Report 格式中的 visual.json 文件,你看到的 Report 不会立刻更新这些更改。 为避免关闭 Power BI Desktop 时不确定是否需要保存而产生困惑,最稳妥的做法是:

  1. 运行脚本前,先保存你的工作并关闭 Power BI Desktop。
  2. 在 TE 中直接从 model.bim 或 TMDL 打开模型,然后执行脚本。
  3. 脚本完成后,再打开 Power BI Desktop 查看 Report 中的更改。

最后但同样重要的是,既然我们已经在使用 PBIP 格式,确保你已经初始化本地 Git repository,并在执行脚本前提交所有更改。 这是核对更改是否符合预期的最佳方式;如果发生意外,也最容易回退。 这对任何脚本都适用,但对会修改 Report 层的脚本尤其如此。 对模型的修改可以用 Ctrl+Z 撤销,但对 PBIR 文件的修改则不行。

提示

执行脚本时,尤其是执行会修改 Report 层的脚本时,记得在 Git 里检查这些更改。

用例:存储并应用自定义显示名称

为了展示前面几节所描述的内容,我们来深入一个用例:有时在 Report 中,我们希望使用与度量值和列的实际名称不同的显示名称。 原因可能有很多,例如我们想用同一个名称展示不同的度量值;又或者在模型中需要用较长的名称说明含义,但在 Visual 对象里并不需要这么详细的信息。

一般做法是先在某个 Visual 对象里手动设置名称,随后才发现还得在 Report 的其他位置重复这些设置。 如果我们能把显示名称存到列或度量值的属性里,然后在需要的地方自动应用这些名称,那就太好了。 现在,我们可以做到。 开始吧。 我们会创建一个脚本,用来从一个或多个 Visual 中保存显示名称;再创建另一个脚本,把这些显示名称应用到一个或多个 Visual。

注意

在这个用例中,我们会使用这篇博客文章里介绍的代码编写环境设置。 这个解决方案包含一些类,用于通用功能、Report 的反序列化以及 Report 的操作。 这个设置包含一个宏,用于将 Visual Studio 或 VS Code 中定义的任意脚本复制到 Tabular Editor。 它会追加运行脚本所需的自定义类,以及其他一些小改动。

存储显示名称

在这个用例中,我们需要同时处理 Report 和模型。 脚本会在连接到模型时运行,所以模型这边不需要做任何额外操作。 不过对于 Report,我们需要先选中它,这样脚本才能把 JSON 文件反序列化为更易于处理的 C# 对象。

我们会用到 GeneralFunctions 类的方法,以及 ReportFunctions 的方法。 为了在 Visual Studio 或 VS Code 中编写代码时标记这个依赖关系,我们需要在方法顶部加入以下两条注释:

//using GeneralFunctions; 
//using Report.DTO;

这两条注释会在把代码复制到 Tabular Editor 时由宏移除;它们只适用于我们当前使用的这套编写环境配置。 它们是一个标记,用来让宏获取自定义类的代码,并把它追加到脚本末尾。

Report 的初始化流程始终一致,我们可以直接复用 ReportFunctions 类中已经定义的方法。 该方法会解析 pages.json、所有 page.json 文件、所有 visual.json 文件,并把所有对象组织到一个大的 Report 对象中。 它还包含一些对话框,用于选择最近打开的 Report,或者选择一个新的 Report。

// Step 1: Initialize report
ReportExtended report = Rx.InitReport();
if (report == null) return;

接下来,我们让用户选择 Report 中的一个或多个 Visual,作为来源,以导入其中配置的任意自定义显示名称。

// Step 2: Let user select visuals
IList<VisualExtended> selectedVisuals = Rx.SelectVisuals(report);
if (selectedVisuals == null || selectedVisuals.Count == 0)
{
    Info("No visuals selected.");
    return;
}
提示

最佳实践是尽量避免未处理的异常。 只要存在用户交互,就要考虑用户可能直接关闭对话框,而没有做出有效选择。 一个好习惯是始终做检查:如果对象未初始化,就提示用户并中止执行。 Rx.InitReport 的代码里已经包含了信息,因此这里只需要中止执行即可。 如果执行能继续,就意味着所有变量都已正确初始化。

到这里,就可以遍历选中的 Visual,看看字段是否在其中定义了自定义名称。 首先,我们需要查看实际的 JSON 文件,确认这些信息存放在哪里。

{
  "$schema": "https://developer...",
  "name": "5715...",
  "position": {...},
  "visual": {
    "visualType": "clusteredColumnChart",
    "query": {
      "queryState": {
        "Category": {
          "projections": [...]
        },
        "Y": {
          "projections": [...]
        }
      }
    },
    "objects": {...},
    "drillFilterOtherVisuals": true
  }
}

构成一个 Visual 的字段位于不同的 “queryState” 元素中,例如 “Category”、“Y”(其他 Visual 还会有不同类型)。 它们的内容结构都很相似。 它们可能是 “Projections”,也可能是 “Field Parameters”。 在我们的用例里,我们不关心 Field Parameters。 因此,为了遍历所有可能的投影,我们可以编写如下代码:

foreach (var visual in selectedVisuals)
{
    var queryState = visual.Content?.Visual?.Query?.QueryState;
    if (queryState == null) continue;
 
    // Create list of all projection sets to iterate
    var projectionSets = new List<VisualDto.ProjectionsSet>
    {
        queryState.Values,
        queryState.Y,
        queryState.Y2,
        queryState.Category,
        queryState.Series,
        queryState.Data,
        queryState.Rows
    };
 
    // Iterate through each projection set
    foreach (var projectionSet in projectionSets)
    {
foreach (var projection in projectionSet.Projections)
{ ... }
    }
}
注意

成员访问表达式中的问号 ? 在 Tabular Editor 2 默认使用的 C# 版本中不可用,而替代写法会非常冗长。 要执行包含这类表达式的脚本,你需要按本文前面所述配置 Roslyn 编译器,或在 Tabular Editor 3 中执行该脚本。

Projections 可以是 “Field”,也可以是 “Visual Calc”。 我们只关心 Field,因为它们存在于模型中,并且可以在不同的 Visual 之间复用。 对于每个字段,我们想检查它们是否配置了“displayName”,如果有,就使用“Entity”和“Property”(即 字段名)定位到模型,并将显示名称存储到那里。

{
  "field": {
    "Measure": {
      "Expression": {
        "SourceRef": {
          "Entity": "Sales"  <-------- Table Name
        }
      },
      "Property": "Sales Amount" <---- Measure Name
    }
  },
  "queryRef": "Sales.Sales Amount",
  "nativeQueryRef": "Sales",
  "displayName": "Sales"  <---------- Display Name
}

此时,代码会变得更复杂一些。 脚本会先创建一个结构,用来存储每个字段可能找到的所有显示名称,因为代码允许选择多个 Visual 对象进行分析。 然后,如果同一个字段找到了多个不同的显示名称,宏会要求用户选择要存储的那个显示名称。

当我们拿到一份干净的键值对列表(字段/度量值 – 显示名称)后,脚本最终会将选定的显示名称以注释的形式写入模型:

foreach (var kvp in measureDisplayNames)
{
    var measure = Model.AllMeasures.FirstOrDefault(
        m => m.Table.DaxObjectFullName + m.DaxObjectFullName == kvp.Key);
    if (measure != null)
    {
        if (measure.GetAnnotation("DisplayName") == kvp.Value) continue;
        
        measure.SetAnnotation("DisplayName", kvp.Value);
        measuresUpdated++;
    }
}

注释是给模型中任何元素存放额外信息的理想位置,比如本脚本中的显示名称。 注释不会对模型的任何行为产生功能性影响,它更像是一个方便存放元数据的地方。

脚本执行完后,记得保存模型里的更改,这样以后就能把这些显示名称应用到任何关联的 Report 上。

注意

无论 Report 是否连接到模型,脚本都会执行;但如果使用未连接到模型的 Report,可能会导致意外结果。

你可以在这里找到脚本。 来看一下实际效果:

应用显示名称

下面我们来看看如何构建第二个脚本。 和前一个脚本一样,我们需要指向要修改的 Report。 如果是同一个 Report,它会显示在文件选择器的“最近使用的 Report”列表顶部,直接点击“确定”即可。

在遍历每个 projection 之前,代码都非常相似。 这里会检查模型中的字段和 Report 中的字段是否配置了显示名称。 如果它在模型中找到了显示名称注释,并且 Visual 中未定义自定义显示名称,或定义的名称不同,那么它会将模型中存储的显示名称应用到该 Visual 对象上。

if (projection?.Field == null) continue;
 
string displayNameFromModel = null;
 
// Check if it's a measure
if (projection.Field.Measure != null)
{
    var measureExpr = projection.Field.Measure;
    if (measureExpr.Expression?.SourceRef?.Entity != null 
        && measureExpr.Property != null)
    {
        string fullName = String.Format(
            "'{0}'[{1}]", 
            measureExpr.Expression.SourceRef.Entity, 
            measureExpr.Property);
        
        var measure = Model.AllMeasures.FirstOrDefault(
            m => m.Table.DaxObjectFullName 
                + m.DaxObjectFullName == fullName);
 
        if (measure != null)
        {
            displayNameFromModel = 
                measure.GetAnnotation("DisplayName");
        }
    }
}
// Check if it's a column
else if (projection.Field.Column != null)
{  …… }
 
// Apply display name if found in model
if (!string.IsNullOrEmpty(displayNameFromModel))
{
    // Check if projection already has a display name
    if (string.IsNullOrEmpty(projection.DisplayName) 
        || projection.DisplayName != displayNameFromModel)
    {
        projection.DisplayName = displayNameFromModel;
        updatedCount++;
        visualModified = true;
    }
}

这是在迭代单个 Visual 的每种 projection 类型时执行的代码。 这里的重点是:要标记该 Visual 是否发生过任何修改。 这是通过语句 visualModified = true 来实现的。 然后,在完成对单个 Visual 的所有 projection 的迭代之后,会执行这段代码:

// Save visual if it was modified
if (visualModified)
{
    Rx.SaveVisual(visual);
    visualsUpdated++;
}

同样,这里我们复用了 Report Functions 类中定义的方法,它包含了将 C# 对象内容重新序列化回 visual.json 文件所需的代码。 在这里我们可以统计有多少个 Visual 对象被修改,以便在最后告知用户。

完整脚本请见这里。 再来看一下实际效果:

这只是一个简单示例,展示了如何在 C# Script 中读取并使用 PBIR 中的信息。 你还能做更多事情,例如查找并替换字段、自动化格式调整等。

结论

现在 PBIR 已可用,Tabular Editor 的 C# Script 不再局限于对模型的修改。 同时,修改 Report 也并非没有风险,应始终留意 Git 里显示的变更内容。

给 Report 层编写脚本比较复杂,因此建议使用本用例所示的编码环境,这样既能享受代码 IDE 的优势,也更方便在不同脚本之间复用代码。

Related articles