MongoDB在ASP .NET中的应用与DDD实践

1. MongoDB 在 ASP .NET Core 中的集成

ASP .NET Core 应用程序与 MongoDB 的集成主要通过官方的 C# 驱动程序实现。

1.1 安装 MongoDB C# 驱动程序

首先,你需要通过 NuGet 包管理器安装官方驱动:

dotnet add package MongoDB.Driver

1.2 配置连接字符串

appsettings.json 文件中配置 MongoDB 的连接字符串和数据库名称:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "MongoDB": {
    "ConnectionString": "mongodb://localhost:27017",
    "DatabaseName": "MyApplicationDb"
  }
}

1.3 注册 MongoDB 客户端(依赖注入)

Program.cs(或 Startup.cs)中,使用依赖注入(DI)注册 IMongoClientIMongoDatabase,以便在整个应用程序中轻松访问:

using MongoDB.Driver;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

// ... other usings

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();

// Configure MongoDB client
builder.Services.AddSingleton<IMongoClient>(sp =>
{
    var configuration = sp.GetRequiredService<IConfiguration>();
    var connectionString = configuration.GetValue<string>("MongoDB:ConnectionString");
    return new MongoClient(connectionString);
});

// Configure MongoDB database
builder.Services.AddSingleton<IMongoDatabase>(sp =>
{
    var client = sp.GetRequiredService<IMongoClient>();
    var configuration = sp.GetRequiredService<IConfiguration>();
    var databaseName = configuration.GetValue<string>("MongoDB:DatabaseName");
    return client.GetDatabase(databaseName);
});

var app = builder.Build();

// ... other app configuration

1.4 定义 C# 对象模型(映射到 MongoDB 文档)

C# 对象将直接映射到 MongoDB 的 BSON 文档。你需要使用 MongoDB.Bson.Serialization.Attributes 命名空间下的属性来控制序列化行为。

关键点:

  • _id 字段: MongoDB 文档的唯一标识符是 _id,它通常是一个 ObjectId 类型。在 C# 类中,你可以用 Id 属性表示它,并使用 [BsonId][BsonRepresentation(BsonType.ObjectId)] 属性进行映射。
  • 其他字段: 其他属性会自动映射,除非你使用 [BsonElement("your_bson_field_name")] 指定不同的字段名。
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;

// 领域层 (Domain Layer) - 实体定义
// 虽然BsonId是基础设施层的概念,但为了方便,通常直接加在实体上。
// 严格DDD可以考虑在基础设施层做映射,或使用Guid作为领域Id,再在基础设施层转换为ObjectId。

public class User
{
    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string Id { get; set; } // MongoDB的_id通常是ObjectId,但在C#中常映射为string

    public string Username { get; set; }
    public string Email { get; set; }
    public string PasswordHash { get; set; }
    public List<string> RoleIds { get; set; } = new List<string>(); // 存储角色ID,引用Role实体

    // 领域方法
    public void AssignRole(string roleId)
    {
        if (!RoleIds.Contains(roleId))
        {
            RoleIds.Add(roleId);
        }
    }

    public void RemoveRole(string roleId)
    {
        RoleIds.Remove(roleId);
    }
}

public class Role
{
    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string Id { get; set; }

    public string Name { get; set; }
    public string Description { get; set; }
}

2. DDD 分层设计中的对象增删改查

DDD 核心在于将业务逻辑与基础设施代码分离。我们将 UserRole 对象作为领域实体(或聚合根),并通过仓库(Repository)模式进行持久化操作。

2.1 DDD 核心分层回顾

  1. 表现层(Presentation Layer):

    • 负责处理用户请求,展示信息。
    • 在 ASP .NET Core 中,这通常是 API 控制器(Controllers)。
    • 不包含业务逻辑,只协调应用服务。
    • 使用 DTO(Data Transfer Object)与客户端交互。
  2. 应用层(Application Layer):

    • 协调领域层对象执行业务操作。
    • 定义和实现业务用例(Use Cases)或工作流。
    • 不包含领域逻辑,但可以进行事务管理、安全检查等。
    • 使用领域服务和仓库接口。
  3. 领域层(Domain Layer):

    • 核心业务逻辑所在。
    • 包含实体(Entities)、值对象(Value Objects)、聚合(Aggregates)、领域服务(Domain Services)、仓库接口(Repository Interfaces)。
    • 与数据库技术无关。
  4. 基础设施层(Infrastructure Layer):

    • 实现领域层定义的仓库接口。
    • 负责与外部系统(如数据库、文件系统、消息队列)交互。
    • 包含 MongoDB 相关的具体实现代码。

2.2 实现对象(User 和 Role)的增删改查

我们将以 UserRole 为例,逐步实现其在 DDD 架构中的 CRUD 操作。

2.2.1 领域层 (Domain Layer)

目标: 定义实体和持久化契约(接口)。

  1. 实体 (Entities) - User.csRole.cs (如上所示)

    • 它们代表了业务概念,并封装了相关的行为和状态。
    • 应尽量保持与持久化机制无关,但为了 _id 的方便映射,我们直接在实体上添加了 [BsonId] 等属性。
  2. 仓库接口 (Repository Interfaces)

    • 定义了对聚合根进行持久化操作的契约。
    • 重要: 仓库应该面向聚合根,而不是单个实体。如果 User 是一个聚合根,那么 IUserRepository 应该提供对 User 聚合的完整生命周期管理。
    // Domain/Repositories/IUserRepository.cs
    public interface IUserRepository
    {
        Task AddAsync(User user);
        Task<User> GetByIdAsync(string id);
        Task<User> GetByUsernameAsync(string username); // 添加特定查询
        Task UpdateAsync(User user);
        Task DeleteAsync(string id);
        Task<IEnumerable<User>> GetAllAsync();
    }
    
    // Domain/Repositories/IRoleRepository.cs
    public interface IRoleRepository
    {
        Task AddAsync(Role role);
        Task<Role> GetByIdAsync(string id);
        Task<Role> GetByNameAsync(string name); // 添加特定查询
        Task UpdateAsync(Role role);
        Task DeleteAsync(string id);
        Task<IEnumerable<Role>> GetAllAsync();
    }
    

2.2.2 基础设施层 (Infrastructure Layer)

目标: 实现领域层定义的仓库接口,使用 MongoDB C# 驱动进行实际的数据操作。

  1. MongoDB 上下文 (可选但推荐)

    • 虽然 MongoDB 不像关系型数据库有严格的上下文概念,但为了封装 IMongoCollection<T> 的获取逻辑,可以创建一个简单的 IMongoContext 或直接在每个 Repository 中注入 IMongoDatabase
    • 这里我们直接在 Repository 中注入 IMongoDatabase 以保持简洁。
  2. 仓库实现 (Repository Implementations)

    using MongoDB.Driver;
    using System.Collections.Generic;
    using System.Threading.Tasks;
    using Domain.Repositories; // 引用领域层的接口
    using Domain.Entities; // 引用领域层的实体
    
    // Infrastructure/Repositories/UserRepository.cs
    public class UserRepository : IUserRepository
    {
        private readonly IMongoCollection<User> _users;
    
        public UserRepository(IMongoDatabase database)
        {
            _users = database.GetCollection<User>("users"); // "users" 是集合名称
        }
    
        public async Task AddAsync(User user)
        {
            await _users.InsertOneAsync(user);
        }
    
        public async Task<User> GetByIdAsync(string id)
        {
            var filter = Builders<User>.Filter.Eq(u => u.Id, id);
            return await _users.Find(filter).FirstOrDefaultAsync();
        }
    
        public async Task<User> GetByUsernameAsync(string username)
        {
            var filter = Builders<User>.Filter.Eq(u => u.Username, username);
            return await _users.Find(filter).FirstOrDefaultAsync();
        }
    
        public async Task UpdateAsync(User user)
        {
            var filter = Builders<User>.Filter.Eq(u => u.Id, user.Id);
            // ReplaceOneAsync 会替换整个文档
            await _users.ReplaceOneAsync(filter, user);
            // 如果只需要部分更新,可以使用 UpdateOneAsync 和 UpdateDefinitionBuilder
            /*
            var update = Builders<User>.Update
                .Set(u => u.Email, user.Email)
                .Set(u => u.PasswordHash, user.PasswordHash);
            await _users.UpdateOneAsync(filter, update);
            */
        }
    
        public async Task DeleteAsync(string id)
        {
            var filter = Builders<User>.Filter.Eq(u => u.Id, id);
            await _users.DeleteOneAsync(filter);
        }
    
        public async Task<IEnumerable<User>> GetAllAsync()
        {
            return await _users.Find(_ => true).ToListAsync();
        }
    }
    
    // Infrastructure/Repositories/RoleRepository.cs (类似UserRepository)
    public class RoleRepository : IRoleRepository
    {
        private readonly IMongoCollection<Role> _roles;
    
        public RoleRepository(IMongoDatabase database)
        {
            _roles = database.GetCollection<Role>("roles"); // "roles" 是集合名称
        }
    
        public async Task AddAsync(Role role)
        {
            await _roles.InsertOneAsync(role);
        }
    
        public async Task<Role> GetByIdAsync(string id)
        {
            var filter = Builders<Role>.Filter.Eq(r => r.Id, id);
            return await _roles.Find(filter).FirstOrDefaultAsync();
        }
    
        public async Task<Role> GetByNameAsync(string name)
        {
            var filter = Builders<Role>.Filter.Eq(r => r.Name, name);
            return await _roles.Find(filter).FirstOrDefaultAsync();
        }
    
        public async Task UpdateAsync(Role role)
        {
            var filter = Builders<Role>.Filter.Eq(r => r.Id, role.Id);
            await _roles.ReplaceOneAsync(filter, role);
        }
    
        public async Task DeleteAsync(string id)
        {
            var filter = Builders<Role>.Filter.Eq(r => r.Id, id);
            await _roles.DeleteOneAsync(filter);
        }
    
        public async Task<IEnumerable<Role>> GetAllAsync()
        {
            return await _roles.Find(_ => true).ToListAsync();
        }
    }
    
  3. 注册仓库实现 (Dependency Injection)

    Program.cs(或 Startup.cs)中注册仓库实现:

    // ... in Program.cs
    builder.Services.AddSingleton<IUserRepository, UserRepository>();
    builder.Services.AddSingleton<IRoleRepository, RoleRepository>();
    // ...
    

2.2.3 应用层 (Application Layer)

目标: 协调领域层和基础设施层,实现具体的业务用例。

  1. 数据传输对象 (DTOs)

    • 用于在应用层和表现层之间传递数据,避免直接暴露领域实体。
    // Application/DTOs/UserDTO.cs
    public class UserDto
    {
        public string Id { get; set; }
        public string Username { get; set; }
        public string Email { get; set; }
        public List<string> RoleIds { get; set; } = new List<string>();
    }
    
    // Application/DTOs/CreateUserRequest.cs
    public class CreateUserRequest
    {
        public string Username { get; set; }
        public string Email { get; set; }
        public string Password { get; set; } // 明文密码,在服务层进行哈希
    }
    
    // Application/DTOs/UpdateUserRequest.cs
    public class UpdateUserRequest
    {
        public string Email { get; set; }
        // ... 其他可更新字段
    }
    
    // Application/DTOs/RoleDTO.cs (类似 UserDTO)
    public class RoleDto
    {
        public string Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
    }
    
  2. 应用服务 (Application Services)

    • 封装了特定的业务用例逻辑。
    • 通过构造函数注入领域层的仓库接口。
    • 处理 DTO 到领域实体的映射,并调用领域实体的方法。
    • 这里可以进行密码哈希、数据验证等。
    using Domain.Repositories;
    using Domain.Entities;
    using Application.DTOs;
    using System.Collections.Generic;
    using System.Threading.Tasks;
    using System.Linq;
    using System.Security.Cryptography; // 用于密码哈希
    using System.Text;
    
    // Application/Services/UserService.cs
    public class UserService
    {
        private readonly IUserRepository _userRepository;
        private readonly IRoleRepository _roleRepository; // 用于验证角色是否存在
    
        public UserService(IUserRepository userRepository, IRoleRepository roleRepository)
        {
            _userRepository = userRepository;
            _roleRepository = roleRepository;
        }
    
        // 创建用户
        public async Task<UserDto> CreateUserAsync(CreateUserRequest request)
        {
            // 业务逻辑:检查用户名或邮箱是否已存在
            if (await _userRepository.GetByUsernameAsync(request.Username) != null)
            {
                throw new ArgumentException("Username already exists.");
            }
            // 密码哈希
            var passwordHash = HashPassword(request.Password);
    
            var user = new User
            {
                Username = request.Username,
                Email = request.Email,
                PasswordHash = passwordHash,
                RoleIds = new List<string>() // 初始不分配角色
            };
            await _userRepository.AddAsync(user);
            return ToUserDto(user);
        }
    
        // 获取单个用户
        public async Task<UserDto> GetUserByIdAsync(string id)
        {
            var user = await _userRepository.GetByIdAsync(id);
            return user == null ? null : ToUserDto(user);
        }
    
        // 获取所有用户
        public async Task<IEnumerable<UserDto>> GetAllUsersAsync()
        {
            var users = await _userRepository.GetAllAsync();
            return users.Select(ToUserDto);
        }
    
        // 更新用户
        public async Task UpdateUserAsync(string id, UpdateUserRequest request)
        {
            var user = await _userRepository.GetByIdAsync(id);
            if (user == null)
            {
                throw new KeyNotFoundException($"User with ID {id} not found.");
            }
    
            user.Email = request.Email;
            // 可以根据需要更新其他字段,调用领域实体的方法
            // user.UpdateProfile(request.Email, ...);
    
            await _userRepository.UpdateAsync(user);
        }
    
        // 删除用户
        public async Task DeleteUserAsync(string id)
        {
            var user = await _userRepository.GetByIdAsync(id);
            if (user == null)
            {
                throw new KeyNotFoundException($"User with ID {id} not found.");
            }
            await _userRepository.DeleteAsync(id);
        }
    
        // 分配角色给用户
        public async Task AssignRoleToUserAsync(string userId, string roleId)
        {
            var user = await _userRepository.GetByIdAsync(userId);
            if (user == null) throw new KeyNotFoundException($"User with ID {userId} not found.");
    
            var role = await _roleRepository.GetByIdAsync(roleId);
            if (role == null) throw new KeyNotFoundException($"Role with ID {roleId} not found.");
    
            user.AssignRole(roleId); // 调用领域实体的方法
            await _userRepository.UpdateAsync(user);
        }
    
        // 辅助方法:将领域实体转换为 DTO
        private UserDto ToUserDto(User user)
        {
            return new UserDto
            {
                Id = user.Id,
                Username = user.Username,
                Email = user.Email,
                RoleIds = user.RoleIds
            };
        }
    
        // 密码哈希方法 (简化版,生产环境应使用更安全的方案如 Argon2)
        private string HashPassword(string password)
        {
            using (var sha256 = SHA256.Create())
            {
                var hashedBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password));
                return BitConverter.ToString(hashedBytes).Replace("-", "").ToLower();
            }
        }
    }
    
    // Application/Services/RoleService.cs (类似 UserService)
    public class RoleService
    {
        private readonly IRoleRepository _roleRepository;
    
        public RoleService(IRoleRepository roleRepository)
        {
            _roleRepository = roleRepository;
        }
    
        public async Task<RoleDto> CreateRoleAsync(string name, string description)
        {
            if (await _roleRepository.GetByNameAsync(name) != null)
            {
                throw new ArgumentException("Role with this name already exists.");
            }
            var role = new Role { Name = name, Description = description };
            await _roleRepository.AddAsync(role);
            return ToRoleDto(role);
        }
    
        public async Task<RoleDto> GetRoleByIdAsync(string id)
        {
            var role = await _roleRepository.GetByIdAsync(id);
            return role == null ? null : ToRoleDto(role);
        }
    
        public async Task<IEnumerable<RoleDto>> GetAllRolesAsync()
        {
            var roles = await _roleRepository.GetAllAsync();
            return roles.Select(ToRoleDto);
        }
    
        public async Task UpdateRoleAsync(string id, string name, string description)
        {
            var role = await _roleRepository.GetByIdAsync(id);
            if (role == null) throw new KeyNotFoundException($"Role with ID {id} not found.");
            role.Name = name;
            role.Description = description;
            await _roleRepository.UpdateAsync(role);
        }
    
        public async Task DeleteRoleAsync(string id)
        {
            var role = await _roleRepository.GetByIdAsync(id);
            if (role == null) throw new KeyNotFoundException($"Role with ID {id} not found.");
            await _roleRepository.DeleteAsync(id);
        }
    
        private RoleDto ToRoleDto(Role role)
        {
            return new RoleDto { Id = role.Id, Name = role.Name, Description = role.Description };
        }
    }
    
  3. 注册应用服务 (Dependency Injection)

    // ... in Program.cs
    builder.Services.AddScoped<UserService>(); // 应用服务通常是 Scoped
    builder.Services.AddScoped<RoleService>();
    // ...
    

2.2.4 表现层 (Presentation Layer)

目标: 提供 RESTful API 接口,接收客户端请求,调用应用服务,并返回响应。

  1. API 控制器 (Controllers)

    • 通过构造函数注入应用服务。
    • 处理 HTTP 请求的路由、参数绑定、验证等。
    • 将 DTO 作为请求和响应的主体。
    using Microsoft.AspNetCore.Mvc;
    using Application.Services;
    using Application.DTOs;
    using System.Collections.Generic;
    using System.Threading.Tasks;
    
    // Presentation/Controllers/UsersController.cs
    [ApiController]
    [Route("api/[controller]")]
    public class UsersController : ControllerBase
    {
        private readonly UserService _userService;
    
        public UsersController(UserService userService)
        {
            _userService = userService;
        }
    
        // GET: api/users
        [HttpGet]
        public async Task<ActionResult<IEnumerable<UserDto>>> Get()
        {
            return Ok(await _userService.GetAllUsersAsync());
        }
    
        // GET: api/users/{id}
        [HttpGet("{id}")]
        public async Task<ActionResult<UserDto>> Get(string id)
        {
            var user = await _userService.GetUserByIdAsync(id);
            if (user == null)
            {
                return NotFound();
            }
            return Ok(user);
        }
    
        // POST: api/users
        [HttpPost]
        public async Task<ActionResult<UserDto>> Post([FromBody] CreateUserRequest request)
        {
            try
            {
                var userDto = await _userService.CreateUserAsync(request);
                // Return 201 Created status with the new user's location
                return CreatedAtAction(nameof(Get), new { id = userDto.Id }, userDto);
            }
            catch (ArgumentException ex)
            {
                return BadRequest(ex.Message);
            }
        }
    
        // PUT: api/users/{id}
        [HttpPut("{id}")]
        public async Task<IActionResult> Put(string id, [FromBody] UpdateUserRequest request)
        {
            try
            {
                await _userService.UpdateUserAsync(id, request);
                return NoContent(); // 204 No Content
            }
            catch (KeyNotFoundException)
            {
                return NotFound();
            }
        }
    
        // DELETE: api/users/{id}
        [HttpDelete("{id}")]
        public async Task<IActionResult> Delete(string id)
        {
            try
            {
                await _userService.DeleteUserAsync(id);
                return NoContent(); // 204 No Content
            }
            catch (KeyNotFoundException)
            {
                return NotFound();
            }
        }
    
        // POST: api/users/{userId}/roles/{roleId} (示例:分配角色)
        [HttpPost("{userId}/roles/{roleId}")]
        public async Task<IActionResult> AssignRole(string userId, string roleId)
        {
            try
            {
                await _userService.AssignRoleToUserAsync(userId, roleId);
                return NoContent();
            }
            catch (KeyNotFoundException ex)
            {
                return NotFound(ex.Message);
            }
            catch (ArgumentException ex)
            {
                return BadRequest(ex.Message);
            }
        }
    }
    
    // Presentation/Controllers/RolesController.cs (类似 UsersController)
    // ...
    

2.3 总结 DDD 与 MongoDB 的结合

  • 领域层(Domain Layer): 保持纯净,定义业务规则和行为,不关心数据存储细节。实体使用普通的 C# 类,通过接口与外部解耦。
  • 基础设施层(Infrastructure Layer): 实现了领域层的仓库接口,包含所有 MongoDB 特定的代码,如 IMongoCollection<T> 的使用、FilterDefinitionBuilderUpdateDefinitionBuilder 等。这一层是 MongoDB 适配器。
  • 应用层(Application Layer): 协调领域实体和仓库,执行应用程序的用例。它处理 DTO 到领域实体的转换,并封装了业务流程。
  • 表现层(Presentation Layer): 作为 API 端点,负责 HTTP 请求和响应的序列化/反序列化,并调用应用服务。

这种分层设计确保了:

  1. 关注点分离: 每个层只负责特定的任务,提高了代码的可读性和可维护性。
  2. 可测试性: 领域层和应用层可以独立于数据库进行单元测试。基础设施层也可以进行集成测试。
  3. 灵活性: 如果未来需要更换数据库(例如从 MongoDB 切换到另一个 NoSQL 数据库),只需修改基础设施层的仓库实现,而不会影响领域层和应用层。
  4. 领域模型驱动: 业务逻辑是核心,数据库只是实现持久化的一种工具。

通过这种方式,ASP .NET Core 应用程序可以高效、优雅地利用 MongoDB 的强大功能,同时遵循 DDD 的最佳实践来构建健壮、可扩展的系统。

扩展思考

在 ASP .NET Core 中使用 MongoDB 时,尽管 MongoDB 本身是“无模式”(schemaless)的,但 C# 实体是强类型的,这确实引入了在数据演进(Schema Evolution)方面需要管理的复杂性。当我们为 C# 实体添加新字段时,我们需要思考如何让 MongoDB 集合中的现有文档也能适应这些变化。

1. MongoDB 的“无模式”特性与 C# 强类型的冲突点

首先,我们明确一下:

  • MongoDB 的无模式: 意味着你可以在同一个集合中存储结构不同的文档。当你为 C# 实体添加一个新字段时,MongoDB 集合中已存在的文档并不会因此而“报错”,它们只是缺少这个新字段。MongoDB 驱动程序在反序列化时,会忽略 C# 实体中存在但在 BSON 文档中不存在的字段(通常会将其设置为 null 或默认值)。
  • C# 的强类型: 意味着你的应用程序代码需要一个明确的类型定义来操作数据。如果你在 C# 实体中添加了一个新属性,但对应的数据库文档中没有这个字段,那么当你读取旧文档时,这个新属性将是其类型的默认值(例如,引用类型为 null,数值类型为 0bool 类型为 false)。如果你的代码期望这个字段总是存在且有值,就可能导致 NullReferenceException 或逻辑错误。

因此,管理数据演进的关键在于协调 C# 代码的类型定义与 MongoDB 实际存储文档之间的结构差异

2. 管理数据演进的策略(添加新字段为例)

UserRole 实体需要添加新字段时,通常遵循以下阶段:

2.1 阶段一:更新 C# 实体定义

这是第一步,在 C# 代码中直接修改你的实体类。

示例:为 User 实体添加 LastLoginDate 字段和 IsActive 字段。

using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;
using System; // For DateTime

public class User
{
    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string Id { get; set; }

    public string Username { get; set; }
    public string Email { get; set; }
    public string PasswordHash { get; set; }
    public List<string> RoleIds { get; set; } = new List<string>();

    // 新增字段
    public DateTime? LastLoginDate { get; set; } // 允许为 null,表示尚未登录过
    public bool IsActive { get; set; } = true; // 提供一个默认值
}

关键考虑点:

  • Nullability (可空性): 如果新字段对现有文档来说不是必需的,并且可以接受在旧文档中缺失(即其值为 null),请将其定义为可空类型(DateTime?string?)。这使得读取旧文档时不会抛出错误。
  • 默认值: 如果新字段在业务逻辑上应该有一个默认值(例如 IsActive 默认为 true),在 C# 属性中设置默认值 (= true;)。当 MongoDB 驱动反序列化一个缺少此字段的文档时,C# 对象的新属性将拥有这个默认值。

在此阶段,数据库中的现有文档尚未改变。 它们仍然没有 LastLoginDateIsActive 字段。但是,你的 ASP .NET Core 应用程序在读取这些文档时,C# User 对象的 LastLoginDate 将是 nullIsActive 将是 true

2.2 阶段二:数据迁移(可选但通常推荐)

虽然 C# 驱动可以处理缺少字段的情况,但为了数据的一致性、查询的方便性以及避免潜在的 null 检查,通常建议运行一次数据迁移脚本,为所有现有文档添加这些新字段并赋予一个默认值。

执行数据迁移的两种方式:

2.2.2.1 使用 MongoDB Shell 或 Compass

这是最直接的方式,特别是对于简单的批量更新。

示例:为 users 集合中的所有文档添加 LastLoginDateIsActive

// 为所有没有 LastLoginDate 字段的文档添加该字段,并设置默认值为 Unix 纪元(1970-01-01)
db.users.updateMany(
  { LastLoginDate: { $exists: false } },
  { $set: { LastLoginDate: ISODate("1970-01-01T00:00:00Z") } }
)

// 为所有没有 IsActive 字段的文档添加该字段,并设置默认值为 true
db.users.updateMany(
  { IsActive: { $exists: false } },
  { $set: { IsActive: true } }
)
  • { $exists: false }:这是一个强大的查询操作符,用于查找缺少特定字段的文档。
  • $set:用于添加或更新字段。

2.2.2.2 通过 C# 代码进行数据迁移

你也可以编写一个一次性的 C# 脚本或一个专门的“数据迁移服务”来执行这些更新。这在自动化部署和更复杂的迁移场景中非常有用。

using MongoDB.Driver;
using System.Threading.Tasks;
using System;
// ... using Domain.Entities;

public class DataMigrationService
{
    private readonly IMongoCollection<User> _usersCollection;

    public DataMigrationService(IMongoDatabase database)
    {
        _usersCollection = database.GetCollection<User>("users");
    }

    public async Task MigrateUsersSchemaAsync()
    {
        Console.WriteLine("Starting User schema migration...");

        // 迁移 LastLoginDate 字段
        var lastLoginDateFilter = Builders<User>.Filter.Exists(u => u.LastLoginDate, false);
        var lastLoginDateUpdate = Builders<User>.Update.Set(u => u.LastLoginDate, DateTime.MinValue); // 设置一个合理的默认值

        var lastLoginDateResult = await _usersCollection.UpdateManyAsync(lastLoginDateFilter, lastLoginDateUpdate);
        Console.WriteLine($"Migrated LastLoginDate for {lastLoginDateResult.ModifiedCount} users.");

        // 迁移 IsActive 字段
        var isActiveFilter = Builders<User>.Filter.Exists(u => u.IsActive, false);
        var isActiveUpdate = Builders<User>.Update.Set(u => u.IsActive, true); // 设置默认值

        var isActiveResult = await _usersCollection.UpdateManyAsync(isActiveFilter, isActiveUpdate);
        Console.WriteLine($"Migrated IsActive for {isActiveResult.ModifiedCount} users.");

        Console.WriteLine("User schema migration completed.");
    }
}
  • 执行时机: 这个服务可以在应用程序启动时(例如在 Program.cs 中)执行一次,或者作为单独的命令行工具运行。
  • 幂等性: 确保你的迁移脚本是幂等的(多次运行结果相同),例如,通过 {$exists: false} 过滤,只更新那些确实缺少字段的文档。

2.3 阶段三:更新应用逻辑(使用新字段)

一旦 C# 实体和数据库中的数据都已更新,你的应用逻辑就可以安全地开始使用这些新字段了。

// 在 UserService 中,现在可以根据 IsActive 过滤用户
public async Task<IEnumerable<UserDto>> GetActiveUsersAsync()
{
    var filter = Builders<User>.Filter.Eq(u => u.IsActive, true);
    var users = await _userRepository.Find(filter); // 假设仓库有Find方法
    return users.Select(ToUserDto);
}

// 在用户登录时更新 LastLoginDate
public async Task LoginUserAsync(string username, string password)
{
    var user = await _userRepository.GetByUsernameAsync(username);
    if (user == null || !VerifyPassword(password, user.PasswordHash))
    {
        throw new UnauthorizedAccessException("Invalid credentials.");
    }
    user.LastLoginDate = DateTime.UtcNow; // 更新领域实体
    await _userRepository.UpdateAsync(user); // 持久化更新
    // ... 其他登录逻辑
}

3. 更复杂的场景和最佳实践

3.1 字段重命名

如果需要重命名字段,情况会稍微复杂一点。你需要:

  1. C# 实体: 使用 [BsonElement("oldFieldName")] 属性保持向后兼容,或者直接重命名属性并在迁移时处理。

  2. 数据迁移: 使用 $rename 操作符。

    db.users.updateMany(
      {}, // 作用于所有文档
      { $rename: { "oldFieldName": "newFieldName" } }
    )
    

    通常,这是一个有风险的操作,因为在 $rename 完成之前,应用程序代码需要同时识别新旧字段名。更好的做法可能是:

    • 阶段1: 添加新字段,同时保持旧字段。
    • 阶段2: 编写代码,在读旧字段时写入新字段。
    • 阶段3: 迁移数据,将旧字段数据复制到新字段,并更新所有读取旧字段的代码去读取新字段。
    • 阶段4: 删除旧字段(在 C# 和 MongoDB 中)。

3.2 字段删除

  1. C# 实体: 直接删除属性。

  2. 数据迁移(可选): 如果想节省空间,可以使用 $unset 操作符删除数据库中的字段。

    db.users.updateMany(
      {},
      { $unset: { "oldUnusedField": "" } }
    )
    

3.3 嵌套文档和数组的变化

处理嵌套文档或数组的结构变化同样遵循上述原则:

  • 添加/删除字段: 直接修改 C# 类,并考虑数据迁移。
  • 改变类型: 这可能导致反序列化错误。需要先将旧字段重命名($rename),然后添加新类型字段,再将数据转换过去。

3.4 版本控制数据模式

对于大型或演进频繁的系统,可以考虑在文档中引入一个 _schemaVersion 字段:

{
  "_id": "...",
  "Username": "...",
  "Email": "...",
  "_schemaVersion": 1, // 当前模式版本
  "CreatedAt": "..."
}

你的应用层或数据访问层可以在读取文档时检查 _schemaVersion

  • 如果 _schemaVersion 小于当前应用期望的版本,则在加载到内存中时对数据进行“升级”转换,或者触发一个后台任务来实际更新数据库中的文档。
  • 这允许应用代码和数据库数据在一段时间内保持不同的模式版本,并在后台逐步进行数据迁移。

3.5 永远保持向后兼容性

在进行模式演进时,核心原则是尽量保持向后兼容性。这意味着在部署新代码之前,旧代码应该能够继续读取和写入数据,即使这些数据可能缺少新字段。通常的做法是:

  1. 部署数据库迁移脚本 (如果需要)。
  2. 部署支持新旧模式的代码 (新字段可空,或有默认值)。
  3. 逐步弃用旧字段/逻辑

4. 总结

MongoDB 的无模式特性赋予了极大的灵活性,使得添加新字段通常比关系型数据库更容易。然而,在强类型的 ASP .NET Core 应用程序中,这种灵活性需要通过细致的 C# 实体设计(可空性、默认值)和必要的数据迁移步骤来管理。

核心流程:

  1. C# 实体更新: 为新字段添加属性,并考虑其可空性或默认值。
  2. 数据迁移(推荐): 运行一次性脚本,为现有文档添加新字段并赋予默认值,以确保数据一致性。
  3. 应用逻辑更新: 在新字段存在且一致后,安全地在业务逻辑中使用它们。

通过这种分阶段、谨慎的方法,可以有效地在 ASP .NET Core 和 DDD 架构中管理 MongoDB 的数据演进。