第十六章:多租戶架構 - 習題解答
本文件提供第十六章實戰練習的完整解答,涵蓋多租戶應用建立、Feature-based 定價和獨立資料庫遷移。
練習 1:建立多租戶應用
題目
- 啟用多租戶功能。
- 建立兩個租戶:
TenantA與TenantB。 - 分別以兩個租戶的身分建立書籍,驗證資料隔離。
解答
步驟 1:啟用多租戶功能
csharp
// BookStore.Domain/BookStoreDomainModule.cs
using Volo.Abp.MultiTenancy;
[DependsOn(typeof(AbpMultiTenancyModule))]
public class BookStoreDomainModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
});
}
}步驟 2:確保實體實作 IMultiTenant
csharp
// BookStore.Domain/Books/Book.cs
using Volo.Abp.MultiTenancy;
public class Book : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public Guid? TenantId { get; set; } // ABP 會自動管理此欄位
public string Name { get; set; }
public BookType Type { get; set; }
public DateTime PublishDate { get; set; }
public float Price { get; set; }
// ... 其他屬性和方法
}步驟 3:建立租戶(使用 UI 或程式碼)
方法 1:使用 ABP 內建的租戶管理 UI
- 啟動應用程式
- 以 admin 身分登入
- 導航至「管理」→「租戶」
- 點擊「新增租戶」
- 輸入租戶名稱:
TenantA - 設定管理員 Email 和密碼
- 重複步驟建立
TenantB
方法 2:使用程式碼建立
csharp
// BookStore.DbMigrator/DbMigratorHostedService.cs
using Volo.Abp.TenantManagement;
public class DbMigratorHostedService : IHostedService
{
private readonly ITenantManager _tenantManager;
private readonly ITenantRepository _tenantRepository;
public async Task StartAsync(CancellationToken cancellationToken)
{
// 建立 TenantA
var tenantA = await _tenantManager.CreateAsync("TenantA");
await _tenantRepository.InsertAsync(tenantA);
// 建立 TenantB
var tenantB = await _tenantManager.CreateAsync("TenantB");
await _tenantRepository.InsertAsync(tenantB);
await _unitOfWorkManager.Current.SaveChangesAsync();
}
}步驟 4:配置租戶解析器
csharp
// BookStore.HttpApi.Host/BookStoreHttpApiHostModule.cs
using Volo.Abp.MultiTenancy;
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpTenantResolveOptions>(options =>
{
// 優先使用 Header 解析
options.TenantResolvers.Clear();
options.TenantResolvers.Add(new HeaderTenantResolveContributor());
options.TenantResolvers.Add(new QueryStringTenantResolveContributor());
options.TenantResolvers.Add(new CookieTenantResolveContributor());
});
}步驟 5:測試資料隔離
使用 Postman 或 curl 測試:
bash
# 為 TenantA 建立書籍
curl -X POST http://localhost:5000/api/app/book \
-H "Content-Type: application/json" \
-H "__tenant: TenantA" \
-H "Authorization: Bearer <token>" \
-d '{
"name": "TenantA的書籍",
"type": 0,
"publishDate": "2024-01-01",
"price": 99.99
}'
# 為 TenantB 建立書籍
curl -X POST http://localhost:5000/api/app/book \
-H "Content-Type: application/json" \
-H "__tenant: TenantB" \
-H "Authorization: Bearer <token>" \
-d '{
"name": "TenantB的書籍",
"type": 1,
"publishDate": "2024-01-01",
"price": 199.99
}'
# 查詢 TenantA 的書籍(應該只看到 TenantA 的書)
curl -X GET http://localhost:5000/api/app/book \
-H "__tenant: TenantA" \
-H "Authorization: Bearer <token>"
# 查詢 TenantB 的書籍(應該只看到 TenantB 的書)
curl -X GET http://localhost:5000/api/app/book \
-H "__tenant: TenantB" \
-H "Authorization: Bearer <token>"步驟 6:在資料庫中驗證
sql
-- 查看所有書籍及其租戶
SELECT Id, Name, TenantId FROM AppBooks;
-- 應該看到:
-- | Id | Name | TenantId |
-- | ... | TenantA的書籍 | <TenantA的GUID> |
-- | ... | TenantB的書籍 | <TenantB的GUID> |步驟 7:在程式碼中手動切換租戶(用於測試或後台任務)
csharp
// 測試程式碼
public class BookServiceTests
{
private readonly ICurrentTenant _currentTenant;
private readonly IBookAppService _bookAppService;
[Fact]
public async Task Should_Isolate_Data_Between_Tenants()
{
Guid tenantAId = /* TenantA 的 ID */;
Guid tenantBId = /* TenantB 的 ID */;
// 以 TenantA 身分建立書籍
Guid bookIdA;
using (_currentTenant.Change(tenantAId))
{
var book = await _bookAppService.CreateAsync(new CreateUpdateBookDto
{
Name = "TenantA的書籍",
Type = BookType.Adventure,
PublishDate = DateTime.Now,
Price = 99.99f
});
bookIdA = book.Id;
}
// 以 TenantB 身分查詢,應該查不到 TenantA 的書
using (_currentTenant.Change(tenantBId))
{
var books = await _bookAppService.GetListAsync(new PagedAndSortedResultRequestDto());
books.Items.ShouldNotContain(b => b.Id == bookIdA);
}
}
}練習 2:實作 Feature-based 定價
題目
- 定義三個 Features:
MaxUsers,AdvancedReporting,APIAccess。 - 建立三個定價方案:
- Basic: MaxUsers=10, 其他關閉。
- Pro: MaxUsers=100, AdvancedReporting=開啟。
- Enterprise: 全部開啟。
- 為不同租戶設定不同方案,並測試功能限制。
解答
步驟 1:定義 Features
csharp
// BookStore.Domain.Shared/Features/BookStoreFeatureDefinitionProvider.cs
using Volo.Abp.Features;
using Volo.Abp.Localization;
using Volo.Abp.Validation.StringValues;
namespace BookStore.Features
{
public class BookStoreFeatureDefinitionProvider : FeatureDefinitionProvider
{
public override void Define(IFeatureDefinitionContext context)
{
var group = context.AddGroup("BookStore", L("Feature:BookStore"));
// 最大使用者數
group.AddFeature(
BookStoreFeatures.MaxUsers,
defaultValue: "10",
displayName: L("Feature:MaxUsers"),
description: L("Feature:MaxUsersDescription"),
valueType: new FreeTextStringValueType(
new NumericValueValidator(1, 10000))
);
// 進階報表功能
group.AddFeature(
BookStoreFeatures.AdvancedReporting,
defaultValue: "false",
displayName: L("Feature:AdvancedReporting"),
description: L("Feature:AdvancedReportingDescription"),
valueType: new ToggleStringValueType()
);
// API 存取權限
group.AddFeature(
BookStoreFeatures.APIAccess,
defaultValue: "false",
displayName: L("Feature:APIAccess"),
description: L("Feature:APIAccessDescription"),
valueType: new ToggleStringValueType()
);
}
private static LocalizableString L(string name)
{
return LocalizableString.Create<BookStoreResource>(name);
}
}
}csharp
// BookStore.Domain.Shared/Features/BookStoreFeatures.cs
namespace BookStore.Features
{
public static class BookStoreFeatures
{
public const string GroupName = "BookStore";
public const string MaxUsers = GroupName + ".MaxUsers";
public const string AdvancedReporting = GroupName + ".AdvancedReporting";
public const string APIAccess = GroupName + ".APIAccess";
}
}步驟 2:建立定價方案服務
csharp
// BookStore.Domain/Pricing/PricingPlan.cs
namespace BookStore.Pricing
{
public enum PricingPlan
{
Basic,
Pro,
Enterprise
}
public class PricingPlanConfiguration
{
public int MaxUsers { get; set; }
public bool AdvancedReporting { get; set; }
public bool APIAccess { get; set; }
public static PricingPlanConfiguration GetConfiguration(PricingPlan plan)
{
return plan switch
{
PricingPlan.Basic => new PricingPlanConfiguration
{
MaxUsers = 10,
AdvancedReporting = false,
APIAccess = false
},
PricingPlan.Pro => new PricingPlanConfiguration
{
MaxUsers = 100,
AdvancedReporting = true,
APIAccess = false
},
PricingPlan.Enterprise => new PricingPlanConfiguration
{
MaxUsers = 10000,
AdvancedReporting = true,
APIAccess = true
},
_ => throw new ArgumentException("Invalid pricing plan", nameof(plan))
};
}
}
}csharp
// BookStore.Application/Pricing/PricingPlanAppService.cs
using Volo.Abp.Features;
using BookStore.Features;
namespace BookStore.Pricing
{
public class PricingPlanAppService : ApplicationService
{
private readonly IFeatureManager _featureManager;
public PricingPlanAppService(IFeatureManager featureManager)
{
_featureManager = featureManager;
}
public async Task SetPricingPlanAsync(Guid tenantId, PricingPlan plan)
{
var config = PricingPlanConfiguration.GetConfiguration(plan);
await _featureManager.SetForTenantAsync(
tenantId,
BookStoreFeatures.MaxUsers,
config.MaxUsers.ToString());
await _featureManager.SetForTenantAsync(
tenantId,
BookStoreFeatures.AdvancedReporting,
config.AdvancedReporting.ToString().ToLowerInvariant());
await _featureManager.SetForTenantAsync(
tenantId,
BookStoreFeatures.APIAccess,
config.APIAccess.ToString().ToLowerInvariant());
}
}
}步驟 3:在應用服務中檢查 Features
csharp
// BookStore.Application/Users/UserAppService.cs
using Volo.Abp.Features;
using BookStore.Features;
public class UserAppService : ApplicationService
{
public async Task<UserDto> CreateAsync(CreateUserDto input)
{
// 檢查是否超過最大使用者數限制
var maxUsers = await FeatureChecker.GetAsync<int>(BookStoreFeatures.MaxUsers);
var currentUserCount = await _userRepository.GetCountAsync();
if (currentUserCount >= maxUsers)
{
throw new BusinessException("BookStore:MaxUsersExceeded")
.WithData("MaxUsers", maxUsers)
.WithData("CurrentUsers", currentUserCount);
}
// 建立使用者...
}
}csharp
// BookStore.Application/Reports/ReportAppService.cs
using Volo.Abp.Features;
using BookStore.Features;
public class ReportAppService : ApplicationService
{
[RequiresFeature(BookStoreFeatures.AdvancedReporting)]
public async Task<byte[]> GenerateAdvancedReportAsync(ReportInput input)
{
// 只有啟用進階報表功能的租戶才能呼叫此方法
// ABP 會自動檢查並拋出異常
// 生成報表邏輯...
}
public async Task<byte[]> GenerateBasicReportAsync(ReportInput input)
{
// 所有租戶都可以使用基本報表
// 生成報表邏輯...
}
}csharp
// BookStore.HttpApi/Controllers/ApiController.cs
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Features;
using BookStore.Features;
[ApiController]
[Route("api/[controller]")]
public class BooksApiController : AbpController
{
private readonly IFeatureChecker _featureChecker;
private readonly IBookAppService _bookAppService;
[HttpGet]
public async Task<IActionResult> GetListAsync()
{
// 檢查 API 存取權限
if (!await _featureChecker.IsEnabledAsync(BookStoreFeatures.APIAccess))
{
return Forbid("API access is not enabled for your plan");
}
var books = await _bookAppService.GetListAsync(new PagedAndSortedResultRequestDto());
return Ok(books);
}
}步驟 4:設定租戶的定價方案
csharp
// 在 DbMigrator 或管理介面中設定
public async Task SeedTenantsAsync()
{
var tenantA = await _tenantRepository.FindByNameAsync("TenantA");
var tenantB = await _tenantRepository.FindByNameAsync("TenantB");
var tenantC = await _tenantRepository.FindByNameAsync("TenantC");
// TenantA: Basic 方案
await _pricingPlanAppService.SetPricingPlanAsync(tenantA.Id, PricingPlan.Basic);
// TenantB: Pro 方案
await _pricingPlanAppService.SetPricingPlanAsync(tenantB.Id, PricingPlan.Pro);
// TenantC: Enterprise 方案
await _pricingPlanAppService.SetPricingPlanAsync(tenantC.Id, PricingPlan.Enterprise);
}步驟 5:在 UI 中顯示方案限制
razor
@* Blazor 範例 *@
@inject IFeatureChecker FeatureChecker
<Card>
<CardHeader>
<h3>您的方案</h3>
</CardHeader>
<CardBody>
<p>最大使用者數:@maxUsers</p>
<p>進階報表:@(advancedReporting ? "✓ 已啟用" : "✗ 未啟用")</p>
<p>API 存取:@(apiAccess ? "✓ 已啟用" : "✗ 未啟用")</p>
@if (!advancedReporting)
{
<Alert Color="Color.Info">
升級至 Pro 方案以使用進階報表功能
</Alert>
}
</CardBody>
</Card>
@code {
private int maxUsers;
private bool advancedReporting;
private bool apiAccess;
protected override async Task OnInitializedAsync()
{
maxUsers = await FeatureChecker.GetAsync<int>(BookStoreFeatures.MaxUsers);
advancedReporting = await FeatureChecker.IsEnabledAsync(BookStoreFeatures.AdvancedReporting);
apiAccess = await FeatureChecker.IsEnabledAsync(BookStoreFeatures.APIAccess);
}
}練習 3:獨立資料庫遷移
題目
- 為一個租戶配置獨立的資料庫連線字串。
- 實作自動遷移腳本,在租戶建立時自動建立並初始化資料庫。
解答
步驟 1:配置租戶的獨立連線字串
csharp
// BookStore.Application/Tenants/TenantAppService.cs
using Volo.Abp.TenantManagement;
public class TenantAppService : ApplicationService
{
private readonly ITenantManager _tenantManager;
private readonly ITenantRepository _tenantRepository;
public async Task<TenantDto> CreateWithDatabaseAsync(CreateTenantWithDatabaseDto input)
{
// 建立租戶
var tenant = await _tenantManager.CreateAsync(input.Name);
// 設定獨立的連線字串
var connectionString = BuildConnectionString(input.Name, input.DatabaseServer);
tenant.SetConnectionString(connectionString);
await _tenantRepository.InsertAsync(tenant);
return ObjectMapper.Map<Tenant, TenantDto>(tenant);
}
private string BuildConnectionString(string tenantName, string server)
{
return $"Server={server};Database=BookStore_{tenantName};User Id=sa;Password=YourPassword;TrustServerCertificate=True";
}
}步驟 2:實作資料庫遷移服務
csharp
// BookStore.Domain/Tenants/TenantDatabaseMigrationService.cs
using Microsoft.EntityFrameworkCore;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Uow;
namespace BookStore.Tenants
{
public class TenantDatabaseMigrationService : ITransientDependency
{
private readonly ICurrentTenant _currentTenant;
private readonly IUnitOfWorkManager _unitOfWorkManager;
private readonly IDbContextProvider<BookStoreDbContext> _dbContextProvider;
private readonly ILogger<TenantDatabaseMigrationService> _logger;
public TenantDatabaseMigrationService(
ICurrentTenant currentTenant,
IUnitOfWorkManager unitOfWorkManager,
IDbContextProvider<BookStoreDbContext> dbContextProvider,
ILogger<TenantDatabaseMigrationService> logger)
{
_currentTenant = currentTenant;
_unitOfWorkManager = unitOfWorkManager;
_dbContextProvider = dbContextProvider;
_logger = logger;
}
public async Task MigrateTenantDatabaseAsync(Guid tenantId)
{
using (_currentTenant.Change(tenantId))
{
using (var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: false))
{
try
{
_logger.LogInformation($"開始遷移租戶 {tenantId} 的資料庫...");
var dbContext = await _dbContextProvider.GetDbContextAsync();
// 建立資料庫(如果不存在)
await dbContext.Database.EnsureCreatedAsync();
// 執行 Migration
await dbContext.Database.MigrateAsync();
_logger.LogInformation($"租戶 {tenantId} 的資料庫遷移完成");
await uow.CompleteAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, $"租戶 {tenantId} 的資料庫遷移失敗");
throw;
}
}
}
}
public async Task MigrateAllTenantsAsync()
{
var tenants = await _tenantRepository.GetListAsync();
foreach (var tenant in tenants)
{
await MigrateTenantDatabaseAsync(tenant.Id);
}
}
}
}步驟 3:在租戶建立時自動遷移
csharp
// BookStore.Application/Tenants/TenantAppService.cs
public class TenantAppService : ApplicationService
{
private readonly TenantDatabaseMigrationService _migrationService;
public async Task<TenantDto> CreateWithDatabaseAsync(CreateTenantWithDatabaseDto input)
{
// 建立租戶
var tenant = await _tenantManager.CreateAsync(input.Name);
tenant.SetConnectionString(BuildConnectionString(input.Name, input.DatabaseServer));
await _tenantRepository.InsertAsync(tenant);
// 自動建立並遷移資料庫
await _migrationService.MigrateTenantDatabaseAsync(tenant.Id);
// 初始化種子資料(選擇性)
await SeedTenantDataAsync(tenant.Id);
return ObjectMapper.Map<Tenant, TenantDto>(tenant);
}
private async Task SeedTenantDataAsync(Guid tenantId)
{
using (_currentTenant.Change(tenantId))
{
// 建立預設角色、權限等
await _dataSeeder.SeedAsync(new DataSeedContext(tenantId));
}
}
}步驟 4:建立 CLI 工具進行批量遷移
csharp
// BookStore.DbMigrator/TenantMigrationCommand.cs
public class TenantMigrationCommand
{
private readonly TenantDatabaseMigrationService _migrationService;
private readonly ITenantRepository _tenantRepository;
public async Task ExecuteAsync(string[] args)
{
if (args.Contains("--all-tenants"))
{
Console.WriteLine("開始遷移所有租戶的資料庫...");
await _migrationService.MigrateAllTenantsAsync();
Console.WriteLine("所有租戶遷移完成!");
}
else if (args.Contains("--tenant"))
{
var tenantName = args[Array.IndexOf(args, "--tenant") + 1];
var tenant = await _tenantRepository.FindByNameAsync(tenantName);
if (tenant == null)
{
Console.WriteLine($"找不到租戶:{tenantName}");
return;
}
Console.WriteLine($"開始遷移租戶 {tenantName} 的資料庫...");
await _migrationService.MigrateTenantDatabaseAsync(tenant.Id);
Console.WriteLine("遷移完成!");
}
}
}使用方式:
bash
# 遷移所有租戶
dotnet run --project src/BookStore.DbMigrator -- --all-tenants
# 遷移特定租戶
dotnet run --project src/BookStore.DbMigrator -- --tenant TenantA步驟 5:監控和錯誤處理
csharp
// BookStore.Domain/Tenants/TenantDatabaseMigrationService.cs
public async Task MigrateTenantDatabaseAsync(Guid tenantId)
{
var maxRetries = 3;
var retryCount = 0;
while (retryCount < maxRetries)
{
try
{
using (_currentTenant.Change(tenantId))
{
using (var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: false))
{
var dbContext = await _dbContextProvider.GetDbContextAsync();
// 檢查資料庫連線
if (!await dbContext.Database.CanConnectAsync())
{
_logger.LogWarning($"無法連線到租戶 {tenantId} 的資料庫,嘗試建立...");
await dbContext.Database.EnsureCreatedAsync();
}
// 執行 Migration
var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync();
if (pendingMigrations.Any())
{
_logger.LogInformation($"租戶 {tenantId} 有 {pendingMigrations.Count()} 個待執行的遷移");
await dbContext.Database.MigrateAsync();
}
else
{
_logger.LogInformation($"租戶 {tenantId} 的資料庫已是最新版本");
}
await uow.CompleteAsync();
break; // 成功,跳出重試迴圈
}
}
}
catch (Exception ex)
{
retryCount++;
_logger.LogError(ex, $"租戶 {tenantId} 的資料庫遷移失敗(嘗試 {retryCount}/{maxRetries})");
if (retryCount >= maxRetries)
{
throw new BusinessException("BookStore:TenantMigrationFailed")
.WithData("TenantId", tenantId)
.WithData("Retries", retryCount);
}
await Task.Delay(TimeSpan.FromSeconds(5 * retryCount)); // 指數退避
}
}
}總結
本章練習涵蓋了多租戶架構的核心實作:
多租戶應用建立:
- 啟用多租戶功能
- 建立和管理租戶
- 驗證資料隔離
Feature-based 定價:
- 定義和管理 Features
- 實作定價方案
- 在應用服務中檢查功能權限
獨立資料庫遷移:
- 配置租戶專屬連線字串
- 自動建立和遷移資料庫
- 處理遷移錯誤和重試
最佳實踐:
- 始終使用
IMultiTenant介面標記需要隔離的實體 - 使用 Feature Management 實現靈活的定價策略
- 為每個租戶提供獨立的資料庫以獲得最佳隔離性
- 實作完善的錯誤處理和監控機制
- 考慮使用資料庫連接池優化效能