基于.NetCore開發博客項目 StarBlog - (17) 自動下載文章里的外部圖片
系列文章
- 基于.NetCore開發博客項目 StarBlog - (1) 為什么需要自己寫一個博客?
- 基于.NetCore開發博客項目 StarBlog - (2) 環境準備和創建項目
- 基于.NetCore開發博客項目 StarBlog - (3) 模型設計
- 基于.NetCore開發博客項目 StarBlog - (4) markdown博客批量導入
- 基于.NetCore開發博客項目 StarBlog - (5) 開始搭建Web項目
- 基于.NetCore開發博客項目 StarBlog - (6) 頁面開發之博客文章列表
- 基于.NetCore開發博客項目 StarBlog - (7) 頁面開發之文章詳情頁面
- 基于.NetCore開發博客項目 StarBlog - (8) 分類層級結構展示
- 基于.NetCore開發博客項目 StarBlog - (9) 圖片批量導入
- 基于.NetCore開發博客項目 StarBlog - (10) 圖片瀑布流
- 基于.NetCore開發博客項目 StarBlog - (11) 實現訪問統計
- 基于.NetCore開發博客項目 StarBlog - (12) Razor頁面動態編譯
- 基于.NetCore開發博客項目 StarBlog - (13) 加入友情鏈接功能
- 基于.NetCore開發博客項目 StarBlog - (14) 實現主題切換功能
- 基于.NetCore開發博客項目 StarBlog - (15) 生成隨機尺寸圖片
- 基于.NetCore開發博客項目 StarBlog - (16) 一些新功能 (監控/統計/配置/初始化)
- 基于.NetCore開發博客項目 StarBlog - (17) 自動下載文章里的外部圖片
前言
好久沒更新博客了,上個月底更新了一篇關于StarBlog博客開發的文章之后,就因為線下培訓、詩詞大會之類的雜七雜八的事浪費了很多時間,有段時間一直在忙這些事情都沒空寫代碼……
PS:我在詩詞大會上分享了這首詩:讀白居易的《禽蟲十二章》
然后最近買了楊中科大佬新出的《AspNetCore技術內幕》,看得津津有味,花了一個多星期的時間,把書里的內容大致看了一遍,DDD(領域驅動設計)我早就想學了,不過一直沒找到好的入門資料,大佬的這本書就很不錯,很好懂,盡管如此,DDD還是一個相對復雜的方法,需要通過不斷的實踐來掌握。
雖然最近做了這么多事,但同時工作也很忙,有個項目需要在九月前上線,本來我打算來實踐一下DDD的,不過寫著寫著發現還是把握不住,只好先用我之前的DjangoStarter框架,后面再慢慢把我的StarBlog博客用DDD思想進行改造~
對了,這么久沒更新博客的原因,還有一點是我在使用過程中對目前的管理后臺非常不滿(使用Vue2+ElementUI開發),用戶體驗極差,所以我同時在構思用何種技術對管理后臺前端項目進行重構,目前有幾個備選項:
- blazor(使用C#開發前端,很酷)
- react(相對其他的來說,我最喜歡的前端技術棧)
- 仍然vue,但重寫現有架構(工作量較小)
還沒拿定主意,在重構完成之前,只能先捏著鼻子用現有的管理后臺,同時大概率也不會在現有的前端項目中增加新功能了。
回到正題
OK,說回本文的內容。在博客的使用過程中,有時候我會從其他網站復制一些markdown片段,或者是從我在其他平臺的博客上復制markdown內容(博客園、掘金之類的),這時候復制過來的markdown內容里面可能會有一些圖片,如果不做處理,可能會產生某些問題,如因圖片防盜鏈功能導致網絡圖片在StarBlog博客中無法顯示、網站運營商關閉導致圖片丟失等,對于數據,還是牢牢掌握在自己的手中比較放心。
于是,我就做了這個功能:將markdown文章中的網絡圖片下載下來,并且替換markdown中的鏈接。
原理很簡單,掃描markdown,把圖片鏈接拿出來下載,同時把圖片鏈接替換成StarBlog上的地址。下面一步步介紹如何在代碼中實現。
下載圖片
首先是下載圖片的功能,C#中訪問網絡,可以使用HttpClient這個標準庫
最簡單的用法是這樣:
var client = new HttpClient();
await client.GetAsync("圖片地址");
不過官方文檔中并不推薦這種用法,最佳實踐是一個程序中只維護一個HttpClient的對象
在AspNetCore中,我們可以利用依賴注入IHttpClientFactory來管理HttpClient對象。
在Program.cs中注冊服務
builder.Services.AddHttpClient();
在需要的地方注入IHttpClientFactory,比如在本項目中,我們新建一個CommonService.cs來放下載文件的代碼,考慮到這個功能以后別的地方也可能用到,所以做成通用的,不和PostService耦合在一起。
代碼如下:
public class CommonService {
private readonly ILogger<CommonService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
public CommonService(ILogger<CommonService> logger, IHttpClientFactory httpClientFactory) {
_logger = logger;
_httpClientFactory = httpClientFactory;
}
public async Task<string?> DownloadFileAsync(string url, string savePath) {
var httpClient = _httpClientFactory.CreateClient();
try {
var resp = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
// 生成隨機文件名
var fileName = GuidUtils.GuidTo16String() + Path.GetExtension(url);
var filePath = Path.Combine(savePath, WebUtility.UrlEncode(fileName));
await using var fs = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Write);
await resp.Content.CopyToAsync(fs);
return fileName;
}
catch (Exception ex) {
_logger.LogError("下載文件出錯,信息:{Error}", ex);
return null;
}
}
}
分析一下部分代碼:
- 第13行代碼使用HttpClient的
GetAsync方法下載數據,添加了個HttpCompletionOption.ResponseHeadersRead參數,這樣我們不必等全部信息加載到內存中后再進行流讀取之類的操作,而是在請求頭返回的時候就可以進入下一步處理。避免因為要下載的文件太大而導致OutOfMemoryException,這對下載文件的程序來說很重要! - 第16行,使用封裝好的Guid工具生成16位的GUID,直接用
Guid.NewGuid().ToString()也行,這是32位的。 - 第18-19行,將Http響應內容寫入文件流
搞定,下載文件代碼比較簡單,涉及到IO操作這種容易出錯的地方,細節要處理好,才能保證程序的穩定性。
PS:別忘了注冊服務!
builder.Services.AddSingleton<CommonService>();
處理Markdown
下載圖片的功能搞定了之后,我們繼續來做markdown處理的部分
關于C#處理Markdown,之前已經有過多次探索了,可以說是輕車熟路了hhh~
附上之前關于Markdown處理的文章:
依然是用Markdig這個庫(貌似.NetCore處理markdown上也沒其他選擇)
在PostService.cs中增加代碼
/// <summary>
/// Markdown中外部圖片下載
/// <para>如果Markdown中包含外部圖片URL,則下載到本地且進行URL替換</para>
/// </summary>
private async Task<string> MdExternalUrlDownloadAsync(Post post) {
if (post.Content == null) return string.Empty;
// 得先初始化目錄
InitPostMediaDir(post);
var document = Markdown.Parse(post.Content);
foreach (var node in document.AsEnumerable()) {
if (node is not ParagraphBlock {Inline: { }} paragraphBlock) continue;
foreach (var inline in paragraphBlock.Inline) {
if (inline is not LinkInline {IsImage: true} linkInline) continue;
var imgUrl = linkInline.Url;
// 跳過空鏈接
if (imgUrl == null) continue;
// 跳過本站地址的圖片
if (imgUrl.StartsWith(Host)) continue;
// 下載圖片
_logger.LogDebug("文章:{Title},下載圖片:{Url}", post.Title, imgUrl);
var savePath = Path.Combine(_environment.WebRootPath, "media", "blog", post.Id!);
var fileName = await _commonService.DownloadFileAsync(imgUrl, savePath);
linkInline.Url = fileName;
}
}
await using var writer = new StringWriter();
var render = new NormalizeRenderer(writer);
render.Render(document);
return writer.ToString();
}
代碼說明:
- 第9行的初始化目錄就是檢查這篇文章有沒有對應的目錄,沒有就先創建,很簡單就不貼代碼了。可以在github項目里看到完整代碼
- 第12行開始的兩層循環通過遍歷markdown文檔樹,把圖片鏈接找出來
- 第22行檢查圖片是站外還是站內的,站內圖片不用下載
這樣就完成了markdown里站外圖片的下載和鏈接替換~
修改文章保存邏輯
接下來修改一下文章的保存邏輯
還是在這個PostService.cs里,保存和新增文章共享一個方法:InsertOrUpdateAsync
直接上代碼
public async Task<Post> InsertOrUpdateAsync(Post post) {
// 是新文章的話,先保存到數據庫
if (await _postRepo.Where(a => a.Id == post.Id).CountAsync() == 0) {
post = await _postRepo.InsertAsync(post);
}
// 檢查文章中的外部圖片,下載并進行替換
post.Content = await MdExternalUrlDownloadAsync(post);
// 修改文章時,將markdown中的圖片地址替換成相對路徑再保存
post.Content = MdImageLinkConvert(post, false);
// 處理完內容再更新一次
await _postRepo.UpdateAsync(post);
return post;
}
代碼說明:
- 新文章的話,會先保存一次,作為草稿。
- 先下載外部圖片,再替換本地圖片鏈接(關于圖片鏈接替換的,可以參考本系列第4篇文章,上面有鏈接)
- 完成這些之后再保存,注意這時文章還是草稿狀態,需要通過另一個方法將文章的
IsPublish屬性設置為true,不過與本文關系不大,這里先不貼代碼,后續在RESTFul接口開發部分的文章里會詳細介紹這個流程。
到這里就搞定啦~

浙公網安備 33010602011771號