Ленивые вычисления для реализации функциональной архитектуры

May 7, 2022

Микропост в догонку к посту о книге.

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

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

Однако не всегда такое разделение даётся легко и/или без ущерба производительности. Например, если есть две операции чтения и одна зависит от другой.

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

"Безпринципная" реализация
fun compressFile(fileId: Long) {
    val fileMeta = findFileMeta(fileId) // <- ввод
    if (fileMeta.isCompressible()) { // <- бинзнес-логика
        throw FileNotCompressible()
    }
    val content = fetchFile(fileId) // <- снова ввод
    val compressed = compress(content) // <- снова бизнес-логика
    filesStorage.putFile(fileMeta.buildFileRelativeUri(), compressed) // <- вывод
}

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

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

Функциональная реализация
fun compressFileById(fileId: Long) {
    // ввод
    val fileMeta = findFileMeta(fileId)
    val content = lazy { fetchFile(fileId) }


    // бизнес-логика
    val compressed = compressFile(fileMeta, content)

    // вывод
    filesStorage.putFile(fileMeta.buildFileRelativeUri(), compressed)
}

fun compressFile(fileMeta: FileMeta, file: Lazy<InputStream>): InputStream {
    if (!fileMeta.isCompressible()) {
        throw FileNotCompressible()
    }

    return ByteArrayInputStream(byteArrayOf(42, 43))
}

Теперь для того чтобы протестировать бизнес-требование "Система не должна позволять сжимать изображения, для которых запрещено сжатие" мы можем написать чистый юнит тест, не заморачиваясь на сетап хранилища файла, Spring-а и моков:

Чистый тест бизнес-логики
fun `Compressor не должен сжимать файлы, сжатие которых запрещено`() {
    // Сетап
    val incompressibleFileMeta = FileMeta(/* фикстура */)
    val fakeFile = lazy { ByteArrayInputStream(byteArrayOf())}

    // Действие
    val result = runCatching { compressFile(incompressibleFileMeta, fakeFile)}

    // Проверка
    res.shouldBeFailure<FileNotCompressible>()
}

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