当前位置: 首页 > news >正文

.NET 领域驱动设计:用户角色更新如何从应用服务落地到领域实体(代码拆解)

很多时候,我们写业务逻辑时会把一堆代码塞进 Service,导致它又肥又难测。下面这两段代码来自同一个功能:更新用户的角色列表。一段是应用服务层的入口,一段是实体内部的核心逻辑。把它们放在一起看,就能明白什么叫“分层不分家”。

整体长什么样

功能很简单:前端传一个用户 ID 和一组新的角色 ID,系统用这组角色全量替换用户原有的角色。如果新旧一样,就什么都不做。

两个方法分工明确:

  • 服务层(UpdateUserRoleAsync)负责接收请求、校验参数、加载聚合、提交事务

  • 实体层(UpdateUserRoles)负责真正的业务规则:计算差异、更新集合、记录操作人

第一段:服务层方法,像一个严谨的门卫

/// <summary> /// 更新用户角色 /// </summary> /// <param name="dto">更新用户角色数据传输对象</param> /// <param name="cancellationToken">取消令牌</param> /// <returns>更新后的用户实体</returns> /// <exception cref="ArgumentNullException">dto 为 null</exception> /// <exception cref="ArgumentException">用户 ID 为空 Guid</exception> /// <exception cref="KeyNotFoundException">用户不存在</exception> public async Task<SysUser> UpdateUserRoleAsync(UpdateUserRoleDto dto, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(dto); if (dto.Id == Guid.Empty) throw new ArgumentException("用户 ID 不能是空 Guid", nameof(dto)); var entity = await _repository.Queryable(s => s.Id == dto.Id) .Include(s => s.UserRoles) .FirstOrDefaultAsync(cancellationToken) ?? throw new KeyNotFoundException($"未找到 Id 为 {dto.Id} 的用户"); entity.UpdateUserRoles(dto.RoleIds, _currentUser.Id); await _unitOfWork.SubmitAsync(cancellationToken); return entity; }

它做了什么

  1. 参数校验:DTO 不能为空,用户 ID 不能是Guid.Empty。不对的东西直接挡在外面。

  2. 加载实体:用仓储查用户,顺带把已有的角色集合(UserRoles)一起加载出来。查不到就抛KeyNotFoundException

  3. 委托业务逻辑:调实体自己的UpdateUserRoles方法,把新角色 ID 和当前操作人传进去。实体内部怎么改,服务层不关心。

  4. 统一提交:工作单元提交所有改动。因为是全量替换,增删操作会在这一次提交里完成。

  5. 返回实体:调用方可以直接拿到修改后的用户,不用再查一次。

几个值得注意的点

  • 事务边界清晰_unitOfWork.SubmitAsync确保所有集合操作原子化。

  • 取消令牌传递到底层:数据库查询和提交都能响应取消请求。

  • 异常的粒度:参数错误、找不到数据分别对应不同异常,上层控制器可以据此返回 400 或 404。

第二段:实体内部方法,真正的业务主场

/// <summary> /// 全量替换当前用户的角色列表 /// </summary> /// <param name="newRoleIds">新的角色 Id 集合(会自动去重)</param> /// <param name="operatorId">操作人 Id,用于记录最后修改人</param> /// <remarks> /// 该方法会计算新旧集合的差异,执行增删操作,并更新当前实体的最后修改信息 /// 若新旧集合完全相同,则不会做任何修改 /// </remarks> public void UpdateUserRoles(IEnumerable<Guid> newRoleIds, Guid operatorId) { ArgumentNullException.ThrowIfNull(newRoleIds); if (operatorId == Guid.Empty) throw new ArgumentException("操作人 Id 不能为空", nameof(operatorId)); var newIdSet = newRoleIds.Distinct().ToHashSet(); var oldIdSet = _userRoles.Select(r => r.RoleId).ToHashSet(); if (newIdSet.SetEquals(oldIdSet)) return; var toAdd = newIdSet.Except(oldIdSet).ToList(); var toRemove = oldIdSet.Except(newIdSet).ToHashSet(); toAdd.ForEach(roleId => _userRoles.Add(new SysUserRole(Id, roleId))); if (toRemove.Count > 0) { _userRoles.RemoveAll(ur => toRemove.Contains(ur.RoleId)); } Update(operatorId); }

这段代码的核心逻辑

基于集合运算的差异更新。不直接清空再添加,而是:

  • HashSet表示新旧角色 ID 集合(自动去重,高效比较)。

  • SetEquals判断完全一致 → 直接返回,避免无意义的数据库操作。

  • Except算出需要新增和需要删除的 ID。

  • 分别操作内部的_userRoles集合。

一些有意思的细节

  • _userRoles是什么:通常是实体里的一个List<UserRole>ICollection<UserRole>,EF Core 会追踪它的变化。直接AddRemoveAll修改集合,ChangeTracker 会自动生成对应的 INSERT/DELETE SQL。

  • 为什么先加后删:顺序其实没有依赖关系。这里先执行toAdd.ForEach,再删toRemove,即使角色 ID 在新旧集合里同时出现(不可能,因为Except已经排除了交集),也不会有问题。但更常见的是先删后加或先加后删都可以。作者用了先加后删,逻辑上没问题。

  • Update(operatorId)是什么:一个内部方法,负责设置LastModifiedBy = operatorIdLastModifiedTime = DateTime.UtcNow。审计字段在这里统一更新,避免了服务层再去手动赋值。

  • 避免了一次坑:如果新旧集合完全相同,直接返回。这省了一次数据库提交(因为外层的_unitOfWork.SubmitAsync虽然会被调用,但 EF Core 的 ChangeTracker 没有检测到任何变更,提交实际是空操作,但显式 return 更清晰)。

集合差异计算示意图

两段代码是怎么配合的

层面关注点代码特征
应用服务协调资源、事务、安全异步、仓储、工作单元、异常转换
领域实体业务规则、数据一致性同步方法、集合操作、无基础设施依赖

服务层不知道“角色更新的规则”——比如要不要去重、要不要比较新旧、要不要记录修改人。它只负责把实体从数据库拿出来,调一个方法,然后保存。

实体层不知道“数据从哪里来、保存到哪里去”——它只知道自己的_userRoles集合和Update方法。这让你可以轻松地对实体做单元测试,不需要 mock 任何数据库。

这种写法的好处

  1. 可测试性new SysUser().UpdateUserRoles(…)可以直接测,不用跑集成测试。

  2. 变更隔离:以后想加一条规则“不能移除最后一个角色”,只需要改实体方法,服务层纹丝不动。

  3. 事务安全:所有改动都在同一个工作单元里,增删要么一起成功,要么一起失败。

  4. 代码阅读流畅:读服务层时不会被业务细节干扰,读实体层时不用担心事务提交。

一些可以讨论的改进点

  • 返回实体 vs 返回 DTO:服务层直接返回SysUser实体,如果上层不小心修改了它且没有再次提交,可能引发隐蔽 bug。更稳妥的做法是返回一个UserRoleDto,或者用AsNoTracking()断开跟踪。

  • 并发问题:两个请求同时更新同一个用户的角色,后提交的会覆盖前一个。可以在实体上加一个ConcurrencyStamp字段,更新时检查乐观锁。

  • 大集合的性能:如果用户角色数量巨大(比如几千个),RemoveAllContains判断会有 O(n*m) 复杂度。但角色通常不会那么多,日常场景足够。

最后

代码写得干净,不是因为用了什么花哨的设计模式,而是因为它做对了一件事:把不同职责放在不同的层次里。服务层就干服务层的活,实体层就干实体层的活,谁也不越界。

下次你写更新用户角色的功能时,不妨也试试这样拆:服务层只做管道,实体层做决策。你会发现,代码变得更容易读懂,也更容易改。

http://www.jsqmd.com/news/986412/

相关文章:

  • 华为交换机开启snmp
  • CANoe测试工程师必看:CAPL全局变量在多个Simulation Node里到底怎么用?
  • 全球供应链风险管控视角:解读一体化关务系统的核心价值 - Discorery
  • 避坑指南:MMSegmentation训练自定义数据集时,这些配置项千万别乱改(基于UperNet消融实验)
  • 优利德数字示波器代理商怎么选?价格最低≠最划算,这篇说透了 - 品牌推荐大师
  • 手把手教你快速判断搬家公司是否靠谱,为什么北京利康鸿运值得信赖? - 资讯纵览
  • N100软路由(一) 知己知彼--搞懂你家网络到底在干什么
  • 2026 昌邑厨卫屋面地下室漏水瓷砖空鼓测评:吉修匠 99.8 分五星榜首 - 吉修匠
  • 【Hermes Agent 进阶教程】彻底解决本地大模型/慢速 API 的请求超时问题
  • 别再只知EMD了!VMD、SSA、ITD算法选型指南:从原理到场景的深度解析
  • LLM推荐系统中的不确定性量化与公平性优化
  • 铲屎官必看!猫咪掉毛自救指南 - 品牌测评鉴赏家
  • 开启全局代理后网络变慢,问题出在哪
  • 大模型三类分类测评指标梳理
  • 中央重磅部署“人工智能+” 推动一二三产业向智能化跃迁
  • 寄快递怎么便宜些?这几招帮你省一半运费 - 快递物流资讯
  • 广州无证书钻石别扔!添价收免费检测估价,不压价秒到账 - 薛定谔的梨花猫
  • 2026年车库门彩涂卷厂家深度测评:如何为你的车库门项目匹配最佳方案? - 热点速览
  • 参加深信服SF-Fastgpt培训小结
  • 借助AI再次理解三次握手和四次挥手
  • 【分享】7.3 提前摸清面试官背景:为什么这不叫“套路“,叫“尊重“
  • 告别乱码!手把手教你配置VSCode的Verilog-Format插件(附GitHub下载加速方案)
  • 上海防水堵漏公司对比:晶亮 VS 传统公司,3 大维度见真章 - 热点速览
  • 绿色积分不是骗局,是太多人把它用成了骗局
  • 从‘虚短虚断’到动手搭建:我的第一个差分放大电路仿真与实测全记录(附Multisim文件)
  • 微信是怎么知道你是同一个用户的?UV统计的底层秘密
  • Verilog代码整洁之道:用VSCode+verilog-format打造你的专属格式化工作流
  • 别再手动复制了!用RStudio的sink()函数自动记录你的完整分析日志
  • 2026年贵州刺梨饮品代理商必读:从源头工厂甄别到全国招商的深度决策指南 - 年度推荐企业名录
  • 高考毕业励志图片素材 轻松搞定毕业季宣传配图