Русский
Русский
English
Статистика
Реклама

Блог компании joom

Автоматизируем локализацию макетов в Figma

29.12.2020 14:10:26 | Автор: admin
В один прекрасный момент наши дизайнеры решили, что пора обновить обложки
нашего приложения в Apple Store и Google Play. На всех 17 языках.

Это история про то, как нырнуть в незнакомый язык программирования, незнакомую платформу и незнакомую задачу, собрать много всего интересного, помочь коллегам и оставить след в open source community.

мемасик



обложки

После подготовки макетов в Figma пришло осознание страшного факта: мы должны поддержать 17 различных языков! Причем максимально качественно и согласованно, так что автоматические переводы через Google Translate (или даже сервис Crowdin) нам не подходили. Что ж, задача была поручена ответственным людям, и вот через некоторое время у нас появилась табличка Google Sheets с переводами всех фраз.

таблица с переводами

Первая попытка ручной замены текстов в макетах привела к следующему:

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

И задача резко обрела пугающий объем и сложность. Требовалась автоматизация.

Конечно же, хотелось бы воспользоваться готовым решением. Однако из рассмотренных (Crowdin, Localize, Yandex.Translate) ни одно нас не устроило, так как они не позволяют использовать кастомизированные переводы и полностью игнорируют форматирование (подсветку, выделения). А в идеале решение должно было давать практически готовый результат, не требующий вмешательства человека.

Как известно, подобные проблемы лучше всего решаются за чашечкой чая в компании коллег-разработчиков на уютной офисной кухне. Несмотря на то, что в нашей команде нет разработчиков, хорошо знакомых с Figma и ее возможностями, никто не брезгует TypeScript'ом, и все способны написать на нем плагин по документации. Даже Golang и Kotlin разработчики заднего конца. Более того, небольшой опыт у некоторых уже имелся.

Написание плагинов для Figma


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

Разработка плагина ведется исключительно в десктоп-версии Figma, которая по сути является обычным WebView, поэтому разработчику доступна консоль JavaScript и прочие привычные инструменты.

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

Автоматический перевод по словарю


Автозамена


Было решено за полчасика сделать PoC, который будет в состоянии просто подменить все тексты в соответствии с табличкой. Пока с максимально примитивным UI, конечно же.

Итак, для начала нам нужно загрузить словарь. Поскольку заморачиваться с подключением сторонних модулей на первом этапе не хотелось, решил выбрать самый простой формат, в который можно выгрузить имеющуюся табличку из Google Sheets. Между CSV и TSV выбор пал на TSV: в текстах могли присутствовать запятые, это усложнило бы парсинг.

Парсим табличку
type Dictionary = {    header: string[];    rows: string[][];};async function parseDictionary(serializedDictionary: string): Promise<Dictionary> {    const table = serializedDictionary.split('\n').map(line => line.split('\t').map(field => field.trim()));    if (table.length === 0) {        throw {error: 'no header in the dictionary'};    }    // в заголовке будут языковые коды    const header = table[0];    const expectedColumnCount = header.length;    const rows = table.slice(1, table.length);    console.log('Dictionary:', {header, rows});    rows.forEach((row, index) => {        if (row.length != expectedColumnCount) {            throw {error: 'row ' + (index + 2) + ' of the dictionary has ' + row.length + ' (not ' + expectedColumnCount + ') columns'};        }    });    return {header, rows};}

Строим словарь для выбранной пары языков
type Mapping = {    [source: string]: string;};async function getMapping(dictionary: Dictionary, sourceLanguage: string, targetLanguage: string): Promise<Mapping> {    const sourceColumnIndex = dictionary.header.indexOf(sourceLanguage);    if (sourceColumnIndex == -1) {        throw {error: sourceLanguage + ' not listed in [' + dictionary.header + ']'};    }    const targetColumnIndex = dictionary.header.indexOf(targetLanguage);    if (targetColumnIndex == -1) {        throw {error: targetLanguage + ' not listed in [' + dictionary.header + ']'};    }    const result: Mapping = {};    dictionary.rows.forEach(row => {        const sourceString = row[sourceColumnIndex];        const targetString = row[targetColumnIndex];        if (targetString.trim() !== '') {            if (sourceString in result) {                throw {error: 'multiple translations for `' + sourceString + '` in the dictionary'};            }            result[sourceString] = targetString;        }    });    // крайне удобный способ отладки в случае с Figma    console.log('Extracted mapping:', result);    return result;}

Подставляем переводы во все текстовые ноды по прямому совпадению
type Replacement = null | {    // в какой ноде заменяем текст    node: TextNode;    // на что заменяем    translation: string;};type ReplacementFailure = {    // в какой ноде произошла ошибка    nodeId: string;    // описание самой ошибки    error: string;};type ReplacementAttempt = Replacement | ReplacementFailure;// Settings получаются элементарно из UIasync function translateSelection(settings: Settings): Promise<void> {    const dictionary = await parseDictionary(settings.serializedDictionary);    const mapping = await getMapping(dictionary, settings.sourceLanguage, settings.targetLanguage);    await replaceAllTexts(mapping);}async function replaceAllTexts(mapping: Mapping): Promise<void> {    const textNodes = await findSelectedTextNodes();    let replacements = (await Promise.all(textNodes.map(node => computeReplacement(node, mapping)))).filter(r => r !== null);    let failures = replacements.filter(r => 'error' in r) as ReplacementFailure[];    if (failures.length > 0) {        console.log('Failures:', failures);        throw {error: 'found some untranslatable nodes', failures};    }    replacements.forEach(replaceText);}async function findSelectedTextNodes(): Promise<TextNode[]> {    const result: TextNode[] = [];    figma.currentPage.selection.forEach(root => {        if (root.type === 'TEXT') {            // либо в выделение попала текстовая нода            result.push(root as TextNode);        } else if ('findAll' in root) {            // либо фрейм/группа,            // тогда в ней можно найти все текстовые подноды встроенной функцией findAll            (root as ChildrenMixin).findAll(node => node.type === 'TEXT').forEach(node => result.push(node as TextNode));        }    });    return result;}async function computeReplacement(node: TextNode, mapping: Mapping): Promise<ReplacementAttempt> {    // текст ноды может содержать лишние пробелы и переносы слов,    // и это не должно влиять на возможность перевода,    // поэтому предварительно нормализуем строки    const content = normalizeContent(node.characters);    if (!(content in mapping)) {        // не нашли перевод? жаль        return {nodeId: node.id, error: 'No translation for `' + content + '`'};    }    const result: Replacement = {        node,        translation: mapping[content],    };    console.log('Replacement:', result);    return result;}function normalizeContent(content: string): string {    // интересные факты из жизни Unicode:    //   \u2028  разделитель строк (но не \n)    //   \u202F  невидимый разделитель пробелов (?)    // по-хорошему, стоит добавить и прочие разделители,    // но они на практике пока не встречались    return content.replace(/[\u000A\u00A0\u2028\u202F]/g, ' ').replace(/ +/g, ' ');}async function replaceText(replacement: Replacement): Promise<void> {    const {node, translation} = replacement;    // интересная особенность Figma:    // перед тем, как менять что-либо в текстовой ноде, нужно предварительно    // загрузить все шрифты, которые в ней используются    await loadFontsForNode(node);    node.characters = translation;}async function loadFontsForNode(node: TextNode): Promise<void> {    await Promise.all(Array.from({length: node.characters.length}, (_, k) => k).map(i => {        // очень забавный момент, конечно:        // Figma позволяет узнать свойства любой секции текста в ноде,        // при этом если в секции оно неоднородно (например, используется несколько шрифтов),        // то возвращается специальный объект mixed        return figma.loadFontAsync(node.getRangeFontName(i, i + 1) as FontName);    }));}

Вся эта конструкция довольно быстро заработала, что порадовало и вселило уверенность.

базовая реализация

Исключения


При первом же запуске было обнаружено множество текстов, не требующих переводов. Потому они и не фигурировали в исходной табличке.

исключения

Имена собственные, числа, валюты, какие-то символы, эмодзи Не беда. Достаточно задать исключения в виде списка регулярок.

Учитываем исключения
async function translateSelection(settings: Settings): Promise<void> {    const dictionary = await parseDictionary(settings.serializedDictionary);    const mapping = await getMapping(dictionary, settings.sourceLanguage, settings.targetLanguage);    // дополнительно учитываем список исключений    const exceptions = await parseExceptions(settings.serializedExceptions);    await replaceAllTexts(mapping, exceptions);}async function parseExceptions(serializedExceptions: string): Promise<RegExp[]> {    return serializedExceptions.split('\n').filter(pattern => pattern !== '').map(pattern => {        try {            return new RegExp(pattern);        } catch (_) {            throw {error: 'invalid regular expression `' + pattern + '`'};        }    });}async function replaceAllTexts(mapping: Mapping, exceptions: RegExp[]): Promise<void> {    const textNodes = await findSelectedTextNodes();    // пробрасываем список в функцию вычисления подстановки    let replacements = (await Promise.all(textNodes.map(node => computeReplacement(node, mapping, exceptions)))).filter(r => r !== null);    let failures = replacements.filter(r => 'error' in r) as ReplacementFailure[];    if (failures.length > 0) {        console.log('Failures:', failures);        throw {error: 'found some untranslatable nodes', failures};    }    replacements.forEach(replaceText);}async function computeReplacement(node: TextNode, mapping: Mapping, exceptions: RegExp[]): Promise<ReplacementAttempt> {    const content = normalizeContent(node.characters);    // если содержимое подходит под одну из регулярок    if (keepAsIs(content, exceptions)) {        // то говорим, что заменять текст в этой ноде не нужно совсем        return null;    }    if (!(content in mapping)) {        // не нашли перевод? жаль        return {nodeId: node.id, error: 'No translation for `' + content + '`'};    }    const result: Replacement = {        node,        translation: mapping[content],    };    console.log('Replacement:', result);    return result;}function keepAsIs(content: string, exceptions: RegExp[]): boolean {    for (let regex of exceptions) {        if (content.match(regex)) {            return true;        }    }    return false;};

Форматирование


Теперь не хватало лишь вишенки на торте.

автоперевод без форматирования

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

Итого.

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

    • Пробуем перевести все секции других стилей.
    • Пробуем найти переводы секций других стилей внутри общего перевода (перевода всего текста).
    • Считаем, что нашли основной стиль, если предыдущие два пункта завершились успешно.

  3. Подставляем сначала общий перевод и применяем к нему основной стиль.
  4. Для каждого вычисленного выделения в переводе применяем соответствующий ему стиль.

алгоритм

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

Разбиваем тексты на моностильные секции
type Style = {    // это поле будет хранить уникальный идентификатор    // так можно будет быстро сравнивать стили между собой    id: string;    fills: Paint[];    fillStyleId: string;    fontName: FontName;    fontSize: number;    letterSpacing: LetterSpacing;    lineHeight: LineHeight;    textDecoration: TextDecoration;    textStyleId: string;};type Section = {    // начало секции, включительно, индексация с 0    from: number;    // конец секции, не включительно, индексация с 0    to: number;    // стиль всей секции    style: Style;};function sliceIntoSections(node: TextNode, from: number = 0, to: number = node.characters.length): Section[] {    if (to == from) {        return [];    }    const style = getSectionStyle(node, from, to);    if (style !== figma.mixed) {        // это моностильная секция        return [{from, to, style}];    }    // разделяй и властвуй!    const center = Math.floor((from + to) / 2);    const leftSections = sliceIntoSections(node, from, center);    const rightSections = sliceIntoSections(node, center, to);    const lastLeftSection = leftSections[leftSections.length-1];    const firstRightSection = rightSections[0];    if (lastLeftSection.style.id === firstRightSection.style.id) {        firstRightSection.from = lastLeftSection.from;        leftSections.pop();    }    return leftSections.concat(rightSections);}function getSectionStyle(node: TextNode, from: number, to: number): Style | PluginAPI['mixed'] {    const fills = node.getRangeFills(from, to);    if (fills === figma.mixed) {        return figma.mixed;    }    const fillStyleId = node.getRangeFillStyleId(from, to);    if (fillStyleId === figma.mixed) {        return figma.mixed;    }    const fontName = node.getRangeFontName(from, to);    if (fontName === figma.mixed) {        return figma.mixed;    }    const fontSize = node.getRangeFontSize(from, to);    if (fontSize === figma.mixed) {        return figma.mixed;    }    const letterSpacing = node.getRangeLetterSpacing(from, to);    if (letterSpacing === figma.mixed) {        return figma.mixed;    }    const lineHeight = node.getRangeLineHeight(from, to);    if (lineHeight === figma.mixed) {        return figma.mixed;    }    const textDecoration = node.getRangeTextDecoration(from, to);    if (textDecoration === figma.mixed) {        return figma.mixed;    }    const textStyleId = node.getRangeTextStyleId(from, to);    if (textStyleId === figma.mixed) {        return figma.mixed;    }    const parameters = {        fills,        fillStyleId,        fontName,        fontSize,        letterSpacing,        lineHeight,        textDecoration,        textStyleId,    };    return {        id: JSON.stringify(parameters),        ...parameters,    };}

Сохраняем форматирование при подстановке текста
// придется немного расширить нашу структуру подстановкиtype Replacement = null | {    node: TextNode;    translation: string;    // тот самый "основной" стиль    baseStyle: Style;    // разметка translation на секции со стилями, отличными от основного    sections: Section[];};async function computeReplacement(node: TextNode, mapping: Mapping, exceptions: RegExp[]): Promise<ReplacementAttempt> {    const content = normalizeContent(node.characters);    if (keepAsIs(content, exceptions)) {        return null;    }    if (!(content in mapping)) {        return {nodeId: node.id, error: 'No translation for `' + content + '`'};    }    // режем на моностильные секции    const sections = sliceIntoSections(node);    // готовим результат    const result: Replacement = {        node,        translation: mapping[content],        baseStyle: null,        sections: [],    };    // формируем лог ошибок на случай безуспешных поисков    const errorLog = [        'Cannot determine a base style for `' + content + '`',        'Split into ' + sections.length + ' sections',    ];    // собираем множество задействованных стилей    const styles = [];    const styleIds = new Set<string>();    sections.forEach(({from, to, style}) => {        if (!styleIds.has(style.id)) {            styleIds.add(style.id);            styles.push({humanId: from + '-' + to, ...style});        }    });    for (let baseStyleCandidate of styles) {        const prelude = 'Style ' + baseStyleCandidate.humanId + ' is not base: ';        let ok = true;        // будем попутно собирать разметку для неосновных стилей        result.sections.length = 0;        for (let {from, to, style} of sections) {            if (style.id === baseStyleCandidate.id) {                continue;            }            const sectionContent = normalizeContent(node.characters.slice(from, to));            let sectionTranslation = sectionContent;            // либо мы должны уметь переводить секцию,            // либо она должна входить в список исключений            if (sectionContent in mapping) {                sectionTranslation = mapping[sectionContent];            } else if (!keepAsIs(sectionContent, exceptions)) {                errorLog.push(prelude + 'no translation for `' + sectionContent + '`');                ok = false;                break;            }            const index = result.translation.indexOf(sectionTranslation);            if (index == -1) {                errorLog.push(prelude + '`' + sectionTranslation + '` not found within `' + result.translation + '`');                ok = false;                break;            }            if (result.translation.indexOf(sectionTranslation, index + 1) != -1) {                errorLog.push(prelude + 'found multiple occurrencies of `' + sectionTranslation + '` within `' + result.translation + '`');                ok = false;                break;            }            result.sections.push({from: index, to: index + sectionTranslation.length, style});        }        if (ok) {            // нашли основной стиль!            result.baseStyle = baseStyleCandidate;            break;        }    }    if (result.baseStyle === null) {        return {nodeId: node.id, error: errorLog.join('. ')};    }    console.log('Replacement:', result);    return result;}async function replaceText(replacement: Replacement): Promise<void> {    // нет необходимости вызывать загрузку для каждого символа,    // когда мы уже получили разбиение по стилям    await loadFontsForReplacement(replacement);    const {node, translation, baseStyle, sections} = replacement;    node.characters = translation;    if (sections.length > 0) {        setSectionStyle(node, 0, translation.length, baseStyle);        for (let {from, to, style} of sections) {            setSectionStyle(node, from, to, style);        }    }}async function loadFontsForReplacement(replacement: Replacement): Promise<void> {    await figma.loadFontAsync(replacement.baseStyle.fontName);    await Promise.all(replacement.sections.map(({style}) => figma.loadFontAsync(style.fontName)));}function setSectionStyle(node: TextNode, from: number, to: number, style: Style): void {    node.setRangeTextStyleId(from, to, style.textStyleId);    node.setRangeFills(from, to, style.fills);    node.setRangeFillStyleId(from, to, style.fillStyleId);    node.setRangeFontName(from, to, style.fontName);    node.setRangeFontSize(from, to, style.fontSize);    node.setRangeLetterSpacing(from, to, style.letterSpacing);    node.setRangeLineHeight(from, to, style.lineHeight);    node.setRangeTextDecoration(from, to, style.textDecoration);}

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

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

автоперевод с форматированием

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

Сообщения об ошибках и отладка


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

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

if (message.type === 'focus-node') {    // максимально приближаемся    figma.viewport.zoom = 1000.0;    // перемещаем viewport в положение, где нода видна целиком    figma.viewport.scrollAndZoomIntoView([figma.getNodeById(message.id)]);    // немного отдаляем для комфортного восприятия    figma.viewport.zoom = 0.75 * figma.viewport.zoom;}


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

Предлагаем доперевести некоторые фразы
function suggest(node: TextNode, content: string, sections: Section[], mapping: Mapping, exceptions: RegExp[]): string[] {    const n = content.length;    const styleScores = new Map<string, number>();    for (let {from, to, style} of sections) {        styleScores.set(style.id, n + to - from + (styleScores.get(style.id) || 0));    }    let suggestedBaseStyleId: string = null;    let suggestedBaseStyleScore = 0;    for (let [styleId, styleScore] of styleScores) {        if (styleScore > suggestedBaseStyleScore) {            suggestedBaseStyleId = styleId;            suggestedBaseStyleScore = styleScore;        }    }    const result: string[] = [];    if (!(content in mapping)) {        result.push(content);    }    for (let {from, to, style} of sections) {        if (style.id === suggestedBaseStyleId) {            continue;        }        const sectionContent = normalizeContent(node.characters.slice(from, to));        if (!keepAsIs(sectionContent, exceptions) && !(sectionContent in mapping)) {            result.push(sectionContent);        }    }    return result;}

ошибки


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

Да, нашлось несколько плагинов для интерактивного ввода арабского текста и один разломанный плагин, пытающийся разворачивать RTL тексты, но все это не решило нашу проблему. Хотя бы потому, что при этом напрочь слетало форматирование.

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

Что может быть сложного? Давайте просто развернем текст посимвольно.

наивный подход к RTL

Так. Теперь все рендерится справа налево, но Figma вставляет по привычке автопереносы строк так, словно это LTR текст. В итоге строки нужно читать снизу вверх. Беда. Факт расстановки автопереносов Figma не отражает в содержимом ноды, да и вообще, никак нигде не отражает Разве что Если нода имеет адаптивную высоту, то при возникновении автопереноса высота ноды изменяется!

Итак, план такой.

  1. Разворачиваем перевод, как раньше.
  2. Создаем невидимую текстовую ноду с адаптивной высотой и той же шириной (если, конечно, ширина исходной ноды тоже фиксирована).
  3. Разбиваем развернутый перевод на слова и начинаем добавлять их в скрытую ноду по одному (справа налево). Если вдруг высота скрытой ноды изменилась, откатываемся, вставляем разделитель строк и продолжаем.
  4. Получившееся разбиение на строки просто конкатенируем.
  5. Удаляем временную ноду.
  6. Инвертируем выравнивание текста (если было по левому краю, то делаем по правому, и наоборот).

Большая сложность заключается в том, что в процессе нужно внимательно следить за тем, как видоизменяются секции.

К тому же, на ровном месте возникло несколько подводных камней.

  • Большинство шрифтов из коробки не поддерживают арабский и иврит. Пришлось искать похожие по начертанию (а их не так много).
  • Нельзя просто брать и разворачивать текст, если он содержит, например, латиницу, цифры и т.п. Эти части должны писаться слева направо даже в RTL тексте.
  • От стиля зависит размер слов, так что внутри скрытой ноды текст нужно правильно форматировать.
  • Первое слово в очередной строке может не влезть по ширине, тогда это ошибка, о которой нужно сообщить.
  • Нашлись слова в арабском, которые в отсутствие пробела после них отображались в Figma некорректно.

Полный код поддержки RTL языков я здесь приводить не стану он доступен в репозитории.

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

автоперевод на арабский

На десерт.

  • Правильное обращение арабского текста куда более сложная задача, чем кажется, если учесть всевозможные примеси из других языков и специальные лигатуры.
  • Интерфейсы на RTL языках необходимо отразить по горизонтали, что пока приходится делать вручную, хотя потенциально и это автоматизируется.
  • Попутно нами был обнаружен и зарепорчен баг в Figma, из-за которого при добавлении латиницы в арабский текст лигатуры развязывались.

Конвертирование валют


Так уж случилось, что мы маркетплейс, и у нас есть товары. И товары имеют цену. Можно, конечно, отдать с десяток текстов вида 1 234 переводчикам и надеяться, что они правильно локализуют цены, использовав верные разделители, верные знаки валют и верное количество цифр после запятой Но зачем, если можно все это проделать автоматически, один раз настроив несложный конвертер?

Конвертируем валюты автоматически
type Currency = {    // уникальный код, у нас для удобства совпадает с языковым    code: string;    // схема, в которой 123 нужно заменить на нужную сумму в нужном формате, вроде "$123"    schema: string;    // разделитель тысяч    digitGroupSeparator: string;    // разделитель дробной части (обычно точка или запятая)    decimalSeparator: string;    // кол-во десятичных знаков в дробной части    precision: number;    // курс к некоторой базовой валюте (должна быть общей для всего конфига)    rate: number;};async function convertCurrencyInSelection(settings: Settings): Promise<void> {    const currencies = parseCurrencies(settings.serializedCurrencies);    console.log('Currencies:', currencies);    const sourceCurrency = currencies.filter(currency => currency.code === settings.sourceCurrencyCode)[0];    if (sourceCurrency === undefined) {        throw {error: 'unknown currency code `' + settings.sourceCurrencyCode + '`'};    }    const targetCurrency = currencies.filter(currency => currency.code === settings.targetCurrencyCode)[0];    if (targetCurrency === undefined) {        throw {error: 'unknown currency code `' + settings.targetCurrencyCode + '`'};    }    await replaceCurrencyInAllTexts(sourceCurrency, targetCurrency);}function parseCurrencies(serializedCurrencies: string): Currency[] {    const codeSet = new Set<string>();    return JSON.parse(serializedCurrencies).map((x: any, index: number) => {        const currency: Currency = {            code: null,            schema: null,            digitGroupSeparator: null,            decimalSeparator: null,            precision: null,            rate: null,        };        Object.keys(currency).forEach(key => {            if (x[key] === undefined || x[key] === null) {                throw {error: 'invalid currency definition: no `' + key + '` in entry #' + (index + 1)};            }            if (key === 'schema' && x[key].indexOf('123') === -1) {                throw {error: 'schema in entry #' + (index + 1) + ' should contain `123`'};            }            if (key === 'rate' && x[key] <= 0) {                throw {error: 'non-positive rate in entry #' + (index + 1)};            }            currency[key] = x[key];        });        if (currency.precision > 0 && currency.decimalSeparator === '') {            throw {error: 'entry #' + (index + 1) + ' must have a non-empty decimal separator'};        }        if (codeSet.has(currency.code)) {            throw {error: 'multiple entries for `' + currency.code + '`'};        }        codeSet.add(currency.code);        return currency;    });}async function replaceCurrencyInAllTexts(sourceCurrency: Currency, targetCurrency: Currency): Promise<void> {    const textNodes = await findSelectedTextNodes();    const escapedSchema = escapeForRegExp(sourceCurrency.schema);    const escapedDigitGroupSeparator = escapeForRegExp(sourceCurrency.digitGroupSeparator);    const escapedDecimalSeparator = escapeForRegExp(sourceCurrency.decimalSeparator);    const sourceValueRegExpString = '((?:[0-9]|' + escapedDigitGroupSeparator + ')+' + escapedDecimalSeparator + '[0-9]{' + sourceCurrency.precision + '})';    const sourceRegExp = new RegExp('^' + escapedSchema.replace('123', sourceValueRegExpString) + '$');    console.log('Source regular expression:', sourceRegExp.toString());    await Promise.all(textNodes.map(async node => {        const content = node.characters;        const match = content.match(sourceRegExp);        if (match !== null && match[1] !== null && match[1] !== undefined) {            const style = getSectionStyle(node, 0, node.characters.length);            if (style === figma.mixed) {                throw {error: 'node `' + content + '` has a mixed style'};            }            let sourceValueString = match[1].replace(new RegExp(escapedDigitGroupSeparator, 'g'), '');            if (sourceCurrency.decimalSeparator !== '') {                sourceValueString = sourceValueString.replace(sourceCurrency.decimalSeparator, '.');            }            const sourceValue = parseFloat(sourceValueString);            const targetValue = sourceValue * targetCurrency.rate / sourceCurrency.rate;            const truncatedTargetValue = Math.trunc(targetValue);            const targetValueFraction = targetValue - truncatedTargetValue;            const targetValueString = (                truncatedTargetValue.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,').replace(/,/g, targetCurrency.digitGroupSeparator) +                targetCurrency.decimalSeparator +                targetValueFraction.toFixed(targetCurrency.precision).slice(2)            );            await figma.loadFontAsync(style.fontName);            node.characters = targetCurrency.schema.replace('123', targetValueString);        }    }));}function escapeForRegExp(s: string): string {    return s.replace(/([[\^$.|?*+()])/g, '\\$1');}

конвертация валют

Публикация


Перед публикацией плагина был проделан ряд улучшений UX. Например, настройки в интерфейсе плагина сохранялись в localStorage (да, Figmа такое умеет!). Это позволяет не загружать словари каждый раз и быстро выполнять переводы на несколько языков. А чтобы интерфейс не зависал во время активных операций с документом, по рекомендации самой же Figma, была ограничена интенсивность асинхронных параллельных операций с помощью простой самописной функции mapWithRateLimit. На сдачу в плагин была добавлена возможность заменять шрифты.

Процедура публикации плагина заняла больше двух недель, но никаких проблем не возникло.

В заключение


Без плагина не обошлась и подготовка к Новому году.

новый год

Возможно, когда-нибудь мы расскажем, как в рамках той же задачи научились выгружать из Figma весь документ в виде одного структурированного архива из картинок.

Хочется поблагодарить Аню Ноджак, дизайн-директора Joom, за веру в мои силы. Очень рекомендую почитать ее взгляд на задачу.

Ссылки


Исходный код плагина Static Localizer на GitHub
Плагин Static Localizer на сайте Figma
Подробнее..

Как построить надежное приложение на базе Event sourcing?

15.09.2020 14:04:30 | Автор: admin

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



The Project


Проект JoomAds предлагает продавцам инструменты продвижения товаров в Joom. Для продавца процесс продвижения начинается с создания рекламной кампании, которая состоит из:


  • списка товаров, продажи которых он хочет повысить;
  • бюджета того, сколько денег он готов потратить на продвижение товаров;
  • ставок на продукты того, какую долю от цены каждого товара продавец готов уступить за оптимизацию продаж или просмотров;
  • параметров таргетирования например, таргетирование на ключевые слова в поиске на манер AdWords.

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



Рис. 1


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


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


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



Рис. 2
JoomAds API выступает в роли медиатора данной микросервисной системы.


Pure Microservices equals Problems


Вы можете задать справедливый вопрос: Есть ли проблема в таком количестве внешних коммуникаций? Всего три вызова....


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


Быстродействие


Любые внешние коммуникации (например, поход за метаданными товара в Inventory) это дополнительные накладные расходы, увеличивающие время ответа медиатора. Такие расходы не проблема на ранних этапах развития проекта: последовательные походы в JoomAds Campaign, Inventory и хранилище аналитики вносили небольшой вклад во время ответа JoomAds API, т.к. количество рекламируемых товаров было небольшим, а рекламная выдача присутствовала только в разделе Лучшее.


Однако с ростом количества товаров в рекламе и подключением трафика других разделов Joom, 95-й перцентиль времени JoomAds API достиг сотен миллисекунд вместо желаемых десятков. Такая ситуация является результатом несоответствия текущих условий эксплуатации исходным требованиям, использованным при разработке отдельных компонентов.


Например, поиск товаров Inventory не был рассчитан на высокие частоты запросов, но он нам нужен именно таким.


Отказоустойчивость


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


Отказ любой зависимости JoomAds API ведет к некорректной или неповторяемой рекламной выдаче, либо к ее полному отсутствию.


Сложность поддержки


Микросервисная архитектура позволяет снизить сложность поддержки узкоспециализированных приложений, таких как Inventory, но значительно усложняет разработку приложений-медиаторов, таких как JoomAds API.


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


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


Эти наблюдения привели нас к осознанию необходимости изменений. Новый JoomAds должен генерировать экономически эффективную и согласованную рекламную выдачу при отказе JoomAds Campaign, Inventory или хранилища аналитики, а также иметь предсказуемое быстродействие и отвечать на входящие запросы быстрее 100 мс в 95% случаев.


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


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


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


Monolith over microservices (kind of)


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


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


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


Materialization


Совместить лучшие качества микросервисов и монолитной архитектуры нам позволил подход, именуемый Materialized View. Материализованные представления часто встречаются в реализациях СУБД. Основной целью их внедрения является оптимизация доступа к данным на чтение при выполнении конкретных запросов.


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


Например, для запросов состояния продукта по его идентификатору (см. Рис. 3) или запросов состояния множества продуктов по идентификатору рекламной кампании.



Рис. 3


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


Но как обновлять данные Materialized View?


Data Sourcing


Разработку стратегии обновления данных мы начали с формализации требований, которым она должна соответствовать:


  • Изолировать клиентскую сторону от проблем доступа к внешним ресурсам.
  • Учитывать возможность высокого времени ответа компонентов инфраструктуры JoomAds.
  • Предоставлять механизм восстановления на случай утраты текущего состояния Materialized View.

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


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


Например, мы не запрашиваем метаданные товара в Inventory, а храним их локальную копию, которая обновляется внешним событием, которое нам отправляет сервис Inventory при каждом редактировании метаданных товара. Эта схема более выгодна сервису Inventory, т.к. операция отправки события о редактировании товара относительно дешевая, а сами изменения метаданных происходят значительно реже, чем их чтения со стороны JoomAds.


Обработка набора событий конкретного товара в порядке их поступления дает одинаковый результат при идентичном нулевом состоянии это свойство отлично подходит для реализации плана восстановления Materialized View в случае нештатных ситуаций.


Event Sourcing


Описанный в предыдущем разделе способ управления данными известен под названием Event Sourcing. Он предполагает наличие объектов-событий, содержащих информацию об изменениях в системе с возможностью установления хронологического порядка их наступления, благодаря чему мы можем восстановить состояние системы на определенный момент времени.


В результате адаптации Event Sourcing подхода в инфраструктуре JoomAds появились три новых компонента: хранилище материализованного представления (MAT View Storage), конвейер материализации (Materialization Pipeline), а так же конвейер ранжирования (Ranking Pipeline), реализующий поточное вычисление потоварных score'ов ранжирования (см. Рис. 4).



Рис. 4


Discussion, Technologies


Materialized View и Event Sourcing позволили нам решить основные проблемы ранней архитектуры проекта JoomAds.


Специализированные Materialized View значительно повысили надежность и быстродействие клиентских запросов. Обновление данных с использованием Event Sourcing подхода повысило надежность коммуникации с внешними сервисами, предоставило инструменты контроля консистентности данных и позволило избавиться от неэффективных запросов к внешним ресурсам.


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


В нашем случае MAT View целиком хранится в одной таблице Cassandra: добавление колонок в таблицы Cassandra безболезненная операция, удаление MAT View осуществляется удалением таблицы. Таким образом, крайне важно выбрать удачное хранилище для реализации Materialized View в вашем проекте.


Event Sourcing предъявляет серьезные требования к своим пользователям. Генерация событий изменения данных во всех интересующих подсистемах в заданном формате с возможностью установления хронологического порядка следования это сложная организационно-техническая задача, которую крайне трудно реализовать в середине жизненного цикла ПО. Можно назвать удачей, что хранилище данных Inventory уже имело функцию генерации событий на обновление метаданных продуктов.


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


Вместо этого мы воспользовались популярными open-source решениями, развивающимися при участии Apache Software Foundation: Apache Kafka и Apache Flink.


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


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


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


Takeaway


Обмен данными на базе событий в совокупности с поточной обработкой позволяют преодолеть несоответствие контрактов различных систем, повысить контроль над отказоустойчивостью и производительностью, но требуют серьезного анализа компромиссов. Кроме того, внедрение event sourcing подхода лучше всего подходит системам, находящимся на ранних стадиях своего развития.


P.S. Этот пост был впервые опубликован в блоге Joom на vc, вы могли его встречать там. Так делать можно.

Подробнее..

Делаем поиск в веб-приложении с нуля

05.11.2020 18:21:17 | Автор: admin
В статье Делаем современное веб-приложение с нуля я рассказал в общих чертах, как выглядит архитектура современных высоконагруженных веб-приложений, и собрал для демонстрации простейшую реализацию такой архитектуры на стеке из нескольких предельно популярных и простых технологий и фреймворков. Мы построили single page application с server side rendering, поддерживающее просмотр неких карточек, набранных в Markdown, и навигацию между ними.

В этой статье я затрону чуть более сложную и интересную (как минимум мне, разработчику команды поиска) тему: полнотекстовый поиск. Мы добавим в наш контейнерный рай ноду Elasticsearch, научимся строить индекс и делать поиск по контенту, взяв в качестве тестовых данных описания пяти тысяч фильмов из TMDB 5000 Movie Dataset. Также мы научимся делать поисковые фильтры и копнём совсем немножко в сторону ранжирования.




Инфраструктура: Elasticsearch


Elasticsearch популярное хранилище документов, умеющее строить полнотекстовые индексы и, как правило, используемое именно как поисковый движок. Elasticsearch добавляет к движку Apache Lucene, на котором он основан, шардирование, репликацию, удобный JSON API и ещё миллион мелочей, которые сделали его одним из самых популярных решений для полнотекстового поиска.

Давайте добавим одну ноду Elasticsearch в наш docker-compose.yml:

services:  ...  elasticsearch:    image: "elasticsearch:7.5.1"    environment:      - discovery.type=single-node    ports:      - "9200:9200"  ...


Переменная окружения discovery.type=single-node подсказывает Elasticsearch, что надо готовиться к работе в одиночку, а не искать другие ноды и объединяться с ними в кластер (таково поведение по умолчанию).

Обратите внимание, что мы публикуем 9200 порт наружу, хотя наше приложение ходит в него внутри сети, создаваемой docker-compose. Это исключительно для отладки: так мы сможем обращаться в Elasticsearch напрямую из терминала (до тех пор, пока не придумаем более умный способ об этом ниже).

Добавить клиент Elasticsearch в наш вайринг не составит труда благо, Elastic предоставляет минималистичный Python-клиент.

Индексация


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

Теперь же перед нами стоит обратная задача по содержимому (или его фрагментам) получить идентификаторы карточек. Стало быть, нам нужен обратный индекс. Для него-то нам и пригодится Elasticsearch!

Общая схема построения индекса обычно выглядит как-то так.
  1. Создаём новый пустой индекс с уникальным именем, конфигурируем его как нам нужно.
  2. Обходим все наши сущности в базе и кладём их в новый индекс.
  3. Переключаем продакшн, чтобы все запросы начали ходить в новый индекс.
  4. Удаляем старый индекс. Тут по желанию вы вполне можете захотеть хранить несколько последних индексов, чтобы, например, удобнее было отлаживать какие-то проблемы.


Давайте создадим скелет индексатора и потом разберёмся подробнее с каждым шагом.

import datetimefrom elasticsearch import Elasticsearch, NotFoundErrorfrom backend.storage.card import Card, CardDAOclass Indexer(object):    def __init__(self, elasticsearch_client: Elasticsearch, card_dao: CardDAO, cards_index_alias: str):        self.elasticsearch_client = elasticsearch_client        self.card_dao = card_dao        self.cards_index_alias = cards_index_alias    def build_new_cards_index(self) -> str:        # Построение нового индекса.        # Сначала придумываем для индекса оригинальное название.        index_name = "cards-" + datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")        # Создаём пустой индекс.         # Здесь мы укажем настройки и опишем схему данных.        self.create_empty_cards_index(index_name)        # Кладём в индекс все наши карточки одну за другой.        # В настоящем проекте вы очень скоро захотите         # переписать это на работу в пакетном режиме.        for card in self.card_dao.get_all():            self.put_card_into_index(card, index_name)        return index_name    def create_empty_cards_index(self, index_name):        ...     def put_card_into_index(self, card: Card, index_name: str):        ...    def switch_current_cards_index(self, new_index_name: str):        ... 


Индексация: создаём индекс


Индекс в Elasticsearch создаётся простым PUT-запросом в /имя-индекса или, в случае использования Python-клиента (нашем случае), вызовом

elasticsearch_client.indices.create(index_name, {    ...})


Тело запроса может содержать три поля.

  • Описание алиасов ("aliases": ...). Система алиасов позволяет держать знание о том, какой индекс сейчас актуальный, на стороне Elasticsearch; мы поговорим про неё ниже.
  • Настройки ("settings": ...). Когда мы будем большими дядями с настоящим продакшном, мы сможем сконфигурировать здесь репликацию, шардирование и другие радости SRE.
  • Схема данных ("mappings": ...). Здесь мы можем указать, какого типа какие поля в документах, которые мы будем индексировать, для каких из этих полей нужны обратные индексы, по каким должны быть поддержаны агрегации и так далее.


Сейчас нас интересует только схема, и у нас она очень простая:

{    "mappings": {        "properties": {            "name": {                "type": "text",                "analyzer": "english"            },            "text": {                "type": "text",                "analyzer": "english"            },            "tags": {                "type": "keyword",                "fields": {                    "text": {                        "type": "text",                        "analyzer": "english"                    }                }            }        }    }}


Мы пометили поля name и text как текстовые на английском языке. Анализатор это сущность в Elasticsearch, которая обрабатывает текст перед сохранением в индекс. В случае english анализатора текст будет разбит на токены по границам слов (подробности), после чего отдельные токены будут лемматизированы по правилам английского языка (например, слово trees упростится до tree), слишком общие леммы (вроде the) будут удалены и оставшиеся леммы будут положены в обратный индекс.

С полем tags чуть-чуть сложнее. Тип keyword предполагает, что значения этого поля некие строковые константы, которые не надо обрабатывать анализатором; обратный индекс будет построен по их сырым значениям без токенизации и лемматизации. Зато Elasticsearch создаст специальные структуры данных, чтобы по значениям этого поля можно было считать агрегации (например, чтобы одновременно с поиском можно было узнать, какие теги встречались в документах, удовлетворяющих поисковому запросу, и в каком количестве). Это очень удобно для полей, которые по сути enum; мы воспользуемся этой фичей, чтобы сделать клёвые поисковые фильтры.

Но чтобы по тексту тегов можно было искать и текстовым поиском тоже, мы добавляем к нему подполе "text", настроенное по аналогии с name и text выше по существу это означает, что Elasticsearch во всех приходящих ему документах будет создавать ещё одно виртуальное поле под названием tags.text, в которое будет копировать содержимое tags, но индексировать его по другим правилам.

Индексация: наполняем индекс


Для индексации документа достаточно сделать PUT-запрос в /имя-индекса/_create/id-документа или, при использовании Python-клиента, просто вызвать нужный метод. Наша реализация будет выглядеть так:

    def put_card_into_index(self, card: Card, index_name: str):        self.elasticsearch_client.create(index_name, card.id, {            "name": card.name,            "text": card.markdown,            "tags": card.tags,        })


Обратите внимание на поле tags. Хотя мы описали его как содержащее keyword, мы отправляем не одну строку, а список строк. Elasticsearch поддерживает такое; наш документ будет находиться по любому из значений.

Индексация: переключаем индекс


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

Алиас это указатель на ноль или более индексов. API Elasticsearch позволяет использовать имя алиаса вместо имени индекса при поиске (POST /имя-алиаса/_search вместо POST /имя-индекса/_search); в таком случае Elasticsearch будет искать по всем индексам, на которые указывает алиас.

Мы заведём алиас под названием cards, который всегда будет указывать на актуальный индекс. Соответственно, переключение на актуальный индекс после завершения построения будет выглядеть так:

    def switch_current_cards_index(self, new_index_name: str):        try:            # Нужно удалить ссылку на старый индекс, если она есть.            remove_actions = [                {                    "remove": {                        "index": index_name,                         "alias": self.cards_index_alias,                    }                }                for index_name in self.elasticsearch_client.indices.get_alias(name=self.cards_index_alias)            ]        except NotFoundError:            # Ого, старого индекса-то и не существует вовсе.            # Наверное, мы впервые запустили индексацию.            remove_actions = []        # Одним махом удаляем ссылку на старый индекс         # и добавляем ссылку на новый.        self.elasticsearch_client.indices.update_aliases({            "actions": remove_actions + [{                "add": {                    "index": new_index_name,                     "alias": self.cards_index_alias,                }            }]        })


Я не стану подробнее останавливаться на alias API; все подробности можно посмотреть в документации.

Здесь надо сделать ремарку, что в реальном высоконагруженном сервисе такое переключение может быть довольно болезненным и может иметь смысл сделать предварительный прогрев нагрузить новый индекс каким-то пулом сохранённых пользовательских запросов.

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

Индексация: добавляем контент


Для демонстрации в этой статье я использую данные из TMDB 5000 Movie Dataset. Чтобы избежать проблем с авторскими правами, я лишь привожу код утилиты, импортирующей их из CSV-файла, который предлагаю вам скачать самостоятельно с сайта Kaggle. После загрузки достаточно выполнить команду

docker-compose exec -T backend python -m tools.add_movies < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv


, чтобы создать пять тысяч карточек, посвящённых кино, и команду

docker-compose exec backend python -m tools.build_index


, чтобы построить индекс. Обратите внимание, что последняя команда на самом деле не строит индекс, а только ставит задачу в очередь задач, после чего она выполнится на воркере подробнее об этом подходе я рассказывал в прошлой статье. docker-compose logs worker покажут вам, как воркер старался!

Прежде, чем мы приступим к, собственно, поиску, нам хочется своими глазами увидеть, записалось ли что-нибудь в Elasticsearch, и если да, то как оно выглядит!

Наиболее прямой и быстрый способ это сделать воспользоваться HTTP API Elasticsearch. Сперва проверим, куда указывает алиас:

$ curl -s localhost:9200/_cat/aliasescards                cards-2020-09-20-16-14-18 - - - -


Отлично, индекс существует! Посмотрим на него пристально:

$ curl -s localhost:9200/cards-2020-09-20-16-14-18 | jq{  "cards-2020-09-20-16-14-18": {    "aliases": {      "cards": {}    },    "mappings": {      ...    },    "settings": {      "index": {        "creation_date": "1600618458522",        "number_of_shards": "1",        "number_of_replicas": "1",        "uuid": "iLX7A8WZQuCkRSOd7mjgMg",        "version": {          "created": "7050199"        },        "provided_name": "cards-2020-09-20-16-14-18"      }    }  }}


Ну и, наконец, посмотрим на его содержимое:

$ curl -s localhost:9200/cards-2020-09-20-16-14-18/_search | jq{  "took": 2,  "timed_out": false,  "_shards": {    "total": 1,    "successful": 1,    "skipped": 0,    "failed": 0  },  "hits": {    "total": {      "value": 4704,      "relation": "eq"    },    "max_score": 1,    "hits": [      ...    ]  }}


Итого в нашем индексе 4704 документа, а в поле hits (которое я пропустил, потому что оно слишком большое) можно даже увидеть содержимое некоторых из них. Успех!

Более удобным способом просмотра содержимого индекса и вообще всевозможного баловства с Elasticsearch будет воспользоваться Kibana. Добавим контейнер в docker-compose.yml:

services:  ...  kibana:    image: "kibana:7.5.1"    ports:      - "5601:5601"    depends_on:      - elasticsearch  ...


После повторного docker-compose up мы сможем зайти в Kibana по адресу localhost:5601 (внимание, сервер может стартовать небыстро) и, после короткой настройки, просмотреть содержимое наших индексов в симпатичном веб-интерфейсе.



Очень советую вкладку Dev Tools при разработке вам часто нужно будет делать те или иные запросы в Elasticsearch, и в интерактивном режиме с автодополнением и автоформатированием это гораздо удобнее.

Поиск


После всех невероятно скучных приготовлений пора нам уже добавить функциональность поиска в наше веб-приложение!

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

  1. Добавляем в бэкенд компонент Searcher, отвечающий за логику поиска. Он будет формировать запрос к Elasticsearch и конвертировать результаты в более удобоваримые для нашего бэкенда.
  2. Добавляем в API эндпоинт (ручку/роут/как у вас в компании это называют?) /cards/search, осуществляющий поиск. Он будет вызывать метод компонента Searcher, обрабатывать полученные результаты и возвращать клиенту.
  3. Реализуем интерфейс поиска на фронтенде. Он будет обращаться в /cards/search, когда пользователь определился, что он хочет искать, и отображать результаты (и, возможно, какие-то дополнительные контролы).


Поиск: реализуем


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

# backend/backend/search/searcher.pyimport abcfrom dataclasses import dataclassfrom typing import Iterable, Optional@dataclassclass CardSearchResult:    total_count: int    card_ids: Iterable[str]    next_card_offset: Optional[int]class Searcher(metaclass=abc.ABCMeta):    @abc.abstractmethod    def search_cards(self, query: str = "",                      count: int = 20, offset: int = 0) -> CardSearchResult:        pass


Какие-то вещи очевидны. Например, пагинация. Мы амбициозный молодой убийца IMDB стартап, и результаты поиска никогда не будут вмещаться на одну страницу!

Какие-то менее очевидны. Например, список ID, а не карточек в качестве результата. Elasticsearch по умолчанию хранит наши документы целиком и возвращает их в результатах поиска. Это поведение можно отключить, чтобы сэкономить на размере поискового индекса, но для нас это явно преждевременная оптимизация. Так почему бы не возвращать сразу карточки? Ответ: это нарушит single-responsibility principle. Возможно, когда-нибудь мы накрутим в менеджере карточек сложную логику, переводящую карточки на другие языки в зависимости от настроек пользователя. Ровно в этот момент данные на странице карточки и данные в результатах поиска разъедутся, потому что добавить ту же самую логику в поисковый менеджер мы забудем. И так далее и тому подобное.

Реализация этого интерфейса настолько проста, что мне было лень писать этот раздел :-(

# backend/backend/search/searcher_impl.pyfrom typing import Anyfrom elasticsearch import Elasticsearchfrom backend.search.searcher import CardSearchResult, SearcherElasticsearchQuery = Any  # для аннотаций типовclass ElasticsearchSearcher(Searcher):    def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):        self.elasticsearch_client = elasticsearch_client        self.cards_index_name = cards_index_name    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:        result = self.elasticsearch_client.search(index=self.cards_index_name, body={            "size": count,            "from": offset,            "query": self._make_text_query(query) if query else self._match_all_query        })        total_count = result["hits"]["total"]["value"]        return CardSearchResult(            total_count=total_count,            card_ids=[hit["_id"] for hit in result["hits"]["hits"]],            next_card_offset=offset + count if offset + count < total_count else None,        )    def _make_text_query(self, query: str) -> ElasticsearchQuery:        return {            # Multi-match query делает текстовый поиск по             # совокупности полей документов (в отличие от match            # query, которая ищет по одному полю).            "multi_match": {                "query": query,                # Число после ^  приоритет. Найти фрагмент текста                # в названии карточки лучше, чем в описании и тегах.                "fields": ["name^3", "tags.text", "text"],            }        }    _match_all_query: ElasticsearchQuery = {"match_all": {}}


По сути мы просто ходим в API Elasticsearch и аккуратно достаём ID найденных карточек из результата.

Реализация эндпоинта тоже довольно тривиальна:

# backend/backend/server.py...    def search_cards(self):        request = flask.request.json        search_result = self.wiring.searcher.search_cards(**request)        cards = self.wiring.card_dao.get_by_ids(search_result.card_ids)        return flask.jsonify({            "totalCount": search_result.total_count,            "cards": [                {                    "id": card.id,                    "slug": card.slug,                    "name": card.name,                    # Здесь не нужны все поля, иначе данных на одной                    # странице поиска будет слишком много, и она будет                    # долго грузиться.                } for card in cards            ],            "nextCardOffset": search_result.next_card_offset,        })...


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



So far so good, идём дальше.

Поиск: добавляем фильтры


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

У наших описаний фильмов из базы TMDB 5000 помимо названий и описаний есть теги, так что давайте для тренировки реализуем фильтры по тегам. Наша цель на скриншоте: при клике на тег в выдаче должны остаться только фильмы с этим тегом (их число указано в скобках рядом с ним).



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


Второе в Elasticsearch элементарно реализуется через API запросов (см. terms query), первое через чуть менее тривиальный механизм агрегаций.

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

# backend/backend/search/searcher.pyimport abcfrom dataclasses import dataclassfrom typing import Iterable, Optional@dataclassclass TagStats:    tag: str    cards_count: int@dataclassclass CardSearchResult:    total_count: int    card_ids: Iterable[str]    next_card_offset: Optional[int]    tag_stats: Iterable[TagStats]class Searcher(metaclass=abc.ABCMeta):    @abc.abstractmethod    def search_cards(self, query: str = "",                      count: int = 20, offset: int = 0,                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:        pass


Теперь перейдём к реализации. Первое, что нам нужно сделать завести агрегацию по полю tags:

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -10,6 +10,8 @@ ElasticsearchQuery = Any  class ElasticsearchSearcher(Searcher): +    TAGS_AGGREGATION_NAME = "tags_aggregation"+     def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):         self.elasticsearch_client = elasticsearch_client         self.cards_index_name = cards_index_name@@ -18,7 +20,12 @@ class ElasticsearchSearcher(Searcher):         result = self.elasticsearch_client.search(index=self.cards_index_name, body={             "size": count,             "from": offset,             "query": self._make_text_query(query) if query else self._match_all_query,+            "aggregations": {+                self.TAGS_AGGREGATION_NAME: {+                    "terms": {"field": "tags"}+                }+            }         })


Теперь в поисковом результате от Elasticsearch будет приходить поле aggregations, из которого по ключу TAGS_AGGREGATION_NAME мы сможем достать бакеты, содержащие информацию о том, какие значения лежат в поле tags у найденных документов и как часто они встречаются. Давайте извлечём эти данные и вернём в удобоваримом виде (as designed above):

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -28,10 +28,15 @@ class ElasticsearchSearcher(Searcher):         total_count = result["hits"]["total"]["value"]+        tag_stats = [+            TagStats(tag=bucket["key"], cards_count=bucket["doc_count"])+            for bucket in result["aggregations"][self.TAGS_AGGREGATION_NAME]["buckets"]+        ]         return CardSearchResult(             total_count=total_count,             card_ids=[hit["_id"] for hit in result["hits"]["hits"]],             next_card_offset=offset + count if offset + count < total_count else None,+            tag_stats=tag_stats,         )


Добавить применение фильтра самая лёгкая часть:

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -16,11 +16,17 @@ class ElasticsearchSearcher(Searcher):         self.elasticsearch_client = elasticsearch_client         self.cards_index_name = cards_index_name -    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:+    def search_cards(self, query: str = "", count: int = 20, offset: int = 0,+                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:         result = self.elasticsearch_client.search(index=self.cards_index_name, body={             "size": count,             "from": offset,-            "query": self._make_text_query(query) if query else self._match_all_query,+            "query": {+                "bool": {+                    "must": self._make_text_queries(query),+                    "filter": self._make_filter_queries(tags),+                }+            },             "aggregations": {


Подзапросы, включённые в must-клаузу, обязательны к выполнению, но также будут учитываться при расчёте скоров документов и, соответственно, ранжировании; если мы когда-нибудь будем добавлять ещё какие-то условия на тексты, их лучше добавить сюда. Подзапросы в filter-клаузе только фильтруют, не влияя на скоры и ранжирование.

Осталось реализовать _make_filter_queries():

    def _make_filter_queries(self, tags: Optional[Iterable[str]] = None) -> List[ElasticsearchQuery]:        return [] if tags is None else [{            "term": {                "tags": {                    "value": tag                }            }        } for tag in tags]


На фронтенд-части опять-таки не стану останавливаться; весь код в этом коммите.

Ранжирование


Итак, наш поиск ищет карточки, фильтрует их по заданному списку тегов и выводит в каком-то порядке. Но в каком? Порядок очень важен для практичного поиска, но всё, что мы сделали за время наших разбирательств в плане порядка это намекнули Elasticsearch, что находить слова в заголовке карточки выгоднее, чем в описании или тегах, указав приоритет ^3 в multi-match query.

Несмотря на то, что по умолчанию Elasticsearch ранжирует документы довольно хитрой формулой на основе TF-IDF, для нашего воображаемого амбициозного стартапа этого вряд ли хватит. Если наши документы это товары, нам надо уметь учитывать их продажи; если это user-generated контент уметь учитывать его свежесть, и так далее. Но и просто отсортировать по числу продаж/дате добавления мы не можем, потому что тогда мы никак не учтём релевантность поисковому запросу.

Ранжирование это большое и запутанное царство технологий, которое никак не покрыть за один раздел в конце статьи. Поэтому здесь я перехожу в режим крупных мазков; я попробую рассказать в самых общих словах, как может быть устроено industrial grade ранжирование в поиске, и раскрою немного технических деталей того, как его можно реализовать с Elasticsearch.

Задача ранжирования очень сложна, так что неудивительно, что один из основных современных методов её решения машинное обучение. Приложение технологий машинного обучения к ранжированию собирательно называется learning to rank.

Типичный процесс выглядит так.

Определяемся, что мы хотим ранжировать. Мы кладём интересующие нас сущности в индекс, научаемся для заданного поискового запроса получать какой-то разумный топ (например, какой-нибудь простой сортировкой и отсечением) этих сущностей и теперь хотим научиться его ранжировать более умным способом.

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

Извлекаем признаки. Мы придумываем для наших сущностей какое-то множество признаков, которые могли бы помочь нам оценить релевантность сущностей поисковым запросам. Помимо того же TF-IDF, который уже умеет для нас вычислять Elasticsearch, типичный пример CTR (click-through rate): мы берём логи нашего сервиса за всё время, для каждой пары сущность+поисковый запрос считаем, сколько раз сущность появлялась в выдаче по этому запросу и сколько раз её кликали, делим одно на другое, et voil простейшая оценка условной вероятности клика готова. Мы также можем придумать признаки для пользователя и парные признаки пользователь-сущность, чтобы сделать ранжирование персонализированным. Придумав признаки, мы пишем код, который их вычисляет, кладёт в какое-то хранилище и умеет отдавать в real time для заданного поискового запроса, пользователя и набора сущностей.

Собираем обучающий датасет. Тут много вариантов, но все они, как правило, формируются из логов хороших (например, клик и потом покупка) и плохих (например, клик и возврат на выдачу) событий в нашем сервисе. Когда мы собрали датасет, будь то список утверждений оценка релевантности товара X запросу Q примерно равна P, список пар товар X релевантнее товара Y запросу Q или набор списков для запроса Q товары P1, P2, правильно отранжировать так-то, мы ко всем фигурирующим в нём строкам подтягиваем соответствующие признаки.

Обучаем модель. Тут вся классика ML: train/test, гиперпараметры, переобучение, перфовидеокарты и так далее. Моделей, подходящих (и повсеместно использующихся) для ранжирования, много; упомяну как минимум XGBoost и CatBoost.

Встраиваем модель. Нам остаётся так или иначе прикрутить вычисление модели на лету для всего топа, чтобы до пользователя долетали уже отранжированные результаты. Тут много вариантов; в иллюстративных целях я (опять-таки) остановлюсь на простом Elasticsearch-плагине Learning to Rank.

Ранжирование: плагин Elasticsearch Learning to Rank


Elasticsearch Learning to Rank это плагин, добавляющий в Elasticsearch возможность вычислить ML-модель на выдаче и тут же отранжировать результаты согласно посчитанным ею скорам. Он также поможет нам получить признаки, идентичные используемым в real time, переиспользовав при этом способности Elasticsearch (TF-IDF и тому подобное).

Для начала нам нужно подключить плагин в нашем контейнере с Elasticsearch. Нам потребуется простенький Dockerfile

# elasticsearch/DockerfileFROM elasticsearch:7.5.1RUN ./bin/elasticsearch-plugin install --batch http://es-learn-to-rank.labs.o19s.com/ltr-1.1.2-es7.5.1.zip


и сопутствующие изменения в docker-compose.yml:

--- a/docker-compose.yml+++ b/docker-compose.yml@@ -5,7 +5,8 @@ services:   elasticsearch:-    image: "elasticsearch:7.5.1"+    build:+      context: elasticsearch     environment:       - discovery.type=single-node


Также нам потребуется поддержка плагина в Python-клиенте. С изумлением я обнаружил, что поддержка для Python не идёт в комплекте с плагином, так что специально для этой статьи я её запилил. Добавим elasticsearch_ltr в requirements.txt и проапгрейдим клиент в вайринге:

--- a/backend/backend/wiring.py+++ b/backend/backend/wiring.py@@ -1,5 +1,6 @@ import os +from elasticsearch_ltr import LTRClient from celery import Celery from elasticsearch import Elasticsearch from pymongo import MongoClient@@ -39,5 +40,6 @@ class Wiring(object):         self.task_manager = TaskManager(self.celery_app)          self.elasticsearch_client = Elasticsearch(hosts=self.settings.ELASTICSEARCH_HOSTS)+        LTRClient.infect_client(self.elasticsearch_client)         self.indexer = Indexer(self.elasticsearch_client, self.card_dao, self.settings.CARDS_INDEX_ALIAS)         self.searcher: Searcher = ElasticsearchSearcher(self.elasticsearch_client, self.settings.CARDS_INDEX_ALIAS)


Ранжирование: пилим признаки


Каждый запрос в Elasticsearch возвращает не только список ID документов, которые нашлись, но и некоторые их скоры (как вы бы перевели на русский язык слово score?). Так, если это match или multi-match query, которую мы используем, то скор это результат вычисления той самой хитрой формулы с участием TF-IDF; если bool query комбинация скоров вложенных запросов; если function score query результат вычисления заданной функции (например, значение какого-то числового поля в документе) и так далее. Плагин ELTR предоставляет нам возможность использовать скор любого запроса как признак, позволяя легко скомбинировать данные о том, насколько хорошо документ соответствует запросу (через multi-match query) и какие-то предрассчитанные статистики, которые мы заранее кладём в документ (через function score query).

Поскольку на руках у нас база TMDB 5000, в которой лежат описания фильмов и, помимо прочего, их рейтинги, давайте возьмём рейтинг в качестве образцово-показательного предрассчитанного признака.

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

  • Признаки мы будем хранить в отдельной коллекции и доставать отдельным менеджером. Сваливать все данные в одну сущность порочная практика.
  • В этот менеджер мы будем обращаться на этапе индексации и класть все имеющиеся признаки в индексируемые документы.
  • Чтобы знать схему индекса, нам надо перед началом построения индекса знать список всех существующих признаков. Этот список мы пока что захардкодим.
  • Поскольку мы не собираемся фильтровать документы по значениям признаков, а собираемся только извлекать их из уже найденных документов для обсчёта модели, мы выключим построение по новым полям обратных индексов опцией index: false в схеме и сэкономим за счёт этого немного места.


Ранжирование: собираем датасет


Поскольку, во-первых, у нас нет продакшна, а во-вторых, поля этой статьи слишком малы для рассказа про телеметрию, Kafka, NiFi, Hadoop, Spark и построение ETL-процессов, я просто сгенерирую случайные просмотры и клики для наших карточек и каких-то поисковых запросов. После этого нужно будет рассчитать признаки для получившихся пар карточка-запрос.

Пришла пора закопаться поглубже в API плагина ELTR. Чтобы рассчитать признаки, нам нужно будет создать сущность feature store (насколько я понимаю, фактически это просто индекс в Elasticsearch, в котором плагин хранит все свои данные), потом создать feature set список признаков с описанием, как вычислять каждый из них. После этого нам достаточно будет сходить в Elasticsearch с запросом специального вида, чтобы получить вектор значений признаков для каждой найденной сущности в результате.

Начнём с создания feature set:

# backend/backend/search/ranking.pyfrom typing import Iterable, List, Mappingfrom elasticsearch import Elasticsearchfrom elasticsearch_ltr import LTRClientfrom backend.search.features import CardFeaturesManagerclass SearchRankingManager:    DEFAULT_FEATURE_SET_NAME = "card_features"    def __init__(self, elasticsearch_client: Elasticsearch,                  card_features_manager: CardFeaturesManager,                 cards_index_name: str):        self.elasticsearch_client = elasticsearch_client        self.card_features_manager = card_features_manager        self.cards_index_name = cards_index_name    def initialize_ranking(self, feature_set_name=DEFAULT_FEATURE_SET_NAME):        ltr: LTRClient = self.elasticsearch_client.ltr        try:            # Создать feature store обязательно для работы,            # но при этом его нельзя создавать дважды \_()_/            ltr.create_feature_store()        except Exception as exc:            if "resource_already_exists_exception" not in str(exc):                raise        # Создаём feature set с невероятными ТРЕМЯ признаками!        ltr.create_feature_set(feature_set_name, {            "featureset": {                "features": [                    # Совпадение поискового запроса с названием                    # карточки может быть более сильным признаком,                     # чем совпадение со всем содержимым, поэтому                     # сделаем отдельный признак про это.                    self._make_feature("name_tf_idf", ["query"], {                        "match": {                            # ELTR позволяет параметризовать                            # запросы, вычисляющие признаки. В данном                            # случае нам, очевидно, нужен текст                             # запроса, чтобы правильно посчитать                             # скор match query.                            "name": "{{query}}"                        }                    }),                    # Скор запроса, которым мы ищем сейчас.                    self._make_feature("combined_tf_idf", ["query"], {                        "multi_match": {                            "query": "{{query}}",                            "fields": ["name^3", "tags.text", "text"]                        }                    }),                    *(                        # Добавляем все имеющиеся предрассчитанные                        # признаки через механизм function score.                        # Если по какой-то причине в документе                         # отсутствует искомое поле, берём 0.                        # (В настоящем проекте вам стоит                        # предусмотреть умолчания получше!)                        self._make_feature(feature_name, [], {                            "function_score": {                                "field_value_factor": {                                    "field": feature_name,                                    "missing": 0                                }                            }                        })                        for feature_name in sorted(self.card_features_manager.get_all_feature_names_set())                    )                ]            }        })    @staticmethod    def _make_feature(name, params, query):        return {            "name": name,            "params": params,            "template_language": "mustache",            "template": query,        }


Теперь функция, вычисляющая признаки для заданного запроса и карточек:

    def compute_cards_features(self, query: str, card_ids: Iterable[str],                                feature_set_name=DEFAULT_FEATURE_SET_NAME) -> Mapping[str, List[float]]:        card_ids = list(card_ids)        result = self.elasticsearch_client.search({            "query": {                "bool": {                    # Нам не нужно проверять, находятся ли карточки                    # на самом деле по такому запросу  если нет,                     # соответствующие признаки просто будут нулевыми.                    # Поэтому оставляем только фильтр по ID.                    "filter": [                        {                            "terms": {                                "_id": card_ids                            }                        },                        # Это  специальный новый тип запроса,                        # вводимый плагином SLTR. Он заставит                        # плагин посчитать все факторы из указанного                        # feature set.                        # (Несмотря на то, что мы всё ещё в разделе                        # filter, этот запрос ничего не фильтрует.)                        {                            "sltr": {                                "_name": "logged_featureset",                                "featureset": feature_set_name,                                "params": {                                    # Та самая параметризация.                                     # Строка, переданная сюда,                                    # подставится в запросах                                    # вместо {{query}}.                                    "query": query                                }                            }                        }                    ]                }            },            # Следующая конструкция заставит плагин запомнить все            # рассчитанные признаки и добавить их в результат поиска.            "ext": {                "ltr_log": {                    "log_specs": {                        "name": "log_entry1",                        "named_query": "logged_featureset"                    }                }            },            "size": len(card_ids),        })        # Осталось достать значения признаков из (несколько        # замысловатого) результата поиска.        # (Чтобы понять, где в недрах результатов нужные мне         # значения, я просто делаю пробные запросы в Kibana.)        return {            hit["_id"]: [feature.get("value", float("nan")) for feature in hit["fields"]["_ltrlog"][0]["log_entry1"]]            for hit in result["hits"]["hits"]        }


Простенький скрипт, принимающий на вход CSV с запросами и ID карточек и выдающий CSV с признаками:

# backend/tools/compute_movie_features.pyimport csvimport itertoolsimport sysimport tqdmfrom backend.wiring import Wiringif __name__ == "__main__":    wiring = Wiring()    reader = iter(csv.reader(sys.stdin))    header = next(reader)    feature_names = wiring.search_ranking_manager.get_feature_names()    writer = csv.writer(sys.stdout)    writer.writerow(["query", "card_id"] + feature_names)    query_index = header.index("query")    card_id_index = header.index("card_id")    chunks = itertools.groupby(reader, lambda row: row[query_index])    for query, rows in tqdm.tqdm(chunks):        card_ids = [row[card_id_index] for row in rows]        features = wiring.search_ranking_manager.compute_cards_features(query, card_ids)        for card_id in card_ids:            writer.writerow((query, card_id, *features[card_id]))


Наконец можно это всё запустить!

# Создаём feature setdocker-compose exec backend python -m tools.initialize_search_ranking# Генерируем событияdocker-compose exec -T backend \    python -m tools.generate_movie_events \    < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv \    > ~/Downloads/habr-app-demo-dataset-events.csv# Считаем признакиdocker-compose exec -T backend \    python -m tools.compute_features \    < ~/Downloads/habr-app-demo-dataset-events.csv \    > ~/Downloads/habr-app-demo-dataset-features.csv


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

Ранжирование: обучаем и внедряем модель


Опустим подробности загрузки датасетов (скрипт полностью можно посмотреть в этом коммите) и перейдём сразу к делу.

# backend/tools/train_model.py... if __name__ == "__main__":    args = parser.parse_args()    feature_names, features = read_features(args.features)    events = read_events(args.events)    # Разделим запросы на train и test в соотношении 4 к 1.    all_queries = set(events.keys())    train_queries = random.sample(all_queries, int(0.8 * len(all_queries)))    test_queries = all_queries - set(train_queries)    # DMatrix  это тип данных, используемый xgboost.    # Фактически это массив значений признаков с названиями     # и лейблами. В качестве лейбла мы берём 1, если был клик,     # и 0, если не было (детали см. в коммите).    train_dmatrix = make_dmatrix(train_queries, events, feature_names, features)    test_dmatrix = make_dmatrix(test_queries, events, feature_names, features)    # Учим модель!    # Поля этой статьи всё ещё крайне малы для долгого разговора     # про ML, так что я возьму минимально модифицированный пример     # из официального туториала к XGBoost.    param = {        "max_depth": 2,        "eta": 0.3,        "objective": "binary:logistic",        "eval_metric": "auc",    }    num_round = 10    booster = xgboost.train(param, train_dmatrix, num_round, evals=((train_dmatrix, "train"), (test_dmatrix, "test")))    # Сохраняем обученную модель в файл.     booster.dump_model(args.output, dump_format="json")     # Санитарный минимум проверки того, как прошло обучение: давайте    # посмотрим на топ признаков по значимости и на ROC-кривую.    xgboost.plot_importance(booster)    plt.figure()    build_roc(test_dmatrix.get_label(), booster.predict(test_dmatrix))    plt.show()


Запускаем

python backend/tools/train_search_ranking_model.py \    --events ~/Downloads/habr-app-demo-dataset-events.csv \    --features ~/Downloads/habr-app-demo-dataset-features.csv \     -o ~/Downloads/habr-app-demo-model.xgb


Обратите внимание, что поскольку мы экспортировали все нужные данные предыдущими скриптами, этот скрипт уже не надо запускать внутри докера его нужно запускать на вашей машине, предварительно установив xgboost и sklearn. Аналогично в настоящем продакшне предыдущие скрипты нужно было бы запускать где-то, где есть доступ в продакшн-окружение, а этот нет.

Если всё сделано правильно, модель успешно обучится, и мы увидим две красивых картинки. Первая график значимости признаков:



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

Второй график ROC-кривая:



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

Дело совсем за малым добавляем скрипт для заливки модели, заливаем и добавляем маленький новый пункт в поисковый запрос рескоринг:

--- a/backend/backend/search/searcher_impl.py+++ b/backend/backend/search/searcher_impl.py@@ -27,6 +30,19 @@ class ElasticsearchSearcher(Searcher):                     "filter": list(self._make_filter_queries(tags, ids)),                 }             },+            "rescore": {+                "window_size": 1000,+                "query": {+                    "rescore_query": {+                        "sltr": {+                            "params": {+                                "query": query+                            },+                            "model": self.ranking_manager.get_current_model_name()+                        }+                    }+                }+            },             "aggregations": {                 self.TAGS_AGGREGATION_NAME: {                     "terms": {"field": "tags"}


Теперь после того, как Elasticsearch произведёт нужный нам поиск и отранжирует результаты своим (довольно быстрым) алгоритмом, мы возьмём топ-1000 результатов и переранжируем, применив нашу (относительно медленную) машинно-обученную формулу. Успех!

Заключение


Мы взяли наше минималистичное веб-приложение и прошли путь от отсутствия фичи поиска как таковой до масштабируемого решения со множеством продвинутых возможностей. Сделать это было не так уж просто. Но и не так уж сложно! Итоговое приложение лежит в репозитории на Github в ветке со скромным названием feature/search и требует для запуска Docker и Python 3 с библиотеками для машинного обучения.

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

И, конечно, это решение не претендует на законченность и готовность к продакшну, а является исключительно иллюстрацией того, как всё может быть сделано. Улучшать его можно практически бесконечно!
  • Инкрементальная индексация. При модификации наших карточек через CardManager хорошо бы сразу обновлять их в индексе. Чтобы CardManager не знал, что у нас в сервисе есть ещё и поиск, и обошлось без циклических зависимостей, придётся прикрутить dependency inversion в том или ином виде.
  • Для индексации в конкретно нашем случае связки MongoDB с Elasticsearch можно использовать готовые решения вроде mongo-connector.
  • Пока пользователь вводит запрос, мы можем предлагать ему подсказки для этого в Elasticsearch есть специальная функциональность.
  • Когда запрос введён, стоит попытаться исправить в нём опечатки, и это тоже целое дело.
  • Для улучшения ранжирования нужно организовать логирование всех пользовательских событий, связанных с поиском, их агрегацию и расчёт признаков на основе счётчиков. Признаки сущность-запрос, сущность-пользователь, сущность-положение Меркурия тысячи их!
  • Особенно весело пилить агрегации событий не офлайновые (раз в день, раз в неделю), а реалтаймовые (задержка от события до учёта в признаках в пределах пяти минут). Вдвойне весело, когда событий сотни миллионов.
  • Предстоит разобраться с прогревом, нагрузочным тестированием, мониторингами.
  • Оркестрировать кластер нод с шардированием и репликацией это целое отдельное наслаждение.

Но чтобы статья осталась читабельного размера, я остановлюсь на этом и оставлю вас наедине с этими челленджами. Спасибо за внимание!
Подробнее..

Код ревью как быть хорошим автором

02.03.2021 16:05:07 | Автор: admin

Привет! Меня зовут Сергей Загурский, я работаю в Joom в команде инфраструктуры. В своей практике ревьюера кода я регулярно сталкиваюсь с тем, что автор не понимает, что ревьюер не является волшебным чёрным ящиком, в который можно закинуть любые изменения и получить по ним обратную связь. Ревьюер, как и автор, будучи человеком, обладает рядом слабостей. И автор должен (если, конечно, он заинтересован в качественном ревью), помочь ревьюеру настолько, насколько это возможно.

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

Зачем мы делаем ревью кода

Вы наверняка и без меня это знаете. Поэтому я не буду углубляться в подробности и растекаться мыслью по древу. Расскажу о самых основах.

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

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

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

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

Дальше мы сосредоточимся на самой фундаментальной составляющей процесса ревью на понимании кода.

Как ревьюер делает ревью

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

Ревьюер часто бывает не настолько погружен в контекст, как автор. Это тоже усложняет понимание сути изменений. Ревьюер может первый раз в жизни видеть код, в котором производятся изменения. Ревьюер может ничего не знать о задаче, которую решает автор. Ревьюер может не понимать, зачем всё это делается. Своевременное предоставление ревьюеру информации о контексте экономит время ревьюера и улучшает качество ревью.

Если речь идёт о каких-то нетривиальных изменениях, то в голове ревьюера происходит тот же процесс анализа, что и в голове автора. И если в ходе этого анализа могут возникать разные решения, не имеющие друг перед другом очевидных преимуществ, то ревьюеру придётся потратить время, чтобы понять, почему автор остановился на определённом решении. Автор, в итоге, мог принять единственно верное решение, но ревьюер, в условиях отсутствия обоснования от автора, потратит своё время на это обоснование.

Бывает такое, что автор вместе с основными изменениями делает что-то, не связанное с решением своей задачи. Когда ревьюер сталкивается с таким, то он тратит дополнительное время, чтобы понять, как эти изменения связаны с решением основной задачи.

Ещё одним большим пожирателем времени ревьюера является написание замечаний. Да, есть замечания, которые относятся к каким-то, условно, косметическим аспектам. Однако даже их бывает полезно снабдить сопровождающей информацией вроде ссылки на нужную часть документа по стилю кода. И это всё требует времени.

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

Как помочь ревьюеру провести качественное ревью

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

Перед тем, как превратить свои изменения в Pull Request, следует разбить их на логические куски, если в этом есть необходимость. Комфортный объём ревью заканчивается примерно на 500 строках кода изменений. Допустимый примерно на 1000 строках. Всё, что за пределами 1000 строк, должно быть разбито на более мелкие Pull Requestы.

Если вы, как и я, не разбиваете свои изменения на куски комфортного размера заранее, то это придётся проделать перед отправкой ваших изменений на ревью. Отмазка, что на это нужно потратить время, не катит. Принимая решение об отправке на ревью 1000+ строк кода, вы, фактически, оцениваете своё время дороже времени ревьюера. По нашим правилам у ревьюера всегда есть право потребовать разбить изменения на более мелкие куски. Мы всегда просим коллег относиться с пониманием, если он этим воспользуется. С опытом становится проще строить свою работу так, чтобы у вас не появлялись гигантские Pull Requestы, в которых ничего нельзя отделить.

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

Следующая часть подготовки состоит в информировании ревьюера о сути изменений. Как я уже писал выше, ревьюеру нужно знать: что сделано, зачем сделано, как сделано, и почему именно так сделано. Часть информации у ревьюера уже может быть. Часть есть в коде. Всё, что не очевидно из кода, должно быть прописано в описании изменений.

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

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

Итак, автор подготовил изменения к ревью. Пора отправлять ревьюеру? Нет! Тут наступает ещё один важный этап, саморевью. Под саморевью я понимаю именно ревью, осуществлённое самим автором. Не просто беглый визуальный контроль. Нужно постараться провести полноценное ревью. Ответственно проведённое саморевью сэкономит уйму времени ревьюеру. Наверняка в коде есть какие-то остатки отладочных конструкций, закомментированного кода, TODO-комментариев, бессмысленных изменений форматирования, не пригодившегося кода и прочего подобного. Взгляд глазами ревьюера также поможет оценить сложность ревью и даст возможность добавить дополнительной информации к ревью, которая была упущена при подготовке. Пренебрежение автором саморевью я считаю проявлением крайнего неуважения к ревьюеру.

В целом, я считаю допустимым потратить порядка 10% от времени, затраченного на написание кода, на подготовку к ревью. Это время автора, которое мы обменяем на экономию времени ревьюера, и на улучшение качества ревью. Следует помнить, что ревьюеру на качественное ревью легко может потребоваться и 20%, и 50% от времени, затраченного автором на написание кода.

Вот только теперь автор может с чистой совестью отправить изменения ревьюеру.

Дальше начинается жизненный цикл Pull Requestа. Ревьюер должен либо одобрить его, либо попросить внести изменения. Чтобы упростить работу ревьюера, автору стоит добавить комментарий к каждому запрошенному изменению. Это вполне может быть краткое OK или Исправил, если ничего другого не требуется. Убедитесь, что вы понимаете, что просит ревьюер и что вам понятна его аргументация. Не стоит безоговорочно принимать любые запросы на внесение изменений, возводя тем самым ревьюера в околобожественный ранг. Ревью это обоюдный процесс. Если ревьюеру что-то не понятно, то он спрашивает об этом автора, и наоборот. В случае, если автор не очень опытен, ревьюеру следует приложить особенные усилия к описанию запросов на изменения, чтобы воспользоваться возможностью поделиться с автором своим опытом. Бывают и спорные моменты, когда аргументация недостаточно сильная, чтобы склонить обе стороны к единой точке зрения. С учётом того, что автор находится в более уязвимой позиции, считаю, что при прочих равных преимущество должно оставаться за автором.

Не вносите изменений в свой Pull Request, не относящихся к тому, что попросил вас ревьюер. Это крайне сбивает с толку. Также следует воздержаться от rebase и подобных действий.

Получили одобрение от ревьюера? Отлично, ещё одно качественно написанное и оформленное изменение было добавлено в проект!

Подробнее..

Неожиданная сложность простых программ

18.05.2021 18:11:11 | Автор: admin
Не раз я сталкивался с удивлением при оглашении оценки сложности проекта: А почему так долго?, Да тут же раз, два и готово!, Можно же просто взять X и сунуть в Y!. Программисты привыкли оценивать сроки как время на написание и отладку кода, хотя в крупные задачи входит ещё много всего.


Знаете ли вы, что в реальности айсберги располагаются в воде горизонтально, а не вертикально, как на большинстве стоковых картинок?

Но даже если забыть про традиционный букет энтерпрайзовых примочек вроде аналитики, поддержки обратной совместимости и A/B-тестирования и сосредоточиться чисто на коде, напрямую связанном с реализуемой функциональностью, можно увидеть, что зачастую его сложность выходит из-под контроля.

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

Поиск по пользователям


Одним из крупных разделов приложения Joom является внутренняя социальная сеть, где покупатели могут писать обзоры на товары, лайкать и обсуждать их и подписываться друг на друга. А какая же соцсеть без поиска по пользователям!

Конечно, поиск нельзя назвать такой уж лёгкой на вид задачей (по крайней мере после моей предыдущей статьи). Но я уже обладал всеми нужными знаниями, а также у нас в компании был готовый компонент joom-mongo-connector, который умел переливать данные из коллекции в MongoDB в индекс Elasticsearch, при необходимости приджойнивая дополнительные данные и делая какую-то ещё постобработку. Задача звучала довольно просто.

Задача. Сделай бэкенд для поиска по пользователям соцсети. Фильтров не надо, сортировка по количеству подписчиков сойдёт для начала.

Окей, это правда звучит просто. Настраиваем переливку из коллекции socialUsers в Elasticsearch путём написания конфига на YAML. На бэкенде добавляем новый эндпоинт с API, аналогичным API поиска товаров, только пока что без поддержки фильтров и сортировок (остаются только текст запроса и пагинация, всего-то). В хендлере делаем простейший запрос в Elasticsearch-кластер (главное не ошибиться кластером!), из результата достаём ID нашедшихся документов, они же ID пользователей по ним самих пользователей, потом конвертируем в клиентский JSON, пряча от посторонних глаз приватную информацию, и готово. Или нет?

Первая проблема, с которой мы столкнулись транслитерация. Имена пользователей брались из соцсетей, где пользователи из России (а их на тот момент было большинство) часто писали их латиницей. Пытаешься найти Мадса, а он в фейсбуке Mads, и всё нет его в результатах. Аналогично по Ivan не получится найти Ивана, а очень хотелось бы.

Вот и первое усложнение при индексации мы стали ходить в Microsoft Translator API за транслитерацией и сохранять две версии имени и фамилии, а общий индексирующий компонент стал зависеть от клиента транслитератора (и зависит до сих пор).

Ну и вторая проблема, которую легко предвидеть, если твой родной язык русский, но существующая и в других европейских языках уменьшительно-ласкательные формы и сокращения имён. Если Иван решил в фейсбуке назваться Ваня, по запросу Иван его уже не найти, сколько ни транслитерируй.

Так что следующее усложнение заключалось в том, что мы нашли на Грамоте.ру указатель уменьшительных имён (из единственного в своём роде словаря русских имён Никандра Александровича Петровского), добавили в кодовую базу в качестве захардкоженной таблички (какие-то две тысячи строк) и стали индексировать не только имя и его транслитерацию, но и все найденные уменьшительные формы (fun fact: в английском языке для них есть термин hypocorisms). Мы брали каждое слово в имени пользователя и делали лукап в нашей скромной таблице.


Нотариально заверенный скриншот кодовой базы Joom. Circa 2018.

Но потом, чтобы не обидеть вторую половину наших пользователей, распределённую неровным слоем по нерусскоговорящему миру, мы кинули клич кантри-менеджерам Joom и попросили их найти нам справочники сокращений национальных имён в их странах. Если не академические, то хоть какие-нибудь. И выяснилось, что в некоторых языках, помимо традиции иметь сложносоставное имя (Juan Carlos, Maria Aurora), также существуют сокращения двух, трёх или даже четырёх слов в одно (Mara de las Nieves Marinieves).

Это новое обстоятельство лишило нас возможности делать лукап по одному слову. Теперь надо разбивать последовательность слов на фрагменты произвольной длины, и более того разные разбиения могут приводить к разным сокращениям! Погружаться в глубины лингвистики и писать искусственный интеллект, сокращающий испанское имя так, как его сократил бы живой испанец, мы не хотели, поэтому набросали, прости Кнут, комбинаторный перебор.

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

Машинный перевод товаров


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

Все, наверное, видели мемы про кривой перевод названий китайских товаров. Мы тоже их видели, но желаемое time to market не позволяло придумывать что-то лучше, чем использование какого-то существующего API для перевода.

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

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

После запуска качество переводов, конечно же, не давало нам покоя, и мы подумали: а что, если использовать более дорогой переводчик? Но будет ли он хорош на наших специфических текстах? На глаз их не сравнишь, нужно проводить A/B-тест. Так в нашем кэше переводов помимо ID товара появился ID переводчика, и мы стали запрашивать перевод с ID переводчика, зависящим от того, в какую группу A/B-теста попал пользователь.

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

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

И наконец, мы решили, что за несколько лет существования Joom наш основной переводчик мог улучшиться, и, возможно, кэш переводов имеет смысл обновлять с какой-то периодичностью. Но как же без A/B-теста? Так в нашем кэше появилось поле freshness, и всё усложнилось ещё раз. В итоге наша компонента, занимающаяся переводом, невероятно сложна, и это при том, что мы даже ещё не прикрутили туда никакой самодельной вычислительной лингвистики. Пока что.

Конвертация размеров одежды


Пожалуй, одна из самых болезненных проблем при покупке одежды и обуви онлайн правильный выбор размера. И если при доставке с локальных складов игроки вроде Lamoda могут просто привозить сразу несколько размеров и с той же лёгкостью увозить неподошедшее обратно, в кроссбордере так не получится. Посылки идут долго, стоимость каждого лишнего килограмма велика, а их отправители не ожидают большой поток входящей почты.

Дополнительно проблема усложняется тем, что продавцы из разных стран могут иметь совершенно разное представление о размерах. Китайский M легко может оказаться русским XS, а ужасающий 9XL не так уж сильно отличаться от XXL. Прошаренным пользователям приходится ориентироваться на замеры, но и те не всегда верны: например, пользователь ожидает, что указан обхват груди человека, а продавец указывает измерения самой одежды они отличаются процентов на пять-десять. Мы не хотим, чтобы пользователю нужно было так заморачиваться для шоппинга на Joom!

Задача. Вместо размеров, предоставляемых продавцами, покажи пользователям размеры, вычисленные нами по некоторой единой таблице, исходя из обхватов.

Окей. Берём таблицу размеров, которая у нас парсится из описания товара (этим занимается отдельный космолёт на 5к строк) и хранится отдельным полем, и подменяем в ней размеры на вычисленные. Хардкодим таблицу для конвертации обхвата в размер, найденную в интернете, и радуемся жизни.

Но если таблицы нет или в ней не хватает строк, это не работает. Фича отключается на товаре неявным образом номер раз.

Хм, в таблице обхваты тела человека, а большинство продавцов указывает их, померив на самих вещах. Вшиваем коэффициент разницы. Продакт-менеджер Родион, счастливый обладатель идеальной М-ки, идёт в торговый центр, меряет на себе кучу разных вещей и приходит с коэффициентами они похожи, но существенно различаются для разных категорий товаров. Для обхватывающей водолазки разница практически 0%, а для свитера все 10%. Также верхняя одежда различается по посадке: slim fit, normal fit, loose fit, и это даёт размах ещё в 5%. Теперь наш коэффициент (увековеченный мною в коде как коэффициент Родиона) состоит из двух множителей.

Для определения посадки делаем ещё один парсер, пытающийся извлечь её из названия или описания товара. Если товар не попал в одну из категорий, проверенных Родионом, фича выключается неявным образом номер два.

Финальный штрих: в большом количестве товаров обхват груди указан от подмышки до подмышки, то есть только половина обхвата, что приводит к смехотворно маленьким размерам. Добавляем логику, что если обхват меньше X, то ну не может такого быть, это явно половина обхвата, и умножаем его на два. Хорошо, что взрослые люди обычно не отличаются друг от друга обхватом груди в два раза.

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

И всё это работает по-разному в зависимости от группы A/B-теста, конечно.

Заключение


Бойтесь данайцев, дары приносящих разработчиков, оптимистично оценивающих сроки. Оценивать время на разработку очень сложно, как бы просто ни звучала задача, и сюрпризы ждут нас на каждом шагу!
Подробнее..

Kubernetes Headless Service А если Pod исчез?

17.06.2021 18:06:38 | Автор: admin

Мы столкнулись с достаточно занятным поведением при работе с Headless-сервисом в Kubernetes. В нашем случае проблема возникла с mongos, но она актуальна для любого Headless-сервиса. Приглашаю вас почитать нашу историю и самим попробовать поиграться с этой проблемой локально.

На одном из проектов мы используем MongoDB и Kubernetes. У MongoDB есть компонент: mongos. Через него выполняются запросы в шардированном MongoDB кластере (можно считать, что это просто хитрый proxy). До переезда в Kubernetes сервисы mongos устанавливались непосредственно на каждый хост.

При переезде сервисов в Kubernetes мы поселили пул mongos в Headless-сервис с автоматическим масштабированием Deployment через HPA (Horizontal Pod Autoscaler).

Через некоторое время выяснилось, что приложению при уменьшении количества Pod с mongos становится не очень хорошо.

Путем отладки выяснилось, что приложение подвисает именно при попытке установить подключение с mongos (net.Dialв терминах Go) и по времени совпадает с остановкой какого-либо Pod.

Для начала надо уточнить, что такое Headless-сервис: это сервис, который не использует отдельный IP-адрес для маршрутизации запросов (ClusterIP: None). В этом случае под DNS-именем сервиса видны IP всех Pod, которые в этот сервис входят.

Headless-сервисы полезны, когда приложение само должно управлять тем, к какому Pod подключаться, например:

  • mongodb-клиент использует IP сервера, с которым он работает, для того, чтобы запросы для одного курсора шли на один хост (курсор живёт на mongos). В случае использованияClusterIPмогут теряться курсоры даже для коротких запросов.

  • gRPC-клиенты держат по одному соединению с сервисами и сами управляют запросами, мультиплексируя запросы к одному серверу. В случае использованияClusterIPклиент может создать одно подключение и нагружать ровно один Pod сервера.

Так как клиент сам управляет, к каким Pod он подключается, возможна ситуация, когда клиент помнит IP-адрес уже удалённого Pod. Причины этого просты:

  • список Pod передаётся через DNS, а DNS кэшируется;

  • клиент сам по себе кэширует ответы от DNS и список сервисов.

Что же происходит в случае, если клиент пытается подключиться к уже несуществующему Pod?

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

При этом, в случае если Pod еще не поднялся или был отстрелен по Out of Memory, но еще не был удалён, то при попытке подключиться клиент получает ошибку connection refused практически сразу. И это гораздо более гуманное решение, чем ждать у моря погоды пока не пробьём таймаут.

Когда стала понятна причина, решить проблему было делом техники.

  • Мы добавили ожидание сигналаSIGTERMв Pod с mongos. При получении этого сигнала мы продолжали работать еще 45 секунд до времени инвалидации DNS (чтобы адреса новых Pod доехали до клиента). После этой паузы завершали mongos и делали еще одну паузу в 15 секунд (чтобы переподключение по старому IP отшивалось по ошибке connection refused, а не таймауту).

  • Мы выставилиterminationGracePeriodSecondsв две минуты, чтобы Pod принудительно не отстрелили до его завершения.

Небольшая ремарка по поводу minReadySeconds

Проблема с остановкой Pod наиболее ярко проявляет себя при перевыкатке сервисов.

Изначально первопричиной казалось то, что выкатка успевает завершиться быстрее, чем обновляются кэши IP-адресов сервиса в клиентском приложении (клиент пытается идти на старые Pod которых нет, а про новые он еще не знает).

Для исправления мы просто замедлили выкатку с помощью параметраminReadySeconds. Это сделало проблему менее острой, но не решило её: остались таймауты при подключении к IP для уже не существующего Pod.

Тем не менее параметрminReadySecondsполезен из-за того, что выкатка не ждёт завершения удаления Pod после перехода его в состояниеTerminating. В результате при раскатке сервиса мы можем на время добавленных пауз получить x2 Pod.

К тому же, если на клиенте не возникает нежелательных эффектов от недоступности части IP-адресов сервиса, то задержку для инвалидации DNS можно переместить вminReadySeconds.

Примером, для которого достаточно толькоminReadySecondsявляются gRPC-сервисы: там клиент держит по одному подключению к каждому серверу и раскидывает запросы между уже имеющимся подключениями, а не подключается к сервису при создании клиентской сессии.

Как поиграться с этой проблемой локально?

Эту ситуацию можно легко воспроизвести в MiniKube на примере nginx.

Для этого надо понадобится headless Service (service.yml):

---apiVersion: v1kind: Servicemetadata:  name: nginxspec:  clusterIP: None  selector:    app: nginx  ports:    - protocol: TCP      port: 80      targetPort: 80

И тестовая утилита (dialer.go):

package mainimport ("fmt""net""os""time")const timeFormat = "15:04:05.999"func main() {address := os.Args[1]last := ""ticker := time.NewTicker(time.Millisecond * 100)t := time.Now()fmt.Printf("%s: === %s\n", t.Format(timeFormat), address)for {conn, err := net.DialTimeout("tcp", address, time.Millisecond*100)var msg stringif conn != nil {msg = fmt.Sprintf("connected (%s)", conn.RemoteAddr())_ = conn.Close()}if err != nil {msg = err.Error()}if last != msg {now := time.Now()if last != "" {fmt.Printf("%s: --- %s: %v\n", now.Format(timeFormat), last, now.Sub(t))}last = msgfmt.Printf("%s: +++ %s\n", now.Format(timeFormat), last)t = now}<-ticker.C}}

Запустим тестовую утилиту для подключения к сервису nginx по 80-му порту. Она будет выводить результат попытки подключиться к сервису (пока не успешный, так как сервис смотрит вникуда):

#!/bin/bashecho "tee dialer.go << EEOF$(cat dialer.go)EEOFgo run dialer.go nginx:80" | kubectl --context=minikube run -i --rm "debug-$(date +'%s')" \            --image=golang:1.16 --restart=Never --

Вывести она должна что-то вида:

16:57:19.986: === nginx:8016:57:19.988: +++ dial tcp: lookup nginx on 10.96.0.10:53: server misbehaving

Пока оставим окно с утилитой и потом будем в него посматривать.

Простой Deployment без задержек

Добавим в сервис Deployment (nginx.yml):

---apiVersion: apps/v1kind: Deploymentmetadata:  name: nginxspec:  replicas: 1  selector:    matchLabels:      app: nginx  template:    metadata:      labels:        app: nginx    spec:      containers:        - name: nginx          image: nginx:1.14.2          ports:            - containerPort: 80

Параметрreplicasдля эксперимента равен единице, чтобы не скакать между IP-адресами.

На боевом Deployment должны быть так жеlivenessProbeиreadinessProbe. Но в данном эксперименте они будут только мешать.

И сделаем обновление Deployment:

#!/bin/bashkubectl --context minikube rollout restart deployment/nginx

От этой команды произойдёт перевыкатка Deployment. При этом важно отметить, что схема выкатки по умолчанию: поднять новый Pod и только затем погасить старый Pod. То есть всегда будет запущен как минимум один Pod.

В выводе тестовой утилиты мы увидим примерно следующее (комментарии добавлены отдельно):

# Здесь мы подключились к созданному Deployment и до обновления попытки# подключения были успешны17:04:08.288: +++ connected (172.17.0.10:80)17:07:32.187: --- connected (172.17.0.10:80): 3m23.899438044s# Здесь завершился nginx при остановке Pod, но клиент еще идет по старому# кэшированному IP.# Так как Pod существует, мы быстро получаем ошибку "connection refused"17:07:32.187: +++ dial tcp 172.17.0.10:80: connect: connection refused17:07:32.488: --- dial tcp 172.17.0.10:80: connect: connection refused: 301.155902ms# Старый Pod уже удалён, но клиент всё еще идет по старому кэшированному IP.# Так как по IP-адресу уже никто не отвечает, мы пробиваем таймаут.17:07:32.488: +++ dial tcp 172.17.0.10:80: i/o timeout17:07:38.448: --- dial tcp 172.17.0.10:80: i/o timeout: 5.960150161s# Старый IP покинул кэш и мы подключились к новому Pod.17:07:38.448: +++ connected (172.17.0.7:80)

Добавляем задержку перед удалением Pod

Добавим в Deployment паузу после завершения сервиса, чтобы вместо долгого таймаута получать быстрый connection refused:

#!/bin/bashkubectl --context minikube patch deployment nginx --output yaml --patch '---spec:  template:    spec:      containers:        - name: nginx          command: [ "sh" ]          # Добавляем паузу после завершения nginx          args:            - "-c"            - "nginx -g \"daemon off;\" && sleep 60"          # К сожалению, sh не пробрасывает SIGTERM в дочерний процесс          lifecycle:            preStop:              exec:                command: ["sh", "-c", "nginx -s stop"]      # Увеличиваем время, которое отводится на остановку Pod-а перед      # его безусловным завершением      terminationGracePeriodSeconds: 180'

Эта пауза нужна только при корректном завершении Pod (в этом случае процесс получаетSIGTERM). Если процесс завершается, к примеру, по Out Of Memory или Segmentation fault, то её быть не должно.

И еще раз сделаем обновление Deployment:

#!/bin/bashkubectl --context minikube rollout restart deployment/nginx

В выводе тестовой утилиты мы увидим примерно следующее (комментарии добавлены отдельно):

# Здесь мы подключились к созданному Deployment и до обновления попытки# подключения были успешны17:58:10.389: +++ connected (172.17.0.7:80)18:00:53.687: --- connected (172.17.0.7:80): 2m43.29763747s# Здесь завершился nginx при остановке Pod, но клиент еще идет по старому# кэшированному IP.# Так как Pod существует, мы быстро получаем ошибку "connection refused".# Существовать Pod будет до тех пор пока не завершится sleep после nginx.18:00:53.687: +++ dial tcp 172.17.0.7:80: connect: connection refused18:01:10.491: --- dial tcp 172.17.0.7:80: connect: connection refused: 16.804114254s# Старый IP покинул кэш и мы подключились к новому Pod.18:01:10.491: +++ connected (172.17.0.10:80)

Добавляем задержку перед остановкой Pod

Добавим в Deployment паузу перед завершением сервиса, чтобы сервис отвечал, пока адрес Pod не покинет кэш на клиенте:

#!/bin/bashkubectl --context minikube patch deployment nginx --output yaml --patch '---spec:  template:    spec:      containers:        - name: nginx          # Добавляем задержку перед остановкой nginx          lifecycle:            preStop:              exec:                command: ["sh", "-c", "sleep 60 && nginx -s stop"]      # Увеличиваем время, которое отводится на остановку Pod перед      # его безусловным завершением      terminationGracePeriodSeconds: 180'

И еще раз сделаем обновление Deployment:

#!/bin/bashkubectl --context minikube rollout restart deployment/nginx

В выводе тестовой утилиты мы увидим примерно следующее (комментарии добавлены отдельно):

# Здесь мы подключились к созданному Deployment и до обновления попытки# подключения были успешны18:05:10.589: +++ connected (172.17.0.7:80)18:07:10.689: --- connected (172.17.0.7:80): 2m0.099149168s# Старый IP покинул кэш и мы подключились к новому Pod.# Старый Pod еще отвечает и из-за этого переключение прошло гладко.18:07:10.689: +++ connected (172.17.0.10:80)

Какие нужны задержки?

Итого: для гладкого переключения необходимо две задержки.

  • МеждуSIGTERMи остановкой приложения чтобы на момент отключения клиента он не мог получить из DNS-кэша ровно тот же Pod и пойти на него.

    Эта задержка должна быть не меньше, чем время жизни записи в DNS-кэше.

    Делать эту паузу больше, чем сумма времени жизни записи в DNS-кэше и времени жизни записи в кэше приложения не имеет особого смысла.

    Если на клиенте не возникает нежелательных эффектов от недоступности части IP-адресов сервиса, то вместо паузы послеSIGTERMможно использоватьminReadySeconds.

  • Между остановкой приложения и завершением Pod, чтобы при попытке клиента подключиться/переподключиться к этому Pod мы получали быстрый connection refused, а не ждали всё время таймаута.

    Эта задержка должна быть подобрана так, чтобы с момента полученияSIGTERMи до завершения Pod прошло время не меньше суммы времени жизни записи в DNS кэше и времени жизни записи в кэше приложения.

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

Конкретные длительности задержек надо подбирать индивидуально.

Подробнее..

Organization as a Function. Введение в бережливую разработку для инженеров

08.04.2021 16:21:10 | Автор: admin

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

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

Наша программа правильно выполняла свою задачу: на экране в бешеном темпе курсор прыгал по ячейкам, писал в них формулы, выделял диапазоны данных. Выполнение программы занимало 40 минут, а загруженность процессора была 100%. Мы хотели быстрее. Может быть, надо запустить программу на компьютере помощнее? Или написать распределенную программу и собрать компьютеры в кластер? Любой здравомыслящий программист поймет, что это плохие решения и ускорения можно добиться куда более простыми методами. Мы исследовали причины низкой производительности и обнаружили, что вся проблема была в том, что программа механически повторяла методические указания. Гораздо эффективнее было не повторять поведение человека в Excel, а перенести все данные из таблицы в память, выполнить необходимые расчеты и записать результаты обратно. Новая версия программы работала 50 мс, так мы оптимизировали программу приблизительно в 50000 раз.

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

В производстве подобные приемы были созданы в Toyota и назывались Toyota Production System. Эти приемы были обобщены до бизнеса в целом в виде философии кайдзен. В области разработки программного обеспечения их называют приемами бережливой разработки программного обеспечения.

Согласитесь, как здорово улучшить работу организации в 50 000 раз! Это, конечно, сказки, но и 10% может быть весьма неплохо.

Когда мы оптимизируем программное обеспечение, мы внедряем изменения, которые улучшают некоторые показатели. Например, время выполнения программы или пропускную способность. Чтобы понять, как применить приемы оптимизации программного обеспечения к организации надо выяснить, что является показателями организации, которые надо улучшать.

Моделируем организацию

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

Прибыль = доходы - расходы

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

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

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

  • Сервис должен надежно и правильно работать. Если он не работает или работает не так, то грош цена всей его функциональности.

Программисты вкладываются в обе составляющие наряду с командами инфраструктуры и DevOps.

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

Продуктовые команды разработки создают функциональность, которая непосредственно приносит пользу. Инфраструктурные команды делают инструменты, которые сокращают расходы команд разработчиков. Команды эксплуатации или DevOps поддерживают все в работоспособном состоянии.

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

Вернемся к аналогии про оптимизацию программного обеспечения.

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

Функция при своей работе тратит ресурсы. Когда мы говорим о настоящей функции в программе, речь идёт обычно о CPU, памяти и времени выполнения. Когда мы говорим об организации о деньгах и времени производства.

Приступим к оптимизации!

Давайте заглянем в код нашей функции-организации. Мы увидим там цепи создания ценностей и методологию разработки, например:

def joom(market_info, money_from_user):    requirements = product_manager(market_info, money_from_user)    infra = infra_team()    while True:        new_release = product_team(requirements, infra)        if quality_control(new_release, requirements, infra):            service = deploy_to_production(new_features)            return service

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

Удаляем мертвый код

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

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

Взгляните на этот пример

def product_team(requirements, infra):    long_useless_meeting() # what is that for? Let's remove it.    implementation_plan = plan(requirements, infra)    log.debug(implementation_plan) # if logging is fast enough, we can leave it here    return code(implementation_plan, requirements, infra)

Если некоторая деятельность в организации не приводит к профиту пользователя прекратите это делать.

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

  • Если что-то нельзя не делать, то тратьте на это минимум ресурсов.

Реализуем только полезную функциональность

Посмотрим на организацию как функцию еще раз.

def joom(market_info, money_from_user):    # something    return service

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

def test():    service = joom(some_marketing_info, previous_money_from_user)    assert makes_money(service)

Представьте себе, что функция joom оказалась с багами и периодически она по непонятным причинам выдает некорректные значения. Если мы не можем исправить ошибку, то придется делать retry вот так:

def profitable_joom(market_info, money_from_user):    while True:        service = joom(market_info, money_from_user)        if makes_money(service)            return service

Теперь тест будет проходить, но цикл while будет тратить много ресурсов при условии, что функция joom часто возвращает не то, что нужно.

Идеально было бы полностью устранить возможности вывода неправильного результата у функции joom. Но если это в принципе невозможно? Все равно есть способ оптимизировать. Взгляните на функцию еще раз. Чем раньше мы поймем, что сервис не будет приносить профита, тем больше ресурсов мы сэкономим.

def joom(market_info, money_from_user):    requirements = product_manager(market_info, money_from_user)    assert makes_money(requirements) # fail-fast if cannot make money    infra = infra_team()    while True:        new_release = product_team(requirements, infra)        assert makes_money(new_release) # fail-fast if cannot make money        if quality_control(new_release, requirements, infra):            service = deploy_to_production(new_features)            assert makes_money(service) # fail-fast if cannot make money            return service

Мы добавили assert для промежуточных результатов. Теперь в случае ошибки мы можем сделать retry раньше и избежать потерь ресурсов.

Вопрос для размышления читателю. А что если сама функция makes_money подвержена ошибкам?

Можно потратить человеко-месяц на функциональность, которая не приносит пользы. По сути это то же самое, что и ошибка на примере кода выше.

Вот возможные причины, почему организация сделала то, что не приносит профит.

  • Сама идея не принесет профит, даже если будет правильно реализована.

  • Реализовали не то, что хотели потери в коммуникации.

  • Реализовали то, что хотели, но оно не работает из-за потерь качества.

Конечно, лучшее решение избегать ошибок. Потери в коммуникации и качестве еще как-то можно устранить, но потери на этапе идеи вряд ли.

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

Любой метод выбора правильных идей требует ресурсов и может давать ложные результаты.

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

А если проверка идеи ложно говорит, что она плохая, то вы не будете делать перспективные идеи. Если вы проверили идею и отвергли ее, то ресурсы, потраченные на проверку, не были скомпенсированы доходом. Это прямые потери. Довод зато мы не потратили еще больше ресурсов на реализацию бессмысленной идеи не работает: потери есть потери. Команда, которая тратит все свое время на отбрасывание идей это мертвый код, так как ее работа не влияет на результаты. Такой команде стоит задуматься о смысле своего существования. В нашей аналогии с исходным кодом это такая функция, которая постоянно проваливает assert makes_money.

Для продуктивной разработки нужен надежный дешевый метод проверки идей. Прогнозы и экспертные оценки на практике работают плохо. Лучше всего проверять идеи прямо на реальных пользователях, но тратя на это мало ресурсов. Такой подход называется MVP (minimum viable product). Для проверки идеи создается дешевая реализация, но которая все-таки реально работает. По реакции пользователей на MVP принимается решение, стоит ли делать хорошую, но дорогую реализацию. Если идея не проходит проверку, то код ее реализации доводится до абсолютного совершенства его удаляют.

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

MVP, конечно, часто не удобны для пользователей и существует риск, что невостребованность функциональности является следствием неудобства. Это требует анализа компромиссов.

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

def profitable_joom(market_info, money_from_user):    while True:        mvp_service = joom_prototyping(market_info, money_from_user)        if makes_money(mvp_service):            service = joom(market_info, money_from_user)            if makes_money(service)                return service

Это классический метод оптимизации. Например, он широкого распространен в форме double check locking.

Минимизируем бюрократию

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

Например, это типично для баз данных и систем анализа данных. Сначала строится план выполнения запроса. Если планирование запроса тратит меньше ресурсов, чем прирост производительности в выполнении запроса это отличная оптимизация.

Возвращаясь к нашей функции-организации, посмотрим на функцию работы продуктовой команды.

def product_team(requirements, infra):    implementation_plan = plan(requirements, infra)    return code(implementation_plan, infra)

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

SCRUM, KanBan, planning poker, недельные отчеты, квартальное планирование, peer review и т.п. все это инструменты повышения продуктивности. Я собирательно назвал их бюрократией, но не вкладываю в это понятие негативную оценку.

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

  • устраняет потери ресурсов из-за недостатка коммуникации,

  • устраняет потери из-за вытеснения одних задач другими,

  • мотивирует сотрудников к профессиональному росту,

  • улучшает фокусировку на том, что принесет максимальную пользу,

  • создает ритм работы с регулярными промежуточными результатами.

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

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

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

Я работаю в команде Joom Ads, и мы отвечаем за рекламу в маркетплейсе. Когда мы начинали работать над проектом, мы намеренно не делали никакого процесса (AdHoc), но затем, по мере роста команды и сложности разработки, мы стали обращать внимание на потери и адаптировали свои процессы. Сейчас наш процесс эволюционировал в KanBan с continuous delivery, практически все рутинные процессы автоматизированы.

Вопрос для размышления читателю. Чтение этих строк приносит пользу?

Используем эффективные алгоритмы

При написании кода важно использовать эффективные алгоритмы. Лучше использовать алгоритм со сложностью O(NLogN) вместо O(N2). Но если вдруг N небольшое, то никакой практической разницы между этими двумя алгоритмами может не быть, можно воспользоваться алгоритмом со сложностью O(N2), к тому же такие алгоритмы, как правило, проще. Проблемы с таким решением начнутся, если N начнет расти.

С организацией происходит то же самое. Если мы делаем что-то маленькое с небольшим сроком жизни, то и быстрые решения будут отлично работать. Но когда начнется рост, появятся проблемы.

Например:

def product_team(requirements, infra):    for implemented in already_implemented:        if implemented == requirements:            return None    implementation_plan = plan(requirements, infra)    release = code(implementation_plan, requirements, infra)    already_implemented.append(requirements)    return release

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

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

Управляем техническим долгом

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

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

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

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

Инвестируем в рефакторинг

Исправление технического долга это техническая инвестиция, так как она не окупается быстро.

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

Инвестиции со временем должны окупаться. Команда должна анализировать, какие инвестиции были удачные, а какие нет, и не повторять ошибок. Очень нехорошо с точки зрения продуктивности вложить недели труда в устранение энтропии, а потом даже не проследить, стало лучше или хуже. Это примерно как сделать спорную оптимизацию в коде и не провести тестирование производительности.

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

Технические инвестиции сложно оценить заранее. Это такая же проверка идеи, о которой мы говорили раньше в контексте функциональности. Своего рода MVP для рефакторинга тоже возможен следует избегать изменений впрок, обоснованных только эстетическими соображениями. Если в архитектуре есть неудачный участок, но этот код надежно выполняет свою задачу и новая функциональность там сейчас не требуется не надо его трогать, потому что вы пока наверняка не знаете, какие именно изменения там будут нужны с точки зрения профита.

В Joom Ads нам приходится проверять множество гипотез разные варианты размещения рекламы в приложении, приемы персонализации и ранжирования рекламы, интерфейс работы с рекламодателем, настройки аукциона и т.д. Нам удалось создать абстракции, которые позволяют быстро проверять гипотезы. Чтобы поддерживать архитектуру адаптированной к быстрому прототипированию новых идей, мы прибегали к рефакторингу, который порой был весьма трудоемким. Но за год разработки мы заметили, что рефакторинг становится все менее инвазивным, а архитектура стабилизируется, сохраняя гибкость. Это связано с тем, что гибкость нужна не по всей кодовой базе, а только в определенных местах, в которых и происходит большинство изменений. Последовательность рефакторингов уже привела эти точки расширения к достаточной гибкости, чтобы можно было проверять практически любые идеи. Все чаще мы сталкиваемся с ситуацией, что мы можем проверить идею без какой-либо подготовки, просто путем новой реализации уже существующих расширений. Подобный путь рефакторингов стал выгодной инвестицией. Оглядываясь назад, я с уверенностью могу сказать, что прийти к такой архитектуре путем раннего принятия решений фактически невозможно.

Инвестируем в библиотеки и инструменты

А как оценить инвестицию не в рефакторинг, а в некоторый инструмент или библиотеку? Стоит ли взять какой-то open source инструмент или написать своё будет продуктивнее? Ведь со сложным open source придется разбираться, это потребует ресурсов, а написать свой инструмент будет даже быстрее.

Главный вопрос при создании велосипедов а что будет дальше?

Пока на написание и поддержку своего инструмента уходит меньше времени, чем на изучение open source, профит точно есть.

Но вот если планируется развивать свое решение и сделать его конкурентом существующему open source, то вам надо будет окупить следующие затраты.

  • Создание и отладку инструмента. Open source уже прошел этот путь.

  • Поддержку инструмента своими силами. У open source есть community для этого.

  • Написание качественной документации. Про open source могут быть написаны книги, как минимум множество ответов в stackoverflow и т.п.

  • Изучение своего инструмента коллегами. Вам не пришлось изучать свой инструмент, так как вы его создатель, но коллегам придется изучать ваш инструмент по той документации и с теми ресурсами, которые вы для них подготовите.

Представьте себе, насколько прорывным должно быть ваше решение, чтобы все это окупить. Не продуктивнее ли будет вместо написания своего инструмента разобраться в коде open source и присоединиться к его создателям?

Тем не менее создание собственного решения может быть продуктивным.

  • Open source инструмент может делать гораздо больше, чем вам понадобится. Тогда ваше решение будет проще и, скорее всего, надежнее. Но важно не забыть, что со временем требования имеют тенденцию к усложнению, а значит, ваш инструмент тоже станет сложнее.

  • Бывают низкокачественные open source решения. Они постоянно требуют ресурсов на поддержку, и починить их уже почти невозможно вашими силами из-за огромного технического долга в самом решении.

  • Open source решение могло быть написано давно, и может быть несовместимо с более молодыми технологиями. Тогда за счет более тесной интеграции с другими технологиями вы действительно можете совершить прорыв.

В ходе разработки JoomAds нам не довелось изобретать велосипеды, мы брали существующие инструменты, порой внося изменения в их код и исправляя баги. К сожалению, некоторые решения пришлось менять. Так, мы не смогли добиться достаточной надежности от Apache Ignite и заменили его на сочетание Apache Cassandra, Apache Kafka и Apache Flink. Как ни странно, решение с большим количеством компонентов в итоге оказалось и проще, и надежнее. Подробнее о нем можно прочитать тут.

Заключение

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

Подробнее..

Знакомимся с Needle, системой внедрения зависимостей на Swift

20.08.2020 18:04:07 | Автор: admin

Привет! Меня зовут Антон, я iOS-разработчик в Joom. Из этой статьи вы узнаете, как мы работаем с DI-фреймворком Needle, и реально ли он чем-то выгодно отличается от аналогичных решений и готов для использования в production-коде. Это всё с замерами производительности, естественно.



Предыстория


Во времена, когда приложения для iOS еще писали полностью на Objective-C, существовало не так много DI-фреймворков, и стандартом по умолчанию среди них считался Typhoon. При всех своих очевидных плюсах, Typhoon приносил с собой и определённый overhead в runtime, что приводило к потере производительности в приложении.


На заре Joom мы попытались воспользоваться этим решением, но показанные им характеристики в тестовых замерах оказались не удовлетворительны для нас, и от него решили отказаться в пользу собственного решения. Это было так давно, что те времена из нашей нынешней iOS-команды застал всего один человек, и описанные события восстановлены по его воспоминаниям.


Потом на смену Objective-C пришел Swift, и все больше приложений стало переходить на этот новый язык. Ну а что же мы?


Пока все переходили на Swift, мы продолжали писать на Objective-C и пользовались самописным решением для DI. В нем было реализовано все то, что нам нужно было от инструмента для внедрения зависимостей: скорость и надежность.
Скорость обеспечивалась за счет того, что не надо было регистрировать никакие зависимости в runtime. Контейнер состоял из обычных property, которые могли при необходимости предоставляться в виде:


  • обычного объекта, который создается при каждом обращении к зависимости;
  • глобального синглтона;
  • синглтона для определенного сочетания набора входных параметров.
    При этом все дочерние контейнеры создавались через lazy property у родительских контейнеров. Другими словами, граф зависимостей у нас строился на этапе компиляции проекта, а не в runtime.

Надежность обеспечивалась за счет того, что все проверки проходили в compile time. Поэтому если где-то в header контейнера мы объявили зависимость и забыли реализовать ее создание в implementation, или у зависимости не находилось какое-либо свойство в месте обращения к ней, то об этом мы узнавали на этапе компиляции проекта.


Но у этого решения был один недостаток, который нам мешал жить.


Представьте, что у вас есть граф DI-контейнеров и вам надо из контейнера в одной ветке графа пронести зависимость в контейнер из другой ветки графа. При этом глубина веток запросто может достигать 5-6 уровней.


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


  • сделать forward declaration типа новой зависимости в .h-файле дочернего контейнера;
  • объявить зависимость в качестве входного параметра конструктора в .h-файле дочернего контейнера;
  • сделать #import header с типом зависимости в .m-файле дочернего контейнера;
  • объявить зависимость в качестве входного параметра конструктора в .m-файле дочернего контейнера;
  • объявить свойство в дочернем контейнере, куда мы положим эту зависимость.

Многовато, не правда ли? И это только для проброса на один уровень ниже.


Понятно, что половину этих действий требует сама идеология разбиения кода на заголовочные файлы и файлы с имплементацией в языках семейства Cи. Но это становилось головной болью разработчика и требовало от него по сути бездумного набора copy/paste действий, которые убивают любую мотивацию в процессе разработки.


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


Но это не правильный путь. В таком случае один объект получает больше знаний, чем ему нужно для работы. Все мы проходили интервью, где рассказывали про принципы SOLID, заветы Дядюшки Боба, и вот это вот все, и знаем, что так делать не стоит. И мы достаточно долго жили только с этим решением и продолжали писать на Objective-C.


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


В плане DI настало время еще раз посмотреть на имеющиеся решения.


Нам нужен был framework, который бы обладал теми же преимуществами, что и наше самописное решение на Objective-C. При этом бы не требовал написания большого объема boilerplate кода.


На данный момент существует множество DI framework-ов на Swift. Cамыми популярными на текущий момент можно назвать Swinject и Dip. Но у этих решений есть проблемы.


А именно:


  • Граф зависимостей создается в runtime. Поэтому, если вы забыли зарегистрировать зависимость, то об этом вы узнаете благодаря падению, которое произойдет непосредственно во время работы приложения и обращения к зависимости.
  • Регистрация зависимостей так же происходит в runtime, что увеличивает время запуска приложения.
  • Для получения зависимости в этих решениях приходится пользоваться такими конструкциями языка, как force unwrap ! (Swinject) или try! (Dip) для получения зависимостей, что не делает ваш код лучше и надежнее.

Нас это не устраивало, и мы решили поискать альтернативные решения. К счастью, нам попался достаточно молодой DI framework под названием Needle.


Общая информация


Needle это open-source решение от компании Uber, которое написано на Swift и существует с 2018 года (первый коммит 7 мая 2018).


Главным преимуществом по словам разработчиков является обеспечение compile time safety кода работы для внедрения зависимостей.


Давайте разберемся как это все работает.


Needle состоит из двух основных частей: генератор кода и NeedleFoundation framework.


Генератор кода


Генератор кода нужен для парсинга DI кода вашего проекта и генерации на его основе графа зависимостей. Работает на базе SourceKit.


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


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


Сам генератор поставляется в бинарном виде. Его можно получить двумя способами:


  1. Воспользоваться утилитой homebrew:
    brew install needle
  2. Склонировать репозиторий проекта и найти его внутри:
    git clone https://github.com/uber/needle.git & cd Generator/bin/needle

Для подключения в проект необходимо добавить Run Script фазу, в которой достаточно указать путь до генератора и путь до файла, куда будет помещен сгенерированный код. Пример такой настройки:


export SOURCEKIT_LOGGING=0 && needle generate ../NeedleGenerated.swift

../NeedleGenerated.swift файл, в которой будет помещен весь генерированный код для построения графа зависимостей.


NeedleFoundation


NeedleFoundation это фреймворк, который предоставляет разработчикам набор базовых классов и протоколов для создания контейнеров с зависимостями.


Устанавливается без проблем через один из менеджеров зависимостей. Пример добавления с помощью CocoaPods:


pod 'NeedleFoundation'

Сам граф начинает строиться с создания root-контейнера, который должен быть наследником специального класса BootstrapComponent.


Остальные контейнеры должны наследоваться от класса Component.
Зависимости DI-контейнера описываются в протоколе, который наследуется от базового протокола зависимостей Dependency и указывается в качестве generic type-а самого контейнера.


Вот пример такого контейнера с зависимостями:


protocol SomeUIDependency: Dependency {    var applicationURLHandler: ApplicationURLHandler { get }    var router: Router { get }}final class SomeUIComponent: Component<SomeDependency> {    ...}

Если зависимостей нет, то указывается специальный протокол <EmptyDependency>.


Все DI-контейнеры содержат в себе lazy-свойства path и name:


// Component.swiftpublic lazy var path: [String] = {        let name = self.name        return parent.path + ["\(name)"]}()private lazy var name: String = {    let fullyQualifiedSelfName = String(describing: self)    let parts = fullyQualifiedSelfName.components(separatedBy: ".")    return parts.last ?? fullyQualifiedSelfName}()

Эти свойства нужны для того, чтобы сформировать путь до DI-контейнера в графе.


Например, если у нас есть следующая иерархия контейнеров:


RootComponent->UIComponent->SupportUIComponent,


то для SupportUIComponent свойство path будет содержать значение [RootComponent, UIComponent, SupportUIComponent].


Во время инициализации DI-контейнера в конструкторе извлекается DependencyProvider из специального регистра, который представлен в виде специального singleton-объекта класса __DependencyProviderRegistry:


// Component.swiftpublic init(parent: Scope) {     self.parent = parent     dependency = createDependencyProvider()}// ...private func createDependencyProvider() -> DependencyType {    let provider = __DependencyProviderRegistry.instance.dependencyProvider(for: self)    if let dependency = provider as? DependencyType {        return dependency    } else {        // This case should never occur with properly generated Needle code.        // Needle's official generator should guarantee the correctness.        fatalError("Dependency provider factory for \(self) returned incorrect type. Should be of type \(String(describing: DependencyType.self)). Actual type is \(String(describing: dependency))")    }}

Для того, чтобы найти нужный DependencyProvider в __DependencyProviderRegistry используется ранее описанное свойство контейнера path. Все строки из этого массива соединяются и образуют итоговую строку, которая отражает путь до контейнера в графе. Далее от итоговой строки берется hash и по нему уже извлекается фабрика, которая и создает провайдер зависимостей:


// DependencyProviderRegistry.swiftfunc dependencyProvider(`for` component: Scope) -> AnyObject {    providerFactoryLock.lock()    defer {        providerFactoryLock.unlock()    }    let pathString = component.path.joined(separator: "->")    if let factory = providerFactories[pathString.hashValue] {        return factory(component)    } else {        // This case should never occur with properly generated Needle code.        // This is useful for Needle generator development only.          fatalError("Missing dependency provider factory for \(component.path)")    }}

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


Пример обращения к зависимости:


protocol SomeUIDependency: Dependency {    var applicationURLHandler: ApplicationURLHandler { get }    var router: Router { get }}final class SomeUIComponent: Component<SomeDependency> {    var someObject: SomeObjectClass {        shared {            SomeObjectClass(router: dependecy.router)        }    }}

Теперь рассмотрим откуда берутся DependecyProvider.


Создание DependencyProvider


Как мы уже было отмечено ранее, для каждого объявленного в коде DI-контейнера создается свой DependencyProvider. Это происходит за счет кодогенерации. Генератор кода Needle анализирует исходный код проекта и ищет всех наследников базовых классов для DI-контейнеров BootstrapComponent и Component.


У каждого DI-контейнера есть протокол описания зависимостей.


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


Зависимость считается найденой только если совпадают имя и тип зависимости.


Если зависимость не найдена, то сборка проекта останавливается с ошибкой, в которой указывается потерянная зависимость. Это первый уровень обеспечения compile-time safety.


После того, как будут найдены все зависимости в проекте, генератор кода Needle создает DependecyProvider для каждого DI-контейнера. Полученный провайдер отвечает соответствующему протоколу зависимостей:


// NeedleGenerated.swift/// ^->RootComponent->UIComponent->SupportUIComponent->SomeUIComponentprivate class SomeUIDependencyfb16d126f544a2fb6a43Provider: SomeUIDependency {    var applicationURLHandler: ApplicationURLHandler {        return supportUIComponent.coreComponents.applicationURLHandler    }    // ...}

Если по каким-то причинам на этапе построения связей между контейнерами потерялась зависимость и генератор пропустил этот момент, то на этом этапе вы получите не собирающийся проект, так как поломанный DependecyProvider не будет отвечать протоколу зависимостей. Это второй уровень compile-time safety от Needle.


Теперь рассмотрим процесс поиска провайдера зависимостей для контейнера.


Регистрация DependencyProvider


Получив готовые DependecyProvider и зная связь между контейнерами, генератор кода Needle создает для каждого контейнера путь в итоговом графе.


Каждому пути сопоставляется closure-фабрика, внутри которой возвращается провайдер зависимостей. Код сопоставления создается кодогенератором.


В результате появляется глобальная функция registerProviderFactories(), которую мы должны вызвать в своем коде до первого обращения к каким-либо DI-контейнерам.


// NeedleGenerated.swiftpublic func registerProviderFactories() {    __DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: "^->RootComponent") { component in        return EmptyDependencyProvider(component: component)    }    __DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: "^->RootComponent->UIComponent") { component in        return EmptyDependencyProvider(component: component)    }        // ...}   

Сама регистрация внутри глобальной функции происходит с помощью singleton-объекта класса __DependencyProviderRegistry. Внутри данного объекта провайдеры зависимостей складываются в словарь [Int: (Scope) -> AnyObject], в котором ключом является hashValue от строки, описывающий путь от вершины графа до контейнера, а значением closure-фабрика. Сама запись в таблицу является thread-safe за счет использования внутри NSRecursiveLock.


// DependencyProviderRegistry.swiftpublic func registerDependencyProviderFactory(`for` componentPath: String, _ dependencyProviderFactory: @escaping (Scope) -> AnyObject) {    providerFactoryLock.lock()    defer {        providerFactoryLock.unlock()    }    providerFactories[componentPath.hashValue] = dependencyProviderFactory}

Результаты тестирования в проекте


Сейчас у нас порядка 430к строк кода без учета сторонних зависимостей. Из них около 83к строк на Swift.


Все замеры мы проводили на iPhone 11 c iOS 13.3.1 и с использование Needle версии 0.14.


В тестах сравнивались две ветки актуальный develop и ветка, в которой root-контейнер и все его дочерние контейнеры были переписаны на needle-конейнеры, и одна ветка контейнеров в графе полностью заменена на Needle. Все изменения для тестов проводились именно в этой ветке графа.


Проведенные тесты


Время полной сборки


Номер измерения Без Needle С Needle
1 294.5s 295.1s
2 280.8s 286.4s
3 268.2s 294.1s
4 282.9s 279.5s
5 291.5s 293.4s

Среднее значение без Needle: 283.58s


Среднее значение с Needle: 289.7s


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


Время инкрементальной сборки


Номер измерения Без Needle С Needle
1 37.8s 36.1s
2 27.9s 37.0s
3 37.3s 33.0s
4 38.2s 35.5s
5 37.8s 35.8s

Среднее значение Без Needle: 35.8s


Среднее значение С Needle: 35.48s


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


Измерения registerProviderFactories()


Среднее значение (секунды): 0.000103


Замеры:


0.00015008449554443360.00009393692016601560.00009000301361083980.00009202957153320310.00012707710266113280.00009500980377197260.00009107589721679680.00009703636169433590.00009691715240478510.0000959634780883789

В этом тесте мы выяснили, что время на запуск нашего приложения при использовании Needle почти не изменилось.


Измерения первого доступа к зависимости


Номер измерения Без Needle С Needle C Needle + FakeComponents
1 0.000069 0.001111 0.002981
2 0.000103 0.001153 0.002657
3 0.000080 0.001132 0.002418
4 0.000096 0.001142 0.002812
5 0.000078 0.001177 0.001960

Среднее значение Без Needle (секунды): 0.000085


Среднее значение C Needle (секунды): 0.001143 (+0.001058)


Среднее значение C Needle + FakeComponents (секунды): 0.002566


Примечание: SomeUIComponent в тестируемом примере лежит на седьмом уровне вложенности графа:^->RootComponent->UIComponent->SupportUIComponent->SupportUIFake0Component->SupportUIFake1Component->SupportUIFake2Component->SupportUIFake3Component->SomeUIComponent


В этом тесте мы померили скорость первоначального обращения к зависимости. Как видно, наше самописное решение тут выигрывает в десятки раз. Но если посмотреть на абсолютные цифры, то это очень незначительное время.


Измерения повторного доступа к BabyloneUIComponent c Needle


Номер измерения Без Needle С Needle C Needle + FakeComponents
1 0.000031 0.000069 0.000088
2 0.000037 0.000049 0.000100
3 0.000053 0.000054 0.000082
4 0.000057 0.000064 0.000092
5 0.000041 0.000053 0.000088

Среднее значение без Needle (секунды): 0.000044


Среднее значение с Needle (секунды): 0.000058


Среднее значение с Needle + FakeComponents (секунды):0.000091


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


Выводы


В итоге по результатам тестов мы пришли к выводу, что Needle дает нам именно то, что мы хотели от DI-фреймворка.


Он дает нам надежность благодаря обеспечению compile time safety кода зависимостей.


Он быстрый. Не такой быстрый, как наше самописное решение на Objective-C, но все же в абсолютных цифрах он достаточно быстрый для нас.


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


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


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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru