基於.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介面開發部分的文章里會詳細介紹這個流程。

到這裡就搞定啦~

參考資料