Неэргономичный Jackson

February 23, 2021

В последнее вермя несколько раз писал примерно такой код для сериализации объекта в json Jackson-ом:

public String renderToJson(Object dto) {
    try {
        return objectMapper.writeValueAsString(dto);
    } catch (IOException e) {
        throw new AssertionError("Unexpected IOException converting object to json");
    }
}

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

А тут за чтением The modern way to perform error handling из глубин подсознания внезапно всплыл ответ:) И знаете что я вам скажу? Это всё в тот же топик смеси политик/алгоритмов и эффектов и проблем вызываемых этим.

Если заглянуть в кишочки writeValueAsString, то этот чистый по сути метод является обёрткой вокруг _writeValueAndClose(JsonGenerator g, Object value). g - это обёртка вокруг java.io.Writer. Writer выбрасывает java.io.IOException. И привет IOException при генерации строки в памяти.

Эта проблема обусловлена смешанием в одной функции (_writeValueAndClose) политики (правил формирования json-а по ява-объекту) и эффекта (записи результата формирования в абстрактный приёмник).

Для того чтобы этот код сделать эргономичиным, надо растащить две эти "штуки":

fun transform(value: Any): Sequence<Char> =
  // весь этот адовый, но чистый алгоритм по ренедренду json-а

fun writeValueAsString(value: Any) = transform(value).joinToString()

@Throws(IOException::class)
fun writeValue(value: Any, w: Writer) = transform(value).forEach { w.append(it) }

Это всё тот же эргономичный (ака ROP, ака Functional Core/Imperative Shell) паттерн (до которого я ещё не дошёл в этом канале):

  1. отдельно чистая бизнес-логика (transform);
  2. отдельно эффективный порт (Writer);
  3. и юзкейс/воркфлов (writeValueAsString, writeValue) их связывающий.

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

Я не то чтобы пинаю авторов Jackson-а, и вижу как минимум три причины сделать так как есть:

  1. Я не уверен, что вариант с сиквенсами можно сделать таким же быстрым как и запись сразу в выходной поток, и для них это критично;
  2. Скорее всего, когда они писали этот код, ещё не было моды на функциональщину и в яве просто не было Stream API;
  3. Сделать сразу хороший дизайн сложно, а если ты автор популярной библиотеки и уважаешь своих юзеров, то твои "ошибки молодости" с тобой на вечно, из-за обратной совместимости.

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

Этот пост написано преимущественно в контексте разработки библиотек (Jackson), но применим и к разработке прикладных программ. Ядро/домен системы следует можно рассматривать как стандартную библиотеку, которой пользуется ваша прикладная программа. Основное преимущество такого подхода заключается в том, что хорошую библиотеку могут использовать много приложений, а у вас их должно быть как минимум два - собственно боевое приложение и тесты. Но вообще "Модуль домена как библиотека" - это тема отдельного поста, поэтому подробности как-нить в другой раз:)


P.s. возможно вы скажете, что ленивый Sequence != энергичный Writer и его писать сложнее непривычнее, но благодоря корутинам даже это не так:

val obj = ""
val seq = sequence<Char> {
    yield('{')
    obj::class.memberProperties.forEach {
        yieldAll("\"${it.name}\": ${it.call()}".asSequence())
    }
    yield('}')
}