[Abp 源码分析]十六、后台作业与后台工作者

0. 简介

在某些时候我们可能会需要执行后台任务,或者是执行一些周期性的任务。比如说可能每隔 1 个小时要清除某个临时文件夹内的数据,可能用户会要针对某一个用户群来群发一组短信。前面这些就是典型的应用场景,在 Abp 框架里面为我们准备了后台作业和后台工作者来帮助我们解决这个问题。

后台作业与后台工作者的区别是,前者主要用于某些耗时较长的任务,而不想阻塞用户的时候所使用。后者主要用于周期性的执行某些任务,从 “工作者” 的名字可以看出来,就是一个个工人,而且他们每个工人都拥有单独的后台线程。

0.1 典型场景

后台作业

  • 某个用户按下了报表按钮来生成一个需要长时间等待的报表。你添加这个工作到队列中,当报表生成完毕后,发送报表结果到该用户的邮箱。
  • 在后台作业中发送一封邮件,有些问题可能会导致发送失败(网络连接异常,或者主机宕机);由于有后台作业以及持久化机制,在问题排除后,可以重试以保证任务的成功执行。

后台工作者

  • 后台工作者能够周期性地执行旧日志的删除。
  • 后台工作者可以周期性地筛选出非活跃性用户,并且发送回归邮件给这些用户。

1. 启动流程

后台作业与后台工作者都是通过各自的 Manager(IBackgroundJobManager/IBackgroundWorkerManager) 来进行管理的。而这两个 Manager 分别继承了 ISingletonDependency 接口,所以在启动的时候就会自动注入这两个管理器以便开发人员管理操作。

这里值得注意的一点是,IBackgroundJobManager 接口是 IBackgroundWorker 的派生接口,而 IBackgroudWorker 是归属于 IBackgroundWorkerManager 进行管理的。

所以,你可以在 AbpKernelModule 里面看到如下代码:

public sealed class AbpKernelModule : AbpModule
{
    public override void PostInitialize()
    {
        // 注册可能缺少的组件
        RegisterMissingComponents();
        
        // ... 忽略的代码
        // 各种管理器的初始化操作

        // 从配置项中读取,是否启用了后台作业功能
        if (Configuration.BackgroundJobs.IsJobExecutionEnabled)
        {
            var workerManager = IocManager.Resolve<IBackgroundWorkerManager>();
            // 开始启动后台工作者
            workerManager.Start();
            // 增加后台作业管理器
            workerManager.Add(IocManager.Resolve<IBackgroundJobManager>());
        }
    }
}

可以看到,后台作业管理器是作为一个后台工作者被添加到了 IBackgroundWorkerManager 当中来执行的。

2. 代码分析

2.1 后台工作者

2.1.1 后台工作者管理器

Abp 通过后台工作者管理器来管理后台作业队列,所以我们首先来看一下后台工作者管理器接口的定义是什么样子的。

public interface IBackgroundWorkerManager : IRunnable
{
    void Add(IBackgroundWorker worker);
}

还是相当简洁的,就一个 Add 方法用来添加一个新的后台工作者对象。只是在这个地方,可以看到该接口又是集成自 IRunnable 接口,那么该接口的作用又是什么呢?

转到其定义可以看到,IRunable 接口定义了三个基本的方法:Start()Stop()WaitStop() ,而且他拥有一个默认实现 RunableBase,其实就是用来标识一个任务的运行状态。

public interface IRunnable
{
    // 开始执行任务
    void Start();

    // 停止执行任务
    void Stop();

    // 阻塞线程,等待任务执行完成后标识为停止。
    void WaitToStop();
}

public abstract class RunnableBase : IRunnable
{
    // 用于标识任务是否运行的布尔值变量
    public bool IsRunning { get { return _isRunning; } }

    private volatile bool _isRunning;

    // 启动之后表示任务正在运行
    public virtual void Start()
    {
        _isRunning = true;
    }

    // 停止之后表示任务结束运行
    public virtual void Stop()
    {
        _isRunning = false;
    }

    public virtual void WaitToStop()
    {

    }
}

到目前为止整个代码都还是比较简单清晰的,我们接着看 IBackgroundWorkerManager 的默认实现 BackgroundWorkerManager 类,首先我们看一下该类拥有哪些属性与字段。

public class BackgroundWorkerManager : RunnableBase, IBackgroundWorkerManager, ISingletonDependency, IDisposable
{
    private readonly IIocResolver _iocResolver;
    private readonly List<IBackgroundWorker> _backgroundJobs;
    
    public BackgroundWorkerManager(IIocResolver iocResolver)
    {
        _iocResolver = iocResolver;
        _backgroundJobs = new List<IBackgroundWorker>();
    }
}

在后台工作者管理器类的内部,默认有一个 List 集合,用于维护所有的后台工作者对象。那么其他的 Start() 等方法肯定是基于这个集合进行操作的。

public override void Start()
{
    base.Start();

    _backgroundJobs.ForEach(job => job.Start());
}

public override void Stop()
{
    _backgroundJobs.ForEach(job => job.Stop());

    base.Stop();
}

public override void WaitToStop()
{
    _backgroundJobs.ForEach(job => job.WaitToStop());

    base.WaitToStop();
}

可以看到实现还是比较简单的,接下来我们继续看他的 Add() 方法是如何进行操作的?

public void Add(IBackgroundWorker worker)
{
    _backgroundJobs.Add(worker);

    if (IsRunning)
    {
        worker.Start();
    }
}

在这里我们看到他会针对 IsRunning 进行判定是否立即启动加入的后台工作者对象。而这个 IsRunning 属性值唯一产生变化的情况就在于 Start() 方法与 Stop() 方法的调用。

最后肯定也有相关的销毁方法,用于释放所有注入的后台工作者对象,并将集合清除。

private bool _isDisposed;

public void Dispose()
{
    if (_isDisposed)
    {
        return;
    }

    _isDisposed = true;

    // 遍历集合,通过 Ioc 解析器的 Release 方法释放对象
    _backgroundJobs.ForEach(_iocResolver.Release);
    // 清空集合
    _backgroundJobs.Clear();
}

所以,针对于所有后台工作者的管理,都是通过 IBackgroundWorkerManager 来进行操作的。

2.1.2 后台工作者

看完了管理器,我们来看一下 IBackgroundWorker 后台工作者对象是怎样的构成。

public interface IBackgroundWorker : IRunnable
{

}

貌似只是一个空的接口,其作用主要是标识某个类型是否为后台工作者,转到其抽象类实现 BackgroundWorkerBase,里面只是注入了一些辅助对象与本地化的一些方法。

public abstract class BackgroundWorkerBase : RunnableBase, IBackgroundWorker
{
    // 配置管理器
    public ISettingManager SettingManager { protected get; set; }

    // 工作单元管理器
    public IUnitOfWorkManager UnitOfWorkManager
    {
        get
        {
            if (_unitOfWorkManager == null)
            {
                throw new AbpException("Must set UnitOfWorkManager before use it.");
            }

            return _unitOfWorkManager;
        }
        set { _unitOfWorkManager = value; }
    }
    private IUnitOfWorkManager _unitOfWorkManager;

    // 获得当前的工作单元
    protected IActiveUnitOfWork CurrentUnitOfWork { get { return UnitOfWorkManager.Current; } }

    // 本地化资源管理器
    public ILocalizationManager LocalizationManager { protected get; set; }

    // 默认的本地化资源的源名称
    protected string LocalizationSourceName { get; set; }

    protected ILocalizationSource LocalizationSource
    {
        get
        {
            // 如果没有配置源名称,直接抛出异常
            if (LocalizationSourceName == null)
            {
                throw new AbpException("Must set LocalizationSourceName before, in order to get LocalizationSource");
            }

            if (_localizationSource == null || _localizationSource.Name != LocalizationSourceName)
            {
                _localizationSource = LocalizationManager.GetSource(LocalizationSourceName);
            }

            return _localizationSource;
        }
    }
    private ILocalizationSource _localizationSource;

    // 日志记录器
    public ILogger Logger { protected get; set; }

    protected BackgroundWorkerBase()
    {
        Logger = NullLogger.Instance;
        LocalizationManager = NullLocalizationManager.Instance;
    }
    
    // ... 其他模板代码
}

我们接着看继承并实现了 BackgroundWorkerBase 的类型 PeriodicBackgroundWorkerBase,从字面意思上来看,该类型应该是一个定时后台工作者基类。

重点在于 Periodic(定时),从其类型内部的定义可以看到,该类型使用了一个 AbpTimer 对象来进行周期计时与具体工作任务的触发。我们暂时先不看这个 AbpTimer,仅仅看 PeriodicBackgroundWorkerBase 的内部实现。

public abstract class PeriodicBackgroundWorkerBase : BackgroundWorkerBase
{
    protected readonly AbpTimer Timer;

    // 注入 AbpTimer
    protected PeriodicBackgroundWorkerBase(AbpTimer timer)
    {
        Timer = timer;
        // 绑定周期执行的任务,这里是 DoWork()
        Timer.Elapsed += Timer_Elapsed;
    }

    public override void Start()
    {
        base.Start();
        Timer.Start();
    }

    public override void Stop()
    {
        Timer.Stop();
        base.Stop();
    }

    public override void WaitToStop()
    {
        Timer.WaitToStop();
        base.WaitToStop();
    }

    private void Timer_Elapsed(object sender, System.EventArgs e)
    {
        try
        {
            DoWork();
        }
        catch (Exception ex)
        {
            Logger.Warn(ex.ToString(), ex);
        }
    }

    protected abstract void DoWork();
}

可以看到,这里基类绑定了 DoWork() 作为其定时执行的方法,那么用户在使用的时候直接继承自该基类,然后重写 DoWork() 方法即可绑定自己的后台工作者的任务。

2.1.3 AbpTimer 定时器

在上面的基类我们看到,基类的 Start()Stop()WaitTpStop() 方法都是调用的 AbpTimer 所提供的,所以说 AbpTimer 其实也继承了 RunableBase 基类并实现其具体的启动与停止操作。

其实 AbpTimer 的核心就是通过 CLR 的 Timer 来实现周期性任务执行的,不过默认的 Timer 类有两个比较大的问题。

  1. CLR 的 Timer 并不会等待你的任务执行完再执行下一个周期的任务,如果你的某个任务耗时过长,超过了 Timer 定义的周期。那么 Timer 会开启一个新的线程执行,这样的话最后我们系统的资源会因为线程大量重复创建而被拖垮。
  2. 如何知道一个 Timer 所执行的业务方法已经真正地被结束了。

所以 Abp 才会重新封装一个 AbpTimer 作为一个基础的计时器。第一个问题的解决方法很简单,就是在执行具体绑定的业务方法之前,通过 Timer.Change() 方法来让 Timer 临时失效。等待业务方法执行完成之后,再将 Timer 的周期置为用户设定的周期。

// CLR Timer 绑定的回调方法
private void TimerCallBack(object state)
{
    lock (_taskTimer)
    {
        if (!_running || _performingTasks)
        {
            return;
        }
        
        // 暂时让 Timer 失效
        _taskTimer.Change(Timeout.Infinite, Timeout.Infinite);
        // 设置执行标识为 TRUE,表示当前的 AbpTimer 正在执行
        _performingTasks = true;
    }

    try
    {
        // 如果绑定了相应的触发事件
        if (Elapsed != null)
        {
            // 执行相应的业务方法,这里就是最开始绑定的 DoWork() 方法
            Elapsed(this, new EventArgs());
        }
    }
    catch
    {

    }
    finally
    {
        lock (_taskTimer)
        {
            // 标识业务方法执行完成
            _performingTasks = false;
            if (_running)
            {
                // 更改周期为用户指定的执行周期,等待下一次触发
                _taskTimer.Change(Period, Timeout.Infinite);
            }

            Monitor.Pulse(_taskTimer);
        }
    }
}

针对于第二个问题,Abp 通过 WaitToStop() 方法会阻塞调用这个 Timer 的线程,并且在 _performingTasks 标识位是 false 的时候释放。

public override void WaitToStop()
{
    // 锁定 CLR 的 Timer 对象
    lock (_taskTimer)
    {
        // 循环检测
        while (_performingTasks)
        {
            Monitor.Wait(_taskTimer);
        }
    }

    base.WaitToStop();
}

至于其他的 Start() 方法就是使用 CLR 的 Timer 更改其执行周期,而 Stop() 就是直接将 Timer 的周期设置为无限大,使计时器失效。

2.1.4 总结

Abp 后台工作者的核心就是通过 AbpTimer 来实现周期性任务的执行,用户只需要继承自 PeriodicBackgroundWorkerBase,然后将其添加到 IBackgroundWorkerManager 的集合当中。这样 Abp 在启动之后就会遍历这个工作者集合,然后周期执行这些后台工作者绑定的方法。

当然如果你继承了 PeriodicBackgroundWorkerBase 之后,可以通过设置构造函数的 AbpTimer 来指定自己的执行周期。

2.2 后台作业队列

后台工作队列的管理是通过 IBackgroundJobManager 来处理的,而该接口又继承自 IBackgroundWorker,所以一整个后台作业队列就是一个后台工作者,只不过这个工作者有点特殊。

2.2.1 后台作业管理器

IBackgroundJobManager 接口的定义其实就两个方法,一个 EnqueueAsync<TJob, TArgs>() 用于将一个后台作业加入到执行队列当中。而 DeleteAsync() 方法呢,顾名思义就是从队列当中移除指定的后台作业。

首先看一下其默认实现 BackgroundJobManager,该实现同样是继承自 PeriodicBackgroundWorkerBase 并且其默认周期为 5000 ms。

public class BackgroundJobManager : PeriodicBackgroundWorkerBase, IBackgroundJobManager, ISingletonDependency
{
        // 事件总线
        public IEventBus EventBus { get; set; }
        
        // 轮训后台作业的间隔,默认值为 5000 毫秒.
        public static int JobPollPeriod { get; set; }

        // IOC 解析器
        private readonly IIocResolver _iocResolver;
        
        // 后台作业队列存储
        private readonly IBackgroundJobStore _store;

        static BackgroundJobManager()
        {
            JobPollPeriod = 5000;
        }

        public BackgroundJobManager(
            IIocResolver iocResolver,
            IBackgroundJobStore store,
            AbpTimer timer)
            : base(timer)
        {
            _store = store;
            _iocResolver = iocResolver;

            EventBus = NullEventBus.Instance;

            Timer.Period = JobPollPeriod;
        }
}

基础结构基本上就这个样子,接下来看一下他的两个接口方法是如何实现的。

EnqueueAsync<TJob, TArgs> 方法通过传入指定的后台作业对象和相应的参数,同时还有任务的优先级。将其通过 IBackgroundJobStore 进行持久化,并返回一个任务的唯一 JobId 以便进行删除操作。

public async Task<string> EnqueueAsync<TJob, TArgs>(TArgs args, BackgroundJobPriority priority = BackgroundJobPriority.Normal, TimeSpan? delay = null)
    where TJob : IBackgroundJob<TArgs>
{
    // 通过 JobInfo 包装任务的基本信息
    var jobInfo = new BackgroundJobInfo
    {
        JobType = typeof(TJob).AssemblyQualifiedName,
        JobArgs = args.ToJsonString(),
        Priority = priority
    };

    // 如果需要延时执行的话,则用当前时间加上延时的时间作为任务下次运行的时间
    if (delay.HasValue)
    {
        jobInfo.NextTryTime = Clock.Now.Add(delay.Value);
    }

    // 通过 Store 进行持久话存储
    await _store.InsertAsync(jobInfo);

    // 返回后台任务的唯一标识
    return jobInfo.Id.ToString();
}

至于删除操作,在 Manager 内部其实也是通过 IBackgroundJobStore 进行实际的删除操作的。

public async Task<bool> DeleteAsync(string jobId)
{
    // 判断 jobId 的值是否有效
    if (long.TryParse(jobId, out long finalJobId) == false)
    {
        throw new ArgumentException($"The jobId '{jobId}' should be a number.", nameof(jobId));
    }

    // 使用 jobId 从 Store 处筛选到 JobInfo 对象的信息
    BackgroundJobInfo jobInfo = await _store.GetAsync(finalJobId);
    if (jobInfo == null)
    {
        return false;
    }

    // 如果存在有 JobInfo 则使用 Store 进行删除操作
    await _store.DeleteAsync(jobInfo);
    return true;
}

后台作业管理器实质上是一个周期性执行的后台工作者,那么我们的后台作业是每 5000 ms 执行一次,那么他的 DoWork() 方法又在执行什么操作呢?

protected override void DoWork()
{
    // 从 Store 当中获得等待执行的后台作业集合
    var waitingJobs = AsyncHelper.RunSync(() => _store.GetWaitingJobsAsync(1000));

    // 遍历这些等待执行的后台任务,然后通过 TryProcessJob 进行执行
    foreach (var job in waitingJobs)
    {
        TryProcessJob(job);
    }
}

可以看到每 5 秒钟我们的后台作业管理器就会从 IBackgroundJobStore 当中拿到最大 1000 条的后台作业信息,然后遍历这些信息。通过 TryProcessJob(job) 方法来执行后台作业。

TryProcessJob() 方法,本质上就是通过反射构建出一个 IBackgroundJob 对象,然后取得序列化的参数值,通过反射得到的 MethodInfo 对象来执行我们的后台任务。执行完成之后,就会从 Store 当中移除掉执行完成的任务。

针对于在执行过程当中所出现的异常,会通过 IEventBus 触发一个 AbpHandledExceptionData 事件记录后台作业执行失败时的异常信息。并且一旦在执行过程当中出现了任何异常的情况,都会将该任务的 IsAbandoned 字段置为 true,当该字段为 true 时,该任务将不再回被执行。

PS:就是在 GetWaitingJobsAsync() 方法时,会过滤掉 IsAbandoned 值为 true 的任务。

private void TryProcessJob(BackgroundJobInfo jobInfo)
{
    try
    {
        // 任务执行次数自增 1
        jobInfo.TryCount++;
        // 最后一次执行时间设置为当前时间
        jobInfo.LastTryTime = Clock.Now;

        // 通过反射取得后台作业的类型
        var jobType = Type.GetType(jobInfo.JobType);
        // 通过 Ioc 解析器得到一个临时的后台作业对象,执行完之后既被释放
        using (var job = _iocResolver.ResolveAsDisposable(jobType))
        {
            try
            {
                // 通过反射得到后台作业的 Execute 方法
                var jobExecuteMethod = job.Object.GetType().GetTypeInfo().GetMethod("Execute");
                var argsType = jobExecuteMethod.GetParameters()[0].ParameterType;
                var argsObj = JsonConvert.DeserializeObject(jobInfo.JobArgs, argsType);

                // 结合持久话存储的参数信息,调用 Execute 方法进行后台作业
                jobExecuteMethod.Invoke(job.Object, new[] { argsObj });

                // 执行完成之后从 Store 删除该任务的信息
                AsyncHelper.RunSync(() => _store.DeleteAsync(jobInfo));
            }
            catch (Exception ex)
            {
                Logger.Warn(ex.Message, ex);

                // 计算下一次执行的时间,一旦超过 2 天该任务都执行失败,则返回 null
                var nextTryTime = jobInfo.CalculateNextTryTime();
                if (nextTryTime.HasValue)
                {
                    jobInfo.NextTryTime = nextTryTime.Value;
                }
                else
                {
                    // 如果为 null 则说明该任务在 2 天的时间内都没有执行成功,则放弃继续执行
                    jobInfo.IsAbandoned = true;
                }

                // 更新 Store 存储的任务信息
                TryUpdate(jobInfo);

                // 触发异常事件
                EventBus.Trigger(
                    this,
                    new AbpHandledExceptionData(
                        new BackgroundJobException(
                            "A background job execution is failed. See inner exception for details. See BackgroundJob property to get information on the background job.", 
                            ex
                        )
                        {
                            BackgroundJob = jobInfo,
                            JobObject = job.Object
                        }
                    )
                );
            }
        }
    }
    catch (Exception ex)
    {
        Logger.Warn(ex.ToString(), ex);
        // 表示任务不再执行
        jobInfo.IsAbandoned = true;
        // 更新 Store
        TryUpdate(jobInfo);
    }
}
2.2.2 后台作业

后台作业的默认接口定义为 IBackgroundJob<in TArgs> ,他只有一个 Execute(TArgs args) 方法,用于接收指定类型的作业参数,并执行。

一般来说我们不建议直接通过继承 IBackgroundJob<in TArgs> 来实现后台作业,而是继承自 BackgroundJob<TArgs> 抽象类。该抽象类内部也没有什么特别的实现,主要是注入了一些基础设施,比如说 UOW 与 本地化资源管理器,方便我们开发使用。

后台作业本身是具体执行的对象,而 BackgroundJobInfo 则是存储了后台作业的 Type 类型和参数,方便在需要执行的时候通过反射的方式执行后台作业。

2.2.2 后台作业队列存储

IBackgroundJobStore 我们就可以猜到以 Abp 框架的套路,他肯定会有两种实现,第一种就是基于内存的 InMemoryBackgroundJobStore。而第二种呢,就是由 Abp.Zero 模块所提供的基于数据库的 BackgroundJobStore

IBackgroundJobStore 接口所定义的方法基本上就是增删改查,没有什么复杂的。

public interface IBackgroundJobStore
{
    // 通过 JobId 获取后台任务信息
    Task<BackgroundJobInfo> GetAsync(long jobId);

    // 插入一个新的后台任务信息
    Task InsertAsync(BackgroundJobInfo jobInfo);

    /// <summary>
    /// Gets waiting jobs. It should get jobs based on these:
    /// Conditions: !IsAbandoned And NextTryTime &lt;= Clock.Now.
    /// Order by: Priority DESC, TryCount ASC, NextTryTime ASC.
    /// Maximum result: <paramref name="maxResultCount"/>.
    /// </summary>
    /// <param name="maxResultCount">Maximum result count.</param>
    Task<List<BackgroundJobInfo>> GetWaitingJobsAsync(int maxResultCount);

    /// <summary>
    /// Deletes a job.
    /// </summary>
    /// <param name="jobInfo">Job information.</param>
    Task DeleteAsync(BackgroundJobInfo jobInfo);

    /// <summary>
    /// Updates a job.
    /// </summary>
    /// <param name="jobInfo">Job information.</param>
    Task UpdateAsync(BackgroundJobInfo jobInfo);
}

这里先从简单的内存 Store 说起,这个 InMemoryBackgroundJobStore 内部使用了一个并行字典来存储这些任务信息。

public class InMemoryBackgroundJobStore : IBackgroundJobStore
{
    private readonly ConcurrentDictionary<long, BackgroundJobInfo> _jobs;
    private long _lastId;
    
    public InMemoryBackgroundJobStore()
    {
        _jobs = new ConcurrentDictionary<long, BackgroundJobInfo>();
    }
}

相当简单,这几个接口方法基本上就是针对与这个并行字典操作的一层封装。

public Task<BackgroundJobInfo> GetAsync(long jobId)
{
    return Task.FromResult(_jobs[jobId]);
}

public Task InsertAsync(BackgroundJobInfo jobInfo)
{
    jobInfo.Id = Interlocked.Increment(ref _lastId);
    _jobs[jobInfo.Id] = jobInfo;

    return Task.FromResult(0);
}

public Task<List<BackgroundJobInfo>> GetWaitingJobsAsync(int maxResultCount)
{
    var waitingJobs = _jobs.Values
        // 首先筛选出不再执行的后台任务
        .Where(t => !t.IsAbandoned && t.NextTryTime <= Clock.Now)
        // 第一次根据后台作业的优先级进行排序,高优先级优先执行
        .OrderByDescending(t => t.Priority)
        // 再根据执行次数排序,执行次数越少的,越靠前
        .ThenBy(t => t.TryCount)
        .ThenBy(t => t.NextTryTime)
        .Take(maxResultCount)
        .ToList();

    return Task.FromResult(waitingJobs);
}

public Task DeleteAsync(BackgroundJobInfo jobInfo)
{
    _jobs.TryRemove(jobInfo.Id, out _);

    return Task.FromResult(0);
}

public Task UpdateAsync(BackgroundJobInfo jobInfo)
{
    // 如果是不再执行的任务,删除
    if (jobInfo.IsAbandoned)
    {
        return DeleteAsync(jobInfo);
    }

    return Task.FromResult(0);
}

至于持久化到数据库,无非是注入一个仓储,然后针对这个仓储进行增删查改的操作罢了,这里就不在赘述。

2.2.3 后台作业优先级

后台作业的优先级定义在 BackgroundJobPriority 枚举当中,一共有 5 个等级,分别是 LowBelowNormalNormalAboveNormalHigh ,他们从最低到最高排列。

3.点此跳转到总目录

转载于:https://www.cnblogs.com/myzony/p/9841601.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值