using Dpz.Core.MessageQueue.Abstractions;
using Dpz.Core.Public.ViewModel.Messages;
using Dpz.Core.Public.ViewModel.Request;
using Dpz.Core.Public.ViewModel.Response;
using Dpz.Core.Service.Mediator.Features.Markdown.Commands;
using Medallion.Threading;
namespace Dpz.Core.Service.RepositoryServiceImpl;
public class ArticleService(
IRepository<Article> repository,
IMediator mediator,
IMapper mapper,
ILogger<ArticleService> logger,
IDistributedLockProvider distributedLockProvider,
IFusionCache fusionCache,
IConfiguration configuration,
IMessagePublisher<RemoveImagesMessage> removeImagesPublisher
) : IArticleService
{
private static readonly TimeSpan CacheDefaultExpiration = TimeSpan.FromHours(3);
/// <summary>
/// 生成文章浏览量缓存键, {Prefix}:ViewCount:{Id}
/// </summary>
private string BuildViewCountCacheKey(string articleId) => $"{ViewCountTag}:{articleId}";
/// <summary>
/// 浏览量缓存统一标签
/// </summary>
private string ViewCountTag =>
GeneratedArticleServiceCacheMetadata.DefaultPrefix + ":ViewCount";
/// <summary>
/// 生成文章评论数缓存键, {Prefix}:Comment:{Id}
/// </summary>
private string BuildCommentCountCacheKey(string articleId) => $"{CommentCountTag}:{articleId}";
/// <summary>
/// 评论数缓存统一标签
/// </summary>
private string CommentCountTag =>
GeneratedArticleServiceCacheMetadata.DefaultPrefix + ":Comment";
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
public async Task<List<ArticleMiniResponse>> GetTopArticlesAsync(
int days = -7,
uint count = 8,
CancellationToken cancellationToken = default
)
{
if (days > 0)
{
throw new ArgumentException("days > 0", nameof(days));
}
if (
!string.Equals(
configuration["AgileConfig:env"],
"PROD",
StringComparison.OrdinalIgnoreCase
)
)
{
var noProdData = await repository
.MongodbQueryable.Sample(count)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(noProdData);
}
var date = DateTime.Now.AddDays(days);
var length = (int)count;
var source = await repository
.SearchFor(x => x.CreateTime > date)
.OrderByDescending(x => x.ViewCount)
.Take(length)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(source);
}
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
public async Task<List<ArticleMiniResponse>> GetRandomArticlesAsync(
int sample = 8,
CancellationToken cancellationToken = default
)
{
if (sample <= 0)
{
throw new ArgumentException("sample <= 0", nameof(sample));
}
if (sample > 30)
{
throw new ArgumentException("sample > 30", nameof(sample));
}
var source = await repository
.SearchFor(x => x.Tags.Contains("cnBeta") || x.Tags.Contains("ItHome"))
.Sample(sample)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(source);
}
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
public async Task<List<ArticleMiniResponse>> GetPublishArticlesAsync(
CancellationToken cancellationToken = default
)
{
var source = await repository
.SearchFor(x =>
(!x.Tags.Contains("cnBeta") && !x.Tags.Contains("ItHome")) || x.CommentCount > 0
)
.OrderByDescending(x => x.CreateTime)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(source);
}
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
public async Task<IPagedList<ArticleMiniResponse>> GetPagesAsync(
int pageIndex = 1,
int pageSize = 20,
string? title = "",
string? account = "",
CancellationToken cancellationToken = default,
params string?[]? tags
)
{
var filterEmpty = Builders<Article>.Filter.Empty;
var filters = new List<FilterDefinition<Article>>();
if (!string.IsNullOrEmpty(account))
{
var filter = Builders<Article>.Filter.Eq(x => x.Author.Id, account);
filters.Add(filter);
}
var clearTags = tags?.Where(x => !string.IsNullOrEmpty(x)).Select(x => x!).ToList() ?? [];
if (clearTags.Count > 0)
{
var filter = Builders<Article>.Filter.AnyIn(x => x.Tags, clearTags);
filters.Add(filter);
}
if (!string.IsNullOrEmpty(title))
{
var filter = Builders<Article>.Filter.Regex(
x => x.Title,
new BsonRegularExpression(title, "i")
);
filters.Add(filter);
}
var filterResult = filters.Count > 0 ? Builders<Article>.Filter.And(filters) : filterEmpty;
var result = await repository
.SearchFor(filterResult)
.SortByDescending(x => x.CreateTime)
.ToPagedListAsync<Article, ArticleMiniResponse>(pageIndex, pageSize, cancellationToken);
return result;
}
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour)]
public async Task<List<string>> GetAllTagsAsync(CancellationToken cancellationToken = default)
{
return await repository
.MongodbQueryable.SelectMany(x => x.Tags)
.GroupBy(x => x)
.Select(x => x.Key)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
}
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountAsync))]
public async Task<ArticleResponse?> GetArticleAsync(
string? id,
CancellationToken cancellationToken = default
)
{
var article = await repository.TryGetAsync(id, cancellationToken: cancellationToken);
return article == null ? null : mapper.Map<ArticleResponse>(article);
}
public async Task ViewAsync(string id, CancellationToken cancellationToken = default)
{
if (!ObjectId.TryParse(id, out var oid))
{
return;
}
// 使用 FindOneAndUpdate 原子更新并返回新值
var filter = Builders<Article>.Filter.Eq(x => x.Id, oid);
var update = Builders<Article>.Update.Inc(x => x.ViewCount, 1);
var options = new FindOneAndUpdateOptions<Article, Article>
{
ReturnDocument = ReturnDocument.After,
};
var updated = await repository.Collection.FindOneAndUpdateAsync(
filter,
update,
options,
cancellationToken
);
if (updated == null)
{
return;
}
var newCount = updated.ViewCount;
// 使用从数据库返回的值更新缓存,避免并发下的覆盖问题
await SetViewCountCacheAsync(id, newCount);
await SyncArticleViewCountAsync(id, newCount);
}
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
public async Task<List<ArticleMiniResponse>> GetLatestAsync(
int range = 5,
CancellationToken cancellationToken = default
)
{
if (range <= 0)
{
throw new ArgumentException("range <= 0", nameof(range));
}
if (range > 200)
{
throw new ArgumentException("range > 200", nameof(range));
}
var source = await repository
.MongodbQueryable.OrderByDescending(x => x.CreateTime)
.Take(range)
.ToListAsync(cancellationToken);
return mapper.Map<List<ArticleMiniResponse>>(source);
}
public async Task<bool> IsExistsAsync(
string title,
CancellationToken cancellationToken = default
)
{
var blog = await repository
.SearchFor(x => x.Title == title)
.FirstOrDefaultAsync(cancellationToken);
return blog != null;
}
public async Task<IReadOnlyCollection<string>> NoExistsByFromAsync(
IReadOnlyCollection<string> feeds,
CancellationToken cancellationToken = default
)
{
var filter = Builders<Article>.Filter.In(x => x.From, feeds);
var exists = await repository
.SearchFor(filter)
.Project(x => x.From)
.ToListAsync(cancellationToken);
return feeds
.Except(exists.Select(x => x ?? "").Where(x => !string.IsNullOrWhiteSpace(x)))
.ToArray();
}
public async Task DeleteAsync(string id, CancellationToken cancellationToken = default)
{
if (ObjectId.TryParse(id, out var oid))
{
var article = await repository.FindAsync(oid, cancellationToken);
await repository.DeleteAsync(x => x.Id == oid, cancellationToken);
await ClearCacheAsync(cancellationToken);
// 发布消息到队列,异步删除图片
if (article?.ImagesAddress is { Count: > 0 })
{
await PublishDeleteImagesMessageAsync(article.ImagesAddress, cancellationToken);
}
}
}
public async Task DeleteOldCnBetaAsync(
int month,
int limit,
CancellationToken cancellationToken = default
)
{
if (month <= 0)
{
throw new ArgumentException("month no can't <= 0", nameof(month));
}
var date = DateTime.Now.AddMonths(-month);
var list = await repository
.SearchFor(x =>
(x.Tags.Contains("cnBeta") || x.Tags.Contains("ItHome"))
&& x.CreateTime < date
&& x.CommentCount == 0
)
.OrderBy(x => x.CreateTime)
.Take(limit)
.ToListAsync(cancellationToken);
if (list.Count == 0)
{
return;
}
// 发布消息到队列,异步删除图片
var allImages = list.SelectMany(x => x.ImagesAddress).ToList();
var ids = list.Select(x => x.Id);
await repository.DeleteAsync(x => ids.Contains(x.Id), cancellationToken);
logger.LogInformation("旧数据删除完毕,共删除{Count}篇文章", list.Count);
if (allImages.Count > 0)
{
await PublishDeleteImagesMessageAsync(allImages, cancellationToken);
}
}
private async Task PublishDeleteImagesMessageAsync(
IEnumerable<string> images,
CancellationToken cancellationToken = default
)
{
var messages = images.Select(x => new RemoveImagesMessage { ImageUrl = x });
// 批量发布删除图片队列消息
await removeImagesPublisher.PublishBatchAsync(
messages,
cancellationToken: cancellationToken
);
}
public async Task<ArticleResponse> CreateArticleAsync(
CreateArticleRequest article,
VmUserInfo creator,
CancellationToken cancellationToken = default
)
{
var author = mapper.Map<UserInfo>(creator);
var htmlContent = article.Markdown.MarkdownToHtml(false);
var htmlParse = new HtmlParser();
var document = await htmlParse.ParseDocumentAsync(htmlContent);
var imageElements = document.GetElementsByTagName("img");
var entity = new Article
{
Author = author,
Title = article.Title,
Tags = article.Tags,
Markdown = article.Markdown,
CommentCount = 0,
CreateTime = article.PublishTime ?? DateTime.Now,
From = article.From,
ImagesAddress = imageElements.GetElementsImageUrls(),
Introduction = article.Introduction,
MainImage = imageElements.FirstOrDefault()?.GetAttribute("src"),
ViewCount = 0,
LastUpdateTime = DateTime.Now,
Categories = article.Categories,
AdWeight = article.AdWeight,
MainImageMetadata = article.MainImageMetadata,
Images = article.Images,
};
await using (
await distributedLockProvider.AcquireLockAsync(
$"Article.Create.Lock:{entity.Title}",
cancellationToken: cancellationToken
)
)
{
if (await IsExistsAsync(entity.Title, cancellationToken))
{
throw new ExistsException($"《{entity.Title}》已存在");
}
await repository.InsertAsync(entity, cancellationToken);
}
return mapper.Map<ArticleResponse>(entity);
}
public async Task EditArticleAsync(
EditArticleRequest article,
CancellationToken cancellationToken = default
)
{
if (!ObjectId.TryParse(article.Id, out var id))
{
return;
}
var entity = await repository.FindAsync(id, cancellationToken);
if (entity == null)
{
return;
}
var images = await mediator.Send(
new EditMarkdownRequest
{
Markdown = article.Markdown,
OriginalMarkdown = entity.Markdown,
},
cancellationToken
);
var update = Builders<Article>
.Update.Set(x => x.Title, article.Title)
.Set(x => x.Markdown, article.Markdown)
.Set(x => x.Tags, article.Tags)
.Set(x => x.Introduction, article.Introduction)
.Set(x => x.MainImage, images.FirstOrDefault())
.Set(x => x.ImagesAddress, images)
.Set(
x => x.MainImageMetadata,
article.MainImageMetadata ?? article.Images.FirstOrDefault()
)
.Set(x => x.Images, article.Images)
.Set(x => x.LastUpdateTime, DateTime.Now);
await repository.UpdateAsync(x => x.Id == id, update, cancellationToken);
//await ClearCacheAsync();
var cacheKey = GeneratedArticleServiceCacheMetadata.BuildGetArticleAsyncCacheKey(
article.Id
);
await fusionCache.RemoveAsync(cacheKey, token: cancellationToken);
await fusionCache.RemoveAsync(
GeneratedArticleServiceCacheMetadata.BuildGetAllTagsAsyncCacheKey(),
token: cancellationToken
);
}
public async Task<int> GetTotalCountAsync(CancellationToken cancellationToken = default)
{
checked
{
return (int)
await repository.Collection.CountDocumentsAsync(
FilterDefinition<Article>.Empty,
cancellationToken: cancellationToken
);
}
}
public async Task<int> GetTodayCountAsync(CancellationToken cancellationToken = default)
{
checked
{
var filter = Builders<Article>.Filter.Gte(x => x.CreateTime, DateTime.Now.Date);
return (int)
await repository.Collection.CountDocumentsAsync(
filter,
cancellationToken: cancellationToken
);
}
}
public async ValueTask ClearCacheAsync(CancellationToken cancellationToken = default)
{
var removeTags = new[]
{
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetTopArticlesAsync)),
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetPagesAsync)),
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetAllTagsAsync)),
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetLatestAsync)),
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetRandomArticlesAsync)),
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetPublishArticlesAsync)),
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetAllAuthorsAsync)),
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetArticleAsync)),
ViewCountTag,
CommentCountTag,
};
await fusionCache.RemoveByTagAsync(removeTags, token: cancellationToken);
}
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour)]
public Task<List<string>> GetAllAuthorsAsync(CancellationToken cancellationToken)
{
return GetAllAuthorsCoreAsync(cancellationToken);
}
private async Task<List<string>> GetAllAuthorsCoreAsync(CancellationToken cancellationToken)
{
var authors = await repository
.MongodbQueryable.Select(x => x.Author.Id)
.Distinct()
.ToListAsync(cancellationToken: cancellationToken);
return authors.OrderBy(x => x).ToList();
}
/// <summary>
/// 回填列表缓存的浏览量和评论数
/// </summary>
internal async Task ApplyViewCountsAsync(IEnumerable<ArticleMiniResponse>? articles)
{
if (articles == null)
{
return;
}
foreach (var article in articles)
{
if (string.IsNullOrWhiteSpace(article.Id))
{
continue;
}
article.ViewCount = await GetCachedViewCountAsync(article.Id, article.ViewCount);
article.CommentCount = await GetCachedCommentCountAsync(
article.Id,
article.CommentCount
);
}
}
/// <summary>
/// 回填文章详情缓存的浏览量和评论数
/// </summary>
internal async Task ApplyViewCountAsync(ArticleResponse? article)
{
if (article == null || string.IsNullOrWhiteSpace(article.Id))
{
return;
}
article.ViewCount = await GetCachedViewCountAsync(article.Id, article.ViewCount);
article.CommentCount = await GetCachedCommentCountAsync(article.Id, article.CommentCount);
}
/// <summary>
/// 读取浏览量缓存,缺失则回填
/// </summary>
private async Task<int> GetCachedViewCountAsync(string articleId, int fallbackValue)
{
var cache = await fusionCache.TryGetAsync<int>(BuildViewCountCacheKey(articleId));
if (cache.HasValue)
{
return cache.Value;
}
await SetViewCountCacheAsync(articleId, fallbackValue);
return fallbackValue;
}
/// <summary>
/// 写入浏览量缓存
/// </summary>
private ValueTask SetViewCountCacheAsync(string articleId, int viewCount)
{
return fusionCache.SetAsync(
BuildViewCountCacheKey(articleId),
viewCount,
options => options.SetDuration(CacheDefaultExpiration),
[GeneratedArticleServiceCacheMetadata.DefaultPrefix, ViewCountTag]
);
}
/// <summary>
/// 读取评论数缓存,缺失则回填
/// </summary>
private async Task<int> GetCachedCommentCountAsync(string articleId, int fallbackValue)
{
var cache = await fusionCache.TryGetAsync<int>(BuildCommentCountCacheKey(articleId));
if (cache.HasValue)
{
return cache.Value;
}
await SetCommentCountCacheAsync(articleId, fallbackValue);
return fallbackValue;
}
/// <summary>
/// 写入评论数缓存
/// </summary>
private ValueTask SetCommentCountCacheAsync(string articleId, int commentCount)
{
return fusionCache.SetAsync(
BuildCommentCountCacheKey(articleId),
commentCount,
options => options.SetDuration(CacheDefaultExpiration),
[GeneratedArticleServiceCacheMetadata.DefaultPrefix, CommentCountTag]
);
}
/// <summary>
/// 同步文章详情缓存中的浏览量
/// </summary>
private async Task SyncArticleViewCountAsync(string articleId, int viewCount)
{
var cacheKey = GeneratedArticleServiceCacheMetadata.BuildGetArticleAsyncCacheKey(articleId);
var cachedArticle = await fusionCache.TryGetAsync<ArticleResponse?>(
cacheKey,
token: CancellationToken.None
);
if (cachedArticle is { HasValue: true, Value: not null })
{
cachedArticle.Value.ViewCount = viewCount;
await SetArticleCacheAsync(cacheKey, cachedArticle.Value);
}
}
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour)]
public async Task<ArticleArchiveResponse> GetArchivePagedAsync(
int pageIndex = 1,
int pageSize = 50,
CancellationToken cancellationToken = default
)
{
var pagedList = await repository
.SearchFor(x => !x.Tags.Contains("cnBeta") && !x.Tags.Contains("ItHome"))
.OrderByDescending(x => x.CreateTime)
.ToPagedListAsync<Article>(pageIndex, pageSize, cancellationToken);
var years = pagedList
.GroupBy(x => x.CreateTime.Year)
.Select(yearGroup => new ArchiveYear
{
Year = yearGroup.Key,
Count = yearGroup.Count(),
Months = yearGroup
.GroupBy(x => x.CreateTime.Month)
.Select(monthGroup => new ArchiveMonth
{
Month = monthGroup.Key,
Count = monthGroup.Count(),
Articles = monthGroup
.Select(x => new ArchiveItem
{
Id = x.Id.ToString(),
Title = x.Title,
CreateTime = x.CreateTime,
ViewCount = x.ViewCount,
CommentCount = x.CommentCount,
Tags = x.Tags,
})
.ToList(),
})
.OrderByDescending(m => m.Month)
.ToList(),
})
.OrderByDescending(y => y.Year)
.ToList();
return new ArticleArchiveResponse { TotalCount = pagedList.TotalItemCount, Years = years };
}
public async Task UpdateCommentCountAsync(
string articleId,
int count,
CancellationToken cancellationToken = default
)
{
var article = await repository.TryGetAsync(articleId, cancellationToken);
if (article == null)
{
return;
}
// 更新数据库
var update = Builders<Article>.Update.Set(x => x.CommentCount, count);
await repository.UpdateAsync(x => x.Id == article.Id, update, cancellationToken);
// 更新缓存
await SetCommentCountCacheAsync(articleId, count);
await SyncArticleCommentCountAsync(articleId, count);
}
/// <summary>
/// 同步文章详情缓存中的评论数
/// </summary>
private async Task SyncArticleCommentCountAsync(string articleId, int commentCount)
{
var cacheKey = GeneratedArticleServiceCacheMetadata.BuildGetArticleAsyncCacheKey(articleId);
var cachedArticle = await fusionCache.TryGetAsync<ArticleResponse?>(
cacheKey,
token: CancellationToken.None
);
if (cachedArticle is { HasValue: true, Value: not null })
{
cachedArticle.Value.CommentCount = commentCount;
await SetArticleCacheAsync(cacheKey, cachedArticle.Value);
}
}
/// <summary>
/// 写入文章详情缓存
/// </summary>
private ValueTask SetArticleCacheAsync(string cacheKey, ArticleResponse article)
{
return fusionCache.SetAsync(
cacheKey,
article,
options => options.SetDuration(CacheDefaultExpiration),
[
GeneratedArticleServiceCacheMetadata.DefaultPrefix,
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetArticleAsync)),
GeneratedArticleServiceCacheMetadata.GetMethodTag(nameof(GetArchivePagedAsync)),
]
);
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
代码分析:ArticleService 文章服务实现
概述
这是一个功能完善的文章服务实现类,负责处理文章的增删改查、浏览统计、缓存管理等核心业务逻辑。采用了仓储模式、缓存策略、分布式锁、消息队列等现代架构模式。
核心功能模块
1. 依赖注入组件
public class ArticleService(
IRepository<Article> repository, // MongoDB仓储
IMediator mediator, // MediatR中介者
IMapper mapper, // AutoMapper映射
ILogger<ArticleService> logger, // 日志记录
IDistributedLockProvider distributedLockProvider, // 分布式锁
IFusionCache fusionCache, // 多级缓存
IConfiguration configuration, // 配置服务
IMessagePublisher<RemoveImagesMessage> removeImagesPublisher // 消息发布者
)
2. 缓存管理策略
缓存键生成
// 浏览量缓存键:{Prefix}:ViewCount:{Id}
private string BuildViewCountCacheKey(string articleId)
=> $"{ViewCountTag}:{articleId}";
// 评论数缓存键:{Prefix}:Comment:{Id}
private string BuildCommentCountCacheKey(string articleId)
=> $"{CommentCountTag}:{articleId}";
缓存失效时间
- 默认过期时间:3小时
- 使用
[Cache]特性标记方法自动缓存 - 支持
PostProcess后处理回填数据
主要功能实现
3. 文章查询接口
热门文章查询
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour, PostProcess = nameof(ApplyViewCountsAsync))]
public async Task<List<ArticleMiniResponse>> GetTopArticlesAsync(
int days = -7, // 最近7天
uint count = 8, // 取8篇
CancellationToken cancellationToken = default
)
特性:
- 非生产环境随机采样
- 生产环境按浏览量排序
- 自动回填缓存浏览量
随机文章查询
public async Task<List<ArticleMiniResponse>> GetRandomArticlesAsync(
int sample = 8,
CancellationToken cancellationToken = default
)
- 仅查询
cnBeta或ItHome标签的文章 - 限制采样数 1-30 篇
分页查询
public async Task<IPagedList<ArticleMiniResponse>> GetPagesAsync(
int pageIndex = 1,
int pageSize = 20,
string? title = "",
string? account = "",
CancellationToken cancellationToken = default,
params string?[]? tags
)
支持多条件过滤:
- 标题模糊搜索(正则表达式)
- 作者ID精确匹配
- 多标签筛选
- 按创建时间倒序排序
4. 浏览量管理
原子性增加浏览量
public async Task ViewAsync(string id, CancellationToken cancellationToken = default)
{
// 1. 使用 FindOneAndUpdate 原子更新数据库
var update = Builders<Article>.Update.Inc(x => x.ViewCount, 1);
var options = new FindOneAndUpdateOptions<Article, Article>
{
ReturnDocument = ReturnDocument.After // 返回更新后的文档
};
// 2. 更新缓存
await SetViewCountCacheAsync(id, newCount);
// 3. 同步详情缓存
await SyncArticleViewCountAsync(id, newCount);
}
关键点:
- 使用 MongoDB 的原子操作避免并发问题
- 三级同步:数据库 → 浏览量缓存 → 详情缓存
缓存回填机制
// 批量回填列表数据
internal async Task ApplyViewCountsAsync(IEnumerable<ArticleMiniResponse>? articles)
{
foreach (var article in articles)
{
article.ViewCount = await GetCachedViewCountAsync(article.Id, article.ViewCount);
article.CommentCount = await GetCachedCommentCountAsync(article.Id, article.CommentCount);
}
}
5. 文章创建流程
public async Task<ArticleResponse> CreateArticleAsync(
CreateArticleRequest article,
VmUserInfo creator,
CancellationToken cancellationToken = default
)
{
// 1. 解析Markdown提取图片
var htmlContent = article.Markdown.MarkdownToHtml(false);
var document = await htmlParse.ParseDocumentAsync(htmlContent);
var imageElements = document.GetElementsByTagName("img");
// 2. 构建实体
var entity = new Article
{
Author = mapper.Map<UserInfo>(creator),
ImagesAddress = imageElements.GetElementsImageUrls(),
MainImage = imageElements.FirstOrDefault()?.GetAttribute("src"),
// ...
};
// 3. 分布式锁防止重复创建
await using (await distributedLockProvider.AcquireLockAsync(
$"Article.Create.Lock:{entity.Title}",
cancellationToken: cancellationToken
))
{
if (await IsExistsAsync(entity.Title, cancellationToken))
{
throw new ExistsException($"《{entity.Title}》已存在");
}
await repository.InsertAsync(entity, cancellationToken);
}
}
安全措施:
- 分布式锁确保并发安全
- 重复标题校验
- HTML解析提取图片资源
6. 文章编辑流程
public async Task EditArticleAsync(EditArticleRequest article, ...)
{
// 1. 通过MediatR处理图片变更
var images = await mediator.Send(
new EditMarkdownRequest
{
Markdown = article.Markdown,
OriginalMarkdown = entity.Markdown
},
cancellationToken
);
// 2. 更新文档
var update = Builders<Article>
.Update.Set(x => x.Title, article.Title)
.Set(x => x.Markdown, article.Markdown)
.Set(x => x.ImagesAddress, images)
.Set(x => x.LastUpdateTime, DateTime.Now);
// 3. 精确清除相关缓存
await fusionCache.RemoveAsync(cacheKey, token: cancellationToken);
}
7. 文章删除与清理
单篇删除
public async Task DeleteAsync(string id, CancellationToken cancellationToken = default)
{
var article = await repository.FindAsync(oid, cancellationToken);
await repository.DeleteAsync(x => x.Id == oid, cancellationToken);
// 异步删除关联图片
if (article?.ImagesAddress is { Count: > 0 })
{
await PublishDeleteImagesMessageAsync(article.ImagesAddress, cancellationToken);
}
}
批量清理旧数据
public async Task DeleteOldCnBetaAsync(int month, int limit, ...)
{
// 查询旧数据:特定标签 && 无评论 && 超过指定月数
var list = await repository
.SearchFor(x =>
(x.Tags.Contains("cnBeta") || x.Tags.Contains("ItHome"))
&& x.CreateTime < date
&& x.CommentCount == 0
)
.OrderBy(x => x.CreateTime)
.Take(limit)
.ToListAsync(cancellationToken);
// 批量删除图片
await PublishDeleteImagesMessageAsync(allImages, cancellationToken);
}
消息队列异步删除
private async Task PublishDeleteImagesMessageAsync(...)
{
var messages = images.Select(x => new RemoveImagesMessage { ImageUrl = x });
await removeImagesPublisher.PublishBatchAsync(messages, ...);
}
8. 归档查询
[Cache(ExpirationSeconds = 3 * ExpirationTime.Hour)]
public async Task<ArticleArchiveResponse> GetArchivePagedAsync(...)
{
// 按年份 -> 月份 -> 文章三级分组
var years = pagedList
.GroupBy(x => x.CreateTime.Year)
.Select(yearGroup => new ArchiveYear
{
Year = yearGroup.Key,
Months = yearGroup
.GroupBy(x => x.CreateTime.Month)
.Select(monthGroup => new ArchiveMonth
{
Articles = monthGroup.Select(...).ToList()
})
.ToList()
})
.ToList();
}
技术亮点
9. 缓存策略设计
| 缓存类型 | 键格式 | 用途 | 过期时间 |
|---|---|---|---|
| 浏览量 | {Prefix}:ViewCount:{Id} | 高频读写分离 | 3小时 |
| 评论数 | {Prefix}:Comment:{Id} | 减少数据库压力 | 3小时 |
| 文章详情 | 方法特征生成 | 热点数据缓存 | 3小时 |
缓存一致性保障:
// 更新评论数时同步三处
public async Task UpdateCommentCountAsync(...)
{
// 1. 更新数据库
await repository.UpdateAsync(...);
// 2. 更新评论数缓存
await SetCommentCountCacheAsync(articleId, count);
// 3. 同步详情缓存
await SyncArticleCommentCountAsync(articleId, count);
}
10. 性能优化
分布式锁应用
await using (await distributedLockProvider.AcquireLockAsync(...)) { // 临界区代码 }MongoDB 聚合管道
// 随机采样 .Sample(count) // 数组展开去重 .SelectMany(x => x.Tags).GroupBy(x => x).Select(x => x.Key)批量操作
// 批量发布消息 await removeImagesPublisher.PublishBatchAsync(messages, ...);
总结
架构特点
- ✅ CQRS模式:读写分离(查询缓存/写直达数据库)
- ✅ DDD领域模型:清晰的业务边界
- ✅ 异步消息:图片删除解耦
- ✅ 多级缓存:内存+分布式缓存
- ✅ 并发控制:分布式锁+原子操作
潜在优化点
- 浏览量可考虑使用 Redis 管道批量更新
- 缓存穿透可增加布隆过滤器
- 热点文章可配置更长缓存时间
- 图片删除失败需要补偿机制
AI 正在分析代码…
评论加载中...