C# 结合 using 语句块的三种实用方法

一、简介

阅读 Abp 源码的过程中,自己也学习到了一些之前没有接触过的知识。在这里,我在这儿针对研究学习 Abp 框架中,遇到的一些值得分享的知识写几篇文章。如果有什么疑问或者问题,欢迎大家评论指正。

在本篇主要是 Scoped 范围与 using 语句块的使用。using 语句块大家一定都不陌生,都是与非托管对象一起存在的,它有一个特性就是在 using 语句块结束的时候会调用对象的 IDispose.Dispose() 方法。一般我们会在非托管类型的 Dispose() 方法内部进行资源的释放,类似于 C 语言的 free() 操作。

例如下面的代码:

public void TestMethod()
{
    using(var waitDisposeObj = new TestClass())
    {
        // 执行其他操作 xxx
    }
    
    // 出了语句块之后就,自动调用 waitDisposeObj 的 Dispose() 方法。
}

可以看到上面的例子,using 语句块包裹的就是一个范围 (Scoped)。其实这里可以延伸到依赖注入的概念,在依赖注入的生命周期当中有一个 Scoped 的生命周期。(PS: 需要了解的可以去阅读我的 这篇文章)

一个 Scoped 其实就可以看作是一个 using 语句块包裹的范围,所有解析出来的对象在离开 using 语句块的时候都应该被释放。

例如下面的代码:

public void TestMethod()
{
    using(var scopedResolver = new ScopedResolver())
    {
        var a = scopedResolver.Resolve<A>();
        var b = scopedResolver.Reslove<B>();
    }
    
    // 出了语句块之后 a b 对象自动释放
}

其实这里也是利用了 using 语句块的特性,在 ScopedResolver 类型的定义当中,也实现了 IDisopse 接口。所以在 using 语句块结束的时候,会自动调用 ScopedResovlerDispose() 方法,在这个方法内部则对已经解析出来的对象调用其 Dispose() 进行释放。

二、分析

2.0 释放委托

也是不知道叫什么标题了,这玩意儿是 Abp 封装的一个类型,它的作用就是在 using 语句块结束的时候,执行你传入的委托。

使用方法如下:

var completedTask = new DisposeAction(()=>Console.WriteLine("using 语句块结束了。"));
using(completedTask)
{
    // 其他操作
}
// 执行完成之后会调用 completedTask 传入的委托。

根据上述用法,你也应该猜出来这个 DisposeAction 类型的定义了。该类型继承了 IDispose 接口,并且在内部有一个 Action 字段,用于存储构造函数传入的委托。在执行 Dispose() 方法的时候,执行传入的委托。

public class DisposeAction : IDisposable
{
	public static readonly DisposeAction Empty = new DisposeAction(null);

	private Action _action;

	public DisposeAction([CanBeNull] Action action)
	{
		_action = action;
	}

	public void Dispose()
	{
		// 防止在多线程环境下,多次调用 action
		var action = Interlocked.Exchange(ref _action, null);
		action?.Invoke();
	}
}

2.1 统一对象释放

统一对象释放是 Abp 当中的另一种用法,其实按照 Abp 框架的定义,叫做 ScopedResolver(范围解析器)。顾名思义,通过 ScopedResolver 解析出来的对象,都会在 using 语句块结束之后统一进行销毁。

IScopedIocResolver 接口继承自 IIocResolverIDisposable 接口,它的本质就是作为 Ioc 解析器的一种特殊实现,所以它拥有所有 Ioc 解析器的方法,这里就不再赘述。

它的实现也比较简单,在其内部有一个集合维护每一次通过 IIocResolver 解析出来的对象。在 Dispose() 方法执行的时候,遍历这个集合,调用 Ioc 解析器的 Release() 方法释放对象并从集合中删除对象。下面就是实现的简化版:

public class ScopedIocResolver : IScopedIocResolver
{
	private readonly IIocResolver _iocResolver;
	private readonly List<object> _resolvedObjects;

	public ScopedIocResolver(IIocResolver iocResolver)
	{
		_iocResolver = iocResolver;
		_resolvedObjects = new List<object>();
	}
	
	// 解析对象
	public object Resolve(Type type)
	{
		var resolvedObject = _iocResolver.Resolve(type);

		// 添加到集合,方便后续释放
		_resolvedObjects.Add(resolvedObject);
		return resolvedObject;
	}
	
	public void Release(object obj)
	{
		// 从集合当中移除
		_resolvedObjects.Remove(obj);
		// 通过 Ioc 管理器释放对象
		_iocResolver.Release(obj);
	}
	
	public void Dispose()
	{
		// 遍历集合,释放对象
		_resolvedObjects.ForEach(_iocResolver.Release);
	}
}

通过 IScopedResolver 解析出来的对象,在 using 语句块结束的时候都会被释放,免去了我们每次手动释放的操作。

2.2 临时值变更

暂时想不到一个好一点的标题,暂时用这个标题代替吧。这里以 Abp 的一段实例代码为例,在有的时候我们可能当前的用户没有登录,所以在 IAbpSession 里面的 UserId 等属性肯定是为 NULL 的。而 IAbpSession 在设计的时候,这些属性是不允许更改的。

那么我们有时候可能会临时更改 IAbpSession 里面关于 UserId 的值怎么办呢?

这个时候可以通过 IAbpSession 提供的一个 IDisposable Use(int tenantId, long? userId, string userCode) 进行临时更改。他拥有一个 Use() 方法,并且返回一个实现了 IDispose 接口的对象,用法一般是这样:

public void TestMethod()
{
    using(AbpSession.Use(1,2,"3"))
    {
       // 内部临时更改了 AbpSession 的值 
    }
    
    // using 语句块结束的时候,调用 Use 返回对象的 Dispose 方法。
}

转到其抽象类 AbpSessionBase 实现,可以看到他的实现是这个样子的:

protected IAmbientScopeProvider<SessionOverride> SessionOverrideScopeProvider { get; }

public IDisposable Use(int tenantId, long? userId, string userCode)
{
    return SessionOverrideScopeProvider.BeginScope(SessionOverrideContextKey, new SessionOverride(null, tenantId, userId, userCode));
}

所以在这里,它是通过 SessionOverrideScopeProviderBegionScope() 方法创建了可以被 Dispose() 的对象。

接着继续跳转,来到 IAmbientScopeProvider 接口定义,这个接口接受一个泛型参数,可以看到之前在 AbpSessionBase 传入了一个 SessionOverride。这个 SessionOverride 就是封装了 UserId 等信息的存储类,也就是说 SessionOverride 就是允许进行临时值更改的类型定义。

在开始执行 BegionScope() 方法的时候,就针对传入的 value 进行存储,获取 Session 值的时候优先读取存储的值,不存在才执行真正的读取,调用 Dispose() 方法的时候就进行释放。

Snipaste_2019-01-16_09-20-16

所以接口提供了两个方法,第一个我们先看 BegionScope() 方法,接收一个 contextKey 用来区分不同的临时值,第二个参数则是要存储的临时值。

第二个方法为 GetValue,从一个上下文(后面讲)当中根据 contextKey 获得存储的临时值。

public interface IAmbientScopeProvider<T>
{
	T GetValue(string contextKey);

	IDisposable BeginScope(string contextKey, T value);
}

针对于该接口,其默认实现是 DataContextAmbientScopeProvider ,它的内部可能略微复杂,牵扯到了另一个接口 IAmbientDataContextScopeItem 类型。

这两个类型一个是上下文,一个是包裹具体临时值对象的类型。我们先从 BeginScope() 方法开始看:

// ScopeItem 的 Id 与其值关联的字典,其键为 Guid,值为具体的 ScopeItem 对象,这里并未与 ContextKey 进行关联。
private static readonly ConcurrentDictionary<string, ScopeItem> ScopeDictionary = new ConcurrentDictionary<string, ScopeItem>();

// 数据的上下文对象,管理 ContextKey 与其 Id。
private readonly IAmbientDataContext _dataContext;

public IDisposable BeginScope(string contextKey, T value)
{
    // 将需要临时存储的对象,用 ScopeItem 包装起来,它的外部对象是当前对象 (如果存在的话)。
	var item = new ScopeItem(value, GetCurrentItem(contextKey));

    // 将包装好的对象以 Id-对象,的形式存储在字典当中。
	if (!ScopeDictionary.TryAdd(item.Id, item))
	{
		throw new AbpException("Can not add item! ScopeDictionary.TryAdd returns false!");
	}

    // 在上下文当中设置当前的 ContextKey 关联的 Id。
	_dataContext.SetData(contextKey, item.Id);

    // 集合释放委托,using 语句块结束时,做释放操作。
	return new DisposeAction(() =>
	{
        // 从字典中移除指定 Id 的对象。
		ScopeDictionary.TryRemove(item.Id, out item);

        // 如果包装对象没有外部对象,直接设置上下文关联的 Id 为 NULL。
		if (item.Outer == null)
		{
			_dataContext.SetData(contextKey, null);
			return;
		}

        // 如果还有外部对象,则设置上下文关联的 Id 为外部对象的 I的。
		_dataContext.SetData(contextKey, item.Outer.Id);
	});
}

从上面的逻辑可以看出来,每次我们加入的临时值都是通过 ScopeItem 包裹起来的。而这个 ScopeItem 与我们的工作单元相似,它会有一个外部连接的对象。这个外部连接对象的作用就是解决 using 语句嵌套问题的,例如我们有以下代码:

public void TestMethod()
{
    using(AbpSession.Use(1,2,"3"))
    {
       // 一些业务逻辑
       // ScopeItem.Outer = null;
       using(AbpSession.Use(4,5,"6"))
       {
           // 一些业务逻辑
           // ScopeItem.Outer = 外部对象;
       }
    }
}

那么我们在这里会有同一个 ContextKey,都是提供给 AbpSession 使用的。第一次我在 Use() 内部通过 BeginScope() 方法创建了一个 ScopeItem 对象,包装了临时值,这个 ScopeItem 的外部对象为 NULL。第二次我又在内部创建了一个 ScopeItem 对象,包装了第二个临时值,这个时候 ScopeItem 的外部对象就是第一次包装的对象了。

执行释放操作的时候,首先判断外部对象是否为空。如果为空则直接在上下文当中将绑定的 ScopeItem 的 Id 值设为 NULL,如果不为空,则设置为它的外部对象的 Id。

还是以上面的代码为例,在 Dispose() 被执行之后,由内而外,到最外层的时候在上下文与 ContextKey 关联的 Id 已经被置为 NULL 了。

private ScopeItem GetCurrentItem(string contextKey)
{
    // 从数据上下文获取指定 ContextKey 当前关联的 Id 值。
	var objKey = _dataContext.GetData(contextKey) as string;
    // 不存在则返回 NULL,存在则尝试以 Id 从字典中拿取对象外部,并返回。
	return objKey != null ? ScopeDictionary.GetOrDefault(objKey) : null;
}

分析了一下 IAmbientDataContext 的实现,感觉与 ICurrentUnitOfWorkProvider 类似,内部都是通过 AsyncLocal 来进行处理的。

public class AsyncLocalAmbientDataContext : IAmbientDataContext, ISingletonDependency
{
	// 这里的字典是以 ContextKey 与 ScopeItem 的 Id 构成的。
	private static readonly ConcurrentDictionary<string, AsyncLocal<object>> AsyncLocalDictionary = new ConcurrentDictionary<string, AsyncLocal<object>>();

	public void SetData(string key, object value)
	{
		// 设置指定 ContextKey 对应的 Id 值。
		var asyncLocal = AsyncLocalDictionary.GetOrAdd(key, (k) => new AsyncLocal<object>());
		asyncLocal.Value = value;
	}

	public object GetData(string key)
	{
		// 获取指定 ContextKey 对应的 Id 值。
		var asyncLocal = AsyncLocalDictionary.GetOrAdd(key, (k) => new AsyncLocal<object>());
		return asyncLocal.Value;
	}
}

从开始到这里使用并行字典的情况来看,这里这么做的原因很简单,是为了处理异步上下文切换的情况,确保 ContextKey 对应的 Id 是一致的,防止在 Get/Set Data 的时候出现 意外的情况

最后呢在具体的 Session 实现类 ClaimsAbpSession 当中要获取 UserId 会经过下面的步骤:

public override long? UserId
{
	get
	{
		// 尝试从临时对象中获取数据。
		if (OverridedValue != null)
		{
			return OverridedValue.UserId;
		}

		// 从 JWT Token 当中获取 UserId 信息。

		return userId;
	}
}

最后我再贴上 ScopeItem 的定义。

private class ScopeItem
{
    public string Id { get; }

    public ScopeItem Outer { get; }

    public T Value { get; }

    public ScopeItem(T value, ScopeItem outer = null)
    {
        Id = Guid.NewGuid().ToString();

        Value = value;
        Outer = outer;
    }
}

这个临时值变更可能是 Abp 用法当中最为复杂的一个,牵扯到了异步上下文和 using 语句嵌套的问题。但仔细阅读源码之后,其实有一种豁然开朗的感觉,也加强了对于 C# 程序设计的理解。

三、结语

通过学习 Abp 框架,也了解了自己在基础方面的诸多不足。其次也是能够看到一些比较实用新奇的写法,你也可以在自己项目中进行应用,本文主要是起一个抛砖引玉的作用。最近年底了,事情也比较多,博客也是疏于更新。后面会陆续恢复博文更新,尽量 2 天 1 更,新年新气象。