前言

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

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

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

访问统计

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

旧实现存在的问题

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

这样会导致两个问题:

  1. 影响性能

  2. 导致数据库太大,不好备份

新的实现

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

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

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

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

取出日志
写入数据库
用户请求
访问日志中间件
日志队列
后台定时任务
访问日志独立数据库
新的访问统计功能设计图

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

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

这样就不会影响访问速度

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

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

新的技术栈.

这次我用了 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 接口

{0}. EFCore默认不支持按字段名称排序,为此我引入了 Microsoft.EntityFrameworkCore.DynamicLinq 库来实现

{0}. EFCore 似乎没有FreeSQL的Aggregate API,可以用原生SQL来替换,但我没有这么做,还是做了多次查询,其实影响不大

其他的属于语法的区别,简单修改即可。

小结

时隔好久再次为 StarBlog 开发新功能,C# 的开发体验还是那么丝滑

然而 "Packages with vulnerabilities have been detected" 的警告也在提醒我这个项目的SDK版本已经outdated了

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

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

参考资料

基于.NetCore开发 StarBlog 番外篇 (2) 深入解析Markdig源码,优化ToC标题提取和文章目录树生成逻辑的更多相关文章

  1. Java微信公众平台开发【番外篇】(七)--公众平台测试帐号的申请

    转自:http://www.cuiyongzhi.com/post/45.html 前面几篇一直都在写一些比较基础接口的使用,在这个过程中一直使用的都是我个人微博认证的一个个人账号,原本准备这篇是写[ ...

  2. 小白自制Linux开发板 番外篇 一 modprobe加载驱动问题(转载整理)

    使用modprobe加载驱动 转载地址:https://blog.csdn.net/qq_39101111/article/details/78773362 前面我们提到,modprobe并不需要指定 ...

  3. Django基于Pycharm开发之四[关于静态文件的使用,配置以及源码分析](原创)

    对于django静态文件的使用,如果开发过netcore程序的开发人员,可能会比较容易理解django关于静态文件访问的设计原理,个人觉得,这是一个middlerware的设计,但是在django中我 ...

  4. 【微信Java开发 --番外篇】错误解析

    虽然在微信开发过程中,会有微信公众平台开发者文档中的<全局返回码>作为错误的参考对比:但是依旧的,会觉得有时候的问题莫名其妙.[注:本人使用weixin-java-tools进行开发] 下 ...

  5. 前端开发--nginx番外篇

    Centos7下Nginx开发使用(背景: 阿里云ECS Centos7) 安装和启动 安装教程 Centos7安装Nginx实战 需要主意的如下: 文中第四步 4.配置编译参数命令:(可以使用./c ...

  6. 基于java开发jsp+ssm+mysql实现的在线考试系统 源码下载

    实现的关于在线考试的功能有:用户前台:用户注册登录.查看考试信息.进行考试.查看考试成绩.查看历史考试记录.回顾已考试卷.修改密码.修改个人信息等,后台管理功能(脚手架功能不在这里列出),科目专业管理 ...

  7. 痞子衡嵌入式:超级下载算法(RT-UFL)开发笔记番外(1) - JLinkScript妙用

    大家好,我是痞子衡,是正经搞技术的痞子.今天痞子衡给大家介绍的是超级下载算法开发笔记番外篇之JLinkScript妙用. JLinkScript 文件是配套 J-Link 调试器使用的脚本,这个脚本适 ...

  8. 可视化(番外篇)——SWT总结

    本篇主要介绍如何在SWT下构建一个应用,如何安装SWT Designer并破解已进行SWT的可视化编程,Display以及Shell为何物.有何用,SWT中的常用组件.面板容器以及事件模型等. 1.可 ...

  9. 基于.NetCore开发博客项目 StarBlog - (17) 自动下载文章里的外部图片

    系列文章 基于.NetCore开发博客项目 StarBlog - (1) 为什么需要自己写一个博客? 基于.NetCore开发博客项目 StarBlog - (2) 环境准备和创建项目 基于.NetC ...

  10. 基于.NetCore开发博客项目 StarBlog - (21) 开始开发RESTFul接口

    前言 最近电脑坏了,开源项目的进度也受到一些影响 这篇酝酿很久了,作为本系列第二部分(API接口开发)的第一篇,得想一个好的开头,想着想着就鸽了好久,索性不扯那么多了,直接开写吧~ 关于RESTFul ...

随机推荐

  1. manim边做边学--动画联动

    今天介绍Manim中的动画联动的技巧,在数学动画中,动画联动是常用的功能, 比如讲解平面几何中三角形与圆的位置关系变化,通过动画联动可以让圆沿着三角形的边滚动,或者让三角形的顶点在圆上移动,从而直观地 ...

  2. 【刷题】牛客模拟面试 > 模拟面试报告

    https://www.nowcoder.com/interview/ai/index 1-TCP协议的流量控制和拥塞控制 TCP的流量控制是基于窗口机制实现的: 在建立连接时, 发送方和接收方都会建 ...

  3. 消息中间件之-Kafka相关知识

    前言 本篇文章是我基于拉勾kafka课程所作的笔记,包括Kafka基本架构.核心概念.生产者解析.消费者解析.存储.事务.一致性保证等等,希望对大家有所帮助. 一.kafka架构 Kafka基础知识 ...

  4. 转载:大模型所需 GPU 内存笔记

    转载文章:大模型所需 GPU 内存笔记 引言 在运行大型模型时,不仅需要考虑计算能力,还需要关注所用内存和 GPU 的适配情况.这不仅影响 GPU 推理大型模型的能力,还决定了在训练集群中总可用的 G ...

  5. make学习

    make学习,参考「Makefile 20分钟入门,简简单单,展示如何使用Makefile管理和编译C++代码」 程序见:https://github.com/ShiqiYu/CPP/tree/mai ...

  6. fopen在VS中不安全的问题

    问题 fopen函数哎VS中使用,报错: error C4996: 'fopen': This function or variable may be unsafe. Consider using f ...

  7. LeetCode 力扣 205. 同构字符串

    给定两个字符串 s 和 t ,判断它们是否是同构的. 如果 s 中的字符可以按某种映射关系替换得到 t ,那么这两个字符串是同构的. 每个出现的字符都应当映射到另一个字符,同时不改变字符的顺序.不同字 ...

  8. h5使用vue-photo-preview 做全屏预览

    h5页面使用全屏预览 最近需要在微信小程序中跳转到h5页面 在h5页面中需要进行图片预览展示 由于没有使用第三方的组件库. 只能手写,但是时间很紧张. 所以只能够寻找第三方的插件 vue-photo- ...

  9. Java虚拟线程探索

    在Java 21中,引入了虚拟线程,这是一个非常非常重要的特性,之前一直苦苦寻找的Java协程,终于问世了.在高并发以及IO密集型的应用中,虚拟线程能极大的提高应用的性能和吞吐量. ## 什么是虚拟线 ...

  10. AAAT 笔记(P5649)

    实际上去掉主函数不长于线段树 3. 对于 LCT 每个点的虚儿子.用 splay 把它们串起来(称为新 splay,虽然是共用的). 具体来说,设 \(1\le x\le n\) 是原 LCT 的 s ...