Журнал

Автоматизация загрузки логов из Kibana в Redmine

Типичный юзкейс для Kibana - смотрим логи, видим ошибки, создаем тикеты по ним. Логов у нас довольно много, места для их хранения мало. Поэтому просто вставить ссылку на документ из Elasticsearch/Kibana недостаточно, особенно для низкоприоритетных задач: пока доберемся до нее, индекс с логом может быть уже удален. Соответственно, приходится документ сохранять в файл и прикреплять к тикету.

Если один раз это делать, то это еще куда ни шло, но создавать уже десять тикетов подряд будет тупо лень, поэтому я решил это “быстренько” (ха-ха) автоматизировать.


Под катом: статья для пятницы, экспериментальная фича javascript, пара грязных хаков, небольшая регулярка с галочками, reverse proxy, проигрыш безопасности удобству, костыли и очевидная картинка из xkcd. Предупреждаю: я далеко не специалист в web-технологиях, и поэтому специалистам, скорее всего, покажутся мои проблемы очень очевидными, а решения - тупыми. Но это не продакшн-решение, а просто мелкий скрипт "для своих". Все происходит в доверенной локальной сети и поэтому скрипт имеет много проблем с безопасностью.

Варианты решения

Сходу можно придумать достаточно много решений проблемы. Во-первых, можно пихать сразу все логи в RM (внезапно, для этого даже есть плагин logstash), предварительно их фильтруя/агрегируя - знай себе меняй описание да исполнителя. Это, конечно, прикольно, но надо будет долго отлаживать/настраивать и появится много новой рутинной работы - давать описания/удалять лишнее.

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

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

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

Плагин Redmine? Не, это ж API надо будет изучать, да и лезть в кишки RM… Простого дополнительного поля недостаточно будет.

В итоге приходим к букмарклету (выполнению javascript из закладок) и/или пользовательскому скрипту (greasemonkey/tampermonkey и т.п.) - javascript‘ом вроде можно и интерфейс нарисовать, и логи скачать через ajax-запрос, да и вообще почти все что угодно со страницей сделать.

Загрузка файлов

Пока самая неясная часть - это загрузка файлов. Все остальное вроде можно легко сделать… За загрузку файлов на странице создания тикета RM отвечает обычный <input type="file">, при изменении которого вызывается функция addInputFiles(this).

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


Сделано это ради того, чтобы нельзя было отправить на сервер /etc/passwd, /etc/shadow/ или фото вашего кота с рабочего стола. В принципе, разумно, но надо это как-то обойти. Впрочем, если нельзя, но очень хочется, то можно заиспользовать такой грязный хак, который основан экспериментальной фиче - Clipboard API.
function createFileList(files){
    const dt = new ClipboardEvent("").clipboardData || new DataTransfer();
    for (let file of files) {
        dt.items.add(file);
    }
    return dt.files;
}

Т.е. тут имитируется добавление файлов из буфера обмена, которые мы потом получаем списком. Сам по себе “файл” из текста создается элементарно:

function createFile(text, fileName){
    let blob = new Blob([text], {type: 'text/plain'});
    let file = new File([blob], fileName);
    return file;
}

Пользовательский интерфейс

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

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

Элементарные вещи
function removeSelf(){
    let old = document.getElementById(ui_id);
    if (old != null) old.remove();
}

function createUi(){
    removeSelf();

    let ui = document.createElement('p');
    ui.id = ui_id;

    let label = document.createElement('label');
    label.innerHTML = "Logs data:";
    ui.appendChild(label);

    let textarea = document.createElement('textarea');
    textarea.id = data_id;
    textarea.cols = 60;
    textarea.rows = 10;
    textarea.name = "issue[logs_data]";
    ui.appendChild(textarea);

    let button = document.createElement('button');
    button.type = "button";
    button.onclick = addLogsData;
    button.innerHTML = "Add logs data";
    ui.appendChild(button);

    let attributesBlock = document.querySelector("#attributes");
    attributesBlock.parentNode.insertBefore(ui, attributesBlock);
}

Основная работа

Здесь тоже все просто: разбиваем текст из поля ввода на пары "ссылка"-"имя файла", скачиваем все из эластика, потому что Kibana так просто данные не отдаст, заливаем на RM, изменяем описание тикета и все. Благо к RM уже подключен jquery и ajax-запросы легко создаются.
Скучный код, регулярку искать здесь
function addLogsData(){
    let text = document.getElementById(data_id).value;
    let lines = text.split('\n');
    let urlsAndNames = lines
        .filter(x => x.length > 2)
        .map(line => line.split(/\s+/, 2));
    downloadUrlsToFiles(urlsAndNames);
}

const kibana_pattern = /http:\/\/([^:]*):\d+\/app\/kibana#\/doc\/[^\/]*\/([^\/]*)\/([^\/]*)\/?\?id=(.*?)(&.*)?$/;
const es_pattern = 'http://$1:9200/$2/$3/$4';

function downloadUrlsToFiles(urlsAndNames){
    let requests = urlsAndNames.map((splitted) => {
        let url = splitted[0].replace(kibana_pattern, es_pattern);
        return $.ajax({
            url: url,
            dataType: 'json'
        });
    });
    $.when(...requests).done(function(...responses){
        let files = responses.map((responseRaw, index) => {
            let response = responseRaw[0];
            checkError(response);
            let fileName = urlsAndNames[index][1];
            return createFile(JSON.stringify(response._source), fileName + '.json');
        });
        uploadFiles(files, urlsAndNames);
    }).fail((error) => {
        let errorString = JSON.stringify(error);
        alert(errorString);
        throw errorString;
    });
}

function uploadFiles(files, urlsAndNames){
    pseudoUpload(files);

    changeDescription(urlsAndNames);
    removeSelf();
}

Отлично, все готово! Делаем тестовый запуск и…

Безопасность

Для тех кто не в курсе, запрашивать http-данные, находясь на https ресурсе, - очень плохо, потому что вам могут подпихнуть левые данные через MITM атаку. Более того, какой-нибудь Firefox даже если вам и разрешит это сделать, просить у него разрешение надо будет каждый раз - и белого списка никогда не будет. Это все правильно и хорошо с точки зрения пользователя, но для скрипта на коленке это только палки в колеса.

Что ж, покупать X-Pack для Elasticsearch ради вшивого скрипта не хочется, поэтому придется сделать прокси https -> http. Он же reverse proxy. Вариантов тут достаточно много, от монструозного squid до питонячего скрипта. Самым подходящим мне показался haproxy - он и прост в настройке/установке, и ресурсы не жрет.

Достаточно лишь сгенерить самоподписанный сертификат (прости, let‘s encrypt, но мы в траст-зоне)

openssl genrsa -out dummy.key 1024
openssl req -new -key dummy.key -out dummy.csr
openssl x509 -req -days 3650 -in dummy.csr -signkey dummy.key -out dummy.crt
cat dummy.crt dummy.key > dummy.pem

и, собственно, настроить haproxy:

frontend https-in
    mode tcp
    bind *:9243 ssl crt /etc/ssl/localcerts/dummy.pem alpn http/1.1
    http-response set-header Strict-Transport-Security "max-age=16000000; includeSubDomains; preload;"
    default_backend nodes-http

backend nodes-http
    server node1 localhost:9200 check

Теперь на порту 9243 будет прозрачная прокси до эластика (соответственно, меняем порт в регулярке и добавляем https).

Однако и это не удовлетворит наш браузер, который печется о безопасности пользователя. На этот раз проблема в том, что нельзя запрашивать данные с другого домена, если он это не разрешил. Решается это с помощью механизма CORS. Хорошо хоть, что Elasticsearch это сам умеет:

http.cors.allow-headers: X-Requested-With, Content-Type, Content-Length
http.cors.allow-origin: "/.*/"
http.cors.enabled: true

Userscript

Напомню, что мы все еще втирали делали эту дичь в формате букмарклета. В принципе, ничего страшного, но кому-то даже лишний раз кликнуть лень (например, мне). Поэтому будем делать userscript. Тут заодно встает проблема его обновления (делаем-то на века!). Поэтому воспользуемся механизмом обновления юзерскриптов кого я обманываю, конечно, очередным костылем:
// ==UserScript==
// @name     KIBANA_LOGS
// @grant    none
// @include  https://<rm-address>/*issues*
// ==/UserScript==
(function(){document.body.appendChild(document.createElement('script')).src='https://<kibana-address>:4443/kibana_logs_rm.js';})();

Зато и в букмарклетах у параноиков будет обновляться. Для раздачи этой фигни нам понадобится https-сервер. Тут я уже откровенно заленился и взял первый попавшийся (да еще и на python 2.7) *посыпаю голову пеплом*:

import BaseHTTPServer, SimpleHTTPServer
import ssl

httpd = BaseHTTPServer.HTTPServer(('0.0.0.0', 4443), 
                    SimpleHTTPServer.SimpleHTTPRequestHandler)
httpd.socket = ssl.wrap_socket(httpd.socket, certfile='/etc/ssl/localcerts/dummy.pem',
                            server_side=True)
httpd.serve_forever()

Вот теперь пользователям осталось только создать юзерскрипт/букмарклет, добавить в исключения сертификат и все будет работать.

Пара багов

Суть первой проблемы заключается в следующем: когда нужно обработать результаты сразу нескольких ajax-запросов, в функцию передается столько аргументов, сколько было запросов. Но когда запрос один, jquery "любезно" его раскрывает в три аргумента. Поэтому пришлось писать такой костыль:
let responses;
if (requests.length == 1){
    responses = [arguments];
} else {
    responses = Array.from(arguments);
}

Второй баг связан с тем, что при смене трекера или при смене статуса заявки Redmine сохраняет все введенные данные, запрашивает новый интерфейс (прямо html cо встроенным js), пересоздает интерфейс и перезаполняет поля с помощью функции replaceIssueFormWith. Звучит немного дико, но это сделано для реализации workflow (а там на разных стадиях поля для ввода потенциально могут отличаться). Тут тоже пришлось сделать костыль ad-hoc решение:

function installReplaceHook(){
    let original = window.replaceIssueFormWith;
    window.replaceIssueFormWith = function(html){
        let logs_data = document.getElementById(data_id).value;
        let ret = original(html);
        createUi();
        document.getElementById(data_id).value = logs_data;
        return ret;
   };
}

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

Заключение

Полную версию скрипта можно посмотреть в моем gist. Вот картинка, которую должно большинство ожидать к концу этой статьи:

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

СсылкаКомментировать

SmartRhino 2018

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

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

Презентация

UPD: Оригинал был удален из-за удаления канала.

Ссылка

Опыт использования библиотеки Puniverse Quasar для акторов

С появлением котлиновских корутин и маячащим релизом project loom эта статья потеряла свою актуальность

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

Библиотека вроде достаточно зрелая, почти 3 тысячи звезд на гитхабе, больше 300 форков, пара рекомендаций на Хабре… Почему бы и нет? Наш проект стартовал в феврале 2017, писали на Kotlin.

Казалось бы, что могло пойти не так?

Вкратце о библиотеке

Разработчик
Документация
GitHub

Основное предназначение библиотеки - легковесные потоки (fibers), уже поверх которых реализованы Go-подобные каналы, Erlang-подобные акторы, всякие реактивные плюшки и другие подобные вещи “для асинхронного программирования на Java и Kotlin”. Разрабатывается с 2013 года.

Настройка сборки

Т.к. проект на котлине, сборка будет на gradle. Важный момент: для работы легковесных потоков необходимы манипуляции с Java байт-кодом (instrumentation), которые обычно делают с помощью java-агента. Этого агента quasar любезно предоставляет. На практике это означает, что:

​Для начала нам понадобится добавить конфигурацию quasar:

configurations {
    quasar
}

Подключим зависимости:

dependencies {
    compile("org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version") // котлин

    compile("co.paralleluniverse:quasar-core:$quasar_version:jdk8") // основные функции quasar
    compile("co.paralleluniverse:quasar-actors:$quasar_version") // акторы
    compile("co.paralleluniverse:quasar-kotlin:$quasar_version") // обертки для котлина
    quasar "co.paralleluniverse:quasar-core:$quasar_version:jdk8" // для java-агента

    //... и другие
}

Говорим, что все gradle-таски надо запускать с java-агентом:

tasks.withType(JavaForkOptions) {
    //uncomment if there are problems with fibers
    //systemProperty 'co.paralleluniverse.fibers.verifyInstrumentation', 'true'

    jvmArgs "-javaagent:${(++configurations.quasar.iterator())}"
}

Cвойство co.paralleluniverse.fibers.verifyInstrumentation отвечает за проверку в рантайме корректности манипуляций с байт-кодом. Разумеется, если эта проверка включена, то все начинает тормозить:)

Для релиза написал еще функцию для генерации bat/sh файлов, которые запускают приложение с java-агентом. Ничего особо интересного, просто создать файлик и прописать туда нужную строку запуска, с нужной версией quasar‘a:

def createRunScript(String scriptPath, String type) {
    def file = new File(scriptPath)
    file.createNewFile()
    file.setExecutable(true)
    def preamble = "@echo off"
    if (type == "sh") {
        preamble = "#!/bin/bash"
    }
    def deps = configurations.quasar.files.collect { "-Xbootclasspath/a:\"libs/${it.name}\"" }.join(" ")
    def flags = "-Dco.paralleluniverse.fibers.detectRunawayFibers=false"
    def quasarAgent = configurations.quasar.files.find { it.name.contains("quasar-core") }.name
    file.text = """$preamble
java -classpath "./*.jar" -javaagent:"libs/$quasarAgent" $deps $flags -jar ${project.name}.jar
"""
}

И таск release, который создает отдельную папку со всем необходимым:

task release(dependsOn: ['build']) {
    group = "Build"
    def targetDir = "$buildDir/release"
    doLast {
        copy {
            from "$buildDir/libs/${project.name}.jar"
            into targetDir
        }
        copy { //копируем все библиотеки quasar, чтобы javaagent мог их подцепить
            from(configurations.quasar.files)
            into "$targetDir/libs"
        } 
        copy { // конфиг по умолчанию, раз уж релиз делаем все равно
            from("src/main/resources/application.yml")
            into targetDir
        }

        //скрипты для запуска
        createRunScript("$targetDir/${project.name}.bat", "bat")  
        createRunScript("$targetDir/${project.name}.sh", "sh")
    }
}

Посмотреть подробнее пример можно в моем gist или в официальном примере для gradle. Теоретически, вроде как существует возможность изменить байт-код на стадии компиляции и не использовать java-агент. Для этого в quasar есть ant-таск. Однако даже с вагоном костылей и изоленты настроить его у меня не удалось.

Использование акторов

Перейдем собственно к акторам. В моем понимании основа акторов - это постоянный обмен сообщениями. Однако из коробки Quasar представляет только универсальный co.paralleluniverse.kotlin.Actor с методом receive. Для постоянного обмена пришлось реализовать небольшую прослойку:
abstract class BasicActor : Actor() {

    @Suspendable
    abstract fun onReceive(message: Any): Any?

    @Suspendable
    override fun doRun() {
        while (true) {
            receive { onReceive(it!!) }
        }
    }

    fun <T> reply(incomingMessage: RequestMessage<T>, result: T) {
        RequestReplyHelper.reply(incomingMessage, result)
    }
}

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

Кроме того, с переходом на Kotlin 1.1 у библиотеки начались проблемы, которые не решены до сих пор (привожу кусок их кода):

// TODO Was "(Any) -> Any?" but in 1.1 the compiler would call the base Java method and not even complain about ambiguity! Investigate and possibly report
inline protected fun receive(proc: (Any?) -> Any?) {
    receive(-1, null, proc)
}

Из-за этого в нашем BasicActor пришлось сделать обертку для receive. Ну и для понятности был сделан метод reply и extenstion-метод ask:

@Suspendable
fun <T> ActorRef<Any>.ask(message: RequestMessage<T>): T {
    return RequestReplyHelper.call(this, message)
}

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

Очень важна аннотация @Suspendable - при использовании quasar ее надо вешать на все методы, которые обращаются к другим акторам или легковесным потокам, иначе получите в рантайме исключение SuspendExecution, и толку от “легковесности” не будет. С точки зрения разработчиков библиотеки - очевидно, что это нужно для java-агента, но с точки зрения программиста-пользователя - это неудобно (существует возможность сделать это автоматически, но будет это далеко не бесплатно).

Дальше, реализация актора сводится к переопределению метода onReceive, что достаточно просто можно сделать с помощью when, делая что-то в зависимости от типа сообщения:

override fun onReceive(message: Any) = when (message) {
    is SomeMessage -> {
        // Do stuff

       val someotherActor = ActorRegistry.getActor("other actor") 
       someotherActor.send(replyOrSomeCommand)        
    }

    is SomeOtherMessage -> {
        process(message.parameter) // работает smart-cast

        val replyFromGuru = guruActor.ask(Question("Does 42 equals 7*6?")) 
        doSomething()
    }

    else -> throw UnknownMessageTypeException(message)
}

Для того, чтобы получить ссылку на актор, надо обратиться к статическому методу ActorRegistry.getActor, который по строковому идентификатору вернет ссылку на актор.

Осталось только акторы запустить. Для этого надо актор сначала создать, потом зарегистрировать, и наконец запустить:

val myActor = MySuperDuperActor()
val actorRef = spawn(register(MY_ACTOR_ID, myActor))<

(Почему нельзя было сразу это одним методом сделать - неясно).

Некоторые проблемы

Как вы думаете, что произойдет, если актор упадет с исключением?

А ничего. Ну упал актор. Теперь он сообщения принимать не будет, ну и что. Великолепное поведение по умолчанию!

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

class WatcherActor : BasicActor(), ILogging by Logging<WatcherActor>() {
    override fun handleLifecycleMessage(lcm: LifecycleMessage): Any? {
        return onReceive(lcm)
    }

    override fun onReceive(message: Any): Any? = when (message) {
        is ExitMessage -> {
            log.fatal("Actor ${message.actor.name} got an unhandled exception. Terminating the app. Reason: ", message.getCause())
            exit(-2)
        }
        else -> {
            log.fatal("Got unknown message for WatcherActor: $message. Terminating the app")
            exit(-1)
        }
    }

}

Но для этого приходится запускать акторы с привязкой к наблюдателю:

@Suspendable
fun registerAndWatch(actorId: String, actorObject: Actor<*, *>): ActorRef<*> {
    val ref = spawn(register(actorId, actorObject))
    watcherActor.link(ref)
    return ref
}

Вообще, по впечатлениям, многие моменты были неудобны или неочевидны. Возможно, “мы просто не умеем готовить” Quasar, но после Akka некоторые моменты выглядят диковато. Например, метод для реализации запроса по типу ask от Akka, который где-то закопан в утилитах и еще требует связывать типы сообщения-вопроса и сообщения-ответа (хотя с другой стороны, это неплохая фича, которая уменьшает число потенциальных ошибок).

Еще одна серьезная проблема возникла с завершением актора. Какие стандартные методы для этого есть? Может быть destroy, unspawn или unregister? А вот и нет. Только костыли:

fun <T : Actor<Any?, Any?>> T.finish() {
    this.ref().send(ExitMessage(this.ref(), null))
    this.unregister()
}

Есть конечно ActorRegistry.clear(), который удаляет ВСЕ акторы, но если залезть к нему в кишочки, то можно увидеть следующее:

public static void clear() {
    if (!Debug.isUnitTest())
        throw new IllegalStateException("Must only be called in unit tests");
    if (registry instanceof LocalActorRegistry)
        ((LocalActorRegistry) registry).clear();
    else
        throw new UnsupportedOperationException();
}

Ага, только в юнит-тестах можно вызывать. А как же они это определяют?

boolean isUnitTest = false;
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
for (StackTraceElement ste : stack) {
    if (ste.getClassName().startsWith("org.junit")
            || ste.getClassName().startsWith("junit.framework")
            || ste.getClassName().contains("JUnitTestClassExecuter")) {
        isUnitTest = true;
        break;
    }
}
unitTest = isUnitTest;

Т.е. если вы вдруг используете не junit - до свидания.

Погодите-погодите, вот же метод ActorRegistry.shutdown(), он то наверняка вызвает у каждого актора закрытие! Смотрим реализацию абстрактного метода в LocalActorRegistry:

    @Override
    public void shutdown() {
    }

Еще один момент, библиотека может таинственно падать с каким-нибудь NPE без видимых на то причин/объяснений:

https://github.com/puniverse/quasar/issues/182

Кроме того, если вы используете сторонние библиотеки, с ними могут возникнуть проблемы. Например, в одной из зависимостей у нас была библиотека, которая общалась с железом (не очень качественная), в которой был Thread.sleep(). Quasar‘у это очень не понравилось, и он плевался логами с исключениями: мол, Thread.sleep() блокирует поток и это плохо скажется на производительности (см. подробнее здесь). При этом конкретных рецептов, как это исправить (кроме как тупо отключить логирование таких ошибок системным флагом) или хотя бы “понять и простить” только для сторонних библиотек, Parallel Universe не дают.

Ну и напоследок, поддержка Kotlin оставляет желать лучшего - например проверка java-agent будет ругаться на некоторые его методы (хотя само приложение при этом может продолжать работать без видимых проблем):

https://github.com/puniverse/quasar/issues/238 https://github.com/puniverse/quasar/issues/288

В целом отлаживать работу приходилось по логам - и это было довольно грустно.

Заключение

В целом впечатления от библиотеки нейтральны. По впечатлениям, акторы в ней реализованы на уровне "демонстрации идеи" - вроде работает, но есть проблемы, которые обычно всплывают при первом боевом применении. Хотя потенциал у библиотеки есть был.

Нам еще “очень повезло”: внимательный читатель мог заметить, что последний релиз был в декабре 2016 (по документации) или в июле 2017 (по гитхабу). А в бложике компании последняя запись вообще в июле 2016 (с интригующим заголовком Why Writing Correct Software Is Hard). В общем, библиотека скорее мертва, чем жива, поэтому в продакшене ее лучше не использовать.

P. S. Тут еще внимательный читатель может спросить - а что же тогда Akka не использовали? В принципе, с ней никаких криминальных проблем не было (хотя по сути получалась цепочка Kotlin-Java-Scala), но т.к. проект был некритичный, решили попробовать “родное” решение.

Скрин опроса:

СсылкаКомментировать

Зачем мне твои неизменяемые коллекции? Они же медленные

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

Бывает так, что когда человек начинает писать на Kotlin, Scala или %language_name%, он делает манипуляции с коллекциями так, как привык, как “удобно”, как “понятнее” или даже “как быстрее”. При этом, разумеется, используются циклы и изменяемые коллекции вместо функций высшего порядка (таких как filter, map, fold и др.) и неизменяемых коллекций.

Эта статья - попытка убедить ортодоксов, что использование “этой вашей функциональщины” не влияет существенно на производительность. В качестве примеров использованы куски кода на Java, Scala и Kotlin, а для измерения скорости был выбран фреймворк для микробенчмаркинга JMH.

Для тех, кто не в курсе, JMH (Java Microbenchmark Harness) - это сравнительно молодой фреймворк, в котором разработчики постарались учесть все нюасы JVM. Подробнее о нем можно узнать из докладов Алексея Шипилева (например, из этого). На мой взгляд, проще всего с ним познакомится на примерах, где вся необходимая информация есть в javadoc.

Самая интересная часть в написании бенчмарков заключалась в том, чтобы заставить JVM реально что-то делать. Эта умная зараза заранее знала ответ на все и выдавала ответы за мизерное время. Именно поэтому в их исходном коде фигурирует prepare, чтобы обхитрить JVM. Кстати, Java Streams с предвычисленным результатом работали на порядок медленнее других способов.

Dicslaimer: все прекрасно знают, что написать бенчмарк правильно - невозможно, но предложения по тому, как написать его менее неправильно приветствуются. Если вам кажется, что обязательно надо рассмотреть пример с X, не стесняйтесь писать в комментариях, что еще надо потестить. Кроме того, несмотря на то, что на одном графике будут и Kotlin, и Scala, и Java, считать это сравнением скорости кода на этих языках не стоит. Как минимум, в примерах на Scala есть накладные расходы на конвертацию scala.Intjava.lang.Integer и используются не Java-коллекции, а свои.

Исходный код примеров можно посмотреть на гитхабе, а все результаты в CSV - здесь. В качестве размера коллекций использовались значения от 10 до 100 000. Тестировать для бОльших размеров я особо не вижу смысла - для этого есть СУБД и другие способы работы с большими объемами данных. Все графики выполнены в логарифмической шкале и показывают среднее время выполнения операции в наносекундах.

Простой map

Начнем с самых простых примеров, которые есть в почти каждой статье про элементы функционального программирования: map, filter, fold и flatMap. В Java циклом преобразовывать коллекции немного быстрее, чем с использованием Streams API. Очевидно, что дело в накладных расходах на преобразование в stream, которые здесь себя не оправдывают. В Kotlin преобразование с использованием map будет быстрее, чем цикл, причем даже быстрее, чем цикл на Java. Почему же это происходит? Смотрим исходный код map:
@PublishedApi
internal fun <T> Iterable<T>.collectionSizeOrDefault(default: Int): Int = 
      if (this is Collection<*>) this.size else default
...
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(
    destination: C, 
    transform: (T) -> R
): C {
   for (item in this)
       destination.add(transform(item))
   return destination
}
...
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
   return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

Получаем выигрыш за счет того, что заранее известен конечный размер коллекции. Конечно можно и в Java так сделать, но часто ли вы такое встречали?

В Scala разница между циклом и map уже ощутима. Если в Kotlin map довольно прямолинейно конвертируется в цикл, то в Scala уже не все так просто: если неявный CanBuildFrom - переиспользуемый ReusableCBFInstance из списка, то применяется хитрый while (заинтересованные могут посмотреть исходный код скаловского map для списка здесь).

Простой filter

В Java для коллекций очень маленького размера Stream API немного медленнее, чем цикл, но, опять же, несущественно. Если коллекции нормального размера - разницы вообще нет.

В Kotlin разницы практически нет (прозрачная компиляция в цикл), но при больших объемах цикл чуть медленнее.

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

Простой fold

Не совсем подходящий пример (потому что на выходе число), но все равно довольно интересный. Во всех трех языках различия между итеративной формой и функциональной несущественны, хотя fold в Scala работает медленнее, чем хотелось бы.

Простой flatMap

Снова разница между подходами практически неразличима.

Цепочка преобразований

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

При этом если у вас все-таки получилась длинная цепочка преобразований, то имеет смысл перейти на ленивые вычисления (т.е. применять преобразования только тогда, когда необходимо). В Kotlin это преобразование к Sequence, в Scala - iterator. Streams в Java всегда выполняются лениво.

Рассмотрим цепочку flatMapfilterfold:

someList
       .flatMap(this::generate)
       .filter(this::filter)
       .fold(initialValue(), this::doFold)
Разница по скорости между императивным подходом и функциональным снова весьма мала. Исключение составляет Scala, в которой функциональный подход медленнее цикла, но с iterator разница сводится к нулю.

Цепочка со вложенными преобразованиями

Рассмотрим последовательность преобразований, где элементами промежуточной коллекции тоже являются коллекции, и над которыми тоже надо выполнять действия. Например топ-10 чего-то по какой-нибудь хитрой метрике:
someList
    .groupBy(this::grouping)
    .mapValues {
        it.value
            .filter(this::filter)
            .map(this::transform)
            .sum()
    }
    .entries
    .sortedByDescending { it.value }
    .take(10)
    .map { it.key }

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

Map<Integer, Integer> sums = new HashMap<>();
for (Integer element : someList) {
   Integer key = grouping(element);
   if (filter(element)) {
       Integer current = sums.get(key);
       if (current == null) {
           sums.put(key, transform(element));
       } else {
           sums.put(key, current + transform(element));
       }
   }
}
List<Map.Entry<Integer, Integer>> entries = new ArrayList<>(sums.entrySet());
entries.sort(Comparator.comparingInt((x) -> -x.getValue()));
ArrayList<Integer> result = new ArrayList<>();
for (int i = 0; i < 10; i++) {
   if (entries.size() <= i){
       break;
   }
   result.add(entries.get(i).getKey());
}
return result;

Заключение

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

Не экономьте на спичках: вы же не пишете на ассемблере, потому что “так быстрее”? Действительно, может получиться быстрее, а может и нет. Компиляторы сейчас умнее одного программиста. Доверьте оптимизации им, это их работа.

Если вы все еще используете какой-нибудь mutableListOf, задумайтесь. Во-первых, это больше строк кода и бОльшая вероятность сделать ошибку. Во-вторых, вы можете потерять оптимизации, которые появятся в будущих версиях языка (или уже есть там). В-третьих, при функциональном подходе лучше инкапсуляция: разделить filter и map проще, чем разбить цикл на два. В-четвертых, если вы уж пишете на языке, в котором есть элементы ФП и пишете “циклами”, то стоит следовать рекомендациям и стилю (например, Intellij Idea вам будет настоятельно рекомендовать заменить for на соответствующее преобразование).

СсылкаКомментировать