Отправка электронных писем асинхронно через AWS SES

Большинство приложений отправляют электронные письма для общения со своими пользователями. Транзакционные электронные письма — это письма, вызванные взаимодействием пользователя с приложением, например, при приветствии нового пользователя после регистрации на сайте, предоставлении пользователю ссылки для сбросить пароль или приподключении счета-фактуры после покупки. Все эти предыдущие случаи, как правило, требуют отправки только одного письма пользователю. В некоторых других случаях, хотя, приложение должно отправить гораздо больше писем, например, когда пользователь публикует новый контент на сайте, и все ее последователи (которые, в платформе, как Twitter, может составить миллионы пользователей) получит уведомление. В этой последней ситуации, не архитектор должным образом, отправка писем может стать узким местом в приложении.

Это то, что произошло в моем случае. У меня есть сайт, который, возможно, потребуется отправить 20 писем после некоторых пользовательских действий (например, уведомления пользователей для всех своих последователей). Первоначально он полагался на отправку писем через популярного облачного поставщика SMTP (например, SendGrid, Mandrill, Mailjet и Mailgun),однако ответ на запрос пользователя займет несколько секунд. Очевидно, что подключение к серверу SMTP для отправки этих 20 писем значительно замедляет процесс.

После осмотра, я узнал источники проблемы:

  1. Синхронное соединение
    Приложение подключается к серверу SMTP и ждет подтверждения синхронно, прежде чем продолжить выполнение процесса.
  2. Высокая задержка
    В то время как мой сервер находится в Сингапуре, поставщик SMTP, который я использовал, имеет свои серверы, расположенные в США, что делает подключение туда и обратно занимает значительное время.
  3. Отсутствие повторного использования соединения SMTP При вызове функции для отправки электронной почты функция немедленно отправляет электронное письмо, создавая новое соединение SMTP в этот момент (она не предлагает собирать все электронные письма и отправлять их все вместе в конце запроса, под одним соединением SMTP).

Из-за #1 время ожидания ответа пользователя привязано к времени отправки электронных писем. Из-за #2, время, чтобы отправить одно письмо является относительно высоким. И из-за #3, время для отправки 20 писем в 20 раз превышает время, необходимое для отправки одного письма. При отправке только одно письмо не может сделать приложение ужасно медленнее, отправка 20 писем, безусловно, делает, влияющих на пользовательский опыт.

Давайте посмотрим, как мы можем решить эту проблему.

Обращая внимание на природу транзакционных писем

Прежде всего, мы должны заметить, что не все письма равны по важности. Мы можем классифицировать электронные письма на две группы: приоритетные и неприоритетные письма. Например, если пользователь забыл пароль для доступа к учетной записи, она будет ожидать, что письмо с паролем сбросить ссылку сразу на ее почтовый ящик; это приоритетная электронная почта. В отличие от этого, отправка электронной почты с уведомлением о том, что кто-то мы следуем опубликовал новый контент не нужно, чтобы прийти на почтовый ящик пользователя немедленно; это неприоритетное письмо.

Решение должно оптимизировать отправку этих двух категорий писем. Предполагая, что будет только несколько (может быть, 1 или 2) приоритетных писем, которые будут отправлены в ходе процесса, и основная часть писем будет неприоритетных из них, то мы разрабатываем решение следующим образом:

  • Приоритетные сообщения электронной почты могут просто избежать проблемы с высокой задержкой, используя поставщика SMTP, расположенного в том же регионе, где развертывается приложение. В дополнение к хорошим исследованиям, это включает в себя интеграцию нашего приложения с API поставщика.
  • Неприоритетные письма могут быть отправлены асинхронно, и в пакетах, где многие письма отправляются вместе. Реализованный на уровне применения, он требует соответствующего технологического стека.

Давайте определим стек технологий для асинхронного сообщения.

Определение технологического стека

Примечание: Я решил основывать свой стек на услугах AWS, потому что мой веб-сайт уже размещен на AWS EC2. В противном случае, я бы накладные расходы от перемещения данных между несколькими сетями компаний. Тем не менее, мы можем реализовать наш растворитель с помощью других поставщиков облачных услуг тоже.

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

Тем не менее, при проверке службы очереди от AWS, называемой S’S,я решил, что это не подходящее решение, потому что:

  • Это довольно сложная настройка;
  • Стандартное сообщение очереди может хранить только верхние 256 кб информации, что может быть недостаточно, если в письме есть вложения (например, счет-фактура). И даже если можно разделить большое сообщение на более мелкие сообщения, сложность возрастает еще больше.

Тогда я понял, что я мог бы прекрасно имитировать поведение очереди через сочетание других услуг AWS, S3 и Lambda, которые гораздо проще настроить. S3, решение для хранения и извлечения данных облачного объекта, может выступать в качестве репозитория для загрузки сообщений, а Lambda, вычислительная служба, которая запускает код в ответ на события, может выбрать сообщение и выполнить операцию с ним.

Другими словами, мы можем настроить наш процесс отправки электронной почты следующим образом:

  1. Приложение загружает файл с содержанием электронной почты и метаданными в ведро S3.
  2. Всякий раз, когда новый файл загружается в ведро S3, S3 запускает событие, содержащее путь к новому файлу.
  3. Функция Lambda выбирает событие, читает файл и отправляет электронное письмо.

Наконец, мы должны решить, как отправить электронную почту. Мы можем либо продолжать использовать провайдера SMTP, который у нас уже есть, имея функцию Lambda взаимодействовать с их AI, или использовать службу AWS для отправки электронных писем, называемых SES. Использование SES имеет как преимущества, так и недостатки:

Преимущества:
  • Очень простой в использовании из AWS Lambda (это займет всего 2 строки кода).
  • Это дешевле: сборы Lambda вычисляются в зависимости от времени, необходимого для выполнения функции, поэтому подключение к SES из сети AWS займет меньше времени, чем подключение к внешнему серверу, что делает работу функции раньше и будет стоить меньше. (Если SES не доступна в том же регионе, где размещение приложения; в моем случае, потому что SES не предлагается в Азиатско-Тихоокеанском регионе (Сингапур), где находится мой сервер EC2, то я мог бы быть лучше подключения к некоторым Азии на основе внешнего SMTP провайдера).
Недостатки:
  • Не так много статистики для мониторинга наших отправленных писем предоставляются, и добавление более мощных требует дополнительных усилий (например: отслеживание того, какой процент писем были открыты, или какие ссылки были нажаты, должны быть настроены через AWS CloudWatch).
  • Если мы будем продолжать использовать поставщика SMTP для отправки приоритетных писем, то мы не будем иметь нашу статистику все вместе в 1 месте.

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

Затем мы определили логику процесса и стек следующим образом: приложение отправляет приоритетные письма, как обычно, но для неприоритетных, он загружает файл с содержанием электронной почты и метаданных в S3; этот файл асинхронно обрабатывается функцией Lambda, которая подключается к SES для отправки электронной почты.

Начнем реализацию решения.

Дифференциирование между приоритетными и неприоритетными электронными письмами

Короче говоря, все это зависит от приложения, поэтому мы должны принять решение по электронной почте по электронной почте основе. Я опишу решение, которое я реализовал для WordPress, который требует некоторых хаки вокруг ограничений от функции wp_mail . Для других платформ, стратегия ниже будет работать тоже, но вполне возможно, что будут лучшие стратегии, которые не требуют хаки на работу.

Способ отправить по электронной почте в WordPress является вызов wp_mail функции, и мы не хотим, чтобы изменить это (например: позвонив либо функции или ), так что наша реализация будет необходимо обрабатывать как wp_mail_synchronous wp_mail_asynchronous wp_mail синхронные и асинхронные случаи, и нужно будет знать, к которому г roup письмо принадлежит. К несчастью, wp_mail не предлагает каких-либо дополнительных параметров, из которых мы могли бы оставить эту информацию, как это видно из его подписи:

function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() )

Затем, чтобы узнать категорию письма, мы добавляем хаки решение: по умолчанию, мы делаем электронную почту принадлежат к группе приоритетов, и если $to содержит конкретную электронную почту (например, nonpriority’asynchronous.mail), или если $subject начинается со специальной строки (например, ” Неприоритетность!»), затем он принадлежит к неприоритетной группе (и мы удаляем соответствующую электронную почту или строку из предмета). wp_mailявляется pluggable функции, так что мы можем переопределить его просто путем реализации новой функции с той же подписью на нашем файле functions.php. Первоначально он содержит тот же код исходной wp_mail функции, расположенный в файле wp-includes/pluggable.php, чтобы извлечь все параметры:

if ( !function_exists( 'wp_mail' ) ) :

function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {

  $atts = apply_filters( 'wp_mail', compact( 'to', 'subject', 'message', 'headers', 'attachments' ) );

  if ( isset( $atts['to'] ) ) {
    $to = $atts['to'];
  }

  if ( !is_array( $to ) ) {
    $to = explode( ',', $to );
  }

  if ( isset( $atts['subject'] ) ) {
    $subject = $atts['subject'];
  }

  if ( isset( $atts['message'] ) ) {
    $message = $atts['message'];
  }

  if ( isset( $atts['headers'] ) ) {
    $headers = $atts['headers'];
  }

  if ( isset( $atts['attachments'] ) ) {
    $attachments = $atts['attachments'];
  }

  if ( ! is_array( $attachments ) ) {
    $attachments = explode( "n", str_replace( "rn", "n", $attachments ) );
  }

  // Continue below...
}
endif;

И затем мы проверяем, не является ли он приоритетным, и в этом случае мы затем раскидывать отдельную логику под функцией send_asynchronous_mail или, если это не так, мы продолжаем выполнять тот же код, что и в исходной wp_mail функции:

function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {

  // Continued from above...

  $hacky_email = "nonpriority@asynchronous.mail";
  if (in_array($hacky_email, $to)) {

    // Remove the hacky email from $to
    array_splice($to, array_search($hacky_email, $to), 1);

    // Fork to asynchronous logic
    return send_asynchronous_mail($to, $subject, $message, $headers, $attachments);
  }

  // Continue all code from original function in wp-includes/pluggable.php
  // ...
}

В нашей send_asynchronous_mail функции, вместо загрузки электронной почты прямо на S3, мы просто добавить письмо к глобальной переменной $emailqueue , из которых мы можем загрузить все письма вместе s3 в одном соединении в конце запроса:

function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) {

  global $emailqueue;
  if (!$emailqueue) {
    $emailqueue = array();
  }

  // Add email to queue. Code continues below...
}

Мы можем загрузить один файл по электронной почте, или мы можем расслоение их так, что в 1 файл мы содержащих много писем. Так как $headers содержит мета электронной почты (от, содержание типа и charset, CC, BCC, и ответить на поля), мы можем группировать письма вместе, когда они имеют то же $headers самое . Таким образом, все эти письма могут быть загружены в том же файле на S3, и $headers мета информация будет включена только один раз в файл, а не один раз в электронной почте:

function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) {

  // Continued from above...

  // Add email to the queue
  $emailqueue[$headers] = $emailqueue[$headers] ?? array();
  $emailqueue[$headers][] = array(
    'to' => $to,
    'subject' => $subject,
    'message' => $message,
    'attachments' => $attachments,
  );

  // Code continues below
}

Наконец, функция send_asynchronous_mail возвращается true . Пожалуйста, обратите внимание, что этот код является хаки: true как правило, означает, что письмо было отправлено успешно, но в этом случае, он даже не был отправлен еще, и он может совершенно не удалось. Из-за этого вызов функции wp_mail не должен рассматривать ответ как true «письмо было отправлено успешно», но подтверждение того, что оно было выполнено. Вот почему важно ограничить этот метод неприоритетными электронными письмами, чтобы, если он не удается, процесс мог продолжать повторную попытку в фоновом режиме, и пользователь не будет ожидать, что письмо уже будет в ее почтовом ящике:

function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) {

  // Continued from above...

  // That's it!
  return true;
}

Загрузка электронной почты на S3

В моей предыдущей статье“Обмен данными между несколькими серверами через AWS S3“, я описал, как создать ведро в S3, и как загружать файлы в ведро через SDK. Весь приведенный ниже код продолжает реализацию решения для WordPress, поэтому мы подключаемся к AWS, используя SDK для PHP.

Мы можем расширить от абстрактного класса AWS_S3 (введено в моей предыдущей статье), чтобы подключиться к S3 и загрузить письма в ведро “async-emails” в конце запроса (срабатывает через wp_footer крючок). Пожалуйста, обратите внимание, что мы должны держать ACL как “частный”, так как мы не хотим, чтобы электронные письма, которые будут подвергаться в Интернете:

class AsyncEmails_AWS_S3 extends AWS_S3 {

  function __construct() {

    // Send all emails at the end of the execution
    add_action("wp_footer", array($this, "upload_emails_to_s3"), PHP_INT_MAX);
  }

  protected function get_acl() {

    return "private";
  }

  protected function get_bucket() {

    return "async-emails";
  }

  function upload_emails_to_s3() {

    $s3Client = $this->get_s3_client();

    // Code continued below...
  }
}
new AsyncEmails_AWS_S3();

Мы начинаем итерации через пары заголовков, х gt; emaildata сохранены в глобальной переменной $emailqueue , и получить конфигурацию по умолчанию от функции, get_default_email_meta если заголовки пусты. В приведенном ниже коде я извлекаю только поле “из” из заголовков (код для извлечения всех заголовков можно скопировать из исходной wp_mail функции):

class AsyncEmails_AWS_S3 extends AWS_S3 {

  public function get_default_email_meta() {

    // Code continued from above...

    return array(
      'from' => sprintf(
        '%s ',
        get_bloginfo('name'),
        get_bloginfo('admin_email')
      ),
      'contentType' => 'text/html',
      'charset' => strtolower(get_option('blog_charset'))
    );
  }

  public function upload_emails_to_s3() {

    // Code continued from above...

    global $emailqueue;
    foreach ($emailqueue as $headers => $emails) {

      $meta = $this->get_default_email_meta();

      // Retrieve the "from" from the headers
      $regexp = '/From:s*(([^',
          $matches[2],
          $matches[3]
        );
      }

      // Code continued below... 
    }
  }
}

Наконец, мы загружаем электронные письма на S3. Мы решаем, сколько писем загружать на файл с намерением сэкономить деньги. Lambda выполняет заряд в зависимости от времени, необходимого для выполнения, рассчитанного на пролеты 100 мс. Чем больше времени требует сятвая функция, тем дороже она становится.

Отправка всех писем, загрузив 1 файл по электронной почте, то, дороже, чем загрузка 1 файл на много писем, так как накладные расходы от выполнения функции вычисляется один раз в электронной почте, а не только один раз для многих писем, а также потому, что отправка многих писем вместе заполняет 100ms охватывает более тщательно.

Таким образом, мы загружаем много писем на файл. Сколько писем? Функции Lambda имеют максимальное время выполнения (3 секунды по умолчанию), и если операция не выполняется, она будет продолжать повторную попытку с самого начала, а не оттуда, где она не удалась. Таким образом, если файл содержит 100 писем, и Lambda удается отправить 50 писем до максимального времени исчерпает, то он не и повторно выполнения операции снова, отправка первых 50 писем еще раз. Чтобы избежать этого, мы должны выбрать количество писем на файл, что мы уверены, достаточно для обработки до максимального времени исчерпал. В нашей ситуации, мы могли бы выбрать для отправки 25 писем на файл. Количество писем зависит от приложения (большие сообщения электронной почты займет больше времени, чтобы быть отправлены, и время, чтобы отправить письмо будет зависеть от инфраструктуры), поэтому мы должны сделать некоторые испытания, чтобы придумать правильный номер.

Содержимое файла является просто объектом JSON, содержащим мета электронной почты под свойством “мета”, и кусок писем под собственностью “письма”:

class AsyncEmails_AWS_S3 extends AWS_S3 {

  public function upload_emails_to_s3() {

    // Code continued from above...
    foreach ($emailqueue as $headers => $emails) {

      // Code continued from above...

      // Split the emails into chunks of no more than the value of constant EMAILS_PER_FILE:
      $chunks = array_chunk($emails, EMAILS_PER_FILE);
      $filename = time().rand();
      for ($chunk_count = 0; $chunk_count  $meta,
          'emails' => $chunks[$chunk_count],
        );

        // Upload to S3
        $s3Client->putObject([
          'ACL' => $this->get_acl(),
          'Bucket' => $this->get_bucket(),
          'Key' => $filename.$chunk_count.'.json',
          'Body' => json_encode($body),
        ]);  
      }   
    }
  }
}

Для простоты, в приведенном выше коде, я не загружаю вложения в S3. Если наши электронные письма должны включать вложения, то мы должны использовать функцию SES SendRawEmail вместо SendEmail (которая используется в сценарии Lambda ниже).

Добавляя логику для загрузки файлов с электронными письмами в S3, мы можем перейти к кодированию функции Lambda.

Сценарий кодирования Ламбда

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

Следующий скрипт Node.js выполняет требуемую работу. Ссылается на событие S3 “Put”, которое указывает на то, что новый объект был создан на ведре, функция:

  1. Получает путь нового объекта (под srcKey переменной) и ведро (под srcBucket переменной).
  2. Загружает объект, через s3.getObject .
  3. Сравнивает содержимое объекта, через JSON.parse(response.Body.toString()) , и извлекает электронную почту и мета электронной почты.
  4. Итерирует через все письма, и отправляет их через ses.sendEmail .
var async = require('async');
var aws = require('aws-sdk');
var s3 = new aws.S3();

exports.handler = function(event, context, callback) {

  var srcBucket = event.Records[0].s3.bucket.name;
  var srcKey = decodeURIComponent(event.Records[0].s3.object.key.replace(/+/g, " ")); 

  // Download the file from S3, parse it, and send the emails
  async.waterfall([

    function download(next) {

      // Download the file from S3 into a buffer.
      s3.getObject({
        Bucket: srcBucket,
        Key: srcKey
      }, next);
    },
    function process(response, next) {

      var file = JSON.parse(response.Body.toString());
      var emails = file.emails;
      var emailsMeta = file.meta;

      // Check required parameters
      if (emails === null || emailsMeta === null) {
        callback('Bad Request: Missing required data: ' + response.Body.toString());
        return;
      }
      if (emails.length === 0) {
        callback('Bad Request: No emails provided: ' + response.Body.toString());
        return;
      }

      var totalEmails = emails.length;
      var sentEmails = 0;
      

Далее мы должны загрузить и настроить функцию Lambda на AWS, которая включает в себя:

  1. Создание роли выполнения, предоставляющей Lambda разрешения на доступ к S3.
  2. Создание пакета .zip, содержащего весь код, т.е. функцию Lambda, которая мы создаем, – все необходимые модули Node.js.
  3. Загрузка этого пакета в AWS с помощью инструмента CLI.

Как это сделать, правильно объясняется на сайте AWS, на учебнике по использованию AWS Lambda с Amazon S3.

Подключение S3 с функцией Lambda

Наконец, создав ведро и функцию Lambda, нам нужно подключить их обоих вместе, так что всякий раз, когда на ведре создается новый объект, он вызовет событие для выполнения функции Lambda. Для этого мы перейдем к панели мониторинга S3 и нажмите на строку ведра, которая покажет ее свойства:

Displaying bucket properties inside the S3 dashboard
Нажатие на строку ведра отображает свойства ведра. (Большой предварительный просмотр)

Затем нажав на свойства, мы прокручиваем вниз к пункту “События”, и там мы нажимаем на Добавить уведомление, и вввод следующих полей:

  • Название: название уведомления, например: “EmailSender”;
  • Мероприятия: “Put”, событие, срабатывающее при создании нового объекта на ведре;
  • Отправить по: “Функция Ламбда”;
  • Lambda: название нашего недавно созданного Lambda, например: “LambdaEmailSender”.
Setting up S3 with Lambda
Добавление уведомления в S3 для запуска события для Lambda. (Большой предварительный просмотр)

Наконец, мы также можем установить ведро S3, чтобы автоматически удалить файлы, содержащие данные электронной почты через некоторое время. Для этого мы перейдем на вкладку управления ведра и создаем новое правило жизненного цикла, определяющее, через сколько дней должно истечь количество писем:

Lifecycle rule
Настройка правила жизненного цикла для автоматического удаления файлов из ведра. (Большой предварительный просмотр)

Ну вот. С этого момента, при добавлении нового объекта на ведро S3 с содержанием и мета для писем, он вызовет функцию Lambda, которая будет читать файл и подключиться к SES для отправки писем.

Я реализовал это решение на моем сайте, и он стал быстрым еще раз: путем разгрузки отправки писем на внешний процесс, будь то приложения отправить 20 или 5000 писем не имеет значения, ответ на пользователя, который вызвал действие будет немедленно.

Заключение

В этой статье мы проанализировали, почему отправка многих транзакционных писем в одном запросе может стать узким местом в приложении, и создали решение для решения проблемы: вместо подключения к серверу SMTP внутри приложения (синхронно), мы можем отправлять электронные письма от внешней функции, асинхронно, на основе стека AWS S3 и Lambda sES.

Отправив письма асинхронно, приложение может управлять, чтобы отправить тысячи писем, но ответ на пользователя, который вызвал действие не будет затронута. Однако, чтобы убедиться, что пользователь не ждет, когда письмо прибудет в папку “Входящие”, мы также решили разделить электронные письма на две группы, приоритетные и неприоритетные, и отправить только неприоритетные письма асинхронно. Мы предоставили реализацию для WordPress, который является довольно хаки из-за ограничений функции wp_mail для отправки писем.

Урок из этой статьи заключается в том, что серверные функции на сервере приложения работают очень хорошо: сайты, работающие на CMS, как WordPress может улучшить свою производительность, реализуя только конкретные функции в облаке, и избежать много сложность, возникающая от миграции высокодинамических сайтов в полностью бессерверную архитектуру.

Источник: smashingmagazine.com

Великолепный Журнал

Великолепный, сокрушительный, разящий (см. перевод smashing) независимый журнал о веб-разработке. Основан в 2006 году в Германии. Имеет няшный дизайн и кучу крутых авторов, которых читают 2 млн человек в месяц.

Добавить комментарий

%d такие блоггеры, как: