Привет! Сегодня хочется обсудить вопрос того, почему мы пишем такой код. Как всегда, если есть вопросы, то пишите мне в телеграм, больше контента от меня можно найти на канале.
Что я хотел сказать этим всем? Я хотел сказать, что мы не просто пишем код. Каждая строка кода, каждое решение — стоит на плечах тех, кто писал код до нас и вместе с нами. Если мы хотим, чтобы код был таким, каким мы его хотим видеть — надо писать его таким. Начав делать вещи верно, верные мысли будут поражать такие же. Но никогда не надо забывать, почему вещи делаются именно таким способом, милым всем не будешь, любой плюс может обернуться минусом. Хочется, чтобы каждый писал код и мог сказать, почему он так написал в объективных оценках, а не плохо/хорошо/сложно/просто. Я не призываю делать хорошо — чтобы было хорошо, хоть так можно и подумать. Я призываю подходить к коду осознанно, понимая, что любая фича взялась не просто так и должна также не просто так применяться.
Все совпадения с реальным кодом случайны. Код является псевдокодом.
Имена языковых элементов отражают их структуру.
Часто в код встречается что-то такое:
var firstCommentOptional = post.getComments().stream()
.max(comparing(Comment::getCreationTime))
.findFirst()
// Do something with firstCommentOptionalval commentsFlow = commentsRepository.fincAllByUserId(userId)
// Далее делаем что-то с этим flowВ Java не было бы ничего криминального в том, чтобы написать вот так:
Optional<Comment> firstCommentOptional = post.getComments().stream()
.max(comparing(Comment::getCreationTime))
.findFirst()
// Do something with firstCommentOptionalИли что-то криминальное тут есть? Мне кажется лучше сделать будет вот так:
Optional<Comment> firstComment = post.getComments().stream()
.max(comparing(Comment::getCreationTime))
.findFirst()
// Do something with firstCommentOptionalСтоит оставить в имени только смысл переменной, пусть тип говорит остальное, не надо его дублировать.
Данным примером хочу показать, что автоматический вывод типов вернул в программирование то, что забыли разработчики со времен языка C. Это у них забрала возможность перегрузки функций и строгая типизация.
Хорошо это можно увидеть в библиотеках для графики, Где есть бесконечные методы со схожими сигнатурами.
glBegin(GL_QUADS);
glColor3f(1.0f, 0.0f, 0.0f);
glVertex2f(-0.5f, -0.5f);
glVertex2f( 0.5f, -0.5f);
glVertex2f( 0.5f, 0.5f);
glVertex2f(-0.5f, 0.5f);
glEnd();Например тут это упоминается, как способ бороться с отсутствием перегрузки.
У нас почти такой случай, решение — указание типа в имени, которое вызвано не отсутствием перегрузки, а автоматическим выводом типов, который делает их неявными.
Мы так долго разрабатывали языки, чтобы вернуться в ”старые добрые”? Потому что так проще писать в Kotlin, а то мне всегда IDEA будет подсвечивать ,что я тут явно указал тип, укажу его явно в имени 😎. Среда разработки влияет на код.
Раньше разработчики писали реактивный код:
var firstCommentMono = commentsRepository.findByUserId(userId)
.filter(not(Comment::isDeleted))
.max(comparing(Comment::likesCount))
.map(Comment::getAuthorId)
.flatMap(userRepository::findById);Код несложный, если ты работал с реактивными фреймворками до этого. Сейчас модные разработчики не любят такое. Чтобы такого не было, они переписывают проект на Kotlin:
val comments = commentsRepository.findByUserId(userId).awaitSingle()
val mostLikedCommentAuthorId = comments
.filter { !it.deleted }
.max { it.likesCount }
?.authorId
?: throw IllegalStateException("No comments found")
val user = userRepository.findById(mostLikedCommentAuthorId).awaitSingle()Вот! Вот это дело! Сразу стало понятно, а то это как-то сложно было, вот тут ближе к тому, то мы имели ранее. Ровный код. Тут разработчики Kotlin решили нашу проблему сложности кода, реализовав такую вещь как coroutine. Это позволило разработчикам применять подход Structured concurrency.
Кажется, что это все к добру. Я тоже так думал, пока не увидел ,как разработчики применили continuation очень интересно. Примерно такой код там был:
object Exchanger {
suspend fun getResult(id: String): Result = suspendCancellableCoroutine { continuation ->
// Поместить continuation в map
}
fun setResult(id: String, result: Result) {
val continuation = map[id] ?: throw IllegalStateException()
// продолжается continuation
}
companion object {
val map = ConcurrentHashMap<String, CancellableContinuation<Result>>()
}
}suspendCancellableCoroutine - kotlinx-coroutines-core
Что здесь происходит? При вызове метода getResult — клиент блокируется, пока кто-то не вызовет setResult с совпадающим параметром id. Получается реактивный интерфейс взаимодействия скрывается за императивным вызовом.
Лучше было бы использовать предназначенный для этого интерфейс. В Kotlin для этого имеется Deferred или просто CompletableFuture. Которое завершать когда надо, чтобы подчеркнуть асинхронную природу процесса.
Я понимаю почему такое решение было применено. Потому что разработчик использует те примитивы, что ближе для него, пусть они и менее прозрачны для пользователя API. Автор отражает код, который его окружает.
В коде время от времени начали встречаться отрывки кода вида:
fun getUsers(): Mono<List<User>>;Данный код обладает рядом минусов:
- Такой код подразумевает, что список уже собран. Он лишает возможности клиента получать элементы лениво.
- Код не позволяет получить доступ к элементам массива без увеличения вложенности, не разворачивая массива во
Flux.
Я предлагаю писать такой код:
Flux<User> getUsers();Этот код не обладает минусами, которыми обладает код выше. У этого код только один минус:
- Чтобы использовать его в Kotlin, удобнее преобразовать его к
Mono<List<User>>, чтобы потом вызватьawaitSingle, а там уже работать со списком. Так как все всегда хотят материализовать список, они предпочитают даже не рассматриватьFluxкак примитив, потому что с ним не так удобно работать в парадигме Structured Concurrency. А уж многочисленно реже требуется явно пользоваться свойствами, которыми мы пренебрегаем, когда используемMono, кроме человечности 😅. То есть в среде преобладания Kotlin кода будет часто встречаются такая проблема в силу упрощения.
Существует проблема, по чрезмерному использованию Optional. Сам по себе класс Optional проектировался как вещь, которая существует на границе контрактов. Обертка должна была явно указать возможность отсутствия значения на уровне типов,. Больше не надо полагаться на то, что пользователь проверит объект на null.
Optional is primarily intended for use as a method return type where there is a clear need to represent “no result,” and where using null is likely to cause errors. A variable whose type is Optional should never itself be null; it should always point to an Optional instance.
Проблема пришла оттуда, откуда её не ждали вовсе.:
class ErrorInfo {
Meta meta;
String getDescription() {
return ofNullable(meta).map(meta::getDescription).orElse(null);
}
}Этот пример часто встречается в врожденном виде. Также он полностью противоречит рекомендации по использованию Optional, он его не использует:
class ErrorInfo {
Meta meta;
String getDescription() {
if (meta == null) {
return null;
}
return meta.getDesccription();
}
}Данный код полностью выражает идею, что выше, правда он длиннее, но он не скрывает сложность и не создает лишние работы на этапе выполнения.
Идеальным примером был бы такой код:
class ErrorInfo {
Meta meta;
Optional<String> getDescription() {
if (meta == null) {
return Optional.empty();
}
return Optional.of(meta.getDesccription());
}
}Люди используют первый вариант, чтобы сэкономить строчки кода. Но не всегда сжатие строк кода в одну — упрощение кода. Даже в таких случаях, далее мы рассмотрим еще один.
Языки программирования растут. Они втягивают в себя новые фичи. Сейчас самые модные языки поддерживают nullable типы.
data class Something(
val a: A,
val b: B?,
)Это круто, можно убрать вездесущие if else. Также я могу сделать еще крутой финт:
val x = a?.b?.c?.d :? eВот тут начинаются вопросы. Отсутствие проблем с получением глубоких данных — порождает такие глубокие данные. Разработчик не думает, что такой API сложен для работы, потому что в его среде с этим проблем нет.
Все говорят о микросервисах. Стандартный холивар о том какой у нас будет система: монолит, микросервис. Я не буду здесь спорить, просто сошлюсь на Закон Конвея, который вытекает из того, как развивалась индустрия и отражает мысль статьи как есть. Н последнее время микросервисы начали применять только потому что, они могут быть применены, а не потому что они решают проблемы.
Все мы читаем чужой код. Это то, что мы делаем в первую очередь, приходя на новое место. Приходя на новое место, ты читаешь то, что там написали до тебя. Зачем мы читаем код? Какие знания из него ты хочешь получить? Есть разные причины.
- Кто-то может сказать, что хочется получить знания доменной области. Но почему тогда этот человек не идет к эксперту доменной области, который эти знания составлял? Нет, разработчик смотрит кривое зеркало знаний эксперта, степень кривизны которого варьируется от проекта к проекту. Разработчик хочет узнать КАК обрамлена доменная логика в проекте, чтобы понимать с чем ему работать.
- Кто-то говорит, что он хочет посмотреть как происходит интеграция с другими системами. Почему тогда не посмотреть в документацию, где это описано? Разработчик хочет узнать КАК реализована эта интеграция на уровне кода, потому что ему надо будет работать в этих же понятиях, чтобы решать задачи интеграции в будущем.
- Кто-то говорит, что он хочет посмотреть на стиль написания кода. Надо понимать, что от него ожидает команда, когда он ставит свой код на ревью. Разработчик хочет узнать КАК ему надо будет писать код в ближайшем будущем.
Причин может быть сколь угодно много. Вывод я вижу только один. Разработчику надо будет делать свою работу. Работа программиста — писать код. На вопрос что писать — отвечает бизнес. Вопрос КАК описывается только кодом, который уже написан. Я не отрицаю, что разработчик может повлиять на то, как пишется код в компании. Новый член команды не сможет навязать коллективу вещи, которые остальным не нужны, пусть даже они являются хорошими практиками в индустрии.
Разработчик не сможет доказать почему лучше развертывать Java приложение в контейнере, если оно монолитное. Потому что контейнеризация не решает проблем, с которыми сталкивается тот, кто поддерживает монолитные сервисы. Зато такой команде можно привить практику, которая будет способствовать лучшему разбиению системы на модули, так как это проблема стоит там острее. С каждым новым разработчиком проект развивается, обогащается новыми идеями, старые идеи не забываются, они смешиваются с новыми, принимаю иной вид, но не отрицая сути системы. Так может продолжаться, пока одна команда не ощутит то, что она стала разделяться. Компетенции внутри команды стали разделяться. Сама команда распалась на несколько. Монолит стал обременять отдел разработки. Чтобы командам было удобнее работать — система делится на части, и за каждой закрепляется команда. В этом им помогают модули, что они строили ранее. Потом одна команда становится продуктивнее, чем другие, они не могут производит разработку в том же ритме, что и другие, может даже и не хотят. Нечего им замедляться! Поэтому тут появляется старый, уже, разработчик и вспоминает контейнеризация и микросервисы, с которыми он сюда пришел, которые теперь решают их проблему и помогают двигаться в будущее. Модули послужили им границами разделения сервиса. Но команда не забудет ей приобретенный опыт и продолжает эксплуатировать модули там где они нужны и не только.
Данный процесс бесконечный — это процесс эволюции процесса разработки (непреднамеренная тавтология). Такие процессы имеют место также в нашем коде. Поэтому хочется рассмотреть некоторые подходы/шаблоны к написанию кода, которые являются воплощением идей старых, или отражением идей новых.