前言

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

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

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

参考资料

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

  1. [转至云风的博客]开发笔记 (2) :redis 数据库结构设计

    接上回,按照我们一期项目的需求,昨天我简单设计了数据库里的数据格式.数据库采用的是 Redis ,我把它看成一个远端的数据结构保存设备.它提供基本的 Key-Value 储存功能,没有层级表.如果需要 ...

  2. Padrino 博客开发示例

    英文版出处:http://www.padrinorb.com/guides/blog-tutorial 楼主按 拿作者自己的话说:Padrino(谐音:派骓诺)是一款基于Sinatra的优雅的Web应 ...

  3. Django博客开发实践,初学者开发经验

    python,Django初学者,开发简易博客,做了一下笔记,记录了开发的过程,功力浅薄,仅供初学者互相 交流,欢迎意见建议.具体链接:Django博客开发实践(一)--分析需求并创建项目 地址:ht ...

  4. Django 博客开发教程目录索引

    Django 博客开发教程目录索引 本项目适合 0 基础的 Django 开发新人. 项目演示地址:Black & White,代码 GitHub 仓库地址:zmrenwu/django-bl ...

  5. django 简易博客开发 4 comments库使用及ajax支持

    首先还是贴一下源代码地址  https://github.com/goodspeedcheng/sblog 上一篇文章我们介绍了静态文件使用以及如何使用from实现对blog的增删改,这篇将介绍如何给 ...

  6. Django博客开发-数据建模与样式设定

    开发流程介绍 之前Django的学习过程当中已经把基本Django开发学完了,现在以Django 的博客项目完成一遍课程的回顾和总结.同时来一次完整开发的Django体验. 一个产品从研究到编码我们要 ...

  7. 微信小程序版博客——开发汇总总结(附源码)

    花了点时间陆陆续续,拼拼凑凑将我的小程序版博客搭建完了,这里做个简单的分享和总结. 整体效果 对于博客来说功能页面不是很多,且有些限制于后端服务(基于ghost博客提供的服务),相关样式可以参考截图或 ...

  8. 一步步开发自己的博客 .NET版(3、注册登录功能)

    前言 这次开发的博客主要功能或特点:    第一:可以兼容各终端,特别是手机端.    第二:到时会用到大量html5,炫啊.    第三:导入博客园的精华文章,并做分类.(不要封我)    第四:做 ...

  9. 一步步开发自己的博客 .NET版(4、文章发布功能)百度编辑器

    前言 这次开发的博客主要功能或特点: 第一:可以兼容各终端,特别是手机端. 第二:到时会用到大量html5,炫啊. 第三:导入博客园的精华文章,并做分类.(不要封我) 第四:做个插件,任何网站上的技术 ...

  10. Django个人博客开发 | 前言

    本渣渣不专注技术,只专注使用技术,不是一个资深的coder,是一个不折不扣的copier 1.前言 自学 Python,始于 Django 框架,Scrapy 框架,elasticsearch搜索引擎 ...

随机推荐

  1. 【C#基础】Dynamic类型和正确用法

    前言 Dynamic类型是C#4.0中引入的新类型,它允许其操作掠过编译器类型检查,而在运行时处理. 编程语言有时可以划分为静态类型化语言和动态类型化语言.C#和Java经常被认为是静态化类型的语言, ...

  2. 调用非托管dll常出现的bug及解决办法

    转自http://www.51testing.com/html/00/n-832200.html C和C++有很多好的类库的沉淀,在.NET中,完全抛弃它们而重头再来是非常不明智的.也是不现实的,所以 ...

  3. Proxmox VE(虚拟机集群)安装配置

    #Proxmox VE 安装配置 创建kvm模板 有私有云需求, 创建与管理Windows和Linux虚拟机的场景,使用PVE(Proxmox VE)管理很方便. 本人使用PVE管理公司开发测试环境几 ...

  4. 【Python】conda基本使用、pip换源、pip超时问题解决

    conda问题 重要警告:安装conda的时候,安装目录不要包含空格以及特殊字符,最好不要直接装在C盘根目录, 往期笔记 conda安装: https://www.cnblogs.com/mllt/p ...

  5. Docker Install on Ubuntu

    https://docs.docker.com/engine/install/ubuntu/ https://docs.docker.com/compose/install/linux/

  6. 有邻App覆盖3000多个小区成杭州用户量最大的邻里分享经济平台 杨仁斌:开创新社区时代

    [浙商创业青云榜] 当下中国大多数的城市社区里,邻居这个词是个淡薄的概念. 2014年,一名阿里高管决心改变现状,辞职创业,深挖社区分享经济,准备用一款手机App"有邻",去敲开陌 ...

  7. Qt视频监控系统一个诡异问题的解决思路(做梦都想不到)

    一.前言 由于Qt版本众多,几百个版本之间存在不兼容的情况,为此如果要兼容很多版本,没有取巧的办法和特殊的捷径,必须自己亲自安装各个版本编译运行并测试,大问题一般不会有,除非缺少模块,小问题还是不断有 ...

  8. Qt编写地图综合应用55-海量点位标注

    一.前言 海量点位标注的出现,是为了解决普通设备点超过几百个性能极速降低的问题,普通的marker标注由于采用的是对象的形式存在于地图中,数量越多,占用内存特别大,超过1000个点性能极其糟糕,哪怕是 ...

  9. [转]快速搭建简单的LBS程序——地图服务

    很多时候,我们的程序需要提供需要搭建基于位置的服务(LBS),本文这里简单的介绍一下其涉及的一些基本知识. 墨卡托投影 地图本身是一个三维图像,但在电脑上展示时,往往需要将其转换为二维的平面图形,需要 ...

  10. d2go使用总结

    d2go使用总结 安装 PyTorch Nightly 安装 PyTorch Nightly(以 CUDA 10.2 为例,详见PyTorch 网站): conda install pytorch t ...