此内容由人工智能翻译,尚未经过人工编辑审核。图像和图表保持其原始语言。
关键要点
- 得益于 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 文件反序列化为便于操作的对象,进行必要的修改,然后把所有被修改过的文件再序列化回去。

当然,你定义的对象结构必须与 JSON 文件中实际出现的内容一致。 麻烦也正是从这里开始。 尽管这些文件的架构在技术上是公开的,但它非常复杂(而且分散在很多地方),要创建一个能够覆盖所有可能的 Visual 对象定义的类非常困难。 针对这个用例,我们将利用一个公开的 repository 作为起点。
当你把 JSON 文件解析成 C# 对象后,就可以像操作模型一样对它进行各种处理。
注意
对象的复杂性极高,这意味着 Tabular Editor 2 所使用的 C# 版本并不够用。 好在你可以配置不同的编译器,从而在代码中使用更高级的表达式。 在 Tabular Editor 3 中则不需要额外配置。
要保存还是不保存?
当 Power BI Desktop 处于打开状态时,如果你修改了 PBIR 的 Report 格式中的 visual.json 文件,你看到的 Report 不会立刻更新这些更改。 为避免关闭 Power BI Desktop 时不确定是否需要保存而产生困惑,最稳妥的做法是:
- 运行脚本前,先保存你的工作并关闭 Power BI Desktop。
- 在 TE 中直接从 model.bim 或 TMDL 打开模型,然后执行脚本。
- 脚本完成后,再打开 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 的优势,也更方便在不同脚本之间复用代码。