Skip to content

一对一关系配置 ​

概述 ​

一对一(One-to-One, 1:1)关系是指一个实体实例最多关联到另一个实体的一个实例。在关系数据库中,这通常通过外键约束实现,外键列同时具有唯一性约束。

典型场景 ​

场景说明
用户与个人资料每个用户有一个详细的资料档案
订单与物流信息每个订单对应一条物流记录
员工与工位每个员工分配一个固定工位
产品与规格详情产品基本信息与详细规格分离
博客与元数据博客内容与SEO元数据分开存储

基础配置 ​

约定配置(Convention) ​

csharp
// EF Core 会自动识别一对一关系
public class User
{
    public int Id { get; set; }
    public string Email { get; set; }
    
    // 导航属性
    public UserProfile Profile { get; set; }
}

public class UserProfile
{
    public int Id { get; set; }
    public string Bio { get; set; }
    
    // 导航属性 + 外键
    public int UserId { get; set; }  // ⚠️ 必须是唯一外键
    public User User { get; set; }
}

// DbContext
public class AppDbContext : DbContext
{
    public DbSet<User> Users { get; set; }
    public DbSet<UserProfile> Profiles { get; set; }
}

// EF Core 自动配置:
// - UserProfile.UserId 是外键
// - UserProfile.UserId 有唯一索引

Fluent API 配置(推荐) ​

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>(entity =>
    {
        entity.HasKey(u => u.Id);
        entity.Property(u => u.Email).IsRequired();
    });

    modelBuilder.Entity<UserProfile>(entity =>
    {
        entity.HasKey(p => p.Id);
        
        // 配置一对一关系
        entity.HasOne(p => p.User)           // UserProfile 有一个 User
              .WithOne(u => u.Profile)       // User 有一个 UserProfile
              .HasForeignKey<UserProfile>(p => p.UserId);  // 外键在 UserProfile
        
        // 可选: 明确指定外键名称
        // entity.HasOne(p => p.User)
        //       .WithOne(u => u.Profile)
        //       .HasForeignKey<UserProfile>("UserId");
    });
}

完整示例: 用户与个人资料 ​

实体定义 ​

csharp
// User.cs
public class User
{
    public int Id { get; set; }
    public string Username { get; set; } = null!;
    public string Email { get; set; } = null!;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    
    // 导航属性
    public UserProfile? Profile { get; set; }
}

// UserProfile.cs
public class UserProfile
{
    public int Id { get; set; }
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public string? Bio { get; set; }
    public string? AvatarUrl { get; set; }
    public DateTime? DateOfBirth { get; set; }
    
    // 外键
    public int UserId { get; set; }
    
    // 导航属性
    public User User { get; set; } = null!;
}

配置 ​

csharp
modelBuilder.Entity<UserProfile>(entity =>
{
    entity.HasKey(p => p.Id);
    
    entity.HasOne(p => p.User)
          .WithOne(u => u.Profile)
          .HasForeignKey<UserProfile>(p => p.UserId)
          .OnDelete(DeleteBehavior.Cascade);  // 删除用户时级联删除资料
    
    // 唯一索引(确保一对一)
    entity.HasIndex(p => p.UserId).IsUnique();
});

CRUD 操作 ​

创建 ​

csharp
// 方式 1: 分别创建
var user = new User 
{ 
    Username = "john_doe", 
    Email = "john@example.com" 
};

context.Users.Add(user);
await context.SaveChangesAsync();  // 生成 UserId

var profile = new UserProfile
{
    UserId = user.Id,  // 使用生成的 UserId
    FirstName = "John",
    LastName = "Doe",
    Bio = "Software Developer"
};

context.Profiles.Add(profile);
await context.SaveChangesAsync();

// 方式 2: 一起创建(推荐)
var user = new User 
{ 
    Username = "jane_doe", 
    Email = "jane@example.com",
    Profile = new UserProfile  // 直接赋值导航属性
    {
        FirstName = "Jane",
        LastName = "Doe",
        Bio = "Product Manager"
    }
};

context.Users.Add(user);
await context.SaveChangesAsync();  // 一次性保存

查询 ​

csharp
// 加载用户及其资料
var user = await context.Users
    .Include(u => u.Profile)  // 预加载
    .FirstOrDefaultAsync(u => u.Id == userId);

if (user != null)
{
    Console.WriteLine($"{user.Username}: {user.Profile?.FirstName} {user.Profile?.LastName}");
}

// 仅查询资料
var profile = await context.Profiles
    .FirstOrDefaultAsync(p => p.UserId == userId);

// 投影查询
var userDto = await context.Users
    .Where(u => u.Id == userId)
    .Select(u => new 
    { 
        u.Id,
        u.Username,
        u.Email,
        Profile = u.Profile != null ? new 
        { 
            u.Profile.FirstName, 
            u.Profile.LastName,
            u.Profile.Bio 
        } : null
    })
    .FirstOrDefaultAsync();

更新 ​

csharp
// 更新资料
var profile = await context.Profiles
    .FirstOrDefaultAsync(p => p.UserId == userId);

if (profile != null)
{
    profile.Bio = "Updated bio";
    profile.AvatarUrl = "https://example.com/avatar.jpg";
    await context.SaveChangesAsync();
}

// 添加资料(如果不存在)
var user = await context.Users
    .Include(u => u.Profile)
    .FirstOrDefaultAsync(u => u.Id == userId);

if (user.Profile == null)
{
    user.Profile = new UserProfile
    {
        FirstName = "John",
        LastName = "Doe"
    };
    await context.SaveChangesAsync();
}

删除 ​

csharp
// 删除用户(级联删除资料)
var user = await context.Users
    .Include(u => u.Profile)
    .FirstOrDefaultAsync(u => u.Id == userId);

if (user != null)
{
    context.Users.Remove(user);
    await context.SaveChangesAsync();
    // UserProfile 也会被自动删除
}

// 仅删除资料
var profile = await context.Profiles
    .FirstOrDefaultAsync(p => p.UserId == userId);

if (profile != null)
{
    context.Profiles.Remove(profile);
    await context.SaveChangesAsync();
}

可选的一对一关系 ​

配置可选关系 ​

csharp
// UserProfile 可以没有对应的 User(理论上不应该,但技术上可行)
modelBuilder.Entity<UserProfile>(entity =>
{
    entity.HasOne(p => p.User)
          .WithOne(u => u.Profile)
          .HasForeignKey<UserProfile>(p => p.UserId)
          .IsRequired(false);  // 外键可为空
});

// 或者将外键改为可空类型
public class UserProfile
{
    public int Id { get; set; }
    public int? UserId { get; set; }  // 可空外键
    public User? User { get; set; }
}

双向 vs 单向导航 ​

双向导航(推荐) ​

csharp
public class User
{
    public int Id { get; set; }
    public UserProfile? Profile { get; set; }  // 可以访问 Profile
}

public class UserProfile
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public User User { get; set; }  // 可以访问 User
}

// ✅ 优点: 两个方向都可以导航
var user = await context.Users.Include(u => u.Profile).FirstAsync();
var profile = await context.Profiles.Include(p => p.User).FirstAsync();

单向导航 ​

csharp
public class User
{
    public int Id { get; set; }
    // 没有 Profile 导航属性
}

public class UserProfile
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public User User { get; set; }
}

// 配置
modelBuilder.Entity<UserProfile>(entity =>
{
    entity.HasOne(p => p.User)
          .WithOne()  // User 端没有导航属性
          .HasForeignKey<UserProfile>(p => p.UserId);
});

// ⚠️ 只能从 Profile 导航到 User
var profile = await context.Profiles.Include(p => p.User).FirstAsync();
// ❌ 不能直接从 User 导航到 Profile

共享主键关系 ​

概念 ​

在某些情况下,依赖实体可以使用与主体实体相同的主键值,这样就不需要单独的外键列。

csharp
public class User
{
    public int Id { get; set; }
    public string Email { get; set; }
    
    public UserProfile? Profile { get; set; }
}

public class UserProfile
{
    public int Id { get; set; }  // 与 User.Id 相同
    public string Bio { get; set; }
    
    public User User { get; set; }
}

// 配置共享主键
modelBuilder.Entity<UserProfile>(entity =>
{
    entity.HasKey(p => p.Id);
    
    entity.HasOne(p => p.User)
          .WithOne(u => u.Profile)
          .HasForeignKey<UserProfile>(p => p.Id);  // 使用主键作为外键
});

// 生成的表结构:
// Users:      Id (PK), Email
// UserProfiles: Id (PK, FK to Users.Id), Bio

使用共享主键 ​

csharp
// 创建
var user = new User 
{ 
    Email = "test@example.com",
    Profile = new UserProfile { Bio = "Hello!" }
};

context.Users.Add(user);
await context.SaveChangesAsync();

// user.Id 和 user.Profile.Id 是相同的值

// 查询
var user = await context.Users
    .Include(u => u.Profile)
    .FirstOrDefaultAsync(u => u.Id == userId);

复杂示例: 订单与物流 ​

实体设计 ​

csharp
public class Order
{
    public int Id { get; set; }
    public string OrderNumber { get; set; } = null!;
    public decimal TotalAmount { get; set; }
    public DateTime OrderDate { get; set; } = DateTime.UtcNow;
    public OrderStatus Status { get; set; }
    
    // 导航属性
    public ShippingInfo? Shipping { get; set; }
}

public enum OrderStatus
{
    Pending,
    Paid,
    Shipped,
    Delivered,
    Cancelled
}

public class ShippingInfo
{
    public int Id { get; set; }
    public string TrackingNumber { get; set; } = null!;
    public string Carrier { get; set; } = null!;  // 快递公司
    public DateTime? ShippedDate { get; set; }
    public DateTime? EstimatedDelivery { get; set; }
    public DateTime? ActualDelivery { get; set; }
    public string? DeliveryAddress { get; set; }
    
    // 外键
    public int OrderId { get; set; }
    
    // 导航属性
    public Order Order { get; set; } = null!;
}

配置 ​

csharp
modelBuilder.Entity<Order>(entity =>
{
    entity.HasKey(o => o.Id);
    entity.Property(o => o.OrderNumber).IsRequired().HasMaxLength(50);
    entity.Property(o => o.TotalAmount).HasColumnType("decimal(18,2)");
    entity.HasIndex(o => o.OrderNumber).IsUnique();
});

modelBuilder.Entity<ShippingInfo>(entity =>
{
    entity.HasKey(s => s.Id);
    
    entity.HasOne(s => s.Order)
          .WithOne(o => o.Shipping)
          .HasForeignKey<ShippingInfo>(s => s.OrderId)
          .OnDelete(DeleteBehavior.Cascade);
    
    entity.HasIndex(s => s.TrackingNumber).IsUnique();
    entity.HasIndex(s => s.OrderId).IsUnique();
});

业务逻辑 ​

csharp
public class OrderService
{
    private readonly AppDbContext _context;

    public OrderService(AppDbContext context)
    {
        _context = context;
    }

    // 创建订单
    public async Task<int> CreateOrderAsync(CreateOrderRequest request)
    {
        var order = new Order
        {
            OrderNumber = GenerateOrderNumber(),
            TotalAmount = request.TotalAmount,
            Status = OrderStatus.Pending
        };

        context.Orders.Add(order);
        await context.SaveChangesAsync();
        
        return order.Id;
    }

    // 发货
    public async Task ShipOrderAsync(int orderId, ShippingRequest request)
    {
        var order = await context.Orders
            .Include(o => o.Shipping)
            .FirstOrDefaultAsync(o => o.Id == orderId);

        if (order == null)
            throw new InvalidOperationException("Order not found");

        if (order.Status != OrderStatus.Paid)
            throw new InvalidOperationException("Order must be paid before shipping");

        // 创建物流信息
        order.Shipping = new ShippingInfo
        {
            TrackingNumber = request.TrackingNumber,
            Carrier = request.Carrier,
            ShippedDate = DateTime.UtcNow,
            EstimatedDelivery = DateTime.UtcNow.AddDays(request.EstimatedDays),
            DeliveryAddress = request.DeliveryAddress
        };

        order.Status = OrderStatus.Shipped;
        await context.SaveChangesAsync();
    }

    // 确认送达
    public async Task DeliverOrderAsync(int orderId)
    {
        var order = await context.Orders
            .Include(o => o.Shipping)
            .FirstOrDefaultAsync(o => o.Id == orderId);

        if (order?.Shipping == null)
            throw new InvalidOperationException("Order has not been shipped");

        order.Shipping.ActualDelivery = DateTime.UtcNow;
        order.Status = OrderStatus.Delivered;
        await context.SaveChangesAsync();
    }

    // 查询订单及物流
    public async Task<OrderDetailDto?> GetOrderDetailAsync(int orderId)
    {
        var order = await context.Orders
            .Include(o => o.Shipping)
            .FirstOrDefaultAsync(o => o.Id == orderId);

        if (order == null)
            return null;

        return new OrderDetailDto
        {
            OrderId = order.Id,
            OrderNumber = order.OrderNumber,
            TotalAmount = order.TotalAmount,
            Status = order.Status.ToString(),
            OrderDate = order.OrderDate,
            Shipping = order.Shipping != null ? new ShippingDto
            {
                TrackingNumber = order.Shipping.TrackingNumber,
                Carrier = order.Shipping.Carrier,
                ShippedDate = order.Shipping.ShippedDate,
                EstimatedDelivery = order.Shipping.EstimatedDelivery,
                ActualDelivery = order.Shipping.ActualDelivery
            } : null
        };
    }

    private string GenerateOrderNumber()
    {
        return $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid():N[..8].ToUpper()}";
    }
}

常见问题与解决方案 ​

问题 1: 外键未创建唯一索引 ​

csharp
// ❌ 错误: 忘记添加唯一索引
entity.HasOne(p => p.User)
      .WithOne(u => u.Profile)
      .HasForeignKey<UserProfile>(p => p.UserId);

// ✅ 正确: 添加唯一索引
entity.HasOne(p => p.User)
      .WithOne(u => u.Profile)
      .HasForeignKey<UserProfile>(p => p.UserId);

entity.HasIndex(p => p.UserId).IsUnique();  // ← 重要!

问题 2: 级联删除冲突 ​

csharp
// ❌ 可能导致循环级联删除
entity.HasOne(p => p.User)
      .WithOne(u => u.Profile)
      .HasForeignKey<UserProfile>(p => p.UserId)
      .OnDelete(DeleteBehavior.Cascade);

// ✅ 如果有多条路径,使用 Restrict
entity.HasOne(p => p.User)
      .WithOne(u => u.Profile)
      .HasForeignKey<UserProfile>(p => p.UserId)
      .OnDelete(DeleteBehavior.Restrict);

// 手动删除
var user = await context.Users.Include(u => u.Profile).FirstAsync();
context.Users.Remove(user);  // Profile 会被级联删除
await context.SaveChangesAsync();

问题 3: 加载空导航属性 ​

csharp
// ❌ Profile 可能为 null
var user = await context.Users.FirstAsync();
Console.WriteLine(user.Profile.FirstName);  // 💥 NullReferenceException

// ✅ 检查 null
var user = await context.Users.Include(u => u.Profile).FirstAsync();
if (user.Profile != null)
{
    Console.WriteLine(user.Profile.FirstName);
}

// ✅ 或使用可选加载
var user = await context.Users
    .Select(u => new 
    { 
        u.Id, 
        ProfileFirstName = u.Profile != null ? u.Profile.FirstName : null 
    })
    .FirstAsync();

性能优化 ​

1. 避免不必要的 Include ​

csharp
// ❌ 不需要 Profile 时也 Include
var users = await context.Users.Include(u => u.Profile).ToListAsync();

// ✅ 按需加载
var users = await context.Users.ToListAsync();  // 只需要用户列表

var userWithProfile = await context.Users
    .Include(u => u.Profile)
    .FirstOrDefaultAsync(u => u.Id == userId);  // 需要详细信息时才 Include

2. 使用投影查询 ​

csharp
// ✅ 只选择需要的字段
var userDtos = await context.Users
    .Select(u => new UserDto
    {
        Id = u.Id,
        Username = u.Username,
        ProfileBio = u.Profile != null ? u.Profile.Bio : null
    })
    .ToListAsync();

3. 批量加载 ​

csharp
// ✅ 一次性加载多个用户的资料
var userIds = new[] { 1, 2, 3, 4, 5 };

var users = await context.Users
    .Include(u => u.Profile)
    .Where(u => userIds.Contains(u.Id))
    .ToListAsync();

最佳实践 ​

✅ 推荐做法 ​

  1. 始终添加唯一索引

    csharp
    entity.HasIndex(p => p.UserId).IsUnique();
  2. 使用共享主键简化模型(如果适用)

    csharp
    .HasForeignKey<UserProfile>(p => p.Id);
  3. 配置级联删除行为

    csharp
    .OnDelete(DeleteBehavior.Cascade);
  4. 检查 null 导航属性

    csharp
    if (user.Profile != null) { ... }

❌ 避免的错误 ​

  1. 不要忘记配置外键

    csharp
    // ❌ EF Core 可能无法自动识别
    .WithOne(u => u.Profile)
    // 缺少 .HasForeignKey<>()
    
    // ✅ 明确指定
    .WithOne(u => u.Profile)
    .HasForeignKey<UserProfile>(p => p.UserId)
  2. 不要在一对一中使用集合

    csharp
    // ❌ 错误
    public ICollection<UserProfile> Profiles { get; set; }
    
    // ✅ 正确
    public UserProfile? Profile { get; set; }

总结 ​

一对一关系要点 ​

要点说明
外键位置通常在依赖实体中
唯一索引必须在外键上添加唯一索引
共享主键可选方案,简化表结构
级联删除根据业务需求配置
导航属性建议双向导航

核心代码模板 ​

csharp
// 标准配置模板
modelBuilder.Entity<DependentEntity>(entity =>
{
    entity.HasOne(d => d.Principal)
          .WithOne(p => p.Dependent)
          .HasForeignKey<DependentEntity>(d => d.PrincipalId)
          .OnDelete(DeleteBehavior.Cascade);
    
    entity.HasIndex(d => d.PrincipalId).IsUnique();
});

基于 MIT 许可发布