很多时候,我们写业务逻辑时会把一堆代码塞进 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)
thrownew ArgumentException("用户 ID 不能是空 Guid", nameof(dto));
var entity = await _repository.Queryable(s => s.Id == dto.Id)
.Include(s => s.UserRoles)
.FirstOrDefaultAsync(cancellationToken)
?? thrownew KeyNotFoundException($"未找到 Id 为 {dto.Id} 的用户");
entity.UpdateUserRoles(dto.RoleIds, _currentUser.Id);
await _unitOfWork.SubmitAsync(cancellationToken);
return entity;
}
Guid.Empty。不对的东西直接挡在外面。UserRoles)一起加载出来。查不到就抛 KeyNotFoundException。UpdateUserRoles 方法,把新角色 ID 和当前操作人传进去。实体内部怎么改,服务层不关心。_unitOfWork.SubmitAsync 确保所有集合操作原子化。
服务层方法流程图
/// <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)
thrownew 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 会追踪它的变化。直接 Add 或 RemoveAll 修改集合,ChangeTracker 会自动生成对应的 INSERT/DELETE SQL。toAdd.ForEach,再删 toRemove,即使角色 ID 在新旧集合里同时出现(不可能,因为 Except 已经排除了交集),也不会有问题。但更常见的是先删后加或先加后删都可以。作者用了先加后删,逻辑上没问题。Update(operatorId) 是什么:一个内部方法,负责设置 LastModifiedBy = operatorId 和 LastModifiedTime = DateTime.UtcNow。审计字段在这里统一更新,避免了服务层再去手动赋值。_unitOfWork.SubmitAsync 虽然会被调用,但 EF Core 的 ChangeTracker 没有检测到任何变更,提交实际是空操作,但显式 return 更清晰)。
集合差异计算示意图
层面 | 关注点 | 代码特征 |
|---|---|---|
应用服务 | 协调资源、事务、安全 | 异步、仓储、工作单元、异常转换 |
领域实体 | 业务规则、数据一致性 | 同步方法、集合操作、无基础设施依赖 |
服务层不知道“角色更新的规则”——比如要不要去重、要不要比较新旧、要不要记录修改人。它只负责把实体从数据库拿出来,调一个方法,然后保存。
实体层不知道“数据从哪里来、保存到哪里去”——它只知道自己的 _userRoles 集合和 Update 方法。这让你可以轻松地对实体做单元测试,不需要 mock 任何数据库。
new SysUser().UpdateUserRoles(…) 可以直接测,不用跑集成测试。
SysUser 实体,如果上层不小心修改了它且没有再次提交,可能引发隐蔽 bug。更稳妥的做法是返回一个 UserRoleDto,或者用 AsNoTracking() 断开跟踪。ConcurrencyStamp 字段,更新时检查乐观锁。RemoveAll 用 Contains 判断会有 O(n*m) 复杂度。但角色通常不会那么多,日常场景足够。代码写得干净,不是因为用了什么花哨的设计模式,而是因为它做对了一件事:把不同职责放在不同的层次里。服务层就干服务层的活,实体层就干实体层的活,谁也不越界。
下次你写更新用户角色的功能时,不妨也试试这样拆:服务层只做管道,实体层做决策。你会发现,代码变得更容易读懂,也更容易改。
(点击关注,修炼不迷路👇)
▌转载请注明出处,渡人渡己
🌟 感谢道友结缘! 若本文助您突破修为瓶颈,不妨【打赏灵丹】或【转发功德】,让更多道友共参.NET天道玄机。修真之路漫漫,我们以代码为符,共绘仙途!