如何运用领域驱动设计 - 工作单元

概述

在上一篇 《如何运用领域驱动设计 - 存储库》 的文章中,我们讲述了有关仓储的概念和使用规范。仓储为聚合提供了持久化到本地的功能,但是在持久化的过程中,有时一个聚合根中的各个领域对象会分散到不同的数据库表里面;又或者是一个用例操作需要操作多个仓储;而这些操作都应该要么同时成功,要么同时失败,因此就需要为这一系列操作提供事务的支持,而事务管理就是由工作单元来提供的。在上一篇中,可能已经提到了工作单元,但是仅仅是一笔带过,现在我们就来详细的探究该如何更好的来实现工作单元。(文章的代码片段都使用的是C#,案例项目也是基于 DotNet Core 平台)。

直接看东西

在上一篇文章中,已经为大家提供了一个Github的Demo。如果已经下载过该Demo的同学,您现在直接进行Pull就可以获得最新的版本了;如果还没有下载该Demo的同学也可以戳下方的跳转链接获取。

GitHub 地址,点击直达哟

在这里我们可以先来看一下,该项目的应用代码是什么样子:

复制代码

[HttpPost]
public ActionResult<string> Add()
{//使用仓储来处理聚合_itineraryRepository.Add(new Itinerary("奥特曼","赛文奥特曼","杰克奥特曼","佐菲奥特曼","泰罗奥特曼"));_itineraryRepository.Add(new Itinerary("盖亚奥特曼","戴拿奥特曼","阿古茹奥特曼","迪迦奥特曼", ""));return "success";
}[HttpGet]
public ActionResult<long> Get()
{var count = _itineraryRepository.GetCount();return count;
}

这是在Aspnet Core的Controller中的代码,也就是对外提供的Api。可以看到我们仅仅只是通过仓储的调用就完成了所有的操作。(ps:原谅我该演示api没有遵循restful风格( ̄▽ ̄)",还有就是那些奥特曼。。。)。

您可能会说,这里没有做操作,那肯定是在 ItineraryRepository 里面做了手脚。好吧,下面我们来看看该仓储的实现。

复制代码

public class ItineraryRepository: EFRepository<UowAppDbContext, Itinerary, Guid>
{public void Add(Itinerary itinerary) => DbContext.Set<Itinerary>().Add(itinerary);
}

是的,它也只有这么一点点代码。而作为后期的业务扩展和维护,我们只需要完善我们的Itinerary聚合(为它扩展行为和增加实体或值对象)以及ItineraryRepository仓储(为它添加对外检索意图的方法)就可以了。

这种做法的好处可能您很快就能发现:在我们代码中处处都是关于领域对象的操作,尽可能的避免其它基础构建或功能支持组件来干扰程序。除了代码量的减少之外,它也让可读性有着明显的提高,如果在此基础上能够构建出明确而干净的聚合根,那么您的程序将具备更高的可扩展性。

好吧,回到我们今天的主题:工作单元。其实上面的代码就是对仓储中工作单元的巧妙运用,它其实在后面默默的支持着程序的正常运转,这是在调用层面上我们完全感觉不到它的存在而已。下面就为您介绍它是怎么工作和实现的。

什么是工作单元

按照国际管理呢,这一章节都是解读有关原著《领域驱动设计:软件核心复杂性应对之道》 中的解释。但是!!!有关工作单元的概念在书里并没有被明确的提及到。所以为了证明我们确确实实是在前人的基础理念上来实践,而不是胡编乱造自己随便弄了一个概念出来。我特地去找了另外一本较为权威的领域驱动设计教材:《领域驱动设计模式、原理与实践》 。在该书中对工作单元的解释如下:

事务管理主要与应用程序服务层有关。存储库只与使用聚合根的单一集合的管理有关,而业务用例可能会造成对多个类型聚合的更新。事务管理是由工作单元处理的。工作单元模式的作用是保持追踪业务任务期间聚合的所有变化。一旦所有的变化都已发生,则之后工作单元会协调事务中持久化存储的更新。如果在将变更提交到数据存储的中途出现了问题,那么要确保不损坏数据完整性的话,就要回滚所有的变更以确保数据保持有效的状态。

其实上文的话真的很好理解(相对于原著而言( ̄y▽, ̄)╭ )。首先我们可以得到的第一个结论:事务管理其实是应用服务层干的事。第二个结论:事务的协调管理都是由工作单元来负责的

所以,我们千万不能因为工作单元和仓储有联系就将它放置在领域层里面:事务的提供往往是由数据库管理程序来提供的,而这一类组件我们一般将它们放置在基础构架层,而领域层可以依赖于基础构架层,所以千万要注意,保持您的领域层足够干净,不要让其它的东西干扰它,也更不要将事务处理这类东西放到了您的领域层来。(这一点,您会在后期MiCake<米蛋糕>的使用中看到详细的案例)。

如何实现工作单元

实现工作单元,就是要实现仓储中的事务操作。您可能已经看到过有些实现Repository的框架,它的写法是注入一个unitOfWork,然后从uow中提取一个仓储,然后再用仓储来完成聚合根的持久化操作。类似的代码就像这样:

复制代码

var yourRepository = uow.GetRepository<yourRepository>();
yourRepository.Add(yourEntity);uow.Commit();

这样做没有一点点的问题,而且是对工作单元和仓储模式的完美实现。uow工作单元中维持了一个事务,从该工作单元中创建的每一个仓储都可以获得该事务,仓储完成了自己的操作之后,工作单元使用Commit方法告诉事务管理器,该事务完成。

复制代码

夏目去参加了妖怪的聚会,一回到家,猫咪老师就发现了它沾染了妖怪的味道


当仓储的操作沾染上了工作单元的事务,它也就受到了事务的管理

如果您喜欢这种实现模式,可以参考 threenine的Threenine.Data项目。

懒的模式

其实在刚开始,为 MiCake(米蛋糕) 选取工作单元实现方案的时候,我也打算采用这种方式。但是在思考了一天之后,我还是放弃了。因为我发现这种模式在完成每一次仓储操作的时候,必须要从工作单元中去获取。在Aspnet Core中,不得不在Controller中注入工作单元对象,然后再从该对象里面去获取仓储。这显然削弱了依赖注入所为我们提供的依赖阅读性(原本在构造函数中,我能看出我需要注入的是A仓储,但是现在我看到的只有工作单元)。

其实最重要的一点就是:我太懒啦 o_o ....。为什么每次都要去多写一个uow.GetXXXXX()。每使用一个仓储就要多写一次获取语句,我就不能好好的只使用仓储吗?所以在这个想法的强烈刺激下,我选取了另外的实现方法。

接下来,就让我们来实现最开始演示代码中的工作单元吧。哦,对了,忘记说了,无论是演示的Github Demo还是本次的博文,我们都选取了Entity Framework Core来作为数据持久组件。所以有些小伙伴会说,那我使用Dapper或者原生的ADO怎么办?其实思路都是一样的,您也可以在看了EFCore的版本后,自己写出对应的工作单元版本。如果有机会的话,欢迎在Github的Demo上直接添加,就可以提交供更多的同学参考啦。

实现思路

  • 找出当前数据库持久组件中具有事务特征的对象(比如在EF中就是DbContext)

  • 创建一个容器去容纳这些对象

  • 工作单元就是该容器的实现,它掌管了这些事务对象,并对外公布了提交事务的方法

  • 工作单元管理器负责了对工作单元的创建工作

脑袋里有了这些还比较模糊的交互对象之后,我们可以来想一下一个仓储完成添加聚合根的操作是怎么样的:

  • 在访问该API之前:使用工作单元管理器创建一个工作单元

  • 访问API中的仓储时候:构造一个事务特征对象,并开启一个事务

  • 事务开启完成之后:将该事务特征对象尝试放入到当前工作单元

  • 仓储事务操作完成后:调用工作单元的提交方法,完成事务的提交,保证仓储的数据一致。

  • 事务完成后:释放上面的各个对象

虽然步骤好像有5步,但总结下来,就是将具有事务的对象放置到工作单元中,让它去负责提交。对!就是这么简单,该方法与上面那种从工作单元中获取仓储的方法想法,它是往工作单元中提交。所以,我们此时可以构造出一个伪代码出来,大致理解它的实现:

复制代码

    //1、使用工作单元管理器创建一个工作单元using (var uow = unitOfWorkManager.Create()){//2、构造事务特征对象,开启事务并注册到工作单元RegisteTransactonFeature(DbContext);//3、执行仓储中的内容DbContext.Set<Itinerary>().Add(itinerary)//4、工作单元保存提交uow.SaveChanges();//5、dispose}

至少到目前,我们可以抽象出上面的各个对象了。
您也可以先自己尝试着想一想,每个对象接口应该实现什么功能(方法)。

复制代码

//首先是事务特征对象,它提供了事务的基本Commit和Rollback方法
public interface ITransactionFeature
{public bool IsCommit { get; }public bool IsRollback { get; }void Commit();Task CommitAsync(CancellationToken cancellationToken = default);void Rollback();Task RollbackAsync(CancellationToken cancellationToken = default);
}//然后是事务特征容器,它具有增加删除事务特征对象的方法
public interface ITransactionFeatureContainer
{void RegisteTranasctionFeature(string key, ITransactionFeature TransactionFeature);ITransactionFeature GetOrAddTransactionFeature(string key, ITransactionFeature TransactionFeature);ITransactionFeature GetTransactionFeature(string key);void RemoveTransaction(string key);
}//接下来是工作单元,它实现了事务特征容器,并且对外提供提交的方法
public interface IUnitOfWork : ITransactionFeatureContainer
{Guid ID { get; }bool IsDisposed { get; }void SaveChanges();Task SaveChangesAsync(CancellationToken cancellationToken = default);void Rollback();Task RollbackAsync(CancellationToken cancellationToken = default);
}//最后是工作单元管理器,它提供了创建工作单元的方法
public interface IUnitOfWorkManager : IUnitOfWokrProvider, IDisposable
{IUnitOfWork Create();
}

落地代码

在构建出接口之后,我们就可以写出具体的实现类了。首先是实现工作单元(UnitOfWork)对象。(由于具体代码实现较多,讲解部分只选取了核心部分,完整代码可以参考Github的项目)

复制代码

public class UnitOfWork : IUnitOfWork
{private readonly Dictionary<string, ITransactionFeature> _transactionFeatures;public UnitOfWork(){_transactionFeatures = new Dictionary<string, ITransactionFeature>();}//往容器中添加事物特征对象public virtual ITransactionFeature GetOrAddTransactionFeature([NotNull]string key,[NotNull] ITransactionFeature transcationFeature){if (_transactionFeatures.ContainsKey(key))return _transactionFeatures.GetValueOrDefault(key);_transactionFeatures.Add(key, transcationFeature);return transcationFeature;}//对外提供的保存方法,执行该方法时调用容器内所有事物特征对象的Commit方法public virtual void SaveChanges(){foreach (var transactionFeature in _transactionFeatures.Values){transactionFeature.Commit();}}
}

接下来就是与ORM框架关联最深的事务特征对象的实现了,由于我们选取了EF,所以此处应该实现EF版本的事务特征对象:

复制代码

public class EFTransactionFeature : ITransactionFeature
{private IDbContextTransaction _dbContextTransaction;private DbContext _dbContext;public EFTransactionFeature(DbContext dbContext){_dbContext = dbContext;}//设置事务public void SetTransaction(IDbContextTransaction dbContextTransaction){_isOpenTransaction = true;_dbContextTransaction = dbContextTransaction;}public void Commit(){if (IsCommit)return;IsCommit = true;//EF 事务的提交_dbContext.SaveChanges();_dbContextTransaction?.Commit();}
}

建立好了这两个对象之后,其实我们只需要一个流转过程就可以实现工作单元了。这个流程就是将事务特征对象添加到工作单元中,但是我们应该在什么时候将它添加进去呢?看过第一版Github代码的小伙伴可能知道,在仓储调用的时候就可以完成该操作。当时在第一版中,我们的实现代码是这样的:

复制代码

public class EFRepository
{protected IUnitOfWorkManager UnitOfWorkManager { get; private set; }protected DbContext DbContext { get; private set; }public EFRepository(IUnitOfWorkManager unitOfWorkManager, DbContext dbContext){UnitOfWorkManager = unitOfWorkManager;DbContext = dbContext;}public void Add(TAggregateRoot aggregateRoot){RegistUnitOfWork(DbContext);DbContext.Set<TAggregateRoot>().Add(aggregateRoot);}private void RegistUnitOfWork(DbContext dbContext){string key = $"EFTransactionFeature - {dbContext.ContextId.InstanceId.ToString()}";unitOfWork.ResigtedTransactionFeature(key, new EFTransactionFeature(DbContext));}
}

在每一次进行仓储操作的时候,都调用了一个RegistUnitOfWork的方法,来完成事务特征对象和工作单元的流转工作。但是很快您就能发现问题:EFRepository是我们实现的一个基类,以后所有的仓储操作都继承该类来完成操作,那不是每扩展一个方法,我都要在该方法中写一句注册代码?如果我忘记写了怎么办。还有一点,该注册过程并没有开启一个事务,那么事务是怎么来的呢?

那么怎么才能避免用户每一次都要去显示调用注册呢,而是让用户在不知不觉中就完成了该操作。所以我们得思考在每一个方法中,用户都一定会写的代码是什么,然后在该代码上下手。可能您已经想到了,DbContext!!!是的,每一个方法里,用户都会去写DbContext,所以我们可以在他获取DbContext的时候就完成注册操作。所以,优化后的代码就是这样的:

复制代码

public class EFRepository
{public virtual TDbContext DbContext{get => _dbContextFactory.CreateDbContext();}public void Add(TAggregateRoot aggregateRoot){DbContext.Set<TAggregateRoot>().Add(aggregateRoot);}
}

而该_dbContextFactory的实现就更简单了,他要完成的任务就是注册到工作单元并且开启事务。

复制代码


internal class UowDbContextFactory<TDbContext>
{private readonly IUnitOfWorkManager _uowManager;public UowDbContextFactory(IUnitOfWorkManager uowManager){_uowManager = uowManager;}public TDbContext CreateDbContext(){AddDbTransactionFeatureToUow(currentUow, DbContext);return wantedDbContext;}private void AddDbTransactionFeatureToUow(IUnitOfWork uow, TDbContext dbContext){string key = $"EFCore - {dbContext.ContextId.InstanceId.ToString()}";var efFeature = uow.GetOrAddTransactionFeature(key, new EFTransactionFeature(dbContext));if (IsFeatureNeedOpenTransaction(uow, efFeature)){var dbcontextTransaction = dbContext.Database.BeginTransaction();efFeature.SetTransaction(dbcontextTransaction);}}private bool IsFeatureNeedOpenTransaction(IUnitOfWork uow, EFTransactionFeature efFeature){return !efFeature.IsOpenTransaction;}
}

dbContext.Database.BeginTransaction是EF为我们提供的手动开启事务的方法。如果您尝试实现另外ORM版本的工作单元,想一下在该ORM中是怎么开启的事务。

此时,我们就已经实现了工作单元的流转了,那么还有一个问题就是:我们怎么默认去实现一个工作单元,而不是每一次都需要手动去开启并提交。

AspNet Core为我们提供了很好的拦截方法。第一种方法: 我们可以在中间件中完成,因为所有的请求都要穿过中间件,我们可以在方法到API之前就开启事务,等API访问结束后就提交事务。第二种方法: 通过IActionFilter等周期接口来完成。本案例选取了第一种实现方法,您也可以根据您自己的爱好选取自己的实现方式。

缺陷

到这里我们已经实现了像上面Demo版本的工作单元,但是该工作单元其实还有许多特性没有实现:

  • 一个业务操作(一个API)中没有创建多个工作单元的能力

  • 目前事务的操作来源于EF Core的支持,如果项目存在多种数据访问方式(比如一个EF,一个ADO),它们之间如何依靠工作单元来完成事务

  • 没有识别什么时候需要开启工作单元,如果一个操作仅仅需要获取数据,其实我们是不需要开启工作单元的

不过如果您的项目仅仅使用了一种ORM框架并且只需要开启一个工作单元,那么可以尝试使用该实现。

在实现MiCake真正的工作单元中,我尝试了很多方法来解决上面的问题。在后面的文章中,您也会看到MiCake真正的工作单元。

附上一个当时写工作单元的手记( ̄︶ ̄)↗

总结

本来这篇文章不打算写在《如何运用领域驱动设计》这个系列的,但是后来纠结了一下,还是纳入了该系列。由于该篇文章是实现工作单元的,所以代码量就比较大,希望不会给您造成阅读上的困难。下一篇的文章,是一个谈了很久的问题————持久化值对象,现在终于是时候该解决它了。在本次Demo中您看到的聚合根Itinerary所有的属性都是string,很显然这是不符合常理的,所以在下一次就要让它成为真正的领域对象。(ps:改成真正的领域对象后,感觉都可以单体DDD应用落地了呢。( ̄︶ ̄)↗醒醒!少年。)为了您不错过下一篇文章的内容,您也可也点击博客园右上角的关注,这样就能及时收到更新了哟。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/312209.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

C++string容器-字符串比较

string字符串比较 功能描述: 字符串之间的比较 比较方式&#xff1a; 通常用来比较两个字符串是否相等 函数原型&#xff1a; 代码如下&#xff1a; #include <iostream> using namespace std; #include <cstring> //string字符串比较void test01() {string…

国产自主研发编程语言火了,同行十二年,不知Python是木兰?

1月17号有媒体称&#xff0c;一款面向智能物联网和编程教育的号称自主研发语言横空出世&#xff0c;名为【Mulan 】。消息一出&#xff0c;引起了众多争议。但有知乎网友发现&#xff0c;下载的程序包解包后——是个 Python&#xff1f;何为木兰&#xff1f;1月17号&#xff0c…

C++string容器-字符存取

string中单个字符存取方式有两种 代码如下&#xff1a; #include <iostream> using namespace std; #include <cstring>//string 字符存取 void test01() {string str "hello";cout << "str " << str << endl;for (int i…

有了容器为什么kubernetes还需要Pod?

简介容器并不是软件开发的银弹&#xff0c;没有任何一种技术能解决软件开发中的所有问题当我们采用容器化技术的时候&#xff0c;摒弃了传统的物理机或者虚拟机的部署方式&#xff0c;以一种更加轻快&#xff0c;便捷的方式来部署我们的应用。到容器化的进阶&#xff0c;再加上…

C++string容器-插入和删除

string插入和删除 功能描述&#xff1a; 对string字符串进行插入和删除字符操作 函数原型&#xff1a; #include <iostream> using namespace std; #include <cstring>//字符串 插入和删除 void test01() {string str "hello";str.insert(1, "…

.NET绘制旋转太极图

前言我之前发了一篇《 用.NET写“算命”程序》的文章&#xff0c;但有人纷纷提出了质疑&#xff0c;认为没有“科学”&#xff08; mi xin&#xff09;依据????。所谓“太极生两仪&#xff0c;两仪生四象&#xff0c;四象生八卦&#xff0c;八卦定吉凶&#xff0c;吉凶生大…

.NET CORE(C#) WPF 抽屉式菜单

.NET CORE(C#) WPF 抽屉式菜单阅读导航本文背景代码实现本文参考源码1. 本文背景使用简单动画实现抽屉式菜单2. 代码实现使用 .NET CORE 3.1 创建名为 “AnimatedColorfulMenu” 的WPF模板项目&#xff0c;添加1个Nuget库&#xff1a;MaterialDesignThemes&#xff0c;版本为最…

C++string容器-子串获取

string子串 功能描述&#xff1a; 从字符串中获取想要的子串 函数原型&#xff1a; 代码如下&#xff1a; #include <iostream> using namespace std; #include <cstring>//string求子串void test01() {string str "abcdef";string subStr str.sub…

我的 .NET Core 博客性能优化经验补充

点击上方蓝字关注“汪宇杰博客”导语去年年底我写了一篇《我的 .NET Core 博客性能优化经验总结》&#xff0c;但后来还发现有一处遗漏需要补充。我们一起来看看~牺牲空间换时间我们知道软件设计只有高手才能做到又小又快&#xff0c;像我这种普通程序员通常只有两种方案&#…

使用 OAS(OpenAPI标准)来描述 Web API

无论哪种类型的Web API, 都可能需要给其他开发者使用. 所以API的开发者体验是很重要的. API的开发者体验, 简写为 API DX (Developer Experience). 它包含很多东西, 例如如何使用API, 文档, 技术支持等等, 但是最重要的还是API的设计. 如果 API 设计的不好, 那么使用该API构建的…

dedemodule.class.php,DEDECMS5.7模块/模块管理列表显示空白问题解决方法

DEDECMS5.7模块/模块管理列表显示空白(站长基地配图)今天站长基地升级至dedecms最新版本&#xff0c;进入后台意外的发现&#xff0c;模块/模块管理一片空白&#xff0c;但有没有及时备份&#xff0c;于是赶紧去网上找寻解决办法&#xff0c;经过整理&#xff0c;大致有以下几种…

dotNET Core 中怎样操作AD(续1)

在之前的文章《dotNET Core 中怎样操作 AD&#xff1f;》中主要以AD的数据同步到数据库的场景来描述了在 dotNetCore 中怎样操作AD&#xff0c;本文将继续介绍一些在 dotNetCore 中操作 AD 的其他常用操作。环境dotNET Core&#xff1a;3.0Novell.Directory.Ldap.NETStandard2_…

【Magicodes.IE 2.0.0-beta1版本发布】已支持数据表格、列筛选器和Sheet拆分

为了更好的完善Magicodes.IE&#xff0c;春节期间我们会进行一次大的重构。由于精力有限&#xff0c;急缺文档和翻译&#xff08;将文档翻译为英文文档&#xff09;支持&#xff0c;诚邀各位加入。同时在功能方便也做了相关规划&#xff0c;有兴趣的朋友可以参与提交PR。https:…

Mbp,一个用于学习.net core的开发框架

Mbp(https://github.com/mbpframework/Mbp)是一个.net core 3的企业级web开发框架,是我个人用于学习.net core而发起的一个开源项目.这个借鉴了国外优秀开源项目abp vnext,及国内优秀开源框架Osharp的一些思想和实现.欢迎各路开发爱好者加入这个项目,一起学习,一起玩耍,共同成长…

什么?原来C#还有这两个关键字

系列介绍简介【五分钟的dotnet】是一个利用您的碎片化时间来学习和丰富.net知识的博文系列。它所包含了.net体系中可能会涉及到的方方面面&#xff0c;比如C#的小细节&#xff0c;AspnetCore&#xff0c;微服务中的.net知识等等。场景您可以在下班坐地铁的时候&#xff0c;拿出…

蓝桥杯2015初赛-三羊献瑞-枚举

题目描述 观察下面的加法算式&#xff1a; 其中&#xff0c;相同的汉字代表相同的数字&#xff0c;不同的汉字代表不同的数字。 请你填写“三羊献瑞”所代表的4位数字&#xff08;答案唯一&#xff09;&#xff0c;不要填写任何多余内容。 输出 请你填写“三羊献瑞”所代表…

如何利用Serilog的RequestLogging来精简ASP.NET Core的日志输出

这是该系列的第一篇文章&#xff1a;在ASP.NET Core 3.0中使用Serilog.AspNetCore。第1部分-使用Serilog RequestLogging来简化ASP.NET Core的日志输出&#xff08;本篇文章&#xff09;第2部分-使用Serilog记录所选的端点名称[敬请期待]第3部分-使用Serilog.AspNetCore记录MVC…

net下的高性能轻量化半自动orm+linq的《SqlBatis》

一、项目介绍该项目内置单表linq操作&#xff0c;xml动态sql解析&#xff0c;词法分析&#xff0c;类型映射等功能。SqlMapper,用来处理sql与数据库操作&#xff0c;它设计的目标是支持mysql,sqlserver,sqllite,pgsql等.TypeMapper用于完成将数据库的字段类型映射到C#类型&…

如何快速融入团队(六)

作者&#xff1a;邹溪源&#xff0c;长沙资深互联网从业者&#xff0c;架构师社区特邀嘉宾&#xff01;一我总是在记忆深处探访那些拥有高效率团队的一切特征&#xff0c;并试图从纷繁复杂的记忆尘埃中找出一些共性&#xff0c;庆幸我已经习惯于通过阅读和思考来解读这些内容&a…

临近年关,修复ASP.NET Core因浏览器内核版本引发的单点登录故障

临近年关&#xff0c;咨询师提出360、搜狗急速浏览器无法单点登录到公司核心产品WD: 重定向过多。现象经过测试&#xff0c; 出现单点登陆故障的是搜狗、360等双核浏览器(默认使用Chrome内核)&#xff0c; 较新式的Edge、Chrome、Firefox均未出现此障碍。Developer tool监测不到…