百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

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

cac55 2025-03-24 14:18 28 浏览 0 评论

前言

虽然现在工作重心以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 文件


public classVisitRecordQueueService {
privatereadonly ConcurrentQueue _logQueue = new ConcurrentQueue();
privatereadonly ILogger _logger;
privatereadonly IServiceScopeFactory _scopeFactory;

///
/// 批量大小
///

privateconstint BatchSize = 10;

public VisitRecordQueueService(ILogger 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();
// 从队列中取出一批日志
while (_logQueue.TryDequeue(outvar log) && batch.Count < BatchSize) {
batch.Add(log);
}

try {
usingvar scope = _scopeFactory.CreateScope();
var dbCtx = scope.ServiceProvider.GetRequiredService();
awaitusingvar 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 classVisitRecordMiddleware {
privatereadonly 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 classVisitRecordWorker : BackgroundService {
privatereadonly ILogger _logger;
privatereadonly IServiceScopeFactory _scopeFactory;
privatereadonly VisitRecordQueueService _logQueue;
privatereadonly TimeSpan _executeInterval = TimeSpan.FromSeconds(30);

public VisitRecordWorker(ILogger 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 classVisitRecordService {
privatereadonly ILogger _logger;
privatereadonly AppDbContext _dbContext;

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

publicasync Task GetById(int id) {
var item = await _dbContext.VisitRecords.FirstOrDefaultAsync(e => e.Id == id);
return item;
}

publicasync Task<List> GetAll() {
returnawait _dbContext.VisitRecords.OrderByDescending(e => e.Time).ToListAsync();
}

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

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

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

querySet = querySet.OrderBy(orderByProperty);
}

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

///
/// 总览数据
///

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

returnnew {
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()
};
}

///
/// 趋势数据
///

/// 查看最近几天的数据,默认7天
public async Task<object> Trend(int days = 7) {
var startDate = DateTime.Today.AddDays(-days).Date;
returnawait _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();
}

///
/// 统计数据
///

public async Task<object> Stats(DateTime date) {
returnnew {
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


解锁AI驱动的生产力跃迁

程序设计实验室 专注前沿技术落地,每周解析代码级解决方案。

关注获取:

《DeepSeek极速上手手册》24页干货:零基础3天玩转智能编码

清华独家课程三部曲:

  • 《DeepSeek从入门到精通》104页精讲(附30+代码实例)
  • 《职场效能革命指南》35页实战:7大行业应用场景深度拆解
  • 《AI红利捕获手册》65页秘籍:普通人快速构建竞争壁垒的5种路径

与万千技术人共建智能开发新范式。


相关推荐

unetbootin中文版:能够将Linux系统装进U盘的U盘启动盘制作工具

unetbootin中文版是一款能够将Linux操作系统装进U盘或移动硬盘的U盘启动盘制作工具,制作好的U盘启动盘能够用于电脑的维护和系统还原等操作,使用起来非常地不错。该软件不会基于操作系统使用特定...

实用之选,实用之改:DELL 戴尔 灵越14CR-4528B 小改作业

昨天发布了一篇三脚架,今天有时间也写写早就准备写的DELL戴尔灵越14CR-4528B作业吧。话说上个笔记本还是2006年底买的华硕A6JE,电脑挺不错的,在家上上网也够用了,就是转轴设计缺陷,容...

教你如何制作一个启动U盘,从此电脑不用找专人做系统

在电脑使用中,老是遇到卡顿,蓝屏,重启等很多故障,大多都是因为自己日常使用习惯而造成的,很多用户在下载软件的时候不知不觉中都被安装许多乱七八糟的软件,当电脑乱七八糟的东西过多的时候我们就重新来装一个系...

8、Deepin操作系统启动盘(系统盘)制作

1、在Deepin官网https://www.deepin.org/zh/download/下载原版Deepin操作系统2、同时在Deepin官网https://www.deepin.org/zh/d...

电脑死机怎么办,电脑如何使用U盘重装系统

电脑死机是我们最常遇到的系统故障,遇到死机时通常重启就可以解决,不过系统损坏引起的死机就只能重装系统,那么电脑死机如何重装系统呢?下面来看看电脑死机怎么办如何使用U盘重装系统_小白一键重装系统官网。 ...

bootmgr is compressed无法启动系统

bootmgriscompressedPressCtrlAltDeltorestart,电脑启动后无法正常开机出现了这样的字样,就是说明你的C盘驱动被压缩解决方法:1、使用系统光盘或者...

新手教程!如何分辨BIOS启动列表(菜单)中的各种启动项

在BIOS启动菜单中识别各类启动项,是新手安装系统或调整启动顺序的必备技能。下面用最直观的方式,为你梳理常见启动项及其含义,帮助你快速上手:一、传统存储设备启动项1.Floppy(软盘驱动器)对应...

带回家的MINI客厅电脑,自学成才,分享U盘装系统教程

刚好老家新装修了房子,客厅买了个大电视,本来是想在客厅弄台主机,接电视玩,大屏幕玩的才爽,但是台式机箱太占地方了。网上逛了一圈,发现有专门的客厅电脑,就搞了一个,外形不错,放客厅很有档次,主要是主机太...

电脑基础知识:BIOS简介及其与Windows操作系统的关系

什么是BIOS?BIOS,全称BasicInputOutputSystem,即“基本输入输出系统”,是一段固化在电脑主板芯片上的底层固件程序。它类似于一款极简化的操作系统,负责电脑开机时的硬件初...

win 7 系统注册表文件丢失或损坏,求不重做系统的解决办法!

粉丝问题解答:win7系统注册表文件丢失或损坏,求不重做系统的解决办法!解决方法:你只需要有启动盘即可,不需要其他的。之所以要求启动盘,是因为下面要对系统文件进行还原覆盖,所以不能用原系统启动。用...

UEFI怎么装Win7 小编呕血解难点!

自从广开言路之后,小编就被你们害苦了,这不,一条评论又让小编彻夜难眠。另外某些小伙伴坐不上沙发后提出要上墙的需求,其实呢只要大家提出的问题具有普遍性、有难度、而且适合小编做微信内容的话,都有机会将你们...

固态攻坚战——ASUS 华硕k45v换固态、拆机清灰教程

作者:蘑菇爱上我现在固态白菜价固态对于电脑体验的提升还是很大的对于固态存储芯片的问题没什么好说的有钱mlc,没钱tlc,不需要考虑什么寿命的问题,我用了一年多的m600,写入才3TB品牌很重要,主控...

MBR启动报错?Win10不重装一样能好!

Win10一遇到启动故障,很多小伙伴可能就会抓瞎,这可怎么弄,我不会修复啊!其实大可不必惊慌,就像这种最常见的Winload启动错误,多半都是MBR分区表丢失造成的(UEFI分区模式的几乎没有这种故障...

从零开始:硬盘手动装系统全攻略

手动安装操作系统是计算机技术必备的基本技能。对于初学者来说,可能会感到有些挑战。但通过掌握硬盘手动装系统方法,你可以亲身体验整个安装过程,进而更好地理解操作系统的工作原理。本文将详细介绍硬盘手动装系统...

电脑开机后显示File:BCD错误0xc000000f

WIN7\WIN8\WIN101、一个win864位PE。这个64位PE的相关文件,路径在boot\BOOT.WIM实机测试,开机后显示File:\EFI\Microsoft\Boot\BCD,...

取消回复欢迎 发表评论: