Pull to refresh
85.25
Rating
Badoo
Big Dating

Как в Badoo генерируются изображения для «шаринга» в соцсетях

Badoo corporate blog Website development *PHP *Programming *
Социальные сети — важный источник трафика. Нам выгодно, когда пользователи делятся контентом, и мы даем им такую возможность — у нас есть несколько видов контента, которым можно поделиться:

  • свой профиль;
  • чужой профиль (если его владелец это разрешил);
  • свой рейтинг, отражающий популярность пользователя на сайте;
  • награды, полученные пользователем за свои действия или действия других пользователей.

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



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

Генерация изображений


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



  1. HTTP-запрос на генерацию бейджа приходит на сервер Photocache, назначение которого — принимать на себя запросы, хранить у себя кэш ответов
  2. и тем самым снижать нагрузку на сервера, хранящие сами фотографии;
  3. nginx на Photocache ищет изображение в своем кэше, и если он есть, то отдает его клиенту. Тут появляется первое отличие бейджей: т.к. запросов сравнительно немного, здесь используется стандартный кэш nginx, а не самописный модуль, как при работе с обычными фотографиями;
  4. Если в кэше ничего нет, то запрос проксируется на Bphotos — сервер, на котором хранятся фото пользователя, бейдж которого мы генерируем;
  5. nginx на Bphotos передает запрос PHP-скрипту, который загружает все необходимые ресурсы, запускает генерацию и отдает готовое изображение. В этом заключается воторое отличие: обычные пользовательские фотографии nginx отдает без помощи каких бы то ни было php-скриптов;
  6. Photocache отдает полученное изображение бейджа клиенту и кэширует его.

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

Код языка в URL очень важен, потому что Badoo — проект, локализованный под несколько языков. В обычных запросах для получения лексем мы используем объект локализации, основанный на настройках текущего пользователя. В бейджах это не работает, т.к. примерно треть запросов приходит от ботов соцсетей, которых наша система трактует как неавторизованных пользователей и пытается угадать их язык по IP-адресу. Поначалу мы об этом не подумали и получалось так, что на нашем сайте или в приложении пользователь видел картинку с текстом на одном языке, добавлял ее в свой фейсбук и видел ее же, но с текстом на английском.

Код соцсети нужен, потому что у разных соцсетей есть разные предпочтения по размерам изображений. Например, для Facebook нужны картинки 1200х630, а для Instagram — 640x640. Если мы знаем, для кого мы генерируем изображение, то мы можем подстроиться под получателя:



Кроме всего этого, обычно мы оставляем в URL место под битовую маску модификаций, которая будет использоваться, если какой-то функционал в генераторе нужно включать лишь в некоторых случаях. Например, мы использовали это поле, когда Apple просил не распространять фотографии с рейтингом пользователей iOS-приложения. Тогда мы добавили флаг для скрытия рейтинга, что позволило, с одной стороны, не ссориться с Apple, а с другой — оставить уже имеющийся бейдж там, где это возможно. Можно обойтись и без этого поля, но тогда придется создавать новый формат URL под каждую модифицированную версию бейджа, что приведет к разрастанию этих форматов и со временем усложнит поддержку.

С точки зрения кода скрипт генерации изображений выглядит следующим образом:

  1. Есть единый контроллер, который принимает запросы от сервера, определяет по формату URL необходимый тип бейджа и создает пару объектов, один из которых — непосредственно генератор, создающий изображение бейджа, а второй — источник данных для генератора.
  2. В объект-источник передаются параметры из URL, по которым он понимает, какие ресурсы нужны для создания бейджа и загружает их.
  3. В объект-генератор «сеттится» объект-источник, что позволяет генератору заниматься только только сборкой изображения из уже загруженных ресурсов.
  4. Контроллер получает результат работы объекта-генератора и возвращает его с нужными заголовками.

Структура объектов-источников данных, как правило, сильно отличается в зависимости от типа бейджа, а объекты-генераторы имеют похожую структуру, поэтому есть абстрактный класс, от которого они наследуются. Выглядит он примерно так:

abstract class AbstractBadgeGenerator
{
    /**
     * Возвращает сгенерированное изображение бейджа либо false в случае неудачи
     * @return bool|\FastImageEditor
     */
    final public function getImage()
    {
        $this->startPinbaTimer('isAvailable');
        $is_available = $this->isAvailable();
        $this->stopPinbaTimer();

        if (!$is_available) {
            return false;
        }

        $this->startPinbaTimer('generate');
        $Result = $this->generate();
        $this->stopPinbaTimer();

        return $Result;
    }

    /**
     * Позволяет проверить, возможно ли сгенерировать бейдж по имеющимся ресурсам.
     * Основная идея — быстро отловить ситуацию, когда бейдж нельзя сформировать,
     * и не тратить в таком случае время на работу с изображениями
     * @return bool
     */
    public function isAvailable()
    {
        return true;
    }

    /**
     * Генерирует изображение бейджа или возвращает false, если сгенероровать его не получилось
     * @return bool|\FastImageEditor
     */
    abstract public function generate();

    // Некоторые вспомогательные методы
}

Самое главное тут — метод getimage, который «дергается» из контроллера. Если он не вернет объект изображения, то контроллер вернет ошибку 404. Вызовы методов проверки доступности и сборки изображения обложены Pinba-таймерами, причем мы подставляем в названия имена конкретных классов, что дает нам данные о времени, необходимом для генерации каждого типа бейджей. Кроме этого, мы стараемся обкладывать таймерами все манипуляции с изображениями в конкретных классах-генераторах и собираем в контроллере информацию по общему времени работы источника и генератора, после чего пишем ее в базу с помощью нашей системы сбора статистики (На Хабре можно найти видео с ее описанием).

При работе с изображениями мы используем очень ограниченное число операций: поворот на произвольный угол, resize и crop изображений, наложение слоёв (в том числе с прорачностью) и добавление текста. Для их выполнения мы используем библиотеку Leptonica, обертку для которой написал Антон Довгаль (старую версию можно найти у него на github-е). Именно ее выбрали несколько лет назад за то, что она работала быстрее конкурентов. Чтобы продемонстрировать это, я сделал небольшой сравнительный тест GD, ImageMagick и Leptonica.

Для каждой из библиотек я написал скрипт, который создает новое изображение размером 1000х1000, на которое накладывается другое изображение, загруженное с диска (аналог ресурса). Чтобы приблизить код к тому, что используется при генерации бейджей, я добавил еще несколько операций над загруженным изображением — оно будет уменьшено и повернуто. Результат манипуляций будет сохранен на диск в формате JPEG. Такой сценарий позволяет оценить скорость почти всех операций, которые мы используем при генерации бейджей. Сам код в статье я приводить не буду из-за его большого размера, но для интересующихся выложу исходники на GitHub.

Я сделал 100 последовательных запусков этих скриптов на нашем сервере и получил следующие результаты:



По результатам теста получилось, что в данном сценарии Leptonica оказалась практически вдвое быстрее и GD, и Imagemagick, которые показали близкое друг к другу общее время.
Конечно, в генерации реальных бейджей используется гораздо больше манипуляций с изображениями, чем в этом тесте, поэтому нужно не надеяться на то, что Leptonica и так работает быстро, а стараться минимизировать количество требуемых операций. Для этого мы стараемся проводить оптимизации на этапе подготовки ресурсов, объединяя как можно больше слоёв в одно изображение.

Тестирование результатов генерации


Постепенно бейджей становилось все больше и нам захотелось быть уверенными, что все бейджи корректно работают после внесения изменений в код или ресурсы. Для этого мы решили использовать функциональные тесты: мы создавали заглушки пользователя, его фотографий и наград, формировали URL бейджей и передавали их генератору. Главная сложность была именно в проверке получившихся результатов. Очевидный способ — сравнивать сгенерирированное в тесте изображение с имеющимся образцом — нам очень не нравился, т.к. он либо требовал ручной сборки всех возможных образцов бейджей (чье число доходило до 50) при любом изменении и лишал нас прелести автоматизации, либо не давал гарантии, что все бейджи работают, если мы брали только часть вариантов.

После некоторых размышлений и экспериментов мы стали использовать два упрощенных типа проверок:

  1. Базовая проверка результатов генерации: мы проверяем, что генератор вернул именно изображение ожидаемого размера и оно содержит несколько цветов в контрольных точках;
  2. Сравнение нескольких результатов генерации: мы генерируем в тесте URL на два бейджа, сходных друг с другом почти во всем, но отличающихся только одним параметром (кодом языка, выводимым текстом, наградой и т.д.) и ожидаем, что мы получим два изображения, отличающиеся друг от друга. Отличие мы определяем как наличие расхождения в цветах хотя бы в одной контрольной точке.

В обеих проверках фигурирует понятие контрольных точек. Под ним я имею в виду набор точек на изображении, получаемый по какому-то правилу. Конкретно мы разбиваем изображение на 10 равных частей по горизонтали и вертикали, в результате чего на пересечении линий получаем 81 контрольную точку, для которой собираем карту цветов с помощью подобного кода:

protected function getImageColorsMap($Image, $color_grid_size = self::COLOR_GRID_SIZE)
{
    $image_info = $Image->getInfo();
    $dx = ceil($image_info['width'] / $color_grid_size);
    $dy = ceil($image_info['height'] / $color_grid_size);

    $colors_map = [];
    for ($row = 1; $row < $color_grid_size; $row++) {
        for ($cell = 1; $cell < $color_grid_size; $cell++) {
            $x = $dx * $cell;
            $y = $dy * $row;

            $color                = $Image->getOnePixel($x, $y);
            $colors_map[$color][] = [$x, $y];
        }
    }

    return $colors_map;
}

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

Конечно, такие простые проверки не дают гарантии, что на бейджах будет показано именно то, что мы ожидаем. Но они позволяют быстро проверить, что все бейджи работают, и помогли нам выловить ряд багов до их попадания на продакшен, причем часть из них была допущена даже не в PHP-коде генерации, а уровнем ниже — в «сишной» библиотеке по работе с изображениями.

Проблемы с выводом текстов


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

Определение алфавита по тексту


Мы выводим тексты на разных языках. Некоторые языки используют алфавиты, отличные от кириллицы и латиницы, и не все шрифты их поддерживают. Поэтому важно понимать, какой алфавит нам нужен для вывода, чтобы использовать подходящий шрифт. Там, где мы выводим предопределённые лексемы, это сделать очень просто — мы знаем язык, на котором написан текст. Но кое-где мы выводим пользовательский текст (например, при «шаринге» профиля в Instagram на бейдже показавается имя пользователя) и не знаем, на каком языке он написан.

Эта проблема решилась достаточно легко, потому что найти принадлежность символа к группе можно прямо на сайте unicode.org. Все, что нам нужно было сделать — скачать таблицы и свести диапазоны групп символов в единую таблицу. Имея такую таблицу, можно проверить все символы в строке, получить список используемых групп и выбрать шрифт, поддерживающий все эти группы символов.

Главное, о чем нужно помнить: коды на сайте приводятся в UCS-4, а не в привычной многим UTF-8.

Подгонка текста под отведённые размеры


В бейджах используются тексты на разных языках, и одна и та же фраза на разных языках будет иметь разную длину (и потому требовать разного места). Для подгонки размеров текстов мы используем два алгоритма: подгонку размера шрифта и разбиение текста по строкам.

Подгонка размера шрифта. У нас есть некий «идеальный» размер шрифта и максимальная ширина, которую мы можем использовать под текст. Проверяем, помещается ли текст в эту ширину (например в GD для этого есть функции imagettfbbox и imageftbbox) и если да, то просто выводим текст. Если же текст занимает больше места, то уменьшаем размер шрифта и повторяем проверку. Код, реализующий выбор размера, выглядит следующим образом:

$font_size = $initial_font_size;
$max_x = 0;
while ($font_size > $min_font_size) {
    $text_size = $Image->getTextBox($font_size, $angle, $font, $text);
    $max_x = max($text_size[2], $text_size[4]);
    if ($max_width >= $max_x) {
        break;
    }
    $font_size -= 1;
}

Данный алгоритм хорош своей простотой, но использовать его можно только для небольшого разброса в длине выводимых текстов. Большие же тексты могут потребовать значительного уменьшения размера шрифта, что сделает их нечитаемыми. Для таких случаев мы используем разбиение по строкам.

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

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

$len = mb_strlen($text);
if ($len <= 2 * $this->max_line_size) {
    $ideal_line_len = ceil($len / 2) - 1;
    $lexems_list = $this->getTextLexems($text);
    $direct_order_lines = $this->getDirectOrderLines($lexems_list, $ideal_line_len);
    $reversed_order_lines = $this->getReversedOrderLines($lexems_list, $ideal_line_len);

    $delta_direct = $this->getLinesDelta($direct_order_lines);
    $delta_reversed = $this->getLinesDelta($reversed_order_lines);
    return ($delta_direct < $delta_reversed) ? $direct_order_lines : $reversed_order_lines;
}

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

Обработка текстов с написанием справа налево


Тексты, использующие написание справа налево (RTL-языки), нельзя выводить как есть — они будут совершенно нечитаемы. Связано это с тем, что у таких языков есть два порядка символов: логический, который используется при хранении, и визуальный, который мы видим.

За преобразование одного порядка в другой отвечает так называемый Unicode Bidirectional Algorithm (сокращенно — BIDI). Подробнее про это можно узнать на хабре (BIDI (unicode bidirectional algorithm)), сайте W3C (Visual vs. Logical Ordering of Text, Unicode Bidirectional Algorithm Basics) и unicode.org (Unicode Bidirectional Algorithm). Современные клиенты, работающие с Юникодом, содержат реализацию этого алгоритма, а при написании текстов на изображениях об этом приходится заботиться самостоятельно.

RTL-языков несколько, но конкретно наши пользователи используют только два — арабский и иврит, которые в сумме дают чуть меньше 1% от общего числа запросов на генерацию бейджей.

Проблема с ивритом решилась очень быстро: в PHP есть функция hebrev для преобразования логического текста в визуальный, а первый же комментарий на странице с документацией этой функции показывает, как ее использовать для текстов в UTF-8:

$visual_hebrew_text = iconv("ISO-8859-8", "UTF-8", hebrev(iconv("UTF-8", "ISO-8859-8", $logical_hebrew_text)));

С арабским языком все было сложнее. Нам повезло, что наш удаленный переводчик с арабского сталкивался с такой проблемой раньше и принимал участие в создании библиотеки Ar-PHP. Библиотека содержит много функционала, но последние пару лет не очень активно развивается. Ее сайт периодически недоступен, хотя код можно найти в форках на GitHub: 1, 2, 3. В оригинальной версии преобразование текста осуществляется следующим образом:

$ArabicGlyphs = new \I18N_Arabic_Glyphs();
$visual_arabic_text = $ArabicGlyphs->utf8Glyphs($logical_arabic_text, $max_characters_count);

Если обработать тексты именно так, то их можно выводить обычными функциями вроде imagettftext. Вот как это будет выглядеть:



  1. Кроме обработки есть еще пара нюансов, про которые нужно помнить при работе с RTL-текстами:
  2. При выводе текста с помощью функций графических библиотек указывается координата левой границы текста, в то время как в RTL, как правило, нужно знать правую. Поэтому перед самим выводом нужно предварительно вычислить ширину текста и уже потом, зная ее и координату правой границы текста, можно будет вычислить координату левой границы.
  3. Если вы выводите текст, разбитый на несколько строк, то нужно предварительно инвертировать их порядок. Мы разбиваем текст слева направо и получаем массив строк, в котором в первом элементе хранится текст, находящийся слева. Для RTL-текстов это будет конец, а не начало, и получится, что текст написан снизу вверх, что неправильно.

Заключение


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

Больше статей и материалов можно найти в нашем техблоге TechBadoo.

Виктор Пряжников, разработчик отдела Features
Tags:
Hubs:
Total votes 35: ↑31 and ↓4 +27
Views 19K
Comments Comments 22

Information

Founded
2006
Location
Россия
Website
badoo.com
Employees
501–1,000 employees
Registered
Representative
Yuliya Telezhkina