.NET 轻量级、高效任务调度器:ScheduleTask

news2025/1/11 1:53:27

前言

至于任务调度这个基础功能,重要性不言而喻,大多数业务系统都会用到,世面上有很多成熟的三方库比如Quartz,Hangfire,Coravel

这里我们不讨论三方的库如何使用 而是从0开始自己制作一个简易的任务调度,如果只是到分钟级别的粒度基本够用。

正文

技术栈用到了:BackgroundServiceNCrontab

第一步我们定义一个简单的任务约定,不干别的就是一个执行方法:

public interface IScheduleTask
{
    Task ExecuteAsync();
}
public abstract class ScheduleTask : IScheduleTask
{
    public virtual Task ExecuteAsync()
    {
        return Task.CompletedTask;
    }
}

第二步定义特性标注任务执行周期等信的metadata

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public class ScheduleTaskAttribute(string cron) : Attribute
{
    /// <summary>
    /// 支持的cron表达式格式 * * * * *:https://en.wikipedia.org/wiki/Cron
    /// 最小单位为分钟
    /// </summary>
    public string Cron { get; set; } = cron;
    public string? Description { get; set; }
    /// <summary>
    /// 是否异步执行.默认false会阻塞接下来的同类任务
    /// </summary>
    public bool IsAsync { get; set; } = false;
    /// <summary>
    /// 是否初始化即启动,默认false
    /// </summary>
    public bool IsStartOnInit { get; set; } = false;
}

第三步我们定义一个调度器约定,不干别的就是判断当前的任务是否可以执行:

public interface IScheduler
{
    /// <summary>
    /// 判断当前的任务是否可以执行
    /// </summary>
    bool CanRun(ScheduleTaskAttribute scheduleMetadata, DateTime referenceTime);
}

好了,基础步骤就完成了,如果我们需要实现配置级别的任务调度或者动态的任务调度 那我们再抽象一个Store:

public class ScheduleTaskMetadata(Type scheduleTaskType, string cron)
{
    public Type ScheduleTaskType { get; set; } = scheduleTaskType;
    public string Cron { get; set; } = cron;
    public string? Description { get; set; }
    public bool IsAsync { get; set; } = false;
    public bool IsStartOnInit { get; set; } = false;
}
public interface IScheduleMetadataStore
{
    /// <summary>
    /// 获取所有ScheduleTaskMetadata
    /// </summary>
    Task<IEnumerable<ScheduleTaskMetadata>> GetAllAsync();
}

实现一个Configuration级别的Store

internal class ConfigurationScheduleMetadataStore(IConfiguration configuration) : IScheduleMetadataStore
{
    const string Key = "BiwenQuickApi:Schedules";

    public Task<IEnumerable<ScheduleTaskMetadata>> GetAllAsync()
    {
        var options = configuration.GetSection(Key).GetChildren();

        if (options?.Any() is true)
        {
            var metadatas = options.Select(x =>
            {
                var type = Type.GetType(x[nameof(ConfigurationScheduleOption.ScheduleType)]!);
                if (type is null)
                    throw new ArgumentException($"Type {x[nameof(ConfigurationScheduleOption.ScheduleType)]} not found!");

                return new ScheduleTaskMetadata(type, x[nameof(ConfigurationScheduleOption.Cron)]!)
                {
                    Description = x[nameof(ConfigurationScheduleOption.Description)],
                    IsAsync = string.IsNullOrEmpty(x[nameof(ConfigurationScheduleOption.IsAsync)]) ? false : bool.Parse(x[nameof(ConfigurationScheduleOption.IsAsync)]!),
                    IsStartOnInit = string.IsNullOrEmpty(x[nameof(ConfigurationScheduleOption.IsStartOnInit)]) ? false : bool.Parse(x[nameof(ConfigurationScheduleOption.IsStartOnInit)]!),
                };
            });
            return Task.FromResult(metadatas);
        }
        return Task.FromResult(Enumerable.Empty<ScheduleTaskMetadata>());
    }
}

然后,我们可能需要多任务调度的事件做一些操作或者日志存储。

比如失败了该干嘛,完成了回调其他后续业务等。

我们再来定义一下具体的事件IEvent,具体可以参考文章: https://www.cnblogs.com/vipwan/p/18184088

事件IEvent代码

1、首先定义一个事件约定的空接口

public interface IEvent{}

2、然后定义事件订阅者接口

public interface IEventSubscriber<T> where T : IEvent
{
    Task HandleAsync(T @event, CancellationToken ct);
    /// <summary>
    /// 执行排序
    /// </summary>
    int Order { get; }

    /// <summary>
    /// 如果发生错误是否抛出异常,将阻塞后续Handler
    /// </summary>
    bool ThrowIfError { get; }
}
public abstract class EventSubscriber<T> : IEventSubscriber<T> where T : IEvent
{
    public abstract Task HandleAsync(T @event, CancellationToken ct);
    public virtual int Order => 0;
    /// <summary>
    /// 默认不抛出异常
    /// </summary>
    public virtual bool ThrowIfError => false;
}

3、接着就是发布者

internal class Publisher(IServiceProvider serviceProvider)
{
 public async Task PublishAsync<T>(T @event, CancellationToken ct) where T : IEvent
 {
  var handlers = serviceProvider.GetServices<IEventSubscriber<T>>();
  if (handlers is null) return;
  foreach (var handler in handlers.OrderBy(x => x.Order))
  {
   try
   {
    await handler.HandleAsync(@event, ct);
   }
   catch
   {
    if (handler.ThrowIfError)
    {
     throw;
    }
    //todo:
   }
  }
 }
}

4、到此发布订阅的基本代码也就写完了.接下来就是注册发布者和所有的订阅者了

public abstract class ScheduleTaskEvent(IScheduleTask scheduleTask, DateTime eventTime) : IEvent
{
    /// <summary>
    /// 任务
    /// </summary>
    public IScheduleTask ScheduleTask { get; set; } = scheduleTask;
    /// <summary>
    /// 触发时间
    /// </summary>
    public DateTime EventTime { get; set; } = eventTime;
}
/// <summary>
/// 执行完成
/// </summary>
public sealed class TaskSuccessedEvent(IScheduleTask scheduleTask, DateTime eventTime, DateTime endTime) : ScheduleTaskEvent(scheduleTask, eventTime)
{
    /// <summary>
    /// 执行结束的时间
    /// </summary>
    public DateTime EndTime { get; set; } = endTime;
}
/// <summary>
/// 执行开始
/// </summary>
public sealed class TaskStartedEvent(IScheduleTask scheduleTask, DateTime eventTime) : ScheduleTaskEvent(scheduleTask, eventTime);
/// <summary>
/// 执行失败
/// </summary>
public sealed class TaskFailedEvent(IScheduleTask scheduleTask, DateTime eventTime, Exception exception) : ScheduleTaskEvent(scheduleTask, eventTime)
{
    /// <summary>
    /// 异常信息
    /// </summary>
    public Exception Exception { get; private set; } = exception;
}

接下来我们再实现基于NCrontab的简易调度器,这个调度器主要是解析Cron表达式判断传入时间是否可以执行ScheduleTask,具体的代码:

internal class SampleNCrontabScheduler : IScheduler
{
    /// <summary>
    /// 暂存上次执行时间
    /// </summary>
    private static ConcurrentDictionary<ScheduleTaskAttribute, DateTime> LastRunTimes = new();

    public bool CanRun(ScheduleTaskAttribute scheduleMetadata, DateTime referenceTime)
    {
        var now = DateTime.Now;
        var haveExcuteTime = LastRunTimes.TryGetValue(scheduleMetadata, out var time);
        if (!haveExcuteTime)
        {
            var nextStartTime = CrontabSchedule.Parse(scheduleMetadata.Cron).GetNextOccurrence(referenceTime);
            LastRunTimes.TryAdd(scheduleMetadata, nextStartTime);

            //如果不是初始化启动,则不执行
            if (!scheduleMetadata.IsStartOnInit)
                return false;
        }
        if (now >= time)
        {
            var nextStartTime = CrontabSchedule.Parse(scheduleMetadata.Cron).GetNextOccurrence(referenceTime);
            //更新下次执行时间
            LastRunTimes.TryUpdate(scheduleMetadata, nextStartTime, time);
            return true;
        }
        return false;
    }
}

然后就是核心的BackgroundService了,这里我用的IdleTime心跳来实现,粒度分钟,当然内部也可以封装Timer等实现更复杂精度更高的调度,这里就不展开讲了。

代码如下:

internal class ScheduleBackgroundService : BackgroundService
{
    private static readonly TimeSpan _pollingTime
DEBUG
      //轮询20s 测试环境下,方便测试。
      = TimeSpan.FromSeconds(20);
if
!DEBUG
     //轮询60s 正式环境下,考虑性能轮询时间延长到60s
     = TimeSpan.FromSeconds(60);
if
    //心跳10s.
    private static readonly TimeSpan _minIdleTime = TimeSpan.FromSeconds(10);
    private readonly ILogger<ScheduleBackgroundService> _logger;
    private readonly IServiceProvider _serviceProvider;
    public ScheduleBackgroundService(ILogger<ScheduleBackgroundService> logger, IServiceProvider serviceProvider)
    {
        _logger = logger;
        _serviceProvider = serviceProvider;
    }
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var pollingDelay = Task.Delay(_pollingTime, stoppingToken);
            try
            {
                await RunAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                //todo:
                _logger.LogError(ex.Message);
            }
            await WaitAsync(pollingDelay, stoppingToken);
        }
    }
    private async Task RunAsync(CancellationToken stoppingToken)
    {
        using var scope = _serviceProvider.CreateScope();
        var tasks = scope.ServiceProvider.GetServices<IScheduleTask>();
        if (tasks is null || !tasks.Any())
        {
            return;
        }
        //调度器
        var scheduler = scope.ServiceProvider.GetRequiredService<IScheduler>();
        async Task DoTaskAsync(IScheduleTask task, ScheduleTaskAttribute metadata)
        {
            if (scheduler.CanRun(metadata, DateTime.Now))
            {
                var eventTime = DateTime.Now;
                //通知启动
                _ = new TaskStartedEvent(task, eventTime).PublishAsync(default);
                try
                {
                    if (metadata.IsAsync)
                    {
                        //异步执行
                        _ = task.ExecuteAsync();
                    }
                    else
                    {
                        //同步执行
                        await task.ExecuteAsync();
                    }
                    //执行完成
                    _ = new TaskSuccessedEvent(task, eventTime, DateTime.Now).PublishAsync(default);
                }
                catch (Exception ex)
                {
                    _ = new TaskFailedEvent(task, DateTime.Now, ex).PublishAsync(default);
                }
            }
        };
        //注解中的task
        foreach (var task in tasks)
        {
            if (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            //标注的metadatas
            var metadatas = task.GetType().GetCustomAttributes<ScheduleTaskAttribute>();

            if (!metadatas.Any())
            {
                continue;
            }
            foreach (var metadata in metadatas)
            {
                await DoTaskAsync(task, metadata);
            }
        }
        //store中的scheduler
        var stores = _serviceProvider.GetServices<IScheduleMetadataStore>().ToArray();

        //并行执行,提高性能
        Parallel.ForEach(stores, async store =>
        {
            if (stoppingToken.IsCancellationRequested)
            {
                return;
            }
            var metadatas = await store.GetAllAsync();
            if (metadatas is null || !metadatas.Any())
            {
                return;
            }
            foreach (var metadata in metadatas)
            {
                var attr = new ScheduleTaskAttribute(metadata.Cron)
                {
                    Description = metadata.Description,
                    IsAsync = metadata.IsAsync,
                    IsStartOnInit = metadata.IsStartOnInit,
                };

                var task = scope.ServiceProvider.GetRequiredService(metadata.ScheduleTaskType) as IScheduleTask;
                if (task is null)
                {
                    return;
                }
                await DoTaskAsync(task, attr);
            }
        });
    }

    private static async Task WaitAsync(Task pollingDelay, CancellationToken stoppingToken)
    {
        try
        {
            await Task.Delay(_minIdleTime, stoppingToken);
            await pollingDelay;
        }
        catch (OperationCanceledException)
        {
        }
    }
}

最后收尾阶段我们老规矩扩展一下IServiceCollection:

internal static IServiceCollection AddScheduleTask(this IServiceCollection services)
{
    foreach (var task in ScheduleTasks)
    {
        services.AddTransient(task);
        services.AddTransient(typeof(IScheduleTask), task);
    }
    //调度器
    services.AddScheduler<SampleNCrontabScheduler>();
    //配置文件Store:
ices.AddScheduleMetadataStore<ConfigurationScheduleMetadataStore>();
    //BackgroundService
   services.AddHostedService<ScheduleBackgroundService>();
    return services;
}
/// <summary>
/// 注册调度器AddScheduler
/// </summary>
public static IServiceCollection AddScheduler<T>(this IServiceCollection services) where T : class, IScheduler
{
    services.AddSingleton<IScheduler, T>();
    return services;
}

/// <summary>
/// 注册ScheduleMetadataStore
/// </summary>
public static IServiceCollection AddScheduleMetadataStore<T>(this IServiceCollection services) where T : class, IScheduleMetadataStore
{
    services.AddSingleton<IScheduleMetadataStore, T>();
    return services;
}

老规矩我们来测试一下:

//通过特性标注的方式执行:
[ScheduleTask(Constants.CronEveryMinute)] //每分钟一次
[ScheduleTask("0/3 * * * *")]//每3分钟执行一次
public class KeepAlive(ILogger<KeepAlive> logger) : IScheduleTask
{
    public async Task ExecuteAsync()
    {
        //执行5s
        await Task.Delay(TimeSpan.FromSeconds(5));
        logger.LogInformation("keep alive!");
    }
}
public class DemoConfigTask(ILogger<DemoConfigTask> logger) : IScheduleTask
{
    public Task ExecuteAsync()
    {
        logger.LogInformation("Demo Config Schedule Done!");
        return Task.CompletedTask;
    }
}

通过配置文件的方式配置Store:

{
  "BiwenQuickApi": {
    "Schedules": [
      {
        "ScheduleType": "Biwen.QuickApi.DemoWeb.Schedules.DemoConfigTask,Biwen.QuickApi.DemoWeb",
        "Cron": "0/5 * * * *",
        "Description": "Every 5 mins",
        "IsAsync": true,
        "IsStartOnInit": false
      },
      {
        "ScheduleType": "Biwen.QuickApi.DemoWeb.Schedules.DemoConfigTask,Biwen.QuickApi.DemoWeb",
        "Cron": "0/10 * * * *",
        "Description": "Every 10 mins",
        "IsAsync": false,
        "IsStartOnInit": true
      }
    ]
  }
}

我们还可以实现自己的Store,这里以放到内存为例,如果有兴趣 你可以可以自行开发一个面板管理:

public class DemoStore : IScheduleMetadataStore
{
    public Task<IEnumerable<ScheduleTaskMetadata>> GetAllAsync()
    {
        //模拟从数据库或配置文件中获取ScheduleTaskMetadata
        IEnumerable<ScheduleTaskMetadata> metadatas =
            [
                new ScheduleTaskMetadata(typeof(DemoTask),Constants.CronEveryNMinutes(2))
                {
                    Description="测试的Schedule"
                },
            ];
        return Task.FromResult(metadatas);
    }
}
//然后注册这个Store:
builder.Services.AddScheduleMetadataStore<DemoStore>();

所有的一切都大功告成,最后我们来跑一下Demo,成功了

图片

当然这里是自己的固定思维设计的一个简约版,还存在一些不足,欢迎板砖轻拍指正!

提供同一时间单一运行中的任务实现

/// <summary>
/// 模拟一个只能同时存在一个的任务.一分钟执行一次,但是耗时两分钟.
/// </summary>
/// <param name="logger"></param>
[ScheduleTask(Constants.CronEveryMinute, IsStartOnInit = true)]
public class OnlyOneTask(ILogger<OnlyOneTask> logger) : OnlyOneRunningScheduleTask
{
    public override Task OnAbort()
    {
        logger.LogWarning($"[{DateTime.Now}]任务被打断.因为有一个相同的任务正在执行!");
        return Task.CompletedTask;
    }

    public override async Task ExecuteAsync()
    {
        var now = DateTime.Now;
        //模拟一个耗时2分钟的任务
        await Task.Delay(TimeSpan.FromMinutes(2));
        logger.LogInformation($"[{now}] ~ {DateTime.Now} 执行一个耗时两分钟的任务!");
    }
}

源码地址

https://github.com/vipwan/Biwen.QuickApi

https://github.com/vipwan/Biwen.QuickApi/tree/master/Biwen.QuickApi/Scheduling

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1707509.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

vue2 案例入门

vue2 案例入门 1 vue环境2 案例2.1 1.v-text v-html2.2 v-bind2.3 v-model2.4 v-on2.5 v-for2.6 v-if和v-show2.7 v-else和v-else-if2.8 计算属性和侦听器2.9 过滤器2.10 组件化2.11 生命周期2.12 使用vue脚手架2.13 引入ElementUI2.13.1 npm方式安装2.13.2 main.js导入element…

本地源码方式部署启动MaxKB知识库问答系统,一篇文章搞定!

MaxKB 是一款基于 LLM 大语言模型的知识库问答系统。MaxKB Max Knowledge Base&#xff0c;旨在成为企业的最强大脑。 开箱即用&#xff1a;支持直接上传文档、自动爬取在线文档&#xff0c;支持文本自动拆分、向量化、RAG&#xff08;检索增强生成&#xff09;&#xff0c;智…

YOLOv5改进 | 注意力机制 | 添加全局注意力机制 GcNet【附代码+小白必备】

&#x1f4a1;&#x1f4a1;&#x1f4a1;本专栏所有程序均经过测试&#xff0c;可成功执行&#x1f4a1;&#x1f4a1;&#x1f4a1; 非局部网络通过将特定于查询的全局上下文聚合到每个查询位置&#xff0c;为捕获长距离依赖关系提供了一种开创性的方法。然而&#xff0c;通…

Android 13 高通设备热点低功耗模式

需求: Android设备开启热点,使Iphone设备连接,自动开启低数据模式 低数据模式: 低数据模式是一种在移动网络或Wi-Fi环境下,通过限制应用程序的数据使用、降低数据传输速率或禁用某些后台操作来减少数据流量消耗的优化模式。 这种模式主要用于节省数据流量费用,特别是…

Github Page 部署失败

添加 .gitmodules 文件 [submodule "themes/ayer"]path themes/ayerurl https://github.com/Shen-Yu/hexo-theme-ayer.git 添加 .nojekyll 文件

使用 Orange Pi AIpro开发板基于 YOLOv8 进行USB 摄像头实时目标检测

文章大纲 简介算力指标与概念香橙派 AIpro NPU 纸面算力直观了解 手把手教你开机与基本配置开机存储挂载设置风扇设置 使用 Orange Pi AIpro进行YOLOv8 目标检测Pytorch pt 格式直接推理NCNN 格式推理 是否可以使用Orange Pi AIpro 的 NPU 进行推理 呢&#xff1f;模型开发流程…

vue 微信公众号定时发送模版消息

目录 第一步&#xff1a;公众号设置 网页授权第二步&#xff1a;引导用户去授权页面并获取code第三步&#xff1a;通过code换取网页授权access_token&openid第四步&#xff1a;后端处理绑定用户和发送消息 相关文档链接&#xff1a; 1、微信开发文档 2、订阅号/服务号/企业…

AI生成视频解决方案,降低成本,提高效率

传统的视频制作方式往往受限于高昂的成本、复杂的拍摄流程以及硬件设备的限制&#xff0c;为了解决这些问题&#xff0c;美摄科技凭借领先的AI技术&#xff0c;推出了全新的AI生成视频解决方案&#xff0c;为企业带来前所未有的视觉创新体验。 一、超越想象的AI视频生成 美摄…

【计算机视觉(4)】

基于Python的OpenCV基础入门——色彩空间转换 色彩空间简介HSV色彩空间GRAY色彩空间色彩空间转换 色彩空间转换代码实现: 色彩空间简介 色彩空间是人们为了表示不同频率的光线的色彩而建立的多种色彩模型。常见的色彩空间有RGB、HSV、HIS、YCrCb、YUV、GRAY&#xff0c;其中最…

Sora,数据驱动的物理引擎

文生视频技术 Text-to-Video 近日&#xff0c;Open AI发布文生视频模型Sora&#xff0c;能够生成一分钟高保真视频。人们惊呼&#xff1a;“真实世界将不再存在。” Open AI自称Sora是“世界模拟器”&#xff0c;让“一句话生成视频”的AI技术向上突破了一大截&#xff0c;引…

数据恢复与取证软件: WinHex 与 X-Ways Forensics 不同许可证功能区别

天津鸿萌科贸发展有限公司从事数据安全业务20余年&#xff0c;在数据恢复、数据取证、数据备份等领域有丰富的案例经验、专业技术及良好的行业口碑。同时&#xff0c;公司面向取证机构及数据恢复公司&#xff0c;提供数据恢复实验室建设方案&#xff0c;包含数据恢复硬件设备及…

外贸仓库管理软件:海外仓效率大幅度提升、避免劳动力积压

随着外贸业务的不断发展&#xff0c;如何高效管理外贸仓库&#xff0c;确保货物顺利流转&#xff0c;订单顺利处理&#xff0c;就变得非常重要。 现在通常的解决方案都是通过引入外贸仓库管理软件&#xff0c;也就是我们常说的海外仓WMS系统来解决。 今天我们就系统的探讨一下…

闲话 .NET(3):.NET Framework 的缺点

前言 2016 年&#xff0c;微软正式推出 .NET Core 1.0&#xff0c;并在 2019 年全面停止 .NET Framework 的更新。 .NET Core 并不是 .NET Framework 的升级版&#xff0c;而是一个从头开始开发的全新平台&#xff0c;一个跟 .NET Framework 截然不同的开源技术框架。 微软为…

Paddle 稀疏计算 使用指南

Paddle 稀疏计算 使用指南 1. 稀疏格式介绍 1.1 稀疏格式介绍 稀疏矩阵是一种特殊的矩阵&#xff0c;其中绝大多数元素为0。与密集矩阵相比&#xff0c;稀疏矩阵可以节省大量存储空间&#xff0c;并提高计算效率。 例如&#xff0c;一个5x5的矩阵中只有3个非零元素: impor…

CTFHUB技能树——SSRF(一)

目录 一、SSRF(服务器端请求伪造) 漏洞产生原理: 漏洞一般存在于 产生SSRF漏洞的函数&#xff08;PHP&#xff09;&#xff1a; 发现SSRF漏洞时&#xff1a; SSRF危害&#xff1a; SSRF漏洞利用手段&#xff1a; SSRF绕过方法&#xff1a; 二、CTFHUB技能树 SSRF 1.Ht…

线上服务突然变慢,卡了很久都出不来

文章目录 0、架构1、现象2、查看服务器指标2.1 cpu负载不高2.2 内存指标2.3 硬盘指标2.4 错误日志2.5 大量的tcp连接为TIME_WAIT 3、总结 0、架构 nginx—>httpd—>postgres 单体服务 1、现象 进入页面非常慢。。。 2、查看服务器指标 2.1 cpu负载不高 如下图&…

vue3学习(三)

前言 继续接上一篇笔记&#xff0c;继续学习的vue的组件化知识&#xff0c;可能需要分2个小节记录。前端大佬请忽略&#xff0c;也可以留下大家的鼓励&#xff0c;感恩&#xff01; 一、理解组件化 二、组件化知识 1、先上知识点&#xff1a; 2、示例代码 App.vue (主页面) …

Captura完全免费的电脑录屏软件

一、简介 1、Captura 是一款免费开源的电脑录屏软件&#xff0c;允许用户捕捉电脑屏幕上的任意区域、窗口、甚至是全屏画面&#xff0c;并将这些画面录制为视频文件。这款软件具有多种功能&#xff0c;例如可以设置是否显示鼠标、记录鼠标点击、键盘按键、计时器以及声音等。此…

BookxNote Pro 宝藏 PDF 笔记软件

一、简介 1、BookxNote Pro 是一款专为电子书阅读和学习笔记设计的软件&#xff0c;支持多种电子书格式&#xff0c;如PDF和EPUB&#xff0c;能够帮助用户高效地管理和阅读电子书籍&#xff0c;同时具备强大的笔记功能&#xff0c;允许用户对书籍内容进行标注、摘录和思维导图绘…

解锁数据奥秘,SPSS for Mac/WIN助您智赢未来

在信息爆炸的时代&#xff0c;数据已成为推动社会进步和企业发展的核心动力。但如何将这些海量数据转化为有价值的洞见&#xff0c;却是摆在每一位决策者面前的难题。IBM SPSS Statistics&#xff0c;一款专业的统计分析软件&#xff0c;凭借其强大的功能和易用的界面&#xff…