问题的产生

   在之前写的爬虫里,有把文章从博客园迁移过来的功能,除了文字之外,还有图片链接。需要把一篇文章里的图片找出来,然后下载,然后重新命名,再把新的名称更新到文章里。这里就涉及到一个问题,图片下载。

    图片下载我这里用的是WebClient类:

 WebClient client = new WebClient();
 client.DownloadFile(imageUri, localFilePath);

     上面这个是同步方法,有两个参数,一个是图片url,一个是要存储的本地文件路径,包含文件名。很显然,不能用这个同步方法,因为它是阻塞的,下载一篇文章不可能一张图片一张图片的去下载。所以需要找到一个异步方法,同时要能提供图片是否下载成功相关信息。

    C#里面提供了一些BeginXX和EndXX的异步方法,在其中的BeginXX方法中,我们可以将一些信息放在object类型参数里面,在EndIXX方法里面,可以通过IAsyncResult对象的AsyncState转为先前定义的参数类型,就能获取到在BeginXX里面存入的一些信息,比如下面这个异步的读取文件的方法,在BeginRead方法的最后一个参数里,存储了一些我们在EndRead里面需要的一些信息。

class Program
    {
        //定义异步读取状态类
        class AsyncState
        {
            public FileStream FS { get; set; }
            public byte[] Buffer { get; set; }
            public ManualResetEvent EvtHandle { get; set; }
        }
        static  int bufferSize = 512;
        static void Main(string[] args)
        {
            string filePath = "d:\\test.txt";
            //以只读方式打开文件流
            using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
            {
                var buffer = new byte[bufferSize];
                //构造BeginRead需要传递的状态
                var asyncState = new AsyncState { FS = fileStream, Buffer = buffer ,EvtHandle = new ManualResetEvent(false)};
                //异步读取
                AsyncResult asyncResult = fileStream.BeginRead(buffer, 0, bufferSize, new AsyncCallback(AsyncReadCallback), asyncState);
                //阻塞当前线程直到读取完毕发出信号
                asyncState.EvtHandle.WaitOne();
                Console.WriteLine();
                Console.WriteLine("read complete");
                Console.Read();
            }
        }
        //异步读取回调处理方法
        public static void AsyncReadCallback(IAsyncResult asyncResult)
        {
            var asyncState = (AsyncState)asyncResult.AsyncState;
            int readCn = asyncState.FS.EndRead(asyncResult);
            //判断是否读到内容
            if (readCn > 0)
            {
                byte[] buffer;
                if (readCn == bufferSize) buffer = asyncState.Buffer;
                else
                {
                    buffer = new byte[readCn];
                    Array.Copy(asyncState.Buffer, 0, buffer, 0, readCn);
                }
                //输出读取内容值
                string readContent = Encoding.UTF8.GetString(buffer);
                 
                Console.Write(readContent);
            }
            if (readCn < bufferSize)
            {
                asyncState.EvtHandle.Set();
            }
            else {
                Array.Clear(asyncState.Buffer, 0, bufferSize);
                //再次执行异步读取操作
                asyncState.FS.BeginRead(asyncState.Buffer, 0, bufferSize, new AsyncCallback(AsyncReadCallback), asyncState);
            }
        }
    }

    但是WebClient的异步下载方法的签名是这样的。

public void DownloadFileAsync(Uri address, string fileName);

   下载完成后的回调签名是这样的。

 public delegate void DownloadDataCompletedEventHandler(object sender, DownloadDataCompletedEventArgs e);

     使用的时候,首先注册下载完成事件,然后调用DownloadFileAsync方法:

 WebClient wc = new WebClient();
 wc.DownloadFileCompleted += OnDownloadComplete;
 wc.DownloadFileAsync(new Uri(url), pathCombine);

    可以看到,在下载完成的方法中,没有任何关于下载方法里诸如 fileName 相关的信息,下载完成之后,不知道是哪个文件下载完成了。这些信息在完成事件里没办法获取到。

解决办法

   解决方法是,通过代理类的方式。首先,定义一个需要承载传递信息,以及包含异步回调方法签名的方法,如下:

public class MyDownloadFileProxy
{
      public string FileUrl;//待下载的图片的Url地址
      public long ImageId;//图片的Id
      public string FileFullPath;//图片要保存到本地的地址及名称
      public Action<string, long, string, AsyncCompletedEventArgs> DownloadCompleted;

      public void OnDownloadComplete(object sender, AsyncCompletedEventArgs e)
      {
           if (DownloadCompleted != null)
           {
                DownloadCompleted(FileUrl, ImageId, FileFullPath, e);
           }
     }
}

   在代理类里,定义一些需要传递的信息,我这里定义了待下载图片url,图片的id,图片保存到本地的地址及名称,以及一个下载完成事件,用来作为在图片下载完成时的回调,这个回调信息里边,就包含了上述我们定义的这些信息。另外还定义了一个方法,这个方法用来注册原始WebClient在文件下载完成之后触发的事件的回调,在这个方法内部,调用我们自己定义的包含了自定义信息的事件。

   现在,WebClient的用法从

 WebClient wc = new WebClient();
 wc.DownloadFileCompleted += OnDownloadComplete;
 wc.DownloadFileAsync(new Uri(url), pathCombine);

变成了:

MyDownloadFileProxy proxy = new MyDownloadFileProxy();
proxy.FileUrl = url;
proxy.ImageId = imageId;
proxy.FileFullPath = pathCombine;
proxy.DownloadCompleted += FileDownloadCompleted;

WebClient wc = new WebClient();
wc.DownloadFileCompleted += proxy.OnDownloadComplete;
wc.DownloadFileAsync(new Uri(url), pathCombine);

在FileDownloadCompleted回调里,我们就可以获取到之前定义的信息了:

private static void FileDownloadCompletedV2(string imageUrl, long imageId, string path, AsyncCompletedEventArgs arg2)
{
    bool isSuccess = false;
    string errorMsg = "";
    if (arg2.Error == null)
    {
        isSuccess = true;
        Console.WriteLine($"下载图片:{imageUrl} 完成");
        FileInfo fileInfo = new System.IO.FileInfo(path);
        if (fileInfo.Exists)
        {
            if (fileInfo.Length > 0)
            {
                //更新db
                isSuccess = true;
                Console.WriteLine($"下载图片:{imageUrl} 完成");
            }
            else
            {
                isSuccess = false;
                errorMsg = "大小为0,删除";
                fileInfo.Delete();
                Console.WriteLine($"下载图片:{imageUrl} 失败,大小为0");
            }
        }
    }
    else
    {
        errorMsg = arg2.Error.Message;
        if (!string.IsNullOrEmpty(errorMsg) && errorMsg.Length > 250)
        {
            errorMsg = errorMsg.Substring(0, 250);
        }
    }
    DBInterface.UpdateParagraphImageStatus(imageId, true, isSuccess, errorMsg);
}

可以看到,通过代理类,我们在下载成功回调中已经能够访问必须要的自定义信息了。在图片下载这个例子里,需要注意的时候,有时候图片下载下来,大小是0,比如可能是有防盗链,或是必须要用https才能下载等操作,需要特殊处理一下。