yiisoft/yii2-queue

Email sending problem in queue/listen mode

padlyuck opened this issue · 16 comments

Hi, thanks for the great extension.
I faced with the problem of sending email when jobs running by ./yii queue/listen.
Seems like Swift_SendmailTransport lost connection after a while.
As a temporary solving I am closing connection manually before sending emails, but I think it's wrong way.
Please tell me if you met a similar problem how you solve it?

А где у Вас переменная SwiftMailer'a для подключения к SMTP-серверу находится?

Правильно будет при каждом запуске задачи открывать соединение и в конце работы его закрывать.

Ну я фактически сейчас так и делаю, закрываю соединение перед отправкой, чтобы оно было открыто заново, но мне кажется это не правильно. Тем более что решение я подсмотрел в этом PR swiftmailer/swiftmailer#673 который не приняли

Вот как я в своём проекте сделал отправку:

MailerBase.php:

<?php

namespace app\commands\jobs;

use Yii;
use yii\base\Object;
use yii\base\View;
use yii\base\ViewNotFoundException;
use Swift_Message;
use Swift_SmtpTransport;
use Swift_Mailer;
use Swift_SwiftException;

/**
 * Базовый класс для всех job-объектов, которые отправляют e-mail сообщения.
 *
 * @package app\commands\jobs
 */
class MailerBase extends Object
{
    /**
     * Возвращает HTML шаблон.
     *
     * @param $name string view name
     * @param $data array
     *
     * @return string
     */
    private function getView($name, array $data)
    {
        $view = new View();
        $viewName = sprintf('%s/mail/%s.php', Yii::getAlias('@views'), $name);

        $body = $view->renderFile($viewName, ['data' => $data]);

        return $body;
    }

    /**
     * Возвращает инстанс Swift_Mailer.
     *
     * @return Swift_Mailer
     */
    private function getMailer()
    {
        $config = Yii::$app->params['mail'];

        $transport = Swift_SmtpTransport::newInstance($config['host'], $config['port'], $config['security'])
            ->setUsername($config['username'])
            ->setPassword($config['password'])
            ->setStreamOptions(['ssl' => $config['ssl']]);

        return Swift_Mailer::newInstance($transport);
    }

    /**
     * Отправка e-mail сообщения (в HTML-формате).
     *
     * @param $config array
     */
    protected function sendHTMLMessage(array $config)
    {
        $from = Yii::$app->params['fromEmail'];
        $data = ['subject' => $config['subject'], 'sentTo' => $config['sentTo']];

        try {
            $body = $this->getView($config['view'], $config['data']);

            /** @var \Swift_Mime_Message $message */
            $message = Swift_Message::newInstance($config['subject'])
                ->setFrom($from)
                ->setTo($config['sentTo'])
                ->setBody($body, 'text/html');

            // отправляем письмо
            $recipients = $this->getMailer()->send($message);

            if ($recipients !== 0) {
                $data['message'] = 'Successful sent';
                Yii::info($data, __METHOD__);
            } else {
                $data['message'] = 'Failure sent';
                Yii::warning($data, __METHOD__);
            }
        } catch (ViewNotFoundException $e) {
            Yii::error($e->getMessage(), __METHOD__);
        } catch (Swift_SwiftException $e) {
            Yii::error($e->getMessage(), __METHOD__);
        } catch (\Exception $e) {
            Yii::error($e->getMessage(), __METHOD__);
        }
    }
}

Вот пример job'a, который его использует:

<?php

namespace app\commands\jobs;

use Yii;
use zhuravljov\yii\queue\Job;

/**
 * Отправка письма об удалении отзыва.
 *
 * @package app\commands\jobs
 */
class DeleteRequestJob extends MailerBase implements Job
{
    /** @var array */
    public $data = [];

    public function run()
    {
        $config = [
            'view' => 'delete-request',
            'subject' => 'Заявка на удаление отзыва',
            'data' => $this->data,
            'sentTo' => Yii::$app->params['emailForDeleteRequests'],
        ];

        $this->sendHTMLMessage($config);
    }
}

Дело в том, что не нужно открывать соединение где-то за пределами job'a и потом его использовать, SMTP-сервер обязательно его закроет.

Спасибо за разъяснения. Последую Вашему примеру.

Столкнулся с такой же проблемой

if (!\Yii::$app->mailer->transport->isStarted()) {
     \Yii::$app->mailer->transport->start();
}
		
\Yii::$app->mailer->compose($this->view, $this->params)
				->setTo($this->to)
				->setSubject($this->subject)
				->send();
		
\Yii::$app->mailer->transport->stop();

Я, вообще, рекомендую не использовать https://github.com/yiisoft/yii2-swiftmailer, а напрямую вызывать SwiftMailer.

Когда использовал yii2-swiftmailer, то были проблемы с конфигурацией и добавлением файлов.

У меня проблем не было, хотя отправлял без атачей. Код пригоднее с использованием yii2-swiftmailer.
Если нужно, то можно переписать BaseMailer, BaseMessage http://www.yiiframework.com/doc-2.0/guide-tutorial-mailing.html#creating-your-own-mail-solution

Суть проблемной ситуации в том, что команда listen работает в режиме демона. То есть, нужно учитывать, что все компоненты, которые используют сокеты или любые другие сетевые соединения, могут отваливаться.

На правах костыля можно попробовать:

Yii::$app->set('mailer', Yii::$app->getComponents()['mailer']);

Этот код с помощью сервис-локатора уничтожит mailer-объект, но оставит его конфиг. А, при первом обращении к Yii::$app->mailer, объект будет создан заново.

В идеале на обработку каждого задания нужно заново поднимать все окружение, но я не вижу как это решить с помощью расширения.

Видел решение с job'ами оформленными в виде консольных экшенов. Смысл был в том, что queue/listen никаких работ не выполнял сам, он только мониторил очередь и при обнаружении нового задания стартовал новый консольный процесс

Смысл был в том, что queue/listen никаких работ не выполнял сам, он только мониторил очередь и при обнаружении нового задания стартовал новый консольный процесс

Хорошая идея. Можно сделать опционально.

но если много job'ов, то и процессов php будет много.

Не очень много. Задания будут обрабатываться последовательно. По одному дополнительному консольному процессу на каждый воркер в момент обработки задания.

Да, смотря как сделать их запуск. Тикет, в принципе, можно закрыть.

Да, смотря как сделать их запуск.

Ну это же очередь, нужно делать последовательно. Чтобы воркер брал следующую задачу только после того как выполнит предыдущую. Тогда количество одновременно выполняющихся заданий будет зависеть от кол-ва запущенных воркеров, и этим самым кол-вом воркеров можно контролировать скорость обработки всей очереди.

Related #18.

Сделал так, чтобы задания выполнялись в отдельных дочерних процессах, а воркер только опрашивал очередь. Это должно решить проблемы с отваливающимися сокетами в компонентах db, mailer и прочих, где это актуально. Потестить можно из ветки master.