Bootstrap

16. 【.NET 8 实战--孢子记账--从单体到微服务】--汇率获取定时器

这篇文章我们将一起编写这个系列专栏中第一个和外部系统交互的功能:获取每日汇率。下面我们一起来编写代码吧。

一、需求

根据文章标题可知,在这片文章中我们只进行汇率的获取和写入数据库。

编号需求说明
1获取每日汇率1. 从第三方汇率API中获取汇率信息并存入数据库中;2. 每天凌晨1点获取汇率;3. 目前仅支持人民币、日元、欧元、韩元、美元、港币、澳门元、英镑、新台币之间的汇率

二、功能编写

网上有很多获取汇率的API,有收费的也有免费的,由于我们开发的项目只是用于实战练习,而不会真正的发布出去让别人使用,因此我们选择免费的API。但是免费的API有好有坏,有的API只提供有限的免费请求额度,有的API只能免费使用很短的几天。所以即使是使用免费的API我们也要谨慎选择,以免耽误我们的练习。
但是,大家不用担心,我已经为大家选好了一个不错的获取汇率的API:Exchange Rate API 。它对免费用户提供每月1500次的接口调用,并且可以一直使用免费的接口,对于我们的项目来说已经足够了。

2.1 编写数据库映射类

我们的需求是获取每天的汇率,因此我们的数据库映射类应该包含汇率日期字段Data。并且我们还需要保存币种之间的汇率,因此还需要币种转换关系字段ConvertCurrency,以及币种汇率字段ExchangeRate
以下的代码就是根据前面分析所编写的数据库映射类ExchangeRateRecord,再次强调的是ExchangeRateRecord 编写完后别忘了迁移数据库:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using SporeAccounting.BaseModels;

namespace SporeAccounting.Models;

/// <summary>
/// 汇率记录表
/// </summary>
[Table(name: "ExchangeRate")]
public class ExchangeRateRecord : BaseModel
{
    /// <summary>
    /// 汇率
    /// </summary>
    [Column(TypeName = "decimal(10,2)")]
    [Required]
    public decimal ExchangeRate { get; set; }

    /// <summary>
    /// 币种转换
    /// </summary>
    [Column(TypeName = "nvarchar(20)")]
    [Required]
    public string ConvertCurrency { get; set; }

    /// <summary>
    /// 汇率日期
    /// </summary>
    [Column(TypeName = "date")]
    [Required]
    public DateTime Date { get; set; }
}
2.2 编写定时器

需求上说需要在每天凌晨1点获取当天的汇率,因此需要编写一个定时器来定时执行获取汇率的工作。我们可以选择的定时器很多,有.NET 原生的也有第三方的。在这里我们选择 Quartz.NET,Quartz.NET 是 Quartz 的.NET版,它在.NET领域中使用的最多。下面我们使用Quartz.NET一起来编写定时器。

  1. 安装 Quartz.NET
    我们不直接使用Quartz.NET,而是使用Quartz.NET的ASP.NET Core集成版。在Nuget中搜索Quartz.AspNetCore,选择支持.NET8的最新版,点击安装即可。
  2. 编写Job
    在Quartz中,一个定时器就是一个Job。新建Job类ExchangeRateTimer,它继承Quartz的IJob接口,并实现Execute方法。在Execute方法中我们要实现向Exchange Rate API获取汇率的接口发送请求并获取汇率信息的功能,以及将汇率信息存入数据库的功能。这里不多说直接上代码:
    using System.Text.Json;
    using Quartz;
    using SporeAccounting.Models;
    using SporeAccounting.Server.Interface;
    using SporeAccounting.Task.Timer.Model;
    
    namespace SporeAccounting.Task.Timer;
    
    /// <summary>
    /// 获取汇率定时器
    /// </summary>
    public class ExchangeRateTimer : IJob
    {
        private readonly IHttpClientFactory _httpClientFactory;
        private readonly IConfiguration _configuration;
        private readonly IServiceScopeFactory _serviceScopeFactory;
        private readonly ICurrencyService _currencyService;
    
        public ExchangeRateTimer(IHttpClientFactory httpClientFactory,
            IConfiguration configuration, IServiceScopeFactory serviceScopeFactory,
            ICurrencyService currencyService)
        {
            _httpClientFactory = httpClientFactory;
            _configuration = configuration;
            _serviceScopeFactory = serviceScopeFactory;
            _currencyService = currencyService;
        }
    
        public System.Threading.Tasks.Task Execute(IJobExecutionContext context)
        {
            string exchangeRateUrl = _configuration["ExchangeRate"];
    
            //获取全部币种
            List<Currency> currencies = _currencyService.Query().ToList();
            //获取对每种币种的汇率
           foreach (var currency in currencies)
           {
               _httpClientFactory.CreateClient().GetAsync($"{exchangeRateUrl}{currency.Abbreviation}")
                   .ContinueWith(
                       response =>
                       {
                           using var scope = _serviceScopeFactory.CreateScope();
                           var exchangeRateRecordService =
                               scope.ServiceProvider.GetRequiredService<IExchangeRateRecordService>();
                           List<ExchangeRateRecord> exchangeRateRecords = new();
                           if (response.Result.IsSuccessStatusCode)
                           {
                               var result = response.Result.Content.ReadAsStringAsync().Result;
                               var resultModel = JsonSerializer.Deserialize<ExchangeRateApiData>(result);
                               if (resultModel?.Result == "success")
                               {
                                   foreach (var rate in resultModel.ConversionRates)
                                   {
                                       //只获取人民币、日元、欧元、韩元、美元、港币、澳门元、英镑、新台币之间的汇率
                                       //其他币种的汇率直接跳过
                                       if (currencies.All(c => c.Abbreviation != rate.Key))
                                       {
                                           continue;
                                       }
    
                                       exchangeRateRecords.Add(new ExchangeRateRecord
                                       {
                                           Id = Guid.NewGuid().ToString(),
                                           ExchangeRate = rate.Value,
                                           //汇率记录的币种代码是基础币种代码和目标币种代码的组合
                                           ConvertCurrency = $"{resultModel.BaseCode}_{rate.Key}",
                                           Date = DateTime.Now,
                                           CreateDateTime = DateTime.Now,
                                           CreateUserId = "System",
                                           IsDeleted = false
                                       });
                                   }
                                   //存入数据库
                                   exchangeRateRecordService.Add(exchangeRateRecords);
                               }
                           }
                       });
           }
    
           return System.Threading.Tasks.Task.CompletedTask;
       }
    }
    
    这段代码看着很复杂,其实是完全按照前面的需求实现的功能,因此这里就不多讲解的。唯一需要注意的是的我们在ContinueWith方法的回调函数中使用了如下代码来获取IExchangeRateRecordService的实例 :
    using var scope = _serviceScopeFactory.CreateScope();
    var exchangeRateRecordService = 
    			scope.ServiceProvider.GetRequiredService<IExchangeRateRecordService>	
    
    为什么要这么做呢,为什么不在构造函数中通过注入的方式获取呢?这是因为我们使用异步的方式来获取汇率数据的,因此ContinueWith内的方法是在另一个线程上运行的,如果通过注入的方式在构造函数中获取IExchangeRateRecordService实例的话,在Execute方法执行完毕后实例就被释放回收了,因此这时如果在ContinueWith内的方法中使用IExchangeRateRecordService的实例就会出发链接已被关闭的异常。
2.3 配置定时器

Job的逻辑已经编写完了,那么最后要做的就是配置定时器,让它在凌晨一点是定时获取汇率信息。在Program类中加入如下代码:

 // 添加定时任务
builder.Services.AddQuartz(q =>
{
    var exchangeRateTimerJobKey=new JobKey("ExchangeRateTimer");
    q.AddJob<ExchangeRateTimer>(opts=>opts.WithIdentity(exchangeRateTimerJobKey));
    q.AddTrigger(opts=>opts
        .ForJob(exchangeRateTimerJobKey)
        .WithIdentity("ExchangeRateTimerTrigger")
        .StartNow()
        .WithCronSchedule("0 0 1 * * ?"));
});
builder.Services.AddQuartzHostedService(options =>
{
	//启用 Quartz 的托管服务,`WaitForJobsToComplete = true` 表示在应用程序停止时等待任务完成后再关闭。
    options.WaitForJobsToComplete = true;
});

这段代码通过 AddQuartz 方法注册了 Quartz 服务容器,在配置定时Job和触发器时,我们先创建了一个唯一标识Job的Job Key exchangeRateTimerJobKey,用于区分不同Job,并通过AddJob方法注册了刚才我们编写的Job类 ExchangeRateTimer,并将其与 exchangeRateTimerJobKey 绑定。接着通过AddTrigger方法创建一个触发器,并将触发器绑定到指定Job上,然后使用StartNow方法将Job注册为立即开始,最后使用WithCronSchedule方法通过Cron 表达式设置每天凌晨 1 点触发Job。

Tip:这篇文章我们只展示出了核心的类、方法以及配置,还有一个方法、接口以及接口的实现类没有展示。一方面因为这些代码和前面文章中的代码类似,另一方面就是我一直再强调的我希望大家能自己动手写写代码。

三、总结

我们一起编写了获取每日汇率的定时器,掌握了 Quartz.NET 的使用,我们的项目也距离完成越来越近了。

;