ABP 框架 - 深入理解与使用会话(Session)

在构建现代 Web 应用程序时,管理用户状态和上下文信息是至关重要的。会话(Session)作为一种常见的机制,用于在多个 HTTP 请求之间存储和跟踪用户特定的数据。然而,直接处理底层会话 API 可能会显得繁琐,并且容易产生重复代码。

ABP (ASP.NET Boilerplate) 框架提供了一个强大且抽象的会话管理系统,它将 ASP.NET Core 的会话概念与自己的多租户、授权等体系无缝集成。这套系统通过一个统一的 ISession 接口,让开发者能够以一种类型安全、易于测试和一致的方式访问当前用户和租户的信息。本文将深入探讨 ABP 框架中的会话机制,包括其核心概念、使用方法、常见实践以及最佳实践。

目录#

  1. ABP 会话系统概述
  2. 核心接口与类
  3. 会话数据的来源与存储
  4. 在应用中使用会话
    • 在应用服务中使用
    • 在控制器中使用
    • 在领域层中使用(谨慎)
  5. 常见实践与示例
  6. 最佳实践与陷阱
  7. 总结
  8. 参考

ABP 会话系统概述#

ABP 的会话系统建立在 ASP.NET Core 的认证和授权机制之上。它并不替代 ASP.NET Core 的 HttpContext.Session(用于存储任意键值对数据),而是专注于提供对当前已认证用户和当前租户信息的标准化访问。

其主要目标包括:

  • 抽象化:提供一致的接口来访问用户和租户 ID,无论底层身份验证机制是 Cookie、JWT Bearer Token 还是其他。
  • 多租户支持:与 ABP 的多租户功能深度集成,轻松识别当前请求所在的租户上下文。
  • 可测试性:由于依赖接口,在单元测试中可以轻松模拟会话对象。
  • 空对象模式:提供空会话实现,优雅处理用户未登录或租户未识别的情况。

核心接口与类#

ISession#

这是 ABP 会话系统的核心接口,你的应用程序代码应该依赖于这个接口,而不是具体的实现。定义在 Volo.Abp 命名空间中。

public interface ISession
{
    // 当前用户的ID,如果用户未登录,则返回null。
    Guid? UserId { get; }
    
    // 当前租户的ID,如果当前请求未关联任何租户(例如主机用户),则返回null。
    Guid? TenantId { get; }
    
    // 当前用户的多租户Side(用于判断用户是租户方还是主机方)。
    MultiTenancySides MultiTenancySide { get; }
    
    // 一个简便的属性,用于检查当前用户是否已认证(UserId != null)。
    bool UserId.HasValue { get; }
}

ClaimsAbpSession#

这是 ISession 的默认实现。它从当前 HttpContext 的 User 对象的 ClaimsPrincipal 中提取信息。

  • UserId 从名为 AbpClaimTypes.UserId 的声明中获取。
  • TenantId 从名为 AbpClaimTypes.TenantId 的声明中获取。

ABP 框架在身份验证过程中会自动将这些声明添加到用户主体中。

NullAbpSession#

这是 ISession 接口的空对象模式实现。当 ABP 无法解析到有效的 HttpContext(例如,在后台线程或特定的控制台应用程序中)时,会使用此实现。它的所有属性都返回 null 或默认值,这可以防止在非 Web 上下文中出现空引用异常。

会话数据的来源与存储#

ABP 会话数据(UserId, TenantId)的生命周期与单个 HTTP 请求的认证状态绑定。其典型流程如下:

  1. 用户登录:用户通过 AccountController 或类似的身份验证端点登录。ABP 会验证凭证,并创建一个包含 UserId 和 TenantId 等声明的身份票据。
  2. 令牌/Cookie 创建:对于基于浏览器的应用,此票据通常存储在加密的 Cookie 中。对于 API/SPA 应用,则可能是一个 JWT Bearer Token。
  3. 后续请求:客户端在后续请求中携带此 Cookie 或 Token(通常在 Authorization 头中)。
  4. 中间件处理:ABP 和 ASP.NET Core 的认证中间件会解析 Cookie/Token,重建 ClaimsPrincipal 对象,并将其设置到当前 HttpContext.User。
  5. ISession 读取:在请求处理过程中,当你访问 ISession.UserId 或 ISession.TenantId 时,ClaimsAbpSession 实现会从 ClaimsPrincipal 中读取相应的声明值。

重要:ABP 的 ISession 不用于存储任意数据(如购物车内容)。对于此类需求,你应直接使用 ASP.NET Core 的 HttpContext.Session。

在应用中使用会话#

在应用服务中使用#

在应用服务(Application Services)中,使用会话最常见的方式是依赖注入。ABP 框架会自动将 ISession 注册为依赖项。

using System;
using System.Threading.Tasks;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
 
namespace MyProject.ToDoItems
{
    public class ToDoItemAppService : ApplicationService, IToDoItemAppService
    {
        private readonly IRepository<ToDoItem, Guid> _todoItemRepository;
 
        // 通过构造函数注入 ISession
        public ToDoItemAppService(IRepository<ToDoItem, Guid> todoItemRepository)
        {
            _todoItemRepository = todoItemRepository;
        }
 
        public async Task<ListResultDto<ToDoItemDto>> GetMyItemsAsync()
        {
            // 检查用户是否已登录
            if (!CurrentUser.IsAuthenticated) // 另一种检查方式: CurrentUser 是应用服务基类提供的属性
            {
                return new ListResultDto<ToDoItemDto>();
            }
 
            // 获取当前用户的ID
            var currentUserId = CurrentUser.Id; // 或者使用 Session.UserId
            // var currentUserId = Session.UserId; 
 
            // 使用当前用户ID过滤数据
            var items = await _todoItemRepository.GetListAsync(item => item.CreatorId == currentUserId);
 
            return ObjectMapper.Map<List<ToDoItem>, ListResultDto<ToDoItemDto>>(items);
        }
    }
}

注意:ApplicationService 基类已经为你注入了 ISession,并暴露为 Session 属性。同时,也提供了更方便的 CurrentUser 属性。

在控制器中使用#

在 MVC 或 Razor Page 控制器中,你同样可以通过依赖注入来使用 ISession。

using Microsoft.AspNetCore.Mvc;
using Volo.Abp;
using Volo.Abp.AspNetCore.Mvc;
 
namespace MyProject.Web.Controllers
{
    public class HomeController : AbpController // 继承 AbpController
    {
        private readonly ISession _session;
 
        // 构造函数注入
        public HomeController(ISession session)
        {
            _session = session;
        }
 
        public IActionResult Index()
        {
            // 使用注入的会话
            if (_session.UserId.HasValue)
            {
                ViewData["WelcomeMessage"] = $"Hello, User {_session.UserId}";
            }
            else
            {
                ViewData["WelcomeMessage"] = "Hello, Guest";
            }
            return View();
        }
    }
}

在领域层中使用(谨慎)#

通常,领域层(Domain Layer)应该与表示层和基础设施层的概念(如 HTTP 会话)保持独立,以确保清晰的分层架构和可测试性。

最佳实践是将会话信息(如 UserId)从应用层显式地传递到领域层。

应用服务层:

public async Task CreateToDoItemAsync(CreateToDoItemDto input)
{
    // 在应用服务中获取会话信息
    var currentUserId = Session.UserId;
 
    // 将UserId传递给领域实体或领域服务
    var newItem = new ToDoItem(
        GuidGenerator.Create(),
        input.Text,
        currentUserId // 显式传递
    );
 
    await _todoItemRepository.InsertAsync(newItem);
}

领域实体:

public class ToDoItem : AuditedAggregateRoot<Guid>
{
    public string Text { get; set; }
    public Guid? OwnerUserId { get; set; } // 显式存储用户ID
 
    public ToDoItem(Guid id, string text, Guid? ownerUserId)
    {
        Id = id;
        Text = text;
        OwnerUserId = ownerUserId; // 通过构造函数设置
    }
}

这种方式使领域逻辑更加纯净和明确。

常见实践与示例#

示例1:基于当前用户的数据过滤#

这是最常见的用法。ABP 框架的数据过滤器(Data Filter)机制已经内置了 IMustHaveCreator 接口,可以自动基于 ISession.UserId 进行过滤。你也可以手动实现。

// 手动在仓储查询中使用 Session
public async Task<int> GetCurrentUserItemCountAsync()
{
    var queryable = await _todoItemRepository.GetQueryableAsync();
    var count = await AsyncExecuter.CountAsync(
        queryable.Where(item => item.CreatorId == CurrentUser.Id)
    );
    return count;
}

示例2:多租户应用程序中的租户隔离#

在 SaaS 应用中,确保数据在不同租户间隔离是核心需求。

public async Task CreateTenantSpecificDataAsync(CreateDataDto input)
{
    // 确保当前请求有租户上下文
    if (!Session.TenantId.HasValue)
    {
        throw new AbpAuthorizationException("This operation requires a tenant context.");
    }
 
    var entity = new MyTenantAwareEntity
    {
        TenantId = Session.TenantId.Value, // 显式设置租户ID
        // ... 其他属性
    };
 
    await _myRepository.InsertAsync(entity);
}

ABP 的 IRepository 默认会自动启用多租户过滤器,因此查询时只会返回当前租户的数据。

示例3:自定义会话属性(扩展)#

虽然 ISession 接口本身是固定的,但你可以通过扩展方法为其添加“虚拟”属性。

public static class SessionExtensions
{
    public static async Task<string> GetCurrentUserEmailAsync(this ISession session, IIdentityUserRepository userRepository)
    {
        if (!session.UserId.HasValue)
        {
            return null;
        }
 
        var user = await userRepository.FindAsync(session.UserId.Value);
        return user?.Email;
    }
}
 
// 使用扩展方法
// var userEmail = await _session.GetCurrentUserEmailAsync(_userRepository);

注意:这只是一个获取数据的便捷方法,并不会在会话中存储数据。

最佳实践与陷阱#

  1. 依赖接口,而非实现:始终注入和使用 ISession 接口,以保证代码的可测试性。
  2. 领域层避免直接使用 ISession:如之前所述,通过应用层将用户/租户信息显式传递给领域层。
  3. 处理空值:始终检查 UserId 和 TenantId 的 HasValue 属性,因为用户可能未登录,或请求可能没有租户上下文。
    // 好
    if (Session.UserId.HasValue) { ... }
     
    // 有风险,如果为null会抛出异常
    var userId = Session.UserId.Value;
  4. 理解作用域:ISession 是作用域生命周期(Scoped),即每个请求创建一个实例。不要在单例(Singleton)服务中注入它。
  5. 不要滥用 ISession 存储业务数据:记住,ISession 用于获取当前上下文,而非存储业务状态。使用缓存(IDistributedCache)或数据库来存储需要持久化的数据。
  6. 在后台作业/工作单元中使用 NullAbpSession:在后台线程或 Hangfire 作业中,由于没有 HTTP 上下文,ISession 将是 NullAbpSession。确保你的代码能正确处理这种情况,或者在启动后台作业时显式传递所需的 UserId 和 TenantId。

总结#

ABP 框架的会话系统是一个设计精良的抽象层,它极大地简化了在 ASP.NET Core 应用程序中处理当前用户和租户上下文的工作。通过遵循依赖注入原则和本文概述的最佳实践,你可以构建出结构清晰、易于测试且具备强大多租户能力的应用程序。

核心要点是:使用 ISession 接口获取上下文信息,在应用层进行决策,并将必要的信息显式地传递给下层,而不是让下层隐式地依赖于会话。

参考#