SaaS 產品常需為每個租戶分配專屬二級域名:tenant1.app.com、tenant2.app.com。本文展示基於 ASP.NET Routing 捕獲子域、結合 DNS 泛解析、配合租戶解析管線的完整方案。
; 域名服務商控制面板新增記錄
* A 192.168.1.100 ; 所有 *.app.com 指向同一伺服器 IP
; 或
* CNAME app.com. ; 指向主域別名
驗證:nslookup random.app.com 解析至目標 IP。
Host Name 留空 (接收所有主機頭) 或明確添加 *.app.com (需 IIS 10+/Windows Server 2016+ 支援通配符)。public class SubdomainRoute : RouteBase {
public override RouteData GetRouteData(HttpContextBase httpContext) {
var host = httpContext.Request.Headers["Host"]; // tenant.app.com:80
if (string.IsNullOrEmpty(host)) return null;
var parts = host.Split('.');
if (parts.Length < 3) return null; // 非二級域名 (如 www.app.com)
var tenant = parts[0]; // tenant
// 可選:驗證租戶是否存在/啟用
if (!TenantService.Exists(tenant)) return null;
var routeData = new RouteData(this, new MvcRouteHandler());
routeData.Values["controller"] = "Home";
routeData.Values["action"] = "Index";
routeData.Values["tenant"] = tenant; // 關鍵:將租戶標識注入路由值
routeData.DataTokens["namespaces"] = new[] { "MyApp.Controllers" };
return routeData;
}
public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
=> null; // 不支援反向生成子域 URL,需自行實作
}
// Global.asax 註冊 (優先於預設路由)
public static void RegisterRoutes(RouteCollection routes) {
routes.Add("Subdomain", new SubdomainRoute());
routes.MapRoute("Default", "{controller}/{action}/{id}",
new { controller="Home", action="Index", id=UrlParameter.Optional });
}
public abstract class TenantControllerBase : Controller {
protected TenantContext Tenant { get; private set; }
protected override void Initialize(RequestContext requestContext) {
base.Initialize(requestContext);
var tenantSlug = requestContext.RouteData.Values["tenant"] as string
?? requestContext.HttpContext.Request.Headers["X-Tenant"]; // 支援 Header 傳遞
Tenant = TenantService.GetContext(tenantSlug); // 包含 DB 連接串、配置、品牌化資源
if (Tenant == null) throw new HttpException(404, "租戶不存在");
// 設定當前執行緒文化、連接字串、View 路徑等
}
}
// 使用
public class HomeController : TenantControllerBase {
public ActionResult Index() {
ViewBag.TenantName = Tenant.Name;
return View(); // 可根據 Tenant.Theme 選擇不同視圖
}
}
| 策略 | 實現 | 優點 | 缺點 |
|---|---|---|---|
| 共享資料庫 + 租戶欄位 | 所有表加 TenantId,查詢自動過濾 | 維護簡單、成本低 | 資料洩露風險、效能干擾、Schema 變更影響所有 |
| 共享資料庫 + 獨立 Schema | PostgreSQL Schema / SQL Server 命名空間 | 隔離性較好、備份靈活 | 跨 Schema 查詢複雜、遷移工具支援有限 |
| 獨立資料庫 | 每租戶一庫,連接字串動態構建 | 最高隔離、可定制版本、合規友善 | 維護成本高、連接池碎片化、部署複雜 |
public class TenantResolver {
public string Resolve(HttpRequestBase request) {
// 1. 子域名
var host = request.Headers["Host"];
if (host != null) {
var parts = host.Split('.');
if (parts.Length >= 3 && !IsWww(parts[0])) return parts[0];
}
// 2. 路由參數 (Area/Route)
var routeTenant = request.RequestContext.RouteData.Values["tenant"] as string;
if (!string.IsNullOrEmpty(routeTenant)) return routeTenant;
// 3. Header (API 客戶端)
var headerTenant = request.Headers["X-Tenant-Id"];
if (!string.IsNullOrEmpty(headerTenant)) return headerTenant;
// 4. QueryString (測試/臨時)
var queryTenant = request.QueryString["tenant"];
if (!string.IsNullOrEmpty(queryTenant)) return queryTenant;
return null; // 預設/公共租戶
}
}
.app.com 共享登入態,參考《跨子域 Session 共享》。*.app.com 或 Let's Encrypt 泛域名憑證 (DNS-01 驗證)。/health (排除租戶解析)。hosts 映射 tenant1.local.app.com → 127.0.0.1,或使用 lvh.me (解析至 127.0.0.1)。