《C# 爬蟲 破境之道》:第二境 爬蟲應用 — 第五節:小總結帶來的優化與重構
在上一節中,我們完成了一個簡單的採集示例。本節呢,我們先來小結一下,這個示例可能存在的問題:
- 沒有做異常處理
- 沒有做反爬應對策略
- 沒有做重試機制
- 沒有做併發限制
- ……
呃,看似平靜的表面下還是隱藏著不少殺機的……
但本節不打算對付上述問題,而是先關注一個隱藏更深的問題,這個問題,可能會牽扯很多人(包括我☹,不包括我☺,包括我☹,不包括我☺)的程式設計習慣問題。
這裡提出一個突出的問題,就是堆疊溢位的問題。
首先,我們以上一節的示例為例,解析一下造成的原因,下圖演示了一個內容採集的遊走路徑,也就是呼叫過程:
可以看出,方法之間存在著比較明顯的依賴關係,也就是說,只有下一級方法執行完畢了,上一級方法才能完成執行,雖然其間,有部分非同步方法,但總體來說,還是會有依賴存在。這就造成了堆疊積壓,也就是一個方法沒有執行完,另一個方法又不壓入棧中,然後又壓入一個,又一個……又一個……最終,就會導致堆疊溢位。
示例的場景應當說是最為簡單的,這種依賴還不算嚴重,但如果量級上來的話,也會是不小的一張關係網,而且被壓入堆疊的,不僅僅是這幾個方法所佔的空間,還有可能會導致這個方法所涉及的類的例項以及其內部一些其他資源都無法被釋放,而系統又不得不“保留”這張網,GC也拿它毫無辦法(因為引用表都在)。再如果場景更復雜一些,可能一個驗證碼所需的依賴關係就要比本示例更為嚴重,再加上後續的流程層級多一些,再加上持久化等處理器的引入、分支結構的增加等等,相應的場景越複雜,耦合度就會越來越高,那對系統的影響將是毀滅性的。
相通的理論,有興趣的同學,可以查詢“遞迴所帶來的問題”,以瞭解更多。而且這個問題的存在,可不是僅僅存在於爬蟲系統中,它存在於我們日常編寫的每一行程式碼中。它與語言無關、與業務無關,稍有不慎,就會留下這麼個坑。很可怕,這也是為什麼要專門拿出一節來說這個問題。
那麼,是什麼造成了這種耦合呢?其實,這也是由於“正常”的思維方式所引發的。拿示例來說,我們需要得到書籍列表,才能得到書籍ID,需要得到書籍ID,才能拼接出書籍章節列表的連結,需要得到書籍ID和章節ID,才能拼接出章節內容的連結,所以,理所當然的,就產生了依賴。
另外,當我們在Start方法和Analize方法內部發生錯誤時,最簡單的“重試”方案時什麼,就是再次執行Start方法嘛,好麼,這種重試,有可能是1次,也有可能是N次,碰到伺服器掛掉了,那麼就會是永無休止的重試。這種情況造成的堆疊積壓可要比一般的N級樹帶來的毀滅性更大。
瞭解了問題的嚴重性以及產生的原因,然後,我們嘗試給出一個解決方案,解決這個問題的關鍵,就在於如何能夠打破這個方法間的耦合。
這裡,我們舉一個生活中的栗子,假設,我是一個輕奢份子,自己不做飯,餓了,就下館子,這樣,我就對館子產生了依賴,從出門去吃飯的那一刻起,我就無法再享受我的寬屏顯示器帶來的舒適感了,而外賣小哥的出現,有效的緩解了我的病症,下完單,足不出戶,就可以繼續抱著顯示器寫文章了。不用關心小哥什麼時候去商家取的餐,也不用操心小哥先送誰的後送誰的,就專心寫文章,等餐到了,就開吃,完活。
是不是聞到了“非同步”的味道?
其實,我們的示例中,已經使用非同步解決了從Start到Analize的耦合,那麼從Analize到下一個Start之間甚至是發生錯誤時的重試呢,我們嘗試使用另一種方法 —— 佇列。
我們把採集列表頁中的每一頁,看作一個單獨的任務,丟到佇列裡;
我們把採集每一本書的章節列表,看作一個單獨的任務,丟到佇列裡;
我們把採集每一本書的每一個章節內容,看作一個單獨的任務,丟到佇列裡;
當佇列中的任務被執行,又沒有執行成功時,就把這個任務再次丟到佇列裡;(重試)
佇列中的任務都是雜湊的,之間都沒有依賴關係,佇列可以採用先進先出(FIFO)或後進先出(LIFO)原則來執行,問題不大。這樣就可以有效避免了之前提出的堆疊溢位的問題。同時,我們還可以通過控制佇列的大小,來限制併發量,一石二鳥:)再加上進入時間為度,還可以對併發頻率做限制,一箭三雕:)
好了,既然已經有了解決方案,那麼就來對我們的爬蟲框架進行一次重構吧:)
第一步,我們在爬蟲框架中新建立一個小螞蟻的領隊(LeaderAnt)類:
1 namespace MikeWare.Core.Components.CrawlerFramework 2 { 3 using System; 4 using System.Collections.Concurrent; 5 using System.Collections.Generic; 6 using System.Threading; 7 using System.Threading.Tasks; 8 9 public class LeaderAnt : Ant 10 { 11 public virtual ConcurrentQueue<JobContext> Queue { get; set; } 12 private ManualResetEvent mre = new ManualResetEvent(false); 13 //private List<WorkerAnt> workers = new List<WorkerAnt>(); 14 15 public void Work() 16 { 17 JobContext context = null; 18 19 do 20 { 21 if (Queue.TryDequeue(out context)) 22 { 23 CreateWorker(context).Work(context); 24 } 25 } while (!mre.WaitOne(1)); 26 } 27 28 private WorkerAnt CreateWorker(JobContext context) 29 { 30 return 31 new WorkerAnt() 32 { 33 AntId = (uint)Math.Abs(DateTime.Now.ToString("yyyyMMddHHmmssfff").GetHashCode()), 34 OnJobStatusChanged = (sender, args) => 35 { 36 //Console.WriteLine($"{args.EventAnt.AntId} said: {args.Context.JobName} entered status '{args.Context.JobStatus}'."); 37 switch (args.Context.JobStatus) 38 { 39 case TaskStatus.Created: 40 //if (string.IsNullOrEmpty(args.Context.JobName)) 41 //{ 42 // Console.WriteLine($"Can not execute a job with no name."); 43 // args.Cancel = true; 44 //} 45 //else 46 // Console.WriteLine($"{args.EventAnt.AntId.ToString("000000000")} said: job {args.Context.JobName} created."); 47 break; 48 case TaskStatus.Running: 49 //if (null != args.Context.Memory) 50 // Console.WriteLine($"{args.EventAnt.AntId} said: {args.Context.JobName} already downloaded {args.Context.Memory.Length} bytes."); 51 break; 52 case TaskStatus.RanToCompletion: 53 if (null != args.Context.Buffer && 0 < args.Context.Buffer.Length) 54 args.Context.Analizer.Analize(this, args.Context); 55 if (null != args.Context.Watch) 56 Console.WriteLine($"{args.EventAnt.AntId.ToString("000000000")} said: job {args.Context.JobName} Finished using {(args.Context.Watch.Elapsed.TotalMilliseconds / 100).ToString("000.00")}ms / request ******************** */{Environment.NewLine + Environment.NewLine}"); 57 break; 58 case TaskStatus.Faulted: 59 Console.WriteLine($"{args.EventAnt.AntId} said: job {args.Context.JobName} faulted because {args.Message}."); 60 Queue.Enqueue(args.Context); 61 break; 62 case TaskStatus.WaitingToRun: 63 case TaskStatus.WaitingForChildrenToComplete: 64 case TaskStatus.Canceled: 65 case TaskStatus.WaitingForActivation: 66 default:/* Do nothing on this even. */ 67 break; 68 } 69 }, 70 }; 71 } 72 } 73 }領隊 -- LeaderAnt 類
在這個類中,聲明瞭一個任務佇列(ConcurrentQueue<JobContext> Queue),用來提供一個任務池;
一個幹活方法(Work),負責從佇列中取出任務,並分配給WorkerAnt,支配工蟻去幹活~
一個建立工蟻方法(CreateWorker),負責根據任務上下文建立一隻小工蟻;這樣,我們就無需在業務層直接與工蟻打交道了,只需要往領隊的任務池裡丟任務就可以了;
領隊在建立工蟻的時候,還指定了一項狀態監控功能,當任務失敗時,就把任務重新丟回任務池,嘗試再次執行該任務:
1 case TaskStatus.Faulted: 2 Console.WriteLine($"{args.EventAnt.AntId} said: job {args.Context.JobName} faulted because {args.Message}."); 3 Queue.Enqueue(args.Context); 4 break;
第二步,我們在爬蟲框架中又新增了一個解析器的抽象類:
1 namespace MikeWare.Core.Components.CrawlerFramework 2 { 3 using System; 4 5 public abstract class ACrawlerAnalizer 6 { 7 public virtual void Analize(LeaderAnt leader, JobContext context) => throw new NotImplementedException(); 8 } 9 }解析器類 -- ACrawlerAnalizer
這個類是一個抽象類,只提供了一個抽象方法Analize。主要用於實際業務去實現不同的業務節點的解析器,將關注點分離出去;
還是上一節使用的示例,我們在業務層重新提供了三個解析器型別:
1 namespace MikeWare.Crawlers.EBooks.Bizs 2 { 3 using MikeWare.Core.Components.CrawlerFramework; 4 using MikeWare.Crawlers.EBooks.Entities; 5 using System; 6 using System.Collections.Generic; 7 using System.Net; 8 using System.Text; 9 using System.Text.RegularExpressions; 10 11 public class BooksListAnalizer : ACrawlerAnalizer 12 { 13 private static Encoding encoding = new UTF8Encoding(false); 14 private static int total_page = -1; 15 private static Regex regex_list = new Regex(@"<li>[^<]+<div.*?更新:(?<updateTime>\d+?-\d+?-\d+?)[^\d].+?<a[^/]+?/Shtml(?<id>\d+?)\.html.+?</li>", RegexOptions.Singleline); 16 private static Regex regex_page = new Regex(@"<div class=""tspage"">.+?<a href='/s/new/index_(?<totalPage>\d+?).html'>尾頁</a>.+?</div>", RegexOptions.Singleline); 17 18 public override void Analize(LeaderAnt leader, JobContext context) 19 { 20 if (null == context.InParams) return; 21 22 var data = context.Buffer; 23 if (null == data || 0 == data.Length) return; 24 25 var content = encoding.GetString(data); 26 var matches = regex_list.Matches(content); 27 if (!context.InParams.ContainsKey(Consts.LAST_UPDATE_TIME) || null == context.InParams[Consts.LAST_UPDATE_TIME]) return; 28 29 if (null != matches && 0 < matches.Count) 30 { 31 var lastUpdateTime = DateTime.MinValue; 32 if (!DateTime.TryParse(context.InParams[Consts.LAST_UPDATE_TIME].ToString(), out lastUpdateTime)) 33 return; 34 35 var update_time = DateTime.MinValue; 36 var bookId = 0; 37 foreach (Match match in matches) 38 { 39 if (!DateTime.TryParse(match.Groups["updateTime"].Value, out update_time) 40 || !int.TryParse(match.Groups["id"].Value, out bookId)) continue; 41 42 if (update_time > lastUpdateTime) 43 { 44 var newContext = new JobContext 45 { 46 JobName = "“奇書網-電子書-章節列表”", 47 Uri = $"http://www.xqishuta.com/du/{bookId / 1000}/{bookId}/", 48 Method = WebRequestMethods.Http.Get, 49 InParams = new Dictionary<string, object>(), 50 Analizer = new BookSectionsListAnalizer(), 51 }; 52 newContext.InParams.Add(Consts.LAST_UPDATE_TIME, context.InParams[Consts.LAST_UPDATE_TIME]); 53 newContext.InParams.Add(Consts.BOOK_ID, bookId); 54 leader.Queue.Enqueue(newContext); 55 } 56 else 57 return; 58 } 59 } 60 61 if (-1 == total_page) 62 { 63 var match = regex_page.Match(content); 64 if (null != match && match.Success && int.TryParse(match.Groups["totalPage"].Value, out total_page)) ; 65 66 } 67 68 var pageIndex = -1; 69 if (!context.InParams.ContainsKey(Consts.PAGE_INDEX) || null == context.InParams[Consts.PAGE_INDEX] 70 || !int.TryParse(context.InParams[Consts.PAGE_INDEX].ToString(), out pageIndex)) return; 71 72 if (pageIndex < total_page) 73 { 74 pageIndex++; 75 var newContext = new JobContext 76 { 77 JobName = $"奇書網-最新電子書-列表-第{pageIndex.ToString("00000")}頁", 78 Uri = $"http://www.xqishuta.com/s/new/index_{pageIndex}.html", 79 Method = WebRequestMethods.Http.Get, 80 InParams = new Dictionary<string, object>(), 81 Analizer = new BooksListAnalizer(), 82 }; 83 newContext.InParams.Add(Consts.PAGE_INDEX, pageIndex); 84 newContext.InParams.Add(Consts.LAST_UPDATE_TIME, context.InParams[Consts.LAST_UPDATE_TIME]); 85 86 leader.Queue.Enqueue(newContext); 87 } 88 } 89 } 90 }電子書列表解析器 -- BooksListAnalizer
1 namespace MikeWare.Crawlers.EBooks.Bizs 2 { 3 using MikeWare.Core.Components.CrawlerFramework; 4 using MikeWare.Crawlers.EBooks.Entities; 5 using System; 6 using System.Collections.Generic; 7 using System.Net; 8 using System.Text; 9 using System.Text.RegularExpressions; 10 11 public class BookSectionsListAnalizer : ACrawlerAnalizer 12 { 13 private static Encoding encoding = new UTF8Encoding(false); 14 private static Regex regex_section_list = new Regex(@"(?<=<div[^>]+>[^<]+<p[^>]+>[^<]+?正文</p>.+?)(<li><a[^\d]+?(?<section_id>\d+?)\.html[^>]*?>(?<section_name>[^<]+?)</a></li>[^<]+?)+?(?=<)", RegexOptions.Singleline); 15 private static Regex regex_book_info = new Regex(@"<img src=""(?<photo>[^""]+)"" onerror=""[^""]+""/>" 16 + @".+?<div class=""info_des"">" 17 + @".+?<h1>(?<name>[^<]+)</h1>" 18 + @".+?<dl>作 者:(?<author>[^<]+)</dl>" 19 + @".+?<dl>最後更新:(?<updateTime>[^<]+)</dl>", RegexOptions.Singleline); 20 21 public override void Analize(LeaderAnt leader, JobContext context) 22 { 23 if (null == context.InParams) return; 24 25 var data = context.Buffer; 26 if (null == data || 0 == data.Length) 27 return; 28 29 var content = encoding.GetString(data); 30 31 var bookId = -1; 32 if (!context.InParams.ContainsKey(Consts.BOOK_ID) 33 || !int.TryParse(context.InParams[Consts.BOOK_ID].ToString(), out bookId)) 34 return; 35 36 var book = new Book { Id = bookId }; 37 var book_info_match = regex_book_info.Match(content); 38 if (null != book_info_match && book_info_match.Success) 39 { 40 book.Name = book_info_match.Groups["name"].Value.Trim(); 41 book.Author = book_info_match.Groups["author"].Value.Trim(); 42 book.PhotoUrl = @"http://www.xqishuta.com" + book_info_match.Groups["photo"].Value; 43 var lastUpdateTime = DateTime.Now; 44 if (DateTime.TryParse(book_info_match.Groups["updateTime"].Value.Trim(), out lastUpdateTime)) 45 book.LastUpdateTime = lastUpdateTime; 46 } 47 48 var matches = regex_section_list.Matches(content); 49 if (null != matches && 0 < matches.Count) 50 { 51 book.Sections = new Dictionary<int, string>(); 52 var section_id = 0; 53 foreach (Match match in matches) 54 { 55 if (!int.TryParse(match.Groups["section_id"].Value, out section_id)) continue; 56 57 if (!book.Sections.ContainsKey(section_id)) 58 book.Sections.Add(section_id, null); 59 book.Sections[section_id] = match.Groups["section_name"].Value.Trim(); 60 61 var newContext = new JobContext 62 { 63 JobName = $"“奇書網-電子書-{section_id}章節內容”", 64 Uri = $"http://www.xqishuta.com/du/{book.Id / 1000}/{book.Id}/{section_id}.html", 65 Method = WebRequestMethods.Http.Get, 66 InParams = new Dictionary<string, object>(), 67 Analizer = new BookSectionAnalizer(), 68 }; 69 70 newContext.InParams.Add(Consts.LAST_UPDATE_TIME, context.InParams[Consts.LAST_UPDATE_TIME]); 71 newContext.InParams.Add(Consts.BOOK_ID, bookId); 72 newContext.InParams.Add(Consts.BOOK_SECTION_ID, section_id); 73 newContext.InParams.Add(Consts.BOOK, book); 74 75 leader.Queue.Enqueue(newContext); 76 } 77 } 78 } 79 } 80 }章節列表解析器 -- BookSectionsListAnalizer
1 namespace MikeWare.Crawlers.EBooks.Bizs 2 { 3 using MikeWare.Core.Components.CrawlerFramework; 4 using MikeWare.Crawlers.EBooks.Entities; 5 using System.Collections.Generic; 6 using System.IO; 7 using System.Text; 8 using System.Text.RegularExpressions; 9 10 public class BookSectionAnalizer : ACrawlerAnalizer 11 { 12 private static Encoding encoding = new UTF8Encoding(false); 13 private static Regex regex_section_content = new Regex(@"(?<=<div[^""]+""content1"">)(?<content>.+?)(?=(<p [^<]+</p>)?</div>)", RegexOptions.Singleline); 14 private static Regex regex_html_tag = new Regex(@"(<(\w+?)[^>]+>[^<>]+?</\2>)|(<(\w+?)[^/>]+/>)|&[^;]+;"); 15 16 public override void Analize(LeaderAnt leader, JobContext context) 17 { 18 if (null == context.InParams) return; 19 20 var content = encoding.GetString(context.Buffer); 21 var match = regex_section_content.Match(content); 22 23 if (null != match && match.Success) 24 { 25 if (!context.InParams.ContainsKey(Consts.BOOK) || null == context.InParams[Consts.BOOK]) 26 return; 27 28 var section_id = -1; 29 30 if (!context.InParams.ContainsKey(Consts.BOOK_SECTION_ID) 31 || !int.TryParse(context.InParams[Consts.BOOK_SECTION_ID].ToString(), out section_id)) 32 return; 33 34 content = regex_html_tag.Replace(match.Groups["content"].Value, string.Empty); 35 var builder = new StringBuilder(); 36 using (var reader = new StringReader(content)) 37 { 38 while (0 < reader.Peek()) 39 { 40 var line = reader.ReadLine().Trim(); 41 if (!string.IsNullOrEmpty(line)) builder.AppendLine(line); 42 } 43 } 44 45 var book = context.InParams[Consts.BOOK] as Book; 46 if (null == book.SectionContents) book.SectionContents = new Dictionary<int, string>(); 47 48 if (!book.SectionContents.ContainsKey(section_id)) book.SectionContents[section_id] = builder.ToString(); 49 builder.Clear(); 50 } 51 52 //Console.WriteLine(book.SectionContents[sectionId]); 53 //Console.WriteLine($"{book.Id} - {sectionId} Finished."); 54 } 55 } 56 }章節內容解析器 -- BookSectionAnalizer
解析器一方面的職責呢,就是解析下載下來的資料,另一方面呢,也根據解析結果,來拼湊出下一步任務,指定該任務的必要如參和對應的解析器,並丟到任務池中。這樣,解析器和下一步任務的執行就解開耦合。
在解析器中也提供了一個修改任務引數的機會,我們甚至可以對任務的引數進行任意的排列組合;
同時,在一個解析器中,也可以產生多個子任務;比如,我們在BooksListAnalizer中,一方面產生了採集書籍章節列表的任務,另一方面呢,又產生了採集翻頁的任務;
還有其他一些重構的零碎的小點,就不一一列出了。
這裡,在丟擲一個小問題,如下圖所示:
當我們執行幾十秒之後,觀察一下佇列,發現它很長,這是為什麼呢,怎麼應對呢?我們下節繼續,如何制定一些併發策略:)
喜歡本系列叢書的朋友,可以點選連結加入QQ交流群(994761602)【C# 破境之道】
方便各位在有疑問的時候可以及時給我個反饋。同時,也算是給各位志同道合的朋友提供一個交流的平臺。
需要原始碼的童鞋,也可以在群檔案中獲取最新原始碼。