续上一节内容,对Web爬虫进行进一步封装,通过委托将爬虫自己的状态变化以及数据变化暴露给上层业务处理或应用程序。
为了方便以后的扩展,我先定义一个蚂蚁抽象类(Ant),并让WorkerAnt(工蚁)继承自它。
[Code 2.2.1]
1 using System; 2 3 public abstract class Ant 4 { 5 public UInt32 AntId { get; set; } 6 7 public Action<Ant, JobEventArgs> OnJobStatusChanged { get; set; } 8 9 protected virtual JobEventArgs NotifyStatusChanged(JobEventArgs args) 10 { 11 if (null != OnJobStatusChanged) 12 OnJobStatusChanged(args.EventAnt, args); 13 else 14 Console.WriteLine($"Worker { args.EventAnt.AntId } JobStatus: {args.Context.JobStatus}."); 15 16 return args; 17 } 18 }
蚂蚁类比较简单,定义了一个属性(AntId),作为每只小蚂蚁的编号;
定义了一个委托(OnJobStatusChanged),当任务状态发生变化时,用来发出状态变化通知;其中第二个参数JobEventArgs我们一会列出它的定义;
在有就是定义了一个虚方法NotifyStatusChanged,用来检查和触发委托事件;
[Code 2.2.2]
1 using System.ComponentModel; 2 3 public class JobEventArgs : CancelEventArgs 4 { 5 public Ant EventAnt { get; set; } 6 public JobContext Context { get; set; } 7 public String Message { get; set; } 8 }
委托参数类也比较简单,
- 定义了一个属性(EventAnt),指示事件的触发者,就是编程世界中很有名气的sender,通常是object类型,不过在我们的爬虫框架里,这个事件通常是有蚂蚁触发,所以我就暂定它的类型为蚂蚁了,先把坑占上,如果以后扩展需要外部触发的话,我们再升级;
- 另一个属性(Context)就是上节中使用的JobContext,内涵与Job相关的属性、描述信息;
- 还有一个属性Message,做简单的说明,比如失败的原因是什么;
[Code 2.2.3]
1 using System; 2 using System.Diagnostics; 3 using System.IO; 4 using System.Net; 5 using System.Security.Cryptography.X509Certificates; 6 using System.Threading.Tasks; 7 8 /// <summary> 9 /// 一个爬虫的最小任务单位,一只小工蚁。 10 /// </summary> 11 public class WorkerAnt : Ant 12 { 13 public void Work(JobContext context) 14 { 15 if (null == context) 16 { 17 context.JobStatus = TaskStatus.Faulted; 18 NotifyStatusChanged(new JobEventArgs 19 { 20 Context = context, 21 EventAnt = this, 22 Message = @"can not start a job with no context", 23 }); 24 return; 25 } 26 27 switch ((context.Method ?? string.Empty)) 28 { 29 case WebRequestMethods.Http.Connect: 30 case WebRequestMethods.Http.Get: 31 case WebRequestMethods.Http.Head: 32 case WebRequestMethods.Http.MkCol: 33 case WebRequestMethods.Http.Post: 34 case WebRequestMethods.Http.Put: 35 break; 36 default: 37 context.JobStatus = TaskStatus.Faulted; 38 NotifyStatusChanged(new JobEventArgs 39 { 40 Context = context, 41 EventAnt = this, 42 Message = $"can not start a job with request method <{(context.Method ?? "no method")}> is unsupported", 43 }); 44 return; 45 } 46 47 if (null == context.Uri || !Uri.IsWellFormedUriString(context.Uri, UriKind.RelativeOrAbsolute)) 48 { 49 context.JobStatus = TaskStatus.Faulted; 50 NotifyStatusChanged(new JobEventArgs 51 { 52 Context = context, 53 EventAnt = this, 54 Message = $"can not start a job with uri '{context.Uri}' is not well formed", 55 }); 56 return; 57 } 58 59 context.JobStatus = TaskStatus.Created; 60 if (NotifyStatusChanged(new JobEventArgs { Context = context, EventAnt = this, }).Cancel) 61 { 62 context.JobStatus = TaskStatus.Canceled; 63 NotifyStatusChanged(new JobEventArgs { Context = context, EventAnt = this, }); 64 return; 65 } 66 67 /* ........... 此处省略上万字 ......... */ 68 } 69 70 private void GetResponse(JobContext context) 71 { 72 context.Request.BeginGetResponse(new AsyncCallback(acGetResponse => 73 { 74 var contextGetResponse = acGetResponse.AsyncState as JobContext; 75 using (contextGetResponse.Response = contextGetResponse.Request.EndGetResponse(acGetResponse)) 76 using (contextGetResponse.ResponseStream = contextGetResponse.Response.GetResponseStream()) 77 using (contextGetResponse.Memory = new MemoryStream()) 78 { 79 var readCount = 0; 80 if (null == contextGetResponse.Buffer) contextGetResponse.Buffer = new byte[512]; 81 IAsyncResult ar = null; 82 do 83 { 84 if (0 < readCount) 85 { 86 contextGetResponse.Memory.Write(contextGetResponse.Buffer, 0, readCount); 87 contextGetResponse.JobStatus = TaskStatus.Running; 88 if (NotifyStatusChanged(new JobEventArgs { Context = contextGetResponse, EventAnt = this, }).Cancel) 89 { 90 contextGetResponse.JobStatus = TaskStatus.Canceled; 91 NotifyStatusChanged(new JobEventArgs { Context = contextGetResponse, EventAnt = this, }); 92 break; 93 } 94 } 95 ar = contextGetResponse.ResponseStream.BeginRead( 96 contextGetResponse.Buffer, 0, contextGetResponse.Buffer.Length, null, contextGetResponse); 97 } while (0 < (readCount = contextGetResponse.ResponseStream.EndRead(ar)) 98 && TaskStatus.Running == contextGetResponse.JobStatus); // 与EndRead的顺序不能颠倒 99 100 contextGetResponse.Request.Abort(); 101 contextGetResponse.Response.Close(); 102 contextGetResponse.Watch.Stop(); 103 104 if (TaskStatus.Running == contextGetResponse.JobStatus) 105 { 106 contextGetResponse.Buffer = contextGetResponse.Memory.ToArray(); 107 108 contextGetResponse.JobStatus = TaskStatus.RanToCompletion; 109 NotifyStatusChanged(new JobEventArgs { Context = context, EventAnt = this, }); 110 } 111 contextGetResponse.Buffer = null; 112 } 113 }), context); 114 } 115 }
工蚁类抹去了内部输出,采用状态变更通知方式向外界传递消息。
第15~57行,演示了如何处理参数异常,发出通知,并停止采集工作。
其中第27~45行,演示了如何验证一个Request Method是否有效,注意,Method需要全部大写,所以,验证方法是区分大小写的;
其中第47~57行,演示了如何验证一个Uri是否是合法的格式;
第60~65行以及82~98,演示了如何处理业务逻辑返回的'Cancel'指令,并停止采集工作;
其中第87~93行,演示了在数据下载过程中,发出状态通知,业务逻辑层或应用层可以借此机会对部分数据进行编码或更新进度条;如果下载的数据是压缩数据,也可以在此时进行解压缩工作;也可以对数据进行文件写入操作;这也将导致在业务层或应用层将收到不止一次JobStatus = TaskStatus.Runing的消息;
第104~110行,演示了如何发出的任务完成通知;
[Code 2.2.4]
1 Console.WriteLine("/* ************** 第二境 * 第二节 * 以事件驱动状态、数据处理 ************** */"); 2 3 var requestDataBuilder = new StringBuilder(); 4 requestDataBuilder.AppendLine("using System;"); 5 requestDataBuilder.AppendLine("namespace HelloWorldApplication"); 6 requestDataBuilder.AppendLine("{"); 7 requestDataBuilder.AppendLine(" class HelloWorld"); 8 requestDataBuilder.AppendLine(" {"); 9 requestDataBuilder.AppendLine(" static void Main(string[] args)"); 10 requestDataBuilder.AppendLine(" {"); 11 requestDataBuilder.AppendLine(" Console.WriteLine("《C# 爬虫 破境之道》");"); 12 requestDataBuilder.AppendLine(" }"); 13 requestDataBuilder.AppendLine(" }"); 14 requestDataBuilder.AppendLine("}"); 15 16 var requestData = Encoding.UTF8.GetBytes( 17 @"code=" + System.Web.HttpUtility.UrlEncode(requestDataBuilder.ToString()) 18 + @"&token=4381fe197827ec87cbac9552f14ec62a&language=10&fileext=cs"); 19 20 for (int i = 0; i < 10; i++) 21 { 22 new WorkerAnt() 23 { 24 AntId = (uint)Math.Abs(DateTime.Now.ToString("yyyyMMddHHmmssfff").GetHashCode()), 25 OnJobStatusChanged = (sender, args) => 26 { 27 Console.WriteLine($"{args.EventAnt.AntId} said: {args.Context.JobName} entered status '{args.Context.JobStatus}'."); 28 switch (args.Context.JobStatus) 29 { 30 case TaskStatus.Created: 31 if (string.IsNullOrEmpty(args.Context.JobName)) 32 { 33 Console.WriteLine($"Can not execute a job with no name."); 34 args.Cancel = true; 35 } 36 else 37 Console.WriteLine($"{args.EventAnt.AntId} said: job {args.Context.JobName} created."); 38 break; 39 case TaskStatus.Running: 40 if (null != args.Context.Memory) 41 Console.WriteLine($"{args.EventAnt.AntId} said: {args.Context.JobName} already downloaded {args.Context.Memory.Length} bytes."); 42 break; 43 case TaskStatus.RanToCompletion: 44 if (null != args.Context.Buffer && 0 < args.Context.Buffer.Length) 45 { 46 Task.Factory.StartNew(oBuffer => 47 { 48 var content = new UTF8Encoding(false).GetString((byte[])oBuffer); 49 Console.WriteLine(content.Length > 100 ? content.Substring(0, 90) + "..." : content); 50 }, new MemoryStream(args.Context.Buffer).ToArray(), TaskCreationOptions.LongRunning); 51 } 52 if (null != args.Context.Watch) 53 Console.WriteLine("/* ********************** using {0}ms / request ******************** */" 54 + Environment.NewLine + Environment.NewLine, (args.Context.Watch.Elapsed.TotalMilliseconds / 100).ToString("000.00")); 55 break; 56 case TaskStatus.Faulted: 57 Console.WriteLine($"{args.EventAnt.AntId} said: job {args.Context.JobName} faulted because {args.Message}."); 58 break; 59 case TaskStatus.WaitingToRun: 60 case TaskStatus.WaitingForChildrenToComplete: 61 case TaskStatus.Canceled: 62 case TaskStatus.WaitingForActivation: 63 default: 64 /* Do nothing on this even. */ 65 break; 66 } 67 }, 68 }.Work(new JobContext 69 { 70 JobName = "“以事件驱动状态、数据处理”", 71 Uri = @"https://tool.runoob.com/compile.php", 72 ContentType = @"application/x-www-form-urlencoded; charset=UTF-8", 73 Method = WebRequestMethods.Http.Post, 74 Buffer = requestData, 75 }); 76 }
对应用层的改造,主要体现在第25~67行,增加了对OnJobStatusChanged事件的处理。
其中,第30~38行,演示了如何在应用层或业务逻辑层,取消采集任务;
其中,第39~42行,演示了如何获取当前任务的当前已下载总量,并且可以通过context.Buffer获取当前下载的增量;如果context.Response.ContentLength不为-1的话,还可以计算出已下载量的占比;不过这里要小心的另一个陷阱就是HTTP 1.1 提供的Transfer-Encoding: Chunked;如果后面能碰到具体的场景,再举栗说明,这里先点破,不说破吧:)
其中,第43~55行,演示了如何获取下载的完整数据,注意,此时的context.Buffer是context.Memory中的所有数据,而不是当前下载的增量了。本节中所说的context.Memory是指当前Job累计下载的所有数据,为什么要加一个条件“本节所说的”呢,因为MemoryStream并不是无限大的,它也有极限,如果我们用它来处理一个Html文档或一张普通小照片还好,如果我们用它来处理一个很大的资源(比如一部蓝光电影或一个巨大的压缩包文件),将会发生异常,在那种情况下,我们就要考虑去使用文件内存映射(MemoryMappedFile)或其他技术了,暂且不在本节讨论。
至此,一个简单的事件处理机制就算是改造完成了。毕竟Web资源采集很重要,后面还会继续改造升级~敬请期待~
喜欢本系列丛书的朋友,可以点击链接加入QQ交流群(994761602)【C# 破境之道】
方便各位在有疑问的时候可以及时给我个反馈。同时,也算是给各位志同道合的朋友提供一个交流的平台。
需要源码的童鞋,也可以在群文件中获取最新源代码。