基于.NetCore开发 StarBlog 番外篇 (1) 全新的访问统计功能,异步队列,分库存储

前言

虽然现在工作重心以AI为主了,不过相比起各种大模型的宏大叙事,我还是更喜欢自己构思功能、写代码,享受解决问题和发布上线的过程。

之前 StarBlog 系列更新的时候我也有提到,随着功能更新,会在教程系列完结之后继续写番外,这不第一篇番外就来了。

这次是全新设计的访问统计功能。

访问统计

访问统计功能很早就已经实现了,在之前这篇 基于.NetCore开发博客项目 StarBlog - (11) 实现访问统计

旧实现存在的问题

之前是添加了一个中间件 VisitRecordMiddleware ,每个请求都写入到数据库里

这样会导致两个问题:

  1. 影响性能
  2. 导致数据库太大,不好备份

新的实现

我一直对之前这个实现不满意

这次索性重新设计了,一次性把以上提到的问题都解决了

我用 mermaid 画了个简单的图(第一次尝试在文章里插入 mermaid 画的图,不知道效果咋样)

https://mermaid.js.org/syntax/flowchart.html
--- title: 新的访问统计功能设计图 --- flowchart LR Request(用户请求) --> Middleware(访问日志中间件) Middleware(访问日志中间件) --> Queue[/日志队列/] Worker[后台定时任务] --取出日志--- Queue[/日志队列/] Worker[后台定时任务] --写入数据库--> DB[(访问日志独立数据库)]

新的实现用一个队列来暂存访问日志

并且添加了后台任务,定时从队列里取出访问日志来写入数据库

这样就不会影响访问速度

到这里这个新的功能基本就介绍完了

当然具体实现会有一些细节需要注意,接下来的代码部分会介绍

新的技术栈

这次我用了 EFCore 作为 ORM

原因和如何引入我在之前这篇文章有介绍了:Asp-Net-Core开发笔记:快速在已有项目中引入efcore

主要目的是使用 EFCore 能更方便实现分库

具体实现

接下来是具体的代码实现

队列

StarBlog.Web/Services 里添加 VisitRecordQueueService.cs 文件

c# 复制代码
public class VisitRecordQueueService {
  private readonly ConcurrentQueue<VisitRecord> _logQueue = new ConcurrentQueue<VisitRecord>();
  private readonly ILogger<VisitRecordQueueService> _logger;
  private readonly IServiceScopeFactory _scopeFactory;

  /// <summary>
  /// 批量大小
  /// </summary>
  private const int BatchSize = 10;

  public VisitRecordQueueService(ILogger<VisitRecordQueueService> logger, IServiceScopeFactory scopeFactory) {
    _logger = logger;
    _scopeFactory = scopeFactory;
  }

  // 将日志加入队列
  public void EnqueueLog(VisitRecord log) {
    _logQueue.Enqueue(log);
  }

  // 定期批量写入数据库的
  public async Task WriteLogsToDatabaseAsync(CancellationToken cancellationToken) {
    if (_logQueue.IsEmpty) {
      // 暂时等待,避免高频次无意义的检查
      await Task.Delay(1000, cancellationToken);
      return;
    }

    var batch = new List<VisitRecord>();
    // 从队列中取出一批日志
    while (_logQueue.TryDequeue(out var log) && batch.Count < BatchSize) {
      batch.Add(log);
    }

    try {
      using var scope = _scopeFactory.CreateScope();
      var dbCtx = scope.ServiceProvider.GetRequiredService<AppDbContext>();
      await using var transaction = await dbCtx.Database.BeginTransactionAsync(cancellationToken);
      try {
        dbCtx.VisitRecords.AddRange(batch);
        await dbCtx.SaveChangesAsync(cancellationToken);
        await transaction.CommitAsync(cancellationToken);
        _logger.LogInformation("访问日志 Successfully wrote {BatchCount} logs to the database", batch.Count);
      }
      catch (Exception) {
        await transaction.RollbackAsync(cancellationToken);
        throw;
      }
    }
    catch (Exception ex) {
      _logger.LogError(ex, "访问日志 Error writing logs to the database: {ExMessage}", ex.Message);
    }
  }
}

这里使用了:

  • ConcurrentQueue 这个线程安全的FIFO队列
  • 在批量写入数据库的时候用了事务,遇到报错自动回滚

中间件

修改 StarBlog.Web/Middlewares/VisitRecordMiddleware.cs

c# 复制代码
public class VisitRecordMiddleware {
  private readonly RequestDelegate _next;

  public VisitRecordMiddleware(RequestDelegate requestDelegate) {
    _next = requestDelegate;
  }

  public Task Invoke(HttpContext context, VisitRecordQueueService logQueue) {
    var request = context.Request;
    var ip = context.GetRemoteIpAddress()?.ToString();
    var item = new VisitRecord {
      Ip = ip?.ToString(),
      RequestPath = request.Path,
      RequestQueryString = request.QueryString.Value,
      RequestMethod = request.Method,
      UserAgent = request.Headers.UserAgent,
      Time = DateTime.Now
    };
    logQueue.EnqueueLog(item);

    return _next(context);
  }
}

没什么特别的,就是把之前数据库操作替换为添加到队列

注意依赖注入不能在中间件的构造方法里,IApplicationBuilder 注册中间件的时候依赖注入容器还没完全准备好

后台任务

StarBlog.Web/Services 里添加 VisitRecordWorker.cs 文件

c# 复制代码
public class VisitRecordWorker : BackgroundService {
  private readonly ILogger<VisitRecordWorker> _logger;
  private readonly IServiceScopeFactory _scopeFactory;
  private readonly VisitRecordQueueService _logQueue;
  private readonly TimeSpan _executeInterval = TimeSpan.FromSeconds(30);

  public VisitRecordWorker(ILogger<VisitRecordWorker> logger, IServiceScopeFactory scopeFactory, VisitRecordQueueService logQueue) {
    _logger = logger;
    _scopeFactory = scopeFactory;
    _logQueue = logQueue;
  }

  protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
    while (!stoppingToken.IsCancellationRequested) {
      await _logQueue.WriteLogsToDatabaseAsync(stoppingToken);
      await Task.Delay(_executeInterval, stoppingToken);
      _logger.LogDebug("后台任务 VisitRecordWorker ExecuteAsync");
    }
  }
}

要注意的是,BackgroundService 是 singleton 生命周期的,而数据库相关的是 scoped 生命周期,所以在使用前要先获取 scope ,而不是直接注入。

这里使用了 IServiceScopeFactory 而不是 IServiceProvider

在多线程环境里可以保证可以获取根容器的实例,这也是微软文档里推荐的做法。

分库与重构

引入EFCore

如上文所说,访问日志是比较大的,上线这个功能之后几个月的时间,就积累了几十万的数据,在数据库里占用也有100多M了,虽然这还远远达不到数据库的瓶颈

但是对于我们这个轻量级的项目来说,当我想要备份的时候,相比起几个MB的博客数据,这上百MB的访问日志就成了冗余数据,这部分几乎没有备份的意义

所以分库就是势在必得的

这次我使用了EFCore来单独操作这个新的数据库

具体如何引入和实现,之前那篇文章介绍得很详细了,本文不再重复。

Asp-Net-Core开发笔记:快速在已有项目中引入efcore

重构服务

因为使用了EFCore,涉及到的服务也需要调整一下,从FreeSQL换到EFCore

修改 StarBlog.Web/Services/VisitRecordService.cs

c# 复制代码
public class VisitRecordService {
  private readonly ILogger<VisitRecordService> _logger;
  private readonly AppDbContext _dbContext;

  public VisitRecordService(ILogger<VisitRecordService> logger, AppDbContext dbContext) {
    _logger = logger;
    _dbContext = dbContext;
  }

  public async Task<VisitRecord?> GetById(int id) {
    var item = await _dbContext.VisitRecords.FirstOrDefaultAsync(e => e.Id == id);
    return item;
  }

  public async Task<List<VisitRecord>> GetAll() {
    return await _dbContext.VisitRecords.OrderByDescending(e => e.Time).ToListAsync();
  }

  public async Task<IPagedList<VisitRecord>> GetPagedList(VisitRecordQueryParameters param) {
    var querySet = _dbContext.VisitRecords.AsQueryable();

    // 搜索
    if (!string.IsNullOrEmpty(param.Search)) {
      querySet = querySet.Where(a => a.RequestPath.Contains(param.Search));
    }

    // 排序
    if (!string.IsNullOrEmpty(param.SortBy)) {
      var isDesc = param.SortBy.StartsWith("-");
      var orderByProperty = param.SortBy.Trim('-');
      if (isDesc) {
        orderByProperty = $"{orderByProperty} desc";
      }

      querySet = querySet.OrderBy(orderByProperty);
    }
    
    IPagedList<VisitRecord> pagedList = new StaticPagedList<VisitRecord>(
      await querySet.Page(param.Page, param.PageSize).ToListAsync(),
      param.Page, param.PageSize,
      Convert.ToInt32(await querySet.CountAsync())
    );
    return pagedList;
  }

  /// <summary>
  /// 总览数据
  /// </summary>
  public async Task<object> Overview() {
    var querySet = _dbContext.VisitRecords
      .Where(e => !e.RequestPath.StartsWith("/Api"));

    return new {
      TotalVisit = await querySet.CountAsync(),
      TodayVisit = await querySet.Where(e => e.Time.Date == DateTime.Today).CountAsync(),
      YesterdayVisit = await querySet
        .Where(e => e.Time.Date == DateTime.Today.AddDays(-1).Date)
        .CountAsync()
    };
  }

  /// <summary>
  /// 趋势数据
  /// </summary>
  /// <param name="days">查看最近几天的数据,默认7天</param>
  public async Task<object> Trend(int days = 7) {
    var startDate = DateTime.Today.AddDays(-days).Date;
    return await _dbContext.VisitRecords
      .Where(e => !e.RequestPath.StartsWith("/Api"))
      .Where(e => e.Time.Date >= startDate)
      .GroupBy(e => e.Time.Date)
      .Select(g => new {
        time = g.Key,
        date = $"{g.Key.Month}-{g.Key.Day}",
        count = g.Count()
      })
      .OrderBy(e => e.time)
      .ToListAsync();
  }

  /// <summary>
  /// 统计数据
  /// </summary>
  public async Task<object> Stats(DateTime date) {
    return new {
      Count = await _dbContext.VisitRecords
        .Where(e => e.Time.Date == date)
        .Where(e => !e.RequestPath.StartsWith("/Api"))
        .CountAsync()
    };
  }
}

主要变动的就是 GetPagedList 和 Overview 接口

  • EFCore默认不支持按字段名称排序,为此我引入了 Microsoft.EntityFrameworkCore.DynamicLinq 库来实现
  • EFCore 似乎没有FreeSQL的Aggregate API,可以用原生SQL来替换,但我没有这么做,还是做了多次查询,其实影响不大

其他的属于语法的区别,简单修改即可。

小结

时隔好久再次为 StarBlog 开发新功能,C# 的开发体验还是那么丝滑

然而 "Packages with vulnerabilities have been detected" 的警告也在提醒我这个项目的SDK版本已经outdated了

所以接下来会找时间尽快升级

预告一波:下一个功能与备份有关

参考资料

相关推荐
万雅虎3 个月前
ASP.NET Core 9.0 中新增的MapStaticAssets() 中间件
netcore·aspnetcore·net9
程序设计实验室3 个月前
基于.NetCore开发博客项目 StarBlog - (32) 第一期完结
aspnetcore·starblog博客开发笔记
万雅虎6 个月前
使用Kiota工具生成WebApi的代理类,以及接口调用的简单体验
webapi·aspnetcore·kiota
万雅虎7 个月前
使用 Alba 对 AspnetCore项目进行测试
单元测试·aspnetcore
万雅虎8 个月前
NET8中增加的简单适用的DI扩展库Microsoft.Extensions.DependencyInjection.AutoActivation
netcore·aspnetcore
万雅虎8 个月前
NET9 提供HybridCache解决分布式缓存中存在的远程链接&序列化带来的性能问题
netcore·aspnetcore·net9
程序设计实验室9 个月前
Asp-Net-Core开发笔记:使用原生的接口限流功能
c#·aspnetcore
程序设计实验室9 个月前
Asp-Net-Core开发笔记:给SwaggerUI加上登录保护功能
c#·aspnetcore
程序设计实验室9 个月前
Asp-Net-Core开发笔记:使用ActionFilterAttribute实现非侵入式的参数校验
c#·aspnetcore