从一脑门子问号到把 Table/View/Entity 一锅端:我做 D365FO Bookmark 插件的拐弯实录

从一脑门子问号到把 Table/View/Entity 一锅端:我做 D365FO Bookmark 插件的拐弯实录

IShirai_KurokoI

从一脑门子问号到把 Table、View、Entity 一锅端: 我做 D365FO Bookmark 插件的拐弯实录

那天领导让我整个小工具,给部门在多个环境 Dynamics 365 Finance & Operations 里查表、看 View、 Entity 数据的时候省点劲儿。目标很朴素,甚至有点土: 生成一个本地 HTML,输个环境 Host,点两下,直接跳 TableBrowser,或者拼好 Data Entity 的 OData 地址。之前是有一个很基础的html的,但是是同事手动维护的,而且很丑,几个项目同时推进手动弄太费劲了。

结果这一脚迈进去,好家伙,真不是我想得那么直给。

这活儿看着像“把元数据列出来”这么简单,实际上中间是一波三折,左一个坑,右一个坑,坑坑不一样,坑坑都挺抽象。尤其是当你这个东西不是跑在 IIS 里,不是跑在普通业务进程里,而是跑在 D365 开发扩展的 Visual Studio 插件上下文里,那很多“平时能用”的路子,到了这儿就开始整活儿了。

这篇就按我当时踩坑的顺序,层层扒开,讲清楚我是怎么把所有 Table、View、Entity 找全的,又是为什么最后选了现在这套实现。


先说目标: 我要的不是“差不多能查”,我要的是“尽可能实时、尽可能准”

我最开始给自己定的标准很简单:

  1. Table、View、Entity 都得能找出来。
  2. 逻辑名称和物理名称都得尽量对应上。
  3. 不能依赖那些“可能没刷新”“可能有缓存”“可能跟当前模型状态不一致”的来源。
  4. 这个插件得独立运行在开发扩展里,别额外引一堆认证库,把宿主环境整炸了。

说白了,这玩意儿不能只是“能跑”,得是“在这个诡异宿主里还能稳稳地跑”。


第一折: 我最先想到查表,结果一上来就被 SQLDictionary 和 SysTableIdTable 整不会了

刚开始我也走过最顺手的思路: 既然 Table 和 View 最终都能落到系统字典、系统表里,那我是不是直接从 SQLDictionary、SysTableIdTable 这类地方抠数据就完事儿了?

听着挺合理,是不是?

可真一上手,我就发现这路子有点虎。

问题不在于“查不到”,而在于“查出来也不一定对”。

原因很现实:

  1. 这些数据本身就不是给“当前开发态元数据精确枚举”这个场景设计的。
  2. 里面混着很多运行态、同步态、历史态、部署态的信息,数据非常凌乱。
  3. 你现在开发机上模型刚改完或者刚换了代码分支,数据库里那份信息不一定跟你当前 AOT 真同步。
  4. Table、View 的名字、标签、最终可用对象之间,并不是简单查一张系统表就能百分百拍板。

这就像你去早市买西红柿,摊主跟你说“都保熟”,你一捏一个软的,再一捏里头还是空心的。不是完全不能吃,但你真要拿它做正经菜,心里发虚。

所以我很快就把这条路否了。

不是因为 SQLDictionary 和 SysTableIdTable 没用,而是因为它们对我这个目标来说,不够“干净”,更不够“实时可信”。


第二折: 那 Entity 呢?实体列表看着现成,结果它也不靠谱

接着我又琢磨,Entity 总有现成列表吧?开发环境里不是能看实体列表么,那我是不是直接走实体列表查询就完了?

结果一试,我又搁那儿直拍大腿。

Entity 列表这个东西,最大的问题不是不好用,而是它要手动刷新。

这意味着啥?

这意味着你眼前看到的“列表”,不一定是当前最新状态。你刚新建了一个实体、改了名字、调整了公开集合名,如果那个列表没刷新,它就还搁那儿装死。

这对平时点点界面问题不大,但对插件来说特别要命。

因为插件一旦生成了不实时的数据,后面你查出来的 OData 地址就可能是旧的、错的,或者压根打不开。那同事点开以后第一反应不是“哦,实体列表缓存了”,而是“这插件是不是拉了”,“这小子又在吹牛b”。

这锅我可不背。

所以 Entity 这边我也放弃了“查实体列表”的思路,转而直接读元数据源头。


第三折: 普通元数据读取思路,在这个插件上下文里不一定能跑

走到这儿,我的想法已经很明确了: 既然系统表不够准,实体列表不够实时,那我就直接读 Metadata。

但接下来新的问题来了。

这插件不是跑在 IIS 里,也不是跑在 D365FnO 的应用业务进程里,它是个独立于 IISExpress 进程外的开发扩展插件,宿主是 Visual Studio 的 D365 开发插件。

这就导致很多“平时你在业务代码里能直接用”的元数据访问方式,到了这里就容易当场撂挑子。

比如我压根不能默认指望那种依赖 IIS 上下文的方式一定可用。你在业务侧、服务侧、甚至某些带上下文的环境里能跑通的东西,到了 VS Addin 这个宿主里,环境就不对味儿了。

所以我最后选的是 Disk Metadata,也就是从磁盘上的 metadata 目录去读。

对应的关键逻辑很清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private static IMetadataProvider TryCreateDiskMetadataProvider(Action<string> log)
{
try
{
IApplicationEnvironment applicationEnvironment = EnvironmentFactory.GetApplicationEnvironment();
if (applicationEnvironment == null || applicationEnvironment.Aos == null)
{
log?.Invoke(R("Output_GetApplicationEnvironmentNull", "GetApplicationEnvironment returned null or Aos is null."));
return null;
}

var packageDir = applicationEnvironment.Aos.PackageDirectory;
var metadataDir = applicationEnvironment.Aos.MetadataDirectory;

log?.Invoke(RF("Output_ApplicationEnvironmentObtained", "Application environment obtained. PackageDirectory: '{0}', MetadataDirectory: '{1}'.", packageDir, metadataDir));

var metadataProviderFactory = new MetadataProviderFactory();
IMetadataProvider backupMetadataProvider = null;
if (!string.IsNullOrWhiteSpace(packageDir))
{
backupMetadataProvider = metadataProviderFactory.CreateRuntimeProvider(new RuntimeProviderConfiguration(packageDir, false));
}

if (!string.IsNullOrWhiteSpace(metadataDir))
{
var diskProvider = metadataProviderFactory.CreateDiskProvider(metadataDir);
if (diskProvider != null)
{
log?.Invoke(R("Output_DiskMetadataProviderCreated", "Disk metadata provider created successfully."));
return diskProvider;
}
}

if (backupMetadataProvider != null)
{
log?.Invoke(R("Output_RuntimeProviderFallbackCreated", "Disk provider unavailable, runtime provider from storage factory created."));
return backupMetadataProvider;
}

return null;
}
catch (Exception ex)
{
log?.Invoke(RF("Output_DiskMetadataProviderFailed", "Disk metadata provider failed: {0}", ExceptionToLogText(ex)));
return null;
}
}

这里我为什么更偏向 Disk Metadata?

  1. 因为它不依赖 IISExpress 进程上下文。
  2. 因为插件运行在 VS 扩展宿主里,这种独立读取方式更稳。
  3. 因为它更贴近当前开发机上的模型文件状态。
  4. 因为对“我要列出当前开发态的 Table、View、Entity”这个目标来说,它比很多运行时入口更合适。

你要说形象点,那就是: 我本来想走正门,结果发现正门得刷工卡、验身份、看你是不是在 IIS 大院里上班;我后来直接绕到后厨,从 metadata 货架自己点货,反倒利索。


第四折: 光有 Metadata 还不够,我还得先“列名”,再“逐个读取”

我一开始还天真地以为,Metadata Provider 既然都拿到了,那是不是可以直接整出所有真正的对象,把完整内容一股脑拉出来?

事实证明,想得挺美。

在这里,不能指望直接一次性列出所有真正的 object 内容。更稳妥的办法,是先把名字列出来,再按名字逐个读取。

对应代码非常直白:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
var tableNames = provider.Tables.ListObjects(null)?.ToList() ?? new List<string>();
var viewNames = provider.Views.ListObjects(null)?.ToList() ?? new List<string>();
var entityNames = provider.DataEntityViews.ListObjects(null)?.ToList() ?? new List<string>();
var labelFileNames = provider.LabelFiles.ListObjects(null)?.ToList() ?? new List<string>();

int total = tableNames.Count + viewNames.Count + entityNames.Count + labelFileNames.Count;
int done = 0;
onProgress?.Invoke(done, total, R("Progress_EnumeratedMetadataObjects", "Enumerated metadata objects"));

int degree = Math.Max(2, System.Environment.ProcessorCount);
var options = new ParallelOptions { MaxDegreeOfParallelism = degree };
var unresolvedLabels = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
var resolvedLabels = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);

var tablesAndViewsBag = new ConcurrentBag<NameMapping>();
var entitiesBag = new ConcurrentBag<NameMapping>();

Parallel.ForEach(tableNames, options, table =>
{
AxTable t = provider.Tables.Read(table);
foreach (var mapping in BuildNameMappings(t.Label, t.Name, labelResolver, id => unresolvedLabels.TryAdd(id, 0)))
{
tablesAndViewsBag.Add(mapping);
}

int current = Interlocked.Increment(ref done);
onProgress?.Invoke(current, total, R("Progress_ReadingTables", "Reading tables"));
});

Parallel.ForEach(viewNames, options, view =>
{
AxView v = provider.Views.Read(view);
foreach (var mapping in BuildNameMappings(v.Label, v.Name, labelResolver, id => unresolvedLabels.TryAdd(id, 0)))
{
tablesAndViewsBag.Add(mapping);
}

int current = Interlocked.Increment(ref done);
onProgress?.Invoke(current, total, R("Progress_ReadingViews", "Reading views"));
});

Parallel.ForEach(entityNames, options, entity =>
{
AxDataEntityView dataEntity = provider.DataEntityViews.Read(entity);
foreach (var mapping in BuildNameMappings(dataEntity.Label, dataEntity.PublicCollectionName, labelResolver, id => unresolvedLabels.TryAdd(id, 0)))
{
entitiesBag.Add(mapping);
}

int current = Interlocked.Increment(ref done);
onProgress?.Invoke(current, total, R("Progress_ReadingEntities", "Reading entities"));
});

这一步非常关键。

因为“列名”和“读对象”是两个阶段:

  1. 先用 ListObjects 拿到一个相对可靠的对象清单。
  2. 再用 Read 按名字读取具体元数据内容。
  3. 这样能把枚举和解析拆开,控制粒度,也更容易做并行和进度汇报。

如果不这么干,直接妄图一步到位,最后多半就是一边读一边骂街: 这啥呀,这咋还不全,这咋还异常呢,这咋还卡死呢。

这叫别上来就抡大勺,先把菜码齐了再下锅,不然锅里全是动静,没有成品。


第五折: 为什么非得用 IMetadataProvider.Tables.Read 这套,而不是别的 GetTable?

到这儿可能有人会问一句: 既然都是拿 Table 元数据,为啥不用 Microsoft.Dynamics.AX.Xpp.MetadataSupport.GetTable 这类接口?

答案很简单,也很扎心:

因为这玩意儿通常得在 IIS 上下文里调。

我这个插件是跑在 D365 开发插件上下文里的,不在那个上下文里,你指望它像在应用服务里一样丝滑工作,属实有点想多了, 你一用,是也没报错,但是就跟死猪一眼,干怼不动弹。

所以这里我选 IMetadataProvider.Tables.ReadIMetadataProvider.Views.ReadIMetadataProvider.DataEntityViews.Read,本质上是为了规避宿主上下文问题。

也就是说,我不是单纯为了“代码看着统一”才这么写,而是因为:

  1. GetTable 一类方式依赖 IIS 语境。
  2. 当前 Addin 运行在 VS 宿主,不是 IIS。
  3. IMetadataProvider 这条路径更贴合当前执行环境。
  4. 同一套 Provider 也方便我把 Table、View、Entity 统一处理。

技术选型这玩意儿很多时候不是“谁更高级”,而是谁在当前场景里不抽风。

能稳定跑完完成任务,比什么都强。


第六折: 名字还不够,我还得把标签翻出来,不然用户看着跟天书似的

当我把 Table、View、Entity 名字都列出来之后,又冒出来另一个问题: 很多对象在 Metadata 里拿到的是标签引用,不是直接的可读名称。

比如一个 Label 可能长这样: @XXX123

你要是直接把这玩意儿扔给用户,那用户看完第一反应就是: 这啥玩意儿,CPU 序列号啊?

所以我又顺手把 Label File 也一并列出来、读取、解析,构建了一个标签解析器。

对应代码是这么一套,它不是只看对象名,而是把英文标签文件也捎上,一起解析成用户能看懂的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
private static Func<string, string> TryCreateLabelResolver(IMetadataProvider provider, List<string> labelFileNames, ConcurrentDictionary<string, string> map, Action<string> log, Action onLabelFileProcessed)
{
try
{
var labelNames = labelFileNames ?? new List<string>();
int totalLabelFiles = labelNames.Count;
log?.Invoke(RF("Output_LabelParsingStarted", "Label parsing started. Label files: {0}", totalLabelFiles));

int parseDegree = Math.Max(16, System.Environment.ProcessorCount * 6);
var parseOptions = new ParallelOptions { MaxDegreeOfParallelism = parseDegree };

Parallel.ForEach(labelNames, parseOptions, labelFileName =>
{
try
{
AxLabelFile labelFile = null;
try
{
labelFile = provider.LabelFiles.Read(labelFileName);
}
catch (Exception ex)
{
log?.Invoke(RF("Output_ReadLabelFileMetadataFailed", "Read label file metadata failed '{0}': {1}", labelFileName, ExceptionToLogText(ex)));
}

if (labelFile != null && IsEnglishLanguage(labelFile.Language))
{
var localPath = labelFile.LocalPath();
if (!string.IsNullOrWhiteSpace(localPath)
&& localPath.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
&& File.Exists(localPath))
{
var moduleName = GetModuleNameFromLocalPath(localPath);
TryLoadLabelFile(localPath, moduleName, map, log);
}
}
}
finally
{
onLabelFileProcessed?.Invoke();
}
});
}
catch (Exception ex)
{
log?.Invoke(RF("Output_LabelResolverInitFailed", "Label resolver initialization failed: {0}", ExceptionToLogText(ex)));
}

return id =>
{
var keys = BuildLookupKeys(id);
foreach (var key in keys)
{
if (map.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}

return id;
};
}

这一步其实也进一步说明了,为什么我要走 Metadata Provider 这条路。因为我要的不只是“对象存在”,而是“对象对人友好”。

最终在 HTML 里,我保留了逻辑名和物理名映射,这样搜索的时候两边都能命中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var tableMap = {
'sales order header': 'SalesTable',
'customer invoice line': 'CustInvoiceTrans'
};

var tableItems = [
{ logical: 'Sales order header', physical: 'SalesTable', search: ('Sales order header SalesTable').toLowerCase() },
{ logical: 'Customer invoice line', physical: 'CustInvoiceTrans', search: ('Customer invoice line CustInvoiceTrans').toLowerCase() }
];

function resolvePhysicalName(inputValue, map) {
var v = (inputValue || '').trim();
if (!v) return '';
var key = v.toLowerCase();
if (map && map[key]) return map[key];
return v;
}

HTML 页面里逻辑名搜索效果

HTML 页面里物理名搜索效果


第七折: Host 为什么不用代码自动拿,非得让用户手动输一份列表?

这个问题我一开始其实也不服。

我当时想的是,环境 Host 这种信息,理论上完全可以自动化,能不能直接接 Azure 登录、调接口、把 replyUrls 或环境地址直接拿下来?

然后现实给了我一记标准的 D365 开发生态组合拳。

如果我在这个 Addin 里引入诸如 Microsoft.Identity.Client.MSAL 之类的认证库,看着是优雅了,实际上风险非常大。因为这个插件不是一个随便自娱自乐的独立 EXE,它是运行在 D365 开发插件上下文里的。

这意味着依赖版本必须和宿主环境严格匹配。

一旦你把不合适版本的库拎进来,就特别容易发生库冲突。更要命的是,这种冲突还不一定当场炸在插件功能上,它可能在你 Build 模型时用一种特别离谱的方式报复你。

比如由于认证相关依赖冲突,导致无法认证到 RDL 服务器,最后 Report 编译失败。

你本来只是想“顺手自动拿个 Host”,最后把整个开发环境的构建链子拧歪了,这不是给自己上强度么。

所以我最后做了一个看起来“没那么智能”,但实际上非常稳的方案:

  1. 插件弹窗给出命令。
  2. 用户自己跑 Azure CLI。
  3. 把 JSON 粘回来。
  4. 插件解析出 Host 列表,让用户勾选。

对应实现不是一句两句,而是整个弹窗流程都做出来了,先给命令,再让用户粘 JSON,再从里面筛 Host:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
Task<string> ShowHostInputDialogAsync(string azCommand, Action<string> writeOutput)
{
return RunOnUiWithResultAsync(() =>
{
ThreadHelper.ThrowIfNotOnUIThread();

try
{
using (var form = new Form())
{
form.Text = R("HostInputDialog_Title", "Bookmark - Paste az Result");
form.StartPosition = FormStartPosition.CenterScreen;
form.ClientSize = new Size(900, 670);
form.MinimumSize = new Size(820, 600);
form.BackColor = Color.FromArgb(244, 248, 255);
form.Font = new Font("Segoe UI", 9F);

var commandBox = new TextBox
{
Left = 24,
Top = 116,
Width = 742,
Height = 28,
ReadOnly = true,
BackColor = Color.White,
Font = new Font("Consolas", 9F),
Text = azCommand
};

var textBox = new TextBox
{
Left = 24,
Top = 182,
Width = 852,
Height = 418,
Multiline = true,
ScrollBars = ScrollBars.Both,
WordWrap = false,
BackColor = Color.White,
Font = new Font("Consolas", 10F)
};

form.Controls.Add(commandBox);
form.Controls.Add(textBox);

if (form.ShowDialog() == DialogResult.OK)
{
return textBox.Text ?? string.Empty;
}

return string.Empty;
}
}
catch (Exception ex)
{
writeOutput?.Invoke(RF("Output_HostInputDialogError", "Host input dialog error: {0}", ExceptionToLogText(ex)));
return string.Empty;
}
});
}

Task<List<string>> ShowHostSelectionDialogAsync(List<string> parsedHosts, Action<string> writeOutput)
{
return RunOnUiWithResultAsync(() =>
{
ThreadHelper.ThrowIfNotOnUIThread();

var allHosts = (parsedHosts ?? new List<string>())
.Where(h => !string.IsNullOrWhiteSpace(h))
.Select(h => h.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(h => h, StringComparer.OrdinalIgnoreCase)
.ToList();

if (allHosts.Count == 0)
{
return new List<string>();
}

return allHosts;
});
}

这个方案的技术哲学很朴素:

不是不能自动化,而是自动化的代价,在这个宿主环境里太高,甚至高到会影响整个开发链路稳定性,调试还麻烦。

这时候,“让用户多复制粘贴一步”,反而是更工程化的选择。

说得再接地气一点: 你非要在拖拉机上装赛车点火模块,理论上也不是不行,但它一旦窜了,你一地找螺丝。

Host 输入弹窗,展示 Azure CLI 命令和 JSON 粘贴框

Host 选择弹窗,多环境勾选列表


第八折: 生成 HTML 不是重点,重点是它得真能拿去用

元数据找到了,Host 也有了,剩下的就是把东西塞进 HTML 里,生成一个顺手能用的小页面。

这个页面干两件事:

  1. 根据 Host 和 Table 名,拼 SysTableBrowser 地址。
  2. 根据 Host、Entity 名和筛选条件,拼 OData 请求地址。

这部分前端脚本我也没整成花架子,而是把最核心的行为都写死在生成出来的 HTML 里,打开就能用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function getEnvironmentUrl() {
var env = document.getElementById('environment').value.trim();
if (!env) { alert('Please select or input a host.'); return null; }
if (!/^https?:\/\//i.test(env)) { env = 'https://' + env; }
if (!env.endsWith('/')) { env += '/'; }
return env;
}

function openTableBrowser() {
var url = getEnvironmentUrl(); if (!url) return;
var tableParm = document.getElementById('table').value.trim();
if (!tableParm) { alert('Please input a table name.'); return; }
var tablePhysical = resolvePhysicalName(tableParm, tableMap);
window_A = window.open(url + '?&mi=SysTableBrowser&Tablename=' + encodeURIComponent(tablePhysical), Date());
}

function getData() {
var url = getEnvironmentUrl(); if (!url) return;
var entityParm = document.getElementById('entity').value.trim();
if (!entityParm) { alert('Please input an entity name.'); return; }
var entityPhysical = resolvePhysicalName(entityParm, entityMap);
var col1 = document.getElementById('col1').value;
var operator1 = document.getElementById('operator1').value;
var val1 = document.getElementById('val1').value;
var c1 = buildCondition(col1, operator1, val1);

var endpoint = url + 'data/' + encodeURIComponent(entityPhysical) + '/';
var query = '?$top=10';
if (c1) { query += '&$filter=' + encodeURIComponent(c1); }
window_A = window.open(endpoint + query, Date());
}

我在这里故意把逻辑名和物理名都保留下来,一方面是为了搜索体验,另一方面也是为了防止用户只记得业务名称,不记得实际对象名称。

这点特别真实。

很多时候你脑子里记的是“客户发票行”这种中文或者语义化名字,真让你手敲物理名,立马开始发懵。

工具的价值,不就是在你发懵之前先替你兜一手么。

最终生成的 Bookmark HTML 页面


第九折: csproj 里输出复制策略为什么要改,只复制主 DLL?

这块也是我后来越用越烦,最后果断改掉的地方。

最开始如果这么写:

1
<OutputFiles Include="$(MSBuildProjectDirectory)\$(OutputPath)\**\*.*" />

那意思就是把输出目录下所有东西一股脑复制进 AddinExtensions。

听着挺省事,是吧?

但实际效果就是目录越堆越乱,文件一大坨,瞅着就闹心。而且这个项目本来引用的大部分库,都是从同版本环境里来的,并不需要你再整包复制过去。

所以我把它改成了只复制主 DLL:

现在实际配置是:

1
2
3
4
5
6
7
8
<Target Name="AfterBuildAction" Condition="'$(BuildingInsideVisualStudio)'=='true'" AfterTargets="AfterBuild">
<ItemGroup>
<OutputFiles Include="$(MSBuildProjectDirectory)\$(OutputPath)\**\Dynamics.Framework.Tools.AddIns.Bookmark.17.0.dll" />
</ItemGroup>
<Message Text="Copying @(OutputFiles) to $(DynamicsVSToolsHintPath)\AddinExtensions" Importance="high" />
<Copy SourceFiles="@(OutputFiles)" DestinationFiles="@(OutputFiles->'$(DynamicsVSToolsHintPath)\AddinExtensions\%(RecursiveDir)%(Filename)%(Extension)')" />
<Message Text="Copying finished" Importance="high" />
</Target>

为什么这么改?

  1. 因为依赖本身已经来自对应版本环境,不需要把一堆引用 DLL 再抄一遍。
  2. 因为全复制会导致 Addin 目录特别杂乱,后期维护和排查都难受。
  3. 因为这个插件只靠主 DLL 就能运行。
  4. 因为发布的时候也更清爽,拿一个主产物就够了。

这属于典型的“早期图省事,后期给自己埋雷”。

改完之后整个输出目录都顺眼不少,真有一种把阳台纸壳箱清出去的畅快感。

csproj 里 AfterBuildAction 只复制主 DLL 的效果


最后这一哆嗦: 为什么每次测试完还得关 VS 重开,太抽象了

写到这儿,功能其实已经差不多全了。

但是,真正折磨人的往往不是功能本身,而是测试闭环。

这个 Addin 每次测试完,如果你还想继续 Build,经常会遇到一个非常经典、非常气人、非常 Visual Studio 、非常 Microsoft 的问题:

插件 DLL 已经被 VS 自己加载了,文件被占用,新的 DLL 复制不过去。

于是你一 Build,它就开始跟你摆脸子。

你看着错误信息,心里也明白是咋回事,但还是会忍不住来一句: 不是哥们,你加载的是你,锁文件的也是你,现在你不让我复制,咋的,全宇宙都得配合你脾气呗?

最后最稳定的办法,往往还是:

  1. 测完。
  2. 关掉 VS。
  3. 重开 VS。
  4. 再 Build。

这套流程不优雅,甚至有点原始,但它有效。(其实如果把复制去了也行,就得手动搬dll,我嫌麻烦)

抽象 VS,诚不欺我。


我最后得到的结论

折腾这一圈下来,我对这类开发扩展工具的理解反而更清楚了。

真正可靠的方案,往往不是“理论上最先进”的那条,而是“在当前宿主约束下最不容易翻车”的那条。

所以这个 Bookmark Addin 最后的技术路线,其实非常工程化:

  1. 不碰 SQLDictionary 和 SysTableIdTable 这种容易把数据整乱的来源。
  2. 不依赖手动刷新的 Entity 列表。
  3. 直接走 Disk Metadata,贴近当前开发态。
  4. ListObjects,再 Read,把枚举和读取拆开。
  5. IMetadataProvider.Tables.Read 这套,绕开 IIS 上下文依赖。
  6. Host 不在插件内强上认证链路,改为用户输入和粘贴 JSON,避开依赖冲突。
  7. 发布产物只复制主 DLL,减少 Addin 目录污染。

你要说这套方案是不是最花哨的?那真不是。

但你要问它是不是在 D365FO 这种环境里足够稳、足够实用、足够少整幺蛾子的?

我可以很负责任地说: 那可太是了。


后记

现在回头看,这插件最有意思的地方,不是我最后做出了一个 Bookmark 页面,而是它逼着我重新理解了一遍 D365FO 开发工具链的边界。

有些东西你在业务代码里觉得理所当然,换个宿主就不是那回事了。

有些东西你以为“自动化一定更高级”,结果真正落到工程实践里,手动一步反而更稳。

有些坑你第一次踩的时候觉得离谱,等你踩完一圈再回头看,又会觉得: 行吧,虽然抽象,但也算讲理。

这开发过程怎么说呢。

一开始我是想省事,最后是边骂边改;中间以为自己走进死胡同,结果拐个弯又通了;等真跑起来的时候,又有一种“哎妈呀总算整明白了”的朴素快乐。

如果你也在 D365FO 的开发扩展、元数据读取、插件宿主兼容这些地方反复拉扯,希望这篇能帮你少走两步冤枉路。

要不然真容易整到最后,屏幕一关,脑瓜子嗡嗡的, 恨不得把公司笔记本嵌墙里(别弄,得赔)。

对了,高版本很多框架库要.net framework 4.8 了,4.7.2 会编译不过哦。

  • 标题: 从一脑门子问号到把 Table/View/Entity 一锅端:我做 D365FO Bookmark 插件的拐弯实录
  • 作者: IShirai_KurokoI
  • 创建于 : 2026-03-14 19:34:00
  • 更新于 : 2026-03-15 01:14:16
  • 链接: https://ishiraikurokoi.top/2026-03-14-D365FO-Bookmark/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论