Appearance
一对一关系配置
概述
一对一(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); // 需要详细信息时才 Include2. 使用投影查询
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();最佳实践
✅ 推荐做法
始终添加唯一索引
csharpentity.HasIndex(p => p.UserId).IsUnique();使用共享主键简化模型(如果适用)
csharp.HasForeignKey<UserProfile>(p => p.Id);配置级联删除行为
csharp.OnDelete(DeleteBehavior.Cascade);检查 null 导航属性
csharpif (user.Profile != null) { ... }
❌ 避免的错误
不要忘记配置外键
csharp// ❌ EF Core 可能无法自动识别 .WithOne(u => u.Profile) // 缺少 .HasForeignKey<>() // ✅ 明确指定 .WithOne(u => u.Profile) .HasForeignKey<UserProfile>(p => p.UserId)不要在一对一中使用集合
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();
});