|
@@ -0,0 +1,572 @@
|
|
|
|
+using System;
|
|
|
|
+using System.Threading;
|
|
|
|
+using Vit.Extensions;
|
|
|
|
+using Vit.Core.Module.Log;
|
|
|
|
+using Vit.Core.Util.Threading.Timer;
|
|
|
|
+using System.Runtime.CompilerServices;
|
|
|
|
+using System.Collections.Concurrent;
|
|
|
|
+
|
|
|
|
+namespace Vit.Core.Util.Threading.Worker
|
|
|
|
+{
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 注意!!!慎用!!!
|
|
|
|
+ /// 线程数量限制不一定准确。
|
|
|
|
+ /// 请勿处理ThreadInterruptedException异常,否则导致线程无法正常结束
|
|
|
|
+ /// 若超时则强制关闭任务。
|
|
|
|
+ /// 托管线程。推任务的模式。
|
|
|
|
+ /// </summary>
|
|
|
|
+ public class ManagedThread<T> : IDisposable
|
|
|
|
+ {
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 不可抛异常
|
|
|
|
+ /// </summary>
|
|
|
|
+ public Action<T> Processor;
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 不可抛异常
|
|
|
|
+ /// status: success/error/timeout/overload
|
|
|
|
+ /// </summary>
|
|
|
|
+ public Action<ETaskFinishStatus, T> OnFinish;
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 线程名称
|
|
|
|
+ /// </summary>
|
|
|
|
+ public string threadName;
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 常驻线程数,默认16。可为0
|
|
|
|
+ /// </summary>
|
|
|
|
+ public int threadCount = 16;
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 最大线程数(包含常驻线程和临时线程),默认100。
|
|
|
|
+ /// </summary>
|
|
|
|
+ public int maxThreadCount = 100;
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 等待队列的最大长度(默认:100000)
|
|
|
|
+ /// </summary>
|
|
|
|
+ public int pendingQueueLength = 100000;
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ public bool IsRunning => runInfo != null;
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 超时时间。脉冲间隔。(主动关闭超过此时间的任务,实际任务强制关闭的时间会在1倍超时时间到2倍超时时间内)。单位:ms。(默认300000)
|
|
|
|
+ /// </summary>
|
|
|
|
+ public int timeoutMs { get => pulseMaker.intervalMs; set => pulseMaker.intervalMs = value; }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ public ManagedThread()
|
|
|
|
+ {
|
|
|
|
+ pulseMaker = new SersTimer { intervalMs = 300000, timerCallback = Pulse };
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ ~ManagedThread()
|
|
|
|
+ {
|
|
|
|
+ Dispose();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public virtual void Dispose()
|
|
|
|
+ {
|
|
|
|
+ Stop();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ #region 电子脉冲
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 脉冲生产器
|
|
|
|
+ /// </summary>
|
|
|
|
+ readonly SersTimer pulseMaker;
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 电子脉冲,在固定的时间间隔发送脉冲
|
|
|
|
+ /// </summary>
|
|
|
|
+ [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public void Pulse(object obj)
|
|
|
|
+ {
|
|
|
|
+ runInfo?.Pulse();
|
|
|
|
+ }
|
|
|
|
+ #endregion
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ public void Start()
|
|
|
|
+ {
|
|
|
|
+ lock (this)
|
|
|
|
+ {
|
|
|
|
+ if (IsRunning)
|
|
|
|
+ {
|
|
|
|
+ throw WorkerHelp.Error_CannotStartWhileRunning.ToException();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ //(x.1)
|
|
|
|
+ runInfo = new RunInfo(this);
|
|
|
|
+ runInfo.Start();
|
|
|
|
+
|
|
|
|
+ //(x.2)开启脉冲生产器
|
|
|
|
+ pulseMaker.Start();
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public void Stop()
|
|
|
|
+ {
|
|
|
|
+ lock (this)
|
|
|
|
+ {
|
|
|
|
+ if (!IsRunning) return;
|
|
|
|
+
|
|
|
|
+ runInfo.needRunning = false;
|
|
|
|
+
|
|
|
|
+ var info = runInfo;
|
|
|
|
+ runInfo = null;
|
|
|
|
+
|
|
|
|
+ //关闭脉冲生产器
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ pulseMaker.Stop();
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ Logger.Error(ex);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ //Task.Run(() =>
|
|
|
|
+ {
|
|
|
|
+ //(x.1)终止常驻线程
|
|
|
|
+ foreach (var thread in info.longThreadManager.threads)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ thread.Pulse();
|
|
|
|
+ thread.Pulse();
|
|
|
|
+ }
|
|
|
|
+ catch (Exception)
|
|
|
|
+ {
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ //(x.2)终止临时线程
|
|
|
|
+ foreach (var thread in info.casualThreadManager.threadMap.Values)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ thread.Pulse();
|
|
|
|
+ thread.Pulse();
|
|
|
|
+ }
|
|
|
|
+ catch (Exception)
|
|
|
|
+ {
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ //(x.3)清空等待队列
|
|
|
|
+ if (OnFinish != null)
|
|
|
|
+ {
|
|
|
|
+ T arg;
|
|
|
|
+ while (info.pendingQueue.TryTake(out arg))
|
|
|
|
+ {
|
|
|
|
+ OnFinish?.Invoke(ETaskFinishStatus.overload, arg);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ //);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public void Publish(T arg)
|
|
|
|
+ {
|
|
|
|
+ if (IsRunning)
|
|
|
|
+ {
|
|
|
|
+ //(x.1)启动临时线程
|
|
|
|
+ if (runInfo.longThreadManager.waitingThreadCount == 0
|
|
|
|
+ && runInfo.casualThreadManager.TryStartNewThread(arg))
|
|
|
|
+ {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ //(x.2)推入等待队列
|
|
|
|
+ if (runInfo.pendingQueue.TryAdd(arg))
|
|
|
|
+ {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ //(x.3)返回服务过载回应
|
|
|
|
+ OnFinish?.Invoke(ETaskFinishStatus.overload, arg);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ #region RunInfo
|
|
|
|
+
|
|
|
|
+ class RunInfo
|
|
|
|
+ {
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 不可抛异常
|
|
|
|
+ /// </summary>
|
|
|
|
+ public Action<T> Processor;
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 不可抛异常
|
|
|
|
+ /// status: success/error/timeout/overload
|
|
|
|
+ /// </summary>
|
|
|
|
+ public Action<ETaskFinishStatus, T> OnFinish;
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ public bool needRunning = true;
|
|
|
|
+
|
|
|
|
+ public readonly BlockingCollection<T> pendingQueue;
|
|
|
|
+
|
|
|
|
+ public LongThreadManager longThreadManager;
|
|
|
|
+
|
|
|
|
+ public CasualThreadManager casualThreadManager;
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ public RunInfo(ManagedThread<T> task)
|
|
|
|
+ {
|
|
|
|
+
|
|
|
|
+ this.Processor = task.Processor;
|
|
|
|
+ this.OnFinish = task.OnFinish;
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ pendingQueue = new BlockingCollection<T>(task.pendingQueueLength);
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ longThreadManager = new LongThreadManager(task);
|
|
|
|
+
|
|
|
|
+ casualThreadManager = new CasualThreadManager(task);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ public void Start()
|
|
|
|
+ {
|
|
|
|
+ longThreadManager.Start();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 电子脉冲,在固定的时间间隔发送脉冲
|
|
|
|
+ /// </summary>
|
|
|
|
+ [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public void Pulse()
|
|
|
|
+ {
|
|
|
|
+ longThreadManager.Pulse();
|
|
|
|
+ casualThreadManager.Pulse();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ }
|
|
|
|
+ #endregion
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ RunInfo runInfo;
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ #region Manager
|
|
|
|
+ class LongThreadManager
|
|
|
|
+ {
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 等待中的线程个数
|
|
|
|
+ /// </summary>
|
|
|
|
+ public int waitingThreadCount = 0;
|
|
|
|
+
|
|
|
|
+ public LongThread[] threads;
|
|
|
|
+ ManagedThread<T> task;
|
|
|
|
+ public LongThreadManager(ManagedThread<T> task)
|
|
|
|
+ {
|
|
|
|
+ this.task = task;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public void Start()
|
|
|
|
+ {
|
|
|
|
+ threads = new LongThread[task.threadCount];
|
|
|
|
+ for (int i = 0; i < threads.Length; i++)
|
|
|
|
+ {
|
|
|
|
+ threads[i] = new LongThread(task,i);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 电子脉冲,在固定的时间间隔发送脉冲
|
|
|
|
+ /// </summary>
|
|
|
|
+ [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public void Pulse()
|
|
|
|
+ {
|
|
|
|
+ foreach (var thread in threads)
|
|
|
|
+ {
|
|
|
|
+ thread?.Pulse();
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ class CasualThreadManager
|
|
|
|
+ {
|
|
|
|
+ public readonly ConcurrentDictionary<int, CasualThread> threadMap = new ConcurrentDictionary<int, CasualThread>();
|
|
|
|
+
|
|
|
|
+ ManagedThread<T> task;
|
|
|
|
+
|
|
|
|
+ public int runningThreadCount = 0;
|
|
|
|
+
|
|
|
|
+ public int curThreadCount = 0;
|
|
|
|
+
|
|
|
|
+ int maxThreadCount;
|
|
|
|
+ public CasualThreadManager(ManagedThread<T> task)
|
|
|
|
+ {
|
|
|
|
+ this.task = task;
|
|
|
|
+ maxThreadCount = task.maxThreadCount - task.threadCount;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public bool TryStartNewThread(T arg)
|
|
|
|
+ {
|
|
|
|
+ var curThreadCount = Interlocked.Increment(ref this.curThreadCount);
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ if (curThreadCount <= maxThreadCount)
|
|
|
|
+ {
|
|
|
|
+ new CasualThread(task, arg);
|
|
|
|
+ return true;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ catch
|
|
|
|
+ {
|
|
|
|
+ Interlocked.Decrement(ref this.curThreadCount);
|
|
|
|
+ throw;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ Interlocked.Decrement(ref this.curThreadCount);
|
|
|
|
+ return false;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ /// <summary>
|
|
|
|
+ /// 电子脉冲,在固定的时间间隔发送脉冲
|
|
|
|
+ /// </summary>
|
|
|
|
+ [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public void Pulse()
|
|
|
|
+ {
|
|
|
|
+ foreach (var thread in threadMap.Values)
|
|
|
|
+ {
|
|
|
|
+ thread?.Pulse();
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ #endregion
|
|
|
|
+
|
|
|
|
+ #region Thread
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ class LongThread
|
|
|
|
+ {
|
|
|
|
+ RunInfo runInfo;
|
|
|
|
+
|
|
|
|
+ LongThreadManager manager;
|
|
|
|
+
|
|
|
|
+ bool isProcessing = false;
|
|
|
|
+
|
|
|
|
+ int pulseCount = 0;
|
|
|
|
+
|
|
|
|
+ Thread thread;
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ public LongThread(ManagedThread<T> task,int index)
|
|
|
|
+ {
|
|
|
|
+ this.runInfo = task.runInfo;
|
|
|
|
+ this.manager = runInfo.longThreadManager;
|
|
|
|
+
|
|
|
|
+ thread = new Thread(Run);
|
|
|
|
+ thread.IsBackground = true;
|
|
|
|
+ thread.Name = task.threadName + "-LongThread-" + index + "-" + thread.ManagedThreadId;
|
|
|
|
+
|
|
|
|
+ thread.Start();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public void Pulse()
|
|
|
|
+ {
|
|
|
|
+ if (thread == null || !thread.IsAlive || !isProcessing)
|
|
|
|
+ return;
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ Interlocked.Increment(ref pulseCount);
|
|
|
|
+ if (pulseCount >= 2)
|
|
|
|
+ thread.Interrupt();
|
|
|
|
+ }
|
|
|
|
+ catch { }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ void Run()
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ T arg_;
|
|
|
|
+ while (runInfo.needRunning)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ isProcessing = false;
|
|
|
|
+ Interlocked.Increment(ref manager.waitingThreadCount);
|
|
|
|
+ arg_ = runInfo.pendingQueue.Take();
|
|
|
|
+ Interlocked.Decrement(ref manager.waitingThreadCount);
|
|
|
|
+ pulseCount = 0;
|
|
|
|
+ isProcessing = true;
|
|
|
|
+
|
|
|
|
+ #region processing
|
|
|
|
+ ETaskFinishStatus status = ETaskFinishStatus.error;
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ runInfo.Processor(arg_);
|
|
|
|
+ status = ETaskFinishStatus.success;
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex) when (ex.GetBaseException() is ThreadInterruptedException)
|
|
|
|
+ {
|
|
|
|
+ status = ETaskFinishStatus.timeout;
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ status = ETaskFinishStatus.error;
|
|
|
|
+ Logger.Error(ex);
|
|
|
|
+ }
|
|
|
|
+ finally
|
|
|
|
+ {
|
|
|
|
+ runInfo.OnFinish?.Invoke(status, arg_);
|
|
|
|
+ }
|
|
|
|
+ #endregion
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ Logger.Error(ex);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ finally
|
|
|
|
+ {
|
|
|
|
+ thread = null;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ class CasualThread
|
|
|
|
+ {
|
|
|
|
+ RunInfo runInfo;
|
|
|
|
+
|
|
|
|
+ int pulseCount = 0;
|
|
|
|
+
|
|
|
|
+ T arg;
|
|
|
|
+ Thread thread;
|
|
|
|
+
|
|
|
|
+ CasualThreadManager manager;
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public CasualThread(ManagedThread<T> task, T arg)
|
|
|
|
+ {
|
|
|
|
+ this.runInfo = task.runInfo;
|
|
|
|
+ this.manager = runInfo.casualThreadManager;
|
|
|
|
+ this.arg = arg;
|
|
|
|
+
|
|
|
|
+ thread = new Thread(Run);
|
|
|
|
+ thread.IsBackground = true;
|
|
|
|
+ thread.Name = task.threadName + "-Casual-" + thread.ManagedThreadId;
|
|
|
|
+ thread.Start();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ public void Pulse()
|
|
|
|
+ {
|
|
|
|
+ if (!thread.IsAlive)
|
|
|
|
+ {
|
|
|
|
+ manager.threadMap.TryRemove(this.GetHashCode(), out _);
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ Interlocked.Increment(ref pulseCount);
|
|
|
|
+ if (pulseCount >= 2)
|
|
|
|
+ thread.Interrupt();
|
|
|
|
+ }
|
|
|
|
+ catch { }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
|
|
+ void Run()
|
|
|
|
+ {
|
|
|
|
+ Interlocked.Increment(ref manager.runningThreadCount);
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ manager.threadMap.TryAdd(this.GetHashCode(), this);
|
|
|
|
+
|
|
|
|
+ T arg_;
|
|
|
|
+ while (runInfo.needRunning)
|
|
|
|
+ {
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ pulseCount = 0;
|
|
|
|
+ if (arg == null)
|
|
|
|
+ {
|
|
|
|
+ if (!runInfo.pendingQueue.TryTake(out arg_))
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+ else
|
|
|
|
+ {
|
|
|
|
+ arg_ = arg;
|
|
|
|
+ arg = default;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ #region processing
|
|
|
|
+ ETaskFinishStatus status = ETaskFinishStatus.error;
|
|
|
|
+ try
|
|
|
|
+ {
|
|
|
|
+ runInfo.Processor(arg_);
|
|
|
|
+ status = ETaskFinishStatus.success;
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex) when (ex.GetBaseException() is ThreadInterruptedException)
|
|
|
|
+ {
|
|
|
|
+ status = ETaskFinishStatus.timeout;
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ status = ETaskFinishStatus.error;
|
|
|
|
+ Logger.Error(ex);
|
|
|
|
+ }
|
|
|
|
+ finally
|
|
|
|
+ {
|
|
|
|
+ runInfo.OnFinish?.Invoke(status, arg_);
|
|
|
|
+ }
|
|
|
|
+ #endregion
|
|
|
|
+ }
|
|
|
|
+ catch (Exception ex)
|
|
|
|
+ {
|
|
|
|
+ Logger.Error(ex);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ finally
|
|
|
|
+ {
|
|
|
|
+ Interlocked.Decrement(ref manager.runningThreadCount);
|
|
|
|
+ manager.threadMap.TryRemove(this.GetHashCode(), out _);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ #endregion
|
|
|
|
+ }
|
|
|
|
+}
|