Подтвердить что ты не робот

Laravel: Работа с большим количеством задач в очереди

Я создаю веб-приложение, используя Laravel 5, который создает ссылки на веб-приложение, которое при посещении показывает форму для отчета о прохождении студента. Эти ссылки отправляются веб-приложением по электронной почте контакта в учреждении, на котором студент посещает, чтобы получатель мог завершить отчет о ходе, доступ к которому был получен по ссылке в письме.

Проблема, с которой я сталкиваюсь, заключается в создании и отправке ссылок. У меня есть код, который отлично работает с несколькими сотнями студентов, однако в реальном мире приложение будет потенциально создавать и отправлять 3000+ или около того ссылок за один раз. Код, который я написал, просто не может обрабатывать такое большое количество своевременно, и приложение сработает. Как ни странно, хотя я не получаю никакой ошибки таймаута через laravel (мне нужно будет дважды проверить журналы php).

Хотя я более чем приветствую другие предложения, я считаю, что ответ на проблему заключается в использовании очередей. Я уже использовал очереди при отправке электронной почты (см. Код), но я хотел бы работать с некоторыми другими частями кода в очереди, но я немного не уверен, как это сделать!

Краткая схема базы данных

Student hasMany Link

Student hasMany InstitutionContact (ограничено двумя моими приложениями)

Link hasMany InstitutionContact (ограничено двумя моими приложениями)

Email manyToMany Link

Что я пытаюсь выполнить

  • Получите все Student, которым требуется новый Link

  • Создайте Link для каждого Student

  • Назначьте Student текущий InstitutionContact контакту Link InstitutionContact (A Student, который может измениться, поэтому я свяжу ссылку InstitutionContact с ссылкой, если это необходимо для повторной отправки.

  • Прокрутите все вновь созданное Links, чтобы сгруппировать их совместно с общим InstitutionContact - это значит, что электронное письмо не отправляется на ссылку (таким образом, возможно, отправка нескольких писем с одной ссылкой на тот же адрес), скорее, ссылки должны быть сгруппированы одним и тем же адресом электронной почты и отправлены вместе, если это применимо

  • Прокрутите все Link, сгруппированные по электронной почте/контакту и:

    • Отправьте электронное письмо с адресом Link (url, имя студента и т.д.) на указанный адрес электронной почты InstitutionContact
    • Напишите копию Email в базу данных
    • Присоединиться к Email, созданному в предыдущем шаге, к Link (s), которые были отправлены в нем (чтобы приложение можно было использовать для поиска, какая ссылка была отправлена ​​в этом письме)

Таким образом, главная задача, с которой я сталкиваюсь, заключается в выполнении вышеупомянутой задачи с большим набором данных. Я уже рассмотрел создание и отправку Link один за другим через очередь, однако это не позволило бы мне группировать все Link вместе путем контакта/электронной почты. Поскольку задача не будет выполняться регулярно, я был бы открыт для рассмотрения выполнения задачи, поскольку это связано с увеличением объема памяти и времени для процесса, однако я не имел большого успеха при попытке использовать это с помощью set_time_limit(0); и ini_set('memory_limit','1056M'); перед отправкой любых ссылок.

Любая помощь будет действительно оценена, спасибо, если вы прочтете это!

код

приложение\Http\Контроллеры\LinkController.php

public function storeAndSendMass(Request $request)
{
    $this->validate($request, [
        'student_id' => 'required|array',
        'subject'    => 'required|max:255',
        'body'       => 'required|max:5000',
    ]);

    $studentIds = $request->get('student_id');
    $subject    = $request->get('subject');
    $body       = $request->get('body');

    $students = $this->student
        ->with('institutionContacts')
        ->whereIn('id', $studentIds)
        ->where('is_active', 1)
        ->get();

    // create link, see Link.php below for method
    $newLinks = $this->link->createActiveLink($students);

    // send link to student contact(s), see LinkEmailer.php below for method
    $this->linkEmailer->send($newLinks, ['subject' => $subject, 'body' => $body], 'mass');

    // return
    return response()->json([
        'message' => 'Creating and sending links'
    ]);
}

приложение \Models\Link.php

public function createActiveLink($students)
{
    $links = [];

    foreach ($students as $student) {
        $newLink = $this->create([
            'token'          => $student->id, // automatically hashed
            'status'         => 'active',
            'sacb_refno'     => $student->sacb_refno,
            'course_title'   => $student->course_title,
            'university_id'  => $student->university_id,
            'student_id'     => $student->id,
            'institution_id' => $student->institution_id,
            'course_id'      => $student->course_id,
        ]);

        $studentContacts = $student->institutionContacts;

        if ($studentContacts) {

            foreach ($studentContacts as $studentContact) {

                $newLink->contacts()->create([
                    'type'                   => $studentContact->pivot->type,
                    'institution_contact_id' => $studentContact->pivot->institution_contact_id
                ]);

                $newLink->save();
            }

        }

        $links[] = $newLink->load('student');
    }

    return $links;
}

Приложение\Письма\LinkEmailer.php

namespace App\Emails;

use App\Emails\EmailComposer;

class LinkEmailer
{
    protected $emailComposer;

    public function __construct(EmailComposer $emailComposer)
    {
        $this->emailComposer = $emailComposer;
    }

    public function send($links, $emailDetails, $emailType)
    {        
        $contactsAndLinks = $this->arrangeContactsToLinks($links);

        foreach ($contactsAndLinks as $linksAndContact) {

            $emailData = array_merge($linksAndContact, $emailDetails);

            // send/queue email
            \Mail::queue('emails/queued/reports', $emailData, function ($message) use ($emailData) {
                $message
                    ->to($emailData['email'], $emailData['formal_name'])
                    ->subject($emailData['subject']);
            });

            // compose email message, returns text of the email
            $emailMessage = $this->emailComposer->composeMessage($emailData);

            // // create Email
            $email = \App\Models\Email::create([
                'to'      => $emailData['email'],
                'from'    => '[email protected]',
                'subject' => $emailData['subject'],
                'body'    => $emailMessage,
                'type'    => $emailType,
                'user'    => $_SERVER['REMOTE_USER']
            ]);

            foreach ($linksAndContact['links'] as $link) {
                $link->emails()->attach($email->id);
            }
        }
    }

    // group links by contact
    public function arrangeContactsToLinks($links)
    {
        $contactsForLinks = [];
        $assigned         = false;
        $match            = false;

        foreach ($links as $link) { // 1, n

            if ($link->contacts) {

                foreach ($link->contacts as $contact) { // 1, 2

                    if ($contactsForLinks) {

                        $assigned = false;

                        foreach ($contactsForLinks as $key => $contactLink) { // n
                            // assign links to existing email in array
                            if ($contactLink['email'] === $contact->institutionContact->email) {
                                $match = false;

                                // check link hasn't already been included
                                foreach ($contactsForLinks[$key]['links'] as $assignedLink) {
                                    if ($assignedLink === $link) {
                                        $match = true;
                                    }
                                }

                                // if there was no match add to list of links
                                if (!$match) {
                                    $contactsForLinks[$key]['links'][] = $link->load('student');
                                    $assigned = true;
                                    break;
                                }
                            }
                        }

                        if (!$assigned) {
                            $contactsForLinks[] = [
                                'email'                 => $contact->institutionContact->email,
                                'formal_name'           => $contact->institutionContact->formal_name,
                                'requires_id'           => $contact->institutionContact->institution->requires_id,
                                'requires_course_title' => $contact->institutionContact->institution->requires_course_title,
                                'links'                 => [$link->load('student')],
                            ];
                        }
                    } else {
                        $contactsForLinks[] = [
                            'email'                 => $contact->institutionContact->email,
                            'formal_name'           => $contact->institutionContact->formal_name,
                            'requires_id'           => $contact->institutionContact->institution->requires_id,
                            'requires_course_title' => $contact->institutionContact->institution->requires_course_title,
                            'links'                 => [$link->load('student')],
                        ];
                    }
                }
            }
        }

        return $contactsForLinks;
    }
}

Изменить 1

У меня теперь это работает с set_time_limit(0); и ini_set('memory_limit','1056M');, для выполнения 3000 учеников потребовалось 8 минут.

Изменить 2

Я запускаю Laravel Framework версии 5.1.6 (LTS), MySQL для DB.

Изменить 3

Оцените все ответы до сих пор, спасибо всем. Я думаю, что я могу запустить процесс создания Link в очередь, которая будет иметь связанный объект в базе данных, называемый как-то вроде Batch, и когда этот Batch ссылок будет создан, затем сгруппируйте все Link от этого Batch и отправить их.

Я мог бы использовать подход, который предложил @denis-mysenko, имея поле sent_at в таблице Link и планируя процесс проверки Link, который не был отправлен, а затем отправил их. Однако, используя вышеупомянутый подход, я могу отправить Batch из Link, когда все они будут созданы, тогда как с подходом sent_at с запланированным процессом, который ищет Link, который не был отправлен, он может потенциально отправить некоторые ссылки, когда все ссылки еще не созданы.

4b9b3361

Ответ 1

Если вы протестировали свой код с небольшим количеством данных, и он преуспевает без сбоев, он ясно говорит о том, что проблема (как вы сказали) - довольно большое количество записей, с которыми вы имеете дело. Почему вы не обрабатываете свою коллекцию с помощью метода chunk?

В соответствии с документами Laravel:

Если вам нужно обработать тысячи записей Eloquent, используйте команду chunk. Метод chunk будет извлекать "кусок" моделей Eloquent, подавая их на заданное Closure для обработки. Использование метода chunk сохранит память при работе с большими наборами результатов

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

Почему бы вам не использовать в очереди коллекцию очереди и метод кусков? Это позволит вам:

  • избежать ошибок тайм-аута
  • используйте разумный объем памяти во время выполнения процесса (это зависит от количества данных, переданных при закрытии блока)
  • предоставить вам приоритетный контроль над этим процессом в очереди и (в конечном итоге) другими процессами в очереди.

Документы Laravel охватывают все, что вам нужно: "Красноречивый" - извлечение нескольких моделей (см. главу "Результаты квантов" для более глубокого изучения того, как сэкономить память при работе с большим набором данных) и Queues для создания заданий и отсоединения некоторых частей вашего программного обеспечения, которые не должны запускаться под вашим веб-сервером, избегая риска тайм-аутов

Ответ 2

Я бы предложил изменить архитектуру. Я думаю, что это излишне сложно.

Контроллер может понравиться:

public function storeAndSendMass(Request $request, LinkEmailer $linkEmailer)
{
    $this->validate($request, [
        'student_id' => 'required|array',
        'subject'    => 'required|max:255',
        'body'       => 'required|max:5000',
    ]);

    $students = $this->student
        ->with('institutionContacts')
        ->whereIn('id', $request->input('student_id'))
        ->where('is_active', 1)
        ->get();

    // Don't use Link.php method at all
    foreach ($students as $student) {
        $student->links()->create([
            'token'          => $student->id, // automatically hashed
            'status'         => 'active',
            'sent_at'        => null,
            'email_body'     => $request->input('body'),
            'email_subject'  => $request->input('subject')
        ]);
    }

    return response()->json([
        'message' => 'Creating and sending links'
    ]);
}

Зачем хранить так много полей в модели Link, которые уже существуют в модели Student и доступны через отношения student()? Вы могли бы просто сохранить статус и токен (я предполагаю, что это часть ссылки?), А также метку "sent_at". Если ссылки обычно отправляются только один раз, разумно держать тело электронной почты и подвергать его также.

Если ученик обновляет контакты своего учреждения, свежие данные будут использоваться во время составления электронной почты, потому что вы явно не связываете ссылки на контакты учреждения.

Затем я бы создал команду (пусть, newLinkNotifier), которая будет запускаться, например, каждые 10 минут, и это будет искать ссылки, которые еще не были отправлены ($links->whereNull('sent_at')), группировать их по электронной почте ($link->student->institutionContacts) и содержимое электронной почты ($link->email_body, $link->email_subject) и создать заданные задания электронной почты. Затем работник очереди отправил эти электронные письма (или вы могли бы установить очередь на "асинхронный", чтобы отправить их сразу же из команды).

Так как эта команда запускает async, на самом деле не имеет значения, требуется ли 5 ​​минут для завершения. Но в реальной жизни это займет меньше минуты для тысяч и тысяч объектов.

Как сделать группировку? Я бы, вероятно, просто полагался на MySQL (если вы его используете), он будет выполнять работу быстрее, чем PHP. Поскольку все 3 поля доступны из таблиц SQL (два непосредственно, другой из JOIN) - это на самом деле довольно простая задача.

В конечном итоге ваш метод senders() отправителя станет таким же тривиальным, как:

public function send()
{        
    $links = Link::whereNull('sent_at')->get();

    // In this example, we will group in PHP, not in MySQL
    $grouped = $links->groupBy(function ($item, $key) {
        return implode('.', [$item->email, $item->email_body, $item->email_subject]);
    });

    $grouped->toArray();

    foreach ($grouped as $group) {
        // We know all items in inside $group array got the same
        // email_body, email, email_subject values anyway!
        Mail::queue('emails/queued/reports', $group[0]->email_body, function ($message) use ($group) {
            $message
                ->to($group[0]->email)
                ->subject($group[0]->email_subject);
        });    
    }
}

Это еще не идеально, и я не тестировал этот код - я написал его прямо здесь, но он показывает предлагаемую концепцию.

Если вы не планируете получать миллионы записей - это, вероятно, достаточно хорошо. В противном случае вы могли бы перемещать создание ссылок в отдельное задание async.

Ответ 3

Предполагая, что вы используете версию 5.0, как насчет передачи этой начальной обработки в очередь?

приложение\Http\Контроллеры\LinkController.php

 // Accept request, validate $students

 // Send this work strait to the cue
 Bus::dispatch(
    new CreateActiveLinks($students));
 );

// return
return response()->json([
    'message' => 'Creating and sending links. This will take a while.'
]);

app\Console\Commands\CreateActiveLinks.php (задание в очереди)

class CreateActiveLinks extends Command implements SelfHandling, ShouldQueue {

    protected $studentIds;

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct($studentIds)
    {
        $this->studentIds = $studentIds;
    }

    /**
     * This part is executed in the queue after the
     * user got their response
     *
     * @return void
     */
    public function handle()
    {
        $students = Student::with('institutionContacts')
            ->whereIn('id', $studentIds)
            ->where('is_active', 1)
            ->get();

        foreach ($students as $student) {
            // Process and create records...

            $newLinks[] = $newLink->load('student');
        }

        // Emailer job would run like normal
        LinkEmailer::send($newLinks, ['subject' => $subject, 'body' => $body], 'mass');

        // Notify user or something...
    }
}

Команды очередей в Laravel 5.0

В 5.1 вперед они называются Jobs и работают немного по-другому.

Этот код не проверен, и я не уверен в вашей структуре приложения, поэтому, пожалуйста, не принимайте это как Евангелие. Это просто основано на работе, которую я сделал в своем приложении, когда сталкивался с подобными обстоятельствами. Возможно, это по крайней мере даст вам некоторые идеи. Если у вас действительно много записей, добавление метода chunk() в запрос класса CreateActiveLinks может оказаться полезным.

Ответ 4

Я выяснил, что создание Event/Listener и их очереди реализации намного проще. Все, что вам нужно - создать Event и Listener для процесса электронной почты (LinkEmailer), а затем реализовать интерфейс ShouldQueue, как указано в документации.

https://laravel.com/docs/5.1/events#queued-event-listeners