Неэргономичный 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) паттерн (до которого я ещё не дошёл в этом канале):
- отдельно чистая бизнес-логика (transform);
- отдельно эффективный порт (Writer);
- и юзкейс/воркфлов (writeValueAsString, writeValue) их связывающий.
Разделение логики и эффектов даёт юзерам кубики из которых каждый юзер может собрать нужный именно ему юз кейс. А юз кейсы которые кажутся наиболее востребованными можно сразу включить в поставку.
Я не то чтобы пинаю авторов Jackson-а, и вижу как минимум три причины сделать так как есть:
- Я не уверен, что вариант с сиквенсами можно сделать таким же быстрым как и запись сразу в выходной поток, и для них это критично;
- Скорее всего, когда они писали этот код, ещё не было моды на функциональщину и в яве просто не было Stream API;
- Сделать сразу хороший дизайн сложно, а если ты автор популярной библиотеки и уважаешь своих юзеров, то твои "ошибки молодости" с тобой на вечно, из-за обратной совместимости.
Но мы, вооружённые знаниями об эргономичном подходе, опытом чужих ошибок и либой сиквенсов, можем сразу проектировать своё приложение эргономичным. Если требования по перформансу позволяют:)
Этот пост написано преимущественно в контексте разработки библиотек (Jackson), но применим и к разработке прикладных программ.
Ядро/домен системы следует можно рассматривать как стандартную библиотеку, которой пользуется ваша прикладная программа.
Основное преимущество такого подхода заключается в том, что хорошую библиотеку могут использовать много приложений, а у вас их должно быть как минимум два - собственно боевое приложение и тесты.
Но вообще "Модуль домена как библиотека" - это тема отдельного поста, поэтому подробности как-нить в другой раз:)
P.s. возможно вы скажете, что ленивый Sequence != энергичный Writer и его писать сложнее непривычнее, но благодоря корутинам даже это не так:
val obj = ""
val seq = sequence<Char> {
yield('{')
obj::class.memberProperties.forEach {
yieldAll("\"${it.name}\": ${it.call()}".asSequence())
}
yield('}')
}