前言

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

    第一届山东大学HASHCTF部分Misc题解 下面是我在本次比赛出的题目的WriteUp Secret of Keyboard 签到脚本题,有些同学的脚本解出来大小写不正确可能是由于脚本无法识别shi ...

  2. .NET Core 堆结构(Heap)底层原理浅谈

    .Net托管堆布局 加载堆 主要是供CLR内部使用,作为承载程序的元数据. HighFrequencyHeap 存放CLR高频使用的内部数据,比如MethodTable,MethodDesc. 通过i ...

  3. Java基础 —— 反射

    动态语言  动态语言,是指程序在运行时可以改变其结构(新函数可以引进,已有的函数可以被删除等结构上的变化).如:JavaScript.Python就属于动态语言,而C.C++则不属于动态语言,从反射的 ...

  4. C#向JAVA发送form-data文件问题处理方案

    前言 和上一篇文章一样,.NET 和 JAVA之间的接口请求又遇到了新问题 我们有一个用来接收文件的接口,外部把文件流.文件名.目录,传进来,我们系统把生成的附件ID反回去,接口为POST-form- ...

  5. Uniapp input的v-model问题

    前情 uni-app是我很喜欢的跨平台框架,它能开发小程序,H5,APP(安卓/iOS),对前端开发很友好,自带的IDE让开发体验也很棒,公司项目就是主推uni-app. 坑位 最近在做一个input ...

  6. 在 ASP.NET Core 中 使用 Serilog

    Serilog.AspNetCore https://github.com/serilog/serilog-aspnetcore#two-stage-initialization 这是 Serilog ...

  7. JVM 语言的探索发现

    又在 WIKI 上溜达了一下 https://en.wikipedia.org/wiki/List_of_JVM_languages,有一些新的发现: ColdFusion Markup Langua ...

  8. GitHub主页3D图表显示

    效果如图,项目来源于 yoshi389111/github-profile-3d-contrib: This GitHub Action creates a GitHub contribution c ...

  9. 解决容器Docker内部启动计划任务crontab不生效不执行的问题

    docker使用centos7镜像 首先编辑crond,注释#session    required   pam_loginuid.so: vi /etc/pam.d/crond 然后重启crond服 ...

  10. Qt编写的项目作品11-带频谱的音乐播放器

    一.功能特点 可获取整个声音文件采样值数据 可实时获取当前播放位置的采样值数据 可设置采样的步长和数量 可开始播放/暂停播放/停止播放 多线程处理,超流畅 可设置当前播放位置 可设置和调节音量 支持任 ...