В июле в офисе РСХБ-Интех (технологической дочки Россельхозбанка) состоялся бесплатный митап для Java-разработчиков — RSHB Backend Dev Meetup. Обсудили Kotlin, Go, маппинг и разные аспекты бэкэнд-разработки. В числе докладчиков выступал Иван Кочергин, руководитель центра собственной разработки РСХБ-Интех. Иван более 10 лет занимается разработкой на Java, последние три года — на Kotlin. В своем докладе он сравнил, на чем лучше писать микросервисы в банке: Java, Kotlin или Go. Делимся расшифровкой доклада. Запись всего митапа можно посмотреть на Rutube.

Предисловие

Можно выделить несколько основных вызовов современной разработки.

Time to Market — время, требующееся на проектирование функционала, его разработку, тестирование и релиз. То есть период от проектирования до момента, когда он станет доступен пользователю. Перед разработчиком стоит очевидная задача минимизировать это время. 

Когнитивная сложность применяемых решений и технологий. Если раньше новой версии Java можно было ждать по три-четыре года, то сейчас версии выходят каждые полгода, появляются новые фреймворки, парадигмы программирования, библиотеки. В мире фронтенд-разработки ситуация выглядит еще более “выпукло”. Эта сфера требует определенного уровня управления, в противном случае разработчик превращается в перегруженного лишними знаниями ослика.

Написание ресурсоэффективных приложений. В 2020 году произошел кризис цепочки поставок, который привел к общемировому дефициту полупроводников. Соответственно, сейчас от разработчиков требуют создания ресурсоэффективных приложений с минимально возможным application footprint, то есть количеством потребляемых ресурсов.

Перейдем к ответам на данные вызовы. 

Микросервисная архитектура — набирающий популярность подход, при котором информационная система может быть рассмотрена как набор отдельных сервисов, каждый из которых удовлетворяет нескольким параметрам:

  • выполняет одну из поставленных бизнес-функций;

  • имеет слабую связку с другими сервисами;

  • имеет возможность деплоя и масштабирования независимо от других сервисов. 

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

Говоря о языках программирования, стоит отметить, что Java лидирует в корпоративной и банковской сфере, и Россельхозбанк (РСХБ) не является исключением. Более 80% репозиториев бэкэнд-сервисов написаны на JVM-стеке: 74% занимает Java и еще около 6% Kotlin. 

Kotlin vs Go

С учетом всего вышесказанного, бэкграунда РСХБ и стоящих перед нами вызовов давайте попробуем разобраться, на чем лучше писать микросервисы в банке — Kotlin или Go.

Для начала определим критерии для сравнения.

  • Null Safety. Позволяет минимизировать количество NPE (NullPointerException), так знакомых всем разработчикам. То есть обращение к непроинициализированным переменным.

  • Возможность расширения функциональности существующих классов/структур без изменения исходного кода.

  • Возможность удобного создания контейнеров для хранения данных.

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

  • Работа с функциями.

  • Синтаксический сахар.

Null Safety

Рассмотрим первый пример на Kotlin. Переменная а имеет тип String с вопросительным знаком, и мы присваиваем ей некое значение. Вопросительный знак говорит о том, что переменная может быть установлена в null. Соответственно, когда мы делаем это на следующей строчке, никаких проблем не возникает и наш код отработает корректно.

Во втором примере переменная b имеет тип String без вопросительного знака. При попытке присвоить ей нулевое значение получаем ошибку компиляции.

	fun main() {
		var a: String? = "abc" // can be set to null
		a = null // ok
		print(a)
		
		var b: String = "abc" // Regular initialization means non-null by default
		b = null // compilation error
	}

Теперь давайте рассмотрим пример, когда мы хотим передать некую переменную в Kotlin. У нас есть функция strLength, возвращающая нам длину нашей строки. Поскольку строка (s) всегда будет проинициализирована, то данный метод завершится без каких-либо ошибок. 

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

Это все здорово, но мы видим, что переменная nullable на самом деле проинициализирована. В таких случаях мы можем сообщить компилятору, что берем все риски на себя. Для этого в Kotlin есть синтаксис !!. 

	fun main() {
		fun strLength(s: String): Int {
			return s.length
		}
		
		val notNullString = "hello"
		println(strLength(notNullString)) // 5
		
		var nullable: String? = "hello"
		println(strLength(nullable)) // compilation error
		
		println(strLength(!!nullable)) // 5
	}

Перейдем к тому, как это все реализовано в Go: никак. Null Safety в Go отсутствует. При разработке вы часто будете видеть представленную ниже конструкцию с методом doSomething, возвращающим два значения: результат и ошибку. Далее идет проверка на то, что ошибки нет. Если ошибка имеет место, выполнение программы завершается.

func main() {
	result, err := doSomething()
	if err != nil {
		os.Exit(1)
	}
}

Такое решение тоже осмысленно. Создатели Go посчитали, что проверка на null является очень простой и всем очевидной. Была попытка реализовать SGo (Safe Go), где использовался похожий на Kotlin синтаксис. Но данный форк был сделан для версии Go 1.7, репозиторий есть в GitLab, но он заброшен. 

Функции расширения

Давайте рассмотрим кейс с функцией расширения в Kotlin — ситуация, когда мы хотим расширить поведение какого-либо класса. Возьмем пример с UserPassword. Здесь мы хотим проверить, является ли secure наш пароль. На деле тут должен быть какой-нибудь “страшный” REGEX, проверяющий безопасность пароля. Но для демонстрации обойдемся простой проверкой, что пароль не короче трех символов. Представленный ниже код отработает корректно.

fun UserPassword.isSecure(): Boolean {
	/* validate password against some horrible regexp*/
	return if(this.password.length < 3) {
		false 
	} else {
		true
	}
}

fun main() {
	val up = UserPassword(username = "John", password = "***")
	println(up.isSecure()) // true
}

Теперь посмотрим на пример, когда мы пытаемся расширить поведение класса, который не находится в нашей кодовой базе. Для примера возьмем LocalDate (класс из package java.time) и создадим для него функцию расширения, которая сможет распечатать его в привычном нам формате: дата, месяц, год. Выглядеть наш код будет вот так. В методе main мы просто создаем объект LocalDate.now() и на нем вызываем метод. Все просто и понятно.

import java.time.LocalDate
import java.time.format.DateTimeFormatter

fun LocalDate.toddMMyyyyFormat(): String {
	return this.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}

fun main() {							
	println(LocalDate.now().toddMMyyyyFormat()) // 06.07.2023
}

Теперь посмотрим, как это выгляди в Go. На самом деле, все очень похоже. Мы описываем структуру, метод, но с несколько иным синтаксисом. В целом все абсолютно идентично.

type UserPassword struct {
	Username string `json:"username"` 
	Password string `json:"password"`
}	

func (up *UserPassword) isSecure() bool {
	/* validate password against some horrible regexp*/
	var isSecure bool
	if len(up.Password) < 3 {
		isSecure = false
	} else {
		isSecure = true
	}
	return isSecure
}

func main() {
	up := UsrPwd{"John", "***"}
	fmt.Println(upwd.MyUsername()) // username = John
}

Есть проблема, что мы не можем расширять поведение структур, которые не в нашем package. У Go есть ответ на этот вопрос. Есть возможность создать через конструкцию type UsrPwd алиас тип и на нем, поскольку он находится уже в моем package, прикрепить функцию, которая вернет мне мой логин. Далее мы создаем структуру, объект этой структуры, и печатаем его.

type UserPassword struct {
	Username string `json:"username"` 
	Password string `json:"password"`
}

type UsrPwd UserPassword

func (up UsrPwd) MyUsername() string {
	return "username = " + up.Username
}

func main() {							
	up := UsrPwd{"John", "***"}
	fmt.Println(up.MyUsername()) // username = John
}

Контейнеры данных

В Kotlin контейнеры данных — это Data classes, удобный синтаксис для создания POJO-файлов, у которых переопределён toString, equals, hashcode, а также реализован метод copy, который позволяет копировать объект при необходимости точечно изменяя некоторые поля. Все это переопределено, и за счет языка можно отказаться от библиотеки Lombok. 

В примере далее мы описываем структуру UserPassword, хранящую в себе логин и пароль пользователя, создаем объект данного класса и печатаем его. Как видно, мы при печати получаем название класса и переменных. Стоит обратить внимание, что здесь используются модификаторы val. Есть модификаторы var и val. val — финальное значение, то есть переменную изменить не получится, поэтому в данном случае POJO-файл будет создан без сеттеров.

	data class UserPassword(val username: String, 
							val password: String)
	 
	fun main() {
		val up = UserPassword(username = "John", password = "***")
		println(up) // UserPassword(username = John, password = ***)
	}

В Go контейнеры данных называются структурами (Structs) и имеют несколько иной синтаксис. Мы определяем тип UserPassword. Все то же самое, но поля Username и Password начинаются с большой буквы, то есть они являются публичными. Они доступны за пределами package, в котором определяются. Соответственно, для того, чтобы при сериализации наши поля имели другие названия, используются теги. В данном случае при сериализации json поля с большой буквы будут конвертированы в поля с маленькой буквы. В Go это называется теги. Чем-то они похожи на аннотации.

После простановки тегов создаем объект структуры и распечатываем его. Информации получаем меньше, но в целом все понятно.

type UserPassword struct {
		Username string `json:"username"` 
		Password string `json:"password"`
	}
	 
	func main() {
		up := UserPassword {
			Username: "John", 
			Password: "***",
		}
		fmt.Println(up) // {John ***}
	}

Coroutines в Kotlin

Давайте представим, что мы хотим получить данные из Интернета, при этом сделать так, чтобы это не влияло на производительность системы в течении большого количества времени. Coroutines дают такую возможность. Для начала создадим Channel для String — структура для обмена данными между Coroutines, чем-то похожая на BlockingQueue. Мы можем отправлять туда и читать оттуда значения. После создания Channel мы передаем в нее функцию getFromInternet, получаем данные из Channel и печатаем то что получили по сети.

Чем же занимается функция getFromInternet? Тут мы создаем request. Сразу скажу, что я использую функционал httpClient, доступный в Java с 11 версии. Очень удобно этим пользоваться. После создания запроса делаем асинхронный запрос, который вернет нам CompletableFuture. Здесь очень важно обратить внимание на метод await. Его нет в классе CompletableFuture. Это именно та функция-расширение, про которую я рассказывал на предыдущих слайдах. То есть это возможность расширить поведение и, в нашем случае, метод await() - это мостик между миром Java и миром Coroutines. Про это я делал доклад на JPoint в прошлом году. Все желающие могут с ним ознакомиться.

После получения ответа мы отправляем его в Channel и можем прочитать его.

fun main() = runBlocking {
	val ch = Channel<String>()
	launch {
		getFromInternet(ch)
	}
	val resBody = ch.receive()
	println(resBody) // response body
}

suspend fun getFromInternet(ch: Channel<String>) {
	val request = HttpRequest.newBuilder()
		.GET()
		.uri(URI.create("https://example.org/"))
		.build()
	
	val response = httpClient
		.sendAsync(request, HttpResponse.BodyHandlers.ofString())
		.await()
	ch.send(response.body())
}

val httpClient: HttpClient = HttpClient.newBuilder()
	.version(HttpClient.Version.HTTP_1_1)
	.build()

Goroutines в Go

Goroutines имеют очень похожий синтаксис. В данном примере я реализовал точно такой же функционал. Сначала создаем Channel, в котором можем хранить строку, после запускаем в Goroutines метод получения данных из Интернета, как я показывал ранее. Подобного рода конструкции проверок на ошибки у нас будут частыми в Go коде. Полученный ответ передаем в Channel, и в main методе мы его читаем.

func main() {
	ch := make(chan string)
	go getFromInternet(ch)
	resBody := <- ch
	fmt.Println(resBody) // response body
}

func getFromInternet(ch chan string) {
	res, err := http.Get("https://example.org/")
	if err != nil {
		os.Exit(1)
	}
	resBody, err := ioutil.ReadAll(res.Body)
	if err != nil {
		os.Exit(1)
	}
	ch <- resBody
}

В целом, за исключением того, что Go использует чуть более хитрый синтаксис в виде стрелочек, а Kotlin более очевидные send/receive, разницы особой нет. Единственное, что нужно отметить, что для запуска Goroutines используется волшебное слово go. Coroutines в Kotlin являются библиотекой. В свою очередь, в Go Goroutines - это часть языка, и для начала их использования ничего подключать не нужно. Про разницу двух подходов (библиотечного и часть языка) есть отличный пост Романа Елизарова.

Kotlin: First-Class Functions

Рассмотрим возможность работы с функциями. First-Class Functions — функции, которые мы можем передавать в другую функцию как параметр и получать их как возвращаемое значение. В уже знакомом классе UserPassword мы создаем два объекта: John и Ivan. Также у нас есть функция аутентификации, принимающая объект из этого класса, и функция приветствия. 

Допустим, у нас англоязычный сервис, и мы создаем функцию приветствия пользователя на английском языке. Функцию приветствия на английском мы передаем как метод референс. В случае, если мы хотим поприветствовать Ivan иначе, всегда можно использовать метод лямбды. В Kotlin лямбду можно вынести за скобки. Это особенность языка, позволяющая удобно писать собственный dsl. Но в данном примере показанный синтаксис более понятен. Тут лямбда является вторым параметров, и у не знакомых с Kotlin людей вопросов не появится.

fun main() {
		val john = UserPassword(username = "John", password = "***")
		val ivan = UserPassword(username = "Иван", password = "***")
	 
		auth(john, ::greetingEng) // Hello, John
		auth(ivan, {name -> "Привет, $name"}) // Привет, Иван
	}
	 
	fun auth(up: UserPassword, greeter: (name: String) -> String) {
		println(greeter(up.username))
	}
	 
	fun greetingEng(name: String): String {
		return "Hello, $name"
	}

Go: First-Class Functions

В Go работа с функциями проходит идентично Kotlin. Мы точно также определяем метод аутентификации, который вторым параметром принимает функцию. Также мы имеем функцию приветствия на английском, и в первом случае передаем ее, во втором передаем анонимную функцию, которая приветствует Ivan по-русски.

func main() {
		john := UserPassword{"John", "***"}
		ivan := UserPassword{"Иван", "***"}
		
		auth(john, greetingEng) // Hello, John
		auth(ivan, func(name string) string) {
			return "Привет, " + name
		}) // Привет, Иван
	}
	 
	fun auth(up *UserPassword, greeter func(name string) string) {
		fmt.Println(greeter(up.Username))
	}
	 
	fun greetingEng(name string) string {
		return "Hello, " + name
	}

Ниже приведена итоговая таблица по результатам сравнения Kotlin и Go.

В Go Null-safety отсутствует как класс. Но это тот выбор, на который создатели языка пошли сознательно. С этим нужно просто жить. Функции расширения в Kotlin и Go работают одинаково хорошо. Data Containers в Kotlin присутствуют, позволяют создать POJO-файлы и иммутабельные POJO-файлы. В Go возможность создания иммутабельных объектов отсутствует. Синтаксического сахара в Kotlin больше. Работа с функциями и неблокирующаяся многопоточность в Kotlin и Go реализованы идентично и работают очень хорошо.

Микросервисы

Возвращаясь к теме написания микросервисов. JVM использует при запуске минимум 70 МБ памяти. Это уже не сильно похоже на микросервис, при этом размер потребляемых JVM ресурсов зависит от размера памяти, которую использует само приложение. Для задач обслуживания приложения JVM необходимо от 10 до 25% памяти приложения.

В итоге наши микросервисы на Java начинают выглядеть как на картинке ниже. Имеется небольшая полезная нагрузка и большая JVM, на которой работает приложение. 

Соответственно, встал вопрос, настолько ли Go эффективен, как о нем говорят. Я написал приложения, удовлетворяющее требованиям App.Farm к наличию OpenAPI и healthcheck-endpoint. App.Farm - это система, которая используется для быстрого запуска и деплоя приложений. Грубо говоря - это корпоративный Kubernetes со всем необходимым обвесом в виде Gitlab, Nexus, SonarQube и тд. Ниже в таблице указаны версии языка и фреймворка, используемые при написании приложений.

JVM

Go

Версия языка

Java 17

Kotlin 1.7

1.20

Версия фреймворка

Spring 3.1

Echo 4

Теперь посмотрим на результаты сравнения объёмов потребляемых ресурсов. В критерии размера артефакта победителя нет. Да, в Go показатель меньше, но не значительно. А вот по размеру потребляемой памяти и времени запуска Go существенно выделяется.

Да, но…

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

GraalVM — виртуальная машина, разрабатывающая Oracle, в состав которой входит утилита Native Image, которая умеет преобразовывать class-файлы и jar-файлы в нативные бинарники. При этом для использования решения есть несколько ограничений.

  • Требуются новейшие версии фреймворков, и с учетом того, что в банках нередко много легаси, не всегда получится это реализовать;

  • Время сборки значительно увеличится. Native Image на вход необходимо передать либо скомпилированные class-файлы, либо jar-файлы, соответственно, к текущему пайплайну добавится новый этап. Бинарник соберет все классы и интерфейсы, необходимые для работы приложения. Если раньше, например, класс String был доступен из JVM, то теперь его нужно будет положить в данный бинарник.

  • Увеличится размер артефакта

  • Проблемы со сторонними библиотеками. Если новейшие фреймворки гарантируют совместимость с Native Image, то со сторонними библиотеками могут быть проблемы. 

Как следствие 

Также есть кейсы, в которых JVM лучше Go. Рассмотрим паттерн Strongman. Допустим,у нас есть приложение, которое занимается отправкой почты для всего банка, создаёт письма по заданным шаблонам, вставляет корпоративную символику и т.д. Это приложение можно считать микросервисом, так как оно выполняет одну функцию, но при этом работает с большим объёмом данных и потребляет большое количество ресурсов. В таком случае объем потребляемых ресурсов JVM размывается на фоне ресурсов, потребляемых приложением. Кроме того, есть сравнение сервисов, написанных на Java и Go, которое показывает, что Java эффективнее работает с большими объемами ресурсов, чем Go.

Допустим, у нас нет ситуации, когда в нашем приложении мы не отправляем почту за весь банк. У нас нет тысяч rps, а почта отправляется время от времени. Тогда можно использовать паттерн Swiss Knife. Когда разный функционал собирается в один сервис, то получается некое подобие швейцарского ножа, который делает все и понемногу. Внимательный читатель может заметить, что это является отходом от микросервисной архитектуры. Да, но это тот trade off, на который нужно идти сознательно, отдавая себе отчёт в том зачем вы это делаете.

Итоги

Резюмируя все вышесказанное, давайте посмотрим, что нам могут предложить Kotlin и Go.

Kotlin

  • Полная совместимость с Java позволяет использовать существующие библиотеки и собственные наработки.

  • Лаконичность синтаксиса позволяет разработчикам писать код быстрее, таким образом снижая time-to-market.

  • Неблокирующая многопоточность: разработка ведется в привычной императивной парадигме. Кроме Coroutines тут имеет место быть такая вещь, как реактивщина. В Spring есть фреймворк Spring Flux, при использовании которого мы можем добиться сопоставимой производительности с Coroutines, но при этом не сильно потеряем в читабельности кода.

  • Количество кода меньше на 20% по сравнению с Java.

  • Возможность использования со старыми версиями Java, что очень критично для банковской сферы, наполненной легаси-системами. Основной плюс заключается в том, что мы получим прирост производительности при использовании Coroutines. Кроме того, даже работая на старых версиях Java разработчики смогут получить опыт работы с новыми парадигмами, которые в их версии языка могут отсутствовать.

Go

  • Маленький Application Footprint, феноменально короткое время запуска.

  • Встроенная поддержка многопоточности — не библиотека, а часть языка.

  • Неблокирующая многопоточность: разработка в привычной императивной парадигме.

Но есть и минусы:

  • Небольшая экосистема по сравнению с Java, что связано с тем, что это относительно молодой язык. 

  • Отсутствие поддержки старых технологий и протоколов. Но тут сложно сказать, плюс это или минус.

  • Меньше возможностей для управления GC.

Какие можно дать рекомендации. Если в вашем проекте используется Java, то лучше начать использовать Kotlin, поскольку это действительно очень хороший язык. Если очень хочется использовать Go, а ваш текущий проект написан на Java, то рекомендация не меняется: все равно начинать использовать Kotlin. Если обременение в виде проекта на Java отсутствует, то стоит дождаться релиза сервиса-пионера на Go, который мы сейчас разрабатываем с командой AppFarm. Это сервис, который проходит все проверки нашего пайплайна и может быть взят за основу.

Вопросы слушателей

В следующем блоке собрали все вопросы от слушателей и зрителей митапа в онлайне, в том числе на которые не удалось ответить непосредственно на мероприятии. Вопросы собирались в чате митапов РСХБ.цифра в Telegram.

В Райффайзенбанке преимущественно на DotNET пишут. Почему не сравниваете с ним?

Мой бэкграунд это Java-разработка. Более 80% бэкэнд-сервисов в РСХБ написаны на Java. Поэтому я делал доклад, более актуальный для компании, в которой на текущий момент работаю.

Есть ли в РСХБ техрадар? Если да, если в нем Go или что там вообще есть.

Есть и техрадар, и Go в нём присутствует. Легализацией Go как языка программирования мы занимаемся не только со стороны разработки, но и со стороны App.Farm. Это тот конвейер приложений, в котором все новые сервисы должны запускаться. App.Farm насаждает определенные стандарты и культуру разработки. До его появления каждый проект был уникальным и это увеличивало когнитивную нагрузку на разработчиков при переходе на другой проект внутри банка. Дополняя тему техрадара, скажу, что следующий доклад у нас будет про Rust. Это тоже очень интересный язык со своими особенностями управления памятью, про который можно чего интересного рассказать и услышать.

В представленной таблице нет пункта про ООП. Также вопрос — насколько код на Java должен быть маленьким, чтобы его можно было перенести Go, и насколько большим, чтобы переносить на Java?

Я сторонник полиглотных систем. Все сервисы, выполняющие небольшую функцию и не находящиеся под большой нагрузкой, но при этом делающие много сетевых запросов, я бы писал на Go. В отношении сервисов, работающих с большими объемами данных и под большими нагрузками, очень интересно посмотреть на Rust. На одном из моих проектов мы собираем все файлы банка в единое хранилище. Некоторые файлы могут иметь большой размер. И иногда мы сталкиваемся с ситуацией, при которой приложение может завершиться с Out Of Memory даже на небольшом файле. Это связано с тем, что GC может не успеть отработать и удалить из памяти файлы от предыдущих запросов. Ввиду отсутствия GC в Rust, память, которая используется, сразу освобождается, и будет меньше случаев, когда Kubernetes рубит сервис из-за перерасхода ресурсов.

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

Вы говорили, что лучше писать на Rust. Много в РСХБ Rust-разработчиков?

Я, наверное, пока единственный. Но на Rust предполагается реализовать небольшой слой, который отвечает за работу с файлами. Для этого не нужна большая команда - двух человек будет достаточно.

Добавлю, что я говорил о том, что каждый язык стоит использовать для определенной задачи. А то, что у нас появляется бизнес-задача в виде найма большой команды, это уже другое. Но думаю, мы можем прийти к созданию команды РСХБ, разрабатывающей на Rust.

Вы приводили сравнение истории с одним сервисом на одну виртуальную машину. Но ведь обычно контейнер используется типа Tomcat, где много сервисов на виртуальную машину. Плюс гексагональная архитектура позволяет решить эту проблему.

Возможно. Боюсь, с ходу ответить на этот вопрос не получится.

В рассказе про разницу между языками вы как-то сразу забыли про Java. С точки зрения программиста и с точки зрения бизнеса зачем нам менять одно на другое? Java популярен, он развивается, и все эти фишки, про которые вы говорили, можно нормально прописать на Java.

Вопрос резонный. Проблема в том, что разница между Java 20 и Kotlin будет незначительной. Но в банке очень много легаси-систем, например, на текущий момент вводиться в эксплуатацию система, написанная на Java 8.0. Соответственно, все те плюшки, о которых мы говорим, попросту недоступны. При этом Kotlin хорошо работает со старыми версиями Java. Да, если в  таблицу сравнения добавить Java, будет примерно то же самое, что у Kotlin.

В конце 2019 года у меня была мысль не использовать Coroutines Kotlin, хотел дождаться project Loom. Не дождался. 

Последние три года я разрабатываю на Kotlin. Это очень приятный в работе язык. Переход на Kotlin также не занимает какого-то значительного времени. Тут скорее вопрос в поиске TeamLead’ов, которые смогут познакомить команду с новыми идиомами. Потому что зачастую Java-разработчики продолжают писать Java-код, только теперь уже на Kotlin. Сам был в таком замечен.

Свой первый проект на Kotlin я начинал под личную ответственность. Программист должен быть смелым, я считаю.

Какая ресурсоэффективность при микросервисной архитектуре, если пустой проект может отъедать 50 МБ памяти? Как эту проблему решать и какие подходы лучше использовать?

Как я показывал, у нас микросервис может занимать 70 МБ с ходу. Как вариант, если очень хочется писать на JVM, можно использовать Native Image. Это повлечет указанные ранее сложности, но решает проблемы. Или вы можете создать некий utils-сервис, который будет неким швейцарским ножом. Это решит проблему, но идти на этот шаг можно только отдавая себе отчёт в своих действиях. 

70 МБ это, конечно, невыносимо много. Зато когда это все крутится в Kuber на целой виртуалке на Unix, тут, конечно же, ничего страшного. Предложенные методы тоже не совсем ресурсоэффективны. 

Если говорить конкретно про мой взгляд на разработку, мне нравится тема с макросервисами. Для большей части систем 5-6 сервисов будет достаточно. Да, они будут достаточно большими, и все же. И мне не очень нравится ситуация, которую часто можно услышать на собеседованиях: у нас было 100 микросервисов, а у нас 200, а у нас 300 и так далее. Начинаются мериться их количеством, когда их создание совершенно неразумно с точки зрения ресурсов. Поэтому я сторонник макросервисной архитектуры.

И решение использовать или не использовать Кубер находится не в моей власти. Моя задача состоит в том, чтобы сделать ресурсоэффективными приложения, а не всю банковскую экосистему.

Неужели проще перевести Java 8.0 на Kotlin чем на Java 17.0?

Да. Ключевой момент в том, что у Kotlin очень хорошая интероперабельность с Java. Вы можете начать писать новый функционал на Kotlin, можете переводить на него часть существующего функционала. В Kotlin есть файл с расширением .kt, куда можно скопировать java-класс. Kotlin сам все конвертирует, и некоторые проблемы решаются автоматически.

К тому же нужно помнить, что обычно вы используете разные фреймворки вроде Spring. И обновление версии Java влечёт за собой и обновление версии Spring’а. Даже при переходе со Spring 2.7 на 3.1 мы столкнулись с рядом проблем. А обновление с первой версии Spring’а может занять значительное время.

Вопрос про Coroutines. Http-клиент возвращает асинхронный ввод-вывод в качестве CompletableFuture в неблокируемый поток. Этот поток обернут в Coroutines. В чем преимущество оборачивания и так неблокируемого I/O в Coroutines, и вместо вызова метода Callable также дождаться ответа асинхронно. Это же то же самое.

Не совсем. Чтобы получить ответ вам нужно на CompletableFuture вызвать блокирующий метод get. Это плохо для производительности.

Способ, который вы предлагаете, предполагает использование Callback’ов, а это сразу рушит возможность писать код в привычной императивной парадигме. Мне довелось немного поработать с GWT, где всё делалось на Callback’ах. Удовольствие ниже среднего.

Как быстро выходят исправления для ошибок в компиляторе для Kotlin и Go? Сколько серьезных ошибок за последние годы было и там, и там, и сталкивались ли вы с багами компилятора? 

Перед тем, как заходить в какую-то авантюру, надо дождаться, пока стабилизируется компилятор. Я предлагаю собрать все ошибки другим, особенно если вы работаете в банке. Соответственно, Go вы можете брать стабильную отлаженную версию 1.20. В Kotlin я начинал с версии 1.3 в 2019 году и ни с какими проблемами не столкнулся. Но, возможно, у меня просто получалось обходить острые углы.

Завезли ли в GO exception, дженерики и систему сборки?

Exception точно нет, все будет точно такое же: получаете два значения из функции, проверяете на ошибки, если ошибка есть прерываете исполнение программы. Дженерики появились, а что касается версии сборки — пока с этим есть проблемы. Существует два способа подтянуть зависимости и обычно в библиотеках пишут, какой командой лучше их добавлять. Я с какими-то проблемами особо не сталкивался, но я, вероятно, просто не пользовался старыми или эзотерическими библиотеками. Не могу назвать себя очень опытным пользователем Go.

Что в Kotlin и Go насчет стрессоустойчивости? Как будет работать приложение под высокой нагрузкой при условии, что база не является узким местом.

Оба языка вытянут. Единственный минус, который может быть, это то, что оба языка используют GC. Соответственно, в графике мониторинга вы будете видеть пики. Есть, например, статья коллег из Discord, которые пишут о том, что один из таких сервисов, который у них был сильно нагружен, был переписан на Rust. В итоге они сразу избавились от пиков просто за счет того, что используемая память после закрытия скоупа освобождается. Это добавляет дополнительные расходы по работе с памятью, но зато полностью удаляет пики.

Сколько времени в среднем нужно программисту, чтобы перейти с Java на Kotlin?

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

Есть ли опыт не синтетических бенчмарков Go vs Kotlin на стандартном бизнесовом сервисе, где помимо вычислений есть запросы к БД, файловый ввод-вывод и пр.?

У меня нет. В своём докладе я опирался на статью, которая сравнивала GraalVM с Go.

Насколько целесообразно разрабатывать на Котлин и Го, разрабы более редкие и поддержка дороже!

Котлин — это всё-таки JVM-based язык программирования. А java-разработчиков достаточно много. Что касается go-разработчиков, то их количество растёт. Да и язык в целом достаточно прост для освоения. И такие компании как VK и Avito используют эти языки в качестве основных.

Какое отношение количества Java разработчиков и Kotlin разработчиков?

В моём Центре сейчас примерно 50/50.

Go огонь! Но, по Вашему мнению, какие задачи в банке НЕ подходят для Go?

Для Go существует мало библиотек по работе со старыми протоколами вроде SOAP. 

Исходя из обсуждения Go vs Kotlin - это практически однозначно новый микросервис, тогда в чем проблема использовать GraalVm? Если проблематикой являются ресурсы, то в чем проблема потратить время на шаг пайплайна для сборки ?

Использование GraalVM плохо подходит для быстрого прототипирования, поскольку у вас вырастет время сборки. Причём достаточно значительно. 

И расходование ресурсов на этапе сборки артефакта тоже вырастет.

JVM в случае микросервисов потребляет больше ресурсов по сравнению с Go. А есть данные по сравнению стабильности работы приложений на Java/Kotlin и Go? Наверное, не просто так JVM используется в финтехе чаще, чем Go.

Боюсь, что ответ на этот вопрос лежит не в технической области. Как я говорил, такой параметр как Time To Market очень важен для бизнеса. И для решения некоторых задач вам требуется большое количество программистов. Очевидно, что Java-разработчиков в банках гораздо больше, чем всех остальных. Иногда даже вместе взятых.

Комментарии (13)


  1. Firsto
    13.07.2023 18:42
    +3

    А есть результаты сравнения при достаточно большой нагрузке, в много тысяч запросов в секунду? (ʘ‿ʘ)


    1. QtRoS
      13.07.2023 18:42
      +8

      Это часто не обязательно для обоснования в финтехе. Хотелось перейти на Котлин с Джавы, как сделать? Показываем что-то радикальное вроде Go. Пока все в шоке, показываем Kotlin, который вроде та же Java, но на стероидах, и плюшки с Go пересекаются. Все счастливы, перемены небольшие, выбрали Котлин. Профит!


  1. SpiderEkb
    13.07.2023 18:42
    +4

    А можно пояснить - что именно подразумевается под backend в данном случае? Что-то мне подсказывает, что речь не идет о сложной бизнес-логике, которая крутится на центральных серверах банка, а о некотором промежуточном слое между фронтами и центральными системами...

    • Null Safety. Позволяет минимизировать количество NPE (NullPointerException), так знакомых всем разработчикам. То есть обращение к непроинициализированным переменным.

    Проблема на самом деле несколько искусственная. Мы пишем на специализированном (те самые "центральные банковские системы" на серверах что реализуют всю банковскую логику) языке где этой проблемы просто нет как класса. Там просто не принято активно использовать динамическое выделение памяти (кроме потенциальных рисков и проблем, это еще и время на выделение-освобождение памяти), хотя язык это и поддерживает в минимальном объеме - alloc/dealloc/realloc + based переменные. Плюс к тому компилятор всегда при объявлении переменной автоматически инициализирует ее дефолтным для данного типа значением (если не указано явное значение для инициализации).

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

    Это, скорее, вопрос не языка но платформы. Любая ОС предоставляет достаточное количество механизмов межпроцессного обмена данными - разделяемая память, пайпы, сокеты - это из общего, что есть везде. Плюс специфические средства для разных систем как то

    • UNIX sockets - локальные именованные (т.е. привязанные к имени, но не к IP/порту) сокеты. Могут быть потоковыми (аналогично TCP) или датаграммными (аналогично UDP). В плане межпроцессного обмена данными датаграммные удобнее.

    • Mailslots в Windows. Некая именованная сущность - создается процессом-сервером, который может из него читать. Все остальные процессы открывают его по имени и могут в него писать. Используют датаграммный формат обмена. Механизм использования примерно такой - каждый процесс (сервис) создает свой слот с уникальным именем и таким образом все общение между процессами сводится к записи нужной информации в соответсвующий слот. Вопросами разделенного доступа занимается сама система.

    • На нашей платформе выбор еще богаче - поддерживаются UNIX sockets, но есть еще специфические для нашей системы объекты - пользовательские пространства (UserSpace) - некий аналог memory mapped file с которым можно работать просто как с областью памяти (получаешь указатель и работаешь, только размер динамически меняется при необходимости), очереди (DataQueue, UserQueue) - это не монстры типа кафки или раббита, но достаточно простые в использовании сущности - записал-прочитал. Любой может писать, любой может читать. Плюс возможность использовать ключ для чтения-записи.

    Так что все это общие вопросы с конкретным языком никак не связанные.

    First-Class Functions — функции, которые мы можем передавать в другую функцию как параметр и получать их как возвращаемое значение

    Не совсем понятно о чем речь. Что мешает передавать указатель на процедуру (например, использовать древний как мир механизм callback-функций)? Например, есть некий сервис многопоточной параллельной обработки данных (одно головное задание, которое делает выборку данных для обработки и раздает отобранные данные нескольким заданиям-обработчикам). Ядро обеспечивает создание и эксплуатацию конвейера (механизм обмена данными), запуск дочерних заданий-обработчиков. Задача разработчика - написать две процедуры - подготовка пакетов для обработки на стороне головного задания и обработку пакета на стороне обработчика. Эти процедуры регистрируются в качестве callback-функций в ядре и все. Дальше ядро уже делает все само - на стороне головы вызовет процедуру подготовки очередного пакета, получит пакет, выложит его на конвейер. На стороне обработчика возьмет пакет с конвейера, вызовет процедуру обработки пакета.

    Если же говорить о микросервисах вообще, то на мой взгляд это просто современный такой хайп на эту тему.

    Есть старая (годов этак с 70-х еще) добрая модель акторов. Сиречь набор модулей, каждый из которых выполняет некоторую функцию и вызывается или напрямую, или по событию.

    В *nix системах есть понятие демонов. В винде - сервисы...

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


    1. akurilov
      13.07.2023 18:42

      IPC? Вы к нам случаем не из python?


      1. SpiderEkb
        13.07.2023 18:42
        +1

        Нет, бог миловал от питона :-)

        Много лет только на С/С++. Сейчас в основном - RPG (такой специалььный язык для коммерческих вычислений на платформе IBM). Ну и на С/С++ иногда (некоторые низкоуровневые вещи).

        Мой посыл в том, что любая платформа (ОС) предоставляет широкий выбор средств межпоточного и/или межпроцессного обмена данными. На выбор - надо быстро - можно быстро (но тут придется самому рулить всеми блокировками скорее всего). Не хочется заморачиваться с блокировками - есть и такое, где всеми блокировками будет рулить сама система.

        Понятно, что тут придется немного разобраться как оно работает. Возможно, один раз написать какую-то апишку для работы под свои условия. Но нет там ничего сложного. Не бином Ньютона.


  1. mello1984
    13.07.2023 18:42
    +3

    Опять сравнение по памяти на примере hello world. Может стоит учесть, что в указанное приложение входит куча поддерживающего кода, который по сути fixed cost? В целом какая разница, сколько 'весит' jvm и спринг, если у сервиса например будет хип на 1.5 Гб, а с метаспейсом и прочим - под 2? Или время старта приложения - 3.5 секунды против 1. Да какая разница, если при старте сервис будет что-то из базы качать для инициализации - собирать внутренние кэши или ещё что.

    Понятно, что это может быть важно, но сравнивать все же лучше на боевой нагрузке. Либо если нужен супербыстрый старт - использовать другие подходы (но может и действительно не джаву). Просто везде одно и то же - пишем hello world, добавляем в него все стартеры спринга, а потом удивляемся, почему так...


    1. akurilov
      13.07.2023 18:42
      +1

      Строго говоря, микросервисы должны быть мелкими в любом отношении. 10 реплик, кушающих по 1 гб памяти и 10 реплик, кушающих по 10 мб - это совсем разные расходы на инфраструктуру


      1. SpiderEkb
        13.07.2023 18:42

        На мой взгляд сомнительно. Любой сервис должен выполнять какую-то законченную бизнес-операцию. Т.е. не просто "дай мне запись из ...", а что-то более серьезное.

        Т.е. не размазывать атомарную логику на 10 микросервисов.


  1. polar11beer
    13.07.2023 18:42
    -1

    Все сервисы, выполняющие небольшую функцию и не находящиеся под большой нагрузкой, но при этом делающие много сетевых запросов, я бы писал на Go.

    Выглядит как подходящая работа для NodeJS, плюс к этому (субъективно) разработчиков под Ноду на рынке больше.


  1. vvdev
    13.07.2023 18:42

    А не смотрели профайлером, что именно отжирает СТОЛЬКО памяти в жава-приложении? — детализацию бы глянуть.


    Мне это чисто из любопытства, я из.нета, просто поразило НАСКОЛЬКО джава-вариант плох.


    1. headliner1985
      13.07.2023 18:42
      +1

      Spring boot, просто похоже его не оптимизировали и даже не пытались. Просто захотелось Котлин и go. Если честно я смотрел конференцию онлайн и так и не понял зачем переходить с java на kotlin или go. Просто если своевременно обновлять версии java и spring то всё будет в шоколаде.


      1. vvdev
        13.07.2023 18:42

        А что там в этом spring boot?
        Web server, насколько я понял? Ещё что-то "крупное"?


  1. m0rtis
    13.07.2023 18:42

    В Райффайзенбанке преимущественно на DotNET пишут. Почему не сравниваете с ним?

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