在C#与MyBatis.NET技术栈中利用Interceptor实现多租户数据隔离的架构复盘


团队接手了一个SaaS项目,底层技术栈是.NET Core + MyBatis.NET + PostgreSQL,前端是Angular。项目初期为了快速验证市场,数据层面没有做严格的多租户隔离,所有租户数据都存放在同一数据库的同一套表中,仅通过一个tenant_id字段区分。随着客户增多,这种方式的弊端暴露无遗:每个数据查询都需要手动附加AND tenant_id = @CurrentTenantId。这不仅是重复劳动,更是一个巨大的安全隐患,任何一次开发疏忽都可能导致数据越权,后果不堪设想。

代码库里充斥着这样的XML映射:

<!-- 之前的做法,极易出错 -->
<select id="FindUsers" resultClass="User">
  SELECT id, name, email, created_at
  FROM users
  WHERE status = #status# AND tenant_id = #tenantId#
</select>

<select id="GetProductById" resultClass="Product">
  SELECT id, product_name, price
  FROM products
  WHERE id = #productId# AND tenant_id = #tenantId#
</select>

每个查询的tenant_id都由业务代码传入。在Code Review上,我们必须花费大量精力去检查每个SQL是否都包含了租户ID的过滤条件。这显然是不可持续的。

初步构想与方案权衡

我们需要一个自动化、无侵入的方案来强制执行数据隔离。初步讨论有几个方向:

  1. 数据库层面隔离
    • **独立数据库 (Database per Tenant)**:隔离性最好,但成本最高,管理复杂,尤其是在租户数量达到成百上千时,数据库连接和维护将是灾难。
    • **独立Schema (Schema per Tenant)**:一种折中方案,隔离性较好,资源开销可控。但需要动态切换search_path (PostgreSQL) 或类似机制,对应用层有一定侵入性,且不是所有数据库都支持得很好。
  2. 应用层面隔离
    • **仓储层封装 (Repository Pattern)**:在所有仓储方法的实现中统一添加租户过滤逻辑。这能解决问题,但依然依赖于开发者自觉遵守约定,无法从根本上杜绝绕过仓储直接执行SQL的风险。
    • ORM/数据映射器层面拦截:利用数据访问框架的扩展点,在SQL执行前进行动态修改,自动注入tenant_id条件。这是最具吸引力的方案,因为它对业务代码完全透明。

我们的技术栈是MyBatis.NET,它前身是iBATIS.NET。这个框架最强大的特性之一就是其拦截器(Interceptor)机制,它允许我们在SQL执行生命周期的关键节点(如参数化、执行、结果映射)插入自定义逻辑。这正是我们需要的钩子。

最终决策:采用应用层面的拦截器方案,基于MyBatis.NET的IInterceptor接口实现一个TenantDataIsolationInterceptor,在SQL预处理阶段自动为所有符合条件的SELECT, UPDATE, DELETE语句追加tenant_id过滤。

步骤化实现:构建透明的数据隔离层

整个实现分为三大部分:后端C#的租户上下文和拦截器实现,MyBatis.NET的配置,以及前端Angular的租户标识传递。

1. 后端实现 (C# & .NET Core)

首先,我们需要一种机制在每个HTTP请求的生命周期内安全地传递当前租户的信息。

a. 定义租户上下文

一个简单的TenantContext类,用于存储当前请求的租户ID。我们将其注册为Scoped服务,保证其在单个HTTP请求内是单例的。

// Services/TenantContext.cs

namespace MultiTenantApp.Api.Services
{
    /// <summary>
    /// 在单个请求作用域内存储当前租户ID。
    /// </summary>
    public interface ITenantContext
    {
        string? TenantId { get; set; }
    }

    public class TenantContext : ITenantContext
    {
        public string? TenantId { get; set; }
    }
}

Program.csStartup.cs中注册它:

// Program.cs
// ...
builder.Services.AddScoped<ITenantContext, TenantContext>();
// ...

b. 创建租户解析中间件

这个中间件负责从传入的HTTP请求中解析出租户ID,并设置到TenantContext中。在真实项目中,租户ID通常来自JWT声明(claim)、HTTP Header(如X-Tenant-ID)或子域名。这里我们为了演示,采用HTTP Header的方式。

// Middlewares/TenantResolutionMiddleware.cs

using MultiTenantApp.Api.Services;

namespace MultiTenantApp.Api.Middlewares
{
    public class TenantResolutionMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger<TenantResolutionMiddleware> _logger;

        public TenantResolutionMiddleware(RequestDelegate next, ILogger<TenantResolutionMiddleware> logger)
        {
            _next = next;
            _logger = logger;
        }

        public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
        {
            const string TenantIdHeaderName = "X-Tenant-ID";

            if (context.Request.Headers.TryGetValue(TenantIdHeaderName, out var tenantIdValue))
            {
                var tenantId = tenantIdValue.FirstOrDefault();
                if (!string.IsNullOrEmpty(tenantId))
                {
                    tenantContext.TenantId = tenantId;
                    _logger.LogInformation("Tenant resolved for request {TraceId}: {TenantId}", context.TraceIdentifier, tenantId);
                }
                else
                {
                    // 在真实项目中,这里可能需要返回400 Bad Request
                    _logger.LogWarning("X-Tenant-ID header was present but empty for request {TraceId}.", context.TraceIdentifier);
                }
            }
            else
            {
                // 对于某些公开API(如登录),没有租户ID是正常的
                _logger.LogInformation("No X-Tenant-ID header found for request {TraceId}. Proceeding without tenant context.", context.TraceIdentifier);
            }

            await _next(context);
        }
    }
}

同样,在Program.cs中注册此中间件,确保它在认证和授权中间件之后,但在MVC/API端点之前执行。

// Program.cs
// ...
app.UseAuthentication();
app.UseAuthorization();

// 在授权后立即解析租户信息
app.UseMiddleware<TenantResolutionMiddleware>();

app.MapControllers();
// ...

c. 核心:MyBatis.NET拦截器

这是整个方案的心脏。我们需要创建一个类实现IBatisNet.Common.Utilities.IInterceptor接口。这个接口只有一个Intercept方法。

我们关注的是SQL的预处理阶段。通过分析IBatisNet.DataMapper.MappedStatement对象,可以获取即将执行的SQL语句。然后,我们需要对SQL进行解析和改写。

一个常见的错误是使用复杂的正则表达式或SQL解析库来处理。对于我们这个特定场景——仅仅是为SELECT, UPDATE, DELETE语句的WHERE子句追加一个条件——过于复杂的方案会引入不必要的性能开销和依赖。一个更务实的做法是基于字符串进行智能查找和拼接,同时处理好WHERE子句是否存在等边界情况。

// Data/Interceptors/TenantDataIsolationInterceptor.cs

using System;
using System.Reflection;
using System.Text.RegularExpressions;
using IBatisNet.Common.Utilities;
using IBatisNet.DataMapper.Configuration.Statements;
using IBatisNet.DataMapper.MappedStatements;
using IBatisNet.DataMapper.Scope;
using Microsoft.Extensions.Logging;
using MultiTenantApp.Api.Services;

namespace MultiTenantApp.Api.Data.Interceptors
{
    /// <summary>
    /// MyBatis.NET 拦截器,用于自动注入多租户的 tenant_id 查询条件。
    /// 这里的关键在于它对业务代码完全透明。
    /// </summary>
    public class TenantDataIsolationInterceptor : IInterceptor
    {
        // 使用 IServiceProvider 来解决 Scoped Service 的注入问题
        // 因为 Interceptor 是单例生命周期
        private readonly IServiceProvider _serviceProvider;
        private readonly ILogger<TenantDataIsolationInterceptor> _logger;
        
        // 假设所有需要隔离的表都包含这个字段
        private const string TenantIdColumn = "tenant_id";
        
        // 使用正则表达式来匹配需要被拦截的SQL操作类型
        private static readonly Regex CommandTypeRegex = new Regex(@"^\s*(SELECT|UPDATE|DELETE)", RegexOptions.Compiled | RegexOptions.IgnoreCase);

        public TenantDataIsolationInterceptor(IServiceProvider serviceProvider, ILogger<TenantDataIsolationInterceptor> logger)
        {
            _serviceProvider = serviceProvider;
            _logger = logger;
        }
        
        public void Intercept(IInvocation invocation)
        {
            // invocation.Target 是被拦截的对象实例
            // 在这里我们关心的是 MappedStatement
            if (invocation.Target is MappedStatement mappedStatement)
            {
                using (var scope = _serviceProvider.CreateScope())
                {
                    var tenantContext = scope.ServiceProvider.GetRequiredService<ITenantContext>();
                    
                    // 如果当前请求上下文没有租户ID,则直接放行。
                    // 这对于登录、系统管理等非租户隔离的场景是必要的。
                    if (string.IsNullOrEmpty(tenantContext.TenantId))
                    {
                        invocation.Proceed();
                        return;
                    }
                    
                    // 获取当前的作用域和SQL语句
                    RequestScope requestScope = (RequestScope)invocation.Arguments[0];
                    IStatement statement = mappedStatement.Statement;
                    
                    // 只处理SELECT, UPDATE, DELETE语句
                    if (!ShouldApplyTenantFilter(statement.Sql.ToString()))
                    {
                        invocation.Proceed();
                        return;
                    }
                    
                    try
                    {
                        var originalSql = GetOriginalSql(requestScope);
                        var modifiedSql = ApplyTenantFilter(originalSql, tenantContext.TenantId);
                        
                        if (originalSql != modifiedSql)
                        {
                            _logger.LogDebug("Tenant filter applied. Original SQL: {OriginalSql} -> Modified SQL: {ModifiedSql}", originalSql, modifiedSql);
                            // 利用反射修改即将执行的SQL
                            // 这是一个hack,但在MyBatis.NET中是有效的做法
                            SetSqlForRequest(requestScope, modifiedSql);
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex, "Failed to apply tenant filter to SQL statement.");
                        // 发生异常时,为安全起见,阻止SQL执行
                        // 或者根据策略决定是否放行原始SQL
                        throw new InvalidOperationException("Tenant filter interception failed.", ex);
                    }
                }
            }
            
            invocation.Proceed();
        }

        private bool ShouldApplyTenantFilter(string sql)
        {
            return CommandTypeRegex.IsMatch(sql);
        }

        private string GetOriginalSql(RequestScope requestScope)
        {
            // Sql property of RequestScope's Statement is the one that gets executed
            return requestScope.Statement.Sql.ToString();
        }
        
        private void SetSqlForRequest(RequestScope requestScope, string sql)
        {
            // MyBatis.NET 内部 Statement.Sql 是只读的,但我们可以通过反射修改其内部的SQL字符串
            var statement = requestScope.Statement;
            var sqlField = statement.GetType().GetField("_sql", BindingFlags.Instance | BindingFlags.NonPublic);
            if (sqlField != null)
            {
                sqlField.SetValue(statement, sql);
            }
            else
            {
                // 如果反射失败,这是一个硬性错误,说明框架内部实现已变更
                throw new NotSupportedException("Cannot modify SQL via reflection. MyBatis.NET internal structure might have changed.");
            }
        }

        private string ApplyTenantFilter(string sql, string tenantId)
        {
            // 避免对已经包含 tenant_id 条件的SQL重复添加
            if (sql.IndexOf(TenantIdColumn, StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return sql;
            }

            // 这是一个简化的SQL解析逻辑,在真实项目中可能需要更健壮的方案
            // 核心思路是找到 WHERE 子句并追加条件,或者在没有 WHERE 时添加一个
            var sqlLower = sql.ToLower();
            var whereIndex = sqlLower.IndexOf(" where ", StringComparison.Ordinal);

            string tenantFilter = $" {TenantIdColumn} = '{tenantId}' "; // 注意SQL注入风险,这里tenantId应是可信来源

            // 定位插入点
            // 我们需要考虑 GROUP BY, HAVING, ORDER BY, LIMIT 等子句
            int insertionPoint = -1;
            var clauses = new[] { "group by", "having", "order by", "limit", "offset", "fetch" };
            foreach (var clause in clauses)
            
            {
                var index = sqlLower.IndexOf($" {clause} ", StringComparison.Ordinal);
                if (index != -1 && (insertionPoint == -1 || index < insertionPoint))
                {
                    insertionPoint = index;
                }
            }


            if (whereIndex != -1) // 已存在WHERE子句
            {
                // 在 WHERE 子句后追加 AND 条件
                var conditionInsertionPoint = whereIndex + " where ".Length;
                 if (insertionPoint > conditionInsertionPoint) // 确保在其他子句之前
                {
                    // 找到WHERE子句的末尾
                    var whereClauseEnd = sqlLower.Substring(0, insertionPoint);
                    return sql.Insert(whereClauseEnd.Length, $" AND {tenantFilter} ");
                }
                else // WHERE是最后一个子句
                {
                     return sql.Insert(sql.Length, $" AND {tenantFilter}");
                }
            }
            else // 不存在WHERE子句
            {
                if (insertionPoint != -1) // 存在其他子句 (GROUP BY, ORDER BY, etc.)
                {
                    return sql.Insert(insertionPoint, $" WHERE {tenantFilter} ");
                }
                else // 纯粹的 SELECT ... FROM ... 或 DELETE FROM ...
                {
                    return sql + $" WHERE {tenantFilter}";
                }
            }
        }
    }
}

d. 配置MyBatis.NET

拦截器的注册是在sqlMapConfig.xml文件中完成的。但由于我们的拦截器依赖于.NET Core的依赖注入容器(为了获取ITenantContext),我们不能直接在XML中配置。我们需要在代码中构建DomSqlMapBuilder,并将我们手动创建的拦截器实例注入进去。

首先,在Program.cs中注册拦截器:

// Program.cs
builder.Services.AddSingleton<TenantDataIsolationInterceptor>();

然后,创建SqlMapper实例时手动添加它:

// Data/MapperFactory.cs

using IBatisNet.DataMapper;
using IBatisNet.DataMapper.Configuration;
using MultiTenantApp.Api.Data.Interceptors;

namespace MultiTenantApp.Api.Data
{
    public static class MapperFactory
    {
        public static ISqlMapper Configure(IServiceProvider serviceProvider)
        {
            var interceptor = serviceProvider.GetRequiredService<TenantDataIsolationInterceptor>();
            
            var builder = new DomSqlMapBuilder
            {
                // 允许在XML中不完全限定类型名
                UseConfigFileWatcher = true,
                ValidateSqlMap = true
            };

            // 手动构建 SqlMapper 并添加拦截器
            // 这是关键一步
            ISqlMapper mapper = builder.ConfigureAndWatch("sqlMap.config", (sender, e) => {
                // 如果配置文件发生变化,重新配置并添加拦截器
                var reconfiguredMapper = (ISqlMapper)sender;
                var interceptorField = typeof(SqlMapper).GetField("_interceptorChain", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
                
                var chain = new InterceptorChain();
                chain.AddInterceptor(interceptor);
                
                interceptorField?.SetValue(reconfiguredMapper.Delegate, chain);
            });
            
            // 初始加载时也需要添加
            var initialChain = new InterceptorChain();
            initialChain.AddInterceptor(interceptor);
            var field = typeof(SqlMapper).GetField("_interceptorChain", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
            field?.SetValue(mapper.Delegate, initialChain);

            return mapper;
        }
    }
}

最后,在Program.cs中注册ISqlMapper

// Program.cs
// ...
builder.Services.AddSingleton<ISqlMapper>(sp => MapperFactory.Configure(sp));
// ...

至此,后端的所有工作都完成了。整个流程如下:

sequenceDiagram
    participant AngularApp as Angular App
    participant CSharpApi as C# API (.NET Core)
    participant Middleware as Tenant Middleware
    participant TenantCtx as TenantContext (Scoped)
    participant MybatisInt as Tenant Interceptor
    participant Database

    AngularApp->>CSharpApi: HTTP GET /api/products (Header: X-Tenant-ID: tenant-A)
    CSharpApi->>Middleware: InvokeAsync()
    Middleware->>TenantCtx: tenantContext.TenantId = "tenant-A"
    Middleware->>CSharpApi: next()
    CSharpApi->>CSharpApi: Controller Action -> Service Call
    CSharpApi->>MybatisInt: Data access via ISqlMapper
    MybatisInt->>MybatisInt: Intercept() called
    MybatisInt->>TenantCtx: Get current TenantId ("tenant-A")
    MybatisInt->>MybatisInt: Original SQL: "SELECT * FROM products"
    MybatisInt->>MybatisInt: Modified SQL: "SELECT * FROM products WHERE tenant_id = 'tenant-A'"
    MybatisInt->>Database: Execute Modified SQL
    Database-->>MybatisInt: Return rows for tenant-A only
    MybatisInt-->>CSharpApi: Return results
    CSharpApi-->>AngularApp: HTTP 200 OK with tenant-A's data

2. 前端实现 (Angular)

前端的任务相对简单:在每次对后端的API请求中,自动附加X-Tenant-ID头。这正是Angular的HttpInterceptor的用武之地。

a. 创建租户服务

首先,一个服务用于存储和获取当前用户的租户信息。这通常在用户登录后设置。

// src/app/core/services/tenant.service.ts

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class TenantService {
  private currentTenantId: string | null = null;

  constructor() {
    // 在真实应用中,这会从localStorage或一个状态管理库中加载
    this.currentTenantId = localStorage.getItem('tenantId');
  }

  setCurrentTenant(tenantId: string): void {
    this.currentTenantId = tenantId;
    localStorage.setItem('tenantId', tenantId);
  }

  getCurrentTenant(): string | null {
    return this.currentTenantId;
  }

  clearTenant(): void {
    this.currentTenantId = null;
    localStorage.removeItem('tenantId');
  }
}

b. 创建HTTP拦截器

这个拦截器会捕获所有出站的HTTP请求,并如果当前存在租户ID,就将其添加到请求头中。

// src/app/core/interceptors/tenant.interceptor.ts

import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { TenantService } from '../services/tenant.service';

@Injectable()
export class TenantInterceptor implements HttpInterceptor {

  constructor(private tenantService: TenantService) {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    const tenantId = this.tenantService.getCurrentTenant();

    // 只为我们的API请求添加头,避免给第三方API发送
    // 这里用 aoi/ 举例
    if (tenantId && request.url.includes('api/')) {
      const clonedRequest = request.clone({
        setHeaders: {
          'X-Tenant-ID': tenantId
        }
      });
      console.log(`TenantInterceptor: Added X-Tenant-ID header with value ${tenantId}`);
      return next.handle(clonedRequest);
    }

    return next.handle(request);
  }
}

c. 在AppModule中提供拦截器

最后,在app.module.ts中注册这个拦截器。

// src/app/app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';

import { AppComponent } from './app.component';
import { TenantInterceptor } from './core/interceptors/tenant.interceptor';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: TenantInterceptor,
      multi: true
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

现在,任何通过Angular的HttpClient发出的API请求都会自动携带租户信息,开发者在调用服务时完全无需关心这一点。

最终成果与反思

改造完成后,我们的MyBatis XML映射文件变得极其干净:

<!-- 改造后,业务代码只关心业务逻辑 -->
<select id="FindUsers" resultClass="User">
  SELECT id, name, email, created_at
  FROM users
  WHERE status = #status#
</select>

<select id="GetProductById" resultClass="Product">
  SELECT id, product_name, price
  FROM products
  WHERE id = #productId#
</select>

开发者不再需要记忆或强制要求添加tenant_id。数据隔离从一个“编码规范”变成了一个“架构特性”,健壮性大大提升。Code Review的焦点也回归到了业务逻辑本身。

遗留问题与未来迭代方向

这个方案并非完美,它也存在一些局限性和需要注意的地方:

  1. SQL改写的健壮性:当前基于字符串拼接的SQL修改逻辑相对简单,可能无法处理极其复杂的SQL,比如带有子查询、UNIONWITH公用表表达式的语句。如果项目中有大量这类复杂查询,可能需要引入一个轻量级的SQL解析器库(如ANTLR的SQL语法),但这会增加复杂性和性能开销。
  2. 特殊场景的绕过机制:某些后台管理功能可能需要查询所有租户的数据。当前的拦截器是一刀切的,需要设计一种机制(例如,通过在SQL映射中添加一个特殊的注释/* no-tenant-filter */)来让拦截器识别并跳过某些特定的查询。
  3. 性能开销:尽管反射和字符串操作都很快,但在极高的QPS下,拦截器带来的微小延迟累积起来也可能成为瓶颈。需要进行严格的性能测试来评估其影响。在我们的场景下,测试表明其开销完全可以忽略不计。
  4. 对存储过程的支持:此方案对直接调用存储过程无效。如果项目大量使用存储过程,那么隔离逻辑必须在存储过程内部实现,例如通过传入tenant_id作为参数。

未来的迭代可以考虑将租户ID与数据库连接绑定,比如在从连接池获取连接后立即执行SET app.current_tenant_id = '...'之类的会话变量设置,然后数据库层面通过视图或行级安全策略(Row-Level Security, RLS)来利用这个会话变量实现过滤。这会将隔离逻辑下沉到数据库,更为彻底,但对数据库的依赖也更强。


  目录