V2rayN 加载所有节点去重时 不要影响原来的各分组订阅

问题

上一篇修改加载节点列表后自动去重, 最后发现一个问题. 点击 "所有" 以后, 调用 节点去重 功能, 然后再回到单独的分组订阅里面, 有节点被去掉了.

举个例子,
如果 订阅A里面有节点 1 2 3 4 5, 订阅B里面有节点3 4 5 6 7. 那么全部里面是1 2 3 4 5 3 4 5 6 7, 再去重.
再去看订阅A和订阅B里面就会有节点被去掉了, 比如, 订阅B里面节点 3 4 5 没有了, 只剩节点 6 7 了.

说具体一点, 本来订阅节点是这样的, 订阅A里面有节点 1 2 3 4 5, 订阅B里面有节点3 4 5 6 7. 

两列表格:订阅A含节点1至5,订阅B含节点3至7

那么点击 "所有" 之后, 我们会得到 1 2 3 4 5 3 4 5 6 7 . 再去重就会得到这样:

合并后的节点列表1至7,与订阅A、订阅B的对应关系

然后再去分别查看 订阅A 和 订阅B, 就成了这样:

去重后订阅A仍是节点1至5,订阅B只剩节点6和7


原理

查阅代码, 作者是把所有的订阅的节点都放在一个总的节点列表里面 (实际上是放在一个数据库里面), 也记载了 这个节点是属于哪个分组订阅的.

界面上需要显示的时候, 就根据你选择的哪个分组, 还有你设置的服务器过滤器, 从这个总列表中把一部分节点过滤出来.

去重操作时, 那些节点就从这个总列表里被删掉了. 当然之后再选择单独分组的时候, 就加载不出来了.


思路

所以我们的思路就是, 当加载节点自动去重的时候, 不要操作底层的那个总列表. 

上一篇教程的最后, 我们的修改成果是这样:

SubSelectedChanged调用RefreshServers,并执行节点去重

我们进到 RefreshServers 函数的内部去看看.

Ctrl + F12 或右键菜单 转到实现.

右键菜单里用转到实现或Ctrl+F12进入函数内部

里面大致是这么几部分.

RefreshServers从数据库读出lstModel,处理后赋值给lstProfile

RefreshServers末尾调用RefreshServersMenu,更新系统栏菜单


所以我们要做的就是在读取了节点列表 lstModel 之后, 进行去重处理, 再执行后面的步骤去显示.


具体实践

我们怎么做呢? 把 SubSelectedChanged 里面 RefreshServers(); 这个函数调用删除, 换成上面我们分析的这些代码, 然后再在中间插写一段给 lstModel 做节点去重的逻辑吗?

从逻辑上说, 这样是可行的, 也是正确的. 

但是在编程开发的工程实践中, 这是糟糕的, 是 代码中坏味道, 是 代码重复. 以后如果需要修改(不管是增强开发, 还是修改BUG)都会带来 霰弹枪手术 的问题.

抱歉上面这一句话掉个书袋, 我是为了给有兴趣的朋友引个路, 介绍一下好的编程学习资料.

换个说法来解释一下. 首先我们要认识到一点, 没有代码是不会在未来修改的. 一是人不可能不犯错误, 二是程序的功能会发展增强.

如果我们把相同功能的代码到处拷贝, 过两个月你发现有BUG需要修改, 你还记得都拷贝到哪里去了吗? 如果有一个地方没有修改到, 就等于在程序中遗漏了BUG没有修改全. 就算你记得, 有10个地方要改, 如果改动比较大, 不是一两行的事, 你怎么保证每个地方都改正确了呢? 不会有地方手一抖, 脑子一抽, 少写了一行吗?

所以在编程开发的工程实践中, 如果有相同或相似功能的代码段, 我们总是将它们合理的安排到一个函数里面, 需要用到的时候, 以这个函数的名字来调用就可以了.

你可以 Shift + F12 搜索一下 RefreshServers 的所有引用, 看看有多少地方用到它, 如果每个地方都是把这一大段代码复制粘贴, 后果不敢想象.

右键RefreshServers,用查找所有引用看它在哪里被调用


重构

我们要做的事情是先将 RefreshServers 进行重构.

幸运的是, VS 工具提供了方便的操作.

我们选中第一行的代码, 右键菜单 - 快速操作和重构

在RefreshServers上打开快速操作和重构菜单

然后选择 提取方法

重构菜单里选择提取方法

给提取出的函数取个名字, LoadProfilelist

把提取出的函数重命名为LoadProfilelist,将更新1个文件中的2处引用

再选中剩下的代码, 同样的 提取方法, 取个名字 DisplayProfilelist

哦, 对了, 编程的世界里, 方法 和 函数 这两个术语常常就是指一段可以重用的代码, 用一个名字来指示. 我个人觉得, 如果不是在某个考试的考卷上答题的话, 你可以认为它们就是同一个东西.


重构的作用就是调整程序的结构, 但是应该不影响程序本身的功能. 所以我们现在就测试一下. 

可以把刚刚添加的调用原来的节点去重函数 RemoveDuplicateServer 先屏蔽掉. 用 // 设置为注释.

把RemoveDuplicateServer那行注释掉,先确认重构后功能不变

保存, 编译, 开始调试(或执行). 

测试, 功能和修改之前完全一样.


还记得我们本来的修改思路吗? 要在获取节点列表和显示节点列表之间插入节点去重的逻辑.

所以现在我们把 RefreshServers 换成我们刚刚重构出来的两个函数 LoadProfilelist 和 DisplayProfilelist.

RefreshServers里调用LoadProfilelist读取节点列表

这一步修改应该不影响功能. 如果你不放心, 那就编译, 执行, 测试一下.


添加 lstModel 节点去重的逻辑

之前我们使用的 RemoveDuplicateServer 会修改底层保存的节点数据, 不能用了, 我们干脆删掉.

我们原计划是在读取节点列表和显示节点列表之间插入一下节点去重的逻辑.

在读取lstModel之后、显示之前,插入节点去重的逻辑

那么这个逻辑应该怎么写呢?

我们可以学习一下原来的节点去重逻辑是怎么写的.

找到 RemoveDuplicateServer 函数的实现, 是这样的.

RemoveDuplicateServer内部调用了DedupServerList

结合英文单词的意思 猜一猜, 我们应该继续看 DedupServerList 函数的实现. 

Ctrl + F12, 转到实现, 内容逻辑如下:

DedupServerList比较节点,把不重复的放进lstKeep

我们要学习的就是中间去重的逻辑部分.

所以我们这么写

List<ProfileItemModel> lstKeep = new();
foreach (ProfileItemModel item in lstModel)
{
  if (!lstKeep.Exists(i => CompareProfileItem(i, item, false)))
  {
    lstKeep.Add(item);
  }
}

然后再把 lstKeep 用于显示.

照DedupServerList的写法,去重后交给DisplayProfilelist

编译, 结果报错了.

编译报错CS0103:当前上下文中不存在名称CompareProfileItem

这个报错的意思是说, VS 不认识 CompareProfileItem, 不知道应该应该去哪里调用代码.


怎么调用其它类里面的函数

这里我们要学习一下怎么去调用这个 CompareProfileItem.

我们发现 我们使用 CompareProfileItem 的代码在 MainWindowViewModel.cs 这个文件里. 而 CompareProfileItem 的实现代码是在 ConfigHandler.cs 这个文件里. 这是两个不同的文件.

当我们看 ConfigHandler.cs 文件的头部, 是这样子的:

CompareProfileItem位于ConfigHandler类中

所以 CompareProfileItem 函数 是位于 class ConfigHandler 这个类里面.

C# 的语法规定, 我们要用这样的方式去使用:

ConfigHandler.CompareProfileItem

调用ConfigHandler.CompareProfileItem后仍有红色波浪线

再编译, 还是报错. 说不可访问, 因为保护级别.

错误列表显示CS0122:CompareProfileItem因保护级别不可访问


public 和 private

我们又要学习新知识了. 查看 CompareProfileItem 的定义.

右键函数名,用转到定义或F12查看它的定义

可以看到, 函数名那一行代码的前面写着 private

CompareProfileItem的定义前写着private,只能在类内部使用

这个 private 意思就是 CompareProfileItem 只能在 ConfigHandler 这个类的内部使用. 

我们想在类的外部使用, 就要把 private 修改为 public.

把private改成public,让类外部也能调用CompareProfileItem

保存, 编译, 没有报错了.


Reality节点仅ShortID不同, 被去重了

经过测试, 当同一个VPS上面开VLESS的Reality节点时, 如果仅设置 ShortID 不同, 应该是当作不同的节点的. 但是在我们的 去重操作中, 被去掉了.

我们来调试一下.


在读取节点列表这一行语句上设置 断点.

在去重逻辑那一行右键,选择插入断点

还要在显示节点列表这一行语句上设置 断点. 

最终是下面这个样子. 最左边显示了一个红点, 是断点的标志.

断点分别设在LoadProfilelist和DisplayProfilelist两处调用上

调试运行. 用菜单也可以, 用快捷键 F5 也可以, 用快捷按钮也可以.

调试菜单里用开始调试或F5运行v2rayN

程序会运行到断点的时候暂停在那里. 我们可以查看运行时的变量, 也可以控制程序的运行行为.

我们在v2rayN的界面上点击一个分组订阅按钮, 这个分组订阅里面包含了 仅ShortID不同的一些Reality节点.

程序暂停在第一个断点. 黄色箭头指向的是, 当前程序正准备执行(还没有执行)的语句.

程序停在LoadProfilelist这一行,已用时间不超过112155毫秒

接下来, 让程序执行一步. 菜单也可以, 快捷键 F10 也可以.

调试菜单里用逐过程或F10,让程序执行一步

程序状态会变成这样. 

单步之后停在新建lstKeep这一行,可查看运行时变量

我们来查看程序中的变量. 查看左下角的 局部变量 窗口. 

局部变量窗口里lstModel的Count为9,lstKeep仍为空

可以看到 lstModel 右边写着 Count = 9, 意思是说这个列表中有9个元素. (这是我自己构造的9个节点数据, 你自己构造了几个节点, 这里就会是几)

接下来, 调试继续. 菜单也可以, 快捷键 F5 也可以.

调试菜单里用继续或F5,让程序跑到下一个断点

程序会继续向前执行, 被下一个断点暂停.

去重后局部变量窗口里lstModel仍为9,lstKeep的Count变成6

可以看到, 节点去重之后 的列表 lstKeep, Count = 6, 意思是里面只剩6个节点了. (我自己构造的数据, 有3个节点是只修改了 ShortID 的)

问题出在哪里呢? 问题应该出在 CompareProfileItem 函数里面. 我们去看一看.

CompareProfileItem逐项比较两个ProfileItem的配置

可以看到, 这个判断条件里面似乎没有 ShortID 的字样?


我们在 CompareProfileItem 函数刚进来的地方设置断点.

CompareProfileItem的判断条件里没有出现ShortID

让程序再次运行起来, 再点击分组订阅按钮, 让程序运行到被刚刚设置的这个断点暂停.

看 局部变量 窗口. o 和 n 是局部变量, 这种带结构的变量, 左边的箭头是可以展开的.

我们把 o 展开来看, 里面是有 shortId 的.

局部变量窗口展开节点配置,红框标出shortId字段

回想刚刚我们看过的, 判断 两个节点信息是否相同的 CompareProfileItem 函数中, 是没有使用到 shortId 参数的.

所以我们应该把 shortId 的判断添加进去. 

同时我们可以看到, 还有这么多节点参数没有判断, 比如 fingerprint, publicKey, spiderX, ... 所以这次一起添加进去. 不要以后遇到问题再改了.

在CompareProfileItem的比较条件里补上shortId


现在测试起来, 总算是没问题了.

========

完


Github 答案 https://github.com/crazypeace/v2rayN/tree/lstProfilesClearDuplicated


========

后记

其实, CompareProfileItem 函数要求的参数是 ProfileItem, 而我们丢进去的是 ProfileItemModel, 可以看到类型并不一样. 那为什么编译没有报错呢? 为什么函数能正确执行呢?

这里要用到面向对象编程的知识. 

CompareProfileItem 是从 ProfileItem 继承而来, 你可以理解为在 ProfileItem 已有的数据的基础上添加了一些额外的数据.

ProfileItemModel继承ProfileItem,多出isActive和delay

所以 CompareProfileItem 函数拿到一个 ProfileItemModel 变量丢进来的时候, 它只管能获取到 ProfileItem 定义的数据就可以了.


评论