0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

以前作ったスクレイピングしてメールを送るバッチ処理アプリをリファクタリングしてみた。

0
Last updated at Posted at 2021-09-11

はじめに

C#の勉強として以前作ったバッチ処理のコードをリファクタリングしてみました。

以前のコードの問題点

色々ありました・・・

  • 一つのメソッドに処理をダーッと書いている
  • 資格情報がソースコード上にべた書き
  • ログが残らない

これくらいのコード量ならまあ良いかな?とも思ったりするのですが、今回は勉強としてちゃんとフォルダを分けたり、各クラスへ処理を分けてみたりしました。

ソースコード

全体のソースコードは以下です。

一部仕様を変更した

今回のソースコードなのですが、一部仕様を変更しました。
前回書いた処理では1日前に投稿されたトピックのみをメールで送る処理にしていたのですが、スクレイピング先のサイトのバグなのか投稿日付が一部全然違う日付になっていたりしていたので、CSVファイルに1度送ったトピックを保存し、そのCSVファイルにないトピックをメールで送るという内容にしました。
CSVファイルにした理由はデータベースを使うほどの規模ではないかなと思ったからです。

フォルダ構成

元のフォルダ構成
ScrapingApp
├─Program.cs
現在のフォルダ構成
ScrapingApp
├─Config
│ ├── MailSettingsConfig.cs
│ ├── ScrapingTargetsConfig.cs
│ └── StorageConfig.cs
├─Dtos
│ ├── Mail.cs
│ └── Topic.cs
├─Models
│ ├── MailModel.cs
│ └── SystemDate.cs
├─Repositories
│ ├── GetTopicRepository.cs
│ └── ResolveCsvRepository.cs
├─Services
│ └── ResolveTopicsService.cs
├─Storages
│ └── CSV
│    └── Topic.csv
├─appsettings.json
├─Program.cs
├─ScrapingApp.cs
├─Startup.cs

各フォルダについては以下を意識しました。

フォルダ名 意識した点
Config appsettings.jsonの各セクションのマッピング
Dtos 利用するデータのオブジェクトたち。
※Entityにしようか迷ったのですがデータベース間との通信ではないかなと思ったのでこちらの名前にしてみました
Models RepositoriesにもServicesにも当てはまらなさそうなもの
Repositories 通信が発生するスクレイピングおよびCSVファイル(今回のデータベース替わり)とのやり取り
Services Repositoriesからもらった情報の加工

ソースコードのBeforeとAfter

スクレイピングで対象データの取得まで

Before

Program.cs
var urlstring = "スクレイピングしたいサイトのURL";
using var stream = await _client.GetStreamAsync(new Uri(urlstring));
var parser = new HtmlParser();
// 指定したサイトのHTMLをストリームで取得する
IHtmlDocument doc = await parser.ParseDocumentAsync(stream);

// 以下から★部分までは僕の取得したかった情報を取得するためにセットしている
// だけなのでここら辺は取得したい内容によって変わるかと思います。
var tdElements = doc.GetElementById("Id名").QuerySelectorAll("セレクタ");

After

Afterではインターフェースを定義してそちらを継承して処理を作りました。
(Unitテストのコードは作っていないですが)これで単体テスト時もMockを使ってテストコードを作ることができます。
またASP.NET CoreアプリケーションにてHttpClientクラスを利用する際はHttpClientFactoryクラスの利用が推奨されているため、そちらを利用しています。

GetTopicRepository.cs
public interface IGetTopicRepository
{
    /// <summary>
    /// スクレイピングで対象のトピックを取得します。
    /// </summary>
    /// <returns></returns>
    ValueTask<IHtmlCollection<IElement>> GetTopicDataAsync();
}
public class GetTopicRepository : IGetTopicRepository
{
    private readonly IHttpClientFactory _clientFactory;
    private readonly ScrapingTargetsConfig _scrapingTargetsConfig;
    public GetTopicRepository(IHttpClientFactory clientFactory, IOptions<ScrapingTargetsConfig> scrapingTargetsConfig)
    {
        _clientFactory = clientFactory;
        _scrapingTargetsConfig = scrapingTargetsConfig.Value;
    }

    public async ValueTask<IHtmlCollection<IElement>> GetTopicDataAsync()
    {
        using var stream = await _clientFactory.CreateClient().GetStreamAsync(new Uri(_scrapingTargetsConfig.Url));
        IHtmlDocument doc = await new HtmlParser().ParseDocumentAsync(stream);
        return doc.GetElementById("Id名").QuerySelectorAll("セレクタ");
    }
}

取得したデータの加工

Before

Program.cs
var yearStr = DateTime.Now.Year.ToString();
// メールのBodyに入れる内容をStringBuilderへ入れていきます。
StringBuilder sb = new();
foreach (var tr in tdElements)
{
    // 今回のサイトでは年が表示されていなかったので追加しています。
    var dttmText = $"{yearStr}/{tr.QuerySelector("セレクタ").TextContent}";
    // 1日前からアプリ実行までの期間内に更新された内容があればメールの本文に追加します。 
    // 今回はテキストとURL(href)をメールで送ります。
    if (DateTime.Now.AddDays(-1).CompareTo(DateTime.Parse(dttmText)) > 0)
        break;
    sb.AppendLine($"{tr.QuerySelector("セレクタ").TextContent} 投稿日付:{dttmText}");
    sb.AppendLine(tr.QuerySelector("セレクタ").GetAttribute("href"));
}

After

一部仕様を変更したのでロジックが異なる箇所がありますが・・・

こちらも各メソッドの役割分担をしっかり分けるように意識しました。
Beforeでは直でDateTime.Nowと書いているところもAfterではテスト時にMockが作成できるようにインターフェースを挟んでいます。

ResolveTopicsService.cs
public interface IResolveTopicsService
{
    ValueTask<IEnumerable<Topic>> GetSendTargets();

    IEnumerable<Topic> FormatRawTopics(IHtmlCollection<IElement> elements);
}

public class ResolveTopicsService : IResolveTopicsService
{
    private readonly IResolveCsvRepository _resolveCsvRepository;
    private readonly IGetTopicRepository _getTopicRepository;
    private readonly ISystemDate _systemDate;

    public ResolveTopicsService(
        IResolveCsvRepository resolveCsvRepository,
        IGetTopicRepository getTopicRepository,
        ISystemDate systemDate)
    {
        _resolveCsvRepository = resolveCsvRepository;
        _getTopicRepository = getTopicRepository;
        _systemDate = systemDate;
    }

    public IEnumerable<Topic> FormatRawTopics(IHtmlCollection<IElement> elements)
    {
        var topicList = new List<Topic>();
        var yearStr = _systemDate.GetSystemDate().Year.ToString();
        foreach (var element in elements)
        {
            topicList.Add(new Topic
            {
                Title = $"{element.QuerySelector("セレクタ").TextContent}",
                CreatedTime = DateTime.Parse($"{yearStr}/{element.QuerySelector("セレクタ").TextContent}"),
                Url = element.QuerySelector("セレクタ").GetAttribute("href")
            });
        }
        return topicList;
    }

    public async ValueTask<IEnumerable<Topic>> GetSendTargets()
    {
        var targets = await _getTopicRepository.GetTopicDataAsync();
        return FormatRawTopics(targets).FilterTopicsWithCsv(_resolveCsvRepository.GetTopics());
    }
}

public static class ResolveTopicsExtensions
{
    public static IEnumerable<Topic> FilterTopicsWithCsv(
        this IEnumerable<Topic> topics,
        IEnumerable<Topic> targets)
    {
        return topics.Except(targets);
    }
}

メール送信まで

Before

Program.cs
// ここからはMailKitというライブラリを用いてメールを送る処理を行っています。
var message = new MimeKit.MimeMessage();
message.From.Add(new MimeKit.MailboxAddress("メール送信元の名前", "メール送信元のアドレス"));
message.To.Add(new MimeKit.MailboxAddress("メール送信先の名前", "メール送信先のアドレス"));
message.Subject = "〇〇通知を送ります~~";
var textPart = new MimeKit.TextPart(MimeKit.Text.TextFormat.Plain)
{
    Text = string.IsNullOrEmpty(sb.ToString()) 
        ? "新規〇〇探しにいったけどなかった(;´∀`)\n無駄にメール送ってごめんねm(__)m"
        : sb.ToString()
};
message.Body = textPart;

using var client = new MailKit.Net.Smtp.SmtpClient();
try
{
    await client.ConnectAsync("smtp.gmail.com", 587);
    await client.AuthenticateAsync("Gmailのメールアドレス", "Gmailのアカウントパスワード");
    await client.SendAsync(message);
    await client.DisconnectAsync(true);
}
catch (Exception ex)
{
    Console.WriteLine(ex.ToString());
}

After

こちらも各メソッドの役割分担をしっかり分けるように意識しました。
あとはBeforeではソースコード上にべた書きしていた情報を外部ファイルから呼び出すように変更したりエラー時のログを残すようにしたり等修正しました。

MailModel.cs
public interface IMailModel
{
    /// <summary>
    /// メールを送信します。
    /// </summary>
    /// <param name="mimeMessage"></param>
    /// <returns></returns>
    ValueTask SendMail(MimeMessage mimeMessage);

    /// <summary>
    /// メール送信に必要な情報を作成します。
    /// </summary>
    /// <param name="mail"></param>
    /// <returns></returns>
    MimeMessage CreateMailContext(Mail mail);

    /// <summary>
    /// メールの本文を作成します。
    /// </summary>
    /// <param name="htmlCollection"></param>
    /// <returns></returns>
    string CreateMailBody(IEnumerable<Topic> topics);
}

public sealed class MailModel : IMailModel
{
    private readonly MailSettingsConfig _mailSettingsConfig;
    private readonly ILogger<IMailModel> _logger;

    public MailModel(
        IOptions<MailSettingsConfig> mailSettingsConfig,
        ILogger<IMailModel> logger)
    {
        _mailSettingsConfig = mailSettingsConfig.Value;
        _logger = logger;
    }

    public string CreateMailBody(IEnumerable<Topic> topics)
    {
        if (!topics.Any()) return _mailSettingsConfig.DefaultResponse;

        StringBuilder sb = new();
        foreach(var topic in topics)
        {
            sb.AppendLine($"{topic.Title} 投稿日付: {topic.CreatedTime}");
            sb.AppendLine($"{topic.Url}");
            sb.AppendLine();
        }

        return sb.ToString();
    }

    public MimeMessage CreateMailContext(Mail mail)
    {
        var context = new MimeMessage();
        context.From.Add(new MailboxAddress(_mailSettingsConfig.FromName, _mailSettingsConfig.FromAddress));
        context.To.Add(new MailboxAddress(_mailSettingsConfig.ToName, mail.To));
        context.Bcc.Add(new MailboxAddress(_mailSettingsConfig.BccName, mail.Bcc));
        context.Subject = mail.Subject;
        context.Body = new TextPart(MimeKit.Text.TextFormat.Plain){ Text = mail.Body.ToString() };
        return context;
    }

    public async ValueTask SendMail(MimeMessage mailContext)
    {
        using var client = new MailKit.Net.Smtp.SmtpClient();
        try
        {
            _logger.LogInformation("メールを送信します。");
            await client.ConnectAsync(_mailSettingsConfig.HostName, _mailSettingsConfig.Port);
            await client.AuthenticateAsync(_mailSettingsConfig.UserName, _mailSettingsConfig.Password);
            await client.SendAsync(mailContext);
            await client.DisconnectAsync(true);
        }
        catch (Exception ex)
        {
            _logger.LogError("メールの送信に失敗しました");
            _logger.LogError(ex.Message);
            _logger.LogError(ex.StackTrace);
        }
    }
}

おわりに

以前、設計系の勉強会に参加した際に「良いコードは"Tell, Don't Ask"が出来ている」という内容をお聞きしました。
"Tell, Don't Ask"は「オブジェクトに対してどのように処理をするのかを聞くのではなく、必要な情報を渡して呼んだら情報が返ってくる」という感じの内容ですが少しは取り入れられたかな?と思っていたりします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?