StarBlog博客开发笔记(33):全新的访问统计功能,异步队列,分库存储

news/2025/2/22 23:49:05/文章来源:https://www.cnblogs.com/deali/p/18731660

前言

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

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

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

访问统计

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

旧实现存在的问题

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

这样会导致两个问题:

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

新的实现

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

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

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

https://mermaid.js.org/syntax/flowchart.html

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

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

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

这样就不会影响访问速度

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

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

新的技术栈

这次我用了 EFCore 作为 ORM

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

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

具体实现

接下来是具体的代码实现

队列

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

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

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 文件

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

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了

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

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

参考资料

  • https://learn.microsoft.com/zh-cn/dotnet/core/extensions/scoped-service
  • https://www.cnblogs.com/wucy/p/16566495.html

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

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

postman的几种参数化

1.环境变量方式设置:创建环境:点击 Postman 右上角的 Environments > Create New。输入环境名称(如 Dev、test),并添加键值对(如 base_url: https://api.vvhan.com)。使用变量:在请求 URL 或请求体中用双花括号引用变量:{{base_url}}/users。切换环境时,变量会自动…

第十五届蓝桥杯省赛

第十五届蓝桥杯省赛 1.握手问题#include <iostream> using namespace std; int main() { //划分为43,7两组 //(43*42)/2+7*43=28*43 cout<<28*43;return 0; }2.小球反弹(难)分析: 当做两个方向往返代码:/* 考点:速度分解 分解为x轴往返,y轴往返(回到左上角起…

OpenWRT安装Caddy实现WebDAV协议的NAS盘

Caddy带有WebDAV插件,可以安装在OpenWRT上,用于提供WebDAV协议的NAS服务。 Windows资源管理器,WinSCP,MAC的Finder,Android上的CX文件管理器都可以作为WebDAV的客户端。 WebDAV使用过程中可以直接打开文件,修改后保存,非常方便,感觉Samb没区别。但其使用https协议,安全…

Java17的安装

Java17的安装因为要用SpringBoot3了,Java版本要更新到17以后 安装连接:Java Archive Downloads - Java SE 17.0.12 and earlier 下载 选择对应的版本下载安装可以更改安装位置成功以后关闭就行配置环境变量 如果电脑已经安装了JDK8,但又不想卸载的同时想安装JDK17,可以如下…

linux中nano和vim用法

Linux下nano,vim使用Linux 编辑器使用指南:nano 和 vim 在 Linux 系统中,编辑文本文件是非常常见的任务。对于大多数 Linux 用户来说,nano 和 vim 是两个最常用的命令行文本编辑器。虽然它们都可以用来编辑文件,但它们的功能和使用方式有很大不同。本篇文章将介绍这两个编辑…

final关键字、Object类

1.规则 被final修饰的变量,名称都要大写,多单词的名称则需_来分隔1.修饰方法method方法已经不能被重写了,因为修饰该方法的是final2.修饰类 当一个类中所有的成员方法都不想被重写时,可以直接在类上加上final,无需再一个一个写在方法上2.object类: 是所有类的祖宗,每一个…

Java要记-持续补充中

1. ArrayList操作自定义对象进行removeAll()时,移除失效原因 由于底层最用调用的是Object的equals()方法进行比较的,比较的是地址,两个对象地址当然是不同的了,移除自然会失败。解决方案:重写equals方法。【注意重写equals方法记得也要重写hashCode方法】同时:retainAll(…

this和super--java进阶day01

1.this和super的代表super是父类的标识符,如堆内存中的标志 2.this和super的访问重点说访问构造方法,super()访问父类构造方法我们已经清楚,但是this()访问本类构造方法,我们不清楚有什么用意义 如以下情境 假设在公司制作一个系统,1.0有三位角色随着版本更新,1.1要新…

软件开发与创新课程设计第一次作业---小游戏《勇者冒险》改写

《勇者冒险》小游戏代码优化 一、项目名称与来源 题目为《勇者冒险》(原项目没有名字,是作者取的),代码来源是CSDN,链接如下: https://blog.csdn.net/zjx120307/article/details/126221342?sharetype=blog&shareId=126221342&sharerefer=APP&sharefrom=qq 本…

多周期处理器debug记录

这篇随笔记录的是从普通的多周期处理器到加入握手信号和axi-lite协议sram的处理器。 在之前的多周期处理器里,由于结构比较简单,所以我给ifu和exu的握手信号加入的是时序逻辑,idu由于只做解码,所以握手信号放在了组合逻辑里,差不多就和透传差不多。但是加入sram握手信号以…

继承内存图--java进阶 day01

主方法进栈,有new进堆堆内存中先存自己类中有的变量又因为继承了父类,所以父类中的变量也要存入 即使被私有化,依旧可以继承,只是没有权限使用!创建对象时,会调用构造方法,所以走构造方法,实参传形参.....继续走到super,访问父类的构造方法,父类构造方法进栈,形参继…

离散化学习笔记

离散化学习笔记 OP:又是一如既往的周更。。。水死了 定义离散化:将数字映射为是第几小的数,其保证数据在Hash之后仍然保持原来的全/偏序关系,能够解决:通过元素相对大小即可解决的问题。 其实本质上就是哈希的一种特殊规则而已。(离散化简化了不止亿点)目标将一堆乱序且…