2009年10月30日金曜日

TaskQueueを使ってメールを送信する

GAEにはMail送信サービスがあり、これを使ってEメールを送信することができます。
しかし、メール送信は必ず成功するとは限らないので、通常はメールを送信する際、送信の成功/失敗を確認し、失敗時には再送信するよう処理すると思います。
GAEでもそういう処理を書くことはできますが、「レスポンスを30秒以内に返さなければならない」という制約があるため、ひとつの処理の中で何度も繰り返すかもしれない処理を行うと、途中でタイムアウトとなり強制的に処理が中断されてしまう可能性があります。

そこで、より確実にメール送信を行うことができるよう、同じくGAEで使えるTaskQueueサービスを使いバックグラウンドでメール送信をする仕組みを作りました。
例えばあるフォームからPOSTされたときにメールを送信しようとした場合、
1. ServletでPOSTを受け取り、送信するメールの情報を作成
2. TaskQueueにそのメール情報の登録だけ行いレスポンスを返し、実際にはメール送信はしない
3. 別のServletでTaskを受け取り、その情報を元にメールを送信する
- 送信が成功すればそのTaskは削除される
- 送信失敗した場合は200以外のステータスを返せば、Taskは削除されずに自動的にリトライし続ける
という処理の流れになります。

ただし、一度TaskQueueに処理を任せてしまうと、そこに登録した情報を確認することが難しくなってしまいます。何故かTaskが実行されない、という問題が起こった場合にデバッグ作業が難しくなってしまいます。上記の場合だと送信が失敗し続けるメールがどのような内容のメールなのか、というのが判別できなくなります。

ということで今回は送信するメールの情報をDatastoreのエンティティとして保存し、そのkeyだけをTaskに渡して処理させるようにしました。こうすることで送信すべきメールの情報を管理コンソールのDataViewerから確認できるようになります。

コードの例としては、以下のようになっています。
/**
* @param inputStream templateを読み込むためのInputStream
* @param context templateに当てはめるpropertyを保持するobject
* @param url TaskQueueのtaskがアクセスするURL
* @param encoding templateのエンコーディング
* @throws IOException
*/
public void spool(InputStream inputStream, Object context,
        String url, String encoding) throws IOException {
    MailTemplateEngine templateEngine = new MailTemplateEngine();
    MailMessage mailMessage = 
            templateEngine.merge(inputStream, context, encoding);

    // MailTask entityに保存
    Map<headerkind, string> headers = mailMessage.getHeaders();
    String from    = headers.get(HeaderKind.FROM);
    String to      = headers.get(HeaderKind.TO);
    String subject = headers.get(HeaderKind.SUBJECT);
    String body    = mailMessage.getBodyText();
    MailTask mailTask = new MailTask(from, to, subject, body);
    Key key = Datastore.put(mailTask);

    // TaskQueueへの登録
    String keyString = KeyFactory.keyToString(key);
    TaskOptions taskOptions =
            TaskOptions.Builder.param(KEY_NAME, keyString).url(url);

    Queue defaultQueue = QueueFactory.getDefaultQueue();
    defaultQueue.add(taskOptions);
}
MailTemplateEngineはメールの情報(TO,CC,BCC,SUBJECT,本文)を作成するためのクラスで、そこで作られた情報をMailMessageクラスに格納しています。これらの実装については省略します。
出来上がったMailMessageの情報をもとにMailTaskエンティティを作成し、Datastoreに保存しています。ここではslim3のDatastoreを利用しています。

これと別に、Taskを処理するためのServletを用意します。
@SuppressWarnings("serial")
public class MailSendServlet extends HttpServlet {

    /**
     * requestパラメータからkeyを取り出し、該当するMailTaskの内容でメールを送信する。
     * メールの送信まで成功した場合のみ該当MailTaskのentityを削除する。
     */
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        // パラメータのkeyからMailTaskを取得
        String keyString = req.getParameter(MailSpooler.KEY_NAME);

        Key key = KeyFactory.stringToKey(keyString);
        MailTask mailTask = Datastore.get(new MailTaskMeta(), key);

        // MailTaskの情報をもとにメールを送信
        MailService mailService = MailServiceFactory.getMailService();
        Message message = new Message(
                mailTask.getFrom(),
                mailTask.getTo(),
                mailTask.getSubject(),
                mailTask.getBody().getValue());
        mailService.send(message);
        // 最後まで例外なく処理を終えることができたときのみ該当MailTaskを消去
        Datastore.delete(mailTask.getId());
    }
}
Taskに登録されているkeyを元に送信すべきメールの情報をDatastoreから取り出し、送信します。送信が成功すれば最後にそのエンティティを削除します。
途中でメールの送信に失敗し例外がthrowされた場合はTaskは処理を完了できなかったということで残り続け、エンティティも削除されず、TaskQueueサービスによって自動的にリトライされるようになります。

追記
bluerabbitさんからコメントにてご指摘をいただきました。ありがとうございます。
Taskを処理するServletで、メール送信をした後のエンティティ削除の部分で例外が発生すると、エンティティもタスクもそのまま残り、メールが再送信されてしまいます。
2重送信を防ぐためにも、最後のエンティティ削除は例外を無視するように変更した方が良いですね。
        // MailTaskの情報をもとにメールを送信
        MailService mailService = MailServiceFactory.getMailService();
        Message message = new Message(
                mailTask.getFrom(),
                mailTask.getTo(),
                mailTask.getSubject(),
                mailTask.getBody().getValue());
        mailService.send(message);

        // 最後まで例外なく処理を終えることができたときのみ該当MailTaskを消去
        try {
            Datastore.delete(mailTask.getId());
        } catch (Exception e) {
            // このときの例外は無視する
        }

2 件のコメント:

  1. メール送信後にDBからタスクを削除していますが、この処理を入れてしまうと削除に失敗した場合にタスクキューがリトライされてメールが二重送信されてしまいます。DBの操作をしないかエラーがあっても無視してステータス200を返した方がいいと思います。

    返信削除
  2. ご指摘ありがとうございます。確かに削除失敗のときは無視するようにした方が良いですね。追記しました。

    返信削除