<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ПАТЕРН ЗАСТОСУНКУ

КонвеєрPipeline

Дані проходять послідовність кроків, і кожен крок отримує результат попереднього. Кроки не знають один про одного, а порядок задають в одному місці.

Laravel складність 1 з 3 6 хв читання
ГЛІФ
01 · ПРОБЛЕМА

Імпорт, де всі кроки в одному циклі

Сайт щогодини забирає вакансії з кількох фідів. Кожну треба почистити, відкинути ті, що вже є, і позначити, Laravel це чи Symfony. Усе робить один цикл на сто рядків, і щоб додати фільтр спаму, треба знайти правильне місце посередині й не зламати решту.

src/Hiring/Import/ImportVacancies.php
1final class ImportVacancies
2{
3 public function run(array $items): void
4 {
5 foreach ($items as $item) {
6 $item['title'] = trim(preg_replace('/\s+/', ' ', $item['title']));
1Очищення, перевірка дублікатів і теги перемішані в одному тілі циклу. Окремо жоден крок не перевірити.
7 $item['city'] = $this->cities->normalize($item['city'] ?? 'Віддалено');
8
9 if ($this->vacancies->existsByUrl($item['url'])) {
2Запит до бази на кожну вакансію. Пакетну перевірку тут не зробиш, бо цикл обробляє по одній.
10 continue;
11 }
12
13 $item['tags'] = [];
14 if (str_contains(strtolower($item['title']), 'laravel')) {
3Класифікацію хочуть перевикористати в ручному додаванні вакансії, але вона вшита в імпорт.
15 $item['tags'][] = 'laravel';
16 }
17
18 $this->vacancies->create($item);
19 }
20 }
21}
02 · ІДЕЯ РІШЕННЯ

Кожен крок окремо, порядок в одному місці

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
PIPELINEsend() · through() · then()then(){ }Фід вакансійNormalizeDedupeClassify:Vacancy[]
PIPELINEsend() · through() · then(){ }Фід вакансійNormalizeDedupeClassify:Vacancy[]
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • Pipeline (send() · through() · then()) містить: Normalize, Dedupe, Classify.
  • Фід вакансій використовує Pipeline.
  • Дані йдуть від Normalize до Dedupe.
  • Дані йдуть від Dedupe до Classify.
  • Classify створює Vacancy[].
03 · КОД

Запустіть і подивіться

PHP 8.5
1final class ImportVacancies
2{
3 public function run(array $items): void
4 {
5 foreach ($items as $item) {
6 $item['title'] = trim(preg_replace('/\s+/', ' ', $item['title']));
7 $item['city'] = $this->cities->normalize($item['city'] ?? 'Віддалено');
8
9 if ($this->vacancies->existsByUrl($item['url'])) {
10 continue;
11 }
12
13 $item['tags'] = [];
14 if (str_contains(strtolower($item['title']), 'laravel')) {
15 $item['tags'][] = 'laravel';
16 }
17
18 $this->vacancies->create($item);
19 }
20 }
21}
1<?php
2
31final class Normalize
4{
5 public function __invoke(array $items): array
6 {
7 return array_map(fn (array $item): array => [
8 ...$item,
9 'title' => trim(preg_replace('/\s+/', ' ', $item['title'])),
10 'city' => in_array(mb_strtolower($item['city']), ['kyiv', 'киев', 'київ'], true) ? 'Київ' : $item['city'],
11 ], $items);
12 }
13}
14
152final class Dedupe
16{
17 /** @param list<string> $known */
18 public function __construct(private array $known) {}
19
20 public function __invoke(array $items): array
21 {
22 $fresh = array_filter($items, fn (array $item): bool => ! in_array($item['url'], $this->known, true));
23
24 return array_values(array_column($fresh, null, 'url'));
25 }
26}
27
28final class Classify
29{
30 public function __invoke(array $items): array
31 {
32 return array_map(fn (array $item): array => [
33 ...$item,
34 'tags' => array_values(array_filter(
35 ['laravel', 'symfony'],
36 fn (string $tag): bool => str_contains(mb_strtolower($item['title']), $tag),
37 )),
38 ], $items);
39 }
40}
41
423function pipeline(array $data, callable ...$stages): array
43{
44 return array_reduce($stages, fn (array $carry, callable $stage): array => $stage($carry), $data);
45}
46
47$feed = [
48 ['title' => ' Senior Laravel Developer ', 'city' => 'Kyiv', 'url' => 'https://example.com/1'],
49 ['title' => 'Symfony Developer', 'city' => 'Львів', 'url' => 'https://example.com/2'],
50 ['title' => 'Symfony Developer', 'city' => 'Львів', 'url' => 'https://example.com/2'],
51 ['title' => 'PHP Team Lead', 'city' => 'Київ', 'url' => 'https://example.com/3'],
52];
53
544$vacancies = pipeline($feed, new Normalize(), new Dedupe(known: ['https://example.com/3']), new Classify());
55
56foreach ($vacancies as $vacancy) {
57 echo mb_str_pad($vacancy['title'], 26), mb_str_pad($vacancy['city'], 7), '[', implode(', ', $vacancy['tags']), "]\n";
58}
59
60echo 'У фіді ', count($feed), ', нових ', count($vacancies), "\n";
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–13

    Кожен крок отримує масив і повертає новий. Про сусідні кроки він нічого не знає.

  2. 2 рядки 15–26

    Dedupe відкидає і те, що вже є на сайті, і повтори всередині фіду. Тепер це одна пакетна перевірка, а не запит на кожну вакансію.

  3. 3 рядки 42–45

    Сам конвеєр це array_reduce: результат одного кроку стає входом наступного.

  4. 4 рядки 54–54

    Порядок кроків задано одним рядком. Фільтр спаму стане ще одним аргументом тут.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · У ВАШОМУ ФРЕЙМВОРКУ

Ви вже цим користуєтесь

LARAVEL

Illuminate\Pipeline

Pipeline::send() передає дані через список класів. Кожен отримує дані й $next і викликає $next($items), щоб передати їх далі. Класи Laravel бере з контейнера, а HTTP-запит проходить middleware через його нащадка Illuminate\Routing\Pipeline.

$vacancies = Pipeline::send($items)
    ->through([Normalize::class, Dedupe::class, Classify::class])
    ->thenReturn();
 
final class Dedupe
{
    public function handle(array $items, Closure $next): array
    {
        return $next($this->withoutKnown($items));
    }
}

Ви вже користуєтесь цим, коли вашу задачу можна описати як «дані проходять ці кроки по черзі», як-от імпорт, обробка завантаженого файлу чи фільтри пошуку.

SYMFONY

Теговані кроки й #[AutowireIterator]

Окремого класу Pipeline Symfony не має. Кроки позначають тегом через #[AutoconfigureTag] на інтерфейсі, контейнер збирає їх у #[AutowireIterator], а порядок задають priority в #[AsTaggedItem].

#[AutoconfigureTag('app.import_stage')]
interface ImportStage
{
    public function __invoke(array $items): array;
}
 
public function __construct(
    #[AutowireIterator('app.import_stage')] private iterable $stages,
) {}
 
foreach ($this->stages as $stage) {
    $items = $stage($items);
}

Ви вже користуєтесь цим, коли додаєте новий клас, що реалізує ImportStage, і він сам стає кроком конвеєра.

✓ ЯК ЦЕ ТЕСТУВАТИ

Кожен крок це чиста функція: масив на вході, масив на виході. Тестуйте його окремо, без бази й без інших кроків. Зібраний конвеєр перевірте одним тестом на реальному прикладі фіду.

it('drops vacancies that are already on the site', function () {
    $dedupe = new Dedupe(known: ['https://example.com/3']);
 
    $result = $dedupe([vacancy(url: 'https://example.com/3'), vacancy(url: 'https://example.com/4')]);
 
    expect(array_column($result, 'url'))->toBe(['https://example.com/4']);
});
05 · КОЛИ НЕ ТРЕБА

Два кроки без перспективи росту

Якщо кроків два й нових не буде, два виклики поспіль читаються легше за конвеєр. А коли має спрацювати лише один обробник, який візьметься за запит, вам потрібен ланцюжок обовʼязків.

$items = $this->normalize($items);
$this->vacancies->insertMany($items);
06 · СУСІДИ

Схожі на вигляд, різні за змістом

07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

У конвеєрі працюють усі кроки, і кожен отримує результат попереднього. У ланцюжку запит обробляє перша ланка, яка взялася, а решта його не бачать.

Не викликати $next і повернути свій результат. Тоді наступні кроки й колбек then() не виконаються. Так працює middleware, що повертає 403 до контролера.

Найпростіше, коли кроки лише перетворюють дані, а запис відбувається один раз у then(). Якщо кроки мусять писати, конвеєр загортають у транзакцію: у Laravel для цього є withinTransaction().