摘要:队列在StarBlog.Web/Services里添加VisitRecordQueueService.cs文件public classVisitRecordQueueService{privatereadonly ConcurrentQueue
虽然现在工作重心以AI为主了,不过相比起各种大模型的宏大叙事,我还是更喜欢自己构思功能、写代码,享受解决问题和发布上线的过程。
之前 StarBlog 系列更新的时候我也有提到,随着功能更新,会在教程系列完结之后继续写番外,这不第一篇番外就来了。
这次是全新设计的访问统计功能。
访问统计功能很早就已经实现了,在之前这篇 基于.NetCore开发博客项目 StarBlog - (11) 实现访问统计
旧实现存在的问题之前是添加了一个中间件VisitRecordMiddleware,每个请求都写入到数据库里这样会导致两个问题:
影响性能
导致数据库太大,不好备份
新的实现我一直对之前这个实现不满意
这次索性重新设计了,一次性把以上提到的问题都解决了
我用 mermaid 画了个简单的图(第一次尝试在文章里插入 mermaid 画的图,不知道效果咋样)
---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(ILogger logger, IServiceScopeFactory scopeFactory) {
_logger = logger;
_scopeFactory = scopeFactory;
}
// 将日志加入队列
public voidEnqueueLog(VisitRecord log) {
_logQueue.Enqueue(log);
}
// 定期批量写入数据库的
public async TaskWriteLogsToDatabaseAsync(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(RequestDelegate requestDelegate) {
_next = requestDelegate;
}
public TaskInvoke(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);
publicVisitRecordWorker(ILogger logger, IServiceScopeFactory scopeFactory, VisitRecordQueueService logQueue) {
_logger = logger;
_scopeFactory = scopeFactory;
_logQueue = logQueue;
}
protected override async TaskExecuteAsync(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;
publicVisitRecordService(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> GetAll {
returnawait _dbContext.VisitRecords.OrderByDescending(e => e.Time).ToListAsync;
}
publicasync Task> 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 TaskOverview(
) {
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天
Trend(int days =7) {
var startDate = DateTime.Today.AddDays(-days).Date;
returnawait _dbContext.VisitRecords
"/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;
}
///
/// 统计数据
///
Stats(DateTime date) {
returnnew {
Count = await _dbContext.VisitRecords
.Where(e => e.Time.Date == date)
"/Api"))
.CountAsync
};
}
}
主要变动的就是 GetPagedList 和 Overview 接口
EFCore默认不支持按字段名称排序,为此我引入了 Microsoft.EntityFrameworkCore.DynamicLinq 库来实现
EFCore 似乎没有FreeSQL的Aggregate API,可以用原生SQL来替换,但我没有这么做,还是做了多次查询,其实影响不大
其他的属于语法的区别,简单修改即可。
时隔好久再次为 StarBlog 开发新功能,C# 的开发体验还是那么丝滑
然而 "Packages with vulnerabilities have been detected" 的警告也在提醒我这个项目的SDK版本已经outdated了
所以接下来会找时间尽快升级
预告一波:下一个功能与备份有关
🔹《DeepSeek极速上手手册》24页干货:零基础3天玩转智能编码
🔸清华独家课程三部曲:
❶《DeepSeek从入门到精通》104页精讲(附30+代码实例)
❷《职场效能革命指南》35页实战:7大行业应用场景深度拆解
❸《AI红利捕获手册》65页秘籍:普通人快速构建竞争壁垒的5种路径
与万千技术人共建智能开发新范式。
来源:opendotnet