I've created a small proof of concept web crawler to learn more about asynchrony in .NET.
Currently when run it crawls stack overflow with a fixed number of current requests (workers).
I was interested if I have made any common mistakes and if there is more of the framework / TPL I could be using to reduce the amount of code used.
To run it you would need to install the HtmlAgilityPack.
I am only really interested in feedback on program structure and the asynchronous aspects of it.
class Program
{
static void Main(string[] args)
{
var crawler = new WebCrawler();
crawler.Start();
}
public class WebCrawler
{
private readonly HttpClient client;
private const string SiteUrl = "http://stackoverflow.com";
private CrawlList crawlList;
private const int MaxWorkers =5;
private int workers;
public WebCrawler()
{
client = new HttpClient();
crawlList = new CrawlList();
}
public void Start()
{
crawlList = new CrawlList();
crawlList.AddUrl(SiteUrl);
do
{
if (workers >= MaxWorkers) continue;
if (!crawlList.HasNext()) continue;
Interlocked.Increment(ref workers);
Debug.Write("Workers " + workers);
ProcessUrl(crawlList.GetNext());
} while (crawlList.HasNext() || workers > 0);
}
private async void ProcessUrl(string url)
{
Debug.Print("Processing " + url);
await client.GetAsync(url).ContinueWith(ProcessResponse);
}
private async void ProcessResponse(Task<HttpResponseMessage> response)
{
Debug.Print("Processing response ");
var html = await response.Result.Content.ReadAsStringAsync();
var doc = new HtmlDocument();
doc.LoadHtml(html);
var internalLinks = doc.DocumentNode.SelectNodes("//a[@href]").Where(x => x.Attributes["href"].Value.StartsWith("/")).Select(x => SiteUrl + x.Attributes["href"].Value).ToList();
internalLinks.ForEach(x => crawlList.AddUrl(x));
Interlocked.Decrement(ref workers);
}
}
public class CrawlList
{
private readonly ConcurrentBag<string> urlsToCrawl;
private readonly ConcurrentBag<string> urlsCompleted;
public CrawlList()
{
urlsToCrawl = new ConcurrentBag<string>();
urlsCompleted = new ConcurrentBag<string>();
}
public bool HasNext()
{
return urlsToCrawl.Any();
}
public string GetNext()
{
string url;
urlsToCrawl.TryTake(out url);
urlsCompleted.Add(url);
return url;
}
public void AddUrl(string url)
{
if (!UrlAlreadyAdded(url))
{
urlsToCrawl.Add(url);
Debug.Print("Adding Url " + url);
}
}
public bool UrlAlreadyAdded(string url)
{
return urlsToCrawl.Contains(url) || urlsCompleted.Contains(url);
}
}
}
After runnning through all the suggestions I have a new implementation here below. I think this simple POC covers most of the issues people found with my code previously. Be great to hear back from all the contributers to what they think about it now?
Really liking the Task.WhenAny approach. Think my work is thread safe but that is my only concern.
class Program
{
static void Main(string[] args)
{
var crawler = new Crawler(5);
try
{
crawler.Crawl("http://cask-marque.co.uk/").Wait();
}
catch (AggregateException e)
{
foreach (var ex in e.InnerExceptions)
{
Console.WriteLine(ex.InnerException);
}
Console.ReadLine();
}
}
public class Crawler
{
public readonly CrawlList CrawlList;
private readonly List<Task<string>> runningTasks = new List<Task<string>>();
private readonly HttpClient client;
private readonly int maxConcurrentDownload;
private const string BaseUrl = "http://cask-marque.co.uk";
public Crawler(int maxConcurrentDownload)
{
CrawlList = new CrawlList();
client = new HttpClient();
this.maxConcurrentDownload = maxConcurrentDownload;
ServicePointManager.DefaultConnectionLimit = maxConcurrentDownload;
}
public async Task<bool> Crawl(string startUrl)
{
runningTasks.Add(ProcessUrl(startUrl));
while (runningTasks.Any())
{
var completedTask = await Task.WhenAny(runningTasks);
runningTasks.Remove(completedTask);
var pageHtml = await completedTask;
while (CrawlList.HasNext() && runningTasks.Count < maxConcurrentDownload)
{
var url = CrawlList.GetNext();
runningTasks.Add(ProcessUrl(url));
}
}
return true;
}
private async Task<string> ProcessUrl(string url)
{
Console.WriteLine("url " + url);
var response = await client.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
var doc = new HtmlDocument();
doc.LoadHtml(content);
var urls = doc.DocumentNode.SelectNodes("//a[@href]").Where(x => x.Attributes["href"].Value.StartsWith("/")).Select(x => BaseUrl + x.Attributes["href"].Value).ToList();
CrawlList.AddUrls(urls);
return content;
}
}
public class CrawlList
{
public readonly Queue<string> UrlsToCrawl;
public readonly List<string> UrlsCompleted;
public CrawlList()
{
UrlsToCrawl = new Queue<string>();
UrlsCompleted = new List<string>();
}
public bool HasNext()
{
return UrlsToCrawl.Any();
}
public string GetNext()
{
return UrlsToCrawl.Dequeue();
}
public void AddUrls(List<string> urls)
{
foreach (var url in urls)
{
AddUrl(url);
}
}
public void AddUrl(string url)
{
if (UrlAlreadyAdded(url)) return;
UrlsToCrawl.Enqueue(url);
}
public bool UrlAlreadyAdded(string url)
{
return UrlsToCrawl.Contains(url) || UrlsCompleted.Contains(url);
}
}
}
}
async
(not at all) but I have the feeling you're doing something wrong. I see everywhere thatasync void
is not a good idea. You probably want to implement a solution similar to the one you can find in this Stack Overflow Q&A \$\endgroup\$workers
,MaxWorkers
, and the async code, it looks like you may want to take a look at ThreadPool (older .NET) or TaskFactory while setting an input TaskScheduler to limit the max concurrency. \$\endgroup\$await
. \$\endgroup\$