【Blog.Core开源】关于实现复杂级联表关系数据迁移的思考

软件开发七年多了,最近突发奇想,想对平时开发中,经常遇到的,但是比较鸡肋的一些开发技巧和方案做个系统性的归纳和思考,比如软件开发中,到底要不要创建主外键?比如多个级联关系中,到底要不要以自增主键id为唯一标识?.

在一年前我写过一篇文章《实现业务数据的同步迁移 · 思路一》,说的就是如何针对BlogCore项目中的数据做一次迁移,这几天一直在写部门权限的业务逻辑,本地开发好后,比如添加了几个菜单和接口,然后也做了权限的分配,然后需要把数据同步到线上的数据库,同时为了让初学者看到效果,还需要生成种子数据,所以就用了之前的迁移接口,发现不太好用,主要就是之前是整个库迁移可以,要是针对最近一两天添加的权限部分,就不好处理了,所以我做了优化和改进,可以实现,针对任意的permission权限做同步迁移,包括module接口和三表关系的同步迁移。

在写迁移的过程中,我开始思考一个问题,为什么要这么复杂呢,有没有其他方案呢,这里先简单说下如果涉及到表数据迁移,特别是复杂级联表关系数据的迁移应该怎么办?

1、万能的String字符串做标识

曾经很多次,想对整个项目做一次大改,把所有的表主键都用Guid,直接用字符串来做唯一标识,然后表与表之间通过这个字符串做关联,这样数据做迁移关系的时候,就可以很好的解决自增id的问题(id不一致问题),随便导入导出就行,看起来是很不错的。

不过这是老生常谈了,主要有几个顾虑:

  1. 历史数据肯定就不能随便动了,伤筋动骨的;
  2. 全部用字符串做主键,肯定对性能会有影响;
  3. 还需要考虑一定的可读性,和下一条;

当然,我也考虑过另一个场景,还用自增id做主键,只不过增加一个字符串字段参与业务逻辑开发,id就不参与了,这种混合开发针对特定的、不是很多很复杂的表还行,但是如果都相互冗余,会加重开发的复杂度,重构也会变难,因为在更新数据的时候,还要考虑更新这个字符串标识,得不偿失。

所以到目前,我还是没有真正使用这个方案,新项目打算尝试一下。那接下来就说一下,如果全部是自增主键id做业务关联,如何实现数据的迁移。

2、Blog.Core复杂表迁移实践

在Blog.Core项目中,权限关系五个表的相爱相杀,相互关联:

Modules表:存放所有的接口API列表,主键Mid;

Permission表:存放前端菜单路由列表,并且有父Pid和接口Mid;

ModulePermission关系表:可以做多对多(目前用不到,舍弃);

Role表:存放所有角色列表,主键Rid;

RoleModulePermission表:三表主键关系表;

平时我们本地有一个测试数据库,然后开发好后,会导出一份数据,无论是Sql还是Json都是可以的,需要导入到生产数据库中,那本地设计的配置的那些id就鸡肋了,因为两个库肯定都经过风吹日晒,不同步了,直接用导出的mid、pid、rid来导入到数据库的话,肯定会存在问题,所以这种方案直接pass掉了,除非你的库是新的。

我的方案就是通过代码的方案,用树的形式,导入,这样用新的pid做关系键就能实现目的。

1、初始化菜单树 InitPermissionTree

从表结构可以看出来,我们的接口Module是来服务菜单的,没有菜单,要接口也是无用的,所以迁移的核心就是菜单树,那首先需要做的就是初始化这个菜单树,很常见的思路就是采用递归的方案。

private void InitPermissionTree(List<Permission> permissionsTree, List<Permission> all, List<Modules> apis)
 {
     foreach (var item in permissionsTree)
     {
         // 给子节点赋值
         item.Children = all.Where(d => d.Pid == item.Id).ToList();
         // 给接口信息赋值
         item.Module = apis.FirstOrDefault(d => d.Id == item.Mid);
         // 向下递归
         InitPermissionTree(item.Children, all, apis);
     }
 }

过程很简单的,这样就得到一个完整的,包含子节点和接口模型的一棵大树。

2、过滤菜单树 FilterPermissionTree

有了完整的树,我们可以直接导入,但是这适用于第一次,如果后期每次小改动,这样都全部导入是不对的,就需要对菜单做个过滤。

测试库中,为了测试,肯定会有一个范围,比如id≥122的是这次业务开发的,所以就需要做个过滤,那我们就需要对刚刚的那个大树,做个过滤,只保留这个分支的就行,当然还是从主干开始的:

// 找到当前分支的根节点
   private void FilterPermissionTree(List<Permission> permissionsAll, List<int> actionPermissionId, List<int> filterPermissionIds)
   {
       actionPermissionId = actionPermissionId.Distinct().ToList();
       var doneIds = permissionsAll.Where(d => actionPermissionId.Contains(d.Id) && d.Pid == 0).Select(d => d.Id).ToList();
       filterPermissionIds.AddRange(doneIds);

       var hasDoIds = permissionsAll.Where(d => actionPermissionId.Contains(d.Id) && d.Pid != 0).Select(d => d.Pid).ToList();
       if (hasDoIds.Any())
       {
           FilterPermissionTree(permissionsAll, hasDoIds, filterPermissionIds);
       }
   }

 // 筛查要操作的核心分支 
 List<int> filterPermissionIds = new();
 FilterPermissionTree(permissionsAllList, actionPermissionIds, filterPermissionIds);
 permissions = permissions.Where(d => filterPermissionIds.Contains(d.Id)).ToList();

3、开始保存菜单球 SavePermissionTreeAsync

上边咱们准备好了数据,接下来就把菜单树保存下来,顺便也把菜单附属品的接口Module做保存,这块代码就稍微多了些,主要通过递归的方式,因为是一棵树,要注意的就是,以前保存过的,肯定不要再保存了,只需要获取id就行,注意的是需要开启事务哟,这里巧用了读写分离的方案,具体的详细内容可以参考这个文章《实现业务数据的同步迁移 · 思路一》:

private async Task SavePermissionTreeAsync(List<Permission> permissionsTree, List<PM> pms, int permissionId = 0)
{
    var parendId = permissionId;

    foreach (var item in permissionsTree)
    {
        PM pm = new PM();
        // 保留原始主键id
        pm.PidOld = item.Id;
        pm.MidOld = (item.Module?.Id).ObjToInt();

        var mid = 0;
        // 接口
        if (item.Module != null)
        {
            var moduleModel = (await _moduleServices.Query(d => d.LinkUrl == item.Module.LinkUrl)).FirstOrDefault();
            if (moduleModel != null)
            {
                mid = moduleModel.Id;
            }
            else
            {
                mid = await _moduleServices.Add(item.Module);
            }
            pm.MidNew = mid;
            Console.WriteLine($"Moudle Added:{item.Module.Name}");
        }
        // 菜单
        if (item != null)
        {
            var permissionModel = (await _permissionServices.Query(d => d.Name == item.Name && d.Pid == item.Pid && d.Mid == item.Mid)).FirstOrDefault();
            item.Pid = parendId;
            item.Mid = mid;
            if (permissionModel != null)
            {
                permissionId = permissionModel.Id;
            }
            else
            {
                permissionId = await _permissionServices.Add(item);
            }

            pm.PidNew = permissionId;
            Console.WriteLine($"Permission Added:{item.Name}");
        }
        pms.Add(pm);

        await SavePermissionTreeAsync(item.Children, pms, permissionId);
    }
}

4、新Pid和Mid保存,并迁移

之前咱们说过,迁移最大的痛点,就是关系id已经发生了变化,那咱们就需要在保存的时候将id做个备份记录:

public class PM
 {
     public int PidOld { get; set; }
     public int MidOld { get; set; }
     public int PidNew { get; set; }
     public int MidNew { get; set; }
 }

【Blog.Core开源】关于实现复杂级联表关系数据迁移的思考

然后在RoleModulePermission关系表中,就需要获取到这个新的值,做循环保存即可,这里就不需要递归了。

【Blog.Core开源】关于实现复杂级联表关系数据迁移的思考

到这里就完全迁移完成了,感兴趣可以试试吧!