Всем привет. На связи Сергей Окатов. Я руковожу отделом разработки в компании Datana, а также являюсь руководителем курсов Kotlin Backend Developer и Kotlin Developer. Basic в OTUS.
В компании я отвечаю за работу команды разработчиков. Команда небольшая - всего 6 разрабов, но за последний год с небольшим мы с нуля разработали и внедрили пять проектов. Причем это были не детские проектики, а вполне промышленные проекты, которые сейчас начинают свою работу на металлургическом заводе и интегрированы со сталеплавильными установками*. Много это или мало? Чаще всего, от запуска проекта до его внедрения проходит примерно год-два. А тут средняя скорость разработки получается примерно проект за два-три месяца.
Сразу скажу, что выдержать такой темп было нелегко и для достижения поставленной задачи применялась целая серия инструментов, архитектурных и организационных приемов. Но на чем бы я хотел остановиться в текущей заметке - это языки разработки.
Мы в команде применяем для бэкенд-разработки Kotlin и Python. Эти два языка - антиподы друг друга. От правильного распределения задач, возложенных на них, зависит ключевой момент: уложимся ли мы в сроки или контракт будет сорван.
Почему вообще два языка, а не один?
Как я уже указал выше, эти два языка - антиподы друг друга. У меня вообще не укладывается в голове как они могут конкурировать друг с другом в каких-то командах. Заточены Kotlin и Python под совершенно разные задачи.
Возьмем Python. Это очень старый язык, который заметно старше даже Java, не говоря уже о Kotlin. Сам в себе он содержит следы смены эпох, и на эти следы постоянно натыкаешься. За всю свою 31-летнюю историю он не пользовался особой популярностью, решая лишь какие-то нишевые задачи. Взрывной рост его популярности случился лишь в последние лет 10 и связан был с прорывом в машинном обучении. Благодаря этому росту Python прорвался во многие смежные экосистемы, например, обработка видео.
Kotlin, напротив - возник лишь 10 лет назад, но гигантскими темпами набирает популярность. В себя он вобрал все лучшие практики опробованные на данный момент, активно поглощает в себя экосистему Java за счет интероперабельности с JVM, а также начал это делать с экосистемами JavaScript и C/C++ за счет интероперабельности с ними в Kotlin Multiplatform. Да, Kotlin явно демонстрирует амбиции в выдавливании Python из темы машинного обучения, но пока говорить о каких-то успехах в этом направлении очень рано.
При выборе задач для Питона и Котлина я выделил три ключевые особенности, которые в итоге и определили архитектуру разработанных систем.
Типизация. Python характеризуется динамической типизацией, т.е., создав переменную x, ей в любой момент можно присвоить хоть целое, хоть строку, хоть любой объект. Kotlin же весь построен на парадигме статической типизации. Т.е., объявив переменную x с типом String, мы уже не сможем ей присвоить ничего кроме строки.
Время старта. Не смотря на то, что Python при старте выполняет компиляцию, программы на нем стартуют гораздо быстрее, чем JVM.
Экосистема. Kotlin, конечно - это очень молодой язык, но он вырос на базе экосистемы JVM. А JVM и Python - это довольно старые и развитые экосистемы. Большую часть инструментов поддерживают обе. Тем не менее, всегда существуют нюансы. В некоторых проектах больше поддерживается Python и меньше JVM, в других - наоборот. Поэтому, каждый раз приходится взвешивать все “за” и “против”, делая выбор в пользу того или иного языка на каждом новом микросервисе.
Вот и разберем подробнее как эти три ключевые особенности влияют на выбор языка для конкретного микросервиса. Оговорюсь сразу, помимо указанных трех особенностей для каких-то проектов могут быть очень актуальными и другие, например, скорость работы, потребляемые ресурсы и пр. В нашем проекте они не сыграли заметной роли.
Типизация
Возьмем простую программу:
Этот код отлично работает вплоть до последней 9-й строки. На ней он отдает вот такую ошибку:
Понятно, что все подобные ошибки легко решаются и исправляются. Проблема в другом: пока мы не добавим print_hi(12) и не запустим соответствующий участок кода, мы не узнаем, что здесь есть ошибка. Предусматривать подобные расклады приходится самому разработчику и никакой помощи от языка здесь ожидать не приходится.
Да, в 3-й версии Питона появились средства явного указания типов, т.е. авторы языка сделали большой шаг в сторону статической типизации, но на текущий момент эти новые фичи слабо поддерживаются как сообществом, так и интерпретатор довольно лоялен к нарушению типизации (по сравнению со строгими языками).
Вторая проблема динамической типизации - это подсказки IDE. Если явно не задан тип объекта, то и отследить его текущий тип очень часто невозможно. Раз тип неизвестен, то и подсказать доступные методы для переменной тоже невозможно. В таком случае, IDE уже не работает как помощник разработчика, а просто служит текстовым редактором.
Далеко не всегда динамическая типизация создает какие-то проблемы. Если программа небольшая и разработчик способен отследить все изменения типов, то работать с динамическими типами гораздо проще, чем явно описывать все классы и типы.
Но, если ваша программа разрастается до больших размеров, то тут уже IDE часто сдается, перестает помогать и уходит много времени на выяснение типов и доступных методов. Также возникают постоянные проблемы с типами, которые никак не отследить и можно выявить только с помощью модульного тестирования. Модульное тестирование - это нужная и полезная вещь, но динамическая типизация предъявляет повышенные требования к нему и требует большего покрытия тестами кода. И чем больше проект, тем будут больше издержки на отладку и поддержку.
Ну и еще одно обстоятельство характеризует различия в статической и динамической типизации. Если у вас функция принимает аргумент только одного типа, то и обработка этого типа очень простая. А вот если ваша функция начинает принимать разные типы, то под каждый тип необходимо будет описать собственную обработку и предусмотреть гораздо больше вариантов.
Для оценки могу привести грубый пример. Предположим, у нас код построен на динамической типизации. В программе используется целое, строка и два класса. Также возможен еще и None (Null). Если мы сделаем функцию с 10 аргументами, то в самом общем (возможно параноидальном) случае нам нужно будет учитывать влияние аргументов друг на друга и мы должны будем предусмотреть количество вариантов: 10 аргументов, по 5 вариантов каждый, итого 5 в 10-й степени = 9 765 625. Очевидно, что никто не будет делать такое количество проверок, а непредусмотренные варианты окажутся просто потенциальными багами.
Что предлагает Kotlin - статическую типизацию. При создании переменной или аргумента мы явно фиксируем ее тип. Неявные преобразования запрещены, поэтому мы всегда знаем какой набор функций над этой переменной можно выполнить и какой набор методов у нее можно вызвать. Т.е. в любой самой большой и сложной программе мы всегда знаем что можно сделать с любой переменной, а IDE всегда готова дать подсказки по доступным операциям. Более того, если мы ошибемся и попытаемся вместо строки передать целое, то не то что компилятор, даже IDE подсветит нам ошибку.
При статической типизации приходится каждый раз инвестировать не малые ресурсы в создание структуры классов, но в качестве вознаграждения будет высокая скорость работы с кодом за счет подсказок, сокращения возможных ручных проверок и снижения количества скрытых ошибок, выявляемых только в рантайме.
Отдельно хочется упомянуть Java. Она тоже обладает статической типизацией, но мы выбрали Kotlin. Все дело в том, что, если забыть про большое количество сахара, Kotlin предоставляет еще и большее количество таких вот проверок. Например, нет в Java настолько развитой работы с Null. Также Kotlin более строг при преобразованиях перечислимых (Enum), требуя явно рассматривать все возможные варианты, либо использовать else. Более чувствителен Kotlin к мутабельности переменных и пр. Да, все эти строгости и проверки требуют повышенного внимания и квалификации разработчика, но позволяют прямо во время написания кода выявлять огромное количество вариантов, ветвления логики, что избавляет нас от большей части ошибок еще даже до компиляции, не говоря уж о выкате в прод.
Итого, динамическая типизация более выигрышна для небольших проектов, а статическая больше подходит для крупных, качественных, корпоративных проектов, нагруженных логикой.
Время старта
Далеко не всегда время старта является критическим. Чаще всего, микросервис один раз запускается и успешно функционирует месяцами. Но все-таки иногда время старта важно. Например, когда нагрузка на систему неожиданно возросла и планировщик принял решение поднять еще один под. Холодный старт в этом случае сколько займет времени? Одну секунду или минуту? Питон, очевидно, в этом плане явно лидирует.
Но и в мире JVM можно кое-что сделать для повышения времени старта. Например, очень сильно может помочь отказ от Spring Framework в пользу более современных разработок. Например, в Ktor не используются прокси-классы и вообще рефлексия. Это позволяет стартовать приложению в 5-10 раз быстрее, чем приложению на Spring.
Экосистема
Как я уже указал выше, есть масса нюансов в каждой экосистеме. Например, Java-обертки для PyTorch или Tensorflow все-таки не так развиты как их Python-версии. Да и найти инженера по машинному обучению/Data scientist-а, готового освоить Java/Kotlin не так просто. Большинству DS-ов просто удобнее работать на Питоне и вряд ли стоит нам - разработчикам системы - с этим что-то делать.
Видео-подсистему с gstreamer и opencv разрабатывать на Java/Kotlin тоже можно, но будет это заметно труднее, чем на Python.
С другой стороны, например, для интеграций есть изумительный Java-фреймворк Apache Camel, который покрывает более 90% всех потребностей в интеграциях с внешними системами. Но при этом, например, эмулятор контроллеров Siemens S7 написан на Python и ничего подобного в JVM стеке не сделать. Так что микросервисная архитектура и сочетание языков нередко - это не блажь, а суровая необходимость.
Но далеко не всегда сторонние библиотеки и фреймворки могут диктовать выбор языка. Например, в бизнес-логике, где нет особых интеграций и специфических библиотек, мы вольны выбирать любой язык, который нам удобнее.
Распределение обязанностей
В итоге, у нас сформировалось такое разделение языков по компонентам систем.
Адаптеры - Kotlin
Это микросервисы, которые отвечают за интеграцию с внешними системами. Их обязанность - принять данные, провалидировать, преобразовать во внутренние форматы системы и отправить дальше.
Фреймворк Apache Camel и статическая типизация Kotlin в данном случае обеспечивают максимальную скорость разработки с учетом довольно сложных структур данных, с которыми приходится работать.
Видеоподсистема и Машинное обучение - Python
Как ни парадоксально, ни то, ни другое практически не содержит сложной бизнес-логики. Проблему сложности некоторых типов данных мы решаем сторонними средствами, включая элементы статической типизации Python, OpenAPI и пр. Практически все микросервисы в этом классе достаточно простые.
Логические микросервисы - Kotlin
Задача логических микросервисов - обеспечить выполнение бизнес-логики. Интеграции в этом случае совершенно не важны, сторонние библиотеки практически не используются. Но что есть в этих компонентах - это большие и сложные структуры данных, большое количество бизнес-операций, обработка условий, машины состояний и многие другие элементы бизнес-логики.
И вот в этих условиях лучшим образом показывает себя именно Kotlin. Да, формирование статических типов и классов требует значительного времени. Но формирование этих структур - это и есть та самая бизнес-логика, которую мы должны разработать. Формируя статические типы, мы отсекаем тысячи вариантов других возможных вариантов, которые в Python нам бы пришлось проверять вручную и с помощью модульных тестов. И когда структура классов сформирована, Kotlin даже на этапе набора текста программы обеспечивает подсказки IDE. Затем на этапе компиляции автоматически выявляются ошибки и несогласованности. Например, если у нас изменится API, то мы обнаружим ошибки не в рантайме на продуктовой площадке, а еще на этапе компиляции программ.
И именно благодаря Kotlin большая часть ошибок, которые мы исправляли в ходе тестирования и внедрения, носила именно бизнесовый характер, а не системный типа фатального падения программы из-за Null Pointer Exception. Честно говоря, за весь период работы NPE мы практически не видели.
Вспомогательные сервисные скрипты - Python
В любом проекте нередко возникают различные сервисные задачи, будь то переименование тысячи файлов или разовая обработка каких-то данных. Эти скрипты никогда не идут на продуктовые сервера, но они очень помогают делать рутинную работу. В подобных скриптах нет нужды предусматривать тысячи логических вариантов, они компактные, а типы и переменные из-за этого наглядны. Питон для таких скриптов подходит идеально.
Итоги
JVM и Python - это две наиболее популярные экосистемы в бэкенд-разработке на сегодня. Знаю, что во многих компаниях эти два инструмента конкурируют друг с другом, что выражается в подходах “Все на Python” или “Все на Java”, но я считаю, что конкуренция здесь неуместна. Эти два языка очень разные и занимают совершенно разные рыночные ниши. Python больше подходит для прототипирования, а Kotlin - для крупной разработки со сложной и нагруженной бизнес-логикой. Сочетание преимуществ каждого из языков дает сочетание высоких темпов разработки и высокого качества кода.
Ссылки
как наши проекты внедряются на металлургическом заводе можно посмотреть на телеграм-канале Datana XP
Узнать о курсе "Kotlin Developer. Basic"
Узнать о курсе "Kotlin Backend Developer"