PR TIMESにおけるメール送信機能をリファクタリングしました

こんにちは、開発本部のソンです。最近、PR TIMESのPHPバージョンアッププロジェクトに参加していて、PR TIMESにおけるメール送信機能のリファクタリングを行いました。これを通して、クラス設計やテストしやすいコードの書き方など様々な技術を身に付けました。

この記事では、なぜメール送信機能のリファクタリングを行ったかと、それを実装した話について書きたいと思います。

目次

なぜメール送信機能のリファクタリングを行ったのか?

PR TIMESのPHPバージョンアップを行うにあたって、レガシーコード上の多くの問題に直面しています。 メール送信機能はその1つです。この機能は様々な問題を抱えていましたが、次のようにまとめられます。

送信する方法が沢山あった

メール送信機能はPEARのMailライブラリPHPMailermb_send_mail など沢山の送信する方法を使っていました。そのため、メール送信機能に関わるタスクを実装する時に、どの既存コードに倣うか悩んでしまうことがありました。

上記の送信する方法の中で、PEARのMailライブラリが使われているコードが一番多かったです。でも、PHP7.4以降、このライブラリがもう使えなくなりました。それで、別のメールライブラリでリプレイスしないと、今後PHPバージョンアップが進められません。これが、メール送信機能をリファクタリングしなければならない主な理由と言っても過言ではありません。

テストコードが書けなかった

メール送信機能のコードにテストコード(Unit Test等)を書くことはできませんでした。テストツールを使ってマニュアルでメール送信をテストする方法しかなくて、QAのコストをすごく費やしていました。

その上、テストコードを書けないことは、コードの品質が低い、バグを早期に見つけて修正するのが難しい、機能の改修・追加は地獄になるなどの問題を引き起こしていました。

メールテンプレート機能の問題

メールテンプレート機能にはテンプレートエンジンが使われていませんでした。そのため、メールの内容をヘッダー、フッターなど共有の部分を分割することができませんでした。これにより、コードの再利用ができず、またコードも長すぎて可読性が落ちていました(特にHTMLコード)。

また、このメールテンプレート機能のロジックコードが古すぎました。そして、コードもとても理解しにくく、新機能や改修を加えることも地獄でした。

問題をどのように解決したのか?

PEARのMailライブラリ を別のライブラリでリプレイスすることで、またPHPバージョンアップが進められます。

しかし、テストコードが書けない問題とメールテンプレートの問題が続いたら、例えPHPバージョンアップができるとしても、将来、メール送信機能のコードのメンテナスや機能改善・追加がすごく大変になります。PHPのバージョンアップは、単なるPHPバージョンをあげることではなく、ソースコードの品質を向上することもすごく重要です。

PrTimesMailerという社内のメールライブラリを作成する

そこで、上記の問題を全て解決するために、メール機能を完全に再構築することになりました。 この再構築では、PrTimesMailerと呼ばれるメール機能用の統一される社内のメールライブラリを作成することになりました。

PrTimesMailerは、次の要件に基づいて作成られました。

  • PR TIMESが使っているPHPバージョンでも最新のPHPバージョンでも動く
  • 送信する方法を統一する
  • テストコードが書きやすい
  • テンプレートエンジンに基づいて作成されたテンプレート機能を持つ

PrTimesMailerの開発工程について書くとかなり長くなるので、次のセクションで詳しく書きたいと思います。

PrTimesMailerでコードのリプレイスを行う

PrTimesMailerを作成した後、コードのリプレイスを行い、PEARのMailライブラリ、PHPMailer、mb_send_mailを使っているものをPrTimesMailerで切り替えることになりました。

リプレイスはコード箇所を1個ずつやるという方針で進められました。なぜなら、一回で全部リプレイスすると、

  • プルリクエストが非常に大きくなりレビューが大変
  • QA確認がすごく大変
  • 万一エラーがあれば、どこでエラーが発生したのか把握しにくい

などの問題があるからです。

リプレイスをスムーズに行うために、メール送信のコードを調査し、グループに分けて、どんどん実装しました。こうすることでリリースの粒度を小さくできました。

また、PrTimesMailerでコードをリプレイスするとともに、JISの文字コードをUTF-8の文字コードにリプレイスなど不要な処理もリファクタリングしました。

今回の1個ずつリプレイスする作業のおかげで、PR TIMESのソースコードを読む機会があり、既存の機能について詳しく理解できました。

PrTimesMailerの開発工程

開発初期

PrTimesMailerの開発に着手する時に、チームとデイリーを行って、課題を解決する方法について議論しました。

送信する方法を統一する

まず、解決しないといけないのは、メールの送信する方法を統一することです。

将来送信する方法を変更しても機能全体に影響がないように、PrTimesMailerはライブラリのラッパークラスを実装する方針に基づいて開発を進めることになりした。そのため、適切なPHPのメールライブラリを選定する必要がありました。今回、PHPMailerを選定することになりました。理由は以下の通りでした。

  • 社内で既に使われている
  • PR TIMESが使っているPHPバージョンでも最新のPHPバージョンでもサポートする
  • 有名なPHPのメールライブラリであり、安定しており、評価が高い

導入するテンプレートエンジンを選択する

次に、PrTimesMailerのメールテンプレート機能はゼロから開発するため、軽量であり、シンプルであり、適切なテンプレートエンジンを選定することが必要です。相談した後、Twigという高速であり、安全で柔軟なPHP用テンプレートエンジンを選定することになりました。

クラス設計とコードの実装

テストコードが書けない問題を解決するために、PrTimesMailerはテストを書きやすい設計にしました。そのため、ロガーやPHPMailerやTwigなど依存性の注入(Dependency Injection)が使えるようにクラス設計を進めました。更に、テストの時に、実際には送信を行わないMockのPrTimesMailerを使えるように、Interfaceを活用しました。

また、PrTimesMailerのクラス設計では、メール送信機能としてMailerと、送信されるメールのデータとしてMailableをお互いに独立する2つのパーツに分離しました。

Mailable

Mailableのクラス設計

Mailableは、電子メールを抽象化したクラスです。 このクラスには、from、to、subject、bodyなど電子メールのプロパティが含まれます。 getterメソッドとsetterメソッドでプロパティの値を取得や設定でき、createメソッドで新しいインスタンスを作成できます。

PR TIMESでは現在、テキストメールとHTMLメールの両方を使っているため、それぞれに応じてPrTimesTextMailとPrTimesHtmlMailという2つのクラスを作成しました。更に、他のクラスに依存性の注入ができるようにインターフェイスを活用してPrTimesMailableInterfaceも作成しました。

また、メールテンプレート機能用のTwigMailTemplateServiceクラスを作成しました。このクラスは、splitSubjectAndBodyメソッドでメール テンプレートをレンダリングし、件名と本文に分割し、それらを使用してcreateTextMailとcreateHtmlMailメソッドで PrTimesTextMailとPrTimesHtmlMailのインスタンスを作成できます。

Mailer

Mailerのクラス設計

MailerはMailableの送信を実行する役割を担い、PrTimesMailerとPrTimesMailerTransportという2つの主要部分に分かれています。

// PrTimesMailer
class PrTimesMailer
{
    ...

    public function __construct(PrtimesMailerTransportInterface $transport, LoggerInterface $logger)
    {
        $this->logger = $logger;
        $this->transport = $transport;
    }

    // PrTimesMailerTransportにMailableを送信する
    public function send(PrTimesMailableInterface $mailable)
    {
        return $this->transport->send($mailable);
    }

    ...
}

// PrTimesMailerTransport
class PrTimesMailerTransport implements PrTimesMailerTransportInterface
{
    ...

    public function __construct(PHPMailerFactory $phpmailer_factory)
    {
        $this->factory = $phpmailer_factory;
    }

    // PHPMailerを使って、メール送信を実行する
    public function send(PrTimesMailableInterface $mailable)
    {
        $phpmailer = $this->factory->createPHPMailer();
        $phpmailer->Subject = $mailable->getSubject();

        if ($mailable instanceof PrtimesHtmlMail) {
            $phpmailer->isHTML(true);
            $phpmailer->Body = $mailable->getHtmlBody();
        } elseif ($mailable instanceof PrTimesTextMail) {
            $phpmailer->isHTML(false);
            $phpmailer->Body = $mailable->getPlainTextBody();
        }

        $from = $mailable->getFrom();
        $phpmailer->setFrom($from->email, $from->display_name);

        $to_list = $mailable->getToList();
        foreach ($to_list as $recipient) {
            $phpmailer->addAddress($recipient->email, $recipient->display_name);
        }

        ...

        if ($phpmailer->send() === false) {
            throw new PrTimesMailerTransportException("Mail library error: " . $phpmailer->ErrorInfo));
        }
    }

    ...
}

PrTimesMailerはMailableを受け取り、それをPrTimesMailerTransportに送信します。 PrTimesMailerTransportはPHPMailerを使って、sendメソッドでメール送信を実行します。

PHPMailerFactoryで、PHPMailerインスタンスまたはそのモックオブジェクトを作成し、それを PrTimesMailerTransportに渡すことができます。 これにより、このモックオブジェクトを使ってメール送信のテストコードを書くことができます。

さらに、メールトランスポート用PrTimesMailerTransportInterfaceも作成しました。 これにより、将来メールライブラリの変更がある場合、PrTimesMailerに影響を与えることなく、PrTimesMailerTransportクラスを変更するだけで済みます。

テストコードを書く

上記のクラス設計により、メール送信機能にテストコードを書けるようになりました。

例えば、これまでテストできない請求情報変更メールの作成にテストコードを書けました。

class BillingServiceTest extends PHPUnit_Framework_TestCase
{
    ...

    public function testCreateBillingInfoChangeMail()
    {
        ...

        $twig_template = "mail_template/billing_info_change.twig";
        $template_params = [
            "billing_company_name" => $billing_company_name,
            "billing_address" => $billing_address
            "change_date" => $change_date,
            ...
        ];
            
        $mailable = TwigMailTemplateService::createTextMail(
            Twig::getInstance(),
            $twig_template,
            $template_params,
            $to,
            $from
        );
        
        $this->assertInstanceOf(PrTimesTextMail::class, $mailable);
        $this->assertSame($expected_subject, $mailable->getSubject());
        $this->assertSame($expected_body, $mailable->getPlainTextBody());
    }
}

更に、メールを送信しなくても、送信内容がテストできるようになりましたので、とても嬉しいです。

メール送信をテストするために、PHPMailerのモックとしてFakePHPMailerクラスやPrTimesMailerTransportのモックとしてFakePrTimesMailerTransportクラスを作成しました。

そして、PrTimesMailerTransportとPrTimesMailerにテストコードを書きました。

PrTimesMailerTransportのテストコード

class PrTimesMailerTransportTest extends PHPUnit_Framework_TestCase
{
    ...

    public function testSendMailWithFakePHPMailer()
    {
        $phpmailer_factory = PHPMailerFactory::buildWithFakePHPMailer();
        ...
        $transport = new PrTimesMailerTransport($phpmailer_factory);
        $text_mail = PrTimesTextMail::create($to, $from, $subject, $body, ...);
        $html_mail = PrTimesHtmlMail::create($to, $from, $subject, $htm_body, ...);

        $this->assertTrue($transport->send($text_mail)); // テキストメールを送信する
        $this->assertTrue($transport->send($html_mail)); // HTMLメールを送信する
    }
}

PrTimesMailerのテストコード

class PrTimesMailerTest extends PHPUnit_Framework_TestCase
{
    ...

    public function testSuccessfullySendTextMail()
    {
        ...
        $mailable = PrTimesTextMail::create($to, $from, $subject, $body, ...);

        $transport = new FakePrTimesMailerTransport($this->logger);
        $prtimes_mailer = new PrTimesMailer($transport, $this->logger);

        $this->assertTrue($prtimes_mailer->send($mailable));
    }

    public function testSuccessfullySendHtmlMail()
    {
        ...
        $mailable = PrTimesHtmlMail::create($to, $from, $subject, $htm_body, ...);
        $transport = new FakePrTimesMailerTransport($this->logger);
        $prtimes_mailer = new PrTimesMailer($transport, $this->logger);

        $this->assertTrue($prtimes_mailer->send($mailable));
    }

    public function testSuccessfullySendTextMailCreatedByTwigTemplateService()
    {
        $twig = TwigService::getTwigInstance();
        $twig_template = "mail/test/twig_template_service_test.twig";
        $template_params = [
            "subject" => "This is test subject rendered by twig",
            "body" => "This is test body rendered by twig"
        ];
        $mailable = TwigMailTemplateService::createTextMail(...);
        $transport = new FakePrTimesMailerTransport($this->logger);
        $prtimes_mailer = new PrTimesMailer($transport, $this->logger);

        $this->assertTrue($prtimes_mailer->send($mailable));
    }
}

最後に

PrTimesMailerの開発とコードのリプレイスにより、PR TIMESのソースコード上の大きな問題の1つが解消しました。これは、PHPバージョンアップに極めて大きな一歩でした。

今回の改修は、設計も最初から作り直したことで、PR TIMESのソースコード改善になり、少し前まで全くテストがなかったメール機能がテストできるようになりました。

メール送信機能の再設計からソースコードのリプレイスの完了まで4ヶ月近くかかりました。 これを通して、社内のチームワークの重要性、最初の設計の重要性、テストを書くことの重要性など多くのことを身につけました。

今後は、身につけたことをPR TIMESの他の問題の解決に応用し、PHPのバージョンアップをよりスムーズに進められて、PR TIMESをより良いサービスにしていけるように頑張ります。

この記事を書いた人

株式会社PR TIMES 開発本部 バックエンドエンジニア

目次
閉じる